From 1f8d206c52187c76b7e3d941b71dc6a98f62f110 Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Mon, 20 Apr 2026 17:50:54 +0800 Subject: [PATCH 01/24] chore: upgrade rmcp to v1.5 rmcp went from 0.17.0 to 1.x with a hard cutover: most of its public model and transport types picked up `#[non_exhaustive]`, `OAuthTokenResponse` switched from `EmptyExtraTokenFields` to `VendorExtraTokenFields`, and `StreamableHttpServerConfig` gained a required `allowed_hosts` field. This commit migrates every call site in the gateway, MCP adapter, credential store, and streamable-HTTP tests onto the new constructors (`Implementation::new`, `ClientInfo::new`, `ServerInfo::new`, `InitializeResult::new`, `CallToolRequestParams::new`, `ReadResourceResult::new`, `GetPromptRequestParams::new`, `OAuthClientConfig::new`, `StoredCredentials::new`, `CallToolResult::success` / `::error`, `AuthorizationSession::for_scope_upgrade`, `StreamableHttpServerConfig::default()`), bumps the `tests/rust` crate to the same rmcp 1.5 so only one version is linked into the workspace, and resolves a handful of latent clippy warnings (`op_ref` in keychain_dpapi test, unused `use super::*` in shell_env on Windows) that became reachable via `--all-targets`. Signed-off-by: Mohammod Al Amin Ashik --- Cargo.lock | 8 +- Cargo.toml | 2 +- crates/mcpmux-gateway/src/mcp/handler.rs | 71 +++++------ .../src/pool/credential_store.rs | 65 +++++----- crates/mcpmux-gateway/src/pool/instance.rs | 17 +-- crates/mcpmux-gateway/src/pool/oauth.rs | 37 +++--- crates/mcpmux-gateway/src/pool/oauth_utils.rs | 21 ++-- crates/mcpmux-gateway/src/pool/routing.rs | 8 +- crates/mcpmux-gateway/src/pool/service.rs | 12 +- .../src/pool/transport/shell_env.rs | 3 +- crates/mcpmux-gateway/src/server/mod.rs | 16 +-- crates/mcpmux-mcp/src/transports.rs | 26 ++-- crates/mcpmux-storage/src/keychain_dpapi.rs | 2 +- tests/rust/Cargo.toml | 2 +- .../streamable_http/gateway_notifications.rs | 28 ++--- .../tests/streamable_http/notifications.rs | 113 ++++++------------ 16 files changed, 181 insertions(+), 250 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 987df85..bb36f42 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4176,9 +4176,9 @@ dependencies = [ [[package]] name = "rmcp" -version = "0.17.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a0ce46f9101dc911f07e1468084c057839d15b08040d110820c5513312ef56a" +checksum = "67d69668de0b0ccd9cc435f700f3b39a7861863cf37a15e1f304ea78688a4826" dependencies = [ "async-trait", "base64 0.22.1", @@ -4211,9 +4211,9 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "0.17.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abad6f5f46e220e3bda2fc90fd1ad64c1c2a2bd716d52c845eb5c9c64cda7542" +checksum = "48fdc01c81097b0aed18633e676e269fefa3a78ec1df56b4fe597c1241b92025" dependencies = [ "darling 0.23.0", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index a174a8b..059f29a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,7 +58,7 @@ os_pipe = "1" # MCP Protocol # NOTE: Never use local path dependency - E:\one-mcp\rust-sdk is for source lookup only -rmcp = { version = "0.17.0", features = [ +rmcp = { version = "1.5", features = [ "client", "server", "transport-io", diff --git a/crates/mcpmux-gateway/src/mcp/handler.rs b/crates/mcpmux-gateway/src/mcp/handler.rs index d6f254a..2a0b042 100644 --- a/crates/mcpmux-gateway/src/mcp/handler.rs +++ b/crates/mcpmux-gateway/src/mcp/handler.rs @@ -83,12 +83,12 @@ impl McpMuxGatewayHandler { /// Build InitializeResult with negotiated protocol version fn build_initialize_result(&self, protocol_version: ProtocolVersion) -> InitializeResult { - InitializeResult { - protocol_version, - capabilities: self.get_info().capabilities, - server_info: self.get_info().server_info, - instructions: self.get_info().instructions, - } + let info = self.get_info(); + let mut result = InitializeResult::new(info.capabilities); + result.protocol_version = protocol_version; + result.server_info = info.server_info; + result.instructions = info.instructions; + result } } @@ -98,32 +98,28 @@ impl ServerHandler for McpMuxGatewayHandler { // Note: get_info is called frequently, no logging needed - ServerInfo { - protocol_version: Default::default(), - capabilities: ServerCapabilities::builder() - .enable_tools_with(ToolsCapability { - list_changed: Some(true), - }) - .enable_prompts_with(PromptsCapability { - list_changed: Some(true), - }) - .enable_resources_with(ResourcesCapability { - subscribe: Some(false), - list_changed: Some(true), - }) - .build(), - server_info: Implementation { - name: "mcpmux-gateway".to_string(), - version: env!("CARGO_PKG_VERSION").to_string(), - title: Some("McpMux".to_string()), - ..Default::default() - }, - instructions: Some( - "McpMux aggregates multiple MCP servers. Use tools/prompts/resources \ - from your authorized backend servers." - .to_string(), - ), - } + let capabilities = ServerCapabilities::builder() + .enable_tools_with(ToolsCapability { + list_changed: Some(true), + }) + .enable_prompts_with(PromptsCapability { + list_changed: Some(true), + }) + .enable_resources_with(ResourcesCapability { + subscribe: Some(false), + list_changed: Some(true), + }) + .build(); + let mut server_info = Implementation::new("mcpmux-gateway", env!("CARGO_PKG_VERSION")); + server_info.title = Some("McpMux".to_string()); + let mut info = ServerInfo::new(capabilities); + info.server_info = server_info; + info.instructions = Some( + "McpMux aggregates multiple MCP servers. Use tools/prompts/resources \ + from your authorized backend servers." + .to_string(), + ); + info } async fn initialize( @@ -321,11 +317,10 @@ impl ServerHandler for McpMuxGatewayHandler { "call_tool result" ); - let result = CallToolResult { - content, - structured_content: None, - is_error: Some(tool_result.is_error), - meta: None, + let result = if tool_result.is_error { + CallToolResult::error(content) + } else { + CallToolResult::success(content) }; Ok(result) @@ -557,7 +552,7 @@ impl ServerHandler for McpMuxGatewayHandler { .filter_map(|v| serde_json::from_value(v).ok()) .collect(); - Ok(ReadResourceResult { contents }) + Ok(ReadResourceResult::new(contents)) } /// Override on_custom_request to handle "initialize" with flexible protocol negotiation diff --git a/crates/mcpmux-gateway/src/pool/credential_store.rs b/crates/mcpmux-gateway/src/pool/credential_store.rs index 49fdf25..26f377b 100644 --- a/crates/mcpmux-gateway/src/pool/credential_store.rs +++ b/crates/mcpmux-gateway/src/pool/credential_store.rs @@ -218,24 +218,24 @@ impl CredentialStore for DatabaseCredentialStore { self.space_id, self.server_id, reg.client_id ); let token_response = Self::build_token_response(access, refresh_cred.as_ref()); - Some(StoredCredentials { - client_id: reg.client_id, - token_response: Some(token_response), - granted_scopes: Vec::new(), - token_received_at: Some(now_epoch_secs()), - }) + Some(StoredCredentials::new( + reg.client_id, + Some(token_response), + Vec::new(), + Some(now_epoch_secs()), + )) } (Some(reg), None) => { debug!( "[CredentialStore] Loaded registration (no token) for {}/{}, client_id={} - will reuse for DCR", self.space_id, self.server_id, reg.client_id ); - Some(StoredCredentials { - client_id: reg.client_id, - token_response: None, - granted_scopes: Vec::new(), - token_received_at: Some(now_epoch_secs()), - }) + Some(StoredCredentials::new( + reg.client_id, + None, + Vec::new(), + Some(now_epoch_secs()), + )) } (None, Some(access)) => { warn!( @@ -243,12 +243,12 @@ impl CredentialStore for DatabaseCredentialStore { self.space_id, self.server_id ); let token_response = Self::build_token_response(access, refresh_cred.as_ref()); - Some(StoredCredentials { - client_id: String::new(), - token_response: Some(token_response), - granted_scopes: Vec::new(), - token_received_at: Some(now_epoch_secs()), - }) + Some(StoredCredentials::new( + String::new(), + Some(token_response), + Vec::new(), + Some(now_epoch_secs()), + )) } (None, None) => { debug!( @@ -286,12 +286,13 @@ fn build_token_response( refresh_token: Option, expires_in: Option, ) -> OAuthTokenResponse { - use oauth2::{EmptyExtraTokenFields, StandardTokenResponse}; + use oauth2::StandardTokenResponse; + use rmcp::transport::auth::VendorExtraTokenFields; let mut response = StandardTokenResponse::new( AccessToken::new(access_token), BasicTokenType::Bearer, - EmptyExtraTokenFields {}, + VendorExtraTokenFields::default(), ); if let Some(refresh) = refresh_token { @@ -601,12 +602,12 @@ mod tests { Some(std::time::Duration::from_secs(3600)), ); - let credentials = StoredCredentials { - client_id: "new-client-id".to_string(), - token_response: Some(token_response), - granted_scopes: Vec::new(), - token_received_at: None, - }; + let credentials = StoredCredentials::new( + "new-client-id".to_string(), + Some(token_response), + Vec::new(), + None, + ); store.save(credentials).await.unwrap(); @@ -660,12 +661,12 @@ mod tests { Some(std::time::Duration::from_secs(3600)), ); - let credentials = StoredCredentials { - client_id: "client-id".to_string(), - token_response: Some(token_response), - granted_scopes: Vec::new(), - token_received_at: None, - }; + let credentials = StoredCredentials::new( + "client-id".to_string(), + Some(token_response), + Vec::new(), + None, + ); store.save(credentials).await.unwrap(); diff --git a/crates/mcpmux-gateway/src/pool/instance.rs b/crates/mcpmux-gateway/src/pool/instance.rs index e9fea7f..44dbbb3 100644 --- a/crates/mcpmux-gateway/src/pool/instance.rs +++ b/crates/mcpmux-gateway/src/pool/instance.rs @@ -50,20 +50,11 @@ impl McpClientHandler { event_tx: Option>, log_manager: Option>, ) -> Self { + let mut client_info = + Implementation::new(format!("mcpmux-{}", server_id), env!("CARGO_PKG_VERSION")); + client_info.title = Some("McpMux Gateway".to_string()); Self { - info: ClientInfo { - protocol_version: Default::default(), - capabilities: ClientCapabilities::default(), - client_info: Implementation { - name: format!("mcpmux-{}", server_id), - version: env!("CARGO_PKG_VERSION").to_string(), - title: Some("McpMux Gateway".to_string()), - icons: None, - website_url: None, - ..Default::default() - }, - meta: None, - }, + info: ClientInfo::new(ClientCapabilities::default(), client_info), server_id: server_id.to_string(), space_id, event_tx, diff --git a/crates/mcpmux-gateway/src/pool/oauth.rs b/crates/mcpmux-gateway/src/pool/oauth.rs index 513aefc..f748d54 100644 --- a/crates/mcpmux-gateway/src/pool/oauth.rs +++ b/crates/mcpmux-gateway/src/pool/oauth.rs @@ -1049,12 +1049,11 @@ impl OutboundOAuthManager { let scopes = Self::get_scopes_from_metadata(&discovered_metadata); // Then configure client with the existing registration - let config = rmcp::transport::auth::OAuthClientConfig { - client_id: reg.client_id.clone(), - client_secret: None, - scopes: scopes.clone(), - redirect_uri: redirect_uri.clone(), - }; + let mut config = rmcp::transport::auth::OAuthClientConfig::new( + reg.client_id.clone(), + redirect_uri.clone(), + ); + config.scopes = scopes.clone(); if let Err(e) = manager.configure_client(config) { self.log( @@ -1084,17 +1083,23 @@ impl OutboundOAuthManager { .await .map_err(|e| anyhow::anyhow!("Failed to get auth URL: {}", e))?; - // Create session manually - oauth_state = OAuthState::Session(rmcp::transport::auth::AuthorizationSession { - auth_manager: std::mem::replace( - manager, - rmcp::transport::auth::AuthorizationManager::new(server_url) - .await - .map_err(|e| anyhow::anyhow!("Failed: {}", e))?, + // Create session manually (reusing the existing registration). + // We already called configure_client + get_authorization_url above, + // so we use `for_scope_upgrade` to wrap the pre-computed values without + // re-registering the client via DCR. + let taken_manager = std::mem::replace( + manager, + rmcp::transport::auth::AuthorizationManager::new(server_url) + .await + .map_err(|e| anyhow::anyhow!("Failed: {}", e))?, + ); + oauth_state = OAuthState::Session( + rmcp::transport::auth::AuthorizationSession::for_scope_upgrade( + taken_manager, + auth_url.clone(), + &redirect_uri, ), - auth_url: auth_url.clone(), - redirect_uri: redirect_uri.clone(), - }); + ); } (false, None) // Not a new registration, no metadata to save } else { diff --git a/crates/mcpmux-gateway/src/pool/oauth_utils.rs b/crates/mcpmux-gateway/src/pool/oauth_utils.rs index 3a45e6d..995d031 100644 --- a/crates/mcpmux-gateway/src/pool/oauth_utils.rs +++ b/crates/mcpmux-gateway/src/pool/oauth_utils.rs @@ -101,17 +101,16 @@ pub fn convert_to_stored_metadata(metadata: &AuthorizationMetadata) -> StoredOAu /// This is used when loading saved metadata and setting it on the RMCP manager /// to bypass discovery. pub fn convert_from_stored_metadata(stored: &StoredOAuthMetadata) -> AuthorizationMetadata { - AuthorizationMetadata { - authorization_endpoint: stored.authorization_endpoint.clone(), - token_endpoint: stored.token_endpoint.clone(), - registration_endpoint: stored.registration_endpoint.clone(), - issuer: stored.issuer.clone(), - jwks_uri: stored.jwks_uri.clone(), - scopes_supported: stored.scopes_supported.clone(), - response_types_supported: stored.response_types_supported.clone(), - additional_fields: stored.additional_fields.clone(), - ..Default::default() - } + let mut metadata = AuthorizationMetadata::default(); + metadata.authorization_endpoint = stored.authorization_endpoint.clone(); + metadata.token_endpoint = stored.token_endpoint.clone(); + metadata.registration_endpoint = stored.registration_endpoint.clone(); + metadata.issuer = stored.issuer.clone(); + metadata.jwks_uri = stored.jwks_uri.clone(); + metadata.scopes_supported = stored.scopes_supported.clone(); + metadata.response_types_supported = stored.response_types_supported.clone(); + metadata.additional_fields = stored.additional_fields.clone(); + metadata } #[cfg(test)] diff --git a/crates/mcpmux-gateway/src/pool/routing.rs b/crates/mcpmux-gateway/src/pool/routing.rs index 180e09e..28daed5 100644 --- a/crates/mcpmux-gateway/src/pool/routing.rs +++ b/crates/mcpmux-gateway/src/pool/routing.rs @@ -283,12 +283,8 @@ impl RoutingService { match client_handle { Some(client) => { - let params = CallToolRequestParams { - name: tool_name.into(), - arguments: args.as_object().cloned(), - task: None, - meta: None, - }; + let mut params = CallToolRequestParams::new(tool_name.to_string()); + params.arguments = args.as_object().cloned(); // Wrap call_tool with timeout to prevent hanging let res = tokio::time::timeout(TOOL_CALL_TIMEOUT, client.call_tool(params)) diff --git a/crates/mcpmux-gateway/src/pool/service.rs b/crates/mcpmux-gateway/src/pool/service.rs index d8208a3..8217afc 100644 --- a/crates/mcpmux-gateway/src/pool/service.rs +++ b/crates/mcpmux-gateway/src/pool/service.rs @@ -164,10 +164,7 @@ impl PoolService { Some(client) => { use rmcp::model::ReadResourceRequestParams; - let params = ReadResourceRequestParams { - uri: uri.into(), - meta: None, - }; + let params = ReadResourceRequestParams::new(uri); let res = client .read_resource(params) @@ -243,11 +240,8 @@ impl PoolService { Some(client) => { use rmcp::model::GetPromptRequestParams; - let params = GetPromptRequestParams { - name: prompt_name.into(), - arguments, - meta: None, - }; + let mut params = GetPromptRequestParams::new(prompt_name); + params.arguments = arguments; let res = client .get_prompt(params) diff --git a/crates/mcpmux-gateway/src/pool/transport/shell_env.rs b/crates/mcpmux-gateway/src/pool/transport/shell_env.rs index 1724187..eebc32f 100644 --- a/crates/mcpmux-gateway/src/pool/transport/shell_env.rs +++ b/crates/mcpmux-gateway/src/pool/transport/shell_env.rs @@ -150,13 +150,12 @@ fn merge_paths(primary: &str, secondary: &str) -> String { merged.join(":") } -#[cfg(test)] +#[cfg(all(test, unix))] mod tests { use super::*; // ── merge_paths tests ────────────────────────────────────────── - #[cfg(unix)] #[test] fn test_merge_paths_deduplicates() { let result = merge_paths("/usr/bin:/usr/local/bin", "/usr/bin:/opt/homebrew/bin"); diff --git a/crates/mcpmux-gateway/src/server/mod.rs b/crates/mcpmux-gateway/src/server/mod.rs index 3d3c6e0..4eeb44d 100644 --- a/crates/mcpmux-gateway/src/server/mod.rs +++ b/crates/mcpmux-gateway/src/server/mod.rs @@ -247,19 +247,21 @@ impl GatewayServer { // - GET endpoint for SSE streams (server-initiated notifications) // - DELETE endpoint for session termination // - list_changed notifications delivered via SSE + // Build via default() + setters so new non-exhaustive fields (e.g. allowed_hosts, + // which defaults to localhost/127.0.0.1/::1) don't require us to enumerate them. + let mut http_cfg = StreamableHttpServerConfig::default(); + http_cfg.stateful_mode = true; + http_cfg.json_response = false; + http_cfg.sse_keep_alive = Some(std::time::Duration::from_secs(30)); + http_cfg.sse_retry = Some(std::time::Duration::from_secs(3)); + http_cfg.cancellation_token = CancellationToken::new(); let mcp_service = StreamableHttpService::new( move || { debug!("[Gateway] Creating handler instance for MCP session"); Ok(handler.clone()) }, LocalSessionManager::default().into(), - StreamableHttpServerConfig { - stateful_mode: true, - json_response: false, - sse_keep_alive: Some(std::time::Duration::from_secs(30)), - sse_retry: Some(std::time::Duration::from_secs(3)), - cancellation_token: CancellationToken::new(), - }, + http_cfg, ); // Wrap MCP service with OAuth middleware diff --git a/crates/mcpmux-mcp/src/transports.rs b/crates/mcpmux-mcp/src/transports.rs index 6472df2..f92ab17 100644 --- a/crates/mcpmux-mcp/src/transports.rs +++ b/crates/mcpmux-mcp/src/transports.rs @@ -83,20 +83,11 @@ pub struct McpClientHandler { impl McpClientHandler { pub fn new(server_id: &str) -> Self { + let mut client_info = + Implementation::new(format!("mcpmux-{}", server_id), env!("CARGO_PKG_VERSION")); + client_info.title = Some("McpMux Gateway".to_string()); Self { - info: ClientInfo { - protocol_version: Default::default(), - capabilities: ClientCapabilities::default(), - client_info: Implementation { - name: format!("mcpmux-{}", server_id), - version: env!("CARGO_PKG_VERSION").to_string(), - title: Some("McpMux Gateway".to_string()), - icons: None, - website_url: None, - ..Default::default() - }, - meta: None, - }, + info: ClientInfo::new(ClientCapabilities::default(), client_info), } } } @@ -217,11 +208,10 @@ impl McpSession { let result = self .client .peer() - .call_tool(CallToolRequestParams { - name: name.to_string().into(), - arguments: args, - task: None, - meta: None, + .call_tool({ + let mut params = CallToolRequestParams::new(name.to_string()); + params.arguments = args; + params }) .await .context("Tool call failed")?; diff --git a/crates/mcpmux-storage/src/keychain_dpapi.rs b/crates/mcpmux-storage/src/keychain_dpapi.rs index 6d868a9..3cf5be8 100644 --- a/crates/mcpmux-storage/src/keychain_dpapi.rs +++ b/crates/mcpmux-storage/src/keychain_dpapi.rs @@ -321,6 +321,6 @@ mod tests { assert!(file_contents.len() > KEY_SIZE); // The raw key bytes should not appear in the file - assert!(!file_contents.windows(KEY_SIZE).any(|w| w == &*key)); + assert!(!file_contents.windows(KEY_SIZE).any(|w| w == *key)); } } diff --git a/tests/rust/Cargo.toml b/tests/rust/Cargo.toml index c7edfc3..ea9a0da 100644 --- a/tests/rust/Cargo.toml +++ b/tests/rust/Cargo.toml @@ -50,7 +50,7 @@ url = "2.5" parking_lot = "0.12" # RMCP for streamable HTTP transport tests -rmcp = { version = "0.17.0", features = [ +rmcp = { version = "1.5", features = [ "client", "server", "transport-streamable-http-server", diff --git a/tests/rust/tests/streamable_http/gateway_notifications.rs b/tests/rust/tests/streamable_http/gateway_notifications.rs index 88c96eb..b4f1368 100644 --- a/tests/rust/tests/streamable_http/gateway_notifications.rs +++ b/tests/rust/tests/streamable_http/gateway_notifications.rs @@ -210,16 +210,16 @@ impl TestGateway { let handler = McpMuxGatewayHandler::new(services.clone(), notifier.clone()); // Build MCP service + let mut http_cfg = StreamableHttpServerConfig::default(); + http_cfg.stateful_mode = true; + http_cfg.json_response = false; + http_cfg.sse_keep_alive = Some(std::time::Duration::from_secs(15)); + http_cfg.sse_retry = Some(std::time::Duration::from_secs(3)); + http_cfg.cancellation_token = ct.child_token(); let mcp_service = StreamableHttpService::new( move || Ok(handler.clone()), Arc::new(LocalSessionManager::default()), - StreamableHttpServerConfig { - stateful_mode: true, - json_response: false, - sse_keep_alive: Some(std::time::Duration::from_secs(15)), - sse_retry: Some(std::time::Duration::from_secs(3)), - cancellation_token: ct.child_token(), - }, + http_cfg, ); // Build router with test OAuth middleware @@ -309,16 +309,10 @@ impl GatewayTestClient { impl rmcp::ClientHandler for GatewayTestClient { fn get_info(&self) -> ClientInfo { - ClientInfo { - protocol_version: Default::default(), - capabilities: ClientCapabilities::default(), - client_info: Implementation { - name: "gateway-test-client".to_string(), - version: "1.0.0".to_string(), - ..Default::default() - }, - ..Default::default() - } + ClientInfo::new( + ClientCapabilities::default(), + Implementation::new("gateway-test-client", "1.0.0"), + ) } fn on_tool_list_changed( diff --git a/tests/rust/tests/streamable_http/notifications.rs b/tests/rust/tests/streamable_http/notifications.rs index 382eac3..255fe28 100644 --- a/tests/rust/tests/streamable_http/notifications.rs +++ b/tests/rust/tests/streamable_http/notifications.rs @@ -52,27 +52,21 @@ impl TestNotificationHandler { impl ServerHandler for TestNotificationHandler { fn get_info(&self) -> ServerInfo { - ServerInfo { - protocol_version: Default::default(), - capabilities: ServerCapabilities::builder() - .enable_tools_with(ToolsCapability { - list_changed: Some(true), // Key: advertise notification support - }) - .enable_prompts_with(PromptsCapability { - list_changed: Some(true), - }) - .enable_resources_with(ResourcesCapability { - subscribe: Some(false), - list_changed: Some(true), - }) - .build(), - server_info: Implementation { - name: "test-notification-server".to_string(), - version: "1.0.0".to_string(), - ..Default::default() - }, - instructions: None, - } + let capabilities = ServerCapabilities::builder() + .enable_tools_with(ToolsCapability { + list_changed: Some(true), // Key: advertise notification support + }) + .enable_prompts_with(PromptsCapability { + list_changed: Some(true), + }) + .enable_resources_with(ResourcesCapability { + subscribe: Some(false), + list_changed: Some(true), + }) + .build(); + let mut info = ServerInfo::new(capabilities); + info.server_info = Implementation::new("test-notification-server", "1.0.0"); + info } async fn on_initialized(&self, context: NotificationContext) { @@ -122,16 +116,16 @@ impl ServerHandler for TestNotificationHandler { async fn start_test_server(handler: TestNotificationHandler) -> (String, CancellationToken) { let ct = CancellationToken::new(); + let mut http_cfg = StreamableHttpServerConfig::default(); + http_cfg.stateful_mode = true; + http_cfg.json_response = false; + http_cfg.sse_keep_alive = Some(std::time::Duration::from_secs(15)); + http_cfg.sse_retry = Some(std::time::Duration::from_secs(3)); + http_cfg.cancellation_token = ct.child_token(); let service = StreamableHttpService::new( move || Ok(handler.clone()), Arc::new(LocalSessionManager::default()), - StreamableHttpServerConfig { - stateful_mode: true, - json_response: false, - sse_keep_alive: Some(std::time::Duration::from_secs(15)), - sse_retry: Some(std::time::Duration::from_secs(3)), - cancellation_token: ct.child_token(), - }, + http_cfg, ); let router = axum::Router::new().nest_service("/mcp", service); @@ -163,16 +157,10 @@ async fn test_stateful_session_management() { // Connect client let transport = StreamableHttpClientTransport::from_uri(url.as_str()); - let client = ClientInfo { - protocol_version: Default::default(), - capabilities: ClientCapabilities::default(), - client_info: Implementation { - name: "test-client".to_string(), - version: "1.0.0".to_string(), - ..Default::default() - }, - ..Default::default() - } + let client = ClientInfo::new( + ClientCapabilities::default(), + Implementation::new("test-client", "1.0.0"), + ) .serve(transport) .await .expect("client should connect"); @@ -304,16 +292,10 @@ impl NotificationTrackingClient { impl rmcp::ClientHandler for NotificationTrackingClient { fn get_info(&self) -> ClientInfo { - ClientInfo { - protocol_version: Default::default(), - capabilities: ClientCapabilities::default(), - client_info: Implementation { - name: "notification-tracking-client".to_string(), - version: "1.0.0".to_string(), - ..Default::default() - }, - ..Default::default() - } + ClientInfo::new( + ClientCapabilities::default(), + Implementation::new("notification-tracking-client", "1.0.0"), + ) } fn on_tool_list_changed( @@ -615,16 +597,10 @@ async fn test_session_persists_across_requests() { let (url, ct) = start_test_server(handler.clone()).await; let transport = StreamableHttpClientTransport::from_uri(url.as_str()); - let client = ClientInfo { - protocol_version: Default::default(), - capabilities: ClientCapabilities::default(), - client_info: Implementation { - name: "session-test-client".to_string(), - version: "1.0.0".to_string(), - ..Default::default() - }, - ..Default::default() - } + let client = ClientInfo::new( + ClientCapabilities::default(), + Implementation::new("session-test-client", "1.0.0"), + ) .serve(transport) .await .expect("client should connect"); @@ -651,12 +627,7 @@ async fn test_session_persists_across_requests() { // Call a tool let result = client - .call_tool(CallToolRequestParams { - name: "test_tool".into(), - arguments: None, - meta: None, - task: None, - }) + .call_tool(CallToolRequestParams::new("test_tool")) .await .expect("call_tool"); assert!(!result.content.is_empty()); @@ -683,16 +654,10 @@ async fn test_protocol_version_negotiation() { // Connect with default (latest) protocol version let transport = StreamableHttpClientTransport::from_uri(url.as_str()); - let client = ClientInfo { - protocol_version: Default::default(), - capabilities: ClientCapabilities::default(), - client_info: Implementation { - name: "protocol-test-client".to_string(), - version: "1.0.0".to_string(), - ..Default::default() - }, - ..Default::default() - } + let client = ClientInfo::new( + ClientCapabilities::default(), + Implementation::new("protocol-test-client", "1.0.0"), + ) .serve(transport) .await .expect("client should connect with default protocol version"); From d16d50dc33358c7a669432589b5c34a804affe92 Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Mon, 20 Apr 2026 18:03:21 +0800 Subject: [PATCH 02/24] feat(core,storage): schema for FeatureSet resolver v2 (forward-compat) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the storage surface for project-oriented FeatureSet selection. Behaviour is unchanged at runtime; nothing reads the new columns yet. This lets later commits plug in the resolver under a shadow-mode flag with zero risk to the existing per-client grants path. Migration 002 (forward-compatible additive only): * inbound_clients.pinned_feature_set_id — chosen at approval time * inbound_clients.pinned_space_id — backfilled from locked_space_id * spaces.active_feature_set_id — backfilled from each space's existing Default FS so day-one resolver behaviour matches today * workspace_bindings table — (space_id, workspace_root) -> fs_id Core: * Space.active_feature_set_id * Client.pinned_space_id + pinned_feature_set_id * new WorkspaceBinding entity + normalize_workspace_root + longest_prefix_match helpers (with Windows drive-letter folding, file:// scheme stripping, percent-decode, trailing-separator trim) * SpaceRepository::set_active_feature_set * InboundMcpClientRepository::set_pin * new WorkspaceBindingRepository trait (CRUD + find_longest_prefix_match) Storage: * SqliteSpaceRepository + SqliteInboundMcpClientRepository round-trip the new columns * SqliteWorkspaceBindingRepository + test that longest-prefix matching picks the deepest binding when multiple candidates share prefixes Old per-client grants (client_grants table, Client.grants field, ConnectionMode enum, grant_feature_set/revoke_feature_set/etc.) remain in place untouched — they'll be removed once the resolver flips out of shadow mode. Signed-off-by: Mohammod Al Amin Ashik --- Cargo.lock | 1 + crates/mcpmux-core/src/domain/client.rs | 21 ++ crates/mcpmux-core/src/domain/mod.rs | 2 + crates/mcpmux-core/src/domain/space.rs | 8 + .../src/domain/workspace_binding.rs | 226 +++++++++++++++ crates/mcpmux-core/src/repository/mod.rs | 61 +++- crates/mcpmux-storage/src/database.rs | 17 +- .../migrations/002_featureset_resolver.sql | 82 ++++++ .../inbound_mcp_client_repository.rs | 63 +++- crates/mcpmux-storage/src/repositories/mod.rs | 2 + .../src/repositories/space_repository.rs | 43 ++- .../workspace_binding_repository.rs | 273 ++++++++++++++++++ tests/rust/Cargo.toml | 3 + tests/rust/src/mocks.rs | 28 ++ .../streamable_http/gateway_notifications.rs | 1 + 15 files changed, 811 insertions(+), 20 deletions(-) create mode 100644 crates/mcpmux-core/src/domain/workspace_binding.rs create mode 100644 crates/mcpmux-storage/src/migrations/002_featureset_resolver.sql create mode 100644 crates/mcpmux-storage/src/repositories/workspace_binding_repository.rs diff --git a/Cargo.lock b/Cargo.lock index bb36f42..a714f86 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5454,6 +5454,7 @@ dependencies = [ name = "tests" version = "0.0.2" dependencies = [ + "anyhow", "async-trait", "axum", "chrono", diff --git a/crates/mcpmux-core/src/domain/client.rs b/crates/mcpmux-core/src/domain/client.rs index 7ec72dc..33f955e 100644 --- a/crates/mcpmux-core/src/domain/client.rs +++ b/crates/mcpmux-core/src/domain/client.rs @@ -51,9 +51,28 @@ pub struct Client { pub connection_mode: ConnectionMode, /// FeatureSet grants per Space: space_id -> [feature_set_ids] + /// + /// Legacy field — superseded by `pinned_feature_set_id` + WorkspaceBinding. + /// Kept while the FeatureSetResolver runs in shadow mode. #[serde(default)] pub grants: HashMap>, + /// Space this access key belongs to (chosen at approval time). + /// + /// Replaces the `Locked` variant of `ConnectionMode`. `None` means + /// "follow the active Space" for legacy clients that haven't been + /// migrated yet; new approvals always populate this. + #[serde(default)] + pub pinned_space_id: Option, + + /// FeatureSet this access key is pinned to (chosen at approval time). + /// + /// When `Some`, the resolver uses this FS directly. When `None`, the + /// resolver falls through to workspace-root binding and then the + /// Space's active FS. + #[serde(default)] + pub pinned_feature_set_id: Option, + /// Access key for authentication (local only, never synced) #[serde(skip)] pub access_key: Option, @@ -78,6 +97,8 @@ impl Client { client_type: client_type.into(), connection_mode: ConnectionMode::default(), grants: HashMap::new(), + pinned_space_id: None, + pinned_feature_set_id: None, access_key: None, created_at: now, updated_at: now, diff --git a/crates/mcpmux-core/src/domain/mod.rs b/crates/mcpmux-core/src/domain/mod.rs index 15d5fc2..f164ce3 100644 --- a/crates/mcpmux-core/src/domain/mod.rs +++ b/crates/mcpmux-core/src/domain/mod.rs @@ -16,6 +16,7 @@ mod server; mod server_feature; mod server_log; mod space; +mod workspace_binding; // Export event types first (ConnectionStatus is defined here) pub use event::{ConnectionStatus, DiscoveredCapabilities, DomainEvent, DomainEventEnvelope}; @@ -31,3 +32,4 @@ pub use server::*; pub use server_feature::*; pub use server_log::*; pub use space::*; +pub use workspace_binding::{longest_prefix_match, normalize_workspace_root, WorkspaceBinding}; diff --git a/crates/mcpmux-core/src/domain/space.rs b/crates/mcpmux-core/src/domain/space.rs index e88b258..bf415a6 100644 --- a/crates/mcpmux-core/src/domain/space.rs +++ b/crates/mcpmux-core/src/domain/space.rs @@ -27,6 +27,13 @@ pub struct Space { /// Sort order for display pub sort_order: i32, + /// Active FeatureSet id — the default FS applied to every client in this + /// Space when neither an access-key pin nor a workspace-root binding matches. + /// + /// `None` means "deny by default" — routing returns an empty toolset. + #[serde(default)] + pub active_feature_set_id: Option, + /// Creation timestamp pub created_at: DateTime, @@ -45,6 +52,7 @@ impl Space { description: None, is_default: false, sort_order: 0, + active_feature_set_id: None, created_at: now, updated_at: now, } diff --git a/crates/mcpmux-core/src/domain/workspace_binding.rs b/crates/mcpmux-core/src/domain/workspace_binding.rs new file mode 100644 index 0000000..4677105 --- /dev/null +++ b/crates/mcpmux-core/src/domain/workspace_binding.rs @@ -0,0 +1,226 @@ +//! WorkspaceBinding entity — maps a workspace root on disk to a FeatureSet. +//! +//! Bindings are the middle tier of FeatureSet resolution: +//! pinned_feature_set_id (on Client) > WorkspaceBinding > Space.active_feature_set_id. +//! +//! When a connected client declares MCP `roots` capability, the gateway calls +//! `roots/list` and matches each reported `file://` root against the bindings +//! for the client's Space using longest-prefix-wins. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// A binding between a normalized workspace root path and a FeatureSet. +/// +/// Uniqueness is `(space_id, workspace_root)` — the same on-disk directory +/// can bind different FeatureSets in different Spaces. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct WorkspaceBinding { + /// Unique identifier + pub id: Uuid, + + /// Space this binding belongs to + pub space_id: Uuid, + + /// Normalized absolute path. + /// + /// Normalization rules (applied before insert/compare): + /// * resolve symlinks / junctions (`std::fs::canonicalize`) + /// * Windows: lowercase drive letter, use backslashes + /// * strip trailing path separator + /// * drop the `file://` scheme if the caller provided a URI + pub workspace_root: String, + + /// FeatureSet to apply when this binding matches + pub feature_set_id: Uuid, + + /// Creation timestamp + pub created_at: DateTime, + + /// Last update timestamp + pub updated_at: DateTime, +} + +impl WorkspaceBinding { + /// Create a new binding. Caller is responsible for passing an already-normalized path. + pub fn new(space_id: Uuid, workspace_root: impl Into, feature_set_id: Uuid) -> Self { + let now = Utc::now(); + Self { + id: Uuid::new_v4(), + space_id, + workspace_root: workspace_root.into(), + feature_set_id, + created_at: now, + updated_at: now, + } + } +} + +/// Normalize an absolute filesystem path or `file://` URI into the canonical +/// form used for binding comparisons. +/// +/// This is the single source of truth for path comparisons — always route +/// through here before calling any repository method that takes `workspace_root`. +pub fn normalize_workspace_root(input: &str) -> String { + // Strip file:// scheme if present; tolerate both "file:///abs/path" and + // "file://host/abs/path" (we don't use host, it's always localhost). + let without_scheme = if let Some(rest) = input.strip_prefix("file://") { + // A leading triple-slash (file:///abs) leaves us with "/abs". + // A double-slash host form (file://localhost/abs) leaves us with + // "localhost/abs" — drop the host component before the first slash. + match rest.find('/') { + Some(0) => rest.to_string(), + Some(n) => rest[n..].to_string(), + None => rest.to_string(), + } + } else { + input.to_string() + }; + + // URL-decode percent-escapes (e.g. %20 -> space) — MCP roots are URIs. + let decoded = urlencoding::decode(&without_scheme) + .map(|s| s.into_owned()) + .unwrap_or(without_scheme); + + // On Windows, "file:///D:/foo" decodes to "/D:/foo" — strip the leading + // slash so callers see "D:\foo"-style paths before case folding. + #[cfg(windows)] + let stripped = { + let trimmed = decoded + .strip_prefix('/') + .filter(|rest| { + let bytes = rest.as_bytes(); + bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' + }) + .unwrap_or(&decoded); + trimmed.replace('/', "\\") + }; + #[cfg(not(windows))] + let stripped = decoded; + + // Lowercase the drive letter on Windows so "D:\" and "d:\" compare equal. + #[cfg(windows)] + let cased = { + let mut chars: Vec = stripped.chars().collect(); + if chars.len() >= 2 && chars[0].is_ascii_alphabetic() && chars[1] == ':' { + chars[0] = chars[0].to_ascii_lowercase(); + } + chars.into_iter().collect::() + }; + #[cfg(not(windows))] + let cased = stripped; + + // Strip trailing path separators (but keep a root like "/" or "d:\"). + let sep: &[char] = if cfg!(windows) { &['\\', '/'] } else { &['/'] }; + let trimmed = cased.trim_end_matches(sep); + + // Preserve root — if the trim removed everything, keep one separator. + if trimmed.is_empty() { + if cfg!(windows) { + "\\".to_string() + } else { + "/".to_string() + } + } else if cfg!(windows) && trimmed.ends_with(':') { + // "d:" → "d:\" + format!("{}\\", trimmed) + } else { + trimmed.to_string() + } +} + +/// Returns the `workspace_root` in `candidates` whose path is the longest +/// prefix of `query`. Used by the resolver to pick which binding wins when +/// a client reports multiple roots. +/// +/// Both `query` and every candidate MUST be already normalized via +/// [`normalize_workspace_root`] — this function does not re-normalize. +pub fn longest_prefix_match<'a, I>(query: &str, candidates: I) -> Option<&'a str> +where + I: IntoIterator, +{ + let mut best: Option<&'a str> = None; + for candidate in candidates { + // Match only at a path-component boundary so "/workspaces/foo" does + // not match a binding for "/workspaces/foo-bar". + let matches = query == candidate + || (query.starts_with(candidate) + && query + .as_bytes() + .get(candidate.len()) + .is_some_and(|b| *b == b'/' || (cfg!(windows) && *b == b'\\'))); + if matches && best.map(|b| candidate.len() > b.len()).unwrap_or(true) { + best = Some(candidate); + } + } + best +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_normalize_file_uri_unix() { + let n = normalize_workspace_root("file:///home/user/proj"); + #[cfg(not(windows))] + assert_eq!(n, "/home/user/proj"); + #[cfg(windows)] + assert_eq!(n, "\\home\\user\\proj"); + } + + #[test] + fn test_normalize_trailing_sep() { + let sep = if cfg!(windows) { "\\" } else { "/" }; + let input = format!("/foo/bar{sep}"); + let n = normalize_workspace_root(&input); + assert!(!n.ends_with(sep) || n.len() <= 3, "got {n}"); + } + + #[cfg(windows)] + #[test] + fn test_normalize_windows_drive_letter_case_insensitive() { + assert_eq!( + normalize_workspace_root("D:\\Projects\\Foo"), + normalize_workspace_root("d:\\Projects\\Foo") + ); + assert_eq!(normalize_workspace_root("D:"), "d:\\"); + } + + #[cfg(windows)] + #[test] + fn test_normalize_windows_file_uri() { + assert_eq!( + normalize_workspace_root("file:///D:/Projects/Foo"), + "d:\\Projects\\Foo" + ); + } + + #[test] + fn test_percent_decoded() { + let n = normalize_workspace_root("file:///home/user/my%20project"); + assert!(n.ends_with("my project")); + } + + #[test] + fn test_longest_prefix_match_exact() { + let bindings = ["/a", "/a/b", "/a/b/c"]; + assert_eq!(longest_prefix_match("/a/b/c", bindings), Some("/a/b/c")); + assert_eq!(longest_prefix_match("/a/b/c/d", bindings), Some("/a/b/c")); + assert_eq!(longest_prefix_match("/a/b", bindings), Some("/a/b")); + } + + #[test] + fn test_longest_prefix_no_false_partial() { + // "/a/b-extra" must NOT match binding "/a/b". + let bindings = ["/a/b"]; + assert_eq!(longest_prefix_match("/a/b-extra", bindings), None); + } + + #[test] + fn test_longest_prefix_empty_candidates() { + let bindings: [&str; 0] = []; + assert_eq!(longest_prefix_match("/a", bindings), None); + } +} diff --git a/crates/mcpmux-core/src/repository/mod.rs b/crates/mcpmux-core/src/repository/mod.rs index 95b1d83..d616cdd 100644 --- a/crates/mcpmux-core/src/repository/mod.rs +++ b/crates/mcpmux-core/src/repository/mod.rs @@ -8,7 +8,7 @@ use uuid::Uuid; use crate::domain::{ Client, Credential, CredentialType, FeatureSet, FeatureSetMember, InstalledServer, MemberMode, - OutboundOAuthRegistration, ServerFeature, Space, + OutboundOAuthRegistration, ServerFeature, Space, WorkspaceBinding, }; /// Result type for repository operations @@ -37,6 +37,16 @@ pub trait SpaceRepository: Send + Sync { /// Set a space as default async fn set_default(&self, id: &Uuid) -> RepoResult<()>; + + /// Set (or clear, with `None`) the active FeatureSet for a Space. + /// + /// The active FS is the fallback applied when a connected client has + /// no pinned FS and no matching workspace binding. + async fn set_active_feature_set( + &self, + id: &Uuid, + feature_set_id: Option<&Uuid>, + ) -> RepoResult<()>; } /// InstalledServer repository trait @@ -266,6 +276,55 @@ pub trait InboundMcpClientRepository: Send + Sync { /// Check if client has any grants for a space async fn has_grants_for_space(&self, client_id: &Uuid, space_id: &str) -> RepoResult; + + /// Set the pinned Space + optional pinned FeatureSet for a client. + /// + /// This is the new (FeatureSet Resolver V2) path: each client row is an + /// independent approval bound to one Space. `pinned_feature_set_id = None` + /// means the client follows workspace-binding / space-active FS. + async fn set_pin( + &self, + client_id: &Uuid, + pinned_space_id: &Uuid, + pinned_feature_set_id: Option<&Uuid>, + ) -> RepoResult<()>; +} + +/// Workspace binding repository trait +/// +/// Bindings map normalized filesystem paths to FeatureSets on a per-Space basis. +/// Matching is longest-prefix-wins; callers are expected to pass +/// already-normalized paths (see [`crate::domain::normalize_workspace_root`]). +#[async_trait] +pub trait WorkspaceBindingRepository: Send + Sync { + /// List every binding across all Spaces. + async fn list(&self) -> RepoResult>; + + /// List bindings for a specific Space. + async fn list_for_space(&self, space_id: &Uuid) -> RepoResult>; + + /// Fetch a binding by id. + async fn get(&self, id: &Uuid) -> RepoResult>; + + /// Insert a new binding. Fails on `(space_id, workspace_root)` conflict. + async fn create(&self, binding: &WorkspaceBinding) -> RepoResult<()>; + + /// Update an existing binding (e.g., point to a different FS). + async fn update(&self, binding: &WorkspaceBinding) -> RepoResult<()>; + + /// Delete a binding by id. + async fn delete(&self, id: &Uuid) -> RepoResult<()>; + + /// Resolve which FeatureSet applies for a set of candidate workspace roots. + /// + /// Every candidate MUST already be normalized. Returns the binding whose + /// `workspace_root` is the longest prefix of any candidate, or `None` + /// when no binding matches. + async fn find_longest_prefix_match( + &self, + space_id: &Uuid, + candidate_roots: &[String], + ) -> RepoResult>; } /// Credential repository trait (local-only, never synced) diff --git a/crates/mcpmux-storage/src/database.rs b/crates/mcpmux-storage/src/database.rs index 86f35d6..3af00a0 100644 --- a/crates/mcpmux-storage/src/database.rs +++ b/crates/mcpmux-storage/src/database.rs @@ -32,11 +32,18 @@ struct Migration { /// Note: Migrations have been consolidated into a single clean initial migration. /// The schema includes cached_definition for offline operation and excludes /// runtime fields (connection_status, last_connected_at, last_error). -const MIGRATIONS: &[Migration] = &[Migration { - version: 1, - name: "initial", - sql: include_str!("migrations/001_initial.sql"), -}]; +const MIGRATIONS: &[Migration] = &[ + Migration { + version: 1, + name: "initial", + sql: include_str!("migrations/001_initial.sql"), + }, + Migration { + version: 2, + name: "featureset_resolver", + sql: include_str!("migrations/002_featureset_resolver.sql"), + }, +]; /// SQLite database wrapper. pub struct Database { diff --git a/crates/mcpmux-storage/src/migrations/002_featureset_resolver.sql b/crates/mcpmux-storage/src/migrations/002_featureset_resolver.sql new file mode 100644 index 0000000..5d434a3 --- /dev/null +++ b/crates/mcpmux-storage/src/migrations/002_featureset_resolver.sql @@ -0,0 +1,82 @@ +-- Migration 002: FeatureSet Resolver V2 +-- +-- Introduces the project-oriented FeatureSet selection model: +-- resolution order = access-key pin > workspace-root binding > space-active FS +-- +-- This migration is forward-compatible: the old per-client grants system +-- (client_grants table + inbound_clients.grants JSON) keeps working. The +-- resolver is switched over in a later migration. +-- +-- Added in this migration: +-- * inbound_clients.pinned_feature_set_id — explicit FS for this access key +-- * inbound_clients.pinned_space_id — Space the access key belongs to +-- * spaces.active_feature_set_id — default FS per Space when no pin / no workspace match +-- * workspace_bindings — (space_id, workspace_root) -> feature_set_id overrides + +-- ============================================================================ +-- inbound_clients: pinned_feature_set_id + pinned_space_id +-- ============================================================================ + +-- The FS chosen at approval time. NULL means "follow workspace / space default". +ALTER TABLE inbound_clients ADD COLUMN pinned_feature_set_id TEXT + REFERENCES feature_sets(id) ON DELETE SET NULL; + +-- The Space this access key belongs to. Replaces locked_space_id semantically, +-- but the old column is kept for backwards compat until a later migration drops it. +ALTER TABLE inbound_clients ADD COLUMN pinned_space_id TEXT + REFERENCES spaces(id) ON DELETE SET NULL; + +-- Backfill pinned_space_id from locked_space_id for any existing rows. +UPDATE inbound_clients +SET pinned_space_id = locked_space_id +WHERE pinned_space_id IS NULL AND locked_space_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_inbound_clients_pinned_space ON inbound_clients(pinned_space_id); +CREATE INDEX IF NOT EXISTS idx_inbound_clients_pinned_fs ON inbound_clients(pinned_feature_set_id); + +-- ============================================================================ +-- spaces.active_feature_set_id +-- ============================================================================ + +ALTER TABLE spaces ADD COLUMN active_feature_set_id TEXT + REFERENCES feature_sets(id) ON DELETE SET NULL; + +-- Backfill every Space's active FS to its existing 'default' FeatureSet +-- so day-one behavior matches pre-migration: clients with no pin and no +-- workspace match receive the same features they had before. +UPDATE spaces +SET active_feature_set_id = ( + SELECT fs.id + FROM feature_sets fs + WHERE fs.space_id = spaces.id + AND fs.feature_set_type = 'default' + AND fs.is_deleted = 0 + LIMIT 1 +) +WHERE active_feature_set_id IS NULL; + +-- ============================================================================ +-- workspace_bindings: (space_id, workspace_root) -> feature_set_id +-- ============================================================================ +-- +-- workspace_root is a normalized absolute filesystem path: +-- * Windows drive letter lowercased (e.g. "d:\projects\foo") +-- * trailing path separator stripped +-- * symlinks / junctions resolved before insert +-- Matching is longest-prefix over the caller's reported MCP roots. + +CREATE TABLE IF NOT EXISTS workspace_bindings ( + id TEXT PRIMARY KEY, + space_id TEXT NOT NULL, + workspace_root TEXT NOT NULL, + feature_set_id TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + + UNIQUE(space_id, workspace_root), + FOREIGN KEY (space_id) REFERENCES spaces(id) ON DELETE CASCADE, + FOREIGN KEY (feature_set_id) REFERENCES feature_sets(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_workspace_bindings_space ON workspace_bindings(space_id); +CREATE INDEX IF NOT EXISTS idx_workspace_bindings_root ON workspace_bindings(workspace_root); diff --git a/crates/mcpmux-storage/src/repositories/inbound_mcp_client_repository.rs b/crates/mcpmux-storage/src/repositories/inbound_mcp_client_repository.rs index 5072ac5..e20f90c 100644 --- a/crates/mcpmux-storage/src/repositories/inbound_mcp_client_repository.rs +++ b/crates/mcpmux-storage/src/repositories/inbound_mcp_client_repository.rs @@ -102,8 +102,8 @@ impl InboundMcpClientRepository for SqliteInboundMcpClientRepository { let mut stmt = conn.prepare( "SELECT client_id, client_name, registration_type, logo_uri, connection_mode, locked_space_id, - '{}', last_seen, created_at, updated_at - FROM inbound_clients + '{}', last_seen, created_at, updated_at, pinned_space_id, pinned_feature_set_id + FROM inbound_clients ORDER BY client_name ASC", )?; @@ -122,6 +122,12 @@ impl InboundMcpClientRepository for SqliteInboundMcpClientRepository { &row.get(5)?, ), grants: Self::parse_grants(&grants_json), + pinned_space_id: row + .get::<_, Option>(10)? + .and_then(|s| Uuid::parse_str(&s).ok()), + pinned_feature_set_id: row + .get::<_, Option>(11)? + .and_then(|s| Uuid::parse_str(&s).ok()), access_key: None, // Never loaded from DB last_seen: Self::parse_optional_datetime(&row.get(7)?), created_at: Self::parse_datetime(&row.get::<_, String>(8)?), @@ -139,8 +145,8 @@ impl InboundMcpClientRepository for SqliteInboundMcpClientRepository { let mut stmt = conn.prepare( "SELECT client_id, client_name, registration_type, logo_uri, connection_mode, locked_space_id, - '{}', last_seen, created_at, updated_at - FROM inbound_clients + '{}', last_seen, created_at, updated_at, pinned_space_id, pinned_feature_set_id + FROM inbound_clients WHERE client_id = ?", )?; @@ -159,6 +165,12 @@ impl InboundMcpClientRepository for SqliteInboundMcpClientRepository { &row.get(5)?, ), grants: Self::parse_grants(&grants_json), + pinned_space_id: row + .get::<_, Option>(10)? + .and_then(|s| Uuid::parse_str(&s).ok()), + pinned_feature_set_id: row + .get::<_, Option>(11)? + .and_then(|s| Uuid::parse_str(&s).ok()), access_key: None, last_seen: Self::parse_optional_datetime(&row.get(7)?), created_at: Self::parse_datetime(&row.get::<_, String>(8)?), @@ -176,8 +188,8 @@ impl InboundMcpClientRepository for SqliteInboundMcpClientRepository { let mut stmt = conn.prepare( "SELECT id, name, client_type, logo_uri, connection_mode, locked_space_id, - grants, last_seen, created_at, updated_at - FROM inbound_clients + grants, last_seen, created_at, updated_at, pinned_space_id, pinned_feature_set_id + FROM inbound_clients WHERE access_key_hash = ?", )?; @@ -196,6 +208,12 @@ impl InboundMcpClientRepository for SqliteInboundMcpClientRepository { &row.get(5)?, ), grants: Self::parse_grants(&grants_json), + pinned_space_id: row + .get::<_, Option>(10)? + .and_then(|s| Uuid::parse_str(&s).ok()), + pinned_feature_set_id: row + .get::<_, Option>(11)? + .and_then(|s| Uuid::parse_str(&s).ok()), access_key: None, last_seen: Self::parse_optional_datetime(&row.get(7)?), created_at: Self::parse_datetime(&row.get::<_, String>(8)?), @@ -394,7 +412,7 @@ impl InboundMcpClientRepository for SqliteInboundMcpClientRepository { let conn = db.connection(); let count: i32 = conn.query_row( - "SELECT COUNT(*) FROM client_grants + "SELECT COUNT(*) FROM client_grants WHERE client_id = ?1 AND space_id = ?2", params![client_id.to_string(), space_id], |row| row.get(0), @@ -402,6 +420,37 @@ impl InboundMcpClientRepository for SqliteInboundMcpClientRepository { Ok(count > 0) } + + async fn set_pin( + &self, + client_id: &Uuid, + pinned_space_id: &Uuid, + pinned_feature_set_id: Option<&Uuid>, + ) -> Result<()> { + let db = self.db.lock().await; + let conn = db.connection(); + + let now = Utc::now().to_rfc3339(); + let fs_str = pinned_feature_set_id.map(|u| u.to_string()); + + let rows_affected = conn.execute( + "UPDATE inbound_clients + SET pinned_space_id = ?2, pinned_feature_set_id = ?3, updated_at = ?4 + WHERE client_id = ?1", + params![ + client_id.to_string(), + pinned_space_id.to_string(), + fs_str, + now + ], + )?; + + if rows_affected == 0 { + anyhow::bail!("Client not found: {}", client_id); + } + + Ok(()) + } } #[cfg(test)] diff --git a/crates/mcpmux-storage/src/repositories/mod.rs b/crates/mcpmux-storage/src/repositories/mod.rs index 725b6db..eac1af5 100644 --- a/crates/mcpmux-storage/src/repositories/mod.rs +++ b/crates/mcpmux-storage/src/repositories/mod.rs @@ -9,6 +9,7 @@ mod installed_server_repository; mod outbound_oauth_client_repository; mod server_feature_repository; mod space_repository; +mod workspace_binding_repository; pub use app_settings_repository::SqliteAppSettingsRepository; pub use credential_repository::SqliteCredentialRepository; @@ -24,3 +25,4 @@ pub use server_feature_repository::{ FeatureType, ServerFeature, ServerFeatureRepository, SqliteServerFeatureRepository, }; pub use space_repository::SqliteSpaceRepository; +pub use workspace_binding_repository::SqliteWorkspaceBindingRepository; diff --git a/crates/mcpmux-storage/src/repositories/space_repository.rs b/crates/mcpmux-storage/src/repositories/space_repository.rs index e35eef9..21d193a 100644 --- a/crates/mcpmux-storage/src/repositories/space_repository.rs +++ b/crates/mcpmux-storage/src/repositories/space_repository.rs @@ -50,8 +50,8 @@ impl SpaceRepository for SqliteSpaceRepository { tracing::debug!("[SpaceRepository::list] Querying spaces..."); let mut stmt = conn.prepare( - "SELECT id, name, icon, description, is_default, sort_order, created_at, updated_at - FROM spaces + "SELECT id, name, icon, description, is_default, sort_order, created_at, updated_at, active_feature_set_id + FROM spaces ORDER BY sort_order ASC, name ASC", )?; @@ -75,6 +75,9 @@ impl SpaceRepository for SqliteSpaceRepository { description: row.get(3)?, is_default: row.get::<_, i32>(4)? == 1, sort_order: row.get(5)?, + active_feature_set_id: row + .get::<_, Option>(8)? + .and_then(|s| Uuid::parse_str(&s).ok()), created_at: Self::parse_datetime(&row.get::<_, String>(6)?), updated_at: Self::parse_datetime(&row.get::<_, String>(7)?), }) @@ -91,8 +94,8 @@ impl SpaceRepository for SqliteSpaceRepository { let conn = db.connection(); let mut stmt = conn.prepare( - "SELECT id, name, icon, description, is_default, sort_order, created_at, updated_at - FROM spaces + "SELECT id, name, icon, description, is_default, sort_order, created_at, updated_at, active_feature_set_id + FROM spaces WHERE id = ?", )?; @@ -108,6 +111,9 @@ impl SpaceRepository for SqliteSpaceRepository { description: row.get(3)?, is_default: row.get::<_, i32>(4)? == 1, sort_order: row.get(5)?, + active_feature_set_id: row + .get::<_, Option>(8)? + .and_then(|s| Uuid::parse_str(&s).ok()), created_at: Self::parse_datetime(&row.get::<_, String>(6)?), updated_at: Self::parse_datetime(&row.get::<_, String>(7)?), }) @@ -169,8 +175,8 @@ impl SpaceRepository for SqliteSpaceRepository { let conn = db.connection(); let rows_affected = conn.execute( - "UPDATE spaces - SET name = ?2, icon = ?3, description = ?4, is_default = ?5, sort_order = ?6, updated_at = ?7 + "UPDATE spaces + SET name = ?2, icon = ?3, description = ?4, is_default = ?5, sort_order = ?6, updated_at = ?7, active_feature_set_id = ?8 WHERE id = ?1", params![ space.id.to_string(), @@ -180,6 +186,7 @@ impl SpaceRepository for SqliteSpaceRepository { if space.is_default { 1 } else { 0 }, space.sort_order, space.updated_at.to_rfc3339(), + space.active_feature_set_id.map(|u| u.to_string()), ], )?; @@ -204,7 +211,7 @@ impl SpaceRepository for SqliteSpaceRepository { let conn = db.connection(); let mut stmt = conn.prepare( - "SELECT id, name, icon, description, is_default, sort_order, created_at, updated_at + "SELECT id, name, icon, description, is_default, sort_order, created_at, updated_at, active_feature_set_id FROM spaces WHERE is_default = 1 LIMIT 1", @@ -222,6 +229,9 @@ impl SpaceRepository for SqliteSpaceRepository { description: row.get(3)?, is_default: true, sort_order: row.get(5)?, + active_feature_set_id: row + .get::<_, Option>(8)? + .and_then(|s| Uuid::parse_str(&s).ok()), created_at: Self::parse_datetime(&row.get::<_, String>(6)?), updated_at: Self::parse_datetime(&row.get::<_, String>(7)?), }) @@ -255,6 +265,25 @@ impl SpaceRepository for SqliteSpaceRepository { Ok(()) } + + async fn set_active_feature_set(&self, id: &Uuid, feature_set_id: Option<&Uuid>) -> Result<()> { + let db = self.db.lock().await; + let conn = db.connection(); + + let fs_str = feature_set_id.map(|u| u.to_string()); + let now = Utc::now().to_rfc3339(); + + let rows_affected = conn.execute( + "UPDATE spaces SET active_feature_set_id = ?2, updated_at = ?3 WHERE id = ?1", + params![id.to_string(), fs_str, now], + )?; + + if rows_affected == 0 { + anyhow::bail!("Space not found: {}", id); + } + + Ok(()) + } } #[cfg(test)] diff --git a/crates/mcpmux-storage/src/repositories/workspace_binding_repository.rs b/crates/mcpmux-storage/src/repositories/workspace_binding_repository.rs new file mode 100644 index 0000000..12cef91 --- /dev/null +++ b/crates/mcpmux-storage/src/repositories/workspace_binding_repository.rs @@ -0,0 +1,273 @@ +//! SQLite implementation of WorkspaceBindingRepository. +//! +//! See the trait docs on [`mcpmux_core::WorkspaceBindingRepository`] for the +//! semantics of "longest-prefix-wins" matching — paths are expected to be +//! already normalized by [`mcpmux_core::normalize_workspace_root`] before being +//! stored or queried. + +use std::sync::Arc; + +use anyhow::Result; +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use mcpmux_core::{longest_prefix_match, WorkspaceBinding, WorkspaceBindingRepository}; +use rusqlite::{params, OptionalExtension}; +use tokio::sync::Mutex; +use uuid::Uuid; + +use crate::Database; + +pub struct SqliteWorkspaceBindingRepository { + db: Arc>, +} + +impl SqliteWorkspaceBindingRepository { + pub fn new(db: Arc>) -> Self { + Self { db } + } + + fn parse_datetime(s: &str) -> DateTime { + if let Ok(dt) = DateTime::parse_from_rfc3339(s) { + return dt.with_timezone(&Utc); + } + if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") { + return dt.and_utc(); + } + Utc::now() + } + + fn row_to_binding(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let id_str: String = row.get(0)?; + let space_id_str: String = row.get(1)?; + let fs_id_str: String = row.get(3)?; + Ok(WorkspaceBinding { + id: id_str.parse().unwrap_or_else(|_| Uuid::new_v4()), + space_id: space_id_str.parse().unwrap_or_else(|_| Uuid::new_v4()), + workspace_root: row.get(2)?, + feature_set_id: fs_id_str.parse().unwrap_or_else(|_| Uuid::new_v4()), + created_at: Self::parse_datetime(&row.get::<_, String>(4)?), + updated_at: Self::parse_datetime(&row.get::<_, String>(5)?), + }) + } +} + +#[async_trait] +impl WorkspaceBindingRepository for SqliteWorkspaceBindingRepository { + async fn list(&self) -> Result> { + let db = self.db.lock().await; + let conn = db.connection(); + + let mut stmt = conn.prepare( + "SELECT id, space_id, workspace_root, feature_set_id, created_at, updated_at + FROM workspace_bindings + ORDER BY space_id, workspace_root", + )?; + + let bindings = stmt + .query_map([], Self::row_to_binding)? + .collect::, _>>()?; + Ok(bindings) + } + + async fn list_for_space(&self, space_id: &Uuid) -> Result> { + let db = self.db.lock().await; + let conn = db.connection(); + + let mut stmt = conn.prepare( + "SELECT id, space_id, workspace_root, feature_set_id, created_at, updated_at + FROM workspace_bindings + WHERE space_id = ? + ORDER BY workspace_root", + )?; + + let bindings = stmt + .query_map(params![space_id.to_string()], Self::row_to_binding)? + .collect::, _>>()?; + Ok(bindings) + } + + async fn get(&self, id: &Uuid) -> Result> { + let db = self.db.lock().await; + let conn = db.connection(); + + let mut stmt = conn.prepare( + "SELECT id, space_id, workspace_root, feature_set_id, created_at, updated_at + FROM workspace_bindings + WHERE id = ?", + )?; + let binding = stmt + .query_row(params![id.to_string()], Self::row_to_binding) + .optional()?; + Ok(binding) + } + + async fn create(&self, binding: &WorkspaceBinding) -> Result<()> { + let db = self.db.lock().await; + let conn = db.connection(); + + conn.execute( + "INSERT INTO workspace_bindings (id, space_id, workspace_root, feature_set_id, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + binding.id.to_string(), + binding.space_id.to_string(), + binding.workspace_root, + binding.feature_set_id.to_string(), + binding.created_at.to_rfc3339(), + binding.updated_at.to_rfc3339(), + ], + )?; + + Ok(()) + } + + async fn update(&self, binding: &WorkspaceBinding) -> Result<()> { + let db = self.db.lock().await; + let conn = db.connection(); + + let rows_affected = conn.execute( + "UPDATE workspace_bindings + SET space_id = ?2, workspace_root = ?3, feature_set_id = ?4, updated_at = ?5 + WHERE id = ?1", + params![ + binding.id.to_string(), + binding.space_id.to_string(), + binding.workspace_root, + binding.feature_set_id.to_string(), + binding.updated_at.to_rfc3339(), + ], + )?; + + if rows_affected == 0 { + anyhow::bail!("WorkspaceBinding not found: {}", binding.id); + } + + Ok(()) + } + + async fn delete(&self, id: &Uuid) -> Result<()> { + let db = self.db.lock().await; + let conn = db.connection(); + + conn.execute( + "DELETE FROM workspace_bindings WHERE id = ?", + params![id.to_string()], + )?; + + Ok(()) + } + + async fn find_longest_prefix_match( + &self, + space_id: &Uuid, + candidate_roots: &[String], + ) -> Result> { + if candidate_roots.is_empty() { + return Ok(None); + } + + // Load all bindings for this space up-front. In practice a Space holds + // O(10) bindings, so SQL-side prefix matching is unnecessary complexity. + let bindings = self.list_for_space(space_id).await?; + if bindings.is_empty() { + return Ok(None); + } + + let candidate_strings: Vec<&str> = + bindings.iter().map(|b| b.workspace_root.as_str()).collect(); + + // For each reported root, find the longest binding prefix. + // Across multiple roots, pick whichever winning prefix is longest. + let mut best: Option<&WorkspaceBinding> = None; + for root in candidate_roots { + if let Some(winner) = longest_prefix_match(root, candidate_strings.iter().copied()) { + let winning = bindings + .iter() + .find(|b| b.workspace_root == winner) + .expect("candidate came from bindings"); + if best + .map(|b| winning.workspace_root.len() > b.workspace_root.len()) + .unwrap_or(true) + { + best = Some(winning); + } + } + } + + Ok(best.cloned()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + async fn make_repo() -> (SqliteWorkspaceBindingRepository, Uuid, Uuid) { + let db = Arc::new(Mutex::new(Database::open_in_memory().unwrap())); + let repo = SqliteWorkspaceBindingRepository::new(db.clone()); + let default_space = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(); + // The default space has a default FS seeded by migration 001; use it. + let fs_id = Uuid::parse_str( + format!("fs_default_{}", default_space).trim_start_matches("fs_default_"), + ) + .unwrap_or_else(|_| Uuid::new_v4()); + // The migration inserts a feature_set with id "fs_default_..." (not a UUID); + // for test purposes create a custom FS we control. + { + let db_guard = db.lock().await; + let now = Utc::now().to_rfc3339(); + db_guard + .connection() + .execute( + "INSERT INTO feature_sets (id, name, feature_set_type, space_id, is_builtin, created_at, updated_at) + VALUES (?1, 'Test FS', 'custom', ?2, 0, ?3, ?3)", + params![fs_id.to_string(), default_space.to_string(), now], + ) + .unwrap(); + } + (repo, default_space, fs_id) + } + + #[tokio::test] + async fn test_crud_and_prefix_match() { + let (repo, space_id, fs_id) = make_repo().await; + + #[cfg(windows)] + let (root_parent, root_child) = ("d:\\projects", "d:\\projects\\foo"); + #[cfg(not(windows))] + let (root_parent, root_child) = ("/home/user/projects", "/home/user/projects/foo"); + + let parent = WorkspaceBinding::new(space_id, root_parent, fs_id); + let child = WorkspaceBinding::new(space_id, root_child, fs_id); + repo.create(&parent).await.unwrap(); + repo.create(&child).await.unwrap(); + + let all = repo.list_for_space(&space_id).await.unwrap(); + assert_eq!(all.len(), 2); + + // Exact match on child + let found = repo + .find_longest_prefix_match(&space_id, &[root_child.to_string()]) + .await + .unwrap(); + assert_eq!(found.unwrap().workspace_root, root_child); + + // Deeper path should still hit child (longest prefix) + #[cfg(windows)] + let deeper = "d:\\projects\\foo\\src"; + #[cfg(not(windows))] + let deeper = "/home/user/projects/foo/src"; + let found = repo + .find_longest_prefix_match(&space_id, &[deeper.to_string()]) + .await + .unwrap(); + assert_eq!(found.unwrap().workspace_root, root_child); + + // Empty candidates returns None + let found = repo + .find_longest_prefix_match(&space_id, &[]) + .await + .unwrap(); + assert!(found.is_none()); + } +} diff --git a/tests/rust/Cargo.toml b/tests/rust/Cargo.toml index ea9a0da..4934b43 100644 --- a/tests/rust/Cargo.toml +++ b/tests/rust/Cargo.toml @@ -62,6 +62,9 @@ axum = "0.8" # Pipe creation for stderr capture tests os_pipe = { workspace = true } +# Error types for mocks +anyhow = "1" + [lib] path = "src/lib.rs" diff --git a/tests/rust/src/mocks.rs b/tests/rust/src/mocks.rs index 227cc13..9f156b4 100644 --- a/tests/rust/src/mocks.rs +++ b/tests/rust/src/mocks.rs @@ -77,6 +77,19 @@ impl SpaceRepository for MockSpaceRepository { *self.default_id.write().unwrap() = Some(*id); Ok(()) } + + async fn set_active_feature_set( + &self, + id: &Uuid, + feature_set_id: Option<&Uuid>, + ) -> RepoResult<()> { + let mut spaces = self.spaces.write().unwrap(); + let space = spaces + .get_mut(id) + .ok_or_else(|| anyhow::anyhow!("Space not found: {}", id))?; + space.active_feature_set_id = feature_set_id.copied(); + Ok(()) + } } // ============================================================================ @@ -677,6 +690,21 @@ impl InboundMcpClientRepository for MockInboundMcpClientRepository { .map(|v| !v.is_empty()) .unwrap_or(false)) } + + async fn set_pin( + &self, + client_id: &Uuid, + pinned_space_id: &Uuid, + pinned_feature_set_id: Option<&Uuid>, + ) -> RepoResult<()> { + let mut clients = self.clients.write().unwrap(); + let client = clients + .get_mut(client_id) + .ok_or_else(|| anyhow::anyhow!("Client not found: {}", client_id))?; + client.pinned_space_id = Some(*pinned_space_id); + client.pinned_feature_set_id = pinned_feature_set_id.copied(); + Ok(()) + } } // ============================================================================ diff --git a/tests/rust/tests/streamable_http/gateway_notifications.rs b/tests/rust/tests/streamable_http/gateway_notifications.rs index b4f1368..ce7701d 100644 --- a/tests/rust/tests/streamable_http/gateway_notifications.rs +++ b/tests/rust/tests/streamable_http/gateway_notifications.rs @@ -109,6 +109,7 @@ impl TestGateway { description: None, is_default: true, sort_order: 0, + active_feature_set_id: None, created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), }; From 76c66386ca2876ed2e699c2ef8c1c65150bf3a5d Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Mon, 20 Apr 2026 18:13:04 +0800 Subject: [PATCH 03/24] feat(gateway): FeatureSetResolver v2 + SessionRootsRegistry (shadow mode) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the runtime surface for the FeatureSet resolver v2. Decisions are computed and logged on every initialize but NOT yet enforced — the existing AuthorizationService::get_client_grants path remains authoritative until a follow-up commit flips the switch. New services: * SessionRootsRegistry — DashMap keyed by mcp-session-id, stores already-normalized workspace roots reported by the peer. * FeatureSetResolverService — resolves one of: Pin (client.pinned_feature_set_id) WorkspaceBinding (longest-prefix match against session roots) SpaceActive (space.active_feature_set_id fallback) Deny (no pin / no binding / no active FS) Gateway wiring: * GatewayDependencies gains inbound_mcp_client_repo + workspace_binding_repo, both wired to the SQLite repos so no DI boilerplate is required at call sites. * ServiceContainer exposes feature_set_resolver + session_roots. * McpMuxGatewayHandler::on_initialized now: - when the peer declared `roots` capability, spawns a task to call peer.list_roots(), normalizes + stores the URIs in the registry, then emits a shadow-mode log of the resolver's decision. - when no roots are declared, resolves immediately against pin / space-active so the shadow log still fires. Peer is cloned via Arc so notifications continue to be delivered. Normalization fix: normalize_workspace_root("") now returns "" so SessionRootsRegistry::set can filter out empty inputs without needing to know the target OS's root sentinel. Shadow-mode log format (grep-friendly): [FeatureSetResolver][shadow] resolved client_id=… session_id=… feature_set_id=… source={Pin|WorkspaceBinding|SpaceActive|Deny} Signed-off-by: Mohammod Al Amin Ashik --- .../src/domain/workspace_binding.rs | 6 + crates/mcpmux-gateway/src/mcp/handler.rs | 92 ++++++++- .../mcpmux-gateway/src/server/dependencies.rs | 33 +++- .../src/server/service_container.rs | 24 ++- .../src/services/feature_set_resolver.rs | 181 ++++++++++++++++++ crates/mcpmux-gateway/src/services/mod.rs | 4 + .../src/services/session_roots.rs | 97 ++++++++++ 7 files changed, 431 insertions(+), 6 deletions(-) create mode 100644 crates/mcpmux-gateway/src/services/feature_set_resolver.rs create mode 100644 crates/mcpmux-gateway/src/services/session_roots.rs diff --git a/crates/mcpmux-core/src/domain/workspace_binding.rs b/crates/mcpmux-core/src/domain/workspace_binding.rs index 4677105..115b8f1 100644 --- a/crates/mcpmux-core/src/domain/workspace_binding.rs +++ b/crates/mcpmux-core/src/domain/workspace_binding.rs @@ -63,6 +63,12 @@ impl WorkspaceBinding { /// This is the single source of truth for path comparisons — always route /// through here before calling any repository method that takes `workspace_root`. pub fn normalize_workspace_root(input: &str) -> String { + // Empty in → empty out: callers filter on this to drop garbage roots + // without needing to know about "/" vs "\" filesystem conventions. + if input.is_empty() { + return String::new(); + } + // Strip file:// scheme if present; tolerate both "file:///abs/path" and // "file://host/abs/path" (we don't use host, it's always localhost). let without_scheme = if let Some(rest) = input.strip_prefix("file://") { diff --git a/crates/mcpmux-gateway/src/mcp/handler.rs b/crates/mcpmux-gateway/src/mcp/handler.rs index 2a0b042..6f7bfa9 100644 --- a/crates/mcpmux-gateway/src/mcp/handler.rs +++ b/crates/mcpmux-gateway/src/mcp/handler.rs @@ -12,7 +12,7 @@ use rmcp::{ use std::sync::Arc; use tracing::{debug, info, warn}; -use super::context::{extract_oauth_context, OAuthContext}; +use super::context::{extract_oauth_context, extract_session_id, OAuthContext}; use crate::consumers::MCPNotifier; use crate::server::ServiceContainer; @@ -81,6 +81,36 @@ impl McpMuxGatewayHandler { } } + /// Shadow-mode log for the new FeatureSet resolver. + /// + /// Does not affect routing; prints the resolver's decision so operators + /// can compare against the legacy `get_client_grants` path before we + /// flip the switch. + async fn shadow_log_resolution( + resolver: &crate::services::FeatureSetResolverService, + client_id: &uuid::Uuid, + session_id: Option<&str>, + ) { + match resolver.resolve(client_id, session_id).await { + Ok(resolved) => { + info!( + %client_id, + session_id = session_id.unwrap_or(""), + feature_set_id = resolved.feature_set_id.map(|u| u.to_string()).unwrap_or_else(|| "".into()), + source = ?resolved.source, + "[FeatureSetResolver][shadow] resolved", + ); + } + Err(e) => { + warn!( + %client_id, + error = %e, + "[FeatureSetResolver][shadow] resolve failed", + ); + } + } + } + /// Build InitializeResult with negotiated protocol version fn build_initialize_result(&self, protocol_version: ProtocolVersion) -> InitializeResult { let info = self.get_info(); @@ -158,7 +188,7 @@ impl ServerHandler for McpMuxGatewayHandler { // Register peer with MCPNotifier for list_changed notification delivery let peer = std::sync::Arc::new(context.peer); self.notification_bridge - .register_peer(oauth_ctx.client_id.clone(), peer); + .register_peer(oauth_ctx.client_id.clone(), peer.clone()); // Mark the client stream as active immediately - RMCP's session transport // handles SSE streaming and message caching internally @@ -170,6 +200,64 @@ impl ServerHandler for McpMuxGatewayHandler { .prime_hashes_for_space(oauth_ctx.space_id) .await; + // Resolver v2 (shadow mode): if the peer advertised the `roots` + // capability, fetch its reported workspace roots and stash them in the + // session registry. We then run the resolver and log its decision — + // the legacy grants path is still authoritative for routing. + if let Some(session_id) = extract_session_id(&context.extensions) { + let declares_roots = peer + .peer_info() + .map(|info| info.capabilities.roots.is_some()) + .unwrap_or(false); + if declares_roots { + let peer_for_roots = peer.clone(); + let session_roots = self.services.session_roots.clone(); + let resolver = self.services.feature_set_resolver.clone(); + let client_id_str = oauth_ctx.client_id.clone(); + let session_id_for_task = session_id.clone(); + tokio::spawn(async move { + match peer_for_roots.list_roots().await { + Ok(result) => { + let uris: Vec = + result.roots.iter().map(|r| r.uri.to_string()).collect(); + session_roots + .set(&session_id_for_task, uris.iter().map(|s| s.as_str())); + debug!( + client_id = %client_id_str, + session_id = %session_id_for_task, + roots = ?uris, + "[FeatureSetResolver] fetched MCP roots", + ); + if let Ok(client_uuid) = uuid::Uuid::parse_str(&client_id_str) { + Self::shadow_log_resolution( + &resolver, + &client_uuid, + Some(&session_id_for_task), + ) + .await; + } + } + Err(e) => { + debug!( + client_id = %client_id_str, + session_id = %session_id_for_task, + error = %e, + "[FeatureSetResolver] peer.list_roots() failed — falling back to Space active FS", + ); + } + } + }); + } else if let Ok(client_uuid) = uuid::Uuid::parse_str(&oauth_ctx.client_id) { + // No roots declared — resolve immediately against pin / space active FS. + Self::shadow_log_resolution( + &self.services.feature_set_resolver, + &client_uuid, + Some(&session_id), + ) + .await; + } + } + info!( client_id = %oauth_ctx.client_id, space_id = %oauth_ctx.space_id, diff --git a/crates/mcpmux-gateway/src/server/dependencies.rs b/crates/mcpmux-gateway/src/server/dependencies.rs index d670fac..5560a91 100644 --- a/crates/mcpmux-gateway/src/server/dependencies.rs +++ b/crates/mcpmux-gateway/src/server/dependencies.rs @@ -9,8 +9,9 @@ use std::sync::Arc; use crate::services::ClientMetadataService; use mcpmux_core::{ AppSettingsRepository, CimdMetadataFetcher, CredentialRepository, FeatureSetRepository, - InstalledServerRepository, OutboundOAuthRepository, ServerDiscoveryService, - ServerFeatureRepository, ServerLogManager, SpaceRepository, + InboundMcpClientRepository, InstalledServerRepository, OutboundOAuthRepository, + ServerDiscoveryService, ServerFeatureRepository, ServerLogManager, SpaceRepository, + WorkspaceBindingRepository, }; use mcpmux_storage::{Database, InboundClientRepository}; use tokio::sync::Mutex; @@ -29,6 +30,13 @@ pub struct GatewayDependencies { pub feature_set_repo: Arc, pub space_repo: Arc, pub inbound_client_repo: Arc, + /// Trait-based MCP client repository (for Client entity CRUD + pin setters). + /// + /// Used by the FeatureSet resolver v2 — separate from `inbound_client_repo` + /// (which is the concrete OAuth-flow-focused repo). + pub inbound_mcp_client_repo: Arc, + /// Workspace -> FeatureSet bindings for resolver v2. + pub workspace_binding_repo: Arc, // Services (Business Layer) pub server_discovery: Arc, @@ -66,6 +74,15 @@ impl GatewayDependencies { jwt_secret: Option>, state_dir: Option, ) -> Self { + // Resolver v2 repositories — always SQLite-backed; no-op at runtime + // until the resolver flag flips out of shadow mode. + let inbound_mcp_client_repo: Arc = Arc::new( + mcpmux_storage::SqliteInboundMcpClientRepository::new(database.clone()), + ); + let workspace_binding_repo: Arc = Arc::new( + mcpmux_storage::SqliteWorkspaceBindingRepository::new(database.clone()), + ); + Self { installed_server_repo, credential_repo, @@ -74,6 +91,8 @@ impl GatewayDependencies { feature_set_repo, space_repo, inbound_client_repo, + inbound_mcp_client_repo, + workspace_binding_repo, server_discovery, log_manager, cimd_fetcher, @@ -214,6 +233,14 @@ impl DependenciesBuilder { )) }); + // Resolver v2 repositories — always SQLite-backed for now. + let inbound_mcp_client_repo: Arc = Arc::new( + mcpmux_storage::SqliteInboundMcpClientRepository::new(database.clone()), + ); + let workspace_binding_repo: Arc = Arc::new( + mcpmux_storage::SqliteWorkspaceBindingRepository::new(database.clone()), + ); + Ok(GatewayDependencies { installed_server_repo: self .installed_server_repo @@ -228,6 +255,8 @@ impl DependenciesBuilder { .ok_or("feature_set_repo is required")?, space_repo, inbound_client_repo, + inbound_mcp_client_repo, + workspace_binding_repo, server_discovery: self .server_discovery .ok_or("server_discovery is required")?, diff --git a/crates/mcpmux-gateway/src/server/service_container.rs b/crates/mcpmux-gateway/src/server/service_container.rs index 648d11e..ca4f4c5 100644 --- a/crates/mcpmux-gateway/src/server/service_container.rs +++ b/crates/mcpmux-gateway/src/server/service_container.rs @@ -7,8 +7,8 @@ use std::sync::Arc; use crate::pool::{PoolServices, ServerManager, ServiceFactory}; use crate::services::{ - AuthorizationService, ClientMetadataService, GrantService, PrefixCacheService, - SpaceResolverService, + AuthorizationService, ClientMetadataService, FeatureSetResolverService, GrantService, + PrefixCacheService, SessionRootsRegistry, SpaceResolverService, }; use mcpmux_core::DomainEvent; @@ -33,6 +33,15 @@ pub struct ServiceContainer { /// Authorization service for checking client permissions (SRP) pub authorization_service: Arc, + /// FeatureSet resolver v2 (pin > workspace > space-active). + /// + /// Runs in shadow mode alongside `authorization_service` — its decision + /// is logged on every request but not yet enforced. + pub feature_set_resolver: Arc, + + /// Registry of per-session workspace roots (populated from MCP `roots/list`). + pub session_roots: Arc, + /// Space resolver for determining client's active space (SRP) pub space_resolver_service: Arc, @@ -91,6 +100,15 @@ impl ServiceContainer { deps.feature_set_repo.clone(), )); + // Resolver v2 — runs in shadow mode alongside AuthorizationService. + let session_roots = SessionRootsRegistry::new(); + let feature_set_resolver = Arc::new(FeatureSetResolverService::new( + deps.inbound_mcp_client_repo.clone(), + deps.space_repo.clone(), + deps.workspace_binding_repo.clone(), + session_roots.clone(), + )); + // Create space resolver service (DIP: inject repository dependencies) let space_resolver_service = Arc::new(SpaceResolverService::new( deps.inbound_client_repo.clone(), @@ -113,6 +131,8 @@ impl ServiceContainer { server_manager, startup_orchestrator, authorization_service, + feature_set_resolver, + session_roots, space_resolver_service, prefix_cache_service, client_metadata_service, diff --git a/crates/mcpmux-gateway/src/services/feature_set_resolver.rs b/crates/mcpmux-gateway/src/services/feature_set_resolver.rs new file mode 100644 index 0000000..03fc042 --- /dev/null +++ b/crates/mcpmux-gateway/src/services/feature_set_resolver.rs @@ -0,0 +1,181 @@ +//! FeatureSet Resolver Service (V2 — pin > workspace > space active). +//! +//! Replaces the per-client-grants lookup in [`super::AuthorizationService`] +//! with a single deterministic resolution: +//! +//! ```text +//! resolve(client, session_id): +//! if client.pinned_feature_set_id: source = Pin +//! else if workspace binding matches: source = WorkspaceBinding +//! else: source = SpaceActive (may be None = Deny) +//! ``` +//! +//! The service runs alongside `AuthorizationService` in **shadow mode**: the +//! gateway still honours the legacy grants path for now, but the resolver's +//! decision is logged on every call so we can verify divergence before +//! flipping the switch. + +use std::sync::Arc; + +use anyhow::Result; +use mcpmux_core::{InboundMcpClientRepository, SpaceRepository, WorkspaceBindingRepository}; +use serde::Serialize; +use tracing::{debug, warn}; +use uuid::Uuid; + +use super::session_roots::SessionRootsRegistry; + +/// Why the resolver picked the FS it picked (or didn't pick one). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ResolutionSource { + /// Client's `pinned_feature_set_id` was set. + Pin, + /// A [`WorkspaceBinding`](mcpmux_core::WorkspaceBinding) matched one of + /// the session's reported MCP roots. + WorkspaceBinding, + /// No pin and no workspace match; fell through to `Space.active_feature_set_id`. + SpaceActive, + /// No pin, no workspace match, and the Space has no active FS — deny. + Deny, +} + +/// Output of [`FeatureSetResolverService::resolve`]. +#[derive(Debug, Clone)] +pub struct ResolvedFeatureSet { + /// Chosen FeatureSet id, or `None` when `source == Deny`. + pub feature_set_id: Option, + pub source: ResolutionSource, +} + +/// Resolves which FeatureSet applies for a given (client, session). +/// +/// Cheap to clone via `Arc`; inject one instance into the gateway's service +/// container and reuse across requests. +pub struct FeatureSetResolverService { + client_repo: Arc, + space_repo: Arc, + binding_repo: Arc, + session_roots: Arc, +} + +impl FeatureSetResolverService { + pub fn new( + client_repo: Arc, + space_repo: Arc, + binding_repo: Arc, + session_roots: Arc, + ) -> Self { + Self { + client_repo, + space_repo, + binding_repo, + session_roots, + } + } + + /// Read the client's pin + the caller's reported roots + the Space's + /// active FS, and return the winning FeatureSet with its source. + /// + /// `session_id` is the client's `mcp-session-id` header (or `None` for + /// stateless callers) — used to look up reported MCP roots. + pub async fn resolve( + &self, + client_id: &Uuid, + session_id: Option<&str>, + ) -> Result { + let Some(client) = self.client_repo.get(client_id).await? else { + warn!(%client_id, "[FeatureSetResolver] client not found"); + return Ok(ResolvedFeatureSet { + feature_set_id: None, + source: ResolutionSource::Deny, + }); + }; + + // 1. Pin wins outright. + if let Some(fs) = client.pinned_feature_set_id { + debug!(%client_id, feature_set = %fs, "[FeatureSetResolver] resolved via Pin"); + return Ok(ResolvedFeatureSet { + feature_set_id: Some(fs), + source: ResolutionSource::Pin, + }); + } + + // Determine which Space the caller belongs to. We prefer the explicit + // pinned_space_id; if it's missing (legacy client pre-migration), + // fall back to the active/default Space. + let space_id = match client.pinned_space_id { + Some(id) => id, + None => match self.space_repo.get_default().await? { + Some(s) => s.id, + None => { + warn!(%client_id, "[FeatureSetResolver] no pinned_space_id and no default space"); + return Ok(ResolvedFeatureSet { + feature_set_id: None, + source: ResolutionSource::Deny, + }); + } + }, + }; + + // 2. Workspace-root match, only when the session reported roots. + if let Some(sid) = session_id { + if let Some(roots) = self.session_roots.get(sid) { + if !roots.is_empty() { + if let Some(binding) = self + .binding_repo + .find_longest_prefix_match(&space_id, &roots) + .await? + { + debug!( + %client_id, + session_id = sid, + feature_set = %binding.feature_set_id, + workspace_root = binding.workspace_root, + "[FeatureSetResolver] resolved via WorkspaceBinding", + ); + return Ok(ResolvedFeatureSet { + feature_set_id: Some(binding.feature_set_id), + source: ResolutionSource::WorkspaceBinding, + }); + } + } + } + } + + // 3. Space active FS is the fallback. + let space = self.space_repo.get(&space_id).await?; + match space.and_then(|s| s.active_feature_set_id) { + Some(fs) => { + debug!( + %client_id, + %space_id, + feature_set = %fs, + "[FeatureSetResolver] resolved via SpaceActive", + ); + Ok(ResolvedFeatureSet { + feature_set_id: Some(fs), + source: ResolutionSource::SpaceActive, + }) + } + None => { + debug!( + %client_id, + %space_id, + "[FeatureSetResolver] no pin / no binding / no active FS — deny", + ); + Ok(ResolvedFeatureSet { + feature_set_id: None, + source: ResolutionSource::Deny, + }) + } + } + } +} + +#[cfg(test)] +mod tests { + //! Resolver decision-table tests live in the integration test crate + //! (`tests/rust/tests/integration/feature_set_resolver.rs`) so they can + //! share the mock repositories with the other gateway tests. +} diff --git a/crates/mcpmux-gateway/src/services/mod.rs b/crates/mcpmux-gateway/src/services/mod.rs index 085d023..5209f4c 100644 --- a/crates/mcpmux-gateway/src/services/mod.rs +++ b/crates/mcpmux-gateway/src/services/mod.rs @@ -8,15 +8,19 @@ mod authorization; mod client_metadata_service; mod event_emitter; +mod feature_set_resolver; mod grant_service; mod notification_emitter; mod prefix_cache; +mod session_roots; mod space_resolver; pub use authorization::AuthorizationService; pub use client_metadata_service::ClientMetadataService; pub use event_emitter::EventEmitter; +pub use feature_set_resolver::{FeatureSetResolverService, ResolutionSource, ResolvedFeatureSet}; pub use grant_service::GrantService; pub use notification_emitter::NotificationEmitter; pub use prefix_cache::PrefixCacheService; +pub use session_roots::SessionRootsRegistry; pub use space_resolver::SpaceResolverService; diff --git a/crates/mcpmux-gateway/src/services/session_roots.rs b/crates/mcpmux-gateway/src/services/session_roots.rs new file mode 100644 index 0000000..64aa807 --- /dev/null +++ b/crates/mcpmux-gateway/src/services/session_roots.rs @@ -0,0 +1,97 @@ +//! Session-scoped registry of MCP workspace roots. +//! +//! When a client declares the `roots` capability on `initialize`, the gateway +//! calls `roots/list` via the peer and stashes the result here keyed by the +//! client's `mcp-session-id`. The `FeatureSetResolverService` consults this +//! registry to pick a workspace binding. +//! +//! Roots are stored already-normalized (via +//! [`mcpmux_core::normalize_workspace_root`]) so the resolver doesn't need to +//! re-normalize on every lookup. + +use std::sync::Arc; + +use dashmap::DashMap; +use mcpmux_core::normalize_workspace_root; + +/// Thread-safe registry mapping `mcp-session-id` to the caller's reported +/// workspace roots. +#[derive(Debug, Default)] +pub struct SessionRootsRegistry { + map: DashMap>, +} + +impl SessionRootsRegistry { + pub fn new() -> Arc { + Arc::new(Self { + map: DashMap::new(), + }) + } + + /// Store the reported roots for a session. `roots` should already be + /// absolute paths or `file://` URIs — we normalize them before storing. + pub fn set(&self, session_id: impl Into, roots: I) + where + I: IntoIterator, + S: AsRef, + { + let normalized: Vec = roots + .into_iter() + .map(|r| normalize_workspace_root(r.as_ref())) + .filter(|r| !r.is_empty()) + .collect(); + self.map.insert(session_id.into(), normalized); + } + + /// Retrieve the (already-normalized) roots for a session, if any. + pub fn get(&self, session_id: &str) -> Option> { + self.map.get(session_id).map(|v| v.clone()) + } + + /// Drop a session's roots — call on client disconnect. + pub fn remove(&self, session_id: &str) { + self.map.remove(session_id); + } + + /// Current number of tracked sessions. Test helper; cheap to call but + /// not useful in hot paths. + #[cfg(test)] + pub fn len(&self) -> usize { + self.map.len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_set_normalizes_and_filters_empty() { + let reg = SessionRootsRegistry::default(); + reg.set( + "sess-1", + [ + #[cfg(windows)] + "file:///D:/proj/", + #[cfg(not(windows))] + "file:///home/user/proj/", + "", + ], + ); + let roots = reg.get("sess-1").unwrap(); + assert_eq!(roots.len(), 1); + #[cfg(windows)] + assert_eq!(roots[0], "d:\\proj"); + #[cfg(not(windows))] + assert_eq!(roots[0], "/home/user/proj"); + } + + #[test] + fn test_remove() { + let reg = SessionRootsRegistry::default(); + reg.set("sess-1", ["/a"]); + assert_eq!(reg.len(), 1); + reg.remove("sess-1"); + assert_eq!(reg.len(), 0); + } +} From ecf6bb8b7b5d03286a1b4b62105b25f7e730e918 Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Mon, 20 Apr 2026 20:14:22 +0800 Subject: [PATCH 04/24] =?UTF-8?q?feat:=20FeatureSet=20resolver=20v2=20?= =?UTF-8?q?=E2=80=94=20authoritative=20+=20UI=20+=20migration=20003?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switches the gateway from per-client grants to the FeatureSetResolver (pin > workspace binding > space-active FS) and lays the Tauri/UI surface so users can actually drive the new model. Enforcement flip: * AuthorizationService::get_client_grants now delegates to FeatureSetResolverService; `client_grants` table is no longer consulted. Shadow-mode logging is removed in favour of the real path. * Call sites in mcp/handler.rs (list_tools, list_prompts, list_resources, get_prompt, read_resource, call_tool) and server/handlers.rs thread `mcp-session-id` through so workspace-binding resolution works. * Legacy repo methods (grant_feature_set / revoke_feature_set / get_grants_for_space / get_all_grants / has_grants_for_space / set_grants_for_space) are retained as no-ops for API compat — Tauri commands, GrantService, PermissionAppService, ClientService, and existing tests keep compiling without the dropped table. Migration 003: * DROP TABLE client_grants (the dead column `inbound_clients.grants` is left for now to avoid a second schema bump on older SQLite; unused in reads and writes already). Domain/API: * `AppState` gains `workspace_binding_repository`. * SpaceService::set_active_feature_set. * New Tauri commands: - set_space_active_feature_set - update_client_pin - list_workspace_bindings - list_workspace_bindings_for_space - create_workspace_binding - update_workspace_binding - delete_workspace_binding * TS bindings (`lib/api/spaces.ts`, `clients.ts`, `workspaceBindings.ts`) expose the new fields + commands. UI: * FeatureSets page: each card gains an "Active" badge (green ring + pill) when it's the Space's fallback, and a "Set Active" link in the footer that swaps it optimistically. * New Workspaces page (`features/workspaces/WorkspacesPage.tsx`): lists bindings for the current Space, with a form to create one (root + FeatureSet dropdown) and delete buttons per row. Paths are normalized Rust-side before storage so Windows/Unix/file:// inputs all compare consistently. Approval-dialog wiring-in (Step 3) is intentionally scoped down: the backend now accepts `update_client_pin` on an existing client, which is what the Connections UI will call once each approval needs to pick a Space + optional FS pin. Full dialog restyle is deferred — the underlying command + storage are ready. Tests: * New integration suite `integration::feature_set_resolver` with 8 tests over real SQLite-backed repos: - falls through to space-active when no pin and no roots - pin wins over space-active - pin wins over workspace binding - workspace binding beats space-active when no pin - deny when no pin / no binding / no space-active - longest-prefix wins across nested bindings - falls through when roots don't match any binding - deny for unknown client * All existing integration/database/streamable_http/oauth tests continue to pass (193 total). Signed-off-by: Mohammod Al Amin Ashik --- apps/desktop/src-tauri/src/commands/client.rs | 48 +++ apps/desktop/src-tauri/src/commands/mod.rs | 2 + apps/desktop/src-tauri/src/commands/space.rs | 35 +++ .../src/commands/workspace_binding.rs | 147 +++++++++ apps/desktop/src-tauri/src/lib.rs | 8 + apps/desktop/src-tauri/src/state/mod.rs | 9 +- .../features/featuresets/FeatureSetsPage.tsx | 76 ++++- .../features/workspaces/WorkspacesPage.tsx | 240 +++++++++++++++ apps/desktop/src/features/workspaces/index.ts | 1 + apps/desktop/src/lib/api/clients.ts | 36 ++- apps/desktop/src/lib/api/index.ts | 1 + apps/desktop/src/lib/api/spaces.ts | 22 ++ apps/desktop/src/lib/api/workspaceBindings.ts | 63 ++++ .../mcpmux-core/src/service/space_service.rs | 14 + crates/mcpmux-gateway/src/mcp/handler.rs | 36 ++- crates/mcpmux-gateway/src/server/handlers.rs | 7 +- .../src/server/service_container.rs | 12 +- .../src/services/authorization.rs | 97 +++--- crates/mcpmux-storage/src/database.rs | 5 + .../src/migrations/003_drop_legacy_grants.sql | 21 ++ .../repositories/inbound_client_repository.rs | 83 +----- .../inbound_mcp_client_repository.rs | 124 ++------ .../tests/integration/feature_set_resolver.rs | 279 ++++++++++++++++++ tests/rust/tests/integration/mod.rs | 1 + 24 files changed, 1122 insertions(+), 245 deletions(-) create mode 100644 apps/desktop/src-tauri/src/commands/workspace_binding.rs create mode 100644 apps/desktop/src/features/workspaces/WorkspacesPage.tsx create mode 100644 apps/desktop/src/features/workspaces/index.ts create mode 100644 apps/desktop/src/lib/api/workspaceBindings.ts create mode 100644 crates/mcpmux-storage/src/migrations/003_drop_legacy_grants.sql create mode 100644 tests/rust/tests/integration/feature_set_resolver.rs diff --git a/apps/desktop/src-tauri/src/commands/client.rs b/apps/desktop/src-tauri/src/commands/client.rs index ee30cb3..14685de 100644 --- a/apps/desktop/src-tauri/src/commands/client.rs +++ b/apps/desktop/src-tauri/src/commands/client.rs @@ -22,6 +22,10 @@ pub struct ClientResponse { pub connection_mode: String, pub locked_space_id: Option, pub grants: HashMap>, + /// Resolver v2: Space this access key belongs to (chosen at approval). + pub pinned_space_id: Option, + /// Resolver v2: explicit FS pin (`None` → follow workspace / space active). + pub pinned_feature_set_id: Option, pub last_seen: Option, } @@ -48,6 +52,8 @@ impl From for ClientResponse { connection_mode: mode, locked_space_id: locked_id, grants, + pinned_space_id: c.pinned_space_id.map(|u| u.to_string()), + pinned_feature_set_id: c.pinned_feature_set_id.map(|u| u.to_string()), last_seen: c.last_seen.map(|dt| dt.to_rfc3339()), } } @@ -127,6 +133,48 @@ pub async fn create_client( Ok(client.into()) } +/// Pin a client to a Space + optional FeatureSet (resolver v2). +/// +/// Precedence used by [`mcpmux_gateway::services::FeatureSetResolverService`]: +/// 1. `pinned_feature_set_id` (this pin) → source = Pin +/// 2. workspace binding matches a root → source = WorkspaceBinding +/// 3. space's `active_feature_set_id` → source = SpaceActive +/// +/// Pass `pinned_feature_set_id = None` to let the resolver fall through to +/// workspace/space default. +#[tauri::command] +pub async fn update_client_pin( + client_id: String, + pinned_space_id: String, + pinned_feature_set_id: Option, + state: State<'_, AppState>, +) -> Result<(), String> { + let client_uuid = Uuid::parse_str(&client_id).map_err(|e| e.to_string())?; + let space_uuid = Uuid::parse_str(&pinned_space_id).map_err(|e| e.to_string())?; + let fs_uuid = pinned_feature_set_id + .as_ref() + .map(|s| Uuid::parse_str(s)) + .transpose() + .map_err(|e| e.to_string())?; + + state + .client_repository + .set_pin(&client_uuid, &space_uuid, fs_uuid.as_ref()) + .await + .map_err(|e| { + tracing::error!("[update_client_pin] {e}"); + e.to_string() + })?; + + tracing::info!( + client_id = %client_id, + pinned_space_id = %pinned_space_id, + pinned_feature_set_id = ?pinned_feature_set_id, + "[update_client_pin] pin updated", + ); + Ok(()) +} + /// Delete a client. #[tauri::command] pub async fn delete_client(id: String, state: State<'_, AppState>) -> Result<(), String> { diff --git a/apps/desktop/src-tauri/src/commands/mod.rs b/apps/desktop/src-tauri/src/commands/mod.rs index 7e775b7..bdee1a8 100644 --- a/apps/desktop/src-tauri/src/commands/mod.rs +++ b/apps/desktop/src-tauri/src/commands/mod.rs @@ -19,6 +19,7 @@ pub mod server_feature; pub mod server_manager; pub mod settings; pub mod space; +pub mod workspace_binding; // Re-export commands for convenience pub use client::*; @@ -36,3 +37,4 @@ pub use server_feature::*; pub use server_manager::*; pub use settings::*; pub use space::*; +pub use workspace_binding::*; diff --git a/apps/desktop/src-tauri/src/commands/space.rs b/apps/desktop/src-tauri/src/commands/space.rs index 7f0bbfc..de5b42c 100644 --- a/apps/desktop/src-tauri/src/commands/space.rs +++ b/apps/desktop/src-tauri/src/commands/space.rs @@ -179,6 +179,41 @@ pub async fn get_active_space(state: State<'_, AppState>) -> Result, + state: State<'_, AppState>, +) -> Result<(), String> { + let space_uuid = Uuid::parse_str(&space_id).map_err(|e| e.to_string())?; + let fs_uuid = feature_set_id + .as_ref() + .map(|s| Uuid::parse_str(s)) + .transpose() + .map_err(|e| e.to_string())?; + + state + .space_service + .set_active_feature_set(&space_uuid, fs_uuid.as_ref()) + .await + .map_err(|e| { + tracing::error!("[set_space_active_feature_set] {e}"); + e.to_string() + })?; + + info!( + space_id = %space_id, + feature_set_id = ?feature_set_id, + "[set_space_active_feature_set] active FS updated", + ); + Ok(()) +} + /// Set the active space. #[tauri::command] pub async fn set_active_space( diff --git a/apps/desktop/src-tauri/src/commands/workspace_binding.rs b/apps/desktop/src-tauri/src/commands/workspace_binding.rs new file mode 100644 index 0000000..eb02c3a --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/workspace_binding.rs @@ -0,0 +1,147 @@ +//! Tauri commands for workspace-root FeatureSet bindings. +//! +//! Bindings are the middle tier of the FeatureSet resolver (pin > binding > +//! space-active). Paths passed in from the UI are normalized via +//! [`mcpmux_core::normalize_workspace_root`] before storage so lookups from +//! the gateway's session registry hit them consistently. + +use mcpmux_core::{normalize_workspace_root, WorkspaceBinding}; +use serde::{Deserialize, Serialize}; +use tauri::State; +use tracing::{error, info}; +use uuid::Uuid; + +use crate::state::AppState; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkspaceBindingDto { + pub id: String, + pub space_id: String, + pub workspace_root: String, + pub feature_set_id: String, + pub created_at: String, + pub updated_at: String, +} + +impl From for WorkspaceBindingDto { + fn from(b: WorkspaceBinding) -> Self { + Self { + id: b.id.to_string(), + space_id: b.space_id.to_string(), + workspace_root: b.workspace_root, + feature_set_id: b.feature_set_id.to_string(), + created_at: b.created_at.to_rfc3339(), + updated_at: b.updated_at.to_rfc3339(), + } + } +} + +/// List every binding across all Spaces. +#[tauri::command] +pub async fn list_workspace_bindings( + state: State<'_, AppState>, +) -> Result, String> { + state + .workspace_binding_repository + .list() + .await + .map(|v| v.into_iter().map(Into::into).collect()) + .map_err(|e| { + error!("[workspace_binding::list] {e}"); + e.to_string() + }) +} + +/// List bindings for a specific Space. +#[tauri::command] +pub async fn list_workspace_bindings_for_space( + space_id: String, + state: State<'_, AppState>, +) -> Result, String> { + let space_uuid = Uuid::parse_str(&space_id).map_err(|e| e.to_string())?; + state + .workspace_binding_repository + .list_for_space(&space_uuid) + .await + .map(|v| v.into_iter().map(Into::into).collect()) + .map_err(|e| e.to_string()) +} + +/// Create a new binding. `workspace_root` is normalized before storage. +#[tauri::command] +pub async fn create_workspace_binding( + space_id: String, + workspace_root: String, + feature_set_id: String, + state: State<'_, AppState>, +) -> Result { + let space_uuid = Uuid::parse_str(&space_id).map_err(|e| e.to_string())?; + let fs_uuid = Uuid::parse_str(&feature_set_id).map_err(|e| e.to_string())?; + let normalized = normalize_workspace_root(&workspace_root); + if normalized.is_empty() { + return Err("workspace_root cannot be empty".into()); + } + let binding = WorkspaceBinding::new(space_uuid, normalized, fs_uuid); + state + .workspace_binding_repository + .create(&binding) + .await + .map_err(|e| e.to_string())?; + info!( + space_id = %binding.space_id, + workspace_root = %binding.workspace_root, + feature_set_id = %binding.feature_set_id, + "[workspace_binding] created", + ); + Ok(binding.into()) +} + +/// Update an existing binding (e.g., point it at a different FS). +#[tauri::command] +pub async fn update_workspace_binding( + id: String, + workspace_root: String, + feature_set_id: String, + state: State<'_, AppState>, +) -> Result { + let id_uuid = Uuid::parse_str(&id).map_err(|e| e.to_string())?; + let fs_uuid = Uuid::parse_str(&feature_set_id).map_err(|e| e.to_string())?; + let normalized = normalize_workspace_root(&workspace_root); + if normalized.is_empty() { + return Err("workspace_root cannot be empty".into()); + } + let existing = state + .workspace_binding_repository + .get(&id_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("binding not found: {}", id))?; + let updated = WorkspaceBinding { + id: existing.id, + space_id: existing.space_id, + workspace_root: normalized, + feature_set_id: fs_uuid, + created_at: existing.created_at, + updated_at: chrono::Utc::now(), + }; + state + .workspace_binding_repository + .update(&updated) + .await + .map_err(|e| e.to_string())?; + Ok(updated.into()) +} + +/// Delete a binding by id. +#[tauri::command] +pub async fn delete_workspace_binding( + id: String, + state: State<'_, AppState>, +) -> Result<(), String> { + let id_uuid = Uuid::parse_str(&id).map_err(|e| e.to_string())?; + state + .workspace_binding_repository + .delete(&id_uuid) + .await + .map_err(|e| e.to_string()) +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index ad5b178..5125332 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -736,6 +736,7 @@ pub fn run() { commands::delete_space, commands::get_active_space, commands::set_active_space, + commands::set_space_active_feature_set, commands::open_space_config_file, commands::read_space_config, commands::save_space_config, @@ -793,6 +794,13 @@ pub fn run() { commands::get_all_client_grants, commands::grant_feature_set_to_client, commands::revoke_feature_set_from_client, + commands::update_client_pin, + // Workspace binding commands (resolver v2) + commands::list_workspace_bindings, + commands::list_workspace_bindings_for_space, + commands::create_workspace_binding, + commands::update_workspace_binding, + commands::delete_workspace_binding, // Config export commands commands::preview_config_export, commands::export_config_to_file, diff --git a/apps/desktop/src-tauri/src/state/mod.rs b/apps/desktop/src-tauri/src/state/mod.rs index f4dae3a..a0f58fc 100644 --- a/apps/desktop/src-tauri/src/state/mod.rs +++ b/apps/desktop/src-tauri/src/state/mod.rs @@ -8,12 +8,13 @@ use mcpmux_core::{ FeatureSetRepository, GatewayPortService, InboundMcpClientRepository, InstalledServerRepository, LogConfig, OutboundOAuthRepository, ServerDiscoveryService, ServerFeatureRepository as CoreServerFeatureRepository, ServerLogManager, SpaceRepository, - SpaceService, + SpaceService, WorkspaceBindingRepository, }; use mcpmux_storage::{ Database, FieldEncryptor, SqliteAppSettingsRepository, SqliteCredentialRepository, SqliteFeatureSetRepository, SqliteInboundMcpClientRepository, SqliteInstalledServerRepository, SqliteOutboundOAuthRepository, SqliteServerFeatureRepository, SqliteSpaceRepository, + SqliteWorkspaceBindingRepository, }; use std::path::PathBuf; use std::sync::Arc; @@ -48,6 +49,8 @@ pub struct AppState { pub feature_set_repository: Arc, /// Client repository for AI clients pub client_repository: Arc, + /// Workspace-root -> FeatureSet bindings (resolver v2) + pub workspace_binding_repository: Arc, /// Server feature repository for discovered MCP features (implements core trait) pub server_feature_repository: Arc, /// Server feature repository cast to core trait (for gateway services) @@ -103,6 +106,9 @@ impl AppState { let client_repository: Arc = Arc::new(SqliteInboundMcpClientRepository::new(db.clone())); + let workspace_binding_repository: Arc = + Arc::new(SqliteWorkspaceBindingRepository::new(db.clone())); + let server_feature_repository = Arc::new(SqliteServerFeatureRepository::new(db.clone())); let server_feature_repository_core: Arc = server_feature_repository.clone(); @@ -162,6 +168,7 @@ impl AppState { backend_oauth_repository, feature_set_repository, client_repository, + workspace_binding_repository, server_feature_repository, server_feature_repository_core, encryptor, diff --git a/apps/desktop/src/features/featuresets/FeatureSetsPage.tsx b/apps/desktop/src/features/featuresets/FeatureSetsPage.tsx index 92587e3..c8cd410 100644 --- a/apps/desktop/src/features/featuresets/FeatureSetsPage.tsx +++ b/apps/desktop/src/features/featuresets/FeatureSetsPage.tsx @@ -11,6 +11,7 @@ import { Star, Search, AlertCircle, + CheckCircle2, } from 'lucide-react'; import { Card, @@ -28,6 +29,7 @@ import { deleteFeatureSet, getFeatureSetWithMembers, } from '@/lib/api/featureSets'; +import { setSpaceActiveFeatureSet, getSpace } from '@/lib/api/spaces'; import { useViewSpace } from '@/stores'; import { FeatureSetPanel } from './FeatureSetPanel'; @@ -81,6 +83,11 @@ export function FeatureSetsPage() { // Panel state const [selectedFeatureSet, setSelectedFeatureSet] = useState(null); + // Resolver v2: which FS is the Space's active fallback. + // Tracked locally so the "Active" badge updates immediately after clicking + // "Set Active" without waiting for a refetch of the whole viewSpace. + const [activeFeatureSetId, setActiveFeatureSetId] = useState(null); + const loadData = useCallback(async (spaceId?: string) => { setIsLoading(true); setError(null); @@ -93,6 +100,11 @@ export function FeatureSetsPage() { // Backend filters out server-all feature sets for disabled servers const data = await listFeatureSetsBySpace(spaceId); setFeatureSets(data); + + // Fetch the Space's active FS for the "Active" badge. The + // viewSpace from the store may be stale, so we fetch fresh here. + const space = await getSpace(spaceId); + setActiveFeatureSetId(space?.active_feature_set_id ?? null); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { @@ -100,6 +112,23 @@ export function FeatureSetsPage() { } }, []); + const handleSetActive = async (fs: FeatureSet, event: React.MouseEvent) => { + // Stop card-click propagation so we don't open the panel. + event.stopPropagation(); + if (!viewSpace) return; + const previous = activeFeatureSetId; + // Optimistic update — revert on failure. + setActiveFeatureSetId(fs.id); + try { + await setSpaceActiveFeatureSet(viewSpace.id, fs.id); + success('Active FeatureSet updated', `"${fs.name}" is now the default for this space`); + } catch (e) { + setActiveFeatureSetId(previous); + const msg = e instanceof Error ? e.message : String(e); + showError('Failed to set active FeatureSet', msg); + } + }; + useEffect(() => { setSelectedFeatureSet(null); setShowCreateModal(false); @@ -290,13 +319,14 @@ export function FeatureSetsPage() { {filteredSets.map((fs) => { const isSelected = selectedFeatureSet?.id === fs.id; const isBuiltin = fs.is_builtin; - + const isActive = activeFeatureSetId === fs.id; + return ( - handleOpenPanel(fs)} data-testid={`featureset-card-${fs.id}`} > @@ -309,10 +339,19 @@ export function FeatureSetsPage() {

{fs.name} + {isActive && ( + + Active + + )}

{getFeatureSetTypeName(fs.feature_set_type)} @@ -336,13 +375,26 @@ export function FeatureSetsPage() { {fs.members?.length || 0} members )}
- {isBuiltin && fs.feature_set_type !== 'default' ? ( - Auto-managed - ) : ( - - Configure - - )} +
+ {!isActive && ( + + )} + {isBuiltin && fs.feature_set_type !== 'default' ? ( + Auto-managed + ) : ( + + Configure + + )} +
diff --git a/apps/desktop/src/features/workspaces/WorkspacesPage.tsx b/apps/desktop/src/features/workspaces/WorkspacesPage.tsx new file mode 100644 index 0000000..839bde9 --- /dev/null +++ b/apps/desktop/src/features/workspaces/WorkspacesPage.tsx @@ -0,0 +1,240 @@ +import { useCallback, useEffect, useState } from 'react'; +import { FolderOpen, Loader2, Plus, Trash2 } from 'lucide-react'; +import { Button, Card, CardContent, CardHeader, CardTitle, useToast, ToastContainer } from '@mcpmux/ui'; +import { + listWorkspaceBindingsForSpace, + createWorkspaceBinding, + deleteWorkspaceBinding, + type WorkspaceBinding, +} from '@/lib/api/workspaceBindings'; +import { listFeatureSetsBySpace, type FeatureSet } from '@/lib/api/featureSets'; +import { useViewSpace } from '@/stores'; + +/** + * Workspaces page — CRUD for WorkspaceBinding (resolver v2, middle tier). + * + * A binding maps a normalized workspace root path to a FeatureSet. When an + * MCP client reports roots via the MCP `roots` capability, the gateway's + * FeatureSetResolver matches the longest-prefix binding for the client's + * Space and uses that FS — unless the client has an explicit pin, which + * always wins. + */ +export function WorkspacesPage() { + const viewSpace = useViewSpace(); + const [bindings, setBindings] = useState([]); + const [featureSets, setFeatureSets] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const { toasts, success, error: showError } = useToast(); + + // Create form state + const [showForm, setShowForm] = useState(false); + const [isCreating, setIsCreating] = useState(false); + const [formRoot, setFormRoot] = useState(''); + const [formFeatureSetId, setFormFeatureSetId] = useState(''); + + const loadData = useCallback(async (spaceId?: string) => { + setIsLoading(true); + setError(null); + try { + if (!spaceId) { + setBindings([]); + setFeatureSets([]); + return; + } + const [b, fs] = await Promise.all([ + listWorkspaceBindingsForSpace(spaceId), + listFeatureSetsBySpace(spaceId), + ]); + setBindings(b); + setFeatureSets(fs); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + loadData(viewSpace?.id); + }, [viewSpace?.id, loadData]); + + const handleCreate = async () => { + if (!viewSpace) return; + if (!formRoot.trim() || !formFeatureSetId) { + showError('Missing fields', 'Workspace root and FeatureSet are both required.'); + return; + } + setIsCreating(true); + try { + const created = await createWorkspaceBinding( + viewSpace.id, + formRoot.trim(), + formFeatureSetId + ); + setBindings((prev) => [...prev, created]); + setFormRoot(''); + setFormFeatureSetId(''); + setShowForm(false); + success('Binding created', created.workspace_root); + } catch (e) { + showError('Failed to create binding', e instanceof Error ? e.message : String(e)); + } finally { + setIsCreating(false); + } + }; + + const handleDelete = async (binding: WorkspaceBinding) => { + try { + await deleteWorkspaceBinding(binding.id); + setBindings((prev) => prev.filter((b) => b.id !== binding.id)); + success('Binding removed', binding.workspace_root); + } catch (e) { + showError('Failed to remove binding', e instanceof Error ? e.message : String(e)); + } + }; + + const featureSetName = (id: string) => + featureSets.find((fs) => fs.id === id)?.name ?? id; + + if (!viewSpace) { + return ( +
+

Select a Space to manage workspace bindings.

+
+ ); + } + + return ( +
+ toasts.find((t) => t.id === id)?.onClose(id)} + /> + +
+
+

+ Workspace Bindings +

+

+ Bind a workspace folder to a FeatureSet. When an MCP client reports this + folder as one of its roots, the gateway uses the bound FeatureSet — unless + the client's access key has an explicit pin, which always wins. +

+
+ +
+ + {error && ( + + {error} + + )} + + {showForm && ( + + + New binding + + +
+ + setFormRoot(e.target.value)} + placeholder="/home/me/projects/android-app or D:\\work\\api" + className="w-full px-3 py-2 border border-[rgb(var(--border))] rounded bg-[rgb(var(--surface))] text-sm" + data-testid="workspace-binding-root-input" + /> +

+ Will be normalized: Windows drive letters are lowercased, trailing separators stripped, + and file:// URIs converted to paths. +

+
+
+ + +
+
+ + +
+
+
+ )} + + {isLoading ? ( +
+ +
+ ) : bindings.length === 0 ? ( + + + +

No workspace bindings yet

+

+ Without a binding, the resolver falls back to the Space's active FeatureSet for + every roots-capable client. +

+
+
+ ) : ( +
+ {bindings.map((b) => ( + +
+

{b.workspace_root}

+

+ → {featureSetName(b.feature_set_id)} +

+
+ +
+ ))} +
+ )} +
+ ); +} diff --git a/apps/desktop/src/features/workspaces/index.ts b/apps/desktop/src/features/workspaces/index.ts new file mode 100644 index 0000000..b4710de --- /dev/null +++ b/apps/desktop/src/features/workspaces/index.ts @@ -0,0 +1 @@ +export { WorkspacesPage } from './WorkspacesPage'; diff --git a/apps/desktop/src/lib/api/clients.ts b/apps/desktop/src/lib/api/clients.ts index d24192f..0c824d7 100644 --- a/apps/desktop/src/lib/api/clients.ts +++ b/apps/desktop/src/lib/api/clients.ts @@ -9,7 +9,18 @@ export interface Client { client_type: string; connection_mode: 'locked' | 'follow_active' | 'ask_on_change'; locked_space_id: string | null; - grants: Record; // space_id -> feature_set_ids + grants: Record; // space_id -> feature_set_ids (legacy, to be removed) + /** + * Resolver v2: Space this access key belongs to (chosen at approval). + * `null` on legacy pre-migration clients — resolver falls through to the + * default Space. + */ + pinned_space_id: string | null; + /** + * Resolver v2: explicit FS pin. `null` means "follow workspace binding / + * space active FS". + */ + pinned_feature_set_id: string | null; last_seen: string | null; } @@ -127,3 +138,26 @@ export async function updateClientMode( export async function initPresetClients(): Promise { return invoke('init_preset_clients'); } + +/** + * Pin a client to a Space + optional FeatureSet (resolver v2). + * + * Precedence used by the gateway's FeatureSetResolver: + * 1. pinned_feature_set_id (this pin) → source = Pin + * 2. workspace binding matches a reported root → source = WorkspaceBinding + * 3. space.active_feature_set_id → source = SpaceActive + * + * Pass `pinnedFeatureSetId = undefined` to let the resolver fall through to + * workspace/space default. + */ +export async function updateClientPin( + clientId: string, + pinnedSpaceId: string, + pinnedFeatureSetId?: string | null +): Promise { + return invoke('update_client_pin', { + clientId, + pinnedSpaceId, + pinnedFeatureSetId: pinnedFeatureSetId ?? null, + }); +} diff --git a/apps/desktop/src/lib/api/index.ts b/apps/desktop/src/lib/api/index.ts index 03bbbdb..3a77bb1 100644 --- a/apps/desktop/src/lib/api/index.ts +++ b/apps/desktop/src/lib/api/index.ts @@ -8,3 +8,4 @@ export * from './clientInstall'; export * from './clients'; export * from './gateway'; export * from './serverManager'; +export * from './workspaceBindings'; diff --git a/apps/desktop/src/lib/api/spaces.ts b/apps/desktop/src/lib/api/spaces.ts index 315084f..bf6c8f2 100644 --- a/apps/desktop/src/lib/api/spaces.ts +++ b/apps/desktop/src/lib/api/spaces.ts @@ -10,6 +10,11 @@ export interface Space { description: string | null; is_default: boolean; sort_order: number; + /** + * Resolver v2: fallback FS applied when a connected client has no pin + * and no workspace-binding match. `null` → deny-by-default. + */ + active_feature_set_id: string | null; created_at: string; updated_at: string; } @@ -56,6 +61,23 @@ export async function setActiveSpace(id: string): Promise { return invoke('set_active_space', { id }); } +/** + * Set (or clear with `null`) the active FeatureSet for a Space. + * + * The active FS is the fallback applied when a connected client has no + * access-key pin and no workspace-binding match. See the FeatureSetResolver + * for the full precedence (pin > binding > active). + */ +export async function setSpaceActiveFeatureSet( + spaceId: string, + featureSetId: string | null +): Promise { + return invoke('set_space_active_feature_set', { + spaceId, + featureSetId, + }); +} + /** * Read space configuration JSON file. */ diff --git a/apps/desktop/src/lib/api/workspaceBindings.ts b/apps/desktop/src/lib/api/workspaceBindings.ts new file mode 100644 index 0000000..ab315c7 --- /dev/null +++ b/apps/desktop/src/lib/api/workspaceBindings.ts @@ -0,0 +1,63 @@ +import { invoke } from '@tauri-apps/api/core'; + +/** + * A WorkspaceBinding maps a normalized filesystem path (the workspace root + * reported by an MCP client via `roots/list`) to a FeatureSet. Bindings are + * the middle tier of resolver v2: pin > binding > space-active. + */ +export interface WorkspaceBinding { + id: string; + space_id: string; + workspace_root: string; + feature_set_id: string; + created_at: string; + updated_at: string; +} + +/** List every binding across all Spaces. */ +export async function listWorkspaceBindings(): Promise { + return invoke('list_workspace_bindings'); +} + +/** List bindings for a specific Space. */ +export async function listWorkspaceBindingsForSpace( + spaceId: string +): Promise { + return invoke('list_workspace_bindings_for_space', { spaceId }); +} + +/** + * Create a new binding. `workspaceRoot` is normalized on the Rust side + * (Windows drive letter case-folded, `file://` scheme stripped, trailing + * separator trimmed) so callers can pass whatever the OS or MCP client + * reports and rely on consistent matching later. + */ +export async function createWorkspaceBinding( + spaceId: string, + workspaceRoot: string, + featureSetId: string +): Promise { + return invoke('create_workspace_binding', { + spaceId, + workspaceRoot, + featureSetId, + }); +} + +/** Update an existing binding's path or FeatureSet. */ +export async function updateWorkspaceBinding( + id: string, + workspaceRoot: string, + featureSetId: string +): Promise { + return invoke('update_workspace_binding', { + id, + workspaceRoot, + featureSetId, + }); +} + +/** Delete a binding by id. */ +export async function deleteWorkspaceBinding(id: string): Promise { + return invoke('delete_workspace_binding', { id }); +} diff --git a/crates/mcpmux-core/src/service/space_service.rs b/crates/mcpmux-core/src/service/space_service.rs index 233046c..4595dab 100644 --- a/crates/mcpmux-core/src/service/space_service.rs +++ b/crates/mcpmux-core/src/service/space_service.rs @@ -100,4 +100,18 @@ impl SpaceService { pub async fn set_active(&self, id: &Uuid) -> anyhow::Result<()> { self.repository.set_default(id).await } + + /// Set (or clear with `None`) the active FeatureSet for a Space. + /// + /// This is the fallback FS applied to every connected client when no + /// access-key pin and no workspace-root binding matches. + pub async fn set_active_feature_set( + &self, + space_id: &Uuid, + feature_set_id: Option<&Uuid>, + ) -> anyhow::Result<()> { + self.repository + .set_active_feature_set(space_id, feature_set_id) + .await + } } diff --git a/crates/mcpmux-gateway/src/mcp/handler.rs b/crates/mcpmux-gateway/src/mcp/handler.rs index 6f7bfa9..c297ee4 100644 --- a/crates/mcpmux-gateway/src/mcp/handler.rs +++ b/crates/mcpmux-gateway/src/mcp/handler.rs @@ -278,7 +278,11 @@ impl ServerHandler for McpMuxGatewayHandler { let feature_set_ids = self .services .authorization_service - .get_client_grants(&oauth_ctx.client_id, &oauth_ctx.space_id) + .get_client_grants( + &oauth_ctx.client_id, + &oauth_ctx.space_id, + extract_session_id(&context.extensions).as_deref(), + ) .await .map_err(|e| McpError::internal_error(format!("Failed to get grants: {}", e), None))?; @@ -335,7 +339,11 @@ impl ServerHandler for McpMuxGatewayHandler { let feature_set_ids = self .services .authorization_service - .get_client_grants(&oauth_ctx.client_id, &oauth_ctx.space_id) + .get_client_grants( + &oauth_ctx.client_id, + &oauth_ctx.space_id, + extract_session_id(&context.extensions).as_deref(), + ) .await .map_err(|e| McpError::internal_error(format!("Failed to get grants: {}", e), None))?; @@ -426,7 +434,11 @@ impl ServerHandler for McpMuxGatewayHandler { let feature_set_ids = self .services .authorization_service - .get_client_grants(&oauth_ctx.client_id, &oauth_ctx.space_id) + .get_client_grants( + &oauth_ctx.client_id, + &oauth_ctx.space_id, + extract_session_id(&context.extensions).as_deref(), + ) .await .map_err(|e| McpError::internal_error(format!("Failed to get grants: {}", e), None))?; @@ -483,7 +495,11 @@ impl ServerHandler for McpMuxGatewayHandler { let feature_set_ids = self .services .authorization_service - .get_client_grants(&oauth_ctx.client_id, &oauth_ctx.space_id) + .get_client_grants( + &oauth_ctx.client_id, + &oauth_ctx.space_id, + extract_session_id(&context.extensions).as_deref(), + ) .await .map_err(|e| McpError::internal_error(format!("Failed to get grants: {}", e), None))?; @@ -541,7 +557,11 @@ impl ServerHandler for McpMuxGatewayHandler { let feature_set_ids = self .services .authorization_service - .get_client_grants(&oauth_ctx.client_id, &oauth_ctx.space_id) + .get_client_grants( + &oauth_ctx.client_id, + &oauth_ctx.space_id, + extract_session_id(&context.extensions).as_deref(), + ) .await .map_err(|e| McpError::internal_error(format!("Failed to get grants: {}", e), None))?; @@ -601,7 +621,11 @@ impl ServerHandler for McpMuxGatewayHandler { let feature_set_ids = self .services .authorization_service - .get_client_grants(&oauth_ctx.client_id, &oauth_ctx.space_id) + .get_client_grants( + &oauth_ctx.client_id, + &oauth_ctx.space_id, + extract_session_id(&context.extensions).as_deref(), + ) .await .map_err(|e| McpError::internal_error(format!("Failed to get grants: {}", e), None))?; diff --git a/crates/mcpmux-gateway/src/server/handlers.rs b/crates/mcpmux-gateway/src/server/handlers.rs index 22376b2..d367e6a 100644 --- a/crates/mcpmux-gateway/src/server/handlers.rs +++ b/crates/mcpmux-gateway/src/server/handlers.rs @@ -1049,11 +1049,14 @@ pub async fn oauth_get_client_features( space_id, client_id ); - // Step 2: Get client grants (SRP: AuthorizationService) + // Step 2: Get client grants via the resolver. + // No MCP session context here (this is an HTTP API endpoint for the + // desktop UI), so workspace-binding resolution is skipped; we fall + // through to Space.active_feature_set_id. let feature_set_ids = match state .services .authorization_service - .get_client_grants(&client_id, &space_id) + .get_client_grants(&client_id, &space_id, None) .await { Ok(grants) => grants, diff --git a/crates/mcpmux-gateway/src/server/service_container.rs b/crates/mcpmux-gateway/src/server/service_container.rs index ca4f4c5..9ce175b 100644 --- a/crates/mcpmux-gateway/src/server/service_container.rs +++ b/crates/mcpmux-gateway/src/server/service_container.rs @@ -94,13 +94,7 @@ impl ServiceContainer { prefix_cache_service.clone(), )); - // Create authorization service (DIP: inject repository dependencies) - let authorization_service = Arc::new(AuthorizationService::new( - deps.inbound_client_repo.clone(), - deps.feature_set_repo.clone(), - )); - - // Resolver v2 — runs in shadow mode alongside AuthorizationService. + // Resolver v2 — now authoritative. AuthorizationService delegates here. let session_roots = SessionRootsRegistry::new(); let feature_set_resolver = Arc::new(FeatureSetResolverService::new( deps.inbound_mcp_client_repo.clone(), @@ -109,6 +103,10 @@ impl ServiceContainer { session_roots.clone(), )); + // Authorization service is now a thin adapter over the resolver. + let authorization_service = + Arc::new(AuthorizationService::new(feature_set_resolver.clone())); + // Create space resolver service (DIP: inject repository dependencies) let space_resolver_service = Arc::new(SpaceResolverService::new( deps.inbound_client_repo.clone(), diff --git a/crates/mcpmux-gateway/src/services/authorization.rs b/crates/mcpmux-gateway/src/services/authorization.rs index 83cf5c6..1e3447a 100644 --- a/crates/mcpmux-gateway/src/services/authorization.rs +++ b/crates/mcpmux-gateway/src/services/authorization.rs @@ -1,73 +1,61 @@ //! Authorization Service //! -//! Responsible for checking client permissions (grants) for accessing features. -//! Follows SRP: Single responsibility is authorization checking. -//! Follows DIP: Depends on repository abstractions, not concrete implementations. +//! Delegates permission resolution to [`FeatureSetResolverService`] (pin > +//! workspace binding > space-active FS). The old per-client grants table is +//! no longer consulted — see migration 003 for its removal. +//! +//! This service remains as a thin adapter so existing callers that take a +//! `Vec` continue to work: the resolver yields at most one +//! FeatureSet, which we wrap in a Vec. use anyhow::Result; -use mcpmux_core::FeatureSetRepository; -use mcpmux_storage::InboundClientRepository; use std::sync::Arc; use uuid::Uuid; -/// Authorization service for checking client permissions +use super::feature_set_resolver::FeatureSetResolverService; + +/// Authorization service for checking client permissions. /// -/// SRP: Only handles authorization decisions -/// DIP: Depends on repository abstractions +/// Backed by [`FeatureSetResolverService`]; no longer reads the legacy +/// `client_grants` table. pub struct AuthorizationService { - client_repo: Arc, - feature_set_repo: Arc, + resolver: Arc, } impl AuthorizationService { - pub fn new( - client_repo: Arc, - feature_set_repo: Arc, - ) -> Self { - Self { - client_repo, - feature_set_repo, - } + pub fn new(resolver: Arc) -> Self { + Self { resolver } } - /// Get effective feature set grants for a client in a specific space. + /// Resolve the active FeatureSet for a client+session and return it as a + /// one-element Vec (or empty when the resolver denies). /// - /// Resolution strategy (least-privilege by default): - /// 1. Return explicit per-client grants from DB if any exist. - /// 2. Always include the Default feature set as a baseline. - /// - /// Clients with no explicit grants only receive the Default feature set, - /// which starts empty (no features). The user must explicitly grant - /// additional feature sets (e.g. "All", "ServerAll", or custom sets) - /// through the UI to expose tools/prompts/resources to a client. - /// This avoids accidental exposure of all server capabilities. - pub async fn get_client_grants(&self, client_id: &str, space_id: &Uuid) -> Result> { - let space_id_str = space_id.to_string(); - - // Get explicit grants from DB - let mut grants = self - .client_repo - .get_grants_for_space(client_id, &space_id_str) - .await?; - - // Always include the Default feature set as baseline permissions. - // Default starts empty — user must explicitly grant additional access. - if let Some(default_fs) = self - .feature_set_repo - .get_default_for_space(&space_id_str) - .await? - { - if !grants.contains(&default_fs.id) { - grants.push(default_fs.id); - } - } - - Ok(grants) + /// `session_id` is the client's `mcp-session-id` header; pass `None` for + /// stateless callers (workspace-binding resolution will be skipped). + pub async fn get_client_grants( + &self, + client_id: &str, + _space_id: &Uuid, + session_id: Option<&str>, + ) -> Result> { + let client_uuid = Uuid::parse_str(client_id)?; + let resolved = self.resolver.resolve(&client_uuid, session_id).await?; + Ok(resolved + .feature_set_id + .map(|fs| vec![fs.to_string()]) + .unwrap_or_default()) } /// Check if a client has any grants in a space - pub async fn has_access(&self, client_id: &str, space_id: &Uuid) -> Result { - let grants = self.get_client_grants(client_id, space_id).await?; + pub async fn has_access( + &self, + client_id: &str, + space_id: &Uuid, + session_id: Option<&str>, + ) -> Result { + let grants = self + .get_client_grants(client_id, space_id, session_id) + .await?; Ok(!grants.is_empty()) } @@ -77,8 +65,11 @@ impl AuthorizationService { client_id: &str, space_id: &Uuid, feature_set_id: &str, + session_id: Option<&str>, ) -> Result { - let grants = self.get_client_grants(client_id, space_id).await?; + let grants = self + .get_client_grants(client_id, space_id, session_id) + .await?; Ok(grants.contains(&feature_set_id.to_string())) } } diff --git a/crates/mcpmux-storage/src/database.rs b/crates/mcpmux-storage/src/database.rs index 3af00a0..bfbcd11 100644 --- a/crates/mcpmux-storage/src/database.rs +++ b/crates/mcpmux-storage/src/database.rs @@ -43,6 +43,11 @@ const MIGRATIONS: &[Migration] = &[ name: "featureset_resolver", sql: include_str!("migrations/002_featureset_resolver.sql"), }, + Migration { + version: 3, + name: "drop_legacy_grants", + sql: include_str!("migrations/003_drop_legacy_grants.sql"), + }, ]; /// SQLite database wrapper. diff --git a/crates/mcpmux-storage/src/migrations/003_drop_legacy_grants.sql b/crates/mcpmux-storage/src/migrations/003_drop_legacy_grants.sql new file mode 100644 index 0000000..c8bc624 --- /dev/null +++ b/crates/mcpmux-storage/src/migrations/003_drop_legacy_grants.sql @@ -0,0 +1,21 @@ +-- Migration 003: Drop legacy per-client grants now that the FeatureSetResolver +-- is authoritative (see migration 002 for the new schema). +-- +-- The resolver (pin > workspace-binding > space-active) no longer consults +-- `client_grants`. The column and table below are safe to remove once every +-- deployed client has picked up the resolver v2 code path (shadow mode was +-- run in the previous release so divergence would already have surfaced). +-- +-- NOTE: Data loss is intentional. If you need to preserve the old grants for +-- audit, export them to JSON before running this migration. + +-- Drop the client_grants table. The resolver no longer reads it, and the +-- corresponding repository methods have been turned into no-ops so lingering +-- Tauri commands (grant_feature_set_to_client, etc.) won't error at runtime. +-- +-- We keep the `grants` JSON column on `inbound_clients` for now — it's +-- already unused in reads/writes (SELECT uses the '{}' placeholder), and +-- leaving it avoids a second schema migration on older SQLite builds that +-- predate ALTER TABLE … DROP COLUMN. It will be removed in a future +-- migration once the Tauri surface is cleaned up. +DROP TABLE IF EXISTS client_grants; diff --git a/crates/mcpmux-storage/src/repositories/inbound_client_repository.rs b/crates/mcpmux-storage/src/repositories/inbound_client_repository.rs index 9e4cf45..03d2f2d 100644 --- a/crates/mcpmux-storage/src/repositories/inbound_client_repository.rs +++ b/crates/mcpmux-storage/src/repositories/inbound_client_repository.rs @@ -731,95 +731,44 @@ impl InboundClientRepository { } // ========================================================================= - // Client Grants (Feature Set Permissions) + // Client Grants (legacy — `client_grants` table dropped in migration 003) + // + // These methods are retained as no-ops so existing Tauri commands and + // services continue to compile. All permission decisions now flow through + // `FeatureSetResolverService` (pin > workspace binding > space-active FS). // ========================================================================= - /// Grant a feature set to a client in a specific space pub async fn grant_feature_set( &self, - client_id: &str, - space_id: &str, - feature_set_id: &str, + _client_id: &str, + _space_id: &str, + _feature_set_id: &str, ) -> Result<()> { - let db = self.db.lock().await; - let conn = db.connection(); - - conn.execute( - "INSERT OR IGNORE INTO client_grants (client_id, space_id, feature_set_id) - VALUES (?1, ?2, ?3)", - params![client_id, space_id, feature_set_id], - )?; - Ok(()) } - /// Revoke a feature set from a client in a specific space pub async fn revoke_feature_set( &self, - client_id: &str, - space_id: &str, - feature_set_id: &str, + _client_id: &str, + _space_id: &str, + _feature_set_id: &str, ) -> Result<()> { - let db = self.db.lock().await; - let conn = db.connection(); - - conn.execute( - "DELETE FROM client_grants - WHERE client_id = ?1 AND space_id = ?2 AND feature_set_id = ?3", - params![client_id, space_id, feature_set_id], - )?; - Ok(()) } - /// Get all grants for a client in a specific space pub async fn get_grants_for_space( &self, - client_id: &str, - space_id: &str, + _client_id: &str, + _space_id: &str, ) -> Result> { - let db = self.db.lock().await; - let conn = db.connection(); - - let mut stmt = conn.prepare( - "SELECT feature_set_id FROM client_grants - WHERE client_id = ?1 AND space_id = ?2", - )?; - - let grants = stmt - .query_map(params![client_id, space_id], |row| row.get::<_, String>(0))? - .collect::, _>>()?; - - Ok(grants) + Ok(Vec::new()) } - /// Get all grants for a client across all spaces pub async fn get_all_grants( &self, - client_id: &str, + _client_id: &str, ) -> Result>> { - let db = self.db.lock().await; - let conn = db.connection(); - - let mut stmt = conn.prepare( - "SELECT space_id, feature_set_id FROM client_grants - WHERE client_id = ?1 - ORDER BY space_id", - )?; - - let mut grants: std::collections::HashMap> = - std::collections::HashMap::new(); - - let rows = stmt.query_map(params![client_id], |row| { - Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) - })?; - - for row in rows { - let (space_id, feature_set_id) = row?; - grants.entry(space_id).or_default().push(feature_set_id); - } - - Ok(grants) + Ok(std::collections::HashMap::new()) } // ========================================================================= diff --git a/crates/mcpmux-storage/src/repositories/inbound_mcp_client_repository.rs b/crates/mcpmux-storage/src/repositories/inbound_mcp_client_repository.rs index e20f90c..55bcaa8 100644 --- a/crates/mcpmux-storage/src/repositories/inbound_mcp_client_repository.rs +++ b/crates/mcpmux-storage/src/repositories/inbound_mcp_client_repository.rs @@ -298,127 +298,59 @@ impl InboundMcpClientRepository for SqliteInboundMcpClientRepository { Ok(()) } + // ------------------------------------------------------------------ + // Legacy per-client grant methods. + // + // The `client_grants` table was dropped in migration 003 in favour of + // the FeatureSetResolver (pin > workspace binding > space active FS). + // These methods remain on the trait so upstream Tauri commands and + // services continue to compile; they are now no-ops. + // ------------------------------------------------------------------ + async fn grant_feature_set( &self, - client_id: &Uuid, - space_id: &str, - feature_set_id: &str, + _client_id: &Uuid, + _space_id: &str, + _feature_set_id: &str, ) -> Result<()> { - let db = self.db.lock().await; - let conn = db.connection(); - - conn.execute( - "INSERT OR IGNORE INTO client_grants (client_id, space_id, feature_set_id) - VALUES (?1, ?2, ?3)", - params![client_id.to_string(), space_id, feature_set_id], - )?; - Ok(()) } async fn revoke_feature_set( &self, - client_id: &Uuid, - space_id: &str, - feature_set_id: &str, + _client_id: &Uuid, + _space_id: &str, + _feature_set_id: &str, ) -> Result<()> { - let db = self.db.lock().await; - let conn = db.connection(); - - conn.execute( - "DELETE FROM client_grants - WHERE client_id = ?1 AND space_id = ?2 AND feature_set_id = ?3", - params![client_id.to_string(), space_id, feature_set_id], - )?; - Ok(()) } - async fn get_grants_for_space(&self, client_id: &Uuid, space_id: &str) -> Result> { - let db = self.db.lock().await; - let conn = db.connection(); - - let mut stmt = conn.prepare( - "SELECT feature_set_id FROM client_grants - WHERE client_id = ?1 AND space_id = ?2", - )?; - - let grants = stmt - .query_map(params![client_id.to_string(), space_id], |row| { - row.get::<_, String>(0) - })? - .collect::, _>>()?; - - Ok(grants) + async fn get_grants_for_space( + &self, + _client_id: &Uuid, + _space_id: &str, + ) -> Result> { + Ok(Vec::new()) } async fn get_all_grants( &self, - client_id: &Uuid, + _client_id: &Uuid, ) -> Result>> { - let db = self.db.lock().await; - let conn = db.connection(); - - let mut stmt = conn.prepare( - "SELECT space_id, feature_set_id FROM client_grants - WHERE client_id = ?1 - ORDER BY space_id", - )?; - - let mut grants: std::collections::HashMap> = - std::collections::HashMap::new(); - - let rows = stmt.query_map(params![client_id.to_string()], |row| { - Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) - })?; - - for row in rows { - let (space_id, feature_set_id) = row?; - grants.entry(space_id).or_default().push(feature_set_id); - } - - Ok(grants) + Ok(std::collections::HashMap::new()) } async fn set_grants_for_space( &self, - client_id: &Uuid, - space_id: &str, - feature_set_ids: &[String], + _client_id: &Uuid, + _space_id: &str, + _feature_set_ids: &[String], ) -> Result<()> { - let db = self.db.lock().await; - let conn = db.connection(); - - // Remove existing grants for this space - conn.execute( - "DELETE FROM client_grants WHERE client_id = ?1 AND space_id = ?2", - params![client_id.to_string(), space_id], - )?; - - // Insert new grants - for feature_set_id in feature_set_ids { - conn.execute( - "INSERT INTO client_grants (client_id, space_id, feature_set_id) - VALUES (?1, ?2, ?3)", - params![client_id.to_string(), space_id, feature_set_id], - )?; - } - Ok(()) } - async fn has_grants_for_space(&self, client_id: &Uuid, space_id: &str) -> Result { - let db = self.db.lock().await; - let conn = db.connection(); - - let count: i32 = conn.query_row( - "SELECT COUNT(*) FROM client_grants - WHERE client_id = ?1 AND space_id = ?2", - params![client_id.to_string(), space_id], - |row| row.get(0), - )?; - - Ok(count > 0) + async fn has_grants_for_space(&self, _client_id: &Uuid, _space_id: &str) -> Result { + Ok(false) } async fn set_pin( diff --git a/tests/rust/tests/integration/feature_set_resolver.rs b/tests/rust/tests/integration/feature_set_resolver.rs new file mode 100644 index 0000000..5d0b01b --- /dev/null +++ b/tests/rust/tests/integration/feature_set_resolver.rs @@ -0,0 +1,279 @@ +//! Decision-table tests for the FeatureSet resolver (pin > workspace > space-active > deny). +//! +//! Uses real SQLite repositories (via in-memory Database) rather than mocks so +//! we exercise the same code paths the gateway will at runtime. + +use std::sync::Arc; + +use mcpmux_core::{ + normalize_workspace_root, Client, FeatureSet, FeatureSetRepository, InboundMcpClientRepository, + Space, SpaceRepository, WorkspaceBinding, WorkspaceBindingRepository, +}; +use mcpmux_gateway::services::{FeatureSetResolverService, ResolutionSource, SessionRootsRegistry}; +use mcpmux_storage::{ + Database, SqliteFeatureSetRepository, SqliteInboundMcpClientRepository, SqliteSpaceRepository, + SqliteWorkspaceBindingRepository, +}; +use tokio::sync::Mutex; +use uuid::Uuid; + +/// Fixture wiring up real SQLite-backed repos + a resolver, with a Space that +/// already has an `active_feature_set_id` so the SpaceActive tier works. +struct Fixture { + resolver: FeatureSetResolverService, + session_roots: Arc, + client_repo: Arc, + space_repo: Arc, + binding_repo: Arc, + space_id: Uuid, + active_fs_id: Uuid, + other_fs_id: Uuid, + client_id: Uuid, +} + +impl Fixture { + async fn new() -> Self { + let db = Arc::new(Mutex::new(Database::open_in_memory().unwrap())); + + let space_repo: Arc = Arc::new(SqliteSpaceRepository::new(db.clone())); + let fs_repo: Arc = + Arc::new(SqliteFeatureSetRepository::new(db.clone())); + let client_repo: Arc = + Arc::new(SqliteInboundMcpClientRepository::new(db.clone())); + let binding_repo: Arc = + Arc::new(SqliteWorkspaceBindingRepository::new(db.clone())); + + // Use the default space (created by migration 001). + let default_space = space_repo.get_default().await.unwrap().unwrap(); + let space_id = default_space.id; + + // Create two custom FSes so we can distinguish Pin from SpaceActive. + let active_fs = FeatureSet::new_custom("Space Active FS", &space_id.to_string()); + let other_fs = FeatureSet::new_custom("Pinned FS", &space_id.to_string()); + fs_repo.create(&active_fs).await.unwrap(); + fs_repo.create(&other_fs).await.unwrap(); + let active_fs_id = Uuid::parse_str(&active_fs.id).unwrap(); + let other_fs_id = Uuid::parse_str(&other_fs.id).unwrap(); + + // Set Space's active FS. + space_repo + .set_active_feature_set(&space_id, Some(&active_fs_id)) + .await + .unwrap(); + + // Create a test client with pinned_space_id set. + let mut client = Client::new("test", "test-type"); + client.pinned_space_id = Some(space_id); + client_repo.create(&client).await.unwrap(); + // set_pin ensures the DB columns are actually populated (create() uses + // the legacy columns by default). + client_repo + .set_pin(&client.id, &space_id, None) + .await + .unwrap(); + + let session_roots = SessionRootsRegistry::new(); + let resolver = FeatureSetResolverService::new( + client_repo.clone(), + space_repo.clone(), + binding_repo.clone(), + session_roots.clone(), + ); + + Self { + resolver, + session_roots, + client_repo, + space_repo, + binding_repo, + space_id, + active_fs_id, + other_fs_id, + client_id: client.id, + } + } + + async fn set_pin(&self, fs: Option) { + self.client_repo + .set_pin(&self.client_id, &self.space_id, fs.as_ref()) + .await + .unwrap(); + } +} + +#[tokio::test] +async fn resolve_falls_through_to_space_active_when_no_pin_and_no_roots() { + let f = Fixture::new().await; + let r = f.resolver.resolve(&f.client_id, None).await.unwrap(); + assert_eq!(r.source, ResolutionSource::SpaceActive); + assert_eq!(r.feature_set_id, Some(f.active_fs_id)); +} + +#[tokio::test] +async fn resolve_pin_wins_over_space_active() { + let f = Fixture::new().await; + f.set_pin(Some(f.other_fs_id)).await; + + let r = f.resolver.resolve(&f.client_id, None).await.unwrap(); + assert_eq!(r.source, ResolutionSource::Pin); + assert_eq!(r.feature_set_id, Some(f.other_fs_id)); +} + +#[tokio::test] +async fn resolve_pin_wins_over_workspace_binding() { + let f = Fixture::new().await; + + // Binding matches our session root, but pin should still win. + let root = if cfg!(windows) { + "d:\\work\\proj" + } else { + "/work/proj" + }; + f.binding_repo + .create(&WorkspaceBinding::new( + f.space_id, + normalize_workspace_root(root), + f.other_fs_id, + )) + .await + .unwrap(); + f.session_roots.set("sess-1", [root]); + + f.set_pin(Some(f.active_fs_id)).await; + let r = f + .resolver + .resolve(&f.client_id, Some("sess-1")) + .await + .unwrap(); + assert_eq!(r.source, ResolutionSource::Pin); + assert_eq!(r.feature_set_id, Some(f.active_fs_id)); +} + +#[tokio::test] +async fn resolve_workspace_binding_beats_space_active_when_no_pin() { + let f = Fixture::new().await; + + let root = if cfg!(windows) { + "d:\\work\\proj" + } else { + "/work/proj" + }; + f.binding_repo + .create(&WorkspaceBinding::new( + f.space_id, + normalize_workspace_root(root), + f.other_fs_id, + )) + .await + .unwrap(); + f.session_roots.set("sess-2", [root]); + + let r = f + .resolver + .resolve(&f.client_id, Some("sess-2")) + .await + .unwrap(); + assert_eq!(r.source, ResolutionSource::WorkspaceBinding); + assert_eq!(r.feature_set_id, Some(f.other_fs_id)); +} + +#[tokio::test] +async fn resolve_deny_when_no_pin_no_binding_no_space_active() { + let f = Fixture::new().await; + // Clear the Space's active FS — last tier becomes Deny. + f.space_repo + .set_active_feature_set(&f.space_id, None) + .await + .unwrap(); + + let r = f.resolver.resolve(&f.client_id, None).await.unwrap(); + assert_eq!(r.source, ResolutionSource::Deny); + assert_eq!(r.feature_set_id, None); +} + +#[tokio::test] +async fn resolve_longest_prefix_wins_across_multiple_bindings() { + let f = Fixture::new().await; + + // Add two nested bindings in the same Space. + let (outer_root, inner_root) = if cfg!(windows) { + ("d:\\work", "d:\\work\\proj") + } else { + ("/work", "/work/proj") + }; + // outer -> active_fs (just any FS we have), inner -> other_fs + f.binding_repo + .create(&WorkspaceBinding::new( + f.space_id, + normalize_workspace_root(outer_root), + f.active_fs_id, + )) + .await + .unwrap(); + f.binding_repo + .create(&WorkspaceBinding::new( + f.space_id, + normalize_workspace_root(inner_root), + f.other_fs_id, + )) + .await + .unwrap(); + + // Caller reports a path inside the inner binding — longest prefix wins. + let deep = if cfg!(windows) { + "d:\\work\\proj\\src" + } else { + "/work/proj/src" + }; + f.session_roots.set("sess-deep", [deep]); + + let r = f + .resolver + .resolve(&f.client_id, Some("sess-deep")) + .await + .unwrap(); + assert_eq!(r.source, ResolutionSource::WorkspaceBinding); + assert_eq!(r.feature_set_id, Some(f.other_fs_id)); +} + +#[tokio::test] +async fn resolve_falls_through_when_roots_dont_match_any_binding() { + let f = Fixture::new().await; + + let bound = if cfg!(windows) { + "d:\\android" + } else { + "/android" + }; + let reported = if cfg!(windows) { + "d:\\cloudflare" + } else { + "/cloudflare" + }; + f.binding_repo + .create(&WorkspaceBinding::new( + f.space_id, + normalize_workspace_root(bound), + f.other_fs_id, + )) + .await + .unwrap(); + f.session_roots.set("sess-3", [reported]); + + let r = f + .resolver + .resolve(&f.client_id, Some("sess-3")) + .await + .unwrap(); + assert_eq!(r.source, ResolutionSource::SpaceActive); + assert_eq!(r.feature_set_id, Some(f.active_fs_id)); +} + +#[tokio::test] +async fn resolve_returns_deny_for_unknown_client() { + let f = Fixture::new().await; + let unknown = Uuid::new_v4(); + let r = f.resolver.resolve(&unknown, None).await.unwrap(); + assert_eq!(r.source, ResolutionSource::Deny); + assert!(r.feature_set_id.is_none()); +} diff --git a/tests/rust/tests/integration/mod.rs b/tests/rust/tests/integration/mod.rs index 6d05a3c..60a660e 100644 --- a/tests/rust/tests/integration/mod.rs +++ b/tests/rust/tests/integration/mod.rs @@ -10,4 +10,5 @@ mod feature_grants; mod feature_routing; +mod feature_set_resolver; mod mcp_flows; From 5416d86f57fce3be1bf51f0aabecf568d397f159 Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Mon, 20 Apr 2026 20:18:05 +0800 Subject: [PATCH 05/24] test: remove obsolete client_grants database tests Migration 003 drops the `client_grants` table and its repository methods are now no-op shims, so the 6 tests in `tests/database/inbound_client.rs` that exercised the legacy grant flow had nothing to assert against. Replaced with a pointer to the new resolver decision-table tests in `tests/integration/feature_set_resolver.rs`. Signed-off-by: Mohammod Al Amin Ashik --- tests/rust/tests/database/inbound_client.rs | 240 +------------------- 1 file changed, 9 insertions(+), 231 deletions(-) diff --git a/tests/rust/tests/database/inbound_client.rs b/tests/rust/tests/database/inbound_client.rs index 4eea1fe..f5eadb9 100644 --- a/tests/rust/tests/database/inbound_client.rs +++ b/tests/rust/tests/database/inbound_client.rs @@ -545,239 +545,17 @@ async fn test_revoke_client_tokens() { } // ============================================================================= -// Client Grants Tests (Feature Set Permissions) +// Client Grants Tests — REMOVED in migration 003. +// +// The `client_grants` table and the repository methods that backed it were +// dropped once the FeatureSetResolver (pin > workspace binding > space-active) +// became authoritative. The trait methods remain as no-op shims for API +// compatibility with Tauri commands, but they no longer persist anything. +// +// For resolver decision-table tests see +// `tests/integration/feature_set_resolver.rs`. // ============================================================================= -#[tokio::test] -async fn test_grant_feature_set() { - let test_db = TestDatabase::new(); - let db = Arc::new(Mutex::new(test_db.db)); - let repo = InboundClientRepository::new(Arc::clone(&db)); - let space_repo = SqliteSpaceRepository::new(db); - - // Create a space (auto-creates All and Default feature sets) - let space = fixtures::test_space("Test Space"); - SpaceRepository::create(&space_repo, &space).await.unwrap(); - - let client = create_test_client("Grant Client"); - repo.save_client(&client).await.unwrap(); - - // Grant the auto-created "All" feature set - let all_fs_id = format!("fs_all_{}", space.id); - repo.grant_feature_set(&client.client_id, &space.id.to_string(), &all_fs_id) - .await - .expect("Failed to grant"); - - // Check grants - let grants = repo - .get_grants_for_space(&client.client_id, &space.id.to_string()) - .await - .unwrap(); - assert_eq!(grants.len(), 1); - assert!(grants.contains(&all_fs_id)); -} - -#[tokio::test] -async fn test_grant_multiple_feature_sets() { - let test_db = TestDatabase::new(); - let db = Arc::new(Mutex::new(test_db.db)); - let repo = InboundClientRepository::new(Arc::clone(&db)); - let space_repo = SqliteSpaceRepository::new(db); - - // Create two spaces - let space1 = fixtures::test_space("Space 1"); - let space2 = fixtures::test_space("Space 2"); - SpaceRepository::create(&space_repo, &space1).await.unwrap(); - SpaceRepository::create(&space_repo, &space2).await.unwrap(); - - let client = create_test_client("Multi Grant"); - repo.save_client(&client).await.unwrap(); - - // Use auto-created feature set IDs - let space1_all = format!("fs_all_{}", space1.id); - let space1_default = format!("fs_default_{}", space1.id); - let space2_all = format!("fs_all_{}", space2.id); - - repo.grant_feature_set(&client.client_id, &space1.id.to_string(), &space1_all) - .await - .unwrap(); - repo.grant_feature_set(&client.client_id, &space1.id.to_string(), &space1_default) - .await - .unwrap(); - repo.grant_feature_set(&client.client_id, &space2.id.to_string(), &space2_all) - .await - .unwrap(); - - // Space 1 should have 2 - let grants1 = repo - .get_grants_for_space(&client.client_id, &space1.id.to_string()) - .await - .unwrap(); - assert_eq!(grants1.len(), 2); - - // Space 2 should have 1 - let grants2 = repo - .get_grants_for_space(&client.client_id, &space2.id.to_string()) - .await - .unwrap(); - assert_eq!(grants2.len(), 1); -} - -#[tokio::test] -async fn test_grant_idempotent() { - let test_db = TestDatabase::new(); - let db = Arc::new(Mutex::new(test_db.db)); - let repo = InboundClientRepository::new(Arc::clone(&db)); - let space_repo = SqliteSpaceRepository::new(db); - - let space = fixtures::test_space("Test Space"); - SpaceRepository::create(&space_repo, &space).await.unwrap(); - - let client = create_test_client("Idempotent"); - repo.save_client(&client).await.unwrap(); - - let all_fs_id = format!("fs_all_{}", space.id); - - // Grant same thing twice - repo.grant_feature_set(&client.client_id, &space.id.to_string(), &all_fs_id) - .await - .unwrap(); - repo.grant_feature_set(&client.client_id, &space.id.to_string(), &all_fs_id) - .await - .unwrap(); - - // Should still be 1 - let grants = repo - .get_grants_for_space(&client.client_id, &space.id.to_string()) - .await - .unwrap(); - assert_eq!(grants.len(), 1); -} - -#[tokio::test] -async fn test_revoke_feature_set() { - let test_db = TestDatabase::new(); - let db = Arc::new(Mutex::new(test_db.db)); - let repo = InboundClientRepository::new(Arc::clone(&db)); - let space_repo = SqliteSpaceRepository::new(db); - - let space = fixtures::test_space("Test Space"); - SpaceRepository::create(&space_repo, &space).await.unwrap(); - - let client = create_test_client("Revoke Grant"); - repo.save_client(&client).await.unwrap(); - - let all_fs_id = format!("fs_all_{}", space.id); - let default_fs_id = format!("fs_default_{}", space.id); - - repo.grant_feature_set(&client.client_id, &space.id.to_string(), &all_fs_id) - .await - .unwrap(); - repo.grant_feature_set(&client.client_id, &space.id.to_string(), &default_fs_id) - .await - .unwrap(); - - // Revoke one - repo.revoke_feature_set(&client.client_id, &space.id.to_string(), &all_fs_id) - .await - .expect("Failed to revoke"); - - // Only default remains - let grants = repo - .get_grants_for_space(&client.client_id, &space.id.to_string()) - .await - .unwrap(); - assert_eq!(grants.len(), 1); - assert!(grants.contains(&default_fs_id)); -} - -#[tokio::test] -async fn test_get_all_grants() { - let test_db = TestDatabase::new(); - let db = Arc::new(Mutex::new(test_db.db)); - let repo = InboundClientRepository::new(Arc::clone(&db)); - let space_repo = SqliteSpaceRepository::new(db); - - let space1 = fixtures::test_space("Space 1"); - let space2 = fixtures::test_space("Space 2"); - SpaceRepository::create(&space_repo, &space1).await.unwrap(); - SpaceRepository::create(&space_repo, &space2).await.unwrap(); - - let client = create_test_client("All Grants"); - repo.save_client(&client).await.unwrap(); - - let space1_all = format!("fs_all_{}", space1.id); - let space1_default = format!("fs_default_{}", space1.id); - let space2_all = format!("fs_all_{}", space2.id); - - repo.grant_feature_set(&client.client_id, &space1.id.to_string(), &space1_all) - .await - .unwrap(); - repo.grant_feature_set(&client.client_id, &space1.id.to_string(), &space1_default) - .await - .unwrap(); - repo.grant_feature_set(&client.client_id, &space2.id.to_string(), &space2_all) - .await - .unwrap(); - - let all_grants = repo - .get_all_grants(&client.client_id) - .await - .expect("Failed to get all"); - assert_eq!(all_grants.len(), 2); // 2 spaces - - assert_eq!(all_grants.get(&space1.id.to_string()).unwrap().len(), 2); - assert_eq!(all_grants.get(&space2.id.to_string()).unwrap().len(), 1); -} - -#[tokio::test] -async fn test_grants_per_space_isolation() { - let test_db = TestDatabase::new(); - let db = Arc::new(Mutex::new(test_db.db)); - let repo = InboundClientRepository::new(Arc::clone(&db)); - let space_repo = SqliteSpaceRepository::new(db); - - let work = fixtures::test_space("Work"); - let personal = fixtures::test_space("Personal"); - SpaceRepository::create(&space_repo, &work).await.unwrap(); - SpaceRepository::create(&space_repo, &personal) - .await - .unwrap(); - - let client = create_test_client("Space Isolation"); - repo.save_client(&client).await.unwrap(); - - let work_all = format!("fs_all_{}", work.id); - let personal_all = format!("fs_all_{}", personal.id); - - // Grant "All" in different spaces - repo.grant_feature_set(&client.client_id, &work.id.to_string(), &work_all) - .await - .unwrap(); - repo.grant_feature_set(&client.client_id, &personal.id.to_string(), &personal_all) - .await - .unwrap(); - - // Revoke from work only - repo.revoke_feature_set(&client.client_id, &work.id.to_string(), &work_all) - .await - .unwrap(); - - // Work should be empty - let work_grants = repo - .get_grants_for_space(&client.client_id, &work.id.to_string()) - .await - .unwrap(); - assert!(work_grants.is_empty()); - - // Personal still has grant - let personal_grants = repo - .get_grants_for_space(&client.client_id, &personal.id.to_string()) - .await - .unwrap(); - assert_eq!(personal_grants.len(), 1); -} - // ============================================================================= // Client Settings Update Tests // ============================================================================= From 8c740a5095a1c0ab5c435c4e634db045cddc2028 Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Tue, 21 Apr 2026 12:47:16 +0800 Subject: [PATCH 06/24] feat(gateway,desktop): mcpmux_* self-management meta tools with native approval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exposes a small built-in toolset (`mcpmux_*`) alongside every backend tool so LLMs can introspect and, with explicit user approval, reshape their own session's FeatureSet. Enabled by default. Read tools (no approval — always advertised): * mcpmux_list_all_tools — unfiltered view across connected servers * mcpmux_list_feature_sets — space's FSes w/ is_active + is_pinned * mcpmux_describe_resolution — current FS + why (pin | binding | active) * mcpmux_describe_workspace — reported MCP roots + matching binding Write tools (each gated by native desktop approval + diff preview): * mcpmux_pin_this_session — caller-scope, sets pinned_feature_set_id * mcpmux_create_feature_set — compose a custom FS from qualified names * mcpmux_bind_current_workspace — persistent WorkspaceBinding (space-wide) * mcpmux_set_space_active — flips space fallback (affects everyone) Gateway plumbing (`crates/mcpmux-gateway/src/services/meta_tools/`): * MetaTool trait + MetaToolRegistry. Handler intercepts `mcpmux_*` before routing, so meta tools are always visible regardless of the caller's resolved FS. * ApprovalBroker: session-scoped rate limit (10/min/client), oneshot request/response with 60s default timeout, session-only "always allow" cache keyed by (client_id, tool_name) — deliberately NOT persisted so gateway restarts re-prompt. * ToolDiff: before/after qualified-name comparison (via FeatureService) so approval dialogs show "68 tools removed, 0 added" instead of abstract FeatureSet names. * Write path emits `FeatureSetMembersChanged` → MCPNotifier pushes `tools/list_changed` → caller re-fetches the trimmed toolset in the next turn. Desktop (Tauri + React): * `commands/meta_tool_approval.rs` Tauri commands: - respond_to_meta_tool_approval(request_id, decision) - list_meta_tool_grants / revoke_meta_tool_grant * `start_gateway` attaches a publisher that emits `meta-tool-approval-request` events to the frontend. * `` — global React component (mounted once from `App.tsx`). Renders the summary + affect-other-clients warning + tool-list diff (+added / −removed, color-coded), with [Allow once] / [Always for this session] / [Deny] buttons. Queues concurrent requests. * GatewayAppState gains `approval_broker: Option>`, populated on gateway start. Tests (20 new passing): * `services::meta_tools::approval::tests` — 6 unit tests covering always-allow short-circuit, publisher allow/deny/timeout, headless no-desktop, and always-scope persistence across calls. * `tests/integration/meta_tools.rs` — 14 end-to-end tests with real SQLite repos + auto-approving publisher: - list_all_tools / list_feature_sets / describe_resolution / describe_workspace return correct payloads - write w/o publisher → approval_required - pin_this_session allow → pin persists; deny → unchanged - always-allow decision bypasses subsequent publisher calls - create_feature_set persists members only after approval - bind_current_workspace fails without roots; normalizes on success - set_space_active updates Space fallback - invalid UUID arg rejected - registry advertises all 8 tools with destructive_hint annotations Other: * `gateway_notifications::test_client_can_list_tools_after_notification` updated to filter `mcpmux_*` from its "empty toolset" assertion — meta tools are always present. * Total test count: 9 (mcpmux), 123 (core), 104 (gateway lib incl. the 6 approval tests), 66 (integration incl. 14 meta-tool tests), plus all existing suites green. Signed-off-by: Mohammod Al Amin Ashik --- .../desktop/src-tauri/src/commands/gateway.rs | 31 + .../src/commands/meta_tool_approval.rs | 111 +++ apps/desktop/src-tauri/src/commands/mod.rs | 2 + apps/desktop/src-tauri/src/lib.rs | 4 + apps/desktop/src/App.tsx | 3 + .../metaTools/MetaToolApprovalDialog.tsx | 220 ++++++ apps/desktop/src/features/metaTools/index.ts | 2 + crates/mcpmux-gateway/src/mcp/handler.rs | 38 +- crates/mcpmux-gateway/src/server/mod.rs | 6 + .../src/server/service_container.rs | 36 +- .../src/services/meta_tools/approval.rs | 451 +++++++++++ .../src/services/meta_tools/diff.rs | 76 ++ .../src/services/meta_tools/mod.rs | 86 ++ .../src/services/meta_tools/registry.rs | 200 +++++ .../src/services/meta_tools/tools.rs | 733 ++++++++++++++++++ crates/mcpmux-gateway/src/services/mod.rs | 5 + tests/rust/tests/integration/meta_tools.rs | 580 ++++++++++++++ tests/rust/tests/integration/mod.rs | 1 + .../streamable_http/gateway_notifications.rs | 24 +- 19 files changed, 2594 insertions(+), 15 deletions(-) create mode 100644 apps/desktop/src-tauri/src/commands/meta_tool_approval.rs create mode 100644 apps/desktop/src/features/metaTools/MetaToolApprovalDialog.tsx create mode 100644 apps/desktop/src/features/metaTools/index.ts create mode 100644 crates/mcpmux-gateway/src/services/meta_tools/approval.rs create mode 100644 crates/mcpmux-gateway/src/services/meta_tools/diff.rs create mode 100644 crates/mcpmux-gateway/src/services/meta_tools/mod.rs create mode 100644 crates/mcpmux-gateway/src/services/meta_tools/registry.rs create mode 100644 crates/mcpmux-gateway/src/services/meta_tools/tools.rs create mode 100644 tests/rust/tests/integration/meta_tools.rs diff --git a/apps/desktop/src-tauri/src/commands/gateway.rs b/apps/desktop/src-tauri/src/commands/gateway.rs index fd20f31..614d72c 100644 --- a/apps/desktop/src-tauri/src/commands/gateway.rs +++ b/apps/desktop/src-tauri/src/commands/gateway.rs @@ -56,6 +56,8 @@ pub struct GatewayAppState { pub event_emitter: Option>, /// Grant service for centralized grant management with auto-notifications pub grant_service: Option>, + /// Approval broker for meta-tool writes (publisher attached on gateway start) + pub approval_broker: Option>, } /// Start domain event bridge from Gateway to Tauri @@ -596,6 +598,34 @@ pub async fn start_gateway( let grant_service = server.grant_service(); info!("[Gateway] Got grant_service: {:p}", &*grant_service); + // Meta-tool approval broker — attach a Tauri-event publisher so + // incoming approval requests reach the React dialog. + let approval_broker = server.approval_broker(); + { + let app_handle_for_broker = app_handle.clone(); + let publisher: mcpmux_gateway::services::meta_tools::ApprovalPublisher = + std::sync::Arc::new(move |req| { + let app_handle = app_handle_for_broker.clone(); + Box::pin(async move { + // Emit the request; the React layer owns rendering + + // collecting the user's decision. Failure to emit means + // no desktop frontend is listening — broker maps that to + // "approval_required" to the calling tool. + match app_handle.emit("meta-tool-approval-request", &req) { + Ok(()) => true, + Err(e) => { + tracing::warn!( + error = %e, + "[meta-tool] failed to emit approval request" + ); + false + } + } + }) + }); + approval_broker.set_publisher(publisher).await; + } + // Start domain event bridge (clean architecture) start_domain_event_bridge(&app_handle, gw_state.clone()); @@ -615,6 +645,7 @@ pub async fn start_gateway( &*grant_service ); state.grant_service = Some(grant_service); + state.approval_broker = Some(approval_broker); info!( "[Gateway] grant_service set! Checking: {}", state.grant_service.is_some() diff --git a/apps/desktop/src-tauri/src/commands/meta_tool_approval.rs b/apps/desktop/src-tauri/src/commands/meta_tool_approval.rs new file mode 100644 index 0000000..568f108 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/meta_tool_approval.rs @@ -0,0 +1,111 @@ +//! Tauri commands for meta-tool approval dialogs. +//! +//! Flow: +//! 1. Gateway's [`ApprovalBroker`] emits `meta-tool-approval-request` +//! event (see gateway.rs `start_gateway`). +//! 2. React dialog renders it, user picks once/always/deny. +//! 3. Dialog calls [`respond_to_meta_tool_approval`], which resolves the +//! broker's oneshot channel and unblocks the calling tool. + +use std::sync::Arc; + +use mcpmux_gateway::services::ApprovalDecision; +use serde::Serialize; +use tauri::State; +use tokio::sync::RwLock; +use tracing::{info, warn}; +use uuid::Uuid; + +use crate::commands::gateway::GatewayAppState; + +#[derive(Debug, Serialize)] +pub struct MetaToolGrantEntry { + pub client_id: String, + pub tool_name: String, +} + +/// Resolve a pending approval dialog. +/// +/// `decision` is one of `"allow_once" | "always_for_this_session_and_client" | "deny"`. +/// Called from the React dialog. If the broker doesn't recognize the +/// request_id (e.g. it already timed out), returns a no-op success so the +/// UI can close its dialog cleanly. +#[tauri::command] +pub async fn respond_to_meta_tool_approval( + request_id: String, + client_id: String, + tool_name: String, + decision: String, + gateway_state: State<'_, Arc>>, +) -> Result { + let decision = match decision.as_str() { + "allow_once" => ApprovalDecision::AllowOnce, + "always_for_this_session_and_client" => ApprovalDecision::AlwaysForThisSessionAndClient, + "deny" => ApprovalDecision::Deny, + other => return Err(format!("unknown decision: {other}")), + }; + let client_uuid = Uuid::parse_str(&client_id).map_err(|e| format!("bad client_id: {e}"))?; + + let broker = { + let state = gateway_state.read().await; + state.approval_broker.clone() + }; + let Some(broker) = broker else { + warn!("[meta-tool] respond called but gateway is not running"); + return Ok(false); + }; + + let resolved = broker.respond(&request_id, client_uuid, &tool_name, decision); + info!( + %request_id, + %client_id, + tool = %tool_name, + ?decision, + resolved, + "[meta-tool] approval decision recorded" + ); + Ok(resolved) +} + +/// List every active "always allow from this client for this tool" grant. +/// +/// Entries are session-only (cleared on gateway restart by design). The +/// Connections page uses this to show a revoke list. +#[tauri::command] +pub async fn list_meta_tool_grants( + gateway_state: State<'_, Arc>>, +) -> Result, String> { + let broker = { + let state = gateway_state.read().await; + state.approval_broker.clone() + }; + let Some(broker) = broker else { + return Ok(vec![]); + }; + Ok(broker + .list_always_allow() + .into_iter() + .map(|(client_id, tool_name)| MetaToolGrantEntry { + client_id: client_id.to_string(), + tool_name, + }) + .collect()) +} + +/// Revoke an "always allow" entry. +#[tauri::command] +pub async fn revoke_meta_tool_grant( + client_id: String, + tool_name: String, + gateway_state: State<'_, Arc>>, +) -> Result { + let client_uuid = Uuid::parse_str(&client_id).map_err(|e| format!("bad client_id: {e}"))?; + let broker = { + let state = gateway_state.read().await; + state.approval_broker.clone() + }; + let Some(broker) = broker else { + return Ok(false); + }; + Ok(broker.revoke_always_allow(client_uuid, &tool_name)) +} diff --git a/apps/desktop/src-tauri/src/commands/mod.rs b/apps/desktop/src-tauri/src/commands/mod.rs index bdee1a8..5aa0e68 100644 --- a/apps/desktop/src-tauri/src/commands/mod.rs +++ b/apps/desktop/src-tauri/src/commands/mod.rs @@ -12,6 +12,7 @@ pub mod feature_members; pub mod feature_set; pub mod gateway; pub mod logs; +pub mod meta_tool_approval; pub mod oauth; pub mod server; pub mod server_discovery; @@ -30,6 +31,7 @@ pub use feature_members::*; pub use feature_set::*; pub use gateway::*; pub use logs::*; +pub use meta_tool_approval::*; pub use oauth::*; pub use server::*; pub use server_discovery::*; diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 5125332..b9a6a91 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -801,6 +801,10 @@ pub fn run() { commands::create_workspace_binding, commands::update_workspace_binding, commands::delete_workspace_binding, + // Meta-tool approval (self-management mcpmux_* tools) + commands::respond_to_meta_tool_approval, + commands::list_meta_tool_grants, + commands::revoke_meta_tool_grant, // Config export commands commands::preview_config_export, commands::export_config_to_file, diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 9521773..3408a69 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -42,6 +42,7 @@ import { ClientsPage } from '@/features/clients'; import { ServersPage } from '@/features/servers'; import { SpacesPage } from '@/features/spaces'; import { SettingsPage } from '@/features/settings'; +import { MetaToolApprovalDialog } from '@/features/metaTools'; import { useGatewayEvents, useServerStatusEvents } from '@/hooks/useDomainEvents'; /** McpMux title-bar icon — miniature cat icon */ @@ -353,6 +354,8 @@ function App() { {/* Server install modal - shown when install deep link is received */} + {/* Meta-tool approval dialog — gates every mcpmux_* write tool */} + ); } diff --git a/apps/desktop/src/features/metaTools/MetaToolApprovalDialog.tsx b/apps/desktop/src/features/metaTools/MetaToolApprovalDialog.tsx new file mode 100644 index 0000000..2b9c017 --- /dev/null +++ b/apps/desktop/src/features/metaTools/MetaToolApprovalDialog.tsx @@ -0,0 +1,220 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { listen } from '@tauri-apps/api/event'; +import { invoke } from '@tauri-apps/api/core'; +import { AlertTriangle, CheckCircle2, XCircle } from 'lucide-react'; +import { Button, Card, CardContent, CardHeader, CardTitle } from '@mcpmux/ui'; + +/** + * Incoming approval request emitted by the gateway's ApprovalBroker. + * Shape mirrors `mcpmux_gateway::services::ApprovalRequest`. + */ +export interface ApprovalRequest { + request_id: string; + client_id: string; + payload: { + tool_name: string; + summary: string; + diff: null | { + before: string[]; + after: string[]; + added: string[]; + removed: string[]; + }; + raw_args: unknown; + affects_other_clients: boolean; + }; + expires_at_unix_secs: number; +} + +type Decision = 'allow_once' | 'always_for_this_session_and_client' | 'deny'; + +/** + * Global listener that renders an approval dialog whenever the gateway + * asks for permission to run an `mcpmux_*` write tool. Place once, near the + * root of the app. + * + * The dialog queues multiple concurrent requests — if two clients request + * approval at the same time, the user sees them in order. + */ +export function MetaToolApprovalDialog() { + const [queue, setQueue] = useState([]); + const current = queue[0]; + + useEffect(() => { + const unlistenPromise = listen( + 'meta-tool-approval-request', + (event) => { + setQueue((prev) => [...prev, event.payload]); + } + ); + return () => { + unlistenPromise.then((fn) => fn()).catch(() => {}); + }; + }, []); + + const respond = useCallback( + async (decision: Decision) => { + if (!current) return; + try { + await invoke('respond_to_meta_tool_approval', { + requestId: current.request_id, + clientId: current.client_id, + toolName: current.payload.tool_name, + decision, + }); + } catch (e) { + // Log but don't block UI — broker will time out and surface + // `approval_timed_out` to the tool caller. + // eslint-disable-next-line no-console + console.warn('respond_to_meta_tool_approval failed', e); + } finally { + setQueue((prev) => prev.slice(1)); + } + }, + [current] + ); + + const diff = current?.payload.diff; + const toolCount = diff?.after.length ?? null; + const deltaLabel = useMemo(() => { + if (!diff) return null; + const added = diff.added.length; + const removed = diff.removed.length; + return `+${added} / -${removed}`; + }, [diff]); + + if (!current) return null; + + return ( +
+ + + + + An MCP client wants to change your tools + + + +
+

{current.payload.summary}

+

+ tool: {current.payload.tool_name} +

+
+ + {current.payload.affects_other_clients && ( +
+ + + This change affects every connection in this Space — not just + the one requesting it. Other connected clients will see a new + toolset on their next tools/list. + +
+ )} + + {diff && ( +
+
+ + + +
+ {(diff.added.length > 0 || diff.removed.length > 0) && ( +
+ {diff.added.map((t) => ( +
+ + {t} +
+ ))} + {diff.removed.map((t) => ( +
+ − {t} +
+ ))} +
+ )} +
+ )} + +
+ + + +
+ + {queue.length > 1 && ( +

+ {queue.length - 1} more pending… +

+ )} +
+
+
+ ); +} + +function Stat({ + label, + value, + emphasis, +}: { + label: string; + value: number | string; + emphasis?: boolean; +}) { + return ( +
+ + {label} + + + {value} + +
+ ); +} diff --git a/apps/desktop/src/features/metaTools/index.ts b/apps/desktop/src/features/metaTools/index.ts new file mode 100644 index 0000000..dfff054 --- /dev/null +++ b/apps/desktop/src/features/metaTools/index.ts @@ -0,0 +1,2 @@ +export { MetaToolApprovalDialog } from './MetaToolApprovalDialog'; +export type { ApprovalRequest } from './MetaToolApprovalDialog'; diff --git a/crates/mcpmux-gateway/src/mcp/handler.rs b/crates/mcpmux-gateway/src/mcp/handler.rs index c297ee4..425b4bf 100644 --- a/crates/mcpmux-gateway/src/mcp/handler.rs +++ b/crates/mcpmux-gateway/src/mcp/handler.rs @@ -296,7 +296,7 @@ impl ServerHandler for McpMuxGatewayHandler { .map_err(|e| McpError::internal_error(format!("Failed to get tools: {}", e), None))?; // Convert to MCP Tool types with qualified names (prefix.tool_name) - let mcp_tools: Vec = tools + let mut mcp_tools: Vec = tools .iter() .filter_map(|f| { f.raw_json.as_ref().and_then(|json| { @@ -308,6 +308,11 @@ impl ServerHandler for McpMuxGatewayHandler { }) .collect(); + // Append built-in `mcpmux_*` meta tools. These are always visible + // (they're introspection + self-management helpers, not filtered by + // the caller's FeatureSet). + mcp_tools.extend(self.services.meta_tool_registry.list_as_tools()); + // Log tool names at DEBUG level for visibility let tool_names: Vec = mcp_tools.iter().map(|t| t.name.to_string()).collect(); debug!( @@ -335,15 +340,36 @@ impl ServerHandler for McpMuxGatewayHandler { "call_tool" ); + let session_id_owned = extract_session_id(&context.extensions); + let session_id = session_id_owned.as_deref(); + + // Intercept meta tools (mcpmux_*) BEFORE feature-set filtering. These + // are always available regardless of the caller's resolved FS. + if crate::services::is_meta_tool(¶ms.name) + && self.services.meta_tool_registry.contains(¶ms.name) + { + let client_uuid = uuid::Uuid::parse_str(&oauth_ctx.client_id) + .map_err(|e| McpError::invalid_params(format!("bad client_id: {e}"), None))?; + let args: serde_json::Value = params + .arguments + .map(|a| serde_json::to_value(a).unwrap_or(serde_json::Value::Null)) + .unwrap_or(serde_json::Value::Null); + return match self + .services + .meta_tool_registry + .call(¶ms.name, &client_uuid, session_id, args) + .await + { + Ok(result) => Ok(result), + Err(e) => Ok(e.into_call_tool_result()), + }; + } + // Get client's feature set grants for authorization let feature_set_ids = self .services .authorization_service - .get_client_grants( - &oauth_ctx.client_id, - &oauth_ctx.space_id, - extract_session_id(&context.extensions).as_deref(), - ) + .get_client_grants(&oauth_ctx.client_id, &oauth_ctx.space_id, session_id) .await .map_err(|e| McpError::internal_error(format!("Failed to get grants: {}", e), None))?; diff --git a/crates/mcpmux-gateway/src/server/mod.rs b/crates/mcpmux-gateway/src/server/mod.rs index 4eeb44d..690cf50 100644 --- a/crates/mcpmux-gateway/src/server/mod.rs +++ b/crates/mcpmux-gateway/src/server/mod.rs @@ -169,6 +169,12 @@ impl GatewayServer { self.services.grant_service.clone() } + /// Approval broker for meta-tool writes. Exposed so the desktop layer + /// can attach a Tauri-event publisher + resolve pending prompts. + pub fn approval_broker(&self) -> Arc { + self.services.approval_broker.clone() + } + /// Get the OAuth manager pub fn oauth_manager(&self) -> Arc { self.services.pool_services.oauth_manager.clone() diff --git a/crates/mcpmux-gateway/src/server/service_container.rs b/crates/mcpmux-gateway/src/server/service_container.rs index 9ce175b..27b150c 100644 --- a/crates/mcpmux-gateway/src/server/service_container.rs +++ b/crates/mcpmux-gateway/src/server/service_container.rs @@ -7,8 +7,9 @@ use std::sync::Arc; use crate::pool::{PoolServices, ServerManager, ServiceFactory}; use crate::services::{ - AuthorizationService, ClientMetadataService, FeatureSetResolverService, GrantService, - PrefixCacheService, SessionRootsRegistry, SpaceResolverService, + meta_tools, ApprovalBroker, AuthorizationService, ClientMetadataService, + FeatureSetResolverService, GrantService, MetaToolRegistry, PrefixCacheService, + SessionRootsRegistry, SpaceResolverService, }; use mcpmux_core::DomainEvent; @@ -34,14 +35,18 @@ pub struct ServiceContainer { pub authorization_service: Arc, /// FeatureSet resolver v2 (pin > workspace > space-active). - /// - /// Runs in shadow mode alongside `authorization_service` — its decision - /// is logged on every request but not yet enforced. pub feature_set_resolver: Arc, /// Registry of per-session workspace roots (populated from MCP `roots/list`). pub session_roots: Arc, + /// Broker that asks the desktop UI for user approval on meta-tool writes. + /// Shared with the Tauri layer so it can attach a publisher + respond. + pub approval_broker: Arc, + + /// Built-in `mcpmux_*` meta tools advertised alongside backend tools. + pub meta_tool_registry: Arc, + /// Space resolver for determining client's active space (SRP) pub space_resolver_service: Arc, @@ -107,6 +112,25 @@ impl ServiceContainer { let authorization_service = Arc::new(AuthorizationService::new(feature_set_resolver.clone())); + // Approval broker for meta-tool writes. Publisher is attached later + // by the Tauri layer; until then, writes return `approval_required`. + let approval_broker = Arc::new(ApprovalBroker::new()); + + // Registry of built-in `mcpmux_*` meta tools (introspection + self- + // management). Each write tool is gated by the broker above. + let meta_tool_registry = meta_tools::build_default_registry( + deps.inbound_mcp_client_repo.clone(), + deps.space_repo.clone(), + deps.feature_set_repo.clone(), + deps.workspace_binding_repo.clone(), + deps.feature_repo.clone(), + feature_set_resolver.clone(), + pool_services.feature_service.clone(), + session_roots.clone(), + approval_broker.clone(), + domain_event_tx.clone(), + ); + // Create space resolver service (DIP: inject repository dependencies) let space_resolver_service = Arc::new(SpaceResolverService::new( deps.inbound_client_repo.clone(), @@ -131,6 +155,8 @@ impl ServiceContainer { authorization_service, feature_set_resolver, session_roots, + approval_broker, + meta_tool_registry, space_resolver_service, prefix_cache_service, client_metadata_service, diff --git a/crates/mcpmux-gateway/src/services/meta_tools/approval.rs b/crates/mcpmux-gateway/src/services/meta_tools/approval.rs new file mode 100644 index 0000000..eca2ec7 --- /dev/null +++ b/crates/mcpmux-gateway/src/services/meta_tools/approval.rs @@ -0,0 +1,451 @@ +//! Native-dialog approval broker for meta-tool writes. +//! +//! When an LLM calls a write meta tool (e.g. `mcpmux_pin_this_session`), +//! the gateway needs human sign-off before mutating state. The broker +//! bridges that: the tool calls [`ApprovalBroker::request_approval`], which +//! emits a Tauri event the desktop app listens for, awaits a response on a +//! oneshot channel, and returns [`ApprovalDecision`] — Allow (once/always) +//! or Deny (user-denied / timeout / rate-limited / no-desktop). +//! +//! Two non-obvious bits: +//! +//! * If no desktop is attached (headless CLI, tests without the subscriber +//! wired), [`ApprovalBroker::request_approval`] returns +//! [`MetaToolError::ApprovalRequiredNoDesktop`] immediately — a write +//! without an approver is a silent deny, which is the safe failure mode. +//! +//! * "Always allow" entries are **session-only** (in-memory `DashMap`, +//! not persisted). A gateway restart re-prompts. This is a deliberate +//! security default — auto-approved writes deserve a fresh nod on every +//! launch. Users can still tick the checkbox once per session. + +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use dashmap::DashMap; +use serde::{Deserialize, Serialize}; +use tokio::sync::{oneshot, Mutex}; +use tracing::{debug, warn}; +use uuid::Uuid; + +use super::MetaToolError; + +/// Default timeout for a single approval prompt. +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60); + +/// Rate limit: max pending approvals per (client_id) within the window. +const RATE_LIMIT_MAX_PENDING: usize = 10; +const RATE_LIMIT_WINDOW: Duration = Duration::from_secs(60); + +/// User's decision on an approval prompt. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ApprovalDecision { + AllowOnce, + /// Allow this (client, tool) pair for the rest of the gateway session. + AlwaysForThisSessionAndClient, + Deny, +} + +/// Scope of an "always allow" grant. Session-only for now; `Persisted` is +/// reserved for a future settings-backed opt-in. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ApprovalScope { + Once, + SessionClient, + #[allow(dead_code)] + Persisted, +} + +/// Payload delivered to the desktop UI so it can render a meaningful dialog. +/// +/// Keep this narrow and JSON-serializable — it crosses the Tauri boundary. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApprovalPayload { + pub tool_name: String, + /// Human summary the dialog puts above the diff. e.g. + /// "Pin this connection to FeatureSet 'android-dev' (12 tools)". + pub summary: String, + /// Tool-list diff the dialog shows to make the change concrete. + /// Optional because some writes (e.g. create_feature_set without + /// activation) don't shift the caller's resolved toolset. + pub diff: Option, + /// Raw arguments the LLM supplied; shown verbatim for auditability. + pub raw_args: serde_json::Value, + /// Does this change affect clients other than the caller? Dictates + /// whether the dialog shows the "also affects other connections" warning. + pub affects_other_clients: bool, +} + +/// Data the broker hands to whoever listens for approval requests. +#[derive(Debug, Clone, Serialize)] +pub struct ApprovalRequest { + pub request_id: String, + pub client_id: String, + pub payload: ApprovalPayload, + /// UNIX seconds at which this request will time out if no response. + pub expires_at_unix_secs: u64, +} + +/// Subscribe-once handler the desktop layer attaches so broker requests +/// reach the Tauri event bus. +/// +/// `respond` closure returns `true` when the listener accepted delivery, +/// `false` when no desktop was attached — which the broker treats as +/// "headless gateway, deny". +pub type ApprovalPublisher = Arc< + dyn Fn(ApprovalRequest) -> futures::future::BoxFuture<'static, bool> + Send + Sync + 'static, +>; + +/// The broker itself. +pub struct ApprovalBroker { + /// Pending oneshot senders keyed by request_id — the Tauri command + /// `respond_to_meta_tool_approval` resolves these. + pending: DashMap>, + /// Session-scoped always-allow grants, keyed by (client_id, tool_name). + always_allow: DashMap<(Uuid, String), ()>, + /// (client_id) -> Vec for rate limiting. + rate_limit: DashMap>, + /// Published to the desktop layer; `None` means headless. + publisher: Mutex>, + timeout: Duration, +} + +impl Default for ApprovalBroker { + fn default() -> Self { + Self::new() + } +} + +impl ApprovalBroker { + pub fn new() -> Self { + Self { + pending: DashMap::new(), + always_allow: DashMap::new(), + rate_limit: DashMap::new(), + publisher: Mutex::new(None), + timeout: DEFAULT_TIMEOUT, + } + } + + pub fn with_timeout(mut self, timeout: Duration) -> Self { + self.timeout = timeout; + self + } + + /// Attach the desktop subscriber. Call once at app startup. + pub async fn set_publisher(&self, publisher: ApprovalPublisher) { + *self.publisher.lock().await = Some(publisher); + } + + /// For tests / headless scenarios: pre-approve everything from a + /// specific client. Returns a guard struct you drop to clear it. + #[cfg(test)] + pub fn insert_always_allow(&self, client_id: Uuid, tool_name: &str) { + self.always_allow + .insert((client_id, tool_name.to_string()), ()); + } + + /// Resolve a pending approval. Called from Tauri command when the user + /// clicks a dialog button. `scope` converts "allow" into an optional + /// always-allow entry. + pub fn respond( + &self, + request_id: &str, + client_id: Uuid, + tool_name: &str, + decision: ApprovalDecision, + ) -> bool { + // Persist always-allow before firing the waiter so a racing second + // call from the same client sees it. + if matches!(decision, ApprovalDecision::AlwaysForThisSessionAndClient) { + self.always_allow + .insert((client_id, tool_name.to_string()), ()); + } + if let Some((_, tx)) = self.pending.remove(request_id) { + tx.send(decision).is_ok() + } else { + warn!( + %request_id, + "[ApprovalBroker] respond() for unknown/expired request", + ); + false + } + } + + /// List currently pending (unresolved) approvals. Useful for UI recovery + /// when the dialog is closed mid-request. + pub fn list_pending_ids(&self) -> Vec { + self.pending.iter().map(|e| e.key().clone()).collect() + } + + /// List always-allow grants (for the UI to display + revoke). + pub fn list_always_allow(&self) -> Vec<(Uuid, String)> { + self.always_allow.iter().map(|e| e.key().clone()).collect() + } + + /// Revoke an always-allow entry. + pub fn revoke_always_allow(&self, client_id: Uuid, tool_name: &str) -> bool { + self.always_allow + .remove(&(client_id, tool_name.to_string())) + .is_some() + } + + /// Core entry point for write meta tools. + /// + /// Order of checks: + /// 1. Always-allow hit → immediate `AllowOnce` (no dialog). + /// 2. Rate limit overflow → `RateLimited`. + /// 3. No publisher attached → `ApprovalRequiredNoDesktop`. + /// 4. Emit + wait → Allow / Deny / Timeout. + pub async fn request_approval( + &self, + client_id: Uuid, + tool_name: &str, + payload: ApprovalPayload, + ) -> Result { + // 1. Always-allow short-circuit. + if self + .always_allow + .contains_key(&(client_id, tool_name.to_string())) + { + debug!( + %client_id, + tool = tool_name, + "[ApprovalBroker] always-allow hit; approving without dialog", + ); + return Ok(ApprovalDecision::AllowOnce); + } + + // 2. Rate limit. + self.prune_rate_limit(client_id); + let pending_for_client = self + .rate_limit + .get(&client_id) + .map(|e| e.value().len()) + .unwrap_or(0); + if pending_for_client >= RATE_LIMIT_MAX_PENDING { + warn!( + %client_id, + tool = tool_name, + pending = pending_for_client, + "[ApprovalBroker] rate-limited", + ); + return Err(MetaToolError::RateLimited); + } + self.rate_limit + .entry(client_id) + .or_default() + .push(Instant::now()); + + // 3. Require an attached publisher. + let publisher = match self.publisher.lock().await.clone() { + Some(p) => p, + None => { + warn!( + %client_id, + tool = tool_name, + "[ApprovalBroker] no publisher attached; failing approval", + ); + return Err(MetaToolError::ApprovalRequiredNoDesktop); + } + }; + + // 4. Emit + wait on oneshot. + let request_id = Uuid::new_v4().to_string(); + let expires_at = chrono::Utc::now() + chrono::Duration::from_std(self.timeout).unwrap(); + let request = ApprovalRequest { + request_id: request_id.clone(), + client_id: client_id.to_string(), + payload, + expires_at_unix_secs: expires_at.timestamp() as u64, + }; + + let (tx, rx) = oneshot::channel(); + self.pending.insert(request_id.clone(), tx); + + let delivered = publisher(request.clone()).await; + if !delivered { + // Publisher disavowed delivery — treat like "no desktop". + self.pending.remove(&request_id); + return Err(MetaToolError::ApprovalRequiredNoDesktop); + } + + match tokio::time::timeout(self.timeout, rx).await { + Ok(Ok(decision)) => match decision { + ApprovalDecision::Deny => Err(MetaToolError::ApprovalDenied), + other => Ok(other), + }, + Ok(Err(_)) => { + // Sender dropped without deciding — treat as deny. + Err(MetaToolError::ApprovalDenied) + } + Err(_) => { + self.pending.remove(&request_id); + Err(MetaToolError::ApprovalTimedOut) + } + } + } + + fn prune_rate_limit(&self, client_id: Uuid) { + if let Some(mut entry) = self.rate_limit.get_mut(&client_id) { + let cutoff = Instant::now() - RATE_LIMIT_WINDOW; + entry.retain(|t| *t > cutoff); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use futures::FutureExt; + + fn make_payload() -> ApprovalPayload { + ApprovalPayload { + tool_name: "mcpmux_pin_this_session".into(), + summary: "test".into(), + diff: None, + raw_args: serde_json::json!({}), + affects_other_clients: false, + } + } + + #[tokio::test] + async fn no_publisher_returns_no_desktop_error() { + let broker = ApprovalBroker::new(); + let err = broker + .request_approval(Uuid::new_v4(), "mcpmux_pin_this_session", make_payload()) + .await + .unwrap_err(); + assert!(matches!(err, MetaToolError::ApprovalRequiredNoDesktop)); + } + + #[tokio::test] + async fn always_allow_short_circuits() { + let broker = ApprovalBroker::new(); + let client_id = Uuid::new_v4(); + broker.insert_always_allow(client_id, "mcpmux_pin_this_session"); + let d = broker + .request_approval(client_id, "mcpmux_pin_this_session", make_payload()) + .await + .unwrap(); + assert_eq!(d, ApprovalDecision::AllowOnce); + } + + #[tokio::test] + async fn publisher_allow_resolves() { + let broker = Arc::new(ApprovalBroker::new().with_timeout(Duration::from_millis(500))); + let broker_clone = broker.clone(); + let client_id = Uuid::new_v4(); + + // Publisher responds asynchronously with Allow. + let publisher: ApprovalPublisher = Arc::new(move |req| { + let b = broker_clone.clone(); + async move { + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(10)).await; + b.respond( + &req.request_id, + Uuid::parse_str(&req.client_id).unwrap(), + &req.payload.tool_name, + ApprovalDecision::AllowOnce, + ); + }); + true + } + .boxed() + }); + broker.set_publisher(publisher).await; + + let decision = broker + .request_approval(client_id, "mcpmux_pin_this_session", make_payload()) + .await + .unwrap(); + assert_eq!(decision, ApprovalDecision::AllowOnce); + } + + #[tokio::test] + async fn publisher_deny_returns_denied_error() { + let broker = Arc::new(ApprovalBroker::new().with_timeout(Duration::from_millis(500))); + let broker_clone = broker.clone(); + let client_id = Uuid::new_v4(); + + let publisher: ApprovalPublisher = Arc::new(move |req| { + let b = broker_clone.clone(); + async move { + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(10)).await; + b.respond( + &req.request_id, + Uuid::parse_str(&req.client_id).unwrap(), + &req.payload.tool_name, + ApprovalDecision::Deny, + ); + }); + true + } + .boxed() + }); + broker.set_publisher(publisher).await; + + let err = broker + .request_approval(client_id, "mcpmux_pin_this_session", make_payload()) + .await + .unwrap_err(); + assert!(matches!(err, MetaToolError::ApprovalDenied)); + } + + #[tokio::test] + async fn publisher_timeout() { + let broker = Arc::new(ApprovalBroker::new().with_timeout(Duration::from_millis(50))); + // Publisher accepts delivery but never responds. + let publisher: ApprovalPublisher = Arc::new(move |_req| async move { true }.boxed()); + broker.set_publisher(publisher).await; + + let err = broker + .request_approval(Uuid::new_v4(), "mcpmux_pin_this_session", make_payload()) + .await + .unwrap_err(); + assert!(matches!(err, MetaToolError::ApprovalTimedOut)); + } + + #[tokio::test] + async fn always_scope_persists_across_calls() { + let broker = Arc::new(ApprovalBroker::new().with_timeout(Duration::from_millis(500))); + let broker_clone = broker.clone(); + let client_id = Uuid::new_v4(); + + let publisher: ApprovalPublisher = Arc::new(move |req| { + let b = broker_clone.clone(); + async move { + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(10)).await; + b.respond( + &req.request_id, + Uuid::parse_str(&req.client_id).unwrap(), + &req.payload.tool_name, + ApprovalDecision::AlwaysForThisSessionAndClient, + ); + }); + true + } + .boxed() + }); + broker.set_publisher(publisher).await; + + // First call → dialog, returns AlwaysForThisSessionAndClient. + let d1 = broker + .request_approval(client_id, "mcpmux_pin_this_session", make_payload()) + .await + .unwrap(); + assert_eq!(d1, ApprovalDecision::AlwaysForThisSessionAndClient); + + // Second call → short-circuits via always-allow entry. + let d2 = broker + .request_approval(client_id, "mcpmux_pin_this_session", make_payload()) + .await + .unwrap(); + assert_eq!(d2, ApprovalDecision::AllowOnce); + } +} diff --git a/crates/mcpmux-gateway/src/services/meta_tools/diff.rs b/crates/mcpmux-gateway/src/services/meta_tools/diff.rs new file mode 100644 index 0000000..c4a7f25 --- /dev/null +++ b/crates/mcpmux-gateway/src/services/meta_tools/diff.rs @@ -0,0 +1,76 @@ +//! Before/after diff of the caller's resolved tool list. +//! +//! Used by write meta tools to build a concrete "you'll go from N tools to +//! M tools" preview for the approval dialog. + +use serde::Serialize; +use uuid::Uuid; + +use crate::pool::FeatureService; + +/// Tool-list diff between two FeatureSet resolutions, both relative to the +/// same Space. Every field is a list of fully-qualified tool names +/// (e.g. `github.create_issue`). +#[derive(Debug, Clone, Serialize, Default)] +pub struct ToolDiff { + pub before: Vec, + pub after: Vec, + pub added: Vec, + pub removed: Vec, +} + +impl ToolDiff { + /// Compute `after − before` for the caller's Space, each given as an + /// optional FeatureSet id. `None` means "deny" (empty toolset), which + /// is a valid before/after state. + /// + /// Uses the shared [`FeatureService`] so the math matches what the + /// client actually receives on a subsequent `list_tools` call. + pub async fn compute( + feature_service: &FeatureService, + space_id: Uuid, + before_fs_id: Option, + after_fs_id: Option, + ) -> anyhow::Result { + let before = Self::tools_for(feature_service, space_id, before_fs_id).await?; + let after = Self::tools_for(feature_service, space_id, after_fs_id).await?; + + let before_set: std::collections::HashSet<&String> = before.iter().collect(); + let after_set: std::collections::HashSet<&String> = after.iter().collect(); + let added: Vec = after + .iter() + .filter(|t| !before_set.contains(t)) + .cloned() + .collect(); + let removed: Vec = before + .iter() + .filter(|t| !after_set.contains(t)) + .cloned() + .collect(); + + Ok(ToolDiff { + before, + after, + added, + removed, + }) + } + + async fn tools_for( + feature_service: &FeatureService, + space_id: Uuid, + fs_id: Option, + ) -> anyhow::Result> { + let Some(fs) = fs_id else { return Ok(vec![]) }; + let space_id_str = space_id.to_string(); + let ids = [fs.to_string()]; + let features = feature_service + .get_tools_for_grants(&space_id_str, &ids) + .await?; + Ok(features + .iter() + .filter(|f| f.is_available) + .map(|f| f.qualified_name()) + .collect()) + } +} diff --git a/crates/mcpmux-gateway/src/services/meta_tools/mod.rs b/crates/mcpmux-gateway/src/services/meta_tools/mod.rs new file mode 100644 index 0000000..c6c36ab --- /dev/null +++ b/crates/mcpmux-gateway/src/services/meta_tools/mod.rs @@ -0,0 +1,86 @@ +//! Self-management meta tools (`mcpmux_*`). +//! +//! A small built-in toolset exposed by the gateway alongside the filtered +//! backend tools. Lets connected LLMs introspect the currently resolved +//! FeatureSet, see what tools exist unfiltered, and — gated by user +//! approval — reshape their own session's toolset (pin, create FS, bind +//! workspace, flip the Space's active FS). +//! +//! Design: the write tools are the token-savings feature. When a project +//! only needs 10 of 80 connected tools, the LLM can call +//! `mcpmux_pin_this_session` after reviewing the workspace, and the next +//! `tools/list` returns only the 10. Existing `tools/list_changed` +//! notification plumbing lands the reduced set in-session. +//! +//! Security: every write tool routes through [`approval::ApprovalBroker`] +//! which pops a native desktop dialog showing the concrete tool-list diff +//! before allowing the change. Headless gateways return `approval_required`. +//! Reads are unmetered. +//! +//! Namespace: all meta tools have names starting with `MCPMUX_PREFIX` +//! (`mcpmux_`) so the handler can route them before feature-set filtering. + +pub mod approval; +pub mod diff; +mod registry; +mod tools; + +pub use approval::{ + ApprovalBroker, ApprovalDecision, ApprovalPayload, ApprovalPublisher, ApprovalRequest, + ApprovalScope, +}; +pub use diff::ToolDiff; +pub use registry::{MetaToolContext, MetaToolError, MetaToolRegistry}; + +/// Every built-in tool's name must start with this prefix so the handler +/// can intercept it before routing to backend servers. +pub const MCPMUX_PREFIX: &str = "mcpmux_"; + +/// Convenience: is this tool name one of ours? +pub fn is_meta_tool(name: &str) -> bool { + name.starts_with(MCPMUX_PREFIX) +} + +/// Factory wiring a fully-configured registry with every default tool. +/// +/// Callers (ServiceContainer) construct one of these at gateway startup +/// and clone the Arc freely. +#[allow(clippy::too_many_arguments)] +pub fn build_default_registry( + client_repo: std::sync::Arc, + space_repo: std::sync::Arc, + feature_set_repo: std::sync::Arc, + binding_repo: std::sync::Arc, + server_feature_repo: std::sync::Arc, + resolver: std::sync::Arc, + feature_service: std::sync::Arc, + session_roots: std::sync::Arc, + approval_broker: std::sync::Arc, + domain_event_tx: tokio::sync::broadcast::Sender, +) -> std::sync::Arc { + let ctx = MetaToolContext { + client_repo, + space_repo, + feature_set_repo, + binding_repo, + server_feature_repo, + resolver, + feature_service, + session_roots, + approval_broker, + domain_event_tx, + }; + + let mut registry = MetaToolRegistry::new(ctx); + // Reads — no approval needed. + registry.register(Box::new(tools::ListAllToolsTool)); + registry.register(Box::new(tools::ListFeatureSetsTool)); + registry.register(Box::new(tools::DescribeResolutionTool)); + registry.register(Box::new(tools::DescribeWorkspaceTool)); + // Writes — gated by ApprovalBroker. + registry.register(Box::new(tools::PinThisSessionTool)); + registry.register(Box::new(tools::CreateFeatureSetTool)); + registry.register(Box::new(tools::BindCurrentWorkspaceTool)); + registry.register(Box::new(tools::SetSpaceActiveTool)); + std::sync::Arc::new(registry) +} diff --git a/crates/mcpmux-gateway/src/services/meta_tools/registry.rs b/crates/mcpmux-gateway/src/services/meta_tools/registry.rs new file mode 100644 index 0000000..df7aa52 --- /dev/null +++ b/crates/mcpmux-gateway/src/services/meta_tools/registry.rs @@ -0,0 +1,200 @@ +//! MetaTool trait + registry. +//! +//! Each meta tool is a unit struct implementing [`MetaTool`]. The registry +//! dispatches a tool name to its handler and exposes `list()` for the MCP +//! `tools/list` response. + +use std::collections::HashMap; +use std::sync::Arc; + +use async_trait::async_trait; +use mcpmux_core::{ + DomainEvent, FeatureSetRepository, InboundMcpClientRepository, ServerFeatureRepository, + SpaceRepository, WorkspaceBindingRepository, +}; +use rmcp::model::{CallToolResult, Tool}; +use serde_json::Value; +use thiserror::Error; +use tokio::sync::broadcast; +use uuid::Uuid; + +use super::approval::ApprovalBroker; +use crate::pool::FeatureService; +use crate::services::{FeatureSetResolverService, SessionRootsRegistry}; + +/// Context injected into every meta-tool invocation. +/// +/// Cheap to clone (all `Arc`s); the registry holds one and hands references +/// to tools via [`MetaToolContext`]. +#[derive(Clone)] +pub struct MetaToolContext { + pub client_repo: Arc, + pub space_repo: Arc, + pub feature_set_repo: Arc, + pub binding_repo: Arc, + pub server_feature_repo: Arc, + pub resolver: Arc, + pub feature_service: Arc, + pub session_roots: Arc, + pub approval_broker: Arc, + /// Broadcast domain events (e.g. ToolsChanged) so MCPNotifier can push + /// `tools/list_changed` to connected peers after a write mutates state. + pub domain_event_tx: broadcast::Sender, +} + +/// Per-request metadata threaded through every tool call. +pub struct MetaToolCall<'a> { + pub client_id: &'a Uuid, + pub session_id: Option<&'a str>, + /// JSON arguments supplied in `CallToolRequestParams.arguments`. + pub args: Value, + pub ctx: &'a MetaToolContext, +} + +/// Errors a meta tool can surface that map cleanly to `CallToolResult::error`. +#[derive(Debug, Error)] +pub enum MetaToolError { + #[error("invalid argument: {0}")] + InvalidArgument(String), + #[error("approval denied by user")] + ApprovalDenied, + #[error("approval request timed out")] + ApprovalTimedOut, + #[error("approval required but no desktop attached to mcpmux gateway")] + ApprovalRequiredNoDesktop, + #[error("rate limited: too many pending approvals for this client")] + RateLimited, + #[error("internal: {0}")] + Internal(String), +} + +impl MetaToolError { + /// Convert to an MCP error result (user-visible message). + pub fn into_call_tool_result(self) -> CallToolResult { + use rmcp::model::Content; + let payload = serde_json::json!({ + "error": match &self { + MetaToolError::InvalidArgument(_) => "invalid_argument", + MetaToolError::ApprovalDenied => "approval_denied", + MetaToolError::ApprovalTimedOut => "approval_timed_out", + MetaToolError::ApprovalRequiredNoDesktop => "approval_required", + MetaToolError::RateLimited => "rate_limited", + MetaToolError::Internal(_) => "internal_error", + }, + "message": self.to_string(), + }); + CallToolResult::error(vec![Content::text(payload.to_string())]) + } +} + +impl From for MetaToolError { + fn from(e: anyhow::Error) -> Self { + MetaToolError::Internal(e.to_string()) + } +} + +/// A single self-management tool. +/// +/// Tools are unit structs (no per-instance state) — all shared state comes +/// from [`MetaToolContext`]. +#[async_trait] +pub trait MetaTool: Send + Sync { + /// MCP tool name — must start with `mcpmux_`. + fn name(&self) -> &'static str; + + /// MCP tool description (shown to the LLM). + fn description(&self) -> &'static str; + + /// JSON-schema describing accepted arguments. The registry converts + /// this into a [`rmcp::model::Tool`] with the right annotations. + fn input_schema(&self) -> Value; + + /// Whether this tool modifies state. Writes are routed through the + /// approval broker; reads are executed immediately. + fn is_write(&self) -> bool { + false + } + + /// Run the tool. + async fn call(&self, call: MetaToolCall<'_>) -> Result; +} + +/// Registry of every built-in tool. Constructed once at gateway startup. +pub struct MetaToolRegistry { + ctx: MetaToolContext, + tools: HashMap<&'static str, Box>, +} + +impl MetaToolRegistry { + pub fn new(ctx: MetaToolContext) -> Self { + Self { + ctx, + tools: HashMap::new(), + } + } + + pub fn register(&mut self, tool: Box) { + let name = tool.name(); + debug_assert!( + name.starts_with(super::MCPMUX_PREFIX), + "meta tool name must start with {}: got {name}", + super::MCPMUX_PREFIX + ); + self.tools.insert(name, tool); + } + + /// Is `name` registered here? + pub fn contains(&self, name: &str) -> bool { + self.tools.contains_key(name) + } + + /// The `rmcp::model::Tool` list advertised to clients. + pub fn list_as_tools(&self) -> Vec { + self.tools + .values() + .map(|t| { + let schema: serde_json::Map = + serde_json::from_value(t.input_schema()).unwrap_or_default(); + let mut tool = Tool::new(t.name(), t.description(), Arc::new(schema)); + // Annotate writes so well-behaved clients surface the hint. + if t.is_write() { + let mut ann = tool.annotations.unwrap_or_default(); + ann.destructive_hint = Some(true); + ann.read_only_hint = Some(false); + tool.annotations = Some(ann); + } else { + let mut ann = tool.annotations.unwrap_or_default(); + ann.read_only_hint = Some(true); + tool.annotations = Some(ann); + } + tool + }) + .collect() + } + + /// Dispatch. Caller (the MCP handler) has already verified the name + /// starts with our prefix. + pub async fn call( + &self, + name: &str, + client_id: &Uuid, + session_id: Option<&str>, + args: Value, + ) -> Result { + let tool = self + .tools + .get(name) + .ok_or_else(|| MetaToolError::InvalidArgument(format!("unknown meta tool: {name}")))?; + let call = MetaToolCall { + client_id, + session_id, + args, + ctx: &self.ctx, + }; + tool.call(call).await + } + + pub fn context(&self) -> &MetaToolContext { + &self.ctx + } +} diff --git a/crates/mcpmux-gateway/src/services/meta_tools/tools.rs b/crates/mcpmux-gateway/src/services/meta_tools/tools.rs new file mode 100644 index 0000000..8d4ebc2 --- /dev/null +++ b/crates/mcpmux-gateway/src/services/meta_tools/tools.rs @@ -0,0 +1,733 @@ +//! Built-in `mcpmux_*` meta tool implementations. +//! +//! Each tool is a unit struct implementing [`MetaTool`]. Reads execute +//! directly; writes route through the [`ApprovalBroker`] first. + +use async_trait::async_trait; +use mcpmux_core::{ + normalize_workspace_root, DomainEvent, FeatureType, MemberMode, WorkspaceBinding, +}; +use rmcp::model::{CallToolResult, Content}; +use serde_json::{json, Value}; +use tokio::sync::broadcast; +use tracing::info; +use uuid::Uuid; + +use super::approval::{ApprovalPayload, ApprovalScope}; +use super::diff::ToolDiff; +use super::registry::{MetaTool, MetaToolCall, MetaToolError}; + +/// Fire a `FeatureSetMembersChanged` event so MCPNotifier pushes a +/// `tools/list_changed` notification to every connected client in the Space. +/// Used by every write tool after a successful mutation. +fn emit_tools_list_changed(event_tx: &broadcast::Sender, space_id: Uuid) { + let _ = event_tx.send(DomainEvent::FeatureSetMembersChanged { + space_id, + feature_set_id: "meta-tool-write".into(), + added_count: 0, + removed_count: 0, + }); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn text_result(v: Value) -> CallToolResult { + CallToolResult::success(vec![Content::text(v.to_string())]) +} + +/// Resolve the caller's effective Space id, using the pin if set, else the +/// default space. Returns `None` only in pathological "no-default-space" +/// setups, which the meta tools treat as errors. +async fn caller_space_id(call: &MetaToolCall<'_>) -> Result { + let client = call + .ctx + .client_repo + .get(call.client_id) + .await? + .ok_or_else(|| MetaToolError::Internal("client not found".into()))?; + if let Some(id) = client.pinned_space_id { + return Ok(id); + } + let default_space = call + .ctx + .space_repo + .get_default() + .await? + .ok_or_else(|| MetaToolError::Internal("no default space".into()))?; + Ok(default_space.id) +} + +// --------------------------------------------------------------------------- +// mcpmux_list_all_tools — read +// --------------------------------------------------------------------------- + +pub struct ListAllToolsTool; + +#[async_trait] +impl MetaTool for ListAllToolsTool { + fn name(&self) -> &'static str { + "mcpmux_list_all_tools" + } + + fn description(&self) -> &'static str { + "List EVERY tool available on every connected MCP server, without the \ + current FeatureSet filter applied. Useful when you want to know what's \ + possible in this workspace before deciding which tools to pin. Returns \ + an array of {server_id, qualified_name, description, available}." + } + + fn input_schema(&self) -> Value { + json!({ "type": "object", "properties": {} }) + } + + async fn call(&self, call: MetaToolCall<'_>) -> Result { + let space_id = caller_space_id(&call).await?; + let features = call + .ctx + .server_feature_repo + .list_for_space(&space_id.to_string()) + .await?; + let tools: Vec<_> = features + .iter() + .filter(|f| f.feature_type == FeatureType::Tool) + .map(|f| { + json!({ + "server_id": f.server_id, + "qualified_name": f.qualified_name(), + "description": f.description, + "available": f.is_available, + }) + }) + .collect(); + Ok(text_result(json!({ "tools": tools }))) + } +} + +// --------------------------------------------------------------------------- +// mcpmux_list_feature_sets — read +// --------------------------------------------------------------------------- + +pub struct ListFeatureSetsTool; + +#[async_trait] +impl MetaTool for ListFeatureSetsTool { + fn name(&self) -> &'static str { + "mcpmux_list_feature_sets" + } + + fn description(&self) -> &'static str { + "List every FeatureSet in the caller's Space — built-ins and custom. \ + Each entry carries `id`, `name`, `type`, `is_active` (the one that \ + applies when no pin/binding matches), and `is_pinned` (this caller's \ + current pin). Use before proposing a pin so you don't recreate one \ + that already fits." + } + + fn input_schema(&self) -> Value { + json!({ "type": "object", "properties": {} }) + } + + async fn call(&self, call: MetaToolCall<'_>) -> Result { + let space_id = caller_space_id(&call).await?; + let space = call + .ctx + .space_repo + .get(&space_id) + .await? + .ok_or_else(|| MetaToolError::Internal("space missing".into()))?; + let client = call.ctx.client_repo.get(call.client_id).await?; + let pinned_fs = client.as_ref().and_then(|c| c.pinned_feature_set_id); + let sets = call + .ctx + .feature_set_repo + .list_by_space(&space_id.to_string()) + .await?; + let sets: Vec<_> = sets + .iter() + .filter(|fs| !fs.is_deleted) + .map(|fs| { + let id_uuid = Uuid::parse_str(&fs.id).ok(); + json!({ + "id": fs.id, + "name": fs.name, + "description": fs.description, + "type": fs.feature_set_type, + "is_builtin": fs.is_builtin, + "is_active": id_uuid + .zip(space.active_feature_set_id) + .map(|(a, b)| a == b) + .unwrap_or(false), + "is_pinned": id_uuid + .zip(pinned_fs) + .map(|(a, b)| a == b) + .unwrap_or(false), + }) + }) + .collect(); + Ok(text_result( + json!({ "space_id": space_id, "feature_sets": sets }), + )) + } +} + +// --------------------------------------------------------------------------- +// mcpmux_describe_resolution — read +// --------------------------------------------------------------------------- + +pub struct DescribeResolutionTool; + +#[async_trait] +impl MetaTool for DescribeResolutionTool { + fn name(&self) -> &'static str { + "mcpmux_describe_resolution" + } + + fn description(&self) -> &'static str { + "Explain which FeatureSet the caller is currently resolved to and \ + why (pin | workspace_binding | space_active | deny). Always call \ + this before a write tool so you know the baseline." + } + + fn input_schema(&self) -> Value { + json!({ "type": "object", "properties": {} }) + } + + async fn call(&self, call: MetaToolCall<'_>) -> Result { + let resolved = call + .ctx + .resolver + .resolve(call.client_id, call.session_id) + .await?; + let fs_name = if let Some(id) = resolved.feature_set_id { + call.ctx + .feature_set_repo + .get(&id.to_string()) + .await? + .map(|fs| fs.name) + } else { + None + }; + let tool_count = if let Some(id) = resolved.feature_set_id { + let space_id = caller_space_id(&call).await?; + call.ctx + .feature_service + .get_tools_for_grants(&space_id.to_string(), &[id.to_string()]) + .await? + .iter() + .filter(|f| f.is_available) + .count() + } else { + 0 + }; + Ok(text_result(json!({ + "feature_set_id": resolved.feature_set_id, + "feature_set_name": fs_name, + "source": resolved.source, + "resolved_tool_count": tool_count, + }))) + } +} + +// --------------------------------------------------------------------------- +// mcpmux_describe_workspace — read +// --------------------------------------------------------------------------- + +pub struct DescribeWorkspaceTool; + +#[async_trait] +impl MetaTool for DescribeWorkspaceTool { + fn name(&self) -> &'static str { + "mcpmux_describe_workspace" + } + + fn description(&self) -> &'static str { + "Report the workspace roots the caller declared via the MCP `roots` \ + capability, and any WorkspaceBinding in this Space that matches. \ + Empty roots means the client didn't declare the `roots` capability \ + — bindings won't apply and workspace-based tools should be skipped." + } + + fn input_schema(&self) -> Value { + json!({ "type": "object", "properties": {} }) + } + + async fn call(&self, call: MetaToolCall<'_>) -> Result { + let space_id = caller_space_id(&call).await?; + let roots = call + .session_id + .and_then(|sid| call.ctx.session_roots.get(sid)) + .unwrap_or_default(); + let matched = if !roots.is_empty() { + call.ctx + .binding_repo + .find_longest_prefix_match(&space_id, &roots) + .await? + } else { + None + }; + Ok(text_result(json!({ + "space_id": space_id, + "reported_roots": roots, + "matched_binding": matched.map(|b| json!({ + "id": b.id, + "workspace_root": b.workspace_root, + "feature_set_id": b.feature_set_id, + })), + }))) + } +} + +// --------------------------------------------------------------------------- +// Writes — each goes through the ApprovalBroker before mutating state. +// --------------------------------------------------------------------------- + +/// Common path for every write tool: build payload, ask broker, run the +/// mutation. Returns the broker's decision so the caller can proceed only +/// on success. `mutate` is the thing that runs post-approval and is +/// expected to emit `tools/list_changed` when relevant. +async fn with_approval( + call: &MetaToolCall<'_>, + tool_name: &'static str, + summary: String, + diff: Option, + affects_other_clients: bool, + raw_args: Value, + mutate: F, +) -> Result +where + F: FnOnce() -> Fut, + Fut: std::future::Future>, +{ + let payload = ApprovalPayload { + tool_name: tool_name.to_string(), + summary, + diff, + raw_args, + affects_other_clients, + }; + call.ctx + .approval_broker + .request_approval(*call.client_id, tool_name, payload) + .await?; + mutate().await +} + +fn parse_uuid_arg(args: &Value, field: &str) -> Result { + let s = args + .get(field) + .and_then(|v| v.as_str()) + .ok_or_else(|| MetaToolError::InvalidArgument(format!("missing `{field}`")))?; + Uuid::parse_str(s) + .map_err(|_| MetaToolError::InvalidArgument(format!("`{field}` is not a UUID: {s}"))) +} + +// --------------------------------------------------------------------------- +// mcpmux_pin_this_session — write (caller-scope) +// --------------------------------------------------------------------------- + +pub struct PinThisSessionTool; + +#[async_trait] +impl MetaTool for PinThisSessionTool { + fn name(&self) -> &'static str { + "mcpmux_pin_this_session" + } + + fn description(&self) -> &'static str { + "Pin THIS caller's access key to the given FeatureSet. Affects only \ + the calling client; does not touch other connections. Requires user \ + approval. After approval the gateway emits tools/list_changed so \ + the trimmed toolset appears on the next list_tools." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["feature_set_id"], + "properties": { + "feature_set_id": { "type": "string", "description": "FeatureSet UUID" } + } + }) + } + + fn is_write(&self) -> bool { + true + } + + async fn call(&self, call: MetaToolCall<'_>) -> Result { + let new_fs = parse_uuid_arg(&call.args, "feature_set_id")?; + let space_id = caller_space_id(&call).await?; + + // Current resolution — becomes the `before` side of the diff. + let before_resolved = call + .ctx + .resolver + .resolve(call.client_id, call.session_id) + .await?; + let diff = ToolDiff::compute( + &call.ctx.feature_service, + space_id, + before_resolved.feature_set_id, + Some(new_fs), + ) + .await?; + + let fs_name = call + .ctx + .feature_set_repo + .get(&new_fs.to_string()) + .await? + .map(|fs| fs.name) + .unwrap_or_else(|| new_fs.to_string()); + let summary = format!( + "Pin this connection to FeatureSet '{fs_name}' ({} tools)", + diff.after.len() + ); + + let client_id = *call.client_id; + let client_repo = call.ctx.client_repo.clone(); + let event_tx = call.ctx.domain_event_tx.clone(); + with_approval( + &call, + "mcpmux_pin_this_session", + summary, + Some(serde_json::to_value(&diff).unwrap_or(Value::Null)), + false, + call.args.clone(), + || async move { + client_repo + .set_pin(&client_id, &space_id, Some(&new_fs)) + .await?; + info!(%client_id, new_fs = %new_fs, "[meta_tools] pin_this_session applied"); + // Trigger a list_changed notification so the caller + // re-fetches the trimmed toolset immediately. + emit_tools_list_changed(&event_tx, space_id); + Ok(text_result(json!({ + "ok": true, + "pinned_feature_set_id": new_fs, + "tool_count": diff.after.len(), + }))) + }, + ) + .await + } +} + +// --------------------------------------------------------------------------- +// mcpmux_create_feature_set — write (creates FS, optionally activates) +// --------------------------------------------------------------------------- + +pub struct CreateFeatureSetTool; + +#[async_trait] +impl MetaTool for CreateFeatureSetTool { + fn name(&self) -> &'static str { + "mcpmux_create_feature_set" + } + + fn description(&self) -> &'static str { + "Create a new custom FeatureSet in the caller's Space from an explicit \ + list of qualified tool names (e.g. ['github_create_issue', \ + 'firebase_deploy']). Returns the new FS id; does NOT activate it — \ + call mcpmux_pin_this_session or mcpmux_set_space_active separately \ + so the user sees the activation dialog distinct from creation." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["name", "tool_qualified_names"], + "properties": { + "name": { "type": "string" }, + "description": { "type": "string" }, + "tool_qualified_names": { + "type": "array", + "items": { "type": "string" } + } + } + }) + } + + fn is_write(&self) -> bool { + true + } + + async fn call(&self, call: MetaToolCall<'_>) -> Result { + let name = call + .args + .get("name") + .and_then(|v| v.as_str()) + .ok_or_else(|| MetaToolError::InvalidArgument("missing `name`".into()))? + .to_string(); + let description = call + .args + .get("description") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let qualified_names: Vec = call + .args + .get("tool_qualified_names") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(); + if qualified_names.is_empty() { + return Err(MetaToolError::InvalidArgument( + "tool_qualified_names must contain at least one entry".into(), + )); + } + + let space_id = caller_space_id(&call).await?; + + // Resolve qualified names → ServerFeature ids up-front so the + // approval dialog can show the exact tool count. + let all_features = call + .ctx + .server_feature_repo + .list_for_space(&space_id.to_string()) + .await?; + let matched: Vec<_> = all_features + .iter() + .filter(|f| { + f.feature_type == FeatureType::Tool && qualified_names.contains(&f.qualified_name()) + }) + .cloned() + .collect(); + if matched.is_empty() { + return Err(MetaToolError::InvalidArgument( + "no provided qualified_names matched any tool in this Space".into(), + )); + } + + let summary = format!("Create FeatureSet '{name}' with {} tools", matched.len()); + let diff = json!({ + "added_tools": matched.iter().map(|f| f.qualified_name()).collect::>(), + }); + + let fs_repo = call.ctx.feature_set_repo.clone(); + let name_for_closure = name.clone(); + let description_for_closure = description.clone(); + with_approval( + &call, + "mcpmux_create_feature_set", + summary, + Some(diff), + false, + call.args.clone(), + || async move { + let mut fs = + mcpmux_core::FeatureSet::new_custom(&name_for_closure, space_id.to_string()); + fs.description = description_for_closure; + fs_repo.create(&fs).await?; + for feature in &matched { + fs_repo + .add_feature_member(&fs.id, &feature.id.to_string(), MemberMode::Include) + .await?; + } + info!(fs_id = %fs.id, name = %name_for_closure, "[meta_tools] create_feature_set applied"); + Ok(text_result(json!({ + "ok": true, + "feature_set_id": fs.id, + "tool_count": matched.len(), + }))) + }, + ) + .await + } +} + +// --------------------------------------------------------------------------- +// mcpmux_bind_current_workspace — write (persistent, space-wide effect) +// --------------------------------------------------------------------------- + +pub struct BindCurrentWorkspaceTool; + +#[async_trait] +impl MetaTool for BindCurrentWorkspaceTool { + fn name(&self) -> &'static str { + "mcpmux_bind_current_workspace" + } + + fn description(&self) -> &'static str { + "Persistently bind the caller's first reported workspace root to the \ + given FeatureSet. Every future connection in this Space that reports \ + the same root (or a subdirectory) will resolve to this FeatureSet \ + unless they have an explicit pin. Requires user approval and the \ + calling client MUST have declared MCP roots." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["feature_set_id"], + "properties": { + "feature_set_id": { "type": "string" } + } + }) + } + + fn is_write(&self) -> bool { + true + } + + async fn call(&self, call: MetaToolCall<'_>) -> Result { + let fs_id = parse_uuid_arg(&call.args, "feature_set_id")?; + + let space_id = caller_space_id(&call).await?; + let roots = call + .session_id + .and_then(|sid| call.ctx.session_roots.get(sid)) + .unwrap_or_default(); + let root = roots.into_iter().next().ok_or_else(|| { + MetaToolError::InvalidArgument( + "caller did not report any MCP roots; cannot bind".into(), + ) + })?; + let normalized = normalize_workspace_root(&root); + + let fs_name = call + .ctx + .feature_set_repo + .get(&fs_id.to_string()) + .await? + .map(|fs| fs.name) + .unwrap_or_else(|| fs_id.to_string()); + + let summary = format!( + "Bind workspace '{normalized}' in this Space to FeatureSet '{fs_name}'. \ + Affects every future connection that reports this path." + ); + + let binding_repo = call.ctx.binding_repo.clone(); + let event_tx = call.ctx.domain_event_tx.clone(); + with_approval( + &call, + "mcpmux_bind_current_workspace", + summary, + None, + true, + call.args.clone(), + || async move { + let binding = WorkspaceBinding::new(space_id, normalized.clone(), fs_id); + binding_repo.create(&binding).await?; + info!( + %space_id, + workspace_root = %normalized, + feature_set_id = %fs_id, + "[meta_tools] bind_current_workspace applied", + ); + emit_tools_list_changed(&event_tx, space_id); + Ok(text_result(json!({ + "ok": true, + "binding_id": binding.id, + "workspace_root": normalized, + "feature_set_id": fs_id, + }))) + }, + ) + .await + } +} + +// --------------------------------------------------------------------------- +// mcpmux_set_space_active — write (affects every client in the Space) +// --------------------------------------------------------------------------- + +pub struct SetSpaceActiveTool; + +#[async_trait] +impl MetaTool for SetSpaceActiveTool { + fn name(&self) -> &'static str { + "mcpmux_set_space_active" + } + + fn description(&self) -> &'static str { + "Change the Space's ACTIVE FeatureSet — the fallback applied to every \ + connected client that has no pin and no matching workspace binding. \ + This affects OTHER clients beyond the caller; use sparingly. Requires \ + user approval." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["feature_set_id"], + "properties": { + "feature_set_id": { "type": "string" } + } + }) + } + + fn is_write(&self) -> bool { + true + } + + async fn call(&self, call: MetaToolCall<'_>) -> Result { + let fs_id = parse_uuid_arg(&call.args, "feature_set_id")?; + let space_id = caller_space_id(&call).await?; + + let space = call + .ctx + .space_repo + .get(&space_id) + .await? + .ok_or_else(|| MetaToolError::Internal("space missing".into()))?; + + let fs_name = call + .ctx + .feature_set_repo + .get(&fs_id.to_string()) + .await? + .map(|fs| fs.name) + .unwrap_or_else(|| fs_id.to_string()); + + let diff = ToolDiff::compute( + &call.ctx.feature_service, + space_id, + space.active_feature_set_id, + Some(fs_id), + ) + .await?; + + let summary = format!( + "Set the Space's active FeatureSet to '{fs_name}' ({} tools). \ + Affects every connection in this Space that has no pin and no workspace binding.", + diff.after.len(), + ); + + let space_repo = call.ctx.space_repo.clone(); + let event_tx = call.ctx.domain_event_tx.clone(); + with_approval( + &call, + "mcpmux_set_space_active", + summary, + Some(serde_json::to_value(&diff).unwrap_or(Value::Null)), + true, + call.args.clone(), + || async move { + space_repo + .set_active_feature_set(&space_id, Some(&fs_id)) + .await?; + info!(%space_id, feature_set_id = %fs_id, "[meta_tools] set_space_active applied"); + emit_tools_list_changed(&event_tx, space_id); + Ok(text_result(json!({ + "ok": true, + "space_id": space_id, + "active_feature_set_id": fs_id, + "tool_count": diff.after.len(), + }))) + }, + ) + .await + } +} + +// Suppress unused warning — `ApprovalScope` is re-exported for the Tauri +// surface and will land as a command argument once the dialog is wired up. +#[allow(dead_code)] +fn _unused_approval_scope(_: ApprovalScope) {} diff --git a/crates/mcpmux-gateway/src/services/mod.rs b/crates/mcpmux-gateway/src/services/mod.rs index 5209f4c..af1edb0 100644 --- a/crates/mcpmux-gateway/src/services/mod.rs +++ b/crates/mcpmux-gateway/src/services/mod.rs @@ -10,6 +10,7 @@ mod client_metadata_service; mod event_emitter; mod feature_set_resolver; mod grant_service; +pub mod meta_tools; mod notification_emitter; mod prefix_cache; mod session_roots; @@ -20,6 +21,10 @@ pub use client_metadata_service::ClientMetadataService; pub use event_emitter::EventEmitter; pub use feature_set_resolver::{FeatureSetResolverService, ResolutionSource, ResolvedFeatureSet}; pub use grant_service::GrantService; +pub use meta_tools::{ + is_meta_tool, ApprovalBroker, ApprovalDecision, ApprovalPayload, ApprovalPublisher, + ApprovalRequest, ApprovalScope, MetaToolRegistry, MCPMUX_PREFIX, +}; pub use notification_emitter::NotificationEmitter; pub use prefix_cache::PrefixCacheService; pub use session_roots::SessionRootsRegistry; diff --git a/tests/rust/tests/integration/meta_tools.rs b/tests/rust/tests/integration/meta_tools.rs new file mode 100644 index 0000000..f0b694f --- /dev/null +++ b/tests/rust/tests/integration/meta_tools.rs @@ -0,0 +1,580 @@ +//! End-to-end tests for the `mcpmux_*` self-management meta tools. +//! +//! Exercises the full path through the [`MetaToolRegistry`]: +//! * read tools return structured payloads +//! * write tools gate on the [`ApprovalBroker`] and only mutate state on Allow +//! * denial / timeout / no-publisher surface as `CallToolResult::error` +//! * "always-allow" persists for subsequent calls in the same session + +use std::sync::Arc; +use std::time::Duration; + +use futures::FutureExt; +use mcpmux_core::{ + normalize_workspace_root, Client, DomainEvent, FeatureSet, FeatureSetRepository, + InboundMcpClientRepository, ServerFeature, ServerFeatureRepository, SpaceRepository, + WorkspaceBindingRepository, +}; +use mcpmux_gateway::pool::FeatureService; +use mcpmux_gateway::services::{ + meta_tools, ApprovalBroker, ApprovalDecision, ApprovalPayload, ApprovalPublisher, + FeatureSetResolverService, MetaToolRegistry, PrefixCacheService, SessionRootsRegistry, +}; +use mcpmux_storage::{ + Database, SqliteFeatureSetRepository, SqliteInboundMcpClientRepository, + SqliteServerFeatureRepository, SqliteSpaceRepository, SqliteWorkspaceBindingRepository, +}; +use serde_json::{json, Value}; +use tokio::sync::{broadcast, Mutex}; +use uuid::Uuid; + +struct Fixture { + registry: Arc, + broker: Arc, + client_repo: Arc, + space_repo: Arc, + feature_set_repo: Arc, + binding_repo: Arc, + server_feature_repo: Arc, + session_roots: Arc, + space_id: Uuid, + client_id: Uuid, + session_id: String, + fs_android_id: Uuid, + fs_full_id: Uuid, +} + +impl Fixture { + async fn new() -> Self { + let db = Arc::new(Mutex::new(Database::open_in_memory().unwrap())); + + let space_repo: Arc = Arc::new(SqliteSpaceRepository::new(db.clone())); + let feature_set_repo: Arc = + Arc::new(SqliteFeatureSetRepository::new(db.clone())); + let client_repo: Arc = + Arc::new(SqliteInboundMcpClientRepository::new(db.clone())); + let binding_repo: Arc = + Arc::new(SqliteWorkspaceBindingRepository::new(db.clone())); + let server_feature_repo: Arc = + Arc::new(SqliteServerFeatureRepository::new(db.clone())); + + let default_space = space_repo.get_default().await.unwrap().unwrap(); + let space_id = default_space.id; + + // Two FSes we'll flip between in the tests. + let fs_android = FeatureSet::new_custom("Android Dev", space_id.to_string()); + let fs_full = FeatureSet::new_custom("Full Access", space_id.to_string()); + feature_set_repo.create(&fs_android).await.unwrap(); + feature_set_repo.create(&fs_full).await.unwrap(); + let fs_android_id = Uuid::parse_str(&fs_android.id).unwrap(); + let fs_full_id = Uuid::parse_str(&fs_full.id).unwrap(); + + // Seed two tools in server_features for the tools listing test. + // + // Tool names are stored bare; qualified_name() prepends the server + // prefix, so e.g. ("github", "create_issue") → "github_create_issue". + let mut feature1 = ServerFeature::tool(space_id, "github", "create_issue"); + feature1.display_name = Some("GitHub".into()); + feature1.description = Some("Create an issue".into()); + let mut feature2 = ServerFeature::tool(space_id, "firebase", "deploy"); + feature2.display_name = Some("Firebase".into()); + feature2.description = Some("Deploy to Firebase".into()); + server_feature_repo.upsert(&feature1).await.unwrap(); + server_feature_repo.upsert(&feature2).await.unwrap(); + + // Start the Space with `fs_full` as its active FS — the baseline the + // caller resolves to before any meta-tool action. + space_repo + .set_active_feature_set(&space_id, Some(&fs_full_id)) + .await + .unwrap(); + + // Create test client with `pinned_space_id` set. + let client = Client::new("TestClient", "test-type"); + let client_id = client.id; + client_repo.create(&client).await.unwrap(); + client_repo + .set_pin(&client_id, &space_id, None) + .await + .unwrap(); + + let session_roots = SessionRootsRegistry::new(); + let session_id = "sess-meta".to_string(); + + let resolver = Arc::new(FeatureSetResolverService::new( + client_repo.clone(), + space_repo.clone(), + binding_repo.clone(), + session_roots.clone(), + )); + + let prefix_cache = Arc::new(PrefixCacheService::new()); + let feature_service = Arc::new(FeatureService::new( + server_feature_repo.clone(), + feature_set_repo.clone(), + prefix_cache, + )); + + let broker = Arc::new(ApprovalBroker::new().with_timeout(Duration::from_millis(500))); + let (tx, _rx) = broadcast::channel::(32); + + let registry = meta_tools::build_default_registry( + client_repo.clone(), + space_repo.clone(), + feature_set_repo.clone(), + binding_repo.clone(), + server_feature_repo.clone(), + resolver, + feature_service, + session_roots.clone(), + broker.clone(), + tx, + ); + + Self { + registry, + broker, + client_repo, + space_repo, + feature_set_repo, + binding_repo, + server_feature_repo, + session_roots, + space_id, + client_id, + session_id, + fs_android_id, + fs_full_id, + } + } + + /// Attach a publisher that always auto-approves with the given decision. + fn attach_auto_publisher(&self, decision: ApprovalDecision) { + let broker = self.broker.clone(); + let publisher: ApprovalPublisher = Arc::new(move |req| { + let b = broker.clone(); + async move { + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(5)).await; + b.respond( + &req.request_id, + Uuid::parse_str(&req.client_id).unwrap(), + &req.payload.tool_name, + decision, + ); + }); + true + } + .boxed() + }); + // set_publisher is async; drive it synchronously via a current-runtime block_on + // is unavailable here, so we spawn and detach — publisher is in place before + // any request is made because tokio::test is single-threaded by default. + let b = self.broker.clone(); + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async move { + b.set_publisher(publisher).await; + }); + }); + } + + fn result_json(result: &rmcp::model::CallToolResult) -> Value { + // CallToolResult's Content is opaque; round-trip through JSON and + // pluck out the first text payload. + let raw = serde_json::to_value(result).unwrap(); + raw.get("content") + .and_then(|c| c.as_array()) + .and_then(|arr| arr.first()) + .and_then(|v| v.get("text")) + .and_then(|t| t.as_str()) + .and_then(|s| serde_json::from_str::(s).ok()) + .unwrap_or(raw) + } + + fn is_error(result: &rmcp::model::CallToolResult) -> bool { + result.is_error.unwrap_or(false) + } + + /// Call the registry and normalize errors to `CallToolResult::error` the + /// same way [`McpMuxGatewayHandler::call_tool`] does, so tests can assert + /// the wire behaviour uniformly. + async fn call_tool_as_handler_would( + &self, + name: &str, + args: Value, + ) -> rmcp::model::CallToolResult { + match self + .registry + .call(name, &self.client_id, Some(&self.session_id), args) + .await + { + Ok(r) => r, + Err(e) => e.into_call_tool_result(), + } + } +} + +// --------------------------------------------------------------------------- +// Reads +// --------------------------------------------------------------------------- + +#[tokio::test(flavor = "multi_thread")] +async fn list_all_tools_returns_unfiltered_across_servers() { + let f = Fixture::new().await; + let result = f + .registry + .call( + "mcpmux_list_all_tools", + &f.client_id, + Some(&f.session_id), + json!({}), + ) + .await + .unwrap(); + assert!(!Fixture::is_error(&result)); + let body = Fixture::result_json(&result); + let tools = body.get("tools").unwrap().as_array().unwrap(); + // Both seeded tools show up regardless of FS. + assert_eq!(tools.len(), 2); +} + +#[tokio::test(flavor = "multi_thread")] +async fn list_feature_sets_marks_active_fs() { + let f = Fixture::new().await; + let result = f + .registry + .call( + "mcpmux_list_feature_sets", + &f.client_id, + Some(&f.session_id), + json!({}), + ) + .await + .unwrap(); + let body = Fixture::result_json(&result); + let sets = body.get("feature_sets").unwrap().as_array().unwrap(); + let active: Vec<_> = sets + .iter() + .filter(|fs| { + fs.get("is_active") + .and_then(Value::as_bool) + .unwrap_or(false) + }) + .collect(); + assert_eq!(active.len(), 1, "exactly one Active FS expected"); + assert_eq!( + active[0].get("id").unwrap().as_str().unwrap(), + f.fs_full_id.to_string() + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn describe_resolution_reports_space_active_baseline() { + let f = Fixture::new().await; + let result = f + .registry + .call( + "mcpmux_describe_resolution", + &f.client_id, + Some(&f.session_id), + json!({}), + ) + .await + .unwrap(); + let body = Fixture::result_json(&result); + assert_eq!( + body.get("source").unwrap().as_str().unwrap(), + "space_active" + ); + assert_eq!( + body.get("feature_set_id").unwrap().as_str().unwrap(), + f.fs_full_id.to_string() + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn describe_workspace_reports_reported_roots() { + let f = Fixture::new().await; + let path = if cfg!(windows) { + "d:\\android\\myapp" + } else { + "/android/myapp" + }; + f.session_roots.set(&f.session_id, [path]); + + let result = f + .registry + .call( + "mcpmux_describe_workspace", + &f.client_id, + Some(&f.session_id), + json!({}), + ) + .await + .unwrap(); + let body = Fixture::result_json(&result); + let roots = body.get("reported_roots").unwrap().as_array().unwrap(); + assert_eq!(roots.len(), 1); + assert!(body.get("matched_binding").unwrap().is_null()); +} + +// --------------------------------------------------------------------------- +// Writes — gated by ApprovalBroker +// --------------------------------------------------------------------------- + +#[tokio::test(flavor = "multi_thread")] +async fn write_without_publisher_returns_approval_required() { + let f = Fixture::new().await; + let result = f + .call_tool_as_handler_would( + "mcpmux_pin_this_session", + json!({ "feature_set_id": f.fs_android_id.to_string() }), + ) + .await; + assert!(Fixture::is_error(&result)); + let body = Fixture::result_json(&result); + assert_eq!( + body.get("error").unwrap().as_str().unwrap(), + "approval_required" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn pin_this_session_writes_state_on_allow() { + let f = Fixture::new().await; + f.attach_auto_publisher(ApprovalDecision::AllowOnce); + + let result = f + .registry + .call( + "mcpmux_pin_this_session", + &f.client_id, + Some(&f.session_id), + json!({ "feature_set_id": f.fs_android_id.to_string() }), + ) + .await + .unwrap(); + assert!(!Fixture::is_error(&result)); + + let client = f.client_repo.get(&f.client_id).await.unwrap().unwrap(); + assert_eq!(client.pinned_feature_set_id, Some(f.fs_android_id)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn pin_this_session_rejected_on_deny_leaves_state_unchanged() { + let f = Fixture::new().await; + f.attach_auto_publisher(ApprovalDecision::Deny); + + let result = f + .call_tool_as_handler_would( + "mcpmux_pin_this_session", + json!({ "feature_set_id": f.fs_android_id.to_string() }), + ) + .await; + assert!(Fixture::is_error(&result)); + let body = Fixture::result_json(&result); + assert_eq!( + body.get("error").unwrap().as_str().unwrap(), + "approval_denied" + ); + + let client = f.client_repo.get(&f.client_id).await.unwrap().unwrap(); + assert_eq!(client.pinned_feature_set_id, None); +} + +#[tokio::test(flavor = "multi_thread")] +async fn always_allow_bypasses_subsequent_dialogs() { + let f = Fixture::new().await; + f.attach_auto_publisher(ApprovalDecision::AlwaysForThisSessionAndClient); + + // First call pops the dialog and banks the always-allow. + let r1 = f + .registry + .call( + "mcpmux_pin_this_session", + &f.client_id, + Some(&f.session_id), + json!({ "feature_set_id": f.fs_android_id.to_string() }), + ) + .await + .unwrap(); + assert!(!Fixture::is_error(&r1)); + + // Detach publisher — any further prompt would fail. Second call must + // short-circuit via always-allow. + let noop_publisher: ApprovalPublisher = Arc::new(move |_req| async move { true }.boxed()); + f.broker.set_publisher(noop_publisher).await; + + let r2 = f + .registry + .call( + "mcpmux_pin_this_session", + &f.client_id, + Some(&f.session_id), + json!({ "feature_set_id": f.fs_full_id.to_string() }), + ) + .await + .unwrap(); + assert!(!Fixture::is_error(&r2)); + + let client = f.client_repo.get(&f.client_id).await.unwrap().unwrap(); + assert_eq!(client.pinned_feature_set_id, Some(f.fs_full_id)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn create_feature_set_persists_members_on_approval() { + let f = Fixture::new().await; + f.attach_auto_publisher(ApprovalDecision::AllowOnce); + + let result = f + .registry + .call( + "mcpmux_create_feature_set", + &f.client_id, + Some(&f.session_id), + json!({ + "name": "Tiny Set", + "tool_qualified_names": ["github_create_issue"], + }), + ) + .await + .unwrap(); + assert!(!Fixture::is_error(&result)); + + let body = Fixture::result_json(&result); + let new_fs_id = body.get("feature_set_id").unwrap().as_str().unwrap(); + + let fs = f + .feature_set_repo + .get_with_members(new_fs_id) + .await + .unwrap() + .unwrap(); + assert_eq!(fs.name, "Tiny Set"); + assert_eq!(fs.members.len(), 1); +} + +#[tokio::test(flavor = "multi_thread")] +async fn bind_current_workspace_fails_when_no_roots_reported() { + let f = Fixture::new().await; + f.attach_auto_publisher(ApprovalDecision::AllowOnce); + // NOTE: session_roots intentionally NOT populated. + + let result = f + .call_tool_as_handler_would( + "mcpmux_bind_current_workspace", + json!({ "feature_set_id": f.fs_android_id.to_string() }), + ) + .await; + assert!(Fixture::is_error(&result)); + let body = Fixture::result_json(&result); + assert_eq!( + body.get("error").unwrap().as_str().unwrap(), + "invalid_argument" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn bind_current_workspace_creates_binding_with_normalized_root() { + let f = Fixture::new().await; + f.attach_auto_publisher(ApprovalDecision::AllowOnce); + let input = if cfg!(windows) { + "D:\\Projects\\Android\\MyApp\\" + } else { + "/home/me/projects/android/myapp/" + }; + f.session_roots.set(&f.session_id, [input]); + + let result = f + .registry + .call( + "mcpmux_bind_current_workspace", + &f.client_id, + Some(&f.session_id), + json!({ "feature_set_id": f.fs_android_id.to_string() }), + ) + .await + .unwrap(); + assert!(!Fixture::is_error(&result)); + + let bindings = f.binding_repo.list_for_space(&f.space_id).await.unwrap(); + assert_eq!(bindings.len(), 1); + let stored = &bindings[0].workspace_root; + // Drive-letter lowercased, trailing separator trimmed. + assert_eq!(stored, &normalize_workspace_root(input)); + assert!(!stored.ends_with('/') && !stored.ends_with('\\')); +} + +#[tokio::test(flavor = "multi_thread")] +async fn set_space_active_updates_space_fallback() { + let f = Fixture::new().await; + f.attach_auto_publisher(ApprovalDecision::AllowOnce); + + let result = f + .registry + .call( + "mcpmux_set_space_active", + &f.client_id, + Some(&f.session_id), + json!({ "feature_set_id": f.fs_android_id.to_string() }), + ) + .await + .unwrap(); + assert!(!Fixture::is_error(&result)); + + let space = f.space_repo.get(&f.space_id).await.unwrap().unwrap(); + assert_eq!(space.active_feature_set_id, Some(f.fs_android_id)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn invalid_feature_set_argument_rejected() { + let f = Fixture::new().await; + let result = f + .call_tool_as_handler_would( + "mcpmux_pin_this_session", + json!({ "feature_set_id": "not-a-uuid" }), + ) + .await; + assert!(Fixture::is_error(&result)); + let body = Fixture::result_json(&result); + assert_eq!( + body.get("error").unwrap().as_str().unwrap(), + "invalid_argument" + ); +} + +// --------------------------------------------------------------------------- +// Registry list-as-tools shape +// --------------------------------------------------------------------------- + +#[tokio::test(flavor = "multi_thread")] +async fn registry_advertises_every_default_tool_with_annotations() { + let f = Fixture::new().await; + let tools = f.registry.list_as_tools(); + let names: Vec<_> = tools.iter().map(|t| t.name.to_string()).collect(); + for expected in [ + "mcpmux_list_all_tools", + "mcpmux_list_feature_sets", + "mcpmux_describe_resolution", + "mcpmux_describe_workspace", + "mcpmux_pin_this_session", + "mcpmux_create_feature_set", + "mcpmux_bind_current_workspace", + "mcpmux_set_space_active", + ] { + assert!(names.iter().any(|n| n == expected), "missing {expected}"); + } + // Writes carry the destructive_hint annotation. + let pin = tools + .iter() + .find(|t| t.name == "mcpmux_pin_this_session") + .unwrap(); + assert_eq!( + pin.annotations.as_ref().and_then(|a| a.destructive_hint), + Some(true) + ); +} + +// Silence unused-import warnings from helper imports that only some tests exercise. +#[allow(dead_code)] +fn _unused(_: ApprovalPayload) {} diff --git a/tests/rust/tests/integration/mod.rs b/tests/rust/tests/integration/mod.rs index 60a660e..4e49884 100644 --- a/tests/rust/tests/integration/mod.rs +++ b/tests/rust/tests/integration/mod.rs @@ -12,3 +12,4 @@ mod feature_grants; mod feature_routing; mod feature_set_resolver; mod mcp_flows; +mod meta_tools; diff --git a/tests/rust/tests/streamable_http/gateway_notifications.rs b/tests/rust/tests/streamable_http/gateway_notifications.rs index ce7701d..6be4bb8 100644 --- a/tests/rust/tests/streamable_http/gateway_notifications.rs +++ b/tests/rust/tests/streamable_http/gateway_notifications.rs @@ -671,19 +671,35 @@ async fn test_client_can_list_tools_after_notification() { tokio::time::sleep(std::time::Duration::from_millis(500)).await; - // Initially no tools (empty feature repo = empty tools list) + // Initially no BACKEND tools (empty feature repo). The gateway always + // appends its built-in `mcpmux_*` meta tools regardless of FS resolution, + // so we assert on the non-meta subset here. let tools = client .list_tools(Default::default()) .await .expect("list_tools should work"); - assert_eq!(tools.tools.len(), 0, "Should start with no tools"); + let backend_tools: Vec<_> = tools + .tools + .iter() + .filter(|t| !t.name.starts_with("mcpmux_")) + .collect(); + assert_eq!( + backend_tools.len(), + 0, + "Should start with no backend tools; meta tools are always present" + ); - // list_tools should still work after re-fetch + // list_tools should still work after re-fetch. let tools2 = client .list_tools(Default::default()) .await .expect("second list_tools should work"); - assert_eq!(tools2.tools.len(), 0, "Still no tools"); + let backend_tools2: Vec<_> = tools2 + .tools + .iter() + .filter(|t| !t.name.starts_with("mcpmux_")) + .collect(); + assert_eq!(backend_tools2.len(), 0, "Still no backend tools"); client.cancel().await.ok(); gw.shutdown(); From 382c3747088b5df3392da3259c5dec69a9770328 Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Tue, 21 Apr 2026 15:56:50 +0800 Subject: [PATCH 07/24] feat(meta-tools): audit log + master switch + grants UI + E2E spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes out the deferred scope from the previous meta-tools commit. Audit trail — DomainEvent::MetaToolInvoked * Added to core DomainEvent enum with payload { client_id, session_id, tool_name, decision, resolved_feature_set_id, summary }. * MetaToolRegistry::call emits one event per invocation (read → "read"; write → "allow_once" | "deny" | "timeout" | "approval_required" | "rate_limited" | "invalid_args" | "error"), so every tool is logged without each tool having to remember to do it. * Desktop domain-event bridge maps it to a new `meta-tool-invoked` Tauri channel distinct from backend server notifications. Master switch — `gateway.meta_tools_enabled` * New setting key (default ON). Read via MetaToolRegistry::is_enabled() which the MCP handler checks in both list_tools (hide tools) and call_tool (fall through to feature-set routing → "tool not found"). * Two new Tauri commands: get_meta_tools_enabled / set_meta_tools_enabled. * Wired through dependencies.rs + service_container.rs + build_default_registry — `settings_repo: Option>` is threaded as a new context field. Desktop UI (SettingsPage gains a Self-management Tools section) * Master-switch toggle with copy explaining scope. * — lists session-scoped "always-allow" grants backed by list_meta_tool_grants / revoke_meta_tool_grant. Polls every 10s in case a dialog click or an external revoke changes state. * — global listener for the `meta-tool-invoked` event; ring-buffer of the last 50 calls rendered with per-decision iconography (Eye/green/red/amber) and elapsed timestamps. * lib/api/metaTools.ts — typed wrappers for the new commands + respondToMetaToolApproval for tests. E2E (WebDriverIO) * tests/e2e/specs/meta-tools.wdio.ts — three specs: - master-switch round-trips via Tauri invoke (settings page) - grants panel + audit log mount in the settings section - synthetic `meta-tool-approval-request` surfaces the dialog; clicking Deny dismisses it. Covers the exact bridge tested in production (event → React → respond_to_meta_tool_approval). Rust integration tests (3 new, 17 passing total in the suite) * read_tool_emits_meta_tool_invoked_with_decision_read — verifies a read-tool call drops "read" on the bus. * denied_write_emits_meta_tool_invoked_with_decision_deny — verifies a write without publisher surfaces "approval_required" on the bus. * master_switch_toggles_registry_visibility — flips the setting on/off and confirms is_enabled() tracks it; missing key defaults on. Test totals now: 9 (mcpmux), 123 (core), 104 (gateway lib), 79 (database), 31 (gateway) + 69 (integration incl. 17 meta-tool tests) + 16 (streamable_http) + 53 (oauth) + 17 (security) + mcpmux-mcp 7, all green. pnpm validate: fmt + clippy + check + eslint + typecheck all clean. Signed-off-by: Mohammod Al Amin Ashik --- .../desktop/src-tauri/src/commands/gateway.rs | 21 ++ .../src-tauri/src/commands/settings.rs | 37 ++++ apps/desktop/src-tauri/src/lib.rs | 2 + .../features/metaTools/MetaToolAuditLog.tsx | 107 ++++++++++ .../metaTools/MetaToolGrantsPanel.tsx | 122 +++++++++++ apps/desktop/src/features/metaTools/index.ts | 2 + .../src/features/settings/SettingsPage.tsx | 69 +++++++ apps/desktop/src/lib/api/index.ts | 1 + apps/desktop/src/lib/api/metaTools.ts | 67 ++++++ crates/mcpmux-core/src/domain/event.rs | 24 ++- crates/mcpmux-gateway/src/mcp/handler.rs | 18 +- .../src/server/service_container.rs | 1 + .../src/services/meta_tools/mod.rs | 4 +- .../src/services/meta_tools/registry.rs | 59 +++++- .../src/services/meta_tools/tools.rs | 3 + tests/e2e/specs/meta-tools.wdio.ts | 116 +++++++++++ tests/rust/tests/integration/meta_tools.rs | 191 ++++++++++++++++++ 17 files changed, 834 insertions(+), 10 deletions(-) create mode 100644 apps/desktop/src/features/metaTools/MetaToolAuditLog.tsx create mode 100644 apps/desktop/src/features/metaTools/MetaToolGrantsPanel.tsx create mode 100644 apps/desktop/src/lib/api/metaTools.ts create mode 100644 tests/e2e/specs/meta-tools.wdio.ts diff --git a/apps/desktop/src-tauri/src/commands/gateway.rs b/apps/desktop/src-tauri/src/commands/gateway.rs index 614d72c..fb1cc5a 100644 --- a/apps/desktop/src-tauri/src/commands/gateway.rs +++ b/apps/desktop/src-tauri/src/commands/gateway.rs @@ -456,6 +456,27 @@ fn map_domain_event_to_ui(event: &DomainEvent) -> (&'static str, serde_json::Val "server_id": server_id, }), ), + DomainEvent::MetaToolInvoked { + client_id, + session_id, + tool_name, + decision, + resolved_feature_set_id, + summary, + } => ( + // New channel so the Connection Log can render a dedicated row + // type without interleaving with regular backend events. + "meta-tool-invoked", + serde_json::json!({ + "client_id": client_id, + "session_id": session_id, + "tool_name": tool_name, + "decision": decision, + "resolved_feature_set_id": resolved_feature_set_id, + "summary": summary, + "timestamp": chrono::Utc::now().to_rfc3339(), + }), + ), } } diff --git a/apps/desktop/src-tauri/src/commands/settings.rs b/apps/desktop/src-tauri/src/commands/settings.rs index 70c10bc..46237e8 100644 --- a/apps/desktop/src-tauri/src/commands/settings.rs +++ b/apps/desktop/src-tauri/src/commands/settings.rs @@ -128,6 +128,43 @@ pub fn should_start_hidden() -> bool { args.contains(&"--hidden".to_string()) } +/// Get the current value of the meta-tools master switch. +/// +/// When disabled, the gateway hides the entire `mcpmux_*` namespace from +/// connected MCP clients — no introspection, no self-management. Default +/// ON. +#[tauri::command] +pub async fn get_meta_tools_enabled(app_state: State<'_, AppState>) -> Result { + match app_state + .settings_repository + .get("gateway.meta_tools_enabled") + .await + { + Ok(Some(v)) => Ok(!matches!(v.as_str(), "false" | "0")), + _ => Ok(true), + } +} + +/// Flip the meta-tools master switch. The change takes effect on the NEXT +/// `list_tools` / `call_tool` from any connected client — existing cached +/// tool lists are invalidated by the usual `tools/list_changed` push. +#[tauri::command] +pub async fn set_meta_tools_enabled( + enabled: bool, + app_state: State<'_, AppState>, +) -> Result<(), String> { + app_state + .settings_repository + .set( + "gateway.meta_tools_enabled", + if enabled { "true" } else { "false" }, + ) + .await + .map_err(|e| format!("Failed to save meta_tools_enabled: {}", e))?; + info!("[Settings] meta_tools_enabled = {}", enabled); + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index b9a6a91..9ee6327 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -805,6 +805,8 @@ pub fn run() { commands::respond_to_meta_tool_approval, commands::list_meta_tool_grants, commands::revoke_meta_tool_grant, + commands::get_meta_tools_enabled, + commands::set_meta_tools_enabled, // Config export commands commands::preview_config_export, commands::export_config_to_file, diff --git a/apps/desktop/src/features/metaTools/MetaToolAuditLog.tsx b/apps/desktop/src/features/metaTools/MetaToolAuditLog.tsx new file mode 100644 index 0000000..cf6348b --- /dev/null +++ b/apps/desktop/src/features/metaTools/MetaToolAuditLog.tsx @@ -0,0 +1,107 @@ +import { useEffect, useState } from 'react'; +import { listen } from '@tauri-apps/api/event'; +import { CheckCircle2, Eye, ShieldAlert, XCircle } from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@mcpmux/ui'; +import type { MetaToolAuditEvent } from '@/lib/api/metaTools'; + +/** Ring-buffer size — keeps the most recent N audit rows in memory. */ +const MAX_ROWS = 50; + +/** + * In-memory audit log of every `mcpmux_*` invocation (read or write, + * success or failure). Subscribes to the gateway's `meta-tool-invoked` + * event channel; rows are kept only for the current UI session — the + * persistent audit stream lives in the gateway's tracing logs. + */ +export function MetaToolAuditLog() { + const [rows, setRows] = useState([]); + + useEffect(() => { + const unlisten = listen( + 'meta-tool-invoked', + (event) => { + setRows((prev) => { + // Most-recent-first; trim to MAX_ROWS. + const next = [event.payload, ...prev]; + return next.length > MAX_ROWS ? next.slice(0, MAX_ROWS) : next; + }); + } + ); + return () => { + unlisten.then((fn) => fn()).catch(() => {}); + }; + }, []); + + return ( + + + + + Recent meta-tool activity + +

+ Every call to mcpmux_* made by a + connected MCP client. Live — last {MAX_ROWS} entries. +

+
+ + {rows.length === 0 ? ( +

+ No activity yet. Rows appear as MCP clients call meta tools. +

+ ) : ( +
    + {rows.map((r, i) => ( +
  • + +
    +
    + + {r.tool_name} + + + {r.decision} + +
    +
    + client {r.client_id.slice(0, 8)}… •{' '} + {new Date(r.timestamp).toLocaleTimeString()} +
    + {r.summary && ( +
    + {r.summary} +
    + )} +
    +
  • + ))} +
+ )} +
+
+ ); +} + +function DecisionIcon({ decision }: { decision: string }) { + const className = 'h-4 w-4 mt-0.5 flex-shrink-0'; + switch (decision) { + case 'read': + return ; + case 'allow_once': + case 'always_for_this_session_and_client': + return ; + case 'deny': + case 'timeout': + case 'rate_limited': + case 'approval_required': + return ; + case 'invalid_args': + case 'error': + default: + return ; + } +} diff --git a/apps/desktop/src/features/metaTools/MetaToolGrantsPanel.tsx b/apps/desktop/src/features/metaTools/MetaToolGrantsPanel.tsx new file mode 100644 index 0000000..510748e --- /dev/null +++ b/apps/desktop/src/features/metaTools/MetaToolGrantsPanel.tsx @@ -0,0 +1,122 @@ +import { useCallback, useEffect, useState } from 'react'; +import { KeyRound, Loader2, Trash2 } from 'lucide-react'; +import { Button, Card, CardContent, CardHeader, CardTitle } from '@mcpmux/ui'; +import { + listMetaToolGrants, + revokeMetaToolGrant, + type MetaToolGrantEntry, +} from '@/lib/api/metaTools'; + +/** + * Session-scoped "always allow (client, tool)" grants. These live in the + * gateway's in-memory `ApprovalBroker` and are wiped on gateway restart — + * so showing the list is both for awareness AND for a panic-revoke button + * when a user regrets ticking "Always for this session". + * + * Drop this anywhere. It refetches on mount and polls every 10s because the + * underlying broker state can change from either side (dialog clicks or + * calls to `revokeMetaToolGrant`). + */ +export function MetaToolGrantsPanel() { + const [grants, setGrants] = useState(null); + const [error, setError] = useState(null); + const [revoking, setRevoking] = useState(null); + + const load = useCallback(async () => { + try { + const data = await listMetaToolGrants(); + setGrants(data); + setError(null); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + }, []); + + useEffect(() => { + load(); + const i = setInterval(load, 10_000); + return () => clearInterval(i); + }, [load]); + + const handleRevoke = async (g: MetaToolGrantEntry) => { + const key = `${g.client_id}:${g.tool_name}`; + setRevoking(key); + try { + await revokeMetaToolGrant(g.client_id, g.tool_name); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setRevoking(null); + } + }; + + return ( + + + + + Meta-tool auto-approvals + +

+ "Always for this session" approvals granted to clients for + specific mcpmux_* tools. Wipes on + gateway restart. +

+
+ + {error && ( +
+ {error} +
+ )} + {grants === null ? ( +
+ Loading… +
+ ) : grants.length === 0 ? ( +

+ No auto-approvals yet. Each dialog defaults to "Allow once". +

+ ) : ( +
    + {grants.map((g) => { + const key = `${g.client_id}:${g.tool_name}`; + return ( +
  • +
    + + {g.tool_name} + + + client {g.client_id.slice(0, 8)}… + +
    + +
  • + ); + })} +
+ )} +
+
+ ); +} diff --git a/apps/desktop/src/features/metaTools/index.ts b/apps/desktop/src/features/metaTools/index.ts index dfff054..a3419b9 100644 --- a/apps/desktop/src/features/metaTools/index.ts +++ b/apps/desktop/src/features/metaTools/index.ts @@ -1,2 +1,4 @@ export { MetaToolApprovalDialog } from './MetaToolApprovalDialog'; export type { ApprovalRequest } from './MetaToolApprovalDialog'; +export { MetaToolGrantsPanel } from './MetaToolGrantsPanel'; +export { MetaToolAuditLog } from './MetaToolAuditLog'; diff --git a/apps/desktop/src/features/settings/SettingsPage.tsx b/apps/desktop/src/features/settings/SettingsPage.tsx index 1fda775..f357d6e 100644 --- a/apps/desktop/src/features/settings/SettingsPage.tsx +++ b/apps/desktop/src/features/settings/SettingsPage.tsx @@ -23,9 +23,12 @@ import { XCircle, Trash2, BarChart3, + Sparkles, } from 'lucide-react'; import { useAppStore, useTheme, useAnalyticsEnabled } from '@/stores'; import { UpdateChecker } from './UpdateChecker'; +import { getMetaToolsEnabled, setMetaToolsEnabled } from '@/lib/api/metaTools'; +import { MetaToolAuditLog, MetaToolGrantsPanel } from '@/features/metaTools'; interface StartupSettings { autoLaunch: boolean; @@ -55,6 +58,34 @@ export function SettingsPage() { const [logRetentionDays, setLogRetentionDays] = useState(30); const [savingRetention, setSavingRetention] = useState(false); + // Meta-tools master switch — gates the entire `mcpmux_*` namespace. + const [metaToolsEnabled, setMetaToolsEnabledState] = useState(true); + const [loadingMetaTools, setLoadingMetaTools] = useState(true); + + useEffect(() => { + getMetaToolsEnabled() + .then((v) => setMetaToolsEnabledState(v)) + .catch((e) => console.error('Failed to load meta_tools_enabled', e)) + .finally(() => setLoadingMetaTools(false)); + }, []); + + const handleToggleMetaTools = async (next: boolean) => { + const previous = metaToolsEnabled; + setMetaToolsEnabledState(next); + try { + await setMetaToolsEnabled(next); + success( + next ? 'Self-management tools enabled' : 'Self-management tools disabled', + next + ? 'Connected MCP clients will see the mcpmux_* toolset on next list_tools.' + : 'mcpmux_* is hidden from connected MCP clients.' + ); + } catch (e) { + setMetaToolsEnabledState(previous); + error('Failed to save setting', e instanceof Error ? e.message : String(e)); + } + }; + // Load logs path on mount useEffect(() => { const loadLogsPath = async () => { @@ -305,6 +336,44 @@ export function SettingsPage() { + {/* Self-management meta tools — `mcpmux_*` namespace */} + + + + + Self-management tools (mcpmux_*) + + + When enabled, connected MCP clients see a small built-in toolset that lets + LLMs introspect and — with your approval — reshape the FeatureSet they see. + Writes always trigger a native approval dialog; reads are silent. + + + +
+
+ +
+ +

+ Shows mcpmux_list_all_tools,  + mcpmux_pin_this_session, and 6 others to + every connected MCP client. Turn off to hide the whole namespace. +

+
+
+ +
+ + +
+
+ {/* Analytics Section */} diff --git a/apps/desktop/src/lib/api/index.ts b/apps/desktop/src/lib/api/index.ts index 3a77bb1..e20f78e 100644 --- a/apps/desktop/src/lib/api/index.ts +++ b/apps/desktop/src/lib/api/index.ts @@ -9,3 +9,4 @@ export * from './clients'; export * from './gateway'; export * from './serverManager'; export * from './workspaceBindings'; +export * from './metaTools'; diff --git a/apps/desktop/src/lib/api/metaTools.ts b/apps/desktop/src/lib/api/metaTools.ts new file mode 100644 index 0000000..23df3b4 --- /dev/null +++ b/apps/desktop/src/lib/api/metaTools.ts @@ -0,0 +1,67 @@ +import { invoke } from '@tauri-apps/api/core'; + +/** An "always allow from (client, tool)" entry kept in the gateway's broker. */ +export interface MetaToolGrantEntry { + client_id: string; + tool_name: string; +} + +/** Audit row emitted on every `mcpmux_*` invocation. */ +export interface MetaToolAuditEvent { + client_id: string; + session_id: string | null; + tool_name: string; + /** "allow_once" | "always_for_this_session_and_client" | "deny" | "timeout" | "approval_required" | "rate_limited" | "invalid_args" | "read" | "error" */ + decision: string; + resolved_feature_set_id: string | null; + summary: string; + /** Populated by the Tauri bridge. */ + timestamp: string; +} + +/** List every session-scoped "always allow" entry in the gateway. */ +export async function listMetaToolGrants(): Promise { + return invoke('list_meta_tool_grants'); +} + +/** Revoke a single "always allow" entry. */ +export async function revokeMetaToolGrant( + clientId: string, + toolName: string +): Promise { + return invoke('revoke_meta_tool_grant', { clientId, toolName }); +} + +/** + * Read the master switch that controls whether `mcpmux_*` meta tools are + * advertised to connected MCP clients. Default ON. + */ +export async function getMetaToolsEnabled(): Promise { + return invoke('get_meta_tools_enabled'); +} + +/** Flip the master switch; takes effect on the next `list_tools` push. */ +export async function setMetaToolsEnabled(enabled: boolean): Promise { + return invoke('set_meta_tools_enabled', { enabled }); +} + +/** + * Respond to a pending approval request. Normally called by + * ``; exported here for tests and advanced flows. + */ +export async function respondToMetaToolApproval( + requestId: string, + clientId: string, + toolName: string, + decision: + | 'allow_once' + | 'always_for_this_session_and_client' + | 'deny' +): Promise { + return invoke('respond_to_meta_tool_approval', { + requestId, + clientId, + toolName, + decision, + }); +} diff --git a/crates/mcpmux-core/src/domain/event.rs b/crates/mcpmux-core/src/domain/event.rs index bf460ba..c76a01c 100644 --- a/crates/mcpmux-core/src/domain/event.rs +++ b/crates/mcpmux-core/src/domain/event.rs @@ -356,6 +356,26 @@ pub enum DomainEvent { /// Backend server notified that its resources changed ResourcesChanged { space_id: Uuid, server_id: String }, + + // ════════════════════════════════════════════════════════════════════════ + // META-TOOL AUDIT TRAIL + // ════════════════════════════════════════════════════════════════════════ + /// A built-in `mcpmux_*` self-management tool was called by an MCP client. + /// + /// Emitted by the gateway for every meta-tool invocation (read + write) + /// so the desktop's Connection Log can show an audit row. For writes, + /// `decision` records what the user chose in the approval dialog. + MetaToolInvoked { + client_id: String, + session_id: Option, + tool_name: String, + /// `"allow_once" | "always_for_this_session_and_client" | "deny" | "timeout" | "read"` + decision: String, + /// FeatureSet that became active as a result of the write, when known. + resolved_feature_set_id: Option, + /// Redacted summary of the payload the LLM supplied (no secrets). + summary: String, + }, } // ============================================================================ @@ -395,6 +415,7 @@ impl DomainEvent { Self::ToolsChanged { .. } => "tools_changed", Self::PromptsChanged { .. } => "prompts_changed", Self::ResourcesChanged { .. } => "resources_changed", + Self::MetaToolInvoked { .. } => "meta_tool_invoked", } } @@ -460,7 +481,8 @@ impl DomainEvent { | Self::ClientDeleted { .. } | Self::ClientTokenIssued { .. } | Self::GatewayStarted { .. } - | Self::GatewayStopped => None, + | Self::GatewayStopped + | Self::MetaToolInvoked { .. } => None, } } diff --git a/crates/mcpmux-gateway/src/mcp/handler.rs b/crates/mcpmux-gateway/src/mcp/handler.rs index 425b4bf..71c8606 100644 --- a/crates/mcpmux-gateway/src/mcp/handler.rs +++ b/crates/mcpmux-gateway/src/mcp/handler.rs @@ -308,10 +308,13 @@ impl ServerHandler for McpMuxGatewayHandler { }) .collect(); - // Append built-in `mcpmux_*` meta tools. These are always visible - // (they're introspection + self-management helpers, not filtered by - // the caller's FeatureSet). - mcp_tools.extend(self.services.meta_tool_registry.list_as_tools()); + // Append built-in `mcpmux_*` meta tools when enabled. Default is ON; + // users can set `gateway.meta_tools_enabled = "false"` in settings + // to hide the entire namespace — useful when a deployment explicitly + // wants a non-self-managing gateway. + if self.services.meta_tool_registry.is_enabled().await { + mcp_tools.extend(self.services.meta_tool_registry.list_as_tools()); + } // Log tool names at DEBUG level for visibility let tool_names: Vec = mcp_tools.iter().map(|t| t.name.to_string()).collect(); @@ -343,10 +346,13 @@ impl ServerHandler for McpMuxGatewayHandler { let session_id_owned = extract_session_id(&context.extensions); let session_id = session_id_owned.as_deref(); - // Intercept meta tools (mcpmux_*) BEFORE feature-set filtering. These - // are always available regardless of the caller's resolved FS. + // Intercept meta tools (mcpmux_*) BEFORE feature-set filtering. + // When the master switch is off we fall through to the feature-set + // path where the tool will miss and surface a normal "not found" + // error — same behaviour a client would see for any unknown tool. if crate::services::is_meta_tool(¶ms.name) && self.services.meta_tool_registry.contains(¶ms.name) + && self.services.meta_tool_registry.is_enabled().await { let client_uuid = uuid::Uuid::parse_str(&oauth_ctx.client_id) .map_err(|e| McpError::invalid_params(format!("bad client_id: {e}"), None))?; diff --git a/crates/mcpmux-gateway/src/server/service_container.rs b/crates/mcpmux-gateway/src/server/service_container.rs index 27b150c..70b1b34 100644 --- a/crates/mcpmux-gateway/src/server/service_container.rs +++ b/crates/mcpmux-gateway/src/server/service_container.rs @@ -129,6 +129,7 @@ impl ServiceContainer { session_roots.clone(), approval_broker.clone(), domain_event_tx.clone(), + deps.settings_repo.clone(), ); // Create space resolver service (DIP: inject repository dependencies) diff --git a/crates/mcpmux-gateway/src/services/meta_tools/mod.rs b/crates/mcpmux-gateway/src/services/meta_tools/mod.rs index c6c36ab..66a2a74 100644 --- a/crates/mcpmux-gateway/src/services/meta_tools/mod.rs +++ b/crates/mcpmux-gateway/src/services/meta_tools/mod.rs @@ -30,7 +30,7 @@ pub use approval::{ ApprovalScope, }; pub use diff::ToolDiff; -pub use registry::{MetaToolContext, MetaToolError, MetaToolRegistry}; +pub use registry::{MetaToolContext, MetaToolError, MetaToolRegistry, META_TOOLS_ENABLED_KEY}; /// Every built-in tool's name must start with this prefix so the handler /// can intercept it before routing to backend servers. @@ -57,6 +57,7 @@ pub fn build_default_registry( session_roots: std::sync::Arc, approval_broker: std::sync::Arc, domain_event_tx: tokio::sync::broadcast::Sender, + settings_repo: Option>, ) -> std::sync::Arc { let ctx = MetaToolContext { client_repo, @@ -69,6 +70,7 @@ pub fn build_default_registry( session_roots, approval_broker, domain_event_tx, + settings_repo, }; let mut registry = MetaToolRegistry::new(ctx); diff --git a/crates/mcpmux-gateway/src/services/meta_tools/registry.rs b/crates/mcpmux-gateway/src/services/meta_tools/registry.rs index df7aa52..1b6f921 100644 --- a/crates/mcpmux-gateway/src/services/meta_tools/registry.rs +++ b/crates/mcpmux-gateway/src/services/meta_tools/registry.rs @@ -22,6 +22,10 @@ use super::approval::ApprovalBroker; use crate::pool::FeatureService; use crate::services::{FeatureSetResolverService, SessionRootsRegistry}; +/// App-settings key that toggles the entire `mcpmux_*` namespace. +/// Present + "false" → hidden; missing or anything else → enabled. +pub const META_TOOLS_ENABLED_KEY: &str = "gateway.meta_tools_enabled"; + /// Context injected into every meta-tool invocation. /// /// Cheap to clone (all `Arc`s); the registry holds one and hands references @@ -40,6 +44,10 @@ pub struct MetaToolContext { /// Broadcast domain events (e.g. ToolsChanged) so MCPNotifier can push /// `tools/list_changed` to connected peers after a write mutates state. pub domain_event_tx: broadcast::Sender, + /// App-settings repo for the `gateway.meta_tools_enabled` master switch. + /// Optional because older dependency builders may not have wired it. + /// When absent the switch defaults to ENABLED (matches the product default). + pub settings_repo: Option>, } /// Per-request metadata threaded through every tool call. @@ -148,6 +156,24 @@ impl MetaToolRegistry { self.tools.contains_key(name) } + /// Master switch: are meta tools enabled in app settings? When disabled, + /// the gateway handler hides `mcpmux_*` from `list_tools` and routes + /// `call_tool` invocations straight to the feature-set path (where they + /// will miss and return "tool not found"). + /// + /// Default when the setting is missing or the repo is not wired: ON. + /// Default when the setting value is unparseable: ON (fail-open on the + /// discoverability side; security-sensitive writes still require approval). + pub async fn is_enabled(&self) -> bool { + let Some(repo) = self.ctx.settings_repo.as_ref() else { + return true; + }; + match repo.get(META_TOOLS_ENABLED_KEY).await { + Ok(Some(v)) => !matches!(v.as_str(), "false" | "0"), + _ => true, + } + } + /// The `rmcp::model::Tool` list advertised to clients. pub fn list_as_tools(&self) -> Vec { self.tools @@ -174,6 +200,11 @@ impl MetaToolRegistry { /// Dispatch. Caller (the MCP handler) has already verified the name /// starts with our prefix. + /// + /// Every invocation — read or write, success or failure — emits a + /// [`DomainEvent::MetaToolInvoked`] audit event so the desktop + /// Connection Log can render a row. Read tools get `decision = "read"`; + /// write tools get the actual approval decision or an error string. pub async fn call( &self, name: &str, @@ -185,13 +216,37 @@ impl MetaToolRegistry { .tools .get(name) .ok_or_else(|| MetaToolError::InvalidArgument(format!("unknown meta tool: {name}")))?; + let is_write = tool.is_write(); let call = MetaToolCall { client_id, session_id, - args, + args: args.clone(), ctx: &self.ctx, }; - tool.call(call).await + let result = tool.call(call).await; + + let (decision, summary) = match &result { + Ok(_) if is_write => ("allow_once", format!("{name} succeeded")), + Ok(_) => ("read", format!("{name} read")), + Err(MetaToolError::ApprovalDenied) => ("deny", format!("{name} denied by user")), + Err(MetaToolError::ApprovalTimedOut) => ("timeout", format!("{name} timed out")), + Err(MetaToolError::ApprovalRequiredNoDesktop) => { + ("approval_required", format!("{name} no desktop")) + } + Err(MetaToolError::RateLimited) => ("rate_limited", format!("{name} rate-limited")), + Err(MetaToolError::InvalidArgument(m)) => ("invalid_args", format!("{name}: {m}")), + Err(MetaToolError::Internal(m)) => ("error", format!("{name}: {m}")), + }; + let _ = self.ctx.domain_event_tx.send(DomainEvent::MetaToolInvoked { + client_id: client_id.to_string(), + session_id: session_id.map(|s| s.to_string()), + tool_name: name.to_string(), + decision: decision.to_string(), + resolved_feature_set_id: None, + summary, + }); + + result } pub fn context(&self) -> &MetaToolContext { diff --git a/crates/mcpmux-gateway/src/services/meta_tools/tools.rs b/crates/mcpmux-gateway/src/services/meta_tools/tools.rs index 8d4ebc2..40d72b9 100644 --- a/crates/mcpmux-gateway/src/services/meta_tools/tools.rs +++ b/crates/mcpmux-gateway/src/services/meta_tools/tools.rs @@ -29,6 +29,9 @@ fn emit_tools_list_changed(event_tx: &broadcast::Sender, space_id: }); } +// NOTE: MetaToolInvoked audit events are emitted centrally by +// MetaToolRegistry::call, so individual tools don't need to fire them. + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/tests/e2e/specs/meta-tools.wdio.ts b/tests/e2e/specs/meta-tools.wdio.ts new file mode 100644 index 0000000..e725a16 --- /dev/null +++ b/tests/e2e/specs/meta-tools.wdio.ts @@ -0,0 +1,116 @@ +/** + * E2E Tests: self-management `mcpmux_*` meta tools. + * + * Covers the user-visible approval flow end-to-end: + * * the master switch round-trips through the SettingsPage + * * the approval dialog renders when the gateway emits a request event + * * the Allow/Deny buttons call respond_to_meta_tool_approval + * * the grants panel + audit log render without a live gateway + * + * The gateway's internal state machine is covered by the Rust integration + * tests; here we verify the Tauri bridge + React wiring actually moves + * bytes between the two. + */ + +import { byTestId, TIMEOUT, safeClick } from '../helpers/selectors'; +import { emitEvent, invoke } from '../helpers/tauri-api'; + +describe('Meta tools - Settings UI', () => { + it('TC-MT-001: Master-switch round-trips through Settings > get_meta_tools_enabled', async () => { + const settingsButton = await byTestId('nav-settings'); + await safeClick(settingsButton); + await browser.pause(1000); + + const metaSection = await byTestId('settings-meta-tools-section'); + await expect(metaSection).toBeDisplayed(); + + // Initial state should be enabled (product default). + const initial = await invoke('get_meta_tools_enabled'); + expect(initial).toBe(true); + + // Toggle via the Tauri command and verify UI reflects the change after + // a navigation away-and-back (the switch is loaded on mount). + await invoke('set_meta_tools_enabled', { enabled: false }); + expect(await invoke('get_meta_tools_enabled')).toBe(false); + + // Restore so subsequent tests see the default. + await invoke('set_meta_tools_enabled', { enabled: true }); + expect(await invoke('get_meta_tools_enabled')).toBe(true); + }); + + it('TC-MT-002: Grants panel + audit log render in the Settings section', async () => { + const settingsButton = await byTestId('nav-settings'); + await safeClick(settingsButton); + await browser.pause(1000); + + const grants = await byTestId('meta-tool-grants-panel'); + const audit = await byTestId('meta-tool-audit-log'); + await expect(grants).toBeDisplayed(); + await expect(audit).toBeDisplayed(); + }); +}); + +describe('Meta tools - Approval dialog', () => { + it('TC-MT-010: Emitting `meta-tool-approval-request` surfaces the dialog', async () => { + // Fire a synthetic approval request from the Rust side; the dialog + // component listens on this exact Tauri event name, no gateway needed. + const requestId = `test-${Date.now()}`; + await emitEvent('meta-tool-approval-request', { + request_id: requestId, + client_id: '00000000-0000-0000-0000-0000000000aa', + payload: { + tool_name: 'mcpmux_pin_this_session', + summary: 'E2E: pin to FeatureSet "tiny" (3 tools)', + diff: { + before: ['github_create_issue', 'firebase_deploy', 'slack_send'], + after: ['github_create_issue'], + added: [], + removed: ['firebase_deploy', 'slack_send'], + }, + raw_args: { feature_set_id: '11111111-1111-1111-1111-111111111111' }, + affects_other_clients: false, + }, + expires_at_unix_secs: Math.floor(Date.now() / 1000) + 60, + }); + + const dialog = await byTestId('meta-tool-approval-dialog'); + await dialog.waitForDisplayed({ timeout: TIMEOUT.medium }); + + // Every button is present and clickable. + await expect(await byTestId('meta-tool-approval-allow-once')).toBeDisplayed(); + await expect(await byTestId('meta-tool-approval-always')).toBeDisplayed(); + await expect(await byTestId('meta-tool-approval-deny')).toBeDisplayed(); + }); + + it('TC-MT-011: Clicking Deny closes the dialog and records a decision', async () => { + // Queue a fresh dialog (previous test may have left one mid-flight on + // slow CI — wait for it to close first). + const requestId = `test-deny-${Date.now()}`; + await emitEvent('meta-tool-approval-request', { + request_id: requestId, + client_id: '00000000-0000-0000-0000-0000000000bb', + payload: { + tool_name: 'mcpmux_set_space_active', + summary: 'E2E deny: change space active FS', + diff: null, + raw_args: {}, + affects_other_clients: true, + }, + expires_at_unix_secs: Math.floor(Date.now() / 1000) + 60, + }); + + const dialog = await byTestId('meta-tool-approval-dialog'); + await dialog.waitForDisplayed({ timeout: TIMEOUT.medium }); + + // The dialog shows the cross-client warning for this request. + await expect( + await byTestId('meta-tool-approval-cross-client-warning') + ).toBeDisplayed(); + + const deny = await byTestId('meta-tool-approval-deny'); + await safeClick(deny); + + // Dialog dismisses after the respond_to_meta_tool_approval round-trip. + await dialog.waitForDisplayed({ reverse: true, timeout: TIMEOUT.medium }); + }); +}); diff --git a/tests/rust/tests/integration/meta_tools.rs b/tests/rust/tests/integration/meta_tools.rs index f0b694f..e5d4526 100644 --- a/tests/rust/tests/integration/meta_tools.rs +++ b/tests/rust/tests/integration/meta_tools.rs @@ -129,6 +129,7 @@ impl Fixture { session_roots.clone(), broker.clone(), tx, + None, ); Self { @@ -575,6 +576,196 @@ async fn registry_advertises_every_default_tool_with_annotations() { ); } +// --------------------------------------------------------------------------- +// MetaToolInvoked audit emission + master switch +// --------------------------------------------------------------------------- + +/// Build a bare registry (no fixture sugar) so tests can subscribe to the +/// event bus before the first call or flip the master-switch setting. +async fn bare_registry( + settings_repo: Option>, +) -> ( + Arc, + Uuid, + broadcast::Sender, + broadcast::Receiver, +) { + let db = Arc::new(Mutex::new(Database::open_in_memory().unwrap())); + let space_repo: Arc = Arc::new(SqliteSpaceRepository::new(db.clone())); + let feature_set_repo: Arc = + Arc::new(SqliteFeatureSetRepository::new(db.clone())); + let client_repo: Arc = + Arc::new(SqliteInboundMcpClientRepository::new(db.clone())); + let binding_repo: Arc = + Arc::new(SqliteWorkspaceBindingRepository::new(db.clone())); + let server_feature_repo: Arc = + Arc::new(SqliteServerFeatureRepository::new(db.clone())); + + let space = space_repo.get_default().await.unwrap().unwrap(); + let client = Client::new("c", "t"); + let client_id = client.id; + client_repo.create(&client).await.unwrap(); + client_repo + .set_pin(&client_id, &space.id, None) + .await + .unwrap(); + + let resolver = Arc::new(FeatureSetResolverService::new( + client_repo.clone(), + space_repo.clone(), + binding_repo.clone(), + SessionRootsRegistry::new(), + )); + let prefix_cache = Arc::new(PrefixCacheService::new()); + let feature_service = Arc::new(FeatureService::new( + server_feature_repo.clone(), + feature_set_repo.clone(), + prefix_cache, + )); + let (tx, rx) = broadcast::channel::(32); + let registry = meta_tools::build_default_registry( + client_repo, + space_repo, + feature_set_repo, + binding_repo, + server_feature_repo, + resolver, + feature_service, + SessionRootsRegistry::new(), + Arc::new(ApprovalBroker::new()), + tx.clone(), + settings_repo, + ); + (registry, client_id, tx, rx) +} + +#[tokio::test(flavor = "multi_thread")] +async fn read_tool_emits_meta_tool_invoked_with_decision_read() { + let (registry, client_id, _tx, mut rx) = bare_registry(None).await; + + registry + .call( + "mcpmux_describe_resolution", + &client_id, + Some("s"), + json!({}), + ) + .await + .unwrap(); + + let evt = tokio::time::timeout(Duration::from_millis(200), rx.recv()) + .await + .expect("receive within 200ms") + .expect("event"); + match evt { + DomainEvent::MetaToolInvoked { + tool_name, + decision, + .. + } => { + assert_eq!(tool_name, "mcpmux_describe_resolution"); + assert_eq!(decision, "read"); + } + other => panic!("unexpected event: {other:?}"), + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn denied_write_emits_meta_tool_invoked_with_decision_deny() { + let (registry, client_id, _tx, mut rx) = bare_registry(None).await; + + // No publisher → write fails with ApprovalRequiredNoDesktop, which the + // registry's central audit-logger records as `approval_required`. + let _ = registry + .call( + "mcpmux_pin_this_session", + &client_id, + Some("s"), + json!({ "feature_set_id": Uuid::new_v4().to_string() }), + ) + .await; + let evt = tokio::time::timeout(Duration::from_millis(200), rx.recv()) + .await + .expect("receive within 200ms") + .expect("event"); + match evt { + DomainEvent::MetaToolInvoked { + decision, + tool_name, + .. + } => { + assert_eq!(tool_name, "mcpmux_pin_this_session"); + assert_eq!(decision, "approval_required"); + } + other => panic!("unexpected event: {other:?}"), + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn master_switch_toggles_registry_visibility() { + use mcpmux_storage::SqliteAppSettingsRepository; + + // Same DB so the settings repo and the registry see one another. + let db = Arc::new(Mutex::new(Database::open_in_memory().unwrap())); + let settings_repo: Arc = + Arc::new(SqliteAppSettingsRepository::new(db.clone())); + settings_repo + .set("gateway.meta_tools_enabled", "false") + .await + .unwrap(); + + let space_repo: Arc = Arc::new(SqliteSpaceRepository::new(db.clone())); + let feature_set_repo: Arc = + Arc::new(SqliteFeatureSetRepository::new(db.clone())); + let client_repo: Arc = + Arc::new(SqliteInboundMcpClientRepository::new(db.clone())); + let binding_repo: Arc = + Arc::new(SqliteWorkspaceBindingRepository::new(db.clone())); + let server_feature_repo: Arc = + Arc::new(SqliteServerFeatureRepository::new(db.clone())); + let resolver = Arc::new(FeatureSetResolverService::new( + client_repo.clone(), + space_repo.clone(), + binding_repo.clone(), + SessionRootsRegistry::new(), + )); + let prefix_cache = Arc::new(PrefixCacheService::new()); + let feature_service = Arc::new(FeatureService::new( + server_feature_repo.clone(), + feature_set_repo.clone(), + prefix_cache, + )); + let (tx, _) = broadcast::channel::(16); + let registry = meta_tools::build_default_registry( + client_repo, + space_repo, + feature_set_repo, + binding_repo, + server_feature_repo, + resolver, + feature_service, + SessionRootsRegistry::new(), + Arc::new(ApprovalBroker::new()), + tx, + Some(settings_repo.clone()), + ); + + assert!(!registry.is_enabled().await, "initially disabled"); + + settings_repo + .set("gateway.meta_tools_enabled", "true") + .await + .unwrap(); + assert!(registry.is_enabled().await, "flipped back on"); + + // Missing key → default on (fresh install). + settings_repo + .delete("gateway.meta_tools_enabled") + .await + .unwrap(); + assert!(registry.is_enabled().await, "missing key defaults on"); +} + // Silence unused-import warnings from helper imports that only some tests exercise. #[allow(dead_code)] fn _unused(_: ApprovalPayload) {} From b4ba091aac69812b43d04447f1206372b4ad9607 Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Tue, 21 Apr 2026 17:27:40 +0800 Subject: [PATCH 08/24] feat(ui): premium Active FeatureSet + empty-state Clients page onboarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three UX fixes driven by first real-app testing. FeatureSets page — Active state is now recognisable * Top-right corner ribbon with a gradient (emerald → green), Zap icon, uppercase "ACTIVE" label, and a soft outer glow. Hard to miss, reads "premium" not "toast notification". * Active cards get a green ring + emerald-tinted background wash + a stronger drop shadow so the whole card feels elevated, not just badged. * "Applied to this Space" caption replaces the footer Set-Active button when a card is Active — users don't have to hunt for what the ribbon means. * New explainer banner above the grid ("One Active FeatureSet per Space…") so the feature is self-documenting. "Set Active" button is now obviously a button * Proper pill with emerald border, hover-fill gradient, Zap icon. No longer competes visually with "Configure". * Loading state (spinner + "Activating…") fires during the Tauri round-trip so a slow backend doesn't feel like a no-op. * Triple event-guards on click (stopPropagation + preventDefault + nativeEvent.stopImmediatePropagation) so no wrapping card onClick can hijack the gesture. onMouseDown is also guarded. * Success toast now says "{name} is now Active" + describes scope so users know what they just did. Clients page — empty state is a guided three-step onboarding * Replaces the "No clients connected" card with a numbered walk-through: 1. Add mcpmux to your IDE (ConnectIDEs grid, embedded) 2. Restart / reconnect the MCP server in that IDE 3. Approve the connection here — "on this page" pill * ConnectIDEs is reused from the Dashboard so users never leave the page to finish the flow. Gateway URL + status are fetched on mount. * When the gateway is stopped, an amber warning box appears inside the card telling users it needs to be running, otherwise the IDE will hang at `initialize` (the exact bug that motivated this fix). * ConnectIDEs header description now reminds users to restart the MCP server in the IDE after add, and that approval shows up in this app. Cleanup * Drop a stale `eslint-disable no-console` directive in MetaToolApprovalDialog that the linter flagged as unused. pnpm typecheck + lint clean; no new warnings introduced. Existing 31 warnings are all pre-existing useEffect dependency suppressions. Signed-off-by: Mohammod Al Amin Ashik --- apps/desktop/src/components/ConnectIDEs.tsx | 4 +- .../src/features/clients/ClientsPage.tsx | 140 ++++++++++++++++-- .../features/featuresets/FeatureSetsPage.tsx | 138 ++++++++++++----- .../metaTools/MetaToolApprovalDialog.tsx | 1 - 4 files changed, 231 insertions(+), 52 deletions(-) diff --git a/apps/desktop/src/components/ConnectIDEs.tsx b/apps/desktop/src/components/ConnectIDEs.tsx index 28b8368..f19448b 100644 --- a/apps/desktop/src/components/ConnectIDEs.tsx +++ b/apps/desktop/src/components/ConnectIDEs.tsx @@ -127,7 +127,9 @@ export function ConnectIDEs({ gatewayUrl, gatewayRunning }: ConnectIDEsProps) {
Connect Your IDEs - Add McpMux to your AI clients. Auth happens on first connect. + Add mcpmux to your AI client, then restart / reconnect + the MCP server in that client — an approval prompt appears in this app when it + hits the gateway.
diff --git a/apps/desktop/src/features/clients/ClientsPage.tsx b/apps/desktop/src/features/clients/ClientsPage.tsx index 094ce06..0c7d3ea 100644 --- a/apps/desktop/src/features/clients/ClientsPage.tsx +++ b/apps/desktop/src/features/clients/ClientsPage.tsx @@ -25,7 +25,13 @@ import { Search, AlertCircle, Zap, + PlugZap, + RotateCcw, + CheckCircle2, } from 'lucide-react'; +import { ConnectIDEs } from '@/components/ConnectIDEs'; +import type { GatewayStatus } from '@/lib/api/gateway'; +import { getGatewayStatus } from '@/lib/api/gateway'; import { Card, CardContent, @@ -132,6 +138,15 @@ export default function ClientsPage() { const [editMode, setEditMode] = useState('follow_active'); const [editLockedSpaceId, setEditLockedSpaceId] = useState(''); const [isSaving, setIsSaving] = useState(false); + + // Gateway status for the empty-state ConnectIDEs embed — fetched once on + // mount + refreshed whenever the gateway starts/stops elsewhere in the app. + const [gatewayStatus, setGatewayStatus] = useState({ + running: false, + url: null, + active_sessions: 0, + connected_backends: 0, + }); // Feature set grant state const viewSpace = useViewSpace(); @@ -269,6 +284,9 @@ export default function ClientsPage() { useEffect(() => { loadData(); + // Prime the gateway status so the empty-state ConnectIDEs can show the + // real mcpmux URL. Silent failure — the empty state tolerates stale data. + getGatewayStatus().then(setGatewayStatus).catch(() => {}); }, []); // Auto-open a client panel when navigated from "Manage Permissions" @@ -603,20 +621,114 @@ export default function ClientsPage() {
) : filteredClients.length === 0 ? ( - - - -

- {searchQuery ? 'No clients match your search' : 'No clients connected'} -

-

- {searchQuery - ? 'Try adjusting your search terms' - : 'Clients like Cursor or VS Code will appear here after connecting via OAuth' - } -

-
-
+ searchQuery ? ( + // Narrow empty state: keep the original card + copy for the + // "no search results" case; ConnectIDEs only shows on + // truly-empty (no clients configured at all). + + + +

No clients match your search

+

+ Try adjusting your search terms. +

+
+
+ ) : ( + // True empty — nothing has connected yet. Give them everything + // they need to finish the flow: install the IDE config, restart + // the IDE, then come back and approve. Each step is a distinct + // visual block so users don't skim past "and then approve here". +
+ + +
+
+ +
+
+

Connect your first IDE

+

+ Nothing has connected to mcpmux yet. It only takes three steps: +

+
+
+ + {/* Three-step flow. Each step is explicit so users don't + get lost between "I added the config" and "why doesn't + my client show up here?". */} +
    +
  1. + + 1 + +
    +

    Add mcpmux to your IDE

    +

    + Pick one from the cards below — we'll copy the config or deep-link + straight into the IDE. +

    +
    +
  2. +
  3. + + + 2 + +
    +

    Start or restart the MCP server in that IDE

    +

    + Some clients connect automatically on restart; others (Cursor, Claude + Code) need you to toggle "mcpmux" on once. +

    +
    +
  4. +
  5. + + + 3 + +
    +

    + Approve the connection here + + on this page + +

    +

    + As soon as the IDE hits the gateway, mcpmux will pop an approval + dialog. Pick a Space + FeatureSet pin and the client shows up right + here. Nothing gets routed until you approve. +

    +
    +
  6. +
+ + {!gatewayStatus.running && ( +
+ +
+

+ Gateway is stopped +

+

+ Start it from the Dashboard first, otherwise your IDE will sit at + "initialize" forever. +

+
+
+ )} +
+
+ + {/* Reuse the shared ConnectIDEs grid from the Dashboard so users + don't have to navigate away. */} + +
+ ) ) : (
{filteredClients.map((client) => { diff --git a/apps/desktop/src/features/featuresets/FeatureSetsPage.tsx b/apps/desktop/src/features/featuresets/FeatureSetsPage.tsx index c8cd410..4290f66 100644 --- a/apps/desktop/src/features/featuresets/FeatureSetsPage.tsx +++ b/apps/desktop/src/features/featuresets/FeatureSetsPage.tsx @@ -12,6 +12,7 @@ import { Search, AlertCircle, CheckCircle2, + Zap, } from 'lucide-react'; import { Card, @@ -87,6 +88,10 @@ export function FeatureSetsPage() { // Tracked locally so the "Active" badge updates immediately after clicking // "Set Active" without waiting for a refetch of the whole viewSpace. const [activeFeatureSetId, setActiveFeatureSetId] = useState(null); + // Id of the FS whose "Set Active" button is mid-flight, so we can render + // a spinner in its place (otherwise the optimistic update swaps the button + // for the Active badge immediately and a slow backend feels like a no-op). + const [activatingId, setActivatingId] = useState(null); const loadData = useCallback(async (spaceId?: string) => { setIsLoading(true); @@ -113,19 +118,30 @@ export function FeatureSetsPage() { }, []); const handleSetActive = async (fs: FeatureSet, event: React.MouseEvent) => { - // Stop card-click propagation so we don't open the panel. + // Belt and suspenders: stop both the synthetic event AND the native + // event so the wrapping Card's onClick can't open the panel. event.stopPropagation(); + event.preventDefault(); + event.nativeEvent?.stopImmediatePropagation?.(); + if (!viewSpace) return; + if (activatingId) return; // Debounce double-clicks. + const previous = activeFeatureSetId; - // Optimistic update — revert on failure. - setActiveFeatureSetId(fs.id); + setActivatingId(fs.id); + setActiveFeatureSetId(fs.id); // Optimistic. try { await setSpaceActiveFeatureSet(viewSpace.id, fs.id); - success('Active FeatureSet updated', `"${fs.name}" is now the default for this space`); + success( + `${fs.name} is now Active`, + 'Applied to every connected client in this Space without a pin or workspace binding.' + ); } catch (e) { setActiveFeatureSetId(previous); const msg = e instanceof Error ? e.message : String(e); - showError('Failed to set active FeatureSet', msg); + showError('Failed to set Active FeatureSet', msg); + } finally { + setActivatingId(null); } }; @@ -276,6 +292,25 @@ export function FeatureSetsPage() {
+ {/* Active FeatureSet explainer — helps users understand what the green + ribbon / "Set Active" button actually does before they click around. */} +
+
+
+ +
+
+

+ One Active FeatureSet per Space +

+

+ The Active set is applied to every connected MCP client in this Space that doesn't + have an explicit pin or a matching workspace binding. Click Set Active on any card to make it the default. +

+
+
+
+ {/* Error */} {error && (
@@ -321,39 +356,54 @@ export function FeatureSetsPage() { const isBuiltin = fs.is_builtin; const isActive = activeFeatureSetId === fs.id; + const isActivating = activatingId === fs.id; + return ( handleOpenPanel(fs)} data-testid={`featureset-card-${fs.id}`} > + {/* Active ribbon — top-right corner badge, premium feel */} + {isActive && ( +
+ + Active +
+ )} + {/* Header */}
-
+
{getFeatureSetIcon(fs)}
-
-

- {fs.name} - {isActive && ( - - Active - - )} -

- +
+

{fs.name}

+ {getFeatureSetTypeName(fs.feature_set_type)}
@@ -364,9 +414,9 @@ export function FeatureSetsPage() { {fs.description || 'No description provided.'}

- {/* Footer Info */} -
-
+ {/* Footer — Set Active is now a prominent gradient button; Active state gets its own caption */} +
+
{fs.feature_set_type === 'server-all' ? ( {fs.server_id} ) : fs.feature_set_type === 'all' ? ( @@ -375,22 +425,38 @@ export function FeatureSetsPage() { {fs.members?.length || 0} members )}
-
- {!isActive && ( + +
+ {isActive ? ( + + + Applied to this Space + + ) : ( )} {isBuiltin && fs.feature_set_type !== 'default' ? ( - Auto-managed + Auto-managed ) : ( - + Configure )} diff --git a/apps/desktop/src/features/metaTools/MetaToolApprovalDialog.tsx b/apps/desktop/src/features/metaTools/MetaToolApprovalDialog.tsx index 2b9c017..9dea4c1 100644 --- a/apps/desktop/src/features/metaTools/MetaToolApprovalDialog.tsx +++ b/apps/desktop/src/features/metaTools/MetaToolApprovalDialog.tsx @@ -65,7 +65,6 @@ export function MetaToolApprovalDialog() { } catch (e) { // Log but don't block UI — broker will time out and surface // `approval_timed_out` to the tool caller. - // eslint-disable-next-line no-console console.warn('respond_to_meta_tool_approval failed', e); } finally { setQueue((prev) => prev.slice(1)); From ce1d3845fa2ac44d69a2ccdc7886669be6b4e3fb Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Tue, 21 Apr 2026 17:34:19 +0800 Subject: [PATCH 09/24] fix(ui): split one-click vs copy-paste install in Clients onboarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous empty-state flow lumped "Add to VS Code" (auto-install via deep link) and "Copy config" (clipboard-only — user has to paste) into the same numbered list, making users think clicking any card auto-installed. The copy-paste tiles only put text on the clipboard; nothing happens in the target IDE until the user pastes it into a config file and restarts. Split into two labeled tracks on the Clients empty state: **Track A — One-click** (VS Code, Cursor): 1. Click → Add to X — IDE opens and registers mcpmux 2. New chat / reload MCP 3. Approve here **Track B — Manual** (Windsurf, Claude Code, JetBrains, Android Studio): 1. Click → Copy config / Copy command (clipboard) 2. Paste into the IDE's MCP config file (or run the command) 3. Restart / reload MCP in the IDE 4. Approve here Also tightened the ConnectIDEs popover so each card's blurb tells the user exactly what pressing the button will do + what they must do next: * deep_link → "Opens the IDE and registers mcpmux automatically. Start a new chat / reload MCP, then approve on the Clients page." * copy_command → "Copies a terminal command… Run it, restart the IDE, approve on the Clients page." * copy_config → "Copies a JSON snippet… Paste into the IDE's MCP config file, restart the IDE, approve on the Clients page." Card header description now says up-front that VS Code + Cursor are one-click and everything else copies a config. The "Copied!" tick now reads "Copied — paste & restart" so users don't think the button finished the job. Signed-off-by: Mohammod Al Amin Ashik --- apps/desktop/src/components/ConnectIDEs.tsx | 23 ++- .../src/features/clients/ClientsPage.tsx | 145 ++++++++++++------ 2 files changed, 112 insertions(+), 56 deletions(-) diff --git a/apps/desktop/src/components/ConnectIDEs.tsx b/apps/desktop/src/components/ConnectIDEs.tsx index f19448b..900d6c7 100644 --- a/apps/desktop/src/components/ConnectIDEs.tsx +++ b/apps/desktop/src/components/ConnectIDEs.tsx @@ -127,9 +127,9 @@ export function ConnectIDEs({ gatewayUrl, gatewayRunning }: ConnectIDEsProps) {
Connect Your IDEs - Add mcpmux to your AI client, then restart / reconnect - the MCP server in that client — an approval prompt appears in this app when it - hits the gateway. + VS Code & Cursor are one-click; the rest copy + a config you paste into their MCP settings. Either path ends with an approval + prompt in this app.
@@ -176,14 +176,23 @@ export function ConnectIDEs({ gatewayUrl, gatewayRunning }: ConnectIDEsProps) { {/* Popover */} {isActive && (
{/* Arrow */}
-

- {entry.name} +

{entry.name}

+ + {/* Per-action "what happens + what's next" blurb. Users + kept asking "did it install? should I restart?" — + state both up front. */} +

+ {entry.action === 'deep_link' + ? 'Opens the IDE and registers mcpmux automatically. Start a new chat or reload MCP servers in the IDE, then approve on the Clients page.' + : entry.action === 'copy_command' + ? 'Copies a terminal command to your clipboard. Run it in your shell, then restart the IDE and approve on the Clients page.' + : 'Copies a JSON snippet to your clipboard. Paste it into the IDE’s MCP config file, restart the IDE, then approve on the Clients page.'}

{entry.action === 'deep_link' ? ( @@ -199,7 +208,7 @@ export function ConnectIDEs({ gatewayUrl, gatewayRunning }: ConnectIDEsProps) { ) : isCopied ? (
- Copied! + Copied — paste & restart
) : (
- {/* Three-step flow. Each step is explicit so users don't - get lost between "I added the config" and "why doesn't - my client show up here?". */} -
    -
  1. - - 1 - -
    -

    Add mcpmux to your IDE

    -

    - Pick one from the cards below — we'll copy the config or deep-link - straight into the IDE. -

    -
    -
  2. -
  3. - - - 2 - -
    -

    Start or restart the MCP server in that IDE

    -

    - Some clients connect automatically on restart; others (Cursor, Claude - Code) need you to toggle "mcpmux" on once. -

    + {/* Two distinct install paths. Mixing them into a single + numbered list made users think "copy config" auto-installs + somewhere — it doesn't; it's clipboard content they have + to paste themselves. Keep them separated. */} +
    + {/* Track A — one-click install (VS Code, Cursor) */} +
    +
    +
    + A +
    +

    + VS Code · Cursor + + One-click + +

    -
  4. -
  5. - - - 3 - -
    -

    - Approve the connection here - - on this page +

      +
    1. + 1. + + Click the IDE icon below → click Add to VS Code / + Add to Cursor. The IDE opens and registers mcpmux. -

      -

      - As soon as the IDE hits the gateway, mcpmux will pop an approval - dialog. Pick a Space + FeatureSet pin and the client shows up right - here. Nothing gets routed until you approve. -

      +
    2. +
    3. + 2. + + Start a new chat / reload the MCP server in that IDE so it actually + connects. + +
    4. +
    5. + 3. + + + Approve here. + {' '} + mcpmux will pop a dialog on this app — nothing is routed to the IDE + until you accept. + +
    6. +
    +
    + + {/* Track B — manual copy-paste (everyone else) */} +
    +
    +
    + B +
    +

    + Windsurf · Claude Code · JetBrains · Android Studio + + Manual + +

    -
  6. -
+
    +
  1. + 1. + + Click the IDE icon below → Copy config or + Copy command (your clipboard now has the + snippet). + +
  2. +
  3. + 2. + + Paste it into that IDE's MCP config file (or run the copied + command for Claude Code). + +
  4. +
  5. + 3. + + Restart the IDE or reload its MCP servers — mcpmux is not picked up + until you do. + +
  6. +
  7. + 4. + + + Approve here. + {' '} + mcpmux will pop a dialog on this app as soon as the IDE reaches the + gateway. + +
  8. +
+
+
{!gatewayStatus.running && (
@@ -712,8 +759,8 @@ export default function ClientsPage() { Gateway is stopped

- Start it from the Dashboard first, otherwise your IDE will sit at - "initialize" forever. + Start it from the Dashboard first — otherwise the IDE will hang at{' '} + initialize.

From 7dcd55485333dd6c2d21a113742c5bac2f1959a2 Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Tue, 21 Apr 2026 17:40:16 +0800 Subject: [PATCH 10/24] =?UTF-8?q?fix(ui):=20Clients=20onboarding=20?= =?UTF-8?q?=E2=80=94=20single=20generic=203-step=20card,=20always=20shown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the dual-track "Auto / Manual" breakdown with one beginner-oriented walkthrough that shows every time there are no connected clients. The dense side-by-side A/B tracks assumed the user already understood what deep-link vs. copy-config means; we were overexplaining to first-time users instead of just pointing at the IDE grid and saying "pick one, follow its prompt." * One welcome Card with "Let's hook up your first IDE" heading, lead sentence framing mcpmux ("one connection your AI client uses to reach every MCP server"), and three numbered steps: 1. Pick your IDE below and follow its prompt. 2. Open or restart the IDE. 3. Approve the connection right here. * Per-IDE "what this button does" detail lives where it belongs — inside the ConnectIDEs card popover, so users see it at the point of action rather than up-front. * No dismiss / localStorage flag. If the Clients page is empty, the user hasn't finished onboarding yet; showing the walkthrough every time is the right default. * Amber "gateway is stopped" inline warning is preserved — it solves the hang-at-initialize issue we hit earlier. Also dropped now-unused `RotateCcw` + `CheckCircle2` icon imports. Signed-off-by: Mohammod Al Amin Ashik --- .../src/features/clients/ClientsPage.tsx | 153 ++++++------------ 1 file changed, 52 insertions(+), 101 deletions(-) diff --git a/apps/desktop/src/features/clients/ClientsPage.tsx b/apps/desktop/src/features/clients/ClientsPage.tsx index 5a7d37f..c59f3c2 100644 --- a/apps/desktop/src/features/clients/ClientsPage.tsx +++ b/apps/desktop/src/features/clients/ClientsPage.tsx @@ -26,8 +26,6 @@ import { AlertCircle, Zap, PlugZap, - RotateCcw, - CheckCircle2, } from 'lucide-react'; import { ConnectIDEs } from '@/components/ConnectIDEs'; import type { GatewayStatus } from '@/lib/api/gateway'; @@ -147,6 +145,7 @@ export default function ClientsPage() { active_sessions: 0, connected_backends: 0, }); + // Feature set grant state const viewSpace = useViewSpace(); @@ -640,116 +639,68 @@ export default function ClientsPage() { // the IDE, then come back and approve. Each step is a distinct // visual block so users don't skim past "and then approve here".
- + {/* Generic 3-step welcome. Always visible while there are no + connected clients — no dismiss — because a user hitting + this page with nothing connected needs the instructions + every time, not just the first time. */} + -
+
-

Connect your first IDE

+

Let's hook up your first IDE

- Nothing has connected to mcpmux yet. Pick the path that matches your IDE - below — the approval prompt shows up on this page either way. + mcpmux is one connection your AI client uses to reach every MCP server. + Three steps and you're done:

- {/* Two distinct install paths. Mixing them into a single - numbered list made users think "copy config" auto-installs - somewhere — it doesn't; it's clipboard content they have - to paste themselves. Keep them separated. */} -
- {/* Track A — one-click install (VS Code, Cursor) */} -
-
-
- A -
-

- VS Code · Cursor - - One-click - -

+
    +
  1. + + 1 + +
    +

    Pick your IDE below and follow its prompt

    +

    + Each card tells you exactly what the button does — either one-click + install or copy a small config for you to paste. +

    -
      -
    1. - 1. - - Click the IDE icon below → click Add to VS Code / - Add to Cursor. The IDE opens and registers mcpmux. - -
    2. -
    3. - 2. - - Start a new chat / reload the MCP server in that IDE so it actually - connects. - -
    4. -
    5. - 3. - - - Approve here. - {' '} - mcpmux will pop a dialog on this app — nothing is routed to the IDE - until you accept. - -
    6. -
    -
- - {/* Track B — manual copy-paste (everyone else) */} -
-
-
- B -
-

- Windsurf · Claude Code · JetBrains · Android Studio - - Manual - -

+ +
  • + + 2 + +
    +

    Open or restart the IDE

    +

    + Your client only connects to mcpmux the next time its MCP servers + start up. +

    -
      -
    1. - 1. - - Click the IDE icon below → Copy config or - Copy command (your clipboard now has the - snippet). - -
    2. -
    3. - 2. - - Paste it into that IDE's MCP config file (or run the copied - command for Claude Code). - -
    4. -
    5. - 3. - - Restart the IDE or reload its MCP servers — mcpmux is not picked up - until you do. - -
    6. -
    7. - 4. - - - Approve here. - {' '} - mcpmux will pop a dialog on this app as soon as the IDE reaches the - gateway. +
    8. +
    9. + + 3 + +
      +

      + Approve the connection{' '} + + right here -

    10. -
    -
  • -
    +

    +

    + mcpmux will pop a dialog on this app the moment your IDE reaches + the gateway. Until you accept it, nothing is routed. +

    +
    + + {!gatewayStatus.running && (
    @@ -768,8 +719,8 @@ export default function ClientsPage() { - {/* Reuse the shared ConnectIDEs grid from the Dashboard so users - don't have to navigate away. */} + {/* IDE grid — each card's popover tells the user what pressing + the button actually does + what to do next. */} Date: Tue, 21 Apr 2026 17:46:06 +0800 Subject: [PATCH 11/24] fix(ConnectIDEs): per-IDE correct instructions + popover opens upward MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two distinct bugs in the IDE install flow, both fixed: 1. The popover anchored to the icon with `top-full mt-2`, opening downward. In the Clients empty state the grid sits near the bottom of its Card, so the action button ended up below the scroll viewport and users had to scroll to find it. Flipped to `bottom-full mb-2` + repositioned the arrow to the bottom edge; the popover now floats above the icon. 2. The per-card "what you do next" blurb was a switch on the action type (`deep_link` vs `copy_config` vs `copy_command`), which papered over real per-IDE differences. In particular the generic "restart the IDE" wording was wrong for several clients and missed the one-click tripwires that the generic "start a chat" path would never surface. Replaced with a per-entry `nextStep` string, each one written from the actual IDE flow: * VS Code — auto-starts the server; user only needs to run "MCP: Show Installed Servers" → Start if it doesn't come up on its own. * Cursor — explicit toggle required in Settings → MCP Tools; does NOT auto-start newly-added servers. * Windsurf — Cascade → MCP settings, paste + Refresh or reload. * Claude Code — `claude mcp add` needs a new session; existing sessions need /restart. * JetBrains / Android Studio — AI Assistant MCP config only reads on IDE startup, full restart required. * JSON — generic reminder that each client's reload path differs. The lingering "Copied — paste & restart" chip now reads "Copied — paste & follow above" so the text doesn't contradict nextStep for IDEs that require a toggle instead of a restart. pnpm typecheck + lint clean; warning count unchanged at 31. Signed-off-by: Mohammod Al Amin Ashik --- apps/desktop/src/components/ConnectIDEs.tsx | 64 ++++++++++++++++----- 1 file changed, 50 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/components/ConnectIDEs.tsx b/apps/desktop/src/components/ConnectIDEs.tsx index 900d6c7..498ac3e 100644 --- a/apps/desktop/src/components/ConnectIDEs.tsx +++ b/apps/desktop/src/components/ConnectIDEs.tsx @@ -18,6 +18,13 @@ interface GridEntry { icon?: string; action: GridAction; handler: (() => Promise) | string; + /** + * Per-IDE, what does the user actually have to do after the button fires? + * Each IDE's "make MCP server live" flow is different — VS Code auto-starts + * while Cursor needs the server toggled on, for example. Keep this wording + * specific; a generic "restart" message has already misled testers. + */ + nextStep: string; } interface ConnectIDEsProps { @@ -40,6 +47,11 @@ export function ConnectIDEs({ gatewayUrl, gatewayRunning }: ConnectIDEsProps) { icon: vscodeIcon, action: 'deep_link', handler: () => addToVscode(gatewayUrl), + nextStep: + 'Opens VS Code and drops mcpmux into mcp.json. VS Code starts the server ' + + 'automatically — if it doesn’t, open the Command Palette and run ' + + '"MCP: Show Installed Servers", then click Start on mcpmux. The approval ' + + 'prompt lands on this page.', }, { id: 'cursor', @@ -48,6 +60,10 @@ export function ConnectIDEs({ gatewayUrl, gatewayRunning }: ConnectIDEsProps) { icon: cursorIcon, action: 'deep_link', handler: () => addToCursor(gatewayUrl), + nextStep: + 'Opens Cursor and adds mcpmux to its config. Cursor does not auto-start ' + + 'new MCP servers — go to Settings → Features → MCP (or the MCP ' + + 'Tools panel) and toggle mcpmux on. The approval prompt lands on this page.', }, { id: 'windsurf', @@ -56,6 +72,10 @@ export function ConnectIDEs({ gatewayUrl, gatewayRunning }: ConnectIDEsProps) { icon: windsurfIcon, action: 'copy_config', handler: `"mcpmux": {\n "serverUrl": "${mcpUrl}"\n}`, + nextStep: + 'Copies a JSON snippet. In Windsurf, open Cascade → MCP settings, ' + + 'paste mcpmux under mcpServers, and hit "Refresh" (or reload Windsurf). ' + + 'Approve on this page when Windsurf reaches the gateway.', }, { id: 'claude-code', @@ -64,6 +84,10 @@ export function ConnectIDEs({ gatewayUrl, gatewayRunning }: ConnectIDEsProps) { icon: claudeIcon, action: 'copy_command', handler: `claude mcp add --transport http --scope user mcpmux ${mcpUrl}`, + nextStep: + 'Copies a `claude mcp add` command. Run it in your shell — Claude Code ' + + 'loads mcpmux on the next `claude` invocation (existing sessions need ' + + '/restart). Approve on this page when it connects.', }, { id: 'jetbrains', @@ -72,6 +96,10 @@ export function ConnectIDEs({ gatewayUrl, gatewayRunning }: ConnectIDEsProps) { icon: jetbrainsIcon, action: 'copy_config', handler: `"mcpmux": {\n "url": "${mcpUrl}"\n}`, + nextStep: + 'Copies a JSON snippet. Paste into the AI Assistant MCP config, then ' + + 'restart the IDE — JetBrains only reads MCP config on startup. Approve ' + + 'on this page.', }, { id: 'android-studio', @@ -80,6 +108,9 @@ export function ConnectIDEs({ gatewayUrl, gatewayRunning }: ConnectIDEsProps) { icon: androidStudioIcon, action: 'copy_config', handler: `"mcpmux": {\n "httpUrl": "${mcpUrl}"\n}`, + nextStep: + 'Copies a JSON snippet. Paste into Android Studio’s AI Assistant MCP ' + + 'config, then restart the IDE. Approve on this page.', }, { id: 'copy-config', @@ -87,6 +118,9 @@ export function ConnectIDEs({ gatewayUrl, gatewayRunning }: ConnectIDEsProps) { label: 'JSON', action: 'copy_config', handler: `"mcpmux": {\n "type": "http",\n "url": "${mcpUrl}"\n}`, + nextStep: + 'Copies a generic MCP JSON snippet. Paste into any MCP-compatible client ' + + 'and follow its reload instructions. Approve on this page when it connects.', }, ]; @@ -173,26 +207,24 @@ export function ConnectIDEs({ gatewayUrl, gatewayRunning }: ConnectIDEsProps) { {entry.label} - {/* Popover */} + {/* Popover — opens UPWARD. The grid usually sits at the + bottom of a Card (Dashboard + Clients empty state), so + opening downward put the action button below the scroll + viewport on first paint, forcing users to scroll to find + it. Anchor to the bottom of the trigger button instead. */} {isActive && (
    - {/* Arrow */} -
    -

    {entry.name}

    - {/* Per-action "what happens + what's next" blurb. Users - kept asking "did it install? should I restart?" — - state both up front. */} + {/* Per-IDE instructions. Not a switch on action type — + each IDE's post-install step is meaningfully different + (VS Code auto-starts, Cursor needs explicit toggle, + JetBrains needs a full restart, etc.). */}

    - {entry.action === 'deep_link' - ? 'Opens the IDE and registers mcpmux automatically. Start a new chat or reload MCP servers in the IDE, then approve on the Clients page.' - : entry.action === 'copy_command' - ? 'Copies a terminal command to your clipboard. Run it in your shell, then restart the IDE and approve on the Clients page.' - : 'Copies a JSON snippet to your clipboard. Paste it into the IDE’s MCP config file, restart the IDE, then approve on the Clients page.'} + {entry.nextStep}

    {entry.action === 'deep_link' ? ( @@ -208,7 +240,7 @@ export function ConnectIDEs({ gatewayUrl, gatewayRunning }: ConnectIDEsProps) { ) : isCopied ? (
    - Copied — paste & restart + Copied — paste & follow above
    ) : (
    From c8c3f1c20035aac0b7a32644ac26ecf376d31361 Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Tue, 21 Apr 2026 17:57:43 +0800 Subject: [PATCH 12/24] feat(ui): Contribute / Request affordances across the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users who hit a dead-end ("searched for a server that isn't in the registry", "found a bug", "wanted to suggest a feature") had no clear place to send that signal — they'd just close the app. This commit adds three coordinated entry points, all wired to the mcpmux and mcp-servers GitHub repos via the Tauri opener plugin. New shared module `lib/contribute.ts` * Centralises every external URL (main repo, servers repo, marketing site, bug-report, feature-request, request-server templates). * `CONTRIBUTE.requestServer(searchTerm?)` URL-encodes the search term into the GitHub issue title so a user coming from the empty-search state lands on a pre-populated form. * `openExternal(url)` wraps `openUrl` with the same opener-plugin fallback OAuthConsentModal uses. New shared components `components/Contribute.tsx` * `` — inline gradient card used on the empty-search state. Two-button footer: Request (gh issue) + Contribute (mcp-servers CONTRIBUTING.md). * `` — dropdown with Request new server / Report bug / Suggest feature / Open on GitHub. Reusable anywhere; lives in the Registry header today. Placements: * Registry page — ContributeMenu in the header (always visible), and RequestServerCTA rendered in the empty-results state with the active searchQuery threaded into the issue title. * Settings — new "Contribute & feedback" card with a 2-column grid of Request-server / Report-bug / Suggest-feature / Open-on-GitHub tiles (ContributeRow helper local to this file). All links open in the user's default browser via the opener plugin; no in-webview navigation. `pnpm typecheck` + lint clean (warnings unchanged at 31). Signed-off-by: Mohammod Al Amin Ashik --- apps/desktop/src/components/Contribute.tsx | 152 ++++++++++++++++++ .../src/features/registry/RegistryPage.tsx | 29 ++-- .../src/features/settings/SettingsPage.tsx | 87 ++++++++++ apps/desktop/src/lib/contribute.ts | 53 ++++++ 4 files changed, 312 insertions(+), 9 deletions(-) create mode 100644 apps/desktop/src/components/Contribute.tsx create mode 100644 apps/desktop/src/lib/contribute.ts diff --git a/apps/desktop/src/components/Contribute.tsx b/apps/desktop/src/components/Contribute.tsx new file mode 100644 index 0000000..b6ba1ac --- /dev/null +++ b/apps/desktop/src/components/Contribute.tsx @@ -0,0 +1,152 @@ +import { useEffect, useRef, useState } from 'react'; +import { Bug, Github, Heart, Lightbulb, Package, SendHorizontal } from 'lucide-react'; +import { Button } from '@mcpmux/ui'; +import { CONTRIBUTE, openExternal } from '@/lib/contribute'; + +/** + * Inline "Didn't find your server?" CTA used on empty search states in the + * Registry page and the Add-Custom-Server flow. + * + * Ships two buttons side-by-side: **Request** (opens a pre-labelled GitHub + * issue in mcp-servers with the search term in the title) and + * **Contribute** (opens the mcp-servers CONTRIBUTING guide). + */ +export function RequestServerCTA({ + searchTerm, + className, +}: { + searchTerm?: string; + className?: string; +}) { + return ( +
    +
    + +
    +
    +

    Don't see what you need?

    +

    + {searchTerm + ? `We couldn't find "${searchTerm}". Request it from the community registry or open a PR yourself.` + : 'Request a new server in the community registry, or add one yourself via a pull request.'} +

    +
    +
    + + +
    +
    + ); +} + +/** + * A persistent "Contribute / Report" dropdown menu — the single global + * affordance for: open GitHub repo, report a bug, request a feature, open + * the server registry. Place wherever you want a friendly "help make mcpmux + * better" call-to-action. + */ +export function ContributeMenu({ + variant = 'ghost', + size = 'sm', +}: { + variant?: 'primary' | 'secondary' | 'ghost'; + size?: 'sm' | 'md'; +}) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [open]); + + const items = [ + { + label: 'Request a new server', + caption: 'Ask the community to add an MCP server to the registry', + icon: Package, + href: CONTRIBUTE.requestServer(), + }, + { + label: 'Report a bug', + caption: 'Something broken in the desktop app or gateway', + icon: Bug, + href: CONTRIBUTE.bug, + }, + { + label: 'Suggest a feature', + caption: 'An idea for mcpmux itself', + icon: Lightbulb, + href: CONTRIBUTE.featureRequest, + }, + { + label: 'Open on GitHub', + caption: 'Browse source, issues, pull requests', + icon: Github, + href: CONTRIBUTE.repo, + }, + ]; + + return ( +
    + + {open && ( +
    + {items.map((item) => ( + + ))} +
    + )} +
    + ); +} diff --git a/apps/desktop/src/features/registry/RegistryPage.tsx b/apps/desktop/src/features/registry/RegistryPage.tsx index dda4e67..bd9b43a 100644 --- a/apps/desktop/src/features/registry/RegistryPage.tsx +++ b/apps/desktop/src/features/registry/RegistryPage.tsx @@ -12,6 +12,7 @@ import { ServerCard } from './ServerCard'; import { ServerDetailModal } from './ServerDetailModal'; import { useViewSpace, useNavigateTo } from '@/stores'; import { capture } from '@/lib/analytics'; +import { RequestServerCTA, ContributeMenu } from '@/components/Contribute'; export function RegistryPage() { const { @@ -140,13 +141,18 @@ export function RegistryPage() { {/* Header */}
    -
    -

    Discover Servers

    - {isOffline && ( - - Offline - - )} +
    +
    +

    Discover Servers

    + {isOffline && ( + + Offline + + )} +
    + {/* Always-reachable contribute menu — users don't have to trigger + an empty search to find the request / bug / feature links. */} +

    {isOffline @@ -242,7 +248,7 @@ export function RegistryPage() {

    ) : displayServers.length === 0 ? ( -
    +

    No servers found

    -

    Try adjusting your search or filters

    +

    Try adjusting your search or filters

    + {/* Empty-search CTA — push the user toward requesting or + contributing the missing server rather than just giving up. */} +
    + +
    ) : (
    diff --git a/apps/desktop/src/features/settings/SettingsPage.tsx b/apps/desktop/src/features/settings/SettingsPage.tsx index f357d6e..f9de28c 100644 --- a/apps/desktop/src/features/settings/SettingsPage.tsx +++ b/apps/desktop/src/features/settings/SettingsPage.tsx @@ -24,11 +24,17 @@ import { Trash2, BarChart3, Sparkles, + Github, + Bug, + Lightbulb, + Package, + Heart, } from 'lucide-react'; import { useAppStore, useTheme, useAnalyticsEnabled } from '@/stores'; import { UpdateChecker } from './UpdateChecker'; import { getMetaToolsEnabled, setMetaToolsEnabled } from '@/lib/api/metaTools'; import { MetaToolAuditLog, MetaToolGrantsPanel } from '@/features/metaTools'; +import { CONTRIBUTE, openExternal } from '@/lib/contribute'; interface StartupSettings { autoLaunch: boolean; @@ -406,6 +412,54 @@ export function SettingsPage() { + {/* Contribute & feedback — the single global "help make mcpmux + better" card. Mirrors the items in so power + users have quick access without digging into GitHub. */} + + + + + Contribute & feedback + + + mcpmux is open source. Request a server, report a bug, suggest a feature, or jump + straight to the source. + + + +
    + openExternal(CONTRIBUTE.requestServer())} + testId="contribute-request-server" + /> + openExternal(CONTRIBUTE.bug)} + testId="contribute-report-bug" + /> + openExternal(CONTRIBUTE.featureRequest)} + testId="contribute-feature-request" + /> + openExternal(CONTRIBUTE.repo)} + testId="contribute-open-github" + /> +
    +
    +
    + {/* Logs Section */} @@ -476,3 +530,36 @@ export function SettingsPage() { ); } + +/** + * Flat row used inside the Contribute card. Local to the Settings page — if + * we ever need this elsewhere, promote it into @mcpmux/ui. + */ +function ContributeRow({ + icon: Icon, + title, + subtitle, + onClick, + testId, +}: { + icon: React.ComponentType<{ className?: string }>; + title: string; + subtitle: string; + onClick: () => void; + testId?: string; +}) { + return ( + + ); +} diff --git a/apps/desktop/src/lib/contribute.ts b/apps/desktop/src/lib/contribute.ts new file mode 100644 index 0000000..022c9df --- /dev/null +++ b/apps/desktop/src/lib/contribute.ts @@ -0,0 +1,53 @@ +/** + * Links + helpers for "Contribute / Request / Report" CTAs scattered across + * the app (registry empty-state, settings, etc.). + * + * All URLs live here so we can update the target org / repo / site from one + * place instead of grepping for hardcoded strings. + * + * Open-in-browser goes through `openUrl` (our Tauri command wrapping + * `tauri-plugin-opener`) so the user's default browser handles the URL + * rather than loading it inside the webview. + */ + +import { openUrl } from '@/lib/api/gateway'; + +export const CONTRIBUTE = { + /** Main desktop + gateway repo. */ + repo: 'https://github.com/mcpmux/mcp-mux', + /** Community-maintained server-definition registry. */ + serversRepo: 'https://github.com/mcpmux/mcp-servers', + /** Marketing site. */ + site: 'https://mcpmux.com', + /** New bug report, pre-labelled. */ + bug: 'https://github.com/mcpmux/mcp-mux/issues/new?labels=bug', + /** Feature request for the app itself. */ + featureRequest: + 'https://github.com/mcpmux/mcp-mux/issues/new?labels=enhancement', + /** + * Request a new server definition in the community registry. Encodes the + * user's search term into the issue title when provided. + */ + requestServer(searchTerm?: string): string { + const base = + 'https://github.com/mcpmux/mcp-servers/issues/new?labels=server-request'; + if (!searchTerm) return base; + const title = encodeURIComponent(`Request: ${searchTerm.slice(0, 120)}`); + return `${base}&title=${title}`; + }, + /** Root of the server-definitions contributing guide. */ + contributeServer: 'https://github.com/mcpmux/mcp-servers/blob/main/CONTRIBUTING.md', +} as const; + +/** + * Open an external URL via the Tauri opener plugin. Falls back to the plugin + * directly if our gateway wrapper fails (mirrors OAuthConsentModal's pattern). + */ +export async function openExternal(url: string): Promise { + try { + await openUrl(url); + } catch { + const { openUrl: plugin } = await import('@tauri-apps/plugin-opener'); + await plugin(url); + } +} From 5846a98f1142e3e61c1fbdb7eb400b2087353ec1 Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Sat, 25 Apr 2026 16:56:01 +0800 Subject: [PATCH 13/24] feat(routing): workspace-root-driven FeatureSet resolution Replace per-client FeatureSet grants and the "active space" / "active feature set" concepts with a simpler model: a session's reported workspace root maps to a (Space, FeatureSet) pair via WorkspaceBinding; unmapped roots fall back to the system default Space and its Default FS. Backend - Drop client_feature_set_grants table and the entire grant API surface. - Drop space.active_feature_set_id, set_active_space, get_active_space. - New WorkspaceBinding entity + repository with longest-prefix lookup, cross-platform path normalization, and validation. - FeatureSetResolverService: resolve(session_id) -> (space, fs, source). - SessionRootsRegistry tracks per-session roots + last-resolved FS so the gateway can fire a per-peer tools/prompts/resources list_changed when a session's resolution flips post-init (roots arrive after initialize and now match a binding). - New get_workspace_effective_features command surfaces resolved FS members enriched with each backend's connection status. - Migrations 004 -> 007: workspace_bindings table, drop client pins, collapse FS types, drop space.active_feature_set_id. Desktop UI - New Workspaces tab: cards for live-reported roots + persisted bindings, native directory picker, debounced cross-platform path validation, status filter. - WorkspaceBindingSheet pops on first connect when roots are unmapped. - Inspector panel matches the FeatureSetPanel pattern: collapsible Mapping section (auto-save on edit, explicit submit on create) and collapsible Effective Features section grouping resolved members by server with availability progress bars and type-coded feature rows. - SpaceSwitcher loses the Set Active affordance; system tray submenu loses its active-checkmark and renames to "Switch Space". - New ConnectionCard component owns the dashboard URL/start surface. Tests - New workspace_binding_events integration tests cover per-peer list_changed on resolution flip. - appStore tests collapse activeSpaceId/viewSpaceId into one, e2e helpers swap getActiveSpace -> getDefaultSpace, comprehensive specs drop set-active flows. Signed-off-by: Mohammod Al Amin Ashik --- .github/ISSUE_TEMPLATE/config.yml | 11 +- AGENTS.md | 131 ++ apps/desktop/src-tauri/src/commands/client.rs | 279 +-- .../src/commands/client_custom_features.rs | 51 - .../src-tauri/src/commands/config_export.rs | 7 +- .../src-tauri/src/commands/feature_set.rs | 58 +- .../desktop/src-tauri/src/commands/gateway.rs | 637 ++++-- apps/desktop/src-tauri/src/commands/logs.rs | 6 +- apps/desktop/src-tauri/src/commands/mod.rs | 2 - apps/desktop/src-tauri/src/commands/oauth.rs | 409 +--- .../src-tauri/src/commands/settings.rs | 12 +- apps/desktop/src-tauri/src/commands/space.rs | 184 +- .../src/commands/workspace_binding.rs | 525 ++++- apps/desktop/src-tauri/src/lib.rs | 378 ++-- apps/desktop/src-tauri/src/state/mod.rs | 11 +- apps/desktop/src-tauri/src/tray.rs | 23 +- apps/desktop/src/App.tsx | 148 +- .../src/components/ConfigEditorModal.tsx | 7 + apps/desktop/src/components/ConnectIDEs.tsx | 208 +- .../desktop/src/components/ConnectionCard.tsx | 302 +++ .../src/components/OAuthConsentModal.tsx | 400 +--- apps/desktop/src/components/SpaceSwitcher.tsx | 97 +- .../src/features/clients/ClientsPage.tsx | 1693 ++++---------- .../features/featuresets/FeatureSetPanel.tsx | 75 +- .../features/featuresets/FeatureSetsPage.tsx | 157 +- .../gateway/AutoStartConflictResolver.tsx | 106 + .../features/gateway/useGatewayControl.tsx | 152 ++ .../src/features/servers/ServersPage.tsx | 20 +- .../src/features/settings/SettingsPage.tsx | 257 +++ .../src/features/spaces/SpacesPage.tsx | 101 +- .../workspaces/WorkspaceBindingSheet.tsx | 370 +++ .../features/workspaces/WorkspacesPage.tsx | 2007 +++++++++++++++-- apps/desktop/src/features/workspaces/index.ts | 1 + apps/desktop/src/hooks/useDataSync.ts | 25 +- apps/desktop/src/hooks/useSpaces.ts | 37 +- apps/desktop/src/lib/api/clients.ts | 138 +- apps/desktop/src/lib/api/featureSets.ts | 25 +- apps/desktop/src/lib/api/gateway.ts | 96 +- apps/desktop/src/lib/api/oauthClients.ts | 69 - apps/desktop/src/lib/api/spaces.ts | 65 +- apps/desktop/src/lib/api/workspaceBindings.ts | 139 +- apps/desktop/src/lib/contribute.ts | 26 +- apps/desktop/src/stores/appStore.ts | 43 +- apps/desktop/src/stores/selectors.ts | 16 +- apps/desktop/src/stores/types.ts | 19 +- crates/mcpmux-core/src/application/mod.rs | 3 +- .../mcpmux-core/src/application/permission.rs | 163 +- crates/mcpmux-core/src/application/server.rs | 32 +- crates/mcpmux-core/src/application/space.rs | 38 +- crates/mcpmux-core/src/domain/client.rs | 107 +- crates/mcpmux-core/src/domain/event.rs | 162 +- crates/mcpmux-core/src/domain/feature_set.rs | 138 +- crates/mcpmux-core/src/domain/mod.rs | 5 +- crates/mcpmux-core/src/domain/space.rs | 8 - .../src/domain/workspace_binding.rs | 564 ++++- crates/mcpmux-core/src/repository/mod.rs | 102 +- .../mcpmux-core/src/service/client_service.rs | 170 -- .../src/service/gateway_port_service.rs | 27 + crates/mcpmux-core/src/service/mod.rs | 4 - .../src/service/permission_service.rs | 344 --- .../mcpmux-core/src/service/space_service.rs | 24 +- .../src/consumers/mcp_notifier.rs | 92 +- crates/mcpmux-gateway/src/lib.rs | 3 +- crates/mcpmux-gateway/src/mcp/handler.rs | 206 +- crates/mcpmux-gateway/src/oauth/dcr.rs | 133 +- crates/mcpmux-gateway/src/oauth/mod.rs | 5 +- .../src/pool/features/discovery.rs | 25 +- .../src/pool/features/facade.rs | 5 +- .../src/pool/features/resolution.rs | 142 +- crates/mcpmux-gateway/src/server/handlers.rs | 54 +- crates/mcpmux-gateway/src/server/mod.rs | 85 +- .../src/server/service_container.rs | 24 +- .../src/services/authorization.rs | 60 +- .../src/services/client_metadata_service.rs | 2 - .../src/services/feature_set_resolver.rs | 162 +- .../src/services/grant_service.rs | 131 +- .../src/services/meta_tools/diff.rs | 10 +- .../src/services/meta_tools/mod.rs | 2 - .../src/services/meta_tools/tools.rs | 237 +- .../src/services/session_roots.rs | 71 +- .../src/services/space_resolver.rs | 98 +- crates/mcpmux-storage/src/database.rs | 20 + .../src/migrations/004_workspace_modes.sql | 77 + .../src/migrations/005_drop_client_pin.sql | 95 + .../migrations/006_collapse_feature_sets.sql | 119 + .../src/migrations/007_concrete_binding.sql | 73 + .../repositories/feature_set_repository.rs | 149 +- .../repositories/inbound_client_repository.rs | 132 +- .../inbound_mcp_client_repository.rs | 347 +-- .../src/repositories/space_repository.rs | 180 +- .../workspace_binding_repository.rs | 211 +- scripts/take-screenshots.cjs | 2 - tests/e2e/helpers/tauri-api.ts | 75 +- tests/e2e/pages/ClientsPage.ts | 2 +- tests/e2e/specs/capture-screenshots.manual.ts | 10 +- tests/e2e/specs/clients.spec.ts | 173 +- tests/e2e/specs/clients.wdio.ts | 142 +- tests/e2e/specs/comprehensive.wdio.ts | 98 +- tests/e2e/specs/gateway.wdio.ts | 12 +- tests/e2e/specs/post-action-guidance.spec.ts | 20 +- tests/e2e/specs/spaces.spec.ts | 20 +- tests/e2e/specs/spaces.wdio.ts | 25 +- tests/e2e/specs/workspaces.wdio.ts | 184 ++ tests/rust/src/lib.rs | 14 - tests/rust/src/mocks.rs | 180 -- tests/rust/tests/database/feature_set.rs | 120 +- tests/rust/tests/database/inbound_client.rs | 35 +- tests/rust/tests/database/repositories.rs | 4 +- .../rust/tests/integration/feature_grants.rs | 685 ------ .../tests/integration/feature_set_resolver.rs | 263 +-- tests/rust/tests/integration/mcp_flows.rs | 82 +- tests/rust/tests/integration/meta_tools.rs | 201 +- tests/rust/tests/integration/mod.rs | 2 +- .../integration/workspace_binding_events.rs | 207 ++ tests/rust/tests/oauth/flow.rs | 4 + .../streamable_http/gateway_notifications.rs | 62 +- tests/ts/components/App.test.tsx | 88 +- tests/ts/stores/appStore.test.ts | 133 +- 118 files changed, 8640 insertions(+), 8567 deletions(-) create mode 100644 AGENTS.md delete mode 100644 apps/desktop/src-tauri/src/commands/client_custom_features.rs create mode 100644 apps/desktop/src/components/ConnectionCard.tsx create mode 100644 apps/desktop/src/features/gateway/AutoStartConflictResolver.tsx create mode 100644 apps/desktop/src/features/gateway/useGatewayControl.tsx create mode 100644 apps/desktop/src/features/workspaces/WorkspaceBindingSheet.tsx delete mode 100644 apps/desktop/src/lib/api/oauthClients.ts delete mode 100644 crates/mcpmux-core/src/service/client_service.rs delete mode 100644 crates/mcpmux-core/src/service/permission_service.rs create mode 100644 crates/mcpmux-storage/src/migrations/004_workspace_modes.sql create mode 100644 crates/mcpmux-storage/src/migrations/005_drop_client_pin.sql create mode 100644 crates/mcpmux-storage/src/migrations/006_collapse_feature_sets.sql create mode 100644 crates/mcpmux-storage/src/migrations/007_concrete_binding.sql create mode 100644 tests/e2e/specs/workspaces.wdio.ts delete mode 100644 tests/rust/tests/integration/feature_grants.rs create mode 100644 tests/rust/tests/integration/workspace_binding_events.rs diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index bbecdcf..fc66845 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,4 +1,4 @@ -blank_issues_enabled: false +blank_issues_enabled: true contact_links: - name: Questions & Help url: https://github.com/mcpmux/mcp-mux/discussions/categories/q-a @@ -6,3 +6,12 @@ contact_links: - name: Feature Ideas url: https://github.com/mcpmux/mcp-mux/discussions/categories/ideas about: Share and discuss feature ideas + - name: Contribute a Server Definition (PR) + url: https://github.com/mcpmux/mcp-servers/blob/main/CONTRIBUTING.md + about: Server definitions live in the mcp-servers repo and land via PR — read the guide + - name: Request a Server + url: https://github.com/mcpmux/mcp-servers/issues/new?template=request-server.yml + about: Ask the community to add an MCP server to the registry + - name: Report a Server Definition Bug + url: https://github.com/mcpmux/mcp-servers/issues/new?template=bug-report.yml + about: Found a broken or incorrect server in the registry? Report it here diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b6e7a2d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,131 @@ +# AGENTS.md + +Guidance for coding agents working inside the `mcp-mux` repo — the McpMux desktop app and local gateway. Complements [`README.md`](README.md) and [`CONTRIBUTING.md`](CONTRIBUTING.md); when anything here conflicts with an explicit user instruction in the current session, the user wins. + +## Project Overview + +McpMux is a Tauri 2 desktop app (Rust + React 19) with a local Axum HTTP gateway on `localhost:45818`. It lets users configure MCP servers once and connect every AI client (Cursor, Claude Desktop, VS Code, Windsurf) through a single endpoint, with credentials encrypted in the OS keychain instead of plain-text JSON files. + +A more detailed map of the workspace lives in [`CLAUDE.md`](CLAUDE.md) at the repo root — read it for the crate layout, frontend architecture, and cross-project context. This file captures the minimum an agent needs to make safe, useful changes here. + +## Workspace Layout + +``` +mcp-mux/ +├── apps/desktop/ # Tauri shell — React frontend (src/) + Rust Tauri commands (src-tauri/) +├── crates/ +│ ├── mcpmux-core/ # Domain entities, repository traits, service layer, EventBus +│ ├── mcpmux-gateway/ # Axum gateway — routing, OAuth refresh, FeatureSet filtering +│ ├── mcpmux-storage/ # SQLite + AES-256-GCM field encryption + OS keychain +│ └── mcpmux-mcp/ # MCP protocol client wrapper (rmcp SDK) +├── packages/ui/ # Shared UI components (`@mcpmux/ui`) +├── schemas/ # JSON Schemas surfaced in the Monaco editor +└── tests/ # Rust integration, TS unit (vitest), desktop E2E (WDIO), web E2E (playwright) +``` + +## Build & Dev Commands + +Run everything from `mcp-mux/`: + +| Command | What it does | +|---------|--------------| +| `pnpm setup` | First-time dev environment setup (PowerShell on Windows). | +| `pnpm dev` | Tauri desktop dev mode (Rust + React hot-reload). | +| `pnpm dev:web` | Web UI only via Vite — no Rust, no Tauri shell. | +| `pnpm build` | Production Tauri build for the current platform. | +| `pnpm validate` | Full correctness gate — runs the items below in sequence. | +| `pnpm lint` | ESLint (recursive) + `cargo clippy --workspace -- -D warnings`. | +| `pnpm lint:fix` | Auto-fix lint issues. | +| `pnpm format` | `prettier --write .` + `cargo fmt --all`. | +| `pnpm format:check` | Formatting check (no writes). | +| `pnpm typecheck` | Recursive TypeScript typecheck. | + +**Before claiming a change is done**, run `pnpm validate` (or the relevant subset) — it mirrors what CI enforces. + +## Testing + +| Command | Scope | +|---------|-------| +| `pnpm test` | Rust + TypeScript, everything. | +| `pnpm test:rust` | `cargo nextest run --workspace`. | +| `pnpm test:rust:unit` | `cargo nextest run --workspace --lib`. | +| `pnpm test:rust:int` | `cargo nextest run -p tests` — integration crate in `tests/rust`. | +| `pnpm test:rust:doc` | `cargo test --workspace --doc`. | +| `pnpm test:ts` | Vitest run (`tests/ts/vitest.config.ts`). | +| `pnpm test:ts:watch` | Vitest watch. | +| `pnpm test:e2e` | Desktop E2E via WebDriver IO — requires `MCPMUX_REGISTRY_URL`. | +| `pnpm test:e2e:file -- tests/e2e/specs/foo.ts` | One WDIO spec file. | +| `pnpm test:e2e:grep -- "test name"` | WDIO tests matching a name. | +| `pnpm test:e2e:web` | Playwright on the web UI. | +| `pnpm test:coverage` | `cargo llvm-cov` + Vitest coverage. | + +Prefer narrow commands over `pnpm test` while iterating — the full suite is slow. + +## Code Style + +- **Rust:** 100-char max width, 4-space indent. Clippy runs with `avoid-breaking-exported-api = false`; all warnings are denied in CI. +- **TypeScript / JSX:** Prettier — single quotes, 2-space indent, 100-char width, trailing commas (es5), Tailwind CSS plugin for class ordering. +- **Path aliases:** `@/` → `apps/desktop/src/`; `@mcpmux/ui` → `packages/ui`. +- **No emojis in code or commits** unless the user explicitly asks for them. +- **Comments:** only when the *why* is non-obvious. Identifiers should explain the *what*. + +## Commit & PR Guidelines + +- Commits must be **signed off** (DCO): `git commit -s -m "..."`. CI rejects unsigned commits. +- Prefer conventional-style subjects — releases use release-please for semantic versioning. +- PRs follow [`.github/pull_request_template.md`](.github/pull_request_template.md): describe the change, how you tested, and check the `pnpm test` / `pnpm lint` / `pnpm typecheck` boxes. +- Don't bypass hooks (`--no-verify`) or DCO signing unless explicitly told to. + +## Platform Gotchas + +### Child-process flags + +Anything that spawns a child process (stdio MCP servers, installers, etc.) **must** go through `mcpmux_gateway::pool::transport::configure_child_process_platform()`. That helper applies: + +- **Windows:** `CREATE_NO_WINDOW` (`0x08000000`) — release builds use `windows_subsystem = "windows"`, so without this the OS briefly flashes a console window when a child starts. +- **Unix:** `process_group(0)` — stops SIGINT/SIGTSTP from the parent terminal from tearing down the child. + +`tokio::process::Command` already exposes `creation_flags()` (Windows) and `process_group()` (Unix). **Do not** import `std::os::*::process::CommandExt` — those traits are unused with Tokio's `Command` and trigger clippy. + +### Cross-platform CI + +- The pre-commit hook runs `cargo clippy --workspace -- -D warnings` on your dev machine. +- `#[cfg(unix)]` only compiles on Unix; `#[cfg(windows)]` only on Windows. CI is Linux, so Windows-gated code is **not** linted in CI, and Unix-gated code is not linted on a Windows dev box. +- When you touch platform-conditional code, check the *other* platform compiles before pushing — CI won't catch a Windows-only clippy regression. + +### Secret handling + +- Never log tokens, API keys, headers with auth material, or raw OAuth responses. Use the existing sanitised-log helpers in `mcpmux-gateway`. +- Credentials encrypt at rest via AES-256-GCM in SQLite plus DPAPI (Windows) / OS keychain (macOS, Linux). Don't add new code paths that persist secrets any other way. +- Secrets should be wiped from memory after use via `zeroize`. +- The gateway binds to `127.0.0.1`. Don't bind to `0.0.0.0` or expose it on the network. + +## Frontend Notes + +- Entry point: `apps/desktop/src/main.tsx` → `App.tsx`. +- Global state: a single Zustand store at `src/stores/appStore.ts`. +- Key hooks: `useServerManager` (server CRUD), `useSpaces` (workspace switching), `useDomainEvents` (Rust-side EventBus listener), `useDataSync`. +- UI: React 19, Tailwind CSS, Lucide icons, Monaco Editor for JSON config surfaces. +- Open external URLs through `openExternal` in `apps/desktop/src/lib/contribute.ts` — it routes through the Tauri opener plugin so links open in the user's default browser, not the webview. +- For UI changes, launch `pnpm dev` and exercise the feature in the running app before reporting done — typecheck and tests verify correctness, not UX regressions. + +## Rust Architecture Cues + +- Cross-layer communication goes through the `EventBus` in `mcpmux-core`. Prefer emitting a domain event over reaching across module boundaries directly. +- Storage is behind repository traits — don't call SQLx or SQLite APIs directly from gateway or app code; add or use a repo method. +- Services are wired up via the `ApplicationServices` builders in `mcpmux-core`. New services should follow the same DI pattern. + +## MCP Specification + +The full MCP spec is vendored at `../modelcontextprotocol/docs/specification/`. Default to the latest stable version (`2025-11-25`) and **read the relevant section before** implementing or modifying protocol behaviour (transports, lifecycle, capability negotiation, OAuth flows, tools / resources / prompts). For features targeting a specific protocol version, use that version's folder. + +## Server Definitions + +Server catalog entries live in the separate [`mcp-servers`](https://github.com/mcpmux/mcp-servers) repo — **not here**. If a task involves adding, editing, or fixing a server definition, switch to that repo and follow its `AGENTS.md`. + +## Things Not To Do + +- Don't add backwards-compatibility shims, deprecated aliases, or `// removed` placeholder comments when removing code — delete it cleanly. +- Don't introduce new fallbacks or input validation for states that are already framework-guaranteed. Trust internal invariants; validate only at the boundary (user input, external APIs). +- Don't edit generated files: `CHANGELOG.md`, release-please manifests, `bundle/*.json` in sibling repos, `packages/ui/dist`. +- Don't commit screenshots, videos, or large binaries to the repo — link out instead. diff --git a/apps/desktop/src-tauri/src/commands/client.rs b/apps/desktop/src-tauri/src/commands/client.rs index 14685de..707f985 100644 --- a/apps/desktop/src-tauri/src/commands/client.rs +++ b/apps/desktop/src-tauri/src/commands/client.rs @@ -1,16 +1,14 @@ //! Client management commands //! -//! IPC commands for managing AI clients (Cursor, VS Code, etc.). +//! Identity-only surface: list, get, create, delete, and preset seeding. +//! Connection modes and per-client FeatureSet grants no longer exist — +//! routing is entirely driven by WorkspaceBinding + Space default FS. -use mcpmux_core::{Client, ConnectionMode}; +use mcpmux_core::Client; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::sync::Arc; use tauri::State; -use tokio::sync::RwLock; use uuid::Uuid; -use crate::commands::gateway::GatewayAppState; use crate::state::AppState; /// Response for client listing @@ -19,41 +17,15 @@ pub struct ClientResponse { pub id: String, pub name: String, pub client_type: String, - pub connection_mode: String, - pub locked_space_id: Option, - pub grants: HashMap>, - /// Resolver v2: Space this access key belongs to (chosen at approval). - pub pinned_space_id: Option, - /// Resolver v2: explicit FS pin (`None` → follow workspace / space active). - pub pinned_feature_set_id: Option, pub last_seen: Option, } impl From for ClientResponse { fn from(c: Client) -> Self { - let (mode, locked_id) = match &c.connection_mode { - ConnectionMode::Locked { space_id } => { - ("locked".to_string(), Some(space_id.to_string())) - } - ConnectionMode::FollowActive => ("follow_active".to_string(), None), - ConnectionMode::AskOnChange { .. } => ("ask_on_change".to_string(), None), - }; - - let grants: HashMap> = c - .grants - .iter() - .map(|(k, v)| (k.to_string(), v.iter().map(|u| u.to_string()).collect())) - .collect(); - Self { id: c.id.to_string(), name: c.name, client_type: c.client_type, - connection_mode: mode, - locked_space_id: locked_id, - grants, - pinned_space_id: c.pinned_space_id.map(|u| u.to_string()), - pinned_feature_set_id: c.pinned_feature_set_id.map(|u| u.to_string()), last_seen: c.last_seen.map(|dt| dt.to_rfc3339()), } } @@ -64,15 +36,6 @@ impl From for ClientResponse { pub struct CreateClientInput { pub name: String, pub client_type: String, - pub connection_mode: String, - pub locked_space_id: Option, -} - -/// Input for updating client grants -#[derive(Debug, Deserialize)] -pub struct UpdateGrantsInput { - pub space_id: String, - pub feature_set_ids: Vec, } /// List all clients. @@ -109,20 +72,7 @@ pub async fn create_client( input: CreateClientInput, state: State<'_, AppState>, ) -> Result { - let connection_mode = match input.connection_mode.as_str() { - "locked" => { - let space_id = input - .locked_space_id - .ok_or("locked_space_id required for locked mode")?; - let uuid = Uuid::parse_str(&space_id).map_err(|e| e.to_string())?; - ConnectionMode::Locked { space_id: uuid } - } - "ask_on_change" => ConnectionMode::AskOnChange { triggers: vec![] }, - _ => ConnectionMode::FollowActive, - }; - - let mut client = Client::new(&input.name, &input.client_type); - client.connection_mode = connection_mode; + let client = Client::new(&input.name, &input.client_type); state .client_repository @@ -133,48 +83,6 @@ pub async fn create_client( Ok(client.into()) } -/// Pin a client to a Space + optional FeatureSet (resolver v2). -/// -/// Precedence used by [`mcpmux_gateway::services::FeatureSetResolverService`]: -/// 1. `pinned_feature_set_id` (this pin) → source = Pin -/// 2. workspace binding matches a root → source = WorkspaceBinding -/// 3. space's `active_feature_set_id` → source = SpaceActive -/// -/// Pass `pinned_feature_set_id = None` to let the resolver fall through to -/// workspace/space default. -#[tauri::command] -pub async fn update_client_pin( - client_id: String, - pinned_space_id: String, - pinned_feature_set_id: Option, - state: State<'_, AppState>, -) -> Result<(), String> { - let client_uuid = Uuid::parse_str(&client_id).map_err(|e| e.to_string())?; - let space_uuid = Uuid::parse_str(&pinned_space_id).map_err(|e| e.to_string())?; - let fs_uuid = pinned_feature_set_id - .as_ref() - .map(|s| Uuid::parse_str(s)) - .transpose() - .map_err(|e| e.to_string())?; - - state - .client_repository - .set_pin(&client_uuid, &space_uuid, fs_uuid.as_ref()) - .await - .map_err(|e| { - tracing::error!("[update_client_pin] {e}"); - e.to_string() - })?; - - tracing::info!( - client_id = %client_id, - pinned_space_id = %pinned_space_id, - pinned_feature_set_id = ?pinned_feature_set_id, - "[update_client_pin] pin updated", - ); - Ok(()) -} - /// Delete a client. #[tauri::command] pub async fn delete_client(id: String, state: State<'_, AppState>) -> Result<(), String> { @@ -186,180 +94,6 @@ pub async fn delete_client(id: String, state: State<'_, AppState>) -> Result<(), .map_err(|e| e.to_string()) } -/// Update client grants for a specific space (using client_grants table). -#[tauri::command] -pub async fn update_client_grants( - client_id: String, - input: UpdateGrantsInput, - state: State<'_, AppState>, -) -> Result { - let client_uuid = Uuid::parse_str(&client_id).map_err(|e| e.to_string())?; - - // Verify client exists - let client = state - .client_repository - .get(&client_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or("Client not found")?; - - // Update grants using the client_grants table - state - .client_repository - .set_grants_for_space(&client_uuid, &input.space_id, &input.feature_set_ids) - .await - .map_err(|e| e.to_string())?; - - Ok(client.into()) -} - -/// Get effective grants for a specific client and space. -/// This includes explicit grants PLUS the default feature set (merged as a set). -#[tauri::command] -pub async fn get_client_grants( - client_id: String, - space_id: String, - state: State<'_, AppState>, -) -> Result, String> { - let client_uuid = Uuid::parse_str(&client_id).map_err(|e| e.to_string())?; - - // Get effective grants (explicit + default, deduplicated) - state - .client_service - .get_effective_grants(&client_uuid, &space_id) - .await - .map_err(|e| e.to_string()) -} - -/// Get all grants for a client across all spaces. -#[tauri::command] -pub async fn get_all_client_grants( - client_id: String, - state: State<'_, AppState>, -) -> Result>, String> { - let client_uuid = Uuid::parse_str(&client_id).map_err(|e| e.to_string())?; - - state - .client_repository - .get_all_grants(&client_uuid) - .await - .map_err(|e| e.to_string()) -} - -/// Grant a specific feature set to a client. -/// -/// Emits MCP list_changed notifications to connected clients. -#[tauri::command] -pub async fn grant_feature_set_to_client( - client_id: String, - space_id: String, - feature_set_id: String, - state: State<'_, AppState>, - gateway_state: State<'_, Arc>>, -) -> Result<(), String> { - let client_uuid = Uuid::parse_str(&client_id).map_err(|e| e.to_string())?; - let space_uuid = Uuid::parse_str(&space_id).map_err(|e| e.to_string())?; - - // Grant the feature set - state - .client_repository - .grant_feature_set(&client_uuid, &space_id, &feature_set_id) - .await - .map_err(|e| e.to_string())?; - - // Emit notifications if gateway is running - let gw_state = gateway_state.read().await; - if let Some(ref emitter) = gw_state.event_emitter { - emitter.emit_all_changed_for_space(space_uuid); - } - - Ok(()) -} - -/// Revoke a specific feature set from a client. -/// -/// Emits MCP list_changed notifications to connected clients. -#[tauri::command] -pub async fn revoke_feature_set_from_client( - client_id: String, - space_id: String, - feature_set_id: String, - state: State<'_, AppState>, - gateway_state: State<'_, Arc>>, -) -> Result<(), String> { - let client_uuid = Uuid::parse_str(&client_id).map_err(|e| e.to_string())?; - let space_uuid = Uuid::parse_str(&space_id).map_err(|e| e.to_string())?; - - // Revoke the feature set - state - .client_repository - .revoke_feature_set(&client_uuid, &space_id, &feature_set_id) - .await - .map_err(|e| e.to_string())?; - - // Emit notifications if gateway is running - let gw_state = gateway_state.read().await; - if let Some(ref emitter) = gw_state.event_emitter { - emitter.emit_all_changed_for_space(space_uuid); - } - - Ok(()) -} - -/// Update client connection mode. -/// -/// Emits MCP list_changed notifications when the client's effective space changes. -#[tauri::command] -pub async fn update_client_mode( - client_id: String, - mode: String, - locked_space_id: Option, - state: State<'_, AppState>, - gateway_state: State<'_, Arc>>, -) -> Result { - let client_uuid = Uuid::parse_str(&client_id).map_err(|e| e.to_string())?; - - let mut client = state - .client_repository - .get(&client_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or("Client not found")?; - - client.connection_mode = match mode.as_str() { - "locked" => { - let space_id = locked_space_id.ok_or("locked_space_id required for locked mode")?; - let uuid = Uuid::parse_str(&space_id).map_err(|e| e.to_string())?; - ConnectionMode::Locked { space_id: uuid } - } - "ask_on_change" => ConnectionMode::AskOnChange { triggers: vec![] }, - _ => ConnectionMode::FollowActive, - }; - client.updated_at = chrono::Utc::now(); - - state - .client_repository - .update(&client) - .await - .map_err(|e| e.to_string())?; - - // Emit notifications for the space this client is now using - let gw_state = gateway_state.read().await; - if let Some(emitter) = &gw_state.event_emitter { - match &client.connection_mode { - ConnectionMode::Locked { space_id } => { - emitter.emit_all_changed_for_space(*space_id); - } - _ => { - // For follow_active or ask_on_change, notifications will be sent - // when the client reconnects and resolves its space - } - } - } - - Ok(client.into()) -} - /// Create preset clients (Cursor, VS Code, Claude Desktop). #[tauri::command] pub async fn init_preset_clients(state: State<'_, AppState>) -> Result<(), String> { @@ -369,7 +103,6 @@ pub async fn init_preset_clients(state: State<'_, AppState>) -> Result<(), Strin .await .map_err(|e| e.to_string())?; - // Create Cursor if not exists if !existing.iter().any(|c| c.client_type == "cursor") { let cursor = Client::cursor(); state @@ -379,7 +112,6 @@ pub async fn init_preset_clients(state: State<'_, AppState>) -> Result<(), Strin .map_err(|e| e.to_string())?; } - // Create VS Code if not exists if !existing.iter().any(|c| c.client_type == "vscode") { let vscode = Client::vscode(); state @@ -389,7 +121,6 @@ pub async fn init_preset_clients(state: State<'_, AppState>) -> Result<(), Strin .map_err(|e| e.to_string())?; } - // Create Claude Desktop if not exists if !existing.iter().any(|c| c.client_type == "claude") { let claude = Client::claude_desktop(); state diff --git a/apps/desktop/src-tauri/src/commands/client_custom_features.rs b/apps/desktop/src-tauri/src/commands/client_custom_features.rs deleted file mode 100644 index 0273006..0000000 --- a/apps/desktop/src-tauri/src/commands/client_custom_features.rs +++ /dev/null @@ -1,51 +0,0 @@ -//! Commands for managing client-specific custom feature sets - -use crate::state::AppState; -use mcpmux_core::{FeatureSet, FeatureSetType}; -use tauri::State; - -/// Find or create a custom feature set for a specific client in a space -/// This ensures only one custom feature set exists per client per space -#[tauri::command] -pub async fn find_or_create_client_custom_feature_set( - state: State<'_, AppState>, - client_name: String, - space_id: String, -) -> Result { - let custom_set_name = format!("{} - Custom", client_name); - - // First, try to find existing custom feature set - let existing_sets = state - .feature_set_repository - .list_by_space(&space_id) - .await - .map_err(|e| format!("Failed to list feature sets: {}", e))?; - - // Look for existing custom feature set with this name - if let Some(existing) = existing_sets.iter().find(|fs| { - fs.name == custom_set_name - && fs.feature_set_type == FeatureSetType::Custom - && !fs.is_deleted - }) { - // Load members - return state - .feature_set_repository - .get_with_members(&existing.id) - .await - .map_err(|e| format!("Failed to load feature set: {}", e))? - .ok_or_else(|| "Feature set not found".to_string()); - } - - // No existing set found, create a new one - let new_set = FeatureSet::new_custom(&custom_set_name, &space_id) - .with_description(format!("Custom features for {}", client_name)) - .with_icon("⚙️"); - - state - .feature_set_repository - .create(&new_set) - .await - .map_err(|e| format!("Failed to create custom feature set: {}", e))?; - - Ok(new_set) -} diff --git a/apps/desktop/src-tauri/src/commands/config_export.rs b/apps/desktop/src-tauri/src/commands/config_export.rs index 0a5fe7e..99b54c6 100644 --- a/apps/desktop/src-tauri/src/commands/config_export.rs +++ b/apps/desktop/src-tauri/src/commands/config_export.rs @@ -45,15 +45,16 @@ fn get_format(client_type: &str) -> Result { } } -/// Get the space ID (resolves "default" to active space) +/// Resolve a `space_id` argument from the UI: the literal "default" or an +/// empty string fall back to the system's `is_default` Space. async fn get_space_id(state: &AppState, space_id: &str) -> Result { if space_id == "default" || space_id.is_empty() { let space = state .space_service - .get_active() + .get_default() .await .map_err(|e: anyhow::Error| e.to_string())? - .ok_or("No active space found")?; + .ok_or("No default space found")?; Ok(space.id.to_string()) } else { Ok(space_id.to_string()) diff --git a/apps/desktop/src-tauri/src/commands/feature_set.rs b/apps/desktop/src-tauri/src/commands/feature_set.rs index 3e3ef5e..6b626f3 100644 --- a/apps/desktop/src-tauri/src/commands/feature_set.rs +++ b/apps/desktop/src-tauri/src/commands/feature_set.rs @@ -128,28 +128,10 @@ pub async fn list_feature_sets_by_space( .await .map_err(|e: anyhow::Error| e.to_string())?; - let enabled_server_ids: std::collections::HashSet = installed_servers - .into_iter() - .filter(|s| s.enabled) - .map(|s| s.server_id) - .collect(); - - // Filter out server-all feature sets for servers that are not enabled - let filtered = feature_sets - .into_iter() - .filter(|fs| { - if fs.feature_set_type == mcpmux_core::FeatureSetType::ServerAll { - // Only include if server is enabled - fs.server_id - .as_ref() - .is_some_and(|sid| enabled_server_ids.contains(sid)) - } else { - true - } - }) - .map(Into::into) - .collect(); - + // `server-all` feature sets no longer exist, so nothing to filter; + // installed_servers lookup kept for future per-server filtering hooks. + let _ = installed_servers; + let filtered = feature_sets.into_iter().map(Into::into).collect(); Ok(filtered) } @@ -266,38 +248,6 @@ pub async fn delete_feature_set( Ok(()) } -/// Get builtin feature sets for a space. -#[tauri::command] -pub async fn get_builtin_feature_sets( - space_id: String, - state: State<'_, AppState>, -) -> Result, String> { - let feature_sets = state - .feature_set_repository - .list_builtin(&space_id) - .await - .map_err(|e| e.to_string())?; - - Ok(feature_sets.into_iter().map(Into::into).collect()) -} - -/// Ensure server-all featureset exists for a server in a space. -#[tauri::command] -pub async fn ensure_server_all_feature_set( - space_id: String, - server_id: String, - server_name: String, - state: State<'_, AppState>, -) -> Result { - let feature_set = state - .feature_set_repository - .ensure_server_all(&space_id, &server_id, &server_name) - .await - .map_err(|e| e.to_string())?; - - Ok(feature_set.into()) -} - /// Update a feature set (name, description, icon). #[tauri::command] pub async fn update_feature_set( diff --git a/apps/desktop/src-tauri/src/commands/gateway.rs b/apps/desktop/src-tauri/src/commands/gateway.rs index fb1cc5a..ff65a5d 100644 --- a/apps/desktop/src-tauri/src/commands/gateway.rs +++ b/apps/desktop/src-tauri/src/commands/gateway.rs @@ -4,10 +4,11 @@ use crate::commands::server_manager::ServerManagerState; use crate::AppState; +use mcpmux_core::service::{allocate_dynamic_port, is_port_available}; use mcpmux_core::DomainEvent; use mcpmux_gateway::{ - ConnectionContext, ConnectionResult, FeatureService, InstalledServerInfo, PoolService, - ResolvedTransport, ServerKey, + ConnectionContext, ConnectionResult, FeatureService, InstalledServerInfo, OAuthCompleteEvent, + PoolService, ResolvedTransport, ServerKey, ServerManager, }; use serde::Serialize; use std::sync::Arc; @@ -37,6 +38,16 @@ pub struct BackendStatusResponse { pub tools_count: usize, } +/// Information about an auto-start attempt that was aborted because the +/// preferred port was busy. The frontend reads this on mount and triggers +/// the port-conflict confirm dialog. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PendingPortConflict { + pub preferred_port: u16, + pub source: &'static str, +} + /// Gateway state managed by Tauri #[derive(Default)] pub struct GatewayAppState { @@ -44,8 +55,10 @@ pub struct GatewayAppState { pub running: bool, /// Gateway URL pub url: Option, - /// Gateway task handle - pub handle: Option>>, + /// Gateway task + graceful-shutdown signal. `shutdown()` + awaiting + /// `task` (with a timeout) lets the OS reclaim the listener socket + /// cleanly; `.abort()` alone can leave an orphaned kernel-level bind. + pub handle: Option, /// Gateway state reference for accessing backends pub gateway_state: Option>>, /// Server connection pool service (initialized when gateway starts) @@ -58,6 +71,157 @@ pub struct GatewayAppState { pub grant_service: Option>, /// Approval broker for meta-tool writes (publisher attached on gateway start) pub approval_broker: Option>, + /// Set when auto-start couldn't bind the preferred port; the UI will + /// read this on mount and prompt the user. + pub pending_port_conflict: Option, + /// Live map of `mcp-session-id → reported workspace roots`. Populated + /// by the gateway handler when clients declare the `roots` capability. + /// Surfaced to the desktop Workspaces tab so users can see + act on + /// every folder connected clients are currently operating in. + pub session_roots: Option>, +} + +/// Gracefully shuts down a running gateway and waits for the axum task +/// to finish so the TCP listener is released back to the OS. +/// +/// Without this, `handle.abort()` alone can leave an orphaned +/// kernel-level bind — a listener socket that netstat still reports even +/// though no process exists — preventing the next `start_gateway` from +/// binding the same port. +/// +/// Flow: +/// 1. Send the graceful-shutdown signal (axum drains in-flight requests). +/// 2. Await the task up to 2s so Rust Drop closes the listener fd. +/// 3. If the task hasn't returned by then, abort as a last resort. +pub(crate) async fn shutdown_gateway_handle(mut handle: mcpmux_gateway::GatewayServerHandle) { + let abort = handle.task.abort_handle(); + handle.shutdown(); + match tokio::time::timeout(std::time::Duration::from_secs(2), handle.task).await { + Ok(Ok(Ok(()))) => info!("[Gateway] Gateway task exited cleanly"), + Ok(Ok(Err(e))) => warn!( + "[Gateway] Gateway task returned error during shutdown: {}", + e + ), + Ok(Err(e)) if e.is_cancelled() => info!("[Gateway] Gateway task was already cancelled"), + Ok(Err(e)) => warn!("[Gateway] Gateway task join error: {}", e), + Err(_) => { + warn!( + "[Gateway] Graceful shutdown timed out after 2s — aborting task \ + (listener socket may briefly linger in kernel)" + ); + abort.abort(); + } + } +} + +/// Wires up ServerManager state + the OAuth completion handler + the +/// periodic refresh loop after a GatewayServer has been spawned. +/// +/// Both the auto-start path (in `lib.rs`) and the `start_gateway` Tauri +/// command must call this — without it, ServerManagerState.manager stays +/// None and the Servers page shows every server stuck on "Connecting..." +/// because `get_server_statuses` can't reach the ServerManager. +/// +/// Call order matters: **subscribe to OAuth events before spawning the +/// gateway** (the subscription is passed in already-created), and call +/// this helper before or after `server.spawn()` — but always before any +/// user-facing code queries server statuses. +pub(crate) async fn init_gateway_runtime( + pool_service: Arc, + server_manager: Arc, + oauth_completion_rx: tokio::sync::broadcast::Receiver, + sm_state: Arc>, +) { + // Store ServerManager + PoolService so the Servers page commands can + // read them. A fresh Arc per start — old handlers on a stopped gateway + // become orphans and drop naturally. + { + let mut sm = sm_state.write().await; + sm.manager = Some(server_manager.clone()); + sm.pool_service = Some(pool_service.clone()); + } + info!("[Gateway] ServerManager + PoolService attached to state"); + + // OAuth completion handler — reconnects servers after the user finishes + // the OAuth flow in the browser. Spawned as a detached task; lives as + // long as the broadcast channel is alive (drops naturally when pool is + // dropped on next gateway start). + let sm_for_oauth = server_manager.clone(); + let pool_for_oauth = pool_service.clone(); + tokio::spawn(async move { + let mut rx = oauth_completion_rx; + info!("[OAuth Handler] Listening for OAuth completions"); + loop { + match rx.recv().await { + Ok(event) => { + info!( + "[OAuth Handler] Completion received: server={} success={}", + event.server_id, event.success + ); + if event.success { + let sm = sm_for_oauth.clone(); + let pool = pool_for_oauth.clone(); + let server_id = event.server_id.clone(); + let space_id = event.space_id; + tokio::spawn(async move { + let key = ServerKey::new(space_id, &server_id); + info!("[OAuth Handler] Reconnecting {} after OAuth", server_id); + sm.set_connecting(&key).await; + match pool.reconnect_instance(space_id, &server_id).await { + ConnectionResult::Connected { features, .. } => { + info!( + "[OAuth Handler] Reconnected {} — {} features", + server_id, + features.tools.len() + ); + sm.set_connected(&key, features).await; + } + ConnectionResult::OAuthRequired { .. } => { + warn!( + "[OAuth Handler] {} still needs OAuth after completion", + server_id + ); + sm.set_auth_required( + &key, + Some("OAuth still required".to_string()), + ) + .await; + } + ConnectionResult::Failed { error } => { + error!( + "[OAuth Handler] Reconnect failed for {}: {}", + server_id, error + ); + sm.set_error(&key, error).await; + } + } + }); + } else { + let key = ServerKey::new(event.space_id, &event.server_id); + let err = event.error.unwrap_or_else(|| "OAuth failed".to_string()); + warn!( + "[OAuth Handler] OAuth failed for {}: {}", + event.server_id, err + ); + sm_for_oauth.set_auth_required(&key, Some(err)).await; + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { + warn!("[OAuth Handler] Lagged {} messages", n); + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + info!("[OAuth Handler] Channel closed, stopping"); + break; + } + } + } + }); + info!("[Gateway] OAuth completion handler spawned"); + + // Periodic refresh loop — re-fetches features from each connected + // server every ~60s so long-running sessions don't drift. + let _refresh = server_manager.clone().start_periodic_refresh(); + info!("[Gateway] Periodic refresh loop started"); } /// Start domain event bridge from Gateway to Tauri @@ -131,20 +295,6 @@ fn map_domain_event_to_ui(event: &DomainEvent) -> (&'static str, serde_json::Val "space_id": space_id, }), ), - DomainEvent::SpaceActivated { - from_space_id, - to_space_id, - to_space_name, - } => ( - "space-changed", - serde_json::json!({ - "action": "activated", - "from_space_id": from_space_id, - "to_space_id": to_space_id, - "to_space_name": to_space_name, - }), - ), - // Server lifecycle events DomainEvent::ServerInstalled { space_id, @@ -365,47 +515,6 @@ fn map_domain_event_to_ui(event: &DomainEvent) -> (&'static str, serde_json::Val }), ), - // Grant events - DomainEvent::GrantIssued { - client_id, - space_id, - feature_set_id, - } => ( - "grants-changed", - serde_json::json!({ - "action": "granted", - "client_id": client_id, - "space_id": space_id, - "feature_set_id": feature_set_id, - }), - ), - DomainEvent::GrantRevoked { - client_id, - space_id, - feature_set_id, - } => ( - "grants-changed", - serde_json::json!({ - "action": "revoked", - "client_id": client_id, - "space_id": space_id, - "feature_set_id": feature_set_id, - }), - ), - DomainEvent::ClientGrantsUpdated { - client_id, - space_id, - feature_set_ids, - } => ( - "grants-changed", - serde_json::json!({ - "action": "batch_updated", - "client_id": client_id, - "space_id": space_id, - "feature_set_ids": feature_set_ids, - }), - ), - // Gateway events DomainEvent::GatewayStarted { url, port } => ( "gateway-changed", @@ -477,6 +586,41 @@ fn map_domain_event_to_ui(event: &DomainEvent) -> (&'static str, serde_json::Val "timestamp": chrono::Utc::now().to_rfc3339(), }), ), + + // Workspace binding write → tell the UI to re-load the bindings + // table. The MCP `list_changed` notifications are handled separately + // by MCPNotifier subscribing to the same event. + DomainEvent::WorkspaceBindingChanged { + space_id, + workspace_root, + } => ( + "workspace-binding-changed", + serde_json::json!({ + "space_id": space_id, + "workspace_root": workspace_root, + }), + ), + + // The set of live reported session roots changed — the Workspaces + // tab re-fetches so unbound folders stay visible. + DomainEvent::SessionRootsChanged => ("session-roots-changed", serde_json::json!({})), + + // A session resolved via `source=Default` and no binding exists for + // any of its reported roots. Front-end shows the binding sheet. + DomainEvent::WorkspaceNeedsBinding { + client_id, + session_id, + space_id, + workspace_root, + } => ( + "workspace-needs-binding", + serde_json::json!({ + "client_id": client_id, + "session_id": session_id, + "space_id": space_id, + "workspace_root": workspace_root, + }), + ), } } @@ -570,11 +714,25 @@ pub async fn get_gateway_status( }) } -/// Start the gateway server +/// Start the gateway server. +/// +/// `port` forces a specific port (used for ad-hoc overrides from a test or +/// power-user flow). When `port` is None, the preferred port is whatever +/// the user has configured, falling back to the shipped default. +/// +/// `allow_dynamic_fallback` controls what happens when the preferred port +/// is busy: +/// - **None / false (strict, default):** return an error prefixed with +/// `PORT_IN_USE::`. The UI should probe first and prompt +/// the user before retrying with fallback enabled. +/// - **true:** silently allocate an OS-assigned port instead. Used by the +/// auto-start path where there's no UI to prompt. #[tauri::command] pub async fn start_gateway( port: Option, + allow_dynamic_fallback: Option, gateway_state: State<'_, Arc>>, + sm_state: State<'_, Arc>>, app_state: State<'_, AppState>, app_handle: tauri::AppHandle, ) -> Result { @@ -584,12 +742,47 @@ pub async fn start_gateway( return Err("Gateway is already running".to_string()); } - // Single Responsibility: Delegate port resolution to GatewayPortService - let final_port = app_state - .gateway_port_service - .resolve_with_override(port) - .await - .map_err(|e| e.to_string())?; + let (preferred_port, source) = resolve_preferred_port(&app_state, port).await; + let allow_fallback = allow_dynamic_fallback.unwrap_or(false); + + let final_port = if is_port_available(preferred_port) { + // Persist first-run default so the Settings UI shows it explicitly. + if matches!(source, PortSource::Default) + && app_state + .gateway_port_service + .load_persisted_port() + .await + .is_none() + { + if let Err(e) = app_state + .gateway_port_service + .save_port(preferred_port) + .await + { + warn!("[Gateway] Failed to persist default port: {}", e); + } + } + preferred_port + } else if allow_fallback { + let dyn_port = allocate_dynamic_port().map_err(|e| e.to_string())?; + warn!( + "[Gateway] Preferred port {} unavailable, falling back to dynamic port {} (not persisted — next start retries {})", + preferred_port, dyn_port, preferred_port + ); + // Intentionally do NOT persist the fallback port — the user's + // configured/default preference must survive so the next launch + // retries it. Persisting here would silently overwrite what the + // Settings page shows. + dyn_port + } else { + // Strict mode — caller must retry with allow_dynamic_fallback=true or + // free the port. The UI parses this sentinel to render its popup. + return Err(format!( + "PORT_IN_USE:{}:{}", + preferred_port, + source.as_str() + )); + }; let url = format!("http://localhost:{}", final_port); @@ -614,10 +807,17 @@ pub async fn start_gateway( let pool_service = server.pool_service(); let feature_service = server.feature_service(); let event_emitter = server.event_emitter(); - - info!("[Gateway] Getting grant_service from server..."); + let server_manager = server.server_manager(); let grant_service = server.grant_service(); - info!("[Gateway] Got grant_service: {:p}", &*grant_service); + let session_roots = server.session_roots(); + + // Subscribe to OAuth completions BEFORE spawn so we don't miss early + // events emitted during initial auto-connect. + let oauth_completion_rx = pool_service.oauth_manager().subscribe(); + info!( + "[Gateway] Services resolved — port={}, server_manager={:p}", + final_port, &*server_manager + ); // Meta-tool approval broker — attach a Tauri-event publisher so // incoming approval requests reach the React dialog. @@ -650,10 +850,26 @@ pub async fn start_gateway( // Start domain event bridge (clean architecture) start_domain_event_bridge(&app_handle, gw_state.clone()); + // Wire ServerManager into state + spawn OAuth handler + periodic + // refresh. MUST happen here, otherwise the Servers page sees every + // server stuck on "Connecting..." because `get_server_statuses` can't + // reach the ServerManager. + let sm_state_inner: Arc> = sm_state.inner().clone(); + init_gateway_runtime( + pool_service.clone(), + server_manager.clone(), + oauth_completion_rx, + sm_state_inner, + ) + .await; + // Spawn gateway (runs in background, auto-connects servers) let handle = server.spawn(); - info!("[Gateway] Setting state fields..."); + info!( + "[Gateway] Setting GatewayAppState fields — port={}, url={}", + final_port, url + ); state.running = true; state.url = Some(url.clone()); state.handle = Some(handle); @@ -661,23 +877,30 @@ pub async fn start_gateway( state.pool_service = Some(pool_service); state.feature_service = Some(feature_service); state.event_emitter = Some(event_emitter); - info!( - "[Gateway] About to set grant_service: {:p}", - &*grant_service - ); state.grant_service = Some(grant_service); state.approval_broker = Some(approval_broker); + state.session_roots = Some(session_roots); info!( - "[Gateway] grant_service set! Checking: {}", - state.grant_service.is_some() - ); - - info!( - "[Gateway] Started successfully - EventEmitter initialized: {}, GrantService initialized: {}", + "[Gateway] Started — url={}, event_emitter={}, grant_service={}", + url, state.event_emitter.is_some(), state.grant_service.is_some() ); - info!("[Gateway] Auto-connect will run in background"); + + // Notify every frontend subscriber (status-bar footer, Dashboard, + // Servers page, Settings). Without this, only the caller sees the new + // URL; the footer would stay on "Gateway: Stopped" until the user + // changes Space and retriggers a manual reload. + if let Err(e) = app_handle.emit( + "gateway-changed", + serde_json::json!({ + "action": "started", + "url": url, + "port": final_port, + }), + ) { + warn!("[Gateway] Failed to emit gateway-changed(started): {}", e); + } Ok(url) } @@ -686,44 +909,232 @@ pub async fn start_gateway( #[tauri::command] pub async fn stop_gateway( gateway_state: State<'_, Arc>>, + app_handle: tauri::AppHandle, ) -> Result<(), String> { - let mut state = gateway_state.write().await; + // Take the handle out under the lock, then drop the guard BEFORE + // awaiting the shutdown — otherwise the lock is held for up to 2s + // and every concurrent status query blocks. + let handle = { + let mut state = gateway_state.write().await; + if !state.running { + return Err("Gateway is not running".to_string()); + } + let handle = state.handle.take(); + state.running = false; + state.url = None; + handle + }; - if !state.running { - return Err("Gateway is not running".to_string()); + if let Some(h) = handle { + info!("[Gateway] Stop requested — shutting down gracefully"); + shutdown_gateway_handle(h).await; + } + + if let Err(e) = app_handle.emit("gateway-changed", serde_json::json!({"action": "stopped"})) { + warn!("[Gateway] Failed to emit gateway-changed(stopped): {}", e); } - if let Some(handle) = state.handle.take() { - handle.abort(); - info!("Gateway stopped"); + Ok(()) +} + +/// Gateway port configuration response. +/// +/// - `configured_port` is the user's persisted override (None = "follow default"). +/// - `default_port` is the built-in default the app ships with. +/// - `active_port` is the port the currently-running gateway is bound to +/// (None when stopped). When it differs from `configured_port`, the UI +/// should nudge the user to restart the gateway. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GatewayPortSettings { + pub configured_port: Option, + pub default_port: u16, + pub active_port: Option, +} + +fn parse_port_from_url(url: &str) -> Option { + // URL shape is always "http://localhost:PORT" — parse defensively. + let after_scheme = url.split("://").nth(1)?; + let host_port = after_scheme.split('/').next()?; + host_port.rsplit(':').next()?.parse().ok() +} + +/// Get the persisted gateway port setting, plus the currently-active port. +#[tauri::command] +pub async fn get_gateway_port_settings( + gateway_state: State<'_, Arc>>, + app_state: State<'_, AppState>, +) -> Result { + let configured_port = app_state.gateway_port_service.load_persisted_port().await; + + let active_port = { + let state = gateway_state.read().await; + state.url.as_deref().and_then(parse_port_from_url) + }; + + Ok(GatewayPortSettings { + configured_port, + default_port: mcpmux_core::DEFAULT_GATEWAY_PORT, + active_port, + }) +} + +/// Persist a custom gateway port. Takes effect on the next gateway start. +/// +/// Does NOT touch a running gateway — the UI is expected to offer a +/// "Restart gateway" action. The port must be in the user-space range +/// (1024–65535). Ports ≤ 1023 are rejected to avoid privileged-port +/// surprises on Unix. +#[tauri::command] +pub async fn set_gateway_port(port: u16, app_state: State<'_, AppState>) -> Result<(), String> { + if port < 1024 { + return Err(format!( + "Port {} is in the privileged range (≤ 1023). Choose a port between 1024 and 65535.", + port + )); } - state.running = false; - state.url = None; + app_state + .gateway_port_service + .save_port(port) + .await + .map_err(|e| e.to_string())?; + + info!("[Gateway] Persisted custom gateway port: {}", port); + Ok(()) +} + +/// Clear the persisted gateway port override. The next gateway start will +/// use the built-in default (or a dynamically-allocated port if the default +/// is in use). +#[tauri::command] +pub async fn reset_gateway_port(app_state: State<'_, AppState>) -> Result<(), String> { + app_state + .gateway_port_service + .clear_persisted_port() + .await + .map_err(|e| e.to_string())?; + info!("[Gateway] Cleared persisted gateway port — reverting to default on next start"); Ok(()) } -/// Restart the gateway server +/// Which port source a startup attempt would use. +/// +/// Kept as a string-valued enum for clean JSON serialization to the UI. +#[derive(Debug, Clone, Copy)] +enum PortSource { + Override, + Configured, + Default, +} + +impl PortSource { + fn as_str(self) -> &'static str { + match self { + PortSource::Override => "override", + PortSource::Configured => "configured", + PortSource::Default => "default", + } + } +} + +/// Result of probing whether the gateway can start on its preferred port. +/// +/// - `preferred_port` is the port that _would_ be used — explicit override +/// wins over configured persisted port, which wins over the shipped default. +/// - `preferred_available` is false when something else is bound to it. +/// - `source` tells the UI which tier was chosen, so messages can reference +/// "your configured port" vs. "the default port". +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GatewayStartProbe { + pub preferred_port: u16, + pub preferred_available: bool, + pub source: &'static str, +} + +async fn resolve_preferred_port( + app_state: &AppState, + explicit_port: Option, +) -> (u16, PortSource) { + if let Some(p) = explicit_port { + return (p, PortSource::Override); + } + if let Some(p) = app_state.gateway_port_service.load_persisted_port().await { + return (p, PortSource::Configured); + } + (mcpmux_core::DEFAULT_GATEWAY_PORT, PortSource::Default) +} + +/// Probe whether the gateway's preferred port is free, without starting it. +/// +/// Frontends should call this before invoking `start_gateway` so they can +/// prompt the user when a fallback would be required. +#[tauri::command] +pub async fn probe_gateway_start( + port: Option, + app_state: State<'_, AppState>, +) -> Result { + let (preferred_port, source) = resolve_preferred_port(&app_state, port).await; + let preferred_available = is_port_available(preferred_port); + Ok(GatewayStartProbe { + preferred_port, + preferred_available, + source: source.as_str(), + }) +} + +/// Atomically read **and clear** any deferred auto-start port conflict. +/// +/// The "take" semantic matters: React StrictMode double-mounts components +/// in dev, and without atomic consumption both mounts would read the same +/// conflict and double-prompt the user. Only the first caller wins. +#[tauri::command] +pub async fn take_pending_port_conflict( + gateway_state: State<'_, Arc>>, +) -> Result, String> { + let mut state = gateway_state.write().await; + Ok(state.pending_port_conflict.take()) +} + +/// Restart the gateway server. +/// +/// Both `port` and `allow_dynamic_fallback` are forwarded to `start_gateway` +/// — see its docs for semantics. #[tauri::command] pub async fn restart_gateway( port: Option, + allow_dynamic_fallback: Option, gateway_state: State<'_, Arc>>, + sm_state: State<'_, Arc>>, app_state: State<'_, AppState>, app_handle: tauri::AppHandle, ) -> Result { - // Stop if running - { + info!("[Gateway] Restart requested — tearing down current state"); + // Take handle out under lock; drop lock before awaiting shutdown so + // start_gateway below can re-acquire it. + let handle = { let mut state = gateway_state.write().await; - if let Some(handle) = state.handle.take() { - handle.abort(); - } + let handle = state.handle.take(); state.running = false; state.url = None; + handle + }; + if let Some(h) = handle { + shutdown_gateway_handle(h).await; } // Start with new config - start_gateway(port, gateway_state, app_state, app_handle).await + start_gateway( + port, + allow_dynamic_fallback, + gateway_state, + sm_state, + app_state, + app_handle, + ) + .await } /// Generate gateway config for a client @@ -774,14 +1185,14 @@ pub async fn generate_gateway_config( serde_json::to_string_pretty(&config).map_err(|e| e.to_string()) } -/// Get the active/default space ID +/// Resolve the system's default space id (the `is_default` Space). async fn get_default_space_id(app_state: &AppState) -> Result { let space = app_state .space_service - .get_active() + .get_default() .await .map_err(|e: anyhow::Error| e.to_string())? - .ok_or("No active space found")?; + .ok_or("No default space found")?; Ok(space.id.to_string()) } @@ -857,9 +1268,6 @@ pub async fn connect_server( features.total_count() ); - // Ensure server-all featureset exists - ensure_server_featureset(&app_state, &server_id, &server_definition, &installed).await; - Ok(()) } ConnectionResult::Failed { error } => { @@ -884,25 +1292,6 @@ pub async fn connect_server( } } -/// Ensure server-all featureset exists after connection -/// -/// Note: Server state is now managed by ServerManager/PoolService, not GatewayState -async fn ensure_server_featureset( - app_state: &AppState, - server_id: &str, - registry_entry: &mcpmux_core::ServerDefinition, - installed: &mcpmux_core::InstalledServer, -) { - let space_id_str = installed.space_id.clone(); - if let Err(e) = app_state - .feature_set_repository - .ensure_server_all(&space_id_str, server_id, ®istry_entry.name) - .await - { - warn!("[Gateway] Failed to create server-all featureset: {}", e); - } -} - /// Disconnect a server from the gateway #[tauri::command] pub async fn disconnect_server( @@ -1123,7 +1512,7 @@ pub async fn connect_all_enabled_servers( errors: vec![], }; - for (server_info, transport, server_definition, installed) in servers_to_connect { + for (server_info, transport, _server_definition, _installed) in servers_to_connect { let space_uuid = server_info.space_id; let server_id = server_info.server_id.clone(); @@ -1142,10 +1531,6 @@ pub async fn connect_all_enabled_servers( reused, features.total_count() ); - - // Ensure server-all featureset exists - ensure_server_featureset(&app_state, &server_id, &server_definition, &installed) - .await; } ConnectionResult::OAuthRequired { auth_url: _ } => { result.oauth_required += 1; diff --git a/apps/desktop/src-tauri/src/commands/logs.rs b/apps/desktop/src-tauri/src/commands/logs.rs index ecc6217..fb04052 100644 --- a/apps/desktop/src-tauri/src/commands/logs.rs +++ b/apps/desktop/src-tauri/src/commands/logs.rs @@ -6,14 +6,14 @@ use serde::Serialize; use tauri::State; use tracing::{info, warn}; -/// Helper to get the default space ID +/// Helper to get the system default space ID. async fn get_default_space_id(state: &AppState) -> Result { let space = state .space_service - .get_active() + .get_default() .await .map_err(|e: anyhow::Error| e.to_string())? - .ok_or("No active space found")?; + .ok_or("No default space found")?; Ok(space.id.to_string()) } diff --git a/apps/desktop/src-tauri/src/commands/mod.rs b/apps/desktop/src-tauri/src/commands/mod.rs index 5aa0e68..bbfd57d 100644 --- a/apps/desktop/src-tauri/src/commands/mod.rs +++ b/apps/desktop/src-tauri/src/commands/mod.rs @@ -4,7 +4,6 @@ //! Commands are organized by feature area. pub mod client; -pub mod client_custom_features; pub mod client_install; pub mod config_export; pub mod credential; @@ -24,7 +23,6 @@ pub mod workspace_binding; // Re-export commands for convenience pub use client::*; -pub use client_custom_features::*; pub use client_install::*; pub use config_export::*; pub use feature_members::*; diff --git a/apps/desktop/src-tauri/src/commands/oauth.rs b/apps/desktop/src-tauri/src/commands/oauth.rs index 2eba910..3ab9d93 100644 --- a/apps/desktop/src-tauri/src/commands/oauth.rs +++ b/apps/desktop/src-tauri/src/commands/oauth.rs @@ -25,7 +25,8 @@ //! - PKCE required for all authorization requests (RFC 7636) use std::collections::HashMap; -use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; use mcpmux_core::branding; use serde::{Deserialize, Serialize}; @@ -40,6 +41,45 @@ use super::gateway::GatewayAppState; // Deep Link Handling // ============================================================================ +/// Holds a deep-link URL the app was cold-started with (Windows/Linux) until +/// the webview has mounted its listeners. Emitting `oauth-consent-request` +/// before React has subscribed drops the event — Tauri events are fire-and- +/// forget with no replay. The frontend calls `flush_pending_deep_link` once +/// its listener is live to process any buffered URL. +#[derive(Default)] +pub struct PendingInitialDeepLink { + pub url: Mutex>, + pub webview_ready: AtomicBool, +} + +/// Called from `on_open_url`: route immediately if the webview has signalled +/// ready, otherwise buffer for later flush. Falls back to direct routing +/// if the state isn't managed yet (shouldn't happen after setup). +pub fn route_or_buffer_deep_link(app: &tauri::AppHandle, url: &str) { + match app.try_state::() { + Some(pending) if !pending.webview_ready.load(Ordering::Acquire) => { + info!("[DeepLink] Webview not ready — buffering URL: {}", url); + if let Ok(mut guard) = pending.url.lock() { + *guard = Some(url.to_string()); + } + } + _ => handle_deep_link(app, url), + } +} + +/// Invoked by the frontend once the `oauth-consent-request` listener is live. +/// Marks the webview ready so subsequent URLs route immediately, and drains +/// any URL that arrived before mount. +#[tauri::command] +pub fn flush_pending_deep_link(app: tauri::AppHandle, pending: State<'_, PendingInitialDeepLink>) { + pending.webview_ready.store(true, Ordering::Release); + let buffered = pending.url.lock().ok().and_then(|mut g| g.take()); + if let Some(url) = buffered { + info!("[DeepLink] Flushing buffered cold-start URL: {}", url); + handle_deep_link(&app, &url); + } +} + /// Event name for OAuth consent requests sent to frontend /// Now only contains request_id - frontend must call get_pending_consent pub const OAUTH_CONSENT_EVENT: &str = "oauth-consent-request"; @@ -408,12 +448,8 @@ pub struct ConsentApprovalRequest { /// Cryptographic consent token (must match the one issued via get_pending_consent). /// This proves the caller obtained the token through Tauri IPC, not HTTP scraping. pub consent_token: String, - /// Optional alias name for the client + /// Optional alias name for the client (set during approval). pub client_alias: Option, - /// Connection mode: "follow_active", "locked", or "ask_on_change" - pub connection_mode: Option, - /// Space ID to lock to (only used when connection_mode is "locked") - pub locked_space_id: Option, } /// Response from consent approval @@ -548,59 +584,30 @@ pub async fn approve_oauth_consent( state.store_pending_authorization(&code, new_pending); - // Mark client as approved and store settings + // Mark client as approved and store any alias override. if let Some(repo) = state.inbound_client_repository() { - // Mark as approved for clients tab visibility if let Err(e) = repo.approve_client(&pending.client_id).await { error!("[OAuth] Failed to approve client: {}", e); } else { info!("[OAuth] Client approved: {}", pending.client_id); } - // Update client settings (alias, connection_mode, locked_space_id) - if let Ok(Some(mut client)) = repo.get_client(&pending.client_id).await { - let mut changed = false; - - // Set alias if provided - if let Some(alias) = &request.client_alias { - if !alias.is_empty() { - client.client_alias = Some(alias.clone()); - changed = true; - info!( - "[OAuth] Set client alias '{}' for: {}", - alias, pending.client_id - ); - } - } - - // Set connection mode if provided - if let Some(mode) = &request.connection_mode { - client.connection_mode = mode.clone(); - changed = true; - info!( - "[OAuth] Set connection mode '{}' for: {}", - mode, pending.client_id - ); - } - - // Set locked space if provided (only meaningful when mode is "locked") - if let Some(space_id) = &request.locked_space_id { - client.locked_space_id = Some(space_id.clone()); - changed = true; + if let Some(alias) = request + .client_alias + .as_deref() + .filter(|s| !s.is_empty()) + .map(String::from) + { + if let Err(e) = repo + .update_client_alias(&pending.client_id, Some(alias.clone())) + .await + { + error!("[OAuth] Failed to save client alias: {}", e); + } else { info!( - "[OAuth] Locked to space '{}' for: {}", - space_id, pending.client_id + "[OAuth] Set client alias '{}' for: {}", + alias, pending.client_id ); - } else if request.connection_mode.as_deref() == Some("follow_active") { - // Clear locked space if switching to follow_active - client.locked_space_id = None; - changed = true; - } - - if changed { - if let Err(e) = repo.save_client(&client).await { - error!("[OAuth] Failed to save client settings: {}", e); - } } } } @@ -683,8 +690,6 @@ pub async fn get_oauth_clients( metadata_url: client.metadata_url, metadata_cached_at: client.metadata_cached_at, metadata_cache_ttl: client.metadata_cache_ttl, - connection_mode: client.connection_mode, - locked_space_id: client.locked_space_id, last_seen: client.last_seen, created_at: client.created_at, }) @@ -755,19 +760,17 @@ pub struct OAuthClientInfo { #[serde(skip_serializing_if = "Option::is_none")] pub metadata_cache_ttl: Option, - // MCP client preferences - pub connection_mode: String, - pub locked_space_id: Option, pub last_seen: Option, pub created_at: String, } -/// Request to update client settings +/// Request to update client settings. +/// +/// Only the alias is user-editable now — connection mode / space pin no +/// longer exist. #[derive(Debug, Serialize, Deserialize)] pub struct UpdateClientSettingsRequest { pub client_alias: Option, - pub connection_mode: Option, - pub locked_space_id: Option, } /// Update an OAuth client's settings (direct service access) @@ -789,31 +792,22 @@ pub async fn update_oauth_client( return Err("Database not available".to_string()); }; - // Update client directly via repository - repo.update_client_settings( - &client_id, - settings.client_alias, - settings.connection_mode, - settings.locked_space_id.map(Some), - ) - .await - .map_err(|e| format!("Failed to update client: {}", e))?; + repo.update_client_alias(&client_id, settings.client_alias) + .await + .map_err(|e| format!("Failed to update client: {}", e))?; info!("[OAuth] Updated client: {}", client_id); - // Emit domain event state.emit_domain_event(mcpmux_core::DomainEvent::ClientUpdated { client_id: client_id.clone(), }); - // Get updated client let updated_client = repo .get_client(&client_id) .await .map_err(|e| format!("Failed to get updated client: {}", e))? .ok_or("Client not found after update")?; - // Map to response format Ok(OAuthClientInfo { client_id: updated_client.client_id, registration_type: updated_client.registration_type.as_str().to_string(), @@ -829,286 +823,11 @@ pub async fn update_oauth_client( metadata_url: updated_client.metadata_url, metadata_cached_at: updated_client.metadata_cached_at, metadata_cache_ttl: updated_client.metadata_cache_ttl, - connection_mode: updated_client.connection_mode, - locked_space_id: updated_client.locked_space_id, last_seen: updated_client.last_seen, created_at: updated_client.created_at, }) } -/// Get grants for an OAuth client in a specific space -/// -/// Returns the effective grants: explicit grants + the default feature set -/// This matches the authorization behavior used by MCP handlers -#[tauri::command] -pub async fn get_oauth_client_grants( - gateway_state: State<'_, Arc>>, - app_state: State<'_, crate::AppState>, - client_id: String, - space_id: String, -) -> Result, String> { - let gw_app_state = gateway_state.read().await; - - // Get gateway state and inbound client repository - let Some(ref gw_state) = gw_app_state.gateway_state else { - return Err("Gateway not running".to_string()); - }; - - let state = gw_state.read().await; - let Some(repo) = state.inbound_client_repository() else { - return Err("Database not available".to_string()); - }; - - // Get explicit grants from DB - let mut grants = repo - .get_grants_for_space(&client_id, &space_id) - .await - .map_err(|e| format!("Failed to get grants: {}", e))?; - - // Add default feature set (layered resolution - same as MCP handlers) - if let Ok(Some(default_fs)) = app_state - .feature_set_repository - .get_default_for_space(&space_id) - .await - { - if !grants.contains(&default_fs.id) { - grants.push(default_fs.id); - } - } - - Ok(grants) -} - -/// Grant a feature set to an OAuth client in a specific space -#[tauri::command] -pub async fn grant_oauth_client_feature_set( - app_handle: tauri::AppHandle, - gateway_state: State<'_, Arc>>, - client_id: String, - space_id: String, - feature_set_id: String, -) -> Result<(), String> { - info!("[OAuth] grant_oauth_client_feature_set called: client_id={}, space_id={}, feature_set_id={}", - client_id, space_id, feature_set_id); - - let app_state = gateway_state.read().await; - - info!("[OAuth] Gateway running: {}", app_state.running); - info!( - "[OAuth] Gateway state exists: {}", - app_state.gateway_state.is_some() - ); - info!( - "[OAuth] Grant service exists: {}", - app_state.grant_service.is_some() - ); - - // Get GrantService (centralized grant management with auto-notifications) - let Some(ref grant_service) = app_state.grant_service else { - error!( - "[OAuth] Grant service is None! Gateway running={}, gateway_state={}", - app_state.running, - app_state.gateway_state.is_some() - ); - return Err("Gateway not running".to_string()); - }; - - // Single call handles: DB update + validation + automatic notifications (DRY!) - grant_service - .grant_feature_set(&client_id, &space_id, &feature_set_id) - .await - .map_err(|e| format!("Failed to grant feature set: {}", e))?; - - // Notify UI - if let Err(e) = app_handle.emit( - "oauth-client-changed", - serde_json::json!({ - "action": "grants_updated", - "client_id": client_id, - }), - ) { - error!("[OAuth] Failed to emit oauth-client-changed event: {}", e); - } - - Ok(()) -} - -/// Revoke a feature set from an OAuth client in a specific space -#[tauri::command] -pub async fn revoke_oauth_client_feature_set( - app_handle: tauri::AppHandle, - gateway_state: State<'_, Arc>>, - client_id: String, - space_id: String, - feature_set_id: String, -) -> Result<(), String> { - let app_state = gateway_state.read().await; - - // Get GrantService (centralized grant management with auto-notifications) - let Some(ref grant_service) = app_state.grant_service else { - return Err("Gateway not running".to_string()); - }; - - // Single call handles: DB update + validation + automatic notifications (DRY!) - grant_service - .revoke_feature_set(&client_id, &space_id, &feature_set_id) - .await - .map_err(|e| format!("Failed to revoke feature set: {}", e))?; - - // Notify UI - if let Err(e) = app_handle.emit( - "oauth-client-changed", - serde_json::json!({ - "action": "grants_updated", - "client_id": client_id, - }), - ) { - error!("[OAuth] Failed to emit oauth-client-changed event: {}", e); - } - - Ok(()) -} - -/// Resolved client features response -#[derive(Debug, Serialize, Deserialize)] -pub struct ResolvedClientFeatures { - pub space_id: String, - pub feature_set_ids: Vec, - pub tools: Vec, - pub prompts: Vec, - pub resources: Vec, -} - -/// Get resolved features for an OAuth client in a specific space -/// -/// Returns the granted feature sets and resolved capabilities for a client. -/// This is used by the UI to display what a client has access to. -/// -/// The frontend is responsible for determining which space to query: -/// - For locked clients: pass the client's locked_space_id -/// - For follow_active clients: pass the currently active space_id -/// -/// This keeps space resolution logic in ONE place (frontend/SpaceResolverService) -/// rather than duplicating it here. -#[tauri::command] -pub async fn get_oauth_client_resolved_features( - gateway_state: State<'_, Arc>>, - app_state: State<'_, crate::AppState>, - client_id: String, - space_id: String, // Required - frontend must resolve which space to use -) -> Result { - let gw_app_state = gateway_state.read().await; - - // Get gateway state - let Some(ref gw_state) = gw_app_state.gateway_state else { - return Err("Gateway not running".to_string()); - }; - - // Get feature service - let Some(ref feature_service) = gw_app_state.feature_service else { - return Err("Feature service not available".to_string()); - }; - - // Get inbound client repository for grants - let state = gw_state.read().await; - let Some(repo) = state.inbound_client_repository() else { - return Err("Database not available".to_string()); - }; - - // Get explicit grants for this client in this space - let mut feature_set_ids = repo - .get_grants_for_space(&client_id, &space_id) - .await - .map_err(|e| format!("Failed to get grants: {}", e))?; - - // Add default feature set (layered resolution - same as MCP handlers) - if let Ok(Some(default_fs)) = app_state - .feature_set_repository - .get_default_for_space(&space_id) - .await - { - if !feature_set_ids.contains(&default_fs.id) { - feature_set_ids.push(default_fs.id); - } - } - - info!( - "[OAuth] Client {} has {} effective grants in space {}", - client_id, - feature_set_ids.len(), - space_id - ); - - // Release the lock before calling feature service - drop(state); - - // Resolve features from feature sets using FeatureService - let tools = feature_service - .get_tools_for_grants(&space_id, &feature_set_ids) - .await - .unwrap_or_default(); - - let prompts = feature_service - .get_prompts_for_grants(&space_id, &feature_set_ids) - .await - .unwrap_or_default(); - - let resources = feature_service - .get_resources_for_grants(&space_id, &feature_set_ids) - .await - .unwrap_or_default(); - - info!( - "[OAuth] Resolved features for client {}: {} tools, {} prompts, {} resources", - client_id, - tools.len(), - prompts.len(), - resources.len() - ); - - // Convert to response format - let tools_response: Vec<_> = tools - .iter() - .map(|f| { - serde_json::json!({ - "name": f.feature_name, - "description": f.description, - "server_id": f.server_id, - }) - }) - .collect(); - - let prompts_response: Vec<_> = prompts - .iter() - .map(|f| { - serde_json::json!({ - "name": f.feature_name, - "description": f.description, - "server_id": f.server_id, - }) - }) - .collect(); - - let resources_response: Vec<_> = resources - .iter() - .map(|f| { - serde_json::json!({ - "name": f.feature_name, - "description": f.description, - "server_id": f.server_id, - }) - }) - .collect(); - - Ok(ResolvedClientFeatures { - space_id, - feature_set_ids, - tools: tools_response, - prompts: prompts_response, - resources: resources_response, - }) -} - /// Delete an OAuth client (direct service access) #[tauri::command] pub async fn delete_oauth_client( diff --git a/apps/desktop/src-tauri/src/commands/settings.rs b/apps/desktop/src-tauri/src/commands/settings.rs index 46237e8..1029951 100644 --- a/apps/desktop/src-tauri/src/commands/settings.rs +++ b/apps/desktop/src-tauri/src/commands/settings.rs @@ -172,9 +172,9 @@ mod tests { #[test] fn test_startup_settings_default() { let settings = StartupSettings::default(); - assert_eq!(settings.auto_launch, true); - assert_eq!(settings.start_minimized, true); - assert_eq!(settings.close_to_tray, true); + assert!(settings.auto_launch); + assert!(settings.start_minimized); + assert!(settings.close_to_tray); } #[test] @@ -196,9 +196,9 @@ mod tests { let json = r#"{"autoLaunch":true,"startMinimized":true,"closeToTray":false}"#; let settings: StartupSettings = serde_json::from_str(json).unwrap(); - assert_eq!(settings.auto_launch, true); - assert_eq!(settings.start_minimized, true); - assert_eq!(settings.close_to_tray, false); + assert!(settings.auto_launch); + assert!(settings.start_minimized); + assert!(!settings.close_to_tray); } #[test] diff --git a/apps/desktop/src-tauri/src/commands/space.rs b/apps/desktop/src-tauri/src/commands/space.rs index de5b42c..7b54787 100644 --- a/apps/desktop/src-tauri/src/commands/space.rs +++ b/apps/desktop/src-tauri/src/commands/space.rs @@ -1,11 +1,14 @@ //! Space management commands //! -//! IPC commands for managing spaces (isolated environments). +//! IPC commands for managing spaces (isolated environments). There's no +//! "active space" — gateway routing is decided per reported workspace +//! root via `WorkspaceBinding`, with the `is_default` Space as the +//! built-in fallback. The desktop UI tracks which space the user is +//! viewing in its own Zustand store (frontend-only state). -use mcpmux_core::{ConnectionMode, Space}; -use serde::Serialize; +use mcpmux_core::Space; use std::sync::Arc; -use tauri::{AppHandle, Emitter, State}; +use tauri::{AppHandle, State}; use tokio::sync::RwLock; use tracing::{info, warn}; use uuid::Uuid; @@ -14,28 +17,6 @@ use crate::commands::gateway::GatewayAppState; use crate::state::AppState; use crate::tray; -/// Space change event payload -#[derive(Debug, Clone, Serialize)] -pub struct SpaceChangeEvent { - /// Previous active space ID - pub from_space_id: Option, - /// New active space ID - pub to_space_id: String, - /// New active space name - pub to_space_name: String, - /// Clients that need confirmation (AskOnChange mode) - pub clients_needing_confirmation: Vec, -} - -/// Client that needs confirmation for space change -#[derive(Debug, Clone, Serialize)] -pub struct ClientConfirmation { - /// Client ID - pub id: String, - /// Client name - pub name: String, -} - /// List all spaces. #[tauri::command] pub async fn list_spaces(state: State<'_, AppState>) -> Result, String> { @@ -156,157 +137,6 @@ pub async fn delete_space( Ok(()) } -/// Get the active (default) space. -#[tauri::command] -pub async fn get_active_space(state: State<'_, AppState>) -> Result, String> { - tracing::info!("[get_active_space] Command invoked"); - - let active = state.space_service.get_active().await.map_err(|e| { - tracing::error!("[get_active_space] Error: {}", e); - e.to_string() - })?; - - if let Some(ref space) = active { - tracing::info!( - "[get_active_space] Returning: {} ({})", - space.name, - space.id - ); - } else { - tracing::warn!("[get_active_space] No active space found"); - } - - Ok(active) -} - -/// Set (or clear with `None`) the active FeatureSet for a Space. -/// -/// The active FS is the fallback applied when a connected client has no -/// access-key pin and no workspace-binding match. See migration 002 for the -/// schema and `FeatureSetResolverService` for enforcement. -#[tauri::command] -pub async fn set_space_active_feature_set( - space_id: String, - feature_set_id: Option, - state: State<'_, AppState>, -) -> Result<(), String> { - let space_uuid = Uuid::parse_str(&space_id).map_err(|e| e.to_string())?; - let fs_uuid = feature_set_id - .as_ref() - .map(|s| Uuid::parse_str(s)) - .transpose() - .map_err(|e| e.to_string())?; - - state - .space_service - .set_active_feature_set(&space_uuid, fs_uuid.as_ref()) - .await - .map_err(|e| { - tracing::error!("[set_space_active_feature_set] {e}"); - e.to_string() - })?; - - info!( - space_id = %space_id, - feature_set_id = ?feature_set_id, - "[set_space_active_feature_set] active FS updated", - ); - Ok(()) -} - -/// Set the active space. -#[tauri::command] -pub async fn set_active_space( - id: String, - app_handle: AppHandle, - state: State<'_, AppState>, - gateway_state: State<'_, Arc>>, -) -> Result<(), String> { - let new_space_uuid = Uuid::parse_str(&id).map_err(|e| e.to_string())?; - - // Get current active space before changing - let old_space = state - .space_service - .get_active() - .await - .map_err(|e| e.to_string())?; - - // Set new active space - state - .space_service - .set_active(&new_space_uuid) - .await - .map_err(|e| e.to_string())?; - - // Get new space details - let new_space = state - .space_service - .get(&new_space_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or("Space not found")?; - - // Emit domain event if gateway is running - let gw_state = gateway_state.read().await; - if let Some(ref gw) = gw_state.gateway_state { - let gw = gw.read().await; - - // Emit activated event with transition info - gw.emit_domain_event(mcpmux_core::DomainEvent::SpaceActivated { - from_space_id: old_space.as_ref().map(|s| s.id), - to_space_id: new_space.id, - to_space_name: new_space.name.clone(), - }); - } - - // Find clients with AskOnChange mode - let clients = state - .client_repository - .list() - .await - .map_err(|e| e.to_string())?; - - let clients_needing_confirmation: Vec = clients - .into_iter() - .filter(|c| matches!(c.connection_mode, ConnectionMode::AskOnChange { .. })) - .map(|c| ClientConfirmation { - id: c.id.to_string(), - name: c.name, - }) - .collect(); - - // Emit legacy space-changed event for backward compatibility (can be removed later) - let event = SpaceChangeEvent { - from_space_id: old_space.map(|s| s.id.to_string()), - to_space_id: new_space.id.to_string(), - to_space_name: new_space.name.clone(), - clients_needing_confirmation: clients_needing_confirmation.clone(), - }; - - if let Err(e) = app_handle.emit("space-changed", &event) { - warn!("Failed to emit space-changed event: {}", e); - } else { - info!( - "Emitted space-changed event: {} clients need confirmation", - clients_needing_confirmation.len() - ); - } - - // Note: MCP list_changed notifications for follow_active clients - // will be emitted by the gateway when they make their next request - // and the SpaceResolver returns the new active space. - - // Update system tray menu to show checkmark (✓) on the newly active space - // Only reached if set_active operation succeeded in DB - if let Err(e) = tray::update_tray_spaces(&app_handle, &state).await { - warn!("Failed to update tray menu: {}", e); - } - - info!("[set_active_space] Switched to space '{}'", new_space.name); - - Ok(()) -} - /// Open space configuration file in external editor #[tauri::command] pub async fn open_space_config_file( diff --git a/apps/desktop/src-tauri/src/commands/workspace_binding.rs b/apps/desktop/src-tauri/src/commands/workspace_binding.rs index eb02c3a..7341ca7 100644 --- a/apps/desktop/src-tauri/src/commands/workspace_binding.rs +++ b/apps/desktop/src-tauri/src/commands/workspace_binding.rs @@ -1,23 +1,56 @@ //! Tauri commands for workspace-root FeatureSet bindings. //! -//! Bindings are the middle tier of the FeatureSet resolver (pin > binding > -//! space-active). Paths passed in from the UI are normalized via -//! [`mcpmux_core::normalize_workspace_root`] before storage so lookups from -//! the gateway's session registry hit them consistently. +//! Every binding hard-pins a concrete (space_id, feature_set_id) pair. No +//! "follow active" modes — the mapping from root on disk to the toolset that +//! clients see is fully explicit, which is what our users actually want. -use mcpmux_core::{normalize_workspace_root, WorkspaceBinding}; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use mcpmux_core::{ + validate_workspace_root as validate_root, DomainEvent, FeatureSet, FeatureSetType, MemberMode, + MemberType, ServerFeature, WorkspaceBinding, WorkspaceRootValidation, +}; use serde::{Deserialize, Serialize}; use tauri::State; -use tracing::{error, info}; +use tokio::sync::RwLock; +use tracing::{debug, error, info}; use uuid::Uuid; +use super::gateway::GatewayAppState; +use super::server_manager::ServerManagerState; use crate::state::AppState; +/// Publish `WorkspaceBindingChanged` on the gateway's domain bus so +/// MCPNotifier broadcasts `list_changed` to every peer whose session now +/// routes through the changed binding. +/// +/// Best-effort: gateway not running (no subscribers) is a normal condition +/// at startup and must not fail the command. +async fn emit_binding_changed( + gateway_state: &Arc>, + space_id: Uuid, + workspace_root: String, +) { + let gw_state = gateway_state.read().await; + let Some(ref gw) = gw_state.gateway_state else { + debug!("[workspace_binding] gateway not running — skipping emit"); + return; + }; + gw.read() + .await + .emit_domain_event(DomainEvent::WorkspaceBindingChanged { + space_id, + workspace_root, + }); +} + +/// DTO returned to the React layer. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WorkspaceBindingDto { pub id: String, - pub space_id: String, pub workspace_root: String, + pub space_id: String, pub feature_set_id: String, pub created_at: String, pub updated_at: String, @@ -27,16 +60,57 @@ impl From for WorkspaceBindingDto { fn from(b: WorkspaceBinding) -> Self { Self { id: b.id.to_string(), - space_id: b.space_id.to_string(), workspace_root: b.workspace_root, - feature_set_id: b.feature_set_id.to_string(), + space_id: b.space_id.to_string(), + feature_set_id: b.feature_set_id, created_at: b.created_at.to_rfc3339(), updated_at: b.updated_at.to_rfc3339(), } } } -/// List every binding across all Spaces. +/// Input for creating or updating a binding. Both `space_id` (UUID) and +/// `feature_set_id` (stringy — custom sets use UUIDs, builtins use +/// `fs_default_`) are required. +#[derive(Debug, Deserialize)] +pub struct WorkspaceBindingInput { + pub workspace_root: String, + pub space_id: String, + pub feature_set_id: String, +} + +fn parse_space_id(input: &WorkspaceBindingInput) -> Result { + Uuid::parse_str(&input.space_id).map_err(|e| format!("bad space_id: {e}")) +} + +fn validate_non_empty_fs(input: &WorkspaceBindingInput) -> Result { + if input.feature_set_id.trim().is_empty() { + Err("feature_set_id required".into()) + } else { + Ok(input.feature_set_id.clone()) + } +} + +/// List every filesystem path connected MCP clients have reported as a +/// workspace root, deduplicated across sessions. The Workspaces tab +/// renders this next to the persisted bindings so users can configure +/// folders they missed the one-shot prompt for. +/// +/// Returns an empty list when the gateway isn't running — that's a normal +/// startup condition, not an error. +#[tauri::command] +pub async fn list_reported_workspace_roots( + gateway_state: State<'_, Arc>>, +) -> Result, String> { + let guard = gateway_state.read().await; + Ok(guard + .session_roots + .as_ref() + .map(|reg| reg.list_all_roots()) + .unwrap_or_default()) +} + +/// List every binding (sorted by workspace_root). #[tauri::command] pub async fn list_workspace_bindings( state: State<'_, AppState>, @@ -52,7 +126,7 @@ pub async fn list_workspace_bindings( }) } -/// List bindings for a specific Space. +/// Bindings whose target Space is the given one. #[tauri::command] pub async fn list_workspace_bindings_for_space( space_id: String, @@ -67,68 +141,121 @@ pub async fn list_workspace_bindings_for_space( .map_err(|e| e.to_string()) } -/// Create a new binding. `workspace_root` is normalized before storage. +/// Live path validation for the UI — returns `Ok(normalized)` or +/// `Err(reason)`. Runs the same rules the create/update commands apply, so +/// the form can show the real error message without round-tripping a save. +#[tauri::command] +pub async fn validate_workspace_root(path: String) -> Result { + match validate_root(&path) { + WorkspaceRootValidation::Empty => Err(String::new()), + WorkspaceRootValidation::Ok { normalized } => Ok(normalized), + WorkspaceRootValidation::Invalid { reason } => Err(reason), + } +} + +/// Normalize + validate a manually-entered workspace root, returning the +/// canonical form to store. Rejects relative paths, filesystem roots, and +/// (for Windows-style paths) reserved characters — these are the exact +/// conditions that would produce a binding no session could ever match. +fn normalize_and_validate(raw: &str) -> Result { + match validate_root(raw) { + WorkspaceRootValidation::Empty => Err("workspace_root cannot be empty".into()), + WorkspaceRootValidation::Ok { normalized } => Ok(normalized), + WorkspaceRootValidation::Invalid { reason } => Err(reason), + } +} + +/// Create a binding. Path is normalized + validated server-side so the UI +/// can pass raw input (Windows paths, file:// URIs, trailing slashes). #[tauri::command] pub async fn create_workspace_binding( - space_id: String, - workspace_root: String, - feature_set_id: String, + input: WorkspaceBindingInput, state: State<'_, AppState>, + gateway_state: State<'_, Arc>>, ) -> Result { - let space_uuid = Uuid::parse_str(&space_id).map_err(|e| e.to_string())?; - let fs_uuid = Uuid::parse_str(&feature_set_id).map_err(|e| e.to_string())?; - let normalized = normalize_workspace_root(&workspace_root); - if normalized.is_empty() { - return Err("workspace_root cannot be empty".into()); - } - let binding = WorkspaceBinding::new(space_uuid, normalized, fs_uuid); + let space_id = parse_space_id(&input)?; + let feature_set_id = validate_non_empty_fs(&input)?; + let normalized = normalize_and_validate(&input.workspace_root)?; + + let binding = WorkspaceBinding::new(normalized.clone(), space_id, feature_set_id); + state .workspace_binding_repository .create(&binding) .await .map_err(|e| e.to_string())?; + info!( - space_id = %binding.space_id, - workspace_root = %binding.workspace_root, + binding_id = %binding.id, + root = %binding.workspace_root, + %space_id, feature_set_id = %binding.feature_set_id, "[workspace_binding] created", ); + + emit_binding_changed( + gateway_state.inner(), + binding.space_id, + binding.workspace_root.clone(), + ) + .await; Ok(binding.into()) } -/// Update an existing binding (e.g., point it at a different FS). +/// Update an existing binding. Accepts full input so the UI can edit any +/// axis (root, target space, target FS) in one call. #[tauri::command] pub async fn update_workspace_binding( id: String, - workspace_root: String, - feature_set_id: String, + input: WorkspaceBindingInput, state: State<'_, AppState>, + gateway_state: State<'_, Arc>>, ) -> Result { let id_uuid = Uuid::parse_str(&id).map_err(|e| e.to_string())?; - let fs_uuid = Uuid::parse_str(&feature_set_id).map_err(|e| e.to_string())?; - let normalized = normalize_workspace_root(&workspace_root); - if normalized.is_empty() { - return Err("workspace_root cannot be empty".into()); - } + let space_id = parse_space_id(&input)?; + let feature_set_id = validate_non_empty_fs(&input)?; + let normalized = normalize_and_validate(&input.workspace_root)?; + let existing = state .workspace_binding_repository .get(&id_uuid) .await .map_err(|e| e.to_string())? .ok_or_else(|| format!("binding not found: {}", id))?; + let old_space_id = existing.space_id; + let updated = WorkspaceBinding { id: existing.id, - space_id: existing.space_id, workspace_root: normalized, - feature_set_id: fs_uuid, + space_id, + feature_set_id, created_at: existing.created_at, updated_at: chrono::Utc::now(), }; + state .workspace_binding_repository .update(&updated) .await .map_err(|e| e.to_string())?; + + // Notify the NEW target space first (peers that now route via this + // binding). If the space changed, also notify the OLD target so peers + // that resolved there lose the stale route. + emit_binding_changed( + gateway_state.inner(), + updated.space_id, + updated.workspace_root.clone(), + ) + .await; + if old_space_id != updated.space_id { + emit_binding_changed( + gateway_state.inner(), + old_space_id, + updated.workspace_root.clone(), + ) + .await; + } Ok(updated.into()) } @@ -137,11 +264,339 @@ pub async fn update_workspace_binding( pub async fn delete_workspace_binding( id: String, state: State<'_, AppState>, + gateway_state: State<'_, Arc>>, ) -> Result<(), String> { let id_uuid = Uuid::parse_str(&id).map_err(|e| e.to_string())?; + + // Capture the binding before delete so we know which space to notify. + let existing = state + .workspace_binding_repository + .get(&id_uuid) + .await + .map_err(|e| e.to_string())?; + state .workspace_binding_repository .delete(&id_uuid) .await - .map_err(|e| e.to_string()) + .map_err(|e| e.to_string())?; + + if let Some(b) = existing { + emit_binding_changed(gateway_state.inner(), b.space_id, b.workspace_root).await; + } + Ok(()) +} + +// ============================================================================ +// Workspace effective-features inspection +// +// Surfaces the same view the gateway resolver builds for live sessions, so +// the desktop UI can answer: "for this folder, what tools/prompts/resources +// would a connected client see right now — and which are configured-but- +// unavailable because their backend server is currently disconnected?" +// +// Pure read-only — no mutations, no event emission. +// ============================================================================ + +/// Per-feature view returned by `get_workspace_effective_features`. +/// +/// `available` is `true` exactly when the underlying server is currently +/// connected. A `false` value with `server_status = "disconnected"` +/// (or `auth_required` / `error`) is the user's "configured but +/// unavailable" case — the FS still includes this feature, but its +/// server isn't usable right now so the gateway hides it from clients. +#[derive(Debug, Clone, Serialize)] +pub struct EffectiveFeatureDto { + pub id: String, + pub feature_name: String, + pub display_name: Option, + pub description: Option, + pub server_id: String, + pub server_alias: Option, + /// snake_case mirror of `mcpmux_gateway::pool::ConnectionStatus`, plus + /// `unknown` when the gateway isn't running (so the UI can grey-out + /// without lying about the cause). + pub server_status: String, + pub available: bool, +} + +/// Top-level DTO: the resolved (Space, FeatureSet) pair for a given root, +/// plus its full configured tool/prompt/resource lists with availability. +#[derive(Debug, Clone, Serialize)] +pub struct WorkspaceEffectiveFeaturesDto { + /// Normalized form of the input root (lower-case drive letter, no + /// trailing slash, etc.). + pub workspace_root: String, + /// `binding` when a `WorkspaceBinding` matched the longest prefix of + /// the root; `fallback` when no binding matched and the resolver fell + /// through to the default Space's Default FS. + pub source: String, + /// `Some(id)` only when `source == "binding"`. + pub binding_id: Option, + pub space_id: String, + pub space_name: String, + pub feature_set_id: String, + pub feature_set_name: String, + pub feature_set_type: String, + /// Configured features by type (includes unavailable ones). + pub tools: Vec, + pub prompts: Vec, + pub resources: Vec, +} + +/// Walk a FeatureSet's members (with nested-FS recursion) to compute the +/// allowed and excluded feature-id sets — same shape the gateway resolver +/// uses, but kept here so we can omit the `is_available` filter and surface +/// "configured but disconnected" features to the UI. +fn collect_member_ids( + fs: &FeatureSet, + fs_lookup: &HashMap, + allowed: &mut HashSet, + excluded: &mut HashSet, + visited: &mut HashSet, +) { + if !visited.insert(fs.id.clone()) { + return; // cycle guard + } + for m in &fs.members { + match m.member_type { + MemberType::Feature => match m.mode { + MemberMode::Include => { + allowed.insert(m.member_id.clone()); + } + MemberMode::Exclude => { + excluded.insert(m.member_id.clone()); + } + }, + MemberType::FeatureSet => { + if let Some(nested) = fs_lookup.get(&m.member_id) { + collect_member_ids(nested, fs_lookup, allowed, excluded, visited); + } + } + } + } +} + +fn server_status_str(status: mcpmux_gateway::ConnectionStatus) -> &'static str { + use mcpmux_gateway::ConnectionStatus as S; + match status { + S::Disconnected => "disconnected", + S::Connecting => "connecting", + S::Connected => "connected", + S::Refreshing => "refreshing", + S::AuthRequired => "auth_required", + S::Authenticating => "authenticating", + S::Error => "error", + } +} + +fn enrich_feature( + f: &ServerFeature, + server_statuses: &HashMap, + gateway_running: bool, +) -> EffectiveFeatureDto { + let status = server_statuses.get(&f.server_id).copied(); + let server_status = match status { + Some(s) => server_status_str(s).to_string(), + // No status entry usually means "gateway not running yet". Fall + // back to the cached `is_available` flag so the UI can still mark + // unavailable features without claiming a status it doesn't know. + None if !gateway_running => "unknown".to_string(), + None => "disconnected".to_string(), + }; + let available = matches!(status, Some(mcpmux_gateway::ConnectionStatus::Connected)) + || (!gateway_running && f.is_available); + + EffectiveFeatureDto { + id: f.id.to_string(), + feature_name: f.feature_name.clone(), + display_name: f.display_name.clone(), + description: f.description.clone(), + server_id: f.server_id.clone(), + server_alias: f.server_alias.clone(), + server_status, + available, + } +} + +/// Compute the resolved (Space, FeatureSet) for a workspace root and return +/// its full configured feature list with per-feature availability. +/// +/// The frontend calls this from the Workspaces tab inspector to answer the +/// "what tools does this folder actually see?" question. It's safe to call +/// even when the gateway isn't running — we degrade gracefully to +/// `server_status = "unknown"` and lean on the cached `is_available` flag. +#[tauri::command] +pub async fn get_workspace_effective_features( + workspace_root: String, + state: State<'_, AppState>, + sm_state: State<'_, Arc>>, +) -> Result { + // 1. Normalize the input the same way the resolver does. + let normalized = match validate_root(&workspace_root) { + WorkspaceRootValidation::Empty => return Err("workspace_root cannot be empty".into()), + WorkspaceRootValidation::Ok { normalized } => normalized, + WorkspaceRootValidation::Invalid { reason } => return Err(reason), + }; + + // 2. Default Space — the routing fallback. + let default_space = state + .space_service + .get_default() + .await + .map_err(|e| e.to_string())? + .ok_or("No default Space configured")?; + + // 3. Tier 1: longest-prefix workspace binding match. + let binding = state + .workspace_binding_repository + .find_longest_prefix_match(&default_space.id, std::slice::from_ref(&normalized)) + .await + .map_err(|e| e.to_string())?; + + let (source, binding_id, space_id, fs_id) = match binding { + Some(b) => ( + "binding".to_string(), + Some(b.id.to_string()), + b.space_id, + b.feature_set_id, + ), + None => { + let default_fs = state + .feature_set_repository + .get_default_for_space(&default_space.id.to_string()) + .await + .map_err(|e| e.to_string())? + .ok_or("Default Space has no Default FeatureSet")?; + ( + "fallback".to_string(), + None, + default_space.id, + default_fs.id, + ) + } + }; + + let space = state + .space_service + .get(&space_id) + .await + .map_err(|e| e.to_string())? + .ok_or("Resolved Space no longer exists")?; + + // 4. The FS itself — with members for the walk below. + let fs = state + .feature_set_repository + .get_with_members(&fs_id) + .await + .map_err(|e| e.to_string())? + .ok_or("Resolved FeatureSet not found")?; + + // 5. Pre-fetch every FS in the same Space so nested-FS members can be + // resolved without N round trips. Cheap — this is just a metadata + // table and Spaces typically hold a handful of sets. + let space_sets = state + .feature_set_repository + .list_by_space(&space_id.to_string()) + .await + .map_err(|e| e.to_string())?; + let mut fs_lookup: HashMap = HashMap::new(); + for sibling in space_sets { + if let Ok(Some(full)) = state + .feature_set_repository + .get_with_members(&sibling.id) + .await + { + fs_lookup.insert(full.id.clone(), full); + } + } + fs_lookup.insert(fs.id.clone(), fs.clone()); + + // 6. Walk members → allowed / excluded id sets. + let mut allowed = HashSet::::new(); + let mut excluded = HashSet::::new(); + let mut visited = HashSet::::new(); + collect_member_ids(&fs, &fs_lookup, &mut allowed, &mut excluded, &mut visited); + + // 7. Pull every feature in the Space, then keep only those that pass + // the FS filter — without the `is_available` gate, so we can show + // "configured but disconnected" rows. + let all_features = state + .server_feature_repository_core + .list_for_space(&space_id.to_string()) + .await + .map_err(|e| e.to_string())?; + let filtered: Vec = all_features + .into_iter() + .filter(|f| { + let fid = f.id.to_string(); + allowed.contains(&fid) && !excluded.contains(&fid) + }) + .collect(); + + // 8. Server statuses — only available when the gateway is running. + let (server_statuses, gateway_running): ( + HashMap, + bool, + ) = { + let sm = sm_state.read().await; + match sm.manager.as_ref() { + Some(mgr) => { + let map = mgr + .get_all_statuses(space_id) + .await + .into_iter() + .map(|(id, (status, _, _, _))| (id, status)) + .collect(); + (map, true) + } + None => (HashMap::new(), false), + } + }; + + // 9. Bucket by feature type. + let mut tools = Vec::new(); + let mut prompts = Vec::new(); + let mut resources = Vec::new(); + for f in &filtered { + let dto = enrich_feature(f, &server_statuses, gateway_running); + match f.feature_type { + mcpmux_core::FeatureType::Tool => tools.push(dto), + mcpmux_core::FeatureType::Prompt => prompts.push(dto), + mcpmux_core::FeatureType::Resource => resources.push(dto), + } + } + // Stable order: alphabetical by qualified-ish name so the UI doesn't + // jitter between calls. + let sort_key = |a: &EffectiveFeatureDto| { + format!( + "{}/{}", + a.server_alias + .clone() + .unwrap_or_else(|| a.server_id.clone()), + a.feature_name + ) + }; + tools.sort_by_key(sort_key); + prompts.sort_by_key(sort_key); + resources.sort_by_key(sort_key); + + let feature_set_type = match fs.feature_set_type { + FeatureSetType::Default => "default", + FeatureSetType::Custom => "custom", + }; + + Ok(WorkspaceEffectiveFeaturesDto { + workspace_root: normalized, + source, + binding_id, + space_id: space_id.to_string(), + space_name: space.name, + feature_set_id: fs.id, + feature_set_name: fs.name, + feature_set_type: feature_set_type.to_string(), + tools, + prompts, + resources, + }) } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 9ee6327..b943554 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -14,9 +14,9 @@ mod state; mod tray; // Re-export deep link handler -use commands::oauth::handle_deep_link; +use commands::oauth::{route_or_buffer_deep_link, PendingInitialDeepLink}; -use commands::gateway::GatewayAppState; +use commands::gateway::{GatewayAppState, PendingPortConflict}; use commands::server_manager::ServerManagerState; use state::AppState; @@ -223,39 +223,35 @@ pub fn run() { info!("Logs directory: {}", logs_dir.display()); tauri::Builder::default() - .plugin(tauri_plugin_opener::init()) - .plugin(tauri_plugin_dialog::init()) - .plugin(tauri_plugin_deep_link::init()) - .plugin(tauri_plugin_autostart::init( - tauri_plugin_autostart::MacosLauncher::LaunchAgent, - Some(vec!["--hidden"]), // Start minimized to tray - )) - .plugin(tauri_plugin_updater::Builder::new().build()) - .plugin(tauri_plugin_process::init()) + // single_instance MUST be registered BEFORE deep_link so its `deep-link` + // feature can forward cold-start URLs (Windows argv[1]) through the + // deep_link plugin's on_open_url handler. Registering deep_link first + // orphans the initial URL — no on_open_url fires, no consent popup. .plugin(tauri_plugin_single_instance::init(|app, args, cwd| { - // This callback is called when a second instance is launched + // Fires when a SECOND instance is launched (e.g. browser deep link + // click while mcpmux is already running). The `deep-link` feature + // on this plugin hands argv off to the deep_link plugin's + // on_open_url on cold-start; this callback only needs to focus + // the window and handle any deep-link arg that single-instance + // did NOT forward (belt-and-suspenders for platforms or versions + // where the auto-forward doesn't trigger). info!("Second instance detected, focusing existing window"); info!("Args: {:?}, CWD: {:?}", args, cwd); - // Check if any arg is a deep link URL for arg in &args { if branding::is_deep_link(arg) { info!("Deep link received via second instance: {}", arg); - handle_deep_link(app, arg); + route_or_buffer_deep_link(app, arg); } } - // Try to focus the main window if let Some(window) = app.get_webview_window("main") { - // Show window if hidden if let Err(e) = window.show() { warn!("Failed to show window: {}", e); } - // Unminimize if minimized if let Err(e) = window.unminimize() { warn!("Failed to unminimize window: {}", e); } - // Focus the window if let Err(e) = window.set_focus() { warn!("Failed to focus window: {}", e); } @@ -263,6 +259,15 @@ pub fn run() { warn!("Main window not found"); } })) + .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_deep_link::init()) + .plugin(tauri_plugin_autostart::init( + tauri_plugin_autostart::MacosLauncher::LaunchAgent, + Some(vec!["--hidden"]), // Start minimized to tray + )) + .plugin(tauri_plugin_updater::Builder::new().build()) + .plugin(tauri_plugin_process::init()) .setup(|app| { info!("Initializing application state..."); @@ -281,6 +286,41 @@ pub fn run() { app.manage(state); + // Backfill the auto-seeded Default FeatureSet for any space that + // predates the seeding code path. Runs once per app boot; idempotent. + // + // Must run inside an async context (the repo uses tokio locks + // internally) — the setup closure runs before the user-facing + // tokio runtime starts, so we use Tauri's own runtime here. + { + let app_state_for_backfill: tauri::State<'_, AppState> = app.state(); + // We can't borrow `app_state_for_backfill` across the await + // inside block_on, so snapshot the two repo handles we need. + let fs_repo = app_state_for_backfill.feature_set_repository.clone(); + let db_for_backfill = app_state_for_backfill.database(); + tauri::async_runtime::block_on(async move { + use mcpmux_core::SpaceRepository; + let space_repo = mcpmux_storage::SqliteSpaceRepository::new(db_for_backfill); + let spaces = space_repo.list().await.unwrap_or_default(); + for s in &spaces { + if let Err(e) = + fs_repo.ensure_builtin_for_space(&s.id.to_string()).await + { + warn!( + space_id = %s.id, + space_name = %s.name, + error = %e, + "[Startup] failed to backfill Default FS", + ); + } + } + info!( + "[Startup] Default FS backfill complete across {} space(s)", + spaces.len() + ); + }); + } + // Create event bus and ServerAppService let app_state: tauri::State<'_, AppState> = app.state(); let event_bus = mcpmux_core::create_shared_event_bus(); @@ -288,7 +328,6 @@ pub fn run() { let server_app_service = mcpmux_core::ServerAppService::new( app_state.installed_server_repository.clone(), - Some(app_state.feature_set_repository.clone()), Some(app_state.server_feature_repository_core.clone()), Some(app_state.credential_repository.clone()), event_sender, @@ -326,15 +365,49 @@ pub fn run() { return; } - // Resolve port using the service (Single Responsibility) - let final_port = match port_service.resolve_and_allocate().await { - Ok(port) => port, - Err(e) => { - warn!("[Gateway] Failed to allocate port: {}", e); - return; - } + // Strict port probe — if the preferred port is busy, defer + // to the user instead of silently binding to a random port. + // IDE configs assume the configured port, so a silent + // fallback breaks every connected client. + let persisted = port_service.load_persisted_port().await; + let (preferred_port, source): (u16, &'static str) = match persisted { + Some(p) => (p, "configured"), + None => (mcpmux_core::DEFAULT_GATEWAY_PORT, "default"), }; + if !mcpmux_core::service::is_port_available(preferred_port) { + warn!( + "[Gateway] Auto-start preferred port {} ({}) unavailable — deferring to user", + preferred_port, source + ); + { + let mut state = gw_state_clone.write().await; + state.pending_port_conflict = Some(PendingPortConflict { + preferred_port, + source, + }); + } + // Emit in case the UI is already listening; the UI also + // checks via `get_pending_port_conflict` on mount. + let _ = app_handle_for_sm.emit( + "gateway-autostart-port-conflict", + serde_json::json!({ + "preferredPort": preferred_port, + "source": source, + }), + ); + return; + } + + // Persist default port on first run so the Settings UI + // reflects the active choice. + if persisted.is_none() { + if let Err(e) = port_service.save_port(preferred_port).await { + warn!("[Gateway] Failed to persist default port: {}", e); + } + } + + let final_port = preferred_port; let url = format!("http://localhost:{}", final_port); info!("Auto-starting gateway on {}", url); @@ -399,6 +472,7 @@ pub fn run() { let server_manager_arc = server.server_manager(); let event_emitter = server.event_emitter(); let grant_service = server.grant_service(); + let session_roots = server.session_roots(); // Start domain event bridge crate::commands::gateway::start_domain_event_bridge(&app_handle_for_sm, gw_inner_state.clone()); @@ -406,88 +480,24 @@ pub fn run() { // Subscribe to OAuth completion events let oauth_completion_rx = pool_service.oauth_manager().subscribe(); - info!("[Gateway] Services initialized via DI"); - - // Store ServerManager and PoolService in state - { - let mut sm_state = sm_state_clone.write().await; - sm_state.manager = Some(server_manager_arc.clone()); - sm_state.pool_service = Some(pool_service.clone()); - } - info!("[Gateway] ServerManager initialized with event bridge"); - - // Start OAuth completion handler - reconnects servers after OAuth completes - // IMPORTANT: Each reconnection is spawned as a separate task to allow parallel connections - let sm_for_oauth = server_manager_arc.clone(); - let pool_for_oauth = pool_service.clone(); - tokio::spawn(async move { - use mcpmux_gateway::{ServerKey, ConnectionResult}; - let mut rx = oauth_completion_rx; - - info!("[OAuth Handler] Started listening for OAuth completions"); - - loop { - match rx.recv().await { - Ok(event) => { - info!( - "[OAuth Handler] Received completion for {}: success={}", - event.server_id, event.success - ); - - if event.success { - // OAuth succeeded - spawn reconnection in separate task for parallelism - let sm = sm_for_oauth.clone(); - let pool = pool_for_oauth.clone(); - let server_id = event.server_id.clone(); - let space_id = event.space_id; - - tokio::spawn(async move { - let key = ServerKey::new(space_id, &server_id); - - info!("[OAuth Handler] Attempting reconnection for {}", server_id); - sm.set_connecting(&key).await; - - match pool.reconnect_instance(space_id, &server_id).await { - ConnectionResult::Connected { features, .. } => { - info!("[OAuth Handler] Reconnection successful for {}", server_id); - sm.set_connected(&key, features).await; - } - ConnectionResult::OAuthRequired { .. } => { - warn!("[OAuth Handler] Still requires OAuth after completion: {}", server_id); - sm.set_auth_required(&key, Some("OAuth still required".to_string())).await; - } - ConnectionResult::Failed { error } => { - error!("[OAuth Handler] Reconnection failed for {}: {}", server_id, error); - sm.set_error(&key, error).await; - } - } - }); - } else { - // OAuth failed - handle synchronously (fast operation) - let key = ServerKey::new(event.space_id, &event.server_id); - let error_msg = event.error.unwrap_or_else(|| "OAuth failed".to_string()); - warn!("[OAuth Handler] OAuth failed for {}: {}", event.server_id, error_msg); - sm_for_oauth.set_auth_required(&key, Some(error_msg)).await; - } - } - Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { - warn!("[OAuth Handler] Lagged {} messages", n); - } - Err(tokio::sync::broadcast::error::RecvError::Closed) => { - info!("[OAuth Handler] Channel closed, stopping"); - break; - } - } - } - }); - info!("[Gateway] OAuth completion handler started"); + info!( + "[Gateway] Auto-start services resolved — port={}, server_manager={:p}", + final_port, &*server_manager_arc + ); - // Start periodic refresh loop (every 60s for connected servers) - let _refresh_handle = server_manager_arc.clone().start_periodic_refresh(); - info!("[Gateway] Periodic refresh service started"); + // Wire ServerManager into state + spawn OAuth handler + + // periodic refresh. Shared with start_gateway command so + // both paths leave the app in an identical post-start + // configuration. + crate::commands::gateway::init_gateway_runtime( + pool_service.clone(), + server_manager_arc.clone(), + oauth_completion_rx, + sm_state_clone.clone(), + ) + .await; // Note: Auto-connect happens in the frontend via useEffect calling connect_all_enabled_servers - // This keeps the backend service clean and follows React best practices let handle = server.spawn(); @@ -500,12 +510,27 @@ pub fn run() { state.feature_service = Some(feature_service); state.event_emitter = Some(event_emitter); state.grant_service = Some(grant_service); + state.session_roots = Some(session_roots); info!( "Gateway auto-started successfully on {} - GrantService initialized: {}", url, state.grant_service.is_some() ); + + // Broadcast the started event to the webview. Must happen + // even on auto-start so the status-bar footer and every + // other subscriber reflect the running gateway. + if let Err(e) = app_handle_for_sm.emit( + "gateway-changed", + serde_json::json!({ + "action": "started", + "url": url, + "port": final_port, + }), + ) { + warn!("[Gateway] Failed to emit gateway-changed(started): {}", e); + } }); app.manage(gateway_state); @@ -714,15 +739,94 @@ pub fn run() { use tauri_plugin_deep_link::DeepLinkExt; let app_handle = app.handle().clone(); - // Register the deep link handler + // Buffer state for cold-start URLs that arrive before the + // frontend listener is registered (the common Windows case: + // browser → mcpmux:// → new mcpmux.exe with URL in argv[1]). + app.manage(PendingInitialDeepLink::default()); + + // Route URLs through the buffer-aware helper so cold-start + // URLs are held until the webview signals ready via + // `flush_pending_deep_link`. app.deep_link().on_open_url(move |event| { for url in event.urls() { info!("[DeepLink] Received URL: {}", url); - handle_deep_link(&app_handle, url.as_str()); + route_or_buffer_deep_link(&app_handle, url.as_str()); } }); } + // Terminal-close / Ctrl+C graceful shutdown. + // + // Without this, when the user hits Ctrl+C on `pnpm run dev` or + // closes the terminal window, the process dies before axum + // can drain and release the TCP socket. On a fast restart the + // kernel may still have the listener bound, so the next run + // fails with "port in use". + // + // We translate every termination signal into `app_handle.exit(0)` + // which fires `RunEvent::ExitRequested` — the existing handler + // below then runs the gateway's graceful shutdown. + // + // Windows console control events (CTRL_CLOSE_EVENT, + // CTRL_LOGOFF_EVENT, CTRL_SHUTDOWN_EVENT) give the process + // ~5 seconds before force-kill, which is plenty for the + // ~2.5s graceful drain downstream. + { + let app_handle = app.handle().clone(); + tauri::async_runtime::spawn(async move { + #[cfg(unix)] + { + use tokio::signal::unix::{signal, SignalKind}; + let mut sigterm = match signal(SignalKind::terminate()) { + Ok(s) => s, + Err(e) => { + warn!("[Signal] Failed to install SIGTERM handler: {}", e); + return; + } + }; + let mut sigint = match signal(SignalKind::interrupt()) { + Ok(s) => s, + Err(e) => { + warn!("[Signal] Failed to install SIGINT handler: {}", e); + return; + } + }; + tokio::select! { + _ = sigterm.recv() => info!("[Signal] SIGTERM — requesting exit"), + _ = sigint.recv() => info!("[Signal] SIGINT — requesting exit"), + } + } + #[cfg(windows)] + { + use tokio::signal::windows::{ + ctrl_break, ctrl_c, ctrl_close, ctrl_logoff, ctrl_shutdown, + }; + let (mut c_c, mut c_break, mut c_close, mut c_logoff, mut c_shutdown) = + match ( + ctrl_c(), + ctrl_break(), + ctrl_close(), + ctrl_logoff(), + ctrl_shutdown(), + ) { + (Ok(a), Ok(b), Ok(c), Ok(d), Ok(e)) => (a, b, c, d, e), + _ => { + warn!("[Signal] Failed to install console handlers"); + return; + } + }; + tokio::select! { + _ = c_c.recv() => info!("[Signal] Ctrl+C — requesting exit"), + _ = c_break.recv() => info!("[Signal] Ctrl+Break — requesting exit"), + _ = c_close.recv() => info!("[Signal] Console close — requesting exit"), + _ = c_logoff.recv() => info!("[Signal] Logoff — requesting exit"), + _ = c_shutdown.recv() => info!("[Signal] Shutdown — requesting exit"), + } + } + app_handle.exit(0); + }); + } + info!("Application started successfully"); Ok(()) }) @@ -734,9 +838,6 @@ pub fn run() { commands::get_space, commands::create_space, commands::delete_space, - commands::get_active_space, - commands::set_active_space, - commands::set_space_active_feature_set, commands::open_space_config_file, commands::read_space_config, commands::save_space_config, @@ -765,8 +866,6 @@ pub fn run() { commands::create_feature_set, commands::update_feature_set, commands::delete_feature_set, - commands::get_builtin_feature_sets, - commands::ensure_server_all_feature_set, commands::add_feature_set_member, commands::remove_feature_set_member, commands::set_feature_set_members, @@ -775,7 +874,6 @@ pub fn run() { commands::remove_feature_from_set, commands::get_feature_set_members, // Client custom feature sets - commands::find_or_create_client_custom_feature_set, // Server feature commands commands::list_server_features, commands::list_server_features_by_server, @@ -787,20 +885,16 @@ pub fn run() { commands::get_client, commands::create_client, commands::delete_client, - commands::update_client_grants, - commands::update_client_mode, commands::init_preset_clients, - commands::get_client_grants, - commands::get_all_client_grants, - commands::grant_feature_set_to_client, - commands::revoke_feature_set_from_client, - commands::update_client_pin, // Workspace binding commands (resolver v2) commands::list_workspace_bindings, commands::list_workspace_bindings_for_space, + commands::list_reported_workspace_roots, commands::create_workspace_binding, commands::update_workspace_binding, commands::delete_workspace_binding, + commands::validate_workspace_root, + commands::get_workspace_effective_features, // Meta-tool approval (self-management mcpmux_* tools) commands::respond_to_meta_tool_approval, commands::list_meta_tool_grants, @@ -818,6 +912,11 @@ pub fn run() { commands::add_to_cursor, // Gateway commands commands::get_gateway_status, + commands::get_gateway_port_settings, + commands::set_gateway_port, + commands::reset_gateway_port, + commands::probe_gateway_start, + commands::take_pending_port_conflict, commands::start_gateway, commands::stop_gateway, commands::restart_gateway, @@ -831,14 +930,11 @@ pub fn run() { // OAuth commands commands::approve_oauth_consent, commands::get_pending_consent, + commands::flush_pending_deep_link, commands::get_oauth_clients, commands::approve_oauth_client, commands::update_oauth_client, commands::delete_oauth_client, - commands::get_oauth_client_grants, - commands::grant_oauth_client_feature_set, - commands::revoke_oauth_client_feature_set, - commands::get_oauth_client_resolved_features, commands::open_url, // Server Manager commands (event-driven v2) commands::get_server_statuses, @@ -862,6 +958,36 @@ pub fn run() { commands::get_startup_settings, commands::update_startup_settings, ]) - .run(tauri::generate_context!()) - .expect("error while running McpMux application"); + .build(tauri::generate_context!()) + .expect("error while building McpMux application") + .run(|app_handle, event| { + if let tauri::RunEvent::ExitRequested { .. } = event { + // Graceful gateway shutdown on app exit. Without this, the + // axum listener gets dropped without a close signal, and + // Windows can leave the TCP socket bound in the kernel — + // which is what orphan PID 21408 on :45818 was. + // + // We block for up to ~2.5s to let the listener close. Any + // longer and Windows would kill us with a "process not + // responding" dialog. Any shorter and we race with axum's + // drain. + if let Some(gw_state) = + app_handle.try_state::>>() + { + let gw_state = gw_state.inner().clone(); + tauri::async_runtime::block_on(async move { + let handle = { + let mut state = gw_state.write().await; + state.running = false; + state.url = None; + state.handle.take() + }; + if let Some(h) = handle { + info!("[Gateway] ExitRequested — gracefully shutting down gateway"); + crate::commands::gateway::shutdown_gateway_handle(h).await; + } + }); + } + } + }); } diff --git a/apps/desktop/src-tauri/src/state/mod.rs b/apps/desktop/src-tauri/src/state/mod.rs index a0f58fc..8de935f 100644 --- a/apps/desktop/src-tauri/src/state/mod.rs +++ b/apps/desktop/src-tauri/src/state/mod.rs @@ -4,9 +4,9 @@ //! between Tauri commands. use mcpmux_core::{ - AppSettingsRepository, AppSettingsService, ClientService, CredentialRepository, - FeatureSetRepository, GatewayPortService, InboundMcpClientRepository, - InstalledServerRepository, LogConfig, OutboundOAuthRepository, ServerDiscoveryService, + AppSettingsRepository, AppSettingsService, CredentialRepository, FeatureSetRepository, + GatewayPortService, InboundMcpClientRepository, InstalledServerRepository, LogConfig, + OutboundOAuthRepository, ServerDiscoveryService, ServerFeatureRepository as CoreServerFeatureRepository, ServerLogManager, SpaceRepository, SpaceService, WorkspaceBindingRepository, }; @@ -33,8 +33,6 @@ pub struct AppState { pub gateway_port_service: Arc, /// Service for managing spaces pub space_service: SpaceService, - /// Service for managing clients (auto-grants, etc.) - pub client_service: ClientService, /// Server discovery service for loading servers from API/bundled/user spaces pub server_discovery: Arc, /// Server log manager for file-based logging @@ -124,8 +122,6 @@ impl AppState { space_repository, feature_set_repository.clone(), ); - let client_service = - ClientService::new(client_repository.clone(), feature_set_repository.clone()); // Create server discovery service // Spaces directory is relative to app data_dir (single source of truth) @@ -160,7 +156,6 @@ impl AppState { settings_repository, gateway_port_service, space_service, - client_service, server_discovery, server_log_manager, installed_server_repository, diff --git a/apps/desktop/src-tauri/src/tray.rs b/apps/desktop/src-tauri/src/tray.rs index 6c33056..a4da532 100644 --- a/apps/desktop/src-tauri/src/tray.rs +++ b/apps/desktop/src-tauri/src/tray.rs @@ -76,12 +76,12 @@ pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> { /// Build the tray menu fn build_tray_menu(app: &AppHandle) -> tauri::Result> { - // Space submenu (will be populated dynamically) - let space_submenu = SubmenuBuilder::new(app, "Active Space") + // Space submenu — pure navigation. Clicking a space opens the main + // window and asks the frontend to switch to that space's view. + let space_submenu = SubmenuBuilder::new(app, "Switch Space") .text("space_default", "🌐 Default") .build()?; - // Build simplified main menu let menu = MenuBuilder::new(app) .item(&space_submenu) .separator() @@ -137,28 +137,27 @@ pub async fn update_tray_spaces( state: &AppState, ) -> tauri::Result<()> { let spaces = state.space_service.list().await.unwrap_or_default(); - let active_space = state.space_service.get_active().await.ok().flatten(); + let default_space = state.space_service.get_default().await.ok().flatten(); - // Get tray handle if let Some(tray) = app.tray_by_id("mcpmux-tray") { - // Rebuild space submenu - let mut space_menu = SubmenuBuilder::new(app, "Active Space"); + let mut space_menu = SubmenuBuilder::new(app, "Switch Space"); for space in spaces { let icon = space.icon.clone().unwrap_or_else(|| "🌐".to_string()); - let is_active = active_space + // Tag the system default Space so the user can tell which one + // catches sessions whose reported root has no binding. + let is_default = default_space .as_ref() - .map(|a| a.id == space.id) + .map(|d| d.id == space.id) .unwrap_or(false); - let check = if is_active { "✓ " } else { " " }; - let label = format!("{}{} {}", check, icon, space.name); + let suffix = if is_default { " · default" } else { "" }; + let label = format!("{} {}{}", icon, space.name, suffix); let id = format!("space_{}", space.id); space_menu = space_menu.text(id, label); } let space_submenu = space_menu.build()?; - // Rebuild simplified menu let menu = MenuBuilder::new(app) .item(&space_submenu) .separator() diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 3408a69..aab0aaa 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -9,9 +9,7 @@ import { Settings, Sun, Moon, - Loader2, FolderOpen, - FileText, Download, X, } from 'lucide-react'; @@ -23,25 +21,26 @@ import { Card, CardHeader, CardTitle, - CardDescription, CardContent, - Button, } from '@mcpmux/ui'; import { ThemeProvider } from '@/components/ThemeProvider'; import { OAuthConsentModal } from '@/components/OAuthConsentModal'; import { ServerInstallModal } from '@/components/ServerInstallModal'; import { SpaceSwitcher } from '@/components/SpaceSwitcher'; -import { ConnectIDEs } from '@/components/ConnectIDEs'; +import { ConnectionCard } from '@/components/ConnectionCard'; import { useDataSync } from '@/hooks/useDataSync'; import { useAnalytics } from '@/hooks/useAnalytics'; import { initAnalytics, capture, optIn, optOut } from '@/lib/analytics'; -import { useAppStore, useActiveSpace, useViewSpace, useTheme, useAnalyticsEnabled, useActiveNav, useNavigateTo } from '@/stores'; +import { useAppStore, useViewSpace, useTheme, useAnalyticsEnabled, useActiveNav, useNavigateTo } from '@/stores'; import { RegistryPage } from '@/features/registry'; import { FeatureSetsPage } from '@/features/featuresets'; import { ClientsPage } from '@/features/clients'; import { ServersPage } from '@/features/servers'; import { SpacesPage } from '@/features/spaces'; +import { WorkspacesPage } from '@/features/workspaces'; import { SettingsPage } from '@/features/settings'; +import { AutoStartConflictResolver } from '@/features/gateway/AutoStartConflictResolver'; +import { WorkspaceBindingSheet } from '@/features/workspaces'; import { MetaToolApprovalDialog } from '@/features/metaTools'; import { useGatewayEvents, useServerStatusEvents } from '@/hooks/useDomainEvents'; @@ -111,7 +110,6 @@ function AppContent() { // Get state from store const theme = useTheme(); const setTheme = useAppStore((state) => state.setTheme); - const activeSpace = useActiveSpace(); const viewSpace = useViewSpace(); const analyticsEnabled = useAnalyticsEnabled(); @@ -182,17 +180,21 @@ function AppContent() { setTheme(theme === 'dark' ? 'light' : 'dark'); }; + const gatewayRunning = gatewayUrl !== null; + const gatewayPort = (() => { + if (!gatewayUrl) return null; + try { + return new URL(gatewayUrl).port || null; + } catch { + return null; + } + })(); + const sidebar = ( } - footer={ -
    -
    McpMux{appVersion ? ` v${appVersion}` : ''}
    -
    Gateway: {gatewayUrl ?? 'Not running'}
    -
    - } > + } + label="Workspaces" + active={activeNav === 'workspaces'} + onClick={() => navigateTo('workspaces')} + data-testid="nav-workspaces" + /> } label="Clients" @@ -260,15 +269,29 @@ function AppContent() { const statusBar = (
    - - - Gateway Active - - Active Space: {activeSpace?.name || 'None'} -
    -
    - 5 Servers • 97 Tools + + Space: {viewSpace?.name || 'None'}
    + {appVersion && ( + + v{appVersion} + + )}
    ); @@ -339,6 +362,7 @@ function AppContent() { {activeNav === 'servers' && } {activeNav === 'spaces' && } {activeNav === 'featuresets' && } + {activeNav === 'workspaces' && } {activeNav === 'clients' && } {activeNav === 'settings' && }
    @@ -350,8 +374,13 @@ function App() { return ( + {/* Resolves deferred auto-start port conflicts — runs once on mount */} + {/* OAuth consent modal - shown when MCP clients request authorization */} + {/* Workspace binding sheet - slides in when a session reports a root + that has no binding yet and resolved via the Space default */} + {/* Server install modal - shown when install deep link is received */} {/* Meta-tool approval dialog — gates every mcpmux_* write tool */} @@ -368,10 +397,6 @@ function DashboardView() { clients: 0, featureSets: 0, }); - const [gatewayStatus, setGatewayStatus] = useState<{ - running: boolean; - url: string | null; - }>({ running: false, url: null }); const viewSpace = useViewSpace(); // Load stats on mount and when gateway changes @@ -385,7 +410,6 @@ function DashboardView() { import('@/lib/api/gateway').then((m) => m.getGatewayStatus(viewSpace?.id)), import('@/lib/api/registry').then((m) => m.listInstalledServers(viewSpace?.id)), ]); - console.log('[Dashboard] Gateway status received:', gateway); setStats({ installedServers: installedServers.length, connectedServers: gateway.connected_backends, @@ -393,7 +417,6 @@ function DashboardView() { clients: clients.length, featureSets: featureSets.length, }); - setGatewayStatus({ running: gateway.running, url: gateway.url }); } catch (e) { console.error('Failed to load dashboard stats:', e); } @@ -404,15 +427,13 @@ function DashboardView() { loadStats(); }, [viewSpace?.id]); - // Subscribe to gateway events for reactive updates (no polling!) + // Reload stats when gateway starts/stops so `Servers: X/Y` stays honest. + // ConnectionCard owns the actual running/URL UI. useGatewayEvents((payload) => { if (payload.action === 'started') { - setGatewayStatus({ running: true, url: payload.url || null }); - // Reload stats to get updated counts loadStats(); } else if (payload.action === 'stopped') { - setGatewayStatus({ running: false, url: null }); - setStats({ installedServers: 0, connectedServers: 0, tools: 0, clients: 0, featureSets: 0 }); + setStats((prev) => ({ ...prev, connectedServers: 0 })); } }); @@ -423,24 +444,6 @@ function DashboardView() { } }); - const handleToggleGateway = async () => { - try { - if (gatewayStatus.running) { - const { stopGateway } = await import('@/lib/api/gateway'); - await stopGateway(); - setGatewayStatus({ running: false, url: null }); - } else { - const { startGateway } = await import('@/lib/api/gateway'); - const url = await startGateway(); - setGatewayStatus({ running: true, url }); - // After starting gateway, reload stats to get updated connected count - setTimeout(loadStats, 500); - } - } catch (e) { - console.error('Gateway toggle failed:', e); - } - }; - return (
    @@ -450,37 +453,10 @@ function DashboardView() {

    - {/* Gateway Status Banner */} - - -
    - -
    - - Gateway: {gatewayStatus.running ? 'Running' : 'Stopped'} - - {gatewayStatus.url && ( - - {gatewayStatus.url} - - )} -
    -
    - -
    -
    + {/* Canonical connection surface — owns URL, Start/Stop, IDE grid, + pending-approval nudge. Replaces the old status banner + the + separate ConnectIDEs card that duplicated the URL. */} + {/* Stats Grid */}
    @@ -527,23 +503,17 @@ function DashboardView() { - Active Space + Workspace
    {viewSpace?.icon} {viewSpace?.name || 'None'}
    -
    Current context
    +
    Currently viewing
    - - {/* Connect IDEs — one-click install */} -
    ); } diff --git a/apps/desktop/src/components/ConfigEditorModal.tsx b/apps/desktop/src/components/ConfigEditorModal.tsx index e508b0a..4c8ffca 100644 --- a/apps/desktop/src/components/ConfigEditorModal.tsx +++ b/apps/desktop/src/components/ConfigEditorModal.tsx @@ -6,6 +6,7 @@ import Editor, { type Monaco } from '@monaco-editor/react'; import type { editor } from 'monaco-editor'; import { useToast, ToastContainer } from '@mcpmux/ui'; import USER_SPACE_CONFIG_SCHEMA from '../../../../schemas/user-space.schema.json'; +import { RequestServerCTA } from './Contribute'; interface ConfigEditorModalProps { spaceId: string; @@ -221,6 +222,12 @@ export function ConfigEditorModal({ spaceId, spaceName, onClose, onSaved }: Conf
    + {/* Contribute / Request CTA — surfaces the registry templates so users + don't have to hand-roll a definition if one already exists upstream. */} +
    + +
    + {/* Editor Area */}
    {(isLoading || !editorReady) ? ( diff --git a/apps/desktop/src/components/ConnectIDEs.tsx b/apps/desktop/src/components/ConnectIDEs.tsx index 498ac3e..b951f0e 100644 --- a/apps/desktop/src/components/ConnectIDEs.tsx +++ b/apps/desktop/src/components/ConnectIDEs.tsx @@ -27,12 +27,17 @@ interface GridEntry { nextStep: string; } -interface ConnectIDEsProps { +interface ConnectIDEsGridProps { gatewayUrl: string; gatewayRunning: boolean; } -export function ConnectIDEs({ gatewayUrl, gatewayRunning }: ConnectIDEsProps) { +/** + * Chromeless grid of IDE connect shortcuts. Used directly by the dashboard + * ConnectionCard (which owns the surrounding chrome) and wrapped by + * `ConnectIDEs` below for the Clients page standalone usage. + */ +export function ConnectIDEsGrid({ gatewayUrl, gatewayRunning }: ConnectIDEsGridProps) { const [activeId, setActiveId] = useState(null); const [copiedId, setCopiedId] = useState(null); const popoverRef = useRef(null); @@ -154,6 +159,115 @@ export function ConnectIDEs({ gatewayUrl, gatewayRunning }: ConnectIDEsProps) { } }; + return ( +
    + {entries.map((entry) => { + const isActive = activeId === entry.id; + const isCopied = copiedId === entry.id; + + return ( +
    + + + {entry.label} + + + {/* Popover — opens UPWARD. The grid usually sits at the + bottom of a Card (Dashboard + Clients empty state), so + opening downward put the action button below the scroll + viewport on first paint, forcing users to scroll to find + it. Anchor to the bottom of the trigger button instead. */} + {isActive && ( +
    +

    {entry.name}

    + + {/* Per-IDE instructions. Not a switch on action type — + each IDE's post-install step is meaningfully different + (VS Code auto-starts, Cursor needs explicit toggle, + JetBrains needs a full restart, etc.). */} +

    + {entry.nextStep} +

    + + {entry.action === 'deep_link' ? ( + + ) : isCopied ? ( +
    + + Copied — paste & follow above +
    + ) : ( + + )} + + {/* Arrow — points down from the popover to the trigger + icon below. */} +
    +
    + )} +
    + ); + })} +
    + ); +} + +interface ConnectIDEsProps { + gatewayUrl: string; + gatewayRunning: boolean; +} + +/** + * Standalone Card-wrapped IDE grid. Used by the Clients page where it lives + * on its own. The dashboard uses the chromeless `ConnectIDEsGrid` inside the + * canonical ConnectionCard instead. + */ +export function ConnectIDEs({ gatewayUrl, gatewayRunning }: ConnectIDEsProps) { return ( @@ -175,95 +289,7 @@ export function ConnectIDEs({ gatewayUrl, gatewayRunning }: ConnectIDEsProps) {
    -
    - {entries.map((entry) => { - const isActive = activeId === entry.id; - const isCopied = copiedId === entry.id; - - return ( -
    - - - {entry.label} - - - {/* Popover — opens UPWARD. The grid usually sits at the - bottom of a Card (Dashboard + Clients empty state), so - opening downward put the action button below the scroll - viewport on first paint, forcing users to scroll to find - it. Anchor to the bottom of the trigger button instead. */} - {isActive && ( -
    -

    {entry.name}

    - - {/* Per-IDE instructions. Not a switch on action type — - each IDE's post-install step is meaningfully different - (VS Code auto-starts, Cursor needs explicit toggle, - JetBrains needs a full restart, etc.). */} -

    - {entry.nextStep} -

    - - {entry.action === 'deep_link' ? ( - - ) : isCopied ? ( -
    - - Copied — paste & follow above -
    - ) : ( - - )} - - {/* Arrow — points down from the popover to the trigger - icon below. */} -
    -
    - )} -
    - ); - })} -
    + ); diff --git a/apps/desktop/src/components/ConnectionCard.tsx b/apps/desktop/src/components/ConnectionCard.tsx new file mode 100644 index 0000000..9762526 --- /dev/null +++ b/apps/desktop/src/components/ConnectionCard.tsx @@ -0,0 +1,302 @@ +import { useCallback, useEffect, useState } from 'react'; +import { + ArrowRight, + Bell, + Check, + Copy, + Loader2, + Lock, + Power, + Sliders, +} from 'lucide-react'; +import { Card, Button } from '@mcpmux/ui'; +import { useViewSpace, useNavigateTo } from '@/stores'; +import { useGatewayControl } from '@/features/gateway/useGatewayControl'; +import { useGatewayEvents } from '@/hooks/useDomainEvents'; +import { + getGatewayStatus, + listOAuthClients, + stopGateway, +} from '@/lib/api/gateway'; +import { ConnectIDEsGrid } from './ConnectIDEs'; + +const FALLBACK_URL = 'http://localhost:45818'; + +function extractPort(url: string | null): string { + try { + const u = new URL(url ?? FALLBACK_URL); + return u.port || '45818'; + } catch { + return '45818'; + } +} + +/** + * Canonical "how do I connect to McpMux" surface. Owns the gateway URL + port + * display, Start/Stop, the IDE connect grid, and the pending-approval nudge. + * Everything else in the app (sidebar footer, status bar) should reduce to a + * compact status pill rather than repeating the URL. + */ +export function ConnectionCard() { + const viewSpace = useViewSpace(); + const navigateTo = useNavigateTo(); + const gatewayControl = useGatewayControl(); + + const [status, setStatus] = useState<{ running: boolean; url: string | null }>({ + running: false, + url: null, + }); + const [pendingApprovals, setPendingApprovals] = useState(0); + const [copied, setCopied] = useState(false); + const [busy, setBusy] = useState(false); + + const displayUrl = status.url ?? FALLBACK_URL; + const mcpUrl = `${displayUrl}/mcp`; + const port = extractPort(status.url); + + const reloadStatus = useCallback(async () => { + try { + const s = await getGatewayStatus(viewSpace?.id); + setStatus({ running: s.running, url: s.url }); + } catch { + /* keep previous status */ + } + }, [viewSpace?.id]); + + const reloadApprovals = useCallback(async () => { + try { + const clients = await listOAuthClients(); + setPendingApprovals(clients.filter((c) => !c.approved).length); + } catch { + setPendingApprovals(0); + } + }, []); + + useEffect(() => { + reloadStatus(); + reloadApprovals(); + }, [reloadStatus, reloadApprovals]); + + // Live gateway state — no polling, driven by the event bus. + useGatewayEvents((payload) => { + if (payload.action === 'started') { + setStatus({ running: true, url: payload.url || null }); + reloadApprovals(); + } else if (payload.action === 'stopped') { + setStatus({ running: false, url: null }); + } + }); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(mcpUrl); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch (e) { + console.error('[ConnectionCard] copy failed', e); + } + }; + + const handleToggle = async () => { + if (busy) return; + setBusy(true); + try { + if (status.running) { + await stopGateway(); + setStatus({ running: false, url: null }); + } else { + const outcome = await gatewayControl.start(); + if (outcome.status !== 'cancelled') { + setStatus({ running: true, url: outcome.url }); + } + } + } catch (e) { + console.error('[ConnectionCard] toggle failed', e); + } finally { + setBusy(false); + } + }; + + return ( + <> + {gatewayControl.ConfirmDialogElement} + + {/* Hairline gradient — present on both states, brighter when running. + Gives the hero card a subtle sense of depth without a heavy header + background. */} +
    + + {/* Top bar — status + primary action */} +
    +
    + +
    +
    + + {status.running ? 'Gateway running' : 'Gateway stopped'} + + {status.running && ( + + + Local only + + )} +
    +

    + {status.running + ? 'Accepting IDE connections on this device.' + : 'Start the gateway to let IDEs connect through McpMux.'} +

    +
    +
    + +
    + +
    + {/* Endpoint — the canonical address users paste into clients. */} +
    +
    + + +
    + + +
    + + {/* Pending approvals — surfaces only when a client is waiting. The + canonical "approve this connection" UI still lives in the Clients + page; this is a nudge so users don't miss pending work. */} + {pendingApprovals > 0 && ( + + )} + + {/* Connect a client — the grid reuses the chromeless ConnectIDEsGrid. */} +
    +
    +

    + Connect a client +

    +

    + VS Code & Cursor are one-click. The rest copy a config you paste into your IDE's + MCP settings. Either path ends with an approval prompt here. +

    +
    + +
    +
    + + + ); +} + +/** + * Two-layer dot: solid circle + a halo that pulses while running. The pulse + * gives ambient life to the "running" state without being a focal point. + */ +function StatusDot({ running }: { running: boolean }) { + return ( +
    -

    {getErrorMessage(modalState.error)}

    +

    + {getErrorMessage(modalState.error)} +

    @@ -297,238 +269,45 @@ export function OAuthConsentModal() { ); } - // Approved state - show success with next-step guidance - if (modalState.type === 'approved') { - return ( -
    - - -
    -
    - -
    -
    - Client Approved - - {modalState.clientName} is now connected - -
    -
    -
    - -
    -

    Next step: Grant permissions

    -

    - Assign FeatureSets to control which tools, prompts, and resources this client can access. -

    -
    -
    - - -
    -
    -
    -
    - ); - } - - // Consent state - show approval modal const { details } = modalState; - const scopes = details.scope?.split(' ').filter(Boolean) || ['mcp']; const logoUrl = getClientLogo(details.clientName); return (
    - - -
    - McpMux -
    - Authorization Request - {details.clientName} wants to connect -
    -
    -
    - - {/* Client Info */} -
    - {logoUrl && ( - {details.clientName} - )} -
    -
    {details.clientName}
    -
    - {details.clientId.length > 50 - ? `${details.clientId.substring(0, 50)}...` - : details.clientId} -
    -
    -
    - - {/* Scopes */} -
    -
    Requested permissions:
    -
    - {scopes.map((scope, i) => ( - - {scope} - - ))} -
    -
    - - {/* Alias Input */} -
    - - setClientAlias(e.target.value)} - placeholder="e.g., Work Cursor, Personal Claude" - className="focus:ring-primary-500/20 mt-1 w-full rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--surface))] px-3 py-2 text-[rgb(var(--foreground))] placeholder:text-[rgb(var(--muted))] focus:outline-none focus:ring-2" + + + {logoUrl ? ( + {details.clientName} -

    - Give this client a friendly name to identify it later -

    -
    - - {/* Space Mode Selection */} -
    - -
    - {/* Follow Active Option */} - - - {/* Lock to Space Option */} - + ) : ( +
    + {details.clientName.slice(0, 1).toUpperCase()}
    + )} - {/* Space Selector (only when locked) */} - {connectionMode === 'locked' && spaces.length > 0 && ( -
    - -
    - )} +
    +

    + Allow {details.clientName} to connect? +

    +

    + It will be able to call tools you enable for this folder. +

    - {/* Error Message */} {processError && ( -
    - +
    + {processError}
    )} - {/* Action Buttons */} -
    - +
    -
    - - {/* Dismiss Link */} -
    - + + Deny +
    diff --git a/apps/desktop/src/components/SpaceSwitcher.tsx b/apps/desktop/src/components/SpaceSwitcher.tsx index f125d8d..1a30b1b 100644 --- a/apps/desktop/src/components/SpaceSwitcher.tsx +++ b/apps/desktop/src/components/SpaceSwitcher.tsx @@ -1,24 +1,19 @@ import { useState, useRef, useEffect } from 'react'; -import { - ChevronDown, - Check, - Plus, - Loader2, -} from 'lucide-react'; +import { ChevronDown, Check, Plus, Loader2 } from 'lucide-react'; import { Button, useToast, ToastContainer } from '@mcpmux/ui'; -import { - useAppStore, - useActiveSpace, - useViewSpace, - useSpaces, - useIsLoading, -} from '@/stores'; -import { createSpace, setActiveSpace as setActiveSpaceAPI } from '@/lib/api/spaces'; +import { useAppStore, useViewSpace, useSpaces, useIsLoading } from '@/stores'; +import { createSpace } from '@/lib/api/spaces'; interface SpaceSwitcherProps { className?: string; } +/** + * Sidebar dropdown for switching which Space the desktop UI is currently + * viewing. Pure UI navigation — does not affect gateway routing. The + * "Default" badge marks the system fallback Space (the one used when a + * session has no matching WorkspaceBinding). + */ export function SpaceSwitcher({ className = '' }: SpaceSwitcherProps) { const [isOpen, setIsOpen] = useState(false); const [isCreating, setIsCreating] = useState(false); @@ -28,14 +23,11 @@ export function SpaceSwitcher({ className = '' }: SpaceSwitcherProps) { const { toasts, success, error: showError, dismiss } = useToast(); const spaces = useSpaces(); - const activeSpace = useActiveSpace(); const viewSpace = useViewSpace(); const isLoadingSpaces = useIsLoading('spaces'); - const setActiveSpaceInStore = useAppStore((state) => state.setActiveSpace); const setViewSpaceInStore = useAppStore((state) => state.setViewSpace); const addSpace = useAppStore((state) => state.addSpace); - // Close dropdown when clicking outside useEffect(() => { function handleClickOutside(event: MouseEvent) { if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { @@ -52,31 +44,17 @@ export function SpaceSwitcher({ className = '' }: SpaceSwitcherProps) { setIsOpen(false); }; - const handleSetActiveSpace = async (spaceId: string) => { - try { - await setActiveSpaceAPI(spaceId); - setActiveSpaceInStore(spaceId); - setIsOpen(false); - const activatedSpace = spaces.find(s => s.id === spaceId); - success('Space activated', `Switched to "${activatedSpace?.name || 'Space'}"`); - } catch (e) { - showError('Failed to switch space', e instanceof Error ? e.message : String(e)); - } - }; - const handleCreateSpace = async () => { if (!newName.trim()) return; setIsCreating(true); try { const space = await createSpace(newName.trim(), '🌐'); addSpace(space); - await setActiveSpaceAPI(space.id); - setActiveSpaceInStore(space.id); setViewSpaceInStore(space.id); setNewName(''); setShowCreateInput(false); setIsOpen(false); - success('Space created', `"${space.name}" has been created and activated`); + success('Space created', `"${space.name}" has been created`); } catch (e) { showError('Failed to create space', e instanceof Error ? e.message : String(e)); } finally { @@ -99,13 +77,14 @@ export function SpaceSwitcher({ className = '' }: SpaceSwitcherProps) { {viewSpace?.icon || '🌐'} )} - {isLoadingSpaces - ? 'Loading...' - : viewSpace?.name || (spaces.length > 0 ? 'Select Space' : 'No Spaces') - } + {isLoadingSpaces + ? 'Loading...' + : viewSpace?.name || (spaces.length > 0 ? 'Select Space' : 'No Spaces')} - + {/* Dropdown */} @@ -128,40 +107,28 @@ export function SpaceSwitcher({ className = '' }: SpaceSwitcherProps) { key={space.id} onClick={() => handleSelectSpace(space.id)} className={`w-full flex items-center justify-between px-3 py-2.5 rounded-lg text-left transition-all duration-150 - ${viewSpace?.id === space.id - ? 'bg-[rgb(var(--primary))/12] text-[rgb(var(--primary))]' - : 'hover:bg-[rgb(var(--surface-hover))]' + ${ + viewSpace?.id === space.id + ? 'bg-[rgb(var(--primary))/12] text-[rgb(var(--primary))]' + : 'hover:bg-[rgb(var(--surface-hover))]' }`} + data-testid={`space-switcher-item-${space.id}`} > - - {space.icon || '🌐'} -
    -
    {space.name}
    + + {space.icon || '🌐'} +
    +
    {space.name}
    {space.is_default && ( -
    Default
    +
    + Default +
    )}
    - - {activeSpace?.id === space.id && ( - Active - )} - {viewSpace?.id === space.id && ( - - )} - {activeSpace?.id !== space.id && ( - - )} - + {viewSpace?.id === space.id && } )) )} diff --git a/apps/desktop/src/features/clients/ClientsPage.tsx b/apps/desktop/src/features/clients/ClientsPage.tsx index c59f3c2..61d89d6 100644 --- a/apps/desktop/src/features/clients/ClientsPage.tsx +++ b/apps/desktop/src/features/clients/ClientsPage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { listen } from '@tauri-apps/api/event'; import cursorIcon from '@/assets/client-icons/cursor.svg'; import vscodeIcon from '@/assets/client-icons/vscode.png'; @@ -10,26 +10,23 @@ import { resolveKnownClientKey } from '@/lib/clientIcons'; import { Laptop, Loader2, - Lock, - Unlock, - HelpCircle, RefreshCw, - Settings, - Trash2, - X, - Check, - ChevronDown, - ChevronRight, - Shield, - Layers, Search, AlertCircle, - Zap, PlugZap, + X, + Trash2, + FolderOpen, + Check, } from 'lucide-react'; import { ConnectIDEs } from '@/components/ConnectIDEs'; -import type { GatewayStatus } from '@/lib/api/gateway'; -import { getGatewayStatus } from '@/lib/api/gateway'; +import type { GatewayStatus, OAuthClient } from '@/lib/api/gateway'; +import { + getGatewayStatus, + listOAuthClients, + updateOAuthClient, + deleteOAuthClient, +} from '@/lib/api/gateway'; import { Card, CardContent, @@ -38,54 +35,13 @@ import { ToastContainer, useConfirm, } from '@mcpmux/ui'; -import type { OAuthClient, UpdateClientRequest } from '@/lib/api/gateway'; -import { listOAuthClients, updateOAuthClient, deleteOAuthClient } from '@/lib/api/gateway'; -import type { Space } from '@/lib/api/spaces'; -import { listSpaces } from '@/lib/api/spaces'; -import { useViewSpace, usePendingClientId, useSetPendingClientId } from '@/stores'; -import type { FeatureSet } from '@/lib/api/featureSets'; -import { listFeatureSetsBySpace } from '@/lib/api/featureSets'; -import { - getOAuthClientGrants, - grantOAuthClientFeatureSet, - revokeOAuthClientFeatureSet, - getOAuthClientResolvedFeatures -} from '@/lib/api/oauthClients'; import { - addFeatureToSet, - removeFeatureFromSet, - getFeatureSetMembers, - type FeatureSetMember -} from '@/lib/api/featureMembers'; -import { listServerFeatures } from '@/lib/api/serverFeatures'; -import { invoke } from '@tauri-apps/api/core'; - -// Connection mode options -const CONNECTION_MODES = [ - { - value: 'follow_active', - label: 'Follow Active Space', - icon: Unlock, - color: 'text-green-500', - description: 'Automatically use your currently active space', - }, - { - value: 'locked', - label: 'Locked to Space', - icon: Lock, - color: 'text-blue-500', - description: 'Always use a specific space', - }, - { - value: 'ask_on_change', - label: 'Ask on Change', - icon: HelpCircle, - color: 'text-orange-500', - description: 'Prompt when switching spaces', - }, -]; + useNavigateTo, + usePendingClientId, + useSetPendingClientId, +} from '@/stores'; -// Bundled icons for well-known AI clients (resolved via icon key) +// Bundled icons for well-known AI clients. const CLIENT_ICON_ASSETS: Record = { cursor: cursorIcon, vscode: vscodeIcon, @@ -95,8 +51,13 @@ const CLIENT_ICON_ASSETS: Record = { 'android-studio': androidStudioIcon, }; -// Client icon component — uses bundled icon for known clients, falls back to logo_uri, then emoji -function ClientIcon({ logo_uri, client_name }: { logo_uri?: string | null; client_name: string }) { +function ClientIcon({ + logo_uri, + client_name, +}: { + logo_uri?: string | null; + client_name: string; +}) { const knownKey = resolveKnownClientKey(client_name); const iconUrl = (knownKey && CLIENT_ICON_ASSETS[knownKey]) || logo_uri; if (iconUrl) { @@ -115,30 +76,35 @@ function ClientIcon({ logo_uri, client_name }: { logo_uri?: string | null; clien return 🤖; } +function formatLastSeen(iso: string | null): string { + if (!iso) return 'never'; + const then = new Date(iso); + const now = new Date(); + const secs = Math.floor((now.getTime() - then.getTime()) / 1000); + if (secs < 10) return 'just now'; + if (secs < 60) return `${secs}s ago`; + if (secs < 3600) return `${Math.floor(secs / 60)}m ago`; + if (secs < 86400) return `${Math.floor(secs / 3600)}h ago`; + return `${Math.floor(secs / 86400)}d ago`; +} + +/** + * Connections page — list approved AI clients and revoke their access. + * + * In the v2 world, routing decisions (which Space, which FeatureSet) live + * in Workspaces (per-root bindings), not per-client. This page is pure + * observability + lifecycle: which clients have been approved, when each + * was last seen, and "remove this key" when trust is withdrawn. + */ export default function ClientsPage() { - const [oauthClients, setOAuthClients] = useState([]); - const [spaces, setSpaces] = useState([]); + const [clients, setClients] = useState([]); const [isLoading, setIsLoading] = useState(true); - const [isRefreshingOAuth, setIsRefreshingOAuth] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); const [error, setError] = useState(null); const [searchQuery, setSearchQuery] = useState(''); - - // Panel state - const [selectedClient, setSelectedClient] = useState(null); - - const { toasts, success, error: showError, info, dismiss } = useToast(); - const { confirm, ConfirmDialogElement } = useConfirm(); - const pendingClientId = usePendingClientId(); - const setPendingClientId = useSetPendingClientId(); - - // Edit state + const [selected, setSelected] = useState(null); const [editAlias, setEditAlias] = useState(''); - const [editMode, setEditMode] = useState('follow_active'); - const [editLockedSpaceId, setEditLockedSpaceId] = useState(''); const [isSaving, setIsSaving] = useState(false); - - // Gateway status for the empty-state ConnectIDEs embed — fetched once on - // mount + refreshed whenever the gateway starts/stops elsewhere in the app. const [gatewayStatus, setGatewayStatus] = useState({ running: false, url: null, @@ -146,80 +112,18 @@ export default function ClientsPage() { connected_backends: 0, }); - - // Feature set grant state - const viewSpace = useViewSpace(); - const [activeSpace, setActiveSpace] = useState(null); - const [availableFeatureSets, setAvailableFeatureSets] = useState([]); - const [grantedFeatureSetIds, setGrantedFeatureSetIds] = useState([]); - const [isLoadingGrants, setIsLoadingGrants] = useState(false); - - // Resolved features state - const [resolvedFeatures, setResolvedFeatures] = useState<{ - tools: Array<{ name: string; description?: string; server_id: string }>; - prompts: Array<{ name: string; description?: string; server_id: string }>; - resources: Array<{ name: string; description?: string; server_id: string }>; - } | null>(null); - const [isLoadingResolvedFeatures, setIsLoadingResolvedFeatures] = useState(false); - - // Individual features management - const [availableFeatures, setAvailableFeatures] = useState>([]); - const [clientCustomFeatureSet, setClientCustomFeatureSet] = useState(null); - const [individualFeatureMembers, setIndividualFeatureMembers] = useState([]); - const [isLoadingFeatures, setIsLoadingFeatures] = useState(false); - - // Collapsible sections - const [expandedSections, setExpandedSections] = useState({ - quickSettings: true, - permissions: true, - effectiveFeatures: false, - advancedPermissions: false, - clientInfo: false, - }); - const [expandedServers, setExpandedServers] = useState>(new Set()); - const [expandedFeatureTypes, setExpandedFeatureTypes] = useState({ - tools: false, - prompts: false, - resources: false, - }); - - const toggleSection = (section: keyof typeof expandedSections) => { - setExpandedSections(prev => { - const isCurrentlyExpanded = prev[section]; - - // If clicking on an already expanded section, just toggle it - if (isCurrentlyExpanded) { - return { ...prev, [section]: false }; - } - - // Otherwise, collapse all and expand the clicked one - return { - quickSettings: false, - permissions: false, - effectiveFeatures: false, - advancedPermissions: false, - clientInfo: false, - [section]: true, - }; - }); - }; + const { toasts, success, error: showError, info, dismiss } = useToast(); + const { confirm, ConfirmDialogElement } = useConfirm(); + const pendingClientId = usePendingClientId(); + const setPendingClientId = useSetPendingClientId(); + const navigateTo = useNavigateTo(); - const loadData = async () => { + const loadClients = async () => { setIsLoading(true); setError(null); try { - const [oauthData, spacesData] = await Promise.all([ - listOAuthClients().catch(() => [] as OAuthClient[]), - listSpaces().catch(() => [] as Space[]), - ]); - setOAuthClients(oauthData); - setSpaces(spacesData); + const data = await listOAuthClients(); + setClients(data); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { @@ -227,382 +131,166 @@ export default function ClientsPage() { } }; - const loadGrantsForClient = async (clientId: string) => { - if (!activeSpace) return; - - setIsLoadingGrants(true); + const refreshClients = async () => { + setIsRefreshing(true); try { - const [featureSets, grants] = await Promise.all([ - listFeatureSetsBySpace(activeSpace.id), - getOAuthClientGrants(clientId, activeSpace.id), - ]); - setAvailableFeatureSets(featureSets); - setGrantedFeatureSetIds(grants); + setClients(await listOAuthClients()); } catch (e) { - console.warn('Failed to load grants:', e); + console.warn('Failed to refresh clients:', e); } finally { - setIsLoadingGrants(false); - } - }; - - const loadResolvedFeatures = async (clientId: string, client?: OAuthClient) => { - const targetClient = client ?? selectedClient; - if (!activeSpace || !targetClient) return; - - setIsLoadingResolvedFeatures(true); - try { - const resolveSpaceId = targetClient.connection_mode === 'locked' && targetClient.locked_space_id - ? targetClient.locked_space_id - : activeSpace.id; - - const resolved = await getOAuthClientResolvedFeatures(clientId, resolveSpaceId); - setResolvedFeatures({ - tools: resolved.tools, - prompts: resolved.prompts, - resources: resolved.resources, - }); - } catch (e) { - console.warn('Failed to load resolved features:', e); - setResolvedFeatures(null); - } finally { - setIsLoadingResolvedFeatures(false); - } - }; - - const refreshOAuthClients = async () => { - setIsRefreshingOAuth(true); - try { - const oauthData = await listOAuthClients(); - setOAuthClients(oauthData); - } catch (e) { - console.warn('Failed to refresh OAuth clients:', e); - } finally { - setIsRefreshingOAuth(false); + setIsRefreshing(false); } }; useEffect(() => { - loadData(); - // Prime the gateway status so the empty-state ConnectIDEs can show the - // real mcpmux URL. Silent failure — the empty state tolerates stale data. - getGatewayStatus().then(setGatewayStatus).catch(() => {}); + void loadClients(); + getGatewayStatus() + .then(setGatewayStatus) + .catch(() => {}); }, []); - // Auto-open a client panel when navigated from "Manage Permissions" useEffect(() => { if (!pendingClientId || isLoading) return; - const client = oauthClients.find(c => c.client_id === pendingClientId); + const client = clients.find((c) => c.client_id === pendingClientId); if (client) { openPanel(client); setPendingClientId(null); } - }, [pendingClientId, isLoading, oauthClients]); - - useEffect(() => { - setActiveSpace(viewSpace); - }, [viewSpace?.id]); - - useEffect(() => { - if (!selectedClient || !activeSpace) return; - loadGrantsForClient(selectedClient.client_id); - loadAvailableFeatures(); - loadClientCustomFeatureSet(selectedClient); - loadResolvedFeatures(selectedClient.client_id); - }, [activeSpace?.id, selectedClient?.client_id]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pendingClientId, isLoading, clients]); useEffect(() => { - const unlistenDomain = listen<{ action: string; client_id: string; client_name?: string }>('client-changed', (event) => { - console.log('Client changed (domain):', event.payload); - refreshOAuthClients(); - - // Show toast for reconnections (silent approval) + const unlistenDomain = listen<{ + action: string; + client_id: string; + client_name?: string; + }>('client-changed', (event) => { + refreshClients(); if (event.payload.action === 'reconnected') { const name = event.payload.client_name || event.payload.client_id; - info('Client connected', `${name} connected`); + info('Client reconnected', name); } }); - - const unlistenOAuth = listen('oauth-client-changed', (event) => { - console.log('OAuth client changed:', event.payload); - refreshOAuthClients(); + const unlistenOAuth = listen('oauth-client-changed', () => { + refreshClients(); }); - return () => { - unlistenDomain.then(fn => fn()); - unlistenOAuth.then(fn => fn()); + unlistenDomain.then((fn) => fn()); + unlistenOAuth.then((fn) => fn()); }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const openPanel = async (client: OAuthClient) => { - setSelectedClient(client); + const openPanel = (client: OAuthClient) => { + setSelected(client); setEditAlias(client.client_alias || ''); - setEditMode(client.connection_mode); - setEditLockedSpaceId(client.locked_space_id || ''); - - // Reset collapsible states - setExpandedSections({ - quickSettings: true, - permissions: true, - effectiveFeatures: false, - advancedPermissions: false, - clientInfo: false, - }); - setExpandedServers(new Set()); - setExpandedFeatureTypes({ tools: false, prompts: false, resources: false }); - - await Promise.all([ - loadGrantsForClient(client.client_id), - loadAvailableFeatures(), - ]); - - await loadClientCustomFeatureSet(client); - loadResolvedFeatures(client.client_id, client); - }; - - const toggleFeatureSetGrant = async (featureSetId: string) => { - if (!selectedClient || !activeSpace) return; - - const featureSet = availableFeatureSets.find(fs => fs.id === featureSetId); - const fsName = featureSet?.name || 'Feature set'; - - try { - if (grantedFeatureSetIds.includes(featureSetId)) { - await revokeOAuthClientFeatureSet(selectedClient.client_id, activeSpace.id, featureSetId); - setGrantedFeatureSetIds(prev => prev.filter(id => id !== featureSetId)); - success('Permission revoked', `"${fsName}" removed from client`); - } else { - await grantOAuthClientFeatureSet(selectedClient.client_id, activeSpace.id, featureSetId); - setGrantedFeatureSetIds(prev => [...prev, featureSetId]); - success('Permission granted', `"${fsName}" added to client`); - } - loadResolvedFeatures(selectedClient.client_id); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - setError(msg); - showError('Failed to update permission', msg); - } }; - const handleSaveConfig = async () => { - if (!selectedClient) return; - + const handleSaveAlias = async () => { + if (!selected) return; setIsSaving(true); try { - const settings: UpdateClientRequest = { + const updated = await updateOAuthClient(selected.client_id, { client_alias: editAlias || undefined, - connection_mode: editMode as 'follow_active' | 'locked' | 'ask_on_change', - locked_space_id: undefined, - }; - - if (editMode === 'locked' && editLockedSpaceId) { - settings.locked_space_id = editLockedSpaceId; - } - - const updated = await updateOAuthClient(selectedClient.client_id, settings); - - setOAuthClients(prev => prev.map(c => - c.client_id === updated.client_id ? updated : c - )); - - setSelectedClient(updated); - success('Client settings saved', `"${updated.client_alias || updated.client_name}" has been updated`); + }); + setClients((prev) => + prev.map((c) => (c.client_id === updated.client_id ? updated : c)) + ); + setSelected(updated); + success('Saved', `"${updated.client_alias || updated.client_name}" updated`); } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - setError(msg); - showError('Failed to save settings', msg); + showError('Failed to save', e instanceof Error ? e.message : String(e)); } finally { setIsSaving(false); } }; - const handleDelete = async (clientId: string) => { - const deletedClient = oauthClients.find(c => c.client_id === clientId); - const name = deletedClient?.client_alias || deletedClient?.client_name || 'this client'; - if (!await confirm({ - title: 'Remove client', - message: `Remove "${name}"? All tokens will be revoked.`, - confirmLabel: 'Remove', - variant: 'danger', - })) return; - const clientName = deletedClient?.client_alias || deletedClient?.client_name || 'Client'; - - try { - await deleteOAuthClient(clientId); - setOAuthClients(prev => prev.filter(c => c.client_id !== clientId)); - setSelectedClient(null); - success('Client removed', `"${clientName}" and its tokens have been revoked`); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - setError(msg); - showError('Failed to remove client', msg); - } - }; - - const getSpaceName = (spaceId: string | null) => { - if (!spaceId) return null; - const space = spaces.find(s => s.id === spaceId); - return space ? `${space.icon || '📁'} ${space.name}` : null; - }; - - const getModeInfo = (mode: string) => { - return CONNECTION_MODES.find(m => m.value === mode) || CONNECTION_MODES[0]; - }; - - const loadAvailableFeatures = async () => { - if (!activeSpace) return; - - setIsLoadingFeatures(true); - try { - const features = await listServerFeatures(activeSpace.id); - setAvailableFeatures(features.map(f => ({ - id: f.id, - feature_name: f.feature_name, - feature_type: f.feature_type, - description: f.description ?? undefined, - server_id: f.server_id, - }))); - } catch (e) { - console.error('Failed to load available features:', e); - setAvailableFeatures([]); - } finally { - setIsLoadingFeatures(false); - } - }; - - const loadClientCustomFeatureSet = async (client: OAuthClient) => { - if (!activeSpace) { - console.log('Cannot load custom feature set: missing space'); + const handleRevoke = async (client: OAuthClient) => { + const name = client.client_alias || client.client_name; + if ( + !(await confirm({ + title: 'Revoke connection', + message: `Remove "${name}"? All tokens for this client will be revoked. The client will need to re-approve to connect again.`, + confirmLabel: 'Revoke', + variant: 'danger', + })) + ) { return; } - - const clientName = client.client_alias || client.client_name; - console.log('Finding or creating custom feature set for:', clientName); - try { - const featureSet = await invoke('find_or_create_client_custom_feature_set', { - clientName, - spaceId: activeSpace.id, - }); - - console.log('Got custom feature set:', featureSet.id); - setClientCustomFeatureSet(featureSet); - - const members = await getFeatureSetMembers(featureSet.id); - console.log('Loaded feature members:', members.length); - setIndividualFeatureMembers(members); - - if (!grantedFeatureSetIds.includes(featureSet.id)) { - console.log('Granting custom feature set to client'); - await grantOAuthClientFeatureSet(client.client_id, activeSpace.id, featureSet.id); - setGrantedFeatureSetIds(prev => [...prev, featureSet.id]); - } + await deleteOAuthClient(client.client_id); + setClients((prev) => prev.filter((c) => c.client_id !== client.client_id)); + setSelected(null); + success('Connection revoked', `"${name}" removed`); } catch (e) { - console.error('Failed to load/create custom feature set:', e); - setClientCustomFeatureSet(null); - setIndividualFeatureMembers([]); + showError('Failed to revoke', e instanceof Error ? e.message : String(e)); } }; - const toggleIndividualFeature = async (featureId: string) => { - if (!selectedClient || !activeSpace || !clientCustomFeatureSet) { - console.error('Missing client, space, or custom feature set'); - return; - } - - console.log('Toggling feature:', featureId); - - const isAdded = individualFeatureMembers.some(m => m.member_id === featureId); - console.log('Feature is currently added:', isAdded); - - const feature = availableFeatures.find(f => f.id === featureId); - const featureName = feature?.feature_name || 'Feature'; - - try { - if (isAdded) { - await removeFeatureFromSet(clientCustomFeatureSet.id, featureId); - setIndividualFeatureMembers(prev => prev.filter(m => m.member_id !== featureId)); - success('Feature removed', `"${featureName}" removed from client`); - } else { - await addFeatureToSet(clientCustomFeatureSet.id, featureId, 'include'); - setIndividualFeatureMembers(prev => [...prev, { - id: '', - feature_set_id: clientCustomFeatureSet.id, - member_type: 'feature', - member_id: featureId, - mode: 'include', - }]); - success('Feature added', `"${featureName}" added to client`); - } - - await loadResolvedFeatures(selectedClient.client_id); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - setError(msg); - showError('Failed to toggle feature', msg); - } - }; - - const getFeatureIcon = (type: string) => { - switch (type) { - case 'tool': return '🔧'; - case 'prompt': return '💬'; - case 'resource': return '📄'; - default: return '⚙️'; - } - }; - - const filteredClients = oauthClients.filter(client => { + const filtered = clients.filter((client) => { if (!searchQuery) return true; - const query = searchQuery.toLowerCase(); + const q = searchQuery.toLowerCase(); return ( - client.client_name.toLowerCase().includes(query) || - client.client_alias?.toLowerCase().includes(query) || - client.client_id.toLowerCase().includes(query) + client.client_name.toLowerCase().includes(q) || + client.client_alias?.toLowerCase().includes(q) || + client.client_id.toLowerCase().includes(q) ); }); - const totalFeatures = resolvedFeatures - ? resolvedFeatures.tools.length + resolvedFeatures.prompts.length + resolvedFeatures.resources.length - : 0; + // Snapshot `now` each time the clients list changes so the staleness + // indicators refresh when the underlying data refreshes — without making + // the component body impure. + const renderNow = useMemo(() => Date.now(), [clients]); return (
    - {/* Header */} -
    +
    -

    Connected Clients

    -

    - Manage OAuth clients and their permissions +

    + Connections +

    +

    + Approved AI clients. Routing (which Space, which FeatureSet) is + configured in{' '} + + {' '}per folder, not per client.

    -
    - {/* Search Bar */} -
    - - setSearchQuery(e.target.value)} - className="w-full pl-12 pr-4 py-3 text-base bg-[rgb(var(--surface))] border border-[rgb(var(--border))] rounded-xl focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-all" - /> -
    + {clients.length > 0 && ( +
    + + setSearchQuery(e.target.value)} + className="w-full pl-12 pr-4 py-3 text-base bg-[rgb(var(--surface))] border border-[rgb(var(--border))] rounded-xl focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-all" + /> +
    + )}
    -
    + - {/* Error */} {error && (
    @@ -612,130 +300,35 @@ export default function ClientsPage() {
    )} - {/* Clients Grid */}
    {isLoading ? (
    - ) : filteredClients.length === 0 ? ( + ) : filtered.length === 0 ? ( searchQuery ? ( - // Narrow empty state: keep the original card + copy for the - // "no search results" case; ConnectIDEs only shows on - // truly-empty (no clients configured at all). -

    No clients match your search

    +

    + No connections match your search +

    Try adjusting your search terms.

    ) : ( - // True empty — nothing has connected yet. Give them everything - // they need to finish the flow: install the IDE config, restart - // the IDE, then come back and approve. Each step is a distinct - // visual block so users don't skim past "and then approve here". -
    - {/* Generic 3-step welcome. Always visible while there are no - connected clients — no dismiss — because a user hitting - this page with nothing connected needs the instructions - every time, not just the first time. */} - - -
    -
    - -
    -
    -

    Let's hook up your first IDE

    -

    - mcpmux is one connection your AI client uses to reach every MCP server. - Three steps and you're done: -

    -
    -
    - -
      -
    1. - - 1 - -
      -

      Pick your IDE below and follow its prompt

      -

      - Each card tells you exactly what the button does — either one-click - install or copy a small config for you to paste. -

      -
      -
    2. -
    3. - - 2 - -
      -

      Open or restart the IDE

      -

      - Your client only connects to mcpmux the next time its MCP servers - start up. -

      -
      -
    4. -
    5. - - 3 - -
      -

      - Approve the connection{' '} - - right here - -

      -

      - mcpmux will pop a dialog on this app the moment your IDE reaches - the gateway. Until you accept it, nothing is routed. -

      -
      -
    6. -
    - - {!gatewayStatus.running && ( -
    - -
    -

    - Gateway is stopped -

    -

    - Start it from the Dashboard first — otherwise the IDE will hang at{' '} - initialize. -

    -
    -
    - )} -
    -
    - - {/* IDE grid — each card's popover tells the user what pressing - the button actually does + what to do next. */} - -
    + ) ) : (
    - {filteredClients.map((client) => { - const modeInfo = getModeInfo(client.connection_mode); - const ModeIcon = modeInfo.icon; - const isSelected = selectedClient?.client_id === client.client_id; - + {filtered.map((client) => { + const isSelected = selected?.client_id === client.client_id; + const displayName = client.client_alias || client.client_name; return ( - - {/* Client Header */} -
    -
    - +
    +
    +
    -

    - {client.client_alias || client.client_name} +

    + {displayName}

    {client.client_alias && ( -

    +

    {client.client_name}

    )}
    - {/* Connection Mode */} -
    - - {modeInfo.label} +
    + + + Last seen {formatLastSeen(client.last_seen)} + + {client.software_version && ( + + v{client.software_version} + + )}
    - - {/* Locked Space Info */} - {client.connection_mode === 'locked' && client.locked_space_id && ( -
    - {getSpaceName(client.locked_space_id)} -
    - )} ); @@ -782,648 +378,325 @@ export default function ClientsPage() {
    - {/* Overlay backdrop when panel is open */} - {selectedClient && ( -
    setSelectedClient(null)} - /> + {selected && ( + <> +
    setSelected(null)} + /> + setSelected(null)} + onSaveAlias={handleSaveAlias} + onRevoke={() => handleRevoke(selected)} + onOpenWorkspaces={() => { + setSelected(null); + navigateTo('workspaces'); + }} + /> + )} - {/* Slide-out Panel */} - {selectedClient && ( -
    - {/* Panel Header - Compact */} -
    -
    -
    -
    - -
    -
    -

    - {selectedClient.client_alias || selectedClient.client_name} -

    - {selectedClient.client_alias && ( -

    - {selectedClient.client_name} -

    - )} -
    -
    + + {ConfirmDialogElement} +
    + ); +} + +function lastSeenDotColor(lastSeen: string | null, now: number): string { + if (!lastSeen) return 'bg-gray-400'; + const secs = (now - new Date(lastSeen).getTime()) / 1000; + if (secs < 120) return 'bg-emerald-500'; + if (secs < 3600) return 'bg-amber-500'; + return 'bg-gray-400'; +} + +// --------------------------------------------------------------------------- +// Side panel +// --------------------------------------------------------------------------- + +interface SidePanelProps { + client: OAuthClient; + editAlias: string; + setEditAlias: (v: string) => void; + isSaving: boolean; + onClose: () => void; + onSaveAlias: () => void; + onRevoke: () => void; + onOpenWorkspaces: () => void; +} + +function SidePanel({ + client, + editAlias, + setEditAlias, + isSaving, + onClose, + onSaveAlias, + onRevoke, + onOpenWorkspaces, +}: SidePanelProps) { + const aliasDirty = (client.client_alias || '') !== editAlias; + + return ( +
    +
    +
    +
    +
    + +
    +
    +

    + {client.client_alias || client.client_name} +

    +

    + {client.client_alias ? client.client_name : client.client_id} +

    +
    +
    + +
    +
    + +
    +
    +

    + Display name +

    +
    + setEditAlias(e.target.value)} + placeholder={client.client_name} + className="flex-1 px-3 py-2 text-sm bg-[rgb(var(--background))] border border-[rgb(var(--border))] rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500" + /> + +
    +

    + An alias shown in logs and this list. Doesn't affect routing. +

    +
    + +
    +
    +
    + +
    +
    +

    Routing is workspace-driven

    +

    + When this client reports a folder as an MCP root, mcpmux uses the + matching Workspace binding to pick the Space and FeatureSet. + Nothing is configured per-client anymore. +

    - - {selectedClient.software_version && ( - - v{selectedClient.software_version} - +
    +
    + +
    +

    + Client info +

    +
    + + + {client.software_id && ( + + )} + {client.software_version && ( + + )} + + {client.last_seen && ( + )}
    +
    +
    - {/* Scrollable Content */} -
    -
    - {/* Quick Settings Section */} -
    - - - {expandedSections.quickSettings && ( -
    - {/* Display Name */} -
    - - setEditAlias(e.target.value)} - placeholder={selectedClient.client_name} - className="w-full px-3 py-2 text-sm bg-[rgb(var(--surface))] border border-[rgb(var(--border))] rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500" - /> -
    - - {/* Connection Mode */} -
    - - -
    - - {/* Locked Space Selection */} - {editMode === 'locked' && ( -
    - - -
    - )} - - {/* Save Button */} - -
    - )} -
    - - {/* Permissions Section */} -
    - - - {expandedSections.permissions && ( -
    - {/* Context Warning */} - {selectedClient.connection_mode === 'locked' && selectedClient.locked_space_id !== activeSpace?.id ? ( -
    -
    - -
    -

    - Locked to {getSpaceName(selectedClient.locked_space_id)} -

    -

    - Switch spaces or change connection mode to manage permissions -

    -
    -
    -
    - ) : ( - <> - {/* Space Context */} - {activeSpace && ( -
    -
    - Managing: - - {activeSpace.icon || '📁'} {activeSpace.name} - -
    -
    - )} - - {/* Feature Sets */} - {isLoadingGrants ? ( -
    - -
    - ) : ( -
    -
    - Feature Sets -
    - {availableFeatureSets - .filter(fs => !fs.name.endsWith(' - Custom')) - .slice(0, 5) - .map((fs) => { - const isGranted = grantedFeatureSetIds.includes(fs.id); - const isDefault = fs.feature_set_type === 'default'; - const isDisabled = isDefault; - - return ( - - ); - })} -
    - )} - - {/* Advanced Permissions Toggle */} - - - {/* Advanced Permissions Content */} - {expandedSections.advancedPermissions && ( -
    - {isLoadingFeatures ? ( -
    - -
    - ) : (() => { - const serverGroups = availableFeatures.reduce((acc, feature) => { - if (!acc[feature.server_id]) { - acc[feature.server_id] = []; - } - acc[feature.server_id].push(feature); - return acc; - }, {} as Record); - - return ( -
    - {Object.entries(serverGroups).map(([serverId, features]) => { - const isExpanded = expandedServers.has(serverId); - const selectedCount = features.filter(f => - individualFeatureMembers.some(m => m.member_id === f.id) - ).length; - - return ( -
    - - - {isExpanded && ( -
    - {features.map((feature) => { - const isAdded = individualFeatureMembers.some(m => m.member_id === feature.id); - - return ( - - ); - })} -
    - )} -
    - ); - })} -
    - ); - })()} -
    - )} - - )} -
    - )} -
    - - {/* Effective Features Section */} -
    - - - {expandedSections.effectiveFeatures && ( -
    - {isLoadingResolvedFeatures ? ( -
    - -
    - ) : !resolvedFeatures || totalFeatures === 0 ? ( -
    - -

    - No features granted yet -

    -
    - ) : ( -
    - {/* Tools */} - {resolvedFeatures.tools.length > 0 && ( -
    - - {expandedFeatureTypes.tools && ( -
    - {resolvedFeatures.tools.map((tool) => ( -
    -
    - {tool.name} -
    - {tool.description && ( -
    - {tool.description} -
    - )} -
    - ))} -
    - )} -
    - )} - - {/* Prompts */} - {resolvedFeatures.prompts.length > 0 && ( -
    - - {expandedFeatureTypes.prompts && ( -
    - {resolvedFeatures.prompts.map((prompt) => ( -
    -
    - {prompt.name} -
    - {prompt.description && ( -
    - {prompt.description} -
    - )} -
    - ))} -
    - )} -
    - )} +
    + +
    +
    + ); +} - {/* Resources */} - {resolvedFeatures.resources.length > 0 && ( -
    - - {expandedFeatureTypes.resources && ( -
    - {resolvedFeatures.resources.map((resource) => ( -
    -
    - {resource.name} -
    - {resource.description && ( -
    - {resource.description} -
    - )} -
    - ))} -
    - )} -
    - )} -
    - )} -
    - )} -
    +function InfoRow({ + label, + value, + mono, +}: { + label: string; + value: string; + mono?: boolean; +}) { + return ( +
    + {label} + + {value} + +
    + ); +} - {/* Client Info Section */} -
    - +// --------------------------------------------------------------------------- +// Empty-state onboarding (preserved from original) +// --------------------------------------------------------------------------- - {expandedSections.clientInfo && ( -
    -
    -
    -
    Client ID
    -
    {selectedClient.client_id}
    -
    -
    -
    Type
    -
    {selectedClient.registration_type || 'dynamic'}
    -
    -
    -
    - )} -
    +function EmptyStateOnboarding({ + gatewayStatus, +}: { + gatewayStatus: GatewayStatus; +}) { + return ( +
    + + +
    +
    + +
    +
    +

    + Let's hook up your first IDE +

    +

    + mcpmux is one connection your AI client uses to reach every MCP + server. Three steps and you're done: +

    - {/* Panel Footer - Sticky */} -
    - -
    -
    - )} +
      + + + + Approve the connection{' '} + + right here + + + } + body="mcpmux will pop a dialog the moment your IDE reaches the gateway. Until you accept it, nothing is routed." + /> +
    + + {!gatewayStatus.running && ( +
    + +
    +

    + Gateway is stopped +

    +

    + Start it from the Dashboard first — otherwise the IDE will hang at{' '} + initialize. +

    +
    +
    + )} + + - - {ConfirmDialogElement} +
    ); } + +function OnboardingStep({ + n, + title, + body, + tone, +}: { + n: number; + title: React.ReactNode; + body: string; + tone: 'primary' | 'emerald'; +}) { + const cls = + tone === 'emerald' + ? 'bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-300' + : 'bg-primary-100 dark:bg-primary-900/40 text-primary-700 dark:text-primary-300'; + return ( +
  • + + {n} + +
    +

    {title}

    +

    {body}

    +
    +
  • + ); +} diff --git a/apps/desktop/src/features/featuresets/FeatureSetPanel.tsx b/apps/desktop/src/features/featuresets/FeatureSetPanel.tsx index d2664b2..430595f 100644 --- a/apps/desktop/src/features/featuresets/FeatureSetPanel.tsx +++ b/apps/desktop/src/features/featuresets/FeatureSetPanel.tsx @@ -15,7 +15,6 @@ import { Settings, Trash2, Check, - Globe, Star, Shield, Save, @@ -57,40 +56,15 @@ export function FeatureSetPanel({ featureSet, spaceId, onClose, onDelete, onUpda features: true, }); - // Determine if this is a configurable feature set - const isConfigurable = featureSet.feature_set_type === 'default' || featureSet.feature_set_type === 'custom'; + // Both FS types are member-driven now. + const isConfigurable = true; const isDefault = featureSet.feature_set_type === 'default'; const isCustom = featureSet.feature_set_type === 'custom'; - const isAll = featureSet.feature_set_type === 'all'; - const isServerAll = featureSet.feature_set_type === 'server-all'; - - // For special feature sets, compute actual member count - const getActualMemberCount = () => { - if (isAll) { - // "All Features" includes everything - return allFeatures.length; - } - if (isServerAll && featureSet.server_id) { - // "Server All" - use server_id from feature set - return allFeatures.filter(f => f.server_id === featureSet.server_id).length; - } - // For configurable sets, use selectedFeatureIds - return selectedFeatureIds.size; - }; - - // Check if a feature should be shown as selected - const isFeatureSelected = (featureId: string, feature: ServerFeature) => { - if (isAll) { - // All features are selected - return true; - } - if (isServerAll && featureSet.server_id) { - // Only features from the target server - return feature.server_id === featureSet.server_id; - } - // For configurable sets, check selectedFeatureIds - return selectedFeatureIds.has(featureId); - }; + + const getActualMemberCount = () => selectedFeatureIds.size; + + const isFeatureSelected = (featureId: string, _feature: ServerFeature) => + selectedFeatureIds.has(featureId); useEffect(() => { const loadFeatures = async () => { @@ -99,29 +73,14 @@ export function FeatureSetPanel({ featureSet, spaceId, onClose, onDelete, onUpda const features = await listServerFeatures(spaceId); setAllFeatures(features); - // Initialize selected features from current members + // Seed from the set's include-mode feature members. const currentIds = new Set(); - - // For special feature sets, compute selection dynamically - if (featureSet.feature_set_type === 'all') { - // All features are selected - features.forEach(f => currentIds.add(f.id)); - } else if (featureSet.feature_set_type === 'server-all' && featureSet.server_id) { - // All features from this server are selected - features.forEach(f => { - if (f.server_id === featureSet.server_id) { - currentIds.add(f.id); - } - }); - } else { - // For configurable sets (default/custom), use members array - featureSet.members?.forEach((m) => { - if (m.member_type === 'feature' && m.mode === 'include') { - currentIds.add(m.member_id); - } - }); - } - + featureSet.members?.forEach((m) => { + if (m.member_type === 'feature' && m.mode === 'include') { + currentIds.add(m.member_id); + } + }); + setSelectedFeatureIds(currentIds); // Start with all servers collapsed @@ -259,10 +218,10 @@ export function FeatureSetPanel({ featureSet, spaceId, onClose, onDelete, onUpda const getFeatureSetIcon = () => { if (featureSet.icon) return {featureSet.icon}; switch (featureSet.feature_set_type) { - case 'all': return ; case 'default': return ; - case 'server-all': return ; - case 'custom': default: return ; + case 'custom': + default: + return ; } }; diff --git a/apps/desktop/src/features/featuresets/FeatureSetsPage.tsx b/apps/desktop/src/features/featuresets/FeatureSetsPage.tsx index 4290f66..f2f8697 100644 --- a/apps/desktop/src/features/featuresets/FeatureSetsPage.tsx +++ b/apps/desktop/src/features/featuresets/FeatureSetsPage.tsx @@ -2,12 +2,10 @@ import { useState, useEffect, useCallback } from 'react'; import { Plus, Loader2, - Server, Package, Settings, X, RefreshCw, - Globe, Star, Search, AlertCircle, @@ -30,21 +28,16 @@ import { deleteFeatureSet, getFeatureSetWithMembers, } from '@/lib/api/featureSets'; -import { setSpaceActiveFeatureSet, getSpace } from '@/lib/api/spaces'; import { useViewSpace } from '@/stores'; import { FeatureSetPanel } from './FeatureSetPanel'; // Get icon for feature set type const getFeatureSetIcon = (fs: FeatureSet) => { if (fs.icon) return {fs.icon}; - + switch (fs.feature_set_type) { - case 'all': - return ; case 'default': return ; - case 'server-all': - return ; case 'custom': default: return ; @@ -54,12 +47,8 @@ const getFeatureSetIcon = (fs: FeatureSet) => { // Get display name for feature set type const getFeatureSetTypeName = (type: string) => { switch (type) { - case 'all': - return 'All Features'; case 'default': return 'Default'; - case 'server-all': - return 'Server All'; case 'custom': default: return 'Custom'; @@ -84,15 +73,6 @@ export function FeatureSetsPage() { // Panel state const [selectedFeatureSet, setSelectedFeatureSet] = useState(null); - // Resolver v2: which FS is the Space's active fallback. - // Tracked locally so the "Active" badge updates immediately after clicking - // "Set Active" without waiting for a refetch of the whole viewSpace. - const [activeFeatureSetId, setActiveFeatureSetId] = useState(null); - // Id of the FS whose "Set Active" button is mid-flight, so we can render - // a spinner in its place (otherwise the optimistic update swaps the button - // for the Active badge immediately and a slow backend feels like a no-op). - const [activatingId, setActivatingId] = useState(null); - const loadData = useCallback(async (spaceId?: string) => { setIsLoading(true); setError(null); @@ -101,15 +81,8 @@ export function FeatureSetsPage() { setFeatureSets([]); return; } - - // Backend filters out server-all feature sets for disabled servers const data = await listFeatureSetsBySpace(spaceId); setFeatureSets(data); - - // Fetch the Space's active FS for the "Active" badge. The - // viewSpace from the store may be stale, so we fetch fresh here. - const space = await getSpace(spaceId); - setActiveFeatureSetId(space?.active_feature_set_id ?? null); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { @@ -117,34 +90,6 @@ export function FeatureSetsPage() { } }, []); - const handleSetActive = async (fs: FeatureSet, event: React.MouseEvent) => { - // Belt and suspenders: stop both the synthetic event AND the native - // event so the wrapping Card's onClick can't open the panel. - event.stopPropagation(); - event.preventDefault(); - event.nativeEvent?.stopImmediatePropagation?.(); - - if (!viewSpace) return; - if (activatingId) return; // Debounce double-clicks. - - const previous = activeFeatureSetId; - setActivatingId(fs.id); - setActiveFeatureSetId(fs.id); // Optimistic. - try { - await setSpaceActiveFeatureSet(viewSpace.id, fs.id); - success( - `${fs.name} is now Active`, - 'Applied to every connected client in this Space without a pin or workspace binding.' - ); - } catch (e) { - setActiveFeatureSetId(previous); - const msg = e instanceof Error ? e.message : String(e); - showError('Failed to set Active FeatureSet', msg); - } finally { - setActivatingId(null); - } - }; - useEffect(() => { setSelectedFeatureSet(null); setShowCreateModal(false); @@ -233,11 +178,12 @@ export function FeatureSetsPage() { ); }) .sort((a, b) => { - // Sort order: all → default → custom → server-all - const order: Record = { all: 0, default: 1, custom: 2, 'server-all': 3 }; - const aOrder = order[a.feature_set_type] ?? 2; - const bOrder = order[b.feature_set_type] ?? 2; - return aOrder - bOrder; + // Default FS first (pinned to top), then Custom sets alphabetically + const order: Record = { default: 0, custom: 1 }; + const aOrder = order[a.feature_set_type] ?? 1; + const bOrder = order[b.feature_set_type] ?? 1; + if (aOrder !== bOrder) return aOrder - bOrder; + return a.name.localeCompare(b.name); }); return ( @@ -292,8 +238,7 @@ export function FeatureSetsPage() {
    - {/* Active FeatureSet explainer — helps users understand what the green - ribbon / "Set Active" button actually does before they click around. */} + {/* Feature-set model explainer */}
    @@ -301,11 +246,12 @@ export function FeatureSetsPage() {

    - One Active FeatureSet per Space + FeatureSets are bound to workspace roots

    - The Active set is applied to every connected MCP client in this Space that doesn't - have an explicit pin or a matching workspace binding. Click Set Active on any card to make it the default. + Each Space gets one auto-created Default set. Routing is decided per + reported folder via Workspaces — sessions + whose root isn't bound fall back to the default Space's Default set.

    @@ -354,9 +300,7 @@ export function FeatureSetsPage() { {filteredSets.map((fs) => { const isSelected = selectedFeatureSet?.id === fs.id; const isBuiltin = fs.is_builtin; - const isActive = activeFeatureSetId === fs.id; - - const isActivating = activatingId === fs.id; + const isDefault = fs.feature_set_type === 'default'; return ( handleOpenPanel(fs)} data-testid={`featureset-card-${fs.id}`} > - {/* Active ribbon — top-right corner badge, premium feel */} - {isActive && ( + {isDefault && (
    - - Active + + Default
    )} - {/* Header */}
    -
    +
    {getFeatureSetIcon(fs)}
    @@ -409,58 +345,15 @@ export function FeatureSetsPage() {
    - {/* Description */}

    {fs.description || 'No description provided.'}

    - {/* Footer — Set Active is now a prominent gradient button; Active state gets its own caption */}
    -
    - {fs.feature_set_type === 'server-all' ? ( - {fs.server_id} - ) : fs.feature_set_type === 'all' ? ( - All features - ) : ( - {fs.members?.length || 0} members - )} -
    - -
    - {isActive ? ( - - - Applied to this Space - - ) : ( - - )} - {isBuiltin && fs.feature_set_type !== 'default' ? ( - Auto-managed - ) : ( - - Configure - - )} -
    + {fs.members?.length || 0} members + + Configure +
    diff --git a/apps/desktop/src/features/gateway/AutoStartConflictResolver.tsx b/apps/desktop/src/features/gateway/AutoStartConflictResolver.tsx new file mode 100644 index 0000000..1486be5 --- /dev/null +++ b/apps/desktop/src/features/gateway/AutoStartConflictResolver.tsx @@ -0,0 +1,106 @@ +import { useEffect } from 'react'; +import { + takePendingPortConflict, + getGatewayStatus, +} from '@/lib/api/gateway'; +import { useGatewayControl } from './useGatewayControl'; + +/** + * Polling schedule (ms after mount). Covers the realistic window for the + * Rust auto-start task to complete its port probe. Short early polls catch + * the common case; longer tails catch cold-start machines / slow disks. + * Total max wait: ~4.75s before giving up silently. + */ +const POLL_SCHEDULE_MS = [0, 150, 300, 600, 1200, 2400]; + +/** + * Mounts at the app root and resolves any auto-start port conflict the + * backend deferred during launch. + * + * ## Why polling, not events + * + * Tauri events aren't buffered — if the Rust auto-start task emits + * `gateway-autostart-port-conflict` before `listen()` has attached the + * frontend listener, the event is dropped. Combined with React + * StrictMode's double-mount in dev, the probability of this race is + * noticeable. + * + * Polling `take_pending_port_conflict` (atomic read-and-clear on the + * backend) plus `get_gateway_status` together covers all three + * launch-time outcomes: + * + * 1. **Silent success** — port free, gateway auto-started. `getGatewayStatus` + * returns `running: true` → we exit. + * 2. **Port conflict** — backend set `pending_port_conflict`. The take + * consumes it; we show the prompt. + * 3. **Auto-start disabled** — neither a conflict nor a running gateway. + * We exhaust the poll schedule and exit quietly; user can start + * manually from the Dashboard. + * + * The backend `take` is atomic so the StrictMode double-mount never + * produces duplicate prompts. + */ +export function AutoStartConflictResolver() { + const gatewayControl = useGatewayControl(); + + useEffect(() => { + let cancelled = false; + + (async () => { + for (let i = 0; i < POLL_SCHEDULE_MS.length; i++) { + if (cancelled) return; + const delay = POLL_SCHEDULE_MS[i]; + if (delay > 0) { + await new Promise((resolve) => setTimeout(resolve, delay)); + } + if (cancelled) return; + + try { + // If the gateway auto-started silently (port was free), we're + // done — no need to keep probing. + const status = await getGatewayStatus(); + if (cancelled) return; + if (status.running) { + console.log( + `[AutoStart] attempt ${i + 1}: gateway already running (${status.url}) — nothing to resolve` + ); + return; + } + + const conflict = await takePendingPortConflict(); + if (cancelled) return; + console.log( + `[AutoStart] attempt ${i + 1}: takePendingPortConflict →`, + conflict + ); + + if (conflict) { + const outcome = await gatewayControl.start(); + console.log('[AutoStart] prompt outcome:', outcome); + return; + } + // Otherwise keep polling — backend auto-start task may not have + // run yet. Last iteration just bails (user can start manually). + } catch (err) { + console.error( + `[AutoStart] attempt ${i + 1} failed — will retry:`, + err + ); + } + } + + console.log( + '[AutoStart] poll schedule exhausted — no conflict, no running gateway (likely auto-start disabled)' + ); + })(); + + return () => { + cancelled = true; + }; + // `gatewayControl` is stable for the lifetime of this component; we + // deliberately run this once on mount. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return <>{gatewayControl.ConfirmDialogElement}; +} diff --git a/apps/desktop/src/features/gateway/useGatewayControl.tsx b/apps/desktop/src/features/gateway/useGatewayControl.tsx new file mode 100644 index 0000000..2c6bd24 --- /dev/null +++ b/apps/desktop/src/features/gateway/useGatewayControl.tsx @@ -0,0 +1,152 @@ +import { useConfirm } from '@mcpmux/ui'; +import { + probeGatewayStart, + startGateway, + restartGateway, + parsePortInUseError, +} from '@/lib/api/gateway'; + +/** + * Shape of the outcome returned by start/restart helpers. + * + * `cancelled` signals the user dismissed the port-in-use prompt — callers + * should treat it as a non-error (no toast, just stop). + */ +export type GatewayStartOutcome = + | { status: 'started'; url: string; fellBackToDynamic: boolean; port: number } + | { status: 'cancelled' }; + +function sourceLabel(source: 'override' | 'configured' | 'default'): string { + switch (source) { + case 'configured': + return 'your configured gateway port'; + case 'default': + return 'the default gateway port'; + case 'override': + return 'the requested gateway port'; + } +} + +/** + * Hook that handles the probe → confirm → start flow uniformly across the + * Dashboard, Servers page, and Settings page. Render `ConfirmDialogElement` + * once inside the consuming component. + * + * When the preferred port is taken, the user is shown a dialog asking + * whether to let the gateway bind to a different (OS-assigned) port. If + * they cancel, the returned outcome is `{ status: 'cancelled' }` and no + * error is thrown — the caller can exit silently. + */ +export function useGatewayControl() { + const { confirm, ConfirmDialogElement } = useConfirm(); + + const runStart = async ( + invoker: (allowFallback: boolean) => Promise, + probePort?: number + ): Promise => { + console.log('[Gateway] probeGatewayStart({port:', probePort, '})'); + const probe = await probeGatewayStart(probePort); + console.log('[Gateway] probe result:', probe); + + if (probe.preferredAvailable) { + console.log('[Gateway] preferred port free → strict start'); + const url = await invoker(false); + const port = parsePortFromUrl(url) ?? probe.preferredPort; + console.log('[Gateway] strict start ok →', url); + return { status: 'started', url, port, fellBackToDynamic: false }; + } + + console.log('[Gateway] preferred port taken → prompting user'); + const ok = await confirm({ + title: 'Gateway port is in use', + message: + `${capitalize(sourceLabel(probe.source))} (:${probe.preferredPort}) is already ` + + `taken by another process. Start the gateway on a different port that the system ` + + `picks automatically? Your IDE configs will need to be updated to point at the new ` + + `port.`, + confirmLabel: 'Use another port', + variant: 'default', + }); + + if (!ok) { + console.log('[Gateway] user cancelled — gateway stays stopped'); + return { status: 'cancelled' }; + } + + console.log('[Gateway] user confirmed → fallback start with dynamic port'); + const url = await invoker(true); + const port = parsePortFromUrl(url) ?? probe.preferredPort; + console.log('[Gateway] fallback start ok →', url); + return { + status: 'started', + url, + port, + fellBackToDynamic: true, + }; + }; + + const start = async (opts?: { port?: number }): Promise => { + try { + return await runStart( + (allowFallback) => + startGateway({ port: opts?.port, allowDynamicFallback: allowFallback }), + opts?.port + ); + } catch (err) { + // If we hit a race (probe said free, bind failed) or any other bind + // error, surface it with the structured prompt flow. + return await handleBindFailure(err, opts?.port, (allowFallback) => + startGateway({ port: opts?.port, allowDynamicFallback: allowFallback }) + ); + } + }; + + const restart = async (opts?: { port?: number }): Promise => { + try { + return await runStart( + (allowFallback) => + restartGateway({ port: opts?.port, allowDynamicFallback: allowFallback }), + opts?.port + ); + } catch (err) { + return await handleBindFailure(err, opts?.port, (allowFallback) => + restartGateway({ port: opts?.port, allowDynamicFallback: allowFallback }) + ); + } + }; + + const handleBindFailure = async ( + err: unknown, + port: number | undefined, + invoker: (allowFallback: boolean) => Promise + ): Promise => { + const pie = parsePortInUseError(err); + if (!pie) throw err; + const ok = await confirm({ + title: 'Gateway port is in use', + message: + `${capitalize(sourceLabel(pie.source))} (:${pie.port}) is already in use. ` + + `Start on a different port?`, + confirmLabel: 'Use another port', + }); + if (!ok) return { status: 'cancelled' }; + const url = await invoker(true); + return { + status: 'started', + url, + port: parsePortFromUrl(url) ?? pie.port, + fellBackToDynamic: true, + }; + }; + + return { start, restart, ConfirmDialogElement }; +} + +function parsePortFromUrl(url: string): number | null { + const match = /:(\d+)(?:\/|$)/.exec(url); + return match ? Number(match[1]) : null; +} + +function capitalize(s: string): string { + return s.charAt(0).toUpperCase() + s.slice(1); +} diff --git a/apps/desktop/src/features/servers/ServersPage.tsx b/apps/desktop/src/features/servers/ServersPage.tsx index 4350c8b..9ae658c 100644 --- a/apps/desktop/src/features/servers/ServersPage.tsx +++ b/apps/desktop/src/features/servers/ServersPage.tsx @@ -28,6 +28,7 @@ import type { ConnectionStatus, ServerStatusResponse } from '@/lib/api/serverMan import { getServerStatuses as fetchServerStatuses } from '@/lib/api/serverManager'; import { useViewSpace, useNavigateTo } from '@/stores'; import { useServerManager } from '@/hooks/useServerManager'; +import { useGatewayControl } from '@/features/gateway/useGatewayControl'; import { useGatewayEvents, useDomainEvents } from '@/hooks/useDomainEvents'; import type { GatewayChangedPayload, ServerChangedPayload } from '@/hooks/useDomainEvents'; import type { FeaturesUpdatedEvent } from '@/lib/api/serverManager'; @@ -166,6 +167,7 @@ export function ServersPage() { const [gatewayUrl, setGatewayUrl] = useState(null); const [isLoading, setIsLoading] = useState(true); const [actionLoading, setActionLoading] = useState(null); + const gatewayControl = useGatewayControl(); // Bottom toast notifications const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' | 'info' } | null>(null); const [configModal, setConfigModal] = useState({ @@ -741,18 +743,25 @@ export function ServersPage() { const handleStartGateway = async () => { try { - const { startGateway, connectAllEnabledServers } = await import('@/lib/api/gateway'); - const url = await startGateway(); + const outcome = await gatewayControl.start(); + if (outcome.status === 'cancelled') return; setGatewayRunning(true); - setGatewayUrl(url); - + setGatewayUrl(outcome.url); + if (outcome.fellBackToDynamic) { + showToast( + `Preferred port was in use — gateway is now on :${outcome.port}. Update IDE configs.`, + 'info' + ); + } + // Auto-connect all enabled servers try { + const { connectAllEnabledServers } = await import('@/lib/api/gateway'); await connectAllEnabledServers(); } catch (e) { console.warn('[ServersPage] Failed to auto-connect servers:', e); } - + await loadData(); } catch (e) { showToast(String(e), 'error'); @@ -836,6 +845,7 @@ export function ServersPage() { return (
    + {gatewayControl.ConfirmDialogElement} {/* Header */}
    diff --git a/apps/desktop/src/features/settings/SettingsPage.tsx b/apps/desktop/src/features/settings/SettingsPage.tsx index f9de28c..caccec2 100644 --- a/apps/desktop/src/features/settings/SettingsPage.tsx +++ b/apps/desktop/src/features/settings/SettingsPage.tsx @@ -29,11 +29,15 @@ import { Lightbulb, Package, Heart, + Network, + RotateCcw, + AlertCircle, } from 'lucide-react'; import { useAppStore, useTheme, useAnalyticsEnabled } from '@/stores'; import { UpdateChecker } from './UpdateChecker'; import { getMetaToolsEnabled, setMetaToolsEnabled } from '@/lib/api/metaTools'; import { MetaToolAuditLog, MetaToolGrantsPanel } from '@/features/metaTools'; +import { useGatewayControl } from '@/features/gateway/useGatewayControl'; import { CONTRIBUTE, openExternal } from '@/lib/contribute'; interface StartupSettings { @@ -42,6 +46,12 @@ interface StartupSettings { closeToTray: boolean; } +interface GatewayPortSettings { + configuredPort: number | null; + defaultPort: number; + activePort: number | null; +} + export function SettingsPage() { const theme = useTheme(); const setTheme = useAppStore((state) => state.setTheme); @@ -50,6 +60,7 @@ export function SettingsPage() { const [logsPath, setLogsPath] = useState(''); const [openingLogs, setOpeningLogs] = useState(false); const { toasts, success, error } = useToast(); + const gatewayControl = useGatewayControl(); // Startup settings state const [startupSettings, setStartupSettings] = useState({ @@ -68,6 +79,103 @@ export function SettingsPage() { const [metaToolsEnabled, setMetaToolsEnabledState] = useState(true); const [loadingMetaTools, setLoadingMetaTools] = useState(true); + // Gateway port — persisted user override, the default the app ships + // with, and the port the currently-running gateway is bound to. When + // saved ≠ active, the user has to restart the gateway to apply. + const [portSettings, setPortSettings] = useState(null); + const [portDraft, setPortDraft] = useState(''); + const [portError, setPortError] = useState(null); + const [savingPort, setSavingPort] = useState(false); + const [resettingPort, setResettingPort] = useState(false); + + const loadPortSettings = async () => { + try { + const s = await invoke('get_gateway_port_settings'); + setPortSettings(s); + setPortDraft(String(s.configuredPort ?? s.defaultPort)); + setPortError(null); + } catch (err) { + console.error('Failed to load gateway port settings:', err); + } + }; + + useEffect(() => { + loadPortSettings(); + }, []); + + const validatePort = (raw: string): { port: number } | { error: string } => { + const trimmed = raw.trim(); + if (!trimmed) return { error: 'Enter a port number' }; + if (!/^\d+$/.test(trimmed)) return { error: 'Port must be a number' }; + const n = Number(trimmed); + if (n < 1024 || n > 65535) { + return { error: 'Port must be between 1024 and 65535' }; + } + return { port: n }; + }; + + const handleSavePort = async () => { + const parsed = validatePort(portDraft); + if ('error' in parsed) { + setPortError(parsed.error); + return; + } + setPortError(null); + setSavingPort(true); + try { + await invoke('set_gateway_port', { port: parsed.port }); + await loadPortSettings(); + success( + 'Gateway port saved', + portSettings?.activePort && portSettings.activePort !== parsed.port + ? `Restart the gateway for port ${parsed.port} to take effect.` + : `Next gateway start will use port ${parsed.port}.` + ); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setPortError(msg); + error('Failed to save port', msg); + } finally { + setSavingPort(false); + } + }; + + const handleResetPort = async () => { + setResettingPort(true); + try { + await invoke('reset_gateway_port'); + await loadPortSettings(); + success( + 'Reset to default', + portSettings && portSettings.activePort !== portSettings.defaultPort + ? `Restart the gateway for port ${portSettings.defaultPort} to take effect.` + : `Next gateway start will use port ${portSettings?.defaultPort ?? ''}.` + ); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + error('Failed to reset port', msg); + } finally { + setResettingPort(false); + } + }; + + const handleRestartGateway = async () => { + try { + const outcome = await gatewayControl.restart(); + await loadPortSettings(); + if (outcome.status === 'cancelled') return; + success( + 'Gateway restarted', + outcome.fellBackToDynamic + ? `Saved port was unavailable — now running on :${outcome.port} instead.` + : 'The new port is now active.' + ); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + error('Failed to restart gateway', msg); + } + }; + useEffect(() => { getMetaToolsEnabled() .then((v) => setMetaToolsEnabledState(v)) @@ -197,6 +305,7 @@ export function SettingsPage() { return ( <> toasts.find(t => t.id === id)?.onClose(id)} /> + {gatewayControl.ConfirmDialogElement}

    Settings

    @@ -298,6 +407,154 @@ export function SettingsPage() { + {/* Gateway Section — port override + reset to default */} + + + + + Gateway + + + The local port every AI client connects to. Changing it takes effect on the next + gateway start — existing IDE configs pointing at the old port will need updating. + + + + {portSettings === null ? ( +
    + + Loading… +
    + ) : ( +
    +
    + +
    + +

    + Default is {portSettings.defaultPort}. + Use a port between 1024 and 65535. + {portSettings.activePort !== null ? ( + <> + {' '}Currently running on{' '} + + :{portSettings.activePort} + + . + + ) : ( + ' Gateway is stopped.' + )} +

    +
    + { + setPortDraft(e.target.value); + if (portError) setPortError(null); + }} + disabled={savingPort || resettingPort} + className="w-28 px-3 py-1.5 text-sm font-mono border border-[rgb(var(--border))] rounded-lg bg-[rgb(var(--surface))] text-[rgb(var(--foreground))] focus:outline-none focus:ring-2 focus:ring-primary-500/40" + data-testid="gateway-port-input" + /> + + +
    + {portError ? ( +

    + {portError} +

    + ) : null} +
    +
    + + {portSettings.activePort !== null && + portSettings.configuredPort !== null && + portSettings.configuredPort !== portSettings.activePort ? ( +
    + +
    +

    + Restart required +

    +

    + Saved port :{portSettings.configuredPort}{' '} + doesn't match the running port{' '} + :{portSettings.activePort}. Restart the + gateway to apply — your IDE configs will need to point at the new URL. +

    +
    + +
    + ) : null} +
    + )} +
    +
    + {/* Appearance Section */} diff --git a/apps/desktop/src/features/spaces/SpacesPage.tsx b/apps/desktop/src/features/spaces/SpacesPage.tsx index 67e3e45..3521614 100644 --- a/apps/desktop/src/features/spaces/SpacesPage.tsx +++ b/apps/desktop/src/features/spaces/SpacesPage.tsx @@ -1,13 +1,5 @@ import { useState } from 'react'; -import { - Plus, - Trash2, - Loader2, - Check, - Search, - Layout, - AlertCircle, -} from 'lucide-react'; +import { Plus, Trash2, Loader2, Search, Layout, AlertCircle } from 'lucide-react'; import { Card, CardHeader, @@ -18,23 +10,16 @@ import { ToastContainer, useConfirm, } from '@mcpmux/ui'; -import { - useAppStore, - useActiveSpace, - useSpaces, - useIsLoading, -} from '@/stores'; -import { createSpace, deleteSpace, setActiveSpace as setActiveSpaceAPI } from '@/lib/api/spaces'; +import { useAppStore, useSpaces, useIsLoading } from '@/stores'; +import { createSpace, deleteSpace } from '@/lib/api/spaces'; export function SpacesPage() { const spaces = useSpaces(); - const activeSpace = useActiveSpace(); const isLoading = useIsLoading('spaces'); - + // Store actions const addSpace = useAppStore((state) => state.addSpace); const removeSpace = useAppStore((state) => state.removeSpace); - const setActiveSpaceInStore = useAppStore((state) => state.setActiveSpace); // Local state const [searchQuery, setSearchQuery] = useState(''); @@ -95,23 +80,6 @@ export function SpacesPage() { } }; - const handleSetActive = async (id: string) => { - setIsActionLoading(id); - setError(null); - try { - await setActiveSpaceAPI(id); - setActiveSpaceInStore(id); - const activatedSpace = spaces.find(s => s.id === id); - success('Active space changed', `"${activatedSpace?.name || 'Space'}" is now active`); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - setError(msg); - showError('Failed to set active space', msg); - } finally { - setIsActionLoading(null); - } - }; - // Filter spaces const filteredSpaces = spaces.filter(space => { if (!searchQuery) return true; @@ -198,72 +166,47 @@ export function SpacesPage() { ) : (
    {filteredSpaces.map((space) => { - const isActive = activeSpace?.id === space.id; const isProcessing = isActionLoading === space.id; return ( - - {/* Header */}
    {space.icon || '🌐'}
    -

    - {space.name} -

    +

    {space.name}

    {space.description || 'No description'}

    - {isActive && ( - - Active + {space.is_default && ( + + Default )} {!space.is_default && ( - + )}
    - - {/* Footer Actions */} -
    - {!isActive ? ( - - ) : ( - - Current Context - - )} -
    ); diff --git a/apps/desktop/src/features/workspaces/WorkspaceBindingSheet.tsx b/apps/desktop/src/features/workspaces/WorkspaceBindingSheet.tsx new file mode 100644 index 0000000..a75bd76 --- /dev/null +++ b/apps/desktop/src/features/workspaces/WorkspaceBindingSheet.tsx @@ -0,0 +1,370 @@ +/** + * Workspace Binding Sheet + * + * Fires when a connected client session resolves via source=Default for a + * workspace root that has no binding yet. The user picks a Space + a + * FeatureSet in that space, and we write a WorkspaceBinding locking both. + * + * • Space picker — defaults to the caller's current space, can be changed. + * • FS picker — always includes a "space default" option (follow + * whichever FS is active for the selected Space) plus + * every Default + Custom set in that space. + * • Dismiss — nothing written, ask again next session. + * + * Committing the binding emits `WorkspaceBindingChanged` on the backend, + * which triggers `notifications/tools/list_changed` — the client re-fetches + * its tool list under the new routing decision without reconnecting. + */ + +import { useEffect, useRef, useState } from 'react'; +import { listen } from '@tauri-apps/api/event'; +import { Check, ChevronDown, FolderOpen, Loader2, Sparkles, X } from 'lucide-react'; +import { Button } from '@mcpmux/ui'; +import { createWorkspaceBinding } from '@/lib/api/workspaceBindings'; +import { listFeatureSetsBySpace, type FeatureSet } from '@/lib/api/featureSets'; +import { listSpaces, type Space } from '@/lib/api/spaces'; + +interface WorkspaceNeedsBindingPayload { + client_id: string; + session_id: string; + space_id: string; + workspace_root: string; +} + +/** + * Display-friendly path — strip the long prefix so a root like + * `/home/user/code/project` or `d:\dev\project` renders compactly, while + * keeping the full text accessible as a `title` tooltip. + */ +function shortenPath(path: string): string { + const parts = path.split(/[/\\]/).filter(Boolean); + if (parts.length <= 3) return path; + const head = parts[0]; + const tail = parts.slice(-2).join('/'); + return `${head}/…/${tail}`; +} + +export function WorkspaceBindingSheet() { + const [payload, setPayload] = useState(null); + const [spaces, setSpaces] = useState([]); + const [selectedSpaceId, setSelectedSpaceId] = useState(''); + const [featureSets, setFeatureSets] = useState([]); + const [loadingFs, setLoadingFs] = useState(false); + const [selectedFsId, setSelectedFsId] = useState(''); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + // Only dedupe the currently-open sheet against itself — if one is already + // showing, swallow a second emit for the same session. We deliberately + // don't dedupe across sessions / reconnects: the backend only emits when + // `source=Default` (i.e. no binding exists), and reconnecting a client + // is a normal signal that the user may want to configure the folder. + // Persisting the dismissal in a ref would black-hole later attempts + // until the next app restart, which is how this bug surfaced before. + const currentSessionRef = useRef(null); + currentSessionRef.current = payload?.session_id ?? null; + + useEffect(() => { + const un = listen( + 'workspace-needs-binding', + (event) => { + // Swallow only while a sheet is already showing — the user is + // mid-decision, a second emit would stack a new sheet on top. Once + // the current sheet closes (Save or Not now), the next emit from + // any fresh session on an unbound root opens the sheet again. + if (currentSessionRef.current !== null) return; + const p = event.payload; + setPayload(p); + setSelectedSpaceId(p.space_id); + setSelectedFsId(''); + setError(null); + } + ); + return () => { + un.then((fn) => fn()); + }; + }, []); + + // Load every Space once the sheet is visible so the user can pin the + // binding to a different Space than the caller happened to land in. + useEffect(() => { + if (!payload) return; + let cancelled = false; + listSpaces() + .then((list) => { + if (!cancelled) setSpaces(list); + }) + .catch((e) => { + if (!cancelled) setError(String(e)); + }); + return () => { + cancelled = true; + }; + }, [payload]); + + // Reload FS list whenever the target space changes. After the list + // arrives, preselect the Space's Default FS so the user has a valid + // selection out of the box — picking a FS from a different Space would + // fail on save. + useEffect(() => { + if (!payload || !selectedSpaceId) return; + let cancelled = false; + setLoadingFs(true); + setSelectedFsId(''); + listFeatureSetsBySpace(selectedSpaceId) + .then((list) => { + if (cancelled) return; + const visible = list.filter((fs) => !fs.is_deleted); + setFeatureSets(visible); + const defaultFs = + visible.find((fs) => fs.feature_set_type === 'default') ?? visible[0]; + if (defaultFs) setSelectedFsId(defaultFs.id); + }) + .catch((e) => { + if (!cancelled) setError(String(e)); + }) + .finally(() => { + if (!cancelled) setLoadingFs(false); + }); + return () => { + cancelled = true; + }; + }, [payload, selectedSpaceId]); + + const markSeenAndClose = (_p: WorkspaceNeedsBindingPayload) => { + setPayload(null); + }; + + const handleSave = async () => { + if (!payload || saving || !selectedSpaceId) return; + if (!selectedFsId) { + setError('Pick a feature set first'); + return; + } + setSaving(true); + setError(null); + try { + await createWorkspaceBinding({ + workspace_root: payload.workspace_root, + space_id: selectedSpaceId, + feature_set_id: selectedFsId, + }); + markSeenAndClose(payload); + } catch (e) { + setError(typeof e === 'string' ? e : String(e)); + } finally { + setSaving(false); + } + }; + + const handleDismiss = () => { + if (!payload || saving) return; + markSeenAndClose(payload); + }; + + if (!payload) return null; + + return ( +
    +
    e.stopPropagation()} + > + + +
    +
    + + New workspace detected +
    +

    + Which tools should this folder see? +

    +

    + Pick a Space and its tool set — every client you open here will get the same one. +

    + +
    + +
    +
    + {shortenPath(payload.workspace_root)} +
    +
    +
    +
    + +
    +
    +
    + Space +
    +
    + + +
    +
    + +
    +
    + Tool set +
    + {loadingFs ? ( +
    + +
    + ) : featureSets.length === 0 ? ( +
    + No feature sets in this space yet. +
    + ) : ( +
    + {featureSets.map((fs) => ( + setSelectedFsId(fs.id)} + title={fs.name} + subtitle={fs.description || describeFs(fs)} + badge={fs.feature_set_type === 'default' ? 'builtin' : undefined} + /> + ))} +
    + )} +
    +
    + +
    + {error && ( +
    + {error} +
    + )} +
    + + +
    +

    + You can change this anytime in Workspaces. +

    +
    +
    +
    + ); +} + +function ChoiceRow({ + selected, + onSelect, + title, + subtitle, + badge, +}: { + selected: boolean; + onSelect: () => void; + title: string; + subtitle?: string; + badge?: string; +}) { + return ( + + ); +} + +function describeFs(fs: FeatureSet): string { + switch (fs.feature_set_type) { + case 'default': + return 'The auto-seeded fallback set for this space'; + case 'custom': + return `${fs.members.length} member${fs.members.length === 1 ? '' : 's'}`; + default: + return ''; + } +} diff --git a/apps/desktop/src/features/workspaces/WorkspacesPage.tsx b/apps/desktop/src/features/workspaces/WorkspacesPage.tsx index 839bde9..a036da2 100644 --- a/apps/desktop/src/features/workspaces/WorkspacesPage.tsx +++ b/apps/desktop/src/features/workspaces/WorkspacesPage.tsx @@ -1,240 +1,1899 @@ -import { useCallback, useEffect, useState } from 'react'; -import { FolderOpen, Loader2, Plus, Trash2 } from 'lucide-react'; -import { Button, Card, CardContent, CardHeader, CardTitle, useToast, ToastContainer } from '@mcpmux/ui'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { listen } from '@tauri-apps/api/event'; +import { open as openDialog } from '@tauri-apps/plugin-dialog'; +import { + AlertCircle, + Check, + ChevronDown, + ChevronRight, + FileText, + FolderOpen, + FolderSearch, + Layers, + Loader2, + MessageSquare, + Package, + Plus, + Radio, + RefreshCw, + Search, + Server as ServerIcon, + Trash2, + Wrench, + X, +} from 'lucide-react'; +import { + Button, + Card, + CardContent, + useToast, + ToastContainer, + useConfirm, +} from '@mcpmux/ui'; import { - listWorkspaceBindingsForSpace, createWorkspaceBinding, deleteWorkspaceBinding, + getWorkspaceEffectiveFeatures, + listReportedWorkspaceRoots, + listWorkspaceBindings, + updateWorkspaceBinding, + validateWorkspaceRoot, + type EffectiveFeature, type WorkspaceBinding, + type WorkspaceBindingInput, + type WorkspaceEffectiveFeatures, } from '@/lib/api/workspaceBindings'; -import { listFeatureSetsBySpace, type FeatureSet } from '@/lib/api/featureSets'; -import { useViewSpace } from '@/stores'; +import { listFeatureSets, type FeatureSet } from '@/lib/api/featureSets'; +import { useSpaces } from '@/stores'; +import type { Space } from '@/lib/api/spaces'; /** - * Workspaces page — CRUD for WorkspaceBinding (resolver v2, middle tier). + * Workspaces page. + * + * Mirrors the Clients page's shape for visual consistency: + * • Header: title + subtitle + refresh, followed by a single large search. + * • Content: responsive cards grid inside a max-w-[2000px] wrapper. + * • Inspector: fixed-right side panel with a `fixed inset-0` backdrop- + * blur dim + `animate-in slide-in-from-right` entrance. * - * A binding maps a normalized workspace root path to a FeatureSet. When an - * MCP client reports roots via the MCP `roots` capability, the gateway's - * FeatureSetResolver matches the longest-prefix binding for the client's - * Space and uses that FS — unless the client has an explicit pin, which - * always wins. + * Each card is a workspace entry, unioning bindings and live reported roots + * (dedup'd by normalized path). Status is conveyed with a corner dot + pill: + * • LIVE + unmapped → amber + * • LIVE + mapped → emerald + * • OFFLINE + mapped → neutral */ + +type EntryKind = 'unmapped-live' | 'mapped-live' | 'mapped-offline'; +interface Entry { + id: string; + kind: EntryKind; + root: string; + binding: WorkspaceBinding | null; + isLive: boolean; +} +type Selected = { mode: 'new' } | { mode: 'entry'; id: string }; + export function WorkspacesPage() { - const viewSpace = useViewSpace(); + const spaces = useSpaces(); const [bindings, setBindings] = useState([]); + const [reportedRoots, setReportedRoots] = useState([]); const [featureSets, setFeatureSets] = useState([]); const [isLoading, setIsLoading] = useState(true); + const [isRefreshing, setIsRefreshing] = useState(false); const [error, setError] = useState(null); - const { toasts, success, error: showError } = useToast(); + const { toasts, success, error: showError, dismiss } = useToast(); + const { confirm, ConfirmDialogElement } = useConfirm(); - // Create form state - const [showForm, setShowForm] = useState(false); - const [isCreating, setIsCreating] = useState(false); - const [formRoot, setFormRoot] = useState(''); - const [formFeatureSetId, setFormFeatureSetId] = useState(''); + const [selected, setSelected] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [filter, setFilter] = useState<'all' | 'live' | 'unmapped'>('all'); - const loadData = useCallback(async (spaceId?: string) => { - setIsLoading(true); + const loadData = useCallback(async () => { setError(null); try { - if (!spaceId) { - setBindings([]); - setFeatureSets([]); - return; - } - const [b, fs] = await Promise.all([ - listWorkspaceBindingsForSpace(spaceId), - listFeatureSetsBySpace(spaceId), + const [b, fs, roots] = await Promise.all([ + listWorkspaceBindings(), + listFeatureSets(), + listReportedWorkspaceRoots().catch(() => [] as string[]), ]); setBindings(b); setFeatureSets(fs); + setReportedRoots(roots); } catch (e) { setError(e instanceof Error ? e.message : String(e)); - } finally { - setIsLoading(false); } }, []); useEffect(() => { - loadData(viewSpace?.id); - }, [viewSpace?.id, loadData]); + setIsLoading(true); + void loadData().finally(() => setIsLoading(false)); + }, [loadData]); - const handleCreate = async () => { - if (!viewSpace) return; - if (!formRoot.trim() || !formFeatureSetId) { - showError('Missing fields', 'Workspace root and FeatureSet are both required.'); - return; - } - setIsCreating(true); + // Refresh the list whenever a session reports (or changes) its roots. + useEffect(() => { + const un = listen('session-roots-changed', () => { + void loadData(); + }); + return () => { + un.then((fn) => fn()); + }; + }, [loadData]); + + const refresh = async () => { + setIsRefreshing(true); try { - const created = await createWorkspaceBinding( - viewSpace.id, - formRoot.trim(), - formFeatureSetId - ); - setBindings((prev) => [...prev, created]); - setFormRoot(''); - setFormFeatureSetId(''); - setShowForm(false); - success('Binding created', created.workspace_root); - } catch (e) { - showError('Failed to create binding', e instanceof Error ? e.message : String(e)); + await loadData(); } finally { - setIsCreating(false); + setIsRefreshing(false); } }; + const bindingsByRoot = useMemo(() => { + const m = new Map(); + for (const b of bindings) m.set(b.workspace_root.toLowerCase(), b); + return m; + }, [bindings]); + const fsById = useMemo(() => { + const m = new Map(); + for (const f of featureSets) m.set(f.id, f); + return m; + }, [featureSets]); + const spaceById = useMemo(() => { + const m = new Map(); + for (const s of spaces) m.set(s.id, s); + return m; + }, [spaces]); + + /** + * The system's routing fallback: the `is_default` Space plus that Space's + * Default FeatureSet. Sessions whose reported root has no binding resolve + * here. We compute it once and pass it down so EntryCard can show the + * effective FS on every row, including unmapped ones. + */ + const fallback = useMemo(() => { + const space = spaces.find((s) => s.is_default) ?? spaces[0] ?? null; + if (!space) return null; + const fs = + featureSets.find( + (f) => f.space_id === space.id && f.feature_set_type === 'default' + ) ?? null; + return { space, fs }; + }, [spaces, featureSets]); + + /** + * Unified list: live-reported roots come first (unmapped amber, then + * mapped emerald), then persisted bindings whose clients aren't live. + */ + const entries: Entry[] = useMemo(() => { + const list: Entry[] = []; + const seen = new Set(); + for (const root of reportedRoots) { + const key = root.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + const binding = bindingsByRoot.get(key) ?? null; + list.push({ + id: binding?.id ?? `live:${root}`, + kind: binding ? 'mapped-live' : 'unmapped-live', + root, + binding, + isLive: true, + }); + } + for (const b of bindings) { + const key = b.workspace_root.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + list.push({ + id: b.id, + kind: 'mapped-offline', + root: b.workspace_root, + binding: b, + isLive: false, + }); + } + const rank: Record = { + 'unmapped-live': 0, + 'mapped-live': 1, + 'mapped-offline': 2, + }; + return list.sort((a, b) => { + const o = rank[a.kind] - rank[b.kind]; + return o !== 0 ? o : a.root.localeCompare(b.root); + }); + }, [bindings, bindingsByRoot, reportedRoots]); + + const filtered = useMemo(() => { + const q = searchQuery.trim().toLowerCase(); + return entries.filter((e) => { + if (filter === 'live' && !e.isLive) return false; + if (filter === 'unmapped' && e.kind !== 'unmapped-live') return false; + if (!q) return true; + const spaceName = e.binding ? spaceById.get(e.binding.space_id)?.name ?? '' : ''; + const fsName = e.binding ? fsById.get(e.binding.feature_set_id)?.name ?? '' : ''; + return ( + e.root.toLowerCase().includes(q) || + spaceName.toLowerCase().includes(q) || + fsName.toLowerCase().includes(q) + ); + }); + }, [entries, searchQuery, filter, spaceById, fsById]); + + const counts = useMemo(() => { + let live = 0; + let unmapped = 0; + for (const e of entries) { + if (e.isLive) live++; + if (e.kind === 'unmapped-live') unmapped++; + } + return { all: entries.length, live, unmapped }; + }, [entries]); + + const selectedEntry: Entry | null = + selected?.mode === 'entry' ? entries.find((e) => e.id === selected.id) ?? null : null; + const selectedIsNew = selected?.mode === 'new'; + const panelOpen = selected !== null; + + const handleCreate = async (input: WorkspaceBindingInput): Promise => { + const created = await createWorkspaceBinding(input); + setBindings((prev) => + [...prev, created].sort((a, b) => a.workspace_root.localeCompare(b.workspace_root)) + ); + success('Binding saved', created.workspace_root); + return created; + }; + + const handleUpdate = async (id: string, input: WorkspaceBindingInput) => { + const updated = await updateWorkspaceBinding(id, input); + setBindings((prev) => + prev + .map((b) => (b.id === id ? updated : b)) + .sort((a, b) => a.workspace_root.localeCompare(b.workspace_root)) + ); + success('Binding updated', updated.workspace_root); + }; + const handleDelete = async (binding: WorkspaceBinding) => { + const ok = await confirm({ + title: 'Remove binding', + message: `Sessions matching "${binding.workspace_root}" will fall back to the default Space. You can recreate the binding anytime.`, + confirmLabel: 'Remove', + variant: 'danger', + }); + if (!ok) return; try { await deleteWorkspaceBinding(binding.id); setBindings((prev) => prev.filter((b) => b.id !== binding.id)); + setSelected(null); success('Binding removed', binding.workspace_root); } catch (e) { showError('Failed to remove binding', e instanceof Error ? e.message : String(e)); } }; - const featureSetName = (id: string) => - featureSets.find((fs) => fs.id === id)?.name ?? id; + return ( +
    +
    +
    +
    +
    +

    + Workspaces +

    +

    + Each binding tells mcpmux which Space and feature set a folder routes into. + Folders without a binding fall back to the default Space. +

    +
    +
    + + +
    +
    + +
    +
    + + setSearchQuery(e.target.value)} + className="w-full pl-12 pr-4 py-3 text-base bg-[rgb(var(--surface))] border border-[rgb(var(--border))] rounded-xl focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-all" + data-testid="workspace-binding-search" + /> +
    + +
    +
    +
    + + {error && ( +
    +
    + {error} +
    +
    + )} + +
    +
    + {isLoading ? ( +
    + +
    + ) : filtered.length === 0 ? ( + 0} + hasFilter={searchQuery.length > 0 || filter !== 'all'} + onCreate={() => setSelected({ mode: 'new' })} + /> + ) : ( +
    + {filtered.map((entry) => { + const isSelected = + selected?.mode === 'entry' && selected.id === entry.id; + // For mapped entries: trust the binding. For unmapped: fall + // back to the system's default Space + its Default FS so + // every card answers "what tools does this folder see?". + const resolvedSpaceName = entry.binding + ? spaceById.get(entry.binding.space_id)?.name + : fallback?.space.name; + const resolvedFsName = entry.binding + ? fsById.get(entry.binding.feature_set_id)?.name + : fallback?.fs?.name; + return ( + setSelected({ mode: 'entry', id: entry.id })} + /> + ); + })} +
    + )} +
    +
    + + {panelOpen && ( + <> +
    setSelected(null)} + /> + setSelected(null)} + onSubmit={async (input) => { + if (selectedEntry?.binding) { + await handleUpdate(selectedEntry.binding.id, input); + } else { + const created = await handleCreate(input); + setSelected({ mode: 'entry', id: created.id }); + } + }} + onDelete={async () => { + if (selectedEntry?.binding) await handleDelete(selectedEntry.binding); + }} + onError={(msg) => showError('Could not save', msg)} + /> + + )} + + + {ConfirmDialogElement} +
    + ); +} + +// --------------------------------------------------------------------------- +// Filter segmented control +// --------------------------------------------------------------------------- + +function SegmentedFilter({ + value, + onChange, + options, +}: { + value: T; + onChange: (v: T) => void; + options: Array<{ value: T; label: string; count?: number }>; +}) { + return ( +
    + {options.map((o) => { + const active = o.value === value; + return ( + + ); + })} +
    + ); +} + +// --------------------------------------------------------------------------- +// Entry card — matches Clients page card anatomy (56×56 icon, 3xl size, chips) +// --------------------------------------------------------------------------- + +function EntryCard({ + entry, + spaceName, + fsName, + selected, + onClick, +}: { + entry: Entry; + spaceName: string | undefined; + fsName: string | undefined; + selected: boolean; + onClick: () => void; +}) { + const tone = + entry.kind === 'unmapped-live' + ? 'amber' + : entry.kind === 'mapped-live' + ? 'emerald' + : 'neutral'; + + return ( + + +
    +
    +
    + +
    + {entry.isLive && ( + + )} +
    +
    +
    + {entry.kind === 'unmapped-live' && Unmapped} + {entry.kind === 'mapped-offline' && Offline} + {entry.kind === 'mapped-live' && Live} +
    +

    + {entry.root} +

    +
    +
    + +
    +
    + Routes to + {fsName ?? '—'} + in + {spaceName ?? '—'} + {!entry.binding && ( + + via fallback + + )} +
    +
    +
    +
    + ); +} + +function Pill({ + children, + tone, +}: { + children: React.ReactNode; + tone: 'amber' | 'emerald' | 'neutral'; +}) { + const cls = + tone === 'amber' + ? 'bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-400 border-amber-200/80 dark:border-amber-800/60' + : tone === 'emerald' + ? 'bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-400 border-emerald-200/80 dark:border-emerald-800/60' + : 'bg-[rgb(var(--surface))] text-[rgb(var(--muted))] border-[rgb(var(--border-subtle))]'; + return ( + + {children} + + ); +} + +function Chip({ + children, + tone, +}: { + children: React.ReactNode; + tone: 'primary' | 'neutral'; +}) { + const styles = + tone === 'primary' + ? 'bg-primary-50 dark:bg-primary-900/20 text-primary-700 dark:text-primary-300 border-primary-200 dark:border-primary-800/60' + : 'bg-[rgb(var(--surface))] border-[rgb(var(--border-subtle))] text-[rgb(var(--foreground))]'; + return ( + + {children} + + ); +} + +// --------------------------------------------------------------------------- +// CollapsibleSection — premium expandable card matching the FeatureSetPanel +// pattern (which the user already considers premium). border-2, gradient +// headers when expanded, icon-in-colored-box that fills white-on-tone when +// active, bold semibold titles. Used for both "Mapping" (terracotta) and +// "Effective features" (purple). +// --------------------------------------------------------------------------- + +type SectionTone = 'primary' | 'purple'; + +interface SectionToneSpec { + /** Header gradient bg when expanded. */ + gradientOpen: string; + /** Icon container — collapsed (tinted bg). */ + iconQuiet: string; + /** Icon container — expanded (solid fill, white glyph). */ + iconActive: string; + /** Badge style when expanded (count chip). */ + badgeOpen: string; +} + +const SECTION_TONES: Record = { + primary: { + gradientOpen: + 'bg-gradient-to-r from-primary-50 to-primary-100/50 dark:from-primary-900/20 dark:to-primary-800/10', + iconQuiet: + 'bg-primary-100 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400', + iconActive: 'bg-primary-500 text-white shadow-sm shadow-primary-500/30', + badgeOpen: + 'bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 border border-primary-300/70 dark:border-primary-700/70', + }, + purple: { + gradientOpen: + 'bg-gradient-to-r from-purple-50 to-pink-50 dark:from-purple-900/20 dark:to-pink-900/15', + iconQuiet: + 'bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400', + iconActive: 'bg-purple-500 text-white shadow-sm shadow-purple-500/30', + badgeOpen: + 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 border border-purple-300/70 dark:border-purple-700/70', + }, +}; + +function CollapsibleSection({ + icon, + tone = 'primary', + title, + subtitle, + defaultOpen = true, + badge, + headerExtra, + testId, + children, +}: { + icon: React.ReactNode; + tone?: SectionTone; + title: string; + subtitle?: React.ReactNode; + defaultOpen?: boolean; + badge?: number; + /** Small element rendered next to the title (e.g. save status). */ + headerExtra?: React.ReactNode; + testId?: string; + children: React.ReactNode; +}) { + const [open, setOpen] = useState(defaultOpen); + const t = SECTION_TONES[tone] ?? SECTION_TONES.primary; + + return ( +
    + + + {open && ( +
    + {children} +
    + )} +
    + ); +} + +// --------------------------------------------------------------------------- +// Inspector side panel +// --------------------------------------------------------------------------- + +type SaveStatus = + | { kind: 'idle' } + | { kind: 'saving' } + | { kind: 'saved' } + | { kind: 'error'; message: string }; + +function InspectorPanel({ + entry, + isNew, + spaces, + featureSets, + onClose, + onSubmit, + onDelete, + onError, +}: { + entry: Entry | null; + isNew: boolean; + spaces: Space[]; + featureSets: FeatureSet[]; + onClose: () => void; + onSubmit: (input: WorkspaceBindingInput) => Promise; + onDelete: () => Promise; + onError: (msg: string) => void; +}) { + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose]); + + const isMapped = !!entry?.binding; + const mode: 'create' | 'edit' | 'create-from-live' = isNew + ? 'create' + : isMapped + ? 'edit' + : 'create-from-live'; + const title = isNew ? 'New binding' : isMapped ? 'Binding' : 'Configure workspace'; + const subtitle = isNew + ? 'Tell mcpmux how a folder should route.' + : entry?.root ?? ''; + + // Auto-save status drives the small pill in the Mapping section header. + const [saveStatus, setSaveStatus] = useState({ kind: 'idle' }); + + // Effective-features count drives the badge in the section header so the + // user can see scale without expanding. + const [effectiveTotal, setEffectiveTotal] = useState(null); + + return ( +
    +
    +
    +
    +
    + +
    +
    +
    + {!isNew && entry?.isLive && Live} + {!isNew && entry && !isMapped && Unmapped} + {!isNew && entry && isMapped && !entry.isLive && Offline} +
    +

    {title}

    +

    + {subtitle} +

    +
    +
    + +
    +
    + +
    + } + tone="primary" + title="Mapping" + subtitle={ + mode === 'create' + ? 'Pick the FeatureSet this folder routes through.' + : mode === 'create-from-live' + ? 'Configure routing for this live workspace.' + : isMapped && entry?.binding + ? `Routes to ${ + featureSets.find((f) => f.id === entry.binding!.feature_set_id)?.name ?? '—' + } in ${ + spaces.find((s) => s.id === entry.binding!.space_id)?.name ?? '—' + }` + : 'Changes save automatically.' + } + defaultOpen={isNew || !isMapped} + headerExtra={mode === 'edit' ? : null} + testId="workspace-mapping-section" + > + + + + {entry && !isNew && ( + } + tone="purple" + title="Effective Features" + subtitle="Tools, prompts, and resources this folder currently sees" + defaultOpen={true} + badge={effectiveTotal ?? undefined} + testId="workspace-effective-features-section" + > + + + )} +
    + + {entry?.binding && ( +
    + +
    + )} +
    + ); +} + +function SaveStatusPill({ status }: { status: SaveStatus }) { + if (status.kind === 'idle') return null; + const base = + 'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider border'; + if (status.kind === 'saving') { + return ( + + + Saving + + ); + } + if (status.kind === 'saved') { + return ( + + + Saved + + ); + } + return ( + + + Error + + ); +} + +// --------------------------------------------------------------------------- +// Effective features — what tools / prompts / resources this folder sees +// right now, grouped by backend server so the user can see at a glance +// "github is fine, but my-search is disconnected so 4 tools are dark." +// Mirrors the expandable-section pattern from the old Clients-page panel. +// --------------------------------------------------------------------------- + +interface ServerGroup { + server_id: string; + server_alias: string; + server_status: EffectiveFeature['server_status']; + available: boolean; + tools: EffectiveFeature[]; + prompts: EffectiveFeature[]; + resources: EffectiveFeature[]; + total: number; + unavailable_total: number; +} + +function buildServerGroups(data: WorkspaceEffectiveFeatures): ServerGroup[] { + const map = new Map(); + const place = (item: EffectiveFeature, kind: 'tool' | 'prompt' | 'resource') => { + let g = map.get(item.server_id); + if (!g) { + g = { + server_id: item.server_id, + server_alias: item.server_alias ?? item.server_id, + // Per-feature status is the same across a server (status comes + // from the server, not the feature) — pick the first one we see. + server_status: item.server_status, + available: item.available, + tools: [], + prompts: [], + resources: [], + total: 0, + unavailable_total: 0, + }; + map.set(item.server_id, g); + } + if (kind === 'tool') g.tools.push(item); + else if (kind === 'prompt') g.prompts.push(item); + else g.resources.push(item); + g.total += 1; + if (!item.available) g.unavailable_total += 1; + }; + for (const t of data.tools) place(t, 'tool'); + for (const p of data.prompts) place(p, 'prompt'); + for (const r of data.resources) place(r, 'resource'); + // Sort: connected first, then by alias. + return Array.from(map.values()).sort((a, b) => { + if (a.available !== b.available) return a.available ? -1 : 1; + return a.server_alias.localeCompare(b.server_alias); + }); +} + +/** + * Body of the Effective-features collapsible. The outer card / header / + * chevron lives in `CollapsibleSection`; this component just renders the + * resolved-to summary and the per-server expandable groups. + * + * Reports the configured-features total to the parent via `onTotalChange` + * so the section header can show a count badge without re-fetching. + */ +function EffectiveFeaturesContent({ + root, + onTotalChange, +}: { + root: string; + onTotalChange?: (total: number | null) => void; +}) { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const [openServers, setOpenServers] = useState>(() => new Set()); + + useEffect(() => { + let cancelled = false; + // Standard fetch-on-prop-change pattern: synchronous resets ensure the + // UI doesn't show stale data from a previous root while a new fetch + // is in flight. The lint rule is overly strict for this idiom. + /* eslint-disable react-hooks/set-state-in-effect */ + setLoading(true); + setError(null); + onTotalChange?.(null); + /* eslint-enable react-hooks/set-state-in-effect */ + void getWorkspaceEffectiveFeatures(root) + .then((d) => { + if (cancelled) return; + setData(d); + const total = d.tools.length + d.prompts.length + d.resources.length; + onTotalChange?.(total); + const groups = buildServerGroups(d); + if (groups.length > 0) { + setOpenServers(new Set([groups[0].server_id])); + } + }) + .catch((e: unknown) => { + if (!cancelled) setError(typeof e === 'string' ? e : String(e)); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [root, onTotalChange]); + + // Re-fetch on binding / server-status changes so the panel stays honest + // without the user reopening it. + useEffect(() => { + let cancelled = false; + const reload = () => { + void getWorkspaceEffectiveFeatures(root) + .then((d) => { + if (cancelled) return; + setData(d); + onTotalChange?.(d.tools.length + d.prompts.length + d.resources.length); + }) + .catch(() => { + /* ignore — initial load already surfaced any error */ + }); + }; + const unBinding = listen('workspace-binding-changed', reload); + const unServer = listen('server-status', reload); + return () => { + cancelled = true; + unBinding.then((fn) => fn()); + unServer.then((fn) => fn()); + }; + }, [root, onTotalChange]); + + // All hooks must run on every render — keep them above any early + // returns so React's hook-order invariant holds. + const groups = useMemo(() => (data ? buildServerGroups(data) : []), [data]); + const totalCount = data ? data.tools.length + data.prompts.length + data.resources.length : 0; + const availableCount = useMemo( + () => groups.reduce((acc, g) => acc + (g.total - g.unavailable_total), 0), + [groups] + ); - if (!viewSpace) { + const toggleServer = (id: string) => { + setOpenServers((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + if (loading && !data) { return ( -
    -

    Select a Space to manage workspace bindings.

    +
    +
    ); } + if (error) { + return ( +
    + + {error} +
    + ); + } + if (!data) return null; + + const allAvailable = totalCount > 0 && availableCount === totalCount; + const partialAvailable = availableCount > 0 && availableCount < totalCount; return ( -
    - toasts.find((t) => t.id === id)?.onClose(id)} - /> - -
    -
    -

    - Workspace Bindings -

    -

    - Bind a workspace folder to a FeatureSet. When an MCP client reports this - folder as one of its roots, the gateway uses the bound FeatureSet — unless - the client's access key has an explicit pin, which always wins. -

    +
    + {/* Resolution summary — bold pills showing what this folder + resolves to, plus a progress bar for availability. */} +
    +
    + + Resolves to + + + {data.feature_set_name} + + in + + {data.space_name} + + + {data.source === 'binding' ? 'binding' : 'fallback'} + +
    + + {/* Availability progress bar. Stays quiet (green) when all servers + are connected, leans amber when some are dim. */} +
    +
    + + {availableCount} + of + {totalCount} + available + + {totalCount > 0 && ( + + {allAvailable ? 'All ready' : partialAvailable ? 'Partial' : 'Offline'} + + )} +
    +
    +
    0 ? `${(availableCount / totalCount) * 100}%` : '0%', + }} + /> +
    -
    - {error && ( - - {error} - + {/* Server-grouped feature list. */} + {groups.length === 0 ? ( +
    + +

    No features configured in this feature set yet.

    +
    + ) : ( +
    +
    + {groups.map((g) => ( + toggleServer(g.server_id)} + /> + ))} +
    +
    )} +
    + ); +} - {showForm && ( - - - New binding - - -
    - - setFormRoot(e.target.value)} - placeholder="/home/me/projects/android-app or D:\\work\\api" - className="w-full px-3 py-2 border border-[rgb(var(--border))] rounded bg-[rgb(var(--surface))] text-sm" - data-testid="workspace-binding-root-input" - /> -

    - Will be normalized: Windows drive letters are lowercased, trailing separators stripped, - and file:// URIs converted to paths. -

    -
    -
    - - + {availableCount}/{group.total} + + {issue && ( + + {issue.label} + + )}
    -
    - - + {/* Per-server progress bar — same treatment as FeatureSetPanel's + server rows so the visual language is consistent. */} +
    +
    0 + ? `${(availableCount / group.total) * 100}%` + : '0%', + }} + />
    - - +
    +
    +
    + + {open && ( +
    + + + +
    )} +
    + ); +} - {isLoading ? ( -
    - -
    - ) : bindings.length === 0 ? ( - - - -

    No workspace bindings yet

    -

    - Without a binding, the resolver falls back to the Space's active FeatureSet for - every roots-capable client. -

    -
    -
    - ) : ( -
    - {bindings.map((b) => ( - -
    -

    {b.workspace_root}

    -

    - → {featureSetName(b.feature_set_id)} -

    -
    - -
    - ))} + {label} + + {!item.available && ( + + unavailable + + )} +
    + {item.description && ( +

    + {item.description} +

    + )} +
    +
    + ))} + + ); +} + +function getFeatureTypeIcon(type: 'tool' | 'prompt' | 'resource') { + switch (type) { + case 'tool': + return ; + case 'prompt': + return ; + case 'resource': + return ; + } +} + +function getFeatureTypeColor(type: 'tool' | 'prompt' | 'resource'): string { + switch (type) { + case 'tool': + return 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300'; + case 'prompt': + return 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300'; + case 'resource': + return 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300'; + } +} + +/** + * Translate a server status into a small UI annotation — but ONLY when + * the status warrants attention. The healthy "connected" path returns + * null so the row stays quiet. + */ +function serverStatusIssue( + status: EffectiveFeature['server_status'] +): { label: string; tone: 'red' | 'amber' | 'muted' } | null { + switch (status) { + case 'connected': + return null; + case 'connecting': + return { label: 'Connecting', tone: 'amber' }; + case 'authenticating': + return { label: 'Authenticating', tone: 'amber' }; + case 'refreshing': + return { label: 'Refreshing', tone: 'amber' }; + case 'auth_required': + return { label: 'Auth needed', tone: 'amber' }; + case 'error': + return { label: 'Error', tone: 'red' }; + case 'disconnected': + return { label: 'Disconnected', tone: 'muted' }; + case 'unknown': + default: + return { label: 'Offline', tone: 'muted' }; + } +} + +// --------------------------------------------------------------------------- +// Binding form +// --------------------------------------------------------------------------- + +function BindingForm({ + mode, + spaces, + featureSets, + initial, + prefillRoot, + onCancel, + onSubmit, + onError, + onSaveStatusChange, +}: { + mode: 'create' | 'edit' | 'create-from-live'; + spaces: Space[]; + featureSets: FeatureSet[]; + initial?: WorkspaceBinding | null; + prefillRoot?: string; + onCancel: () => void; + onSubmit: (input: WorkspaceBindingInput) => Promise; + onError: (message: string) => void; + /** Surfaced upward so the section header can show a Saving / Saved pill. */ + onSaveStatusChange?: (status: SaveStatus) => void; +}) { + const defaultSpaceId = useMemo( + () => spaces.find((s) => s.is_default)?.id ?? spaces[0]?.id ?? '', + [spaces] + ); + + const rootRef = useRef(null); + const [root, setRoot] = useState(initial?.workspace_root ?? prefillRoot ?? ''); + const [spaceId, setSpaceId] = useState(initial?.space_id ?? defaultSpaceId); + const [fsId, setFsId] = useState(initial?.feature_set_id ?? ''); + const [submitting, setSubmitting] = useState(false); + const isEdit = mode === 'edit'; + + // Live validation of the workspace_root field. Edit + create-from-live + // modes already have a trusted root (edit: the persisted one; create-from- + // live: came from the MCP client), so we skip validation for those — only + // manual creates / edits to the path need the live check. + const [rootValidation, setRootValidation] = useState< + | { state: 'idle' } + | { state: 'checking' } + | { state: 'ok'; normalized: string } + | { state: 'error'; reason: string } + >({ state: 'idle' }); + const validationSeq = useRef(0); + + const rootEditable = mode !== 'create-from-live'; + + useEffect(() => { + if (!rootEditable) { + setRootValidation({ state: 'ok', normalized: root }); + return; + } + if (!root.trim()) { + setRootValidation({ state: 'idle' }); + return; + } + // Debounce a little so we don't hammer the IPC on every keystroke. + const seq = ++validationSeq.current; + setRootValidation({ state: 'checking' }); + const handle = setTimeout(() => { + void validateWorkspaceRoot(root) + .then((normalized) => { + if (validationSeq.current !== seq) return; + setRootValidation({ state: 'ok', normalized }); + }) + .catch((e: unknown) => { + if (validationSeq.current !== seq) return; + const reason = typeof e === 'string' ? e : String(e); + setRootValidation( + reason === '' + ? { state: 'idle' } + : { state: 'error', reason } + ); + }); + }, 180); + return () => clearTimeout(handle); + }, [root, rootEditable]); + + useEffect(() => { + if (mode === 'create') rootRef.current?.focus(); + }, [mode]); + + const availableFs = useMemo( + () => featureSets.filter((f) => f.space_id === spaceId && !f.is_deleted), + [featureSets, spaceId] + ); + + useEffect(() => { + if (availableFs.length === 0) return; + if (!availableFs.some((f) => f.id === fsId)) { + const fallback = + availableFs.find((f) => f.feature_set_type === 'default') ?? availableFs[0]; + setFsId(fallback.id); + } + }, [availableFs, fsId]); + + const canSubmit = + !submitting && + !!spaceId && + !!fsId && + (rootValidation.state === 'ok' || !rootEditable); + + const handleSubmit = async () => { + if (!root.trim()) { + onError('Workspace root is required.'); + return; + } + if (rootValidation.state === 'error') { + onError(rootValidation.reason); + return; + } + if (!spaceId) { + onError('Pick a Space.'); + return; + } + if (!fsId) { + onError('Pick a feature set.'); + return; + } + setSubmitting(true); + try { + await onSubmit({ + workspace_root: root.trim(), + space_id: spaceId, + feature_set_id: fsId, + }); + } catch (e) { + onError(e instanceof Error ? e.message : String(e)); + } finally { + setSubmitting(false); + } + }; + + // Auto-save in edit mode — debounced, sequence-numbered to discard stale + // saves, and a no-op while the form's contents still match the initial + // values so just opening the panel doesn't fire a write. + const saveSeqRef = useRef(0); + const savedTimerRef = useRef | null>(null); + useEffect(() => { + if (!isEdit || !initial) return; + const same = + root.trim() === initial.workspace_root && + spaceId === initial.space_id && + fsId === initial.feature_set_id; + if (same) return; + if (!canSubmit) return; + const seq = ++saveSeqRef.current; + onSaveStatusChange?.({ kind: 'idle' }); + const handle = setTimeout(async () => { + if (saveSeqRef.current !== seq) return; + onSaveStatusChange?.({ kind: 'saving' }); + setSubmitting(true); + try { + await onSubmit({ + workspace_root: root.trim(), + space_id: spaceId, + feature_set_id: fsId, + }); + if (saveSeqRef.current !== seq) return; + onSaveStatusChange?.({ kind: 'saved' }); + if (savedTimerRef.current) clearTimeout(savedTimerRef.current); + savedTimerRef.current = setTimeout(() => { + onSaveStatusChange?.({ kind: 'idle' }); + }, 1800); + } catch (e) { + if (saveSeqRef.current !== seq) return; + const msg = e instanceof Error ? e.message : String(e); + onSaveStatusChange?.({ kind: 'error', message: msg }); + onError(msg); + } finally { + setSubmitting(false); + } + }, 600); + return () => clearTimeout(handle); + }, [ + isEdit, + initial, + root, + spaceId, + fsId, + canSubmit, + onSubmit, + onError, + onSaveStatusChange, + ]); + + const submitLabel = + mode === 'create-from-live' ? 'Save binding' : 'Create binding'; + + return ( +
    + +
    + setRoot(e.target.value)} + readOnly={!rootEditable} + placeholder="Pick a folder, or paste an absolute path" + className={[ + 'flex-1 min-w-0 px-3 py-2 rounded-lg text-sm font-mono focus:outline-none focus:ring-2', + !rootEditable + ? 'bg-[rgb(var(--background))] border border-[rgb(var(--border-subtle))] text-[rgb(var(--muted))] cursor-not-allowed focus:ring-primary-500' + : rootValidation.state === 'error' + ? 'bg-[rgb(var(--background))] border border-red-500/60 focus:ring-red-500 focus:border-red-500' + : 'bg-[rgb(var(--background))] border border-[rgb(var(--border))] focus:ring-primary-500 focus:border-primary-500', + ].join(' ')} + data-testid="workspace-binding-root-input" + /> + {rootEditable && ( + + )} +
    + +
    + + + ({ + value: s.id, + label: s.is_default ? `${s.name} · default` : s.name, + icon: s.icon ?? undefined, + }))} + testId="workspace-binding-space" + /> + + + + ({ + value: f.id, + label: f.feature_set_type === 'default' ? `${f.name} · builtin` : f.name, + icon: f.icon ?? undefined, + }))} + disabled={!spaceId || availableFs.length === 0} + testId="workspace-binding-fs" + /> + + + {!isEdit && ( +
    + +
    )}
    ); } + +/** + * Inline hint under the workspace_root input. Three visual states: + * • idle — neutral hint about normalization rules + * • checking — subtle spinner + "Checking…" + * • ok — if the normalized form differs from the raw input, + * show it as a preview so the user sees exactly what + * gets saved (drive letter lowercased, URI scheme + * stripped, slashes flipped, etc.). Otherwise silent. + * • error — red message with the server's explanation + */ +function RootValidationHint({ + state, + editable, + originalValue, +}: { + state: + | { state: 'idle' } + | { state: 'checking' } + | { state: 'ok'; normalized: string } + | { state: 'error'; reason: string }; + editable: boolean; + originalValue: string; +}) { + if (!editable) { + return ( +

    + Reported by the connected client — the path isn't editable. +

    + ); + } + if (state.state === 'idle') { + return ( +

    + Click Browse to pick a folder, or paste an absolute path. Accepts{' '} + /unix, C:\windows, and file:// forms. +

    + ); + } + if (state.state === 'checking') { + return ( +

    + + Checking… +

    + ); + } + if (state.state === 'error') { + return ( +

    + {state.reason} +

    + ); + } + // ok + const changed = state.normalized !== originalValue.trim(); + if (!changed) { + return ( +

    + Ready to save. +

    + ); + } + return ( +

    + Will be saved as{' '} + + {state.normalized} + + . +

    + ); +} + +function FormField({ + label, + hint, + children, +}: { + label: string; + hint?: string; + children: React.ReactNode; +}) { + return ( +
    + + {children} + {hint &&

    {hint}

    } +
    + ); +} + +function Picker({ + value, + onChange, + options, + placeholder, + disabled, + testId, +}: { + value: string; + onChange: (value: string) => void; + options: Array<{ value: string; label: string; icon?: string }>; + placeholder: string; + disabled?: boolean; + testId?: string; +}) { + return ( +
    + + +
    + ); +} + +// --------------------------------------------------------------------------- +// Empty state +// --------------------------------------------------------------------------- + +function EmptyState({ + hasAny, + hasFilter, + onCreate, +}: { + hasAny: boolean; + hasFilter: boolean; + onCreate: () => void; +}) { + if (hasFilter && hasAny) { + return ( + + + +

    No workspaces match

    +

    + Try adjusting the search or filter. +

    +
    +
    + ); + } + return ( + + +
    + +
    +

    Nothing to show yet

    +

    + When a connected MCP client reports a workspace root, it will appear here live. + You can also add a binding ahead of time for a folder you care about. +

    + +
    +
    + ); +} diff --git a/apps/desktop/src/features/workspaces/index.ts b/apps/desktop/src/features/workspaces/index.ts index b4710de..c3f2429 100644 --- a/apps/desktop/src/features/workspaces/index.ts +++ b/apps/desktop/src/features/workspaces/index.ts @@ -1 +1,2 @@ export { WorkspacesPage } from './WorkspacesPage'; +export { WorkspaceBindingSheet } from './WorkspaceBindingSheet'; diff --git a/apps/desktop/src/hooks/useDataSync.ts b/apps/desktop/src/hooks/useDataSync.ts index 5d59f14..140ce8e 100644 --- a/apps/desktop/src/hooks/useDataSync.ts +++ b/apps/desktop/src/hooks/useDataSync.ts @@ -1,6 +1,6 @@ import { useEffect } from 'react'; import { useAppStore } from '@/stores/appStore'; -import { listSpaces, getActiveSpace } from '@/lib/api/spaces'; +import { listSpaces } from '@/lib/api/spaces'; import { refreshOAuthTokensOnStartup } from '@/lib/api/gateway'; /** @@ -10,7 +10,6 @@ import { refreshOAuthTokensOnStartup } from '@/lib/api/gateway'; export function useDataSync() { const setSpaces = useAppStore((state) => state.setSpaces); const setLoading = useAppStore((state) => state.setLoading); - const setActiveSpaceInStore = useAppStore((state) => state.setActiveSpace); useEffect(() => { async function syncData() { @@ -26,27 +25,13 @@ export function useDataSync() { console.error('[useDataSync] OAuth token refresh failed (non-fatal):', error); } - // Fetch spaces and active space from backend console.log('[useDataSync] Calling listSpaces...'); const spaces = await listSpaces(); - console.log('[useDataSync] listSpaces returned:', spaces.length, 'spaces', spaces); + console.log('[useDataSync] listSpaces returned:', spaces.length, 'spaces'); - console.log('[useDataSync] Calling getActiveSpace...'); - const activeSpace = await getActiveSpace(); - console.log('[useDataSync] getActiveSpace returned:', activeSpace); - - console.log('[useDataSync] Setting spaces in store...'); + // setSpaces handles validating viewSpaceId and falling back to the + // is_default space when the persisted view space doesn't exist. setSpaces(spaces); - - // Set active space from backend - if (activeSpace) { - console.log('[useDataSync] Setting active space:', activeSpace.id); - setActiveSpaceInStore(activeSpace.id); - } else if (spaces.length > 0) { - // If no active space but we have spaces, set the first one - console.log('[useDataSync] No active space, using first space:', spaces[0].id); - setActiveSpaceInStore(spaces[0].id); - } } catch (error) { console.error('[useDataSync] Failed to sync:', error); } finally { @@ -56,5 +41,5 @@ export function useDataSync() { } syncData(); - }, [setSpaces, setLoading, setActiveSpaceInStore]); + }, [setSpaces, setLoading]); } diff --git a/apps/desktop/src/hooks/useSpaces.ts b/apps/desktop/src/hooks/useSpaces.ts index 3e3823b..8ed8b76 100644 --- a/apps/desktop/src/hooks/useSpaces.ts +++ b/apps/desktop/src/hooks/useSpaces.ts @@ -1,32 +1,28 @@ import { useState, useEffect, useCallback } from 'react'; -import { - Space, - listSpaces, - createSpace, - deleteSpace, - setActiveSpace, - getActiveSpace, -} from '@/lib/api/spaces'; +import { Space, listSpaces, createSpace, deleteSpace } from '@/lib/api/spaces'; /** * Hook for managing spaces (isolated environments). + * + * Note: there's no longer an "active space" concept — gateway routing is + * decided per reported workspace root via WorkspaceBinding, with the + * `is_default` Space as the fallback. The desktop UI still tracks which + * space the user is *viewing* via `viewSpaceId` in the Zustand store. */ export function useSpaces() { const [spaces, setSpaces] = useState([]); - const [activeSpace, setActiveSpaceState] = useState(null); + const [defaultSpace, setDefaultSpace] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - // Refresh the list of spaces const refresh = useCallback(async () => { try { setLoading(true); setError(null); - const [spacesList, active] = await Promise.all([listSpaces(), getActiveSpace()]); - + const spacesList = await listSpaces(); setSpaces(spacesList); - setActiveSpaceState(active); + setDefaultSpace(spacesList.find((s) => s.is_default) ?? null); } catch (e) { const message = e instanceof Error ? e.message : String(e); setError(message); @@ -36,12 +32,10 @@ export function useSpaces() { } }, []); - // Load spaces on mount useEffect(() => { refresh(); }, [refresh]); - // Create a new space const create = useCallback( async (name: string, icon?: string): Promise => { const space = await createSpace(name, icon); @@ -51,7 +45,6 @@ export function useSpaces() { [refresh] ); - // Delete a space const remove = useCallback( async (id: string): Promise => { await deleteSpace(id); @@ -60,23 +53,13 @@ export function useSpaces() { [refresh] ); - // Set the active space - const setActive = useCallback( - async (id: string): Promise => { - await setActiveSpace(id); - await refresh(); - }, - [refresh] - ); - return { spaces, - activeSpace, + defaultSpace, loading, error, refresh, create, remove, - setActive, }; } diff --git a/apps/desktop/src/lib/api/clients.ts b/apps/desktop/src/lib/api/clients.ts index 0c824d7..240ebd5 100644 --- a/apps/desktop/src/lib/api/clients.ts +++ b/apps/desktop/src/lib/api/clients.ts @@ -1,163 +1,45 @@ import { invoke } from '@tauri-apps/api/core'; /** - * A Client represents an AI assistant (Cursor, VS Code, Claude, etc.) + * A Client represents an AI assistant (Cursor, VS Code, Claude, etc.). + * + * Identity only — routing is decided at session time by the gateway's + * FeatureSetResolver (WorkspaceBinding → Space default FS), not per client. */ export interface Client { id: string; name: string; client_type: string; - connection_mode: 'locked' | 'follow_active' | 'ask_on_change'; - locked_space_id: string | null; - grants: Record; // space_id -> feature_set_ids (legacy, to be removed) - /** - * Resolver v2: Space this access key belongs to (chosen at approval). - * `null` on legacy pre-migration clients — resolver falls through to the - * default Space. - */ - pinned_space_id: string | null; - /** - * Resolver v2: explicit FS pin. `null` means "follow workspace binding / - * space active FS". - */ - pinned_feature_set_id: string | null; last_seen: string | null; } -/** - * Input for creating a client. - */ +/** Input for creating a client. */ export interface CreateClientInput { name: string; client_type: string; - connection_mode: string; - locked_space_id?: string; -} - -/** - * Input for updating client grants. - */ -export interface UpdateGrantsInput { - space_id: string; - feature_set_ids: string[]; } -/** - * List all clients. - */ +/** List all clients. */ export async function listClients(): Promise { return invoke('list_clients'); } -/** - * Get a client by ID. - */ +/** Get a client by ID. */ export async function getClient(id: string): Promise { return invoke('get_client', { id }); } -/** - * Create a new client. - */ +/** Create a new client. */ export async function createClient(input: CreateClientInput): Promise { return invoke('create_client', { input }); } -/** - * Delete a client. - */ +/** Delete a client. */ export async function deleteClient(id: string): Promise { return invoke('delete_client', { id }); } -/** - * Update client grants for a specific space (replaces existing). - */ -export async function updateClientGrants( - clientId: string, - input: UpdateGrantsInput -): Promise { - return invoke('update_client_grants', { clientId, input }); -} - -/** - * Get grants for a client in a specific space. - */ -export async function getClientGrants( - clientId: string, - spaceId: string -): Promise { - return invoke('get_client_grants', { clientId, spaceId }); -} - -/** - * Get all grants for a client across all spaces. - */ -export async function getAllClientGrants( - clientId: string -): Promise> { - return invoke('get_all_client_grants', { clientId }); -} - -/** - * Grant a specific feature set to a client. - */ -export async function grantFeatureSetToClient( - clientId: string, - spaceId: string, - featureSetId: string -): Promise { - return invoke('grant_feature_set_to_client', { clientId, spaceId, featureSetId }); -} - -/** - * Revoke a specific feature set from a client. - */ -export async function revokeFeatureSetFromClient( - clientId: string, - spaceId: string, - featureSetId: string -): Promise { - return invoke('revoke_feature_set_from_client', { clientId, spaceId, featureSetId }); -} - -/** - * Update client connection mode. - */ -export async function updateClientMode( - clientId: string, - mode: string, - lockedSpaceId?: string -): Promise { - return invoke('update_client_mode', { clientId, mode, lockedSpaceId }); -} - -/** - * Initialize preset clients (Cursor, VS Code, Claude). - */ +/** Initialize preset clients (Cursor, VS Code, Claude). */ export async function initPresetClients(): Promise { return invoke('init_preset_clients'); } - -/** - * Pin a client to a Space + optional FeatureSet (resolver v2). - * - * Precedence used by the gateway's FeatureSetResolver: - * 1. pinned_feature_set_id (this pin) → source = Pin - * 2. workspace binding matches a reported root → source = WorkspaceBinding - * 3. space.active_feature_set_id → source = SpaceActive - * - * Pass `pinnedFeatureSetId = undefined` to let the resolver fall through to - * workspace/space default. - */ -export async function updateClientPin( - clientId: string, - pinnedSpaceId: string, - pinnedFeatureSetId?: string | null -): Promise { - return invoke('update_client_pin', { - clientId, - pinnedSpaceId, - pinnedFeatureSetId: pinnedFeatureSetId ?? null, - }); -} diff --git a/apps/desktop/src/lib/api/featureSets.ts b/apps/desktop/src/lib/api/featureSets.ts index 95ef87d..191e740 100644 --- a/apps/desktop/src/lib/api/featureSets.ts +++ b/apps/desktop/src/lib/api/featureSets.ts @@ -1,9 +1,12 @@ import { invoke } from '@tauri-apps/api/core'; /** - * FeatureSet type determines how features are resolved. + * FeatureSet type. + * + * - `default`: auto-created per Space. Fallback when no WorkspaceBinding matches. + * - `custom`: user-defined. */ -export type FeatureSetType = 'all' | 'default' | 'server-all' | 'custom'; +export type FeatureSetType = 'default' | 'custom'; /** * Member type in a feature set. @@ -105,24 +108,6 @@ export async function deleteFeatureSet(id: string): Promise { return invoke('delete_feature_set', { id }); } -/** - * Get builtin feature sets for a space. - */ -export async function getBuiltinFeatureSets(spaceId: string): Promise { - return invoke('get_builtin_feature_sets', { spaceId }); -} - -/** - * Ensure a server-all featureset exists for a server in a space. - */ -export async function ensureServerAllFeatureSet( - spaceId: string, - serverId: string, - serverName: string -): Promise { - return invoke('ensure_server_all_feature_set', { spaceId, serverId, serverName }); -} - /** * Get a feature set with its members. */ diff --git a/apps/desktop/src/lib/api/gateway.ts b/apps/desktop/src/lib/api/gateway.ts index fd3f060..31cb60e 100644 --- a/apps/desktop/src/lib/api/gateway.ts +++ b/apps/desktop/src/lib/api/gateway.ts @@ -23,10 +23,79 @@ export async function getGatewayStatus(spaceId?: string): Promise } /** - * Start the gateway server. + * Probe result for a proposed gateway start. + * + * `source` tells the UI which tier the preferred port came from so it can + * phrase the prompt correctly ("your configured port" vs "the default port"). */ -export async function startGateway(port?: number): Promise { - return invoke('start_gateway', { port }); +export interface GatewayStartProbe { + preferredPort: number; + preferredAvailable: boolean; + source: 'override' | 'configured' | 'default'; +} + +/** + * Ask the backend whether the gateway can start on its preferred port. + * Does not start anything — used by the UI to decide whether to prompt. + */ +export async function probeGatewayStart(port?: number): Promise { + return invoke('probe_gateway_start', { port }); +} + +/** + * Auto-start port conflict raised during app launch. When non-null, the UI + * must prompt the user before the gateway will bind. + */ +export interface PendingPortConflict { + preferredPort: number; + source: 'configured' | 'default'; +} + +/** + * Atomically read AND clear the deferred auto-start port conflict. + * + * "Take" semantics — only the first caller gets the conflict; subsequent + * calls return null. Prevents duplicate prompts under React StrictMode's + * double-mount. + */ +export async function takePendingPortConflict(): Promise { + return invoke('take_pending_port_conflict'); +} + +/** + * Error marker the backend returns when the preferred port is busy and + * `allowDynamicFallback` is false. Shape: `PORT_IN_USE::`. + */ +export interface PortInUseError { + kind: 'PortInUse'; + port: number; + source: 'override' | 'configured' | 'default'; +} + +/** Parse the `PORT_IN_USE::` sentinel the backend emits. */ +export function parsePortInUseError(err: unknown): PortInUseError | null { + const msg = err instanceof Error ? err.message : typeof err === 'string' ? err : ''; + const match = /^PORT_IN_USE:(\d+):(override|configured|default)$/.exec(msg); + if (!match) return null; + return { + kind: 'PortInUse', + port: Number(match[1]), + source: match[2] as PortInUseError['source'], + }; +} + +/** + * Start the gateway server. Strict by default — pass `allowDynamicFallback` + * to let the gateway pick a dynamic port when the preferred one is taken. + */ +export async function startGateway(opts?: { + port?: number; + allowDynamicFallback?: boolean; +}): Promise { + return invoke('start_gateway', { + port: opts?.port, + allowDynamicFallback: opts?.allowDynamicFallback, + }); } /** @@ -37,10 +106,16 @@ export async function stopGateway(): Promise { } /** - * Restart the gateway server. + * Restart the gateway server. Same semantics as `startGateway`. */ -export async function restartGateway(): Promise { - return invoke('restart_gateway'); +export async function restartGateway(opts?: { + port?: number; + allowDynamicFallback?: boolean; +}): Promise { + return invoke('restart_gateway', { + port: opts?.port, + allowDynamicFallback: opts?.allowDynamicFallback, + }); } /** @@ -125,21 +200,16 @@ export interface OAuthClient { metadata_url?: string | null; // URL where metadata was fetched metadata_cached_at?: string | null; // When we last fetched metadata_cache_ttl?: number | null; // Cache duration in seconds - - // MCP client preferences - connection_mode: string; - locked_space_id: string | null; + last_seen: string | null; created_at: string; } /** - * Update client settings request. + * Update client settings request. Only the display alias is editable. */ export interface UpdateClientRequest { client_alias?: string; - connection_mode?: 'follow_active' | 'locked' | 'ask_on_change'; - locked_space_id?: string | null; } /** diff --git a/apps/desktop/src/lib/api/oauthClients.ts b/apps/desktop/src/lib/api/oauthClients.ts deleted file mode 100644 index 898ab22..0000000 --- a/apps/desktop/src/lib/api/oauthClients.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * OAuth Client Grants API - * - * For managing feature set grants for OAuth/inbound clients (Cursor, VS Code, etc.) - */ - -import { invoke } from '@tauri-apps/api/core'; - -/** - * Get grants for an OAuth client in a specific space. - */ -export async function getOAuthClientGrants( - clientId: string, - spaceId: string -): Promise { - return invoke('get_oauth_client_grants', { clientId, spaceId }); -} - -/** - * Grant a feature set to an OAuth client in a specific space. - */ -export async function grantOAuthClientFeatureSet( - clientId: string, - spaceId: string, - featureSetId: string -): Promise { - return invoke('grant_oauth_client_feature_set', { clientId, spaceId, featureSetId }); -} - -/** - * Revoke a feature set from an OAuth client in a specific space. - */ -export async function revokeOAuthClientFeatureSet( - clientId: string, - spaceId: string, - featureSetId: string -): Promise { - return invoke('revoke_oauth_client_feature_set', { clientId, spaceId, featureSetId }); -} - -/** - * Resolved features for a client - */ -export interface ResolvedClientFeatures { - space_id: string; - feature_set_ids: string[]; - tools: Array<{ name: string; description?: string; server_id: string }>; - prompts: Array<{ name: string; description?: string; server_id: string }>; - resources: Array<{ name: string; description?: string; server_id: string }>; -} - -/** - * Get resolved features (tools/prompts/resources) for an OAuth client in a specific space. - * This resolves all feature sets granted to the client into actual features. - * - * The caller is responsible for determining which space to query: - * - For locked clients: pass the client's locked_space_id - * - For follow_active clients: pass the currently active space_id - * - * @param clientId - The OAuth client ID - * @param spaceId - The space ID to resolve features for (required) - */ -export async function getOAuthClientResolvedFeatures( - clientId: string, - spaceId: string -): Promise { - return invoke('get_oauth_client_resolved_features', { clientId, spaceId }); -} - diff --git a/apps/desktop/src/lib/api/spaces.ts b/apps/desktop/src/lib/api/spaces.ts index bf6c8f2..625df98 100644 --- a/apps/desktop/src/lib/api/spaces.ts +++ b/apps/desktop/src/lib/api/spaces.ts @@ -1,93 +1,43 @@ import { invoke } from '@tauri-apps/api/core'; /** - * A Space represents an isolated environment with its own credentials and server configs. + * A Space represents an isolated environment with its own credentials and + * server configs. Every Space has exactly one auto-seeded Default FeatureSet + * which is the routing fallback when no WorkspaceBinding matches. Exactly + * one Space carries `is_default = true` — that's the gateway's fallback + * when a session reports no root or its root has no binding. */ export interface Space { - id: string; // UUID string + id: string; name: string; icon: string | null; description: string | null; is_default: boolean; sort_order: number; - /** - * Resolver v2: fallback FS applied when a connected client has no pin - * and no workspace-binding match. `null` → deny-by-default. - */ - active_feature_set_id: string | null; created_at: string; updated_at: string; } -/** - * List all spaces. - */ export async function listSpaces(): Promise { return invoke('list_spaces'); } -/** - * Get a space by ID. - */ export async function getSpace(id: string): Promise { return invoke('get_space', { id }); } -/** - * Create a new space. - */ export async function createSpace(name: string, icon?: string): Promise { return invoke('create_space', { name, icon }); } -/** - * Delete a space. - */ export async function deleteSpace(id: string): Promise { return invoke('delete_space', { id }); } -/** - * Get the active (default) space. - */ -export async function getActiveSpace(): Promise { - return invoke('get_active_space'); -} - -/** - * Set the active space. - */ -export async function setActiveSpace(id: string): Promise { - return invoke('set_active_space', { id }); -} - -/** - * Set (or clear with `null`) the active FeatureSet for a Space. - * - * The active FS is the fallback applied when a connected client has no - * access-key pin and no workspace-binding match. See the FeatureSetResolver - * for the full precedence (pin > binding > active). - */ -export async function setSpaceActiveFeatureSet( - spaceId: string, - featureSetId: string | null -): Promise { - return invoke('set_space_active_feature_set', { - spaceId, - featureSetId, - }); -} - -/** - * Read space configuration JSON file. - */ export async function readSpaceConfig(spaceId: string): Promise { return invoke('read_space_config', { spaceId }); } -/** - * Save space configuration JSON file. - */ export async function saveSpaceConfig(spaceId: string, content: string): Promise { return invoke('save_space_config', { spaceId, content }); } @@ -100,9 +50,6 @@ export async function removeServerFromConfig(spaceId: string, serverId: string): return invoke('remove_server_from_config', { spaceId, serverId }); } -/** - * Open space configuration file in external editor. - */ export async function openSpaceConfigFile(spaceId: string): Promise { return invoke('open_space_config_file', { spaceId }); } diff --git a/apps/desktop/src/lib/api/workspaceBindings.ts b/apps/desktop/src/lib/api/workspaceBindings.ts index ab315c7..f50a473 100644 --- a/apps/desktop/src/lib/api/workspaceBindings.ts +++ b/apps/desktop/src/lib/api/workspaceBindings.ts @@ -1,25 +1,58 @@ import { invoke } from '@tauri-apps/api/core'; /** - * A WorkspaceBinding maps a normalized filesystem path (the workspace root - * reported by an MCP client via `roots/list`) to a FeatureSet. Bindings are - * the middle tier of resolver v2: pin > binding > space-active. + * A WorkspaceBinding maps one normalized filesystem path to a concrete + * (Space, FeatureSet) pair. When an MCP session reports a root that matches + * a binding (longest-prefix wins), the resolver hands back the binding's + * `space_id` + `feature_set_id` directly — no "follow active" indirection. */ export interface WorkspaceBinding { id: string; - space_id: string; workspace_root: string; + space_id: string; + /** FeatureSet ids are strings (builtins use `fs_default_`, customs use UUIDs). */ feature_set_id: string; created_at: string; updated_at: string; } -/** List every binding across all Spaces. */ +/** Input payload for create / update. */ +export interface WorkspaceBindingInput { + workspace_root: string; + space_id: string; + feature_set_id: string; +} + +/** List every binding (sorted by workspace_root). */ export async function listWorkspaceBindings(): Promise { return invoke('list_workspace_bindings'); } -/** List bindings for a specific Space. */ +/** + * Every filesystem root that connected MCP clients have reported during + * their current sessions, deduplicated across sessions. Surfaces folders + * that aren't bound yet so the user can configure them from the Workspaces + * tab instead of waiting for the one-shot prompt. + */ +export async function listReportedWorkspaceRoots(): Promise { + return invoke('list_reported_workspace_roots'); +} + +/** + * Live path validation for the manual-add form. Runs the SAME rules the + * create/update commands apply so "validates in UI → saves OK" is a + * guarantee, not a hope. + * + * Resolves with the server's normalized form (e.g. `d:\foo` from raw + * `D:\foo\`). Rejects with a descriptive message on invalid input; empty + * input rejects with an empty string so the UI can distinguish + * "don't nag yet" from "here's a real error". + */ +export async function validateWorkspaceRoot(path: string): Promise { + return invoke('validate_workspace_root', { path }); +} + +/** List bindings whose target Space is the given one. */ export async function listWorkspaceBindingsForSpace( spaceId: string ): Promise { @@ -27,37 +60,91 @@ export async function listWorkspaceBindingsForSpace( } /** - * Create a new binding. `workspaceRoot` is normalized on the Rust side - * (Windows drive letter case-folded, `file://` scheme stripped, trailing - * separator trimmed) so callers can pass whatever the OS or MCP client - * reports and rely on consistent matching later. + * Create a new binding. `workspace_root` is normalized server-side so + * callers can pass raw OS paths, `file://` URIs, or MCP-reported roots. */ export async function createWorkspaceBinding( - spaceId: string, - workspaceRoot: string, - featureSetId: string + input: WorkspaceBindingInput ): Promise { - return invoke('create_workspace_binding', { - spaceId, - workspaceRoot, - featureSetId, - }); + return invoke('create_workspace_binding', { input }); } -/** Update an existing binding's path or FeatureSet. */ +/** Update any axis of an existing binding. */ export async function updateWorkspaceBinding( id: string, - workspaceRoot: string, - featureSetId: string + input: WorkspaceBindingInput ): Promise { - return invoke('update_workspace_binding', { - id, - workspaceRoot, - featureSetId, - }); + return invoke('update_workspace_binding', { id, input }); } /** Delete a binding by id. */ export async function deleteWorkspaceBinding(id: string): Promise { return invoke('delete_workspace_binding', { id }); } + +/** Convenience: build a `WorkspaceBindingInput` from a binding-shaped object. */ +export function toInput(b: WorkspaceBinding): WorkspaceBindingInput { + return { + workspace_root: b.workspace_root, + space_id: b.space_id, + feature_set_id: b.feature_set_id, + }; +} + +/** + * Per-feature view returned from `get_workspace_effective_features`. + * + * `available` is `true` exactly when the underlying server is currently + * connected. A `false` value with `server_status = "disconnected"` (or + * `auth_required` / `error`) is the user's "configured but unavailable" + * case — the FS still includes this feature, but its server isn't usable + * right now. + */ +export interface EffectiveFeature { + id: string; + feature_name: string; + display_name: string | null; + description: string | null; + server_id: string; + server_alias: string | null; + /** + * snake_case mirror of the gateway's connection status, plus `unknown` + * when the gateway isn't running. + */ + server_status: + | 'connected' + | 'connecting' + | 'disconnected' + | 'refreshing' + | 'auth_required' + | 'authenticating' + | 'error' + | 'unknown'; + available: boolean; +} + +export interface WorkspaceEffectiveFeatures { + workspace_root: string; + /** `binding` when a saved WorkspaceBinding matched; `fallback` for the default Space's Default FS. */ + source: 'binding' | 'fallback'; + binding_id: string | null; + space_id: string; + space_name: string; + feature_set_id: string; + feature_set_name: string; + feature_set_type: 'default' | 'custom'; + tools: EffectiveFeature[]; + prompts: EffectiveFeature[]; + resources: EffectiveFeature[]; +} + +/** + * Resolve the FeatureSet that applies for a given workspace root and return + * its full configured tool/prompt/resource list with per-feature + * availability — same view the gateway resolver builds for live sessions. + */ +export async function getWorkspaceEffectiveFeatures( + workspaceRoot: string +): Promise { + return invoke('get_workspace_effective_features', { workspaceRoot }); +} diff --git a/apps/desktop/src/lib/contribute.ts b/apps/desktop/src/lib/contribute.ts index 022c9df..8d121ad 100644 --- a/apps/desktop/src/lib/contribute.ts +++ b/apps/desktop/src/lib/contribute.ts @@ -19,24 +19,32 @@ export const CONTRIBUTE = { serversRepo: 'https://github.com/mcpmux/mcp-servers', /** Marketing site. */ site: 'https://mcpmux.com', - /** New bug report, pre-labelled. */ - bug: 'https://github.com/mcpmux/mcp-mux/issues/new?labels=bug', - /** Feature request for the app itself. */ + /** New bug report, pre-filled with the bug_report template. */ + bug: 'https://github.com/mcpmux/mcp-mux/issues/new?template=bug_report.yml', + /** Feature request for the app itself, pre-filled with the feature_request template. */ featureRequest: - 'https://github.com/mcpmux/mcp-mux/issues/new?labels=enhancement', + 'https://github.com/mcpmux/mcp-mux/issues/new?template=feature_request.yml', /** - * Request a new server definition in the community registry. Encodes the - * user's search term into the issue title when provided. + * Request a new server definition in the community registry. Opens the + * `request-server.yml` issue template and encodes the user's search term + * into the title when provided. */ requestServer(searchTerm?: string): string { const base = - 'https://github.com/mcpmux/mcp-servers/issues/new?labels=server-request'; + 'https://github.com/mcpmux/mcp-servers/issues/new?template=request-server.yml'; if (!searchTerm) return base; - const title = encodeURIComponent(`Request: ${searchTerm.slice(0, 120)}`); + const title = encodeURIComponent(`[Request] ${searchTerm.slice(0, 120)}`); return `${base}&title=${title}`; }, - /** Root of the server-definitions contributing guide. */ + /** + * Contribute a new server definition — points at the registry's + * CONTRIBUTING guide. Server definitions are JSON files landed via PR, + * not issues, so we send users straight down the fork → PR path. + */ contributeServer: 'https://github.com/mcpmux/mcp-servers/blob/main/CONTRIBUTING.md', + /** Report a bug in an existing server definition. */ + serverDefinitionBug: + 'https://github.com/mcpmux/mcp-servers/issues/new?template=bug-report.yml', } as const; /** diff --git a/apps/desktop/src/stores/appStore.ts b/apps/desktop/src/stores/appStore.ts index 91e554d..c145340 100644 --- a/apps/desktop/src/stores/appStore.ts +++ b/apps/desktop/src/stores/appStore.ts @@ -5,7 +5,6 @@ import { AppStore, AppState } from './types'; const initialState: AppState = { spaces: [], - activeSpaceId: null, viewSpaceId: null, activeNav: 'home', pendingClientId: null, @@ -27,28 +26,13 @@ export const useAppStore = create()( setSpaces: (spaces) => set((state) => { state.spaces = spaces; - // Validate persisted activeSpaceId still exists, reset to default if not - const activeExists = state.activeSpaceId - ? spaces.some((s) => s.id === state.activeSpaceId) - : false; - if (!activeExists && spaces.length > 0) { - const defaultSpace = spaces.find((s) => s.is_default); - state.activeSpaceId = defaultSpace?.id ?? spaces[0].id; - } + // Validate persisted viewSpaceId still exists; reset to default if not const viewExists = state.viewSpaceId ? spaces.some((s) => s.id === state.viewSpaceId) : false; - if (!viewExists) { - state.viewSpaceId = state.activeSpaceId; - } - }), - - setActiveSpace: (id) => - set((state) => { - const shouldFollow = !state.viewSpaceId || state.viewSpaceId === state.activeSpaceId; - state.activeSpaceId = id; - if (shouldFollow) { - state.viewSpaceId = id; + if (!viewExists && spaces.length > 0) { + const defaultSpace = spaces.find((s) => s.is_default); + state.viewSpaceId = defaultSpace?.id ?? spaces[0].id; } }), @@ -60,22 +44,18 @@ export const useAppStore = create()( addSpace: (space) => set((state) => { state.spaces.push(space); - if (space.is_default || state.spaces.length === 1) { - state.activeSpaceId = space.id; - } - if (!state.viewSpaceId) { - state.viewSpaceId = state.activeSpaceId; + if (!state.viewSpaceId || space.is_default) { + state.viewSpaceId = space.id; } }), removeSpace: (id) => set((state) => { state.spaces = state.spaces.filter((s) => s.id !== id); - if (state.activeSpaceId === id) { - state.activeSpaceId = state.spaces[0]?.id ?? null; - } if (state.viewSpaceId === id) { - state.viewSpaceId = state.activeSpaceId; + const fallback = + state.spaces.find((s) => s.is_default) ?? state.spaces[0]; + state.viewSpaceId = fallback?.id ?? null; } }), @@ -124,9 +104,7 @@ export const useAppStore = create()( name: 'mcpmux-storage', storage: createJSONStorage(() => localStorage), partialize: (state) => ({ - // Only persist these fields - // Note: viewSpaceId is NOT persisted - always starts as activeSpaceId on launch - activeSpaceId: state.activeSpaceId, + viewSpaceId: state.viewSpaceId, sidebarCollapsed: state.sidebarCollapsed, theme: state.theme, analyticsEnabled: state.analyticsEnabled, @@ -134,4 +112,3 @@ export const useAppStore = create()( } ) ); - diff --git a/apps/desktop/src/stores/selectors.ts b/apps/desktop/src/stores/selectors.ts index 02ed4cb..99282af 100644 --- a/apps/desktop/src/stores/selectors.ts +++ b/apps/desktop/src/stores/selectors.ts @@ -3,7 +3,6 @@ import { Space } from '@/lib/api/spaces'; // Typed selectors for better performance export const useSpaces = () => useAppStore((state) => state.spaces); -export const useActiveSpaceId = () => useAppStore((state) => state.activeSpaceId); export const useViewSpaceId = () => useAppStore((state) => state.viewSpaceId); export const useActiveNav = () => useAppStore((state) => state.activeNav); export const useNavigateTo = () => useAppStore((state) => state.navigateTo); @@ -14,21 +13,18 @@ export const useSidebarCollapsed = () => useAppStore((state) => state.sidebarCol export const useAnalyticsEnabled = () => useAppStore((state) => state.analyticsEnabled); // Computed selectors -export const useActiveSpace = (): Space | null => { +export const useViewSpace = (): Space | null => { const spaces = useSpaces(); - const activeSpaceId = useActiveSpaceId(); - return spaces.find((s) => s.id === activeSpaceId) ?? null; + const viewSpaceId = useViewSpaceId(); + return spaces.find((s) => s.id === viewSpaceId) ?? null; }; -export const useViewSpace = (): Space | null => { +/** The system's fallback space — `is_default` Space, used by gateway when no WorkspaceBinding matches. */ +export const useDefaultSpace = (): Space | null => { const spaces = useSpaces(); - const activeSpaceId = useActiveSpaceId(); - const viewSpaceId = useViewSpaceId(); - const effectiveId = viewSpaceId ?? activeSpaceId; - return spaces.find((s) => s.id === effectiveId) ?? null; + return spaces.find((s) => s.is_default) ?? null; }; export const useIsLoading = (key: 'spaces' | 'servers') => { return useAppStore((state) => state.loading[key]); }; - diff --git a/apps/desktop/src/stores/types.ts b/apps/desktop/src/stores/types.ts index 15b55d3..4cacf04 100644 --- a/apps/desktop/src/stores/types.ts +++ b/apps/desktop/src/stores/types.ts @@ -1,11 +1,24 @@ import { Space } from '@/lib/api/spaces'; -export type NavItem = 'home' | 'registry' | 'servers' | 'spaces' | 'featuresets' | 'clients' | 'settings'; +export type NavItem = + | 'home' + | 'registry' + | 'servers' + | 'spaces' + | 'featuresets' + | 'workspaces' + | 'clients' + | 'settings'; export interface AppState { // Spaces spaces: Space[]; - activeSpaceId: string | null; + /** + * The space the user is currently viewing in the desktop app. Pure + * UI navigation state — has no effect on gateway routing, which always + * resolves via reported workspace root → WorkspaceBinding (or the + * built-in default Space when no binding matches). + */ viewSpaceId: string | null; // Navigation @@ -28,7 +41,6 @@ export interface AppState { export interface AppActions { // Spaces setSpaces: (spaces: Space[]) => void; - setActiveSpace: (id: string | null) => void; setViewSpace: (id: string | null) => void; addSpace: (space: Space) => void; removeSpace: (id: string) => void; @@ -48,4 +60,3 @@ export interface AppActions { } export type AppStore = AppState & AppActions; - diff --git a/crates/mcpmux-core/src/application/mod.rs b/crates/mcpmux-core/src/application/mod.rs index 7c90495..5f58ebf 100644 --- a/crates/mcpmux-core/src/application/mod.rs +++ b/crates/mcpmux-core/src/application/mod.rs @@ -134,7 +134,6 @@ impl ApplicationServicesBuilder { server: self.installed_server_repo.map(|r| { ServerAppService::new( r, - self.feature_set_repo.clone(), self.server_feature_repo.clone(), self.credential_repo.clone(), sender.clone(), @@ -142,7 +141,7 @@ impl ApplicationServicesBuilder { }), permission: self .feature_set_repo - .map(|r| PermissionAppService::new(r, self.client_repo.clone(), sender.clone())), + .map(|r| PermissionAppService::new(r, sender.clone())), client: self .client_repo .map(|r| ClientAppService::new(r, sender.clone())), diff --git a/crates/mcpmux-core/src/application/permission.rs b/crates/mcpmux-core/src/application/permission.rs index b77e816..99f2bbd 100644 --- a/crates/mcpmux-core/src/application/permission.rs +++ b/crates/mcpmux-core/src/application/permission.rs @@ -9,24 +9,22 @@ use uuid::Uuid; use crate::domain::{DomainEvent, FeatureSet, FeatureSetMember, MemberMode}; use crate::event_bus::EventSender; -use crate::repository::{FeatureSetRepository, InboundMcpClientRepository}; +use crate::repository::FeatureSetRepository; -/// Application service for feature sets and grants management +/// Application service for feature sets. +/// +/// Grants no longer exist — routing is driven by WorkspaceBinding and each +/// Space's Default feature set. This service therefore only covers FS +/// creation, edits, and membership. pub struct PermissionAppService { feature_set_repo: Arc, - client_repo: Option>, event_sender: EventSender, } impl PermissionAppService { - pub fn new( - feature_set_repo: Arc, - client_repo: Option>, - event_sender: EventSender, - ) -> Self { + pub fn new(feature_set_repo: Arc, event_sender: EventSender) -> Self { Self { feature_set_repo, - client_repo, event_sender, } } @@ -283,151 +281,4 @@ impl PermissionAppService { .get_feature_members(feature_set_id) .await } - - // ======================================================================== - // GRANT OPERATIONS - // ======================================================================== - - /// Grant a feature set to a client for a space - /// - /// Emits: `GrantIssued` - pub async fn grant_feature_set( - &self, - client_id: Uuid, - space_id: &str, - feature_set_id: &str, - ) -> Result<()> { - let client_repo = self - .client_repo - .as_ref() - .ok_or_else(|| anyhow!("Client repository not configured"))?; - - // Verify client exists - client_repo - .get(&client_id) - .await? - .ok_or_else(|| anyhow!("Client not found"))?; - - // Verify feature set exists - self.feature_set_repo - .get(feature_set_id) - .await? - .ok_or_else(|| anyhow!("Feature set not found"))?; - - client_repo - .grant_feature_set(&client_id, space_id, feature_set_id) - .await?; - - // Parse space_id to UUID - let space_uuid = - Uuid::parse_str(space_id).map_err(|e| anyhow!("Invalid space ID: {}", e))?; - - info!( - client_id = %client_id, - space_id = space_id, - feature_set_id = feature_set_id, - "[PermissionAppService] Granted feature set to client" - ); - - // Emit event - this will trigger MCP notifications to connected clients - self.event_sender.emit(DomainEvent::GrantIssued { - client_id: client_id.to_string(), - space_id: space_uuid, - feature_set_id: feature_set_id.to_string(), - }); - - Ok(()) - } - - /// Revoke a feature set from a client - /// - /// Emits: `GrantRevoked` - pub async fn revoke_feature_set( - &self, - client_id: Uuid, - space_id: &str, - feature_set_id: &str, - ) -> Result<()> { - let client_repo = self - .client_repo - .as_ref() - .ok_or_else(|| anyhow!("Client repository not configured"))?; - - client_repo - .revoke_feature_set(&client_id, space_id, feature_set_id) - .await?; - - // Parse space_id to UUID - let space_uuid = - Uuid::parse_str(space_id).map_err(|e| anyhow!("Invalid space ID: {}", e))?; - - info!( - client_id = %client_id, - space_id = space_id, - feature_set_id = feature_set_id, - "[PermissionAppService] Revoked feature set from client" - ); - - // Emit event - self.event_sender.emit(DomainEvent::GrantRevoked { - client_id: client_id.to_string(), - space_id: space_uuid, - feature_set_id: feature_set_id.to_string(), - }); - - Ok(()) - } - - /// Get all grants for a client in a space - pub async fn get_grants_for_space( - &self, - client_id: Uuid, - space_id: &str, - ) -> Result> { - let client_repo = self - .client_repo - .as_ref() - .ok_or_else(|| anyhow!("Client repository not configured"))?; - - client_repo.get_grants_for_space(&client_id, space_id).await - } - - /// Set all grants for a client in a space (replaces existing) - /// - /// Emits: `ClientGrantsUpdated` - pub async fn set_grants_for_space( - &self, - client_id: Uuid, - space_id: &str, - feature_set_ids: Vec, - ) -> Result<()> { - let client_repo = self - .client_repo - .as_ref() - .ok_or_else(|| anyhow!("Client repository not configured"))?; - - client_repo - .set_grants_for_space(&client_id, space_id, &feature_set_ids) - .await?; - - // Parse space_id to UUID - let space_uuid = - Uuid::parse_str(space_id).map_err(|e| anyhow!("Invalid space ID: {}", e))?; - - info!( - client_id = %client_id, - space_id = space_id, - count = feature_set_ids.len(), - "[PermissionAppService] Updated client grants" - ); - - // Emit event - self.event_sender.emit(DomainEvent::ClientGrantsUpdated { - client_id: client_id.to_string(), - space_id: space_uuid, - feature_set_ids, - }); - - Ok(()) - } } diff --git a/crates/mcpmux-core/src/application/server.rs b/crates/mcpmux-core/src/application/server.rs index a9a5d58..80181b0 100644 --- a/crates/mcpmux-core/src/application/server.rs +++ b/crates/mcpmux-core/src/application/server.rs @@ -10,14 +10,11 @@ use uuid::Uuid; use crate::domain::{DomainEvent, InstallationSource, InstalledServer, ServerDefinition}; use crate::event_bus::EventSender; -use crate::repository::{ - CredentialRepository, FeatureSetRepository, InstalledServerRepository, ServerFeatureRepository, -}; +use crate::repository::{CredentialRepository, InstalledServerRepository, ServerFeatureRepository}; /// Application service for server installation and management pub struct ServerAppService { server_repo: Arc, - feature_set_repo: Option>, feature_repo: Option>, credential_repo: Option>, event_sender: EventSender, @@ -26,14 +23,12 @@ pub struct ServerAppService { impl ServerAppService { pub fn new( server_repo: Arc, - feature_set_repo: Option>, feature_repo: Option>, credential_repo: Option>, event_sender: EventSender, ) -> Self { Self { server_repo, - feature_set_repo, feature_repo, credential_repo, event_sender, @@ -86,20 +81,6 @@ impl ServerAppService { self.server_repo.install(&server).await?; - // Create server-all feature set - if let Some(ref fs_repo) = self.feature_set_repo { - if let Err(e) = fs_repo - .ensure_server_all(&space_id_str, server_id, &definition.name) - .await - { - tracing::warn!( - server_id = server_id, - error = %e, - "Failed to create server-all feature set" - ); - } - } - info!( space_id = %space_id, server_id = server_id, @@ -150,17 +131,6 @@ impl ServerAppService { } } - // Delete server-all feature set - if let Some(ref fs_repo) = self.feature_set_repo { - if let Err(e) = fs_repo.delete_server_all(&space_id_str, server_id).await { - warn!( - server_id = server_id, - error = %e, - "Failed to delete server-all feature set" - ); - } - } - // Delete discovered features if let Some(ref feature_repo) = self.feature_repo { if let Err(e) = feature_repo diff --git a/crates/mcpmux-core/src/application/space.rs b/crates/mcpmux-core/src/application/space.rs index 9618ded..36f4d66 100644 --- a/crates/mcpmux-core/src/application/space.rs +++ b/crates/mcpmux-core/src/application/space.rs @@ -43,8 +43,9 @@ impl SpaceAppService { self.space_repo.get(&id).await } - /// Get the active (default) space - pub async fn get_active(&self) -> Result> { + /// Get the system's default Space (the routing fallback when a session + /// reports no root or no `WorkspaceBinding` matches). + pub async fn get_default(&self) -> Result> { self.space_repo.get_default().await } @@ -156,37 +157,4 @@ impl SpaceAppService { Ok(()) } - - /// Set the active space - /// - /// Emits: `SpaceActivated` - pub async fn set_active(&self, id: Uuid) -> Result { - // Get current active space - let old_space = self.space_repo.get_default().await?; - - // Get new space - let new_space = self - .space_repo - .get(&id) - .await? - .ok_or_else(|| anyhow!("Space not found"))?; - - // Set as default - self.space_repo.set_default(&id).await?; - - info!( - space_id = %id, - name = %new_space.name, - "[SpaceAppService] Activated space" - ); - - // Emit event - self.event_sender.emit(DomainEvent::SpaceActivated { - from_space_id: old_space.map(|s| s.id), - to_space_id: new_space.id, - to_space_name: new_space.name.clone(), - }); - - Ok(new_space) - } } diff --git a/crates/mcpmux-core/src/domain/client.rs b/crates/mcpmux-core/src/domain/client.rs index 33f955e..2172a4f 100644 --- a/crates/mcpmux-core/src/domain/client.rs +++ b/crates/mcpmux-core/src/domain/client.rs @@ -1,40 +1,16 @@ //! Client entity - AI clients that connect to McpMux +//! +//! A Client is the *identity* an approved connection uses (Cursor, VS Code, +//! Claude Desktop, etc.). Routing is driven entirely by WorkspaceBinding + +//! the session's Space; per-client FeatureSet grants and Space/FS pins no +//! longer exist. use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; use uuid::Uuid; -/// Connection mode determines how a client resolves which Space to use -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum ConnectionMode { - /// Client is locked to a specific Space - Locked { space_id: Uuid }, - - /// Client follows the currently active Space - #[default] - FollowActive, - - /// Prompt user when context suggests a different Space - AskOnChange { triggers: Vec }, -} - -/// Triggers for auto-suggesting Space changes -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum ContextTrigger { - /// Match git remote URL - GitRemote { pattern: String, space_id: Uuid }, - - /// Match working directory - Directory { pattern: String, space_id: Uuid }, - - /// Match time of day - TimeSchedule { cron: String, space_id: Uuid }, -} - -/// Client represents an AI client (Cursor, VS Code, Claude Desktop) +/// Client represents an AI client (Cursor, VS Code, Claude Desktop, ...) +/// that has been approved to connect to the gateway. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Client { /// Unique identifier @@ -46,33 +22,6 @@ pub struct Client { /// Client type (cursor, vscode, claude, etc.) pub client_type: String, - /// How this client resolves Spaces - #[serde(default)] - pub connection_mode: ConnectionMode, - - /// FeatureSet grants per Space: space_id -> [feature_set_ids] - /// - /// Legacy field — superseded by `pinned_feature_set_id` + WorkspaceBinding. - /// Kept while the FeatureSetResolver runs in shadow mode. - #[serde(default)] - pub grants: HashMap>, - - /// Space this access key belongs to (chosen at approval time). - /// - /// Replaces the `Locked` variant of `ConnectionMode`. `None` means - /// "follow the active Space" for legacy clients that haven't been - /// migrated yet; new approvals always populate this. - #[serde(default)] - pub pinned_space_id: Option, - - /// FeatureSet this access key is pinned to (chosen at approval time). - /// - /// When `Some`, the resolver uses this FS directly. When `None`, the - /// resolver falls through to workspace-root binding and then the - /// Space's active FS. - #[serde(default)] - pub pinned_feature_set_id: Option, - /// Access key for authentication (local only, never synced) #[serde(skip)] pub access_key: Option, @@ -95,10 +44,6 @@ impl Client { id: Uuid::new_v4(), name: name.into(), client_type: client_type.into(), - connection_mode: ConnectionMode::default(), - grants: HashMap::new(), - pinned_space_id: None, - pinned_feature_set_id: None, access_key: None, created_at: now, updated_at: now, @@ -121,26 +66,6 @@ impl Client { Self::new("Claude Desktop", "claude") } - /// Set connection mode - pub fn with_mode(mut self, mode: ConnectionMode) -> Self { - self.connection_mode = mode; - self - } - - /// Grant FeatureSets for a Space - pub fn grant(mut self, space_id: Uuid, feature_sets: Vec) -> Self { - self.grants.insert(space_id, feature_sets); - self - } - - /// Check if client has any grants for a Space - pub fn has_access_to(&self, space_id: &Uuid) -> bool { - self.grants - .get(space_id) - .map(|g| !g.is_empty()) - .unwrap_or(false) - } - /// Generate a new access key pub fn generate_access_key(&mut self) { self.access_key = Some(format!("mcp_{}", Uuid::new_v4().simple())); @@ -156,20 +81,14 @@ mod tests { let client = Client::cursor(); assert_eq!(client.name, "Cursor"); assert_eq!(client.client_type, "cursor"); - assert!(matches!( - client.connection_mode, - ConnectionMode::FollowActive - )); } #[test] - fn test_grants() { - let space_id = Uuid::new_v4(); - let fs_id = Uuid::new_v4(); - - let client = Client::cursor().grant(space_id, vec![fs_id]); - - assert!(client.has_access_to(&space_id)); - assert!(!client.has_access_to(&Uuid::new_v4())); + fn test_access_key_generation() { + let mut client = Client::vscode(); + assert!(client.access_key.is_none()); + client.generate_access_key(); + let key = client.access_key.as_ref().expect("key was generated"); + assert!(key.starts_with("mcp_")); } } diff --git a/crates/mcpmux-core/src/domain/event.rs b/crates/mcpmux-core/src/domain/event.rs index c76a01c..c6466a4 100644 --- a/crates/mcpmux-core/src/domain/event.rs +++ b/crates/mcpmux-core/src/domain/event.rs @@ -183,14 +183,6 @@ pub enum DomainEvent { /// A space was deleted SpaceDeleted { space_id: Uuid }, - /// Active space changed - SpaceActivated { - #[serde(skip_serializing_if = "Option::is_none")] - from_space_id: Option, - to_space_id: Uuid, - to_space_name: String, - }, - // ════════════════════════════════════════════════════════════════════════ // SERVER LIFECYCLE (Configuration) // ════════════════════════════════════════════════════════════════════════ @@ -315,27 +307,6 @@ pub enum DomainEvent { /// A client was issued an access token ClientTokenIssued { client_id: String }, - /// A feature set was granted to a client in a space - GrantIssued { - client_id: String, - space_id: Uuid, - feature_set_id: String, - }, - - /// A feature set was revoked from a client in a space - GrantRevoked { - client_id: String, - space_id: Uuid, - feature_set_id: String, - }, - - /// Client's grants were batch-updated for a space - ClientGrantsUpdated { - client_id: String, - space_id: Uuid, - feature_set_ids: Vec, - }, - // ════════════════════════════════════════════════════════════════════════ // GATEWAY // ════════════════════════════════════════════════════════════════════════ @@ -357,6 +328,42 @@ pub enum DomainEvent { /// Backend server notified that its resources changed ResourcesChanged { space_id: Uuid, server_id: String }, + // ════════════════════════════════════════════════════════════════════════ + // WORKSPACE BINDINGS (root → FeatureSet resolution) + // ════════════════════════════════════════════════════════════════════════ + /// A workspace binding was created, updated, or deleted. + /// + /// Emitted by the WorkspaceBinding application service. MCPNotifier + /// listens for this and broadcasts `notifications/tools/list_changed` + /// (plus prompts + resources) to every peer in the affected space so + /// clients re-fetch their tool list under the new routing decision. + WorkspaceBindingChanged { + space_id: Uuid, + workspace_root: String, + }, + + /// A client session resolved via `source=Default` because no binding + /// matched any of its reported roots. The desktop UI uses this to + /// prompt the user once per new (space, root) pair to pick a FeatureSet + /// (or explicitly commit to the default and stop re-prompting). + /// + /// NOT fired for rootless sessions — nothing to bind. + WorkspaceNeedsBinding { + client_id: String, + session_id: String, + space_id: Uuid, + workspace_root: String, + }, + + /// The live set of reported session roots changed (a client connected + /// and surfaced new folders, or an existing client's roots moved). The + /// desktop Workspaces tab listens for this and re-fetches the detected + /// roots list so unbound folders stay visible to the user. + /// + /// Payload-less on purpose — the consumer always re-queries; embedding + /// the roots here would be redundant and race with disconnect cleanup. + SessionRootsChanged, + // ════════════════════════════════════════════════════════════════════════ // META-TOOL AUDIT TRAIL // ════════════════════════════════════════════════════════════════════════ @@ -389,7 +396,6 @@ impl DomainEvent { Self::SpaceCreated { .. } => "space_created", Self::SpaceUpdated { .. } => "space_updated", Self::SpaceDeleted { .. } => "space_deleted", - Self::SpaceActivated { .. } => "space_activated", Self::ServerInstalled { .. } => "server_installed", Self::ServerUninstalled { .. } => "server_uninstalled", Self::ServerConfigUpdated { .. } => "server_config_updated", @@ -407,14 +413,14 @@ impl DomainEvent { Self::ClientUpdated { .. } => "client_updated", Self::ClientDeleted { .. } => "client_deleted", Self::ClientTokenIssued { .. } => "client_token_issued", - Self::GrantIssued { .. } => "grant_issued", - Self::GrantRevoked { .. } => "grant_revoked", - Self::ClientGrantsUpdated { .. } => "client_grants_updated", Self::GatewayStarted { .. } => "gateway_started", Self::GatewayStopped => "gateway_stopped", Self::ToolsChanged { .. } => "tools_changed", Self::PromptsChanged { .. } => "prompts_changed", Self::ResourcesChanged { .. } => "resources_changed", + Self::WorkspaceBindingChanged { .. } => "workspace_binding_changed", + Self::WorkspaceNeedsBinding { .. } => "workspace_needs_binding", + Self::SessionRootsChanged => "session_roots_changed", Self::MetaToolInvoked { .. } => "meta_tool_invoked", } } @@ -433,16 +439,16 @@ impl DomainEvent { } // Feature refresh directly affects capabilities Self::ServerFeaturesRefreshed { .. } => true, - // Grant changes affect what client can access - Self::GrantIssued { .. } - | Self::GrantRevoked { .. } - | Self::ClientGrantsUpdated { .. } => true, // Feature set member changes affect granted capabilities Self::FeatureSetMembersChanged { .. } => true, // Backend server notifications Self::ToolsChanged { .. } | Self::PromptsChanged { .. } | Self::ResourcesChanged { .. } => true, + // Binding changes reshuffle every peer's resolution in the space + Self::WorkspaceBindingChanged { .. } => true, + // WorkspaceNeedsBinding is a UI prompt — doesn't itself change what + // tools a client sees, just invites the user to configure. // All other events don't affect MCP capabilities _ => false, } @@ -466,14 +472,11 @@ impl DomainEvent { | Self::FeatureSetUpdated { space_id, .. } | Self::FeatureSetDeleted { space_id, .. } | Self::FeatureSetMembersChanged { space_id, .. } - | Self::GrantIssued { space_id, .. } - | Self::GrantRevoked { space_id, .. } - | Self::ClientGrantsUpdated { space_id, .. } | Self::ToolsChanged { space_id, .. } | Self::PromptsChanged { space_id, .. } - | Self::ResourcesChanged { space_id, .. } => Some(*space_id), - - Self::SpaceActivated { to_space_id, .. } => Some(*to_space_id), + | Self::ResourcesChanged { space_id, .. } + | Self::WorkspaceBindingChanged { space_id, .. } + | Self::WorkspaceNeedsBinding { space_id, .. } => Some(*space_id), Self::ClientRegistered { .. } | Self::ClientReconnected { .. } @@ -482,10 +485,13 @@ impl DomainEvent { | Self::ClientTokenIssued { .. } | Self::GatewayStarted { .. } | Self::GatewayStopped + | Self::SessionRootsChanged | Self::MetaToolInvoked { .. } => None, } } + // (grant events removed — routing is via WorkspaceBinding + Space.default FS only) + /// Get the server_id if this event is server-scoped pub fn server_id(&self) -> Option<&str> { match self { @@ -511,9 +517,7 @@ impl DomainEvent { | Self::ClientUpdated { client_id, .. } | Self::ClientDeleted { client_id, .. } | Self::ClientTokenIssued { client_id, .. } - | Self::GrantIssued { client_id, .. } - | Self::GrantRevoked { client_id, .. } - | Self::ClientGrantsUpdated { client_id, .. } => Some(client_id), + | Self::WorkspaceNeedsBinding { client_id, .. } => Some(client_id), _ => None, } } @@ -524,9 +528,7 @@ impl DomainEvent { Self::FeatureSetCreated { feature_set_id, .. } | Self::FeatureSetUpdated { feature_set_id, .. } | Self::FeatureSetDeleted { feature_set_id, .. } - | Self::FeatureSetMembersChanged { feature_set_id, .. } - | Self::GrantIssued { feature_set_id, .. } - | Self::GrantRevoked { feature_set_id, .. } => Some(feature_set_id), + | Self::FeatureSetMembersChanged { feature_set_id, .. } => Some(feature_set_id), _ => None, } } @@ -601,13 +603,14 @@ mod tests { #[test] fn test_affects_mcp_capabilities() { - // Grant events affect capabilities - let grant = DomainEvent::GrantIssued { - client_id: "test".to_string(), + // Feature-set member changes affect every peer that resolves into that set + let members = DomainEvent::FeatureSetMembersChanged { space_id: Uuid::new_v4(), feature_set_id: "fs1".to_string(), + added_count: 1, + removed_count: 0, }; - assert!(grant.affects_mcp_capabilities()); + assert!(members.affects_mcp_capabilities()); // Space creation doesn't affect capabilities let space = DomainEvent::SpaceCreated { @@ -655,4 +658,57 @@ mod tests { assert!(ConnectionStatus::Connected.is_terminal()); assert!(!ConnectionStatus::Connecting.is_terminal()); } + + #[test] + fn test_workspace_binding_changed_affects_capabilities() { + // Binding writes reshuffle what every peer in the space resolves to + // — MCPNotifier must broadcast list_changed for this event. + let e = DomainEvent::WorkspaceBindingChanged { + space_id: Uuid::new_v4(), + workspace_root: "/proj/foo".to_string(), + }; + assert!(e.affects_mcp_capabilities()); + assert_eq!(e.type_name(), "workspace_binding_changed"); + assert!(e.space_id().is_some()); + } + + #[test] + fn test_workspace_needs_binding_is_ui_only() { + // The "hey, pick a FeatureSet" prompt is a UI event — it does not + // itself change tool visibility and must NOT trigger list_changed. + let e = DomainEvent::WorkspaceNeedsBinding { + client_id: "client-1".to_string(), + session_id: "sess-1".to_string(), + space_id: Uuid::new_v4(), + workspace_root: "/proj/foo".to_string(), + }; + assert!(!e.affects_mcp_capabilities()); + assert!(e.is_ui_only()); + assert_eq!(e.type_name(), "workspace_needs_binding"); + assert!(e.space_id().is_some()); + assert_eq!(e.client_id(), Some("client-1")); + } + + #[test] + fn test_workspace_events_roundtrip_through_json() { + // The Tauri bridge serializes these to JSON for the webview; verify + // the serde tag + fields match what the frontend expects. + let changed = DomainEvent::WorkspaceBindingChanged { + space_id: Uuid::parse_str("11111111-1111-1111-1111-111111111111").unwrap(), + workspace_root: "d:\\proj".to_string(), + }; + let json = serde_json::to_string(&changed).unwrap(); + assert!(json.contains("\"type\":\"workspace_binding_changed\"")); + assert!(json.contains("\"workspace_root\":\"d:\\\\proj\"")); + + let needs = DomainEvent::WorkspaceNeedsBinding { + client_id: "c".into(), + session_id: "s".into(), + space_id: Uuid::nil(), + workspace_root: "/r".into(), + }; + let json = serde_json::to_string(&needs).unwrap(); + assert!(json.contains("\"type\":\"workspace_needs_binding\"")); + assert!(json.contains("\"session_id\":\"s\"")); + } } diff --git a/crates/mcpmux-core/src/domain/feature_set.rs b/crates/mcpmux-core/src/domain/feature_set.rs index 241a512..cfe8d28 100644 --- a/crates/mcpmux-core/src/domain/feature_set.rs +++ b/crates/mcpmux-core/src/domain/feature_set.rs @@ -1,28 +1,24 @@ //! FeatureSet entity - permission bundles for tools/prompts/resources //! -//! The new featureset model uses explicit feature selection instead of glob patterns. -//! Each featureset is scoped to a space and can be one of: -//! - All: All features from all connected servers in the space -//! - Default: Features auto-granted to all clients in the space -//! - ServerAll: All features from a specific server -//! - Custom: User-defined composition of features and other featuresets +//! Each featureset is scoped to a space and is one of two types: +//! - Default: auto-created per space; the fallback when no workspace binding applies +//! - Custom: user-defined composition of features and other featuresets use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -/// The type of a FeatureSet +/// The type of a FeatureSet. +/// +/// `Default` is auto-created once per space and acts as the no-binding +/// fallback. `Custom` sets are always user-created. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] #[derive(Default)] pub enum FeatureSetType { - /// All features from all connected servers in this space - All, - /// Features auto-granted to all clients in this space + /// Auto-created per space. The fallback FS when no WorkspaceBinding matches. Default, - /// All features from a specific server - ServerAll, - /// Custom user-defined featureset + /// User-defined featureset. #[default] Custom, } @@ -30,18 +26,14 @@ pub enum FeatureSetType { impl FeatureSetType { pub fn as_str(&self) -> &'static str { match self { - Self::All => "all", Self::Default => "default", - Self::ServerAll => "server-all", Self::Custom => "custom", } } pub fn parse(s: &str) -> Option { match s { - "all" => Some(Self::All), "default" => Some(Self::Default), - "server-all" => Some(Self::ServerAll), "custom" => Some(Self::Custom), _ => None, } @@ -154,12 +146,9 @@ impl FeatureSetMember { /// FeatureSet defines a bundle of permissions using explicit feature selection. /// -/// Each featureset is scoped to a space and can contain: -/// - Other featuresets (composition) -/// - Specific features (tools, prompts, resources) -/// -/// For builtin types (All, Default, ServerAll), the effective features are -/// computed dynamically based on connected servers and their discovered features. +/// Scoped to a space. Can contain other featuresets (composition) or specific +/// features (tools, prompts, resources). The `Default` type is auto-created per +/// space; its effective members can be edited by the user just like a Custom set. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FeatureSet { /// Unique identifier @@ -222,38 +211,17 @@ impl FeatureSet { } } - /// Create the "All Features" featureset for a space - pub fn new_all(space_id: impl Into) -> Self { - let space_id = space_id.into(); - let now = Utc::now(); - Self { - id: format!("fs_all_{}", space_id), - name: "All Features".to_string(), - description: Some( - "All features from all connected MCP servers in this space".to_string(), - ), - icon: Some("🌐".to_string()), - space_id: Some(space_id), - feature_set_type: FeatureSetType::All, - server_id: None, - is_builtin: true, - is_deleted: false, - created_at: now, - updated_at: now, - members: vec![], - } - } - - /// Create the "Default" featureset for a space + /// Create the "Default" featureset for a space. + /// + /// Uses a deterministic id (`fs_default_`) so repositories can + /// upsert this row without having to remember a mapping. pub fn new_default(space_id: impl Into) -> Self { let space_id = space_id.into(); let now = Utc::now(); Self { id: format!("fs_default_{}", space_id), name: "Default".to_string(), - description: Some( - "Features automatically granted to all connected clients in this space".to_string(), - ), + description: Some("The fallback feature set for this space".to_string()), icon: Some("⭐".to_string()), space_id: Some(space_id), feature_set_type: FeatureSetType::Default, @@ -266,32 +234,6 @@ impl FeatureSet { } } - /// Create a "Server-All" featureset for a specific server in a space - pub fn new_server_all( - space_id: impl Into, - server_id: impl Into, - server_name: impl Into, - ) -> Self { - let space_id = space_id.into(); - let server_id = server_id.into(); - let server_name = server_name.into(); - let now = Utc::now(); - Self { - id: format!("fs_server_{}_{}", server_id, space_id), - name: format!("{} - All", server_name), - description: Some(format!("All features from the {} server", server_name)), - icon: Some("📦".to_string()), - space_id: Some(space_id), - feature_set_type: FeatureSetType::ServerAll, - server_id: Some(server_id), - is_builtin: true, - is_deleted: false, - created_at: now, - updated_at: now, - members: vec![], - } - } - /// Add description pub fn with_description(mut self, desc: impl Into) -> Self { self.description = Some(desc.into()); @@ -304,35 +246,16 @@ impl FeatureSet { self } - /// Check if this featureset is the "All" type for a space - pub fn is_all_type(&self) -> bool { - self.feature_set_type == FeatureSetType::All - } - /// Check if this featureset is the "Default" type for a space pub fn is_default_type(&self) -> bool { self.feature_set_type == FeatureSetType::Default } - - /// Check if this featureset is the "ServerAll" type - pub fn is_server_all_type(&self) -> bool { - self.feature_set_type == FeatureSetType::ServerAll - } } #[cfg(test)] mod tests { use super::*; - #[test] - fn test_new_all_featureset() { - let fs = FeatureSet::new_all("space_123"); - assert_eq!(fs.id, "fs_all_space_123"); - assert_eq!(fs.feature_set_type, FeatureSetType::All); - assert!(fs.is_builtin); - assert!(fs.is_all_type()); - } - #[test] fn test_new_default_featureset() { let fs = FeatureSet::new_default("space_123"); @@ -342,16 +265,6 @@ mod tests { assert!(fs.is_default_type()); } - #[test] - fn test_new_server_all_featureset() { - let fs = FeatureSet::new_server_all("space_123", "github-mcp", "GitHub"); - assert_eq!(fs.id, "fs_server_github-mcp_space_123"); - assert_eq!(fs.feature_set_type, FeatureSetType::ServerAll); - assert_eq!(fs.server_id, Some("github-mcp".to_string())); - assert!(fs.is_builtin); - assert!(fs.is_server_all_type()); - } - #[test] fn test_new_custom_featureset() { let fs = FeatureSet::new_custom("My Custom Set", "space_123"); @@ -369,39 +282,30 @@ mod tests { // FeatureSetType parse tests #[test] fn test_feature_set_type_parse() { - assert_eq!(FeatureSetType::parse("all"), Some(FeatureSetType::All)); assert_eq!( FeatureSetType::parse("default"), Some(FeatureSetType::Default) ); - assert_eq!( - FeatureSetType::parse("server-all"), - Some(FeatureSetType::ServerAll) - ); assert_eq!( FeatureSetType::parse("custom"), Some(FeatureSetType::Custom) ); assert_eq!(FeatureSetType::parse("invalid"), None); assert_eq!(FeatureSetType::parse(""), None); + // Legacy variants no longer exist + assert_eq!(FeatureSetType::parse("all"), None); + assert_eq!(FeatureSetType::parse("server-all"), None); } #[test] fn test_feature_set_type_as_str() { - assert_eq!(FeatureSetType::All.as_str(), "all"); assert_eq!(FeatureSetType::Default.as_str(), "default"); - assert_eq!(FeatureSetType::ServerAll.as_str(), "server-all"); assert_eq!(FeatureSetType::Custom.as_str(), "custom"); } #[test] fn test_feature_set_type_roundtrip() { - for fs_type in [ - FeatureSetType::All, - FeatureSetType::Default, - FeatureSetType::ServerAll, - FeatureSetType::Custom, - ] { + for fs_type in [FeatureSetType::Default, FeatureSetType::Custom] { let s = fs_type.as_str(); let parsed = FeatureSetType::parse(s).expect("should parse"); assert_eq!(parsed, fs_type); diff --git a/crates/mcpmux-core/src/domain/mod.rs b/crates/mcpmux-core/src/domain/mod.rs index f164ce3..b0d72e0 100644 --- a/crates/mcpmux-core/src/domain/mod.rs +++ b/crates/mcpmux-core/src/domain/mod.rs @@ -32,4 +32,7 @@ pub use server::*; pub use server_feature::*; pub use server_log::*; pub use space::*; -pub use workspace_binding::{longest_prefix_match, normalize_workspace_root, WorkspaceBinding}; +pub use workspace_binding::{ + longest_prefix_match, normalize_workspace_root, validate_workspace_root, WorkspaceBinding, + WorkspaceRootValidation, +}; diff --git a/crates/mcpmux-core/src/domain/space.rs b/crates/mcpmux-core/src/domain/space.rs index bf415a6..e88b258 100644 --- a/crates/mcpmux-core/src/domain/space.rs +++ b/crates/mcpmux-core/src/domain/space.rs @@ -27,13 +27,6 @@ pub struct Space { /// Sort order for display pub sort_order: i32, - /// Active FeatureSet id — the default FS applied to every client in this - /// Space when neither an access-key pin nor a workspace-root binding matches. - /// - /// `None` means "deny by default" — routing returns an empty toolset. - #[serde(default)] - pub active_feature_set_id: Option, - /// Creation timestamp pub created_at: DateTime, @@ -52,7 +45,6 @@ impl Space { description: None, is_default: false, sort_order: 0, - active_feature_set_id: None, created_at: now, updated_at: now, } diff --git a/crates/mcpmux-core/src/domain/workspace_binding.rs b/crates/mcpmux-core/src/domain/workspace_binding.rs index 115b8f1..0b4c934 100644 --- a/crates/mcpmux-core/src/domain/workspace_binding.rs +++ b/crates/mcpmux-core/src/domain/workspace_binding.rs @@ -1,80 +1,132 @@ -//! WorkspaceBinding entity — maps a workspace root on disk to a FeatureSet. +//! WorkspaceBinding entity — maps a workspace root on disk to a concrete +//! (Space, FeatureSet) pair. //! -//! Bindings are the middle tier of FeatureSet resolution: -//! pinned_feature_set_id (on Client) > WorkspaceBinding > Space.active_feature_set_id. +//! Bindings are the only override surface for FS resolution: //! -//! When a connected client declares MCP `roots` capability, the gateway calls -//! `roots/list` and matches each reported `file://` root against the bindings -//! for the client's Space using longest-prefix-wins. +//! workspace root matches a binding? → (binding.space_id, binding.feature_set_id) +//! else → (default Space, its seeded Default FS) +//! +//! Path handling is **platform-agnostic**. A binding written on Windows +//! (`d:\work\proj`) has to match correctly on a Linux host that's just +//! reading the DB (and vice versa). We detect the path style from the +//! string itself — drive-letter prefix ⇒ Windows, leading `/` ⇒ POSIX — +//! rather than from `cfg!(windows)`. Both separators are accepted for +//! prefix matching regardless of the host OS. use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -/// A binding between a normalized workspace root path and a FeatureSet. -/// -/// Uniqueness is `(space_id, workspace_root)` — the same on-disk directory -/// can bind different FeatureSets in different Spaces. +/// A binding between a normalized workspace root and a concrete +/// (Space, FeatureSet) pair. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct WorkspaceBinding { - /// Unique identifier pub id: Uuid, - - /// Space this binding belongs to - pub space_id: Uuid, - - /// Normalized absolute path. - /// - /// Normalization rules (applied before insert/compare): - /// * resolve symlinks / junctions (`std::fs::canonicalize`) - /// * Windows: lowercase drive letter, use backslashes - /// * strip trailing path separator - /// * drop the `file://` scheme if the caller provided a URI pub workspace_root: String, - - /// FeatureSet to apply when this binding matches - pub feature_set_id: Uuid, - - /// Creation timestamp + pub space_id: Uuid, + pub feature_set_id: String, pub created_at: DateTime, - - /// Last update timestamp pub updated_at: DateTime, } impl WorkspaceBinding { - /// Create a new binding. Caller is responsible for passing an already-normalized path. - pub fn new(space_id: Uuid, workspace_root: impl Into, feature_set_id: Uuid) -> Self { + pub fn new( + workspace_root: impl Into, + space_id: Uuid, + feature_set_id: impl Into, + ) -> Self { let now = Utc::now(); Self { id: Uuid::new_v4(), - space_id, workspace_root: workspace_root.into(), - feature_set_id, + space_id, + feature_set_id: feature_set_id.into(), created_at: now, updated_at: now, } } } +// ============================================================================ +// Path style detection +// ============================================================================ + +/// Which family of absolute-path syntax a string uses. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PathStyle { + /// POSIX / Unix absolute path: `/home/me/proj`. + Posix, + /// Windows drive-letter path: `C:\work\proj`, `c:/work/proj`, or `c:`. + WindowsDrive, + /// Windows UNC path: `\\server\share\...`. + WindowsUnc, +} + +/// Detect the style from the first few characters of an already-scheme- +/// stripped path. Returns None when it isn't recognizably absolute. +fn detect_style(path: &str) -> Option { + let bytes = path.as_bytes(); + if path.starts_with("\\\\") || path.starts_with("//") { + return Some(PathStyle::WindowsUnc); + } + // `c:` / `c:\` / `c:/...` — drive letter then colon. + if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' { + return Some(PathStyle::WindowsDrive); + } + if path.starts_with('/') { + return Some(PathStyle::Posix); + } + None +} + +// ============================================================================ +// Normalization +// ============================================================================ + /// Normalize an absolute filesystem path or `file://` URI into the canonical /// form used for binding comparisons. /// -/// This is the single source of truth for path comparisons — always route -/// through here before calling any repository method that takes `workspace_root`. +/// Platform-agnostic — the output only depends on the input's syntax, not +/// on the host OS. Same input always yields the same output. +/// +/// Rules: +/// * Strip `file://` / `file:///` scheme (tolerating an optional host). +/// * URL-decode percent escapes. +/// * On Windows-style paths: +/// - Lowercase the drive letter (`D:` → `d:`). +/// - Use `\` as the separator throughout (`d:/foo` → `d:\foo`). +/// - Strip trailing separators but keep `c:\` as the root form. +/// * On POSIX paths: strip trailing `/` but keep `/` alone. +/// * On empty input: return empty string (callers filter). pub fn normalize_workspace_root(input: &str) -> String { - // Empty in → empty out: callers filter on this to drop garbage roots - // without needing to know about "/" vs "\" filesystem conventions. if input.is_empty() { return String::new(); } - // Strip file:// scheme if present; tolerate both "file:///abs/path" and - // "file://host/abs/path" (we don't use host, it's always localhost). + let decoded = strip_scheme_and_decode(input); + + // `file:///D:/foo` → after scheme strip + decode we have `/D:/foo`. The + // leading `/` is a URI artifact, not part of the path — drop it so the + // drive-letter detector can fire on the following byte. + let cleaned = strip_leading_slash_before_drive(&decoded); + + match detect_style(&cleaned) { + Some(PathStyle::Posix) => normalize_posix(&cleaned), + Some(PathStyle::WindowsDrive) => normalize_windows_drive(&cleaned), + Some(PathStyle::WindowsUnc) => normalize_windows_unc(&cleaned), + None => { + // Unrecognized / relative — return as-is (trimmed). Callers + // that require an absolute path should use + // [`validate_workspace_root`] instead of trusting normalization. + cleaned.trim().to_string() + } + } +} + +fn strip_scheme_and_decode(input: &str) -> String { let without_scheme = if let Some(rest) = input.strip_prefix("file://") { - // A leading triple-slash (file:///abs) leaves us with "/abs". - // A double-slash host form (file://localhost/abs) leaves us with - // "localhost/abs" — drop the host component before the first slash. + // Triple-slash form `file:///abs` → `rest` = `/abs`. Host form + // `file://localhost/abs` → drop up to the first `/`. match rest.find('/') { Some(0) => rest.to_string(), Some(n) => rest[n..].to_string(), @@ -84,78 +136,202 @@ pub fn normalize_workspace_root(input: &str) -> String { input.to_string() }; - // URL-decode percent-escapes (e.g. %20 -> space) — MCP roots are URIs. - let decoded = urlencoding::decode(&without_scheme) + urlencoding::decode(&without_scheme) .map(|s| s.into_owned()) - .unwrap_or(without_scheme); - - // On Windows, "file:///D:/foo" decodes to "/D:/foo" — strip the leading - // slash so callers see "D:\foo"-style paths before case folding. - #[cfg(windows)] - let stripped = { - let trimmed = decoded - .strip_prefix('/') - .filter(|rest| { - let bytes = rest.as_bytes(); - bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' - }) - .unwrap_or(&decoded); - trimmed.replace('/', "\\") + .unwrap_or(without_scheme) +} + +fn strip_leading_slash_before_drive(path: &str) -> String { + let rest = match path.strip_prefix('/') { + Some(r) => r, + None => return path.to_string(), }; - #[cfg(not(windows))] - let stripped = decoded; - - // Lowercase the drive letter on Windows so "D:\" and "d:\" compare equal. - #[cfg(windows)] - let cased = { - let mut chars: Vec = stripped.chars().collect(); - if chars.len() >= 2 && chars[0].is_ascii_alphabetic() && chars[1] == ':' { - chars[0] = chars[0].to_ascii_lowercase(); + let bytes = rest.as_bytes(); + let looks_like_drive = bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'; + if looks_like_drive { + rest.to_string() + } else { + path.to_string() + } +} + +fn normalize_posix(path: &str) -> String { + let trimmed = path.trim_end_matches('/'); + if trimmed.is_empty() { + "/".to_string() + } else { + trimmed.to_string() + } +} + +fn normalize_windows_drive(path: &str) -> String { + // Lowercase the drive letter. + let mut chars: Vec = path.chars().collect(); + if !chars.is_empty() && chars[0].is_ascii_alphabetic() { + chars[0] = chars[0].to_ascii_lowercase(); + } + let mut s: String = chars.into_iter().collect(); + + // Convert every `/` to `\` for canonical Windows form. + s = s.replace('/', "\\"); + + // Trim trailing `\`, but keep `c:\` as a root form. + let trimmed = s.trim_end_matches('\\'); + if trimmed.len() < 2 { + return s; + } + // After trim, `c:` needs its trailing `\` back to remain absolute. + if trimmed.ends_with(':') { + format!("{trimmed}\\") + } else { + trimmed.to_string() + } +} + +fn normalize_windows_unc(path: &str) -> String { + // `\\server\share\path` — normalize separators to `\` and strip trailing `\`. + let s = path.replace('/', "\\"); + let trimmed = s.trim_end_matches('\\'); + // Preserve the leading `\\` prefix. + if trimmed.len() < 2 { + "\\\\".to_string() + } else { + trimmed.to_string() + } +} + +// ============================================================================ +// Validation (for manual user input) +// ============================================================================ + +/// Validation outcome for a prospective workspace root, returned by +/// [`validate_workspace_root`]. The UI renders normalized in the success +/// case and `reason` in the failure case. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WorkspaceRootValidation { + /// Empty input — UI shouldn't show an error while the field is empty. + Empty, + /// Accepted; `normalized` is what the caller should persist/submit. + Ok { normalized: String }, + /// Rejected; show `reason` to the user. + Invalid { reason: String }, +} + +/// Validate a user-entered workspace root. +/// +/// Applied on manual add/edit ONLY — roots reported by connected MCP +/// clients are trusted (they come from a live `roots/list` response via +/// `SessionRootsRegistry` and are normalized on insert). +/// +/// Rules enforced, independent of the host OS: +/// * Non-empty after trim. +/// * Normalization must classify the input as a real absolute path +/// (POSIX, Windows drive, or Windows UNC). +/// * Not the filesystem root alone (`/`, `c:\`, `\\`) — binding that +/// captures every session defeats the purpose. +/// * Windows-style paths may not contain `<>:"|?*` or stray `:` outside +/// the drive-letter position — the OS forbids those in filenames so a +/// path that contains them can't correspond to a real folder. +pub fn validate_workspace_root(input: &str) -> WorkspaceRootValidation { + let trimmed = input.trim(); + if trimmed.is_empty() { + return WorkspaceRootValidation::Empty; + } + + let normalized = normalize_workspace_root(trimmed); + if normalized.is_empty() { + return WorkspaceRootValidation::Invalid { + reason: "Path is empty after normalization.".into(), + }; + } + + let style = match detect_style(&normalized) { + Some(s) => s, + None => { + return WorkspaceRootValidation::Invalid { + reason: "Path must be absolute (e.g. /home/me/proj or D:\\work\\proj). \ + Relative paths can't route." + .into(), + }; } - chars.into_iter().collect::() }; - #[cfg(not(windows))] - let cased = stripped; - // Strip trailing path separators (but keep a root like "/" or "d:\"). - let sep: &[char] = if cfg!(windows) { &['\\', '/'] } else { &['/'] }; - let trimmed = cased.trim_end_matches(sep); + if is_filesystem_root(&normalized, style) { + return WorkspaceRootValidation::Invalid { + reason: + "Can't bind the filesystem root — every session would match. Pick a project folder." + .into(), + }; + } - // Preserve root — if the trim removed everything, keep one separator. - if trimmed.is_empty() { - if cfg!(windows) { - "\\".to_string() - } else { - "/".to_string() + if matches!(style, PathStyle::WindowsDrive | PathStyle::WindowsUnc) { + if let Err(reason) = check_windows_reserved_chars(&normalized) { + return WorkspaceRootValidation::Invalid { reason }; + } + } + + WorkspaceRootValidation::Ok { normalized } +} + +fn is_filesystem_root(normalized: &str, style: PathStyle) -> bool { + match style { + PathStyle::Posix => normalized == "/", + PathStyle::WindowsDrive => { + // `c:\` — 3 chars, drive letter + colon + backslash. + normalized.len() == 3 && normalized.ends_with(":\\") + } + PathStyle::WindowsUnc => normalized == "\\\\", + } +} + +fn check_windows_reserved_chars(path: &str) -> Result<(), String> { + const RESERVED: &[char] = &['<', '>', '"', '|', '?', '*']; + // Byte index 1 is the drive-letter colon (`c:`); that's the only place + // `:` is legal. Everywhere else it's a reserved character. + for (i, ch) in path.char_indices() { + if i == 1 && ch == ':' { + continue; + } + if ch == ':' { + return Err(format!("Illegal character ':' in path at position {i}.")); + } + if RESERVED.contains(&ch) { + return Err(format!( + "Illegal character '{ch}' — Windows forbids {} in filenames.", + RESERVED + .iter() + .map(|c| format!("'{c}'")) + .collect::>() + .join(", ") + )); } - } else if cfg!(windows) && trimmed.ends_with(':') { - // "d:" → "d:\" - format!("{}\\", trimmed) - } else { - trimmed.to_string() } + Ok(()) } +// ============================================================================ +// Longest-prefix match (separator-agnostic) +// ============================================================================ + /// Returns the `workspace_root` in `candidates` whose path is the longest -/// prefix of `query`. Used by the resolver to pick which binding wins when -/// a client reports multiple roots. +/// prefix of `query`, respecting path-component boundaries. /// /// Both `query` and every candidate MUST be already normalized via -/// [`normalize_workspace_root`] — this function does not re-normalize. +/// [`normalize_workspace_root`]. The boundary check accepts either `/` or +/// `\` regardless of host OS so a binding written on Windows matches a +/// Linux reader (and vice versa). pub fn longest_prefix_match<'a, I>(query: &str, candidates: I) -> Option<&'a str> where I: IntoIterator, { let mut best: Option<&'a str> = None; for candidate in candidates { - // Match only at a path-component boundary so "/workspaces/foo" does - // not match a binding for "/workspaces/foo-bar". let matches = query == candidate || (query.starts_with(candidate) && query .as_bytes() .get(candidate.len()) - .is_some_and(|b| *b == b'/' || (cfg!(windows) && *b == b'\\'))); + .is_some_and(|b| *b == b'/' || *b == b'\\')); if matches && best.map(|b| candidate.len() > b.len()).unwrap_or(true) { best = Some(candidate); } @@ -167,36 +343,44 @@ where mod tests { use super::*; + // ---- normalize ------------------------------------------------------- + #[test] - fn test_normalize_file_uri_unix() { - let n = normalize_workspace_root("file:///home/user/proj"); - #[cfg(not(windows))] - assert_eq!(n, "/home/user/proj"); - #[cfg(windows)] - assert_eq!(n, "\\home\\user\\proj"); + fn normalize_posix_plain() { + assert_eq!( + normalize_workspace_root("/home/user/proj"), + "/home/user/proj" + ); + } + + #[test] + fn normalize_posix_trailing_slash() { + assert_eq!( + normalize_workspace_root("/home/user/proj/"), + "/home/user/proj" + ); } #[test] - fn test_normalize_trailing_sep() { - let sep = if cfg!(windows) { "\\" } else { "/" }; - let input = format!("/foo/bar{sep}"); - let n = normalize_workspace_root(&input); - assert!(!n.ends_with(sep) || n.len() <= 3, "got {n}"); + fn normalize_posix_file_uri() { + assert_eq!( + normalize_workspace_root("file:///home/user/proj"), + "/home/user/proj" + ); } - #[cfg(windows)] #[test] - fn test_normalize_windows_drive_letter_case_insensitive() { + fn normalize_windows_plain_on_any_host() { + // Normalization runs the same everywhere — cfg(windows) isn't involved. assert_eq!( normalize_workspace_root("D:\\Projects\\Foo"), - normalize_workspace_root("d:\\Projects\\Foo") + "d:\\Projects\\Foo" ); - assert_eq!(normalize_workspace_root("D:"), "d:\\"); + assert_eq!(normalize_workspace_root("C:/work/proj"), "c:\\work\\proj"); } - #[cfg(windows)] #[test] - fn test_normalize_windows_file_uri() { + fn normalize_windows_file_uri_on_any_host() { assert_eq!( normalize_workspace_root("file:///D:/Projects/Foo"), "d:\\Projects\\Foo" @@ -204,13 +388,152 @@ mod tests { } #[test] - fn test_percent_decoded() { + fn normalize_windows_drive_letter_case_insensitive() { + assert_eq!( + normalize_workspace_root("D:\\Projects\\Foo"), + normalize_workspace_root("d:\\Projects\\Foo") + ); + } + + #[test] + fn normalize_windows_trailing_sep() { + assert_eq!(normalize_workspace_root("D:\\work\\"), "d:\\work"); + assert_eq!(normalize_workspace_root("D:\\"), "d:\\"); + assert_eq!(normalize_workspace_root("D:"), "d:\\"); + } + + #[test] + fn normalize_unc_basic() { + assert_eq!( + normalize_workspace_root("\\\\server\\share\\folder"), + "\\\\server\\share\\folder" + ); + assert_eq!( + normalize_workspace_root("\\\\server\\share\\folder\\"), + "\\\\server\\share\\folder" + ); + } + + #[test] + fn normalize_percent_decoded() { let n = normalize_workspace_root("file:///home/user/my%20project"); - assert!(n.ends_with("my project")); + assert_eq!(n, "/home/user/my project"); + } + + // ---- validate -------------------------------------------------------- + + #[test] + fn validate_empty_is_not_an_error() { + assert_eq!(validate_workspace_root(""), WorkspaceRootValidation::Empty); + assert_eq!( + validate_workspace_root(" "), + WorkspaceRootValidation::Empty + ); + } + + #[test] + fn validate_accepts_posix() { + assert_eq!( + validate_workspace_root("/home/me/proj"), + WorkspaceRootValidation::Ok { + normalized: "/home/me/proj".into() + } + ); + } + + #[test] + fn validate_accepts_windows_on_any_host() { + assert_eq!( + validate_workspace_root("D:\\proj"), + WorkspaceRootValidation::Ok { + normalized: "d:\\proj".into() + } + ); + assert_eq!( + validate_workspace_root("c:/work/proj/"), + WorkspaceRootValidation::Ok { + normalized: "c:\\work\\proj".into() + } + ); + } + + #[test] + fn validate_accepts_unc() { + assert_eq!( + validate_workspace_root("\\\\server\\share\\folder"), + WorkspaceRootValidation::Ok { + normalized: "\\\\server\\share\\folder".into() + } + ); + } + + #[test] + fn validate_accepts_both_file_uris() { + assert_eq!( + validate_workspace_root("file:///home/me/proj"), + WorkspaceRootValidation::Ok { + normalized: "/home/me/proj".into() + } + ); + assert_eq!( + validate_workspace_root("file:///D:/proj"), + WorkspaceRootValidation::Ok { + normalized: "d:\\proj".into() + } + ); } #[test] - fn test_longest_prefix_match_exact() { + fn validate_rejects_relative() { + assert!(matches!( + validate_workspace_root("my-project"), + WorkspaceRootValidation::Invalid { .. } + )); + assert!(matches!( + validate_workspace_root("./proj"), + WorkspaceRootValidation::Invalid { .. } + )); + assert!(matches!( + validate_workspace_root("~/proj"), + WorkspaceRootValidation::Invalid { .. } + )); + } + + #[test] + fn validate_rejects_filesystem_root() { + for bad in &["/", "D:\\", "d:\\", "\\\\"] { + match validate_workspace_root(bad) { + WorkspaceRootValidation::Invalid { reason } => { + assert!( + reason.to_lowercase().contains("filesystem root"), + "got {reason}" + ); + } + other => panic!("expected Invalid for {bad:?}, got {other:?}"), + } + } + } + + #[test] + fn validate_rejects_windows_reserved_chars() { + match validate_workspace_root("D:\\bad|name") { + WorkspaceRootValidation::Invalid { reason } => { + assert!(reason.contains('|'), "got {reason}"); + } + other => panic!("expected Invalid, got {other:?}"), + } + match validate_workspace_root("D:\\has { + assert!(reason.contains('<'), "got {reason}"); + } + other => panic!("expected Invalid, got {other:?}"), + } + } + + // ---- longest_prefix_match — cross-platform --------------------------- + + #[test] + fn longest_prefix_posix() { let bindings = ["/a", "/a/b", "/a/b/c"]; assert_eq!(longest_prefix_match("/a/b/c", bindings), Some("/a/b/c")); assert_eq!(longest_prefix_match("/a/b/c/d", bindings), Some("/a/b/c")); @@ -218,14 +541,29 @@ mod tests { } #[test] - fn test_longest_prefix_no_false_partial() { - // "/a/b-extra" must NOT match binding "/a/b". + fn longest_prefix_windows_runs_on_any_host() { + // No cfg(windows) gating — this test must pass on Linux CI too. + let bindings = ["d:\\work", "d:\\work\\proj"]; + assert_eq!( + longest_prefix_match("d:\\work\\proj\\src", bindings), + Some("d:\\work\\proj") + ); + assert_eq!( + longest_prefix_match("d:\\work\\other", bindings), + Some("d:\\work") + ); + } + + #[test] + fn longest_prefix_no_false_partial() { let bindings = ["/a/b"]; assert_eq!(longest_prefix_match("/a/b-extra", bindings), None); + let win = ["d:\\work"]; + assert_eq!(longest_prefix_match("d:\\workspace", win), None); } #[test] - fn test_longest_prefix_empty_candidates() { + fn longest_prefix_empty_candidates() { let bindings: [&str; 0] = []; assert_eq!(longest_prefix_match("/a", bindings), None); } diff --git a/crates/mcpmux-core/src/repository/mod.rs b/crates/mcpmux-core/src/repository/mod.rs index d616cdd..1a215d9 100644 --- a/crates/mcpmux-core/src/repository/mod.rs +++ b/crates/mcpmux-core/src/repository/mod.rs @@ -37,16 +37,6 @@ pub trait SpaceRepository: Send + Sync { /// Set a space as default async fn set_default(&self, id: &Uuid) -> RepoResult<()>; - - /// Set (or clear, with `None`) the active FeatureSet for a Space. - /// - /// The active FS is the fallback applied when a connected client has - /// no pinned FS and no matching workspace binding. - async fn set_active_feature_set( - &self, - id: &Uuid, - feature_set_id: Option<&Uuid>, - ) -> RepoResult<()>; } /// InstalledServer repository trait @@ -167,36 +157,15 @@ pub trait FeatureSetRepository: Send + Sync { /// Delete a feature set (soft delete) async fn delete(&self, id: &str) -> RepoResult<()>; - /// Get builtin feature sets for a space - async fn list_builtin(&self, space_id: &str) -> RepoResult>; - - /// Get server-all featureset for a server in a space - async fn get_server_all( - &self, - space_id: &str, - server_id: &str, - ) -> RepoResult>; - - /// Create server-all featureset if it doesn't exist - async fn ensure_server_all( - &self, - space_id: &str, - server_id: &str, - server_name: &str, - ) -> RepoResult; - /// Get the "Default" featureset for a space async fn get_default_for_space(&self, space_id: &str) -> RepoResult>; - /// Get the "All" featureset for a space - async fn get_all_for_space(&self, space_id: &str) -> RepoResult>; - - /// Ensure builtin feature sets exist for a space (All + Default) + /// Ensure the built-in Default feature set exists for a space. + /// + /// Called during Space creation and any time the resolver falls back and + /// cannot find a Default to route to (defensive re-seed). async fn ensure_builtin_for_space(&self, space_id: &str) -> RepoResult<()>; - /// Delete server-all feature set for a server (used when uninstalling) - async fn delete_server_all(&self, space_id: &str, server_id: &str) -> RepoResult<()>; - /// Add an individual feature as a member of a feature set async fn add_feature_member( &self, @@ -217,6 +186,9 @@ pub trait FeatureSetRepository: Send + Sync { /// /// Manages MCP client entities (apps connecting TO McpMux). /// Works with the unified `inbound_clients` table. +/// +/// Only identity is persisted here — routing is resolved per-session +/// via WorkspaceBinding and each Space's Default feature set. #[async_trait] pub trait InboundMcpClientRepository: Send + Sync { /// Get all clients @@ -236,58 +208,6 @@ pub trait InboundMcpClientRepository: Send + Sync { /// Delete a client async fn delete(&self, id: &Uuid) -> RepoResult<()>; - - /// Grant a feature set to a client for a specific space - async fn grant_feature_set( - &self, - client_id: &Uuid, - space_id: &str, - feature_set_id: &str, - ) -> RepoResult<()>; - - /// Revoke a feature set from a client for a specific space - async fn revoke_feature_set( - &self, - client_id: &Uuid, - space_id: &str, - feature_set_id: &str, - ) -> RepoResult<()>; - - /// Get all feature set IDs granted to a client for a specific space - async fn get_grants_for_space( - &self, - client_id: &Uuid, - space_id: &str, - ) -> RepoResult>; - - /// Get all grants for a client (all spaces) - async fn get_all_grants( - &self, - client_id: &Uuid, - ) -> RepoResult>>; - - /// Set all grants for a client in a space (replaces existing) - async fn set_grants_for_space( - &self, - client_id: &Uuid, - space_id: &str, - feature_set_ids: &[String], - ) -> RepoResult<()>; - - /// Check if client has any grants for a space - async fn has_grants_for_space(&self, client_id: &Uuid, space_id: &str) -> RepoResult; - - /// Set the pinned Space + optional pinned FeatureSet for a client. - /// - /// This is the new (FeatureSet Resolver V2) path: each client row is an - /// independent approval bound to one Space. `pinned_feature_set_id = None` - /// means the client follows workspace-binding / space-active FS. - async fn set_pin( - &self, - client_id: &Uuid, - pinned_space_id: &Uuid, - pinned_feature_set_id: Option<&Uuid>, - ) -> RepoResult<()>; } /// Workspace binding repository trait @@ -315,11 +235,13 @@ pub trait WorkspaceBindingRepository: Send + Sync { /// Delete a binding by id. async fn delete(&self, id: &Uuid) -> RepoResult<()>; - /// Resolve which FeatureSet applies for a set of candidate workspace roots. + /// Resolve which binding applies for a set of candidate workspace roots. /// /// Every candidate MUST already be normalized. Returns the binding whose - /// `workspace_root` is the longest prefix of any candidate, or `None` - /// when no binding matches. + /// `workspace_root` is the longest prefix of any candidate, scoped to the + /// given Space (so bindings in unrelated spaces don't leak across). The + /// caller is responsible for then following the binding's space_mode and + /// fs_mode to compute the effective Space + FeatureSet. async fn find_longest_prefix_match( &self, space_id: &Uuid, diff --git a/crates/mcpmux-core/src/service/client_service.rs b/crates/mcpmux-core/src/service/client_service.rs deleted file mode 100644 index e3255e1..0000000 --- a/crates/mcpmux-core/src/service/client_service.rs +++ /dev/null @@ -1,170 +0,0 @@ -//! Client Service - manages AI client configuration and grants -//! -//! Handles auto-granting of Default feature set and permission resolution. - -use std::sync::Arc; - -use anyhow::Result; -use tracing::{info, warn}; -use uuid::Uuid; - -use crate::repository::{FeatureSetRepository, InboundMcpClientRepository}; - -/// Service for managing AI clients and their permissions -pub struct ClientService { - client_repository: Arc, - feature_set_repository: Arc, -} - -impl ClientService { - /// Create a new client service - pub fn new( - client_repository: Arc, - feature_set_repository: Arc, - ) -> Self { - Self { - client_repository, - feature_set_repository, - } - } - - /// Ensure a client has the Default feature set granted for a space. - /// This is called when a client first connects to a space. - pub async fn ensure_default_grant(&self, client_id: &Uuid, space_id: &str) -> Result { - // Check if client already has any grants for this space - if self - .client_repository - .has_grants_for_space(client_id, space_id) - .await? - { - return Ok(false); // Already has grants, don't auto-grant - } - - // Get the Default feature set for this space - let default_fs = match self - .feature_set_repository - .get_default_for_space(space_id) - .await? - { - Some(fs) => fs, - None => { - warn!( - "Default feature set not found for space {}. Attempting to create.", - space_id - ); - // Try to create builtin feature sets - self.feature_set_repository - .ensure_builtin_for_space(space_id) - .await?; - - // Try again - match self - .feature_set_repository - .get_default_for_space(space_id) - .await? - { - Some(fs) => fs, - None => { - anyhow::bail!( - "Could not find or create Default feature set for space {}", - space_id - ); - } - } - } - }; - - // Grant the Default feature set - self.client_repository - .grant_feature_set(client_id, space_id, &default_fs.id) - .await?; - - info!( - client_id = %client_id, - space_id = %space_id, - feature_set_id = %default_fs.id, - "Auto-granted Default feature set to client" - ); - - Ok(true) - } - - /// Ensure a client has the All feature set granted for a space. - pub async fn grant_all_features(&self, client_id: &Uuid, space_id: &str) -> Result<()> { - // Get the All feature set for this space - let all_fs = match self - .feature_set_repository - .get_all_for_space(space_id) - .await? - { - Some(fs) => fs, - None => { - // Try to create builtin feature sets - self.feature_set_repository - .ensure_builtin_for_space(space_id) - .await?; - - self.feature_set_repository - .get_all_for_space(space_id) - .await? - .ok_or_else(|| { - anyhow::anyhow!("Could not find All feature set for space {}", space_id) - })? - } - }; - - // Grant the All feature set - self.client_repository - .grant_feature_set(client_id, space_id, &all_fs.id) - .await?; - - info!( - client_id = %client_id, - space_id = %space_id, - feature_set_id = %all_fs.id, - "Granted All feature set to client" - ); - - Ok(()) - } - - /// Get all granted feature set IDs for a client in a space (explicit grants only) - pub async fn get_granted_feature_sets( - &self, - client_id: &Uuid, - space_id: &str, - ) -> Result> { - self.client_repository - .get_grants_for_space(client_id, space_id) - .await - } - - /// Get effective feature set IDs for a client in a space. - /// This includes explicit grants PLUS the default feature set for the space. - /// Returns a deduplicated set (no repetition). - pub async fn get_effective_grants( - &self, - client_id: &Uuid, - space_id: &str, - ) -> Result> { - // Get explicit grants from DB - let mut grants = self - .client_repository - .get_grants_for_space(client_id, space_id) - .await?; - - // Get default feature set for this space - if let Some(default_fs) = self - .feature_set_repository - .get_default_for_space(space_id) - .await? - { - // Add default if not already in grants (set semantics) - if !grants.contains(&default_fs.id) { - grants.push(default_fs.id); - } - } - - Ok(grants) - } -} diff --git a/crates/mcpmux-core/src/service/gateway_port_service.rs b/crates/mcpmux-core/src/service/gateway_port_service.rs index e6069f6..703f018 100644 --- a/crates/mcpmux-core/src/service/gateway_port_service.rs +++ b/crates/mcpmux-core/src/service/gateway_port_service.rs @@ -121,6 +121,18 @@ impl GatewayPortService { .map_err(|e| PortAllocationError::PersistFailed(e.to_string())) } + /// Clear the persisted gateway port. + /// + /// After clearing, [`resolve`] falls back to [`DEFAULT_GATEWAY_PORT`] (or + /// a dynamic port if the default is in use). Use this to reset the user's + /// override and return to default behavior. + pub async fn clear_persisted_port(&self) -> Result<(), PortAllocationError> { + self.settings + .delete(keys::gateway::PORT) + .await + .map_err(|e| PortAllocationError::PersistFailed(e.to_string())) + } + /// Resolve which port to use based on the fallback strategy. /// /// Strategy: @@ -313,6 +325,21 @@ mod tests { assert_eq!(service.load_persisted_port().await, Some(12345)); } + #[tokio::test] + async fn test_clear_persisted_port() { + let settings = Arc::new(InMemorySettings::new()); + let service = GatewayPortService::new(settings); + + service.save_port(54321).await.unwrap(); + assert_eq!(service.load_persisted_port().await, Some(54321)); + + service.clear_persisted_port().await.unwrap(); + assert!(service.load_persisted_port().await.is_none()); + + // Clearing again is a no-op + service.clear_persisted_port().await.unwrap(); + } + #[tokio::test] async fn test_auto_start() { let settings = Arc::new(InMemorySettings::new()); diff --git a/crates/mcpmux-core/src/service/mod.rs b/crates/mcpmux-core/src/service/mod.rs index d553357..8cddb47 100644 --- a/crates/mcpmux-core/src/service/mod.rs +++ b/crates/mcpmux-core/src/service/mod.rs @@ -5,10 +5,8 @@ pub mod app_settings_service; mod cimd_fetcher; mod client_install; -mod client_service; mod config_export; pub mod gateway_port_service; -mod permission_service; mod registry_api_client; mod server_discovery; mod server_log_manager; @@ -17,13 +15,11 @@ mod space_service; pub use app_settings_service::{keys, AppSettingsService}; pub use cimd_fetcher::*; pub use client_install::{cursor_deep_link, vscode_deep_link}; -pub use client_service::*; pub use config_export::*; pub use gateway_port_service::{ allocate_dynamic_port, is_port_available, GatewayPortService, PortAllocationError, PortResolution, DEFAULT_GATEWAY_PORT, }; -pub use permission_service::*; pub use registry_api_client::*; pub use server_discovery::*; pub use server_log_manager::*; diff --git a/crates/mcpmux-core/src/service/permission_service.rs b/crates/mcpmux-core/src/service/permission_service.rs deleted file mode 100644 index bb03b5d..0000000 --- a/crates/mcpmux-core/src/service/permission_service.rs +++ /dev/null @@ -1,344 +0,0 @@ -//! Permission Service - resolves effective features from granted feature sets -//! -//! This service computes which features a client can access based on their -//! granted feature sets and the feature set composition rules. - -use std::collections::HashSet; -use std::sync::Arc; - -use anyhow::Result; -use tracing::{debug, warn}; -use uuid::Uuid; - -use crate::domain::{FeatureSet, FeatureSetType, MemberMode, MemberType, ServerFeature}; -use crate::repository::{ - FeatureSetRepository, InboundMcpClientRepository, ServerFeatureRepository, -}; - -/// Resolved permissions for a client in a space -#[derive(Debug, Clone, Default)] -pub struct ResolvedPermissions { - /// Feature IDs that are allowed (from server_features table) - pub allowed_feature_ids: HashSet, - /// Whether this permission set grants all features - pub grants_all: bool, - /// Server IDs that grant all features (for server-all type) - pub all_from_servers: HashSet, -} - -impl ResolvedPermissions { - /// Check if a feature is allowed - pub fn allows_feature(&self, feature_id: &str, server_id: Option<&str>) -> bool { - if self.grants_all { - return true; - } - if let Some(sid) = server_id { - if self.all_from_servers.contains(sid) { - return true; - } - } - self.allowed_feature_ids.contains(feature_id) - } - - /// Check if a tool is allowed by name and server - pub fn allows_tool(&self, tool_name: &str, server_id: &str) -> bool { - if self.grants_all { - return true; - } - if self.all_from_servers.contains(server_id) { - return true; - } - // Check by qualified name (server_id/tool_name) - let qualified = format!("{}/{}", server_id, tool_name); - self.allowed_feature_ids.contains(&qualified) - } -} - -/// Service for resolving permissions -pub struct PermissionService { - client_repository: Arc, - feature_set_repository: Arc, - server_feature_repository: Arc, -} - -impl PermissionService { - /// Create a new permission service - pub fn new( - client_repository: Arc, - feature_set_repository: Arc, - server_feature_repository: Arc, - ) -> Self { - Self { - client_repository, - feature_set_repository, - server_feature_repository, - } - } - - /// Resolve effective permissions for a client in a space - pub async fn resolve_permissions( - &self, - client_id: &Uuid, - space_id: &str, - ) -> Result { - let mut result = ResolvedPermissions::default(); - - // Get granted feature set IDs - let granted_ids = self - .client_repository - .get_grants_for_space(client_id, space_id) - .await?; - - if granted_ids.is_empty() { - debug!( - client_id = %client_id, - space_id = %space_id, - "No grants found for client" - ); - return Ok(result); - } - - // Resolve each feature set - for fs_id in &granted_ids { - self.resolve_feature_set(fs_id, space_id, &mut result, &mut HashSet::new()) - .await?; - } - - debug!( - client_id = %client_id, - space_id = %space_id, - grants_all = %result.grants_all, - feature_count = %result.allowed_feature_ids.len(), - server_all_count = %result.all_from_servers.len(), - "Resolved permissions" - ); - - Ok(result) - } - - /// Recursively resolve a feature set - fn resolve_feature_set<'a>( - &'a self, - feature_set_id: &'a str, - space_id: &'a str, - result: &'a mut ResolvedPermissions, - visited: &'a mut HashSet, - ) -> std::pin::Pin> + Send + 'a>> { - Box::pin(async move { - // Prevent infinite recursion - if visited.contains(feature_set_id) { - warn!( - feature_set_id = %feature_set_id, - "Circular reference detected in feature set composition" - ); - return Ok(()); - } - visited.insert(feature_set_id.to_string()); - - // Get the feature set with members - let feature_set = match self - .feature_set_repository - .get_with_members(feature_set_id) - .await? - { - Some(fs) => fs, - None => { - warn!( - feature_set_id = %feature_set_id, - "Feature set not found" - ); - return Ok(()); - } - }; - - // Handle based on type - match feature_set.feature_set_type { - FeatureSetType::All => { - // All features in the space - result.grants_all = true; - debug!(feature_set_id = %feature_set_id, "Resolved as All type - grants all"); - } - FeatureSetType::Default => { - // Resolve members of the Default set - self.resolve_members(&feature_set, space_id, result, visited) - .await?; - } - FeatureSetType::ServerAll => { - // All features from a specific server - if let Some(ref server_id) = feature_set.server_id { - result.all_from_servers.insert(server_id.clone()); - debug!( - feature_set_id = %feature_set_id, - server_id = %server_id, - "Resolved as ServerAll type" - ); - } - } - FeatureSetType::Custom => { - // Resolve members recursively - self.resolve_members(&feature_set, space_id, result, visited) - .await?; - } - } - - Ok(()) - }) - } - - /// Resolve members of a feature set - fn resolve_members<'a>( - &'a self, - feature_set: &'a FeatureSet, - space_id: &'a str, - result: &'a mut ResolvedPermissions, - visited: &'a mut HashSet, - ) -> std::pin::Pin> + Send + 'a>> { - Box::pin(async move { - for member in &feature_set.members { - match member.mode { - MemberMode::Include => { - match member.member_type { - MemberType::FeatureSet => { - // Recursively resolve nested feature set - self.resolve_feature_set( - &member.member_id, - space_id, - result, - visited, - ) - .await?; - } - MemberType::Feature => { - // Add individual feature - result.allowed_feature_ids.insert(member.member_id.clone()); - } - } - } - MemberMode::Exclude => { - // For exclusions, remove from allowed set - result.allowed_feature_ids.remove(&member.member_id); - } - } - } - Ok(()) - }) - } - - /// Get all allowed features for a client in a space - pub async fn get_allowed_features( - &self, - client_id: &Uuid, - space_id: &str, - ) -> Result> { - let permissions = self.resolve_permissions(client_id, space_id).await?; - - // Get all features in the space - let all_features = self - .server_feature_repository - .list_for_space(space_id) - .await?; - - // Filter based on permissions - let allowed: Vec = if permissions.grants_all { - all_features - } else { - all_features - .into_iter() - .filter(|f| { - permissions.allows_feature(&f.id.to_string(), Some(&f.server_id)) - || permissions.all_from_servers.contains(&f.server_id) - || permissions.allowed_feature_ids.contains(&f.id.to_string()) - }) - .collect() - }; - - Ok(allowed) - } - - /// Check if a client can access a specific tool - pub async fn can_access_tool( - &self, - client_id: &Uuid, - space_id: &str, - tool_name: &str, - server_id: &str, - ) -> Result { - let permissions = self.resolve_permissions(client_id, space_id).await?; - Ok(permissions.allows_tool(tool_name, server_id)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_resolved_permissions_default() { - let perms = ResolvedPermissions::default(); - - assert!(!perms.grants_all); - assert!(perms.allowed_feature_ids.is_empty()); - assert!(perms.all_from_servers.is_empty()); - } - - #[test] - fn test_resolved_permissions_grants_all() { - let perms = ResolvedPermissions { - grants_all: true, - ..Default::default() - }; - - // grants_all should allow any feature - assert!(perms.allows_feature("any-feature", None)); - assert!(perms.allows_feature("any-feature", Some("any-server"))); - assert!(perms.allows_tool("any-tool", "any-server")); - } - - #[test] - fn test_resolved_permissions_explicit_features() { - let mut perms = ResolvedPermissions::default(); - perms.allowed_feature_ids.insert("feature-1".to_string()); - perms - .allowed_feature_ids - .insert("server-a/tool-x".to_string()); - - assert!(perms.allows_feature("feature-1", None)); - assert!(!perms.allows_feature("feature-2", None)); - - // Tool check uses qualified name - assert!(perms.allows_tool("tool-x", "server-a")); - assert!(!perms.allows_tool("tool-y", "server-a")); - } - - #[test] - fn test_resolved_permissions_server_all() { - let mut perms = ResolvedPermissions::default(); - perms.all_from_servers.insert("github-mcp".to_string()); - - // Any tool from that server should be allowed - assert!(perms.allows_tool("any-tool", "github-mcp")); - assert!(perms.allows_feature("any-feature", Some("github-mcp"))); - - // Other servers not allowed - assert!(!perms.allows_tool("tool", "other-server")); - assert!(!perms.allows_feature("feature", Some("other-server"))); - } - - #[test] - fn test_resolved_permissions_combined() { - let mut perms = ResolvedPermissions::default(); - perms - .allowed_feature_ids - .insert("explicit/tool".to_string()); - perms.all_from_servers.insert("trusted-server".to_string()); - - // Explicit feature - assert!(perms.allows_tool("tool", "explicit")); - - // Server-all grant - assert!(perms.allows_tool("any-tool", "trusted-server")); - - // Neither - assert!(!perms.allows_tool("tool", "untrusted")); - } -} diff --git a/crates/mcpmux-core/src/service/space_service.rs b/crates/mcpmux-core/src/service/space_service.rs index 4595dab..b5af692 100644 --- a/crates/mcpmux-core/src/service/space_service.rs +++ b/crates/mcpmux-core/src/service/space_service.rs @@ -91,27 +91,9 @@ impl SpaceService { self.repository.delete(id).await } - /// Get the active (default) space - pub async fn get_active(&self) -> anyhow::Result> { + /// Get the system's default Space (the gateway's routing fallback when + /// no `WorkspaceBinding` matches a session's reported workspace root). + pub async fn get_default(&self) -> anyhow::Result> { self.repository.get_default().await } - - /// Set the active space - pub async fn set_active(&self, id: &Uuid) -> anyhow::Result<()> { - self.repository.set_default(id).await - } - - /// Set (or clear with `None`) the active FeatureSet for a Space. - /// - /// This is the fallback FS applied to every connected client when no - /// access-key pin and no workspace-root binding matches. - pub async fn set_active_feature_set( - &self, - space_id: &Uuid, - feature_set_id: Option<&Uuid>, - ) -> anyhow::Result<()> { - self.repository - .set_active_feature_set(space_id, feature_set_id) - .await - } } diff --git a/crates/mcpmux-gateway/src/consumers/mcp_notifier.rs b/crates/mcpmux-gateway/src/consumers/mcp_notifier.rs index 9dd2b73..621c57d 100644 --- a/crates/mcpmux-gateway/src/consumers/mcp_notifier.rs +++ b/crates/mcpmux-gateway/src/consumers/mcp_notifier.rs @@ -402,59 +402,32 @@ impl MCPNotifier { } match event { - // ============ Grant Events ============ - // When grants are issued/revoked, tools/prompts/resources might change - DomainEvent::GrantIssued { - client_id, - space_id, - feature_set_id, - } => { - info!( - client_id = %client_id, - space_id = %space_id, - feature_set_id = %feature_set_id, - "[MCPNotifier] 📨 GrantIssued - notifying all clients in space" - ); - self.notify_all_list_changed(space_id, true).await; - } - - DomainEvent::GrantRevoked { - client_id, + DomainEvent::FeatureSetMembersChanged { space_id, feature_set_id, + .. } => { info!( - client_id = %client_id, space_id = %space_id, feature_set_id = %feature_set_id, - "[MCPNotifier] 📨 GrantRevoked - notifying all clients in space" - ); - self.notify_all_list_changed(space_id, true).await; - } - - DomainEvent::ClientGrantsUpdated { - client_id, - space_id, - feature_set_ids, - } => { - info!( - client_id = %client_id, - space_id = %space_id, - feature_sets = feature_set_ids.len(), - "[MCPNotifier] 📨 ClientGrantsUpdated - notifying all clients in space" + "[MCPNotifier] 📨 FeatureSetMembersChanged - notifying all clients in space" ); self.notify_all_list_changed(space_id, true).await; } - DomainEvent::FeatureSetMembersChanged { + // A workspace binding was created / updated / deleted. Every + // session in the space may now resolve to a different FS, so + // broadcast all three list_changed notifications. `force=true` + // bypasses the content-hash dedupe because the resolver's output + // changed even when backend tool content hasn't. + DomainEvent::WorkspaceBindingChanged { space_id, - feature_set_id, - .. + workspace_root, } => { info!( space_id = %space_id, - feature_set_id = %feature_set_id, - "[MCPNotifier] 📨 FeatureSetMembersChanged - notifying all clients in space" + workspace_root = %workspace_root, + "[MCPNotifier] 📨 WorkspaceBindingChanged - notifying all clients in space" ); self.notify_all_list_changed(space_id, true).await; } @@ -948,4 +921,45 @@ impl MCPNotifier { } } } + + /// Send all three list_changed notifications to a single peer, bypassing + /// the space-level hash dedup and throttle. + /// + /// Called when a *specific session's* feature-set resolution flips — + /// e.g. workspace roots arrive after `initialize` and now match a + /// binding, so the client's effective tool set differs from what it + /// just fetched. The space-wide bridge can't catch this on its own: + /// its hash is per-space, not per-resolved-FS, so a flip from the + /// fallback FS to a bound FS doesn't change the space hash even though + /// the client's view changed. + pub async fn notify_peer_lists_changed(&self, client_id: &str) { + if DISABLE_ALL_NOTIFICATIONS { + trace!(%client_id, "[MCPNotifier] 🚫 disabled — skipping peer list_changed"); + return; + } + + let peer = { + let peers = self.client_peers.read(); + peers + .get(client_id) + .filter(|h| h.has_active_stream) + .map(|h| h.peer.clone()) + }; + let Some(peer) = peer else { + debug!(%client_id, "[MCPNotifier] no active peer — skipping peer list_changed"); + return; + }; + + info!(%client_id, "[MCPNotifier] 📤 per-peer list_changed (resolution flipped)"); + + if let Err(e) = peer.notify_tool_list_changed().await { + warn!(error = ?e, %client_id, "[MCPNotifier] failed tools/list_changed"); + } + if let Err(e) = peer.notify_prompt_list_changed().await { + warn!(error = ?e, %client_id, "[MCPNotifier] failed prompts/list_changed"); + } + if let Err(e) = peer.notify_resource_list_changed().await { + warn!(error = ?e, %client_id, "[MCPNotifier] failed resources/list_changed"); + } + } } diff --git a/crates/mcpmux-gateway/src/lib.rs b/crates/mcpmux-gateway/src/lib.rs index bb399c2..c974b0a 100644 --- a/crates/mcpmux-gateway/src/lib.rs +++ b/crates/mcpmux-gateway/src/lib.rs @@ -23,7 +23,7 @@ pub use oauth::{OAuthConfig, OAuthManager, OAuthToken}; pub use permissions::{PermissionFilter, PermissionSet}; pub use server::{ AutoConnectResult, DependenciesBuilder, GatewayConfig, GatewayDependencies, GatewayServer, - GatewayState, PendingAuthorization, StartupOrchestrator, + GatewayServerHandle, GatewayState, PendingAuthorization, StartupOrchestrator, }; // Pool module - SOLID architecture @@ -48,6 +48,7 @@ pub use pool::{ McpClientConnection, McpClientHandler, OAuthCallback, + OAuthCompleteEvent, OAuthInitResult, OAuthTokenInfo, // OAuth diff --git a/crates/mcpmux-gateway/src/mcp/handler.rs b/crates/mcpmux-gateway/src/mcp/handler.rs index 71c8606..9b74171 100644 --- a/crates/mcpmux-gateway/src/mcp/handler.rs +++ b/crates/mcpmux-gateway/src/mcp/handler.rs @@ -81,31 +81,77 @@ impl McpMuxGatewayHandler { } } - /// Shadow-mode log for the new FeatureSet resolver. + /// Log resolver decision, emit `WorkspaceNeedsBinding` when a session + /// reports roots but no binding matched (`source=Default`), and — when + /// the session's resolved FS *flipped* from a prior value — fire a + /// per-peer `list_changed` so the client re-pulls its tools. /// - /// Does not affect routing; prints the resolver's decision so operators - /// can compare against the legacy `get_client_grants` path before we - /// flip the switch. - async fn shadow_log_resolution( - resolver: &crate::services::FeatureSetResolverService, - client_id: &uuid::Uuid, + /// `notifier` is optional: callers from contexts where peer notification + /// doesn't apply (e.g. rootless init paths) can pass `None`. + /// + /// Rootless sessions never trigger the binding prompt — there's nothing + /// to bind (caller passes `root_for_prompt = None`). + async fn log_and_notify_resolution( + services: &std::sync::Arc, + notifier: Option<&MCPNotifier>, + client_id: &str, session_id: Option<&str>, + root_for_prompt: Option<&str>, ) { - match resolver.resolve(client_id, session_id).await { + let resolver = &services.feature_set_resolver; + match resolver.resolve(session_id).await { Ok(resolved) => { info!( %client_id, session_id = session_id.unwrap_or(""), - feature_set_id = resolved.feature_set_id.map(|u| u.to_string()).unwrap_or_else(|| "".into()), + feature_set_id = resolved.feature_set_id.clone().unwrap_or_else(|| "".into()), + space_id = resolved.space_id.map(|u| u.to_string()).unwrap_or_else(|| "".into()), source = ?resolved.source, - "[FeatureSetResolver][shadow] resolved", + "[FeatureSetResolver] resolved", ); + + // Track the resolved FS per session so we can detect flips. + // The very first sighting (no prior entry) counts as a flip + // — that's the case where the client's `tools/list` at init + // saw the fallback set but roots arriving later may have + // landed on a different binding. Firing once on first sight + // is safe (idempotent re-list); the dedup protects against + // repeated identical resolutions. + if let (Some(sid), Some(notifier)) = (session_id, notifier) { + let changed = services + .session_roots + .record_resolution(sid, resolved.feature_set_id.as_deref()); + if changed { + notifier.notify_peer_lists_changed(client_id).await; + } + } + + // Prompt only when the session reported a root AND no binding + // matched (source=Default). `session_id` must be Some too so + // the UI can correlate back to this peer. + let should_prompt = + matches!(resolved.source, crate::services::ResolutionSource::Default); + if let (true, Some(sid), Some(space_id), Some(root)) = ( + should_prompt, + session_id, + resolved.space_id, + root_for_prompt, + ) { + services.gateway_state.read().await.emit_domain_event( + mcpmux_core::DomainEvent::WorkspaceNeedsBinding { + client_id: client_id.to_string(), + session_id: sid.to_string(), + space_id, + workspace_root: root.to_string(), + }, + ); + } } Err(e) => { warn!( %client_id, error = %e, - "[FeatureSetResolver][shadow] resolve failed", + "[FeatureSetResolver] resolve failed", ); } } @@ -200,10 +246,9 @@ impl ServerHandler for McpMuxGatewayHandler { .prime_hashes_for_space(oauth_ctx.space_id) .await; - // Resolver v2 (shadow mode): if the peer advertised the `roots` - // capability, fetch its reported workspace roots and stash them in the - // session registry. We then run the resolver and log its decision — - // the legacy grants path is still authoritative for routing. + // If the peer advertised the `roots` capability, fetch its reported + // workspace roots into the session registry so the resolver can pick + // a binding. Then log + (if no binding matched) prompt the UI. if let Some(session_id) = extract_session_id(&context.extensions) { let declares_roots = peer .peer_info() @@ -212,7 +257,8 @@ impl ServerHandler for McpMuxGatewayHandler { if declares_roots { let peer_for_roots = peer.clone(); let session_roots = self.services.session_roots.clone(); - let resolver = self.services.feature_set_resolver.clone(); + let services = self.services.clone(); + let notifier = self.notification_bridge.clone(); let client_id_str = oauth_ctx.client_id.clone(); let session_id_for_task = session_id.clone(); tokio::spawn(async move { @@ -228,31 +274,58 @@ impl ServerHandler for McpMuxGatewayHandler { roots = ?uris, "[FeatureSetResolver] fetched MCP roots", ); - if let Ok(client_uuid) = uuid::Uuid::parse_str(&client_id_str) { - Self::shadow_log_resolution( - &resolver, - &client_uuid, - Some(&session_id_for_task), - ) - .await; - } + + // Tell the desktop UI the detected-roots list may + // have grown so the Workspaces tab refreshes + // without waiting for a polling cycle. + services + .gateway_state + .read() + .await + .emit_domain_event(mcpmux_core::DomainEvent::SessionRootsChanged); + + // Pick the longest (most specific) normalized + // root for the sheet. The resolver has already + // normalized them on insert. Passing `Some(root)` + // lets log_and_notify_resolution emit + // `WorkspaceNeedsBinding` if the resolver ended + // up at `source = Default` (i.e. no binding yet). + let root_for_prompt = + session_roots.get(&session_id_for_task).and_then(|roots| { + roots + .into_iter() + .filter(|r| !r.is_empty()) + .max_by_key(|r| r.len()) + }); + + Self::log_and_notify_resolution( + &services, + Some(¬ifier), + &client_id_str, + Some(&session_id_for_task), + root_for_prompt.as_deref(), + ) + .await; } Err(e) => { debug!( client_id = %client_id_str, session_id = %session_id_for_task, error = %e, - "[FeatureSetResolver] peer.list_roots() failed — falling back to Space active FS", + "[FeatureSetResolver] peer.list_roots() failed — falling back to active Space default", ); } } }); - } else if let Ok(client_uuid) = uuid::Uuid::parse_str(&oauth_ctx.client_id) { - // No roots declared — resolve immediately against pin / space active FS. - Self::shadow_log_resolution( - &self.services.feature_set_resolver, - &client_uuid, + } else { + // No roots declared — silent default, never prompt + // (root_for_prompt = None suppresses the emit). + Self::log_and_notify_resolution( + &self.services, + Some(&self.notification_bridge), + &oauth_ctx.client_id, Some(&session_id), + None, ) .await; } @@ -265,6 +338,79 @@ impl ServerHandler for McpMuxGatewayHandler { ); } + /// The client told us its roots list changed (e.g. VS Code added a + /// folder to a multi-root workspace). Re-fetch via `list_roots`, + /// update the session registry, and re-run the resolver — if any root + /// is still unbound, `log_and_notify_resolution` fires a fresh + /// `WorkspaceNeedsBinding` so the sheet pops for the newly-surfaced + /// folder. + async fn on_roots_list_changed(&self, context: NotificationContext) { + let oauth_ctx = match self.get_oauth_context(&context.extensions) { + Ok(ctx) => ctx, + Err(e) => { + warn!( + "Failed to extract OAuth context on_roots_list_changed: {}", + e + ); + return; + } + }; + let Some(session_id) = extract_session_id(&context.extensions) else { + debug!("[FeatureSetResolver] roots/list_changed with no session id — skipping"); + return; + }; + let peer = std::sync::Arc::new(context.peer); + let session_roots = self.services.session_roots.clone(); + let services = self.services.clone(); + let notifier = self.notification_bridge.clone(); + let client_id_str = oauth_ctx.client_id.clone(); + let session_id_for_task = session_id.clone(); + tokio::spawn(async move { + match peer.list_roots().await { + Ok(result) => { + let uris: Vec = + result.roots.iter().map(|r| r.uri.to_string()).collect(); + session_roots.set(&session_id_for_task, uris.iter().map(|s| s.as_str())); + debug!( + client_id = %client_id_str, + session_id = %session_id_for_task, + roots = ?uris, + "[FeatureSetResolver] refreshed MCP roots (roots/list_changed)", + ); + services + .gateway_state + .read() + .await + .emit_domain_event(mcpmux_core::DomainEvent::SessionRootsChanged); + + let root_for_prompt = + session_roots.get(&session_id_for_task).and_then(|roots| { + roots + .into_iter() + .filter(|r| !r.is_empty()) + .max_by_key(|r| r.len()) + }); + Self::log_and_notify_resolution( + &services, + Some(¬ifier), + &client_id_str, + Some(&session_id_for_task), + root_for_prompt.as_deref(), + ) + .await; + } + Err(e) => { + debug!( + client_id = %client_id_str, + session_id = %session_id_for_task, + error = %e, + "[FeatureSetResolver] refresh list_roots failed — silent", + ); + } + } + }); + } + async fn list_tools( &self, _params: Option, diff --git a/crates/mcpmux-gateway/src/oauth/dcr.rs b/crates/mcpmux-gateway/src/oauth/dcr.rs index faaf42f..f698879 100644 --- a/crates/mcpmux-gateway/src/oauth/dcr.rs +++ b/crates/mcpmux-gateway/src/oauth/dcr.rs @@ -108,8 +108,6 @@ fn build_inbound_client_from_request( response_types: Vec, token_endpoint_auth_method: String, client_alias: Option, - connection_mode: String, - locked_space_id: Option, last_seen: Option, created_at: String, updated_at: String, @@ -135,9 +133,6 @@ fn build_inbound_client_from_request( metadata_url: None, metadata_cached_at: None, metadata_cache_ttl: None, - // MCP client settings - connection_mode, - locked_space_id, last_seen, created_at, updated_at, @@ -170,6 +165,48 @@ impl DcrError { } } +/// Check whether a requested redirect URI matches one in the registered list. +/// +/// Per RFC 8252 §7.3, when the registered redirect URI is a loopback address +/// (`127.0.0.1`, `::1`, or `localhost`), the authorization server MUST ignore +/// the port component when matching — native public clients obtain an ephemeral +/// port from the OS at request time, so the port will differ between the DCR +/// registration and the `/authorize` request. +/// +/// For non-loopback URIs (HTTPS, custom schemes like `cursor://`), strict +/// byte-exact equality is required. +pub fn is_redirect_uri_allowed(registered: &[String], requested: &str) -> bool { + registered + .iter() + .any(|r| redirect_uri_matches(r, requested)) +} + +fn redirect_uri_matches(registered: &str, requested: &str) -> bool { + if registered == requested { + return true; + } + + let (Ok(reg_url), Ok(req_url)) = (url::Url::parse(registered), url::Url::parse(requested)) + else { + return false; + }; + + let is_loopback = |u: &url::Url| match u.host() { + Some(url::Host::Ipv4(ip)) => ip.is_loopback(), + Some(url::Host::Ipv6(ip)) => ip.is_loopback(), + Some(url::Host::Domain(d)) => d.eq_ignore_ascii_case("localhost"), + None => false, + }; + + if !is_loopback(®_url) || !is_loopback(&req_url) { + return false; + } + + reg_url.scheme() == req_url.scheme() + && reg_url.host() == req_url.host() + && reg_url.path() == req_url.path() +} + /// Validate redirect URIs per RFC 8252 (OAuth 2.0 for Native Apps) /// /// Allowed redirect URI types: @@ -290,9 +327,7 @@ pub async fn process_dcr_request( grant_types.clone(), response_types.clone(), token_endpoint_auth_method.clone(), - existing.client_alias, // Preserve user-set alias - existing.connection_mode, // Preserve connection mode - existing.locked_space_id, // Preserve locked space + existing.client_alias, // Preserve user-set alias existing.last_seen, existing.created_at, now, @@ -360,9 +395,7 @@ pub async fn process_dcr_request( grant_types.clone(), response_types.clone(), token_endpoint_auth_method.clone(), - None, // No alias yet - "follow_active".to_string(), // Default connection mode - None, // No locked space + None, // No alias yet Some(now_str.clone()), now_str.clone(), now_str, @@ -425,6 +458,84 @@ mod tests { assert!(validate_redirect_uris(&["https://example.com/callback".to_string()]).is_err()); } + #[test] + fn loopback_ignores_port_per_rfc_8252() { + // Registered with one port, requested with another — must match. + let registered = vec!["http://127.0.0.1:12345/callback".to_string()]; + assert!(is_redirect_uri_allowed( + ®istered, + "http://127.0.0.1:44307/callback" + )); + assert!(is_redirect_uri_allowed( + ®istered, + "http://127.0.0.1:1/callback" + )); + + let localhost = vec!["http://localhost:3000/callback".to_string()]; + assert!(is_redirect_uri_allowed( + &localhost, + "http://localhost:55555/callback" + )); + + let ipv6 = vec!["http://[::1]:8080/callback".to_string()]; + assert!(is_redirect_uri_allowed(&ipv6, "http://[::1]:9999/callback")); + } + + #[test] + fn loopback_requires_matching_scheme_host_and_path() { + let registered = vec!["http://127.0.0.1:8080/callback".to_string()]; + // Different path + assert!(!is_redirect_uri_allowed( + ®istered, + "http://127.0.0.1:8080/other" + )); + // Different host family — 127.0.0.1 and localhost are not interchangeable + // (per RFC 8252, clients SHOULD NOT use `localhost`; treat as distinct). + assert!(!is_redirect_uri_allowed( + ®istered, + "http://localhost:8080/callback" + )); + // HTTPS vs HTTP + assert!(!is_redirect_uri_allowed( + ®istered, + "https://127.0.0.1:8080/callback" + )); + } + + #[test] + fn non_loopback_requires_exact_match() { + // HTTPS: exact match only (no port flex) + let https = vec!["https://app.example.com/callback".to_string()]; + assert!(is_redirect_uri_allowed( + &https, + "https://app.example.com/callback" + )); + assert!(!is_redirect_uri_allowed( + &https, + "https://app.example.com:8443/callback" + )); + + // Custom scheme: exact match only + let custom = vec!["cursor://callback".to_string()]; + assert!(is_redirect_uri_allowed(&custom, "cursor://callback")); + assert!(!is_redirect_uri_allowed(&custom, "cursor://other")); + } + + #[test] + fn unparseable_uris_fall_back_to_strict_equality() { + let registered = vec!["not-a-url".to_string()]; + assert!(is_redirect_uri_allowed(®istered, "not-a-url")); + assert!(!is_redirect_uri_allowed(®istered, "not-a-url-either")); + } + + #[test] + fn empty_registered_list_denies_everything() { + assert!(!is_redirect_uri_allowed( + &[], + "http://127.0.0.1:8080/callback" + )); + } + // Note: Integration tests for idempotent registration are better handled // in tests that use an actual database, since process_dcr_request now // persists directly to the database. diff --git a/crates/mcpmux-gateway/src/oauth/mod.rs b/crates/mcpmux-gateway/src/oauth/mod.rs index 860392e..286ffbd 100644 --- a/crates/mcpmux-gateway/src/oauth/mod.rs +++ b/crates/mcpmux-gateway/src/oauth/mod.rs @@ -8,7 +8,10 @@ mod flow; mod pkce; mod token; -pub use dcr::{process_dcr_request, validate_redirect_uris, DcrError, DcrRequest, DcrResponse}; +pub use dcr::{ + is_redirect_uri_allowed, process_dcr_request, validate_redirect_uris, DcrError, DcrRequest, + DcrResponse, +}; pub use discovery::{OAuthDiscovery, OAuthMetadata}; pub use flow::{AuthorizationCallback, AuthorizationRequest, OAuthFlow}; pub use pkce::PkceChallenge; diff --git a/crates/mcpmux-gateway/src/pool/features/discovery.rs b/crates/mcpmux-gateway/src/pool/features/discovery.rs index 2d61884..0ff8f83 100644 --- a/crates/mcpmux-gateway/src/pool/features/discovery.rs +++ b/crates/mcpmux-gateway/src/pool/features/discovery.rs @@ -6,23 +6,16 @@ use tracing::{debug, info, warn}; use super::{convert_to_feature, resource_to_feature, CachedFeatures}; use crate::pool::instance::McpClient; -use mcpmux_core::{FeatureSetRepository, ServerFeatureRepository}; +use mcpmux_core::ServerFeatureRepository; /// Handles feature discovery and caching from MCP clients pub struct FeatureDiscoveryService { feature_repo: Arc, - feature_set_repo: Arc, } impl FeatureDiscoveryService { - pub fn new( - feature_repo: Arc, - feature_set_repo: Arc, - ) -> Self { - Self { - feature_repo, - feature_set_repo, - } + pub fn new(feature_repo: Arc) -> Self { + Self { feature_repo } } /// Discover features from a connected MCP client and cache them @@ -99,18 +92,6 @@ impl FeatureDiscoveryService { } } - // Ensure server-all featureset exists - if let Err(e) = self - .feature_set_repo - .ensure_server_all(space_id, server_id, server_id) - .await - { - warn!( - "[FeatureDiscovery] Failed to ensure server-all featureset: {}", - e - ); - } - Ok(discovered) } diff --git a/crates/mcpmux-gateway/src/pool/features/facade.rs b/crates/mcpmux-gateway/src/pool/features/facade.rs index e23cd35..ad0914c 100644 --- a/crates/mcpmux-gateway/src/pool/features/facade.rs +++ b/crates/mcpmux-gateway/src/pool/features/facade.rs @@ -24,10 +24,7 @@ impl FeatureService { feature_set_repo: Arc, prefix_cache: Arc, ) -> Self { - let discovery = Arc::new(FeatureDiscoveryService::new( - feature_repo.clone(), - feature_set_repo.clone(), - )); + let discovery = Arc::new(FeatureDiscoveryService::new(feature_repo.clone())); let resolution = Arc::new(FeatureResolutionService::new( feature_repo.clone(), diff --git a/crates/mcpmux-gateway/src/pool/features/resolution.rs b/crates/mcpmux-gateway/src/pool/features/resolution.rs index 2bfcbad..7f779b8 100644 --- a/crates/mcpmux-gateway/src/pool/features/resolution.rs +++ b/crates/mcpmux-gateway/src/pool/features/resolution.rs @@ -7,8 +7,8 @@ use tracing::debug; use crate::services::PrefixCacheService; use mcpmux_core::{ - FeatureSet, FeatureSetRepository, FeatureSetType, FeatureType, MemberMode, MemberType, - ServerFeature, ServerFeatureRepository, + FeatureSet, FeatureSetRepository, FeatureType, MemberMode, MemberType, ServerFeature, + ServerFeatureRepository, }; /// Helper to apply include/exclude mode (DRY) @@ -82,7 +82,6 @@ impl FeatureResolutionService { ) -> Result> { let mut allowed_feature_ids: HashSet = HashSet::new(); let mut excluded_feature_ids: HashSet = HashSet::new(); - let mut has_all_grant = false; let all_features = self.feature_repo.list_for_space(space_id).await?; @@ -107,87 +106,40 @@ impl FeatureResolutionService { } }; - match feature_set.feature_set_type { - FeatureSetType::All => { - has_all_grant = true; - } - FeatureSetType::Default => { - // Default feature set uses explicit members only - // Empty default = no features (secure by default) - self.resolve_members( - &feature_set, - &all_features, - &mut allowed_feature_ids, - &mut excluded_feature_ids, - ) - .await?; - } - FeatureSetType::ServerAll => { - if let Some(ref server_id) = feature_set.server_id { - debug!( - "[FeatureResolution] ServerAll: querying features for server_id={} in space={}", - server_id, space_id - ); - let server_features = self - .feature_repo - .list_for_server(space_id, server_id) - .await?; - debug!( - "[FeatureResolution] ServerAll: found {} features for server {}", - server_features.len(), - server_id - ); - for f in &server_features { - debug!( - "[FeatureResolution] ServerAll: adding feature id={}, name={}, available={}", - f.id, f.feature_name, f.is_available - ); - allowed_feature_ids.insert(f.id.to_string()); - } - } else { - debug!("[FeatureResolution] ServerAll: feature_set.server_id is None!"); - } - } - FeatureSetType::Custom => { - self.resolve_members( - &feature_set, - &all_features, - &mut allowed_feature_ids, - &mut excluded_feature_ids, - ) - .await?; - } - } + // Both Default and Custom sets use explicit members; the + // resolution is identical — walk the members and build up + // allow/exclude sets. + self.resolve_members( + &feature_set, + &all_features, + &mut allowed_feature_ids, + &mut excluded_feature_ids, + ) + .await?; } - // Apply filters debug!( - "[FeatureResolution] Filtering: has_all_grant={}, all_features={}, allowed_ids={}, excluded_ids={}", - has_all_grant, all_features.len(), allowed_feature_ids.len(), excluded_feature_ids.len() + "[FeatureResolution] Filtering: all_features={}, allowed_ids={}, excluded_ids={}", + all_features.len(), + allowed_feature_ids.len(), + excluded_feature_ids.len() ); - let mut result: Vec = if has_all_grant { - all_features - .into_iter() - .filter(|f| f.is_available) - .collect() - } else { - all_features - .into_iter() - .filter(|f| { - let in_allowed = allowed_feature_ids.contains(&f.id.to_string()); - let in_excluded = excluded_feature_ids.contains(&f.id.to_string()); - let passes = f.is_available && in_allowed && !in_excluded; - if !passes && in_allowed { - debug!( - "[FeatureResolution] Feature {} (server={}) filtered out: is_available={}, in_allowed={}, in_excluded={}", - f.feature_name, f.server_id, f.is_available, in_allowed, in_excluded - ); - } - passes - }) - .collect() - }; + let mut result: Vec = all_features + .into_iter() + .filter(|f| { + let in_allowed = allowed_feature_ids.contains(&f.id.to_string()); + let in_excluded = excluded_feature_ids.contains(&f.id.to_string()); + let passes = f.is_available && in_allowed && !in_excluded; + if !passes && in_allowed { + debug!( + "[FeatureResolution] Feature {} (server={}) filtered out: is_available={}, in_allowed={}, in_excluded={}", + f.feature_name, f.server_id, f.is_available, in_allowed, in_excluded + ); + } + passes + }) + .collect(); debug!( "[FeatureResolution] After filter: {} features", @@ -229,38 +181,16 @@ impl FeatureResolutionService { ); } MemberType::FeatureSet => { + // Composition: recurse into the nested FS, walking its + // members the same way. Both Default and Custom sets + // are purely member-driven now. if let Some(nested_fs) = self .feature_set_repo .get_with_members(&member.member_id) .await? { - match nested_fs.feature_set_type { - FeatureSetType::All => { - let ids = all_features - .iter() - .filter(|f| f.is_available) - .map(|f| f.id.to_string()); - apply_mode_to_set(member.mode, ids, allowed, excluded); - } - FeatureSetType::ServerAll => { - if let Some(ref server_id) = nested_fs.server_id { - let ids = all_features - .iter() - .filter(|f| f.server_id == *server_id && f.is_available) - .map(|f| f.id.to_string()); - apply_mode_to_set(member.mode, ids, allowed, excluded); - } - } - _ => { - Box::pin(self.resolve_members( - &nested_fs, - all_features, - allowed, - excluded, - )) - .await?; - } - } + Box::pin(self.resolve_members(&nested_fs, all_features, allowed, excluded)) + .await?; } } } diff --git a/crates/mcpmux-gateway/src/server/handlers.rs b/crates/mcpmux-gateway/src/server/handlers.rs index d367e6a..09f7359 100644 --- a/crates/mcpmux-gateway/src/server/handlers.rs +++ b/crates/mcpmux-gateway/src/server/handlers.rs @@ -14,7 +14,9 @@ use tracing::{debug, error, info, warn}; use super::{GatewayState, ServiceContainer}; use crate::auth::{create_access_token, create_refresh_token}; -use crate::oauth::{process_dcr_request, DcrError, DcrRequest, DcrResponse}; +use crate::oauth::{ + is_redirect_uri_allowed, process_dcr_request, DcrError, DcrRequest, DcrResponse, +}; /// App State structure holding both GatewayState and ServiceContainer #[derive(Clone)] @@ -214,8 +216,11 @@ pub async fn oauth_authorize( } }; - // Validate redirect_uri against resolved client - if !client.redirect_uris.contains(¶ms.redirect_uri) { + // Validate redirect_uri against resolved client. + // Per RFC 8252 §7.3, loopback redirect URIs are matched ignoring the port, + // since native public clients use an ephemeral OS-assigned port at request + // time that may differ from the one captured at DCR. + if !is_redirect_uri_allowed(&client.redirect_uris, ¶ms.redirect_uri) { warn!( "[OAuth] Invalid redirect_uri for client: {} (expected one of: {:?})", params.redirect_uri, client.redirect_uris @@ -941,9 +946,6 @@ pub struct OAuthClientInfoResponse { #[serde(skip_serializing_if = "Option::is_none")] pub metadata_cache_ttl: Option, - // MCP client preferences - pub connection_mode: String, - pub locked_space_id: Option, pub last_seen: Option, pub created_at: String, } @@ -979,8 +981,6 @@ pub async fn oauth_list_clients( metadata_url: c.metadata_url, metadata_cached_at: c.metadata_cached_at, metadata_cache_ttl: c.metadata_cache_ttl, - connection_mode: c.connection_mode, - locked_space_id: c.locked_space_id, last_seen: c.last_seen, created_at: c.created_at, }) @@ -999,8 +999,6 @@ pub async fn oauth_list_clients( #[derive(Debug, Deserialize)] pub struct UpdateClientRequest { pub client_alias: Option, - pub connection_mode: Option, - pub locked_space_id: Option, } /// Update client settings (connection mode, alias, etc.) @@ -1051,8 +1049,8 @@ pub async fn oauth_get_client_features( // Step 2: Get client grants via the resolver. // No MCP session context here (this is an HTTP API endpoint for the - // desktop UI), so workspace-binding resolution is skipped; we fall - // through to Space.active_feature_set_id. + // desktop UI), so workspace-binding resolution is skipped; the + // resolver falls back to the Space's Default FeatureSet. let feature_set_ids = match state .services .authorization_service @@ -1178,35 +1176,7 @@ pub async fn oauth_update_client( return (StatusCode::SERVICE_UNAVAILABLE, "Database not available").into_response(); }; - // Validate connection_mode if provided - if let Some(ref mode) = req.connection_mode { - if !["follow_active", "locked", "ask_on_change"].contains(&mode.as_str()) { - return (StatusCode::BAD_REQUEST, "Invalid connection_mode").into_response(); - } - } - - // Handle locked_space_id: convert to Option> - let locked_space_id = if req.connection_mode.as_deref() == Some("locked") { - Some(req.locked_space_id.clone()) - } else if req.connection_mode.as_deref() == Some("follow_active") - || req.connection_mode.as_deref() == Some("ask_on_change") - { - // Clear locked_space_id when switching away from locked mode - Some(None) - } else { - // Don't change if not explicitly setting mode - None - }; - - match repo - .update_client_settings( - &client_id, - req.client_alias, - req.connection_mode, - locked_space_id, - ) - .await - { + match repo.update_client_alias(&client_id, req.client_alias).await { Ok(Some(client)) => { let response = OAuthClientInfoResponse { client_id: client.client_id, @@ -1222,8 +1192,6 @@ pub async fn oauth_update_client( metadata_url: client.metadata_url, metadata_cached_at: client.metadata_cached_at, metadata_cache_ttl: client.metadata_cache_ttl, - connection_mode: client.connection_mode, - locked_space_id: client.locked_space_id, last_seen: client.last_seen, created_at: client.created_at, }; diff --git a/crates/mcpmux-gateway/src/server/mod.rs b/crates/mcpmux-gateway/src/server/mod.rs index 690cf50..1ac40cf 100644 --- a/crates/mcpmux-gateway/src/server/mod.rs +++ b/crates/mcpmux-gateway/src/server/mod.rs @@ -175,6 +175,16 @@ impl GatewayServer { self.services.approval_broker.clone() } + /// Session-roots registry (MCP roots reported by connected peers). + /// + /// The desktop Workspaces tab reads this to surface every folder + /// clients are currently operating in — both bound and unbound — so + /// users can configure mappings even for roots they missed the + /// one-shot prompt for. + pub fn session_roots(&self) -> Arc { + self.services.session_roots.clone() + } + /// Get the OAuth manager pub fn oauth_manager(&self) -> Arc { self.services.pool_services.oauth_manager.clone() @@ -376,6 +386,21 @@ impl GatewayServer { /// 1. Starts auto-connect in background /// 2. Starts the HTTP server pub async fn run(self) -> anyhow::Result<()> { + // No external shutdown signal — axum will run until the process + // exits or its future is dropped. Prefer `spawn()` for anything + // that wants a clean stop without orphaning the listener socket. + self.run_with_shutdown(std::future::pending::<()>()).await + } + + /// Same as `run`, but accepts a shutdown future. When the future + /// resolves, axum stops accepting new connections, drains in-flight + /// requests, and closes the TCP listener. Rust `Drop` on the + /// `TcpListener` then releases the port on the OS — preventing the + /// orphaned-socket condition that force-killed processes leave behind. + pub async fn run_with_shutdown( + self, + shutdown: impl std::future::Future + Send + 'static, + ) -> anyhow::Result<()> { let addr = self.config.addr(); info!("[Gateway] Starting on {}", addr); @@ -457,15 +482,65 @@ impl GatewayServer { info!("[Gateway] Ready to accept connections (servers connecting in background)"); - axum::serve(listener, router).await?; + axum::serve(listener, router) + .with_graceful_shutdown(async move { + shutdown.await; + info!("[Gateway] Graceful shutdown signal received — closing listener"); + }) + .await?; + info!("[Gateway] Listener closed, run_with_shutdown returning"); Ok(()) } - /// Start the server in the background + /// Start the server in the background. /// - /// Returns a JoinHandle that can be used to wait for completion or abort. - pub fn spawn(self) -> tokio::task::JoinHandle> { - tokio::spawn(async move { self.run().await }) + /// Returns a [`GatewayServerHandle`] with both the `JoinHandle` and a + /// one-shot shutdown sender. Call `handle.shutdown()` (and then + /// `.await` the join handle with a timeout) to close the listener + /// cleanly. Dropping the sender without using it leaves axum running + /// until its task is aborted — the old behavior. + pub fn spawn(self) -> GatewayServerHandle { + let (tx, rx) = tokio::sync::oneshot::channel::<()>(); + let task = tokio::spawn(async move { + self.run_with_shutdown(async move { + // If the sender is dropped without being used, `rx.await` + // resolves with `Err` and we treat that as "shut down now" + // — this makes accidental Drop of the handle release the + // port instead of orphaning it. + let _ = rx.await; + }) + .await + }); + GatewayServerHandle { + task, + shutdown: Some(tx), + } + } +} + +/// Handle returned by [`GatewayServer::spawn`] — carries the task's +/// `JoinHandle` plus a one-shot shutdown sender for graceful stop. +/// +/// Sending on `shutdown` tells axum to drain in-flight requests and close +/// the listener. After sending, await `task` (with a timeout) to let Rust +/// `Drop` release the socket on the OS — otherwise the port stays bound +/// in the kernel until the process exits. +pub struct GatewayServerHandle { + pub task: tokio::task::JoinHandle>, + shutdown: Option>, +} + +impl GatewayServerHandle { + /// Send the graceful-shutdown signal. No-op if already sent (idempotent). + pub fn shutdown(&mut self) { + if let Some(tx) = self.shutdown.take() { + let _ = tx.send(()); + } + } + + /// True when no shutdown signal has been sent yet. + pub fn is_active(&self) -> bool { + self.shutdown.is_some() } } diff --git a/crates/mcpmux-gateway/src/server/service_container.rs b/crates/mcpmux-gateway/src/server/service_container.rs index 70b1b34..b755b4f 100644 --- a/crates/mcpmux-gateway/src/server/service_container.rs +++ b/crates/mcpmux-gateway/src/server/service_container.rs @@ -99,12 +99,14 @@ impl ServiceContainer { prefix_cache_service.clone(), )); - // Resolver v2 — now authoritative. AuthorizationService delegates here. + // Resolver — workspace-root-driven. AuthorizationService delegates + // here; the old per-client pin path is gone (see v2 migration + // journey in mcpmux.space/diagrams/workppace-root-session/). let session_roots = SessionRootsRegistry::new(); let feature_set_resolver = Arc::new(FeatureSetResolverService::new( - deps.inbound_mcp_client_repo.clone(), deps.space_repo.clone(), deps.workspace_binding_repo.clone(), + deps.feature_set_repo.clone(), session_roots.clone(), )); @@ -132,21 +134,19 @@ impl ServiceContainer { deps.settings_repo.clone(), ); - // Create space resolver service (DIP: inject repository dependencies) - let space_resolver_service = Arc::new(SpaceResolverService::new( - deps.inbound_client_repo.clone(), - deps.space_repo.clone(), - )); + // Space resolver — currently just exposes the active Space, but + // keeps a stable seam for future session-targeted routing. + let space_resolver_service = Arc::new(SpaceResolverService::new(deps.space_repo.clone())); // Create client metadata service let client_metadata_service = deps.client_metadata_service.clone(); - // Create grant service (centralized grant management with domain events) - // Emits domain events (what happened) instead of implementation-specific events (what to do) + // Feature-set change broadcaster — emits FeatureSetMembersChanged so + // the MCP notifier can fan list_changed out to every peer that + // resolves into the affected set. let grant_service = Arc::new(GrantService::new( - deps.inbound_client_repo.clone(), // Concrete type (pragmatic) - deps.feature_set_repo.clone(), // Trait (DIP) - domain_event_tx.clone(), // Direct event bus (decoupled) + deps.feature_set_repo.clone(), + domain_event_tx.clone(), )); Self { diff --git a/crates/mcpmux-gateway/src/services/authorization.rs b/crates/mcpmux-gateway/src/services/authorization.rs index 1e3447a..069aa8c 100644 --- a/crates/mcpmux-gateway/src/services/authorization.rs +++ b/crates/mcpmux-gateway/src/services/authorization.rs @@ -1,23 +1,17 @@ -//! Authorization Service +//! Authorization Service. //! -//! Delegates permission resolution to [`FeatureSetResolverService`] (pin > -//! workspace binding > space-active FS). The old per-client grants table is -//! no longer consulted — see migration 003 for its removal. -//! -//! This service remains as a thin adapter so existing callers that take a -//! `Vec` continue to work: the resolver yields at most one -//! FeatureSet, which we wrap in a Vec. +//! Thin adapter over [`FeatureSetResolverService`]. Routing decisions are +//! keyed purely on session (→ workspace root → binding); client_id is only +//! used for approval (upstream of this service), never for routing. That's +//! what fixes the "two VS Code windows share a pin" bug — a single client +//! can have many sessions, each routing independently. use anyhow::Result; use std::sync::Arc; use uuid::Uuid; -use super::feature_set_resolver::FeatureSetResolverService; +use super::feature_set_resolver::{FeatureSetResolverService, ResolvedFeatureSet}; -/// Authorization service for checking client permissions. -/// -/// Backed by [`FeatureSetResolverService`]; no longer reads the legacy -/// `client_grants` table. pub struct AuthorizationService { resolver: Arc, } @@ -27,49 +21,43 @@ impl AuthorizationService { Self { resolver } } - /// Resolve the active FeatureSet for a client+session and return it as a - /// one-element Vec (or empty when the resolver denies). + /// Resolve the active FeatureSet for a session and return it as a + /// one-element Vec (or empty when resolution fully fails — no active + /// space + no "All" FS, a pathological setup). /// - /// `session_id` is the client's `mcp-session-id` header; pass `None` for - /// stateless callers (workspace-binding resolution will be skipped). + /// `session_id` is the client's `mcp-session-id` header. `client_id` + /// and `space_id` are ignored — they come from legacy call sites and + /// are not used by the new resolver. pub async fn get_client_grants( &self, - client_id: &str, + _client_id: &str, _space_id: &Uuid, session_id: Option<&str>, ) -> Result> { - let client_uuid = Uuid::parse_str(client_id)?; - let resolved = self.resolver.resolve(&client_uuid, session_id).await?; + let resolved = self.resolver.resolve(session_id).await?; Ok(resolved .feature_set_id - .map(|fs| vec![fs.to_string()]) + .map(|fs| vec![fs]) .unwrap_or_default()) } - /// Check if a client has any grants in a space - pub async fn has_access( - &self, - client_id: &str, - space_id: &Uuid, - session_id: Option<&str>, - ) -> Result { - let grants = self - .get_client_grants(client_id, space_id, session_id) - .await?; - Ok(!grants.is_empty()) + /// Full resolution metadata — returns (Space, FS, source) so the MCP + /// handler can also filter on the resolved Space rather than the + /// caller-advertised one. + pub async fn resolve(&self, session_id: Option<&str>) -> Result { + self.resolver.resolve(session_id).await } - /// Check if a client has access to a specific feature set - pub async fn has_feature_set_access( + /// Does this session resolve to any FeatureSet? + pub async fn has_access( &self, client_id: &str, space_id: &Uuid, - feature_set_id: &str, session_id: Option<&str>, ) -> Result { let grants = self .get_client_grants(client_id, space_id, session_id) .await?; - Ok(grants.contains(&feature_set_id.to_string())) + Ok(!grants.is_empty()) } } diff --git a/crates/mcpmux-gateway/src/services/client_metadata_service.rs b/crates/mcpmux-gateway/src/services/client_metadata_service.rs index f847414..dced118 100644 --- a/crates/mcpmux-gateway/src/services/client_metadata_service.rs +++ b/crates/mcpmux-gateway/src/services/client_metadata_service.rs @@ -134,8 +134,6 @@ impl ClientMetadataService { metadata_url: Some(metadata.client_id), metadata_cached_at: Some(now.clone()), metadata_cache_ttl: Some(3600), // 1 hour default - connection_mode: "follow_active".to_string(), - locked_space_id: None, last_seen: Some(now.clone()), created_at: now.clone(), updated_at: now, diff --git a/crates/mcpmux-gateway/src/services/feature_set_resolver.rs b/crates/mcpmux-gateway/src/services/feature_set_resolver.rs index 03fc042..08b82f6 100644 --- a/crates/mcpmux-gateway/src/services/feature_set_resolver.rs +++ b/crates/mcpmux-gateway/src/services/feature_set_resolver.rs @@ -1,24 +1,25 @@ -//! FeatureSet Resolver Service (V2 — pin > workspace > space active). +//! FeatureSet Resolver Service. //! -//! Replaces the per-client-grants lookup in [`super::AuthorizationService`] -//! with a single deterministic resolution: +//! Two-tier resolution, keyed by the caller's reported workspace roots: //! //! ```text -//! resolve(client, session_id): -//! if client.pinned_feature_set_id: source = Pin -//! else if workspace binding matches: source = WorkspaceBinding -//! else: source = SpaceActive (may be None = Deny) +//! resolve(session_id): +//! if session reported roots AND a binding matches: +//! return (binding.space_id, binding.feature_set_id, WorkspaceBinding) +//! default_space = SpaceRepository.get_default() +//! return (default_space.id, default_space's Default FS id, Default) //! ``` //! -//! The service runs alongside `AuthorizationService` in **shadow mode**: the -//! gateway still honours the legacy grants path for now, but the resolver's -//! decision is logged on every call so we can verify divergence before -//! flipping the switch. +//! The caller's client identity is deliberately NOT used for routing — two +//! VS Code windows share one OAuth identity but open different folders; +//! routing must come from the session's reported root, not from the shared +//! client. See `mcpmux.space/diagrams/workppace-root-session/` for the full +//! design. use std::sync::Arc; use anyhow::Result; -use mcpmux_core::{InboundMcpClientRepository, SpaceRepository, WorkspaceBindingRepository}; +use mcpmux_core::{FeatureSetRepository, SpaceRepository, WorkspaceBindingRepository}; use serde::Serialize; use tracing::{debug, warn}; use uuid::Uuid; @@ -29,113 +30,90 @@ use super::session_roots::SessionRootsRegistry; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub enum ResolutionSource { - /// Client's `pinned_feature_set_id` was set. - Pin, /// A [`WorkspaceBinding`](mcpmux_core::WorkspaceBinding) matched one of /// the session's reported MCP roots. WorkspaceBinding, - /// No pin and no workspace match; fell through to `Space.active_feature_set_id`. - SpaceActive, - /// No pin, no workspace match, and the Space has no active FS — deny. - Deny, + /// No binding matched (or the session reported no roots); fell through + /// to the default Space's Default FeatureSet. + Default, } /// Output of [`FeatureSetResolverService::resolve`]. +/// +/// `feature_set_id` is a `String` (not `Uuid`) because built-in FeatureSets +/// use stable stringy ids like `fs_default_` that aren't valid UUIDs. #[derive(Debug, Clone)] pub struct ResolvedFeatureSet { - /// Chosen FeatureSet id, or `None` when `source == Deny`. - pub feature_set_id: Option, + /// Chosen FeatureSet id. `None` only when every fallback tier failed + /// (no default space, no Default FS in that space — a pathological setup). + pub feature_set_id: Option, + /// Resolved Space id. Used by the routing layer when filtering features. + pub space_id: Option, pub source: ResolutionSource, } -/// Resolves which FeatureSet applies for a given (client, session). +/// Resolves which FeatureSet applies for a given session. /// /// Cheap to clone via `Arc`; inject one instance into the gateway's service /// container and reuse across requests. pub struct FeatureSetResolverService { - client_repo: Arc, space_repo: Arc, binding_repo: Arc, + feature_set_repo: Arc, session_roots: Arc, } impl FeatureSetResolverService { pub fn new( - client_repo: Arc, space_repo: Arc, binding_repo: Arc, + feature_set_repo: Arc, session_roots: Arc, ) -> Self { Self { - client_repo, space_repo, binding_repo, + feature_set_repo, session_roots, } } - /// Read the client's pin + the caller's reported roots + the Space's - /// active FS, and return the winning FeatureSet with its source. + /// Resolve the effective (Space, FeatureSet) pair for a session. /// /// `session_id` is the client's `mcp-session-id` header (or `None` for - /// stateless callers) — used to look up reported MCP roots. - pub async fn resolve( - &self, - client_id: &Uuid, - session_id: Option<&str>, - ) -> Result { - let Some(client) = self.client_repo.get(client_id).await? else { - warn!(%client_id, "[FeatureSetResolver] client not found"); - return Ok(ResolvedFeatureSet { - feature_set_id: None, - source: ResolutionSource::Deny, - }); - }; - - // 1. Pin wins outright. - if let Some(fs) = client.pinned_feature_set_id { - debug!(%client_id, feature_set = %fs, "[FeatureSetResolver] resolved via Pin"); - return Ok(ResolvedFeatureSet { - feature_set_id: Some(fs), - source: ResolutionSource::Pin, - }); - } - - // Determine which Space the caller belongs to. We prefer the explicit - // pinned_space_id; if it's missing (legacy client pre-migration), - // fall back to the active/default Space. - let space_id = match client.pinned_space_id { - Some(id) => id, - None => match self.space_repo.get_default().await? { - Some(s) => s.id, - None => { - warn!(%client_id, "[FeatureSetResolver] no pinned_space_id and no default space"); - return Ok(ResolvedFeatureSet { - feature_set_id: None, - source: ResolutionSource::Deny, - }); - } - }, + /// stateless callers) — used to look up MCP roots reported on + /// `on_initialized`. + pub async fn resolve(&self, session_id: Option<&str>) -> Result { + let default_space_id = match self.space_repo.get_default().await? { + Some(s) => s.id, + None => { + warn!("[FeatureSetResolver] no default space — deny"); + return Ok(ResolvedFeatureSet { + feature_set_id: None, + space_id: None, + source: ResolutionSource::Default, + }); + } }; - // 2. Workspace-root match, only when the session reported roots. + // Tier 1: session has roots AND a binding matches. if let Some(sid) = session_id { if let Some(roots) = self.session_roots.get(sid) { if !roots.is_empty() { if let Some(binding) = self .binding_repo - .find_longest_prefix_match(&space_id, &roots) + .find_longest_prefix_match(&default_space_id, &roots) .await? { debug!( - %client_id, - session_id = sid, + workspace_root = %binding.workspace_root, + space_id = %binding.space_id, feature_set = %binding.feature_set_id, - workspace_root = binding.workspace_root, "[FeatureSetResolver] resolved via WorkspaceBinding", ); return Ok(ResolvedFeatureSet { feature_set_id: Some(binding.feature_set_id), + space_id: Some(binding.space_id), source: ResolutionSource::WorkspaceBinding, }); } @@ -143,33 +121,25 @@ impl FeatureSetResolverService { } } - // 3. Space active FS is the fallback. - let space = self.space_repo.get(&space_id).await?; - match space.and_then(|s| s.active_feature_set_id) { - Some(fs) => { - debug!( - %client_id, - %space_id, - feature_set = %fs, - "[FeatureSetResolver] resolved via SpaceActive", - ); - Ok(ResolvedFeatureSet { - feature_set_id: Some(fs), - source: ResolutionSource::SpaceActive, - }) - } - None => { - debug!( - %client_id, - %space_id, - "[FeatureSetResolver] no pin / no binding / no active FS — deny", - ); - Ok(ResolvedFeatureSet { - feature_set_id: None, - source: ResolutionSource::Deny, - }) - } - } + // Tier 2: default — the default Space's seeded Default FS. + let default_fs = self + .feature_set_repo + .get_default_for_space(&default_space_id.to_string()) + .await + .unwrap_or_default() + .map(|fs| fs.id); + + debug!( + space_id = %default_space_id, + feature_set = ?default_fs, + "[FeatureSetResolver] resolved via Default (default space's Default FS)", + ); + + Ok(ResolvedFeatureSet { + feature_set_id: default_fs, + space_id: Some(default_space_id), + source: ResolutionSource::Default, + }) } } diff --git a/crates/mcpmux-gateway/src/services/grant_service.rs b/crates/mcpmux-gateway/src/services/grant_service.rs index 7dc2aad..5095b40 100644 --- a/crates/mcpmux-gateway/src/services/grant_service.rs +++ b/crates/mcpmux-gateway/src/services/grant_service.rs @@ -1,132 +1,43 @@ -//! Grant Service +//! Feature set change broadcaster. //! -//! Centralized service for managing client feature set grants. -//! -//! **Responsibility (SRP):** -//! - Grant/revoke feature sets to clients -//! - Emit list_changed notifications automatically for ALL grant changes -//! - Ensure DRY - single place for grant logic + notifications -//! -//! **Design:** -//! - UI/Tauri commands call this service for ALL grant operations -//! - Service updates DB + emits events (no manual notification calls needed) -//! - Notifications work for: default grants, custom grants, individual features, batch updates +//! Emits `FeatureSetMembersChanged` domain events so the MCP notifier can +//! broadcast `list_changed` notifications after any member edit. This used +//! to host grant/revoke plumbing too, but per-client grants have been +//! removed — routing now flows purely through WorkspaceBinding + each +//! Space's Default feature set. use anyhow::Result; use mcpmux_core::{DomainEvent, FeatureSetRepository}; -use mcpmux_storage::InboundClientRepository; use std::sync::Arc; use tokio::sync::broadcast; use tracing::{info, warn}; use uuid::Uuid; -/// Centralized service for grant management with automatic event emission -/// -/// **SOLID & Domain-Driven Design:** -/// - **SRP**: Single responsibility - manage grants + emit domain events -/// - **DIP**: Depends on abstractions (FeatureSetRepository trait) -/// - **Domain Events**: Emits what happened, not what to do (consumers decide) +/// Emits domain events for FeatureSet membership edits. /// -/// **Enterprise Pattern:** -/// - Uses domain events (GrantIssued, etc.) instead of implementation-specific events -/// - Consumers (MCPNotifier, UI) interpret events based on their context -/// - Testable, extensible, and follows event-driven architecture principles +/// Named `GrantService` for historical reasons (older callers expect the +/// symbol) — functionally it's just a thin notifier around +/// `FeatureSetMembersChanged`. pub struct GrantService { - /// OAuth client grant repository (concrete for simplicity) - client_repo: Arc, - /// Feature set validation (trait for flexibility) feature_set_repo: Arc, - /// Domain event broadcaster (decoupled from consumers) event_tx: broadcast::Sender, } impl GrantService { pub fn new( - client_repo: Arc, feature_set_repo: Arc, event_tx: broadcast::Sender, ) -> Self { Self { - client_repo, feature_set_repo, event_tx, } } - /// Grant a feature set to a client in a space - /// - /// Emits FeatureSetGranted domain event for consumers to handle. - pub async fn grant_feature_set( - &self, - client_id: &str, - space_id: &str, - feature_set_id: &str, - ) -> Result<()> { - let space_uuid = Uuid::parse_str(space_id)?; - - info!( - client_id = %client_id, - space_id = %space_id, - feature_set_id = %feature_set_id, - "[GrantService] Granting feature set" - ); - - // Update database - self.client_repo - .grant_feature_set(client_id, space_id, feature_set_id) - .await?; - - info!("[GrantService] Feature set granted successfully"); - - // Emit domain event (what happened, not what to do) - let _ = self.event_tx.send(DomainEvent::GrantIssued { - client_id: client_id.to_string(), - space_id: space_uuid, - feature_set_id: feature_set_id.to_string(), - }); - - Ok(()) - } - - /// Revoke a feature set from a client in a space + /// Emit a `FeatureSetMembersChanged` event for the given feature set. /// - /// Emits FeatureSetRevoked domain event for consumers to handle. - pub async fn revoke_feature_set( - &self, - client_id: &str, - space_id: &str, - feature_set_id: &str, - ) -> Result<()> { - let space_uuid = Uuid::parse_str(space_id)?; - - info!( - client_id = %client_id, - space_id = %space_id, - feature_set_id = %feature_set_id, - "[GrantService] Revoking feature set" - ); - - // Update database - self.client_repo - .revoke_feature_set(client_id, space_id, feature_set_id) - .await?; - - info!("[GrantService] Feature set revoked successfully"); - - // Emit domain event (what happened, not what to do) - let _ = self.event_tx.send(DomainEvent::GrantRevoked { - client_id: client_id.to_string(), - space_id: space_uuid, - feature_set_id: feature_set_id.to_string(), - }); - - Ok(()) - } - - /// Notify when a feature set's contents are modified - /// - /// Call this after adding/removing features to/from a feature set. - /// Emits FeatureSetModified domain event for consumers to handle. + /// Call this after adding or removing members so every peer subscribed + /// to the resulting FS re-fetches its tool/prompt/resource list. pub async fn notify_feature_set_modified( &self, space_id: &str, @@ -137,35 +48,31 @@ impl GrantService { info!( space_id = %space_id, feature_set_id = %feature_set_id, - "[GrantService] Feature set modified - emitting domain event" + "[GrantService] feature set modified — emitting domain event" ); - // Verify feature set exists match self.feature_set_repo.get(feature_set_id).await? { Some(feature_set) => { - // Ensure the feature set belongs to the specified space if feature_set.space_id.as_deref() != Some(space_id) { warn!( - "[GrantService] Feature set {} belongs to space {:?}, not {}", + "[GrantService] FS {} belongs to space {:?}, not {}", feature_set_id, feature_set.space_id, space_id ); - return Ok(()); // Silently skip + return Ok(()); } - // Emit domain event (what happened, not what to do) - // Note: We don't track exact counts here since this is a generic modified signal let _ = self.event_tx.send(DomainEvent::FeatureSetMembersChanged { space_id: space_uuid, feature_set_id: feature_set_id.to_string(), - added_count: 0, // Generic modification signal + added_count: 0, removed_count: 0, }); Ok(()) } None => { - warn!("[GrantService] Feature set {} not found", feature_set_id); - Ok(()) // Silently skip + warn!("[GrantService] FS {} not found", feature_set_id); + Ok(()) } } } diff --git a/crates/mcpmux-gateway/src/services/meta_tools/diff.rs b/crates/mcpmux-gateway/src/services/meta_tools/diff.rs index c4a7f25..4304430 100644 --- a/crates/mcpmux-gateway/src/services/meta_tools/diff.rs +++ b/crates/mcpmux-gateway/src/services/meta_tools/diff.rs @@ -29,11 +29,11 @@ impl ToolDiff { pub async fn compute( feature_service: &FeatureService, space_id: Uuid, - before_fs_id: Option, - after_fs_id: Option, + before_fs_id: Option, + after_fs_id: Option, ) -> anyhow::Result { - let before = Self::tools_for(feature_service, space_id, before_fs_id).await?; - let after = Self::tools_for(feature_service, space_id, after_fs_id).await?; + let before = Self::tools_for(feature_service, space_id, before_fs_id.as_deref()).await?; + let after = Self::tools_for(feature_service, space_id, after_fs_id.as_deref()).await?; let before_set: std::collections::HashSet<&String> = before.iter().collect(); let after_set: std::collections::HashSet<&String> = after.iter().collect(); @@ -59,7 +59,7 @@ impl ToolDiff { async fn tools_for( feature_service: &FeatureService, space_id: Uuid, - fs_id: Option, + fs_id: Option<&str>, ) -> anyhow::Result> { let Some(fs) = fs_id else { return Ok(vec![]) }; let space_id_str = space_id.to_string(); diff --git a/crates/mcpmux-gateway/src/services/meta_tools/mod.rs b/crates/mcpmux-gateway/src/services/meta_tools/mod.rs index 66a2a74..c6ca9e2 100644 --- a/crates/mcpmux-gateway/src/services/meta_tools/mod.rs +++ b/crates/mcpmux-gateway/src/services/meta_tools/mod.rs @@ -80,9 +80,7 @@ pub fn build_default_registry( registry.register(Box::new(tools::DescribeResolutionTool)); registry.register(Box::new(tools::DescribeWorkspaceTool)); // Writes — gated by ApprovalBroker. - registry.register(Box::new(tools::PinThisSessionTool)); registry.register(Box::new(tools::CreateFeatureSetTool)); registry.register(Box::new(tools::BindCurrentWorkspaceTool)); - registry.register(Box::new(tools::SetSpaceActiveTool)); std::sync::Arc::new(registry) } diff --git a/crates/mcpmux-gateway/src/services/meta_tools/tools.rs b/crates/mcpmux-gateway/src/services/meta_tools/tools.rs index 40d72b9..236a99b 100644 --- a/crates/mcpmux-gateway/src/services/meta_tools/tools.rs +++ b/crates/mcpmux-gateway/src/services/meta_tools/tools.rs @@ -14,7 +14,6 @@ use tracing::info; use uuid::Uuid; use super::approval::{ApprovalPayload, ApprovalScope}; -use super::diff::ToolDiff; use super::registry::{MetaTool, MetaToolCall, MetaToolError}; /// Fire a `FeatureSetMembersChanged` event so MCPNotifier pushes a @@ -40,19 +39,10 @@ fn text_result(v: Value) -> CallToolResult { CallToolResult::success(vec![Content::text(v.to_string())]) } -/// Resolve the caller's effective Space id, using the pin if set, else the -/// default space. Returns `None` only in pathological "no-default-space" -/// setups, which the meta tools treat as errors. +/// Resolve the caller's effective Space id — always the default/active space +/// in the current (no-client-pinning) model. Returns an error only in the +/// pathological "no default space" setup. async fn caller_space_id(call: &MetaToolCall<'_>) -> Result { - let client = call - .ctx - .client_repo - .get(call.client_id) - .await? - .ok_or_else(|| MetaToolError::Internal("client not found".into()))?; - if let Some(id) = client.pinned_space_id { - return Ok(id); - } let default_space = call .ctx .space_repo @@ -140,8 +130,6 @@ impl MetaTool for ListFeatureSetsTool { .get(&space_id) .await? .ok_or_else(|| MetaToolError::Internal("space missing".into()))?; - let client = call.ctx.client_repo.get(call.client_id).await?; - let pinned_fs = client.as_ref().and_then(|c| c.pinned_feature_set_id); let sets = call .ctx .feature_set_repo @@ -151,26 +139,17 @@ impl MetaTool for ListFeatureSetsTool { .iter() .filter(|fs| !fs.is_deleted) .map(|fs| { - let id_uuid = Uuid::parse_str(&fs.id).ok(); json!({ "id": fs.id, "name": fs.name, "description": fs.description, "type": fs.feature_set_type, "is_builtin": fs.is_builtin, - "is_active": id_uuid - .zip(space.active_feature_set_id) - .map(|(a, b)| a == b) - .unwrap_or(false), - "is_pinned": id_uuid - .zip(pinned_fs) - .map(|(a, b)| a == b) - .unwrap_or(false), }) }) .collect(); Ok(text_result( - json!({ "space_id": space_id, "feature_sets": sets }), + json!({ "space_id": space.id, "feature_sets": sets }), )) } } @@ -198,21 +177,14 @@ impl MetaTool for DescribeResolutionTool { } async fn call(&self, call: MetaToolCall<'_>) -> Result { - let resolved = call - .ctx - .resolver - .resolve(call.client_id, call.session_id) - .await?; - let fs_name = if let Some(id) = resolved.feature_set_id { - call.ctx - .feature_set_repo - .get(&id.to_string()) - .await? - .map(|fs| fs.name) + let resolved = call.ctx.resolver.resolve(call.session_id).await?; + let fs_id = resolved.feature_set_id.clone(); + let fs_name = if let Some(id) = fs_id.as_deref() { + call.ctx.feature_set_repo.get(id).await?.map(|fs| fs.name) } else { None }; - let tool_count = if let Some(id) = resolved.feature_set_id { + let tool_count = if let Some(id) = fs_id.as_deref() { let space_id = caller_space_id(&call).await?; call.ctx .feature_service @@ -225,7 +197,7 @@ impl MetaTool for DescribeResolutionTool { 0 }; Ok(text_result(json!({ - "feature_set_id": resolved.feature_set_id, + "feature_set_id": fs_id, "feature_set_name": fs_name, "source": resolved.source, "resolved_tool_count": tool_count, @@ -276,6 +248,7 @@ impl MetaTool for DescribeWorkspaceTool { "matched_binding": matched.map(|b| json!({ "id": b.id, "workspace_root": b.workspace_root, + "space_id": b.space_id, "feature_set_id": b.feature_set_id, })), }))) @@ -326,98 +299,6 @@ fn parse_uuid_arg(args: &Value, field: &str) -> Result { .map_err(|_| MetaToolError::InvalidArgument(format!("`{field}` is not a UUID: {s}"))) } -// --------------------------------------------------------------------------- -// mcpmux_pin_this_session — write (caller-scope) -// --------------------------------------------------------------------------- - -pub struct PinThisSessionTool; - -#[async_trait] -impl MetaTool for PinThisSessionTool { - fn name(&self) -> &'static str { - "mcpmux_pin_this_session" - } - - fn description(&self) -> &'static str { - "Pin THIS caller's access key to the given FeatureSet. Affects only \ - the calling client; does not touch other connections. Requires user \ - approval. After approval the gateway emits tools/list_changed so \ - the trimmed toolset appears on the next list_tools." - } - - fn input_schema(&self) -> Value { - json!({ - "type": "object", - "required": ["feature_set_id"], - "properties": { - "feature_set_id": { "type": "string", "description": "FeatureSet UUID" } - } - }) - } - - fn is_write(&self) -> bool { - true - } - - async fn call(&self, call: MetaToolCall<'_>) -> Result { - let new_fs = parse_uuid_arg(&call.args, "feature_set_id")?; - let space_id = caller_space_id(&call).await?; - - // Current resolution — becomes the `before` side of the diff. - let before_resolved = call - .ctx - .resolver - .resolve(call.client_id, call.session_id) - .await?; - let diff = ToolDiff::compute( - &call.ctx.feature_service, - space_id, - before_resolved.feature_set_id, - Some(new_fs), - ) - .await?; - - let fs_name = call - .ctx - .feature_set_repo - .get(&new_fs.to_string()) - .await? - .map(|fs| fs.name) - .unwrap_or_else(|| new_fs.to_string()); - let summary = format!( - "Pin this connection to FeatureSet '{fs_name}' ({} tools)", - diff.after.len() - ); - - let client_id = *call.client_id; - let client_repo = call.ctx.client_repo.clone(); - let event_tx = call.ctx.domain_event_tx.clone(); - with_approval( - &call, - "mcpmux_pin_this_session", - summary, - Some(serde_json::to_value(&diff).unwrap_or(Value::Null)), - false, - call.args.clone(), - || async move { - client_repo - .set_pin(&client_id, &space_id, Some(&new_fs)) - .await?; - info!(%client_id, new_fs = %new_fs, "[meta_tools] pin_this_session applied"); - // Trigger a list_changed notification so the caller - // re-fetches the trimmed toolset immediately. - emit_tools_list_changed(&event_tx, space_id); - Ok(text_result(json!({ - "ok": true, - "pinned_feature_set_id": new_fs, - "tool_count": diff.after.len(), - }))) - }, - ) - .await - } -} - // --------------------------------------------------------------------------- // mcpmux_create_feature_set — write (creates FS, optionally activates) // --------------------------------------------------------------------------- @@ -616,7 +497,8 @@ impl MetaTool for BindCurrentWorkspaceTool { true, call.args.clone(), || async move { - let binding = WorkspaceBinding::new(space_id, normalized.clone(), fs_id); + let binding = + WorkspaceBinding::new(normalized.clone(), space_id, fs_id.to_string()); binding_repo.create(&binding).await?; info!( %space_id, @@ -637,99 +519,6 @@ impl MetaTool for BindCurrentWorkspaceTool { } } -// --------------------------------------------------------------------------- -// mcpmux_set_space_active — write (affects every client in the Space) -// --------------------------------------------------------------------------- - -pub struct SetSpaceActiveTool; - -#[async_trait] -impl MetaTool for SetSpaceActiveTool { - fn name(&self) -> &'static str { - "mcpmux_set_space_active" - } - - fn description(&self) -> &'static str { - "Change the Space's ACTIVE FeatureSet — the fallback applied to every \ - connected client that has no pin and no matching workspace binding. \ - This affects OTHER clients beyond the caller; use sparingly. Requires \ - user approval." - } - - fn input_schema(&self) -> Value { - json!({ - "type": "object", - "required": ["feature_set_id"], - "properties": { - "feature_set_id": { "type": "string" } - } - }) - } - - fn is_write(&self) -> bool { - true - } - - async fn call(&self, call: MetaToolCall<'_>) -> Result { - let fs_id = parse_uuid_arg(&call.args, "feature_set_id")?; - let space_id = caller_space_id(&call).await?; - - let space = call - .ctx - .space_repo - .get(&space_id) - .await? - .ok_or_else(|| MetaToolError::Internal("space missing".into()))?; - - let fs_name = call - .ctx - .feature_set_repo - .get(&fs_id.to_string()) - .await? - .map(|fs| fs.name) - .unwrap_or_else(|| fs_id.to_string()); - - let diff = ToolDiff::compute( - &call.ctx.feature_service, - space_id, - space.active_feature_set_id, - Some(fs_id), - ) - .await?; - - let summary = format!( - "Set the Space's active FeatureSet to '{fs_name}' ({} tools). \ - Affects every connection in this Space that has no pin and no workspace binding.", - diff.after.len(), - ); - - let space_repo = call.ctx.space_repo.clone(); - let event_tx = call.ctx.domain_event_tx.clone(); - with_approval( - &call, - "mcpmux_set_space_active", - summary, - Some(serde_json::to_value(&diff).unwrap_or(Value::Null)), - true, - call.args.clone(), - || async move { - space_repo - .set_active_feature_set(&space_id, Some(&fs_id)) - .await?; - info!(%space_id, feature_set_id = %fs_id, "[meta_tools] set_space_active applied"); - emit_tools_list_changed(&event_tx, space_id); - Ok(text_result(json!({ - "ok": true, - "space_id": space_id, - "active_feature_set_id": fs_id, - "tool_count": diff.after.len(), - }))) - }, - ) - .await - } -} - // Suppress unused warning — `ApprovalScope` is re-exported for the Tauri // surface and will land as a command argument once the dialog is wired up. #[allow(dead_code)] diff --git a/crates/mcpmux-gateway/src/services/session_roots.rs b/crates/mcpmux-gateway/src/services/session_roots.rs index 64aa807..8492100 100644 --- a/crates/mcpmux-gateway/src/services/session_roots.rs +++ b/crates/mcpmux-gateway/src/services/session_roots.rs @@ -15,16 +15,23 @@ use dashmap::DashMap; use mcpmux_core::normalize_workspace_root; /// Thread-safe registry mapping `mcp-session-id` to the caller's reported -/// workspace roots. +/// workspace roots, plus the most recently resolved feature-set id so the +/// gateway can tell when a session's resolution flips and emit a per-peer +/// `list_changed` to that one session only. #[derive(Debug, Default)] pub struct SessionRootsRegistry { map: DashMap>, + /// `session_id -> last-resolved feature-set id` (or `None` for "deny"). + /// We compare each fresh resolution to this snapshot; a different value + /// means the client's effective tools changed and we must notify it. + last_resolution: DashMap>, } impl SessionRootsRegistry { pub fn new() -> Arc { Arc::new(Self { map: DashMap::new(), + last_resolution: DashMap::new(), }) } @@ -51,6 +58,36 @@ impl SessionRootsRegistry { /// Drop a session's roots — call on client disconnect. pub fn remove(&self, session_id: &str) { self.map.remove(session_id); + self.last_resolution.remove(session_id); + } + + /// Compare-and-set the session's resolved feature-set id. Returns `true` + /// when the value actually changed (caller should fire `list_changed`), + /// `false` when it's the same as before. + pub fn record_resolution(&self, session_id: &str, fs_id: Option<&str>) -> bool { + let new_val: Option = fs_id.map(|s| s.to_string()); + match self.last_resolution.get(session_id) { + Some(prev) if *prev == new_val => false, + _ => { + self.last_resolution.insert(session_id.to_string(), new_val); + true + } + } + } + + /// Returns every reported root across every active session, de-duplicated + /// and sorted for stable presentation. Used by the UI's "Detected + /// workspaces" panel so the user can act on folders that clients have + /// surfaced but haven't been bound yet. + pub fn list_all_roots(&self) -> Vec { + let mut out: Vec = self + .map + .iter() + .flat_map(|entry| entry.value().clone()) + .collect(); + out.sort(); + out.dedup(); + out } /// Current number of tracked sessions. Test helper; cheap to call but @@ -59,6 +96,13 @@ impl SessionRootsRegistry { pub fn len(&self) -> usize { self.map.len() } + + /// Whether no sessions are tracked. Paired with [`Self::len`] — clippy + /// requires this when `len` is present. + #[cfg(test)] + pub fn is_empty(&self) -> bool { + self.map.is_empty() + } } #[cfg(test)] @@ -94,4 +138,29 @@ mod tests { reg.remove("sess-1"); assert_eq!(reg.len(), 0); } + + #[test] + fn test_record_resolution_flips_on_change() { + let reg = SessionRootsRegistry::default(); + // First sighting always counts as a change so the caller emits the + // initial list_changed for whoever subscribed late. + assert!(reg.record_resolution("sess-1", Some("fs-fallback"))); + // Same value → no change. + assert!(!reg.record_resolution("sess-1", Some("fs-fallback"))); + // Different value → change. + assert!(reg.record_resolution("sess-1", Some("fs-bound"))); + // None ↔ Some both count. + assert!(reg.record_resolution("sess-1", None)); + assert!(!reg.record_resolution("sess-1", None)); + } + + #[test] + fn test_remove_clears_resolution_too() { + let reg = SessionRootsRegistry::default(); + reg.record_resolution("sess-1", Some("fs-a")); + reg.remove("sess-1"); + // After remove, recording the same value should be considered a + // change (no prior entry). + assert!(reg.record_resolution("sess-1", Some("fs-a"))); + } } diff --git a/crates/mcpmux-gateway/src/services/space_resolver.rs b/crates/mcpmux-gateway/src/services/space_resolver.rs index 63819e3..5a348d4 100644 --- a/crates/mcpmux-gateway/src/services/space_resolver.rs +++ b/crates/mcpmux-gateway/src/services/space_resolver.rs @@ -1,99 +1,37 @@ //! Space Resolution Service //! -//! Determines which space a client should access based on their connection mode. -//! Follows SRP: Single responsibility is space resolution logic. -//! Follows DIP: Depends on repository abstractions. +//! Picks which Space a connecting client lands in. With per-client connection +//! modes gone, the answer is always "the active/default Space" — but this +//! service stays as a thin abstraction so callers don't reach into +//! SpaceRepository directly and so future per-session targeting (e.g. +//! WorkspaceBinding-driven space selection) has a single seam to extend. use anyhow::{anyhow, Result}; use mcpmux_core::SpaceRepository; -use mcpmux_storage::InboundClientRepository; use std::sync::Arc; -use tracing::warn; use uuid::Uuid; -/// Space resolver service -/// -/// SRP: Only responsible for determining which space a client should use -/// OCP: Can be extended with new resolution strategies without modification pub struct SpaceResolverService { - client_repo: Arc, space_repo: Arc, } impl SpaceResolverService { - pub fn new( - client_repo: Arc, - space_repo: Arc, - ) -> Self { - Self { - client_repo, - space_repo, - } + pub fn new(space_repo: Arc) -> Self { + Self { space_repo } } - /// Resolve which space a client should access + /// Resolve which space a client should access. /// - /// Resolution strategy based on client's connection_mode: - /// - "locked": Use client.locked_space_id - /// - "follow_active": Use currently active space - /// - "ask_on_change": Use last selected space (not implemented yet) - pub async fn resolve_space_for_client(&self, client_id: &str) -> Result { - // Get client record - let client = self - .client_repo - .get_client(client_id) + /// Currently always returns the default/active Space — per-client pins + /// no longer exist. `client_id` is kept in the signature for forward + /// compatibility with routing rules keyed on identity (e.g. future + /// headless-connection policies). + pub async fn resolve_space_for_client(&self, _client_id: &str) -> Result { + let active_space = self + .space_repo + .get_default() .await? - .ok_or_else(|| anyhow!("Client not found: {}", client_id))?; - - match client.connection_mode.as_str() { - "locked" => { - // Use locked space - let space_id_str = client - .locked_space_id - .ok_or_else(|| anyhow!("Client has locked mode but no locked_space_id"))?; - - let space_id = Uuid::parse_str(&space_id_str) - .map_err(|e| anyhow!("Invalid locked_space_id: {}", e))?; - - Ok(space_id) - } - "follow_active" => { - // Use currently active space - let active_space = self - .space_repo - .get_default() - .await? - .ok_or_else(|| anyhow!("No active space set"))?; - - Ok(active_space.id) - } - "ask_on_change" => { - // TODO: Implement session-based space tracking - // For now, fall back to active space - warn!( - "[SpaceResolver] ask_on_change mode not fully implemented, using active space" - ); - let active_space = self - .space_repo - .get_default() - .await? - .ok_or_else(|| anyhow!("No active space set"))?; - - Ok(active_space.id) - } - mode => { - warn!( - "[SpaceResolver] Unknown connection mode: {}, defaulting to active space", - mode - ); - let active_space = self - .space_repo - .get_default() - .await? - .ok_or_else(|| anyhow!("No active space set"))?; - - Ok(active_space.id) - } - } + .ok_or_else(|| anyhow!("No active space set"))?; + Ok(active_space.id) } } diff --git a/crates/mcpmux-storage/src/database.rs b/crates/mcpmux-storage/src/database.rs index bfbcd11..9973db2 100644 --- a/crates/mcpmux-storage/src/database.rs +++ b/crates/mcpmux-storage/src/database.rs @@ -48,6 +48,26 @@ const MIGRATIONS: &[Migration] = &[ name: "drop_legacy_grants", sql: include_str!("migrations/003_drop_legacy_grants.sql"), }, + Migration { + version: 4, + name: "workspace_modes", + sql: include_str!("migrations/004_workspace_modes.sql"), + }, + Migration { + version: 5, + name: "drop_client_pin", + sql: include_str!("migrations/005_drop_client_pin.sql"), + }, + Migration { + version: 6, + name: "collapse_feature_sets", + sql: include_str!("migrations/006_collapse_feature_sets.sql"), + }, + Migration { + version: 7, + name: "concrete_binding", + sql: include_str!("migrations/007_concrete_binding.sql"), + }, ]; /// SQLite database wrapper. diff --git a/crates/mcpmux-storage/src/migrations/004_workspace_modes.sql b/crates/mcpmux-storage/src/migrations/004_workspace_modes.sql new file mode 100644 index 0000000..94cbb6d --- /dev/null +++ b/crates/mcpmux-storage/src/migrations/004_workspace_modes.sql @@ -0,0 +1,77 @@ +-- Migration 004: Workspace-root-driven routing. +-- +-- Each WorkspaceBinding now has TWO resolution modes — one for the Space +-- axis, one for the FeatureSet axis — each either "active" (follow the +-- global default) or "locked" to a specific id. See +-- `mcpmux.space/diagrams/workppace-root-session/` for the plan. +-- +-- Changes: +-- * Drop the `space_id` uniqueness from `workspace_bindings` — routing is +-- now keyed on the root alone, not on (space_id, root), and the binding +-- itself carries space info via `space_mode`. +-- * Add `space_mode` + `space_id` (with space_id NULL when Active). +-- * Add `fs_mode` + `fs_id` (with fs_id NULL when ActiveForSpace). +-- * Backfill existing rows: a binding today has (space_id, feature_set_id) +-- both set, so migrate to space_mode='locked' + fs_mode='locked'. This +-- preserves exact existing behaviour. +-- * The old (space_id, feature_set_id) columns stay readable for one +-- release; new writes use the mode columns only. They're dropped in +-- migration 005 once the resolver is on the new path everywhere. +-- +-- Lifetime: forward-compatible additive. Old code can still read +-- (space_id, feature_set_id) directly; new code reads via the mode pair. + +-- 1. Add the mode columns with sane defaults for existing rows. +ALTER TABLE workspace_bindings ADD COLUMN space_mode TEXT NOT NULL DEFAULT 'active'; +ALTER TABLE workspace_bindings ADD COLUMN fs_mode TEXT NOT NULL DEFAULT 'active_for_space'; +-- New nullable "locked to" pointers. Use distinct column names so we can +-- keep the old `space_id` / `feature_set_id` columns for one release. +ALTER TABLE workspace_bindings ADD COLUMN locked_space_id TEXT + REFERENCES spaces(id) ON DELETE SET NULL; +ALTER TABLE workspace_bindings ADD COLUMN locked_feature_set_id TEXT + REFERENCES feature_sets(id) ON DELETE SET NULL; + +-- 2. Backfill existing bindings. Today every row has both ids populated +-- (non-null) so the "locked+locked" mode preserves exact behaviour. +UPDATE workspace_bindings +SET + space_mode = 'locked', + locked_space_id = space_id, + fs_mode = 'locked', + locked_feature_set_id = feature_set_id +WHERE space_id IS NOT NULL AND feature_set_id IS NOT NULL; + +-- 3. Globalize uniqueness. The old `UNIQUE(space_id, workspace_root)` +-- conflicts with the new model where a root resolves globally. SQLite +-- doesn't support ALTER TABLE … DROP CONSTRAINT, so the pragmatic approach +-- is a table rebuild. We keep the old columns around for read compat. +CREATE TABLE workspace_bindings_v2 ( + id TEXT PRIMARY KEY, + workspace_root TEXT NOT NULL UNIQUE, + space_mode TEXT NOT NULL DEFAULT 'active', + locked_space_id TEXT REFERENCES spaces(id) ON DELETE SET NULL, + fs_mode TEXT NOT NULL DEFAULT 'active_for_space', + locked_feature_set_id TEXT REFERENCES feature_sets(id) ON DELETE SET NULL, + -- Legacy columns preserved until migration 005 ships and everything is + -- on the new mode columns. Unused by new code. + legacy_space_id TEXT, + legacy_feature_set_id TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +INSERT INTO workspace_bindings_v2 ( + id, workspace_root, space_mode, locked_space_id, fs_mode, locked_feature_set_id, + legacy_space_id, legacy_feature_set_id, created_at, updated_at +) +SELECT + id, workspace_root, space_mode, locked_space_id, fs_mode, locked_feature_set_id, + space_id, feature_set_id, created_at, updated_at +FROM workspace_bindings; + +DROP TABLE workspace_bindings; +ALTER TABLE workspace_bindings_v2 RENAME TO workspace_bindings; + +CREATE INDEX IF NOT EXISTS idx_workspace_bindings_root ON workspace_bindings(workspace_root); +CREATE INDEX IF NOT EXISTS idx_workspace_bindings_locked_space ON workspace_bindings(locked_space_id); +CREATE INDEX IF NOT EXISTS idx_workspace_bindings_locked_fs ON workspace_bindings(locked_feature_set_id); diff --git a/crates/mcpmux-storage/src/migrations/005_drop_client_pin.sql b/crates/mcpmux-storage/src/migrations/005_drop_client_pin.sql new file mode 100644 index 0000000..0f2f60c --- /dev/null +++ b/crates/mcpmux-storage/src/migrations/005_drop_client_pin.sql @@ -0,0 +1,95 @@ +-- Migration 005: Drop client-level FeatureSet pinning +-- +-- The per-client pin was an escape hatch for "lock this access key to FS X". +-- In the new model, routing is keyed on the workspace root (WorkspaceBinding) +-- not the client identity — two IDEs opening the same folder should see the +-- same tools regardless of which one they are. Sessions without a root fall +-- through to the Space's active FS (same behaviour as today's default). +-- +-- SQLite requires table-rebuild semantics for DROP COLUMN when the column has +-- a FOREIGN KEY reference. PRAGMA foreign_keys is toggled off during the copy +-- so the FK constraint doesn't block the rebuild; it's restored at the end. + +PRAGMA foreign_keys = OFF; + +-- Mirror the original `inbound_clients` schema (migration 001) MINUS the two +-- pinned_* columns added in migration 002. Keep every other column so the +-- copy preserves all user data (OAuth metadata, approval flag, aliases, …). +CREATE TABLE inbound_clients_new ( + client_id TEXT PRIMARY KEY, + + registration_type TEXT NOT NULL CHECK(registration_type IN ('cimd', 'dcr', 'preregistered')), + + client_name TEXT NOT NULL, + client_alias TEXT, + + logo_uri TEXT, + client_uri TEXT, + software_id TEXT, + software_version TEXT, + + redirect_uris TEXT NOT NULL, + grant_types TEXT NOT NULL, + response_types TEXT NOT NULL, + token_endpoint_auth_method TEXT NOT NULL, + scope TEXT, + + metadata_url TEXT, + metadata_cached_at TEXT, + metadata_cache_ttl INTEGER DEFAULT 3600, + + connection_mode TEXT NOT NULL DEFAULT 'follow_active', + locked_space_id TEXT, + + grants TEXT, + + approved INTEGER NOT NULL DEFAULT 0, + + last_seen TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + + FOREIGN KEY (locked_space_id) REFERENCES spaces(id) ON DELETE SET NULL +); + +INSERT INTO inbound_clients_new ( + client_id, + registration_type, + client_name, client_alias, + logo_uri, client_uri, software_id, software_version, + redirect_uris, grant_types, response_types, token_endpoint_auth_method, scope, + metadata_url, metadata_cached_at, metadata_cache_ttl, + connection_mode, locked_space_id, + grants, + approved, + last_seen, created_at, updated_at +) +SELECT + client_id, + registration_type, + client_name, client_alias, + logo_uri, client_uri, software_id, software_version, + redirect_uris, grant_types, response_types, token_endpoint_auth_method, scope, + metadata_url, metadata_cached_at, metadata_cache_ttl, + connection_mode, locked_space_id, + grants, + approved, + last_seen, created_at, updated_at +FROM inbound_clients; + +DROP TABLE inbound_clients; +ALTER TABLE inbound_clients_new RENAME TO inbound_clients; + +-- Recreate the indices that 001 defined (migration 001 used CREATE INDEX +-- IF NOT EXISTS so re-creating is safe when run on databases that already +-- dropped them alongside the table). +CREATE INDEX IF NOT EXISTS idx_inbound_clients_type + ON inbound_clients(registration_type); +CREATE INDEX IF NOT EXISTS idx_inbound_clients_name + ON inbound_clients(client_name); +CREATE INDEX IF NOT EXISTS idx_inbound_clients_metadata_url + ON inbound_clients(metadata_url) WHERE metadata_url IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_inbound_clients_approved + ON inbound_clients(approved) WHERE approved = 1; + +PRAGMA foreign_keys = ON; diff --git a/crates/mcpmux-storage/src/migrations/006_collapse_feature_sets.sql b/crates/mcpmux-storage/src/migrations/006_collapse_feature_sets.sql new file mode 100644 index 0000000..f101c1a --- /dev/null +++ b/crates/mcpmux-storage/src/migrations/006_collapse_feature_sets.sql @@ -0,0 +1,119 @@ +-- Migration 006: Collapse FeatureSet model to Default + Custom only +-- +-- Previously each space auto-spawned two builtin FSes (`all` + `default`) and +-- every installed server got a `server-all` set; clients could also grow +-- auto-created "{client_name} - Custom" sets on first use. The resolver no +-- longer consults those — routing is pure `WorkspaceBinding → Space default`. +-- +-- This migration: +-- 1. Hard-deletes the legacy auto-created rows so they disappear from the UI. +-- 2. Rebuilds `inbound_clients` without the `connection_mode` / +-- `locked_space_id` columns (they belonged to the old client-level +-- routing surface and are now dead weight). +-- +-- Custom sets the user authored by hand stay; they may still be referenced +-- from `workspace_bindings.locked_feature_set_id` or `spaces.active_feature_set_id`. + +PRAGMA foreign_keys = OFF; + +-- Clear any `active_feature_set_id` pointing at an `all` / `server-all` set +-- before we delete those rows, otherwise the FK would dangle. +UPDATE spaces +SET active_feature_set_id = NULL +WHERE active_feature_set_id IN ( + SELECT id FROM feature_sets + WHERE feature_set_type IN ('all', 'server-all') +); + +-- Same cleanup for workspace_bindings.locked_feature_set_id — if a binding +-- pinned an 'all'/'server-all' FS, collapse to ActiveForSpace so routing +-- falls through to the space's Default FS instead of dangling. +UPDATE workspace_bindings +SET fs_mode = 'active_for_space', locked_feature_set_id = NULL +WHERE locked_feature_set_id IN ( + SELECT id FROM feature_sets + WHERE feature_set_type IN ('all', 'server-all') +); + +-- Delete legacy auto-created feature sets. We also nuke the per-client +-- "{client_name} - Custom" rows that find_or_create_client_custom_feature_set +-- seeded (they are conventionally named — exact pattern match). +DELETE FROM feature_set_members +WHERE feature_set_id IN ( + SELECT id FROM feature_sets WHERE feature_set_type IN ('all', 'server-all') +); + +DELETE FROM feature_sets +WHERE feature_set_type IN ('all', 'server-all') + OR (feature_set_type = 'custom' AND name LIKE '% - Custom'); + +-- Rebuild inbound_clients WITHOUT connection_mode + locked_space_id. +-- Mirror the 005 schema; just drop the two dead columns and the FK they had. +CREATE TABLE inbound_clients_new ( + client_id TEXT PRIMARY KEY, + + registration_type TEXT NOT NULL CHECK(registration_type IN ('cimd', 'dcr', 'preregistered')), + + client_name TEXT NOT NULL, + client_alias TEXT, + + logo_uri TEXT, + client_uri TEXT, + software_id TEXT, + software_version TEXT, + + redirect_uris TEXT NOT NULL, + grant_types TEXT NOT NULL, + response_types TEXT NOT NULL, + token_endpoint_auth_method TEXT NOT NULL, + scope TEXT, + + metadata_url TEXT, + metadata_cached_at TEXT, + metadata_cache_ttl INTEGER DEFAULT 3600, + + grants TEXT, + + approved INTEGER NOT NULL DEFAULT 0, + + last_seen TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +INSERT INTO inbound_clients_new ( + client_id, + registration_type, + client_name, client_alias, + logo_uri, client_uri, software_id, software_version, + redirect_uris, grant_types, response_types, token_endpoint_auth_method, scope, + metadata_url, metadata_cached_at, metadata_cache_ttl, + grants, + approved, + last_seen, created_at, updated_at +) +SELECT + client_id, + registration_type, + client_name, client_alias, + logo_uri, client_uri, software_id, software_version, + redirect_uris, grant_types, response_types, token_endpoint_auth_method, scope, + metadata_url, metadata_cached_at, metadata_cache_ttl, + grants, + approved, + last_seen, created_at, updated_at +FROM inbound_clients; + +DROP TABLE inbound_clients; +ALTER TABLE inbound_clients_new RENAME TO inbound_clients; + +CREATE INDEX IF NOT EXISTS idx_inbound_clients_type + ON inbound_clients(registration_type); +CREATE INDEX IF NOT EXISTS idx_inbound_clients_name + ON inbound_clients(client_name); +CREATE INDEX IF NOT EXISTS idx_inbound_clients_metadata_url + ON inbound_clients(metadata_url) WHERE metadata_url IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_inbound_clients_approved + ON inbound_clients(approved) WHERE approved = 1; + +PRAGMA foreign_keys = ON; diff --git a/crates/mcpmux-storage/src/migrations/007_concrete_binding.sql b/crates/mcpmux-storage/src/migrations/007_concrete_binding.sql new file mode 100644 index 0000000..3bfdba0 --- /dev/null +++ b/crates/mcpmux-storage/src/migrations/007_concrete_binding.sql @@ -0,0 +1,73 @@ +-- Migration 007: Collapse WorkspaceBinding + Space resolution to concrete pointers +-- +-- Bindings used to carry a matrix of modes: +-- space_mode = active | locked +-- locked_space_id (set when locked) +-- fs_mode = active_for_space | locked +-- locked_feature_set_id (set when locked) +-- And Space carried `active_feature_set_id` so Active-mode bindings could +-- follow whichever FS the user had promoted. +-- +-- That indirection didn't carry its weight — the only real use case is +-- "for root X, use space S + feature set F". This migration collapses +-- every binding to a concrete `(space_id, feature_set_id)` pair and drops +-- the Space's `active_feature_set_id` column. Bindings that can't be +-- concretely resolved (missing a locked target on either side) are dropped +-- — there's no sensible place to land them in the new model. +-- +-- SQLite note: PRAGMA foreign_keys can only be toggled outside a transaction +-- (the migration runner wraps each file in one), so we can't use the +-- table-rebuild pattern for `spaces` — a bare DROP would cascade through +-- feature_sets' ON DELETE CASCADE. Instead we DROP COLUMN directly, which +-- SQLite 3.35+ supports natively and doesn't touch dependent rows. + +-- --------------------------------------------------------------------------- +-- 1. Drop WorkspaceBindings that can't be concretely resolved, then rebuild +-- the table with just the concrete pointer columns. +-- --------------------------------------------------------------------------- + +-- Bindings with ActiveForSpace or Active modes can't be promoted into +-- (space_id, feature_set_id) without guessing — drop them. +DELETE FROM workspace_bindings +WHERE + space_mode <> 'locked' + OR fs_mode <> 'locked' + OR locked_space_id IS NULL + OR locked_feature_set_id IS NULL; + +-- Rebuild the table around the columns we actually keep. The delete above +-- means every remaining row has non-null locked_* columns; the copy below +-- promotes them to the new NOT NULL schema without relying on FK cascade. +CREATE TABLE workspace_bindings_new ( + id TEXT PRIMARY KEY, + workspace_root TEXT NOT NULL UNIQUE, + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, + feature_set_id TEXT NOT NULL REFERENCES feature_sets(id) ON DELETE CASCADE, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +INSERT INTO workspace_bindings_new + (id, workspace_root, space_id, feature_set_id, created_at, updated_at) +SELECT + id, + workspace_root, + locked_space_id, + locked_feature_set_id, + created_at, + updated_at +FROM workspace_bindings; + +DROP TABLE workspace_bindings; +ALTER TABLE workspace_bindings_new RENAME TO workspace_bindings; + +CREATE INDEX IF NOT EXISTS idx_workspace_bindings_space + ON workspace_bindings(space_id); +CREATE INDEX IF NOT EXISTS idx_workspace_bindings_fs + ON workspace_bindings(feature_set_id); + +-- --------------------------------------------------------------------------- +-- 2. Drop spaces.active_feature_set_id directly via ALTER — no rebuild. +-- --------------------------------------------------------------------------- + +ALTER TABLE spaces DROP COLUMN active_feature_set_id; diff --git a/crates/mcpmux-storage/src/repositories/feature_set_repository.rs b/crates/mcpmux-storage/src/repositories/feature_set_repository.rs index f7a5191..a450401 100644 --- a/crates/mcpmux-storage/src/repositories/feature_set_repository.rs +++ b/crates/mcpmux-storage/src/repositories/feature_set_repository.rs @@ -298,69 +298,15 @@ impl FeatureSetRepository for SqliteFeatureSetRepository { Ok(()) } - async fn list_builtin(&self, space_id: &str) -> Result> { - let db = self.db.lock().await; - let conn = db.connection(); - - let mut stmt = conn.prepare( - "SELECT id, name, description, icon, space_id, feature_set_type, - server_id, is_builtin, is_deleted, created_at, updated_at - FROM feature_sets - WHERE space_id = ? AND is_builtin = 1 AND is_deleted = 0 - ORDER BY feature_set_type, name ASC", - )?; - - let feature_sets = stmt - .query_map(params![space_id], Self::row_to_feature_set)? - .collect::, _>>()?; - - Ok(feature_sets) - } - - async fn get_server_all(&self, space_id: &str, server_id: &str) -> Result> { - let db = self.db.lock().await; - let conn = db.connection(); - - let result = conn - .query_row( - "SELECT id, name, description, icon, space_id, feature_set_type, - server_id, is_builtin, is_deleted, created_at, updated_at - FROM feature_sets - WHERE space_id = ? AND server_id = ? AND feature_set_type = 'server-all' AND is_deleted = 0", - params![space_id, server_id], - Self::row_to_feature_set, - ) - .optional()?; - - Ok(result) - } - - async fn ensure_server_all( - &self, - space_id: &str, - server_id: &str, - server_name: &str, - ) -> Result { - // Check if it already exists - if let Some(existing) = self.get_server_all(space_id, server_id).await? { - return Ok(existing); - } - - // Create new server-all featureset - let fs = FeatureSet::new_server_all(space_id, server_id, server_name); - self.create(&fs).await?; - Ok(fs) - } - async fn get_default_for_space(&self, space_id: &str) -> Result> { let db = self.db.lock().await; let conn = db.connection(); let result = conn .query_row( - "SELECT id, name, description, icon, space_id, feature_set_type, - server_id, is_builtin, is_deleted, created_at, updated_at - FROM feature_sets + "SELECT id, name, description, icon, space_id, feature_set_type, + server_id, is_builtin, is_deleted, created_at, updated_at + FROM feature_sets WHERE space_id = ? AND feature_set_type = 'default' AND is_deleted = 0", params![space_id], Self::row_to_feature_set, @@ -370,52 +316,11 @@ impl FeatureSetRepository for SqliteFeatureSetRepository { Ok(result) } - async fn get_all_for_space(&self, space_id: &str) -> Result> { - let db = self.db.lock().await; - let conn = db.connection(); - - let result = conn - .query_row( - "SELECT id, name, description, icon, space_id, feature_set_type, - server_id, is_builtin, is_deleted, created_at, updated_at - FROM feature_sets - WHERE space_id = ? AND feature_set_type = 'all' AND is_deleted = 0", - params![space_id], - Self::row_to_feature_set, - ) - .optional()?; - - Ok(result) - } - - async fn delete_server_all(&self, space_id: &str, server_id: &str) -> Result<()> { - let db = self.db.lock().await; - let conn = db.connection(); - - // Hard delete server-all feature set for this server (used during uninstall) - // Unlike regular delete(), this allows deleting builtin server-all feature sets - conn.execute( - "DELETE FROM feature_sets - WHERE space_id = ? AND server_id = ? AND feature_set_type = 'server-all'", - params![space_id, server_id], - )?; - - Ok(()) - } - async fn ensure_builtin_for_space(&self, space_id: &str) -> Result<()> { - // Check if "All" exists - if self.get_all_for_space(space_id).await?.is_none() { - let all = FeatureSet::new_all(space_id); - self.create(&all).await?; - } - - // Check if "Default" exists if self.get_default_for_space(space_id).await?.is_none() { let default = FeatureSet::new_default(space_id); self.create(&default).await?; } - Ok(()) } @@ -510,9 +415,9 @@ mod tests { let found = found.unwrap(); assert_eq!(found.name, "My Custom Set"); - // List by space (migration creates 2 builtin + our 1 custom = 3) + // List by space: migration seeds 1 builtin (Default) + our 1 custom = 2. let all = repo.list_by_space(DEFAULT_SPACE_ID).await.unwrap(); - assert_eq!(all.len(), 3); + assert_eq!(all.len(), 2); // Delete repo.delete(&fs.id).await.unwrap(); @@ -521,41 +426,41 @@ mod tests { } #[tokio::test] - async fn test_builtin_feature_sets() { + async fn test_default_feature_set_seeded_for_default_space() { let db = Arc::new(Mutex::new(Database::open_in_memory().unwrap())); let repo = SqliteFeatureSetRepository::new(db); - // Migration already creates builtin feature sets for default space - let builtin = repo.list_builtin(DEFAULT_SPACE_ID).await.unwrap(); - assert_eq!(builtin.len(), 2); + // Migration 001 seeds the Default FS for the migration-created + // default space; later migrations preserve it. Confirm it's + // present and blocked from deletion (builtins aren't user-deletable). + let default = repo + .get_default_for_space(DEFAULT_SPACE_ID) + .await + .unwrap() + .expect("Default FS should exist for the default space"); + assert_eq!(default.feature_set_type, FeatureSetType::Default); - // Cannot delete builtin - let all_fs = builtin - .iter() - .find(|f| f.feature_set_type == FeatureSetType::All) - .unwrap(); - let result = repo.delete(&all_fs.id).await; - assert!(result.is_err()); + let result = repo.delete(&default.id).await; + assert!(result.is_err(), "builtin Default FS must not be deletable"); } #[tokio::test] - async fn test_server_all_featureset() { + async fn test_ensure_builtin_is_idempotent() { let db = Arc::new(Mutex::new(Database::open_in_memory().unwrap())); let repo = SqliteFeatureSetRepository::new(db); - // Ensure creates new (use default space from migration) - let fs = repo - .ensure_server_all(DEFAULT_SPACE_ID, "github-mcp", "GitHub") + repo.ensure_builtin_for_space(DEFAULT_SPACE_ID) .await .unwrap(); - assert_eq!(fs.feature_set_type, FeatureSetType::ServerAll); - assert_eq!(fs.server_id, Some("github-mcp".to_string())); - - // Ensure returns existing - let fs2 = repo - .ensure_server_all(DEFAULT_SPACE_ID, "github-mcp", "GitHub") + repo.ensure_builtin_for_space(DEFAULT_SPACE_ID) .await .unwrap(); - assert_eq!(fs.id, fs2.id); + + let by_space = repo.list_by_space(DEFAULT_SPACE_ID).await.unwrap(); + let defaults = by_space + .iter() + .filter(|f| matches!(f.feature_set_type, FeatureSetType::Default)) + .count(); + assert_eq!(defaults, 1); } } diff --git a/crates/mcpmux-storage/src/repositories/inbound_client_repository.rs b/crates/mcpmux-storage/src/repositories/inbound_client_repository.rs index 03d2f2d..5e84377 100644 --- a/crates/mcpmux-storage/src/repositories/inbound_client_repository.rs +++ b/crates/mcpmux-storage/src/repositories/inbound_client_repository.rs @@ -87,9 +87,6 @@ pub struct InboundClient { pub metadata_cached_at: Option, // When we last fetched pub metadata_cache_ttl: Option, // Cache duration in seconds - // MCP client preferences - pub connection_mode: String, // 'follow_active', 'locked', 'ask_on_change' - pub locked_space_id: Option, pub last_seen: Option, pub created_at: String, pub updated_at: String, @@ -161,20 +158,13 @@ impl InboundClientRepository { // Private Helper: Row Mapping (DRY) // ========================================================================= - /// Map a SQL row to InboundClient - /// - /// Expects columns in this exact order (as returned by our queries): - /// 0: client_id, 1: registration_type, 2: client_name, 3: client_alias, - /// 4: logo_uri, 5: client_uri, 6: software_id, 7: software_version, - /// 8: redirect_uris, 9: grant_types, 10: response_types, 11: token_endpoint_auth_method, 12: scope, - /// 13: metadata_url, 14: metadata_cached_at, 15: metadata_cache_ttl, - /// 16: connection_mode, 17: locked_space_id, 18: last_seen, 19: created_at, 20: updated_at, 21: approved + /// Map a SQL row to InboundClient. Column order must match `CLIENT_COLUMNS`. fn map_row_to_client(row: &rusqlite::Row) -> rusqlite::Result { let registration_type_str: String = row.get(1)?; let redirect_uris_json: Option = row.get(8)?; let grant_types_json: Option = row.get(9)?; let response_types_json: Option = row.get(10)?; - let approved_int: i32 = row.get::<_, Option>(21)?.unwrap_or(0); + let approved_int: i32 = row.get::<_, Option>(19)?.unwrap_or(0); Ok(InboundClient { client_id: row.get(0)?, @@ -202,23 +192,20 @@ impl InboundClientRepository { metadata_url: row.get(13)?, metadata_cached_at: row.get(14)?, metadata_cache_ttl: row.get(15)?, - connection_mode: row - .get::<_, Option>(16)? - .unwrap_or_else(|| "follow_active".to_string()), - locked_space_id: row.get(17)?, - last_seen: row.get(18)?, - created_at: row.get(19)?, - updated_at: row.get(20)?, + last_seen: row.get(16)?, + created_at: row.get(17)?, + updated_at: row.get(18)?, approved: approved_int != 0, }) } - /// Standard column selection for InboundClient queries + /// Standard column selection for InboundClient queries. + /// Order must match `map_row_to_client`. const CLIENT_COLUMNS: &'static str = "client_id, registration_type, client_name, client_alias, logo_uri, client_uri, software_id, software_version, redirect_uris, grant_types, response_types, token_endpoint_auth_method, scope, metadata_url, metadata_cached_at, metadata_cache_ttl, - connection_mode, locked_space_id, last_seen, created_at, updated_at, approved"; + last_seen, created_at, updated_at, approved"; // ========================================================================= // Client Operations (unified inbound_clients table) @@ -234,18 +221,16 @@ impl InboundClientRepository { logo_uri, client_uri, software_id, software_version, redirect_uris, grant_types, response_types, token_endpoint_auth_method, scope, metadata_url, metadata_cached_at, metadata_cache_ttl, - connection_mode, locked_space_id, last_seen, created_at, updated_at, approved ) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20) ON CONFLICT(client_id) DO UPDATE SET registration_type = ?2, client_name = ?3, client_alias = ?4, logo_uri = ?5, client_uri = ?6, software_id = ?7, software_version = ?8, redirect_uris = ?9, grant_types = ?10, response_types = ?11, token_endpoint_auth_method = ?12, scope = ?13, metadata_url = ?14, metadata_cached_at = ?15, metadata_cache_ttl = ?16, - connection_mode = ?17, locked_space_id = ?18, - last_seen = ?19, updated_at = ?21, approved = ?22", + last_seen = ?17, updated_at = ?19, approved = ?20", params![ client.client_id, client.registration_type.as_str(), @@ -263,8 +248,6 @@ impl InboundClientRepository { client.metadata_url, client.metadata_cached_at, client.metadata_cache_ttl, - client.connection_mode, - client.locked_space_id, client.last_seen, client.created_at, client.updated_at, @@ -317,7 +300,10 @@ impl InboundClientRepository { } } - /// Validate redirect URI for a client + /// Strict byte-equal membership check of a redirect URI in the client's + /// registered list. This is a low-level DB lookup; for OAuth policy + /// decisions (including RFC 8252 §7.3 loopback-port flexibility) use + /// `mcpmux_gateway::oauth::is_redirect_uri_allowed` instead. pub async fn validate_redirect_uri(&self, client_id: &str, redirect_uri: &str) -> Result { if let Some(client) = self.get_client(client_id).await? { Ok(client.redirect_uris.iter().any(|uri| uri == redirect_uri)) @@ -420,59 +406,22 @@ impl InboundClientRepository { Ok(merged_uris) } - /// Update client configuration settings - pub async fn update_client_settings( + /// Update a client's human-facing alias. + pub async fn update_client_alias( &self, client_id: &str, client_alias: Option, - connection_mode: Option, - locked_space_id: Option>, // None = don't change, Some(None) = clear, Some(Some(x)) = set ) -> Result> { let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); - - // Update timestamp { let db = self.db.lock().await; let conn = db.connection(); conn.execute( - "UPDATE inbound_clients SET updated_at = ?1 WHERE client_id = ?2", - params![now, client_id], + "UPDATE inbound_clients SET client_alias = ?1, updated_at = ?2 WHERE client_id = ?3", + params![client_alias, now, client_id], )?; } - - // Update alias if provided - if let Some(alias) = &client_alias { - let db = self.db.lock().await; - let conn = db.connection(); - conn.execute( - "UPDATE inbound_clients SET client_alias = ?1 WHERE client_id = ?2", - params![alias, client_id], - )?; - } - - // Update connection mode if provided - if let Some(mode) = &connection_mode { - let db = self.db.lock().await; - let conn = db.connection(); - conn.execute( - "UPDATE inbound_clients SET connection_mode = ?1 WHERE client_id = ?2", - params![mode, client_id], - )?; - } - - // Update locked_space_id if provided - if let Some(space_id) = &locked_space_id { - let db = self.db.lock().await; - let conn = db.connection(); - conn.execute( - "UPDATE inbound_clients SET locked_space_id = ?1 WHERE client_id = ?2", - params![space_id, client_id], - )?; - } - - debug!("[OAuth] Updated settings for client: {}", client_id); - - // Return updated client + debug!("[OAuth] Updated alias for client: {}", client_id); self.get_client(client_id).await } @@ -729,49 +678,6 @@ impl InboundClientRepository { } Ok(deleted) } - - // ========================================================================= - // Client Grants (legacy — `client_grants` table dropped in migration 003) - // - // These methods are retained as no-ops so existing Tauri commands and - // services continue to compile. All permission decisions now flow through - // `FeatureSetResolverService` (pin > workspace binding > space-active FS). - // ========================================================================= - - pub async fn grant_feature_set( - &self, - _client_id: &str, - _space_id: &str, - _feature_set_id: &str, - ) -> Result<()> { - Ok(()) - } - - pub async fn revoke_feature_set( - &self, - _client_id: &str, - _space_id: &str, - _feature_set_id: &str, - ) -> Result<()> { - Ok(()) - } - - pub async fn get_grants_for_space( - &self, - _client_id: &str, - _space_id: &str, - ) -> Result> { - Ok(Vec::new()) - } - - pub async fn get_all_grants( - &self, - _client_id: &str, - ) -> Result>> { - Ok(std::collections::HashMap::new()) - } - - // ========================================================================= } #[cfg(test)] diff --git a/crates/mcpmux-storage/src/repositories/inbound_mcp_client_repository.rs b/crates/mcpmux-storage/src/repositories/inbound_mcp_client_repository.rs index 55bcaa8..123759d 100644 --- a/crates/mcpmux-storage/src/repositories/inbound_mcp_client_repository.rs +++ b/crates/mcpmux-storage/src/repositories/inbound_mcp_client_repository.rs @@ -1,15 +1,15 @@ //! SQLite implementation of InboundMcpClientRepository. //! -//! Manages MCP client entities (apps connecting TO McpMux). -//! Works with the unified `inbound_clients` table. +//! Identity-only persistence for approved MCP clients. Per-client grants and +//! connection modes have been removed — routing is driven by WorkspaceBinding +//! + each Space's Default feature set (see FeatureSetResolverService). -use std::collections::HashMap; use std::sync::Arc; use anyhow::Result; use async_trait::async_trait; use chrono::{DateTime, Utc}; -use mcpmux_core::{Client, ConnectionMode, InboundMcpClientRepository}; +use mcpmux_core::{Client, InboundMcpClientRepository}; use rusqlite::{params, OptionalExtension}; use tokio::sync::Mutex; use uuid::Uuid; @@ -18,8 +18,9 @@ use crate::Database; /// SQLite-backed implementation of InboundMcpClientRepository. /// -/// Works with the unified `inbound_clients` table which stores both -/// OAuth registration data and MCP client preferences. +/// Reads identity columns from the unified `inbound_clients` table. OAuth +/// fields (registrations, tokens, etc.) live alongside but are managed +/// through `InboundClientRepository` (the OAuth-oriented helper). pub struct SqliteInboundMcpClientRepository { db: Arc>, } @@ -32,11 +33,9 @@ impl SqliteInboundMcpClientRepository { /// Parse a datetime string to DateTime. fn parse_datetime(s: &str) -> DateTime { - // Try RFC3339 first if let Ok(dt) = DateTime::parse_from_rfc3339(s) { return dt.with_timezone(&Utc); } - // Try SQLite datetime format if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") { return dt.and_utc(); } @@ -48,49 +47,23 @@ impl SqliteInboundMcpClientRepository { s.as_ref().map(|s| Self::parse_datetime(s)) } - /// Parse connection mode from string. - fn parse_connection_mode(mode_str: &str, locked_space_id: &Option) -> ConnectionMode { - match mode_str { - "locked" => { - if let Some(space_id_str) = locked_space_id { - if let Ok(space_id) = space_id_str.parse() { - return ConnectionMode::Locked { space_id }; - } - } - ConnectionMode::FollowActive - } - "ask_on_change" => { - // Simplified: don't load triggers from DB yet - ConnectionMode::AskOnChange { triggers: vec![] } - } - _ => ConnectionMode::FollowActive, - } - } - - /// Convert connection mode to storage strings. - fn connection_mode_to_strings(mode: &ConnectionMode) -> (&'static str, Option) { - match mode { - ConnectionMode::Locked { space_id } => ("locked", Some(space_id.to_string())), - ConnectionMode::FollowActive => ("follow_active", None), - ConnectionMode::AskOnChange { .. } => ("ask_on_change", None), - } - } - - /// Parse grants JSON to HashMap>. - fn parse_grants(json: &Option) -> HashMap> { - json.as_ref() - .and_then(|s| serde_json::from_str::>>(s).ok()) - .map(|m| { - m.into_iter() - .filter_map(|(k, v)| { - let key: Uuid = k.parse().ok()?; - let vals: Vec = - v.into_iter().filter_map(|s| s.parse().ok()).collect(); - Some((key, vals)) - }) - .collect() - }) - .unwrap_or_default() + /// Columns selected for every `Client` read. Order must match `map_row`. + const COLUMNS: &'static str = + "client_id, client_name, registration_type, last_seen, created_at, updated_at"; + + fn map_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(Client { + id: row + .get::<_, String>(0)? + .parse() + .unwrap_or_else(|_| Uuid::new_v4()), + name: row.get(1)?, + client_type: row.get(2)?, + access_key: None, + last_seen: Self::parse_optional_datetime(&row.get(3)?), + created_at: Self::parse_datetime(&row.get::<_, String>(4)?), + updated_at: Self::parse_datetime(&row.get::<_, String>(5)?), + }) } } @@ -100,42 +73,14 @@ impl InboundMcpClientRepository for SqliteInboundMcpClientRepository { let db = self.db.lock().await; let conn = db.connection(); - let mut stmt = conn.prepare( - "SELECT client_id, client_name, registration_type, logo_uri, connection_mode, locked_space_id, - '{}', last_seen, created_at, updated_at, pinned_space_id, pinned_feature_set_id - FROM inbound_clients - ORDER BY client_name ASC", - )?; - + let sql = format!( + "SELECT {} FROM inbound_clients ORDER BY client_name ASC", + Self::COLUMNS + ); + let mut stmt = conn.prepare(&sql)?; let clients = stmt - .query_map([], |row| { - let grants_json: Option = row.get(6)?; // Empty grants JSON placeholder - Ok(Client { - id: row - .get::<_, String>(0)? - .parse() - .unwrap_or_else(|_| Uuid::new_v4()), - name: row.get(1)?, - client_type: row.get(2)?, - connection_mode: Self::parse_connection_mode( - &row.get::<_, String>(4)?, - &row.get(5)?, - ), - grants: Self::parse_grants(&grants_json), - pinned_space_id: row - .get::<_, Option>(10)? - .and_then(|s| Uuid::parse_str(&s).ok()), - pinned_feature_set_id: row - .get::<_, Option>(11)? - .and_then(|s| Uuid::parse_str(&s).ok()), - access_key: None, // Never loaded from DB - last_seen: Self::parse_optional_datetime(&row.get(7)?), - created_at: Self::parse_datetime(&row.get::<_, String>(8)?), - updated_at: Self::parse_datetime(&row.get::<_, String>(9)?), - }) - })? + .query_map([], Self::map_row)? .collect::, _>>()?; - Ok(clients) } @@ -143,42 +88,14 @@ impl InboundMcpClientRepository for SqliteInboundMcpClientRepository { let db = self.db.lock().await; let conn = db.connection(); - let mut stmt = conn.prepare( - "SELECT client_id, client_name, registration_type, logo_uri, connection_mode, locked_space_id, - '{}', last_seen, created_at, updated_at, pinned_space_id, pinned_feature_set_id - FROM inbound_clients - WHERE client_id = ?", - )?; - + let sql = format!( + "SELECT {} FROM inbound_clients WHERE client_id = ?", + Self::COLUMNS + ); + let mut stmt = conn.prepare(&sql)?; let client = stmt - .query_row(params![id.to_string()], |row| { - let grants_json: Option = row.get(6)?; // Empty grants JSON placeholder - Ok(Client { - id: row - .get::<_, String>(0)? - .parse() - .unwrap_or_else(|_| Uuid::new_v4()), - name: row.get(1)?, - client_type: row.get(2)?, - connection_mode: Self::parse_connection_mode( - &row.get::<_, String>(4)?, - &row.get(5)?, - ), - grants: Self::parse_grants(&grants_json), - pinned_space_id: row - .get::<_, Option>(10)? - .and_then(|s| Uuid::parse_str(&s).ok()), - pinned_feature_set_id: row - .get::<_, Option>(11)? - .and_then(|s| Uuid::parse_str(&s).ok()), - access_key: None, - last_seen: Self::parse_optional_datetime(&row.get(7)?), - created_at: Self::parse_datetime(&row.get::<_, String>(8)?), - updated_at: Self::parse_datetime(&row.get::<_, String>(9)?), - }) - }) + .query_row(params![id.to_string()], Self::map_row) .optional()?; - Ok(client) } @@ -186,42 +103,14 @@ impl InboundMcpClientRepository for SqliteInboundMcpClientRepository { let db = self.db.lock().await; let conn = db.connection(); - let mut stmt = conn.prepare( - "SELECT id, name, client_type, logo_uri, connection_mode, locked_space_id, - grants, last_seen, created_at, updated_at, pinned_space_id, pinned_feature_set_id - FROM inbound_clients - WHERE access_key_hash = ?", - )?; - + let sql = format!( + "SELECT {} FROM inbound_clients WHERE access_key_hash = ?", + Self::COLUMNS + ); + let mut stmt = conn.prepare(&sql)?; let client = stmt - .query_row(params![key_hash], |row| { - let grants_json: Option = row.get(6)?; - Ok(Client { - id: row - .get::<_, String>(0)? - .parse() - .unwrap_or_else(|_| Uuid::new_v4()), - name: row.get(1)?, - client_type: row.get(2)?, - connection_mode: Self::parse_connection_mode( - &row.get::<_, String>(4)?, - &row.get(5)?, - ), - grants: Self::parse_grants(&grants_json), - pinned_space_id: row - .get::<_, Option>(10)? - .and_then(|s| Uuid::parse_str(&s).ok()), - pinned_feature_set_id: row - .get::<_, Option>(11)? - .and_then(|s| Uuid::parse_str(&s).ok()), - access_key: None, - last_seen: Self::parse_optional_datetime(&row.get(7)?), - created_at: Self::parse_datetime(&row.get::<_, String>(8)?), - updated_at: Self::parse_datetime(&row.get::<_, String>(9)?), - }) - }) + .query_row(params![key_hash], Self::map_row) .optional()?; - Ok(client) } @@ -229,29 +118,23 @@ impl InboundMcpClientRepository for SqliteInboundMcpClientRepository { let db = self.db.lock().await; let conn = db.connection(); - let (mode_str, locked_space_id) = Self::connection_mode_to_strings(&client.connection_mode); - conn.execute( "INSERT INTO inbound_clients ( - client_id, registration_type, client_name, logo_uri, - connection_mode, locked_space_id, last_seen, created_at, updated_at, + client_id, registration_type, client_name, last_seen, created_at, updated_at, redirect_uris, grant_types, response_types, token_endpoint_auth_method, scope - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)", + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", params![ client.id.to_string(), "preregistered", // Default registration type for MCP clients client.name, - None::, // logo_uri - mode_str, - locked_space_id, client.last_seen.map(|dt| dt.to_rfc3339()), client.created_at.to_rfc3339(), client.updated_at.to_rfc3339(), - "[]", // Empty redirect_uris array - "[]", // Empty grant_types array - "[]", // Empty response_types array - "none", // Default auth method - None::, // No scope + "[]", // redirect_uris + "[]", // grant_types + "[]", // response_types + "none", // token_endpoint_auth_method + None::, // scope ], )?; @@ -262,18 +145,13 @@ impl InboundMcpClientRepository for SqliteInboundMcpClientRepository { let db = self.db.lock().await; let conn = db.connection(); - let (mode_str, locked_space_id) = Self::connection_mode_to_strings(&client.connection_mode); - let rows_affected = conn.execute( - "UPDATE inbound_clients - SET client_name = ?2, connection_mode = ?3, locked_space_id = ?4, - last_seen = ?5, updated_at = ?6 + "UPDATE inbound_clients + SET client_name = ?2, last_seen = ?3, updated_at = ?4 WHERE client_id = ?1", params![ client.id.to_string(), client.name, - mode_str, - locked_space_id, client.last_seen.map(|dt| dt.to_rfc3339()), client.updated_at.to_rfc3339(), ], @@ -297,120 +175,27 @@ impl InboundMcpClientRepository for SqliteInboundMcpClientRepository { Ok(()) } - - // ------------------------------------------------------------------ - // Legacy per-client grant methods. - // - // The `client_grants` table was dropped in migration 003 in favour of - // the FeatureSetResolver (pin > workspace binding > space active FS). - // These methods remain on the trait so upstream Tauri commands and - // services continue to compile; they are now no-ops. - // ------------------------------------------------------------------ - - async fn grant_feature_set( - &self, - _client_id: &Uuid, - _space_id: &str, - _feature_set_id: &str, - ) -> Result<()> { - Ok(()) - } - - async fn revoke_feature_set( - &self, - _client_id: &Uuid, - _space_id: &str, - _feature_set_id: &str, - ) -> Result<()> { - Ok(()) - } - - async fn get_grants_for_space( - &self, - _client_id: &Uuid, - _space_id: &str, - ) -> Result> { - Ok(Vec::new()) - } - - async fn get_all_grants( - &self, - _client_id: &Uuid, - ) -> Result>> { - Ok(std::collections::HashMap::new()) - } - - async fn set_grants_for_space( - &self, - _client_id: &Uuid, - _space_id: &str, - _feature_set_ids: &[String], - ) -> Result<()> { - Ok(()) - } - - async fn has_grants_for_space(&self, _client_id: &Uuid, _space_id: &str) -> Result { - Ok(false) - } - - async fn set_pin( - &self, - client_id: &Uuid, - pinned_space_id: &Uuid, - pinned_feature_set_id: Option<&Uuid>, - ) -> Result<()> { - let db = self.db.lock().await; - let conn = db.connection(); - - let now = Utc::now().to_rfc3339(); - let fs_str = pinned_feature_set_id.map(|u| u.to_string()); - - let rows_affected = conn.execute( - "UPDATE inbound_clients - SET pinned_space_id = ?2, pinned_feature_set_id = ?3, updated_at = ?4 - WHERE client_id = ?1", - params![ - client_id.to_string(), - pinned_space_id.to_string(), - fs_str, - now - ], - )?; - - if rows_affected == 0 { - anyhow::bail!("Client not found: {}", client_id); - } - - Ok(()) - } } #[cfg(test)] mod tests { use super::*; - /// Default space ID created by migration - const DEFAULT_SPACE_ID: &str = "00000000-0000-0000-0000-000000000001"; - #[tokio::test] async fn test_crud_operations() { let db = Arc::new(Mutex::new(Database::open_in_memory().unwrap())); let repo = SqliteInboundMcpClientRepository::new(db); - // Create let client = Client::cursor(); repo.create(&client).await.unwrap(); - // Read let found = repo.get(&client.id).await.unwrap(); assert!(found.is_some()); assert_eq!(found.unwrap().name, "Cursor"); - // List let all = repo.list().await.unwrap(); assert_eq!(all.len(), 1); - // Update let mut updated = client.clone(); updated.name = "Cursor AI".to_string(); repo.update(&updated).await.unwrap(); @@ -418,38 +203,8 @@ mod tests { let found = repo.get(&client.id).await.unwrap().unwrap(); assert_eq!(found.name, "Cursor AI"); - // Delete repo.delete(&client.id).await.unwrap(); let found = repo.get(&client.id).await.unwrap(); assert!(found.is_none()); } - - #[tokio::test] - async fn test_connection_modes() { - let db = Arc::new(Mutex::new(Database::open_in_memory().unwrap())); - let repo = SqliteInboundMcpClientRepository::new(db); - - // Create with FollowActive - let client1 = Client::cursor(); - repo.create(&client1).await.unwrap(); - - let found = repo.get(&client1.id).await.unwrap().unwrap(); - assert!(matches!( - found.connection_mode, - ConnectionMode::FollowActive - )); - - // Create with Locked (use default space from migration for FK constraint) - let mut client2 = Client::vscode(); - let space_id = Uuid::parse_str(DEFAULT_SPACE_ID).unwrap(); - client2.connection_mode = ConnectionMode::Locked { space_id }; - repo.create(&client2).await.unwrap(); - - let found = repo.get(&client2.id).await.unwrap().unwrap(); - if let ConnectionMode::Locked { space_id: found_id } = found.connection_mode { - assert_eq!(found_id, space_id); - } else { - panic!("Expected Locked connection mode"); - } - } } diff --git a/crates/mcpmux-storage/src/repositories/space_repository.rs b/crates/mcpmux-storage/src/repositories/space_repository.rs index 21d193a..49e9deb 100644 --- a/crates/mcpmux-storage/src/repositories/space_repository.rs +++ b/crates/mcpmux-storage/src/repositories/space_repository.rs @@ -26,19 +26,32 @@ impl SqliteSpaceRepository { /// Parse a datetime string to DateTime. /// Handles both RFC3339 format and SQLite's `datetime('now')` format. fn parse_datetime(s: &str) -> DateTime { - // Try RFC3339 first (e.g., "2024-01-01T00:00:00Z") if let Ok(dt) = DateTime::parse_from_rfc3339(s) { return dt.with_timezone(&Utc); } - - // Try SQLite's datetime format (e.g., "2024-01-01 00:00:00") if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") { return dt.and_utc(); } - - // Fallback to current time Utc::now() } + + /// Columns selected for every `Space` read. Order must match `map_row`. + const COLUMNS: &'static str = + "id, name, icon, description, is_default, sort_order, created_at, updated_at"; + + fn map_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let id_str: String = row.get(0)?; + Ok(Space { + id: id_str.parse().unwrap_or_else(|_| Uuid::new_v4()), + name: row.get(1)?, + icon: row.get(2)?, + description: row.get(3)?, + is_default: row.get::<_, i32>(4)? == 1, + sort_order: row.get(5)?, + created_at: Self::parse_datetime(&row.get::<_, String>(6)?), + updated_at: Self::parse_datetime(&row.get::<_, String>(7)?), + }) + } } #[async_trait] @@ -47,45 +60,15 @@ impl SpaceRepository for SqliteSpaceRepository { let db = self.db.lock().await; let conn = db.connection(); - tracing::debug!("[SpaceRepository::list] Querying spaces..."); - - let mut stmt = conn.prepare( - "SELECT id, name, icon, description, is_default, sort_order, created_at, updated_at, active_feature_set_id - FROM spaces - ORDER BY sort_order ASC, name ASC", - )?; - + let sql = format!( + "SELECT {} FROM spaces ORDER BY sort_order ASC, name ASC", + Self::COLUMNS + ); + let mut stmt = conn.prepare(&sql)?; let spaces = stmt - .query_map([], |row| { - let id_str: String = row.get(0)?; - let name: String = row.get(1)?; - tracing::debug!("[SpaceRepository::list] Found space: {} ({})", name, id_str); - - Ok(Space { - id: id_str.parse().unwrap_or_else(|e| { - tracing::warn!( - "[SpaceRepository::list] Failed to parse UUID '{}': {}", - id_str, - e - ); - Uuid::new_v4() - }), - name, - icon: row.get(2)?, - description: row.get(3)?, - is_default: row.get::<_, i32>(4)? == 1, - sort_order: row.get(5)?, - active_feature_set_id: row - .get::<_, Option>(8)? - .and_then(|s| Uuid::parse_str(&s).ok()), - created_at: Self::parse_datetime(&row.get::<_, String>(6)?), - updated_at: Self::parse_datetime(&row.get::<_, String>(7)?), - }) - })? + .query_map([], Self::map_row)? .collect::, _>>()?; - tracing::info!("[SpaceRepository::list] Returning {} spaces", spaces.len()); - Ok(spaces) } @@ -93,31 +76,10 @@ impl SpaceRepository for SqliteSpaceRepository { let db = self.db.lock().await; let conn = db.connection(); - let mut stmt = conn.prepare( - "SELECT id, name, icon, description, is_default, sort_order, created_at, updated_at, active_feature_set_id - FROM spaces - WHERE id = ?", - )?; - + let sql = format!("SELECT {} FROM spaces WHERE id = ?", Self::COLUMNS); + let mut stmt = conn.prepare(&sql)?; let space = stmt - .query_row(params![id.to_string()], |row| { - Ok(Space { - id: row - .get::<_, String>(0)? - .parse() - .unwrap_or_else(|_| Uuid::new_v4()), - name: row.get(1)?, - icon: row.get(2)?, - description: row.get(3)?, - is_default: row.get::<_, i32>(4)? == 1, - sort_order: row.get(5)?, - active_feature_set_id: row - .get::<_, Option>(8)? - .and_then(|s| Uuid::parse_str(&s).ok()), - created_at: Self::parse_datetime(&row.get::<_, String>(6)?), - updated_at: Self::parse_datetime(&row.get::<_, String>(7)?), - }) - }) + .query_row(params![id.to_string()], Self::map_row) .optional()?; Ok(space) @@ -144,22 +106,12 @@ impl SpaceRepository for SqliteSpaceRepository { ], )?; - // Auto-create builtin featuresets for this space - // "All Features" - contains all features from all servers in this space + // Auto-create the builtin "Default" featureset for this space. + // It's the fallback FS when no WorkspaceBinding matches. No other + // builtin sets are seeded — user can create Custom sets as needed. conn.execute( "INSERT OR IGNORE INTO feature_sets (id, name, description, icon, space_id, feature_set_type, is_builtin, created_at, updated_at) - VALUES (?1, 'All Features', 'All features from all connected MCP servers in this space', '🌐', ?2, 'all', 1, ?3, ?3)", - params![ - format!("fs_all_{}", space_id), - space_id, - now, - ], - )?; - - // "Default" - auto-granted to all clients in this space - conn.execute( - "INSERT OR IGNORE INTO feature_sets (id, name, description, icon, space_id, feature_set_type, is_builtin, created_at, updated_at) - VALUES (?1, 'Default', 'Features automatically granted to all connected clients in this space', '⭐', ?2, 'default', 1, ?3, ?3)", + VALUES (?1, 'Default', 'The fallback feature set for this space', '⭐', ?2, 'default', 1, ?3, ?3)", params![ format!("fs_default_{}", space_id), space_id, @@ -176,7 +128,7 @@ impl SpaceRepository for SqliteSpaceRepository { let rows_affected = conn.execute( "UPDATE spaces - SET name = ?2, icon = ?3, description = ?4, is_default = ?5, sort_order = ?6, updated_at = ?7, active_feature_set_id = ?8 + SET name = ?2, icon = ?3, description = ?4, is_default = ?5, sort_order = ?6, updated_at = ?7 WHERE id = ?1", params![ space.id.to_string(), @@ -186,7 +138,6 @@ impl SpaceRepository for SqliteSpaceRepository { if space.is_default { 1 } else { 0 }, space.sort_order, space.updated_at.to_rfc3339(), - space.active_feature_set_id.map(|u| u.to_string()), ], )?; @@ -210,33 +161,12 @@ impl SpaceRepository for SqliteSpaceRepository { let db = self.db.lock().await; let conn = db.connection(); - let mut stmt = conn.prepare( - "SELECT id, name, icon, description, is_default, sort_order, created_at, updated_at, active_feature_set_id - FROM spaces - WHERE is_default = 1 - LIMIT 1", - )?; - - let space = stmt - .query_row([], |row| { - let id_str: String = row.get(0)?; - let name: String = row.get(1)?; - - Ok(Space { - id: id_str.parse().unwrap_or_else(|_| Uuid::new_v4()), - name, - icon: row.get(2)?, - description: row.get(3)?, - is_default: true, - sort_order: row.get(5)?, - active_feature_set_id: row - .get::<_, Option>(8)? - .and_then(|s| Uuid::parse_str(&s).ok()), - created_at: Self::parse_datetime(&row.get::<_, String>(6)?), - updated_at: Self::parse_datetime(&row.get::<_, String>(7)?), - }) - }) - .optional()?; + let sql = format!( + "SELECT {} FROM spaces WHERE is_default = 1 LIMIT 1", + Self::COLUMNS + ); + let mut stmt = conn.prepare(&sql)?; + let space = stmt.query_row([], Self::map_row).optional()?; Ok(space) } @@ -245,13 +175,8 @@ impl SpaceRepository for SqliteSpaceRepository { let db = self.db.lock().await; let conn = db.connection(); - // Use a transaction to ensure atomicity let tx = conn.unchecked_transaction()?; - - // Clear all defaults tx.execute("UPDATE spaces SET is_default = 0", [])?; - - // Set the new default let rows_affected = tx.execute( "UPDATE spaces SET is_default = 1 WHERE id = ?", params![id.to_string()], @@ -265,25 +190,6 @@ impl SpaceRepository for SqliteSpaceRepository { Ok(()) } - - async fn set_active_feature_set(&self, id: &Uuid, feature_set_id: Option<&Uuid>) -> Result<()> { - let db = self.db.lock().await; - let conn = db.connection(); - - let fs_str = feature_set_id.map(|u| u.to_string()); - let now = Utc::now().to_rfc3339(); - - let rows_affected = conn.execute( - "UPDATE spaces SET active_feature_set_id = ?2, updated_at = ?3 WHERE id = ?1", - params![id.to_string(), fs_str, now], - )?; - - if rows_affected == 0 { - anyhow::bail!("Space not found: {}", id); - } - - Ok(()) - } } #[cfg(test)] @@ -298,25 +204,20 @@ mod tests { let db = Arc::new(Mutex::new(Database::open_in_memory().unwrap())); let repo = SqliteSpaceRepository::new(db); - // Migration creates default space, so we start with 1 let initial = repo.list().await.unwrap(); assert_eq!(initial.len(), 1); assert_eq!(initial[0].name, "My Space"); - // Create let space = Space::new("Test Space").with_icon("🧪"); repo.create(&space).await.unwrap(); - // Read let found = repo.get(&space.id).await.unwrap(); assert!(found.is_some()); assert_eq!(found.unwrap().name, "Test Space"); - // List (default + new = 2) let all = repo.list().await.unwrap(); assert_eq!(all.len(), 2); - // Update let mut updated = space.clone(); updated.name = "Updated Space".to_string(); repo.update(&updated).await.unwrap(); @@ -324,7 +225,6 @@ mod tests { let found = repo.get(&space.id).await.unwrap().unwrap(); assert_eq!(found.name, "Updated Space"); - // Delete repo.delete(&space.id).await.unwrap(); let found = repo.get(&space.id).await.unwrap(); assert!(found.is_none()); @@ -335,22 +235,18 @@ mod tests { let db = Arc::new(Mutex::new(Database::open_in_memory().unwrap())); let repo = SqliteSpaceRepository::new(db); - // Migration creates "My Space" as default let default = repo.get_default().await.unwrap(); assert!(default.is_some()); assert_eq!(default.unwrap().name, "My Space"); - // Create a new space and set as default let space2 = Space::new("Space 2"); repo.create(&space2).await.unwrap(); - // Change default repo.set_default(&space2.id).await.unwrap(); let default = repo.get_default().await.unwrap(); assert!(default.is_some()); assert_eq!(default.unwrap().name, "Space 2"); - // Change back to original let default_uuid = Uuid::parse_str(DEFAULT_SPACE_ID).unwrap(); repo.set_default(&default_uuid).await.unwrap(); let default = repo.get_default().await.unwrap(); diff --git a/crates/mcpmux-storage/src/repositories/workspace_binding_repository.rs b/crates/mcpmux-storage/src/repositories/workspace_binding_repository.rs index 12cef91..bdc7507 100644 --- a/crates/mcpmux-storage/src/repositories/workspace_binding_repository.rs +++ b/crates/mcpmux-storage/src/repositories/workspace_binding_repository.rs @@ -1,9 +1,19 @@ -//! SQLite implementation of WorkspaceBindingRepository. +//! SQLite implementation of [`WorkspaceBindingRepository`]. //! -//! See the trait docs on [`mcpmux_core::WorkspaceBindingRepository`] for the -//! semantics of "longest-prefix-wins" matching — paths are expected to be -//! already normalized by [`mcpmux_core::normalize_workspace_root`] before being -//! stored or queried. +//! Schema after migration 007 (concrete-pointers model): +//! +//! ```text +//! workspace_bindings +//! id TEXT PK +//! workspace_root TEXT UNIQUE — routing key, globally unique +//! space_id TEXT NOT NULL — FK → spaces(id) +//! feature_set_id TEXT NOT NULL — FK → feature_sets(id) +//! created_at TEXT NOT NULL +//! updated_at TEXT NOT NULL +//! ``` +//! +//! Longest-prefix matching (used by the resolver) is done in-memory against +//! `list()` since a mcpmux DB is expected to hold O(tens) of bindings. use std::sync::Arc; @@ -38,17 +48,24 @@ impl SqliteWorkspaceBindingRepository { fn row_to_binding(row: &rusqlite::Row<'_>) -> rusqlite::Result { let id_str: String = row.get(0)?; - let space_id_str: String = row.get(1)?; - let fs_id_str: String = row.get(3)?; + let workspace_root: String = row.get(1)?; + let space_id_str: String = row.get(2)?; + let feature_set_id: String = row.get(3)?; + let created_at: String = row.get(4)?; + let updated_at: String = row.get(5)?; + Ok(WorkspaceBinding { id: id_str.parse().unwrap_or_else(|_| Uuid::new_v4()), - space_id: space_id_str.parse().unwrap_or_else(|_| Uuid::new_v4()), - workspace_root: row.get(2)?, - feature_set_id: fs_id_str.parse().unwrap_or_else(|_| Uuid::new_v4()), - created_at: Self::parse_datetime(&row.get::<_, String>(4)?), - updated_at: Self::parse_datetime(&row.get::<_, String>(5)?), + workspace_root, + space_id: space_id_str.parse().unwrap_or_else(|_| Uuid::nil()), + feature_set_id, + created_at: Self::parse_datetime(&created_at), + updated_at: Self::parse_datetime(&updated_at), }) } + + const SELECT_COLS: &'static str = + "id, workspace_root, space_id, feature_set_id, created_at, updated_at"; } #[async_trait] @@ -56,13 +73,11 @@ impl WorkspaceBindingRepository for SqliteWorkspaceBindingRepository { async fn list(&self) -> Result> { let db = self.db.lock().await; let conn = db.connection(); - - let mut stmt = conn.prepare( - "SELECT id, space_id, workspace_root, feature_set_id, created_at, updated_at - FROM workspace_bindings - ORDER BY space_id, workspace_root", - )?; - + let sql = format!( + "SELECT {} FROM workspace_bindings ORDER BY workspace_root", + Self::SELECT_COLS + ); + let mut stmt = conn.prepare(&sql)?; let bindings = stmt .query_map([], Self::row_to_binding)? .collect::, _>>()?; @@ -72,14 +87,11 @@ impl WorkspaceBindingRepository for SqliteWorkspaceBindingRepository { async fn list_for_space(&self, space_id: &Uuid) -> Result> { let db = self.db.lock().await; let conn = db.connection(); - - let mut stmt = conn.prepare( - "SELECT id, space_id, workspace_root, feature_set_id, created_at, updated_at - FROM workspace_bindings - WHERE space_id = ? - ORDER BY workspace_root", - )?; - + let sql = format!( + "SELECT {} FROM workspace_bindings WHERE space_id = ? ORDER BY workspace_root", + Self::SELECT_COLS + ); + let mut stmt = conn.prepare(&sql)?; let bindings = stmt .query_map(params![space_id.to_string()], Self::row_to_binding)? .collect::, _>>()?; @@ -89,12 +101,11 @@ impl WorkspaceBindingRepository for SqliteWorkspaceBindingRepository { async fn get(&self, id: &Uuid) -> Result> { let db = self.db.lock().await; let conn = db.connection(); - - let mut stmt = conn.prepare( - "SELECT id, space_id, workspace_root, feature_set_id, created_at, updated_at - FROM workspace_bindings - WHERE id = ?", - )?; + let sql = format!( + "SELECT {} FROM workspace_bindings WHERE id = ?", + Self::SELECT_COLS + ); + let mut stmt = conn.prepare(&sql)?; let binding = stmt .query_row(params![id.to_string()], Self::row_to_binding) .optional()?; @@ -106,13 +117,14 @@ impl WorkspaceBindingRepository for SqliteWorkspaceBindingRepository { let conn = db.connection(); conn.execute( - "INSERT INTO workspace_bindings (id, space_id, workspace_root, feature_set_id, created_at, updated_at) + "INSERT INTO workspace_bindings + (id, workspace_root, space_id, feature_set_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", params![ binding.id.to_string(), - binding.space_id.to_string(), binding.workspace_root, - binding.feature_set_id.to_string(), + binding.space_id.to_string(), + binding.feature_set_id, binding.created_at.to_rfc3339(), binding.updated_at.to_rfc3339(), ], @@ -127,13 +139,13 @@ impl WorkspaceBindingRepository for SqliteWorkspaceBindingRepository { let rows_affected = conn.execute( "UPDATE workspace_bindings - SET space_id = ?2, workspace_root = ?3, feature_set_id = ?4, updated_at = ?5 + SET workspace_root = ?2, space_id = ?3, feature_set_id = ?4, updated_at = ?5 WHERE id = ?1", params![ binding.id.to_string(), - binding.space_id.to_string(), binding.workspace_root, - binding.feature_set_id.to_string(), + binding.space_id.to_string(), + binding.feature_set_id, binding.updated_at.to_rfc3339(), ], )?; @@ -148,27 +160,27 @@ impl WorkspaceBindingRepository for SqliteWorkspaceBindingRepository { async fn delete(&self, id: &Uuid) -> Result<()> { let db = self.db.lock().await; let conn = db.connection(); - conn.execute( "DELETE FROM workspace_bindings WHERE id = ?", params![id.to_string()], )?; - Ok(()) } async fn find_longest_prefix_match( &self, - space_id: &Uuid, + // `space_id` is no longer used for lookup — routing is keyed on root + // alone and each binding already carries its target space. Kept in + // the signature for trait compatibility with callers that still hold + // onto a "caller's space" hint. + _space_id: &Uuid, candidate_roots: &[String], ) -> Result> { if candidate_roots.is_empty() { return Ok(None); } - // Load all bindings for this space up-front. In practice a Space holds - // O(10) bindings, so SQL-side prefix matching is unnecessary complexity. - let bindings = self.list_for_space(space_id).await?; + let bindings = self.list().await?; if bindings.is_empty() { return Ok(None); } @@ -176,8 +188,6 @@ impl WorkspaceBindingRepository for SqliteWorkspaceBindingRepository { let candidate_strings: Vec<&str> = bindings.iter().map(|b| b.workspace_root.as_str()).collect(); - // For each reported root, find the longest binding prefix. - // Across multiple roots, pick whichever winning prefix is longest. let mut best: Option<&WorkspaceBinding> = None; for root in candidate_roots { if let Some(winner) = longest_prefix_match(root, candidate_strings.iter().copied()) { @@ -201,73 +211,84 @@ impl WorkspaceBindingRepository for SqliteWorkspaceBindingRepository { #[cfg(test)] mod tests { use super::*; + use mcpmux_core::FeatureSet; - async fn make_repo() -> (SqliteWorkspaceBindingRepository, Uuid, Uuid) { + async fn fixture() -> (SqliteWorkspaceBindingRepository, Uuid, String) { let db = Arc::new(Mutex::new(Database::open_in_memory().unwrap())); let repo = SqliteWorkspaceBindingRepository::new(db.clone()); - let default_space = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(); - // The default space has a default FS seeded by migration 001; use it. - let fs_id = Uuid::parse_str( - format!("fs_default_{}", default_space).trim_start_matches("fs_default_"), - ) - .unwrap_or_else(|_| Uuid::new_v4()); - // The migration inserts a feature_set with id "fs_default_..." (not a UUID); - // for test purposes create a custom FS we control. + let space_id = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(); + + // Seed a real FeatureSet so FK constraints are satisfied. + let fs = FeatureSet::new_custom("test", space_id.to_string()); + let fs_id = fs.id.clone(); + let now = Utc::now().to_rfc3339(); { - let db_guard = db.lock().await; - let now = Utc::now().to_rfc3339(); - db_guard + let guard = db.lock().await; + guard .connection() .execute( "INSERT INTO feature_sets (id, name, feature_set_type, space_id, is_builtin, created_at, updated_at) - VALUES (?1, 'Test FS', 'custom', ?2, 0, ?3, ?3)", - params![fs_id.to_string(), default_space.to_string(), now], + VALUES (?1, 'test', 'custom', ?2, 0, ?3, ?3)", + params![fs.id, space_id.to_string(), now], ) .unwrap(); } - (repo, default_space, fs_id) + (repo, space_id, fs_id) } #[tokio::test] - async fn test_crud_and_prefix_match() { - let (repo, space_id, fs_id) = make_repo().await; + async fn test_crud_round_trip() { + let (repo, space_id, fs_id) = fixture().await; + let root = if cfg!(windows) { "d:\\proj" } else { "/proj" }; + let binding = WorkspaceBinding::new(root, space_id, fs_id.clone()); + repo.create(&binding).await.unwrap(); + + let got = repo.get(&binding.id).await.unwrap().unwrap(); + assert_eq!(got.workspace_root, root); + assert_eq!(got.space_id, space_id); + assert_eq!(got.feature_set_id, fs_id); + } - #[cfg(windows)] - let (root_parent, root_child) = ("d:\\projects", "d:\\projects\\foo"); - #[cfg(not(windows))] - let (root_parent, root_child) = ("/home/user/projects", "/home/user/projects/foo"); + #[tokio::test] + async fn test_list_for_space_filters_by_pointer() { + let (repo, space_id, fs_id) = fixture().await; + let root = if cfg!(windows) { "d:\\proj" } else { "/proj" }; + repo.create(&WorkspaceBinding::new(root, space_id, fs_id)) + .await + .unwrap(); - let parent = WorkspaceBinding::new(space_id, root_parent, fs_id); - let child = WorkspaceBinding::new(space_id, root_child, fs_id); - repo.create(&parent).await.unwrap(); - repo.create(&child).await.unwrap(); + let hits = repo.list_for_space(&space_id).await.unwrap(); + assert_eq!(hits.len(), 1); - let all = repo.list_for_space(&space_id).await.unwrap(); - assert_eq!(all.len(), 2); + let other = Uuid::new_v4(); + let hits_other = repo.list_for_space(&other).await.unwrap(); + assert!(hits_other.is_empty()); + } - // Exact match on child - let found = repo - .find_longest_prefix_match(&space_id, &[root_child.to_string()]) - .await - .unwrap(); - assert_eq!(found.unwrap().workspace_root, root_child); - - // Deeper path should still hit child (longest prefix) - #[cfg(windows)] - let deeper = "d:\\projects\\foo\\src"; - #[cfg(not(windows))] - let deeper = "/home/user/projects/foo/src"; - let found = repo - .find_longest_prefix_match(&space_id, &[deeper.to_string()]) + #[tokio::test] + async fn test_longest_prefix_match_picks_nested_root() { + let (repo, space_id, fs_id) = fixture().await; + let (outer, inner) = if cfg!(windows) { + ("d:\\work", "d:\\work\\proj") + } else { + ("/work", "/work/proj") + }; + repo.create(&WorkspaceBinding::new(outer, space_id, fs_id.clone())) .await .unwrap(); - assert_eq!(found.unwrap().workspace_root, root_child); - - // Empty candidates returns None - let found = repo - .find_longest_prefix_match(&space_id, &[]) + let b_inner = WorkspaceBinding::new(inner, space_id, fs_id); + repo.create(&b_inner).await.unwrap(); + + let deep = if cfg!(windows) { + "d:\\work\\proj\\src" + } else { + "/work/proj/src" + }; + let hit = repo + .find_longest_prefix_match(&space_id, &[deep.to_string()]) .await - .unwrap(); - assert!(found.is_none()); + .unwrap() + .expect("match"); + assert_eq!(hit.workspace_root, inner); } } diff --git a/scripts/take-screenshots.cjs b/scripts/take-screenshots.cjs index 7a0fdb3..dff35fc 100644 --- a/scripts/take-screenshots.cjs +++ b/scripts/take-screenshots.cjs @@ -224,9 +224,7 @@ function buildMockHandler() { window.__TAURI_INTERNALS__.invoke = async function(cmd, args) { switch (cmd) { case 'list_spaces': return SPACES; - case 'get_active_space': return SPACES[0]; case 'get_space': return SPACES.find(s => s.id === args?.id) || SPACES[0]; - case 'set_active_space': return null; case 'create_space': return { id: crypto.randomUUID(), name: args?.name, icon: args?.icon, description: null, is_default: false, sort_order: 3, created_at: new Date().toISOString(), updated_at: new Date().toISOString() }; case 'get_gateway_status': return { running: true, url: 'http://localhost:9315', active_sessions: 2, connected_backends: 6 }; case 'start_gateway': return null; diff --git a/tests/e2e/helpers/tauri-api.ts b/tests/e2e/helpers/tauri-api.ts index bd05137..a71d9fc 100644 --- a/tests/e2e/helpers/tauri-api.ts +++ b/tests/e2e/helpers/tauri-api.ts @@ -55,12 +55,10 @@ export async function listSpaces(): Promise { return invoke('list_spaces'); } -export async function getActiveSpace(): Promise { - return invoke('get_active_space'); -} - -export async function setActiveSpace(id: string): Promise { - return invoke('set_active_space', { id }); +/** The system's `is_default` Space — the gateway's routing fallback. */ +export async function getDefaultSpace(): Promise { + const spaces = await listSpaces(); + return spaces.find((s) => s.is_default) ?? null; } // ============================================================================ @@ -71,16 +69,12 @@ export interface Client { id: string; name: string; client_type: string; - connection_mode: 'locked' | 'follow_active' | 'ask_on_change'; - locked_space_id: string | null; - grants: Record; + last_seen: string | null; } export interface CreateClientInput { name: string; client_type: string; - connection_mode: string; - locked_space_id?: string; } export async function createClient(input: CreateClientInput): Promise { @@ -95,23 +89,6 @@ export async function listClients(): Promise { return invoke('list_clients'); } -export async function grantFeatureSetToClient( - clientId: string, - spaceId: string, - featureSetId: string -): Promise { - return invoke('grant_feature_set_to_client', { clientId, spaceId, featureSetId }); -} - -/** Grant a feature set to an OAuth/inbound client (Cursor, VS Code, etc.) */ -export async function grantOAuthClientFeatureSet( - clientId: string, - spaceId: string, - featureSetId: string -): Promise { - return invoke('grant_oauth_client_feature_set', { clientId, spaceId, featureSetId }); -} - // ============================================================================ // FeatureSet API // ============================================================================ @@ -119,7 +96,7 @@ export async function grantOAuthClientFeatureSet( export interface FeatureSet { id: string; name: string; - feature_set_type: 'all' | 'default' | 'server-all' | 'custom'; + feature_set_type: 'default' | 'custom'; server_id: string | null; is_builtin: boolean; } @@ -276,3 +253,43 @@ export interface GatewayStatus { export async function getGatewayStatus(): Promise { return invoke('get_gateway_status'); } + +// ============================================================================ +// Workspace Binding API (primary routing config) +// ============================================================================ + +export interface WorkspaceBinding { + id: string; + workspace_root: string; + space_id: string; + feature_set_id: string; + created_at: string; + updated_at: string; +} + +export interface WorkspaceBindingInput { + workspace_root: string; + space_id: string; + feature_set_id: string; +} + +export async function listWorkspaceBindings(): Promise { + return invoke('list_workspace_bindings'); +} + +export async function createWorkspaceBinding( + input: WorkspaceBindingInput +): Promise { + return invoke('create_workspace_binding', { input }); +} + +export async function updateWorkspaceBinding( + id: string, + input: WorkspaceBindingInput +): Promise { + return invoke('update_workspace_binding', { id, input }); +} + +export async function deleteWorkspaceBinding(id: string): Promise { + return invoke('delete_workspace_binding', { id }); +} diff --git a/tests/e2e/pages/ClientsPage.ts b/tests/e2e/pages/ClientsPage.ts index cf88c52..113b3d6 100644 --- a/tests/e2e/pages/ClientsPage.ts +++ b/tests/e2e/pages/ClientsPage.ts @@ -13,7 +13,7 @@ export class ClientsPage extends BasePage { constructor(page: Page) { super(page); - this.heading = page.getByRole('heading', { name: 'Connected Clients' }); + this.heading = page.getByRole('heading', { name: 'Connections' }); this.clientList = page.locator('[data-testid="client-list"]'); this.clientCards = page.locator('[data-testid="client-card"]'); this.emptyState = page.locator('text=No clients connected'); diff --git a/tests/e2e/specs/capture-screenshots.manual.ts b/tests/e2e/specs/capture-screenshots.manual.ts index 80c34bf..ad07c5f 100644 --- a/tests/e2e/specs/capture-screenshots.manual.ts +++ b/tests/e2e/specs/capture-screenshots.manual.ts @@ -37,10 +37,9 @@ import fs from 'fs'; import { byTestId, safeClick } from '../helpers/selectors'; import { createSpace, - setActiveSpace, createFeatureSet, installServer, - getActiveSpace, + getDefaultSpace, refreshRegistry, enableServerV2, emitEvent, @@ -285,8 +284,8 @@ describe('Screenshot Capture', function () { // ---- Seed data from preseed config ---- // Get default space - const activeSpace = await getActiveSpace(); - defaultSpaceId = activeSpace?.id || ''; + const defaultSpace = await getDefaultSpace(); + defaultSpaceId = defaultSpace?.id || ''; console.log('[setup] Default space:', defaultSpaceId); // Create additional spaces @@ -486,8 +485,7 @@ describe('Screenshot Capture', function () { console.warn('[setup] OAuth client feature set grant failed:', e); } - // Set active space back to default - await setActiveSpace(defaultSpaceId); + // (Active-space concept removed — routing is per workspace root.) // Reload the page so the frontend store picks up all seeded data // (spaces, feature sets, etc. created via Tauri invoke aren't in the Zustand store yet) diff --git a/tests/e2e/specs/clients.spec.ts b/tests/e2e/specs/clients.spec.ts index d489b8f..3087a7e 100644 --- a/tests/e2e/specs/clients.spec.ts +++ b/tests/e2e/specs/clients.spec.ts @@ -1,17 +1,28 @@ import { test, expect } from '@playwright/test'; import { DashboardPage, ClientsPage } from '../pages'; -test.describe('Clients Page', () => { - test('should display the Clients heading', async ({ page }) => { +test.describe('Connections Page', () => { + test('should display the Connections heading', async ({ page }) => { const dashboard = new DashboardPage(page); const clients = new ClientsPage(page); await dashboard.navigate(); - + // Click Clients in sidebar await page.locator('nav button:has-text("Clients")').click(); - + await expect(clients.heading).toBeVisible(); - await expect(clients.heading).toHaveText('Connected Clients'); + await expect(clients.heading).toHaveText('Connections'); + }); + + test('should describe that routing lives in Workspaces', async ({ page }) => { + const dashboard = new DashboardPage(page); + await dashboard.navigate(); + await page.locator('nav button:has-text("Clients")').click(); + + // Routing is configured in Workspaces, not per-client. + await expect( + page.getByRole('button', { name: /^Workspaces$/ }) + ).toBeVisible(); }); test('should show description text', async ({ page }) => { @@ -54,160 +65,118 @@ test.describe('Clients Page', () => { }); }); -test.describe('Client Details', () => { - test('should show client details', async ({ page }) => { +test.describe('Connection Details', () => { + test('should show last-seen indicator on connection cards', async ({ page }) => { const dashboard = new DashboardPage(page); await dashboard.navigate(); await page.locator('nav button:has-text("Clients")').click(); - const clientCards = page.locator('[class*="rounded"][class*="border"]'); + const clientCards = page.locator('[data-testid^="client-card-"]'); const count = await clientCards.count(); if (count > 0) { - // Clients should have connection mode indicators + // Each card surfaces "Last seen …" — pure observability (no routing bits). const firstCard = clientCards.first(); await expect(firstCard).toBeVisible(); + await expect(firstCard).toContainText(/Last seen/); } }); - test('should show granted feature sets for clients', async ({ page }) => { + test('should route routing config to Workspaces from the side panel', async ({ + page, + }) => { const dashboard = new DashboardPage(page); await dashboard.navigate(); await page.locator('nav button:has-text("Clients")').click(); - - const clientCards = page.locator('[class*="rounded"][class*="border"]'); + + const clientCards = page.locator('[data-testid^="client-card-"]'); const count = await clientCards.count(); - + if (count > 0) { - // Clients may show which feature sets they have access to - const featureSetRefs = page.locator('text=/granted|access|permission/i'); - // May or may not be visible + await clientCards.first().click(); + + // The side panel's "routing is workspace-driven" callout exposes a + // button that sends the user to Workspaces. + await expect(page.getByRole('button', { name: /Open Workspaces/ })).toBeVisible(); + + // Legacy per-client controls MUST NOT be present any more. + await expect(page.locator('text=Quick Settings')).toHaveCount(0); + await expect(page.locator('text=Connection Mode')).toHaveCount(0); + await expect(page.locator('text=Effective Features')).toHaveCount(0); + await expect(page.locator('text=Advanced Permissions')).toHaveCount(0); } }); }); -test.describe('Client Management', () => { +test.describe('Connection lifecycle', () => { test('should have refresh button if available', async ({ page }) => { const dashboard = new DashboardPage(page); await dashboard.navigate(); await page.locator('nav button:has-text("Clients")').click(); - - const refreshButton = page.getByRole('button', { name: /Refresh/i }); - // May or may not be visible - }); - test('should show revoke option for connected clients', async ({ page }) => { - const dashboard = new DashboardPage(page); - await dashboard.navigate(); - await page.locator('nav button:has-text("Clients")').click(); - - const clientCards = page.locator('[class*="rounded"][class*="border"]'); - const count = await clientCards.count(); - - if (count > 0) { - const firstCard = clientCards.first(); - const revokeButton = firstCard.getByRole('button', { name: /Revoke|Disconnect|Remove/i }); - // May or may not be visible - } + const refreshButton = page.getByRole('button', { name: /Refresh/ }); + // Always rendered on the Connections header. + await expect(refreshButton).toBeVisible(); }); }); -test.describe('Client Toast Notifications', () => { - test('should have toast container on clients page', async ({ page }) => { +test.describe('Connections toast container', () => { + test('should have toast container on Connections page', async ({ page }) => { const dashboard = new DashboardPage(page); const clients = new ClientsPage(page); await dashboard.navigate(); - + await page.locator('nav button:has-text("Clients")').click(); await expect(clients.heading).toBeVisible(); - + await expect(clients.toastContainer).toBeAttached(); }); - // Skip in web mode - requires Tauri API for client operations - test.skip('should show success toast when saving client config', async ({ page }) => { + // Skip in web mode - requires Tauri API for the save-alias command. + test.skip('should toast on display-name save', async ({ page }) => { const dashboard = new DashboardPage(page); const clients = new ClientsPage(page); await dashboard.navigate(); - - await page.locator('nav button:has-text("Clients")').click(); - - // Click first client card to open panel - const clientCards = page.locator('[data-testid^="client-card-"]'); - const count = await clientCards.count(); - - if (count > 0) { - await clientCards.first().click(); - - // Wait for panel to open - await expect(page.locator('text=Quick Settings')).toBeVisible(); - - // Click Save Changes - const saveButton = page.getByRole('button', { name: /Save Changes/i }); - if (await saveButton.isVisible()) { - await saveButton.click(); - - await clients.waitForToast('success'); - const toastText = await clients.getToastText(); - expect(toastText).toContain('Client settings saved'); - } - } - }); - // Skip in web mode - requires Tauri API for client deletion - test.skip('should show success toast when removing a client', async ({ page }) => { - const dashboard = new DashboardPage(page); - const clients = new ClientsPage(page); - await dashboard.navigate(); - await page.locator('nav button:has-text("Clients")').click(); - + const clientCards = page.locator('[data-testid^="client-card-"]'); const count = await clientCards.count(); - + if (count > 0) { await clientCards.first().click(); - - // Click Remove Client in panel footer - page.on('dialog', dialog => dialog.accept()); - const removeButton = page.getByRole('button', { name: /Remove Client/i }); - if (await removeButton.isVisible()) { - await removeButton.click(); - - await clients.waitForToast('success'); - const toastText = await clients.getToastText(); - expect(toastText).toContain('Client removed'); - } + + // Type into the display-name input and hit save. + const aliasInput = page.getByPlaceholder(/./).first(); + await aliasInput.fill('New Alias'); + await page.getByRole('button', { name: /Save/ }).click(); + + await clients.waitForToast('success'); + expect(await clients.getToastText()).toMatch(/Saved/); } }); - // Skip in web mode - requires Tauri API for permission toggle - test.skip('should show success toast when toggling feature set grant', async ({ page }) => { + // Skip in web mode - requires Tauri API for revoke. + test.skip('should toast on revoke', async ({ page }) => { const dashboard = new DashboardPage(page); const clients = new ClientsPage(page); await dashboard.navigate(); - + await page.locator('nav button:has-text("Clients")').click(); - + const clientCards = page.locator('[data-testid^="client-card-"]'); const count = await clientCards.count(); - + if (count > 0) { await clientCards.first().click(); - - // Expand Permissions section - await page.locator('text=Permissions').click(); - await page.waitForTimeout(300); - - // Find a non-default feature set checkbox - const featureSetToggle = page.locator('button:has([class*="rounded border"])').first(); - if (await featureSetToggle.isVisible()) { - await featureSetToggle.click(); - - await clients.waitForToast('success'); - const toastText = await clients.getToastText(); - expect(toastText).toMatch(/Permission (granted|revoked)/); - } + + page.on('dialog', (dialog) => dialog.accept()); + await page.getByRole('button', { name: /Revoke connection/ }).click(); + // Confirm dialog + await page.getByRole('button', { name: /Revoke/ }).click(); + + await clients.waitForToast('success'); + expect(await clients.getToastText()).toMatch(/revoked/); } }); }); diff --git a/tests/e2e/specs/clients.wdio.ts b/tests/e2e/specs/clients.wdio.ts index d895010..eeda4cc 100644 --- a/tests/e2e/specs/clients.wdio.ts +++ b/tests/e2e/specs/clients.wdio.ts @@ -1,126 +1,60 @@ /** - * E2E Tests: Client Management + * E2E Tests: Connections page (the renamed, observability-focused view). + * + * Routing is no longer configured here — that lives in Workspaces. These + * specs verify the page loads, reveals the list of approved clients (if + * any), and surfaces a link back to Workspaces instead of per-client + * routing controls. + * * Uses data-testid only (ADR-003). */ import { byTestId } from '../helpers/selectors'; -describe('Client Management - View Clients', () => { - it('TC-CL-001: Navigate to Clients page and display registered clients', async () => { - const clientsButton = await byTestId('nav-clients'); - await clientsButton.click(); +describe('Connections - Page shell', () => { + it('TC-CL-001: Navigate to Connections page and see heading + Workspaces link', async () => { + const connectionsBtn = await byTestId('nav-clients'); + await connectionsBtn.click(); await browser.pause(2000); - - await browser.saveScreenshot('./tests/e2e/screenshots/cl-01-clients-page.png'); - - // Verify page loaded + + await browser.saveScreenshot('./tests/e2e/screenshots/cl-01-connections-page.png'); + const pageSource = await browser.getPageSource(); - const hasClientsPage = pageSource.includes('Clients'); - - expect(hasClientsPage).toBe(true); - - // Check for preset clients (Cursor, VS Code, Claude Desktop) - const hasCursor = pageSource.includes('Cursor'); - const hasVSCode = pageSource.includes('VS Code') || pageSource.includes('VSCode'); - const hasClaude = pageSource.includes('Claude'); - - console.log('[DEBUG] Has Cursor:', hasCursor); - console.log('[DEBUG] Has VS Code:', hasVSCode); - console.log('[DEBUG] Has Claude:', hasClaude); - - // At least one preset client should exist - const hasPresetClients = hasCursor || hasVSCode || hasClaude; - expect(hasPresetClients).toBe(true); + + // Heading has been renamed. + expect(pageSource.includes('Connections')).toBe(true); + + // The page routes users to Workspaces for any routing questions. + expect(pageSource.includes('Workspaces')).toBe(true); }); - it('TC-CL-002: Click on a client to open detail panel', async () => { + it('TC-CL-002: Open side panel and verify legacy routing controls are gone', async () => { const clientCards = await $$('[data-testid^="client-card-"]'); const firstCard = clientCards[0]; const isDisplayed = firstCard ? await firstCard.isDisplayed().catch(() => false) : false; - + if (isDisplayed && firstCard) { await firstCard.click(); await browser.pause(1500); - - await browser.saveScreenshot('./tests/e2e/screenshots/cl-02-client-panel.png'); - - // Verify panel opened - should show settings/permissions sections - const pageSource = await browser.getPageSource(); - const hasPanelContent = - pageSource.includes('Settings') || - pageSource.includes('Permissions') || - pageSource.includes('Features') || - pageSource.includes('Connection'); - - expect(hasPanelContent).toBe(true); - } else { - const pageSource = await browser.getPageSource(); - expect(pageSource.includes('Client') || pageSource.includes('Permissions') || pageSource.includes('Clients')).toBe(true); - } - }); - it('TC-CL-009: Verify Default FeatureSet is shown as granted', async () => { - // Should already have panel open from previous test - await browser.saveScreenshot('./tests/e2e/screenshots/cl-03-permissions.png'); - - const pageSource = await browser.getPageSource(); - - // Look for Permissions section and Default feature set - const hasPermissions = pageSource.includes('Permission') || pageSource.includes('Feature'); - const hasDefault = pageSource.includes('Default'); - - console.log('[DEBUG] Has Permissions section:', hasPermissions); - console.log('[DEBUG] Has Default mentioned:', hasDefault); - - // The page should have permission-related content - expect(hasPermissions).toBe(true); - }); + await browser.saveScreenshot('./tests/e2e/screenshots/cl-02-connection-panel.png'); - it('TC-CL-010: Check for Effective Features section', async () => { - // Look for Effective Features section - const pageSource = await browser.getPageSource(); - - const hasEffectiveFeatures = - pageSource.includes('Effective') || - pageSource.includes('Features') || - pageSource.includes('Tools') || - pageSource.includes('Prompts'); - - console.log('[DEBUG] Has Effective Features:', hasEffectiveFeatures); - - await browser.saveScreenshot('./tests/e2e/screenshots/cl-04-effective-features.png'); - - expect(hasEffectiveFeatures).toBe(true); - }); -}); + const pageSource = await browser.getPageSource(); -describe('Client Management - Connection Modes', () => { - it('TC-CL-004: Verify connection mode options exist', async () => { - const clientsButton = await byTestId('nav-clients'); - await clientsButton.click(); - await browser.pause(2000); - - const clientCards = await $$('[data-testid^="client-card-"]'); - const firstCard = clientCards[0]; - if (firstCard && await firstCard.isDisplayed().catch(() => false)) { - await firstCard.click(); - await browser.pause(1500); + // Positive: the new panel exposes the Workspaces entry point. + const hasWorkspacesLink = + pageSource.includes('Open Workspaces') || pageSource.includes('workspace-driven'); + expect(hasWorkspacesLink).toBe(true); + + // Negative: all removed per-client routing sections must be gone. + expect(pageSource.includes('Quick Settings')).toBe(false); + expect(pageSource.includes('Connection Mode')).toBe(false); + expect(pageSource.includes('Effective Features')).toBe(false); + expect(pageSource.includes('Advanced Permissions')).toBe(false); + } else { + // Empty-state path: ConnectIDEs onboarding must render instead. + const pageSource = await browser.getPageSource(); + expect(pageSource.includes("Let's hook up your first IDE")).toBe(true); } - - await browser.saveScreenshot('./tests/e2e/screenshots/cl-05-connection-mode.png'); - - // Check for connection mode options - const pageSource = await browser.getPageSource(); - const hasConnectionMode = - pageSource.includes('Follow') || - pageSource.includes('Locked') || - pageSource.includes('Ask') || - pageSource.includes('Connection') || - pageSource.includes('Mode'); - - console.log('[DEBUG] Has connection mode options:', hasConnectionMode); - - // Connection mode should be visible in client settings - expect(hasConnectionMode).toBe(true); }); }); diff --git a/tests/e2e/specs/comprehensive.wdio.ts b/tests/e2e/specs/comprehensive.wdio.ts index af80ce3..2162258 100644 --- a/tests/e2e/specs/comprehensive.wdio.ts +++ b/tests/e2e/specs/comprehensive.wdio.ts @@ -7,12 +7,8 @@ import { byTestId, safeClick } from '../helpers/selectors'; import { createSpace, deleteSpace, - getActiveSpace, - setActiveSpace, + getDefaultSpace, listSpaces, - createClient, - deleteClient, - listClients, listFeatureSetsBySpace, createFeatureSet, deleteFeatureSet, @@ -22,7 +18,6 @@ import { enableServerV2, disableServerV2, getGatewayStatus, - grantFeatureSetToClient, } from '../helpers/tauri-api'; // ============================================================================ @@ -37,8 +32,8 @@ describe('Comprehensive: Space Isolation', () => { before(async () => { // Get default space - const activeSpace = await getActiveSpace(); - defaultSpaceId = activeSpace?.id || ''; + const defaultSpace = await getDefaultSpace(); + defaultSpaceId = defaultSpace?.id || ''; console.log('[setup] Default space:', defaultSpaceId); // Create test spaces @@ -69,9 +64,8 @@ describe('Comprehensive: Space Isolation', () => { }); it('TC-COMP-SP-002: Enable server and verify FeatureSet created', async () => { - // Set Work space as active - await setActiveSpace(workSpaceId); - await browser.pause(500); + // Server-enable / FS-listing APIs are scoped by spaceId arg — no + // "active space" switch needed. Routing is per workspace root now. // Enable server - MCP handshake can fail on CI, so wrap in try-catch try { @@ -93,8 +87,6 @@ describe('Comprehensive: Space Isolation', () => { }); it('TC-COMP-SP-003: Verify UI shows correct space servers', async () => { - await setActiveSpace(workSpaceId); - await browser.pause(500); await browser.refresh(); await browser.pause(2000); @@ -112,11 +104,8 @@ describe('Comprehensive: Space Isolation', () => { }); it('TC-COMP-SP-004: Switch space and verify server not visible', async () => { - // Switch to Personal space - await setActiveSpace(personalSpaceId); - await browser.pause(500); - - // Refresh UI + // Server isolation is verified via the spaceId-bound API — no UI + // active-space switch needed. await browser.refresh(); await browser.pause(2000); @@ -141,57 +130,15 @@ describe('Comprehensive: Space Isolation', () => { try { await deleteSpace(personalSpaceId); } catch (e) { /* ignore */ } - - // Reset to default space - if (defaultSpaceId) { - await setActiveSpace(defaultSpaceId); - } }); }); // ============================================================================ -// Test Suite: Client Grants +// Test Suite: Connections page (observability — no more per-client grants) // ============================================================================ -describe('Comprehensive: Client Grants', () => { - let defaultSpaceId: string; - let testClientId: string; - let defaultFeatureSetId: string; - - before(async () => { - // Get default space - const activeSpace = await getActiveSpace(); - defaultSpaceId = activeSpace?.id || ''; - - // Create test client - const client = await createClient({ - name: 'Test Client for Grants', - client_type: 'test', - connection_mode: 'follow_active', - }); - testClientId = client.id; - console.log('[setup] Created client:', testClientId); - - // Get default feature set - const featureSets = await listFeatureSetsBySpace(defaultSpaceId); - const defaultFs = featureSets.find(fs => fs.feature_set_type === 'default'); - defaultFeatureSetId = defaultFs?.id || ''; - console.log('[setup] Default FeatureSet:', defaultFeatureSetId); - }); - - it('TC-COMP-CL-001: Grant FeatureSet to client', async () => { - // Grant default feature set - await grantFeatureSetToClient(testClientId, defaultSpaceId, defaultFeatureSetId); - - // Verify client has grants - const clients = await listClients(); - const ourClient = clients.find(c => c.id === testClientId); - - expect(ourClient).toBeDefined(); - console.log('[test] Client grants:', JSON.stringify(ourClient?.grants)); - }); - - it('TC-COMP-CL-002: Verify Clients page loads', async () => { +describe('Comprehensive: Connections page', () => { + it('TC-COMP-CL-001: Verify Connections page loads', async () => { const clientsBtn = await byTestId('nav-clients'); await safeClick(clientsBtn); await browser.pause(2000); @@ -199,16 +146,10 @@ describe('Comprehensive: Client Grants', () => { await browser.saveScreenshot('./tests/e2e/screenshots/comp-03-clients.png'); const pageSource = await browser.getPageSource(); - expect(pageSource.includes('Clients') || pageSource.includes('Client')).toBe(true); - }); - - after(async () => { - // Cleanup - if (testClientId) { - try { - await deleteClient(testClientId); - } catch (e) { /* ignore */ } - } + // Heading changed from "Connected Clients" to "Connections". + expect(pageSource.includes('Connections')).toBe(true); + // And routing is advertised as workspace-driven, not per-client. + expect(pageSource.includes('Workspaces')).toBe(true); }); }); @@ -221,9 +162,8 @@ describe('Comprehensive: Server Lifecycle with API', () => { const serverId = 'github-server'; // From mock bundle before(async () => { - const activeSpace = await getActiveSpace(); - defaultSpaceId = activeSpace?.id || ''; - await setActiveSpace(defaultSpaceId); + const defaultSpace = await getDefaultSpace(); + defaultSpaceId = defaultSpace?.id || ''; // Uninstall if already present (from earlier specs) to ensure clean state try { await uninstallServer(serverId, defaultSpaceId); @@ -413,7 +353,6 @@ describe('Comprehensive: Multi-Space Server Management', () => { it('TC-COMP-MS-002: Enable server in first space only', async () => { // Enable in first space - MCP handshake can fail on CI - await setActiveSpace(testSpaces[0]); try { await enableServerV2(testSpaces[0], serverId); await browser.pause(5000); // Longer wait for CI @@ -462,10 +401,5 @@ describe('Comprehensive: Multi-Space Server Management', () => { await deleteSpace(spaceId); } catch (e) { /* ignore */ } } - - // Reset to default space - if (defaultSpaceId) { - await setActiveSpace(defaultSpaceId); - } }); }); diff --git a/tests/e2e/specs/gateway.wdio.ts b/tests/e2e/specs/gateway.wdio.ts index 4a13361..362abb7 100644 --- a/tests/e2e/specs/gateway.wdio.ts +++ b/tests/e2e/specs/gateway.wdio.ts @@ -36,9 +36,9 @@ describe('Gateway Status - Dashboard', () => { const pageSource = await browser.getPageSource(); // Gateway should be running by default - const isRunning = - pageSource.includes('Gateway: Running') || - pageSource.includes('border-green-500'); + const isRunning = + pageSource.includes('Gateway running') || + pageSource.includes('bg-green-500'); console.log('[DEBUG] Gateway running:', isRunning); expect(isRunning).toBe(true); @@ -49,9 +49,9 @@ describe('Gateway Status - Dashboard', () => { const pageSource = await browser.getPageSource(); // Check for gateway status card - const hasGatewayCard = - pageSource.includes('Gateway: Running') || - pageSource.includes('Gateway: Stopped') || + const hasGatewayCard = + pageSource.includes('Gateway running') || + pageSource.includes('Gateway stopped') || pageSource.includes('gateway-status-card'); expect(hasGatewayCard).toBe(true); diff --git a/tests/e2e/specs/post-action-guidance.spec.ts b/tests/e2e/specs/post-action-guidance.spec.ts index b4d706d..8499d66 100644 --- a/tests/e2e/specs/post-action-guidance.spec.ts +++ b/tests/e2e/specs/post-action-guidance.spec.ts @@ -89,19 +89,19 @@ test.describe('Post-Action User Guidance', () => { test.describe('OAuth consent post-approval guidance', () => { // Skip in web mode - OAuth consent requires Tauri deep link events - test.skip('should show success state with Manage Permissions button after approval', async ({ page }) => { - // This test requires the OAuthConsentModal to be triggered via a deep link event - // which is only available in the full Tauri desktop app + test.skip('should show success state with Open Workspaces button after approval', async ({ + page, + }) => { + // This test requires the OAuthConsentModal to be triggered via a deep link + // event, which is only available in the full Tauri desktop app. const dashboard = new DashboardPage(page); await dashboard.navigate(); - // After approval, the modal should show: - // - "Client Approved" heading - // - "Manage Permissions" button - // - "Later" button - const manageBtn = page.locator('[data-testid="go-to-clients-btn"]'); - await expect(manageBtn).toBeVisible(); - await expect(manageBtn).toContainText('Manage Permissions'); + // In the v2 flow the post-approval screen sends users to Workspaces + // (where routing per folder lives), not to a per-client permissions page. + const openWorkspacesBtn = page.locator('[data-testid="go-to-workspaces-btn"]'); + await expect(openWorkspacesBtn).toBeVisible(); + await expect(openWorkspacesBtn).toContainText('Open Workspaces'); }); }); }); diff --git a/tests/e2e/specs/spaces.spec.ts b/tests/e2e/specs/spaces.spec.ts index 8c440a3..797720a 100644 --- a/tests/e2e/specs/spaces.spec.ts +++ b/tests/e2e/specs/spaces.spec.ts @@ -163,24 +163,8 @@ test.describe('Space Toast Notifications', () => { expect(toastText).toContain('Space created'); }); - // Skip in web mode - requires Tauri API - test.skip('should show success toast on set active space', async ({ page }) => { - const dashboard = new DashboardPage(page); - const spacesPage = new SpacesPage(page); - await dashboard.navigate(); - - await goToSpaces(page); - - // Find a non-active space and click "Set Active" - const setActiveBtn = page.locator('[data-testid^="set-active-space-"]').first(); - if (await setActiveBtn.isVisible()) { - await setActiveBtn.click(); - - await spacesPage.waitForToast('success'); - const toastText = await spacesPage.getToastText(); - expect(toastText).toContain('Active space changed'); - } - }); + // Removed: "Set Active" toast test — gateway routing is workspace-root-driven, + // there is no per-Space active toggle anymore. // Skip in web mode - requires Tauri API test.skip('should show success toast on space deletion', async ({ page }) => { diff --git a/tests/e2e/specs/spaces.wdio.ts b/tests/e2e/specs/spaces.wdio.ts index 2ac3703..34883d4 100644 --- a/tests/e2e/specs/spaces.wdio.ts +++ b/tests/e2e/specs/spaces.wdio.ts @@ -103,29 +103,8 @@ describe('Space Management - Create and Delete', () => { } }); - it('TC-SP-003: Set a space as active', async () => { - await dismissCreateModalIfOpen(); - const setActiveButtons = await $$('[data-testid^="set-active-space-"]'); - - if (setActiveButtons.length > 0) { - const firstButton = setActiveButtons[0]; - const isDisplayed = await firstButton.isDisplayed().catch(() => false); - if (isDisplayed) { - await browser.saveScreenshot('./tests/e2e/screenshots/sp-04-before-set-active.png'); - await firstButton.click(); - await browser.pause(2000); - await browser.saveScreenshot('./tests/e2e/screenshots/sp-05-after-set-active.png'); - } - } - - // Verify page has active space indicator - const pageSource = await browser.getPageSource(); - const hasActiveIndicator = - pageSource.includes('Active') || - pageSource.includes('active'); - - expect(hasActiveIndicator).toBe(true); - }); + // TC-SP-003 removed: there's no "Set Active" affordance — gateway routing + // is decided per reported workspace root via WorkspaceBinding. it('TC-SP-011: Verify spaces are listed on page', async () => { await dismissCreateModalIfOpen(); diff --git a/tests/e2e/specs/workspaces.wdio.ts b/tests/e2e/specs/workspaces.wdio.ts new file mode 100644 index 0000000..a8824d5 --- /dev/null +++ b/tests/e2e/specs/workspaces.wdio.ts @@ -0,0 +1,184 @@ +/** + * E2E Tests: Workspaces page. + * + * A WorkspaceBinding maps a normalized filesystem path to a concrete + * (space_id, feature_set_id) pair. Roots are globally unique. These specs + * cover the CRUD path plus the UI shell. + * + * Uses data-testid only (ADR-003). + */ + +import { byTestId, safeClick, TIMEOUT } from '../helpers/selectors'; +import { + createWorkspaceBinding, + deleteWorkspaceBinding, + getActiveSpace, + listFeatureSetsBySpace, + listWorkspaceBindings, + type WorkspaceBinding, +} from '../helpers/tauri-api'; + +function uniqueRoot(): string { + const stamp = Date.now(); + return process.platform === 'win32' + ? `d:\\tmp\\mcpmux-e2e-${stamp}` + : `/tmp/mcpmux-e2e-${stamp}`; +} + +describe('Workspaces - Page shell', () => { + before(async () => { + // Clean any leftover e2e bindings so the empty-state / populated-state + // assertions are deterministic across reruns. + const existing = await listWorkspaceBindings(); + for (const b of existing.filter((x) => x.workspace_root.includes('mcpmux-e2e'))) { + await deleteWorkspaceBinding(b.id); + } + }); + + it('TC-WS-001: Navigate to Workspaces page and see heading', async () => { + const nav = await byTestId('nav-workspaces'); + await safeClick(nav); + await browser.pause(1500); + + await browser.saveScreenshot('./tests/e2e/screenshots/ws-01-page.png'); + + const src = await browser.getPageSource(); + expect(src.includes('Workspaces')).toBe(true); + + const createBtn = await byTestId('workspace-binding-create-toggle'); + expect(await createBtn.isDisplayed()).toBe(true); + }); +}); + +describe('Workspaces - Create, render, delete', () => { + let bindingId: string | null = null; + let spaceId = ''; + let featureSetId = ''; + const root = uniqueRoot(); + + before(async () => { + const active = await getActiveSpace(); + if (!active) throw new Error('No active space — cannot set up test'); + spaceId = active.id; + const fsList = await listFeatureSetsBySpace(spaceId); + const defaultFs = fsList.find((fs) => fs.feature_set_type === 'default'); + if (!defaultFs) throw new Error('No Default FS in active space'); + featureSetId = defaultFs.id; + }); + + it('TC-WS-002: Create binding pointing at the active space default FS', async () => { + const created: WorkspaceBinding = await createWorkspaceBinding({ + workspace_root: root, + space_id: spaceId, + feature_set_id: featureSetId, + }); + bindingId = created.id; + + expect(created.workspace_root.toLowerCase().endsWith(root.toLowerCase())).toBe(true); + expect(created.space_id).toBe(spaceId); + expect(created.feature_set_id).toBe(featureSetId); + }); + + it('TC-WS-003: Binding row renders on the Workspaces page', async () => { + const nav = await byTestId('nav-workspaces'); + await safeClick(nav); + await browser.pause(1500); + + // Brief nav-away-and-back to force a data reload. + const dashBtn = await byTestId('nav-dashboard'); + await safeClick(dashBtn); + await browser.pause(300); + await safeClick(nav); + await browser.pause(1500); + + await browser.saveScreenshot('./tests/e2e/screenshots/ws-02-populated.png'); + + if (bindingId) { + const row = await $(`[data-testid="workspace-binding-row-${bindingId}"]`); + await row.waitForDisplayed({ timeout: TIMEOUT.short }); + expect(await row.isDisplayed()).toBe(true); + } + }); + + it('TC-WS-004: Binding row references the target Space + FS by name', async () => { + const src = await browser.getPageSource(); + // The row's footer shows "Routes to in " — check the Space + // name is present. FS is "Default" (builtin) which may also appear in + // unrelated copy, so we only assert on the Space name for stability. + const active = await getActiveSpace(); + expect(src.includes(active?.name ?? '__never__')).toBe(true); + }); + + it('TC-WS-005: Delete binding and row disappears', async () => { + if (!bindingId) throw new Error('bindingId missing — TC-WS-002 must succeed first'); + await deleteWorkspaceBinding(bindingId); + + const dash = await byTestId('nav-dashboard'); + await safeClick(dash); + await browser.pause(300); + const nav = await byTestId('nav-workspaces'); + await safeClick(nav); + await browser.pause(1500); + + const rows = await $$(`[data-testid="workspace-binding-row-${bindingId}"]`); + expect(rows.length).toBe(0); + bindingId = null; + }); + + after(async () => { + if (bindingId) { + try { + await deleteWorkspaceBinding(bindingId); + } catch { + /* ignore */ + } + } + }); +}); + +describe('Workspaces - Create form flow (UI)', () => { + let bindingId: string | null = null; + + it('TC-WS-006: Create binding through the form and see it listed', async () => { + const nav = await byTestId('nav-workspaces'); + await safeClick(nav); + await browser.pause(1000); + + const toggle = await byTestId('workspace-binding-create-toggle'); + await safeClick(toggle); + await browser.pause(400); + + const rootInput = await byTestId('workspace-binding-root-input'); + const root = uniqueRoot(); + await rootInput.setValue(root); + + // `space` and `fs` default to the active space + its Default FS, so we + // can submit without touching the pickers. + const submit = await byTestId('workspace-binding-submit'); + await safeClick(submit); + await browser.pause(800); + + const created = (await listWorkspaceBindings()).find( + (b) => b.workspace_root.toLowerCase().endsWith(root.toLowerCase()) + ); + expect(created).toBeTruthy(); + if (created) { + bindingId = created.id; + const row = await $(`[data-testid="workspace-binding-row-${created.id}"]`); + await row.waitForDisplayed({ timeout: TIMEOUT.short }); + expect(await row.isDisplayed()).toBe(true); + } + + await browser.saveScreenshot('./tests/e2e/screenshots/ws-04-created-via-form.png'); + }); + + after(async () => { + if (bindingId) { + try { + await deleteWorkspaceBinding(bindingId); + } catch { + /* ignore */ + } + } + }); +}); diff --git a/tests/rust/src/lib.rs b/tests/rust/src/lib.rs index 1c2fbac..5770e97 100644 --- a/tests/rust/src/lib.rs +++ b/tests/rust/src/lib.rs @@ -162,25 +162,11 @@ pub mod fixtures { .with_description(format!("Test feature set: {}", name)) } - /// Create an "all features" feature set - pub fn all_features_set(space_id: &str) -> FeatureSet { - FeatureSet::new_all(space_id) - } - /// Create a "default" feature set pub fn default_feature_set(space_id: &str) -> FeatureSet { FeatureSet::new_default(space_id) } - /// Create a server-all feature set - pub fn server_all_feature_set( - space_id: &str, - server_id: &str, - server_name: &str, - ) -> FeatureSet { - FeatureSet::new_server_all(space_id, server_id, server_name) - } - /// Generate a random UUID string pub fn random_id() -> String { Uuid::new_v4().to_string() diff --git a/tests/rust/src/mocks.rs b/tests/rust/src/mocks.rs index 9f156b4..42d7b81 100644 --- a/tests/rust/src/mocks.rs +++ b/tests/rust/src/mocks.rs @@ -77,19 +77,6 @@ impl SpaceRepository for MockSpaceRepository { *self.default_id.write().unwrap() = Some(*id); Ok(()) } - - async fn set_active_feature_set( - &self, - id: &Uuid, - feature_set_id: Option<&Uuid>, - ) -> RepoResult<()> { - let mut spaces = self.spaces.write().unwrap(); - let space = spaces - .get_mut(id) - .ok_or_else(|| anyhow::anyhow!("Space not found: {}", id))?; - space.active_feature_set_id = feature_set_id.copied(); - Ok(()) - } } // ============================================================================ @@ -413,55 +400,6 @@ impl FeatureSetRepository for MockFeatureSetRepository { Ok(()) } - async fn list_builtin(&self, space_id: &str) -> RepoResult> { - Ok(self - .sets - .read() - .unwrap() - .values() - .filter(|s| { - s.space_id.as_deref() == Some(space_id) - && matches!( - s.feature_set_type, - FeatureSetType::All | FeatureSetType::Default - ) - }) - .cloned() - .collect()) - } - - async fn get_server_all( - &self, - space_id: &str, - server_id: &str, - ) -> RepoResult> { - Ok(self - .sets - .read() - .unwrap() - .values() - .find(|s| { - s.space_id.as_deref() == Some(space_id) - && s.feature_set_type == FeatureSetType::ServerAll - && s.server_id.as_deref() == Some(server_id) - }) - .cloned()) - } - - async fn ensure_server_all( - &self, - space_id: &str, - server_id: &str, - server_name: &str, - ) -> RepoResult { - if let Some(existing) = self.get_server_all(space_id, server_id).await? { - return Ok(existing); - } - let set = FeatureSet::new_server_all(space_id, server_id, server_name); - self.create(&set).await?; - Ok(set) - } - async fn get_default_for_space(&self, space_id: &str) -> RepoResult> { Ok(self .sets @@ -475,35 +413,13 @@ impl FeatureSetRepository for MockFeatureSetRepository { .cloned()) } - async fn get_all_for_space(&self, space_id: &str) -> RepoResult> { - Ok(self - .sets - .read() - .unwrap() - .values() - .find(|s| { - s.space_id.as_deref() == Some(space_id) && s.feature_set_type == FeatureSetType::All - }) - .cloned()) - } - async fn ensure_builtin_for_space(&self, space_id: &str) -> RepoResult<()> { - if self.get_all_for_space(space_id).await?.is_none() { - self.create(&FeatureSet::new_all(space_id)).await?; - } if self.get_default_for_space(space_id).await?.is_none() { self.create(&FeatureSet::new_default(space_id)).await?; } Ok(()) } - async fn delete_server_all(&self, space_id: &str, server_id: &str) -> RepoResult<()> { - if let Some(set) = self.get_server_all(space_id, server_id).await? { - self.delete(&set.id).await?; - } - Ok(()) - } - async fn add_feature_member( &self, feature_set_id: &str, @@ -556,7 +472,6 @@ impl FeatureSetRepository for MockFeatureSetRepository { #[derive(Default)] pub struct MockInboundMcpClientRepository { clients: RwLock>, - grants: RwLock>>, // (client_id, space_id) -> feature_set_ids } impl MockInboundMcpClientRepository { @@ -610,101 +525,6 @@ impl InboundMcpClientRepository for MockInboundMcpClientRepository { self.clients.write().unwrap().remove(id); Ok(()) } - - async fn grant_feature_set( - &self, - client_id: &Uuid, - space_id: &str, - feature_set_id: &str, - ) -> RepoResult<()> { - self.grants - .write() - .unwrap() - .entry((*client_id, space_id.to_string())) - .or_default() - .push(feature_set_id.to_string()); - Ok(()) - } - - async fn revoke_feature_set( - &self, - client_id: &Uuid, - space_id: &str, - feature_set_id: &str, - ) -> RepoResult<()> { - if let Some(sets) = self - .grants - .write() - .unwrap() - .get_mut(&(*client_id, space_id.to_string())) - { - sets.retain(|s| s != feature_set_id); - } - Ok(()) - } - - async fn get_grants_for_space( - &self, - client_id: &Uuid, - space_id: &str, - ) -> RepoResult> { - Ok(self - .grants - .read() - .unwrap() - .get(&(*client_id, space_id.to_string())) - .cloned() - .unwrap_or_default()) - } - - async fn get_all_grants(&self, client_id: &Uuid) -> RepoResult>> { - let grants = self.grants.read().unwrap(); - let mut result = HashMap::new(); - for ((cid, space_id), sets) in grants.iter() { - if cid == client_id { - result.insert(space_id.clone(), sets.clone()); - } - } - Ok(result) - } - - async fn set_grants_for_space( - &self, - client_id: &Uuid, - space_id: &str, - feature_set_ids: &[String], - ) -> RepoResult<()> { - self.grants - .write() - .unwrap() - .insert((*client_id, space_id.to_string()), feature_set_ids.to_vec()); - Ok(()) - } - - async fn has_grants_for_space(&self, client_id: &Uuid, space_id: &str) -> RepoResult { - Ok(self - .grants - .read() - .unwrap() - .get(&(*client_id, space_id.to_string())) - .map(|v| !v.is_empty()) - .unwrap_or(false)) - } - - async fn set_pin( - &self, - client_id: &Uuid, - pinned_space_id: &Uuid, - pinned_feature_set_id: Option<&Uuid>, - ) -> RepoResult<()> { - let mut clients = self.clients.write().unwrap(); - let client = clients - .get_mut(client_id) - .ok_or_else(|| anyhow::anyhow!("Client not found: {}", client_id))?; - client.pinned_space_id = Some(*pinned_space_id); - client.pinned_feature_set_id = pinned_feature_set_id.copied(); - Ok(()) - } } // ============================================================================ diff --git a/tests/rust/tests/database/feature_set.rs b/tests/rust/tests/database/feature_set.rs index ecef0b5..6a91183 100644 --- a/tests/rust/tests/database/feature_set.rs +++ b/tests/rust/tests/database/feature_set.rs @@ -68,17 +68,17 @@ async fn test_list_by_space() { .await .unwrap(); - // List for space1: 2 custom + 2 builtin (All, Default) = 4 + // List for space1: 2 custom + 1 builtin (Default only) = 3. let space1_sets = FeatureSetRepository::list_by_space(&feature_repo, &space1.id.to_string()) .await .expect("Failed to list"); - assert_eq!(space1_sets.len(), 4); + assert_eq!(space1_sets.len(), 3); - // List for space2: 1 custom + 2 builtin = 3 + // List for space2: 1 custom + 1 builtin = 2. let space2_sets = FeatureSetRepository::list_by_space(&feature_repo, &space2.id.to_string()) .await .expect("Failed to list"); - assert_eq!(space2_sets.len(), 3); + assert_eq!(space2_sets.len(), 2); } #[tokio::test] @@ -153,18 +153,11 @@ async fn test_ensure_builtin_for_space() { let space = fixtures::test_space("Test Space"); SpaceRepository::create(&space_repo, &space).await.unwrap(); - // Ensure builtin (All + Default) + // Ensure builtin (only Default; All/ServerAll were removed) FeatureSetRepository::ensure_builtin_for_space(&feature_repo, &space.id.to_string()) .await .expect("Failed to ensure builtin"); - // Get All feature set - let all_set = FeatureSetRepository::get_all_for_space(&feature_repo, &space.id.to_string()) - .await - .expect("Failed to get All"); - assert!(all_set.is_some()); - assert_eq!(all_set.unwrap().feature_set_type, FeatureSetType::All); - // Get Default feature set let default_set = FeatureSetRepository::get_default_for_space(&feature_repo, &space.id.to_string()) @@ -187,7 +180,7 @@ async fn test_ensure_builtin_idempotent() { let space = fixtures::test_space("Test Space"); SpaceRepository::create(&space_repo, &space).await.unwrap(); - // Call twice + // Call twice — must stay idempotent. FeatureSetRepository::ensure_builtin_for_space(&feature_repo, &space.id.to_string()) .await .unwrap(); @@ -195,69 +188,14 @@ async fn test_ensure_builtin_idempotent() { .await .unwrap(); - // Should still have exactly 2 builtin sets - let builtin = FeatureSetRepository::list_builtin(&feature_repo, &space.id.to_string()) - .await - .expect("Failed to list builtin"); - assert_eq!(builtin.len(), 2); -} - -#[tokio::test] -async fn test_server_all_feature_set() { - let test_db = TestDatabase::new(); - let db = Arc::new(Mutex::new(test_db.db)); - let feature_repo = SqliteFeatureSetRepository::new(Arc::clone(&db)); - let space_repo = SqliteSpaceRepository::new(db); - - let space = fixtures::test_space("Test Space"); - SpaceRepository::create(&space_repo, &space).await.unwrap(); - - // Create server-all feature set - let server_all = FeatureSetRepository::ensure_server_all( - &feature_repo, - &space.id.to_string(), - "my-server", - "My Server", - ) - .await - .expect("Failed to ensure server-all"); - - assert_eq!(server_all.feature_set_type, FeatureSetType::ServerAll); - assert_eq!(server_all.server_id, Some("my-server".to_string())); - - // Get by server_id - let found = - FeatureSetRepository::get_server_all(&feature_repo, &space.id.to_string(), "my-server") - .await - .expect("Failed to get server-all"); - assert!(found.is_some()); - assert_eq!(found.unwrap().id, server_all.id); -} - -#[tokio::test] -async fn test_delete_server_all() { - let test_db = TestDatabase::new(); - let db = Arc::new(Mutex::new(test_db.db)); - let feature_repo = SqliteFeatureSetRepository::new(Arc::clone(&db)); - let space_repo = SqliteSpaceRepository::new(db); - - let space = fixtures::test_space("Test Space"); - SpaceRepository::create(&space_repo, &space).await.unwrap(); - - // Create then delete - FeatureSetRepository::ensure_server_all(&feature_repo, &space.id.to_string(), "srv", "Srv") - .await - .unwrap(); - - FeatureSetRepository::delete_server_all(&feature_repo, &space.id.to_string(), "srv") - .await - .expect("Failed to delete server-all"); - - // Should be gone - let found = FeatureSetRepository::get_server_all(&feature_repo, &space.id.to_string(), "srv") + let by_space = FeatureSetRepository::list_by_space(&feature_repo, &space.id.to_string()) .await - .unwrap(); - assert!(found.is_none()); + .expect("Failed to list by space"); + let default_count = by_space + .iter() + .filter(|fs| matches!(fs.feature_set_type, FeatureSetType::Default)) + .count(); + assert_eq!(default_count, 1, "exactly one Default FS per space"); } // ============================================================================= @@ -430,28 +368,15 @@ async fn test_feature_set_types() { let space = fixtures::test_space("Test Space"); SpaceRepository::create(&space_repo, &space).await.unwrap(); - // Note: SpaceRepository::create auto-creates All and Default feature sets - // So we only need to create Custom and ServerAll here + // Space creation auto-seeds the Default FS; add a Custom one by hand. let custom = fixtures::test_feature_set("Custom", &space.id.to_string()); - let server_all = fixtures::server_all_feature_set(&space.id.to_string(), "srv", "Server"); FeatureSetRepository::create(&feature_repo, &custom) .await .unwrap(); - FeatureSetRepository::create(&feature_repo, &server_all) - .await - .unwrap(); - // Verify types - use the auto-created IDs for All and Default - let all_id = format!("fs_all_{}", space.id); let default_id = format!("fs_default_{}", space.id); - let all_loaded = FeatureSetRepository::get(&feature_repo, &all_id) - .await - .unwrap() - .unwrap(); - assert_eq!(all_loaded.feature_set_type, FeatureSetType::All); - let default_loaded = FeatureSetRepository::get(&feature_repo, &default_id) .await .unwrap() @@ -463,15 +388,6 @@ async fn test_feature_set_types() { .unwrap() .unwrap(); assert_eq!(custom_loaded.feature_set_type, FeatureSetType::Custom); - - let server_all_loaded = FeatureSetRepository::get(&feature_repo, &server_all.id) - .await - .unwrap() - .unwrap(); - assert_eq!( - server_all_loaded.feature_set_type, - FeatureSetType::ServerAll - ); } // ============================================================================= @@ -503,8 +419,8 @@ async fn test_feature_set_space_isolation() { .await .unwrap(); - // They should be independent - // Each space has 2 builtin (All, Default) + 1 custom = 3 + // They should be independent. Each space auto-seeds the Default FS + // plus the custom one we just added = 2. let work_sets = FeatureSetRepository::list_by_space(&feature_repo, &work.id.to_string()) .await .unwrap(); @@ -513,8 +429,8 @@ async fn test_feature_set_space_isolation() { .await .unwrap(); - assert_eq!(work_sets.len(), 3); - assert_eq!(personal_sets.len(), 3); + assert_eq!(work_sets.len(), 2); + assert_eq!(personal_sets.len(), 2); // Verify the custom sets are different let work_custom: Vec<_> = work_sets diff --git a/tests/rust/tests/database/inbound_client.rs b/tests/rust/tests/database/inbound_client.rs index f5eadb9..5ca7639 100644 --- a/tests/rust/tests/database/inbound_client.rs +++ b/tests/rust/tests/database/inbound_client.rs @@ -3,13 +3,12 @@ //! Tests for DCR registration, OAuth authorization codes, tokens, and client grants. //! These test the INBOUND flow: AI clients (Cursor, Claude) connecting TO McpMux. -use mcpmux_core::repository::SpaceRepository; use mcpmux_storage::{ - AuthorizationCode, InboundClient, InboundClientRepository, RegistrationType, - SqliteSpaceRepository, TokenRecord, TokenType, + AuthorizationCode, InboundClient, InboundClientRepository, RegistrationType, TokenRecord, + TokenType, }; use std::sync::Arc; -use tests::{db::TestDatabase, fixtures}; +use tests::db::TestDatabase; use tokio::sync::Mutex; fn create_test_client(name: &str) -> InboundClient { @@ -35,8 +34,6 @@ fn create_test_client(name: &str) -> InboundClient { metadata_url: None, metadata_cached_at: None, metadata_cache_ttl: None, - connection_mode: "follow_active".to_string(), - locked_space_id: None, last_seen: None, created_at: now.clone(), updated_at: now, @@ -561,35 +558,21 @@ async fn test_revoke_client_tokens() { // ============================================================================= #[tokio::test] -async fn test_update_client_settings() { +async fn test_update_client_alias() { let test_db = TestDatabase::new(); let db = Arc::new(Mutex::new(test_db.db)); - let repo = InboundClientRepository::new(Arc::clone(&db)); - let space_repo = SqliteSpaceRepository::new(db); - - // Create a space for locking - let space = fixtures::test_space("Locked Space"); - SpaceRepository::create(&space_repo, &space).await.unwrap(); + let repo = InboundClientRepository::new(db); - let client = create_test_client("Settings Test"); + let client = create_test_client("Alias Test"); repo.save_client(&client).await.unwrap(); - // Update settings let updated = repo - .update_client_settings( - &client.client_id, - Some("My Cursor".to_string()), // alias - Some("locked".to_string()), // connection_mode - Some(Some(space.id.to_string())), // locked_space_id - ) + .update_client_alias(&client.client_id, Some("My Cursor".to_string())) .await - .expect("Failed to update settings"); + .expect("Failed to update alias"); - assert!(updated.is_some()); - let updated = updated.unwrap(); + let updated = updated.expect("client should exist after alias update"); assert_eq!(updated.client_alias, Some("My Cursor".to_string())); - assert_eq!(updated.connection_mode, "locked"); - assert_eq!(updated.locked_space_id, Some(space.id.to_string())); } #[tokio::test] diff --git a/tests/rust/tests/database/repositories.rs b/tests/rust/tests/database/repositories.rs index 0d243c5..4f7eb7d 100644 --- a/tests/rust/tests/database/repositories.rs +++ b/tests/rust/tests/database/repositories.rs @@ -188,7 +188,7 @@ async fn test_space_repository_concurrent_reads() { // Create a space let space = fixtures::test_space("Concurrent Test"); - let space_id = space.id.clone(); + let space_id = space.id; SpaceRepository::create(repo.as_ref(), &space) .await .unwrap(); @@ -197,7 +197,7 @@ async fn test_space_repository_concurrent_reads() { let mut handles = vec![]; for _ in 0..5 { let repo_clone = Arc::clone(&repo); - let id = space_id.clone(); + let id = space_id; handles.push(tokio::spawn(async move { SpaceRepository::get(repo_clone.as_ref(), &id).await })); diff --git a/tests/rust/tests/integration/feature_grants.rs b/tests/rust/tests/integration/feature_grants.rs deleted file mode 100644 index 47dcb29..0000000 --- a/tests/rust/tests/integration/feature_grants.rs +++ /dev/null @@ -1,685 +0,0 @@ -//! Feature Grant Resolution tests -//! -//! Tests the complete flow: Space → FeatureSet → Features using FeatureService facade -//! Covers all feature set types: All, Default, ServerAll, Custom - -use std::sync::Arc; -use uuid::Uuid; - -use mcpmux_core::{ - FeatureSet, FeatureSetMember, FeatureSetRepository, FeatureType, MemberMode, MemberType, - ServerFeature, ServerFeatureRepository, -}; -use mcpmux_gateway::{FeatureService, PrefixCacheService}; -use tests::mocks::{MockFeatureSetRepository, MockServerFeatureRepository}; - -// Helper to create test features -fn create_test_feature( - space_id: &str, - server_id: &str, - name: &str, - feature_type: FeatureType, -) -> ServerFeature { - let mut feature = match feature_type { - FeatureType::Tool => ServerFeature::tool(space_id, server_id, name), - FeatureType::Prompt => ServerFeature::prompt(space_id, server_id, name), - FeatureType::Resource => ServerFeature::resource(space_id, server_id, name), - }; - feature.is_available = true; - feature -} - -fn create_feature_service( - feature_repo: Arc, - feature_set_repo: Arc, - prefix_cache: Arc, -) -> FeatureService { - FeatureService::new( - feature_repo as Arc, - feature_set_repo as Arc, - prefix_cache, - ) -} - -// ============================================================================ -// FEATURE SET TYPE: ALL -// ============================================================================ - -#[tokio::test] -async fn test_all_featureset_grants_all_features() { - let space_id = Uuid::new_v4().to_string(); - let server_id = "server-001"; - - let feature_repo = Arc::new(MockServerFeatureRepository::new()); - let feature_set_repo = Arc::new(MockFeatureSetRepository::new()); - let prefix_cache = Arc::new(PrefixCacheService::new()); - - // Create features - feature_repo - .upsert(&create_test_feature( - &space_id, - server_id, - "tool_a", - FeatureType::Tool, - )) - .await - .unwrap(); - feature_repo - .upsert(&create_test_feature( - &space_id, - server_id, - "tool_b", - FeatureType::Tool, - )) - .await - .unwrap(); - feature_repo - .upsert(&create_test_feature( - &space_id, - server_id, - "prompt_a", - FeatureType::Prompt, - )) - .await - .unwrap(); - - // Create "All" feature set - let all_fs = FeatureSet::new_all(&space_id); - let all_fs_id = all_fs.id.clone(); - feature_set_repo.create(&all_fs).await.unwrap(); - - let service = create_feature_service(feature_repo, feature_set_repo, prefix_cache); - - let resolved = service - .resolve_feature_sets(&space_id, &[all_fs_id]) - .await - .unwrap(); - - assert_eq!(resolved.len(), 3, "All 3 features should be resolved"); - assert!(resolved.iter().any(|f| f.feature_name == "tool_a")); - assert!(resolved.iter().any(|f| f.feature_name == "tool_b")); - assert!(resolved.iter().any(|f| f.feature_name == "prompt_a")); -} - -#[tokio::test] -async fn test_all_featureset_excludes_unavailable() { - let space_id = Uuid::new_v4().to_string(); - let server_id = "server-001"; - - let feature_repo = Arc::new(MockServerFeatureRepository::new()); - let feature_set_repo = Arc::new(MockFeatureSetRepository::new()); - let prefix_cache = Arc::new(PrefixCacheService::new()); - - // Create available and unavailable features - let available = create_test_feature(&space_id, server_id, "available_tool", FeatureType::Tool); - let mut unavailable = - create_test_feature(&space_id, server_id, "unavailable_tool", FeatureType::Tool); - unavailable.is_available = false; - - feature_repo.upsert(&available).await.unwrap(); - feature_repo.upsert(&unavailable).await.unwrap(); - - let all_fs = FeatureSet::new_all(&space_id); - let all_fs_id = all_fs.id.clone(); - feature_set_repo.create(&all_fs).await.unwrap(); - - let service = create_feature_service(feature_repo, feature_set_repo, prefix_cache); - - let resolved = service - .resolve_feature_sets(&space_id, &[all_fs_id]) - .await - .unwrap(); - - assert_eq!( - resolved.len(), - 1, - "Only available feature should be resolved" - ); - assert_eq!(resolved[0].feature_name, "available_tool"); -} - -// ============================================================================ -// FEATURE SET TYPE: SERVER-ALL -// ============================================================================ - -#[tokio::test] -async fn test_server_all_grants_only_server_features() { - let space_id = Uuid::new_v4().to_string(); - let server_a = "server-a"; - let server_b = "server-b"; - - let feature_repo = Arc::new(MockServerFeatureRepository::new()); - let feature_set_repo = Arc::new(MockFeatureSetRepository::new()); - let prefix_cache = Arc::new(PrefixCacheService::new()); - - // Create features for both servers - feature_repo - .upsert(&create_test_feature( - &space_id, - server_a, - "tool_a1", - FeatureType::Tool, - )) - .await - .unwrap(); - feature_repo - .upsert(&create_test_feature( - &space_id, - server_a, - "tool_a2", - FeatureType::Tool, - )) - .await - .unwrap(); - feature_repo - .upsert(&create_test_feature( - &space_id, - server_b, - "tool_b1", - FeatureType::Tool, - )) - .await - .unwrap(); - - // Create ServerAll for server_a only - let server_all = FeatureSet::new_server_all(&space_id, server_a, "Server A"); - let server_all_id = server_all.id.clone(); - feature_set_repo.create(&server_all).await.unwrap(); - - let service = create_feature_service(feature_repo, feature_set_repo, prefix_cache); - - let resolved = service - .resolve_feature_sets(&space_id, &[server_all_id]) - .await - .unwrap(); - - // Should only include server_a features - assert_eq!( - resolved.len(), - 2, - "Only server_a features should be resolved" - ); - assert!(resolved.iter().all(|f| f.server_id == server_a)); - assert!(resolved.iter().any(|f| f.feature_name == "tool_a1")); - assert!(resolved.iter().any(|f| f.feature_name == "tool_a2")); -} - -// ============================================================================ -// FEATURE SET TYPE: DEFAULT (Empty = No features) -// ============================================================================ - -#[tokio::test] -async fn test_default_featureset_empty_grants_nothing() { - let space_id = Uuid::new_v4().to_string(); - let server_id = "server-001"; - - let feature_repo = Arc::new(MockServerFeatureRepository::new()); - let feature_set_repo = Arc::new(MockFeatureSetRepository::new()); - let prefix_cache = Arc::new(PrefixCacheService::new()); - - // Create features - feature_repo - .upsert(&create_test_feature( - &space_id, - server_id, - "tool_a", - FeatureType::Tool, - )) - .await - .unwrap(); - - // Create empty Default feature set (secure by default) - let default_fs = FeatureSet::new_default(&space_id); - let default_fs_id = default_fs.id.clone(); - feature_set_repo.create(&default_fs).await.unwrap(); - - let service = create_feature_service(feature_repo, feature_set_repo, prefix_cache); - - let resolved = service - .resolve_feature_sets(&space_id, &[default_fs_id]) - .await - .unwrap(); - - assert_eq!(resolved.len(), 0, "Empty default should grant no features"); -} - -// ============================================================================ -// FEATURE SET TYPE: CUSTOM -// ============================================================================ - -#[tokio::test] -async fn test_custom_featureset_with_include_members() { - let space_id = Uuid::new_v4().to_string(); - let server_id = "server-001"; - - let feature_repo = Arc::new(MockServerFeatureRepository::new()); - let feature_set_repo = Arc::new(MockFeatureSetRepository::new()); - let prefix_cache = Arc::new(PrefixCacheService::new()); - - // Create features - let tool_a = create_test_feature(&space_id, server_id, "tool_a", FeatureType::Tool); - let tool_a_id = tool_a.id.to_string(); - let tool_b = create_test_feature(&space_id, server_id, "tool_b", FeatureType::Tool); - let tool_b_id = tool_b.id.to_string(); - let tool_c = create_test_feature(&space_id, server_id, "tool_c", FeatureType::Tool); - feature_repo.upsert(&tool_a).await.unwrap(); - feature_repo.upsert(&tool_b).await.unwrap(); - feature_repo.upsert(&tool_c).await.unwrap(); - - // Create Custom feature set with specific members - let mut custom_fs = FeatureSet::new_custom("Custom Set", &space_id); - custom_fs.members.push(FeatureSetMember { - id: Uuid::new_v4().to_string(), - feature_set_id: custom_fs.id.clone(), - member_id: tool_a_id, - member_type: MemberType::Feature, - mode: MemberMode::Include, - }); - custom_fs.members.push(FeatureSetMember { - id: Uuid::new_v4().to_string(), - feature_set_id: custom_fs.id.clone(), - member_id: tool_b_id, - member_type: MemberType::Feature, - mode: MemberMode::Include, - }); - let custom_fs_id = custom_fs.id.clone(); - feature_set_repo.create(&custom_fs).await.unwrap(); - - let service = create_feature_service(feature_repo, feature_set_repo, prefix_cache); - - let resolved = service - .resolve_feature_sets(&space_id, &[custom_fs_id]) - .await - .unwrap(); - - assert_eq!( - resolved.len(), - 2, - "Only included features should be resolved" - ); - assert!(resolved.iter().any(|f| f.feature_name == "tool_a")); - assert!(resolved.iter().any(|f| f.feature_name == "tool_b")); - assert!(!resolved.iter().any(|f| f.feature_name == "tool_c")); -} - -// ============================================================================ -// NESTED FEATURE SETS -// ============================================================================ - -#[tokio::test] -async fn test_nested_featureset_composition() { - let space_id = Uuid::new_v4().to_string(); - let server_a = "server-a"; - let server_b = "server-b"; - - let feature_repo = Arc::new(MockServerFeatureRepository::new()); - let feature_set_repo = Arc::new(MockFeatureSetRepository::new()); - let prefix_cache = Arc::new(PrefixCacheService::new()); - - // Create features - feature_repo - .upsert(&create_test_feature( - &space_id, - server_a, - "tool_a", - FeatureType::Tool, - )) - .await - .unwrap(); - feature_repo - .upsert(&create_test_feature( - &space_id, - server_b, - "tool_b", - FeatureType::Tool, - )) - .await - .unwrap(); - - // Create ServerAll for each server - let server_all_a = FeatureSet::new_server_all(&space_id, server_a, "Server A"); - let server_all_a_id = server_all_a.id.clone(); - let server_all_b = FeatureSet::new_server_all(&space_id, server_b, "Server B"); - let server_all_b_id = server_all_b.id.clone(); - feature_set_repo.create(&server_all_a).await.unwrap(); - feature_set_repo.create(&server_all_b).await.unwrap(); - - // Create composite Custom feature set that includes both ServerAll sets - let mut composite_fs = FeatureSet::new_custom("Composite", &space_id); - composite_fs.members.push(FeatureSetMember { - id: Uuid::new_v4().to_string(), - feature_set_id: composite_fs.id.clone(), - member_id: server_all_a_id, - member_type: MemberType::FeatureSet, - mode: MemberMode::Include, - }); - composite_fs.members.push(FeatureSetMember { - id: Uuid::new_v4().to_string(), - feature_set_id: composite_fs.id.clone(), - member_id: server_all_b_id, - member_type: MemberType::FeatureSet, - mode: MemberMode::Include, - }); - let composite_fs_id = composite_fs.id.clone(); - feature_set_repo.create(&composite_fs).await.unwrap(); - - let service = create_feature_service(feature_repo, feature_set_repo, prefix_cache); - - let resolved = service - .resolve_feature_sets(&space_id, &[composite_fs_id]) - .await - .unwrap(); - - assert_eq!(resolved.len(), 2, "Both server features should be resolved"); - assert!(resolved - .iter() - .any(|f| f.feature_name == "tool_a" && f.server_id == server_a)); - assert!(resolved - .iter() - .any(|f| f.feature_name == "tool_b" && f.server_id == server_b)); -} - -// ============================================================================ -// TYPE FILTERING -// ============================================================================ - -#[tokio::test] -async fn test_get_tools_for_grants() { - let space_id = Uuid::new_v4().to_string(); - let server_id = "server-001"; - - let feature_repo = Arc::new(MockServerFeatureRepository::new()); - let feature_set_repo = Arc::new(MockFeatureSetRepository::new()); - let prefix_cache = Arc::new(PrefixCacheService::new()); - - // Create mixed features - feature_repo - .upsert(&create_test_feature( - &space_id, - server_id, - "tool_a", - FeatureType::Tool, - )) - .await - .unwrap(); - feature_repo - .upsert(&create_test_feature( - &space_id, - server_id, - "prompt_a", - FeatureType::Prompt, - )) - .await - .unwrap(); - feature_repo - .upsert(&create_test_feature( - &space_id, - server_id, - "resource://test", - FeatureType::Resource, - )) - .await - .unwrap(); - - let all_fs = FeatureSet::new_all(&space_id); - let all_fs_id = all_fs.id.clone(); - feature_set_repo.create(&all_fs).await.unwrap(); - - let service = create_feature_service(feature_repo, feature_set_repo, prefix_cache); - - let tools = service - .get_tools_for_grants(&space_id, &[all_fs_id]) - .await - .unwrap(); - - assert_eq!(tools.len(), 1, "Only tools should be returned"); - assert_eq!(tools[0].feature_type, FeatureType::Tool); -} - -#[tokio::test] -async fn test_get_prompts_for_grants() { - let space_id = Uuid::new_v4().to_string(); - let server_id = "server-001"; - - let feature_repo = Arc::new(MockServerFeatureRepository::new()); - let feature_set_repo = Arc::new(MockFeatureSetRepository::new()); - let prefix_cache = Arc::new(PrefixCacheService::new()); - - // Create mixed features - feature_repo - .upsert(&create_test_feature( - &space_id, - server_id, - "tool_a", - FeatureType::Tool, - )) - .await - .unwrap(); - feature_repo - .upsert(&create_test_feature( - &space_id, - server_id, - "prompt_a", - FeatureType::Prompt, - )) - .await - .unwrap(); - feature_repo - .upsert(&create_test_feature( - &space_id, - server_id, - "prompt_b", - FeatureType::Prompt, - )) - .await - .unwrap(); - - let all_fs = FeatureSet::new_all(&space_id); - let all_fs_id = all_fs.id.clone(); - feature_set_repo.create(&all_fs).await.unwrap(); - - let service = create_feature_service(feature_repo, feature_set_repo, prefix_cache); - - let prompts = service - .get_prompts_for_grants(&space_id, &[all_fs_id]) - .await - .unwrap(); - - assert_eq!(prompts.len(), 2, "Only prompts should be returned"); - assert!(prompts - .iter() - .all(|f| f.feature_type == FeatureType::Prompt)); -} - -#[tokio::test] -async fn test_get_resources_for_grants() { - let space_id = Uuid::new_v4().to_string(); - let server_id = "server-001"; - - let feature_repo = Arc::new(MockServerFeatureRepository::new()); - let feature_set_repo = Arc::new(MockFeatureSetRepository::new()); - let prefix_cache = Arc::new(PrefixCacheService::new()); - - // Create mixed features - feature_repo - .upsert(&create_test_feature( - &space_id, - server_id, - "tool_a", - FeatureType::Tool, - )) - .await - .unwrap(); - feature_repo - .upsert(&create_test_feature( - &space_id, - server_id, - "resource://test", - FeatureType::Resource, - )) - .await - .unwrap(); - - let all_fs = FeatureSet::new_all(&space_id); - let all_fs_id = all_fs.id.clone(); - feature_set_repo.create(&all_fs).await.unwrap(); - - let service = create_feature_service(feature_repo, feature_set_repo, prefix_cache); - - let resources = service - .get_resources_for_grants(&space_id, &[all_fs_id]) - .await - .unwrap(); - - assert_eq!(resources.len(), 1, "Only resources should be returned"); - assert_eq!(resources[0].feature_type, FeatureType::Resource); -} - -// ============================================================================ -// SPACE ISOLATION -// ============================================================================ - -#[tokio::test] -async fn test_features_isolated_by_space() { - let space_a = Uuid::new_v4().to_string(); - let space_b = Uuid::new_v4().to_string(); - let server_id = "server-001"; - - let feature_repo = Arc::new(MockServerFeatureRepository::new()); - let feature_set_repo = Arc::new(MockFeatureSetRepository::new()); - let prefix_cache = Arc::new(PrefixCacheService::new()); - - // Create features in different spaces - feature_repo - .upsert(&create_test_feature( - &space_a, - server_id, - "tool_in_space_a", - FeatureType::Tool, - )) - .await - .unwrap(); - feature_repo - .upsert(&create_test_feature( - &space_b, - server_id, - "tool_in_space_b", - FeatureType::Tool, - )) - .await - .unwrap(); - - // Create All feature set for space_a - let all_fs = FeatureSet::new_all(&space_a); - let all_fs_id = all_fs.id.clone(); - feature_set_repo.create(&all_fs).await.unwrap(); - - let service = create_feature_service(feature_repo, feature_set_repo, prefix_cache); - - // Resolve for space_a - let resolved = service - .resolve_feature_sets(&space_a, &[all_fs_id]) - .await - .unwrap(); - - // Should only get space_a feature - assert_eq!(resolved.len(), 1); - assert_eq!(resolved[0].feature_name, "tool_in_space_a"); - assert_eq!(resolved[0].space_id, space_a); -} - -// ============================================================================ -// MULTIPLE GRANTS COMBINED -// ============================================================================ - -#[tokio::test] -async fn test_multiple_grants_union() { - let space_id = Uuid::new_v4().to_string(); - let server_a = "server-a"; - let server_b = "server-b"; - - let feature_repo = Arc::new(MockServerFeatureRepository::new()); - let feature_set_repo = Arc::new(MockFeatureSetRepository::new()); - let prefix_cache = Arc::new(PrefixCacheService::new()); - - // Create features - feature_repo - .upsert(&create_test_feature( - &space_id, - server_a, - "tool_a", - FeatureType::Tool, - )) - .await - .unwrap(); - feature_repo - .upsert(&create_test_feature( - &space_id, - server_b, - "tool_b", - FeatureType::Tool, - )) - .await - .unwrap(); - - // Create ServerAll for each server - let server_all_a = FeatureSet::new_server_all(&space_id, server_a, "Server A"); - let server_all_a_id = server_all_a.id.clone(); - let server_all_b = FeatureSet::new_server_all(&space_id, server_b, "Server B"); - let server_all_b_id = server_all_b.id.clone(); - feature_set_repo.create(&server_all_a).await.unwrap(); - feature_set_repo.create(&server_all_b).await.unwrap(); - - let service = create_feature_service(feature_repo, feature_set_repo, prefix_cache); - - // Resolve with multiple grants - let resolved = service - .resolve_feature_sets(&space_id, &[server_all_a_id, server_all_b_id]) - .await - .unwrap(); - - // Should include features from both servers - assert_eq!(resolved.len(), 2); - assert!(resolved.iter().any(|f| f.feature_name == "tool_a")); - assert!(resolved.iter().any(|f| f.feature_name == "tool_b")); -} - -#[tokio::test] -async fn test_prefix_enrichment() { - let space_id = Uuid::new_v4().to_string(); - let server_id = "my-server"; - - let feature_repo = Arc::new(MockServerFeatureRepository::new()); - let feature_set_repo = Arc::new(MockFeatureSetRepository::new()); - let prefix_cache = Arc::new(PrefixCacheService::new()); - - // Register server prefix - prefix_cache - .assign_prefix_runtime(&space_id, server_id, Some("myalias")) - .await; - - // Create feature - feature_repo - .upsert(&create_test_feature( - &space_id, - server_id, - "my_tool", - FeatureType::Tool, - )) - .await - .unwrap(); - - let all_fs = FeatureSet::new_all(&space_id); - let all_fs_id = all_fs.id.clone(); - feature_set_repo.create(&all_fs).await.unwrap(); - - let service = create_feature_service(feature_repo, feature_set_repo, prefix_cache); - - let resolved = service - .resolve_feature_sets(&space_id, &[all_fs_id]) - .await - .unwrap(); - - assert_eq!(resolved.len(), 1); - assert_eq!(resolved[0].server_alias, Some("myalias".to_string())); -} diff --git a/tests/rust/tests/integration/feature_set_resolver.rs b/tests/rust/tests/integration/feature_set_resolver.rs index 5d0b01b..402c6a1 100644 --- a/tests/rust/tests/integration/feature_set_resolver.rs +++ b/tests/rust/tests/integration/feature_set_resolver.rs @@ -1,279 +1,176 @@ -//! Decision-table tests for the FeatureSet resolver (pin > workspace > space-active > deny). +//! Decision-table tests for the FeatureSet resolver. //! -//! Uses real SQLite repositories (via in-memory Database) rather than mocks so -//! we exercise the same code paths the gateway will at runtime. +//! Post-simplification the resolver has exactly two outcomes: +//! +//! 1. **WorkspaceBinding** — session reports roots AND a binding matches. +//! Both `space_id` and `feature_set_id` are pulled directly from the +//! binding row — no "active FS" indirection. +//! 2. **Default** — no roots / no match. Returns the default Space's +//! auto-seeded `fs_default_` FeatureSet. use std::sync::Arc; use mcpmux_core::{ - normalize_workspace_root, Client, FeatureSet, FeatureSetRepository, InboundMcpClientRepository, - Space, SpaceRepository, WorkspaceBinding, WorkspaceBindingRepository, + normalize_workspace_root, FeatureSet, FeatureSetRepository, SpaceRepository, WorkspaceBinding, + WorkspaceBindingRepository, }; use mcpmux_gateway::services::{FeatureSetResolverService, ResolutionSource, SessionRootsRegistry}; use mcpmux_storage::{ - Database, SqliteFeatureSetRepository, SqliteInboundMcpClientRepository, SqliteSpaceRepository, - SqliteWorkspaceBindingRepository, + Database, SqliteFeatureSetRepository, SqliteSpaceRepository, SqliteWorkspaceBindingRepository, }; use tokio::sync::Mutex; use uuid::Uuid; -/// Fixture wiring up real SQLite-backed repos + a resolver, with a Space that -/// already has an `active_feature_set_id` so the SpaceActive tier works. struct Fixture { resolver: FeatureSetResolverService, session_roots: Arc, - client_repo: Arc, - space_repo: Arc, binding_repo: Arc, space_id: Uuid, - active_fs_id: Uuid, - other_fs_id: Uuid, - client_id: Uuid, + default_fs_id: String, + fs_a_id: String, + fs_b_id: String, } impl Fixture { async fn new() -> Self { let db = Arc::new(Mutex::new(Database::open_in_memory().unwrap())); - let space_repo: Arc = Arc::new(SqliteSpaceRepository::new(db.clone())); let fs_repo: Arc = Arc::new(SqliteFeatureSetRepository::new(db.clone())); - let client_repo: Arc = - Arc::new(SqliteInboundMcpClientRepository::new(db.clone())); let binding_repo: Arc = Arc::new(SqliteWorkspaceBindingRepository::new(db.clone())); - // Use the default space (created by migration 001). let default_space = space_repo.get_default().await.unwrap().unwrap(); let space_id = default_space.id; - // Create two custom FSes so we can distinguish Pin from SpaceActive. - let active_fs = FeatureSet::new_custom("Space Active FS", &space_id.to_string()); - let other_fs = FeatureSet::new_custom("Pinned FS", &space_id.to_string()); - fs_repo.create(&active_fs).await.unwrap(); - fs_repo.create(&other_fs).await.unwrap(); - let active_fs_id = Uuid::parse_str(&active_fs.id).unwrap(); - let other_fs_id = Uuid::parse_str(&other_fs.id).unwrap(); - - // Set Space's active FS. - space_repo - .set_active_feature_set(&space_id, Some(&active_fs_id)) + // Migration seeds exactly one builtin per space: Default. + let default_fs_id = fs_repo + .get_default_for_space(&space_id.to_string()) .await - .unwrap(); + .unwrap() + .expect("Default FS seeded by migration") + .id; - // Create a test client with pinned_space_id set. - let mut client = Client::new("test", "test-type"); - client.pinned_space_id = Some(space_id); - client_repo.create(&client).await.unwrap(); - // set_pin ensures the DB columns are actually populated (create() uses - // the legacy columns by default). - client_repo - .set_pin(&client.id, &space_id, None) - .await - .unwrap(); + let a = FeatureSet::new_custom("A", space_id.to_string()); + let b = FeatureSet::new_custom("B", space_id.to_string()); + fs_repo.create(&a).await.unwrap(); + fs_repo.create(&b).await.unwrap(); + let fs_a_id = a.id.clone(); + let fs_b_id = b.id.clone(); let session_roots = SessionRootsRegistry::new(); let resolver = FeatureSetResolverService::new( - client_repo.clone(), space_repo.clone(), binding_repo.clone(), + fs_repo.clone(), session_roots.clone(), ); Self { resolver, session_roots, - client_repo, - space_repo, binding_repo, space_id, - active_fs_id, - other_fs_id, - client_id: client.id, + default_fs_id, + fs_a_id, + fs_b_id, } } +} - async fn set_pin(&self, fs: Option) { - self.client_repo - .set_pin(&self.client_id, &self.space_id, fs.as_ref()) - .await - .unwrap(); +fn test_root() -> &'static str { + if cfg!(windows) { + "d:\\work\\proj" + } else { + "/work/proj" } } -#[tokio::test] -async fn resolve_falls_through_to_space_active_when_no_pin_and_no_roots() { - let f = Fixture::new().await; - let r = f.resolver.resolve(&f.client_id, None).await.unwrap(); - assert_eq!(r.source, ResolutionSource::SpaceActive); - assert_eq!(r.feature_set_id, Some(f.active_fs_id)); -} +// --------------------------------------------------------------------------- +// Tier 2: Default fallback +// --------------------------------------------------------------------------- #[tokio::test] -async fn resolve_pin_wins_over_space_active() { +async fn default_when_no_session_id() { let f = Fixture::new().await; - f.set_pin(Some(f.other_fs_id)).await; - - let r = f.resolver.resolve(&f.client_id, None).await.unwrap(); - assert_eq!(r.source, ResolutionSource::Pin); - assert_eq!(r.feature_set_id, Some(f.other_fs_id)); + let r = f.resolver.resolve(None).await.unwrap(); + assert_eq!(r.source, ResolutionSource::Default); + assert_eq!(r.space_id, Some(f.space_id)); + assert_eq!(r.feature_set_id, Some(f.default_fs_id)); } #[tokio::test] -async fn resolve_pin_wins_over_workspace_binding() { +async fn default_when_session_has_no_roots() { let f = Fixture::new().await; - - // Binding matches our session root, but pin should still win. - let root = if cfg!(windows) { - "d:\\work\\proj" - } else { - "/work/proj" - }; - f.binding_repo - .create(&WorkspaceBinding::new( - f.space_id, - normalize_workspace_root(root), - f.other_fs_id, - )) - .await - .unwrap(); - f.session_roots.set("sess-1", [root]); - - f.set_pin(Some(f.active_fs_id)).await; - let r = f - .resolver - .resolve(&f.client_id, Some("sess-1")) - .await - .unwrap(); - assert_eq!(r.source, ResolutionSource::Pin); - assert_eq!(r.feature_set_id, Some(f.active_fs_id)); + let r = f.resolver.resolve(Some("orphan")).await.unwrap(); + assert_eq!(r.source, ResolutionSource::Default); + assert_eq!(r.feature_set_id, Some(f.default_fs_id)); } #[tokio::test] -async fn resolve_workspace_binding_beats_space_active_when_no_pin() { +async fn default_when_no_binding_matches_reported_root() { let f = Fixture::new().await; - - let root = if cfg!(windows) { - "d:\\work\\proj" - } else { - "/work/proj" - }; - f.binding_repo - .create(&WorkspaceBinding::new( - f.space_id, - normalize_workspace_root(root), - f.other_fs_id, - )) - .await - .unwrap(); - f.session_roots.set("sess-2", [root]); - - let r = f - .resolver - .resolve(&f.client_id, Some("sess-2")) - .await - .unwrap(); - assert_eq!(r.source, ResolutionSource::WorkspaceBinding); - assert_eq!(r.feature_set_id, Some(f.other_fs_id)); + let other = if cfg!(windows) { "d:\\tmp" } else { "/tmp" }; + f.session_roots.set("sess", [other]); + let r = f.resolver.resolve(Some("sess")).await.unwrap(); + assert_eq!(r.source, ResolutionSource::Default); + assert_eq!(r.feature_set_id, Some(f.default_fs_id)); } +// --------------------------------------------------------------------------- +// Tier 1: WorkspaceBinding — concrete (space_id, feature_set_id) pointers +// --------------------------------------------------------------------------- + #[tokio::test] -async fn resolve_deny_when_no_pin_no_binding_no_space_active() { +async fn binding_routes_to_its_target_space_and_fs() { let f = Fixture::new().await; - // Clear the Space's active FS — last tier becomes Deny. - f.space_repo - .set_active_feature_set(&f.space_id, None) - .await - .unwrap(); - - let r = f.resolver.resolve(&f.client_id, None).await.unwrap(); - assert_eq!(r.source, ResolutionSource::Deny); - assert_eq!(r.feature_set_id, None); + let binding = WorkspaceBinding::new( + normalize_workspace_root(test_root()), + f.space_id, + f.fs_a_id.clone(), + ); + f.binding_repo.create(&binding).await.unwrap(); + f.session_roots.set("s", [test_root()]); + + let r = f.resolver.resolve(Some("s")).await.unwrap(); + assert_eq!(r.source, ResolutionSource::WorkspaceBinding); + assert_eq!(r.space_id, Some(f.space_id)); + assert_eq!(r.feature_set_id, Some(f.fs_a_id)); } #[tokio::test] -async fn resolve_longest_prefix_wins_across_multiple_bindings() { +async fn longest_prefix_wins_across_nested_bindings() { let f = Fixture::new().await; - - // Add two nested bindings in the same Space. - let (outer_root, inner_root) = if cfg!(windows) { + let (outer, inner) = if cfg!(windows) { ("d:\\work", "d:\\work\\proj") } else { ("/work", "/work/proj") }; - // outer -> active_fs (just any FS we have), inner -> other_fs f.binding_repo .create(&WorkspaceBinding::new( + normalize_workspace_root(outer), f.space_id, - normalize_workspace_root(outer_root), - f.active_fs_id, + f.fs_a_id.clone(), )) .await .unwrap(); f.binding_repo .create(&WorkspaceBinding::new( + normalize_workspace_root(inner), f.space_id, - normalize_workspace_root(inner_root), - f.other_fs_id, + f.fs_b_id.clone(), )) .await .unwrap(); - // Caller reports a path inside the inner binding — longest prefix wins. let deep = if cfg!(windows) { "d:\\work\\proj\\src" } else { "/work/proj/src" }; - f.session_roots.set("sess-deep", [deep]); + f.session_roots.set("s", [deep]); - let r = f - .resolver - .resolve(&f.client_id, Some("sess-deep")) - .await - .unwrap(); + let r = f.resolver.resolve(Some("s")).await.unwrap(); assert_eq!(r.source, ResolutionSource::WorkspaceBinding); - assert_eq!(r.feature_set_id, Some(f.other_fs_id)); -} - -#[tokio::test] -async fn resolve_falls_through_when_roots_dont_match_any_binding() { - let f = Fixture::new().await; - - let bound = if cfg!(windows) { - "d:\\android" - } else { - "/android" - }; - let reported = if cfg!(windows) { - "d:\\cloudflare" - } else { - "/cloudflare" - }; - f.binding_repo - .create(&WorkspaceBinding::new( - f.space_id, - normalize_workspace_root(bound), - f.other_fs_id, - )) - .await - .unwrap(); - f.session_roots.set("sess-3", [reported]); - - let r = f - .resolver - .resolve(&f.client_id, Some("sess-3")) - .await - .unwrap(); - assert_eq!(r.source, ResolutionSource::SpaceActive); - assert_eq!(r.feature_set_id, Some(f.active_fs_id)); -} - -#[tokio::test] -async fn resolve_returns_deny_for_unknown_client() { - let f = Fixture::new().await; - let unknown = Uuid::new_v4(); - let r = f.resolver.resolve(&unknown, None).await.unwrap(); - assert_eq!(r.source, ResolutionSource::Deny); - assert!(r.feature_set_id.is_none()); + assert_eq!(r.feature_set_id, Some(f.fs_b_id)); } diff --git a/tests/rust/tests/integration/mcp_flows.rs b/tests/rust/tests/integration/mcp_flows.rs index 1c61350..77c1cc0 100644 --- a/tests/rust/tests/integration/mcp_flows.rs +++ b/tests/rust/tests/integration/mcp_flows.rs @@ -2,7 +2,11 @@ //! //! Tests the complete MCP request handling flow using FeatureService: //! - tools/list, tools/call with authorization -//! - resources/list, resources/read with authorization +//! - resources/list, resources/read with authorization + +// clippy 1.93+ prefers `std::slice::from_ref(&id)` over `&[id.clone()]`. +// Kept as-is for test readability. +#![allow(clippy::cloned_ref_to_slice_refs)] //! - prompts/list, prompts/get with authorization //! - Space isolation @@ -81,6 +85,53 @@ impl TestContext { self.feature_set_repo.create(&fs).await.unwrap(); id } + + /// Build a FeatureSet that grants every feature currently known to the + /// mock feature repository. Replaces the legacy `FeatureSet::new_all` + /// escape hatch — with the new model, "grant all" is expressed as a + /// Custom set whose members enumerate every ServerFeature id. + async fn new_grant_everything_set(&self) -> FeatureSet { + let mut fs = FeatureSet::new_custom("All (test fixture)", &self.space_id); + for feature in self + .feature_repo + .list_for_space(&self.space_id) + .await + .unwrap() + { + fs.members.push(FeatureSetMember { + id: Uuid::new_v4().to_string(), + feature_set_id: fs.id.clone(), + member_type: MemberType::Feature, + member_id: feature.id.to_string(), + mode: MemberMode::Include, + }); + } + fs + } + + /// Build a FeatureSet whose members are every feature belonging to a + /// specific server — replaces `FeatureSet::new_server_all`. + async fn new_grant_server_all_set(&self, server_id: &str) -> FeatureSet { + let mut fs = FeatureSet::new_custom( + format!("{} - All (test fixture)", server_id), + &self.space_id, + ); + for feature in self + .feature_repo + .list_for_server(&self.space_id, server_id) + .await + .unwrap() + { + fs.members.push(FeatureSetMember { + id: Uuid::new_v4().to_string(), + feature_set_id: fs.id.clone(), + member_type: MemberType::Feature, + member_id: feature.id.to_string(), + mode: MemberMode::Include, + }); + } + fs + } } // ============================================================================ @@ -99,7 +150,7 @@ async fn test_list_tools_with_all_grant() { .await; // Create "All" grant - let all_fs = FeatureSet::new_all(&ctx.space_id); + let all_fs = ctx.new_grant_everything_set().await; let all_fs_id = ctx.add_feature_set(all_fs).await; // Simulate tools/list with grant @@ -205,7 +256,7 @@ async fn test_list_resources_with_grant() { ctx.add_feature("files", "file:///docs/config.json", FeatureType::Resource) .await; - let all_fs = FeatureSet::new_all(&ctx.space_id); + let all_fs = ctx.new_grant_everything_set().await; let all_fs_id = ctx.add_feature_set(all_fs).await; let resources = ctx @@ -248,7 +299,7 @@ async fn test_resource_custom_uri_scheme() { ) .await; - let all_fs = FeatureSet::new_all(&ctx.space_id); + let all_fs = ctx.new_grant_everything_set().await; let all_fs_id = ctx.add_feature_set(all_fs).await; let resources = ctx @@ -278,7 +329,7 @@ async fn test_list_prompts_with_grant() { ctx.add_feature("prompts-server", "explain_code", FeatureType::Prompt) .await; - let all_fs = FeatureSet::new_all(&ctx.space_id); + let all_fs = ctx.new_grant_everything_set().await; let all_fs_id = ctx.add_feature_set(all_fs).await; let prompts = ctx @@ -330,7 +381,7 @@ async fn test_server_provides_multiple_feature_types() { ctx.add_feature("full-server", "my://resource", FeatureType::Resource) .await; - let all_fs = FeatureSet::new_all(&ctx.space_id); + let all_fs = ctx.new_grant_everything_set().await; let all_fs_id = ctx.add_feature_set(all_fs).await; // List all @@ -385,7 +436,7 @@ async fn test_aggregate_tools_from_multiple_servers() { .await; // Grant access to all - let all_fs = FeatureSet::new_all(&ctx.space_id); + let all_fs = ctx.new_grant_everything_set().await; let all_fs_id = ctx.add_feature_set(all_fs).await; let tools = ctx @@ -415,7 +466,7 @@ async fn test_partial_server_grant() { .await; // Create ServerAll grant for server-a only - let server_all_a = FeatureSet::new_server_all(&ctx.space_id, "server-a", "Server A"); + let server_all_a = ctx.new_grant_server_all_set("server-a").await; let server_all_a_id = ctx.add_feature_set(server_all_a).await; let tools = ctx @@ -452,8 +503,15 @@ async fn test_features_dont_leak_between_spaces() { feature_repo.upsert(&work_tool).await.unwrap(); feature_repo.upsert(&personal_tool).await.unwrap(); - // Create All grant for work space - let work_all = FeatureSet::new_all(&space_work); + // Create "grant-everything-in-work" FS manually (no new_all helper any more). + let mut work_all = FeatureSet::new_custom("All (test fixture)", &space_work); + work_all.members.push(FeatureSetMember { + id: Uuid::new_v4().to_string(), + feature_set_id: work_all.id.clone(), + member_type: MemberType::Feature, + member_id: work_tool.id.to_string(), + mode: MemberMode::Include, + }); let work_all_id = work_all.id.clone(); feature_set_repo.create(&work_all).await.unwrap(); @@ -545,7 +603,7 @@ async fn test_unavailable_features_filtered_out() { unavailable.is_available = false; ctx.feature_repo.upsert(&unavailable).await.unwrap(); - let all_fs = FeatureSet::new_all(&ctx.space_id); + let all_fs = ctx.new_grant_everything_set().await; let all_fs_id = ctx.add_feature_set(all_fs).await; let tools = ctx @@ -566,7 +624,7 @@ async fn test_server_disconnect_marks_features_unavailable() { ctx.add_feature("server", "tool_1", FeatureType::Tool).await; ctx.add_feature("server", "tool_2", FeatureType::Tool).await; - let all_fs = FeatureSet::new_all(&ctx.space_id); + let all_fs = ctx.new_grant_everything_set().await; let all_fs_id = ctx.add_feature_set(all_fs).await; // Initially available diff --git a/tests/rust/tests/integration/meta_tools.rs b/tests/rust/tests/integration/meta_tools.rs index e5d4526..bb8d7f9 100644 --- a/tests/rust/tests/integration/meta_tools.rs +++ b/tests/rust/tests/integration/meta_tools.rs @@ -31,17 +31,15 @@ use uuid::Uuid; struct Fixture { registry: Arc, broker: Arc, + #[allow(dead_code)] client_repo: Arc, - space_repo: Arc, feature_set_repo: Arc, binding_repo: Arc, - server_feature_repo: Arc, session_roots: Arc, space_id: Uuid, client_id: Uuid, session_id: String, fs_android_id: Uuid, - fs_full_id: Uuid, } impl Fixture { @@ -82,29 +80,22 @@ impl Fixture { server_feature_repo.upsert(&feature1).await.unwrap(); server_feature_repo.upsert(&feature2).await.unwrap(); - // Start the Space with `fs_full` as its active FS — the baseline the - // caller resolves to before any meta-tool action. - space_repo - .set_active_feature_set(&space_id, Some(&fs_full_id)) - .await - .unwrap(); + // The space's auto-seeded Default FS is the resolver's baseline + // when no binding matches — no "set active FS" step needed. + let _ = fs_full_id; - // Create test client with `pinned_space_id` set. + // Create test client — routing is per-session-root now, not per-client. let client = Client::new("TestClient", "test-type"); let client_id = client.id; client_repo.create(&client).await.unwrap(); - client_repo - .set_pin(&client_id, &space_id, None) - .await - .unwrap(); let session_roots = SessionRootsRegistry::new(); let session_id = "sess-meta".to_string(); let resolver = Arc::new(FeatureSetResolverService::new( - client_repo.clone(), space_repo.clone(), binding_repo.clone(), + feature_set_repo.clone(), session_roots.clone(), )); @@ -136,16 +127,13 @@ impl Fixture { registry, broker, client_repo, - space_repo, feature_set_repo, binding_repo, - server_feature_repo, session_roots, space_id, client_id, session_id, fs_android_id, - fs_full_id, } } @@ -240,7 +228,7 @@ async fn list_all_tools_returns_unfiltered_across_servers() { } #[tokio::test(flavor = "multi_thread")] -async fn list_feature_sets_marks_active_fs() { +async fn list_feature_sets_returns_space_contents() { let f = Fixture::new().await; let result = f .registry @@ -254,23 +242,15 @@ async fn list_feature_sets_marks_active_fs() { .unwrap(); let body = Fixture::result_json(&result); let sets = body.get("feature_sets").unwrap().as_array().unwrap(); - let active: Vec<_> = sets - .iter() - .filter(|fs| { - fs.get("is_active") - .and_then(Value::as_bool) - .unwrap_or(false) - }) - .collect(); - assert_eq!(active.len(), 1, "exactly one Active FS expected"); - assert_eq!( - active[0].get("id").unwrap().as_str().unwrap(), - f.fs_full_id.to_string() - ); + // Seed created 2 custom FSes + the auto-seeded Default. + assert_eq!(sets.len(), 3, "Default + 2 custom expected"); } #[tokio::test(flavor = "multi_thread")] -async fn describe_resolution_reports_space_active_baseline() { +async fn describe_resolution_reports_default_baseline() { + // With no bindings and no reported roots, the resolver falls through + // to the Default tier and returns the space's auto-seeded + // `fs_default_` FS. let f = Fixture::new().await; let result = f .registry @@ -283,13 +263,11 @@ async fn describe_resolution_reports_space_active_baseline() { .await .unwrap(); let body = Fixture::result_json(&result); - assert_eq!( - body.get("source").unwrap().as_str().unwrap(), - "space_active" - ); - assert_eq!( - body.get("feature_set_id").unwrap().as_str().unwrap(), - f.fs_full_id.to_string() + assert_eq!(body.get("source").unwrap().as_str().unwrap(), "default"); + let fs_id = body.get("feature_set_id").unwrap().as_str().unwrap(); + assert!( + fs_id.starts_with("fs_default_"), + "default tier should surface the Default FS, got {fs_id}" ); } @@ -326,9 +304,15 @@ async fn describe_workspace_reports_reported_roots() { #[tokio::test(flavor = "multi_thread")] async fn write_without_publisher_returns_approval_required() { let f = Fixture::new().await; + let input = if cfg!(windows) { + "D:\\Projects\\Approval\\" + } else { + "/proj/approval" + }; + f.session_roots.set(&f.session_id, [input]); let result = f .call_tool_as_handler_would( - "mcpmux_pin_this_session", + "mcpmux_bind_current_workspace", json!({ "feature_set_id": f.fs_android_id.to_string() }), ) .await; @@ -341,34 +325,21 @@ async fn write_without_publisher_returns_approval_required() { } #[tokio::test(flavor = "multi_thread")] -async fn pin_this_session_writes_state_on_allow() { - let f = Fixture::new().await; - f.attach_auto_publisher(ApprovalDecision::AllowOnce); - - let result = f - .registry - .call( - "mcpmux_pin_this_session", - &f.client_id, - Some(&f.session_id), - json!({ "feature_set_id": f.fs_android_id.to_string() }), - ) - .await - .unwrap(); - assert!(!Fixture::is_error(&result)); - - let client = f.client_repo.get(&f.client_id).await.unwrap().unwrap(); - assert_eq!(client.pinned_feature_set_id, Some(f.fs_android_id)); -} - -#[tokio::test(flavor = "multi_thread")] -async fn pin_this_session_rejected_on_deny_leaves_state_unchanged() { +async fn write_rejected_on_deny_leaves_state_unchanged() { let f = Fixture::new().await; f.attach_auto_publisher(ApprovalDecision::Deny); + let before_bindings = f.binding_repo.list().await.unwrap().len(); + + let input = if cfg!(windows) { + "D:\\Projects\\Denied\\" + } else { + "/proj/denied" + }; + f.session_roots.set(&f.session_id, [input]); let result = f .call_tool_as_handler_would( - "mcpmux_pin_this_session", + "mcpmux_bind_current_workspace", json!({ "feature_set_id": f.fs_android_id.to_string() }), ) .await; @@ -379,47 +350,8 @@ async fn pin_this_session_rejected_on_deny_leaves_state_unchanged() { "approval_denied" ); - let client = f.client_repo.get(&f.client_id).await.unwrap().unwrap(); - assert_eq!(client.pinned_feature_set_id, None); -} - -#[tokio::test(flavor = "multi_thread")] -async fn always_allow_bypasses_subsequent_dialogs() { - let f = Fixture::new().await; - f.attach_auto_publisher(ApprovalDecision::AlwaysForThisSessionAndClient); - - // First call pops the dialog and banks the always-allow. - let r1 = f - .registry - .call( - "mcpmux_pin_this_session", - &f.client_id, - Some(&f.session_id), - json!({ "feature_set_id": f.fs_android_id.to_string() }), - ) - .await - .unwrap(); - assert!(!Fixture::is_error(&r1)); - - // Detach publisher — any further prompt would fail. Second call must - // short-circuit via always-allow. - let noop_publisher: ApprovalPublisher = Arc::new(move |_req| async move { true }.boxed()); - f.broker.set_publisher(noop_publisher).await; - - let r2 = f - .registry - .call( - "mcpmux_pin_this_session", - &f.client_id, - Some(&f.session_id), - json!({ "feature_set_id": f.fs_full_id.to_string() }), - ) - .await - .unwrap(); - assert!(!Fixture::is_error(&r2)); - - let client = f.client_repo.get(&f.client_id).await.unwrap().unwrap(); - assert_eq!(client.pinned_feature_set_id, Some(f.fs_full_id)); + let after_bindings = f.binding_repo.list().await.unwrap().len(); + assert_eq!(after_bindings, before_bindings); } #[tokio::test(flavor = "multi_thread")] @@ -504,35 +436,23 @@ async fn bind_current_workspace_creates_binding_with_normalized_root() { // Drive-letter lowercased, trailing separator trimmed. assert_eq!(stored, &normalize_workspace_root(input)); assert!(!stored.ends_with('/') && !stored.ends_with('\\')); -} - -#[tokio::test(flavor = "multi_thread")] -async fn set_space_active_updates_space_fallback() { - let f = Fixture::new().await; - f.attach_auto_publisher(ApprovalDecision::AllowOnce); - - let result = f - .registry - .call( - "mcpmux_set_space_active", - &f.client_id, - Some(&f.session_id), - json!({ "feature_set_id": f.fs_android_id.to_string() }), - ) - .await - .unwrap(); - assert!(!Fixture::is_error(&result)); - - let space = f.space_repo.get(&f.space_id).await.unwrap().unwrap(); - assert_eq!(space.active_feature_set_id, Some(f.fs_android_id)); + // Binding points at the concrete FS we passed in. + assert_eq!(bindings[0].space_id, f.space_id); + assert_eq!(bindings[0].feature_set_id, f.fs_android_id.to_string()); } #[tokio::test(flavor = "multi_thread")] async fn invalid_feature_set_argument_rejected() { let f = Fixture::new().await; + let input = if cfg!(windows) { + "D:\\Projects\\Invalid\\" + } else { + "/proj/invalid" + }; + f.session_roots.set(&f.session_id, [input]); let result = f .call_tool_as_handler_would( - "mcpmux_pin_this_session", + "mcpmux_bind_current_workspace", json!({ "feature_set_id": "not-a-uuid" }), ) .await; @@ -558,20 +478,18 @@ async fn registry_advertises_every_default_tool_with_annotations() { "mcpmux_list_feature_sets", "mcpmux_describe_resolution", "mcpmux_describe_workspace", - "mcpmux_pin_this_session", "mcpmux_create_feature_set", "mcpmux_bind_current_workspace", - "mcpmux_set_space_active", ] { assert!(names.iter().any(|n| n == expected), "missing {expected}"); } // Writes carry the destructive_hint annotation. - let pin = tools + let bind = tools .iter() - .find(|t| t.name == "mcpmux_pin_this_session") + .find(|t| t.name == "mcpmux_bind_current_workspace") .unwrap(); assert_eq!( - pin.annotations.as_ref().and_then(|a| a.destructive_hint), + bind.annotations.as_ref().and_then(|a| a.destructive_hint), Some(true) ); } @@ -601,19 +519,15 @@ async fn bare_registry( let server_feature_repo: Arc = Arc::new(SqliteServerFeatureRepository::new(db.clone())); - let space = space_repo.get_default().await.unwrap().unwrap(); + let _space = space_repo.get_default().await.unwrap().unwrap(); let client = Client::new("c", "t"); let client_id = client.id; client_repo.create(&client).await.unwrap(); - client_repo - .set_pin(&client_id, &space.id, None) - .await - .unwrap(); let resolver = Arc::new(FeatureSetResolverService::new( - client_repo.clone(), space_repo.clone(), binding_repo.clone(), + feature_set_repo.clone(), SessionRootsRegistry::new(), )); let prefix_cache = Arc::new(PrefixCacheService::new()); @@ -678,7 +592,7 @@ async fn denied_write_emits_meta_tool_invoked_with_decision_deny() { // registry's central audit-logger records as `approval_required`. let _ = registry .call( - "mcpmux_pin_this_session", + "mcpmux_bind_current_workspace", &client_id, Some("s"), json!({ "feature_set_id": Uuid::new_v4().to_string() }), @@ -694,8 +608,11 @@ async fn denied_write_emits_meta_tool_invoked_with_decision_deny() { tool_name, .. } => { - assert_eq!(tool_name, "mcpmux_pin_this_session"); - assert_eq!(decision, "approval_required"); + assert_eq!(tool_name, "mcpmux_bind_current_workspace"); + // bind_current_workspace bails on "invalid_args" (missing reported + // roots) before it reaches the approval broker — the audit + // logger records the bail-out reason, not approval_required. + assert_eq!(decision, "invalid_args"); } other => panic!("unexpected event: {other:?}"), } @@ -724,9 +641,9 @@ async fn master_switch_toggles_registry_visibility() { let server_feature_repo: Arc = Arc::new(SqliteServerFeatureRepository::new(db.clone())); let resolver = Arc::new(FeatureSetResolverService::new( - client_repo.clone(), space_repo.clone(), binding_repo.clone(), + feature_set_repo.clone(), SessionRootsRegistry::new(), )); let prefix_cache = Arc::new(PrefixCacheService::new()); diff --git a/tests/rust/tests/integration/mod.rs b/tests/rust/tests/integration/mod.rs index 4e49884..c1f1acb 100644 --- a/tests/rust/tests/integration/mod.rs +++ b/tests/rust/tests/integration/mod.rs @@ -8,8 +8,8 @@ //! NOTE: Authorization tests that require InboundClientRepository //! are in the database tests since they need the real SQLite implementation. -mod feature_grants; mod feature_routing; mod feature_set_resolver; mod mcp_flows; mod meta_tools; +mod workspace_binding_events; diff --git a/tests/rust/tests/integration/workspace_binding_events.rs b/tests/rust/tests/integration/workspace_binding_events.rs new file mode 100644 index 0000000..2c57c73 --- /dev/null +++ b/tests/rust/tests/integration/workspace_binding_events.rs @@ -0,0 +1,207 @@ +//! Integration tests for the workspace-binding domain event flow. +//! +//! These tests exercise the parts the gateway relies on at the domain layer +//! — the full `on_initialized` → `list_roots` → resolver → event emission +//! path in `handler.rs` needs a live rmcp peer to drive and is covered by the +//! desktop E2E suite. What we can reach here is: +//! +//! 1. `WorkspaceBindingChanged` + `WorkspaceNeedsBinding` round-trip through +//! JSON with the shape the Tauri bridge and the frontend consumers expect. +//! 2. The resolver's decision table: roots + no binding → `source = Default` +//! (the trigger the gateway uses to decide whether to emit the event). +//! 3. Creating / updating a binding flips the next resolution from Default to +//! WorkspaceBinding — the behaviour that justifies firing list_changed. + +use std::sync::Arc; + +use mcpmux_core::{ + normalize_workspace_root, DomainEvent, FeatureSet, FeatureSetRepository, SpaceRepository, + WorkspaceBinding, WorkspaceBindingRepository, +}; +use mcpmux_gateway::services::{FeatureSetResolverService, ResolutionSource, SessionRootsRegistry}; +use mcpmux_storage::{ + Database, SqliteFeatureSetRepository, SqliteSpaceRepository, SqliteWorkspaceBindingRepository, +}; +use tokio::sync::Mutex; +use uuid::Uuid; + +struct Ctx { + resolver: FeatureSetResolverService, + session_roots: Arc, + binding_repo: Arc, + space_id: Uuid, + fs_custom_id: String, +} + +impl Ctx { + async fn new() -> Self { + let db = Arc::new(Mutex::new(Database::open_in_memory().unwrap())); + let space_repo: Arc = Arc::new(SqliteSpaceRepository::new(db.clone())); + let fs_repo: Arc = + Arc::new(SqliteFeatureSetRepository::new(db.clone())); + let binding_repo: Arc = + Arc::new(SqliteWorkspaceBindingRepository::new(db.clone())); + + let default_space = space_repo.get_default().await.unwrap().unwrap(); + let space_id = default_space.id; + + let custom = FeatureSet::new_custom("Custom", space_id.to_string()); + fs_repo.create(&custom).await.unwrap(); + + let session_roots = SessionRootsRegistry::new(); + let resolver = FeatureSetResolverService::new( + space_repo.clone(), + binding_repo.clone(), + fs_repo.clone(), + session_roots.clone(), + ); + + Self { + resolver, + session_roots, + binding_repo, + space_id, + fs_custom_id: custom.id, + } + } +} + +/// Session with roots, no binding → resolver returns `source = Default`. +/// This is the exact condition `handler.rs::log_and_notify_resolution` +/// turns into a `WorkspaceNeedsBinding` emission. +#[tokio::test(flavor = "multi_thread")] +async fn session_with_unbound_root_resolves_via_default() { + let ctx = Ctx::new().await; + ctx.session_roots.set("sess-1", ["/proj/unbound"]); + + let resolved = ctx.resolver.resolve(Some("sess-1")).await.unwrap(); + assert_eq!(resolved.source, ResolutionSource::Default); + assert_eq!(resolved.space_id, Some(ctx.space_id)); + // Default always hands back the Space's "All" FS so the client gets a + // working toolset even before the user binds the folder. + assert!(resolved.feature_set_id.is_some()); +} + +/// After creating a binding for the root the next resolve flips to +/// `source = WorkspaceBinding`. In production that's what triggers the +/// `WorkspaceBindingChanged` → `list_changed` broadcast. +#[tokio::test(flavor = "multi_thread")] +async fn creating_binding_flips_next_resolution_source() { + let ctx = Ctx::new().await; + + // Normalize both sides so the longest-prefix lookup matches — the + // resolver compares already-normalized strings from both stores. + let raw = if cfg!(windows) { + "d:\\proj\\bind-me" + } else { + "/proj/bind-me" + }; + let root = normalize_workspace_root(raw); + ctx.session_roots.set("sess-1", [raw]); + + let before = ctx.resolver.resolve(Some("sess-1")).await.unwrap(); + assert_eq!(before.source, ResolutionSource::Default); + + let binding = WorkspaceBinding::new(root, ctx.space_id, ctx.fs_custom_id.clone()); + ctx.binding_repo.create(&binding).await.unwrap(); + + let after = ctx.resolver.resolve(Some("sess-1")).await.unwrap(); + assert_eq!(after.source, ResolutionSource::WorkspaceBinding); + assert_eq!(after.feature_set_id, Some(ctx.fs_custom_id.clone())); +} + +/// Rootless session never resolves via a binding — stays at Default and +/// should never produce a `WorkspaceNeedsBinding` event. This test pins the +/// rootless-silence contract; if it ever fails, the notifier would start +/// prompting users with no folder context. +#[tokio::test(flavor = "multi_thread")] +async fn rootless_session_stays_default_no_prompt() { + let ctx = Ctx::new().await; + // Deliberately no call to session_roots.set — simulates a rootless + // (CLI-ish) client. + let resolved = ctx.resolver.resolve(Some("rootless")).await.unwrap(); + assert_eq!(resolved.source, ResolutionSource::Default); +} + +/// Binding → different Space should actually route the session to that +/// Space, regardless of which Space the caller was "in" before. Pins the +/// contract that bindings carry concrete pointers (not "follow active"). +#[tokio::test(flavor = "multi_thread")] +async fn binding_to_non_default_space_reroutes_session() { + let db = Arc::new(Mutex::new(Database::open_in_memory().unwrap())); + let space_repo: Arc = Arc::new(SqliteSpaceRepository::new(db.clone())); + let fs_repo: Arc = + Arc::new(SqliteFeatureSetRepository::new(db.clone())); + let binding_repo: Arc = + Arc::new(SqliteWorkspaceBindingRepository::new(db.clone())); + + let default_space = space_repo.get_default().await.unwrap().unwrap(); + + // A second Space, with its own Custom FS. The binding below will route + // the reported root here even though the default Space is still the + // "default". + let other = mcpmux_core::Space::new("Other"); + let other_id = other.id; + space_repo.create(&other).await.unwrap(); + let other_fs = FeatureSet::new_custom("Other Custom", other_id.to_string()); + fs_repo.create(&other_fs).await.unwrap(); + + let session_roots = SessionRootsRegistry::new(); + let resolver = FeatureSetResolverService::new( + space_repo.clone(), + binding_repo.clone(), + fs_repo.clone(), + session_roots.clone(), + ); + + let raw = if cfg!(windows) { + "d:\\other\\work" + } else { + "/other/work" + }; + let root = normalize_workspace_root(raw); + session_roots.set("sess-X", [raw]); + + // Before binding: Default tier — lands in the *default* space with its + // Default FS. + let before = resolver.resolve(Some("sess-X")).await.unwrap(); + assert_eq!(before.source, ResolutionSource::Default); + assert_eq!(before.space_id, Some(default_space.id)); + + // Create a binding targeting `other` space's Custom FS. + let b = WorkspaceBinding::new(root, other_id, other_fs.id.clone()); + binding_repo.create(&b).await.unwrap(); + + let after = resolver.resolve(Some("sess-X")).await.unwrap(); + assert_eq!(after.source, ResolutionSource::WorkspaceBinding); + assert_eq!(after.space_id, Some(other_id)); + assert_eq!(after.feature_set_id, Some(other_fs.id)); +} + +/// Minimal "is the Tauri bridge payload the shape the webview expects?" +/// sanity check. If the serde tag or field names change, both the +/// `workspace-needs-binding` Tauri channel consumer and the +/// `WorkspaceBindingSheet` component's TypeScript payload type break. +#[test] +fn event_json_payloads_are_stable() { + let changed = DomainEvent::WorkspaceBindingChanged { + space_id: Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(), + workspace_root: "/abs/path".to_string(), + }; + let v: serde_json::Value = serde_json::to_value(&changed).unwrap(); + assert_eq!(v["type"], "workspace_binding_changed"); + assert_eq!(v["workspace_root"], "/abs/path"); + assert_eq!(v["space_id"], "00000000-0000-0000-0000-000000000001"); + + let needs = DomainEvent::WorkspaceNeedsBinding { + client_id: "vscode".to_string(), + session_id: "s-9".to_string(), + space_id: Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(), + workspace_root: "/abs/path".to_string(), + }; + let v: serde_json::Value = serde_json::to_value(&needs).unwrap(); + assert_eq!(v["type"], "workspace_needs_binding"); + assert_eq!(v["client_id"], "vscode"); + assert_eq!(v["session_id"], "s-9"); + assert_eq!(v["workspace_root"], "/abs/path"); +} diff --git a/tests/rust/tests/oauth/flow.rs b/tests/rust/tests/oauth/flow.rs index de7f3dc..d9c0a17 100644 --- a/tests/rust/tests/oauth/flow.rs +++ b/tests/rust/tests/oauth/flow.rs @@ -1,5 +1,9 @@ //! OAuth Flow integration tests with mock HTTP server +// Pre-existing test code uses `&mock_server.uri()` where clippy 1.93+ prefers +// passing the String directly. Silenced at file scope to keep the diff small. +#![allow(clippy::needless_borrows_for_generic_args)] + use mcpmux_gateway::oauth::{ AuthorizationCallback, OAuthConfig, OAuthFlow, OAuthManager, OAuthMetadata, }; diff --git a/tests/rust/tests/streamable_http/gateway_notifications.rs b/tests/rust/tests/streamable_http/gateway_notifications.rs index 6be4bb8..9fed9b1 100644 --- a/tests/rust/tests/streamable_http/gateway_notifications.rs +++ b/tests/rust/tests/streamable_http/gateway_notifications.rs @@ -109,7 +109,6 @@ impl TestGateway { description: None, is_default: true, sort_order: 0, - active_feature_set_id: None, created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), }; @@ -141,8 +140,6 @@ impl TestGateway { metadata_url: None, metadata_cached_at: None, metadata_cache_ttl: None, - connection_mode: "follow_active".to_string(), - locked_space_id: None, last_seen: None, created_at: now.clone(), updated_at: now, @@ -480,12 +477,14 @@ async fn test_gateway_forwards_server_disconnect_to_client() { // ============================================================================ #[tokio::test(flavor = "multi_thread")] -async fn test_gateway_forwards_grant_change_to_client() { +async fn test_gateway_forwards_feature_set_member_change_to_client() { + // Replaces the old "grant change" test. Per-client grants are gone, so + // the corresponding signal now is `FeatureSetMembersChanged` — emitted + // when a user edits which features a Space's FS exposes. let space_id = Uuid::new_v4(); let client_id = Uuid::new_v4().to_string(); let gw = TestGateway::start(&client_id, space_id).await; - // Seed a feature so hash has content let tool = tests::features::test_tool(&space_id.to_string(), "srv", "tool1"); gw.feature_repo.upsert(&tool).await.unwrap(); @@ -495,15 +494,14 @@ async fn test_gateway_forwards_grant_change_to_client() { tokio::time::sleep(std::time::Duration::from_millis(500)).await; - // Add another feature so hash changes let new_tool = tests::features::test_tool(&space_id.to_string(), "srv", "tool2"); gw.feature_repo.upsert(&new_tool).await.unwrap(); - // Emit GrantIssued event - gw.emit(DomainEvent::GrantIssued { - client_id: client_id.clone(), + gw.emit(DomainEvent::FeatureSetMembersChanged { space_id, feature_set_id: "fs-test".to_string(), + added_count: 1, + removed_count: 0, }); let result = @@ -511,7 +509,51 @@ async fn test_gateway_forwards_grant_change_to_client() { assert!( result.is_ok(), - "Client should receive list_changed when grant is issued" + "Client should receive list_changed when a FS's members change" + ); + + client.cancel().await.ok(); + gw.shutdown(); +} + +// ============================================================================ +// B4b: Gateway forwards WorkspaceBindingChanged to every peer in the space +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_gateway_forwards_workspace_binding_change_to_client() { + // User just created / updated / deleted a binding. Every connected MCP + // client that resolves into this Space must re-fetch its tool list, + // since the binding could have flipped the root → (space, FS) mapping. + let space_id = Uuid::new_v4(); + let client_id = Uuid::new_v4().to_string(); + let gw = TestGateway::start(&client_id, space_id).await; + + // Seed then add another tool so the content hash changes and the + // notifier actually forwards the event (it dedupes on identical hash). + let tool = tests::features::test_tool(&space_id.to_string(), "srv", "tool1"); + gw.feature_repo.upsert(&tool).await.unwrap(); + + let client_handler = GatewayTestClient::new(); + let tools_changed = client_handler.tools_changed.clone(); + let client = connect_client(&gw.url, client_handler).await; + + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + + let new_tool = tests::features::test_tool(&space_id.to_string(), "srv", "tool2"); + gw.feature_repo.upsert(&new_tool).await.unwrap(); + + gw.emit(DomainEvent::WorkspaceBindingChanged { + space_id, + workspace_root: "/abs/proj".to_string(), + }); + + let result = + tokio::time::timeout(std::time::Duration::from_secs(5), tools_changed.notified()).await; + + assert!( + result.is_ok(), + "Client should receive list_changed when a WorkspaceBinding is changed", ); client.cancel().await.ok(); diff --git a/tests/ts/components/App.test.tsx b/tests/ts/components/App.test.tsx index e32a2e0..d653ce5 100644 --- a/tests/ts/components/App.test.tsx +++ b/tests/ts/components/App.test.tsx @@ -40,6 +40,10 @@ vi.mock('@/features/spaces', () => ({ vi.mock('@/features/settings', () => ({ SettingsPage: () =>
    , })); +vi.mock('@/features/workspaces', () => ({ + WorkspacesPage: () =>
    , + WorkspaceBindingSheet: () => null, +})); // Mock non-essential components vi.mock('@/components/OAuthConsentModal', () => ({ @@ -89,6 +93,21 @@ vi.mock('@/lib/api/gateway', () => ({ startGateway: vi.fn().mockResolvedValue('http://localhost:45818'), stopGateway: vi.fn().mockResolvedValue(undefined), restartGateway: vi.fn().mockResolvedValue(undefined), + // AutoStartConflictResolver polls these on mount; default to a + // "no conflict" state so the resolver no-ops in tests. + takePendingPortConflict: vi.fn().mockResolvedValue(null), + probeGatewayStart: vi.fn().mockResolvedValue({ + preferred_port: 45818, + preferred_available: true, + source: 'Default', + }), + getGatewayPortSettings: vi.fn().mockResolvedValue({ + configured_port: null, + default_port: 45818, + active_port: null, + }), + openUrl: vi.fn().mockResolvedValue(undefined), + parsePortInUseError: vi.fn().mockReturnValue(null), })); vi.mock('@/lib/api/clients', () => ({ listClients: vi.fn().mockResolvedValue([]), @@ -149,34 +168,30 @@ describe('App – dynamic version display', () => { render(); await waitFor(() => { - expect(screen.getByTestId('sidebar')).toHaveTextContent('McpMux v1.2.3'); + expect(screen.getByTestId('statusbar-version')).toHaveTextContent('v1.2.3'); }); }); - it('should display "McpMux" without version suffix while loading', () => { + it('should hide the version span while loading', () => { // invoke never resolves mockInvoke.mockImplementation(() => new Promise(() => {})); render(); - const sidebar = screen.getByTestId('sidebar'); - expect(sidebar).toHaveTextContent('McpMux'); - expect(sidebar).not.toHaveTextContent('McpMux v'); + // The span only renders once `appVersion` has a value. + expect(screen.queryByTestId('statusbar-version')).toBeNull(); }); - it('should display "McpMux" without crashing when version fetch fails', async () => { + it('should not crash and should omit the version when fetch fails', async () => { setupInvoke({ get_version: new Error('command failed') }); render(); - // Wait for the rejected promise to be handled + // App still renders (sidebar is present) even if version lookup errored. await waitFor(() => { - const sidebar = screen.getByTestId('sidebar'); - expect(sidebar).toHaveTextContent('McpMux'); + expect(screen.getByTestId('sidebar')).toBeInTheDocument(); }); - - // Should not show a version number - expect(screen.getByTestId('sidebar')).not.toHaveTextContent('McpMux v'); + expect(screen.queryByTestId('statusbar-version')).toBeNull(); }); }); @@ -186,74 +201,87 @@ describe('App – dynamic gateway URL display', () => { setupInvoke({ get_version: '0.1.2' }); }); - it('should show "Not running" as default gateway state', async () => { + it('should show "Gateway stopped" as default gateway state', async () => { setupGateway({ running: false, url: null }); render(); await waitFor(() => { - expect(screen.getByTestId('sidebar')).toHaveTextContent('Gateway: Not running'); + expect(screen.getByTestId('statusbar-gateway')).toHaveTextContent( + 'Gateway stopped' + ); }); }); - it('should show "Not running" when gateway is running but url is null', async () => { + it('should show "Gateway stopped" when gateway is running but url is null', async () => { setupGateway({ running: true, url: null }); render(); await waitFor(() => { - expect(screen.getByTestId('sidebar')).toHaveTextContent('Gateway: Not running'); + expect(screen.getByTestId('statusbar-gateway')).toHaveTextContent( + 'Gateway stopped' + ); }); }); - it('should update URL when gateway-started event fires', async () => { + it('should flip to running state when gateway-started event fires', async () => { setupGateway({ running: false, url: null }); render(); await waitFor(() => { - expect(screen.getByTestId('sidebar')).toHaveTextContent('Gateway: Not running'); + expect(screen.getByTestId('statusbar-gateway')).toHaveTextContent( + 'Gateway stopped' + ); }); - // Simulate gateway started event + // Simulate gateway started event with a port. act(() => { - fireGatewayEvent({ action: 'started', url: 'http://localhost:9999' }); + fireGatewayEvent({ action: 'started', url: 'http://localhost:9999', port: 9999 }); }); await waitFor(() => { - expect(screen.getByTestId('sidebar')).toHaveTextContent( - 'Gateway: http://localhost:9999' + expect(screen.getByTestId('statusbar-gateway')).toHaveTextContent('Gateway'); + expect(screen.getByTestId('statusbar-gateway')).not.toHaveTextContent( + 'stopped' ); }); }); - it('should show "Not running" when gateway-stopped event fires', async () => { + it('should flip back to stopped when gateway-stopped event fires', async () => { setupGateway({ running: false, url: null }); render(); await waitFor(() => { - expect(screen.getByTestId('sidebar')).toHaveTextContent('Gateway: Not running'); + expect(screen.getByTestId('statusbar-gateway')).toHaveTextContent( + 'Gateway stopped' + ); }); - // Start the gateway via event, then stop it act(() => { - fireGatewayEvent({ action: 'started', url: 'http://localhost:45818' }); + fireGatewayEvent({ + action: 'started', + url: 'http://localhost:45818', + port: 45818, + }); }); await waitFor(() => { - expect(screen.getByTestId('sidebar')).toHaveTextContent( - 'Gateway: http://localhost:45818' + expect(screen.getByTestId('statusbar-gateway')).not.toHaveTextContent( + 'stopped' ); }); - // Simulate gateway stopped event act(() => { fireGatewayEvent({ action: 'stopped' }); }); await waitFor(() => { - expect(screen.getByTestId('sidebar')).toHaveTextContent('Gateway: Not running'); + expect(screen.getByTestId('statusbar-gateway')).toHaveTextContent( + 'Gateway stopped' + ); }); }); }); diff --git a/tests/ts/stores/appStore.test.ts b/tests/ts/stores/appStore.test.ts index 0866a07..60a46bc 100644 --- a/tests/ts/stores/appStore.test.ts +++ b/tests/ts/stores/appStore.test.ts @@ -1,13 +1,12 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { useAppStore } from '../../../apps/desktop/src/stores/appStore'; -import { createTestSpace, createDefaultSpace, createTestSpaces, Space } from '../fixtures'; +import { createTestSpace, createDefaultSpace, createTestSpaces } from '../fixtures'; describe('appStore', () => { beforeEach(() => { // Reset store to initial state before each test useAppStore.setState({ spaces: [], - activeSpaceId: null, viewSpaceId: null, activeNav: 'home', pendingClientId: null, @@ -25,14 +24,14 @@ describe('appStore', () => { expect(useAppStore.getState().spaces).toEqual(spaces); }); - it('should auto-select first space as active when none selected', () => { + it('should auto-select first space as the view when none selected', () => { const spaces = createTestSpaces(3); useAppStore.getState().setSpaces(spaces); - expect(useAppStore.getState().activeSpaceId).toBe(spaces[0].id); + expect(useAppStore.getState().viewSpaceId).toBe(spaces[0].id); }); - it('should prefer default space when auto-selecting', () => { + it('should prefer the default space when auto-selecting the view', () => { const spaces = [ createTestSpace({ name: 'Space 1', is_default: false }), createDefaultSpace({ name: 'Default' }), @@ -40,99 +39,40 @@ describe('appStore', () => { ]; useAppStore.getState().setSpaces(spaces); - expect(useAppStore.getState().activeSpaceId).toBe(spaces[1].id); - }); - - it('should set viewSpaceId to activeSpaceId when not set', () => { - const spaces = createTestSpaces(2); - useAppStore.getState().setSpaces(spaces); - - expect(useAppStore.getState().viewSpaceId).toBe(useAppStore.getState().activeSpaceId); + expect(useAppStore.getState().viewSpaceId).toBe(spaces[1].id); }); - it('should reset viewSpaceId if current view space no longer exists', () => { - const spaces = createTestSpaces(2); - useAppStore.setState({ viewSpaceId: 'non-existent-id' }); + it('should keep viewSpaceId when it still exists in the spaces list', () => { + const spaces = createTestSpaces(3); + useAppStore.setState({ viewSpaceId: spaces[1].id }); useAppStore.getState().setSpaces(spaces); - expect(useAppStore.getState().viewSpaceId).toBe(useAppStore.getState().activeSpaceId); + expect(useAppStore.getState().viewSpaceId).toBe(spaces[1].id); }); - it('should reset activeSpaceId when persisted value points to deleted space', () => { + it('should reset viewSpaceId to the default space when the persisted view is gone', () => { const spaces = [ createTestSpace({ name: 'Space A', is_default: false }), createDefaultSpace({ name: 'Default Space' }), ]; - // Simulate a persisted activeSpaceId that no longer exists in the spaces list - useAppStore.setState({ activeSpaceId: 'deleted-space-id' }); + useAppStore.setState({ viewSpaceId: 'deleted-space-id' }); useAppStore.getState().setSpaces(spaces); - // Should fallback to the default space - expect(useAppStore.getState().activeSpaceId).toBe(spaces[1].id); + expect(useAppStore.getState().viewSpaceId).toBe(spaces[1].id); }); - it('should reset activeSpaceId to first space when no default exists', () => { + it('should reset viewSpaceId to the first space when no default exists', () => { const spaces = [ createTestSpace({ name: 'Space A', is_default: false }), createTestSpace({ name: 'Space B', is_default: false }), ]; - useAppStore.setState({ activeSpaceId: 'deleted-space-id' }); - useAppStore.getState().setSpaces(spaces); - - expect(useAppStore.getState().activeSpaceId).toBe(spaces[0].id); - }); - - it('should keep activeSpaceId when it still exists in spaces list', () => { - const spaces = createTestSpaces(3); - useAppStore.setState({ activeSpaceId: spaces[1].id }); + useAppStore.setState({ viewSpaceId: 'deleted-space-id' }); useAppStore.getState().setSpaces(spaces); - expect(useAppStore.getState().activeSpaceId).toBe(spaces[1].id); - }); - - it('should reset both activeSpaceId and viewSpaceId when both point to deleted spaces', () => { - const spaces = [createDefaultSpace({ name: 'My Space' })]; - useAppStore.setState({ - activeSpaceId: 'deleted-active-id', - viewSpaceId: 'deleted-view-id', - }); - useAppStore.getState().setSpaces(spaces); - - expect(useAppStore.getState().activeSpaceId).toBe(spaces[0].id); expect(useAppStore.getState().viewSpaceId).toBe(spaces[0].id); }); }); - describe('setActiveSpace', () => { - it('should set active space id', () => { - const spaces = createTestSpaces(3); - useAppStore.getState().setSpaces(spaces); - useAppStore.getState().setActiveSpace(spaces[2].id); - - expect(useAppStore.getState().activeSpaceId).toBe(spaces[2].id); - }); - - it('should follow with viewSpaceId when they were the same', () => { - const spaces = createTestSpaces(3); - useAppStore.getState().setSpaces(spaces); - // viewSpaceId should now equal activeSpaceId (both spaces[0].id) - - useAppStore.getState().setActiveSpace(spaces[1].id); - - expect(useAppStore.getState().viewSpaceId).toBe(spaces[1].id); - }); - - it('should not change viewSpaceId when different from activeSpaceId', () => { - const spaces = createTestSpaces(3); - useAppStore.getState().setSpaces(spaces); - useAppStore.getState().setViewSpace(spaces[2].id); - - useAppStore.getState().setActiveSpace(spaces[1].id); - - expect(useAppStore.getState().viewSpaceId).toBe(spaces[2].id); - }); - }); - describe('setViewSpace', () => { it('should set view space id', () => { const spaces = createTestSpaces(3); @@ -151,38 +91,31 @@ describe('appStore', () => { expect(useAppStore.getState().spaces).toContainEqual(space); }); - it('should set active space when first space is added', () => { + it('should set viewSpaceId when first space is added', () => { const space = createTestSpace(); useAppStore.getState().addSpace(space); - expect(useAppStore.getState().activeSpaceId).toBe(space.id); + expect(useAppStore.getState().viewSpaceId).toBe(space.id); }); - it('should set active space when default space is added', () => { + it('should snap viewSpaceId to a newly added default space', () => { const existing = createTestSpace(); const defaultSpace = createDefaultSpace(); useAppStore.getState().addSpace(existing); useAppStore.getState().addSpace(defaultSpace); - expect(useAppStore.getState().activeSpaceId).toBe(defaultSpace.id); + expect(useAppStore.getState().viewSpaceId).toBe(defaultSpace.id); }); - it('should not change active space when non-default space is added', () => { + it('should not change viewSpaceId when adding a non-default space', () => { const first = createTestSpace({ name: 'First' }); const second = createTestSpace({ name: 'Second' }); useAppStore.getState().addSpace(first); useAppStore.getState().addSpace(second); - expect(useAppStore.getState().activeSpaceId).toBe(first.id); - }); - - it('should initialize viewSpaceId when first space is added', () => { - const space = createTestSpace(); - useAppStore.getState().addSpace(space); - - expect(useAppStore.getState().viewSpaceId).toBe(space.id); + expect(useAppStore.getState().viewSpaceId).toBe(first.id); }); }); @@ -196,29 +129,23 @@ describe('appStore', () => { expect(useAppStore.getState().spaces.find((s) => s.id === spaces[1].id)).toBeUndefined(); }); - it('should select first remaining space when active space is removed', () => { - const spaces = createTestSpaces(3); - useAppStore.getState().setSpaces(spaces); - useAppStore.getState().removeSpace(spaces[0].id); + it('should fall back to the default space when the viewed space is removed', () => { + const def = createDefaultSpace({ name: 'Default' }); + const other = createTestSpace({ name: 'Other', is_default: false }); + useAppStore.getState().setSpaces([def, other]); + useAppStore.getState().setViewSpace(other.id); + + useAppStore.getState().removeSpace(other.id); - expect(useAppStore.getState().activeSpaceId).toBe(spaces[1].id); + expect(useAppStore.getState().viewSpaceId).toBe(def.id); }); - it('should set activeSpaceId to null when last space is removed', () => { + it('should set viewSpaceId to null when last space is removed', () => { const space = createTestSpace(); useAppStore.getState().addSpace(space); useAppStore.getState().removeSpace(space.id); - expect(useAppStore.getState().activeSpaceId).toBeNull(); - }); - - it('should update viewSpaceId when viewed space is removed', () => { - const spaces = createTestSpaces(3); - useAppStore.getState().setSpaces(spaces); - useAppStore.getState().setViewSpace(spaces[1].id); - useAppStore.getState().removeSpace(spaces[1].id); - - expect(useAppStore.getState().viewSpaceId).toBe(useAppStore.getState().activeSpaceId); + expect(useAppStore.getState().viewSpaceId).toBeNull(); }); }); From c2f02f6eba368d564a4c3ead3e91e5dee016fe50 Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Sat, 25 Apr 2026 19:13:20 +0800 Subject: [PATCH 14/24] fix(gateway): route to resolved space + accept URL client_ids in meta tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 /* 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 --- .../src/commands/meta_tool_approval.rs | 12 +- crates/mcpmux-gateway/src/mcp/handler.rs | 165 +++++++----------- .../src/services/meta_tools/approval.rs | 92 ++++++---- .../src/services/meta_tools/registry.rs | 9 +- .../src/services/meta_tools/tools.rs | 2 +- tests/rust/tests/integration/meta_tools.rs | 12 +- 6 files changed, 146 insertions(+), 146 deletions(-) diff --git a/apps/desktop/src-tauri/src/commands/meta_tool_approval.rs b/apps/desktop/src-tauri/src/commands/meta_tool_approval.rs index 568f108..e7e77e9 100644 --- a/apps/desktop/src-tauri/src/commands/meta_tool_approval.rs +++ b/apps/desktop/src-tauri/src/commands/meta_tool_approval.rs @@ -14,7 +14,6 @@ use serde::Serialize; use tauri::State; use tokio::sync::RwLock; use tracing::{info, warn}; -use uuid::Uuid; use crate::commands::gateway::GatewayAppState; @@ -44,7 +43,6 @@ pub async fn respond_to_meta_tool_approval( "deny" => ApprovalDecision::Deny, other => return Err(format!("unknown decision: {other}")), }; - let client_uuid = Uuid::parse_str(&client_id).map_err(|e| format!("bad client_id: {e}"))?; let broker = { let state = gateway_state.read().await; @@ -55,7 +53,10 @@ pub async fn respond_to_meta_tool_approval( return Ok(false); }; - let resolved = broker.respond(&request_id, client_uuid, &tool_name, decision); + // client_id is opaque (UUID for preset clients, OAuth client_metadata + // URL for DCR clients like Claude Code). The broker treats it as a + // hash key only. + let resolved = broker.respond(&request_id, &client_id, &tool_name, decision); info!( %request_id, %client_id, @@ -86,7 +87,7 @@ pub async fn list_meta_tool_grants( .list_always_allow() .into_iter() .map(|(client_id, tool_name)| MetaToolGrantEntry { - client_id: client_id.to_string(), + client_id, tool_name, }) .collect()) @@ -99,7 +100,6 @@ pub async fn revoke_meta_tool_grant( tool_name: String, gateway_state: State<'_, Arc>>, ) -> Result { - let client_uuid = Uuid::parse_str(&client_id).map_err(|e| format!("bad client_id: {e}"))?; let broker = { let state = gateway_state.read().await; state.approval_broker.clone() @@ -107,5 +107,5 @@ pub async fn revoke_meta_tool_grant( let Some(broker) = broker else { return Ok(false); }; - Ok(broker.revoke_always_allow(client_uuid, &tool_name)) + Ok(broker.revoke_always_allow(&client_id, &tool_name)) } diff --git a/crates/mcpmux-gateway/src/mcp/handler.rs b/crates/mcpmux-gateway/src/mcp/handler.rs index 9b74171..0635814 100644 --- a/crates/mcpmux-gateway/src/mcp/handler.rs +++ b/crates/mcpmux-gateway/src/mcp/handler.rs @@ -157,6 +157,34 @@ impl McpMuxGatewayHandler { } } + /// Resolve the (Space, FeatureSet ids) the gateway should route a + /// session through. The OAuth-context space is *not* used for routing + /// — when a `WorkspaceBinding` matches, the binding's target space is + /// authoritative and may differ from the OAuth-bound space (this is + /// the whole point of workspace-root routing). Pass the returned + /// `space_id` to every `feature_service.get_*_for_grants` / + /// `routing_service.call_tool` invocation; otherwise the lookup queries + /// the wrong space and returns 0 matches. + async fn resolve_routing( + &self, + session_id: Option<&str>, + ) -> Result<(uuid::Uuid, Vec), McpError> { + let resolved = self + .services + .authorization_service + .resolve(session_id) + .await + .map_err(|e| McpError::internal_error(format!("Failed to resolve: {e}"), None))?; + let space_id = resolved.space_id.ok_or_else(|| { + McpError::internal_error("No space resolved (no default space configured)", None) + })?; + let feature_set_ids = resolved + .feature_set_id + .map(|fs| vec![fs]) + .unwrap_or_default(); + Ok((space_id, feature_set_ids)) + } + /// Build InitializeResult with negotiated protocol version fn build_initialize_result(&self, protocol_version: ProtocolVersion) -> InitializeResult { let info = self.get_info(); @@ -416,28 +444,19 @@ impl ServerHandler for McpMuxGatewayHandler { _params: Option, context: RequestContext, ) -> Result { - let oauth_ctx = self - .get_oauth_context(&context.extensions) - .map_err(|e| McpError::invalid_params(e.to_string(), None))?; - - // Get client's grants - let feature_set_ids = self - .services - .authorization_service - .get_client_grants( - &oauth_ctx.client_id, - &oauth_ctx.space_id, - extract_session_id(&context.extensions).as_deref(), - ) - .await - .map_err(|e| McpError::internal_error(format!("Failed to get grants: {}", e), None))?; - - // Get tools via FeatureService + // Resolve routing once: the resolver returns the authoritative + // (Space, FS) for this session — this may differ from oauth_ctx + // when a WorkspaceBinding redirects to another space. + let (space_id, feature_set_ids) = self + .resolve_routing(extract_session_id(&context.extensions).as_deref()) + .await?; + + // Get tools via FeatureService — using the *resolved* space. let tools = self .services .pool_services .feature_service - .get_tools_for_grants(&oauth_ctx.space_id.to_string(), &feature_set_ids) + .get_tools_for_grants(&space_id.to_string(), &feature_set_ids) .await .map_err(|e| McpError::internal_error(format!("Failed to get tools: {}", e), None))?; @@ -500,8 +519,9 @@ impl ServerHandler for McpMuxGatewayHandler { && self.services.meta_tool_registry.contains(¶ms.name) && self.services.meta_tool_registry.is_enabled().await { - let client_uuid = uuid::Uuid::parse_str(&oauth_ctx.client_id) - .map_err(|e| McpError::invalid_params(format!("bad client_id: {e}"), None))?; + // Note: client_id is the OAuth client identity (a URL for DCR- + // registered clients like Claude, a UUID for others). The meta- + // tool registry treats it as an opaque string identity key. let args: serde_json::Value = params .arguments .map(|a| serde_json::to_value(a).unwrap_or(serde_json::Value::Null)) @@ -509,7 +529,7 @@ impl ServerHandler for McpMuxGatewayHandler { return match self .services .meta_tool_registry - .call(¶ms.name, &client_uuid, session_id, args) + .call(¶ms.name, &oauth_ctx.client_id, session_id, args) .await { Ok(result) => Ok(result), @@ -517,13 +537,9 @@ impl ServerHandler for McpMuxGatewayHandler { }; } - // Get client's feature set grants for authorization - let feature_set_ids = self - .services - .authorization_service - .get_client_grants(&oauth_ctx.client_id, &oauth_ctx.space_id, session_id) - .await - .map_err(|e| McpError::internal_error(format!("Failed to get grants: {}", e), None))?; + // Resolve routing — the binding's target space is authoritative, + // which may differ from oauth_ctx.space_id. + let (space_id, feature_set_ids) = self.resolve_routing(session_id).await?; // Call tool via routing service (handles auth and routing) let tool_result = self @@ -531,7 +547,7 @@ impl ServerHandler for McpMuxGatewayHandler { .pool_services .routing_service .call_tool( - oauth_ctx.space_id, + space_id, &feature_set_ids, ¶ms.name, serde_json::to_value(params.arguments.unwrap_or_default()).unwrap_or_default(), @@ -605,26 +621,15 @@ impl ServerHandler for McpMuxGatewayHandler { _params: Option, context: RequestContext, ) -> Result { - let oauth_ctx = self - .get_oauth_context(&context.extensions) - .map_err(|e| McpError::invalid_params(e.to_string(), None))?; - - let feature_set_ids = self - .services - .authorization_service - .get_client_grants( - &oauth_ctx.client_id, - &oauth_ctx.space_id, - extract_session_id(&context.extensions).as_deref(), - ) - .await - .map_err(|e| McpError::internal_error(format!("Failed to get grants: {}", e), None))?; + let (space_id, feature_set_ids) = self + .resolve_routing(extract_session_id(&context.extensions).as_deref()) + .await?; let prompts = self .services .pool_services .feature_service - .get_prompts_for_grants(&oauth_ctx.space_id.to_string(), &feature_set_ids) + .get_prompts_for_grants(&space_id.to_string(), &feature_set_ids) .await .map_err(|e| McpError::internal_error(format!("Failed to get prompts: {}", e), None))?; @@ -657,35 +662,23 @@ impl ServerHandler for McpMuxGatewayHandler { params: GetPromptRequestParams, context: RequestContext, ) -> Result { - let oauth_ctx = self - .get_oauth_context(&context.extensions) - .map_err(|e| McpError::invalid_params(e.to_string(), None))?; + let (space_id, feature_set_ids) = self + .resolve_routing(extract_session_id(&context.extensions).as_deref()) + .await?; let (server_id, prompt_name) = self .services .pool_services .feature_service - .parse_qualified_prompt_name(&oauth_ctx.space_id.to_string(), ¶ms.name) + .parse_qualified_prompt_name(&space_id.to_string(), ¶ms.name) .await .map_err(|e| McpError::invalid_params(format!("Invalid prompt name: {}", e), None))?; - // Verify authorization - let feature_set_ids = self - .services - .authorization_service - .get_client_grants( - &oauth_ctx.client_id, - &oauth_ctx.space_id, - extract_session_id(&context.extensions).as_deref(), - ) - .await - .map_err(|e| McpError::internal_error(format!("Failed to get grants: {}", e), None))?; - let authorized_prompts = self .services .pool_services .feature_service - .get_prompts_for_grants(&oauth_ctx.space_id.to_string(), &feature_set_ids) + .get_prompts_for_grants(&space_id.to_string(), &feature_set_ids) .await .map_err(|e| { McpError::internal_error(format!("Failed to verify authorization: {}", e), None) @@ -706,12 +699,7 @@ impl ServerHandler for McpMuxGatewayHandler { .services .pool_services .pool_service - .get_prompt( - oauth_ctx.space_id, - &server_id, - &prompt_name, - params.arguments, - ) + .get_prompt(space_id, &server_id, &prompt_name, params.arguments) .await .map_err(|e| McpError::internal_error(format!("Get prompt failed: {}", e), None))?; @@ -728,26 +716,15 @@ impl ServerHandler for McpMuxGatewayHandler { _params: Option, context: RequestContext, ) -> Result { - let oauth_ctx = self - .get_oauth_context(&context.extensions) - .map_err(|e| McpError::invalid_params(e.to_string(), None))?; - - let feature_set_ids = self - .services - .authorization_service - .get_client_grants( - &oauth_ctx.client_id, - &oauth_ctx.space_id, - extract_session_id(&context.extensions).as_deref(), - ) - .await - .map_err(|e| McpError::internal_error(format!("Failed to get grants: {}", e), None))?; + let (space_id, feature_set_ids) = self + .resolve_routing(extract_session_id(&context.extensions).as_deref()) + .await?; let resources = self .services .pool_services .feature_service - .get_resources_for_grants(&oauth_ctx.space_id.to_string(), &feature_set_ids) + .get_resources_for_grants(&space_id.to_string(), &feature_set_ids) .await .map_err(|e| { McpError::internal_error(format!("Failed to get resources: {}", e), None) @@ -778,15 +755,15 @@ impl ServerHandler for McpMuxGatewayHandler { params: ReadResourceRequestParams, context: RequestContext, ) -> Result { - let oauth_ctx = self - .get_oauth_context(&context.extensions) - .map_err(|e| McpError::invalid_params(e.to_string(), None))?; + let (space_id, feature_set_ids) = self + .resolve_routing(extract_session_id(&context.extensions).as_deref()) + .await?; let server_id = self .services .pool_services .feature_service - .find_server_for_resource(&oauth_ctx.space_id.to_string(), ¶ms.uri) + .find_server_for_resource(&space_id.to_string(), ¶ms.uri) .await .map_err(|e| { McpError::internal_error(format!("Failed to resolve resource: {}", e), None) @@ -795,23 +772,11 @@ impl ServerHandler for McpMuxGatewayHandler { McpError::invalid_params(format!("Resource '{}' not found", params.uri), None) })?; - // Verify authorization - let feature_set_ids = self - .services - .authorization_service - .get_client_grants( - &oauth_ctx.client_id, - &oauth_ctx.space_id, - extract_session_id(&context.extensions).as_deref(), - ) - .await - .map_err(|e| McpError::internal_error(format!("Failed to get grants: {}", e), None))?; - let authorized_resources = self .services .pool_services .feature_service - .get_resources_for_grants(&oauth_ctx.space_id.to_string(), &feature_set_ids) + .get_resources_for_grants(&space_id.to_string(), &feature_set_ids) .await .map_err(|e| { McpError::internal_error(format!("Failed to verify authorization: {}", e), None) @@ -832,7 +797,7 @@ impl ServerHandler for McpMuxGatewayHandler { .services .pool_services .pool_service - .read_resource(oauth_ctx.space_id, &server_id, ¶ms.uri) + .read_resource(space_id, &server_id, ¶ms.uri) .await .map_err(|e| McpError::internal_error(format!("Read resource failed: {}", e), None))?; diff --git a/crates/mcpmux-gateway/src/services/meta_tools/approval.rs b/crates/mcpmux-gateway/src/services/meta_tools/approval.rs index eca2ec7..bd29be8 100644 --- a/crates/mcpmux-gateway/src/services/meta_tools/approval.rs +++ b/crates/mcpmux-gateway/src/services/meta_tools/approval.rs @@ -18,6 +18,11 @@ //! not persisted). A gateway restart re-prompts. This is a deliberate //! security default — auto-approved writes deserve a fresh nod on every //! launch. Users can still tick the checkbox once per session. +//! +//! Client identity is treated as an opaque `String` (the OAuth client_id +//! from the JWT — a UUID for the legacy preset-clients path, a +//! client_metadata URL for DCR-registered clients like Claude Code). The +//! broker doesn't parse it; equality + hashing is enough. use std::sync::Arc; use std::time::{Duration, Instant}; @@ -104,9 +109,11 @@ pub struct ApprovalBroker { /// `respond_to_meta_tool_approval` resolves these. pending: DashMap>, /// Session-scoped always-allow grants, keyed by (client_id, tool_name). - always_allow: DashMap<(Uuid, String), ()>, + /// `client_id` is opaque (UUID for preset clients, URL for DCR clients); + /// the broker only does equality lookups. + always_allow: DashMap<(String, String), ()>, /// (client_id) -> Vec for rate limiting. - rate_limit: DashMap>, + rate_limit: DashMap>, /// Published to the desktop layer; `None` means headless. publisher: Mutex>, timeout: Duration, @@ -140,11 +147,11 @@ impl ApprovalBroker { } /// For tests / headless scenarios: pre-approve everything from a - /// specific client. Returns a guard struct you drop to clear it. + /// specific client. #[cfg(test)] - pub fn insert_always_allow(&self, client_id: Uuid, tool_name: &str) { + pub fn insert_always_allow(&self, client_id: &str, tool_name: &str) { self.always_allow - .insert((client_id, tool_name.to_string()), ()); + .insert((client_id.to_string(), tool_name.to_string()), ()); } /// Resolve a pending approval. Called from Tauri command when the user @@ -153,7 +160,7 @@ impl ApprovalBroker { pub fn respond( &self, request_id: &str, - client_id: Uuid, + client_id: &str, tool_name: &str, decision: ApprovalDecision, ) -> bool { @@ -161,7 +168,7 @@ impl ApprovalBroker { // call from the same client sees it. if matches!(decision, ApprovalDecision::AlwaysForThisSessionAndClient) { self.always_allow - .insert((client_id, tool_name.to_string()), ()); + .insert((client_id.to_string(), tool_name.to_string()), ()); } if let Some((_, tx)) = self.pending.remove(request_id) { tx.send(decision).is_ok() @@ -181,14 +188,14 @@ impl ApprovalBroker { } /// List always-allow grants (for the UI to display + revoke). - pub fn list_always_allow(&self) -> Vec<(Uuid, String)> { + pub fn list_always_allow(&self) -> Vec<(String, String)> { self.always_allow.iter().map(|e| e.key().clone()).collect() } /// Revoke an always-allow entry. - pub fn revoke_always_allow(&self, client_id: Uuid, tool_name: &str) -> bool { + pub fn revoke_always_allow(&self, client_id: &str, tool_name: &str) -> bool { self.always_allow - .remove(&(client_id, tool_name.to_string())) + .remove(&(client_id.to_string(), tool_name.to_string())) .is_some() } @@ -201,14 +208,14 @@ impl ApprovalBroker { /// 4. Emit + wait → Allow / Deny / Timeout. pub async fn request_approval( &self, - client_id: Uuid, + client_id: &str, tool_name: &str, payload: ApprovalPayload, ) -> Result { // 1. Always-allow short-circuit. if self .always_allow - .contains_key(&(client_id, tool_name.to_string())) + .contains_key(&(client_id.to_string(), tool_name.to_string())) { debug!( %client_id, @@ -222,7 +229,7 @@ impl ApprovalBroker { self.prune_rate_limit(client_id); let pending_for_client = self .rate_limit - .get(&client_id) + .get(client_id) .map(|e| e.value().len()) .unwrap_or(0); if pending_for_client >= RATE_LIMIT_MAX_PENDING { @@ -235,7 +242,7 @@ impl ApprovalBroker { return Err(MetaToolError::RateLimited); } self.rate_limit - .entry(client_id) + .entry(client_id.to_string()) .or_default() .push(Instant::now()); @@ -288,8 +295,8 @@ impl ApprovalBroker { } } - fn prune_rate_limit(&self, client_id: Uuid) { - if let Some(mut entry) = self.rate_limit.get_mut(&client_id) { + fn prune_rate_limit(&self, client_id: &str) { + if let Some(mut entry) = self.rate_limit.get_mut(client_id) { let cutoff = Instant::now() - RATE_LIMIT_WINDOW; entry.retain(|t| *t > cutoff); } @@ -315,7 +322,11 @@ mod tests { async fn no_publisher_returns_no_desktop_error() { let broker = ApprovalBroker::new(); let err = broker - .request_approval(Uuid::new_v4(), "mcpmux_pin_this_session", make_payload()) + .request_approval( + &Uuid::new_v4().to_string(), + "mcpmux_pin_this_session", + make_payload(), + ) .await .unwrap_err(); assert!(matches!(err, MetaToolError::ApprovalRequiredNoDesktop)); @@ -324,10 +335,25 @@ mod tests { #[tokio::test] async fn always_allow_short_circuits() { let broker = ApprovalBroker::new(); - let client_id = Uuid::new_v4(); - broker.insert_always_allow(client_id, "mcpmux_pin_this_session"); + let client_id = Uuid::new_v4().to_string(); + broker.insert_always_allow(&client_id, "mcpmux_pin_this_session"); + let d = broker + .request_approval(&client_id, "mcpmux_pin_this_session", make_payload()) + .await + .unwrap(); + assert_eq!(d, ApprovalDecision::AllowOnce); + } + + #[tokio::test] + async fn url_client_id_works() { + // Regression for the bug where DCR-registered clients (which use + // a client_metadata URL as their client_id) couldn't get past the + // approval flow because we tried to parse the URL as a UUID. + let broker = ApprovalBroker::new(); + let url_client_id = "https://claude.ai/oauth/claude-code-client-metadata"; + broker.insert_always_allow(url_client_id, "mcpmux_pin_this_session"); let d = broker - .request_approval(client_id, "mcpmux_pin_this_session", make_payload()) + .request_approval(url_client_id, "mcpmux_pin_this_session", make_payload()) .await .unwrap(); assert_eq!(d, ApprovalDecision::AllowOnce); @@ -337,7 +363,7 @@ mod tests { async fn publisher_allow_resolves() { let broker = Arc::new(ApprovalBroker::new().with_timeout(Duration::from_millis(500))); let broker_clone = broker.clone(); - let client_id = Uuid::new_v4(); + let client_id = Uuid::new_v4().to_string(); // Publisher responds asynchronously with Allow. let publisher: ApprovalPublisher = Arc::new(move |req| { @@ -347,7 +373,7 @@ mod tests { tokio::time::sleep(Duration::from_millis(10)).await; b.respond( &req.request_id, - Uuid::parse_str(&req.client_id).unwrap(), + &req.client_id, &req.payload.tool_name, ApprovalDecision::AllowOnce, ); @@ -359,7 +385,7 @@ mod tests { broker.set_publisher(publisher).await; let decision = broker - .request_approval(client_id, "mcpmux_pin_this_session", make_payload()) + .request_approval(&client_id, "mcpmux_pin_this_session", make_payload()) .await .unwrap(); assert_eq!(decision, ApprovalDecision::AllowOnce); @@ -369,7 +395,7 @@ mod tests { async fn publisher_deny_returns_denied_error() { let broker = Arc::new(ApprovalBroker::new().with_timeout(Duration::from_millis(500))); let broker_clone = broker.clone(); - let client_id = Uuid::new_v4(); + let client_id = Uuid::new_v4().to_string(); let publisher: ApprovalPublisher = Arc::new(move |req| { let b = broker_clone.clone(); @@ -378,7 +404,7 @@ mod tests { tokio::time::sleep(Duration::from_millis(10)).await; b.respond( &req.request_id, - Uuid::parse_str(&req.client_id).unwrap(), + &req.client_id, &req.payload.tool_name, ApprovalDecision::Deny, ); @@ -390,7 +416,7 @@ mod tests { broker.set_publisher(publisher).await; let err = broker - .request_approval(client_id, "mcpmux_pin_this_session", make_payload()) + .request_approval(&client_id, "mcpmux_pin_this_session", make_payload()) .await .unwrap_err(); assert!(matches!(err, MetaToolError::ApprovalDenied)); @@ -404,7 +430,11 @@ mod tests { broker.set_publisher(publisher).await; let err = broker - .request_approval(Uuid::new_v4(), "mcpmux_pin_this_session", make_payload()) + .request_approval( + &Uuid::new_v4().to_string(), + "mcpmux_pin_this_session", + make_payload(), + ) .await .unwrap_err(); assert!(matches!(err, MetaToolError::ApprovalTimedOut)); @@ -414,7 +444,7 @@ mod tests { async fn always_scope_persists_across_calls() { let broker = Arc::new(ApprovalBroker::new().with_timeout(Duration::from_millis(500))); let broker_clone = broker.clone(); - let client_id = Uuid::new_v4(); + let client_id = Uuid::new_v4().to_string(); let publisher: ApprovalPublisher = Arc::new(move |req| { let b = broker_clone.clone(); @@ -423,7 +453,7 @@ mod tests { tokio::time::sleep(Duration::from_millis(10)).await; b.respond( &req.request_id, - Uuid::parse_str(&req.client_id).unwrap(), + &req.client_id, &req.payload.tool_name, ApprovalDecision::AlwaysForThisSessionAndClient, ); @@ -436,14 +466,14 @@ mod tests { // First call → dialog, returns AlwaysForThisSessionAndClient. let d1 = broker - .request_approval(client_id, "mcpmux_pin_this_session", make_payload()) + .request_approval(&client_id, "mcpmux_pin_this_session", make_payload()) .await .unwrap(); assert_eq!(d1, ApprovalDecision::AlwaysForThisSessionAndClient); // Second call → short-circuits via always-allow entry. let d2 = broker - .request_approval(client_id, "mcpmux_pin_this_session", make_payload()) + .request_approval(&client_id, "mcpmux_pin_this_session", make_payload()) .await .unwrap(); assert_eq!(d2, ApprovalDecision::AllowOnce); diff --git a/crates/mcpmux-gateway/src/services/meta_tools/registry.rs b/crates/mcpmux-gateway/src/services/meta_tools/registry.rs index 1b6f921..54307dd 100644 --- a/crates/mcpmux-gateway/src/services/meta_tools/registry.rs +++ b/crates/mcpmux-gateway/src/services/meta_tools/registry.rs @@ -16,7 +16,6 @@ use rmcp::model::{CallToolResult, Tool}; use serde_json::Value; use thiserror::Error; use tokio::sync::broadcast; -use uuid::Uuid; use super::approval::ApprovalBroker; use crate::pool::FeatureService; @@ -51,8 +50,12 @@ pub struct MetaToolContext { } /// Per-request metadata threaded through every tool call. +/// +/// `client_id` is the OAuth client identity from the JWT — opaque string +/// (a UUID for preset-clients, a `client_metadata` URL for DCR-registered +/// clients like Claude Code). The registry treats it as a hash key only. pub struct MetaToolCall<'a> { - pub client_id: &'a Uuid, + pub client_id: &'a str, pub session_id: Option<&'a str>, /// JSON arguments supplied in `CallToolRequestParams.arguments`. pub args: Value, @@ -208,7 +211,7 @@ impl MetaToolRegistry { pub async fn call( &self, name: &str, - client_id: &Uuid, + client_id: &str, session_id: Option<&str>, args: Value, ) -> Result { diff --git a/crates/mcpmux-gateway/src/services/meta_tools/tools.rs b/crates/mcpmux-gateway/src/services/meta_tools/tools.rs index 236a99b..f6fda05 100644 --- a/crates/mcpmux-gateway/src/services/meta_tools/tools.rs +++ b/crates/mcpmux-gateway/src/services/meta_tools/tools.rs @@ -285,7 +285,7 @@ where }; call.ctx .approval_broker - .request_approval(*call.client_id, tool_name, payload) + .request_approval(call.client_id, tool_name, payload) .await?; mutate().await } diff --git a/tests/rust/tests/integration/meta_tools.rs b/tests/rust/tests/integration/meta_tools.rs index bb8d7f9..2b42772 100644 --- a/tests/rust/tests/integration/meta_tools.rs +++ b/tests/rust/tests/integration/meta_tools.rs @@ -37,7 +37,9 @@ struct Fixture { binding_repo: Arc, session_roots: Arc, space_id: Uuid, - client_id: Uuid, + /// Opaque client identity (UUID-as-string here; in production for DCR + /// clients this can be a `client_metadata` URL). + client_id: String, session_id: String, fs_android_id: Uuid, } @@ -86,7 +88,7 @@ impl Fixture { // Create test client — routing is per-session-root now, not per-client. let client = Client::new("TestClient", "test-type"); - let client_id = client.id; + let client_id = client.id.to_string(); client_repo.create(&client).await.unwrap(); let session_roots = SessionRootsRegistry::new(); @@ -147,7 +149,7 @@ impl Fixture { tokio::time::sleep(Duration::from_millis(5)).await; b.respond( &req.request_id, - Uuid::parse_str(&req.client_id).unwrap(), + &req.client_id, &req.payload.tool_name, decision, ); @@ -504,7 +506,7 @@ async fn bare_registry( settings_repo: Option>, ) -> ( Arc, - Uuid, + String, broadcast::Sender, broadcast::Receiver, ) { @@ -521,7 +523,7 @@ async fn bare_registry( let _space = space_repo.get_default().await.unwrap().unwrap(); let client = Client::new("c", "t"); - let client_id = client.id; + let client_id = client.id.to_string(); client_repo.create(&client).await.unwrap(); let resolver = Arc::new(FeatureSetResolverService::new( From ac3313653c083c8ea060f25137d41f41eedc4b66 Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Sat, 25 Apr 2026 19:43:36 +0800 Subject: [PATCH 15/24] fix(gateway): wire approval publisher on auto-start + focus window on popup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs around meta-tool write approvals. 1. Approval publisher missing on auto-start Only the manual `start_gateway` Tauri command attached the broker publisher; the lib.rs auto-start path (which runs on every desktop app launch when auto-start is enabled — i.e. virtually always) never did. Result: even with the desktop app fully running, every write meta tool (`mcpmux_create_feature_set`, `mcpmux_bind_current_workspace`, …) returned `approval_required: no desktop attached to mcpmux gateway`. Factored the publisher wiring into `commands::gateway::attach_approval_publisher` and called it from both paths. Also added the missing `state.approval_broker = Some(...)` in the auto-start block so the desktop's grants-list / revoke commands can reach the broker too. 2. Popups rendered behind other windows When a meta-tool approval request fired or a session reported a root that needs binding, the dialog/sheet rendered in whichever window the user wasn't focused on. Added `focus_main_window(&app)` at two chokepoints: - In the approval publisher closure, before emitting the `meta-tool-approval-request` event. - In the domain-event bridge, when forwarding `WorkspaceNeedsBinding` (which triggers the binding sheet). `unminimize` + `show` + `set_focus` covers minimized, hidden behind another app, and tray-hidden states. Signed-off-by: Mohammod Al Amin Ashik --- .../desktop/src-tauri/src/commands/gateway.rs | 89 ++++++++++++++----- apps/desktop/src-tauri/src/lib.rs | 11 +++ 2 files changed, 76 insertions(+), 24 deletions(-) diff --git a/apps/desktop/src-tauri/src/commands/gateway.rs b/apps/desktop/src-tauri/src/commands/gateway.rs index ff65a5d..0b99d54 100644 --- a/apps/desktop/src-tauri/src/commands/gateway.rs +++ b/apps/desktop/src-tauri/src/commands/gateway.rs @@ -114,6 +114,62 @@ pub(crate) async fn shutdown_gateway_handle(mut handle: mcpmux_gateway::GatewayS } } +/// Bring the main webview window forward so the user sees a popup the +/// gateway just emitted. Best-effort — silently no-ops when the window +/// doesn't exist (rare, e.g. during teardown). Used by the approval +/// publisher and the WorkspaceNeedsBinding bridge so an LLM tool call or +/// a fresh client connection automatically draws the user's eye to the +/// mcpmux app instead of the dialog rendering invisibly under another +/// window. +pub(crate) fn focus_main_window(app: &tauri::AppHandle) { + use tauri::Manager; + let Some(window) = app.get_webview_window("main") else { + return; + }; + // unminimize + show + set_focus together cover every state the user + // could have left the window in (minimized, hidden behind another + // app, hidden by user via the close-to-tray flow). + let _ = window.unminimize(); + let _ = window.show(); + let _ = window.set_focus(); +} + +/// Wire the meta-tool approval broker to the desktop event bus so write +/// tools (e.g. `mcpmux_bind_current_workspace`) can prompt the React +/// dialog. Both the manual `start_gateway` command and the lib.rs +/// auto-start path must call this — without it the broker stays +/// publisher-less and every write surfaces as +/// `approval_required: no desktop attached to mcpmux gateway`. +pub(crate) async fn attach_approval_publisher( + approval_broker: &Arc, + app_handle: tauri::AppHandle, +) { + let publisher: mcpmux_gateway::services::meta_tools::ApprovalPublisher = Arc::new(move |req| { + let app_handle = app_handle.clone(); + Box::pin(async move { + // Bring the window forward BEFORE emitting so the dialog + // animates into a visible window — otherwise it'd render + // behind whatever the user is currently focused on. + focus_main_window(&app_handle); + // Emit the request; the React layer owns rendering + + // collecting the user's decision. Failure to emit means + // no desktop frontend is listening — broker maps that to + // "approval_required" to the calling tool. + match app_handle.emit("meta-tool-approval-request", &req) { + Ok(()) => true, + Err(e) => { + tracing::warn!( + error = %e, + "[meta-tool] failed to emit approval request" + ); + false + } + } + }) + }); + approval_broker.set_publisher(publisher).await; +} + /// Wires up ServerManager state + the OAuth completion handler + the /// periodic refresh loop after a GatewayServer has been spawned. /// @@ -245,6 +301,14 @@ pub fn start_domain_event_bridge( while let Ok(event) = event_rx.recv().await { let event_type = event.type_name(); + // Some domain events imply a popup the user must see (a workspace + // root needs binding, a backend wants OAuth, etc.). Bring the + // window forward BEFORE emitting so the popup animates into a + // visible window instead of rendering behind another app. + if matches!(event, DomainEvent::WorkspaceNeedsBinding { .. }) { + focus_main_window(&app_handle_clone); + } + // Map domain events to UI channels let (channel, payload) = map_domain_event_to_ui(&event); @@ -822,30 +886,7 @@ pub async fn start_gateway( // Meta-tool approval broker — attach a Tauri-event publisher so // incoming approval requests reach the React dialog. let approval_broker = server.approval_broker(); - { - let app_handle_for_broker = app_handle.clone(); - let publisher: mcpmux_gateway::services::meta_tools::ApprovalPublisher = - std::sync::Arc::new(move |req| { - let app_handle = app_handle_for_broker.clone(); - Box::pin(async move { - // Emit the request; the React layer owns rendering + - // collecting the user's decision. Failure to emit means - // no desktop frontend is listening — broker maps that to - // "approval_required" to the calling tool. - match app_handle.emit("meta-tool-approval-request", &req) { - Ok(()) => true, - Err(e) => { - tracing::warn!( - error = %e, - "[meta-tool] failed to emit approval request" - ); - false - } - } - }) - }); - approval_broker.set_publisher(publisher).await; - } + attach_approval_publisher(&approval_broker, app_handle.clone()).await; // Start domain event bridge (clean architecture) start_domain_event_bridge(&app_handle, gw_state.clone()); diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index b943554..0e26f6d 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -473,6 +473,16 @@ pub fn run() { let event_emitter = server.event_emitter(); let grant_service = server.grant_service(); let session_roots = server.session_roots(); + let approval_broker = server.approval_broker(); + + // Wire the approval broker to the desktop event bus so + // write meta tools can prompt the React dialog. Without + // this, every write surfaces as "no desktop attached". + crate::commands::gateway::attach_approval_publisher( + &approval_broker, + app_handle_for_sm.clone(), + ) + .await; // Start domain event bridge crate::commands::gateway::start_domain_event_bridge(&app_handle_for_sm, gw_inner_state.clone()); @@ -510,6 +520,7 @@ pub fn run() { state.feature_service = Some(feature_service); state.event_emitter = Some(event_emitter); state.grant_service = Some(grant_service); + state.approval_broker = Some(approval_broker); state.session_roots = Some(session_roots); info!( From 02bd7b9935bf6bd3e8c93feb1bf17d9bbb0e6617 Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Mon, 27 Apr 2026 12:47:22 +0800 Subject: [PATCH 16/24] fix(gateway,ui): collapse describe tools, fire list_changed on Connect, badge denominator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Collapse `mcpmux_describe_resolution` into `mcpmux_describe_workspace`. The split surfaced two reads with overlapping output and shipped a redundant tool to LLMs. `describe_workspace` now returns a `resolution` block with `feature_set_id`, `feature_set_name`, `source`, and `resolved_tool_count`. * Fire `list_changed` on `ServerStatusChanged(Connected)`, not just Disconnected. Reconnect flips per-feature `is_available`, which `get_all_features_for_space` filters on, so the content hash legitimately changes both ways. Without this, a backend reconnect after the client's initial `tools/list` left the client view stuck without the freshly-available tools. Loop concern mooted by the existing hash dedup. * `WorkspacesPage` per-server badge now reads `{mapped}/{server total}` — backend returns `server_totals` (HashMap of server_id -> per-type counts) computed before the FS filter is applied. The old `3/3` was `mapped/mapped`; the new badge tells the user "this FS includes 3 of the 10 cloudflare-docs tools available." * `WorkspacesPage` listens for `workspace-binding-changed` so popup-driven binding saves refresh the page live (previously stayed on `UNMAPPED` until the user navigated away and back). * Migration 008 enforces the default-space invariant: the seeded `My Space` row is the canonical default; every other row gets `is_default = 0`. Repairs DBs corrupted by the older `if no spaces exist, set_default()` branch (now removed from `SpaceAppService::create`). Signed-off-by: Mohammod Al Amin Ashik --- .../src/commands/workspace_binding.rs | 40 +++++++- .../features/workspaces/WorkspacesPage.tsx | 55 +++++++---- apps/desktop/src/lib/api/workspaceBindings.ts | 12 +++ crates/mcpmux-core/src/application/space.rs | 11 +-- .../src/consumers/mcp_notifier.rs | 31 ++++-- .../src/services/meta_tools/mod.rs | 5 +- .../src/services/meta_tools/tools.rs | 96 ++++++++----------- crates/mcpmux-storage/src/database.rs | 5 + .../008_canonical_default_space.sql | 41 ++++++++ tests/rust/tests/integration/meta_tools.rs | 33 +++++-- 10 files changed, 232 insertions(+), 97 deletions(-) create mode 100644 crates/mcpmux-storage/src/migrations/008_canonical_default_space.sql diff --git a/apps/desktop/src-tauri/src/commands/workspace_binding.rs b/apps/desktop/src-tauri/src/commands/workspace_binding.rs index 7341ca7..5b620e8 100644 --- a/apps/desktop/src-tauri/src/commands/workspace_binding.rs +++ b/apps/desktop/src-tauri/src/commands/workspace_binding.rs @@ -320,6 +320,17 @@ pub struct EffectiveFeatureDto { pub available: bool, } +/// Per-server total counts in the resolved Space, regardless of the +/// FeatureSet filter. The UI shows badges like "3 / {total}" — the right +/// side is the total the server exposes in the Space, so the user can see +/// "this FS includes 3 of the 10 cloudflare-docs tools available." +#[derive(Debug, Clone, Serialize)] +pub struct ServerFeatureTotalsDto { + pub tools: usize, + pub prompts: usize, + pub resources: usize, +} + /// Top-level DTO: the resolved (Space, FeatureSet) pair for a given root, /// plus its full configured tool/prompt/resource lists with availability. #[derive(Debug, Clone, Serialize)] @@ -342,6 +353,10 @@ pub struct WorkspaceEffectiveFeaturesDto { pub tools: Vec, pub prompts: Vec, pub resources: Vec, + /// `server_id -> totals` over every feature the server exposes in the + /// resolved Space (no FS filter applied). Used by the UI to render + /// "{mapped} / {server total}" badges. + pub server_totals: HashMap, } /// Walk a FeatureSet's members (with nested-FS recursion) to compute the @@ -518,14 +533,32 @@ pub async fn get_workspace_effective_features( let mut visited = HashSet::::new(); collect_member_ids(&fs, &fs_lookup, &mut allowed, &mut excluded, &mut visited); - // 7. Pull every feature in the Space, then keep only those that pass - // the FS filter — without the `is_available` gate, so we can show - // "configured but disconnected" rows. + // 7. Pull every feature in the Space, compute per-server totals (the + // badge denominator), then keep only the FS-filtered subset for the + // rendered list. The `is_available` gate is intentionally not + // applied here — disconnected features still appear, dimmed. let all_features = state .server_feature_repository_core .list_for_space(&space_id.to_string()) .await .map_err(|e| e.to_string())?; + + let mut server_totals: HashMap = HashMap::new(); + for f in &all_features { + let entry = server_totals + .entry(f.server_id.clone()) + .or_insert(ServerFeatureTotalsDto { + tools: 0, + prompts: 0, + resources: 0, + }); + match f.feature_type { + mcpmux_core::FeatureType::Tool => entry.tools += 1, + mcpmux_core::FeatureType::Prompt => entry.prompts += 1, + mcpmux_core::FeatureType::Resource => entry.resources += 1, + } + } + let filtered: Vec = all_features .into_iter() .filter(|f| { @@ -598,5 +631,6 @@ pub async fn get_workspace_effective_features( tools, prompts, resources, + server_totals, }) } diff --git a/apps/desktop/src/features/workspaces/WorkspacesPage.tsx b/apps/desktop/src/features/workspaces/WorkspacesPage.tsx index a036da2..26ad8a3 100644 --- a/apps/desktop/src/features/workspaces/WorkspacesPage.tsx +++ b/apps/desktop/src/features/workspaces/WorkspacesPage.tsx @@ -109,13 +109,21 @@ export function WorkspacesPage() { void loadData().finally(() => setIsLoading(false)); }, [loadData]); - // Refresh the list whenever a session reports (or changes) its roots. + // Refresh whenever something the table reflects changes outside the page: + // • `session-roots-changed` — a connected client newly reported a root. + // • `workspace-binding-changed` — a binding was created/updated/deleted + // by another surface (e.g. the new-workspace popup or the meta-tool). + // Without the binding listener, popup-driven saves leave this page showing + // the stale "UNMAPPED" badge until the user navigates away and back. useEffect(() => { - const un = listen('session-roots-changed', () => { + const reload = () => { void loadData(); - }); + }; + const unRoots = listen('session-roots-changed', reload); + const unBinding = listen('workspace-binding-changed', reload); return () => { - un.then((fn) => fn()); + unRoots.then((fn) => fn()); + unBinding.then((fn) => fn()); }; }, [loadData]); @@ -942,8 +950,12 @@ interface ServerGroup { tools: EffectiveFeature[]; prompts: EffectiveFeature[]; resources: EffectiveFeature[]; - total: number; - unavailable_total: number; + /** Mapped count for this server in the resolved FS (= tools+prompts+resources lengths). */ + mapped: number; + /** Total count of features the server exposes in the resolved Space, regardless of FS. */ + server_total: number; + /** Of `mapped`, how many are unavailable because the server is disconnected. */ + unavailable_mapped: number; } function buildServerGroups(data: WorkspaceEffectiveFeatures): ServerGroup[] { @@ -951,6 +963,10 @@ function buildServerGroups(data: WorkspaceEffectiveFeatures): ServerGroup[] { const place = (item: EffectiveFeature, kind: 'tool' | 'prompt' | 'resource') => { let g = map.get(item.server_id); if (!g) { + const totals = data.server_totals[item.server_id]; + const server_total = totals + ? totals.tools + totals.prompts + totals.resources + : 0; g = { server_id: item.server_id, server_alias: item.server_alias ?? item.server_id, @@ -961,16 +977,17 @@ function buildServerGroups(data: WorkspaceEffectiveFeatures): ServerGroup[] { tools: [], prompts: [], resources: [], - total: 0, - unavailable_total: 0, + mapped: 0, + server_total, + unavailable_mapped: 0, }; map.set(item.server_id, g); } if (kind === 'tool') g.tools.push(item); else if (kind === 'prompt') g.prompts.push(item); else g.resources.push(item); - g.total += 1; - if (!item.available) g.unavailable_total += 1; + g.mapped += 1; + if (!item.available) g.unavailable_mapped += 1; }; for (const t of data.tools) place(t, 'tool'); for (const p of data.prompts) place(p, 'prompt'); @@ -1063,7 +1080,7 @@ function EffectiveFeaturesContent({ const groups = useMemo(() => (data ? buildServerGroups(data) : []), [data]); const totalCount = data ? data.tools.length + data.prompts.length + data.resources.length : 0; const availableCount = useMemo( - () => groups.reduce((acc, g) => acc + (g.total - g.unavailable_total), 0), + () => groups.reduce((acc, g) => acc + (g.mapped - g.unavailable_mapped), 0), [groups] ); @@ -1203,9 +1220,13 @@ function ServerGroupRow({ onToggle: () => void; }) { const issue = serverStatusIssue(group.server_status); - const availableCount = group.total - group.unavailable_total; - const allAvailable = group.total > 0 && availableCount === group.total; - const someAvailable = availableCount > 0 && availableCount < group.total; + const availableCount = group.mapped - group.unavailable_mapped; + // Badge denominator is the server's *total* feature count in the Space, + // not the mapped count — the user wants to see "3 of 10 cloudflare-docs + // tools are in this FS" rather than "3 of 3 mapped tools work". + const denominator = group.server_total > 0 ? group.server_total : group.mapped; + const allAvailable = group.mapped > 0 && availableCount === group.mapped; + const someAvailable = availableCount > 0 && availableCount < group.mapped; const noneAvailable = availableCount === 0; // Strip reverse-DNS prefix so display reads "cloudflare-bindings" not @@ -1252,7 +1273,7 @@ function ServerGroupRow({ : 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 border border-amber-300/70 dark:border-amber-700/70', ].join(' ')} > - {availableCount}/{group.total} + {group.mapped}/{denominator} {issue && ( 0 - ? `${(availableCount / group.total) * 100}%` + group.mapped > 0 + ? `${(availableCount / group.mapped) * 100}%` : '0%', }} /> diff --git a/apps/desktop/src/lib/api/workspaceBindings.ts b/apps/desktop/src/lib/api/workspaceBindings.ts index f50a473..5b3d828 100644 --- a/apps/desktop/src/lib/api/workspaceBindings.ts +++ b/apps/desktop/src/lib/api/workspaceBindings.ts @@ -123,6 +123,16 @@ export interface EffectiveFeature { available: boolean; } +/** + * Per-server total feature counts in the resolved Space, regardless of FS + * filter. The right-hand side of the "{mapped} / {total}" badges. + */ +export interface ServerFeatureTotals { + tools: number; + prompts: number; + resources: number; +} + export interface WorkspaceEffectiveFeatures { workspace_root: string; /** `binding` when a saved WorkspaceBinding matched; `fallback` for the default Space's Default FS. */ @@ -136,6 +146,8 @@ export interface WorkspaceEffectiveFeatures { tools: EffectiveFeature[]; prompts: EffectiveFeature[]; resources: EffectiveFeature[]; + /** `server_id -> totals` for every server installed in the resolved Space. */ + server_totals: Record; } /** diff --git a/crates/mcpmux-core/src/application/space.rs b/crates/mcpmux-core/src/application/space.rs index 36f4d66..797b549 100644 --- a/crates/mcpmux-core/src/application/space.rs +++ b/crates/mcpmux-core/src/application/space.rs @@ -51,6 +51,11 @@ impl SpaceAppService { /// Create a new space /// + /// User-created spaces are NEVER marked as default — the canonical + /// default is the seeded "My Space" row, restored by migration 008 if + /// it goes missing. The previous "first-created becomes default" branch + /// caused durable corruption on installs that hit it. + /// /// Emits: `SpaceCreated` pub async fn create(&self, name: &str, icon: Option) -> Result { let mut space = Space::new(name); @@ -58,12 +63,6 @@ impl SpaceAppService { space = space.with_icon(icon); } - // If no spaces exist, make this one the default - let existing = self.space_repo.list().await?; - if existing.is_empty() { - space = space.set_default(); - } - // Persist self.space_repo.create(&space).await?; diff --git a/crates/mcpmux-gateway/src/consumers/mcp_notifier.rs b/crates/mcpmux-gateway/src/consumers/mcp_notifier.rs index 621c57d..331b5e0 100644 --- a/crates/mcpmux-gateway/src/consumers/mcp_notifier.rs +++ b/crates/mcpmux-gateway/src/consumers/mcp_notifier.rs @@ -484,17 +484,32 @@ impl MCPNotifier { } => { use mcpmux_core::ConnectionStatus; - // Only notify if server disconnected (features unavailable) - // We DO NOT notify on Connect because: - // 1. If it's a new server, ToolsChanged will fire separately if needed - // 2. If it's a reconnect, hashing will handle it - // 3. Most importantly: Client connections trigger auto-connects, which would cause loops - if matches!(status, ConnectionStatus::Disconnected) { + // Disconnect AND reconnect both flip the per-feature + // `is_available` flag, which `get_all_features_for_space` + // filters on — so the content hash actually changes both + // ways. We notify on each so the client's effective tool + // list reflects "configured but unavailable" features + // dropping out (on Disconnect) and coming back in (on + // Connect). `force=false` lets the hash dedup absorb the + // intermediate transient states (Connecting / Refreshing / + // AuthRequired) without spamming. + // + // Loop concern (the old comment): a client `tools/list` + // query that triggers a lazy backend connect would chain + // Connected -> list_changed -> client refetch. Hashing + // breaks that chain on the second iteration: the second + // refetch sees the same hash as the first and dedupes. + let should_notify = matches!( + status, + ConnectionStatus::Connected | ConnectionStatus::Disconnected + ); + if should_notify { info!( server_id = %server_id, space_id = %space_id, status = ?status, - "[MCPNotifier] ServerStatusChanged (Disconnected) - notifying clients to clear features" + "[MCPNotifier] ServerStatusChanged ({:?}) - re-checking effective list", + status, ); self.notify_all_list_changed(space_id, false).await; } else { @@ -502,7 +517,7 @@ impl MCPNotifier { server_id = %server_id, space_id = %space_id, status = ?status, - "[MCPNotifier] ServerStatusChanged - ignoring (not a disconnection)" + "[MCPNotifier] ServerStatusChanged - transient state, no notify" ); } } diff --git a/crates/mcpmux-gateway/src/services/meta_tools/mod.rs b/crates/mcpmux-gateway/src/services/meta_tools/mod.rs index c6ca9e2..e0bca33 100644 --- a/crates/mcpmux-gateway/src/services/meta_tools/mod.rs +++ b/crates/mcpmux-gateway/src/services/meta_tools/mod.rs @@ -77,7 +77,10 @@ pub fn build_default_registry( // Reads — no approval needed. registry.register(Box::new(tools::ListAllToolsTool)); registry.register(Box::new(tools::ListFeatureSetsTool)); - registry.register(Box::new(tools::DescribeResolutionTool)); + // `describe_workspace` also returns the resolution fields the older + // `describe_resolution` tool used to expose — the split was confusing + // for LLMs (two reads with overlapping output) and trimming it shrinks + // the toolbar visible to the caller. registry.register(Box::new(tools::DescribeWorkspaceTool)); // Writes — gated by ApprovalBroker. registry.register(Box::new(tools::CreateFeatureSetTool)); diff --git a/crates/mcpmux-gateway/src/services/meta_tools/tools.rs b/crates/mcpmux-gateway/src/services/meta_tools/tools.rs index f6fda05..060d77a 100644 --- a/crates/mcpmux-gateway/src/services/meta_tools/tools.rs +++ b/crates/mcpmux-gateway/src/services/meta_tools/tools.rs @@ -155,21 +155,30 @@ impl MetaTool for ListFeatureSetsTool { } // --------------------------------------------------------------------------- -// mcpmux_describe_resolution — read +// mcpmux_describe_workspace — read +// +// Single read endpoint that combines: +// • workspace roots reported by the caller +// • the matched WorkspaceBinding (if any) +// • the resolved FeatureSet (id, name, source, tool count) +// — replacing the older two-tool split where `describe_resolution` and +// `describe_workspace` returned overlapping fragments. // --------------------------------------------------------------------------- -pub struct DescribeResolutionTool; +pub struct DescribeWorkspaceTool; #[async_trait] -impl MetaTool for DescribeResolutionTool { +impl MetaTool for DescribeWorkspaceTool { fn name(&self) -> &'static str { - "mcpmux_describe_resolution" + "mcpmux_describe_workspace" } fn description(&self) -> &'static str { - "Explain which FeatureSet the caller is currently resolved to and \ - why (pin | workspace_binding | space_active | deny). Always call \ - this before a write tool so you know the baseline." + "Report everything that determines this caller's effective tool set: \ + the workspace roots declared via MCP `roots`, the matched \ + WorkspaceBinding (if any), and the resolved FeatureSet (id, name, \ + source, tool count). Always call this before a write tool so you \ + know the baseline." } fn input_schema(&self) -> Value { @@ -177,6 +186,24 @@ impl MetaTool for DescribeResolutionTool { } async fn call(&self, call: MetaToolCall<'_>) -> Result { + let space_id = caller_space_id(&call).await?; + let roots = call + .session_id + .and_then(|sid| call.ctx.session_roots.get(sid)) + .unwrap_or_default(); + let matched = if !roots.is_empty() { + call.ctx + .binding_repo + .find_longest_prefix_match(&space_id, &roots) + .await? + } else { + None + }; + + // Walk the resolver too — that's the authoritative answer for "which + // FS would actually apply right now". A binding may exist but the + // caller's resolution chain may still pick something else (e.g. a + // session pin, when those exist again). let resolved = call.ctx.resolver.resolve(call.session_id).await?; let fs_id = resolved.feature_set_id.clone(); let fs_name = if let Some(id) = fs_id.as_deref() { @@ -185,10 +212,10 @@ impl MetaTool for DescribeResolutionTool { None }; let tool_count = if let Some(id) = fs_id.as_deref() { - let space_id = caller_space_id(&call).await?; + let resolved_space = resolved.space_id.unwrap_or(space_id); call.ctx .feature_service - .get_tools_for_grants(&space_id.to_string(), &[id.to_string()]) + .get_tools_for_grants(&resolved_space.to_string(), &[id.to_string()]) .await? .iter() .filter(|f| f.is_available) @@ -196,52 +223,7 @@ impl MetaTool for DescribeResolutionTool { } else { 0 }; - Ok(text_result(json!({ - "feature_set_id": fs_id, - "feature_set_name": fs_name, - "source": resolved.source, - "resolved_tool_count": tool_count, - }))) - } -} - -// --------------------------------------------------------------------------- -// mcpmux_describe_workspace — read -// --------------------------------------------------------------------------- - -pub struct DescribeWorkspaceTool; -#[async_trait] -impl MetaTool for DescribeWorkspaceTool { - fn name(&self) -> &'static str { - "mcpmux_describe_workspace" - } - - fn description(&self) -> &'static str { - "Report the workspace roots the caller declared via the MCP `roots` \ - capability, and any WorkspaceBinding in this Space that matches. \ - Empty roots means the client didn't declare the `roots` capability \ - — bindings won't apply and workspace-based tools should be skipped." - } - - fn input_schema(&self) -> Value { - json!({ "type": "object", "properties": {} }) - } - - async fn call(&self, call: MetaToolCall<'_>) -> Result { - let space_id = caller_space_id(&call).await?; - let roots = call - .session_id - .and_then(|sid| call.ctx.session_roots.get(sid)) - .unwrap_or_default(); - let matched = if !roots.is_empty() { - call.ctx - .binding_repo - .find_longest_prefix_match(&space_id, &roots) - .await? - } else { - None - }; Ok(text_result(json!({ "space_id": space_id, "reported_roots": roots, @@ -251,6 +233,12 @@ impl MetaTool for DescribeWorkspaceTool { "space_id": b.space_id, "feature_set_id": b.feature_set_id, })), + "resolution": { + "feature_set_id": fs_id, + "feature_set_name": fs_name, + "source": resolved.source, + "resolved_tool_count": tool_count, + }, }))) } } diff --git a/crates/mcpmux-storage/src/database.rs b/crates/mcpmux-storage/src/database.rs index 9973db2..588023b 100644 --- a/crates/mcpmux-storage/src/database.rs +++ b/crates/mcpmux-storage/src/database.rs @@ -68,6 +68,11 @@ const MIGRATIONS: &[Migration] = &[ name: "concrete_binding", sql: include_str!("migrations/007_concrete_binding.sql"), }, + Migration { + version: 8, + name: "canonical_default_space", + sql: include_str!("migrations/008_canonical_default_space.sql"), + }, ]; /// SQLite database wrapper. diff --git a/crates/mcpmux-storage/src/migrations/008_canonical_default_space.sql b/crates/mcpmux-storage/src/migrations/008_canonical_default_space.sql new file mode 100644 index 0000000..03ed019 --- /dev/null +++ b/crates/mcpmux-storage/src/migrations/008_canonical_default_space.sql @@ -0,0 +1,41 @@ +-- Migration 008: Repair the default-space invariant. +-- +-- Older code paths (and one bad branch in `SpaceAppService::create`) could +-- promote a user-created space to `is_default = 1` if it happened to be +-- created when no spaces existed. Combined with bare DB edits during early +-- testing, this left some installs with the wrong space marked default +-- (or worse, multiple defaults). The resolver picks "the" default space via +-- a query that returns whichever row SQLite hands back first, so symptoms +-- vary across machines. +-- +-- This migration enforces the invariant: the seeded "My Space" row +-- (id `00000000-0000-0000-0000-000000000001`) is the canonical default, +-- and no other row carries the flag. It's idempotent — running it on a +-- healthy DB is a no-op. + +-- Make sure the canonical row exists. The seed in migration 001 uses +-- `INSERT OR IGNORE`, so a freshly-installed DB already has it. This +-- safety net covers DBs that lost the row through manual editing. +INSERT OR IGNORE INTO spaces + (id, name, icon, description, is_default, sort_order, created_at, updated_at) +VALUES ( + '00000000-0000-0000-0000-000000000001', + 'My Space', + '🏠', + 'Default workspace for your MCP servers', + 1, + 0, + datetime('now'), + datetime('now') +); + +-- Sole-default invariant: clear the flag on every other row first, then +-- set it on the canonical row. Order matters — the inverse would briefly +-- leave the table with two defaults if the canonical row was already flagged. +UPDATE spaces + SET is_default = 0 + WHERE id <> '00000000-0000-0000-0000-000000000001'; + +UPDATE spaces + SET is_default = 1 + WHERE id = '00000000-0000-0000-0000-000000000001'; diff --git a/tests/rust/tests/integration/meta_tools.rs b/tests/rust/tests/integration/meta_tools.rs index 2b42772..5587ca2 100644 --- a/tests/rust/tests/integration/meta_tools.rs +++ b/tests/rust/tests/integration/meta_tools.rs @@ -249,15 +249,17 @@ async fn list_feature_sets_returns_space_contents() { } #[tokio::test(flavor = "multi_thread")] -async fn describe_resolution_reports_default_baseline() { +async fn describe_workspace_reports_default_resolution() { // With no bindings and no reported roots, the resolver falls through // to the Default tier and returns the space's auto-seeded - // `fs_default_` FS. + // `fs_default_` FS. `describe_workspace` surfaces this in its + // `resolution` block (the field used to live on a separate + // `describe_resolution` tool that we collapsed into this one). let f = Fixture::new().await; let result = f .registry .call( - "mcpmux_describe_resolution", + "mcpmux_describe_workspace", &f.client_id, Some(&f.session_id), json!({}), @@ -265,8 +267,12 @@ async fn describe_resolution_reports_default_baseline() { .await .unwrap(); let body = Fixture::result_json(&result); - assert_eq!(body.get("source").unwrap().as_str().unwrap(), "default"); - let fs_id = body.get("feature_set_id").unwrap().as_str().unwrap(); + let resolution = body.get("resolution").unwrap(); + assert_eq!( + resolution.get("source").unwrap().as_str().unwrap(), + "default" + ); + let fs_id = resolution.get("feature_set_id").unwrap().as_str().unwrap(); assert!( fs_id.starts_with("fs_default_"), "default tier should surface the Default FS, got {fs_id}" @@ -297,6 +303,12 @@ async fn describe_workspace_reports_reported_roots() { let roots = body.get("reported_roots").unwrap().as_array().unwrap(); assert_eq!(roots.len(), 1); assert!(body.get("matched_binding").unwrap().is_null()); + // Resolution still reports the default tier — no binding matched. + let resolution = body.get("resolution").unwrap(); + assert_eq!( + resolution.get("source").unwrap().as_str().unwrap(), + "default" + ); } // --------------------------------------------------------------------------- @@ -478,13 +490,18 @@ async fn registry_advertises_every_default_tool_with_annotations() { for expected in [ "mcpmux_list_all_tools", "mcpmux_list_feature_sets", - "mcpmux_describe_resolution", "mcpmux_describe_workspace", "mcpmux_create_feature_set", "mcpmux_bind_current_workspace", ] { assert!(names.iter().any(|n| n == expected), "missing {expected}"); } + // describe_resolution was collapsed into describe_workspace — the + // registry must NOT advertise it any more. + assert!( + !names.iter().any(|n| n == "mcpmux_describe_resolution"), + "describe_resolution should be removed; got {names:?}" + ); // Writes carry the destructive_hint annotation. let bind = tools .iter() @@ -561,7 +578,7 @@ async fn read_tool_emits_meta_tool_invoked_with_decision_read() { registry .call( - "mcpmux_describe_resolution", + "mcpmux_describe_workspace", &client_id, Some("s"), json!({}), @@ -579,7 +596,7 @@ async fn read_tool_emits_meta_tool_invoked_with_decision_read() { decision, .. } => { - assert_eq!(tool_name, "mcpmux_describe_resolution"); + assert_eq!(tool_name, "mcpmux_describe_workspace"); assert_eq!(decision, "read"); } other => panic!("unexpected event: {other:?}"), From 42bfbf984f9673256cd138a034c007d52797260f Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Mon, 27 Apr 2026 15:04:06 +0800 Subject: [PATCH 17/24] fix(gateway): drop mcpmux_describe_workspace meta tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User feedback: the describe_* surface was redundant — `list_all_tools` and `list_feature_sets` already give an LLM enough to introspect the resolved state, and trimming the toolbar reduces visual noise on the client side. * Remove `DescribeWorkspaceTool` from `tools.rs` and its registration in `meta_tools/mod.rs`. * Drop the two `describe_workspace_*` integration tests; the resolver behavior they exercised is already covered by the `feature_set_resolver` integration suite. * Re-target the audit-emission test to `mcpmux_list_all_tools` (still a read tool, same `decision = "read"` audit path). * Update `registry_advertises_every_default_tool_with_annotations` to assert both describe_* tools are NOT advertised. Signed-off-by: Mohammod Al Amin Ashik --- .../src/services/meta_tools/mod.rs | 8 +- .../src/services/meta_tools/tools.rs | 89 ------------------ tests/rust/tests/integration/meta_tools.rs | 90 +++---------------- 3 files changed, 17 insertions(+), 170 deletions(-) diff --git a/crates/mcpmux-gateway/src/services/meta_tools/mod.rs b/crates/mcpmux-gateway/src/services/meta_tools/mod.rs index e0bca33..49c151e 100644 --- a/crates/mcpmux-gateway/src/services/meta_tools/mod.rs +++ b/crates/mcpmux-gateway/src/services/meta_tools/mod.rs @@ -77,11 +77,9 @@ pub fn build_default_registry( // Reads — no approval needed. registry.register(Box::new(tools::ListAllToolsTool)); registry.register(Box::new(tools::ListFeatureSetsTool)); - // `describe_workspace` also returns the resolution fields the older - // `describe_resolution` tool used to expose — the split was confusing - // for LLMs (two reads with overlapping output) and trimming it shrinks - // the toolbar visible to the caller. - registry.register(Box::new(tools::DescribeWorkspaceTool)); + // Both `describe_resolution` and `describe_workspace` were removed by + // user request — the read surface is just the two list_* tools above, + // which an LLM can stitch into the same picture without an extra hop. // Writes — gated by ApprovalBroker. registry.register(Box::new(tools::CreateFeatureSetTool)); registry.register(Box::new(tools::BindCurrentWorkspaceTool)); diff --git a/crates/mcpmux-gateway/src/services/meta_tools/tools.rs b/crates/mcpmux-gateway/src/services/meta_tools/tools.rs index 060d77a..a82919d 100644 --- a/crates/mcpmux-gateway/src/services/meta_tools/tools.rs +++ b/crates/mcpmux-gateway/src/services/meta_tools/tools.rs @@ -154,95 +154,6 @@ impl MetaTool for ListFeatureSetsTool { } } -// --------------------------------------------------------------------------- -// mcpmux_describe_workspace — read -// -// Single read endpoint that combines: -// • workspace roots reported by the caller -// • the matched WorkspaceBinding (if any) -// • the resolved FeatureSet (id, name, source, tool count) -// — replacing the older two-tool split where `describe_resolution` and -// `describe_workspace` returned overlapping fragments. -// --------------------------------------------------------------------------- - -pub struct DescribeWorkspaceTool; - -#[async_trait] -impl MetaTool for DescribeWorkspaceTool { - fn name(&self) -> &'static str { - "mcpmux_describe_workspace" - } - - fn description(&self) -> &'static str { - "Report everything that determines this caller's effective tool set: \ - the workspace roots declared via MCP `roots`, the matched \ - WorkspaceBinding (if any), and the resolved FeatureSet (id, name, \ - source, tool count). Always call this before a write tool so you \ - know the baseline." - } - - fn input_schema(&self) -> Value { - json!({ "type": "object", "properties": {} }) - } - - async fn call(&self, call: MetaToolCall<'_>) -> Result { - let space_id = caller_space_id(&call).await?; - let roots = call - .session_id - .and_then(|sid| call.ctx.session_roots.get(sid)) - .unwrap_or_default(); - let matched = if !roots.is_empty() { - call.ctx - .binding_repo - .find_longest_prefix_match(&space_id, &roots) - .await? - } else { - None - }; - - // Walk the resolver too — that's the authoritative answer for "which - // FS would actually apply right now". A binding may exist but the - // caller's resolution chain may still pick something else (e.g. a - // session pin, when those exist again). - let resolved = call.ctx.resolver.resolve(call.session_id).await?; - let fs_id = resolved.feature_set_id.clone(); - let fs_name = if let Some(id) = fs_id.as_deref() { - call.ctx.feature_set_repo.get(id).await?.map(|fs| fs.name) - } else { - None - }; - let tool_count = if let Some(id) = fs_id.as_deref() { - let resolved_space = resolved.space_id.unwrap_or(space_id); - call.ctx - .feature_service - .get_tools_for_grants(&resolved_space.to_string(), &[id.to_string()]) - .await? - .iter() - .filter(|f| f.is_available) - .count() - } else { - 0 - }; - - Ok(text_result(json!({ - "space_id": space_id, - "reported_roots": roots, - "matched_binding": matched.map(|b| json!({ - "id": b.id, - "workspace_root": b.workspace_root, - "space_id": b.space_id, - "feature_set_id": b.feature_set_id, - })), - "resolution": { - "feature_set_id": fs_id, - "feature_set_name": fs_name, - "source": resolved.source, - "resolved_tool_count": tool_count, - }, - }))) - } -} - // --------------------------------------------------------------------------- // Writes — each goes through the ApprovalBroker before mutating state. // --------------------------------------------------------------------------- diff --git a/tests/rust/tests/integration/meta_tools.rs b/tests/rust/tests/integration/meta_tools.rs index 5587ca2..5245c99 100644 --- a/tests/rust/tests/integration/meta_tools.rs +++ b/tests/rust/tests/integration/meta_tools.rs @@ -248,68 +248,11 @@ async fn list_feature_sets_returns_space_contents() { assert_eq!(sets.len(), 3, "Default + 2 custom expected"); } -#[tokio::test(flavor = "multi_thread")] -async fn describe_workspace_reports_default_resolution() { - // With no bindings and no reported roots, the resolver falls through - // to the Default tier and returns the space's auto-seeded - // `fs_default_` FS. `describe_workspace` surfaces this in its - // `resolution` block (the field used to live on a separate - // `describe_resolution` tool that we collapsed into this one). - let f = Fixture::new().await; - let result = f - .registry - .call( - "mcpmux_describe_workspace", - &f.client_id, - Some(&f.session_id), - json!({}), - ) - .await - .unwrap(); - let body = Fixture::result_json(&result); - let resolution = body.get("resolution").unwrap(); - assert_eq!( - resolution.get("source").unwrap().as_str().unwrap(), - "default" - ); - let fs_id = resolution.get("feature_set_id").unwrap().as_str().unwrap(); - assert!( - fs_id.starts_with("fs_default_"), - "default tier should surface the Default FS, got {fs_id}" - ); -} - -#[tokio::test(flavor = "multi_thread")] -async fn describe_workspace_reports_reported_roots() { - let f = Fixture::new().await; - let path = if cfg!(windows) { - "d:\\android\\myapp" - } else { - "/android/myapp" - }; - f.session_roots.set(&f.session_id, [path]); - - let result = f - .registry - .call( - "mcpmux_describe_workspace", - &f.client_id, - Some(&f.session_id), - json!({}), - ) - .await - .unwrap(); - let body = Fixture::result_json(&result); - let roots = body.get("reported_roots").unwrap().as_array().unwrap(); - assert_eq!(roots.len(), 1); - assert!(body.get("matched_binding").unwrap().is_null()); - // Resolution still reports the default tier — no binding matched. - let resolution = body.get("resolution").unwrap(); - assert_eq!( - resolution.get("source").unwrap().as_str().unwrap(), - "default" - ); -} +// `describe_resolution` and `describe_workspace` were both removed at the +// user's request — the read surface is now just `list_all_tools` and +// `list_feature_sets`. Behavior previously asserted here is covered by +// `FeatureSetResolverService`'s own tests in +// `tests/rust/tests/integration/feature_set_resolver.rs`. // --------------------------------------------------------------------------- // Writes — gated by ApprovalBroker @@ -490,18 +433,18 @@ async fn registry_advertises_every_default_tool_with_annotations() { for expected in [ "mcpmux_list_all_tools", "mcpmux_list_feature_sets", - "mcpmux_describe_workspace", "mcpmux_create_feature_set", "mcpmux_bind_current_workspace", ] { assert!(names.iter().any(|n| n == expected), "missing {expected}"); } - // describe_resolution was collapsed into describe_workspace — the - // registry must NOT advertise it any more. - assert!( - !names.iter().any(|n| n == "mcpmux_describe_resolution"), - "describe_resolution should be removed; got {names:?}" - ); + // Both describe_* tools were removed — they must NOT be advertised. + for removed in ["mcpmux_describe_resolution", "mcpmux_describe_workspace"] { + assert!( + !names.iter().any(|n| n == removed), + "{removed} should be removed; got {names:?}" + ); + } // Writes carry the destructive_hint annotation. let bind = tools .iter() @@ -577,12 +520,7 @@ async fn read_tool_emits_meta_tool_invoked_with_decision_read() { let (registry, client_id, _tx, mut rx) = bare_registry(None).await; registry - .call( - "mcpmux_describe_workspace", - &client_id, - Some("s"), - json!({}), - ) + .call("mcpmux_list_all_tools", &client_id, Some("s"), json!({})) .await .unwrap(); @@ -596,7 +534,7 @@ async fn read_tool_emits_meta_tool_invoked_with_decision_read() { decision, .. } => { - assert_eq!(tool_name, "mcpmux_describe_workspace"); + assert_eq!(tool_name, "mcpmux_list_all_tools"); assert_eq!(decision, "read"); } other => panic!("unexpected event: {other:?}"), From a313ffd0cc27c2d657972c185b2b23af0cc0c1d2 Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Mon, 27 Apr 2026 15:23:09 +0800 Subject: [PATCH 18/24] fix(meta-tools): scope to caller's resolved Space + drop stale pin/active wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs the user surfaced after exercising the live tools: 1. **Wrong space.** All four meta tools (`list_all_tools`, `list_feature_sets`, `create_feature_set`, `bind_current_workspace`) went through `caller_space_id`, which always returned the *default* Space — meaning a client routed via WorkspaceBinding into a non-default Space could still read/write FSes in the default Space. The tools must stay inside the Space the resolver actually picked for that caller, so `caller_space_id` now consults `FeatureSetResolverService::resolve` and uses the resolved `space_id`. Falls back to the default Space when the resolver returns no binding match. 2. **Stale tool descriptions.** Several descriptions still referenced long-gone concepts: * `list_feature_sets` claimed an `is_active` / `is_pinned` field on each entry — neither exists in the response, and both concepts have been removed from the model. Description now lists the actual fields (`id`, `name`, `description`, `type`, `is_builtin`). * `create_feature_set` told callers to follow up with `mcpmux_pin_this_session` or `mcpmux_set_space_active` — neither tool exists. New text points at `mcpmux_bind_current_workspace`, which is the one mechanism that actually makes a FeatureSet take effect. * `list_all_tools` mentioned "before deciding which tools to pin" — trimmed to "before composing a custom FeatureSet". * `bind_current_workspace` removed the "unless they have an explicit pin" carve-out. Signed-off-by: Mohammod Al Amin Ashik --- .../src/services/meta_tools/tools.rs | 63 ++++++++++--------- 1 file changed, 35 insertions(+), 28 deletions(-) diff --git a/crates/mcpmux-gateway/src/services/meta_tools/tools.rs b/crates/mcpmux-gateway/src/services/meta_tools/tools.rs index a82919d..46bbbdc 100644 --- a/crates/mcpmux-gateway/src/services/meta_tools/tools.rs +++ b/crates/mcpmux-gateway/src/services/meta_tools/tools.rs @@ -39,17 +39,25 @@ fn text_result(v: Value) -> CallToolResult { CallToolResult::success(vec![Content::text(v.to_string())]) } -/// Resolve the caller's effective Space id — always the default/active space -/// in the current (no-client-pinning) model. Returns an error only in the -/// pathological "no default space" setup. +/// Resolve the Space the caller is *actually* routed into — i.e. whichever +/// Space the resolver picks via WorkspaceBinding for this session's reported +/// roots, falling back to the default Space when no binding matches. +/// +/// Every meta tool reads (and writes) inside this Space. That keeps the +/// caller's tool/FS view aligned with the tools the gateway actually exposes +/// to them, and prevents an LLM in workspace A from mutating FSes in +/// workspace B just because both sit under the same default-Space-flagged +/// row in the DB. async fn caller_space_id(call: &MetaToolCall<'_>) -> Result { - let default_space = call - .ctx - .space_repo - .get_default() - .await? - .ok_or_else(|| MetaToolError::Internal("no default space".into()))?; - Ok(default_space.id) + let resolved = call.ctx.resolver.resolve(call.session_id).await?; + if let Some(space_id) = resolved.space_id { + return Ok(space_id); + } + // Resolver returned no space — should only happen in the pathological + // "no default space configured" setup. Fail loudly so callers see why. + Err(MetaToolError::Internal( + "no Space resolved for this caller (no default Space configured?)".into(), + )) } // --------------------------------------------------------------------------- @@ -65,10 +73,10 @@ impl MetaTool for ListAllToolsTool { } fn description(&self) -> &'static str { - "List EVERY tool available on every connected MCP server, without the \ - current FeatureSet filter applied. Useful when you want to know what's \ - possible in this workspace before deciding which tools to pin. Returns \ - an array of {server_id, qualified_name, description, available}." + "List every tool installed in the caller's resolved Space, without \ + the current FeatureSet filter applied. Use this to see what the \ + workspace could expose before composing a custom FeatureSet. \ + Returns an array of {server_id, qualified_name, description, available}." } fn input_schema(&self) -> Value { @@ -111,11 +119,10 @@ impl MetaTool for ListFeatureSetsTool { } fn description(&self) -> &'static str { - "List every FeatureSet in the caller's Space — built-ins and custom. \ - Each entry carries `id`, `name`, `type`, `is_active` (the one that \ - applies when no pin/binding matches), and `is_pinned` (this caller's \ - current pin). Use before proposing a pin so you don't recreate one \ - that already fits." + "List every FeatureSet defined in the caller's resolved Space — \ + built-ins and custom. Each entry carries `id`, `name`, `description`, \ + `type`, and `is_builtin`. Use before composing a new FeatureSet so \ + you don't recreate one that already fits." } fn input_schema(&self) -> Value { @@ -211,11 +218,11 @@ impl MetaTool for CreateFeatureSetTool { } fn description(&self) -> &'static str { - "Create a new custom FeatureSet in the caller's Space from an explicit \ - list of qualified tool names (e.g. ['github_create_issue', \ - 'firebase_deploy']). Returns the new FS id; does NOT activate it — \ - call mcpmux_pin_this_session or mcpmux_set_space_active separately \ - so the user sees the activation dialog distinct from creation." + "Create a new custom FeatureSet in the caller's resolved Space from \ + an explicit list of qualified tool names (e.g. ['github_create_issue', \ + 'firebase_deploy']). Returns the new FS id. To make a workspace \ + actually route through this FeatureSet, follow up with \ + `mcpmux_bind_current_workspace`." } fn input_schema(&self) -> Value { @@ -338,10 +345,10 @@ impl MetaTool for BindCurrentWorkspaceTool { fn description(&self) -> &'static str { "Persistently bind the caller's first reported workspace root to the \ - given FeatureSet. Every future connection in this Space that reports \ - the same root (or a subdirectory) will resolve to this FeatureSet \ - unless they have an explicit pin. Requires user approval and the \ - calling client MUST have declared MCP roots." + given FeatureSet inside the caller's resolved Space. Every future \ + connection that reports the same root (or a subdirectory) will \ + resolve to this FeatureSet. Requires user approval and the calling \ + client MUST have declared MCP roots." } fn input_schema(&self) -> Value { From 98bceb82a951261b68898d18de002731fb990413 Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Wed, 29 Apr 2026 07:52:36 +0800 Subject: [PATCH 19/24] feat(routing): per-client grants, multi-FS bindings, capability-branched resolver Replaces the resolver's permissive Tier-2 fallback (which silently routed unbound sessions through the default Space's Default FeatureSet) with a capability-branched four-tier model. Roots-capable sessions route via WorkspaceBinding or pend; rootless clients route via per-client grants restored from the pre-resolver-v2 design; everything else denies. Resolver (crates/mcpmux-gateway/src/services/feature_set_resolver.rs): Tier 1 roots reported + binding match -> binding.feature_set_ids Tier 1b roots reported + no binding -> Deny + WorkspaceNeedsBinding Tier 1c declared roots, none yet -> PendingRoots (empty) Tier 2 client declared rootless -> client_grants for (client, space) Tier 3 no signal -> Deny ResolvedFeatureSet now carries Vec so multi-FS bindings and multi-grant clients fan into a single union allow set. fingerprint() gives change-detection a stable key. Storage: 009 restore client_grants table (junction client_id x space x FS) 010 inbound_clients.reports_roots 011 inbound_clients.roots_capability_known (tri-state UI) 012 workspace_binding_feature_sets junction; recreate workspace_bindings without the legacy single feature_set_id column 013 feature_set_type 'default' -> 'starter' 014 rewrite the auto-seeded Starter FS's stale 'Default' / 'fallback feature set for this space' copy (only when row still matches the seed exactly so renamed FSes are untouched) Notifier (mcp_notifier.rs): client_peers -> sessions, keyed on mcp-session-id. Fanout consults the same FeatureSetResolverService the request handlers use, so a session redirected to a non-default Space via a binding is matched correctly. New ClientGrantChanged DomainEvent wired through the GrantService write path; per-peer push covers grant edits without a reconnect. Capability: on_initialized stamps SessionRootsRegistry::set_roots_capable for every session and InboundClientRepository::mark_roots_capability sticky- positively for every client (a one-off rootless reconnect from a normally-rooted client doesn't bounce the badge). The Clients UI hides the per-client grants section entirely except for explicitly- rootless clients. Multi-FS bindings: WorkspaceBinding.feature_set_id (single) -> feature_set_ids: Vec. Tauri create/update commands accept arrays, validate non-empty, and dedup while preserving operator-chosen order. Inspector DTO surfaces the full FS list (binding_id + feature_sets[]); Workspaces page multi-select picker with always-on search and max-h scrolling. Same search treatment ported to the per-client grants list. Default -> Starter rename (FeatureSetType, copy, helper): The 'Default' name implied a routing fallback that no longer exists. Renamed throughout (DB + enum + helpers + UI). The id prefix fs_default_ is preserved for FK stability. parse('default') still resolves to Starter so a stale read during the migration window is harmless; isStarterFeatureSet() helper accepts both. Verified: cargo check --workspace, cargo clippy --workspace --all-targets -- -D warnings, pnpm typecheck. Signed-off-by: Mohammod Al Amin Ashik --- .../desktop/src-tauri/src/commands/gateway.rs | 14 + apps/desktop/src-tauri/src/commands/oauth.rs | 123 ++++++ .../src/commands/workspace_binding.rs | 157 +++++--- apps/desktop/src-tauri/src/lib.rs | 4 + .../src/features/clients/ClientsPage.tsx | 381 +++++++++++++++++- .../features/featuresets/FeatureSetPanel.tsx | 33 +- .../features/featuresets/FeatureSetsPage.tsx | 34 +- .../workspaces/WorkspaceBindingSheet.tsx | 30 +- .../features/workspaces/WorkspacesPage.tsx | 249 ++++++++++-- apps/desktop/src/lib/api/featureSets.ts | 20 +- apps/desktop/src/lib/api/gateway.ts | 74 ++++ apps/desktop/src/lib/api/workspaceBindings.ts | 39 +- crates/mcpmux-core/src/domain/event.rs | 17 + crates/mcpmux-core/src/domain/feature_set.rs | 91 +++-- .../src/domain/workspace_binding.rs | 40 +- crates/mcpmux-core/src/repository/mod.rs | 13 +- .../src/consumers/mcp_notifier.rs | 331 ++++++++------- crates/mcpmux-gateway/src/mcp/handler.rs | 136 +++++-- crates/mcpmux-gateway/src/oauth/dcr.rs | 4 + crates/mcpmux-gateway/src/server/mod.rs | 4 +- .../src/server/service_container.rs | 3 +- .../src/services/authorization.rs | 42 +- .../src/services/client_metadata_service.rs | 5 + .../src/services/feature_set_resolver.rs | 217 +++++++--- .../src/services/grant_service.rs | 110 ++++- .../src/services/meta_tools/tools.rs | 6 +- .../src/services/session_roots.rs | 25 ++ crates/mcpmux-storage/src/database.rs | 30 ++ .../migrations/009_restore_client_grants.sql | 29 ++ .../010_inbound_client_reports_roots.sql | 15 + ..._inbound_client_roots_capability_known.sql | 25 ++ .../012_workspace_binding_feature_sets.sql | 55 +++ .../013_rename_default_to_starter.sql | 18 + .../014_rewrite_starter_seed_copy.sql | 22 + .../repositories/feature_set_repository.rs | 42 +- .../repositories/inbound_client_repository.rs | 150 ++++++- .../src/repositories/space_repository.rs | 10 +- .../workspace_binding_repository.rs | 241 +++++++++-- tests/rust/src/lib.rs | 6 +- tests/rust/src/mocks.rs | 8 +- tests/rust/tests/database/feature_set.rs | 32 +- tests/rust/tests/database/inbound_client.rs | 2 + .../tests/integration/feature_set_resolver.rs | 187 +++++++-- tests/rust/tests/integration/meta_tools.rs | 19 +- .../integration/workspace_binding_events.rs | 77 ++-- .../streamable_http/gateway_notifications.rs | 3 + 46 files changed, 2549 insertions(+), 624 deletions(-) create mode 100644 crates/mcpmux-storage/src/migrations/009_restore_client_grants.sql create mode 100644 crates/mcpmux-storage/src/migrations/010_inbound_client_reports_roots.sql create mode 100644 crates/mcpmux-storage/src/migrations/011_inbound_client_roots_capability_known.sql create mode 100644 crates/mcpmux-storage/src/migrations/012_workspace_binding_feature_sets.sql create mode 100644 crates/mcpmux-storage/src/migrations/013_rename_default_to_starter.sql create mode 100644 crates/mcpmux-storage/src/migrations/014_rewrite_starter_seed_copy.sql diff --git a/apps/desktop/src-tauri/src/commands/gateway.rs b/apps/desktop/src-tauri/src/commands/gateway.rs index 0b99d54..9c04632 100644 --- a/apps/desktop/src-tauri/src/commands/gateway.rs +++ b/apps/desktop/src-tauri/src/commands/gateway.rs @@ -685,6 +685,20 @@ fn map_domain_event_to_ui(event: &DomainEvent) -> (&'static str, serde_json::Val "workspace_root": workspace_root, }), ), + + // Per-client grant edited — Clients page re-fetches the toggles for + // the affected client. MCPNotifier handles the corresponding + // `list_changed` push to the client's open peers separately. + DomainEvent::ClientGrantChanged { + client_id, + space_id, + } => ( + "client-grant-changed", + serde_json::json!({ + "client_id": client_id, + "space_id": space_id, + }), + ), } } diff --git a/apps/desktop/src-tauri/src/commands/oauth.rs b/apps/desktop/src-tauri/src/commands/oauth.rs index 3ab9d93..820e8ea 100644 --- a/apps/desktop/src-tauri/src/commands/oauth.rs +++ b/apps/desktop/src-tauri/src/commands/oauth.rs @@ -692,6 +692,8 @@ pub async fn get_oauth_clients( metadata_cache_ttl: client.metadata_cache_ttl, last_seen: client.last_seen, created_at: client.created_at, + reports_roots: client.reports_roots, + roots_capability_known: client.roots_capability_known, }) .collect(); @@ -762,6 +764,20 @@ pub struct OAuthClientInfo { pub last_seen: Option, pub created_at: String, + + /// Sticky-positive bit: `true` once any session of this client + /// declared the MCP `roots` capability. Meaningful only when + /// `roots_capability_known` is `true` — for a brand-new client we + /// haven't seen `initialize` for yet, this defaults to `false` but + /// the UI must hide the "Rootless" badge instead of trusting it. + pub reports_roots: bool, + + /// `true` once we've processed at least one `notifications/initialized` + /// for this client. Until then, the UI treats the capability as + /// unknown (no badge). Once known, the badge resolves to either + /// "Reports workspace" (`reports_roots = true`) or "Rootless" + /// (`reports_roots = false`). + pub roots_capability_known: bool, } /// Request to update client settings. @@ -825,6 +841,8 @@ pub async fn update_oauth_client( metadata_cache_ttl: updated_client.metadata_cache_ttl, last_seen: updated_client.last_seen, created_at: updated_client.created_at, + reports_roots: updated_client.reports_roots, + roots_capability_known: updated_client.roots_capability_known, }) } @@ -972,3 +990,108 @@ pub async fn open_url(url: String) -> Result<(), String> { Ok(()) } } + +// ============================================================================ +// Client grants — rootless OAuth-client fallback path. +// +// Roots-capable sessions ignore these grants; the resolver routes them via +// `WorkspaceBinding`. These commands target the older `client_grants` table +// (restored in migration 009) and back the per-client FS toggles in the +// Clients UI. Each write is funnelled through `GrantService` so a +// `ClientGrantChanged` domain event fires + MCPNotifier pushes +// `list_changed` to that client's open peers. +// ============================================================================ + +/// Read the FeatureSet ids granted to a (client, space) pair. +/// +/// Returns an empty Vec when nothing is granted — the UI renders the +/// "no defaults configured" state in that case. The default-FS layering +/// from older revisions is *not* applied here: the resolver itself decides +/// what an unconfigured grant means (deny when rootless), and the UI shows +/// the literal grant set so the user can see exactly what they configured. +#[tauri::command] +pub async fn get_oauth_client_grants( + gateway_state: State<'_, Arc>>, + client_id: String, + space_id: String, +) -> Result, String> { + let gw_state = gateway_state.read().await; + let Some(ref grant_service) = gw_state.grant_service else { + return Err("Gateway not running".to_string()); + }; + grant_service + .get_grants_for_space(&client_id, &space_id) + .await + .map_err(|e| format!("Failed to get grants: {}", e)) +} + +/// Grant a feature set to an OAuth client in a specific space. +/// Idempotent at the DB layer; always emits `ClientGrantChanged`. +#[tauri::command] +pub async fn grant_oauth_client_feature_set( + app_handle: tauri::AppHandle, + gateway_state: State<'_, Arc>>, + client_id: String, + space_id: String, + feature_set_id: String, +) -> Result<(), String> { + info!( + "[OAuth] grant_oauth_client_feature_set: client_id={}, space_id={}, feature_set_id={}", + client_id, space_id, feature_set_id + ); + + let gw_state = gateway_state.read().await; + let Some(ref grant_service) = gw_state.grant_service else { + error!("[OAuth] Grant service unavailable (gateway not running)"); + return Err("Gateway not running".to_string()); + }; + + grant_service + .grant_feature_set(&client_id, &space_id, &feature_set_id) + .await + .map_err(|e| format!("Failed to grant feature set: {}", e))?; + + if let Err(e) = app_handle.emit( + "oauth-client-changed", + serde_json::json!({ + "action": "grants_updated", + "client_id": client_id, + }), + ) { + error!("[OAuth] Failed to emit oauth-client-changed event: {}", e); + } + + Ok(()) +} + +/// Revoke a feature set from an OAuth client in a specific space. +#[tauri::command] +pub async fn revoke_oauth_client_feature_set( + app_handle: tauri::AppHandle, + gateway_state: State<'_, Arc>>, + client_id: String, + space_id: String, + feature_set_id: String, +) -> Result<(), String> { + let gw_state = gateway_state.read().await; + let Some(ref grant_service) = gw_state.grant_service else { + return Err("Gateway not running".to_string()); + }; + + grant_service + .revoke_feature_set(&client_id, &space_id, &feature_set_id) + .await + .map_err(|e| format!("Failed to revoke feature set: {}", e))?; + + if let Err(e) = app_handle.emit( + "oauth-client-changed", + serde_json::json!({ + "action": "grants_updated", + "client_id": client_id, + }), + ) { + error!("[OAuth] Failed to emit oauth-client-changed event: {}", e); + } + + Ok(()) +} diff --git a/apps/desktop/src-tauri/src/commands/workspace_binding.rs b/apps/desktop/src-tauri/src/commands/workspace_binding.rs index 5b620e8..2d5a083 100644 --- a/apps/desktop/src-tauri/src/commands/workspace_binding.rs +++ b/apps/desktop/src-tauri/src/commands/workspace_binding.rs @@ -46,12 +46,16 @@ async fn emit_binding_changed( } /// DTO returned to the React layer. +/// +/// `feature_set_ids` is non-empty by construction — empty bindings are +/// rejected at the create/update commands. Order is the operator-chosen +/// rendering order; the resolver treats the list as a set. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WorkspaceBindingDto { pub id: String, pub workspace_root: String, pub space_id: String, - pub feature_set_id: String, + pub feature_set_ids: Vec, pub created_at: String, pub updated_at: String, } @@ -62,33 +66,46 @@ impl From for WorkspaceBindingDto { id: b.id.to_string(), workspace_root: b.workspace_root, space_id: b.space_id.to_string(), - feature_set_id: b.feature_set_id, + feature_set_ids: b.feature_set_ids, created_at: b.created_at.to_rfc3339(), updated_at: b.updated_at.to_rfc3339(), } } } -/// Input for creating or updating a binding. Both `space_id` (UUID) and -/// `feature_set_id` (stringy — custom sets use UUIDs, builtins use -/// `fs_default_`) are required. +/// Input for creating or updating a binding. Pass at least one +/// `feature_set_id` in `feature_set_ids` — empty is rejected. +/// +/// Order matters for UI rendering only; the resolver merges them. #[derive(Debug, Deserialize)] pub struct WorkspaceBindingInput { pub workspace_root: String, pub space_id: String, - pub feature_set_id: String, + pub feature_set_ids: Vec, } fn parse_space_id(input: &WorkspaceBindingInput) -> Result { Uuid::parse_str(&input.space_id).map_err(|e| format!("bad space_id: {e}")) } -fn validate_non_empty_fs(input: &WorkspaceBindingInput) -> Result { - if input.feature_set_id.trim().is_empty() { - Err("feature_set_id required".into()) - } else { - Ok(input.feature_set_id.clone()) +fn validate_fs_list(input: &WorkspaceBindingInput) -> Result, String> { + let cleaned: Vec = input + .feature_set_ids + .iter() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + if cleaned.is_empty() { + return Err("at least one feature_set_id is required".into()); } + // Dedup while preserving order so the operator's intent ("primary then + // overlay") survives a duplicate they may have accidentally supplied. + let mut seen = HashSet::new(); + let deduped: Vec = cleaned + .into_iter() + .filter(|id| seen.insert(id.clone())) + .collect(); + Ok(deduped) } /// List every filesystem path connected MCP clients have reported as a @@ -174,10 +191,10 @@ pub async fn create_workspace_binding( gateway_state: State<'_, Arc>>, ) -> Result { let space_id = parse_space_id(&input)?; - let feature_set_id = validate_non_empty_fs(&input)?; + let feature_set_ids = validate_fs_list(&input)?; let normalized = normalize_and_validate(&input.workspace_root)?; - let binding = WorkspaceBinding::new(normalized.clone(), space_id, feature_set_id); + let binding = WorkspaceBinding::new_multi(normalized.clone(), space_id, feature_set_ids); state .workspace_binding_repository @@ -189,7 +206,7 @@ pub async fn create_workspace_binding( binding_id = %binding.id, root = %binding.workspace_root, %space_id, - feature_set_id = %binding.feature_set_id, + feature_sets = ?binding.feature_set_ids, "[workspace_binding] created", ); @@ -213,7 +230,7 @@ pub async fn update_workspace_binding( ) -> Result { let id_uuid = Uuid::parse_str(&id).map_err(|e| e.to_string())?; let space_id = parse_space_id(&input)?; - let feature_set_id = validate_non_empty_fs(&input)?; + let feature_set_ids = validate_fs_list(&input)?; let normalized = normalize_and_validate(&input.workspace_root)?; let existing = state @@ -228,7 +245,7 @@ pub async fn update_workspace_binding( id: existing.id, workspace_root: normalized, space_id, - feature_set_id, + feature_set_ids, created_at: existing.created_at, updated_at: chrono::Utc::now(), }; @@ -331,25 +348,41 @@ pub struct ServerFeatureTotalsDto { pub resources: usize, } -/// Top-level DTO: the resolved (Space, FeatureSet) pair for a given root, -/// plus its full configured tool/prompt/resource lists with availability. +/// One FeatureSet that the binding resolves through. The Workspaces UI +/// renders these as a chip strip ("FS-A + FS-B"); the resolver merges +/// their members into a single allow set. +#[derive(Debug, Clone, Serialize)] +pub struct EffectiveFeatureSetDto { + pub id: String, + pub name: String, + /// `default` | `custom` — matches `FeatureSetType`. + pub feature_set_type: String, +} + +/// Top-level DTO: the resolved (Space, FeatureSet…) for a given root, +/// plus the union of their tool/prompt/resource lists with availability. #[derive(Debug, Clone, Serialize)] pub struct WorkspaceEffectiveFeaturesDto { /// Normalized form of the input root (lower-case drive letter, no /// trailing slash, etc.). pub workspace_root: String, /// `binding` when a `WorkspaceBinding` matched the longest prefix of - /// the root; `fallback` when no binding matched and the resolver fell - /// through to the default Space's Default FS. + /// the root; `unbound` when no binding matched. With the new resolver, + /// `unbound` means a live roots-capable session for this folder would + /// be **denied** — the `feature_sets` field below shows the default + /// Space's Default FS purely as a *preview* of what binding the folder + /// to that FS would expose, not as the active routing target. pub source: String, /// `Some(id)` only when `source == "binding"`. pub binding_id: Option, pub space_id: String, pub space_name: String, - pub feature_set_id: String, - pub feature_set_name: String, - pub feature_set_type: String, - /// Configured features by type (includes unavailable ones). + /// All FeatureSets contributing to the resolved view, in + /// operator-chosen order. Always ≥ 1 entry (resolved or preview). + pub feature_sets: Vec, + /// Configured features (union across all `feature_sets`) by type; + /// includes unavailable ones for the "configured but disconnected" + /// rendering case. pub tools: Vec, pub prompts: Vec, pub resources: Vec, @@ -469,25 +502,30 @@ pub async fn get_workspace_effective_features( .await .map_err(|e| e.to_string())?; - let (source, binding_id, space_id, fs_id) = match binding { + let (source, binding_id, space_id, fs_ids) = match binding { Some(b) => ( "binding".to_string(), Some(b.id.to_string()), b.space_id, - b.feature_set_id, + b.feature_set_ids, ), None => { - let default_fs = state + // Source = `unbound` mirrors the new resolver: a live session + // here would be denied. We still surface the default Space's + // Default FS as a *preview* so the UI can render "if you bound + // this folder to , here's what it would see" — it's + // informational, not the active routing target. + let starter_fs = state .feature_set_repository - .get_default_for_space(&default_space.id.to_string()) + .get_starter_for_space(&default_space.id.to_string()) .await .map_err(|e| e.to_string())? - .ok_or("Default Space has no Default FeatureSet")?; + .ok_or("Default Space has no Starter FeatureSet")?; ( - "fallback".to_string(), + "unbound".to_string(), None, default_space.id, - default_fs.id, + vec![starter_fs.id], ) } }; @@ -499,13 +537,18 @@ pub async fn get_workspace_effective_features( .map_err(|e| e.to_string())? .ok_or("Resolved Space no longer exists")?; - // 4. The FS itself — with members for the walk below. - let fs = state - .feature_set_repository - .get_with_members(&fs_id) - .await - .map_err(|e| e.to_string())? - .ok_or("Resolved FeatureSet not found")?; + // 4. Resolve every FeatureSet the binding points to (preserving order) + // so we can walk their members below for the union allow set. + let mut resolved_sets: Vec = Vec::with_capacity(fs_ids.len()); + for fs_id in &fs_ids { + let fs = state + .feature_set_repository + .get_with_members(fs_id) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Resolved FeatureSet {fs_id} not found"))?; + resolved_sets.push(fs); + } // 5. Pre-fetch every FS in the same Space so nested-FS members can be // resolved without N round trips. Cheap — this is just a metadata @@ -525,13 +568,26 @@ pub async fn get_workspace_effective_features( fs_lookup.insert(full.id.clone(), full); } } - fs_lookup.insert(fs.id.clone(), fs.clone()); + for fs in &resolved_sets { + fs_lookup.insert(fs.id.clone(), fs.clone()); + } - // 6. Walk members → allowed / excluded id sets. + // 6. Walk every FS in the binding → union allow set, union exclude set. + // Excludes win over includes within a single FS (collect_member_ids + // contract); when multiple FSes disagree we keep the include because + // the user's intent for adding the FS to the binding was to surface + // its members. Visiting state is shared across the loop so a nested + // FS shared between two parent FSes is walked once. let mut allowed = HashSet::::new(); let mut excluded = HashSet::::new(); let mut visited = HashSet::::new(); - collect_member_ids(&fs, &fs_lookup, &mut allowed, &mut excluded, &mut visited); + for fs in &resolved_sets { + collect_member_ids(fs, &fs_lookup, &mut allowed, &mut excluded, &mut visited); + } + // Cross-FS exclude → include resolution: if any FS lists the feature as + // an explicit include, override an exclude from a sibling FS. This is + // the operator-friendly default — adding an FS is additive. + excluded.retain(|id| !allowed.contains(id)); // 7. Pull every feature in the Space, compute per-server totals (the // badge denominator), then keep only the FS-filtered subset for the @@ -614,10 +670,17 @@ pub async fn get_workspace_effective_features( prompts.sort_by_key(sort_key); resources.sort_by_key(sort_key); - let feature_set_type = match fs.feature_set_type { - FeatureSetType::Default => "default", - FeatureSetType::Custom => "custom", - }; + let feature_sets: Vec = resolved_sets + .into_iter() + .map(|fs| EffectiveFeatureSetDto { + id: fs.id, + name: fs.name, + feature_set_type: match fs.feature_set_type { + FeatureSetType::Starter => "starter".to_string(), + FeatureSetType::Custom => "custom".to_string(), + }, + }) + .collect(); Ok(WorkspaceEffectiveFeaturesDto { workspace_root: normalized, @@ -625,9 +688,7 @@ pub async fn get_workspace_effective_features( binding_id, space_id: space_id.to_string(), space_name: space.name, - feature_set_id: fs.id, - feature_set_name: fs.name, - feature_set_type: feature_set_type.to_string(), + feature_sets, tools, prompts, resources, diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 0e26f6d..08c7d44 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -947,6 +947,10 @@ pub fn run() { commands::update_oauth_client, commands::delete_oauth_client, commands::open_url, + // Per-client grants for the rootless fallback path + commands::get_oauth_client_grants, + commands::grant_oauth_client_feature_set, + commands::revoke_oauth_client_feature_set, // Server Manager commands (event-driven v2) commands::get_server_statuses, commands::enable_server_v2, diff --git a/apps/desktop/src/features/clients/ClientsPage.tsx b/apps/desktop/src/features/clients/ClientsPage.tsx index 61d89d6..5b33ccc 100644 --- a/apps/desktop/src/features/clients/ClientsPage.tsx +++ b/apps/desktop/src/features/clients/ClientsPage.tsx @@ -18,6 +18,8 @@ import { Trash2, FolderOpen, Check, + Globe, + ShieldOff, } from 'lucide-react'; import { ConnectIDEs } from '@/components/ConnectIDEs'; import type { GatewayStatus, OAuthClient } from '@/lib/api/gateway'; @@ -26,7 +28,15 @@ import { listOAuthClients, updateOAuthClient, deleteOAuthClient, + getOAuthClientGrants, + grantOAuthClientFeatureSet, + revokeOAuthClientFeatureSet, } from '@/lib/api/gateway'; +import { + isStarterFeatureSet, + listFeatureSetsBySpace, + type FeatureSet, +} from '@/lib/api/featureSets'; import { Card, CardContent, @@ -36,6 +46,7 @@ import { useConfirm, } from '@mcpmux/ui'; import { + useDefaultSpace, useNavigateTo, usePendingClientId, useSetPendingClientId, @@ -117,6 +128,7 @@ export default function ClientsPage() { const pendingClientId = usePendingClientId(); const setPendingClientId = useSetPendingClientId(); const navigateTo = useNavigateTo(); + const defaultSpace = useDefaultSpace(); const loadClients = async () => { setIsLoading(true); @@ -363,11 +375,10 @@ export default function ClientsPage() { /> Last seen {formatLastSeen(client.last_seen)} - {client.software_version && ( - - v{client.software_version} - - )} +
    @@ -389,6 +400,7 @@ export default function ClientsPage() { editAlias={editAlias} setEditAlias={setEditAlias} isSaving={isSaving} + defaultSpaceId={defaultSpace?.id ?? null} onClose={() => setSelected(null)} onSaveAlias={handleSaveAlias} onRevoke={() => handleRevoke(selected)} @@ -396,6 +408,8 @@ export default function ClientsPage() { setSelected(null); navigateTo('workspaces'); }} + onToastError={showError} + onToastSuccess={success} /> )} @@ -414,6 +428,57 @@ function lastSeenDotColor(lastSeen: string | null, now: number): string { return 'bg-gray-400'; } +/** + * Tri-state capability chip: shows nothing until the gateway has actually + * observed this client's `initialize` (so a brand-new client doesn't + * misleadingly look "Rootless" before we know which it is). Once we've + * processed at least one session the chip resolves to: + * - **Reports workspace** (green) — the client declared MCP `roots`, + * routing flows through Workspace bindings, per-client grants are a + * rare-case fallback only. + * - **Rootless** (amber) — the client explicitly does NOT declare the + * `roots` capability (Claude.ai web, ChatGPT connectors, …); the + * per-client grant list below is the routing source. + * + * Sticky-positive: once a client has been seen reporting roots we keep + * the green badge across reconnects so a one-off rootless session doesn't + * flip the UI to amber. + */ +function CapabilityBadge({ + reportsRoots, + rootsCapabilityKnown, +}: { + reportsRoots: boolean; + rootsCapabilityKnown: boolean; +}) { + if (!rootsCapabilityKnown) { + // Unknown — hide the badge entirely. Returning null keeps adjacent + // layout stable (the panel header + the grants section both render + // their own context, so we don't need a placeholder). + return null; + } + if (reportsRoots) { + return ( + + + Reports workspace + + ); + } + return ( + + + Rootless + + ); +} + // --------------------------------------------------------------------------- // Side panel // --------------------------------------------------------------------------- @@ -423,10 +488,13 @@ interface SidePanelProps { editAlias: string; setEditAlias: (v: string) => void; isSaving: boolean; + defaultSpaceId: string | null; onClose: () => void; onSaveAlias: () => void; onRevoke: () => void; onOpenWorkspaces: () => void; + onToastError: (title: string, body?: string) => void; + onToastSuccess: (title: string, body?: string) => void; } function SidePanel({ @@ -434,10 +502,13 @@ function SidePanel({ editAlias, setEditAlias, isSaving, + defaultSpaceId, onClose, onSaveAlias, onRevoke, onOpenWorkspaces, + onToastError, + onToastSuccess, }: SidePanelProps) { const aliasDirty = (client.client_alias || '') !== editAlias; @@ -456,9 +527,15 @@ function SidePanel({

    {client.client_alias || client.client_name}

    -

    - {client.client_alias ? client.client_name : client.client_id} -

    +
    +

    + {client.client_alias ? client.client_name : client.client_id} +

    + +
    + ); + }) + )} +
    + {search && filteredFs.length > 0 && filteredFs.length < featureSets.length && ( +
    + {filteredFs.length} of {featureSets.length} shown + {grantedIds.some((id) => !filteredFs.find((f) => f.id === id)) && + ' (granted FSes always visible)'} +
    + )} +
    + )} + + {grantedIds.length === 0 && featureSets.length > 0 && !isLoading && ( +
    + +

    + No defaults set — rootless sessions from this client are denied. + That's the safe default. Pick a FeatureSet above only if + you trust this client to operate without a workspace folder. +

    +
    + )} + + ); +} + function InfoRow({ label, value, diff --git a/apps/desktop/src/features/featuresets/FeatureSetPanel.tsx b/apps/desktop/src/features/featuresets/FeatureSetPanel.tsx index 430595f..b992613 100644 --- a/apps/desktop/src/features/featuresets/FeatureSetPanel.tsx +++ b/apps/desktop/src/features/featuresets/FeatureSetPanel.tsx @@ -21,7 +21,7 @@ import { } from 'lucide-react'; import { Button, useToast, ToastContainer, useConfirm } from '@mcpmux/ui'; import type { FeatureSet, AddMemberInput } from '@/lib/api/featureSets'; -import { setFeatureSetMembers } from '@/lib/api/featureSets'; +import { isStarterFeatureSet, setFeatureSetMembers } from '@/lib/api/featureSets'; import type { ServerFeature } from '@/lib/api/serverFeatures'; import { listServerFeatures } from '@/lib/api/serverFeatures'; @@ -58,7 +58,9 @@ export function FeatureSetPanel({ featureSet, spaceId, onClose, onDelete, onUpda // Both FS types are member-driven now. const isConfigurable = true; - const isDefault = featureSet.feature_set_type === 'default'; + // The auto-seeded "Starter" FS is treated identically to a Custom one + // — the type tag is a UI hint, not a routing flag. + const isStarter = isStarterFeatureSet(featureSet); const isCustom = featureSet.feature_set_type === 'custom'; const getActualMemberCount = () => selectedFeatureIds.size; @@ -252,14 +254,21 @@ export function FeatureSetPanel({ featureSet, spaceId, onClose, onDelete, onUpda {featureSet.name}
    - - {featureSet.feature_set_type.toUpperCase()} + + {isStarter ? 'STARTER' : featureSet.feature_set_type.toUpperCase()} ID: {featureSet.id} @@ -325,12 +334,12 @@ export function FeatureSetPanel({ featureSet, spaceId, onClose, onDelete, onUpda

    - {isDefault && ( + {isStarter && (
    - Default Feature Set: Features selected here are automatically granted to all clients in this workspace. + Starter FeatureSet: auto-created with this Space. It's an ordinary FeatureSet — edit, rename, or delete it freely. No special routing role: Workspace bindings and per-client grants pick FeatureSets explicitly.
    diff --git a/apps/desktop/src/features/featuresets/FeatureSetsPage.tsx b/apps/desktop/src/features/featuresets/FeatureSetsPage.tsx index f2f8697..363eb94 100644 --- a/apps/desktop/src/features/featuresets/FeatureSetsPage.tsx +++ b/apps/desktop/src/features/featuresets/FeatureSetsPage.tsx @@ -27,6 +27,7 @@ import { createFeatureSet, deleteFeatureSet, getFeatureSetWithMembers, + isStarterFeatureSet, } from '@/lib/api/featureSets'; import { useViewSpace } from '@/stores'; import { FeatureSetPanel } from './FeatureSetPanel'; @@ -36,7 +37,8 @@ const getFeatureSetIcon = (fs: FeatureSet) => { if (fs.icon) return {fs.icon}; switch (fs.feature_set_type) { - case 'default': + case 'starter': + case 'default': // legacy alias — pre-migration-013 reads still parse here return ; case 'custom': default: @@ -44,11 +46,14 @@ const getFeatureSetIcon = (fs: FeatureSet) => { } }; -// Get display name for feature set type +// Get display name for feature set type. The 'default' alias is kept on +// the read path so a stale row from before migration 013 still renders +// the right pill — migration 013 rewrites stored values to 'starter'. const getFeatureSetTypeName = (type: string) => { switch (type) { + case 'starter': case 'default': - return 'Default'; + return 'Starter'; case 'custom': default: return 'Custom'; @@ -178,8 +183,15 @@ export function FeatureSetsPage() { ); }) .sort((a, b) => { - // Default FS first (pinned to top), then Custom sets alphabetically - const order: Record = { default: 0, custom: 1 }; + // Starter FS first (pinned to top — operator usually wants the + // auto-seeded one near the top so they can edit / delete it + // first), then Custom sets alphabetically. The 'default' key is + // kept so a stale row read pre-migration still sorts correctly. + const order: Record = { + starter: 0, + default: 0, + custom: 1, + }; const aOrder = order[a.feature_set_type] ?? 1; const bOrder = order[b.feature_set_type] ?? 1; if (aOrder !== bOrder) return aOrder - bOrder; @@ -300,7 +312,7 @@ export function FeatureSetsPage() { {filteredSets.map((fs) => { const isSelected = selectedFeatureSet?.id === fs.id; const isBuiltin = fs.is_builtin; - const isDefault = fs.feature_set_type === 'default'; + const isStarter = isStarterFeatureSet(fs); return ( handleOpenPanel(fs)} data-testid={`featureset-card-${fs.id}`} > - {isDefault && ( + {isStarter && (
    - Default + Starter
    )} diff --git a/apps/desktop/src/features/workspaces/WorkspaceBindingSheet.tsx b/apps/desktop/src/features/workspaces/WorkspaceBindingSheet.tsx index a75bd76..a3b7c1f 100644 --- a/apps/desktop/src/features/workspaces/WorkspaceBindingSheet.tsx +++ b/apps/desktop/src/features/workspaces/WorkspaceBindingSheet.tsx @@ -21,7 +21,11 @@ import { listen } from '@tauri-apps/api/event'; import { Check, ChevronDown, FolderOpen, Loader2, Sparkles, X } from 'lucide-react'; import { Button } from '@mcpmux/ui'; import { createWorkspaceBinding } from '@/lib/api/workspaceBindings'; -import { listFeatureSetsBySpace, type FeatureSet } from '@/lib/api/featureSets'; +import { + isStarterFeatureSet, + listFeatureSetsBySpace, + type FeatureSet, +} from '@/lib/api/featureSets'; import { listSpaces, type Space } from '@/lib/api/spaces'; interface WorkspaceNeedsBindingPayload { @@ -116,9 +120,10 @@ export function WorkspaceBindingSheet() { if (cancelled) return; const visible = list.filter((fs) => !fs.is_deleted); setFeatureSets(visible); - const defaultFs = - visible.find((fs) => fs.feature_set_type === 'default') ?? visible[0]; - if (defaultFs) setSelectedFsId(defaultFs.id); + // Pre-select the auto-seeded Starter as a sensible default in + // the sheet — operator can change it before approving. + const seedFs = visible.find(isStarterFeatureSet) ?? visible[0]; + if (seedFs) setSelectedFsId(seedFs.id); }) .catch((e) => { if (!cancelled) setError(String(e)); @@ -147,7 +152,9 @@ export function WorkspaceBindingSheet() { await createWorkspaceBinding({ workspace_root: payload.workspace_root, space_id: selectedSpaceId, - feature_set_id: selectedFsId, + // Sheet flow only writes one FS — the multi-FS picker lives in the + // full Workspaces editor. + feature_set_ids: [selectedFsId], }); markSeenAndClose(payload); } catch (e) { @@ -251,7 +258,7 @@ export function WorkspaceBindingSheet() { onSelect={() => setSelectedFsId(fs.id)} title={fs.name} subtitle={fs.description || describeFs(fs)} - badge={fs.feature_set_type === 'default' ? 'builtin' : undefined} + badge={isStarterFeatureSet(fs) ? 'starter' : undefined} /> ))}
    @@ -265,10 +272,13 @@ export function WorkspaceBindingSheet() { {error}
    )} + {/* "Not now" auto-sizes to its label; the primary action takes + the rest of the row. Equal flex-1 columns wrapped the longer + "Remember for this folder" text onto two lines. */}
    diff --git a/apps/desktop/src/features/workspaces/WorkspacesPage.tsx b/apps/desktop/src/features/workspaces/WorkspacesPage.tsx index 26ad8a3..697cdee 100644 --- a/apps/desktop/src/features/workspaces/WorkspacesPage.tsx +++ b/apps/desktop/src/features/workspaces/WorkspacesPage.tsx @@ -43,7 +43,11 @@ import { type WorkspaceBindingInput, type WorkspaceEffectiveFeatures, } from '@/lib/api/workspaceBindings'; -import { listFeatureSets, type FeatureSet } from '@/lib/api/featureSets'; +import { + isStarterFeatureSet, + listFeatureSets, + type FeatureSet, +} from '@/lib/api/featureSets'; import { useSpaces } from '@/stores'; import type { Space } from '@/lib/api/spaces'; @@ -163,7 +167,7 @@ export function WorkspacesPage() { if (!space) return null; const fs = featureSets.find( - (f) => f.space_id === space.id && f.feature_set_type === 'default' + (f) => f.space_id === space.id && isStarterFeatureSet(f) ) ?? null; return { space, fs }; }, [spaces, featureSets]); @@ -218,11 +222,15 @@ export function WorkspacesPage() { if (filter === 'unmapped' && e.kind !== 'unmapped-live') return false; if (!q) return true; const spaceName = e.binding ? spaceById.get(e.binding.space_id)?.name ?? '' : ''; - const fsName = e.binding ? fsById.get(e.binding.feature_set_id)?.name ?? '' : ''; + const fsNames = e.binding + ? e.binding.feature_set_ids + .map((id) => fsById.get(id)?.name ?? '') + .join(' ') + : ''; return ( e.root.toLowerCase().includes(q) || spaceName.toLowerCase().includes(q) || - fsName.toLowerCase().includes(q) + fsNames.toLowerCase().includes(q) ); }); }, [entries, searchQuery, filter, spaceById, fsById]); @@ -374,7 +382,11 @@ export function WorkspacesPage() { ? spaceById.get(entry.binding.space_id)?.name : fallback?.space.name; const resolvedFsName = entry.binding - ? fsById.get(entry.binding.feature_set_id)?.name + ? formatFsList( + entry.binding.feature_set_ids.map( + (id) => fsById.get(id)?.name ?? id + ) + ) : fallback?.fs?.name; return ( n && n.length > 0).join(' + '); +} + function SegmentedFilter({ value, onChange, @@ -553,9 +577,9 @@ function EntryCard({ {!entry.binding && ( - via fallback + unbound )}
    @@ -841,7 +865,11 @@ function InspectorPanel({ ? 'Configure routing for this live workspace.' : isMapped && entry?.binding ? `Routes to ${ - featureSets.find((f) => f.id === entry.binding!.feature_set_id)?.name ?? '—' + formatFsList( + entry.binding!.feature_set_ids.map( + (id) => featureSets.find((f) => f.id === id)?.name ?? id + ) + ) || '—' } in ${ spaces.find((s) => s.id === entry.binding!.space_id)?.name ?? '—' }` @@ -1123,21 +1151,26 @@ function EffectiveFeaturesContent({ Resolves to - {data.feature_set_name} + {formatFsList(data.feature_sets.map((fs) => fs.name)) || '—'} in {data.space_name} - {data.source === 'binding' ? 'binding' : 'fallback'} + {data.source === 'binding' ? 'binding' : 'unbound'}
    @@ -1468,7 +1501,11 @@ function BindingForm({ const rootRef = useRef(null); const [root, setRoot] = useState(initial?.workspace_root ?? prefillRoot ?? ''); const [spaceId, setSpaceId] = useState(initial?.space_id ?? defaultSpaceId); - const [fsId, setFsId] = useState(initial?.feature_set_id ?? ''); + // Multi-FS: a binding may resolve to N FeatureSets (the resolver merges + // their members into one allow set). Order is preserved so the operator + // can rank a "primary" FS first; the resolver itself doesn't care. + const [fsIds, setFsIds] = useState(initial?.feature_set_ids ?? []); + const [fsSearch, setFsSearch] = useState(''); const [submitting, setSubmitting] = useState(false); const isEdit = mode === 'edit'; @@ -1526,19 +1563,48 @@ function BindingForm({ [featureSets, spaceId] ); + // Filter the available FS list by the search query. Search runs against + // name + description, case-insensitive — matches the typeahead expectation + // most operators bring from the FeatureSets editor. + const filteredFs = useMemo(() => { + const q = fsSearch.trim().toLowerCase(); + if (!q) return availableFs; + return availableFs.filter((f) => { + if (f.name.toLowerCase().includes(q)) return true; + if (f.description?.toLowerCase().includes(q)) return true; + return false; + }); + }, [availableFs, fsSearch]); + + // When the Space changes, drop selections that aren't in the new Space's + // FS list. Reseed an empty selection with the default FS so the operator + // doesn't have to click anything for a "single-FS, default" binding. useEffect(() => { - if (availableFs.length === 0) return; - if (!availableFs.some((f) => f.id === fsId)) { - const fallback = - availableFs.find((f) => f.feature_set_type === 'default') ?? availableFs[0]; - setFsId(fallback.id); + if (availableFs.length === 0) { + if (fsIds.length > 0) setFsIds([]); + return; } - }, [availableFs, fsId]); + const validIds = new Set(availableFs.map((f) => f.id)); + const filtered = fsIds.filter((id) => validIds.has(id)); + if (filtered.length === 0) { + const fallback = availableFs.find(isStarterFeatureSet) ?? availableFs[0]; + setFsIds([fallback.id]); + } else if (filtered.length !== fsIds.length) { + setFsIds(filtered); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [availableFs]); + + const toggleFs = (id: string) => { + setFsIds((prev) => + prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id] + ); + }; const canSubmit = !submitting && !!spaceId && - !!fsId && + fsIds.length > 0 && (rootValidation.state === 'ok' || !rootEditable); const handleSubmit = async () => { @@ -1554,8 +1620,8 @@ function BindingForm({ onError('Pick a Space.'); return; } - if (!fsId) { - onError('Pick a feature set.'); + if (fsIds.length === 0) { + onError('Pick at least one feature set.'); return; } setSubmitting(true); @@ -1563,7 +1629,7 @@ function BindingForm({ await onSubmit({ workspace_root: root.trim(), space_id: spaceId, - feature_set_id: fsId, + feature_set_ids: fsIds, }); } catch (e) { onError(e instanceof Error ? e.message : String(e)); @@ -1579,10 +1645,13 @@ function BindingForm({ const savedTimerRef = useRef | null>(null); useEffect(() => { if (!isEdit || !initial) return; + const sameFs = + fsIds.length === initial.feature_set_ids.length && + fsIds.every((id, i) => id === initial.feature_set_ids[i]); const same = root.trim() === initial.workspace_root && spaceId === initial.space_id && - fsId === initial.feature_set_id; + sameFs; if (same) return; if (!canSubmit) return; const seq = ++saveSeqRef.current; @@ -1595,7 +1664,7 @@ function BindingForm({ await onSubmit({ workspace_root: root.trim(), space_id: spaceId, - feature_set_id: fsId, + feature_set_ids: fsIds, }); if (saveSeqRef.current !== seq) return; onSaveStatusChange?.({ kind: 'saved' }); @@ -1618,7 +1687,7 @@ function BindingForm({ initial, root, spaceId, - fsId, + fsIds, canSubmit, onSubmit, onError, @@ -1696,25 +1765,117 @@ function BindingForm({ /> - - ({ - value: f.id, - label: f.feature_set_type === 'default' ? `${f.name} · builtin` : f.name, - icon: f.icon ?? undefined, - }))} - disabled={!spaceId || availableFs.length === 0} - testId="workspace-binding-fs" - /> + 1 + ? `Feature sets (${fsIds.length} selected)` + : 'Feature set' + } + hint="Which tools this folder sees. Pick one or compose several — selected sets union into a single allow list." + > + {!spaceId ? ( +

    + Pick a Space first. +

    + ) : availableFs.length === 0 ? ( +

    + No feature sets in that Space yet. +

    + ) : ( +
    +
    + setFsSearch(e.target.value)} + placeholder={`Search ${availableFs.length} feature set${availableFs.length === 1 ? '' : 's'}…`} + className="w-full px-2.5 py-1.5 text-xs bg-[rgb(var(--surface))] border border-[rgb(var(--border-subtle))] rounded focus:outline-none focus:ring-2 focus:ring-primary-500" + data-testid="workspace-binding-fs-search" + /> +
    +
    + {filteredFs.length === 0 ? ( +

    + No feature sets match “{fsSearch}”. +

    + ) : ( + filteredFs.map((f) => { + const isSelected = fsIds.includes(f.id); + const order = isSelected ? fsIds.indexOf(f.id) + 1 : null; + return ( + + ); + }) + )} +
    + {fsSearch && filteredFs.length > 0 && filteredFs.length < availableFs.length && ( +
    + {filteredFs.length} of {availableFs.length} shown +
    + )} +
    + )}
    {!isEdit && ( diff --git a/apps/desktop/src/lib/api/featureSets.ts b/apps/desktop/src/lib/api/featureSets.ts index 191e740..64bf8e2 100644 --- a/apps/desktop/src/lib/api/featureSets.ts +++ b/apps/desktop/src/lib/api/featureSets.ts @@ -6,7 +6,25 @@ import { invoke } from '@tauri-apps/api/core'; * - `default`: auto-created per Space. Fallback when no WorkspaceBinding matches. * - `custom`: user-defined. */ -export type FeatureSetType = 'default' | 'custom'; +/** + * `starter` is the auto-seeded FS that comes with each Space. It has no + * special routing role under resolver v3 — bindings and per-client grants + * pick FeatureSets explicitly. The legacy `'default'` value is accepted on + * read because migration 013 rewrites stored rows lazily and a stale fetch + * could still surface it; new writes use `'starter'`. + */ +export type FeatureSetType = 'starter' | 'default' | 'custom'; + +/** + * Is this FeatureSet the auto-seeded "Starter" for its Space? Returns + * `true` for both the new `'starter'` value and the legacy `'default'` + * — migration 013 rewrites rows in-place but a stale read could still + * surface the old value. Use this everywhere instead of comparing the + * type literal directly so the transition window is invisible to UI. + */ +export function isStarterFeatureSet(fs: { feature_set_type: FeatureSetType }): boolean { + return fs.feature_set_type === 'starter' || fs.feature_set_type === 'default'; +} /** * Member type in a feature set. diff --git a/apps/desktop/src/lib/api/gateway.ts b/apps/desktop/src/lib/api/gateway.ts index 31cb60e..4c26cf1 100644 --- a/apps/desktop/src/lib/api/gateway.ts +++ b/apps/desktop/src/lib/api/gateway.ts @@ -203,6 +203,23 @@ export interface OAuthClient { last_seen: string | null; created_at: string; + + /** + * Sticky-positive bit: `true` once any session of this client declared + * the MCP `roots` capability. **Only meaningful when + * `roots_capability_known` is `true`** — for clients we haven't observed + * yet, this defaults to `false` and the UI must NOT render "Rootless" + * based on it alone. + */ + reports_roots: boolean; + + /** + * `true` once we've processed `notifications/initialized` for at least + * one session of this client. Until then the capability is **unknown** + * and the UI hides the badge entirely. Once known the badge resolves + * to either "Reports workspace" or "Rootless". + */ + roots_capability_known: boolean; } /** @@ -236,6 +253,63 @@ export async function deleteOAuthClient(clientId: string): Promise { return invoke('delete_oauth_client', { clientId }); } +// ============================================================================= +// Per-client FeatureSet grants (rootless fallback path) +// ============================================================================= +// +// These grants only apply to clients that did NOT declare the MCP `roots` +// capability — Claude.ai web, ChatGPT, and similar rootless connectors. +// Roots-capable desktop clients (Cursor, VS Code, Claude Desktop) route via +// `WorkspaceBinding` and ignore these grants. +// +// Backed by the `client_grants` table (restored in migration 009). Writes +// emit a `ClientGrantChanged` domain event so MCPNotifier pushes +// `notifications/{tools,prompts,resources}/list_changed` to the client's +// connected peers without requiring a reconnect. + +/** + * Read the FeatureSet ids granted to a (client, space) pair. Empty array + * means the rootless fallback would deny — consumer should render the + * "no defaults configured" empty state. + */ +export async function getOAuthClientGrants( + clientId: string, + spaceId: string +): Promise { + return invoke('get_oauth_client_grants', { clientId, spaceId }); +} + +/** + * Grant a FeatureSet to an OAuth client in a space. Idempotent at the DB + * layer; always emits the change event so peers re-fetch. + */ +export async function grantOAuthClientFeatureSet( + clientId: string, + spaceId: string, + featureSetId: string +): Promise { + return invoke('grant_oauth_client_feature_set', { + clientId, + spaceId, + featureSetId, + }); +} + +/** + * Revoke a FeatureSet from an OAuth client in a space. + */ +export async function revokeOAuthClientFeatureSet( + clientId: string, + spaceId: string, + featureSetId: string +): Promise { + return invoke('revoke_oauth_client_feature_set', { + clientId, + spaceId, + featureSetId, + }); +} + /** * Result of bulk server connection. */ diff --git a/apps/desktop/src/lib/api/workspaceBindings.ts b/apps/desktop/src/lib/api/workspaceBindings.ts index 5b3d828..cc23dbf 100644 --- a/apps/desktop/src/lib/api/workspaceBindings.ts +++ b/apps/desktop/src/lib/api/workspaceBindings.ts @@ -1,26 +1,31 @@ import { invoke } from '@tauri-apps/api/core'; /** - * A WorkspaceBinding maps one normalized filesystem path to a concrete - * (Space, FeatureSet) pair. When an MCP session reports a root that matches - * a binding (longest-prefix wins), the resolver hands back the binding's - * `space_id` + `feature_set_id` directly — no "follow active" indirection. + * A WorkspaceBinding maps one normalized filesystem path to one or more + * FeatureSets within a Space. When an MCP session reports a root that + * matches a binding (longest-prefix wins), the resolver hands back the + * binding's `space_id` and the union of `feature_set_ids` — multiple FSes + * compose into a single allow set, no "follow active" indirection. */ export interface WorkspaceBinding { id: string; workspace_root: string; space_id: string; - /** FeatureSet ids are strings (builtins use `fs_default_`, customs use UUIDs). */ - feature_set_id: string; + /** + * Non-empty by construction. Order is the operator-chosen rendering + * order; the resolver treats the list as a set. FeatureSet ids are + * strings (builtins use `fs_default_`, customs use UUIDs). + */ + feature_set_ids: string[]; created_at: string; updated_at: string; } -/** Input payload for create / update. */ +/** Input payload for create / update. `feature_set_ids` must be non-empty. */ export interface WorkspaceBindingInput { workspace_root: string; space_id: string; - feature_set_id: string; + feature_set_ids: string[]; } /** List every binding (sorted by workspace_root). */ @@ -87,7 +92,7 @@ export function toInput(b: WorkspaceBinding): WorkspaceBindingInput { return { workspace_root: b.workspace_root, space_id: b.space_id, - feature_set_id: b.feature_set_id, + feature_set_ids: b.feature_set_ids, }; } @@ -133,16 +138,22 @@ export interface ServerFeatureTotals { resources: number; } +/** One FeatureSet contributing to the resolved view. */ +export interface EffectiveFeatureSetSummary { + id: string; + name: string; + feature_set_type: 'starter' | 'default' | 'custom'; +} + export interface WorkspaceEffectiveFeatures { workspace_root: string; - /** `binding` when a saved WorkspaceBinding matched; `fallback` for the default Space's Default FS. */ - source: 'binding' | 'fallback'; + /** `binding` when a saved WorkspaceBinding matched; `unbound` when no binding matched — the `feature_sets` field previews the default Space's Default FS but a live session here would be denied. */ + source: 'binding' | 'unbound'; binding_id: string | null; space_id: string; space_name: string; - feature_set_id: string; - feature_set_name: string; - feature_set_type: 'default' | 'custom'; + /** All FeatureSets contributing to the resolved view, in operator-chosen order. ≥ 1. */ + feature_sets: EffectiveFeatureSetSummary[]; tools: EffectiveFeature[]; prompts: EffectiveFeature[]; resources: EffectiveFeature[]; diff --git a/crates/mcpmux-core/src/domain/event.rs b/crates/mcpmux-core/src/domain/event.rs index c6466a4..f971ae8 100644 --- a/crates/mcpmux-core/src/domain/event.rs +++ b/crates/mcpmux-core/src/domain/event.rs @@ -284,6 +284,18 @@ pub enum DomainEvent { // ════════════════════════════════════════════════════════════════════════ // CLIENT & GRANTS // ════════════════════════════════════════════════════════════════════════ + /// A client's per-space FeatureSet grants were added, removed, or + /// replaced wholesale. MCPNotifier listens and pushes + /// `notifications/{tools,prompts,resources}/list_changed` to every + /// peer registered under this `client_id` so they re-fetch under the + /// new permission set. + /// + /// Used only by the rootless-client fallback path (the resolver consults + /// `client_grants` when a session has no roots and the client did not + /// declare the MCP `roots` capability). Roots-capable sessions ignore + /// these grants and continue to route via `WorkspaceBinding`. + ClientGrantChanged { client_id: String, space_id: Uuid }, + /// An MCP client was registered (Cursor, VS Code, etc.) ClientRegistered { client_id: String, @@ -408,6 +420,7 @@ impl DomainEvent { Self::FeatureSetUpdated { .. } => "feature_set_updated", Self::FeatureSetDeleted { .. } => "feature_set_deleted", Self::FeatureSetMembersChanged { .. } => "feature_set_members_changed", + Self::ClientGrantChanged { .. } => "client_grant_changed", Self::ClientRegistered { .. } => "client_registered", Self::ClientReconnected { .. } => "client_reconnected", Self::ClientUpdated { .. } => "client_updated", @@ -441,6 +454,8 @@ impl DomainEvent { Self::ServerFeaturesRefreshed { .. } => true, // Feature set member changes affect granted capabilities Self::FeatureSetMembersChanged { .. } => true, + // Per-client grant changes affect what rootless sessions see + Self::ClientGrantChanged { .. } => true, // Backend server notifications Self::ToolsChanged { .. } | Self::PromptsChanged { .. } @@ -472,6 +487,7 @@ impl DomainEvent { | Self::FeatureSetUpdated { space_id, .. } | Self::FeatureSetDeleted { space_id, .. } | Self::FeatureSetMembersChanged { space_id, .. } + | Self::ClientGrantChanged { space_id, .. } | Self::ToolsChanged { space_id, .. } | Self::PromptsChanged { space_id, .. } | Self::ResourcesChanged { space_id, .. } @@ -517,6 +533,7 @@ impl DomainEvent { | Self::ClientUpdated { client_id, .. } | Self::ClientDeleted { client_id, .. } | Self::ClientTokenIssued { client_id, .. } + | Self::ClientGrantChanged { client_id, .. } | Self::WorkspaceNeedsBinding { client_id, .. } => Some(client_id), _ => None, } diff --git a/crates/mcpmux-core/src/domain/feature_set.rs b/crates/mcpmux-core/src/domain/feature_set.rs index cfe8d28..53bd950 100644 --- a/crates/mcpmux-core/src/domain/feature_set.rs +++ b/crates/mcpmux-core/src/domain/feature_set.rs @@ -1,8 +1,13 @@ //! FeatureSet entity - permission bundles for tools/prompts/resources //! -//! Each featureset is scoped to a space and is one of two types: -//! - Default: auto-created per space; the fallback when no workspace binding applies -//! - Custom: user-defined composition of features and other featuresets +//! Each FeatureSet is scoped to a space and is one of two types: +//! - **Starter**: auto-created with the Space as a convenient starting +//! point. Has no special routing role under the resolver — bindings and +//! per-client grants pick FeatureSets explicitly. Pre-resolver-v3 this +//! was the "Default" type and acted as the implicit fallback; that +//! behaviour is gone, and the rename reflects the type's actual job +//! (a seed you can rename, edit, or delete freely). +//! - **Custom**: any other operator-defined FeatureSet. use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; @@ -10,15 +15,18 @@ use uuid::Uuid; /// The type of a FeatureSet. /// -/// `Default` is auto-created once per space and acts as the no-binding -/// fallback. `Custom` sets are always user-created. +/// `Starter` is auto-created once per Space; `Custom` covers everything +/// else. Routing-wise the two are interchangeable — the type tag is +/// purely a UI affordance ("this one came pre-seeded with the Space"). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] #[derive(Default)] pub enum FeatureSetType { - /// Auto-created per space. The fallback FS when no WorkspaceBinding matches. - Default, - /// User-defined featureset. + /// Auto-created with the Space. Editable / deletable like any other + /// FS — no special routing semantics. Was historically called + /// `Default` (DB column value carried over via migration 013). + Starter, + /// Any operator-defined FeatureSet. #[default] Custom, } @@ -26,14 +34,18 @@ pub enum FeatureSetType { impl FeatureSetType { pub fn as_str(&self) -> &'static str { match self { - Self::Default => "default", + Self::Starter => "starter", Self::Custom => "custom", } } pub fn parse(s: &str) -> Option { match s { - "default" => Some(Self::Default), + // "default" stays accepted by the parser so a downgrade-then- + // upgrade dance, or a stale read of an in-memory value during + // migration, doesn't surprise the user. Migration 013 rewrites + // the DB rows to "starter" on its first run. + "starter" | "default" => Some(Self::Starter), "custom" => Some(Self::Custom), _ => None, } @@ -211,20 +223,28 @@ impl FeatureSet { } } - /// Create the "Default" featureset for a space. + /// Create the auto-seeded "Starter" FeatureSet for a Space. /// /// Uses a deterministic id (`fs_default_`) so repositories can - /// upsert this row without having to remember a mapping. - pub fn new_default(space_id: impl Into) -> Self { + /// upsert this row without remembering a mapping. The id prefix is + /// kept for FK stability — only the *type* and display copy were + /// renamed from "Default" → "Starter" in migration 013. Any code that + /// relies on the prefix should treat it as opaque. + pub fn new_starter(space_id: impl Into) -> Self { let space_id = space_id.into(); let now = Utc::now(); Self { id: format!("fs_default_{}", space_id), - name: "Default".to_string(), - description: Some("The fallback feature set for this space".to_string()), + name: "Starter".to_string(), + description: Some( + "Auto-created with this Space. Edit, rename, or delete freely \ + — bindings and per-client grants pick FeatureSets explicitly, \ + so this one has no special routing role." + .to_string(), + ), icon: Some("⭐".to_string()), space_id: Some(space_id), - feature_set_type: FeatureSetType::Default, + feature_set_type: FeatureSetType::Starter, server_id: None, is_builtin: true, is_deleted: false, @@ -234,6 +254,13 @@ impl FeatureSet { } } + /// Backwards-compat shim for callers that still use `new_default`. + /// Delegates to [`Self::new_starter`]. + #[deprecated(note = "Renamed to `new_starter`; the FS type is now `Starter`.")] + pub fn new_default(space_id: impl Into) -> Self { + Self::new_starter(space_id) + } + /// Add description pub fn with_description(mut self, desc: impl Into) -> Self { self.description = Some(desc.into()); @@ -246,9 +273,15 @@ impl FeatureSet { self } - /// Check if this featureset is the "Default" type for a space + /// Check if this is the auto-seeded "Starter" FeatureSet for its Space. + pub fn is_starter(&self) -> bool { + self.feature_set_type == FeatureSetType::Starter + } + + /// Backwards-compat alias. Prefer [`Self::is_starter`]. + #[deprecated(note = "Renamed to `is_starter`.")] pub fn is_default_type(&self) -> bool { - self.feature_set_type == FeatureSetType::Default + self.is_starter() } } @@ -257,12 +290,14 @@ mod tests { use super::*; #[test] - fn test_new_default_featureset() { - let fs = FeatureSet::new_default("space_123"); + fn test_new_starter_featureset() { + let fs = FeatureSet::new_starter("space_123"); + // Stable id prefix preserved for FK compatibility; only the + // type / display copy were renamed. assert_eq!(fs.id, "fs_default_space_123"); - assert_eq!(fs.feature_set_type, FeatureSetType::Default); + assert_eq!(fs.feature_set_type, FeatureSetType::Starter); assert!(fs.is_builtin); - assert!(fs.is_default_type()); + assert!(fs.is_starter()); } #[test] @@ -282,9 +317,15 @@ mod tests { // FeatureSetType parse tests #[test] fn test_feature_set_type_parse() { + assert_eq!( + FeatureSetType::parse("starter"), + Some(FeatureSetType::Starter) + ); + // Legacy alias retained so old in-memory values from a stale + // read still parse cleanly. Migration 013 rewrites stored rows. assert_eq!( FeatureSetType::parse("default"), - Some(FeatureSetType::Default) + Some(FeatureSetType::Starter) ); assert_eq!( FeatureSetType::parse("custom"), @@ -299,13 +340,13 @@ mod tests { #[test] fn test_feature_set_type_as_str() { - assert_eq!(FeatureSetType::Default.as_str(), "default"); + assert_eq!(FeatureSetType::Starter.as_str(), "starter"); assert_eq!(FeatureSetType::Custom.as_str(), "custom"); } #[test] fn test_feature_set_type_roundtrip() { - for fs_type in [FeatureSetType::Default, FeatureSetType::Custom] { + for fs_type in [FeatureSetType::Starter, FeatureSetType::Custom] { let s = fs_type.as_str(); let parsed = FeatureSetType::parse(s).expect("should parse"); assert_eq!(parsed, fs_type); diff --git a/crates/mcpmux-core/src/domain/workspace_binding.rs b/crates/mcpmux-core/src/domain/workspace_binding.rs index 0b4c934..a816cc1 100644 --- a/crates/mcpmux-core/src/domain/workspace_binding.rs +++ b/crates/mcpmux-core/src/domain/workspace_binding.rs @@ -1,10 +1,18 @@ -//! WorkspaceBinding entity — maps a workspace root on disk to a concrete -//! (Space, FeatureSet) pair. +//! WorkspaceBinding entity — maps a workspace root on disk to one or more +//! FeatureSets within a Space. //! //! Bindings are the only override surface for FS resolution: //! -//! workspace root matches a binding? → (binding.space_id, binding.feature_set_id) -//! else → (default Space, its seeded Default FS) +//! workspace root matches a binding? → (binding.space_id, binding.feature_set_ids) +//! else → deny (live session would hit +//! PendingRoots / WorkspaceNeedsBinding) +//! +//! A binding may resolve to multiple FeatureSets — the resolver hands them +//! all to `FeatureService::get_*_for_grants` which composes the union. +//! This is what lets one folder layer e.g. `Read Only` + `Project-specific +//! tools` without forcing the user to merge them into a single FS by hand. +//! Empty `feature_set_ids` is rejected at validation time; storing one +//! would be indistinguishable from "not bound" yet route via Tier 1. //! //! Path handling is **platform-agnostic**. A binding written on Windows //! (`d:\work\proj`) has to match correctly on a Linux host that's just @@ -17,30 +25,46 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -/// A binding between a normalized workspace root and a concrete -/// (Space, FeatureSet) pair. +/// A binding between a normalized workspace root and the FeatureSet(s) it +/// resolves to. `feature_set_ids` is non-empty by construction — see +/// [`WorkspaceBinding::new`] / `new_multi`. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct WorkspaceBinding { pub id: Uuid, pub workspace_root: String, pub space_id: Uuid, - pub feature_set_id: String, + /// Order matters for UI rendering only — the resolver treats them as + /// a set. Stored in the `workspace_binding_feature_sets` junction + /// table (one row per FS, `sort_order` from this Vec's index). + pub feature_set_ids: Vec, pub created_at: DateTime, pub updated_at: DateTime, } impl WorkspaceBinding { + /// Convenience for the common single-FS case. pub fn new( workspace_root: impl Into, space_id: Uuid, feature_set_id: impl Into, + ) -> Self { + Self::new_multi(workspace_root, space_id, vec![feature_set_id.into()]) + } + + /// Construct a binding with one or more FeatureSets. Caller must + /// guarantee `feature_set_ids` is non-empty; the storage layer rejects + /// empties with a validation error. + pub fn new_multi( + workspace_root: impl Into, + space_id: Uuid, + feature_set_ids: Vec, ) -> Self { let now = Utc::now(); Self { id: Uuid::new_v4(), workspace_root: workspace_root.into(), space_id, - feature_set_id: feature_set_id.into(), + feature_set_ids, created_at: now, updated_at: now, } diff --git a/crates/mcpmux-core/src/repository/mod.rs b/crates/mcpmux-core/src/repository/mod.rs index 1a215d9..409ce6d 100644 --- a/crates/mcpmux-core/src/repository/mod.rs +++ b/crates/mcpmux-core/src/repository/mod.rs @@ -157,13 +157,16 @@ pub trait FeatureSetRepository: Send + Sync { /// Delete a feature set (soft delete) async fn delete(&self, id: &str) -> RepoResult<()>; - /// Get the "Default" featureset for a space - async fn get_default_for_space(&self, space_id: &str) -> RepoResult>; + /// Get the auto-seeded "Starter" FeatureSet for a Space, if it + /// exists. Routing-irrelevant under resolver v3 — UI helpers use it + /// to suggest a default selection in the binding/grant pickers. + async fn get_starter_for_space(&self, space_id: &str) -> RepoResult>; - /// Ensure the built-in Default feature set exists for a space. + /// Ensure the auto-seeded Starter FeatureSet exists for a Space. /// - /// Called during Space creation and any time the resolver falls back and - /// cannot find a Default to route to (defensive re-seed). + /// Called during Space creation and any time a defensive re-seed is + /// needed (Workspace inspector references the Starter as a "preview" + /// for unbound roots and would crash with `None`). async fn ensure_builtin_for_space(&self, space_id: &str) -> RepoResult<()>; /// Add an individual feature as a member of a feature set diff --git a/crates/mcpmux-gateway/src/consumers/mcp_notifier.rs b/crates/mcpmux-gateway/src/consumers/mcp_notifier.rs index 331b5e0..b681e59 100644 --- a/crates/mcpmux-gateway/src/consumers/mcp_notifier.rs +++ b/crates/mcpmux-gateway/src/consumers/mcp_notifier.rs @@ -26,29 +26,40 @@ use tracing::{debug, info, trace, warn}; use uuid::Uuid; use crate::pool::FeatureService; -use crate::services::SpaceResolverService; +use crate::services::{FeatureSetResolverService, SpaceResolverService}; -/// MCP Notifier - Sends list_changed notifications to connected MCP clients +/// MCP Notifier — sends `list_changed` notifications to connected sessions. /// -/// **Smart Consumer Pattern:** -/// - Subscribes to DomainEvents from the EventBus -/// - Tracks connected peers by client_id for notification delivery -/// - Resolves client spaces dynamically at notification time (handles follow_active mode) -/// - Dispatches list_changed notifications only to affected clients -/// - Interprets events based on MCP notification context -/// - **Content-Based Deduping**: Hashes feature lists to prevent redundant notifications -/// - **Throttles notifications** to prevent infinite loops from rapid backend changes +/// **Session-keyed registry.** A single OAuth client (Cursor, Claude +/// Desktop) can hold multiple concurrent MCP sessions, and each session +/// can resolve to a *different* (Space, FeatureSet) via WorkspaceBinding +/// — two VS Code windows on different folders are the canonical case. +/// Indexing by `mcp-session-id` lets us notify the right session(s) +/// without over-notifying the others, and matches the request-side +/// routing model (resolver consults session_id, not client_id). /// -/// **Peer Registry:** -/// - Registers peers when clients initialize (used by session manager) -/// - Unregisters peers when sessions close +/// **Fanout uses the same resolver as the request handlers.** When an +/// event implies "FS X may have changed for any session resolving to it", +/// we re-run the resolver per session and notify the ones whose resolved +/// FS list contains X (or whose resolved space matches, depending on the +/// trigger). This is what closes the "FS edit doesn't reflect until +/// reconnect" loophole. +/// +/// **Other duties (unchanged):** +/// - Listens to DomainEvents from the EventBus. +/// - Throttles per (space_id, notification_type) to prevent flapping. +/// - Hashes feature lists to dedupe spurious notifications. #[derive(Clone)] pub struct MCPNotifier { - /// Map: client_id -> peer handle - /// Clients are tracked by client_id, not by space (space is resolved per-request) - client_peers: Arc>>, - /// Space resolver for determining which space a client is currently in + /// Map: `mcp-session-id` → session handle. + sessions: Arc>>, + /// Space resolver for the legacy client-→home-space query (kept for + /// callers that don't have a session id; the new fanout paths use + /// `feature_set_resolver` instead). space_resolver: Arc, + /// FeatureSet resolver — same one the request handlers use. Consulted + /// per session to decide whether a notification applies. + feature_set_resolver: Arc, /// Feature service for calculating content hashes feature_service: Arc, /// Throttle tracker: (space_id, notification_type) -> last_sent_timestamp @@ -76,19 +87,25 @@ enum NotificationType { /// prevents rapid state oscillation (flapping). const THROTTLE_WINDOW: Duration = Duration::from_secs(1); -/// Wrapper around Peer for storage +/// One registered MCP session — the gateway's view of a single live +/// `mcp-session-id`. The peer is what we push notifications to; the +/// `client_id` is kept for per-client fanout (e.g. on grant change). #[derive(Clone)] -struct PeerHandle { +struct SessionEntry { peer: Arc>, - /// Whether this peer has an active SSE stream (can receive notifications) + client_id: String, + /// True once the SSE stream for this session is open and notifications + /// will actually deliver. Sessions register on `initialize`; the + /// stream-active flag flips when the gateway opens the SSE side. has_active_stream: bool, } -impl PeerHandle { - fn new(peer: Arc>) -> Self { +impl SessionEntry { + fn new(client_id: String, peer: Arc>) -> Self { Self { peer, - has_active_stream: false, // Initially false until stream is created + client_id, + has_active_stream: false, } } } @@ -96,11 +113,13 @@ impl PeerHandle { impl MCPNotifier { pub fn new( space_resolver: Arc, + feature_set_resolver: Arc, feature_service: Arc, ) -> Self { Self { - client_peers: Arc::new(RwLock::new(HashMap::new())), + sessions: Arc::new(RwLock::new(HashMap::new())), space_resolver, + feature_set_resolver, feature_service, throttle_tracker: Arc::new(RwLock::new(HashMap::new())), state_hashes: Arc::new(RwLock::new(HashMap::new())), @@ -139,28 +158,32 @@ impl MCPNotifier { hasher.finish() } - /// Register a peer for a client - /// - /// Called when a client initializes. Tracks by client_id (not space_id) because - /// space resolution is dynamic (follow_active mode can change active space). + /// Register a session for notification delivery. /// - /// Handles both initial connection and resume/reconnect scenarios. + /// Called from `on_initialized` once per `mcp-session-id`. The same + /// client may register multiple sessions concurrently (two VS Code + /// windows on different folders share one OAuth `client_id`); the + /// session-keyed map keeps them independent. /// - /// **Note**: Peer starts with `has_active_stream = false`. Call `mark_client_stream_active()` - /// after the client creates an SSE stream to enable notifications. - pub fn register_peer(&self, client_id: String, peer: Arc>) { - let handle = PeerHandle::new(peer); - let mut peers = self.client_peers.write(); - - // Replace any existing peer for this client (handles reconnect/resume) - let is_reconnect = peers.contains_key(&client_id); - peers.insert(client_id.clone(), handle); - + /// **Note**: starts with `has_active_stream = false`. Call + /// [`mark_session_stream_active`](Self::mark_session_stream_active) + /// after the SSE stream opens. + pub fn register_session( + &self, + session_id: String, + client_id: String, + peer: Arc>, + ) { + let entry = SessionEntry::new(client_id.clone(), peer); + let mut sessions = self.sessions.write(); + let is_reconnect = sessions.contains_key(&session_id); + sessions.insert(session_id.clone(), entry); info!( - client_id = %client_id, - is_reconnect = is_reconnect, - total_peers = peers.len(), - "[MCPNotifier] 📡 Registered peer for client (stream not yet active)" + %session_id, + %client_id, + is_reconnect, + total_sessions = sessions.len(), + "[MCPNotifier] 📡 Registered session (stream not yet active)" ); } @@ -173,19 +196,19 @@ impl MCPNotifier { /// spurious "first notification" issues. Without this, the first `list_changed` /// event would always be forwarded (no hash to compare against), potentially /// causing client reconnection loops. - pub fn mark_client_stream_active(&self, client_id: &str) { - let mut peers = self.client_peers.write(); - - if let Some(handle) = peers.get_mut(client_id) { - handle.has_active_stream = true; + pub fn mark_session_stream_active(&self, session_id: &str) { + let mut sessions = self.sessions.write(); + if let Some(entry) = sessions.get_mut(session_id) { + entry.has_active_stream = true; info!( - client_id = %client_id, - "[MCPNotifier] ✅ Client stream is now active (notifications enabled)" + %session_id, + client_id = %entry.client_id, + "[MCPNotifier] ✅ Session stream is now active (notifications enabled)" ); } else { warn!( - client_id = %client_id, - "[MCPNotifier] ⚠️ Attempted to mark stream active for unknown peer" + %session_id, + "[MCPNotifier] ⚠️ Attempted to mark stream active for unknown session" ); } } @@ -228,22 +251,22 @@ impl MCPNotifier { ); } - /// Unregister a peer + /// Unregister a session. /// - /// Called when a client disconnects or session closes - pub fn unregister_peer(&self, client_id: &str) { - let mut peers = self.client_peers.write(); - - if peers.remove(client_id).is_some() { + /// Called when a client disconnects or the session closes. + pub fn unregister_session(&self, session_id: &str) { + let mut sessions = self.sessions.write(); + if let Some(removed) = sessions.remove(session_id) { info!( - client_id = %client_id, - remaining_peers = peers.len(), - "[MCPNotifier] 📴 Unregistered peer" + %session_id, + client_id = %removed.client_id, + remaining_sessions = sessions.len(), + "[MCPNotifier] 📴 Unregistered session" ); } else { warn!( - client_id = %client_id, - "[MCPNotifier] ⚠️ Attempted to unregister unknown peer" + %session_id, + "[MCPNotifier] ⚠️ Attempted to unregister unknown session" ); } } @@ -299,52 +322,55 @@ impl MCPNotifier { tracker.insert((space_id, NotificationType::All), timestamp); } - /// Get all peers for a specific space (resolves client spaces at notification time) + /// Get every peer whose **session** currently routes into `space_id`. /// - /// **Key Feature**: Resolves space dynamically for each client, handling: - /// - follow_active mode (clients see active space changes) - /// - locked mode (clients stay in their locked space) - /// - Space changes without reconnection + /// Iterates the session registry and re-runs the FeatureSet resolver + /// per session — same logic the request handlers use, so a session + /// redirected by `WorkspaceBinding` to a non-default space is matched + /// correctly. Sessions whose stream isn't active yet are skipped (the + /// notification would be queued but not delivered). async fn get_peers_for_space(&self, space_id: Uuid) -> Vec>> { - // Clone the client list to avoid holding lock across await - let client_list: Vec<(String, Arc>)> = { - let peers = self.client_peers.read(); - peers + let session_list: Vec<(String, String, Arc>)> = { + let sessions = self.sessions.read(); + sessions .iter() - .map(|(client_id, handle)| (client_id.clone(), handle.peer.clone())) + .filter(|(_, e)| e.has_active_stream) + .map(|(sid, entry)| (sid.clone(), entry.client_id.clone(), entry.peer.clone())) .collect() }; let mut matching_peers = Vec::new(); - - for (client_id, peer) in client_list { - // Resolve current space for this client + let _space_resolver = &self.space_resolver; // kept-but-unused; resolver below is authoritative + for (session_id, client_id, peer) in session_list { match self - .space_resolver - .resolve_space_for_client(&client_id) + .feature_set_resolver + .resolve(Some(&session_id), Some(&client_id)) .await { - Ok(client_space) if client_space == space_id => { + Ok(resolved) if resolved.space_id == Some(space_id) => { debug!( - client_id = %client_id, - space_id = %space_id, - "[MCPNotifier] Client is in target space" + %session_id, + %client_id, + %space_id, + "[MCPNotifier] Session resolves to target space" ); matching_peers.push(peer); } - Ok(other_space) => { + Ok(resolved) => { debug!( - client_id = %client_id, - client_space = %other_space, - target_space = %space_id, - "[MCPNotifier] Client is in different space, skipping" + %session_id, + %client_id, + resolved_space = ?resolved.space_id, + %space_id, + "[MCPNotifier] Session is in a different space, skipping" ); } Err(e) => { warn!( - client_id = %client_id, + %session_id, + %client_id, error = %e, - "[MCPNotifier] ⚠️ Failed to resolve space for client" + "[MCPNotifier] ⚠️ Failed to resolve space for session" ); } } @@ -415,6 +441,23 @@ impl MCPNotifier { self.notify_all_list_changed(space_id, true).await; } + // Per-client grant changed — only the rootless-fallback path + // consumes these grants, so we only need to notify peers + // registered under this client_id. Bypass the space-wide fanout + // (which would over-notify roots-capable peers in the space + // whose resolution didn't change). + DomainEvent::ClientGrantChanged { + client_id, + space_id, + } => { + info!( + %client_id, + %space_id, + "[MCPNotifier] 📨 ClientGrantChanged - notifying peer for this client" + ); + self.notify_peer_lists_changed(&client_id).await; + } + // A workspace binding was created / updated / deleted. Every // session in the space may now resolve to a different FS, so // broadcast all three list_changed notifications. `force=true` @@ -749,64 +792,59 @@ impl MCPNotifier { } } - /// Get peers for a space that have active SSE streams (for notifications) + /// Get peers for a space that have active SSE streams. /// - /// Returns both the peers and their client_ids (for logging) + /// Session-keyed: iterates `sessions`, re-runs the FeatureSet resolver + /// per session (same path as the request handlers), and returns the + /// peers whose session resolves into `space_id`. The second tuple + /// element is `client_id`s of those sessions, kept for log clarity. async fn get_peers_for_space_with_streams( &self, space_id: Uuid, ) -> (Vec>>, Vec) { - // Clone the client list to avoid holding lock across await - let client_list: Vec<(String, PeerHandle)> = { - let peers = self.client_peers.read(); - peers + let session_list: Vec<(String, String, Arc>)> = { + let sessions = self.sessions.read(); + sessions .iter() - .map(|(client_id, handle)| (client_id.clone(), handle.clone())) + .filter(|(_, e)| e.has_active_stream) + .map(|(sid, entry)| (sid.clone(), entry.client_id.clone(), entry.peer.clone())) .collect() }; let mut matching_peers = Vec::new(); let mut matching_client_ids = Vec::new(); - for (client_id, handle) in client_list { - // Skip peers without active streams - if !handle.has_active_stream { - debug!( - client_id = %client_id, - space_id = %space_id, - "[MCPNotifier] Skipping peer without active stream" - ); - continue; - } - - // Resolve current space for this client + for (session_id, client_id, peer) in session_list { match self - .space_resolver - .resolve_space_for_client(&client_id) + .feature_set_resolver + .resolve(Some(&session_id), Some(&client_id)) .await { - Ok(client_space) if client_space == space_id => { + Ok(resolved) if resolved.space_id == Some(space_id) => { debug!( - client_id = %client_id, - space_id = %space_id, - "[MCPNotifier] Client is in target space with active stream" + %session_id, + %client_id, + %space_id, + "[MCPNotifier] Session in target space with active stream" ); - matching_peers.push(handle.peer.clone()); + matching_peers.push(peer); matching_client_ids.push(client_id); } - Ok(other_space) => { + Ok(resolved) => { debug!( - client_id = %client_id, - client_space = %other_space, + %session_id, + %client_id, + resolved_space = ?resolved.space_id, target_space = %space_id, - "[MCPNotifier] Client is in different space, skipping" + "[MCPNotifier] Session in different space, skipping" ); } Err(e) => { warn!( - client_id = %client_id, + %session_id, + %client_id, error = %e, - "[MCPNotifier] ⚠️ Failed to resolve space for client" + "[MCPNotifier] ⚠️ Failed to resolve space for session" ); } } @@ -953,28 +991,43 @@ impl MCPNotifier { return; } - let peer = { - let peers = self.client_peers.read(); - peers - .get(client_id) - .filter(|h| h.has_active_stream) - .map(|h| h.peer.clone()) + // A single client may hold several active sessions (multi-window + // editors, parallel CLI invocations). Push the notification on + // every active session for that client_id; client-side dedup is + // their problem, but missing a session would be ours. + let peers: Vec>> = { + let sessions = self.sessions.read(); + sessions + .iter() + .filter(|(_, e)| e.client_id == client_id && e.has_active_stream) + .map(|(_, e)| e.peer.clone()) + .collect() }; - let Some(peer) = peer else { - debug!(%client_id, "[MCPNotifier] no active peer — skipping peer list_changed"); + + if peers.is_empty() { + debug!( + %client_id, + "[MCPNotifier] no active session — skipping peer list_changed" + ); return; - }; + } - info!(%client_id, "[MCPNotifier] 📤 per-peer list_changed (resolution flipped)"); + info!( + %client_id, + session_count = peers.len(), + "[MCPNotifier] 📤 per-client list_changed (resolution flipped or grant edited)" + ); - if let Err(e) = peer.notify_tool_list_changed().await { - warn!(error = ?e, %client_id, "[MCPNotifier] failed tools/list_changed"); - } - if let Err(e) = peer.notify_prompt_list_changed().await { - warn!(error = ?e, %client_id, "[MCPNotifier] failed prompts/list_changed"); - } - if let Err(e) = peer.notify_resource_list_changed().await { - warn!(error = ?e, %client_id, "[MCPNotifier] failed resources/list_changed"); + for peer in &peers { + if let Err(e) = peer.notify_tool_list_changed().await { + warn!(error = ?e, %client_id, "[MCPNotifier] failed tools/list_changed"); + } + if let Err(e) = peer.notify_prompt_list_changed().await { + warn!(error = ?e, %client_id, "[MCPNotifier] failed prompts/list_changed"); + } + if let Err(e) = peer.notify_resource_list_changed().await { + warn!(error = ?e, %client_id, "[MCPNotifier] failed resources/list_changed"); + } } } } diff --git a/crates/mcpmux-gateway/src/mcp/handler.rs b/crates/mcpmux-gateway/src/mcp/handler.rs index 0635814..4c7ab0f 100644 --- a/crates/mcpmux-gateway/src/mcp/handler.rs +++ b/crates/mcpmux-gateway/src/mcp/handler.rs @@ -99,38 +99,39 @@ impl McpMuxGatewayHandler { root_for_prompt: Option<&str>, ) { let resolver = &services.feature_set_resolver; - match resolver.resolve(session_id).await { + match resolver.resolve(session_id, Some(client_id)).await { Ok(resolved) => { info!( %client_id, session_id = session_id.unwrap_or(""), - feature_set_id = resolved.feature_set_id.clone().unwrap_or_else(|| "".into()), + feature_set_ids = ?resolved.feature_set_ids, space_id = resolved.space_id.map(|u| u.to_string()).unwrap_or_else(|| "".into()), source = ?resolved.source, "[FeatureSetResolver] resolved", ); - // Track the resolved FS per session so we can detect flips. - // The very first sighting (no prior entry) counts as a flip - // — that's the case where the client's `tools/list` at init - // saw the fallback set but roots arriving later may have - // landed on a different binding. Firing once on first sight - // is safe (idempotent re-list); the dedup protects against - // repeated identical resolutions. + // Track the resolved FS fingerprint per session so we can + // detect flips. The very first sighting (no prior entry) + // counts as a flip — that's the case where the client's + // `tools/list` at init saw an empty/pending list but roots + // arriving later may have landed on a binding. Firing once + // on first sight is safe (idempotent re-list); the dedup + // protects against repeated identical resolutions. if let (Some(sid), Some(notifier)) = (session_id, notifier) { let changed = services .session_roots - .record_resolution(sid, resolved.feature_set_id.as_deref()); + .record_resolution(sid, resolved.fingerprint().as_deref()); if changed { notifier.notify_peer_lists_changed(client_id).await; } } - // Prompt only when the session reported a root AND no binding - // matched (source=Default). `session_id` must be Some too so - // the UI can correlate back to this peer. + // Prompt only when the session reported a root but no + // binding matched (`Deny` with a non-empty root_for_prompt). + // PendingRoots / ClientGrant / WorkspaceBinding never + // trigger the prompt. let should_prompt = - matches!(resolved.source, crate::services::ResolutionSource::Default); + matches!(resolved.source, crate::services::ResolutionSource::Deny); if let (true, Some(sid), Some(space_id), Some(root)) = ( should_prompt, session_id, @@ -168,21 +169,18 @@ impl McpMuxGatewayHandler { async fn resolve_routing( &self, session_id: Option<&str>, + client_id: &str, ) -> Result<(uuid::Uuid, Vec), McpError> { let resolved = self .services .authorization_service - .resolve(session_id) + .resolve(session_id, Some(client_id)) .await .map_err(|e| McpError::internal_error(format!("Failed to resolve: {e}"), None))?; let space_id = resolved.space_id.ok_or_else(|| { McpError::internal_error("No space resolved (no default space configured)", None) })?; - let feature_set_ids = resolved - .feature_set_id - .map(|fs| vec![fs]) - .unwrap_or_default(); - Ok((space_id, feature_set_ids)) + Ok((space_id, resolved.feature_set_ids)) } /// Build InitializeResult with negotiated protocol version @@ -259,15 +257,26 @@ impl ServerHandler for McpMuxGatewayHandler { } }; - // Register peer with MCPNotifier for list_changed notification delivery + // Register the *session* with MCPNotifier so subsequent fanout can + // re-resolve per session (a single OAuth client can hold multiple + // sessions on different folders, each routing independently). let peer = std::sync::Arc::new(context.peer); - self.notification_bridge - .register_peer(oauth_ctx.client_id.clone(), peer.clone()); - - // Mark the client stream as active immediately - RMCP's session transport - // handles SSE streaming and message caching internally - self.notification_bridge - .mark_client_stream_active(&oauth_ctx.client_id); + let session_id_for_register = extract_session_id(&context.extensions); + if let Some(sid) = session_id_for_register.as_deref() { + self.notification_bridge.register_session( + sid.to_string(), + oauth_ctx.client_id.clone(), + peer.clone(), + ); + // Mark the SSE stream as active immediately — RMCP's session + // transport handles streaming + message caching internally. + self.notification_bridge.mark_session_stream_active(sid); + } else { + warn!( + client_id = %oauth_ctx.client_id, + "[on_initialized] no mcp-session-id; skipping notifier registration (rare — stateless transport?)" + ); + } // Pre-populate feature hashes to prevent spurious first notifications self.notification_bridge @@ -282,6 +291,33 @@ impl ServerHandler for McpMuxGatewayHandler { .peer_info() .map(|info| info.capabilities.roots.is_some()) .unwrap_or(false); + // Stash the capability so the resolver can branch between + // workspace-binding routing (capable) and the per-client grant + // fallback (rootless). Done unconditionally so the registry has + // a definitive answer for every session, not just those with + // roots declared. + self.services + .session_roots + .set_roots_capable(&session_id, declares_roots); + // Persist the bit on the client row, *always* — the Clients UI + // needs to distinguish "never observed" from "explicitly + // rootless" so its capability badge isn't misleading on + // newly-approved clients. The repo applies sticky-positive + // semantics on `reports_roots` so a one-off rootless reconnect + // doesn't bounce the badge. + { + let repo = self.services.dependencies.inbound_client_repo.clone(); + let cid = oauth_ctx.client_id.clone(); + tokio::spawn(async move { + if let Err(e) = repo.mark_roots_capability(&cid, declares_roots).await { + debug!( + client_id = %cid, + error = %e, + "[on_initialized] mark_roots_capability failed (non-fatal)" + ); + } + }); + } if declares_roots { let peer_for_roots = peer.clone(); let session_roots = self.services.session_roots.clone(); @@ -444,11 +480,17 @@ impl ServerHandler for McpMuxGatewayHandler { _params: Option, context: RequestContext, ) -> Result { + let oauth_ctx = self + .get_oauth_context(&context.extensions) + .map_err(|e| McpError::invalid_params(e.to_string(), None))?; // Resolve routing once: the resolver returns the authoritative // (Space, FS) for this session — this may differ from oauth_ctx // when a WorkspaceBinding redirects to another space. let (space_id, feature_set_ids) = self - .resolve_routing(extract_session_id(&context.extensions).as_deref()) + .resolve_routing( + extract_session_id(&context.extensions).as_deref(), + &oauth_ctx.client_id, + ) .await?; // Get tools via FeatureService — using the *resolved* space. @@ -539,7 +581,9 @@ impl ServerHandler for McpMuxGatewayHandler { // Resolve routing — the binding's target space is authoritative, // which may differ from oauth_ctx.space_id. - let (space_id, feature_set_ids) = self.resolve_routing(session_id).await?; + let (space_id, feature_set_ids) = self + .resolve_routing(session_id, &oauth_ctx.client_id) + .await?; // Call tool via routing service (handles auth and routing) let tool_result = self @@ -621,8 +665,14 @@ impl ServerHandler for McpMuxGatewayHandler { _params: Option, context: RequestContext, ) -> Result { + let oauth_ctx = self + .get_oauth_context(&context.extensions) + .map_err(|e| McpError::invalid_params(e.to_string(), None))?; let (space_id, feature_set_ids) = self - .resolve_routing(extract_session_id(&context.extensions).as_deref()) + .resolve_routing( + extract_session_id(&context.extensions).as_deref(), + &oauth_ctx.client_id, + ) .await?; let prompts = self @@ -662,8 +712,14 @@ impl ServerHandler for McpMuxGatewayHandler { params: GetPromptRequestParams, context: RequestContext, ) -> Result { + let oauth_ctx = self + .get_oauth_context(&context.extensions) + .map_err(|e| McpError::invalid_params(e.to_string(), None))?; let (space_id, feature_set_ids) = self - .resolve_routing(extract_session_id(&context.extensions).as_deref()) + .resolve_routing( + extract_session_id(&context.extensions).as_deref(), + &oauth_ctx.client_id, + ) .await?; let (server_id, prompt_name) = self @@ -716,8 +772,14 @@ impl ServerHandler for McpMuxGatewayHandler { _params: Option, context: RequestContext, ) -> Result { + let oauth_ctx = self + .get_oauth_context(&context.extensions) + .map_err(|e| McpError::invalid_params(e.to_string(), None))?; let (space_id, feature_set_ids) = self - .resolve_routing(extract_session_id(&context.extensions).as_deref()) + .resolve_routing( + extract_session_id(&context.extensions).as_deref(), + &oauth_ctx.client_id, + ) .await?; let resources = self @@ -755,8 +817,14 @@ impl ServerHandler for McpMuxGatewayHandler { params: ReadResourceRequestParams, context: RequestContext, ) -> Result { + let oauth_ctx = self + .get_oauth_context(&context.extensions) + .map_err(|e| McpError::invalid_params(e.to_string(), None))?; let (space_id, feature_set_ids) = self - .resolve_routing(extract_session_id(&context.extensions).as_deref()) + .resolve_routing( + extract_session_id(&context.extensions).as_deref(), + &oauth_ctx.client_id, + ) .await?; let server_id = self diff --git a/crates/mcpmux-gateway/src/oauth/dcr.rs b/crates/mcpmux-gateway/src/oauth/dcr.rs index f698879..fc6a9a0 100644 --- a/crates/mcpmux-gateway/src/oauth/dcr.rs +++ b/crates/mcpmux-gateway/src/oauth/dcr.rs @@ -136,6 +136,10 @@ fn build_inbound_client_from_request( last_seen, created_at, updated_at, + // Capability bits default off / unknown; the gateway flips them + // on the first `initialize` for any session of this client. + reports_roots: false, + roots_capability_known: false, } } diff --git a/crates/mcpmux-gateway/src/server/mod.rs b/crates/mcpmux-gateway/src/server/mod.rs index 1ac40cf..9888c70 100644 --- a/crates/mcpmux-gateway/src/server/mod.rs +++ b/crates/mcpmux-gateway/src/server/mod.rs @@ -226,9 +226,11 @@ impl GatewayServer { base_url: self.config.base_url(), }; - // Create MCP notifier (smart consumer for domain events with dynamic space resolution) + // Create MCP notifier (session-keyed fanout, consults the same + // FeatureSet resolver the request handlers use). let notification_bridge = Arc::new(MCPNotifier::new( self.services.space_resolver_service.clone(), + self.services.feature_set_resolver.clone(), self.services.pool_services.feature_service.clone(), )); diff --git a/crates/mcpmux-gateway/src/server/service_container.rs b/crates/mcpmux-gateway/src/server/service_container.rs index b755b4f..d0b6d98 100644 --- a/crates/mcpmux-gateway/src/server/service_container.rs +++ b/crates/mcpmux-gateway/src/server/service_container.rs @@ -106,8 +106,8 @@ impl ServiceContainer { let feature_set_resolver = Arc::new(FeatureSetResolverService::new( deps.space_repo.clone(), deps.workspace_binding_repo.clone(), - deps.feature_set_repo.clone(), session_roots.clone(), + deps.inbound_client_repo.clone(), )); // Authorization service is now a thin adapter over the resolver. @@ -145,6 +145,7 @@ impl ServiceContainer { // the MCP notifier can fan list_changed out to every peer that // resolves into the affected set. let grant_service = Arc::new(GrantService::new( + deps.inbound_client_repo.clone(), deps.feature_set_repo.clone(), domain_event_tx.clone(), )); diff --git a/crates/mcpmux-gateway/src/services/authorization.rs b/crates/mcpmux-gateway/src/services/authorization.rs index 069aa8c..6f9cc3e 100644 --- a/crates/mcpmux-gateway/src/services/authorization.rs +++ b/crates/mcpmux-gateway/src/services/authorization.rs @@ -1,10 +1,10 @@ //! Authorization Service. //! //! Thin adapter over [`FeatureSetResolverService`]. Routing decisions are -//! keyed purely on session (→ workspace root → binding); client_id is only -//! used for approval (upstream of this service), never for routing. That's -//! what fixes the "two VS Code windows share a pin" bug — a single client -//! can have many sessions, each routing independently. +//! keyed primarily on session (→ workspace root → binding); `client_id` is +//! consulted only on the rootless Tier-2 fallback (`client_grants` lookup). +//! Two VS Code windows sharing one OAuth identity still route independently +//! because the binding path uses session-reported roots. use anyhow::Result; use std::sync::Arc; @@ -21,34 +21,34 @@ impl AuthorizationService { Self { resolver } } - /// Resolve the active FeatureSet for a session and return it as a - /// one-element Vec (or empty when resolution fully fails — no active - /// space + no "All" FS, a pathological setup). + /// Resolve the active FeatureSet ids for a session/client pair. /// - /// `session_id` is the client's `mcp-session-id` header. `client_id` - /// and `space_id` are ignored — they come from legacy call sites and - /// are not used by the new resolver. + /// Returns an empty Vec when resolution denies (no roots + no grants, + /// or roots reported but no binding matched). The MCP request handler + /// surfaces this as "no tools" plus its own `WorkspaceNeedsBinding` + /// nudge for bound-but-unbound roots. pub async fn get_client_grants( &self, - _client_id: &str, + client_id: &str, _space_id: &Uuid, session_id: Option<&str>, ) -> Result> { - let resolved = self.resolver.resolve(session_id).await?; - Ok(resolved - .feature_set_id - .map(|fs| vec![fs]) - .unwrap_or_default()) + let resolved = self.resolver.resolve(session_id, Some(client_id)).await?; + Ok(resolved.feature_set_ids) } - /// Full resolution metadata — returns (Space, FS, source) so the MCP - /// handler can also filter on the resolved Space rather than the + /// Full resolution metadata — returns (Space, FS list, source) so the + /// MCP handler can also filter on the resolved Space rather than the /// caller-advertised one. - pub async fn resolve(&self, session_id: Option<&str>) -> Result { - self.resolver.resolve(session_id).await + pub async fn resolve( + &self, + session_id: Option<&str>, + client_id: Option<&str>, + ) -> Result { + self.resolver.resolve(session_id, client_id).await } - /// Does this session resolve to any FeatureSet? + /// Does this session/client resolve to any FeatureSet? pub async fn has_access( &self, client_id: &str, diff --git a/crates/mcpmux-gateway/src/services/client_metadata_service.rs b/crates/mcpmux-gateway/src/services/client_metadata_service.rs index dced118..97d4b00 100644 --- a/crates/mcpmux-gateway/src/services/client_metadata_service.rs +++ b/crates/mcpmux-gateway/src/services/client_metadata_service.rs @@ -137,6 +137,11 @@ impl ClientMetadataService { last_seen: Some(now.clone()), created_at: now.clone(), updated_at: now, + // Capability bits default off / unknown; the gateway flips + // them on the first `initialize` for any session of this + // client. + reports_roots: false, + roots_capability_known: false, } } } diff --git a/crates/mcpmux-gateway/src/services/feature_set_resolver.rs b/crates/mcpmux-gateway/src/services/feature_set_resolver.rs index 08b82f6..5da456a 100644 --- a/crates/mcpmux-gateway/src/services/feature_set_resolver.rs +++ b/crates/mcpmux-gateway/src/services/feature_set_resolver.rs @@ -1,144 +1,237 @@ //! FeatureSet Resolver Service. //! -//! Two-tier resolution, keyed by the caller's reported workspace roots: +//! Capability-branched four-tier resolution. The branch point is the MCP +//! `roots` capability declared by the client at `initialize`: //! //! ```text -//! resolve(session_id): +//! resolve(session_id, client_id): +//! // Tier 1 — roots-capable session with reported roots //! if session reported roots AND a binding matches: -//! return (binding.space_id, binding.feature_set_id, WorkspaceBinding) -//! default_space = SpaceRepository.get_default() -//! return (default_space.id, default_space's Default FS id, Default) +//! return (binding.space_id, [binding.feature_set_id], WorkspaceBinding) +//! +//! // Tier 1b — roots-capable, roots reported, but no binding yet +//! if session reported roots AND no binding matched: +//! return ([], , Deny) // emits WorkspaceNeedsBinding upstream +//! +//! // Tier 1c — declared `roots` but they haven't arrived yet +//! if session declared `roots` AND none yet in registry: +//! return ([], default_space, PendingRoots) +//! +//! // Tier 2 — rootless-by-design (Claude.ai web, ChatGPT, …) +//! if client has grants in the default space: +//! return (default_space, grants, ClientGrant) +//! +//! // Tier 3 — no signal at all +//! return ([], default_space, Deny) //! ``` //! -//! The caller's client identity is deliberately NOT used for routing — two -//! VS Code windows share one OAuth identity but open different folders; -//! routing must come from the session's reported root, not from the shared -//! client. See `mcpmux.space/diagrams/workppace-root-session/` for the full -//! design. +//! The caller's client identity is used **only** for the rootless fallback — +//! every roots-capable session routes via its own reported roots, regardless +//! of which OAuth client opened it. This is what makes "two VS Code windows +//! sharing one OAuth identity" route independently. +//! +//! Roots-capable detection is stamped at `on_initialized` time into +//! [`SessionRootsRegistry::set_roots_capable`]. use std::sync::Arc; use anyhow::Result; -use mcpmux_core::{FeatureSetRepository, SpaceRepository, WorkspaceBindingRepository}; +use mcpmux_core::{SpaceRepository, WorkspaceBindingRepository}; +use mcpmux_storage::InboundClientRepository; use serde::Serialize; use tracing::{debug, warn}; use uuid::Uuid; use super::session_roots::SessionRootsRegistry; -/// Why the resolver picked the FS it picked (or didn't pick one). +/// Why the resolver picked the FS(es) it picked (or didn't pick any). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub enum ResolutionSource { /// A [`WorkspaceBinding`](mcpmux_core::WorkspaceBinding) matched one of /// the session's reported MCP roots. WorkspaceBinding, - /// No binding matched (or the session reported no roots); fell through - /// to the default Space's Default FeatureSet. - Default, + /// No binding matched, but the client is roots-capable so its `roots` + /// list is in flight; return empty and re-resolve when they arrive. + PendingRoots, + /// Rootless-by-design client. The space-default's per-client + /// `client_grants` were applied. + ClientGrant, + /// No FeatureSet resolved. Either no roots + no grants, or the session + /// reported roots but no binding matched (the upstream caller emits + /// `WorkspaceNeedsBinding` in that subcase). + Deny, } /// Output of [`FeatureSetResolverService::resolve`]. /// -/// `feature_set_id` is a `String` (not `Uuid`) because built-in FeatureSets -/// use stable stringy ids like `fs_default_` that aren't valid UUIDs. +/// `feature_set_ids` is empty when the resolution was a deny. Multiple ids +/// are possible only on the `ClientGrant` path — bindings always resolve to +/// exactly one FS. #[derive(Debug, Clone)] pub struct ResolvedFeatureSet { - /// Chosen FeatureSet id. `None` only when every fallback tier failed - /// (no default space, no Default FS in that space — a pathological setup). - pub feature_set_id: Option, + pub feature_set_ids: Vec, /// Resolved Space id. Used by the routing layer when filtering features. pub space_id: Option, pub source: ResolutionSource, } -/// Resolves which FeatureSet applies for a given session. +impl ResolvedFeatureSet { + /// Stable key for change detection (sorted + comma-joined). Used by + /// `SessionRootsRegistry::record_resolution` to decide when a session's + /// effective tools changed and a per-peer `list_changed` is owed. + pub fn fingerprint(&self) -> Option { + if self.feature_set_ids.is_empty() { + return None; + } + let mut ids = self.feature_set_ids.clone(); + ids.sort(); + Some(ids.join(",")) + } +} + +/// Resolves which FeatureSet(s) apply for a given session. /// /// Cheap to clone via `Arc`; inject one instance into the gateway's service /// container and reuse across requests. pub struct FeatureSetResolverService { space_repo: Arc, binding_repo: Arc, - feature_set_repo: Arc, session_roots: Arc, + /// Reads `client_grants` for the rootless Tier-2 fallback. Stored as a + /// concrete repo (storage owns this type and there's only ever one). + client_repo: Arc, } impl FeatureSetResolverService { pub fn new( space_repo: Arc, binding_repo: Arc, - feature_set_repo: Arc, session_roots: Arc, + client_repo: Arc, ) -> Self { Self { space_repo, binding_repo, - feature_set_repo, session_roots, + client_repo, } } - /// Resolve the effective (Space, FeatureSet) pair for a session. + /// Resolve the effective (Space, FS list, source) tuple for a session. /// - /// `session_id` is the client's `mcp-session-id` header (or `None` for - /// stateless callers) — used to look up MCP roots reported on - /// `on_initialized`. - pub async fn resolve(&self, session_id: Option<&str>) -> Result { + /// `session_id`: the client's `mcp-session-id` header (or `None` when + /// the caller is stateless — e.g. desktop UI HTTP path). + /// `client_id`: the OAuth client identity. Used only for the Tier-2 + /// `client_grants` lookup; ignored for binding-based routing. + pub async fn resolve( + &self, + session_id: Option<&str>, + client_id: Option<&str>, + ) -> Result { let default_space_id = match self.space_repo.get_default().await? { Some(s) => s.id, None => { warn!("[FeatureSetResolver] no default space — deny"); return Ok(ResolvedFeatureSet { - feature_set_id: None, + feature_set_ids: vec![], space_id: None, - source: ResolutionSource::Default, + source: ResolutionSource::Deny, }); } }; - // Tier 1: session has roots AND a binding matches. + // Tier 1 / 1b / 1c — branches on roots-capable + roots-arrived state. if let Some(sid) = session_id { - if let Some(roots) = self.session_roots.get(sid) { - if !roots.is_empty() { - if let Some(binding) = self - .binding_repo - .find_longest_prefix_match(&default_space_id, &roots) - .await? - { - debug!( - workspace_root = %binding.workspace_root, - space_id = %binding.space_id, - feature_set = %binding.feature_set_id, - "[FeatureSetResolver] resolved via WorkspaceBinding", - ); - return Ok(ResolvedFeatureSet { - feature_set_id: Some(binding.feature_set_id), - space_id: Some(binding.space_id), - source: ResolutionSource::WorkspaceBinding, - }); - } + let roots = self.session_roots.get(sid); + let has_roots = roots.as_ref().is_some_and(|r| !r.is_empty()); + let roots_capable = self.session_roots.is_roots_capable(sid).unwrap_or(false); + + // Tier 1: session reported roots — try a binding match. + if has_roots { + if let Some(binding) = self + .binding_repo + .find_longest_prefix_match(&default_space_id, &roots.unwrap()) + .await? + { + debug!( + workspace_root = %binding.workspace_root, + space_id = %binding.space_id, + feature_sets = ?binding.feature_set_ids, + "[FeatureSetResolver] resolved via WorkspaceBinding", + ); + return Ok(ResolvedFeatureSet { + feature_set_ids: binding.feature_set_ids, + space_id: Some(binding.space_id), + source: ResolutionSource::WorkspaceBinding, + }); } + // Tier 1b: had roots, no binding — deny + upstream emits + // WorkspaceNeedsBinding so the user can choose an FS. + debug!("[FeatureSetResolver] roots reported but no binding matched — deny",); + return Ok(ResolvedFeatureSet { + feature_set_ids: vec![], + space_id: Some(default_space_id), + source: ResolutionSource::Deny, + }); + } + + // Tier 1c: client declared `roots` but they haven't shown up yet. + // Don't fall through to client grants — that's the leak the old + // Tier-2 fallback caused. Return empty; we'll fire `list_changed` + // when roots actually arrive. + if roots_capable { + debug!( + session_id = %sid, + "[FeatureSetResolver] roots-capable, roots pending — empty until they arrive", + ); + return Ok(ResolvedFeatureSet { + feature_set_ids: vec![], + space_id: Some(default_space_id), + source: ResolutionSource::PendingRoots, + }); } } - // Tier 2: default — the default Space's seeded Default FS. - let default_fs = self - .feature_set_repo - .get_default_for_space(&default_space_id.to_string()) - .await - .unwrap_or_default() - .map(|fs| fs.id); + // Tier 2 — rootless-by-design. Either the session declared no + // `roots` capability, or the caller has no session id at all + // (the desktop UI's preview HTTP path lands here too). Consult the + // per-client grant table. + if let Some(cid) = client_id { + let grants = self + .client_repo + .get_grants_for_space(cid, &default_space_id.to_string()) + .await + .unwrap_or_default(); + if !grants.is_empty() { + debug!( + client_id = %cid, + space_id = %default_space_id, + grant_count = grants.len(), + "[FeatureSetResolver] resolved via ClientGrant", + ); + return Ok(ResolvedFeatureSet { + feature_set_ids: grants, + space_id: Some(default_space_id), + source: ResolutionSource::ClientGrant, + }); + } + } + // Tier 3 — no roots, no grants. Deny. + // The mcpmux_* meta tools are still appended unconditionally by the + // request handler, so the LLM can self-bind / ask the user for + // a grant from this state. debug!( space_id = %default_space_id, - feature_set = ?default_fs, - "[FeatureSetResolver] resolved via Default (default space's Default FS)", + ?client_id, + "[FeatureSetResolver] no roots + no grants — deny", ); Ok(ResolvedFeatureSet { - feature_set_id: default_fs, + feature_set_ids: vec![], space_id: Some(default_space_id), - source: ResolutionSource::Default, + source: ResolutionSource::Deny, }) } } diff --git a/crates/mcpmux-gateway/src/services/grant_service.rs b/crates/mcpmux-gateway/src/services/grant_service.rs index 5095b40..4da8787 100644 --- a/crates/mcpmux-gateway/src/services/grant_service.rs +++ b/crates/mcpmux-gateway/src/services/grant_service.rs @@ -1,39 +1,123 @@ -//! Feature set change broadcaster. +//! Grant Service. //! -//! Emits `FeatureSetMembersChanged` domain events so the MCP notifier can -//! broadcast `list_changed` notifications after any member edit. This used -//! to host grant/revoke plumbing too, but per-client grants have been -//! removed — routing now flows purely through WorkspaceBinding + each -//! Space's Default feature set. +//! Two responsibilities, both centred on emitting domain events so MCPNotifier +//! can broadcast `list_changed` notifications: +//! +//! 1. **Per-client FeatureSet grants** — used by the resolver's rootless-fallback +//! path. When a client has not declared the MCP `roots` capability (or has +//! no workspace context), the resolver consults `client_grants` for that +//! `(client_id, space_id)` pair. Grant/revoke flows here update the table +//! *and* fire `ClientGrantChanged` so any open peer for that client +//! re-fetches its tool list under the new permission set. +//! 2. **FeatureSet membership change broadcast** — when individual features are +//! added or removed inside a FeatureSet, fire `FeatureSetMembersChanged` +//! for the same notifier path. +//! +//! Routing for roots-capable clients flows through `WorkspaceBinding` and is +//! handled by the resolver directly — this service is not on that path. use anyhow::Result; use mcpmux_core::{DomainEvent, FeatureSetRepository}; +use mcpmux_storage::InboundClientRepository; use std::sync::Arc; use tokio::sync::broadcast; use tracing::{info, warn}; use uuid::Uuid; -/// Emits domain events for FeatureSet membership edits. -/// -/// Named `GrantService` for historical reasons (older callers expect the -/// symbol) — functionally it's just a thin notifier around -/// `FeatureSetMembersChanged`. +/// Grant management with automatic event emission. pub struct GrantService { + /// OAuth client grant repository (concrete; storage-owned). + client_repo: Arc, + /// Feature set lookup for member-change notifications. feature_set_repo: Arc, + /// Domain event broadcaster. event_tx: broadcast::Sender, } impl GrantService { pub fn new( + client_repo: Arc, feature_set_repo: Arc, event_tx: broadcast::Sender, ) -> Self { Self { + client_repo, feature_set_repo, event_tx, } } + /// Grant a feature set to a client in a space. + /// + /// Idempotent — re-granting an existing pair is a no-op at the DB layer + /// (`INSERT OR IGNORE`) but still fires the event so any peer that + /// missed an earlier notification gets a fresh `list_changed`. + pub async fn grant_feature_set( + &self, + client_id: &str, + space_id: &str, + feature_set_id: &str, + ) -> Result<()> { + let space_uuid = Uuid::parse_str(space_id)?; + + info!( + %client_id, + %space_id, + %feature_set_id, + "[GrantService] granting feature set" + ); + + self.client_repo + .grant_feature_set(client_id, space_id, feature_set_id) + .await?; + + let _ = self.event_tx.send(DomainEvent::ClientGrantChanged { + client_id: client_id.to_string(), + space_id: space_uuid, + }); + + Ok(()) + } + + /// Revoke a feature set from a client in a space. + pub async fn revoke_feature_set( + &self, + client_id: &str, + space_id: &str, + feature_set_id: &str, + ) -> Result<()> { + let space_uuid = Uuid::parse_str(space_id)?; + + info!( + %client_id, + %space_id, + %feature_set_id, + "[GrantService] revoking feature set" + ); + + self.client_repo + .revoke_feature_set(client_id, space_id, feature_set_id) + .await?; + + let _ = self.event_tx.send(DomainEvent::ClientGrantChanged { + client_id: client_id.to_string(), + space_id: space_uuid, + }); + + Ok(()) + } + + /// Read the granted feature_set_ids for a (client, space) pair. + pub async fn get_grants_for_space( + &self, + client_id: &str, + space_id: &str, + ) -> Result> { + self.client_repo + .get_grants_for_space(client_id, space_id) + .await + } + /// Emit a `FeatureSetMembersChanged` event for the given feature set. /// /// Call this after adding or removing members so every peer subscribed @@ -46,8 +130,8 @@ impl GrantService { let space_uuid = Uuid::parse_str(space_id)?; info!( - space_id = %space_id, - feature_set_id = %feature_set_id, + %space_id, + %feature_set_id, "[GrantService] feature set modified — emitting domain event" ); diff --git a/crates/mcpmux-gateway/src/services/meta_tools/tools.rs b/crates/mcpmux-gateway/src/services/meta_tools/tools.rs index 46bbbdc..95fc111 100644 --- a/crates/mcpmux-gateway/src/services/meta_tools/tools.rs +++ b/crates/mcpmux-gateway/src/services/meta_tools/tools.rs @@ -49,7 +49,11 @@ fn text_result(v: Value) -> CallToolResult { /// workspace B just because both sit under the same default-Space-flagged /// row in the DB. async fn caller_space_id(call: &MetaToolCall<'_>) -> Result { - let resolved = call.ctx.resolver.resolve(call.session_id).await?; + let resolved = call + .ctx + .resolver + .resolve(call.session_id, Some(call.client_id)) + .await?; if let Some(space_id) = resolved.space_id { return Ok(space_id); } diff --git a/crates/mcpmux-gateway/src/services/session_roots.rs b/crates/mcpmux-gateway/src/services/session_roots.rs index 8492100..6aa64fd 100644 --- a/crates/mcpmux-gateway/src/services/session_roots.rs +++ b/crates/mcpmux-gateway/src/services/session_roots.rs @@ -25,6 +25,16 @@ pub struct SessionRootsRegistry { /// We compare each fresh resolution to this snapshot; a different value /// means the client's effective tools changed and we must notify it. last_resolution: DashMap>, + /// `session_id -> declared MCP `roots` capability` (true when the peer's + /// `initialize.params.capabilities.roots` was non-empty). + /// + /// Stamped during `on_initialized` regardless of whether roots have + /// arrived yet. The resolver reads this to decide between + /// `WorkspaceBinding` routing (capable) and the rootless `client_grants` + /// fallback (not capable). Absence here means we never saw an + /// `initialize` for that session — treated as "unknown" by the resolver + /// and routed via grants. + roots_capable: DashMap, } impl SessionRootsRegistry { @@ -32,9 +42,23 @@ impl SessionRootsRegistry { Arc::new(Self { map: DashMap::new(), last_resolution: DashMap::new(), + roots_capable: DashMap::new(), }) } + /// Record whether a session declared the MCP `roots` capability on + /// `initialize`. Idempotent — called once per session lifecycle. + pub fn set_roots_capable(&self, session_id: impl Into, capable: bool) { + self.roots_capable.insert(session_id.into(), capable); + } + + /// `Some(true)` when the session declared `roots`, `Some(false)` when it + /// explicitly didn't, `None` when no `initialize` has been observed + /// (callers without a session id, or pre-init requests). + pub fn is_roots_capable(&self, session_id: &str) -> Option { + self.roots_capable.get(session_id).map(|v| *v) + } + /// Store the reported roots for a session. `roots` should already be /// absolute paths or `file://` URIs — we normalize them before storing. pub fn set(&self, session_id: impl Into, roots: I) @@ -59,6 +83,7 @@ impl SessionRootsRegistry { pub fn remove(&self, session_id: &str) { self.map.remove(session_id); self.last_resolution.remove(session_id); + self.roots_capable.remove(session_id); } /// Compare-and-set the session's resolved feature-set id. Returns `true` diff --git a/crates/mcpmux-storage/src/database.rs b/crates/mcpmux-storage/src/database.rs index 588023b..a8c5413 100644 --- a/crates/mcpmux-storage/src/database.rs +++ b/crates/mcpmux-storage/src/database.rs @@ -73,6 +73,36 @@ const MIGRATIONS: &[Migration] = &[ name: "canonical_default_space", sql: include_str!("migrations/008_canonical_default_space.sql"), }, + Migration { + version: 9, + name: "restore_client_grants", + sql: include_str!("migrations/009_restore_client_grants.sql"), + }, + Migration { + version: 10, + name: "inbound_client_reports_roots", + sql: include_str!("migrations/010_inbound_client_reports_roots.sql"), + }, + Migration { + version: 11, + name: "inbound_client_roots_capability_known", + sql: include_str!("migrations/011_inbound_client_roots_capability_known.sql"), + }, + Migration { + version: 12, + name: "workspace_binding_feature_sets", + sql: include_str!("migrations/012_workspace_binding_feature_sets.sql"), + }, + Migration { + version: 13, + name: "rename_default_to_starter", + sql: include_str!("migrations/013_rename_default_to_starter.sql"), + }, + Migration { + version: 14, + name: "rewrite_starter_seed_copy", + sql: include_str!("migrations/014_rewrite_starter_seed_copy.sql"), + }, ]; /// SQLite database wrapper. diff --git a/crates/mcpmux-storage/src/migrations/009_restore_client_grants.sql b/crates/mcpmux-storage/src/migrations/009_restore_client_grants.sql new file mode 100644 index 0000000..54e7f11 --- /dev/null +++ b/crates/mcpmux-storage/src/migrations/009_restore_client_grants.sql @@ -0,0 +1,29 @@ +-- Migration 009: Restore client_grants for rootless clients. +-- +-- Migration 003 dropped this table when the FeatureSetResolver was made +-- authoritative — but the resolver only handles roots-capable clients. +-- Clients that don't declare the MCP roots capability (Claude.ai web, +-- ChatGPT, …) need a per-OAuth-client default FeatureSet, which is what +-- this table stores. +-- +-- Resolution order (resolver v3): +-- 1. Session has roots + WorkspaceBinding matches → binding.fs +-- 2. Session has roots + no binding → deny + emit prompt +-- 3. Session has no roots, client roots-capable → empty (waiting on roots) +-- 4. Session has no roots, client rootless → client_grants for (client, space) +-- 5. Otherwise → deny +-- +-- Schema mirrors migration 001's pre-003 definition. + +CREATE TABLE IF NOT EXISTS client_grants ( + client_id TEXT NOT NULL, -- References inbound_clients.client_id + space_id TEXT NOT NULL, + feature_set_id TEXT NOT NULL, + PRIMARY KEY (client_id, space_id, feature_set_id), + FOREIGN KEY (client_id) REFERENCES inbound_clients(client_id) ON DELETE CASCADE, + FOREIGN KEY (space_id) REFERENCES spaces(id) ON DELETE CASCADE, + FOREIGN KEY (feature_set_id) REFERENCES feature_sets(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_client_grants_client ON client_grants(client_id); +CREATE INDEX IF NOT EXISTS idx_client_grants_space ON client_grants(space_id); diff --git a/crates/mcpmux-storage/src/migrations/010_inbound_client_reports_roots.sql b/crates/mcpmux-storage/src/migrations/010_inbound_client_reports_roots.sql new file mode 100644 index 0000000..edae4c1 --- /dev/null +++ b/crates/mcpmux-storage/src/migrations/010_inbound_client_reports_roots.sql @@ -0,0 +1,15 @@ +-- Migration 010: Track whether each OAuth client has been seen reporting +-- the MCP `roots` capability. +-- +-- The flag is stamped once per session by the gateway handler during +-- `on_initialized`. The Clients UI uses it to show a "Reports workspace" +-- vs "Rootless" badge, which in turn tells the user whether the per-client +-- grant editor on that client matters (it only does for rootless clients). +-- +-- Default = 0 (unknown / not seen). The flag is monotonic — once a client +-- is observed reporting roots we keep the bit set, even if a later session +-- doesn't (ChatGPT-style connectors may flip per session). Users who want +-- to reset the bit can revoke + re-approve the client. + +ALTER TABLE inbound_clients + ADD COLUMN reports_roots INTEGER NOT NULL DEFAULT 0; diff --git a/crates/mcpmux-storage/src/migrations/011_inbound_client_roots_capability_known.sql b/crates/mcpmux-storage/src/migrations/011_inbound_client_roots_capability_known.sql new file mode 100644 index 0000000..bd91db4 --- /dev/null +++ b/crates/mcpmux-storage/src/migrations/011_inbound_client_roots_capability_known.sql @@ -0,0 +1,25 @@ +-- Migration 011: Distinguish "we haven't seen this client initialize yet" +-- from "this client explicitly does NOT support MCP roots". +-- +-- Migration 010 added `reports_roots` defaulting to 0. The Clients UI +-- treated the column as a 2-state — but a brand-new approved client that +-- has never opened a session looks identical to a known-rootless client. +-- This migration adds an explicit "known" flag so the UI can render three +-- states: unknown (no badge), reports-workspace, rootless. +-- +-- `roots_capability_known` flips to 1 the first time the gateway processes +-- `notifications/initialized` for a session of this client. After that the +-- value is sticky. `reports_roots` remains sticky-positive: once we've +-- seen *any* session declare the capability, we treat the whole client as +-- roots-capable so a one-off rootless reconnect doesn't bounce the badge. + +ALTER TABLE inbound_clients + ADD COLUMN roots_capability_known INTEGER NOT NULL DEFAULT 0; + +-- Backfill: any row that already has reports_roots = 1 must have been +-- observed at least once, so seed it as "known". Rows with reports_roots = 0 +-- stay at "unknown" — they may legitimately be either case until we see +-- their next initialize. +UPDATE inbound_clients + SET roots_capability_known = 1 + WHERE reports_roots = 1; diff --git a/crates/mcpmux-storage/src/migrations/012_workspace_binding_feature_sets.sql b/crates/mcpmux-storage/src/migrations/012_workspace_binding_feature_sets.sql new file mode 100644 index 0000000..7dfea4b --- /dev/null +++ b/crates/mcpmux-storage/src/migrations/012_workspace_binding_feature_sets.sql @@ -0,0 +1,55 @@ +-- Migration 012: Multi-FS workspace bindings. +-- +-- One workspace root can now route into N FeatureSets (composed at the +-- resolver into a single allow set). Up until now each binding owned a +-- single `feature_set_id` column on `workspace_bindings`; this migration +-- moves to a junction table and recreates `workspace_bindings` without +-- the legacy column. +-- +-- Order: +-- 1. Create the junction. +-- 2. Backfill (binding_id, feature_set_id, sort_order=0) from each +-- current row. +-- 3. Recreate `workspace_bindings` without the column (the recreate-and- +-- copy pattern keeps us compatible with older SQLite that doesn't +-- support `ALTER TABLE … DROP COLUMN`). + +CREATE TABLE workspace_binding_feature_sets ( + binding_id TEXT NOT NULL REFERENCES workspace_bindings(id) ON DELETE CASCADE, + feature_set_id TEXT NOT NULL REFERENCES feature_sets(id) ON DELETE CASCADE, + -- Stable rendering order in the UI; resolver doesn't care about order + -- but the operator may want "primary" to render first. + sort_order INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (binding_id, feature_set_id) +); + +CREATE INDEX IF NOT EXISTS idx_wbfs_binding + ON workspace_binding_feature_sets(binding_id); + +-- Backfill — every existing binding has exactly one FS. +INSERT INTO workspace_binding_feature_sets (binding_id, feature_set_id, sort_order) +SELECT id, feature_set_id, 0 +FROM workspace_bindings; + +-- Recreate `workspace_bindings` without the legacy column. +CREATE TABLE workspace_bindings_new ( + id TEXT PRIMARY KEY, + workspace_root TEXT NOT NULL UNIQUE, + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +INSERT INTO workspace_bindings_new + (id, workspace_root, space_id, created_at, updated_at) +SELECT + id, workspace_root, space_id, created_at, updated_at +FROM workspace_bindings; + +DROP TABLE workspace_bindings; +ALTER TABLE workspace_bindings_new RENAME TO workspace_bindings; + +CREATE INDEX IF NOT EXISTS idx_workspace_bindings_root + ON workspace_bindings(workspace_root); +CREATE INDEX IF NOT EXISTS idx_workspace_bindings_space + ON workspace_bindings(space_id); diff --git a/crates/mcpmux-storage/src/migrations/013_rename_default_to_starter.sql b/crates/mcpmux-storage/src/migrations/013_rename_default_to_starter.sql new file mode 100644 index 0000000..811275b --- /dev/null +++ b/crates/mcpmux-storage/src/migrations/013_rename_default_to_starter.sql @@ -0,0 +1,18 @@ +-- Migration 013: Rename FeatureSetType `default` → `starter`. +-- +-- The "Default" name dates back to when the resolver fell back to the +-- per-Space Default FS for any unbound session. Post-resolver-v3 nothing +-- routes there automatically — the type is just a flag for "this FS got +-- auto-seeded with the Space, you can edit/rename/delete it freely." +-- "Starter" matches that role honestly. +-- +-- Idempotent: running on a fresh DB seeded with the new value is a no-op. +-- The stable id prefix `fs_default_` is intentionally NOT renamed: +-- those ids are foreign keys in `workspace_binding_feature_sets` and +-- `client_grants`, and rewriting them would cascade for no operator- +-- visible benefit. The on-disk id stays for FK integrity; only the +-- type *label* changes. + +UPDATE feature_sets + SET feature_set_type = 'starter' + WHERE feature_set_type = 'default'; diff --git a/crates/mcpmux-storage/src/migrations/014_rewrite_starter_seed_copy.sql b/crates/mcpmux-storage/src/migrations/014_rewrite_starter_seed_copy.sql new file mode 100644 index 0000000..b35a009 --- /dev/null +++ b/crates/mcpmux-storage/src/migrations/014_rewrite_starter_seed_copy.sql @@ -0,0 +1,22 @@ +-- Migration 014: Rewrite the auto-seeded Starter FS's display copy. +-- +-- Migration 001 hard-coded `name = 'Default'` and +-- `description = 'The fallback feature set for this space'` on every +-- auto-seeded FS row. Both lie under the new resolver — nothing routes +-- to this FS automatically anymore. Migration 013 fixed the *type* but +-- couldn't re-run on DBs that had already recorded it as applied, so +-- the human-readable copy stayed wrong on those installs. This migration +-- rewrites the copy. +-- +-- Safety: only updates rows that *still* match the exact seeded values. +-- An operator who renamed their auto-FS to anything else keeps their +-- custom name + description untouched. The `is_builtin = 1` filter +-- prevents collisions with a user-created FS that happens to be named +-- "Default". + +UPDATE feature_sets + SET name = 'Starter', + description = 'Auto-created with this Space. Edit, rename, or delete freely — bindings and per-client grants pick FeatureSets explicitly, so this one has no special routing role.' + WHERE is_builtin = 1 + AND name = 'Default' + AND description = 'The fallback feature set for this space'; diff --git a/crates/mcpmux-storage/src/repositories/feature_set_repository.rs b/crates/mcpmux-storage/src/repositories/feature_set_repository.rs index a450401..aaf2b43 100644 --- a/crates/mcpmux-storage/src/repositories/feature_set_repository.rs +++ b/crates/mcpmux-storage/src/repositories/feature_set_repository.rs @@ -298,16 +298,21 @@ impl FeatureSetRepository for SqliteFeatureSetRepository { Ok(()) } - async fn get_default_for_space(&self, space_id: &str) -> Result> { + async fn get_starter_for_space(&self, space_id: &str) -> Result> { let db = self.db.lock().await; let conn = db.connection(); + // Match on `'starter' OR 'default'` so a freshly-migrated DB and a + // pre-013 read both resolve correctly; migration 013 itself + // rewrites stored rows so the legacy alias is dead weight quickly. let result = conn .query_row( "SELECT id, name, description, icon, space_id, feature_set_type, server_id, is_builtin, is_deleted, created_at, updated_at FROM feature_sets - WHERE space_id = ? AND feature_set_type = 'default' AND is_deleted = 0", + WHERE space_id = ? + AND feature_set_type IN ('starter', 'default') + AND is_deleted = 0", params![space_id], Self::row_to_feature_set, ) @@ -317,9 +322,9 @@ impl FeatureSetRepository for SqliteFeatureSetRepository { } async fn ensure_builtin_for_space(&self, space_id: &str) -> Result<()> { - if self.get_default_for_space(space_id).await?.is_none() { - let default = FeatureSet::new_default(space_id); - self.create(&default).await?; + if self.get_starter_for_space(space_id).await?.is_none() { + let starter = FeatureSet::new_starter(space_id); + self.create(&starter).await?; } Ok(()) } @@ -426,22 +431,23 @@ mod tests { } #[tokio::test] - async fn test_default_feature_set_seeded_for_default_space() { + async fn test_starter_feature_set_seeded_for_default_space() { let db = Arc::new(Mutex::new(Database::open_in_memory().unwrap())); let repo = SqliteFeatureSetRepository::new(db); - // Migration 001 seeds the Default FS for the migration-created - // default space; later migrations preserve it. Confirm it's - // present and blocked from deletion (builtins aren't user-deletable). - let default = repo - .get_default_for_space(DEFAULT_SPACE_ID) + // Migration 001 seeds the auto-Starter FS for the migration- + // created default Space; migration 013 renames its type from + // 'default' to 'starter'. Confirm it's present and blocked from + // deletion (builtins aren't user-deletable). + let starter = repo + .get_starter_for_space(DEFAULT_SPACE_ID) .await .unwrap() - .expect("Default FS should exist for the default space"); - assert_eq!(default.feature_set_type, FeatureSetType::Default); + .expect("Starter FS should exist for the default space"); + assert_eq!(starter.feature_set_type, FeatureSetType::Starter); - let result = repo.delete(&default.id).await; - assert!(result.is_err(), "builtin Default FS must not be deletable"); + let result = repo.delete(&starter.id).await; + assert!(result.is_err(), "builtin Starter FS must not be deletable"); } #[tokio::test] @@ -457,10 +463,10 @@ mod tests { .unwrap(); let by_space = repo.list_by_space(DEFAULT_SPACE_ID).await.unwrap(); - let defaults = by_space + let starters = by_space .iter() - .filter(|f| matches!(f.feature_set_type, FeatureSetType::Default)) + .filter(|f| matches!(f.feature_set_type, FeatureSetType::Starter)) .count(); - assert_eq!(defaults, 1); + assert_eq!(starters, 1); } } diff --git a/crates/mcpmux-storage/src/repositories/inbound_client_repository.rs b/crates/mcpmux-storage/src/repositories/inbound_client_repository.rs index 5e84377..44676fc 100644 --- a/crates/mcpmux-storage/src/repositories/inbound_client_repository.rs +++ b/crates/mcpmux-storage/src/repositories/inbound_client_repository.rs @@ -90,6 +90,23 @@ pub struct InboundClient { pub last_seen: Option, pub created_at: String, pub updated_at: String, + + /// `true` once the gateway has observed this client declare the MCP + /// `roots` capability on `initialize`. Sticky-positive — a roots-capable + /// client that opens a one-off rootless session keeps the flag set so + /// the UI doesn't bounce. Reset by re-approving the client. + /// + /// Meaningful only when [`Self::roots_capability_known`] is `true`; for + /// `roots_capability_known = false` the value is undefined and the UI + /// treats it as "unknown". + pub reports_roots: bool, + + /// `true` once we've processed `notifications/initialized` for *any* + /// session of this client and so know whether `reports_roots` reflects + /// a real declaration. Defaults to `false` for newly-approved clients + /// that haven't opened a session yet — the UI hides the capability + /// badge in that state instead of misleadingly showing "Rootless". + pub roots_capability_known: bool, } /// Authorization code (pending exchange) @@ -165,6 +182,8 @@ impl InboundClientRepository { let grant_types_json: Option = row.get(9)?; let response_types_json: Option = row.get(10)?; let approved_int: i32 = row.get::<_, Option>(19)?.unwrap_or(0); + let reports_roots_int: i32 = row.get::<_, Option>(20)?.unwrap_or(0); + let roots_capability_known_int: i32 = row.get::<_, Option>(21)?.unwrap_or(0); Ok(InboundClient { client_id: row.get(0)?, @@ -196,6 +215,8 @@ impl InboundClientRepository { created_at: row.get(17)?, updated_at: row.get(18)?, approved: approved_int != 0, + reports_roots: reports_roots_int != 0, + roots_capability_known: roots_capability_known_int != 0, }) } @@ -205,7 +226,7 @@ impl InboundClientRepository { logo_uri, client_uri, software_id, software_version, redirect_uris, grant_types, response_types, token_endpoint_auth_method, scope, metadata_url, metadata_cached_at, metadata_cache_ttl, - last_seen, created_at, updated_at, approved"; + last_seen, created_at, updated_at, approved, reports_roots, roots_capability_known"; // ========================================================================= // Client Operations (unified inbound_clients table) @@ -678,6 +699,133 @@ impl InboundClientRepository { } Ok(deleted) } + + // ========================================================================= + // Client Grants (Feature Set Permissions for rootless OAuth clients) + // + // Consulted by FeatureSetResolverService when a session belongs to a + // client that did not declare the MCP `roots` capability (or has no + // workspace context). Roots-capable clients route through + // WorkspaceBinding instead — these methods are the rootless fallback. + // ========================================================================= + + /// Grant a feature set to a client in a specific space. + pub async fn grant_feature_set( + &self, + client_id: &str, + space_id: &str, + feature_set_id: &str, + ) -> Result<()> { + let db = self.db.lock().await; + let conn = db.connection(); + + conn.execute( + "INSERT OR IGNORE INTO client_grants (client_id, space_id, feature_set_id) + VALUES (?1, ?2, ?3)", + params![client_id, space_id, feature_set_id], + )?; + + Ok(()) + } + + /// Revoke a feature set from a client in a specific space. + pub async fn revoke_feature_set( + &self, + client_id: &str, + space_id: &str, + feature_set_id: &str, + ) -> Result<()> { + let db = self.db.lock().await; + let conn = db.connection(); + + conn.execute( + "DELETE FROM client_grants + WHERE client_id = ?1 AND space_id = ?2 AND feature_set_id = ?3", + params![client_id, space_id, feature_set_id], + )?; + + Ok(()) + } + + /// Record the MCP `roots` capability state for a client. + /// + /// Called from the gateway's `on_initialized` for *every* session, + /// regardless of whether the client declared the capability. After the + /// first call: + /// - `roots_capability_known` flips to 1 and stays there. + /// - `reports_roots` is sticky-positive: it goes 0 → 1 the first + /// session that declares roots, but a later session that doesn't + /// declare can't flip it back to 0. This prevents the UI badge + /// from bouncing on transient rootless reconnects from a normally + /// roots-capable client. + /// + /// Reset by re-approving the client (delete + re-DCR). + pub async fn mark_roots_capability(&self, client_id: &str, declares: bool) -> Result<()> { + let db = self.db.lock().await; + let conn = db.connection(); + // `MAX(reports_roots, ?2)` is the sticky-positive update — once 1, + // stays 1 even when `declares = false`. + conn.execute( + "UPDATE inbound_clients + SET roots_capability_known = 1, + reports_roots = MAX(reports_roots, ?2) + WHERE client_id = ?1", + params![client_id, declares as i32], + )?; + Ok(()) + } + + /// Get all granted feature_set_ids for a (client, space) pair. + /// Empty Vec means "no grant" → resolver returns Deny. + pub async fn get_grants_for_space( + &self, + client_id: &str, + space_id: &str, + ) -> Result> { + let db = self.db.lock().await; + let conn = db.connection(); + + let mut stmt = conn.prepare( + "SELECT feature_set_id FROM client_grants + WHERE client_id = ?1 AND space_id = ?2", + )?; + + let grants = stmt + .query_map(params![client_id, space_id], |row| row.get::<_, String>(0))? + .collect::, _>>()?; + + Ok(grants) + } + + /// Get every grant for a client across all spaces, grouped by space_id. + /// Used by the Clients UI to render the full permission picture. + pub async fn get_all_grants( + &self, + client_id: &str, + ) -> Result>> { + let db = self.db.lock().await; + let conn = db.connection(); + + let mut stmt = conn.prepare( + "SELECT space_id, feature_set_id FROM client_grants + WHERE client_id = ?1 + ORDER BY space_id", + )?; + + let mut grants: std::collections::HashMap> = + std::collections::HashMap::new(); + + let rows = stmt.query_map(params![client_id], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + })?; + + for row in rows { + let (space_id, feature_set_id) = row?; + grants.entry(space_id).or_default().push(feature_set_id); + } + + Ok(grants) + } } #[cfg(test)] diff --git a/crates/mcpmux-storage/src/repositories/space_repository.rs b/crates/mcpmux-storage/src/repositories/space_repository.rs index 49e9deb..d17c906 100644 --- a/crates/mcpmux-storage/src/repositories/space_repository.rs +++ b/crates/mcpmux-storage/src/repositories/space_repository.rs @@ -106,12 +106,14 @@ impl SpaceRepository for SqliteSpaceRepository { ], )?; - // Auto-create the builtin "Default" featureset for this space. - // It's the fallback FS when no WorkspaceBinding matches. No other - // builtin sets are seeded — user can create Custom sets as needed. + // Auto-seed the builtin "Starter" FeatureSet for this Space — a + // ready-to-use starting point. The id prefix `fs_default_` + // is preserved for FK-stability across the rename (migration 013). + // No special routing role under resolver v3 — bindings and per- + // client grants pick FeatureSets explicitly. conn.execute( "INSERT OR IGNORE INTO feature_sets (id, name, description, icon, space_id, feature_set_type, is_builtin, created_at, updated_at) - VALUES (?1, 'Default', 'The fallback feature set for this space', '⭐', ?2, 'default', 1, ?3, ?3)", + VALUES (?1, 'Starter', 'Auto-created with this Space. Edit, rename, or delete freely — bindings and per-client grants pick FeatureSets explicitly, so this one has no special routing role.', '⭐', ?2, 'starter', 1, ?3, ?3)", params![ format!("fs_default_{}", space_id), space_id, diff --git a/crates/mcpmux-storage/src/repositories/workspace_binding_repository.rs b/crates/mcpmux-storage/src/repositories/workspace_binding_repository.rs index bdc7507..7f3df1a 100644 --- a/crates/mcpmux-storage/src/repositories/workspace_binding_repository.rs +++ b/crates/mcpmux-storage/src/repositories/workspace_binding_repository.rs @@ -1,27 +1,37 @@ //! SQLite implementation of [`WorkspaceBindingRepository`]. //! -//! Schema after migration 007 (concrete-pointers model): +//! Schema after migration 012 (multi-FS bindings): //! //! ```text //! workspace_bindings //! id TEXT PK //! workspace_root TEXT UNIQUE — routing key, globally unique //! space_id TEXT NOT NULL — FK → spaces(id) -//! feature_set_id TEXT NOT NULL — FK → feature_sets(id) //! created_at TEXT NOT NULL //! updated_at TEXT NOT NULL +//! +//! workspace_binding_feature_sets (junction) +//! binding_id TEXT NOT NULL — FK → workspace_bindings(id) +//! feature_set_id TEXT NOT NULL — FK → feature_sets(id) +//! sort_order INTEGER — UI render order; resolver-irrelevant +//! PK (binding_id, feature_set_id) //! ``` //! +//! Each binding owns ≥ 1 FeatureSet. The repository surfaces them as +//! `WorkspaceBinding.feature_set_ids` (sorted by `sort_order`) so callers +//! can stop reasoning about the join. +//! //! Longest-prefix matching (used by the resolver) is done in-memory against //! `list()` since a mcpmux DB is expected to hold O(tens) of bindings. +use std::collections::HashMap; use std::sync::Arc; use anyhow::Result; use async_trait::async_trait; use chrono::{DateTime, Utc}; use mcpmux_core::{longest_prefix_match, WorkspaceBinding, WorkspaceBindingRepository}; -use rusqlite::{params, OptionalExtension}; +use rusqlite::params; use tokio::sync::Mutex; use uuid::Uuid; @@ -46,106 +56,192 @@ impl SqliteWorkspaceBindingRepository { Utc::now() } - fn row_to_binding(row: &rusqlite::Row<'_>) -> rusqlite::Result { + /// Map a row from `workspace_bindings` (columns in the order of + /// [`Self::SELECT_COLS`]) to a partially-populated [`WorkspaceBinding`] + /// — `feature_set_ids` is filled by the caller from the junction. + fn row_to_binding_no_fs(row: &rusqlite::Row<'_>) -> rusqlite::Result { let id_str: String = row.get(0)?; let workspace_root: String = row.get(1)?; let space_id_str: String = row.get(2)?; - let feature_set_id: String = row.get(3)?; - let created_at: String = row.get(4)?; - let updated_at: String = row.get(5)?; + let created_at: String = row.get(3)?; + let updated_at: String = row.get(4)?; Ok(WorkspaceBinding { id: id_str.parse().unwrap_or_else(|_| Uuid::new_v4()), workspace_root, space_id: space_id_str.parse().unwrap_or_else(|_| Uuid::nil()), - feature_set_id, + feature_set_ids: Vec::new(), // filled in by caller created_at: Self::parse_datetime(&created_at), updated_at: Self::parse_datetime(&updated_at), }) } - const SELECT_COLS: &'static str = - "id, workspace_root, space_id, feature_set_id, created_at, updated_at"; -} + /// Bulk-load `(binding_id, feature_set_ids)` from the junction for the + /// given binding ids, ordered by `sort_order` then `feature_set_id` + /// (stable, so the UI doesn't shuffle). + fn load_fs_for_bindings( + conn: &rusqlite::Connection, + binding_ids: &[String], + ) -> rusqlite::Result>> { + if binding_ids.is_empty() { + return Ok(HashMap::new()); + } -#[async_trait] -impl WorkspaceBindingRepository for SqliteWorkspaceBindingRepository { - async fn list(&self) -> Result> { - let db = self.db.lock().await; - let conn = db.connection(); + // Build a `(?, ?, …)` placeholder list — rusqlite has no native + // IN-array binding, so we expand manually. + let placeholders = std::iter::repeat_n("?", binding_ids.len()) + .collect::>() + .join(", "); let sql = format!( - "SELECT {} FROM workspace_bindings ORDER BY workspace_root", - Self::SELECT_COLS + "SELECT binding_id, feature_set_id + FROM workspace_binding_feature_sets + WHERE binding_id IN ({placeholders}) + ORDER BY binding_id, sort_order, feature_set_id" ); let mut stmt = conn.prepare(&sql)?; - let bindings = stmt - .query_map([], Self::row_to_binding)? - .collect::, _>>()?; - Ok(bindings) + let params_dyn: Vec<&dyn rusqlite::ToSql> = binding_ids + .iter() + .map(|s| s as &dyn rusqlite::ToSql) + .collect(); + let rows = stmt.query_map(params_dyn.as_slice(), |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + })?; + + let mut grouped: HashMap> = HashMap::new(); + for row in rows { + let (binding_id, fs_id) = row?; + grouped.entry(binding_id).or_default().push(fs_id); + } + Ok(grouped) } - async fn list_for_space(&self, space_id: &Uuid) -> Result> { + /// Replace the junction rows for `binding_id` with the supplied list, + /// preserving `sort_order` from the slice's index. Used by both + /// create() and update() so they share the write path. + fn rewrite_fs_for_binding( + conn: &rusqlite::Connection, + binding_id: &str, + feature_set_ids: &[String], + ) -> rusqlite::Result<()> { + conn.execute( + "DELETE FROM workspace_binding_feature_sets WHERE binding_id = ?1", + params![binding_id], + )?; + for (idx, fs_id) in feature_set_ids.iter().enumerate() { + conn.execute( + "INSERT INTO workspace_binding_feature_sets + (binding_id, feature_set_id, sort_order) + VALUES (?1, ?2, ?3)", + params![binding_id, fs_id, idx as i64], + )?; + } + Ok(()) + } + + const SELECT_COLS: &'static str = "id, workspace_root, space_id, created_at, updated_at"; + + /// Fetch bindings + their FeatureSet lists in two queries. + /// `where_clause` is appended to the binding SELECT (use `""` for none); + /// `string_params` are bound to its placeholders in order. + /// + /// Owned `String` params keep this future `Send` — passing borrowed + /// `&dyn ToSql` slices breaks `async_trait`'s `Send` requirement + /// because `dyn ToSql` isn't `Sync`. + async fn fetch_bindings( + &self, + where_clause: &str, + string_params: Vec, + ) -> Result> { let db = self.db.lock().await; let conn = db.connection(); let sql = format!( - "SELECT {} FROM workspace_bindings WHERE space_id = ? ORDER BY workspace_root", - Self::SELECT_COLS + "SELECT {} FROM workspace_bindings {} ORDER BY workspace_root", + Self::SELECT_COLS, + where_clause, ); let mut stmt = conn.prepare(&sql)?; - let bindings = stmt - .query_map(params![space_id.to_string()], Self::row_to_binding)? + let params_dyn: Vec<&dyn rusqlite::ToSql> = string_params + .iter() + .map(|s| s as &dyn rusqlite::ToSql) + .collect(); + let mut bindings: Vec = stmt + .query_map(params_dyn.as_slice(), Self::row_to_binding_no_fs)? .collect::, _>>()?; + + let ids: Vec = bindings.iter().map(|b| b.id.to_string()).collect(); + let mut fs_map = Self::load_fs_for_bindings(conn, &ids)?; + for binding in &mut bindings { + if let Some(fs_ids) = fs_map.remove(&binding.id.to_string()) { + binding.feature_set_ids = fs_ids; + } + } Ok(bindings) } +} + +#[async_trait] +impl WorkspaceBindingRepository for SqliteWorkspaceBindingRepository { + async fn list(&self) -> Result> { + self.fetch_bindings("", Vec::new()).await + } + + async fn list_for_space(&self, space_id: &Uuid) -> Result> { + self.fetch_bindings("WHERE space_id = ?", vec![space_id.to_string()]) + .await + } async fn get(&self, id: &Uuid) -> Result> { - let db = self.db.lock().await; - let conn = db.connection(); - let sql = format!( - "SELECT {} FROM workspace_bindings WHERE id = ?", - Self::SELECT_COLS - ); - let mut stmt = conn.prepare(&sql)?; - let binding = stmt - .query_row(params![id.to_string()], Self::row_to_binding) - .optional()?; - Ok(binding) + let mut bindings = self + .fetch_bindings("WHERE id = ?", vec![id.to_string()]) + .await?; + Ok(bindings.pop()) } async fn create(&self, binding: &WorkspaceBinding) -> Result<()> { + if binding.feature_set_ids.is_empty() { + anyhow::bail!( + "WorkspaceBinding {} must have at least one feature_set_id", + binding.id + ); + } let db = self.db.lock().await; let conn = db.connection(); conn.execute( "INSERT INTO workspace_bindings - (id, workspace_root, space_id, feature_set_id, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + (id, workspace_root, space_id, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5)", params![ binding.id.to_string(), binding.workspace_root, binding.space_id.to_string(), - binding.feature_set_id, binding.created_at.to_rfc3339(), binding.updated_at.to_rfc3339(), ], )?; + Self::rewrite_fs_for_binding(conn, &binding.id.to_string(), &binding.feature_set_ids)?; Ok(()) } async fn update(&self, binding: &WorkspaceBinding) -> Result<()> { + if binding.feature_set_ids.is_empty() { + anyhow::bail!( + "WorkspaceBinding {} must have at least one feature_set_id", + binding.id + ); + } let db = self.db.lock().await; let conn = db.connection(); let rows_affected = conn.execute( "UPDATE workspace_bindings - SET workspace_root = ?2, space_id = ?3, feature_set_id = ?4, updated_at = ?5 + SET workspace_root = ?2, space_id = ?3, updated_at = ?4 WHERE id = ?1", params![ binding.id.to_string(), binding.workspace_root, binding.space_id.to_string(), - binding.feature_set_id, binding.updated_at.to_rfc3339(), ], )?; @@ -154,12 +250,18 @@ impl WorkspaceBindingRepository for SqliteWorkspaceBindingRepository { anyhow::bail!("WorkspaceBinding not found: {}", binding.id); } + // Rewrite the junction. ON DELETE CASCADE on the FK means a binding + // delete cleans up automatically, but for an update we have to do + // it manually — the user may have re-ordered or swapped FSes. + Self::rewrite_fs_for_binding(conn, &binding.id.to_string(), &binding.feature_set_ids)?; + Ok(()) } async fn delete(&self, id: &Uuid) -> Result<()> { let db = self.db.lock().await; let conn = db.connection(); + // Junction rows go away via ON DELETE CASCADE. conn.execute( "DELETE FROM workspace_bindings WHERE id = ?", params![id.to_string()], @@ -236,6 +338,22 @@ mod tests { (repo, space_id, fs_id) } + async fn add_fs(db: &Arc>, space_id: Uuid, name: &str) -> String { + let fs = FeatureSet::new_custom(name, space_id.to_string()); + let fs_id = fs.id.clone(); + let now = Utc::now().to_rfc3339(); + let guard = db.lock().await; + guard + .connection() + .execute( + "INSERT INTO feature_sets (id, name, feature_set_type, space_id, is_builtin, created_at, updated_at) + VALUES (?1, ?2, 'custom', ?3, 0, ?4, ?4)", + params![fs.id, name, space_id.to_string(), now], + ) + .unwrap(); + fs_id + } + #[tokio::test] async fn test_crud_round_trip() { let (repo, space_id, fs_id) = fixture().await; @@ -246,7 +364,42 @@ mod tests { let got = repo.get(&binding.id).await.unwrap().unwrap(); assert_eq!(got.workspace_root, root); assert_eq!(got.space_id, space_id); - assert_eq!(got.feature_set_id, fs_id); + assert_eq!(got.feature_set_ids, vec![fs_id]); + } + + #[tokio::test] + async fn test_multi_fs_round_trip() { + let (repo, space_id, fs_id1) = fixture().await; + // Need to construct a fresh DB-backed FS pair to satisfy the FK. + // Reach back into the same DB the repo was built around by going + // through a second `add_fs`. + let db = repo.db.clone(); + let fs_id2 = add_fs(&db, space_id, "second").await; + + let root = if cfg!(windows) { "d:\\multi" } else { "/multi" }; + let binding = + WorkspaceBinding::new_multi(root, space_id, vec![fs_id1.clone(), fs_id2.clone()]); + repo.create(&binding).await.unwrap(); + + let got = repo.get(&binding.id).await.unwrap().unwrap(); + // Insertion order preserved via sort_order. + assert_eq!(got.feature_set_ids, vec![fs_id1.clone(), fs_id2.clone()]); + + // Update — drop one, reorder. + let mut updated = got; + updated.feature_set_ids = vec![fs_id2.clone()]; + repo.update(&updated).await.unwrap(); + let after = repo.get(&binding.id).await.unwrap().unwrap(); + assert_eq!(after.feature_set_ids, vec![fs_id2]); + } + + #[tokio::test] + async fn test_create_rejects_empty_fs_list() { + let (repo, space_id, _) = fixture().await; + let root = if cfg!(windows) { "d:\\empty" } else { "/empty" }; + let binding = WorkspaceBinding::new_multi(root, space_id, vec![]); + let err = repo.create(&binding).await.unwrap_err(); + assert!(err.to_string().contains("at least one feature_set_id")); } #[tokio::test] diff --git a/tests/rust/src/lib.rs b/tests/rust/src/lib.rs index 5770e97..b6eff50 100644 --- a/tests/rust/src/lib.rs +++ b/tests/rust/src/lib.rs @@ -162,9 +162,9 @@ pub mod fixtures { .with_description(format!("Test feature set: {}", name)) } - /// Create a "default" feature set - pub fn default_feature_set(space_id: &str) -> FeatureSet { - FeatureSet::new_default(space_id) + /// Create the auto-seeded "Starter" FeatureSet for a Space. + pub fn starter_feature_set(space_id: &str) -> FeatureSet { + FeatureSet::new_starter(space_id) } /// Generate a random UUID string diff --git a/tests/rust/src/mocks.rs b/tests/rust/src/mocks.rs index 42d7b81..57ab83b 100644 --- a/tests/rust/src/mocks.rs +++ b/tests/rust/src/mocks.rs @@ -400,7 +400,7 @@ impl FeatureSetRepository for MockFeatureSetRepository { Ok(()) } - async fn get_default_for_space(&self, space_id: &str) -> RepoResult> { + async fn get_starter_for_space(&self, space_id: &str) -> RepoResult> { Ok(self .sets .read() @@ -408,14 +408,14 @@ impl FeatureSetRepository for MockFeatureSetRepository { .values() .find(|s| { s.space_id.as_deref() == Some(space_id) - && s.feature_set_type == FeatureSetType::Default + && s.feature_set_type == FeatureSetType::Starter }) .cloned()) } async fn ensure_builtin_for_space(&self, space_id: &str) -> RepoResult<()> { - if self.get_default_for_space(space_id).await?.is_none() { - self.create(&FeatureSet::new_default(space_id)).await?; + if self.get_starter_for_space(space_id).await?.is_none() { + self.create(&FeatureSet::new_starter(space_id)).await?; } Ok(()) } diff --git a/tests/rust/tests/database/feature_set.rs b/tests/rust/tests/database/feature_set.rs index 6a91183..c160e88 100644 --- a/tests/rust/tests/database/feature_set.rs +++ b/tests/rust/tests/database/feature_set.rs @@ -153,20 +153,20 @@ async fn test_ensure_builtin_for_space() { let space = fixtures::test_space("Test Space"); SpaceRepository::create(&space_repo, &space).await.unwrap(); - // Ensure builtin (only Default; All/ServerAll were removed) + // Ensure builtin (only the auto-Starter; All/ServerAll were removed) FeatureSetRepository::ensure_builtin_for_space(&feature_repo, &space.id.to_string()) .await .expect("Failed to ensure builtin"); - // Get Default feature set - let default_set = - FeatureSetRepository::get_default_for_space(&feature_repo, &space.id.to_string()) + // Get Starter feature set + let starter_set = + FeatureSetRepository::get_starter_for_space(&feature_repo, &space.id.to_string()) .await - .expect("Failed to get Default"); - assert!(default_set.is_some()); + .expect("Failed to get Starter"); + assert!(starter_set.is_some()); assert_eq!( - default_set.unwrap().feature_set_type, - FeatureSetType::Default + starter_set.unwrap().feature_set_type, + FeatureSetType::Starter ); } @@ -191,11 +191,11 @@ async fn test_ensure_builtin_idempotent() { let by_space = FeatureSetRepository::list_by_space(&feature_repo, &space.id.to_string()) .await .expect("Failed to list by space"); - let default_count = by_space + let starter_count = by_space .iter() - .filter(|fs| matches!(fs.feature_set_type, FeatureSetType::Default)) + .filter(|fs| matches!(fs.feature_set_type, FeatureSetType::Starter)) .count(); - assert_eq!(default_count, 1, "exactly one Default FS per space"); + assert_eq!(starter_count, 1, "exactly one Starter FS per space"); } // ============================================================================= @@ -368,20 +368,22 @@ async fn test_feature_set_types() { let space = fixtures::test_space("Test Space"); SpaceRepository::create(&space_repo, &space).await.unwrap(); - // Space creation auto-seeds the Default FS; add a Custom one by hand. + // Space creation auto-seeds the Starter FS; add a Custom one by hand. let custom = fixtures::test_feature_set("Custom", &space.id.to_string()); FeatureSetRepository::create(&feature_repo, &custom) .await .unwrap(); - let default_id = format!("fs_default_{}", space.id); + // Stable id prefix kept for FK compatibility — `fs_default_` + // remains the row id even after the type rename. + let starter_id = format!("fs_default_{}", space.id); - let default_loaded = FeatureSetRepository::get(&feature_repo, &default_id) + let starter_loaded = FeatureSetRepository::get(&feature_repo, &starter_id) .await .unwrap() .unwrap(); - assert_eq!(default_loaded.feature_set_type, FeatureSetType::Default); + assert_eq!(starter_loaded.feature_set_type, FeatureSetType::Starter); let custom_loaded = FeatureSetRepository::get(&feature_repo, &custom.id) .await diff --git a/tests/rust/tests/database/inbound_client.rs b/tests/rust/tests/database/inbound_client.rs index 5ca7639..b645bfe 100644 --- a/tests/rust/tests/database/inbound_client.rs +++ b/tests/rust/tests/database/inbound_client.rs @@ -37,6 +37,8 @@ fn create_test_client(name: &str) -> InboundClient { last_seen: None, created_at: now.clone(), updated_at: now, + reports_roots: false, + roots_capability_known: false, } } diff --git a/tests/rust/tests/integration/feature_set_resolver.rs b/tests/rust/tests/integration/feature_set_resolver.rs index 402c6a1..4115979 100644 --- a/tests/rust/tests/integration/feature_set_resolver.rs +++ b/tests/rust/tests/integration/feature_set_resolver.rs @@ -1,12 +1,15 @@ -//! Decision-table tests for the FeatureSet resolver. +//! Decision-table tests for the FeatureSet resolver (capability-branched v3). //! -//! Post-simplification the resolver has exactly two outcomes: -//! -//! 1. **WorkspaceBinding** — session reports roots AND a binding matches. -//! Both `space_id` and `feature_set_id` are pulled directly from the -//! binding row — no "active FS" indirection. -//! 2. **Default** — no roots / no match. Returns the default Space's -//! auto-seeded `fs_default_` FeatureSet. +//! Outcomes: +//! 1. **WorkspaceBinding** — session reported roots AND a binding matched +//! one of them. `space_id` + `feature_set_ids[0]` come from the binding. +//! 2. **PendingRoots** — session declared MCP `roots` capability but the +//! list hasn't arrived yet. Empty FS list; resolver fires +//! `list_changed` later when roots populate. +//! 3. **ClientGrant** — rootless-by-design client. Per-client grants +//! from the `client_grants` table apply. +//! 4. **Deny** — every other case (roots reported but no binding; no +//! session id and no grants; etc.). Empty FS list. use std::sync::Arc; @@ -16,7 +19,8 @@ use mcpmux_core::{ }; use mcpmux_gateway::services::{FeatureSetResolverService, ResolutionSource, SessionRootsRegistry}; use mcpmux_storage::{ - Database, SqliteFeatureSetRepository, SqliteSpaceRepository, SqliteWorkspaceBindingRepository, + Database, InboundClient, InboundClientRepository, RegistrationType, SqliteFeatureSetRepository, + SqliteSpaceRepository, SqliteWorkspaceBindingRepository, }; use tokio::sync::Mutex; use uuid::Uuid; @@ -25,8 +29,8 @@ struct Fixture { resolver: FeatureSetResolverService, session_roots: Arc, binding_repo: Arc, + client_repo: Arc, space_id: Uuid, - default_fs_id: String, fs_a_id: String, fs_b_id: String, } @@ -39,18 +43,11 @@ impl Fixture { Arc::new(SqliteFeatureSetRepository::new(db.clone())); let binding_repo: Arc = Arc::new(SqliteWorkspaceBindingRepository::new(db.clone())); + let client_repo = Arc::new(InboundClientRepository::new(db.clone())); let default_space = space_repo.get_default().await.unwrap().unwrap(); let space_id = default_space.id; - // Migration seeds exactly one builtin per space: Default. - let default_fs_id = fs_repo - .get_default_for_space(&space_id.to_string()) - .await - .unwrap() - .expect("Default FS seeded by migration") - .id; - let a = FeatureSet::new_custom("A", space_id.to_string()); let b = FeatureSet::new_custom("B", space_id.to_string()); fs_repo.create(&a).await.unwrap(); @@ -62,20 +59,51 @@ impl Fixture { let resolver = FeatureSetResolverService::new( space_repo.clone(), binding_repo.clone(), - fs_repo.clone(), session_roots.clone(), + client_repo.clone(), ); Self { resolver, session_roots, binding_repo, + client_repo, space_id, - default_fs_id, fs_a_id, fs_b_id, } } + + /// Insert an inbound client row so we can attach grants to it (the + /// `client_grants` FK requires the row to exist). + async fn make_client(&self, client_id: &str) { + let now = chrono::Utc::now().to_rfc3339(); + let c = InboundClient { + client_id: client_id.to_string(), + registration_type: RegistrationType::Dcr, + client_name: "test-client".to_string(), + client_alias: None, + redirect_uris: vec!["http://localhost/cb".to_string()], + grant_types: vec!["authorization_code".to_string()], + response_types: vec!["code".to_string()], + token_endpoint_auth_method: "none".to_string(), + scope: None, + approved: true, + logo_uri: None, + client_uri: None, + software_id: None, + software_version: None, + metadata_url: None, + metadata_cached_at: None, + metadata_cache_ttl: None, + last_seen: None, + created_at: now.clone(), + updated_at: now, + reports_roots: false, + roots_capability_known: false, + }; + self.client_repo.save_client(&c).await.unwrap(); + } } fn test_root() -> &'static str { @@ -87,38 +115,56 @@ fn test_root() -> &'static str { } // --------------------------------------------------------------------------- -// Tier 2: Default fallback +// Deny tier // --------------------------------------------------------------------------- #[tokio::test] -async fn default_when_no_session_id() { +async fn deny_when_no_session_id_and_no_grants() { let f = Fixture::new().await; - let r = f.resolver.resolve(None).await.unwrap(); - assert_eq!(r.source, ResolutionSource::Default); + let r = f.resolver.resolve(None, None).await.unwrap(); + assert_eq!(r.source, ResolutionSource::Deny); + assert!(r.feature_set_ids.is_empty()); assert_eq!(r.space_id, Some(f.space_id)); - assert_eq!(r.feature_set_id, Some(f.default_fs_id)); } #[tokio::test] -async fn default_when_session_has_no_roots() { +async fn deny_when_session_has_no_roots_and_not_capable() { + // Default capability state is "unknown" (None). The resolver treats + // missing capability info as rootless, so this falls through to Tier 2 + // (no client_id supplied → Deny). let f = Fixture::new().await; - let r = f.resolver.resolve(Some("orphan")).await.unwrap(); - assert_eq!(r.source, ResolutionSource::Default); - assert_eq!(r.feature_set_id, Some(f.default_fs_id)); + let r = f.resolver.resolve(Some("orphan"), None).await.unwrap(); + assert_eq!(r.source, ResolutionSource::Deny); } #[tokio::test] -async fn default_when_no_binding_matches_reported_root() { +async fn deny_when_roots_reported_but_no_binding_matches() { let f = Fixture::new().await; let other = if cfg!(windows) { "d:\\tmp" } else { "/tmp" }; f.session_roots.set("sess", [other]); - let r = f.resolver.resolve(Some("sess")).await.unwrap(); - assert_eq!(r.source, ResolutionSource::Default); - assert_eq!(r.feature_set_id, Some(f.default_fs_id)); + let r = f.resolver.resolve(Some("sess"), None).await.unwrap(); + // Roots present but no binding → upstream emits WorkspaceNeedsBinding; + // resolver itself reports Deny (no FS to apply). + assert_eq!(r.source, ResolutionSource::Deny); + assert!(r.feature_set_ids.is_empty()); } // --------------------------------------------------------------------------- -// Tier 1: WorkspaceBinding — concrete (space_id, feature_set_id) pointers +// PendingRoots tier +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn pending_when_capable_but_roots_havent_arrived() { + let f = Fixture::new().await; + f.session_roots.set_roots_capable("sess", true); + // No roots set in the registry yet. + let r = f.resolver.resolve(Some("sess"), None).await.unwrap(); + assert_eq!(r.source, ResolutionSource::PendingRoots); + assert!(r.feature_set_ids.is_empty()); +} + +// --------------------------------------------------------------------------- +// WorkspaceBinding tier // --------------------------------------------------------------------------- #[tokio::test] @@ -131,11 +177,12 @@ async fn binding_routes_to_its_target_space_and_fs() { ); f.binding_repo.create(&binding).await.unwrap(); f.session_roots.set("s", [test_root()]); + f.session_roots.set_roots_capable("s", true); - let r = f.resolver.resolve(Some("s")).await.unwrap(); + let r = f.resolver.resolve(Some("s"), None).await.unwrap(); assert_eq!(r.source, ResolutionSource::WorkspaceBinding); assert_eq!(r.space_id, Some(f.space_id)); - assert_eq!(r.feature_set_id, Some(f.fs_a_id)); + assert_eq!(r.feature_set_ids, vec![f.fs_a_id]); } #[tokio::test] @@ -169,8 +216,72 @@ async fn longest_prefix_wins_across_nested_bindings() { "/work/proj/src" }; f.session_roots.set("s", [deep]); + f.session_roots.set_roots_capable("s", true); - let r = f.resolver.resolve(Some("s")).await.unwrap(); + let r = f.resolver.resolve(Some("s"), None).await.unwrap(); assert_eq!(r.source, ResolutionSource::WorkspaceBinding); - assert_eq!(r.feature_set_id, Some(f.fs_b_id)); + assert_eq!(r.feature_set_ids, vec![f.fs_b_id]); +} + +// --------------------------------------------------------------------------- +// ClientGrant tier — rootless fallback +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn rootless_client_uses_grants() { + let f = Fixture::new().await; + let client_id = "rootless.example/client"; + f.make_client(client_id).await; + f.client_repo + .grant_feature_set(client_id, &f.space_id.to_string(), &f.fs_a_id) + .await + .unwrap(); + + // Session declared no roots capability — Tier-2 grant lookup applies. + f.session_roots.set_roots_capable("s", false); + let r = f + .resolver + .resolve(Some("s"), Some(client_id)) + .await + .unwrap(); + assert_eq!(r.source, ResolutionSource::ClientGrant); + assert_eq!(r.feature_set_ids, vec![f.fs_a_id]); +} + +#[tokio::test] +async fn rootless_client_without_grants_denies() { + let f = Fixture::new().await; + let client_id = "rootless.example/no-grants"; + f.make_client(client_id).await; + f.session_roots.set_roots_capable("s", false); + let r = f + .resolver + .resolve(Some("s"), Some(client_id)) + .await + .unwrap(); + assert_eq!(r.source, ResolutionSource::Deny); + assert!(r.feature_set_ids.is_empty()); +} + +#[tokio::test] +async fn capable_session_does_not_fall_through_to_grants() { + // Critical: the leak we set out to fix. A roots-capable session whose + // roots haven't arrived yet must NOT pick up any client grants. It + // returns PendingRoots and only resolves once the roots actually land. + let f = Fixture::new().await; + let client_id = "permissive.example/client"; + f.make_client(client_id).await; + f.client_repo + .grant_feature_set(client_id, &f.space_id.to_string(), &f.fs_a_id) + .await + .unwrap(); + + f.session_roots.set_roots_capable("s", true); + let r = f + .resolver + .resolve(Some("s"), Some(client_id)) + .await + .unwrap(); + assert_eq!(r.source, ResolutionSource::PendingRoots); + assert!(r.feature_set_ids.is_empty()); } diff --git a/tests/rust/tests/integration/meta_tools.rs b/tests/rust/tests/integration/meta_tools.rs index 5245c99..4b81e6d 100644 --- a/tests/rust/tests/integration/meta_tools.rs +++ b/tests/rust/tests/integration/meta_tools.rs @@ -21,8 +21,9 @@ use mcpmux_gateway::services::{ FeatureSetResolverService, MetaToolRegistry, PrefixCacheService, SessionRootsRegistry, }; use mcpmux_storage::{ - Database, SqliteFeatureSetRepository, SqliteInboundMcpClientRepository, - SqliteServerFeatureRepository, SqliteSpaceRepository, SqliteWorkspaceBindingRepository, + Database, InboundClientRepository, SqliteFeatureSetRepository, + SqliteInboundMcpClientRepository, SqliteServerFeatureRepository, SqliteSpaceRepository, + SqliteWorkspaceBindingRepository, }; use serde_json::{json, Value}; use tokio::sync::{broadcast, Mutex}; @@ -94,11 +95,12 @@ impl Fixture { let session_roots = SessionRootsRegistry::new(); let session_id = "sess-meta".to_string(); + let inbound_client_repo = Arc::new(InboundClientRepository::new(db.clone())); let resolver = Arc::new(FeatureSetResolverService::new( space_repo.clone(), binding_repo.clone(), - feature_set_repo.clone(), session_roots.clone(), + inbound_client_repo.clone(), )); let prefix_cache = Arc::new(PrefixCacheService::new()); @@ -395,7 +397,10 @@ async fn bind_current_workspace_creates_binding_with_normalized_root() { assert!(!stored.ends_with('/') && !stored.ends_with('\\')); // Binding points at the concrete FS we passed in. assert_eq!(bindings[0].space_id, f.space_id); - assert_eq!(bindings[0].feature_set_id, f.fs_android_id.to_string()); + assert_eq!( + bindings[0].feature_set_ids, + vec![f.fs_android_id.to_string()] + ); } #[tokio::test(flavor = "multi_thread")] @@ -486,11 +491,12 @@ async fn bare_registry( let client_id = client.id.to_string(); client_repo.create(&client).await.unwrap(); + let inbound_client_repo = Arc::new(InboundClientRepository::new(db.clone())); let resolver = Arc::new(FeatureSetResolverService::new( space_repo.clone(), binding_repo.clone(), - feature_set_repo.clone(), SessionRootsRegistry::new(), + inbound_client_repo.clone(), )); let prefix_cache = Arc::new(PrefixCacheService::new()); let feature_service = Arc::new(FeatureService::new( @@ -597,11 +603,12 @@ async fn master_switch_toggles_registry_visibility() { Arc::new(SqliteWorkspaceBindingRepository::new(db.clone())); let server_feature_repo: Arc = Arc::new(SqliteServerFeatureRepository::new(db.clone())); + let inbound_client_repo = Arc::new(InboundClientRepository::new(db.clone())); let resolver = Arc::new(FeatureSetResolverService::new( space_repo.clone(), binding_repo.clone(), - feature_set_repo.clone(), SessionRootsRegistry::new(), + inbound_client_repo.clone(), )); let prefix_cache = Arc::new(PrefixCacheService::new()); let feature_service = Arc::new(FeatureService::new( diff --git a/tests/rust/tests/integration/workspace_binding_events.rs b/tests/rust/tests/integration/workspace_binding_events.rs index 2c57c73..d691336 100644 --- a/tests/rust/tests/integration/workspace_binding_events.rs +++ b/tests/rust/tests/integration/workspace_binding_events.rs @@ -7,9 +7,10 @@ //! //! 1. `WorkspaceBindingChanged` + `WorkspaceNeedsBinding` round-trip through //! JSON with the shape the Tauri bridge and the frontend consumers expect. -//! 2. The resolver's decision table: roots + no binding → `source = Default` -//! (the trigger the gateway uses to decide whether to emit the event). -//! 3. Creating / updating a binding flips the next resolution from Default to +//! 2. The resolver's decision table: roots + no binding → `source = Deny` +//! (the trigger the gateway uses to decide whether to emit the +//! `WorkspaceNeedsBinding` prompt). +//! 3. Creating / updating a binding flips the next resolution from Deny to //! WorkspaceBinding — the behaviour that justifies firing list_changed. use std::sync::Arc; @@ -20,7 +21,8 @@ use mcpmux_core::{ }; use mcpmux_gateway::services::{FeatureSetResolverService, ResolutionSource, SessionRootsRegistry}; use mcpmux_storage::{ - Database, SqliteFeatureSetRepository, SqliteSpaceRepository, SqliteWorkspaceBindingRepository, + Database, InboundClientRepository, SqliteFeatureSetRepository, SqliteSpaceRepository, + SqliteWorkspaceBindingRepository, }; use tokio::sync::Mutex; use uuid::Uuid; @@ -41,6 +43,7 @@ impl Ctx { Arc::new(SqliteFeatureSetRepository::new(db.clone())); let binding_repo: Arc = Arc::new(SqliteWorkspaceBindingRepository::new(db.clone())); + let inbound_client_repo = Arc::new(InboundClientRepository::new(db.clone())); let default_space = space_repo.get_default().await.unwrap().unwrap(); let space_id = default_space.id; @@ -52,8 +55,8 @@ impl Ctx { let resolver = FeatureSetResolverService::new( space_repo.clone(), binding_repo.clone(), - fs_repo.clone(), session_roots.clone(), + inbound_client_repo.clone(), ); Self { @@ -66,20 +69,22 @@ impl Ctx { } } -/// Session with roots, no binding → resolver returns `source = Default`. +/// Session with roots, no binding → resolver returns `source = Deny`. /// This is the exact condition `handler.rs::log_and_notify_resolution` /// turns into a `WorkspaceNeedsBinding` emission. #[tokio::test(flavor = "multi_thread")] -async fn session_with_unbound_root_resolves_via_default() { +async fn session_with_unbound_root_resolves_via_deny() { let ctx = Ctx::new().await; ctx.session_roots.set("sess-1", ["/proj/unbound"]); + ctx.session_roots.set_roots_capable("sess-1", true); - let resolved = ctx.resolver.resolve(Some("sess-1")).await.unwrap(); - assert_eq!(resolved.source, ResolutionSource::Default); + let resolved = ctx.resolver.resolve(Some("sess-1"), None).await.unwrap(); + assert_eq!(resolved.source, ResolutionSource::Deny); assert_eq!(resolved.space_id, Some(ctx.space_id)); - // Default always hands back the Space's "All" FS so the client gets a - // working toolset even before the user binds the folder. - assert!(resolved.feature_set_id.is_some()); + // No FS resolves until the user binds the folder; mcpmux_* meta tools + // are appended unconditionally by the request handler so the LLM can + // self-bind from this state. + assert!(resolved.feature_set_ids.is_empty()); } /// After creating a binding for the root the next resolve flips to @@ -98,29 +103,34 @@ async fn creating_binding_flips_next_resolution_source() { }; let root = normalize_workspace_root(raw); ctx.session_roots.set("sess-1", [raw]); + ctx.session_roots.set_roots_capable("sess-1", true); - let before = ctx.resolver.resolve(Some("sess-1")).await.unwrap(); - assert_eq!(before.source, ResolutionSource::Default); + let before = ctx.resolver.resolve(Some("sess-1"), None).await.unwrap(); + assert_eq!(before.source, ResolutionSource::Deny); let binding = WorkspaceBinding::new(root, ctx.space_id, ctx.fs_custom_id.clone()); ctx.binding_repo.create(&binding).await.unwrap(); - let after = ctx.resolver.resolve(Some("sess-1")).await.unwrap(); + let after = ctx.resolver.resolve(Some("sess-1"), None).await.unwrap(); assert_eq!(after.source, ResolutionSource::WorkspaceBinding); - assert_eq!(after.feature_set_id, Some(ctx.fs_custom_id.clone())); + assert_eq!(after.feature_set_ids, vec![ctx.fs_custom_id.clone()]); } -/// Rootless session never resolves via a binding — stays at Default and -/// should never produce a `WorkspaceNeedsBinding` event. This test pins the -/// rootless-silence contract; if it ever fails, the notifier would start -/// prompting users with no folder context. +/// Rootless session without client grants resolves to `Deny`. No +/// `WorkspaceNeedsBinding` is appropriate here (rootless = nothing to +/// bind). This pins the rootless-silence contract — if it ever fails, the +/// notifier would start prompting users with no folder context. #[tokio::test(flavor = "multi_thread")] -async fn rootless_session_stays_default_no_prompt() { +async fn rootless_session_without_grants_denies_silently() { let ctx = Ctx::new().await; - // Deliberately no call to session_roots.set — simulates a rootless - // (CLI-ish) client. - let resolved = ctx.resolver.resolve(Some("rootless")).await.unwrap(); - assert_eq!(resolved.source, ResolutionSource::Default); + // Deliberately no roots set; capability stamped as false (rootless). + ctx.session_roots.set_roots_capable("rootless", false); + let resolved = ctx + .resolver + .resolve(Some("rootless"), Some("unknown-client")) + .await + .unwrap(); + assert_eq!(resolved.source, ResolutionSource::Deny); } /// Binding → different Space should actually route the session to that @@ -134,6 +144,7 @@ async fn binding_to_non_default_space_reroutes_session() { Arc::new(SqliteFeatureSetRepository::new(db.clone())); let binding_repo: Arc = Arc::new(SqliteWorkspaceBindingRepository::new(db.clone())); + let inbound_client_repo = Arc::new(InboundClientRepository::new(db.clone())); let default_space = space_repo.get_default().await.unwrap().unwrap(); @@ -150,8 +161,8 @@ async fn binding_to_non_default_space_reroutes_session() { let resolver = FeatureSetResolverService::new( space_repo.clone(), binding_repo.clone(), - fs_repo.clone(), session_roots.clone(), + inbound_client_repo.clone(), ); let raw = if cfg!(windows) { @@ -161,21 +172,23 @@ async fn binding_to_non_default_space_reroutes_session() { }; let root = normalize_workspace_root(raw); session_roots.set("sess-X", [raw]); + session_roots.set_roots_capable("sess-X", true); - // Before binding: Default tier — lands in the *default* space with its - // Default FS. - let before = resolver.resolve(Some("sess-X")).await.unwrap(); - assert_eq!(before.source, ResolutionSource::Default); + // Before binding: roots reported, no binding → Deny in the default + // space (the resolver still reports a space_id so the upstream prompt + // knows where to scope the binding sheet). + let before = resolver.resolve(Some("sess-X"), None).await.unwrap(); + assert_eq!(before.source, ResolutionSource::Deny); assert_eq!(before.space_id, Some(default_space.id)); // Create a binding targeting `other` space's Custom FS. let b = WorkspaceBinding::new(root, other_id, other_fs.id.clone()); binding_repo.create(&b).await.unwrap(); - let after = resolver.resolve(Some("sess-X")).await.unwrap(); + let after = resolver.resolve(Some("sess-X"), None).await.unwrap(); assert_eq!(after.source, ResolutionSource::WorkspaceBinding); assert_eq!(after.space_id, Some(other_id)); - assert_eq!(after.feature_set_id, Some(other_fs.id)); + assert_eq!(after.feature_set_ids, vec![other_fs.id]); } /// Minimal "is the Tauri bridge payload the shape the webview expects?" diff --git a/tests/rust/tests/streamable_http/gateway_notifications.rs b/tests/rust/tests/streamable_http/gateway_notifications.rs index 9fed9b1..bbb7b53 100644 --- a/tests/rust/tests/streamable_http/gateway_notifications.rs +++ b/tests/rust/tests/streamable_http/gateway_notifications.rs @@ -143,6 +143,8 @@ impl TestGateway { last_seen: None, created_at: now.clone(), updated_at: now, + reports_roots: false, + roots_capability_known: false, }; inbound_client_repo .save_client(&test_client) @@ -197,6 +199,7 @@ impl TestGateway { // Create MCPNotifier let notifier = Arc::new(MCPNotifier::new( services.space_resolver_service.clone(), + services.feature_set_resolver.clone(), services.pool_services.feature_service.clone(), )); From af600c859ca3f8b35ef61c5cbd05b2f7cef18292 Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Wed, 29 Apr 2026 08:57:48 +0800 Subject: [PATCH 20/24] fix(routing): close root-fetch race + Starter editability + binding autosave MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five fixes pulled from a session-mapping debugging pass. 1. on_initialized retries list_roots(). The single-shot peer.list_roots() in the spawned init task had no retry. A transient transport blip left the session at PendingRoots forever, and roots-capable sessions saw only the meta tools. Loop with backoffs 100ms / 300ms / 800ms / 2s / 5s = 6 attempts, ~8s budget, retrying only on transport errors (Ok([]) is a valid 'no folder open' answer the client will follow up with a roots/list_changed when it has one). 2. On-demand probe in list_tools / list_prompts / list_resources. If a roots-capable session hits a list request before its init list_roots() landed (the visible 'Claude shows only 4 meta tools after opening a new workspace' bug), fire a 300ms-budget probe here, populate session_roots, then resolve routing. SessionRootsRegistry gains claim_probe(sid, throttle) so a burst of three list calls doesn't fan out three upstream peer.list_roots() — only the first in any 1s window wins. 3. Migration 015 rewrites the *other* legacy seeded copy. Migration 014 only caught the 'The fallback feature set for this space' variant set by space_repository.rs. The Default Space's row was seeded by migration 001 itself with 'Features automatically granted to all connected clients in this space' — directly the opposite of what's true under resolver v3. 015 rewrites that string with the same is_builtin + name + description guard so any operator-customized copy survives. 4. Starter FSes are editable. The two member-modification guards in commands/feature_set.rs (add_feature_set_member, set_feature_set_members) rejected feature_set_type='starter' because they only accepted 'default' and 'custom'. Result: the auto-seeded Starter FS was read-only — useless. Both guards now accept 'starter' (and keep 'default' as a legacy alias for any stale read pre-migration-013). Comment updated. 5. WorkspaceBinding autosave debounce + flush-on-close. Bumped debounce 600ms to 1500ms to coalesce multi-FS toggle bursts into one save. Stricter dedupe (compares against last-saved, not just initial — re-toggling A → B → A is a true no-op). Most importantly, pending edits now survive panel close: a separate unmount-only useEffect reads pendingPayloadRef and posts the IPC immediately if there's unsaved work, so closing the sheet right after typing no longer drops the change. Latest onSubmit / onSaveStatusChange are kept in refs so the unmount handler uses the freshest closures. cargo check + clippy (-D warnings) + pnpm typecheck all clean. Signed-off-by: Mohammod Al Amin Ashik --- .../src-tauri/src/commands/feature_set.rs | 18 +- .../features/workspaces/WorkspacesPage.tsx | 121 ++++++-- crates/mcpmux-gateway/src/mcp/handler.rs | 288 ++++++++++++++---- .../src/services/session_roots.rs | 38 +++ crates/mcpmux-storage/src/database.rs | 5 + .../015_rewrite_starter_seed_copy_v2.sql | 26 ++ 6 files changed, 408 insertions(+), 88 deletions(-) create mode 100644 crates/mcpmux-storage/src/migrations/015_rewrite_starter_seed_copy_v2.sql diff --git a/apps/desktop/src-tauri/src/commands/feature_set.rs b/apps/desktop/src-tauri/src/commands/feature_set.rs index 6b626f3..7ecd8e8 100644 --- a/apps/desktop/src-tauri/src/commands/feature_set.rs +++ b/apps/desktop/src-tauri/src/commands/feature_set.rs @@ -314,9 +314,14 @@ pub async fn add_feature_set_member( .map_err(|e| e.to_string())? .ok_or("Feature set not found")?; - // Only "default" and "custom" types can have their members modified + // Both Starter (auto-seeded) and Custom FeatureSets are member-driven + // and editable. Reject anything else — there are no other configurable + // types today, but the guard stays for forward compatibility. + // `'default'` is accepted as a legacy alias because `parse('default')` + // resolves to `Starter` and `as_str()` always emits `'starter'` post- + // migration 013, but older in-memory data could still surface it. let fs_type = feature_set.feature_set_type.as_str(); - if fs_type != "default" && fs_type != "custom" { + if fs_type != "starter" && fs_type != "default" && fs_type != "custom" { return Err(format!( "Cannot modify members of '{}' type feature set", fs_type @@ -453,12 +458,13 @@ pub async fn set_feature_set_members( .map_err(|e| e.to_string())? .ok_or("Feature set not found")?; - // Only "default" and "custom" types can have their members modified - // "all" grants everything automatically, "server-all" is also auto-computed + // Both Starter (auto-seeded) and Custom FeatureSets are member-driven + // and editable. `'default'` is accepted as a legacy alias for the same + // reason described in `add_feature_set_member` — see comment there. let fs_type = feature_set.feature_set_type.as_str(); - if fs_type != "default" && fs_type != "custom" { + if fs_type != "starter" && fs_type != "default" && fs_type != "custom" { return Err(format!( - "Cannot modify members of '{}' type feature set. Only 'default' and 'custom' types are configurable.", + "Cannot modify members of '{}' type feature set. Only Starter and Custom FeatureSets are configurable.", fs_type )); } diff --git a/apps/desktop/src/features/workspaces/WorkspacesPage.tsx b/apps/desktop/src/features/workspaces/WorkspacesPage.tsx index 697cdee..18b6666 100644 --- a/apps/desktop/src/features/workspaces/WorkspacesPage.tsx +++ b/apps/desktop/src/features/workspaces/WorkspacesPage.tsx @@ -455,6 +455,23 @@ function formatFsList(names: string[]): string { return names.filter((n) => n && n.length > 0).join(' + '); } +/** + * Structural equality between two binding inputs. The autosave effect + * uses this to skip writes when the user re-toggled their way back to + * the last-saved state — avoids spamming `WorkspaceBindingChanged` for + * a no-op edit. `feature_set_ids` order matters (it's the operator- + * chosen render order, not just a set), so we compare positionally. + */ +function sameBindingInput( + a: WorkspaceBindingInput, + b: { workspace_root: string; space_id: string; feature_set_ids: string[] } +): boolean { + if (a.workspace_root.trim() !== b.workspace_root.trim()) return false; + if (a.space_id !== b.space_id) return false; + if (a.feature_set_ids.length !== b.feature_set_ids.length) return false; + return a.feature_set_ids.every((id, i) => id === b.feature_set_ids[i]); +} + function SegmentedFilter({ value, onChange, @@ -1638,22 +1655,64 @@ function BindingForm({ } }; - // Auto-save in edit mode — debounced, sequence-numbered to discard stale - // saves, and a no-op while the form's contents still match the initial - // values so just opening the panel doesn't fire a write. + // ---------- Autosave (edit mode) ----------------------------------------- + // + // Debounced (1500 ms) so a burst of FS-toggle clicks coalesces into one + // save instead of firing N WorkspaceBindingChanged events back-to-back. + // Dedupe is against the **last successfully-saved** payload, not just + // `initial` — so re-toggling A → B → A is a no-op (back to last saved), + // and once a save lands the next idle window doesn't re-save the same + // values. + // + // Critical: the debounce timer is cleared on dependency change but the + // **pending payload survives panel close**. If the user edits then + // closes before the debounce fires, the unmount handler flushes the + // save synchronously to Tauri — the IPC goes out before React tears + // the component down, and the save completes in the background. const saveSeqRef = useRef(0); const savedTimerRef = useRef | null>(null); + // Snapshot of the last payload we successfully wrote. `null` means + // "never saved during this panel session" — fall back to `initial` for + // dedupe in that case. + const lastSavedRef = useRef(null); + // The most recent payload the user produced that has NOT yet been + // committed. Cleared on successful save. The unmount handler reads + // this to decide whether to flush. + const pendingPayloadRef = useRef(null); + // Latest closures via ref so the unmount-only effect's empty-deps + // cleanup can still call the freshest handlers — closing the panel + // mid-edit must use the parent's *current* `onSubmit`, not whatever it + // captured on first mount. + const onSubmitRef = useRef(onSubmit); + const onSaveStatusChangeRef = useRef(onSaveStatusChange); + useEffect(() => { + onSubmitRef.current = onSubmit; + onSaveStatusChangeRef.current = onSaveStatusChange; + }, [onSubmit, onSaveStatusChange]); + useEffect(() => { if (!isEdit || !initial) return; - const sameFs = - fsIds.length === initial.feature_set_ids.length && - fsIds.every((id, i) => id === initial.feature_set_ids[i]); - const same = - root.trim() === initial.workspace_root && - spaceId === initial.space_id && - sameFs; - if (same) return; if (!canSubmit) return; + + const candidate: WorkspaceBindingInput = { + workspace_root: root.trim(), + space_id: spaceId, + feature_set_ids: fsIds, + }; + + // Dedupe baseline: last-saved if we've saved during this session, + // otherwise the initial payload from when the panel opened. + const baseline = lastSavedRef.current ?? { + workspace_root: initial.workspace_root, + space_id: initial.space_id, + feature_set_ids: initial.feature_set_ids, + }; + if (sameBindingInput(candidate, baseline)) { + pendingPayloadRef.current = null; + return; + } + + pendingPayloadRef.current = candidate; const seq = ++saveSeqRef.current; onSaveStatusChange?.({ kind: 'idle' }); const handle = setTimeout(async () => { @@ -1661,12 +1720,10 @@ function BindingForm({ onSaveStatusChange?.({ kind: 'saving' }); setSubmitting(true); try { - await onSubmit({ - workspace_root: root.trim(), - space_id: spaceId, - feature_set_ids: fsIds, - }); + await onSubmit(candidate); if (saveSeqRef.current !== seq) return; + lastSavedRef.current = candidate; + pendingPayloadRef.current = null; onSaveStatusChange?.({ kind: 'saved' }); if (savedTimerRef.current) clearTimeout(savedTimerRef.current); savedTimerRef.current = setTimeout(() => { @@ -1680,7 +1737,7 @@ function BindingForm({ } finally { setSubmitting(false); } - }, 600); + }, 1500); return () => clearTimeout(handle); }, [ isEdit, @@ -1694,6 +1751,36 @@ function BindingForm({ onSaveStatusChange, ]); + // Unmount-only flush. If a save was scheduled but the timer hasn't + // fired by the time the user closes the panel, fire it now so their + // edits aren't silently dropped. Empty-deps so this only runs on + // unmount, not on every dep change of the autosave effect above. + useEffect(() => { + return () => { + const pending = pendingPayloadRef.current; + if (!pending) return; + // Fire-and-forget. Tauri's `invoke` posts the IPC message to the + // Rust side immediately; the React tree can unmount in parallel + // and the save still completes. Bump the seq so any in-flight + // debounced save from before the close is discarded if it lands. + saveSeqRef.current += 1; + onSaveStatusChangeRef.current?.({ kind: 'saving' }); + onSubmitRef + .current(pending) + .then(() => { + onSaveStatusChangeRef.current?.({ kind: 'saved' }); + }) + .catch((e) => { + // Parent's toast bridge is gone with the panel — fall back to + // the console so the failure isn't silent in dev. + console.warn( + '[workspace-binding] flush-on-close save failed:', + e instanceof Error ? e.message : String(e) + ); + }); + }; + }, []); + const submitLabel = mode === 'create-from-live' ? 'Save binding' : 'Create binding'; diff --git a/crates/mcpmux-gateway/src/mcp/handler.rs b/crates/mcpmux-gateway/src/mcp/handler.rs index 4c7ab0f..81628fe 100644 --- a/crates/mcpmux-gateway/src/mcp/handler.rs +++ b/crates/mcpmux-gateway/src/mcp/handler.rs @@ -183,6 +183,113 @@ impl McpMuxGatewayHandler { Ok((space_id, resolved.feature_set_ids)) } + /// On-demand `roots/list` probe for sessions that initialized as + /// roots-capable but have no roots yet — typically because the first + /// `list_roots()` from `on_initialized` raced this request, or its + /// retries are still mid-backoff after a transient failure. + /// + /// Without this, a roots-capable client that fires `tools/list` + /// immediately after `notifications/initialized` resolves to + /// `PendingRoots` and gets only the meta tools — even though we'd + /// have the right answer milliseconds later. The 300 ms timeout + /// caps the latency cost of bridging that gap; in steady state + /// (`session_roots.get(sid)` already populated) this is a no-op + /// early-return. + /// + /// Rate-limited per session to once per second so a burst of + /// `tools/list` + `prompts/list` + `resources/list` doesn't fan out + /// three parallel `peer.list_roots()` calls. + async fn ensure_roots_probed( + &self, + peer: &rmcp::service::Peer, + session_id: Option<&str>, + client_id: &str, + ) { + let Some(sid) = session_id else { return }; + // Already have a definitive answer (Some(roots) — possibly empty). + if self.services.session_roots.get(sid).is_some() { + return; + } + // Not roots-capable → resolver routes via client grants, no + // probe useful. + if !self + .services + .session_roots + .is_roots_capable(sid) + .unwrap_or(false) + { + return; + } + // Throttle: at most one probe per session per second so a + // request burst doesn't multiply upstream calls. + if !self + .services + .session_roots + .claim_probe(sid, std::time::Duration::from_secs(1)) + { + return; + } + + const PROBE_BUDGET: std::time::Duration = std::time::Duration::from_millis(300); + match tokio::time::timeout(PROBE_BUDGET, peer.list_roots()).await { + Ok(Ok(result)) => { + let uris: Vec = result.roots.iter().map(|r| r.uri.to_string()).collect(); + self.services + .session_roots + .set(sid, uris.iter().map(|s| s.as_str())); + debug!( + %client_id, + session_id = %sid, + roots = ?uris, + "[FeatureSetResolver] on-demand probe populated roots", + ); + // Notify the UI / re-emit `WorkspaceNeedsBinding` if the + // session now resolves to Deny because of an unbound + // root. Fire-and-forget so the request itself isn't + // blocked on the desktop event bus. + let services = self.services.clone(); + let notifier = self.notification_bridge.clone(); + let client_id = client_id.to_string(); + let session_id = sid.to_string(); + let root_for_prompt = uris + .into_iter() + .filter(|r| !r.is_empty()) + .max_by_key(|r| r.len()); + tokio::spawn(async move { + services + .gateway_state + .read() + .await + .emit_domain_event(mcpmux_core::DomainEvent::SessionRootsChanged); + Self::log_and_notify_resolution( + &services, + Some(¬ifier), + &client_id, + Some(&session_id), + root_for_prompt.as_deref(), + ) + .await; + }); + } + Ok(Err(e)) => { + debug!( + %client_id, + session_id = %sid, + error = %e, + "[FeatureSetResolver] on-demand probe failed (will retry on next request)", + ); + } + Err(_elapsed) => { + debug!( + %client_id, + session_id = %sid, + budget_ms = PROBE_BUDGET.as_millis(), + "[FeatureSetResolver] on-demand probe timed out (will retry on next request)", + ); + } + } + } + /// Build InitializeResult with negotiated protocol version fn build_initialize_result(&self, protocol_version: ProtocolVersion) -> InitializeResult { let info = self.get_info(); @@ -326,60 +433,95 @@ impl ServerHandler for McpMuxGatewayHandler { let client_id_str = oauth_ctx.client_id.clone(); let session_id_for_task = session_id.clone(); tokio::spawn(async move { - match peer_for_roots.list_roots().await { - Ok(result) => { - let uris: Vec = - result.roots.iter().map(|r| r.uri.to_string()).collect(); - session_roots - .set(&session_id_for_task, uris.iter().map(|s| s.as_str())); - debug!( - client_id = %client_id_str, - session_id = %session_id_for_task, - roots = ?uris, - "[FeatureSetResolver] fetched MCP roots", - ); - - // Tell the desktop UI the detected-roots list may - // have grown so the Workspaces tab refreshes - // without waiting for a polling cycle. - services - .gateway_state - .read() - .await - .emit_domain_event(mcpmux_core::DomainEvent::SessionRootsChanged); - - // Pick the longest (most specific) normalized - // root for the sheet. The resolver has already - // normalized them on insert. Passing `Some(root)` - // lets log_and_notify_resolution emit - // `WorkspaceNeedsBinding` if the resolver ended - // up at `source = Default` (i.e. no binding yet). - let root_for_prompt = - session_roots.get(&session_id_for_task).and_then(|roots| { - roots - .into_iter() - .filter(|r| !r.is_empty()) - .max_by_key(|r| r.len()) - }); - - Self::log_and_notify_resolution( - &services, - Some(¬ifier), - &client_id_str, - Some(&session_id_for_task), - root_for_prompt.as_deref(), - ) - .await; + // Retry list_roots() on transport errors with bounded + // backoff. Without roots a roots-capable session is + // useless (resolver returns PendingRoots → empty + // tools list), so it's worth being aggressive about + // recovering from transient failures. Empty results + // (`Ok([])`) are NOT retried — that's a valid answer + // ("client has no folder open right now") and the + // client will notify us via `roots/list_changed` if + // they open one. + // + // Total budget ≈ 8.2 s wall-clock if every attempt + // hits a transport error before timing out. + const BACKOFFS_MS: &[u64] = &[100, 300, 800, 2000, 5000]; + let max_attempts = BACKOFFS_MS.len() + 1; // 6 total = 1 initial + 5 retries + let mut attempt: usize = 0; + let result = loop { + match peer_for_roots.list_roots().await { + Ok(r) => break Some(r), + Err(e) => { + attempt += 1; + if attempt >= max_attempts { + warn!( + client_id = %client_id_str, + session_id = %session_id_for_task, + attempts = attempt, + error = %e, + "[FeatureSetResolver] peer.list_roots() exhausted retries; session left unresolved (next list/get request will re-probe)", + ); + break None; + } + let backoff = BACKOFFS_MS[attempt - 1]; + warn!( + client_id = %client_id_str, + session_id = %session_id_for_task, + attempt, + max_attempts, + next_backoff_ms = backoff, + error = %e, + "[FeatureSetResolver] peer.list_roots() failed; retrying after backoff", + ); + tokio::time::sleep(std::time::Duration::from_millis(backoff)).await; + } } - Err(e) => { - debug!( - client_id = %client_id_str, - session_id = %session_id_for_task, - error = %e, - "[FeatureSetResolver] peer.list_roots() failed — falling back to active Space default", - ); - } - } + }; + + let Some(result) = result else { return }; + + let uris: Vec = + result.roots.iter().map(|r| r.uri.to_string()).collect(); + session_roots.set(&session_id_for_task, uris.iter().map(|s| s.as_str())); + debug!( + client_id = %client_id_str, + session_id = %session_id_for_task, + roots = ?uris, + attempts = attempt + 1, + "[FeatureSetResolver] fetched MCP roots", + ); + + // Tell the desktop UI the detected-roots list may + // have grown so the Workspaces tab refreshes + // without waiting for a polling cycle. + services + .gateway_state + .read() + .await + .emit_domain_event(mcpmux_core::DomainEvent::SessionRootsChanged); + + // Pick the longest (most specific) normalized + // root for the sheet. The resolver has already + // normalized them on insert. Passing `Some(root)` + // lets log_and_notify_resolution emit + // `WorkspaceNeedsBinding` if the resolver ended + // up at `source = Deny` (i.e. no binding yet). + let root_for_prompt = + session_roots.get(&session_id_for_task).and_then(|roots| { + roots + .into_iter() + .filter(|r| !r.is_empty()) + .max_by_key(|r| r.len()) + }); + + Self::log_and_notify_resolution( + &services, + Some(¬ifier), + &client_id_str, + Some(&session_id_for_task), + root_for_prompt.as_deref(), + ) + .await; }); } else { // No roots declared — silent default, never prompt @@ -483,14 +625,22 @@ impl ServerHandler for McpMuxGatewayHandler { let oauth_ctx = self .get_oauth_context(&context.extensions) .map_err(|e| McpError::invalid_params(e.to_string(), None))?; + let session_id_owned = extract_session_id(&context.extensions); + // Bridge the init race: roots-capable sessions whose first + // `list_roots()` raced this request get a one-shot 300 ms probe + // here so they end up at the right routing decision instead of + // empty (PendingRoots). Throttled per session. + self.ensure_roots_probed( + &context.peer, + session_id_owned.as_deref(), + &oauth_ctx.client_id, + ) + .await; // Resolve routing once: the resolver returns the authoritative // (Space, FS) for this session — this may differ from oauth_ctx // when a WorkspaceBinding redirects to another space. let (space_id, feature_set_ids) = self - .resolve_routing( - extract_session_id(&context.extensions).as_deref(), - &oauth_ctx.client_id, - ) + .resolve_routing(session_id_owned.as_deref(), &oauth_ctx.client_id) .await?; // Get tools via FeatureService — using the *resolved* space. @@ -668,11 +818,15 @@ impl ServerHandler for McpMuxGatewayHandler { let oauth_ctx = self .get_oauth_context(&context.extensions) .map_err(|e| McpError::invalid_params(e.to_string(), None))?; + let session_id_owned = extract_session_id(&context.extensions); + self.ensure_roots_probed( + &context.peer, + session_id_owned.as_deref(), + &oauth_ctx.client_id, + ) + .await; let (space_id, feature_set_ids) = self - .resolve_routing( - extract_session_id(&context.extensions).as_deref(), - &oauth_ctx.client_id, - ) + .resolve_routing(session_id_owned.as_deref(), &oauth_ctx.client_id) .await?; let prompts = self @@ -775,11 +929,15 @@ impl ServerHandler for McpMuxGatewayHandler { let oauth_ctx = self .get_oauth_context(&context.extensions) .map_err(|e| McpError::invalid_params(e.to_string(), None))?; + let session_id_owned = extract_session_id(&context.extensions); + self.ensure_roots_probed( + &context.peer, + session_id_owned.as_deref(), + &oauth_ctx.client_id, + ) + .await; let (space_id, feature_set_ids) = self - .resolve_routing( - extract_session_id(&context.extensions).as_deref(), - &oauth_ctx.client_id, - ) + .resolve_routing(session_id_owned.as_deref(), &oauth_ctx.client_id) .await?; let resources = self diff --git a/crates/mcpmux-gateway/src/services/session_roots.rs b/crates/mcpmux-gateway/src/services/session_roots.rs index 6aa64fd..4f41dd3 100644 --- a/crates/mcpmux-gateway/src/services/session_roots.rs +++ b/crates/mcpmux-gateway/src/services/session_roots.rs @@ -10,6 +10,7 @@ //! re-normalize on every lookup. use std::sync::Arc; +use std::time::{Duration, Instant}; use dashmap::DashMap; use mcpmux_core::normalize_workspace_root; @@ -35,6 +36,15 @@ pub struct SessionRootsRegistry { /// `initialize` for that session — treated as "unknown" by the resolver /// and routed via grants. roots_capable: DashMap, + /// `session_id -> Instant of the last on-demand `list_roots()` probe`. + /// + /// Used by the request-time re-probe path in the MCP handler to avoid + /// firing N parallel `list_roots()` calls when a roots-capable session + /// hits a burst of `tools/list` / `prompts/list` / `resources/list` in + /// quick succession. The handler calls `claim_probe(sid, throttle)` + /// before firing; if it returns false, another probe was attempted + /// recently and this one is skipped. + last_probe: DashMap, } impl SessionRootsRegistry { @@ -43,9 +53,36 @@ impl SessionRootsRegistry { map: DashMap::new(), last_resolution: DashMap::new(), roots_capable: DashMap::new(), + last_probe: DashMap::new(), }) } + /// Try to claim a probe slot for `session_id`. Returns `true` if it's + /// been at least `throttle` since the last attempt for this session + /// (or if there's never been one) — and stamps the new attempt + /// atomically. Returns `false` if a probe was already attempted + /// within the throttle window. + /// + /// The handler calls this before firing `peer.list_roots()` so a + /// burst of three `tools/list` / `prompts/list` / `resources/list` + /// calls in 50 ms results in at most one upstream probe. + pub fn claim_probe(&self, session_id: &str, throttle: Duration) -> bool { + let now = Instant::now(); + match self.last_probe.entry(session_id.to_string()) { + dashmap::mapref::entry::Entry::Occupied(mut e) => { + if now.duration_since(*e.get()) < throttle { + return false; + } + e.insert(now); + true + } + dashmap::mapref::entry::Entry::Vacant(e) => { + e.insert(now); + true + } + } + } + /// Record whether a session declared the MCP `roots` capability on /// `initialize`. Idempotent — called once per session lifecycle. pub fn set_roots_capable(&self, session_id: impl Into, capable: bool) { @@ -84,6 +121,7 @@ impl SessionRootsRegistry { self.map.remove(session_id); self.last_resolution.remove(session_id); self.roots_capable.remove(session_id); + self.last_probe.remove(session_id); } /// Compare-and-set the session's resolved feature-set id. Returns `true` diff --git a/crates/mcpmux-storage/src/database.rs b/crates/mcpmux-storage/src/database.rs index a8c5413..bb6fc02 100644 --- a/crates/mcpmux-storage/src/database.rs +++ b/crates/mcpmux-storage/src/database.rs @@ -103,6 +103,11 @@ const MIGRATIONS: &[Migration] = &[ name: "rewrite_starter_seed_copy", sql: include_str!("migrations/014_rewrite_starter_seed_copy.sql"), }, + Migration { + version: 15, + name: "rewrite_starter_seed_copy_v2", + sql: include_str!("migrations/015_rewrite_starter_seed_copy_v2.sql"), + }, ]; /// SQLite database wrapper. diff --git a/crates/mcpmux-storage/src/migrations/015_rewrite_starter_seed_copy_v2.sql b/crates/mcpmux-storage/src/migrations/015_rewrite_starter_seed_copy_v2.sql new file mode 100644 index 0000000..c5c9b7b --- /dev/null +++ b/crates/mcpmux-storage/src/migrations/015_rewrite_starter_seed_copy_v2.sql @@ -0,0 +1,26 @@ +-- Migration 015: catch the *other* legacy seed copy that 014 missed. +-- +-- Migration 001 (still shipped, can't edit retroactively) seeds the +-- default Space's auto-Starter row with: +-- name = 'Default' +-- description = 'Features automatically granted to all connected clients in this space' +-- +-- Migration 014 only rewrote rows whose description was the OTHER stale +-- variant ('The fallback feature set for this space'), set by +-- space_repository.rs::create() at one point in history. So the +-- migration-001-seeded row on every existing install survived 014 +-- unchanged, and the Clients UI still shows "Features automatically +-- granted to all connected clients in this space" — which is the most +-- misleading of the lot under resolver v3 (literally the opposite of +-- the truth). +-- +-- Same safety guard as 014: only rewrite rows that still match the +-- exact stale seed values, so any operator who customized the copy +-- keeps their change. + +UPDATE feature_sets + SET name = 'Starter', + description = 'Auto-created with this Space. Edit, rename, or delete freely — bindings and per-client grants pick FeatureSets explicitly, so this one has no special routing role.' + WHERE is_builtin = 1 + AND name = 'Default' + AND description = 'Features automatically granted to all connected clients in this space'; From f08e8ec1c18024f605199ce799338ca0d5c1adf1 Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Wed, 29 Apr 2026 09:12:39 +0800 Subject: [PATCH 21/24] fix(oauth): drop duplicate RFC 8707 resource param rmcp 1.5 already appends resource to the authorize and token requests, so the gateway's add_resource_parameter wrapper produced ?resource=...&resource=... Supabase's authorize endpoint rejects the repeated key with "resource: Expected string, received array". Signed-off-by: Mohammod Al Amin Ashik --- crates/mcpmux-gateway/src/pool/oauth.rs | 35 ------------------------- 1 file changed, 35 deletions(-) diff --git a/crates/mcpmux-gateway/src/pool/oauth.rs b/crates/mcpmux-gateway/src/pool/oauth.rs index f748d54..e60ac05 100644 --- a/crates/mcpmux-gateway/src/pool/oauth.rs +++ b/crates/mcpmux-gateway/src/pool/oauth.rs @@ -255,36 +255,6 @@ impl OutboundOAuthManager { scopes.iter().map(|s| s.as_str()).collect() } - /// Add RFC 8707 'resource' parameter to authorization URL. - /// - /// The resource parameter tells the Authorization Server which protected resource - /// (MCP server) the client is requesting access to. This enables the AS to: - /// - Issue tokens scoped to the specific resource - /// - Apply resource-specific policies - /// - Prevent token replay at other resources - /// - /// Some servers (like Miro) require this parameter. - fn add_resource_parameter(auth_url: &str, server_url: &str) -> String { - use url::Url; - - match Url::parse(auth_url) { - Ok(mut url) => { - // Add the resource parameter with the MCP server URL - url.query_pairs_mut().append_pair("resource", server_url); - info!("[OAuth] Added RFC 8707 resource parameter: {}", server_url); - url.to_string() - } - Err(e) => { - warn!( - "[OAuth] Failed to parse auth URL to add resource parameter: {}", - e - ); - // Return original URL if parsing fails - auth_url.to_string() - } - } - } - /// Subscribe to OAuth completion events pub fn subscribe(&self) -> tokio::sync::broadcast::Receiver { self.completion_tx.subscribe() @@ -1252,11 +1222,6 @@ impl OutboundOAuthManager { } }; - // Add RFC 8707 'resource' parameter to the authorization URL. - // This tells the Authorization Server which protected resource (MCP server) - // the token is being requested for. Some servers (like Miro) require this. - let auth_url = Self::add_resource_parameter(&auth_url, server_url); - // Extract state parameter from auth_url let state = match Self::extract_state_from_url(&auth_url) { Some(s) => s, From c9a18b7e88f6bc97e51e6d9588b33d1dd0beb3b1 Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Wed, 29 Apr 2026 09:17:24 +0800 Subject: [PATCH 22/24] fix(notifier): lazy-GC dead sessions on every fanout / per-peer push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rmcp's ServerHandler doesn't expose a session-close callback and the streamable-HTTP session manager owns the close path internally, so we have no obvious place to call unregister_session. Without GC the sessions map grows unbounded across the gateway's lifetime — every reconnect leaves stale entries that fanout iterates and the resolver attempts to re-route. What rmcp *does* give us is `Peer::is_transport_closed()`, which flips true once the underlying transport has terminated. Reap lazily: each fanout / per-peer push snapshots the live session list, scans for closed peers, and removes them from both `sessions` and the `feature_set_resolver`'s `SessionRootsRegistry` in one pass. After the sweep the regular routing loop runs against the cleaned snapshot. Three call sites covered: get_peers_for_space (broadcasts), get_peers_for_space_with_streams (the variant used by the per-type notify_*_list_changed), and notify_peer_lists_changed (per-client push for resolution flips and grant edits). Logged at info level when dead > 0 so a future spike is visible. Adds `FeatureSetResolverService::session_roots()` accessor so the notifier can keep the two registries in sync — they were drifting silently otherwise. cargo check + clippy (-D warnings) clean. Signed-off-by: Mohammod Al Amin Ashik --- .../src/consumers/mcp_notifier.rs | 85 ++++++++++++++++++- .../src/services/feature_set_resolver.rs | 7 ++ 2 files changed, 89 insertions(+), 3 deletions(-) diff --git a/crates/mcpmux-gateway/src/consumers/mcp_notifier.rs b/crates/mcpmux-gateway/src/consumers/mcp_notifier.rs index b681e59..fac84a9 100644 --- a/crates/mcpmux-gateway/src/consumers/mcp_notifier.rs +++ b/crates/mcpmux-gateway/src/consumers/mcp_notifier.rs @@ -322,13 +322,61 @@ impl MCPNotifier { tracker.insert((space_id, NotificationType::All), timestamp); } + /// Lazy GC for dead sessions. + /// + /// rmcp's `ServerHandler` doesn't expose a session-close callback, and + /// the streamable-HTTP session manager owns the close path internally. + /// What we *do* have on every `Peer` is `is_transport_closed()` — + /// it flips true once the underlying transport has terminated. So we + /// reap lazily: every fanout / probe pass scans for closed peers and + /// removes them from both `sessions` and `session_roots`. + /// + /// Returns the ids that were reaped (for logging / metrics). Callers + /// pass the live (snapshot) list of `(session_id, peer)` they were + /// about to iterate; this mutates `self.sessions` and the + /// `feature_set_resolver`'s session registry. + fn reap_dead_sessions(&self, snapshot: &[(String, Arc>)]) -> Vec { + let dead: Vec = snapshot + .iter() + .filter_map(|(sid, peer)| { + if peer.is_transport_closed() { + Some(sid.clone()) + } else { + None + } + }) + .collect(); + if dead.is_empty() { + return dead; + } + { + let mut sessions = self.sessions.write(); + for sid in &dead { + sessions.remove(sid); + } + } + // Also clean the session_roots registry the resolver consults so + // it doesn't keep returning stale roots / capability flags for + // sessions that no longer exist. + for sid in &dead { + self.feature_set_resolver.session_roots().remove(sid); + } + info!( + reaped = dead.len(), + "[MCPNotifier] 🧹 reaped dead sessions (transport closed)" + ); + dead + } + /// Get every peer whose **session** currently routes into `space_id`. /// /// Iterates the session registry and re-runs the FeatureSet resolver /// per session — same logic the request handlers use, so a session /// redirected by `WorkspaceBinding` to a non-default space is matched /// correctly. Sessions whose stream isn't active yet are skipped (the - /// notification would be queued but not delivered). + /// notification would be queued but not delivered). Dead sessions + /// (transport closed) are GC'd before the resolve pass so we don't + /// waste a `feature_set_resolver.resolve()` call on them. async fn get_peers_for_space(&self, space_id: Uuid) -> Vec>> { let session_list: Vec<(String, String, Arc>)> = { let sessions = self.sessions.read(); @@ -339,9 +387,22 @@ impl MCPNotifier { .collect() }; + // GC dead sessions before resolving — also drops them from the + // snapshot so the resolve loop below skips them. + let dead = self.reap_dead_sessions( + &session_list + .iter() + .map(|(sid, _, peer)| (sid.clone(), peer.clone())) + .collect::>(), + ); + let dead_set: std::collections::HashSet<&str> = dead.iter().map(String::as_str).collect(); + let mut matching_peers = Vec::new(); let _space_resolver = &self.space_resolver; // kept-but-unused; resolver below is authoritative for (session_id, client_id, peer) in session_list { + if dead_set.contains(session_id.as_str()) { + continue; + } match self .feature_set_resolver .resolve(Some(&session_id), Some(&client_id)) @@ -811,10 +872,21 @@ impl MCPNotifier { .collect() }; + let dead = self.reap_dead_sessions( + &session_list + .iter() + .map(|(sid, _, peer)| (sid.clone(), peer.clone())) + .collect::>(), + ); + let dead_set: std::collections::HashSet<&str> = dead.iter().map(String::as_str).collect(); + let mut matching_peers = Vec::new(); let mut matching_client_ids = Vec::new(); for (session_id, client_id, peer) in session_list { + if dead_set.contains(session_id.as_str()) { + continue; + } match self .feature_set_resolver .resolve(Some(&session_id), Some(&client_id)) @@ -995,14 +1067,21 @@ impl MCPNotifier { // editors, parallel CLI invocations). Push the notification on // every active session for that client_id; client-side dedup is // their problem, but missing a session would be ours. - let peers: Vec>> = { + let snapshot: Vec<(String, Arc>)> = { let sessions = self.sessions.read(); sessions .iter() .filter(|(_, e)| e.client_id == client_id && e.has_active_stream) - .map(|(_, e)| e.peer.clone()) + .map(|(sid, e)| (sid.clone(), e.peer.clone())) .collect() }; + let dead = self.reap_dead_sessions(&snapshot); + let dead_set: std::collections::HashSet<&str> = dead.iter().map(String::as_str).collect(); + let peers: Vec>> = snapshot + .into_iter() + .filter(|(sid, _)| !dead_set.contains(sid.as_str())) + .map(|(_, peer)| peer) + .collect(); if peers.is_empty() { debug!( diff --git a/crates/mcpmux-gateway/src/services/feature_set_resolver.rs b/crates/mcpmux-gateway/src/services/feature_set_resolver.rs index 5da456a..56c40fd 100644 --- a/crates/mcpmux-gateway/src/services/feature_set_resolver.rs +++ b/crates/mcpmux-gateway/src/services/feature_set_resolver.rs @@ -118,6 +118,13 @@ impl FeatureSetResolverService { } } + /// Borrow the session-roots registry. The notifier uses this to GC + /// dead sessions out of the registry when reaping the corresponding + /// peer entries — keeping both stores in sync. + pub fn session_roots(&self) -> &Arc { + &self.session_roots + } + /// Resolve the effective (Space, FS list, source) tuple for a session. /// /// `session_id`: the client's `mcp-session-id` header (or `None` when From a83e28876556003930ff93cfd6e9a40b848b3703 Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Wed, 29 Apr 2026 09:30:01 +0800 Subject: [PATCH 23/24] fix(notifier): tag every list_changed push with session_id + client_id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'Sent tools/list_changed notification' debug line was anonymous — the design routes per-session correctly (each Peer we hand to notify_*_list_changed is the one we stored in SessionEntry, tied to exactly one mcp-session-id), but the log didn't prove it. With six concurrent sessions across two clients, an audit needed cross- referencing peer pointers, which is impractical. Thread session_id + client_id through the send paths: - get_peers_for_space_with_streams now returns Vec<(session_id, client_id, peer)> instead of two parallel Vecs; the third element lets every send_*_list_changed call log who got the push. - send_tools_list_changed, send_prompts_list_changed, send_resources_list_changed iterate the triples and tag each ✅ / Failed line with both ids. - notify_peer_lists_changed (per-client fanout for resolution flips and grant edits) also tags each ✅ / failed line with session_id. Drops the now-dead get_peers_for_space (plain-Vec variant) and the SpaceResolverService field — both unused once every fanout path goes through the streams variant. Constructor signature trimmed accordingly; both call sites (server/mod.rs + the gateway- notifications integration test) updated. cargo check + clippy (-D warnings) clean. Signed-off-by: Mohammod Al Amin Ashik --- .../src/consumers/mcp_notifier.rs | 237 +++++++++--------- crates/mcpmux-gateway/src/server/mod.rs | 1 - .../streamable_http/gateway_notifications.rs | 1 - 3 files changed, 114 insertions(+), 125 deletions(-) diff --git a/crates/mcpmux-gateway/src/consumers/mcp_notifier.rs b/crates/mcpmux-gateway/src/consumers/mcp_notifier.rs index fac84a9..3072320 100644 --- a/crates/mcpmux-gateway/src/consumers/mcp_notifier.rs +++ b/crates/mcpmux-gateway/src/consumers/mcp_notifier.rs @@ -26,7 +26,7 @@ use tracing::{debug, info, trace, warn}; use uuid::Uuid; use crate::pool::FeatureService; -use crate::services::{FeatureSetResolverService, SpaceResolverService}; +use crate::services::FeatureSetResolverService; /// MCP Notifier — sends `list_changed` notifications to connected sessions. /// @@ -53,10 +53,6 @@ use crate::services::{FeatureSetResolverService, SpaceResolverService}; pub struct MCPNotifier { /// Map: `mcp-session-id` → session handle. sessions: Arc>>, - /// Space resolver for the legacy client-→home-space query (kept for - /// callers that don't have a session id; the new fanout paths use - /// `feature_set_resolver` instead). - space_resolver: Arc, /// FeatureSet resolver — same one the request handlers use. Consulted /// per session to decide whether a notification applies. feature_set_resolver: Arc, @@ -112,13 +108,11 @@ impl SessionEntry { impl MCPNotifier { pub fn new( - space_resolver: Arc, feature_set_resolver: Arc, feature_service: Arc, ) -> Self { Self { sessions: Arc::new(RwLock::new(HashMap::new())), - space_resolver, feature_set_resolver, feature_service, throttle_tracker: Arc::new(RwLock::new(HashMap::new())), @@ -368,78 +362,6 @@ impl MCPNotifier { dead } - /// Get every peer whose **session** currently routes into `space_id`. - /// - /// Iterates the session registry and re-runs the FeatureSet resolver - /// per session — same logic the request handlers use, so a session - /// redirected by `WorkspaceBinding` to a non-default space is matched - /// correctly. Sessions whose stream isn't active yet are skipped (the - /// notification would be queued but not delivered). Dead sessions - /// (transport closed) are GC'd before the resolve pass so we don't - /// waste a `feature_set_resolver.resolve()` call on them. - async fn get_peers_for_space(&self, space_id: Uuid) -> Vec>> { - let session_list: Vec<(String, String, Arc>)> = { - let sessions = self.sessions.read(); - sessions - .iter() - .filter(|(_, e)| e.has_active_stream) - .map(|(sid, entry)| (sid.clone(), entry.client_id.clone(), entry.peer.clone())) - .collect() - }; - - // GC dead sessions before resolving — also drops them from the - // snapshot so the resolve loop below skips them. - let dead = self.reap_dead_sessions( - &session_list - .iter() - .map(|(sid, _, peer)| (sid.clone(), peer.clone())) - .collect::>(), - ); - let dead_set: std::collections::HashSet<&str> = dead.iter().map(String::as_str).collect(); - - let mut matching_peers = Vec::new(); - let _space_resolver = &self.space_resolver; // kept-but-unused; resolver below is authoritative - for (session_id, client_id, peer) in session_list { - if dead_set.contains(session_id.as_str()) { - continue; - } - match self - .feature_set_resolver - .resolve(Some(&session_id), Some(&client_id)) - .await - { - Ok(resolved) if resolved.space_id == Some(space_id) => { - debug!( - %session_id, - %client_id, - %space_id, - "[MCPNotifier] Session resolves to target space" - ); - matching_peers.push(peer); - } - Ok(resolved) => { - debug!( - %session_id, - %client_id, - resolved_space = ?resolved.space_id, - %space_id, - "[MCPNotifier] Session is in a different space, skipping" - ); - } - Err(e) => { - warn!( - %session_id, - %client_id, - error = %e, - "[MCPNotifier] ⚠️ Failed to resolve space for session" - ); - } - } - } - - matching_peers - } - /// Start listening to domain events and notifying peers /// /// Spawns a background task that listens to DomainEvents and calls @@ -811,33 +733,47 @@ impl MCPNotifier { return; } - // Get peers for this space, filtering to only those with active streams - let (peers, _client_ids) = self.get_peers_for_space_with_streams(space_id).await; + // Get sessions in this space with active streams, paired with + // their session_id + client_id for per-push log attribution. + let targets = self.get_peers_for_space_with_streams(space_id).await; - if peers.is_empty() { - debug!(space_id = %space_id, "[MCPNotifier] No peers with active streams to notify about tools"); + if targets.is_empty() { + debug!( + space_id = %space_id, + "[MCPNotifier] No sessions with active streams to notify about tools" + ); return; } info!( space_id = %space_id, - peer_count = peers.len(), - "[MCPNotifier] 📤 Sending tools/list_changed to {} peers with active streams", - peers.len() + session_count = targets.len(), + "[MCPNotifier] 📤 Sending tools/list_changed to {} session(s) with active streams", + targets.len() ); let mut success_count = 0; let mut failure_count = 0; - for peer in peers { + for (session_id, client_id, peer) in targets { match peer.notify_tool_list_changed().await { Ok(_) => { success_count += 1; - debug!("[MCPNotifier] ✅ Sent tools/list_changed notification"); + debug!( + %session_id, + %client_id, + %space_id, + "[MCPNotifier] ✅ Sent tools/list_changed to session" + ); } Err(e) => { failure_count += 1; - warn!(error = ?e, "[MCPNotifier] Failed to send tools/list_changed"); + warn!( + %session_id, + %client_id, + error = ?e, + "[MCPNotifier] Failed to send tools/list_changed to session" + ); } } } @@ -853,16 +789,20 @@ impl MCPNotifier { } } - /// Get peers for a space that have active SSE streams. + /// Get the sessions in `space_id` that have an active SSE stream and + /// can therefore actually receive a notification. /// /// Session-keyed: iterates `sessions`, re-runs the FeatureSet resolver /// per session (same path as the request handlers), and returns the - /// peers whose session resolves into `space_id`. The second tuple - /// element is `client_id`s of those sessions, kept for log clarity. + /// `(session_id, client_id, peer)` triples whose session resolves into + /// `space_id`. Threading session_id through to the call site lets the + /// log lines on each `peer.notify_*_list_changed()` prove *which* + /// session got the push — important for verifying that two windows of + /// the same client routing into different spaces don't cross-talk. async fn get_peers_for_space_with_streams( &self, space_id: Uuid, - ) -> (Vec>>, Vec) { + ) -> Vec<(String, String, Arc>)> { let session_list: Vec<(String, String, Arc>)> = { let sessions = self.sessions.read(); sessions @@ -880,8 +820,7 @@ impl MCPNotifier { ); let dead_set: std::collections::HashSet<&str> = dead.iter().map(String::as_str).collect(); - let mut matching_peers = Vec::new(); - let mut matching_client_ids = Vec::new(); + let mut matching = Vec::new(); for (session_id, client_id, peer) in session_list { if dead_set.contains(session_id.as_str()) { @@ -899,8 +838,7 @@ impl MCPNotifier { %space_id, "[MCPNotifier] Session in target space with active stream" ); - matching_peers.push(peer); - matching_client_ids.push(client_id); + matching.push((session_id, client_id, peer)); } Ok(resolved) => { debug!( @@ -922,7 +860,7 @@ impl MCPNotifier { } } - (matching_peers, matching_client_ids) + matching } /// Notify all peers in a space that prompts list has changed (with throttling and deduping) @@ -967,21 +905,33 @@ impl MCPNotifier { return; } - let peers = self.get_peers_for_space(space_id).await; + let targets = self.get_peers_for_space_with_streams(space_id).await; - if peers.is_empty() { + if targets.is_empty() { return; } info!( space_id = %space_id, - peer_count = peers.len(), - "[MCPNotifier] 📤 Sending prompts/list_changed" + session_count = targets.len(), + "[MCPNotifier] 📤 Sending prompts/list_changed to {} session(s)", + targets.len() ); - for peer in peers { - if let Err(e) = peer.notify_prompt_list_changed().await { - warn!(error = ?e, "[MCPNotifier] Failed to send prompts/list_changed"); + for (session_id, client_id, peer) in targets { + match peer.notify_prompt_list_changed().await { + Ok(_) => debug!( + %session_id, + %client_id, + %space_id, + "[MCPNotifier] ✅ Sent prompts/list_changed to session" + ), + Err(e) => warn!( + %session_id, + %client_id, + error = ?e, + "[MCPNotifier] Failed to send prompts/list_changed to session" + ), } } } @@ -1028,21 +978,33 @@ impl MCPNotifier { return; } - let peers = self.get_peers_for_space(space_id).await; + let targets = self.get_peers_for_space_with_streams(space_id).await; - if peers.is_empty() { + if targets.is_empty() { return; } info!( space_id = %space_id, - peer_count = peers.len(), - "[MCPNotifier] 📤 Sending resources/list_changed" + session_count = targets.len(), + "[MCPNotifier] 📤 Sending resources/list_changed to {} session(s)", + targets.len() ); - for peer in peers { - if let Err(e) = peer.notify_resource_list_changed().await { - warn!(error = ?e, "[MCPNotifier] Failed to send resources/list_changed"); + for (session_id, client_id, peer) in targets { + match peer.notify_resource_list_changed().await { + Ok(_) => debug!( + %session_id, + %client_id, + %space_id, + "[MCPNotifier] ✅ Sent resources/list_changed to session" + ), + Err(e) => warn!( + %session_id, + %client_id, + error = ?e, + "[MCPNotifier] Failed to send resources/list_changed to session" + ), } } } @@ -1077,13 +1039,12 @@ impl MCPNotifier { }; let dead = self.reap_dead_sessions(&snapshot); let dead_set: std::collections::HashSet<&str> = dead.iter().map(String::as_str).collect(); - let peers: Vec>> = snapshot + let live: Vec<(String, Arc>)> = snapshot .into_iter() .filter(|(sid, _)| !dead_set.contains(sid.as_str())) - .map(|(_, peer)| peer) .collect(); - if peers.is_empty() { + if live.is_empty() { debug!( %client_id, "[MCPNotifier] no active session — skipping peer list_changed" @@ -1093,19 +1054,49 @@ impl MCPNotifier { info!( %client_id, - session_count = peers.len(), + session_count = live.len(), "[MCPNotifier] 📤 per-client list_changed (resolution flipped or grant edited)" ); - for peer in &peers { - if let Err(e) = peer.notify_tool_list_changed().await { - warn!(error = ?e, %client_id, "[MCPNotifier] failed tools/list_changed"); + for (session_id, peer) in &live { + match peer.notify_tool_list_changed().await { + Ok(_) => debug!( + %session_id, + %client_id, + "[MCPNotifier] ✅ Sent tools/list_changed to session (per-client)" + ), + Err(e) => warn!( + %session_id, + %client_id, + error = ?e, + "[MCPNotifier] failed tools/list_changed" + ), } - if let Err(e) = peer.notify_prompt_list_changed().await { - warn!(error = ?e, %client_id, "[MCPNotifier] failed prompts/list_changed"); + match peer.notify_prompt_list_changed().await { + Ok(_) => debug!( + %session_id, + %client_id, + "[MCPNotifier] ✅ Sent prompts/list_changed to session (per-client)" + ), + Err(e) => warn!( + %session_id, + %client_id, + error = ?e, + "[MCPNotifier] failed prompts/list_changed" + ), } - if let Err(e) = peer.notify_resource_list_changed().await { - warn!(error = ?e, %client_id, "[MCPNotifier] failed resources/list_changed"); + match peer.notify_resource_list_changed().await { + Ok(_) => debug!( + %session_id, + %client_id, + "[MCPNotifier] ✅ Sent resources/list_changed to session (per-client)" + ), + Err(e) => warn!( + %session_id, + %client_id, + error = ?e, + "[MCPNotifier] failed resources/list_changed" + ), } } } diff --git a/crates/mcpmux-gateway/src/server/mod.rs b/crates/mcpmux-gateway/src/server/mod.rs index 9888c70..4bf01df 100644 --- a/crates/mcpmux-gateway/src/server/mod.rs +++ b/crates/mcpmux-gateway/src/server/mod.rs @@ -229,7 +229,6 @@ impl GatewayServer { // Create MCP notifier (session-keyed fanout, consults the same // FeatureSet resolver the request handlers use). let notification_bridge = Arc::new(MCPNotifier::new( - self.services.space_resolver_service.clone(), self.services.feature_set_resolver.clone(), self.services.pool_services.feature_service.clone(), )); diff --git a/tests/rust/tests/streamable_http/gateway_notifications.rs b/tests/rust/tests/streamable_http/gateway_notifications.rs index bbb7b53..7ba1fc8 100644 --- a/tests/rust/tests/streamable_http/gateway_notifications.rs +++ b/tests/rust/tests/streamable_http/gateway_notifications.rs @@ -198,7 +198,6 @@ impl TestGateway { // Create MCPNotifier let notifier = Arc::new(MCPNotifier::new( - services.space_resolver_service.clone(), services.feature_set_resolver.clone(), services.pool_services.feature_service.clone(), )); From deab6807e26fdeae2a127a3544e9407bc63a786f Mon Sep 17 00:00:00 2001 From: Mohammod Al Amin Ashik Date: Wed, 29 Apr 2026 10:38:22 +0800 Subject: [PATCH 24/24] fix(handler): single-flight on-demand probe per session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The boolean `claim_probe` rate-limiter let the first list request enter the probe path, but **followers in the same burst skipped the probe entirely** and resolved to PendingRoots immediately — returning empty *before* the first probe's `peer.list_roots()` came back. Trace from a Claude Code claude-vscode session opening a new workspace: 02:17:19.950 resolver: roots-capable, roots pending (req 1) 02:17:19.950 resolver: roots-capable, roots pending (req 2 — skipped probe) 02:17:19.973 on-demand probe populated roots (req 1's probe finally landed) Two of the three list responses (tools/list + prompts/list + resources/list, fired within 1ms by Claude Code at init) returned empty. The probe success at +23ms triggered a notifications/ tools/list_changed which the CLI variant honors but the VS Code extension doesn't refetch on, so the empty initial list is what the panel kept showing. Replace the boolean with a per-session `tokio::sync::Mutex` for single-flight semantics. First request acquires the lock, fires the probe, populates `session_roots`. Followers await the same lock; on acquire, they re-check `session_roots.get(sid)` and exit early since the predecessor already populated. Net result: one upstream `peer.list_roots()` call, three list responses with the correct routing decision. Kept the cool-down semantic too: `should_throttle_probe` + `mark_probe_completed` rate-limit *sequential* probe attempts when the previous one errored, so a peer that's failing list_roots doesn't get hammered. Distinct from the lock — the lock is concurrency, the throttle is failure recovery. Doesn't help the upstream client bug (Claude Code claude-vscode doesn't refetch on tools/list_changed) but means the *first* response is correct, which is the only one some clients ever read. cargo check + clippy (-D warnings) clean. Signed-off-by: Mohammod Al Amin Ashik --- crates/mcpmux-gateway/src/mcp/handler.rs | 44 ++++++++-- .../src/services/session_roots.rs | 80 ++++++++++++------- 2 files changed, 87 insertions(+), 37 deletions(-) diff --git a/crates/mcpmux-gateway/src/mcp/handler.rs b/crates/mcpmux-gateway/src/mcp/handler.rs index 81628fe..0279f6e 100644 --- a/crates/mcpmux-gateway/src/mcp/handler.rs +++ b/crates/mcpmux-gateway/src/mcp/handler.rs @@ -206,7 +206,8 @@ impl McpMuxGatewayHandler { client_id: &str, ) { let Some(sid) = session_id else { return }; - // Already have a definitive answer (Some(roots) — possibly empty). + // Fast path: already have a definitive answer (Some(roots), + // possibly empty). No probe needed. if self.services.session_roots.get(sid).is_some() { return; } @@ -220,18 +221,45 @@ impl McpMuxGatewayHandler { { return; } - // Throttle: at most one probe per session per second so a - // request burst doesn't multiply upstream calls. - if !self + // Cool-down after a recent failed probe so we don't hammer a + // peer whose previous list_roots() errored. Doesn't apply + // when a probe is currently *running* — that's the + // probe_lock's job below. + if self .services .session_roots - .claim_probe(sid, std::time::Duration::from_secs(1)) + .should_throttle_probe(sid, std::time::Duration::from_secs(1)) { return; } + // Single-flight: serialize concurrent probes per session so a + // burst of three list calls (tools/list + prompts/list + + // resources/list within milliseconds) doesn't fan out three + // upstream `peer.list_roots()` calls. The first request enters + // the critical section, fires the probe, populates + // session_roots; the second and third await the same lock, + // then re-check session_roots and exit early. + // + // Without this, the followers used to skip the probe entirely + // (boolean `claim_probe` flag) and resolve to PendingRoots — + // exactly the empty-tools-list bug Claude Code's VS Code + // extension was hitting. + let lock = self.services.session_roots.probe_lock(sid); + let _guard = lock.lock().await; + + // Recheck after acquiring the lock — the predecessor probe may + // have already populated the registry. + if self.services.session_roots.get(sid).is_some() { + return; + } + const PROBE_BUDGET: std::time::Duration = std::time::Duration::from_millis(300); - match tokio::time::timeout(PROBE_BUDGET, peer.list_roots()).await { + let outcome = tokio::time::timeout(PROBE_BUDGET, peer.list_roots()).await; + // Stamp completion regardless of success/failure so the + // sequential cool-down kicks in for the next caller. + self.services.session_roots.mark_probe_completed(sid); + match outcome { Ok(Ok(result)) => { let uris: Vec = result.roots.iter().map(|r| r.uri.to_string()).collect(); self.services @@ -276,7 +304,7 @@ impl McpMuxGatewayHandler { %client_id, session_id = %sid, error = %e, - "[FeatureSetResolver] on-demand probe failed (will retry on next request)", + "[FeatureSetResolver] on-demand probe failed (will retry on next request after throttle)", ); } Err(_elapsed) => { @@ -284,7 +312,7 @@ impl McpMuxGatewayHandler { %client_id, session_id = %sid, budget_ms = PROBE_BUDGET.as_millis(), - "[FeatureSetResolver] on-demand probe timed out (will retry on next request)", + "[FeatureSetResolver] on-demand probe timed out (will retry on next request after throttle)", ); } } diff --git a/crates/mcpmux-gateway/src/services/session_roots.rs b/crates/mcpmux-gateway/src/services/session_roots.rs index 4f41dd3..d258c70 100644 --- a/crates/mcpmux-gateway/src/services/session_roots.rs +++ b/crates/mcpmux-gateway/src/services/session_roots.rs @@ -38,13 +38,27 @@ pub struct SessionRootsRegistry { roots_capable: DashMap, /// `session_id -> Instant of the last on-demand `list_roots()` probe`. /// - /// Used by the request-time re-probe path in the MCP handler to avoid - /// firing N parallel `list_roots()` calls when a roots-capable session - /// hits a burst of `tools/list` / `prompts/list` / `resources/list` in - /// quick succession. The handler calls `claim_probe(sid, throttle)` - /// before firing; if it returns false, another probe was attempted - /// recently and this one is skipped. + /// Used by [`Self::should_throttle_probe`] to avoid hammering a + /// failing client when its previous probe already errored out + /// recently. Only stamped after a probe attempt completes (success + /// or failure), not on entry — so concurrent in-flight probes + /// coordinate via [`Self::probe_lock`] instead of this throttle. last_probe: DashMap, + /// Per-session mutex guarding `peer.list_roots()` probe attempts. + /// + /// Single-flight semantics: when a burst of three list requests + /// (`tools/list` + `prompts/list` + `resources/list`) hits a + /// roots-pending session within milliseconds, only one upstream + /// `list_roots()` call should be in flight. The other two block on + /// the same lock; once the first attempt populates `map`, the + /// followers re-check `map.get(sid)` and skip the upstream call + /// entirely. + /// + /// Without this, a boolean "already tried" flag let the followers + /// see `roots_pending` and return empty *before* the first probe's + /// result landed — exactly the bug that left Claude Code's + /// VS Code extension showing only the meta tools. + probe_lock: DashMap>>, } impl SessionRootsRegistry { @@ -54,33 +68,40 @@ impl SessionRootsRegistry { last_resolution: DashMap::new(), roots_capable: DashMap::new(), last_probe: DashMap::new(), + probe_lock: DashMap::new(), }) } - /// Try to claim a probe slot for `session_id`. Returns `true` if it's - /// been at least `throttle` since the last attempt for this session - /// (or if there's never been one) — and stamps the new attempt - /// atomically. Returns `false` if a probe was already attempted - /// within the throttle window. + /// Get (or create) the per-session probe lock. The returned Arc is + /// what the handler awaits to serialize concurrent probes — see + /// [`Self::probe_lock`] for the rationale. + pub fn probe_lock(&self, session_id: &str) -> Arc> { + self.probe_lock + .entry(session_id.to_string()) + .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))) + .clone() + } + + /// Should we skip an on-demand probe because the previous attempt + /// completed (success or failure) within the last `throttle`? /// - /// The handler calls this before firing `peer.list_roots()` so a - /// burst of three `tools/list` / `prompts/list` / `resources/list` - /// calls in 50 ms results in at most one upstream probe. - pub fn claim_probe(&self, session_id: &str, throttle: Duration) -> bool { - let now = Instant::now(); - match self.last_probe.entry(session_id.to_string()) { - dashmap::mapref::entry::Entry::Occupied(mut e) => { - if now.duration_since(*e.get()) < throttle { - return false; - } - e.insert(now); - true - } - dashmap::mapref::entry::Entry::Vacant(e) => { - e.insert(now); - true - } - } + /// Distinct from `probe_lock`: the lock serializes *concurrent* + /// probes; this rate-limit prevents *sequential* probes from + /// hammering a peer whose previous attempt errored. + pub fn should_throttle_probe(&self, session_id: &str, throttle: Duration) -> bool { + let Some(last) = self.last_probe.get(session_id) else { + return false; + }; + Instant::now().duration_since(*last) < throttle + } + + /// Stamp the completion of an on-demand probe so the next caller + /// observes the throttle. Called after the probe returns (regardless + /// of success or failure) so successive probes back off only when + /// the previous one actually finished. + pub fn mark_probe_completed(&self, session_id: &str) { + self.last_probe + .insert(session_id.to_string(), Instant::now()); } /// Record whether a session declared the MCP `roots` capability on @@ -122,6 +143,7 @@ impl SessionRootsRegistry { self.last_resolution.remove(session_id); self.roots_capable.remove(session_id); self.last_probe.remove(session_id); + self.probe_lock.remove(session_id); } /// Compare-and-set the session's resolved feature-set id. Returns `true`