Skip to content

Commit c2f02f6

Browse files
committed
fix(gateway): route to resolved space + accept URL client_ids in meta tools
Two related bugs surfaced when Claude Code (DCR-registered) connected to a Space whose workspace root binding pointed at a different Space. 1. List / call handlers ignored the resolver `list_tools`, `list_prompts`, `list_resources`, `get_prompt`, `read_resource`, and `call_tool` all read grants via the resolver but then queried features from `oauth_ctx.space_id` — which is the OAuth- bound space, not the WorkspaceBinding's target. Result: when a binding pointed elsewhere, every list returned 0 features (the FS lives in the resolved space; the OAuth-context space has no FS by that id). Fix: new `McpMuxGatewayHandler::resolve_routing(session_id)` helper that returns `(Uuid /* resolved space */, Vec<String> /* fs ids */)`. All six handlers route through it and use the resolved space everywhere downstream. The OAuth-context space_id is now only used by `oauth_ctx` itself for upstream auth — not for routing. 2. Meta-tool dispatcher rejected DCR client ids `call_tool`'s meta-tool fast path tried to parse `oauth_ctx.client_id` as a `Uuid`, then handed the UUID to `MetaToolRegistry::call`. For Claude Code (and any other DCR-registered client) `client_id` is the `client_metadata` URL — not a UUID. Result: every `mcpmux_*` tool call failed with `"bad client_id"` before the tool could run. Fix: meta-tool client_id is now treated as opaque `&str` end-to-end: - `MetaToolCall.client_id: &'a Uuid` → `&'a str` - `MetaToolRegistry::call(_, &Uuid, _, _)` → `&str` - `ApprovalBroker` switches its DashMap keys + every public method to `String` / `&str`. Always-allow grants and rate-limit buckets still use the same opaque identity, just typed correctly. - `respond_to_meta_tool_approval` and `revoke_meta_tool_grant` Tauri commands drop the `Uuid::parse_str` step. Includes a regression test (`url_client_id_works`) using `https://claude.ai/oauth/claude-code-client-metadata` as the client id to lock the URL form in. Signed-off-by: Mohammod Al Amin Ashik <maa.ashik00@gmail.com>
1 parent 5846a98 commit c2f02f6

6 files changed

Lines changed: 146 additions & 146 deletions

File tree

apps/desktop/src-tauri/src/commands/meta_tool_approval.rs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ use serde::Serialize;
1414
use tauri::State;
1515
use tokio::sync::RwLock;
1616
use tracing::{info, warn};
17-
use uuid::Uuid;
1817

1918
use crate::commands::gateway::GatewayAppState;
2019

@@ -44,7 +43,6 @@ pub async fn respond_to_meta_tool_approval(
4443
"deny" => ApprovalDecision::Deny,
4544
other => return Err(format!("unknown decision: {other}")),
4645
};
47-
let client_uuid = Uuid::parse_str(&client_id).map_err(|e| format!("bad client_id: {e}"))?;
4846

4947
let broker = {
5048
let state = gateway_state.read().await;
@@ -55,7 +53,10 @@ pub async fn respond_to_meta_tool_approval(
5553
return Ok(false);
5654
};
5755

58-
let resolved = broker.respond(&request_id, client_uuid, &tool_name, decision);
56+
// client_id is opaque (UUID for preset clients, OAuth client_metadata
57+
// URL for DCR clients like Claude Code). The broker treats it as a
58+
// hash key only.
59+
let resolved = broker.respond(&request_id, &client_id, &tool_name, decision);
5960
info!(
6061
%request_id,
6162
%client_id,
@@ -86,7 +87,7 @@ pub async fn list_meta_tool_grants(
8687
.list_always_allow()
8788
.into_iter()
8889
.map(|(client_id, tool_name)| MetaToolGrantEntry {
89-
client_id: client_id.to_string(),
90+
client_id,
9091
tool_name,
9192
})
9293
.collect())
@@ -99,13 +100,12 @@ pub async fn revoke_meta_tool_grant(
99100
tool_name: String,
100101
gateway_state: State<'_, Arc<RwLock<GatewayAppState>>>,
101102
) -> Result<bool, String> {
102-
let client_uuid = Uuid::parse_str(&client_id).map_err(|e| format!("bad client_id: {e}"))?;
103103
let broker = {
104104
let state = gateway_state.read().await;
105105
state.approval_broker.clone()
106106
};
107107
let Some(broker) = broker else {
108108
return Ok(false);
109109
};
110-
Ok(broker.revoke_always_allow(client_uuid, &tool_name))
110+
Ok(broker.revoke_always_allow(&client_id, &tool_name))
111111
}

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

Lines changed: 65 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,34 @@ impl McpMuxGatewayHandler {
157157
}
158158
}
159159

160+
/// Resolve the (Space, FeatureSet ids) the gateway should route a
161+
/// session through. The OAuth-context space is *not* used for routing
162+
/// — when a `WorkspaceBinding` matches, the binding's target space is
163+
/// authoritative and may differ from the OAuth-bound space (this is
164+
/// the whole point of workspace-root routing). Pass the returned
165+
/// `space_id` to every `feature_service.get_*_for_grants` /
166+
/// `routing_service.call_tool` invocation; otherwise the lookup queries
167+
/// the wrong space and returns 0 matches.
168+
async fn resolve_routing(
169+
&self,
170+
session_id: Option<&str>,
171+
) -> Result<(uuid::Uuid, Vec<String>), McpError> {
172+
let resolved = self
173+
.services
174+
.authorization_service
175+
.resolve(session_id)
176+
.await
177+
.map_err(|e| McpError::internal_error(format!("Failed to resolve: {e}"), None))?;
178+
let space_id = resolved.space_id.ok_or_else(|| {
179+
McpError::internal_error("No space resolved (no default space configured)", None)
180+
})?;
181+
let feature_set_ids = resolved
182+
.feature_set_id
183+
.map(|fs| vec![fs])
184+
.unwrap_or_default();
185+
Ok((space_id, feature_set_ids))
186+
}
187+
160188
/// Build InitializeResult with negotiated protocol version
161189
fn build_initialize_result(&self, protocol_version: ProtocolVersion) -> InitializeResult {
162190
let info = self.get_info();
@@ -416,28 +444,19 @@ impl ServerHandler for McpMuxGatewayHandler {
416444
_params: Option<PaginatedRequestParams>,
417445
context: RequestContext<RoleServer>,
418446
) -> Result<ListToolsResult, McpError> {
419-
let oauth_ctx = self
420-
.get_oauth_context(&context.extensions)
421-
.map_err(|e| McpError::invalid_params(e.to_string(), None))?;
422-
423-
// Get client's grants
424-
let feature_set_ids = self
425-
.services
426-
.authorization_service
427-
.get_client_grants(
428-
&oauth_ctx.client_id,
429-
&oauth_ctx.space_id,
430-
extract_session_id(&context.extensions).as_deref(),
431-
)
432-
.await
433-
.map_err(|e| McpError::internal_error(format!("Failed to get grants: {}", e), None))?;
434-
435-
// Get tools via FeatureService
447+
// Resolve routing once: the resolver returns the authoritative
448+
// (Space, FS) for this session — this may differ from oauth_ctx
449+
// when a WorkspaceBinding redirects to another space.
450+
let (space_id, feature_set_ids) = self
451+
.resolve_routing(extract_session_id(&context.extensions).as_deref())
452+
.await?;
453+
454+
// Get tools via FeatureService — using the *resolved* space.
436455
let tools = self
437456
.services
438457
.pool_services
439458
.feature_service
440-
.get_tools_for_grants(&oauth_ctx.space_id.to_string(), &feature_set_ids)
459+
.get_tools_for_grants(&space_id.to_string(), &feature_set_ids)
441460
.await
442461
.map_err(|e| McpError::internal_error(format!("Failed to get tools: {}", e), None))?;
443462

@@ -500,38 +519,35 @@ impl ServerHandler for McpMuxGatewayHandler {
500519
&& self.services.meta_tool_registry.contains(&params.name)
501520
&& self.services.meta_tool_registry.is_enabled().await
502521
{
503-
let client_uuid = uuid::Uuid::parse_str(&oauth_ctx.client_id)
504-
.map_err(|e| McpError::invalid_params(format!("bad client_id: {e}"), None))?;
522+
// Note: client_id is the OAuth client identity (a URL for DCR-
523+
// registered clients like Claude, a UUID for others). The meta-
524+
// tool registry treats it as an opaque string identity key.
505525
let args: serde_json::Value = params
506526
.arguments
507527
.map(|a| serde_json::to_value(a).unwrap_or(serde_json::Value::Null))
508528
.unwrap_or(serde_json::Value::Null);
509529
return match self
510530
.services
511531
.meta_tool_registry
512-
.call(&params.name, &client_uuid, session_id, args)
532+
.call(&params.name, &oauth_ctx.client_id, session_id, args)
513533
.await
514534
{
515535
Ok(result) => Ok(result),
516536
Err(e) => Ok(e.into_call_tool_result()),
517537
};
518538
}
519539

520-
// Get client's feature set grants for authorization
521-
let feature_set_ids = self
522-
.services
523-
.authorization_service
524-
.get_client_grants(&oauth_ctx.client_id, &oauth_ctx.space_id, session_id)
525-
.await
526-
.map_err(|e| McpError::internal_error(format!("Failed to get grants: {}", e), None))?;
540+
// Resolve routing — the binding's target space is authoritative,
541+
// which may differ from oauth_ctx.space_id.
542+
let (space_id, feature_set_ids) = self.resolve_routing(session_id).await?;
527543

528544
// Call tool via routing service (handles auth and routing)
529545
let tool_result = self
530546
.services
531547
.pool_services
532548
.routing_service
533549
.call_tool(
534-
oauth_ctx.space_id,
550+
space_id,
535551
&feature_set_ids,
536552
&params.name,
537553
serde_json::to_value(params.arguments.unwrap_or_default()).unwrap_or_default(),
@@ -605,26 +621,15 @@ impl ServerHandler for McpMuxGatewayHandler {
605621
_params: Option<PaginatedRequestParams>,
606622
context: RequestContext<RoleServer>,
607623
) -> Result<ListPromptsResult, McpError> {
608-
let oauth_ctx = self
609-
.get_oauth_context(&context.extensions)
610-
.map_err(|e| McpError::invalid_params(e.to_string(), None))?;
611-
612-
let feature_set_ids = self
613-
.services
614-
.authorization_service
615-
.get_client_grants(
616-
&oauth_ctx.client_id,
617-
&oauth_ctx.space_id,
618-
extract_session_id(&context.extensions).as_deref(),
619-
)
620-
.await
621-
.map_err(|e| McpError::internal_error(format!("Failed to get grants: {}", e), None))?;
624+
let (space_id, feature_set_ids) = self
625+
.resolve_routing(extract_session_id(&context.extensions).as_deref())
626+
.await?;
622627

623628
let prompts = self
624629
.services
625630
.pool_services
626631
.feature_service
627-
.get_prompts_for_grants(&oauth_ctx.space_id.to_string(), &feature_set_ids)
632+
.get_prompts_for_grants(&space_id.to_string(), &feature_set_ids)
628633
.await
629634
.map_err(|e| McpError::internal_error(format!("Failed to get prompts: {}", e), None))?;
630635

@@ -657,35 +662,23 @@ impl ServerHandler for McpMuxGatewayHandler {
657662
params: GetPromptRequestParams,
658663
context: RequestContext<RoleServer>,
659664
) -> Result<GetPromptResult, McpError> {
660-
let oauth_ctx = self
661-
.get_oauth_context(&context.extensions)
662-
.map_err(|e| McpError::invalid_params(e.to_string(), None))?;
665+
let (space_id, feature_set_ids) = self
666+
.resolve_routing(extract_session_id(&context.extensions).as_deref())
667+
.await?;
663668

664669
let (server_id, prompt_name) = self
665670
.services
666671
.pool_services
667672
.feature_service
668-
.parse_qualified_prompt_name(&oauth_ctx.space_id.to_string(), &params.name)
673+
.parse_qualified_prompt_name(&space_id.to_string(), &params.name)
669674
.await
670675
.map_err(|e| McpError::invalid_params(format!("Invalid prompt name: {}", e), None))?;
671676

672-
// Verify authorization
673-
let feature_set_ids = self
674-
.services
675-
.authorization_service
676-
.get_client_grants(
677-
&oauth_ctx.client_id,
678-
&oauth_ctx.space_id,
679-
extract_session_id(&context.extensions).as_deref(),
680-
)
681-
.await
682-
.map_err(|e| McpError::internal_error(format!("Failed to get grants: {}", e), None))?;
683-
684677
let authorized_prompts = self
685678
.services
686679
.pool_services
687680
.feature_service
688-
.get_prompts_for_grants(&oauth_ctx.space_id.to_string(), &feature_set_ids)
681+
.get_prompts_for_grants(&space_id.to_string(), &feature_set_ids)
689682
.await
690683
.map_err(|e| {
691684
McpError::internal_error(format!("Failed to verify authorization: {}", e), None)
@@ -706,12 +699,7 @@ impl ServerHandler for McpMuxGatewayHandler {
706699
.services
707700
.pool_services
708701
.pool_service
709-
.get_prompt(
710-
oauth_ctx.space_id,
711-
&server_id,
712-
&prompt_name,
713-
params.arguments,
714-
)
702+
.get_prompt(space_id, &server_id, &prompt_name, params.arguments)
715703
.await
716704
.map_err(|e| McpError::internal_error(format!("Get prompt failed: {}", e), None))?;
717705

@@ -728,26 +716,15 @@ impl ServerHandler for McpMuxGatewayHandler {
728716
_params: Option<PaginatedRequestParams>,
729717
context: RequestContext<RoleServer>,
730718
) -> Result<ListResourcesResult, McpError> {
731-
let oauth_ctx = self
732-
.get_oauth_context(&context.extensions)
733-
.map_err(|e| McpError::invalid_params(e.to_string(), None))?;
734-
735-
let feature_set_ids = self
736-
.services
737-
.authorization_service
738-
.get_client_grants(
739-
&oauth_ctx.client_id,
740-
&oauth_ctx.space_id,
741-
extract_session_id(&context.extensions).as_deref(),
742-
)
743-
.await
744-
.map_err(|e| McpError::internal_error(format!("Failed to get grants: {}", e), None))?;
719+
let (space_id, feature_set_ids) = self
720+
.resolve_routing(extract_session_id(&context.extensions).as_deref())
721+
.await?;
745722

746723
let resources = self
747724
.services
748725
.pool_services
749726
.feature_service
750-
.get_resources_for_grants(&oauth_ctx.space_id.to_string(), &feature_set_ids)
727+
.get_resources_for_grants(&space_id.to_string(), &feature_set_ids)
751728
.await
752729
.map_err(|e| {
753730
McpError::internal_error(format!("Failed to get resources: {}", e), None)
@@ -778,15 +755,15 @@ impl ServerHandler for McpMuxGatewayHandler {
778755
params: ReadResourceRequestParams,
779756
context: RequestContext<RoleServer>,
780757
) -> Result<ReadResourceResult, McpError> {
781-
let oauth_ctx = self
782-
.get_oauth_context(&context.extensions)
783-
.map_err(|e| McpError::invalid_params(e.to_string(), None))?;
758+
let (space_id, feature_set_ids) = self
759+
.resolve_routing(extract_session_id(&context.extensions).as_deref())
760+
.await?;
784761

785762
let server_id = self
786763
.services
787764
.pool_services
788765
.feature_service
789-
.find_server_for_resource(&oauth_ctx.space_id.to_string(), &params.uri)
766+
.find_server_for_resource(&space_id.to_string(), &params.uri)
790767
.await
791768
.map_err(|e| {
792769
McpError::internal_error(format!("Failed to resolve resource: {}", e), None)
@@ -795,23 +772,11 @@ impl ServerHandler for McpMuxGatewayHandler {
795772
McpError::invalid_params(format!("Resource '{}' not found", params.uri), None)
796773
})?;
797774

798-
// Verify authorization
799-
let feature_set_ids = self
800-
.services
801-
.authorization_service
802-
.get_client_grants(
803-
&oauth_ctx.client_id,
804-
&oauth_ctx.space_id,
805-
extract_session_id(&context.extensions).as_deref(),
806-
)
807-
.await
808-
.map_err(|e| McpError::internal_error(format!("Failed to get grants: {}", e), None))?;
809-
810775
let authorized_resources = self
811776
.services
812777
.pool_services
813778
.feature_service
814-
.get_resources_for_grants(&oauth_ctx.space_id.to_string(), &feature_set_ids)
779+
.get_resources_for_grants(&space_id.to_string(), &feature_set_ids)
815780
.await
816781
.map_err(|e| {
817782
McpError::internal_error(format!("Failed to verify authorization: {}", e), None)
@@ -832,7 +797,7 @@ impl ServerHandler for McpMuxGatewayHandler {
832797
.services
833798
.pool_services
834799
.pool_service
835-
.read_resource(oauth_ctx.space_id, &server_id, &params.uri)
800+
.read_resource(space_id, &server_id, &params.uri)
836801
.await
837802
.map_err(|e| McpError::internal_error(format!("Read resource failed: {}", e), None))?;
838803

0 commit comments

Comments
 (0)