Skip to content

Commit f3e1103

Browse files
committed
feat(gateway): resolve FeatureSets from an explicit per-call workspace root
Add a non-mutating resolve_for_workspace_root path so Cursor preToolUse context can pick a binding without writing session or window pins. Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent 6ba2c3d commit f3e1103

4 files changed

Lines changed: 155 additions & 0 deletions

File tree

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
//! Per-call `_mcpmux_context` attached by Cursor's `preToolUse` hook.
2+
//!
3+
//! The reserved argument is transport metadata: parse it, validate the root,
4+
//! then strip it before meta-tool parsing or backend forwarding.
5+
6+
use rmcp::model::JsonObject;
7+
use serde_json::Value;
8+
9+
use crate::services::SessionRootsRegistry;
10+
use mcpmux_core::normalize_workspace_root;
11+
12+
/// Reserved tool-argument key injected by the managed Cursor hook.
13+
pub const MCPMUX_CONTEXT_KEY: &str = "_mcpmux_context";
14+
15+
/// Exact workspace identity carried on one `tools/call`.
16+
#[derive(Debug, Clone)]
17+
pub struct ExtractedCallContext {
18+
pub workspace_root: String,
19+
pub tool_use_id: Option<String>,
20+
}
21+
22+
/// Remove `_mcpmux_context` from `arguments` and validate it when present.
23+
///
24+
/// `Ok(None)` means the call has no hook context and should use the session
25+
/// ladder. Malformed objects and candidate-set mismatches are errors.
26+
pub fn take_mcpmux_context(
27+
arguments: &mut JsonObject,
28+
session_id: Option<&str>,
29+
session_roots: &SessionRootsRegistry,
30+
) -> Result<Option<ExtractedCallContext>, String> {
31+
let Some(raw) = arguments.remove(MCPMUX_CONTEXT_KEY) else {
32+
return Ok(None);
33+
};
34+
35+
let obj = match raw {
36+
Value::Object(obj) => obj,
37+
_ => {
38+
return Err("invalid _mcpmux_context: expected an object".into());
39+
}
40+
};
41+
42+
let raw_root = obj
43+
.get("workspace_root")
44+
.and_then(Value::as_str)
45+
.map(str::trim)
46+
.filter(|s| !s.is_empty())
47+
.ok_or_else(|| {
48+
"invalid _mcpmux_context: workspace_root must be a non-empty string".to_string()
49+
})?;
50+
51+
let workspace_root = normalize_workspace_root(raw_root);
52+
if workspace_root.is_empty() {
53+
return Err("invalid _mcpmux_context: workspace_root is empty after normalize".into());
54+
}
55+
56+
if let Some(sid) = session_id {
57+
if !session_roots.is_candidate(sid, &workspace_root) {
58+
return Err(
59+
"invalid _mcpmux_context: workspace_root is not in this session's candidate set"
60+
.into(),
61+
);
62+
}
63+
}
64+
65+
let tool_use_id = obj
66+
.get("tool_use_id")
67+
.and_then(Value::as_str)
68+
.map(str::trim)
69+
.filter(|s| !s.is_empty())
70+
.map(str::to_string);
71+
72+
Ok(Some(ExtractedCallContext {
73+
workspace_root,
74+
tool_use_id,
75+
}))
76+
}

crates/mcpmux-gateway/src/mcp/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
1212
pub mod context;
1313
pub mod handler;
14+
pub mod mcpmux_context;
1415
pub mod oauth_middleware;
1516

1617
pub use handler::McpMuxGatewayHandler;

crates/mcpmux-gateway/src/services/authorization.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,18 @@ impl AuthorizationService {
5454
.await
5555
}
5656

57+
/// Per-call explicit-root resolution. Does not read or write session pins.
58+
pub async fn resolve_for_workspace_root(
59+
&self,
60+
workspace_root: &str,
61+
client_id: Option<&str>,
62+
request_machine_id: Option<Uuid>,
63+
) -> Result<ResolvedFeatureSet> {
64+
self.resolver
65+
.resolve_for_workspace_root(workspace_root, client_id, request_machine_id)
66+
.await
67+
}
68+
5769
/// Does this session/client resolve to any FeatureSet?
5870
pub async fn has_access(
5971
&self,

crates/mcpmux-gateway/src/services/feature_set_resolver.rs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -795,4 +795,70 @@ impl FeatureSetResolverService {
795795
);
796796
Ok(self.unbound(deny_space_id))
797797
}
798+
799+
/// Resolve from an explicit per-call workspace root without reading or
800+
/// writing [`SessionRootsRegistry`]. Used when Cursor's `preToolUse` hook
801+
/// attaches `_mcpmux_context` to this exact `tools/call`.
802+
pub async fn resolve_for_workspace_root(
803+
&self,
804+
workspace_root: &str,
805+
client_id: Option<&str>,
806+
request_machine_id: Option<Uuid>,
807+
) -> Result<ResolvedFeatureSet> {
808+
let default_space_id = match self.space_repo.get_default().await? {
809+
Some(s) => s.id,
810+
None => {
811+
warn!("[FeatureSetResolver] no default space — deny");
812+
return Ok(ResolvedFeatureSet {
813+
feature_set_ids: vec![],
814+
space_id: None,
815+
source: ResolutionSource::Deny,
816+
});
817+
}
818+
};
819+
let space_lock = match client_id {
820+
Some(cid) => self.client_repo.get_locked_space(cid).await?,
821+
None => None,
822+
};
823+
let deny_space_id = Self::unbound_space_id(space_lock, default_space_id);
824+
let reported_roots = [workspace_root.to_string()];
825+
826+
if let Some(binding) = self
827+
.find_binding_for_roots(&reported_roots, client_id, request_machine_id)
828+
.await?
829+
{
830+
if Self::binding_matches_space_lock(&binding, space_lock) {
831+
debug!(
832+
workspace_root = %binding.workspace_root,
833+
space_id = %binding.space_id,
834+
feature_sets = ?binding.feature_set_ids,
835+
"[FeatureSetResolver] resolved via explicit workspace_root",
836+
);
837+
return Ok(ResolvedFeatureSet {
838+
feature_set_ids: binding.feature_set_ids,
839+
space_id: Some(binding.space_id),
840+
source: ResolutionSource::WorkspaceBinding,
841+
});
842+
}
843+
debug!(
844+
binding_space = %binding.space_id,
845+
?space_lock,
846+
"[FeatureSetResolver] explicit-root binding outside locked Space — ignored",
847+
);
848+
}
849+
850+
let target_space = if space_lock.is_some() {
851+
deny_space_id
852+
} else {
853+
self.space_for_roots(&reported_roots)
854+
.await?
855+
.unwrap_or(default_space_id)
856+
};
857+
debug!(
858+
%target_space,
859+
workspace_root,
860+
"[FeatureSetResolver] explicit workspace_root but no binding matched — Unbound",
861+
);
862+
Ok(self.unbound(target_space))
863+
}
798864
}

0 commit comments

Comments
 (0)