diff --git a/README.md b/README.md index a4444377..64d13a80 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ Lightweight and cross-platform — built in Rust with Tauri 2, McpMux uses minim } ``` -**3.** Done. Every tool from every server is available in every client, right now. +**3.** Done. Connected clients see a small fixed meta-tool surface (~12 `mcpmux_*` tools). Backend tools are discovered via **`mcpmux_search_tools`** → **`mcpmux_get_tool_schema`** → **`mcpmux_invoke_tool`**, keeping context windows lean. Optionally surface individual hot-path tools into `tools/list` per FeatureSet. McpMux routes calls to the right server, refreshes OAuth tokens automatically, and keeps credentials encrypted in your OS keychain — you never think about it again. @@ -109,10 +109,37 @@ Create isolated Spaces — each with their own servers, credentials, and permiss ### Control What Each Client Can Do -Not every AI client should have the same power. Create Feature Sets — permission bundles that control exactly which tools, prompts, and resources a client can access. Build a "Read Only" set for cautious workflows, a "React Development" set with just GitHub and Filesystem, or a "Full Stack Dev" set with everything. Assign them per-client so each tool only goes where you want it. +Not every AI client should have the same power. Create Feature Sets — permission bundles that control exactly which tools a client can **invoke** (search + invoke ACL). In the editor, the **checkbox** includes a tool in that ACL; the **Surface** button (optional, per row) promotes an included tool into the client's `tools/list` for one-hop hot paths. Build a "Read Only" set for cautious workflows, a "React Development" set with just GitHub and Filesystem, or a "Full Stack Dev" set with everything. Assign them per-client so each tool only goes where you want it. ![Feature Sets — granular per-server tool selection](docs/screenshots/featureset-detail.png) +### Self-Management Meta Tools (mcpmux_*) + +Connected AI clients see a fixed ~12-tool meta surface instead of every backend tool definition. FeatureSets define what is **invokable**; optional **surfaced** tools (0–N per set) can be promoted into `tools/list` for one-hop hot paths. Workspace bindings pin stable per-folder toolsets; session enable/disable gates server activity without bloating context. + +McpMux exposes a built-in `mcpmux_*` tool namespace for search → schema → invoke workflows: + +1. Call **`mcpmux_list_servers`** — server-level manifest with per-server status: `enabled_via_binding`, `enabled_via_session`, `disabled_via_session`, or `inactive`. +2. Call **`mcpmux_enable_server`** or **`mcpmux_disable_server`** — toggle servers on or off for the session or workspace. +3. Call **`mcpmux_search_tools`** — find invokable tools by query (respects FeatureSet ACL). +4. Call **`mcpmux_get_tool_schema`** — load parameter schemas before invoking. +5. Call **`mcpmux_invoke_tool`** — invoke any permitted backend tool through one entry point. + +| Tool | Type | Purpose | +| ---- | ---- | ------- | +| `mcpmux_list_all_tools` | read | Full tool roster in the resolved Space (diagnostic) | +| `mcpmux_list_feature_sets` | read | FeatureSets available in the resolved Space | +| `mcpmux_list_servers` | read | Server-level manifest with status | +| `mcpmux_search_tools` | read | Search invokable tools with optional schema detail | +| `mcpmux_get_tool_schema` | read | Load input schemas before invoke | +| `mcpmux_invoke_tool` | read | Invoke a backend tool by server_id + tool name | +| `mcpmux_enable_server` | write | Enable a server (session or workspace scope) | +| `mcpmux_disable_server` | write | Disable a server (session or workspace scope) | +| `mcpmux_create_feature_set` | write | Create a custom FeatureSet (optional `surfaced_tools[]`) | +| `mcpmux_bind_current_workspace` | write | Bind the session's workspace root to FeatureSets | + +In the desktop app: **Settings → Self-management tools** toggles the whole namespace and optional approval for session-scope overrides. **Workspaces → live folder inspector → Active session overrides** shows per-session enabled/disabled servers and lets you clear overrides with one click. + ### See and Manage Every Connected Client Cursor, VS Code, Windsurf, Claude Code — see every AI client connected to your gateway in real time. Click any client to manage its workspace, grant or revoke feature sets, and see exactly which tools it can access. New clients authenticate via OAuth with a one-click approval flow. diff --git a/apps/desktop/src-tauri/src/commands/feature_set.rs b/apps/desktop/src-tauri/src/commands/feature_set.rs index 7ecd8e82..ae132c0c 100644 --- a/apps/desktop/src-tauri/src/commands/feature_set.rs +++ b/apps/desktop/src-tauri/src/commands/feature_set.rs @@ -22,6 +22,7 @@ pub struct FeatureSetMemberResponse { pub member_type: String, pub member_id: String, pub mode: String, + pub surfaced: bool, } impl From<&FeatureSetMember> for FeatureSetMemberResponse { @@ -32,6 +33,7 @@ impl From<&FeatureSetMember> for FeatureSetMemberResponse { member_type: m.member_type.as_str().to_string(), member_id: m.member_id.clone(), mode: m.mode.as_str().to_string(), + surfaced: m.surfaced, } } } @@ -92,6 +94,7 @@ pub struct AddMemberInput { pub member_type: String, // "feature" or "feature_set" pub member_id: String, pub mode: Option, // "include" or "exclude", defaults to "include" + pub surfaced: Option, } /// List all feature sets. @@ -263,10 +266,6 @@ pub async fn update_feature_set( .map_err(|e| e.to_string())? .ok_or("Feature set not found")?; - if feature_set.is_builtin { - return Err("Cannot modify builtin feature set".to_string()); - } - if let Some(name) = input.name { feature_set.name = name; } @@ -372,6 +371,7 @@ pub async fn add_feature_set_member( member_type, member_id: input.member_id, mode, + surfaced: input.surfaced.unwrap_or(false), }; feature_set.members.push(member); @@ -496,6 +496,7 @@ pub async fn set_feature_set_members( member_type, member_id: input.member_id, mode, + surfaced: input.surfaced.unwrap_or(false), } }) .collect(); diff --git a/apps/desktop/src-tauri/src/commands/gateway.rs b/apps/desktop/src-tauri/src/commands/gateway.rs index 9c04632e..6150fce1 100644 --- a/apps/desktop/src-tauri/src/commands/gateway.rs +++ b/apps/desktop/src-tauri/src/commands/gateway.rs @@ -79,6 +79,10 @@ pub struct GatewayAppState { /// Surfaced to the desktop Workspaces tab so users can see + act on /// every folder connected clients are currently operating in. pub session_roots: Option>, + /// Session-scoped server enable/disable overrides (meta-tool mutations). + pub session_overrides: Option>, + /// Per-session list_changed bridge — used when the UI clears overrides. + pub mcp_notifier: Option>, } /// Gracefully shuts down a running gateway and waits for the axum task @@ -888,6 +892,8 @@ pub async fn start_gateway( let server_manager = server.server_manager(); let grant_service = server.grant_service(); let session_roots = server.session_roots(); + let session_overrides = server.session_overrides(); + let mcp_notifier = server.notification_bridge(); // Subscribe to OAuth completions BEFORE spawn so we don't miss early // events emitted during initial auto-connect. @@ -935,6 +941,8 @@ pub async fn start_gateway( state.grant_service = Some(grant_service); state.approval_broker = Some(approval_broker); state.session_roots = Some(session_roots); + state.session_overrides = Some(session_overrides); + state.mcp_notifier = Some(mcp_notifier); info!( "[Gateway] Started — url={}, event_emitter={}, grant_service={}", url, @@ -1567,11 +1575,12 @@ 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(); - let ctx = ConnectionContext::new(space_uuid, server_id.clone(), transport); + let ctx = ConnectionContext::new(space_uuid, server_id.clone(), transport) + .with_auto_reconnect(true); match pool_service.connect_server(&ctx).await { ConnectionResult::Connected { reused, features } => { if reused { @@ -1580,6 +1589,19 @@ pub async fn connect_all_enabled_servers( result.connected += 1; } + if server_info.requires_oauth && !installed.oauth_connected { + if let Err(e) = app_state + .installed_server_repository + .set_oauth_connected(&installed.id, true) + .await + { + warn!( + "[Gateway] Connected {} but failed to set oauth_connected: {}", + server_id, e + ); + } + } + info!( "[Gateway] Connected {} (reused: {}, features: {})", server_id, diff --git a/apps/desktop/src-tauri/src/commands/mod.rs b/apps/desktop/src-tauri/src/commands/mod.rs index bbfd57d9..6d8ff479 100644 --- a/apps/desktop/src-tauri/src/commands/mod.rs +++ b/apps/desktop/src-tauri/src/commands/mod.rs @@ -14,9 +14,11 @@ pub mod logs; pub mod meta_tool_approval; pub mod oauth; pub mod server; +pub mod server_clone; pub mod server_discovery; pub mod server_feature; pub mod server_manager; +pub mod session_overrides; pub mod settings; pub mod space; pub mod workspace_binding; @@ -32,9 +34,11 @@ pub use logs::*; pub use meta_tool_approval::*; pub use oauth::*; pub use server::*; +pub use server_clone::*; pub use server_discovery::*; pub use server_feature::*; pub use server_manager::*; +pub use session_overrides::*; pub use settings::*; pub use space::*; pub use workspace_binding::*; diff --git a/apps/desktop/src-tauri/src/commands/server.rs b/apps/desktop/src-tauri/src/commands/server.rs index e2c8f44a..73f97a43 100644 --- a/apps/desktop/src-tauri/src/commands/server.rs +++ b/apps/desktop/src-tauri/src/commands/server.rs @@ -127,6 +127,7 @@ pub async fn set_server_oauth_connected( .map_err(|e| e.to_string()) } +#[allow(clippy::too_many_arguments)] #[tauri::command] pub async fn save_server_inputs( app_service: State<'_, Arc>>>, @@ -136,6 +137,7 @@ pub async fn save_server_inputs( env_overrides: Option>, args_append: Option>, extra_headers: Option>, + display_name_override: Option, ) -> Result { let service_lock = app_service.read().await; let service = service_lock @@ -152,7 +154,32 @@ pub async fn save_server_inputs( env_overrides, args_append, extra_headers, + display_name_override, ) .await .map_err(|e| e.to_string()) } + +/// Set or clear the user-supplied display label on an installed server. +/// +/// Empty/whitespace clears the override and the UI falls back to the cached +/// definition name. Does not change `server_id`, alias, or tool prefixes. +#[tauri::command] +pub async fn set_server_display_name( + app_service: State<'_, Arc>>>, + id: String, + space_id: String, + display_name: Option, +) -> Result { + let service_lock = app_service.read().await; + let service = service_lock + .as_ref() + .ok_or("ServerAppService not initialized")?; + + let space_uuid = uuid::Uuid::parse_str(&space_id).map_err(|e| e.to_string())?; + + service + .set_display_name_override(space_uuid, &id, display_name) + .await + .map_err(|e| e.to_string()) +} diff --git a/apps/desktop/src-tauri/src/commands/server_clone.rs b/apps/desktop/src-tauri/src/commands/server_clone.rs new file mode 100644 index 00000000..ac7b30ca --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/server_clone.rs @@ -0,0 +1,99 @@ +//! Server clone commands + +use mcpmux_core::application::ServerAppService; +use mcpmux_core::domain::InstalledServer; +use std::sync::Arc; +use tauri::State; +use tokio::sync::RwLock; + +/// Clone an installed server into a new suffixed manual-entry install in the same space. +/// +/// `display_name` (optional) is stored as `display_name_override` so the user-supplied +/// label survives later definition refreshes (e.g. user-config sync). When omitted, the +/// auto `"Source (suffix)"` label on the cached definition is used as fallback. +#[tauri::command] +pub async fn clone_server( + app_service: State<'_, Arc>>>, + space_id: String, + source_server_id: String, + suffix: String, + alias: Option, + display_name: Option, +) -> Result { + let service_lock = app_service.read().await; + let service = service_lock + .as_ref() + .ok_or("ServerAppService not initialized")?; + + let space_uuid = uuid::Uuid::parse_str(&space_id).map_err(|e| e.to_string())?; + + service + .clone_server( + space_uuid, + &source_server_id, + &suffix, + alias.as_deref(), + display_name.as_deref(), + ) + .await + .map_err(|e| e.to_string()) +} + +/// Return whether a suffixed clone ID is available in the given space. +#[tauri::command] +pub async fn is_clone_id_available( + app_service: State<'_, Arc>>>, + space_id: String, + source_server_id: String, + suffix: String, +) -> Result { + let service_lock = app_service.read().await; + let service = service_lock + .as_ref() + .ok_or("ServerAppService not initialized")?; + + let space_uuid = uuid::Uuid::parse_str(&space_id).map_err(|e| e.to_string())?; + + service + .is_clone_id_available(space_uuid, &source_server_id, &suffix) + .await + .map_err(|e| e.to_string()) +} + +/// Suggest the first available default suffix for cloning a server. +#[tauri::command] +pub async fn suggest_clone_suffix( + app_service: State<'_, Arc>>>, + space_id: String, + source_server_id: String, +) -> Result { + let service_lock = app_service.read().await; + let service = service_lock + .as_ref() + .ok_or("ServerAppService not initialized")?; + + let space_uuid = uuid::Uuid::parse_str(&space_id).map_err(|e| e.to_string())?; + + service + .suggest_clone_suffix(space_uuid, &source_server_id) + .await + .map_err(|e| e.to_string()) +} + +/// List installed servers in a space that were cloned from the given source. +#[tauri::command] +pub async fn list_clone_dependents( + app_service: State<'_, Arc>>>, + space_id: String, + source_server_id: String, +) -> Result, String> { + let service_lock = app_service.read().await; + let service = service_lock + .as_ref() + .ok_or("ServerAppService not initialized")?; + + service + .list_clone_dependents(&space_id, &source_server_id) + .await + .map_err(|e| e.to_string()) +} diff --git a/apps/desktop/src-tauri/src/commands/session_overrides.rs b/apps/desktop/src-tauri/src/commands/session_overrides.rs new file mode 100644 index 00000000..6d12c131 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/session_overrides.rs @@ -0,0 +1,106 @@ +//! Tauri commands for inspecting and clearing session-scoped server overrides. + +use std::sync::Arc; + +use mcpmux_gateway::services::SessionOverrideEntry; +use serde::Serialize; +use tauri::{AppHandle, Emitter, State}; +use tokio::sync::RwLock; +use tracing::info; + +use super::gateway::GatewayAppState; + +/// Per-session override state surfaced to the Workspaces inspector. +#[derive(Debug, Clone, Serialize)] +pub struct SessionOverrideDto { + pub session_id: String, + pub enabled: Vec, + pub disabled: Vec, + pub roots: Vec, +} + +impl SessionOverrideDto { + fn from_entry(entry: SessionOverrideEntry, roots: Vec) -> Self { + Self { + session_id: entry.session_id, + enabled: entry.enabled, + disabled: entry.disabled, + roots, + } + } +} + +fn build_dtos(gateway: &GatewayAppState) -> Vec { + let Some(ref overrides) = gateway.session_overrides else { + return vec![]; + }; + let roots_by_session: std::collections::HashMap> = gateway + .session_roots + .as_ref() + .map(|reg| reg.list_all_sessions().into_iter().collect()) + .unwrap_or_default(); + + overrides + .list_all() + .into_iter() + .map(|entry| { + let roots = roots_by_session + .get(&entry.session_id) + .cloned() + .unwrap_or_default(); + SessionOverrideDto::from_entry(entry, roots) + }) + .collect() +} + +/// List override state for one session, or every session when `session_id` +/// is omitted. Returns an empty list when the gateway is not running. +#[tauri::command] +pub async fn list_session_overrides( + session_id: Option, + gateway_state: State<'_, Arc>>, +) -> Result, String> { + let guard = gateway_state.read().await; + let mut dtos = build_dtos(&guard); + if let Some(sid) = session_id { + dtos.retain(|d| d.session_id == sid); + } + Ok(dtos) +} + +/// Drop all enable/disable overrides for a session and push list_changed so +/// the client's tool list reverts to binding-only routing. +#[tauri::command] +pub async fn clear_session_overrides( + session_id: String, + gateway_state: State<'_, Arc>>, + app_handle: AppHandle, +) -> Result<(), String> { + let notifier = { + let guard = gateway_state.read().await; + let overrides = guard + .session_overrides + .as_ref() + .ok_or("Gateway is not running")?; + overrides.clear(&session_id); + guard.mcp_notifier.clone() + }; + + if let Some(notifier) = notifier { + notifier.notify_session_lists_changed(&session_id).await; + } + + info!( + "[session_overrides] cleared overrides for session {}", + session_id + ); + + if let Err(e) = app_handle.emit( + "session-overrides-changed", + serde_json::json!({ "session_id": session_id }), + ) { + tracing::warn!("[session_overrides] failed to emit session-overrides-changed: {e}"); + } + + Ok(()) +} diff --git a/apps/desktop/src-tauri/src/commands/settings.rs b/apps/desktop/src-tauri/src/commands/settings.rs index 10299519..d0c95682 100644 --- a/apps/desktop/src-tauri/src/commands/settings.rs +++ b/apps/desktop/src-tauri/src/commands/settings.rs @@ -165,6 +165,44 @@ pub async fn set_meta_tools_enabled( Ok(()) } +/// Whether session-scope `mcpmux_enable_server` / `mcpmux_disable_server` +/// calls require approval. Default OFF (auto-allow). +#[tauri::command] +pub async fn get_session_overrides_require_approval( + app_state: State<'_, AppState>, +) -> Result { + match app_state + .settings_repository + .get("gateway.session_overrides_require_approval") + .await + { + Ok(Some(v)) => Ok(matches!(v.as_str(), "true" | "1")), + _ => Ok(false), + } +} + +/// Flip the session-override approval gate. Takes effect on the next +/// session-scope enable/disable meta-tool call. +#[tauri::command] +pub async fn set_session_overrides_require_approval( + require_approval: bool, + app_state: State<'_, AppState>, +) -> Result<(), String> { + app_state + .settings_repository + .set( + "gateway.session_overrides_require_approval", + if require_approval { "true" } else { "false" }, + ) + .await + .map_err(|e| format!("Failed to save session_overrides_require_approval: {}", e))?; + info!( + "[Settings] session_overrides_require_approval = {}", + require_approval + ); + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/apps/desktop/src-tauri/src/commands/space.rs b/apps/desktop/src-tauri/src/commands/space.rs index 7b547878..8d8b3ea2 100644 --- a/apps/desktop/src-tauri/src/commands/space.rs +++ b/apps/desktop/src-tauri/src/commands/space.rs @@ -7,6 +7,7 @@ //! viewing in its own Zustand store (frontend-only state). use mcpmux_core::Space; +use serde::Deserialize; use std::sync::Arc; use tauri::{AppHandle, State}; use tokio::sync::RwLock; @@ -103,6 +104,59 @@ pub async fn create_space( Ok(space) } +/// Partial update payload for a Space (name, icon, description). +#[derive(Debug, Deserialize)] +pub struct UpdateSpaceInput { + pub name: Option, + pub icon: Option, + pub description: Option, +} + +/// Update a space's display metadata. +#[tauri::command] +pub async fn update_space( + id: String, + input: UpdateSpaceInput, + app: AppHandle, + state: State<'_, AppState>, + gateway_state: State<'_, Arc>>, +) -> Result { + let uuid = Uuid::parse_str(&id).map_err(|e| e.to_string())?; + + let name = input + .name + .map(|n| n.trim().to_string()) + .filter(|n| !n.is_empty()); + let icon = input + .icon + .map(|i| i.trim().to_string()) + .filter(|i| !i.is_empty()); + let description = input.description.map(|d| d.trim().to_string()); + + let space = state + .space_service + .update(uuid, name, icon, description) + .await + .map_err(|e| e.to_string())?; + + let gw_state = gateway_state.read().await; + if let Some(ref gw) = gw_state.gateway_state { + let gw = gw.read().await; + gw.emit_domain_event(mcpmux_core::DomainEvent::SpaceUpdated { + space_id: space.id, + name: space.name.clone(), + }); + } + + if let Err(e) = tray::update_tray_spaces(&app, &state).await { + warn!("Failed to update tray menu: {}", e); + } + + info!("[update_space] Space '{}' updated successfully", space.name); + + Ok(space) +} + /// Delete a space. #[tauri::command] pub async fn delete_space( diff --git a/apps/desktop/src-tauri/src/commands/workspace_binding.rs b/apps/desktop/src-tauri/src/commands/workspace_binding.rs index 2d5a083c..b1c1fcc9 100644 --- a/apps/desktop/src-tauri/src/commands/workspace_binding.rs +++ b/apps/desktop/src-tauri/src/commands/workspace_binding.rs @@ -54,6 +54,7 @@ async fn emit_binding_changed( pub struct WorkspaceBindingDto { pub id: String, pub workspace_root: String, + pub label: Option, pub space_id: String, pub feature_set_ids: Vec, pub created_at: String, @@ -65,6 +66,7 @@ impl From for WorkspaceBindingDto { Self { id: b.id.to_string(), workspace_root: b.workspace_root, + label: b.label, space_id: b.space_id.to_string(), feature_set_ids: b.feature_set_ids, created_at: b.created_at.to_rfc3339(), @@ -80,10 +82,18 @@ impl From for WorkspaceBindingDto { #[derive(Debug, Deserialize)] pub struct WorkspaceBindingInput { pub workspace_root: String, + pub label: Option, pub space_id: String, pub feature_set_ids: Vec, } +fn normalize_label(label: &Option) -> Option { + label + .as_ref() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + fn parse_space_id(input: &WorkspaceBindingInput) -> Result { Uuid::parse_str(&input.space_id).map_err(|e| format!("bad space_id: {e}")) } @@ -194,7 +204,8 @@ pub async fn create_workspace_binding( let feature_set_ids = validate_fs_list(&input)?; let normalized = normalize_and_validate(&input.workspace_root)?; - let binding = WorkspaceBinding::new_multi(normalized.clone(), space_id, feature_set_ids); + let mut binding = WorkspaceBinding::new_multi(normalized.clone(), space_id, feature_set_ids); + binding.label = normalize_label(&input.label); state .workspace_binding_repository @@ -241,9 +252,16 @@ pub async fn update_workspace_binding( .ok_or_else(|| format!("binding not found: {}", id))?; let old_space_id = existing.space_id; + let label = if input.label.is_some() { + normalize_label(&input.label) + } else { + existing.label + }; + let updated = WorkspaceBinding { id: existing.id, workspace_root: normalized, + label, space_id, feature_set_ids, created_at: existing.created_at, @@ -695,3 +713,23 @@ pub async fn get_workspace_effective_features( server_totals, }) } + +#[cfg(test)] +mod tests { + use super::normalize_label; + + #[test] + fn normalize_label_none_and_empty() { + assert_eq!(normalize_label(&None), None); + assert_eq!(normalize_label(&Some(String::new())), None); + assert_eq!(normalize_label(&Some(" ".to_string())), None); + } + + #[test] + fn normalize_label_trims_non_empty() { + assert_eq!( + normalize_label(&Some(" My Project ".to_string())), + Some("My Project".to_string()) + ); + } +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 08c7d44d..a7e62ff0 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -848,6 +848,7 @@ pub fn run() { commands::list_spaces, commands::get_space, commands::create_space, + commands::update_space, commands::delete_space, commands::open_space_config_file, commands::read_space_config, @@ -869,6 +870,11 @@ pub fn run() { commands::set_server_enabled, commands::set_server_oauth_connected, commands::save_server_inputs, + commands::set_server_display_name, + commands::clone_server, + commands::is_clone_id_available, + commands::suggest_clone_suffix, + commands::list_clone_dependents, // FeatureSet commands commands::list_feature_sets, commands::list_feature_sets_by_space, @@ -912,6 +918,10 @@ pub fn run() { commands::revoke_meta_tool_grant, commands::get_meta_tools_enabled, commands::set_meta_tools_enabled, + commands::get_session_overrides_require_approval, + commands::set_session_overrides_require_approval, + commands::list_session_overrides, + commands::clear_session_overrides, // Config export commands commands::preview_config_export, commands::export_config_to_file, diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index d3e1ab02..ffd8cc04 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -36,7 +36,8 @@ "minHeight": 600, "center": true, "preventOverflow": true, - "decorations": false + "decorations": false, + "acceptFirstMouse": true } ], "security": { diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index aab0aaaf..50351835 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -296,16 +296,22 @@ function AppContent() { ); const titleBar = ( -
- - - Mcp - Mux - -
+
+
+ + + Mcp + Mux + +
+
- {open && ( -
- {items.map((item) => ( - - ))} -
- )} -
+ + + + + + {items.map((item) => ( + openExternal(item.href)} + /> + ))} + + ); } diff --git a/apps/desktop/src/components/ServerLogViewer.tsx b/apps/desktop/src/components/ServerLogViewer.tsx index 7905496d..8524867f 100644 --- a/apps/desktop/src/components/ServerLogViewer.tsx +++ b/apps/desktop/src/components/ServerLogViewer.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, useRef } from 'react'; -import { X, Download, Trash2, RefreshCw } from 'lucide-react'; +import { X, Download, Trash2, RefreshCw, Copy } from 'lucide-react'; import { useToast, ToastContainer, useConfirm } from '@mcpmux/ui'; import { getServerLogs, clearServerLogs, getServerLogFile, type ServerLogEntry } from '@/lib/api/logs'; @@ -32,6 +32,30 @@ const SOURCE_COLORS: Record = { server: 'text-cyan-400', }; +/** + * Formats an ISO timestamp for log display and export. + */ +function formatTimestamp(ts: string): string { + const date = new Date(ts); + const hours = date.getHours().toString().padStart(2, '0'); + const minutes = date.getMinutes().toString().padStart(2, '0'); + const seconds = date.getSeconds().toString().padStart(2, '0'); + const ms = date.getMilliseconds().toString().padStart(3, '0'); + return `${hours}:${minutes}:${seconds}.${ms}`; +} + +/** + * Formats a log entry as a single plain-text line for display export. + */ +function formatLogLine(log: ServerLogEntry): string { + const level = log.level.toUpperCase().padEnd(5); + const base = `${formatTimestamp(log.timestamp)} ${level} ${log.source} ${log.message}`; + if (!log.metadata) { + return base; + } + return `${base} ${JSON.stringify(log.metadata)}`; +} + export function ServerLogViewer({ serverId, serverName, onClose }: ServerLogViewerProps) { const [logs, setLogs] = useState([]); const [loading, setLoading] = useState(true); @@ -122,15 +146,6 @@ export function ServerLogViewer({ serverId, serverName, onClose }: ServerLogView } }; - const formatTimestamp = (ts: string) => { - const date = new Date(ts); - const hours = date.getHours().toString().padStart(2, '0'); - const minutes = date.getMinutes().toString().padStart(2, '0'); - const seconds = date.getSeconds().toString().padStart(2, '0'); - const ms = date.getMilliseconds().toString().padStart(3, '0'); - return `${hours}:${minutes}:${seconds}.${ms}`; - }; - const filteredLogs = logs.filter(log => { if (levelFilter === 'all') return true; const logLevelIndex = LOG_LEVELS.indexOf(log.level as LogLevel); @@ -138,6 +153,25 @@ export function ServerLogViewer({ serverId, serverName, onClose }: ServerLogView return logLevelIndex >= filterLevelIndex; }); + /** Copies all currently visible (filtered) log lines to the clipboard. */ + const handleCopyAll = async () => { + if (filteredLogs.length === 0) { + showError('Nothing to copy', 'No logs match the current filter'); + return; + } + + try { + const text = filteredLogs.map((log) => formatLogLine(log)).join('\n'); + await navigator.clipboard.writeText(text); + success( + 'Logs copied', + `${filteredLogs.length} log${filteredLogs.length !== 1 ? 's' : ''} copied to clipboard` + ); + } catch (e) { + showError('Failed to copy logs', e instanceof Error ? e.message : String(e)); + } + }; + return (
@@ -195,6 +229,16 @@ export function ServerLogViewer({ serverId, serverName, onClose }: ServerLogView > + + {/* Copy All */} + {/* Clear Logs */}

- {featureSet.name} + {displayName}

-

- {featureSet.description || 'No description provided.'} -

+ setEditName(e.target.value)} + className="w-full px-3 py-2 text-sm rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--background))] focus:outline-none focus:ring-2 focus:ring-primary-500" + data-testid="featureset-panel-name" + />
+ +
+ + setEditDescription(e.target.value)} + placeholder="What this feature set allows..." + className="w-full px-3 py-2 text-sm rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--background))] focus:outline-none focus:ring-2 focus:ring-primary-500" + data-testid="featureset-panel-description" + /> +
+ +
+ + setEditIcon(e.target.value)} + placeholder="🔧" + maxLength={2} + className="w-full px-3 py-2 text-sm rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--background))] focus:outline-none focus:ring-2 focus:ring-primary-500" + data-testid="featureset-panel-icon" + /> +
+ + + {isStarter && (
@@ -510,42 +643,64 @@ export function FeatureSetPanel({ featureSet, spaceId, onClose, onDelete, onUpda
{group.features.map((feature) => { const isSelected = isFeatureSelected(feature.id, feature); - + const isSurfaced = surfacedFeatureIds.has(feature.id); + const isTool = feature.feature_type === 'tool'; + return ( -
- + + + {isConfigurable && isTool && isSelected && ( + + )} +
); })}
diff --git a/apps/desktop/src/features/servers/AddServerMenu.tsx b/apps/desktop/src/features/servers/AddServerMenu.tsx new file mode 100644 index 00000000..15bafe2d --- /dev/null +++ b/apps/desktop/src/features/servers/AddServerMenu.tsx @@ -0,0 +1,48 @@ +import { ChevronDown, Compass, FileJson, Plus } from 'lucide-react'; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@mcpmux/ui'; + +interface AddServerMenuProps { + /** Opens the Discover page to browse the community server registry. */ + onDiscover: () => void; + /** Opens the Space JSON editor to add a custom server definition. */ + onCustom: () => void; +} + +/** + * Dropdown for the two ways to add MCP servers: registry discover vs custom JSON. + */ +export function AddServerMenu({ onDiscover, onCustom }: AddServerMenuProps) { + return ( + + + + + + + + + + ); +} diff --git a/apps/desktop/src/features/servers/CloneAccountModal.tsx b/apps/desktop/src/features/servers/CloneAccountModal.tsx new file mode 100644 index 00000000..7faaebe2 --- /dev/null +++ b/apps/desktop/src/features/servers/CloneAccountModal.tsx @@ -0,0 +1,338 @@ +/** + * CloneAccountModal — wizard for adding another account of an installed MCP server. + */ + +import { useCallback, useEffect, useState } from 'react'; +import { Copy, Loader2, X } from 'lucide-react'; +import type { ServerViewModel } from '@/types/registry'; +import { + CLONE_SUFFIX_SUGGESTIONS, + cloneServer, + deriveCloneAlias, + deriveCloneServerId, + isCloneIdAvailable, + suggestCloneSuffix, + type ClonedInstalledServer, +} from '@/lib/api/serverClone'; + +export interface CloneAccountModalProps { + open: boolean; + spaceId: string; + sourceServer: ServerViewModel; + onClose: () => void; + /** Called after a successful clone with the new install row. */ + onCloned: (cloned: ClonedInstalledServer) => void; +} + +/** + * Modal for creating a suffixed clone of an installed server in the same space. + */ +export function CloneAccountModal({ + open, + spaceId, + sourceServer, + onClose, + onCloned, +}: CloneAccountModalProps) { + const [suffix, setSuffix] = useState(''); + const [displayName, setDisplayName] = useState(''); + const [isChecking, setIsChecking] = useState(false); + const [isAvailable, setIsAvailable] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + const [isLoadingSuggestion, setIsLoadingSuggestion] = useState(false); + + const trimmedSuffix = suffix.trim(); + const trimmedDisplayName = displayName.trim(); + const displayNamePlaceholder = trimmedSuffix + ? `${sourceServer.name} (${trimmedSuffix})` + : `${sourceServer.name} (work)`; + + const previewId = deriveCloneServerId(sourceServer.id, suffix); + const previewAlias = deriveCloneAlias(suffix); + const hasSuffix = suffix.trim().length > 0; + const hasCollision = hasSuffix && isAvailable === false; + + /** + * Load the first available suggested suffix when the modal opens. + */ + useEffect(() => { + if (!open) { + return; + } + + let cancelled = false; + + const loadSuggestion = async () => { + setIsLoadingSuggestion(true); + setSubmitError(null); + try { + const suggested = await suggestCloneSuffix(spaceId, sourceServer.id); + if (!cancelled) { + setSuffix(suggested); + } + } catch (e) { + if (!cancelled) { + setSuffix(CLONE_SUFFIX_SUGGESTIONS[0]); + setSubmitError(String(e)); + } + } finally { + if (!cancelled) { + setIsLoadingSuggestion(false); + } + } + }; + + loadSuggestion(); + + return () => { + cancelled = true; + }; + }, [open, spaceId, sourceServer.id]); + + /** + * Debounced collision check against the backend. + */ + useEffect(() => { + if (!open || !hasSuffix) { + setIsAvailable(null); + setIsChecking(false); + return; + } + + let cancelled = false; + setIsChecking(true); + + const timer = setTimeout(async () => { + try { + const available = await isCloneIdAvailable(spaceId, sourceServer.id, suffix); + if (!cancelled) { + setIsAvailable(available); + } + } catch { + if (!cancelled) { + setIsAvailable(null); + } + } finally { + if (!cancelled) { + setIsChecking(false); + } + } + }, 300); + + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [open, spaceId, sourceServer.id, suffix, hasSuffix]); + + /** + * Submit the clone request. + */ + const handleSubmit = useCallback(async () => { + if (!hasSuffix || hasCollision || isChecking) { + return; + } + + setIsSubmitting(true); + setSubmitError(null); + + try { + const cloned = await cloneServer( + spaceId, + sourceServer.id, + suffix, + undefined, + trimmedDisplayName.length > 0 ? trimmedDisplayName : undefined + ); + onCloned(cloned); + onClose(); + } catch (e) { + setSubmitError(String(e)); + } finally { + setIsSubmitting(false); + } + }, [ + hasSuffix, + hasCollision, + isChecking, + spaceId, + sourceServer.id, + suffix, + trimmedDisplayName, + onCloned, + onClose, + ]); + + if (!open) { + return null; + } + + const canSubmit = + hasSuffix && !hasCollision && !isChecking && !isSubmitting && !isLoadingSuggestion; + + return ( +
+
+
+
+
+ +
+
+

+ Add another account +

+

+ Clone {sourceServer.name} with a separate credential set +

+
+
+ +
+ +
+
+ +

+ Shown in My Servers only. Leave blank to use the default. +

+ setDisplayName(e.target.value)} + placeholder={displayNamePlaceholder} + className="input w-full" + disabled={isSubmitting} + data-testid="clone-display-name-input" + /> +
+ +
+ +

+ Used in the server ID and tool prefix (e.g. work, personal) +

+ setSuffix(e.target.value)} + placeholder="work" + className={`input w-full ${hasCollision ? 'border-[rgb(var(--error))]' : ''}`} + disabled={isLoadingSuggestion || isSubmitting} + data-testid="clone-suffix-input" + /> + {hasCollision && ( +

+ An account with this label already exists in this space +

+ )} +
+ +
+

Suggestions

+
+ {CLONE_SUFFIX_SUGGESTIONS.map((suggestion) => ( + + ))} +
+
+ + {hasSuffix && ( +
+
+ Server ID + + {previewId || '—'} + +
+
+ Tool prefix + + {previewAlias ? `${previewAlias}_*` : '—'} + +
+ {isChecking && ( +
+ + Checking availability… +
+ )} +
+ )} + +

+ The clone copies the server definition but not credentials. You will configure this + account before enabling it. +

+ + {submitError && ( +

+ {submitError} +

+ )} + +
+ + +
+
+
+
+ ); +} diff --git a/apps/desktop/src/features/servers/ServerActionMenu.tsx b/apps/desktop/src/features/servers/ServerActionMenu.tsx index bbec31e9..f157ce8a 100644 --- a/apps/desktop/src/features/servers/ServerActionMenu.tsx +++ b/apps/desktop/src/features/servers/ServerActionMenu.tsx @@ -1,33 +1,35 @@ -/** - * ServerActionMenu - Overflow menu for server actions - * - * Actions: - * - Configure: Edit server inputs - * - Refresh: Quick reconnect with existing credentials - * - Reconnect: Logout + re-authenticate (OAuth only) - * - View Logs: Open log viewer - * - View Definition: View server definition JSON - * - Uninstall: Remove server - */ - -import { useState, useRef, useEffect } from 'react'; -import { MoreVertical, Settings, RefreshCw, RotateCcw, FileText, Code, Trash2 } from 'lucide-react'; +import { MoreVertical, Settings, RefreshCw, RotateCcw, FileText, Code, Trash2, Copy } from 'lucide-react'; +import { + DropdownMenu, + DropdownMenuAction, + DropdownMenuContent, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@mcpmux/ui'; export interface ServerActionMenuProps { serverId: string; serverName: string; + /** Whether the server has credential / config inputs. Servers with no inputs still show + * Configure so the display name can be edited. */ hasInputs: boolean; isOAuth: boolean; isEnabled: boolean; isConnected: boolean; + /** Show "Add another account…" for registry/manual installs (not clones-of-clones). */ + canCloneAccount?: boolean; onConfigure: () => void; onRefresh: () => void; onReconnect: () => void; onViewLogs: () => void; onViewDefinition: () => void; + onCloneAccount?: () => void; onUninstall: () => void; } +/** + * Overflow menu for per-server actions (configure, logs, uninstall, etc.). + */ export function ServerActionMenu({ serverId, serverName: _serverName, @@ -35,149 +37,74 @@ export function ServerActionMenu({ isOAuth, isEnabled, isConnected: _isConnected, + canCloneAccount = false, onConfigure, onRefresh, onReconnect, onViewLogs, onViewDefinition, + onCloneAccount, onUninstall, }: ServerActionMenuProps) { - const [isOpen, setIsOpen] = useState(false); - const menuRef = useRef(null); - const buttonRef = useRef(null); - - // Close menu when clicking outside - useEffect(() => { - function handleClickOutside(event: MouseEvent) { - if ( - menuRef.current && - !menuRef.current.contains(event.target as Node) && - buttonRef.current && - !buttonRef.current.contains(event.target as Node) - ) { - setIsOpen(false); - } - } - - if (isOpen) { - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - } - }, [isOpen]); - - // Close menu on escape - useEffect(() => { - function handleEscape(event: KeyboardEvent) { - if (event.key === 'Escape') { - setIsOpen(false); - } - } - - if (isOpen) { - document.addEventListener('keydown', handleEscape); - return () => document.removeEventListener('keydown', handleEscape); - } - }, [isOpen]); - - const handleAction = (action: () => void) => { - setIsOpen(false); - action(); - }; - return ( -
- - - {isOpen && ( -
+ + - )} - - {/* Refresh - visible when enabled (quick reconnect with existing creds) */} - {isEnabled && ( - - )} - - {/* Reconnect - OAuth only (logout + re-auth) */} - {isOAuth && isEnabled && ( - - )} - - {/* View Logs - always visible */} - - - {/* View Definition - always visible */} - - - {/* Separator */} -
- - {/* Uninstall - always visible, destructive */} - -
- )} -
+ + + + + + {isEnabled && ( + + )} + {isOAuth && isEnabled && ( + + )} + + + {canCloneAccount && onCloneAccount && ( + + )} + + + + ); } diff --git a/apps/desktop/src/features/servers/ServerEnabledToggle.tsx b/apps/desktop/src/features/servers/ServerEnabledToggle.tsx new file mode 100644 index 00000000..5e0807f4 --- /dev/null +++ b/apps/desktop/src/features/servers/ServerEnabledToggle.tsx @@ -0,0 +1,34 @@ +import { Switch } from '@mcpmux/ui'; + +interface ServerEnabledToggleProps { + serverId: string; + enabled: boolean; + isLoading: boolean; + disabled?: boolean; + onToggle: (enabled: boolean) => void; +} + +/** + * Labeled enable/disable control for an installed server row. + */ +export function ServerEnabledToggle({ + serverId, + enabled, + isLoading, + disabled = false, + onToggle, +}: ServerEnabledToggleProps) { + const label = isLoading ? (enabled ? 'Disabling…' : 'Enabling…') : enabled ? 'Enabled' : 'Disabled'; + + return ( +
+ {label} + +
+ ); +} diff --git a/apps/desktop/src/features/servers/ServersCountSummary.tsx b/apps/desktop/src/features/servers/ServersCountSummary.tsx new file mode 100644 index 00000000..1d4ba97b --- /dev/null +++ b/apps/desktop/src/features/servers/ServersCountSummary.tsx @@ -0,0 +1,35 @@ +import { HoverTooltip } from '@mcpmux/ui'; +import { + describeServerCountSummary, + formatServerCountSummary, + type ServerCountSummary, +} from './servers-page.helpers'; + +interface ServersCountSummaryProps { + summary: ServerCountSummary; +} + +/** + * Inline installed-server counts beside the My Servers title, with hover breakdown. + */ +export function ServersCountSummary({ summary }: ServersCountSummaryProps) { + if (summary.installed === 0) { + return null; + } + + return ( + +

+ {formatServerCountSummary(summary)} +

+
+ ); +} diff --git a/apps/desktop/src/features/servers/ServersFiltersPopover.tsx b/apps/desktop/src/features/servers/ServersFiltersPopover.tsx new file mode 100644 index 00000000..9ad723ef --- /dev/null +++ b/apps/desktop/src/features/servers/ServersFiltersPopover.tsx @@ -0,0 +1,141 @@ +import { useState } from 'react'; +import { ChevronDown, SlidersHorizontal } from 'lucide-react'; +import { + Button, + ChipButton, + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, + HoverTooltip, +} from '@mcpmux/ui'; +import { + STATUS_FILTERS, + TRANSPORT_FILTERS, + countActiveServerFilters, + describeAppliedServerFilters, + type StatusFilterKey, + type TransportFilter, +} from './servers-page.helpers'; + +interface ServersFiltersPopoverProps { + transportFilter: TransportFilter; + onTransportFilterChange: (filter: TransportFilter) => void; + activeStatusFilters: Set; + onToggleStatusFilter: (statusKey: StatusFilterKey) => void; + onClearStatusFilters: () => void; + onClearAllFilters: () => void; +} + +/** + * Popover for transport (stdio/http) and Beeper-style multi-select status filters. + */ +export function ServersFiltersPopover({ + transportFilter, + onTransportFilterChange, + activeStatusFilters, + onToggleStatusFilter, + onClearStatusFilters, + onClearAllFilters, +}: ServersFiltersPopoverProps) { + const [open, setOpen] = useState(false); + const activeCount = countActiveServerFilters(transportFilter, activeStatusFilters); + const appliedFilterLines = describeAppliedServerFilters(transportFilter, activeStatusFilters); + + return ( + + ); +} diff --git a/apps/desktop/src/features/servers/ServersPage.tsx b/apps/desktop/src/features/servers/ServersPage.tsx index 9ae658c0..c49880f9 100644 --- a/apps/desktop/src/features/servers/ServersPage.tsx +++ b/apps/desktop/src/features/servers/ServersPage.tsx @@ -19,11 +19,30 @@ import { Clock, FileJson, FolderOpen, + UnfoldVertical, + FoldVertical, + Search, } from 'lucide-react'; +import { Button, SearchField } from '@mcpmux/ui'; import { ServerActionMenu } from './ServerActionMenu'; +import { ServerEnabledToggle } from './ServerEnabledToggle'; +import { CloneAccountModal } from './CloneAccountModal'; +import { AddServerMenu } from './AddServerMenu'; +import { ServersFiltersPopover } from './ServersFiltersPopover'; +import { ServersCountSummary } from './ServersCountSummary'; +import { UninstallSourceWithClonesDialog } from './UninstallSourceWithClonesDialog'; import type { ServerViewModel, ServerDefinition, InstalledServerState, InputDefinition } from '../../types/registry'; import type { ServerFeature } from '@/lib/api/serverFeatures'; -import { listServerFeaturesByServer } from '@/lib/api/serverFeatures'; +import { listServerFeatures, listServerFeaturesByServer } from '@/lib/api/serverFeatures'; +import { + computeServerCountSummary, + groupFeaturesByServerId, + serverMatchesFilters, + type ServerActionKey, + type StatusFilterKey, + type TransportFilter, +} from './servers-page.helpers'; +import { resolveInstalledDisplayName } from './server-display-name.helpers'; import type { ConnectionStatus, ServerStatusResponse } from '@/lib/api/serverManager'; import { getServerStatuses as fetchServerStatuses } from '@/lib/api/serverManager'; import { useViewSpace, useNavigateTo } from '@/stores'; @@ -36,40 +55,64 @@ import { ServerLogViewer } from '@/components/ServerLogViewer'; import { ConfigEditorModal } from '@/components/ConfigEditorModal'; import { ServerDefinitionModal } from '@/components/ServerDefinitionModal'; import { SourceBadge } from '@/components/SourceBadge'; +import type { ClonedInstalledServer } from '@/lib/api/serverClone'; +import { listCloneDependents } from '@/lib/api/serverClone'; + +/** Server view model extended with optional clone lineage from the backend. */ +type ServerViewModelWithClone = ServerViewModel & { cloned_from?: string }; + +/** + * Read clone lineage from an installed-server row when the TS type has not caught up yet. + */ +function getInstalledCloneLineage(state: InstalledServerState): string | undefined { + const clonedFrom = (state as InstalledServerState & { cloned_from?: string | null }).cloned_from; + return clonedFrom ?? undefined; +} + +/** + * Whether the overflow menu should offer "Add another account…". + */ +function canCloneServer(server: ServerViewModelWithClone): boolean { + if (server.cloned_from) { + return false; + } + + const sourceType = server.installation_source?.type; + return sourceType === 'registry' || sourceType === 'manual_entry'; +} // Helper to merge definitions with states (same as registryStore) function mergeDefinitionsWithStates( definitions: ServerDefinition[], states: InstalledServerState[] -): ServerViewModel[] { +): ServerViewModelWithClone[] { const stateMap = new Map(states.map(s => [s.server_id, s])); return definitions.map(def => { const state = stateMap.get(def.id); - - // Check if any required inputs are missing + const inputs = def.transport.metadata?.inputs ?? []; const inputValues = state?.input_values ?? {}; const missing_required_inputs = inputs.some((input: InputDefinition) => input.required && !inputValues[input.id] ); - - // Calculate initial connection_status based on enabled state - // Calculate initial connection_status based on enabled state - // Actual runtime status comes from ServerManager events via useServerManager hook + const connection_status = state?.enabled ? 'connecting' : 'disconnected'; - + const displayName = state ? resolveInstalledDisplayName(state, def) : def.name; + return { ...def, + name: displayName, is_installed: !!state, enabled: state?.enabled ?? false, oauth_connected: state?.oauth_connected ?? false, input_values: inputValues, - connection_status, // Initial status, will be overridden by runtime events + connection_status, missing_required_inputs, - last_error: null, // Runtime-only, will be set by ServerManager events - created_at: state?.created_at, // Include for sorting - installation_source: state?.source, // Track how server was installed + last_error: null, + created_at: state?.created_at, + installation_source: state?.source, + cloned_from: state ? getInstalledCloneLineage(state) : undefined, env_overrides: state?.env_overrides ?? {}, args_append: state?.args_append ?? [], extra_headers: state?.extra_headers ?? {}, @@ -79,8 +122,7 @@ function mergeDefinitionsWithStates( // Helper to create ServerViewModel from installed state when registry is unavailable // Uses cached_definition if available (proper offline support), otherwise falls back to minimal data -function createOfflineServerViewModel(state: InstalledServerState): ServerViewModel { - // Try to use cached definition first (proper offline support) +function createOfflineServerViewModel(state: InstalledServerState): ServerViewModelWithClone { if (state.cached_definition) { try { const definition: ServerDefinition = JSON.parse(state.cached_definition); @@ -95,6 +137,7 @@ function createOfflineServerViewModel(state: InstalledServerState): ServerViewMo return { ...definition, + name: resolveInstalledDisplayName(state, definition), is_installed: true, enabled: state.enabled, oauth_connected: state.oauth_connected, @@ -104,6 +147,7 @@ function createOfflineServerViewModel(state: InstalledServerState): ServerViewMo last_error: null, created_at: state.created_at, installation_source: state.source, + cloned_from: getInstalledCloneLineage(state), env_overrides: state.env_overrides ?? {}, args_append: state.args_append ?? [], extra_headers: state.extra_headers ?? {}, @@ -113,10 +157,9 @@ function createOfflineServerViewModel(state: InstalledServerState): ServerViewMo } } - // Fallback: minimal view model when no cached definition available return { id: state.server_id, - name: state.server_name || state.server_id.split('/').pop() || state.server_id, + name: resolveInstalledDisplayName(state), description: '(Server definition not cached)', alias: null, icon: null, @@ -140,6 +183,7 @@ function createOfflineServerViewModel(state: InstalledServerState): ServerViewMo last_error: null, created_at: state.created_at, installation_source: state.source, + cloned_from: getInstalledCloneLineage(state), env_overrides: state.env_overrides ?? {}, args_append: state.args_append ?? [], extra_headers: state.extra_headers ?? {}, @@ -159,10 +203,17 @@ interface ConfigModalState { argsAppend: string[]; /** Extra HTTP headers (http only) */ extraHeaders: Record; + /** User-supplied display label (empty string = clear override). */ + displayName: string; + /** Display name when the modal opened — used to detect changes on save. */ + initialDisplayName: string; } export function ServersPage() { - const [installedServers, setInstalledServers] = useState([]); + const [installedServers, setInstalledServers] = useState([]); + const [searchQuery, setSearchQuery] = useState(''); + const [transportFilter, setTransportFilter] = useState('all'); + const [activeStatusFilters, setActiveStatusFilters] = useState>(new Set()); const [gatewayRunning, setGatewayRunning] = useState(false); const [gatewayUrl, setGatewayUrl] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -177,6 +228,8 @@ export function ServersPage() { envOverrides: {}, argsAppend: [], extraHeaders: {}, + displayName: '', + initialDisplayName: '', }); // Features state @@ -189,6 +242,15 @@ export function ServersPage() { // Definition viewer state const [definitionServer, setDefinitionServer] = useState<{ id: string; name: string } | null>(null); + + // Clone account wizard state + const [cloneModalServer, setCloneModalServer] = useState(null); + + // Uninstall source-with-clones confirmation + const [uninstallClonesDialog, setUninstallClonesDialog] = useState<{ + server: ServerViewModelWithClone; + dependents: ClonedInstalledServer[]; + } | null>(null); // Config editor state const [editConfigSpace, setEditConfigSpace] = useState<{ id: string; name: string } | null>(null); @@ -327,7 +389,7 @@ export function ServersPage() { // Merge definitions with installed states // If definitions are missing, create minimal ServerViewModels from installed states - let mergedServers: ServerViewModel[]; + let mergedServers: ServerViewModelWithClone[]; if (definitions.length > 0) { // Normal case: merge definitions with states @@ -371,6 +433,15 @@ export function ServersPage() { setInstalledServers(mergedServers); setGatewayRunning(gateway.running); setGatewayUrl(gateway.url); + + if (viewSpace?.id) { + try { + const allFeatures = await listServerFeatures(viewSpace.id); + setServerFeatures(groupFeaturesByServerId(allFeatures)); + } catch (featureError) { + console.warn('[ServersPage] Failed to load server features for search:', featureError); + } + } } catch (e) { console.error('Failed to load data:', e); } finally { @@ -486,6 +557,73 @@ export function ServersPage() { return 'connected_auto'; }; + /** Connected servers that show the expand/collapse chevron in the list. */ + const isServerExpandable = (server: ServerViewModel): boolean => { + const action = getServerAction(server); + return action === 'running' || action === 'connected_auto'; + }; + + /** Expands every connected server row and loads features for any not yet fetched. */ + const expandAllServers = () => { + const expandableIds = installedServers.filter(isServerExpandable).map((s) => s.id); + setExpandedServers(new Set(expandableIds)); + for (const serverId of expandableIds) { + if (!serverFeatures[serverId]) { + loadFeaturesForServer(serverId); + } + } + }; + + /** Collapses every expanded server row. */ + const collapseAllServers = () => { + setExpandedServers(new Set()); + }; + + const expandableServerCount = installedServers.filter(isServerExpandable).length; + const hasExpandedServers = expandedServers.size > 0; + const serverCountSummary = computeServerCountSummary(installedServers, (server) => + getServerAction(server) + ); + + /** Installed servers matching transport, status, and search filters. */ + const filteredServers = installedServers.filter((server) => + serverMatchesFilters( + server, + searchQuery, + serverFeatures[server.id] ?? [], + transportFilter, + activeStatusFilters, + getServerAction(server) as ServerActionKey + ) + ); + + /** Toggle a Beeper-style status filter chip on or off. */ + const toggleStatusFilter = (statusKey: StatusFilterKey) => { + setActiveStatusFilters((previous) => { + const next = new Set(previous); + if (next.has(statusKey)) { + next.delete(statusKey); + } else { + next.add(statusKey); + } + return next; + }); + }; + + /** Reset transport and status filters to defaults. */ + const clearAllServerFilters = () => { + setTransportFilter('all'); + setActiveStatusFilters(new Set()); + }; + + /** Expand or collapse a connected server row; loads features on first expand. */ + const handleServerRowActivate = (server: ServerViewModel) => { + if (!isServerExpandable(server)) { + return; + } + toggleExpanded(server.id); + }; + // Get display status for UI const getDisplayStatus = (server: ServerViewModel): string => { const action = getServerAction(server); @@ -531,14 +669,17 @@ export function ServersPage() { serverInputs.forEach((input: InputDefinition) => { initialValues[input.id] = server.input_values[input.id] || ''; }); + const initialDisplayName = server.name ?? ''; setConfigModal({ open: true, server, inputValues: initialValues, - enableOnSave: true, // This is from Enable flow + enableOnSave: true, envOverrides: { ...(server.env_overrides ?? {}) }, argsAppend: [...(server.args_append ?? [])], extraHeaders: { ...(server.extra_headers ?? {}) }, + displayName: initialDisplayName, + initialDisplayName, }); return; } @@ -595,39 +736,99 @@ export function ServersPage() { } }; - // Handle Configure button click (from overflow menu or pending_config state) const handleConfigureClick = (server: ServerViewModel) => { const serverInputs = server.transport.metadata?.inputs ?? []; const initialValues: Record = {}; serverInputs.forEach((input: InputDefinition) => { initialValues[input.id] = server.input_values[input.id] || ''; }); + const initialDisplayName = server.name ?? ''; setConfigModal({ open: true, server, inputValues: initialValues, - enableOnSave: false, // Just configure, don't enable + enableOnSave: false, envOverrides: { ...(server.env_overrides ?? {}) }, argsAppend: [...(server.args_append ?? [])], extraHeaders: { ...(server.extra_headers ?? {}) }, + displayName: initialDisplayName, + initialDisplayName, }); }; + /** + * Build a view model from a freshly cloned install row for the configure step. + */ + const createViewModelFromClone = (cloned: ClonedInstalledServer): ServerViewModelWithClone | null => { + if (!cloned.cached_definition) { + return null; + } + + try { + const definition: ServerDefinition = JSON.parse(cloned.cached_definition); + const inputValues = cloned.input_values ?? {}; + const inputs = definition.transport.metadata?.inputs ?? []; + const missing_required_inputs = inputs.some( + (input: InputDefinition) => input.required && !inputValues[input.id] + ); + + return { + ...definition, + name: resolveInstalledDisplayName(cloned, definition), + is_installed: true, + enabled: cloned.enabled, + oauth_connected: cloned.oauth_connected, + input_values: inputValues, + connection_status: 'disconnected', + missing_required_inputs, + last_error: null, + created_at: cloned.created_at, + installation_source: cloned.source, + cloned_from: cloned.cloned_from ?? undefined, + env_overrides: cloned.env_overrides ?? {}, + args_append: cloned.args_append ?? [], + extra_headers: cloned.extra_headers ?? {}, + }; + } catch (e) { + console.warn('[ServersPage] Failed to parse cloned server definition:', e); + return null; + } + }; + + /** + * Open the configure modal after a successful clone so the user can enter credentials. + */ + const handleCloneComplete = async (cloned: ClonedInstalledServer) => { + await loadData(); + + const clonedViewModel = createViewModelFromClone(cloned); + if (clonedViewModel) { + handleConfigureClick(clonedViewModel); + showToast(`Created ${clonedViewModel.name}`, 'success'); + return; + } + + showToast('Account created — configure it from My Servers', 'success'); + }; + const handleSaveConfig = async () => { if (!configModal.server) return; - + const server = configModal.server; const serverId = server.id; const shouldEnable = configModal.enableOnSave ?? false; - + setActionLoading(`config-${serverId}`); try { const { saveServerInputs } = await import('@/lib/api/registry'); - // Save input values with env overrides, args, and headers. - // Always send the values (even if empty) so that clearing them works. - // Backend treats None as "keep existing", so we must send Some({}/[]) - // to actually clear fields the user removed. + const trimmedDisplayName = configModal.displayName.trim(); + const trimmedInitial = configModal.initialDisplayName.trim(); + // Only send a value when the user actually edited the field; otherwise pass + // undefined so the backend leaves the existing override untouched. + const displayNameOverride = + trimmedDisplayName === trimmedInitial ? undefined : trimmedDisplayName; + await saveServerInputs( serverId, configModal.inputValues, @@ -635,9 +836,19 @@ export function ServersPage() { configModal.envOverrides, configModal.argsAppend, configModal.extraHeaders, + displayNameOverride, ); - setConfigModal({ open: false, server: null, inputValues: {}, envOverrides: {}, argsAppend: [], extraHeaders: {} }); + setConfigModal({ + open: false, + server: null, + inputValues: {}, + envOverrides: {}, + argsAppend: [], + extraHeaders: {}, + displayName: '', + initialDisplayName: '', + }); // Only enable if requested (from Enable flow) if (shouldEnable && !server.enabled) { @@ -675,7 +886,16 @@ export function ServersPage() { // Set the server to pending_config state by enabling but not connecting // Actually, we just close the modal - the UI already shows Configure button for missing inputs } - setConfigModal({ open: false, server: null, inputValues: {}, envOverrides: {}, argsAppend: [], extraHeaders: {} }); + setConfigModal({ + open: false, + server: null, + inputValues: {}, + envOverrides: {}, + argsAppend: [], + extraHeaders: {}, + displayName: '', + initialDisplayName: '', + }); }; // Cancel OAuth flow - uses new ServerManager v2 @@ -711,31 +931,103 @@ export function ServersPage() { } }; - const handleUninstall = async (server: ServerViewModel) => { + const performUninstall = async (serverIds: string[]) => { + const { uninstallServer } = await import('@/lib/api/registry'); + const { disconnectServer } = await import('@/lib/api/gateway'); + + if (gatewayRunning && viewSpace) { + for (const serverId of serverIds) { + const target = installedServers.find((entry) => entry.id === serverId); + if (!target?.enabled) { + continue; + } + + try { + await disconnectServer(serverId, viewSpace.id); + } catch (error) { + console.warn(`[ServersPage] Failed to disconnect server from gateway:`, error); + } + } + } + + for (const serverId of serverIds) { + await uninstallServer(serverId, viewSpace?.id ?? ''); + } + + await loadData(); + }; + + const handleUninstall = async (server: ServerViewModelWithClone) => { + if (!viewSpace) { + return; + } + + if (!server.cloned_from) { + try { + const dependents = await listCloneDependents(viewSpace.id, server.id); + if (dependents.length > 0) { + setUninstallClonesDialog({ server, dependents }); + return; + } + } catch (error) { + showToast(String(error), 'error'); + return; + } + } + const { getUninstallLabel } = await import('@/components/SourceBadge'); const actionLabel = getUninstallLabel(server.installation_source); setActionLoading(`uninstall-${server.id}`); try { - const { uninstallServer } = await import('@/lib/api/registry'); - const { disconnectServer } = await import('@/lib/api/gateway'); - - if (gatewayRunning && server.enabled && viewSpace) { - try { - await disconnectServer(server.id, viewSpace.id); - } catch (e) { - console.warn(`[ServersPage] Failed to disconnect server from gateway:`, e); - } - } - - // ServerAppService handles source-aware cleanup automatically: - // - UserConfig: removes from JSON file + DB - // - Registry/ManualEntry: just removes from DB - await uninstallServer(server.id, viewSpace?.id ?? ''); - await loadData(); + await performUninstall([server.id]); showToast(`${server.name} ${actionLabel.toLowerCase()}ed`, 'success'); - } catch (e) { - showToast(String(e), 'error'); + } catch (error) { + showToast(String(error), 'error'); + } finally { + setActionLoading(null); + } + }; + + const handleUninstallSourceOnly = async () => { + if (!uninstallClonesDialog) { + return; + } + + const { server } = uninstallClonesDialog; + const { getUninstallLabel } = await import('@/components/SourceBadge'); + const actionLabel = getUninstallLabel(server.installation_source); + + setUninstallClonesDialog(null); + setActionLoading(`uninstall-${server.id}`); + try { + await performUninstall([server.id]); + showToast(`${server.name} ${actionLabel.toLowerCase()}ed`, 'success'); + } catch (error) { + showToast(String(error), 'error'); + } finally { + setActionLoading(null); + } + }; + + const handleUninstallAllWithClones = async () => { + if (!uninstallClonesDialog) { + return; + } + + const { server, dependents } = uninstallClonesDialog; + const serverIds = [...dependents.map((dependent) => dependent.server_id), server.id]; + + setUninstallClonesDialog(null); + setActionLoading(`uninstall-${server.id}`); + try { + await performUninstall(serverIds); + showToast( + `${server.name} and ${dependents.length} clone${dependents.length === 1 ? '' : 's'} uninstalled`, + 'success' + ); + } catch (error) { + showToast(String(error), 'error'); } finally { setActionLoading(null); } @@ -844,56 +1136,126 @@ export function ServersPage() { } return ( -
+
{gatewayControl.ConfirmDialogElement} - {/* Header */} -
-
-

My Servers

-

- Manage your installed MCP servers -

-
- {viewSpace && ( - - )} -
- - {/* Gateway Status */} + {uninstallClonesDialog && ( + setUninstallClonesDialog(null)} + onUninstallSourceOnly={handleUninstallSourceOnly} + onUninstallAll={handleUninstallAllWithClones} + /> + )} + {/* Toolbar — stays visible while the server list scrolls in
*/}
-
-
- - +
+
+
+

+ My Servers +

+ +
+

+ Manage your installed MCP servers +

+
+ +
+ + {gatewayRunning ? 'Gateway Running' : 'Gateway Stopped'} - {gatewayRunning && ( - + {gatewayRunning && gatewayUrl && ( + {gatewayUrl} )} + {!gatewayRunning && viewSpace && ( + + )}
- {!gatewayRunning && ( - + + {viewSpace && ( +
+ {installedServers.length > 0 && ( + <> + + + + )} + navigateTo('registry')} + onCustom={() => setEditConfigSpace({ id: viewSpace.id, name: viewSpace.name })} + /> +
)}
+ + {viewSpace && installedServers.length > 0 && ( +
+ setSearchQuery(e.target.value)} + onClear={() => setSearchQuery('')} + data-testid="servers-search" + /> + setActiveStatusFilters(new Set())} + onClearAllFilters={clearAllServerFilters} + /> +
+ )}
{/* Server List */} @@ -901,18 +1263,27 @@ export function ServersPage() {
📦

No servers installed

- +

+ Add from the community registry or define a custom server in your Space config. +

+ {viewSpace && ( +
+ navigateTo('registry')} + onCustom={() => setEditConfigSpace({ id: viewSpace.id, name: viewSpace.name })} + /> +
+ )} +
+ ) : filteredServers.length === 0 ? ( +
+ +

No servers match your filters

+

Try adjusting your search or filters

) : (
- {installedServers.map((server) => { + {filteredServers.map((server) => { const serverAction = getServerAction(server); const displayStatus = getDisplayStatus(server); const enableLoading = actionLoading === `enable-${server.id}`; @@ -936,21 +1307,33 @@ export function ServersPage() { > {/* Server Header */}
-
-
- {/* Expand/Collapse button for connected servers */} - {isConnected && ( - + )}
@@ -1028,7 +1411,10 @@ export function ServersPage() { {server.transport.type} {/* Installation Source Badge */} - +
{/* Show runtime message inline (from ServerManager events) */} @@ -1047,7 +1433,11 @@ export function ServersPage() { Connection error ·
{/* Actions - horizontal row with primary and secondary actions */} -
- {/* Primary action button */} - {serverAction === 'enable' && ( - +
event.stopPropagation()} + > + {(serverAction === 'enable' || + (server.enabled && + (serverAction === 'running' || + serverAction === 'connected_auto' || + serverAction === 'error'))) && ( + { + if (checked) { + handleEnableClick(server); + } else { + handleDisableClick(server); + } + }} + /> )} {serverAction === 'configure' && ( @@ -1144,18 +1547,6 @@ export function ServersPage() { )} - {/* Disable button - shown when enabled and connected/running */} - {server.enabled && (serverAction === 'running' || serverAction === 'connected_auto') && ( - - )} - {/* Overflow menu with secondary actions */} handleConfigureClick(server)} onRefresh={() => handleRefresh(server)} onReconnect={() => handleReconnect(server)} onViewLogs={() => setLogViewerServer({ id: server.id, name: server.name })} onViewDefinition={() => setDefinitionServer({ id: server.id, name: server.name })} + onCloneAccount={() => setCloneModalServer(server)} onUninstall={() => handleUninstall(server)} />
@@ -1291,6 +1684,17 @@ export function ServersPage() {
)} + {/* Clone Account Modal */} + {cloneModalServer && viewSpace && ( + setCloneModalServer(null)} + onCloned={handleCloneComplete} + /> + )} + {/* Configuration Modal */} {configModal.open && configModal.server && (
@@ -1301,8 +1705,31 @@ export function ServersPage() {

{(configModal.server.auth && 'instructions' in configModal.server.auth ? configModal.server.auth.instructions : null) || 'Enter the required configuration to enable this server.'}

- +
+
+ +

+ Shown in My Servers only. Does not change the server ID or tool names. +

+ + setConfigModal({ ...configModal, displayName: e.target.value }) + } + placeholder={configModal.server.name} + className="input w-full" + data-testid="config-display-name" + /> +
+ {(configModal.server.transport.metadata?.inputs ?? []).map((input: InputDefinition) => { const obtainUrl = input.obtain_url || input.obtain?.url; const obtainInstructions = input.obtain_instructions || input.obtain?.instructions; diff --git a/apps/desktop/src/features/servers/UninstallSourceWithClonesDialog.tsx b/apps/desktop/src/features/servers/UninstallSourceWithClonesDialog.tsx new file mode 100644 index 00000000..0ac1c14b --- /dev/null +++ b/apps/desktop/src/features/servers/UninstallSourceWithClonesDialog.tsx @@ -0,0 +1,98 @@ +import { AlertCircle } from 'lucide-react'; +import { resolveInstalledDisplayName } from './server-display-name.helpers'; + +export interface CloneDependentSummary { + server_id: string; + server_name?: string | null; + display_name_override?: string | null; +} + +interface UninstallSourceWithClonesDialogProps { + open: boolean; + sourceName: string; + dependents: CloneDependentSummary[]; + onCancel: () => void; + onUninstallSourceOnly: () => void; + onUninstallAll: () => void; +} + +/** + * Warn when uninstalling a source server that still has account clones in the same space. + */ +export function UninstallSourceWithClonesDialog({ + open, + sourceName, + dependents, + onCancel, + onUninstallSourceOnly, + onUninstallAll, +}: UninstallSourceWithClonesDialogProps) { + if (!open) { + return null; + } + + const dependentLabels = dependents.map((dependent) => + resolveInstalledDisplayName({ + server_id: dependent.server_id, + server_name: dependent.server_name ?? null, + display_name_override: dependent.display_name_override ?? null, + }) + ); + const dependentList = dependentLabels.join(', '); + const totalCount = dependents.length + 1; + + return ( +
+
event.stopPropagation()} + data-testid="uninstall-clones-dialog" + > +
+
+ +
+
+

Uninstall server with account clones?

+

+ {sourceName} has{' '} + {dependents.length} account clone{dependents.length === 1 ? '' : 's'} in this space:{' '} + {dependentList}. +

+

+ Uninstalling the source leaves clones installed and working. You can also remove + everything at once. +

+
+
+
+ + + +
+
+
+ ); +} diff --git a/apps/desktop/src/features/servers/server-display-name.helpers.ts b/apps/desktop/src/features/servers/server-display-name.helpers.ts new file mode 100644 index 00000000..7a3eadf9 --- /dev/null +++ b/apps/desktop/src/features/servers/server-display-name.helpers.ts @@ -0,0 +1,32 @@ +import type { InstalledServerState, ServerDefinition } from '@/types/registry'; + +/** + * Resolve the effective display label for an installed server. + * + * Mirrors the Rust `InstalledServer::display_name()` precedence so the UI and + * meta-tools agree on what to show. Order: + * 1. `display_name_override` (user-supplied, survives user-config sync) + * 2. `server_name` cached from the definition at install time + * 3. `definition.name` if a parsed registry definition is provided + * 4. Final segment of `server_id` + */ +export function resolveInstalledDisplayName( + state: Pick, + definition?: Pick | null +): string { + const override = state.display_name_override?.trim(); + if (override) { + return override; + } + + if (state.server_name && state.server_name.length > 0) { + return state.server_name; + } + + if (definition?.name) { + return definition.name; + } + + const tail = state.server_id.split('/').pop(); + return tail && tail.length > 0 ? tail : state.server_id; +} diff --git a/apps/desktop/src/features/servers/servers-page.helpers.ts b/apps/desktop/src/features/servers/servers-page.helpers.ts new file mode 100644 index 00000000..6976c450 --- /dev/null +++ b/apps/desktop/src/features/servers/servers-page.helpers.ts @@ -0,0 +1,236 @@ +import type { ServerFeature } from '@/lib/api/serverFeatures'; +import type { ServerViewModel } from '../../types/registry'; + +/** Runtime action used to derive status filter buckets. */ +export type ServerActionKey = + | 'enable' + | 'configure' + | 'connecting' + | 'authenticating' + | 'auth_required' + | 'running' + | 'error' + | 'connected_auto'; + +/** Transport filter for installed servers. */ +export type TransportFilter = 'all' | 'stdio' | 'http'; + +/** Status bucket for Beeper-style multi-select filters. */ +export type StatusFilterKey = 'connected' | 'disabled' | 'error' | 'needs_setup'; + +export const TRANSPORT_FILTERS: { id: TransportFilter; label: string }[] = [ + { id: 'all', label: 'All' }, + { id: 'stdio', label: 'stdio' }, + { id: 'http', label: 'http' }, +]; + +export const STATUS_FILTERS: { id: StatusFilterKey; label: string }[] = [ + { id: 'connected', label: 'Connected' }, + { id: 'disabled', label: 'Disabled' }, + { id: 'error', label: 'Error' }, + { id: 'needs_setup', label: 'Needs setup' }, +]; + +/** Group discovered features by installed server id. */ +export function groupFeaturesByServerId(features: ServerFeature[]): Record { + return features.reduce>((acc, feature) => { + const bucket = acc[feature.server_id] ?? []; + bucket.push(feature); + acc[feature.server_id] = bucket; + return acc; + }, {}); +} + +/** + * Map a server action to the status filter bucket it belongs in. + */ +export function statusKeyFromAction(action: ServerActionKey): StatusFilterKey { + switch (action) { + case 'running': + case 'connected_auto': + return 'connected'; + case 'enable': + return 'disabled'; + case 'error': + return 'error'; + default: + return 'needs_setup'; + } +} + +/** Whether a server matches the selected transport filter. */ +export function matchesTransport(server: ServerViewModel, transportFilter: TransportFilter): boolean { + if (transportFilter === 'all') { + return true; + } + + return server.transport.type === transportFilter; +} + +/** + * Whether a server matches active status toggles. + * Empty set means show all (Beeper-style: no status filter applied). + */ +export function matchesStatus( + action: ServerActionKey, + activeStatusFilters: ReadonlySet +): boolean { + if (activeStatusFilters.size === 0) { + return true; + } + + return activeStatusFilters.has(statusKeyFromAction(action)); +} + +/** Whether a feature name or description matches the search query. */ +function featureMatchesQuery(feature: ServerFeature, query: string): boolean { + return ( + feature.feature_name.toLowerCase().includes(query) || + (feature.display_name?.toLowerCase().includes(query) ?? false) || + (feature.description?.toLowerCase().includes(query) ?? false) + ); +} + +/** + * Whether an installed server matches transport, status, and search filters. + */ +export function serverMatchesFilters( + server: ServerViewModel, + searchQuery: string, + features: ServerFeature[], + transportFilter: TransportFilter, + activeStatusFilters: ReadonlySet, + serverAction: ServerActionKey +): boolean { + if (!matchesTransport(server, transportFilter)) { + return false; + } + + if (!matchesStatus(serverAction, activeStatusFilters)) { + return false; + } + + const query = searchQuery.trim().toLowerCase(); + if (!query) { + return true; + } + + const metadataMatch = + server.name.toLowerCase().includes(query) || + server.id.toLowerCase().includes(query) || + (server.description?.toLowerCase().includes(query) ?? false); + + if (metadataMatch) { + return true; + } + + return features.some((feature) => featureMatchesQuery(feature, query)); +} + +/** + * Count non-default transport and status filters for the Filters button badge. + */ +export function countActiveServerFilters( + transportFilter: TransportFilter, + activeStatusFilters: ReadonlySet +): number { + let count = activeStatusFilters.size; + if (transportFilter !== 'all') { + count += 1; + } + return count; +} + +/** + * Human-readable lines describing the currently applied server list filters. + */ +/** Per-status counts for the My Servers header summary. */ +export type ServerCountSummary = { + installed: number; + connected: number; + disabled: number; + error: number; + needsSetup: number; +}; + +/** + * Aggregate installed-server counts by status bucket (same buckets as status filters). + */ +export function computeServerCountSummary( + servers: ServerViewModel[], + getAction: (server: ServerViewModel) => ServerActionKey +): ServerCountSummary { + const summary: ServerCountSummary = { + installed: servers.length, + connected: 0, + disabled: 0, + error: 0, + needsSetup: 0, + }; + + for (const server of servers) { + switch (statusKeyFromAction(getAction(server))) { + case 'connected': + summary.connected += 1; + break; + case 'disabled': + summary.disabled += 1; + break; + case 'error': + summary.error += 1; + break; + case 'needs_setup': + summary.needsSetup += 1; + break; + } + } + + return summary; +} + +/** Compact inline summary next to the My Servers title. */ +export function formatServerCountSummary(summary: ServerCountSummary): string { + return [ + `${summary.installed} installed`, + `${summary.connected} connected`, + `${summary.disabled} disabled`, + `${summary.error} error`, + ].join(', '); +} + +/** Tooltip lines for the server count hover panel. */ +export function describeServerCountSummary(summary: ServerCountSummary): string[] { + const lines = [ + `${summary.installed} installed`, + `${summary.connected} connected`, + `${summary.disabled} disabled`, + `${summary.error} error`, + ]; + + if (summary.needsSetup > 0) { + lines.push(`${summary.needsSetup} needs setup`); + } + + return lines; +} + +export function describeAppliedServerFilters( + transportFilter: TransportFilter, + activeStatusFilters: ReadonlySet +): string[] { + const transportLabel = + TRANSPORT_FILTERS.find((filter) => filter.id === transportFilter)?.label ?? transportFilter; + + const statusLabel = + activeStatusFilters.size === 0 + ? 'All' + : STATUS_FILTERS.filter((filter) => activeStatusFilters.has(filter.id)) + .map((filter) => filter.label) + .join(', '); + + if (countActiveServerFilters(transportFilter, activeStatusFilters) === 0) { + return ['No filters applied', 'Showing all servers']; + } + + return [`Transport: ${transportLabel}`, `Status: ${statusLabel}`]; +} diff --git a/apps/desktop/src/features/settings/SettingsPage.tsx b/apps/desktop/src/features/settings/SettingsPage.tsx index caccec21..89b48e56 100644 --- a/apps/desktop/src/features/settings/SettingsPage.tsx +++ b/apps/desktop/src/features/settings/SettingsPage.tsx @@ -36,6 +36,10 @@ import { import { useAppStore, useTheme, useAnalyticsEnabled } from '@/stores'; import { UpdateChecker } from './UpdateChecker'; import { getMetaToolsEnabled, setMetaToolsEnabled } from '@/lib/api/metaTools'; +import { + getSessionOverridesRequireApproval, + setSessionOverridesRequireApproval, +} from '@/lib/api/sessionOverrides'; import { MetaToolAuditLog, MetaToolGrantsPanel } from '@/features/metaTools'; import { useGatewayControl } from '@/features/gateway/useGatewayControl'; import { CONTRIBUTE, openExternal } from '@/lib/contribute'; @@ -78,6 +82,10 @@ export function SettingsPage() { // Meta-tools master switch — gates the entire `mcpmux_*` namespace. const [metaToolsEnabled, setMetaToolsEnabledState] = useState(true); const [loadingMetaTools, setLoadingMetaTools] = useState(true); + const [sessionOverridesRequireApproval, setSessionOverridesRequireApprovalState] = + useState(false); + const [loadingSessionOverrideApproval, setLoadingSessionOverrideApproval] = + useState(true); // Gateway port — persisted user override, the default the app ships // with, and the port the currently-running gateway is bound to. When @@ -181,6 +189,12 @@ export function SettingsPage() { .then((v) => setMetaToolsEnabledState(v)) .catch((e) => console.error('Failed to load meta_tools_enabled', e)) .finally(() => setLoadingMetaTools(false)); + getSessionOverridesRequireApproval() + .then((v) => setSessionOverridesRequireApprovalState(v)) + .catch((e) => + console.error('Failed to load session_overrides_require_approval', e) + ) + .finally(() => setLoadingSessionOverrideApproval(false)); }, []); const handleToggleMetaTools = async (next: boolean) => { @@ -200,6 +214,23 @@ export function SettingsPage() { } }; + const handleToggleSessionOverrideApproval = async (next: boolean) => { + const previous = sessionOverridesRequireApproval; + setSessionOverridesRequireApprovalState(next); + try { + await setSessionOverridesRequireApproval(next); + success( + next ? 'Session overrides require approval' : 'Session overrides auto-allowed', + next + ? 'mcpmux_enable_server / mcpmux_disable_server (session scope) will prompt before applying.' + : 'Session-scope enable/disable applies immediately without a dialog.' + ); + } catch (e) { + setSessionOverridesRequireApprovalState(previous); + error('Failed to save setting', e instanceof Error ? e.message : String(e)); + } + }; + // Load logs path on mount useEffect(() => { const loadLogsPath = async () => { @@ -607,9 +638,10 @@ export function SettingsPage() { 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. + When enabled, connected MCP clients see a fixed meta-tool surface (~12 tools) + for search → schema → invoke workflows. FeatureSets control what is invokable; + optional surfaced tools can appear directly in tools/list. Writes always trigger + a native approval dialog; reads are silent. @@ -619,9 +651,9 @@ export function SettingsPage() {

- Shows mcpmux_list_all_tools,  - mcpmux_pin_this_session, and 6 others to - every connected MCP client. Turn off to hide the whole namespace. + Shows mcpmux_search_tools,  + mcpmux_invoke_tool, and other meta tools + to every connected MCP client. Turn off to hide the whole namespace.

@@ -632,6 +664,29 @@ export function SettingsPage() { data-testid="meta-tools-enabled-switch" />
+
+
+ +
+ +

+ When on,{' '} + mcpmux_enable_server /{' '} + mcpmux_disable_server with{' '} + scope: "session" show the native + approval dialog. Workspace-scope writes always require approval. +

+
+
+ +
diff --git a/apps/desktop/src/features/spaces/SpacePanel.tsx b/apps/desktop/src/features/spaces/SpacePanel.tsx new file mode 100644 index 00000000..eedd345d --- /dev/null +++ b/apps/desktop/src/features/spaces/SpacePanel.tsx @@ -0,0 +1,227 @@ +import { useEffect, useState } from 'react'; +import { Loader2, Save, Trash2, X } from 'lucide-react'; +import { Button, useConfirm, useToast, ToastContainer } from '@mcpmux/ui'; +import type { Space } from '@/lib/api/spaces'; +import { deleteSpace, updateSpace } from '@/lib/api/spaces'; + +const SPACE_ICON_OPTIONS = ['🌐', '💻', '🚀', '🏢', '🏠', '🔒', '🧪', '📦'] as const; + +export interface SpacePanelProps { + space: Space; + onClose: () => void; + onSaved: (space: Space) => void; + onDeleted: (id: string) => void; +} + +/** + * Slide-out panel for editing a Space's display metadata (name, icon, description). + */ +export function SpacePanel({ space, onClose, onSaved, onDeleted }: SpacePanelProps) { + const [name, setName] = useState(space.name); + const [icon, setIcon] = useState(space.icon ?? '🌐'); + const [description, setDescription] = useState(space.description ?? ''); + const [isSaving, setIsSaving] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [error, setError] = useState(null); + const { toasts, success, error: showError, dismiss } = useToast(); + const { confirm, ConfirmDialogElement } = useConfirm(); + + useEffect(() => { + setName(space.name); + setIcon(space.icon ?? '🌐'); + setDescription(space.description ?? ''); + setError(null); + }, [space]); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose]); + + /** + * Persist name, icon, and description to the backend and notify the parent. + */ + const handleSave = async () => { + const trimmedName = name.trim(); + if (!trimmedName) { + setError('Name is required.'); + return; + } + + setIsSaving(true); + setError(null); + try { + const updated = await updateSpace(space.id, { + name: trimmedName, + icon: icon.trim() || undefined, + description: description.trim() || undefined, + }); + success('Space updated', `"${updated.name}" has been saved`); + onSaved(updated); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + setError(msg); + showError('Failed to save space', msg); + } finally { + setIsSaving(false); + } + }; + + /** + * Delete this Space after confirmation (default Space cannot be deleted). + */ + const handleDelete = async () => { + const ok = await confirm({ + title: 'Delete workspace', + message: `Are you sure you want to delete "${space.name}"? This action cannot be undone.`, + confirmLabel: 'Delete', + variant: 'danger', + }); + if (!ok) return; + + setIsDeleting(true); + try { + await deleteSpace(space.id); + success('Space deleted', `"${space.name}" has been deleted`); + onDeleted(space.id); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + showError('Failed to delete space', msg); + } finally { + setIsDeleting(false); + } + }; + + const hasChanges = + name.trim() !== space.name || + (icon.trim() || '🌐') !== (space.icon ?? '🌐') || + description.trim() !== (space.description ?? ''); + + return ( +
+ + {ConfirmDialogElement} + +
+
+
+
+ {icon} +
+
+

{space.name}

+ {space.is_default && ( + + Default + + )} +
+
+ +
+
+ +
+ {error && ( +

+ {error} +

+ )} + +
+ +
+ {SPACE_ICON_OPTIONS.map((emoji) => ( + + ))} +
+
+ +
+ + setName(e.target.value)} + className="w-full px-3 py-2 rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--background))] focus:outline-none focus:ring-2 focus:ring-primary-500" + data-testid="space-panel-name" + /> +
+ +
+ + setDescription(e.target.value)} + placeholder="Optional description for this workspace" + className="w-full px-3 py-2 rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--background))] focus:outline-none focus:ring-2 focus:ring-primary-500" + data-testid="space-panel-description" + /> +
+
+ +
+ + {!space.is_default && ( + + )} +
+
+ ); +} diff --git a/apps/desktop/src/features/spaces/SpacesPage.tsx b/apps/desktop/src/features/spaces/SpacesPage.tsx index 3521614f..ed4bd1ee 100644 --- a/apps/desktop/src/features/spaces/SpacesPage.tsx +++ b/apps/desktop/src/features/spaces/SpacesPage.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { Plus, Trash2, Loader2, Search, Layout, AlertCircle } from 'lucide-react'; +import { Plus, Loader2, Search, Layout, AlertCircle, Pencil } from 'lucide-react'; import { Card, CardHeader, @@ -11,7 +11,8 @@ import { useConfirm, } from '@mcpmux/ui'; import { useAppStore, useSpaces, useIsLoading } from '@/stores'; -import { createSpace, deleteSpace } from '@/lib/api/spaces'; +import { createSpace } from '@/lib/api/spaces'; +import { SpacePanel } from './SpacePanel'; export function SpacesPage() { const spaces = useSpaces(); @@ -20,12 +21,12 @@ export function SpacesPage() { // Store actions const addSpace = useAppStore((state) => state.addSpace); const removeSpace = useAppStore((state) => state.removeSpace); + const updateSpaceInStore = useAppStore((state) => state.updateSpace); // Local state const [searchQuery, setSearchQuery] = useState(''); const [error, setError] = useState(null); - const [isActionLoading, setIsActionLoading] = useState(null); // ID of space being acted on - const { confirm, ConfirmDialogElement } = useConfirm(); + const { ConfirmDialogElement } = useConfirm(); const { toasts, success, error: showError, dismiss } = useToast(); // Create Modal State @@ -33,6 +34,7 @@ export function SpacesPage() { const [newSpaceName, setNewSpaceName] = useState(''); const [newSpaceIcon, setNewSpaceIcon] = useState('🌐'); const [isCreating, setIsCreating] = useState(false); + const [selectedSpaceId, setSelectedSpaceId] = useState(null); const handleCreate = async () => { if (!newSpaceName.trim()) return; @@ -55,30 +57,9 @@ export function SpacesPage() { } }; - const handleDelete = async (id: string) => { - const spaceName = spaces.find(s => s.id === id)?.name || 'this space'; - if (!await confirm({ - title: 'Delete workspace', - message: `Are you sure you want to delete "${spaceName}"? This action cannot be undone.`, - confirmLabel: 'Delete', - variant: 'danger', - })) return; - - setIsActionLoading(id); - setError(null); - try { - const deletedSpace = spaces.find(s => s.id === id); - await deleteSpace(id); - removeSpace(id); - success('Space deleted', `"${deletedSpace?.name || 'Space'}" has been deleted`); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - setError(msg); - showError('Failed to delete space', msg); - } finally { - setIsActionLoading(null); - } - }; + const selectedSpace = selectedSpaceId + ? spaces.find((s) => s.id === selectedSpaceId) ?? null + : null; // Filter spaces const filteredSpaces = spaces.filter(space => { @@ -166,16 +147,19 @@ export function SpacesPage() { ) : (
{filteredSpaces.map((space) => { - const isProcessing = isActionLoading === space.id; + const isSelected = selectedSpaceId === space.id; return ( setSelectedSpaceId(space.id)} data-testid={`space-card-${space.id}`} > -
+
{space.icon || '🌐'}
@@ -185,7 +169,7 @@ export function SpacesPage() { {space.description || 'No description'}

-
+
{space.is_default && ( )} - {!space.is_default && ( - - )} + + +
@@ -216,6 +195,27 @@ export function SpacesPage() {
+ {selectedSpace && ( + <> +
setSelectedSpaceId(null)} + /> + setSelectedSpaceId(null)} + onSaved={(updated) => { + updateSpaceInStore(updated.id, updated); + setSelectedSpaceId(updated.id); + }} + onDeleted={(id) => { + removeSpace(id); + setSelectedSpaceId(null); + }} + /> + + )} + {/* Create Modal */} {showCreateModal && (
diff --git a/apps/desktop/src/features/workspaces/WorkspacesPage.tsx b/apps/desktop/src/features/workspaces/WorkspacesPage.tsx index 18b66666..f99d64e5 100644 --- a/apps/desktop/src/features/workspaces/WorkspacesPage.tsx +++ b/apps/desktop/src/features/workspaces/WorkspacesPage.tsx @@ -19,6 +19,7 @@ import { Search, Server as ServerIcon, Trash2, + ToggleLeft, Wrench, X, } from 'lucide-react'; @@ -43,6 +44,12 @@ import { type WorkspaceBindingInput, type WorkspaceEffectiveFeatures, } from '@/lib/api/workspaceBindings'; +import { + clearSessionOverrides, + listSessionOverrides, + overridesForWorkspace, + type SessionOverride, +} from '@/lib/api/sessionOverrides'; import { isStarterFeatureSet, listFeatureSets, @@ -227,8 +234,10 @@ export function WorkspacesPage() { .map((id) => fsById.get(id)?.name ?? '') .join(' ') : ''; + const label = e.binding?.label?.toLowerCase() ?? ''; return ( e.root.toLowerCase().includes(q) || + label.includes(q) || spaceName.toLowerCase().includes(q) || fsNames.toLowerCase().includes(q) ); @@ -462,11 +471,31 @@ function formatFsList(names: string[]): string { * 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 normalizeLabel(label: string | null | undefined): string | null { + const trimmed = label?.trim() ?? ''; + return trimmed.length > 0 ? trimmed : null; +} + +/** + * Primary title for a workspace entry — label when set, otherwise the path. + */ +function entryDisplayTitle(entry: Entry): string { + const label = entry.binding?.label?.trim(); + if (label) return label; + return entry.root; +} + function sameBindingInput( a: WorkspaceBindingInput, - b: { workspace_root: string; space_id: string; feature_set_ids: string[] } + b: { + workspace_root: string; + label?: string | null; + space_id: string; + feature_set_ids: string[]; + } ): boolean { if (a.workspace_root.trim() !== b.workspace_root.trim()) return false; + if (normalizeLabel(a.label) !== normalizeLabel(b.label)) 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]); @@ -577,11 +606,18 @@ function EntryCard({ {entry.kind === 'mapped-live' && Live}

- {entry.root} + {entryDisplayTitle(entry)}

+ {entry.binding?.label?.trim() && ( +

+ {entry.root} +

+ )}
@@ -826,9 +862,12 @@ function InspectorPanel({ ? 'edit' : 'create-from-live'; const title = isNew ? 'New binding' : isMapped ? 'Binding' : 'Configure workspace'; + const displayTitle = entry ? entryDisplayTitle(entry) : ''; const subtitle = isNew ? 'Tell mcpmux how a folder should route.' - : entry?.root ?? ''; + : displayTitle !== entry?.root + ? entry?.root ?? '' + : displayTitle; // Auto-save status drives the small pill in the Mapping section header. const [saveStatus, setSaveStatus] = useState({ kind: 'idle' }); @@ -851,13 +890,20 @@ function InspectorPanel({ {!isNew && entry && !isMapped && Unmapped} {!isNew && entry && isMapped && !entry.isLive && Offline}
-

{title}

-

- {subtitle} -

+

+ {!isNew && entry ? displayTitle : title} +

+ {!isNew && entry && displayTitle !== entry.root && ( +

+ {entry.root} +

+ )} + {isNew && ( +

{subtitle}

+ )}
{entry?.binding && ( @@ -980,6 +1039,177 @@ function SaveStatusPill({ status }: { status: SaveStatus }) { ); } +// --------------------------------------------------------------------------- +// Session overrides — per-session enable/disable from meta tools +// --------------------------------------------------------------------------- + +/** + * Lists live sessions reporting this workspace root and any session-scoped + * server overrides applied via `mcpmux_enable_server` / `mcpmux_disable_server`. + */ +function SessionOverridesContent({ workspaceRoot }: { workspaceRoot: string }) { + const [entries, setEntries] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [clearingId, setClearingId] = useState(null); + + const reload = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const all = await listSessionOverrides(); + setEntries(overridesForWorkspace(all, workspaceRoot)); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + setEntries([]); + } finally { + setIsLoading(false); + } + }, [workspaceRoot]); + + useEffect(() => { + void reload(); + }, [reload]); + + useEffect(() => { + const unMeta = listen<{ tool_name?: string }>('meta-tool-invoked', (ev) => { + const name = ev.payload?.tool_name ?? ''; + if (name === 'mcpmux_enable_server' || name === 'mcpmux_disable_server') { + void reload(); + } + }); + const unOverrides = listen('session-overrides-changed', () => { + void reload(); + }); + return () => { + void unMeta.then((fn) => fn()); + void unOverrides.then((fn) => fn()); + }; + }, [reload]); + + const handleClear = async (sessionId: string) => { + setClearingId(sessionId); + try { + await clearSessionOverrides(sessionId); + await reload(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setClearingId(null); + } + }; + + if (isLoading) { + return ( +
+ + Loading session overrides… +
+ ); + } + + if (error) { + return ( +

+ {error} +

+ ); + } + + if (entries.length === 0) { + return ( +

+ No live sessions on this folder have session-scoped server overrides. +

+ ); + } + + return ( +
+ {entries.map((entry) => { + const shortId = + entry.session_id.length > 12 + ? `${entry.session_id.slice(0, 8)}…${entry.session_id.slice(-4)}` + : entry.session_id; + const hasOverrides = entry.enabled.length > 0 || entry.disabled.length > 0; + return ( +
+
+
+

+ {shortId} +

+ {entry.roots.length > 0 && ( +

+ {entry.roots.join(', ')} +

+ )} +
+ {hasOverrides && ( + + )} +
+ {entry.enabled.length > 0 && ( +
+

+ Enabled (session) +

+
+ {entry.enabled.map((id) => ( + + {id} + + ))} +
+
+ )} + {entry.disabled.length > 0 && ( +
+

+ Disabled (session) +

+
+ {entry.disabled.map((id) => ( + + {id} + + ))} +
+
+ )} +
+ ); + })} +
+ ); +} + // --------------------------------------------------------------------------- // Effective features — what tools / prompts / resources this folder sees // right now, grouped by backend server so the user can see at a glance @@ -1517,6 +1747,7 @@ function BindingForm({ const rootRef = useRef(null); const [root, setRoot] = useState(initial?.workspace_root ?? prefillRoot ?? ''); + const [label, setLabel] = useState(initial?.label ?? ''); const [spaceId, setSpaceId] = useState(initial?.space_id ?? defaultSpaceId); // Multi-FS: a binding may resolve to N FeatureSets (the resolver merges // their members into one allow set). Order is preserved so the operator @@ -1645,6 +1876,7 @@ function BindingForm({ try { await onSubmit({ workspace_root: root.trim(), + label: label.trim() || null, space_id: spaceId, feature_set_ids: fsIds, }); @@ -1696,6 +1928,7 @@ function BindingForm({ const candidate: WorkspaceBindingInput = { workspace_root: root.trim(), + label: label.trim() || null, space_id: spaceId, feature_set_ids: fsIds, }; @@ -1704,6 +1937,7 @@ function BindingForm({ // otherwise the initial payload from when the panel opened. const baseline = lastSavedRef.current ?? { workspace_root: initial.workspace_root, + label: initial.label, space_id: initial.space_id, feature_set_ids: initial.feature_set_ids, }; @@ -1743,6 +1977,7 @@ function BindingForm({ isEdit, initial, root, + label, spaceId, fsIds, canSubmit, @@ -1786,6 +2021,20 @@ function BindingForm({ return (
+ + setLabel(e.target.value)} + placeholder="e.g., Frontend project" + className="w-full px-3 py-2 rounded-lg text-sm bg-[rgb(var(--background))] border border-[rgb(var(--border))] focus:outline-none focus:ring-2 focus:ring-primary-500" + data-testid="workspace-binding-label-input" + /> + +
{ @@ -25,7 +31,7 @@ export async function isRegistryOffline(): Promise { return invoke('is_registry_offline'); } -/** Force refresh server discovery from all sources (ignores cache) +/** Force refresh server discovery from all sources (ignores cache) * Returns number of newly auto-installed user-configured servers */ export async function refreshRegistry(): Promise { return invoke('refresh_registry'); @@ -87,7 +93,30 @@ export async function saveServerInputs( spaceId: string, envOverrides?: Record, argsAppend?: string[], - extraHeaders?: Record + extraHeaders?: Record, + displayNameOverride?: string ): Promise { - return invoke('save_server_inputs', { id, inputValues, spaceId, envOverrides, argsAppend, extraHeaders }); + return invoke('save_server_inputs', { + id, + inputValues, + spaceId, + envOverrides, + argsAppend, + extraHeaders, + displayNameOverride, + }); +} + +/** + * Set or clear the user-supplied display label for an installed server. + * + * Pass an empty string to clear the override; the UI then falls back to the cached + * definition name. Does not change `server_id`, alias, or tool prefixes. + */ +export async function setServerDisplayName( + id: string, + spaceId: string, + displayName: string +): Promise { + return invoke('set_server_display_name', { id, spaceId, displayName }); } diff --git a/apps/desktop/src/lib/api/serverClone.ts b/apps/desktop/src/lib/api/serverClone.ts new file mode 100644 index 00000000..a32cc06e --- /dev/null +++ b/apps/desktop/src/lib/api/serverClone.ts @@ -0,0 +1,104 @@ +/** + * Server clone API — Tauri wrappers for multi-account cloning. + */ + +import { invoke } from '@tauri-apps/api/core'; +import type { InstalledServerState } from '@/types/registry'; + +/** Default suffix suggestions shown in the clone wizard */ +export const CLONE_SUFFIX_SUGGESTIONS = ['work', 'personal', 'prod', 'staging'] as const; + +/** Installed server row returned by clone_server (includes clone lineage). */ +export interface ClonedInstalledServer extends InstalledServerState { + cloned_from?: string | null; +} + +/** + * Clone an installed server into a new suffixed manual-entry install in the same space. + * + * `displayName` is optional; when set, it is stored as the user-supplied display label + * (`display_name_override`) and survives later definition refreshes. When omitted, the + * UI falls back to the auto `"Source (suffix)"` cached definition name. + */ +export async function cloneServer( + spaceId: string, + sourceServerId: string, + suffix: string, + alias?: string, + displayName?: string +): Promise { + return invoke('clone_server', { + spaceId, + sourceServerId, + suffix, + alias: alias ?? null, + displayName: displayName ?? null, + }); +} + +/** + * Return whether a suffixed clone ID is available in the given space. + */ +export async function isCloneIdAvailable( + spaceId: string, + sourceServerId: string, + suffix: string +): Promise { + return invoke('is_clone_id_available', { + spaceId, + sourceServerId, + suffix, + }); +} + +/** + * Suggest the first available default suffix for cloning a server. + */ +export async function suggestCloneSuffix(spaceId: string, sourceServerId: string): Promise { + return invoke('suggest_clone_suffix', { + spaceId, + sourceServerId, + }); +} + +/** + * List account clones that were created from the given source server in a space. + */ +export async function listCloneDependents( + spaceId: string, + sourceServerId: string +): Promise { + return invoke('list_clone_dependents', { + spaceId, + sourceServerId, + }); +} + +/** + * Normalize a server ID the same way the backend does (lowercase, strip underscores/spaces). + */ +export function normalizeServerId(id: string): string { + return id + .split('') + .filter((c) => /[a-zA-Z0-9]/.test(c) || c === '-' || c === '.') + .map((c) => (/[a-zA-Z0-9]/.test(c) ? c.toLowerCase() : c)) + .join(''); +} + +/** + * Derive the clone server ID preview from a base install ID and user suffix. + */ +export function deriveCloneServerId(baseServerId: string, suffix: string): string { + const normalizedSuffix = normalizeServerId(suffix); + if (!normalizedSuffix) { + return ''; + } + return normalizeServerId(`${baseServerId}-${normalizedSuffix}`); +} + +/** + * Derive the tool-name alias preview for a clone suffix. + */ +export function deriveCloneAlias(suffix: string): string { + return normalizeServerId(suffix).replace(/_/g, '-'); +} diff --git a/apps/desktop/src/lib/api/sessionOverrides.ts b/apps/desktop/src/lib/api/sessionOverrides.ts new file mode 100644 index 00000000..981dba52 --- /dev/null +++ b/apps/desktop/src/lib/api/sessionOverrides.ts @@ -0,0 +1,60 @@ +import { invoke } from '@tauri-apps/api/core'; + +/** Session-scoped server enable/disable overrides from meta tools. */ +export interface SessionOverride { + session_id: string; + enabled: string[]; + disabled: string[]; + /** Reported MCP workspace roots for this session (may be empty). */ + roots: string[]; +} + +/** List override state for all sessions, or one session when `sessionId` is set. */ +export async function listSessionOverrides( + sessionId?: string +): Promise { + return invoke('list_session_overrides', { sessionId: sessionId ?? null }); +} + +/** Drop all overrides for a session and refresh its tool list. */ +export async function clearSessionOverrides(sessionId: string): Promise { + return invoke('clear_session_overrides', { sessionId }); +} + +/** Whether session-scope enable/disable meta tools require approval. Default false. */ +export async function getSessionOverridesRequireApproval(): Promise { + return invoke('get_session_overrides_require_approval'); +} + +/** Persist the session-override approval gate. */ +export async function setSessionOverridesRequireApproval( + requireApproval: boolean +): Promise { + return invoke('set_session_overrides_require_approval', { requireApproval }); +} + +/** + * True when a session's reported root relates to the workspace path shown + * in the inspector (exact match or parent/child prefix). + */ +export function sessionRootMatchesWorkspace( + sessionRoot: string, + workspaceRoot: string +): boolean { + if (sessionRoot === workspaceRoot) return true; + const sep = sessionRoot.includes('\\') ? '\\' : '/'; + return ( + workspaceRoot.startsWith(`${sessionRoot}${sep}`) || + sessionRoot.startsWith(`${workspaceRoot}${sep}`) + ); +} + +/** Filter overrides to sessions reporting this workspace root. */ +export function overridesForWorkspace( + overrides: SessionOverride[], + workspaceRoot: string +): SessionOverride[] { + return overrides.filter((entry) => + entry.roots.some((root) => sessionRootMatchesWorkspace(root, workspaceRoot)) + ); +} diff --git a/apps/desktop/src/lib/api/spaces.ts b/apps/desktop/src/lib/api/spaces.ts index 625df98c..40e446e5 100644 --- a/apps/desktop/src/lib/api/spaces.ts +++ b/apps/desktop/src/lib/api/spaces.ts @@ -30,6 +30,20 @@ export async function createSpace(name: string, icon?: string): Promise { return invoke('create_space', { name, icon }); } +/** Partial update payload for a Space. */ +export interface UpdateSpaceInput { + name?: string; + icon?: string; + description?: string; +} + +/** + * Update a Space's display metadata (name, icon, description). + */ +export async function updateSpace(id: string, input: UpdateSpaceInput): Promise { + return invoke('update_space', { id, input }); +} + export async function deleteSpace(id: string): Promise { return invoke('delete_space', { id }); } diff --git a/apps/desktop/src/lib/api/workspaceBindings.ts b/apps/desktop/src/lib/api/workspaceBindings.ts index cc23dbfd..2f2ad669 100644 --- a/apps/desktop/src/lib/api/workspaceBindings.ts +++ b/apps/desktop/src/lib/api/workspaceBindings.ts @@ -10,6 +10,8 @@ import { invoke } from '@tauri-apps/api/core'; export interface WorkspaceBinding { id: string; workspace_root: string; + /** Friendly display name shown instead of the folder path when set. */ + label: string | null; space_id: string; /** * Non-empty by construction. Order is the operator-chosen rendering @@ -24,6 +26,7 @@ export interface WorkspaceBinding { /** Input payload for create / update. `feature_set_ids` must be non-empty. */ export interface WorkspaceBindingInput { workspace_root: string; + label?: string | null; space_id: string; feature_set_ids: string[]; } @@ -91,6 +94,7 @@ export async function deleteWorkspaceBinding(id: string): Promise { export function toInput(b: WorkspaceBinding): WorkspaceBindingInput { return { workspace_root: b.workspace_root, + label: b.label, space_id: b.space_id, feature_set_ids: b.feature_set_ids, }; diff --git a/apps/desktop/src/stores/registryStore.ts b/apps/desktop/src/stores/registryStore.ts index 98bba9cd..e0cf2d4c 100644 --- a/apps/desktop/src/stores/registryStore.ts +++ b/apps/desktop/src/stores/registryStore.ts @@ -16,6 +16,7 @@ import type { SortOption, } from '../types/registry'; import * as api from '../lib/api/registry'; +import { resolveInstalledDisplayName } from '../features/servers/server-display-name.helpers'; // ============================================ // State & Actions Types @@ -368,6 +369,7 @@ function mergeServers(defs: ServerDefinition[], states: InstalledServerState[]): return { ...def, + name: state ? resolveInstalledDisplayName(state, def) : def.name, is_installed: !!state, enabled: state?.enabled ?? false, oauth_connected: state?.oauth_connected ?? false, diff --git a/apps/desktop/src/types/registry.ts b/apps/desktop/src/types/registry.ts index 991b7bce..4a1b42da 100644 --- a/apps/desktop/src/types/registry.ts +++ b/apps/desktop/src/types/registry.ts @@ -101,6 +101,8 @@ export interface InstalledServerState { extra_headers: Record; oauth_connected: boolean; source: InstallationSource; // How this server was installed + /** User-supplied display label that survives user-config sync. */ + display_name_override?: string | null; created_at: string; updated_at: string; } diff --git a/crates/mcpmux-core/src/application/server.rs b/crates/mcpmux-core/src/application/server.rs index 80181b03..dc344170 100644 --- a/crates/mcpmux-core/src/application/server.rs +++ b/crates/mcpmux-core/src/application/server.rs @@ -8,7 +8,9 @@ use std::sync::Arc; use tracing::{info, warn}; use uuid::Uuid; -use crate::domain::{DomainEvent, InstallationSource, InstalledServer, ServerDefinition}; +use crate::domain::{ + DomainEvent, InstallationSource, InstalledServer, ServerDefinition, UserServerEntry, +}; use crate::event_bus::EventSender; use crate::repository::{CredentialRepository, InstalledServerRepository, ServerFeatureRepository}; @@ -97,6 +99,157 @@ impl ServerAppService { Ok(server) } + /// Clone an installed server into a new manual-entry install in the same space. + /// + /// Copies the source `cached_definition`, assigns a suffixed `server_id`, clears credentials, + /// and records lineage in `cloned_from`. When `display_name_override` is provided it is + /// stored as the user-supplied label (UI / meta tools prefer it); otherwise the auto + /// `"Source (suffix)"` label on `definition.name` is used as fallback. + /// + /// Emits: `ServerInstalled` + pub async fn clone_server( + &self, + space_id: Uuid, + source_server_id: &str, + suffix: &str, + alias_override: Option<&str>, + display_name_override: Option<&str>, + ) -> Result { + let space_id_str = space_id.to_string(); + let new_server_id = Self::derive_clone_server_id(source_server_id, suffix)?; + + let source = self + .server_repo + .get_by_server_id(&space_id_str, source_server_id) + .await? + .ok_or_else(|| anyhow!("Source server not installed"))?; + + if self + .server_repo + .get_by_server_id(&space_id_str, &new_server_id) + .await? + .is_some() + { + return Err(anyhow!("Clone server ID already exists in this space")); + } + + let mut definition = source + .get_definition() + .ok_or_else(|| anyhow!("Source server has no cached definition"))?; + + let normalized_suffix = UserServerEntry::normalize_server_id(suffix); + let alias = alias_override + .map(UserServerEntry::normalize_alias) + .unwrap_or_else(|| normalized_suffix.clone()); + + definition.id = new_server_id.clone(); + definition.name = format!("{} ({})", source.display_name(), normalized_suffix); + definition.alias = Some(alias); + + let server = InstalledServer::new(&space_id_str, &new_server_id) + .with_definition(&definition) + .with_source(InstallationSource::ManualEntry) + .with_cloned_from(source_server_id) + .with_display_name_override(display_name_override) + .with_enabled(false); + + self.server_repo.install(&server).await?; + + info!( + space_id = %space_id, + source_server_id = source_server_id, + server_id = %new_server_id, + "[ServerAppService] Cloned server" + ); + + let event_name = server + .display_name_override + .clone() + .unwrap_or_else(|| definition.name.clone()); + + self.event_sender.emit(DomainEvent::ServerInstalled { + space_id, + server_id: new_server_id.clone(), + server_name: event_name, + }); + + Ok(server) + } + + /// Return whether a suffixed clone ID is available in the given space. + pub async fn is_clone_id_available( + &self, + space_id: Uuid, + source_server_id: &str, + suffix: &str, + ) -> Result { + let space_id_str = space_id.to_string(); + let new_server_id = match Self::derive_clone_server_id(source_server_id, suffix) { + Ok(id) => id, + Err(_) => return Ok(false), + }; + + Ok(self + .server_repo + .get_by_server_id(&space_id_str, &new_server_id) + .await? + .is_none()) + } + + /// List installed servers in a space that were cloned from the given source. + pub async fn list_clone_dependents( + &self, + space_id: &str, + source_server_id: &str, + ) -> Result> { + let servers = self.server_repo.list_for_space(space_id).await?; + Ok(servers + .into_iter() + .filter(|server| server.cloned_from.as_deref() == Some(source_server_id)) + .collect()) + } + + /// Suggest the first available default suffix for cloning a server. + pub async fn suggest_clone_suffix( + &self, + space_id: Uuid, + source_server_id: &str, + ) -> Result { + const DEFAULT_SUFFIXES: &[&str] = &["work", "personal", "prod", "staging"]; + + for suffix in DEFAULT_SUFFIXES { + if self + .is_clone_id_available(space_id, source_server_id, suffix) + .await? + { + return Ok((*suffix).to_string()); + } + } + + for index in 2..100 { + let suffix = index.to_string(); + if self + .is_clone_id_available(space_id, source_server_id, &suffix) + .await? + { + return Ok(suffix); + } + } + + Err(anyhow!("No available clone suffix")) + } + + /// Derive the normalized clone server ID from a base install ID and user suffix. + fn derive_clone_server_id(base_server_id: &str, suffix: &str) -> Result { + let normalized_suffix = UserServerEntry::normalize_server_id(suffix); + if normalized_suffix.is_empty() { + return Err(anyhow!("Clone suffix cannot be empty")); + } + + let composite = format!("{base_server_id}-{normalized_suffix}"); + Ok(UserServerEntry::normalize_server_id(&composite)) + } + /// Uninstall a server /// /// For UserConfig servers, this also removes the entry from the source JSON file. @@ -207,9 +360,15 @@ impl ServerAppService { Ok(()) } - /// Update server configuration (inputs, env overrides, args, headers) + /// Update server configuration (inputs, env overrides, args, headers, display label). + /// + /// `display_name_override` semantics: + /// - `None` — leave existing override unchanged. + /// - `Some(value)` — normalize via [`InstalledServer::with_display_name_override`] so + /// empty/whitespace clears the override and any other value replaces it. /// /// Emits: `ServerConfigUpdated` + #[allow(clippy::too_many_arguments)] pub async fn update_config( &self, space_id: Uuid, @@ -218,6 +377,7 @@ impl ServerAppService { env_overrides: Option>, args_append: Option>, extra_headers: Option>, + display_name_override: Option, ) -> Result { let space_id_str = space_id.to_string(); @@ -237,6 +397,11 @@ impl ServerAppService { if let Some(headers) = extra_headers { server.extra_headers = headers; } + if let Some(value) = display_name_override { + server.display_name_override = Some(value) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + } server.updated_at = chrono::Utc::now(); self.server_repo.update(&server).await?; @@ -256,6 +421,49 @@ impl ServerAppService { Ok(server) } + /// Set or clear the user-supplied display name for an installed server. + /// + /// Empty/whitespace values clear the override. Emits `ServerConfigUpdated` so the UI + /// re-renders the server list with the new label. + pub async fn set_display_name_override( + &self, + space_id: Uuid, + server_id: &str, + value: Option, + ) -> Result { + let space_id_str = space_id.to_string(); + + let server = self + .server_repo + .get_by_server_id(&space_id_str, server_id) + .await? + .ok_or_else(|| anyhow!("Server not installed"))?; + + let normalized = value + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + + self.server_repo + .set_display_name_override(&server.id, normalized.clone()) + .await?; + + info!( + space_id = %space_id, + server_id = server_id, + has_override = normalized.is_some(), + "[ServerAppService] Updated display name override" + ); + + self.event_sender.emit(DomainEvent::ServerConfigUpdated { + space_id, + server_id: server_id.to_string(), + }); + + let mut updated = server; + updated.display_name_override = normalized; + Ok(updated) + } + /// Enable a server /// /// Emits: `ServerEnabled` @@ -343,3 +551,526 @@ impl ServerAppService { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use async_trait::async_trait; + use std::collections::HashMap; + use std::sync::RwLock; + + use crate::domain::{ServerSource, TransportConfig, TransportMetadata}; + use crate::event_bus::EventBus; + use crate::repository::InstalledServerRepository; + + struct InMemoryInstalledServerRepo { + servers: RwLock>, + } + + impl InMemoryInstalledServerRepo { + fn new() -> Self { + Self { + servers: RwLock::new(HashMap::new()), + } + } + + fn with_server(self, server: InstalledServer) -> Self { + self.servers.write().unwrap().insert(server.id, server); + self + } + } + + #[async_trait] + impl InstalledServerRepository for InMemoryInstalledServerRepo { + async fn list(&self) -> crate::repository::RepoResult> { + Ok(self.servers.read().unwrap().values().cloned().collect()) + } + + async fn list_for_space( + &self, + space_id: &str, + ) -> crate::repository::RepoResult> { + Ok(self + .servers + .read() + .unwrap() + .values() + .filter(|server| server.space_id == space_id) + .cloned() + .collect()) + } + + async fn list_by_source_file( + &self, + _file_path: &std::path::Path, + ) -> crate::repository::RepoResult> { + Ok(vec![]) + } + + async fn get(&self, id: &Uuid) -> crate::repository::RepoResult> { + Ok(self.servers.read().unwrap().get(id).cloned()) + } + + async fn get_by_server_id( + &self, + space_id: &str, + server_id: &str, + ) -> crate::repository::RepoResult> { + Ok(self + .servers + .read() + .unwrap() + .values() + .find(|server| server.space_id == space_id && server.server_id == server_id) + .cloned()) + } + + async fn install(&self, server: &InstalledServer) -> crate::repository::RepoResult<()> { + self.servers + .write() + .unwrap() + .insert(server.id, server.clone()); + Ok(()) + } + + async fn update(&self, server: &InstalledServer) -> crate::repository::RepoResult<()> { + self.servers + .write() + .unwrap() + .insert(server.id, server.clone()); + Ok(()) + } + + async fn uninstall(&self, id: &Uuid) -> crate::repository::RepoResult<()> { + self.servers.write().unwrap().remove(id); + Ok(()) + } + + async fn list_enabled( + &self, + space_id: &str, + ) -> crate::repository::RepoResult> { + Ok(self + .servers + .read() + .unwrap() + .values() + .filter(|server| server.space_id == space_id && server.enabled) + .cloned() + .collect()) + } + + async fn list_enabled_all(&self) -> crate::repository::RepoResult> { + Ok(self + .servers + .read() + .unwrap() + .values() + .filter(|server| server.enabled) + .cloned() + .collect()) + } + + async fn set_enabled(&self, id: &Uuid, enabled: bool) -> crate::repository::RepoResult<()> { + if let Some(server) = self.servers.write().unwrap().get_mut(id) { + server.enabled = enabled; + } + Ok(()) + } + + async fn set_oauth_connected( + &self, + id: &Uuid, + connected: bool, + ) -> crate::repository::RepoResult<()> { + if let Some(server) = self.servers.write().unwrap().get_mut(id) { + server.oauth_connected = connected; + } + Ok(()) + } + + async fn update_inputs( + &self, + id: &Uuid, + input_values: HashMap, + ) -> crate::repository::RepoResult<()> { + if let Some(server) = self.servers.write().unwrap().get_mut(id) { + server.input_values = input_values; + } + Ok(()) + } + + async fn update_cached_definition( + &self, + id: &Uuid, + server_name: Option, + cached_definition: Option, + ) -> crate::repository::RepoResult<()> { + if let Some(server) = self.servers.write().unwrap().get_mut(id) { + server.server_name = server_name; + server.cached_definition = cached_definition; + } + Ok(()) + } + + async fn set_display_name_override( + &self, + id: &Uuid, + value: Option, + ) -> crate::repository::RepoResult<()> { + if let Some(server) = self.servers.write().unwrap().get_mut(id) { + server.display_name_override = value; + } + Ok(()) + } + } + + fn sample_definition(server_id: &str, name: &str) -> ServerDefinition { + ServerDefinition { + id: server_id.to_string(), + name: name.to_string(), + description: None, + alias: None, + auth: None, + icon: None, + transport: TransportConfig::Stdio { + command: "npx".to_string(), + args: vec!["-y".to_string(), "posthog-mcp".to_string()], + env: HashMap::new(), + metadata: TransportMetadata::default(), + }, + categories: vec![], + publisher: None, + source: ServerSource::Bundled, + badges: vec![], + hosting_type: Default::default(), + license: None, + license_url: None, + installation: None, + capabilities: None, + sponsored: None, + media: None, + changelog_url: None, + } + } + + fn build_service(repo: Arc) -> ServerAppService { + ServerAppService::new(repo, None, None, EventBus::new().sender()) + } + + #[tokio::test] + async fn clone_server_happy_path() { + let space_id = Uuid::new_v4(); + let space_id_str = space_id.to_string(); + let source = InstalledServer::new(&space_id_str, "posthog") + .with_definition(&sample_definition("posthog", "PostHog")) + .with_input("API_KEY", "secret"); + + let repo = Arc::new(InMemoryInstalledServerRepo::new().with_server(source)); + let service = build_service(repo.clone()); + + let cloned = service + .clone_server(space_id, "posthog", "work", None, None) + .await + .expect("clone should succeed"); + + assert_eq!(cloned.server_id, "posthog-work"); + assert_eq!(cloned.cloned_from.as_deref(), Some("posthog")); + assert_eq!(cloned.source, InstallationSource::ManualEntry); + assert!(!cloned.enabled); + assert!(cloned.input_values.is_empty()); + assert_eq!(cloned.server_name.as_deref(), Some("PostHog (work)")); + assert!(cloned.display_name_override.is_none()); + + let definition = cloned.get_definition().expect("definition cached"); + assert_eq!(definition.id, "posthog-work"); + assert_eq!(definition.alias.as_deref(), Some("work")); + + let stored = repo + .get_by_server_id(&space_id_str, "posthog-work") + .await + .expect("repo lookup") + .expect("clone persisted"); + assert_eq!(stored.cloned_from.as_deref(), Some("posthog")); + } + + #[tokio::test] + async fn clone_server_rejects_collision() { + let space_id = Uuid::new_v4(); + let space_id_str = space_id.to_string(); + let source = InstalledServer::new(&space_id_str, "posthog") + .with_definition(&sample_definition("posthog", "PostHog")); + let existing_clone = InstalledServer::new(&space_id_str, "posthog-work") + .with_definition(&sample_definition("posthog-work", "PostHog (work)")); + + let repo = Arc::new( + InMemoryInstalledServerRepo::new() + .with_server(source) + .with_server(existing_clone), + ); + let service = build_service(repo); + + let error = service + .clone_server(space_id, "posthog", "work", None, None) + .await + .expect_err("collision should fail"); + + assert!(error.to_string().contains("already exists")); + } + + #[tokio::test] + async fn clone_server_rejects_missing_source() { + let space_id = Uuid::new_v4(); + let repo = Arc::new(InMemoryInstalledServerRepo::new()); + let service = build_service(repo); + + let error = service + .clone_server(space_id, "posthog", "work", None, None) + .await + .expect_err("missing source should fail"); + + assert!(error.to_string().contains("Source server not installed")); + } + + #[tokio::test] + async fn clone_server_normalizes_suffix_without_underscores() { + let space_id = Uuid::new_v4(); + let space_id_str = space_id.to_string(); + let source = InstalledServer::new(&space_id_str, "posthog") + .with_definition(&sample_definition("posthog", "PostHog")); + + let repo = Arc::new(InMemoryInstalledServerRepo::new().with_server(source)); + let service = build_service(repo); + + let cloned = service + .clone_server(space_id, "posthog", "my_work", None, None) + .await + .expect("clone should succeed"); + + assert_eq!(cloned.server_id, "posthog-mywork"); + assert_eq!( + cloned + .get_definition() + .and_then(|definition| definition.alias), + Some("mywork".to_string()) + ); + } + + #[tokio::test] + async fn suggest_clone_suffix_skips_taken_ids() { + let space_id = Uuid::new_v4(); + let space_id_str = space_id.to_string(); + let source = InstalledServer::new(&space_id_str, "posthog") + .with_definition(&sample_definition("posthog", "PostHog")); + let existing_clone = InstalledServer::new(&space_id_str, "posthog-work") + .with_definition(&sample_definition("posthog-work", "PostHog (work)")); + + let repo = Arc::new( + InMemoryInstalledServerRepo::new() + .with_server(source) + .with_server(existing_clone), + ); + let service = build_service(repo); + + let suffix = service + .suggest_clone_suffix(space_id, "posthog") + .await + .expect("suffix suggestion"); + + assert_eq!(suffix, "personal"); + } + + #[tokio::test] + async fn list_clone_dependents_returns_matching_clones() { + let space_id = Uuid::new_v4(); + let space_id_str = space_id.to_string(); + let source = InstalledServer::new(&space_id_str, "posthog") + .with_definition(&sample_definition("posthog", "PostHog")); + let clone_work = InstalledServer::new(&space_id_str, "posthog-work") + .with_definition(&sample_definition("posthog-work", "PostHog (work)")) + .with_cloned_from("posthog"); + let clone_personal = InstalledServer::new(&space_id_str, "posthog-personal") + .with_definition(&sample_definition("posthog-personal", "PostHog (personal)")) + .with_cloned_from("posthog"); + let unrelated = InstalledServer::new(&space_id_str, "github") + .with_definition(&sample_definition("github", "GitHub")); + + let repo = Arc::new( + InMemoryInstalledServerRepo::new() + .with_server(source) + .with_server(clone_work) + .with_server(clone_personal) + .with_server(unrelated), + ); + let service = build_service(repo); + + let dependents = service + .list_clone_dependents(&space_id_str, "posthog") + .await + .expect("dependents lookup"); + + assert_eq!(dependents.len(), 2); + let ids: Vec<_> = dependents + .iter() + .map(|server| server.server_id.as_str()) + .collect(); + assert!(ids.contains(&"posthog-work")); + assert!(ids.contains(&"posthog-personal")); + } + + #[tokio::test] + async fn clone_server_with_display_name_persists_override() { + let space_id = Uuid::new_v4(); + let space_id_str = space_id.to_string(); + let source = InstalledServer::new(&space_id_str, "posthog") + .with_definition(&sample_definition("posthog", "PostHog")); + + let repo = Arc::new(InMemoryInstalledServerRepo::new().with_server(source)); + let service = build_service(repo.clone()); + + let cloned = service + .clone_server(space_id, "posthog", "work", None, Some("Work account")) + .await + .expect("clone with display name"); + + assert_eq!(cloned.server_id, "posthog-work"); + assert_eq!( + cloned.display_name_override.as_deref(), + Some("Work account") + ); + assert_eq!(cloned.display_name(), "Work account"); + assert_eq!(cloned.server_name.as_deref(), Some("PostHog (work)")); + } + + #[tokio::test] + async fn set_display_name_override_sets_and_clears() { + let space_id = Uuid::new_v4(); + let space_id_str = space_id.to_string(); + let source = InstalledServer::new(&space_id_str, "posthog") + .with_definition(&sample_definition("posthog", "PostHog")); + + let repo = Arc::new(InMemoryInstalledServerRepo::new().with_server(source)); + let service = build_service(repo.clone()); + + let renamed = service + .set_display_name_override(space_id, "posthog", Some(" Joe Calendar ".into())) + .await + .expect("set override"); + assert_eq!( + renamed.display_name_override.as_deref(), + Some("Joe Calendar") + ); + + let stored = repo + .get_by_server_id(&space_id_str, "posthog") + .await + .unwrap() + .expect("server persisted"); + assert_eq!( + stored.display_name_override.as_deref(), + Some("Joe Calendar") + ); + + let cleared = service + .set_display_name_override(space_id, "posthog", Some(" ".into())) + .await + .expect("clear override"); + assert!(cleared.display_name_override.is_none()); + + let stored = repo + .get_by_server_id(&space_id_str, "posthog") + .await + .unwrap() + .expect("server persisted"); + assert!(stored.display_name_override.is_none()); + assert_eq!(stored.display_name(), "PostHog"); + } + + #[tokio::test] + async fn update_config_with_display_name_override_persists() { + let space_id = Uuid::new_v4(); + let space_id_str = space_id.to_string(); + let source = InstalledServer::new(&space_id_str, "posthog") + .with_definition(&sample_definition("posthog", "PostHog")); + + let repo = Arc::new(InMemoryInstalledServerRepo::new().with_server(source)); + let service = build_service(repo.clone()); + + let updated = service + .update_config( + space_id, + "posthog", + HashMap::new(), + None, + None, + None, + Some("My Calendar".into()), + ) + .await + .expect("update with display name"); + + assert_eq!( + updated.display_name_override.as_deref(), + Some("My Calendar") + ); + + // None leaves the existing override untouched. + let untouched = service + .update_config(space_id, "posthog", HashMap::new(), None, None, None, None) + .await + .expect("update without display name"); + assert_eq!( + untouched.display_name_override.as_deref(), + Some("My Calendar") + ); + + // Empty string clears. + let cleared = service + .update_config( + space_id, + "posthog", + HashMap::new(), + None, + None, + None, + Some(" ".into()), + ) + .await + .expect("clear override via update_config"); + assert!(cleared.display_name_override.is_none()); + } + + #[tokio::test] + async fn uninstall_clone_preserves_source() { + let space_id = Uuid::new_v4(); + let space_id_str = space_id.to_string(); + let source = InstalledServer::new(&space_id_str, "posthog") + .with_definition(&sample_definition("posthog", "PostHog")); + let clone_work = InstalledServer::new(&space_id_str, "posthog-work") + .with_definition(&sample_definition("posthog-work", "PostHog (work)")) + .with_cloned_from("posthog"); + + let repo = Arc::new( + InMemoryInstalledServerRepo::new() + .with_server(source) + .with_server(clone_work), + ); + let service = build_service(repo.clone()); + + service + .uninstall(space_id, "posthog-work") + .await + .expect("clone uninstall"); + + assert!(repo + .get_by_server_id(&space_id_str, "posthog") + .await + .expect("lookup source") + .is_some()); + assert!(repo + .get_by_server_id(&space_id_str, "posthog-work") + .await + .expect("lookup clone") + .is_none()); + } +} diff --git a/crates/mcpmux-core/src/domain/config.rs b/crates/mcpmux-core/src/domain/config.rs index d306edcd..afa25333 100644 --- a/crates/mcpmux-core/src/domain/config.rs +++ b/crates/mcpmux-core/src/domain/config.rs @@ -140,7 +140,7 @@ impl UserServerEntry { /// Normalize a server ID for prefix compatibility /// Removes spaces and special characters, converts to lowercase /// IMPORTANT: No underscores - underscore is reserved as delimiter in qualified names (prefix_toolname) - fn normalize_server_id(id: &str) -> String { + pub fn normalize_server_id(id: &str) -> String { id.chars() .filter_map(|c| { if c.is_alphanumeric() { @@ -156,7 +156,7 @@ impl UserServerEntry { /// Normalize an alias to be underscore-free /// Underscores are replaced with hyphens since underscore is the prefix_toolname delimiter - fn normalize_alias(alias: &str) -> String { + pub fn normalize_alias(alias: &str) -> String { alias .chars() .map(|c| { diff --git a/crates/mcpmux-core/src/domain/feature_set.rs b/crates/mcpmux-core/src/domain/feature_set.rs index 53bd950c..84f4653a 100644 --- a/crates/mcpmux-core/src/domain/feature_set.rs +++ b/crates/mcpmux-core/src/domain/feature_set.rs @@ -119,6 +119,9 @@ pub struct FeatureSetMember { pub member_id: String, /// Include or exclude pub mode: MemberMode, + /// When true on an included tool member, promote into client `tools/list`. + #[serde(default)] + pub surfaced: bool, } impl FeatureSetMember { @@ -130,6 +133,7 @@ impl FeatureSetMember { member_type: MemberType::Feature, member_id: feature_id.to_string(), mode: MemberMode::Include, + surfaced: false, } } @@ -141,6 +145,7 @@ impl FeatureSetMember { member_type: MemberType::Feature, member_id: feature_id.to_string(), mode: MemberMode::Exclude, + surfaced: false, } } @@ -152,6 +157,7 @@ impl FeatureSetMember { member_type: MemberType::FeatureSet, member_id: included_featureset_id.to_string(), mode: MemberMode::Include, + surfaced: false, } } } diff --git a/crates/mcpmux-core/src/domain/installed_server.rs b/crates/mcpmux-core/src/domain/installed_server.rs index 8c5422f7..2e58c76c 100644 --- a/crates/mcpmux-core/src/domain/installed_server.rs +++ b/crates/mcpmux-core/src/domain/installed_server.rs @@ -81,6 +81,18 @@ pub struct InstalledServer { #[serde(default)] pub source: InstallationSource, + /// Source server ID when this install was cloned from another server in the same space + #[serde(default)] + pub cloned_from: Option, + + /// User-supplied display label that survives user-config sync. + /// + /// When set, the UI and meta tools prefer this over `server_name` / + /// `cached_definition.name`. The `server_id`, alias, and tool prefixes are + /// unaffected. + #[serde(default)] + pub display_name_override: Option, + /// Creation timestamp pub created_at: DateTime, @@ -107,6 +119,8 @@ impl InstalledServer { extra_headers: HashMap::new(), oauth_connected: false, source: InstallationSource::default(), + cloned_from: None, + display_name_override: None, created_at: now, updated_at: now, } @@ -126,8 +140,14 @@ impl InstalledServer { .and_then(|json| serde_json::from_str(json).ok()) } - /// Get display name (from cached definition or server_id fallback) + /// Get effective display name. + /// + /// Precedence: `display_name_override` (user-supplied) → `server_name` + /// (cached at install time) → final segment of `server_id`. pub fn display_name(&self) -> &str { + if let Some(override_name) = self.display_name_override.as_deref() { + return override_name; + } self.server_name.as_deref().unwrap_or_else(|| { self.server_id .split('/') @@ -136,6 +156,15 @@ impl InstalledServer { }) } + /// Set the user-supplied display override (None or empty/whitespace clears it). + pub fn with_display_name_override(mut self, value: Option>) -> Self { + self.display_name_override = value + .map(Into::into) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + self + } + /// Set input values pub fn with_inputs(mut self, inputs: HashMap) -> Self { self.input_values = inputs; @@ -160,6 +189,12 @@ impl InstalledServer { self } + /// Set the source server ID when this install is a clone + pub fn with_cloned_from(mut self, source_server_id: impl Into) -> Self { + self.cloned_from = Some(source_server_id.into()); + self + } + /// Update OAuth connected state pub fn set_oauth_connected(&mut self, connected: bool) { self.oauth_connected = connected; @@ -450,4 +485,54 @@ mod tests { assert_eq!(deserialized.args_append.len(), 100); assert_eq!(deserialized.args_append[99], "--arg-99"); } + + #[test] + fn test_display_name_override_takes_precedence() { + let mut server = InstalledServer::new("space_default", "google.com/calendar"); + server.server_name = Some("Google Calendar".to_string()); + + assert_eq!(server.display_name(), "Google Calendar"); + + server.display_name_override = Some("Joe Calendar".to_string()); + assert_eq!(server.display_name(), "Joe Calendar"); + } + + #[test] + fn test_with_display_name_override_trims_and_clears() { + let server = InstalledServer::new("space_default", "test-server") + .with_display_name_override(Some(" Work Account ")); + assert_eq!( + server.display_name_override.as_deref(), + Some("Work Account") + ); + + let cleared = server.with_display_name_override(Some(" ")); + assert!(cleared.display_name_override.is_none()); + + let none_clears = InstalledServer::new("space_default", "test-server") + .with_display_name_override(Some("Name")) + .with_display_name_override(Option::::None); + assert!(none_clears.display_name_override.is_none()); + } + + #[test] + fn test_display_name_override_default_on_deserialize() { + let json = r#"{ + "id": "00000000-0000-0000-0000-000000000001", + "space_id": "space_default", + "server_id": "test-server", + "server_name": "Catalog Name", + "cached_definition": null, + "input_values": {}, + "enabled": false, + "oauth_connected": false, + "source": {"type": "registry"}, + "created_at": "2025-01-01T00:00:00Z", + "updated_at": "2025-01-01T00:00:00Z" + }"#; + + let server: InstalledServer = serde_json::from_str(json).expect("Failed to deserialize"); + assert!(server.display_name_override.is_none()); + assert_eq!(server.display_name(), "Catalog Name"); + } } diff --git a/crates/mcpmux-core/src/domain/workspace_binding.rs b/crates/mcpmux-core/src/domain/workspace_binding.rs index a816cc17..495ed327 100644 --- a/crates/mcpmux-core/src/domain/workspace_binding.rs +++ b/crates/mcpmux-core/src/domain/workspace_binding.rs @@ -32,6 +32,8 @@ use uuid::Uuid; pub struct WorkspaceBinding { pub id: Uuid, pub workspace_root: String, + /// Optional friendly display name shown in the UI instead of the path. + pub label: Option, pub space_id: Uuid, /// Order matters for UI rendering only — the resolver treats them as /// a set. Stored in the `workspace_binding_feature_sets` junction @@ -63,6 +65,7 @@ impl WorkspaceBinding { Self { id: Uuid::new_v4(), workspace_root: workspace_root.into(), + label: None, space_id, feature_set_ids, created_at: now, diff --git a/crates/mcpmux-core/src/repository/mod.rs b/crates/mcpmux-core/src/repository/mod.rs index 409ce6d9..73381d51 100644 --- a/crates/mcpmux-core/src/repository/mod.rs +++ b/crates/mcpmux-core/src/repository/mod.rs @@ -99,6 +99,12 @@ pub trait InstalledServerRepository: Send + Sync { server_name: Option, cached_definition: Option, ) -> RepoResult<()>; + + /// Set or clear the user-supplied display name override for an installed server. + /// + /// Pass `None` to clear the override (UI falls back to `server_name` / + /// `cached_definition.name` / `server_id` tail). + async fn set_display_name_override(&self, id: &Uuid, value: Option) -> RepoResult<()>; } /// ServerFeature repository trait diff --git a/crates/mcpmux-core/src/service/space_service.rs b/crates/mcpmux-core/src/service/space_service.rs index b5af6926..cbaae2ce 100644 --- a/crates/mcpmux-core/src/service/space_service.rs +++ b/crates/mcpmux-core/src/service/space_service.rs @@ -80,6 +80,35 @@ impl SpaceService { Ok(space) } + /// Update a space's display metadata (name, icon, description). + pub async fn update( + &self, + id: Uuid, + name: Option, + icon: Option, + description: Option, + ) -> anyhow::Result { + let mut space = self + .repository + .get(&id) + .await? + .ok_or_else(|| anyhow::anyhow!("Space not found"))?; + + if let Some(name) = name { + space.name = name; + } + if let Some(icon) = icon { + space.icon = Some(icon); + } + if let Some(description) = description { + space.description = Some(description); + } + space.updated_at = chrono::Utc::now(); + + self.repository.update(&space).await?; + Ok(space) + } + /// Delete a space pub async fn delete(&self, id: &Uuid) -> anyhow::Result<()> { let space = self.repository.get(id).await?; @@ -97,3 +126,126 @@ impl SpaceService { self.repository.get_default().await } } + +#[cfg(test)] +mod tests { + use super::*; + use async_trait::async_trait; + use std::collections::HashMap; + use tokio::sync::RwLock; + + struct InMemorySpaceRepo { + spaces: RwLock>, + } + + async fn repo_with_space(space: Space) -> Arc { + let repo = Arc::new(InMemorySpaceRepo { + spaces: RwLock::new(HashMap::new()), + }); + repo.spaces.write().await.insert(space.id, space); + repo + } + + #[async_trait] + impl SpaceRepository for InMemorySpaceRepo { + async fn list(&self) -> crate::repository::RepoResult> { + Ok(self.spaces.read().await.values().cloned().collect()) + } + + async fn get(&self, id: &Uuid) -> crate::repository::RepoResult> { + Ok(self.spaces.read().await.get(id).cloned()) + } + + async fn create(&self, space: &Space) -> crate::repository::RepoResult<()> { + self.spaces.write().await.insert(space.id, space.clone()); + Ok(()) + } + + async fn update(&self, space: &Space) -> crate::repository::RepoResult<()> { + self.spaces.write().await.insert(space.id, space.clone()); + Ok(()) + } + + async fn delete(&self, id: &Uuid) -> crate::repository::RepoResult<()> { + self.spaces.write().await.remove(id); + Ok(()) + } + + async fn get_default(&self) -> crate::repository::RepoResult> { + Ok(self + .spaces + .read() + .await + .values() + .find(|s| s.is_default) + .cloned()) + } + + async fn set_default(&self, id: &Uuid) -> crate::repository::RepoResult<()> { + let mut spaces = self.spaces.write().await; + for space in spaces.values_mut() { + space.is_default = false; + } + if let Some(space) = spaces.get_mut(id) { + space.is_default = true; + } + Ok(()) + } + } + + #[tokio::test] + async fn update_changes_name_and_bumps_updated_at() { + let original = Space::new("Original"); + let id = original.id; + let original_updated_at = original.updated_at; + let repo = repo_with_space(original).await; + let service = SpaceService::new(repo); + + let updated = service + .update(id, Some("Renamed".to_string()), None, None) + .await + .unwrap(); + + assert_eq!(updated.name, "Renamed"); + assert!(updated.updated_at >= original_updated_at); + + let loaded = service.get(&id).await.unwrap().expect("space exists"); + assert_eq!(loaded.name, "Renamed"); + } + + #[tokio::test] + async fn update_applies_icon_and_description() { + let space = Space::new("Space"); + let id = space.id; + let repo = repo_with_space(space).await; + let service = SpaceService::new(repo); + + let updated = service + .update( + id, + None, + Some("rocket".to_string()), + Some("Side project".to_string()), + ) + .await + .unwrap(); + + assert_eq!(updated.icon.as_deref(), Some("rocket")); + assert_eq!(updated.description.as_deref(), Some("Side project")); + } + + #[tokio::test] + async fn update_returns_not_found_for_missing_space() { + let repo = Arc::new(InMemorySpaceRepo { + spaces: RwLock::new(HashMap::new()), + }); + let service = SpaceService::new(repo); + + let err = service + .update(Uuid::new_v4(), Some("nope".to_string()), None, None) + .await + .unwrap_err(); + + assert!(err.to_string().contains("Space not found")); + } +} diff --git a/crates/mcpmux-gateway/src/consumers/mcp_notifier.rs b/crates/mcpmux-gateway/src/consumers/mcp_notifier.rs index 30723200..ce865e87 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; +use crate::services::{FeatureSetResolverService, SessionOverrideRegistry}; /// MCP Notifier — sends `list_changed` notifications to connected sessions. /// @@ -58,6 +58,8 @@ pub struct MCPNotifier { feature_set_resolver: Arc, /// Feature service for calculating content hashes feature_service: Arc, + /// Session override registry — reaped alongside session roots. + session_overrides: Arc, /// Throttle tracker: (space_id, notification_type) -> last_sent_timestamp /// Prevents sending duplicate notifications within THROTTLE_WINDOW throttle_tracker: Arc>>, @@ -110,11 +112,13 @@ impl MCPNotifier { pub fn new( feature_set_resolver: Arc, feature_service: Arc, + session_overrides: Arc, ) -> Self { Self { sessions: Arc::new(RwLock::new(HashMap::new())), feature_set_resolver, feature_service, + session_overrides, throttle_tracker: Arc::new(RwLock::new(HashMap::new())), state_hashes: Arc::new(RwLock::new(HashMap::new())), } @@ -354,6 +358,7 @@ impl MCPNotifier { // sessions that no longer exist. for sid in &dead { self.feature_set_resolver.session_roots().remove(sid); + self.session_overrides.remove(sid); } info!( reaped = dead.len(), @@ -1059,45 +1064,103 @@ impl MCPNotifier { ); 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" - ), - } - 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" - ), - } - 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" - ), - } + self.send_all_lists_changed_to_peer(session_id, client_id, peer) + .await; + } + } + + /// Send all three list_changed notifications to one session, bypassing + /// space-level hash dedup. Used after session-scoped override mutations + /// so only the calling session refreshes its tool list. + pub async fn notify_session_lists_changed(&self, session_id: &str) { + if DISABLE_ALL_NOTIFICATIONS { + trace!( + %session_id, + "[MCPNotifier] 🚫 disabled — skipping session list_changed" + ); + return; + } + + let snapshot: Option<(String, Arc>)> = { + let sessions = self.sessions.read(); + sessions.get(session_id).and_then(|entry| { + if entry.has_active_stream { + Some((entry.client_id.clone(), entry.peer.clone())) + } else { + None + } + }) + }; + + let Some((client_id, peer)) = snapshot else { + debug!( + %session_id, + "[MCPNotifier] no active stream — skipping session list_changed" + ); + return; + }; + + if self + .reap_dead_sessions(&[(session_id.to_string(), peer.clone())]) + .contains(&session_id.to_string()) + { + return; + } + + info!( + %session_id, + %client_id, + "[MCPNotifier] 📤 session list_changed (override mutated)" + ); + self.send_all_lists_changed_to_peer(session_id, &client_id, &peer) + .await; + } + + /// Push tools/prompts/resources list_changed to a single peer. + async fn send_all_lists_changed_to_peer( + &self, + session_id: &str, + client_id: &str, + peer: &Peer, + ) { + match peer.notify_tool_list_changed().await { + Ok(_) => debug!( + %session_id, + %client_id, + "[MCPNotifier] ✅ Sent tools/list_changed to session" + ), + Err(e) => warn!( + %session_id, + %client_id, + error = ?e, + "[MCPNotifier] failed tools/list_changed" + ), + } + match peer.notify_prompt_list_changed().await { + Ok(_) => debug!( + %session_id, + %client_id, + "[MCPNotifier] ✅ Sent prompts/list_changed to session" + ), + Err(e) => warn!( + %session_id, + %client_id, + error = ?e, + "[MCPNotifier] failed prompts/list_changed" + ), + } + match peer.notify_resource_list_changed().await { + Ok(_) => debug!( + %session_id, + %client_id, + "[MCPNotifier] ✅ Sent resources/list_changed to session" + ), + Err(e) => warn!( + %session_id, + %client_id, + error = ?e, + "[MCPNotifier] failed resources/list_changed" + ), } } } diff --git a/crates/mcpmux-gateway/src/lib.rs b/crates/mcpmux-gateway/src/lib.rs index c974b0a3..3841ec26 100644 --- a/crates/mcpmux-gateway/src/lib.rs +++ b/crates/mcpmux-gateway/src/lib.rs @@ -70,13 +70,17 @@ pub use pool::{ ServerState, ServiceFactory, TokenService, + ToolCallResult, TransportConnectResult, TransportFactory, TransportType, }; // Services module -pub use services::{EventEmitter, GrantService, PrefixCacheService}; +pub use services::{ + EventEmitter, GrantService, InvokeToolBackend, PrefixCacheService, SessionOverrideRegistry, + routing_as_invoke_backend, +}; // MCP module (rmcp-based implementation) pub use mcp::McpMuxGatewayHandler; diff --git a/crates/mcpmux-gateway/src/mcp/handler.rs b/crates/mcpmux-gateway/src/mcp/handler.rs index 0279f6ec..c62a7ded 100644 --- a/crates/mcpmux-gateway/src/mcp/handler.rs +++ b/crates/mcpmux-gateway/src/mcp/handler.rs @@ -671,12 +671,16 @@ impl ServerHandler for McpMuxGatewayHandler { .resolve_routing(session_id_owned.as_deref(), &oauth_ctx.client_id) .await?; - // Get tools via FeatureService — using the *resolved* space. + // Get advertised tools (meta + surfaced only) for client tools/list. let tools = self .services .pool_services .feature_service - .get_tools_for_grants(&space_id.to_string(), &feature_set_ids) + .get_advertised_tools_for_grants( + &space_id.to_string(), + &feature_set_ids, + session_id_owned.as_deref(), + ) .await .map_err(|e| McpError::internal_error(format!("Failed to get tools: {}", e), None))?; @@ -746,23 +750,92 @@ impl ServerHandler for McpMuxGatewayHandler { .arguments .map(|a| serde_json::to_value(a).unwrap_or(serde_json::Value::Null)) .unwrap_or(serde_json::Value::Null); + let scope = args + .get("scope") + .and_then(|v| v.as_str()) + .unwrap_or("session") + .to_string(); return match self .services .meta_tool_registry .call(¶ms.name, &oauth_ctx.client_id, session_id, args) .await { - Ok(result) => Ok(result), + Ok(result) => { + if matches!( + params.name.as_ref(), + "mcpmux_enable_server" | "mcpmux_disable_server" + ) && scope == "session" + { + if let Some(sid) = session_id { + self.notification_bridge + .notify_session_lists_changed(sid) + .await; + } + } + Ok(result) + } Err(e) => Ok(e.into_call_tool_result()), }; } + self.ensure_roots_probed( + &context.peer, + session_id, + &oauth_ctx.client_id, + ) + .await; + // 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, &oauth_ctx.client_id) .await?; + // Hard cut: non-surfaced backend tools must use mcpmux_invoke_tool. + // Surfaced tools stay in tools/list for one-hop calls. + let space_id_str = space_id.to_string(); + if let Ok(Some((server_id, actual_tool_name))) = self + .services + .pool_services + .feature_service + .find_server_for_qualified_tool(&space_id_str, ¶ms.name) + .await + { + let advertised = self + .services + .pool_services + .feature_service + .get_advertised_tools_for_grants( + &space_id_str, + &feature_set_ids, + session_id, + ) + .await + .map_err(|e| { + McpError::internal_error(format!("Failed to get advertised tools: {}", e), None) + })?; + + let is_surfaced = advertised + .iter() + .any(|feature| feature.qualified_name() == params.name.as_ref()); + + if !is_surfaced { + let message = crate::pool::format_direct_call_redirect( + ¶ms.name, + &server_id, + &actual_tool_name, + ); + return Ok(CallToolResult::error(vec![Content::text( + serde_json::json!({ + "error": "use_invoke_tool", + "message": message, + }) + .to_string(), + )])); + } + } + // Call tool via routing service (handles auth and routing) let tool_result = self .services @@ -771,6 +844,7 @@ impl ServerHandler for McpMuxGatewayHandler { .call_tool( space_id, &feature_set_ids, + session_id, ¶ms.name, serde_json::to_value(params.arguments.unwrap_or_default()).unwrap_or_default(), ) @@ -829,11 +903,12 @@ impl ServerHandler for McpMuxGatewayHandler { "call_tool result" ); - let result = if tool_result.is_error { + let mut result = if tool_result.is_error { CallToolResult::error(content) } else { CallToolResult::success(content) }; + result.structured_content = tool_result.structured_content; Ok(result) } @@ -861,7 +936,11 @@ impl ServerHandler for McpMuxGatewayHandler { .services .pool_services .feature_service - .get_prompts_for_grants(&space_id.to_string(), &feature_set_ids) + .get_prompts_for_grants( + &space_id.to_string(), + &feature_set_ids, + session_id_owned.as_deref(), + ) .await .map_err(|e| McpError::internal_error(format!("Failed to get prompts: {}", e), None))?; @@ -897,11 +976,9 @@ 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); 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 (server_id, prompt_name) = self @@ -916,7 +993,11 @@ impl ServerHandler for McpMuxGatewayHandler { .services .pool_services .feature_service - .get_prompts_for_grants(&space_id.to_string(), &feature_set_ids) + .get_prompts_for_grants( + &space_id.to_string(), + &feature_set_ids, + session_id_owned.as_deref(), + ) .await .map_err(|e| { McpError::internal_error(format!("Failed to verify authorization: {}", e), None) @@ -972,7 +1053,11 @@ impl ServerHandler for McpMuxGatewayHandler { .services .pool_services .feature_service - .get_resources_for_grants(&space_id.to_string(), &feature_set_ids) + .get_resources_for_grants( + &space_id.to_string(), + &feature_set_ids, + session_id_owned.as_deref(), + ) .await .map_err(|e| { McpError::internal_error(format!("Failed to get resources: {}", e), None) @@ -1006,11 +1091,9 @@ 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); 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 server_id = self @@ -1030,7 +1113,11 @@ impl ServerHandler for McpMuxGatewayHandler { .services .pool_services .feature_service - .get_resources_for_grants(&space_id.to_string(), &feature_set_ids) + .get_resources_for_grants( + &space_id.to_string(), + &feature_set_ids, + session_id_owned.as_deref(), + ) .await .map_err(|e| { McpError::internal_error(format!("Failed to verify authorization: {}", e), None) diff --git a/crates/mcpmux-gateway/src/oauth/dcr.rs b/crates/mcpmux-gateway/src/oauth/dcr.rs index fc6a9a06..ed255019 100644 --- a/crates/mcpmux-gateway/src/oauth/dcr.rs +++ b/crates/mcpmux-gateway/src/oauth/dcr.rs @@ -227,6 +227,8 @@ pub fn validate_redirect_uris(uris: &[String]) -> Result<(), DcrError> { )); } + let mut valid_count = 0; + for uri in uris { let is_loopback = uri.starts_with("http://127.0.0.1") || uri.starts_with("http://localhost") @@ -237,20 +239,28 @@ pub fn validate_redirect_uris(uris: &[String]) -> Result<(), DcrError> { let is_custom_scheme = !uri.starts_with("http://") && !uri.starts_with("https://"); if !is_loopback && !is_custom_scheme { + // Skip invalid URIs (e.g. https://www.cursor.com/agents/mcp/oauth/callback) + // rather than rejecting the entire registration — clients like Cursor send a + // mix of valid and invalid URIs and only ever use the valid ones in practice. warn!( - "[DCR] Rejected redirect_uri: {} (must be loopback or custom scheme)", + "[DCR] Skipping invalid redirect_uri: {} (must be loopback or custom scheme)", uri ); - return Err(DcrError::invalid_redirect_uri( - "Redirect URI must be loopback (http://127.0.0.1 or http://localhost) \ - or a custom URL scheme (e.g., cursor://, vscode://)", - )); + continue; } debug!( "[DCR] Validated redirect_uri: {} (loopback={}, custom_scheme={})", uri, is_loopback, is_custom_scheme ); + valid_count += 1; + } + + if valid_count == 0 { + return Err(DcrError::invalid_redirect_uri( + "No valid redirect_uris provided — must include at least one loopback \ + (http://127.0.0.1 or http://localhost) or custom URL scheme (e.g., cursor://, vscode://)", + )); } Ok(()) @@ -462,6 +472,29 @@ mod tests { assert!(validate_redirect_uris(&["https://example.com/callback".to_string()]).is_err()); } + #[test] + fn test_mixed_valid_and_invalid_uris_pass() { + // Real-world case: Cursor sends a mix of valid (custom scheme + loopback) and + // invalid (https) URIs. Registration must succeed as long as at least one valid + // URI is present — otherwise clients that send any non-loopback HTTPS URI cannot + // register at all. + let uris = vec![ + "cursor://anysphere.cursor-mcp/oauth/callback".to_string(), + "https://www.cursor.com/agents/mcp/oauth/callback".to_string(), + "http://localhost:8787/callback".to_string(), + ]; + assert!(validate_redirect_uris(&uris).is_ok()); + } + + #[test] + fn test_all_invalid_uris_fail() { + let uris = vec![ + "https://www.cursor.com/agents/mcp/oauth/callback".to_string(), + "http://example.com/callback".to_string(), + ]; + assert!(validate_redirect_uris(&uris).is_err()); + } + #[test] fn loopback_ignores_port_per_rfc_8252() { // Registered with one port, requested with another — must match. diff --git a/crates/mcpmux-gateway/src/pool/features/facade.rs b/crates/mcpmux-gateway/src/pool/features/facade.rs index ad0914c3..4ccef995 100644 --- a/crates/mcpmux-gateway/src/pool/features/facade.rs +++ b/crates/mcpmux-gateway/src/pool/features/facade.rs @@ -1,10 +1,11 @@ //! Feature Service Facade - Unified API delegating to specialized services use anyhow::Result; +use std::collections::HashSet; use std::sync::Arc; use crate::pool::instance::McpClient; -use crate::services::PrefixCacheService; +use crate::services::{PrefixCacheService, SessionOverrideRegistry}; use mcpmux_core::{FeatureSetRepository, FeatureType, ServerFeature, ServerFeatureRepository}; use super::{ @@ -16,6 +17,7 @@ pub struct FeatureService { discovery: Arc, resolution: Arc, routing: Arc, + session_overrides: Arc, } impl FeatureService { @@ -23,6 +25,7 @@ impl FeatureService { feature_repo: Arc, feature_set_repo: Arc, prefix_cache: Arc, + session_overrides: Arc, ) -> Self { let discovery = Arc::new(FeatureDiscoveryService::new(feature_repo.clone())); @@ -41,6 +44,7 @@ impl FeatureService { discovery, resolution, routing, + session_overrides, } } @@ -86,35 +90,145 @@ impl FeatureService { .await } - // Type-specific helpers + /// Resolve granted feature sets to tools invokable via search/invoke ACL. + pub async fn get_invokable_tools_for_grants( + &self, + space_id: &str, + feature_set_ids: &[String], + session_id: Option<&str>, + ) -> Result> { + self.get_features_for_grants( + space_id, + feature_set_ids, + session_id, + Some(FeatureType::Tool), + ) + .await + } + + /// Tools promoted into client `tools/list` (surfaced backend tools only). + pub async fn get_advertised_tools_for_grants( + &self, + space_id: &str, + feature_set_ids: &[String], + session_id: Option<&str>, + ) -> Result> { + if feature_set_ids.is_empty() { + return Ok(Vec::new()); + } + + let invokable = self + .get_invokable_tools_for_grants(space_id, feature_set_ids, session_id) + .await?; + let surfaced_ids = self + .resolution + .resolve_surfaced_feature_ids(feature_set_ids) + .await?; + + Ok(invokable + .into_iter() + .filter(|f| surfaced_ids.contains(&f.id.to_string())) + .collect()) + } + + /// Resolve granted feature sets to tools, applying session server overrides. pub async fn get_tools_for_grants( &self, space_id: &str, feature_set_ids: &[String], + session_id: Option<&str>, ) -> Result> { - self.resolution - .resolve_feature_sets(space_id, feature_set_ids, Some(FeatureType::Tool)) + self.get_invokable_tools_for_grants(space_id, feature_set_ids, session_id) .await } + /// Resolve granted feature sets to prompts, applying session server overrides. pub async fn get_prompts_for_grants( &self, space_id: &str, feature_set_ids: &[String], + session_id: Option<&str>, ) -> Result> { - self.resolution - .resolve_feature_sets(space_id, feature_set_ids, Some(FeatureType::Prompt)) - .await + self.get_features_for_grants( + space_id, + feature_set_ids, + session_id, + Some(FeatureType::Prompt), + ) + .await } + /// Resolve granted feature sets to resources, applying session server overrides. pub async fn get_resources_for_grants( &self, space_id: &str, feature_set_ids: &[String], + session_id: Option<&str>, ) -> Result> { - self.resolution - .resolve_feature_sets(space_id, feature_set_ids, Some(FeatureType::Resource)) - .await + self.get_features_for_grants( + space_id, + feature_set_ids, + session_id, + Some(FeatureType::Resource), + ) + .await + } + + /// Shared list materialization: binding FS resolution + session overrides. + async fn get_features_for_grants( + &self, + space_id: &str, + feature_set_ids: &[String], + session_id: Option<&str>, + filter_type: Option, + ) -> Result> { + let binding_features = self + .resolution + .resolve_feature_sets(space_id, feature_set_ids, filter_type.clone()) + .await?; + + let Some(session_id) = session_id else { + return Ok(binding_features); + }; + + let enabled = self.session_overrides.enabled_set(session_id); + let disabled = self.session_overrides.disabled_set(session_id); + + if enabled.is_empty() && disabled.is_empty() { + return Ok(binding_features); + } + + // Bound FeatureSets: member filter is authoritative — session overrides + // only gate server activity, they do not expand to all server tools. + if !feature_set_ids.is_empty() { + return Ok(binding_features + .into_iter() + .filter(|f| !disabled.contains(&f.server_id)) + .collect()); + } + + // Unbound session (no FS): session-enabled servers expose all tools so + // meta tools can bootstrap before bind/grant. + let mut active_servers: HashSet = binding_features + .iter() + .map(|f| f.server_id.clone()) + .collect(); + active_servers.extend(enabled.iter().cloned()); + active_servers.retain(|server_id| !disabled.contains(server_id)); + + if active_servers.is_empty() { + return Ok(Vec::new()); + } + + let all_features = self + .resolution + .get_all_features_for_space(space_id, filter_type) + .await?; + + Ok(all_features + .into_iter() + .filter(|f| f.is_available && active_servers.contains(&f.server_id)) + .collect()) } // Delegate to FeatureRoutingService (with type-specific helpers) diff --git a/crates/mcpmux-gateway/src/pool/features/resolution.rs b/crates/mcpmux-gateway/src/pool/features/resolution.rs index 7f779b8d..1709ac81 100644 --- a/crates/mcpmux-gateway/src/pool/features/resolution.rs +++ b/crates/mcpmux-gateway/src/pool/features/resolution.rs @@ -163,6 +163,48 @@ impl FeatureResolutionService { Ok(result) } + /// Collect feature IDs marked `surfaced: true` across the given FeatureSets. + pub async fn resolve_surfaced_feature_ids( + &self, + feature_set_ids: &[String], + ) -> Result> { + let mut surfaced = HashSet::new(); + for fs_id in feature_set_ids { + let Some(feature_set) = self.feature_set_repo.get_with_members(fs_id).await? else { + continue; + }; + self.collect_surfaced_members(&feature_set, &mut surfaced) + .await?; + } + Ok(surfaced) + } + + async fn collect_surfaced_members( + &self, + feature_set: &FeatureSet, + surfaced: &mut HashSet, + ) -> Result<()> { + for member in &feature_set.members { + match member.member_type { + MemberType::Feature => { + if member.mode == MemberMode::Include && member.surfaced { + surfaced.insert(member.member_id.clone()); + } + } + MemberType::FeatureSet => { + if let Some(nested_fs) = self + .feature_set_repo + .get_with_members(&member.member_id) + .await? + { + Box::pin(self.collect_surfaced_members(&nested_fs, surfaced)).await?; + } + } + } + } + Ok(()) + } + async fn resolve_members( &self, feature_set: &FeatureSet, diff --git a/crates/mcpmux-gateway/src/pool/mod.rs b/crates/mcpmux-gateway/src/pool/mod.rs index 3a4b366a..e3ab01df 100644 --- a/crates/mcpmux-gateway/src/pool/mod.rs +++ b/crates/mcpmux-gateway/src/pool/mod.rs @@ -42,7 +42,10 @@ pub use oauth::{ // SOLID Services pub use connection::{ConnectionResult, ConnectionService}; pub use features::{CachedFeatures, FeatureService}; -pub use routing::{RoutedPrompt, RoutedResource, RoutedTool, RoutingService}; +pub use routing::{ + format_direct_call_redirect, format_invoke_permission_denied, format_server_inactive_error, + RoutedPrompt, RoutedResource, RoutedTool, RoutingService, ToolCallResult, +}; pub use service::{InstalledServerInfo, PoolService, PoolStats, ReconnectResult}; pub use token::TokenService; pub use transport::{ResolvedTransport, Transport, TransportConnectResult, TransportFactory}; diff --git a/crates/mcpmux-gateway/src/pool/routing.rs b/crates/mcpmux-gateway/src/pool/routing.rs index 28daed57..18f00fa9 100644 --- a/crates/mcpmux-gateway/src/pool/routing.rs +++ b/crates/mcpmux-gateway/src/pool/routing.rs @@ -48,15 +48,55 @@ pub struct RoutedResource { } /// Result of a tool call -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct ToolCallResult { pub content: Vec, + pub structured_content: Option, pub is_error: bool, } /// Default timeout for MCP tool calls (60 seconds) const TOOL_CALL_TIMEOUT: Duration = Duration::from_secs(60); +/// Actionable error when a server is not in the effective enable set. +pub fn format_server_inactive_error(server_id: &str) -> String { + format!( + "server '{server_id}' is inactive → mcpmux_enable_server({{ \"server_id\": \"{server_id}\" }})" + ) +} + +/// Actionable error when invoke targets a tool outside the permission set. +pub fn format_invoke_permission_denied( + qualified_name: &str, + server_id: &str, + tool_name: &str, + suggestions: &[String], +) -> String { + if suggestions.is_empty() { + format!( + "tool '{qualified_name}' is not invokable with current grants (server_id='{server_id}', tool='{tool_name}')" + ) + } else { + format!( + "tool '{qualified_name}' is not invokable — did you mean {}?", + suggestions.join(", ") + ) + } +} + +/// Redirect message for direct backend `call_tool` attempts. +pub fn format_direct_call_redirect( + qualified_name: &str, + server_id: &str, + tool_name: &str, +) -> String { + format!( + "Direct backend tool calls are not supported. Use mcpmux_invoke_tool instead: \ + mcpmux_invoke_tool({{ \"server_id\": \"{server_id}\", \"tool\": \"{tool_name}\", \"args\": {{}} }}) \ + (qualified name was '{qualified_name}')" + ) +} + /// RoutingService dispatches requests to backend MCP servers pub struct RoutingService { feature_service: Arc, @@ -84,13 +124,14 @@ impl RoutingService { &self, space_id: Uuid, feature_set_ids: &[String], + session_id: Option<&str>, ) -> Result> { let space_id_str = space_id.to_string(); // Resolve feature sets to allowed features let allowed_features = self .feature_service - .get_tools_for_grants(&space_id_str, feature_set_ids) + .get_invokable_tools_for_grants(&space_id_str, feature_set_ids, session_id) .await?; // Filter to just tools @@ -119,12 +160,13 @@ impl RoutingService { &self, space_id: Uuid, feature_set_ids: &[String], + session_id: Option<&str>, ) -> Result> { let space_id_str = space_id.to_string(); let allowed_features = self .feature_service - .get_prompts_for_grants(&space_id_str, feature_set_ids) + .get_prompts_for_grants(&space_id_str, feature_set_ids, session_id) .await?; let prompts: Vec = allowed_features @@ -151,12 +193,13 @@ impl RoutingService { &self, space_id: Uuid, feature_set_ids: &[String], + session_id: Option<&str>, ) -> Result> { let space_id_str = space_id.to_string(); let allowed_features = self .feature_service - .get_resources_for_grants(&space_id_str, feature_set_ids) + .get_resources_for_grants(&space_id_str, feature_set_ids, session_id) .await?; let resources: Vec = allowed_features @@ -184,6 +227,7 @@ impl RoutingService { &self, space_id: Uuid, feature_set_ids: &[String], + session_id: Option<&str>, tool_name: &str, arguments: Value, ) -> Result { @@ -196,10 +240,10 @@ impl RoutingService { .await? .ok_or_else(|| anyhow!("Tool '{}' not found", tool_name))?; - // 2. Check if the tool is allowed by grants + // 2. Check if the tool is allowed by grants (session overrides included) let allowed_features = self .feature_service - .resolve_feature_sets(&space_id_str, feature_set_ids) + .get_invokable_tools_for_grants(&space_id_str, feature_set_ids, session_id) .await?; info!( @@ -235,10 +279,12 @@ impl RoutingService { "[RoutingService] Tool '{}' NOT allowed. Looking for server_id='{}', feature_name='{}', is_available=true", tool_name, server_id, actual_tool_name ); - return Err(anyhow!( - "Tool '{}' is not allowed by the current grants", - tool_name - )); + return Err(anyhow!(format_invoke_permission_denied( + tool_name, + &server_id, + &actual_tool_name, + &[], + ))); } info!("[RoutingService] Tool '{}' is ALLOWED", tool_name); @@ -300,6 +346,7 @@ impl RoutingService { Ok(ToolCallResult { content, + structured_content: res.structured_content, is_error: res.is_error.unwrap_or(false), }) } diff --git a/crates/mcpmux-gateway/src/pool/service_factory.rs b/crates/mcpmux-gateway/src/pool/service_factory.rs index 99da9ad0..19ff52c0 100644 --- a/crates/mcpmux-gateway/src/pool/service_factory.rs +++ b/crates/mcpmux-gateway/src/pool/service_factory.rs @@ -44,6 +44,7 @@ impl ServiceFactory { deps: &GatewayDependencies, event_tx: tokio::sync::broadcast::Sender, prefix_cache: Arc, + session_overrides: Arc, ) -> PoolServices { // TokenService - single source of truth for token management let token_service = Arc::new(TokenService::new( @@ -80,7 +81,8 @@ impl ServiceFactory { let feature_service = Arc::new(FeatureService::new( deps.feature_repo.clone(), deps.feature_set_repo.clone(), - prefix_cache.clone(), // Clone here since we use it again below + prefix_cache.clone(), + session_overrides, )); // ServerManager - event-driven orchestrator for server state diff --git a/crates/mcpmux-gateway/src/server/handlers.rs b/crates/mcpmux-gateway/src/server/handlers.rs index 09f73593..3aa2db25 100644 --- a/crates/mcpmux-gateway/src/server/handlers.rs +++ b/crates/mcpmux-gateway/src/server/handlers.rs @@ -1087,7 +1087,7 @@ pub async fn oauth_get_client_features( .services .pool_services .feature_service - .get_tools_for_grants(&space_id_str, &feature_set_ids) + .get_tools_for_grants(&space_id_str, &feature_set_ids, None) .await .unwrap_or_default(); @@ -1095,7 +1095,7 @@ pub async fn oauth_get_client_features( .services .pool_services .feature_service - .get_prompts_for_grants(&space_id_str, &feature_set_ids) + .get_prompts_for_grants(&space_id_str, &feature_set_ids, None) .await .unwrap_or_default(); @@ -1103,7 +1103,7 @@ pub async fn oauth_get_client_features( .services .pool_services .feature_service - .get_resources_for_grants(&space_id_str, &feature_set_ids) + .get_resources_for_grants(&space_id_str, &feature_set_ids, None) .await .unwrap_or_default(); diff --git a/crates/mcpmux-gateway/src/server/mod.rs b/crates/mcpmux-gateway/src/server/mod.rs index 4bf01dfc..64124e38 100644 --- a/crates/mcpmux-gateway/src/server/mod.rs +++ b/crates/mcpmux-gateway/src/server/mod.rs @@ -84,6 +84,9 @@ pub struct GatewayServer { config: GatewayConfig, state: Arc>, services: ServiceContainer, + /// Shared with the MCP handler and the desktop layer for session-scoped + /// list_changed pushes after override mutations. + notification_bridge: Arc, } impl GatewayServer { @@ -118,12 +121,19 @@ impl GatewayServer { // Initialize all services using DI container (pass domain event sender for non-blocking emission) let services = ServiceContainer::initialize(&dependencies, domain_event_tx, state.clone()); + let notification_bridge = Arc::new(MCPNotifier::new( + services.feature_set_resolver.clone(), + services.pool_services.feature_service.clone(), + services.session_overrides.clone(), + )); + info!("[Gateway] Services initialized successfully"); Self { config, state, services, + notification_bridge, } } @@ -185,6 +195,16 @@ impl GatewayServer { self.services.session_roots.clone() } + /// Session-scoped enable/disable overrides (meta-tool mutations). + pub fn session_overrides(&self) -> Arc { + self.services.session_overrides.clone() + } + + /// Notification bridge for per-session list_changed after override clears. + pub fn notification_bridge(&self) -> Arc { + self.notification_bridge.clone() + } + /// Get the OAuth manager pub fn oauth_manager(&self) -> Arc { self.services.pool_services.oauth_manager.clone() @@ -226,18 +246,11 @@ impl GatewayServer { base_url: self.config.base_url(), }; - // Create MCP notifier (session-keyed fanout, consults the same - // FeatureSet resolver the request handlers use). - let notification_bridge = Arc::new(MCPNotifier::new( - self.services.feature_set_resolver.clone(), - self.services.pool_services.feature_service.clone(), - )); - // Start listening to DomainEvents { let gw_state = tokio::task::block_in_place(|| state.blocking_read()); let event_rx = gw_state.subscribe_domain_events(); - notification_bridge.clone().start(event_rx); + self.notification_bridge.clone().start(event_rx); } // Create OAuth event handler (updates oauth_connected flag on OAuth success) @@ -255,8 +268,10 @@ impl GatewayServer { } // Create MCP handler - let handler = - McpMuxGatewayHandler::new(Arc::new(self.services.clone()), notification_bridge.clone()); + let handler = McpMuxGatewayHandler::new( + Arc::new(self.services.clone()), + self.notification_bridge.clone(), + ); // Create STATEFUL MCP service (full Streamable HTTP per spec 2025-11-25) // stateful_mode: true means: diff --git a/crates/mcpmux-gateway/src/server/service_container.rs b/crates/mcpmux-gateway/src/server/service_container.rs index d0b6d98c..8189c2dd 100644 --- a/crates/mcpmux-gateway/src/server/service_container.rs +++ b/crates/mcpmux-gateway/src/server/service_container.rs @@ -9,7 +9,7 @@ use crate::pool::{PoolServices, ServerManager, ServiceFactory}; use crate::services::{ meta_tools, ApprovalBroker, AuthorizationService, ClientMetadataService, FeatureSetResolverService, GrantService, MetaToolRegistry, PrefixCacheService, - SessionRootsRegistry, SpaceResolverService, + SessionOverrideRegistry, SessionRootsRegistry, SpaceResolverService, }; use mcpmux_core::DomainEvent; @@ -40,6 +40,9 @@ pub struct ServiceContainer { /// Registry of per-session workspace roots (populated from MCP `roots/list`). pub session_roots: Arc, + /// Per-session server enable/disable overrides (in-memory, process-lifetime). + pub session_overrides: 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, @@ -82,10 +85,12 @@ impl ServiceContainer { )); // Create pool services using factory (pass event_tx and prefix_cache) + let session_overrides = SessionOverrideRegistry::new(); let pool_services = ServiceFactory::create_pool_services( deps, domain_event_tx.clone(), prefix_cache_service.clone(), + session_overrides.clone(), ); // Extract server_manager before moving pool_services @@ -126,9 +131,14 @@ impl ServiceContainer { deps.feature_set_repo.clone(), deps.workspace_binding_repo.clone(), deps.feature_repo.clone(), + deps.installed_server_repo.clone(), feature_set_resolver.clone(), pool_services.feature_service.clone(), + Some(meta_tools::routing_as_invoke_backend( + pool_services.routing_service.clone(), + )), session_roots.clone(), + session_overrides.clone(), approval_broker.clone(), domain_event_tx.clone(), deps.settings_repo.clone(), @@ -157,6 +167,7 @@ impl ServiceContainer { authorization_service, feature_set_resolver, session_roots, + session_overrides, approval_broker, meta_tool_registry, space_resolver_service, diff --git a/crates/mcpmux-gateway/src/server/startup.rs b/crates/mcpmux-gateway/src/server/startup.rs index 9c431441..4edb1190 100644 --- a/crates/mcpmux-gateway/src/server/startup.rs +++ b/crates/mcpmux-gateway/src/server/startup.rs @@ -6,6 +6,7 @@ use std::sync::Arc; use anyhow::Result; +use mcpmux_core::domain::{AuthConfig, CredentialType, ServerDefinition, TransportConfig}; use mcpmux_core::InstalledServer; use tracing::{info, warn}; @@ -230,16 +231,17 @@ impl StartupOrchestrator { let space_id = uuid::Uuid::parse_str(&server.space_id) .map_err(|e| anyhow::anyhow!("Invalid space_id: {}", e))?; - // Check if server requires OAuth but hasn't been approved yet - // This prevents auto-connect from setting "Connected" status without user approval - let requires_oauth = matches!( - definition.auth, - Some(mcpmux_core::domain::AuthConfig::Oauth) - ); + let requires_oauth = matches!(definition.auth, Some(AuthConfig::Oauth)); - if requires_oauth && !server.oauth_connected { + if should_skip_oauth_autoconnect( + requires_oauth, + server.oauth_connected, + is_stdio_transport(&definition), + self.has_mux_oauth_credentials(space_id, &server.server_id) + .await?, + ) { info!( - "[Startup] Skipping {}/{} - requires OAuth approval", + "[Startup] Skipping {}/{} - HTTP OAuth with no stored credentials and no prior approval", server.space_id, server.server_id ); let key = crate::pool::ServerKey::new(space_id, server.server_id.clone()); @@ -270,10 +272,27 @@ impl StartupOrchestrator { match connection_result { ConnectionResult::Connected { reused, features } => { - // Explicitly update ServerManager status to Connected - // While PoolService might update instance state, ServerManager is the source of truth for UI events self.server_manager.set_connected(&key, features).await; + if requires_oauth && !server.oauth_connected { + if let Err(e) = self + .dependencies + .installed_server_repo + .set_oauth_connected(&server.id, true) + .await + { + warn!( + "[Startup] Connected {}/{} but failed to set oauth_connected: {}", + server.space_id, server.server_id, e + ); + } else { + info!( + "[Startup] Bootstrapped oauth_connected for {}/{} after credential-based connect", + server.space_id, server.server_id + ); + } + } + if reused { Ok(ConnectOutcome::AlreadyConnected) } else { @@ -317,3 +336,55 @@ enum ConnectOutcome { AlreadyConnected, NeedsOAuth, } + +impl StartupOrchestrator { + /// Whether mux has a stored OAuth access token for this install. + async fn has_mux_oauth_credentials( + &self, + space_id: uuid::Uuid, + server_id: &str, + ) -> Result { + Ok(self + .dependencies + .credential_repo + .get(&space_id, server_id, &CredentialType::AccessToken) + .await? + .is_some()) + } +} + +/// Stdio MCPs manage auth inside the child process; do not gate on `oauth_connected`. +fn is_stdio_transport(definition: &ServerDefinition) -> bool { + matches!(definition.transport, TransportConfig::Stdio { .. }) +} + +/// Skip auto-connect and show Connect Required only for HTTP OAuth with no mux tokens +/// and no prior user approval (`oauth_connected`). +fn should_skip_oauth_autoconnect( + requires_oauth: bool, + oauth_connected: bool, + is_stdio: bool, + has_mux_credentials: bool, +) -> bool { + if !requires_oauth { + return false; + } + if is_stdio { + return false; + } + !oauth_connected && !has_mux_credentials +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn skip_only_http_oauth_without_credentials_or_approval() { + assert!(!should_skip_oauth_autoconnect(false, false, false, false)); + assert!(!should_skip_oauth_autoconnect(true, false, true, false)); + assert!(!should_skip_oauth_autoconnect(true, true, false, false)); + assert!(!should_skip_oauth_autoconnect(true, false, false, true)); + assert!(should_skip_oauth_autoconnect(true, false, false, false)); + } +} diff --git a/crates/mcpmux-gateway/src/services/meta_tools/diff.rs b/crates/mcpmux-gateway/src/services/meta_tools/diff.rs index 43044303..f3e9d90f 100644 --- a/crates/mcpmux-gateway/src/services/meta_tools/diff.rs +++ b/crates/mcpmux-gateway/src/services/meta_tools/diff.rs @@ -65,7 +65,7 @@ impl ToolDiff { 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) + .get_tools_for_grants(&space_id_str, &ids, None) .await?; Ok(features .iter() diff --git a/crates/mcpmux-gateway/src/services/meta_tools/invoke.rs b/crates/mcpmux-gateway/src/services/meta_tools/invoke.rs new file mode 100644 index 00000000..1f24f231 --- /dev/null +++ b/crates/mcpmux-gateway/src/services/meta_tools/invoke.rs @@ -0,0 +1,692 @@ +//! `mcpmux_invoke_tool` — permission-checked gateway into backend MCP tools. + +use async_trait::async_trait; +use rmcp::model::{CallToolResult, Content}; +use serde_json::{json, Map, Value}; + +use super::registry::{MetaTool, MetaToolCall, MetaToolError}; +use super::tools::{caller_resolution, caller_space_id}; +use crate::pool::{format_invoke_permission_denied, format_server_inactive_error}; +use crate::services::tool_discovery::ToolDiscoveryService; +use mcpmux_core::FeatureType; + +/// Object keys that commonly hold large list payloads from backend tools. +const HEAVY_ARRAY_KEYS: &[&str] = &[ + "items", "data", "results", "rows", "records", "issues", "entries", "values", "list", +]; + +/// Optional post-processing controls for invoke results. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct InvokeResultFilter { + pub max_rows: Option, + pub max_bytes: Option, + pub fields: Option>, + pub format: Option, +} + +/// Parse the optional `filter` object from `mcpmux_invoke_tool` arguments. +pub fn parse_invoke_filter(value: Option<&Value>) -> Option { + let filter = value?; + if !filter.is_object() { + return None; + } + + Some(InvokeResultFilter { + max_rows: filter + .get("max_rows") + .and_then(|v| v.as_u64()) + .map(|n| n as usize), + max_bytes: filter + .get("max_bytes") + .and_then(|v| v.as_u64()) + .map(|n| n as usize), + fields: filter.get("fields").and_then(|v| { + v.as_array().map(|arr| { + arr.iter() + .filter_map(|item| item.as_str().map(str::to_string)) + .collect() + }) + }), + format: filter + .get("format") + .and_then(|v| v.as_str()) + .map(str::to_string), + }) +} + +impl InvokeResultFilter { + fn is_summary(&self) -> bool { + self.format.as_deref() == Some("summary") + } +} + +/// Post-process routed tool output before returning it to the MCP client. +pub fn apply_invoke_result_filter( + content: Vec, + structured_content: Option, + filter: &InvokeResultFilter, +) -> (Vec, Option) { + let shaped_structured = structured_content.map(|value| shape_json_value(value, filter)); + let shaped_content = content + .into_iter() + .map(|block| shape_content_block(block, filter)) + .collect(); + (shaped_content, shaped_structured) +} + +/// Meta tool that forwards invocations to [`RoutingService::call_tool`]. +pub struct InvokeToolTool; + +#[async_trait] +impl MetaTool for InvokeToolTool { + fn name(&self) -> &'static str { + "mcpmux_invoke_tool" + } + + fn description(&self) -> &'static str { + "Invoke a backend MCP tool by server_id and tool name. Requires the \ + server to be active (binding or session enable) and the tool to be \ + in the current permission set. Use mcpmux_search_tools and \ + mcpmux_get_tool_schema before calling. Pass an optional filter object \ + to bound large payloads; omit filter to return the backend response as-is." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["server_id", "tool"], + "properties": { + "server_id": { + "type": "string", + "description": "Registry server id (e.g. github)" + }, + "tool": { + "type": "string", + "description": "Bare tool name on that server (e.g. list_issues), not the qualified name" + }, + "args": { + "type": "object", + "description": "Arguments object passed to the backend tool", + "default": {} + }, + "filter": { + "type": "object", + "description": "Optional result shaping (max_rows, max_bytes, fields, format). Omit to return the backend response as-is.", + "properties": { + "max_rows": { + "type": "integer", + "minimum": 1, + "description": "Maximum rows/items to return from large arrays" + }, + "max_bytes": { + "type": "integer", + "minimum": 1, + "description": "Maximum UTF-8 bytes for text or serialized JSON payloads" + }, + "fields": { + "type": "array", + "items": { "type": "string" }, + "description": "When set, keep only these fields on each object in list results" + }, + "format": { + "type": "string", + "enum": ["summary", "full"], + "description": "When max_rows is set: summary caps the sample at min(max_rows, 5); full returns up to max_rows rows. Ignored when max_rows is omitted." + } + } + } + } + }) + } + + fn is_write(&self) -> bool { + false + } + + async fn call(&self, call: MetaToolCall<'_>) -> Result { + let server_id = call + .args + .get("server_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| MetaToolError::InvalidArgument("missing `server_id`".into()))? + .to_string(); + let tool_name = call + .args + .get("tool") + .and_then(|v| v.as_str()) + .ok_or_else(|| MetaToolError::InvalidArgument("missing `tool`".into()))? + .to_string(); + let args = call.args.get("args").cloned().unwrap_or_else(|| json!({})); + let filter = parse_invoke_filter(call.args.get("filter")); + + let resolved = caller_resolution(&call).await?; + let space_id = caller_space_id(&call).await?; + let session_id = call.session_id; + + let invokable = call + .ctx + .feature_service + .get_invokable_tools_for_grants( + &space_id.to_string(), + &resolved.feature_set_ids, + session_id, + ) + .await + .map_err(|e| MetaToolError::Internal(e.to_string()))?; + + let binding_features = call + .ctx + .feature_service + .resolve_feature_sets(&space_id.to_string(), &resolved.feature_set_ids) + .await + .map_err(|e| MetaToolError::Internal(e.to_string()))?; + let binding_servers: std::collections::HashSet = binding_features + .iter() + .map(|f| f.server_id.clone()) + .collect(); + let session_enabled = session_id + .map(|sid| call.ctx.session_overrides.enabled_set(sid)) + .unwrap_or_default(); + let session_disabled = session_id + .map(|sid| call.ctx.session_overrides.disabled_set(sid)) + .unwrap_or_default(); + + let is_server_active = binding_servers.contains(&server_id) + || (session_enabled.contains(&server_id) && !session_disabled.contains(&server_id)); + + if session_disabled.contains(&server_id) { + return Ok(invoke_error(format!( + "server '{server_id}' is disabled for this session → mcpmux_enable_server({{ \"server_id\": \"{server_id}\" }})" + ))); + } + + if !is_server_active { + return Ok(invoke_error(format_server_inactive_error(&server_id))); + } + + let qualified_name = invokable + .iter() + .find(|f| f.server_id == server_id && f.feature_name == tool_name) + .map(|f| f.qualified_name()) + .unwrap_or_else(|| format!("{server_id}_{tool_name}")); + let is_invokable = invokable.iter().any(|f| { + f.feature_type == FeatureType::Tool + && f.server_id == server_id + && f.feature_name == tool_name + && f.is_available + }); + + if !is_invokable { + let index = call + .ctx + .tool_discovery + .build_index(&space_id.to_string(), &invokable) + .await + .map_err(|e| MetaToolError::Internal(e.to_string()))?; + let suggestions: Vec = ToolDiscoveryService::search( + &index, + Some(&tool_name), + Some(&server_id), + crate::services::tool_discovery::DetailLevel::Name, + 5, + None, + ) + .tools + .iter() + .filter_map(|v| { + v.get("qualified_name") + .and_then(|n| n.as_str().map(String::from)) + }) + .collect(); + return Ok(invoke_error(format_invoke_permission_denied( + &qualified_name, + &server_id, + &tool_name, + &suggestions, + ))); + } + + let backend = call + .ctx + .invoke_backend + .as_ref() + .ok_or_else(|| MetaToolError::Internal("invoke routing not configured".into()))?; + match backend + .call_tool( + space_id, + &resolved.feature_set_ids, + session_id, + &qualified_name, + args, + ) + .await + { + Ok(result) => { + if result.is_error { + let content: Vec = result + .content + .into_iter() + .filter_map(|v| serde_json::from_value(v).ok()) + .collect(); + let mut mcp_result = CallToolResult::error(content); + mcp_result.structured_content = result.structured_content; + return Ok(mcp_result); + } + + let (content, structured_content) = if let Some(ref filter) = filter { + apply_invoke_result_filter(result.content, result.structured_content, filter) + } else { + (result.content, result.structured_content) + }; + let parsed_content: Vec = content + .into_iter() + .filter_map(|v| serde_json::from_value(v).ok()) + .collect(); + let mut mcp_result = CallToolResult::success(parsed_content); + mcp_result.structured_content = structured_content; + Ok(mcp_result) + } + Err(e) => Ok(invoke_error(e.to_string())), + } + } +} + +/// Shape one MCP content block (typically `{ "type": "text", "text": "..." }`). +fn shape_content_block(block: Value, filter: &InvokeResultFilter) -> Value { + let Some(text) = block.get("text").and_then(|v| v.as_str()) else { + return block; + }; + + if let Ok(parsed) = serde_json::from_str::(text) { + let shaped = shape_json_value(parsed, filter); + return json!({ + "type": "text", + "text": shaped.to_string(), + }); + } + + let Some(max_bytes) = filter.max_bytes else { + return block; + }; + if text.len() <= max_bytes { + return block; + } + + let envelope = byte_truncation_envelope(text, max_bytes); + json!({ + "type": "text", + "text": envelope.to_string(), + }) +} + +/// Shape a JSON value, applying truncation when explicit filter limits are set. +pub fn shape_json_value(value: Value, filter: &InvokeResultFilter) -> Value { + match value { + Value::Array(items) => shape_array(items, filter, "items"), + Value::Object(map) => shape_object(map, filter), + other => enforce_byte_limit(other, filter), + } +} + +fn shape_object(map: Map, filter: &InvokeResultFilter) -> Value { + for key in HEAVY_ARRAY_KEYS { + if let Some(Value::Array(items)) = map.get(*key).cloned() { + if should_truncate(items.len(), filter) { + return shape_object_with_truncated_array(map, key, items, filter); + } + } + } + + for (key, value) in &map { + if let Value::Array(items) = value { + if should_truncate(items.len(), filter) { + return shape_object_with_truncated_array(map.clone(), key, items.clone(), filter); + } + } + } + + enforce_byte_limit(Value::Object(map), filter) +} + +fn shape_object_with_truncated_array( + mut map: Map, + array_key: &str, + items: Vec, + filter: &InvokeResultFilter, +) -> Value { + let shaped_array = shape_array(items, filter, array_key); + if let Value::Object(truncation) = &shaped_array { + if truncation.get("truncated") == Some(&Value::Bool(true)) { + for (meta_key, meta_value) in truncation { + if meta_key != array_key { + map.insert(meta_key.clone(), meta_value.clone()); + } + } + if let Some(data) = truncation.get(array_key) { + map.insert(array_key.to_string(), data.clone()); + } + return enforce_byte_limit(Value::Object(map), filter); + } + } + + map.insert(array_key.to_string(), shaped_array); + enforce_byte_limit(Value::Object(map), filter) +} + +fn shape_array(items: Vec, filter: &InvokeResultFilter, data_key: &str) -> Value { + let total = items.len(); + let filtered_items = apply_fields_filter(items, filter); + + let Some(max_rows) = filter.max_rows else { + return enforce_byte_limit(Value::Array(filtered_items), filter); + }; + + if total <= max_rows { + return enforce_byte_limit(Value::Array(filtered_items), filter); + } + + let sample_size = if filter.is_summary() { + max_rows.min(5) + } else { + max_rows + }; + let sample: Vec = filtered_items.into_iter().take(sample_size).collect(); + let returned = sample.len(); + + json!({ + "returned": returned, + "total": total, + "truncated": true, + data_key: sample, + }) +} + +fn apply_fields_filter(items: Vec, filter: &InvokeResultFilter) -> Vec { + let Some(fields) = &filter.fields else { + return items; + }; + + items + .into_iter() + .map(|item| pick_fields(item, fields)) + .collect() +} + +fn pick_fields(value: Value, fields: &[String]) -> Value { + let Value::Object(map) = value else { + return value; + }; + + let mut picked = Map::new(); + for field in fields { + if let Some(v) = map.get(field) { + picked.insert(field.clone(), v.clone()); + } + } + Value::Object(picked) +} + +fn should_truncate(length: usize, filter: &InvokeResultFilter) -> bool { + match filter.max_rows { + Some(max_rows) => length > max_rows, + None => false, + } +} + +fn enforce_byte_limit(value: Value, filter: &InvokeResultFilter) -> Value { + let Some(max_bytes) = filter.max_bytes else { + return value; + }; + + let serialized = value.to_string(); + if serialized.len() <= max_bytes { + return value; + } + + byte_truncation_envelope(&serialized, max_bytes) +} + +/// Build a `{ returned, total, truncated, text }` envelope for byte-capped plain text or JSON. +fn byte_truncation_envelope(text: &str, max_bytes: usize) -> Value { + let total_bytes = text.len(); + let mut truncated = text.to_string(); + truncated.truncate(max_bytes); + truncated.push_str("...[truncated]"); + json!({ + "returned": truncated.len(), + "total": total_bytes, + "truncated": true, + "text": truncated, + }) +} + +/// Build a structured MCP error payload for invoke failures. +fn invoke_error(message: String) -> CallToolResult { + let payload = json!({ + "error": "invoke_failed", + "message": message, + }); + CallToolResult::error(vec![Content::text(payload.to_string())]) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn issue_rows(count: usize) -> Vec { + (0..count) + .map(|i| { + json!({ + "id": i, + "title": format!("issue-{i}"), + "body": format!("body-{i}") + }) + }) + .collect() + } + + #[test] + fn no_filter_passes_through_large_array() { + let items: Vec = (0..100).map(|i| json!({ "id": i, "name": format!("n{i}") })).collect(); + let shaped = shape_json_value(Value::Array(items.clone()), &InvokeResultFilter::default()); + assert_eq!(shaped, Value::Array(items)); + } + + #[test] + fn explicit_max_rows_truncates_top_level_array() { + let items: Vec = issue_rows(20); + let filter = InvokeResultFilter { + max_rows: Some(3), + ..Default::default() + }; + let shaped = shape_json_value(Value::Array(items), &filter); + assert_eq!(shaped.get("returned"), Some(&json!(3))); + assert_eq!(shaped.get("total"), Some(&json!(20))); + assert_eq!(shaped.get("truncated"), Some(&json!(true))); + let sample = shaped.get("items").and_then(|v| v.as_array()).unwrap(); + assert_eq!(sample.len(), 3); + } + + #[test] + fn explicit_max_rows_truncates_nested_issues_key() { + let issues = issue_rows(20); + let filter = InvokeResultFilter { + max_rows: Some(3), + ..Default::default() + }; + let shaped = shape_json_value(json!({ "issues": issues }), &filter); + assert_eq!(shaped.get("returned"), Some(&json!(3))); + assert_eq!(shaped.get("total"), Some(&json!(20))); + assert_eq!(shaped.get("truncated"), Some(&json!(true))); + let sample = shaped.get("issues").and_then(|v| v.as_array()).unwrap(); + assert_eq!(sample.len(), 3); + } + + #[test] + fn json_in_text_block_truncates_with_metadata() { + let rows: Vec = (0..80).map(|i| json!({ "n": i })).collect(); + let content = vec![json!({ + "type": "text", + "text": json!({ "results": rows }).to_string(), + })]; + let filter = parse_invoke_filter(Some(&json!({ "max_rows": 10 }))).unwrap(); + + let (shaped_content, _) = apply_invoke_result_filter(content, None, &filter); + let text = shaped_content[0].get("text").and_then(|t| t.as_str()).unwrap(); + let parsed: Value = serde_json::from_str(text).unwrap(); + + assert_eq!(parsed.get("returned"), Some(&json!(10))); + assert_eq!(parsed.get("total"), Some(&json!(80))); + assert_eq!(parsed.get("truncated"), Some(&json!(true))); + } + + #[test] + fn structured_content_and_text_both_shaped() { + let items = issue_rows(20); + let structured = json!({ "items": items }); + let content = vec![json!({ + "type": "text", + "text": structured.to_string(), + })]; + let filter = InvokeResultFilter { + max_rows: Some(5), + fields: Some(vec!["id".into(), "title".into()]), + ..Default::default() + }; + + let (shaped_content, shaped_structured) = + apply_invoke_result_filter(content, Some(structured), &filter); + + let parsed_text: Value = + serde_json::from_str(shaped_content[0].get("text").and_then(|t| t.as_str()).unwrap()) + .unwrap(); + assert_eq!(parsed_text.get("returned"), Some(&json!(5))); + assert_eq!(parsed_text.get("total"), Some(&json!(20))); + + let shaped = shaped_structured.unwrap(); + let structured_sample = shaped.get("items").and_then(|v| v.as_array()).unwrap(); + assert_eq!(structured_sample.len(), 5); + assert_eq!(structured_sample[0], json!({ "id": 0, "title": "issue-0" })); + } + + #[test] + fn fields_filter_keeps_only_requested_columns() { + let items = vec![ + json!({ "id": 1, "name": "a", "secret": "x" }), + json!({ "id": 2, "name": "b", "secret": "y" }), + ]; + let filter = InvokeResultFilter { + fields: Some(vec!["id".into(), "name".into()]), + ..Default::default() + }; + let shaped = shape_json_value(Value::Array(items), &filter); + let kept = shaped.as_array().unwrap(); + assert_eq!(kept[0], json!({ "id": 1, "name": "a" })); + assert_eq!(kept[1], json!({ "id": 2, "name": "b" })); + } + + #[test] + fn max_rows_and_fields_together() { + let items: Vec = (0..30) + .map(|i| json!({ "id": i, "label": format!("row-{i}") })) + .collect(); + let filter = parse_invoke_filter(Some(&json!({ "max_rows": 5, "fields": ["id"] }))).unwrap(); + let shaped = shape_json_value(Value::Array(items), &filter); + + assert_eq!(shaped.get("returned"), Some(&json!(5))); + assert_eq!(shaped.get("total"), Some(&json!(30))); + assert_eq!(shaped.get("truncated"), Some(&json!(true))); + let sample = shaped.get("items").and_then(|v| v.as_array()).unwrap(); + assert_eq!(sample.len(), 5); + assert_eq!(sample[0], json!({ "id": 0 })); + } + + #[test] + fn summary_format_no_op_when_max_rows_at_most_five() { + let items = issue_rows(20); + let filter = InvokeResultFilter { + max_rows: Some(3), + format: Some("summary".into()), + ..Default::default() + }; + let shaped = shape_json_value(Value::Array(items), &filter); + assert_eq!(shaped.get("returned"), Some(&json!(3))); + } + + #[test] + fn summary_format_caps_sample_at_five() { + let items = issue_rows(20); + let filter = InvokeResultFilter { + max_rows: Some(10), + format: Some("summary".into()), + ..Default::default() + }; + let shaped = shape_json_value(Value::Array(items), &filter); + assert_eq!(shaped.get("returned"), Some(&json!(5))); + assert_eq!(shaped.get("total"), Some(&json!(20))); + } + + #[test] + fn full_format_returns_up_to_max_rows() { + let items = issue_rows(20); + let filter = InvokeResultFilter { + max_rows: Some(10), + format: Some("full".into()), + ..Default::default() + }; + let shaped = shape_json_value(Value::Array(items), &filter); + assert_eq!(shaped.get("returned"), Some(&json!(10))); + let sample = shaped.get("items").and_then(|v| v.as_array()).unwrap(); + assert_eq!(sample.len(), 10); + } + + #[test] + fn parse_invoke_filter_ignores_invalid_types() { + let filter = parse_invoke_filter(Some(&json!({ + "max_rows": "not-a-number", + "max_bytes": true, + "fields": "id", + "format": 123 + }))) + .unwrap(); + assert_eq!(filter.max_rows, None); + assert_eq!(filter.max_bytes, None); + assert_eq!(filter.fields, None); + assert_eq!(filter.format, None); + } + + #[test] + fn parse_invoke_filter_accepts_partial_objects() { + let filter = parse_invoke_filter(Some(&json!({ "max_rows": 3 }))).unwrap(); + assert_eq!(filter.max_rows, Some(3)); + assert_eq!(filter.max_bytes, None); + } + + #[test] + fn max_bytes_only_truncates_top_level_json_array() { + let items: Vec = (0..50) + .map(|i| json!({ "id": i, "label": format!("row-{i}-padding") })) + .collect(); + let filter = InvokeResultFilter { + max_bytes: Some(512), + ..Default::default() + }; + let shaped = shape_json_value(Value::Array(items), &filter); + assert_eq!(shaped.get("truncated"), Some(&json!(true))); + assert!(shaped.get("total").and_then(|v| v.as_u64()).unwrap_or(0) > 512); + } + + #[test] + fn plain_text_byte_trunc_includes_metadata() { + let text = "x".repeat(100); + let filter = InvokeResultFilter { + max_bytes: Some(50), + ..Default::default() + }; + let block = json!({ "type": "text", "text": text }); + let shaped = shape_content_block(block, &filter); + let parsed: Value = serde_json::from_str(shaped.get("text").unwrap().as_str().unwrap()).unwrap(); + assert_eq!(parsed.get("truncated"), Some(&json!(true))); + assert_eq!(parsed.get("total"), Some(&json!(100))); + } +} diff --git a/crates/mcpmux-gateway/src/services/meta_tools/invoke_backend.rs b/crates/mcpmux-gateway/src/services/meta_tools/invoke_backend.rs new file mode 100644 index 00000000..d781fb8a --- /dev/null +++ b/crates/mcpmux-gateway/src/services/meta_tools/invoke_backend.rs @@ -0,0 +1,51 @@ +//! Pluggable backend for `mcpmux_invoke_tool` routing. + +use std::sync::Arc; + +use anyhow::Result; +use async_trait::async_trait; +use serde_json::Value; +use uuid::Uuid; + +use crate::pool::{RoutingService, ToolCallResult}; + +/// Dispatches permission-checked tool calls to a backend MCP server. +#[async_trait] +pub trait InvokeToolBackend: Send + Sync { + /// Invoke a qualified backend tool and return raw MCP content. + async fn call_tool( + &self, + space_id: Uuid, + feature_set_ids: &[String], + session_id: Option<&str>, + qualified_name: &str, + arguments: Value, + ) -> Result; +} + +#[async_trait] +impl InvokeToolBackend for RoutingService { + async fn call_tool( + &self, + space_id: Uuid, + feature_set_ids: &[String], + session_id: Option<&str>, + qualified_name: &str, + arguments: Value, + ) -> Result { + RoutingService::call_tool( + self, + space_id, + feature_set_ids, + session_id, + qualified_name, + arguments, + ) + .await + } +} + +/// Wrap a [`RoutingService`] as an [`InvokeToolBackend`] trait object. +pub fn routing_as_invoke_backend(routing: Arc) -> Arc { + routing +} diff --git a/crates/mcpmux-gateway/src/services/meta_tools/mod.rs b/crates/mcpmux-gateway/src/services/meta_tools/mod.rs index 49c151ec..5a646ddb 100644 --- a/crates/mcpmux-gateway/src/services/meta_tools/mod.rs +++ b/crates/mcpmux-gateway/src/services/meta_tools/mod.rs @@ -22,15 +22,24 @@ pub mod approval; pub mod diff; +pub mod invoke; +pub mod invoke_backend; mod registry; mod tools; +mod workspace_server; pub use approval::{ ApprovalBroker, ApprovalDecision, ApprovalPayload, ApprovalPublisher, ApprovalRequest, ApprovalScope, }; pub use diff::ToolDiff; -pub use registry::{MetaToolContext, MetaToolError, MetaToolRegistry, META_TOOLS_ENABLED_KEY}; +pub use invoke_backend::{routing_as_invoke_backend, InvokeToolBackend}; +pub use registry::{ + MetaToolContext, MetaToolError, MetaToolRegistry, META_TOOLS_ENABLED_KEY, + SESSION_OVERRIDES_REQUIRE_APPROVAL_KEY, +}; + +use crate::services::ToolDiscoveryService; /// Every built-in tool's name must start with this prefix so the handler /// can intercept it before routing to backend servers. @@ -52,22 +61,31 @@ pub fn build_default_registry( feature_set_repo: std::sync::Arc, binding_repo: std::sync::Arc, server_feature_repo: std::sync::Arc, + installed_server_repo: std::sync::Arc, resolver: std::sync::Arc, feature_service: std::sync::Arc, + invoke_backend: Option>, session_roots: std::sync::Arc, + session_overrides: std::sync::Arc, approval_broker: std::sync::Arc, domain_event_tx: tokio::sync::broadcast::Sender, settings_repo: Option>, ) -> std::sync::Arc { + let tool_discovery = + std::sync::Arc::new(ToolDiscoveryService::new(server_feature_repo.clone())); let ctx = MetaToolContext { client_repo, space_repo, feature_set_repo, binding_repo, server_feature_repo, + installed_server_repo, resolver, feature_service, + invoke_backend, + tool_discovery, session_roots, + session_overrides, approval_broker, domain_event_tx, settings_repo, @@ -77,10 +95,13 @@ pub fn build_default_registry( // Reads — no approval needed. registry.register(Box::new(tools::ListAllToolsTool)); registry.register(Box::new(tools::ListFeatureSetsTool)); - // 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::ListServersTool)); + registry.register(Box::new(tools::SearchToolsTool)); + registry.register(Box::new(tools::GetToolSchemaTool)); + registry.register(Box::new(invoke::InvokeToolTool)); + // Writes — gated by ApprovalBroker (or auto-allowed for session overrides). + registry.register(Box::new(tools::EnableServerTool)); + registry.register(Box::new(tools::DisableServerTool)); registry.register(Box::new(tools::CreateFeatureSetTool)); registry.register(Box::new(tools::BindCurrentWorkspaceTool)); 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 index 54307dd5..b6c12923 100644 --- a/crates/mcpmux-gateway/src/services/meta_tools/registry.rs +++ b/crates/mcpmux-gateway/src/services/meta_tools/registry.rs @@ -5,12 +5,12 @@ //! `tools/list` response. use std::collections::HashMap; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use async_trait::async_trait; use mcpmux_core::{ - DomainEvent, FeatureSetRepository, InboundMcpClientRepository, ServerFeatureRepository, - SpaceRepository, WorkspaceBindingRepository, + DomainEvent, FeatureSetRepository, InboundMcpClientRepository, InstalledServerRepository, + ServerFeatureRepository, SpaceRepository, WorkspaceBindingRepository, }; use rmcp::model::{CallToolResult, Tool}; use serde_json::Value; @@ -18,13 +18,21 @@ use thiserror::Error; use tokio::sync::broadcast; use super::approval::ApprovalBroker; +use super::invoke_backend::InvokeToolBackend; use crate::pool::FeatureService; -use crate::services::{FeatureSetResolverService, SessionRootsRegistry}; +use crate::services::{ + FeatureSetResolverService, SessionOverrideRegistry, SessionRootsRegistry, ToolDiscoveryService, +}; /// 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"; +/// When `"true"`, session-scope enable/disable routes through the approval +/// broker. Default (missing / unparseable): auto-allow. +pub const SESSION_OVERRIDES_REQUIRE_APPROVAL_KEY: &str = + "gateway.session_overrides_require_approval"; + /// Context injected into every meta-tool invocation. /// /// Cheap to clone (all `Arc`s); the registry holds one and hands references @@ -36,9 +44,14 @@ pub struct MetaToolContext { pub feature_set_repo: Arc, pub binding_repo: Arc, pub server_feature_repo: Arc, + pub installed_server_repo: Arc, pub resolver: Arc, pub feature_service: Arc, + /// Backend invoke path — required for `mcpmux_invoke_tool`. + pub invoke_backend: Option>, + pub tool_discovery: Arc, pub session_roots: Arc, + pub session_overrides: 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. @@ -60,6 +73,9 @@ pub struct MetaToolCall<'a> { /// JSON arguments supplied in `CallToolRequestParams.arguments`. pub args: Value, pub ctx: &'a MetaToolContext, + /// Write tools set this before returning `Ok` to override the default + /// `"allow_once"` audit decision (e.g. `"session_override"`). + pub audit_decision: Arc>>, } /// Errors a meta tool can surface that map cleanly to `CallToolResult::error`. @@ -220,16 +236,25 @@ impl MetaToolRegistry { .get(name) .ok_or_else(|| MetaToolError::InvalidArgument(format!("unknown meta tool: {name}")))?; let is_write = tool.is_write(); + let audit_decision = Arc::new(Mutex::new(None)); let call = MetaToolCall { client_id, session_id, args: args.clone(), ctx: &self.ctx, + audit_decision: audit_decision.clone(), }; let result = tool.call(call).await; let (decision, summary) = match &result { - Ok(_) if is_write => ("allow_once", format!("{name} succeeded")), + Ok(_) if is_write => ( + audit_decision + .lock() + .ok() + .and_then(|g| *g) + .unwrap_or("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")), diff --git a/crates/mcpmux-gateway/src/services/meta_tools/tools.rs b/crates/mcpmux-gateway/src/services/meta_tools/tools.rs index 95fc1112..baffc358 100644 --- a/crates/mcpmux-gateway/src/services/meta_tools/tools.rs +++ b/crates/mcpmux-gateway/src/services/meta_tools/tools.rs @@ -5,16 +5,21 @@ use async_trait::async_trait; use mcpmux_core::{ - normalize_workspace_root, DomainEvent, FeatureType, MemberMode, WorkspaceBinding, + normalize_workspace_root, DomainEvent, FeatureType, WorkspaceBinding, }; use rmcp::model::{CallToolResult, Content}; use serde_json::{json, Value}; +use std::collections::{HashMap, HashSet}; use tokio::sync::broadcast; use tracing::info; use uuid::Uuid; use super::approval::{ApprovalPayload, ApprovalScope}; -use super::registry::{MetaTool, MetaToolCall, MetaToolError}; +use super::registry::{ + MetaTool, MetaToolCall, MetaToolError, SESSION_OVERRIDES_REQUIRE_APPROVAL_KEY, +}; +use super::workspace_server::emit_workspace_binding_changed; +use crate::services::ResolvedFeatureSet; /// Fire a `FeatureSetMembersChanged` event so MCPNotifier pushes a /// `tools/list_changed` notification to every connected client in the Space. @@ -35,7 +40,7 @@ fn emit_tools_list_changed(event_tx: &broadcast::Sender, space_id: // Helpers // --------------------------------------------------------------------------- -fn text_result(v: Value) -> CallToolResult { +pub(crate) fn text_result(v: Value) -> CallToolResult { CallToolResult::success(vec![Content::text(v.to_string())]) } @@ -48,7 +53,7 @@ fn text_result(v: Value) -> CallToolResult { /// 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 { +pub(crate) async fn caller_space_id(call: &MetaToolCall<'_>) -> Result { let resolved = call .ctx .resolver @@ -64,6 +69,35 @@ async fn caller_space_id(call: &MetaToolCall<'_>) -> Result )) } +/// Full resolver output for the caller — space + binding FS ids + source. +pub(crate) async fn caller_resolution( + call: &MetaToolCall<'_>, +) -> Result { + call.ctx + .resolver + .resolve(call.session_id, Some(call.client_id)) + .await + .map_err(|e| MetaToolError::Internal(e.to_string())) +} + +/// Derive the manifest status for one server in the caller's session. +fn derive_server_status( + server_id: &str, + binding_servers: &HashSet, + session_enabled: &HashSet, + session_disabled: &HashSet, +) -> &'static str { + if session_disabled.contains(server_id) { + "disabled_via_session" + } else if session_enabled.contains(server_id) && !binding_servers.contains(server_id) { + "enabled_via_session" + } else if binding_servers.contains(server_id) { + "enabled_via_binding" + } else { + "inactive" + } +} + // --------------------------------------------------------------------------- // mcpmux_list_all_tools — read // --------------------------------------------------------------------------- @@ -77,18 +111,46 @@ impl MetaTool for ListAllToolsTool { } fn description(&self) -> &'static str { - "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}." + "Operator/diagnostic: list every tool installed in the caller's resolved \ + Space (ignores FeatureSet filter on the roster). Each entry includes \ + server_available (seen on the connected server) and invokable (callable \ + via mcpmux_invoke_tool with current grants). Agents should prefer \ + mcpmux_search_tools for discovery — only invokable tools can be invoked." } fn input_schema(&self) -> Value { - json!({ "type": "object", "properties": {} }) + json!({ + "type": "object", + "properties": { + "server_id": { + "type": "string", + "description": "Optional filter to one server id" + } + } + }) } async fn call(&self, call: MetaToolCall<'_>) -> Result { + let resolved = caller_resolution(&call).await?; let space_id = caller_space_id(&call).await?; + let server_filter = call.args.get("server_id").and_then(|v| v.as_str()); + + let invokable = call + .ctx + .feature_service + .get_invokable_tools_for_grants( + &space_id.to_string(), + &resolved.feature_set_ids, + call.session_id, + ) + .await + .map_err(|e| MetaToolError::Internal(e.to_string()))?; + let invokable_names: HashSet = invokable + .iter() + .filter(|f| f.feature_type == FeatureType::Tool) + .map(|f| f.qualified_name()) + .collect(); + let features = call .ctx .server_feature_repo @@ -97,16 +159,28 @@ impl MetaTool for ListAllToolsTool { let tools: Vec<_> = features .iter() .filter(|f| f.feature_type == FeatureType::Tool) + .filter(|f| server_filter.is_none_or(|sid| f.server_id == sid)) .map(|f| { + let qualified_name = f.qualified_name(); json!({ "server_id": f.server_id, - "qualified_name": f.qualified_name(), + "qualified_name": qualified_name, "description": f.description, - "available": f.is_available, + "server_available": f.is_available, + "invokable": invokable_names.contains(&qualified_name), }) }) .collect(); - Ok(text_result(json!({ "tools": tools }))) + let total_invokable = tools + .iter() + .filter(|t| t.get("invokable") == Some(&json!(true))) + .count(); + Ok(text_result(json!({ + "tools": tools, + "total_installed": tools.len(), + "total_invokable": total_invokable, + "hint": "Use mcpmux_search_tools for agent discovery. Only invokable tools can be invoked with current FeatureSet grants.", + }))) } } @@ -165,6 +239,370 @@ impl MetaTool for ListFeatureSetsTool { } } +// --------------------------------------------------------------------------- +// mcpmux_list_servers — read +// --------------------------------------------------------------------------- + +pub struct ListServersTool; + +#[async_trait] +impl MetaTool for ListServersTool { + fn name(&self) -> &'static str { + "mcpmux_list_servers" + } + + fn description(&self) -> &'static str { + "List every MCP server installed in the caller's resolved Space with \ + a coarse status per server: enabled_via_binding, enabled_via_session, \ + disabled_via_session, or inactive. Clone installs include optional \ + `cloned_from` (source server_id). Use before enable/disable to see \ + current routing state without loading every tool." + } + + fn input_schema(&self) -> Value { + json!({ "type": "object", "properties": {} }) + } + + async fn call(&self, call: MetaToolCall<'_>) -> Result { + let resolved = caller_resolution(&call).await?; + let space_id = resolved + .space_id + .ok_or_else(|| MetaToolError::Internal("space missing".into()))?; + + let binding_features = call + .ctx + .feature_service + .resolve_feature_sets(&space_id.to_string(), &resolved.feature_set_ids) + .await?; + let binding_servers: HashSet = binding_features + .iter() + .map(|f| f.server_id.clone()) + .collect(); + + let session_enabled = call + .session_id + .map(|sid| call.ctx.session_overrides.enabled_set(sid)) + .unwrap_or_default(); + let session_disabled = call + .session_id + .map(|sid| call.ctx.session_overrides.disabled_set(sid)) + .unwrap_or_default(); + + let features = call + .ctx + .server_feature_repo + .list_for_space(&space_id.to_string()) + .await?; + + let installed = call + .ctx + .installed_server_repo + .list_for_space(&space_id.to_string()) + .await + .map_err(|e| MetaToolError::Internal(e.to_string()))?; + // Per-server lookup of effective display name (override → server_name → tail) + // and clone lineage. Centralized so JSON output and UI agree on the label. + struct InstalledMeta { + display_name: String, + cloned_from: Option, + } + let installed_meta_by_server: HashMap = installed + .into_iter() + .map(|s| { + let display_name = s.display_name().to_string(); + ( + s.server_id, + InstalledMeta { + display_name, + cloned_from: s.cloned_from, + }, + ) + }) + .collect(); + + let mut by_server: HashMap, usize)> = HashMap::new(); + for feature in &features { + if feature.feature_type != FeatureType::Tool { + continue; + } + let entry = by_server + .entry(feature.server_id.clone()) + .or_insert((None, 0)); + if entry.0.is_none() { + entry.0 = feature.display_name.clone(); + } + entry.1 += 1; + } + + let mut servers: Vec = by_server + .into_iter() + .map(|(id, (feature_display_name, tool_count))| { + // Prefer the installed row's effective display name (override or + // server_name) so users see "Joe Calendar" instead of the catalog name. + let installed_meta = installed_meta_by_server.get(&id); + let name = installed_meta + .map(|meta| meta.display_name.clone()) + .or(feature_display_name) + .unwrap_or_else(|| id.clone()); + let status = derive_server_status( + &id, + &binding_servers, + &session_enabled, + &session_disabled, + ); + let mut entry = json!({ + "id": id, + "name": name, + "tool_count": tool_count, + "status": status, + }); + if let Some(cloned_from) = installed_meta.and_then(|meta| meta.cloned_from.as_ref()) + { + entry["cloned_from"] = json!(cloned_from); + } + entry + }) + .collect(); + servers.sort_by(|a, b| { + a.get("id") + .and_then(|v| v.as_str()) + .unwrap_or("") + .cmp(b.get("id").and_then(|v| v.as_str()).unwrap_or("")) + }); + + Ok(text_result(json!({ "servers": servers }))) + } +} + +// --------------------------------------------------------------------------- +// mcpmux_search_tools — read +// --------------------------------------------------------------------------- + +pub struct SearchToolsTool; + +#[async_trait] +impl MetaTool for SearchToolsTool { + fn name(&self) -> &'static str { + "mcpmux_search_tools" + } + + fn description(&self) -> &'static str { + "Search invokable backend tools in the caller's resolved Space. \ + Supports query substring match, optional server_id filter, \ + detail_level (name | description | schema), and pagination." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "query": { "type": "string" }, + "server_id": { "type": "string" }, + "detail_level": { + "type": "string", + "enum": ["name", "description", "schema"], + "default": "description" + }, + "limit": { "type": "integer", "minimum": 1, "maximum": 100, "default": 20 }, + "cursor": { "type": "string" } + } + }) + } + + async fn call(&self, call: MetaToolCall<'_>) -> Result { + let resolved = caller_resolution(&call).await?; + let space_id = caller_space_id(&call).await?; + + let detail_level = call + .args + .get("detail_level") + .and_then(|v| v.as_str()) + .and_then(crate::services::tool_discovery::DetailLevel::parse) + .unwrap_or(crate::services::tool_discovery::DetailLevel::Description); + + let limit = call + .args + .get("limit") + .and_then(|v| v.as_u64()) + .unwrap_or(20) as usize; + + let invokable = call + .ctx + .feature_service + .get_invokable_tools_for_grants( + &space_id.to_string(), + &resolved.feature_set_ids, + call.session_id, + ) + .await + .map_err(|e| MetaToolError::Internal(e.to_string()))?; + + let index = call + .ctx + .tool_discovery + .build_index(&space_id.to_string(), &invokable) + .await + .map_err(|e| MetaToolError::Internal(e.to_string()))?; + + let result = crate::services::tool_discovery::ToolDiscoveryService::search( + &index, + call.args.get("query").and_then(|v| v.as_str()), + call.args.get("server_id").and_then(|v| v.as_str()), + detail_level, + limit, + call.args.get("cursor").and_then(|v| v.as_str()), + ); + + Ok(text_result(json!({ + "tools": result.tools, + "next_cursor": result.next_cursor, + "total": result.total, + }))) + } +} + +// --------------------------------------------------------------------------- +// mcpmux_get_tool_schema — read +// --------------------------------------------------------------------------- + +/// Parse the `tools` argument from `mcpmux_get_tool_schema` call args. +/// +/// Accepts a qualified name string, a string array, or a JSON-encoded array +/// string (common when agents double-serialize through MCP clients). +fn parse_tool_schema_names(value: Option<&Value>) -> Result, MetaToolError> { + let Some(value) = value else { + return Err(MetaToolError::InvalidArgument( + "missing or invalid `tools` — expected string or string array".into(), + )); + }; + + match value { + Value::String(s) => { + if let Ok(Value::Array(arr)) = serde_json::from_str(s) { + return names_from_json_array(&arr); + } + Ok(vec![s.clone()]) + } + Value::Array(arr) => names_from_json_array(arr), + _ => Err(MetaToolError::InvalidArgument( + "missing or invalid `tools` — expected string or string array".into(), + )), + } +} + +/// Collect non-empty qualified tool names from a JSON string array. +fn names_from_json_array(arr: &[Value]) -> Result, MetaToolError> { + let names: Vec = arr + .iter() + .filter_map(|v| v.as_str().map(str::trim)) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect(); + if names.is_empty() { + return Err(MetaToolError::InvalidArgument( + "`tools` must contain at least one qualified name".into(), + )); + } + Ok(names) +} + +pub struct GetToolSchemaTool; + +#[async_trait] +impl MetaTool for GetToolSchemaTool { + fn name(&self) -> &'static str { + "mcpmux_get_tool_schema" + } + + fn description(&self) -> &'static str { + "Load input schemas for one or more qualified tool names before \ + invoking via mcpmux_invoke_tool. Pass tools as a single qualified \ + name string or a string array (e.g. [\"github_list_issues\"]). \ + Set compact: true to omit descriptions. Tools must be invokable \ + with current grants — use mcpmux_search_tools to discover names." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["tools"], + "properties": { + "tools": { + "oneOf": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } + ] + }, + "compact": { "type": "boolean", "default": false } + } + }) + } + + async fn call(&self, call: MetaToolCall<'_>) -> Result { + let resolved = caller_resolution(&call).await?; + let space_id = caller_space_id(&call).await?; + + let tool_names = parse_tool_schema_names(call.args.get("tools"))?; + + let compact = call + .args + .get("compact") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + let invokable = call + .ctx + .feature_service + .get_invokable_tools_for_grants( + &space_id.to_string(), + &resolved.feature_set_ids, + call.session_id, + ) + .await + .map_err(|e| MetaToolError::Internal(e.to_string()))?; + + let index = call + .ctx + .tool_discovery + .build_index(&space_id.to_string(), &invokable) + .await + .map_err(|e| MetaToolError::Internal(e.to_string()))?; + + let schemas = crate::services::tool_discovery::ToolDiscoveryService::get_schemas( + &index, + &tool_names, + compact, + ); + + let found_names: HashSet = schemas + .iter() + .filter_map(|s| { + s.get("qualified_name") + .and_then(|v| v.as_str()) + .map(str::to_string) + }) + .collect(); + let missing: Vec<&String> = tool_names + .iter() + .filter(|name| !found_names.contains(*name)) + .collect(); + + if missing.is_empty() { + return Ok(text_result(json!({ "schemas": schemas }))); + } + + let missing_list: Vec<&str> = missing.iter().map(|s| s.as_str()).collect(); + Ok(text_result(json!({ + "schemas": schemas, + "missing": missing_list, + "message": format!( + "{} tool(s) not invokable or unknown with current grants → use mcpmux_search_tools to discover allowed names", + missing.len() + ), + }))) + } +} + // --------------------------------------------------------------------------- // Writes — each goes through the ApprovalBroker before mutating state. // --------------------------------------------------------------------------- @@ -173,7 +611,7 @@ impl MetaTool for ListFeatureSetsTool { /// 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( +pub(crate) async fn with_approval( call: &MetaToolCall<'_>, tool_name: &'static str, summary: String, @@ -209,6 +647,249 @@ fn parse_uuid_arg(args: &Value, field: &str) -> Result { .map_err(|_| MetaToolError::InvalidArgument(format!("`{field}` is not a UUID: {s}"))) } +fn parse_string_arg(args: &Value, field: &str) -> Result { + args.get(field) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| MetaToolError::InvalidArgument(format!("missing `{field}`"))) +} + +/// Parse `scope` for enable/disable server tools. +fn parse_scope(args: &Value) -> Result<&'static str, MetaToolError> { + match args.get("scope").and_then(|v| v.as_str()) { + None | Some("session") => Ok("session"), + Some("workspace") => Ok("workspace"), + Some(other) => Err(MetaToolError::InvalidArgument(format!( + "invalid scope '{other}'; expected 'session' or 'workspace'" + ))), + } +} + +/// Whether session-scope server overrides require desktop approval. +async fn session_overrides_require_approval(ctx: &super::registry::MetaToolContext) -> bool { + let Some(repo) = ctx.settings_repo.as_ref() else { + return false; + }; + match repo.get(SESSION_OVERRIDES_REQUIRE_APPROVAL_KEY).await { + Ok(Some(v)) => matches!(v.as_str(), "true" | "1"), + _ => false, + } +} + +/// Ensure `server_id` has at least one feature row in the caller's Space. +async fn validate_server_in_space( + call: &MetaToolCall<'_>, + space_id: Uuid, + server_id: &str, +) -> Result<(), MetaToolError> { + let features = call + .ctx + .server_feature_repo + .list_for_space(&space_id.to_string()) + .await?; + if features.iter().any(|f| f.server_id == server_id) { + return Ok(()); + } + Err(MetaToolError::InvalidArgument(format!( + "unknown server_id '{server_id}' in this Space" + ))) +} + +fn require_session_id(call: &MetaToolCall<'_>) -> Result { + call.session_id.map(|s| s.to_string()).ok_or_else(|| { + MetaToolError::InvalidArgument("session scope requires an MCP session id".into()) + }) +} + +// --------------------------------------------------------------------------- +// mcpmux_enable_server / mcpmux_disable_server — write (session scope) +// --------------------------------------------------------------------------- + +pub struct EnableServerTool; + +#[async_trait] +impl MetaTool for EnableServerTool { + fn name(&self) -> &'static str { + "mcpmux_enable_server" + } + + fn description(&self) -> &'static str { + "Enable an MCP server. Default scope is session (ephemeral). Use \ + scope: \"workspace\" to persist on the matched workspace binding \ + (requires approval). Use mcpmux_list_servers first." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["server_id"], + "properties": { + "server_id": { "type": "string" }, + "scope": { + "type": "string", + "enum": ["session", "workspace"], + "default": "session" + } + } + }) + } + + fn is_write(&self) -> bool { + true + } + + async fn call(&self, call: MetaToolCall<'_>) -> Result { + let scope = parse_scope(&call.args)?; + let server_id = parse_string_arg(&call.args, "server_id")?; + let space_id = caller_space_id(&call).await?; + validate_server_in_space(&call, space_id, &server_id).await?; + + if scope == "workspace" { + return super::workspace_server::enable_workspace_server(call, space_id, server_id) + .await; + } + + let session_id = require_session_id(&call)?; + + if session_overrides_require_approval(call.ctx).await { + let overrides = call.ctx.session_overrides.clone(); + let server_id_for_closure = server_id.clone(); + let session_id_owned = session_id.clone(); + let summary = format!("Enable server '{server_id}' for this session"); + return with_approval( + &call, + "mcpmux_enable_server", + summary, + None, + false, + call.args.clone(), + || async move { + overrides.enable(&session_id_owned, &server_id_for_closure); + info!( + session_id = %session_id_owned, + server_id = %server_id_for_closure, + "[meta_tools] enable_server applied (approved)" + ); + Ok(text_result(json!({ + "ok": true, + "server_id": server_id_for_closure, + "scope": "session", + }))) + }, + ) + .await; + } + + call.ctx.session_overrides.enable(&session_id, &server_id); + if let Ok(mut decision) = call.audit_decision.lock() { + *decision = Some("session_override"); + } + info!( + %session_id, + server_id = %server_id, + "[meta_tools] enable_server applied" + ); + Ok(text_result(json!({ + "ok": true, + "server_id": server_id, + "scope": "session", + }))) + } +} + +pub struct DisableServerTool; + +#[async_trait] +impl MetaTool for DisableServerTool { + fn name(&self) -> &'static str { + "mcpmux_disable_server" + } + + fn description(&self) -> &'static str { + "Disable an MCP server. Default scope is session (ephemeral). Use \ + scope: \"workspace\" to remove the server-all layer from the \ + workspace binding (requires approval; custom FeatureSets must be \ + edited in the Workspaces UI)." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["server_id"], + "properties": { + "server_id": { "type": "string" }, + "scope": { + "type": "string", + "enum": ["session", "workspace"], + "default": "session" + } + } + }) + } + + fn is_write(&self) -> bool { + true + } + + async fn call(&self, call: MetaToolCall<'_>) -> Result { + let scope = parse_scope(&call.args)?; + let server_id = parse_string_arg(&call.args, "server_id")?; + let space_id = caller_space_id(&call).await?; + validate_server_in_space(&call, space_id, &server_id).await?; + + if scope == "workspace" { + return super::workspace_server::disable_workspace_server(call, space_id, server_id) + .await; + } + + let session_id = require_session_id(&call)?; + + if session_overrides_require_approval(call.ctx).await { + let overrides = call.ctx.session_overrides.clone(); + let server_id_for_closure = server_id.clone(); + let session_id_owned = session_id.clone(); + let summary = format!("Disable server '{server_id}' for this session"); + return with_approval( + &call, + "mcpmux_disable_server", + summary, + None, + false, + call.args.clone(), + || async move { + overrides.disable(&session_id_owned, &server_id_for_closure); + info!( + session_id = %session_id_owned, + server_id = %server_id_for_closure, + "[meta_tools] disable_server applied (approved)" + ); + Ok(text_result(json!({ + "ok": true, + "server_id": server_id_for_closure, + "scope": "session", + }))) + }, + ) + .await; + } + + call.ctx.session_overrides.disable(&session_id, &server_id); + if let Ok(mut decision) = call.audit_decision.lock() { + *decision = Some("session_override"); + } + info!( + %session_id, + server_id = %server_id, + "[meta_tools] disable_server applied" + ); + Ok(text_result(json!({ + "ok": true, + "server_id": server_id, + "scope": "session", + }))) + } +} + // --------------------------------------------------------------------------- // mcpmux_create_feature_set — write (creates FS, optionally activates) // --------------------------------------------------------------------------- @@ -224,7 +905,8 @@ impl MetaTool for CreateFeatureSetTool { fn description(&self) -> &'static str { "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 \ + 'firebase_deploy']). Optional surfaced_tools promotes a subset into \ + client tools/list. Returns the new FS id. To make a workspace \ actually route through this FeatureSet, follow up with \ `mcpmux_bind_current_workspace`." } @@ -239,6 +921,11 @@ impl MetaTool for CreateFeatureSetTool { "tool_qualified_names": { "type": "array", "items": { "type": "string" } + }, + "surfaced_tools": { + "type": "array", + "items": { "type": "string" }, + "description": "Optional subset of tool_qualified_names to promote into client tools/list" } } }) @@ -276,6 +963,17 @@ impl MetaTool for CreateFeatureSetTool { )); } + let surfaced_names: HashSet = call + .args + .get("surfaced_tools") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default(); + let space_id = caller_space_id(&call).await?; // Resolve qualified names → ServerFeature ids up-front so the @@ -317,17 +1015,24 @@ impl MetaTool for CreateFeatureSetTool { 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?; + let mut member = mcpmux_core::FeatureSetMember::include_feature( + &fs.id, + &feature.id.to_string(), + ); + if surfaced_names.contains(&feature.qualified_name()) { + member.surfaced = true; + } + fs.members.push(member); } + fs_repo.create(&fs).await?; + let surfaced_count = fs.members.iter().filter(|m| m.surfaced).count(); 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(), + "surfaced_count": surfaced_count, }))) }, ) @@ -407,19 +1112,46 @@ impl MetaTool for BindCurrentWorkspaceTool { true, call.args.clone(), || async move { - let binding = - WorkspaceBinding::new(normalized.clone(), space_id, fs_id.to_string()); - binding_repo.create(&binding).await?; - info!( - %space_id, - workspace_root = %normalized, - feature_set_id = %fs_id, - "[meta_tools] bind_current_workspace applied", - ); + let fs_id_str = fs_id.to_string(); + let existing = binding_repo + .list() + .await? + .into_iter() + .find(|b| b.workspace_root == normalized); + + let binding_id = if let Some(mut binding) = existing { + binding.space_id = space_id; + binding.feature_set_ids = vec![fs_id_str.clone()]; + binding.updated_at = chrono::Utc::now(); + binding_repo.update(&binding).await?; + emit_workspace_binding_changed(&event_tx, space_id, &normalized); + info!( + %space_id, + binding_id = %binding.id, + workspace_root = %normalized, + feature_set_id = %fs_id, + "[meta_tools] bind_current_workspace updated existing binding", + ); + binding.id + } else { + let binding = + WorkspaceBinding::new(normalized.clone(), space_id, fs_id_str.clone()); + let binding_id = binding.id; + binding_repo.create(&binding).await?; + info!( + %space_id, + binding_id = %binding_id, + workspace_root = %normalized, + feature_set_id = %fs_id, + "[meta_tools] bind_current_workspace created binding", + ); + binding_id + }; + emit_tools_list_changed(&event_tx, space_id); Ok(text_result(json!({ "ok": true, - "binding_id": binding.id, + "binding_id": binding_id, "workspace_root": normalized, "feature_set_id": fs_id, }))) diff --git a/crates/mcpmux-gateway/src/services/meta_tools/workspace_server.rs b/crates/mcpmux-gateway/src/services/meta_tools/workspace_server.rs new file mode 100644 index 00000000..b2683a9b --- /dev/null +++ b/crates/mcpmux-gateway/src/services/meta_tools/workspace_server.rs @@ -0,0 +1,275 @@ +//! Workspace-scope enable/disable for MCP servers via binding FeatureSets. +//! +//! Persists a per-server "all tools" FeatureSet (tagged with +//! [`FeatureSet::server_id`]) and appends it to the matched +//! [`WorkspaceBinding`]'s `feature_set_ids`. + +use mcpmux_core::{DomainEvent, FeatureSet, MemberMode, MemberType, WorkspaceBinding}; +use rmcp::model::CallToolResult; +use serde_json::json; +use tokio::sync::broadcast; +use tracing::info; +use uuid::Uuid; + +use super::registry::{MetaToolCall, MetaToolError}; +use super::tools::{text_result, with_approval}; + +/// Whether a FeatureSet is the workspace-scoped "all tools for server" row. +fn is_server_all_feature_set(fs: &FeatureSet, server_id: &str) -> bool { + !fs.is_deleted && fs.server_id.as_deref() == Some(server_id) +} + +/// Resolve the workspace binding for the caller's first reported root. +async fn resolve_workspace_binding( + call: &MetaToolCall<'_>, + space_id: Uuid, +) -> Result<(WorkspaceBinding, String), MetaToolError> { + let session_id = call.session_id.ok_or_else(|| { + MetaToolError::InvalidArgument("workspace scope requires an MCP session id".into()) + })?; + let roots = call.ctx.session_roots.get(session_id).unwrap_or_default(); + let root = roots.into_iter().next().ok_or_else(|| { + MetaToolError::InvalidArgument( + "caller did not report any MCP roots; cannot resolve workspace".into(), + ) + })?; + let normalized = mcpmux_core::normalize_workspace_root(&root); + + let binding = call + .ctx + .binding_repo + .find_longest_prefix_match(&space_id, std::slice::from_ref(&normalized)) + .await? + .ok_or_else(|| { + MetaToolError::InvalidArgument( + "no binding exists for this workspace; create one with \ + mcpmux_create_feature_set + mcpmux_bind_current_workspace first" + .into(), + ) + })?; + Ok((binding, normalized)) +} + +pub(crate) fn emit_workspace_binding_changed( + event_tx: &broadcast::Sender, + space_id: Uuid, + workspace_root: &str, +) { + let _ = event_tx.send(DomainEvent::WorkspaceBindingChanged { + space_id, + workspace_root: workspace_root.to_string(), + }); +} + +/// Enable `server_id` persistently on the caller's workspace binding. +pub async fn enable_workspace_server( + call: MetaToolCall<'_>, + space_id: Uuid, + server_id: String, +) -> Result { + let (binding, workspace_root) = resolve_workspace_binding(&call, space_id).await?; + let summary = format!( + "Enable server '{server_id}' for workspace '{workspace_root}' (persists across sessions)" + ); + + let fs_repo = call.ctx.feature_set_repo.clone(); + let binding_repo = call.ctx.binding_repo.clone(); + let server_feature_repo = call.ctx.server_feature_repo.clone(); + let event_tx = call.ctx.domain_event_tx.clone(); + let args = call.args.clone(); + + let mut binding_for_closure = binding.clone(); + let workspace_root_for_closure = workspace_root.clone(); + + with_approval( + &call, + "mcpmux_enable_server", + summary, + None, + true, + args, + || async move { + let existing = { + let sets = fs_repo.list_by_space(&space_id.to_string()).await?; + Ok::<_, MetaToolError>( + sets.into_iter() + .find(|fs| is_server_all_feature_set(fs, &server_id)), + ) + }?; + + let fs_id = if let Some(fs) = existing { + fs.id + } else { + let mut fs = + FeatureSet::new_custom(format!("{server_id} — All"), space_id.to_string()); + fs.server_id = Some(server_id.clone()); + fs.description = Some(format!("All tools from {server_id} (workspace scope)")); + + let features = server_feature_repo + .list_for_space(&space_id.to_string()) + .await? + .into_iter() + .filter(|f| f.server_id == server_id) + .collect::>(); + + fs_repo.create(&fs).await?; + for feature in &features { + fs_repo + .add_feature_member(&fs.id, &feature.id.to_string(), MemberMode::Include) + .await?; + } + fs.id + }; + + if binding_for_closure + .feature_set_ids + .iter() + .any(|id| id == &fs_id) + { + info!( + binding_id = %binding_for_closure.id, + server_id = %server_id, + "[meta_tools] enable_server workspace already bound" + ); + return Ok(text_result(json!({ + "ok": true, + "server_id": server_id, + "scope": "workspace", + "feature_set_id": fs_id, + "binding_id": binding_for_closure.id, + }))); + } + + binding_for_closure.feature_set_ids.push(fs_id.clone()); + binding_for_closure.updated_at = chrono::Utc::now(); + binding_repo.update(&binding_for_closure).await?; + emit_workspace_binding_changed(&event_tx, space_id, &workspace_root_for_closure); + info!( + binding_id = %binding_for_closure.id, + feature_set_id = %fs_id, + server_id = %server_id, + "[meta_tools] enable_server workspace applied" + ); + Ok(text_result(json!({ + "ok": true, + "server_id": server_id, + "scope": "workspace", + "feature_set_id": fs_id, + "binding_id": binding_for_closure.id, + }))) + }, + ) + .await +} + +/// Returns true when `server_id` tools are exposed via a non-server-all FS on the binding. +async fn binding_exposes_server_via_custom_fs( + call: &MetaToolCall<'_>, + binding: &WorkspaceBinding, + space_id: &str, + server_id: &str, +) -> Result { + for fs_id in &binding.feature_set_ids { + let Some(fs) = call.ctx.feature_set_repo.get(fs_id).await? else { + continue; + }; + if is_server_all_feature_set(&fs, server_id) { + continue; + } + let members = call.ctx.feature_set_repo.get_feature_members(fs_id).await?; + for member in members { + if member.member_type != MemberType::Feature { + continue; + } + let Ok(feature_id) = Uuid::parse_str(&member.member_id) else { + continue; + }; + if let Some(feature) = call.ctx.server_feature_repo.get(&feature_id).await? { + if feature.space_id == space_id && feature.server_id == server_id { + return Ok(true); + } + } + } + } + Ok(false) +} + +/// Disable `server_id` on the caller's workspace binding (server-all FS only). +pub async fn disable_workspace_server( + call: MetaToolCall<'_>, + space_id: Uuid, + server_id: String, +) -> Result { + let (binding, workspace_root) = resolve_workspace_binding(&call, space_id).await?; + + if binding_exposes_server_via_custom_fs(&call, &binding, &space_id.to_string(), &server_id) + .await? + { + return Err(MetaToolError::InvalidArgument(format!( + "server '{server_id}' is enabled via a custom FeatureSet on this binding; \ + edit or remove it in the Workspaces UI instead" + ))); + } + + let server_all_id = { + let mut found: Option = None; + for fs_id in &binding.feature_set_ids { + if let Some(fs) = call.ctx.feature_set_repo.get(fs_id).await? { + if is_server_all_feature_set(&fs, &server_id) { + found = Some(fs_id.clone()); + break; + } + } + } + found + }; + + let Some(server_all_id) = server_all_id else { + return Ok(text_result(json!({ + "ok": true, + "server_id": server_id, + "scope": "workspace", + "removed": false, + }))); + }; + + let summary = format!( + "Disable server '{server_id}' for workspace '{workspace_root}' (persistent binding change)" + ); + let binding_repo = call.ctx.binding_repo.clone(); + let event_tx = call.ctx.domain_event_tx.clone(); + let mut binding_for_closure = binding.clone(); + let args = call.args.clone(); + + with_approval( + &call, + "mcpmux_disable_server", + summary, + None, + true, + args, + || async move { + binding_for_closure + .feature_set_ids + .retain(|id| id != &server_all_id); + binding_for_closure.updated_at = chrono::Utc::now(); + binding_repo.update(&binding_for_closure).await?; + emit_workspace_binding_changed(&event_tx, space_id, &workspace_root); + info!( + binding_id = %binding_for_closure.id, + feature_set_id = %server_all_id, + server_id = %server_id, + "[meta_tools] disable_server workspace applied" + ); + Ok(text_result(json!({ + "ok": true, + "server_id": server_id, + "scope": "workspace", + "removed": true, + "feature_set_id": server_all_id, + "binding_id": binding_for_closure.id, + }))) + }, + ) + .await +} diff --git a/crates/mcpmux-gateway/src/services/mod.rs b/crates/mcpmux-gateway/src/services/mod.rs index af1edb07..00d41c8e 100644 --- a/crates/mcpmux-gateway/src/services/mod.rs +++ b/crates/mcpmux-gateway/src/services/mod.rs @@ -13,8 +13,10 @@ mod grant_service; pub mod meta_tools; mod notification_emitter; mod prefix_cache; +mod session_overrides; mod session_roots; mod space_resolver; +pub mod tool_discovery; pub use authorization::AuthorizationService; pub use client_metadata_service::ClientMetadataService; @@ -22,10 +24,13 @@ 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, + is_meta_tool, routing_as_invoke_backend, ApprovalBroker, ApprovalDecision, ApprovalPayload, + ApprovalPublisher, ApprovalRequest, ApprovalScope, InvokeToolBackend, MetaToolRegistry, + MCPMUX_PREFIX, }; pub use notification_emitter::NotificationEmitter; pub use prefix_cache::PrefixCacheService; +pub use session_overrides::{SessionOverrideEntry, SessionOverrideRegistry}; pub use session_roots::SessionRootsRegistry; pub use space_resolver::SpaceResolverService; +pub use tool_discovery::{DetailLevel, ToolDiscoveryService, ToolIndexEntry}; diff --git a/crates/mcpmux-gateway/src/services/prefix_cache.rs b/crates/mcpmux-gateway/src/services/prefix_cache.rs index f5976af6..e631252f 100644 --- a/crates/mcpmux-gateway/src/services/prefix_cache.rs +++ b/crates/mcpmux-gateway/src/services/prefix_cache.rs @@ -138,7 +138,7 @@ impl PrefixCacheService { // Sort by created_at (earliest first) // TODO: Add verified status priority when registry supports it - servers.sort_by(|a, b| a.created_at.cmp(&b.created_at)); + servers.sort_by_key(|a| a.created_at); // Clear existing cache for this space self.clear_space(space_id).await; @@ -159,11 +159,16 @@ impl PrefixCacheService { continue; } - // Get desired alias from server discovery - let desired_alias = server_discovery - .get(&server.server_id) - .await - .and_then(|s| s.alias.clone()); + let desired_alias = match server + .get_definition() + .and_then(|definition| definition.alias.clone()) + { + Some(alias) => Some(alias), + None => server_discovery + .get(&server.server_id) + .await + .and_then(|definition| definition.alias), + }; // Try to assign alias, fallback to server_id if taken let prefix = if let Some(ref alias) = desired_alias { @@ -286,18 +291,38 @@ impl PrefixCacheService { /// This is the recommended method for runtime prefix assignment. /// Returns the actual prefix assigned. pub async fn assign_prefix_for_server(&self, space_id: &str, server_id: &str) -> String { - // Fetch alias from server discovery if available - let desired_alias = if let Some(ref discovery) = self.server_discovery { - discovery.get(server_id).await.and_then(|s| s.alias.clone()) - } else { - None - }; - - // Delegate to existing assign_prefix_runtime + let desired_alias = self.resolve_desired_alias(space_id, server_id).await; self.assign_prefix_runtime(space_id, server_id, desired_alias.as_deref()) .await } + /// Resolve the preferred tool prefix alias for an installed server. + async fn resolve_desired_alias(&self, space_id: &str, server_id: &str) -> Option { + if let Some(ref installed_server_repo) = self.installed_server_repo { + if let Ok(Some(server)) = installed_server_repo + .get_by_server_id(space_id, server_id) + .await + { + if let Some(alias) = server + .get_definition() + .and_then(|definition| definition.alias) + .filter(|alias| !alias.is_empty()) + { + return Some(alias); + } + } + } + + if let Some(ref discovery) = self.server_discovery { + return discovery + .get(server_id) + .await + .and_then(|definition| definition.alias); + } + + None + } + /// Release a server's prefix (runtime only - no reassignment) pub async fn release_prefix_runtime(&self, space_id: &str, server_id: &str) { let mut caches = self.caches.write().await; diff --git a/crates/mcpmux-gateway/src/services/session_overrides.rs b/crates/mcpmux-gateway/src/services/session_overrides.rs new file mode 100644 index 00000000..e91f3e13 --- /dev/null +++ b/crates/mcpmux-gateway/src/services/session_overrides.rs @@ -0,0 +1,201 @@ +//! Session-scoped enable/disable overrides for backend MCP servers. +//! +//! When a client session calls `mcpmux_enable_server` / `mcpmux_disable_server` +//! (Phase 3), the gateway mutates this registry. [`FeatureService`] consults it +//! at list materialization time to compose the effective server set: +//! `(binding_servers ∪ enabled) − disabled`. + +use std::collections::HashSet; +use std::sync::Arc; + +use dashmap::DashMap; + +/// One session's override state for UI inspection (Phase 5). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionOverrideEntry { + pub session_id: String, + pub enabled: Vec, + pub disabled: Vec, +} + +/// Thread-safe registry mapping `mcp-session-id` to per-session server +/// enable/disable sets. Process-lifetime only — reaped with the session. +#[derive(Debug, Default)] +pub struct SessionOverrideRegistry { + enabled: DashMap>, + disabled: DashMap>, +} + +impl SessionOverrideRegistry { + /// Create a new registry wrapped in `Arc`. + pub fn new() -> Arc { + Arc::new(Self { + enabled: DashMap::new(), + disabled: DashMap::new(), + }) + } + + /// Add `server_id` to the session's enabled set; remove from disabled. + pub fn enable(&self, session_id: impl Into, server_id: impl Into) { + let session_id = session_id.into(); + let server_id = server_id.into(); + if let Some(mut disabled) = self.disabled.get_mut(&session_id) { + disabled.remove(&server_id); + if disabled.is_empty() { + drop(disabled); + self.disabled.remove(&session_id); + } + } + self.enabled + .entry(session_id) + .or_default() + .insert(server_id); + } + + /// Add `server_id` to the session's disabled set; remove from enabled. + pub fn disable(&self, session_id: impl Into, server_id: impl Into) { + let session_id = session_id.into(); + let server_id = server_id.into(); + if let Some(mut enabled) = self.enabled.get_mut(&session_id) { + enabled.remove(&server_id); + if enabled.is_empty() { + drop(enabled); + self.enabled.remove(&session_id); + } + } + self.disabled + .entry(session_id) + .or_default() + .insert(server_id); + } + + /// Drop both override sets for a session. + pub fn clear(&self, session_id: &str) { + self.enabled.remove(session_id); + self.disabled.remove(session_id); + } + + /// Enabled server ids for a session (empty when none). + pub fn enabled_set(&self, session_id: &str) -> HashSet { + self.enabled + .get(session_id) + .map(|set| set.clone()) + .unwrap_or_default() + } + + /// Disabled server ids for a session (empty when none). + pub fn disabled_set(&self, session_id: &str) -> HashSet { + self.disabled + .get(session_id) + .map(|set| set.clone()) + .unwrap_or_default() + } + + /// Drop a session's overrides — call on client disconnect / reap. + pub fn remove(&self, session_id: &str) { + self.enabled.remove(session_id); + self.disabled.remove(session_id); + } + + /// Snapshot of every session with non-empty override state. + pub fn list_all(&self) -> Vec { + let mut session_ids: HashSet = HashSet::new(); + session_ids.extend(self.enabled.iter().map(|e| e.key().clone())); + session_ids.extend(self.disabled.iter().map(|e| e.key().clone())); + + let mut out: Vec = session_ids + .into_iter() + .filter_map(|session_id| { + let enabled: Vec = self + .enabled + .get(&session_id) + .map(|set| set.iter().cloned().collect()) + .unwrap_or_default(); + let disabled: Vec = self + .disabled + .get(&session_id) + .map(|set| set.iter().cloned().collect()) + .unwrap_or_default(); + if enabled.is_empty() && disabled.is_empty() { + return None; + } + Some(SessionOverrideEntry { + session_id, + enabled, + disabled, + }) + }) + .collect(); + out.sort_by(|a, b| a.session_id.cmp(&b.session_id)); + out + } + + /// Current number of sessions with enabled overrides. Test helper. + #[cfg(test)] + pub fn enabled_session_count(&self) -> usize { + self.enabled.len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_enable_round_trip() { + let reg = SessionOverrideRegistry::default(); + reg.enable("sess-1", "github"); + let enabled = reg.enabled_set("sess-1"); + assert_eq!(enabled.len(), 1); + assert!(enabled.contains("github")); + assert!(reg.disabled_set("sess-1").is_empty()); + } + + #[test] + fn test_disable_round_trip() { + let reg = SessionOverrideRegistry::default(); + reg.disable("sess-1", "firebase"); + let disabled = reg.disabled_set("sess-1"); + assert_eq!(disabled.len(), 1); + assert!(disabled.contains("firebase")); + assert!(reg.enabled_set("sess-1").is_empty()); + } + + #[test] + fn test_enable_clears_disable_and_vice_versa() { + let reg = SessionOverrideRegistry::default(); + reg.disable("sess-1", "github"); + reg.enable("sess-1", "github"); + assert!(reg.enabled_set("sess-1").contains("github")); + assert!(!reg.disabled_set("sess-1").contains("github")); + + reg.disable("sess-1", "github"); + assert!(!reg.enabled_set("sess-1").contains("github")); + assert!(reg.disabled_set("sess-1").contains("github")); + } + + #[test] + fn test_clear_and_remove() { + let reg = SessionOverrideRegistry::default(); + reg.enable("sess-1", "github"); + reg.disable("sess-1", "firebase"); + reg.clear("sess-1"); + assert!(reg.enabled_set("sess-1").is_empty()); + assert!(reg.disabled_set("sess-1").is_empty()); + + reg.enable("sess-2", "slack"); + reg.remove("sess-2"); + assert!(reg.enabled_set("sess-2").is_empty()); + } + + #[test] + fn test_list_all() { + let reg = SessionOverrideRegistry::default(); + reg.enable("b", "github"); + reg.disable("a", "firebase"); + let all = reg.list_all(); + assert_eq!(all.len(), 2); + assert_eq!(all[0].session_id, "a"); + assert_eq!(all[1].session_id, "b"); + } +} diff --git a/crates/mcpmux-gateway/src/services/session_roots.rs b/crates/mcpmux-gateway/src/services/session_roots.rs index d258c70d..71a8cf97 100644 --- a/crates/mcpmux-gateway/src/services/session_roots.rs +++ b/crates/mcpmux-gateway/src/services/session_roots.rs @@ -151,13 +151,16 @@ impl SessionRootsRegistry { /// `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 - } + let unchanged = self + .last_resolution + .get(session_id) + .map(|prev| *prev == new_val) + .unwrap_or(false); + if unchanged { + return false; } + self.last_resolution.insert(session_id.to_string(), new_val); + true } /// Returns every reported root across every active session, de-duplicated @@ -175,6 +178,17 @@ impl SessionRootsRegistry { out } + /// Snapshot of every session with reported roots (for UI inspection). + pub fn list_all_sessions(&self) -> Vec<(String, Vec)> { + let mut out: Vec<(String, Vec)> = self + .map + .iter() + .map(|entry| (entry.key().clone(), entry.value().clone())) + .collect(); + out.sort_by(|a, b| a.0.cmp(&b.0)); + out + } + /// Current number of tracked sessions. Test helper; cheap to call but /// not useful in hot paths. #[cfg(test)] diff --git a/crates/mcpmux-gateway/src/services/tool_discovery.rs b/crates/mcpmux-gateway/src/services/tool_discovery.rs new file mode 100644 index 00000000..f99e37f8 --- /dev/null +++ b/crates/mcpmux-gateway/src/services/tool_discovery.rs @@ -0,0 +1,221 @@ +//! In-memory tool index for meta-gateway search and schema lookup. +//! +//! Built from Space [`ServerFeature`] rows and filtered to the caller's +//! invokable tool set before search/schema operations run. + +use std::collections::HashSet; +use std::sync::Arc; + +use anyhow::Result; +use mcpmux_core::{FeatureType, ServerFeature, ServerFeatureRepository}; +use serde_json::{json, Value}; + +/// How much detail search results include per matched tool. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DetailLevel { + Name, + Description, + Schema, +} + +impl DetailLevel { + /// Parse a wire-level detail level string. + pub fn parse(s: &str) -> Option { + match s { + "name" => Some(Self::Name), + "description" => Some(Self::Description), + "schema" => Some(Self::Schema), + _ => None, + } + } +} + +/// One searchable tool entry in the Space index. +#[derive(Debug, Clone)] +pub struct ToolIndexEntry { + pub server_id: String, + pub feature_name: String, + pub qualified_name: String, + pub description: Option, + pub input_schema: Option, + pub is_available: bool, +} + +/// Paginated search output. +#[derive(Debug, Clone)] +pub struct SearchToolsResult { + pub tools: Vec, + pub next_cursor: Option, + pub total: usize, +} + +/// Service that builds and queries a tool index for a Space. +pub struct ToolDiscoveryService { + server_feature_repo: Arc, +} + +impl ToolDiscoveryService { + /// Create a discovery service backed by the Space feature repository. + pub fn new(server_feature_repo: Arc) -> Self { + Self { + server_feature_repo, + } + } + + /// Build an index for `space_id`, retaining only tools present in `invokable`. + pub async fn build_index( + &self, + space_id: &str, + invokable: &[ServerFeature], + ) -> Result> { + let invokable_keys: HashSet<(String, String)> = invokable + .iter() + .filter(|f| f.feature_type == FeatureType::Tool) + .map(|f| (f.server_id.clone(), f.feature_name.clone())) + .collect(); + + let features = self.server_feature_repo.list_for_space(space_id).await?; + let mut index: Vec = features + .into_iter() + .filter(|f| { + f.feature_type == FeatureType::Tool + && invokable_keys.contains(&(f.server_id.clone(), f.feature_name.clone())) + }) + .map(|f| ToolIndexEntry { + server_id: f.server_id.clone(), + feature_name: f.feature_name.clone(), + qualified_name: f.qualified_name(), + description: f.description.clone(), + input_schema: extract_input_schema(f.raw_json.as_ref()), + is_available: f.is_available, + }) + .collect(); + + index.sort_by(|a, b| a.qualified_name.cmp(&b.qualified_name)); + Ok(index) + } + + /// Search the index with optional query, server filter, and pagination. + pub fn search( + index: &[ToolIndexEntry], + query: Option<&str>, + server_id: Option<&str>, + detail_level: DetailLevel, + limit: usize, + cursor: Option<&str>, + ) -> SearchToolsResult { + let limit = limit.clamp(1, 100); + let offset = cursor.and_then(|c| c.parse::().ok()).unwrap_or(0); + + let query_lower = query.map(|q| q.to_lowercase()); + let filtered: Vec<&ToolIndexEntry> = index + .iter() + .filter(|entry| { + if let Some(sid) = server_id { + if entry.server_id != sid { + return false; + } + } + if let Some(ref q) = query_lower { + let haystack = format!( + "{} {} {}", + entry.qualified_name, + entry.feature_name, + entry.description.as_deref().unwrap_or("") + ) + .to_lowercase(); + if !haystack.contains(q.as_str()) { + return false; + } + } + true + }) + .collect(); + + let total = filtered.len(); + let page: Vec = filtered + .iter() + .skip(offset) + .take(limit) + .map(|entry| entry_to_json(entry, detail_level)) + .collect(); + + let next_offset = offset + page.len(); + let next_cursor = if next_offset < total { + Some(next_offset.to_string()) + } else { + None + }; + + SearchToolsResult { + tools: page, + next_cursor, + total, + } + } + + /// Resolve schemas for one or more qualified tool names. + pub fn get_schemas( + index: &[ToolIndexEntry], + tool_names: &[String], + compact: bool, + ) -> Vec { + tool_names + .iter() + .filter_map(|name| { + let entry = index.iter().find(|e| e.qualified_name == *name)?; + Some(schema_entry_to_json(entry, compact)) + }) + .collect() + } +} + +/// Extract MCP `inputSchema` from a cached tool JSON blob. +fn extract_input_schema(raw_json: Option<&Value>) -> Option { + raw_json.and_then(|json| { + json.get("inputSchema") + .or_else(|| json.get("input_schema")) + .cloned() + }) +} + +fn entry_to_json(entry: &ToolIndexEntry, detail_level: DetailLevel) -> Value { + let mut obj = json!({ + "server_id": entry.server_id, + "qualified_name": entry.qualified_name, + "available": entry.is_available, + }); + match detail_level { + DetailLevel::Name => {} + DetailLevel::Description | DetailLevel::Schema => { + if let Some(desc) = &entry.description { + obj["description"] = json!(desc); + } + } + } + if detail_level == DetailLevel::Schema { + if let Some(schema) = &entry.input_schema { + obj["input_schema"] = schema.clone(); + } + } + obj +} + +fn schema_entry_to_json(entry: &ToolIndexEntry, compact: bool) -> Value { + let mut obj = json!({ + "qualified_name": entry.qualified_name, + "server_id": entry.server_id, + "feature_name": entry.feature_name, + }); + if !compact { + if let Some(desc) = &entry.description { + obj["description"] = json!(desc); + } + } + if let Some(schema) = &entry.input_schema { + obj["input_schema"] = schema.clone(); + } else { + obj["input_schema"] = json!({"type": "object", "properties": {}}); + } + obj +} diff --git a/crates/mcpmux-storage/src/database.rs b/crates/mcpmux-storage/src/database.rs index bb6fc021..13c96d00 100644 --- a/crates/mcpmux-storage/src/database.rs +++ b/crates/mcpmux-storage/src/database.rs @@ -108,6 +108,26 @@ const MIGRATIONS: &[Migration] = &[ name: "rewrite_starter_seed_copy_v2", sql: include_str!("migrations/015_rewrite_starter_seed_copy_v2.sql"), }, + Migration { + version: 16, + name: "workspace_binding_label", + sql: include_str!("migrations/016_workspace_binding_label.sql"), + }, + Migration { + version: 17, + name: "installed_server_cloned_from", + sql: include_str!("migrations/017_installed_server_cloned_from.sql"), + }, + Migration { + version: 18, + name: "installed_server_display_name_override", + sql: include_str!("migrations/018_installed_server_display_name_override.sql"), + }, + Migration { + version: 19, + name: "feature_set_member_surfaced", + sql: include_str!("migrations/019_feature_set_member_surfaced.sql"), + }, ]; /// SQLite database wrapper. diff --git a/crates/mcpmux-storage/src/migrations/016_workspace_binding_label.sql b/crates/mcpmux-storage/src/migrations/016_workspace_binding_label.sql new file mode 100644 index 00000000..8e68ad92 --- /dev/null +++ b/crates/mcpmux-storage/src/migrations/016_workspace_binding_label.sql @@ -0,0 +1,2 @@ +-- Optional friendly display name for workspace bindings (separate from workspace_root). +ALTER TABLE workspace_bindings ADD COLUMN label TEXT; diff --git a/crates/mcpmux-storage/src/migrations/017_installed_server_cloned_from.sql b/crates/mcpmux-storage/src/migrations/017_installed_server_cloned_from.sql new file mode 100644 index 00000000..a1616bb9 --- /dev/null +++ b/crates/mcpmux-storage/src/migrations/017_installed_server_cloned_from.sql @@ -0,0 +1,2 @@ +-- Track clone lineage on installed servers (display-only in v1). +ALTER TABLE installed_servers ADD COLUMN cloned_from TEXT; diff --git a/crates/mcpmux-storage/src/migrations/018_installed_server_display_name_override.sql b/crates/mcpmux-storage/src/migrations/018_installed_server_display_name_override.sql new file mode 100644 index 00000000..868380a8 --- /dev/null +++ b/crates/mcpmux-storage/src/migrations/018_installed_server_display_name_override.sql @@ -0,0 +1,2 @@ +-- User-supplied display name that survives user-config sync (UI-preferred label). +ALTER TABLE installed_servers ADD COLUMN display_name_override TEXT; diff --git a/crates/mcpmux-storage/src/migrations/019_feature_set_member_surfaced.sql b/crates/mcpmux-storage/src/migrations/019_feature_set_member_surfaced.sql new file mode 100644 index 00000000..aa114a59 --- /dev/null +++ b/crates/mcpmux-storage/src/migrations/019_feature_set_member_surfaced.sql @@ -0,0 +1,2 @@ +-- Per-member flag: when set on an included tool, promote into client tools/list. +ALTER TABLE feature_set_members ADD COLUMN surfaced INTEGER NOT NULL DEFAULT 0; diff --git a/crates/mcpmux-storage/src/repositories/feature_set_repository.rs b/crates/mcpmux-storage/src/repositories/feature_set_repository.rs index aaf2b436..2971d5e3 100644 --- a/crates/mcpmux-storage/src/repositories/feature_set_repository.rs +++ b/crates/mcpmux-storage/src/repositories/feature_set_repository.rs @@ -67,6 +67,7 @@ impl SqliteFeatureSetRepository { .unwrap_or(MemberType::Feature), member_id: row.get(3)?, mode: MemberMode::parse(&row.get::<_, String>(4)?).unwrap_or(MemberMode::Include), + surfaced: row.get::<_, i32>(5).unwrap_or(0) == 1, }) } @@ -76,7 +77,7 @@ impl SqliteFeatureSetRepository { let conn = db.connection(); let mut stmt = conn.prepare( - "SELECT id, feature_set_id, member_type, member_id, mode + "SELECT id, feature_set_id, member_type, member_id, mode, surfaced FROM feature_set_members WHERE feature_set_id = ? ORDER BY id", @@ -95,7 +96,7 @@ impl SqliteFeatureSetRepository { feature_set_id: &str, ) -> Result> { let mut stmt = conn.prepare( - "SELECT id, feature_set_id, member_type, member_id, mode + "SELECT id, feature_set_id, member_type, member_id, mode, surfaced FROM feature_set_members WHERE feature_set_id = ? ORDER BY id", @@ -210,14 +211,15 @@ impl FeatureSetRepository for SqliteFeatureSetRepository { let now = chrono::Utc::now().to_rfc3339(); for member in &feature_set.members { conn.execute( - "INSERT INTO feature_set_members (id, feature_set_id, member_type, member_id, mode, created_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + "INSERT INTO feature_set_members (id, feature_set_id, member_type, member_id, mode, surfaced, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", params![ member.id, member.feature_set_id, member.member_type.as_str(), member.member_id, member.mode.as_str(), + if member.surfaced { 1 } else { 0 }, now, ], )?; @@ -256,14 +258,15 @@ impl FeatureSetRepository for SqliteFeatureSetRepository { let now = chrono::Utc::now().to_rfc3339(); for member in &feature_set.members { conn.execute( - "INSERT INTO feature_set_members (id, feature_set_id, member_type, member_id, mode, created_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + "INSERT INTO feature_set_members (id, feature_set_id, member_type, member_id, mode, surfaced, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", params![ member.id, member.feature_set_id, member.member_type.as_str(), member.member_id, member.mode.as_str(), + if member.surfaced { 1 } else { 0 }, now, ], )?; @@ -345,17 +348,19 @@ impl FeatureSetRepository for SqliteFeatureSetRepository { member_type: MemberType::Feature, member_id: feature_id.to_string(), mode, + surfaced: false, }; conn.execute( - "INSERT INTO feature_set_members (id, feature_set_id, member_type, member_id, mode, created_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + "INSERT INTO feature_set_members (id, feature_set_id, member_type, member_id, mode, surfaced, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", params![ member.id, member.feature_set_id, member.member_type.as_str(), member.member_id, member.mode.as_str(), + if member.surfaced { 1 } else { 0 }, chrono::Utc::now().to_rfc3339(), ], )?; @@ -383,7 +388,7 @@ impl FeatureSetRepository for SqliteFeatureSetRepository { let conn = db.connection(); let mut stmt = conn.prepare( - "SELECT id, feature_set_id, member_type, member_id, mode + "SELECT id, feature_set_id, member_type, member_id, mode, surfaced FROM feature_set_members WHERE feature_set_id = ?1 AND member_type = 'feature' ORDER BY id", diff --git a/crates/mcpmux-storage/src/repositories/installed_server_repository.rs b/crates/mcpmux-storage/src/repositories/installed_server_repository.rs index 3ddef0cc..7256eaf2 100644 --- a/crates/mcpmux-storage/src/repositories/installed_server_repository.rs +++ b/crates/mcpmux-storage/src/repositories/installed_server_repository.rs @@ -30,6 +30,8 @@ struct RawServerRow { created_at: String, updated_at: String, source: Option, + cloned_from: Option, + display_name_override: Option, } /// SQLite-backed implementation of InstalledServerRepository. @@ -131,7 +133,8 @@ impl SqliteInstalledServerRepository { /// Standard column list for SELECT queries const SELECT_COLUMNS: &'static str = "id, space_id, server_id, server_name, cached_definition, input_values, enabled, env_overrides, - args_append, extra_headers, oauth_connected, created_at, updated_at, source"; + args_append, extra_headers, oauth_connected, created_at, updated_at, source, cloned_from, + display_name_override"; /// Extract raw row data (used in the closure passed to rusqlite). fn extract_row(row: &rusqlite::Row) -> rusqlite::Result { @@ -150,6 +153,8 @@ impl SqliteInstalledServerRepository { created_at: row.get(11)?, updated_at: row.get(12)?, source: row.get(13)?, + cloned_from: row.get(14)?, + display_name_override: row.get(15)?, }) } @@ -168,6 +173,8 @@ impl SqliteInstalledServerRepository { extra_headers: Self::parse_json_map(row.extra_headers), oauth_connected: row.oauth_connected, source: Self::parse_source(row.source), + cloned_from: row.cloned_from, + display_name_override: row.display_name_override, created_at: Self::parse_datetime(&row.created_at), updated_at: Self::parse_datetime(&row.updated_at), } @@ -275,8 +282,9 @@ impl InstalledServerRepository for SqliteInstalledServerRepository { conn.execute( "INSERT INTO installed_servers (id, space_id, server_id, server_name, cached_definition, input_values, enabled, env_overrides, - args_append, extra_headers, oauth_connected, created_at, updated_at, source) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)", + args_append, extra_headers, oauth_connected, created_at, updated_at, source, cloned_from, + display_name_override) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)", params![ server.id.to_string(), server.space_id, @@ -292,6 +300,8 @@ impl InstalledServerRepository for SqliteInstalledServerRepository { server.created_at.to_rfc3339(), server.updated_at.to_rfc3339(), Self::serialize_source(&server.source), + server.cloned_from, + server.display_name_override, ], )?; Ok(()) @@ -307,7 +317,7 @@ impl InstalledServerRepository for SqliteInstalledServerRepository { "UPDATE installed_servers SET server_name = ?2, cached_definition = ?3, input_values = ?4, enabled = ?5, env_overrides = ?6, args_append = ?7, extra_headers = ?8, oauth_connected = ?9, - updated_at = ?10, source = ?11 + updated_at = ?10, source = ?11, display_name_override = ?12 WHERE id = ?1", params![ server.id.to_string(), @@ -321,6 +331,7 @@ impl InstalledServerRepository for SqliteInstalledServerRepository { server.oauth_connected, Utc::now().to_rfc3339(), Self::serialize_source(&server.source), + server.display_name_override, ], )?; Ok(()) @@ -439,4 +450,15 @@ impl InstalledServerRepository for SqliteInstalledServerRepository { )?; Ok(()) } + + async fn set_display_name_override(&self, id: &Uuid, value: Option) -> Result<()> { + let db = self.db.lock().await; + let conn = db.connection(); + + conn.execute( + "UPDATE installed_servers SET display_name_override = ?2, updated_at = ?3 WHERE id = ?1", + params![id.to_string(), value, Utc::now().to_rfc3339()], + )?; + Ok(()) + } } diff --git a/crates/mcpmux-storage/src/repositories/workspace_binding_repository.rs b/crates/mcpmux-storage/src/repositories/workspace_binding_repository.rs index 7f3df1a3..d03b3774 100644 --- a/crates/mcpmux-storage/src/repositories/workspace_binding_repository.rs +++ b/crates/mcpmux-storage/src/repositories/workspace_binding_repository.rs @@ -62,13 +62,15 @@ impl SqliteWorkspaceBindingRepository { 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 created_at: String = row.get(3)?; - let updated_at: String = row.get(4)?; + let label: Option = row.get(2)?; + let space_id_str: 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()), workspace_root, + label, space_id: space_id_str.parse().unwrap_or_else(|_| Uuid::nil()), feature_set_ids: Vec::new(), // filled in by caller created_at: Self::parse_datetime(&created_at), @@ -138,7 +140,7 @@ impl SqliteWorkspaceBindingRepository { Ok(()) } - const SELECT_COLS: &'static str = "id, workspace_root, space_id, created_at, updated_at"; + const SELECT_COLS: &'static str = "id, workspace_root, label, 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); @@ -209,11 +211,12 @@ impl WorkspaceBindingRepository for SqliteWorkspaceBindingRepository { conn.execute( "INSERT INTO workspace_bindings - (id, workspace_root, space_id, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5)", + (id, workspace_root, label, space_id, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", params![ binding.id.to_string(), binding.workspace_root, + binding.label, binding.space_id.to_string(), binding.created_at.to_rfc3339(), binding.updated_at.to_rfc3339(), @@ -236,11 +239,12 @@ impl WorkspaceBindingRepository for SqliteWorkspaceBindingRepository { let rows_affected = conn.execute( "UPDATE workspace_bindings - SET workspace_root = ?2, space_id = ?3, updated_at = ?4 + SET workspace_root = ?2, label = ?3, space_id = ?4, updated_at = ?5 WHERE id = ?1", params![ binding.id.to_string(), binding.workspace_root, + binding.label, binding.space_id.to_string(), binding.updated_at.to_rfc3339(), ], @@ -365,6 +369,35 @@ mod tests { assert_eq!(got.workspace_root, root); assert_eq!(got.space_id, space_id); assert_eq!(got.feature_set_ids, vec![fs_id]); + assert_eq!(got.label, None); + } + + #[tokio::test] + async fn test_label_round_trip() { + let (repo, space_id, fs_id) = fixture().await; + let root = if cfg!(windows) { + "d:\\labeled" + } else { + "/labeled" + }; + let mut binding = WorkspaceBinding::new(root, space_id, fs_id); + binding.label = Some("My Project".to_string()); + repo.create(&binding).await.unwrap(); + + let got = repo.get(&binding.id).await.unwrap().unwrap(); + assert_eq!(got.label.as_deref(), Some("My Project")); + + let mut updated = got; + updated.label = None; + repo.update(&updated).await.unwrap(); + let cleared = repo.get(&binding.id).await.unwrap().unwrap(); + assert_eq!(cleared.label, None); + + let mut relabeled = cleared; + relabeled.label = Some("Renamed".to_string()); + repo.update(&relabeled).await.unwrap(); + let final_got = repo.get(&binding.id).await.unwrap().unwrap(); + assert_eq!(final_got.label.as_deref(), Some("Renamed")); } #[tokio::test] diff --git a/docs/guide/feature-sets.mdx b/docs/guide/feature-sets.mdx index e7ee156e..56471d0a 100644 --- a/docs/guide/feature-sets.mdx +++ b/docs/guide/feature-sets.mdx @@ -1,9 +1,11 @@ --- title: FeatureSets — Permission Control -description: FeatureSets control which MCP tools, resources, and prompts each AI client can access in McpMux. Create role-based permissions, domain bundles, or read-only views. +description: FeatureSets control which MCP tools AI clients can invoke and optionally promote into tools/list. Create role-based permissions, domain bundles, or read-only views. --- -FeatureSets are permission bundles that control what MCP capabilities (tools, resources, and prompts) each AI client can access. They let you grant fine-grained permissions per client, per Space. +FeatureSets are permission bundles that control what MCP capabilities each AI client can use in a Space. For **tools**, they act as an **invoke ACL**: they define what agents can reach through `mcpmux_search_tools` and `mcpmux_invoke_tool`. They do **not** dump every permitted tool into the client's tool list by default — that keeps context windows lean. + +Resources and prompts still follow the classic grant model (included members are exposed when the client lists them). ## Why FeatureSets @@ -50,6 +52,26 @@ Exclude rules always win over include rules. This means you can create a permiss 1. Include the **GitHub — All** ServerAll FeatureSet 2. Exclude `delete_repository`, `delete_branch`, `delete_file` +## Included vs Surface (FeatureSet editor) + +When you edit a custom FeatureSet, each tool row has two independent controls: + +| Control | What it does | Client effect | +| ------- | ------------ | --------------- | +| **Checkbox** (left) | **Include** the tool in this FeatureSet's invoke ACL | Tool is **invokable** via `mcpmux_search_tools` → `mcpmux_get_tool_schema` → `mcpmux_invoke_tool`. It does **not** appear in the client's `tools/list`. | +| **Surface** button (right, monitor icon) | **Promote** an already-included tool into `tools/list` | Tool appears alongside the ~12 `mcpmux_*` meta tools. The agent can call it **directly** (one hop) instead of going through `mcpmux_invoke_tool`. | + +**Rules:** + +- **Surface only appears when the checkbox is on.** You cannot surface a tool you have not included. +- **Default is checkbox on, Surface off.** Most backend tools stay off the client tool list; agents discover them through search + invoke. +- **Use Surface sparingly.** Each promoted tool adds its full schema to the client context window. Reserve it for hot paths you call constantly (e.g. one GitHub read tool). +- **The server header toggle** (Enable All / Disable All) bulk-selects checkboxes for that server — it is **not** the Surface control. + +**Example:** A "GitHub read-only" FeatureSet might include `list_issues` and `get_me` (both checked), with **Surface on** only for `list_issues`. Cursor shows `github_list_issues` in its tool list; `get_me` stays invoke-only. + +Connected clients always see the fixed `mcpmux_*` meta surface regardless of FeatureSet membership. See [Self-management meta tools](#self-management-meta-tools) below. + ## Composition FeatureSets can **contain other FeatureSets**. This lets you build hierarchical permission structures: @@ -95,8 +117,11 @@ FeatureSets can **contain other FeatureSets**. This lets you build hierarchical 1. Go to the **FeatureSets** page 2. Click **Create FeatureSet** 3. Give it a name and optional description -4. Add members — select features or other FeatureSets -5. Set each member to include or exclude mode +4. Under **Included Features**, check the tools/resources/prompts to allow (invoke ACL for tools) +5. Optionally click **Surface** on individual included tools you want promoted into client `tools/list` +6. Save — if a connected MCP client is open, reload its tools after changing Surface toggles + +You can also nest FeatureSets (include another FeatureSet as a member) and set each member to include or exclude mode. ### Assigning to Clients @@ -104,6 +129,20 @@ FeatureSets are assigned to clients per Space. Go to the **Clients** page, selec A client's effective permissions are the combination of all its granted FeatureSets, with exclude rules taking priority. +Workspace **bindings** attach FeatureSets to folder roots so the invoke ACL follows the project you have open. Client grants stack additional FeatureSets on top. + +### Self-management meta tools + +McpMux exposes a built-in `mcpmux_*` namespace (~12 tools) for server toggles, search, schema load, and invoke. FeatureSets control the **backend** pool those meta tools can reach; they do not replace the meta tools themselves. + +Typical agent flow for a non-surfaced backend tool: + +1. `mcpmux_search_tools` — find tools allowed by the active FeatureSet +2. `mcpmux_get_tool_schema` — read parameter names before calling +3. `mcpmux_invoke_tool` — run the backend tool + +See the [Gateway](/docs/gateway/) doc for how bindings, session enable/disable, and FeatureSet members compose at request time. + ## Next Steps - [Set up Clients](/docs/clients/) and assign FeatureSets per Space diff --git a/docs/guide/servers.mdx b/docs/guide/servers.mdx index 6f68ebe5..49bdbf0e 100644 --- a/docs/guide/servers.mdx +++ b/docs/guide/servers.mdx @@ -100,6 +100,33 @@ Disabling a server immediately disconnects it and removes its tools from connect ![Expanded server view showing available tools and prompts for each connected server](https://mcpmux.com/screenshots/server-expanded.png) +## Multiple Accounts + +Some MCP servers only support one account per process. Others accept a per-call account parameter, or you may simply want work and personal credentials in separate contexts. Use this decision tree: + +```text +Need more than one account for the same MCP? +├─ The MCP accepts a per-call account parameter (e.g. Google Workspace `user_google_email`) +│ └─ Install once — pass the account on each tool call. No clone needed. +├─ Accounts map to different repo or project context (work vs personal vs client) +│ └─ Use [Spaces](/docs/spaces/) — one install per Space with separate credentials. +└─ Two or more accounts in the SAME Space for a single-account MCP + └─ Clone via **Add another account…** on the server card in My Servers. +``` + +### Cloning a server + +When you need two PostHog workspaces, Firebase projects, or Gmail accounts in one Space: + +1. Open **My Servers** and use the server menu → **Add another account…** +2. Choose a suffix (`work`, `personal`, `prod`, etc.) — the clone ID becomes `{server}-{suffix}` (e.g. `posthog-work`) +3. Configure credentials for the clone (secrets are never copied from the source) +4. Enable the clone — tools appear with the clone prefix (e.g. `posthog-work_capture`) + +Clones are independent installs: separate credentials, OAuth sessions, and tool prefixes. The source server is unchanged. You cannot clone a clone (max depth 1). + +When using [meta tools](/docs/feature-sets/) (`mcpmux_list_servers`), clone rows include an optional `cloned_from` field with the source server ID so an LLM can see lineage. + ## Connection Status The **My Servers** page shows real-time connection status for each server: diff --git a/packages/ui/src/components/common/ChipButton.tsx b/packages/ui/src/components/common/ChipButton.tsx new file mode 100644 index 00000000..93b40de4 --- /dev/null +++ b/packages/ui/src/components/common/ChipButton.tsx @@ -0,0 +1,44 @@ +import { type ButtonHTMLAttributes, forwardRef } from 'react'; +import { cn } from '../../lib/cn'; + +export type ChipButtonVariant = 'fill' | 'outline'; + +export interface ChipButtonProps extends ButtonHTMLAttributes { + active?: boolean; + variant?: ChipButtonVariant; +} + +/** + * Small pill toggle used for transport/status filter chips. + */ +export const ChipButton = forwardRef( + ({ className, active = false, variant = 'fill', children, type = 'button', ...props }, ref) => { + return ( + + ); + } +); + +ChipButton.displayName = 'ChipButton'; diff --git a/packages/ui/src/components/common/DropdownMenu.tsx b/packages/ui/src/components/common/DropdownMenu.tsx new file mode 100644 index 00000000..31d34835 --- /dev/null +++ b/packages/ui/src/components/common/DropdownMenu.tsx @@ -0,0 +1,259 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useId, + useRef, + useState, + type HTMLAttributes, + type ReactNode, +} from 'react'; +import type { LucideIcon } from 'lucide-react'; +import { cn } from '../../lib/cn'; +import { useClickOutside } from '../../hooks/useClickOutside'; + +interface DropdownMenuContextValue { + open: boolean; + setOpen: (open: boolean) => void; + menuId: string; +} + +const DropdownMenuContext = createContext(null); + +function useDropdownMenu(): DropdownMenuContextValue { + const context = useContext(DropdownMenuContext); + if (!context) { + throw new Error('DropdownMenu components must be used within DropdownMenu'); + } + return context; +} + +export interface DropdownMenuProps { + children: ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; + className?: string; +} + +/** + * Root dropdown container with open state and click-outside handling. + */ +export function DropdownMenu({ + children, + open: controlledOpen, + onOpenChange, + className, +}: DropdownMenuProps) { + const [uncontrolledOpen, setUncontrolledOpen] = useState(false); + const rootRef = useRef(null); + const menuId = useId(); + + const open = controlledOpen ?? uncontrolledOpen; + + const setOpen = useCallback( + (next: boolean) => { + if (controlledOpen === undefined) { + setUncontrolledOpen(next); + } + onOpenChange?.(next); + }, + [controlledOpen, onOpenChange] + ); + + useClickOutside([rootRef], () => setOpen(false), open); + + useEffect(() => { + if (!open) { + return; + } + + function handleEscape(event: KeyboardEvent) { + if (event.key === 'Escape') { + setOpen(false); + } + } + + document.addEventListener('keydown', handleEscape); + return () => document.removeEventListener('keydown', handleEscape); + }, [open, setOpen]); + + return ( + +
+ {children} +
+
+ ); +} + +export interface DropdownMenuTriggerProps extends HTMLAttributes { + children: ReactNode; +} + +/** + * Wraps the element that toggles the dropdown open state. + */ +export function DropdownMenuTrigger({ children, className, ...props }: DropdownMenuTriggerProps) { + const { open, setOpen, menuId } = useDropdownMenu(); + + return ( +
setOpen(!open)} + aria-expanded={open} + aria-haspopup="menu" + aria-controls={menuId} + {...props} + > + {children} +
+ ); +} + +export interface DropdownMenuContentProps extends HTMLAttributes { + children: ReactNode; + align?: 'start' | 'end'; +} + +/** + * Panel shown below the trigger when the menu is open. + */ +export function DropdownMenuContent({ + children, + align = 'end', + className, + ...props +}: DropdownMenuContentProps) { + const { open, menuId } = useDropdownMenu(); + + if (!open) { + return null; + } + + return ( + + ); +} + +export interface DropdownMenuItemProps { + icon?: LucideIcon; + label: string; + description?: string; + onSelect: () => void; + variant?: 'default' | 'warning' | 'danger'; + className?: string; + 'data-testid'?: string; +} + +/** + * Menu row with optional icon, title, and description (for discover/custom style items). + */ +export function DropdownMenuItem({ + icon: Icon, + label, + description, + onSelect, + variant = 'default', + className, + 'data-testid': testId, +}: DropdownMenuItemProps) { + const { setOpen } = useDropdownMenu(); + + const labelClass = + variant === 'danger' + ? 'text-[rgb(var(--error))]' + : variant === 'warning' + ? 'text-[rgb(var(--warning))]' + : 'text-[rgb(var(--foreground))]'; + + return ( + + ); +} + +/** + * Simple compact menu row (icon + label) for action menus. + */ +export function DropdownMenuAction({ + icon: Icon, + label, + onSelect, + variant = 'default', + className, + 'data-testid': testId, +}: Omit) { + const { setOpen } = useDropdownMenu(); + + const labelClass = + variant === 'danger' + ? 'text-[rgb(var(--error))]' + : variant === 'warning' + ? 'text-[rgb(var(--warning))]' + : 'text-[rgb(var(--foreground))]'; + + return ( + + ); +} + +export function DropdownMenuSeparator() { + return
; +} diff --git a/packages/ui/src/components/common/HoverTooltip.tsx b/packages/ui/src/components/common/HoverTooltip.tsx new file mode 100644 index 00000000..a17cff6f --- /dev/null +++ b/packages/ui/src/components/common/HoverTooltip.tsx @@ -0,0 +1,188 @@ +import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react'; +import { cn } from '../../lib/cn'; + +export type HoverTooltipSide = 'top' | 'bottom' | 'auto'; + +const VIEWPORT_PADDING = 8; +const GAP = 8; + +export interface HoverTooltipProps { + children: ReactNode; + title: string; + lines?: string[]; + /** Preferred placement; `auto` flips based on available viewport space. */ + side?: HoverTooltipSide; + className?: string; + hidden?: boolean; + 'data-testid'?: string; +} + +/** + * Pick top or bottom placement from viewport space around the trigger. + */ +function resolveTooltipSide( + preferred: HoverTooltipSide, + triggerRect: DOMRect, + tooltipHeight: number +): 'top' | 'bottom' { + const spaceAbove = triggerRect.top; + const spaceBelow = window.innerHeight - triggerRect.bottom; + const needed = tooltipHeight + GAP; + + if (preferred === 'top') { + if (spaceAbove >= needed) { + return 'top'; + } + if (spaceBelow >= needed) { + return 'bottom'; + } + return spaceBelow > spaceAbove ? 'bottom' : 'top'; + } + + if (preferred === 'bottom') { + if (spaceBelow >= needed) { + return 'bottom'; + } + if (spaceAbove >= needed) { + return 'top'; + } + return spaceAbove > spaceBelow ? 'top' : 'bottom'; + } + + if (spaceAbove >= needed && spaceBelow >= needed) { + return spaceAbove >= spaceBelow ? 'top' : 'bottom'; + } + if (spaceBelow >= needed) { + return 'bottom'; + } + if (spaceAbove >= needed) { + return 'top'; + } + return spaceBelow > spaceAbove ? 'bottom' : 'top'; +} + +/** + * Compute fixed viewport coordinates for the tooltip panel. + */ +function computeTooltipCoords( + triggerRect: DOMRect, + tooltipWidth: number, + tooltipHeight: number, + placement: 'top' | 'bottom' +): { top: number; left: number } { + let top = + placement === 'top' + ? triggerRect.top - tooltipHeight - GAP + : triggerRect.bottom + GAP; + + top = Math.max( + VIEWPORT_PADDING, + Math.min(top, window.innerHeight - tooltipHeight - VIEWPORT_PADDING) + ); + + let left = triggerRect.right - tooltipWidth; + left = Math.max( + VIEWPORT_PADDING, + Math.min(left, window.innerWidth - tooltipWidth - VIEWPORT_PADDING) + ); + + return { top, left }; +} + +/** + * Wraps a control and shows a tooltip panel on hover (hidden while `hidden` is true). + * Placement flips above/below based on viewport space when `side` is `auto`. + */ +export function HoverTooltip({ + children, + title, + lines = [], + side = 'auto', + className, + hidden = false, + 'data-testid': testId, +}: HoverTooltipProps) { + const containerRef = useRef(null); + const tooltipRef = useRef(null); + const [active, setActive] = useState(false); + const [coords, setCoords] = useState<{ top: number; left: number } | null>(null); + + const updateCoords = useCallback(() => { + const container = containerRef.current; + const tooltip = tooltipRef.current; + if (!container || !tooltip) { + return; + } + + const triggerRect = container.getBoundingClientRect(); + const tooltipRect = tooltip.getBoundingClientRect(); + const tooltipWidth = tooltipRect.width > 0 ? tooltipRect.width : tooltip.scrollWidth; + const tooltipHeight = tooltipRect.height > 0 ? tooltipRect.height : tooltip.scrollHeight; + + const placement = resolveTooltipSide(side, triggerRect, tooltipHeight); + setCoords(computeTooltipCoords(triggerRect, tooltipWidth, tooltipHeight, placement)); + }, [side]); + + useLayoutEffect(() => { + if (!active || hidden) { + setCoords(null); + return; + } + updateCoords(); + }, [active, hidden, updateCoords, title, lines]); + + useEffect(() => { + if (!active || hidden) { + return; + } + + const handleReposition = () => updateCoords(); + window.addEventListener('resize', handleReposition); + window.addEventListener('scroll', handleReposition, true); + return () => { + window.removeEventListener('resize', handleReposition); + window.removeEventListener('scroll', handleReposition, true); + }; + }, [active, hidden, updateCoords]); + + const showTooltip = active && !hidden && coords !== null; + + return ( +
setActive(true)} + onMouseLeave={() => setActive(false)} + onFocusCapture={() => setActive(true)} + onBlurCapture={(event) => { + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { + setActive(false); + } + }} + > +
+

{title}

+ {lines.map((line) => ( +

+ {line} +

+ ))} +
+ {children} +
+ ); +} diff --git a/packages/ui/src/components/common/SearchField.tsx b/packages/ui/src/components/common/SearchField.tsx new file mode 100644 index 00000000..97066783 --- /dev/null +++ b/packages/ui/src/components/common/SearchField.tsx @@ -0,0 +1,50 @@ +import { forwardRef, type InputHTMLAttributes } from 'react'; +import { Search, X, type LucideIcon } from 'lucide-react'; +import { cn } from '../../lib/cn'; + +export interface SearchFieldProps extends Omit, 'type'> { + onClear?: () => void; + icon?: LucideIcon; + 'data-testid'?: string; +} + +/** + * Search input with leading icon and optional clear control. + */ +export const SearchField = forwardRef( + ({ className, value, onClear, icon: Icon = Search, 'data-testid': testId, ...props }, ref) => { + const hasValue = String(value ?? '').length > 0; + + return ( +
+ + + {hasValue && onClear && ( + + )} +
+ ); + } +); + +SearchField.displayName = 'SearchField'; diff --git a/packages/ui/src/components/layout/AppShell.tsx b/packages/ui/src/components/layout/AppShell.tsx index 2175e28b..994c304f 100644 --- a/packages/ui/src/components/layout/AppShell.tsx +++ b/packages/ui/src/components/layout/AppShell.tsx @@ -16,8 +16,8 @@ export function AppShell({ sidebar, children, statusBar, titleBar, windowControl {/* Custom title bar */} {titleBar && (
- {/* Draggable area — fills space between logo and window controls */} -
+ {/* Draggable area — titleBar marks regions with data-tauri-drag-region (Tauri 2) */} +
{titleBar}
{/* Window controls — outside drag region so clicks work */} diff --git a/packages/ui/src/hooks/useClickOutside.ts b/packages/ui/src/hooks/useClickOutside.ts new file mode 100644 index 00000000..7af00eeb --- /dev/null +++ b/packages/ui/src/hooks/useClickOutside.ts @@ -0,0 +1,27 @@ +import { useEffect, type RefObject } from 'react'; + +/** + * Invoke a callback when the user clicks outside all provided element refs. + */ +export function useClickOutside( + refs: RefObject[], + onClickOutside: () => void, + enabled: boolean +): void { + useEffect(() => { + if (!enabled) { + return; + } + + function handlePointerDown(event: MouseEvent) { + const target = event.target as Node; + const isInside = refs.some((ref) => ref.current?.contains(target)); + if (!isInside) { + onClickOutside(); + } + } + + document.addEventListener('mousedown', handlePointerDown); + return () => document.removeEventListener('mousedown', handlePointerDown); + }, [refs, onClickOutside, enabled]); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index ffcf9745..c68a6c2d 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -12,6 +12,26 @@ export { StatusBar, StatusBarItem } from './components/layout/StatusBar'; // Common components export { Button } from './components/common/Button'; export { Input } from './components/common/Input'; +export { SearchField } from './components/common/SearchField'; +export type { SearchFieldProps } from './components/common/SearchField'; +export { ChipButton } from './components/common/ChipButton'; +export type { ChipButtonProps, ChipButtonVariant } from './components/common/ChipButton'; +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuAction, + DropdownMenuSeparator, +} from './components/common/DropdownMenu'; +export type { + DropdownMenuProps, + DropdownMenuTriggerProps, + DropdownMenuContentProps, + DropdownMenuItemProps, +} from './components/common/DropdownMenu'; +export { HoverTooltip } from './components/common/HoverTooltip'; +export type { HoverTooltipProps, HoverTooltipSide } from './components/common/HoverTooltip'; export { Card, CardHeader, CardTitle, CardDescription, CardContent } from './components/common/Card'; export { Switch } from './components/common/Switch'; export { Toast, ToastContainer } from './components/common/Toast'; @@ -22,6 +42,7 @@ export type { ConfirmDialogState, ConfirmDialogProps } from './components/common // Hooks export { useToast } from './hooks/useToast'; export type { ToastOptions } from './hooks/useToast'; +export { useClickOutside } from './hooks/useClickOutside'; // Utilities export { cn } from './lib/cn'; diff --git a/tests/rust/src/canned_invoke_backend.rs b/tests/rust/src/canned_invoke_backend.rs new file mode 100644 index 00000000..08a2589d --- /dev/null +++ b/tests/rust/src/canned_invoke_backend.rs @@ -0,0 +1,58 @@ +//! Canned invoke backend for integration tests. + +use std::collections::HashMap; +use std::sync::Arc; + +use anyhow::{anyhow, Result}; +use async_trait::async_trait; +use mcpmux_gateway::{InvokeToolBackend, ToolCallResult}; +use serde_json::Value; +use uuid::Uuid; + +/// Returns predetermined tool results keyed by qualified tool name. +pub struct CannedInvokeBackend { + responses: HashMap, +} + +impl CannedInvokeBackend { + /// Create an empty canned backend. + pub fn new() -> Self { + Self { + responses: HashMap::new(), + } + } + + /// Register a response for a qualified tool name. + pub fn with_response(mut self, qualified_name: impl Into, result: ToolCallResult) -> Self { + self.responses.insert(qualified_name.into(), result); + self + } + + /// Wrap as a trait object for registry wiring. + pub fn into_arc(self) -> Arc { + Arc::new(self) + } +} + +impl Default for CannedInvokeBackend { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl InvokeToolBackend for CannedInvokeBackend { + async fn call_tool( + &self, + _space_id: Uuid, + _feature_set_ids: &[String], + _session_id: Option<&str>, + qualified_name: &str, + _arguments: Value, + ) -> Result { + self.responses + .get(qualified_name) + .cloned() + .ok_or_else(|| anyhow!("no canned response for {qualified_name}")) + } +} diff --git a/tests/rust/src/lib.rs b/tests/rust/src/lib.rs index b6eff502..5aa818fe 100644 --- a/tests/rust/src/lib.rs +++ b/tests/rust/src/lib.rs @@ -8,7 +8,9 @@ pub use mcpmux_core::{ }; /// Mock repository implementations +pub mod canned_invoke_backend; pub mod mocks; +pub use canned_invoke_backend::CannedInvokeBackend; pub use mocks::MockRepositories; /// Service test helpers diff --git a/tests/rust/src/mocks.rs b/tests/rust/src/mocks.rs index 57ab83b8..a4869122 100644 --- a/tests/rust/src/mocks.rs +++ b/tests/rust/src/mocks.rs @@ -223,6 +223,13 @@ impl InstalledServerRepository for MockInstalledServerRepository { } Ok(()) } + + async fn set_display_name_override(&self, id: &Uuid, value: Option) -> RepoResult<()> { + if let Some(server) = self.servers.write().unwrap().get_mut(id) { + server.display_name_override = value; + } + Ok(()) + } } // ============================================================================ @@ -432,6 +439,7 @@ impl FeatureSetRepository for MockFeatureSetRepository { member_type: MemberType::Feature, member_id: feature_id.to_string(), mode, + surfaced: false, }; self.members .write() diff --git a/tests/rust/src/services.rs b/tests/rust/src/services.rs index 06501cce..e7500990 100644 --- a/tests/rust/src/services.rs +++ b/tests/rust/src/services.rs @@ -8,7 +8,7 @@ use mcpmux_core::DomainEvent; use tokio::sync::broadcast; use mcpmux_gateway::pool::{FeatureService, ServerManager}; -use mcpmux_gateway::services::PrefixCacheService; +use mcpmux_gateway::services::{PrefixCacheService, SessionOverrideRegistry}; use crate::mocks::{ MockCredentialRepository, MockFeatureSetRepository, MockOutboundOAuthRepository, @@ -59,6 +59,7 @@ impl ServerManagerTestHarness { feature_repo.clone(), feature_set_repo.clone(), prefix_cache.clone(), + SessionOverrideRegistry::new(), )); // Create ConnectionService mock @@ -155,6 +156,7 @@ pub fn test_feature_service() -> ( feature_repo.clone(), feature_set_repo.clone(), prefix_cache, + SessionOverrideRegistry::new(), )); (service, feature_repo, feature_set_repo) diff --git a/tests/rust/tests/integration/feature_routing.rs b/tests/rust/tests/integration/feature_routing.rs index c749eb22..a29593c7 100644 --- a/tests/rust/tests/integration/feature_routing.rs +++ b/tests/rust/tests/integration/feature_routing.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use uuid::Uuid; use mcpmux_core::{FeatureSetRepository, ServerFeature, ServerFeatureRepository}; -use mcpmux_gateway::{FeatureService, PrefixCacheService}; +use mcpmux_gateway::{FeatureService, PrefixCacheService, SessionOverrideRegistry}; use tests::mocks::{MockFeatureSetRepository, MockServerFeatureRepository}; // Helper to create test features @@ -38,6 +38,7 @@ fn create_feature_service( feature_repo as Arc, feature_set_repo as Arc, prefix_cache, + SessionOverrideRegistry::new(), ) } diff --git a/tests/rust/tests/integration/mcp_flows.rs b/tests/rust/tests/integration/mcp_flows.rs index 77c1cc09..2953e009 100644 --- a/tests/rust/tests/integration/mcp_flows.rs +++ b/tests/rust/tests/integration/mcp_flows.rs @@ -17,7 +17,7 @@ use mcpmux_core::{ FeatureSet, FeatureSetMember, FeatureSetRepository, FeatureType, MemberMode, MemberType, ServerFeature, ServerFeatureRepository, }; -use mcpmux_gateway::{FeatureService, PrefixCacheService}; +use mcpmux_gateway::{FeatureService, PrefixCacheService, SessionOverrideRegistry}; use tests::mocks::{MockFeatureSetRepository, MockServerFeatureRepository}; // Helper functions @@ -56,6 +56,7 @@ impl TestContext { Arc::clone(&feature_repo) as Arc, Arc::clone(&feature_set_repo) as Arc, Arc::clone(&prefix_cache), + SessionOverrideRegistry::new(), ); Self { @@ -104,6 +105,7 @@ impl TestContext { member_type: MemberType::Feature, member_id: feature.id.to_string(), mode: MemberMode::Include, + surfaced: false, }); } fs @@ -128,6 +130,7 @@ impl TestContext { member_type: MemberType::Feature, member_id: feature.id.to_string(), mode: MemberMode::Include, + surfaced: false, }); } fs @@ -156,7 +159,7 @@ async fn test_list_tools_with_all_grant() { // Simulate tools/list with grant let tools = ctx .service - .get_tools_for_grants(&ctx.space_id, &[all_fs_id]) + .get_tools_for_grants(&ctx.space_id, &[all_fs_id], None) .await .unwrap(); @@ -187,12 +190,13 @@ async fn test_list_tools_with_restricted_grant() { member_id: tool_a_id.to_string(), member_type: MemberType::Feature, mode: MemberMode::Include, + surfaced: false, }); let custom_fs_id = ctx.add_feature_set(custom_fs).await; let tools = ctx .service - .get_tools_for_grants(&ctx.space_id, &[custom_fs_id]) + .get_tools_for_grants(&ctx.space_id, &[custom_fs_id], None) .await .unwrap(); @@ -235,7 +239,7 @@ async fn test_call_tool_unauthorized() { let tools = ctx .service - .get_tools_for_grants(&ctx.space_id, &[empty_fs_id]) + .get_tools_for_grants(&ctx.space_id, &[empty_fs_id], None) .await .unwrap(); @@ -261,7 +265,7 @@ async fn test_list_resources_with_grant() { let resources = ctx .service - .get_resources_for_grants(&ctx.space_id, &[all_fs_id]) + .get_resources_for_grants(&ctx.space_id, &[all_fs_id], None) .await .unwrap(); @@ -304,7 +308,7 @@ async fn test_resource_custom_uri_scheme() { let resources = ctx .service - .get_resources_for_grants(&ctx.space_id, &[all_fs_id]) + .get_resources_for_grants(&ctx.space_id, &[all_fs_id], None) .await .unwrap(); @@ -334,7 +338,7 @@ async fn test_list_prompts_with_grant() { let prompts = ctx .service - .get_prompts_for_grants(&ctx.space_id, &[all_fs_id]) + .get_prompts_for_grants(&ctx.space_id, &[all_fs_id], None) .await .unwrap(); @@ -396,17 +400,17 @@ async fn test_server_provides_multiple_feature_types() { // Filter by type let tools = ctx .service - .get_tools_for_grants(&ctx.space_id, &[all_fs_id.clone()]) + .get_tools_for_grants(&ctx.space_id, &[all_fs_id.clone()], None) .await .unwrap(); let prompts = ctx .service - .get_prompts_for_grants(&ctx.space_id, &[all_fs_id.clone()]) + .get_prompts_for_grants(&ctx.space_id, &[all_fs_id.clone()], None) .await .unwrap(); let resources = ctx .service - .get_resources_for_grants(&ctx.space_id, &[all_fs_id]) + .get_resources_for_grants(&ctx.space_id, &[all_fs_id], None) .await .unwrap(); @@ -441,7 +445,7 @@ async fn test_aggregate_tools_from_multiple_servers() { let tools = ctx .service - .get_tools_for_grants(&ctx.space_id, &[all_fs_id]) + .get_tools_for_grants(&ctx.space_id, &[all_fs_id], None) .await .unwrap(); @@ -471,7 +475,7 @@ async fn test_partial_server_grant() { let tools = ctx .service - .get_tools_for_grants(&ctx.space_id, &[server_all_a_id]) + .get_tools_for_grants(&ctx.space_id, &[server_all_a_id], None) .await .unwrap(); @@ -511,6 +515,7 @@ async fn test_features_dont_leak_between_spaces() { member_type: MemberType::Feature, member_id: work_tool.id.to_string(), mode: MemberMode::Include, + surfaced: false, }); let work_all_id = work_all.id.clone(); feature_set_repo.create(&work_all).await.unwrap(); @@ -519,11 +524,12 @@ async fn test_features_dont_leak_between_spaces() { feature_repo as Arc, feature_set_repo as Arc, prefix_cache, + SessionOverrideRegistry::new(), ); // Query work space let work_tools = service - .get_tools_for_grants(&space_work, &[work_all_id]) + .get_tools_for_grants(&space_work, &[work_all_id], None) .await .unwrap(); @@ -562,6 +568,7 @@ async fn test_routing_is_space_scoped() { feature_repo as Arc, feature_set_repo as Arc, prefix_cache, + SessionOverrideRegistry::new(), ); // Resolve same qualified name in different spaces @@ -608,7 +615,7 @@ async fn test_unavailable_features_filtered_out() { let tools = ctx .service - .get_tools_for_grants(&ctx.space_id, &[all_fs_id]) + .get_tools_for_grants(&ctx.space_id, &[all_fs_id], None) .await .unwrap(); @@ -630,7 +637,7 @@ async fn test_server_disconnect_marks_features_unavailable() { // Initially available let tools_before = ctx .service - .get_tools_for_grants(&ctx.space_id, &[all_fs_id.clone()]) + .get_tools_for_grants(&ctx.space_id, &[all_fs_id.clone()], None) .await .unwrap(); assert_eq!(tools_before.len(), 2); @@ -644,7 +651,7 @@ async fn test_server_disconnect_marks_features_unavailable() { // After disconnect let tools_after = ctx .service - .get_tools_for_grants(&ctx.space_id, &[all_fs_id]) + .get_tools_for_grants(&ctx.space_id, &[all_fs_id], None) .await .unwrap(); assert_eq!(tools_after.len(), 0); diff --git a/tests/rust/tests/integration/meta_gateway_invoke.rs b/tests/rust/tests/integration/meta_gateway_invoke.rs new file mode 100644 index 00000000..42db2139 --- /dev/null +++ b/tests/rust/tests/integration/meta_gateway_invoke.rs @@ -0,0 +1,800 @@ +//! Integration tests for meta-gateway invoke (search → schema → invoke). + +use std::sync::Arc; +use std::time::Duration; + +use mcpmux_core::{ + Client, DomainEvent, FeatureSet, FeatureSetMember, FeatureSetRepository, + InboundMcpClientRepository, InstalledServerRepository, MemberMode, MemberType, ServerFeature, + ServerFeatureRepository, SpaceRepository, WorkspaceBindingRepository, +}; +use mcpmux_gateway::pool::{format_direct_call_redirect, FeatureService, ToolCallResult}; +use mcpmux_gateway::services::meta_tools::invoke::{ + apply_invoke_result_filter, parse_invoke_filter, shape_json_value, InvokeResultFilter, +}; +use mcpmux_gateway::services::{ + meta_tools, ApprovalBroker, FeatureSetResolverService, InvokeToolBackend, MetaToolRegistry, + PrefixCacheService, SessionOverrideRegistry, SessionRootsRegistry, +}; +use mcpmux_storage::{ + generate_master_key, Database, FieldEncryptor, InboundClientRepository, + SqliteFeatureSetRepository, SqliteInboundMcpClientRepository, SqliteInstalledServerRepository, + SqliteServerFeatureRepository, SqliteSpaceRepository, SqliteWorkspaceBindingRepository, +}; +use serde_json::{json, Value}; +use tests::CannedInvokeBackend; +use tokio::sync::{broadcast, Mutex}; +use uuid::Uuid; + +struct Fixture { + registry: Arc, + feature_service: Arc, + session_overrides: Arc, + session_roots: Arc, + inbound_client_repo: Arc, + server_feature_repo: Arc, + feature_set_repo: Arc, + space_id: Uuid, + client_id: String, + session_id: String, +} + +fn test_encryptor() -> Arc { + let key = generate_master_key().expect("generate key"); + Arc::new(FieldEncryptor::new(&key).expect("create encryptor")) +} + +impl Fixture { + async fn new() -> Self { + Self::with_invoke_backend(None).await + } + + async fn with_invoke_backend(invoke_backend: Option>) -> 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 installed_server_repo: Arc = Arc::new( + SqliteInstalledServerRepository::new(db.clone(), test_encryptor()), + ); + + let default_space = space_repo.get_default().await.unwrap().unwrap(); + let space_id = default_space.id; + + let client = Client::new("InvokeTestClient", "test-type"); + let client_id = client.id.to_string(); + client_repo.create(&client).await.unwrap(); + + let mut list_issues = ServerFeature::tool(space_id, "github", "list_issues"); + list_issues.description = Some("List issues in a repository".into()); + list_issues.raw_json = Some(json!({ + "name": "list_issues", + "description": "List issues in a repository", + "inputSchema": { + "type": "object", + "properties": { + "owner": { "type": "string" }, + "repo": { "type": "string" } + }, + "required": ["owner", "repo"] + } + })); + server_feature_repo.upsert(&list_issues).await.unwrap(); + + let mut grant_all = FeatureSet::new_custom("Grant GitHub", space_id.to_string()); + grant_all.members.push(FeatureSetMember { + id: Uuid::new_v4().to_string(), + feature_set_id: grant_all.id.clone(), + member_type: MemberType::Feature, + member_id: list_issues.id.to_string(), + mode: MemberMode::Include, + surfaced: false, + }); + feature_set_repo.create(&grant_all).await.unwrap(); + + let session_roots = SessionRootsRegistry::new(); + let session_overrides = SessionOverrideRegistry::new(); + let session_id = "sess-invoke".to_string(); + + let inbound_client_repo = Arc::new(InboundClientRepository::new(db.clone())); + let resolver = Arc::new(FeatureSetResolverService::new( + space_repo.clone(), + binding_repo.clone(), + session_roots.clone(), + inbound_client_repo.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, + session_overrides.clone(), + )); + + let broker = Arc::new(ApprovalBroker::new().with_timeout(Duration::from_millis(500))); + let (tx, _event_rx) = broadcast::channel::(32); + + let registry = meta_tools::build_default_registry( + client_repo, + space_repo, + feature_set_repo.clone(), + binding_repo, + server_feature_repo.clone(), + installed_server_repo, + resolver, + feature_service.clone(), + invoke_backend, + session_roots.clone(), + session_overrides.clone(), + broker, + tx, + None, + ); + + Self { + registry, + feature_service, + session_overrides, + session_roots, + inbound_client_repo, + server_feature_repo, + feature_set_repo, + space_id, + client_id, + session_id, + } + } + + /// Grant a FeatureSet to the fixture client (Tier-2 resolver path). + async fn grant_feature_set(&self, feature_set_id: &str) { + self.inbound_client_repo + .grant_feature_set( + &self.client_id, + &self.space_id.to_string(), + feature_set_id, + ) + .await + .unwrap(); + self.session_roots + .set_roots_capable(&self.session_id, false); + } + + fn result_json(result: &rmcp::model::CallToolResult) -> Value { + 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) + } + + async fn call(&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(), + } + } + async fn grant_github_feature_set(&self) -> String { + let fs_id = self + .feature_set_repo + .list_by_space(&self.space_id.to_string()) + .await + .unwrap() + .into_iter() + .find(|fs| fs.name == "Grant GitHub") + .unwrap() + .id; + self.grant_feature_set(&fs_id).await; + fs_id + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn invoke_tool_applies_filter_end_to_end() { + let issues: Vec = (0..20) + .map(|i| { + json!({ + "id": i, + "title": format!("issue-{i}"), + "body": format!("body-{i}") + }) + }) + .collect(); + let payload = json!({ "issues": issues }); + let backend_result = ToolCallResult { + content: vec![json!({ + "type": "text", + "text": payload.to_string(), + })], + structured_content: Some(payload), + is_error: false, + }; + let invoke_backend = CannedInvokeBackend::new() + .with_response("github_list_issues", backend_result) + .into_arc(); + + let f = Fixture::with_invoke_backend(Some(invoke_backend)).await; + f.grant_github_feature_set().await; + f.session_overrides.enable(&f.session_id, "github"); + + let result = f + .call( + "mcpmux_invoke_tool", + json!({ + "server_id": "github", + "tool": "list_issues", + "args": { "owner": "mcpmux", "repo": "mcp-mux" }, + "filter": { + "max_rows": 3, + "fields": ["id", "title"], + "format": "summary" + } + }), + ) + .await; + + assert!(!result.is_error.unwrap_or(true)); + let body = Fixture::result_json(&result); + assert_eq!(body.get("returned"), Some(&json!(3))); + assert_eq!(body.get("total"), Some(&json!(20))); + assert_eq!(body.get("truncated"), Some(&json!(true))); + let sample = body.get("issues").and_then(|v| v.as_array()).unwrap(); + assert_eq!(sample.len(), 3); + assert_eq!(sample[0], json!({ "id": 0, "title": "issue-0" })); + + let structured = result.structured_content.expect("structured content shaped"); + assert_eq!(structured.get("returned"), Some(&json!(3))); + let structured_sample = structured.get("issues").and_then(|v| v.as_array()).unwrap(); + assert_eq!(structured_sample.len(), 3); + assert_eq!(structured_sample[0], json!({ "id": 0, "title": "issue-0" })); +} + +#[tokio::test(flavor = "multi_thread")] +async fn advertised_tools_empty_without_surfaced_members() { + let f = Fixture::new().await; + let fs_ids = vec![ + f.feature_set_repo + .list_by_space(&f.space_id.to_string()) + .await + .unwrap() + .into_iter() + .find(|fs| fs.name == "Grant GitHub") + .unwrap() + .id, + ]; + + let advertised = f + .feature_service + .get_advertised_tools_for_grants(&f.space_id.to_string(), &fs_ids, Some(&f.session_id)) + .await + .unwrap(); + assert!(advertised.is_empty(), "no surfaced members by default"); + + f.session_overrides.enable(&f.session_id, "github"); + let invokable = f + .feature_service + .get_invokable_tools_for_grants(&f.space_id.to_string(), &fs_ids, Some(&f.session_id)) + .await + .unwrap(); + assert_eq!(invokable.len(), 1); + assert_eq!(invokable[0].feature_name, "list_issues"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn github_read_path_enable_search_schema() { + let f = Fixture::new().await; + + let servers = f.call("mcpmux_list_servers", json!({})).await; + let body = Fixture::result_json(&servers); + let github = body + .get("servers") + .and_then(|s| s.as_array()) + .and_then(|arr| arr.iter().find(|s| s.get("id") == Some(&json!("github")))) + .expect("github server listed"); + assert_eq!(github.get("status"), Some(&json!("inactive"))); + + f.session_overrides.enable(&f.session_id, "github"); + + let search = f + .call( + "mcpmux_search_tools", + json!({ + "query": "list issues", + "server_id": "github", + "detail_level": "description" + }), + ) + .await; + let search_body = Fixture::result_json(&search); + let tools = search_body.get("tools").unwrap().as_array().unwrap(); + assert_eq!(tools.len(), 1); + assert_eq!( + tools[0].get("qualified_name"), + Some(&json!("github_list_issues")) + ); + + let schema = f + .call( + "mcpmux_get_tool_schema", + json!({ "tools": "github_list_issues" }), + ) + .await; + let schema_body = Fixture::result_json(&schema); + let schemas = schema_body.get("schemas").unwrap().as_array().unwrap(); + assert_eq!(schemas.len(), 1); + assert!(schemas[0].get("input_schema").is_some()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn invoke_denied_when_server_inactive() { + let f = Fixture::new().await; + let result = f + .call( + "mcpmux_invoke_tool", + json!({ + "server_id": "github", + "tool": "list_issues", + "args": { "owner": "mcpmux", "repo": "mcp-mux" } + }), + ) + .await; + assert!(result.is_error.unwrap_or(false)); + let body = Fixture::result_json(&result); + let message = body.get("message").and_then(|m| m.as_str()).unwrap_or(""); + assert!(message.contains("inactive")); + assert!(message.contains("mcpmux_enable_server")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn search_empty_when_server_inactive() { + let f = Fixture::new().await; + let search = f + .call( + "mcpmux_search_tools", + json!({ "query": "list", "server_id": "github" }), + ) + .await; + let body = Fixture::result_json(&search); + assert_eq!(body.get("total"), Some(&json!(0))); +} + +#[tokio::test(flavor = "multi_thread")] +async fn list_all_tools_filters_by_server_id() { + let f = Fixture::new().await; + + let other = ServerFeature::tool(f.space_id, "firebase", "deploy"); + f.server_feature_repo.upsert(&other).await.unwrap(); + + let all = f.call("mcpmux_list_all_tools", json!({})).await; + let all_body = Fixture::result_json(&all); + assert_eq!(all_body.get("tools").unwrap().as_array().unwrap().len(), 2); + + let filtered = f + .call("mcpmux_list_all_tools", json!({ "server_id": "github" })) + .await; + let filtered_body = Fixture::result_json(&filtered); + let tools = filtered_body.get("tools").unwrap().as_array().unwrap(); + assert_eq!(tools.len(), 1); + assert_eq!(tools[0].get("server_id"), Some(&json!("github"))); +} + +#[tokio::test(flavor = "multi_thread")] +async fn direct_backend_call_redirect_message() { + let msg = format_direct_call_redirect("github_list_issues", "github", "list_issues"); + assert!(msg.contains("mcpmux_invoke_tool")); + assert!(msg.contains("github")); + assert!(msg.contains("list_issues")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn registry_lists_new_meta_tools() { + let f = Fixture::new().await; + let names: Vec = f + .registry + .list_as_tools() + .into_iter() + .map(|t| t.name.to_string()) + .collect(); + assert!(names.iter().any(|n| n == "mcpmux_search_tools")); + assert!(names.iter().any(|n| n == "mcpmux_get_tool_schema")); + assert!(names.iter().any(|n| n == "mcpmux_invoke_tool")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn invoke_input_schema_includes_filter() { + let f = Fixture::new().await; + let invoke = f + .registry + .list_as_tools() + .into_iter() + .find(|t| t.name.as_ref() == "mcpmux_invoke_tool") + .expect("invoke tool registered"); + let schema = invoke.input_schema; + assert!(schema.get("properties").and_then(|p| p.get("filter")).is_some()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn invoke_result_no_filter_passes_through() { + let items: Vec = (0..100).map(|i| json!({ "id": i })).collect(); + let payload = json!({ "items": items.clone() }); + + let shaped = shape_json_value(payload, &InvokeResultFilter::default()); + + assert_eq!(shaped.get("items").and_then(|v| v.as_array()).unwrap().len(), 100); + assert!(shaped.get("truncated").is_none()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn invoke_result_explicit_filter_limits_rows() { + let items: Vec = (0..30).map(|i| json!({ "id": i, "label": format!("row-{i}") })).collect(); + let filter = parse_invoke_filter(Some(&json!({ "max_rows": 5, "fields": ["id"] }))).unwrap(); + + let shaped = shape_json_value(Value::Array(items), &filter); + + assert_eq!(shaped.get("returned"), Some(&json!(5))); + assert_eq!(shaped.get("total"), Some(&json!(30))); + assert_eq!(shaped.get("truncated"), Some(&json!(true))); + let sample = shaped.get("items").and_then(|v| v.as_array()).unwrap(); + assert_eq!(sample.len(), 5); + assert_eq!(sample[0], json!({ "id": 0 })); +} + +#[tokio::test(flavor = "multi_thread")] +async fn invoke_result_filter_shapes_text_content_blocks() { + let rows: Vec = (0..80).map(|i| json!({ "n": i })).collect(); + let content = vec![json!({ + "type": "text", + "text": json!({ "results": rows }).to_string(), + })]; + let filter = parse_invoke_filter(Some(&json!({ "max_rows": 10 }))).unwrap(); + + let (shaped_content, _) = apply_invoke_result_filter(content, None, &filter); + let text = shaped_content[0].get("text").and_then(|t| t.as_str()).unwrap(); + let parsed: Value = serde_json::from_str(text).unwrap(); + + assert_eq!(parsed.get("returned"), Some(&json!(10))); + assert_eq!(parsed.get("total"), Some(&json!(80))); + assert_eq!(parsed.get("truncated"), Some(&json!(true))); +} + +#[tokio::test(flavor = "multi_thread")] +async fn invoke_result_explicit_max_bytes_plain_text() { + let text = "x".repeat(200); + let content = vec![json!({ "type": "text", "text": text })]; + let filter = parse_invoke_filter(Some(&json!({ "max_bytes": 80 }))).unwrap(); + + let (shaped_content, _) = apply_invoke_result_filter(content, None, &filter); + let parsed: Value = + serde_json::from_str(shaped_content[0].get("text").and_then(|t| t.as_str()).unwrap()).unwrap(); + + assert_eq!(parsed.get("truncated"), Some(&json!(true))); + assert_eq!(parsed.get("total"), Some(&json!(200))); +} + +#[tokio::test(flavor = "multi_thread")] +async fn partial_feature_set_binding_limits_search_and_invoke() { + let f = Fixture::new().await; + + let mut create_issue = ServerFeature::tool(f.space_id, "github", "create_issue"); + create_issue.description = Some("Create an issue".into()); + create_issue.raw_json = Some(json!({ + "name": "create_issue", + "description": "Create an issue", + "inputSchema": { "type": "object" } + })); + f.server_feature_repo.upsert(&create_issue).await.unwrap(); + + let list_issues = f + .server_feature_repo + .list_for_space(&f.space_id.to_string()) + .await + .unwrap() + .into_iter() + .find(|feat| feat.feature_name == "list_issues") + .unwrap(); + + let mut partial_fs = FeatureSet::new_custom("Partial GitHub", f.space_id.to_string()); + partial_fs.members.push(FeatureSetMember { + id: Uuid::new_v4().to_string(), + feature_set_id: partial_fs.id.clone(), + member_type: MemberType::Feature, + member_id: list_issues.id.to_string(), + mode: MemberMode::Include, + surfaced: false, + }); + f.feature_set_repo.create(&partial_fs).await.unwrap(); + f.grant_feature_set(&partial_fs.id).await; + f.session_overrides.enable(&f.session_id, "github"); + + let fs_ids = vec![partial_fs.id.clone()]; + let invokable = f + .feature_service + .get_invokable_tools_for_grants(&f.space_id.to_string(), &fs_ids, Some(&f.session_id)) + .await + .unwrap(); + assert_eq!(invokable.len(), 1); + assert_eq!(invokable[0].feature_name, "list_issues"); + + let search = f + .call( + "mcpmux_search_tools", + json!({ "query": "issue", "server_id": "github" }), + ) + .await; + let search_body = Fixture::result_json(&search); + let tools = search_body.get("tools").unwrap().as_array().unwrap(); + assert_eq!(tools.len(), 1); + assert_eq!( + tools[0].get("qualified_name"), + Some(&json!("github_list_issues")) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn surfaced_tool_appears_in_advertised_set() { + let f = Fixture::new().await; + + let list_issues = f + .server_feature_repo + .list_for_space(&f.space_id.to_string()) + .await + .unwrap() + .into_iter() + .find(|feat| feat.feature_name == "list_issues") + .unwrap(); + + let mut surfaced_fs = FeatureSet::new_custom("Surfaced GitHub", f.space_id.to_string()); + surfaced_fs.members.push(FeatureSetMember { + id: Uuid::new_v4().to_string(), + feature_set_id: surfaced_fs.id.clone(), + member_type: MemberType::Feature, + member_id: list_issues.id.to_string(), + mode: MemberMode::Include, + surfaced: true, + }); + f.feature_set_repo.create(&surfaced_fs).await.unwrap(); + + f.session_overrides.enable(&f.session_id, "github"); + + let fs_ids = vec![surfaced_fs.id.clone()]; + let advertised = f + .feature_service + .get_advertised_tools_for_grants(&f.space_id.to_string(), &fs_ids, Some(&f.session_id)) + .await + .unwrap(); + + assert_eq!(advertised.len(), 1); + assert_eq!(advertised[0].feature_name, "list_issues"); + assert_eq!(advertised[0].qualified_name(), "github_list_issues"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn direct_backend_call_gate_allows_surfaced_only() { + let f = Fixture::new().await; + + let features = f + .server_feature_repo + .list_for_space(&f.space_id.to_string()) + .await + .unwrap(); + let list_issues = features + .iter() + .find(|feat| feat.feature_name == "list_issues") + .unwrap(); + + let mut get_me = ServerFeature::tool(f.space_id, "github", "get_me"); + get_me.description = Some("Get authenticated GitHub user".into()); + f.server_feature_repo.upsert(&get_me).await.unwrap(); + + let mut mixed_fs = FeatureSet::new_custom("Mixed Surfaced GitHub", f.space_id.to_string()); + mixed_fs.members.push(FeatureSetMember { + id: Uuid::new_v4().to_string(), + feature_set_id: mixed_fs.id.clone(), + member_type: MemberType::Feature, + member_id: list_issues.id.to_string(), + mode: MemberMode::Include, + surfaced: true, + }); + mixed_fs.members.push(FeatureSetMember { + id: Uuid::new_v4().to_string(), + feature_set_id: mixed_fs.id.clone(), + member_type: MemberType::Feature, + member_id: get_me.id.to_string(), + mode: MemberMode::Include, + surfaced: false, + }); + f.feature_set_repo.create(&mixed_fs).await.unwrap(); + f.grant_feature_set(&mixed_fs.id).await; + f.session_overrides.enable(&f.session_id, "github"); + + let fs_ids = vec![mixed_fs.id.clone()]; + let invokable = f + .feature_service + .get_invokable_tools_for_grants(&f.space_id.to_string(), &fs_ids, Some(&f.session_id)) + .await + .unwrap(); + let advertised = f + .feature_service + .get_advertised_tools_for_grants(&f.space_id.to_string(), &fs_ids, Some(&f.session_id)) + .await + .unwrap(); + + assert_eq!(invokable.len(), 2); + assert_eq!(advertised.len(), 1); + assert_eq!(advertised[0].qualified_name(), "github_list_issues"); + + let is_surfaced = |qualified_name: &str| { + advertised + .iter() + .any(|feature| feature.qualified_name() == qualified_name) + }; + assert!(is_surfaced("github_list_issues")); + assert!(!is_surfaced("github_get_me")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn list_all_tools_marks_invokable_against_acl() { + let f = Fixture::new().await; + + let list_issues = f + .server_feature_repo + .list_for_space(&f.space_id.to_string()) + .await + .unwrap() + .into_iter() + .find(|feat| feat.feature_name == "list_issues") + .unwrap(); + + let mut create_issue = ServerFeature::tool(f.space_id, "github", "create_issue"); + create_issue.description = Some("Create an issue".into()); + f.server_feature_repo.upsert(&create_issue).await.unwrap(); + + let mut partial_fs = FeatureSet::new_custom("Partial GitHub", f.space_id.to_string()); + partial_fs.members.push(FeatureSetMember { + id: Uuid::new_v4().to_string(), + feature_set_id: partial_fs.id.clone(), + member_type: MemberType::Feature, + member_id: list_issues.id.to_string(), + mode: MemberMode::Include, + surfaced: false, + }); + f.feature_set_repo.create(&partial_fs).await.unwrap(); + f.grant_feature_set(&partial_fs.id).await; + f.session_overrides.enable(&f.session_id, "github"); + + let result = f + .call("mcpmux_list_all_tools", json!({ "server_id": "github" })) + .await; + let body = Fixture::result_json(&result); + assert_eq!(body.get("total_installed").and_then(|v| v.as_u64()), Some(2)); + assert_eq!(body.get("total_invokable").and_then(|v| v.as_u64()), Some(1)); + + let tools = body.get("tools").unwrap().as_array().unwrap(); + let list_row = tools + .iter() + .find(|t| t.get("qualified_name") == Some(&json!("github_list_issues"))) + .expect("list_issues in catalog"); + let create_row = tools + .iter() + .find(|t| t.get("qualified_name") == Some(&json!("github_create_issue"))) + .expect("create_issue in catalog"); + assert_eq!(list_row.get("invokable"), Some(&json!(true))); + assert_eq!(create_row.get("invokable"), Some(&json!(false))); + assert_eq!(list_row.get("server_available"), Some(&json!(true))); +} + +#[tokio::test(flavor = "multi_thread")] +async fn get_tool_schema_accepts_string_array() { + let f = Fixture::new().await; + f.grant_github_feature_set().await; + f.session_overrides.enable(&f.session_id, "github"); + + let result = f + .call( + "mcpmux_get_tool_schema", + json!({ "tools": ["github_list_issues"] }), + ) + .await; + let body = Fixture::result_json(&result); + let schemas = body.get("schemas").unwrap().as_array().unwrap(); + assert_eq!(schemas.len(), 1); + assert_eq!( + schemas[0].get("qualified_name"), + Some(&json!("github_list_issues")) + ); + assert!(body.get("missing").is_none()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn get_tool_schema_accepts_json_encoded_array_string() { + let f = Fixture::new().await; + f.grant_github_feature_set().await; + f.session_overrides.enable(&f.session_id, "github"); + + let result = f + .call( + "mcpmux_get_tool_schema", + json!({ "tools": "[\"github_list_issues\"]" }), + ) + .await; + let body = Fixture::result_json(&result); + let schemas = body.get("schemas").unwrap().as_array().unwrap(); + assert_eq!(schemas.len(), 1); + assert_eq!( + schemas[0].get("qualified_name"), + Some(&json!("github_list_issues")) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn get_tool_schema_reports_missing_tools() { + let f = Fixture::new().await; + f.grant_github_feature_set().await; + f.session_overrides.enable(&f.session_id, "github"); + + let result = f + .call( + "mcpmux_get_tool_schema", + json!({ "tools": ["github_list_issues", "github_create_issue"] }), + ) + .await; + let body = Fixture::result_json(&result); + let schemas = body.get("schemas").unwrap().as_array().unwrap(); + assert_eq!(schemas.len(), 1); + let missing = body.get("missing").unwrap().as_array().unwrap(); + assert_eq!(missing, &[json!("github_create_issue")]); + assert!(body.get("message").and_then(|m| m.as_str()).is_some()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn invoke_max_bytes_truncates_json_array_without_max_rows() { + let rows: Vec = (0..40) + .map(|i| json!({ "id": i, "label": format!("row-{i}-padding-value") })) + .collect(); + let payload = json!({ "items": rows }); + let backend_result = ToolCallResult { + content: vec![json!({ + "type": "text", + "text": payload.to_string(), + })], + structured_content: None, + is_error: false, + }; + let invoke_backend = CannedInvokeBackend::new() + .with_response("github_list_issues", backend_result) + .into_arc(); + + let f = Fixture::with_invoke_backend(Some(invoke_backend)).await; + f.grant_github_feature_set().await; + f.session_overrides.enable(&f.session_id, "github"); + + let result = f + .call( + "mcpmux_invoke_tool", + json!({ + "server_id": "github", + "tool": "list_issues", + "args": { "owner": "mcpmux", "repo": "mcp-mux" }, + "filter": { "max_bytes": 512 } + }), + ) + .await; + + assert!(!result.is_error.unwrap_or(true)); + let body = Fixture::result_json(&result); + assert_eq!(body.get("truncated"), Some(&json!(true))); +} diff --git a/tests/rust/tests/integration/meta_tools.rs b/tests/rust/tests/integration/meta_tools.rs index 4b81e6d5..c0e00fe0 100644 --- a/tests/rust/tests/integration/meta_tools.rs +++ b/tests/rust/tests/integration/meta_tools.rs @@ -11,19 +11,21 @@ use std::time::Duration; use futures::FutureExt; use mcpmux_core::{ - normalize_workspace_root, Client, DomainEvent, FeatureSet, FeatureSetRepository, - InboundMcpClientRepository, ServerFeature, ServerFeatureRepository, SpaceRepository, - WorkspaceBindingRepository, + normalize_workspace_root, Client, DomainEvent, FeatureSet, FeatureSetMember, + FeatureSetRepository, InboundMcpClientRepository, InstalledServer, InstalledServerRepository, + MemberMode, MemberType, ServerFeature, ServerFeatureRepository, SpaceRepository, + WorkspaceBinding, WorkspaceBindingRepository, }; use mcpmux_gateway::pool::FeatureService; use mcpmux_gateway::services::{ meta_tools, ApprovalBroker, ApprovalDecision, ApprovalPayload, ApprovalPublisher, - FeatureSetResolverService, MetaToolRegistry, PrefixCacheService, SessionRootsRegistry, + FeatureSetResolverService, MetaToolRegistry, PrefixCacheService, SessionOverrideRegistry, + SessionRootsRegistry, }; use mcpmux_storage::{ - Database, InboundClientRepository, SqliteFeatureSetRepository, - SqliteInboundMcpClientRepository, SqliteServerFeatureRepository, SqliteSpaceRepository, - SqliteWorkspaceBindingRepository, + generate_master_key, Database, FieldEncryptor, InboundClientRepository, + SqliteFeatureSetRepository, SqliteInboundMcpClientRepository, SqliteInstalledServerRepository, + SqliteServerFeatureRepository, SqliteSpaceRepository, SqliteWorkspaceBindingRepository, }; use serde_json::{json, Value}; use tokio::sync::{broadcast, Mutex}; @@ -36,13 +38,23 @@ struct Fixture { client_repo: Arc, feature_set_repo: Arc, binding_repo: Arc, + installed_server_repo: Arc, session_roots: Arc, + session_overrides: Arc, + feature_service: Arc, space_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, + github_tool_id: Uuid, + event_rx: broadcast::Receiver, +} + +fn test_encryptor() -> Arc { + let key = generate_master_key().expect("generate key"); + Arc::new(FieldEncryptor::new(&key).expect("create encryptor")) } impl Fixture { @@ -58,6 +70,9 @@ impl Fixture { Arc::new(SqliteWorkspaceBindingRepository::new(db.clone())); let server_feature_repo: Arc = Arc::new(SqliteServerFeatureRepository::new(db.clone())); + let installed_server_repo: Arc = Arc::new( + SqliteInstalledServerRepository::new(db.clone(), test_encryptor()), + ); let default_space = space_repo.get_default().await.unwrap().unwrap(); let space_id = default_space.id; @@ -82,6 +97,7 @@ impl Fixture { feature2.description = Some("Deploy to Firebase".into()); server_feature_repo.upsert(&feature1).await.unwrap(); server_feature_repo.upsert(&feature2).await.unwrap(); + let github_tool_id = feature1.id; // The space's auto-seeded Default FS is the resolver's baseline // when no binding matches — no "set active FS" step needed. @@ -93,6 +109,7 @@ impl Fixture { client_repo.create(&client).await.unwrap(); let session_roots = SessionRootsRegistry::new(); + let session_overrides = SessionOverrideRegistry::new(); let session_id = "sess-meta".to_string(); let inbound_client_repo = Arc::new(InboundClientRepository::new(db.clone())); @@ -108,10 +125,11 @@ impl Fixture { server_feature_repo.clone(), feature_set_repo.clone(), prefix_cache, + session_overrides.clone(), )); let broker = Arc::new(ApprovalBroker::new().with_timeout(Duration::from_millis(500))); - let (tx, _rx) = broadcast::channel::(32); + let (tx, event_rx) = broadcast::channel::(32); let registry = meta_tools::build_default_registry( client_repo.clone(), @@ -119,9 +137,12 @@ impl Fixture { feature_set_repo.clone(), binding_repo.clone(), server_feature_repo.clone(), + installed_server_repo.clone(), resolver, - feature_service, + feature_service.clone(), + None, session_roots.clone(), + session_overrides.clone(), broker.clone(), tx, None, @@ -133,11 +154,16 @@ impl Fixture { client_repo, feature_set_repo, binding_repo, + installed_server_repo, session_roots, + session_overrides, + feature_service, space_id, client_id, session_id, fs_android_id, + github_tool_id, + event_rx, } } @@ -250,6 +276,336 @@ async fn list_feature_sets_returns_space_contents() { assert_eq!(sets.len(), 3, "Default + 2 custom expected"); } +fn server_status(body: &Value, server_id: &str) -> String { + body.get("servers") + .unwrap() + .as_array() + .unwrap() + .iter() + .find(|s| s.get("id").and_then(|v| v.as_str()) == Some(server_id)) + .unwrap() + .get("status") + .unwrap() + .as_str() + .unwrap() + .to_string() +} + +async fn bind_github_only_to_session_root(f: &Fixture) -> String { + use mcpmux_core::WorkspaceBinding; + + let fs_id = github_only_fs(f).await; + let root = "/tmp/mcpmux-list-servers-test"; + f.session_roots.set_roots_capable(&f.session_id, true); + f.session_roots.set(&f.session_id, [root]); + let binding = WorkspaceBinding::new(normalize_workspace_root(root), f.space_id, fs_id.clone()); + f.binding_repo.create(&binding).await.unwrap(); + fs_id +} + +#[tokio::test(flavor = "multi_thread")] +async fn list_servers_marks_unbound_servers_inactive() { + let f = Fixture::new().await; + let result = f + .registry + .call( + "mcpmux_list_servers", + &f.client_id, + Some(&f.session_id), + json!({}), + ) + .await + .unwrap(); + assert!(!Fixture::is_error(&result)); + let body = Fixture::result_json(&result); + let servers = body.get("servers").unwrap().as_array().unwrap(); + assert_eq!(servers.len(), 2); + assert_eq!(server_status(&body, "github"), "inactive"); + assert_eq!(server_status(&body, "firebase"), "inactive"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn list_servers_shows_enabled_via_binding() { + let f = Fixture::new().await; + bind_github_only_to_session_root(&f).await; + + let result = f + .registry + .call( + "mcpmux_list_servers", + &f.client_id, + Some(&f.session_id), + json!({}), + ) + .await + .unwrap(); + let body = Fixture::result_json(&result); + assert_eq!(server_status(&body, "github"), "enabled_via_binding"); + assert_eq!(server_status(&body, "firebase"), "inactive"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn list_servers_shows_session_override_statuses() { + let f = Fixture::new().await; + bind_github_only_to_session_root(&f).await; + f.session_overrides.enable(&f.session_id, "firebase"); + f.session_overrides.disable(&f.session_id, "github"); + + let result = f + .registry + .call( + "mcpmux_list_servers", + &f.client_id, + Some(&f.session_id), + json!({}), + ) + .await + .unwrap(); + let body = Fixture::result_json(&result); + assert_eq!(server_status(&body, "github"), "disabled_via_session"); + assert_eq!(server_status(&body, "firebase"), "enabled_via_session"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn list_servers_includes_cloned_from_for_clone_installs() { + let f = Fixture::new().await; + let space_id = f.space_id.to_string(); + + let posthog = InstalledServer::new(&space_id, "posthog"); + f.installed_server_repo.install(&posthog).await.unwrap(); + let posthog_work = InstalledServer::new(&space_id, "posthog-work").with_cloned_from("posthog"); + f.installed_server_repo + .install(&posthog_work) + .await + .unwrap(); + + let mut clone_tool = ServerFeature::tool(f.space_id, "posthog-work", "capture"); + clone_tool.display_name = Some("PostHog (work)".into()); + f.registry + .context() + .server_feature_repo + .upsert(&clone_tool) + .await + .unwrap(); + + let result = f + .registry + .call( + "mcpmux_list_servers", + &f.client_id, + Some(&f.session_id), + json!({}), + ) + .await + .unwrap(); + let body = Fixture::result_json(&result); + let clone_entry = body + .get("servers") + .unwrap() + .as_array() + .unwrap() + .iter() + .find(|s| s.get("id").and_then(|v| v.as_str()) == Some("posthog-work")) + .expect("clone server in manifest"); + assert_eq!( + clone_entry.get("cloned_from").and_then(|v| v.as_str()), + Some("posthog") + ); + + let github_entry = body + .get("servers") + .unwrap() + .as_array() + .unwrap() + .iter() + .find(|s| s.get("id").and_then(|v| v.as_str()) == Some("github")) + .expect("github in manifest"); + assert!(github_entry.get("cloned_from").is_none()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn enable_server_adds_tools_on_next_list() { + let f = Fixture::new().await; + let result = f + .registry + .call( + "mcpmux_enable_server", + &f.client_id, + Some(&f.session_id), + json!({ "server_id": "github" }), + ) + .await + .unwrap(); + assert!(!Fixture::is_error(&result)); + + let tools = f + .feature_service + .get_tools_for_grants(&f.space_id.to_string(), &[], Some(&f.session_id)) + .await + .unwrap(); + assert_eq!(tools.len(), 1); + assert_eq!(tools[0].server_id, "github"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn disable_server_removes_tools_from_list() { + let f = Fixture::new().await; + f.session_overrides.enable(&f.session_id, "github"); + + f.registry + .call( + "mcpmux_disable_server", + &f.client_id, + Some(&f.session_id), + json!({ "server_id": "github" }), + ) + .await + .unwrap(); + + let tools = f + .feature_service + .get_tools_for_grants(&f.space_id.to_string(), &[], Some(&f.session_id)) + .await + .unwrap(); + assert!(tools.is_empty()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn enable_server_workspace_persists_on_binding() { + let f = Fixture::new().await; + f.attach_auto_publisher(ApprovalDecision::AllowOnce); + bind_github_only_to_session_root(&f).await; + + let result = f + .registry + .call( + "mcpmux_enable_server", + &f.client_id, + Some(&f.session_id), + json!({ "server_id": "firebase", "scope": "workspace" }), + ) + .await + .unwrap(); + assert!(!Fixture::is_error(&result)); + let body = Fixture::result_json(&result); + assert_eq!(body.get("scope").unwrap().as_str().unwrap(), "workspace"); + + let root = normalize_workspace_root("/tmp/mcpmux-list-servers-test"); + let binding = f + .binding_repo + .find_longest_prefix_match(&f.space_id, &[root.clone()]) + .await + .unwrap() + .unwrap(); + assert_eq!(binding.feature_set_ids.len(), 2); + + let new_session = "sess-restart-sim"; + let tools = f + .feature_service + .get_tools_for_grants( + &f.space_id.to_string(), + &binding.feature_set_ids, + Some(new_session), + ) + .await + .unwrap(); + let servers: std::collections::HashSet<_> = + tools.iter().map(|t| t.server_id.as_str()).collect(); + assert!(servers.contains("github")); + assert!(servers.contains("firebase")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn disable_server_workspace_removes_server_all_from_binding() { + let f = Fixture::new().await; + f.attach_auto_publisher(ApprovalDecision::AllowOnce); + bind_github_only_to_session_root(&f).await; + + f.registry + .call( + "mcpmux_enable_server", + &f.client_id, + Some(&f.session_id), + json!({ "server_id": "firebase", "scope": "workspace" }), + ) + .await + .unwrap(); + + f.registry + .call( + "mcpmux_disable_server", + &f.client_id, + Some(&f.session_id), + json!({ "server_id": "firebase", "scope": "workspace" }), + ) + .await + .unwrap(); + + let root = normalize_workspace_root("/tmp/mcpmux-list-servers-test"); + let binding = f + .binding_repo + .find_longest_prefix_match(&f.space_id, &[root.clone()]) + .await + .unwrap() + .unwrap(); + assert_eq!(binding.feature_set_ids.len(), 1); + + let tools = f + .feature_service + .get_tools_for_grants(&f.space_id.to_string(), &binding.feature_set_ids, None) + .await + .unwrap(); + assert_eq!(tools.len(), 1); + assert_eq!(tools[0].server_id, "github"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn enable_server_workspace_requires_binding() { + let f = Fixture::new().await; + f.attach_auto_publisher(ApprovalDecision::AllowOnce); + f.session_roots.set_roots_capable(&f.session_id, true); + f.session_roots + .set(&f.session_id, ["/tmp/unbound-workspace"]); + + let result = f + .call_tool_as_handler_would( + "mcpmux_enable_server", + json!({ "server_id": "github", "scope": "workspace" }), + ) + .await; + assert!(Fixture::is_error(&result)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn enable_server_emits_session_override_audit_decision() { + let mut f = Fixture::new().await; + f.registry + .call( + "mcpmux_enable_server", + &f.client_id, + Some(&f.session_id), + json!({ "server_id": "github" }), + ) + .await + .unwrap(); + + let evt = tokio::time::timeout(Duration::from_millis(200), f.event_rx.recv()) + .await + .expect("receive within 200ms") + .expect("event"); + match evt { + DomainEvent::MetaToolInvoked { + tool_name, + decision, + .. + } => { + assert_eq!(tool_name, "mcpmux_enable_server"); + assert_eq!(decision, "session_override"); + } + other => panic!("unexpected event: {other:?}"), + } +} + // `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 @@ -403,6 +759,61 @@ async fn bind_current_workspace_creates_binding_with_normalized_root() { ); } +#[tokio::test(flavor = "multi_thread")] +async fn bind_current_workspace_updates_existing_binding_for_same_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/" + }; + let normalized = normalize_workspace_root(input); + f.session_roots.set(&f.session_id, [input]); + + let fs_full_id = { + let sets = f + .feature_set_repo + .list_by_space(&f.space_id.to_string()) + .await + .unwrap(); + let full = sets + .iter() + .find(|fs| fs.name == "Full Access") + .expect("Full Access FS"); + Uuid::parse_str(&full.id).unwrap() + }; + + // Seed an existing binding (simulates Workspaces UI or prior bind). + let starter = WorkspaceBinding::new( + normalized.clone(), + f.space_id, + f.fs_android_id.to_string(), + ); + f.binding_repo.create(&starter).await.unwrap(); + + let result = f + .registry + .call( + "mcpmux_bind_current_workspace", + &f.client_id, + Some(&f.session_id), + json!({ "feature_set_id": fs_full_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, "must not insert a second binding row"); + assert_eq!(bindings[0].id, starter.id, "must reuse existing binding id"); + assert_eq!(bindings[0].workspace_root, normalized); + assert_eq!( + bindings[0].feature_set_ids, + vec![fs_full_id.to_string()] + ); +} + #[tokio::test(flavor = "multi_thread")] async fn invalid_feature_set_argument_rejected() { let f = Fixture::new().await; @@ -438,6 +849,9 @@ async fn registry_advertises_every_default_tool_with_annotations() { for expected in [ "mcpmux_list_all_tools", "mcpmux_list_feature_sets", + "mcpmux_list_servers", + "mcpmux_enable_server", + "mcpmux_disable_server", "mcpmux_create_feature_set", "mcpmux_bind_current_workspace", ] { @@ -485,6 +899,9 @@ async fn bare_registry( Arc::new(SqliteWorkspaceBindingRepository::new(db.clone())); let server_feature_repo: Arc = Arc::new(SqliteServerFeatureRepository::new(db.clone())); + let installed_server_repo: Arc = Arc::new( + SqliteInstalledServerRepository::new(db.clone(), test_encryptor()), + ); let _space = space_repo.get_default().await.unwrap().unwrap(); let client = Client::new("c", "t"); @@ -503,6 +920,7 @@ async fn bare_registry( server_feature_repo.clone(), feature_set_repo.clone(), prefix_cache, + SessionOverrideRegistry::new(), )); let (tx, rx) = broadcast::channel::(32); let registry = meta_tools::build_default_registry( @@ -511,9 +929,12 @@ async fn bare_registry( feature_set_repo, binding_repo, server_feature_repo, + installed_server_repo, resolver, feature_service, + None, SessionRootsRegistry::new(), + SessionOverrideRegistry::new(), Arc::new(ApprovalBroker::new()), tx.clone(), settings_repo, @@ -603,6 +1024,9 @@ 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 installed_server_repo: Arc = Arc::new( + SqliteInstalledServerRepository::new(db.clone(), test_encryptor()), + ); let inbound_client_repo = Arc::new(InboundClientRepository::new(db.clone())); let resolver = Arc::new(FeatureSetResolverService::new( space_repo.clone(), @@ -615,6 +1039,7 @@ async fn master_switch_toggles_registry_visibility() { server_feature_repo.clone(), feature_set_repo.clone(), prefix_cache, + SessionOverrideRegistry::new(), )); let (tx, _) = broadcast::channel::(16); let registry = meta_tools::build_default_registry( @@ -623,9 +1048,12 @@ async fn master_switch_toggles_registry_visibility() { feature_set_repo, binding_repo, server_feature_repo, + installed_server_repo, resolver, feature_service, + None, SessionRootsRegistry::new(), + SessionOverrideRegistry::new(), Arc::new(ApprovalBroker::new()), tx, Some(settings_repo.clone()), @@ -650,3 +1078,84 @@ async fn master_switch_toggles_registry_visibility() { // Silence unused-import warnings from helper imports that only some tests exercise. #[allow(dead_code)] fn _unused(_: ApprovalPayload) {} + +// ============================================================================ +// Session override composition (Phase 1) +// ============================================================================ + +async fn github_only_fs(f: &Fixture) -> String { + let mut fs = FeatureSet::new_custom("GitHub only", f.space_id.to_string()); + fs.members.push(FeatureSetMember { + id: Uuid::new_v4().to_string(), + feature_set_id: fs.id.clone(), + member_type: MemberType::Feature, + member_id: f.github_tool_id.to_string(), + mode: MemberMode::Include, + surfaced: false, + }); + let id = fs.id.clone(); + f.feature_set_repo.create(&fs).await.unwrap(); + id +} + +#[tokio::test] +async fn session_override_deny_bootstrap_enables_server() { + let f = Fixture::new().await; + f.session_overrides.enable(&f.session_id, "github"); + + let tools = f + .feature_service + .get_tools_for_grants(&f.space_id.to_string(), &[], Some(&f.session_id)) + .await + .unwrap(); + + assert_eq!(tools.len(), 1); + assert_eq!(tools[0].server_id, "github"); + assert_eq!(tools[0].feature_name, "create_issue"); +} + +#[tokio::test] +async fn session_override_disable_mutes_bound_server() { + let f = Fixture::new().await; + let fs_id = github_only_fs(&f).await; + + let before = f + .feature_service + .get_tools_for_grants( + &f.space_id.to_string(), + &[fs_id.clone()], + Some(&f.session_id), + ) + .await + .unwrap(); + assert_eq!(before.len(), 1); + + f.session_overrides.disable(&f.session_id, "github"); + + let after = f + .feature_service + .get_tools_for_grants(&f.space_id.to_string(), &[fs_id], Some(&f.session_id)) + .await + .unwrap(); + assert!(after.is_empty()); +} + +#[tokio::test] +async fn session_override_additive_over_binding() { + let f = Fixture::new().await; + let fs_id = github_only_fs(&f).await; + + f.session_overrides.enable(&f.session_id, "firebase"); + + let tools = f + .feature_service + .get_tools_for_grants(&f.space_id.to_string(), &[fs_id], Some(&f.session_id)) + .await + .unwrap(); + + assert_eq!(tools.len(), 2); + let servers: std::collections::HashSet<_> = + tools.iter().map(|t| t.server_id.as_str()).collect(); + assert!(servers.contains("github")); + assert!(servers.contains("firebase")); +} diff --git a/tests/rust/tests/integration/mod.rs b/tests/rust/tests/integration/mod.rs index c1f1acb3..eaf1640a 100644 --- a/tests/rust/tests/integration/mod.rs +++ b/tests/rust/tests/integration/mod.rs @@ -11,5 +11,7 @@ mod feature_routing; mod feature_set_resolver; mod mcp_flows; +mod meta_gateway_invoke; mod meta_tools; +mod server_clone; mod workspace_binding_events; diff --git a/tests/rust/tests/integration/server_clone.rs b/tests/rust/tests/integration/server_clone.rs new file mode 100644 index 00000000..78cfedc5 --- /dev/null +++ b/tests/rust/tests/integration/server_clone.rs @@ -0,0 +1,384 @@ +//! Integration tests for server account clones — lifecycle, prefixes, and uninstall edges. + +use std::collections::HashMap; +use std::sync::Arc; + +use mcpmux_core::{ + application::ServerAppService, EventBus, InstalledServer, InstalledServerRepository, + ServerDefinition, ServerDiscoveryService, ServerFeature, ServerFeatureRepository, ServerSource, + SpaceRepository, TransportConfig, TransportMetadata, +}; +use mcpmux_gateway::{FeatureService, PrefixCacheService, SessionOverrideRegistry}; +use mcpmux_storage::{ + generate_master_key, FieldEncryptor, SqliteInstalledServerRepository, + SqliteServerFeatureRepository, SqliteSpaceRepository, +}; +use tests::db::TestDatabase; +use tests::fixtures; +use tokio::sync::Mutex; +use uuid::Uuid; + +struct CloneFixture { + service: ServerAppService, + installed_server_repo: Arc, + feature_repo: Arc, + prefix_cache: Arc, + feature_service: Arc, + space_id: Uuid, +} + +impl CloneFixture { + async fn new() -> Self { + let test_db = TestDatabase::in_memory(); + let db = Arc::new(Mutex::new(test_db.db)); + let key = generate_master_key().expect("generate key"); + let encryptor = Arc::new(FieldEncryptor::new(&key).expect("create encryptor")); + + let space_repo = SqliteSpaceRepository::new(db.clone()); + let default_space = space_repo.get_default().await.unwrap().unwrap(); + let space_id = default_space.id; + + let installed_server_repo: Arc = + Arc::new(SqliteInstalledServerRepository::new(db.clone(), encryptor)); + let feature_repo: Arc = + Arc::new(SqliteServerFeatureRepository::new(db)); + + let prefix_cache = Arc::new(PrefixCacheService::new().with_dependencies( + installed_server_repo.clone(), + Arc::new(ServerDiscoveryService::new( + std::env::temp_dir().join(format!("mcpmux-clone-test-{}", Uuid::new_v4())), + std::env::temp_dir().join(format!("mcpmux-clone-spaces-{}", Uuid::new_v4())), + )), + )); + let feature_service = Arc::new(FeatureService::new( + feature_repo.clone(), + Arc::new(tests::mocks::MockFeatureSetRepository::new()), + prefix_cache.clone(), + SessionOverrideRegistry::new(), + )); + + let service = ServerAppService::new( + installed_server_repo.clone(), + Some(feature_repo.clone()), + None, + EventBus::new().sender(), + ); + + Self { + service, + installed_server_repo, + feature_repo, + prefix_cache, + feature_service, + space_id, + } + } + + fn space_id_str(&self) -> String { + self.space_id.to_string() + } +} + +fn env_stdio_definition(server_id: &str, name: &str, alias: &str) -> ServerDefinition { + ServerDefinition { + id: server_id.to_string(), + name: name.to_string(), + description: None, + alias: Some(alias.to_string()), + auth: None, + icon: None, + transport: TransportConfig::Stdio { + command: "echo".to_string(), + args: vec!["mcp".to_string()], + env: HashMap::from([("ACCOUNT".to_string(), "${ACCOUNT}".to_string())]), + metadata: TransportMetadata::default(), + }, + categories: vec![], + publisher: None, + source: ServerSource::Bundled, + badges: vec![], + hosting_type: Default::default(), + license: None, + license_url: None, + installation: None, + capabilities: None, + sponsored: None, + media: None, + changelog_url: None, + } +} + +async fn seed_tool( + feature_repo: &Arc, + space_id: &str, + server_id: &str, + tool_name: &str, +) { + let mut feature = ServerFeature::tool(space_id, server_id, tool_name); + feature.is_available = true; + feature_repo.upsert(&feature).await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn two_clones_have_distinct_prefixes_and_env() { + let fixture = CloneFixture::new().await; + let space_id = fixture.space_id; + let space_id_str = fixture.space_id_str(); + + let source = fixtures::test_installed_server(&space_id_str, "posthog") + .with_definition(&env_stdio_definition("posthog", "PostHog", "posthog")); + fixture + .installed_server_repo + .install(&source) + .await + .unwrap(); + + let clone_work = fixture + .service + .clone_server(space_id, "posthog", "work", None, None) + .await + .expect("clone work"); + let clone_personal = fixture + .service + .clone_server(space_id, "posthog", "personal", None, None) + .await + .expect("clone personal"); + + fixture + .service + .update_config( + space_id, + "posthog-work", + HashMap::from([("ACCOUNT".to_string(), "work-account".to_string())]), + Some(HashMap::from([( + "ACCOUNT".to_string(), + "work-account".to_string(), + )])), + None, + None, + None, + ) + .await + .unwrap(); + fixture + .service + .update_config( + space_id, + "posthog-personal", + HashMap::from([("ACCOUNT".to_string(), "personal-account".to_string())]), + Some(HashMap::from([( + "ACCOUNT".to_string(), + "personal-account".to_string(), + )])), + None, + None, + None, + ) + .await + .unwrap(); + + seed_tool( + &fixture.feature_repo, + &space_id_str, + "posthog-work", + "capture", + ) + .await; + seed_tool( + &fixture.feature_repo, + &space_id_str, + "posthog-personal", + "capture", + ) + .await; + + let work_prefix = fixture + .prefix_cache + .assign_prefix_for_server(&space_id_str, "posthog-work") + .await; + let personal_prefix = fixture + .prefix_cache + .assign_prefix_for_server(&space_id_str, "posthog-personal") + .await; + + assert_eq!(work_prefix, "work"); + assert_eq!(personal_prefix, "personal"); + assert_ne!(work_prefix, personal_prefix); + + let work_resolved = fixture + .feature_service + .find_server_for_qualified_tool(&space_id_str, "work_capture") + .await + .unwrap() + .expect("work tool resolves"); + let personal_resolved = fixture + .feature_service + .find_server_for_qualified_tool(&space_id_str, "personal_capture") + .await + .unwrap() + .expect("personal tool resolves"); + + assert_eq!(work_resolved.0, "posthog-work"); + assert_eq!(personal_resolved.0, "posthog-personal"); + + let stored_work = fixture + .installed_server_repo + .get_by_server_id(&space_id_str, "posthog-work") + .await + .unwrap() + .unwrap(); + let stored_personal = fixture + .installed_server_repo + .get_by_server_id(&space_id_str, "posthog-personal") + .await + .unwrap() + .unwrap(); + + assert_eq!( + stored_work.input_values.get("ACCOUNT").map(String::as_str), + Some("work-account") + ); + assert_eq!( + stored_personal + .input_values + .get("ACCOUNT") + .map(String::as_str), + Some("personal-account") + ); + assert_eq!(clone_work.cloned_from.as_deref(), Some("posthog")); + assert_eq!(clone_personal.cloned_from.as_deref(), Some("posthog")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn uninstall_clone_does_not_affect_source() { + let fixture = CloneFixture::new().await; + let space_id = fixture.space_id; + let space_id_str = fixture.space_id_str(); + + let source = fixtures::test_installed_server(&space_id_str, "posthog") + .with_definition(&env_stdio_definition("posthog", "PostHog", "posthog")); + fixture + .installed_server_repo + .install(&source) + .await + .unwrap(); + fixture + .service + .clone_server(space_id, "posthog", "work", None, None) + .await + .unwrap(); + + fixture + .service + .uninstall(space_id, "posthog-work") + .await + .expect("clone uninstall"); + + assert!(fixture + .installed_server_repo + .get_by_server_id(&space_id_str, "posthog") + .await + .unwrap() + .is_some()); + assert!(fixture + .installed_server_repo + .get_by_server_id(&space_id_str, "posthog-work") + .await + .unwrap() + .is_none()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn list_clone_dependents_returns_source_clones() { + let fixture = CloneFixture::new().await; + let space_id = fixture.space_id; + let space_id_str = fixture.space_id_str(); + + let source = fixtures::test_installed_server(&space_id_str, "posthog") + .with_definition(&env_stdio_definition("posthog", "PostHog", "posthog")); + fixture + .installed_server_repo + .install(&source) + .await + .unwrap(); + fixture + .service + .clone_server(space_id, "posthog", "work", None, None) + .await + .unwrap(); + fixture + .service + .clone_server(space_id, "posthog", "personal", None, None) + .await + .unwrap(); + + let dependents = fixture + .service + .list_clone_dependents(&space_id_str, "posthog") + .await + .unwrap(); + + assert_eq!(dependents.len(), 2); + let ids: Vec<_> = dependents + .iter() + .map(|server| server.server_id.as_str()) + .collect(); + assert!(ids.contains(&"posthog-work")); + assert!(ids.contains(&"posthog-personal")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn clone_prefixes_do_not_break_existing_alias_uniqueness() { + let fixture = CloneFixture::new().await; + let space_id_str = fixture.space_id_str(); + + let posthog = fixtures::test_installed_server(&space_id_str, "posthog") + .with_definition(&env_stdio_definition("posthog", "PostHog", "api")); + let other = fixtures::test_installed_server(&space_id_str, "other-server") + .with_definition(&env_stdio_definition("other-server", "Other", "api")); + + fixture + .installed_server_repo + .install(&posthog) + .await + .unwrap(); + fixture.installed_server_repo.install(&other).await.unwrap(); + + let clone = InstalledServer::new(&space_id_str, "posthog-work") + .with_definition(&env_stdio_definition( + "posthog-work", + "PostHog (work)", + "work", + )) + .with_cloned_from("posthog"); + fixture.installed_server_repo.install(&clone).await.unwrap(); + + let posthog_prefix = fixture + .prefix_cache + .assign_prefix_runtime(&space_id_str, "posthog", Some("api")) + .await; + let clone_prefix = fixture + .prefix_cache + .assign_prefix_runtime(&space_id_str, "posthog-work", Some("work")) + .await; + let other_prefix = fixture + .prefix_cache + .assign_prefix_runtime(&space_id_str, "other-server", Some("api")) + .await; + + assert_eq!(posthog_prefix, "api"); + assert_eq!(clone_prefix, "work"); + assert_eq!(other_prefix, "other-server"); + assert!( + !fixture + .prefix_cache + .is_prefix_available(&space_id_str, "api") + .await + ); + assert!( + !fixture + .prefix_cache + .is_prefix_available(&space_id_str, "work") + .await + ); +} diff --git a/tests/rust/tests/oauth/dcr.rs b/tests/rust/tests/oauth/dcr.rs index d6ca2345..7d132c59 100644 --- a/tests/rust/tests/oauth/dcr.rs +++ b/tests/rust/tests/oauth/dcr.rs @@ -71,11 +71,20 @@ fn test_external_https_rejected() { } #[test] -fn test_mixed_valid_invalid_rejected() { - // One invalid URI should fail the whole validation +fn test_mixed_valid_invalid_skips_invalid() { + // Invalid URIs are skipped — clients like Cursor send a mix and only use valid ones. let uris = vec![ "http://127.0.0.1:8080/callback".to_string(), - "https://evil.com/steal".to_string(), // invalid + "https://evil.com/steal".to_string(), + ]; + assert!(validate_redirect_uris(&uris).is_ok()); +} + +#[test] +fn test_all_invalid_rejected() { + let uris = vec![ + "https://evil.com/steal".to_string(), + "http://example.com/callback".to_string(), ]; assert!(validate_redirect_uris(&uris).is_err()); } diff --git a/tests/rust/tests/streamable_http/gateway_notifications.rs b/tests/rust/tests/streamable_http/gateway_notifications.rs index 7ba1fc85..834e0530 100644 --- a/tests/rust/tests/streamable_http/gateway_notifications.rs +++ b/tests/rust/tests/streamable_http/gateway_notifications.rs @@ -200,6 +200,7 @@ impl TestGateway { let notifier = Arc::new(MCPNotifier::new( services.feature_set_resolver.clone(), services.pool_services.feature_service.clone(), + services.session_overrides.clone(), )); // Start MCPNotifier listening for domain events @@ -580,8 +581,9 @@ async fn test_gateway_content_deduping_prevents_spurious_notifications() { let tools_count = client_handler.tools_count.clone(); let client = connect_client(&gw.url, client_handler).await; - // Wait for init + hash priming + // Wait for init + hash priming (first tools/list may fire one resolution-flip notification) tokio::time::sleep(std::time::Duration::from_millis(500)).await; + let baseline = tools_count.load(Ordering::SeqCst); // Emit ToolsChanged WITHOUT changing features (hash stays same) gw.emit(DomainEvent::ToolsChanged { @@ -594,7 +596,7 @@ async fn test_gateway_content_deduping_prevents_spurious_notifications() { assert_eq!( tools_count.load(Ordering::SeqCst), - 0, + baseline, "No notification should be sent when features haven't changed (content deduping)" );