Skip to content

Commit 5ed4db6

Browse files
committed
feat(gateway): honor _mcpmux_context on call_tool and meta tools
Strip and validate the reserved hook argument, route that call from the exact root, and thread it through MetaToolCall so bind and search stay on the same binding. Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent f3e1103 commit 5ed4db6

5 files changed

Lines changed: 117 additions & 65 deletions

File tree

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

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,32 @@ impl McpMuxGatewayHandler {
230230
Ok((space_id, resolved.feature_set_ids))
231231
}
232232

233+
/// `call_tool` routing: a valid `_mcpmux_context.workspace_root` wins
234+
/// for this invocation only. List handlers keep using [`Self::resolve_routing`].
235+
async fn resolve_call_routing(
236+
&self,
237+
session_id: Option<&str>,
238+
client_id: &str,
239+
request_machine_id: Option<uuid::Uuid>,
240+
explicit_root: Option<&str>,
241+
) -> Result<(uuid::Uuid, Vec<String>), McpError> {
242+
let Some(root) = explicit_root else {
243+
return self
244+
.resolve_routing(session_id, client_id, request_machine_id)
245+
.await;
246+
};
247+
let resolved = self
248+
.services
249+
.authorization_service
250+
.resolve_for_workspace_root(root, Some(client_id), request_machine_id)
251+
.await
252+
.map_err(|e| McpError::internal_error(format!("Failed to resolve: {e}"), None))?;
253+
let space_id = resolved.space_id.ok_or_else(|| {
254+
McpError::internal_error("No space resolved (no default space configured)", None)
255+
})?;
256+
Ok((space_id, resolved.feature_set_ids))
257+
}
258+
233259
/// On-demand `roots/list` probe for sessions that initialized as
234260
/// roots-capable but have no roots yet — typically because the first
235261
/// `list_roots()` from `on_initialized` raced this request, or its
@@ -824,7 +850,7 @@ impl ServerHandler for McpMuxGatewayHandler {
824850

825851
async fn call_tool(
826852
&self,
827-
params: CallToolRequestParams,
853+
mut params: CallToolRequestParams,
828854
context: RequestContext<RoleServer>,
829855
) -> Result<CallToolResult, McpError> {
830856
let oauth_ctx = self
@@ -841,6 +867,23 @@ impl ServerHandler for McpMuxGatewayHandler {
841867
let session_id_owned = extract_session_id(&context.extensions);
842868
let session_id = session_id_owned.as_deref();
843869

870+
let mut arguments = params.arguments.take().unwrap_or_default();
871+
let call_ctx = super::mcpmux_context::take_mcpmux_context(
872+
&mut arguments,
873+
session_id,
874+
&self.services.session_roots,
875+
)
876+
.map_err(|e| McpError::invalid_params(e, None))?;
877+
if let Some(ctx) = &call_ctx {
878+
info!(
879+
workspace_root = %ctx.workspace_root,
880+
tool_use_id = ctx.tool_use_id.as_deref().unwrap_or(""),
881+
session_id = session_id.unwrap_or(""),
882+
source = "cursor_pre_tool_use",
883+
"call_tool exact workspace context"
884+
);
885+
}
886+
844887
// Bridge the init race on the call side too: a tools/call can land
845888
// while a roots-capable session is still PendingRoots (client
846889
// resumed and immediately invoked a tool it listed on a previous
@@ -859,10 +902,11 @@ impl ServerHandler for McpMuxGatewayHandler {
859902
// (may differ from oauth_ctx.space_id). Needed both to gate the
860903
// per-Space meta tools below and to route a normal tool call.
861904
let (space_id, feature_set_ids) = self
862-
.resolve_routing(
905+
.resolve_call_routing(
863906
session_id,
864907
&oauth_ctx.client_id,
865908
oauth_ctx.request_machine_id,
909+
call_ctx.as_ref().map(|c| c.workspace_root.as_str()),
866910
)
867911
.await?;
868912

@@ -878,19 +922,16 @@ impl ServerHandler for McpMuxGatewayHandler {
878922
// Note: client_id is the OAuth client identity (a URL for DCR-
879923
// registered clients like Claude, a UUID for others). The meta-
880924
// tool registry treats it as an opaque string identity key.
881-
let args: serde_json::Value = params
882-
.arguments
883-
.map(|a| serde_json::to_value(a).unwrap_or(serde_json::Value::Null))
884-
.unwrap_or(serde_json::Value::Null);
885925
return match self
886926
.services
887927
.meta_tool_registry
888928
.call_from_device(
889929
&params.name,
890930
&oauth_ctx.client_id,
891931
session_id,
892-
args,
932+
serde_json::Value::Object(arguments),
893933
oauth_ctx.request_machine_id,
934+
call_ctx.as_ref().map(|c| c.workspace_root.clone()),
894935
)
895936
.await
896937
{
@@ -1004,7 +1045,7 @@ impl ServerHandler for McpMuxGatewayHandler {
10041045
space_id,
10051046
&feature_set_ids,
10061047
&params.name,
1007-
serde_json::to_value(params.arguments.unwrap_or_default()).unwrap_or_default(),
1048+
serde_json::Value::Object(arguments),
10081049
)
10091050
.await
10101051
.map_err(|e| McpError::internal_error(format!("Tool call failed: {}", e), None))?;

crates/mcpmux-gateway/src/services/meta_tools/bind_workspace.rs

Lines changed: 51 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -133,60 +133,64 @@ impl MetaTool for BindCurrentWorkspaceTool {
133133
.session_id
134134
.and_then(|sid| call.ctx.session_roots.get(sid))
135135
.unwrap_or_default();
136-
// Same gate as FeatureSetResolver PendingRoots: never first-root-wins
137-
// when multiple unpinned roots are present (header pin collapses get() to 1).
138-
let root = match roots.as_slice() {
139-
[] => {
140-
// The window's folder set (X-Mcpmux-Workspace-Set) usually
141-
// survives even when the active-folder header came through
142-
// empty, so name those folders rather than making the caller
143-
// guess what to declare.
144-
let candidates = call
145-
.session_id
146-
.and_then(|sid| call.ctx.session_roots.get_candidates(sid))
147-
.unwrap_or_default();
148-
let known = if candidates.is_empty() {
149-
String::new()
150-
} else {
151-
format!(
152-
" This window has these folders open:\n{}\n",
153-
candidates
154-
.iter()
155-
.map(|c| format!(" - {c}"))
156-
.collect::<Vec<_>>()
157-
.join("\n")
158-
)
159-
};
160-
return Err(MetaToolError::InvalidArgument(format!(
161-
"caller did not report any MCP roots; cannot bind.{known} \
136+
// Exact call context (Cursor preToolUse) names the bind target even
137+
// when the shared session still reports multiple unpinned roots.
138+
let root = if let Some(explicit) = call.explicit_workspace_root.as_deref() {
139+
explicit.to_string()
140+
} else {
141+
match roots.as_slice() {
142+
[] => {
143+
// The window's folder set (X-Mcpmux-Workspace-Set) usually
144+
// survives even when the active-folder header came through
145+
// empty, so name those folders rather than making the caller
146+
// guess what to declare.
147+
let candidates = call
148+
.session_id
149+
.and_then(|sid| call.ctx.session_roots.get_candidates(sid))
150+
.unwrap_or_default();
151+
let known = if candidates.is_empty() {
152+
String::new()
153+
} else {
154+
format!(
155+
" This window has these folders open:\n{}\n",
156+
candidates
157+
.iter()
158+
.map(|c| format!(" - {c}"))
159+
.collect::<Vec<_>>()
160+
.join("\n")
161+
)
162+
};
163+
return Err(MetaToolError::InvalidArgument(format!(
164+
"caller did not report any MCP roots; cannot bind.{known} \
162165
Call mcpmux_set_workspace_root to declare the workspace this agent is \
163166
actually working in, then retry mcpmux_bind_current_workspace."
164-
)));
165-
}
166-
[single] => single.clone(),
167-
many => {
168-
info!(
169-
session_id = ?call.session_id,
170-
client_id = %call.client_id,
171-
root_count = many.len(),
172-
reported_roots = ?many,
173-
feature_set_id = %fs_id,
174-
"[meta_tools] bind_current_workspace refused — multiple unpinned roots"
175-
);
176-
let listed = many
177-
.iter()
178-
.map(|r| format!(" - {r}"))
179-
.collect::<Vec<_>>()
180-
.join("\n");
181-
return Err(MetaToolError::InvalidArgument(format!(
182-
"cannot bind: {} workspace roots reported and none is pinned, so which \
167+
)));
168+
}
169+
[single] => single.clone(),
170+
many => {
171+
info!(
172+
session_id = ?call.session_id,
173+
client_id = %call.client_id,
174+
root_count = many.len(),
175+
reported_roots = ?many,
176+
feature_set_id = %fs_id,
177+
"[meta_tools] bind_current_workspace refused — multiple unpinned roots"
178+
);
179+
let listed = many
180+
.iter()
181+
.map(|r| format!(" - {r}"))
182+
.collect::<Vec<_>>()
183+
.join("\n");
184+
return Err(MetaToolError::InvalidArgument(format!(
185+
"cannot bind: {} workspace roots reported and none is pinned, so which \
183186
folder to mutate is ambiguous. Reported roots:\n{listed}\n\
184187
Call mcpmux_set_workspace_root with exactly one of those paths (the \
185188
workspace this agent is actually working in), then retry \
186189
mcpmux_bind_current_workspace with the same feature_set_id. A correct \
187190
X-Mcpmux-Workspace header pin also collapses this.",
188-
many.len(),
189-
)));
191+
many.len(),
192+
)));
193+
}
190194
}
191195
};
192196
let normalized = normalize_workspace_root(&root);

crates/mcpmux-gateway/src/services/meta_tools/meta_tool_common.rs

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -55,15 +55,7 @@ pub(crate) fn text_result(v: Value) -> CallToolResult {
5555
/// workspace B just because both sit under the same default-Space-flagged
5656
/// row in the DB.
5757
pub(crate) async fn caller_space_id(call: &MetaToolCall<'_>) -> Result<Uuid, MetaToolError> {
58-
let resolved = call
59-
.ctx
60-
.resolver
61-
.resolve(
62-
call.session_id,
63-
Some(call.client_id),
64-
call.request_machine_id,
65-
)
66-
.await?;
58+
let resolved = caller_resolution(call).await?;
6759
if let Some(space_id) = resolved.space_id {
6860
return Ok(space_id);
6961
}
@@ -75,9 +67,18 @@ pub(crate) async fn caller_space_id(call: &MetaToolCall<'_>) -> Result<Uuid, Met
7567
}
7668

7769
/// Full resolver output for the caller — space + binding FS ids + source.
70+
/// Prefers an exact `_mcpmux_context` root when the handler attached one.
7871
pub(crate) async fn caller_resolution(
7972
call: &MetaToolCall<'_>,
8073
) -> Result<ResolvedFeatureSet, MetaToolError> {
74+
if let Some(root) = call.explicit_workspace_root.as_deref() {
75+
return call
76+
.ctx
77+
.resolver
78+
.resolve_for_workspace_root(root, Some(call.client_id), call.request_machine_id)
79+
.await
80+
.map_err(|e| MetaToolError::Internal(e.to_string()));
81+
}
8182
call.ctx
8283
.resolver
8384
.resolve(

crates/mcpmux-gateway/src/services/meta_tools/registry.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,9 @@ pub struct MetaToolCall<'a> {
109109
/// header, when the transport carries one. `None` for local/stdio
110110
/// callers and for tests that don't exercise per-device routing.
111111
pub request_machine_id: Option<Uuid>,
112+
/// Exact workspace root from `_mcpmux_context` on this call, if any.
113+
/// Never written into session/window pin maps.
114+
pub explicit_workspace_root: Option<String>,
112115
}
113116

114117
/// Errors a meta tool can surface that map cleanly to `CallToolResult::error`.
@@ -285,7 +288,7 @@ impl MetaToolRegistry {
285288
session_id: Option<&str>,
286289
args: Value,
287290
) -> Result<CallToolResult, MetaToolError> {
288-
self.call_from_device(name, client_id, session_id, args, None)
291+
self.call_from_device(name, client_id, session_id, args, None, None)
289292
.await
290293
}
291294

@@ -300,6 +303,7 @@ impl MetaToolRegistry {
300303
session_id: Option<&str>,
301304
args: Value,
302305
request_machine_id: Option<Uuid>,
306+
explicit_workspace_root: Option<String>,
303307
) -> Result<CallToolResult, MetaToolError> {
304308
let tool = self
305309
.tools
@@ -314,6 +318,7 @@ impl MetaToolRegistry {
314318
ctx: &self.ctx,
315319
audit_decision: audit_decision.clone(),
316320
request_machine_id,
321+
explicit_workspace_root,
317322
};
318323
let result = tool.call(call).await;
319324

tests/rust/tests/integration/meta_tools.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1921,6 +1921,7 @@ async fn bind_current_workspace_header_targets_caller_machine_not_gateway_local(
19211921
Some(&f.session_id),
19221922
json!({ "feature_set_id": f.fs_android_id.to_string() }),
19231923
Some(rohan_id),
1924+
None,
19241925
)
19251926
.await
19261927
.unwrap();

0 commit comments

Comments
 (0)