diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index bbecdcf2..fc66845d 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,4 +1,4 @@ -blank_issues_enabled: false +blank_issues_enabled: true contact_links: - name: Questions & Help url: https://github.com/mcpmux/mcp-mux/discussions/categories/q-a @@ -6,3 +6,12 @@ contact_links: - name: Feature Ideas url: https://github.com/mcpmux/mcp-mux/discussions/categories/ideas about: Share and discuss feature ideas + - name: Contribute a Server Definition (PR) + url: https://github.com/mcpmux/mcp-servers/blob/main/CONTRIBUTING.md + about: Server definitions live in the mcp-servers repo and land via PR — read the guide + - name: Request a Server + url: https://github.com/mcpmux/mcp-servers/issues/new?template=request-server.yml + about: Ask the community to add an MCP server to the registry + - name: Report a Server Definition Bug + url: https://github.com/mcpmux/mcp-servers/issues/new?template=bug-report.yml + about: Found a broken or incorrect server in the registry? Report it here diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..b6e7a2d6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,131 @@ +# AGENTS.md + +Guidance for coding agents working inside the `mcp-mux` repo — the McpMux desktop app and local gateway. Complements [`README.md`](README.md) and [`CONTRIBUTING.md`](CONTRIBUTING.md); when anything here conflicts with an explicit user instruction in the current session, the user wins. + +## Project Overview + +McpMux is a Tauri 2 desktop app (Rust + React 19) with a local Axum HTTP gateway on `localhost:45818`. It lets users configure MCP servers once and connect every AI client (Cursor, Claude Desktop, VS Code, Windsurf) through a single endpoint, with credentials encrypted in the OS keychain instead of plain-text JSON files. + +A more detailed map of the workspace lives in [`CLAUDE.md`](CLAUDE.md) at the repo root — read it for the crate layout, frontend architecture, and cross-project context. This file captures the minimum an agent needs to make safe, useful changes here. + +## Workspace Layout + +``` +mcp-mux/ +├── apps/desktop/ # Tauri shell — React frontend (src/) + Rust Tauri commands (src-tauri/) +├── crates/ +│ ├── mcpmux-core/ # Domain entities, repository traits, service layer, EventBus +│ ├── mcpmux-gateway/ # Axum gateway — routing, OAuth refresh, FeatureSet filtering +│ ├── mcpmux-storage/ # SQLite + AES-256-GCM field encryption + OS keychain +│ └── mcpmux-mcp/ # MCP protocol client wrapper (rmcp SDK) +├── packages/ui/ # Shared UI components (`@mcpmux/ui`) +├── schemas/ # JSON Schemas surfaced in the Monaco editor +└── tests/ # Rust integration, TS unit (vitest), desktop E2E (WDIO), web E2E (playwright) +``` + +## Build & Dev Commands + +Run everything from `mcp-mux/`: + +| Command | What it does | +|---------|--------------| +| `pnpm setup` | First-time dev environment setup (PowerShell on Windows). | +| `pnpm dev` | Tauri desktop dev mode (Rust + React hot-reload). | +| `pnpm dev:web` | Web UI only via Vite — no Rust, no Tauri shell. | +| `pnpm build` | Production Tauri build for the current platform. | +| `pnpm validate` | Full correctness gate — runs the items below in sequence. | +| `pnpm lint` | ESLint (recursive) + `cargo clippy --workspace -- -D warnings`. | +| `pnpm lint:fix` | Auto-fix lint issues. | +| `pnpm format` | `prettier --write .` + `cargo fmt --all`. | +| `pnpm format:check` | Formatting check (no writes). | +| `pnpm typecheck` | Recursive TypeScript typecheck. | + +**Before claiming a change is done**, run `pnpm validate` (or the relevant subset) — it mirrors what CI enforces. + +## Testing + +| Command | Scope | +|---------|-------| +| `pnpm test` | Rust + TypeScript, everything. | +| `pnpm test:rust` | `cargo nextest run --workspace`. | +| `pnpm test:rust:unit` | `cargo nextest run --workspace --lib`. | +| `pnpm test:rust:int` | `cargo nextest run -p tests` — integration crate in `tests/rust`. | +| `pnpm test:rust:doc` | `cargo test --workspace --doc`. | +| `pnpm test:ts` | Vitest run (`tests/ts/vitest.config.ts`). | +| `pnpm test:ts:watch` | Vitest watch. | +| `pnpm test:e2e` | Desktop E2E via WebDriver IO — requires `MCPMUX_REGISTRY_URL`. | +| `pnpm test:e2e:file -- tests/e2e/specs/foo.ts` | One WDIO spec file. | +| `pnpm test:e2e:grep -- "test name"` | WDIO tests matching a name. | +| `pnpm test:e2e:web` | Playwright on the web UI. | +| `pnpm test:coverage` | `cargo llvm-cov` + Vitest coverage. | + +Prefer narrow commands over `pnpm test` while iterating — the full suite is slow. + +## Code Style + +- **Rust:** 100-char max width, 4-space indent. Clippy runs with `avoid-breaking-exported-api = false`; all warnings are denied in CI. +- **TypeScript / JSX:** Prettier — single quotes, 2-space indent, 100-char width, trailing commas (es5), Tailwind CSS plugin for class ordering. +- **Path aliases:** `@/` → `apps/desktop/src/`; `@mcpmux/ui` → `packages/ui`. +- **No emojis in code or commits** unless the user explicitly asks for them. +- **Comments:** only when the *why* is non-obvious. Identifiers should explain the *what*. + +## Commit & PR Guidelines + +- Commits must be **signed off** (DCO): `git commit -s -m "..."`. CI rejects unsigned commits. +- Prefer conventional-style subjects — releases use release-please for semantic versioning. +- PRs follow [`.github/pull_request_template.md`](.github/pull_request_template.md): describe the change, how you tested, and check the `pnpm test` / `pnpm lint` / `pnpm typecheck` boxes. +- Don't bypass hooks (`--no-verify`) or DCO signing unless explicitly told to. + +## Platform Gotchas + +### Child-process flags + +Anything that spawns a child process (stdio MCP servers, installers, etc.) **must** go through `mcpmux_gateway::pool::transport::configure_child_process_platform()`. That helper applies: + +- **Windows:** `CREATE_NO_WINDOW` (`0x08000000`) — release builds use `windows_subsystem = "windows"`, so without this the OS briefly flashes a console window when a child starts. +- **Unix:** `process_group(0)` — stops SIGINT/SIGTSTP from the parent terminal from tearing down the child. + +`tokio::process::Command` already exposes `creation_flags()` (Windows) and `process_group()` (Unix). **Do not** import `std::os::*::process::CommandExt` — those traits are unused with Tokio's `Command` and trigger clippy. + +### Cross-platform CI + +- The pre-commit hook runs `cargo clippy --workspace -- -D warnings` on your dev machine. +- `#[cfg(unix)]` only compiles on Unix; `#[cfg(windows)]` only on Windows. CI is Linux, so Windows-gated code is **not** linted in CI, and Unix-gated code is not linted on a Windows dev box. +- When you touch platform-conditional code, check the *other* platform compiles before pushing — CI won't catch a Windows-only clippy regression. + +### Secret handling + +- Never log tokens, API keys, headers with auth material, or raw OAuth responses. Use the existing sanitised-log helpers in `mcpmux-gateway`. +- Credentials encrypt at rest via AES-256-GCM in SQLite plus DPAPI (Windows) / OS keychain (macOS, Linux). Don't add new code paths that persist secrets any other way. +- Secrets should be wiped from memory after use via `zeroize`. +- The gateway binds to `127.0.0.1`. Don't bind to `0.0.0.0` or expose it on the network. + +## Frontend Notes + +- Entry point: `apps/desktop/src/main.tsx` → `App.tsx`. +- Global state: a single Zustand store at `src/stores/appStore.ts`. +- Key hooks: `useServerManager` (server CRUD), `useSpaces` (workspace switching), `useDomainEvents` (Rust-side EventBus listener), `useDataSync`. +- UI: React 19, Tailwind CSS, Lucide icons, Monaco Editor for JSON config surfaces. +- Open external URLs through `openExternal` in `apps/desktop/src/lib/contribute.ts` — it routes through the Tauri opener plugin so links open in the user's default browser, not the webview. +- For UI changes, launch `pnpm dev` and exercise the feature in the running app before reporting done — typecheck and tests verify correctness, not UX regressions. + +## Rust Architecture Cues + +- Cross-layer communication goes through the `EventBus` in `mcpmux-core`. Prefer emitting a domain event over reaching across module boundaries directly. +- Storage is behind repository traits — don't call SQLx or SQLite APIs directly from gateway or app code; add or use a repo method. +- Services are wired up via the `ApplicationServices` builders in `mcpmux-core`. New services should follow the same DI pattern. + +## MCP Specification + +The full MCP spec is vendored at `../modelcontextprotocol/docs/specification/`. Default to the latest stable version (`2025-11-25`) and **read the relevant section before** implementing or modifying protocol behaviour (transports, lifecycle, capability negotiation, OAuth flows, tools / resources / prompts). For features targeting a specific protocol version, use that version's folder. + +## Server Definitions + +Server catalog entries live in the separate [`mcp-servers`](https://github.com/mcpmux/mcp-servers) repo — **not here**. If a task involves adding, editing, or fixing a server definition, switch to that repo and follow its `AGENTS.md`. + +## Things Not To Do + +- Don't add backwards-compatibility shims, deprecated aliases, or `// removed` placeholder comments when removing code — delete it cleanly. +- Don't introduce new fallbacks or input validation for states that are already framework-guaranteed. Trust internal invariants; validate only at the boundary (user input, external APIs). +- Don't edit generated files: `CHANGELOG.md`, release-please manifests, `bundle/*.json` in sibling repos, `packages/ui/dist`. +- Don't commit screenshots, videos, or large binaries to the repo — link out instead. diff --git a/Cargo.lock b/Cargo.lock index 987df857..a714f86c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4176,9 +4176,9 @@ dependencies = [ [[package]] name = "rmcp" -version = "0.17.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a0ce46f9101dc911f07e1468084c057839d15b08040d110820c5513312ef56a" +checksum = "67d69668de0b0ccd9cc435f700f3b39a7861863cf37a15e1f304ea78688a4826" dependencies = [ "async-trait", "base64 0.22.1", @@ -4211,9 +4211,9 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "0.17.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abad6f5f46e220e3bda2fc90fd1ad64c1c2a2bd716d52c845eb5c9c64cda7542" +checksum = "48fdc01c81097b0aed18633e676e269fefa3a78ec1df56b4fe597c1241b92025" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -5454,6 +5454,7 @@ dependencies = [ name = "tests" version = "0.0.2" dependencies = [ + "anyhow", "async-trait", "axum", "chrono", diff --git a/Cargo.toml b/Cargo.toml index a174a8b7..059f29a4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,7 +58,7 @@ os_pipe = "1" # MCP Protocol # NOTE: Never use local path dependency - E:\one-mcp\rust-sdk is for source lookup only -rmcp = { version = "0.17.0", features = [ +rmcp = { version = "1.5", features = [ "client", "server", "transport-io", diff --git a/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/client.rs b/apps/desktop/src-tauri/src/commands/client.rs index ee30cb3e..707f9859 100644 --- a/apps/desktop/src-tauri/src/commands/client.rs +++ b/apps/desktop/src-tauri/src/commands/client.rs @@ -1,16 +1,14 @@ //! Client management commands //! -//! IPC commands for managing AI clients (Cursor, VS Code, etc.). +//! Identity-only surface: list, get, create, delete, and preset seeding. +//! Connection modes and per-client FeatureSet grants no longer exist — +//! routing is entirely driven by WorkspaceBinding + Space default FS. -use mcpmux_core::{Client, ConnectionMode}; +use mcpmux_core::Client; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::sync::Arc; use tauri::State; -use tokio::sync::RwLock; use uuid::Uuid; -use crate::commands::gateway::GatewayAppState; use crate::state::AppState; /// Response for client listing @@ -19,35 +17,15 @@ pub struct ClientResponse { pub id: String, pub name: String, pub client_type: String, - pub connection_mode: String, - pub locked_space_id: Option, - pub grants: HashMap>, pub last_seen: Option, } impl From for ClientResponse { fn from(c: Client) -> Self { - let (mode, locked_id) = match &c.connection_mode { - ConnectionMode::Locked { space_id } => { - ("locked".to_string(), Some(space_id.to_string())) - } - ConnectionMode::FollowActive => ("follow_active".to_string(), None), - ConnectionMode::AskOnChange { .. } => ("ask_on_change".to_string(), None), - }; - - let grants: HashMap> = c - .grants - .iter() - .map(|(k, v)| (k.to_string(), v.iter().map(|u| u.to_string()).collect())) - .collect(); - Self { id: c.id.to_string(), name: c.name, client_type: c.client_type, - connection_mode: mode, - locked_space_id: locked_id, - grants, last_seen: c.last_seen.map(|dt| dt.to_rfc3339()), } } @@ -58,15 +36,6 @@ impl From for ClientResponse { pub struct CreateClientInput { pub name: String, pub client_type: String, - pub connection_mode: String, - pub locked_space_id: Option, -} - -/// Input for updating client grants -#[derive(Debug, Deserialize)] -pub struct UpdateGrantsInput { - pub space_id: String, - pub feature_set_ids: Vec, } /// List all clients. @@ -103,20 +72,7 @@ pub async fn create_client( input: CreateClientInput, state: State<'_, AppState>, ) -> Result { - let connection_mode = match input.connection_mode.as_str() { - "locked" => { - let space_id = input - .locked_space_id - .ok_or("locked_space_id required for locked mode")?; - let uuid = Uuid::parse_str(&space_id).map_err(|e| e.to_string())?; - ConnectionMode::Locked { space_id: uuid } - } - "ask_on_change" => ConnectionMode::AskOnChange { triggers: vec![] }, - _ => ConnectionMode::FollowActive, - }; - - let mut client = Client::new(&input.name, &input.client_type); - client.connection_mode = connection_mode; + let client = Client::new(&input.name, &input.client_type); state .client_repository @@ -138,180 +94,6 @@ pub async fn delete_client(id: String, state: State<'_, AppState>) -> Result<(), .map_err(|e| e.to_string()) } -/// Update client grants for a specific space (using client_grants table). -#[tauri::command] -pub async fn update_client_grants( - client_id: String, - input: UpdateGrantsInput, - state: State<'_, AppState>, -) -> Result { - let client_uuid = Uuid::parse_str(&client_id).map_err(|e| e.to_string())?; - - // Verify client exists - let client = state - .client_repository - .get(&client_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or("Client not found")?; - - // Update grants using the client_grants table - state - .client_repository - .set_grants_for_space(&client_uuid, &input.space_id, &input.feature_set_ids) - .await - .map_err(|e| e.to_string())?; - - Ok(client.into()) -} - -/// Get effective grants for a specific client and space. -/// This includes explicit grants PLUS the default feature set (merged as a set). -#[tauri::command] -pub async fn get_client_grants( - client_id: String, - space_id: String, - state: State<'_, AppState>, -) -> Result, String> { - let client_uuid = Uuid::parse_str(&client_id).map_err(|e| e.to_string())?; - - // Get effective grants (explicit + default, deduplicated) - state - .client_service - .get_effective_grants(&client_uuid, &space_id) - .await - .map_err(|e| e.to_string()) -} - -/// Get all grants for a client across all spaces. -#[tauri::command] -pub async fn get_all_client_grants( - client_id: String, - state: State<'_, AppState>, -) -> Result>, String> { - let client_uuid = Uuid::parse_str(&client_id).map_err(|e| e.to_string())?; - - state - .client_repository - .get_all_grants(&client_uuid) - .await - .map_err(|e| e.to_string()) -} - -/// Grant a specific feature set to a client. -/// -/// Emits MCP list_changed notifications to connected clients. -#[tauri::command] -pub async fn grant_feature_set_to_client( - client_id: String, - space_id: String, - feature_set_id: String, - state: State<'_, AppState>, - gateway_state: State<'_, Arc>>, -) -> Result<(), String> { - let client_uuid = Uuid::parse_str(&client_id).map_err(|e| e.to_string())?; - let space_uuid = Uuid::parse_str(&space_id).map_err(|e| e.to_string())?; - - // Grant the feature set - state - .client_repository - .grant_feature_set(&client_uuid, &space_id, &feature_set_id) - .await - .map_err(|e| e.to_string())?; - - // Emit notifications if gateway is running - let gw_state = gateway_state.read().await; - if let Some(ref emitter) = gw_state.event_emitter { - emitter.emit_all_changed_for_space(space_uuid); - } - - Ok(()) -} - -/// Revoke a specific feature set from a client. -/// -/// Emits MCP list_changed notifications to connected clients. -#[tauri::command] -pub async fn revoke_feature_set_from_client( - client_id: String, - space_id: String, - feature_set_id: String, - state: State<'_, AppState>, - gateway_state: State<'_, Arc>>, -) -> Result<(), String> { - let client_uuid = Uuid::parse_str(&client_id).map_err(|e| e.to_string())?; - let space_uuid = Uuid::parse_str(&space_id).map_err(|e| e.to_string())?; - - // Revoke the feature set - state - .client_repository - .revoke_feature_set(&client_uuid, &space_id, &feature_set_id) - .await - .map_err(|e| e.to_string())?; - - // Emit notifications if gateway is running - let gw_state = gateway_state.read().await; - if let Some(ref emitter) = gw_state.event_emitter { - emitter.emit_all_changed_for_space(space_uuid); - } - - Ok(()) -} - -/// Update client connection mode. -/// -/// Emits MCP list_changed notifications when the client's effective space changes. -#[tauri::command] -pub async fn update_client_mode( - client_id: String, - mode: String, - locked_space_id: Option, - state: State<'_, AppState>, - gateway_state: State<'_, Arc>>, -) -> Result { - let client_uuid = Uuid::parse_str(&client_id).map_err(|e| e.to_string())?; - - let mut client = state - .client_repository - .get(&client_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or("Client not found")?; - - client.connection_mode = match mode.as_str() { - "locked" => { - let space_id = locked_space_id.ok_or("locked_space_id required for locked mode")?; - let uuid = Uuid::parse_str(&space_id).map_err(|e| e.to_string())?; - ConnectionMode::Locked { space_id: uuid } - } - "ask_on_change" => ConnectionMode::AskOnChange { triggers: vec![] }, - _ => ConnectionMode::FollowActive, - }; - client.updated_at = chrono::Utc::now(); - - state - .client_repository - .update(&client) - .await - .map_err(|e| e.to_string())?; - - // Emit notifications for the space this client is now using - let gw_state = gateway_state.read().await; - if let Some(emitter) = &gw_state.event_emitter { - match &client.connection_mode { - ConnectionMode::Locked { space_id } => { - emitter.emit_all_changed_for_space(*space_id); - } - _ => { - // For follow_active or ask_on_change, notifications will be sent - // when the client reconnects and resolves its space - } - } - } - - Ok(client.into()) -} - /// Create preset clients (Cursor, VS Code, Claude Desktop). #[tauri::command] pub async fn init_preset_clients(state: State<'_, AppState>) -> Result<(), String> { @@ -321,7 +103,6 @@ pub async fn init_preset_clients(state: State<'_, AppState>) -> Result<(), Strin .await .map_err(|e| e.to_string())?; - // Create Cursor if not exists if !existing.iter().any(|c| c.client_type == "cursor") { let cursor = Client::cursor(); state @@ -331,7 +112,6 @@ pub async fn init_preset_clients(state: State<'_, AppState>) -> Result<(), Strin .map_err(|e| e.to_string())?; } - // Create VS Code if not exists if !existing.iter().any(|c| c.client_type == "vscode") { let vscode = Client::vscode(); state @@ -341,7 +121,6 @@ pub async fn init_preset_clients(state: State<'_, AppState>) -> Result<(), Strin .map_err(|e| e.to_string())?; } - // Create Claude Desktop if not exists if !existing.iter().any(|c| c.client_type == "claude") { let claude = Client::claude_desktop(); state diff --git a/apps/desktop/src-tauri/src/commands/client_custom_features.rs b/apps/desktop/src-tauri/src/commands/client_custom_features.rs deleted file mode 100644 index 02730060..00000000 --- a/apps/desktop/src-tauri/src/commands/client_custom_features.rs +++ /dev/null @@ -1,51 +0,0 @@ -//! Commands for managing client-specific custom feature sets - -use crate::state::AppState; -use mcpmux_core::{FeatureSet, FeatureSetType}; -use tauri::State; - -/// Find or create a custom feature set for a specific client in a space -/// This ensures only one custom feature set exists per client per space -#[tauri::command] -pub async fn find_or_create_client_custom_feature_set( - state: State<'_, AppState>, - client_name: String, - space_id: String, -) -> Result { - let custom_set_name = format!("{} - Custom", client_name); - - // First, try to find existing custom feature set - let existing_sets = state - .feature_set_repository - .list_by_space(&space_id) - .await - .map_err(|e| format!("Failed to list feature sets: {}", e))?; - - // Look for existing custom feature set with this name - if let Some(existing) = existing_sets.iter().find(|fs| { - fs.name == custom_set_name - && fs.feature_set_type == FeatureSetType::Custom - && !fs.is_deleted - }) { - // Load members - return state - .feature_set_repository - .get_with_members(&existing.id) - .await - .map_err(|e| format!("Failed to load feature set: {}", e))? - .ok_or_else(|| "Feature set not found".to_string()); - } - - // No existing set found, create a new one - let new_set = FeatureSet::new_custom(&custom_set_name, &space_id) - .with_description(format!("Custom features for {}", client_name)) - .with_icon("⚙️"); - - state - .feature_set_repository - .create(&new_set) - .await - .map_err(|e| format!("Failed to create custom feature set: {}", e))?; - - Ok(new_set) -} diff --git a/apps/desktop/src-tauri/src/commands/config_export.rs b/apps/desktop/src-tauri/src/commands/config_export.rs index 0a5fe7e5..99b54c61 100644 --- a/apps/desktop/src-tauri/src/commands/config_export.rs +++ b/apps/desktop/src-tauri/src/commands/config_export.rs @@ -45,15 +45,16 @@ fn get_format(client_type: &str) -> Result { } } -/// Get the space ID (resolves "default" to active space) +/// Resolve a `space_id` argument from the UI: the literal "default" or an +/// empty string fall back to the system's `is_default` Space. async fn get_space_id(state: &AppState, space_id: &str) -> Result { if space_id == "default" || space_id.is_empty() { let space = state .space_service - .get_active() + .get_default() .await .map_err(|e: anyhow::Error| e.to_string())? - .ok_or("No active space found")?; + .ok_or("No default space found")?; Ok(space.id.to_string()) } else { Ok(space_id.to_string()) diff --git a/apps/desktop/src-tauri/src/commands/feature_set.rs b/apps/desktop/src-tauri/src/commands/feature_set.rs index 3e3ef5ef..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. @@ -128,28 +131,10 @@ pub async fn list_feature_sets_by_space( .await .map_err(|e: anyhow::Error| e.to_string())?; - let enabled_server_ids: std::collections::HashSet = installed_servers - .into_iter() - .filter(|s| s.enabled) - .map(|s| s.server_id) - .collect(); - - // Filter out server-all feature sets for servers that are not enabled - let filtered = feature_sets - .into_iter() - .filter(|fs| { - if fs.feature_set_type == mcpmux_core::FeatureSetType::ServerAll { - // Only include if server is enabled - fs.server_id - .as_ref() - .is_some_and(|sid| enabled_server_ids.contains(sid)) - } else { - true - } - }) - .map(Into::into) - .collect(); - + // `server-all` feature sets no longer exist, so nothing to filter; + // installed_servers lookup kept for future per-server filtering hooks. + let _ = installed_servers; + let filtered = feature_sets.into_iter().map(Into::into).collect(); Ok(filtered) } @@ -266,38 +251,6 @@ pub async fn delete_feature_set( Ok(()) } -/// Get builtin feature sets for a space. -#[tauri::command] -pub async fn get_builtin_feature_sets( - space_id: String, - state: State<'_, AppState>, -) -> Result, String> { - let feature_sets = state - .feature_set_repository - .list_builtin(&space_id) - .await - .map_err(|e| e.to_string())?; - - Ok(feature_sets.into_iter().map(Into::into).collect()) -} - -/// Ensure server-all featureset exists for a server in a space. -#[tauri::command] -pub async fn ensure_server_all_feature_set( - space_id: String, - server_id: String, - server_name: String, - state: State<'_, AppState>, -) -> Result { - let feature_set = state - .feature_set_repository - .ensure_server_all(&space_id, &server_id, &server_name) - .await - .map_err(|e| e.to_string())?; - - Ok(feature_set.into()) -} - /// Update a feature set (name, description, icon). #[tauri::command] pub async fn update_feature_set( @@ -313,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; } @@ -364,9 +313,14 @@ pub async fn add_feature_set_member( .map_err(|e| e.to_string())? .ok_or("Feature set not found")?; - // Only "default" and "custom" types can have their members modified + // Both Starter (auto-seeded) and Custom FeatureSets are member-driven + // and editable. Reject anything else — there are no other configurable + // types today, but the guard stays for forward compatibility. + // `'default'` is accepted as a legacy alias because `parse('default')` + // resolves to `Starter` and `as_str()` always emits `'starter'` post- + // migration 013, but older in-memory data could still surface it. let fs_type = feature_set.feature_set_type.as_str(); - if fs_type != "default" && fs_type != "custom" { + if fs_type != "starter" && fs_type != "default" && fs_type != "custom" { return Err(format!( "Cannot modify members of '{}' type feature set", fs_type @@ -417,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); @@ -503,12 +458,13 @@ pub async fn set_feature_set_members( .map_err(|e| e.to_string())? .ok_or("Feature set not found")?; - // Only "default" and "custom" types can have their members modified - // "all" grants everything automatically, "server-all" is also auto-computed + // Both Starter (auto-seeded) and Custom FeatureSets are member-driven + // and editable. `'default'` is accepted as a legacy alias for the same + // reason described in `add_feature_set_member` — see comment there. let fs_type = feature_set.feature_set_type.as_str(); - if fs_type != "default" && fs_type != "custom" { + if fs_type != "starter" && fs_type != "default" && fs_type != "custom" { return Err(format!( - "Cannot modify members of '{}' type feature set. Only 'default' and 'custom' types are configurable.", + "Cannot modify members of '{}' type feature set. Only Starter and Custom FeatureSets are configurable.", fs_type )); } @@ -540,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 fd20f311..6150fce1 100644 --- a/apps/desktop/src-tauri/src/commands/gateway.rs +++ b/apps/desktop/src-tauri/src/commands/gateway.rs @@ -4,10 +4,11 @@ use crate::commands::server_manager::ServerManagerState; use crate::AppState; +use mcpmux_core::service::{allocate_dynamic_port, is_port_available}; use mcpmux_core::DomainEvent; use mcpmux_gateway::{ - ConnectionContext, ConnectionResult, FeatureService, InstalledServerInfo, PoolService, - ResolvedTransport, ServerKey, + ConnectionContext, ConnectionResult, FeatureService, InstalledServerInfo, OAuthCompleteEvent, + PoolService, ResolvedTransport, ServerKey, ServerManager, }; use serde::Serialize; use std::sync::Arc; @@ -37,6 +38,16 @@ pub struct BackendStatusResponse { pub tools_count: usize, } +/// Information about an auto-start attempt that was aborted because the +/// preferred port was busy. The frontend reads this on mount and triggers +/// the port-conflict confirm dialog. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PendingPortConflict { + pub preferred_port: u16, + pub source: &'static str, +} + /// Gateway state managed by Tauri #[derive(Default)] pub struct GatewayAppState { @@ -44,8 +55,10 @@ pub struct GatewayAppState { pub running: bool, /// Gateway URL pub url: Option, - /// Gateway task handle - pub handle: Option>>, + /// Gateway task + graceful-shutdown signal. `shutdown()` + awaiting + /// `task` (with a timeout) lets the OS reclaim the listener socket + /// cleanly; `.abort()` alone can leave an orphaned kernel-level bind. + pub handle: Option, /// Gateway state reference for accessing backends pub gateway_state: Option>>, /// Server connection pool service (initialized when gateway starts) @@ -56,6 +69,219 @@ pub struct GatewayAppState { pub event_emitter: Option>, /// Grant service for centralized grant management with auto-notifications pub grant_service: Option>, + /// Approval broker for meta-tool writes (publisher attached on gateway start) + pub approval_broker: Option>, + /// Set when auto-start couldn't bind the preferred port; the UI will + /// read this on mount and prompt the user. + pub pending_port_conflict: Option, + /// Live map of `mcp-session-id → reported workspace roots`. Populated + /// by the gateway handler when clients declare the `roots` capability. + /// Surfaced to the desktop Workspaces tab so users can see + act on + /// every folder connected clients are currently operating in. + pub session_roots: Option>, + /// 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 +/// to finish so the TCP listener is released back to the OS. +/// +/// Without this, `handle.abort()` alone can leave an orphaned +/// kernel-level bind — a listener socket that netstat still reports even +/// though no process exists — preventing the next `start_gateway` from +/// binding the same port. +/// +/// Flow: +/// 1. Send the graceful-shutdown signal (axum drains in-flight requests). +/// 2. Await the task up to 2s so Rust Drop closes the listener fd. +/// 3. If the task hasn't returned by then, abort as a last resort. +pub(crate) async fn shutdown_gateway_handle(mut handle: mcpmux_gateway::GatewayServerHandle) { + let abort = handle.task.abort_handle(); + handle.shutdown(); + match tokio::time::timeout(std::time::Duration::from_secs(2), handle.task).await { + Ok(Ok(Ok(()))) => info!("[Gateway] Gateway task exited cleanly"), + Ok(Ok(Err(e))) => warn!( + "[Gateway] Gateway task returned error during shutdown: {}", + e + ), + Ok(Err(e)) if e.is_cancelled() => info!("[Gateway] Gateway task was already cancelled"), + Ok(Err(e)) => warn!("[Gateway] Gateway task join error: {}", e), + Err(_) => { + warn!( + "[Gateway] Graceful shutdown timed out after 2s — aborting task \ + (listener socket may briefly linger in kernel)" + ); + abort.abort(); + } + } +} + +/// Bring the main webview window forward so the user sees a popup the +/// gateway just emitted. Best-effort — silently no-ops when the window +/// doesn't exist (rare, e.g. during teardown). Used by the approval +/// publisher and the WorkspaceNeedsBinding bridge so an LLM tool call or +/// a fresh client connection automatically draws the user's eye to the +/// mcpmux app instead of the dialog rendering invisibly under another +/// window. +pub(crate) fn focus_main_window(app: &tauri::AppHandle) { + use tauri::Manager; + let Some(window) = app.get_webview_window("main") else { + return; + }; + // unminimize + show + set_focus together cover every state the user + // could have left the window in (minimized, hidden behind another + // app, hidden by user via the close-to-tray flow). + let _ = window.unminimize(); + let _ = window.show(); + let _ = window.set_focus(); +} + +/// Wire the meta-tool approval broker to the desktop event bus so write +/// tools (e.g. `mcpmux_bind_current_workspace`) can prompt the React +/// dialog. Both the manual `start_gateway` command and the lib.rs +/// auto-start path must call this — without it the broker stays +/// publisher-less and every write surfaces as +/// `approval_required: no desktop attached to mcpmux gateway`. +pub(crate) async fn attach_approval_publisher( + approval_broker: &Arc, + app_handle: tauri::AppHandle, +) { + let publisher: mcpmux_gateway::services::meta_tools::ApprovalPublisher = Arc::new(move |req| { + let app_handle = app_handle.clone(); + Box::pin(async move { + // Bring the window forward BEFORE emitting so the dialog + // animates into a visible window — otherwise it'd render + // behind whatever the user is currently focused on. + focus_main_window(&app_handle); + // Emit the request; the React layer owns rendering + + // collecting the user's decision. Failure to emit means + // no desktop frontend is listening — broker maps that to + // "approval_required" to the calling tool. + match app_handle.emit("meta-tool-approval-request", &req) { + Ok(()) => true, + Err(e) => { + tracing::warn!( + error = %e, + "[meta-tool] failed to emit approval request" + ); + false + } + } + }) + }); + approval_broker.set_publisher(publisher).await; +} + +/// Wires up ServerManager state + the OAuth completion handler + the +/// periodic refresh loop after a GatewayServer has been spawned. +/// +/// Both the auto-start path (in `lib.rs`) and the `start_gateway` Tauri +/// command must call this — without it, ServerManagerState.manager stays +/// None and the Servers page shows every server stuck on "Connecting..." +/// because `get_server_statuses` can't reach the ServerManager. +/// +/// Call order matters: **subscribe to OAuth events before spawning the +/// gateway** (the subscription is passed in already-created), and call +/// this helper before or after `server.spawn()` — but always before any +/// user-facing code queries server statuses. +pub(crate) async fn init_gateway_runtime( + pool_service: Arc, + server_manager: Arc, + oauth_completion_rx: tokio::sync::broadcast::Receiver, + sm_state: Arc>, +) { + // Store ServerManager + PoolService so the Servers page commands can + // read them. A fresh Arc per start — old handlers on a stopped gateway + // become orphans and drop naturally. + { + let mut sm = sm_state.write().await; + sm.manager = Some(server_manager.clone()); + sm.pool_service = Some(pool_service.clone()); + } + info!("[Gateway] ServerManager + PoolService attached to state"); + + // OAuth completion handler — reconnects servers after the user finishes + // the OAuth flow in the browser. Spawned as a detached task; lives as + // long as the broadcast channel is alive (drops naturally when pool is + // dropped on next gateway start). + let sm_for_oauth = server_manager.clone(); + let pool_for_oauth = pool_service.clone(); + tokio::spawn(async move { + let mut rx = oauth_completion_rx; + info!("[OAuth Handler] Listening for OAuth completions"); + loop { + match rx.recv().await { + Ok(event) => { + info!( + "[OAuth Handler] Completion received: server={} success={}", + event.server_id, event.success + ); + if event.success { + let sm = sm_for_oauth.clone(); + let pool = pool_for_oauth.clone(); + let server_id = event.server_id.clone(); + let space_id = event.space_id; + tokio::spawn(async move { + let key = ServerKey::new(space_id, &server_id); + info!("[OAuth Handler] Reconnecting {} after OAuth", server_id); + sm.set_connecting(&key).await; + match pool.reconnect_instance(space_id, &server_id).await { + ConnectionResult::Connected { features, .. } => { + info!( + "[OAuth Handler] Reconnected {} — {} features", + server_id, + features.tools.len() + ); + sm.set_connected(&key, features).await; + } + ConnectionResult::OAuthRequired { .. } => { + warn!( + "[OAuth Handler] {} still needs OAuth after completion", + server_id + ); + sm.set_auth_required( + &key, + Some("OAuth still required".to_string()), + ) + .await; + } + ConnectionResult::Failed { error } => { + error!( + "[OAuth Handler] Reconnect failed for {}: {}", + server_id, error + ); + sm.set_error(&key, error).await; + } + } + }); + } else { + let key = ServerKey::new(event.space_id, &event.server_id); + let err = event.error.unwrap_or_else(|| "OAuth failed".to_string()); + warn!( + "[OAuth Handler] OAuth failed for {}: {}", + event.server_id, err + ); + sm_for_oauth.set_auth_required(&key, Some(err)).await; + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { + warn!("[OAuth Handler] Lagged {} messages", n); + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + info!("[OAuth Handler] Channel closed, stopping"); + break; + } + } + } + }); + info!("[Gateway] OAuth completion handler spawned"); + + // Periodic refresh loop — re-fetches features from each connected + // server every ~60s so long-running sessions don't drift. + let _refresh = server_manager.clone().start_periodic_refresh(); + info!("[Gateway] Periodic refresh loop started"); } /// Start domain event bridge from Gateway to Tauri @@ -79,6 +305,14 @@ pub fn start_domain_event_bridge( while let Ok(event) = event_rx.recv().await { let event_type = event.type_name(); + // Some domain events imply a popup the user must see (a workspace + // root needs binding, a backend wants OAuth, etc.). Bring the + // window forward BEFORE emitting so the popup animates into a + // visible window instead of rendering behind another app. + if matches!(event, DomainEvent::WorkspaceNeedsBinding { .. }) { + focus_main_window(&app_handle_clone); + } + // Map domain events to UI channels let (channel, payload) = map_domain_event_to_ui(&event); @@ -129,20 +363,6 @@ fn map_domain_event_to_ui(event: &DomainEvent) -> (&'static str, serde_json::Val "space_id": space_id, }), ), - DomainEvent::SpaceActivated { - from_space_id, - to_space_id, - to_space_name, - } => ( - "space-changed", - serde_json::json!({ - "action": "activated", - "from_space_id": from_space_id, - "to_space_id": to_space_id, - "to_space_name": to_space_name, - }), - ), - // Server lifecycle events DomainEvent::ServerInstalled { space_id, @@ -363,47 +583,6 @@ fn map_domain_event_to_ui(event: &DomainEvent) -> (&'static str, serde_json::Val }), ), - // Grant events - DomainEvent::GrantIssued { - client_id, - space_id, - feature_set_id, - } => ( - "grants-changed", - serde_json::json!({ - "action": "granted", - "client_id": client_id, - "space_id": space_id, - "feature_set_id": feature_set_id, - }), - ), - DomainEvent::GrantRevoked { - client_id, - space_id, - feature_set_id, - } => ( - "grants-changed", - serde_json::json!({ - "action": "revoked", - "client_id": client_id, - "space_id": space_id, - "feature_set_id": feature_set_id, - }), - ), - DomainEvent::ClientGrantsUpdated { - client_id, - space_id, - feature_set_ids, - } => ( - "grants-changed", - serde_json::json!({ - "action": "batch_updated", - "client_id": client_id, - "space_id": space_id, - "feature_set_ids": feature_set_ids, - }), - ), - // Gateway events DomainEvent::GatewayStarted { url, port } => ( "gateway-changed", @@ -454,6 +633,76 @@ fn map_domain_event_to_ui(event: &DomainEvent) -> (&'static str, serde_json::Val "server_id": server_id, }), ), + DomainEvent::MetaToolInvoked { + client_id, + session_id, + tool_name, + decision, + resolved_feature_set_id, + summary, + } => ( + // New channel so the Connection Log can render a dedicated row + // type without interleaving with regular backend events. + "meta-tool-invoked", + serde_json::json!({ + "client_id": client_id, + "session_id": session_id, + "tool_name": tool_name, + "decision": decision, + "resolved_feature_set_id": resolved_feature_set_id, + "summary": summary, + "timestamp": chrono::Utc::now().to_rfc3339(), + }), + ), + + // Workspace binding write → tell the UI to re-load the bindings + // table. The MCP `list_changed` notifications are handled separately + // by MCPNotifier subscribing to the same event. + DomainEvent::WorkspaceBindingChanged { + space_id, + workspace_root, + } => ( + "workspace-binding-changed", + serde_json::json!({ + "space_id": space_id, + "workspace_root": workspace_root, + }), + ), + + // The set of live reported session roots changed — the Workspaces + // tab re-fetches so unbound folders stay visible. + DomainEvent::SessionRootsChanged => ("session-roots-changed", serde_json::json!({})), + + // A session resolved via `source=Default` and no binding exists for + // any of its reported roots. Front-end shows the binding sheet. + DomainEvent::WorkspaceNeedsBinding { + client_id, + session_id, + space_id, + workspace_root, + } => ( + "workspace-needs-binding", + serde_json::json!({ + "client_id": client_id, + "session_id": session_id, + "space_id": space_id, + "workspace_root": workspace_root, + }), + ), + + // Per-client grant edited — Clients page re-fetches the toggles for + // the affected client. MCPNotifier handles the corresponding + // `list_changed` push to the client's open peers separately. + DomainEvent::ClientGrantChanged { + client_id, + space_id, + } => ( + "client-grant-changed", + serde_json::json!({ + "client_id": client_id, + "space_id": space_id, + }), + ), } } @@ -547,11 +796,25 @@ pub async fn get_gateway_status( }) } -/// Start the gateway server +/// Start the gateway server. +/// +/// `port` forces a specific port (used for ad-hoc overrides from a test or +/// power-user flow). When `port` is None, the preferred port is whatever +/// the user has configured, falling back to the shipped default. +/// +/// `allow_dynamic_fallback` controls what happens when the preferred port +/// is busy: +/// - **None / false (strict, default):** return an error prefixed with +/// `PORT_IN_USE::`. The UI should probe first and prompt +/// the user before retrying with fallback enabled. +/// - **true:** silently allocate an OS-assigned port instead. Used by the +/// auto-start path where there's no UI to prompt. #[tauri::command] pub async fn start_gateway( port: Option, + allow_dynamic_fallback: Option, gateway_state: State<'_, Arc>>, + sm_state: State<'_, Arc>>, app_state: State<'_, AppState>, app_handle: tauri::AppHandle, ) -> Result { @@ -561,12 +824,47 @@ pub async fn start_gateway( return Err("Gateway is already running".to_string()); } - // Single Responsibility: Delegate port resolution to GatewayPortService - let final_port = app_state - .gateway_port_service - .resolve_with_override(port) - .await - .map_err(|e| e.to_string())?; + let (preferred_port, source) = resolve_preferred_port(&app_state, port).await; + let allow_fallback = allow_dynamic_fallback.unwrap_or(false); + + let final_port = if is_port_available(preferred_port) { + // Persist first-run default so the Settings UI shows it explicitly. + if matches!(source, PortSource::Default) + && app_state + .gateway_port_service + .load_persisted_port() + .await + .is_none() + { + if let Err(e) = app_state + .gateway_port_service + .save_port(preferred_port) + .await + { + warn!("[Gateway] Failed to persist default port: {}", e); + } + } + preferred_port + } else if allow_fallback { + let dyn_port = allocate_dynamic_port().map_err(|e| e.to_string())?; + warn!( + "[Gateway] Preferred port {} unavailable, falling back to dynamic port {} (not persisted — next start retries {})", + preferred_port, dyn_port, preferred_port + ); + // Intentionally do NOT persist the fallback port — the user's + // configured/default preference must survive so the next launch + // retries it. Persisting here would silently overwrite what the + // Settings page shows. + dyn_port + } else { + // Strict mode — caller must retry with allow_dynamic_fallback=true or + // free the port. The UI parses this sentinel to render its popup. + return Err(format!( + "PORT_IN_USE:{}:{}", + preferred_port, + source.as_str() + )); + }; let url = format!("http://localhost:{}", final_port); @@ -591,18 +889,48 @@ pub async fn start_gateway( let pool_service = server.pool_service(); let feature_service = server.feature_service(); let event_emitter = server.event_emitter(); - - info!("[Gateway] Getting grant_service from server..."); + let server_manager = server.server_manager(); let grant_service = server.grant_service(); - info!("[Gateway] Got grant_service: {:p}", &*grant_service); + let session_roots = server.session_roots(); + 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. + let oauth_completion_rx = pool_service.oauth_manager().subscribe(); + info!( + "[Gateway] Services resolved — port={}, server_manager={:p}", + final_port, &*server_manager + ); + + // Meta-tool approval broker — attach a Tauri-event publisher so + // incoming approval requests reach the React dialog. + let approval_broker = server.approval_broker(); + attach_approval_publisher(&approval_broker, app_handle.clone()).await; // Start domain event bridge (clean architecture) start_domain_event_bridge(&app_handle, gw_state.clone()); + // Wire ServerManager into state + spawn OAuth handler + periodic + // refresh. MUST happen here, otherwise the Servers page sees every + // server stuck on "Connecting..." because `get_server_statuses` can't + // reach the ServerManager. + let sm_state_inner: Arc> = sm_state.inner().clone(); + init_gateway_runtime( + pool_service.clone(), + server_manager.clone(), + oauth_completion_rx, + sm_state_inner, + ) + .await; + // Spawn gateway (runs in background, auto-connects servers) let handle = server.spawn(); - info!("[Gateway] Setting state fields..."); + info!( + "[Gateway] Setting GatewayAppState fields — port={}, url={}", + final_port, url + ); state.running = true; state.url = Some(url.clone()); state.handle = Some(handle); @@ -610,22 +938,32 @@ pub async fn start_gateway( state.pool_service = Some(pool_service); state.feature_service = Some(feature_service); state.event_emitter = Some(event_emitter); - info!( - "[Gateway] About to set grant_service: {:p}", - &*grant_service - ); state.grant_service = Some(grant_service); + state.approval_broker = Some(approval_broker); + state.session_roots = Some(session_roots); + state.session_overrides = Some(session_overrides); + state.mcp_notifier = Some(mcp_notifier); info!( - "[Gateway] grant_service set! Checking: {}", - state.grant_service.is_some() - ); - - info!( - "[Gateway] Started successfully - EventEmitter initialized: {}, GrantService initialized: {}", + "[Gateway] Started — url={}, event_emitter={}, grant_service={}", + url, state.event_emitter.is_some(), state.grant_service.is_some() ); - info!("[Gateway] Auto-connect will run in background"); + + // Notify every frontend subscriber (status-bar footer, Dashboard, + // Servers page, Settings). Without this, only the caller sees the new + // URL; the footer would stay on "Gateway: Stopped" until the user + // changes Space and retriggers a manual reload. + if let Err(e) = app_handle.emit( + "gateway-changed", + serde_json::json!({ + "action": "started", + "url": url, + "port": final_port, + }), + ) { + warn!("[Gateway] Failed to emit gateway-changed(started): {}", e); + } Ok(url) } @@ -634,44 +972,232 @@ pub async fn start_gateway( #[tauri::command] pub async fn stop_gateway( gateway_state: State<'_, Arc>>, + app_handle: tauri::AppHandle, ) -> Result<(), String> { - let mut state = gateway_state.write().await; + // Take the handle out under the lock, then drop the guard BEFORE + // awaiting the shutdown — otherwise the lock is held for up to 2s + // and every concurrent status query blocks. + let handle = { + let mut state = gateway_state.write().await; + if !state.running { + return Err("Gateway is not running".to_string()); + } + let handle = state.handle.take(); + state.running = false; + state.url = None; + handle + }; - if !state.running { - return Err("Gateway is not running".to_string()); + if let Some(h) = handle { + info!("[Gateway] Stop requested — shutting down gracefully"); + shutdown_gateway_handle(h).await; } - if let Some(handle) = state.handle.take() { - handle.abort(); - info!("Gateway stopped"); + if let Err(e) = app_handle.emit("gateway-changed", serde_json::json!({"action": "stopped"})) { + warn!("[Gateway] Failed to emit gateway-changed(stopped): {}", e); } - state.running = false; - state.url = None; + Ok(()) +} + +/// Gateway port configuration response. +/// +/// - `configured_port` is the user's persisted override (None = "follow default"). +/// - `default_port` is the built-in default the app ships with. +/// - `active_port` is the port the currently-running gateway is bound to +/// (None when stopped). When it differs from `configured_port`, the UI +/// should nudge the user to restart the gateway. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GatewayPortSettings { + pub configured_port: Option, + pub default_port: u16, + pub active_port: Option, +} + +fn parse_port_from_url(url: &str) -> Option { + // URL shape is always "http://localhost:PORT" — parse defensively. + let after_scheme = url.split("://").nth(1)?; + let host_port = after_scheme.split('/').next()?; + host_port.rsplit(':').next()?.parse().ok() +} + +/// Get the persisted gateway port setting, plus the currently-active port. +#[tauri::command] +pub async fn get_gateway_port_settings( + gateway_state: State<'_, Arc>>, + app_state: State<'_, AppState>, +) -> Result { + let configured_port = app_state.gateway_port_service.load_persisted_port().await; + + let active_port = { + let state = gateway_state.read().await; + state.url.as_deref().and_then(parse_port_from_url) + }; + + Ok(GatewayPortSettings { + configured_port, + default_port: mcpmux_core::DEFAULT_GATEWAY_PORT, + active_port, + }) +} + +/// Persist a custom gateway port. Takes effect on the next gateway start. +/// +/// Does NOT touch a running gateway — the UI is expected to offer a +/// "Restart gateway" action. The port must be in the user-space range +/// (1024–65535). Ports ≤ 1023 are rejected to avoid privileged-port +/// surprises on Unix. +#[tauri::command] +pub async fn set_gateway_port(port: u16, app_state: State<'_, AppState>) -> Result<(), String> { + if port < 1024 { + return Err(format!( + "Port {} is in the privileged range (≤ 1023). Choose a port between 1024 and 65535.", + port + )); + } + app_state + .gateway_port_service + .save_port(port) + .await + .map_err(|e| e.to_string())?; + + info!("[Gateway] Persisted custom gateway port: {}", port); + Ok(()) +} + +/// Clear the persisted gateway port override. The next gateway start will +/// use the built-in default (or a dynamically-allocated port if the default +/// is in use). +#[tauri::command] +pub async fn reset_gateway_port(app_state: State<'_, AppState>) -> Result<(), String> { + app_state + .gateway_port_service + .clear_persisted_port() + .await + .map_err(|e| e.to_string())?; + + info!("[Gateway] Cleared persisted gateway port — reverting to default on next start"); Ok(()) } -/// Restart the gateway server +/// Which port source a startup attempt would use. +/// +/// Kept as a string-valued enum for clean JSON serialization to the UI. +#[derive(Debug, Clone, Copy)] +enum PortSource { + Override, + Configured, + Default, +} + +impl PortSource { + fn as_str(self) -> &'static str { + match self { + PortSource::Override => "override", + PortSource::Configured => "configured", + PortSource::Default => "default", + } + } +} + +/// Result of probing whether the gateway can start on its preferred port. +/// +/// - `preferred_port` is the port that _would_ be used — explicit override +/// wins over configured persisted port, which wins over the shipped default. +/// - `preferred_available` is false when something else is bound to it. +/// - `source` tells the UI which tier was chosen, so messages can reference +/// "your configured port" vs. "the default port". +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GatewayStartProbe { + pub preferred_port: u16, + pub preferred_available: bool, + pub source: &'static str, +} + +async fn resolve_preferred_port( + app_state: &AppState, + explicit_port: Option, +) -> (u16, PortSource) { + if let Some(p) = explicit_port { + return (p, PortSource::Override); + } + if let Some(p) = app_state.gateway_port_service.load_persisted_port().await { + return (p, PortSource::Configured); + } + (mcpmux_core::DEFAULT_GATEWAY_PORT, PortSource::Default) +} + +/// Probe whether the gateway's preferred port is free, without starting it. +/// +/// Frontends should call this before invoking `start_gateway` so they can +/// prompt the user when a fallback would be required. +#[tauri::command] +pub async fn probe_gateway_start( + port: Option, + app_state: State<'_, AppState>, +) -> Result { + let (preferred_port, source) = resolve_preferred_port(&app_state, port).await; + let preferred_available = is_port_available(preferred_port); + Ok(GatewayStartProbe { + preferred_port, + preferred_available, + source: source.as_str(), + }) +} + +/// Atomically read **and clear** any deferred auto-start port conflict. +/// +/// The "take" semantic matters: React StrictMode double-mounts components +/// in dev, and without atomic consumption both mounts would read the same +/// conflict and double-prompt the user. Only the first caller wins. +#[tauri::command] +pub async fn take_pending_port_conflict( + gateway_state: State<'_, Arc>>, +) -> Result, String> { + let mut state = gateway_state.write().await; + Ok(state.pending_port_conflict.take()) +} + +/// Restart the gateway server. +/// +/// Both `port` and `allow_dynamic_fallback` are forwarded to `start_gateway` +/// — see its docs for semantics. #[tauri::command] pub async fn restart_gateway( port: Option, + allow_dynamic_fallback: Option, gateway_state: State<'_, Arc>>, + sm_state: State<'_, Arc>>, app_state: State<'_, AppState>, app_handle: tauri::AppHandle, ) -> Result { - // Stop if running - { + info!("[Gateway] Restart requested — tearing down current state"); + // Take handle out under lock; drop lock before awaiting shutdown so + // start_gateway below can re-acquire it. + let handle = { let mut state = gateway_state.write().await; - if let Some(handle) = state.handle.take() { - handle.abort(); - } + let handle = state.handle.take(); state.running = false; state.url = None; + handle + }; + if let Some(h) = handle { + shutdown_gateway_handle(h).await; } // Start with new config - start_gateway(port, gateway_state, app_state, app_handle).await + start_gateway( + port, + allow_dynamic_fallback, + gateway_state, + sm_state, + app_state, + app_handle, + ) + .await } /// Generate gateway config for a client @@ -722,14 +1248,14 @@ pub async fn generate_gateway_config( serde_json::to_string_pretty(&config).map_err(|e| e.to_string()) } -/// Get the active/default space ID +/// Resolve the system's default space id (the `is_default` Space). async fn get_default_space_id(app_state: &AppState) -> Result { let space = app_state .space_service - .get_active() + .get_default() .await .map_err(|e: anyhow::Error| e.to_string())? - .ok_or("No active space found")?; + .ok_or("No default space found")?; Ok(space.id.to_string()) } @@ -805,9 +1331,6 @@ pub async fn connect_server( features.total_count() ); - // Ensure server-all featureset exists - ensure_server_featureset(&app_state, &server_id, &server_definition, &installed).await; - Ok(()) } ConnectionResult::Failed { error } => { @@ -832,25 +1355,6 @@ pub async fn connect_server( } } -/// Ensure server-all featureset exists after connection -/// -/// Note: Server state is now managed by ServerManager/PoolService, not GatewayState -async fn ensure_server_featureset( - app_state: &AppState, - server_id: &str, - registry_entry: &mcpmux_core::ServerDefinition, - installed: &mcpmux_core::InstalledServer, -) { - let space_id_str = installed.space_id.clone(); - if let Err(e) = app_state - .feature_set_repository - .ensure_server_all(&space_id_str, server_id, ®istry_entry.name) - .await - { - warn!("[Gateway] Failed to create server-all featureset: {}", e); - } -} - /// Disconnect a server from the gateway #[tauri::command] pub async fn disconnect_server( @@ -1071,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 { @@ -1084,16 +1589,25 @@ 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, reused, features.total_count() ); - - // Ensure server-all featureset exists - ensure_server_featureset(&app_state, &server_id, &server_definition, &installed) - .await; } ConnectionResult::OAuthRequired { auth_url: _ } => { result.oauth_required += 1; diff --git a/apps/desktop/src-tauri/src/commands/logs.rs b/apps/desktop/src-tauri/src/commands/logs.rs index ecc62172..fb04052a 100644 --- a/apps/desktop/src-tauri/src/commands/logs.rs +++ b/apps/desktop/src-tauri/src/commands/logs.rs @@ -6,14 +6,14 @@ use serde::Serialize; use tauri::State; use tracing::{info, warn}; -/// Helper to get the default space ID +/// Helper to get the system default space ID. async fn get_default_space_id(state: &AppState) -> Result { let space = state .space_service - .get_active() + .get_default() .await .map_err(|e: anyhow::Error| e.to_string())? - .ok_or("No active space found")?; + .ok_or("No default space found")?; Ok(space.id.to_string()) } diff --git a/apps/desktop/src-tauri/src/commands/meta_tool_approval.rs b/apps/desktop/src-tauri/src/commands/meta_tool_approval.rs new file mode 100644 index 00000000..e7e77e95 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/meta_tool_approval.rs @@ -0,0 +1,111 @@ +//! Tauri commands for meta-tool approval dialogs. +//! +//! Flow: +//! 1. Gateway's [`ApprovalBroker`] emits `meta-tool-approval-request` +//! event (see gateway.rs `start_gateway`). +//! 2. React dialog renders it, user picks once/always/deny. +//! 3. Dialog calls [`respond_to_meta_tool_approval`], which resolves the +//! broker's oneshot channel and unblocks the calling tool. + +use std::sync::Arc; + +use mcpmux_gateway::services::ApprovalDecision; +use serde::Serialize; +use tauri::State; +use tokio::sync::RwLock; +use tracing::{info, warn}; + +use crate::commands::gateway::GatewayAppState; + +#[derive(Debug, Serialize)] +pub struct MetaToolGrantEntry { + pub client_id: String, + pub tool_name: String, +} + +/// Resolve a pending approval dialog. +/// +/// `decision` is one of `"allow_once" | "always_for_this_session_and_client" | "deny"`. +/// Called from the React dialog. If the broker doesn't recognize the +/// request_id (e.g. it already timed out), returns a no-op success so the +/// UI can close its dialog cleanly. +#[tauri::command] +pub async fn respond_to_meta_tool_approval( + request_id: String, + client_id: String, + tool_name: String, + decision: String, + gateway_state: State<'_, Arc>>, +) -> Result { + let decision = match decision.as_str() { + "allow_once" => ApprovalDecision::AllowOnce, + "always_for_this_session_and_client" => ApprovalDecision::AlwaysForThisSessionAndClient, + "deny" => ApprovalDecision::Deny, + other => return Err(format!("unknown decision: {other}")), + }; + + let broker = { + let state = gateway_state.read().await; + state.approval_broker.clone() + }; + let Some(broker) = broker else { + warn!("[meta-tool] respond called but gateway is not running"); + return Ok(false); + }; + + // client_id is opaque (UUID for preset clients, OAuth client_metadata + // URL for DCR clients like Claude Code). The broker treats it as a + // hash key only. + let resolved = broker.respond(&request_id, &client_id, &tool_name, decision); + info!( + %request_id, + %client_id, + tool = %tool_name, + ?decision, + resolved, + "[meta-tool] approval decision recorded" + ); + Ok(resolved) +} + +/// List every active "always allow from this client for this tool" grant. +/// +/// Entries are session-only (cleared on gateway restart by design). The +/// Connections page uses this to show a revoke list. +#[tauri::command] +pub async fn list_meta_tool_grants( + gateway_state: State<'_, Arc>>, +) -> Result, String> { + let broker = { + let state = gateway_state.read().await; + state.approval_broker.clone() + }; + let Some(broker) = broker else { + return Ok(vec![]); + }; + Ok(broker + .list_always_allow() + .into_iter() + .map(|(client_id, tool_name)| MetaToolGrantEntry { + client_id, + tool_name, + }) + .collect()) +} + +/// Revoke an "always allow" entry. +#[tauri::command] +pub async fn revoke_meta_tool_grant( + client_id: String, + tool_name: String, + gateway_state: State<'_, Arc>>, +) -> Result { + let broker = { + let state = gateway_state.read().await; + state.approval_broker.clone() + }; + let Some(broker) = broker else { + return Ok(false); + }; + Ok(broker.revoke_always_allow(&client_id, &tool_name)) +} diff --git a/apps/desktop/src-tauri/src/commands/mod.rs b/apps/desktop/src-tauri/src/commands/mod.rs index 7e775b70..6d8ff479 100644 --- a/apps/desktop/src-tauri/src/commands/mod.rs +++ b/apps/desktop/src-tauri/src/commands/mod.rs @@ -4,7 +4,6 @@ //! Commands are organized by feature area. pub mod client; -pub mod client_custom_features; pub mod client_install; pub mod config_export; pub mod credential; @@ -12,27 +11,34 @@ pub mod feature_members; pub mod feature_set; pub mod gateway; pub mod logs; +pub mod meta_tool_approval; pub mod oauth; pub mod server; +pub mod server_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; // Re-export commands for convenience pub use client::*; -pub use client_custom_features::*; pub use client_install::*; pub use config_export::*; pub use feature_members::*; pub use feature_set::*; pub use gateway::*; 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/oauth.rs b/apps/desktop/src-tauri/src/commands/oauth.rs index 2eba910a..820e8eaa 100644 --- a/apps/desktop/src-tauri/src/commands/oauth.rs +++ b/apps/desktop/src-tauri/src/commands/oauth.rs @@ -25,7 +25,8 @@ //! - PKCE required for all authorization requests (RFC 7636) use std::collections::HashMap; -use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; use mcpmux_core::branding; use serde::{Deserialize, Serialize}; @@ -40,6 +41,45 @@ use super::gateway::GatewayAppState; // Deep Link Handling // ============================================================================ +/// Holds a deep-link URL the app was cold-started with (Windows/Linux) until +/// the webview has mounted its listeners. Emitting `oauth-consent-request` +/// before React has subscribed drops the event — Tauri events are fire-and- +/// forget with no replay. The frontend calls `flush_pending_deep_link` once +/// its listener is live to process any buffered URL. +#[derive(Default)] +pub struct PendingInitialDeepLink { + pub url: Mutex>, + pub webview_ready: AtomicBool, +} + +/// Called from `on_open_url`: route immediately if the webview has signalled +/// ready, otherwise buffer for later flush. Falls back to direct routing +/// if the state isn't managed yet (shouldn't happen after setup). +pub fn route_or_buffer_deep_link(app: &tauri::AppHandle, url: &str) { + match app.try_state::() { + Some(pending) if !pending.webview_ready.load(Ordering::Acquire) => { + info!("[DeepLink] Webview not ready — buffering URL: {}", url); + if let Ok(mut guard) = pending.url.lock() { + *guard = Some(url.to_string()); + } + } + _ => handle_deep_link(app, url), + } +} + +/// Invoked by the frontend once the `oauth-consent-request` listener is live. +/// Marks the webview ready so subsequent URLs route immediately, and drains +/// any URL that arrived before mount. +#[tauri::command] +pub fn flush_pending_deep_link(app: tauri::AppHandle, pending: State<'_, PendingInitialDeepLink>) { + pending.webview_ready.store(true, Ordering::Release); + let buffered = pending.url.lock().ok().and_then(|mut g| g.take()); + if let Some(url) = buffered { + info!("[DeepLink] Flushing buffered cold-start URL: {}", url); + handle_deep_link(&app, &url); + } +} + /// Event name for OAuth consent requests sent to frontend /// Now only contains request_id - frontend must call get_pending_consent pub const OAUTH_CONSENT_EVENT: &str = "oauth-consent-request"; @@ -408,12 +448,8 @@ pub struct ConsentApprovalRequest { /// Cryptographic consent token (must match the one issued via get_pending_consent). /// This proves the caller obtained the token through Tauri IPC, not HTTP scraping. pub consent_token: String, - /// Optional alias name for the client + /// Optional alias name for the client (set during approval). pub client_alias: Option, - /// Connection mode: "follow_active", "locked", or "ask_on_change" - pub connection_mode: Option, - /// Space ID to lock to (only used when connection_mode is "locked") - pub locked_space_id: Option, } /// Response from consent approval @@ -548,59 +584,30 @@ pub async fn approve_oauth_consent( state.store_pending_authorization(&code, new_pending); - // Mark client as approved and store settings + // Mark client as approved and store any alias override. if let Some(repo) = state.inbound_client_repository() { - // Mark as approved for clients tab visibility if let Err(e) = repo.approve_client(&pending.client_id).await { error!("[OAuth] Failed to approve client: {}", e); } else { info!("[OAuth] Client approved: {}", pending.client_id); } - // Update client settings (alias, connection_mode, locked_space_id) - if let Ok(Some(mut client)) = repo.get_client(&pending.client_id).await { - let mut changed = false; - - // Set alias if provided - if let Some(alias) = &request.client_alias { - if !alias.is_empty() { - client.client_alias = Some(alias.clone()); - changed = true; - info!( - "[OAuth] Set client alias '{}' for: {}", - alias, pending.client_id - ); - } - } - - // Set connection mode if provided - if let Some(mode) = &request.connection_mode { - client.connection_mode = mode.clone(); - changed = true; - info!( - "[OAuth] Set connection mode '{}' for: {}", - mode, pending.client_id - ); - } - - // Set locked space if provided (only meaningful when mode is "locked") - if let Some(space_id) = &request.locked_space_id { - client.locked_space_id = Some(space_id.clone()); - changed = true; + if let Some(alias) = request + .client_alias + .as_deref() + .filter(|s| !s.is_empty()) + .map(String::from) + { + if let Err(e) = repo + .update_client_alias(&pending.client_id, Some(alias.clone())) + .await + { + error!("[OAuth] Failed to save client alias: {}", e); + } else { info!( - "[OAuth] Locked to space '{}' for: {}", - space_id, pending.client_id + "[OAuth] Set client alias '{}' for: {}", + alias, pending.client_id ); - } else if request.connection_mode.as_deref() == Some("follow_active") { - // Clear locked space if switching to follow_active - client.locked_space_id = None; - changed = true; - } - - if changed { - if let Err(e) = repo.save_client(&client).await { - error!("[OAuth] Failed to save client settings: {}", e); - } } } } @@ -683,10 +690,10 @@ pub async fn get_oauth_clients( metadata_url: client.metadata_url, metadata_cached_at: client.metadata_cached_at, metadata_cache_ttl: client.metadata_cache_ttl, - connection_mode: client.connection_mode, - locked_space_id: client.locked_space_id, last_seen: client.last_seen, created_at: client.created_at, + reports_roots: client.reports_roots, + roots_capability_known: client.roots_capability_known, }) .collect(); @@ -755,19 +762,31 @@ pub struct OAuthClientInfo { #[serde(skip_serializing_if = "Option::is_none")] pub metadata_cache_ttl: Option, - // MCP client preferences - pub connection_mode: String, - pub locked_space_id: Option, pub last_seen: Option, pub created_at: String, + + /// Sticky-positive bit: `true` once any session of this client + /// declared the MCP `roots` capability. Meaningful only when + /// `roots_capability_known` is `true` — for a brand-new client we + /// haven't seen `initialize` for yet, this defaults to `false` but + /// the UI must hide the "Rootless" badge instead of trusting it. + pub reports_roots: bool, + + /// `true` once we've processed at least one `notifications/initialized` + /// for this client. Until then, the UI treats the capability as + /// unknown (no badge). Once known, the badge resolves to either + /// "Reports workspace" (`reports_roots = true`) or "Rootless" + /// (`reports_roots = false`). + pub roots_capability_known: bool, } -/// Request to update client settings +/// Request to update client settings. +/// +/// Only the alias is user-editable now — connection mode / space pin no +/// longer exist. #[derive(Debug, Serialize, Deserialize)] pub struct UpdateClientSettingsRequest { pub client_alias: Option, - pub connection_mode: Option, - pub locked_space_id: Option, } /// Update an OAuth client's settings (direct service access) @@ -789,31 +808,22 @@ pub async fn update_oauth_client( return Err("Database not available".to_string()); }; - // Update client directly via repository - repo.update_client_settings( - &client_id, - settings.client_alias, - settings.connection_mode, - settings.locked_space_id.map(Some), - ) - .await - .map_err(|e| format!("Failed to update client: {}", e))?; + repo.update_client_alias(&client_id, settings.client_alias) + .await + .map_err(|e| format!("Failed to update client: {}", e))?; info!("[OAuth] Updated client: {}", client_id); - // Emit domain event state.emit_domain_event(mcpmux_core::DomainEvent::ClientUpdated { client_id: client_id.clone(), }); - // Get updated client let updated_client = repo .get_client(&client_id) .await .map_err(|e| format!("Failed to get updated client: {}", e))? .ok_or("Client not found after update")?; - // Map to response format Ok(OAuthClientInfo { client_id: updated_client.client_id, registration_type: updated_client.registration_type.as_str().to_string(), @@ -829,283 +839,10 @@ pub async fn update_oauth_client( metadata_url: updated_client.metadata_url, metadata_cached_at: updated_client.metadata_cached_at, metadata_cache_ttl: updated_client.metadata_cache_ttl, - connection_mode: updated_client.connection_mode, - locked_space_id: updated_client.locked_space_id, last_seen: updated_client.last_seen, created_at: updated_client.created_at, - }) -} - -/// Get grants for an OAuth client in a specific space -/// -/// Returns the effective grants: explicit grants + the default feature set -/// This matches the authorization behavior used by MCP handlers -#[tauri::command] -pub async fn get_oauth_client_grants( - gateway_state: State<'_, Arc>>, - app_state: State<'_, crate::AppState>, - client_id: String, - space_id: String, -) -> Result, String> { - let gw_app_state = gateway_state.read().await; - - // Get gateway state and inbound client repository - let Some(ref gw_state) = gw_app_state.gateway_state else { - return Err("Gateway not running".to_string()); - }; - - let state = gw_state.read().await; - let Some(repo) = state.inbound_client_repository() else { - return Err("Database not available".to_string()); - }; - - // Get explicit grants from DB - let mut grants = repo - .get_grants_for_space(&client_id, &space_id) - .await - .map_err(|e| format!("Failed to get grants: {}", e))?; - - // Add default feature set (layered resolution - same as MCP handlers) - if let Ok(Some(default_fs)) = app_state - .feature_set_repository - .get_default_for_space(&space_id) - .await - { - if !grants.contains(&default_fs.id) { - grants.push(default_fs.id); - } - } - - Ok(grants) -} - -/// Grant a feature set to an OAuth client in a specific space -#[tauri::command] -pub async fn grant_oauth_client_feature_set( - app_handle: tauri::AppHandle, - gateway_state: State<'_, Arc>>, - client_id: String, - space_id: String, - feature_set_id: String, -) -> Result<(), String> { - info!("[OAuth] grant_oauth_client_feature_set called: client_id={}, space_id={}, feature_set_id={}", - client_id, space_id, feature_set_id); - - let app_state = gateway_state.read().await; - - info!("[OAuth] Gateway running: {}", app_state.running); - info!( - "[OAuth] Gateway state exists: {}", - app_state.gateway_state.is_some() - ); - info!( - "[OAuth] Grant service exists: {}", - app_state.grant_service.is_some() - ); - - // Get GrantService (centralized grant management with auto-notifications) - let Some(ref grant_service) = app_state.grant_service else { - error!( - "[OAuth] Grant service is None! Gateway running={}, gateway_state={}", - app_state.running, - app_state.gateway_state.is_some() - ); - return Err("Gateway not running".to_string()); - }; - - // Single call handles: DB update + validation + automatic notifications (DRY!) - grant_service - .grant_feature_set(&client_id, &space_id, &feature_set_id) - .await - .map_err(|e| format!("Failed to grant feature set: {}", e))?; - - // Notify UI - if let Err(e) = app_handle.emit( - "oauth-client-changed", - serde_json::json!({ - "action": "grants_updated", - "client_id": client_id, - }), - ) { - error!("[OAuth] Failed to emit oauth-client-changed event: {}", e); - } - - Ok(()) -} - -/// Revoke a feature set from an OAuth client in a specific space -#[tauri::command] -pub async fn revoke_oauth_client_feature_set( - app_handle: tauri::AppHandle, - gateway_state: State<'_, Arc>>, - client_id: String, - space_id: String, - feature_set_id: String, -) -> Result<(), String> { - let app_state = gateway_state.read().await; - - // Get GrantService (centralized grant management with auto-notifications) - let Some(ref grant_service) = app_state.grant_service else { - return Err("Gateway not running".to_string()); - }; - - // Single call handles: DB update + validation + automatic notifications (DRY!) - grant_service - .revoke_feature_set(&client_id, &space_id, &feature_set_id) - .await - .map_err(|e| format!("Failed to revoke feature set: {}", e))?; - - // Notify UI - if let Err(e) = app_handle.emit( - "oauth-client-changed", - serde_json::json!({ - "action": "grants_updated", - "client_id": client_id, - }), - ) { - error!("[OAuth] Failed to emit oauth-client-changed event: {}", e); - } - - Ok(()) -} - -/// Resolved client features response -#[derive(Debug, Serialize, Deserialize)] -pub struct ResolvedClientFeatures { - pub space_id: String, - pub feature_set_ids: Vec, - pub tools: Vec, - pub prompts: Vec, - pub resources: Vec, -} - -/// Get resolved features for an OAuth client in a specific space -/// -/// Returns the granted feature sets and resolved capabilities for a client. -/// This is used by the UI to display what a client has access to. -/// -/// The frontend is responsible for determining which space to query: -/// - For locked clients: pass the client's locked_space_id -/// - For follow_active clients: pass the currently active space_id -/// -/// This keeps space resolution logic in ONE place (frontend/SpaceResolverService) -/// rather than duplicating it here. -#[tauri::command] -pub async fn get_oauth_client_resolved_features( - gateway_state: State<'_, Arc>>, - app_state: State<'_, crate::AppState>, - client_id: String, - space_id: String, // Required - frontend must resolve which space to use -) -> Result { - let gw_app_state = gateway_state.read().await; - - // Get gateway state - let Some(ref gw_state) = gw_app_state.gateway_state else { - return Err("Gateway not running".to_string()); - }; - - // Get feature service - let Some(ref feature_service) = gw_app_state.feature_service else { - return Err("Feature service not available".to_string()); - }; - - // Get inbound client repository for grants - let state = gw_state.read().await; - let Some(repo) = state.inbound_client_repository() else { - return Err("Database not available".to_string()); - }; - - // Get explicit grants for this client in this space - let mut feature_set_ids = repo - .get_grants_for_space(&client_id, &space_id) - .await - .map_err(|e| format!("Failed to get grants: {}", e))?; - - // Add default feature set (layered resolution - same as MCP handlers) - if let Ok(Some(default_fs)) = app_state - .feature_set_repository - .get_default_for_space(&space_id) - .await - { - if !feature_set_ids.contains(&default_fs.id) { - feature_set_ids.push(default_fs.id); - } - } - - info!( - "[OAuth] Client {} has {} effective grants in space {}", - client_id, - feature_set_ids.len(), - space_id - ); - - // Release the lock before calling feature service - drop(state); - - // Resolve features from feature sets using FeatureService - let tools = feature_service - .get_tools_for_grants(&space_id, &feature_set_ids) - .await - .unwrap_or_default(); - - let prompts = feature_service - .get_prompts_for_grants(&space_id, &feature_set_ids) - .await - .unwrap_or_default(); - - let resources = feature_service - .get_resources_for_grants(&space_id, &feature_set_ids) - .await - .unwrap_or_default(); - - info!( - "[OAuth] Resolved features for client {}: {} tools, {} prompts, {} resources", - client_id, - tools.len(), - prompts.len(), - resources.len() - ); - - // Convert to response format - let tools_response: Vec<_> = tools - .iter() - .map(|f| { - serde_json::json!({ - "name": f.feature_name, - "description": f.description, - "server_id": f.server_id, - }) - }) - .collect(); - - let prompts_response: Vec<_> = prompts - .iter() - .map(|f| { - serde_json::json!({ - "name": f.feature_name, - "description": f.description, - "server_id": f.server_id, - }) - }) - .collect(); - - let resources_response: Vec<_> = resources - .iter() - .map(|f| { - serde_json::json!({ - "name": f.feature_name, - "description": f.description, - "server_id": f.server_id, - }) - }) - .collect(); - - Ok(ResolvedClientFeatures { - space_id, - feature_set_ids, - tools: tools_response, - prompts: prompts_response, - resources: resources_response, + reports_roots: updated_client.reports_roots, + roots_capability_known: updated_client.roots_capability_known, }) } @@ -1253,3 +990,108 @@ pub async fn open_url(url: String) -> Result<(), String> { Ok(()) } } + +// ============================================================================ +// Client grants — rootless OAuth-client fallback path. +// +// Roots-capable sessions ignore these grants; the resolver routes them via +// `WorkspaceBinding`. These commands target the older `client_grants` table +// (restored in migration 009) and back the per-client FS toggles in the +// Clients UI. Each write is funnelled through `GrantService` so a +// `ClientGrantChanged` domain event fires + MCPNotifier pushes +// `list_changed` to that client's open peers. +// ============================================================================ + +/// Read the FeatureSet ids granted to a (client, space) pair. +/// +/// Returns an empty Vec when nothing is granted — the UI renders the +/// "no defaults configured" state in that case. The default-FS layering +/// from older revisions is *not* applied here: the resolver itself decides +/// what an unconfigured grant means (deny when rootless), and the UI shows +/// the literal grant set so the user can see exactly what they configured. +#[tauri::command] +pub async fn get_oauth_client_grants( + gateway_state: State<'_, Arc>>, + client_id: String, + space_id: String, +) -> Result, String> { + let gw_state = gateway_state.read().await; + let Some(ref grant_service) = gw_state.grant_service else { + return Err("Gateway not running".to_string()); + }; + grant_service + .get_grants_for_space(&client_id, &space_id) + .await + .map_err(|e| format!("Failed to get grants: {}", e)) +} + +/// Grant a feature set to an OAuth client in a specific space. +/// Idempotent at the DB layer; always emits `ClientGrantChanged`. +#[tauri::command] +pub async fn grant_oauth_client_feature_set( + app_handle: tauri::AppHandle, + gateway_state: State<'_, Arc>>, + client_id: String, + space_id: String, + feature_set_id: String, +) -> Result<(), String> { + info!( + "[OAuth] grant_oauth_client_feature_set: client_id={}, space_id={}, feature_set_id={}", + client_id, space_id, feature_set_id + ); + + let gw_state = gateway_state.read().await; + let Some(ref grant_service) = gw_state.grant_service else { + error!("[OAuth] Grant service unavailable (gateway not running)"); + return Err("Gateway not running".to_string()); + }; + + grant_service + .grant_feature_set(&client_id, &space_id, &feature_set_id) + .await + .map_err(|e| format!("Failed to grant feature set: {}", e))?; + + if let Err(e) = app_handle.emit( + "oauth-client-changed", + serde_json::json!({ + "action": "grants_updated", + "client_id": client_id, + }), + ) { + error!("[OAuth] Failed to emit oauth-client-changed event: {}", e); + } + + Ok(()) +} + +/// Revoke a feature set from an OAuth client in a specific space. +#[tauri::command] +pub async fn revoke_oauth_client_feature_set( + app_handle: tauri::AppHandle, + gateway_state: State<'_, Arc>>, + client_id: String, + space_id: String, + feature_set_id: String, +) -> Result<(), String> { + let gw_state = gateway_state.read().await; + let Some(ref grant_service) = gw_state.grant_service else { + return Err("Gateway not running".to_string()); + }; + + grant_service + .revoke_feature_set(&client_id, &space_id, &feature_set_id) + .await + .map_err(|e| format!("Failed to revoke feature set: {}", e))?; + + if let Err(e) = app_handle.emit( + "oauth-client-changed", + serde_json::json!({ + "action": "grants_updated", + "client_id": client_id, + }), + ) { + error!("[OAuth] Failed to emit oauth-client-changed event: {}", e); + } + + Ok(()) +} diff --git a/apps/desktop/src-tauri/src/commands/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 70c10bc5..d0c95682 100644 --- a/apps/desktop/src-tauri/src/commands/settings.rs +++ b/apps/desktop/src-tauri/src/commands/settings.rs @@ -128,6 +128,81 @@ pub fn should_start_hidden() -> bool { args.contains(&"--hidden".to_string()) } +/// Get the current value of the meta-tools master switch. +/// +/// When disabled, the gateway hides the entire `mcpmux_*` namespace from +/// connected MCP clients — no introspection, no self-management. Default +/// ON. +#[tauri::command] +pub async fn get_meta_tools_enabled(app_state: State<'_, AppState>) -> Result { + match app_state + .settings_repository + .get("gateway.meta_tools_enabled") + .await + { + Ok(Some(v)) => Ok(!matches!(v.as_str(), "false" | "0")), + _ => Ok(true), + } +} + +/// Flip the meta-tools master switch. The change takes effect on the NEXT +/// `list_tools` / `call_tool` from any connected client — existing cached +/// tool lists are invalidated by the usual `tools/list_changed` push. +#[tauri::command] +pub async fn set_meta_tools_enabled( + enabled: bool, + app_state: State<'_, AppState>, +) -> Result<(), String> { + app_state + .settings_repository + .set( + "gateway.meta_tools_enabled", + if enabled { "true" } else { "false" }, + ) + .await + .map_err(|e| format!("Failed to save meta_tools_enabled: {}", e))?; + info!("[Settings] meta_tools_enabled = {}", enabled); + Ok(()) +} + +/// 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::*; @@ -135,9 +210,9 @@ mod tests { #[test] fn test_startup_settings_default() { let settings = StartupSettings::default(); - assert_eq!(settings.auto_launch, true); - assert_eq!(settings.start_minimized, true); - assert_eq!(settings.close_to_tray, true); + assert!(settings.auto_launch); + assert!(settings.start_minimized); + assert!(settings.close_to_tray); } #[test] @@ -159,9 +234,9 @@ mod tests { let json = r#"{"autoLaunch":true,"startMinimized":true,"closeToTray":false}"#; let settings: StartupSettings = serde_json::from_str(json).unwrap(); - assert_eq!(settings.auto_launch, true); - assert_eq!(settings.start_minimized, true); - assert_eq!(settings.close_to_tray, false); + assert!(settings.auto_launch); + assert!(settings.start_minimized); + assert!(!settings.close_to_tray); } #[test] diff --git a/apps/desktop/src-tauri/src/commands/space.rs b/apps/desktop/src-tauri/src/commands/space.rs index 7f0bbfc7..8d8b3ea2 100644 --- a/apps/desktop/src-tauri/src/commands/space.rs +++ b/apps/desktop/src-tauri/src/commands/space.rs @@ -1,11 +1,15 @@ //! Space management commands //! -//! IPC commands for managing spaces (isolated environments). - -use mcpmux_core::{ConnectionMode, Space}; -use serde::Serialize; +//! IPC commands for managing spaces (isolated environments). There's no +//! "active space" — gateway routing is decided per reported workspace +//! root via `WorkspaceBinding`, with the `is_default` Space as the +//! built-in fallback. The desktop UI tracks which space the user is +//! viewing in its own Zustand store (frontend-only state). + +use mcpmux_core::Space; +use serde::Deserialize; use std::sync::Arc; -use tauri::{AppHandle, Emitter, State}; +use tauri::{AppHandle, State}; use tokio::sync::RwLock; use tracing::{info, warn}; use uuid::Uuid; @@ -14,28 +18,6 @@ use crate::commands::gateway::GatewayAppState; use crate::state::AppState; use crate::tray; -/// Space change event payload -#[derive(Debug, Clone, Serialize)] -pub struct SpaceChangeEvent { - /// Previous active space ID - pub from_space_id: Option, - /// New active space ID - pub to_space_id: String, - /// New active space name - pub to_space_name: String, - /// Clients that need confirmation (AskOnChange mode) - pub clients_needing_confirmation: Vec, -} - -/// Client that needs confirmation for space change -#[derive(Debug, Clone, Serialize)] -pub struct ClientConfirmation { - /// Client ID - pub id: String, - /// Client name - pub name: String, -} - /// List all spaces. #[tauri::command] pub async fn list_spaces(state: State<'_, AppState>) -> Result, String> { @@ -122,152 +104,89 @@ pub async fn create_space( Ok(space) } -/// Delete a 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 delete_space( +pub async fn update_space( id: String, + input: UpdateSpaceInput, app: AppHandle, state: State<'_, AppState>, gateway_state: State<'_, Arc>>, -) -> Result<(), String> { +) -> Result { let uuid = Uuid::parse_str(&id).map_err(|e| e.to_string())?; - state + 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 - .delete(&uuid) + .update(uuid, name, icon, description) .await .map_err(|e| e.to_string())?; - // Emit domain event if gateway is running let gw_state = gateway_state.read().await; if let Some(ref gw) = gw_state.gateway_state { let gw = gw.read().await; - gw.emit_domain_event(mcpmux_core::DomainEvent::SpaceDeleted { space_id: uuid }); + gw.emit_domain_event(mcpmux_core::DomainEvent::SpaceUpdated { + space_id: space.id, + name: space.name.clone(), + }); } - // Update system tray menu to remove the deleted space - // Only reached if space deletion from DB succeeded if let Err(e) = tray::update_tray_spaces(&app, &state).await { warn!("Failed to update tray menu: {}", e); } - info!("[delete_space] Space '{}' deleted successfully", uuid); - - Ok(()) -} - -/// Get the active (default) space. -#[tauri::command] -pub async fn get_active_space(state: State<'_, AppState>) -> Result, String> { - tracing::info!("[get_active_space] Command invoked"); - - let active = state.space_service.get_active().await.map_err(|e| { - tracing::error!("[get_active_space] Error: {}", e); - e.to_string() - })?; - - if let Some(ref space) = active { - tracing::info!( - "[get_active_space] Returning: {} ({})", - space.name, - space.id - ); - } else { - tracing::warn!("[get_active_space] No active space found"); - } + info!("[update_space] Space '{}' updated successfully", space.name); - Ok(active) + Ok(space) } -/// Set the active space. +/// Delete a space. #[tauri::command] -pub async fn set_active_space( +pub async fn delete_space( id: String, - app_handle: AppHandle, + app: AppHandle, state: State<'_, AppState>, gateway_state: State<'_, Arc>>, ) -> Result<(), String> { - let new_space_uuid = Uuid::parse_str(&id).map_err(|e| e.to_string())?; - - // Get current active space before changing - let old_space = state - .space_service - .get_active() - .await - .map_err(|e| e.to_string())?; + let uuid = Uuid::parse_str(&id).map_err(|e| e.to_string())?; - // Set new active space state .space_service - .set_active(&new_space_uuid) + .delete(&uuid) .await .map_err(|e| e.to_string())?; - // Get new space details - let new_space = state - .space_service - .get(&new_space_uuid) - .await - .map_err(|e| e.to_string())? - .ok_or("Space not found")?; - // Emit domain event if gateway is running let gw_state = gateway_state.read().await; if let Some(ref gw) = gw_state.gateway_state { let gw = gw.read().await; - - // Emit activated event with transition info - gw.emit_domain_event(mcpmux_core::DomainEvent::SpaceActivated { - from_space_id: old_space.as_ref().map(|s| s.id), - to_space_id: new_space.id, - to_space_name: new_space.name.clone(), - }); - } - - // Find clients with AskOnChange mode - let clients = state - .client_repository - .list() - .await - .map_err(|e| e.to_string())?; - - let clients_needing_confirmation: Vec = clients - .into_iter() - .filter(|c| matches!(c.connection_mode, ConnectionMode::AskOnChange { .. })) - .map(|c| ClientConfirmation { - id: c.id.to_string(), - name: c.name, - }) - .collect(); - - // Emit legacy space-changed event for backward compatibility (can be removed later) - let event = SpaceChangeEvent { - from_space_id: old_space.map(|s| s.id.to_string()), - to_space_id: new_space.id.to_string(), - to_space_name: new_space.name.clone(), - clients_needing_confirmation: clients_needing_confirmation.clone(), - }; - - if let Err(e) = app_handle.emit("space-changed", &event) { - warn!("Failed to emit space-changed event: {}", e); - } else { - info!( - "Emitted space-changed event: {} clients need confirmation", - clients_needing_confirmation.len() - ); + gw.emit_domain_event(mcpmux_core::DomainEvent::SpaceDeleted { space_id: uuid }); } - // Note: MCP list_changed notifications for follow_active clients - // will be emitted by the gateway when they make their next request - // and the SpaceResolver returns the new active space. - - // Update system tray menu to show checkmark (✓) on the newly active space - // Only reached if set_active operation succeeded in DB - if let Err(e) = tray::update_tray_spaces(&app_handle, &state).await { + // Update system tray menu to remove the deleted space + // Only reached if space deletion from DB succeeded + if let Err(e) = tray::update_tray_spaces(&app, &state).await { warn!("Failed to update tray menu: {}", e); } - info!("[set_active_space] Switched to space '{}'", new_space.name); + info!("[delete_space] Space '{}' deleted successfully", uuid); Ok(()) } diff --git a/apps/desktop/src-tauri/src/commands/workspace_binding.rs b/apps/desktop/src-tauri/src/commands/workspace_binding.rs new file mode 100644 index 00000000..b1c1fcc9 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/workspace_binding.rs @@ -0,0 +1,735 @@ +//! Tauri commands for workspace-root FeatureSet bindings. +//! +//! Every binding hard-pins a concrete (space_id, feature_set_id) pair. No +//! "follow active" modes — the mapping from root on disk to the toolset that +//! clients see is fully explicit, which is what our users actually want. + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use mcpmux_core::{ + validate_workspace_root as validate_root, DomainEvent, FeatureSet, FeatureSetType, MemberMode, + MemberType, ServerFeature, WorkspaceBinding, WorkspaceRootValidation, +}; +use serde::{Deserialize, Serialize}; +use tauri::State; +use tokio::sync::RwLock; +use tracing::{debug, error, info}; +use uuid::Uuid; + +use super::gateway::GatewayAppState; +use super::server_manager::ServerManagerState; +use crate::state::AppState; + +/// Publish `WorkspaceBindingChanged` on the gateway's domain bus so +/// MCPNotifier broadcasts `list_changed` to every peer whose session now +/// routes through the changed binding. +/// +/// Best-effort: gateway not running (no subscribers) is a normal condition +/// at startup and must not fail the command. +async fn emit_binding_changed( + gateway_state: &Arc>, + space_id: Uuid, + workspace_root: String, +) { + let gw_state = gateway_state.read().await; + let Some(ref gw) = gw_state.gateway_state else { + debug!("[workspace_binding] gateway not running — skipping emit"); + return; + }; + gw.read() + .await + .emit_domain_event(DomainEvent::WorkspaceBindingChanged { + space_id, + workspace_root, + }); +} + +/// DTO returned to the React layer. +/// +/// `feature_set_ids` is non-empty by construction — empty bindings are +/// rejected at the create/update commands. Order is the operator-chosen +/// rendering order; the resolver treats the list as a set. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkspaceBindingDto { + pub id: String, + pub workspace_root: String, + pub label: Option, + pub space_id: String, + pub feature_set_ids: Vec, + pub created_at: String, + pub updated_at: String, +} + +impl From for WorkspaceBindingDto { + fn from(b: WorkspaceBinding) -> Self { + 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(), + updated_at: b.updated_at.to_rfc3339(), + } + } +} + +/// Input for creating or updating a binding. Pass at least one +/// `feature_set_id` in `feature_set_ids` — empty is rejected. +/// +/// Order matters for UI rendering only; the resolver merges them. +#[derive(Debug, Deserialize)] +pub struct WorkspaceBindingInput { + pub workspace_root: String, + pub 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}")) +} + +fn validate_fs_list(input: &WorkspaceBindingInput) -> Result, String> { + let cleaned: Vec = input + .feature_set_ids + .iter() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + if cleaned.is_empty() { + return Err("at least one feature_set_id is required".into()); + } + // Dedup while preserving order so the operator's intent ("primary then + // overlay") survives a duplicate they may have accidentally supplied. + let mut seen = HashSet::new(); + let deduped: Vec = cleaned + .into_iter() + .filter(|id| seen.insert(id.clone())) + .collect(); + Ok(deduped) +} + +/// List every filesystem path connected MCP clients have reported as a +/// workspace root, deduplicated across sessions. The Workspaces tab +/// renders this next to the persisted bindings so users can configure +/// folders they missed the one-shot prompt for. +/// +/// Returns an empty list when the gateway isn't running — that's a normal +/// startup condition, not an error. +#[tauri::command] +pub async fn list_reported_workspace_roots( + gateway_state: State<'_, Arc>>, +) -> Result, String> { + let guard = gateway_state.read().await; + Ok(guard + .session_roots + .as_ref() + .map(|reg| reg.list_all_roots()) + .unwrap_or_default()) +} + +/// List every binding (sorted by workspace_root). +#[tauri::command] +pub async fn list_workspace_bindings( + state: State<'_, AppState>, +) -> Result, String> { + state + .workspace_binding_repository + .list() + .await + .map(|v| v.into_iter().map(Into::into).collect()) + .map_err(|e| { + error!("[workspace_binding::list] {e}"); + e.to_string() + }) +} + +/// Bindings whose target Space is the given one. +#[tauri::command] +pub async fn list_workspace_bindings_for_space( + space_id: String, + state: State<'_, AppState>, +) -> Result, String> { + let space_uuid = Uuid::parse_str(&space_id).map_err(|e| e.to_string())?; + state + .workspace_binding_repository + .list_for_space(&space_uuid) + .await + .map(|v| v.into_iter().map(Into::into).collect()) + .map_err(|e| e.to_string()) +} + +/// Live path validation for the UI — returns `Ok(normalized)` or +/// `Err(reason)`. Runs the same rules the create/update commands apply, so +/// the form can show the real error message without round-tripping a save. +#[tauri::command] +pub async fn validate_workspace_root(path: String) -> Result { + match validate_root(&path) { + WorkspaceRootValidation::Empty => Err(String::new()), + WorkspaceRootValidation::Ok { normalized } => Ok(normalized), + WorkspaceRootValidation::Invalid { reason } => Err(reason), + } +} + +/// Normalize + validate a manually-entered workspace root, returning the +/// canonical form to store. Rejects relative paths, filesystem roots, and +/// (for Windows-style paths) reserved characters — these are the exact +/// conditions that would produce a binding no session could ever match. +fn normalize_and_validate(raw: &str) -> Result { + match validate_root(raw) { + WorkspaceRootValidation::Empty => Err("workspace_root cannot be empty".into()), + WorkspaceRootValidation::Ok { normalized } => Ok(normalized), + WorkspaceRootValidation::Invalid { reason } => Err(reason), + } +} + +/// Create a binding. Path is normalized + validated server-side so the UI +/// can pass raw input (Windows paths, file:// URIs, trailing slashes). +#[tauri::command] +pub async fn create_workspace_binding( + input: WorkspaceBindingInput, + state: State<'_, AppState>, + gateway_state: State<'_, Arc>>, +) -> Result { + let space_id = parse_space_id(&input)?; + let feature_set_ids = validate_fs_list(&input)?; + let normalized = normalize_and_validate(&input.workspace_root)?; + + let mut binding = WorkspaceBinding::new_multi(normalized.clone(), space_id, feature_set_ids); + binding.label = normalize_label(&input.label); + + state + .workspace_binding_repository + .create(&binding) + .await + .map_err(|e| e.to_string())?; + + info!( + binding_id = %binding.id, + root = %binding.workspace_root, + %space_id, + feature_sets = ?binding.feature_set_ids, + "[workspace_binding] created", + ); + + emit_binding_changed( + gateway_state.inner(), + binding.space_id, + binding.workspace_root.clone(), + ) + .await; + Ok(binding.into()) +} + +/// Update an existing binding. Accepts full input so the UI can edit any +/// axis (root, target space, target FS) in one call. +#[tauri::command] +pub async fn update_workspace_binding( + id: String, + input: WorkspaceBindingInput, + state: State<'_, AppState>, + gateway_state: State<'_, Arc>>, +) -> Result { + let id_uuid = Uuid::parse_str(&id).map_err(|e| e.to_string())?; + let space_id = parse_space_id(&input)?; + let feature_set_ids = validate_fs_list(&input)?; + let normalized = normalize_and_validate(&input.workspace_root)?; + + let existing = state + .workspace_binding_repository + .get(&id_uuid) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("binding not found: {}", id))?; + let old_space_id = existing.space_id; + + let 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, + updated_at: chrono::Utc::now(), + }; + + state + .workspace_binding_repository + .update(&updated) + .await + .map_err(|e| e.to_string())?; + + // Notify the NEW target space first (peers that now route via this + // binding). If the space changed, also notify the OLD target so peers + // that resolved there lose the stale route. + emit_binding_changed( + gateway_state.inner(), + updated.space_id, + updated.workspace_root.clone(), + ) + .await; + if old_space_id != updated.space_id { + emit_binding_changed( + gateway_state.inner(), + old_space_id, + updated.workspace_root.clone(), + ) + .await; + } + Ok(updated.into()) +} + +/// Delete a binding by id. +#[tauri::command] +pub async fn delete_workspace_binding( + id: String, + state: State<'_, AppState>, + gateway_state: State<'_, Arc>>, +) -> Result<(), String> { + let id_uuid = Uuid::parse_str(&id).map_err(|e| e.to_string())?; + + // Capture the binding before delete so we know which space to notify. + let existing = state + .workspace_binding_repository + .get(&id_uuid) + .await + .map_err(|e| e.to_string())?; + + state + .workspace_binding_repository + .delete(&id_uuid) + .await + .map_err(|e| e.to_string())?; + + if let Some(b) = existing { + emit_binding_changed(gateway_state.inner(), b.space_id, b.workspace_root).await; + } + Ok(()) +} + +// ============================================================================ +// Workspace effective-features inspection +// +// Surfaces the same view the gateway resolver builds for live sessions, so +// the desktop UI can answer: "for this folder, what tools/prompts/resources +// would a connected client see right now — and which are configured-but- +// unavailable because their backend server is currently disconnected?" +// +// Pure read-only — no mutations, no event emission. +// ============================================================================ + +/// Per-feature view returned by `get_workspace_effective_features`. +/// +/// `available` is `true` exactly when the underlying server is currently +/// connected. A `false` value with `server_status = "disconnected"` +/// (or `auth_required` / `error`) is the user's "configured but +/// unavailable" case — the FS still includes this feature, but its +/// server isn't usable right now so the gateway hides it from clients. +#[derive(Debug, Clone, Serialize)] +pub struct EffectiveFeatureDto { + pub id: String, + pub feature_name: String, + pub display_name: Option, + pub description: Option, + pub server_id: String, + pub server_alias: Option, + /// snake_case mirror of `mcpmux_gateway::pool::ConnectionStatus`, plus + /// `unknown` when the gateway isn't running (so the UI can grey-out + /// without lying about the cause). + pub server_status: String, + pub available: bool, +} + +/// Per-server total counts in the resolved Space, regardless of the +/// FeatureSet filter. The UI shows badges like "3 / {total}" — the right +/// side is the total the server exposes in the Space, so the user can see +/// "this FS includes 3 of the 10 cloudflare-docs tools available." +#[derive(Debug, Clone, Serialize)] +pub struct ServerFeatureTotalsDto { + pub tools: usize, + pub prompts: usize, + pub resources: usize, +} + +/// One FeatureSet that the binding resolves through. The Workspaces UI +/// renders these as a chip strip ("FS-A + FS-B"); the resolver merges +/// their members into a single allow set. +#[derive(Debug, Clone, Serialize)] +pub struct EffectiveFeatureSetDto { + pub id: String, + pub name: String, + /// `default` | `custom` — matches `FeatureSetType`. + pub feature_set_type: String, +} + +/// Top-level DTO: the resolved (Space, FeatureSet…) for a given root, +/// plus the union of their tool/prompt/resource lists with availability. +#[derive(Debug, Clone, Serialize)] +pub struct WorkspaceEffectiveFeaturesDto { + /// Normalized form of the input root (lower-case drive letter, no + /// trailing slash, etc.). + pub workspace_root: String, + /// `binding` when a `WorkspaceBinding` matched the longest prefix of + /// the root; `unbound` when no binding matched. With the new resolver, + /// `unbound` means a live roots-capable session for this folder would + /// be **denied** — the `feature_sets` field below shows the default + /// Space's Default FS purely as a *preview* of what binding the folder + /// to that FS would expose, not as the active routing target. + pub source: String, + /// `Some(id)` only when `source == "binding"`. + pub binding_id: Option, + pub space_id: String, + pub space_name: String, + /// All FeatureSets contributing to the resolved view, in + /// operator-chosen order. Always ≥ 1 entry (resolved or preview). + pub feature_sets: Vec, + /// Configured features (union across all `feature_sets`) by type; + /// includes unavailable ones for the "configured but disconnected" + /// rendering case. + pub tools: Vec, + pub prompts: Vec, + pub resources: Vec, + /// `server_id -> totals` over every feature the server exposes in the + /// resolved Space (no FS filter applied). Used by the UI to render + /// "{mapped} / {server total}" badges. + pub server_totals: HashMap, +} + +/// Walk a FeatureSet's members (with nested-FS recursion) to compute the +/// allowed and excluded feature-id sets — same shape the gateway resolver +/// uses, but kept here so we can omit the `is_available` filter and surface +/// "configured but disconnected" features to the UI. +fn collect_member_ids( + fs: &FeatureSet, + fs_lookup: &HashMap, + allowed: &mut HashSet, + excluded: &mut HashSet, + visited: &mut HashSet, +) { + if !visited.insert(fs.id.clone()) { + return; // cycle guard + } + for m in &fs.members { + match m.member_type { + MemberType::Feature => match m.mode { + MemberMode::Include => { + allowed.insert(m.member_id.clone()); + } + MemberMode::Exclude => { + excluded.insert(m.member_id.clone()); + } + }, + MemberType::FeatureSet => { + if let Some(nested) = fs_lookup.get(&m.member_id) { + collect_member_ids(nested, fs_lookup, allowed, excluded, visited); + } + } + } + } +} + +fn server_status_str(status: mcpmux_gateway::ConnectionStatus) -> &'static str { + use mcpmux_gateway::ConnectionStatus as S; + match status { + S::Disconnected => "disconnected", + S::Connecting => "connecting", + S::Connected => "connected", + S::Refreshing => "refreshing", + S::AuthRequired => "auth_required", + S::Authenticating => "authenticating", + S::Error => "error", + } +} + +fn enrich_feature( + f: &ServerFeature, + server_statuses: &HashMap, + gateway_running: bool, +) -> EffectiveFeatureDto { + let status = server_statuses.get(&f.server_id).copied(); + let server_status = match status { + Some(s) => server_status_str(s).to_string(), + // No status entry usually means "gateway not running yet". Fall + // back to the cached `is_available` flag so the UI can still mark + // unavailable features without claiming a status it doesn't know. + None if !gateway_running => "unknown".to_string(), + None => "disconnected".to_string(), + }; + let available = matches!(status, Some(mcpmux_gateway::ConnectionStatus::Connected)) + || (!gateway_running && f.is_available); + + EffectiveFeatureDto { + id: f.id.to_string(), + feature_name: f.feature_name.clone(), + display_name: f.display_name.clone(), + description: f.description.clone(), + server_id: f.server_id.clone(), + server_alias: f.server_alias.clone(), + server_status, + available, + } +} + +/// Compute the resolved (Space, FeatureSet) for a workspace root and return +/// its full configured feature list with per-feature availability. +/// +/// The frontend calls this from the Workspaces tab inspector to answer the +/// "what tools does this folder actually see?" question. It's safe to call +/// even when the gateway isn't running — we degrade gracefully to +/// `server_status = "unknown"` and lean on the cached `is_available` flag. +#[tauri::command] +pub async fn get_workspace_effective_features( + workspace_root: String, + state: State<'_, AppState>, + sm_state: State<'_, Arc>>, +) -> Result { + // 1. Normalize the input the same way the resolver does. + let normalized = match validate_root(&workspace_root) { + WorkspaceRootValidation::Empty => return Err("workspace_root cannot be empty".into()), + WorkspaceRootValidation::Ok { normalized } => normalized, + WorkspaceRootValidation::Invalid { reason } => return Err(reason), + }; + + // 2. Default Space — the routing fallback. + let default_space = state + .space_service + .get_default() + .await + .map_err(|e| e.to_string())? + .ok_or("No default Space configured")?; + + // 3. Tier 1: longest-prefix workspace binding match. + let binding = state + .workspace_binding_repository + .find_longest_prefix_match(&default_space.id, std::slice::from_ref(&normalized)) + .await + .map_err(|e| e.to_string())?; + + let (source, binding_id, space_id, fs_ids) = match binding { + Some(b) => ( + "binding".to_string(), + Some(b.id.to_string()), + b.space_id, + b.feature_set_ids, + ), + None => { + // Source = `unbound` mirrors the new resolver: a live session + // here would be denied. We still surface the default Space's + // Default FS as a *preview* so the UI can render "if you bound + // this folder to , here's what it would see" — it's + // informational, not the active routing target. + let starter_fs = state + .feature_set_repository + .get_starter_for_space(&default_space.id.to_string()) + .await + .map_err(|e| e.to_string())? + .ok_or("Default Space has no Starter FeatureSet")?; + ( + "unbound".to_string(), + None, + default_space.id, + vec![starter_fs.id], + ) + } + }; + + let space = state + .space_service + .get(&space_id) + .await + .map_err(|e| e.to_string())? + .ok_or("Resolved Space no longer exists")?; + + // 4. Resolve every FeatureSet the binding points to (preserving order) + // so we can walk their members below for the union allow set. + let mut resolved_sets: Vec = Vec::with_capacity(fs_ids.len()); + for fs_id in &fs_ids { + let fs = state + .feature_set_repository + .get_with_members(fs_id) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Resolved FeatureSet {fs_id} not found"))?; + resolved_sets.push(fs); + } + + // 5. Pre-fetch every FS in the same Space so nested-FS members can be + // resolved without N round trips. Cheap — this is just a metadata + // table and Spaces typically hold a handful of sets. + let space_sets = state + .feature_set_repository + .list_by_space(&space_id.to_string()) + .await + .map_err(|e| e.to_string())?; + let mut fs_lookup: HashMap = HashMap::new(); + for sibling in space_sets { + if let Ok(Some(full)) = state + .feature_set_repository + .get_with_members(&sibling.id) + .await + { + fs_lookup.insert(full.id.clone(), full); + } + } + for fs in &resolved_sets { + fs_lookup.insert(fs.id.clone(), fs.clone()); + } + + // 6. Walk every FS in the binding → union allow set, union exclude set. + // Excludes win over includes within a single FS (collect_member_ids + // contract); when multiple FSes disagree we keep the include because + // the user's intent for adding the FS to the binding was to surface + // its members. Visiting state is shared across the loop so a nested + // FS shared between two parent FSes is walked once. + let mut allowed = HashSet::::new(); + let mut excluded = HashSet::::new(); + let mut visited = HashSet::::new(); + for fs in &resolved_sets { + collect_member_ids(fs, &fs_lookup, &mut allowed, &mut excluded, &mut visited); + } + // Cross-FS exclude → include resolution: if any FS lists the feature as + // an explicit include, override an exclude from a sibling FS. This is + // the operator-friendly default — adding an FS is additive. + excluded.retain(|id| !allowed.contains(id)); + + // 7. Pull every feature in the Space, compute per-server totals (the + // badge denominator), then keep only the FS-filtered subset for the + // rendered list. The `is_available` gate is intentionally not + // applied here — disconnected features still appear, dimmed. + let all_features = state + .server_feature_repository_core + .list_for_space(&space_id.to_string()) + .await + .map_err(|e| e.to_string())?; + + let mut server_totals: HashMap = HashMap::new(); + for f in &all_features { + let entry = server_totals + .entry(f.server_id.clone()) + .or_insert(ServerFeatureTotalsDto { + tools: 0, + prompts: 0, + resources: 0, + }); + match f.feature_type { + mcpmux_core::FeatureType::Tool => entry.tools += 1, + mcpmux_core::FeatureType::Prompt => entry.prompts += 1, + mcpmux_core::FeatureType::Resource => entry.resources += 1, + } + } + + let filtered: Vec = all_features + .into_iter() + .filter(|f| { + let fid = f.id.to_string(); + allowed.contains(&fid) && !excluded.contains(&fid) + }) + .collect(); + + // 8. Server statuses — only available when the gateway is running. + let (server_statuses, gateway_running): ( + HashMap, + bool, + ) = { + let sm = sm_state.read().await; + match sm.manager.as_ref() { + Some(mgr) => { + let map = mgr + .get_all_statuses(space_id) + .await + .into_iter() + .map(|(id, (status, _, _, _))| (id, status)) + .collect(); + (map, true) + } + None => (HashMap::new(), false), + } + }; + + // 9. Bucket by feature type. + let mut tools = Vec::new(); + let mut prompts = Vec::new(); + let mut resources = Vec::new(); + for f in &filtered { + let dto = enrich_feature(f, &server_statuses, gateway_running); + match f.feature_type { + mcpmux_core::FeatureType::Tool => tools.push(dto), + mcpmux_core::FeatureType::Prompt => prompts.push(dto), + mcpmux_core::FeatureType::Resource => resources.push(dto), + } + } + // Stable order: alphabetical by qualified-ish name so the UI doesn't + // jitter between calls. + let sort_key = |a: &EffectiveFeatureDto| { + format!( + "{}/{}", + a.server_alias + .clone() + .unwrap_or_else(|| a.server_id.clone()), + a.feature_name + ) + }; + tools.sort_by_key(sort_key); + prompts.sort_by_key(sort_key); + resources.sort_by_key(sort_key); + + let feature_sets: Vec = resolved_sets + .into_iter() + .map(|fs| EffectiveFeatureSetDto { + id: fs.id, + name: fs.name, + feature_set_type: match fs.feature_set_type { + FeatureSetType::Starter => "starter".to_string(), + FeatureSetType::Custom => "custom".to_string(), + }, + }) + .collect(); + + Ok(WorkspaceEffectiveFeaturesDto { + workspace_root: normalized, + source, + binding_id, + space_id: space_id.to_string(), + space_name: space.name, + feature_sets, + tools, + prompts, + resources, + 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 ad5b178b..a7e62ff0 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -14,9 +14,9 @@ mod state; mod tray; // Re-export deep link handler -use commands::oauth::handle_deep_link; +use commands::oauth::{route_or_buffer_deep_link, PendingInitialDeepLink}; -use commands::gateway::GatewayAppState; +use commands::gateway::{GatewayAppState, PendingPortConflict}; use commands::server_manager::ServerManagerState; use state::AppState; @@ -223,39 +223,35 @@ pub fn run() { info!("Logs directory: {}", logs_dir.display()); tauri::Builder::default() - .plugin(tauri_plugin_opener::init()) - .plugin(tauri_plugin_dialog::init()) - .plugin(tauri_plugin_deep_link::init()) - .plugin(tauri_plugin_autostart::init( - tauri_plugin_autostart::MacosLauncher::LaunchAgent, - Some(vec!["--hidden"]), // Start minimized to tray - )) - .plugin(tauri_plugin_updater::Builder::new().build()) - .plugin(tauri_plugin_process::init()) + // single_instance MUST be registered BEFORE deep_link so its `deep-link` + // feature can forward cold-start URLs (Windows argv[1]) through the + // deep_link plugin's on_open_url handler. Registering deep_link first + // orphans the initial URL — no on_open_url fires, no consent popup. .plugin(tauri_plugin_single_instance::init(|app, args, cwd| { - // This callback is called when a second instance is launched + // Fires when a SECOND instance is launched (e.g. browser deep link + // click while mcpmux is already running). The `deep-link` feature + // on this plugin hands argv off to the deep_link plugin's + // on_open_url on cold-start; this callback only needs to focus + // the window and handle any deep-link arg that single-instance + // did NOT forward (belt-and-suspenders for platforms or versions + // where the auto-forward doesn't trigger). info!("Second instance detected, focusing existing window"); info!("Args: {:?}, CWD: {:?}", args, cwd); - // Check if any arg is a deep link URL for arg in &args { if branding::is_deep_link(arg) { info!("Deep link received via second instance: {}", arg); - handle_deep_link(app, arg); + route_or_buffer_deep_link(app, arg); } } - // Try to focus the main window if let Some(window) = app.get_webview_window("main") { - // Show window if hidden if let Err(e) = window.show() { warn!("Failed to show window: {}", e); } - // Unminimize if minimized if let Err(e) = window.unminimize() { warn!("Failed to unminimize window: {}", e); } - // Focus the window if let Err(e) = window.set_focus() { warn!("Failed to focus window: {}", e); } @@ -263,6 +259,15 @@ pub fn run() { warn!("Main window not found"); } })) + .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_deep_link::init()) + .plugin(tauri_plugin_autostart::init( + tauri_plugin_autostart::MacosLauncher::LaunchAgent, + Some(vec!["--hidden"]), // Start minimized to tray + )) + .plugin(tauri_plugin_updater::Builder::new().build()) + .plugin(tauri_plugin_process::init()) .setup(|app| { info!("Initializing application state..."); @@ -281,6 +286,41 @@ pub fn run() { app.manage(state); + // Backfill the auto-seeded Default FeatureSet for any space that + // predates the seeding code path. Runs once per app boot; idempotent. + // + // Must run inside an async context (the repo uses tokio locks + // internally) — the setup closure runs before the user-facing + // tokio runtime starts, so we use Tauri's own runtime here. + { + let app_state_for_backfill: tauri::State<'_, AppState> = app.state(); + // We can't borrow `app_state_for_backfill` across the await + // inside block_on, so snapshot the two repo handles we need. + let fs_repo = app_state_for_backfill.feature_set_repository.clone(); + let db_for_backfill = app_state_for_backfill.database(); + tauri::async_runtime::block_on(async move { + use mcpmux_core::SpaceRepository; + let space_repo = mcpmux_storage::SqliteSpaceRepository::new(db_for_backfill); + let spaces = space_repo.list().await.unwrap_or_default(); + for s in &spaces { + if let Err(e) = + fs_repo.ensure_builtin_for_space(&s.id.to_string()).await + { + warn!( + space_id = %s.id, + space_name = %s.name, + error = %e, + "[Startup] failed to backfill Default FS", + ); + } + } + info!( + "[Startup] Default FS backfill complete across {} space(s)", + spaces.len() + ); + }); + } + // Create event bus and ServerAppService let app_state: tauri::State<'_, AppState> = app.state(); let event_bus = mcpmux_core::create_shared_event_bus(); @@ -288,7 +328,6 @@ pub fn run() { let server_app_service = mcpmux_core::ServerAppService::new( app_state.installed_server_repository.clone(), - Some(app_state.feature_set_repository.clone()), Some(app_state.server_feature_repository_core.clone()), Some(app_state.credential_repository.clone()), event_sender, @@ -326,15 +365,49 @@ pub fn run() { return; } - // Resolve port using the service (Single Responsibility) - let final_port = match port_service.resolve_and_allocate().await { - Ok(port) => port, - Err(e) => { - warn!("[Gateway] Failed to allocate port: {}", e); - return; - } + // Strict port probe — if the preferred port is busy, defer + // to the user instead of silently binding to a random port. + // IDE configs assume the configured port, so a silent + // fallback breaks every connected client. + let persisted = port_service.load_persisted_port().await; + let (preferred_port, source): (u16, &'static str) = match persisted { + Some(p) => (p, "configured"), + None => (mcpmux_core::DEFAULT_GATEWAY_PORT, "default"), }; + if !mcpmux_core::service::is_port_available(preferred_port) { + warn!( + "[Gateway] Auto-start preferred port {} ({}) unavailable — deferring to user", + preferred_port, source + ); + { + let mut state = gw_state_clone.write().await; + state.pending_port_conflict = Some(PendingPortConflict { + preferred_port, + source, + }); + } + // Emit in case the UI is already listening; the UI also + // checks via `get_pending_port_conflict` on mount. + let _ = app_handle_for_sm.emit( + "gateway-autostart-port-conflict", + serde_json::json!({ + "preferredPort": preferred_port, + "source": source, + }), + ); + return; + } + + // Persist default port on first run so the Settings UI + // reflects the active choice. + if persisted.is_none() { + if let Err(e) = port_service.save_port(preferred_port).await { + warn!("[Gateway] Failed to persist default port: {}", e); + } + } + + let final_port = preferred_port; let url = format!("http://localhost:{}", final_port); info!("Auto-starting gateway on {}", url); @@ -399,6 +472,17 @@ pub fn run() { let server_manager_arc = server.server_manager(); let event_emitter = server.event_emitter(); let grant_service = server.grant_service(); + let session_roots = server.session_roots(); + let approval_broker = server.approval_broker(); + + // Wire the approval broker to the desktop event bus so + // write meta tools can prompt the React dialog. Without + // this, every write surfaces as "no desktop attached". + crate::commands::gateway::attach_approval_publisher( + &approval_broker, + app_handle_for_sm.clone(), + ) + .await; // Start domain event bridge crate::commands::gateway::start_domain_event_bridge(&app_handle_for_sm, gw_inner_state.clone()); @@ -406,88 +490,24 @@ pub fn run() { // Subscribe to OAuth completion events let oauth_completion_rx = pool_service.oauth_manager().subscribe(); - info!("[Gateway] Services initialized via DI"); - - // Store ServerManager and PoolService in state - { - let mut sm_state = sm_state_clone.write().await; - sm_state.manager = Some(server_manager_arc.clone()); - sm_state.pool_service = Some(pool_service.clone()); - } - info!("[Gateway] ServerManager initialized with event bridge"); - - // Start OAuth completion handler - reconnects servers after OAuth completes - // IMPORTANT: Each reconnection is spawned as a separate task to allow parallel connections - let sm_for_oauth = server_manager_arc.clone(); - let pool_for_oauth = pool_service.clone(); - tokio::spawn(async move { - use mcpmux_gateway::{ServerKey, ConnectionResult}; - let mut rx = oauth_completion_rx; - - info!("[OAuth Handler] Started listening for OAuth completions"); - - loop { - match rx.recv().await { - Ok(event) => { - info!( - "[OAuth Handler] Received completion for {}: success={}", - event.server_id, event.success - ); - - if event.success { - // OAuth succeeded - spawn reconnection in separate task for parallelism - let sm = sm_for_oauth.clone(); - let pool = pool_for_oauth.clone(); - let server_id = event.server_id.clone(); - let space_id = event.space_id; - - tokio::spawn(async move { - let key = ServerKey::new(space_id, &server_id); - - info!("[OAuth Handler] Attempting reconnection for {}", server_id); - sm.set_connecting(&key).await; - - match pool.reconnect_instance(space_id, &server_id).await { - ConnectionResult::Connected { features, .. } => { - info!("[OAuth Handler] Reconnection successful for {}", server_id); - sm.set_connected(&key, features).await; - } - ConnectionResult::OAuthRequired { .. } => { - warn!("[OAuth Handler] Still requires OAuth after completion: {}", server_id); - sm.set_auth_required(&key, Some("OAuth still required".to_string())).await; - } - ConnectionResult::Failed { error } => { - error!("[OAuth Handler] Reconnection failed for {}: {}", server_id, error); - sm.set_error(&key, error).await; - } - } - }); - } else { - // OAuth failed - handle synchronously (fast operation) - let key = ServerKey::new(event.space_id, &event.server_id); - let error_msg = event.error.unwrap_or_else(|| "OAuth failed".to_string()); - warn!("[OAuth Handler] OAuth failed for {}: {}", event.server_id, error_msg); - sm_for_oauth.set_auth_required(&key, Some(error_msg)).await; - } - } - Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { - warn!("[OAuth Handler] Lagged {} messages", n); - } - Err(tokio::sync::broadcast::error::RecvError::Closed) => { - info!("[OAuth Handler] Channel closed, stopping"); - break; - } - } - } - }); - info!("[Gateway] OAuth completion handler started"); + info!( + "[Gateway] Auto-start services resolved — port={}, server_manager={:p}", + final_port, &*server_manager_arc + ); - // Start periodic refresh loop (every 60s for connected servers) - let _refresh_handle = server_manager_arc.clone().start_periodic_refresh(); - info!("[Gateway] Periodic refresh service started"); + // Wire ServerManager into state + spawn OAuth handler + + // periodic refresh. Shared with start_gateway command so + // both paths leave the app in an identical post-start + // configuration. + crate::commands::gateway::init_gateway_runtime( + pool_service.clone(), + server_manager_arc.clone(), + oauth_completion_rx, + sm_state_clone.clone(), + ) + .await; // Note: Auto-connect happens in the frontend via useEffect calling connect_all_enabled_servers - // This keeps the backend service clean and follows React best practices let handle = server.spawn(); @@ -500,12 +520,28 @@ pub fn run() { state.feature_service = Some(feature_service); state.event_emitter = Some(event_emitter); state.grant_service = Some(grant_service); + state.approval_broker = Some(approval_broker); + state.session_roots = Some(session_roots); info!( "Gateway auto-started successfully on {} - GrantService initialized: {}", url, state.grant_service.is_some() ); + + // Broadcast the started event to the webview. Must happen + // even on auto-start so the status-bar footer and every + // other subscriber reflect the running gateway. + if let Err(e) = app_handle_for_sm.emit( + "gateway-changed", + serde_json::json!({ + "action": "started", + "url": url, + "port": final_port, + }), + ) { + warn!("[Gateway] Failed to emit gateway-changed(started): {}", e); + } }); app.manage(gateway_state); @@ -714,15 +750,94 @@ pub fn run() { use tauri_plugin_deep_link::DeepLinkExt; let app_handle = app.handle().clone(); - // Register the deep link handler + // Buffer state for cold-start URLs that arrive before the + // frontend listener is registered (the common Windows case: + // browser → mcpmux:// → new mcpmux.exe with URL in argv[1]). + app.manage(PendingInitialDeepLink::default()); + + // Route URLs through the buffer-aware helper so cold-start + // URLs are held until the webview signals ready via + // `flush_pending_deep_link`. app.deep_link().on_open_url(move |event| { for url in event.urls() { info!("[DeepLink] Received URL: {}", url); - handle_deep_link(&app_handle, url.as_str()); + route_or_buffer_deep_link(&app_handle, url.as_str()); } }); } + // Terminal-close / Ctrl+C graceful shutdown. + // + // Without this, when the user hits Ctrl+C on `pnpm run dev` or + // closes the terminal window, the process dies before axum + // can drain and release the TCP socket. On a fast restart the + // kernel may still have the listener bound, so the next run + // fails with "port in use". + // + // We translate every termination signal into `app_handle.exit(0)` + // which fires `RunEvent::ExitRequested` — the existing handler + // below then runs the gateway's graceful shutdown. + // + // Windows console control events (CTRL_CLOSE_EVENT, + // CTRL_LOGOFF_EVENT, CTRL_SHUTDOWN_EVENT) give the process + // ~5 seconds before force-kill, which is plenty for the + // ~2.5s graceful drain downstream. + { + let app_handle = app.handle().clone(); + tauri::async_runtime::spawn(async move { + #[cfg(unix)] + { + use tokio::signal::unix::{signal, SignalKind}; + let mut sigterm = match signal(SignalKind::terminate()) { + Ok(s) => s, + Err(e) => { + warn!("[Signal] Failed to install SIGTERM handler: {}", e); + return; + } + }; + let mut sigint = match signal(SignalKind::interrupt()) { + Ok(s) => s, + Err(e) => { + warn!("[Signal] Failed to install SIGINT handler: {}", e); + return; + } + }; + tokio::select! { + _ = sigterm.recv() => info!("[Signal] SIGTERM — requesting exit"), + _ = sigint.recv() => info!("[Signal] SIGINT — requesting exit"), + } + } + #[cfg(windows)] + { + use tokio::signal::windows::{ + ctrl_break, ctrl_c, ctrl_close, ctrl_logoff, ctrl_shutdown, + }; + let (mut c_c, mut c_break, mut c_close, mut c_logoff, mut c_shutdown) = + match ( + ctrl_c(), + ctrl_break(), + ctrl_close(), + ctrl_logoff(), + ctrl_shutdown(), + ) { + (Ok(a), Ok(b), Ok(c), Ok(d), Ok(e)) => (a, b, c, d, e), + _ => { + warn!("[Signal] Failed to install console handlers"); + return; + } + }; + tokio::select! { + _ = c_c.recv() => info!("[Signal] Ctrl+C — requesting exit"), + _ = c_break.recv() => info!("[Signal] Ctrl+Break — requesting exit"), + _ = c_close.recv() => info!("[Signal] Console close — requesting exit"), + _ = c_logoff.recv() => info!("[Signal] Logoff — requesting exit"), + _ = c_shutdown.recv() => info!("[Signal] Shutdown — requesting exit"), + } + } + app_handle.exit(0); + }); + } + info!("Application started successfully"); Ok(()) }) @@ -733,9 +848,8 @@ pub fn run() { commands::list_spaces, commands::get_space, commands::create_space, + commands::update_space, commands::delete_space, - commands::get_active_space, - commands::set_active_space, commands::open_space_config_file, commands::read_space_config, commands::save_space_config, @@ -756,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, @@ -764,8 +883,6 @@ pub fn run() { commands::create_feature_set, commands::update_feature_set, commands::delete_feature_set, - commands::get_builtin_feature_sets, - commands::ensure_server_all_feature_set, commands::add_feature_set_member, commands::remove_feature_set_member, commands::set_feature_set_members, @@ -774,7 +891,6 @@ pub fn run() { commands::remove_feature_from_set, commands::get_feature_set_members, // Client custom feature sets - commands::find_or_create_client_custom_feature_set, // Server feature commands commands::list_server_features, commands::list_server_features_by_server, @@ -786,13 +902,26 @@ pub fn run() { commands::get_client, commands::create_client, commands::delete_client, - commands::update_client_grants, - commands::update_client_mode, commands::init_preset_clients, - commands::get_client_grants, - commands::get_all_client_grants, - commands::grant_feature_set_to_client, - commands::revoke_feature_set_from_client, + // Workspace binding commands (resolver v2) + commands::list_workspace_bindings, + commands::list_workspace_bindings_for_space, + commands::list_reported_workspace_roots, + commands::create_workspace_binding, + commands::update_workspace_binding, + commands::delete_workspace_binding, + commands::validate_workspace_root, + commands::get_workspace_effective_features, + // Meta-tool approval (self-management mcpmux_* tools) + commands::respond_to_meta_tool_approval, + commands::list_meta_tool_grants, + 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, @@ -804,6 +933,11 @@ pub fn run() { commands::add_to_cursor, // Gateway commands commands::get_gateway_status, + commands::get_gateway_port_settings, + commands::set_gateway_port, + commands::reset_gateway_port, + commands::probe_gateway_start, + commands::take_pending_port_conflict, commands::start_gateway, commands::stop_gateway, commands::restart_gateway, @@ -817,15 +951,16 @@ pub fn run() { // OAuth commands commands::approve_oauth_consent, commands::get_pending_consent, + commands::flush_pending_deep_link, commands::get_oauth_clients, commands::approve_oauth_client, commands::update_oauth_client, commands::delete_oauth_client, + commands::open_url, + // Per-client grants for the rootless fallback path commands::get_oauth_client_grants, commands::grant_oauth_client_feature_set, commands::revoke_oauth_client_feature_set, - commands::get_oauth_client_resolved_features, - commands::open_url, // Server Manager commands (event-driven v2) commands::get_server_statuses, commands::enable_server_v2, @@ -848,6 +983,36 @@ pub fn run() { commands::get_startup_settings, commands::update_startup_settings, ]) - .run(tauri::generate_context!()) - .expect("error while running McpMux application"); + .build(tauri::generate_context!()) + .expect("error while building McpMux application") + .run(|app_handle, event| { + if let tauri::RunEvent::ExitRequested { .. } = event { + // Graceful gateway shutdown on app exit. Without this, the + // axum listener gets dropped without a close signal, and + // Windows can leave the TCP socket bound in the kernel — + // which is what orphan PID 21408 on :45818 was. + // + // We block for up to ~2.5s to let the listener close. Any + // longer and Windows would kill us with a "process not + // responding" dialog. Any shorter and we race with axum's + // drain. + if let Some(gw_state) = + app_handle.try_state::>>() + { + let gw_state = gw_state.inner().clone(); + tauri::async_runtime::block_on(async move { + let handle = { + let mut state = gw_state.write().await; + state.running = false; + state.url = None; + state.handle.take() + }; + if let Some(h) = handle { + info!("[Gateway] ExitRequested — gracefully shutting down gateway"); + crate::commands::gateway::shutdown_gateway_handle(h).await; + } + }); + } + } + }); } diff --git a/apps/desktop/src-tauri/src/state/mod.rs b/apps/desktop/src-tauri/src/state/mod.rs index f4dae3aa..8de935fb 100644 --- a/apps/desktop/src-tauri/src/state/mod.rs +++ b/apps/desktop/src-tauri/src/state/mod.rs @@ -4,16 +4,17 @@ //! between Tauri commands. use mcpmux_core::{ - AppSettingsRepository, AppSettingsService, ClientService, CredentialRepository, - FeatureSetRepository, GatewayPortService, InboundMcpClientRepository, - InstalledServerRepository, LogConfig, OutboundOAuthRepository, ServerDiscoveryService, + AppSettingsRepository, AppSettingsService, CredentialRepository, FeatureSetRepository, + GatewayPortService, InboundMcpClientRepository, InstalledServerRepository, LogConfig, + OutboundOAuthRepository, ServerDiscoveryService, ServerFeatureRepository as CoreServerFeatureRepository, ServerLogManager, SpaceRepository, - SpaceService, + SpaceService, WorkspaceBindingRepository, }; use mcpmux_storage::{ Database, FieldEncryptor, SqliteAppSettingsRepository, SqliteCredentialRepository, SqliteFeatureSetRepository, SqliteInboundMcpClientRepository, SqliteInstalledServerRepository, SqliteOutboundOAuthRepository, SqliteServerFeatureRepository, SqliteSpaceRepository, + SqliteWorkspaceBindingRepository, }; use std::path::PathBuf; use std::sync::Arc; @@ -32,8 +33,6 @@ pub struct AppState { pub gateway_port_service: Arc, /// Service for managing spaces pub space_service: SpaceService, - /// Service for managing clients (auto-grants, etc.) - pub client_service: ClientService, /// Server discovery service for loading servers from API/bundled/user spaces pub server_discovery: Arc, /// Server log manager for file-based logging @@ -48,6 +47,8 @@ pub struct AppState { pub feature_set_repository: Arc, /// Client repository for AI clients pub client_repository: Arc, + /// Workspace-root -> FeatureSet bindings (resolver v2) + pub workspace_binding_repository: Arc, /// Server feature repository for discovered MCP features (implements core trait) pub server_feature_repository: Arc, /// Server feature repository cast to core trait (for gateway services) @@ -103,6 +104,9 @@ impl AppState { let client_repository: Arc = Arc::new(SqliteInboundMcpClientRepository::new(db.clone())); + let workspace_binding_repository: Arc = + Arc::new(SqliteWorkspaceBindingRepository::new(db.clone())); + let server_feature_repository = Arc::new(SqliteServerFeatureRepository::new(db.clone())); let server_feature_repository_core: Arc = server_feature_repository.clone(); @@ -118,8 +122,6 @@ impl AppState { space_repository, feature_set_repository.clone(), ); - let client_service = - ClientService::new(client_repository.clone(), feature_set_repository.clone()); // Create server discovery service // Spaces directory is relative to app data_dir (single source of truth) @@ -154,7 +156,6 @@ impl AppState { settings_repository, gateway_port_service, space_service, - client_service, server_discovery, server_log_manager, installed_server_repository, @@ -162,6 +163,7 @@ impl AppState { backend_oauth_repository, feature_set_repository, client_repository, + workspace_binding_repository, server_feature_repository, server_feature_repository_core, encryptor, diff --git a/apps/desktop/src-tauri/src/tray.rs b/apps/desktop/src-tauri/src/tray.rs index 6c330568..a4da532f 100644 --- a/apps/desktop/src-tauri/src/tray.rs +++ b/apps/desktop/src-tauri/src/tray.rs @@ -76,12 +76,12 @@ pub fn setup_tray(app: &AppHandle) -> tauri::Result<()> { /// Build the tray menu fn build_tray_menu(app: &AppHandle) -> tauri::Result> { - // Space submenu (will be populated dynamically) - let space_submenu = SubmenuBuilder::new(app, "Active Space") + // Space submenu — pure navigation. Clicking a space opens the main + // window and asks the frontend to switch to that space's view. + let space_submenu = SubmenuBuilder::new(app, "Switch Space") .text("space_default", "🌐 Default") .build()?; - // Build simplified main menu let menu = MenuBuilder::new(app) .item(&space_submenu) .separator() @@ -137,28 +137,27 @@ pub async fn update_tray_spaces( state: &AppState, ) -> tauri::Result<()> { let spaces = state.space_service.list().await.unwrap_or_default(); - let active_space = state.space_service.get_active().await.ok().flatten(); + let default_space = state.space_service.get_default().await.ok().flatten(); - // Get tray handle if let Some(tray) = app.tray_by_id("mcpmux-tray") { - // Rebuild space submenu - let mut space_menu = SubmenuBuilder::new(app, "Active Space"); + let mut space_menu = SubmenuBuilder::new(app, "Switch Space"); for space in spaces { let icon = space.icon.clone().unwrap_or_else(|| "🌐".to_string()); - let is_active = active_space + // Tag the system default Space so the user can tell which one + // catches sessions whose reported root has no binding. + let is_default = default_space .as_ref() - .map(|a| a.id == space.id) + .map(|d| d.id == space.id) .unwrap_or(false); - let check = if is_active { "✓ " } else { " " }; - let label = format!("{}{} {}", check, icon, space.name); + let suffix = if is_default { " · default" } else { "" }; + let label = format!("{} {}{}", icon, space.name, suffix); let id = format!("space_{}", space.id); space_menu = space_menu.text(id, label); } let space_submenu = space_menu.build()?; - // Rebuild simplified menu let menu = MenuBuilder::new(app) .item(&space_submenu) .separator() diff --git a/apps/desktop/src-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 9521773f..50351835 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -9,9 +9,7 @@ import { Settings, Sun, Moon, - Loader2, FolderOpen, - FileText, Download, X, } from 'lucide-react'; @@ -23,25 +21,27 @@ import { Card, CardHeader, CardTitle, - CardDescription, CardContent, - Button, } from '@mcpmux/ui'; import { ThemeProvider } from '@/components/ThemeProvider'; import { OAuthConsentModal } from '@/components/OAuthConsentModal'; import { ServerInstallModal } from '@/components/ServerInstallModal'; import { SpaceSwitcher } from '@/components/SpaceSwitcher'; -import { ConnectIDEs } from '@/components/ConnectIDEs'; +import { ConnectionCard } from '@/components/ConnectionCard'; import { useDataSync } from '@/hooks/useDataSync'; import { useAnalytics } from '@/hooks/useAnalytics'; import { initAnalytics, capture, optIn, optOut } from '@/lib/analytics'; -import { useAppStore, useActiveSpace, useViewSpace, useTheme, useAnalyticsEnabled, useActiveNav, useNavigateTo } from '@/stores'; +import { useAppStore, useViewSpace, useTheme, useAnalyticsEnabled, useActiveNav, useNavigateTo } from '@/stores'; import { RegistryPage } from '@/features/registry'; import { FeatureSetsPage } from '@/features/featuresets'; import { ClientsPage } from '@/features/clients'; import { ServersPage } from '@/features/servers'; import { SpacesPage } from '@/features/spaces'; +import { WorkspacesPage } from '@/features/workspaces'; import { SettingsPage } from '@/features/settings'; +import { AutoStartConflictResolver } from '@/features/gateway/AutoStartConflictResolver'; +import { WorkspaceBindingSheet } from '@/features/workspaces'; +import { MetaToolApprovalDialog } from '@/features/metaTools'; import { useGatewayEvents, useServerStatusEvents } from '@/hooks/useDomainEvents'; /** McpMux title-bar icon — miniature cat icon */ @@ -110,7 +110,6 @@ function AppContent() { // Get state from store const theme = useTheme(); const setTheme = useAppStore((state) => state.setTheme); - const activeSpace = useActiveSpace(); const viewSpace = useViewSpace(); const analyticsEnabled = useAnalyticsEnabled(); @@ -181,17 +180,21 @@ function AppContent() { setTheme(theme === 'dark' ? 'light' : 'dark'); }; + const gatewayRunning = gatewayUrl !== null; + const gatewayPort = (() => { + if (!gatewayUrl) return null; + try { + return new URL(gatewayUrl).port || null; + } catch { + return null; + } + })(); + const sidebar = ( } - footer={ -
-
McpMux{appVersion ? ` v${appVersion}` : ''}
-
Gateway: {gatewayUrl ?? 'Not running'}
-
- } > + } + label="Workspaces" + active={activeNav === 'workspaces'} + onClick={() => navigateTo('workspaces')} + data-testid="nav-workspaces" + /> } label="Clients" @@ -259,29 +269,49 @@ function AppContent() { const statusBar = (
- - - Gateway Active - - Active Space: {activeSpace?.name || 'None'} -
-
- 5 Servers • 97 Tools + + Space: {viewSpace?.name || 'None'}
+ {appVersion && ( + + v{appVersion} + + )}
); const titleBar = ( -
- - - Mcp - Mux - -
+
+
+ + + Mcp + Mux + +
+
@@ -349,15 +380,23 @@ function App() { return ( + {/* Resolves deferred auto-start port conflicts — runs once on mount */} + {/* OAuth consent modal - shown when MCP clients request authorization */} + {/* Workspace binding sheet - slides in when a session reports a root + that has no binding yet and resolved via the Space default */} + {/* Server install modal - shown when install deep link is received */} + {/* Meta-tool approval dialog — gates every mcpmux_* write tool */} + ); } function DashboardView() { + const navigateTo = useNavigateTo(); const [stats, setStats] = useState({ installedServers: 0, connectedServers: 0, @@ -365,12 +404,11 @@ function DashboardView() { clients: 0, featureSets: 0, }); - const [gatewayStatus, setGatewayStatus] = useState<{ - running: boolean; - url: string | null; - }>({ running: false, url: null }); const viewSpace = useViewSpace(); + const statCardClass = + 'cursor-pointer transition-all hover:shadow-lg hover:scale-[1.01] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/50'; + // Load stats on mount and when gateway changes const loadStats = async () => { try { @@ -382,7 +420,6 @@ function DashboardView() { import('@/lib/api/gateway').then((m) => m.getGatewayStatus(viewSpace?.id)), import('@/lib/api/registry').then((m) => m.listInstalledServers(viewSpace?.id)), ]); - console.log('[Dashboard] Gateway status received:', gateway); setStats({ installedServers: installedServers.length, connectedServers: gateway.connected_backends, @@ -390,7 +427,6 @@ function DashboardView() { clients: clients.length, featureSets: featureSets.length, }); - setGatewayStatus({ running: gateway.running, url: gateway.url }); } catch (e) { console.error('Failed to load dashboard stats:', e); } @@ -401,15 +437,13 @@ function DashboardView() { loadStats(); }, [viewSpace?.id]); - // Subscribe to gateway events for reactive updates (no polling!) + // Reload stats when gateway starts/stops so `Servers: X/Y` stays honest. + // ConnectionCard owns the actual running/URL UI. useGatewayEvents((payload) => { if (payload.action === 'started') { - setGatewayStatus({ running: true, url: payload.url || null }); - // Reload stats to get updated counts loadStats(); } else if (payload.action === 'stopped') { - setGatewayStatus({ running: false, url: null }); - setStats({ installedServers: 0, connectedServers: 0, tools: 0, clients: 0, featureSets: 0 }); + setStats((prev) => ({ ...prev, connectedServers: 0 })); } }); @@ -420,24 +454,6 @@ function DashboardView() { } }); - const handleToggleGateway = async () => { - try { - if (gatewayStatus.running) { - const { stopGateway } = await import('@/lib/api/gateway'); - await stopGateway(); - setGatewayStatus({ running: false, url: null }); - } else { - const { startGateway } = await import('@/lib/api/gateway'); - const url = await startGateway(); - setGatewayStatus({ running: true, url }); - // After starting gateway, reload stats to get updated connected count - setTimeout(loadStats, 500); - } - } catch (e) { - console.error('Gateway toggle failed:', e); - } - }; - return (
@@ -447,41 +463,27 @@ function DashboardView() {

- {/* Gateway Status Banner */} - - -
- -
- - Gateway: {gatewayStatus.running ? 'Running' : 'Stopped'} - - {gatewayStatus.url && ( - - {gatewayStatus.url} - - )} -
-
- -
-
+ {/* Canonical connection surface — owns URL, Start/Stop, IDE grid, + pending-approval nudge. Replaces the old status banner + the + separate ConnectIDEs card that duplicated the URL. */} + {/* Stats Grid */}
- + navigateTo('servers')} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + navigateTo('servers'); + } + }} + > @@ -494,7 +496,20 @@ function DashboardView() { - + navigateTo('featuresets')} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + navigateTo('featuresets'); + } + }} + > @@ -507,7 +522,20 @@ function DashboardView() { - + navigateTo('clients')} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + navigateTo('clients'); + } + }} + > @@ -520,27 +548,34 @@ function DashboardView() { - + navigateTo('spaces')} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + navigateTo('spaces'); + } + }} + > - Active Space + Workspace
{viewSpace?.icon} {viewSpace?.name || 'None'}
-
Current context
+
Currently viewing
- - {/* Connect IDEs — one-click install */} -
); } diff --git a/apps/desktop/src/components/ConfigEditorModal.tsx b/apps/desktop/src/components/ConfigEditorModal.tsx index e508b0ae..4c8ffca6 100644 --- a/apps/desktop/src/components/ConfigEditorModal.tsx +++ b/apps/desktop/src/components/ConfigEditorModal.tsx @@ -6,6 +6,7 @@ import Editor, { type Monaco } from '@monaco-editor/react'; import type { editor } from 'monaco-editor'; import { useToast, ToastContainer } from '@mcpmux/ui'; import USER_SPACE_CONFIG_SCHEMA from '../../../../schemas/user-space.schema.json'; +import { RequestServerCTA } from './Contribute'; interface ConfigEditorModalProps { spaceId: string; @@ -221,6 +222,12 @@ export function ConfigEditorModal({ spaceId, spaceName, onClose, onSaved }: Conf
+ {/* Contribute / Request CTA — surfaces the registry templates so users + don't have to hand-roll a definition if one already exists upstream. */} +
+ +
+ {/* Editor Area */}
{(isLoading || !editorReady) ? ( diff --git a/apps/desktop/src/components/ConnectIDEs.tsx b/apps/desktop/src/components/ConnectIDEs.tsx index 28b83685..b951f0e7 100644 --- a/apps/desktop/src/components/ConnectIDEs.tsx +++ b/apps/desktop/src/components/ConnectIDEs.tsx @@ -18,14 +18,26 @@ interface GridEntry { icon?: string; action: GridAction; handler: (() => Promise) | string; + /** + * Per-IDE, what does the user actually have to do after the button fires? + * Each IDE's "make MCP server live" flow is different — VS Code auto-starts + * while Cursor needs the server toggled on, for example. Keep this wording + * specific; a generic "restart" message has already misled testers. + */ + nextStep: string; } -interface ConnectIDEsProps { +interface ConnectIDEsGridProps { gatewayUrl: string; gatewayRunning: boolean; } -export function ConnectIDEs({ gatewayUrl, gatewayRunning }: ConnectIDEsProps) { +/** + * Chromeless grid of IDE connect shortcuts. Used directly by the dashboard + * ConnectionCard (which owns the surrounding chrome) and wrapped by + * `ConnectIDEs` below for the Clients page standalone usage. + */ +export function ConnectIDEsGrid({ gatewayUrl, gatewayRunning }: ConnectIDEsGridProps) { const [activeId, setActiveId] = useState(null); const [copiedId, setCopiedId] = useState(null); const popoverRef = useRef(null); @@ -40,6 +52,11 @@ export function ConnectIDEs({ gatewayUrl, gatewayRunning }: ConnectIDEsProps) { icon: vscodeIcon, action: 'deep_link', handler: () => addToVscode(gatewayUrl), + nextStep: + 'Opens VS Code and drops mcpmux into mcp.json. VS Code starts the server ' + + 'automatically — if it doesn’t, open the Command Palette and run ' + + '"MCP: Show Installed Servers", then click Start on mcpmux. The approval ' + + 'prompt lands on this page.', }, { id: 'cursor', @@ -48,6 +65,10 @@ export function ConnectIDEs({ gatewayUrl, gatewayRunning }: ConnectIDEsProps) { icon: cursorIcon, action: 'deep_link', handler: () => addToCursor(gatewayUrl), + nextStep: + 'Opens Cursor and adds mcpmux to its config. Cursor does not auto-start ' + + 'new MCP servers — go to Settings → Features → MCP (or the MCP ' + + 'Tools panel) and toggle mcpmux on. The approval prompt lands on this page.', }, { id: 'windsurf', @@ -56,6 +77,10 @@ export function ConnectIDEs({ gatewayUrl, gatewayRunning }: ConnectIDEsProps) { icon: windsurfIcon, action: 'copy_config', handler: `"mcpmux": {\n "serverUrl": "${mcpUrl}"\n}`, + nextStep: + 'Copies a JSON snippet. In Windsurf, open Cascade → MCP settings, ' + + 'paste mcpmux under mcpServers, and hit "Refresh" (or reload Windsurf). ' + + 'Approve on this page when Windsurf reaches the gateway.', }, { id: 'claude-code', @@ -64,6 +89,10 @@ export function ConnectIDEs({ gatewayUrl, gatewayRunning }: ConnectIDEsProps) { icon: claudeIcon, action: 'copy_command', handler: `claude mcp add --transport http --scope user mcpmux ${mcpUrl}`, + nextStep: + 'Copies a `claude mcp add` command. Run it in your shell — Claude Code ' + + 'loads mcpmux on the next `claude` invocation (existing sessions need ' + + '/restart). Approve on this page when it connects.', }, { id: 'jetbrains', @@ -72,6 +101,10 @@ export function ConnectIDEs({ gatewayUrl, gatewayRunning }: ConnectIDEsProps) { icon: jetbrainsIcon, action: 'copy_config', handler: `"mcpmux": {\n "url": "${mcpUrl}"\n}`, + nextStep: + 'Copies a JSON snippet. Paste into the AI Assistant MCP config, then ' + + 'restart the IDE — JetBrains only reads MCP config on startup. Approve ' + + 'on this page.', }, { id: 'android-studio', @@ -80,6 +113,9 @@ export function ConnectIDEs({ gatewayUrl, gatewayRunning }: ConnectIDEsProps) { icon: androidStudioIcon, action: 'copy_config', handler: `"mcpmux": {\n "httpUrl": "${mcpUrl}"\n}`, + nextStep: + 'Copies a JSON snippet. Paste into Android Studio’s AI Assistant MCP ' + + 'config, then restart the IDE. Approve on this page.', }, { id: 'copy-config', @@ -87,6 +123,9 @@ export function ConnectIDEs({ gatewayUrl, gatewayRunning }: ConnectIDEsProps) { label: 'JSON', action: 'copy_config', handler: `"mcpmux": {\n "type": "http",\n "url": "${mcpUrl}"\n}`, + nextStep: + 'Copies a generic MCP JSON snippet. Paste into any MCP-compatible client ' + + 'and follow its reload instructions. Approve on this page when it connects.', }, ]; @@ -120,6 +159,115 @@ export function ConnectIDEs({ gatewayUrl, gatewayRunning }: ConnectIDEsProps) { } }; + return ( +
+ {entries.map((entry) => { + const isActive = activeId === entry.id; + const isCopied = copiedId === entry.id; + + return ( +
+ + + {entry.label} + + + {/* Popover — opens UPWARD. The grid usually sits at the + bottom of a Card (Dashboard + Clients empty state), so + opening downward put the action button below the scroll + viewport on first paint, forcing users to scroll to find + it. Anchor to the bottom of the trigger button instead. */} + {isActive && ( +
+

{entry.name}

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

+ {entry.nextStep} +

+ + {entry.action === 'deep_link' ? ( + + ) : isCopied ? ( +
+ + Copied — paste & follow above +
+ ) : ( + + )} + + {/* Arrow — points down from the popover to the trigger + icon below. */} +
+
+ )} +
+ ); + })} +
+ ); +} + +interface ConnectIDEsProps { + gatewayUrl: string; + gatewayRunning: boolean; +} + +/** + * Standalone Card-wrapped IDE grid. Used by the Clients page where it lives + * on its own. The dashboard uses the chromeless `ConnectIDEsGrid` inside the + * canonical ConnectionCard instead. + */ +export function ConnectIDEs({ gatewayUrl, gatewayRunning }: ConnectIDEsProps) { return ( @@ -127,7 +275,9 @@ export function ConnectIDEs({ gatewayUrl, gatewayRunning }: ConnectIDEsProps) {
Connect Your IDEs - Add McpMux to your AI clients. Auth happens on first connect. + VS Code & Cursor are one-click; the rest copy + a config you paste into their MCP settings. Either path ends with an approval + prompt in this app.
@@ -139,84 +289,7 @@ export function ConnectIDEs({ gatewayUrl, gatewayRunning }: ConnectIDEsProps) {
-
- {entries.map((entry) => { - const isActive = activeId === entry.id; - const isCopied = copiedId === entry.id; - - return ( -
- - - {entry.label} - - - {/* Popover */} - {isActive && ( -
- {/* Arrow */} -
- -

- {entry.name} -

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

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

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

+ Connect a client +

+

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

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

{getErrorMessage(modalState.error)}

+

+ {getErrorMessage(modalState.error)} +

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

Next step: Grant permissions

-

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

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

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

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

+ Allow {details.clientName} to connect? +

+

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

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

Connected Clients

-

- Manage OAuth clients and their permissions +

+ Connections +

+

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

-
- {/* Search Bar */} -
- - setSearchQuery(e.target.value)} - className="w-full pl-12 pr-4 py-3 text-base bg-[rgb(var(--surface))] border border-[rgb(var(--border))] rounded-xl focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-all" - /> -
+ {clients.length > 0 && ( +
+ + setSearchQuery(e.target.value)} + className="w-full pl-12 pr-4 py-3 text-base bg-[rgb(var(--surface))] border border-[rgb(var(--border))] rounded-xl focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-all" + /> +
+ )}
-
+ - {/* Error */} {error && (
@@ -595,37 +312,35 @@ export default function ClientsPage() {
)} - {/* Clients Grid */}
{isLoading ? (
- ) : filteredClients.length === 0 ? ( - - - -

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

-

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

-
-
+ ) : filtered.length === 0 ? ( + searchQuery ? ( + + + +

+ No connections match your search +

+

+ Try adjusting your search terms. +

+
+
+ ) : ( + + ) ) : (
- {filteredClients.map((client) => { - const modeInfo = getModeInfo(client.connection_mode); - const ModeIcon = modeInfo.icon; - const isSelected = selectedClient?.client_id === client.client_id; - + {filtered.map((client) => { + const isSelected = selected?.client_id === client.client_id; + const displayName = client.client_alias || client.client_name; return ( - - {/* Client Header */} -
-
- +
+
+
-

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

+ {displayName}

{client.client_alias && ( -

+

{client.client_name}

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

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

- {selectedClient.client_alias && ( -

- {selectedClient.client_name} -

- )} -
+ + {ConfirmDialogElement} +
+ ); +} + +function lastSeenDotColor(lastSeen: string | null, now: number): string { + if (!lastSeen) return 'bg-gray-400'; + const secs = (now - new Date(lastSeen).getTime()) / 1000; + if (secs < 120) return 'bg-emerald-500'; + if (secs < 3600) return 'bg-amber-500'; + return 'bg-gray-400'; +} + +/** + * Tri-state capability chip: shows nothing until the gateway has actually + * observed this client's `initialize` (so a brand-new client doesn't + * misleadingly look "Rootless" before we know which it is). Once we've + * processed at least one session the chip resolves to: + * - **Reports workspace** (green) — the client declared MCP `roots`, + * routing flows through Workspace bindings, per-client grants are a + * rare-case fallback only. + * - **Rootless** (amber) — the client explicitly does NOT declare the + * `roots` capability (Claude.ai web, ChatGPT connectors, …); the + * per-client grant list below is the routing source. + * + * Sticky-positive: once a client has been seen reporting roots we keep + * the green badge across reconnects so a one-off rootless session doesn't + * flip the UI to amber. + */ +function CapabilityBadge({ + reportsRoots, + rootsCapabilityKnown, +}: { + reportsRoots: boolean; + rootsCapabilityKnown: boolean; +}) { + if (!rootsCapabilityKnown) { + // Unknown — hide the badge entirely. Returning null keeps adjacent + // layout stable (the panel header + the grants section both render + // their own context, so we don't need a placeholder). + return null; + } + if (reportsRoots) { + return ( + + + Reports workspace + + ); + } + return ( + + + Rootless + + ); +} + +// --------------------------------------------------------------------------- +// Side panel +// --------------------------------------------------------------------------- + +interface SidePanelProps { + client: OAuthClient; + editAlias: string; + setEditAlias: (v: string) => void; + isSaving: boolean; + defaultSpaceId: string | null; + onClose: () => void; + onSaveAlias: () => void; + onRevoke: () => void; + onOpenWorkspaces: () => void; + onToastError: (title: string, body?: string) => void; + onToastSuccess: (title: string, body?: string) => void; +} + +function SidePanel({ + client, + editAlias, + setEditAlias, + isSaving, + defaultSpaceId, + onClose, + onSaveAlias, + onRevoke, + onOpenWorkspaces, + onToastError, + onToastSuccess, +}: SidePanelProps) { + const aliasDirty = (client.client_alias || '') !== editAlias; + + return ( +
+
+
+
+
+ +
+
+

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

+
+

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

+
+
+
+ +
+
+ +
+
+

+ Display name +

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

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

+
+ +
+
+
+ +
+
+

Routing is workspace-driven

+

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

- - {selectedClient.software_version && ( - - v{selectedClient.software_version} - - )}
+
- {/* Scrollable Content */} -
-
- {/* Quick Settings Section */} -
- + {/* Per-client grants only matter for clients that explicitly do + NOT declare the MCP `roots` capability — Claude.ai web, + ChatGPT connectors, and similar rootless connectors. For + roots-capable clients (Cursor, VS Code, Claude Desktop) + routing flows through Workspace bindings and these grants + never apply, so the section is just chrome. For clients + we haven't observed yet, the capability is unknown and the + section would have no audience either way — defer it until + the first `initialize` reveals the answer. */} + {client.roots_capability_known && !client.reports_roots && ( + + )} - {expandedSections.quickSettings && ( -
- {/* Display Name */} -
- - setEditAlias(e.target.value)} - placeholder={selectedClient.client_name} - className="w-full px-3 py-2 text-sm bg-[rgb(var(--surface))] border border-[rgb(var(--border))] rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500" - /> -
- - {/* Connection Mode */} -
- - -
- - {/* Locked Space Selection */} - {editMode === 'locked' && ( -
- - -
- )} +
+

+ Client info +

+
+ + + {client.software_id && ( + + )} + {client.software_version && ( + + )} + + {client.last_seen && ( + + )} +
+
+
- {/* Save Button */} - -
- )} -
+
+ +
+
+ ); +} - {/* Permissions Section */} -
- +// --------------------------------------------------------------------------- +// Rootless-fallback FeatureSet grants +// +// Edits the `client_grants` table. Only consulted by the resolver when the +// client did NOT declare the MCP `roots` capability — i.e. Claude.ai web, +// ChatGPT, and similar connectors that don't surface a workspace folder. +// Roots-capable desktop clients (Cursor, VS Code, Claude Desktop) ignore +// these grants entirely; their routing comes from Workspace bindings. +// +// We render this section unconditionally rather than hiding it for +// roots-capable clients: capability detection only happens at session time, +// so a client we've classified as "reports workspace" today might tomorrow +// open a rootless session (e.g. CLI subcommand). Surfacing the grant +// editor + a clear "only used when…" note is more honest than hiding it. +// --------------------------------------------------------------------------- - {expandedSections.permissions && ( -
- {/* Context Warning */} - {selectedClient.connection_mode === 'locked' && selectedClient.locked_space_id !== activeSpace?.id ? ( -
-
- -
-

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

-

- Switch spaces or change connection mode to manage permissions -

-
-
-
- ) : ( - <> - {/* Space Context */} - {activeSpace && ( -
-
- Managing: - - {activeSpace.icon || '📁'} {activeSpace.name} - -
-
- )} +/** + * Renders the per-client FS grant editor. The parent decides whether to + * mount this — only mounted for clients that have explicitly declared + * they do NOT support the MCP `roots` capability. Roots-capable and + * unknown-capability clients don't see this section at all. + */ +function RootlessGrantsSection({ + clientId, + defaultSpaceId, + onError, + onSuccess, +}: { + clientId: string; + defaultSpaceId: string | null; + onError: (title: string, body?: string) => void; + onSuccess: (title: string, body?: string) => void; +}) { + const [featureSets, setFeatureSets] = useState([]); + const [grantedIds, setGrantedIds] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [pendingFsId, setPendingFsId] = useState(null); + const [search, setSearch] = useState(''); - {/* Feature Sets */} - {isLoadingGrants ? ( -
- -
- ) : ( -
-
- Feature Sets -
- {availableFeatureSets - .filter(fs => !fs.name.endsWith(' - Custom')) - .slice(0, 5) - .map((fs) => { - const isGranted = grantedFeatureSetIds.includes(fs.id); - const isDefault = fs.feature_set_type === 'default'; - const isDisabled = isDefault; - - return ( - - ); - })} -
- )} + // Filter the FS list by search query (name + description, case- + // insensitive). Always show currently-granted FSes even if they don't + // match the query — otherwise the operator could "lose" a granted FS + // they're trying to revoke. A small "+ N granted" hint surfaces them + // so the omission is visible. + const filteredFs = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return featureSets; + return featureSets.filter((f) => { + if (grantedIds.includes(f.id)) return true; + if (f.name.toLowerCase().includes(q)) return true; + if (f.description?.toLowerCase().includes(q)) return true; + return false; + }); + }, [featureSets, search, grantedIds]); - {/* Advanced Permissions Toggle */} - + useEffect(() => { + let cancelled = false; + if (!defaultSpaceId) { + setIsLoading(false); + return; + } + setIsLoading(true); + Promise.all([ + listFeatureSetsBySpace(defaultSpaceId), + getOAuthClientGrants(clientId, defaultSpaceId), + ]) + .then(([fs, grants]) => { + if (cancelled) return; + setFeatureSets(fs); + setGrantedIds(grants); + }) + .catch((e) => { + if (cancelled) return; + onError( + 'Failed to load grants', + e instanceof Error ? e.message : String(e) + ); + }) + .finally(() => { + if (!cancelled) setIsLoading(false); + }); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [clientId, defaultSpaceId]); - {/* Advanced Permissions Content */} - {expandedSections.advancedPermissions && ( -
- {isLoadingFeatures ? ( -
- -
- ) : (() => { - const serverGroups = availableFeatures.reduce((acc, feature) => { - if (!acc[feature.server_id]) { - acc[feature.server_id] = []; - } - acc[feature.server_id].push(feature); - return acc; - }, {} as Record); + const toggle = async (fs: FeatureSet) => { + if (!defaultSpaceId) return; + const isGranted = grantedIds.includes(fs.id); + setPendingFsId(fs.id); + // Optimistic update — gateway emits ClientGrantChanged + we'll re-sync + // via the `oauth-client-changed` listener at the parent level. + setGrantedIds((prev) => + isGranted ? prev.filter((id) => id !== fs.id) : [...prev, fs.id] + ); + try { + if (isGranted) { + await revokeOAuthClientFeatureSet(clientId, defaultSpaceId, fs.id); + onSuccess(`Revoked "${fs.name}"`); + } else { + await grantOAuthClientFeatureSet(clientId, defaultSpaceId, fs.id); + onSuccess(`Granted "${fs.name}"`); + } + } catch (e) { + // Roll back the optimistic update on failure. + setGrantedIds((prev) => + isGranted ? [...prev, fs.id] : prev.filter((id) => id !== fs.id) + ); + onError( + isGranted ? 'Failed to revoke grant' : 'Failed to grant', + e instanceof Error ? e.message : String(e) + ); + } finally { + setPendingFsId(null); + } + }; - return ( -
- {Object.entries(serverGroups).map(([serverId, features]) => { - const isExpanded = expandedServers.has(serverId); - const selectedCount = features.filter(f => - individualFeatureMembers.some(m => m.member_id === f.id) - ).length; - - return ( -
- - - {isExpanded && ( -
- {features.map((feature) => { - const isAdded = individualFeatureMembers.some(m => m.member_id === feature.id); - - return ( - - ); - })} -
- )} -
- ); - })} -
- ); - })()} -
- )} - - )} -
- )} -
+ return ( +
+
+
+

+ Default for rootless sessions +

+
+ + + Rootless only + +
+

+ This client doesn't declare the MCP{' '} + + roots + {' '} + capability, so its sessions route through the FeatureSets you + pick here instead of through Workspace bindings. Leaving the + list empty denies the client — rootless sessions then see only + the built-in + + mcpmux_* + + management tools. +

- {/* Effective Features Section */} -
-
- {expandedSections.effectiveFeatures ? ( - - ) : ( - - )} - - - {expandedSections.effectiveFeatures && ( -
- {isLoadingResolvedFeatures ? ( -
- -
- ) : !resolvedFeatures || totalFeatures === 0 ? ( -
- -

- No features granted yet -

-
- ) : ( -
- {/* Tools */} - {resolvedFeatures.tools.length > 0 && ( -
- - {expandedFeatureTypes.tools && ( -
- {resolvedFeatures.tools.map((tool) => ( -
-
- {tool.name} -
- {tool.description && ( -
- {tool.description} -
- )} -
- ))} -
- )} -
- )} + + ); + }) + )} +
+ {search && filteredFs.length > 0 && filteredFs.length < featureSets.length && ( +
+ {filteredFs.length} of {featureSets.length} shown + {grantedIds.some((id) => !filteredFs.find((f) => f.id === id)) && + ' (granted FSes always visible)'} +
+ )} +
+ )} - {/* Prompts */} - {resolvedFeatures.prompts.length > 0 && ( -
- - {expandedFeatureTypes.prompts && ( -
- {resolvedFeatures.prompts.map((prompt) => ( -
-
- {prompt.name} -
- {prompt.description && ( -
- {prompt.description} -
- )} -
- ))} -
- )} -
- )} + {grantedIds.length === 0 && featureSets.length > 0 && !isLoading && ( +
+ +

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

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

+ Let's hook up your first IDE +

+

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

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

+ Gateway is stopped +

+

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

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

    {title}

    +

    {body}

    +
    +
  • + ); +} diff --git a/apps/desktop/src/features/featuresets/FeatureSetPanel.tsx b/apps/desktop/src/features/featuresets/FeatureSetPanel.tsx index d2664b22..2db92725 100644 --- a/apps/desktop/src/features/featuresets/FeatureSetPanel.tsx +++ b/apps/desktop/src/features/featuresets/FeatureSetPanel.tsx @@ -15,14 +15,18 @@ import { Settings, Trash2, Check, - Globe, Star, Shield, Save, + Monitor, } from 'lucide-react'; import { Button, useToast, ToastContainer, useConfirm } from '@mcpmux/ui'; import type { FeatureSet, AddMemberInput } from '@/lib/api/featureSets'; -import { setFeatureSetMembers } from '@/lib/api/featureSets'; +import { + isStarterFeatureSet, + setFeatureSetMembers, + updateFeatureSet, +} from '@/lib/api/featureSets'; import type { ServerFeature } from '@/lib/api/serverFeatures'; import { listServerFeatures } from '@/lib/api/serverFeatures'; @@ -43,9 +47,15 @@ interface ServerGroup { export function FeatureSetPanel({ featureSet, spaceId, onClose, onDelete, onUpdate }: FeatureSetPanelProps) { const [allFeatures, setAllFeatures] = useState([]); const [selectedFeatureIds, setSelectedFeatureIds] = useState>(new Set()); + const [surfacedFeatureIds, setSurfacedFeatureIds] = useState>(new Set()); const [searchQuery, setSearchQuery] = useState(''); const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); + const [isSavingGeneral, setIsSavingGeneral] = useState(false); + const [displayName, setDisplayName] = useState(featureSet.name); + const [editName, setEditName] = useState(featureSet.name); + const [editDescription, setEditDescription] = useState(featureSet.description ?? ''); + const [editIcon, setEditIcon] = useState(featureSet.icon ?? ''); const [error, setError] = useState(null); const [expandedServers, setExpandedServers] = useState>(new Set()); const { toasts, success, error: showError, dismiss } = useToast(); @@ -57,40 +67,24 @@ export function FeatureSetPanel({ featureSet, spaceId, onClose, onDelete, onUpda features: true, }); - // Determine if this is a configurable feature set - const isConfigurable = featureSet.feature_set_type === 'default' || featureSet.feature_set_type === 'custom'; - const isDefault = featureSet.feature_set_type === 'default'; + // Both FS types are member-driven now. + const isConfigurable = true; + // The auto-seeded "Starter" FS is treated identically to a Custom one + // — the type tag is a UI hint, not a routing flag. + const isStarter = isStarterFeatureSet(featureSet); const isCustom = featureSet.feature_set_type === 'custom'; - const isAll = featureSet.feature_set_type === 'all'; - const isServerAll = featureSet.feature_set_type === 'server-all'; - - // For special feature sets, compute actual member count - const getActualMemberCount = () => { - if (isAll) { - // "All Features" includes everything - return allFeatures.length; - } - if (isServerAll && featureSet.server_id) { - // "Server All" - use server_id from feature set - return allFeatures.filter(f => f.server_id === featureSet.server_id).length; - } - // For configurable sets, use selectedFeatureIds - return selectedFeatureIds.size; - }; - - // Check if a feature should be shown as selected - const isFeatureSelected = (featureId: string, feature: ServerFeature) => { - if (isAll) { - // All features are selected - return true; - } - if (isServerAll && featureSet.server_id) { - // Only features from the target server - return feature.server_id === featureSet.server_id; - } - // For configurable sets, check selectedFeatureIds - return selectedFeatureIds.has(featureId); - }; + + const getActualMemberCount = () => selectedFeatureIds.size; + + const isFeatureSelected = (featureId: string, _feature: ServerFeature) => + selectedFeatureIds.has(featureId); + + useEffect(() => { + setDisplayName(featureSet.name); + setEditName(featureSet.name); + setEditDescription(featureSet.description ?? ''); + setEditIcon(featureSet.icon ?? ''); + }, [featureSet]); useEffect(() => { const loadFeatures = async () => { @@ -99,30 +93,20 @@ export function FeatureSetPanel({ featureSet, spaceId, onClose, onDelete, onUpda const features = await listServerFeatures(spaceId); setAllFeatures(features); - // Initialize selected features from current members + // Seed from the set's include-mode feature members. const currentIds = new Set(); - - // For special feature sets, compute selection dynamically - if (featureSet.feature_set_type === 'all') { - // All features are selected - features.forEach(f => currentIds.add(f.id)); - } else if (featureSet.feature_set_type === 'server-all' && featureSet.server_id) { - // All features from this server are selected - features.forEach(f => { - if (f.server_id === featureSet.server_id) { - currentIds.add(f.id); - } - }); - } else { - // For configurable sets (default/custom), use members array - featureSet.members?.forEach((m) => { - if (m.member_type === 'feature' && m.mode === 'include') { - currentIds.add(m.member_id); + const surfacedIds = new Set(); + featureSet.members?.forEach((m) => { + if (m.member_type === 'feature' && m.mode === 'include') { + currentIds.add(m.member_id); + if (m.surfaced) { + surfacedIds.add(m.member_id); } - }); - } - + } + }); + setSelectedFeatureIds(currentIds); + setSurfacedFeatureIds(surfacedIds); // Start with all servers collapsed setExpandedServers(new Set()); @@ -166,6 +150,28 @@ export function FeatureSetPanel({ featureSet, spaceId, onClose, onDelete, onUpda const toggleFeature = (featureId: string) => { if (!isConfigurable) return; setSelectedFeatureIds((prev) => { + const next = new Set(prev); + if (next.has(featureId)) { + next.delete(featureId); + setSurfacedFeatureIds((surfaced) => { + const nextSurfaced = new Set(surfaced); + nextSurfaced.delete(featureId); + return nextSurfaced; + }); + } else { + next.add(featureId); + } + return next; + }); + }; + + /** + * Toggle whether an included tool is promoted into client tools/list. + */ + const toggleSurfaced = (featureId: string, event: React.MouseEvent) => { + event.stopPropagation(); + if (!isConfigurable || !selectedFeatureIds.has(featureId)) return; + setSurfacedFeatureIds((prev) => { const next = new Set(prev); if (next.has(featureId)) { next.delete(featureId); @@ -206,6 +212,44 @@ export function FeatureSetPanel({ featureSet, spaceId, onClose, onDelete, onUpda }); }; + /** + * Save name, description, and icon from the General Information section. + */ + const handleSaveGeneral = async () => { + const trimmedName = editName.trim(); + if (!trimmedName) { + setError('Name is required.'); + return; + } + + setIsSavingGeneral(true); + setError(null); + try { + const updated = await updateFeatureSet(featureSet.id, { + name: trimmedName, + description: editDescription.trim() || undefined, + icon: editIcon.trim() || undefined, + }); + setDisplayName(updated.name); + setEditName(updated.name); + setEditDescription(updated.description ?? ''); + setEditIcon(updated.icon ?? ''); + success('Feature set updated', `"${updated.name}" has been saved`); + onUpdate?.(); + } catch (e) { + const errorMsg = e instanceof Error ? e.message : String(e); + setError(errorMsg); + showError('Failed to save feature set', errorMsg); + } finally { + setIsSavingGeneral(false); + } + }; + + const hasGeneralChanges = + editName.trim() !== featureSet.name || + editDescription.trim() !== (featureSet.description ?? '') || + editIcon.trim() !== (featureSet.icon ?? ''); + const handleSave = async () => { setIsSaving(true); setError(null); @@ -215,6 +259,7 @@ export function FeatureSetPanel({ featureSet, spaceId, onClose, onDelete, onUpda member_type: 'feature' as const, member_id: id, mode: 'include' as const, + surfaced: surfacedFeatureIds.has(id), })); await setFeatureSetMembers(featureSet.id, members); @@ -259,10 +304,10 @@ export function FeatureSetPanel({ featureSet, spaceId, onClose, onDelete, onUpda const getFeatureSetIcon = () => { if (featureSet.icon) return {featureSet.icon}; switch (featureSet.feature_set_type) { - case 'all': return ; case 'default': return ; - case 'server-all': return ; - case 'custom': default: return ; + case 'custom': + default: + return ; } }; @@ -290,17 +335,24 @@ export function FeatureSetPanel({ featureSet, spaceId, onClose, onDelete, onUpda

    - {featureSet.name} + {displayName}

    - - {featureSet.feature_set_type.toUpperCase()} + + {isStarter ? 'STARTER' : featureSet.feature_set_type.toUpperCase()} ID: {featureSet.id} @@ -359,19 +411,68 @@ export function FeatureSetPanel({ featureSet, spaceId, onClose, onDelete, onUpda
    -

    - {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" + />
    + - {isDefault && ( +
    + + 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 && (
    - Default Feature Set: Features selected here are automatically granted to all clients in this workspace. + Starter FeatureSet: auto-created with this Space. It's an ordinary FeatureSet — edit, rename, or delete it freely. No special routing role: Workspace bindings and per-client grants pick FeatureSets explicitly.
    @@ -542,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 ( -
    - + + {getFeatureIcon(feature.feature_type)} + +
    +
    + + {feature.display_name || feature.feature_name} + + + {feature.feature_type} + +
    + {feature.description && ( +

    + {feature.description} +

    + )} +
    + + + {isConfigurable && isTool && isSelected && ( + + )} +
    ); })}
    diff --git a/apps/desktop/src/features/featuresets/FeatureSetsPage.tsx b/apps/desktop/src/features/featuresets/FeatureSetsPage.tsx index 92587e39..363eb940 100644 --- a/apps/desktop/src/features/featuresets/FeatureSetsPage.tsx +++ b/apps/desktop/src/features/featuresets/FeatureSetsPage.tsx @@ -2,15 +2,15 @@ import { useState, useEffect, useCallback } from 'react'; import { Plus, Loader2, - Server, Package, Settings, X, RefreshCw, - Globe, Star, Search, AlertCircle, + CheckCircle2, + Zap, } from 'lucide-react'; import { Card, @@ -27,6 +27,7 @@ import { createFeatureSet, deleteFeatureSet, getFeatureSetWithMembers, + isStarterFeatureSet, } from '@/lib/api/featureSets'; import { useViewSpace } from '@/stores'; import { FeatureSetPanel } from './FeatureSetPanel'; @@ -34,29 +35,25 @@ import { FeatureSetPanel } from './FeatureSetPanel'; // Get icon for feature set type const getFeatureSetIcon = (fs: FeatureSet) => { if (fs.icon) return {fs.icon}; - + switch (fs.feature_set_type) { - case 'all': - return ; - case 'default': + case 'starter': + case 'default': // legacy alias — pre-migration-013 reads still parse here return ; - case 'server-all': - return ; case 'custom': default: return ; } }; -// Get display name for feature set type +// Get display name for feature set type. The 'default' alias is kept on +// the read path so a stale row from before migration 013 still renders +// the right pill — migration 013 rewrites stored values to 'starter'. const getFeatureSetTypeName = (type: string) => { switch (type) { - case 'all': - return 'All Features'; + case 'starter': case 'default': - return 'Default'; - case 'server-all': - return 'Server All'; + return 'Starter'; case 'custom': default: return 'Custom'; @@ -89,8 +86,6 @@ export function FeatureSetsPage() { setFeatureSets([]); return; } - - // Backend filters out server-all feature sets for disabled servers const data = await listFeatureSetsBySpace(spaceId); setFeatureSets(data); } catch (e) { @@ -188,11 +183,19 @@ export function FeatureSetsPage() { ); }) .sort((a, b) => { - // Sort order: all → default → custom → server-all - const order: Record = { all: 0, default: 1, custom: 2, 'server-all': 3 }; - const aOrder = order[a.feature_set_type] ?? 2; - const bOrder = order[b.feature_set_type] ?? 2; - return aOrder - bOrder; + // Starter FS first (pinned to top — operator usually wants the + // auto-seeded one near the top so they can edit / delete it + // first), then Custom sets alphabetically. The 'default' key is + // kept so a stale row read pre-migration still sorts correctly. + const order: Record = { + starter: 0, + default: 0, + custom: 1, + }; + const aOrder = order[a.feature_set_type] ?? 1; + const bOrder = order[b.feature_set_type] ?? 1; + if (aOrder !== bOrder) return aOrder - bOrder; + return a.name.localeCompare(b.name); }); return ( @@ -247,6 +250,25 @@ export function FeatureSetsPage() {
    + {/* Feature-set model explainer */} +
    +
    +
    + +
    +
    +

    + FeatureSets are bound to workspace roots +

    +

    + Each Space gets one auto-created Default set. Routing is decided per + reported folder via Workspaces — sessions + whose root isn't bound fall back to the default Space's Default set. +

    +
    +
    +
    + {/* Error */} {error && (
    @@ -290,59 +312,60 @@ export function FeatureSetsPage() { {filteredSets.map((fs) => { const isSelected = selectedFeatureSet?.id === fs.id; const isBuiltin = fs.is_builtin; - + const isStarter = isStarterFeatureSet(fs); + return ( - handleOpenPanel(fs)} data-testid={`featureset-card-${fs.id}`} > + {isStarter && ( +
    + + Starter +
    + )} + - {/* Header */}
    -
    +
    {getFeatureSetIcon(fs)}
    -
    -

    - {fs.name} -

    - +
    +

    {fs.name}

    + {getFeatureSetTypeName(fs.feature_set_type)}
    - {/* Description */}

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

    - {/* Footer Info */} -
    -
    - {fs.feature_set_type === 'server-all' ? ( - {fs.server_id} - ) : fs.feature_set_type === 'all' ? ( - All features - ) : ( - {fs.members?.length || 0} members - )} -
    - {isBuiltin && fs.feature_set_type !== 'default' ? ( - Auto-managed - ) : ( - - Configure - - )} +
    + {fs.members?.length || 0} members + + Configure +
    diff --git a/apps/desktop/src/features/gateway/AutoStartConflictResolver.tsx b/apps/desktop/src/features/gateway/AutoStartConflictResolver.tsx new file mode 100644 index 00000000..1486be5d --- /dev/null +++ b/apps/desktop/src/features/gateway/AutoStartConflictResolver.tsx @@ -0,0 +1,106 @@ +import { useEffect } from 'react'; +import { + takePendingPortConflict, + getGatewayStatus, +} from '@/lib/api/gateway'; +import { useGatewayControl } from './useGatewayControl'; + +/** + * Polling schedule (ms after mount). Covers the realistic window for the + * Rust auto-start task to complete its port probe. Short early polls catch + * the common case; longer tails catch cold-start machines / slow disks. + * Total max wait: ~4.75s before giving up silently. + */ +const POLL_SCHEDULE_MS = [0, 150, 300, 600, 1200, 2400]; + +/** + * Mounts at the app root and resolves any auto-start port conflict the + * backend deferred during launch. + * + * ## Why polling, not events + * + * Tauri events aren't buffered — if the Rust auto-start task emits + * `gateway-autostart-port-conflict` before `listen()` has attached the + * frontend listener, the event is dropped. Combined with React + * StrictMode's double-mount in dev, the probability of this race is + * noticeable. + * + * Polling `take_pending_port_conflict` (atomic read-and-clear on the + * backend) plus `get_gateway_status` together covers all three + * launch-time outcomes: + * + * 1. **Silent success** — port free, gateway auto-started. `getGatewayStatus` + * returns `running: true` → we exit. + * 2. **Port conflict** — backend set `pending_port_conflict`. The take + * consumes it; we show the prompt. + * 3. **Auto-start disabled** — neither a conflict nor a running gateway. + * We exhaust the poll schedule and exit quietly; user can start + * manually from the Dashboard. + * + * The backend `take` is atomic so the StrictMode double-mount never + * produces duplicate prompts. + */ +export function AutoStartConflictResolver() { + const gatewayControl = useGatewayControl(); + + useEffect(() => { + let cancelled = false; + + (async () => { + for (let i = 0; i < POLL_SCHEDULE_MS.length; i++) { + if (cancelled) return; + const delay = POLL_SCHEDULE_MS[i]; + if (delay > 0) { + await new Promise((resolve) => setTimeout(resolve, delay)); + } + if (cancelled) return; + + try { + // If the gateway auto-started silently (port was free), we're + // done — no need to keep probing. + const status = await getGatewayStatus(); + if (cancelled) return; + if (status.running) { + console.log( + `[AutoStart] attempt ${i + 1}: gateway already running (${status.url}) — nothing to resolve` + ); + return; + } + + const conflict = await takePendingPortConflict(); + if (cancelled) return; + console.log( + `[AutoStart] attempt ${i + 1}: takePendingPortConflict →`, + conflict + ); + + if (conflict) { + const outcome = await gatewayControl.start(); + console.log('[AutoStart] prompt outcome:', outcome); + return; + } + // Otherwise keep polling — backend auto-start task may not have + // run yet. Last iteration just bails (user can start manually). + } catch (err) { + console.error( + `[AutoStart] attempt ${i + 1} failed — will retry:`, + err + ); + } + } + + console.log( + '[AutoStart] poll schedule exhausted — no conflict, no running gateway (likely auto-start disabled)' + ); + })(); + + return () => { + cancelled = true; + }; + // `gatewayControl` is stable for the lifetime of this component; we + // deliberately run this once on mount. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return <>{gatewayControl.ConfirmDialogElement}; +} diff --git a/apps/desktop/src/features/gateway/useGatewayControl.tsx b/apps/desktop/src/features/gateway/useGatewayControl.tsx new file mode 100644 index 00000000..2c6bd24f --- /dev/null +++ b/apps/desktop/src/features/gateway/useGatewayControl.tsx @@ -0,0 +1,152 @@ +import { useConfirm } from '@mcpmux/ui'; +import { + probeGatewayStart, + startGateway, + restartGateway, + parsePortInUseError, +} from '@/lib/api/gateway'; + +/** + * Shape of the outcome returned by start/restart helpers. + * + * `cancelled` signals the user dismissed the port-in-use prompt — callers + * should treat it as a non-error (no toast, just stop). + */ +export type GatewayStartOutcome = + | { status: 'started'; url: string; fellBackToDynamic: boolean; port: number } + | { status: 'cancelled' }; + +function sourceLabel(source: 'override' | 'configured' | 'default'): string { + switch (source) { + case 'configured': + return 'your configured gateway port'; + case 'default': + return 'the default gateway port'; + case 'override': + return 'the requested gateway port'; + } +} + +/** + * Hook that handles the probe → confirm → start flow uniformly across the + * Dashboard, Servers page, and Settings page. Render `ConfirmDialogElement` + * once inside the consuming component. + * + * When the preferred port is taken, the user is shown a dialog asking + * whether to let the gateway bind to a different (OS-assigned) port. If + * they cancel, the returned outcome is `{ status: 'cancelled' }` and no + * error is thrown — the caller can exit silently. + */ +export function useGatewayControl() { + const { confirm, ConfirmDialogElement } = useConfirm(); + + const runStart = async ( + invoker: (allowFallback: boolean) => Promise, + probePort?: number + ): Promise => { + console.log('[Gateway] probeGatewayStart({port:', probePort, '})'); + const probe = await probeGatewayStart(probePort); + console.log('[Gateway] probe result:', probe); + + if (probe.preferredAvailable) { + console.log('[Gateway] preferred port free → strict start'); + const url = await invoker(false); + const port = parsePortFromUrl(url) ?? probe.preferredPort; + console.log('[Gateway] strict start ok →', url); + return { status: 'started', url, port, fellBackToDynamic: false }; + } + + console.log('[Gateway] preferred port taken → prompting user'); + const ok = await confirm({ + title: 'Gateway port is in use', + message: + `${capitalize(sourceLabel(probe.source))} (:${probe.preferredPort}) is already ` + + `taken by another process. Start the gateway on a different port that the system ` + + `picks automatically? Your IDE configs will need to be updated to point at the new ` + + `port.`, + confirmLabel: 'Use another port', + variant: 'default', + }); + + if (!ok) { + console.log('[Gateway] user cancelled — gateway stays stopped'); + return { status: 'cancelled' }; + } + + console.log('[Gateway] user confirmed → fallback start with dynamic port'); + const url = await invoker(true); + const port = parsePortFromUrl(url) ?? probe.preferredPort; + console.log('[Gateway] fallback start ok →', url); + return { + status: 'started', + url, + port, + fellBackToDynamic: true, + }; + }; + + const start = async (opts?: { port?: number }): Promise => { + try { + return await runStart( + (allowFallback) => + startGateway({ port: opts?.port, allowDynamicFallback: allowFallback }), + opts?.port + ); + } catch (err) { + // If we hit a race (probe said free, bind failed) or any other bind + // error, surface it with the structured prompt flow. + return await handleBindFailure(err, opts?.port, (allowFallback) => + startGateway({ port: opts?.port, allowDynamicFallback: allowFallback }) + ); + } + }; + + const restart = async (opts?: { port?: number }): Promise => { + try { + return await runStart( + (allowFallback) => + restartGateway({ port: opts?.port, allowDynamicFallback: allowFallback }), + opts?.port + ); + } catch (err) { + return await handleBindFailure(err, opts?.port, (allowFallback) => + restartGateway({ port: opts?.port, allowDynamicFallback: allowFallback }) + ); + } + }; + + const handleBindFailure = async ( + err: unknown, + port: number | undefined, + invoker: (allowFallback: boolean) => Promise + ): Promise => { + const pie = parsePortInUseError(err); + if (!pie) throw err; + const ok = await confirm({ + title: 'Gateway port is in use', + message: + `${capitalize(sourceLabel(pie.source))} (:${pie.port}) is already in use. ` + + `Start on a different port?`, + confirmLabel: 'Use another port', + }); + if (!ok) return { status: 'cancelled' }; + const url = await invoker(true); + return { + status: 'started', + url, + port: parsePortFromUrl(url) ?? pie.port, + fellBackToDynamic: true, + }; + }; + + return { start, restart, ConfirmDialogElement }; +} + +function parsePortFromUrl(url: string): number | null { + const match = /:(\d+)(?:\/|$)/.exec(url); + return match ? Number(match[1]) : null; +} + +function capitalize(s: string): string { + return s.charAt(0).toUpperCase() + s.slice(1); +} diff --git a/apps/desktop/src/features/metaTools/MetaToolApprovalDialog.tsx b/apps/desktop/src/features/metaTools/MetaToolApprovalDialog.tsx new file mode 100644 index 00000000..9dea4c18 --- /dev/null +++ b/apps/desktop/src/features/metaTools/MetaToolApprovalDialog.tsx @@ -0,0 +1,219 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { listen } from '@tauri-apps/api/event'; +import { invoke } from '@tauri-apps/api/core'; +import { AlertTriangle, CheckCircle2, XCircle } from 'lucide-react'; +import { Button, Card, CardContent, CardHeader, CardTitle } from '@mcpmux/ui'; + +/** + * Incoming approval request emitted by the gateway's ApprovalBroker. + * Shape mirrors `mcpmux_gateway::services::ApprovalRequest`. + */ +export interface ApprovalRequest { + request_id: string; + client_id: string; + payload: { + tool_name: string; + summary: string; + diff: null | { + before: string[]; + after: string[]; + added: string[]; + removed: string[]; + }; + raw_args: unknown; + affects_other_clients: boolean; + }; + expires_at_unix_secs: number; +} + +type Decision = 'allow_once' | 'always_for_this_session_and_client' | 'deny'; + +/** + * Global listener that renders an approval dialog whenever the gateway + * asks for permission to run an `mcpmux_*` write tool. Place once, near the + * root of the app. + * + * The dialog queues multiple concurrent requests — if two clients request + * approval at the same time, the user sees them in order. + */ +export function MetaToolApprovalDialog() { + const [queue, setQueue] = useState([]); + const current = queue[0]; + + useEffect(() => { + const unlistenPromise = listen( + 'meta-tool-approval-request', + (event) => { + setQueue((prev) => [...prev, event.payload]); + } + ); + return () => { + unlistenPromise.then((fn) => fn()).catch(() => {}); + }; + }, []); + + const respond = useCallback( + async (decision: Decision) => { + if (!current) return; + try { + await invoke('respond_to_meta_tool_approval', { + requestId: current.request_id, + clientId: current.client_id, + toolName: current.payload.tool_name, + decision, + }); + } catch (e) { + // Log but don't block UI — broker will time out and surface + // `approval_timed_out` to the tool caller. + console.warn('respond_to_meta_tool_approval failed', e); + } finally { + setQueue((prev) => prev.slice(1)); + } + }, + [current] + ); + + const diff = current?.payload.diff; + const toolCount = diff?.after.length ?? null; + const deltaLabel = useMemo(() => { + if (!diff) return null; + const added = diff.added.length; + const removed = diff.removed.length; + return `+${added} / -${removed}`; + }, [diff]); + + if (!current) return null; + + return ( +
    + + + + + An MCP client wants to change your tools + + + +
    +

    {current.payload.summary}

    +

    + tool: {current.payload.tool_name} +

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

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

    + )} +
    +
    +
    + ); +} + +function Stat({ + label, + value, + emphasis, +}: { + label: string; + value: number | string; + emphasis?: boolean; +}) { + return ( +
    + + {label} + + + {value} + +
    + ); +} diff --git a/apps/desktop/src/features/metaTools/MetaToolAuditLog.tsx b/apps/desktop/src/features/metaTools/MetaToolAuditLog.tsx new file mode 100644 index 00000000..cf6348b9 --- /dev/null +++ b/apps/desktop/src/features/metaTools/MetaToolAuditLog.tsx @@ -0,0 +1,107 @@ +import { useEffect, useState } from 'react'; +import { listen } from '@tauri-apps/api/event'; +import { CheckCircle2, Eye, ShieldAlert, XCircle } from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@mcpmux/ui'; +import type { MetaToolAuditEvent } from '@/lib/api/metaTools'; + +/** Ring-buffer size — keeps the most recent N audit rows in memory. */ +const MAX_ROWS = 50; + +/** + * In-memory audit log of every `mcpmux_*` invocation (read or write, + * success or failure). Subscribes to the gateway's `meta-tool-invoked` + * event channel; rows are kept only for the current UI session — the + * persistent audit stream lives in the gateway's tracing logs. + */ +export function MetaToolAuditLog() { + const [rows, setRows] = useState([]); + + useEffect(() => { + const unlisten = listen( + 'meta-tool-invoked', + (event) => { + setRows((prev) => { + // Most-recent-first; trim to MAX_ROWS. + const next = [event.payload, ...prev]; + return next.length > MAX_ROWS ? next.slice(0, MAX_ROWS) : next; + }); + } + ); + return () => { + unlisten.then((fn) => fn()).catch(() => {}); + }; + }, []); + + return ( + + + + + Recent meta-tool activity + +

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

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

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

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

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

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

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

    + ) : ( +
      + {grants.map((g) => { + const key = `${g.client_id}:${g.tool_name}`; + return ( +
    • +
      + + {g.tool_name} + + + client {g.client_id.slice(0, 8)}… + +
      + +
    • + ); + })} +
    + )} +
    +
    + ); +} diff --git a/apps/desktop/src/features/metaTools/index.ts b/apps/desktop/src/features/metaTools/index.ts new file mode 100644 index 00000000..a3419b9a --- /dev/null +++ b/apps/desktop/src/features/metaTools/index.ts @@ -0,0 +1,4 @@ +export { MetaToolApprovalDialog } from './MetaToolApprovalDialog'; +export type { ApprovalRequest } from './MetaToolApprovalDialog'; +export { MetaToolGrantsPanel } from './MetaToolGrantsPanel'; +export { MetaToolAuditLog } from './MetaToolAuditLog'; diff --git a/apps/desktop/src/features/registry/RegistryPage.tsx b/apps/desktop/src/features/registry/RegistryPage.tsx index dda4e672..bd9b43a6 100644 --- a/apps/desktop/src/features/registry/RegistryPage.tsx +++ b/apps/desktop/src/features/registry/RegistryPage.tsx @@ -12,6 +12,7 @@ import { ServerCard } from './ServerCard'; import { ServerDetailModal } from './ServerDetailModal'; import { useViewSpace, useNavigateTo } from '@/stores'; import { capture } from '@/lib/analytics'; +import { RequestServerCTA, ContributeMenu } from '@/components/Contribute'; export function RegistryPage() { const { @@ -140,13 +141,18 @@ export function RegistryPage() { {/* Header */}
    -
    -

    Discover Servers

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

    Discover Servers

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

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

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

    No servers found

    -

    Try adjusting your search or filters

    +

    Try adjusting your search or filters

    + {/* Empty-search CTA — push the user toward requesting or + contributing the missing server rather than just giving up. */} +
    + +
    ) : (
    diff --git a/apps/desktop/src/features/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 4350c8b2..c49880f9 100644 --- a/apps/desktop/src/features/servers/ServersPage.tsx +++ b/apps/desktop/src/features/servers/ServersPage.tsx @@ -19,15 +19,35 @@ 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'; import { useServerManager } from '@/hooks/useServerManager'; +import { useGatewayControl } from '@/features/gateway/useGatewayControl'; import { useGatewayEvents, useDomainEvents } from '@/hooks/useDomainEvents'; import type { GatewayChangedPayload, ServerChangedPayload } from '@/hooks/useDomainEvents'; import type { FeaturesUpdatedEvent } from '@/lib/api/serverManager'; @@ -35,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 ?? {}, @@ -78,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); @@ -94,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, @@ -103,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 ?? {}, @@ -112,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, @@ -139,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 ?? {}, @@ -158,14 +203,22 @@ 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); const [actionLoading, setActionLoading] = useState(null); + const gatewayControl = useGatewayControl(); // Bottom toast notifications const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' | 'info' } | null>(null); const [configModal, setConfigModal] = useState({ @@ -175,6 +228,8 @@ export function ServersPage() { envOverrides: {}, argsAppend: [], extraHeaders: {}, + displayName: '', + initialDisplayName: '', }); // Features state @@ -187,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); @@ -325,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 @@ -369,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 { @@ -484,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); @@ -529,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; } @@ -593,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, @@ -633,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) { @@ -673,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 @@ -709,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 { + await performUninstall([server.id]); + showToast(`${server.name} ${actionLabel.toLowerCase()}ed`, 'success'); + } 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 { - 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 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); } @@ -741,18 +1035,25 @@ export function ServersPage() { const handleStartGateway = async () => { try { - const { startGateway, connectAllEnabledServers } = await import('@/lib/api/gateway'); - const url = await startGateway(); + const outcome = await gatewayControl.start(); + if (outcome.status === 'cancelled') return; setGatewayRunning(true); - setGatewayUrl(url); - + setGatewayUrl(outcome.url); + if (outcome.fellBackToDynamic) { + showToast( + `Preferred port was in use — gateway is now on :${outcome.port}. Update IDE configs.`, + 'info' + ); + } + // Auto-connect all enabled servers try { + const { connectAllEnabledServers } = await import('@/lib/api/gateway'); await connectAllEnabledServers(); } catch (e) { console.warn('[ServersPage] Failed to auto-connect servers:', e); } - + await loadData(); } catch (e) { showToast(String(e), 'error'); @@ -835,55 +1136,126 @@ export function ServersPage() { } return ( -
    - {/* Header */} -
    -
    -

    My Servers

    -

    - Manage your installed MCP servers -

    -
    - {viewSpace && ( - - )} -
    - - {/* Gateway Status */} +
    + {gatewayControl.ConfirmDialogElement} + {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 */} @@ -891,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}`; @@ -926,21 +1307,33 @@ export function ServersPage() { > {/* Server Header */}
    -
    -
    - {/* Expand/Collapse button for connected servers */} - {isConnected && ( - + )}
    @@ -1018,7 +1411,10 @@ export function ServersPage() { {server.transport.type} {/* Installation Source Badge */} - +
    {/* Show runtime message inline (from ServerManager events) */} @@ -1037,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' && ( @@ -1134,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)} />
    @@ -1281,6 +1684,17 @@ export function ServersPage() {
    )} + {/* Clone Account Modal */} + {cloneModalServer && viewSpace && ( + setCloneModalServer(null)} + onCloned={handleCloneComplete} + /> + )} + {/* Configuration Modal */} {configModal.open && configModal.server && (
    @@ -1291,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 1fda7757..89b48e56 100644 --- a/apps/desktop/src/features/settings/SettingsPage.tsx +++ b/apps/desktop/src/features/settings/SettingsPage.tsx @@ -23,9 +23,26 @@ import { XCircle, Trash2, BarChart3, + Sparkles, + Github, + Bug, + Lightbulb, + Package, + Heart, + Network, + RotateCcw, + AlertCircle, } from 'lucide-react'; import { useAppStore, useTheme, useAnalyticsEnabled } from '@/stores'; import { UpdateChecker } from './UpdateChecker'; +import { getMetaToolsEnabled, setMetaToolsEnabled } from '@/lib/api/metaTools'; +import { + getSessionOverridesRequireApproval, + setSessionOverridesRequireApproval, +} from '@/lib/api/sessionOverrides'; +import { MetaToolAuditLog, MetaToolGrantsPanel } from '@/features/metaTools'; +import { useGatewayControl } from '@/features/gateway/useGatewayControl'; +import { CONTRIBUTE, openExternal } from '@/lib/contribute'; interface StartupSettings { autoLaunch: boolean; @@ -33,6 +50,12 @@ interface StartupSettings { closeToTray: boolean; } +interface GatewayPortSettings { + configuredPort: number | null; + defaultPort: number; + activePort: number | null; +} + export function SettingsPage() { const theme = useTheme(); const setTheme = useAppStore((state) => state.setTheme); @@ -41,6 +64,7 @@ export function SettingsPage() { const [logsPath, setLogsPath] = useState(''); const [openingLogs, setOpeningLogs] = useState(false); const { toasts, success, error } = useToast(); + const gatewayControl = useGatewayControl(); // Startup settings state const [startupSettings, setStartupSettings] = useState({ @@ -55,6 +79,158 @@ export function SettingsPage() { const [logRetentionDays, setLogRetentionDays] = useState(30); const [savingRetention, setSavingRetention] = useState(false); + // Meta-tools master switch — gates the entire `mcpmux_*` namespace. + const [metaToolsEnabled, setMetaToolsEnabledState] = useState(true); + const [loadingMetaTools, setLoadingMetaTools] = useState(true); + 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 + // saved ≠ active, the user has to restart the gateway to apply. + const [portSettings, setPortSettings] = useState(null); + const [portDraft, setPortDraft] = useState(''); + const [portError, setPortError] = useState(null); + const [savingPort, setSavingPort] = useState(false); + const [resettingPort, setResettingPort] = useState(false); + + const loadPortSettings = async () => { + try { + const s = await invoke('get_gateway_port_settings'); + setPortSettings(s); + setPortDraft(String(s.configuredPort ?? s.defaultPort)); + setPortError(null); + } catch (err) { + console.error('Failed to load gateway port settings:', err); + } + }; + + useEffect(() => { + loadPortSettings(); + }, []); + + const validatePort = (raw: string): { port: number } | { error: string } => { + const trimmed = raw.trim(); + if (!trimmed) return { error: 'Enter a port number' }; + if (!/^\d+$/.test(trimmed)) return { error: 'Port must be a number' }; + const n = Number(trimmed); + if (n < 1024 || n > 65535) { + return { error: 'Port must be between 1024 and 65535' }; + } + return { port: n }; + }; + + const handleSavePort = async () => { + const parsed = validatePort(portDraft); + if ('error' in parsed) { + setPortError(parsed.error); + return; + } + setPortError(null); + setSavingPort(true); + try { + await invoke('set_gateway_port', { port: parsed.port }); + await loadPortSettings(); + success( + 'Gateway port saved', + portSettings?.activePort && portSettings.activePort !== parsed.port + ? `Restart the gateway for port ${parsed.port} to take effect.` + : `Next gateway start will use port ${parsed.port}.` + ); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setPortError(msg); + error('Failed to save port', msg); + } finally { + setSavingPort(false); + } + }; + + const handleResetPort = async () => { + setResettingPort(true); + try { + await invoke('reset_gateway_port'); + await loadPortSettings(); + success( + 'Reset to default', + portSettings && portSettings.activePort !== portSettings.defaultPort + ? `Restart the gateway for port ${portSettings.defaultPort} to take effect.` + : `Next gateway start will use port ${portSettings?.defaultPort ?? ''}.` + ); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + error('Failed to reset port', msg); + } finally { + setResettingPort(false); + } + }; + + const handleRestartGateway = async () => { + try { + const outcome = await gatewayControl.restart(); + await loadPortSettings(); + if (outcome.status === 'cancelled') return; + success( + 'Gateway restarted', + outcome.fellBackToDynamic + ? `Saved port was unavailable — now running on :${outcome.port} instead.` + : 'The new port is now active.' + ); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + error('Failed to restart gateway', msg); + } + }; + + useEffect(() => { + getMetaToolsEnabled() + .then((v) => setMetaToolsEnabledState(v)) + .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) => { + const previous = metaToolsEnabled; + setMetaToolsEnabledState(next); + try { + await setMetaToolsEnabled(next); + success( + next ? 'Self-management tools enabled' : 'Self-management tools disabled', + next + ? 'Connected MCP clients will see the mcpmux_* toolset on next list_tools.' + : 'mcpmux_* is hidden from connected MCP clients.' + ); + } catch (e) { + setMetaToolsEnabledState(previous); + error('Failed to save setting', e instanceof Error ? e.message : String(e)); + } + }; + + 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 () => { @@ -160,6 +336,7 @@ export function SettingsPage() { return ( <> toasts.find(t => t.id === id)?.onClose(id)} /> + {gatewayControl.ConfirmDialogElement}

    Settings

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

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

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

    + {portError} +

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

    + Restart required +

    +

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

    +
    + +
    + ) : null} +
    + )} +
    +
    + {/* Appearance Section */} @@ -305,6 +630,68 @@ export function SettingsPage() { + {/* Self-management meta tools — `mcpmux_*` namespace */} + + + + + Self-management tools (mcpmux_*) + + + 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. + + + +
    +
    + +
    + +

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

    +
    +
    + +
    +
    +
    + +
    + +

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

    +
    +
    + +
    + + +
    +
    + {/* Analytics Section */} @@ -337,6 +724,54 @@ export function SettingsPage() { + {/* Contribute & feedback — the single global "help make mcpmux + better" card. Mirrors the items in so power + users have quick access without digging into GitHub. */} + + + + + Contribute & feedback + + + mcpmux is open source. Request a server, report a bug, suggest a feature, or jump + straight to the source. + + + +
    + openExternal(CONTRIBUTE.requestServer())} + testId="contribute-request-server" + /> + openExternal(CONTRIBUTE.bug)} + testId="contribute-report-bug" + /> + openExternal(CONTRIBUTE.featureRequest)} + testId="contribute-feature-request" + /> + openExternal(CONTRIBUTE.repo)} + testId="contribute-open-github" + /> +
    +
    +
    + {/* Logs Section */} @@ -407,3 +842,36 @@ export function SettingsPage() { ); } + +/** + * Flat row used inside the Contribute card. Local to the Settings page — if + * we ever need this elsewhere, promote it into @mcpmux/ui. + */ +function ContributeRow({ + icon: Icon, + title, + subtitle, + onClick, + testId, +}: { + icon: React.ComponentType<{ className?: string }>; + title: string; + subtitle: string; + onClick: () => void; + testId?: string; +}) { + return ( + + ); +} diff --git a/apps/desktop/src/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 67e3e45f..ed4bd1ee 100644 --- a/apps/desktop/src/features/spaces/SpacesPage.tsx +++ b/apps/desktop/src/features/spaces/SpacesPage.tsx @@ -1,13 +1,5 @@ import { useState } from 'react'; -import { - Plus, - Trash2, - Loader2, - Check, - Search, - Layout, - AlertCircle, -} from 'lucide-react'; +import { Plus, Loader2, Search, Layout, AlertCircle, Pencil } from 'lucide-react'; import { Card, CardHeader, @@ -18,29 +10,23 @@ import { ToastContainer, useConfirm, } from '@mcpmux/ui'; -import { - useAppStore, - useActiveSpace, - useSpaces, - useIsLoading, -} from '@/stores'; -import { createSpace, deleteSpace, setActiveSpace as setActiveSpaceAPI } from '@/lib/api/spaces'; +import { useAppStore, useSpaces, useIsLoading } from '@/stores'; +import { createSpace } from '@/lib/api/spaces'; +import { SpacePanel } from './SpacePanel'; export function SpacesPage() { const spaces = useSpaces(); - const activeSpace = useActiveSpace(); const isLoading = useIsLoading('spaces'); - + // Store actions const addSpace = useAppStore((state) => state.addSpace); const removeSpace = useAppStore((state) => state.removeSpace); - const setActiveSpaceInStore = useAppStore((state) => state.setActiveSpace); + 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 @@ -48,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; @@ -70,47 +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 handleSetActive = async (id: string) => { - setIsActionLoading(id); - setError(null); - try { - await setActiveSpaceAPI(id); - setActiveSpaceInStore(id); - const activatedSpace = spaces.find(s => s.id === id); - success('Active space changed', `"${activatedSpace?.name || 'Space'}" is now active`); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - setError(msg); - showError('Failed to set active space', msg); - } finally { - setIsActionLoading(null); - } - }; + const selectedSpace = selectedSpaceId + ? spaces.find((s) => s.id === selectedSpaceId) ?? null + : null; // Filter spaces const filteredSpaces = spaces.filter(space => { @@ -198,72 +147,45 @@ export function SpacesPage() { ) : (
    {filteredSpaces.map((space) => { - const isActive = activeSpace?.id === space.id; - const isProcessing = isActionLoading === space.id; + const isSelected = selectedSpaceId === space.id; return ( - setSelectedSpaceId(space.id)} data-testid={`space-card-${space.id}`} > - {/* Header */} -
    +
    {space.icon || '🌐'}
    -

    - {space.name} -

    +

    {space.name}

    {space.description || 'No description'}

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

    + Which tools should this folder see? +

    +

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

    + +
    + +
    +
    + {shortenPath(payload.workspace_root)} +
    +
    +
    +
    + +
    +
    +
    + Space +
    +
    + + +
    +
    + +
    +
    + Tool set +
    + {loadingFs ? ( +
    + +
    + ) : featureSets.length === 0 ? ( +
    + No feature sets in this space yet. +
    + ) : ( +
    + {featureSets.map((fs) => ( + setSelectedFsId(fs.id)} + title={fs.name} + subtitle={fs.description || describeFs(fs)} + badge={isStarterFeatureSet(fs) ? 'starter' : undefined} + /> + ))} +
    + )} +
    +
    + +
    + {error && ( +
    + {error} +
    + )} + {/* "Not now" auto-sizes to its label; the primary action takes + the rest of the row. Equal flex-1 columns wrapped the longer + "Remember for this folder" text onto two lines. */} +
    + + +
    +

    + You can change this anytime in Workspaces. +

    +
    +
    +
    + ); +} + +function ChoiceRow({ + selected, + onSelect, + title, + subtitle, + badge, +}: { + selected: boolean; + onSelect: () => void; + title: string; + subtitle?: string; + badge?: string; +}) { + return ( + + ); +} + +function describeFs(fs: FeatureSet): string { + switch (fs.feature_set_type) { + case 'default': + return 'The auto-seeded fallback set for this space'; + case 'custom': + return `${fs.members.length} member${fs.members.length === 1 ? '' : 's'}`; + default: + return ''; + } +} diff --git a/apps/desktop/src/features/workspaces/WorkspacesPage.tsx b/apps/desktop/src/features/workspaces/WorkspacesPage.tsx new file mode 100644 index 00000000..f99d64e5 --- /dev/null +++ b/apps/desktop/src/features/workspaces/WorkspacesPage.tsx @@ -0,0 +1,2417 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { listen } from '@tauri-apps/api/event'; +import { open as openDialog } from '@tauri-apps/plugin-dialog'; +import { + AlertCircle, + Check, + ChevronDown, + ChevronRight, + FileText, + FolderOpen, + FolderSearch, + Layers, + Loader2, + MessageSquare, + Package, + Plus, + Radio, + RefreshCw, + Search, + Server as ServerIcon, + Trash2, + ToggleLeft, + Wrench, + X, +} from 'lucide-react'; +import { + Button, + Card, + CardContent, + useToast, + ToastContainer, + useConfirm, +} from '@mcpmux/ui'; +import { + createWorkspaceBinding, + deleteWorkspaceBinding, + getWorkspaceEffectiveFeatures, + listReportedWorkspaceRoots, + listWorkspaceBindings, + updateWorkspaceBinding, + validateWorkspaceRoot, + type EffectiveFeature, + type WorkspaceBinding, + type WorkspaceBindingInput, + type WorkspaceEffectiveFeatures, +} from '@/lib/api/workspaceBindings'; +import { + clearSessionOverrides, + listSessionOverrides, + overridesForWorkspace, + type SessionOverride, +} from '@/lib/api/sessionOverrides'; +import { + isStarterFeatureSet, + listFeatureSets, + type FeatureSet, +} from '@/lib/api/featureSets'; +import { useSpaces } from '@/stores'; +import type { Space } from '@/lib/api/spaces'; + +/** + * Workspaces page. + * + * Mirrors the Clients page's shape for visual consistency: + * • Header: title + subtitle + refresh, followed by a single large search. + * • Content: responsive cards grid inside a max-w-[2000px] wrapper. + * • Inspector: fixed-right side panel with a `fixed inset-0` backdrop- + * blur dim + `animate-in slide-in-from-right` entrance. + * + * Each card is a workspace entry, unioning bindings and live reported roots + * (dedup'd by normalized path). Status is conveyed with a corner dot + pill: + * • LIVE + unmapped → amber + * • LIVE + mapped → emerald + * • OFFLINE + mapped → neutral + */ + +type EntryKind = 'unmapped-live' | 'mapped-live' | 'mapped-offline'; +interface Entry { + id: string; + kind: EntryKind; + root: string; + binding: WorkspaceBinding | null; + isLive: boolean; +} +type Selected = { mode: 'new' } | { mode: 'entry'; id: string }; + +export function WorkspacesPage() { + const spaces = useSpaces(); + const [bindings, setBindings] = useState([]); + const [reportedRoots, setReportedRoots] = useState([]); + const [featureSets, setFeatureSets] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isRefreshing, setIsRefreshing] = useState(false); + const [error, setError] = useState(null); + const { toasts, success, error: showError, dismiss } = useToast(); + const { confirm, ConfirmDialogElement } = useConfirm(); + + const [selected, setSelected] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [filter, setFilter] = useState<'all' | 'live' | 'unmapped'>('all'); + + const loadData = useCallback(async () => { + setError(null); + try { + const [b, fs, roots] = await Promise.all([ + listWorkspaceBindings(), + listFeatureSets(), + listReportedWorkspaceRoots().catch(() => [] as string[]), + ]); + setBindings(b); + setFeatureSets(fs); + setReportedRoots(roots); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + }, []); + + useEffect(() => { + setIsLoading(true); + void loadData().finally(() => setIsLoading(false)); + }, [loadData]); + + // Refresh whenever something the table reflects changes outside the page: + // • `session-roots-changed` — a connected client newly reported a root. + // • `workspace-binding-changed` — a binding was created/updated/deleted + // by another surface (e.g. the new-workspace popup or the meta-tool). + // Without the binding listener, popup-driven saves leave this page showing + // the stale "UNMAPPED" badge until the user navigates away and back. + useEffect(() => { + const reload = () => { + void loadData(); + }; + const unRoots = listen('session-roots-changed', reload); + const unBinding = listen('workspace-binding-changed', reload); + return () => { + unRoots.then((fn) => fn()); + unBinding.then((fn) => fn()); + }; + }, [loadData]); + + const refresh = async () => { + setIsRefreshing(true); + try { + await loadData(); + } finally { + setIsRefreshing(false); + } + }; + + const bindingsByRoot = useMemo(() => { + const m = new Map(); + for (const b of bindings) m.set(b.workspace_root.toLowerCase(), b); + return m; + }, [bindings]); + const fsById = useMemo(() => { + const m = new Map(); + for (const f of featureSets) m.set(f.id, f); + return m; + }, [featureSets]); + const spaceById = useMemo(() => { + const m = new Map(); + for (const s of spaces) m.set(s.id, s); + return m; + }, [spaces]); + + /** + * The system's routing fallback: the `is_default` Space plus that Space's + * Default FeatureSet. Sessions whose reported root has no binding resolve + * here. We compute it once and pass it down so EntryCard can show the + * effective FS on every row, including unmapped ones. + */ + const fallback = useMemo(() => { + const space = spaces.find((s) => s.is_default) ?? spaces[0] ?? null; + if (!space) return null; + const fs = + featureSets.find( + (f) => f.space_id === space.id && isStarterFeatureSet(f) + ) ?? null; + return { space, fs }; + }, [spaces, featureSets]); + + /** + * Unified list: live-reported roots come first (unmapped amber, then + * mapped emerald), then persisted bindings whose clients aren't live. + */ + const entries: Entry[] = useMemo(() => { + const list: Entry[] = []; + const seen = new Set(); + for (const root of reportedRoots) { + const key = root.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + const binding = bindingsByRoot.get(key) ?? null; + list.push({ + id: binding?.id ?? `live:${root}`, + kind: binding ? 'mapped-live' : 'unmapped-live', + root, + binding, + isLive: true, + }); + } + for (const b of bindings) { + const key = b.workspace_root.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + list.push({ + id: b.id, + kind: 'mapped-offline', + root: b.workspace_root, + binding: b, + isLive: false, + }); + } + const rank: Record = { + 'unmapped-live': 0, + 'mapped-live': 1, + 'mapped-offline': 2, + }; + return list.sort((a, b) => { + const o = rank[a.kind] - rank[b.kind]; + return o !== 0 ? o : a.root.localeCompare(b.root); + }); + }, [bindings, bindingsByRoot, reportedRoots]); + + const filtered = useMemo(() => { + const q = searchQuery.trim().toLowerCase(); + return entries.filter((e) => { + if (filter === 'live' && !e.isLive) return false; + if (filter === 'unmapped' && e.kind !== 'unmapped-live') return false; + if (!q) return true; + const spaceName = e.binding ? spaceById.get(e.binding.space_id)?.name ?? '' : ''; + const fsNames = e.binding + ? e.binding.feature_set_ids + .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) + ); + }); + }, [entries, searchQuery, filter, spaceById, fsById]); + + const counts = useMemo(() => { + let live = 0; + let unmapped = 0; + for (const e of entries) { + if (e.isLive) live++; + if (e.kind === 'unmapped-live') unmapped++; + } + return { all: entries.length, live, unmapped }; + }, [entries]); + + const selectedEntry: Entry | null = + selected?.mode === 'entry' ? entries.find((e) => e.id === selected.id) ?? null : null; + const selectedIsNew = selected?.mode === 'new'; + const panelOpen = selected !== null; + + const handleCreate = async (input: WorkspaceBindingInput): Promise => { + const created = await createWorkspaceBinding(input); + setBindings((prev) => + [...prev, created].sort((a, b) => a.workspace_root.localeCompare(b.workspace_root)) + ); + success('Binding saved', created.workspace_root); + return created; + }; + + const handleUpdate = async (id: string, input: WorkspaceBindingInput) => { + const updated = await updateWorkspaceBinding(id, input); + setBindings((prev) => + prev + .map((b) => (b.id === id ? updated : b)) + .sort((a, b) => a.workspace_root.localeCompare(b.workspace_root)) + ); + success('Binding updated', updated.workspace_root); + }; + + const handleDelete = async (binding: WorkspaceBinding) => { + const ok = await confirm({ + title: 'Remove binding', + message: `Sessions matching "${binding.workspace_root}" will fall back to the default Space. You can recreate the binding anytime.`, + confirmLabel: 'Remove', + variant: 'danger', + }); + if (!ok) return; + try { + await deleteWorkspaceBinding(binding.id); + setBindings((prev) => prev.filter((b) => b.id !== binding.id)); + setSelected(null); + success('Binding removed', binding.workspace_root); + } catch (e) { + showError('Failed to remove binding', e instanceof Error ? e.message : String(e)); + } + }; + + return ( +
    +
    +
    +
    +
    +

    + Workspaces +

    +

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

    +
    +
    + + +
    +
    + +
    +
    + + setSearchQuery(e.target.value)} + className="w-full pl-12 pr-4 py-3 text-base bg-[rgb(var(--surface))] border border-[rgb(var(--border))] rounded-xl focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-all" + data-testid="workspace-binding-search" + /> +
    + +
    +
    +
    + + {error && ( +
    +
    + {error} +
    +
    + )} + +
    +
    + {isLoading ? ( +
    + +
    + ) : filtered.length === 0 ? ( + 0} + hasFilter={searchQuery.length > 0 || filter !== 'all'} + onCreate={() => setSelected({ mode: 'new' })} + /> + ) : ( +
    + {filtered.map((entry) => { + const isSelected = + selected?.mode === 'entry' && selected.id === entry.id; + // For mapped entries: trust the binding. For unmapped: fall + // back to the system's default Space + its Default FS so + // every card answers "what tools does this folder see?". + const resolvedSpaceName = entry.binding + ? spaceById.get(entry.binding.space_id)?.name + : fallback?.space.name; + const resolvedFsName = entry.binding + ? formatFsList( + entry.binding.feature_set_ids.map( + (id) => fsById.get(id)?.name ?? id + ) + ) + : fallback?.fs?.name; + return ( + setSelected({ mode: 'entry', id: entry.id })} + /> + ); + })} +
    + )} +
    +
    + + {panelOpen && ( + <> +
    setSelected(null)} + /> + setSelected(null)} + onSubmit={async (input) => { + if (selectedEntry?.binding) { + await handleUpdate(selectedEntry.binding.id, input); + } else { + const created = await handleCreate(input); + setSelected({ mode: 'entry', id: created.id }); + } + }} + onDelete={async () => { + if (selectedEntry?.binding) await handleDelete(selectedEntry.binding); + }} + onError={(msg) => showError('Could not save', msg)} + /> + + )} + + + {ConfirmDialogElement} +
    + ); +} + +// --------------------------------------------------------------------------- +// Filter segmented control +// --------------------------------------------------------------------------- + +/** + * Render a list of FeatureSet names as a single string for display + * surfaces (cards, badges, panel headers) where a multi-FS binding has + * to fit on one line. Returns '' for empty input so callers can fall + * back to a placeholder. Drops empty/missing entries silently — they're + * already known to the caller as "fs not found", and there's nothing + * useful to show. + */ +function formatFsList(names: string[]): string { + return names.filter((n) => n && n.length > 0).join(' + '); +} + +/** + * Structural equality between two binding inputs. The autosave effect + * uses this to skip writes when the user re-toggled their way back to + * the last-saved state — avoids spamming `WorkspaceBindingChanged` for + * a no-op edit. `feature_set_ids` order matters (it's the operator- + * chosen render order, not just a set), so we compare positionally. + */ +function 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; + 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]); +} + +function SegmentedFilter({ + value, + onChange, + options, +}: { + value: T; + onChange: (v: T) => void; + options: Array<{ value: T; label: string; count?: number }>; +}) { + return ( +
    + {options.map((o) => { + const active = o.value === value; + return ( + + ); + })} +
    + ); +} + +// --------------------------------------------------------------------------- +// Entry card — matches Clients page card anatomy (56×56 icon, 3xl size, chips) +// --------------------------------------------------------------------------- + +function EntryCard({ + entry, + spaceName, + fsName, + selected, + onClick, +}: { + entry: Entry; + spaceName: string | undefined; + fsName: string | undefined; + selected: boolean; + onClick: () => void; +}) { + const tone = + entry.kind === 'unmapped-live' + ? 'amber' + : entry.kind === 'mapped-live' + ? 'emerald' + : 'neutral'; + + return ( + + +
    +
    +
    + +
    + {entry.isLive && ( + + )} +
    +
    +
    + {entry.kind === 'unmapped-live' && Unmapped} + {entry.kind === 'mapped-offline' && Offline} + {entry.kind === 'mapped-live' && Live} +
    +

    + {entryDisplayTitle(entry)} +

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

    + {entry.root} +

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

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

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

    + {entry.root} +

    + )} + {isNew && ( +

    {subtitle}

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

    No features configured in this feature set yet.

    +
    + ) : ( +
    +
    + {groups.map((g) => ( + toggleServer(g.server_id)} + /> + ))} +
    +
    + )} +
    + ); +} + +function ServerGroupRow({ + group, + open, + onToggle, +}: { + group: ServerGroup; + open: boolean; + onToggle: () => void; +}) { + const issue = serverStatusIssue(group.server_status); + const availableCount = group.mapped - group.unavailable_mapped; + // Badge denominator is the server's *total* feature count in the Space, + // not the mapped count — the user wants to see "3 of 10 cloudflare-docs + // tools are in this FS" rather than "3 of 3 mapped tools work". + const denominator = group.server_total > 0 ? group.server_total : group.mapped; + const allAvailable = group.mapped > 0 && availableCount === group.mapped; + const someAvailable = availableCount > 0 && availableCount < group.mapped; + const noneAvailable = availableCount === 0; + + // Strip reverse-DNS prefix so display reads "cloudflare-bindings" not + // "com.cloudflare-bindings". The full id stays in title for hover. + const prefix = group.server_alias.includes('.') + ? group.server_alias.split('.', 2)[0] + : null; + const displayName = prefix + ? group.server_alias.slice(prefix.length + 1) + : group.server_alias; + + return ( +
    +
    +
    + {open ? ( + + ) : ( + + )} + +
    +
    + {prefix && ( + + {prefix}. + + )} + + {displayName} + + + {group.mapped}/{denominator} + + {issue && ( + + {issue.label} + + )} +
    + {/* Per-server progress bar — same treatment as FeatureSetPanel's + server rows so the visual language is consistent. */} +
    +
    0 + ? `${(availableCount / group.mapped) * 100}%` + : '0%', + }} + /> +
    +
    +
    +
    + + {open && ( +
    + + + +
    + )} +
    + ); +} + +/** + * Indented feature rows inside an expanded server group. Mirrors the + * FeatureSetPanel feature rows: type icon + name + type pill + + * description, indented `pl-12` to align under the server icon. + */ +function FeatureSubGroup({ + label, + items, +}: { + label: 'tool' | 'prompt' | 'resource'; + items: EffectiveFeature[]; +}) { + if (items.length === 0) return null; + return ( + <> + {items.map((item) => ( +
    + {getFeatureTypeIcon(label)} +
    +
    + + {item.display_name || item.feature_name} + + + {label} + + {!item.available && ( + + unavailable + + )} +
    + {item.description && ( +

    + {item.description} +

    + )} +
    +
    + ))} + + ); +} + +function getFeatureTypeIcon(type: 'tool' | 'prompt' | 'resource') { + switch (type) { + case 'tool': + return ; + case 'prompt': + return ; + case 'resource': + return ; + } +} + +function getFeatureTypeColor(type: 'tool' | 'prompt' | 'resource'): string { + switch (type) { + case 'tool': + return 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300'; + case 'prompt': + return 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300'; + case 'resource': + return 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300'; + } +} + +/** + * Translate a server status into a small UI annotation — but ONLY when + * the status warrants attention. The healthy "connected" path returns + * null so the row stays quiet. + */ +function serverStatusIssue( + status: EffectiveFeature['server_status'] +): { label: string; tone: 'red' | 'amber' | 'muted' } | null { + switch (status) { + case 'connected': + return null; + case 'connecting': + return { label: 'Connecting', tone: 'amber' }; + case 'authenticating': + return { label: 'Authenticating', tone: 'amber' }; + case 'refreshing': + return { label: 'Refreshing', tone: 'amber' }; + case 'auth_required': + return { label: 'Auth needed', tone: 'amber' }; + case 'error': + return { label: 'Error', tone: 'red' }; + case 'disconnected': + return { label: 'Disconnected', tone: 'muted' }; + case 'unknown': + default: + return { label: 'Offline', tone: 'muted' }; + } +} + +// --------------------------------------------------------------------------- +// Binding form +// --------------------------------------------------------------------------- + +function BindingForm({ + mode, + spaces, + featureSets, + initial, + prefillRoot, + onCancel, + onSubmit, + onError, + onSaveStatusChange, +}: { + mode: 'create' | 'edit' | 'create-from-live'; + spaces: Space[]; + featureSets: FeatureSet[]; + initial?: WorkspaceBinding | null; + prefillRoot?: string; + onCancel: () => void; + onSubmit: (input: WorkspaceBindingInput) => Promise; + onError: (message: string) => void; + /** Surfaced upward so the section header can show a Saving / Saved pill. */ + onSaveStatusChange?: (status: SaveStatus) => void; +}) { + const defaultSpaceId = useMemo( + () => spaces.find((s) => s.is_default)?.id ?? spaces[0]?.id ?? '', + [spaces] + ); + + const rootRef = useRef(null); + const [root, setRoot] = useState(initial?.workspace_root ?? prefillRoot ?? ''); + const [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 + // can rank a "primary" FS first; the resolver itself doesn't care. + const [fsIds, setFsIds] = useState(initial?.feature_set_ids ?? []); + const [fsSearch, setFsSearch] = useState(''); + const [submitting, setSubmitting] = useState(false); + const isEdit = mode === 'edit'; + + // Live validation of the workspace_root field. Edit + create-from-live + // modes already have a trusted root (edit: the persisted one; create-from- + // live: came from the MCP client), so we skip validation for those — only + // manual creates / edits to the path need the live check. + const [rootValidation, setRootValidation] = useState< + | { state: 'idle' } + | { state: 'checking' } + | { state: 'ok'; normalized: string } + | { state: 'error'; reason: string } + >({ state: 'idle' }); + const validationSeq = useRef(0); + + const rootEditable = mode !== 'create-from-live'; + + useEffect(() => { + if (!rootEditable) { + setRootValidation({ state: 'ok', normalized: root }); + return; + } + if (!root.trim()) { + setRootValidation({ state: 'idle' }); + return; + } + // Debounce a little so we don't hammer the IPC on every keystroke. + const seq = ++validationSeq.current; + setRootValidation({ state: 'checking' }); + const handle = setTimeout(() => { + void validateWorkspaceRoot(root) + .then((normalized) => { + if (validationSeq.current !== seq) return; + setRootValidation({ state: 'ok', normalized }); + }) + .catch((e: unknown) => { + if (validationSeq.current !== seq) return; + const reason = typeof e === 'string' ? e : String(e); + setRootValidation( + reason === '' + ? { state: 'idle' } + : { state: 'error', reason } + ); + }); + }, 180); + return () => clearTimeout(handle); + }, [root, rootEditable]); + + useEffect(() => { + if (mode === 'create') rootRef.current?.focus(); + }, [mode]); + + const availableFs = useMemo( + () => featureSets.filter((f) => f.space_id === spaceId && !f.is_deleted), + [featureSets, spaceId] + ); + + // Filter the available FS list by the search query. Search runs against + // name + description, case-insensitive — matches the typeahead expectation + // most operators bring from the FeatureSets editor. + const filteredFs = useMemo(() => { + const q = fsSearch.trim().toLowerCase(); + if (!q) return availableFs; + return availableFs.filter((f) => { + if (f.name.toLowerCase().includes(q)) return true; + if (f.description?.toLowerCase().includes(q)) return true; + return false; + }); + }, [availableFs, fsSearch]); + + // When the Space changes, drop selections that aren't in the new Space's + // FS list. Reseed an empty selection with the default FS so the operator + // doesn't have to click anything for a "single-FS, default" binding. + useEffect(() => { + if (availableFs.length === 0) { + if (fsIds.length > 0) setFsIds([]); + return; + } + const validIds = new Set(availableFs.map((f) => f.id)); + const filtered = fsIds.filter((id) => validIds.has(id)); + if (filtered.length === 0) { + const fallback = availableFs.find(isStarterFeatureSet) ?? availableFs[0]; + setFsIds([fallback.id]); + } else if (filtered.length !== fsIds.length) { + setFsIds(filtered); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [availableFs]); + + const toggleFs = (id: string) => { + setFsIds((prev) => + prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id] + ); + }; + + const canSubmit = + !submitting && + !!spaceId && + fsIds.length > 0 && + (rootValidation.state === 'ok' || !rootEditable); + + const handleSubmit = async () => { + if (!root.trim()) { + onError('Workspace root is required.'); + return; + } + if (rootValidation.state === 'error') { + onError(rootValidation.reason); + return; + } + if (!spaceId) { + onError('Pick a Space.'); + return; + } + if (fsIds.length === 0) { + onError('Pick at least one feature set.'); + return; + } + setSubmitting(true); + try { + await onSubmit({ + workspace_root: root.trim(), + label: label.trim() || null, + space_id: spaceId, + feature_set_ids: fsIds, + }); + } catch (e) { + onError(e instanceof Error ? e.message : String(e)); + } finally { + setSubmitting(false); + } + }; + + // ---------- Autosave (edit mode) ----------------------------------------- + // + // Debounced (1500 ms) so a burst of FS-toggle clicks coalesces into one + // save instead of firing N WorkspaceBindingChanged events back-to-back. + // Dedupe is against the **last successfully-saved** payload, not just + // `initial` — so re-toggling A → B → A is a no-op (back to last saved), + // and once a save lands the next idle window doesn't re-save the same + // values. + // + // Critical: the debounce timer is cleared on dependency change but the + // **pending payload survives panel close**. If the user edits then + // closes before the debounce fires, the unmount handler flushes the + // save synchronously to Tauri — the IPC goes out before React tears + // the component down, and the save completes in the background. + const saveSeqRef = useRef(0); + const savedTimerRef = useRef | null>(null); + // Snapshot of the last payload we successfully wrote. `null` means + // "never saved during this panel session" — fall back to `initial` for + // dedupe in that case. + const lastSavedRef = useRef(null); + // The most recent payload the user produced that has NOT yet been + // committed. Cleared on successful save. The unmount handler reads + // this to decide whether to flush. + const pendingPayloadRef = useRef(null); + // Latest closures via ref so the unmount-only effect's empty-deps + // cleanup can still call the freshest handlers — closing the panel + // mid-edit must use the parent's *current* `onSubmit`, not whatever it + // captured on first mount. + const onSubmitRef = useRef(onSubmit); + const onSaveStatusChangeRef = useRef(onSaveStatusChange); + useEffect(() => { + onSubmitRef.current = onSubmit; + onSaveStatusChangeRef.current = onSaveStatusChange; + }, [onSubmit, onSaveStatusChange]); + + useEffect(() => { + if (!isEdit || !initial) return; + if (!canSubmit) return; + + const candidate: WorkspaceBindingInput = { + workspace_root: root.trim(), + label: label.trim() || null, + space_id: spaceId, + feature_set_ids: fsIds, + }; + + // Dedupe baseline: last-saved if we've saved during this session, + // otherwise the initial payload from when the panel opened. + const baseline = lastSavedRef.current ?? { + workspace_root: initial.workspace_root, + label: initial.label, + space_id: initial.space_id, + feature_set_ids: initial.feature_set_ids, + }; + if (sameBindingInput(candidate, baseline)) { + pendingPayloadRef.current = null; + return; + } + + pendingPayloadRef.current = candidate; + const seq = ++saveSeqRef.current; + onSaveStatusChange?.({ kind: 'idle' }); + const handle = setTimeout(async () => { + if (saveSeqRef.current !== seq) return; + onSaveStatusChange?.({ kind: 'saving' }); + setSubmitting(true); + try { + await onSubmit(candidate); + if (saveSeqRef.current !== seq) return; + lastSavedRef.current = candidate; + pendingPayloadRef.current = null; + onSaveStatusChange?.({ kind: 'saved' }); + if (savedTimerRef.current) clearTimeout(savedTimerRef.current); + savedTimerRef.current = setTimeout(() => { + onSaveStatusChange?.({ kind: 'idle' }); + }, 1800); + } catch (e) { + if (saveSeqRef.current !== seq) return; + const msg = e instanceof Error ? e.message : String(e); + onSaveStatusChange?.({ kind: 'error', message: msg }); + onError(msg); + } finally { + setSubmitting(false); + } + }, 1500); + return () => clearTimeout(handle); + }, [ + isEdit, + initial, + root, + label, + spaceId, + fsIds, + canSubmit, + onSubmit, + onError, + onSaveStatusChange, + ]); + + // Unmount-only flush. If a save was scheduled but the timer hasn't + // fired by the time the user closes the panel, fire it now so their + // edits aren't silently dropped. Empty-deps so this only runs on + // unmount, not on every dep change of the autosave effect above. + useEffect(() => { + return () => { + const pending = pendingPayloadRef.current; + if (!pending) return; + // Fire-and-forget. Tauri's `invoke` posts the IPC message to the + // Rust side immediately; the React tree can unmount in parallel + // and the save still completes. Bump the seq so any in-flight + // debounced save from before the close is discarded if it lands. + saveSeqRef.current += 1; + onSaveStatusChangeRef.current?.({ kind: 'saving' }); + onSubmitRef + .current(pending) + .then(() => { + onSaveStatusChangeRef.current?.({ kind: 'saved' }); + }) + .catch((e) => { + // Parent's toast bridge is gone with the panel — fall back to + // the console so the failure isn't silent in dev. + console.warn( + '[workspace-binding] flush-on-close save failed:', + e instanceof Error ? e.message : String(e) + ); + }); + }; + }, []); + + const submitLabel = + mode === 'create-from-live' ? 'Save binding' : 'Create binding'; + + 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" + /> + + + +
    + setRoot(e.target.value)} + readOnly={!rootEditable} + placeholder="Pick a folder, or paste an absolute path" + className={[ + 'flex-1 min-w-0 px-3 py-2 rounded-lg text-sm font-mono focus:outline-none focus:ring-2', + !rootEditable + ? 'bg-[rgb(var(--background))] border border-[rgb(var(--border-subtle))] text-[rgb(var(--muted))] cursor-not-allowed focus:ring-primary-500' + : rootValidation.state === 'error' + ? 'bg-[rgb(var(--background))] border border-red-500/60 focus:ring-red-500 focus:border-red-500' + : 'bg-[rgb(var(--background))] border border-[rgb(var(--border))] focus:ring-primary-500 focus:border-primary-500', + ].join(' ')} + data-testid="workspace-binding-root-input" + /> + {rootEditable && ( + + )} +
    + +
    + + + ({ + value: s.id, + label: s.is_default ? `${s.name} · default` : s.name, + icon: s.icon ?? undefined, + }))} + testId="workspace-binding-space" + /> + + + 1 + ? `Feature sets (${fsIds.length} selected)` + : 'Feature set' + } + hint="Which tools this folder sees. Pick one or compose several — selected sets union into a single allow list." + > + {!spaceId ? ( +

    + Pick a Space first. +

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

    + No feature sets in that Space yet. +

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

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

    + ) : ( + filteredFs.map((f) => { + const isSelected = fsIds.includes(f.id); + const order = isSelected ? fsIds.indexOf(f.id) + 1 : null; + return ( + + ); + }) + )} +
    + {fsSearch && filteredFs.length > 0 && filteredFs.length < availableFs.length && ( +
    + {filteredFs.length} of {availableFs.length} shown +
    + )} +
    + )} +
    + + {!isEdit && ( +
    + + +
    + )} +
    + ); +} + +/** + * Inline hint under the workspace_root input. Three visual states: + * • idle — neutral hint about normalization rules + * • checking — subtle spinner + "Checking…" + * • ok — if the normalized form differs from the raw input, + * show it as a preview so the user sees exactly what + * gets saved (drive letter lowercased, URI scheme + * stripped, slashes flipped, etc.). Otherwise silent. + * • error — red message with the server's explanation + */ +function RootValidationHint({ + state, + editable, + originalValue, +}: { + state: + | { state: 'idle' } + | { state: 'checking' } + | { state: 'ok'; normalized: string } + | { state: 'error'; reason: string }; + editable: boolean; + originalValue: string; +}) { + if (!editable) { + return ( +

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

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

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

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

    + + Checking… +

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

    + {state.reason} +

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

    + Ready to save. +

    + ); + } + return ( +

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

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

    {hint}

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

    No workspaces match

    +

    + Try adjusting the search or filter. +

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

    Nothing to show yet

    +

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

    + +
    +
    + ); +} diff --git a/apps/desktop/src/features/workspaces/index.ts b/apps/desktop/src/features/workspaces/index.ts new file mode 100644 index 00000000..c3f2429d --- /dev/null +++ b/apps/desktop/src/features/workspaces/index.ts @@ -0,0 +1,2 @@ +export { WorkspacesPage } from './WorkspacesPage'; +export { WorkspaceBindingSheet } from './WorkspaceBindingSheet'; diff --git a/apps/desktop/src/hooks/useDataSync.ts b/apps/desktop/src/hooks/useDataSync.ts index 5d59f144..140ce8e1 100644 --- a/apps/desktop/src/hooks/useDataSync.ts +++ b/apps/desktop/src/hooks/useDataSync.ts @@ -1,6 +1,6 @@ import { useEffect } from 'react'; import { useAppStore } from '@/stores/appStore'; -import { listSpaces, getActiveSpace } from '@/lib/api/spaces'; +import { listSpaces } from '@/lib/api/spaces'; import { refreshOAuthTokensOnStartup } from '@/lib/api/gateway'; /** @@ -10,7 +10,6 @@ import { refreshOAuthTokensOnStartup } from '@/lib/api/gateway'; export function useDataSync() { const setSpaces = useAppStore((state) => state.setSpaces); const setLoading = useAppStore((state) => state.setLoading); - const setActiveSpaceInStore = useAppStore((state) => state.setActiveSpace); useEffect(() => { async function syncData() { @@ -26,27 +25,13 @@ export function useDataSync() { console.error('[useDataSync] OAuth token refresh failed (non-fatal):', error); } - // Fetch spaces and active space from backend console.log('[useDataSync] Calling listSpaces...'); const spaces = await listSpaces(); - console.log('[useDataSync] listSpaces returned:', spaces.length, 'spaces', spaces); + console.log('[useDataSync] listSpaces returned:', spaces.length, 'spaces'); - console.log('[useDataSync] Calling getActiveSpace...'); - const activeSpace = await getActiveSpace(); - console.log('[useDataSync] getActiveSpace returned:', activeSpace); - - console.log('[useDataSync] Setting spaces in store...'); + // setSpaces handles validating viewSpaceId and falling back to the + // is_default space when the persisted view space doesn't exist. setSpaces(spaces); - - // Set active space from backend - if (activeSpace) { - console.log('[useDataSync] Setting active space:', activeSpace.id); - setActiveSpaceInStore(activeSpace.id); - } else if (spaces.length > 0) { - // If no active space but we have spaces, set the first one - console.log('[useDataSync] No active space, using first space:', spaces[0].id); - setActiveSpaceInStore(spaces[0].id); - } } catch (error) { console.error('[useDataSync] Failed to sync:', error); } finally { @@ -56,5 +41,5 @@ export function useDataSync() { } syncData(); - }, [setSpaces, setLoading, setActiveSpaceInStore]); + }, [setSpaces, setLoading]); } diff --git a/apps/desktop/src/hooks/useSpaces.ts b/apps/desktop/src/hooks/useSpaces.ts index 3e3823b1..8ed8b764 100644 --- a/apps/desktop/src/hooks/useSpaces.ts +++ b/apps/desktop/src/hooks/useSpaces.ts @@ -1,32 +1,28 @@ import { useState, useEffect, useCallback } from 'react'; -import { - Space, - listSpaces, - createSpace, - deleteSpace, - setActiveSpace, - getActiveSpace, -} from '@/lib/api/spaces'; +import { Space, listSpaces, createSpace, deleteSpace } from '@/lib/api/spaces'; /** * Hook for managing spaces (isolated environments). + * + * Note: there's no longer an "active space" concept — gateway routing is + * decided per reported workspace root via WorkspaceBinding, with the + * `is_default` Space as the fallback. The desktop UI still tracks which + * space the user is *viewing* via `viewSpaceId` in the Zustand store. */ export function useSpaces() { const [spaces, setSpaces] = useState([]); - const [activeSpace, setActiveSpaceState] = useState(null); + const [defaultSpace, setDefaultSpace] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - // Refresh the list of spaces const refresh = useCallback(async () => { try { setLoading(true); setError(null); - const [spacesList, active] = await Promise.all([listSpaces(), getActiveSpace()]); - + const spacesList = await listSpaces(); setSpaces(spacesList); - setActiveSpaceState(active); + setDefaultSpace(spacesList.find((s) => s.is_default) ?? null); } catch (e) { const message = e instanceof Error ? e.message : String(e); setError(message); @@ -36,12 +32,10 @@ export function useSpaces() { } }, []); - // Load spaces on mount useEffect(() => { refresh(); }, [refresh]); - // Create a new space const create = useCallback( async (name: string, icon?: string): Promise => { const space = await createSpace(name, icon); @@ -51,7 +45,6 @@ export function useSpaces() { [refresh] ); - // Delete a space const remove = useCallback( async (id: string): Promise => { await deleteSpace(id); @@ -60,23 +53,13 @@ export function useSpaces() { [refresh] ); - // Set the active space - const setActive = useCallback( - async (id: string): Promise => { - await setActiveSpace(id); - await refresh(); - }, - [refresh] - ); - return { spaces, - activeSpace, + defaultSpace, loading, error, refresh, create, remove, - setActive, }; } diff --git a/apps/desktop/src/index.css b/apps/desktop/src/index.css index 54d48ea8..0dbf714b 100644 --- a/apps/desktop/src/index.css +++ b/apps/desktop/src/index.css @@ -194,10 +194,13 @@ } @layer utilities { - .drag-region { + /* Tauri 2 custom titlebar — see https://v2.tauri.app/learn/window-customization/ */ + [data-tauri-drag-region] { + app-region: drag; -webkit-app-region: drag; } .no-drag { + app-region: no-drag; -webkit-app-region: no-drag; } diff --git a/apps/desktop/src/lib/api/clients.ts b/apps/desktop/src/lib/api/clients.ts index d24192f5..240ebd59 100644 --- a/apps/desktop/src/lib/api/clients.ts +++ b/apps/desktop/src/lib/api/clients.ts @@ -1,129 +1,45 @@ import { invoke } from '@tauri-apps/api/core'; /** - * A Client represents an AI assistant (Cursor, VS Code, Claude, etc.) + * A Client represents an AI assistant (Cursor, VS Code, Claude, etc.). + * + * Identity only — routing is decided at session time by the gateway's + * FeatureSetResolver (WorkspaceBinding → Space default FS), not per client. */ export interface Client { id: string; name: string; client_type: string; - connection_mode: 'locked' | 'follow_active' | 'ask_on_change'; - locked_space_id: string | null; - grants: Record; // space_id -> feature_set_ids last_seen: string | null; } -/** - * Input for creating a client. - */ +/** Input for creating a client. */ export interface CreateClientInput { name: string; client_type: string; - connection_mode: string; - locked_space_id?: string; -} - -/** - * Input for updating client grants. - */ -export interface UpdateGrantsInput { - space_id: string; - feature_set_ids: string[]; } -/** - * List all clients. - */ +/** List all clients. */ export async function listClients(): Promise { return invoke('list_clients'); } -/** - * Get a client by ID. - */ +/** Get a client by ID. */ export async function getClient(id: string): Promise { return invoke('get_client', { id }); } -/** - * Create a new client. - */ +/** Create a new client. */ export async function createClient(input: CreateClientInput): Promise { return invoke('create_client', { input }); } -/** - * Delete a client. - */ +/** Delete a client. */ export async function deleteClient(id: string): Promise { return invoke('delete_client', { id }); } -/** - * Update client grants for a specific space (replaces existing). - */ -export async function updateClientGrants( - clientId: string, - input: UpdateGrantsInput -): Promise { - return invoke('update_client_grants', { clientId, input }); -} - -/** - * Get grants for a client in a specific space. - */ -export async function getClientGrants( - clientId: string, - spaceId: string -): Promise { - return invoke('get_client_grants', { clientId, spaceId }); -} - -/** - * Get all grants for a client across all spaces. - */ -export async function getAllClientGrants( - clientId: string -): Promise> { - return invoke('get_all_client_grants', { clientId }); -} - -/** - * Grant a specific feature set to a client. - */ -export async function grantFeatureSetToClient( - clientId: string, - spaceId: string, - featureSetId: string -): Promise { - return invoke('grant_feature_set_to_client', { clientId, spaceId, featureSetId }); -} - -/** - * Revoke a specific feature set from a client. - */ -export async function revokeFeatureSetFromClient( - clientId: string, - spaceId: string, - featureSetId: string -): Promise { - return invoke('revoke_feature_set_from_client', { clientId, spaceId, featureSetId }); -} - -/** - * Update client connection mode. - */ -export async function updateClientMode( - clientId: string, - mode: string, - lockedSpaceId?: string -): Promise { - return invoke('update_client_mode', { clientId, mode, lockedSpaceId }); -} - -/** - * Initialize preset clients (Cursor, VS Code, Claude). - */ +/** Initialize preset clients (Cursor, VS Code, Claude). */ export async function initPresetClients(): Promise { return invoke('init_preset_clients'); } diff --git a/apps/desktop/src/lib/api/featureSets.ts b/apps/desktop/src/lib/api/featureSets.ts index 95ef87d0..4c1a750f 100644 --- a/apps/desktop/src/lib/api/featureSets.ts +++ b/apps/desktop/src/lib/api/featureSets.ts @@ -1,9 +1,30 @@ import { invoke } from '@tauri-apps/api/core'; /** - * FeatureSet type determines how features are resolved. + * FeatureSet type. + * + * - `default`: auto-created per Space. Fallback when no WorkspaceBinding matches. + * - `custom`: user-defined. */ -export type FeatureSetType = 'all' | 'default' | 'server-all' | 'custom'; +/** + * `starter` is the auto-seeded FS that comes with each Space. It has no + * special routing role under resolver v3 — bindings and per-client grants + * pick FeatureSets explicitly. The legacy `'default'` value is accepted on + * read because migration 013 rewrites stored rows lazily and a stale fetch + * could still surface it; new writes use `'starter'`. + */ +export type FeatureSetType = 'starter' | 'default' | 'custom'; + +/** + * Is this FeatureSet the auto-seeded "Starter" for its Space? Returns + * `true` for both the new `'starter'` value and the legacy `'default'` + * — migration 013 rewrites rows in-place but a stale read could still + * surface the old value. Use this everywhere instead of comparing the + * type literal directly so the transition window is invisible to UI. + */ +export function isStarterFeatureSet(fs: { feature_set_type: FeatureSetType }): boolean { + return fs.feature_set_type === 'starter' || fs.feature_set_type === 'default'; +} /** * Member type in a feature set. @@ -24,6 +45,7 @@ export interface FeatureSetMember { member_type: MemberType; member_id: string; mode: MemberMode; + surfaced?: boolean; } /** @@ -68,6 +90,7 @@ export interface AddMemberInput { member_type: MemberType; member_id: string; mode?: MemberMode; + surfaced?: boolean; } /** @@ -105,24 +128,6 @@ export async function deleteFeatureSet(id: string): Promise { return invoke('delete_feature_set', { id }); } -/** - * Get builtin feature sets for a space. - */ -export async function getBuiltinFeatureSets(spaceId: string): Promise { - return invoke('get_builtin_feature_sets', { spaceId }); -} - -/** - * Ensure a server-all featureset exists for a server in a space. - */ -export async function ensureServerAllFeatureSet( - spaceId: string, - serverId: string, - serverName: string -): Promise { - return invoke('ensure_server_all_feature_set', { spaceId, serverId, serverName }); -} - /** * Get a feature set with its members. */ diff --git a/apps/desktop/src/lib/api/gateway.ts b/apps/desktop/src/lib/api/gateway.ts index fd3f0604..4c26cf15 100644 --- a/apps/desktop/src/lib/api/gateway.ts +++ b/apps/desktop/src/lib/api/gateway.ts @@ -23,10 +23,79 @@ export async function getGatewayStatus(spaceId?: string): Promise } /** - * Start the gateway server. + * Probe result for a proposed gateway start. + * + * `source` tells the UI which tier the preferred port came from so it can + * phrase the prompt correctly ("your configured port" vs "the default port"). */ -export async function startGateway(port?: number): Promise { - return invoke('start_gateway', { port }); +export interface GatewayStartProbe { + preferredPort: number; + preferredAvailable: boolean; + source: 'override' | 'configured' | 'default'; +} + +/** + * Ask the backend whether the gateway can start on its preferred port. + * Does not start anything — used by the UI to decide whether to prompt. + */ +export async function probeGatewayStart(port?: number): Promise { + return invoke('probe_gateway_start', { port }); +} + +/** + * Auto-start port conflict raised during app launch. When non-null, the UI + * must prompt the user before the gateway will bind. + */ +export interface PendingPortConflict { + preferredPort: number; + source: 'configured' | 'default'; +} + +/** + * Atomically read AND clear the deferred auto-start port conflict. + * + * "Take" semantics — only the first caller gets the conflict; subsequent + * calls return null. Prevents duplicate prompts under React StrictMode's + * double-mount. + */ +export async function takePendingPortConflict(): Promise { + return invoke('take_pending_port_conflict'); +} + +/** + * Error marker the backend returns when the preferred port is busy and + * `allowDynamicFallback` is false. Shape: `PORT_IN_USE::`. + */ +export interface PortInUseError { + kind: 'PortInUse'; + port: number; + source: 'override' | 'configured' | 'default'; +} + +/** Parse the `PORT_IN_USE::` sentinel the backend emits. */ +export function parsePortInUseError(err: unknown): PortInUseError | null { + const msg = err instanceof Error ? err.message : typeof err === 'string' ? err : ''; + const match = /^PORT_IN_USE:(\d+):(override|configured|default)$/.exec(msg); + if (!match) return null; + return { + kind: 'PortInUse', + port: Number(match[1]), + source: match[2] as PortInUseError['source'], + }; +} + +/** + * Start the gateway server. Strict by default — pass `allowDynamicFallback` + * to let the gateway pick a dynamic port when the preferred one is taken. + */ +export async function startGateway(opts?: { + port?: number; + allowDynamicFallback?: boolean; +}): Promise { + return invoke('start_gateway', { + port: opts?.port, + allowDynamicFallback: opts?.allowDynamicFallback, + }); } /** @@ -37,10 +106,16 @@ export async function stopGateway(): Promise { } /** - * Restart the gateway server. + * Restart the gateway server. Same semantics as `startGateway`. */ -export async function restartGateway(): Promise { - return invoke('restart_gateway'); +export async function restartGateway(opts?: { + port?: number; + allowDynamicFallback?: boolean; +}): Promise { + return invoke('restart_gateway', { + port: opts?.port, + allowDynamicFallback: opts?.allowDynamicFallback, + }); } /** @@ -125,21 +200,33 @@ export interface OAuthClient { metadata_url?: string | null; // URL where metadata was fetched metadata_cached_at?: string | null; // When we last fetched metadata_cache_ttl?: number | null; // Cache duration in seconds - - // MCP client preferences - connection_mode: string; - locked_space_id: string | null; + last_seen: string | null; created_at: string; + + /** + * Sticky-positive bit: `true` once any session of this client declared + * the MCP `roots` capability. **Only meaningful when + * `roots_capability_known` is `true`** — for clients we haven't observed + * yet, this defaults to `false` and the UI must NOT render "Rootless" + * based on it alone. + */ + reports_roots: boolean; + + /** + * `true` once we've processed `notifications/initialized` for at least + * one session of this client. Until then the capability is **unknown** + * and the UI hides the badge entirely. Once known the badge resolves + * to either "Reports workspace" or "Rootless". + */ + roots_capability_known: boolean; } /** - * Update client settings request. + * Update client settings request. Only the display alias is editable. */ export interface UpdateClientRequest { client_alias?: string; - connection_mode?: 'follow_active' | 'locked' | 'ask_on_change'; - locked_space_id?: string | null; } /** @@ -166,6 +253,63 @@ export async function deleteOAuthClient(clientId: string): Promise { return invoke('delete_oauth_client', { clientId }); } +// ============================================================================= +// Per-client FeatureSet grants (rootless fallback path) +// ============================================================================= +// +// These grants only apply to clients that did NOT declare the MCP `roots` +// capability — Claude.ai web, ChatGPT, and similar rootless connectors. +// Roots-capable desktop clients (Cursor, VS Code, Claude Desktop) route via +// `WorkspaceBinding` and ignore these grants. +// +// Backed by the `client_grants` table (restored in migration 009). Writes +// emit a `ClientGrantChanged` domain event so MCPNotifier pushes +// `notifications/{tools,prompts,resources}/list_changed` to the client's +// connected peers without requiring a reconnect. + +/** + * Read the FeatureSet ids granted to a (client, space) pair. Empty array + * means the rootless fallback would deny — consumer should render the + * "no defaults configured" empty state. + */ +export async function getOAuthClientGrants( + clientId: string, + spaceId: string +): Promise { + return invoke('get_oauth_client_grants', { clientId, spaceId }); +} + +/** + * Grant a FeatureSet to an OAuth client in a space. Idempotent at the DB + * layer; always emits the change event so peers re-fetch. + */ +export async function grantOAuthClientFeatureSet( + clientId: string, + spaceId: string, + featureSetId: string +): Promise { + return invoke('grant_oauth_client_feature_set', { + clientId, + spaceId, + featureSetId, + }); +} + +/** + * Revoke a FeatureSet from an OAuth client in a space. + */ +export async function revokeOAuthClientFeatureSet( + clientId: string, + spaceId: string, + featureSetId: string +): Promise { + return invoke('revoke_oauth_client_feature_set', { + clientId, + spaceId, + featureSetId, + }); +} + /** * Result of bulk server connection. */ diff --git a/apps/desktop/src/lib/api/index.ts b/apps/desktop/src/lib/api/index.ts index 03bbbdb9..e20f78ee 100644 --- a/apps/desktop/src/lib/api/index.ts +++ b/apps/desktop/src/lib/api/index.ts @@ -8,3 +8,5 @@ export * from './clientInstall'; export * from './clients'; export * from './gateway'; export * from './serverManager'; +export * from './workspaceBindings'; +export * from './metaTools'; diff --git a/apps/desktop/src/lib/api/metaTools.ts b/apps/desktop/src/lib/api/metaTools.ts new file mode 100644 index 00000000..23df3b4b --- /dev/null +++ b/apps/desktop/src/lib/api/metaTools.ts @@ -0,0 +1,67 @@ +import { invoke } from '@tauri-apps/api/core'; + +/** An "always allow from (client, tool)" entry kept in the gateway's broker. */ +export interface MetaToolGrantEntry { + client_id: string; + tool_name: string; +} + +/** Audit row emitted on every `mcpmux_*` invocation. */ +export interface MetaToolAuditEvent { + client_id: string; + session_id: string | null; + tool_name: string; + /** "allow_once" | "always_for_this_session_and_client" | "deny" | "timeout" | "approval_required" | "rate_limited" | "invalid_args" | "read" | "error" */ + decision: string; + resolved_feature_set_id: string | null; + summary: string; + /** Populated by the Tauri bridge. */ + timestamp: string; +} + +/** List every session-scoped "always allow" entry in the gateway. */ +export async function listMetaToolGrants(): Promise { + return invoke('list_meta_tool_grants'); +} + +/** Revoke a single "always allow" entry. */ +export async function revokeMetaToolGrant( + clientId: string, + toolName: string +): Promise { + return invoke('revoke_meta_tool_grant', { clientId, toolName }); +} + +/** + * Read the master switch that controls whether `mcpmux_*` meta tools are + * advertised to connected MCP clients. Default ON. + */ +export async function getMetaToolsEnabled(): Promise { + return invoke('get_meta_tools_enabled'); +} + +/** Flip the master switch; takes effect on the next `list_tools` push. */ +export async function setMetaToolsEnabled(enabled: boolean): Promise { + return invoke('set_meta_tools_enabled', { enabled }); +} + +/** + * Respond to a pending approval request. Normally called by + * ``; exported here for tests and advanced flows. + */ +export async function respondToMetaToolApproval( + requestId: string, + clientId: string, + toolName: string, + decision: + | 'allow_once' + | 'always_for_this_session_and_client' + | 'deny' +): Promise { + return invoke('respond_to_meta_tool_approval', { + requestId, + clientId, + toolName, + decision, + }); +} diff --git a/apps/desktop/src/lib/api/oauthClients.ts b/apps/desktop/src/lib/api/oauthClients.ts deleted file mode 100644 index 898ab228..00000000 --- a/apps/desktop/src/lib/api/oauthClients.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * OAuth Client Grants API - * - * For managing feature set grants for OAuth/inbound clients (Cursor, VS Code, etc.) - */ - -import { invoke } from '@tauri-apps/api/core'; - -/** - * Get grants for an OAuth client in a specific space. - */ -export async function getOAuthClientGrants( - clientId: string, - spaceId: string -): Promise { - return invoke('get_oauth_client_grants', { clientId, spaceId }); -} - -/** - * Grant a feature set to an OAuth client in a specific space. - */ -export async function grantOAuthClientFeatureSet( - clientId: string, - spaceId: string, - featureSetId: string -): Promise { - return invoke('grant_oauth_client_feature_set', { clientId, spaceId, featureSetId }); -} - -/** - * Revoke a feature set from an OAuth client in a specific space. - */ -export async function revokeOAuthClientFeatureSet( - clientId: string, - spaceId: string, - featureSetId: string -): Promise { - return invoke('revoke_oauth_client_feature_set', { clientId, spaceId, featureSetId }); -} - -/** - * Resolved features for a client - */ -export interface ResolvedClientFeatures { - space_id: string; - feature_set_ids: string[]; - tools: Array<{ name: string; description?: string; server_id: string }>; - prompts: Array<{ name: string; description?: string; server_id: string }>; - resources: Array<{ name: string; description?: string; server_id: string }>; -} - -/** - * Get resolved features (tools/prompts/resources) for an OAuth client in a specific space. - * This resolves all feature sets granted to the client into actual features. - * - * The caller is responsible for determining which space to query: - * - For locked clients: pass the client's locked_space_id - * - For follow_active clients: pass the currently active space_id - * - * @param clientId - The OAuth client ID - * @param spaceId - The space ID to resolve features for (required) - */ -export async function getOAuthClientResolvedFeatures( - clientId: string, - spaceId: string -): Promise { - return invoke('get_oauth_client_resolved_features', { clientId, spaceId }); -} - diff --git a/apps/desktop/src/lib/api/registry.ts b/apps/desktop/src/lib/api/registry.ts index 4128f0ee..cce4a258 100644 --- a/apps/desktop/src/lib/api/registry.ts +++ b/apps/desktop/src/lib/api/registry.ts @@ -3,7 +3,13 @@ */ import { invoke } from '@tauri-apps/api/core'; -import type { RegistryCategory, ServerDefinition, InstalledServerState, UiConfig, HomeConfig } from '../../types/registry'; +import type { + RegistryCategory, + ServerDefinition, + InstalledServerState, + UiConfig, + HomeConfig, +} from '../../types/registry'; /** Discover all servers (definitions from all sources) */ export async function discoverServers(): Promise { @@ -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 315084fe..40e446e5 100644 --- a/apps/desktop/src/lib/api/spaces.ts +++ b/apps/desktop/src/lib/api/spaces.ts @@ -1,10 +1,14 @@ import { invoke } from '@tauri-apps/api/core'; /** - * A Space represents an isolated environment with its own credentials and server configs. + * A Space represents an isolated environment with its own credentials and + * server configs. Every Space has exactly one auto-seeded Default FeatureSet + * which is the routing fallback when no WorkspaceBinding matches. Exactly + * one Space carries `is_default = true` — that's the gateway's fallback + * when a session reports no root or its root has no binding. */ export interface Space { - id: string; // UUID string + id: string; name: string; icon: string | null; description: string | null; @@ -14,58 +18,40 @@ export interface Space { updated_at: string; } -/** - * List all spaces. - */ export async function listSpaces(): Promise { return invoke('list_spaces'); } -/** - * Get a space by ID. - */ export async function getSpace(id: string): Promise { return invoke('get_space', { id }); } -/** - * Create a new space. - */ export async function createSpace(name: string, icon?: string): Promise { return invoke('create_space', { name, icon }); } -/** - * Delete a space. - */ -export async function deleteSpace(id: string): Promise { - return invoke('delete_space', { id }); +/** Partial update payload for a Space. */ +export interface UpdateSpaceInput { + name?: string; + icon?: string; + description?: string; } /** - * Get the active (default) space. + * Update a Space's display metadata (name, icon, description). */ -export async function getActiveSpace(): Promise { - return invoke('get_active_space'); +export async function updateSpace(id: string, input: UpdateSpaceInput): Promise { + return invoke('update_space', { id, input }); } -/** - * Set the active space. - */ -export async function setActiveSpace(id: string): Promise { - return invoke('set_active_space', { id }); +export async function deleteSpace(id: string): Promise { + return invoke('delete_space', { id }); } -/** - * Read space configuration JSON file. - */ export async function readSpaceConfig(spaceId: string): Promise { return invoke('read_space_config', { spaceId }); } -/** - * Save space configuration JSON file. - */ export async function saveSpaceConfig(spaceId: string, content: string): Promise { return invoke('save_space_config', { spaceId, content }); } @@ -78,9 +64,6 @@ export async function removeServerFromConfig(spaceId: string, serverId: string): return invoke('remove_server_from_config', { spaceId, serverId }); } -/** - * Open space configuration file in external editor. - */ export async function openSpaceConfigFile(spaceId: string): Promise { return invoke('open_space_config_file', { spaceId }); } diff --git a/apps/desktop/src/lib/api/workspaceBindings.ts b/apps/desktop/src/lib/api/workspaceBindings.ts new file mode 100644 index 00000000..2f2ad669 --- /dev/null +++ b/apps/desktop/src/lib/api/workspaceBindings.ts @@ -0,0 +1,177 @@ +import { invoke } from '@tauri-apps/api/core'; + +/** + * A WorkspaceBinding maps one normalized filesystem path to one or more + * FeatureSets within a Space. When an MCP session reports a root that + * matches a binding (longest-prefix wins), the resolver hands back the + * binding's `space_id` and the union of `feature_set_ids` — multiple FSes + * compose into a single allow set, no "follow active" indirection. + */ +export interface WorkspaceBinding { + id: string; + workspace_root: string; + /** 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 + * order; the resolver treats the list as a set. FeatureSet ids are + * strings (builtins use `fs_default_`, customs use UUIDs). + */ + feature_set_ids: string[]; + created_at: string; + updated_at: string; +} + +/** Input payload for create / update. `feature_set_ids` must be non-empty. */ +export interface WorkspaceBindingInput { + workspace_root: string; + label?: string | null; + space_id: string; + feature_set_ids: string[]; +} + +/** List every binding (sorted by workspace_root). */ +export async function listWorkspaceBindings(): Promise { + return invoke('list_workspace_bindings'); +} + +/** + * Every filesystem root that connected MCP clients have reported during + * their current sessions, deduplicated across sessions. Surfaces folders + * that aren't bound yet so the user can configure them from the Workspaces + * tab instead of waiting for the one-shot prompt. + */ +export async function listReportedWorkspaceRoots(): Promise { + return invoke('list_reported_workspace_roots'); +} + +/** + * Live path validation for the manual-add form. Runs the SAME rules the + * create/update commands apply so "validates in UI → saves OK" is a + * guarantee, not a hope. + * + * Resolves with the server's normalized form (e.g. `d:\foo` from raw + * `D:\foo\`). Rejects with a descriptive message on invalid input; empty + * input rejects with an empty string so the UI can distinguish + * "don't nag yet" from "here's a real error". + */ +export async function validateWorkspaceRoot(path: string): Promise { + return invoke('validate_workspace_root', { path }); +} + +/** List bindings whose target Space is the given one. */ +export async function listWorkspaceBindingsForSpace( + spaceId: string +): Promise { + return invoke('list_workspace_bindings_for_space', { spaceId }); +} + +/** + * Create a new binding. `workspace_root` is normalized server-side so + * callers can pass raw OS paths, `file://` URIs, or MCP-reported roots. + */ +export async function createWorkspaceBinding( + input: WorkspaceBindingInput +): Promise { + return invoke('create_workspace_binding', { input }); +} + +/** Update any axis of an existing binding. */ +export async function updateWorkspaceBinding( + id: string, + input: WorkspaceBindingInput +): Promise { + return invoke('update_workspace_binding', { id, input }); +} + +/** Delete a binding by id. */ +export async function deleteWorkspaceBinding(id: string): Promise { + return invoke('delete_workspace_binding', { id }); +} + +/** Convenience: build a `WorkspaceBindingInput` from a binding-shaped object. */ +export function toInput(b: WorkspaceBinding): WorkspaceBindingInput { + return { + workspace_root: b.workspace_root, + label: b.label, + space_id: b.space_id, + feature_set_ids: b.feature_set_ids, + }; +} + +/** + * Per-feature view returned from `get_workspace_effective_features`. + * + * `available` is `true` exactly when the underlying server is currently + * connected. A `false` value with `server_status = "disconnected"` (or + * `auth_required` / `error`) is the user's "configured but unavailable" + * case — the FS still includes this feature, but its server isn't usable + * right now. + */ +export interface EffectiveFeature { + id: string; + feature_name: string; + display_name: string | null; + description: string | null; + server_id: string; + server_alias: string | null; + /** + * snake_case mirror of the gateway's connection status, plus `unknown` + * when the gateway isn't running. + */ + server_status: + | 'connected' + | 'connecting' + | 'disconnected' + | 'refreshing' + | 'auth_required' + | 'authenticating' + | 'error' + | 'unknown'; + available: boolean; +} + +/** + * Per-server total feature counts in the resolved Space, regardless of FS + * filter. The right-hand side of the "{mapped} / {total}" badges. + */ +export interface ServerFeatureTotals { + tools: number; + prompts: number; + resources: number; +} + +/** One FeatureSet contributing to the resolved view. */ +export interface EffectiveFeatureSetSummary { + id: string; + name: string; + feature_set_type: 'starter' | 'default' | 'custom'; +} + +export interface WorkspaceEffectiveFeatures { + workspace_root: string; + /** `binding` when a saved WorkspaceBinding matched; `unbound` when no binding matched — the `feature_sets` field previews the default Space's Default FS but a live session here would be denied. */ + source: 'binding' | 'unbound'; + binding_id: string | null; + space_id: string; + space_name: string; + /** All FeatureSets contributing to the resolved view, in operator-chosen order. ≥ 1. */ + feature_sets: EffectiveFeatureSetSummary[]; + tools: EffectiveFeature[]; + prompts: EffectiveFeature[]; + resources: EffectiveFeature[]; + /** `server_id -> totals` for every server installed in the resolved Space. */ + server_totals: Record; +} + +/** + * Resolve the FeatureSet that applies for a given workspace root and return + * its full configured tool/prompt/resource list with per-feature + * availability — same view the gateway resolver builds for live sessions. + */ +export async function getWorkspaceEffectiveFeatures( + workspaceRoot: string +): Promise { + return invoke('get_workspace_effective_features', { workspaceRoot }); +} diff --git a/apps/desktop/src/lib/contribute.ts b/apps/desktop/src/lib/contribute.ts new file mode 100644 index 00000000..8d121ad7 --- /dev/null +++ b/apps/desktop/src/lib/contribute.ts @@ -0,0 +1,61 @@ +/** + * Links + helpers for "Contribute / Request / Report" CTAs scattered across + * the app (registry empty-state, settings, etc.). + * + * All URLs live here so we can update the target org / repo / site from one + * place instead of grepping for hardcoded strings. + * + * Open-in-browser goes through `openUrl` (our Tauri command wrapping + * `tauri-plugin-opener`) so the user's default browser handles the URL + * rather than loading it inside the webview. + */ + +import { openUrl } from '@/lib/api/gateway'; + +export const CONTRIBUTE = { + /** Main desktop + gateway repo. */ + repo: 'https://github.com/mcpmux/mcp-mux', + /** Community-maintained server-definition registry. */ + serversRepo: 'https://github.com/mcpmux/mcp-servers', + /** Marketing site. */ + site: 'https://mcpmux.com', + /** New bug report, pre-filled with the bug_report template. */ + bug: 'https://github.com/mcpmux/mcp-mux/issues/new?template=bug_report.yml', + /** Feature request for the app itself, pre-filled with the feature_request template. */ + featureRequest: + 'https://github.com/mcpmux/mcp-mux/issues/new?template=feature_request.yml', + /** + * Request a new server definition in the community registry. Opens the + * `request-server.yml` issue template and encodes the user's search term + * into the title when provided. + */ + requestServer(searchTerm?: string): string { + const base = + 'https://github.com/mcpmux/mcp-servers/issues/new?template=request-server.yml'; + if (!searchTerm) return base; + const title = encodeURIComponent(`[Request] ${searchTerm.slice(0, 120)}`); + return `${base}&title=${title}`; + }, + /** + * Contribute a new server definition — points at the registry's + * CONTRIBUTING guide. Server definitions are JSON files landed via PR, + * not issues, so we send users straight down the fork → PR path. + */ + contributeServer: 'https://github.com/mcpmux/mcp-servers/blob/main/CONTRIBUTING.md', + /** Report a bug in an existing server definition. */ + serverDefinitionBug: + 'https://github.com/mcpmux/mcp-servers/issues/new?template=bug-report.yml', +} as const; + +/** + * Open an external URL via the Tauri opener plugin. Falls back to the plugin + * directly if our gateway wrapper fails (mirrors OAuthConsentModal's pattern). + */ +export async function openExternal(url: string): Promise { + try { + await openUrl(url); + } catch { + const { openUrl: plugin } = await import('@tauri-apps/plugin-opener'); + await plugin(url); + } +} diff --git a/apps/desktop/src/stores/appStore.ts b/apps/desktop/src/stores/appStore.ts index 91e554dd..c1453408 100644 --- a/apps/desktop/src/stores/appStore.ts +++ b/apps/desktop/src/stores/appStore.ts @@ -5,7 +5,6 @@ import { AppStore, AppState } from './types'; const initialState: AppState = { spaces: [], - activeSpaceId: null, viewSpaceId: null, activeNav: 'home', pendingClientId: null, @@ -27,28 +26,13 @@ export const useAppStore = create()( setSpaces: (spaces) => set((state) => { state.spaces = spaces; - // Validate persisted activeSpaceId still exists, reset to default if not - const activeExists = state.activeSpaceId - ? spaces.some((s) => s.id === state.activeSpaceId) - : false; - if (!activeExists && spaces.length > 0) { - const defaultSpace = spaces.find((s) => s.is_default); - state.activeSpaceId = defaultSpace?.id ?? spaces[0].id; - } + // Validate persisted viewSpaceId still exists; reset to default if not const viewExists = state.viewSpaceId ? spaces.some((s) => s.id === state.viewSpaceId) : false; - if (!viewExists) { - state.viewSpaceId = state.activeSpaceId; - } - }), - - setActiveSpace: (id) => - set((state) => { - const shouldFollow = !state.viewSpaceId || state.viewSpaceId === state.activeSpaceId; - state.activeSpaceId = id; - if (shouldFollow) { - state.viewSpaceId = id; + if (!viewExists && spaces.length > 0) { + const defaultSpace = spaces.find((s) => s.is_default); + state.viewSpaceId = defaultSpace?.id ?? spaces[0].id; } }), @@ -60,22 +44,18 @@ export const useAppStore = create()( addSpace: (space) => set((state) => { state.spaces.push(space); - if (space.is_default || state.spaces.length === 1) { - state.activeSpaceId = space.id; - } - if (!state.viewSpaceId) { - state.viewSpaceId = state.activeSpaceId; + if (!state.viewSpaceId || space.is_default) { + state.viewSpaceId = space.id; } }), removeSpace: (id) => set((state) => { state.spaces = state.spaces.filter((s) => s.id !== id); - if (state.activeSpaceId === id) { - state.activeSpaceId = state.spaces[0]?.id ?? null; - } if (state.viewSpaceId === id) { - state.viewSpaceId = state.activeSpaceId; + const fallback = + state.spaces.find((s) => s.is_default) ?? state.spaces[0]; + state.viewSpaceId = fallback?.id ?? null; } }), @@ -124,9 +104,7 @@ export const useAppStore = create()( name: 'mcpmux-storage', storage: createJSONStorage(() => localStorage), partialize: (state) => ({ - // Only persist these fields - // Note: viewSpaceId is NOT persisted - always starts as activeSpaceId on launch - activeSpaceId: state.activeSpaceId, + viewSpaceId: state.viewSpaceId, sidebarCollapsed: state.sidebarCollapsed, theme: state.theme, analyticsEnabled: state.analyticsEnabled, @@ -134,4 +112,3 @@ export const useAppStore = create()( } ) ); - diff --git a/apps/desktop/src/stores/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/stores/selectors.ts b/apps/desktop/src/stores/selectors.ts index 02ed4cb4..99282afb 100644 --- a/apps/desktop/src/stores/selectors.ts +++ b/apps/desktop/src/stores/selectors.ts @@ -3,7 +3,6 @@ import { Space } from '@/lib/api/spaces'; // Typed selectors for better performance export const useSpaces = () => useAppStore((state) => state.spaces); -export const useActiveSpaceId = () => useAppStore((state) => state.activeSpaceId); export const useViewSpaceId = () => useAppStore((state) => state.viewSpaceId); export const useActiveNav = () => useAppStore((state) => state.activeNav); export const useNavigateTo = () => useAppStore((state) => state.navigateTo); @@ -14,21 +13,18 @@ export const useSidebarCollapsed = () => useAppStore((state) => state.sidebarCol export const useAnalyticsEnabled = () => useAppStore((state) => state.analyticsEnabled); // Computed selectors -export const useActiveSpace = (): Space | null => { +export const useViewSpace = (): Space | null => { const spaces = useSpaces(); - const activeSpaceId = useActiveSpaceId(); - return spaces.find((s) => s.id === activeSpaceId) ?? null; + const viewSpaceId = useViewSpaceId(); + return spaces.find((s) => s.id === viewSpaceId) ?? null; }; -export const useViewSpace = (): Space | null => { +/** The system's fallback space — `is_default` Space, used by gateway when no WorkspaceBinding matches. */ +export const useDefaultSpace = (): Space | null => { const spaces = useSpaces(); - const activeSpaceId = useActiveSpaceId(); - const viewSpaceId = useViewSpaceId(); - const effectiveId = viewSpaceId ?? activeSpaceId; - return spaces.find((s) => s.id === effectiveId) ?? null; + return spaces.find((s) => s.is_default) ?? null; }; export const useIsLoading = (key: 'spaces' | 'servers') => { return useAppStore((state) => state.loading[key]); }; - diff --git a/apps/desktop/src/stores/types.ts b/apps/desktop/src/stores/types.ts index 15b55d3c..4cacf04b 100644 --- a/apps/desktop/src/stores/types.ts +++ b/apps/desktop/src/stores/types.ts @@ -1,11 +1,24 @@ import { Space } from '@/lib/api/spaces'; -export type NavItem = 'home' | 'registry' | 'servers' | 'spaces' | 'featuresets' | 'clients' | 'settings'; +export type NavItem = + | 'home' + | 'registry' + | 'servers' + | 'spaces' + | 'featuresets' + | 'workspaces' + | 'clients' + | 'settings'; export interface AppState { // Spaces spaces: Space[]; - activeSpaceId: string | null; + /** + * The space the user is currently viewing in the desktop app. Pure + * UI navigation state — has no effect on gateway routing, which always + * resolves via reported workspace root → WorkspaceBinding (or the + * built-in default Space when no binding matches). + */ viewSpaceId: string | null; // Navigation @@ -28,7 +41,6 @@ export interface AppState { export interface AppActions { // Spaces setSpaces: (spaces: Space[]) => void; - setActiveSpace: (id: string | null) => void; setViewSpace: (id: string | null) => void; addSpace: (space: Space) => void; removeSpace: (id: string) => void; @@ -48,4 +60,3 @@ export interface AppActions { } export type AppStore = AppState & AppActions; - diff --git a/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/mod.rs b/crates/mcpmux-core/src/application/mod.rs index 7c904956..5f58ebfb 100644 --- a/crates/mcpmux-core/src/application/mod.rs +++ b/crates/mcpmux-core/src/application/mod.rs @@ -134,7 +134,6 @@ impl ApplicationServicesBuilder { server: self.installed_server_repo.map(|r| { ServerAppService::new( r, - self.feature_set_repo.clone(), self.server_feature_repo.clone(), self.credential_repo.clone(), sender.clone(), @@ -142,7 +141,7 @@ impl ApplicationServicesBuilder { }), permission: self .feature_set_repo - .map(|r| PermissionAppService::new(r, self.client_repo.clone(), sender.clone())), + .map(|r| PermissionAppService::new(r, sender.clone())), client: self .client_repo .map(|r| ClientAppService::new(r, sender.clone())), diff --git a/crates/mcpmux-core/src/application/permission.rs b/crates/mcpmux-core/src/application/permission.rs index b77e8167..99f2bbd2 100644 --- a/crates/mcpmux-core/src/application/permission.rs +++ b/crates/mcpmux-core/src/application/permission.rs @@ -9,24 +9,22 @@ use uuid::Uuid; use crate::domain::{DomainEvent, FeatureSet, FeatureSetMember, MemberMode}; use crate::event_bus::EventSender; -use crate::repository::{FeatureSetRepository, InboundMcpClientRepository}; +use crate::repository::FeatureSetRepository; -/// Application service for feature sets and grants management +/// Application service for feature sets. +/// +/// Grants no longer exist — routing is driven by WorkspaceBinding and each +/// Space's Default feature set. This service therefore only covers FS +/// creation, edits, and membership. pub struct PermissionAppService { feature_set_repo: Arc, - client_repo: Option>, event_sender: EventSender, } impl PermissionAppService { - pub fn new( - feature_set_repo: Arc, - client_repo: Option>, - event_sender: EventSender, - ) -> Self { + pub fn new(feature_set_repo: Arc, event_sender: EventSender) -> Self { Self { feature_set_repo, - client_repo, event_sender, } } @@ -283,151 +281,4 @@ impl PermissionAppService { .get_feature_members(feature_set_id) .await } - - // ======================================================================== - // GRANT OPERATIONS - // ======================================================================== - - /// Grant a feature set to a client for a space - /// - /// Emits: `GrantIssued` - pub async fn grant_feature_set( - &self, - client_id: Uuid, - space_id: &str, - feature_set_id: &str, - ) -> Result<()> { - let client_repo = self - .client_repo - .as_ref() - .ok_or_else(|| anyhow!("Client repository not configured"))?; - - // Verify client exists - client_repo - .get(&client_id) - .await? - .ok_or_else(|| anyhow!("Client not found"))?; - - // Verify feature set exists - self.feature_set_repo - .get(feature_set_id) - .await? - .ok_or_else(|| anyhow!("Feature set not found"))?; - - client_repo - .grant_feature_set(&client_id, space_id, feature_set_id) - .await?; - - // Parse space_id to UUID - let space_uuid = - Uuid::parse_str(space_id).map_err(|e| anyhow!("Invalid space ID: {}", e))?; - - info!( - client_id = %client_id, - space_id = space_id, - feature_set_id = feature_set_id, - "[PermissionAppService] Granted feature set to client" - ); - - // Emit event - this will trigger MCP notifications to connected clients - self.event_sender.emit(DomainEvent::GrantIssued { - client_id: client_id.to_string(), - space_id: space_uuid, - feature_set_id: feature_set_id.to_string(), - }); - - Ok(()) - } - - /// Revoke a feature set from a client - /// - /// Emits: `GrantRevoked` - pub async fn revoke_feature_set( - &self, - client_id: Uuid, - space_id: &str, - feature_set_id: &str, - ) -> Result<()> { - let client_repo = self - .client_repo - .as_ref() - .ok_or_else(|| anyhow!("Client repository not configured"))?; - - client_repo - .revoke_feature_set(&client_id, space_id, feature_set_id) - .await?; - - // Parse space_id to UUID - let space_uuid = - Uuid::parse_str(space_id).map_err(|e| anyhow!("Invalid space ID: {}", e))?; - - info!( - client_id = %client_id, - space_id = space_id, - feature_set_id = feature_set_id, - "[PermissionAppService] Revoked feature set from client" - ); - - // Emit event - self.event_sender.emit(DomainEvent::GrantRevoked { - client_id: client_id.to_string(), - space_id: space_uuid, - feature_set_id: feature_set_id.to_string(), - }); - - Ok(()) - } - - /// Get all grants for a client in a space - pub async fn get_grants_for_space( - &self, - client_id: Uuid, - space_id: &str, - ) -> Result> { - let client_repo = self - .client_repo - .as_ref() - .ok_or_else(|| anyhow!("Client repository not configured"))?; - - client_repo.get_grants_for_space(&client_id, space_id).await - } - - /// Set all grants for a client in a space (replaces existing) - /// - /// Emits: `ClientGrantsUpdated` - pub async fn set_grants_for_space( - &self, - client_id: Uuid, - space_id: &str, - feature_set_ids: Vec, - ) -> Result<()> { - let client_repo = self - .client_repo - .as_ref() - .ok_or_else(|| anyhow!("Client repository not configured"))?; - - client_repo - .set_grants_for_space(&client_id, space_id, &feature_set_ids) - .await?; - - // Parse space_id to UUID - let space_uuid = - Uuid::parse_str(space_id).map_err(|e| anyhow!("Invalid space ID: {}", e))?; - - info!( - client_id = %client_id, - space_id = space_id, - count = feature_set_ids.len(), - "[PermissionAppService] Updated client grants" - ); - - // Emit event - self.event_sender.emit(DomainEvent::ClientGrantsUpdated { - client_id: client_id.to_string(), - space_id: space_uuid, - feature_set_ids, - }); - - Ok(()) - } } diff --git a/crates/mcpmux-core/src/application/server.rs b/crates/mcpmux-core/src/application/server.rs index a9a5d580..dc344170 100644 --- a/crates/mcpmux-core/src/application/server.rs +++ b/crates/mcpmux-core/src/application/server.rs @@ -8,16 +8,15 @@ use std::sync::Arc; use tracing::{info, warn}; use uuid::Uuid; -use crate::domain::{DomainEvent, InstallationSource, InstalledServer, ServerDefinition}; -use crate::event_bus::EventSender; -use crate::repository::{ - CredentialRepository, FeatureSetRepository, InstalledServerRepository, ServerFeatureRepository, +use crate::domain::{ + DomainEvent, InstallationSource, InstalledServer, ServerDefinition, UserServerEntry, }; +use crate::event_bus::EventSender; +use crate::repository::{CredentialRepository, InstalledServerRepository, ServerFeatureRepository}; /// Application service for server installation and management pub struct ServerAppService { server_repo: Arc, - feature_set_repo: Option>, feature_repo: Option>, credential_repo: Option>, event_sender: EventSender, @@ -26,14 +25,12 @@ pub struct ServerAppService { impl ServerAppService { pub fn new( server_repo: Arc, - feature_set_repo: Option>, feature_repo: Option>, credential_repo: Option>, event_sender: EventSender, ) -> Self { Self { server_repo, - feature_set_repo, feature_repo, credential_repo, event_sender, @@ -86,20 +83,6 @@ impl ServerAppService { self.server_repo.install(&server).await?; - // Create server-all feature set - if let Some(ref fs_repo) = self.feature_set_repo { - if let Err(e) = fs_repo - .ensure_server_all(&space_id_str, server_id, &definition.name) - .await - { - tracing::warn!( - server_id = server_id, - error = %e, - "Failed to create server-all feature set" - ); - } - } - info!( space_id = %space_id, server_id = server_id, @@ -116,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. @@ -150,17 +284,6 @@ impl ServerAppService { } } - // Delete server-all feature set - if let Some(ref fs_repo) = self.feature_set_repo { - if let Err(e) = fs_repo.delete_server_all(&space_id_str, server_id).await { - warn!( - server_id = server_id, - error = %e, - "Failed to delete server-all feature set" - ); - } - } - // Delete discovered features if let Some(ref feature_repo) = self.feature_repo { if let Err(e) = feature_repo @@ -237,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, @@ -248,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(); @@ -267,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?; @@ -286,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` @@ -373,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/application/space.rs b/crates/mcpmux-core/src/application/space.rs index 9618ded1..797b549c 100644 --- a/crates/mcpmux-core/src/application/space.rs +++ b/crates/mcpmux-core/src/application/space.rs @@ -43,13 +43,19 @@ impl SpaceAppService { self.space_repo.get(&id).await } - /// Get the active (default) space - pub async fn get_active(&self) -> Result> { + /// Get the system's default Space (the routing fallback when a session + /// reports no root or no `WorkspaceBinding` matches). + pub async fn get_default(&self) -> Result> { self.space_repo.get_default().await } /// Create a new space /// + /// User-created spaces are NEVER marked as default — the canonical + /// default is the seeded "My Space" row, restored by migration 008 if + /// it goes missing. The previous "first-created becomes default" branch + /// caused durable corruption on installs that hit it. + /// /// Emits: `SpaceCreated` pub async fn create(&self, name: &str, icon: Option) -> Result { let mut space = Space::new(name); @@ -57,12 +63,6 @@ impl SpaceAppService { space = space.with_icon(icon); } - // If no spaces exist, make this one the default - let existing = self.space_repo.list().await?; - if existing.is_empty() { - space = space.set_default(); - } - // Persist self.space_repo.create(&space).await?; @@ -156,37 +156,4 @@ impl SpaceAppService { Ok(()) } - - /// Set the active space - /// - /// Emits: `SpaceActivated` - pub async fn set_active(&self, id: Uuid) -> Result { - // Get current active space - let old_space = self.space_repo.get_default().await?; - - // Get new space - let new_space = self - .space_repo - .get(&id) - .await? - .ok_or_else(|| anyhow!("Space not found"))?; - - // Set as default - self.space_repo.set_default(&id).await?; - - info!( - space_id = %id, - name = %new_space.name, - "[SpaceAppService] Activated space" - ); - - // Emit event - self.event_sender.emit(DomainEvent::SpaceActivated { - from_space_id: old_space.map(|s| s.id), - to_space_id: new_space.id, - to_space_name: new_space.name.clone(), - }); - - Ok(new_space) - } } diff --git a/crates/mcpmux-core/src/domain/client.rs b/crates/mcpmux-core/src/domain/client.rs index 7ec72dcc..2172a4fb 100644 --- a/crates/mcpmux-core/src/domain/client.rs +++ b/crates/mcpmux-core/src/domain/client.rs @@ -1,40 +1,16 @@ //! Client entity - AI clients that connect to McpMux +//! +//! A Client is the *identity* an approved connection uses (Cursor, VS Code, +//! Claude Desktop, etc.). Routing is driven entirely by WorkspaceBinding + +//! the session's Space; per-client FeatureSet grants and Space/FS pins no +//! longer exist. use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; use uuid::Uuid; -/// Connection mode determines how a client resolves which Space to use -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum ConnectionMode { - /// Client is locked to a specific Space - Locked { space_id: Uuid }, - - /// Client follows the currently active Space - #[default] - FollowActive, - - /// Prompt user when context suggests a different Space - AskOnChange { triggers: Vec }, -} - -/// Triggers for auto-suggesting Space changes -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum ContextTrigger { - /// Match git remote URL - GitRemote { pattern: String, space_id: Uuid }, - - /// Match working directory - Directory { pattern: String, space_id: Uuid }, - - /// Match time of day - TimeSchedule { cron: String, space_id: Uuid }, -} - -/// Client represents an AI client (Cursor, VS Code, Claude Desktop) +/// Client represents an AI client (Cursor, VS Code, Claude Desktop, ...) +/// that has been approved to connect to the gateway. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Client { /// Unique identifier @@ -46,14 +22,6 @@ pub struct Client { /// Client type (cursor, vscode, claude, etc.) pub client_type: String, - /// How this client resolves Spaces - #[serde(default)] - pub connection_mode: ConnectionMode, - - /// FeatureSet grants per Space: space_id -> [feature_set_ids] - #[serde(default)] - pub grants: HashMap>, - /// Access key for authentication (local only, never synced) #[serde(skip)] pub access_key: Option, @@ -76,8 +44,6 @@ impl Client { id: Uuid::new_v4(), name: name.into(), client_type: client_type.into(), - connection_mode: ConnectionMode::default(), - grants: HashMap::new(), access_key: None, created_at: now, updated_at: now, @@ -100,26 +66,6 @@ impl Client { Self::new("Claude Desktop", "claude") } - /// Set connection mode - pub fn with_mode(mut self, mode: ConnectionMode) -> Self { - self.connection_mode = mode; - self - } - - /// Grant FeatureSets for a Space - pub fn grant(mut self, space_id: Uuid, feature_sets: Vec) -> Self { - self.grants.insert(space_id, feature_sets); - self - } - - /// Check if client has any grants for a Space - pub fn has_access_to(&self, space_id: &Uuid) -> bool { - self.grants - .get(space_id) - .map(|g| !g.is_empty()) - .unwrap_or(false) - } - /// Generate a new access key pub fn generate_access_key(&mut self) { self.access_key = Some(format!("mcp_{}", Uuid::new_v4().simple())); @@ -135,20 +81,14 @@ mod tests { let client = Client::cursor(); assert_eq!(client.name, "Cursor"); assert_eq!(client.client_type, "cursor"); - assert!(matches!( - client.connection_mode, - ConnectionMode::FollowActive - )); } #[test] - fn test_grants() { - let space_id = Uuid::new_v4(); - let fs_id = Uuid::new_v4(); - - let client = Client::cursor().grant(space_id, vec![fs_id]); - - assert!(client.has_access_to(&space_id)); - assert!(!client.has_access_to(&Uuid::new_v4())); + fn test_access_key_generation() { + let mut client = Client::vscode(); + assert!(client.access_key.is_none()); + client.generate_access_key(); + let key = client.access_key.as_ref().expect("key was generated"); + assert!(key.starts_with("mcp_")); } } diff --git a/crates/mcpmux-core/src/domain/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/event.rs b/crates/mcpmux-core/src/domain/event.rs index bf460bad..f971ae80 100644 --- a/crates/mcpmux-core/src/domain/event.rs +++ b/crates/mcpmux-core/src/domain/event.rs @@ -183,14 +183,6 @@ pub enum DomainEvent { /// A space was deleted SpaceDeleted { space_id: Uuid }, - /// Active space changed - SpaceActivated { - #[serde(skip_serializing_if = "Option::is_none")] - from_space_id: Option, - to_space_id: Uuid, - to_space_name: String, - }, - // ════════════════════════════════════════════════════════════════════════ // SERVER LIFECYCLE (Configuration) // ════════════════════════════════════════════════════════════════════════ @@ -292,6 +284,18 @@ pub enum DomainEvent { // ════════════════════════════════════════════════════════════════════════ // CLIENT & GRANTS // ════════════════════════════════════════════════════════════════════════ + /// A client's per-space FeatureSet grants were added, removed, or + /// replaced wholesale. MCPNotifier listens and pushes + /// `notifications/{tools,prompts,resources}/list_changed` to every + /// peer registered under this `client_id` so they re-fetch under the + /// new permission set. + /// + /// Used only by the rootless-client fallback path (the resolver consults + /// `client_grants` when a session has no roots and the client did not + /// declare the MCP `roots` capability). Roots-capable sessions ignore + /// these grants and continue to route via `WorkspaceBinding`. + ClientGrantChanged { client_id: String, space_id: Uuid }, + /// An MCP client was registered (Cursor, VS Code, etc.) ClientRegistered { client_id: String, @@ -315,27 +319,6 @@ pub enum DomainEvent { /// A client was issued an access token ClientTokenIssued { client_id: String }, - /// A feature set was granted to a client in a space - GrantIssued { - client_id: String, - space_id: Uuid, - feature_set_id: String, - }, - - /// A feature set was revoked from a client in a space - GrantRevoked { - client_id: String, - space_id: Uuid, - feature_set_id: String, - }, - - /// Client's grants were batch-updated for a space - ClientGrantsUpdated { - client_id: String, - space_id: Uuid, - feature_set_ids: Vec, - }, - // ════════════════════════════════════════════════════════════════════════ // GATEWAY // ════════════════════════════════════════════════════════════════════════ @@ -356,6 +339,62 @@ pub enum DomainEvent { /// Backend server notified that its resources changed ResourcesChanged { space_id: Uuid, server_id: String }, + + // ════════════════════════════════════════════════════════════════════════ + // WORKSPACE BINDINGS (root → FeatureSet resolution) + // ════════════════════════════════════════════════════════════════════════ + /// A workspace binding was created, updated, or deleted. + /// + /// Emitted by the WorkspaceBinding application service. MCPNotifier + /// listens for this and broadcasts `notifications/tools/list_changed` + /// (plus prompts + resources) to every peer in the affected space so + /// clients re-fetch their tool list under the new routing decision. + WorkspaceBindingChanged { + space_id: Uuid, + workspace_root: String, + }, + + /// A client session resolved via `source=Default` because no binding + /// matched any of its reported roots. The desktop UI uses this to + /// prompt the user once per new (space, root) pair to pick a FeatureSet + /// (or explicitly commit to the default and stop re-prompting). + /// + /// NOT fired for rootless sessions — nothing to bind. + WorkspaceNeedsBinding { + client_id: String, + session_id: String, + space_id: Uuid, + workspace_root: String, + }, + + /// The live set of reported session roots changed (a client connected + /// and surfaced new folders, or an existing client's roots moved). The + /// desktop Workspaces tab listens for this and re-fetches the detected + /// roots list so unbound folders stay visible to the user. + /// + /// Payload-less on purpose — the consumer always re-queries; embedding + /// the roots here would be redundant and race with disconnect cleanup. + SessionRootsChanged, + + // ════════════════════════════════════════════════════════════════════════ + // META-TOOL AUDIT TRAIL + // ════════════════════════════════════════════════════════════════════════ + /// A built-in `mcpmux_*` self-management tool was called by an MCP client. + /// + /// Emitted by the gateway for every meta-tool invocation (read + write) + /// so the desktop's Connection Log can show an audit row. For writes, + /// `decision` records what the user chose in the approval dialog. + MetaToolInvoked { + client_id: String, + session_id: Option, + tool_name: String, + /// `"allow_once" | "always_for_this_session_and_client" | "deny" | "timeout" | "read"` + decision: String, + /// FeatureSet that became active as a result of the write, when known. + resolved_feature_set_id: Option, + /// Redacted summary of the payload the LLM supplied (no secrets). + summary: String, + }, } // ============================================================================ @@ -369,7 +408,6 @@ impl DomainEvent { Self::SpaceCreated { .. } => "space_created", Self::SpaceUpdated { .. } => "space_updated", Self::SpaceDeleted { .. } => "space_deleted", - Self::SpaceActivated { .. } => "space_activated", Self::ServerInstalled { .. } => "server_installed", Self::ServerUninstalled { .. } => "server_uninstalled", Self::ServerConfigUpdated { .. } => "server_config_updated", @@ -382,19 +420,21 @@ impl DomainEvent { Self::FeatureSetUpdated { .. } => "feature_set_updated", Self::FeatureSetDeleted { .. } => "feature_set_deleted", Self::FeatureSetMembersChanged { .. } => "feature_set_members_changed", + Self::ClientGrantChanged { .. } => "client_grant_changed", Self::ClientRegistered { .. } => "client_registered", Self::ClientReconnected { .. } => "client_reconnected", Self::ClientUpdated { .. } => "client_updated", Self::ClientDeleted { .. } => "client_deleted", Self::ClientTokenIssued { .. } => "client_token_issued", - Self::GrantIssued { .. } => "grant_issued", - Self::GrantRevoked { .. } => "grant_revoked", - Self::ClientGrantsUpdated { .. } => "client_grants_updated", Self::GatewayStarted { .. } => "gateway_started", Self::GatewayStopped => "gateway_stopped", Self::ToolsChanged { .. } => "tools_changed", Self::PromptsChanged { .. } => "prompts_changed", Self::ResourcesChanged { .. } => "resources_changed", + Self::WorkspaceBindingChanged { .. } => "workspace_binding_changed", + Self::WorkspaceNeedsBinding { .. } => "workspace_needs_binding", + Self::SessionRootsChanged => "session_roots_changed", + Self::MetaToolInvoked { .. } => "meta_tool_invoked", } } @@ -412,16 +452,18 @@ impl DomainEvent { } // Feature refresh directly affects capabilities Self::ServerFeaturesRefreshed { .. } => true, - // Grant changes affect what client can access - Self::GrantIssued { .. } - | Self::GrantRevoked { .. } - | Self::ClientGrantsUpdated { .. } => true, // Feature set member changes affect granted capabilities Self::FeatureSetMembersChanged { .. } => true, + // Per-client grant changes affect what rootless sessions see + Self::ClientGrantChanged { .. } => true, // Backend server notifications Self::ToolsChanged { .. } | Self::PromptsChanged { .. } | Self::ResourcesChanged { .. } => true, + // Binding changes reshuffle every peer's resolution in the space + Self::WorkspaceBindingChanged { .. } => true, + // WorkspaceNeedsBinding is a UI prompt — doesn't itself change what + // tools a client sees, just invites the user to configure. // All other events don't affect MCP capabilities _ => false, } @@ -445,14 +487,12 @@ impl DomainEvent { | Self::FeatureSetUpdated { space_id, .. } | Self::FeatureSetDeleted { space_id, .. } | Self::FeatureSetMembersChanged { space_id, .. } - | Self::GrantIssued { space_id, .. } - | Self::GrantRevoked { space_id, .. } - | Self::ClientGrantsUpdated { space_id, .. } + | Self::ClientGrantChanged { space_id, .. } | Self::ToolsChanged { space_id, .. } | Self::PromptsChanged { space_id, .. } - | Self::ResourcesChanged { space_id, .. } => Some(*space_id), - - Self::SpaceActivated { to_space_id, .. } => Some(*to_space_id), + | Self::ResourcesChanged { space_id, .. } + | Self::WorkspaceBindingChanged { space_id, .. } + | Self::WorkspaceNeedsBinding { space_id, .. } => Some(*space_id), Self::ClientRegistered { .. } | Self::ClientReconnected { .. } @@ -460,10 +500,14 @@ impl DomainEvent { | Self::ClientDeleted { .. } | Self::ClientTokenIssued { .. } | Self::GatewayStarted { .. } - | Self::GatewayStopped => None, + | Self::GatewayStopped + | Self::SessionRootsChanged + | Self::MetaToolInvoked { .. } => None, } } + // (grant events removed — routing is via WorkspaceBinding + Space.default FS only) + /// Get the server_id if this event is server-scoped pub fn server_id(&self) -> Option<&str> { match self { @@ -489,9 +533,8 @@ impl DomainEvent { | Self::ClientUpdated { client_id, .. } | Self::ClientDeleted { client_id, .. } | Self::ClientTokenIssued { client_id, .. } - | Self::GrantIssued { client_id, .. } - | Self::GrantRevoked { client_id, .. } - | Self::ClientGrantsUpdated { client_id, .. } => Some(client_id), + | Self::ClientGrantChanged { client_id, .. } + | Self::WorkspaceNeedsBinding { client_id, .. } => Some(client_id), _ => None, } } @@ -502,9 +545,7 @@ impl DomainEvent { Self::FeatureSetCreated { feature_set_id, .. } | Self::FeatureSetUpdated { feature_set_id, .. } | Self::FeatureSetDeleted { feature_set_id, .. } - | Self::FeatureSetMembersChanged { feature_set_id, .. } - | Self::GrantIssued { feature_set_id, .. } - | Self::GrantRevoked { feature_set_id, .. } => Some(feature_set_id), + | Self::FeatureSetMembersChanged { feature_set_id, .. } => Some(feature_set_id), _ => None, } } @@ -579,13 +620,14 @@ mod tests { #[test] fn test_affects_mcp_capabilities() { - // Grant events affect capabilities - let grant = DomainEvent::GrantIssued { - client_id: "test".to_string(), + // Feature-set member changes affect every peer that resolves into that set + let members = DomainEvent::FeatureSetMembersChanged { space_id: Uuid::new_v4(), feature_set_id: "fs1".to_string(), + added_count: 1, + removed_count: 0, }; - assert!(grant.affects_mcp_capabilities()); + assert!(members.affects_mcp_capabilities()); // Space creation doesn't affect capabilities let space = DomainEvent::SpaceCreated { @@ -633,4 +675,57 @@ mod tests { assert!(ConnectionStatus::Connected.is_terminal()); assert!(!ConnectionStatus::Connecting.is_terminal()); } + + #[test] + fn test_workspace_binding_changed_affects_capabilities() { + // Binding writes reshuffle what every peer in the space resolves to + // — MCPNotifier must broadcast list_changed for this event. + let e = DomainEvent::WorkspaceBindingChanged { + space_id: Uuid::new_v4(), + workspace_root: "/proj/foo".to_string(), + }; + assert!(e.affects_mcp_capabilities()); + assert_eq!(e.type_name(), "workspace_binding_changed"); + assert!(e.space_id().is_some()); + } + + #[test] + fn test_workspace_needs_binding_is_ui_only() { + // The "hey, pick a FeatureSet" prompt is a UI event — it does not + // itself change tool visibility and must NOT trigger list_changed. + let e = DomainEvent::WorkspaceNeedsBinding { + client_id: "client-1".to_string(), + session_id: "sess-1".to_string(), + space_id: Uuid::new_v4(), + workspace_root: "/proj/foo".to_string(), + }; + assert!(!e.affects_mcp_capabilities()); + assert!(e.is_ui_only()); + assert_eq!(e.type_name(), "workspace_needs_binding"); + assert!(e.space_id().is_some()); + assert_eq!(e.client_id(), Some("client-1")); + } + + #[test] + fn test_workspace_events_roundtrip_through_json() { + // The Tauri bridge serializes these to JSON for the webview; verify + // the serde tag + fields match what the frontend expects. + let changed = DomainEvent::WorkspaceBindingChanged { + space_id: Uuid::parse_str("11111111-1111-1111-1111-111111111111").unwrap(), + workspace_root: "d:\\proj".to_string(), + }; + let json = serde_json::to_string(&changed).unwrap(); + assert!(json.contains("\"type\":\"workspace_binding_changed\"")); + assert!(json.contains("\"workspace_root\":\"d:\\\\proj\"")); + + let needs = DomainEvent::WorkspaceNeedsBinding { + client_id: "c".into(), + session_id: "s".into(), + space_id: Uuid::nil(), + workspace_root: "/r".into(), + }; + let json = serde_json::to_string(&needs).unwrap(); + assert!(json.contains("\"type\":\"workspace_needs_binding\"")); + assert!(json.contains("\"session_id\":\"s\"")); + } } diff --git a/crates/mcpmux-core/src/domain/feature_set.rs b/crates/mcpmux-core/src/domain/feature_set.rs index 241a5122..84f4653a 100644 --- a/crates/mcpmux-core/src/domain/feature_set.rs +++ b/crates/mcpmux-core/src/domain/feature_set.rs @@ -1,28 +1,32 @@ //! FeatureSet entity - permission bundles for tools/prompts/resources //! -//! The new featureset model uses explicit feature selection instead of glob patterns. -//! Each featureset is scoped to a space and can be one of: -//! - All: All features from all connected servers in the space -//! - Default: Features auto-granted to all clients in the space -//! - ServerAll: All features from a specific server -//! - Custom: User-defined composition of features and other featuresets +//! Each FeatureSet is scoped to a space and is one of two types: +//! - **Starter**: auto-created with the Space as a convenient starting +//! point. Has no special routing role under the resolver — bindings and +//! per-client grants pick FeatureSets explicitly. Pre-resolver-v3 this +//! was the "Default" type and acted as the implicit fallback; that +//! behaviour is gone, and the rename reflects the type's actual job +//! (a seed you can rename, edit, or delete freely). +//! - **Custom**: any other operator-defined FeatureSet. use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -/// The type of a FeatureSet +/// The type of a FeatureSet. +/// +/// `Starter` is auto-created once per Space; `Custom` covers everything +/// else. Routing-wise the two are interchangeable — the type tag is +/// purely a UI affordance ("this one came pre-seeded with the Space"). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] #[derive(Default)] pub enum FeatureSetType { - /// All features from all connected servers in this space - All, - /// Features auto-granted to all clients in this space - Default, - /// All features from a specific server - ServerAll, - /// Custom user-defined featureset + /// Auto-created with the Space. Editable / deletable like any other + /// FS — no special routing semantics. Was historically called + /// `Default` (DB column value carried over via migration 013). + Starter, + /// Any operator-defined FeatureSet. #[default] Custom, } @@ -30,18 +34,18 @@ pub enum FeatureSetType { impl FeatureSetType { pub fn as_str(&self) -> &'static str { match self { - Self::All => "all", - Self::Default => "default", - Self::ServerAll => "server-all", + Self::Starter => "starter", Self::Custom => "custom", } } pub fn parse(s: &str) -> Option { match s { - "all" => Some(Self::All), - "default" => Some(Self::Default), - "server-all" => Some(Self::ServerAll), + // "default" stays accepted by the parser so a downgrade-then- + // upgrade dance, or a stale read of an in-memory value during + // migration, doesn't surprise the user. Migration 013 rewrites + // the DB rows to "starter" on its first run. + "starter" | "default" => Some(Self::Starter), "custom" => Some(Self::Custom), _ => None, } @@ -115,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 { @@ -126,6 +133,7 @@ impl FeatureSetMember { member_type: MemberType::Feature, member_id: feature_id.to_string(), mode: MemberMode::Include, + surfaced: false, } } @@ -137,6 +145,7 @@ impl FeatureSetMember { member_type: MemberType::Feature, member_id: feature_id.to_string(), mode: MemberMode::Exclude, + surfaced: false, } } @@ -148,18 +157,16 @@ impl FeatureSetMember { member_type: MemberType::FeatureSet, member_id: included_featureset_id.to_string(), mode: MemberMode::Include, + surfaced: false, } } } /// FeatureSet defines a bundle of permissions using explicit feature selection. /// -/// Each featureset is scoped to a space and can contain: -/// - Other featuresets (composition) -/// - Specific features (tools, prompts, resources) -/// -/// For builtin types (All, Default, ServerAll), the effective features are -/// computed dynamically based on connected servers and their discovered features. +/// Scoped to a space. Can contain other featuresets (composition) or specific +/// features (tools, prompts, resources). The `Default` type is auto-created per +/// space; its effective members can be edited by the user just like a Custom set. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FeatureSet { /// Unique identifier @@ -222,41 +229,28 @@ impl FeatureSet { } } - /// Create the "All Features" featureset for a space - pub fn new_all(space_id: impl Into) -> Self { - let space_id = space_id.into(); - let now = Utc::now(); - Self { - id: format!("fs_all_{}", space_id), - name: "All Features".to_string(), - description: Some( - "All features from all connected MCP servers in this space".to_string(), - ), - icon: Some("🌐".to_string()), - space_id: Some(space_id), - feature_set_type: FeatureSetType::All, - server_id: None, - is_builtin: true, - is_deleted: false, - created_at: now, - updated_at: now, - members: vec![], - } - } - - /// Create the "Default" featureset for a space - pub fn new_default(space_id: impl Into) -> Self { + /// Create the auto-seeded "Starter" FeatureSet for a Space. + /// + /// Uses a deterministic id (`fs_default_`) so repositories can + /// upsert this row without remembering a mapping. The id prefix is + /// kept for FK stability — only the *type* and display copy were + /// renamed from "Default" → "Starter" in migration 013. Any code that + /// relies on the prefix should treat it as opaque. + pub fn new_starter(space_id: impl Into) -> Self { let space_id = space_id.into(); let now = Utc::now(); Self { id: format!("fs_default_{}", space_id), - name: "Default".to_string(), + name: "Starter".to_string(), description: Some( - "Features automatically granted to all connected clients in this space".to_string(), + "Auto-created with this Space. Edit, rename, or delete freely \ + — bindings and per-client grants pick FeatureSets explicitly, \ + so this one has no special routing role." + .to_string(), ), icon: Some("⭐".to_string()), space_id: Some(space_id), - feature_set_type: FeatureSetType::Default, + feature_set_type: FeatureSetType::Starter, server_id: None, is_builtin: true, is_deleted: false, @@ -266,30 +260,11 @@ impl FeatureSet { } } - /// Create a "Server-All" featureset for a specific server in a space - pub fn new_server_all( - space_id: impl Into, - server_id: impl Into, - server_name: impl Into, - ) -> Self { - let space_id = space_id.into(); - let server_id = server_id.into(); - let server_name = server_name.into(); - let now = Utc::now(); - Self { - id: format!("fs_server_{}_{}", server_id, space_id), - name: format!("{} - All", server_name), - description: Some(format!("All features from the {} server", server_name)), - icon: Some("📦".to_string()), - space_id: Some(space_id), - feature_set_type: FeatureSetType::ServerAll, - server_id: Some(server_id), - is_builtin: true, - is_deleted: false, - created_at: now, - updated_at: now, - members: vec![], - } + /// Backwards-compat shim for callers that still use `new_default`. + /// Delegates to [`Self::new_starter`]. + #[deprecated(note = "Renamed to `new_starter`; the FS type is now `Starter`.")] + pub fn new_default(space_id: impl Into) -> Self { + Self::new_starter(space_id) } /// Add description @@ -304,19 +279,15 @@ impl FeatureSet { self } - /// Check if this featureset is the "All" type for a space - pub fn is_all_type(&self) -> bool { - self.feature_set_type == FeatureSetType::All + /// Check if this is the auto-seeded "Starter" FeatureSet for its Space. + pub fn is_starter(&self) -> bool { + self.feature_set_type == FeatureSetType::Starter } - /// Check if this featureset is the "Default" type for a space + /// Backwards-compat alias. Prefer [`Self::is_starter`]. + #[deprecated(note = "Renamed to `is_starter`.")] pub fn is_default_type(&self) -> bool { - self.feature_set_type == FeatureSetType::Default - } - - /// Check if this featureset is the "ServerAll" type - pub fn is_server_all_type(&self) -> bool { - self.feature_set_type == FeatureSetType::ServerAll + self.is_starter() } } @@ -325,31 +296,14 @@ mod tests { use super::*; #[test] - fn test_new_all_featureset() { - let fs = FeatureSet::new_all("space_123"); - assert_eq!(fs.id, "fs_all_space_123"); - assert_eq!(fs.feature_set_type, FeatureSetType::All); - assert!(fs.is_builtin); - assert!(fs.is_all_type()); - } - - #[test] - fn test_new_default_featureset() { - let fs = FeatureSet::new_default("space_123"); + fn test_new_starter_featureset() { + let fs = FeatureSet::new_starter("space_123"); + // Stable id prefix preserved for FK compatibility; only the + // type / display copy were renamed. assert_eq!(fs.id, "fs_default_space_123"); - assert_eq!(fs.feature_set_type, FeatureSetType::Default); - assert!(fs.is_builtin); - assert!(fs.is_default_type()); - } - - #[test] - fn test_new_server_all_featureset() { - let fs = FeatureSet::new_server_all("space_123", "github-mcp", "GitHub"); - assert_eq!(fs.id, "fs_server_github-mcp_space_123"); - assert_eq!(fs.feature_set_type, FeatureSetType::ServerAll); - assert_eq!(fs.server_id, Some("github-mcp".to_string())); + assert_eq!(fs.feature_set_type, FeatureSetType::Starter); assert!(fs.is_builtin); - assert!(fs.is_server_all_type()); + assert!(fs.is_starter()); } #[test] @@ -369,14 +323,15 @@ mod tests { // FeatureSetType parse tests #[test] fn test_feature_set_type_parse() { - assert_eq!(FeatureSetType::parse("all"), Some(FeatureSetType::All)); assert_eq!( - FeatureSetType::parse("default"), - Some(FeatureSetType::Default) + FeatureSetType::parse("starter"), + Some(FeatureSetType::Starter) ); + // Legacy alias retained so old in-memory values from a stale + // read still parse cleanly. Migration 013 rewrites stored rows. assert_eq!( - FeatureSetType::parse("server-all"), - Some(FeatureSetType::ServerAll) + FeatureSetType::parse("default"), + Some(FeatureSetType::Starter) ); assert_eq!( FeatureSetType::parse("custom"), @@ -384,24 +339,20 @@ mod tests { ); assert_eq!(FeatureSetType::parse("invalid"), None); assert_eq!(FeatureSetType::parse(""), None); + // Legacy variants no longer exist + assert_eq!(FeatureSetType::parse("all"), None); + assert_eq!(FeatureSetType::parse("server-all"), None); } #[test] fn test_feature_set_type_as_str() { - assert_eq!(FeatureSetType::All.as_str(), "all"); - assert_eq!(FeatureSetType::Default.as_str(), "default"); - assert_eq!(FeatureSetType::ServerAll.as_str(), "server-all"); + assert_eq!(FeatureSetType::Starter.as_str(), "starter"); assert_eq!(FeatureSetType::Custom.as_str(), "custom"); } #[test] fn test_feature_set_type_roundtrip() { - for fs_type in [ - FeatureSetType::All, - FeatureSetType::Default, - FeatureSetType::ServerAll, - FeatureSetType::Custom, - ] { + for fs_type in [FeatureSetType::Starter, FeatureSetType::Custom] { let s = fs_type.as_str(); let parsed = FeatureSetType::parse(s).expect("should parse"); assert_eq!(parsed, fs_type); diff --git a/crates/mcpmux-core/src/domain/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/mod.rs b/crates/mcpmux-core/src/domain/mod.rs index 15d5fc20..b0d72e09 100644 --- a/crates/mcpmux-core/src/domain/mod.rs +++ b/crates/mcpmux-core/src/domain/mod.rs @@ -16,6 +16,7 @@ mod server; mod server_feature; mod server_log; mod space; +mod workspace_binding; // Export event types first (ConnectionStatus is defined here) pub use event::{ConnectionStatus, DiscoveredCapabilities, DomainEvent, DomainEventEnvelope}; @@ -31,3 +32,7 @@ pub use server::*; pub use server_feature::*; pub use server_log::*; pub use space::*; +pub use workspace_binding::{ + longest_prefix_match, normalize_workspace_root, validate_workspace_root, WorkspaceBinding, + WorkspaceRootValidation, +}; diff --git a/crates/mcpmux-core/src/domain/workspace_binding.rs b/crates/mcpmux-core/src/domain/workspace_binding.rs new file mode 100644 index 00000000..495ed327 --- /dev/null +++ b/crates/mcpmux-core/src/domain/workspace_binding.rs @@ -0,0 +1,597 @@ +//! WorkspaceBinding entity — maps a workspace root on disk to one or more +//! FeatureSets within a Space. +//! +//! Bindings are the only override surface for FS resolution: +//! +//! workspace root matches a binding? → (binding.space_id, binding.feature_set_ids) +//! else → deny (live session would hit +//! PendingRoots / WorkspaceNeedsBinding) +//! +//! A binding may resolve to multiple FeatureSets — the resolver hands them +//! all to `FeatureService::get_*_for_grants` which composes the union. +//! This is what lets one folder layer e.g. `Read Only` + `Project-specific +//! tools` without forcing the user to merge them into a single FS by hand. +//! Empty `feature_set_ids` is rejected at validation time; storing one +//! would be indistinguishable from "not bound" yet route via Tier 1. +//! +//! Path handling is **platform-agnostic**. A binding written on Windows +//! (`d:\work\proj`) has to match correctly on a Linux host that's just +//! reading the DB (and vice versa). We detect the path style from the +//! string itself — drive-letter prefix ⇒ Windows, leading `/` ⇒ POSIX — +//! rather than from `cfg!(windows)`. Both separators are accepted for +//! prefix matching regardless of the host OS. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// A binding between a normalized workspace root and the FeatureSet(s) it +/// resolves to. `feature_set_ids` is non-empty by construction — see +/// [`WorkspaceBinding::new`] / `new_multi`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct WorkspaceBinding { + pub id: Uuid, + pub workspace_root: String, + /// 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 + /// table (one row per FS, `sort_order` from this Vec's index). + pub feature_set_ids: Vec, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl WorkspaceBinding { + /// Convenience for the common single-FS case. + pub fn new( + workspace_root: impl Into, + space_id: Uuid, + feature_set_id: impl Into, + ) -> Self { + Self::new_multi(workspace_root, space_id, vec![feature_set_id.into()]) + } + + /// Construct a binding with one or more FeatureSets. Caller must + /// guarantee `feature_set_ids` is non-empty; the storage layer rejects + /// empties with a validation error. + pub fn new_multi( + workspace_root: impl Into, + space_id: Uuid, + feature_set_ids: Vec, + ) -> Self { + let now = Utc::now(); + Self { + id: Uuid::new_v4(), + workspace_root: workspace_root.into(), + label: None, + space_id, + feature_set_ids, + created_at: now, + updated_at: now, + } + } +} + +// ============================================================================ +// Path style detection +// ============================================================================ + +/// Which family of absolute-path syntax a string uses. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PathStyle { + /// POSIX / Unix absolute path: `/home/me/proj`. + Posix, + /// Windows drive-letter path: `C:\work\proj`, `c:/work/proj`, or `c:`. + WindowsDrive, + /// Windows UNC path: `\\server\share\...`. + WindowsUnc, +} + +/// Detect the style from the first few characters of an already-scheme- +/// stripped path. Returns None when it isn't recognizably absolute. +fn detect_style(path: &str) -> Option { + let bytes = path.as_bytes(); + if path.starts_with("\\\\") || path.starts_with("//") { + return Some(PathStyle::WindowsUnc); + } + // `c:` / `c:\` / `c:/...` — drive letter then colon. + if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' { + return Some(PathStyle::WindowsDrive); + } + if path.starts_with('/') { + return Some(PathStyle::Posix); + } + None +} + +// ============================================================================ +// Normalization +// ============================================================================ + +/// Normalize an absolute filesystem path or `file://` URI into the canonical +/// form used for binding comparisons. +/// +/// Platform-agnostic — the output only depends on the input's syntax, not +/// on the host OS. Same input always yields the same output. +/// +/// Rules: +/// * Strip `file://` / `file:///` scheme (tolerating an optional host). +/// * URL-decode percent escapes. +/// * On Windows-style paths: +/// - Lowercase the drive letter (`D:` → `d:`). +/// - Use `\` as the separator throughout (`d:/foo` → `d:\foo`). +/// - Strip trailing separators but keep `c:\` as the root form. +/// * On POSIX paths: strip trailing `/` but keep `/` alone. +/// * On empty input: return empty string (callers filter). +pub fn normalize_workspace_root(input: &str) -> String { + if input.is_empty() { + return String::new(); + } + + let decoded = strip_scheme_and_decode(input); + + // `file:///D:/foo` → after scheme strip + decode we have `/D:/foo`. The + // leading `/` is a URI artifact, not part of the path — drop it so the + // drive-letter detector can fire on the following byte. + let cleaned = strip_leading_slash_before_drive(&decoded); + + match detect_style(&cleaned) { + Some(PathStyle::Posix) => normalize_posix(&cleaned), + Some(PathStyle::WindowsDrive) => normalize_windows_drive(&cleaned), + Some(PathStyle::WindowsUnc) => normalize_windows_unc(&cleaned), + None => { + // Unrecognized / relative — return as-is (trimmed). Callers + // that require an absolute path should use + // [`validate_workspace_root`] instead of trusting normalization. + cleaned.trim().to_string() + } + } +} + +fn strip_scheme_and_decode(input: &str) -> String { + let without_scheme = if let Some(rest) = input.strip_prefix("file://") { + // Triple-slash form `file:///abs` → `rest` = `/abs`. Host form + // `file://localhost/abs` → drop up to the first `/`. + match rest.find('/') { + Some(0) => rest.to_string(), + Some(n) => rest[n..].to_string(), + None => rest.to_string(), + } + } else { + input.to_string() + }; + + urlencoding::decode(&without_scheme) + .map(|s| s.into_owned()) + .unwrap_or(without_scheme) +} + +fn strip_leading_slash_before_drive(path: &str) -> String { + let rest = match path.strip_prefix('/') { + Some(r) => r, + None => return path.to_string(), + }; + let bytes = rest.as_bytes(); + let looks_like_drive = bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'; + if looks_like_drive { + rest.to_string() + } else { + path.to_string() + } +} + +fn normalize_posix(path: &str) -> String { + let trimmed = path.trim_end_matches('/'); + if trimmed.is_empty() { + "/".to_string() + } else { + trimmed.to_string() + } +} + +fn normalize_windows_drive(path: &str) -> String { + // Lowercase the drive letter. + let mut chars: Vec = path.chars().collect(); + if !chars.is_empty() && chars[0].is_ascii_alphabetic() { + chars[0] = chars[0].to_ascii_lowercase(); + } + let mut s: String = chars.into_iter().collect(); + + // Convert every `/` to `\` for canonical Windows form. + s = s.replace('/', "\\"); + + // Trim trailing `\`, but keep `c:\` as a root form. + let trimmed = s.trim_end_matches('\\'); + if trimmed.len() < 2 { + return s; + } + // After trim, `c:` needs its trailing `\` back to remain absolute. + if trimmed.ends_with(':') { + format!("{trimmed}\\") + } else { + trimmed.to_string() + } +} + +fn normalize_windows_unc(path: &str) -> String { + // `\\server\share\path` — normalize separators to `\` and strip trailing `\`. + let s = path.replace('/', "\\"); + let trimmed = s.trim_end_matches('\\'); + // Preserve the leading `\\` prefix. + if trimmed.len() < 2 { + "\\\\".to_string() + } else { + trimmed.to_string() + } +} + +// ============================================================================ +// Validation (for manual user input) +// ============================================================================ + +/// Validation outcome for a prospective workspace root, returned by +/// [`validate_workspace_root`]. The UI renders normalized in the success +/// case and `reason` in the failure case. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WorkspaceRootValidation { + /// Empty input — UI shouldn't show an error while the field is empty. + Empty, + /// Accepted; `normalized` is what the caller should persist/submit. + Ok { normalized: String }, + /// Rejected; show `reason` to the user. + Invalid { reason: String }, +} + +/// Validate a user-entered workspace root. +/// +/// Applied on manual add/edit ONLY — roots reported by connected MCP +/// clients are trusted (they come from a live `roots/list` response via +/// `SessionRootsRegistry` and are normalized on insert). +/// +/// Rules enforced, independent of the host OS: +/// * Non-empty after trim. +/// * Normalization must classify the input as a real absolute path +/// (POSIX, Windows drive, or Windows UNC). +/// * Not the filesystem root alone (`/`, `c:\`, `\\`) — binding that +/// captures every session defeats the purpose. +/// * Windows-style paths may not contain `<>:"|?*` or stray `:` outside +/// the drive-letter position — the OS forbids those in filenames so a +/// path that contains them can't correspond to a real folder. +pub fn validate_workspace_root(input: &str) -> WorkspaceRootValidation { + let trimmed = input.trim(); + if trimmed.is_empty() { + return WorkspaceRootValidation::Empty; + } + + let normalized = normalize_workspace_root(trimmed); + if normalized.is_empty() { + return WorkspaceRootValidation::Invalid { + reason: "Path is empty after normalization.".into(), + }; + } + + let style = match detect_style(&normalized) { + Some(s) => s, + None => { + return WorkspaceRootValidation::Invalid { + reason: "Path must be absolute (e.g. /home/me/proj or D:\\work\\proj). \ + Relative paths can't route." + .into(), + }; + } + }; + + if is_filesystem_root(&normalized, style) { + return WorkspaceRootValidation::Invalid { + reason: + "Can't bind the filesystem root — every session would match. Pick a project folder." + .into(), + }; + } + + if matches!(style, PathStyle::WindowsDrive | PathStyle::WindowsUnc) { + if let Err(reason) = check_windows_reserved_chars(&normalized) { + return WorkspaceRootValidation::Invalid { reason }; + } + } + + WorkspaceRootValidation::Ok { normalized } +} + +fn is_filesystem_root(normalized: &str, style: PathStyle) -> bool { + match style { + PathStyle::Posix => normalized == "/", + PathStyle::WindowsDrive => { + // `c:\` — 3 chars, drive letter + colon + backslash. + normalized.len() == 3 && normalized.ends_with(":\\") + } + PathStyle::WindowsUnc => normalized == "\\\\", + } +} + +fn check_windows_reserved_chars(path: &str) -> Result<(), String> { + const RESERVED: &[char] = &['<', '>', '"', '|', '?', '*']; + // Byte index 1 is the drive-letter colon (`c:`); that's the only place + // `:` is legal. Everywhere else it's a reserved character. + for (i, ch) in path.char_indices() { + if i == 1 && ch == ':' { + continue; + } + if ch == ':' { + return Err(format!("Illegal character ':' in path at position {i}.")); + } + if RESERVED.contains(&ch) { + return Err(format!( + "Illegal character '{ch}' — Windows forbids {} in filenames.", + RESERVED + .iter() + .map(|c| format!("'{c}'")) + .collect::>() + .join(", ") + )); + } + } + Ok(()) +} + +// ============================================================================ +// Longest-prefix match (separator-agnostic) +// ============================================================================ + +/// Returns the `workspace_root` in `candidates` whose path is the longest +/// prefix of `query`, respecting path-component boundaries. +/// +/// Both `query` and every candidate MUST be already normalized via +/// [`normalize_workspace_root`]. The boundary check accepts either `/` or +/// `\` regardless of host OS so a binding written on Windows matches a +/// Linux reader (and vice versa). +pub fn longest_prefix_match<'a, I>(query: &str, candidates: I) -> Option<&'a str> +where + I: IntoIterator, +{ + let mut best: Option<&'a str> = None; + for candidate in candidates { + let matches = query == candidate + || (query.starts_with(candidate) + && query + .as_bytes() + .get(candidate.len()) + .is_some_and(|b| *b == b'/' || *b == b'\\')); + if matches && best.map(|b| candidate.len() > b.len()).unwrap_or(true) { + best = Some(candidate); + } + } + best +} + +#[cfg(test)] +mod tests { + use super::*; + + // ---- normalize ------------------------------------------------------- + + #[test] + fn normalize_posix_plain() { + assert_eq!( + normalize_workspace_root("/home/user/proj"), + "/home/user/proj" + ); + } + + #[test] + fn normalize_posix_trailing_slash() { + assert_eq!( + normalize_workspace_root("/home/user/proj/"), + "/home/user/proj" + ); + } + + #[test] + fn normalize_posix_file_uri() { + assert_eq!( + normalize_workspace_root("file:///home/user/proj"), + "/home/user/proj" + ); + } + + #[test] + fn normalize_windows_plain_on_any_host() { + // Normalization runs the same everywhere — cfg(windows) isn't involved. + assert_eq!( + normalize_workspace_root("D:\\Projects\\Foo"), + "d:\\Projects\\Foo" + ); + assert_eq!(normalize_workspace_root("C:/work/proj"), "c:\\work\\proj"); + } + + #[test] + fn normalize_windows_file_uri_on_any_host() { + assert_eq!( + normalize_workspace_root("file:///D:/Projects/Foo"), + "d:\\Projects\\Foo" + ); + } + + #[test] + fn normalize_windows_drive_letter_case_insensitive() { + assert_eq!( + normalize_workspace_root("D:\\Projects\\Foo"), + normalize_workspace_root("d:\\Projects\\Foo") + ); + } + + #[test] + fn normalize_windows_trailing_sep() { + assert_eq!(normalize_workspace_root("D:\\work\\"), "d:\\work"); + assert_eq!(normalize_workspace_root("D:\\"), "d:\\"); + assert_eq!(normalize_workspace_root("D:"), "d:\\"); + } + + #[test] + fn normalize_unc_basic() { + assert_eq!( + normalize_workspace_root("\\\\server\\share\\folder"), + "\\\\server\\share\\folder" + ); + assert_eq!( + normalize_workspace_root("\\\\server\\share\\folder\\"), + "\\\\server\\share\\folder" + ); + } + + #[test] + fn normalize_percent_decoded() { + let n = normalize_workspace_root("file:///home/user/my%20project"); + assert_eq!(n, "/home/user/my project"); + } + + // ---- validate -------------------------------------------------------- + + #[test] + fn validate_empty_is_not_an_error() { + assert_eq!(validate_workspace_root(""), WorkspaceRootValidation::Empty); + assert_eq!( + validate_workspace_root(" "), + WorkspaceRootValidation::Empty + ); + } + + #[test] + fn validate_accepts_posix() { + assert_eq!( + validate_workspace_root("/home/me/proj"), + WorkspaceRootValidation::Ok { + normalized: "/home/me/proj".into() + } + ); + } + + #[test] + fn validate_accepts_windows_on_any_host() { + assert_eq!( + validate_workspace_root("D:\\proj"), + WorkspaceRootValidation::Ok { + normalized: "d:\\proj".into() + } + ); + assert_eq!( + validate_workspace_root("c:/work/proj/"), + WorkspaceRootValidation::Ok { + normalized: "c:\\work\\proj".into() + } + ); + } + + #[test] + fn validate_accepts_unc() { + assert_eq!( + validate_workspace_root("\\\\server\\share\\folder"), + WorkspaceRootValidation::Ok { + normalized: "\\\\server\\share\\folder".into() + } + ); + } + + #[test] + fn validate_accepts_both_file_uris() { + assert_eq!( + validate_workspace_root("file:///home/me/proj"), + WorkspaceRootValidation::Ok { + normalized: "/home/me/proj".into() + } + ); + assert_eq!( + validate_workspace_root("file:///D:/proj"), + WorkspaceRootValidation::Ok { + normalized: "d:\\proj".into() + } + ); + } + + #[test] + fn validate_rejects_relative() { + assert!(matches!( + validate_workspace_root("my-project"), + WorkspaceRootValidation::Invalid { .. } + )); + assert!(matches!( + validate_workspace_root("./proj"), + WorkspaceRootValidation::Invalid { .. } + )); + assert!(matches!( + validate_workspace_root("~/proj"), + WorkspaceRootValidation::Invalid { .. } + )); + } + + #[test] + fn validate_rejects_filesystem_root() { + for bad in &["/", "D:\\", "d:\\", "\\\\"] { + match validate_workspace_root(bad) { + WorkspaceRootValidation::Invalid { reason } => { + assert!( + reason.to_lowercase().contains("filesystem root"), + "got {reason}" + ); + } + other => panic!("expected Invalid for {bad:?}, got {other:?}"), + } + } + } + + #[test] + fn validate_rejects_windows_reserved_chars() { + match validate_workspace_root("D:\\bad|name") { + WorkspaceRootValidation::Invalid { reason } => { + assert!(reason.contains('|'), "got {reason}"); + } + other => panic!("expected Invalid, got {other:?}"), + } + match validate_workspace_root("D:\\has { + assert!(reason.contains('<'), "got {reason}"); + } + other => panic!("expected Invalid, got {other:?}"), + } + } + + // ---- longest_prefix_match — cross-platform --------------------------- + + #[test] + fn longest_prefix_posix() { + let bindings = ["/a", "/a/b", "/a/b/c"]; + assert_eq!(longest_prefix_match("/a/b/c", bindings), Some("/a/b/c")); + assert_eq!(longest_prefix_match("/a/b/c/d", bindings), Some("/a/b/c")); + assert_eq!(longest_prefix_match("/a/b", bindings), Some("/a/b")); + } + + #[test] + fn longest_prefix_windows_runs_on_any_host() { + // No cfg(windows) gating — this test must pass on Linux CI too. + let bindings = ["d:\\work", "d:\\work\\proj"]; + assert_eq!( + longest_prefix_match("d:\\work\\proj\\src", bindings), + Some("d:\\work\\proj") + ); + assert_eq!( + longest_prefix_match("d:\\work\\other", bindings), + Some("d:\\work") + ); + } + + #[test] + fn longest_prefix_no_false_partial() { + let bindings = ["/a/b"]; + assert_eq!(longest_prefix_match("/a/b-extra", bindings), None); + let win = ["d:\\work"]; + assert_eq!(longest_prefix_match("d:\\workspace", win), None); + } + + #[test] + fn longest_prefix_empty_candidates() { + let bindings: [&str; 0] = []; + assert_eq!(longest_prefix_match("/a", bindings), None); + } +} diff --git a/crates/mcpmux-core/src/repository/mod.rs b/crates/mcpmux-core/src/repository/mod.rs index 95b1d838..73381d51 100644 --- a/crates/mcpmux-core/src/repository/mod.rs +++ b/crates/mcpmux-core/src/repository/mod.rs @@ -8,7 +8,7 @@ use uuid::Uuid; use crate::domain::{ Client, Credential, CredentialType, FeatureSet, FeatureSetMember, InstalledServer, MemberMode, - OutboundOAuthRegistration, ServerFeature, Space, + OutboundOAuthRegistration, ServerFeature, Space, WorkspaceBinding, }; /// Result type for repository operations @@ -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 @@ -157,36 +163,18 @@ pub trait FeatureSetRepository: Send + Sync { /// Delete a feature set (soft delete) async fn delete(&self, id: &str) -> RepoResult<()>; - /// Get builtin feature sets for a space - async fn list_builtin(&self, space_id: &str) -> RepoResult>; - - /// Get server-all featureset for a server in a space - async fn get_server_all( - &self, - space_id: &str, - server_id: &str, - ) -> RepoResult>; - - /// Create server-all featureset if it doesn't exist - async fn ensure_server_all( - &self, - space_id: &str, - server_id: &str, - server_name: &str, - ) -> RepoResult; + /// Get the auto-seeded "Starter" FeatureSet for a Space, if it + /// exists. Routing-irrelevant under resolver v3 — UI helpers use it + /// to suggest a default selection in the binding/grant pickers. + async fn get_starter_for_space(&self, space_id: &str) -> RepoResult>; - /// Get the "Default" featureset for a space - async fn get_default_for_space(&self, space_id: &str) -> RepoResult>; - - /// Get the "All" featureset for a space - async fn get_all_for_space(&self, space_id: &str) -> RepoResult>; - - /// Ensure builtin feature sets exist for a space (All + Default) + /// Ensure the auto-seeded Starter FeatureSet exists for a Space. + /// + /// Called during Space creation and any time a defensive re-seed is + /// needed (Workspace inspector references the Starter as a "preview" + /// for unbound roots and would crash with `None`). async fn ensure_builtin_for_space(&self, space_id: &str) -> RepoResult<()>; - /// Delete server-all feature set for a server (used when uninstalling) - async fn delete_server_all(&self, space_id: &str, server_id: &str) -> RepoResult<()>; - /// Add an individual feature as a member of a feature set async fn add_feature_member( &self, @@ -207,6 +195,9 @@ pub trait FeatureSetRepository: Send + Sync { /// /// Manages MCP client entities (apps connecting TO McpMux). /// Works with the unified `inbound_clients` table. +/// +/// Only identity is persisted here — routing is resolved per-session +/// via WorkspaceBinding and each Space's Default feature set. #[async_trait] pub trait InboundMcpClientRepository: Send + Sync { /// Get all clients @@ -226,46 +217,45 @@ pub trait InboundMcpClientRepository: Send + Sync { /// Delete a client async fn delete(&self, id: &Uuid) -> RepoResult<()>; +} - /// Grant a feature set to a client for a specific space - async fn grant_feature_set( - &self, - client_id: &Uuid, - space_id: &str, - feature_set_id: &str, - ) -> RepoResult<()>; +/// Workspace binding repository trait +/// +/// Bindings map normalized filesystem paths to FeatureSets on a per-Space basis. +/// Matching is longest-prefix-wins; callers are expected to pass +/// already-normalized paths (see [`crate::domain::normalize_workspace_root`]). +#[async_trait] +pub trait WorkspaceBindingRepository: Send + Sync { + /// List every binding across all Spaces. + async fn list(&self) -> RepoResult>; - /// Revoke a feature set from a client for a specific space - async fn revoke_feature_set( - &self, - client_id: &Uuid, - space_id: &str, - feature_set_id: &str, - ) -> RepoResult<()>; + /// List bindings for a specific Space. + async fn list_for_space(&self, space_id: &Uuid) -> RepoResult>; - /// Get all feature set IDs granted to a client for a specific space - async fn get_grants_for_space( - &self, - client_id: &Uuid, - space_id: &str, - ) -> RepoResult>; + /// Fetch a binding by id. + async fn get(&self, id: &Uuid) -> RepoResult>; - /// Get all grants for a client (all spaces) - async fn get_all_grants( - &self, - client_id: &Uuid, - ) -> RepoResult>>; + /// Insert a new binding. Fails on `(space_id, workspace_root)` conflict. + async fn create(&self, binding: &WorkspaceBinding) -> RepoResult<()>; - /// Set all grants for a client in a space (replaces existing) - async fn set_grants_for_space( - &self, - client_id: &Uuid, - space_id: &str, - feature_set_ids: &[String], - ) -> RepoResult<()>; + /// Update an existing binding (e.g., point to a different FS). + async fn update(&self, binding: &WorkspaceBinding) -> RepoResult<()>; - /// Check if client has any grants for a space - async fn has_grants_for_space(&self, client_id: &Uuid, space_id: &str) -> RepoResult; + /// Delete a binding by id. + async fn delete(&self, id: &Uuid) -> RepoResult<()>; + + /// Resolve which binding applies for a set of candidate workspace roots. + /// + /// Every candidate MUST already be normalized. Returns the binding whose + /// `workspace_root` is the longest prefix of any candidate, scoped to the + /// given Space (so bindings in unrelated spaces don't leak across). The + /// caller is responsible for then following the binding's space_mode and + /// fs_mode to compute the effective Space + FeatureSet. + async fn find_longest_prefix_match( + &self, + space_id: &Uuid, + candidate_roots: &[String], + ) -> RepoResult>; } /// Credential repository trait (local-only, never synced) diff --git a/crates/mcpmux-core/src/service/client_service.rs b/crates/mcpmux-core/src/service/client_service.rs deleted file mode 100644 index e3255e17..00000000 --- a/crates/mcpmux-core/src/service/client_service.rs +++ /dev/null @@ -1,170 +0,0 @@ -//! Client Service - manages AI client configuration and grants -//! -//! Handles auto-granting of Default feature set and permission resolution. - -use std::sync::Arc; - -use anyhow::Result; -use tracing::{info, warn}; -use uuid::Uuid; - -use crate::repository::{FeatureSetRepository, InboundMcpClientRepository}; - -/// Service for managing AI clients and their permissions -pub struct ClientService { - client_repository: Arc, - feature_set_repository: Arc, -} - -impl ClientService { - /// Create a new client service - pub fn new( - client_repository: Arc, - feature_set_repository: Arc, - ) -> Self { - Self { - client_repository, - feature_set_repository, - } - } - - /// Ensure a client has the Default feature set granted for a space. - /// This is called when a client first connects to a space. - pub async fn ensure_default_grant(&self, client_id: &Uuid, space_id: &str) -> Result { - // Check if client already has any grants for this space - if self - .client_repository - .has_grants_for_space(client_id, space_id) - .await? - { - return Ok(false); // Already has grants, don't auto-grant - } - - // Get the Default feature set for this space - let default_fs = match self - .feature_set_repository - .get_default_for_space(space_id) - .await? - { - Some(fs) => fs, - None => { - warn!( - "Default feature set not found for space {}. Attempting to create.", - space_id - ); - // Try to create builtin feature sets - self.feature_set_repository - .ensure_builtin_for_space(space_id) - .await?; - - // Try again - match self - .feature_set_repository - .get_default_for_space(space_id) - .await? - { - Some(fs) => fs, - None => { - anyhow::bail!( - "Could not find or create Default feature set for space {}", - space_id - ); - } - } - } - }; - - // Grant the Default feature set - self.client_repository - .grant_feature_set(client_id, space_id, &default_fs.id) - .await?; - - info!( - client_id = %client_id, - space_id = %space_id, - feature_set_id = %default_fs.id, - "Auto-granted Default feature set to client" - ); - - Ok(true) - } - - /// Ensure a client has the All feature set granted for a space. - pub async fn grant_all_features(&self, client_id: &Uuid, space_id: &str) -> Result<()> { - // Get the All feature set for this space - let all_fs = match self - .feature_set_repository - .get_all_for_space(space_id) - .await? - { - Some(fs) => fs, - None => { - // Try to create builtin feature sets - self.feature_set_repository - .ensure_builtin_for_space(space_id) - .await?; - - self.feature_set_repository - .get_all_for_space(space_id) - .await? - .ok_or_else(|| { - anyhow::anyhow!("Could not find All feature set for space {}", space_id) - })? - } - }; - - // Grant the All feature set - self.client_repository - .grant_feature_set(client_id, space_id, &all_fs.id) - .await?; - - info!( - client_id = %client_id, - space_id = %space_id, - feature_set_id = %all_fs.id, - "Granted All feature set to client" - ); - - Ok(()) - } - - /// Get all granted feature set IDs for a client in a space (explicit grants only) - pub async fn get_granted_feature_sets( - &self, - client_id: &Uuid, - space_id: &str, - ) -> Result> { - self.client_repository - .get_grants_for_space(client_id, space_id) - .await - } - - /// Get effective feature set IDs for a client in a space. - /// This includes explicit grants PLUS the default feature set for the space. - /// Returns a deduplicated set (no repetition). - pub async fn get_effective_grants( - &self, - client_id: &Uuid, - space_id: &str, - ) -> Result> { - // Get explicit grants from DB - let mut grants = self - .client_repository - .get_grants_for_space(client_id, space_id) - .await?; - - // Get default feature set for this space - if let Some(default_fs) = self - .feature_set_repository - .get_default_for_space(space_id) - .await? - { - // Add default if not already in grants (set semantics) - if !grants.contains(&default_fs.id) { - grants.push(default_fs.id); - } - } - - Ok(grants) - } -} diff --git a/crates/mcpmux-core/src/service/gateway_port_service.rs b/crates/mcpmux-core/src/service/gateway_port_service.rs index e6069f6e..703f0183 100644 --- a/crates/mcpmux-core/src/service/gateway_port_service.rs +++ b/crates/mcpmux-core/src/service/gateway_port_service.rs @@ -121,6 +121,18 @@ impl GatewayPortService { .map_err(|e| PortAllocationError::PersistFailed(e.to_string())) } + /// Clear the persisted gateway port. + /// + /// After clearing, [`resolve`] falls back to [`DEFAULT_GATEWAY_PORT`] (or + /// a dynamic port if the default is in use). Use this to reset the user's + /// override and return to default behavior. + pub async fn clear_persisted_port(&self) -> Result<(), PortAllocationError> { + self.settings + .delete(keys::gateway::PORT) + .await + .map_err(|e| PortAllocationError::PersistFailed(e.to_string())) + } + /// Resolve which port to use based on the fallback strategy. /// /// Strategy: @@ -313,6 +325,21 @@ mod tests { assert_eq!(service.load_persisted_port().await, Some(12345)); } + #[tokio::test] + async fn test_clear_persisted_port() { + let settings = Arc::new(InMemorySettings::new()); + let service = GatewayPortService::new(settings); + + service.save_port(54321).await.unwrap(); + assert_eq!(service.load_persisted_port().await, Some(54321)); + + service.clear_persisted_port().await.unwrap(); + assert!(service.load_persisted_port().await.is_none()); + + // Clearing again is a no-op + service.clear_persisted_port().await.unwrap(); + } + #[tokio::test] async fn test_auto_start() { let settings = Arc::new(InMemorySettings::new()); diff --git a/crates/mcpmux-core/src/service/mod.rs b/crates/mcpmux-core/src/service/mod.rs index d5533575..8cddb479 100644 --- a/crates/mcpmux-core/src/service/mod.rs +++ b/crates/mcpmux-core/src/service/mod.rs @@ -5,10 +5,8 @@ pub mod app_settings_service; mod cimd_fetcher; mod client_install; -mod client_service; mod config_export; pub mod gateway_port_service; -mod permission_service; mod registry_api_client; mod server_discovery; mod server_log_manager; @@ -17,13 +15,11 @@ mod space_service; pub use app_settings_service::{keys, AppSettingsService}; pub use cimd_fetcher::*; pub use client_install::{cursor_deep_link, vscode_deep_link}; -pub use client_service::*; pub use config_export::*; pub use gateway_port_service::{ allocate_dynamic_port, is_port_available, GatewayPortService, PortAllocationError, PortResolution, DEFAULT_GATEWAY_PORT, }; -pub use permission_service::*; pub use registry_api_client::*; pub use server_discovery::*; pub use server_log_manager::*; diff --git a/crates/mcpmux-core/src/service/permission_service.rs b/crates/mcpmux-core/src/service/permission_service.rs deleted file mode 100644 index bb03b5d3..00000000 --- a/crates/mcpmux-core/src/service/permission_service.rs +++ /dev/null @@ -1,344 +0,0 @@ -//! Permission Service - resolves effective features from granted feature sets -//! -//! This service computes which features a client can access based on their -//! granted feature sets and the feature set composition rules. - -use std::collections::HashSet; -use std::sync::Arc; - -use anyhow::Result; -use tracing::{debug, warn}; -use uuid::Uuid; - -use crate::domain::{FeatureSet, FeatureSetType, MemberMode, MemberType, ServerFeature}; -use crate::repository::{ - FeatureSetRepository, InboundMcpClientRepository, ServerFeatureRepository, -}; - -/// Resolved permissions for a client in a space -#[derive(Debug, Clone, Default)] -pub struct ResolvedPermissions { - /// Feature IDs that are allowed (from server_features table) - pub allowed_feature_ids: HashSet, - /// Whether this permission set grants all features - pub grants_all: bool, - /// Server IDs that grant all features (for server-all type) - pub all_from_servers: HashSet, -} - -impl ResolvedPermissions { - /// Check if a feature is allowed - pub fn allows_feature(&self, feature_id: &str, server_id: Option<&str>) -> bool { - if self.grants_all { - return true; - } - if let Some(sid) = server_id { - if self.all_from_servers.contains(sid) { - return true; - } - } - self.allowed_feature_ids.contains(feature_id) - } - - /// Check if a tool is allowed by name and server - pub fn allows_tool(&self, tool_name: &str, server_id: &str) -> bool { - if self.grants_all { - return true; - } - if self.all_from_servers.contains(server_id) { - return true; - } - // Check by qualified name (server_id/tool_name) - let qualified = format!("{}/{}", server_id, tool_name); - self.allowed_feature_ids.contains(&qualified) - } -} - -/// Service for resolving permissions -pub struct PermissionService { - client_repository: Arc, - feature_set_repository: Arc, - server_feature_repository: Arc, -} - -impl PermissionService { - /// Create a new permission service - pub fn new( - client_repository: Arc, - feature_set_repository: Arc, - server_feature_repository: Arc, - ) -> Self { - Self { - client_repository, - feature_set_repository, - server_feature_repository, - } - } - - /// Resolve effective permissions for a client in a space - pub async fn resolve_permissions( - &self, - client_id: &Uuid, - space_id: &str, - ) -> Result { - let mut result = ResolvedPermissions::default(); - - // Get granted feature set IDs - let granted_ids = self - .client_repository - .get_grants_for_space(client_id, space_id) - .await?; - - if granted_ids.is_empty() { - debug!( - client_id = %client_id, - space_id = %space_id, - "No grants found for client" - ); - return Ok(result); - } - - // Resolve each feature set - for fs_id in &granted_ids { - self.resolve_feature_set(fs_id, space_id, &mut result, &mut HashSet::new()) - .await?; - } - - debug!( - client_id = %client_id, - space_id = %space_id, - grants_all = %result.grants_all, - feature_count = %result.allowed_feature_ids.len(), - server_all_count = %result.all_from_servers.len(), - "Resolved permissions" - ); - - Ok(result) - } - - /// Recursively resolve a feature set - fn resolve_feature_set<'a>( - &'a self, - feature_set_id: &'a str, - space_id: &'a str, - result: &'a mut ResolvedPermissions, - visited: &'a mut HashSet, - ) -> std::pin::Pin> + Send + 'a>> { - Box::pin(async move { - // Prevent infinite recursion - if visited.contains(feature_set_id) { - warn!( - feature_set_id = %feature_set_id, - "Circular reference detected in feature set composition" - ); - return Ok(()); - } - visited.insert(feature_set_id.to_string()); - - // Get the feature set with members - let feature_set = match self - .feature_set_repository - .get_with_members(feature_set_id) - .await? - { - Some(fs) => fs, - None => { - warn!( - feature_set_id = %feature_set_id, - "Feature set not found" - ); - return Ok(()); - } - }; - - // Handle based on type - match feature_set.feature_set_type { - FeatureSetType::All => { - // All features in the space - result.grants_all = true; - debug!(feature_set_id = %feature_set_id, "Resolved as All type - grants all"); - } - FeatureSetType::Default => { - // Resolve members of the Default set - self.resolve_members(&feature_set, space_id, result, visited) - .await?; - } - FeatureSetType::ServerAll => { - // All features from a specific server - if let Some(ref server_id) = feature_set.server_id { - result.all_from_servers.insert(server_id.clone()); - debug!( - feature_set_id = %feature_set_id, - server_id = %server_id, - "Resolved as ServerAll type" - ); - } - } - FeatureSetType::Custom => { - // Resolve members recursively - self.resolve_members(&feature_set, space_id, result, visited) - .await?; - } - } - - Ok(()) - }) - } - - /// Resolve members of a feature set - fn resolve_members<'a>( - &'a self, - feature_set: &'a FeatureSet, - space_id: &'a str, - result: &'a mut ResolvedPermissions, - visited: &'a mut HashSet, - ) -> std::pin::Pin> + Send + 'a>> { - Box::pin(async move { - for member in &feature_set.members { - match member.mode { - MemberMode::Include => { - match member.member_type { - MemberType::FeatureSet => { - // Recursively resolve nested feature set - self.resolve_feature_set( - &member.member_id, - space_id, - result, - visited, - ) - .await?; - } - MemberType::Feature => { - // Add individual feature - result.allowed_feature_ids.insert(member.member_id.clone()); - } - } - } - MemberMode::Exclude => { - // For exclusions, remove from allowed set - result.allowed_feature_ids.remove(&member.member_id); - } - } - } - Ok(()) - }) - } - - /// Get all allowed features for a client in a space - pub async fn get_allowed_features( - &self, - client_id: &Uuid, - space_id: &str, - ) -> Result> { - let permissions = self.resolve_permissions(client_id, space_id).await?; - - // Get all features in the space - let all_features = self - .server_feature_repository - .list_for_space(space_id) - .await?; - - // Filter based on permissions - let allowed: Vec = if permissions.grants_all { - all_features - } else { - all_features - .into_iter() - .filter(|f| { - permissions.allows_feature(&f.id.to_string(), Some(&f.server_id)) - || permissions.all_from_servers.contains(&f.server_id) - || permissions.allowed_feature_ids.contains(&f.id.to_string()) - }) - .collect() - }; - - Ok(allowed) - } - - /// Check if a client can access a specific tool - pub async fn can_access_tool( - &self, - client_id: &Uuid, - space_id: &str, - tool_name: &str, - server_id: &str, - ) -> Result { - let permissions = self.resolve_permissions(client_id, space_id).await?; - Ok(permissions.allows_tool(tool_name, server_id)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_resolved_permissions_default() { - let perms = ResolvedPermissions::default(); - - assert!(!perms.grants_all); - assert!(perms.allowed_feature_ids.is_empty()); - assert!(perms.all_from_servers.is_empty()); - } - - #[test] - fn test_resolved_permissions_grants_all() { - let perms = ResolvedPermissions { - grants_all: true, - ..Default::default() - }; - - // grants_all should allow any feature - assert!(perms.allows_feature("any-feature", None)); - assert!(perms.allows_feature("any-feature", Some("any-server"))); - assert!(perms.allows_tool("any-tool", "any-server")); - } - - #[test] - fn test_resolved_permissions_explicit_features() { - let mut perms = ResolvedPermissions::default(); - perms.allowed_feature_ids.insert("feature-1".to_string()); - perms - .allowed_feature_ids - .insert("server-a/tool-x".to_string()); - - assert!(perms.allows_feature("feature-1", None)); - assert!(!perms.allows_feature("feature-2", None)); - - // Tool check uses qualified name - assert!(perms.allows_tool("tool-x", "server-a")); - assert!(!perms.allows_tool("tool-y", "server-a")); - } - - #[test] - fn test_resolved_permissions_server_all() { - let mut perms = ResolvedPermissions::default(); - perms.all_from_servers.insert("github-mcp".to_string()); - - // Any tool from that server should be allowed - assert!(perms.allows_tool("any-tool", "github-mcp")); - assert!(perms.allows_feature("any-feature", Some("github-mcp"))); - - // Other servers not allowed - assert!(!perms.allows_tool("tool", "other-server")); - assert!(!perms.allows_feature("feature", Some("other-server"))); - } - - #[test] - fn test_resolved_permissions_combined() { - let mut perms = ResolvedPermissions::default(); - perms - .allowed_feature_ids - .insert("explicit/tool".to_string()); - perms.all_from_servers.insert("trusted-server".to_string()); - - // Explicit feature - assert!(perms.allows_tool("tool", "explicit")); - - // Server-all grant - assert!(perms.allows_tool("any-tool", "trusted-server")); - - // Neither - assert!(!perms.allows_tool("tool", "untrusted")); - } -} diff --git a/crates/mcpmux-core/src/service/space_service.rs b/crates/mcpmux-core/src/service/space_service.rs index 233046cc..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?; @@ -91,13 +120,132 @@ impl SpaceService { self.repository.delete(id).await } - /// Get the active (default) space - pub async fn get_active(&self) -> anyhow::Result> { + /// Get the system's default Space (the gateway's routing fallback when + /// no `WorkspaceBinding` matches a session's reported workspace root). + pub async fn get_default(&self) -> anyhow::Result> { self.repository.get_default().await } +} + +#[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(); - /// Set the active space - pub async fn set_active(&self, id: &Uuid) -> anyhow::Result<()> { - self.repository.set_default(id).await + 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 9dd2b731..ce865e87 100644 --- a/crates/mcpmux-gateway/src/consumers/mcp_notifier.rs +++ b/crates/mcpmux-gateway/src/consumers/mcp_notifier.rs @@ -26,31 +26,40 @@ use tracing::{debug, info, trace, warn}; use uuid::Uuid; use crate::pool::FeatureService; -use crate::services::SpaceResolverService; +use crate::services::{FeatureSetResolverService, SessionOverrideRegistry}; -/// MCP Notifier - Sends list_changed notifications to connected MCP clients +/// MCP Notifier — sends `list_changed` notifications to connected sessions. /// -/// **Smart Consumer Pattern:** -/// - Subscribes to DomainEvents from the EventBus -/// - Tracks connected peers by client_id for notification delivery -/// - Resolves client spaces dynamically at notification time (handles follow_active mode) -/// - Dispatches list_changed notifications only to affected clients -/// - Interprets events based on MCP notification context -/// - **Content-Based Deduping**: Hashes feature lists to prevent redundant notifications -/// - **Throttles notifications** to prevent infinite loops from rapid backend changes +/// **Session-keyed registry.** A single OAuth client (Cursor, Claude +/// Desktop) can hold multiple concurrent MCP sessions, and each session +/// can resolve to a *different* (Space, FeatureSet) via WorkspaceBinding +/// — two VS Code windows on different folders are the canonical case. +/// Indexing by `mcp-session-id` lets us notify the right session(s) +/// without over-notifying the others, and matches the request-side +/// routing model (resolver consults session_id, not client_id). /// -/// **Peer Registry:** -/// - Registers peers when clients initialize (used by session manager) -/// - Unregisters peers when sessions close +/// **Fanout uses the same resolver as the request handlers.** When an +/// event implies "FS X may have changed for any session resolving to it", +/// we re-run the resolver per session and notify the ones whose resolved +/// FS list contains X (or whose resolved space matches, depending on the +/// trigger). This is what closes the "FS edit doesn't reflect until +/// reconnect" loophole. +/// +/// **Other duties (unchanged):** +/// - Listens to DomainEvents from the EventBus. +/// - Throttles per (space_id, notification_type) to prevent flapping. +/// - Hashes feature lists to dedupe spurious notifications. #[derive(Clone)] pub struct MCPNotifier { - /// Map: client_id -> peer handle - /// Clients are tracked by client_id, not by space (space is resolved per-request) - client_peers: Arc>>, - /// Space resolver for determining which space a client is currently in - space_resolver: Arc, + /// Map: `mcp-session-id` → session handle. + sessions: Arc>>, + /// FeatureSet resolver — same one the request handlers use. Consulted + /// per session to decide whether a notification applies. + feature_set_resolver: Arc, /// Feature service for calculating content hashes feature_service: Arc, + /// 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>>, @@ -76,32 +85,40 @@ enum NotificationType { /// prevents rapid state oscillation (flapping). const THROTTLE_WINDOW: Duration = Duration::from_secs(1); -/// Wrapper around Peer for storage +/// One registered MCP session — the gateway's view of a single live +/// `mcp-session-id`. The peer is what we push notifications to; the +/// `client_id` is kept for per-client fanout (e.g. on grant change). #[derive(Clone)] -struct PeerHandle { +struct SessionEntry { peer: Arc>, - /// Whether this peer has an active SSE stream (can receive notifications) + client_id: String, + /// True once the SSE stream for this session is open and notifications + /// will actually deliver. Sessions register on `initialize`; the + /// stream-active flag flips when the gateway opens the SSE side. has_active_stream: bool, } -impl PeerHandle { - fn new(peer: Arc>) -> Self { +impl SessionEntry { + fn new(client_id: String, peer: Arc>) -> Self { Self { peer, - has_active_stream: false, // Initially false until stream is created + client_id, + has_active_stream: false, } } } impl MCPNotifier { pub fn new( - space_resolver: Arc, + feature_set_resolver: Arc, feature_service: Arc, + session_overrides: Arc, ) -> Self { Self { - client_peers: Arc::new(RwLock::new(HashMap::new())), - space_resolver, + 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())), } @@ -139,28 +156,32 @@ impl MCPNotifier { hasher.finish() } - /// Register a peer for a client - /// - /// Called when a client initializes. Tracks by client_id (not space_id) because - /// space resolution is dynamic (follow_active mode can change active space). + /// Register a session for notification delivery. /// - /// Handles both initial connection and resume/reconnect scenarios. + /// Called from `on_initialized` once per `mcp-session-id`. The same + /// client may register multiple sessions concurrently (two VS Code + /// windows on different folders share one OAuth `client_id`); the + /// session-keyed map keeps them independent. /// - /// **Note**: Peer starts with `has_active_stream = false`. Call `mark_client_stream_active()` - /// after the client creates an SSE stream to enable notifications. - pub fn register_peer(&self, client_id: String, peer: Arc>) { - let handle = PeerHandle::new(peer); - let mut peers = self.client_peers.write(); - - // Replace any existing peer for this client (handles reconnect/resume) - let is_reconnect = peers.contains_key(&client_id); - peers.insert(client_id.clone(), handle); - + /// **Note**: starts with `has_active_stream = false`. Call + /// [`mark_session_stream_active`](Self::mark_session_stream_active) + /// after the SSE stream opens. + pub fn register_session( + &self, + session_id: String, + client_id: String, + peer: Arc>, + ) { + let entry = SessionEntry::new(client_id.clone(), peer); + let mut sessions = self.sessions.write(); + let is_reconnect = sessions.contains_key(&session_id); + sessions.insert(session_id.clone(), entry); info!( - client_id = %client_id, - is_reconnect = is_reconnect, - total_peers = peers.len(), - "[MCPNotifier] 📡 Registered peer for client (stream not yet active)" + %session_id, + %client_id, + is_reconnect, + total_sessions = sessions.len(), + "[MCPNotifier] 📡 Registered session (stream not yet active)" ); } @@ -173,19 +194,19 @@ impl MCPNotifier { /// spurious "first notification" issues. Without this, the first `list_changed` /// event would always be forwarded (no hash to compare against), potentially /// causing client reconnection loops. - pub fn mark_client_stream_active(&self, client_id: &str) { - let mut peers = self.client_peers.write(); - - if let Some(handle) = peers.get_mut(client_id) { - handle.has_active_stream = true; + pub fn mark_session_stream_active(&self, session_id: &str) { + let mut sessions = self.sessions.write(); + if let Some(entry) = sessions.get_mut(session_id) { + entry.has_active_stream = true; info!( - client_id = %client_id, - "[MCPNotifier] ✅ Client stream is now active (notifications enabled)" + %session_id, + client_id = %entry.client_id, + "[MCPNotifier] ✅ Session stream is now active (notifications enabled)" ); } else { warn!( - client_id = %client_id, - "[MCPNotifier] ⚠️ Attempted to mark stream active for unknown peer" + %session_id, + "[MCPNotifier] ⚠️ Attempted to mark stream active for unknown session" ); } } @@ -228,22 +249,22 @@ impl MCPNotifier { ); } - /// Unregister a peer + /// Unregister a session. /// - /// Called when a client disconnects or session closes - pub fn unregister_peer(&self, client_id: &str) { - let mut peers = self.client_peers.write(); - - if peers.remove(client_id).is_some() { + /// Called when a client disconnects or the session closes. + pub fn unregister_session(&self, session_id: &str) { + let mut sessions = self.sessions.write(); + if let Some(removed) = sessions.remove(session_id) { info!( - client_id = %client_id, - remaining_peers = peers.len(), - "[MCPNotifier] 📴 Unregistered peer" + %session_id, + client_id = %removed.client_id, + remaining_sessions = sessions.len(), + "[MCPNotifier] 📴 Unregistered session" ); } else { warn!( - client_id = %client_id, - "[MCPNotifier] ⚠️ Attempted to unregister unknown peer" + %session_id, + "[MCPNotifier] ⚠️ Attempted to unregister unknown session" ); } } @@ -299,58 +320,51 @@ impl MCPNotifier { tracker.insert((space_id, NotificationType::All), timestamp); } - /// Get all peers for a specific space (resolves client spaces at notification time) + /// Lazy GC for dead sessions. /// - /// **Key Feature**: Resolves space dynamically for each client, handling: - /// - follow_active mode (clients see active space changes) - /// - locked mode (clients stay in their locked space) - /// - Space changes without reconnection - async fn get_peers_for_space(&self, space_id: Uuid) -> Vec>> { - // Clone the client list to avoid holding lock across await - let client_list: Vec<(String, Arc>)> = { - let peers = self.client_peers.read(); - peers - .iter() - .map(|(client_id, handle)| (client_id.clone(), handle.peer.clone())) - .collect() - }; - - let mut matching_peers = Vec::new(); - - for (client_id, peer) in client_list { - // Resolve current space for this client - match self - .space_resolver - .resolve_space_for_client(&client_id) - .await - { - Ok(client_space) if client_space == space_id => { - debug!( - client_id = %client_id, - space_id = %space_id, - "[MCPNotifier] Client is in target space" - ); - matching_peers.push(peer); - } - Ok(other_space) => { - debug!( - client_id = %client_id, - client_space = %other_space, - target_space = %space_id, - "[MCPNotifier] Client is in different space, skipping" - ); - } - Err(e) => { - warn!( - client_id = %client_id, - error = %e, - "[MCPNotifier] ⚠️ Failed to resolve space for client" - ); + /// rmcp's `ServerHandler` doesn't expose a session-close callback, and + /// the streamable-HTTP session manager owns the close path internally. + /// What we *do* have on every `Peer` is `is_transport_closed()` — + /// it flips true once the underlying transport has terminated. So we + /// reap lazily: every fanout / probe pass scans for closed peers and + /// removes them from both `sessions` and `session_roots`. + /// + /// Returns the ids that were reaped (for logging / metrics). Callers + /// pass the live (snapshot) list of `(session_id, peer)` they were + /// about to iterate; this mutates `self.sessions` and the + /// `feature_set_resolver`'s session registry. + fn reap_dead_sessions(&self, snapshot: &[(String, Arc>)]) -> Vec { + let dead: Vec = snapshot + .iter() + .filter_map(|(sid, peer)| { + if peer.is_transport_closed() { + Some(sid.clone()) + } else { + None } + }) + .collect(); + if dead.is_empty() { + return dead; + } + { + let mut sessions = self.sessions.write(); + for sid in &dead { + sessions.remove(sid); } } - - matching_peers + // Also clean the session_roots registry the resolver consults so + // it doesn't keep returning stale roots / capability flags for + // sessions that no longer exist. + for sid in &dead { + self.feature_set_resolver.session_roots().remove(sid); + self.session_overrides.remove(sid); + } + info!( + reaped = dead.len(), + "[MCPNotifier] 🧹 reaped dead sessions (transport closed)" + ); + dead } /// Start listening to domain events and notifying peers @@ -402,59 +416,49 @@ impl MCPNotifier { } match event { - // ============ Grant Events ============ - // When grants are issued/revoked, tools/prompts/resources might change - DomainEvent::GrantIssued { - client_id, - space_id, - feature_set_id, - } => { - info!( - client_id = %client_id, - space_id = %space_id, - feature_set_id = %feature_set_id, - "[MCPNotifier] 📨 GrantIssued - notifying all clients in space" - ); - self.notify_all_list_changed(space_id, true).await; - } - - DomainEvent::GrantRevoked { - client_id, + DomainEvent::FeatureSetMembersChanged { space_id, feature_set_id, + .. } => { info!( - client_id = %client_id, space_id = %space_id, feature_set_id = %feature_set_id, - "[MCPNotifier] 📨 GrantRevoked - notifying all clients in space" + "[MCPNotifier] 📨 FeatureSetMembersChanged - notifying all clients in space" ); self.notify_all_list_changed(space_id, true).await; } - DomainEvent::ClientGrantsUpdated { + // Per-client grant changed — only the rootless-fallback path + // consumes these grants, so we only need to notify peers + // registered under this client_id. Bypass the space-wide fanout + // (which would over-notify roots-capable peers in the space + // whose resolution didn't change). + DomainEvent::ClientGrantChanged { client_id, space_id, - feature_set_ids, } => { info!( - client_id = %client_id, - space_id = %space_id, - feature_sets = feature_set_ids.len(), - "[MCPNotifier] 📨 ClientGrantsUpdated - notifying all clients in space" + %client_id, + %space_id, + "[MCPNotifier] 📨 ClientGrantChanged - notifying peer for this client" ); - self.notify_all_list_changed(space_id, true).await; + self.notify_peer_lists_changed(&client_id).await; } - DomainEvent::FeatureSetMembersChanged { + // A workspace binding was created / updated / deleted. Every + // session in the space may now resolve to a different FS, so + // broadcast all three list_changed notifications. `force=true` + // bypasses the content-hash dedupe because the resolver's output + // changed even when backend tool content hasn't. + DomainEvent::WorkspaceBindingChanged { space_id, - feature_set_id, - .. + workspace_root, } => { info!( space_id = %space_id, - feature_set_id = %feature_set_id, - "[MCPNotifier] 📨 FeatureSetMembersChanged - notifying all clients in space" + workspace_root = %workspace_root, + "[MCPNotifier] 📨 WorkspaceBindingChanged - notifying all clients in space" ); self.notify_all_list_changed(space_id, true).await; } @@ -511,17 +515,32 @@ impl MCPNotifier { } => { use mcpmux_core::ConnectionStatus; - // Only notify if server disconnected (features unavailable) - // We DO NOT notify on Connect because: - // 1. If it's a new server, ToolsChanged will fire separately if needed - // 2. If it's a reconnect, hashing will handle it - // 3. Most importantly: Client connections trigger auto-connects, which would cause loops - if matches!(status, ConnectionStatus::Disconnected) { + // Disconnect AND reconnect both flip the per-feature + // `is_available` flag, which `get_all_features_for_space` + // filters on — so the content hash actually changes both + // ways. We notify on each so the client's effective tool + // list reflects "configured but unavailable" features + // dropping out (on Disconnect) and coming back in (on + // Connect). `force=false` lets the hash dedup absorb the + // intermediate transient states (Connecting / Refreshing / + // AuthRequired) without spamming. + // + // Loop concern (the old comment): a client `tools/list` + // query that triggers a lazy backend connect would chain + // Connected -> list_changed -> client refetch. Hashing + // breaks that chain on the second iteration: the second + // refetch sees the same hash as the first and dedupes. + let should_notify = matches!( + status, + ConnectionStatus::Connected | ConnectionStatus::Disconnected + ); + if should_notify { info!( server_id = %server_id, space_id = %space_id, status = ?status, - "[MCPNotifier] ServerStatusChanged (Disconnected) - notifying clients to clear features" + "[MCPNotifier] ServerStatusChanged ({:?}) - re-checking effective list", + status, ); self.notify_all_list_changed(space_id, false).await; } else { @@ -529,7 +548,7 @@ impl MCPNotifier { server_id = %server_id, space_id = %space_id, status = ?status, - "[MCPNotifier] ServerStatusChanged - ignoring (not a disconnection)" + "[MCPNotifier] ServerStatusChanged - transient state, no notify" ); } } @@ -719,33 +738,47 @@ impl MCPNotifier { return; } - // Get peers for this space, filtering to only those with active streams - let (peers, _client_ids) = self.get_peers_for_space_with_streams(space_id).await; + // Get sessions in this space with active streams, paired with + // their session_id + client_id for per-push log attribution. + let targets = self.get_peers_for_space_with_streams(space_id).await; - if peers.is_empty() { - debug!(space_id = %space_id, "[MCPNotifier] No peers with active streams to notify about tools"); + if targets.is_empty() { + debug!( + space_id = %space_id, + "[MCPNotifier] No sessions with active streams to notify about tools" + ); return; } info!( space_id = %space_id, - peer_count = peers.len(), - "[MCPNotifier] 📤 Sending tools/list_changed to {} peers with active streams", - peers.len() + session_count = targets.len(), + "[MCPNotifier] 📤 Sending tools/list_changed to {} session(s) with active streams", + targets.len() ); let mut success_count = 0; let mut failure_count = 0; - for peer in peers { + for (session_id, client_id, peer) in targets { match peer.notify_tool_list_changed().await { Ok(_) => { success_count += 1; - debug!("[MCPNotifier] ✅ Sent tools/list_changed notification"); + debug!( + %session_id, + %client_id, + %space_id, + "[MCPNotifier] ✅ Sent tools/list_changed to session" + ); } Err(e) => { failure_count += 1; - warn!(error = ?e, "[MCPNotifier] Failed to send tools/list_changed"); + warn!( + %session_id, + %client_id, + error = ?e, + "[MCPNotifier] Failed to send tools/list_changed to session" + ); } } } @@ -761,70 +794,78 @@ impl MCPNotifier { } } - /// Get peers for a space that have active SSE streams (for notifications) + /// Get the sessions in `space_id` that have an active SSE stream and + /// can therefore actually receive a notification. /// - /// Returns both the peers and their client_ids (for logging) + /// Session-keyed: iterates `sessions`, re-runs the FeatureSet resolver + /// per session (same path as the request handlers), and returns the + /// `(session_id, client_id, peer)` triples whose session resolves into + /// `space_id`. Threading session_id through to the call site lets the + /// log lines on each `peer.notify_*_list_changed()` prove *which* + /// session got the push — important for verifying that two windows of + /// the same client routing into different spaces don't cross-talk. async fn get_peers_for_space_with_streams( &self, space_id: Uuid, - ) -> (Vec>>, Vec) { - // Clone the client list to avoid holding lock across await - let client_list: Vec<(String, PeerHandle)> = { - let peers = self.client_peers.read(); - peers + ) -> Vec<(String, String, Arc>)> { + let session_list: Vec<(String, String, Arc>)> = { + let sessions = self.sessions.read(); + sessions .iter() - .map(|(client_id, handle)| (client_id.clone(), handle.clone())) + .filter(|(_, e)| e.has_active_stream) + .map(|(sid, entry)| (sid.clone(), entry.client_id.clone(), entry.peer.clone())) .collect() }; - let mut matching_peers = Vec::new(); - let mut matching_client_ids = Vec::new(); + let dead = self.reap_dead_sessions( + &session_list + .iter() + .map(|(sid, _, peer)| (sid.clone(), peer.clone())) + .collect::>(), + ); + let dead_set: std::collections::HashSet<&str> = dead.iter().map(String::as_str).collect(); - for (client_id, handle) in client_list { - // Skip peers without active streams - if !handle.has_active_stream { - debug!( - client_id = %client_id, - space_id = %space_id, - "[MCPNotifier] Skipping peer without active stream" - ); + let mut matching = Vec::new(); + + for (session_id, client_id, peer) in session_list { + if dead_set.contains(session_id.as_str()) { continue; } - - // Resolve current space for this client match self - .space_resolver - .resolve_space_for_client(&client_id) + .feature_set_resolver + .resolve(Some(&session_id), Some(&client_id)) .await { - Ok(client_space) if client_space == space_id => { + Ok(resolved) if resolved.space_id == Some(space_id) => { debug!( - client_id = %client_id, - space_id = %space_id, - "[MCPNotifier] Client is in target space with active stream" + %session_id, + %client_id, + %space_id, + "[MCPNotifier] Session in target space with active stream" ); - matching_peers.push(handle.peer.clone()); - matching_client_ids.push(client_id); + matching.push((session_id, client_id, peer)); } - Ok(other_space) => { + Ok(resolved) => { debug!( - client_id = %client_id, - client_space = %other_space, + %session_id, + %client_id, + resolved_space = ?resolved.space_id, target_space = %space_id, - "[MCPNotifier] Client is in different space, skipping" + "[MCPNotifier] Session in different space, skipping" ); } Err(e) => { warn!( - client_id = %client_id, + %session_id, + %client_id, error = %e, - "[MCPNotifier] ⚠️ Failed to resolve space for client" + "[MCPNotifier] ⚠️ Failed to resolve space for session" ); } } } - (matching_peers, matching_client_ids) + matching } /// Notify all peers in a space that prompts list has changed (with throttling and deduping) @@ -869,21 +910,33 @@ impl MCPNotifier { return; } - let peers = self.get_peers_for_space(space_id).await; + let targets = self.get_peers_for_space_with_streams(space_id).await; - if peers.is_empty() { + if targets.is_empty() { return; } info!( space_id = %space_id, - peer_count = peers.len(), - "[MCPNotifier] 📤 Sending prompts/list_changed" + session_count = targets.len(), + "[MCPNotifier] 📤 Sending prompts/list_changed to {} session(s)", + targets.len() ); - for peer in peers { - if let Err(e) = peer.notify_prompt_list_changed().await { - warn!(error = ?e, "[MCPNotifier] Failed to send prompts/list_changed"); + for (session_id, client_id, peer) in targets { + match peer.notify_prompt_list_changed().await { + Ok(_) => debug!( + %session_id, + %client_id, + %space_id, + "[MCPNotifier] ✅ Sent prompts/list_changed to session" + ), + Err(e) => warn!( + %session_id, + %client_id, + error = ?e, + "[MCPNotifier] Failed to send prompts/list_changed to session" + ), } } } @@ -930,22 +983,184 @@ impl MCPNotifier { return; } - let peers = self.get_peers_for_space(space_id).await; + let targets = self.get_peers_for_space_with_streams(space_id).await; - if peers.is_empty() { + if targets.is_empty() { return; } info!( space_id = %space_id, - peer_count = peers.len(), - "[MCPNotifier] 📤 Sending resources/list_changed" + session_count = targets.len(), + "[MCPNotifier] 📤 Sending resources/list_changed to {} session(s)", + targets.len() ); - for peer in peers { - if let Err(e) = peer.notify_resource_list_changed().await { - warn!(error = ?e, "[MCPNotifier] Failed to send resources/list_changed"); + for (session_id, client_id, peer) in targets { + match peer.notify_resource_list_changed().await { + Ok(_) => debug!( + %session_id, + %client_id, + %space_id, + "[MCPNotifier] ✅ Sent resources/list_changed to session" + ), + Err(e) => warn!( + %session_id, + %client_id, + error = ?e, + "[MCPNotifier] Failed to send resources/list_changed to session" + ), } } } + + /// Send all three list_changed notifications to a single peer, bypassing + /// the space-level hash dedup and throttle. + /// + /// Called when a *specific session's* feature-set resolution flips — + /// e.g. workspace roots arrive after `initialize` and now match a + /// binding, so the client's effective tool set differs from what it + /// just fetched. The space-wide bridge can't catch this on its own: + /// its hash is per-space, not per-resolved-FS, so a flip from the + /// fallback FS to a bound FS doesn't change the space hash even though + /// the client's view changed. + pub async fn notify_peer_lists_changed(&self, client_id: &str) { + if DISABLE_ALL_NOTIFICATIONS { + trace!(%client_id, "[MCPNotifier] 🚫 disabled — skipping peer list_changed"); + return; + } + + // A single client may hold several active sessions (multi-window + // editors, parallel CLI invocations). Push the notification on + // every active session for that client_id; client-side dedup is + // their problem, but missing a session would be ours. + let snapshot: Vec<(String, Arc>)> = { + let sessions = self.sessions.read(); + sessions + .iter() + .filter(|(_, e)| e.client_id == client_id && e.has_active_stream) + .map(|(sid, e)| (sid.clone(), e.peer.clone())) + .collect() + }; + let dead = self.reap_dead_sessions(&snapshot); + let dead_set: std::collections::HashSet<&str> = dead.iter().map(String::as_str).collect(); + let live: Vec<(String, Arc>)> = snapshot + .into_iter() + .filter(|(sid, _)| !dead_set.contains(sid.as_str())) + .collect(); + + if live.is_empty() { + debug!( + %client_id, + "[MCPNotifier] no active session — skipping peer list_changed" + ); + return; + } + + info!( + %client_id, + session_count = live.len(), + "[MCPNotifier] 📤 per-client list_changed (resolution flipped or grant edited)" + ); + + for (session_id, peer) in &live { + 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 bb399c24..3841ec26 100644 --- a/crates/mcpmux-gateway/src/lib.rs +++ b/crates/mcpmux-gateway/src/lib.rs @@ -23,7 +23,7 @@ pub use oauth::{OAuthConfig, OAuthManager, OAuthToken}; pub use permissions::{PermissionFilter, PermissionSet}; pub use server::{ AutoConnectResult, DependenciesBuilder, GatewayConfig, GatewayDependencies, GatewayServer, - GatewayState, PendingAuthorization, StartupOrchestrator, + GatewayServerHandle, GatewayState, PendingAuthorization, StartupOrchestrator, }; // Pool module - SOLID architecture @@ -48,6 +48,7 @@ pub use pool::{ McpClientConnection, McpClientHandler, OAuthCallback, + OAuthCompleteEvent, OAuthInitResult, OAuthTokenInfo, // OAuth @@ -69,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 d6f254ae..c62a7ded 100644 --- a/crates/mcpmux-gateway/src/mcp/handler.rs +++ b/crates/mcpmux-gateway/src/mcp/handler.rs @@ -12,7 +12,7 @@ use rmcp::{ use std::sync::Arc; use tracing::{debug, info, warn}; -use super::context::{extract_oauth_context, OAuthContext}; +use super::context::{extract_oauth_context, extract_session_id, OAuthContext}; use crate::consumers::MCPNotifier; use crate::server::ServiceContainer; @@ -81,14 +81,251 @@ impl McpMuxGatewayHandler { } } + /// Log resolver decision, emit `WorkspaceNeedsBinding` when a session + /// reports roots but no binding matched (`source=Default`), and — when + /// the session's resolved FS *flipped* from a prior value — fire a + /// per-peer `list_changed` so the client re-pulls its tools. + /// + /// `notifier` is optional: callers from contexts where peer notification + /// doesn't apply (e.g. rootless init paths) can pass `None`. + /// + /// Rootless sessions never trigger the binding prompt — there's nothing + /// to bind (caller passes `root_for_prompt = None`). + async fn log_and_notify_resolution( + services: &std::sync::Arc, + notifier: Option<&MCPNotifier>, + client_id: &str, + session_id: Option<&str>, + root_for_prompt: Option<&str>, + ) { + let resolver = &services.feature_set_resolver; + match resolver.resolve(session_id, Some(client_id)).await { + Ok(resolved) => { + info!( + %client_id, + session_id = session_id.unwrap_or(""), + feature_set_ids = ?resolved.feature_set_ids, + space_id = resolved.space_id.map(|u| u.to_string()).unwrap_or_else(|| "".into()), + source = ?resolved.source, + "[FeatureSetResolver] resolved", + ); + + // Track the resolved FS fingerprint per session so we can + // detect flips. The very first sighting (no prior entry) + // counts as a flip — that's the case where the client's + // `tools/list` at init saw an empty/pending list but roots + // arriving later may have landed on a binding. Firing once + // on first sight is safe (idempotent re-list); the dedup + // protects against repeated identical resolutions. + if let (Some(sid), Some(notifier)) = (session_id, notifier) { + let changed = services + .session_roots + .record_resolution(sid, resolved.fingerprint().as_deref()); + if changed { + notifier.notify_peer_lists_changed(client_id).await; + } + } + + // Prompt only when the session reported a root but no + // binding matched (`Deny` with a non-empty root_for_prompt). + // PendingRoots / ClientGrant / WorkspaceBinding never + // trigger the prompt. + let should_prompt = + matches!(resolved.source, crate::services::ResolutionSource::Deny); + if let (true, Some(sid), Some(space_id), Some(root)) = ( + should_prompt, + session_id, + resolved.space_id, + root_for_prompt, + ) { + services.gateway_state.read().await.emit_domain_event( + mcpmux_core::DomainEvent::WorkspaceNeedsBinding { + client_id: client_id.to_string(), + session_id: sid.to_string(), + space_id, + workspace_root: root.to_string(), + }, + ); + } + } + Err(e) => { + warn!( + %client_id, + error = %e, + "[FeatureSetResolver] resolve failed", + ); + } + } + } + + /// Resolve the (Space, FeatureSet ids) the gateway should route a + /// session through. The OAuth-context space is *not* used for routing + /// — when a `WorkspaceBinding` matches, the binding's target space is + /// authoritative and may differ from the OAuth-bound space (this is + /// the whole point of workspace-root routing). Pass the returned + /// `space_id` to every `feature_service.get_*_for_grants` / + /// `routing_service.call_tool` invocation; otherwise the lookup queries + /// the wrong space and returns 0 matches. + async fn resolve_routing( + &self, + session_id: Option<&str>, + client_id: &str, + ) -> Result<(uuid::Uuid, Vec), McpError> { + let resolved = self + .services + .authorization_service + .resolve(session_id, Some(client_id)) + .await + .map_err(|e| McpError::internal_error(format!("Failed to resolve: {e}"), None))?; + let space_id = resolved.space_id.ok_or_else(|| { + McpError::internal_error("No space resolved (no default space configured)", None) + })?; + Ok((space_id, resolved.feature_set_ids)) + } + + /// On-demand `roots/list` probe for sessions that initialized as + /// roots-capable but have no roots yet — typically because the first + /// `list_roots()` from `on_initialized` raced this request, or its + /// retries are still mid-backoff after a transient failure. + /// + /// Without this, a roots-capable client that fires `tools/list` + /// immediately after `notifications/initialized` resolves to + /// `PendingRoots` and gets only the meta tools — even though we'd + /// have the right answer milliseconds later. The 300 ms timeout + /// caps the latency cost of bridging that gap; in steady state + /// (`session_roots.get(sid)` already populated) this is a no-op + /// early-return. + /// + /// Rate-limited per session to once per second so a burst of + /// `tools/list` + `prompts/list` + `resources/list` doesn't fan out + /// three parallel `peer.list_roots()` calls. + async fn ensure_roots_probed( + &self, + peer: &rmcp::service::Peer, + session_id: Option<&str>, + client_id: &str, + ) { + let Some(sid) = session_id else { return }; + // Fast path: already have a definitive answer (Some(roots), + // possibly empty). No probe needed. + if self.services.session_roots.get(sid).is_some() { + return; + } + // Not roots-capable → resolver routes via client grants, no + // probe useful. + if !self + .services + .session_roots + .is_roots_capable(sid) + .unwrap_or(false) + { + return; + } + // Cool-down after a recent failed probe so we don't hammer a + // peer whose previous list_roots() errored. Doesn't apply + // when a probe is currently *running* — that's the + // probe_lock's job below. + if self + .services + .session_roots + .should_throttle_probe(sid, std::time::Duration::from_secs(1)) + { + return; + } + + // Single-flight: serialize concurrent probes per session so a + // burst of three list calls (tools/list + prompts/list + + // resources/list within milliseconds) doesn't fan out three + // upstream `peer.list_roots()` calls. The first request enters + // the critical section, fires the probe, populates + // session_roots; the second and third await the same lock, + // then re-check session_roots and exit early. + // + // Without this, the followers used to skip the probe entirely + // (boolean `claim_probe` flag) and resolve to PendingRoots — + // exactly the empty-tools-list bug Claude Code's VS Code + // extension was hitting. + let lock = self.services.session_roots.probe_lock(sid); + let _guard = lock.lock().await; + + // Recheck after acquiring the lock — the predecessor probe may + // have already populated the registry. + if self.services.session_roots.get(sid).is_some() { + return; + } + + const PROBE_BUDGET: std::time::Duration = std::time::Duration::from_millis(300); + let outcome = tokio::time::timeout(PROBE_BUDGET, peer.list_roots()).await; + // Stamp completion regardless of success/failure so the + // sequential cool-down kicks in for the next caller. + self.services.session_roots.mark_probe_completed(sid); + match outcome { + Ok(Ok(result)) => { + let uris: Vec = result.roots.iter().map(|r| r.uri.to_string()).collect(); + self.services + .session_roots + .set(sid, uris.iter().map(|s| s.as_str())); + debug!( + %client_id, + session_id = %sid, + roots = ?uris, + "[FeatureSetResolver] on-demand probe populated roots", + ); + // Notify the UI / re-emit `WorkspaceNeedsBinding` if the + // session now resolves to Deny because of an unbound + // root. Fire-and-forget so the request itself isn't + // blocked on the desktop event bus. + let services = self.services.clone(); + let notifier = self.notification_bridge.clone(); + let client_id = client_id.to_string(); + let session_id = sid.to_string(); + let root_for_prompt = uris + .into_iter() + .filter(|r| !r.is_empty()) + .max_by_key(|r| r.len()); + tokio::spawn(async move { + services + .gateway_state + .read() + .await + .emit_domain_event(mcpmux_core::DomainEvent::SessionRootsChanged); + Self::log_and_notify_resolution( + &services, + Some(¬ifier), + &client_id, + Some(&session_id), + root_for_prompt.as_deref(), + ) + .await; + }); + } + Ok(Err(e)) => { + debug!( + %client_id, + session_id = %sid, + error = %e, + "[FeatureSetResolver] on-demand probe failed (will retry on next request after throttle)", + ); + } + Err(_elapsed) => { + debug!( + %client_id, + session_id = %sid, + budget_ms = PROBE_BUDGET.as_millis(), + "[FeatureSetResolver] on-demand probe timed out (will retry on next request after throttle)", + ); + } + } + } + /// Build InitializeResult with negotiated protocol version fn build_initialize_result(&self, protocol_version: ProtocolVersion) -> InitializeResult { - InitializeResult { - protocol_version, - capabilities: self.get_info().capabilities, - server_info: self.get_info().server_info, - instructions: self.get_info().instructions, - } + let info = self.get_info(); + let mut result = InitializeResult::new(info.capabilities); + result.protocol_version = protocol_version; + result.server_info = info.server_info; + result.instructions = info.instructions; + result } } @@ -98,32 +335,28 @@ impl ServerHandler for McpMuxGatewayHandler { // Note: get_info is called frequently, no logging needed - ServerInfo { - protocol_version: Default::default(), - capabilities: ServerCapabilities::builder() - .enable_tools_with(ToolsCapability { - list_changed: Some(true), - }) - .enable_prompts_with(PromptsCapability { - list_changed: Some(true), - }) - .enable_resources_with(ResourcesCapability { - subscribe: Some(false), - list_changed: Some(true), - }) - .build(), - server_info: Implementation { - name: "mcpmux-gateway".to_string(), - version: env!("CARGO_PKG_VERSION").to_string(), - title: Some("McpMux".to_string()), - ..Default::default() - }, - instructions: Some( - "McpMux aggregates multiple MCP servers. Use tools/prompts/resources \ - from your authorized backend servers." - .to_string(), - ), - } + let capabilities = ServerCapabilities::builder() + .enable_tools_with(ToolsCapability { + list_changed: Some(true), + }) + .enable_prompts_with(PromptsCapability { + list_changed: Some(true), + }) + .enable_resources_with(ResourcesCapability { + subscribe: Some(false), + list_changed: Some(true), + }) + .build(); + let mut server_info = Implementation::new("mcpmux-gateway", env!("CARGO_PKG_VERSION")); + server_info.title = Some("McpMux".to_string()); + let mut info = ServerInfo::new(capabilities); + info.server_info = server_info; + info.instructions = Some( + "McpMux aggregates multiple MCP servers. Use tools/prompts/resources \ + from your authorized backend servers." + .to_string(), + ); + info } async fn initialize( @@ -159,21 +392,179 @@ impl ServerHandler for McpMuxGatewayHandler { } }; - // Register peer with MCPNotifier for list_changed notification delivery + // Register the *session* with MCPNotifier so subsequent fanout can + // re-resolve per session (a single OAuth client can hold multiple + // sessions on different folders, each routing independently). let peer = std::sync::Arc::new(context.peer); - self.notification_bridge - .register_peer(oauth_ctx.client_id.clone(), peer); - - // Mark the client stream as active immediately - RMCP's session transport - // handles SSE streaming and message caching internally - self.notification_bridge - .mark_client_stream_active(&oauth_ctx.client_id); + let session_id_for_register = extract_session_id(&context.extensions); + if let Some(sid) = session_id_for_register.as_deref() { + self.notification_bridge.register_session( + sid.to_string(), + oauth_ctx.client_id.clone(), + peer.clone(), + ); + // Mark the SSE stream as active immediately — RMCP's session + // transport handles streaming + message caching internally. + self.notification_bridge.mark_session_stream_active(sid); + } else { + warn!( + client_id = %oauth_ctx.client_id, + "[on_initialized] no mcp-session-id; skipping notifier registration (rare — stateless transport?)" + ); + } // Pre-populate feature hashes to prevent spurious first notifications self.notification_bridge .prime_hashes_for_space(oauth_ctx.space_id) .await; + // If the peer advertised the `roots` capability, fetch its reported + // workspace roots into the session registry so the resolver can pick + // a binding. Then log + (if no binding matched) prompt the UI. + if let Some(session_id) = extract_session_id(&context.extensions) { + let declares_roots = peer + .peer_info() + .map(|info| info.capabilities.roots.is_some()) + .unwrap_or(false); + // Stash the capability so the resolver can branch between + // workspace-binding routing (capable) and the per-client grant + // fallback (rootless). Done unconditionally so the registry has + // a definitive answer for every session, not just those with + // roots declared. + self.services + .session_roots + .set_roots_capable(&session_id, declares_roots); + // Persist the bit on the client row, *always* — the Clients UI + // needs to distinguish "never observed" from "explicitly + // rootless" so its capability badge isn't misleading on + // newly-approved clients. The repo applies sticky-positive + // semantics on `reports_roots` so a one-off rootless reconnect + // doesn't bounce the badge. + { + let repo = self.services.dependencies.inbound_client_repo.clone(); + let cid = oauth_ctx.client_id.clone(); + tokio::spawn(async move { + if let Err(e) = repo.mark_roots_capability(&cid, declares_roots).await { + debug!( + client_id = %cid, + error = %e, + "[on_initialized] mark_roots_capability failed (non-fatal)" + ); + } + }); + } + if declares_roots { + let peer_for_roots = peer.clone(); + let session_roots = self.services.session_roots.clone(); + let services = self.services.clone(); + let notifier = self.notification_bridge.clone(); + let client_id_str = oauth_ctx.client_id.clone(); + let session_id_for_task = session_id.clone(); + tokio::spawn(async move { + // Retry list_roots() on transport errors with bounded + // backoff. Without roots a roots-capable session is + // useless (resolver returns PendingRoots → empty + // tools list), so it's worth being aggressive about + // recovering from transient failures. Empty results + // (`Ok([])`) are NOT retried — that's a valid answer + // ("client has no folder open right now") and the + // client will notify us via `roots/list_changed` if + // they open one. + // + // Total budget ≈ 8.2 s wall-clock if every attempt + // hits a transport error before timing out. + const BACKOFFS_MS: &[u64] = &[100, 300, 800, 2000, 5000]; + let max_attempts = BACKOFFS_MS.len() + 1; // 6 total = 1 initial + 5 retries + let mut attempt: usize = 0; + let result = loop { + match peer_for_roots.list_roots().await { + Ok(r) => break Some(r), + Err(e) => { + attempt += 1; + if attempt >= max_attempts { + warn!( + client_id = %client_id_str, + session_id = %session_id_for_task, + attempts = attempt, + error = %e, + "[FeatureSetResolver] peer.list_roots() exhausted retries; session left unresolved (next list/get request will re-probe)", + ); + break None; + } + let backoff = BACKOFFS_MS[attempt - 1]; + warn!( + client_id = %client_id_str, + session_id = %session_id_for_task, + attempt, + max_attempts, + next_backoff_ms = backoff, + error = %e, + "[FeatureSetResolver] peer.list_roots() failed; retrying after backoff", + ); + tokio::time::sleep(std::time::Duration::from_millis(backoff)).await; + } + } + }; + + let Some(result) = result else { return }; + + let uris: Vec = + result.roots.iter().map(|r| r.uri.to_string()).collect(); + session_roots.set(&session_id_for_task, uris.iter().map(|s| s.as_str())); + debug!( + client_id = %client_id_str, + session_id = %session_id_for_task, + roots = ?uris, + attempts = attempt + 1, + "[FeatureSetResolver] fetched MCP roots", + ); + + // Tell the desktop UI the detected-roots list may + // have grown so the Workspaces tab refreshes + // without waiting for a polling cycle. + services + .gateway_state + .read() + .await + .emit_domain_event(mcpmux_core::DomainEvent::SessionRootsChanged); + + // Pick the longest (most specific) normalized + // root for the sheet. The resolver has already + // normalized them on insert. Passing `Some(root)` + // lets log_and_notify_resolution emit + // `WorkspaceNeedsBinding` if the resolver ended + // up at `source = Deny` (i.e. no binding yet). + let root_for_prompt = + session_roots.get(&session_id_for_task).and_then(|roots| { + roots + .into_iter() + .filter(|r| !r.is_empty()) + .max_by_key(|r| r.len()) + }); + + Self::log_and_notify_resolution( + &services, + Some(¬ifier), + &client_id_str, + Some(&session_id_for_task), + root_for_prompt.as_deref(), + ) + .await; + }); + } else { + // No roots declared — silent default, never prompt + // (root_for_prompt = None suppresses the emit). + Self::log_and_notify_resolution( + &self.services, + Some(&self.notification_bridge), + &oauth_ctx.client_id, + Some(&session_id), + None, + ) + .await; + } + } + info!( client_id = %oauth_ctx.client_id, space_id = %oauth_ctx.space_id, @@ -181,6 +572,79 @@ impl ServerHandler for McpMuxGatewayHandler { ); } + /// The client told us its roots list changed (e.g. VS Code added a + /// folder to a multi-root workspace). Re-fetch via `list_roots`, + /// update the session registry, and re-run the resolver — if any root + /// is still unbound, `log_and_notify_resolution` fires a fresh + /// `WorkspaceNeedsBinding` so the sheet pops for the newly-surfaced + /// folder. + async fn on_roots_list_changed(&self, context: NotificationContext) { + let oauth_ctx = match self.get_oauth_context(&context.extensions) { + Ok(ctx) => ctx, + Err(e) => { + warn!( + "Failed to extract OAuth context on_roots_list_changed: {}", + e + ); + return; + } + }; + let Some(session_id) = extract_session_id(&context.extensions) else { + debug!("[FeatureSetResolver] roots/list_changed with no session id — skipping"); + return; + }; + let peer = std::sync::Arc::new(context.peer); + let session_roots = self.services.session_roots.clone(); + let services = self.services.clone(); + let notifier = self.notification_bridge.clone(); + let client_id_str = oauth_ctx.client_id.clone(); + let session_id_for_task = session_id.clone(); + tokio::spawn(async move { + match peer.list_roots().await { + Ok(result) => { + let uris: Vec = + result.roots.iter().map(|r| r.uri.to_string()).collect(); + session_roots.set(&session_id_for_task, uris.iter().map(|s| s.as_str())); + debug!( + client_id = %client_id_str, + session_id = %session_id_for_task, + roots = ?uris, + "[FeatureSetResolver] refreshed MCP roots (roots/list_changed)", + ); + services + .gateway_state + .read() + .await + .emit_domain_event(mcpmux_core::DomainEvent::SessionRootsChanged); + + let root_for_prompt = + session_roots.get(&session_id_for_task).and_then(|roots| { + roots + .into_iter() + .filter(|r| !r.is_empty()) + .max_by_key(|r| r.len()) + }); + Self::log_and_notify_resolution( + &services, + Some(¬ifier), + &client_id_str, + Some(&session_id_for_task), + root_for_prompt.as_deref(), + ) + .await; + } + Err(e) => { + debug!( + client_id = %client_id_str, + session_id = %session_id_for_task, + error = %e, + "[FeatureSetResolver] refresh list_roots failed — silent", + ); + } + } + }); + } + async fn list_tools( &self, _params: Option, @@ -189,26 +653,39 @@ impl ServerHandler for McpMuxGatewayHandler { let oauth_ctx = self .get_oauth_context(&context.extensions) .map_err(|e| McpError::invalid_params(e.to_string(), None))?; - - // Get client's grants - let feature_set_ids = self - .services - .authorization_service - .get_client_grants(&oauth_ctx.client_id, &oauth_ctx.space_id) - .await - .map_err(|e| McpError::internal_error(format!("Failed to get grants: {}", e), None))?; - - // Get tools via FeatureService + let session_id_owned = extract_session_id(&context.extensions); + // Bridge the init race: roots-capable sessions whose first + // `list_roots()` raced this request get a one-shot 300 ms probe + // here so they end up at the right routing decision instead of + // empty (PendingRoots). Throttled per session. + self.ensure_roots_probed( + &context.peer, + session_id_owned.as_deref(), + &oauth_ctx.client_id, + ) + .await; + // Resolve routing once: the resolver returns the authoritative + // (Space, FS) for this session — this may differ from oauth_ctx + // when a WorkspaceBinding redirects to another space. + let (space_id, feature_set_ids) = self + .resolve_routing(session_id_owned.as_deref(), &oauth_ctx.client_id) + .await?; + + // Get advertised tools (meta + surfaced only) for client tools/list. let tools = self .services .pool_services .feature_service - .get_tools_for_grants(&oauth_ctx.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))?; // Convert to MCP Tool types with qualified names (prefix.tool_name) - let mcp_tools: Vec = tools + let mut mcp_tools: Vec = tools .iter() .filter_map(|f| { f.raw_json.as_ref().and_then(|json| { @@ -220,6 +697,14 @@ impl ServerHandler for McpMuxGatewayHandler { }) .collect(); + // Append built-in `mcpmux_*` meta tools when enabled. Default is ON; + // users can set `gateway.meta_tools_enabled = "false"` in settings + // to hide the entire namespace — useful when a deployment explicitly + // wants a non-self-managing gateway. + if self.services.meta_tool_registry.is_enabled().await { + mcp_tools.extend(self.services.meta_tool_registry.list_as_tools()); + } + // Log tool names at DEBUG level for visibility let tool_names: Vec = mcp_tools.iter().map(|t| t.name.to_string()).collect(); debug!( @@ -247,13 +732,109 @@ impl ServerHandler for McpMuxGatewayHandler { "call_tool" ); - // Get client's feature set grants for authorization - let feature_set_ids = self + let session_id_owned = extract_session_id(&context.extensions); + let session_id = session_id_owned.as_deref(); + + // Intercept meta tools (mcpmux_*) BEFORE feature-set filtering. + // When the master switch is off we fall through to the feature-set + // path where the tool will miss and surface a normal "not found" + // error — same behaviour a client would see for any unknown tool. + if crate::services::is_meta_tool(¶ms.name) + && self.services.meta_tool_registry.contains(¶ms.name) + && self.services.meta_tool_registry.is_enabled().await + { + // Note: client_id is the OAuth client identity (a URL for DCR- + // registered clients like Claude, a UUID for others). The meta- + // tool registry treats it as an opaque string identity key. + let args: serde_json::Value = params + .arguments + .map(|a| serde_json::to_value(a).unwrap_or(serde_json::Value::Null)) + .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) => { + 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 - .authorization_service - .get_client_grants(&oauth_ctx.client_id, &oauth_ctx.space_id) + .pool_services + .feature_service + .find_server_for_qualified_tool(&space_id_str, ¶ms.name) .await - .map_err(|e| McpError::internal_error(format!("Failed to get grants: {}", e), None))?; + { + 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 @@ -261,8 +842,9 @@ impl ServerHandler for McpMuxGatewayHandler { .pool_services .routing_service .call_tool( - oauth_ctx.space_id, + space_id, &feature_set_ids, + session_id, ¶ms.name, serde_json::to_value(params.arguments.unwrap_or_default()).unwrap_or_default(), ) @@ -321,12 +903,12 @@ impl ServerHandler for McpMuxGatewayHandler { "call_tool result" ); - let result = CallToolResult { - content, - structured_content: None, - is_error: Some(tool_result.is_error), - meta: None, + let mut result = if tool_result.is_error { + CallToolResult::error(content) + } else { + CallToolResult::success(content) }; + result.structured_content = tool_result.structured_content; Ok(result) } @@ -339,19 +921,26 @@ impl ServerHandler for McpMuxGatewayHandler { let oauth_ctx = self .get_oauth_context(&context.extensions) .map_err(|e| McpError::invalid_params(e.to_string(), None))?; - - let feature_set_ids = self - .services - .authorization_service - .get_client_grants(&oauth_ctx.client_id, &oauth_ctx.space_id) - .await - .map_err(|e| McpError::internal_error(format!("Failed to get grants: {}", e), None))?; + let session_id_owned = extract_session_id(&context.extensions); + self.ensure_roots_probed( + &context.peer, + session_id_owned.as_deref(), + &oauth_ctx.client_id, + ) + .await; + let (space_id, feature_set_ids) = self + .resolve_routing(session_id_owned.as_deref(), &oauth_ctx.client_id) + .await?; let prompts = self .services .pool_services .feature_service - .get_prompts_for_grants(&oauth_ctx.space_id.to_string(), &feature_set_ids) + .get_prompts_for_grants( + &space_id.to_string(), + &feature_set_ids, + session_id_owned.as_deref(), + ) .await .map_err(|e| McpError::internal_error(format!("Failed to get prompts: {}", e), None))?; @@ -387,28 +976,28 @@ 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(session_id_owned.as_deref(), &oauth_ctx.client_id) + .await?; let (server_id, prompt_name) = self .services .pool_services .feature_service - .parse_qualified_prompt_name(&oauth_ctx.space_id.to_string(), ¶ms.name) + .parse_qualified_prompt_name(&space_id.to_string(), ¶ms.name) .await .map_err(|e| McpError::invalid_params(format!("Invalid prompt name: {}", e), None))?; - // Verify authorization - let feature_set_ids = self - .services - .authorization_service - .get_client_grants(&oauth_ctx.client_id, &oauth_ctx.space_id) - .await - .map_err(|e| McpError::internal_error(format!("Failed to get grants: {}", e), None))?; - let authorized_prompts = self .services .pool_services .feature_service - .get_prompts_for_grants(&oauth_ctx.space_id.to_string(), &feature_set_ids) + .get_prompts_for_grants( + &space_id.to_string(), + &feature_set_ids, + session_id_owned.as_deref(), + ) .await .map_err(|e| { McpError::internal_error(format!("Failed to verify authorization: {}", e), None) @@ -429,12 +1018,7 @@ impl ServerHandler for McpMuxGatewayHandler { .services .pool_services .pool_service - .get_prompt( - oauth_ctx.space_id, - &server_id, - &prompt_name, - params.arguments, - ) + .get_prompt(space_id, &server_id, &prompt_name, params.arguments) .await .map_err(|e| McpError::internal_error(format!("Get prompt failed: {}", e), None))?; @@ -454,19 +1038,26 @@ impl ServerHandler for McpMuxGatewayHandler { let oauth_ctx = self .get_oauth_context(&context.extensions) .map_err(|e| McpError::invalid_params(e.to_string(), None))?; - - let feature_set_ids = self - .services - .authorization_service - .get_client_grants(&oauth_ctx.client_id, &oauth_ctx.space_id) - .await - .map_err(|e| McpError::internal_error(format!("Failed to get grants: {}", e), None))?; + let session_id_owned = extract_session_id(&context.extensions); + self.ensure_roots_probed( + &context.peer, + session_id_owned.as_deref(), + &oauth_ctx.client_id, + ) + .await; + let (space_id, feature_set_ids) = self + .resolve_routing(session_id_owned.as_deref(), &oauth_ctx.client_id) + .await?; let resources = self .services .pool_services .feature_service - .get_resources_for_grants(&oauth_ctx.space_id.to_string(), &feature_set_ids) + .get_resources_for_grants( + &space_id.to_string(), + &feature_set_ids, + session_id_owned.as_deref(), + ) .await .map_err(|e| { McpError::internal_error(format!("Failed to get resources: {}", e), None) @@ -500,12 +1091,16 @@ 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(session_id_owned.as_deref(), &oauth_ctx.client_id) + .await?; let server_id = self .services .pool_services .feature_service - .find_server_for_resource(&oauth_ctx.space_id.to_string(), ¶ms.uri) + .find_server_for_resource(&space_id.to_string(), ¶ms.uri) .await .map_err(|e| { McpError::internal_error(format!("Failed to resolve resource: {}", e), None) @@ -514,19 +1109,15 @@ impl ServerHandler for McpMuxGatewayHandler { McpError::invalid_params(format!("Resource '{}' not found", params.uri), None) })?; - // Verify authorization - let feature_set_ids = self - .services - .authorization_service - .get_client_grants(&oauth_ctx.client_id, &oauth_ctx.space_id) - .await - .map_err(|e| McpError::internal_error(format!("Failed to get grants: {}", e), None))?; - let authorized_resources = self .services .pool_services .feature_service - .get_resources_for_grants(&oauth_ctx.space_id.to_string(), &feature_set_ids) + .get_resources_for_grants( + &space_id.to_string(), + &feature_set_ids, + session_id_owned.as_deref(), + ) .await .map_err(|e| { McpError::internal_error(format!("Failed to verify authorization: {}", e), None) @@ -547,7 +1138,7 @@ impl ServerHandler for McpMuxGatewayHandler { .services .pool_services .pool_service - .read_resource(oauth_ctx.space_id, &server_id, ¶ms.uri) + .read_resource(space_id, &server_id, ¶ms.uri) .await .map_err(|e| McpError::internal_error(format!("Read resource failed: {}", e), None))?; @@ -557,7 +1148,7 @@ impl ServerHandler for McpMuxGatewayHandler { .filter_map(|v| serde_json::from_value(v).ok()) .collect(); - Ok(ReadResourceResult { contents }) + Ok(ReadResourceResult::new(contents)) } /// Override on_custom_request to handle "initialize" with flexible protocol negotiation diff --git a/crates/mcpmux-gateway/src/oauth/dcr.rs b/crates/mcpmux-gateway/src/oauth/dcr.rs index faaf42f0..ed255019 100644 --- a/crates/mcpmux-gateway/src/oauth/dcr.rs +++ b/crates/mcpmux-gateway/src/oauth/dcr.rs @@ -108,8 +108,6 @@ fn build_inbound_client_from_request( response_types: Vec, token_endpoint_auth_method: String, client_alias: Option, - connection_mode: String, - locked_space_id: Option, last_seen: Option, created_at: String, updated_at: String, @@ -135,12 +133,13 @@ fn build_inbound_client_from_request( metadata_url: None, metadata_cached_at: None, metadata_cache_ttl: None, - // MCP client settings - connection_mode, - locked_space_id, last_seen, created_at, updated_at, + // Capability bits default off / unknown; the gateway flips them + // on the first `initialize` for any session of this client. + reports_roots: false, + roots_capability_known: false, } } @@ -170,6 +169,48 @@ impl DcrError { } } +/// Check whether a requested redirect URI matches one in the registered list. +/// +/// Per RFC 8252 §7.3, when the registered redirect URI is a loopback address +/// (`127.0.0.1`, `::1`, or `localhost`), the authorization server MUST ignore +/// the port component when matching — native public clients obtain an ephemeral +/// port from the OS at request time, so the port will differ between the DCR +/// registration and the `/authorize` request. +/// +/// For non-loopback URIs (HTTPS, custom schemes like `cursor://`), strict +/// byte-exact equality is required. +pub fn is_redirect_uri_allowed(registered: &[String], requested: &str) -> bool { + registered + .iter() + .any(|r| redirect_uri_matches(r, requested)) +} + +fn redirect_uri_matches(registered: &str, requested: &str) -> bool { + if registered == requested { + return true; + } + + let (Ok(reg_url), Ok(req_url)) = (url::Url::parse(registered), url::Url::parse(requested)) + else { + return false; + }; + + let is_loopback = |u: &url::Url| match u.host() { + Some(url::Host::Ipv4(ip)) => ip.is_loopback(), + Some(url::Host::Ipv6(ip)) => ip.is_loopback(), + Some(url::Host::Domain(d)) => d.eq_ignore_ascii_case("localhost"), + None => false, + }; + + if !is_loopback(®_url) || !is_loopback(&req_url) { + return false; + } + + reg_url.scheme() == req_url.scheme() + && reg_url.host() == req_url.host() + && reg_url.path() == req_url.path() +} + /// Validate redirect URIs per RFC 8252 (OAuth 2.0 for Native Apps) /// /// Allowed redirect URI types: @@ -186,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") @@ -196,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(()) @@ -290,9 +341,7 @@ pub async fn process_dcr_request( grant_types.clone(), response_types.clone(), token_endpoint_auth_method.clone(), - existing.client_alias, // Preserve user-set alias - existing.connection_mode, // Preserve connection mode - existing.locked_space_id, // Preserve locked space + existing.client_alias, // Preserve user-set alias existing.last_seen, existing.created_at, now, @@ -360,9 +409,7 @@ pub async fn process_dcr_request( grant_types.clone(), response_types.clone(), token_endpoint_auth_method.clone(), - None, // No alias yet - "follow_active".to_string(), // Default connection mode - None, // No locked space + None, // No alias yet Some(now_str.clone()), now_str.clone(), now_str, @@ -425,6 +472,107 @@ 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. + let registered = vec!["http://127.0.0.1:12345/callback".to_string()]; + assert!(is_redirect_uri_allowed( + ®istered, + "http://127.0.0.1:44307/callback" + )); + assert!(is_redirect_uri_allowed( + ®istered, + "http://127.0.0.1:1/callback" + )); + + let localhost = vec!["http://localhost:3000/callback".to_string()]; + assert!(is_redirect_uri_allowed( + &localhost, + "http://localhost:55555/callback" + )); + + let ipv6 = vec!["http://[::1]:8080/callback".to_string()]; + assert!(is_redirect_uri_allowed(&ipv6, "http://[::1]:9999/callback")); + } + + #[test] + fn loopback_requires_matching_scheme_host_and_path() { + let registered = vec!["http://127.0.0.1:8080/callback".to_string()]; + // Different path + assert!(!is_redirect_uri_allowed( + ®istered, + "http://127.0.0.1:8080/other" + )); + // Different host family — 127.0.0.1 and localhost are not interchangeable + // (per RFC 8252, clients SHOULD NOT use `localhost`; treat as distinct). + assert!(!is_redirect_uri_allowed( + ®istered, + "http://localhost:8080/callback" + )); + // HTTPS vs HTTP + assert!(!is_redirect_uri_allowed( + ®istered, + "https://127.0.0.1:8080/callback" + )); + } + + #[test] + fn non_loopback_requires_exact_match() { + // HTTPS: exact match only (no port flex) + let https = vec!["https://app.example.com/callback".to_string()]; + assert!(is_redirect_uri_allowed( + &https, + "https://app.example.com/callback" + )); + assert!(!is_redirect_uri_allowed( + &https, + "https://app.example.com:8443/callback" + )); + + // Custom scheme: exact match only + let custom = vec!["cursor://callback".to_string()]; + assert!(is_redirect_uri_allowed(&custom, "cursor://callback")); + assert!(!is_redirect_uri_allowed(&custom, "cursor://other")); + } + + #[test] + fn unparseable_uris_fall_back_to_strict_equality() { + let registered = vec!["not-a-url".to_string()]; + assert!(is_redirect_uri_allowed(®istered, "not-a-url")); + assert!(!is_redirect_uri_allowed(®istered, "not-a-url-either")); + } + + #[test] + fn empty_registered_list_denies_everything() { + assert!(!is_redirect_uri_allowed( + &[], + "http://127.0.0.1:8080/callback" + )); + } + // Note: Integration tests for idempotent registration are better handled // in tests that use an actual database, since process_dcr_request now // persists directly to the database. diff --git a/crates/mcpmux-gateway/src/oauth/mod.rs b/crates/mcpmux-gateway/src/oauth/mod.rs index 860392e1..286ffbd4 100644 --- a/crates/mcpmux-gateway/src/oauth/mod.rs +++ b/crates/mcpmux-gateway/src/oauth/mod.rs @@ -8,7 +8,10 @@ mod flow; mod pkce; mod token; -pub use dcr::{process_dcr_request, validate_redirect_uris, DcrError, DcrRequest, DcrResponse}; +pub use dcr::{ + is_redirect_uri_allowed, process_dcr_request, validate_redirect_uris, DcrError, DcrRequest, + DcrResponse, +}; pub use discovery::{OAuthDiscovery, OAuthMetadata}; pub use flow::{AuthorizationCallback, AuthorizationRequest, OAuthFlow}; pub use pkce::PkceChallenge; diff --git a/crates/mcpmux-gateway/src/pool/credential_store.rs b/crates/mcpmux-gateway/src/pool/credential_store.rs index 49fdf250..26f377bf 100644 --- a/crates/mcpmux-gateway/src/pool/credential_store.rs +++ b/crates/mcpmux-gateway/src/pool/credential_store.rs @@ -218,24 +218,24 @@ impl CredentialStore for DatabaseCredentialStore { self.space_id, self.server_id, reg.client_id ); let token_response = Self::build_token_response(access, refresh_cred.as_ref()); - Some(StoredCredentials { - client_id: reg.client_id, - token_response: Some(token_response), - granted_scopes: Vec::new(), - token_received_at: Some(now_epoch_secs()), - }) + Some(StoredCredentials::new( + reg.client_id, + Some(token_response), + Vec::new(), + Some(now_epoch_secs()), + )) } (Some(reg), None) => { debug!( "[CredentialStore] Loaded registration (no token) for {}/{}, client_id={} - will reuse for DCR", self.space_id, self.server_id, reg.client_id ); - Some(StoredCredentials { - client_id: reg.client_id, - token_response: None, - granted_scopes: Vec::new(), - token_received_at: Some(now_epoch_secs()), - }) + Some(StoredCredentials::new( + reg.client_id, + None, + Vec::new(), + Some(now_epoch_secs()), + )) } (None, Some(access)) => { warn!( @@ -243,12 +243,12 @@ impl CredentialStore for DatabaseCredentialStore { self.space_id, self.server_id ); let token_response = Self::build_token_response(access, refresh_cred.as_ref()); - Some(StoredCredentials { - client_id: String::new(), - token_response: Some(token_response), - granted_scopes: Vec::new(), - token_received_at: Some(now_epoch_secs()), - }) + Some(StoredCredentials::new( + String::new(), + Some(token_response), + Vec::new(), + Some(now_epoch_secs()), + )) } (None, None) => { debug!( @@ -286,12 +286,13 @@ fn build_token_response( refresh_token: Option, expires_in: Option, ) -> OAuthTokenResponse { - use oauth2::{EmptyExtraTokenFields, StandardTokenResponse}; + use oauth2::StandardTokenResponse; + use rmcp::transport::auth::VendorExtraTokenFields; let mut response = StandardTokenResponse::new( AccessToken::new(access_token), BasicTokenType::Bearer, - EmptyExtraTokenFields {}, + VendorExtraTokenFields::default(), ); if let Some(refresh) = refresh_token { @@ -601,12 +602,12 @@ mod tests { Some(std::time::Duration::from_secs(3600)), ); - let credentials = StoredCredentials { - client_id: "new-client-id".to_string(), - token_response: Some(token_response), - granted_scopes: Vec::new(), - token_received_at: None, - }; + let credentials = StoredCredentials::new( + "new-client-id".to_string(), + Some(token_response), + Vec::new(), + None, + ); store.save(credentials).await.unwrap(); @@ -660,12 +661,12 @@ mod tests { Some(std::time::Duration::from_secs(3600)), ); - let credentials = StoredCredentials { - client_id: "client-id".to_string(), - token_response: Some(token_response), - granted_scopes: Vec::new(), - token_received_at: None, - }; + let credentials = StoredCredentials::new( + "client-id".to_string(), + Some(token_response), + Vec::new(), + None, + ); store.save(credentials).await.unwrap(); diff --git a/crates/mcpmux-gateway/src/pool/features/discovery.rs b/crates/mcpmux-gateway/src/pool/features/discovery.rs index 2d618841..0ff8f837 100644 --- a/crates/mcpmux-gateway/src/pool/features/discovery.rs +++ b/crates/mcpmux-gateway/src/pool/features/discovery.rs @@ -6,23 +6,16 @@ use tracing::{debug, info, warn}; use super::{convert_to_feature, resource_to_feature, CachedFeatures}; use crate::pool::instance::McpClient; -use mcpmux_core::{FeatureSetRepository, ServerFeatureRepository}; +use mcpmux_core::ServerFeatureRepository; /// Handles feature discovery and caching from MCP clients pub struct FeatureDiscoveryService { feature_repo: Arc, - feature_set_repo: Arc, } impl FeatureDiscoveryService { - pub fn new( - feature_repo: Arc, - feature_set_repo: Arc, - ) -> Self { - Self { - feature_repo, - feature_set_repo, - } + pub fn new(feature_repo: Arc) -> Self { + Self { feature_repo } } /// Discover features from a connected MCP client and cache them @@ -99,18 +92,6 @@ impl FeatureDiscoveryService { } } - // Ensure server-all featureset exists - if let Err(e) = self - .feature_set_repo - .ensure_server_all(space_id, server_id, server_id) - .await - { - warn!( - "[FeatureDiscovery] Failed to ensure server-all featureset: {}", - e - ); - } - Ok(discovered) } diff --git a/crates/mcpmux-gateway/src/pool/features/facade.rs b/crates/mcpmux-gateway/src/pool/features/facade.rs index e23cd35a..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,11 +25,9 @@ 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(), - feature_set_repo.clone(), - )); + let discovery = Arc::new(FeatureDiscoveryService::new(feature_repo.clone())); let resolution = Arc::new(FeatureResolutionService::new( feature_repo.clone(), @@ -44,6 +44,7 @@ impl FeatureService { discovery, resolution, routing, + session_overrides, } } @@ -89,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 2bfcbad7..1709ac81 100644 --- a/crates/mcpmux-gateway/src/pool/features/resolution.rs +++ b/crates/mcpmux-gateway/src/pool/features/resolution.rs @@ -7,8 +7,8 @@ use tracing::debug; use crate::services::PrefixCacheService; use mcpmux_core::{ - FeatureSet, FeatureSetRepository, FeatureSetType, FeatureType, MemberMode, MemberType, - ServerFeature, ServerFeatureRepository, + FeatureSet, FeatureSetRepository, FeatureType, MemberMode, MemberType, ServerFeature, + ServerFeatureRepository, }; /// Helper to apply include/exclude mode (DRY) @@ -82,7 +82,6 @@ impl FeatureResolutionService { ) -> Result> { let mut allowed_feature_ids: HashSet = HashSet::new(); let mut excluded_feature_ids: HashSet = HashSet::new(); - let mut has_all_grant = false; let all_features = self.feature_repo.list_for_space(space_id).await?; @@ -107,87 +106,40 @@ impl FeatureResolutionService { } }; - match feature_set.feature_set_type { - FeatureSetType::All => { - has_all_grant = true; - } - FeatureSetType::Default => { - // Default feature set uses explicit members only - // Empty default = no features (secure by default) - self.resolve_members( - &feature_set, - &all_features, - &mut allowed_feature_ids, - &mut excluded_feature_ids, - ) - .await?; - } - FeatureSetType::ServerAll => { - if let Some(ref server_id) = feature_set.server_id { - debug!( - "[FeatureResolution] ServerAll: querying features for server_id={} in space={}", - server_id, space_id - ); - let server_features = self - .feature_repo - .list_for_server(space_id, server_id) - .await?; - debug!( - "[FeatureResolution] ServerAll: found {} features for server {}", - server_features.len(), - server_id - ); - for f in &server_features { - debug!( - "[FeatureResolution] ServerAll: adding feature id={}, name={}, available={}", - f.id, f.feature_name, f.is_available - ); - allowed_feature_ids.insert(f.id.to_string()); - } - } else { - debug!("[FeatureResolution] ServerAll: feature_set.server_id is None!"); - } - } - FeatureSetType::Custom => { - self.resolve_members( - &feature_set, - &all_features, - &mut allowed_feature_ids, - &mut excluded_feature_ids, - ) - .await?; - } - } + // Both Default and Custom sets use explicit members; the + // resolution is identical — walk the members and build up + // allow/exclude sets. + self.resolve_members( + &feature_set, + &all_features, + &mut allowed_feature_ids, + &mut excluded_feature_ids, + ) + .await?; } - // Apply filters debug!( - "[FeatureResolution] Filtering: has_all_grant={}, all_features={}, allowed_ids={}, excluded_ids={}", - has_all_grant, all_features.len(), allowed_feature_ids.len(), excluded_feature_ids.len() + "[FeatureResolution] Filtering: all_features={}, allowed_ids={}, excluded_ids={}", + all_features.len(), + allowed_feature_ids.len(), + excluded_feature_ids.len() ); - let mut result: Vec = if has_all_grant { - all_features - .into_iter() - .filter(|f| f.is_available) - .collect() - } else { - all_features - .into_iter() - .filter(|f| { - let in_allowed = allowed_feature_ids.contains(&f.id.to_string()); - let in_excluded = excluded_feature_ids.contains(&f.id.to_string()); - let passes = f.is_available && in_allowed && !in_excluded; - if !passes && in_allowed { - debug!( - "[FeatureResolution] Feature {} (server={}) filtered out: is_available={}, in_allowed={}, in_excluded={}", - f.feature_name, f.server_id, f.is_available, in_allowed, in_excluded - ); - } - passes - }) - .collect() - }; + let mut result: Vec = all_features + .into_iter() + .filter(|f| { + let in_allowed = allowed_feature_ids.contains(&f.id.to_string()); + let in_excluded = excluded_feature_ids.contains(&f.id.to_string()); + let passes = f.is_available && in_allowed && !in_excluded; + if !passes && in_allowed { + debug!( + "[FeatureResolution] Feature {} (server={}) filtered out: is_available={}, in_allowed={}, in_excluded={}", + f.feature_name, f.server_id, f.is_available, in_allowed, in_excluded + ); + } + passes + }) + .collect(); debug!( "[FeatureResolution] After filter: {} features", @@ -211,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, @@ -229,38 +223,16 @@ impl FeatureResolutionService { ); } MemberType::FeatureSet => { + // Composition: recurse into the nested FS, walking its + // members the same way. Both Default and Custom sets + // are purely member-driven now. if let Some(nested_fs) = self .feature_set_repo .get_with_members(&member.member_id) .await? { - match nested_fs.feature_set_type { - FeatureSetType::All => { - let ids = all_features - .iter() - .filter(|f| f.is_available) - .map(|f| f.id.to_string()); - apply_mode_to_set(member.mode, ids, allowed, excluded); - } - FeatureSetType::ServerAll => { - if let Some(ref server_id) = nested_fs.server_id { - let ids = all_features - .iter() - .filter(|f| f.server_id == *server_id && f.is_available) - .map(|f| f.id.to_string()); - apply_mode_to_set(member.mode, ids, allowed, excluded); - } - } - _ => { - Box::pin(self.resolve_members( - &nested_fs, - all_features, - allowed, - excluded, - )) - .await?; - } - } + Box::pin(self.resolve_members(&nested_fs, all_features, allowed, excluded)) + .await?; } } } diff --git a/crates/mcpmux-gateway/src/pool/instance.rs b/crates/mcpmux-gateway/src/pool/instance.rs index e9fea7fe..44dbbb3b 100644 --- a/crates/mcpmux-gateway/src/pool/instance.rs +++ b/crates/mcpmux-gateway/src/pool/instance.rs @@ -50,20 +50,11 @@ impl McpClientHandler { event_tx: Option>, log_manager: Option>, ) -> Self { + let mut client_info = + Implementation::new(format!("mcpmux-{}", server_id), env!("CARGO_PKG_VERSION")); + client_info.title = Some("McpMux Gateway".to_string()); Self { - info: ClientInfo { - protocol_version: Default::default(), - capabilities: ClientCapabilities::default(), - client_info: Implementation { - name: format!("mcpmux-{}", server_id), - version: env!("CARGO_PKG_VERSION").to_string(), - title: Some("McpMux Gateway".to_string()), - icons: None, - website_url: None, - ..Default::default() - }, - meta: None, - }, + info: ClientInfo::new(ClientCapabilities::default(), client_info), server_id: server_id.to_string(), space_id, event_tx, diff --git a/crates/mcpmux-gateway/src/pool/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/oauth.rs b/crates/mcpmux-gateway/src/pool/oauth.rs index 513aefc9..e60ac057 100644 --- a/crates/mcpmux-gateway/src/pool/oauth.rs +++ b/crates/mcpmux-gateway/src/pool/oauth.rs @@ -255,36 +255,6 @@ impl OutboundOAuthManager { scopes.iter().map(|s| s.as_str()).collect() } - /// Add RFC 8707 'resource' parameter to authorization URL. - /// - /// The resource parameter tells the Authorization Server which protected resource - /// (MCP server) the client is requesting access to. This enables the AS to: - /// - Issue tokens scoped to the specific resource - /// - Apply resource-specific policies - /// - Prevent token replay at other resources - /// - /// Some servers (like Miro) require this parameter. - fn add_resource_parameter(auth_url: &str, server_url: &str) -> String { - use url::Url; - - match Url::parse(auth_url) { - Ok(mut url) => { - // Add the resource parameter with the MCP server URL - url.query_pairs_mut().append_pair("resource", server_url); - info!("[OAuth] Added RFC 8707 resource parameter: {}", server_url); - url.to_string() - } - Err(e) => { - warn!( - "[OAuth] Failed to parse auth URL to add resource parameter: {}", - e - ); - // Return original URL if parsing fails - auth_url.to_string() - } - } - } - /// Subscribe to OAuth completion events pub fn subscribe(&self) -> tokio::sync::broadcast::Receiver { self.completion_tx.subscribe() @@ -1049,12 +1019,11 @@ impl OutboundOAuthManager { let scopes = Self::get_scopes_from_metadata(&discovered_metadata); // Then configure client with the existing registration - let config = rmcp::transport::auth::OAuthClientConfig { - client_id: reg.client_id.clone(), - client_secret: None, - scopes: scopes.clone(), - redirect_uri: redirect_uri.clone(), - }; + let mut config = rmcp::transport::auth::OAuthClientConfig::new( + reg.client_id.clone(), + redirect_uri.clone(), + ); + config.scopes = scopes.clone(); if let Err(e) = manager.configure_client(config) { self.log( @@ -1084,17 +1053,23 @@ impl OutboundOAuthManager { .await .map_err(|e| anyhow::anyhow!("Failed to get auth URL: {}", e))?; - // Create session manually - oauth_state = OAuthState::Session(rmcp::transport::auth::AuthorizationSession { - auth_manager: std::mem::replace( - manager, - rmcp::transport::auth::AuthorizationManager::new(server_url) - .await - .map_err(|e| anyhow::anyhow!("Failed: {}", e))?, + // Create session manually (reusing the existing registration). + // We already called configure_client + get_authorization_url above, + // so we use `for_scope_upgrade` to wrap the pre-computed values without + // re-registering the client via DCR. + let taken_manager = std::mem::replace( + manager, + rmcp::transport::auth::AuthorizationManager::new(server_url) + .await + .map_err(|e| anyhow::anyhow!("Failed: {}", e))?, + ); + oauth_state = OAuthState::Session( + rmcp::transport::auth::AuthorizationSession::for_scope_upgrade( + taken_manager, + auth_url.clone(), + &redirect_uri, ), - auth_url: auth_url.clone(), - redirect_uri: redirect_uri.clone(), - }); + ); } (false, None) // Not a new registration, no metadata to save } else { @@ -1247,11 +1222,6 @@ impl OutboundOAuthManager { } }; - // Add RFC 8707 'resource' parameter to the authorization URL. - // This tells the Authorization Server which protected resource (MCP server) - // the token is being requested for. Some servers (like Miro) require this. - let auth_url = Self::add_resource_parameter(&auth_url, server_url); - // Extract state parameter from auth_url let state = match Self::extract_state_from_url(&auth_url) { Some(s) => s, diff --git a/crates/mcpmux-gateway/src/pool/oauth_utils.rs b/crates/mcpmux-gateway/src/pool/oauth_utils.rs index 3a45e6d2..995d031c 100644 --- a/crates/mcpmux-gateway/src/pool/oauth_utils.rs +++ b/crates/mcpmux-gateway/src/pool/oauth_utils.rs @@ -101,17 +101,16 @@ pub fn convert_to_stored_metadata(metadata: &AuthorizationMetadata) -> StoredOAu /// This is used when loading saved metadata and setting it on the RMCP manager /// to bypass discovery. pub fn convert_from_stored_metadata(stored: &StoredOAuthMetadata) -> AuthorizationMetadata { - AuthorizationMetadata { - authorization_endpoint: stored.authorization_endpoint.clone(), - token_endpoint: stored.token_endpoint.clone(), - registration_endpoint: stored.registration_endpoint.clone(), - issuer: stored.issuer.clone(), - jwks_uri: stored.jwks_uri.clone(), - scopes_supported: stored.scopes_supported.clone(), - response_types_supported: stored.response_types_supported.clone(), - additional_fields: stored.additional_fields.clone(), - ..Default::default() - } + let mut metadata = AuthorizationMetadata::default(); + metadata.authorization_endpoint = stored.authorization_endpoint.clone(); + metadata.token_endpoint = stored.token_endpoint.clone(); + metadata.registration_endpoint = stored.registration_endpoint.clone(); + metadata.issuer = stored.issuer.clone(); + metadata.jwks_uri = stored.jwks_uri.clone(); + metadata.scopes_supported = stored.scopes_supported.clone(); + metadata.response_types_supported = stored.response_types_supported.clone(); + metadata.additional_fields = stored.additional_fields.clone(); + metadata } #[cfg(test)] diff --git a/crates/mcpmux-gateway/src/pool/routing.rs b/crates/mcpmux-gateway/src/pool/routing.rs index 180e09e6..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); @@ -283,12 +329,8 @@ impl RoutingService { match client_handle { Some(client) => { - let params = CallToolRequestParams { - name: tool_name.into(), - arguments: args.as_object().cloned(), - task: None, - meta: None, - }; + let mut params = CallToolRequestParams::new(tool_name.to_string()); + params.arguments = args.as_object().cloned(); // Wrap call_tool with timeout to prevent hanging let res = tokio::time::timeout(TOOL_CALL_TIMEOUT, client.call_tool(params)) @@ -304,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.rs b/crates/mcpmux-gateway/src/pool/service.rs index d8208a33..8217afc9 100644 --- a/crates/mcpmux-gateway/src/pool/service.rs +++ b/crates/mcpmux-gateway/src/pool/service.rs @@ -164,10 +164,7 @@ impl PoolService { Some(client) => { use rmcp::model::ReadResourceRequestParams; - let params = ReadResourceRequestParams { - uri: uri.into(), - meta: None, - }; + let params = ReadResourceRequestParams::new(uri); let res = client .read_resource(params) @@ -243,11 +240,8 @@ impl PoolService { Some(client) => { use rmcp::model::GetPromptRequestParams; - let params = GetPromptRequestParams { - name: prompt_name.into(), - arguments, - meta: None, - }; + let mut params = GetPromptRequestParams::new(prompt_name); + params.arguments = arguments; let res = client .get_prompt(params) diff --git a/crates/mcpmux-gateway/src/pool/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/pool/transport/shell_env.rs b/crates/mcpmux-gateway/src/pool/transport/shell_env.rs index 17241873..eebc32f3 100644 --- a/crates/mcpmux-gateway/src/pool/transport/shell_env.rs +++ b/crates/mcpmux-gateway/src/pool/transport/shell_env.rs @@ -150,13 +150,12 @@ fn merge_paths(primary: &str, secondary: &str) -> String { merged.join(":") } -#[cfg(test)] +#[cfg(all(test, unix))] mod tests { use super::*; // ── merge_paths tests ────────────────────────────────────────── - #[cfg(unix)] #[test] fn test_merge_paths_deduplicates() { let result = merge_paths("/usr/bin:/usr/local/bin", "/usr/bin:/opt/homebrew/bin"); diff --git a/crates/mcpmux-gateway/src/server/dependencies.rs b/crates/mcpmux-gateway/src/server/dependencies.rs index d670facc..5560a91b 100644 --- a/crates/mcpmux-gateway/src/server/dependencies.rs +++ b/crates/mcpmux-gateway/src/server/dependencies.rs @@ -9,8 +9,9 @@ use std::sync::Arc; use crate::services::ClientMetadataService; use mcpmux_core::{ AppSettingsRepository, CimdMetadataFetcher, CredentialRepository, FeatureSetRepository, - InstalledServerRepository, OutboundOAuthRepository, ServerDiscoveryService, - ServerFeatureRepository, ServerLogManager, SpaceRepository, + InboundMcpClientRepository, InstalledServerRepository, OutboundOAuthRepository, + ServerDiscoveryService, ServerFeatureRepository, ServerLogManager, SpaceRepository, + WorkspaceBindingRepository, }; use mcpmux_storage::{Database, InboundClientRepository}; use tokio::sync::Mutex; @@ -29,6 +30,13 @@ pub struct GatewayDependencies { pub feature_set_repo: Arc, pub space_repo: Arc, pub inbound_client_repo: Arc, + /// Trait-based MCP client repository (for Client entity CRUD + pin setters). + /// + /// Used by the FeatureSet resolver v2 — separate from `inbound_client_repo` + /// (which is the concrete OAuth-flow-focused repo). + pub inbound_mcp_client_repo: Arc, + /// Workspace -> FeatureSet bindings for resolver v2. + pub workspace_binding_repo: Arc, // Services (Business Layer) pub server_discovery: Arc, @@ -66,6 +74,15 @@ impl GatewayDependencies { jwt_secret: Option>, state_dir: Option, ) -> Self { + // Resolver v2 repositories — always SQLite-backed; no-op at runtime + // until the resolver flag flips out of shadow mode. + let inbound_mcp_client_repo: Arc = Arc::new( + mcpmux_storage::SqliteInboundMcpClientRepository::new(database.clone()), + ); + let workspace_binding_repo: Arc = Arc::new( + mcpmux_storage::SqliteWorkspaceBindingRepository::new(database.clone()), + ); + Self { installed_server_repo, credential_repo, @@ -74,6 +91,8 @@ impl GatewayDependencies { feature_set_repo, space_repo, inbound_client_repo, + inbound_mcp_client_repo, + workspace_binding_repo, server_discovery, log_manager, cimd_fetcher, @@ -214,6 +233,14 @@ impl DependenciesBuilder { )) }); + // Resolver v2 repositories — always SQLite-backed for now. + let inbound_mcp_client_repo: Arc = Arc::new( + mcpmux_storage::SqliteInboundMcpClientRepository::new(database.clone()), + ); + let workspace_binding_repo: Arc = Arc::new( + mcpmux_storage::SqliteWorkspaceBindingRepository::new(database.clone()), + ); + Ok(GatewayDependencies { installed_server_repo: self .installed_server_repo @@ -228,6 +255,8 @@ impl DependenciesBuilder { .ok_or("feature_set_repo is required")?, space_repo, inbound_client_repo, + inbound_mcp_client_repo, + workspace_binding_repo, server_discovery: self .server_discovery .ok_or("server_discovery is required")?, diff --git a/crates/mcpmux-gateway/src/server/handlers.rs b/crates/mcpmux-gateway/src/server/handlers.rs index 22376b2c..3aa2db25 100644 --- a/crates/mcpmux-gateway/src/server/handlers.rs +++ b/crates/mcpmux-gateway/src/server/handlers.rs @@ -14,7 +14,9 @@ use tracing::{debug, error, info, warn}; use super::{GatewayState, ServiceContainer}; use crate::auth::{create_access_token, create_refresh_token}; -use crate::oauth::{process_dcr_request, DcrError, DcrRequest, DcrResponse}; +use crate::oauth::{ + is_redirect_uri_allowed, process_dcr_request, DcrError, DcrRequest, DcrResponse, +}; /// App State structure holding both GatewayState and ServiceContainer #[derive(Clone)] @@ -214,8 +216,11 @@ pub async fn oauth_authorize( } }; - // Validate redirect_uri against resolved client - if !client.redirect_uris.contains(¶ms.redirect_uri) { + // Validate redirect_uri against resolved client. + // Per RFC 8252 §7.3, loopback redirect URIs are matched ignoring the port, + // since native public clients use an ephemeral OS-assigned port at request + // time that may differ from the one captured at DCR. + if !is_redirect_uri_allowed(&client.redirect_uris, ¶ms.redirect_uri) { warn!( "[OAuth] Invalid redirect_uri for client: {} (expected one of: {:?})", params.redirect_uri, client.redirect_uris @@ -941,9 +946,6 @@ pub struct OAuthClientInfoResponse { #[serde(skip_serializing_if = "Option::is_none")] pub metadata_cache_ttl: Option, - // MCP client preferences - pub connection_mode: String, - pub locked_space_id: Option, pub last_seen: Option, pub created_at: String, } @@ -979,8 +981,6 @@ pub async fn oauth_list_clients( metadata_url: c.metadata_url, metadata_cached_at: c.metadata_cached_at, metadata_cache_ttl: c.metadata_cache_ttl, - connection_mode: c.connection_mode, - locked_space_id: c.locked_space_id, last_seen: c.last_seen, created_at: c.created_at, }) @@ -999,8 +999,6 @@ pub async fn oauth_list_clients( #[derive(Debug, Deserialize)] pub struct UpdateClientRequest { pub client_alias: Option, - pub connection_mode: Option, - pub locked_space_id: Option, } /// Update client settings (connection mode, alias, etc.) @@ -1049,11 +1047,14 @@ pub async fn oauth_get_client_features( space_id, client_id ); - // Step 2: Get client grants (SRP: AuthorizationService) + // Step 2: Get client grants via the resolver. + // No MCP session context here (this is an HTTP API endpoint for the + // desktop UI), so workspace-binding resolution is skipped; the + // resolver falls back to the Space's Default FeatureSet. let feature_set_ids = match state .services .authorization_service - .get_client_grants(&client_id, &space_id) + .get_client_grants(&client_id, &space_id, None) .await { Ok(grants) => grants, @@ -1086,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(); @@ -1094,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(); @@ -1102,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(); @@ -1175,35 +1176,7 @@ pub async fn oauth_update_client( return (StatusCode::SERVICE_UNAVAILABLE, "Database not available").into_response(); }; - // Validate connection_mode if provided - if let Some(ref mode) = req.connection_mode { - if !["follow_active", "locked", "ask_on_change"].contains(&mode.as_str()) { - return (StatusCode::BAD_REQUEST, "Invalid connection_mode").into_response(); - } - } - - // Handle locked_space_id: convert to Option> - let locked_space_id = if req.connection_mode.as_deref() == Some("locked") { - Some(req.locked_space_id.clone()) - } else if req.connection_mode.as_deref() == Some("follow_active") - || req.connection_mode.as_deref() == Some("ask_on_change") - { - // Clear locked_space_id when switching away from locked mode - Some(None) - } else { - // Don't change if not explicitly setting mode - None - }; - - match repo - .update_client_settings( - &client_id, - req.client_alias, - req.connection_mode, - locked_space_id, - ) - .await - { + match repo.update_client_alias(&client_id, req.client_alias).await { Ok(Some(client)) => { let response = OAuthClientInfoResponse { client_id: client.client_id, @@ -1219,8 +1192,6 @@ pub async fn oauth_update_client( metadata_url: client.metadata_url, metadata_cached_at: client.metadata_cached_at, metadata_cache_ttl: client.metadata_cache_ttl, - connection_mode: client.connection_mode, - locked_space_id: client.locked_space_id, last_seen: client.last_seen, created_at: client.created_at, }; diff --git a/crates/mcpmux-gateway/src/server/mod.rs b/crates/mcpmux-gateway/src/server/mod.rs index 3d3c6e0d..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, } } @@ -169,6 +179,32 @@ impl GatewayServer { self.services.grant_service.clone() } + /// Approval broker for meta-tool writes. Exposed so the desktop layer + /// can attach a Tauri-event publisher + resolve pending prompts. + pub fn approval_broker(&self) -> Arc { + self.services.approval_broker.clone() + } + + /// Session-roots registry (MCP roots reported by connected peers). + /// + /// The desktop Workspaces tab reads this to surface every folder + /// clients are currently operating in — both bound and unbound — so + /// users can configure mappings even for roots they missed the + /// one-shot prompt for. + pub fn session_roots(&self) -> Arc { + self.services.session_roots.clone() + } + + /// 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() @@ -210,17 +246,11 @@ impl GatewayServer { base_url: self.config.base_url(), }; - // Create MCP notifier (smart consumer for domain events with dynamic space resolution) - let notification_bridge = Arc::new(MCPNotifier::new( - self.services.space_resolver_service.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) @@ -238,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: @@ -247,19 +279,21 @@ impl GatewayServer { // - GET endpoint for SSE streams (server-initiated notifications) // - DELETE endpoint for session termination // - list_changed notifications delivered via SSE + // Build via default() + setters so new non-exhaustive fields (e.g. allowed_hosts, + // which defaults to localhost/127.0.0.1/::1) don't require us to enumerate them. + let mut http_cfg = StreamableHttpServerConfig::default(); + http_cfg.stateful_mode = true; + http_cfg.json_response = false; + http_cfg.sse_keep_alive = Some(std::time::Duration::from_secs(30)); + http_cfg.sse_retry = Some(std::time::Duration::from_secs(3)); + http_cfg.cancellation_token = CancellationToken::new(); let mcp_service = StreamableHttpService::new( move || { debug!("[Gateway] Creating handler instance for MCP session"); Ok(handler.clone()) }, LocalSessionManager::default().into(), - StreamableHttpServerConfig { - stateful_mode: true, - json_response: false, - sse_keep_alive: Some(std::time::Duration::from_secs(30)), - sse_retry: Some(std::time::Duration::from_secs(3)), - cancellation_token: CancellationToken::new(), - }, + http_cfg, ); // Wrap MCP service with OAuth middleware @@ -368,6 +402,21 @@ impl GatewayServer { /// 1. Starts auto-connect in background /// 2. Starts the HTTP server pub async fn run(self) -> anyhow::Result<()> { + // No external shutdown signal — axum will run until the process + // exits or its future is dropped. Prefer `spawn()` for anything + // that wants a clean stop without orphaning the listener socket. + self.run_with_shutdown(std::future::pending::<()>()).await + } + + /// Same as `run`, but accepts a shutdown future. When the future + /// resolves, axum stops accepting new connections, drains in-flight + /// requests, and closes the TCP listener. Rust `Drop` on the + /// `TcpListener` then releases the port on the OS — preventing the + /// orphaned-socket condition that force-killed processes leave behind. + pub async fn run_with_shutdown( + self, + shutdown: impl std::future::Future + Send + 'static, + ) -> anyhow::Result<()> { let addr = self.config.addr(); info!("[Gateway] Starting on {}", addr); @@ -449,15 +498,65 @@ impl GatewayServer { info!("[Gateway] Ready to accept connections (servers connecting in background)"); - axum::serve(listener, router).await?; + axum::serve(listener, router) + .with_graceful_shutdown(async move { + shutdown.await; + info!("[Gateway] Graceful shutdown signal received — closing listener"); + }) + .await?; + info!("[Gateway] Listener closed, run_with_shutdown returning"); Ok(()) } - /// Start the server in the background + /// Start the server in the background. /// - /// Returns a JoinHandle that can be used to wait for completion or abort. - pub fn spawn(self) -> tokio::task::JoinHandle> { - tokio::spawn(async move { self.run().await }) + /// Returns a [`GatewayServerHandle`] with both the `JoinHandle` and a + /// one-shot shutdown sender. Call `handle.shutdown()` (and then + /// `.await` the join handle with a timeout) to close the listener + /// cleanly. Dropping the sender without using it leaves axum running + /// until its task is aborted — the old behavior. + pub fn spawn(self) -> GatewayServerHandle { + let (tx, rx) = tokio::sync::oneshot::channel::<()>(); + let task = tokio::spawn(async move { + self.run_with_shutdown(async move { + // If the sender is dropped without being used, `rx.await` + // resolves with `Err` and we treat that as "shut down now" + // — this makes accidental Drop of the handle release the + // port instead of orphaning it. + let _ = rx.await; + }) + .await + }); + GatewayServerHandle { + task, + shutdown: Some(tx), + } + } +} + +/// Handle returned by [`GatewayServer::spawn`] — carries the task's +/// `JoinHandle` plus a one-shot shutdown sender for graceful stop. +/// +/// Sending on `shutdown` tells axum to drain in-flight requests and close +/// the listener. After sending, await `task` (with a timeout) to let Rust +/// `Drop` release the socket on the OS — otherwise the port stays bound +/// in the kernel until the process exits. +pub struct GatewayServerHandle { + pub task: tokio::task::JoinHandle>, + shutdown: Option>, +} + +impl GatewayServerHandle { + /// Send the graceful-shutdown signal. No-op if already sent (idempotent). + pub fn shutdown(&mut self) { + if let Some(tx) = self.shutdown.take() { + let _ = tx.send(()); + } + } + + /// True when no shutdown signal has been sent yet. + pub fn is_active(&self) -> bool { + self.shutdown.is_some() } } diff --git a/crates/mcpmux-gateway/src/server/service_container.rs b/crates/mcpmux-gateway/src/server/service_container.rs index 648d11ef..8189c2dd 100644 --- a/crates/mcpmux-gateway/src/server/service_container.rs +++ b/crates/mcpmux-gateway/src/server/service_container.rs @@ -7,8 +7,9 @@ use std::sync::Arc; use crate::pool::{PoolServices, ServerManager, ServiceFactory}; use crate::services::{ - AuthorizationService, ClientMetadataService, GrantService, PrefixCacheService, - SpaceResolverService, + meta_tools, ApprovalBroker, AuthorizationService, ClientMetadataService, + FeatureSetResolverService, GrantService, MetaToolRegistry, PrefixCacheService, + SessionOverrideRegistry, SessionRootsRegistry, SpaceResolverService, }; use mcpmux_core::DomainEvent; @@ -33,6 +34,22 @@ pub struct ServiceContainer { /// Authorization service for checking client permissions (SRP) pub authorization_service: Arc, + /// FeatureSet resolver v2 (pin > workspace > space-active). + pub feature_set_resolver: Arc, + + /// 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, + + /// Built-in `mcpmux_*` meta tools advertised alongside backend tools. + pub meta_tool_registry: Arc, + /// Space resolver for determining client's active space (SRP) pub space_resolver_service: Arc, @@ -68,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 @@ -85,27 +104,60 @@ impl ServiceContainer { prefix_cache_service.clone(), )); - // Create authorization service (DIP: inject repository dependencies) - let authorization_service = Arc::new(AuthorizationService::new( + // Resolver — workspace-root-driven. AuthorizationService delegates + // here; the old per-client pin path is gone (see v2 migration + // journey in mcpmux.space/diagrams/workppace-root-session/). + let session_roots = SessionRootsRegistry::new(); + let feature_set_resolver = Arc::new(FeatureSetResolverService::new( + deps.space_repo.clone(), + deps.workspace_binding_repo.clone(), + session_roots.clone(), deps.inbound_client_repo.clone(), - deps.feature_set_repo.clone(), )); - // Create space resolver service (DIP: inject repository dependencies) - let space_resolver_service = Arc::new(SpaceResolverService::new( - deps.inbound_client_repo.clone(), + // Authorization service is now a thin adapter over the resolver. + let authorization_service = + Arc::new(AuthorizationService::new(feature_set_resolver.clone())); + + // Approval broker for meta-tool writes. Publisher is attached later + // by the Tauri layer; until then, writes return `approval_required`. + let approval_broker = Arc::new(ApprovalBroker::new()); + + // Registry of built-in `mcpmux_*` meta tools (introspection + self- + // management). Each write tool is gated by the broker above. + let meta_tool_registry = meta_tools::build_default_registry( + deps.inbound_mcp_client_repo.clone(), deps.space_repo.clone(), - )); + deps.feature_set_repo.clone(), + deps.workspace_binding_repo.clone(), + deps.feature_repo.clone(), + 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(), + ); + + // Space resolver — currently just exposes the active Space, but + // keeps a stable seam for future session-targeted routing. + let space_resolver_service = Arc::new(SpaceResolverService::new(deps.space_repo.clone())); // Create client metadata service let client_metadata_service = deps.client_metadata_service.clone(); - // Create grant service (centralized grant management with domain events) - // Emits domain events (what happened) instead of implementation-specific events (what to do) + // Feature-set change broadcaster — emits FeatureSetMembersChanged so + // the MCP notifier can fan list_changed out to every peer that + // resolves into the affected set. let grant_service = Arc::new(GrantService::new( - deps.inbound_client_repo.clone(), // Concrete type (pragmatic) - deps.feature_set_repo.clone(), // Trait (DIP) - domain_event_tx.clone(), // Direct event bus (decoupled) + deps.inbound_client_repo.clone(), + deps.feature_set_repo.clone(), + domain_event_tx.clone(), )); Self { @@ -113,6 +165,11 @@ impl ServiceContainer { server_manager, startup_orchestrator, authorization_service, + feature_set_resolver, + session_roots, + session_overrides, + approval_broker, + meta_tool_registry, space_resolver_service, prefix_cache_service, client_metadata_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/authorization.rs b/crates/mcpmux-gateway/src/services/authorization.rs index 83cf5c68..6f9cc3e4 100644 --- a/crates/mcpmux-gateway/src/services/authorization.rs +++ b/crates/mcpmux-gateway/src/services/authorization.rs @@ -1,84 +1,63 @@ -//! Authorization Service +//! Authorization Service. //! -//! Responsible for checking client permissions (grants) for accessing features. -//! Follows SRP: Single responsibility is authorization checking. -//! Follows DIP: Depends on repository abstractions, not concrete implementations. +//! Thin adapter over [`FeatureSetResolverService`]. Routing decisions are +//! keyed primarily on session (→ workspace root → binding); `client_id` is +//! consulted only on the rootless Tier-2 fallback (`client_grants` lookup). +//! Two VS Code windows sharing one OAuth identity still route independently +//! because the binding path uses session-reported roots. use anyhow::Result; -use mcpmux_core::FeatureSetRepository; -use mcpmux_storage::InboundClientRepository; use std::sync::Arc; use uuid::Uuid; -/// Authorization service for checking client permissions -/// -/// SRP: Only handles authorization decisions -/// DIP: Depends on repository abstractions +use super::feature_set_resolver::{FeatureSetResolverService, ResolvedFeatureSet}; + pub struct AuthorizationService { - client_repo: Arc, - feature_set_repo: Arc, + resolver: Arc, } impl AuthorizationService { - pub fn new( - client_repo: Arc, - feature_set_repo: Arc, - ) -> Self { - Self { - client_repo, - feature_set_repo, - } + pub fn new(resolver: Arc) -> Self { + Self { resolver } } - /// Get effective feature set grants for a client in a specific space. - /// - /// Resolution strategy (least-privilege by default): - /// 1. Return explicit per-client grants from DB if any exist. - /// 2. Always include the Default feature set as a baseline. + /// Resolve the active FeatureSet ids for a session/client pair. /// - /// Clients with no explicit grants only receive the Default feature set, - /// which starts empty (no features). The user must explicitly grant - /// additional feature sets (e.g. "All", "ServerAll", or custom sets) - /// through the UI to expose tools/prompts/resources to a client. - /// This avoids accidental exposure of all server capabilities. - pub async fn get_client_grants(&self, client_id: &str, space_id: &Uuid) -> Result> { - let space_id_str = space_id.to_string(); - - // Get explicit grants from DB - let mut grants = self - .client_repo - .get_grants_for_space(client_id, &space_id_str) - .await?; - - // Always include the Default feature set as baseline permissions. - // Default starts empty — user must explicitly grant additional access. - if let Some(default_fs) = self - .feature_set_repo - .get_default_for_space(&space_id_str) - .await? - { - if !grants.contains(&default_fs.id) { - grants.push(default_fs.id); - } - } - - Ok(grants) + /// Returns an empty Vec when resolution denies (no roots + no grants, + /// or roots reported but no binding matched). The MCP request handler + /// surfaces this as "no tools" plus its own `WorkspaceNeedsBinding` + /// nudge for bound-but-unbound roots. + pub async fn get_client_grants( + &self, + client_id: &str, + _space_id: &Uuid, + session_id: Option<&str>, + ) -> Result> { + let resolved = self.resolver.resolve(session_id, Some(client_id)).await?; + Ok(resolved.feature_set_ids) } - /// Check if a client has any grants in a space - pub async fn has_access(&self, client_id: &str, space_id: &Uuid) -> Result { - let grants = self.get_client_grants(client_id, space_id).await?; - Ok(!grants.is_empty()) + /// Full resolution metadata — returns (Space, FS list, source) so the + /// MCP handler can also filter on the resolved Space rather than the + /// caller-advertised one. + pub async fn resolve( + &self, + session_id: Option<&str>, + client_id: Option<&str>, + ) -> Result { + self.resolver.resolve(session_id, client_id).await } - /// Check if a client has access to a specific feature set - pub async fn has_feature_set_access( + /// Does this session/client resolve to any FeatureSet? + pub async fn has_access( &self, client_id: &str, space_id: &Uuid, - feature_set_id: &str, + session_id: Option<&str>, ) -> Result { - let grants = self.get_client_grants(client_id, space_id).await?; - Ok(grants.contains(&feature_set_id.to_string())) + let grants = self + .get_client_grants(client_id, space_id, session_id) + .await?; + Ok(!grants.is_empty()) } } diff --git a/crates/mcpmux-gateway/src/services/client_metadata_service.rs b/crates/mcpmux-gateway/src/services/client_metadata_service.rs index f8474140..97d4b002 100644 --- a/crates/mcpmux-gateway/src/services/client_metadata_service.rs +++ b/crates/mcpmux-gateway/src/services/client_metadata_service.rs @@ -134,11 +134,14 @@ impl ClientMetadataService { metadata_url: Some(metadata.client_id), metadata_cached_at: Some(now.clone()), metadata_cache_ttl: Some(3600), // 1 hour default - connection_mode: "follow_active".to_string(), - locked_space_id: None, last_seen: Some(now.clone()), created_at: now.clone(), updated_at: now, + // Capability bits default off / unknown; the gateway flips + // them on the first `initialize` for any session of this + // client. + reports_roots: false, + roots_capability_known: false, } } } diff --git a/crates/mcpmux-gateway/src/services/feature_set_resolver.rs b/crates/mcpmux-gateway/src/services/feature_set_resolver.rs new file mode 100644 index 00000000..56c40fd6 --- /dev/null +++ b/crates/mcpmux-gateway/src/services/feature_set_resolver.rs @@ -0,0 +1,251 @@ +//! FeatureSet Resolver Service. +//! +//! Capability-branched four-tier resolution. The branch point is the MCP +//! `roots` capability declared by the client at `initialize`: +//! +//! ```text +//! resolve(session_id, client_id): +//! // Tier 1 — roots-capable session with reported roots +//! if session reported roots AND a binding matches: +//! return (binding.space_id, [binding.feature_set_id], WorkspaceBinding) +//! +//! // Tier 1b — roots-capable, roots reported, but no binding yet +//! if session reported roots AND no binding matched: +//! return ([], , Deny) // emits WorkspaceNeedsBinding upstream +//! +//! // Tier 1c — declared `roots` but they haven't arrived yet +//! if session declared `roots` AND none yet in registry: +//! return ([], default_space, PendingRoots) +//! +//! // Tier 2 — rootless-by-design (Claude.ai web, ChatGPT, …) +//! if client has grants in the default space: +//! return (default_space, grants, ClientGrant) +//! +//! // Tier 3 — no signal at all +//! return ([], default_space, Deny) +//! ``` +//! +//! The caller's client identity is used **only** for the rootless fallback — +//! every roots-capable session routes via its own reported roots, regardless +//! of which OAuth client opened it. This is what makes "two VS Code windows +//! sharing one OAuth identity" route independently. +//! +//! Roots-capable detection is stamped at `on_initialized` time into +//! [`SessionRootsRegistry::set_roots_capable`]. + +use std::sync::Arc; + +use anyhow::Result; +use mcpmux_core::{SpaceRepository, WorkspaceBindingRepository}; +use mcpmux_storage::InboundClientRepository; +use serde::Serialize; +use tracing::{debug, warn}; +use uuid::Uuid; + +use super::session_roots::SessionRootsRegistry; + +/// Why the resolver picked the FS(es) it picked (or didn't pick any). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ResolutionSource { + /// A [`WorkspaceBinding`](mcpmux_core::WorkspaceBinding) matched one of + /// the session's reported MCP roots. + WorkspaceBinding, + /// No binding matched, but the client is roots-capable so its `roots` + /// list is in flight; return empty and re-resolve when they arrive. + PendingRoots, + /// Rootless-by-design client. The space-default's per-client + /// `client_grants` were applied. + ClientGrant, + /// No FeatureSet resolved. Either no roots + no grants, or the session + /// reported roots but no binding matched (the upstream caller emits + /// `WorkspaceNeedsBinding` in that subcase). + Deny, +} + +/// Output of [`FeatureSetResolverService::resolve`]. +/// +/// `feature_set_ids` is empty when the resolution was a deny. Multiple ids +/// are possible only on the `ClientGrant` path — bindings always resolve to +/// exactly one FS. +#[derive(Debug, Clone)] +pub struct ResolvedFeatureSet { + pub feature_set_ids: Vec, + /// Resolved Space id. Used by the routing layer when filtering features. + pub space_id: Option, + pub source: ResolutionSource, +} + +impl ResolvedFeatureSet { + /// Stable key for change detection (sorted + comma-joined). Used by + /// `SessionRootsRegistry::record_resolution` to decide when a session's + /// effective tools changed and a per-peer `list_changed` is owed. + pub fn fingerprint(&self) -> Option { + if self.feature_set_ids.is_empty() { + return None; + } + let mut ids = self.feature_set_ids.clone(); + ids.sort(); + Some(ids.join(",")) + } +} + +/// Resolves which FeatureSet(s) apply for a given session. +/// +/// Cheap to clone via `Arc`; inject one instance into the gateway's service +/// container and reuse across requests. +pub struct FeatureSetResolverService { + space_repo: Arc, + binding_repo: Arc, + session_roots: Arc, + /// Reads `client_grants` for the rootless Tier-2 fallback. Stored as a + /// concrete repo (storage owns this type and there's only ever one). + client_repo: Arc, +} + +impl FeatureSetResolverService { + pub fn new( + space_repo: Arc, + binding_repo: Arc, + session_roots: Arc, + client_repo: Arc, + ) -> Self { + Self { + space_repo, + binding_repo, + session_roots, + client_repo, + } + } + + /// Borrow the session-roots registry. The notifier uses this to GC + /// dead sessions out of the registry when reaping the corresponding + /// peer entries — keeping both stores in sync. + pub fn session_roots(&self) -> &Arc { + &self.session_roots + } + + /// Resolve the effective (Space, FS list, source) tuple for a session. + /// + /// `session_id`: the client's `mcp-session-id` header (or `None` when + /// the caller is stateless — e.g. desktop UI HTTP path). + /// `client_id`: the OAuth client identity. Used only for the Tier-2 + /// `client_grants` lookup; ignored for binding-based routing. + pub async fn resolve( + &self, + session_id: Option<&str>, + client_id: Option<&str>, + ) -> Result { + let default_space_id = match self.space_repo.get_default().await? { + Some(s) => s.id, + None => { + warn!("[FeatureSetResolver] no default space — deny"); + return Ok(ResolvedFeatureSet { + feature_set_ids: vec![], + space_id: None, + source: ResolutionSource::Deny, + }); + } + }; + + // Tier 1 / 1b / 1c — branches on roots-capable + roots-arrived state. + if let Some(sid) = session_id { + let roots = self.session_roots.get(sid); + let has_roots = roots.as_ref().is_some_and(|r| !r.is_empty()); + let roots_capable = self.session_roots.is_roots_capable(sid).unwrap_or(false); + + // Tier 1: session reported roots — try a binding match. + if has_roots { + if let Some(binding) = self + .binding_repo + .find_longest_prefix_match(&default_space_id, &roots.unwrap()) + .await? + { + debug!( + workspace_root = %binding.workspace_root, + space_id = %binding.space_id, + feature_sets = ?binding.feature_set_ids, + "[FeatureSetResolver] resolved via WorkspaceBinding", + ); + return Ok(ResolvedFeatureSet { + feature_set_ids: binding.feature_set_ids, + space_id: Some(binding.space_id), + source: ResolutionSource::WorkspaceBinding, + }); + } + // Tier 1b: had roots, no binding — deny + upstream emits + // WorkspaceNeedsBinding so the user can choose an FS. + debug!("[FeatureSetResolver] roots reported but no binding matched — deny",); + return Ok(ResolvedFeatureSet { + feature_set_ids: vec![], + space_id: Some(default_space_id), + source: ResolutionSource::Deny, + }); + } + + // Tier 1c: client declared `roots` but they haven't shown up yet. + // Don't fall through to client grants — that's the leak the old + // Tier-2 fallback caused. Return empty; we'll fire `list_changed` + // when roots actually arrive. + if roots_capable { + debug!( + session_id = %sid, + "[FeatureSetResolver] roots-capable, roots pending — empty until they arrive", + ); + return Ok(ResolvedFeatureSet { + feature_set_ids: vec![], + space_id: Some(default_space_id), + source: ResolutionSource::PendingRoots, + }); + } + } + + // Tier 2 — rootless-by-design. Either the session declared no + // `roots` capability, or the caller has no session id at all + // (the desktop UI's preview HTTP path lands here too). Consult the + // per-client grant table. + if let Some(cid) = client_id { + let grants = self + .client_repo + .get_grants_for_space(cid, &default_space_id.to_string()) + .await + .unwrap_or_default(); + if !grants.is_empty() { + debug!( + client_id = %cid, + space_id = %default_space_id, + grant_count = grants.len(), + "[FeatureSetResolver] resolved via ClientGrant", + ); + return Ok(ResolvedFeatureSet { + feature_set_ids: grants, + space_id: Some(default_space_id), + source: ResolutionSource::ClientGrant, + }); + } + } + + // Tier 3 — no roots, no grants. Deny. + // The mcpmux_* meta tools are still appended unconditionally by the + // request handler, so the LLM can self-bind / ask the user for + // a grant from this state. + debug!( + space_id = %default_space_id, + ?client_id, + "[FeatureSetResolver] no roots + no grants — deny", + ); + + Ok(ResolvedFeatureSet { + feature_set_ids: vec![], + space_id: Some(default_space_id), + source: ResolutionSource::Deny, + }) + } +} + +#[cfg(test)] +mod tests { + //! Resolver decision-table tests live in the integration test crate + //! (`tests/rust/tests/integration/feature_set_resolver.rs`) so they can + //! share the mock repositories with the other gateway tests. +} diff --git a/crates/mcpmux-gateway/src/services/grant_service.rs b/crates/mcpmux-gateway/src/services/grant_service.rs index 7dc2aad9..4da8787b 100644 --- a/crates/mcpmux-gateway/src/services/grant_service.rs +++ b/crates/mcpmux-gateway/src/services/grant_service.rs @@ -1,16 +1,20 @@ -//! Grant Service +//! Grant Service. //! -//! Centralized service for managing client feature set grants. +//! Two responsibilities, both centred on emitting domain events so MCPNotifier +//! can broadcast `list_changed` notifications: //! -//! **Responsibility (SRP):** -//! - Grant/revoke feature sets to clients -//! - Emit list_changed notifications automatically for ALL grant changes -//! - Ensure DRY - single place for grant logic + notifications +//! 1. **Per-client FeatureSet grants** — used by the resolver's rootless-fallback +//! path. When a client has not declared the MCP `roots` capability (or has +//! no workspace context), the resolver consults `client_grants` for that +//! `(client_id, space_id)` pair. Grant/revoke flows here update the table +//! *and* fire `ClientGrantChanged` so any open peer for that client +//! re-fetches its tool list under the new permission set. +//! 2. **FeatureSet membership change broadcast** — when individual features are +//! added or removed inside a FeatureSet, fire `FeatureSetMembersChanged` +//! for the same notifier path. //! -//! **Design:** -//! - UI/Tauri commands call this service for ALL grant operations -//! - Service updates DB + emits events (no manual notification calls needed) -//! - Notifications work for: default grants, custom grants, individual features, batch updates +//! Routing for roots-capable clients flows through `WorkspaceBinding` and is +//! handled by the resolver directly — this service is not on that path. use anyhow::Result; use mcpmux_core::{DomainEvent, FeatureSetRepository}; @@ -20,23 +24,13 @@ use tokio::sync::broadcast; use tracing::{info, warn}; use uuid::Uuid; -/// Centralized service for grant management with automatic event emission -/// -/// **SOLID & Domain-Driven Design:** -/// - **SRP**: Single responsibility - manage grants + emit domain events -/// - **DIP**: Depends on abstractions (FeatureSetRepository trait) -/// - **Domain Events**: Emits what happened, not what to do (consumers decide) -/// -/// **Enterprise Pattern:** -/// - Uses domain events (GrantIssued, etc.) instead of implementation-specific events -/// - Consumers (MCPNotifier, UI) interpret events based on their context -/// - Testable, extensible, and follows event-driven architecture principles +/// Grant management with automatic event emission. pub struct GrantService { - /// OAuth client grant repository (concrete for simplicity) + /// OAuth client grant repository (concrete; storage-owned). client_repo: Arc, - /// Feature set validation (trait for flexibility) + /// Feature set lookup for member-change notifications. feature_set_repo: Arc, - /// Domain event broadcaster (decoupled from consumers) + /// Domain event broadcaster. event_tx: broadcast::Sender, } @@ -53,9 +47,11 @@ impl GrantService { } } - /// Grant a feature set to a client in a space + /// Grant a feature set to a client in a space. /// - /// Emits FeatureSetGranted domain event for consumers to handle. + /// Idempotent — re-granting an existing pair is a no-op at the DB layer + /// (`INSERT OR IGNORE`) but still fires the event so any peer that + /// missed an earlier notification gets a fresh `list_changed`. pub async fn grant_feature_set( &self, client_id: &str, @@ -65,32 +61,25 @@ impl GrantService { let space_uuid = Uuid::parse_str(space_id)?; info!( - client_id = %client_id, - space_id = %space_id, - feature_set_id = %feature_set_id, - "[GrantService] Granting feature set" + %client_id, + %space_id, + %feature_set_id, + "[GrantService] granting feature set" ); - // Update database self.client_repo .grant_feature_set(client_id, space_id, feature_set_id) .await?; - info!("[GrantService] Feature set granted successfully"); - - // Emit domain event (what happened, not what to do) - let _ = self.event_tx.send(DomainEvent::GrantIssued { + let _ = self.event_tx.send(DomainEvent::ClientGrantChanged { client_id: client_id.to_string(), space_id: space_uuid, - feature_set_id: feature_set_id.to_string(), }); Ok(()) } - /// Revoke a feature set from a client in a space - /// - /// Emits FeatureSetRevoked domain event for consumers to handle. + /// Revoke a feature set from a client in a space. pub async fn revoke_feature_set( &self, client_id: &str, @@ -100,33 +89,39 @@ impl GrantService { let space_uuid = Uuid::parse_str(space_id)?; info!( - client_id = %client_id, - space_id = %space_id, - feature_set_id = %feature_set_id, - "[GrantService] Revoking feature set" + %client_id, + %space_id, + %feature_set_id, + "[GrantService] revoking feature set" ); - // Update database self.client_repo .revoke_feature_set(client_id, space_id, feature_set_id) .await?; - info!("[GrantService] Feature set revoked successfully"); - - // Emit domain event (what happened, not what to do) - let _ = self.event_tx.send(DomainEvent::GrantRevoked { + let _ = self.event_tx.send(DomainEvent::ClientGrantChanged { client_id: client_id.to_string(), space_id: space_uuid, - feature_set_id: feature_set_id.to_string(), }); Ok(()) } - /// Notify when a feature set's contents are modified + /// Read the granted feature_set_ids for a (client, space) pair. + pub async fn get_grants_for_space( + &self, + client_id: &str, + space_id: &str, + ) -> Result> { + self.client_repo + .get_grants_for_space(client_id, space_id) + .await + } + + /// Emit a `FeatureSetMembersChanged` event for the given feature set. /// - /// Call this after adding/removing features to/from a feature set. - /// Emits FeatureSetModified domain event for consumers to handle. + /// Call this after adding or removing members so every peer subscribed + /// to the resulting FS re-fetches its tool/prompt/resource list. pub async fn notify_feature_set_modified( &self, space_id: &str, @@ -135,37 +130,33 @@ impl GrantService { let space_uuid = Uuid::parse_str(space_id)?; info!( - space_id = %space_id, - feature_set_id = %feature_set_id, - "[GrantService] Feature set modified - emitting domain event" + %space_id, + %feature_set_id, + "[GrantService] feature set modified — emitting domain event" ); - // Verify feature set exists match self.feature_set_repo.get(feature_set_id).await? { Some(feature_set) => { - // Ensure the feature set belongs to the specified space if feature_set.space_id.as_deref() != Some(space_id) { warn!( - "[GrantService] Feature set {} belongs to space {:?}, not {}", + "[GrantService] FS {} belongs to space {:?}, not {}", feature_set_id, feature_set.space_id, space_id ); - return Ok(()); // Silently skip + return Ok(()); } - // Emit domain event (what happened, not what to do) - // Note: We don't track exact counts here since this is a generic modified signal let _ = self.event_tx.send(DomainEvent::FeatureSetMembersChanged { space_id: space_uuid, feature_set_id: feature_set_id.to_string(), - added_count: 0, // Generic modification signal + added_count: 0, removed_count: 0, }); Ok(()) } None => { - warn!("[GrantService] Feature set {} not found", feature_set_id); - Ok(()) // Silently skip + warn!("[GrantService] FS {} not found", feature_set_id); + Ok(()) } } } diff --git a/crates/mcpmux-gateway/src/services/meta_tools/approval.rs b/crates/mcpmux-gateway/src/services/meta_tools/approval.rs new file mode 100644 index 00000000..bd29be8e --- /dev/null +++ b/crates/mcpmux-gateway/src/services/meta_tools/approval.rs @@ -0,0 +1,481 @@ +//! Native-dialog approval broker for meta-tool writes. +//! +//! When an LLM calls a write meta tool (e.g. `mcpmux_pin_this_session`), +//! the gateway needs human sign-off before mutating state. The broker +//! bridges that: the tool calls [`ApprovalBroker::request_approval`], which +//! emits a Tauri event the desktop app listens for, awaits a response on a +//! oneshot channel, and returns [`ApprovalDecision`] — Allow (once/always) +//! or Deny (user-denied / timeout / rate-limited / no-desktop). +//! +//! Two non-obvious bits: +//! +//! * If no desktop is attached (headless CLI, tests without the subscriber +//! wired), [`ApprovalBroker::request_approval`] returns +//! [`MetaToolError::ApprovalRequiredNoDesktop`] immediately — a write +//! without an approver is a silent deny, which is the safe failure mode. +//! +//! * "Always allow" entries are **session-only** (in-memory `DashMap`, +//! not persisted). A gateway restart re-prompts. This is a deliberate +//! security default — auto-approved writes deserve a fresh nod on every +//! launch. Users can still tick the checkbox once per session. +//! +//! Client identity is treated as an opaque `String` (the OAuth client_id +//! from the JWT — a UUID for the legacy preset-clients path, a +//! client_metadata URL for DCR-registered clients like Claude Code). The +//! broker doesn't parse it; equality + hashing is enough. + +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use dashmap::DashMap; +use serde::{Deserialize, Serialize}; +use tokio::sync::{oneshot, Mutex}; +use tracing::{debug, warn}; +use uuid::Uuid; + +use super::MetaToolError; + +/// Default timeout for a single approval prompt. +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60); + +/// Rate limit: max pending approvals per (client_id) within the window. +const RATE_LIMIT_MAX_PENDING: usize = 10; +const RATE_LIMIT_WINDOW: Duration = Duration::from_secs(60); + +/// User's decision on an approval prompt. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ApprovalDecision { + AllowOnce, + /// Allow this (client, tool) pair for the rest of the gateway session. + AlwaysForThisSessionAndClient, + Deny, +} + +/// Scope of an "always allow" grant. Session-only for now; `Persisted` is +/// reserved for a future settings-backed opt-in. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ApprovalScope { + Once, + SessionClient, + #[allow(dead_code)] + Persisted, +} + +/// Payload delivered to the desktop UI so it can render a meaningful dialog. +/// +/// Keep this narrow and JSON-serializable — it crosses the Tauri boundary. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApprovalPayload { + pub tool_name: String, + /// Human summary the dialog puts above the diff. e.g. + /// "Pin this connection to FeatureSet 'android-dev' (12 tools)". + pub summary: String, + /// Tool-list diff the dialog shows to make the change concrete. + /// Optional because some writes (e.g. create_feature_set without + /// activation) don't shift the caller's resolved toolset. + pub diff: Option, + /// Raw arguments the LLM supplied; shown verbatim for auditability. + pub raw_args: serde_json::Value, + /// Does this change affect clients other than the caller? Dictates + /// whether the dialog shows the "also affects other connections" warning. + pub affects_other_clients: bool, +} + +/// Data the broker hands to whoever listens for approval requests. +#[derive(Debug, Clone, Serialize)] +pub struct ApprovalRequest { + pub request_id: String, + pub client_id: String, + pub payload: ApprovalPayload, + /// UNIX seconds at which this request will time out if no response. + pub expires_at_unix_secs: u64, +} + +/// Subscribe-once handler the desktop layer attaches so broker requests +/// reach the Tauri event bus. +/// +/// `respond` closure returns `true` when the listener accepted delivery, +/// `false` when no desktop was attached — which the broker treats as +/// "headless gateway, deny". +pub type ApprovalPublisher = Arc< + dyn Fn(ApprovalRequest) -> futures::future::BoxFuture<'static, bool> + Send + Sync + 'static, +>; + +/// The broker itself. +pub struct ApprovalBroker { + /// Pending oneshot senders keyed by request_id — the Tauri command + /// `respond_to_meta_tool_approval` resolves these. + pending: DashMap>, + /// Session-scoped always-allow grants, keyed by (client_id, tool_name). + /// `client_id` is opaque (UUID for preset clients, URL for DCR clients); + /// the broker only does equality lookups. + always_allow: DashMap<(String, String), ()>, + /// (client_id) -> Vec for rate limiting. + rate_limit: DashMap>, + /// Published to the desktop layer; `None` means headless. + publisher: Mutex>, + timeout: Duration, +} + +impl Default for ApprovalBroker { + fn default() -> Self { + Self::new() + } +} + +impl ApprovalBroker { + pub fn new() -> Self { + Self { + pending: DashMap::new(), + always_allow: DashMap::new(), + rate_limit: DashMap::new(), + publisher: Mutex::new(None), + timeout: DEFAULT_TIMEOUT, + } + } + + pub fn with_timeout(mut self, timeout: Duration) -> Self { + self.timeout = timeout; + self + } + + /// Attach the desktop subscriber. Call once at app startup. + pub async fn set_publisher(&self, publisher: ApprovalPublisher) { + *self.publisher.lock().await = Some(publisher); + } + + /// For tests / headless scenarios: pre-approve everything from a + /// specific client. + #[cfg(test)] + pub fn insert_always_allow(&self, client_id: &str, tool_name: &str) { + self.always_allow + .insert((client_id.to_string(), tool_name.to_string()), ()); + } + + /// Resolve a pending approval. Called from Tauri command when the user + /// clicks a dialog button. `scope` converts "allow" into an optional + /// always-allow entry. + pub fn respond( + &self, + request_id: &str, + client_id: &str, + tool_name: &str, + decision: ApprovalDecision, + ) -> bool { + // Persist always-allow before firing the waiter so a racing second + // call from the same client sees it. + if matches!(decision, ApprovalDecision::AlwaysForThisSessionAndClient) { + self.always_allow + .insert((client_id.to_string(), tool_name.to_string()), ()); + } + if let Some((_, tx)) = self.pending.remove(request_id) { + tx.send(decision).is_ok() + } else { + warn!( + %request_id, + "[ApprovalBroker] respond() for unknown/expired request", + ); + false + } + } + + /// List currently pending (unresolved) approvals. Useful for UI recovery + /// when the dialog is closed mid-request. + pub fn list_pending_ids(&self) -> Vec { + self.pending.iter().map(|e| e.key().clone()).collect() + } + + /// List always-allow grants (for the UI to display + revoke). + pub fn list_always_allow(&self) -> Vec<(String, String)> { + self.always_allow.iter().map(|e| e.key().clone()).collect() + } + + /// Revoke an always-allow entry. + pub fn revoke_always_allow(&self, client_id: &str, tool_name: &str) -> bool { + self.always_allow + .remove(&(client_id.to_string(), tool_name.to_string())) + .is_some() + } + + /// Core entry point for write meta tools. + /// + /// Order of checks: + /// 1. Always-allow hit → immediate `AllowOnce` (no dialog). + /// 2. Rate limit overflow → `RateLimited`. + /// 3. No publisher attached → `ApprovalRequiredNoDesktop`. + /// 4. Emit + wait → Allow / Deny / Timeout. + pub async fn request_approval( + &self, + client_id: &str, + tool_name: &str, + payload: ApprovalPayload, + ) -> Result { + // 1. Always-allow short-circuit. + if self + .always_allow + .contains_key(&(client_id.to_string(), tool_name.to_string())) + { + debug!( + %client_id, + tool = tool_name, + "[ApprovalBroker] always-allow hit; approving without dialog", + ); + return Ok(ApprovalDecision::AllowOnce); + } + + // 2. Rate limit. + self.prune_rate_limit(client_id); + let pending_for_client = self + .rate_limit + .get(client_id) + .map(|e| e.value().len()) + .unwrap_or(0); + if pending_for_client >= RATE_LIMIT_MAX_PENDING { + warn!( + %client_id, + tool = tool_name, + pending = pending_for_client, + "[ApprovalBroker] rate-limited", + ); + return Err(MetaToolError::RateLimited); + } + self.rate_limit + .entry(client_id.to_string()) + .or_default() + .push(Instant::now()); + + // 3. Require an attached publisher. + let publisher = match self.publisher.lock().await.clone() { + Some(p) => p, + None => { + warn!( + %client_id, + tool = tool_name, + "[ApprovalBroker] no publisher attached; failing approval", + ); + return Err(MetaToolError::ApprovalRequiredNoDesktop); + } + }; + + // 4. Emit + wait on oneshot. + let request_id = Uuid::new_v4().to_string(); + let expires_at = chrono::Utc::now() + chrono::Duration::from_std(self.timeout).unwrap(); + let request = ApprovalRequest { + request_id: request_id.clone(), + client_id: client_id.to_string(), + payload, + expires_at_unix_secs: expires_at.timestamp() as u64, + }; + + let (tx, rx) = oneshot::channel(); + self.pending.insert(request_id.clone(), tx); + + let delivered = publisher(request.clone()).await; + if !delivered { + // Publisher disavowed delivery — treat like "no desktop". + self.pending.remove(&request_id); + return Err(MetaToolError::ApprovalRequiredNoDesktop); + } + + match tokio::time::timeout(self.timeout, rx).await { + Ok(Ok(decision)) => match decision { + ApprovalDecision::Deny => Err(MetaToolError::ApprovalDenied), + other => Ok(other), + }, + Ok(Err(_)) => { + // Sender dropped without deciding — treat as deny. + Err(MetaToolError::ApprovalDenied) + } + Err(_) => { + self.pending.remove(&request_id); + Err(MetaToolError::ApprovalTimedOut) + } + } + } + + fn prune_rate_limit(&self, client_id: &str) { + if let Some(mut entry) = self.rate_limit.get_mut(client_id) { + let cutoff = Instant::now() - RATE_LIMIT_WINDOW; + entry.retain(|t| *t > cutoff); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use futures::FutureExt; + + fn make_payload() -> ApprovalPayload { + ApprovalPayload { + tool_name: "mcpmux_pin_this_session".into(), + summary: "test".into(), + diff: None, + raw_args: serde_json::json!({}), + affects_other_clients: false, + } + } + + #[tokio::test] + async fn no_publisher_returns_no_desktop_error() { + let broker = ApprovalBroker::new(); + let err = broker + .request_approval( + &Uuid::new_v4().to_string(), + "mcpmux_pin_this_session", + make_payload(), + ) + .await + .unwrap_err(); + assert!(matches!(err, MetaToolError::ApprovalRequiredNoDesktop)); + } + + #[tokio::test] + async fn always_allow_short_circuits() { + let broker = ApprovalBroker::new(); + let client_id = Uuid::new_v4().to_string(); + broker.insert_always_allow(&client_id, "mcpmux_pin_this_session"); + let d = broker + .request_approval(&client_id, "mcpmux_pin_this_session", make_payload()) + .await + .unwrap(); + assert_eq!(d, ApprovalDecision::AllowOnce); + } + + #[tokio::test] + async fn url_client_id_works() { + // Regression for the bug where DCR-registered clients (which use + // a client_metadata URL as their client_id) couldn't get past the + // approval flow because we tried to parse the URL as a UUID. + let broker = ApprovalBroker::new(); + let url_client_id = "https://claude.ai/oauth/claude-code-client-metadata"; + broker.insert_always_allow(url_client_id, "mcpmux_pin_this_session"); + let d = broker + .request_approval(url_client_id, "mcpmux_pin_this_session", make_payload()) + .await + .unwrap(); + assert_eq!(d, ApprovalDecision::AllowOnce); + } + + #[tokio::test] + async fn publisher_allow_resolves() { + let broker = Arc::new(ApprovalBroker::new().with_timeout(Duration::from_millis(500))); + let broker_clone = broker.clone(); + let client_id = Uuid::new_v4().to_string(); + + // Publisher responds asynchronously with Allow. + let publisher: ApprovalPublisher = Arc::new(move |req| { + let b = broker_clone.clone(); + async move { + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(10)).await; + b.respond( + &req.request_id, + &req.client_id, + &req.payload.tool_name, + ApprovalDecision::AllowOnce, + ); + }); + true + } + .boxed() + }); + broker.set_publisher(publisher).await; + + let decision = broker + .request_approval(&client_id, "mcpmux_pin_this_session", make_payload()) + .await + .unwrap(); + assert_eq!(decision, ApprovalDecision::AllowOnce); + } + + #[tokio::test] + async fn publisher_deny_returns_denied_error() { + let broker = Arc::new(ApprovalBroker::new().with_timeout(Duration::from_millis(500))); + let broker_clone = broker.clone(); + let client_id = Uuid::new_v4().to_string(); + + let publisher: ApprovalPublisher = Arc::new(move |req| { + let b = broker_clone.clone(); + async move { + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(10)).await; + b.respond( + &req.request_id, + &req.client_id, + &req.payload.tool_name, + ApprovalDecision::Deny, + ); + }); + true + } + .boxed() + }); + broker.set_publisher(publisher).await; + + let err = broker + .request_approval(&client_id, "mcpmux_pin_this_session", make_payload()) + .await + .unwrap_err(); + assert!(matches!(err, MetaToolError::ApprovalDenied)); + } + + #[tokio::test] + async fn publisher_timeout() { + let broker = Arc::new(ApprovalBroker::new().with_timeout(Duration::from_millis(50))); + // Publisher accepts delivery but never responds. + let publisher: ApprovalPublisher = Arc::new(move |_req| async move { true }.boxed()); + broker.set_publisher(publisher).await; + + let err = broker + .request_approval( + &Uuid::new_v4().to_string(), + "mcpmux_pin_this_session", + make_payload(), + ) + .await + .unwrap_err(); + assert!(matches!(err, MetaToolError::ApprovalTimedOut)); + } + + #[tokio::test] + async fn always_scope_persists_across_calls() { + let broker = Arc::new(ApprovalBroker::new().with_timeout(Duration::from_millis(500))); + let broker_clone = broker.clone(); + let client_id = Uuid::new_v4().to_string(); + + let publisher: ApprovalPublisher = Arc::new(move |req| { + let b = broker_clone.clone(); + async move { + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(10)).await; + b.respond( + &req.request_id, + &req.client_id, + &req.payload.tool_name, + ApprovalDecision::AlwaysForThisSessionAndClient, + ); + }); + true + } + .boxed() + }); + broker.set_publisher(publisher).await; + + // First call → dialog, returns AlwaysForThisSessionAndClient. + let d1 = broker + .request_approval(&client_id, "mcpmux_pin_this_session", make_payload()) + .await + .unwrap(); + assert_eq!(d1, ApprovalDecision::AlwaysForThisSessionAndClient); + + // Second call → short-circuits via always-allow entry. + let d2 = broker + .request_approval(&client_id, "mcpmux_pin_this_session", make_payload()) + .await + .unwrap(); + assert_eq!(d2, ApprovalDecision::AllowOnce); + } +} diff --git a/crates/mcpmux-gateway/src/services/meta_tools/diff.rs b/crates/mcpmux-gateway/src/services/meta_tools/diff.rs new file mode 100644 index 00000000..f3e9d90f --- /dev/null +++ b/crates/mcpmux-gateway/src/services/meta_tools/diff.rs @@ -0,0 +1,76 @@ +//! Before/after diff of the caller's resolved tool list. +//! +//! Used by write meta tools to build a concrete "you'll go from N tools to +//! M tools" preview for the approval dialog. + +use serde::Serialize; +use uuid::Uuid; + +use crate::pool::FeatureService; + +/// Tool-list diff between two FeatureSet resolutions, both relative to the +/// same Space. Every field is a list of fully-qualified tool names +/// (e.g. `github.create_issue`). +#[derive(Debug, Clone, Serialize, Default)] +pub struct ToolDiff { + pub before: Vec, + pub after: Vec, + pub added: Vec, + pub removed: Vec, +} + +impl ToolDiff { + /// Compute `after − before` for the caller's Space, each given as an + /// optional FeatureSet id. `None` means "deny" (empty toolset), which + /// is a valid before/after state. + /// + /// Uses the shared [`FeatureService`] so the math matches what the + /// client actually receives on a subsequent `list_tools` call. + pub async fn compute( + feature_service: &FeatureService, + space_id: Uuid, + before_fs_id: Option, + after_fs_id: Option, + ) -> anyhow::Result { + let before = Self::tools_for(feature_service, space_id, before_fs_id.as_deref()).await?; + let after = Self::tools_for(feature_service, space_id, after_fs_id.as_deref()).await?; + + let before_set: std::collections::HashSet<&String> = before.iter().collect(); + let after_set: std::collections::HashSet<&String> = after.iter().collect(); + let added: Vec = after + .iter() + .filter(|t| !before_set.contains(t)) + .cloned() + .collect(); + let removed: Vec = before + .iter() + .filter(|t| !after_set.contains(t)) + .cloned() + .collect(); + + Ok(ToolDiff { + before, + after, + added, + removed, + }) + } + + async fn tools_for( + feature_service: &FeatureService, + space_id: Uuid, + fs_id: Option<&str>, + ) -> anyhow::Result> { + let Some(fs) = fs_id else { return Ok(vec![]) }; + let space_id_str = space_id.to_string(); + let ids = [fs.to_string()]; + let features = feature_service + .get_tools_for_grants(&space_id_str, &ids, None) + .await?; + Ok(features + .iter() + .filter(|f| f.is_available) + .map(|f| f.qualified_name()) + .collect()) + } +} diff --git a/crates/mcpmux-gateway/src/services/meta_tools/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 new file mode 100644 index 00000000..5a646ddb --- /dev/null +++ b/crates/mcpmux-gateway/src/services/meta_tools/mod.rs @@ -0,0 +1,108 @@ +//! Self-management meta tools (`mcpmux_*`). +//! +//! A small built-in toolset exposed by the gateway alongside the filtered +//! backend tools. Lets connected LLMs introspect the currently resolved +//! FeatureSet, see what tools exist unfiltered, and — gated by user +//! approval — reshape their own session's toolset (pin, create FS, bind +//! workspace, flip the Space's active FS). +//! +//! Design: the write tools are the token-savings feature. When a project +//! only needs 10 of 80 connected tools, the LLM can call +//! `mcpmux_pin_this_session` after reviewing the workspace, and the next +//! `tools/list` returns only the 10. Existing `tools/list_changed` +//! notification plumbing lands the reduced set in-session. +//! +//! Security: every write tool routes through [`approval::ApprovalBroker`] +//! which pops a native desktop dialog showing the concrete tool-list diff +//! before allowing the change. Headless gateways return `approval_required`. +//! Reads are unmetered. +//! +//! Namespace: all meta tools have names starting with `MCPMUX_PREFIX` +//! (`mcpmux_`) so the handler can route them before feature-set filtering. + +pub mod approval; +pub mod diff; +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 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. +pub const MCPMUX_PREFIX: &str = "mcpmux_"; + +/// Convenience: is this tool name one of ours? +pub fn is_meta_tool(name: &str) -> bool { + name.starts_with(MCPMUX_PREFIX) +} + +/// Factory wiring a fully-configured registry with every default tool. +/// +/// Callers (ServiceContainer) construct one of these at gateway startup +/// and clone the Arc freely. +#[allow(clippy::too_many_arguments)] +pub fn build_default_registry( + client_repo: std::sync::Arc, + space_repo: std::sync::Arc, + feature_set_repo: std::sync::Arc, + binding_repo: std::sync::Arc, + server_feature_repo: std::sync::Arc, + 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, + }; + + let mut registry = MetaToolRegistry::new(ctx); + // Reads — no approval needed. + registry.register(Box::new(tools::ListAllToolsTool)); + registry.register(Box::new(tools::ListFeatureSetsTool)); + registry.register(Box::new(tools::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 new file mode 100644 index 00000000..b6c12923 --- /dev/null +++ b/crates/mcpmux-gateway/src/services/meta_tools/registry.rs @@ -0,0 +1,283 @@ +//! MetaTool trait + registry. +//! +//! Each meta tool is a unit struct implementing [`MetaTool`]. The registry +//! dispatches a tool name to its handler and exposes `list()` for the MCP +//! `tools/list` response. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use mcpmux_core::{ + DomainEvent, FeatureSetRepository, InboundMcpClientRepository, InstalledServerRepository, + ServerFeatureRepository, SpaceRepository, WorkspaceBindingRepository, +}; +use rmcp::model::{CallToolResult, Tool}; +use serde_json::Value; +use thiserror::Error; +use tokio::sync::broadcast; + +use super::approval::ApprovalBroker; +use super::invoke_backend::InvokeToolBackend; +use crate::pool::FeatureService; +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 +/// to tools via [`MetaToolContext`]. +#[derive(Clone)] +pub struct MetaToolContext { + pub client_repo: Arc, + pub space_repo: Arc, + pub feature_set_repo: Arc, + pub binding_repo: Arc, + pub server_feature_repo: Arc, + pub 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. + pub domain_event_tx: broadcast::Sender, + /// App-settings repo for the `gateway.meta_tools_enabled` master switch. + /// Optional because older dependency builders may not have wired it. + /// When absent the switch defaults to ENABLED (matches the product default). + pub settings_repo: Option>, +} + +/// Per-request metadata threaded through every tool call. +/// +/// `client_id` is the OAuth client identity from the JWT — opaque string +/// (a UUID for preset-clients, a `client_metadata` URL for DCR-registered +/// clients like Claude Code). The registry treats it as a hash key only. +pub struct MetaToolCall<'a> { + pub client_id: &'a str, + pub session_id: Option<&'a str>, + /// 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`. +#[derive(Debug, Error)] +pub enum MetaToolError { + #[error("invalid argument: {0}")] + InvalidArgument(String), + #[error("approval denied by user")] + ApprovalDenied, + #[error("approval request timed out")] + ApprovalTimedOut, + #[error("approval required but no desktop attached to mcpmux gateway")] + ApprovalRequiredNoDesktop, + #[error("rate limited: too many pending approvals for this client")] + RateLimited, + #[error("internal: {0}")] + Internal(String), +} + +impl MetaToolError { + /// Convert to an MCP error result (user-visible message). + pub fn into_call_tool_result(self) -> CallToolResult { + use rmcp::model::Content; + let payload = serde_json::json!({ + "error": match &self { + MetaToolError::InvalidArgument(_) => "invalid_argument", + MetaToolError::ApprovalDenied => "approval_denied", + MetaToolError::ApprovalTimedOut => "approval_timed_out", + MetaToolError::ApprovalRequiredNoDesktop => "approval_required", + MetaToolError::RateLimited => "rate_limited", + MetaToolError::Internal(_) => "internal_error", + }, + "message": self.to_string(), + }); + CallToolResult::error(vec![Content::text(payload.to_string())]) + } +} + +impl From for MetaToolError { + fn from(e: anyhow::Error) -> Self { + MetaToolError::Internal(e.to_string()) + } +} + +/// A single self-management tool. +/// +/// Tools are unit structs (no per-instance state) — all shared state comes +/// from [`MetaToolContext`]. +#[async_trait] +pub trait MetaTool: Send + Sync { + /// MCP tool name — must start with `mcpmux_`. + fn name(&self) -> &'static str; + + /// MCP tool description (shown to the LLM). + fn description(&self) -> &'static str; + + /// JSON-schema describing accepted arguments. The registry converts + /// this into a [`rmcp::model::Tool`] with the right annotations. + fn input_schema(&self) -> Value; + + /// Whether this tool modifies state. Writes are routed through the + /// approval broker; reads are executed immediately. + fn is_write(&self) -> bool { + false + } + + /// Run the tool. + async fn call(&self, call: MetaToolCall<'_>) -> Result; +} + +/// Registry of every built-in tool. Constructed once at gateway startup. +pub struct MetaToolRegistry { + ctx: MetaToolContext, + tools: HashMap<&'static str, Box>, +} + +impl MetaToolRegistry { + pub fn new(ctx: MetaToolContext) -> Self { + Self { + ctx, + tools: HashMap::new(), + } + } + + pub fn register(&mut self, tool: Box) { + let name = tool.name(); + debug_assert!( + name.starts_with(super::MCPMUX_PREFIX), + "meta tool name must start with {}: got {name}", + super::MCPMUX_PREFIX + ); + self.tools.insert(name, tool); + } + + /// Is `name` registered here? + pub fn contains(&self, name: &str) -> bool { + self.tools.contains_key(name) + } + + /// Master switch: are meta tools enabled in app settings? When disabled, + /// the gateway handler hides `mcpmux_*` from `list_tools` and routes + /// `call_tool` invocations straight to the feature-set path (where they + /// will miss and return "tool not found"). + /// + /// Default when the setting is missing or the repo is not wired: ON. + /// Default when the setting value is unparseable: ON (fail-open on the + /// discoverability side; security-sensitive writes still require approval). + pub async fn is_enabled(&self) -> bool { + let Some(repo) = self.ctx.settings_repo.as_ref() else { + return true; + }; + match repo.get(META_TOOLS_ENABLED_KEY).await { + Ok(Some(v)) => !matches!(v.as_str(), "false" | "0"), + _ => true, + } + } + + /// The `rmcp::model::Tool` list advertised to clients. + pub fn list_as_tools(&self) -> Vec { + self.tools + .values() + .map(|t| { + let schema: serde_json::Map = + serde_json::from_value(t.input_schema()).unwrap_or_default(); + let mut tool = Tool::new(t.name(), t.description(), Arc::new(schema)); + // Annotate writes so well-behaved clients surface the hint. + if t.is_write() { + let mut ann = tool.annotations.unwrap_or_default(); + ann.destructive_hint = Some(true); + ann.read_only_hint = Some(false); + tool.annotations = Some(ann); + } else { + let mut ann = tool.annotations.unwrap_or_default(); + ann.read_only_hint = Some(true); + tool.annotations = Some(ann); + } + tool + }) + .collect() + } + + /// Dispatch. Caller (the MCP handler) has already verified the name + /// starts with our prefix. + /// + /// Every invocation — read or write, success or failure — emits a + /// [`DomainEvent::MetaToolInvoked`] audit event so the desktop + /// Connection Log can render a row. Read tools get `decision = "read"`; + /// write tools get the actual approval decision or an error string. + pub async fn call( + &self, + name: &str, + client_id: &str, + session_id: Option<&str>, + args: Value, + ) -> Result { + let tool = self + .tools + .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 => ( + 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")), + Err(MetaToolError::ApprovalRequiredNoDesktop) => { + ("approval_required", format!("{name} no desktop")) + } + Err(MetaToolError::RateLimited) => ("rate_limited", format!("{name} rate-limited")), + Err(MetaToolError::InvalidArgument(m)) => ("invalid_args", format!("{name}: {m}")), + Err(MetaToolError::Internal(m)) => ("error", format!("{name}: {m}")), + }; + let _ = self.ctx.domain_event_tx.send(DomainEvent::MetaToolInvoked { + client_id: client_id.to_string(), + session_id: session_id.map(|s| s.to_string()), + tool_name: name.to_string(), + decision: decision.to_string(), + resolved_feature_set_id: None, + summary, + }); + + result + } + + pub fn context(&self) -> &MetaToolContext { + &self.ctx + } +} diff --git a/crates/mcpmux-gateway/src/services/meta_tools/tools.rs b/crates/mcpmux-gateway/src/services/meta_tools/tools.rs new file mode 100644 index 00000000..baffc358 --- /dev/null +++ b/crates/mcpmux-gateway/src/services/meta_tools/tools.rs @@ -0,0 +1,1167 @@ +//! Built-in `mcpmux_*` meta tool implementations. +//! +//! Each tool is a unit struct implementing [`MetaTool`]. Reads execute +//! directly; writes route through the [`ApprovalBroker`] first. + +use async_trait::async_trait; +use mcpmux_core::{ + normalize_workspace_root, DomainEvent, FeatureType, 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, 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. +/// Used by every write tool after a successful mutation. +fn emit_tools_list_changed(event_tx: &broadcast::Sender, space_id: Uuid) { + let _ = event_tx.send(DomainEvent::FeatureSetMembersChanged { + space_id, + feature_set_id: "meta-tool-write".into(), + added_count: 0, + removed_count: 0, + }); +} + +// NOTE: MetaToolInvoked audit events are emitted centrally by +// MetaToolRegistry::call, so individual tools don't need to fire them. + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +pub(crate) fn text_result(v: Value) -> CallToolResult { + CallToolResult::success(vec![Content::text(v.to_string())]) +} + +/// Resolve the Space the caller is *actually* routed into — i.e. whichever +/// Space the resolver picks via WorkspaceBinding for this session's reported +/// roots, falling back to the default Space when no binding matches. +/// +/// Every meta tool reads (and writes) inside this Space. That keeps the +/// caller's tool/FS view aligned with the tools the gateway actually exposes +/// to them, and prevents an LLM in workspace A from mutating FSes in +/// workspace B just because both sit under the same default-Space-flagged +/// row in the DB. +pub(crate) async fn caller_space_id(call: &MetaToolCall<'_>) -> Result { + let resolved = call + .ctx + .resolver + .resolve(call.session_id, Some(call.client_id)) + .await?; + if let Some(space_id) = resolved.space_id { + return Ok(space_id); + } + // Resolver returned no space — should only happen in the pathological + // "no default space configured" setup. Fail loudly so callers see why. + Err(MetaToolError::Internal( + "no Space resolved for this caller (no default Space configured?)".into(), + )) +} + +/// 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 +// --------------------------------------------------------------------------- + +pub struct ListAllToolsTool; + +#[async_trait] +impl MetaTool for ListAllToolsTool { + fn name(&self) -> &'static str { + "mcpmux_list_all_tools" + } + + fn description(&self) -> &'static str { + "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": { + "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 + .list_for_space(&space_id.to_string()) + .await?; + 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": qualified_name, + "description": f.description, + "server_available": f.is_available, + "invokable": invokable_names.contains(&qualified_name), + }) + }) + .collect(); + 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.", + }))) + } +} + +// --------------------------------------------------------------------------- +// mcpmux_list_feature_sets — read +// --------------------------------------------------------------------------- + +pub struct ListFeatureSetsTool; + +#[async_trait] +impl MetaTool for ListFeatureSetsTool { + fn name(&self) -> &'static str { + "mcpmux_list_feature_sets" + } + + fn description(&self) -> &'static str { + "List every FeatureSet defined in the caller's resolved Space — \ + built-ins and custom. Each entry carries `id`, `name`, `description`, \ + `type`, and `is_builtin`. Use before composing a new FeatureSet so \ + you don't recreate one that already fits." + } + + fn input_schema(&self) -> Value { + json!({ "type": "object", "properties": {} }) + } + + async fn call(&self, call: MetaToolCall<'_>) -> Result { + let space_id = caller_space_id(&call).await?; + let space = call + .ctx + .space_repo + .get(&space_id) + .await? + .ok_or_else(|| MetaToolError::Internal("space missing".into()))?; + let sets = call + .ctx + .feature_set_repo + .list_by_space(&space_id.to_string()) + .await?; + let sets: Vec<_> = sets + .iter() + .filter(|fs| !fs.is_deleted) + .map(|fs| { + json!({ + "id": fs.id, + "name": fs.name, + "description": fs.description, + "type": fs.feature_set_type, + "is_builtin": fs.is_builtin, + }) + }) + .collect(); + Ok(text_result( + json!({ "space_id": space.id, "feature_sets": sets }), + )) + } +} + +// --------------------------------------------------------------------------- +// 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. +// --------------------------------------------------------------------------- + +/// Common path for every write tool: build payload, ask broker, run the +/// mutation. Returns the broker's decision so the caller can proceed only +/// on success. `mutate` is the thing that runs post-approval and is +/// expected to emit `tools/list_changed` when relevant. +pub(crate) async fn with_approval( + call: &MetaToolCall<'_>, + tool_name: &'static str, + summary: String, + diff: Option, + affects_other_clients: bool, + raw_args: Value, + mutate: F, +) -> Result +where + F: FnOnce() -> Fut, + Fut: std::future::Future>, +{ + let payload = ApprovalPayload { + tool_name: tool_name.to_string(), + summary, + diff, + raw_args, + affects_other_clients, + }; + call.ctx + .approval_broker + .request_approval(call.client_id, tool_name, payload) + .await?; + mutate().await +} + +fn parse_uuid_arg(args: &Value, field: &str) -> Result { + let s = args + .get(field) + .and_then(|v| v.as_str()) + .ok_or_else(|| MetaToolError::InvalidArgument(format!("missing `{field}`")))?; + Uuid::parse_str(s) + .map_err(|_| MetaToolError::InvalidArgument(format!("`{field}` is not a UUID: {s}"))) +} + +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) +// --------------------------------------------------------------------------- + +pub struct CreateFeatureSetTool; + +#[async_trait] +impl MetaTool for CreateFeatureSetTool { + fn name(&self) -> &'static str { + "mcpmux_create_feature_set" + } + + fn description(&self) -> &'static str { + "Create a new custom FeatureSet in the caller's resolved Space from \ + an explicit list of qualified tool names (e.g. ['github_create_issue', \ + '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`." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["name", "tool_qualified_names"], + "properties": { + "name": { "type": "string" }, + "description": { "type": "string" }, + "tool_qualified_names": { + "type": "array", + "items": { "type": "string" } + }, + "surfaced_tools": { + "type": "array", + "items": { "type": "string" }, + "description": "Optional subset of tool_qualified_names to promote into client tools/list" + } + } + }) + } + + fn is_write(&self) -> bool { + true + } + + async fn call(&self, call: MetaToolCall<'_>) -> Result { + let name = call + .args + .get("name") + .and_then(|v| v.as_str()) + .ok_or_else(|| MetaToolError::InvalidArgument("missing `name`".into()))? + .to_string(); + let description = call + .args + .get("description") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let qualified_names: Vec = call + .args + .get("tool_qualified_names") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(); + if qualified_names.is_empty() { + return Err(MetaToolError::InvalidArgument( + "tool_qualified_names must contain at least one entry".into(), + )); + } + + let 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 + // approval dialog can show the exact tool count. + let all_features = call + .ctx + .server_feature_repo + .list_for_space(&space_id.to_string()) + .await?; + let matched: Vec<_> = all_features + .iter() + .filter(|f| { + f.feature_type == FeatureType::Tool && qualified_names.contains(&f.qualified_name()) + }) + .cloned() + .collect(); + if matched.is_empty() { + return Err(MetaToolError::InvalidArgument( + "no provided qualified_names matched any tool in this Space".into(), + )); + } + + let summary = format!("Create FeatureSet '{name}' with {} tools", matched.len()); + let diff = json!({ + "added_tools": matched.iter().map(|f| f.qualified_name()).collect::>(), + }); + + let fs_repo = call.ctx.feature_set_repo.clone(); + let name_for_closure = name.clone(); + let description_for_closure = description.clone(); + with_approval( + &call, + "mcpmux_create_feature_set", + summary, + Some(diff), + false, + call.args.clone(), + || async move { + let mut fs = + mcpmux_core::FeatureSet::new_custom(&name_for_closure, space_id.to_string()); + fs.description = description_for_closure; + for feature in &matched { + 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, + }))) + }, + ) + .await + } +} + +// --------------------------------------------------------------------------- +// mcpmux_bind_current_workspace — write (persistent, space-wide effect) +// --------------------------------------------------------------------------- + +pub struct BindCurrentWorkspaceTool; + +#[async_trait] +impl MetaTool for BindCurrentWorkspaceTool { + fn name(&self) -> &'static str { + "mcpmux_bind_current_workspace" + } + + fn description(&self) -> &'static str { + "Persistently bind the caller's first reported workspace root to the \ + given FeatureSet inside the caller's resolved Space. Every future \ + connection that reports the same root (or a subdirectory) will \ + resolve to this FeatureSet. Requires user approval and the calling \ + client MUST have declared MCP roots." + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "required": ["feature_set_id"], + "properties": { + "feature_set_id": { "type": "string" } + } + }) + } + + fn is_write(&self) -> bool { + true + } + + async fn call(&self, call: MetaToolCall<'_>) -> Result { + let fs_id = parse_uuid_arg(&call.args, "feature_set_id")?; + + let space_id = caller_space_id(&call).await?; + let roots = call + .session_id + .and_then(|sid| call.ctx.session_roots.get(sid)) + .unwrap_or_default(); + let root = roots.into_iter().next().ok_or_else(|| { + MetaToolError::InvalidArgument( + "caller did not report any MCP roots; cannot bind".into(), + ) + })?; + let normalized = normalize_workspace_root(&root); + + let fs_name = call + .ctx + .feature_set_repo + .get(&fs_id.to_string()) + .await? + .map(|fs| fs.name) + .unwrap_or_else(|| fs_id.to_string()); + + let summary = format!( + "Bind workspace '{normalized}' in this Space to FeatureSet '{fs_name}'. \ + Affects every future connection that reports this path." + ); + + let binding_repo = call.ctx.binding_repo.clone(); + let event_tx = call.ctx.domain_event_tx.clone(); + with_approval( + &call, + "mcpmux_bind_current_workspace", + summary, + None, + true, + call.args.clone(), + || async move { + let 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, + "workspace_root": normalized, + "feature_set_id": fs_id, + }))) + }, + ) + .await + } +} + +// Suppress unused warning — `ApprovalScope` is re-exported for the Tauri +// surface and will land as a command argument once the dialog is wired up. +#[allow(dead_code)] +fn _unused_approval_scope(_: ApprovalScope) {} diff --git a/crates/mcpmux-gateway/src/services/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 085d0237..00d41c8e 100644 --- a/crates/mcpmux-gateway/src/services/mod.rs +++ b/crates/mcpmux-gateway/src/services/mod.rs @@ -8,15 +8,29 @@ mod authorization; mod client_metadata_service; mod event_emitter; +mod feature_set_resolver; mod grant_service; +pub mod meta_tools; mod notification_emitter; mod prefix_cache; +mod session_overrides; +mod session_roots; mod space_resolver; +pub mod tool_discovery; pub use authorization::AuthorizationService; pub use client_metadata_service::ClientMetadataService; pub use event_emitter::EventEmitter; +pub use feature_set_resolver::{FeatureSetResolverService, ResolutionSource, ResolvedFeatureSet}; pub use grant_service::GrantService; +pub use meta_tools::{ + 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 new file mode 100644 index 00000000..71a8cf97 --- /dev/null +++ b/crates/mcpmux-gateway/src/services/session_roots.rs @@ -0,0 +1,265 @@ +//! Session-scoped registry of MCP workspace roots. +//! +//! When a client declares the `roots` capability on `initialize`, the gateway +//! calls `roots/list` via the peer and stashes the result here keyed by the +//! client's `mcp-session-id`. The `FeatureSetResolverService` consults this +//! registry to pick a workspace binding. +//! +//! Roots are stored already-normalized (via +//! [`mcpmux_core::normalize_workspace_root`]) so the resolver doesn't need to +//! re-normalize on every lookup. + +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use dashmap::DashMap; +use mcpmux_core::normalize_workspace_root; + +/// Thread-safe registry mapping `mcp-session-id` to the caller's reported +/// workspace roots, plus the most recently resolved feature-set id so the +/// gateway can tell when a session's resolution flips and emit a per-peer +/// `list_changed` to that one session only. +#[derive(Debug, Default)] +pub struct SessionRootsRegistry { + map: DashMap>, + /// `session_id -> last-resolved feature-set id` (or `None` for "deny"). + /// We compare each fresh resolution to this snapshot; a different value + /// means the client's effective tools changed and we must notify it. + last_resolution: DashMap>, + /// `session_id -> declared MCP `roots` capability` (true when the peer's + /// `initialize.params.capabilities.roots` was non-empty). + /// + /// Stamped during `on_initialized` regardless of whether roots have + /// arrived yet. The resolver reads this to decide between + /// `WorkspaceBinding` routing (capable) and the rootless `client_grants` + /// fallback (not capable). Absence here means we never saw an + /// `initialize` for that session — treated as "unknown" by the resolver + /// and routed via grants. + roots_capable: DashMap, + /// `session_id -> Instant of the last on-demand `list_roots()` probe`. + /// + /// Used by [`Self::should_throttle_probe`] to avoid hammering a + /// failing client when its previous probe already errored out + /// recently. Only stamped after a probe attempt completes (success + /// or failure), not on entry — so concurrent in-flight probes + /// coordinate via [`Self::probe_lock`] instead of this throttle. + last_probe: DashMap, + /// Per-session mutex guarding `peer.list_roots()` probe attempts. + /// + /// Single-flight semantics: when a burst of three list requests + /// (`tools/list` + `prompts/list` + `resources/list`) hits a + /// roots-pending session within milliseconds, only one upstream + /// `list_roots()` call should be in flight. The other two block on + /// the same lock; once the first attempt populates `map`, the + /// followers re-check `map.get(sid)` and skip the upstream call + /// entirely. + /// + /// Without this, a boolean "already tried" flag let the followers + /// see `roots_pending` and return empty *before* the first probe's + /// result landed — exactly the bug that left Claude Code's + /// VS Code extension showing only the meta tools. + probe_lock: DashMap>>, +} + +impl SessionRootsRegistry { + pub fn new() -> Arc { + Arc::new(Self { + map: DashMap::new(), + last_resolution: DashMap::new(), + roots_capable: DashMap::new(), + last_probe: DashMap::new(), + probe_lock: DashMap::new(), + }) + } + + /// Get (or create) the per-session probe lock. The returned Arc is + /// what the handler awaits to serialize concurrent probes — see + /// [`Self::probe_lock`] for the rationale. + pub fn probe_lock(&self, session_id: &str) -> Arc> { + self.probe_lock + .entry(session_id.to_string()) + .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))) + .clone() + } + + /// Should we skip an on-demand probe because the previous attempt + /// completed (success or failure) within the last `throttle`? + /// + /// Distinct from `probe_lock`: the lock serializes *concurrent* + /// probes; this rate-limit prevents *sequential* probes from + /// hammering a peer whose previous attempt errored. + pub fn should_throttle_probe(&self, session_id: &str, throttle: Duration) -> bool { + let Some(last) = self.last_probe.get(session_id) else { + return false; + }; + Instant::now().duration_since(*last) < throttle + } + + /// Stamp the completion of an on-demand probe so the next caller + /// observes the throttle. Called after the probe returns (regardless + /// of success or failure) so successive probes back off only when + /// the previous one actually finished. + pub fn mark_probe_completed(&self, session_id: &str) { + self.last_probe + .insert(session_id.to_string(), Instant::now()); + } + + /// Record whether a session declared the MCP `roots` capability on + /// `initialize`. Idempotent — called once per session lifecycle. + pub fn set_roots_capable(&self, session_id: impl Into, capable: bool) { + self.roots_capable.insert(session_id.into(), capable); + } + + /// `Some(true)` when the session declared `roots`, `Some(false)` when it + /// explicitly didn't, `None` when no `initialize` has been observed + /// (callers without a session id, or pre-init requests). + pub fn is_roots_capable(&self, session_id: &str) -> Option { + self.roots_capable.get(session_id).map(|v| *v) + } + + /// Store the reported roots for a session. `roots` should already be + /// absolute paths or `file://` URIs — we normalize them before storing. + pub fn set(&self, session_id: impl Into, roots: I) + where + I: IntoIterator, + S: AsRef, + { + let normalized: Vec = roots + .into_iter() + .map(|r| normalize_workspace_root(r.as_ref())) + .filter(|r| !r.is_empty()) + .collect(); + self.map.insert(session_id.into(), normalized); + } + + /// Retrieve the (already-normalized) roots for a session, if any. + pub fn get(&self, session_id: &str) -> Option> { + self.map.get(session_id).map(|v| v.clone()) + } + + /// Drop a session's roots — call on client disconnect. + pub fn remove(&self, session_id: &str) { + self.map.remove(session_id); + self.last_resolution.remove(session_id); + self.roots_capable.remove(session_id); + self.last_probe.remove(session_id); + self.probe_lock.remove(session_id); + } + + /// Compare-and-set the session's resolved feature-set id. Returns `true` + /// when the value actually changed (caller should fire `list_changed`), + /// `false` when it's the same as before. + pub fn record_resolution(&self, session_id: &str, fs_id: Option<&str>) -> bool { + let new_val: Option = fs_id.map(|s| s.to_string()); + 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 + /// and sorted for stable presentation. Used by the UI's "Detected + /// workspaces" panel so the user can act on folders that clients have + /// surfaced but haven't been bound yet. + pub fn list_all_roots(&self) -> Vec { + let mut out: Vec = self + .map + .iter() + .flat_map(|entry| entry.value().clone()) + .collect(); + out.sort(); + out.dedup(); + out + } + + /// 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)] + pub fn len(&self) -> usize { + self.map.len() + } + + /// Whether no sessions are tracked. Paired with [`Self::len`] — clippy + /// requires this when `len` is present. + #[cfg(test)] + pub fn is_empty(&self) -> bool { + self.map.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_set_normalizes_and_filters_empty() { + let reg = SessionRootsRegistry::default(); + reg.set( + "sess-1", + [ + #[cfg(windows)] + "file:///D:/proj/", + #[cfg(not(windows))] + "file:///home/user/proj/", + "", + ], + ); + let roots = reg.get("sess-1").unwrap(); + assert_eq!(roots.len(), 1); + #[cfg(windows)] + assert_eq!(roots[0], "d:\\proj"); + #[cfg(not(windows))] + assert_eq!(roots[0], "/home/user/proj"); + } + + #[test] + fn test_remove() { + let reg = SessionRootsRegistry::default(); + reg.set("sess-1", ["/a"]); + assert_eq!(reg.len(), 1); + reg.remove("sess-1"); + assert_eq!(reg.len(), 0); + } + + #[test] + fn test_record_resolution_flips_on_change() { + let reg = SessionRootsRegistry::default(); + // First sighting always counts as a change so the caller emits the + // initial list_changed for whoever subscribed late. + assert!(reg.record_resolution("sess-1", Some("fs-fallback"))); + // Same value → no change. + assert!(!reg.record_resolution("sess-1", Some("fs-fallback"))); + // Different value → change. + assert!(reg.record_resolution("sess-1", Some("fs-bound"))); + // None ↔ Some both count. + assert!(reg.record_resolution("sess-1", None)); + assert!(!reg.record_resolution("sess-1", None)); + } + + #[test] + fn test_remove_clears_resolution_too() { + let reg = SessionRootsRegistry::default(); + reg.record_resolution("sess-1", Some("fs-a")); + reg.remove("sess-1"); + // After remove, recording the same value should be considered a + // change (no prior entry). + assert!(reg.record_resolution("sess-1", Some("fs-a"))); + } +} diff --git a/crates/mcpmux-gateway/src/services/space_resolver.rs b/crates/mcpmux-gateway/src/services/space_resolver.rs index 63819e3e..5a348d40 100644 --- a/crates/mcpmux-gateway/src/services/space_resolver.rs +++ b/crates/mcpmux-gateway/src/services/space_resolver.rs @@ -1,99 +1,37 @@ //! Space Resolution Service //! -//! Determines which space a client should access based on their connection mode. -//! Follows SRP: Single responsibility is space resolution logic. -//! Follows DIP: Depends on repository abstractions. +//! Picks which Space a connecting client lands in. With per-client connection +//! modes gone, the answer is always "the active/default Space" — but this +//! service stays as a thin abstraction so callers don't reach into +//! SpaceRepository directly and so future per-session targeting (e.g. +//! WorkspaceBinding-driven space selection) has a single seam to extend. use anyhow::{anyhow, Result}; use mcpmux_core::SpaceRepository; -use mcpmux_storage::InboundClientRepository; use std::sync::Arc; -use tracing::warn; use uuid::Uuid; -/// Space resolver service -/// -/// SRP: Only responsible for determining which space a client should use -/// OCP: Can be extended with new resolution strategies without modification pub struct SpaceResolverService { - client_repo: Arc, space_repo: Arc, } impl SpaceResolverService { - pub fn new( - client_repo: Arc, - space_repo: Arc, - ) -> Self { - Self { - client_repo, - space_repo, - } + pub fn new(space_repo: Arc) -> Self { + Self { space_repo } } - /// Resolve which space a client should access + /// Resolve which space a client should access. /// - /// Resolution strategy based on client's connection_mode: - /// - "locked": Use client.locked_space_id - /// - "follow_active": Use currently active space - /// - "ask_on_change": Use last selected space (not implemented yet) - pub async fn resolve_space_for_client(&self, client_id: &str) -> Result { - // Get client record - let client = self - .client_repo - .get_client(client_id) + /// Currently always returns the default/active Space — per-client pins + /// no longer exist. `client_id` is kept in the signature for forward + /// compatibility with routing rules keyed on identity (e.g. future + /// headless-connection policies). + pub async fn resolve_space_for_client(&self, _client_id: &str) -> Result { + let active_space = self + .space_repo + .get_default() .await? - .ok_or_else(|| anyhow!("Client not found: {}", client_id))?; - - match client.connection_mode.as_str() { - "locked" => { - // Use locked space - let space_id_str = client - .locked_space_id - .ok_or_else(|| anyhow!("Client has locked mode but no locked_space_id"))?; - - let space_id = Uuid::parse_str(&space_id_str) - .map_err(|e| anyhow!("Invalid locked_space_id: {}", e))?; - - Ok(space_id) - } - "follow_active" => { - // Use currently active space - let active_space = self - .space_repo - .get_default() - .await? - .ok_or_else(|| anyhow!("No active space set"))?; - - Ok(active_space.id) - } - "ask_on_change" => { - // TODO: Implement session-based space tracking - // For now, fall back to active space - warn!( - "[SpaceResolver] ask_on_change mode not fully implemented, using active space" - ); - let active_space = self - .space_repo - .get_default() - .await? - .ok_or_else(|| anyhow!("No active space set"))?; - - Ok(active_space.id) - } - mode => { - warn!( - "[SpaceResolver] Unknown connection mode: {}, defaulting to active space", - mode - ); - let active_space = self - .space_repo - .get_default() - .await? - .ok_or_else(|| anyhow!("No active space set"))?; - - Ok(active_space.id) - } - } + .ok_or_else(|| anyhow!("No active space set"))?; + Ok(active_space.id) } } diff --git a/crates/mcpmux-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-mcp/src/transports.rs b/crates/mcpmux-mcp/src/transports.rs index 6472df25..f92ab175 100644 --- a/crates/mcpmux-mcp/src/transports.rs +++ b/crates/mcpmux-mcp/src/transports.rs @@ -83,20 +83,11 @@ pub struct McpClientHandler { impl McpClientHandler { pub fn new(server_id: &str) -> Self { + let mut client_info = + Implementation::new(format!("mcpmux-{}", server_id), env!("CARGO_PKG_VERSION")); + client_info.title = Some("McpMux Gateway".to_string()); Self { - info: ClientInfo { - protocol_version: Default::default(), - capabilities: ClientCapabilities::default(), - client_info: Implementation { - name: format!("mcpmux-{}", server_id), - version: env!("CARGO_PKG_VERSION").to_string(), - title: Some("McpMux Gateway".to_string()), - icons: None, - website_url: None, - ..Default::default() - }, - meta: None, - }, + info: ClientInfo::new(ClientCapabilities::default(), client_info), } } } @@ -217,11 +208,10 @@ impl McpSession { let result = self .client .peer() - .call_tool(CallToolRequestParams { - name: name.to_string().into(), - arguments: args, - task: None, - meta: None, + .call_tool({ + let mut params = CallToolRequestParams::new(name.to_string()); + params.arguments = args; + params }) .await .context("Tool call failed")?; diff --git a/crates/mcpmux-storage/src/database.rs b/crates/mcpmux-storage/src/database.rs index 86f35d68..13c96d00 100644 --- a/crates/mcpmux-storage/src/database.rs +++ b/crates/mcpmux-storage/src/database.rs @@ -32,11 +32,103 @@ struct Migration { /// Note: Migrations have been consolidated into a single clean initial migration. /// The schema includes cached_definition for offline operation and excludes /// runtime fields (connection_status, last_connected_at, last_error). -const MIGRATIONS: &[Migration] = &[Migration { - version: 1, - name: "initial", - sql: include_str!("migrations/001_initial.sql"), -}]; +const MIGRATIONS: &[Migration] = &[ + Migration { + version: 1, + name: "initial", + sql: include_str!("migrations/001_initial.sql"), + }, + Migration { + version: 2, + name: "featureset_resolver", + sql: include_str!("migrations/002_featureset_resolver.sql"), + }, + Migration { + version: 3, + name: "drop_legacy_grants", + sql: include_str!("migrations/003_drop_legacy_grants.sql"), + }, + Migration { + version: 4, + name: "workspace_modes", + sql: include_str!("migrations/004_workspace_modes.sql"), + }, + Migration { + version: 5, + name: "drop_client_pin", + sql: include_str!("migrations/005_drop_client_pin.sql"), + }, + Migration { + version: 6, + name: "collapse_feature_sets", + sql: include_str!("migrations/006_collapse_feature_sets.sql"), + }, + Migration { + version: 7, + name: "concrete_binding", + sql: include_str!("migrations/007_concrete_binding.sql"), + }, + Migration { + version: 8, + name: "canonical_default_space", + sql: include_str!("migrations/008_canonical_default_space.sql"), + }, + Migration { + version: 9, + name: "restore_client_grants", + sql: include_str!("migrations/009_restore_client_grants.sql"), + }, + Migration { + version: 10, + name: "inbound_client_reports_roots", + sql: include_str!("migrations/010_inbound_client_reports_roots.sql"), + }, + Migration { + version: 11, + name: "inbound_client_roots_capability_known", + sql: include_str!("migrations/011_inbound_client_roots_capability_known.sql"), + }, + Migration { + version: 12, + name: "workspace_binding_feature_sets", + sql: include_str!("migrations/012_workspace_binding_feature_sets.sql"), + }, + Migration { + version: 13, + name: "rename_default_to_starter", + sql: include_str!("migrations/013_rename_default_to_starter.sql"), + }, + Migration { + version: 14, + name: "rewrite_starter_seed_copy", + sql: include_str!("migrations/014_rewrite_starter_seed_copy.sql"), + }, + Migration { + version: 15, + 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. pub struct Database { diff --git a/crates/mcpmux-storage/src/keychain_dpapi.rs b/crates/mcpmux-storage/src/keychain_dpapi.rs index 6d868a97..3cf5be8d 100644 --- a/crates/mcpmux-storage/src/keychain_dpapi.rs +++ b/crates/mcpmux-storage/src/keychain_dpapi.rs @@ -321,6 +321,6 @@ mod tests { assert!(file_contents.len() > KEY_SIZE); // The raw key bytes should not appear in the file - assert!(!file_contents.windows(KEY_SIZE).any(|w| w == &*key)); + assert!(!file_contents.windows(KEY_SIZE).any(|w| w == *key)); } } diff --git a/crates/mcpmux-storage/src/migrations/002_featureset_resolver.sql b/crates/mcpmux-storage/src/migrations/002_featureset_resolver.sql new file mode 100644 index 00000000..5d434a36 --- /dev/null +++ b/crates/mcpmux-storage/src/migrations/002_featureset_resolver.sql @@ -0,0 +1,82 @@ +-- Migration 002: FeatureSet Resolver V2 +-- +-- Introduces the project-oriented FeatureSet selection model: +-- resolution order = access-key pin > workspace-root binding > space-active FS +-- +-- This migration is forward-compatible: the old per-client grants system +-- (client_grants table + inbound_clients.grants JSON) keeps working. The +-- resolver is switched over in a later migration. +-- +-- Added in this migration: +-- * inbound_clients.pinned_feature_set_id — explicit FS for this access key +-- * inbound_clients.pinned_space_id — Space the access key belongs to +-- * spaces.active_feature_set_id — default FS per Space when no pin / no workspace match +-- * workspace_bindings — (space_id, workspace_root) -> feature_set_id overrides + +-- ============================================================================ +-- inbound_clients: pinned_feature_set_id + pinned_space_id +-- ============================================================================ + +-- The FS chosen at approval time. NULL means "follow workspace / space default". +ALTER TABLE inbound_clients ADD COLUMN pinned_feature_set_id TEXT + REFERENCES feature_sets(id) ON DELETE SET NULL; + +-- The Space this access key belongs to. Replaces locked_space_id semantically, +-- but the old column is kept for backwards compat until a later migration drops it. +ALTER TABLE inbound_clients ADD COLUMN pinned_space_id TEXT + REFERENCES spaces(id) ON DELETE SET NULL; + +-- Backfill pinned_space_id from locked_space_id for any existing rows. +UPDATE inbound_clients +SET pinned_space_id = locked_space_id +WHERE pinned_space_id IS NULL AND locked_space_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_inbound_clients_pinned_space ON inbound_clients(pinned_space_id); +CREATE INDEX IF NOT EXISTS idx_inbound_clients_pinned_fs ON inbound_clients(pinned_feature_set_id); + +-- ============================================================================ +-- spaces.active_feature_set_id +-- ============================================================================ + +ALTER TABLE spaces ADD COLUMN active_feature_set_id TEXT + REFERENCES feature_sets(id) ON DELETE SET NULL; + +-- Backfill every Space's active FS to its existing 'default' FeatureSet +-- so day-one behavior matches pre-migration: clients with no pin and no +-- workspace match receive the same features they had before. +UPDATE spaces +SET active_feature_set_id = ( + SELECT fs.id + FROM feature_sets fs + WHERE fs.space_id = spaces.id + AND fs.feature_set_type = 'default' + AND fs.is_deleted = 0 + LIMIT 1 +) +WHERE active_feature_set_id IS NULL; + +-- ============================================================================ +-- workspace_bindings: (space_id, workspace_root) -> feature_set_id +-- ============================================================================ +-- +-- workspace_root is a normalized absolute filesystem path: +-- * Windows drive letter lowercased (e.g. "d:\projects\foo") +-- * trailing path separator stripped +-- * symlinks / junctions resolved before insert +-- Matching is longest-prefix over the caller's reported MCP roots. + +CREATE TABLE IF NOT EXISTS workspace_bindings ( + id TEXT PRIMARY KEY, + space_id TEXT NOT NULL, + workspace_root TEXT NOT NULL, + feature_set_id TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + + UNIQUE(space_id, workspace_root), + FOREIGN KEY (space_id) REFERENCES spaces(id) ON DELETE CASCADE, + FOREIGN KEY (feature_set_id) REFERENCES feature_sets(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_workspace_bindings_space ON workspace_bindings(space_id); +CREATE INDEX IF NOT EXISTS idx_workspace_bindings_root ON workspace_bindings(workspace_root); diff --git a/crates/mcpmux-storage/src/migrations/003_drop_legacy_grants.sql b/crates/mcpmux-storage/src/migrations/003_drop_legacy_grants.sql new file mode 100644 index 00000000..c8bc6241 --- /dev/null +++ b/crates/mcpmux-storage/src/migrations/003_drop_legacy_grants.sql @@ -0,0 +1,21 @@ +-- Migration 003: Drop legacy per-client grants now that the FeatureSetResolver +-- is authoritative (see migration 002 for the new schema). +-- +-- The resolver (pin > workspace-binding > space-active) no longer consults +-- `client_grants`. The column and table below are safe to remove once every +-- deployed client has picked up the resolver v2 code path (shadow mode was +-- run in the previous release so divergence would already have surfaced). +-- +-- NOTE: Data loss is intentional. If you need to preserve the old grants for +-- audit, export them to JSON before running this migration. + +-- Drop the client_grants table. The resolver no longer reads it, and the +-- corresponding repository methods have been turned into no-ops so lingering +-- Tauri commands (grant_feature_set_to_client, etc.) won't error at runtime. +-- +-- We keep the `grants` JSON column on `inbound_clients` for now — it's +-- already unused in reads/writes (SELECT uses the '{}' placeholder), and +-- leaving it avoids a second schema migration on older SQLite builds that +-- predate ALTER TABLE … DROP COLUMN. It will be removed in a future +-- migration once the Tauri surface is cleaned up. +DROP TABLE IF EXISTS client_grants; diff --git a/crates/mcpmux-storage/src/migrations/004_workspace_modes.sql b/crates/mcpmux-storage/src/migrations/004_workspace_modes.sql new file mode 100644 index 00000000..94cbb6d7 --- /dev/null +++ b/crates/mcpmux-storage/src/migrations/004_workspace_modes.sql @@ -0,0 +1,77 @@ +-- Migration 004: Workspace-root-driven routing. +-- +-- Each WorkspaceBinding now has TWO resolution modes — one for the Space +-- axis, one for the FeatureSet axis — each either "active" (follow the +-- global default) or "locked" to a specific id. See +-- `mcpmux.space/diagrams/workppace-root-session/` for the plan. +-- +-- Changes: +-- * Drop the `space_id` uniqueness from `workspace_bindings` — routing is +-- now keyed on the root alone, not on (space_id, root), and the binding +-- itself carries space info via `space_mode`. +-- * Add `space_mode` + `space_id` (with space_id NULL when Active). +-- * Add `fs_mode` + `fs_id` (with fs_id NULL when ActiveForSpace). +-- * Backfill existing rows: a binding today has (space_id, feature_set_id) +-- both set, so migrate to space_mode='locked' + fs_mode='locked'. This +-- preserves exact existing behaviour. +-- * The old (space_id, feature_set_id) columns stay readable for one +-- release; new writes use the mode columns only. They're dropped in +-- migration 005 once the resolver is on the new path everywhere. +-- +-- Lifetime: forward-compatible additive. Old code can still read +-- (space_id, feature_set_id) directly; new code reads via the mode pair. + +-- 1. Add the mode columns with sane defaults for existing rows. +ALTER TABLE workspace_bindings ADD COLUMN space_mode TEXT NOT NULL DEFAULT 'active'; +ALTER TABLE workspace_bindings ADD COLUMN fs_mode TEXT NOT NULL DEFAULT 'active_for_space'; +-- New nullable "locked to" pointers. Use distinct column names so we can +-- keep the old `space_id` / `feature_set_id` columns for one release. +ALTER TABLE workspace_bindings ADD COLUMN locked_space_id TEXT + REFERENCES spaces(id) ON DELETE SET NULL; +ALTER TABLE workspace_bindings ADD COLUMN locked_feature_set_id TEXT + REFERENCES feature_sets(id) ON DELETE SET NULL; + +-- 2. Backfill existing bindings. Today every row has both ids populated +-- (non-null) so the "locked+locked" mode preserves exact behaviour. +UPDATE workspace_bindings +SET + space_mode = 'locked', + locked_space_id = space_id, + fs_mode = 'locked', + locked_feature_set_id = feature_set_id +WHERE space_id IS NOT NULL AND feature_set_id IS NOT NULL; + +-- 3. Globalize uniqueness. The old `UNIQUE(space_id, workspace_root)` +-- conflicts with the new model where a root resolves globally. SQLite +-- doesn't support ALTER TABLE … DROP CONSTRAINT, so the pragmatic approach +-- is a table rebuild. We keep the old columns around for read compat. +CREATE TABLE workspace_bindings_v2 ( + id TEXT PRIMARY KEY, + workspace_root TEXT NOT NULL UNIQUE, + space_mode TEXT NOT NULL DEFAULT 'active', + locked_space_id TEXT REFERENCES spaces(id) ON DELETE SET NULL, + fs_mode TEXT NOT NULL DEFAULT 'active_for_space', + locked_feature_set_id TEXT REFERENCES feature_sets(id) ON DELETE SET NULL, + -- Legacy columns preserved until migration 005 ships and everything is + -- on the new mode columns. Unused by new code. + legacy_space_id TEXT, + legacy_feature_set_id TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +INSERT INTO workspace_bindings_v2 ( + id, workspace_root, space_mode, locked_space_id, fs_mode, locked_feature_set_id, + legacy_space_id, legacy_feature_set_id, created_at, updated_at +) +SELECT + id, workspace_root, space_mode, locked_space_id, fs_mode, locked_feature_set_id, + space_id, feature_set_id, created_at, updated_at +FROM workspace_bindings; + +DROP TABLE workspace_bindings; +ALTER TABLE workspace_bindings_v2 RENAME TO workspace_bindings; + +CREATE INDEX IF NOT EXISTS idx_workspace_bindings_root ON workspace_bindings(workspace_root); +CREATE INDEX IF NOT EXISTS idx_workspace_bindings_locked_space ON workspace_bindings(locked_space_id); +CREATE INDEX IF NOT EXISTS idx_workspace_bindings_locked_fs ON workspace_bindings(locked_feature_set_id); diff --git a/crates/mcpmux-storage/src/migrations/005_drop_client_pin.sql b/crates/mcpmux-storage/src/migrations/005_drop_client_pin.sql new file mode 100644 index 00000000..0f2f60ce --- /dev/null +++ b/crates/mcpmux-storage/src/migrations/005_drop_client_pin.sql @@ -0,0 +1,95 @@ +-- Migration 005: Drop client-level FeatureSet pinning +-- +-- The per-client pin was an escape hatch for "lock this access key to FS X". +-- In the new model, routing is keyed on the workspace root (WorkspaceBinding) +-- not the client identity — two IDEs opening the same folder should see the +-- same tools regardless of which one they are. Sessions without a root fall +-- through to the Space's active FS (same behaviour as today's default). +-- +-- SQLite requires table-rebuild semantics for DROP COLUMN when the column has +-- a FOREIGN KEY reference. PRAGMA foreign_keys is toggled off during the copy +-- so the FK constraint doesn't block the rebuild; it's restored at the end. + +PRAGMA foreign_keys = OFF; + +-- Mirror the original `inbound_clients` schema (migration 001) MINUS the two +-- pinned_* columns added in migration 002. Keep every other column so the +-- copy preserves all user data (OAuth metadata, approval flag, aliases, …). +CREATE TABLE inbound_clients_new ( + client_id TEXT PRIMARY KEY, + + registration_type TEXT NOT NULL CHECK(registration_type IN ('cimd', 'dcr', 'preregistered')), + + client_name TEXT NOT NULL, + client_alias TEXT, + + logo_uri TEXT, + client_uri TEXT, + software_id TEXT, + software_version TEXT, + + redirect_uris TEXT NOT NULL, + grant_types TEXT NOT NULL, + response_types TEXT NOT NULL, + token_endpoint_auth_method TEXT NOT NULL, + scope TEXT, + + metadata_url TEXT, + metadata_cached_at TEXT, + metadata_cache_ttl INTEGER DEFAULT 3600, + + connection_mode TEXT NOT NULL DEFAULT 'follow_active', + locked_space_id TEXT, + + grants TEXT, + + approved INTEGER NOT NULL DEFAULT 0, + + last_seen TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + + FOREIGN KEY (locked_space_id) REFERENCES spaces(id) ON DELETE SET NULL +); + +INSERT INTO inbound_clients_new ( + client_id, + registration_type, + client_name, client_alias, + logo_uri, client_uri, software_id, software_version, + redirect_uris, grant_types, response_types, token_endpoint_auth_method, scope, + metadata_url, metadata_cached_at, metadata_cache_ttl, + connection_mode, locked_space_id, + grants, + approved, + last_seen, created_at, updated_at +) +SELECT + client_id, + registration_type, + client_name, client_alias, + logo_uri, client_uri, software_id, software_version, + redirect_uris, grant_types, response_types, token_endpoint_auth_method, scope, + metadata_url, metadata_cached_at, metadata_cache_ttl, + connection_mode, locked_space_id, + grants, + approved, + last_seen, created_at, updated_at +FROM inbound_clients; + +DROP TABLE inbound_clients; +ALTER TABLE inbound_clients_new RENAME TO inbound_clients; + +-- Recreate the indices that 001 defined (migration 001 used CREATE INDEX +-- IF NOT EXISTS so re-creating is safe when run on databases that already +-- dropped them alongside the table). +CREATE INDEX IF NOT EXISTS idx_inbound_clients_type + ON inbound_clients(registration_type); +CREATE INDEX IF NOT EXISTS idx_inbound_clients_name + ON inbound_clients(client_name); +CREATE INDEX IF NOT EXISTS idx_inbound_clients_metadata_url + ON inbound_clients(metadata_url) WHERE metadata_url IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_inbound_clients_approved + ON inbound_clients(approved) WHERE approved = 1; + +PRAGMA foreign_keys = ON; diff --git a/crates/mcpmux-storage/src/migrations/006_collapse_feature_sets.sql b/crates/mcpmux-storage/src/migrations/006_collapse_feature_sets.sql new file mode 100644 index 00000000..f101c1a8 --- /dev/null +++ b/crates/mcpmux-storage/src/migrations/006_collapse_feature_sets.sql @@ -0,0 +1,119 @@ +-- Migration 006: Collapse FeatureSet model to Default + Custom only +-- +-- Previously each space auto-spawned two builtin FSes (`all` + `default`) and +-- every installed server got a `server-all` set; clients could also grow +-- auto-created "{client_name} - Custom" sets on first use. The resolver no +-- longer consults those — routing is pure `WorkspaceBinding → Space default`. +-- +-- This migration: +-- 1. Hard-deletes the legacy auto-created rows so they disappear from the UI. +-- 2. Rebuilds `inbound_clients` without the `connection_mode` / +-- `locked_space_id` columns (they belonged to the old client-level +-- routing surface and are now dead weight). +-- +-- Custom sets the user authored by hand stay; they may still be referenced +-- from `workspace_bindings.locked_feature_set_id` or `spaces.active_feature_set_id`. + +PRAGMA foreign_keys = OFF; + +-- Clear any `active_feature_set_id` pointing at an `all` / `server-all` set +-- before we delete those rows, otherwise the FK would dangle. +UPDATE spaces +SET active_feature_set_id = NULL +WHERE active_feature_set_id IN ( + SELECT id FROM feature_sets + WHERE feature_set_type IN ('all', 'server-all') +); + +-- Same cleanup for workspace_bindings.locked_feature_set_id — if a binding +-- pinned an 'all'/'server-all' FS, collapse to ActiveForSpace so routing +-- falls through to the space's Default FS instead of dangling. +UPDATE workspace_bindings +SET fs_mode = 'active_for_space', locked_feature_set_id = NULL +WHERE locked_feature_set_id IN ( + SELECT id FROM feature_sets + WHERE feature_set_type IN ('all', 'server-all') +); + +-- Delete legacy auto-created feature sets. We also nuke the per-client +-- "{client_name} - Custom" rows that find_or_create_client_custom_feature_set +-- seeded (they are conventionally named — exact pattern match). +DELETE FROM feature_set_members +WHERE feature_set_id IN ( + SELECT id FROM feature_sets WHERE feature_set_type IN ('all', 'server-all') +); + +DELETE FROM feature_sets +WHERE feature_set_type IN ('all', 'server-all') + OR (feature_set_type = 'custom' AND name LIKE '% - Custom'); + +-- Rebuild inbound_clients WITHOUT connection_mode + locked_space_id. +-- Mirror the 005 schema; just drop the two dead columns and the FK they had. +CREATE TABLE inbound_clients_new ( + client_id TEXT PRIMARY KEY, + + registration_type TEXT NOT NULL CHECK(registration_type IN ('cimd', 'dcr', 'preregistered')), + + client_name TEXT NOT NULL, + client_alias TEXT, + + logo_uri TEXT, + client_uri TEXT, + software_id TEXT, + software_version TEXT, + + redirect_uris TEXT NOT NULL, + grant_types TEXT NOT NULL, + response_types TEXT NOT NULL, + token_endpoint_auth_method TEXT NOT NULL, + scope TEXT, + + metadata_url TEXT, + metadata_cached_at TEXT, + metadata_cache_ttl INTEGER DEFAULT 3600, + + grants TEXT, + + approved INTEGER NOT NULL DEFAULT 0, + + last_seen TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +INSERT INTO inbound_clients_new ( + client_id, + registration_type, + client_name, client_alias, + logo_uri, client_uri, software_id, software_version, + redirect_uris, grant_types, response_types, token_endpoint_auth_method, scope, + metadata_url, metadata_cached_at, metadata_cache_ttl, + grants, + approved, + last_seen, created_at, updated_at +) +SELECT + client_id, + registration_type, + client_name, client_alias, + logo_uri, client_uri, software_id, software_version, + redirect_uris, grant_types, response_types, token_endpoint_auth_method, scope, + metadata_url, metadata_cached_at, metadata_cache_ttl, + grants, + approved, + last_seen, created_at, updated_at +FROM inbound_clients; + +DROP TABLE inbound_clients; +ALTER TABLE inbound_clients_new RENAME TO inbound_clients; + +CREATE INDEX IF NOT EXISTS idx_inbound_clients_type + ON inbound_clients(registration_type); +CREATE INDEX IF NOT EXISTS idx_inbound_clients_name + ON inbound_clients(client_name); +CREATE INDEX IF NOT EXISTS idx_inbound_clients_metadata_url + ON inbound_clients(metadata_url) WHERE metadata_url IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_inbound_clients_approved + ON inbound_clients(approved) WHERE approved = 1; + +PRAGMA foreign_keys = ON; diff --git a/crates/mcpmux-storage/src/migrations/007_concrete_binding.sql b/crates/mcpmux-storage/src/migrations/007_concrete_binding.sql new file mode 100644 index 00000000..3bfdba0a --- /dev/null +++ b/crates/mcpmux-storage/src/migrations/007_concrete_binding.sql @@ -0,0 +1,73 @@ +-- Migration 007: Collapse WorkspaceBinding + Space resolution to concrete pointers +-- +-- Bindings used to carry a matrix of modes: +-- space_mode = active | locked +-- locked_space_id (set when locked) +-- fs_mode = active_for_space | locked +-- locked_feature_set_id (set when locked) +-- And Space carried `active_feature_set_id` so Active-mode bindings could +-- follow whichever FS the user had promoted. +-- +-- That indirection didn't carry its weight — the only real use case is +-- "for root X, use space S + feature set F". This migration collapses +-- every binding to a concrete `(space_id, feature_set_id)` pair and drops +-- the Space's `active_feature_set_id` column. Bindings that can't be +-- concretely resolved (missing a locked target on either side) are dropped +-- — there's no sensible place to land them in the new model. +-- +-- SQLite note: PRAGMA foreign_keys can only be toggled outside a transaction +-- (the migration runner wraps each file in one), so we can't use the +-- table-rebuild pattern for `spaces` — a bare DROP would cascade through +-- feature_sets' ON DELETE CASCADE. Instead we DROP COLUMN directly, which +-- SQLite 3.35+ supports natively and doesn't touch dependent rows. + +-- --------------------------------------------------------------------------- +-- 1. Drop WorkspaceBindings that can't be concretely resolved, then rebuild +-- the table with just the concrete pointer columns. +-- --------------------------------------------------------------------------- + +-- Bindings with ActiveForSpace or Active modes can't be promoted into +-- (space_id, feature_set_id) without guessing — drop them. +DELETE FROM workspace_bindings +WHERE + space_mode <> 'locked' + OR fs_mode <> 'locked' + OR locked_space_id IS NULL + OR locked_feature_set_id IS NULL; + +-- Rebuild the table around the columns we actually keep. The delete above +-- means every remaining row has non-null locked_* columns; the copy below +-- promotes them to the new NOT NULL schema without relying on FK cascade. +CREATE TABLE workspace_bindings_new ( + id TEXT PRIMARY KEY, + workspace_root TEXT NOT NULL UNIQUE, + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, + feature_set_id TEXT NOT NULL REFERENCES feature_sets(id) ON DELETE CASCADE, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +INSERT INTO workspace_bindings_new + (id, workspace_root, space_id, feature_set_id, created_at, updated_at) +SELECT + id, + workspace_root, + locked_space_id, + locked_feature_set_id, + created_at, + updated_at +FROM workspace_bindings; + +DROP TABLE workspace_bindings; +ALTER TABLE workspace_bindings_new RENAME TO workspace_bindings; + +CREATE INDEX IF NOT EXISTS idx_workspace_bindings_space + ON workspace_bindings(space_id); +CREATE INDEX IF NOT EXISTS idx_workspace_bindings_fs + ON workspace_bindings(feature_set_id); + +-- --------------------------------------------------------------------------- +-- 2. Drop spaces.active_feature_set_id directly via ALTER — no rebuild. +-- --------------------------------------------------------------------------- + +ALTER TABLE spaces DROP COLUMN active_feature_set_id; diff --git a/crates/mcpmux-storage/src/migrations/008_canonical_default_space.sql b/crates/mcpmux-storage/src/migrations/008_canonical_default_space.sql new file mode 100644 index 00000000..03ed0191 --- /dev/null +++ b/crates/mcpmux-storage/src/migrations/008_canonical_default_space.sql @@ -0,0 +1,41 @@ +-- Migration 008: Repair the default-space invariant. +-- +-- Older code paths (and one bad branch in `SpaceAppService::create`) could +-- promote a user-created space to `is_default = 1` if it happened to be +-- created when no spaces existed. Combined with bare DB edits during early +-- testing, this left some installs with the wrong space marked default +-- (or worse, multiple defaults). The resolver picks "the" default space via +-- a query that returns whichever row SQLite hands back first, so symptoms +-- vary across machines. +-- +-- This migration enforces the invariant: the seeded "My Space" row +-- (id `00000000-0000-0000-0000-000000000001`) is the canonical default, +-- and no other row carries the flag. It's idempotent — running it on a +-- healthy DB is a no-op. + +-- Make sure the canonical row exists. The seed in migration 001 uses +-- `INSERT OR IGNORE`, so a freshly-installed DB already has it. This +-- safety net covers DBs that lost the row through manual editing. +INSERT OR IGNORE INTO spaces + (id, name, icon, description, is_default, sort_order, created_at, updated_at) +VALUES ( + '00000000-0000-0000-0000-000000000001', + 'My Space', + '🏠', + 'Default workspace for your MCP servers', + 1, + 0, + datetime('now'), + datetime('now') +); + +-- Sole-default invariant: clear the flag on every other row first, then +-- set it on the canonical row. Order matters — the inverse would briefly +-- leave the table with two defaults if the canonical row was already flagged. +UPDATE spaces + SET is_default = 0 + WHERE id <> '00000000-0000-0000-0000-000000000001'; + +UPDATE spaces + SET is_default = 1 + WHERE id = '00000000-0000-0000-0000-000000000001'; diff --git a/crates/mcpmux-storage/src/migrations/009_restore_client_grants.sql b/crates/mcpmux-storage/src/migrations/009_restore_client_grants.sql new file mode 100644 index 00000000..54e7f112 --- /dev/null +++ b/crates/mcpmux-storage/src/migrations/009_restore_client_grants.sql @@ -0,0 +1,29 @@ +-- Migration 009: Restore client_grants for rootless clients. +-- +-- Migration 003 dropped this table when the FeatureSetResolver was made +-- authoritative — but the resolver only handles roots-capable clients. +-- Clients that don't declare the MCP roots capability (Claude.ai web, +-- ChatGPT, …) need a per-OAuth-client default FeatureSet, which is what +-- this table stores. +-- +-- Resolution order (resolver v3): +-- 1. Session has roots + WorkspaceBinding matches → binding.fs +-- 2. Session has roots + no binding → deny + emit prompt +-- 3. Session has no roots, client roots-capable → empty (waiting on roots) +-- 4. Session has no roots, client rootless → client_grants for (client, space) +-- 5. Otherwise → deny +-- +-- Schema mirrors migration 001's pre-003 definition. + +CREATE TABLE IF NOT EXISTS client_grants ( + client_id TEXT NOT NULL, -- References inbound_clients.client_id + space_id TEXT NOT NULL, + feature_set_id TEXT NOT NULL, + PRIMARY KEY (client_id, space_id, feature_set_id), + FOREIGN KEY (client_id) REFERENCES inbound_clients(client_id) ON DELETE CASCADE, + FOREIGN KEY (space_id) REFERENCES spaces(id) ON DELETE CASCADE, + FOREIGN KEY (feature_set_id) REFERENCES feature_sets(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_client_grants_client ON client_grants(client_id); +CREATE INDEX IF NOT EXISTS idx_client_grants_space ON client_grants(space_id); diff --git a/crates/mcpmux-storage/src/migrations/010_inbound_client_reports_roots.sql b/crates/mcpmux-storage/src/migrations/010_inbound_client_reports_roots.sql new file mode 100644 index 00000000..edae4c17 --- /dev/null +++ b/crates/mcpmux-storage/src/migrations/010_inbound_client_reports_roots.sql @@ -0,0 +1,15 @@ +-- Migration 010: Track whether each OAuth client has been seen reporting +-- the MCP `roots` capability. +-- +-- The flag is stamped once per session by the gateway handler during +-- `on_initialized`. The Clients UI uses it to show a "Reports workspace" +-- vs "Rootless" badge, which in turn tells the user whether the per-client +-- grant editor on that client matters (it only does for rootless clients). +-- +-- Default = 0 (unknown / not seen). The flag is monotonic — once a client +-- is observed reporting roots we keep the bit set, even if a later session +-- doesn't (ChatGPT-style connectors may flip per session). Users who want +-- to reset the bit can revoke + re-approve the client. + +ALTER TABLE inbound_clients + ADD COLUMN reports_roots INTEGER NOT NULL DEFAULT 0; diff --git a/crates/mcpmux-storage/src/migrations/011_inbound_client_roots_capability_known.sql b/crates/mcpmux-storage/src/migrations/011_inbound_client_roots_capability_known.sql new file mode 100644 index 00000000..bd91db49 --- /dev/null +++ b/crates/mcpmux-storage/src/migrations/011_inbound_client_roots_capability_known.sql @@ -0,0 +1,25 @@ +-- Migration 011: Distinguish "we haven't seen this client initialize yet" +-- from "this client explicitly does NOT support MCP roots". +-- +-- Migration 010 added `reports_roots` defaulting to 0. The Clients UI +-- treated the column as a 2-state — but a brand-new approved client that +-- has never opened a session looks identical to a known-rootless client. +-- This migration adds an explicit "known" flag so the UI can render three +-- states: unknown (no badge), reports-workspace, rootless. +-- +-- `roots_capability_known` flips to 1 the first time the gateway processes +-- `notifications/initialized` for a session of this client. After that the +-- value is sticky. `reports_roots` remains sticky-positive: once we've +-- seen *any* session declare the capability, we treat the whole client as +-- roots-capable so a one-off rootless reconnect doesn't bounce the badge. + +ALTER TABLE inbound_clients + ADD COLUMN roots_capability_known INTEGER NOT NULL DEFAULT 0; + +-- Backfill: any row that already has reports_roots = 1 must have been +-- observed at least once, so seed it as "known". Rows with reports_roots = 0 +-- stay at "unknown" — they may legitimately be either case until we see +-- their next initialize. +UPDATE inbound_clients + SET roots_capability_known = 1 + WHERE reports_roots = 1; diff --git a/crates/mcpmux-storage/src/migrations/012_workspace_binding_feature_sets.sql b/crates/mcpmux-storage/src/migrations/012_workspace_binding_feature_sets.sql new file mode 100644 index 00000000..7dfea4b7 --- /dev/null +++ b/crates/mcpmux-storage/src/migrations/012_workspace_binding_feature_sets.sql @@ -0,0 +1,55 @@ +-- Migration 012: Multi-FS workspace bindings. +-- +-- One workspace root can now route into N FeatureSets (composed at the +-- resolver into a single allow set). Up until now each binding owned a +-- single `feature_set_id` column on `workspace_bindings`; this migration +-- moves to a junction table and recreates `workspace_bindings` without +-- the legacy column. +-- +-- Order: +-- 1. Create the junction. +-- 2. Backfill (binding_id, feature_set_id, sort_order=0) from each +-- current row. +-- 3. Recreate `workspace_bindings` without the column (the recreate-and- +-- copy pattern keeps us compatible with older SQLite that doesn't +-- support `ALTER TABLE … DROP COLUMN`). + +CREATE TABLE workspace_binding_feature_sets ( + binding_id TEXT NOT NULL REFERENCES workspace_bindings(id) ON DELETE CASCADE, + feature_set_id TEXT NOT NULL REFERENCES feature_sets(id) ON DELETE CASCADE, + -- Stable rendering order in the UI; resolver doesn't care about order + -- but the operator may want "primary" to render first. + sort_order INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (binding_id, feature_set_id) +); + +CREATE INDEX IF NOT EXISTS idx_wbfs_binding + ON workspace_binding_feature_sets(binding_id); + +-- Backfill — every existing binding has exactly one FS. +INSERT INTO workspace_binding_feature_sets (binding_id, feature_set_id, sort_order) +SELECT id, feature_set_id, 0 +FROM workspace_bindings; + +-- Recreate `workspace_bindings` without the legacy column. +CREATE TABLE workspace_bindings_new ( + id TEXT PRIMARY KEY, + workspace_root TEXT NOT NULL UNIQUE, + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +INSERT INTO workspace_bindings_new + (id, workspace_root, space_id, created_at, updated_at) +SELECT + id, workspace_root, space_id, created_at, updated_at +FROM workspace_bindings; + +DROP TABLE workspace_bindings; +ALTER TABLE workspace_bindings_new RENAME TO workspace_bindings; + +CREATE INDEX IF NOT EXISTS idx_workspace_bindings_root + ON workspace_bindings(workspace_root); +CREATE INDEX IF NOT EXISTS idx_workspace_bindings_space + ON workspace_bindings(space_id); diff --git a/crates/mcpmux-storage/src/migrations/013_rename_default_to_starter.sql b/crates/mcpmux-storage/src/migrations/013_rename_default_to_starter.sql new file mode 100644 index 00000000..811275bc --- /dev/null +++ b/crates/mcpmux-storage/src/migrations/013_rename_default_to_starter.sql @@ -0,0 +1,18 @@ +-- Migration 013: Rename FeatureSetType `default` → `starter`. +-- +-- The "Default" name dates back to when the resolver fell back to the +-- per-Space Default FS for any unbound session. Post-resolver-v3 nothing +-- routes there automatically — the type is just a flag for "this FS got +-- auto-seeded with the Space, you can edit/rename/delete it freely." +-- "Starter" matches that role honestly. +-- +-- Idempotent: running on a fresh DB seeded with the new value is a no-op. +-- The stable id prefix `fs_default_` is intentionally NOT renamed: +-- those ids are foreign keys in `workspace_binding_feature_sets` and +-- `client_grants`, and rewriting them would cascade for no operator- +-- visible benefit. The on-disk id stays for FK integrity; only the +-- type *label* changes. + +UPDATE feature_sets + SET feature_set_type = 'starter' + WHERE feature_set_type = 'default'; diff --git a/crates/mcpmux-storage/src/migrations/014_rewrite_starter_seed_copy.sql b/crates/mcpmux-storage/src/migrations/014_rewrite_starter_seed_copy.sql new file mode 100644 index 00000000..b35a0098 --- /dev/null +++ b/crates/mcpmux-storage/src/migrations/014_rewrite_starter_seed_copy.sql @@ -0,0 +1,22 @@ +-- Migration 014: Rewrite the auto-seeded Starter FS's display copy. +-- +-- Migration 001 hard-coded `name = 'Default'` and +-- `description = 'The fallback feature set for this space'` on every +-- auto-seeded FS row. Both lie under the new resolver — nothing routes +-- to this FS automatically anymore. Migration 013 fixed the *type* but +-- couldn't re-run on DBs that had already recorded it as applied, so +-- the human-readable copy stayed wrong on those installs. This migration +-- rewrites the copy. +-- +-- Safety: only updates rows that *still* match the exact seeded values. +-- An operator who renamed their auto-FS to anything else keeps their +-- custom name + description untouched. The `is_builtin = 1` filter +-- prevents collisions with a user-created FS that happens to be named +-- "Default". + +UPDATE feature_sets + SET name = 'Starter', + description = 'Auto-created with this Space. Edit, rename, or delete freely — bindings and per-client grants pick FeatureSets explicitly, so this one has no special routing role.' + WHERE is_builtin = 1 + AND name = 'Default' + AND description = 'The fallback feature set for this space'; diff --git a/crates/mcpmux-storage/src/migrations/015_rewrite_starter_seed_copy_v2.sql b/crates/mcpmux-storage/src/migrations/015_rewrite_starter_seed_copy_v2.sql new file mode 100644 index 00000000..c5c9b7b1 --- /dev/null +++ b/crates/mcpmux-storage/src/migrations/015_rewrite_starter_seed_copy_v2.sql @@ -0,0 +1,26 @@ +-- Migration 015: catch the *other* legacy seed copy that 014 missed. +-- +-- Migration 001 (still shipped, can't edit retroactively) seeds the +-- default Space's auto-Starter row with: +-- name = 'Default' +-- description = 'Features automatically granted to all connected clients in this space' +-- +-- Migration 014 only rewrote rows whose description was the OTHER stale +-- variant ('The fallback feature set for this space'), set by +-- space_repository.rs::create() at one point in history. So the +-- migration-001-seeded row on every existing install survived 014 +-- unchanged, and the Clients UI still shows "Features automatically +-- granted to all connected clients in this space" — which is the most +-- misleading of the lot under resolver v3 (literally the opposite of +-- the truth). +-- +-- Same safety guard as 014: only rewrite rows that still match the +-- exact stale seed values, so any operator who customized the copy +-- keeps their change. + +UPDATE feature_sets + SET name = 'Starter', + description = 'Auto-created with this Space. Edit, rename, or delete freely — bindings and per-client grants pick FeatureSets explicitly, so this one has no special routing role.' + WHERE is_builtin = 1 + AND name = 'Default' + AND description = 'Features automatically granted to all connected clients in this space'; 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 f7a51919..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, ], )?; @@ -298,88 +301,21 @@ impl FeatureSetRepository for SqliteFeatureSetRepository { Ok(()) } - async fn list_builtin(&self, space_id: &str) -> Result> { - let db = self.db.lock().await; - let conn = db.connection(); - - let mut stmt = conn.prepare( - "SELECT id, name, description, icon, space_id, feature_set_type, - server_id, is_builtin, is_deleted, created_at, updated_at - FROM feature_sets - WHERE space_id = ? AND is_builtin = 1 AND is_deleted = 0 - ORDER BY feature_set_type, name ASC", - )?; - - let feature_sets = stmt - .query_map(params![space_id], Self::row_to_feature_set)? - .collect::, _>>()?; - - Ok(feature_sets) - } - - async fn get_server_all(&self, space_id: &str, server_id: &str) -> Result> { + async fn get_starter_for_space(&self, space_id: &str) -> Result> { let db = self.db.lock().await; let conn = db.connection(); + // Match on `'starter' OR 'default'` so a freshly-migrated DB and a + // pre-013 read both resolve correctly; migration 013 itself + // rewrites stored rows so the legacy alias is dead weight quickly. let result = conn .query_row( - "SELECT id, name, description, icon, space_id, feature_set_type, - server_id, is_builtin, is_deleted, created_at, updated_at - FROM feature_sets - WHERE space_id = ? AND server_id = ? AND feature_set_type = 'server-all' AND is_deleted = 0", - params![space_id, server_id], - Self::row_to_feature_set, - ) - .optional()?; - - Ok(result) - } - - async fn ensure_server_all( - &self, - space_id: &str, - server_id: &str, - server_name: &str, - ) -> Result { - // Check if it already exists - if let Some(existing) = self.get_server_all(space_id, server_id).await? { - return Ok(existing); - } - - // Create new server-all featureset - let fs = FeatureSet::new_server_all(space_id, server_id, server_name); - self.create(&fs).await?; - Ok(fs) - } - - async fn get_default_for_space(&self, space_id: &str) -> Result> { - let db = self.db.lock().await; - let conn = db.connection(); - - let result = conn - .query_row( - "SELECT id, name, description, icon, space_id, feature_set_type, - server_id, is_builtin, is_deleted, created_at, updated_at - FROM feature_sets - WHERE space_id = ? AND feature_set_type = 'default' AND is_deleted = 0", - params![space_id], - Self::row_to_feature_set, - ) - .optional()?; - - Ok(result) - } - - async fn get_all_for_space(&self, space_id: &str) -> Result> { - let db = self.db.lock().await; - let conn = db.connection(); - - let result = conn - .query_row( - "SELECT id, name, description, icon, space_id, feature_set_type, - server_id, is_builtin, is_deleted, created_at, updated_at - FROM feature_sets - WHERE space_id = ? AND feature_set_type = 'all' AND is_deleted = 0", + "SELECT id, name, description, icon, space_id, feature_set_type, + server_id, is_builtin, is_deleted, created_at, updated_at + FROM feature_sets + WHERE space_id = ? + AND feature_set_type IN ('starter', 'default') + AND is_deleted = 0", params![space_id], Self::row_to_feature_set, ) @@ -388,34 +324,11 @@ impl FeatureSetRepository for SqliteFeatureSetRepository { Ok(result) } - async fn delete_server_all(&self, space_id: &str, server_id: &str) -> Result<()> { - let db = self.db.lock().await; - let conn = db.connection(); - - // Hard delete server-all feature set for this server (used during uninstall) - // Unlike regular delete(), this allows deleting builtin server-all feature sets - conn.execute( - "DELETE FROM feature_sets - WHERE space_id = ? AND server_id = ? AND feature_set_type = 'server-all'", - params![space_id, server_id], - )?; - - Ok(()) - } - async fn ensure_builtin_for_space(&self, space_id: &str) -> Result<()> { - // Check if "All" exists - if self.get_all_for_space(space_id).await?.is_none() { - let all = FeatureSet::new_all(space_id); - self.create(&all).await?; + if self.get_starter_for_space(space_id).await?.is_none() { + let starter = FeatureSet::new_starter(space_id); + self.create(&starter).await?; } - - // Check if "Default" exists - if self.get_default_for_space(space_id).await?.is_none() { - let default = FeatureSet::new_default(space_id); - self.create(&default).await?; - } - Ok(()) } @@ -435,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(), ], )?; @@ -473,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", @@ -510,9 +425,9 @@ mod tests { let found = found.unwrap(); assert_eq!(found.name, "My Custom Set"); - // List by space (migration creates 2 builtin + our 1 custom = 3) + // List by space: migration seeds 1 builtin (Default) + our 1 custom = 2. let all = repo.list_by_space(DEFAULT_SPACE_ID).await.unwrap(); - assert_eq!(all.len(), 3); + assert_eq!(all.len(), 2); // Delete repo.delete(&fs.id).await.unwrap(); @@ -521,41 +436,42 @@ mod tests { } #[tokio::test] - async fn test_builtin_feature_sets() { + async fn test_starter_feature_set_seeded_for_default_space() { let db = Arc::new(Mutex::new(Database::open_in_memory().unwrap())); let repo = SqliteFeatureSetRepository::new(db); - // Migration already creates builtin feature sets for default space - let builtin = repo.list_builtin(DEFAULT_SPACE_ID).await.unwrap(); - assert_eq!(builtin.len(), 2); + // Migration 001 seeds the auto-Starter FS for the migration- + // created default Space; migration 013 renames its type from + // 'default' to 'starter'. Confirm it's present and blocked from + // deletion (builtins aren't user-deletable). + let starter = repo + .get_starter_for_space(DEFAULT_SPACE_ID) + .await + .unwrap() + .expect("Starter FS should exist for the default space"); + assert_eq!(starter.feature_set_type, FeatureSetType::Starter); - // Cannot delete builtin - let all_fs = builtin - .iter() - .find(|f| f.feature_set_type == FeatureSetType::All) - .unwrap(); - let result = repo.delete(&all_fs.id).await; - assert!(result.is_err()); + let result = repo.delete(&starter.id).await; + assert!(result.is_err(), "builtin Starter FS must not be deletable"); } #[tokio::test] - async fn test_server_all_featureset() { + async fn test_ensure_builtin_is_idempotent() { let db = Arc::new(Mutex::new(Database::open_in_memory().unwrap())); let repo = SqliteFeatureSetRepository::new(db); - // Ensure creates new (use default space from migration) - let fs = repo - .ensure_server_all(DEFAULT_SPACE_ID, "github-mcp", "GitHub") + repo.ensure_builtin_for_space(DEFAULT_SPACE_ID) .await .unwrap(); - assert_eq!(fs.feature_set_type, FeatureSetType::ServerAll); - assert_eq!(fs.server_id, Some("github-mcp".to_string())); - - // Ensure returns existing - let fs2 = repo - .ensure_server_all(DEFAULT_SPACE_ID, "github-mcp", "GitHub") + repo.ensure_builtin_for_space(DEFAULT_SPACE_ID) .await .unwrap(); - assert_eq!(fs.id, fs2.id); + + let by_space = repo.list_by_space(DEFAULT_SPACE_ID).await.unwrap(); + let starters = by_space + .iter() + .filter(|f| matches!(f.feature_set_type, FeatureSetType::Starter)) + .count(); + assert_eq!(starters, 1); } } diff --git a/crates/mcpmux-storage/src/repositories/inbound_client_repository.rs b/crates/mcpmux-storage/src/repositories/inbound_client_repository.rs index 9e4cf45d..44676fc6 100644 --- a/crates/mcpmux-storage/src/repositories/inbound_client_repository.rs +++ b/crates/mcpmux-storage/src/repositories/inbound_client_repository.rs @@ -87,12 +87,26 @@ pub struct InboundClient { pub metadata_cached_at: Option, // When we last fetched pub metadata_cache_ttl: Option, // Cache duration in seconds - // MCP client preferences - pub connection_mode: String, // 'follow_active', 'locked', 'ask_on_change' - pub locked_space_id: Option, pub last_seen: Option, pub created_at: String, pub updated_at: String, + + /// `true` once the gateway has observed this client declare the MCP + /// `roots` capability on `initialize`. Sticky-positive — a roots-capable + /// client that opens a one-off rootless session keeps the flag set so + /// the UI doesn't bounce. Reset by re-approving the client. + /// + /// Meaningful only when [`Self::roots_capability_known`] is `true`; for + /// `roots_capability_known = false` the value is undefined and the UI + /// treats it as "unknown". + pub reports_roots: bool, + + /// `true` once we've processed `notifications/initialized` for *any* + /// session of this client and so know whether `reports_roots` reflects + /// a real declaration. Defaults to `false` for newly-approved clients + /// that haven't opened a session yet — the UI hides the capability + /// badge in that state instead of misleadingly showing "Rootless". + pub roots_capability_known: bool, } /// Authorization code (pending exchange) @@ -161,20 +175,15 @@ impl InboundClientRepository { // Private Helper: Row Mapping (DRY) // ========================================================================= - /// Map a SQL row to InboundClient - /// - /// Expects columns in this exact order (as returned by our queries): - /// 0: client_id, 1: registration_type, 2: client_name, 3: client_alias, - /// 4: logo_uri, 5: client_uri, 6: software_id, 7: software_version, - /// 8: redirect_uris, 9: grant_types, 10: response_types, 11: token_endpoint_auth_method, 12: scope, - /// 13: metadata_url, 14: metadata_cached_at, 15: metadata_cache_ttl, - /// 16: connection_mode, 17: locked_space_id, 18: last_seen, 19: created_at, 20: updated_at, 21: approved + /// Map a SQL row to InboundClient. Column order must match `CLIENT_COLUMNS`. fn map_row_to_client(row: &rusqlite::Row) -> rusqlite::Result { let registration_type_str: String = row.get(1)?; let redirect_uris_json: Option = row.get(8)?; let grant_types_json: Option = row.get(9)?; let response_types_json: Option = row.get(10)?; - let approved_int: i32 = row.get::<_, Option>(21)?.unwrap_or(0); + let approved_int: i32 = row.get::<_, Option>(19)?.unwrap_or(0); + let reports_roots_int: i32 = row.get::<_, Option>(20)?.unwrap_or(0); + let roots_capability_known_int: i32 = row.get::<_, Option>(21)?.unwrap_or(0); Ok(InboundClient { client_id: row.get(0)?, @@ -202,23 +211,22 @@ impl InboundClientRepository { metadata_url: row.get(13)?, metadata_cached_at: row.get(14)?, metadata_cache_ttl: row.get(15)?, - connection_mode: row - .get::<_, Option>(16)? - .unwrap_or_else(|| "follow_active".to_string()), - locked_space_id: row.get(17)?, - last_seen: row.get(18)?, - created_at: row.get(19)?, - updated_at: row.get(20)?, + last_seen: row.get(16)?, + created_at: row.get(17)?, + updated_at: row.get(18)?, approved: approved_int != 0, + reports_roots: reports_roots_int != 0, + roots_capability_known: roots_capability_known_int != 0, }) } - /// Standard column selection for InboundClient queries + /// Standard column selection for InboundClient queries. + /// Order must match `map_row_to_client`. const CLIENT_COLUMNS: &'static str = "client_id, registration_type, client_name, client_alias, logo_uri, client_uri, software_id, software_version, redirect_uris, grant_types, response_types, token_endpoint_auth_method, scope, metadata_url, metadata_cached_at, metadata_cache_ttl, - connection_mode, locked_space_id, last_seen, created_at, updated_at, approved"; + last_seen, created_at, updated_at, approved, reports_roots, roots_capability_known"; // ========================================================================= // Client Operations (unified inbound_clients table) @@ -234,18 +242,16 @@ impl InboundClientRepository { logo_uri, client_uri, software_id, software_version, redirect_uris, grant_types, response_types, token_endpoint_auth_method, scope, metadata_url, metadata_cached_at, metadata_cache_ttl, - connection_mode, locked_space_id, last_seen, created_at, updated_at, approved ) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20) ON CONFLICT(client_id) DO UPDATE SET registration_type = ?2, client_name = ?3, client_alias = ?4, logo_uri = ?5, client_uri = ?6, software_id = ?7, software_version = ?8, redirect_uris = ?9, grant_types = ?10, response_types = ?11, token_endpoint_auth_method = ?12, scope = ?13, metadata_url = ?14, metadata_cached_at = ?15, metadata_cache_ttl = ?16, - connection_mode = ?17, locked_space_id = ?18, - last_seen = ?19, updated_at = ?21, approved = ?22", + last_seen = ?17, updated_at = ?19, approved = ?20", params![ client.client_id, client.registration_type.as_str(), @@ -263,8 +269,6 @@ impl InboundClientRepository { client.metadata_url, client.metadata_cached_at, client.metadata_cache_ttl, - client.connection_mode, - client.locked_space_id, client.last_seen, client.created_at, client.updated_at, @@ -317,7 +321,10 @@ impl InboundClientRepository { } } - /// Validate redirect URI for a client + /// Strict byte-equal membership check of a redirect URI in the client's + /// registered list. This is a low-level DB lookup; for OAuth policy + /// decisions (including RFC 8252 §7.3 loopback-port flexibility) use + /// `mcpmux_gateway::oauth::is_redirect_uri_allowed` instead. pub async fn validate_redirect_uri(&self, client_id: &str, redirect_uri: &str) -> Result { if let Some(client) = self.get_client(client_id).await? { Ok(client.redirect_uris.iter().any(|uri| uri == redirect_uri)) @@ -420,59 +427,22 @@ impl InboundClientRepository { Ok(merged_uris) } - /// Update client configuration settings - pub async fn update_client_settings( + /// Update a client's human-facing alias. + pub async fn update_client_alias( &self, client_id: &str, client_alias: Option, - connection_mode: Option, - locked_space_id: Option>, // None = don't change, Some(None) = clear, Some(Some(x)) = set ) -> Result> { let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); - - // Update timestamp { let db = self.db.lock().await; let conn = db.connection(); conn.execute( - "UPDATE inbound_clients SET updated_at = ?1 WHERE client_id = ?2", - params![now, client_id], - )?; - } - - // Update alias if provided - if let Some(alias) = &client_alias { - let db = self.db.lock().await; - let conn = db.connection(); - conn.execute( - "UPDATE inbound_clients SET client_alias = ?1 WHERE client_id = ?2", - params![alias, client_id], + "UPDATE inbound_clients SET client_alias = ?1, updated_at = ?2 WHERE client_id = ?3", + params![client_alias, now, client_id], )?; } - - // Update connection mode if provided - if let Some(mode) = &connection_mode { - let db = self.db.lock().await; - let conn = db.connection(); - conn.execute( - "UPDATE inbound_clients SET connection_mode = ?1 WHERE client_id = ?2", - params![mode, client_id], - )?; - } - - // Update locked_space_id if provided - if let Some(space_id) = &locked_space_id { - let db = self.db.lock().await; - let conn = db.connection(); - conn.execute( - "UPDATE inbound_clients SET locked_space_id = ?1 WHERE client_id = ?2", - params![space_id, client_id], - )?; - } - - debug!("[OAuth] Updated settings for client: {}", client_id); - - // Return updated client + debug!("[OAuth] Updated alias for client: {}", client_id); self.get_client(client_id).await } @@ -731,10 +701,15 @@ impl InboundClientRepository { } // ========================================================================= - // Client Grants (Feature Set Permissions) + // Client Grants (Feature Set Permissions for rootless OAuth clients) + // + // Consulted by FeatureSetResolverService when a session belongs to a + // client that did not declare the MCP `roots` capability (or has no + // workspace context). Roots-capable clients route through + // WorkspaceBinding instead — these methods are the rootless fallback. // ========================================================================= - /// Grant a feature set to a client in a specific space + /// Grant a feature set to a client in a specific space. pub async fn grant_feature_set( &self, client_id: &str, @@ -753,7 +728,7 @@ impl InboundClientRepository { Ok(()) } - /// Revoke a feature set from a client in a specific space + /// Revoke a feature set from a client in a specific space. pub async fn revoke_feature_set( &self, client_id: &str, @@ -764,7 +739,7 @@ impl InboundClientRepository { let conn = db.connection(); conn.execute( - "DELETE FROM client_grants + "DELETE FROM client_grants WHERE client_id = ?1 AND space_id = ?2 AND feature_set_id = ?3", params![client_id, space_id, feature_set_id], )?; @@ -772,7 +747,36 @@ impl InboundClientRepository { Ok(()) } - /// Get all grants for a client in a specific space + /// Record the MCP `roots` capability state for a client. + /// + /// Called from the gateway's `on_initialized` for *every* session, + /// regardless of whether the client declared the capability. After the + /// first call: + /// - `roots_capability_known` flips to 1 and stays there. + /// - `reports_roots` is sticky-positive: it goes 0 → 1 the first + /// session that declares roots, but a later session that doesn't + /// declare can't flip it back to 0. This prevents the UI badge + /// from bouncing on transient rootless reconnects from a normally + /// roots-capable client. + /// + /// Reset by re-approving the client (delete + re-DCR). + pub async fn mark_roots_capability(&self, client_id: &str, declares: bool) -> Result<()> { + let db = self.db.lock().await; + let conn = db.connection(); + // `MAX(reports_roots, ?2)` is the sticky-positive update — once 1, + // stays 1 even when `declares = false`. + conn.execute( + "UPDATE inbound_clients + SET roots_capability_known = 1, + reports_roots = MAX(reports_roots, ?2) + WHERE client_id = ?1", + params![client_id, declares as i32], + )?; + Ok(()) + } + + /// Get all granted feature_set_ids for a (client, space) pair. + /// Empty Vec means "no grant" → resolver returns Deny. pub async fn get_grants_for_space( &self, client_id: &str, @@ -782,7 +786,7 @@ impl InboundClientRepository { let conn = db.connection(); let mut stmt = conn.prepare( - "SELECT feature_set_id FROM client_grants + "SELECT feature_set_id FROM client_grants WHERE client_id = ?1 AND space_id = ?2", )?; @@ -793,7 +797,8 @@ impl InboundClientRepository { Ok(grants) } - /// Get all grants for a client across all spaces + /// Get every grant for a client across all spaces, grouped by space_id. + /// Used by the Clients UI to render the full permission picture. pub async fn get_all_grants( &self, client_id: &str, @@ -802,7 +807,7 @@ impl InboundClientRepository { let conn = db.connection(); let mut stmt = conn.prepare( - "SELECT space_id, feature_set_id FROM client_grants + "SELECT space_id, feature_set_id FROM client_grants WHERE client_id = ?1 ORDER BY space_id", )?; @@ -821,8 +826,6 @@ impl InboundClientRepository { Ok(grants) } - - // ========================================================================= } #[cfg(test)] diff --git a/crates/mcpmux-storage/src/repositories/inbound_mcp_client_repository.rs b/crates/mcpmux-storage/src/repositories/inbound_mcp_client_repository.rs index 5072ac5b..123759d1 100644 --- a/crates/mcpmux-storage/src/repositories/inbound_mcp_client_repository.rs +++ b/crates/mcpmux-storage/src/repositories/inbound_mcp_client_repository.rs @@ -1,15 +1,15 @@ //! SQLite implementation of InboundMcpClientRepository. //! -//! Manages MCP client entities (apps connecting TO McpMux). -//! Works with the unified `inbound_clients` table. +//! Identity-only persistence for approved MCP clients. Per-client grants and +//! connection modes have been removed — routing is driven by WorkspaceBinding +//! + each Space's Default feature set (see FeatureSetResolverService). -use std::collections::HashMap; use std::sync::Arc; use anyhow::Result; use async_trait::async_trait; use chrono::{DateTime, Utc}; -use mcpmux_core::{Client, ConnectionMode, InboundMcpClientRepository}; +use mcpmux_core::{Client, InboundMcpClientRepository}; use rusqlite::{params, OptionalExtension}; use tokio::sync::Mutex; use uuid::Uuid; @@ -18,8 +18,9 @@ use crate::Database; /// SQLite-backed implementation of InboundMcpClientRepository. /// -/// Works with the unified `inbound_clients` table which stores both -/// OAuth registration data and MCP client preferences. +/// Reads identity columns from the unified `inbound_clients` table. OAuth +/// fields (registrations, tokens, etc.) live alongside but are managed +/// through `InboundClientRepository` (the OAuth-oriented helper). pub struct SqliteInboundMcpClientRepository { db: Arc>, } @@ -32,11 +33,9 @@ impl SqliteInboundMcpClientRepository { /// Parse a datetime string to DateTime. fn parse_datetime(s: &str) -> DateTime { - // Try RFC3339 first if let Ok(dt) = DateTime::parse_from_rfc3339(s) { return dt.with_timezone(&Utc); } - // Try SQLite datetime format if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") { return dt.and_utc(); } @@ -48,49 +47,23 @@ impl SqliteInboundMcpClientRepository { s.as_ref().map(|s| Self::parse_datetime(s)) } - /// Parse connection mode from string. - fn parse_connection_mode(mode_str: &str, locked_space_id: &Option) -> ConnectionMode { - match mode_str { - "locked" => { - if let Some(space_id_str) = locked_space_id { - if let Ok(space_id) = space_id_str.parse() { - return ConnectionMode::Locked { space_id }; - } - } - ConnectionMode::FollowActive - } - "ask_on_change" => { - // Simplified: don't load triggers from DB yet - ConnectionMode::AskOnChange { triggers: vec![] } - } - _ => ConnectionMode::FollowActive, - } - } - - /// Convert connection mode to storage strings. - fn connection_mode_to_strings(mode: &ConnectionMode) -> (&'static str, Option) { - match mode { - ConnectionMode::Locked { space_id } => ("locked", Some(space_id.to_string())), - ConnectionMode::FollowActive => ("follow_active", None), - ConnectionMode::AskOnChange { .. } => ("ask_on_change", None), - } - } - - /// Parse grants JSON to HashMap>. - fn parse_grants(json: &Option) -> HashMap> { - json.as_ref() - .and_then(|s| serde_json::from_str::>>(s).ok()) - .map(|m| { - m.into_iter() - .filter_map(|(k, v)| { - let key: Uuid = k.parse().ok()?; - let vals: Vec = - v.into_iter().filter_map(|s| s.parse().ok()).collect(); - Some((key, vals)) - }) - .collect() - }) - .unwrap_or_default() + /// Columns selected for every `Client` read. Order must match `map_row`. + const COLUMNS: &'static str = + "client_id, client_name, registration_type, last_seen, created_at, updated_at"; + + fn map_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(Client { + id: row + .get::<_, String>(0)? + .parse() + .unwrap_or_else(|_| Uuid::new_v4()), + name: row.get(1)?, + client_type: row.get(2)?, + access_key: None, + last_seen: Self::parse_optional_datetime(&row.get(3)?), + created_at: Self::parse_datetime(&row.get::<_, String>(4)?), + updated_at: Self::parse_datetime(&row.get::<_, String>(5)?), + }) } } @@ -100,36 +73,14 @@ impl InboundMcpClientRepository for SqliteInboundMcpClientRepository { let db = self.db.lock().await; let conn = db.connection(); - let mut stmt = conn.prepare( - "SELECT client_id, client_name, registration_type, logo_uri, connection_mode, locked_space_id, - '{}', last_seen, created_at, updated_at - FROM inbound_clients - ORDER BY client_name ASC", - )?; - + let sql = format!( + "SELECT {} FROM inbound_clients ORDER BY client_name ASC", + Self::COLUMNS + ); + let mut stmt = conn.prepare(&sql)?; let clients = stmt - .query_map([], |row| { - let grants_json: Option = row.get(6)?; // Empty grants JSON placeholder - Ok(Client { - id: row - .get::<_, String>(0)? - .parse() - .unwrap_or_else(|_| Uuid::new_v4()), - name: row.get(1)?, - client_type: row.get(2)?, - connection_mode: Self::parse_connection_mode( - &row.get::<_, String>(4)?, - &row.get(5)?, - ), - grants: Self::parse_grants(&grants_json), - access_key: None, // Never loaded from DB - last_seen: Self::parse_optional_datetime(&row.get(7)?), - created_at: Self::parse_datetime(&row.get::<_, String>(8)?), - updated_at: Self::parse_datetime(&row.get::<_, String>(9)?), - }) - })? + .query_map([], Self::map_row)? .collect::, _>>()?; - Ok(clients) } @@ -137,36 +88,14 @@ impl InboundMcpClientRepository for SqliteInboundMcpClientRepository { let db = self.db.lock().await; let conn = db.connection(); - let mut stmt = conn.prepare( - "SELECT client_id, client_name, registration_type, logo_uri, connection_mode, locked_space_id, - '{}', last_seen, created_at, updated_at - FROM inbound_clients - WHERE client_id = ?", - )?; - + let sql = format!( + "SELECT {} FROM inbound_clients WHERE client_id = ?", + Self::COLUMNS + ); + let mut stmt = conn.prepare(&sql)?; let client = stmt - .query_row(params![id.to_string()], |row| { - let grants_json: Option = row.get(6)?; // Empty grants JSON placeholder - Ok(Client { - id: row - .get::<_, String>(0)? - .parse() - .unwrap_or_else(|_| Uuid::new_v4()), - name: row.get(1)?, - client_type: row.get(2)?, - connection_mode: Self::parse_connection_mode( - &row.get::<_, String>(4)?, - &row.get(5)?, - ), - grants: Self::parse_grants(&grants_json), - access_key: None, - last_seen: Self::parse_optional_datetime(&row.get(7)?), - created_at: Self::parse_datetime(&row.get::<_, String>(8)?), - updated_at: Self::parse_datetime(&row.get::<_, String>(9)?), - }) - }) + .query_row(params![id.to_string()], Self::map_row) .optional()?; - Ok(client) } @@ -174,36 +103,14 @@ impl InboundMcpClientRepository for SqliteInboundMcpClientRepository { let db = self.db.lock().await; let conn = db.connection(); - let mut stmt = conn.prepare( - "SELECT id, name, client_type, logo_uri, connection_mode, locked_space_id, - grants, last_seen, created_at, updated_at - FROM inbound_clients - WHERE access_key_hash = ?", - )?; - + let sql = format!( + "SELECT {} FROM inbound_clients WHERE access_key_hash = ?", + Self::COLUMNS + ); + let mut stmt = conn.prepare(&sql)?; let client = stmt - .query_row(params![key_hash], |row| { - let grants_json: Option = row.get(6)?; - Ok(Client { - id: row - .get::<_, String>(0)? - .parse() - .unwrap_or_else(|_| Uuid::new_v4()), - name: row.get(1)?, - client_type: row.get(2)?, - connection_mode: Self::parse_connection_mode( - &row.get::<_, String>(4)?, - &row.get(5)?, - ), - grants: Self::parse_grants(&grants_json), - access_key: None, - last_seen: Self::parse_optional_datetime(&row.get(7)?), - created_at: Self::parse_datetime(&row.get::<_, String>(8)?), - updated_at: Self::parse_datetime(&row.get::<_, String>(9)?), - }) - }) + .query_row(params![key_hash], Self::map_row) .optional()?; - Ok(client) } @@ -211,29 +118,23 @@ impl InboundMcpClientRepository for SqliteInboundMcpClientRepository { let db = self.db.lock().await; let conn = db.connection(); - let (mode_str, locked_space_id) = Self::connection_mode_to_strings(&client.connection_mode); - conn.execute( "INSERT INTO inbound_clients ( - client_id, registration_type, client_name, logo_uri, - connection_mode, locked_space_id, last_seen, created_at, updated_at, + client_id, registration_type, client_name, last_seen, created_at, updated_at, redirect_uris, grant_types, response_types, token_endpoint_auth_method, scope - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)", + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", params![ client.id.to_string(), "preregistered", // Default registration type for MCP clients client.name, - None::, // logo_uri - mode_str, - locked_space_id, client.last_seen.map(|dt| dt.to_rfc3339()), client.created_at.to_rfc3339(), client.updated_at.to_rfc3339(), - "[]", // Empty redirect_uris array - "[]", // Empty grant_types array - "[]", // Empty response_types array - "none", // Default auth method - None::, // No scope + "[]", // redirect_uris + "[]", // grant_types + "[]", // response_types + "none", // token_endpoint_auth_method + None::, // scope ], )?; @@ -244,18 +145,13 @@ impl InboundMcpClientRepository for SqliteInboundMcpClientRepository { let db = self.db.lock().await; let conn = db.connection(); - let (mode_str, locked_space_id) = Self::connection_mode_to_strings(&client.connection_mode); - let rows_affected = conn.execute( - "UPDATE inbound_clients - SET client_name = ?2, connection_mode = ?3, locked_space_id = ?4, - last_seen = ?5, updated_at = ?6 + "UPDATE inbound_clients + SET client_name = ?2, last_seen = ?3, updated_at = ?4 WHERE client_id = ?1", params![ client.id.to_string(), client.name, - mode_str, - locked_space_id, client.last_seen.map(|dt| dt.to_rfc3339()), client.updated_at.to_rfc3339(), ], @@ -279,157 +175,27 @@ impl InboundMcpClientRepository for SqliteInboundMcpClientRepository { Ok(()) } - - async fn grant_feature_set( - &self, - client_id: &Uuid, - space_id: &str, - feature_set_id: &str, - ) -> Result<()> { - let db = self.db.lock().await; - let conn = db.connection(); - - conn.execute( - "INSERT OR IGNORE INTO client_grants (client_id, space_id, feature_set_id) - VALUES (?1, ?2, ?3)", - params![client_id.to_string(), space_id, feature_set_id], - )?; - - Ok(()) - } - - async fn revoke_feature_set( - &self, - client_id: &Uuid, - space_id: &str, - feature_set_id: &str, - ) -> Result<()> { - let db = self.db.lock().await; - let conn = db.connection(); - - conn.execute( - "DELETE FROM client_grants - WHERE client_id = ?1 AND space_id = ?2 AND feature_set_id = ?3", - params![client_id.to_string(), space_id, feature_set_id], - )?; - - Ok(()) - } - - async fn get_grants_for_space(&self, client_id: &Uuid, space_id: &str) -> Result> { - let db = self.db.lock().await; - let conn = db.connection(); - - let mut stmt = conn.prepare( - "SELECT feature_set_id FROM client_grants - WHERE client_id = ?1 AND space_id = ?2", - )?; - - let grants = stmt - .query_map(params![client_id.to_string(), space_id], |row| { - row.get::<_, String>(0) - })? - .collect::, _>>()?; - - Ok(grants) - } - - async fn get_all_grants( - &self, - client_id: &Uuid, - ) -> Result>> { - let db = self.db.lock().await; - let conn = db.connection(); - - let mut stmt = conn.prepare( - "SELECT space_id, feature_set_id FROM client_grants - WHERE client_id = ?1 - ORDER BY space_id", - )?; - - let mut grants: std::collections::HashMap> = - std::collections::HashMap::new(); - - let rows = stmt.query_map(params![client_id.to_string()], |row| { - Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) - })?; - - for row in rows { - let (space_id, feature_set_id) = row?; - grants.entry(space_id).or_default().push(feature_set_id); - } - - Ok(grants) - } - - async fn set_grants_for_space( - &self, - client_id: &Uuid, - space_id: &str, - feature_set_ids: &[String], - ) -> Result<()> { - let db = self.db.lock().await; - let conn = db.connection(); - - // Remove existing grants for this space - conn.execute( - "DELETE FROM client_grants WHERE client_id = ?1 AND space_id = ?2", - params![client_id.to_string(), space_id], - )?; - - // Insert new grants - for feature_set_id in feature_set_ids { - conn.execute( - "INSERT INTO client_grants (client_id, space_id, feature_set_id) - VALUES (?1, ?2, ?3)", - params![client_id.to_string(), space_id, feature_set_id], - )?; - } - - Ok(()) - } - - async fn has_grants_for_space(&self, client_id: &Uuid, space_id: &str) -> Result { - let db = self.db.lock().await; - let conn = db.connection(); - - let count: i32 = conn.query_row( - "SELECT COUNT(*) FROM client_grants - WHERE client_id = ?1 AND space_id = ?2", - params![client_id.to_string(), space_id], - |row| row.get(0), - )?; - - Ok(count > 0) - } } #[cfg(test)] mod tests { use super::*; - /// Default space ID created by migration - const DEFAULT_SPACE_ID: &str = "00000000-0000-0000-0000-000000000001"; - #[tokio::test] async fn test_crud_operations() { let db = Arc::new(Mutex::new(Database::open_in_memory().unwrap())); let repo = SqliteInboundMcpClientRepository::new(db); - // Create let client = Client::cursor(); repo.create(&client).await.unwrap(); - // Read let found = repo.get(&client.id).await.unwrap(); assert!(found.is_some()); assert_eq!(found.unwrap().name, "Cursor"); - // List let all = repo.list().await.unwrap(); assert_eq!(all.len(), 1); - // Update let mut updated = client.clone(); updated.name = "Cursor AI".to_string(); repo.update(&updated).await.unwrap(); @@ -437,38 +203,8 @@ mod tests { let found = repo.get(&client.id).await.unwrap().unwrap(); assert_eq!(found.name, "Cursor AI"); - // Delete repo.delete(&client.id).await.unwrap(); let found = repo.get(&client.id).await.unwrap(); assert!(found.is_none()); } - - #[tokio::test] - async fn test_connection_modes() { - let db = Arc::new(Mutex::new(Database::open_in_memory().unwrap())); - let repo = SqliteInboundMcpClientRepository::new(db); - - // Create with FollowActive - let client1 = Client::cursor(); - repo.create(&client1).await.unwrap(); - - let found = repo.get(&client1.id).await.unwrap().unwrap(); - assert!(matches!( - found.connection_mode, - ConnectionMode::FollowActive - )); - - // Create with Locked (use default space from migration for FK constraint) - let mut client2 = Client::vscode(); - let space_id = Uuid::parse_str(DEFAULT_SPACE_ID).unwrap(); - client2.connection_mode = ConnectionMode::Locked { space_id }; - repo.create(&client2).await.unwrap(); - - let found = repo.get(&client2.id).await.unwrap().unwrap(); - if let ConnectionMode::Locked { space_id: found_id } = found.connection_mode { - assert_eq!(found_id, space_id); - } else { - panic!("Expected Locked connection mode"); - } - } } diff --git a/crates/mcpmux-storage/src/repositories/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/mod.rs b/crates/mcpmux-storage/src/repositories/mod.rs index 725b6db6..eac1af51 100644 --- a/crates/mcpmux-storage/src/repositories/mod.rs +++ b/crates/mcpmux-storage/src/repositories/mod.rs @@ -9,6 +9,7 @@ mod installed_server_repository; mod outbound_oauth_client_repository; mod server_feature_repository; mod space_repository; +mod workspace_binding_repository; pub use app_settings_repository::SqliteAppSettingsRepository; pub use credential_repository::SqliteCredentialRepository; @@ -24,3 +25,4 @@ pub use server_feature_repository::{ FeatureType, ServerFeature, ServerFeatureRepository, SqliteServerFeatureRepository, }; pub use space_repository::SqliteSpaceRepository; +pub use workspace_binding_repository::SqliteWorkspaceBindingRepository; diff --git a/crates/mcpmux-storage/src/repositories/space_repository.rs b/crates/mcpmux-storage/src/repositories/space_repository.rs index e35eef96..d17c9060 100644 --- a/crates/mcpmux-storage/src/repositories/space_repository.rs +++ b/crates/mcpmux-storage/src/repositories/space_repository.rs @@ -26,19 +26,32 @@ impl SqliteSpaceRepository { /// Parse a datetime string to DateTime. /// Handles both RFC3339 format and SQLite's `datetime('now')` format. fn parse_datetime(s: &str) -> DateTime { - // Try RFC3339 first (e.g., "2024-01-01T00:00:00Z") if let Ok(dt) = DateTime::parse_from_rfc3339(s) { return dt.with_timezone(&Utc); } - - // Try SQLite's datetime format (e.g., "2024-01-01 00:00:00") if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") { return dt.and_utc(); } - - // Fallback to current time Utc::now() } + + /// Columns selected for every `Space` read. Order must match `map_row`. + const COLUMNS: &'static str = + "id, name, icon, description, is_default, sort_order, created_at, updated_at"; + + fn map_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let id_str: String = row.get(0)?; + Ok(Space { + id: id_str.parse().unwrap_or_else(|_| Uuid::new_v4()), + name: row.get(1)?, + icon: row.get(2)?, + description: row.get(3)?, + is_default: row.get::<_, i32>(4)? == 1, + sort_order: row.get(5)?, + created_at: Self::parse_datetime(&row.get::<_, String>(6)?), + updated_at: Self::parse_datetime(&row.get::<_, String>(7)?), + }) + } } #[async_trait] @@ -47,42 +60,15 @@ impl SpaceRepository for SqliteSpaceRepository { let db = self.db.lock().await; let conn = db.connection(); - tracing::debug!("[SpaceRepository::list] Querying spaces..."); - - let mut stmt = conn.prepare( - "SELECT id, name, icon, description, is_default, sort_order, created_at, updated_at - FROM spaces - ORDER BY sort_order ASC, name ASC", - )?; - + let sql = format!( + "SELECT {} FROM spaces ORDER BY sort_order ASC, name ASC", + Self::COLUMNS + ); + let mut stmt = conn.prepare(&sql)?; let spaces = stmt - .query_map([], |row| { - let id_str: String = row.get(0)?; - let name: String = row.get(1)?; - tracing::debug!("[SpaceRepository::list] Found space: {} ({})", name, id_str); - - Ok(Space { - id: id_str.parse().unwrap_or_else(|e| { - tracing::warn!( - "[SpaceRepository::list] Failed to parse UUID '{}': {}", - id_str, - e - ); - Uuid::new_v4() - }), - name, - icon: row.get(2)?, - description: row.get(3)?, - is_default: row.get::<_, i32>(4)? == 1, - sort_order: row.get(5)?, - created_at: Self::parse_datetime(&row.get::<_, String>(6)?), - updated_at: Self::parse_datetime(&row.get::<_, String>(7)?), - }) - })? + .query_map([], Self::map_row)? .collect::, _>>()?; - tracing::info!("[SpaceRepository::list] Returning {} spaces", spaces.len()); - Ok(spaces) } @@ -90,28 +76,10 @@ impl SpaceRepository for SqliteSpaceRepository { let db = self.db.lock().await; let conn = db.connection(); - let mut stmt = conn.prepare( - "SELECT id, name, icon, description, is_default, sort_order, created_at, updated_at - FROM spaces - WHERE id = ?", - )?; - + let sql = format!("SELECT {} FROM spaces WHERE id = ?", Self::COLUMNS); + let mut stmt = conn.prepare(&sql)?; let space = stmt - .query_row(params![id.to_string()], |row| { - Ok(Space { - id: row - .get::<_, String>(0)? - .parse() - .unwrap_or_else(|_| Uuid::new_v4()), - name: row.get(1)?, - icon: row.get(2)?, - description: row.get(3)?, - is_default: row.get::<_, i32>(4)? == 1, - sort_order: row.get(5)?, - created_at: Self::parse_datetime(&row.get::<_, String>(6)?), - updated_at: Self::parse_datetime(&row.get::<_, String>(7)?), - }) - }) + .query_row(params![id.to_string()], Self::map_row) .optional()?; Ok(space) @@ -138,22 +106,14 @@ impl SpaceRepository for SqliteSpaceRepository { ], )?; - // Auto-create builtin featuresets for this space - // "All Features" - contains all features from all servers in this space - conn.execute( - "INSERT OR IGNORE INTO feature_sets (id, name, description, icon, space_id, feature_set_type, is_builtin, created_at, updated_at) - VALUES (?1, 'All Features', 'All features from all connected MCP servers in this space', '🌐', ?2, 'all', 1, ?3, ?3)", - params![ - format!("fs_all_{}", space_id), - space_id, - now, - ], - )?; - - // "Default" - auto-granted to all clients in this space + // Auto-seed the builtin "Starter" FeatureSet for this Space — a + // ready-to-use starting point. The id prefix `fs_default_` + // is preserved for FK-stability across the rename (migration 013). + // No special routing role under resolver v3 — bindings and per- + // client grants pick FeatureSets explicitly. conn.execute( "INSERT OR IGNORE INTO feature_sets (id, name, description, icon, space_id, feature_set_type, is_builtin, created_at, updated_at) - VALUES (?1, 'Default', 'Features automatically granted to all connected clients in this space', '⭐', ?2, 'default', 1, ?3, ?3)", + VALUES (?1, 'Starter', 'Auto-created with this Space. Edit, rename, or delete freely — bindings and per-client grants pick FeatureSets explicitly, so this one has no special routing role.', '⭐', ?2, 'starter', 1, ?3, ?3)", params![ format!("fs_default_{}", space_id), space_id, @@ -169,7 +129,7 @@ impl SpaceRepository for SqliteSpaceRepository { let conn = db.connection(); let rows_affected = conn.execute( - "UPDATE spaces + "UPDATE spaces SET name = ?2, icon = ?3, description = ?4, is_default = ?5, sort_order = ?6, updated_at = ?7 WHERE id = ?1", params![ @@ -203,30 +163,12 @@ impl SpaceRepository for SqliteSpaceRepository { let db = self.db.lock().await; let conn = db.connection(); - let mut stmt = conn.prepare( - "SELECT id, name, icon, description, is_default, sort_order, created_at, updated_at - FROM spaces - WHERE is_default = 1 - LIMIT 1", - )?; - - let space = stmt - .query_row([], |row| { - let id_str: String = row.get(0)?; - let name: String = row.get(1)?; - - Ok(Space { - id: id_str.parse().unwrap_or_else(|_| Uuid::new_v4()), - name, - icon: row.get(2)?, - description: row.get(3)?, - is_default: true, - sort_order: row.get(5)?, - created_at: Self::parse_datetime(&row.get::<_, String>(6)?), - updated_at: Self::parse_datetime(&row.get::<_, String>(7)?), - }) - }) - .optional()?; + let sql = format!( + "SELECT {} FROM spaces WHERE is_default = 1 LIMIT 1", + Self::COLUMNS + ); + let mut stmt = conn.prepare(&sql)?; + let space = stmt.query_row([], Self::map_row).optional()?; Ok(space) } @@ -235,13 +177,8 @@ impl SpaceRepository for SqliteSpaceRepository { let db = self.db.lock().await; let conn = db.connection(); - // Use a transaction to ensure atomicity let tx = conn.unchecked_transaction()?; - - // Clear all defaults tx.execute("UPDATE spaces SET is_default = 0", [])?; - - // Set the new default let rows_affected = tx.execute( "UPDATE spaces SET is_default = 1 WHERE id = ?", params![id.to_string()], @@ -269,25 +206,20 @@ mod tests { let db = Arc::new(Mutex::new(Database::open_in_memory().unwrap())); let repo = SqliteSpaceRepository::new(db); - // Migration creates default space, so we start with 1 let initial = repo.list().await.unwrap(); assert_eq!(initial.len(), 1); assert_eq!(initial[0].name, "My Space"); - // Create let space = Space::new("Test Space").with_icon("🧪"); repo.create(&space).await.unwrap(); - // Read let found = repo.get(&space.id).await.unwrap(); assert!(found.is_some()); assert_eq!(found.unwrap().name, "Test Space"); - // List (default + new = 2) let all = repo.list().await.unwrap(); assert_eq!(all.len(), 2); - // Update let mut updated = space.clone(); updated.name = "Updated Space".to_string(); repo.update(&updated).await.unwrap(); @@ -295,7 +227,6 @@ mod tests { let found = repo.get(&space.id).await.unwrap().unwrap(); assert_eq!(found.name, "Updated Space"); - // Delete repo.delete(&space.id).await.unwrap(); let found = repo.get(&space.id).await.unwrap(); assert!(found.is_none()); @@ -306,22 +237,18 @@ mod tests { let db = Arc::new(Mutex::new(Database::open_in_memory().unwrap())); let repo = SqliteSpaceRepository::new(db); - // Migration creates "My Space" as default let default = repo.get_default().await.unwrap(); assert!(default.is_some()); assert_eq!(default.unwrap().name, "My Space"); - // Create a new space and set as default let space2 = Space::new("Space 2"); repo.create(&space2).await.unwrap(); - // Change default repo.set_default(&space2.id).await.unwrap(); let default = repo.get_default().await.unwrap(); assert!(default.is_some()); assert_eq!(default.unwrap().name, "Space 2"); - // Change back to original let default_uuid = Uuid::parse_str(DEFAULT_SPACE_ID).unwrap(); repo.set_default(&default_uuid).await.unwrap(); let default = repo.get_default().await.unwrap(); diff --git a/crates/mcpmux-storage/src/repositories/workspace_binding_repository.rs b/crates/mcpmux-storage/src/repositories/workspace_binding_repository.rs new file mode 100644 index 00000000..d03b3774 --- /dev/null +++ b/crates/mcpmux-storage/src/repositories/workspace_binding_repository.rs @@ -0,0 +1,480 @@ +//! SQLite implementation of [`WorkspaceBindingRepository`]. +//! +//! Schema after migration 012 (multi-FS bindings): +//! +//! ```text +//! workspace_bindings +//! id TEXT PK +//! workspace_root TEXT UNIQUE — routing key, globally unique +//! space_id TEXT NOT NULL — FK → spaces(id) +//! created_at TEXT NOT NULL +//! updated_at TEXT NOT NULL +//! +//! workspace_binding_feature_sets (junction) +//! binding_id TEXT NOT NULL — FK → workspace_bindings(id) +//! feature_set_id TEXT NOT NULL — FK → feature_sets(id) +//! sort_order INTEGER — UI render order; resolver-irrelevant +//! PK (binding_id, feature_set_id) +//! ``` +//! +//! Each binding owns ≥ 1 FeatureSet. The repository surfaces them as +//! `WorkspaceBinding.feature_set_ids` (sorted by `sort_order`) so callers +//! can stop reasoning about the join. +//! +//! Longest-prefix matching (used by the resolver) is done in-memory against +//! `list()` since a mcpmux DB is expected to hold O(tens) of bindings. + +use std::collections::HashMap; +use std::sync::Arc; + +use anyhow::Result; +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use mcpmux_core::{longest_prefix_match, WorkspaceBinding, WorkspaceBindingRepository}; +use rusqlite::params; +use tokio::sync::Mutex; +use uuid::Uuid; + +use crate::Database; + +pub struct SqliteWorkspaceBindingRepository { + db: Arc>, +} + +impl SqliteWorkspaceBindingRepository { + pub fn new(db: Arc>) -> Self { + Self { db } + } + + fn parse_datetime(s: &str) -> DateTime { + if let Ok(dt) = DateTime::parse_from_rfc3339(s) { + return dt.with_timezone(&Utc); + } + if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") { + return dt.and_utc(); + } + Utc::now() + } + + /// Map a row from `workspace_bindings` (columns in the order of + /// [`Self::SELECT_COLS`]) to a partially-populated [`WorkspaceBinding`] + /// — `feature_set_ids` is filled by the caller from the junction. + fn row_to_binding_no_fs(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let id_str: String = row.get(0)?; + let workspace_root: String = row.get(1)?; + let 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), + updated_at: Self::parse_datetime(&updated_at), + }) + } + + /// Bulk-load `(binding_id, feature_set_ids)` from the junction for the + /// given binding ids, ordered by `sort_order` then `feature_set_id` + /// (stable, so the UI doesn't shuffle). + fn load_fs_for_bindings( + conn: &rusqlite::Connection, + binding_ids: &[String], + ) -> rusqlite::Result>> { + if binding_ids.is_empty() { + return Ok(HashMap::new()); + } + + // Build a `(?, ?, …)` placeholder list — rusqlite has no native + // IN-array binding, so we expand manually. + let placeholders = std::iter::repeat_n("?", binding_ids.len()) + .collect::>() + .join(", "); + let sql = format!( + "SELECT binding_id, feature_set_id + FROM workspace_binding_feature_sets + WHERE binding_id IN ({placeholders}) + ORDER BY binding_id, sort_order, feature_set_id" + ); + let mut stmt = conn.prepare(&sql)?; + let params_dyn: Vec<&dyn rusqlite::ToSql> = binding_ids + .iter() + .map(|s| s as &dyn rusqlite::ToSql) + .collect(); + let rows = stmt.query_map(params_dyn.as_slice(), |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + })?; + + let mut grouped: HashMap> = HashMap::new(); + for row in rows { + let (binding_id, fs_id) = row?; + grouped.entry(binding_id).or_default().push(fs_id); + } + Ok(grouped) + } + + /// Replace the junction rows for `binding_id` with the supplied list, + /// preserving `sort_order` from the slice's index. Used by both + /// create() and update() so they share the write path. + fn rewrite_fs_for_binding( + conn: &rusqlite::Connection, + binding_id: &str, + feature_set_ids: &[String], + ) -> rusqlite::Result<()> { + conn.execute( + "DELETE FROM workspace_binding_feature_sets WHERE binding_id = ?1", + params![binding_id], + )?; + for (idx, fs_id) in feature_set_ids.iter().enumerate() { + conn.execute( + "INSERT INTO workspace_binding_feature_sets + (binding_id, feature_set_id, sort_order) + VALUES (?1, ?2, ?3)", + params![binding_id, fs_id, idx as i64], + )?; + } + Ok(()) + } + + const SELECT_COLS: &'static str = "id, workspace_root, 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); + /// `string_params` are bound to its placeholders in order. + /// + /// Owned `String` params keep this future `Send` — passing borrowed + /// `&dyn ToSql` slices breaks `async_trait`'s `Send` requirement + /// because `dyn ToSql` isn't `Sync`. + async fn fetch_bindings( + &self, + where_clause: &str, + string_params: Vec, + ) -> Result> { + let db = self.db.lock().await; + let conn = db.connection(); + let sql = format!( + "SELECT {} FROM workspace_bindings {} ORDER BY workspace_root", + Self::SELECT_COLS, + where_clause, + ); + let mut stmt = conn.prepare(&sql)?; + let params_dyn: Vec<&dyn rusqlite::ToSql> = string_params + .iter() + .map(|s| s as &dyn rusqlite::ToSql) + .collect(); + let mut bindings: Vec = stmt + .query_map(params_dyn.as_slice(), Self::row_to_binding_no_fs)? + .collect::, _>>()?; + + let ids: Vec = bindings.iter().map(|b| b.id.to_string()).collect(); + let mut fs_map = Self::load_fs_for_bindings(conn, &ids)?; + for binding in &mut bindings { + if let Some(fs_ids) = fs_map.remove(&binding.id.to_string()) { + binding.feature_set_ids = fs_ids; + } + } + Ok(bindings) + } +} + +#[async_trait] +impl WorkspaceBindingRepository for SqliteWorkspaceBindingRepository { + async fn list(&self) -> Result> { + self.fetch_bindings("", Vec::new()).await + } + + async fn list_for_space(&self, space_id: &Uuid) -> Result> { + self.fetch_bindings("WHERE space_id = ?", vec![space_id.to_string()]) + .await + } + + async fn get(&self, id: &Uuid) -> Result> { + let mut bindings = self + .fetch_bindings("WHERE id = ?", vec![id.to_string()]) + .await?; + Ok(bindings.pop()) + } + + async fn create(&self, binding: &WorkspaceBinding) -> Result<()> { + if binding.feature_set_ids.is_empty() { + anyhow::bail!( + "WorkspaceBinding {} must have at least one feature_set_id", + binding.id + ); + } + let db = self.db.lock().await; + let conn = db.connection(); + + conn.execute( + "INSERT INTO workspace_bindings + (id, workspace_root, 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(), + ], + )?; + Self::rewrite_fs_for_binding(conn, &binding.id.to_string(), &binding.feature_set_ids)?; + + Ok(()) + } + + async fn update(&self, binding: &WorkspaceBinding) -> Result<()> { + if binding.feature_set_ids.is_empty() { + anyhow::bail!( + "WorkspaceBinding {} must have at least one feature_set_id", + binding.id + ); + } + let db = self.db.lock().await; + let conn = db.connection(); + + let rows_affected = conn.execute( + "UPDATE workspace_bindings + SET workspace_root = ?2, 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(), + ], + )?; + + if rows_affected == 0 { + anyhow::bail!("WorkspaceBinding not found: {}", binding.id); + } + + // Rewrite the junction. ON DELETE CASCADE on the FK means a binding + // delete cleans up automatically, but for an update we have to do + // it manually — the user may have re-ordered or swapped FSes. + Self::rewrite_fs_for_binding(conn, &binding.id.to_string(), &binding.feature_set_ids)?; + + Ok(()) + } + + async fn delete(&self, id: &Uuid) -> Result<()> { + let db = self.db.lock().await; + let conn = db.connection(); + // Junction rows go away via ON DELETE CASCADE. + conn.execute( + "DELETE FROM workspace_bindings WHERE id = ?", + params![id.to_string()], + )?; + Ok(()) + } + + async fn find_longest_prefix_match( + &self, + // `space_id` is no longer used for lookup — routing is keyed on root + // alone and each binding already carries its target space. Kept in + // the signature for trait compatibility with callers that still hold + // onto a "caller's space" hint. + _space_id: &Uuid, + candidate_roots: &[String], + ) -> Result> { + if candidate_roots.is_empty() { + return Ok(None); + } + + let bindings = self.list().await?; + if bindings.is_empty() { + return Ok(None); + } + + let candidate_strings: Vec<&str> = + bindings.iter().map(|b| b.workspace_root.as_str()).collect(); + + let mut best: Option<&WorkspaceBinding> = None; + for root in candidate_roots { + if let Some(winner) = longest_prefix_match(root, candidate_strings.iter().copied()) { + let winning = bindings + .iter() + .find(|b| b.workspace_root == winner) + .expect("candidate came from bindings"); + if best + .map(|b| winning.workspace_root.len() > b.workspace_root.len()) + .unwrap_or(true) + { + best = Some(winning); + } + } + } + + Ok(best.cloned()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use mcpmux_core::FeatureSet; + + async fn fixture() -> (SqliteWorkspaceBindingRepository, Uuid, String) { + let db = Arc::new(Mutex::new(Database::open_in_memory().unwrap())); + let repo = SqliteWorkspaceBindingRepository::new(db.clone()); + let space_id = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(); + + // Seed a real FeatureSet so FK constraints are satisfied. + let fs = FeatureSet::new_custom("test", space_id.to_string()); + let fs_id = fs.id.clone(); + let now = Utc::now().to_rfc3339(); + { + let guard = db.lock().await; + guard + .connection() + .execute( + "INSERT INTO feature_sets (id, name, feature_set_type, space_id, is_builtin, created_at, updated_at) + VALUES (?1, 'test', 'custom', ?2, 0, ?3, ?3)", + params![fs.id, space_id.to_string(), now], + ) + .unwrap(); + } + (repo, space_id, fs_id) + } + + async fn add_fs(db: &Arc>, space_id: Uuid, name: &str) -> String { + let fs = FeatureSet::new_custom(name, space_id.to_string()); + let fs_id = fs.id.clone(); + let now = Utc::now().to_rfc3339(); + let guard = db.lock().await; + guard + .connection() + .execute( + "INSERT INTO feature_sets (id, name, feature_set_type, space_id, is_builtin, created_at, updated_at) + VALUES (?1, ?2, 'custom', ?3, 0, ?4, ?4)", + params![fs.id, name, space_id.to_string(), now], + ) + .unwrap(); + fs_id + } + + #[tokio::test] + async fn test_crud_round_trip() { + let (repo, space_id, fs_id) = fixture().await; + let root = if cfg!(windows) { "d:\\proj" } else { "/proj" }; + let binding = WorkspaceBinding::new(root, space_id, fs_id.clone()); + repo.create(&binding).await.unwrap(); + + let got = repo.get(&binding.id).await.unwrap().unwrap(); + assert_eq!(got.workspace_root, root); + assert_eq!(got.space_id, space_id); + assert_eq!(got.feature_set_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] + async fn test_multi_fs_round_trip() { + let (repo, space_id, fs_id1) = fixture().await; + // Need to construct a fresh DB-backed FS pair to satisfy the FK. + // Reach back into the same DB the repo was built around by going + // through a second `add_fs`. + let db = repo.db.clone(); + let fs_id2 = add_fs(&db, space_id, "second").await; + + let root = if cfg!(windows) { "d:\\multi" } else { "/multi" }; + let binding = + WorkspaceBinding::new_multi(root, space_id, vec![fs_id1.clone(), fs_id2.clone()]); + repo.create(&binding).await.unwrap(); + + let got = repo.get(&binding.id).await.unwrap().unwrap(); + // Insertion order preserved via sort_order. + assert_eq!(got.feature_set_ids, vec![fs_id1.clone(), fs_id2.clone()]); + + // Update — drop one, reorder. + let mut updated = got; + updated.feature_set_ids = vec![fs_id2.clone()]; + repo.update(&updated).await.unwrap(); + let after = repo.get(&binding.id).await.unwrap().unwrap(); + assert_eq!(after.feature_set_ids, vec![fs_id2]); + } + + #[tokio::test] + async fn test_create_rejects_empty_fs_list() { + let (repo, space_id, _) = fixture().await; + let root = if cfg!(windows) { "d:\\empty" } else { "/empty" }; + let binding = WorkspaceBinding::new_multi(root, space_id, vec![]); + let err = repo.create(&binding).await.unwrap_err(); + assert!(err.to_string().contains("at least one feature_set_id")); + } + + #[tokio::test] + async fn test_list_for_space_filters_by_pointer() { + let (repo, space_id, fs_id) = fixture().await; + let root = if cfg!(windows) { "d:\\proj" } else { "/proj" }; + repo.create(&WorkspaceBinding::new(root, space_id, fs_id)) + .await + .unwrap(); + + let hits = repo.list_for_space(&space_id).await.unwrap(); + assert_eq!(hits.len(), 1); + + let other = Uuid::new_v4(); + let hits_other = repo.list_for_space(&other).await.unwrap(); + assert!(hits_other.is_empty()); + } + + #[tokio::test] + async fn test_longest_prefix_match_picks_nested_root() { + let (repo, space_id, fs_id) = fixture().await; + let (outer, inner) = if cfg!(windows) { + ("d:\\work", "d:\\work\\proj") + } else { + ("/work", "/work/proj") + }; + repo.create(&WorkspaceBinding::new(outer, space_id, fs_id.clone())) + .await + .unwrap(); + let b_inner = WorkspaceBinding::new(inner, space_id, fs_id); + repo.create(&b_inner).await.unwrap(); + + let deep = if cfg!(windows) { + "d:\\work\\proj\\src" + } else { + "/work/proj/src" + }; + let hit = repo + .find_longest_prefix_match(&space_id, &[deep.to_string()]) + .await + .unwrap() + .expect("match"); + assert_eq!(hit.workspace_root, inner); + } +} 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/docs/planning/dynamic-mcp-toggle-meta-tools.md b/docs/planning/dynamic-mcp-toggle-meta-tools.md new file mode 100644 index 00000000..540080c6 --- /dev/null +++ b/docs/planning/dynamic-mcp-toggle-meta-tools.md @@ -0,0 +1,317 @@ +# Dynamic MCP Toggling via Meta Tools + +**Last Updated:** May 23, 2026 +**Status:** Feature complete on fork — session grants + structuredContent fixes verified; pending upstream PR merge +**Branch:** `feat/dynamic-mcp-toggle-meta-tools` +**Base branch:** `feat/workspace-root-routing` ([upstream PR #151](https://github.com/mcpmux/mcp-mux/pull/151)) +**Issue:** TBD — file after planning review +**Depends on:** [PR #151](https://github.com/mcpmux/mcp-mux/pull/151) merging or being consumed via fork (provides the `mcpmux_*` namespace, `MetaToolRegistry`, `ApprovalBroker`, `FeatureSetResolverService`, per-peer `list_changed`, `SessionRootsRegistry`) +**Unblocks:** [`jsg-tech-check` homelab MCP strategy](../../../jsg-tech-check/docs/setup/home-lab-overview.md#mcp-strategy--current-state) + +--- + +## Problem + +The Cursor / Claude Code pre-McpMux workflow gave a per-project escape valve: each `.cursor/mcp.json` declared a subset of servers, so the client only loaded the tools that mattered for that project. Token budget stayed proportional to the project's actual needs. + +Routing everything through McpMux collapses that signal. The gateway exposes one consolidated MCP endpoint; the client side sees a single `mcpmux` server entry that's either ON or OFF. All 35+ tools from every enabled backend land in the LLM context window the moment a session opens, regardless of what the project actually needs. + +PR #151 partly addresses this with persistent `WorkspaceBinding`s: bind `~/code/personal/set-times-app` to `{core, browser, design, db-personal}` and the gateway serves exactly those tools when Cursor opens that folder. That works for stable, known scopes. It does not work for: + +- **Discovery-driven work** — "use whichever MCPs you need for this task" with no pre-declared bundle. +- **One-off needs** — "I'm in `set-times-app` (which is bound to a bundle that excludes `firebase`) but I need `firebase` for the next 15 minutes." +- **Minimum-context defaults** — start a session with zero backend tools loaded, let the LLM pull in what it needs based on the manifest, drop tools when it's done. + +The user-facing ask, stated as the original request: + +> Instead of having the whole definitions of all my MCPs all the time, I'd just have 1 always-on tool that gives me a manifest and then mcp can be smart enough to turn itself on. + +PR #151 ships four meta tools (`mcpmux_list_all_tools`, `mcpmux_list_feature_sets`, `mcpmux_create_feature_set`, `mcpmux_bind_current_workspace`). They cover the manifest + persist-a-new-bundle path. They don't cover the ephemeral toggle path — there's no way to turn a backend server on for "just this session" without writing a binding to the DB. + +This doc extends the meta-tools surface with session-scoped enable/disable, plus a server-level (coarser than tool-level) `mcpmux_list_servers` manifest tool. The resolver gains a Tier 0 (`SessionOverride`) that composes additively over Tier 1's `WorkspaceBinding`. + +--- + +## Decisions + +| # | Decision | Choice | Rationale | +| - | -------- | ------ | --------- | +| 1 | Granularity | **Server-level** (`mcpmux_enable_server("github")`), not tool-level | Matches the user's mental model ("turn on github") and the existing `FeatureSetType::ServerAll`. Tool-level enable can be added later as a degenerate case if a real use case shows up. | +| 2 | Default scope | **Session** (default), with `scope: "workspace"` as an opt-in arg | Session is the low-risk default; ephemerality is the point. Workspace scope falls back to the existing `WorkspaceBinding` write path, reusing PR #151 plumbing. | +| 3 | Composition with bindings | **Additive over `WorkspaceBinding`**: `effective = (binding ∪ session_enabled) − session_disabled` | Lets users keep their stable per-project bundle AND opportunistically add a server for a single session. Subtractive disable lets them mute a noisy server temporarily without unbinding. | +| 4 | Override lifetime | **In-memory, dies with `mcp-session-id`** | Matches `SessionRootsRegistry` semantics introduced by PR #151. Restart of gateway or client = fresh start. No DB persistence; no migration. | +| 5 | Approval flow | Session enables auto-allow by default (configurable); workspace writes require approval (existing flow) | Session-scope is ephemeral and safer than persistent state. App setting `gateway.session_overrides_require_approval` (default `false`) lets paranoid users gate everything. | +| 6 | Audit | Every override emits a `DomainEvent::MetaToolInvoked` (existing path) | No new event variants; the audit log already renders meta-tool calls. The "decision" field gets `"session_override"` for auto-allowed session writes. | +| 7 | Manifest format | `mcpmux_list_servers` returns server roster with `{id, name, tool_count, status}` where status ∈ `enabled_via_binding \| enabled_via_session \| disabled_via_session \| inactive` | The LLM needs to see current state, not just availability — otherwise it can't reason about whether to call enable or just call the tool. | +| 8 | Tier-0 placement | New `SessionOverrideRegistry` consulted **inside** `FeatureService` materialization, not as a new resolver tier | Resolver already returns `(space, feature_set_ids)` cleanly. Layering at the materialization step keeps the resolver pure and concentrates the composition logic in one place (`FeatureService::get_tools_for_grants`). | + +--- + +## The Model + +### Override store + +Per-session, two server-id sets. Both empty = no overrides, default routing applies. + +```text +SessionOverrideRegistry { + enabled : DashMap>, + disabled: DashMap>, +} +``` + +GC mirrors `SessionRootsRegistry`: both maps drop on `MCPNotifier`'s session-reap pass. + +### Composition rule + +For a session resolving its effective server set: + +```text +1. (space, feature_set_ids) ← FeatureSetResolverService::resolve(...) +2. binding_servers ← FeatureService::servers_for(space, feature_set_ids) +3. session_on ← SessionOverrideRegistry.enabled[session_id] +4. session_off ← SessionOverrideRegistry.disabled[session_id] +5. effective ← (binding_servers ∪ session_on) − session_off +6. tools ← every Tool feature whose server_id ∈ effective AND is_available +``` + +`session_on` and `session_off` are honored even when the resolver returned `Deny` (no binding match) — the session-override path is how a roots-capable client opts into tools without a binding. Empty override sets + `Deny` from resolver = no tools (existing behavior). + +### Tool surface + +Three new tools added to `build_default_registry`: + +| Tool | Type | Approval (default) | Purpose | +| ---- | ---- | ------------------ | ------- | +| `mcpmux_list_servers` | read | none | Server-level manifest with status per server. Coarser than `mcpmux_list_all_tools`. | +| `mcpmux_enable_server` | write | session: auto-allow; workspace: approval | Adds `server_id` to session overrides (or writes a binding). | +| `mcpmux_disable_server` | write | session: auto-allow; workspace: approval | Adds `server_id` to session disable set (or removes from binding). | + +Each write fires `tools/list_changed` per-peer via the existing `MCPNotifier::notify_peer_lists_changed` path so the calling LLM's tool list refreshes mid-conversation. + +### What McpMux still stores + +| Item | Storage | Persistence | +| ---- | ------- | ----------- | +| Session overrides (enabled + disabled sets) | `SessionOverrideRegistry` (in-memory `DashMap`) | Process-lifetime; dies with session reap | +| Workspace-scope writes | `workspace_bindings` table (existing) | Persistent (no schema change) | +| Audit trail | `DomainEvent::MetaToolInvoked` (existing) | Persistent via existing audit log | +| `gateway.session_overrides_require_approval` setting | `app_settings` table (existing) | Persistent | + +--- + +## Architecture + +``` + ┌──────────────────────────────────────────┐ + │ FeatureService::get_tools_for_grants │ + │ (existing materialization chokepoint) │ + │ │ + │ binding_servers = resolver-derived │ + │ + session_enabled ← Tier 0 overrides │ + │ − session_disabled │ + └──────────────────────────────────────────┘ + ▲ + │ + ┌──────────────────────────┴──────────────────────────┐ + │ │ + ▼ ▼ +┌─────────────────────────┐ ┌──────────────────────────────┐ +│ FeatureSetResolverService│ │ SessionOverrideRegistry │ +│ (PR #151 — unchanged) │ │ (new) │ +│ │ │ │ +│ Tier 1: WorkspaceBinding │ │ enabled : DashMap │ +│ Tier 2: ClientGrant │ │ disabled: DashMap │ +│ Tier 3: Deny │ └──────────────────────────────┘ +└─────────────────────────┘ ▲ + │ + ┌────────────┴────────────┐ + │ Meta tool writes mutate │ + │ this registry directly. │ + │ │ + │ mcpmux_enable_server │ + │ mcpmux_disable_server │ + └─────────────────────────┘ +``` + +- `SessionOverrideRegistry` lives in `crates/mcpmux-gateway/src/services/`, sibling to `session_roots.rs`. Same `Arc` factory pattern, same GC contract. +- `FeatureService` is the only consumer that reads it. The resolver itself stays pure — no new tier, no new branch in `feature_set_resolver.rs`. +- Writes go through the existing `MetaToolRegistry` dispatch in `tools.rs` → `with_approval()` (session-scope short-circuits approval when the setting allows) → mutate the registry → emit `tools/list_changed` via the existing `emit_tools_list_changed` helper. + +--- + +## Files to create + +| File | Purpose | +| ---- | ------- | +| `crates/mcpmux-gateway/src/services/session_overrides.rs` | `SessionOverrideRegistry` — `DashMap`-backed enable/disable sets, GC hooks, query helpers (`is_enabled`, `is_disabled`, `effective_overlay`) | +| `tests/rust/tests/integration/meta_tools.rs` | Composition tests: deny bootstrap, disable, additive (Phase 1); meta-tool E2E (existing) | +| `docs/planning/dynamic-mcp-toggle-meta-tools.md` | This doc | + +## Files to modify + +| File | Change | +| ---- | ------ | +| [`crates/mcpmux-gateway/src/services/mod.rs`](../../crates/mcpmux-gateway/src/services/mod.rs) | `pub mod session_overrides;` + re-export `SessionOverrideRegistry` | +| [`crates/mcpmux-gateway/src/services/meta_tools/mod.rs`](../../crates/mcpmux-gateway/src/services/meta_tools/mod.rs) | Register `ListServersTool`, `EnableServerTool`, `DisableServerTool` in `build_default_registry`. Add `session_overrides: Arc` to `MetaToolContext`. | +| [`crates/mcpmux-gateway/src/services/meta_tools/registry.rs`](../../crates/mcpmux-gateway/src/services/meta_tools/registry.rs) | Extend `MetaToolContext` with `session_overrides`. Add `"session_override"` to the decision-string match in `MetaToolRegistry::call` so audit rows are distinguishable. | +| [`crates/mcpmux-gateway/src/services/meta_tools/tools.rs`](../../crates/mcpmux-gateway/src/services/meta_tools/tools.rs) | Implement `ListServersTool`, `EnableServerTool`, `DisableServerTool`. Session-scope short-circuits `with_approval` when `gateway.session_overrides_require_approval` is false. | +| [`crates/mcpmux-gateway/src/pool/features/facade.rs`](../../crates/mcpmux-gateway/src/pool/features/facade.rs) | `FeatureService::get_tools_for_grants` (and sibling `get_prompts_for_grants`, `get_resources_for_grants`) take `session_id: Option<&str>` and apply `SessionOverrideRegistry` composition before returning. | +| [`crates/mcpmux-gateway/src/mcp/handler.rs`](../../crates/mcpmux-gateway/src/mcp/handler.rs) | Pass `session_id` (already on `RequestContext`) into the new `get_*_for_grants` signatures. | +| [`crates/mcpmux-gateway/src/server/service_container.rs`](../../crates/mcpmux-gateway/src/server/service_container.rs) | Construct `Arc` once; wire into `MetaToolContext`, `FeatureService`, and the session-reap path in `MCPNotifier`. | +| [`crates/mcpmux-gateway/src/consumers/mcp_notifier.rs`](../../crates/mcpmux-gateway/src/consumers/mcp_notifier.rs) | In the session-reap pass, also call `SessionOverrideRegistry::remove(session_id)` alongside `SessionRootsRegistry::remove`. | +| [`crates/mcpmux-core/src/domain/event.rs`](../../crates/mcpmux-core/src/domain/event.rs) | No new variant — `MetaToolInvoked` already carries `decision: String`. Document `"session_override"` as a valid value in the doc comment. | +| [`apps/desktop/src/features/workspaces/WorkspacesPage.tsx`](../../apps/desktop/src/features/workspaces/WorkspacesPage.tsx) | New "Active session overrides" sub-panel under the live-session inspector: per-session list of enabled / disabled server_ids with a "clear" button. | +| [`apps/desktop/src-tauri/src/commands/session_overrides.rs`](../../apps/desktop/src-tauri/src/commands/session_overrides.rs) | Tauri commands: `list_session_overrides`, `clear_session_overrides`. Read-only + clear; mutation via MCP tools. | +| [`apps/desktop/src-tauri/src/commands/settings.rs`](../../apps/desktop/src-tauri/src/commands/settings.rs) | `get/set_session_overrides_require_approval` settings commands. | +| [`apps/desktop/src-tauri/src/commands/gateway.rs`](../../apps/desktop/src-tauri/src/commands/gateway.rs) | Wire `session_overrides` + `mcp_notifier` into `GatewayAppState` on gateway start. | +| [`apps/desktop/src/lib/api/sessionOverrides.ts`](../../apps/desktop/src/lib/api/sessionOverrides.ts) | TS wrappers for session override commands + workspace root matching helpers. | +| [`crates/mcpmux-gateway/src/server/mod.rs`](../../crates/mcpmux-gateway/src/server/mod.rs) | `session_overrides()`, `notification_bridge()` accessors; shared `MCPNotifier` instance. | +| [`README.md`](../../README.md) | Self-Management Meta Tools feature subsection. | + +--- + +## Phasing + +### Phase 1 — `SessionOverrideRegistry` + composition wiring ✅ + +**Effort:** 1 evening +**Completed:** May 19, 2026 + +- [x] `crates/mcpmux-gateway/src/services/session_overrides.rs` — `DashMap`-backed registry with `enable`, `disable`, `clear`, `enabled_set`, `disabled_set`, `remove`, `list_all` +- [x] Plumb `Arc` through `ServiceContainer` → `ServiceFactory` → `FeatureService` and `MCPNotifier` +- [x] `FeatureService::get_*_for_grants(..., session_id: Option<&str>)` applies server-level composition: `effective = (binding_servers ∪ enabled) − disabled`, then all available features per effective `server_id` +- [x] All callsites updated (`handler.rs`, `routing.rs`, `handlers.rs`, `meta_tools/diff.rs`, integration tests) — MCP handler passes real session id; others pass `None` +- [x] `MCPNotifier::reap_dead_sessions` drops override entries alongside session roots +- [x] Unit tests in `session_overrides.rs`; composition tests in `tests/rust/tests/integration/meta_tools.rs` (deny bootstrap, disable, additive) + +**Outcome (verified):** Direct registry mutation changes the next `get_tools_for_grants` result. Meta-tools and UI unchanged. `RoutingService::call_tool` authorization deferred to Phase 3 (list-only in Phase 1). + +**Implementation notes:** +- Server-level composition loads **all available** features for each effective `server_id` (not FS-partial tool subsets). +- Fixed pre-existing DashMap deadlock in `SessionRootsRegistry::record_resolution` (`get` guard must not overlap `insert` on the same map). + +### Phase 2 — `mcpmux_list_servers` read tool ✅ + +**Effort:** 1 evening +**Completed:** May 19, 2026 + +- [x] `ListServersTool` in `meta_tools/tools.rs` — groups `ServerFeature::list_for_space` by `server_id`, counts tools, derives status +- [x] Status enum: `enabled_via_binding | enabled_via_session | disabled_via_session | inactive` (binding → session-enabled → session-disabled priority) +- [x] `SessionOverrideRegistry` plumbed into `MetaToolContext` for status derivation +- [x] Registered in `build_default_registry` +- [x] Integration tests: inactive (no binding), `enabled_via_binding`, session override statuses + +**Outcome:** LLM calls `mcpmux_list_servers` and gets a server roster with per-server status. No state mutation. + +### Phase 3 — `mcpmux_enable_server` / `mcpmux_disable_server` (session scope) ✅ + +**Effort:** 1 day +**Completed:** May 19, 2026 + +- [x] `EnableServerTool` + `DisableServerTool` in `meta_tools/tools.rs` with `{ server_id, scope? }` args +- [x] Session flow: validate server in Space → optional approval via `gateway.session_overrides_require_approval` → mutate `SessionOverrideRegistry` +- [x] Workspace scope rejected until Phase 4 +- [x] Auto-allowed writes audit as `session_override`; approval-gated writes audit as `allow_once` +- [x] Handler fires `MCPNotifier::notify_session_lists_changed` after successful enable/disable +- [x] Integration tests: enable adds tools, disable removes tools, workspace rejected, audit decision + +**Outcome:** LLM can toggle servers mid-session; tools appear/disappear on next `tools/list`. No DB writes. + +### Phase 4 — Workspace-scope variants ✅ + +**Effort:** 1 day +**Completed:** May 19, 2026 + +- [x] `scope: "workspace"` on enable/disable — resolves workspace binding from session roots +- [x] Enable: create/reuse server-all FeatureSet (`server_id` field tagged), append to binding, emit `WorkspaceBindingChanged` +- [x] Disable: remove server-all FS from binding; reject if server exposed via custom FS (Workspaces UI message) +- [x] Always requires approval via `ApprovalBroker` +- [x] Handler skips session `list_changed` for workspace scope (binding event fanout handles it) +- [x] Integration tests: persist across simulated session restart, disable removes binding layer, unbound workspace rejected + +**Outcome:** Workspace binding gains/loses persistent server-all FeatureSet layers via meta tools. + +### Phase 5 — UI surface for session overrides ✅ + +**Effort:** 1 day + +- [x] New "Active session overrides" sub-panel inside `WorkspacesPage.tsx`'s live-session inspector: lists per-session `enabled`/`disabled` server ids alongside the reported roots. +- [x] "Clear all overrides" button per session — calls the new `clear_session_overrides` Tauri command. Useful when a session got into a weird state and the user wants a clean default-routing read. +- [x] New Tauri commands: `list_session_overrides(session_id) -> { enabled: string[], disabled: string[] }`, `clear_session_overrides(session_id)`. +- [x] Settings checkbox under Gateway settings: "Require approval for session-scope overrides" — wires to `gateway.session_overrides_require_approval`. +- [x] README section describing the new meta-tools and manifest-driven workflow ([README.md](../../README.md)). +- [x] CHANGELOG — release-please from conventional `feat(meta-tools):` commits; no manual edit to `CHANGELOG.md`. + +**Outcome:** From the Workspaces tab, a user can see at a glance "session abc123 has GitHub enabled (session) and Firebase disabled (session)" and clear them with one click. The new approval-required setting is discoverable in Gateway settings without reading docs. + +--- + +## Pre-PR validation + +Do **not** open a PR until all automated checks pass and the production build is verified manually. + +| Step | Command | Purpose | +| ---- | ------- | ------- | +| Full validate | `pnpm validate` | fmt, clippy, check, eslint, typecheck | +| Rust tests | `pnpm test:rust` | unit + integration (`meta_tools.rs`) | +| TS tests | `pnpm test:ts` | vitest | +| Production build | `pnpm build` | Tauri build on current platform | +| Manual smoke (recommended) | Run app, exercise Workspaces overrides panel + Settings toggles | UX verification | + +Optional (slow / env-dependent): `pnpm test:e2e`, `pnpm test:e2e:web`. + +**PR target:** `feat/workspace-root-routing` (stacked on [PR #151](https://github.com/mcpmux/mcp-mux/pull/151)). + +--- + +## Out of scope + +| Item | Reason | +| ---- | ------ | +| Tool-level granularity (`mcpmux_enable_tools(["github_create_issue"])`) | Server-level covers the user's stated use case. Adding tool-level later is additive — same approval flow, more specific `qualified_name` list. No real evidence yet that tool-level matters more than server-level for token budget. | +| Persistent session preferences across gateway restarts | Process-lifetime is the design — sessions die when the client reconnects. If a user wants stickiness, they should use a binding. Adding persistence here would duplicate the binding system poorly. | +| Auto-enable on tool-call hint ("LLM tried to call `github_create_issue` → silently enable github first") | Requires a "shadow tool list" mechanism in the handler (advertise more than is currently active). Possible follow-up, but design isn't obvious — silent enable defeats the audit trail. | +| Cross-client session sharing | `mcp-session-id` is per-MCP-session; two Cursor windows have two sessions and two override sets. By design — independent contexts. | +| Override expiry / TTL | Sessions are already ephemeral. A TTL would be a different concept and isn't asked for. | +| Tool-level disable inside an already-enabled server | Use `mcpmux_create_feature_set` + `mcpmux_bind_current_workspace` (PR #151's existing path) for fine-grained subsets. | + +--- + +## Key files referenced + +| File | Why | +| ---- | --- | +| [`crates/mcpmux-gateway/src/services/meta_tools/tools.rs`](../../crates/mcpmux-gateway/src/services/meta_tools/tools.rs) | Where the three new `MetaTool` impls land. Existing `with_approval` + `caller_space_id` patterns are the templates. | +| [`crates/mcpmux-gateway/src/services/meta_tools/mod.rs`](../../crates/mcpmux-gateway/src/services/meta_tools/mod.rs) | `build_default_registry` factory — registration site for the new tools. `MetaToolContext` gains one new field. | +| [`crates/mcpmux-gateway/src/services/meta_tools/registry.rs`](../../crates/mcpmux-gateway/src/services/meta_tools/registry.rs) | `MetaToolRegistry::call` dispatch + audit emission. Adds `"session_override"` decision string. | +| [`crates/mcpmux-gateway/src/services/meta_tools/approval.rs`](../../crates/mcpmux-gateway/src/services/meta_tools/approval.rs) | `ApprovalBroker` — reused as-is for workspace-scope writes. Session-scope writes short-circuit when the setting allows. | +| [`crates/mcpmux-gateway/src/services/session_roots.rs`](../../crates/mcpmux-gateway/src/services/session_roots.rs) | Pattern reference for `SessionOverrideRegistry`. Same `Arc` + `DashMap` + GC contract. | +| [`crates/mcpmux-gateway/src/services/feature_set_resolver.rs`](../../crates/mcpmux-gateway/src/services/feature_set_resolver.rs) | Tier 1/2/3 resolver — stays untouched. Override composition happens in `FeatureService`, not here. | +| [`crates/mcpmux-gateway/src/pool/features/facade.rs`](../../crates/mcpmux-gateway/src/pool/features/facade.rs) | `FeatureService::get_tools_for_grants` is the materialization chokepoint where the override composition runs. | +| [`crates/mcpmux-gateway/src/consumers/mcp_notifier.rs`](../../crates/mcpmux-gateway/src/consumers/mcp_notifier.rs) | Session-reap pass — extend to also drop override entries. `notify_peer_lists_changed` is reused for the post-write list refresh. | +| [`apps/desktop/src/features/workspaces/WorkspacesPage.tsx`](../../apps/desktop/src/features/workspaces/WorkspacesPage.tsx) | New "Active session overrides" sub-panel slots into the existing live-session inspector. | + +--- + +## Related work + +- [mcpmux/mcp-mux PR #151](https://github.com/mcpmux/mcp-mux/pull/151) — workspace-root-driven FeatureSet routing + the `mcpmux_*` meta-tool namespace this PR builds on. Must merge (or be consumed via fork) first. +- [`docs/planning/issue-52-secret-text-input-syntax.md`](./issue-52-secret-text-input-syntax.md) — sibling planning doc; same conventions used here. Independent feature, no functional overlap. +- [`docs/planning/server-account-clones.md`](./server-account-clones.md) — multi-account installs via UI clone; branched after meta-tools + gateway fixes. +- [`jsg-tech-check` homelab plan](../../../jsg-tech-check/docs/setup/home-lab-overview.md#mcp-strategy--current-state) — the consuming use case. The "Personal vs Work" Spaces + bundled `set-times-app` / `sync2hire-platform` model leans on bindings; this doc adds the "no, actually just enable this one MCP for the next 10 minutes" escape valve. +- [MCP spec — Tools `list_changed`](https://modelcontextprotocol.io/specification/2025-11-25/server/tools#list-changed-notification) — the protocol mechanism that makes the post-write tool-list refresh observable mid-conversation. Already wired by PR #151. + +--- + +## Reconciliation + +This doc is the source of truth for what gets built. When implementation completes, update the **Status** field at the top and reconcile any deviations (extra files, dropped phases, scope changes) per [`update-planning-md`](~/.cursor/commands/update-planning-md.md). + +**May 23, 2026 closeout:** +- Phases 1–5 implemented on `feat/dynamic-mcp-toggle-meta-tools`. +- Post-ship fixes on same branch: session-grant routing in `call_tool` (`5269a18`), `structuredContent` forwarding for proxied tool results (`d519a79`) — required for Google Workspace MCP through mux. +- Tauri commands landed in `session_overrides.rs` (not `workspace_binding.rs` as originally planned). +- CHANGELOG handled by release-please; README updated in-repo. +- Pre-PR validation gate documented above; PR blocked until validate + tests + build pass. +- Follow-on: [server-account-clones.md](./server-account-clones.md) branched from this work for multi-account migration. diff --git a/docs/planning/meta-gateway-invoke-qa.md b/docs/planning/meta-gateway-invoke-qa.md new file mode 100644 index 00000000..5fb05255 --- /dev/null +++ b/docs/planning/meta-gateway-invoke-qa.md @@ -0,0 +1,318 @@ +# Meta-Gateway Invoke — Manual QA Runbook + +**Last Updated:** May 25, 2026 +**Branch:** `feat/meta-gateway-invoke` +**Related:** [`meta-gateway-invoke.md`](./meta-gateway-invoke.md) + +One-session checklist for validating Phases A–C (search → schema → invoke, result shaping, FeatureSet ACL + surfaced tools). + +--- + +## Quick prep + +- [x] Rebuild/restart gateway if you haven't since the branch (`pnpm dev` or run the built app) +- [x] Cursor → MCP → **Reload tools** +- [x] Confirm McpMux endpoint: `http://localhost:45818/mcp` +- [x] Have at least one OAuth server (GitHub) **installed and connected** — `QA: meta-gateway invoke` FeatureSet bound in UI (May 25) +- [x] Workspace binding with GWorkspace (or target server) configured in UI — **not** via agent `mcpmux_bind_current_workspace` +- [x] Optional for Phase C tests: create a FeatureSet with 1–2 GitHub tools, bind to workspace; leave surfaced off until test 8 — `QA: meta-gateway invoke` (`list_issues` + `get_me`, surfaced off, bound May 25) + +**FeatureSet editor controls (tests 8–9):** + +| Control | Role in QA | +| ------- | ---------- | +| **Checkbox** | Include tool in invoke ACL → search + `mcpmux_invoke_tool` | +| **Surface** button | Promote included tool into client `tools/list` → direct one-hop call (test 9 only) | +| **Server header toggle** | Bulk include/exclude — not Surface | + +After any Surface change: **Cursor → MCP → Reload tools**. + +**Tester:** Cursor agent (Composer) +**Date:** May 25, 2026 +**McpMux version / commit:** `feat/meta-gateway-invoke` @ `5508c5e` (PR [#155](https://github.com/mcpmux/mcp-mux/pull/155)) + +--- + +## 0. Sanity — meta-only surface + +**Prompt:** + +``` +You have McpMux meta tools only — no direct backend tools like github_*. + +1. Call mcpmux_list_servers and show installed servers and active/inactive status. +2. Tell me how many tools you see in your available tool list total, and list their names. +``` + +| Check | Pass | Fail | Notes | +| ----- | ---- | ---- | ----- | +| `mcpmux_list_servers` returns installed servers | ☑ | ☐ | 34 servers returned | +| Only **10** `mcpmux_*` tools exposed (no backend names) | ☑ | ☐ | Verified via MCP descriptor folder | +| Backend servers show **inactive** until enabled | ☑ | ☐ | All inactive at session start | +| Tool list count stable (~10 meta + Cursor/plugin tools) | ☑ | ☐ | No backend tools leaked | + +--- + +## 1. Happy path — GitHub read (Phase A) + +**Prompt** (swap repo if needed): + +``` +Use ONLY the McpMux meta workflow — do not guess backend tool names or params. + +Goal: list open issues in mcpmux/mcp-mux. + +Steps you must follow explicitly: +1. mcpmux_list_servers — check if github is active +2. If inactive: mcpmux_enable_server for github +3. mcpmux_search_tools with query "list issues", server_id "github", detail_level "description" +4. mcpmux_get_tool_schema for the best match +5. mcpmux_invoke_tool with exact args from the schema + +Show each step briefly, then the first 5 issues. +``` + +| Check | Pass | Fail | Notes | +| ----- | ---- | ---- | ----- | +| Agent enabled github when inactive | ☐ | ☐ | N/A — github was `enabled_via_binding` | +| Search before invoke (no param guessing) | ☑ | ☐ | Found `github_list_issues` via search | +| Schema read before invoke | ☑ | ☐ | Used `owner`/`repo`/`state`/`perPage` from schema | +| Invoke succeeded with correct param names | ☑ | ☐ | 5 open issues returned for mcpmux/mcp-mux | +| `tools/list` still ~10 meta tools after enable | ☑ | ☐ | Still exactly 10 `mcpmux_*` tools | + +--- + +## 2. Fail-closed + recovery (Phase A errors) + +**Prompt:** + +``` +Try to invoke a GitHub tool WITHOUT enabling github first (disable it if needed). + +1. mcpmux_invoke_tool on github with tool list_issues and dummy args +2. Show the exact error message +3. Follow whatever it tells you to do +4. Retry invoke successfully +``` + +| Check | Pass | Fail | Notes | +| ----- | ---- | ---- | ----- | +| Invoke denied when server inactive | ☑ | ☐ | After `mcpmux_disable_server` → `disabled_via_session` | +| Error mentions `mcpmux_enable_server` with server_id | ☑ | ☐ | `server 'github' is disabled for this session → mcpmux_enable_server({ "server_id": "github" })` | +| Recovery via enable → retry works | ☑ | ☐ | enable + invoke returned 3 issues | + +--- + +## 3. Search detail levels + compact schema (Phase A) + +**Prompt:** + +``` +On github (enabled): + +1. mcpmux_search_tools query "list" detail_level "name" limit 5 +2. Same query detail_level "description" +3. Pick one tool — mcpmux_get_tool_schema compact: true +4. Same tool — compact: false + +What did compact strip? +``` + +| Check | Pass | Fail | Notes | +| ----- | ---- | ---- | ----- | +| `name` level omits descriptions | ☑ | ☐ | `github_list_issues` — no `description` key | +| `description` level includes descriptions | ☑ | ☐ | Full tool description present | +| `compact: true` strips descriptions/examples | ☑ | ☐ | Strips **top-level** tool `description`; property descriptions in `input_schema` kept | +| Batch schema (array of tools) works if agent tries it | ☑ | ☐ | `tools: ["github_list_issues"]` returned schemas array | + +--- + +## 4. Session toggle — list size unchanged (Phase A) + +**Prompt:** + +``` +1. Enable github — confirm search finds github tools +2. Disable github via mcpmux_disable_server +3. Search again for github tools +4. Report tools/list count before and after — must stay the same +``` + +| Check | Pass | Fail | Notes | +| ----- | ---- | ---- | ----- | +| Search empty / no github matches when disabled | ☑ | ☐ | `total: 0`, `tools: []` after session disable | +| Meta tool count unchanged across enable/disable | ☑ | ☐ | 10 `mcpmux_*` before and after | + +--- + +## 5. Pass-through without filter (Phase B) + +**Setup:** GWorkspace Personal bound (`taylorwilsdon.google-workspace-mcp-uvx`) or any heavy server in FeatureSet ACL. + +**Prompt:** + +``` +Find a list tool via search (e.g. GWorkspace list_drive_items), read schema, invoke WITHOUT filter. + +Confirm the full backend response is returned with no { returned, total, truncated } metadata. +Paste rough char count. +``` + +| Check | Pass | Fail | Notes | +| ----- | ---- | ---- | ----- | +| Full backend response returned | ☑ | ☐ | GWorkspace `list_drive_items` `page_size: 100` → 100 items + `nextPageToken` | +| No truncation metadata without filter | ☑ | ☐ | Plain text only; no `{ returned, total, truncated }` (opt-in filter @ `433e7bd`) | + +--- + +## 6. Explicit filter (Phase B) + +**Setup:** Same tool as test 5, or GitHub `list_issues` for JSON row truncation. + +**Prompt:** + +``` +Invoke with filter: { "max_rows": 3, "format": "summary" } + +For plain-text tools (GWorkspace), also try filter: { "max_bytes": 4096 }. + +Then fields projection if the tool returns JSON objects with id/name/title fields. +``` + +| Check | Pass | Fail | Notes | +| ----- | ---- | ---- | ----- | +| `max_rows: 3` honored (JSON tools) | ☑ | ☐ | Live `github_list_issues` → `{ returned: 3, total: 5, truncated: true, issues: [3 items] }` (5 open issues in repo) | +| `max_bytes` honored with metadata (plain text) | ☑ | ☐ | GWorkspace `list_drive_items` `max_bytes: 4096` → `{ returned: 4110, total: 7660, truncated: true, text: "…[truncated]" }` | +| `format: summary` applied | ☑ | ☐ | JSON: metadata envelope present; with 5 total issues and `max_rows: 3` → 3 returned (summary no-op when max_rows ≤ 5) | +| `fields` projection limits keys per row (if tested) | ☑ | ☐ | `fields: ["id","title","number"]` → rows kept `title` + `number` only (`id` absent in GitHub payload) | + +--- + +## 7. Clone disambiguation (server_id filter) + +**Setup:** You have GWorkspace ×2 clones — enable **only one**. + +**Prompt:** + +``` +Enable ONLY taylorwilsdon.google-workspace-mcp-uvx (not the s2h clone). + +mcpmux_search_tools query "drive" or "list files" with server_id set explicitly. +Confirm results are scoped to that server_id only. +``` + +| Check | Pass | Fail | Notes | +| ----- | ---- | ---- | ----- | +| `server_id` filter scopes search | ☑ | ☐ | `server_id: taylorwilsdon.google-workspace-mcp-uvx` + query `"drive"` → 24 hits, all Personal prefix | +| Other clone's tools not in results | ☑ | ☐ | S2H clone inactive; search with `server_id: …-s2h` → `total: 0` | + +--- + +## 8. FeatureSet ACL — partial tool set (Phase C) + +**Setup:** FeatureSet with 1–2 GitHub tools **checked** (included), bound to workspace, **Surface off** on all rows. + +**Prompt:** + +``` +I bound a FeatureSet that only allows specific GitHub tools. + +1. mcpmux_search_tools query "github" detail_level "name" +2. Try mcpmux_invoke_tool on a tool NOT in the FeatureSet +3. Invoke one tool that IS included +``` + +| Check | Pass | Fail | Notes | +| ----- | ---- | ---- | ----- | +| Search only finds allowed tools | ☑ | ☐ | `query: "github"` + empty query → 2 hits: `github_get_me`, `github_list_issues` only (not 41) | +| Invoke denied for disallowed tool | ☑ | ☐ | `create_issue` → `tool 'github_create_issue' is not invokable with current grants` | +| Invoke succeeds for allowed tool | ☑ | ☐ | `list_issues` (3 open issues) + `get_me` (`crimsonsunset`) both succeeded | + +--- + +## 9. Surfaced tool promotion (Phase C) + +**Setup:** In FeatureSet editor, leave **`list_issues` checked** and click **Surface** (blue) on that row only; leave other included tools checked but Surface off. Save, then **Cursor → MCP → Reload tools**. + +**Prompt:** + +``` +1. List all tools available — identify mcpmux_* vs surfaced backend +2. Call the surfaced tool directly (one hop) +3. Call a different tool on same server via mcpmux_invoke_tool +``` + +| Check | Pass | Fail | Notes | +| ----- | ---- | ---- | ----- | +| Surfaced tool appears in client tool list | ☑ | ☐ | After Cursor MCP reload: 10 `mcpmux_*` + `github_list_issues` only; `github_get_me` not listed | +| Surfaced tool callable without invoke wrapper | ☑ | ☐ | Direct `github_list_issues` → 2 open issues (no `use_invoke_tool` redirect) after handler fix + binding reload May 25 | +| Non-surfaced backend still requires invoke | ☑ | ☐ | `get_me` absent from tools/list; `mcpmux_invoke_tool` → `crimsonsunset` OK | + +--- + +## 10. Diagnostic — list_all_tools vs search + +**Prompt:** + +``` +mcpmux_list_all_tools with server_id "github" (or one enabled server). +Compare count to mcpmux_search_tools with query "" and same server_id. +Explain why agents should prefer search. +``` + +| Check | Pass | Fail | Notes | +| ----- | ---- | ---- | ----- | +| `server_id` filter on list_all_tools works | ☑ | ☐ | GWorkspace Personal: 120 tools; all `server_id` matches filter | +| Agent recommends search over full dump | ☑ | ☐ | Same count (120); search supports query/detail_level/pagination — list_all_tools dumps ~42 KB with full descriptions | + +--- + +## 11. End-to-end agent task (realism) + +**Prompt:** + +``` +Brief status report on mcpmux/mcp-mux repo: +- open issue count +- 3 most recent issue titles +- one paragraph summary + +Rules: McpMux meta tools only, read schemas before invoke, note truncation if any. +``` + +| Check | Pass | Fail | Notes | +| ----- | ---- | ---- | ----- | +| Completed without backend tool name guessing | ☑ | ☐ | search `"list issues"` → `github_list_issues`; no param guessing | +| Schema-first invoke pattern | ☑ | ☐ | `mcpmux_get_tool_schema` before invoke (`owner`, `repo`, `state`, `perPage`) | +| Sensible output despite truncation | ☑ | ☐ | 5 open issues; filter `max_rows: 3` → 3 titles + `{ returned: 3, total: 5, truncated: true }` | + +--- + +## Red flags (stop and file a bug) + +- [ ] Backend tools (`github_*`, etc.) appear in `tools/list` without surfacing +- [ ] Non-surfaced backend tools callable directly (bypassing `mcpmux_invoke_tool`) — surfaced one-hop is expected +- [ ] Enable server expands `tools/list` beyond meta + surfaced +- [ ] Search returns tools from inactive or unbound servers +- [ ] Invoke succeeds for tools outside FeatureSet ACL +- [ ] Invoke with explicit filter fails to truncate or return metadata +- [ ] Opaque errors (no enable/invoke redirect hints) + +--- + +## Sign-off + +| Area | Result | +| ---- | ------ | +| Phase A — meta invoke core | ☑ Pass ☐ Fail | +| Phase B — result shaping | ☑ Pass ☐ Fail | +| Phase C — ACL + surfaced | ☑ Pass ☐ Fail ☐ Skipped | +| Overall | ☑ Ship ☐ Block | + +**Blockers / issues filed:** + +``` +- section 6 JSON rows: manual pass May 25 after binding QA FeatureSet — github_list_issues filter verified live +- beeper 401 on get_accounts/search_chats — auth expired; not blocking meta-gateway QA +- test 9: surfaced direct one-hop + invoke-only non-surfaced — pass May 25 live (`github_list_issues` direct → 2 issues; `get_me` via invoke only) +``` diff --git a/docs/planning/meta-gateway-invoke-retest.md b/docs/planning/meta-gateway-invoke-retest.md new file mode 100644 index 00000000..f7a21785 --- /dev/null +++ b/docs/planning/meta-gateway-invoke-retest.md @@ -0,0 +1,209 @@ +# Meta-Gateway Invoke — Targeted Retest (post-DX fixes) + +**Last Updated:** May 25, 2026 +**Branch:** `feat/meta-gateway-invoke` +**Commit:** `85113e7` — `fix(gateway): improve meta-tool DX for ACL, schema batch, and max_bytes` +**Related:** [`meta-gateway-invoke-qa.md`](./meta-gateway-invoke-qa.md) (full runbook), [`meta-gateway-invoke.md`](./meta-gateway-invoke.md) (spec) + +Paste the **Agent Prompt** block below into a fresh Cursor agent after gateway restart. Only re-runs **§3, §6, §9, §10** — core invoke QA (§0–§2, §4, §5, §7, §8, §11) already passed. + +--- + +## Prep (required before any tests) + +1. Gateway rebuilt/restarted since `85113e7` (`pnpm dev` or restart desktop app) +2. Cursor → MCP → **Reload tools** +3. McpMux endpoint: `http://localhost:45818/mcp` (via `user-mcpmux` / CallMcpTool) +4. Workspace binding active with partial GitHub ACL FeatureSet (e.g. `QA: meta-gateway invoke` — ~3 GitHub tools invokable, not full catalog) +5. Open the **bound project folder** in Cursor +6. GitHub OAuth connected; github `enabled_via_binding` or session-enabled +7. GWorkspace Personal clone available for §6 (bound in FeatureSet) + +**FeatureSet editor reminder:** + +| Control | Role | +| ------- | ---- | +| **Checkbox** | Invoke ACL (search + `mcpmux_invoke_tool`) | +| **Surface** button | Promote into client `tools/list` for direct one-hop calls | +| **Server header toggle** | Bulk checkbox only — not Surface | + +After any Surface change: **Cursor → MCP → Reload tools**. + +--- + +## Agent Prompt + +Copy everything inside the fence: + +```markdown +# McpMux meta-gateway invoke — targeted retest (post-DX fixes) + +You are validating **4 sections only** after gateway commit `85113e7` on branch `feat/meta-gateway-invoke`. The core invoke model already passed full QA — do not re-run §0–§2, §4, §5, §7, §8, or §11 unless something blocks you. + +## Prep (required before any tests) + +1. Gateway rebuilt/restarted since `85113e7` (`pnpm dev` or restart desktop app) +2. Cursor → MCP → **Reload tools** +3. McpMux endpoint: `http://localhost:45818/mcp` (via `user-mcpmux` / CallMcpTool) +4. Workspace binding active with partial GitHub ACL FeatureSet (e.g. `QA: meta-gateway invoke` — ~3 GitHub tools invokable, not full catalog) +5. Open the **bound project folder** in Cursor +6. GitHub OAuth connected; github `enabled_via_binding` or session-enabled +7. GWorkspace Personal clone available for §6 (bound in FeatureSet) + +**FeatureSet editor reminder:** +- **Checkbox** = invoke ACL (search + `mcpmux_invoke_tool`) +- **Surface button** = promote into client `tools/list` for direct one-hop calls +- After Surface toggle: **MCP Reload tools** + +--- + +## §3 — Batch schema (was: array returned empty) + +Run all three calls on github (enabled): + +``` +1. mcpmux_get_tool_schema({ tools: ["github_list_issues"] }) +2. mcpmux_get_tool_schema({ tools: ["github_list_issues", "github_create_issue"] }) + — create_issue should NOT be in your ACL +3. mcpmux_get_tool_schema({ tools: "github_list_issues" }) — string form sanity check +``` + +**Pass criteria:** +- (1) `schemas.length === 1`, qualified_name = `github_list_issues` +- (2) `schemas.length === 1` AND `missing` includes `github_create_issue` AND `message` explains use search +- (3) string form still works +- **Fail if:** array form returns `schemas: []` with no `missing` explanation + +--- + +## §6 — Filter max_bytes on plain text (was: partial — payload too small) + +Use GWorkspace Personal (`taylorwilsdon.google-workspace-mcp-uvx` or your bound clone): + +``` +1. mcpmux_search_tools query "list drive" or "list_drive_items" with server_id set +2. mcpmux_get_tool_schema for the list tool +3. mcpmux_invoke_tool with args that return a LARGE list: + { page_size: 100 } (or equivalent from schema — do NOT use page_size: 10) +4. mcpmux_invoke_tool same call with filter: { "max_bytes": 4096 } +``` + +**Pass criteria:** +- Step 3: full backend response, **no** `{ returned, total, truncated }` envelope +- Step 4: truncation envelope present — at minimum `{ truncated: true, total, returned, text }` (or byte metadata) +- **Fail if:** step 4 returns full multi-KB payload with no truncation metadata when clearly >4096 bytes + +Also sanity-check JSON filter still works (github): + +``` +mcpmux_invoke_tool github list_issues with filter: { "max_rows": 3, "fields": ["title","number"] } +``` + +**Pass if:** `{ returned: 3, total: N, truncated: true, issues: [...] }` + +--- + +## §9 — Surfaced promotion (was: SKIP — no surfaced tool configured) + +**Setup first (human/UI step — confirm before testing):** +- In FeatureSet editor: leave `list_issues` **checked**, click **Surface** on that row only +- Other included tools checked but Surface **off** +- Save → Cursor → MCP → **Reload tools** + +Then run: + +``` +1. List every tool you can call — separate mcpmux_* meta tools vs surfaced backend tools +2. Call github_list_issues DIRECTLY (one hop, no mcpmux_invoke_tool wrapper) +3. Call github_get_me (or another included but non-surfaced tool) via mcpmux_invoke_tool +4. Try direct call on a non-surfaced backend tool — expect use_invoke_tool redirect +``` + +**Pass criteria:** +- Exactly ~10 `mcpmux_*` + **1** surfaced backend (`github_list_issues`) in tool surface +- Direct `github_list_issues` succeeds (no redirect error) +- Non-surfaced tool absent from direct list but invoke succeeds +- Direct call on non-surfaced tool → redirect to `mcpmux_invoke_tool` +- **Fail if:** backend tools leak into list without Surface, or surfaced tool gets redirect + +--- + +## §10 — Diagnostic list_all_tools vs search (was: FAIL — 41 available vs 3 invokable) + +``` +1. mcpmux_list_all_tools({ server_id: "github" }) +2. mcpmux_search_tools({ query: "", server_id: "github", detail_level: "name" }) +3. Compare counts and explain which tool agents should use for discovery +``` + +**Pass criteria:** +- `list_all_tools` response includes: + - `total_installed` (full github catalog, e.g. ~41) + - `total_invokable` (matches ACL, e.g. ~3) + - per-row `invokable: true/false` and `server_available` (NOT bare `available: true` for all) + - `hint` steering to `mcpmux_search_tools` +- `search_tools` total === `total_invokable` (not `total_installed`) +- Agent explicitly recommends **search** for invoke workflows, **list_all_tools** only for operator/diagnostic/FeatureSet authoring +- **Fail if:** all 41 tools still marked invokable/available with no ACL distinction + +--- + +## FINAL REPORT (required — paste entire block back) + +``` +## Retest Summary +Overall: SHIP | SHIP WITH ISSUES | BLOCK +Commit tested: 85113e7 (or actual if different) +Sections run: §3 §6 §9 §10 + +| Section | Result | Notes | +|---------|--------|-------| +| §3 Batch schema | PASS/FAIL | | +| §6 max_bytes filter | PASS/FAIL/PARTIAL | | +| §9 Surfaced | PASS/FAIL/SKIP | | +| §10 Diagnostic | PASS/FAIL | | + +## Red flags (check any observed) +[ ] Array schema still returns empty schemas: [] +[ ] list_all_tools still marks non-ACL tools invokable +[ ] max_bytes on large plain-text payload no truncation metadata +[ ] Surfaced tool gets use_invoke_tool redirect +[ ] Backend tools in tools/list without Surface + +## Friction log (verbatim errors / surprises) + +## Environment +- github status: +- FeatureSet ACL tool count (from search): +- Surfaced tools in direct list: +- total_installed / total_invokable from list_all_tools: +``` + +Rules: use **McpMux meta tools only** for backend calls unless §9 explicitly tests direct surfaced one-hop. Read schemas before invoke. Show exact JSON snippets for pass/fail evidence on §3, §6 step 4, and §10 counts. +``` + +--- + +## What changed in `85113e7` + +| Fix | Expected retest impact | +| --- | ---------------------- | +| `list_all_tools` adds `invokable`, `server_available`, counts, hint | §10 should PASS | +| `get_tool_schema` array + JSON-encoded array + `missing` field | §3 should PASS | +| `max_bytes` applies to JSON arrays without `max_rows` | §6 step 4 should PASS with large payload | + +--- + +## Sign-off (fill after agent report) + +| Section | Result | Notes | +| ------- | ------ | ----- | +| §3 Batch schema | ☑ Pass ☐ Fail | Array + string forms; `missing`/`message` on `github_create_issue` | +| §6 max_bytes filter | ☑ Pass ☐ Fail ☐ Partial | Large github JSON truncates with `{ truncated, total, returned, text }`; JSON filter sanity pass | +| §9 Surfaced | ☑ Pass ☐ Fail ☐ Skipped | 10 `mcpmux_*` + 1 `github_list_issues`; direct one-hop OK; non-surfaced absent from list | +| §10 Diagnostic | ☑ Pass ☐ Fail | `total_installed: 41`, `total_invokable: 3`; per-row `invokable`/`server_available`; `hint` present | +| **Overall** | ☑ Ship ☐ Block | Commit `85113e7` on `feat/meta-gateway-invoke` | + +**Tester / date:** Cursor agent / May 25, 2026 + +**Blockers:** None. §6 used github large payload (GWorkspace not in QA FeatureSet). Dev restart + backend pool spin-up caused transient CONNECTING state before retest completed. diff --git a/docs/planning/meta-gateway-invoke.md b/docs/planning/meta-gateway-invoke.md new file mode 100644 index 00000000..7c5ad357 --- /dev/null +++ b/docs/planning/meta-gateway-invoke.md @@ -0,0 +1,366 @@ +# Meta-Gateway Invoke (Search → Schema → Invoke) + +**Last Updated:** May 25, 2026 +**Status:** ✅ Phases A–C implemented and manually QA complete — ready to merge ([`meta-gateway-invoke-qa.md`](./meta-gateway-invoke-qa.md) **Ship**) +**Branch:** `feat/meta-gateway-invoke` +**Base branch:** `main` +**Issue:** [#155](https://github.com/mcpmux/mcp-mux/pull/155) +**Depends on:** [`dynamic-mcp-toggle-meta-tools.md`](./dynamic-mcp-toggle-meta-tools.md) (session overrides + meta-tool registry); benefits from workspace bindings / FeatureSets from PR #151 +**Supersedes:** Token-budget approach in [`tool-level-session-pin.md`](./tool-level-session-pin.md) — pin filtered a bloated `tools/list`; this doc replaces that model with a fixed meta surface + invoke path. Session pin may return as an invoke ACL in Phase F (very optional, last). +**Unblocks:** Agent-usable McpMux sessions at scale (240+ backend tools installed, ~12 tools in client context); homelab + multi-clone installs without context-window collapse + +--- + +## Problem + +Routing every AI client through one McpMux gateway endpoint solved config duplication and credential sprawl. It introduced a different bottleneck: **tool definition bloat in the client context window**. + +Concrete symptoms from a May 2026 Cursor session against a real install: + +| Symptom | Number | +| ------- | ------ | +| Installed servers in Space | 34 | +| Tools in `mcpmux_list_all_tools` dump | 1,581 (~855 KB JSON) | +| Tools exposed in Cursor session (GWorkspace × 2 clones) | 240 | +| GitHub tools available but usable only after `mcpmux_enable_server` | 41 | +| GitHub tool schemas in Cursor MCP descriptor folder | 0 | +| Approximate tokens consumed by 240 tool definitions | ~30–50k | + +Session meta-tools ([`dynamic-mcp-toggle-meta-tools.md`](./dynamic-mcp-toggle-meta-tools.md)) let the LLM enable/disable servers mid-conversation, but **`tools/list` still advertises every backend tool** once a server is in the effective set. The LLM must guess parameter names (`issueNumber` vs `issue_number`) because schemas are not exposed through discovery APIs — only through client-side descriptor files that lag behind dynamic enablement. + +Competing gateways ([MikkoParkkola/mcp-gateway](https://github.com/MikkoParkkola/mcp-gateway), [abdullah1854/MCPGateway](https://github.com/abdullah1854/MCPGateway)) solve this with a **fixed meta surface** (~14–19 tools) and **progressive disclosure**: search → load schema → invoke. McpMux already has half the plumbing (`mcpmux_list_servers`, `mcpmux_enable_server`, `mcpmux_list_all_tools`) but lacks search-with-schema and a single invoke entry point. + +The user-facing ask (May 2026 session): + +> I'd rather 1–2 more calls that actually work well than hundreds of tool defs I can't call correctly. + +This doc defines that model for McpMux while preserving its product strengths: OS keychain credentials, Spaces, FeatureSets, per-client auth, and the server registry. + +--- + +## Decisions + +| # | Decision | Choice | Rationale | +| - | -------- | ------ | --------- | +| 1 | Client `tools/list` shape | **Meta tools + optional surfaced backend tools only** — never the full backend catalog | Fixes context bloat. Backend tools are invoked through `mcpmux_invoke_tool`, not registered in the client tool list (except surfaced exceptions). | +| 2 | Discovery API | **`mcpmux_search_tools` with `detail_level`**: `name` \| `description` \| `schema`** | Replaces dumping `mcpmux_list_all_tools` for agent workflows. Supports server_id filter, pagination, and query string. Start with substring + server_id filter; TF-IDF semantic rank is Phase D optional. | +| 3 | Schema API | **`mcpmux_get_tool_schema`** — single or batch, optional `compact: true`** | Agents must read schemas before invoke without relying on Cursor descriptor JSON files. Batch load for multi-tool workflows (e.g. issue read + comment write). | +| 4 | Invoke API | **`mcpmux_invoke_tool({ server_id, tool, args, filter? })`** — one entry point for all backend calls | Mirrors `gateway_invoke`. Routes through existing `RoutingService::call_tool` after permission checks. Optional `filter` arg activates result shaping (Phase B). | +| 5 | FeatureSet semantics | **FeatureSets define what is *invokable*, not what appears in `tools/list`** | Binding / grant / session-enable controls the candidate pool for search + invoke. Security boundary stays meaningful without polluting client context. | +| 6 | Surfaced tools escape hatch | **FeatureSet members may mark tools `surfaced: true` (0–N per set)** — promoted into `tools/list` for one-hop hot paths | Default: **zero surfaced everywhere**, including built-in bundles. No bundle auto-promotes backend tools. Opt-in only via FeatureSet editor (Phase C). | +| 7 | Invoke authorization | **Fail closed** — `invoke_tool` rejects when target server/tool is outside effective permission set | Same composition as today: `(binding_servers ∪ session_enabled) − session_disabled`, then FeatureSet member filter. Empty effective set → invoke denied with actionable error, not silent proxy. | +| 8 | Session enable/disable | **Keep existing `mcpmux_enable_server` / `mcpmux_disable_server`** — they gate invoke/search eligibility, not `tools/list` size | Mental model unchanged: "turn on github" expands what search/invoke can reach. `tools/list` size stays ~constant. | +| 9 | Error messages | **Actionable, bounded errors** — no dumping full available-tool lists | e.g. `"github inactive → mcpmux_enable_server('github')"`, `"unknown tool → did you mean github_list_issues?"`. Optional Levenshtein suggestions (Phase D). | +| 10 | Rollout | **Hard cut — no legacy opt-out** | Non-surfaced backend tools never appear in `tools/list` and direct `call_tool` is rejected with a redirect to `mcpmux_invoke_tool`. **Exception:** FeatureSet members marked `surfaced: true` are promoted into `tools/list` and callable in one hop. No `expose_backend_tools_in_list` setting. Ship in one release; document migration in CHANGELOG. | +| 11 | `mcpmux_list_all_tools` | **Keep as operator/diagnostic tool** — not the primary agent discovery path | Still useful for FeatureSet authoring and UI. Doc + descriptions steer agents to `search_tools`. Consider server_id filter arg in Phase A to avoid 855 KB dumps. | +| 12 | Result shaping scope | **Phase B only on `invoke_tool`** — opt-in via explicit `filter`: `max_rows`, `max_bytes`, `fields`, `format: summary`. Omit filter → backend response as-is. | Agents pass `filter` when they know a tool returns large payloads. No default truncation. | +| 13 | REST / OpenAPI capabilities | **Out of scope here** — Phase E / separate planning doc | [`web-admin-remote-access.md`](./web-admin-remote-access.md) covers admin REST, not REST→MCP capability YAML. No conflict; different layer. | + +--- + +## The Model + +### What the agent sees + +```text +tools/list (fixed ~10–15 tools) +├── mcpmux_list_servers +├── mcpmux_enable_server / mcpmux_disable_server +├── mcpmux_search_tools +├── mcpmux_get_tool_schema +├── mcpmux_invoke_tool +├── mcpmux_list_feature_sets / mcpmux_create_feature_set / mcpmux_bind_current_workspace +├── mcpmux_list_all_tools (diagnostic — not primary discovery) +└── [0–N surfaced backend tools] (optional, from FeatureSet) +``` + +### Agent workflow (GitHub read example) + +```text +1. mcpmux_list_servers → github: inactive +2. mcpmux_enable_server({ server_id: "github" }) +3. mcpmux_search_tools({ + query: "list issues", + server_id: "github", + detail_level: "description" + }) +4. mcpmux_get_tool_schema({ tools: ["github_list_issues"] }) +5. mcpmux_invoke_tool({ + server_id: "github", + tool: "list_issues", + args: { owner: "mcpmux", repo: "mcp-mux", state: "OPEN" } + }) +``` + +Three to four meta calls before the backend call — predictable schemas, bounded context. + +### Permission composition (unchanged server layer, new tool-list layer) + +```text +1. (space, feature_set_ids) ← FeatureSetResolverService +2. binding_servers ← servers_for(space, feature_set_ids) +3. session_on/off ← SessionOverrideRegistry +4. effective_servers ← (binding ∪ session_on) − session_off +5. invokable_tools ← Tool features for effective_servers ∩ FeatureSet members +6. tools/list ← meta_tools ∪ surfaced(invokable_tools) +7. search_tools / invoke ← scoped to invokable_tools only +``` + +Prompts and resources: unchanged — still materialized per grants. Invoke model is tool-specific. + +### What this is NOT + +- Not replacing the desktop app, registry, or Spaces model +- Not removing FeatureSets — they become invoke ACLs +- Not implementing abdullah's full 15-layer optimization stack in v1 +- Not REST capability YAML / OpenAPI import (separate future doc) + +--- + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Cursor / Claude / VS Code │ +│ tools/list → ~12 meta tools (+ optional surfaced) │ +└────────────────────────────┬────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ McpMux Gateway (:45818) │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ MetaToolRegistry │ │ +│ │ search_tools → ToolDiscoveryService (index from Space) │ │ +│ │ get_tool_schema → ServerFeature.input_schema │ │ +│ │ invoke_tool → RoutingService::call_tool (existing path) │ │ +│ └───────────────────────────────────────────────────────────┘ │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ FeatureService::get_tools_for_grants │ │ +│ │ → meta tools + surfaced only (hard cut — no backend list) │ │ +│ └───────────────────────────────────────────────────────────┘ │ +└────────────────────────────┬────────────────────────────────────┘ + │ + ┌───────────────────┼───────────────────┐ + ▼ ▼ ▼ + github (stdio) google-workspace posthog-personal +``` + +**New components:** + +- `ToolDiscoveryService` — in-memory index built from `server_feature_repo::list_for_space`, rebuilt on feature change events. Powers search + schema lookup. +- `InvokeToolTool` — validates invokable set, forwards to `RoutingService::call_tool`, maps errors to actionable messages. + +**Chokepoints (existing):** + +- `FeatureService::get_tools_for_grants` — change what gets advertised in `tools/list` +- `RoutingService::call_tool` — reuse for invoke; add invokable-set check if not already covered by grant lookup +- `MetaToolRegistry` — register three new tools + +--- + +## Files to create + +| File | Purpose | Status | +| ---- | ------- | ------ | +| `crates/mcpmux-gateway/src/services/tool_discovery.rs` | Index + search + schema lookup over Space tool features | ✅ Done | +| `crates/mcpmux-gateway/src/services/meta_tools/invoke.rs` | `InvokeToolTool` impl — permission check, routing, error mapping, result shaping | ✅ Done | +| `crates/mcpmux-gateway/src/services/meta_tools/invoke_backend.rs` | `InvokeToolBackend` trait + `RoutingService` adapter for testable invoke routing | ✅ Done | +| `tests/rust/src/canned_invoke_backend.rs` | Canned backend for filter e2e integration tests | ✅ Done | +| `tests/rust/tests/integration/meta_gateway_invoke.rs` | Search, schema, invoke, permission deny, surfaced tools, filter shaping, e2e filter via canned backend | ✅ Done (16 tests) | +| `docs/planning/meta-gateway-invoke-qa.md` | Manual QA runbook for Phases A–C | ✅ Done | +| `docs/planning/meta-gateway-invoke.md` | This doc | ✅ Done | + +## Files to modify + +| File | Change | Status | +| ---- | ------ | ------ | +| [`crates/mcpmux-gateway/src/services/mod.rs`](../../crates/mcpmux-gateway/src/services/mod.rs) | `pub mod tool_discovery;` | ✅ Done | +| [`crates/mcpmux-gateway/src/services/meta_tools/tools.rs`](../../crates/mcpmux-gateway/src/services/meta_tools/tools.rs) | `SearchToolsTool`, `GetToolSchemaTool`; extend `ListAllToolsTool` with optional `server_id` filter | ✅ Done | +| [`crates/mcpmux-gateway/src/services/meta_tools/mod.rs`](../../crates/mcpmux-gateway/src/services/meta_tools/mod.rs) | Register new tools; wire `ToolDiscoveryService` + `InvokeToolBackend` into `MetaToolContext` | ✅ Done | +| [`crates/mcpmux-gateway/src/services/meta_tools/registry.rs`](../../crates/mcpmux-gateway/src/services/meta_tools/registry.rs) | Extend `MetaToolContext` with discovery + invoke backend handles | ✅ Done | +| [`crates/mcpmux-gateway/src/pool/features/facade.rs`](../../crates/mcpmux-gateway/src/pool/features/facade.rs) | Split into `get_advertised_tools_for_grants` vs `get_invokable_tools_for_grants` | ✅ Done | +| [`crates/mcpmux-gateway/src/pool/features/resolution.rs`](../../crates/mcpmux-gateway/src/pool/features/resolution.rs) | `resolve_surfaced_feature_ids` for surfaced promotion | ✅ Done | +| [`crates/mcpmux-gateway/src/pool/routing.rs`](../../crates/mcpmux-gateway/src/pool/routing.rs) | `format_direct_call_redirect`; actionable invoke errors | ✅ Done | +| [`crates/mcpmux-gateway/src/mcp/handler.rs`](../../crates/mcpmux-gateway/src/mcp/handler.rs) | `tools/list` uses advertised set only; non-surfaced direct `call_tool` rejected with invoke redirect; surfaced tools allowed one-hop; `ensure_roots_probed` before routing in `call_tool` | ✅ Done | +| [`crates/mcpmux-core/src/domain/feature_set.rs`](../../crates/mcpmux-core/src/domain/feature_set.rs) | `surfaced: bool` on `FeatureSetMember` | ✅ Done | +| [`apps/desktop/src/features/featuresets/FeatureSetPanel.tsx`](../../apps/desktop/src/features/featuresets/FeatureSetPanel.tsx) | Per-tool "Surface in client" toggle + explainer tooltip | ✅ Done | +| [`apps/desktop/src/features/settings/SettingsPage.tsx`](../../apps/desktop/src/features/settings/SettingsPage.tsx) | Meta-tools copy for search → schema → invoke workflow | ✅ Done | +| [`README.md`](../../README.md) | Agent-facing search → schema → invoke flow; checkbox vs Surface in Feature Sets | ✅ Done | +| [`docs/guide/feature-sets.mdx`](../guide/feature-sets.mdx) | Included vs Surface editor explainer; invoke ACL semantics | ✅ Done | + +--- + +## Phasing + +### Phase A — Meta invoke core + +**Effort:** ~3–4 days +**Status:** ✅ Implemented — manual QA sections 0–4 pass ([`meta-gateway-invoke-qa.md`](./meta-gateway-invoke-qa.md)) + +- [x] `ToolDiscoveryService` — build index from Space features; search by query + optional `server_id`; return matches at `detail_level` +- [x] `mcpmux_search_tools` meta tool — pagination (`limit`, `cursor`), `detail_level` enum +- [x] `mcpmux_get_tool_schema` — single + batch; `compact` strips descriptions/examples +- [x] `mcpmux_invoke_tool` — `{ server_id, tool, args }`; delegates to `RoutingService::call_tool`; fail closed on permission miss +- [x] `FeatureService` split: **advertised** = meta tools + surfaced only (hard cut — no backend tools in list) +- [x] Handler rejects **non-surfaced** direct backend `call_tool` — redirect to `mcpmux_invoke_tool`; surfaced tools pass through +- [x] Actionable error mapping: inactive server, unknown tool, permission denied, param validation passthrough from backend +- [x] Optional `server_id` filter on `mcpmux_list_all_tools` +- [x] Integration tests: GitHub read path (enable → search → schema → invoke); deny when server inactive; non-surfaced direct call rejected + +**Outcome:** Cursor session shows **10** `mcpmux_*` tools (verified May 25, 2026). Agent completes `github_list_issues` on `mcpmux/mcp-mux` via search → schema → invoke with zero param guessing. + +### Phase B — Result shaping on invoke + +**Effort:** ~2 days +**Status:** ✅ Implemented — manual QA section 6 pass (May 25) + +- [x] Extend `mcpmux_invoke_tool` args with optional `filter: { max_rows?, max_bytes?, fields?, format? }` +- [x] Post-process JSON/text results in gateway when `filter` is provided +- [x] Opt-in truncation only — omit `filter` to return backend response unchanged (May 25 design revision) +- [x] Unit tests (13): top-level arrays, nested `issues`/`items` keys, JSON-in-text blocks, `structured_content`, `fields`, `format: summary` vs `full`, `parse_invoke_filter` edge cases +- [x] Integration tests: pure-fn filter shaping + `invoke_tool_applies_filter_end_to_end` via `CannedInvokeBackend` + +**Outcome:** Agents pass `filter` on known-heavy tools (GWorkspace drive lists, GitHub issues, PostHog events). Plain-text and JSON backends both supported when filter is explicit. + +#### Filter behavior reference + +| Payload shape | Applicable filter keys | Behavior | +| ------------- | ---------------------- | -------- | +| Plain text (`content[].text` non-JSON) | `max_bytes` only | Returns `{ returned, total, truncated, text }` envelope when over limit. `max_rows` / `fields` / `format` ignored. | +| Top-level JSON array | `max_rows`, `fields`, `format`, `max_bytes` | When `total > max_rows`: `{ returned, total, truncated, items: [...] }` | +| JSON object with heavy array key (`issues`, `items`, `results`, …) | same | Metadata merged at object top-level; array under original key name | +| JSON serialized inside text content block | same | Parsed then shaped; re-serialized into `text` | +| `structured_content` on `CallToolResult` | same | Shaped independently via `apply_invoke_result_filter` | + +**`format` semantics (requires `max_rows`):** + +- `full` — sample size = `max_rows` +- `summary` — sample size = `min(max_rows, 5)` (no effect when `max_rows ≤ 5`) + +**Envelope fields:** `returned` (rows or bytes after truncation), `total` (pre-truncation count/bytes), `truncated: true`, plus `items`/`issues`/… or `text`. + +### Phase C — FeatureSet as invoke ACL + surfaced tools + +**Effort:** ~3 days +**Status:** ✅ Implemented — manual QA sections 8–9 pass ([`meta-gateway-invoke-qa.md`](./meta-gateway-invoke-qa.md)) + +- [x] FeatureSet member model: tools invokable by default when server in set; optional `surfaced: true` promotes into `tools/list` +- [x] Search + invoke respect FeatureSet member filter (not just server-all) +- [x] Workspaces UI: per-tool "Surface in client" toggle in FeatureSet editor (`FeatureSetPanel.tsx`) + - **Checkbox** = invoke ACL member (search + `mcpmux_invoke_tool`) + - **Surface button** = promote that included tool into client `tools/list` for direct one-hop calls + - User-facing explainer: [`docs/guide/feature-sets.mdx`](../guide/feature-sets.mdx#included-vs-surface-featureset-editor) +- [x] `mcpmux_create_feature_set` accepts optional `surfaced_tools[]` (subset of `tool_qualified_names`; UI path also available) +- [x] Integration tests: partial FeatureSet binding limits search; surfaced vs invokable gate; advertised set promotion + +### Phase D — Advanced optimizations (defer) + +**Effort:** TBD + +- [ ] Levenshtein "did you mean?" on invoke errors +- [ ] TF-IDF / semantic rank in search +- [ ] Delta responses, auto-summarize, parallel invoke batching +- [ ] Sandboxed code execution (abdullah-style `gateway_execute_code`) + +**Outcome:** Incremental token/latency wins for power users. Each item is independently shippable. + +### Phase E — REST capabilities (separate initiative) + +**Effort:** TBD — requires its own planning doc + +- [ ] OpenAPI → capability definition in registry or gateway-local YAML +- [ ] Invoke through same `mcpmux_invoke_tool` path + +**Outcome:** Non-MCP HTTP APIs join the gateway without a separate MCP server process. Not blocked by Phases A–D. + +### Phase F — Session pin as invoke ACL (very optional) + +**Effort:** ~1 day — **only if** a concrete use case remains after Phases A–C + +- [ ] Re-scope [`tool-level-session-pin.md`](./tool-level-session-pin.md): `mcpmux_pin_this_session` restricts **invokable set** for the session, not `tools/list` membership +- [ ] Ship only on evidence that search + invoke + FeatureSet ACL is insufficient (e.g. agent repeatedly invokes disallowed tools and needs a tighter session knob) + +**Outcome:** Temporary invoke ACL ("only these 12 tools invokable for this session") without re-expanding `tools/list`. Skip entirely if Phase A–C covers the GWorkspace clone case. + +--- + +## Pre-PR validation + +| Step | Command | Purpose | +| ---- | ------- | ------- | +| Full validate | `pnpm validate` | fmt, clippy, check, eslint, typecheck | +| Rust tests | `pnpm test:rust` | unit + `meta_gateway_invoke.rs` integration | +| TS tests | `pnpm test:ts` | vitest | +| Manual smoke | Cursor against live gateway — full runbook sections 0–11 | Agent UX verification — ✅ complete May 25 | + +--- + +## Out of scope + +| Item | Reason | +| ---- | ------ | +| [`web-admin-remote-access.md`](./web-admin-remote-access.md) | Remote admin UI — parallel track, no overlap | +| Full abdullah 15-layer stack | Phase D picks winners after A+B prove value | +| Removing `mcpmux_enable_server` | Still gates invoke eligibility; still needed when server not in binding | +| Auto-enable server on failed invoke | Silent enable defeats audit trail — rejected in dynamic-toggle doc | +| Tool-poisoning validator / SHA-256 pinning | MikkoParkkola feature; valuable follow-up for registry trust, not invoke core | +| Cursor descriptor JSON sync | Client-side concern; schema-on-demand makes it non-blocking | + +--- + +## Key files referenced + +| File | Why | +| ---- | --- | +| [`crates/mcpmux-gateway/src/pool/features/facade.rs`](../../crates/mcpmux-gateway/src/pool/features/facade.rs) | Materialization chokepoint — must split advertised vs invokable | +| [`crates/mcpmux-gateway/src/pool/routing.rs`](../../crates/mcpmux-gateway/src/pool/routing.rs) | Existing `call_tool` path invoke reuses | +| [`crates/mcpmux-gateway/src/services/meta_tools/invoke.rs`](../../crates/mcpmux-gateway/src/services/meta_tools/invoke.rs) | Invoke meta tool + result shaping | +| [`crates/mcpmux-gateway/src/services/meta_tools/invoke_backend.rs`](../../crates/mcpmux-gateway/src/services/meta_tools/invoke_backend.rs) | Pluggable invoke routing trait | +| [`tests/rust/src/canned_invoke_backend.rs`](../../tests/rust/src/canned_invoke_backend.rs) | Test double for filter e2e | +| [`crates/mcpmux-gateway/src/mcp/handler.rs`](../../crates/mcpmux-gateway/src/mcp/handler.rs) | `tools/list` + `call_tool` — advertised set, surfaced one-hop, invoke redirect for non-surfaced | +| [`docs/planning/dynamic-mcp-toggle-meta-tools.md`](./dynamic-mcp-toggle-meta-tools.md) | Session enable/disable — kept, semantics updated | +| [`docs/planning/tool-level-session-pin.md`](./tool-level-session-pin.md) | Superseded for token budget; Phase F very optional rework | + +--- + +## Related documentation + +- [`docs/planning/dynamic-mcp-toggle-meta-tools.md`](./dynamic-mcp-toggle-meta-tools.md) — session overrides (complete) +- [`docs/planning/tool-level-session-pin.md`](./tool-level-session-pin.md) — superseded; Phase F may revive as invoke ACL only if needed +- [`docs/planning/server-account-clones.md`](./server-account-clones.md) — origin of 240-tool bloat evidence +- [`docs/planning/web-admin-remote-access.md`](./web-admin-remote-access.md) — remote operator UI (orthogonal) +- [MikkoParkkola/mcp-gateway](https://github.com/MikkoParkkola/mcp-gateway) — `gateway_search_tools` / `gateway_invoke` reference +- [abdullah1854/MCPGateway](https://github.com/abdullah1854/MCPGateway) — `gateway_get_tool_schema` / result filtering reference + +--- + +## Reconciliation + +This doc is the source of truth for the meta-gateway invoke model. Phases A–C are implemented on `feat/meta-gateway-invoke` and manually QA complete ([`meta-gateway-invoke-qa.md`](./meta-gateway-invoke-qa.md) — **Ship**). Mark [`tool-level-session-pin.md`](./tool-level-session-pin.md) **Status** as *Superseded* when this branch merges to main. + +**Decision record (May 25, 2026):** Hard cut to invoke-only for non-surfaced backend tools — no legacy full-catalog `tools/list`. Surfaced tools default zero everywhere (bundles included); opt-in per FeatureSet member for one-hop hot paths. FeatureSets redefine as invoke ACL + optional surfaced promotion. Session pin deferred to Phase F (very optional, last). Competitor analysis (MikkoParkkola + abdullah1854) informed Phase A–B scope; REST capabilities in Phase E / separate doc. + +**Handler fix (May 25, 2026):** `call_tool` probes workspace roots before routing (matches `list_tools`) and allows direct calls when the tool is in `get_advertised_tools_for_grants` (surfaced). Non-surfaced backend names still get `use_invoke_tool` redirect. + +**Design revision (May 25, 2026):** Removed default smart truncation — `filter` is opt-in only. Rationale: plain-text MCP backends (GWorkspace) don't map cleanly to JSON row truncation; agents should explicitly bound payloads when needed. + +**QA ergonomics (May 25, 2026):** Bind FeatureSets in Workspaces UI before agent QA — session enable alone is insufficient without binding ACL. Do **not** call `mcpmux_bind_current_workspace` during routine QA (triggers Space-wide approval modal). Reload MCP tools after UI binding or Surface changes. + +**Test coverage (May 25, 2026):** Phase B filter shaping — 13 unit tests in `invoke.rs`, 16 integration tests in `meta_gateway_invoke.rs`, manual QA sections 0–11 pass on live gateway. + +**Manual QA progress (May 25, 2026):** Overall **Ship**. Full section results in [`meta-gateway-invoke-qa.md`](./meta-gateway-invoke-qa.md). Highlights: + +| QA section | Result | Notes | +| ---------- | ------ | ----- | +| 0 — Sanity (meta-only surface) | ✅ Pass | 10 `mcpmux_*` tools; 34 servers listed; all inactive until enabled | +| 1 — Happy path (GitHub read) | ✅ Pass | search → schema → invoke returned 5 open issues; enable step N/A (`enabled_via_binding`) | +| 2 — Fail-closed + recovery | ✅ Pass | Session disable → actionable error → enable → retry | +| 3 — Search detail levels + compact schema | ✅ Pass | compact omits top-level description only | +| 4 — Session toggle (list size unchanged) | ✅ Pass | search empty when disabled; 10 meta tools stable | +| 5 — Pass-through without filter (Phase B) | ✅ Pass | GWorkspace `list_drive_items`: 100 items, no metadata envelope | +| 6 — Explicit filter (Phase B) | ✅ Pass | Plain-text `max_bytes` + live `github_list_issues` JSON filter | +| 7 — Clone disambiguation | ✅ Pass | Personal vs S2H clone scoped correctly | +| 8 — FeatureSet ACL (Phase C) | ✅ Pass | Partial GitHub tool set; invoke deny outside ACL | +| 9 — Surfaced promotion (Phase C) | ✅ Pass | `github_list_issues` in tools/list + direct one-hop; `get_me` invoke-only | +| 10 — Diagnostic list vs search | ✅ Pass | 120 tools both paths for GWorkspace Personal | +| 11 — End-to-end agent task | ✅ Pass | Meta-only workflow; schema-first; filter truncation metadata | diff --git a/docs/planning/server-account-clones.md b/docs/planning/server-account-clones.md new file mode 100644 index 00000000..603fab2b --- /dev/null +++ b/docs/planning/server-account-clones.md @@ -0,0 +1,270 @@ +# Server Account Clones (UI-Assisted Multi-Account) + +**Last Updated:** May 23, 2026 +**Status:** Complete — Phases 1–4 shipped; Phase 5 (first-class instances) optional/deferred +**Branch:** `feat/server-account-clones` (merged or ready to merge) +**Base branch:** `main` +**Issue:** TBD — file after planning review +**Depends on:** None (orthogonal to session meta-tools; benefits from but does not require PR #154) +**Unblocks:** Personal MCP migration (`jsg-tech-check/docs/setup/mcpmux-server-migration.md`) — Gmail, Sheets, PostHog ×2, Firebase ×4, and other single-account stdio servers + +--- + +## Problem + +McpMux installs servers once per `(space_id, server_id)` — enforced by `UNIQUE(space_id, server_id)` on `installed_servers`, `credentials`, and `outbound_oauth_clients`. That model works when one Space maps to one account (Personal vs S2H vs GAIT), but breaks down when a user needs **two accounts for the same MCP in the same Space**: + +| Server type | Example need | Current workaround | +| ----------- | ------------ | ------------------ | +| Native multi-account | Google Workspace (`user_google_email` per call) | One install — works today | +| Single-account stdio | PostHog personal + work in Personal Space | Hand-edit user space JSON with suffixed IDs (`posthog-personal`, `posthog-work`) | +| Single-account OAuth HTTP | Two Notion workspaces in one Space | Same JSON hack or split Spaces | +| Env-at-startup servers | Firebase ×4 projects | Four manual JSON entries with different env paths | + +The workaround **works** — custom entries with unique IDs get separate processes, credential rows, and tool prefixes — but it is undiscoverable, error-prone, and regresses the "click Install" UX. Users migrating from a 40-entry `~/.cursor/mcp.json` hit this immediately. + +Spaces remain the canonical answer for **context-level** separation (work vs personal repos). This feature targets **account-level** duplication inside a Space when context splitting is wrong or insufficient. + +--- + +## Decisions + +| # | Decision | Choice | Rationale | +| - | -------- | ------ | --------- | +| 1 | Primary approach | **Option 2: UI-assisted clone** — new `server_id` + `manual_entry`, no schema migration | Highest value-to-effort. Removes JSON-editing pain without touching OAuth/credential layer mid-migration. | +| 2 | Schema change | **Defer Option 3** (`instance_label` column) to optional Phase 5 | Architecturally cleaner long-term, but 2–3 weeks of migration risk across 5+ tables. Ship clones first; revisit when migration volume justifies it. | +| 3 | Clone identity | **`{base_server_id}-{suffix}`** where suffix is user-chosen (default suggestions: `work`, `personal`, `prod`) | Satisfies unique constraint. Hyphen suffix only — underscores are stripped by `normalize_server_id` and reserved as the tool-name delimiter. | +| 4 | Definition source | **Copy `cached_definition` from source install** into clone at creation time | Clone is self-contained for offline/gateway startup. Registry updates do not auto-propagate — acceptable tradeoff for v1; document in UI. | +| 5 | Prefix / alias | **Auto-set alias = suffix** (e.g. `posthog-work` → tools prefixed `posthog-work_*`) | Reuses `PrefixCacheService` first-come assignment. User can override alias in configure step. | +| 6 | Credentials | **Never copy secrets** — clone starts with empty `input_values` / no OAuth; user configures fresh | Prevents accidental credential sharing. Clone wizard opens configure flow immediately after create. | +| 7 | Source tracking | **`InstallationSource::ManualEntry`** + optional `cloned_from: Option` metadata on `InstalledServer` | Distinguishes registry installs from clones in UI (`SourceBadge`). `cloned_from` is display-only in v1 — not a FK. | +| 8 | Registry dedup UX | **"Add another account" disabled when source is already a clone-of-clone** (max depth 1) or when suffix collision detected | Prevents unbounded ID sprawl (`posthog-work-work-work`). Clones clone from registry/original only. | +| 9 | Spaces unchanged | **No change to Space model** — clone is per-Space like any install | Work/personal split via Spaces stays documented as primary pattern; clones are the escape hatch. | + +--- + +## The Model + +### What a clone is + +A clone is a **new `InstalledServer` row** in the same Space as the source, with: + +```text +InstalledServer { + server_id: "{base_id}-{suffix}", // e.g. "posthog-work" + server_name: "{display} ({suffix})", // e.g. "PostHog (work)" + cached_definition: , + input_values: {}, // empty — user fills in configure step + source: ManualEntry, + cloned_from: Some("{base_id}"), // new optional field, v1 display-only + enabled: false, // same default as registry install +} +``` + +Prefix resolution treats the clone as an independent server. Tool names become `{alias}_{tool}` (e.g. `posthog-work_capture_event`). + +### What a clone is NOT + +- Not a second OAuth session on the same `server_id` row +- Not a runtime credential swap (stdio env is fixed at process spawn) +- Not a registry duplicate — the registry still has one definition for `posthog`; clones are local installs + +### Composition with existing patterns + +```text +Multi-account need? +├─ MCP has per-call account param (Google Workspace) +│ └─ ONE install — no clone needed +├─ Accounts map to repo context (Personal / S2H / GAIT) +│ └─ Spaces — no clone needed +└─ Two+ accounts in SAME Space, single-account MCP + └─ Clone via "Add another account" (this feature) +``` + +--- + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ My Servers UI │ +│ │ +│ [PostHog ▼] Connected │ +│ ├─ Configure / Logs / … │ +│ └─ "Add another account…" ─────────────────────┐ │ +└────────────────────────────────────────────────────│────────────┘ + │ + ▼ + ┌──────────────────────────────────┐ + │ CloneAccountModal │ + │ • suffix input (work/personal/…) │ + │ • alias preview (posthog-work) │ + │ • collision check │ + └──────────────────────────────────┘ + │ + ▼ + ┌──────────────────────────────────┐ + │ ServerAppService::clone_server │ + │ 1. validate unique server_id │ + │ 2. copy cached_definition │ + │ 3. set alias in definition JSON │ + │ 4. install as ManualEntry │ + │ 5. emit ServerInstalled │ + └──────────────────────────────────┘ + │ + ┌────────────────────────────────┴───────────────┐ + ▼ ▼ + installed_servers row credentials row(s) + (new server_id) (empty until configure) + │ + ▼ + PrefixCache assigns alias on first enable/connect + │ + ▼ + Separate stdio process / OAuth flow with clone's creds +``` + +- **No gateway routing changes** — clone is a distinct `server_id`; existing prefix cache + routing already handle multiple servers in one Space. +- **FeatureSets** see clones as separate servers — user adds `posthog-work` to a FeatureSet independently. Future Option 3 could add "all instances of server X" grouping. +- **Meta tools** (`mcpmux_enable_server`) already accept any `server_id` string — clones work with session enable once the user knows the suffixed ID. Phase 3 adds optional `cloned_from` hint in `mcpmux_list_servers` response. + +--- + +## Files to create + +| File | Purpose | +| ---- | ------- | +| `apps/desktop/src/features/servers/CloneAccountModal.tsx` | Suffix input, alias preview, collision feedback, submit → Tauri command | +| `apps/desktop/src/lib/api/serverClone.ts` | TS wrappers: `cloneServer`, `suggestCloneSuffix`, `isCloneIdAvailable` | +| `apps/desktop/src-tauri/src/commands/server_clone.rs` | Tauri commands delegating to `ServerAppService::clone_server` | +| `tests/rust/tests/integration/server_clone.rs` | Clone creates distinct install, empty creds, unique prefix, collision rejection | +| `docs/planning/server-account-clones.md` | This doc | + +## Files to modify + +| File | Change | +| ---- | ------ | +| [`crates/mcpmux-core/src/domain/installed_server.rs`](../../crates/mcpmux-core/src/domain/installed_server.rs) | Add optional `cloned_from: Option`. Builder `with_cloned_from`. | +| [`crates/mcpmux-core/src/application/server.rs`](../../crates/mcpmux-core/src/application/server.rs) | `clone_server(space_id, source_server_id, suffix, alias_override?)` — copy definition, derive new ID, install as `ManualEntry`. | +| [`crates/mcpmux-storage/src/repositories/installed_server_repository.rs`](../../crates/mcpmux-storage/src/repositories/installed_server_repository.rs) | Serialize/deserialize `cloned_from` (new nullable column or JSON in existing row — see Phase 1). | +| [`crates/mcpmux-storage/src/migrations/`](../../crates/mcpmux-storage/src/migrations/) | New migration: `cloned_from TEXT` nullable on `installed_servers`. | +| [`apps/desktop/src/features/servers/ServerActionMenu.tsx`](../../apps/desktop/src/features/servers/ServerActionMenu.tsx) | Add "Add another account…" action; hidden for clones-of-clones. | +| [`apps/desktop/src/features/servers/ServersPage.tsx`](../../apps/desktop/src/features/servers/ServersPage.tsx) | Wire modal, group clones visually under source (optional Phase 2 polish). | +| [`apps/desktop/src/components/SourceBadge.tsx`](../../apps/desktop/src/components/SourceBadge.tsx) | Badge variant for cloned servers ("Clone of posthog"). | +| [`apps/desktop/src-tauri/src/lib.rs`](../../apps/desktop/src-tauri/src/lib.rs) | Register `clone_server`, `suggest_clone_suffix`, `is_clone_id_available` commands. | +| [`crates/mcpmux-gateway/src/services/meta_tools/tools.rs`](../../crates/mcpmux-gateway/src/services/meta_tools/tools.rs) | (Phase 3) Optional `cloned_from` field in `mcpmux_list_servers` payload. | +| [`docs/guide/servers.mdx`](../../docs/guide/servers.mdx) | Document multi-account patterns: Spaces vs native params vs clones. | + +--- + +## Phasing + +### Phase 1 — Core clone API + storage + +**Effort:** ~1 day + +- [x] Migration: `cloned_from TEXT NULL` on `installed_servers` +- [x] `InstalledServer.cloned_from` field + repo round-trip +- [x] `ServerAppService::clone_server`: + - Load source install + definition from `cached_definition` + - Derive `new_id = "{base}-{suffix}"` using same normalization as `UserServerEntry::normalize_server_id` + - Reject if `(space_id, new_id)` exists or source is missing + - Patch definition `alias` to suffix (or user override) + - Install via existing `install()` path with `ManualEntry` + `with_cloned_from(source_id)` +- [x] Unit tests: happy path, collision, missing source, suffix normalization (no underscores) +- [x] Tauri command `clone_server(space_id, source_server_id, suffix, alias?)` + +**Outcome:** `clone_server` from Tauri creates a disabled `posthog-work` install with copied definition, empty creds, and `cloned_from = "posthog"`. Verifiable via `list_installed_servers` and SQLite inspection. No UI yet. + +### Phase 2 — Clone wizard UI + +**Effort:** ~1 day + +- [x] `CloneAccountModal` — suffix field with suggestions (`work`, `personal`, `prod`, `staging`), live alias preview, inline collision error +- [x] `ServerActionMenu` → "Add another account…" on registry and manual installs (not on clones) +- [x] Post-clone flow: open existing `ConfigEditorModal` for credential entry before enable +- [x] `SourceBadge` shows clone lineage +- [ ] Optional: collapsed "Accounts" group on `ServersPage` when `cloned_from` matches same base (visual only, no schema) + +**Outcome:** User clicks "Add another account" on PostHog, enters suffix `work`, gets `posthog-work` card in My Servers, configures API key, enables — tools appear as `posthog-work_*` in gateway. No JSON editing. + +### Phase 3 — Meta-tool + docs surfacing + +**Effort:** ~0.5 day + +- [x] `mcpmux_list_servers` returns optional `cloned_from` for clone rows +- [x] `docs/guide/servers.mdx` section: "Multiple accounts" — decision tree (Spaces / native param / clone) +- [ ] Migration doc update in `jsg-tech-check` with concrete clone targets (PostHog, Gmail, Sheets, Firebase) — out of repo; deferred + +**Outcome:** LLM manifest shows clone lineage. Docs explain when to clone vs use a Space. Migration checklist has explicit suffix naming convention. + +### Phase 4 — Validation + edge cases + +**Effort:** ~0.5 day + +- [x] Integration test: two clones in one Space, distinct prefixes, both connect with different env +- [x] Uninstall clone does not affect source +- [x] Uninstall source warns if clones exist (list dependents, offer bulk uninstall) +- [x] Prefix collision: two different registry servers cannot claim same alias (existing behavior — verify clones don't break it) +- [x] `pnpm validate` + targeted Rust/TS tests + +**Outcome:** Clone lifecycle is safe through install → configure → enable → uninstall. Source/uninstall warnings prevent orphaned expectations. + +### Phase 5 — (Optional) First-class instances (Option 3) + +**Effort:** ~2–3 weeks — **defer until clone UX proves demand** + +- [ ] Schema: replace `UNIQUE(space_id, server_id)` with `UNIQUE(space_id, server_id, instance_label)` on `installed_servers`, `credentials`, `outbound_oauth_clients`, `server_features` +- [ ] `instance_label` default `"default"` for existing rows; migration backfills +- [ ] UI: one registry card with N instance sub-cards instead of flat clone list +- [ ] FeatureSet member type: `ServerInstance { server_id, instance_label }` for grouped grants +- [ ] Data migration: existing clones (`posthog-work`) → `(posthog, instance_label=work)` +- [ ] Log paths, OAuth refresh, event payloads gain instance dimension + +**Outcome:** Registry server is the template; instances are first-class. Clone IDs like `posthog-work` become legacy format migrated to structured instances. Only pursue if Phase 1–4 adoption shows ID-suffix sprawl or FeatureSet pain. + +--- + +## Out of scope + +| Item | Reason | +| ---- | ------ | +| Auto-sync clone definition when registry updates | Requires shared definition store or periodic refresh job. Defer; document "clone may drift from registry" in UI. Option 3 addresses properly. | +| Credential copy / "duplicate with same secrets" | Security footgun. User always re-enters creds on clone. | +| Runtime account switching on one process | Impossible for stdio env-at-startup servers. Not McpMux's layer to fix. | +| Wrapper MCP shims per backend | Per-server maintenance burden (Option 5 from brainstorm). Rejected. | +| Cross-Space clone | Install separately per Space — already works via Spaces. "Clone to another Space" is a nice follow-up, not v1. | +| Tool-level account selection injection | Would require MCP spec / client header support. Out of scope. | + +--- + +## Key files referenced + +| File | Why | +| ---- | --- | +| [`crates/mcpmux-core/src/application/server.rs`](../../crates/mcpmux-core/src/application/server.rs) | `install()` uniqueness check — clone must use a new `server_id`. | +| [`crates/mcpmux-core/src/domain/config.rs`](../../crates/mcpmux-core/src/domain/config.rs) | `normalize_server_id` / `normalize_alias` — suffix rules (no underscores). | +| [`crates/mcpmux-storage/src/migrations/001_initial.sql`](../../crates/mcpmux-storage/src/migrations/001_initial.sql) | Current `UNIQUE(space_id, server_id)` constraints clone works around. | +| [`crates/mcpmux-gateway/src/services/prefix_cache.rs`](../../crates/mcpmux-gateway/src/services/prefix_cache.rs) | Prefix assignment for clone's alias at connect time. | +| [`apps/desktop/src/features/servers/ServersPage.tsx`](../../apps/desktop/src/features/servers/ServersPage.tsx) | Primary UI surface for install/configure/enable flow. | +| [`apps/desktop/src/components/ConfigEditorModal.tsx`](../../apps/desktop/src/components/ConfigEditorModal.tsx) | Reused post-clone credential entry. | +| [`docs/guide/spaces.mdx`](../../docs/guide/spaces.mdx) | Canonical work/personal separation — clones complement, not replace. | + +--- + +## Related documentation + +- [`docs/planning/dynamic-mcp-toggle-meta-tools.md`](./dynamic-mcp-toggle-meta-tools.md) — session enable works with clone `server_id`s once user knows the suffixed name; Phase 3 links them. +- [`docs/guide/servers.mdx`](../../docs/guide/servers.mdx) — server management baseline; gets multi-account section in Phase 3. +- [`docs/guide/spaces.mdx`](../../docs/guide/spaces.mdx) — primary pattern for context-level account separation. +- Personal migration tracker: `jsg-tech-check/docs/setup/mcpmux-server-migration.md` — consuming checklist for PostHog, Gmail, Sheets, Firebase clones. + +--- + +## Reconciliation + +This doc is the source of truth for server account clones. When implementation starts, update **Status** and **Branch** at the top. Phase 5 remains optional — do not block Phases 1–4 on it. + +**Decision record (May 23, 2026):** Option 2 (UI-assisted clone) selected over status quo, first-class instances (deferred Phase 5), per-client credential override (rejected), and wrapper meta-servers (rejected). Brainstorm source: Cursor session on multi-account MCP patterns. + +**May 23, 2026 closeout:** Phases 1–4 implemented and verified in dev. Consumed by [mcpmux-server-migration.md](../../../jsg-tech-check/docs/setup/mcpmux-server-migration.md) Phase B2 (clone sprint). Phase 5 remains optional until clone ID sprawl or FeatureSet grouping pain justifies schema migration. diff --git a/docs/planning/server-display-rename.md b/docs/planning/server-display-rename.md new file mode 100644 index 00000000..88a60004 --- /dev/null +++ b/docs/planning/server-display-rename.md @@ -0,0 +1,132 @@ +# Server Display Rename + +**Last Updated:** May 25, 2026 +**Status:** Complete — Phases 1–4 shipped +**Branch:** `feat/server-account-clones` (continuation of clone work) +**Base branch:** `main` +**Depends on:** [Server Account Clones](server-account-clones.md) (Phase 1–4 shipped) +**Unblocks:** Multi-account UX — distinguishing two installs of the same MCP (e.g. two Google Calendars) without renaming `server_id` + +--- + +## Problem + +Once a Space has multiple installs of the same MCP — either via the clone wizard or a hand-edited user-config JSON — the only thing telling them apart in the UI is the cached definition name (`Google Calendar`, `Google Calendar (work)`). That string lives on `cached_definition` and `server_name`, both of which the user cannot edit and both of which `user_space_sync` overwrites on every config sync. + +Users want a friendly per-install label that: + +- They can edit any time, not just at clone time. +- Survives later definition refreshes (registry pulls, user-config sync). +- Does not affect routing — `server_id`, alias, or tool prefixes stay locked once chosen, otherwise existing tool names break. + +--- + +## Decisions + +| # | Decision | Choice | Rationale | +| - | -------- | ------ | --------- | +| 1 | Identity vs label | **Display label only now**; `server_id` / alias / tool prefixes unchanged | Renaming the ID would invalidate every prompt, binding, and tool name referencing the server. Labels are reversible; IDs are not. | +| 2 | Storage shape | New nullable `display_name_override` column on `installed_servers` (migration 018) | `server_name` is overwritten on every user-config sync, so the override needs its own column to survive. | +| 3 | Eligible installs | Registry, manual, clones, and user-config installs are all renamable | Users want to label *any* install — not just clones. | +| 4 | Uniqueness | Duplicate display names allowed in a space | The `server_id` is already unique; labels are pure UX. | +| 5 | Configure surface | Display name field at top of the Configure modal, with helper text "Shown in My Servers only. Does not change the server ID or tool names." | Single place users already go to edit a server. | +| 6 | Clone wizard | Optional freeform display name + existing suffix (suffix still drives `server_id` / alias) | Lets users pick a friendly name at clone time without giving up suffix-driven routing. | +| 7 | Always-show Configure | Action menu shows Configure (renamed to "Settings" when there are no inputs) for every installed server | Servers without credential inputs still need a way to be renamed. | +| 8 | Meta-tool surfacing | `mcpmux_list_servers` reports the effective display name (override → `server_name` → `server_id` tail) | Agents see "Joe Calendar" instead of the catalog name when the user has renamed an install. | + +--- + +## The Model + +```mermaid +flowchart LR + subgraph storage [Storage] + override["display_name_override"] + server_name["server_name / cached_definition"] + end + subgraph ui [Desktop UI] + configure["Configure modal"] + clone["Clone wizard"] + list["My Servers list / search"] + end + subgraph api [API] + save["save_server_inputs"] + set_name["set_server_display_name"] + clone_api["clone_server"] + end + configure --> save --> override + clone --> clone_api --> override + configure --> set_name --> override + override --> list + server_name --> list +``` + +`InstalledServer::display_name()` is the single source of truth for the effective label: + +```rust +override + .or(server_name) + .or(server_id.split('/').last()) +``` + +The frontend `resolveInstalledDisplayName` mirrors that precedence so every code path agrees on what to render. + +--- + +## File Inventory + +### Phase 1 — Storage and domain (✅) + +- `crates/mcpmux-storage/src/migrations/018_installed_server_display_name_override.sql` — new column. +- `crates/mcpmux-storage/src/database.rs` — register migration 018. +- `crates/mcpmux-core/src/domain/installed_server.rs` — `display_name_override` field, `display_name()` precedence, `with_display_name_override` helper, unit tests. +- `crates/mcpmux-storage/src/repositories/installed_server_repository.rs` — INSERT/UPDATE/SELECT round-trip and `set_display_name_override`. +- `crates/mcpmux-core/src/repository/mod.rs` — trait method `set_display_name_override`. +- `tests/rust/src/mocks.rs` — mock repo impl. + +### Phase 2 — Application service and Tauri (✅) + +- `crates/mcpmux-core/src/application/server.rs` — `clone_server` accepts optional `display_name_override`; `update_config` accepts optional `display_name_override` (None = leave alone, Some(empty) = clear, Some(value) = set); new `set_display_name_override` service method; unit tests covering set/clear via update_config and the dedicated method, plus clone-with-display-name. +- `apps/desktop/src-tauri/src/commands/server.rs` — `save_server_inputs` extended with `display_name_override`; new `set_server_display_name` command. +- `apps/desktop/src-tauri/src/commands/server_clone.rs` — `clone_server` extended with optional `display_name`. +- `apps/desktop/src-tauri/src/lib.rs` — register `set_server_display_name`. + +### Phase 3 — Frontend (✅) + +- `apps/desktop/src/types/registry.ts` — `display_name_override` on `InstalledServerState`. +- `apps/desktop/src/features/servers/server-display-name.helpers.ts` — new `resolveInstalledDisplayName` helper. +- `apps/desktop/src/features/servers/ServersPage.tsx` — view-model merge paths (`mergeDefinitionsWithStates`, `createOfflineServerViewModel`, `createViewModelFromClone`) use the helper; `ConfigModalState` carries `displayName` / `initialDisplayName`; modal seeds and persists the field via `saveServerInputs`. +- `apps/desktop/src/features/servers/ServerActionMenu.tsx` — Configure action always shown ("Settings" when no inputs). +- `apps/desktop/src/features/servers/CloneAccountModal.tsx` — optional Display name field, defaults to `{Source} ({suffix})` placeholder, sent to `clone_server`. +- `apps/desktop/src/features/servers/UninstallSourceWithClonesDialog.tsx` — dependent labels resolved through the helper. +- `apps/desktop/src/lib/api/registry.ts` — extended `saveServerInputs`; new `setServerDisplayName`. +- `apps/desktop/src/lib/api/serverClone.ts` — `cloneServer` accepts `displayName`. +- `apps/desktop/src/stores/registryStore.ts` — `mergeServers` uses the helper for installed rows. + +### Phase 4 — Meta tools (✅) + +- `crates/mcpmux-gateway/src/services/meta_tools/tools.rs` — `mcpmux_list_servers` builds `server_id → InstalledServer.display_name()` lookup and prefers it over feature-derived names. + +--- + +## Out of scope + +- Changing `server_id`, clone suffix, or tool prefix (`alias_*`) via rename UI. +- Phase 5 "first-class instances" (`instance_label` schema) from [server-account-clones.md](server-account-clones.md). +- Syncing the override back into user-space JSON `label` field (future enhancement). + +## Risks and mitigations + +| Risk | Mitigation | +|------|------------| +| UI still shows catalog name in some surface | Centralized `resolveInstalledDisplayName` + every merge path fixed | +| User-config sync clobbers the label | `display_name_override` lives in its own column; sync only refreshes `server_name` / `cached_definition` | +| Configure unreachable for no-input servers | Action menu always shows Configure (renamed to "Settings") | +| Tool names break after rename | Rename only updates the label; `server_id` and alias are immutable in v1 | + +## Validation + +- `cargo nextest run -p mcpmux-core -p mcpmux-storage` — all tests including new override coverage pass. +- `cargo nextest run -p tests --test integration server_clone` — clone integration tests still pass with the new parameter. +- `pnpm typecheck` — passes. +- `pnpm validate` should be re-run before merge for full clippy + ESLint + formatting coverage. diff --git a/docs/planning/tool-level-session-pin.md b/docs/planning/tool-level-session-pin.md new file mode 100644 index 00000000..31abeee2 --- /dev/null +++ b/docs/planning/tool-level-session-pin.md @@ -0,0 +1,327 @@ +# Tool-Level Session Pin Meta Tool (`mcpmux_pin_this_session`) + +**Last Updated:** May 25, 2026 +**Status:** Deferred — superseded by [`meta-gateway-invoke.md`](./meta-gateway-invoke.md) for token budget. May revive as Phase F (invoke ACL only) if search + invoke proves insufficient. +**Branch:** TBD — branch off `main` once [`dynamic-mcp-toggle-meta-tools`](./dynamic-mcp-toggle-meta-tools.md) merges +**Base branch:** `main` (depends on the meta-tools infrastructure shipped via the dynamic-toggle PR being live) +**Issue:** TBD — file after planning review +**Depends on:** [`dynamic-mcp-toggle-meta-tools.md`](./dynamic-mcp-toggle-meta-tools.md) — provides `SessionOverrideRegistry`, `MetaToolContext`, per-peer `tools/list_changed` plumbing, and the `gateway.session_overrides_require_approval` setting reused here +**Supersedes:** Out-of-Scope row #1 in [`dynamic-mcp-toggle-meta-tools.md`](./dynamic-mcp-toggle-meta-tools.md) — *"Tool-level granularity… no real evidence yet that tool-level matters more than server-level for token budget"* +**Unblocks:** Per-chat tool-budget control for high-tool-count installs (Google Workspace 120 tools, cloned ×2 = 240) — see [`server-account-clones.md`](./server-account-clones.md) + +--- + +## Problem + +The May 23 `dynamic-mcp-toggle-meta-tools` planning doc deferred tool-level granularity on the grounds that *server-level enable/disable covered the user's stated use case* and there was *no real evidence yet that tool-level mattered more than server-level for token budget*. That evidence now exists. + +[`server-account-clones.md`](./server-account-clones.md) shipped Phase 1–4 in mid-May, and the first heavy real-world install — Google Workspace cloned for a Personal + S2H account split — exposed the gap: + +| Symptom | Concrete number | +| ------- | --------------- | +| Tools per Google Workspace install | 120 | +| Installs after the account clone | 2 | +| Tools surfaced to a Cursor session that needs both accounts | 240 | +| Approximate system-prompt tokens consumed by those 240 tool definitions | ~30–50k | + +Server-level overrides do not help here: both clones are wanted in the session, so neither can be disabled. `mcpmux_create_feature_set` + `mcpmux_bind_current_workspace` can carve a persistent subset, but persisting a tool list per workspace is the wrong shape for *"for the next 10 minutes I only need Gmail send + read + 8 calendar tools, drop the other 230"* — the LLM needs an ephemeral knob it can flip per task, not a binding. + +This doc fills that gap with **one new write tool plus a small clear tool**, both session-scoped (with an opt-in workspace-scope path that reuses the existing FeatureSet + binding plumbing). The killer detail: the plumbing for this is already wired — `SessionOverrideRegistry` exists, `FeatureService::get_tools_for_grants` is already the materialization chokepoint, per-peer `tools/list_changed` already fires after meta-tool writes. The change is additive: one new field on the registry, one new filter step at the bottom of the existing composition rule, two new `MetaTool` impls. + +A note on naming: `mcpmux_pin_this_session` is referenced verbatim in the meta-tools module docstring, in `SettingsPage.tsx`, in the approval-broker tests, and in the WDIO meta-tools spec — leftover from an early draft when the tool was intended to ship with the original meta-tools work. Reusing the name keeps every existing reference truthful instead of forcing a docs-only renaming pass. + +--- + +## Decisions + +| # | Decision | Choice | Rationale | +| - | -------- | ------ | --------- | +| 1 | New tool vs extend existing | **One new write tool (`mcpmux_pin_this_session`) + one new write tool (`mcpmux_clear_session_pin`)** — do not overload `enable_server`/`disable_server` | Server-level and tool-level have different composition semantics (additive overlay vs exclusive replacement). Conflating them would force every call site to disambiguate. Two narrowly-scoped tools keep the surface clean. | +| 2 | Composition with existing overrides | **Exclusive replacement applied AFTER server-level composition.** Effective = `(binding_servers ∪ session_on) − session_off`, materialize tools, then if `pinned_tools` non-empty: filter to `tools ∩ pinned_tools`. | Pin semantically means "lock to this set." Layering it on top means a pin to `["github_create_issue"]` works whether `github` is enabled via binding, via session-enable, or both — the user expresses intent (which tools they want) without caring about how those tools got into the candidate pool. | +| 3 | Empty list semantics | **Empty `tool_qualified_names` rejected as invalid argument** — clearing is done via `mcpmux_clear_session_pin` | Avoids two paths for the same operation. Reject-on-empty matches `mcpmux_create_feature_set` which already rejects empty `tool_qualified_names`. | +| 4 | Explicit clear vs implicit | **Dedicated `mcpmux_clear_session_pin` tool** — no args, operates on caller's session | Forces an intentional unpin, audited like any other write. Letting "pin with full list" act as clear would require the LLM to call `list_all_tools` first just to undo — wasteful and ambiguous. | +| 5 | Naming | **`mcpmux_pin_this_session`** | Matches every existing stale reference (mod.rs docstring, SettingsPage copy, approval tests, WDIO spec). The "this_session" suffix makes scope intuitive — ergonomic mirror to the existing `mcpmux_bind_current_workspace`. | +| 6 | Scope support | **`scope: "session"` (default) + `scope: "workspace"`** mirroring `mcpmux_enable_server` | Session is the ephemeral knob. Workspace persists the same pin set via a new auto-named custom FeatureSet bound to the caller's first reported root — single approval-gated atomic op instead of two-step `create_feature_set` + `bind_current_workspace`. | +| 7 | Workspace-scope FeatureSet naming | **Auto-named `"Pinned: {root_basename} {YYYY-MM-DD HH:MM}"`** unless caller supplies optional `name` arg | Discoverable in the Workspaces UI after creation. Optional override lets the LLM name it meaningfully (`"Gmail send-only"`) when the user prompt makes intent clear. | +| 8 | Approval default | **Session: auto-allow, gated by existing `gateway.session_overrides_require_approval`. Workspace: always required via `ApprovalBroker`.** | Reuses the existing setting — no new toggle. Session pin is ephemeral (dies with the MCP session) so the same risk profile as `enable_server`/`disable_server` applies. Workspace persists state, so it always shows the approval dialog with the full tool list in the diff. | +| 9 | Override store layout | **Extend `SessionOverrideRegistry` with `pinned_tools: DashMap>`** — do not introduce a sibling registry | Same lifecycle (process-only, dies with session reap), same factory, same GC hook in `MCPNotifier`. Keeping it co-located concentrates session-scoped override state in one struct so `reap_dead_sessions` only needs to know about one type. | +| 10 | Validation timing | **Validate every qualified name exists in caller's resolved Space at call time** (look up via `server_feature_repo::list_for_space`) | Rejects typos and stale tool names up-front with a clear error. Storing unresolved names would let the pin silently shrink to nothing if a server becomes unavailable. | +| 11 | Audit decision string | **`"session_pin"` for session-scope writes, `"allow_once"` for workspace-scope approvals** | Distinct from `"session_override"` used by enable/disable so the audit log + Workspaces UI can render pin-specific rows ("pinned 12 tools" vs "enabled github"). | + +--- + +## The Model + +### Override store extension + +```text +SessionOverrideRegistry { + enabled : DashMap>, // existing + disabled : DashMap>, // existing + pinned_tools : DashMap>, // NEW +} +``` + +`pinned_tools[sid]` stores fully qualified tool names (e.g. `"github_create_issue"`, `"google-workspace-mcp-uvx_send_email"`) — the same format `mcpmux_list_all_tools` returns and `mcpmux_create_feature_set` accepts. Empty/missing = no pin = pass-through. + +GC: `SessionOverrideRegistry::remove(session_id)` extended to drop `pinned_tools[sid]` alongside the existing two sets. `MCPNotifier::reap_dead_sessions` already calls `remove` per reaped session — no notifier change needed. + +### Composition rule + +The existing composition in `FeatureService::get_tools_for_grants` runs steps 1–6; this doc adds steps 7–8: + +```text +1. (space, feature_set_ids) ← FeatureSetResolverService::resolve(...) +2. binding_servers ← FeatureService::servers_for(space, feature_set_ids) +3. session_on ← SessionOverrideRegistry.enabled[session_id] +4. session_off ← SessionOverrideRegistry.disabled[session_id] +5. effective_servers ← (binding_servers ∪ session_on) − session_off +6. base_tools ← every Tool feature whose server_id ∈ effective_servers AND is_available + +7. pinned ← SessionOverrideRegistry.pinned_tools[session_id] +8. if pinned is non-empty: + tools ← base_tools.filter(qualified_name ∈ pinned) + else: + tools ← base_tools +``` + +The pin filter applies only to **tools**. Prompts and resources are unaffected — pinning is a tool-budget concept, not a capability-restriction concept. `get_prompts_for_grants` and `get_resources_for_grants` skip steps 7–8. + +A pin that resolves to zero matches (every name filtered out by `is_available`) returns an empty tool list. The caller's `tools/list` will show no tools; calling `mcpmux_clear_session_pin` is the recovery path. This is intentional — silent fall-through to `base_tools` on empty intersection would mask user error. + +### Tool surface + +Two new tools registered in `build_default_registry`: + +| Tool | Type | Approval (default) | Purpose | +| ---- | ---- | ------------------ | ------- | +| `mcpmux_pin_this_session` | write | session: auto-allow (configurable via `gateway.session_overrides_require_approval`); workspace: required | Replace the caller's session tool list with the explicit qualified-name set. Optional `scope: "workspace"` persists as a custom FeatureSet + binding. | +| `mcpmux_clear_session_pin` | write | auto-allow | Drop the caller's pin; next `tools/list` returns the full default-routed set. | + +Both fire `MCPNotifier::notify_session_lists_changed(session_id)` after a successful write — the calling LLM sees the new (or restored) tool list on its next `tools/list` poll. + +### Workspace-scope variant flow + +`mcpmux_pin_this_session({ tool_qualified_names: [...], scope: "workspace", name?: "..." })` is sugar for the two-call sequence the user would otherwise run: + +```text +1. Validate every qualified_name exists in caller's Space +2. Resolve caller's first reported workspace root (require session_roots; reject if missing) +3. Open approval dialog with diff: { root, new FS name, full tool list } +4. On allow: + a. Create custom FeatureSet with the matched ServerFeature ids (using add_feature_member + MemberMode::Include) + b. Create WorkspaceBinding(root, space_id, feature_set_id) + c. Emit FeatureSetMembersChanged and WorkspaceBindingChanged so the resolver picks it up +5. Return { ok: true, feature_set_id, binding_id, scope: "workspace" } +``` + +The same path `create_feature_set` + `bind_current_workspace` exercises, run atomically as one approval. Failure between (4a) and (4b) leaves a custom FeatureSet without a binding — the existing Workspaces UI already handles unbound custom FSes (they appear in the list and can be bound or deleted), so no compensating cleanup is needed. + +### What McpMux stores + +| Item | Storage | Persistence | +| ---- | ------- | ----------- | +| `pinned_tools` set | `SessionOverrideRegistry` (in-memory `DashMap`) | Process-lifetime; dies with session reap | +| Workspace-scope pin | new custom `FeatureSet` row + new `workspace_bindings` row | Persistent (uses existing schema) | +| Audit trail | `DomainEvent::MetaToolInvoked` with `decision: "session_pin"` or `"allow_once"` | Persistent via existing audit log | + +No new tables, columns, or migrations. + +--- + +## Architecture + +``` + ┌──────────────────────────────────────────────┐ + │ FeatureService::get_tools_for_grants │ + │ │ + │ 1–6. Server composition (existing) │ + │ effective_servers = │ + │ (binding ∪ session_on) − session_off │ + │ base_tools = tools for servers │ + │ │ + │ 7–8. NEW: tool-pin filter │ + │ if pinned_tools[sid] non-empty: │ + │ tools = base_tools ∩ pinned_tools │ + │ else: │ + │ tools = base_tools │ + └──────────────────────────────────────────────┘ + ▲ + │ + ┌───────────────┴───────────────┐ + │ │ + ▼ ▼ + ┌──────────────────────┐ ┌──────────────────────────────┐ + │ SessionOverride- │ │ Meta tool writes mutate this │ + │ Registry (extended) │ │ registry directly. │ + │ │ │ │ + │ enabled │ │ mcpmux_pin_this_session │ + │ disabled │ │ mcpmux_clear_session_pin │ + │ pinned_tools ← NEW │ │ │ + └──────────────────────┘ └──────────────────────────────┘ +``` + +- `SessionOverrideRegistry` lives where it already does (`crates/mcpmux-gateway/src/services/session_overrides.rs`). One new field, three new methods (`pin`, `clear_pin`, `pinned_set`). Existing `remove(session_id)` extended to clear the new map. +- `FeatureService::get_tools_for_grants` gains steps 7–8. The sibling `get_prompts_for_grants` / `get_resources_for_grants` are intentionally untouched — pin is tool-only. +- The two new `MetaTool` impls land in `meta_tools/tools.rs` alongside the existing five write tools. Same `with_approval()` template, same `caller_space_id` / `validate_server_in_space` helpers — workspace variant calls into a new `workspace_pin::pin_workspace` helper module mirroring `workspace_server.rs`. + +--- + +## Files to create + +| File | Purpose | +| ---- | ------- | +| [`crates/mcpmux-gateway/src/services/meta_tools/workspace_pin.rs`](../../crates/mcpmux-gateway/src/services/meta_tools/workspace_pin.rs) | `pin_workspace(call, space_id, qualified_names, name) -> CallToolResult` — atomic create-FS + bind-workspace under one approval, mirroring `workspace_server.rs` | +| [`tests/rust/tests/integration/tool_pin.rs`](../../tests/rust/tests/integration/tool_pin.rs) | E2E composition + meta-tool tests for pin / clear / workspace-scope | + +## Files to modify + +| File | Change | +| ---- | ------ | +| [`crates/mcpmux-gateway/src/services/session_overrides.rs`](../../crates/mcpmux-gateway/src/services/session_overrides.rs) | Add `pinned_tools: DashMap>` field. Methods: `pin(session_id, names)`, `clear_pin(session_id)`, `pinned_set(session_id) -> HashSet`. Extend `remove()` to drop the entry. Extend `list_all()` to surface pinned counts for UI | +| [`crates/mcpmux-gateway/src/pool/features/facade.rs`](../../crates/mcpmux-gateway/src/pool/features/facade.rs) | `get_tools_for_grants` applies the pin filter as the final step. `get_prompts_for_grants` / `get_resources_for_grants` unchanged | +| [`crates/mcpmux-gateway/src/services/meta_tools/tools.rs`](../../crates/mcpmux-gateway/src/services/meta_tools/tools.rs) | Implement `PinThisSessionTool` and `ClearSessionPinTool`. Session path mirrors `EnableServerTool` (optional approval). Workspace path delegates to `workspace_pin::pin_workspace`. Validation via existing `server_feature_repo::list_for_space` | +| [`crates/mcpmux-gateway/src/services/meta_tools/mod.rs`](../../crates/mcpmux-gateway/src/services/meta_tools/mod.rs) | `pub mod workspace_pin;` + `registry.register(Box::new(tools::PinThisSessionTool))` and `registry.register(Box::new(tools::ClearSessionPinTool))` in `build_default_registry`. Update module-level docstring example (currently references `mcpmux_pin_this_session` aspirationally — now real) | +| [`crates/mcpmux-gateway/src/services/meta_tools/registry.rs`](../../crates/mcpmux-gateway/src/services/meta_tools/registry.rs) | Document `"session_pin"` as a valid `decision` value in the audit-emission block; no other change | +| [`apps/desktop/src/features/settings/SettingsPage.tsx`](../../apps/desktop/src/features/settings/SettingsPage.tsx) | Update meta-tools description from `"mcpmux_list_all_tools, mcpmux_pin_this_session, and 6 others"` to `"mcpmux_list_all_tools, mcpmux_pin_this_session, and 7 others"` (count goes from 7 → 9 registered tools) | +| [`apps/desktop/src/features/workspaces/WorkspacesPage.tsx`](../../apps/desktop/src/features/workspaces/WorkspacesPage.tsx) | Extend "Active session overrides" panel with a "Pinned tools" row per session — shows count + expandable list, plus a "Clear pin" button calling a new Tauri command | +| [`apps/desktop/src-tauri/src/commands/session_overrides.rs`](../../apps/desktop/src-tauri/src/commands/session_overrides.rs) | Extend `list_session_overrides` return shape with `pinned_tools: string[]`. Add `clear_session_pin(session_id)` Tauri command | +| [`apps/desktop/src/lib/api/sessionOverrides.ts`](../../apps/desktop/src/lib/api/sessionOverrides.ts) | TS wrapper update: extend return type with `pinned_tools` field; add `clearSessionPin(sessionId)` | +| [`README.md`](../../README.md) | Self-management meta tools section gains the pin tool — keep the section format the existing dynamic-toggle work established | + +--- + +## Phasing + +### Phase 1 — Registry extension + composition wiring + +**Effort:** 1 evening + +- [ ] Add `pinned_tools: DashMap>` to `SessionOverrideRegistry` +- [ ] Methods: `pin(session_id, names)` (replaces any existing pin), `clear_pin(session_id)`, `pinned_set(session_id) -> HashSet` +- [ ] Extend `remove(session_id)` to drop `pinned_tools` alongside `enabled` / `disabled` +- [ ] Extend `list_all()` snapshot to include pinned counts so the UI panel can render without extra calls +- [ ] `FeatureService::get_tools_for_grants(session_id)` applies the pin filter as the final step (steps 7–8 in the composition rule above) +- [ ] Unit tests on the registry; composition test in `tests/rust/tests/integration/tool_pin.rs` exercising: no pin → full list, pin with subset → filtered, pin with non-matching names → empty, clear → restored + +**Outcome:** Direct registry mutation (`registry.pin("sess-1", ["github_create_issue"])`) causes the next `get_tools_for_grants("sess-1", grants)` to return exactly one tool, regardless of the binding's normal output. Meta tools and UI unchanged. + +### Phase 2 — `mcpmux_pin_this_session` (session scope only) + +**Effort:** 1 evening + +- [ ] `PinThisSessionTool` in `meta_tools/tools.rs` — args `{ tool_qualified_names: string[], scope?: "session" | "workspace", name?: string }` +- [ ] Validate `tool_qualified_names` non-empty (else `InvalidArgument`) +- [ ] Validate every qualified name resolves to an available `Tool` feature in the caller's resolved Space — collect the mismatches and surface them in the error message so the LLM can recover without guessing +- [ ] Session path: optional approval via `gateway.session_overrides_require_approval` → `registry.pin(sid, names)` → set audit decision to `"session_pin"` → emit per-peer `tools/list_changed` via `MCPNotifier::notify_session_lists_changed` +- [ ] Workspace path: return `InvalidArgument("workspace scope ships in Phase 4")` for now +- [ ] Register in `build_default_registry` +- [ ] Integration tests: pin reduces tool list, `list_changed` fires, invalid name returns descriptive error, approval-required setting routes through `ApprovalBroker` + +**Outcome:** LLM calls `mcpmux_pin_this_session({ tool_qualified_names: ["google-workspace-mcp-uvx_send_email", "google-workspace-mcp-uvx_list_calendar_events"] })` and the next `tools/list` returns exactly those two tools instead of 240. Cursor's UI updates without restarting the session. + +### Phase 3 — `mcpmux_clear_session_pin` + +**Effort:** 1 hour + +- [ ] `ClearSessionPinTool` — no args; operates on caller's `session_id` +- [ ] Calls `registry.clear_pin(sid)`; idempotent (clearing an unpinned session is `ok: true` with `was_pinned: false`) +- [ ] No approval gate even when `session_overrides_require_approval` is on — clear is always safe (broadens scope) +- [ ] Audit decision `"session_pin_cleared"` +- [ ] Emit per-peer `tools/list_changed` +- [ ] Register in `build_default_registry` +- [ ] Integration test: pin then clear restores full set + +**Outcome:** LLM can recover from a bad pin (e.g. pinned the wrong tool name and now has 0 visible tools) without restarting the session. + +### Phase 4 — Workspace-scope variant + +**Effort:** 1 day + +- [ ] `crates/mcpmux-gateway/src/services/meta_tools/workspace_pin.rs` — `pin_workspace(call, space_id, qualified_names, name)` +- [ ] Resolve caller's first reported root via `session_roots.get(sid)` — reject with descriptive error if no roots reported +- [ ] Auto-name FS as `"Pinned: {root_basename} {YYYY-MM-DD HH:MM}"` unless caller supplied `name` +- [ ] Build approval diff payload: `{ workspace_root, feature_set_name, added_tools: [first ~10 qualified names], total_count }` +- [ ] `with_approval` → on allow: create custom `FeatureSet` with matched `ServerFeature` ids (using `add_feature_member` + `MemberMode::Include`) → create `WorkspaceBinding` → emit `FeatureSetMembersChanged` and `WorkspaceBindingChanged` +- [ ] Audit decision `"allow_once"` (matches workspace writes) +- [ ] Return `{ ok: true, feature_set_id, binding_id, workspace_root, scope: "workspace", tool_count }` +- [ ] Integration test: workspace-scope pin survives simulated session restart (new session for the same root resolves through the new binding), unbound caller rejected, name override respected + +**Outcome:** A single approval-gated tool call persists a custom tool subset for the caller's workspace cwd. Equivalent to `create_feature_set` + `bind_current_workspace` chained, with one approval and one audit row instead of two. + +### Phase 5 — UI surface + doc cleanup + +**Effort:** 0.5 day + +- [ ] Workspaces page session inspector gains "Pinned tools (N)" row per session; expanding shows the full qualified-name list +- [ ] "Clear pin" button next to the existing per-session "Clear all overrides" button — calls `clearSessionPin` from `lib/api/sessionOverrides.ts` +- [ ] Settings copy update in `SettingsPage.tsx`: bump "and 6 others" to "and 7 others" so the displayed count matches the registry +- [ ] `meta_tools/mod.rs` module docstring no longer needs the aspirational `mcpmux_pin_this_session` reference qualifier — leave it as-is (the example is now accurate) +- [ ] README self-management meta-tools section gains `mcpmux_pin_this_session` + `mcpmux_clear_session_pin` entries; keep the table format the existing dynamic-toggle work established +- [ ] CHANGELOG: release-please handles it via conventional `feat(meta-tools): add tool-level session pin` commit — no manual edit + +**Outcome:** A user viewing the Workspaces page can see "session abc123 is pinned to 12 tools" with the tool names expandable and a one-click clear. Settings copy is honest about the registered tool count. README and module docs no longer reference a tool that doesn't exist. + +--- + +## Pre-PR validation + +Do **not** open a PR until all automated checks pass and the production build is verified manually. + +| Step | Command | Purpose | +| ---- | ------- | ------- | +| Full validate | `pnpm validate` | fmt, clippy, check, eslint, typecheck | +| Rust tests | `pnpm test:rust` | unit + integration (`tool_pin.rs`) | +| TS tests | `pnpm test:ts` | vitest | +| Production build | `pnpm build` | Tauri build on current platform | +| Manual smoke | Run app, exercise pin + clear from a real MCP client (Cursor), verify Workspaces panel reflects state, verify approval dialog content for workspace scope | UX verification — the diff payload's tool-list rendering is easy to regress | + +Optional (slow / env-dependent): `pnpm test:e2e`, `pnpm test:e2e:web`. + +**PR target:** `main` (assumes `dynamic-mcp-toggle-meta-tools` is already merged). + +--- + +## Out of scope + +| Item | Reason | +| ---- | ------ | +| Pin expiry / TTL | Sessions are already ephemeral. Explicit `mcpmux_clear_session_pin` covers the cleanup path. A TTL would be a different concept and isn't asked for. | +| Wildcard / glob support in pin lists (`google-workspace-mcp-uvx_*`) | Explicit qualified names are unambiguous and the LLM can produce them by reading `mcpmux_list_all_tools` first. Wildcard support is additive — defer until a real use case shows up. | +| Multi-pin layering (stack multiple pin sets, additive) | Single replacement set keeps semantics crisp. Layering would need ordering rules and conflict resolution — not justified by the current evidence. | +| Pin export to a portable template (across workspaces or users) | Workspace-scope variant already creates a persisted custom FeatureSet; sharing those across workspaces is a separate "FeatureSet templates" feature not yet scoped. | +| LLM-driven pin recommendations (host suggests "based on this prompt, here are the 10 tools you need") | That's a host-side concern (Cursor / Claude Code), not a gateway concern. Gateway exposes the mechanism; host decides when to use it. | +| Pin applied to prompts / resources | Pin is tool-budget-shaped. Prompts/resources are not the bottleneck. Keep the surface narrow. | +| Settings UI toggle for `mcpmux_pin_this_session` independent of other session overrides | Reuses the existing `gateway.session_overrides_require_approval` setting deliberately — adding per-tool toggles balloons the settings surface for no clear benefit. Revisit if user feedback diverges. | + +--- + +## Key files referenced + +| File | Why | +| ---- | --- | +| [`crates/mcpmux-gateway/src/services/session_overrides.rs`](../../crates/mcpmux-gateway/src/services/session_overrides.rs) | Registry being extended. Existing `enabled` / `disabled` field shape and `remove()` GC contract are the template for the new `pinned_tools` field | +| [`crates/mcpmux-gateway/src/services/meta_tools/tools.rs`](../../crates/mcpmux-gateway/src/services/meta_tools/tools.rs) | Where the two new `MetaTool` impls land. `EnableServerTool` / `DisableServerTool` are the closest templates (session-scope short-circuit + workspace delegation pattern) | +| [`crates/mcpmux-gateway/src/services/meta_tools/workspace_server.rs`](../../crates/mcpmux-gateway/src/services/meta_tools/workspace_server.rs) | Pattern reference for `workspace_pin.rs` — shows how workspace-scope writes resolve roots, build approval payloads, and create/modify bindings atomically | +| [`crates/mcpmux-gateway/src/services/meta_tools/mod.rs`](../../crates/mcpmux-gateway/src/services/meta_tools/mod.rs) | `build_default_registry` factory — registration site for the new tools. Module-level docstring already mentions `mcpmux_pin_this_session` as the canonical example; this PR makes that mention accurate | +| [`crates/mcpmux-gateway/src/services/meta_tools/registry.rs`](../../crates/mcpmux-gateway/src/services/meta_tools/registry.rs) | `MetaToolContext` + `MetaToolRegistry::call` dispatch. Audit-decision string list extended with `"session_pin"` / `"session_pin_cleared"` | +| [`crates/mcpmux-gateway/src/pool/features/facade.rs`](../../crates/mcpmux-gateway/src/pool/features/facade.rs) | `FeatureService::get_tools_for_grants` is the materialization chokepoint where the pin filter applies. Existing server composition logic stays untouched — the filter is one new conditional block at the bottom | +| [`crates/mcpmux-gateway/src/consumers/mcp_notifier.rs`](../../crates/mcpmux-gateway/src/consumers/mcp_notifier.rs) | Session-reap pass already calls `SessionOverrideRegistry::remove` per reaped session — automatically picks up the new `pinned_tools` field, no notifier change needed | +| [`docs/planning/dynamic-mcp-toggle-meta-tools.md`](./dynamic-mcp-toggle-meta-tools.md) | Direct predecessor — defines the SessionOverrideRegistry pattern, approval flow conventions, and Out-of-Scope row #1 this doc supersedes | +| [`docs/planning/server-account-clones.md`](./server-account-clones.md) | Origin of the 240-tool context-bloat evidence that justifies revisiting tool-level granularity | + +--- + +## Related work + +- [`docs/planning/dynamic-mcp-toggle-meta-tools.md`](./dynamic-mcp-toggle-meta-tools.md) — defines the meta-tools infrastructure and the session-override registry pattern. Supersedes its Out-of-Scope row #1. +- [`docs/planning/server-account-clones.md`](./server-account-clones.md) — clone feature that created the 240-tool context-bloat evidence. This doc is a natural follow-on for users who hit that ceiling. +- [MikkoParkkola/mcp-gateway](https://github.com/MikkoParkkola/mcp-gateway) and [abdullah1854/MCPGateway](https://github.com/abdullah1854/MCPGateway) — alternative architectural answer (search-then-invoke meta gateway, ~95% context reduction). Considered and rejected for this PR: their model hides all backend tools by default and requires the LLM to discover via `gateway_search_tools` on every call. The McpMux approach keeps the LLM's mental model of named tools intact and lets the user (via the LLM) opt in to budget reduction per session. Worth revisiting as a separate planning doc if the pin-based approach proves insufficient. +- [MCP spec — Tools `list_changed` notification](https://modelcontextprotocol.io/specification/2025-11-25/server/tools#list-changed-notification) — protocol mechanism that makes the post-pin tool-list refresh observable mid-conversation. Already wired by [`dynamic-mcp-toggle-meta-tools`](./dynamic-mcp-toggle-meta-tools.md). +- [modelcontextprotocol/servers#2173](https://github.com/modelcontextprotocol/servers/issues/2173) — upstream tracking issue for server-side multi-tenancy. Pin is a gateway-side workaround for the same underlying problem (tool surface scaling poorly with multi-account use). + +--- + +## Reconciliation + +This doc is the source of truth for what gets built. When implementation completes, update the **Status** field at the top and reconcile any deviations (extra files, dropped phases, scope changes) per [`update-planning-md`](~/.cursor/commands/update-planning-md.md). diff --git a/docs/planning/web-admin-remote-access.md b/docs/planning/web-admin-remote-access.md new file mode 100644 index 00000000..c2fd2ce8 --- /dev/null +++ b/docs/planning/web-admin-remote-access.md @@ -0,0 +1,317 @@ +# Web Admin Mode (Remote UI via HTTP) + +**Last Updated:** May 25, 2026 +**Status:** Planning +**Branch:** TBD — branch off `main` (or current fork head) +**Base branch:** `main` +**Issue:** TBD — file after planning review +**Depends on:** None for core admin HTTP layer; benefits from merged fork features (session meta-tools, account clones) but does not require them +**Unblocks:** [`jsg-tech-check` homelab wiring Step 6](../../../jsg-tech-check/docs/setup/home-lab-wiring-plan.md) — remote McpMux admin UI from Weathertop / Rohan at `https://mux.joe-hassio.com` + +--- + +## Problem + +The McpMux admin UI (Spaces, servers, credentials, workspace bindings, FeatureSets, OAuth consent) is a Tauri desktop app. The React frontend talks to Rust exclusively via Tauri `invoke()` — ~80 calls across 15 API modules, backed by ~110 Tauri commands in 19 command modules. There is no HTTP admin surface. + +The homelab wiring plan already exposes two public endpoints via Cloudflare Tunnel on Gondor: + +| Hostname | Target | What it serves | +| -------- | ------ | -------------- | +| `mcp.joe-hassio.com` | `localhost:45818` | MCP gateway (`/mcp`) for AI clients | +| `code.joe-hassio.com` | `localhost:3001` | ClaudeCodeUI | + +Neither exposes the admin UI. Tunneling Vite dev (`:1420`) serves a React shell with no backend — every action fails because nothing answers `invoke()`. Tunneling the MCP gateway (`:45818`) serves the protocol endpoint, not admin pages. + +The user-facing ask: + +> I want to be able to reach the UI — that's the main point. + +Screen sharing / VNC behind CF Access works today but is not a web UI. This doc defines a **web admin mode**: an optional HTTP server that serves the built React SPA and exposes a REST API mirroring Tauri commands, gated by Cloudflare Access at the edge. + +--- + +## Decisions + +| # | Decision | Choice | Rationale | +| - | -------- | ------ | --------- | +| 1 | Deployment model | **Single-user homelab** — one McpMux instance on Gondor, one operator | Avoids multi-tenant auth, cloud KMS, and per-user DB isolation. The Rust process still runs locally with OS keychain access. | +| 2 | Auth | **Cloudflare Access at the tunnel edge** — app trusts `CF-Access-Jwt-Assertion` when `gateway.admin_trust_cf_access` is enabled | No login UI to build. Same pattern as `b.joe-hassio.com` (Beeper). Reject requests without a valid JWT when admin mode is enabled. | +| 3 | Admin server placement | **Separate Axum router on configurable port** (default `45819`), not mixed into MCP gateway routes | Keeps MCP protocol surface unchanged. Admin and MCP can be tunneled independently (`mux.joe-hassio.com` vs `mcp.joe-hassio.com`). Easier to disable admin without stopping the gateway. | +| 4 | Static UI | **Serve `frontendDist` from the Tauri build** at `/` with SPA fallback | Reuses the existing React app. No separate web bundle. | +| 5 | API shape | **REST JSON at `/api/v1/*`** mirroring Tauri command names (kebab → snake mapping) | Predictable mapping: `get_gateway_status` → `GET /api/v1/gateway/status`. One handler module per Tauri command group. | +| 6 | Frontend transport | **Transport abstraction in `lib/api/`** — `invoke()` in Tauri, `fetch()` in web mode | Detect via `window.__TAURI__` or build-time `import.meta.env.VITE_ADMIN_WEB`. Same function signatures, different backend. | +| 7 | OAuth consent | **Re-enable guarded HTTP consent endpoint** for web admin only — `POST /api/v1/oauth/consent/approve` behind CF Access + CSRF token | Production desktop keeps Tauri-IPC-only consent (existing security model). Web mode needs an HTTP path because there is no Tauri shell on Weathertop. | +| 8 | Bind address | **Default `127.0.0.1:45819`** — same loopback-first posture as MCP gateway | CF tunnel reaches localhost; no need to bind `0.0.0.0`. `AGENTS.md` loopback rule preserved. | +| 9 | Event streaming | **SSE at `/api/v1/events`** bridging existing `EventBus` | Replaces Tauri event listeners (`useDomainEvents`) in web mode. Desktop keeps Tauri events. | +| 10 | Scope phasing | **Read-only views first, then writes, then OAuth** | Each phase is independently testable behind CF Access. Avoids a big-bang API dump. | + +--- + +## The Model + +### What web admin mode is + +An optional HTTP server started alongside (or instead of) the Tauri window when `gateway.admin_enabled` is true: + +```text +AdminServer (Axum, :45819) +├── GET /* → SPA static files (frontendDist) +├── GET /api/v1/health → { status: "ok", gateway_running: bool } +├── GET /api/v1/events → SSE stream (EventBus bridge) +├── /api/v1/gateway/* → gateway commands +├── /api/v1/spaces/* → space commands +├── /api/v1/servers/* → server manager + install + clone +├── /api/v1/workspaces/* → workspace bindings + session overrides +├── /api/v1/feature-sets/* → feature sets + members +├── /api/v1/clients/* → inbound MCP clients +├── /api/v1/oauth/* → consent approve/reject (web only) +└── /api/v1/settings/* → app settings +``` + +All handlers delegate to the same `ApplicationServices` / command-layer logic Tauri uses today — no duplicated business logic. + +### What web admin mode is NOT + +- Not a hosted multi-tenant SaaS ("McpMux Cloud") +- Not a replacement for the Tauri desktop app on Gondor (desktop remains primary for local use) +- Not exposing the MCP gateway without separate hardening (that route stays on `:45818` with its own OAuth JWT model) +- Not moving secrets off OS keychain — encryption keys stay local + +### Homelab tunnel layout (target) + +```yaml +# gondor cloudflared config (addition to home-lab-wiring-plan.md Step 5) +ingress: + - hostname: mux.joe-hassio.com + service: http://localhost:45819 # NEW — admin UI + - hostname: mcp.joe-hassio.com + service: http://localhost:45818 # existing — MCP clients + - hostname: code.joe-hassio.com + service: http://localhost:3001 # existing — ClaudeCodeUI + - service: http_status:404 +``` + +CF Access policy on `mux.joe-hassio.com`: allow `jsangio1@gmail.com` (or equivalent Zero Trust rule). + +--- + +## Architecture + +``` +Weathertop / Rohan browser + │ + │ HTTPS + CF Access (Google login) + ▼ + mux.joe-hassio.com ──── cloudflared tunnel ────► localhost:45819 + │ + ┌───────────────────────────┤ + │ │ + ▼ ▼ + ┌──────────────────┐ ┌──────────────────┐ + │ Static SPA │ │ /api/v1/* REST │ + │ (frontendDist) │ │ + SSE /events │ + └──────────────────┘ └────────┬─────────┘ + │ + ▼ + ┌──────────────────────┐ + │ ApplicationServices │ + │ (same as Tauri cmds) │ + └──────────┬───────────┘ + │ + ┌────────────────────────────────────┼────────────────────┐ + ▼ ▼ ▼ + SQLite + OS Keychain Gateway :45818 + AES-256-GCM JWT secret (unchanged) +``` + +**Middleware stack (admin router):** + +1. `CF-Access-Jwt-Assertion` validation (when enabled) +2. CSRF token check on mutating routes (web OAuth consent) +3. Request logging (sanitized — no secrets) +4. CORS: deny by default; allow same-origin only (SPA served from same host) + +**Frontend transport switch:** + +```typescript +// lib/api/transport.ts (new) +export async function apiCall(command: string, args?: Record): Promise { + if (isTauri()) { + return invoke(command, args); + } + return fetchApi(command, args); +} +``` + +Existing `lib/api/*.ts` modules swap `invoke(...)` → `apiCall(...)` with no signature changes. + +--- + +## Files to create + +| File | Purpose | +| ---- | ------- | +| `crates/mcpmux-gateway/src/admin/mod.rs` | Admin router module entry | +| `crates/mcpmux-gateway/src/admin/server.rs` | `AdminServer` — bind, static file serving, route mounting | +| `crates/mcpmux-gateway/src/admin/middleware/cf_access.rs` | Validate `CF-Access-Jwt-Assertion` against CF team domain certs | +| `crates/mcpmux-gateway/src/admin/middleware/csrf.rs` | CSRF token generation + validation for mutating routes | +| `crates/mcpmux-gateway/src/admin/handlers/mod.rs` | Handler module tree | +| `crates/mcpmux-gateway/src/admin/handlers/gateway.rs` | Gateway status/start/stop REST handlers | +| `crates/mcpmux-gateway/src/admin/handlers/spaces.rs` | Space CRUD handlers | +| `crates/mcpmux-gateway/src/admin/handlers/servers.rs` | Server manager + install + clone handlers | +| `crates/mcpmux-gateway/src/admin/handlers/workspaces.rs` | Workspace binding + session override handlers | +| `crates/mcpmux-gateway/src/admin/handlers/feature_sets.rs` | FeatureSet + member handlers | +| `crates/mcpmux-gateway/src/admin/handlers/clients.rs` | Inbound MCP client handlers | +| `crates/mcpmux-gateway/src/admin/handlers/oauth.rs` | Web consent approve/reject handlers | +| `crates/mcpmux-gateway/src/admin/handlers/settings.rs` | App settings handlers | +| `crates/mcpmux-gateway/src/admin/handlers/events.rs` | SSE EventBus bridge | +| `crates/mcpmux-gateway/src/admin/command_bridge.rs` | Shared helper: call Tauri command logic without Tauri runtime | +| `apps/desktop/src/lib/api/transport.ts` | Tauri vs fetch transport abstraction | +| `apps/desktop/src/lib/api/fetch-api.ts` | REST client mapping command names → HTTP paths | +| `apps/desktop/src/hooks/useDomainEventsWeb.ts` | SSE-based event listener for web mode | +| `tests/rust/tests/integration/admin_api.rs` | Admin API integration tests (health, auth rejection, read endpoints) | +| `docs/planning/web-admin-remote-access.md` | This doc | + +## Files to modify + +| File | Change | +| ---- | ------ | +| [`crates/mcpmux-gateway/src/lib.rs`](../../crates/mcpmux-gateway/src/lib.rs) | `pub mod admin;` | +| [`crates/mcpmux-gateway/src/server/mod.rs`](../../crates/mcpmux-gateway/src/server/mod.rs) | `GatewayConfig` gains `admin_enabled`, `admin_port`, `admin_trust_cf_access`, `admin_cf_team_domain` | +| [`apps/desktop/src-tauri/src/lib.rs`](../../apps/desktop/src-tauri/src/lib.rs) | Start `AdminServer` when setting enabled; share `ApplicationServices` Arc | +| [`apps/desktop/src-tauri/src/commands/gateway.rs`](../../apps/desktop/src-tauri/src/commands/gateway.rs) | Extract shared gateway logic callable from admin handlers | +| [`apps/desktop/src/lib/api/*.ts`](../../apps/desktop/src/lib/api/) | Replace direct `invoke()` with `apiCall()` from transport layer | +| [`apps/desktop/src/hooks/useDomainEvents.ts`](../../apps/desktop/src/hooks/useDomainEvents.ts) | Delegate to SSE hook in web mode | +| [`apps/desktop/src/features/oauth/OAuthConsentModal.tsx`](../../apps/desktop/src/features/oauth/OAuthConsentModal.tsx) | Web mode: POST to `/api/v1/oauth/consent/approve` instead of Tauri command | +| [`apps/desktop/src/features/settings/SettingsPage.tsx`](../../apps/desktop/src/features/settings/SettingsPage.tsx) | Admin mode toggle + port setting | +| [`apps/desktop/vite.config.ts`](../../apps/desktop/vite.config.ts) | `VITE_ADMIN_WEB` build flag for web-only builds | +| [`apps/desktop/package.json`](../../apps/desktop/package.json) | `build:web:admin` script — production SPA build for admin serving | +| [`tests/e2e/playwright.config.ts`](../../tests/e2e/playwright.config.ts) | Optional admin web E2E project against `:45819` with mocked CF JWT | +| [`AGENTS.md`](../../AGENTS.md) | Document admin server loopback binding + CF Access requirement | + +--- + +## Phasing + +### Phase 1 — Admin server skeleton + static SPA + CF Access gate + +**Effort:** ~2 days + +- [ ] `AdminServer` Axum router on `127.0.0.1:45819` (configurable) +- [ ] Serve `frontendDist` with SPA fallback (`index.html` for unknown routes) +- [ ] `GET /api/v1/health` — returns gateway running status +- [ ] CF Access middleware: validate `CF-Access-Jwt-Assertion` when `admin_trust_cf_access` is true; 401 without it +- [ ] Settings: `gateway.admin_enabled` (default `false`), `gateway.admin_port` (default `45819`) +- [ ] Start admin server from Tauri app when setting enabled (alongside gateway) +- [ ] Unit test: health endpoint returns 200; unauthenticated request returns 401 when CF Access enabled + +**Outcome:** With admin mode enabled and a valid CF Access JWT, `https://mux.joe-hassio.com` (via tunnel) loads the McpMux UI shell. API calls still fail (no handlers yet), but static assets render and auth gate works. + +### Phase 2 — Transport abstraction + read-only API + +**Effort:** ~3 days + +- [ ] `transport.ts` + `fetch-api.ts` — command name → HTTP path mapping +- [ ] Refactor all `lib/api/*.ts` modules to use `apiCall()` +- [ ] `command_bridge.rs` — extract shared logic from Tauri commands into functions callable from both IPC and HTTP +- [ ] Read-only handlers: gateway status, list spaces, list installed servers, list workspace bindings, list feature sets, list clients, list session overrides, get settings +- [ ] `GET /api/v1/events` — SSE bridge from `EventBus` +- [ ] `useDomainEventsWeb.ts` — SSE listener; `useDomainEvents` switches on environment +- [ ] Integration tests for each read endpoint + +**Outcome:** From Weathertop, authenticated user can browse Spaces, My Servers, Workspaces, FeatureSets, Clients, and Settings in read-only mode. Domain events (server connected, gateway started) stream via SSE. No writes yet. + +### Phase 3 — Write API (config mutations) + +**Effort:** ~4 days + +- [ ] Write handlers: install/uninstall server, enable/disable, configure inputs, clone server, CRUD spaces, CRUD workspace bindings, CRUD feature sets + members, gateway start/stop, export config, clear session overrides, update settings +- [ ] CSRF middleware on all `POST`/`PUT`/`DELETE` routes +- [ ] Error mapping: domain errors → HTTP status codes with JSON body +- [ ] Integration tests: install + configure + enable round-trip via HTTP +- [ ] Playwright admin web E2E: smoke test install flow against `:45819` + +**Outcome:** Full admin CRUD works from the browser. User can install servers, edit credentials, manage bindings and FeatureSets, start/stop gateway — all remote. OAuth consent still requires Phase 4. + +### Phase 4 — Web OAuth consent + +**Effort:** ~2 days + +- [ ] `POST /api/v1/oauth/consent/approve` and `/reject` — guarded HTTP endpoints (web admin only; desktop keeps Tauri IPC) +- [ ] CSRF + consent token validation (reuse existing cryptographic consent token from gateway) +- [ ] `OAuthConsentModal.tsx` — web path posts to HTTP endpoint; desktop path unchanged +- [ ] Deep link bridge: `mcpmux://` URLs on Gondor still work for desktop; web mode polls consent pending state via SSE +- [ ] Integration test: OAuth authorize → consent approve via HTTP → token issued + +**Outcome:** Remote user can complete OAuth flows (Notion, GitHub, Google Workspace, etc.) from the browser without needing the Tauri shell on Gondor. + +### Phase 5 — Homelab integration + docs + +**Effort:** ~1 day + +- [ ] Update [`jsg-tech-check/docs/setup/home-lab-wiring-plan.md`](../../../jsg-tech-check/docs/setup/home-lab-wiring-plan.md) Step 5 with `mux.joe-hassio.com` ingress rule +- [ ] Document CF Access policy setup for `mux.joe-hassio.com` +- [ ] Add admin mode section to [`docs/guide/gateway.mdx`](../../docs/guide/gateway.mdx) +- [ ] `pnpm build:web:admin` + verify production SPA served correctly from admin server +- [ ] End-to-end smoke from Weathertop: open `https://mux.joe-hassio.com`, manage a server, approve OAuth + +**Outcome:** Homelab wiring plan reflects the third public hostname. Operator can manage McpMux from phone/laptop browser with CF Access auth. + +--- + +## Pre-PR validation + +| Step | Command | Purpose | +| ---- | ------- | ------- | +| Full validate | `pnpm validate` | fmt, clippy, check, eslint, typecheck | +| Rust tests | `pnpm test:rust` | unit + integration (`admin_api.rs`) | +| TS tests | `pnpm test:ts` | vitest (transport layer) | +| Admin web E2E | `pnpm test:e2e:web:admin` (new) | Playwright against `:45819` | +| Manual smoke | Enable admin mode, tunnel `mux.joe-hassio.com`, browse from phone | UX + CF Access verification | + +--- + +## Out of scope + +| Item | Reason | +| ---- | ------ | +| Multi-tenant / per-user accounts | Single-user homelab. Adding user management is a different product. | +| Cloud KMS / secrets off OS keychain | Admin server runs on Gondor; keychain access is preserved. No remote secret vault needed. | +| Binding admin server to `0.0.0.0` | Loopback + CF tunnel is the access path. Direct internet bind violates `AGENTS.md` posture. | +| Replacing Tauri desktop app | Desktop remains primary on Gondor. Web admin is for remote access only. | +| Mobile-optimized responsive UI | React app works in mobile browser but no dedicated mobile layout pass. Acceptable for v1 homelab use. | +| Public MCP gateway hardening (`mcp.joe-hassio.com`) | Separate concern — OAuth JWT auth exists but unauthenticated admin routes on `:45818` need CF Access too. Track as follow-up, not blocked on this doc. | +| WebSocket transport (instead of SSE) | SSE is sufficient for EventBus fan-out. WebSocket adds complexity with no v1 benefit. | +| Headless-only mode (no Tauri window) | v1 starts admin server from Tauri app. Headless/systemd mode is a follow-up for Rivendell-style deployment. | + +--- + +## Key files referenced + +| File | Why | +| ---- | --- | +| [`apps/desktop/src/lib/api/gateway.ts`](../../apps/desktop/src/lib/api/gateway.ts) | Largest API module (~20 invokes) — template for transport refactor | +| [`apps/desktop/src-tauri/src/commands/mod.rs`](../../apps/desktop/src-tauri/src/commands/mod.rs) | Command module registry — each module gets a corresponding admin handler | +| [`crates/mcpmux-gateway/src/server/mod.rs`](../../crates/mcpmux-gateway/src/server/mod.rs) | Existing Axum gateway — pattern reference for admin router | +| [`crates/mcpmux-gateway/src/server/mod.rs`](../../crates/mcpmux-gateway/src/server/mod.rs) (lines 340–365) | OAuth consent removed from HTTP for security — web admin re-adds guarded version | +| [`apps/desktop/src/hooks/useDomainEvents.ts`](../../apps/desktop/src/hooks/useDomainEvents.ts) | Tauri event listener — needs SSE equivalent for web mode | +| [`jsg-tech-check/docs/setup/home-lab-wiring-plan.md`](../../../jsg-tech-check/docs/setup/home-lab-wiring-plan.md) | CF tunnel config — gets `mux.joe-hassio.com` ingress in Phase 5 | + +--- + +## Related documentation + +- [`jsg-tech-check/docs/setup/home-lab-wiring-plan.md`](../../../jsg-tech-check/docs/setup/home-lab-wiring-plan.md) — Step 5 (CF tunnel), Step 6 (McpMux on Gondor), cross-device MCP access +- [`jsg-tech-check/docs/setup/mcpmux-server-migration.md`](../../../jsg-tech-check/docs/setup/mcpmux-server-migration.md) — server/bundle/binding migration tracker (orthogonal to web admin) +- [`docs/guide/security.mdx`](../../docs/guide/security.mdx) — credential encryption model (unchanged by web admin) +- [`docs/planning/dynamic-mcp-toggle-meta-tools.md`](./dynamic-mcp-toggle-meta-tools.md) — session override UI that web admin must expose via HTTP +- [`docs/planning/server-account-clones.md`](./server-account-clones.md) — clone wizard that web admin must expose via HTTP + +--- + +## Reconciliation + +This doc is the source of truth for web admin mode. When implementation starts, update **Status** and **Branch** at the top. Phase 5 homelab doc updates live in `jsg-tech-check` — track cross-repo separately. + +**Decision record (May 25, 2026):** Web admin mode on fork selected over screen sharing (immediate but not web UI), tunneling `:1420` (broken), and full "McpMux Cloud" multi-tenant SaaS (months of work). CF Access at edge replaces building login UI. Separate admin port (`45819`) keeps MCP gateway surface unchanged. diff --git a/docs/run-from-source-macos.md b/docs/run-from-source-macos.md new file mode 100644 index 00000000..2a309216 --- /dev/null +++ b/docs/run-from-source-macos.md @@ -0,0 +1,258 @@ +# Run McpMux from Source (macOS) + +Two flows for working against this repo, picked by what you're doing: + +| Flow | Use when | Speed | Cursor / Claude / VS Code see it? | +| ---- | -------- | ----- | --------------------------------- | +| **Dev watch mode** (`pnpm dev`) | Iterating on UI or Rust — you want HMR for React and auto-recompile for Tauri commands | Vite HMR is instant; Rust changes ~5–15s incremental | Yes — same `localhost:45818` endpoint while `pnpm dev` is running | +| **Build + swap** (replace `/Applications/McpMux.app`) | You want a real installed app on this branch — autostart, system tray, runs without a terminal, survives reboot | Full build ~5–10 min, incremental ~1–3 min | Yes — and stays running after you close your editor | + +Quick rule of thumb: **`pnpm dev` while you're coding, swap when you're done** so other AI clients keep working when Cursor isn't open. + +--- + +## What survives between flows + +Both dev mode and a swapped `.app` use the same `com.mcpmux.desktop` bundle identifier, so they share data: + +| Data | Location | +| ---- | -------- | +| SQLite DB (spaces, servers, clients, settings) | `~/Library/Application Support/com.mcpmux.desktop/mcpmux.db` | +| Per-space files | `~/Library/Application Support/com.mcpmux.desktop/spaces/` | +| Logs | `~/Library/Application Support/com.mcpmux.desktop/logs/` | +| Encryption master key | macOS Keychain (`com.mcpmux.desktop` service) | +| OAuth tokens / credentials | Encrypted in SQLite + Keychain | + +The new binary reads the same data dir and keychain entries as the release. Spaces, server installs, and access keys persist across `pnpm dev` ↔ `/Applications` swaps. + +**What you might need to redo:** OAuth re-auth in Cursor/Claude Desktop if DCR or token validation changed on your branch. + +--- + +## Prerequisites + +From repo root (`mcp-mux/`): + +- Rust 1.75+ +- Node.js 20+ +- pnpm 9+ +- Xcode Command Line Tools (`xcode-select --install`) + +First-time setup (if deps aren't installed): + +```bash +pnpm install +``` + +--- + +## Flow 1 — Dev watch mode (`pnpm dev`) + +Live-reload while you code. Best for tight iteration on UI or Rust. + +### What it does + +| Layer | Behavior | +| ----- | -------- | +| React / Tailwind / TS | Vite dev server on `localhost:1420` with **HMR** — change a `.tsx`/`.css`, see it instantly without losing app state | +| Rust (Tauri commands, gateway, storage) | Recompiles + relaunches the Tauri window on any `.rs` save under `src-tauri/` or `crates/` | +| Bundle ID | Same `com.mcpmux.desktop` — reads your real DB and Keychain entries | + +### Run it + +```bash +# Quit the installed app first so the dev gateway can bind 127.0.0.1:45818 +osascript -e 'tell application "McpMux" to quit' 2>/dev/null; sleep 2 + +pnpm dev +``` + +A Tauri window opens. Edit `.tsx` files for instant HMR; edit Rust and the window will relaunch on its own after recompile. + +### Frontend-only iteration + +If you're only changing UI and want the fastest possible loop: + +```bash +pnpm dev:web +``` + +This runs Vite alone in a browser tab — no Rust, no Tauri shell. Tauri `invoke()` calls won't work (no backend), but for pure layout/styling work it's the quickest path. + +### Gotchas + +| Symptom | Why | Fix | +| ------- | --- | --- | +| Keychain prompts on first launch of the dev binary | Different signer than `/Applications/McpMux.app` | Click **Always Allow** once — sticks for that built artifact. See `Keychain prompts` below for detail | +| `Address already in use: 45818` | Installed `.app` still running | `osascript -e 'tell application "McpMux" to quit'` then retry | +| Cursor's MCP server "disconnected" mid-session | You stopped `pnpm dev` | Cursor reconnects when the gateway is back on `localhost:45818` (either flow) | +| Rust recompile feels slow | Big edits in `mcpmux-gateway` / `mcpmux-storage` | Expected — keep edits scoped or use `pnpm dev:web` for UI | +| `pnpm dev` keeps crashing with "Master key not found" | DB/keychain mismatch from manual deletion | Don't manually delete keychain entries — see `Keychain prompts` below | + +### Keychain prompts + +McpMux reads two secrets from Keychain on startup: + +1. **Master encryption key** — every app launch (decrypts SQLite credentials) +2. **JWT signing secret** — first time you start the gateway in a session + +macOS scopes Keychain access to the **specific signed binary**, not just the bundle ID. So: + +- First launch of a `pnpm dev` build → 1–2 prompts +- First launch after a fresh `pnpm build` swap → 1–2 prompts +- Subsequent launches of the **same** built binary → silent if you clicked **Always Allow** +- Alternating between `pnpm dev` and `/Applications/McpMux.app` → may re-prompt because each is a different signer + +This is expected. Click **Always Allow** the first time you see each prompt for a new build. + +--- + +## Flow 2 — Build and swap into `/Applications` + +Use when you want the source build to behave like an installed app: launch from Spotlight/Dock, autostart, run in the background without a dev terminal, survive reboots. + +### Option A — Full build (recommended) + +Rebuilds the React frontend and produces a fresh `.app` bundle. Use this when frontend or Tauri config changed, or when you want a clean bundle. + +#### 1. Quit the running app + +```bash +osascript -e 'tell application "McpMux" to quit' 2>/dev/null || true +# Give it a moment to release the gateway port +sleep 2 +``` + +#### 2. Build + +```bash +cd /path/to/mcp-mux +pnpm build +``` + +First build: ~5–10 min. Incremental: ~1–3 min. + +Output: + +``` +target/release/bundle/macos/McpMux.app +target/release/bundle/dmg/McpMux_*.dmg # optional installer artifact +``` + +> **Note:** the build may exit non-zero at the very end with `TAURI_SIGNING_PRIVATE_KEY` missing. That only blocks the auto-update artifact; the `.app` and `.dmg` are still produced and usable. + +#### 3. Backup and swap + +```bash +# Backup current install (skip if you already have a recent .bak) +sudo mv /Applications/McpMux.app /Applications/McpMux.app.bak + +# Install the new build +sudo cp -R target/release/bundle/macos/McpMux.app /Applications/ + +# Fix ownership (sudo cp leaves root-owned files) +sudo chown -R "$(whoami):admin" /Applications/McpMux.app +``` + +#### 4. Re-sign (required after manual swap) + +macOS Gatekeeper rejects a bundle whose binary was replaced without re-signing: + +```bash +xattr -dr com.apple.quarantine /Applications/McpMux.app 2>/dev/null || true +codesign --force --deep --sign - /Applications/McpMux.app +``` + +#### 5. Launch + +```bash +open /Applications/McpMux.app +``` + +Verify: spaces, installed servers, and gateway on `localhost:45818` should look exactly as before. First launch will trigger 1–2 Keychain prompts because the new ad-hoc signature is a different signer than the previous build — click **Always Allow** once and you're set until the next swap. + +### Option B — Binary-only swap (fast path) + +When you changed **Rust only** (no frontend, no `tauri.conf.json` changes). Skips the Vite build and DMG step. + +```bash +osascript -e 'tell application "McpMux" to quit' 2>/dev/null || true +sleep 2 + +cd /path/to/mcp-mux +cargo build --release -p mcpmux + +cp /Applications/McpMux.app/Contents/MacOS/mcpmux \ + /Applications/McpMux.app/Contents/MacOS/mcpmux.bak +cp target/release/mcpmux /Applications/McpMux.app/Contents/MacOS/mcpmux + +xattr -dr com.apple.quarantine /Applications/McpMux.app 2>/dev/null || true +codesign --force --deep --sign - /Applications/McpMux.app + +open /Applications/McpMux.app +``` + +Keeps the existing bundle shell (icons, Info.plist, embedded frontend from last full build). Only the Rust binary updates. + +--- + +## Rollback (Flow 2 only) + +If a swapped build is broken, restore the previous `/Applications/McpMux.app`. Dev-mode (`pnpm dev`) doesn't need a rollback — just stop the dev process. + +### Full build rollback + +```bash +osascript -e 'tell application "McpMux" to quit' 2>/dev/null || true +sudo rm -rf /Applications/McpMux.app +sudo mv /Applications/McpMux.app.bak /Applications/McpMux.app +open /Applications/McpMux.app +``` + +### Binary-only rollback + +```bash +osascript -e 'tell application "McpMux" to quit' 2>/dev/null || true +cp /Applications/McpMux.app/Contents/MacOS/mcpmux.bak \ + /Applications/McpMux.app/Contents/MacOS/mcpmux +codesign --force --deep --sign - /Applications/McpMux.app +open /Applications/McpMux.app +``` + +--- + +## Troubleshooting + +| Symptom | Applies to | Fix | +| ------- | ---------- | --- | +| "App is damaged" / won't open | Flow 2 | Re-run `codesign --force --deep --sign - /Applications/McpMux.app` | +| Gateway port already in use | Both | Old process still running — `pkill -f mcpmux` then relaunch | +| Cursor OAuth fails after swap | Both | Re-trigger MCP OAuth in Cursor (DCR redirect URI validation may have changed) | +| Empty app / missing UI | Flow 2 (Option B) | You used binary-only swap but frontend changed — run Option A (full build) | +| Permission denied on `/Applications` | Flow 2 | Use `sudo` for mv/cp/chown, or install to `~/Applications/` and skip sudo | +| Keychain prompts on every launch of the same binary | Both | Click **Always Allow** (not just **Allow**); check Keychain Access for duplicate `master-encryption-key` / `jwt-signing-secret` entries from old signers | +| `pnpm dev` won't start — `EADDRINUSE 45818` | Flow 1 | The installed `.app` is still running — quit it before `pnpm dev` | + +--- + +## One-liner (full build + swap) + +Assumes you're in repo root and have a recent backup: + +```bash +osascript -e 'tell application "McpMux" to quit' 2>/dev/null; sleep 2 && \ +pnpm build && \ +sudo rm -rf /Applications/McpMux.app && \ +sudo cp -R target/release/bundle/macos/McpMux.app /Applications/ && \ +sudo chown -R "$(whoami):admin" /Applications/McpMux.app && \ +xattr -dr com.apple.quarantine /Applications/McpMux.app 2>/dev/null; \ +codesign --force --deep --sign - /Applications/McpMux.app && \ +open /Applications/McpMux.app +``` + +--- + +## Related + +- [`AGENTS.md`](../AGENTS.md) — build commands and project layout +- [`CLAUDE.md`](../CLAUDE.md) — full dev environment reference 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/scripts/take-screenshots.cjs b/scripts/take-screenshots.cjs index 7a0fdb3f..dff35fc1 100644 --- a/scripts/take-screenshots.cjs +++ b/scripts/take-screenshots.cjs @@ -224,9 +224,7 @@ function buildMockHandler() { window.__TAURI_INTERNALS__.invoke = async function(cmd, args) { switch (cmd) { case 'list_spaces': return SPACES; - case 'get_active_space': return SPACES[0]; case 'get_space': return SPACES.find(s => s.id === args?.id) || SPACES[0]; - case 'set_active_space': return null; case 'create_space': return { id: crypto.randomUUID(), name: args?.name, icon: args?.icon, description: null, is_default: false, sort_order: 3, created_at: new Date().toISOString(), updated_at: new Date().toISOString() }; case 'get_gateway_status': return { running: true, url: 'http://localhost:9315', active_sessions: 2, connected_backends: 6 }; case 'start_gateway': return null; diff --git a/tests/e2e/helpers/tauri-api.ts b/tests/e2e/helpers/tauri-api.ts index bd05137c..a71d9fc9 100644 --- a/tests/e2e/helpers/tauri-api.ts +++ b/tests/e2e/helpers/tauri-api.ts @@ -55,12 +55,10 @@ export async function listSpaces(): Promise { return invoke('list_spaces'); } -export async function getActiveSpace(): Promise { - return invoke('get_active_space'); -} - -export async function setActiveSpace(id: string): Promise { - return invoke('set_active_space', { id }); +/** The system's `is_default` Space — the gateway's routing fallback. */ +export async function getDefaultSpace(): Promise { + const spaces = await listSpaces(); + return spaces.find((s) => s.is_default) ?? null; } // ============================================================================ @@ -71,16 +69,12 @@ export interface Client { id: string; name: string; client_type: string; - connection_mode: 'locked' | 'follow_active' | 'ask_on_change'; - locked_space_id: string | null; - grants: Record; + last_seen: string | null; } export interface CreateClientInput { name: string; client_type: string; - connection_mode: string; - locked_space_id?: string; } export async function createClient(input: CreateClientInput): Promise { @@ -95,23 +89,6 @@ export async function listClients(): Promise { return invoke('list_clients'); } -export async function grantFeatureSetToClient( - clientId: string, - spaceId: string, - featureSetId: string -): Promise { - return invoke('grant_feature_set_to_client', { clientId, spaceId, featureSetId }); -} - -/** Grant a feature set to an OAuth/inbound client (Cursor, VS Code, etc.) */ -export async function grantOAuthClientFeatureSet( - clientId: string, - spaceId: string, - featureSetId: string -): Promise { - return invoke('grant_oauth_client_feature_set', { clientId, spaceId, featureSetId }); -} - // ============================================================================ // FeatureSet API // ============================================================================ @@ -119,7 +96,7 @@ export async function grantOAuthClientFeatureSet( export interface FeatureSet { id: string; name: string; - feature_set_type: 'all' | 'default' | 'server-all' | 'custom'; + feature_set_type: 'default' | 'custom'; server_id: string | null; is_builtin: boolean; } @@ -276,3 +253,43 @@ export interface GatewayStatus { export async function getGatewayStatus(): Promise { return invoke('get_gateway_status'); } + +// ============================================================================ +// Workspace Binding API (primary routing config) +// ============================================================================ + +export interface WorkspaceBinding { + id: string; + workspace_root: string; + space_id: string; + feature_set_id: string; + created_at: string; + updated_at: string; +} + +export interface WorkspaceBindingInput { + workspace_root: string; + space_id: string; + feature_set_id: string; +} + +export async function listWorkspaceBindings(): Promise { + return invoke('list_workspace_bindings'); +} + +export async function createWorkspaceBinding( + input: WorkspaceBindingInput +): Promise { + return invoke('create_workspace_binding', { input }); +} + +export async function updateWorkspaceBinding( + id: string, + input: WorkspaceBindingInput +): Promise { + return invoke('update_workspace_binding', { id, input }); +} + +export async function deleteWorkspaceBinding(id: string): Promise { + return invoke('delete_workspace_binding', { id }); +} diff --git a/tests/e2e/pages/ClientsPage.ts b/tests/e2e/pages/ClientsPage.ts index cf88c520..113b3d66 100644 --- a/tests/e2e/pages/ClientsPage.ts +++ b/tests/e2e/pages/ClientsPage.ts @@ -13,7 +13,7 @@ export class ClientsPage extends BasePage { constructor(page: Page) { super(page); - this.heading = page.getByRole('heading', { name: 'Connected Clients' }); + this.heading = page.getByRole('heading', { name: 'Connections' }); this.clientList = page.locator('[data-testid="client-list"]'); this.clientCards = page.locator('[data-testid="client-card"]'); this.emptyState = page.locator('text=No clients connected'); diff --git a/tests/e2e/specs/capture-screenshots.manual.ts b/tests/e2e/specs/capture-screenshots.manual.ts index 80c34bf6..ad07c5f2 100644 --- a/tests/e2e/specs/capture-screenshots.manual.ts +++ b/tests/e2e/specs/capture-screenshots.manual.ts @@ -37,10 +37,9 @@ import fs from 'fs'; import { byTestId, safeClick } from '../helpers/selectors'; import { createSpace, - setActiveSpace, createFeatureSet, installServer, - getActiveSpace, + getDefaultSpace, refreshRegistry, enableServerV2, emitEvent, @@ -285,8 +284,8 @@ describe('Screenshot Capture', function () { // ---- Seed data from preseed config ---- // Get default space - const activeSpace = await getActiveSpace(); - defaultSpaceId = activeSpace?.id || ''; + const defaultSpace = await getDefaultSpace(); + defaultSpaceId = defaultSpace?.id || ''; console.log('[setup] Default space:', defaultSpaceId); // Create additional spaces @@ -486,8 +485,7 @@ describe('Screenshot Capture', function () { console.warn('[setup] OAuth client feature set grant failed:', e); } - // Set active space back to default - await setActiveSpace(defaultSpaceId); + // (Active-space concept removed — routing is per workspace root.) // Reload the page so the frontend store picks up all seeded data // (spaces, feature sets, etc. created via Tauri invoke aren't in the Zustand store yet) diff --git a/tests/e2e/specs/clients.spec.ts b/tests/e2e/specs/clients.spec.ts index d489b8f3..3087a7e6 100644 --- a/tests/e2e/specs/clients.spec.ts +++ b/tests/e2e/specs/clients.spec.ts @@ -1,17 +1,28 @@ import { test, expect } from '@playwright/test'; import { DashboardPage, ClientsPage } from '../pages'; -test.describe('Clients Page', () => { - test('should display the Clients heading', async ({ page }) => { +test.describe('Connections Page', () => { + test('should display the Connections heading', async ({ page }) => { const dashboard = new DashboardPage(page); const clients = new ClientsPage(page); await dashboard.navigate(); - + // Click Clients in sidebar await page.locator('nav button:has-text("Clients")').click(); - + await expect(clients.heading).toBeVisible(); - await expect(clients.heading).toHaveText('Connected Clients'); + await expect(clients.heading).toHaveText('Connections'); + }); + + test('should describe that routing lives in Workspaces', async ({ page }) => { + const dashboard = new DashboardPage(page); + await dashboard.navigate(); + await page.locator('nav button:has-text("Clients")').click(); + + // Routing is configured in Workspaces, not per-client. + await expect( + page.getByRole('button', { name: /^Workspaces$/ }) + ).toBeVisible(); }); test('should show description text', async ({ page }) => { @@ -54,160 +65,118 @@ test.describe('Clients Page', () => { }); }); -test.describe('Client Details', () => { - test('should show client details', async ({ page }) => { +test.describe('Connection Details', () => { + test('should show last-seen indicator on connection cards', async ({ page }) => { const dashboard = new DashboardPage(page); await dashboard.navigate(); await page.locator('nav button:has-text("Clients")').click(); - const clientCards = page.locator('[class*="rounded"][class*="border"]'); + const clientCards = page.locator('[data-testid^="client-card-"]'); const count = await clientCards.count(); if (count > 0) { - // Clients should have connection mode indicators + // Each card surfaces "Last seen …" — pure observability (no routing bits). const firstCard = clientCards.first(); await expect(firstCard).toBeVisible(); + await expect(firstCard).toContainText(/Last seen/); } }); - test('should show granted feature sets for clients', async ({ page }) => { + test('should route routing config to Workspaces from the side panel', async ({ + page, + }) => { const dashboard = new DashboardPage(page); await dashboard.navigate(); await page.locator('nav button:has-text("Clients")').click(); - - const clientCards = page.locator('[class*="rounded"][class*="border"]'); + + const clientCards = page.locator('[data-testid^="client-card-"]'); const count = await clientCards.count(); - + if (count > 0) { - // Clients may show which feature sets they have access to - const featureSetRefs = page.locator('text=/granted|access|permission/i'); - // May or may not be visible + await clientCards.first().click(); + + // The side panel's "routing is workspace-driven" callout exposes a + // button that sends the user to Workspaces. + await expect(page.getByRole('button', { name: /Open Workspaces/ })).toBeVisible(); + + // Legacy per-client controls MUST NOT be present any more. + await expect(page.locator('text=Quick Settings')).toHaveCount(0); + await expect(page.locator('text=Connection Mode')).toHaveCount(0); + await expect(page.locator('text=Effective Features')).toHaveCount(0); + await expect(page.locator('text=Advanced Permissions')).toHaveCount(0); } }); }); -test.describe('Client Management', () => { +test.describe('Connection lifecycle', () => { test('should have refresh button if available', async ({ page }) => { const dashboard = new DashboardPage(page); await dashboard.navigate(); await page.locator('nav button:has-text("Clients")').click(); - - const refreshButton = page.getByRole('button', { name: /Refresh/i }); - // May or may not be visible - }); - test('should show revoke option for connected clients', async ({ page }) => { - const dashboard = new DashboardPage(page); - await dashboard.navigate(); - await page.locator('nav button:has-text("Clients")').click(); - - const clientCards = page.locator('[class*="rounded"][class*="border"]'); - const count = await clientCards.count(); - - if (count > 0) { - const firstCard = clientCards.first(); - const revokeButton = firstCard.getByRole('button', { name: /Revoke|Disconnect|Remove/i }); - // May or may not be visible - } + const refreshButton = page.getByRole('button', { name: /Refresh/ }); + // Always rendered on the Connections header. + await expect(refreshButton).toBeVisible(); }); }); -test.describe('Client Toast Notifications', () => { - test('should have toast container on clients page', async ({ page }) => { +test.describe('Connections toast container', () => { + test('should have toast container on Connections page', async ({ page }) => { const dashboard = new DashboardPage(page); const clients = new ClientsPage(page); await dashboard.navigate(); - + await page.locator('nav button:has-text("Clients")').click(); await expect(clients.heading).toBeVisible(); - + await expect(clients.toastContainer).toBeAttached(); }); - // Skip in web mode - requires Tauri API for client operations - test.skip('should show success toast when saving client config', async ({ page }) => { + // Skip in web mode - requires Tauri API for the save-alias command. + test.skip('should toast on display-name save', async ({ page }) => { const dashboard = new DashboardPage(page); const clients = new ClientsPage(page); await dashboard.navigate(); - - await page.locator('nav button:has-text("Clients")').click(); - - // Click first client card to open panel - const clientCards = page.locator('[data-testid^="client-card-"]'); - const count = await clientCards.count(); - - if (count > 0) { - await clientCards.first().click(); - - // Wait for panel to open - await expect(page.locator('text=Quick Settings')).toBeVisible(); - - // Click Save Changes - const saveButton = page.getByRole('button', { name: /Save Changes/i }); - if (await saveButton.isVisible()) { - await saveButton.click(); - - await clients.waitForToast('success'); - const toastText = await clients.getToastText(); - expect(toastText).toContain('Client settings saved'); - } - } - }); - // Skip in web mode - requires Tauri API for client deletion - test.skip('should show success toast when removing a client', async ({ page }) => { - const dashboard = new DashboardPage(page); - const clients = new ClientsPage(page); - await dashboard.navigate(); - await page.locator('nav button:has-text("Clients")').click(); - + const clientCards = page.locator('[data-testid^="client-card-"]'); const count = await clientCards.count(); - + if (count > 0) { await clientCards.first().click(); - - // Click Remove Client in panel footer - page.on('dialog', dialog => dialog.accept()); - const removeButton = page.getByRole('button', { name: /Remove Client/i }); - if (await removeButton.isVisible()) { - await removeButton.click(); - - await clients.waitForToast('success'); - const toastText = await clients.getToastText(); - expect(toastText).toContain('Client removed'); - } + + // Type into the display-name input and hit save. + const aliasInput = page.getByPlaceholder(/./).first(); + await aliasInput.fill('New Alias'); + await page.getByRole('button', { name: /Save/ }).click(); + + await clients.waitForToast('success'); + expect(await clients.getToastText()).toMatch(/Saved/); } }); - // Skip in web mode - requires Tauri API for permission toggle - test.skip('should show success toast when toggling feature set grant', async ({ page }) => { + // Skip in web mode - requires Tauri API for revoke. + test.skip('should toast on revoke', async ({ page }) => { const dashboard = new DashboardPage(page); const clients = new ClientsPage(page); await dashboard.navigate(); - + await page.locator('nav button:has-text("Clients")').click(); - + const clientCards = page.locator('[data-testid^="client-card-"]'); const count = await clientCards.count(); - + if (count > 0) { await clientCards.first().click(); - - // Expand Permissions section - await page.locator('text=Permissions').click(); - await page.waitForTimeout(300); - - // Find a non-default feature set checkbox - const featureSetToggle = page.locator('button:has([class*="rounded border"])').first(); - if (await featureSetToggle.isVisible()) { - await featureSetToggle.click(); - - await clients.waitForToast('success'); - const toastText = await clients.getToastText(); - expect(toastText).toMatch(/Permission (granted|revoked)/); - } + + page.on('dialog', (dialog) => dialog.accept()); + await page.getByRole('button', { name: /Revoke connection/ }).click(); + // Confirm dialog + await page.getByRole('button', { name: /Revoke/ }).click(); + + await clients.waitForToast('success'); + expect(await clients.getToastText()).toMatch(/revoked/); } }); }); diff --git a/tests/e2e/specs/clients.wdio.ts b/tests/e2e/specs/clients.wdio.ts index d8950109..eeda4cc8 100644 --- a/tests/e2e/specs/clients.wdio.ts +++ b/tests/e2e/specs/clients.wdio.ts @@ -1,126 +1,60 @@ /** - * E2E Tests: Client Management + * E2E Tests: Connections page (the renamed, observability-focused view). + * + * Routing is no longer configured here — that lives in Workspaces. These + * specs verify the page loads, reveals the list of approved clients (if + * any), and surfaces a link back to Workspaces instead of per-client + * routing controls. + * * Uses data-testid only (ADR-003). */ import { byTestId } from '../helpers/selectors'; -describe('Client Management - View Clients', () => { - it('TC-CL-001: Navigate to Clients page and display registered clients', async () => { - const clientsButton = await byTestId('nav-clients'); - await clientsButton.click(); +describe('Connections - Page shell', () => { + it('TC-CL-001: Navigate to Connections page and see heading + Workspaces link', async () => { + const connectionsBtn = await byTestId('nav-clients'); + await connectionsBtn.click(); await browser.pause(2000); - - await browser.saveScreenshot('./tests/e2e/screenshots/cl-01-clients-page.png'); - - // Verify page loaded + + await browser.saveScreenshot('./tests/e2e/screenshots/cl-01-connections-page.png'); + const pageSource = await browser.getPageSource(); - const hasClientsPage = pageSource.includes('Clients'); - - expect(hasClientsPage).toBe(true); - - // Check for preset clients (Cursor, VS Code, Claude Desktop) - const hasCursor = pageSource.includes('Cursor'); - const hasVSCode = pageSource.includes('VS Code') || pageSource.includes('VSCode'); - const hasClaude = pageSource.includes('Claude'); - - console.log('[DEBUG] Has Cursor:', hasCursor); - console.log('[DEBUG] Has VS Code:', hasVSCode); - console.log('[DEBUG] Has Claude:', hasClaude); - - // At least one preset client should exist - const hasPresetClients = hasCursor || hasVSCode || hasClaude; - expect(hasPresetClients).toBe(true); + + // Heading has been renamed. + expect(pageSource.includes('Connections')).toBe(true); + + // The page routes users to Workspaces for any routing questions. + expect(pageSource.includes('Workspaces')).toBe(true); }); - it('TC-CL-002: Click on a client to open detail panel', async () => { + it('TC-CL-002: Open side panel and verify legacy routing controls are gone', async () => { const clientCards = await $$('[data-testid^="client-card-"]'); const firstCard = clientCards[0]; const isDisplayed = firstCard ? await firstCard.isDisplayed().catch(() => false) : false; - + if (isDisplayed && firstCard) { await firstCard.click(); await browser.pause(1500); - - await browser.saveScreenshot('./tests/e2e/screenshots/cl-02-client-panel.png'); - - // Verify panel opened - should show settings/permissions sections - const pageSource = await browser.getPageSource(); - const hasPanelContent = - pageSource.includes('Settings') || - pageSource.includes('Permissions') || - pageSource.includes('Features') || - pageSource.includes('Connection'); - - expect(hasPanelContent).toBe(true); - } else { - const pageSource = await browser.getPageSource(); - expect(pageSource.includes('Client') || pageSource.includes('Permissions') || pageSource.includes('Clients')).toBe(true); - } - }); - it('TC-CL-009: Verify Default FeatureSet is shown as granted', async () => { - // Should already have panel open from previous test - await browser.saveScreenshot('./tests/e2e/screenshots/cl-03-permissions.png'); - - const pageSource = await browser.getPageSource(); - - // Look for Permissions section and Default feature set - const hasPermissions = pageSource.includes('Permission') || pageSource.includes('Feature'); - const hasDefault = pageSource.includes('Default'); - - console.log('[DEBUG] Has Permissions section:', hasPermissions); - console.log('[DEBUG] Has Default mentioned:', hasDefault); - - // The page should have permission-related content - expect(hasPermissions).toBe(true); - }); + await browser.saveScreenshot('./tests/e2e/screenshots/cl-02-connection-panel.png'); - it('TC-CL-010: Check for Effective Features section', async () => { - // Look for Effective Features section - const pageSource = await browser.getPageSource(); - - const hasEffectiveFeatures = - pageSource.includes('Effective') || - pageSource.includes('Features') || - pageSource.includes('Tools') || - pageSource.includes('Prompts'); - - console.log('[DEBUG] Has Effective Features:', hasEffectiveFeatures); - - await browser.saveScreenshot('./tests/e2e/screenshots/cl-04-effective-features.png'); - - expect(hasEffectiveFeatures).toBe(true); - }); -}); + const pageSource = await browser.getPageSource(); -describe('Client Management - Connection Modes', () => { - it('TC-CL-004: Verify connection mode options exist', async () => { - const clientsButton = await byTestId('nav-clients'); - await clientsButton.click(); - await browser.pause(2000); - - const clientCards = await $$('[data-testid^="client-card-"]'); - const firstCard = clientCards[0]; - if (firstCard && await firstCard.isDisplayed().catch(() => false)) { - await firstCard.click(); - await browser.pause(1500); + // Positive: the new panel exposes the Workspaces entry point. + const hasWorkspacesLink = + pageSource.includes('Open Workspaces') || pageSource.includes('workspace-driven'); + expect(hasWorkspacesLink).toBe(true); + + // Negative: all removed per-client routing sections must be gone. + expect(pageSource.includes('Quick Settings')).toBe(false); + expect(pageSource.includes('Connection Mode')).toBe(false); + expect(pageSource.includes('Effective Features')).toBe(false); + expect(pageSource.includes('Advanced Permissions')).toBe(false); + } else { + // Empty-state path: ConnectIDEs onboarding must render instead. + const pageSource = await browser.getPageSource(); + expect(pageSource.includes("Let's hook up your first IDE")).toBe(true); } - - await browser.saveScreenshot('./tests/e2e/screenshots/cl-05-connection-mode.png'); - - // Check for connection mode options - const pageSource = await browser.getPageSource(); - const hasConnectionMode = - pageSource.includes('Follow') || - pageSource.includes('Locked') || - pageSource.includes('Ask') || - pageSource.includes('Connection') || - pageSource.includes('Mode'); - - console.log('[DEBUG] Has connection mode options:', hasConnectionMode); - - // Connection mode should be visible in client settings - expect(hasConnectionMode).toBe(true); }); }); diff --git a/tests/e2e/specs/comprehensive.wdio.ts b/tests/e2e/specs/comprehensive.wdio.ts index af80ce3c..21622581 100644 --- a/tests/e2e/specs/comprehensive.wdio.ts +++ b/tests/e2e/specs/comprehensive.wdio.ts @@ -7,12 +7,8 @@ import { byTestId, safeClick } from '../helpers/selectors'; import { createSpace, deleteSpace, - getActiveSpace, - setActiveSpace, + getDefaultSpace, listSpaces, - createClient, - deleteClient, - listClients, listFeatureSetsBySpace, createFeatureSet, deleteFeatureSet, @@ -22,7 +18,6 @@ import { enableServerV2, disableServerV2, getGatewayStatus, - grantFeatureSetToClient, } from '../helpers/tauri-api'; // ============================================================================ @@ -37,8 +32,8 @@ describe('Comprehensive: Space Isolation', () => { before(async () => { // Get default space - const activeSpace = await getActiveSpace(); - defaultSpaceId = activeSpace?.id || ''; + const defaultSpace = await getDefaultSpace(); + defaultSpaceId = defaultSpace?.id || ''; console.log('[setup] Default space:', defaultSpaceId); // Create test spaces @@ -69,9 +64,8 @@ describe('Comprehensive: Space Isolation', () => { }); it('TC-COMP-SP-002: Enable server and verify FeatureSet created', async () => { - // Set Work space as active - await setActiveSpace(workSpaceId); - await browser.pause(500); + // Server-enable / FS-listing APIs are scoped by spaceId arg — no + // "active space" switch needed. Routing is per workspace root now. // Enable server - MCP handshake can fail on CI, so wrap in try-catch try { @@ -93,8 +87,6 @@ describe('Comprehensive: Space Isolation', () => { }); it('TC-COMP-SP-003: Verify UI shows correct space servers', async () => { - await setActiveSpace(workSpaceId); - await browser.pause(500); await browser.refresh(); await browser.pause(2000); @@ -112,11 +104,8 @@ describe('Comprehensive: Space Isolation', () => { }); it('TC-COMP-SP-004: Switch space and verify server not visible', async () => { - // Switch to Personal space - await setActiveSpace(personalSpaceId); - await browser.pause(500); - - // Refresh UI + // Server isolation is verified via the spaceId-bound API — no UI + // active-space switch needed. await browser.refresh(); await browser.pause(2000); @@ -141,57 +130,15 @@ describe('Comprehensive: Space Isolation', () => { try { await deleteSpace(personalSpaceId); } catch (e) { /* ignore */ } - - // Reset to default space - if (defaultSpaceId) { - await setActiveSpace(defaultSpaceId); - } }); }); // ============================================================================ -// Test Suite: Client Grants +// Test Suite: Connections page (observability — no more per-client grants) // ============================================================================ -describe('Comprehensive: Client Grants', () => { - let defaultSpaceId: string; - let testClientId: string; - let defaultFeatureSetId: string; - - before(async () => { - // Get default space - const activeSpace = await getActiveSpace(); - defaultSpaceId = activeSpace?.id || ''; - - // Create test client - const client = await createClient({ - name: 'Test Client for Grants', - client_type: 'test', - connection_mode: 'follow_active', - }); - testClientId = client.id; - console.log('[setup] Created client:', testClientId); - - // Get default feature set - const featureSets = await listFeatureSetsBySpace(defaultSpaceId); - const defaultFs = featureSets.find(fs => fs.feature_set_type === 'default'); - defaultFeatureSetId = defaultFs?.id || ''; - console.log('[setup] Default FeatureSet:', defaultFeatureSetId); - }); - - it('TC-COMP-CL-001: Grant FeatureSet to client', async () => { - // Grant default feature set - await grantFeatureSetToClient(testClientId, defaultSpaceId, defaultFeatureSetId); - - // Verify client has grants - const clients = await listClients(); - const ourClient = clients.find(c => c.id === testClientId); - - expect(ourClient).toBeDefined(); - console.log('[test] Client grants:', JSON.stringify(ourClient?.grants)); - }); - - it('TC-COMP-CL-002: Verify Clients page loads', async () => { +describe('Comprehensive: Connections page', () => { + it('TC-COMP-CL-001: Verify Connections page loads', async () => { const clientsBtn = await byTestId('nav-clients'); await safeClick(clientsBtn); await browser.pause(2000); @@ -199,16 +146,10 @@ describe('Comprehensive: Client Grants', () => { await browser.saveScreenshot('./tests/e2e/screenshots/comp-03-clients.png'); const pageSource = await browser.getPageSource(); - expect(pageSource.includes('Clients') || pageSource.includes('Client')).toBe(true); - }); - - after(async () => { - // Cleanup - if (testClientId) { - try { - await deleteClient(testClientId); - } catch (e) { /* ignore */ } - } + // Heading changed from "Connected Clients" to "Connections". + expect(pageSource.includes('Connections')).toBe(true); + // And routing is advertised as workspace-driven, not per-client. + expect(pageSource.includes('Workspaces')).toBe(true); }); }); @@ -221,9 +162,8 @@ describe('Comprehensive: Server Lifecycle with API', () => { const serverId = 'github-server'; // From mock bundle before(async () => { - const activeSpace = await getActiveSpace(); - defaultSpaceId = activeSpace?.id || ''; - await setActiveSpace(defaultSpaceId); + const defaultSpace = await getDefaultSpace(); + defaultSpaceId = defaultSpace?.id || ''; // Uninstall if already present (from earlier specs) to ensure clean state try { await uninstallServer(serverId, defaultSpaceId); @@ -413,7 +353,6 @@ describe('Comprehensive: Multi-Space Server Management', () => { it('TC-COMP-MS-002: Enable server in first space only', async () => { // Enable in first space - MCP handshake can fail on CI - await setActiveSpace(testSpaces[0]); try { await enableServerV2(testSpaces[0], serverId); await browser.pause(5000); // Longer wait for CI @@ -462,10 +401,5 @@ describe('Comprehensive: Multi-Space Server Management', () => { await deleteSpace(spaceId); } catch (e) { /* ignore */ } } - - // Reset to default space - if (defaultSpaceId) { - await setActiveSpace(defaultSpaceId); - } }); }); diff --git a/tests/e2e/specs/gateway.wdio.ts b/tests/e2e/specs/gateway.wdio.ts index 4a13361e..362abb71 100644 --- a/tests/e2e/specs/gateway.wdio.ts +++ b/tests/e2e/specs/gateway.wdio.ts @@ -36,9 +36,9 @@ describe('Gateway Status - Dashboard', () => { const pageSource = await browser.getPageSource(); // Gateway should be running by default - const isRunning = - pageSource.includes('Gateway: Running') || - pageSource.includes('border-green-500'); + const isRunning = + pageSource.includes('Gateway running') || + pageSource.includes('bg-green-500'); console.log('[DEBUG] Gateway running:', isRunning); expect(isRunning).toBe(true); @@ -49,9 +49,9 @@ describe('Gateway Status - Dashboard', () => { const pageSource = await browser.getPageSource(); // Check for gateway status card - const hasGatewayCard = - pageSource.includes('Gateway: Running') || - pageSource.includes('Gateway: Stopped') || + const hasGatewayCard = + pageSource.includes('Gateway running') || + pageSource.includes('Gateway stopped') || pageSource.includes('gateway-status-card'); expect(hasGatewayCard).toBe(true); diff --git a/tests/e2e/specs/meta-tools.wdio.ts b/tests/e2e/specs/meta-tools.wdio.ts new file mode 100644 index 00000000..e725a16f --- /dev/null +++ b/tests/e2e/specs/meta-tools.wdio.ts @@ -0,0 +1,116 @@ +/** + * E2E Tests: self-management `mcpmux_*` meta tools. + * + * Covers the user-visible approval flow end-to-end: + * * the master switch round-trips through the SettingsPage + * * the approval dialog renders when the gateway emits a request event + * * the Allow/Deny buttons call respond_to_meta_tool_approval + * * the grants panel + audit log render without a live gateway + * + * The gateway's internal state machine is covered by the Rust integration + * tests; here we verify the Tauri bridge + React wiring actually moves + * bytes between the two. + */ + +import { byTestId, TIMEOUT, safeClick } from '../helpers/selectors'; +import { emitEvent, invoke } from '../helpers/tauri-api'; + +describe('Meta tools - Settings UI', () => { + it('TC-MT-001: Master-switch round-trips through Settings > get_meta_tools_enabled', async () => { + const settingsButton = await byTestId('nav-settings'); + await safeClick(settingsButton); + await browser.pause(1000); + + const metaSection = await byTestId('settings-meta-tools-section'); + await expect(metaSection).toBeDisplayed(); + + // Initial state should be enabled (product default). + const initial = await invoke('get_meta_tools_enabled'); + expect(initial).toBe(true); + + // Toggle via the Tauri command and verify UI reflects the change after + // a navigation away-and-back (the switch is loaded on mount). + await invoke('set_meta_tools_enabled', { enabled: false }); + expect(await invoke('get_meta_tools_enabled')).toBe(false); + + // Restore so subsequent tests see the default. + await invoke('set_meta_tools_enabled', { enabled: true }); + expect(await invoke('get_meta_tools_enabled')).toBe(true); + }); + + it('TC-MT-002: Grants panel + audit log render in the Settings section', async () => { + const settingsButton = await byTestId('nav-settings'); + await safeClick(settingsButton); + await browser.pause(1000); + + const grants = await byTestId('meta-tool-grants-panel'); + const audit = await byTestId('meta-tool-audit-log'); + await expect(grants).toBeDisplayed(); + await expect(audit).toBeDisplayed(); + }); +}); + +describe('Meta tools - Approval dialog', () => { + it('TC-MT-010: Emitting `meta-tool-approval-request` surfaces the dialog', async () => { + // Fire a synthetic approval request from the Rust side; the dialog + // component listens on this exact Tauri event name, no gateway needed. + const requestId = `test-${Date.now()}`; + await emitEvent('meta-tool-approval-request', { + request_id: requestId, + client_id: '00000000-0000-0000-0000-0000000000aa', + payload: { + tool_name: 'mcpmux_pin_this_session', + summary: 'E2E: pin to FeatureSet "tiny" (3 tools)', + diff: { + before: ['github_create_issue', 'firebase_deploy', 'slack_send'], + after: ['github_create_issue'], + added: [], + removed: ['firebase_deploy', 'slack_send'], + }, + raw_args: { feature_set_id: '11111111-1111-1111-1111-111111111111' }, + affects_other_clients: false, + }, + expires_at_unix_secs: Math.floor(Date.now() / 1000) + 60, + }); + + const dialog = await byTestId('meta-tool-approval-dialog'); + await dialog.waitForDisplayed({ timeout: TIMEOUT.medium }); + + // Every button is present and clickable. + await expect(await byTestId('meta-tool-approval-allow-once')).toBeDisplayed(); + await expect(await byTestId('meta-tool-approval-always')).toBeDisplayed(); + await expect(await byTestId('meta-tool-approval-deny')).toBeDisplayed(); + }); + + it('TC-MT-011: Clicking Deny closes the dialog and records a decision', async () => { + // Queue a fresh dialog (previous test may have left one mid-flight on + // slow CI — wait for it to close first). + const requestId = `test-deny-${Date.now()}`; + await emitEvent('meta-tool-approval-request', { + request_id: requestId, + client_id: '00000000-0000-0000-0000-0000000000bb', + payload: { + tool_name: 'mcpmux_set_space_active', + summary: 'E2E deny: change space active FS', + diff: null, + raw_args: {}, + affects_other_clients: true, + }, + expires_at_unix_secs: Math.floor(Date.now() / 1000) + 60, + }); + + const dialog = await byTestId('meta-tool-approval-dialog'); + await dialog.waitForDisplayed({ timeout: TIMEOUT.medium }); + + // The dialog shows the cross-client warning for this request. + await expect( + await byTestId('meta-tool-approval-cross-client-warning') + ).toBeDisplayed(); + + const deny = await byTestId('meta-tool-approval-deny'); + await safeClick(deny); + + // Dialog dismisses after the respond_to_meta_tool_approval round-trip. + await dialog.waitForDisplayed({ reverse: true, timeout: TIMEOUT.medium }); + }); +}); diff --git a/tests/e2e/specs/post-action-guidance.spec.ts b/tests/e2e/specs/post-action-guidance.spec.ts index b4d706d2..8499d66c 100644 --- a/tests/e2e/specs/post-action-guidance.spec.ts +++ b/tests/e2e/specs/post-action-guidance.spec.ts @@ -89,19 +89,19 @@ test.describe('Post-Action User Guidance', () => { test.describe('OAuth consent post-approval guidance', () => { // Skip in web mode - OAuth consent requires Tauri deep link events - test.skip('should show success state with Manage Permissions button after approval', async ({ page }) => { - // This test requires the OAuthConsentModal to be triggered via a deep link event - // which is only available in the full Tauri desktop app + test.skip('should show success state with Open Workspaces button after approval', async ({ + page, + }) => { + // This test requires the OAuthConsentModal to be triggered via a deep link + // event, which is only available in the full Tauri desktop app. const dashboard = new DashboardPage(page); await dashboard.navigate(); - // After approval, the modal should show: - // - "Client Approved" heading - // - "Manage Permissions" button - // - "Later" button - const manageBtn = page.locator('[data-testid="go-to-clients-btn"]'); - await expect(manageBtn).toBeVisible(); - await expect(manageBtn).toContainText('Manage Permissions'); + // In the v2 flow the post-approval screen sends users to Workspaces + // (where routing per folder lives), not to a per-client permissions page. + const openWorkspacesBtn = page.locator('[data-testid="go-to-workspaces-btn"]'); + await expect(openWorkspacesBtn).toBeVisible(); + await expect(openWorkspacesBtn).toContainText('Open Workspaces'); }); }); }); diff --git a/tests/e2e/specs/spaces.spec.ts b/tests/e2e/specs/spaces.spec.ts index 8c440a3c..797720a8 100644 --- a/tests/e2e/specs/spaces.spec.ts +++ b/tests/e2e/specs/spaces.spec.ts @@ -163,24 +163,8 @@ test.describe('Space Toast Notifications', () => { expect(toastText).toContain('Space created'); }); - // Skip in web mode - requires Tauri API - test.skip('should show success toast on set active space', async ({ page }) => { - const dashboard = new DashboardPage(page); - const spacesPage = new SpacesPage(page); - await dashboard.navigate(); - - await goToSpaces(page); - - // Find a non-active space and click "Set Active" - const setActiveBtn = page.locator('[data-testid^="set-active-space-"]').first(); - if (await setActiveBtn.isVisible()) { - await setActiveBtn.click(); - - await spacesPage.waitForToast('success'); - const toastText = await spacesPage.getToastText(); - expect(toastText).toContain('Active space changed'); - } - }); + // Removed: "Set Active" toast test — gateway routing is workspace-root-driven, + // there is no per-Space active toggle anymore. // Skip in web mode - requires Tauri API test.skip('should show success toast on space deletion', async ({ page }) => { diff --git a/tests/e2e/specs/spaces.wdio.ts b/tests/e2e/specs/spaces.wdio.ts index 2ac3703e..34883d47 100644 --- a/tests/e2e/specs/spaces.wdio.ts +++ b/tests/e2e/specs/spaces.wdio.ts @@ -103,29 +103,8 @@ describe('Space Management - Create and Delete', () => { } }); - it('TC-SP-003: Set a space as active', async () => { - await dismissCreateModalIfOpen(); - const setActiveButtons = await $$('[data-testid^="set-active-space-"]'); - - if (setActiveButtons.length > 0) { - const firstButton = setActiveButtons[0]; - const isDisplayed = await firstButton.isDisplayed().catch(() => false); - if (isDisplayed) { - await browser.saveScreenshot('./tests/e2e/screenshots/sp-04-before-set-active.png'); - await firstButton.click(); - await browser.pause(2000); - await browser.saveScreenshot('./tests/e2e/screenshots/sp-05-after-set-active.png'); - } - } - - // Verify page has active space indicator - const pageSource = await browser.getPageSource(); - const hasActiveIndicator = - pageSource.includes('Active') || - pageSource.includes('active'); - - expect(hasActiveIndicator).toBe(true); - }); + // TC-SP-003 removed: there's no "Set Active" affordance — gateway routing + // is decided per reported workspace root via WorkspaceBinding. it('TC-SP-011: Verify spaces are listed on page', async () => { await dismissCreateModalIfOpen(); diff --git a/tests/e2e/specs/workspaces.wdio.ts b/tests/e2e/specs/workspaces.wdio.ts new file mode 100644 index 00000000..a8824d5d --- /dev/null +++ b/tests/e2e/specs/workspaces.wdio.ts @@ -0,0 +1,184 @@ +/** + * E2E Tests: Workspaces page. + * + * A WorkspaceBinding maps a normalized filesystem path to a concrete + * (space_id, feature_set_id) pair. Roots are globally unique. These specs + * cover the CRUD path plus the UI shell. + * + * Uses data-testid only (ADR-003). + */ + +import { byTestId, safeClick, TIMEOUT } from '../helpers/selectors'; +import { + createWorkspaceBinding, + deleteWorkspaceBinding, + getActiveSpace, + listFeatureSetsBySpace, + listWorkspaceBindings, + type WorkspaceBinding, +} from '../helpers/tauri-api'; + +function uniqueRoot(): string { + const stamp = Date.now(); + return process.platform === 'win32' + ? `d:\\tmp\\mcpmux-e2e-${stamp}` + : `/tmp/mcpmux-e2e-${stamp}`; +} + +describe('Workspaces - Page shell', () => { + before(async () => { + // Clean any leftover e2e bindings so the empty-state / populated-state + // assertions are deterministic across reruns. + const existing = await listWorkspaceBindings(); + for (const b of existing.filter((x) => x.workspace_root.includes('mcpmux-e2e'))) { + await deleteWorkspaceBinding(b.id); + } + }); + + it('TC-WS-001: Navigate to Workspaces page and see heading', async () => { + const nav = await byTestId('nav-workspaces'); + await safeClick(nav); + await browser.pause(1500); + + await browser.saveScreenshot('./tests/e2e/screenshots/ws-01-page.png'); + + const src = await browser.getPageSource(); + expect(src.includes('Workspaces')).toBe(true); + + const createBtn = await byTestId('workspace-binding-create-toggle'); + expect(await createBtn.isDisplayed()).toBe(true); + }); +}); + +describe('Workspaces - Create, render, delete', () => { + let bindingId: string | null = null; + let spaceId = ''; + let featureSetId = ''; + const root = uniqueRoot(); + + before(async () => { + const active = await getActiveSpace(); + if (!active) throw new Error('No active space — cannot set up test'); + spaceId = active.id; + const fsList = await listFeatureSetsBySpace(spaceId); + const defaultFs = fsList.find((fs) => fs.feature_set_type === 'default'); + if (!defaultFs) throw new Error('No Default FS in active space'); + featureSetId = defaultFs.id; + }); + + it('TC-WS-002: Create binding pointing at the active space default FS', async () => { + const created: WorkspaceBinding = await createWorkspaceBinding({ + workspace_root: root, + space_id: spaceId, + feature_set_id: featureSetId, + }); + bindingId = created.id; + + expect(created.workspace_root.toLowerCase().endsWith(root.toLowerCase())).toBe(true); + expect(created.space_id).toBe(spaceId); + expect(created.feature_set_id).toBe(featureSetId); + }); + + it('TC-WS-003: Binding row renders on the Workspaces page', async () => { + const nav = await byTestId('nav-workspaces'); + await safeClick(nav); + await browser.pause(1500); + + // Brief nav-away-and-back to force a data reload. + const dashBtn = await byTestId('nav-dashboard'); + await safeClick(dashBtn); + await browser.pause(300); + await safeClick(nav); + await browser.pause(1500); + + await browser.saveScreenshot('./tests/e2e/screenshots/ws-02-populated.png'); + + if (bindingId) { + const row = await $(`[data-testid="workspace-binding-row-${bindingId}"]`); + await row.waitForDisplayed({ timeout: TIMEOUT.short }); + expect(await row.isDisplayed()).toBe(true); + } + }); + + it('TC-WS-004: Binding row references the target Space + FS by name', async () => { + const src = await browser.getPageSource(); + // The row's footer shows "Routes to in " — check the Space + // name is present. FS is "Default" (builtin) which may also appear in + // unrelated copy, so we only assert on the Space name for stability. + const active = await getActiveSpace(); + expect(src.includes(active?.name ?? '__never__')).toBe(true); + }); + + it('TC-WS-005: Delete binding and row disappears', async () => { + if (!bindingId) throw new Error('bindingId missing — TC-WS-002 must succeed first'); + await deleteWorkspaceBinding(bindingId); + + const dash = await byTestId('nav-dashboard'); + await safeClick(dash); + await browser.pause(300); + const nav = await byTestId('nav-workspaces'); + await safeClick(nav); + await browser.pause(1500); + + const rows = await $$(`[data-testid="workspace-binding-row-${bindingId}"]`); + expect(rows.length).toBe(0); + bindingId = null; + }); + + after(async () => { + if (bindingId) { + try { + await deleteWorkspaceBinding(bindingId); + } catch { + /* ignore */ + } + } + }); +}); + +describe('Workspaces - Create form flow (UI)', () => { + let bindingId: string | null = null; + + it('TC-WS-006: Create binding through the form and see it listed', async () => { + const nav = await byTestId('nav-workspaces'); + await safeClick(nav); + await browser.pause(1000); + + const toggle = await byTestId('workspace-binding-create-toggle'); + await safeClick(toggle); + await browser.pause(400); + + const rootInput = await byTestId('workspace-binding-root-input'); + const root = uniqueRoot(); + await rootInput.setValue(root); + + // `space` and `fs` default to the active space + its Default FS, so we + // can submit without touching the pickers. + const submit = await byTestId('workspace-binding-submit'); + await safeClick(submit); + await browser.pause(800); + + const created = (await listWorkspaceBindings()).find( + (b) => b.workspace_root.toLowerCase().endsWith(root.toLowerCase()) + ); + expect(created).toBeTruthy(); + if (created) { + bindingId = created.id; + const row = await $(`[data-testid="workspace-binding-row-${created.id}"]`); + await row.waitForDisplayed({ timeout: TIMEOUT.short }); + expect(await row.isDisplayed()).toBe(true); + } + + await browser.saveScreenshot('./tests/e2e/screenshots/ws-04-created-via-form.png'); + }); + + after(async () => { + if (bindingId) { + try { + await deleteWorkspaceBinding(bindingId); + } catch { + /* ignore */ + } + } + }); +}); diff --git a/tests/rust/Cargo.toml b/tests/rust/Cargo.toml index c7edfc35..4934b434 100644 --- a/tests/rust/Cargo.toml +++ b/tests/rust/Cargo.toml @@ -50,7 +50,7 @@ url = "2.5" parking_lot = "0.12" # RMCP for streamable HTTP transport tests -rmcp = { version = "0.17.0", features = [ +rmcp = { version = "1.5", features = [ "client", "server", "transport-streamable-http-server", @@ -62,6 +62,9 @@ axum = "0.8" # Pipe creation for stderr capture tests os_pipe = { workspace = true } +# Error types for mocks +anyhow = "1" + [lib] path = "src/lib.rs" diff --git a/tests/rust/src/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 1c2fbacb..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 @@ -162,23 +164,9 @@ pub mod fixtures { .with_description(format!("Test feature set: {}", name)) } - /// Create an "all features" feature set - pub fn all_features_set(space_id: &str) -> FeatureSet { - FeatureSet::new_all(space_id) - } - - /// Create a "default" feature set - pub fn default_feature_set(space_id: &str) -> FeatureSet { - FeatureSet::new_default(space_id) - } - - /// Create a server-all feature set - pub fn server_all_feature_set( - space_id: &str, - server_id: &str, - server_name: &str, - ) -> FeatureSet { - FeatureSet::new_server_all(space_id, server_id, server_name) + /// Create the auto-seeded "Starter" FeatureSet for a Space. + pub fn starter_feature_set(space_id: &str) -> FeatureSet { + FeatureSet::new_starter(space_id) } /// Generate a random UUID string diff --git a/tests/rust/src/mocks.rs b/tests/rust/src/mocks.rs index 227cc13f..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(()) + } } // ============================================================================ @@ -400,28 +407,7 @@ impl FeatureSetRepository for MockFeatureSetRepository { Ok(()) } - async fn list_builtin(&self, space_id: &str) -> RepoResult> { - Ok(self - .sets - .read() - .unwrap() - .values() - .filter(|s| { - s.space_id.as_deref() == Some(space_id) - && matches!( - s.feature_set_type, - FeatureSetType::All | FeatureSetType::Default - ) - }) - .cloned() - .collect()) - } - - async fn get_server_all( - &self, - space_id: &str, - server_id: &str, - ) -> RepoResult> { + async fn get_starter_for_space(&self, space_id: &str) -> RepoResult> { Ok(self .sets .read() @@ -429,64 +415,14 @@ impl FeatureSetRepository for MockFeatureSetRepository { .values() .find(|s| { s.space_id.as_deref() == Some(space_id) - && s.feature_set_type == FeatureSetType::ServerAll - && s.server_id.as_deref() == Some(server_id) - }) - .cloned()) - } - - async fn ensure_server_all( - &self, - space_id: &str, - server_id: &str, - server_name: &str, - ) -> RepoResult { - if let Some(existing) = self.get_server_all(space_id, server_id).await? { - return Ok(existing); - } - let set = FeatureSet::new_server_all(space_id, server_id, server_name); - self.create(&set).await?; - Ok(set) - } - - async fn get_default_for_space(&self, space_id: &str) -> RepoResult> { - Ok(self - .sets - .read() - .unwrap() - .values() - .find(|s| { - s.space_id.as_deref() == Some(space_id) - && s.feature_set_type == FeatureSetType::Default - }) - .cloned()) - } - - async fn get_all_for_space(&self, space_id: &str) -> RepoResult> { - Ok(self - .sets - .read() - .unwrap() - .values() - .find(|s| { - s.space_id.as_deref() == Some(space_id) && s.feature_set_type == FeatureSetType::All + && s.feature_set_type == FeatureSetType::Starter }) .cloned()) } async fn ensure_builtin_for_space(&self, space_id: &str) -> RepoResult<()> { - if self.get_all_for_space(space_id).await?.is_none() { - self.create(&FeatureSet::new_all(space_id)).await?; - } - if self.get_default_for_space(space_id).await?.is_none() { - self.create(&FeatureSet::new_default(space_id)).await?; - } - Ok(()) - } - - async fn delete_server_all(&self, space_id: &str, server_id: &str) -> RepoResult<()> { - if let Some(set) = self.get_server_all(space_id, server_id).await? { - self.delete(&set.id).await?; + if self.get_starter_for_space(space_id).await?.is_none() { + self.create(&FeatureSet::new_starter(space_id)).await?; } Ok(()) } @@ -503,6 +439,7 @@ impl FeatureSetRepository for MockFeatureSetRepository { member_type: MemberType::Feature, member_id: feature_id.to_string(), mode, + surfaced: false, }; self.members .write() @@ -543,7 +480,6 @@ impl FeatureSetRepository for MockFeatureSetRepository { #[derive(Default)] pub struct MockInboundMcpClientRepository { clients: RwLock>, - grants: RwLock>>, // (client_id, space_id) -> feature_set_ids } impl MockInboundMcpClientRepository { @@ -597,86 +533,6 @@ impl InboundMcpClientRepository for MockInboundMcpClientRepository { self.clients.write().unwrap().remove(id); Ok(()) } - - async fn grant_feature_set( - &self, - client_id: &Uuid, - space_id: &str, - feature_set_id: &str, - ) -> RepoResult<()> { - self.grants - .write() - .unwrap() - .entry((*client_id, space_id.to_string())) - .or_default() - .push(feature_set_id.to_string()); - Ok(()) - } - - async fn revoke_feature_set( - &self, - client_id: &Uuid, - space_id: &str, - feature_set_id: &str, - ) -> RepoResult<()> { - if let Some(sets) = self - .grants - .write() - .unwrap() - .get_mut(&(*client_id, space_id.to_string())) - { - sets.retain(|s| s != feature_set_id); - } - Ok(()) - } - - async fn get_grants_for_space( - &self, - client_id: &Uuid, - space_id: &str, - ) -> RepoResult> { - Ok(self - .grants - .read() - .unwrap() - .get(&(*client_id, space_id.to_string())) - .cloned() - .unwrap_or_default()) - } - - async fn get_all_grants(&self, client_id: &Uuid) -> RepoResult>> { - let grants = self.grants.read().unwrap(); - let mut result = HashMap::new(); - for ((cid, space_id), sets) in grants.iter() { - if cid == client_id { - result.insert(space_id.clone(), sets.clone()); - } - } - Ok(result) - } - - async fn set_grants_for_space( - &self, - client_id: &Uuid, - space_id: &str, - feature_set_ids: &[String], - ) -> RepoResult<()> { - self.grants - .write() - .unwrap() - .insert((*client_id, space_id.to_string()), feature_set_ids.to_vec()); - Ok(()) - } - - async fn has_grants_for_space(&self, client_id: &Uuid, space_id: &str) -> RepoResult { - Ok(self - .grants - .read() - .unwrap() - .get(&(*client_id, space_id.to_string())) - .map(|v| !v.is_empty()) - .unwrap_or(false)) - } } // ============================================================================ 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/database/feature_set.rs b/tests/rust/tests/database/feature_set.rs index ecef0b59..c160e881 100644 --- a/tests/rust/tests/database/feature_set.rs +++ b/tests/rust/tests/database/feature_set.rs @@ -68,17 +68,17 @@ async fn test_list_by_space() { .await .unwrap(); - // List for space1: 2 custom + 2 builtin (All, Default) = 4 + // List for space1: 2 custom + 1 builtin (Default only) = 3. let space1_sets = FeatureSetRepository::list_by_space(&feature_repo, &space1.id.to_string()) .await .expect("Failed to list"); - assert_eq!(space1_sets.len(), 4); + assert_eq!(space1_sets.len(), 3); - // List for space2: 1 custom + 2 builtin = 3 + // List for space2: 1 custom + 1 builtin = 2. let space2_sets = FeatureSetRepository::list_by_space(&feature_repo, &space2.id.to_string()) .await .expect("Failed to list"); - assert_eq!(space2_sets.len(), 3); + assert_eq!(space2_sets.len(), 2); } #[tokio::test] @@ -153,27 +153,20 @@ async fn test_ensure_builtin_for_space() { let space = fixtures::test_space("Test Space"); SpaceRepository::create(&space_repo, &space).await.unwrap(); - // Ensure builtin (All + Default) + // Ensure builtin (only the auto-Starter; All/ServerAll were removed) FeatureSetRepository::ensure_builtin_for_space(&feature_repo, &space.id.to_string()) .await .expect("Failed to ensure builtin"); - // Get All feature set - let all_set = FeatureSetRepository::get_all_for_space(&feature_repo, &space.id.to_string()) - .await - .expect("Failed to get All"); - assert!(all_set.is_some()); - assert_eq!(all_set.unwrap().feature_set_type, FeatureSetType::All); - - // Get Default feature set - let default_set = - FeatureSetRepository::get_default_for_space(&feature_repo, &space.id.to_string()) + // Get Starter feature set + let starter_set = + FeatureSetRepository::get_starter_for_space(&feature_repo, &space.id.to_string()) .await - .expect("Failed to get Default"); - assert!(default_set.is_some()); + .expect("Failed to get Starter"); + assert!(starter_set.is_some()); assert_eq!( - default_set.unwrap().feature_set_type, - FeatureSetType::Default + starter_set.unwrap().feature_set_type, + FeatureSetType::Starter ); } @@ -187,7 +180,7 @@ async fn test_ensure_builtin_idempotent() { let space = fixtures::test_space("Test Space"); SpaceRepository::create(&space_repo, &space).await.unwrap(); - // Call twice + // Call twice — must stay idempotent. FeatureSetRepository::ensure_builtin_for_space(&feature_repo, &space.id.to_string()) .await .unwrap(); @@ -195,69 +188,14 @@ async fn test_ensure_builtin_idempotent() { .await .unwrap(); - // Should still have exactly 2 builtin sets - let builtin = FeatureSetRepository::list_builtin(&feature_repo, &space.id.to_string()) - .await - .expect("Failed to list builtin"); - assert_eq!(builtin.len(), 2); -} - -#[tokio::test] -async fn test_server_all_feature_set() { - let test_db = TestDatabase::new(); - let db = Arc::new(Mutex::new(test_db.db)); - let feature_repo = SqliteFeatureSetRepository::new(Arc::clone(&db)); - let space_repo = SqliteSpaceRepository::new(db); - - let space = fixtures::test_space("Test Space"); - SpaceRepository::create(&space_repo, &space).await.unwrap(); - - // Create server-all feature set - let server_all = FeatureSetRepository::ensure_server_all( - &feature_repo, - &space.id.to_string(), - "my-server", - "My Server", - ) - .await - .expect("Failed to ensure server-all"); - - assert_eq!(server_all.feature_set_type, FeatureSetType::ServerAll); - assert_eq!(server_all.server_id, Some("my-server".to_string())); - - // Get by server_id - let found = - FeatureSetRepository::get_server_all(&feature_repo, &space.id.to_string(), "my-server") - .await - .expect("Failed to get server-all"); - assert!(found.is_some()); - assert_eq!(found.unwrap().id, server_all.id); -} - -#[tokio::test] -async fn test_delete_server_all() { - let test_db = TestDatabase::new(); - let db = Arc::new(Mutex::new(test_db.db)); - let feature_repo = SqliteFeatureSetRepository::new(Arc::clone(&db)); - let space_repo = SqliteSpaceRepository::new(db); - - let space = fixtures::test_space("Test Space"); - SpaceRepository::create(&space_repo, &space).await.unwrap(); - - // Create then delete - FeatureSetRepository::ensure_server_all(&feature_repo, &space.id.to_string(), "srv", "Srv") - .await - .unwrap(); - - FeatureSetRepository::delete_server_all(&feature_repo, &space.id.to_string(), "srv") - .await - .expect("Failed to delete server-all"); - - // Should be gone - let found = FeatureSetRepository::get_server_all(&feature_repo, &space.id.to_string(), "srv") + let by_space = FeatureSetRepository::list_by_space(&feature_repo, &space.id.to_string()) .await - .unwrap(); - assert!(found.is_none()); + .expect("Failed to list by space"); + let starter_count = by_space + .iter() + .filter(|fs| matches!(fs.feature_set_type, FeatureSetType::Starter)) + .count(); + assert_eq!(starter_count, 1, "exactly one Starter FS per space"); } // ============================================================================= @@ -430,48 +368,28 @@ async fn test_feature_set_types() { let space = fixtures::test_space("Test Space"); SpaceRepository::create(&space_repo, &space).await.unwrap(); - // Note: SpaceRepository::create auto-creates All and Default feature sets - // So we only need to create Custom and ServerAll here + // Space creation auto-seeds the Starter FS; add a Custom one by hand. let custom = fixtures::test_feature_set("Custom", &space.id.to_string()); - let server_all = fixtures::server_all_feature_set(&space.id.to_string(), "srv", "Server"); FeatureSetRepository::create(&feature_repo, &custom) .await .unwrap(); - FeatureSetRepository::create(&feature_repo, &server_all) - .await - .unwrap(); - - // Verify types - use the auto-created IDs for All and Default - let all_id = format!("fs_all_{}", space.id); - let default_id = format!("fs_default_{}", space.id); - let all_loaded = FeatureSetRepository::get(&feature_repo, &all_id) - .await - .unwrap() - .unwrap(); - assert_eq!(all_loaded.feature_set_type, FeatureSetType::All); + // Stable id prefix kept for FK compatibility — `fs_default_` + // remains the row id even after the type rename. + let starter_id = format!("fs_default_{}", space.id); - let default_loaded = FeatureSetRepository::get(&feature_repo, &default_id) + let starter_loaded = FeatureSetRepository::get(&feature_repo, &starter_id) .await .unwrap() .unwrap(); - assert_eq!(default_loaded.feature_set_type, FeatureSetType::Default); + assert_eq!(starter_loaded.feature_set_type, FeatureSetType::Starter); let custom_loaded = FeatureSetRepository::get(&feature_repo, &custom.id) .await .unwrap() .unwrap(); assert_eq!(custom_loaded.feature_set_type, FeatureSetType::Custom); - - let server_all_loaded = FeatureSetRepository::get(&feature_repo, &server_all.id) - .await - .unwrap() - .unwrap(); - assert_eq!( - server_all_loaded.feature_set_type, - FeatureSetType::ServerAll - ); } // ============================================================================= @@ -503,8 +421,8 @@ async fn test_feature_set_space_isolation() { .await .unwrap(); - // They should be independent - // Each space has 2 builtin (All, Default) + 1 custom = 3 + // They should be independent. Each space auto-seeds the Default FS + // plus the custom one we just added = 2. let work_sets = FeatureSetRepository::list_by_space(&feature_repo, &work.id.to_string()) .await .unwrap(); @@ -513,8 +431,8 @@ async fn test_feature_set_space_isolation() { .await .unwrap(); - assert_eq!(work_sets.len(), 3); - assert_eq!(personal_sets.len(), 3); + assert_eq!(work_sets.len(), 2); + assert_eq!(personal_sets.len(), 2); // Verify the custom sets are different let work_custom: Vec<_> = work_sets diff --git a/tests/rust/tests/database/inbound_client.rs b/tests/rust/tests/database/inbound_client.rs index 4eea1fe5..b645bfe3 100644 --- a/tests/rust/tests/database/inbound_client.rs +++ b/tests/rust/tests/database/inbound_client.rs @@ -3,13 +3,12 @@ //! Tests for DCR registration, OAuth authorization codes, tokens, and client grants. //! These test the INBOUND flow: AI clients (Cursor, Claude) connecting TO McpMux. -use mcpmux_core::repository::SpaceRepository; use mcpmux_storage::{ - AuthorizationCode, InboundClient, InboundClientRepository, RegistrationType, - SqliteSpaceRepository, TokenRecord, TokenType, + AuthorizationCode, InboundClient, InboundClientRepository, RegistrationType, TokenRecord, + TokenType, }; use std::sync::Arc; -use tests::{db::TestDatabase, fixtures}; +use tests::db::TestDatabase; use tokio::sync::Mutex; fn create_test_client(name: &str) -> InboundClient { @@ -35,11 +34,11 @@ fn create_test_client(name: &str) -> InboundClient { metadata_url: None, metadata_cached_at: None, metadata_cache_ttl: None, - connection_mode: "follow_active".to_string(), - locked_space_id: None, last_seen: None, created_at: now.clone(), updated_at: now, + reports_roots: false, + roots_capability_known: false, } } @@ -545,273 +544,37 @@ async fn test_revoke_client_tokens() { } // ============================================================================= -// Client Grants Tests (Feature Set Permissions) +// Client Grants Tests — REMOVED in migration 003. +// +// The `client_grants` table and the repository methods that backed it were +// dropped once the FeatureSetResolver (pin > workspace binding > space-active) +// became authoritative. The trait methods remain as no-op shims for API +// compatibility with Tauri commands, but they no longer persist anything. +// +// For resolver decision-table tests see +// `tests/integration/feature_set_resolver.rs`. // ============================================================================= -#[tokio::test] -async fn test_grant_feature_set() { - let test_db = TestDatabase::new(); - let db = Arc::new(Mutex::new(test_db.db)); - let repo = InboundClientRepository::new(Arc::clone(&db)); - let space_repo = SqliteSpaceRepository::new(db); - - // Create a space (auto-creates All and Default feature sets) - let space = fixtures::test_space("Test Space"); - SpaceRepository::create(&space_repo, &space).await.unwrap(); - - let client = create_test_client("Grant Client"); - repo.save_client(&client).await.unwrap(); - - // Grant the auto-created "All" feature set - let all_fs_id = format!("fs_all_{}", space.id); - repo.grant_feature_set(&client.client_id, &space.id.to_string(), &all_fs_id) - .await - .expect("Failed to grant"); - - // Check grants - let grants = repo - .get_grants_for_space(&client.client_id, &space.id.to_string()) - .await - .unwrap(); - assert_eq!(grants.len(), 1); - assert!(grants.contains(&all_fs_id)); -} - -#[tokio::test] -async fn test_grant_multiple_feature_sets() { - let test_db = TestDatabase::new(); - let db = Arc::new(Mutex::new(test_db.db)); - let repo = InboundClientRepository::new(Arc::clone(&db)); - let space_repo = SqliteSpaceRepository::new(db); - - // Create two spaces - let space1 = fixtures::test_space("Space 1"); - let space2 = fixtures::test_space("Space 2"); - SpaceRepository::create(&space_repo, &space1).await.unwrap(); - SpaceRepository::create(&space_repo, &space2).await.unwrap(); - - let client = create_test_client("Multi Grant"); - repo.save_client(&client).await.unwrap(); - - // Use auto-created feature set IDs - let space1_all = format!("fs_all_{}", space1.id); - let space1_default = format!("fs_default_{}", space1.id); - let space2_all = format!("fs_all_{}", space2.id); - - repo.grant_feature_set(&client.client_id, &space1.id.to_string(), &space1_all) - .await - .unwrap(); - repo.grant_feature_set(&client.client_id, &space1.id.to_string(), &space1_default) - .await - .unwrap(); - repo.grant_feature_set(&client.client_id, &space2.id.to_string(), &space2_all) - .await - .unwrap(); - - // Space 1 should have 2 - let grants1 = repo - .get_grants_for_space(&client.client_id, &space1.id.to_string()) - .await - .unwrap(); - assert_eq!(grants1.len(), 2); - - // Space 2 should have 1 - let grants2 = repo - .get_grants_for_space(&client.client_id, &space2.id.to_string()) - .await - .unwrap(); - assert_eq!(grants2.len(), 1); -} - -#[tokio::test] -async fn test_grant_idempotent() { - let test_db = TestDatabase::new(); - let db = Arc::new(Mutex::new(test_db.db)); - let repo = InboundClientRepository::new(Arc::clone(&db)); - let space_repo = SqliteSpaceRepository::new(db); - - let space = fixtures::test_space("Test Space"); - SpaceRepository::create(&space_repo, &space).await.unwrap(); - - let client = create_test_client("Idempotent"); - repo.save_client(&client).await.unwrap(); - - let all_fs_id = format!("fs_all_{}", space.id); - - // Grant same thing twice - repo.grant_feature_set(&client.client_id, &space.id.to_string(), &all_fs_id) - .await - .unwrap(); - repo.grant_feature_set(&client.client_id, &space.id.to_string(), &all_fs_id) - .await - .unwrap(); - - // Should still be 1 - let grants = repo - .get_grants_for_space(&client.client_id, &space.id.to_string()) - .await - .unwrap(); - assert_eq!(grants.len(), 1); -} - -#[tokio::test] -async fn test_revoke_feature_set() { - let test_db = TestDatabase::new(); - let db = Arc::new(Mutex::new(test_db.db)); - let repo = InboundClientRepository::new(Arc::clone(&db)); - let space_repo = SqliteSpaceRepository::new(db); - - let space = fixtures::test_space("Test Space"); - SpaceRepository::create(&space_repo, &space).await.unwrap(); - - let client = create_test_client("Revoke Grant"); - repo.save_client(&client).await.unwrap(); - - let all_fs_id = format!("fs_all_{}", space.id); - let default_fs_id = format!("fs_default_{}", space.id); - - repo.grant_feature_set(&client.client_id, &space.id.to_string(), &all_fs_id) - .await - .unwrap(); - repo.grant_feature_set(&client.client_id, &space.id.to_string(), &default_fs_id) - .await - .unwrap(); - - // Revoke one - repo.revoke_feature_set(&client.client_id, &space.id.to_string(), &all_fs_id) - .await - .expect("Failed to revoke"); - - // Only default remains - let grants = repo - .get_grants_for_space(&client.client_id, &space.id.to_string()) - .await - .unwrap(); - assert_eq!(grants.len(), 1); - assert!(grants.contains(&default_fs_id)); -} - -#[tokio::test] -async fn test_get_all_grants() { - let test_db = TestDatabase::new(); - let db = Arc::new(Mutex::new(test_db.db)); - let repo = InboundClientRepository::new(Arc::clone(&db)); - let space_repo = SqliteSpaceRepository::new(db); - - let space1 = fixtures::test_space("Space 1"); - let space2 = fixtures::test_space("Space 2"); - SpaceRepository::create(&space_repo, &space1).await.unwrap(); - SpaceRepository::create(&space_repo, &space2).await.unwrap(); - - let client = create_test_client("All Grants"); - repo.save_client(&client).await.unwrap(); - - let space1_all = format!("fs_all_{}", space1.id); - let space1_default = format!("fs_default_{}", space1.id); - let space2_all = format!("fs_all_{}", space2.id); - - repo.grant_feature_set(&client.client_id, &space1.id.to_string(), &space1_all) - .await - .unwrap(); - repo.grant_feature_set(&client.client_id, &space1.id.to_string(), &space1_default) - .await - .unwrap(); - repo.grant_feature_set(&client.client_id, &space2.id.to_string(), &space2_all) - .await - .unwrap(); - - let all_grants = repo - .get_all_grants(&client.client_id) - .await - .expect("Failed to get all"); - assert_eq!(all_grants.len(), 2); // 2 spaces - - assert_eq!(all_grants.get(&space1.id.to_string()).unwrap().len(), 2); - assert_eq!(all_grants.get(&space2.id.to_string()).unwrap().len(), 1); -} - -#[tokio::test] -async fn test_grants_per_space_isolation() { - let test_db = TestDatabase::new(); - let db = Arc::new(Mutex::new(test_db.db)); - let repo = InboundClientRepository::new(Arc::clone(&db)); - let space_repo = SqliteSpaceRepository::new(db); - - let work = fixtures::test_space("Work"); - let personal = fixtures::test_space("Personal"); - SpaceRepository::create(&space_repo, &work).await.unwrap(); - SpaceRepository::create(&space_repo, &personal) - .await - .unwrap(); - - let client = create_test_client("Space Isolation"); - repo.save_client(&client).await.unwrap(); - - let work_all = format!("fs_all_{}", work.id); - let personal_all = format!("fs_all_{}", personal.id); - - // Grant "All" in different spaces - repo.grant_feature_set(&client.client_id, &work.id.to_string(), &work_all) - .await - .unwrap(); - repo.grant_feature_set(&client.client_id, &personal.id.to_string(), &personal_all) - .await - .unwrap(); - - // Revoke from work only - repo.revoke_feature_set(&client.client_id, &work.id.to_string(), &work_all) - .await - .unwrap(); - - // Work should be empty - let work_grants = repo - .get_grants_for_space(&client.client_id, &work.id.to_string()) - .await - .unwrap(); - assert!(work_grants.is_empty()); - - // Personal still has grant - let personal_grants = repo - .get_grants_for_space(&client.client_id, &personal.id.to_string()) - .await - .unwrap(); - assert_eq!(personal_grants.len(), 1); -} - // ============================================================================= // Client Settings Update Tests // ============================================================================= #[tokio::test] -async fn test_update_client_settings() { +async fn test_update_client_alias() { let test_db = TestDatabase::new(); let db = Arc::new(Mutex::new(test_db.db)); - let repo = InboundClientRepository::new(Arc::clone(&db)); - let space_repo = SqliteSpaceRepository::new(db); - - // Create a space for locking - let space = fixtures::test_space("Locked Space"); - SpaceRepository::create(&space_repo, &space).await.unwrap(); + let repo = InboundClientRepository::new(db); - let client = create_test_client("Settings Test"); + let client = create_test_client("Alias Test"); repo.save_client(&client).await.unwrap(); - // Update settings let updated = repo - .update_client_settings( - &client.client_id, - Some("My Cursor".to_string()), // alias - Some("locked".to_string()), // connection_mode - Some(Some(space.id.to_string())), // locked_space_id - ) + .update_client_alias(&client.client_id, Some("My Cursor".to_string())) .await - .expect("Failed to update settings"); + .expect("Failed to update alias"); - assert!(updated.is_some()); - let updated = updated.unwrap(); + let updated = updated.expect("client should exist after alias update"); assert_eq!(updated.client_alias, Some("My Cursor".to_string())); - assert_eq!(updated.connection_mode, "locked"); - assert_eq!(updated.locked_space_id, Some(space.id.to_string())); } #[tokio::test] diff --git a/tests/rust/tests/database/repositories.rs b/tests/rust/tests/database/repositories.rs index 0d243c5f..4f7eb7dd 100644 --- a/tests/rust/tests/database/repositories.rs +++ b/tests/rust/tests/database/repositories.rs @@ -188,7 +188,7 @@ async fn test_space_repository_concurrent_reads() { // Create a space let space = fixtures::test_space("Concurrent Test"); - let space_id = space.id.clone(); + let space_id = space.id; SpaceRepository::create(repo.as_ref(), &space) .await .unwrap(); @@ -197,7 +197,7 @@ async fn test_space_repository_concurrent_reads() { let mut handles = vec![]; for _ in 0..5 { let repo_clone = Arc::clone(&repo); - let id = space_id.clone(); + let id = space_id; handles.push(tokio::spawn(async move { SpaceRepository::get(repo_clone.as_ref(), &id).await })); diff --git a/tests/rust/tests/integration/feature_grants.rs b/tests/rust/tests/integration/feature_grants.rs deleted file mode 100644 index 47dcb293..00000000 --- a/tests/rust/tests/integration/feature_grants.rs +++ /dev/null @@ -1,685 +0,0 @@ -//! Feature Grant Resolution tests -//! -//! Tests the complete flow: Space → FeatureSet → Features using FeatureService facade -//! Covers all feature set types: All, Default, ServerAll, Custom - -use std::sync::Arc; -use uuid::Uuid; - -use mcpmux_core::{ - FeatureSet, FeatureSetMember, FeatureSetRepository, FeatureType, MemberMode, MemberType, - ServerFeature, ServerFeatureRepository, -}; -use mcpmux_gateway::{FeatureService, PrefixCacheService}; -use tests::mocks::{MockFeatureSetRepository, MockServerFeatureRepository}; - -// Helper to create test features -fn create_test_feature( - space_id: &str, - server_id: &str, - name: &str, - feature_type: FeatureType, -) -> ServerFeature { - let mut feature = match feature_type { - FeatureType::Tool => ServerFeature::tool(space_id, server_id, name), - FeatureType::Prompt => ServerFeature::prompt(space_id, server_id, name), - FeatureType::Resource => ServerFeature::resource(space_id, server_id, name), - }; - feature.is_available = true; - feature -} - -fn create_feature_service( - feature_repo: Arc, - feature_set_repo: Arc, - prefix_cache: Arc, -) -> FeatureService { - FeatureService::new( - feature_repo as Arc, - feature_set_repo as Arc, - prefix_cache, - ) -} - -// ============================================================================ -// FEATURE SET TYPE: ALL -// ============================================================================ - -#[tokio::test] -async fn test_all_featureset_grants_all_features() { - let space_id = Uuid::new_v4().to_string(); - let server_id = "server-001"; - - let feature_repo = Arc::new(MockServerFeatureRepository::new()); - let feature_set_repo = Arc::new(MockFeatureSetRepository::new()); - let prefix_cache = Arc::new(PrefixCacheService::new()); - - // Create features - feature_repo - .upsert(&create_test_feature( - &space_id, - server_id, - "tool_a", - FeatureType::Tool, - )) - .await - .unwrap(); - feature_repo - .upsert(&create_test_feature( - &space_id, - server_id, - "tool_b", - FeatureType::Tool, - )) - .await - .unwrap(); - feature_repo - .upsert(&create_test_feature( - &space_id, - server_id, - "prompt_a", - FeatureType::Prompt, - )) - .await - .unwrap(); - - // Create "All" feature set - let all_fs = FeatureSet::new_all(&space_id); - let all_fs_id = all_fs.id.clone(); - feature_set_repo.create(&all_fs).await.unwrap(); - - let service = create_feature_service(feature_repo, feature_set_repo, prefix_cache); - - let resolved = service - .resolve_feature_sets(&space_id, &[all_fs_id]) - .await - .unwrap(); - - assert_eq!(resolved.len(), 3, "All 3 features should be resolved"); - assert!(resolved.iter().any(|f| f.feature_name == "tool_a")); - assert!(resolved.iter().any(|f| f.feature_name == "tool_b")); - assert!(resolved.iter().any(|f| f.feature_name == "prompt_a")); -} - -#[tokio::test] -async fn test_all_featureset_excludes_unavailable() { - let space_id = Uuid::new_v4().to_string(); - let server_id = "server-001"; - - let feature_repo = Arc::new(MockServerFeatureRepository::new()); - let feature_set_repo = Arc::new(MockFeatureSetRepository::new()); - let prefix_cache = Arc::new(PrefixCacheService::new()); - - // Create available and unavailable features - let available = create_test_feature(&space_id, server_id, "available_tool", FeatureType::Tool); - let mut unavailable = - create_test_feature(&space_id, server_id, "unavailable_tool", FeatureType::Tool); - unavailable.is_available = false; - - feature_repo.upsert(&available).await.unwrap(); - feature_repo.upsert(&unavailable).await.unwrap(); - - let all_fs = FeatureSet::new_all(&space_id); - let all_fs_id = all_fs.id.clone(); - feature_set_repo.create(&all_fs).await.unwrap(); - - let service = create_feature_service(feature_repo, feature_set_repo, prefix_cache); - - let resolved = service - .resolve_feature_sets(&space_id, &[all_fs_id]) - .await - .unwrap(); - - assert_eq!( - resolved.len(), - 1, - "Only available feature should be resolved" - ); - assert_eq!(resolved[0].feature_name, "available_tool"); -} - -// ============================================================================ -// FEATURE SET TYPE: SERVER-ALL -// ============================================================================ - -#[tokio::test] -async fn test_server_all_grants_only_server_features() { - let space_id = Uuid::new_v4().to_string(); - let server_a = "server-a"; - let server_b = "server-b"; - - let feature_repo = Arc::new(MockServerFeatureRepository::new()); - let feature_set_repo = Arc::new(MockFeatureSetRepository::new()); - let prefix_cache = Arc::new(PrefixCacheService::new()); - - // Create features for both servers - feature_repo - .upsert(&create_test_feature( - &space_id, - server_a, - "tool_a1", - FeatureType::Tool, - )) - .await - .unwrap(); - feature_repo - .upsert(&create_test_feature( - &space_id, - server_a, - "tool_a2", - FeatureType::Tool, - )) - .await - .unwrap(); - feature_repo - .upsert(&create_test_feature( - &space_id, - server_b, - "tool_b1", - FeatureType::Tool, - )) - .await - .unwrap(); - - // Create ServerAll for server_a only - let server_all = FeatureSet::new_server_all(&space_id, server_a, "Server A"); - let server_all_id = server_all.id.clone(); - feature_set_repo.create(&server_all).await.unwrap(); - - let service = create_feature_service(feature_repo, feature_set_repo, prefix_cache); - - let resolved = service - .resolve_feature_sets(&space_id, &[server_all_id]) - .await - .unwrap(); - - // Should only include server_a features - assert_eq!( - resolved.len(), - 2, - "Only server_a features should be resolved" - ); - assert!(resolved.iter().all(|f| f.server_id == server_a)); - assert!(resolved.iter().any(|f| f.feature_name == "tool_a1")); - assert!(resolved.iter().any(|f| f.feature_name == "tool_a2")); -} - -// ============================================================================ -// FEATURE SET TYPE: DEFAULT (Empty = No features) -// ============================================================================ - -#[tokio::test] -async fn test_default_featureset_empty_grants_nothing() { - let space_id = Uuid::new_v4().to_string(); - let server_id = "server-001"; - - let feature_repo = Arc::new(MockServerFeatureRepository::new()); - let feature_set_repo = Arc::new(MockFeatureSetRepository::new()); - let prefix_cache = Arc::new(PrefixCacheService::new()); - - // Create features - feature_repo - .upsert(&create_test_feature( - &space_id, - server_id, - "tool_a", - FeatureType::Tool, - )) - .await - .unwrap(); - - // Create empty Default feature set (secure by default) - let default_fs = FeatureSet::new_default(&space_id); - let default_fs_id = default_fs.id.clone(); - feature_set_repo.create(&default_fs).await.unwrap(); - - let service = create_feature_service(feature_repo, feature_set_repo, prefix_cache); - - let resolved = service - .resolve_feature_sets(&space_id, &[default_fs_id]) - .await - .unwrap(); - - assert_eq!(resolved.len(), 0, "Empty default should grant no features"); -} - -// ============================================================================ -// FEATURE SET TYPE: CUSTOM -// ============================================================================ - -#[tokio::test] -async fn test_custom_featureset_with_include_members() { - let space_id = Uuid::new_v4().to_string(); - let server_id = "server-001"; - - let feature_repo = Arc::new(MockServerFeatureRepository::new()); - let feature_set_repo = Arc::new(MockFeatureSetRepository::new()); - let prefix_cache = Arc::new(PrefixCacheService::new()); - - // Create features - let tool_a = create_test_feature(&space_id, server_id, "tool_a", FeatureType::Tool); - let tool_a_id = tool_a.id.to_string(); - let tool_b = create_test_feature(&space_id, server_id, "tool_b", FeatureType::Tool); - let tool_b_id = tool_b.id.to_string(); - let tool_c = create_test_feature(&space_id, server_id, "tool_c", FeatureType::Tool); - feature_repo.upsert(&tool_a).await.unwrap(); - feature_repo.upsert(&tool_b).await.unwrap(); - feature_repo.upsert(&tool_c).await.unwrap(); - - // Create Custom feature set with specific members - let mut custom_fs = FeatureSet::new_custom("Custom Set", &space_id); - custom_fs.members.push(FeatureSetMember { - id: Uuid::new_v4().to_string(), - feature_set_id: custom_fs.id.clone(), - member_id: tool_a_id, - member_type: MemberType::Feature, - mode: MemberMode::Include, - }); - custom_fs.members.push(FeatureSetMember { - id: Uuid::new_v4().to_string(), - feature_set_id: custom_fs.id.clone(), - member_id: tool_b_id, - member_type: MemberType::Feature, - mode: MemberMode::Include, - }); - let custom_fs_id = custom_fs.id.clone(); - feature_set_repo.create(&custom_fs).await.unwrap(); - - let service = create_feature_service(feature_repo, feature_set_repo, prefix_cache); - - let resolved = service - .resolve_feature_sets(&space_id, &[custom_fs_id]) - .await - .unwrap(); - - assert_eq!( - resolved.len(), - 2, - "Only included features should be resolved" - ); - assert!(resolved.iter().any(|f| f.feature_name == "tool_a")); - assert!(resolved.iter().any(|f| f.feature_name == "tool_b")); - assert!(!resolved.iter().any(|f| f.feature_name == "tool_c")); -} - -// ============================================================================ -// NESTED FEATURE SETS -// ============================================================================ - -#[tokio::test] -async fn test_nested_featureset_composition() { - let space_id = Uuid::new_v4().to_string(); - let server_a = "server-a"; - let server_b = "server-b"; - - let feature_repo = Arc::new(MockServerFeatureRepository::new()); - let feature_set_repo = Arc::new(MockFeatureSetRepository::new()); - let prefix_cache = Arc::new(PrefixCacheService::new()); - - // Create features - feature_repo - .upsert(&create_test_feature( - &space_id, - server_a, - "tool_a", - FeatureType::Tool, - )) - .await - .unwrap(); - feature_repo - .upsert(&create_test_feature( - &space_id, - server_b, - "tool_b", - FeatureType::Tool, - )) - .await - .unwrap(); - - // Create ServerAll for each server - let server_all_a = FeatureSet::new_server_all(&space_id, server_a, "Server A"); - let server_all_a_id = server_all_a.id.clone(); - let server_all_b = FeatureSet::new_server_all(&space_id, server_b, "Server B"); - let server_all_b_id = server_all_b.id.clone(); - feature_set_repo.create(&server_all_a).await.unwrap(); - feature_set_repo.create(&server_all_b).await.unwrap(); - - // Create composite Custom feature set that includes both ServerAll sets - let mut composite_fs = FeatureSet::new_custom("Composite", &space_id); - composite_fs.members.push(FeatureSetMember { - id: Uuid::new_v4().to_string(), - feature_set_id: composite_fs.id.clone(), - member_id: server_all_a_id, - member_type: MemberType::FeatureSet, - mode: MemberMode::Include, - }); - composite_fs.members.push(FeatureSetMember { - id: Uuid::new_v4().to_string(), - feature_set_id: composite_fs.id.clone(), - member_id: server_all_b_id, - member_type: MemberType::FeatureSet, - mode: MemberMode::Include, - }); - let composite_fs_id = composite_fs.id.clone(); - feature_set_repo.create(&composite_fs).await.unwrap(); - - let service = create_feature_service(feature_repo, feature_set_repo, prefix_cache); - - let resolved = service - .resolve_feature_sets(&space_id, &[composite_fs_id]) - .await - .unwrap(); - - assert_eq!(resolved.len(), 2, "Both server features should be resolved"); - assert!(resolved - .iter() - .any(|f| f.feature_name == "tool_a" && f.server_id == server_a)); - assert!(resolved - .iter() - .any(|f| f.feature_name == "tool_b" && f.server_id == server_b)); -} - -// ============================================================================ -// TYPE FILTERING -// ============================================================================ - -#[tokio::test] -async fn test_get_tools_for_grants() { - let space_id = Uuid::new_v4().to_string(); - let server_id = "server-001"; - - let feature_repo = Arc::new(MockServerFeatureRepository::new()); - let feature_set_repo = Arc::new(MockFeatureSetRepository::new()); - let prefix_cache = Arc::new(PrefixCacheService::new()); - - // Create mixed features - feature_repo - .upsert(&create_test_feature( - &space_id, - server_id, - "tool_a", - FeatureType::Tool, - )) - .await - .unwrap(); - feature_repo - .upsert(&create_test_feature( - &space_id, - server_id, - "prompt_a", - FeatureType::Prompt, - )) - .await - .unwrap(); - feature_repo - .upsert(&create_test_feature( - &space_id, - server_id, - "resource://test", - FeatureType::Resource, - )) - .await - .unwrap(); - - let all_fs = FeatureSet::new_all(&space_id); - let all_fs_id = all_fs.id.clone(); - feature_set_repo.create(&all_fs).await.unwrap(); - - let service = create_feature_service(feature_repo, feature_set_repo, prefix_cache); - - let tools = service - .get_tools_for_grants(&space_id, &[all_fs_id]) - .await - .unwrap(); - - assert_eq!(tools.len(), 1, "Only tools should be returned"); - assert_eq!(tools[0].feature_type, FeatureType::Tool); -} - -#[tokio::test] -async fn test_get_prompts_for_grants() { - let space_id = Uuid::new_v4().to_string(); - let server_id = "server-001"; - - let feature_repo = Arc::new(MockServerFeatureRepository::new()); - let feature_set_repo = Arc::new(MockFeatureSetRepository::new()); - let prefix_cache = Arc::new(PrefixCacheService::new()); - - // Create mixed features - feature_repo - .upsert(&create_test_feature( - &space_id, - server_id, - "tool_a", - FeatureType::Tool, - )) - .await - .unwrap(); - feature_repo - .upsert(&create_test_feature( - &space_id, - server_id, - "prompt_a", - FeatureType::Prompt, - )) - .await - .unwrap(); - feature_repo - .upsert(&create_test_feature( - &space_id, - server_id, - "prompt_b", - FeatureType::Prompt, - )) - .await - .unwrap(); - - let all_fs = FeatureSet::new_all(&space_id); - let all_fs_id = all_fs.id.clone(); - feature_set_repo.create(&all_fs).await.unwrap(); - - let service = create_feature_service(feature_repo, feature_set_repo, prefix_cache); - - let prompts = service - .get_prompts_for_grants(&space_id, &[all_fs_id]) - .await - .unwrap(); - - assert_eq!(prompts.len(), 2, "Only prompts should be returned"); - assert!(prompts - .iter() - .all(|f| f.feature_type == FeatureType::Prompt)); -} - -#[tokio::test] -async fn test_get_resources_for_grants() { - let space_id = Uuid::new_v4().to_string(); - let server_id = "server-001"; - - let feature_repo = Arc::new(MockServerFeatureRepository::new()); - let feature_set_repo = Arc::new(MockFeatureSetRepository::new()); - let prefix_cache = Arc::new(PrefixCacheService::new()); - - // Create mixed features - feature_repo - .upsert(&create_test_feature( - &space_id, - server_id, - "tool_a", - FeatureType::Tool, - )) - .await - .unwrap(); - feature_repo - .upsert(&create_test_feature( - &space_id, - server_id, - "resource://test", - FeatureType::Resource, - )) - .await - .unwrap(); - - let all_fs = FeatureSet::new_all(&space_id); - let all_fs_id = all_fs.id.clone(); - feature_set_repo.create(&all_fs).await.unwrap(); - - let service = create_feature_service(feature_repo, feature_set_repo, prefix_cache); - - let resources = service - .get_resources_for_grants(&space_id, &[all_fs_id]) - .await - .unwrap(); - - assert_eq!(resources.len(), 1, "Only resources should be returned"); - assert_eq!(resources[0].feature_type, FeatureType::Resource); -} - -// ============================================================================ -// SPACE ISOLATION -// ============================================================================ - -#[tokio::test] -async fn test_features_isolated_by_space() { - let space_a = Uuid::new_v4().to_string(); - let space_b = Uuid::new_v4().to_string(); - let server_id = "server-001"; - - let feature_repo = Arc::new(MockServerFeatureRepository::new()); - let feature_set_repo = Arc::new(MockFeatureSetRepository::new()); - let prefix_cache = Arc::new(PrefixCacheService::new()); - - // Create features in different spaces - feature_repo - .upsert(&create_test_feature( - &space_a, - server_id, - "tool_in_space_a", - FeatureType::Tool, - )) - .await - .unwrap(); - feature_repo - .upsert(&create_test_feature( - &space_b, - server_id, - "tool_in_space_b", - FeatureType::Tool, - )) - .await - .unwrap(); - - // Create All feature set for space_a - let all_fs = FeatureSet::new_all(&space_a); - let all_fs_id = all_fs.id.clone(); - feature_set_repo.create(&all_fs).await.unwrap(); - - let service = create_feature_service(feature_repo, feature_set_repo, prefix_cache); - - // Resolve for space_a - let resolved = service - .resolve_feature_sets(&space_a, &[all_fs_id]) - .await - .unwrap(); - - // Should only get space_a feature - assert_eq!(resolved.len(), 1); - assert_eq!(resolved[0].feature_name, "tool_in_space_a"); - assert_eq!(resolved[0].space_id, space_a); -} - -// ============================================================================ -// MULTIPLE GRANTS COMBINED -// ============================================================================ - -#[tokio::test] -async fn test_multiple_grants_union() { - let space_id = Uuid::new_v4().to_string(); - let server_a = "server-a"; - let server_b = "server-b"; - - let feature_repo = Arc::new(MockServerFeatureRepository::new()); - let feature_set_repo = Arc::new(MockFeatureSetRepository::new()); - let prefix_cache = Arc::new(PrefixCacheService::new()); - - // Create features - feature_repo - .upsert(&create_test_feature( - &space_id, - server_a, - "tool_a", - FeatureType::Tool, - )) - .await - .unwrap(); - feature_repo - .upsert(&create_test_feature( - &space_id, - server_b, - "tool_b", - FeatureType::Tool, - )) - .await - .unwrap(); - - // Create ServerAll for each server - let server_all_a = FeatureSet::new_server_all(&space_id, server_a, "Server A"); - let server_all_a_id = server_all_a.id.clone(); - let server_all_b = FeatureSet::new_server_all(&space_id, server_b, "Server B"); - let server_all_b_id = server_all_b.id.clone(); - feature_set_repo.create(&server_all_a).await.unwrap(); - feature_set_repo.create(&server_all_b).await.unwrap(); - - let service = create_feature_service(feature_repo, feature_set_repo, prefix_cache); - - // Resolve with multiple grants - let resolved = service - .resolve_feature_sets(&space_id, &[server_all_a_id, server_all_b_id]) - .await - .unwrap(); - - // Should include features from both servers - assert_eq!(resolved.len(), 2); - assert!(resolved.iter().any(|f| f.feature_name == "tool_a")); - assert!(resolved.iter().any(|f| f.feature_name == "tool_b")); -} - -#[tokio::test] -async fn test_prefix_enrichment() { - let space_id = Uuid::new_v4().to_string(); - let server_id = "my-server"; - - let feature_repo = Arc::new(MockServerFeatureRepository::new()); - let feature_set_repo = Arc::new(MockFeatureSetRepository::new()); - let prefix_cache = Arc::new(PrefixCacheService::new()); - - // Register server prefix - prefix_cache - .assign_prefix_runtime(&space_id, server_id, Some("myalias")) - .await; - - // Create feature - feature_repo - .upsert(&create_test_feature( - &space_id, - server_id, - "my_tool", - FeatureType::Tool, - )) - .await - .unwrap(); - - let all_fs = FeatureSet::new_all(&space_id); - let all_fs_id = all_fs.id.clone(); - feature_set_repo.create(&all_fs).await.unwrap(); - - let service = create_feature_service(feature_repo, feature_set_repo, prefix_cache); - - let resolved = service - .resolve_feature_sets(&space_id, &[all_fs_id]) - .await - .unwrap(); - - assert_eq!(resolved.len(), 1); - assert_eq!(resolved[0].server_alias, Some("myalias".to_string())); -} diff --git a/tests/rust/tests/integration/feature_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/feature_set_resolver.rs b/tests/rust/tests/integration/feature_set_resolver.rs new file mode 100644 index 00000000..41159791 --- /dev/null +++ b/tests/rust/tests/integration/feature_set_resolver.rs @@ -0,0 +1,287 @@ +//! Decision-table tests for the FeatureSet resolver (capability-branched v3). +//! +//! Outcomes: +//! 1. **WorkspaceBinding** — session reported roots AND a binding matched +//! one of them. `space_id` + `feature_set_ids[0]` come from the binding. +//! 2. **PendingRoots** — session declared MCP `roots` capability but the +//! list hasn't arrived yet. Empty FS list; resolver fires +//! `list_changed` later when roots populate. +//! 3. **ClientGrant** — rootless-by-design client. Per-client grants +//! from the `client_grants` table apply. +//! 4. **Deny** — every other case (roots reported but no binding; no +//! session id and no grants; etc.). Empty FS list. + +use std::sync::Arc; + +use mcpmux_core::{ + normalize_workspace_root, FeatureSet, FeatureSetRepository, SpaceRepository, WorkspaceBinding, + WorkspaceBindingRepository, +}; +use mcpmux_gateway::services::{FeatureSetResolverService, ResolutionSource, SessionRootsRegistry}; +use mcpmux_storage::{ + Database, InboundClient, InboundClientRepository, RegistrationType, SqliteFeatureSetRepository, + SqliteSpaceRepository, SqliteWorkspaceBindingRepository, +}; +use tokio::sync::Mutex; +use uuid::Uuid; + +struct Fixture { + resolver: FeatureSetResolverService, + session_roots: Arc, + binding_repo: Arc, + client_repo: Arc, + space_id: Uuid, + fs_a_id: String, + fs_b_id: String, +} + +impl Fixture { + async fn new() -> Self { + let db = Arc::new(Mutex::new(Database::open_in_memory().unwrap())); + let space_repo: Arc = Arc::new(SqliteSpaceRepository::new(db.clone())); + let fs_repo: Arc = + Arc::new(SqliteFeatureSetRepository::new(db.clone())); + let binding_repo: Arc = + Arc::new(SqliteWorkspaceBindingRepository::new(db.clone())); + let client_repo = Arc::new(InboundClientRepository::new(db.clone())); + + let default_space = space_repo.get_default().await.unwrap().unwrap(); + let space_id = default_space.id; + + let a = FeatureSet::new_custom("A", space_id.to_string()); + let b = FeatureSet::new_custom("B", space_id.to_string()); + fs_repo.create(&a).await.unwrap(); + fs_repo.create(&b).await.unwrap(); + let fs_a_id = a.id.clone(); + let fs_b_id = b.id.clone(); + + let session_roots = SessionRootsRegistry::new(); + let resolver = FeatureSetResolverService::new( + space_repo.clone(), + binding_repo.clone(), + session_roots.clone(), + client_repo.clone(), + ); + + Self { + resolver, + session_roots, + binding_repo, + client_repo, + space_id, + fs_a_id, + fs_b_id, + } + } + + /// Insert an inbound client row so we can attach grants to it (the + /// `client_grants` FK requires the row to exist). + async fn make_client(&self, client_id: &str) { + let now = chrono::Utc::now().to_rfc3339(); + let c = InboundClient { + client_id: client_id.to_string(), + registration_type: RegistrationType::Dcr, + client_name: "test-client".to_string(), + client_alias: None, + redirect_uris: vec!["http://localhost/cb".to_string()], + grant_types: vec!["authorization_code".to_string()], + response_types: vec!["code".to_string()], + token_endpoint_auth_method: "none".to_string(), + scope: None, + approved: true, + logo_uri: None, + client_uri: None, + software_id: None, + software_version: None, + metadata_url: None, + metadata_cached_at: None, + metadata_cache_ttl: None, + last_seen: None, + created_at: now.clone(), + updated_at: now, + reports_roots: false, + roots_capability_known: false, + }; + self.client_repo.save_client(&c).await.unwrap(); + } +} + +fn test_root() -> &'static str { + if cfg!(windows) { + "d:\\work\\proj" + } else { + "/work/proj" + } +} + +// --------------------------------------------------------------------------- +// Deny tier +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn deny_when_no_session_id_and_no_grants() { + let f = Fixture::new().await; + let r = f.resolver.resolve(None, None).await.unwrap(); + assert_eq!(r.source, ResolutionSource::Deny); + assert!(r.feature_set_ids.is_empty()); + assert_eq!(r.space_id, Some(f.space_id)); +} + +#[tokio::test] +async fn deny_when_session_has_no_roots_and_not_capable() { + // Default capability state is "unknown" (None). The resolver treats + // missing capability info as rootless, so this falls through to Tier 2 + // (no client_id supplied → Deny). + let f = Fixture::new().await; + let r = f.resolver.resolve(Some("orphan"), None).await.unwrap(); + assert_eq!(r.source, ResolutionSource::Deny); +} + +#[tokio::test] +async fn deny_when_roots_reported_but_no_binding_matches() { + let f = Fixture::new().await; + let other = if cfg!(windows) { "d:\\tmp" } else { "/tmp" }; + f.session_roots.set("sess", [other]); + let r = f.resolver.resolve(Some("sess"), None).await.unwrap(); + // Roots present but no binding → upstream emits WorkspaceNeedsBinding; + // resolver itself reports Deny (no FS to apply). + assert_eq!(r.source, ResolutionSource::Deny); + assert!(r.feature_set_ids.is_empty()); +} + +// --------------------------------------------------------------------------- +// PendingRoots tier +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn pending_when_capable_but_roots_havent_arrived() { + let f = Fixture::new().await; + f.session_roots.set_roots_capable("sess", true); + // No roots set in the registry yet. + let r = f.resolver.resolve(Some("sess"), None).await.unwrap(); + assert_eq!(r.source, ResolutionSource::PendingRoots); + assert!(r.feature_set_ids.is_empty()); +} + +// --------------------------------------------------------------------------- +// WorkspaceBinding tier +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn binding_routes_to_its_target_space_and_fs() { + let f = Fixture::new().await; + let binding = WorkspaceBinding::new( + normalize_workspace_root(test_root()), + f.space_id, + f.fs_a_id.clone(), + ); + f.binding_repo.create(&binding).await.unwrap(); + f.session_roots.set("s", [test_root()]); + f.session_roots.set_roots_capable("s", true); + + let r = f.resolver.resolve(Some("s"), None).await.unwrap(); + assert_eq!(r.source, ResolutionSource::WorkspaceBinding); + assert_eq!(r.space_id, Some(f.space_id)); + assert_eq!(r.feature_set_ids, vec![f.fs_a_id]); +} + +#[tokio::test] +async fn longest_prefix_wins_across_nested_bindings() { + let f = Fixture::new().await; + let (outer, inner) = if cfg!(windows) { + ("d:\\work", "d:\\work\\proj") + } else { + ("/work", "/work/proj") + }; + f.binding_repo + .create(&WorkspaceBinding::new( + normalize_workspace_root(outer), + f.space_id, + f.fs_a_id.clone(), + )) + .await + .unwrap(); + f.binding_repo + .create(&WorkspaceBinding::new( + normalize_workspace_root(inner), + f.space_id, + f.fs_b_id.clone(), + )) + .await + .unwrap(); + + let deep = if cfg!(windows) { + "d:\\work\\proj\\src" + } else { + "/work/proj/src" + }; + f.session_roots.set("s", [deep]); + f.session_roots.set_roots_capable("s", true); + + let r = f.resolver.resolve(Some("s"), None).await.unwrap(); + assert_eq!(r.source, ResolutionSource::WorkspaceBinding); + assert_eq!(r.feature_set_ids, vec![f.fs_b_id]); +} + +// --------------------------------------------------------------------------- +// ClientGrant tier — rootless fallback +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn rootless_client_uses_grants() { + let f = Fixture::new().await; + let client_id = "rootless.example/client"; + f.make_client(client_id).await; + f.client_repo + .grant_feature_set(client_id, &f.space_id.to_string(), &f.fs_a_id) + .await + .unwrap(); + + // Session declared no roots capability — Tier-2 grant lookup applies. + f.session_roots.set_roots_capable("s", false); + let r = f + .resolver + .resolve(Some("s"), Some(client_id)) + .await + .unwrap(); + assert_eq!(r.source, ResolutionSource::ClientGrant); + assert_eq!(r.feature_set_ids, vec![f.fs_a_id]); +} + +#[tokio::test] +async fn rootless_client_without_grants_denies() { + let f = Fixture::new().await; + let client_id = "rootless.example/no-grants"; + f.make_client(client_id).await; + f.session_roots.set_roots_capable("s", false); + let r = f + .resolver + .resolve(Some("s"), Some(client_id)) + .await + .unwrap(); + assert_eq!(r.source, ResolutionSource::Deny); + assert!(r.feature_set_ids.is_empty()); +} + +#[tokio::test] +async fn capable_session_does_not_fall_through_to_grants() { + // Critical: the leak we set out to fix. A roots-capable session whose + // roots haven't arrived yet must NOT pick up any client grants. It + // returns PendingRoots and only resolves once the roots actually land. + let f = Fixture::new().await; + let client_id = "permissive.example/client"; + f.make_client(client_id).await; + f.client_repo + .grant_feature_set(client_id, &f.space_id.to_string(), &f.fs_a_id) + .await + .unwrap(); + + f.session_roots.set_roots_capable("s", true); + let r = f + .resolver + .resolve(Some("s"), Some(client_id)) + .await + .unwrap(); + assert_eq!(r.source, ResolutionSource::PendingRoots); + assert!(r.feature_set_ids.is_empty()); +} diff --git a/tests/rust/tests/integration/mcp_flows.rs b/tests/rust/tests/integration/mcp_flows.rs index 1c613503..2953e009 100644 --- a/tests/rust/tests/integration/mcp_flows.rs +++ b/tests/rust/tests/integration/mcp_flows.rs @@ -2,7 +2,11 @@ //! //! Tests the complete MCP request handling flow using FeatureService: //! - tools/list, tools/call with authorization -//! - resources/list, resources/read with authorization +//! - resources/list, resources/read with authorization + +// clippy 1.93+ prefers `std::slice::from_ref(&id)` over `&[id.clone()]`. +// Kept as-is for test readability. +#![allow(clippy::cloned_ref_to_slice_refs)] //! - prompts/list, prompts/get with authorization //! - Space isolation @@ -13,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 @@ -52,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 { @@ -81,6 +86,55 @@ impl TestContext { self.feature_set_repo.create(&fs).await.unwrap(); id } + + /// Build a FeatureSet that grants every feature currently known to the + /// mock feature repository. Replaces the legacy `FeatureSet::new_all` + /// escape hatch — with the new model, "grant all" is expressed as a + /// Custom set whose members enumerate every ServerFeature id. + async fn new_grant_everything_set(&self) -> FeatureSet { + let mut fs = FeatureSet::new_custom("All (test fixture)", &self.space_id); + for feature in self + .feature_repo + .list_for_space(&self.space_id) + .await + .unwrap() + { + fs.members.push(FeatureSetMember { + id: Uuid::new_v4().to_string(), + feature_set_id: fs.id.clone(), + member_type: MemberType::Feature, + member_id: feature.id.to_string(), + mode: MemberMode::Include, + surfaced: false, + }); + } + fs + } + + /// Build a FeatureSet whose members are every feature belonging to a + /// specific server — replaces `FeatureSet::new_server_all`. + async fn new_grant_server_all_set(&self, server_id: &str) -> FeatureSet { + let mut fs = FeatureSet::new_custom( + format!("{} - All (test fixture)", server_id), + &self.space_id, + ); + for feature in self + .feature_repo + .list_for_server(&self.space_id, server_id) + .await + .unwrap() + { + fs.members.push(FeatureSetMember { + id: Uuid::new_v4().to_string(), + feature_set_id: fs.id.clone(), + member_type: MemberType::Feature, + member_id: feature.id.to_string(), + mode: MemberMode::Include, + surfaced: false, + }); + } + fs + } } // ============================================================================ @@ -99,13 +153,13 @@ async fn test_list_tools_with_all_grant() { .await; // Create "All" grant - let all_fs = FeatureSet::new_all(&ctx.space_id); + let all_fs = ctx.new_grant_everything_set().await; let all_fs_id = ctx.add_feature_set(all_fs).await; // Simulate tools/list with grant 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(); @@ -136,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(); @@ -184,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(); @@ -205,12 +260,12 @@ async fn test_list_resources_with_grant() { ctx.add_feature("files", "file:///docs/config.json", FeatureType::Resource) .await; - let all_fs = FeatureSet::new_all(&ctx.space_id); + let all_fs = ctx.new_grant_everything_set().await; let all_fs_id = ctx.add_feature_set(all_fs).await; let resources = ctx .service - .get_resources_for_grants(&ctx.space_id, &[all_fs_id]) + .get_resources_for_grants(&ctx.space_id, &[all_fs_id], None) .await .unwrap(); @@ -248,12 +303,12 @@ async fn test_resource_custom_uri_scheme() { ) .await; - let all_fs = FeatureSet::new_all(&ctx.space_id); + let all_fs = ctx.new_grant_everything_set().await; let all_fs_id = ctx.add_feature_set(all_fs).await; let resources = ctx .service - .get_resources_for_grants(&ctx.space_id, &[all_fs_id]) + .get_resources_for_grants(&ctx.space_id, &[all_fs_id], None) .await .unwrap(); @@ -278,12 +333,12 @@ async fn test_list_prompts_with_grant() { ctx.add_feature("prompts-server", "explain_code", FeatureType::Prompt) .await; - let all_fs = FeatureSet::new_all(&ctx.space_id); + let all_fs = ctx.new_grant_everything_set().await; let all_fs_id = ctx.add_feature_set(all_fs).await; let prompts = ctx .service - .get_prompts_for_grants(&ctx.space_id, &[all_fs_id]) + .get_prompts_for_grants(&ctx.space_id, &[all_fs_id], None) .await .unwrap(); @@ -330,7 +385,7 @@ async fn test_server_provides_multiple_feature_types() { ctx.add_feature("full-server", "my://resource", FeatureType::Resource) .await; - let all_fs = FeatureSet::new_all(&ctx.space_id); + let all_fs = ctx.new_grant_everything_set().await; let all_fs_id = ctx.add_feature_set(all_fs).await; // List all @@ -345,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(); @@ -385,12 +440,12 @@ async fn test_aggregate_tools_from_multiple_servers() { .await; // Grant access to all - let all_fs = FeatureSet::new_all(&ctx.space_id); + let all_fs = ctx.new_grant_everything_set().await; let all_fs_id = ctx.add_feature_set(all_fs).await; let tools = ctx .service - .get_tools_for_grants(&ctx.space_id, &[all_fs_id]) + .get_tools_for_grants(&ctx.space_id, &[all_fs_id], None) .await .unwrap(); @@ -415,12 +470,12 @@ async fn test_partial_server_grant() { .await; // Create ServerAll grant for server-a only - let server_all_a = FeatureSet::new_server_all(&ctx.space_id, "server-a", "Server A"); + let server_all_a = ctx.new_grant_server_all_set("server-a").await; let server_all_a_id = ctx.add_feature_set(server_all_a).await; let tools = ctx .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(); @@ -452,8 +507,16 @@ async fn test_features_dont_leak_between_spaces() { feature_repo.upsert(&work_tool).await.unwrap(); feature_repo.upsert(&personal_tool).await.unwrap(); - // Create All grant for work space - let work_all = FeatureSet::new_all(&space_work); + // Create "grant-everything-in-work" FS manually (no new_all helper any more). + let mut work_all = FeatureSet::new_custom("All (test fixture)", &space_work); + work_all.members.push(FeatureSetMember { + id: Uuid::new_v4().to_string(), + feature_set_id: work_all.id.clone(), + member_type: MemberType::Feature, + member_id: work_tool.id.to_string(), + mode: MemberMode::Include, + surfaced: false, + }); let work_all_id = work_all.id.clone(); feature_set_repo.create(&work_all).await.unwrap(); @@ -461,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(); @@ -504,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 @@ -545,12 +610,12 @@ async fn test_unavailable_features_filtered_out() { unavailable.is_available = false; ctx.feature_repo.upsert(&unavailable).await.unwrap(); - let all_fs = FeatureSet::new_all(&ctx.space_id); + let all_fs = ctx.new_grant_everything_set().await; let all_fs_id = ctx.add_feature_set(all_fs).await; let tools = ctx .service - .get_tools_for_grants(&ctx.space_id, &[all_fs_id]) + .get_tools_for_grants(&ctx.space_id, &[all_fs_id], None) .await .unwrap(); @@ -566,13 +631,13 @@ async fn test_server_disconnect_marks_features_unavailable() { ctx.add_feature("server", "tool_1", FeatureType::Tool).await; ctx.add_feature("server", "tool_2", FeatureType::Tool).await; - let all_fs = FeatureSet::new_all(&ctx.space_id); + let all_fs = ctx.new_grant_everything_set().await; let all_fs_id = ctx.add_feature_set(all_fs).await; // Initially available 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); @@ -586,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 new file mode 100644 index 00000000..c0e00fe0 --- /dev/null +++ b/tests/rust/tests/integration/meta_tools.rs @@ -0,0 +1,1161 @@ +//! End-to-end tests for the `mcpmux_*` self-management meta tools. +//! +//! Exercises the full path through the [`MetaToolRegistry`]: +//! * read tools return structured payloads +//! * write tools gate on the [`ApprovalBroker`] and only mutate state on Allow +//! * denial / timeout / no-publisher surface as `CallToolResult::error` +//! * "always-allow" persists for subsequent calls in the same session + +use std::sync::Arc; +use std::time::Duration; + +use futures::FutureExt; +use mcpmux_core::{ + normalize_workspace_root, Client, DomainEvent, FeatureSet, 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, SessionOverrideRegistry, + SessionRootsRegistry, +}; +use mcpmux_storage::{ + generate_master_key, Database, FieldEncryptor, InboundClientRepository, + SqliteFeatureSetRepository, SqliteInboundMcpClientRepository, SqliteInstalledServerRepository, + SqliteServerFeatureRepository, SqliteSpaceRepository, SqliteWorkspaceBindingRepository, +}; +use serde_json::{json, Value}; +use tokio::sync::{broadcast, Mutex}; +use uuid::Uuid; + +struct Fixture { + registry: Arc, + broker: Arc, + #[allow(dead_code)] + 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 { + async fn new() -> Self { + let db = Arc::new(Mutex::new(Database::open_in_memory().unwrap())); + + let space_repo: Arc = Arc::new(SqliteSpaceRepository::new(db.clone())); + let feature_set_repo: Arc = + Arc::new(SqliteFeatureSetRepository::new(db.clone())); + let client_repo: Arc = + Arc::new(SqliteInboundMcpClientRepository::new(db.clone())); + let binding_repo: Arc = + Arc::new(SqliteWorkspaceBindingRepository::new(db.clone())); + let server_feature_repo: Arc = + Arc::new(SqliteServerFeatureRepository::new(db.clone())); + let 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; + + // Two FSes we'll flip between in the tests. + let fs_android = FeatureSet::new_custom("Android Dev", space_id.to_string()); + let fs_full = FeatureSet::new_custom("Full Access", space_id.to_string()); + feature_set_repo.create(&fs_android).await.unwrap(); + feature_set_repo.create(&fs_full).await.unwrap(); + let fs_android_id = Uuid::parse_str(&fs_android.id).unwrap(); + let fs_full_id = Uuid::parse_str(&fs_full.id).unwrap(); + + // Seed two tools in server_features for the tools listing test. + // + // Tool names are stored bare; qualified_name() prepends the server + // prefix, so e.g. ("github", "create_issue") → "github_create_issue". + let mut feature1 = ServerFeature::tool(space_id, "github", "create_issue"); + feature1.display_name = Some("GitHub".into()); + feature1.description = Some("Create an issue".into()); + let mut feature2 = ServerFeature::tool(space_id, "firebase", "deploy"); + feature2.display_name = Some("Firebase".into()); + feature2.description = Some("Deploy to Firebase".into()); + server_feature_repo.upsert(&feature1).await.unwrap(); + server_feature_repo.upsert(&feature2).await.unwrap(); + 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. + let _ = fs_full_id; + + // Create test client — routing is per-session-root now, not per-client. + let client = Client::new("TestClient", "test-type"); + let client_id = client.id.to_string(); + 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())); + 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.clone(), + space_repo.clone(), + feature_set_repo.clone(), + binding_repo.clone(), + server_feature_repo.clone(), + installed_server_repo.clone(), + resolver, + feature_service.clone(), + None, + session_roots.clone(), + session_overrides.clone(), + broker.clone(), + tx, + None, + ); + + Self { + registry, + broker, + 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, + } + } + + /// Attach a publisher that always auto-approves with the given decision. + fn attach_auto_publisher(&self, decision: ApprovalDecision) { + let broker = self.broker.clone(); + let publisher: ApprovalPublisher = Arc::new(move |req| { + let b = broker.clone(); + async move { + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(5)).await; + b.respond( + &req.request_id, + &req.client_id, + &req.payload.tool_name, + decision, + ); + }); + true + } + .boxed() + }); + // set_publisher is async; drive it synchronously via a current-runtime block_on + // is unavailable here, so we spawn and detach — publisher is in place before + // any request is made because tokio::test is single-threaded by default. + let b = self.broker.clone(); + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async move { + b.set_publisher(publisher).await; + }); + }); + } + + fn result_json(result: &rmcp::model::CallToolResult) -> Value { + // CallToolResult's Content is opaque; round-trip through JSON and + // pluck out the first text payload. + let raw = serde_json::to_value(result).unwrap(); + raw.get("content") + .and_then(|c| c.as_array()) + .and_then(|arr| arr.first()) + .and_then(|v| v.get("text")) + .and_then(|t| t.as_str()) + .and_then(|s| serde_json::from_str::(s).ok()) + .unwrap_or(raw) + } + + fn is_error(result: &rmcp::model::CallToolResult) -> bool { + result.is_error.unwrap_or(false) + } + + /// Call the registry and normalize errors to `CallToolResult::error` the + /// same way [`McpMuxGatewayHandler::call_tool`] does, so tests can assert + /// the wire behaviour uniformly. + async fn call_tool_as_handler_would( + &self, + name: &str, + args: Value, + ) -> rmcp::model::CallToolResult { + match self + .registry + .call(name, &self.client_id, Some(&self.session_id), args) + .await + { + Ok(r) => r, + Err(e) => e.into_call_tool_result(), + } + } +} + +// --------------------------------------------------------------------------- +// Reads +// --------------------------------------------------------------------------- + +#[tokio::test(flavor = "multi_thread")] +async fn list_all_tools_returns_unfiltered_across_servers() { + let f = Fixture::new().await; + let result = f + .registry + .call( + "mcpmux_list_all_tools", + &f.client_id, + Some(&f.session_id), + json!({}), + ) + .await + .unwrap(); + assert!(!Fixture::is_error(&result)); + let body = Fixture::result_json(&result); + let tools = body.get("tools").unwrap().as_array().unwrap(); + // Both seeded tools show up regardless of FS. + assert_eq!(tools.len(), 2); +} + +#[tokio::test(flavor = "multi_thread")] +async fn list_feature_sets_returns_space_contents() { + let f = Fixture::new().await; + let result = f + .registry + .call( + "mcpmux_list_feature_sets", + &f.client_id, + Some(&f.session_id), + json!({}), + ) + .await + .unwrap(); + let body = Fixture::result_json(&result); + let sets = body.get("feature_sets").unwrap().as_array().unwrap(); + // Seed created 2 custom FSes + the auto-seeded Default. + 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 +// `FeatureSetResolverService`'s own tests in +// `tests/rust/tests/integration/feature_set_resolver.rs`. + +// --------------------------------------------------------------------------- +// Writes — gated by ApprovalBroker +// --------------------------------------------------------------------------- + +#[tokio::test(flavor = "multi_thread")] +async fn write_without_publisher_returns_approval_required() { + let f = Fixture::new().await; + let input = if cfg!(windows) { + "D:\\Projects\\Approval\\" + } else { + "/proj/approval" + }; + f.session_roots.set(&f.session_id, [input]); + let result = f + .call_tool_as_handler_would( + "mcpmux_bind_current_workspace", + json!({ "feature_set_id": f.fs_android_id.to_string() }), + ) + .await; + assert!(Fixture::is_error(&result)); + let body = Fixture::result_json(&result); + assert_eq!( + body.get("error").unwrap().as_str().unwrap(), + "approval_required" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn write_rejected_on_deny_leaves_state_unchanged() { + let f = Fixture::new().await; + f.attach_auto_publisher(ApprovalDecision::Deny); + + let before_bindings = f.binding_repo.list().await.unwrap().len(); + + let input = if cfg!(windows) { + "D:\\Projects\\Denied\\" + } else { + "/proj/denied" + }; + f.session_roots.set(&f.session_id, [input]); + let result = f + .call_tool_as_handler_would( + "mcpmux_bind_current_workspace", + json!({ "feature_set_id": f.fs_android_id.to_string() }), + ) + .await; + assert!(Fixture::is_error(&result)); + let body = Fixture::result_json(&result); + assert_eq!( + body.get("error").unwrap().as_str().unwrap(), + "approval_denied" + ); + + let after_bindings = f.binding_repo.list().await.unwrap().len(); + assert_eq!(after_bindings, before_bindings); +} + +#[tokio::test(flavor = "multi_thread")] +async fn create_feature_set_persists_members_on_approval() { + let f = Fixture::new().await; + f.attach_auto_publisher(ApprovalDecision::AllowOnce); + + let result = f + .registry + .call( + "mcpmux_create_feature_set", + &f.client_id, + Some(&f.session_id), + json!({ + "name": "Tiny Set", + "tool_qualified_names": ["github_create_issue"], + }), + ) + .await + .unwrap(); + assert!(!Fixture::is_error(&result)); + + let body = Fixture::result_json(&result); + let new_fs_id = body.get("feature_set_id").unwrap().as_str().unwrap(); + + let fs = f + .feature_set_repo + .get_with_members(new_fs_id) + .await + .unwrap() + .unwrap(); + assert_eq!(fs.name, "Tiny Set"); + assert_eq!(fs.members.len(), 1); +} + +#[tokio::test(flavor = "multi_thread")] +async fn bind_current_workspace_fails_when_no_roots_reported() { + let f = Fixture::new().await; + f.attach_auto_publisher(ApprovalDecision::AllowOnce); + // NOTE: session_roots intentionally NOT populated. + + let result = f + .call_tool_as_handler_would( + "mcpmux_bind_current_workspace", + json!({ "feature_set_id": f.fs_android_id.to_string() }), + ) + .await; + assert!(Fixture::is_error(&result)); + let body = Fixture::result_json(&result); + assert_eq!( + body.get("error").unwrap().as_str().unwrap(), + "invalid_argument" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn bind_current_workspace_creates_binding_with_normalized_root() { + let f = Fixture::new().await; + f.attach_auto_publisher(ApprovalDecision::AllowOnce); + let input = if cfg!(windows) { + "D:\\Projects\\Android\\MyApp\\" + } else { + "/home/me/projects/android/myapp/" + }; + f.session_roots.set(&f.session_id, [input]); + + let result = f + .registry + .call( + "mcpmux_bind_current_workspace", + &f.client_id, + Some(&f.session_id), + json!({ "feature_set_id": f.fs_android_id.to_string() }), + ) + .await + .unwrap(); + assert!(!Fixture::is_error(&result)); + + let bindings = f.binding_repo.list_for_space(&f.space_id).await.unwrap(); + assert_eq!(bindings.len(), 1); + let stored = &bindings[0].workspace_root; + // Drive-letter lowercased, trailing separator trimmed. + assert_eq!(stored, &normalize_workspace_root(input)); + assert!(!stored.ends_with('/') && !stored.ends_with('\\')); + // Binding points at the concrete FS we passed in. + assert_eq!(bindings[0].space_id, f.space_id); + assert_eq!( + bindings[0].feature_set_ids, + vec![f.fs_android_id.to_string()] + ); +} + +#[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; + let input = if cfg!(windows) { + "D:\\Projects\\Invalid\\" + } else { + "/proj/invalid" + }; + f.session_roots.set(&f.session_id, [input]); + let result = f + .call_tool_as_handler_would( + "mcpmux_bind_current_workspace", + json!({ "feature_set_id": "not-a-uuid" }), + ) + .await; + assert!(Fixture::is_error(&result)); + let body = Fixture::result_json(&result); + assert_eq!( + body.get("error").unwrap().as_str().unwrap(), + "invalid_argument" + ); +} + +// --------------------------------------------------------------------------- +// Registry list-as-tools shape +// --------------------------------------------------------------------------- + +#[tokio::test(flavor = "multi_thread")] +async fn registry_advertises_every_default_tool_with_annotations() { + let f = Fixture::new().await; + let tools = f.registry.list_as_tools(); + let names: Vec<_> = tools.iter().map(|t| t.name.to_string()).collect(); + for expected in [ + "mcpmux_list_all_tools", + "mcpmux_list_feature_sets", + "mcpmux_list_servers", + "mcpmux_enable_server", + "mcpmux_disable_server", + "mcpmux_create_feature_set", + "mcpmux_bind_current_workspace", + ] { + assert!(names.iter().any(|n| n == expected), "missing {expected}"); + } + // Both describe_* tools were removed — they must NOT be advertised. + for removed in ["mcpmux_describe_resolution", "mcpmux_describe_workspace"] { + assert!( + !names.iter().any(|n| n == removed), + "{removed} should be removed; got {names:?}" + ); + } + // Writes carry the destructive_hint annotation. + let bind = tools + .iter() + .find(|t| t.name == "mcpmux_bind_current_workspace") + .unwrap(); + assert_eq!( + bind.annotations.as_ref().and_then(|a| a.destructive_hint), + Some(true) + ); +} + +// --------------------------------------------------------------------------- +// MetaToolInvoked audit emission + master switch +// --------------------------------------------------------------------------- + +/// Build a bare registry (no fixture sugar) so tests can subscribe to the +/// event bus before the first call or flip the master-switch setting. +async fn bare_registry( + settings_repo: Option>, +) -> ( + Arc, + String, + broadcast::Sender, + broadcast::Receiver, +) { + let db = Arc::new(Mutex::new(Database::open_in_memory().unwrap())); + let space_repo: Arc = Arc::new(SqliteSpaceRepository::new(db.clone())); + let feature_set_repo: Arc = + Arc::new(SqliteFeatureSetRepository::new(db.clone())); + let client_repo: Arc = + Arc::new(SqliteInboundMcpClientRepository::new(db.clone())); + let binding_repo: Arc = + Arc::new(SqliteWorkspaceBindingRepository::new(db.clone())); + let server_feature_repo: Arc = + Arc::new(SqliteServerFeatureRepository::new(db.clone())); + let 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"); + let client_id = client.id.to_string(); + client_repo.create(&client).await.unwrap(); + + let inbound_client_repo = Arc::new(InboundClientRepository::new(db.clone())); + let resolver = Arc::new(FeatureSetResolverService::new( + space_repo.clone(), + binding_repo.clone(), + SessionRootsRegistry::new(), + 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, + SessionOverrideRegistry::new(), + )); + let (tx, rx) = broadcast::channel::(32); + let registry = meta_tools::build_default_registry( + client_repo, + space_repo, + feature_set_repo, + binding_repo, + server_feature_repo, + installed_server_repo, + resolver, + feature_service, + None, + SessionRootsRegistry::new(), + SessionOverrideRegistry::new(), + Arc::new(ApprovalBroker::new()), + tx.clone(), + settings_repo, + ); + (registry, client_id, tx, rx) +} + +#[tokio::test(flavor = "multi_thread")] +async fn read_tool_emits_meta_tool_invoked_with_decision_read() { + let (registry, client_id, _tx, mut rx) = bare_registry(None).await; + + registry + .call("mcpmux_list_all_tools", &client_id, Some("s"), json!({})) + .await + .unwrap(); + + let evt = tokio::time::timeout(Duration::from_millis(200), rx.recv()) + .await + .expect("receive within 200ms") + .expect("event"); + match evt { + DomainEvent::MetaToolInvoked { + tool_name, + decision, + .. + } => { + assert_eq!(tool_name, "mcpmux_list_all_tools"); + assert_eq!(decision, "read"); + } + other => panic!("unexpected event: {other:?}"), + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn denied_write_emits_meta_tool_invoked_with_decision_deny() { + let (registry, client_id, _tx, mut rx) = bare_registry(None).await; + + // No publisher → write fails with ApprovalRequiredNoDesktop, which the + // registry's central audit-logger records as `approval_required`. + let _ = registry + .call( + "mcpmux_bind_current_workspace", + &client_id, + Some("s"), + json!({ "feature_set_id": Uuid::new_v4().to_string() }), + ) + .await; + let evt = tokio::time::timeout(Duration::from_millis(200), rx.recv()) + .await + .expect("receive within 200ms") + .expect("event"); + match evt { + DomainEvent::MetaToolInvoked { + decision, + tool_name, + .. + } => { + assert_eq!(tool_name, "mcpmux_bind_current_workspace"); + // bind_current_workspace bails on "invalid_args" (missing reported + // roots) before it reaches the approval broker — the audit + // logger records the bail-out reason, not approval_required. + assert_eq!(decision, "invalid_args"); + } + other => panic!("unexpected event: {other:?}"), + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn master_switch_toggles_registry_visibility() { + use mcpmux_storage::SqliteAppSettingsRepository; + + // Same DB so the settings repo and the registry see one another. + let db = Arc::new(Mutex::new(Database::open_in_memory().unwrap())); + let settings_repo: Arc = + Arc::new(SqliteAppSettingsRepository::new(db.clone())); + settings_repo + .set("gateway.meta_tools_enabled", "false") + .await + .unwrap(); + + let space_repo: Arc = Arc::new(SqliteSpaceRepository::new(db.clone())); + let feature_set_repo: Arc = + Arc::new(SqliteFeatureSetRepository::new(db.clone())); + let client_repo: Arc = + Arc::new(SqliteInboundMcpClientRepository::new(db.clone())); + let binding_repo: Arc = + Arc::new(SqliteWorkspaceBindingRepository::new(db.clone())); + let server_feature_repo: Arc = + Arc::new(SqliteServerFeatureRepository::new(db.clone())); + let 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(), + binding_repo.clone(), + SessionRootsRegistry::new(), + 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, + SessionOverrideRegistry::new(), + )); + let (tx, _) = broadcast::channel::(16); + let registry = meta_tools::build_default_registry( + client_repo, + space_repo, + 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()), + ); + + assert!(!registry.is_enabled().await, "initially disabled"); + + settings_repo + .set("gateway.meta_tools_enabled", "true") + .await + .unwrap(); + assert!(registry.is_enabled().await, "flipped back on"); + + // Missing key → default on (fresh install). + settings_repo + .delete("gateway.meta_tools_enabled") + .await + .unwrap(); + assert!(registry.is_enabled().await, "missing key defaults on"); +} + +// Silence unused-import warnings from helper imports that only some tests exercise. +#[allow(dead_code)] +fn _unused(_: ApprovalPayload) {} + +// ============================================================================ +// 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 6d05a3c4..eaf1640a 100644 --- a/tests/rust/tests/integration/mod.rs +++ b/tests/rust/tests/integration/mod.rs @@ -8,6 +8,10 @@ //! NOTE: Authorization tests that require InboundClientRepository //! are in the database tests since they need the real SQLite implementation. -mod feature_grants; mod feature_routing; +mod feature_set_resolver; mod mcp_flows; +mod meta_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/integration/workspace_binding_events.rs b/tests/rust/tests/integration/workspace_binding_events.rs new file mode 100644 index 00000000..d691336f --- /dev/null +++ b/tests/rust/tests/integration/workspace_binding_events.rs @@ -0,0 +1,220 @@ +//! Integration tests for the workspace-binding domain event flow. +//! +//! These tests exercise the parts the gateway relies on at the domain layer +//! — the full `on_initialized` → `list_roots` → resolver → event emission +//! path in `handler.rs` needs a live rmcp peer to drive and is covered by the +//! desktop E2E suite. What we can reach here is: +//! +//! 1. `WorkspaceBindingChanged` + `WorkspaceNeedsBinding` round-trip through +//! JSON with the shape the Tauri bridge and the frontend consumers expect. +//! 2. The resolver's decision table: roots + no binding → `source = Deny` +//! (the trigger the gateway uses to decide whether to emit the +//! `WorkspaceNeedsBinding` prompt). +//! 3. Creating / updating a binding flips the next resolution from Deny to +//! WorkspaceBinding — the behaviour that justifies firing list_changed. + +use std::sync::Arc; + +use mcpmux_core::{ + normalize_workspace_root, DomainEvent, FeatureSet, FeatureSetRepository, SpaceRepository, + WorkspaceBinding, WorkspaceBindingRepository, +}; +use mcpmux_gateway::services::{FeatureSetResolverService, ResolutionSource, SessionRootsRegistry}; +use mcpmux_storage::{ + Database, InboundClientRepository, SqliteFeatureSetRepository, SqliteSpaceRepository, + SqliteWorkspaceBindingRepository, +}; +use tokio::sync::Mutex; +use uuid::Uuid; + +struct Ctx { + resolver: FeatureSetResolverService, + session_roots: Arc, + binding_repo: Arc, + space_id: Uuid, + fs_custom_id: String, +} + +impl Ctx { + async fn new() -> Self { + let db = Arc::new(Mutex::new(Database::open_in_memory().unwrap())); + let space_repo: Arc = Arc::new(SqliteSpaceRepository::new(db.clone())); + let fs_repo: Arc = + Arc::new(SqliteFeatureSetRepository::new(db.clone())); + let binding_repo: Arc = + Arc::new(SqliteWorkspaceBindingRepository::new(db.clone())); + let inbound_client_repo = Arc::new(InboundClientRepository::new(db.clone())); + + let default_space = space_repo.get_default().await.unwrap().unwrap(); + let space_id = default_space.id; + + let custom = FeatureSet::new_custom("Custom", space_id.to_string()); + fs_repo.create(&custom).await.unwrap(); + + let session_roots = SessionRootsRegistry::new(); + let resolver = FeatureSetResolverService::new( + space_repo.clone(), + binding_repo.clone(), + session_roots.clone(), + inbound_client_repo.clone(), + ); + + Self { + resolver, + session_roots, + binding_repo, + space_id, + fs_custom_id: custom.id, + } + } +} + +/// Session with roots, no binding → resolver returns `source = Deny`. +/// This is the exact condition `handler.rs::log_and_notify_resolution` +/// turns into a `WorkspaceNeedsBinding` emission. +#[tokio::test(flavor = "multi_thread")] +async fn session_with_unbound_root_resolves_via_deny() { + let ctx = Ctx::new().await; + ctx.session_roots.set("sess-1", ["/proj/unbound"]); + ctx.session_roots.set_roots_capable("sess-1", true); + + let resolved = ctx.resolver.resolve(Some("sess-1"), None).await.unwrap(); + assert_eq!(resolved.source, ResolutionSource::Deny); + assert_eq!(resolved.space_id, Some(ctx.space_id)); + // No FS resolves until the user binds the folder; mcpmux_* meta tools + // are appended unconditionally by the request handler so the LLM can + // self-bind from this state. + assert!(resolved.feature_set_ids.is_empty()); +} + +/// After creating a binding for the root the next resolve flips to +/// `source = WorkspaceBinding`. In production that's what triggers the +/// `WorkspaceBindingChanged` → `list_changed` broadcast. +#[tokio::test(flavor = "multi_thread")] +async fn creating_binding_flips_next_resolution_source() { + let ctx = Ctx::new().await; + + // Normalize both sides so the longest-prefix lookup matches — the + // resolver compares already-normalized strings from both stores. + let raw = if cfg!(windows) { + "d:\\proj\\bind-me" + } else { + "/proj/bind-me" + }; + let root = normalize_workspace_root(raw); + ctx.session_roots.set("sess-1", [raw]); + ctx.session_roots.set_roots_capable("sess-1", true); + + let before = ctx.resolver.resolve(Some("sess-1"), None).await.unwrap(); + assert_eq!(before.source, ResolutionSource::Deny); + + let binding = WorkspaceBinding::new(root, ctx.space_id, ctx.fs_custom_id.clone()); + ctx.binding_repo.create(&binding).await.unwrap(); + + let after = ctx.resolver.resolve(Some("sess-1"), None).await.unwrap(); + assert_eq!(after.source, ResolutionSource::WorkspaceBinding); + assert_eq!(after.feature_set_ids, vec![ctx.fs_custom_id.clone()]); +} + +/// Rootless session without client grants resolves to `Deny`. No +/// `WorkspaceNeedsBinding` is appropriate here (rootless = nothing to +/// bind). This pins the rootless-silence contract — if it ever fails, the +/// notifier would start prompting users with no folder context. +#[tokio::test(flavor = "multi_thread")] +async fn rootless_session_without_grants_denies_silently() { + let ctx = Ctx::new().await; + // Deliberately no roots set; capability stamped as false (rootless). + ctx.session_roots.set_roots_capable("rootless", false); + let resolved = ctx + .resolver + .resolve(Some("rootless"), Some("unknown-client")) + .await + .unwrap(); + assert_eq!(resolved.source, ResolutionSource::Deny); +} + +/// Binding → different Space should actually route the session to that +/// Space, regardless of which Space the caller was "in" before. Pins the +/// contract that bindings carry concrete pointers (not "follow active"). +#[tokio::test(flavor = "multi_thread")] +async fn binding_to_non_default_space_reroutes_session() { + let db = Arc::new(Mutex::new(Database::open_in_memory().unwrap())); + let space_repo: Arc = Arc::new(SqliteSpaceRepository::new(db.clone())); + let fs_repo: Arc = + Arc::new(SqliteFeatureSetRepository::new(db.clone())); + let binding_repo: Arc = + Arc::new(SqliteWorkspaceBindingRepository::new(db.clone())); + let inbound_client_repo = Arc::new(InboundClientRepository::new(db.clone())); + + let default_space = space_repo.get_default().await.unwrap().unwrap(); + + // A second Space, with its own Custom FS. The binding below will route + // the reported root here even though the default Space is still the + // "default". + let other = mcpmux_core::Space::new("Other"); + let other_id = other.id; + space_repo.create(&other).await.unwrap(); + let other_fs = FeatureSet::new_custom("Other Custom", other_id.to_string()); + fs_repo.create(&other_fs).await.unwrap(); + + let session_roots = SessionRootsRegistry::new(); + let resolver = FeatureSetResolverService::new( + space_repo.clone(), + binding_repo.clone(), + session_roots.clone(), + inbound_client_repo.clone(), + ); + + let raw = if cfg!(windows) { + "d:\\other\\work" + } else { + "/other/work" + }; + let root = normalize_workspace_root(raw); + session_roots.set("sess-X", [raw]); + session_roots.set_roots_capable("sess-X", true); + + // Before binding: roots reported, no binding → Deny in the default + // space (the resolver still reports a space_id so the upstream prompt + // knows where to scope the binding sheet). + let before = resolver.resolve(Some("sess-X"), None).await.unwrap(); + assert_eq!(before.source, ResolutionSource::Deny); + assert_eq!(before.space_id, Some(default_space.id)); + + // Create a binding targeting `other` space's Custom FS. + let b = WorkspaceBinding::new(root, other_id, other_fs.id.clone()); + binding_repo.create(&b).await.unwrap(); + + let after = resolver.resolve(Some("sess-X"), None).await.unwrap(); + assert_eq!(after.source, ResolutionSource::WorkspaceBinding); + assert_eq!(after.space_id, Some(other_id)); + assert_eq!(after.feature_set_ids, vec![other_fs.id]); +} + +/// Minimal "is the Tauri bridge payload the shape the webview expects?" +/// sanity check. If the serde tag or field names change, both the +/// `workspace-needs-binding` Tauri channel consumer and the +/// `WorkspaceBindingSheet` component's TypeScript payload type break. +#[test] +fn event_json_payloads_are_stable() { + let changed = DomainEvent::WorkspaceBindingChanged { + space_id: Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(), + workspace_root: "/abs/path".to_string(), + }; + let v: serde_json::Value = serde_json::to_value(&changed).unwrap(); + assert_eq!(v["type"], "workspace_binding_changed"); + assert_eq!(v["workspace_root"], "/abs/path"); + assert_eq!(v["space_id"], "00000000-0000-0000-0000-000000000001"); + + let needs = DomainEvent::WorkspaceNeedsBinding { + client_id: "vscode".to_string(), + session_id: "s-9".to_string(), + space_id: Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(), + workspace_root: "/abs/path".to_string(), + }; + let v: serde_json::Value = serde_json::to_value(&needs).unwrap(); + assert_eq!(v["type"], "workspace_needs_binding"); + assert_eq!(v["client_id"], "vscode"); + assert_eq!(v["session_id"], "s-9"); + assert_eq!(v["workspace_root"], "/abs/path"); +} diff --git a/tests/rust/tests/oauth/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/oauth/flow.rs b/tests/rust/tests/oauth/flow.rs index de7f3dcb..d9c0a174 100644 --- a/tests/rust/tests/oauth/flow.rs +++ b/tests/rust/tests/oauth/flow.rs @@ -1,5 +1,9 @@ //! OAuth Flow integration tests with mock HTTP server +// Pre-existing test code uses `&mock_server.uri()` where clippy 1.93+ prefers +// passing the String directly. Silenced at file scope to keep the diff small. +#![allow(clippy::needless_borrows_for_generic_args)] + use mcpmux_gateway::oauth::{ AuthorizationCallback, OAuthConfig, OAuthFlow, OAuthManager, OAuthMetadata, }; diff --git a/tests/rust/tests/streamable_http/gateway_notifications.rs b/tests/rust/tests/streamable_http/gateway_notifications.rs index 88c96eb5..834e0530 100644 --- a/tests/rust/tests/streamable_http/gateway_notifications.rs +++ b/tests/rust/tests/streamable_http/gateway_notifications.rs @@ -140,11 +140,11 @@ impl TestGateway { metadata_url: None, metadata_cached_at: None, metadata_cache_ttl: None, - connection_mode: "follow_active".to_string(), - locked_space_id: None, last_seen: None, created_at: now.clone(), updated_at: now, + reports_roots: false, + roots_capability_known: false, }; inbound_client_repo .save_client(&test_client) @@ -198,8 +198,9 @@ impl TestGateway { // Create MCPNotifier let notifier = Arc::new(MCPNotifier::new( - services.space_resolver_service.clone(), + services.feature_set_resolver.clone(), services.pool_services.feature_service.clone(), + services.session_overrides.clone(), )); // Start MCPNotifier listening for domain events @@ -210,16 +211,16 @@ impl TestGateway { let handler = McpMuxGatewayHandler::new(services.clone(), notifier.clone()); // Build MCP service + let mut http_cfg = StreamableHttpServerConfig::default(); + http_cfg.stateful_mode = true; + http_cfg.json_response = false; + http_cfg.sse_keep_alive = Some(std::time::Duration::from_secs(15)); + http_cfg.sse_retry = Some(std::time::Duration::from_secs(3)); + http_cfg.cancellation_token = ct.child_token(); let mcp_service = StreamableHttpService::new( move || Ok(handler.clone()), Arc::new(LocalSessionManager::default()), - StreamableHttpServerConfig { - stateful_mode: true, - json_response: false, - sse_keep_alive: Some(std::time::Duration::from_secs(15)), - sse_retry: Some(std::time::Duration::from_secs(3)), - cancellation_token: ct.child_token(), - }, + http_cfg, ); // Build router with test OAuth middleware @@ -309,16 +310,10 @@ impl GatewayTestClient { impl rmcp::ClientHandler for GatewayTestClient { fn get_info(&self) -> ClientInfo { - ClientInfo { - protocol_version: Default::default(), - capabilities: ClientCapabilities::default(), - client_info: Implementation { - name: "gateway-test-client".to_string(), - version: "1.0.0".to_string(), - ..Default::default() - }, - ..Default::default() - } + ClientInfo::new( + ClientCapabilities::default(), + Implementation::new("gateway-test-client", "1.0.0"), + ) } fn on_tool_list_changed( @@ -485,12 +480,14 @@ async fn test_gateway_forwards_server_disconnect_to_client() { // ============================================================================ #[tokio::test(flavor = "multi_thread")] -async fn test_gateway_forwards_grant_change_to_client() { +async fn test_gateway_forwards_feature_set_member_change_to_client() { + // Replaces the old "grant change" test. Per-client grants are gone, so + // the corresponding signal now is `FeatureSetMembersChanged` — emitted + // when a user edits which features a Space's FS exposes. let space_id = Uuid::new_v4(); let client_id = Uuid::new_v4().to_string(); let gw = TestGateway::start(&client_id, space_id).await; - // Seed a feature so hash has content let tool = tests::features::test_tool(&space_id.to_string(), "srv", "tool1"); gw.feature_repo.upsert(&tool).await.unwrap(); @@ -500,15 +497,14 @@ async fn test_gateway_forwards_grant_change_to_client() { tokio::time::sleep(std::time::Duration::from_millis(500)).await; - // Add another feature so hash changes let new_tool = tests::features::test_tool(&space_id.to_string(), "srv", "tool2"); gw.feature_repo.upsert(&new_tool).await.unwrap(); - // Emit GrantIssued event - gw.emit(DomainEvent::GrantIssued { - client_id: client_id.clone(), + gw.emit(DomainEvent::FeatureSetMembersChanged { space_id, feature_set_id: "fs-test".to_string(), + added_count: 1, + removed_count: 0, }); let result = @@ -516,7 +512,51 @@ async fn test_gateway_forwards_grant_change_to_client() { assert!( result.is_ok(), - "Client should receive list_changed when grant is issued" + "Client should receive list_changed when a FS's members change" + ); + + client.cancel().await.ok(); + gw.shutdown(); +} + +// ============================================================================ +// B4b: Gateway forwards WorkspaceBindingChanged to every peer in the space +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_gateway_forwards_workspace_binding_change_to_client() { + // User just created / updated / deleted a binding. Every connected MCP + // client that resolves into this Space must re-fetch its tool list, + // since the binding could have flipped the root → (space, FS) mapping. + let space_id = Uuid::new_v4(); + let client_id = Uuid::new_v4().to_string(); + let gw = TestGateway::start(&client_id, space_id).await; + + // Seed then add another tool so the content hash changes and the + // notifier actually forwards the event (it dedupes on identical hash). + let tool = tests::features::test_tool(&space_id.to_string(), "srv", "tool1"); + gw.feature_repo.upsert(&tool).await.unwrap(); + + let client_handler = GatewayTestClient::new(); + let tools_changed = client_handler.tools_changed.clone(); + let client = connect_client(&gw.url, client_handler).await; + + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + + let new_tool = tests::features::test_tool(&space_id.to_string(), "srv", "tool2"); + gw.feature_repo.upsert(&new_tool).await.unwrap(); + + gw.emit(DomainEvent::WorkspaceBindingChanged { + space_id, + workspace_root: "/abs/proj".to_string(), + }); + + let result = + tokio::time::timeout(std::time::Duration::from_secs(5), tools_changed.notified()).await; + + assert!( + result.is_ok(), + "Client should receive list_changed when a WorkspaceBinding is changed", ); client.cancel().await.ok(); @@ -541,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 { @@ -555,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)" ); @@ -676,19 +717,35 @@ async fn test_client_can_list_tools_after_notification() { tokio::time::sleep(std::time::Duration::from_millis(500)).await; - // Initially no tools (empty feature repo = empty tools list) + // Initially no BACKEND tools (empty feature repo). The gateway always + // appends its built-in `mcpmux_*` meta tools regardless of FS resolution, + // so we assert on the non-meta subset here. let tools = client .list_tools(Default::default()) .await .expect("list_tools should work"); - assert_eq!(tools.tools.len(), 0, "Should start with no tools"); + let backend_tools: Vec<_> = tools + .tools + .iter() + .filter(|t| !t.name.starts_with("mcpmux_")) + .collect(); + assert_eq!( + backend_tools.len(), + 0, + "Should start with no backend tools; meta tools are always present" + ); - // list_tools should still work after re-fetch + // list_tools should still work after re-fetch. let tools2 = client .list_tools(Default::default()) .await .expect("second list_tools should work"); - assert_eq!(tools2.tools.len(), 0, "Still no tools"); + let backend_tools2: Vec<_> = tools2 + .tools + .iter() + .filter(|t| !t.name.starts_with("mcpmux_")) + .collect(); + assert_eq!(backend_tools2.len(), 0, "Still no backend tools"); client.cancel().await.ok(); gw.shutdown(); diff --git a/tests/rust/tests/streamable_http/notifications.rs b/tests/rust/tests/streamable_http/notifications.rs index 382eac36..255fe28c 100644 --- a/tests/rust/tests/streamable_http/notifications.rs +++ b/tests/rust/tests/streamable_http/notifications.rs @@ -52,27 +52,21 @@ impl TestNotificationHandler { impl ServerHandler for TestNotificationHandler { fn get_info(&self) -> ServerInfo { - ServerInfo { - protocol_version: Default::default(), - capabilities: ServerCapabilities::builder() - .enable_tools_with(ToolsCapability { - list_changed: Some(true), // Key: advertise notification support - }) - .enable_prompts_with(PromptsCapability { - list_changed: Some(true), - }) - .enable_resources_with(ResourcesCapability { - subscribe: Some(false), - list_changed: Some(true), - }) - .build(), - server_info: Implementation { - name: "test-notification-server".to_string(), - version: "1.0.0".to_string(), - ..Default::default() - }, - instructions: None, - } + let capabilities = ServerCapabilities::builder() + .enable_tools_with(ToolsCapability { + list_changed: Some(true), // Key: advertise notification support + }) + .enable_prompts_with(PromptsCapability { + list_changed: Some(true), + }) + .enable_resources_with(ResourcesCapability { + subscribe: Some(false), + list_changed: Some(true), + }) + .build(); + let mut info = ServerInfo::new(capabilities); + info.server_info = Implementation::new("test-notification-server", "1.0.0"); + info } async fn on_initialized(&self, context: NotificationContext) { @@ -122,16 +116,16 @@ impl ServerHandler for TestNotificationHandler { async fn start_test_server(handler: TestNotificationHandler) -> (String, CancellationToken) { let ct = CancellationToken::new(); + let mut http_cfg = StreamableHttpServerConfig::default(); + http_cfg.stateful_mode = true; + http_cfg.json_response = false; + http_cfg.sse_keep_alive = Some(std::time::Duration::from_secs(15)); + http_cfg.sse_retry = Some(std::time::Duration::from_secs(3)); + http_cfg.cancellation_token = ct.child_token(); let service = StreamableHttpService::new( move || Ok(handler.clone()), Arc::new(LocalSessionManager::default()), - StreamableHttpServerConfig { - stateful_mode: true, - json_response: false, - sse_keep_alive: Some(std::time::Duration::from_secs(15)), - sse_retry: Some(std::time::Duration::from_secs(3)), - cancellation_token: ct.child_token(), - }, + http_cfg, ); let router = axum::Router::new().nest_service("/mcp", service); @@ -163,16 +157,10 @@ async fn test_stateful_session_management() { // Connect client let transport = StreamableHttpClientTransport::from_uri(url.as_str()); - let client = ClientInfo { - protocol_version: Default::default(), - capabilities: ClientCapabilities::default(), - client_info: Implementation { - name: "test-client".to_string(), - version: "1.0.0".to_string(), - ..Default::default() - }, - ..Default::default() - } + let client = ClientInfo::new( + ClientCapabilities::default(), + Implementation::new("test-client", "1.0.0"), + ) .serve(transport) .await .expect("client should connect"); @@ -304,16 +292,10 @@ impl NotificationTrackingClient { impl rmcp::ClientHandler for NotificationTrackingClient { fn get_info(&self) -> ClientInfo { - ClientInfo { - protocol_version: Default::default(), - capabilities: ClientCapabilities::default(), - client_info: Implementation { - name: "notification-tracking-client".to_string(), - version: "1.0.0".to_string(), - ..Default::default() - }, - ..Default::default() - } + ClientInfo::new( + ClientCapabilities::default(), + Implementation::new("notification-tracking-client", "1.0.0"), + ) } fn on_tool_list_changed( @@ -615,16 +597,10 @@ async fn test_session_persists_across_requests() { let (url, ct) = start_test_server(handler.clone()).await; let transport = StreamableHttpClientTransport::from_uri(url.as_str()); - let client = ClientInfo { - protocol_version: Default::default(), - capabilities: ClientCapabilities::default(), - client_info: Implementation { - name: "session-test-client".to_string(), - version: "1.0.0".to_string(), - ..Default::default() - }, - ..Default::default() - } + let client = ClientInfo::new( + ClientCapabilities::default(), + Implementation::new("session-test-client", "1.0.0"), + ) .serve(transport) .await .expect("client should connect"); @@ -651,12 +627,7 @@ async fn test_session_persists_across_requests() { // Call a tool let result = client - .call_tool(CallToolRequestParams { - name: "test_tool".into(), - arguments: None, - meta: None, - task: None, - }) + .call_tool(CallToolRequestParams::new("test_tool")) .await .expect("call_tool"); assert!(!result.content.is_empty()); @@ -683,16 +654,10 @@ async fn test_protocol_version_negotiation() { // Connect with default (latest) protocol version let transport = StreamableHttpClientTransport::from_uri(url.as_str()); - let client = ClientInfo { - protocol_version: Default::default(), - capabilities: ClientCapabilities::default(), - client_info: Implementation { - name: "protocol-test-client".to_string(), - version: "1.0.0".to_string(), - ..Default::default() - }, - ..Default::default() - } + let client = ClientInfo::new( + ClientCapabilities::default(), + Implementation::new("protocol-test-client", "1.0.0"), + ) .serve(transport) .await .expect("client should connect with default protocol version"); diff --git a/tests/ts/components/App.test.tsx b/tests/ts/components/App.test.tsx index e32a2e04..d653ce5c 100644 --- a/tests/ts/components/App.test.tsx +++ b/tests/ts/components/App.test.tsx @@ -40,6 +40,10 @@ vi.mock('@/features/spaces', () => ({ vi.mock('@/features/settings', () => ({ SettingsPage: () =>
    , })); +vi.mock('@/features/workspaces', () => ({ + WorkspacesPage: () =>
    , + WorkspaceBindingSheet: () => null, +})); // Mock non-essential components vi.mock('@/components/OAuthConsentModal', () => ({ @@ -89,6 +93,21 @@ vi.mock('@/lib/api/gateway', () => ({ startGateway: vi.fn().mockResolvedValue('http://localhost:45818'), stopGateway: vi.fn().mockResolvedValue(undefined), restartGateway: vi.fn().mockResolvedValue(undefined), + // AutoStartConflictResolver polls these on mount; default to a + // "no conflict" state so the resolver no-ops in tests. + takePendingPortConflict: vi.fn().mockResolvedValue(null), + probeGatewayStart: vi.fn().mockResolvedValue({ + preferred_port: 45818, + preferred_available: true, + source: 'Default', + }), + getGatewayPortSettings: vi.fn().mockResolvedValue({ + configured_port: null, + default_port: 45818, + active_port: null, + }), + openUrl: vi.fn().mockResolvedValue(undefined), + parsePortInUseError: vi.fn().mockReturnValue(null), })); vi.mock('@/lib/api/clients', () => ({ listClients: vi.fn().mockResolvedValue([]), @@ -149,34 +168,30 @@ describe('App – dynamic version display', () => { render(); await waitFor(() => { - expect(screen.getByTestId('sidebar')).toHaveTextContent('McpMux v1.2.3'); + expect(screen.getByTestId('statusbar-version')).toHaveTextContent('v1.2.3'); }); }); - it('should display "McpMux" without version suffix while loading', () => { + it('should hide the version span while loading', () => { // invoke never resolves mockInvoke.mockImplementation(() => new Promise(() => {})); render(); - const sidebar = screen.getByTestId('sidebar'); - expect(sidebar).toHaveTextContent('McpMux'); - expect(sidebar).not.toHaveTextContent('McpMux v'); + // The span only renders once `appVersion` has a value. + expect(screen.queryByTestId('statusbar-version')).toBeNull(); }); - it('should display "McpMux" without crashing when version fetch fails', async () => { + it('should not crash and should omit the version when fetch fails', async () => { setupInvoke({ get_version: new Error('command failed') }); render(); - // Wait for the rejected promise to be handled + // App still renders (sidebar is present) even if version lookup errored. await waitFor(() => { - const sidebar = screen.getByTestId('sidebar'); - expect(sidebar).toHaveTextContent('McpMux'); + expect(screen.getByTestId('sidebar')).toBeInTheDocument(); }); - - // Should not show a version number - expect(screen.getByTestId('sidebar')).not.toHaveTextContent('McpMux v'); + expect(screen.queryByTestId('statusbar-version')).toBeNull(); }); }); @@ -186,74 +201,87 @@ describe('App – dynamic gateway URL display', () => { setupInvoke({ get_version: '0.1.2' }); }); - it('should show "Not running" as default gateway state', async () => { + it('should show "Gateway stopped" as default gateway state', async () => { setupGateway({ running: false, url: null }); render(); await waitFor(() => { - expect(screen.getByTestId('sidebar')).toHaveTextContent('Gateway: Not running'); + expect(screen.getByTestId('statusbar-gateway')).toHaveTextContent( + 'Gateway stopped' + ); }); }); - it('should show "Not running" when gateway is running but url is null', async () => { + it('should show "Gateway stopped" when gateway is running but url is null', async () => { setupGateway({ running: true, url: null }); render(); await waitFor(() => { - expect(screen.getByTestId('sidebar')).toHaveTextContent('Gateway: Not running'); + expect(screen.getByTestId('statusbar-gateway')).toHaveTextContent( + 'Gateway stopped' + ); }); }); - it('should update URL when gateway-started event fires', async () => { + it('should flip to running state when gateway-started event fires', async () => { setupGateway({ running: false, url: null }); render(); await waitFor(() => { - expect(screen.getByTestId('sidebar')).toHaveTextContent('Gateway: Not running'); + expect(screen.getByTestId('statusbar-gateway')).toHaveTextContent( + 'Gateway stopped' + ); }); - // Simulate gateway started event + // Simulate gateway started event with a port. act(() => { - fireGatewayEvent({ action: 'started', url: 'http://localhost:9999' }); + fireGatewayEvent({ action: 'started', url: 'http://localhost:9999', port: 9999 }); }); await waitFor(() => { - expect(screen.getByTestId('sidebar')).toHaveTextContent( - 'Gateway: http://localhost:9999' + expect(screen.getByTestId('statusbar-gateway')).toHaveTextContent('Gateway'); + expect(screen.getByTestId('statusbar-gateway')).not.toHaveTextContent( + 'stopped' ); }); }); - it('should show "Not running" when gateway-stopped event fires', async () => { + it('should flip back to stopped when gateway-stopped event fires', async () => { setupGateway({ running: false, url: null }); render(); await waitFor(() => { - expect(screen.getByTestId('sidebar')).toHaveTextContent('Gateway: Not running'); + expect(screen.getByTestId('statusbar-gateway')).toHaveTextContent( + 'Gateway stopped' + ); }); - // Start the gateway via event, then stop it act(() => { - fireGatewayEvent({ action: 'started', url: 'http://localhost:45818' }); + fireGatewayEvent({ + action: 'started', + url: 'http://localhost:45818', + port: 45818, + }); }); await waitFor(() => { - expect(screen.getByTestId('sidebar')).toHaveTextContent( - 'Gateway: http://localhost:45818' + expect(screen.getByTestId('statusbar-gateway')).not.toHaveTextContent( + 'stopped' ); }); - // Simulate gateway stopped event act(() => { fireGatewayEvent({ action: 'stopped' }); }); await waitFor(() => { - expect(screen.getByTestId('sidebar')).toHaveTextContent('Gateway: Not running'); + expect(screen.getByTestId('statusbar-gateway')).toHaveTextContent( + 'Gateway stopped' + ); }); }); }); diff --git a/tests/ts/stores/appStore.test.ts b/tests/ts/stores/appStore.test.ts index 0866a077..60a46bcf 100644 --- a/tests/ts/stores/appStore.test.ts +++ b/tests/ts/stores/appStore.test.ts @@ -1,13 +1,12 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { useAppStore } from '../../../apps/desktop/src/stores/appStore'; -import { createTestSpace, createDefaultSpace, createTestSpaces, Space } from '../fixtures'; +import { createTestSpace, createDefaultSpace, createTestSpaces } from '../fixtures'; describe('appStore', () => { beforeEach(() => { // Reset store to initial state before each test useAppStore.setState({ spaces: [], - activeSpaceId: null, viewSpaceId: null, activeNav: 'home', pendingClientId: null, @@ -25,14 +24,14 @@ describe('appStore', () => { expect(useAppStore.getState().spaces).toEqual(spaces); }); - it('should auto-select first space as active when none selected', () => { + it('should auto-select first space as the view when none selected', () => { const spaces = createTestSpaces(3); useAppStore.getState().setSpaces(spaces); - expect(useAppStore.getState().activeSpaceId).toBe(spaces[0].id); + expect(useAppStore.getState().viewSpaceId).toBe(spaces[0].id); }); - it('should prefer default space when auto-selecting', () => { + it('should prefer the default space when auto-selecting the view', () => { const spaces = [ createTestSpace({ name: 'Space 1', is_default: false }), createDefaultSpace({ name: 'Default' }), @@ -40,99 +39,40 @@ describe('appStore', () => { ]; useAppStore.getState().setSpaces(spaces); - expect(useAppStore.getState().activeSpaceId).toBe(spaces[1].id); - }); - - it('should set viewSpaceId to activeSpaceId when not set', () => { - const spaces = createTestSpaces(2); - useAppStore.getState().setSpaces(spaces); - - expect(useAppStore.getState().viewSpaceId).toBe(useAppStore.getState().activeSpaceId); + expect(useAppStore.getState().viewSpaceId).toBe(spaces[1].id); }); - it('should reset viewSpaceId if current view space no longer exists', () => { - const spaces = createTestSpaces(2); - useAppStore.setState({ viewSpaceId: 'non-existent-id' }); + it('should keep viewSpaceId when it still exists in the spaces list', () => { + const spaces = createTestSpaces(3); + useAppStore.setState({ viewSpaceId: spaces[1].id }); useAppStore.getState().setSpaces(spaces); - expect(useAppStore.getState().viewSpaceId).toBe(useAppStore.getState().activeSpaceId); + expect(useAppStore.getState().viewSpaceId).toBe(spaces[1].id); }); - it('should reset activeSpaceId when persisted value points to deleted space', () => { + it('should reset viewSpaceId to the default space when the persisted view is gone', () => { const spaces = [ createTestSpace({ name: 'Space A', is_default: false }), createDefaultSpace({ name: 'Default Space' }), ]; - // Simulate a persisted activeSpaceId that no longer exists in the spaces list - useAppStore.setState({ activeSpaceId: 'deleted-space-id' }); + useAppStore.setState({ viewSpaceId: 'deleted-space-id' }); useAppStore.getState().setSpaces(spaces); - // Should fallback to the default space - expect(useAppStore.getState().activeSpaceId).toBe(spaces[1].id); + expect(useAppStore.getState().viewSpaceId).toBe(spaces[1].id); }); - it('should reset activeSpaceId to first space when no default exists', () => { + it('should reset viewSpaceId to the first space when no default exists', () => { const spaces = [ createTestSpace({ name: 'Space A', is_default: false }), createTestSpace({ name: 'Space B', is_default: false }), ]; - useAppStore.setState({ activeSpaceId: 'deleted-space-id' }); - useAppStore.getState().setSpaces(spaces); - - expect(useAppStore.getState().activeSpaceId).toBe(spaces[0].id); - }); - - it('should keep activeSpaceId when it still exists in spaces list', () => { - const spaces = createTestSpaces(3); - useAppStore.setState({ activeSpaceId: spaces[1].id }); + useAppStore.setState({ viewSpaceId: 'deleted-space-id' }); useAppStore.getState().setSpaces(spaces); - expect(useAppStore.getState().activeSpaceId).toBe(spaces[1].id); - }); - - it('should reset both activeSpaceId and viewSpaceId when both point to deleted spaces', () => { - const spaces = [createDefaultSpace({ name: 'My Space' })]; - useAppStore.setState({ - activeSpaceId: 'deleted-active-id', - viewSpaceId: 'deleted-view-id', - }); - useAppStore.getState().setSpaces(spaces); - - expect(useAppStore.getState().activeSpaceId).toBe(spaces[0].id); expect(useAppStore.getState().viewSpaceId).toBe(spaces[0].id); }); }); - describe('setActiveSpace', () => { - it('should set active space id', () => { - const spaces = createTestSpaces(3); - useAppStore.getState().setSpaces(spaces); - useAppStore.getState().setActiveSpace(spaces[2].id); - - expect(useAppStore.getState().activeSpaceId).toBe(spaces[2].id); - }); - - it('should follow with viewSpaceId when they were the same', () => { - const spaces = createTestSpaces(3); - useAppStore.getState().setSpaces(spaces); - // viewSpaceId should now equal activeSpaceId (both spaces[0].id) - - useAppStore.getState().setActiveSpace(spaces[1].id); - - expect(useAppStore.getState().viewSpaceId).toBe(spaces[1].id); - }); - - it('should not change viewSpaceId when different from activeSpaceId', () => { - const spaces = createTestSpaces(3); - useAppStore.getState().setSpaces(spaces); - useAppStore.getState().setViewSpace(spaces[2].id); - - useAppStore.getState().setActiveSpace(spaces[1].id); - - expect(useAppStore.getState().viewSpaceId).toBe(spaces[2].id); - }); - }); - describe('setViewSpace', () => { it('should set view space id', () => { const spaces = createTestSpaces(3); @@ -151,38 +91,31 @@ describe('appStore', () => { expect(useAppStore.getState().spaces).toContainEqual(space); }); - it('should set active space when first space is added', () => { + it('should set viewSpaceId when first space is added', () => { const space = createTestSpace(); useAppStore.getState().addSpace(space); - expect(useAppStore.getState().activeSpaceId).toBe(space.id); + expect(useAppStore.getState().viewSpaceId).toBe(space.id); }); - it('should set active space when default space is added', () => { + it('should snap viewSpaceId to a newly added default space', () => { const existing = createTestSpace(); const defaultSpace = createDefaultSpace(); useAppStore.getState().addSpace(existing); useAppStore.getState().addSpace(defaultSpace); - expect(useAppStore.getState().activeSpaceId).toBe(defaultSpace.id); + expect(useAppStore.getState().viewSpaceId).toBe(defaultSpace.id); }); - it('should not change active space when non-default space is added', () => { + it('should not change viewSpaceId when adding a non-default space', () => { const first = createTestSpace({ name: 'First' }); const second = createTestSpace({ name: 'Second' }); useAppStore.getState().addSpace(first); useAppStore.getState().addSpace(second); - expect(useAppStore.getState().activeSpaceId).toBe(first.id); - }); - - it('should initialize viewSpaceId when first space is added', () => { - const space = createTestSpace(); - useAppStore.getState().addSpace(space); - - expect(useAppStore.getState().viewSpaceId).toBe(space.id); + expect(useAppStore.getState().viewSpaceId).toBe(first.id); }); }); @@ -196,29 +129,23 @@ describe('appStore', () => { expect(useAppStore.getState().spaces.find((s) => s.id === spaces[1].id)).toBeUndefined(); }); - it('should select first remaining space when active space is removed', () => { - const spaces = createTestSpaces(3); - useAppStore.getState().setSpaces(spaces); - useAppStore.getState().removeSpace(spaces[0].id); + it('should fall back to the default space when the viewed space is removed', () => { + const def = createDefaultSpace({ name: 'Default' }); + const other = createTestSpace({ name: 'Other', is_default: false }); + useAppStore.getState().setSpaces([def, other]); + useAppStore.getState().setViewSpace(other.id); + + useAppStore.getState().removeSpace(other.id); - expect(useAppStore.getState().activeSpaceId).toBe(spaces[1].id); + expect(useAppStore.getState().viewSpaceId).toBe(def.id); }); - it('should set activeSpaceId to null when last space is removed', () => { + it('should set viewSpaceId to null when last space is removed', () => { const space = createTestSpace(); useAppStore.getState().addSpace(space); useAppStore.getState().removeSpace(space.id); - expect(useAppStore.getState().activeSpaceId).toBeNull(); - }); - - it('should update viewSpaceId when viewed space is removed', () => { - const spaces = createTestSpaces(3); - useAppStore.getState().setSpaces(spaces); - useAppStore.getState().setViewSpace(spaces[1].id); - useAppStore.getState().removeSpace(spaces[1].id); - - expect(useAppStore.getState().viewSpaceId).toBe(useAppStore.getState().activeSpaceId); + expect(useAppStore.getState().viewSpaceId).toBeNull(); }); });