From 985e6a4d0c025ebdea435f6884e91158e31d0ce9 Mon Sep 17 00:00:00 2001 From: crimsonsunset Date: Mon, 18 May 2026 22:36:16 -0600 Subject: [PATCH 01/48] =?UTF-8?q?fix(oauth,services):=20port=20#152=20?= =?UTF-8?q?=E2=80=94=20DCR=20redirect=20URI=20tolerance=20+=20clippy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-picks the two changes from upstream PR #152 (mcpmux/mcp-mux#152) since `feat/workspace-root-routing` (PR #151, this branch's base) reworked `dcr.rs` extensively and a direct merge would conflict. - `validate_redirect_uris` now skips invalid URIs with a warn log and only fails registration when zero valid URIs remain. Unblocks Cursor 3.4.20, which sends a mixed-validity list (`cursor://`, `https://www.cursor.com`, `http://localhost`) in a single DCR request. - `PrefixCacheService` uses `sort_by_key` for the `created_at` sort so `clippy::unnecessary_sort_by` doesn't fail `cargo clippy -D warnings`. Adds two tests covering the mixed-validity case and the all-invalid case. Signed-off-by: crimsonsunset --- crates/mcpmux-gateway/src/oauth/dcr.rs | 43 ++++++++++++++++--- .../src/services/prefix_cache.rs | 2 +- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/crates/mcpmux-gateway/src/oauth/dcr.rs b/crates/mcpmux-gateway/src/oauth/dcr.rs index fc6a9a06..ed255019 100644 --- a/crates/mcpmux-gateway/src/oauth/dcr.rs +++ b/crates/mcpmux-gateway/src/oauth/dcr.rs @@ -227,6 +227,8 @@ pub fn validate_redirect_uris(uris: &[String]) -> Result<(), DcrError> { )); } + let mut valid_count = 0; + for uri in uris { let is_loopback = uri.starts_with("http://127.0.0.1") || uri.starts_with("http://localhost") @@ -237,20 +239,28 @@ pub fn validate_redirect_uris(uris: &[String]) -> Result<(), DcrError> { let is_custom_scheme = !uri.starts_with("http://") && !uri.starts_with("https://"); if !is_loopback && !is_custom_scheme { + // Skip invalid URIs (e.g. https://www.cursor.com/agents/mcp/oauth/callback) + // rather than rejecting the entire registration — clients like Cursor send a + // mix of valid and invalid URIs and only ever use the valid ones in practice. warn!( - "[DCR] Rejected redirect_uri: {} (must be loopback or custom scheme)", + "[DCR] Skipping invalid redirect_uri: {} (must be loopback or custom scheme)", uri ); - return Err(DcrError::invalid_redirect_uri( - "Redirect URI must be loopback (http://127.0.0.1 or http://localhost) \ - or a custom URL scheme (e.g., cursor://, vscode://)", - )); + continue; } debug!( "[DCR] Validated redirect_uri: {} (loopback={}, custom_scheme={})", uri, is_loopback, is_custom_scheme ); + valid_count += 1; + } + + if valid_count == 0 { + return Err(DcrError::invalid_redirect_uri( + "No valid redirect_uris provided — must include at least one loopback \ + (http://127.0.0.1 or http://localhost) or custom URL scheme (e.g., cursor://, vscode://)", + )); } Ok(()) @@ -462,6 +472,29 @@ mod tests { assert!(validate_redirect_uris(&["https://example.com/callback".to_string()]).is_err()); } + #[test] + fn test_mixed_valid_and_invalid_uris_pass() { + // Real-world case: Cursor sends a mix of valid (custom scheme + loopback) and + // invalid (https) URIs. Registration must succeed as long as at least one valid + // URI is present — otherwise clients that send any non-loopback HTTPS URI cannot + // register at all. + let uris = vec![ + "cursor://anysphere.cursor-mcp/oauth/callback".to_string(), + "https://www.cursor.com/agents/mcp/oauth/callback".to_string(), + "http://localhost:8787/callback".to_string(), + ]; + assert!(validate_redirect_uris(&uris).is_ok()); + } + + #[test] + fn test_all_invalid_uris_fail() { + let uris = vec![ + "https://www.cursor.com/agents/mcp/oauth/callback".to_string(), + "http://example.com/callback".to_string(), + ]; + assert!(validate_redirect_uris(&uris).is_err()); + } + #[test] fn loopback_ignores_port_per_rfc_8252() { // Registered with one port, requested with another — must match. diff --git a/crates/mcpmux-gateway/src/services/prefix_cache.rs b/crates/mcpmux-gateway/src/services/prefix_cache.rs index f5976af6..2a85d95b 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; From 7bf4cb17548bf20daa1a49d07565377ceef98001 Mon Sep 17 00:00:00 2001 From: crimsonsunset Date: Mon, 18 May 2026 22:36:23 -0600 Subject: [PATCH 02/48] docs(planning): add plan for dynamic MCP toggle meta tools 5-phase plan layered on top of PR #151's `mcpmux_*` meta-tool surface: adds `mcpmux_list_servers`, `mcpmux_enable_server`, `mcpmux_disable_server` with a session-scoped default (in-memory `SessionOverrideRegistry`) and a workspace-scope opt-in that reuses the existing `WorkspaceBinding` write path. Resolver stays untouched; composition lives in `FeatureService`. Closes the gap between PR #151's persistent bindings and the ephemeral "LLM-driven, minimum-context-default" workflow from the jsg-tech-check homelab plan. Signed-off-by: crimsonsunset --- .../planning/dynamic-mcp-toggle-meta-tools.md | 278 ++++++++++++++++++ 1 file changed, 278 insertions(+) create mode 100644 docs/planning/dynamic-mcp-toggle-meta-tools.md 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..3fca5d91 --- /dev/null +++ b/docs/planning/dynamic-mcp-toggle-meta-tools.md @@ -0,0 +1,278 @@ +# Dynamic MCP Toggling via Meta Tools + +**Last Updated:** May 18, 2026 +**Status:** Planning — decisions locked, ready for implementation +**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/session_overrides.rs` | Integration tests: enable adds tool, disable removes tool, composition with binding, GC on session reap, per-peer `list_changed` fires | +| `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/workspace_binding.rs`](../../apps/desktop/src-tauri/src/commands/workspace_binding.rs) | New Tauri commands: `list_session_overrides(session_id)`, `clear_session_overrides(session_id)`. Read-only + clear; mutation happens via the MCP tool, not the UI. | +| [`apps/desktop/src/lib/api/workspaceBindings.ts`](../../apps/desktop/src/lib/api/workspaceBindings.ts) | TS wrappers for the two new commands. | + +--- + +## Phasing + +### Phase 1 — `SessionOverrideRegistry` + composition wiring + +**Effort:** 1 evening + +- Add `crates/mcpmux-gateway/src/services/session_overrides.rs` mirroring `session_roots.rs` shape: `DashMap`-backed, `Arc` factory, `set_enabled`, `set_disabled`, `clear_enabled`, `clear_disabled`, `enabled_set`, `disabled_set`, `remove`, `list_all` for the UI. +- Plumb `Arc` through `ServiceContainer` into both `FeatureService` and `MCPNotifier`. +- Extend `FeatureService::get_tools_for_grants` (+ prompts + resources) signatures to accept `session_id: Option<&str>` and apply `(servers ∪ enabled) − disabled` filtering. +- Update `handler.rs` callsites to pass `session_id` from `RequestContext` headers. +- `MCPNotifier` session-reap pass also drops override entries. +- Unit tests in `session_overrides.rs`: set/get round-trip, enable/disable composition, GC on remove. + +**Outcome:** A test that directly mutates `SessionOverrideRegistry` for a fake session id observes the next `FeatureService::get_tools_for_grants` call return the composed set. No meta-tools exist yet; UI unchanged. CI green. + +### Phase 2 — `mcpmux_list_servers` read tool + +**Effort:** 1 evening + +- Add `ListServersTool` unit struct + `MetaTool` impl in `meta_tools/tools.rs`. +- Implementation: load `ServerFeature::list_for_space(caller_space_id)`, group by `server_id`, compute `tool_count = features.iter().filter(|f| f.feature_type == Tool).count()`, derive `status` per server by checking `binding`, `session_overrides.enabled`, `session_overrides.disabled` in order. +- JSON schema: empty `properties` (no args). +- Register in `build_default_registry` alongside the existing reads. +- Integration test: connect a fake session, call `mcpmux_list_servers`, assert response shape includes `status` enum values for both bound and unbound servers. + +**Outcome:** An LLM calling `mcpmux_list_servers` from any session receives a server roster like `[{id: "github", name: "GitHub", tool_count: 24, status: "enabled_via_binding"}, {id: "firebase", name: "Firebase", tool_count: 18, status: "inactive"}, ...]`. No state mutation yet. + +### Phase 3 — `mcpmux_enable_server` / `mcpmux_disable_server` (session scope) + +**Effort:** 1 day + +- Add `EnableServerTool` + `DisableServerTool` to `meta_tools/tools.rs`. +- Args: `{ server_id: string, scope?: "session" | "workspace" (default "session") }`. +- Session-scope flow: validate `server_id` exists in caller's resolved Space → look up `gateway.session_overrides_require_approval` setting → if `false`, mutate registry directly; if `true`, route through `with_approval` first. +- Enable adds to `enabled` and removes from `disabled` (the two sets are mutually exclusive per server-id, last-write-wins). +- Disable mirror: adds to `disabled`, removes from `enabled`. +- After mutation: fire per-peer `tools/list_changed` via `notify_peer_lists_changed(client_id)`. Emit `MetaToolInvoked` with `decision: "session_override"` when auto-allowed, `"allow_once"` when approval was required. +- Reject `scope: "workspace"` with `MetaToolError::InvalidArgument("workspace scope not yet implemented; see Phase 4")` until Phase 4 lands. +- Integration tests: enable → tool appears in next `tools/list`, disable → tool disappears, both with the per-peer notify firing. + +**Outcome:** From a fresh Cursor window (no binding, no overrides), an LLM calls `mcpmux_enable_server({"server_id": "github"})`. The GitHub tools appear in the next `tools/list`. The LLM uses them, then calls `mcpmux_disable_server({"server_id": "github"})` when done. Tools disappear. No DB writes; closing Cursor and reopening it = clean slate. + +### Phase 4 — Workspace-scope variants + +**Effort:** 1 day + +- Extend `EnableServerTool` / `DisableServerTool` to handle `scope: "workspace"`. +- Enable + workspace: requires the caller to have reported MCP roots (reuse `caller_space_id` + `session_roots.get` pattern from `BindCurrentWorkspaceTool`). If no binding exists for the first reported root, return `MetaToolError::InvalidArgument("no binding exists for this workspace; create one with mcpmux_create_feature_set + mcpmux_bind_current_workspace first")`. If a binding exists, look up its FS, add a `ServerAll`-typed `FeatureSet` for `server_id`, append its id to the binding's `feature_set_ids` list. +- Disable + workspace: remove the matching `ServerAll` FS from the binding's `feature_set_ids` if present; if the server's tools come from a custom FS (not a `ServerAll` row), reject with a message pointing the user at the Workspaces UI. +- Always require approval for workspace scope (no auto-allow setting). +- Integration test: enable + workspace persists across a session restart; disable + workspace removes from binding row. + +**Outcome:** An LLM in a bound workspace adds a `ServerAll` FS layer to its binding via `mcpmux_enable_server({"server_id": "firebase", "scope": "workspace"})`, approves in the desktop dialog, and the next time it opens that folder Firebase tools are there without re-enabling. + +### Phase 5 — UI surface for session overrides + +**Effort:** 1 day + +- New "Active session overrides" sub-panel inside `WorkspacesPage.tsx`'s live-session inspector: lists per-session `enabled`/`disabled` server ids alongside the reported roots. +- "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. +- New Tauri commands: `list_session_overrides(session_id) -> { enabled: string[], disabled: string[] }`, `clear_session_overrides(session_id)`. +- Settings checkbox under Gateway settings: "Require approval for session-scope overrides" — wires to `gateway.session_overrides_require_approval`. +- README + CHANGELOG entries describing the new meta-tools and the manifest-driven workflow. + +**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. + +--- + +## 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. +- [`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). From 5e04746a5fdd34304cb1e77993c4fc6e54303be0 Mon Sep 17 00:00:00 2001 From: crimsonsunset Date: Mon, 18 May 2026 22:49:36 -0600 Subject: [PATCH 03/48] docs: add macOS build-from-source and app swap guide Document how to replace /Applications/McpMux.app with a local build while preserving user data in Application Support and the keychain. Signed-off-by: crimsonsunset --- docs/build-from-source-macos.md | 182 ++++++++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 docs/build-from-source-macos.md diff --git a/docs/build-from-source-macos.md b/docs/build-from-source-macos.md new file mode 100644 index 00000000..0410fdb7 --- /dev/null +++ b/docs/build-from-source-macos.md @@ -0,0 +1,182 @@ +# Build from Source and Replace the Installed App (macOS) + +Replace the release McpMux in `/Applications` with a locally built copy from this repo. Useful when running a fork, a feature branch, or patches that haven't shipped yet. + +--- + +## What survives a swap + +Replacing the `.app` bundle does **not** touch your data. McpMux stores everything outside the app: + +| 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 app identifier (`com.mcpmux.desktop`) is unchanged between release and source builds, so the new binary reads the same data directory and keychain entries. + +**What you might need to redo:** OAuth re-auth in Cursor/Claude Desktop if DCR or token validation changed on your branch. Your McpMux config, spaces, and server installs stay put. + +--- + +## 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 +``` + +--- + +## 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 +``` + +### 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. + +--- + +## 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 + +### 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 | Fix | +| ------- | --- | +| "App is damaged" / won't open | Re-run `codesign --force --deep --sign - /Applications/McpMux.app` | +| Gateway port already in use | Old process still running — `pkill -f mcpmux` then relaunch | +| Cursor OAuth fails after swap | Re-trigger MCP OAuth in Cursor (DCR redirect URI validation may have changed) | +| Empty app / missing UI | You used binary-only swap but frontend changed — run Option A (full build) | +| Permission denied on `/Applications` | Use `sudo` for mv/cp/chown, or install to `~/Applications/` and skip sudo | + +--- + +## 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 From e72b64e96de49f2ba85637c8673e0942246aa59b Mon Sep 17 00:00:00 2001 From: crimsonsunset Date: Mon, 18 May 2026 23:04:01 -0600 Subject: [PATCH 04/48] feat(gateway): add SessionOverrideRegistry and list-path composition Phase 1 of dynamic MCP toggling: per-session enable/disable sets wired through FeatureService list paths, session GC, and composition tests. Also fixes a DashMap deadlock in SessionRootsRegistry::record_resolution. Signed-off-by: crimsonsunset --- .../src/consumers/mcp_notifier.rs | 7 +- crates/mcpmux-gateway/src/lib.rs | 2 +- crates/mcpmux-gateway/src/mcp/handler.rs | 38 ++-- .../src/pool/features/facade.rs | 89 +++++++- crates/mcpmux-gateway/src/pool/routing.rs | 9 +- .../src/pool/service_factory.rs | 4 +- crates/mcpmux-gateway/src/server/handlers.rs | 6 +- crates/mcpmux-gateway/src/server/mod.rs | 1 + .../src/server/service_container.rs | 8 +- .../src/services/meta_tools/diff.rs | 2 +- crates/mcpmux-gateway/src/services/mod.rs | 2 + .../src/services/session_overrides.rs | 201 ++++++++++++++++++ .../src/services/session_roots.rs | 16 +- .../planning/dynamic-mcp-toggle-meta-tools.md | 29 +-- tests/rust/src/services.rs | 4 +- .../rust/tests/integration/feature_routing.rs | 3 +- tests/rust/tests/integration/mcp_flows.rs | 35 +-- tests/rust/tests/integration/meta_tools.rs | 97 ++++++++- .../streamable_http/gateway_notifications.rs | 1 + 19 files changed, 478 insertions(+), 76 deletions(-) create mode 100644 crates/mcpmux-gateway/src/services/session_overrides.rs diff --git a/crates/mcpmux-gateway/src/consumers/mcp_notifier.rs b/crates/mcpmux-gateway/src/consumers/mcp_notifier.rs index 30723200..5820296c 100644 --- a/crates/mcpmux-gateway/src/consumers/mcp_notifier.rs +++ b/crates/mcpmux-gateway/src/consumers/mcp_notifier.rs @@ -26,7 +26,7 @@ use tracing::{debug, info, trace, warn}; use uuid::Uuid; use crate::pool::FeatureService; -use crate::services::FeatureSetResolverService; +use crate::services::{FeatureSetResolverService, SessionOverrideRegistry}; /// MCP Notifier — sends `list_changed` notifications to connected sessions. /// @@ -58,6 +58,8 @@ pub struct MCPNotifier { feature_set_resolver: Arc, /// Feature service for calculating content hashes feature_service: Arc, + /// Session override registry — reaped alongside session roots. + session_overrides: Arc, /// Throttle tracker: (space_id, notification_type) -> last_sent_timestamp /// Prevents sending duplicate notifications within THROTTLE_WINDOW throttle_tracker: Arc>>, @@ -110,11 +112,13 @@ impl MCPNotifier { pub fn new( feature_set_resolver: Arc, feature_service: Arc, + session_overrides: Arc, ) -> Self { Self { sessions: Arc::new(RwLock::new(HashMap::new())), feature_set_resolver, feature_service, + session_overrides, throttle_tracker: Arc::new(RwLock::new(HashMap::new())), state_hashes: Arc::new(RwLock::new(HashMap::new())), } @@ -354,6 +358,7 @@ impl MCPNotifier { // sessions that no longer exist. for sid in &dead { self.feature_set_resolver.session_roots().remove(sid); + self.session_overrides.remove(sid); } info!( reaped = dead.len(), diff --git a/crates/mcpmux-gateway/src/lib.rs b/crates/mcpmux-gateway/src/lib.rs index c974b0a3..92ada347 100644 --- a/crates/mcpmux-gateway/src/lib.rs +++ b/crates/mcpmux-gateway/src/lib.rs @@ -76,7 +76,7 @@ pub use pool::{ }; // Services module -pub use services::{EventEmitter, GrantService, PrefixCacheService}; +pub use services::{EventEmitter, GrantService, PrefixCacheService, SessionOverrideRegistry}; // MCP module (rmcp-based implementation) pub use mcp::McpMuxGatewayHandler; diff --git a/crates/mcpmux-gateway/src/mcp/handler.rs b/crates/mcpmux-gateway/src/mcp/handler.rs index 0279f6ec..3095c656 100644 --- a/crates/mcpmux-gateway/src/mcp/handler.rs +++ b/crates/mcpmux-gateway/src/mcp/handler.rs @@ -676,7 +676,7 @@ impl ServerHandler for McpMuxGatewayHandler { .services .pool_services .feature_service - .get_tools_for_grants(&space_id.to_string(), &feature_set_ids) + .get_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))?; @@ -861,7 +861,11 @@ impl ServerHandler for McpMuxGatewayHandler { .services .pool_services .feature_service - .get_prompts_for_grants(&space_id.to_string(), &feature_set_ids) + .get_prompts_for_grants( + &space_id.to_string(), + &feature_set_ids, + session_id_owned.as_deref(), + ) .await .map_err(|e| McpError::internal_error(format!("Failed to get prompts: {}", e), None))?; @@ -897,11 +901,9 @@ impl ServerHandler for McpMuxGatewayHandler { let oauth_ctx = self .get_oauth_context(&context.extensions) .map_err(|e| McpError::invalid_params(e.to_string(), None))?; + let session_id_owned = extract_session_id(&context.extensions); let (space_id, feature_set_ids) = self - .resolve_routing( - extract_session_id(&context.extensions).as_deref(), - &oauth_ctx.client_id, - ) + .resolve_routing(session_id_owned.as_deref(), &oauth_ctx.client_id) .await?; let (server_id, prompt_name) = self @@ -916,7 +918,11 @@ impl ServerHandler for McpMuxGatewayHandler { .services .pool_services .feature_service - .get_prompts_for_grants(&space_id.to_string(), &feature_set_ids) + .get_prompts_for_grants( + &space_id.to_string(), + &feature_set_ids, + session_id_owned.as_deref(), + ) .await .map_err(|e| { McpError::internal_error(format!("Failed to verify authorization: {}", e), None) @@ -972,7 +978,11 @@ impl ServerHandler for McpMuxGatewayHandler { .services .pool_services .feature_service - .get_resources_for_grants(&space_id.to_string(), &feature_set_ids) + .get_resources_for_grants( + &space_id.to_string(), + &feature_set_ids, + session_id_owned.as_deref(), + ) .await .map_err(|e| { McpError::internal_error(format!("Failed to get resources: {}", e), None) @@ -1006,11 +1016,9 @@ impl ServerHandler for McpMuxGatewayHandler { let oauth_ctx = self .get_oauth_context(&context.extensions) .map_err(|e| McpError::invalid_params(e.to_string(), None))?; + let session_id_owned = extract_session_id(&context.extensions); let (space_id, feature_set_ids) = self - .resolve_routing( - extract_session_id(&context.extensions).as_deref(), - &oauth_ctx.client_id, - ) + .resolve_routing(session_id_owned.as_deref(), &oauth_ctx.client_id) .await?; let server_id = self @@ -1030,7 +1038,11 @@ impl ServerHandler for McpMuxGatewayHandler { .services .pool_services .feature_service - .get_resources_for_grants(&space_id.to_string(), &feature_set_ids) + .get_resources_for_grants( + &space_id.to_string(), + &feature_set_ids, + session_id_owned.as_deref(), + ) .await .map_err(|e| { McpError::internal_error(format!("Failed to verify authorization: {}", e), None) diff --git a/crates/mcpmux-gateway/src/pool/features/facade.rs b/crates/mcpmux-gateway/src/pool/features/facade.rs index ad0914c3..c3a6d461 100644 --- a/crates/mcpmux-gateway/src/pool/features/facade.rs +++ b/crates/mcpmux-gateway/src/pool/features/facade.rs @@ -1,10 +1,11 @@ //! Feature Service Facade - Unified API delegating to specialized services use anyhow::Result; +use std::collections::HashSet; use std::sync::Arc; use crate::pool::instance::McpClient; -use crate::services::PrefixCacheService; +use crate::services::{PrefixCacheService, SessionOverrideRegistry}; use mcpmux_core::{FeatureSetRepository, FeatureType, ServerFeature, ServerFeatureRepository}; use super::{ @@ -16,6 +17,7 @@ pub struct FeatureService { discovery: Arc, resolution: Arc, routing: Arc, + session_overrides: Arc, } impl FeatureService { @@ -23,6 +25,7 @@ impl FeatureService { feature_repo: Arc, feature_set_repo: Arc, prefix_cache: Arc, + session_overrides: Arc, ) -> Self { let discovery = Arc::new(FeatureDiscoveryService::new(feature_repo.clone())); @@ -41,6 +44,7 @@ impl FeatureService { discovery, resolution, routing, + session_overrides, } } @@ -86,35 +90,98 @@ impl FeatureService { .await } - // Type-specific helpers + /// 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)) - .await + self.get_features_for_grants( + space_id, + feature_set_ids, + session_id, + Some(FeatureType::Tool), + ) + .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); + } + + let mut binding_servers: HashSet = binding_features + .iter() + .map(|f| f.server_id.clone()) + .collect(); + binding_servers.extend(enabled.iter().cloned()); + binding_servers.retain(|server_id| !disabled.contains(server_id)); + + if binding_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 && binding_servers.contains(&f.server_id)) + .collect()) } // Delegate to FeatureRoutingService (with type-specific helpers) diff --git a/crates/mcpmux-gateway/src/pool/routing.rs b/crates/mcpmux-gateway/src/pool/routing.rs index 28daed57..0c5b21fb 100644 --- a/crates/mcpmux-gateway/src/pool/routing.rs +++ b/crates/mcpmux-gateway/src/pool/routing.rs @@ -84,13 +84,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_tools_for_grants(&space_id_str, feature_set_ids, session_id) .await?; // Filter to just tools @@ -119,12 +120,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 +153,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 diff --git a/crates/mcpmux-gateway/src/pool/service_factory.rs b/crates/mcpmux-gateway/src/pool/service_factory.rs index 99da9ad0..19ff52c0 100644 --- a/crates/mcpmux-gateway/src/pool/service_factory.rs +++ b/crates/mcpmux-gateway/src/pool/service_factory.rs @@ -44,6 +44,7 @@ impl ServiceFactory { deps: &GatewayDependencies, event_tx: tokio::sync::broadcast::Sender, prefix_cache: Arc, + session_overrides: Arc, ) -> PoolServices { // TokenService - single source of truth for token management let token_service = Arc::new(TokenService::new( @@ -80,7 +81,8 @@ impl ServiceFactory { let feature_service = Arc::new(FeatureService::new( deps.feature_repo.clone(), deps.feature_set_repo.clone(), - prefix_cache.clone(), // Clone here since we use it again below + prefix_cache.clone(), + session_overrides, )); // ServerManager - event-driven orchestrator for server state diff --git a/crates/mcpmux-gateway/src/server/handlers.rs b/crates/mcpmux-gateway/src/server/handlers.rs index 09f73593..3aa2db25 100644 --- a/crates/mcpmux-gateway/src/server/handlers.rs +++ b/crates/mcpmux-gateway/src/server/handlers.rs @@ -1087,7 +1087,7 @@ pub async fn oauth_get_client_features( .services .pool_services .feature_service - .get_tools_for_grants(&space_id_str, &feature_set_ids) + .get_tools_for_grants(&space_id_str, &feature_set_ids, None) .await .unwrap_or_default(); @@ -1095,7 +1095,7 @@ pub async fn oauth_get_client_features( .services .pool_services .feature_service - .get_prompts_for_grants(&space_id_str, &feature_set_ids) + .get_prompts_for_grants(&space_id_str, &feature_set_ids, None) .await .unwrap_or_default(); @@ -1103,7 +1103,7 @@ pub async fn oauth_get_client_features( .services .pool_services .feature_service - .get_resources_for_grants(&space_id_str, &feature_set_ids) + .get_resources_for_grants(&space_id_str, &feature_set_ids, None) .await .unwrap_or_default(); diff --git a/crates/mcpmux-gateway/src/server/mod.rs b/crates/mcpmux-gateway/src/server/mod.rs index 4bf01dfc..8bd55dc9 100644 --- a/crates/mcpmux-gateway/src/server/mod.rs +++ b/crates/mcpmux-gateway/src/server/mod.rs @@ -231,6 +231,7 @@ impl GatewayServer { let notification_bridge = Arc::new(MCPNotifier::new( self.services.feature_set_resolver.clone(), self.services.pool_services.feature_service.clone(), + self.services.session_overrides.clone(), )); // Start listening to DomainEvents diff --git a/crates/mcpmux-gateway/src/server/service_container.rs b/crates/mcpmux-gateway/src/server/service_container.rs index d0b6d98c..c729ac15 100644 --- a/crates/mcpmux-gateway/src/server/service_container.rs +++ b/crates/mcpmux-gateway/src/server/service_container.rs @@ -9,7 +9,7 @@ use crate::pool::{PoolServices, ServerManager, ServiceFactory}; use crate::services::{ meta_tools, ApprovalBroker, AuthorizationService, ClientMetadataService, FeatureSetResolverService, GrantService, MetaToolRegistry, PrefixCacheService, - SessionRootsRegistry, SpaceResolverService, + SessionOverrideRegistry, SessionRootsRegistry, SpaceResolverService, }; use mcpmux_core::DomainEvent; @@ -40,6 +40,9 @@ pub struct ServiceContainer { /// Registry of per-session workspace roots (populated from MCP `roots/list`). pub session_roots: Arc, + /// Per-session server enable/disable overrides (in-memory, process-lifetime). + pub session_overrides: Arc, + /// Broker that asks the desktop UI for user approval on meta-tool writes. /// Shared with the Tauri layer so it can attach a publisher + respond. pub approval_broker: Arc, @@ -82,10 +85,12 @@ impl ServiceContainer { )); // Create pool services using factory (pass event_tx and prefix_cache) + let session_overrides = SessionOverrideRegistry::new(); let pool_services = ServiceFactory::create_pool_services( deps, domain_event_tx.clone(), prefix_cache_service.clone(), + session_overrides.clone(), ); // Extract server_manager before moving pool_services @@ -157,6 +162,7 @@ impl ServiceContainer { authorization_service, feature_set_resolver, session_roots, + session_overrides, approval_broker, meta_tool_registry, space_resolver_service, diff --git a/crates/mcpmux-gateway/src/services/meta_tools/diff.rs b/crates/mcpmux-gateway/src/services/meta_tools/diff.rs index 43044303..f3e9d90f 100644 --- a/crates/mcpmux-gateway/src/services/meta_tools/diff.rs +++ b/crates/mcpmux-gateway/src/services/meta_tools/diff.rs @@ -65,7 +65,7 @@ impl ToolDiff { let space_id_str = space_id.to_string(); let ids = [fs.to_string()]; let features = feature_service - .get_tools_for_grants(&space_id_str, &ids) + .get_tools_for_grants(&space_id_str, &ids, None) .await?; Ok(features .iter() diff --git a/crates/mcpmux-gateway/src/services/mod.rs b/crates/mcpmux-gateway/src/services/mod.rs index af1edb07..387b6739 100644 --- a/crates/mcpmux-gateway/src/services/mod.rs +++ b/crates/mcpmux-gateway/src/services/mod.rs @@ -13,6 +13,7 @@ mod grant_service; pub mod meta_tools; mod notification_emitter; mod prefix_cache; +mod session_overrides; mod session_roots; mod space_resolver; @@ -27,5 +28,6 @@ pub use meta_tools::{ }; 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; diff --git a/crates/mcpmux-gateway/src/services/session_overrides.rs b/crates/mcpmux-gateway/src/services/session_overrides.rs new file mode 100644 index 00000000..e91f3e13 --- /dev/null +++ b/crates/mcpmux-gateway/src/services/session_overrides.rs @@ -0,0 +1,201 @@ +//! Session-scoped enable/disable overrides for backend MCP servers. +//! +//! When a client session calls `mcpmux_enable_server` / `mcpmux_disable_server` +//! (Phase 3), the gateway mutates this registry. [`FeatureService`] consults it +//! at list materialization time to compose the effective server set: +//! `(binding_servers ∪ enabled) − disabled`. + +use std::collections::HashSet; +use std::sync::Arc; + +use dashmap::DashMap; + +/// One session's override state for UI inspection (Phase 5). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionOverrideEntry { + pub session_id: String, + pub enabled: Vec, + pub disabled: Vec, +} + +/// Thread-safe registry mapping `mcp-session-id` to per-session server +/// enable/disable sets. Process-lifetime only — reaped with the session. +#[derive(Debug, Default)] +pub struct SessionOverrideRegistry { + enabled: DashMap>, + disabled: DashMap>, +} + +impl SessionOverrideRegistry { + /// Create a new registry wrapped in `Arc`. + pub fn new() -> Arc { + Arc::new(Self { + enabled: DashMap::new(), + disabled: DashMap::new(), + }) + } + + /// Add `server_id` to the session's enabled set; remove from disabled. + pub fn enable(&self, session_id: impl Into, server_id: impl Into) { + let session_id = session_id.into(); + let server_id = server_id.into(); + if let Some(mut disabled) = self.disabled.get_mut(&session_id) { + disabled.remove(&server_id); + if disabled.is_empty() { + drop(disabled); + self.disabled.remove(&session_id); + } + } + self.enabled + .entry(session_id) + .or_default() + .insert(server_id); + } + + /// Add `server_id` to the session's disabled set; remove from enabled. + pub fn disable(&self, session_id: impl Into, server_id: impl Into) { + let session_id = session_id.into(); + let server_id = server_id.into(); + if let Some(mut enabled) = self.enabled.get_mut(&session_id) { + enabled.remove(&server_id); + if enabled.is_empty() { + drop(enabled); + self.enabled.remove(&session_id); + } + } + self.disabled + .entry(session_id) + .or_default() + .insert(server_id); + } + + /// Drop both override sets for a session. + pub fn clear(&self, session_id: &str) { + self.enabled.remove(session_id); + self.disabled.remove(session_id); + } + + /// Enabled server ids for a session (empty when none). + pub fn enabled_set(&self, session_id: &str) -> HashSet { + self.enabled + .get(session_id) + .map(|set| set.clone()) + .unwrap_or_default() + } + + /// Disabled server ids for a session (empty when none). + pub fn disabled_set(&self, session_id: &str) -> HashSet { + self.disabled + .get(session_id) + .map(|set| set.clone()) + .unwrap_or_default() + } + + /// Drop a session's overrides — call on client disconnect / reap. + pub fn remove(&self, session_id: &str) { + self.enabled.remove(session_id); + self.disabled.remove(session_id); + } + + /// Snapshot of every session with non-empty override state. + pub fn list_all(&self) -> Vec { + let mut session_ids: HashSet = HashSet::new(); + session_ids.extend(self.enabled.iter().map(|e| e.key().clone())); + session_ids.extend(self.disabled.iter().map(|e| e.key().clone())); + + let mut out: Vec = session_ids + .into_iter() + .filter_map(|session_id| { + let enabled: Vec = self + .enabled + .get(&session_id) + .map(|set| set.iter().cloned().collect()) + .unwrap_or_default(); + let disabled: Vec = self + .disabled + .get(&session_id) + .map(|set| set.iter().cloned().collect()) + .unwrap_or_default(); + if enabled.is_empty() && disabled.is_empty() { + return None; + } + Some(SessionOverrideEntry { + session_id, + enabled, + disabled, + }) + }) + .collect(); + out.sort_by(|a, b| a.session_id.cmp(&b.session_id)); + out + } + + /// Current number of sessions with enabled overrides. Test helper. + #[cfg(test)] + pub fn enabled_session_count(&self) -> usize { + self.enabled.len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_enable_round_trip() { + let reg = SessionOverrideRegistry::default(); + reg.enable("sess-1", "github"); + let enabled = reg.enabled_set("sess-1"); + assert_eq!(enabled.len(), 1); + assert!(enabled.contains("github")); + assert!(reg.disabled_set("sess-1").is_empty()); + } + + #[test] + fn test_disable_round_trip() { + let reg = SessionOverrideRegistry::default(); + reg.disable("sess-1", "firebase"); + let disabled = reg.disabled_set("sess-1"); + assert_eq!(disabled.len(), 1); + assert!(disabled.contains("firebase")); + assert!(reg.enabled_set("sess-1").is_empty()); + } + + #[test] + fn test_enable_clears_disable_and_vice_versa() { + let reg = SessionOverrideRegistry::default(); + reg.disable("sess-1", "github"); + reg.enable("sess-1", "github"); + assert!(reg.enabled_set("sess-1").contains("github")); + assert!(!reg.disabled_set("sess-1").contains("github")); + + reg.disable("sess-1", "github"); + assert!(!reg.enabled_set("sess-1").contains("github")); + assert!(reg.disabled_set("sess-1").contains("github")); + } + + #[test] + fn test_clear_and_remove() { + let reg = SessionOverrideRegistry::default(); + reg.enable("sess-1", "github"); + reg.disable("sess-1", "firebase"); + reg.clear("sess-1"); + assert!(reg.enabled_set("sess-1").is_empty()); + assert!(reg.disabled_set("sess-1").is_empty()); + + reg.enable("sess-2", "slack"); + reg.remove("sess-2"); + assert!(reg.enabled_set("sess-2").is_empty()); + } + + #[test] + fn test_list_all() { + let reg = SessionOverrideRegistry::default(); + reg.enable("b", "github"); + reg.disable("a", "firebase"); + let all = reg.list_all(); + assert_eq!(all.len(), 2); + assert_eq!(all[0].session_id, "a"); + assert_eq!(all[1].session_id, "b"); + } +} diff --git a/crates/mcpmux-gateway/src/services/session_roots.rs b/crates/mcpmux-gateway/src/services/session_roots.rs index d258c70d..ee8a4426 100644 --- a/crates/mcpmux-gateway/src/services/session_roots.rs +++ b/crates/mcpmux-gateway/src/services/session_roots.rs @@ -151,13 +151,17 @@ impl SessionRootsRegistry { /// `false` when it's the same as before. pub fn record_resolution(&self, session_id: &str, fs_id: Option<&str>) -> bool { let new_val: Option = fs_id.map(|s| s.to_string()); - match self.last_resolution.get(session_id) { - Some(prev) if *prev == new_val => false, - _ => { - self.last_resolution.insert(session_id.to_string(), new_val); - true - } + let unchanged = self + .last_resolution + .get(session_id) + .map(|prev| *prev == new_val) + .unwrap_or(false); + if unchanged { + return false; } + self.last_resolution + .insert(session_id.to_string(), new_val); + true } /// Returns every reported root across every active session, de-duplicated diff --git a/docs/planning/dynamic-mcp-toggle-meta-tools.md b/docs/planning/dynamic-mcp-toggle-meta-tools.md index 3fca5d91..d7333c84 100644 --- a/docs/planning/dynamic-mcp-toggle-meta-tools.md +++ b/docs/planning/dynamic-mcp-toggle-meta-tools.md @@ -1,7 +1,7 @@ # Dynamic MCP Toggling via Meta Tools -**Last Updated:** May 18, 2026 -**Status:** Planning — decisions locked, ready for implementation +**Last Updated:** May 19, 2026 +**Status:** Phase 1 complete — SessionOverrideRegistry + list-path composition wired; Phases 2–5 pending **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 @@ -145,7 +145,7 @@ Each write fires `tools/list_changed` per-peer via the existing `MCPNotifier::no | 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/session_overrides.rs` | Integration tests: enable adds tool, disable removes tool, composition with binding, GC on session reap, per-peer `list_changed` fires | +| `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 @@ -169,18 +169,23 @@ Each write fires `tools/list_changed` per-peer via the existing `MCPNotifier::no ## Phasing -### Phase 1 — `SessionOverrideRegistry` + composition wiring +### Phase 1 — `SessionOverrideRegistry` + composition wiring ✅ -**Effort:** 1 evening +**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) -- Add `crates/mcpmux-gateway/src/services/session_overrides.rs` mirroring `session_roots.rs` shape: `DashMap`-backed, `Arc` factory, `set_enabled`, `set_disabled`, `clear_enabled`, `clear_disabled`, `enabled_set`, `disabled_set`, `remove`, `list_all` for the UI. -- Plumb `Arc` through `ServiceContainer` into both `FeatureService` and `MCPNotifier`. -- Extend `FeatureService::get_tools_for_grants` (+ prompts + resources) signatures to accept `session_id: Option<&str>` and apply `(servers ∪ enabled) − disabled` filtering. -- Update `handler.rs` callsites to pass `session_id` from `RequestContext` headers. -- `MCPNotifier` session-reap pass also drops override entries. -- Unit tests in `session_overrides.rs`: set/get round-trip, enable/disable composition, GC on remove. +**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). -**Outcome:** A test that directly mutates `SessionOverrideRegistry` for a fake session id observes the next `FeatureService::get_tools_for_grants` call return the composed set. No meta-tools exist yet; UI unchanged. CI green. +**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 diff --git a/tests/rust/src/services.rs b/tests/rust/src/services.rs index 06501cce..e7500990 100644 --- a/tests/rust/src/services.rs +++ b/tests/rust/src/services.rs @@ -8,7 +8,7 @@ use mcpmux_core::DomainEvent; use tokio::sync::broadcast; use mcpmux_gateway::pool::{FeatureService, ServerManager}; -use mcpmux_gateway::services::PrefixCacheService; +use mcpmux_gateway::services::{PrefixCacheService, SessionOverrideRegistry}; use crate::mocks::{ MockCredentialRepository, MockFeatureSetRepository, MockOutboundOAuthRepository, @@ -59,6 +59,7 @@ impl ServerManagerTestHarness { feature_repo.clone(), feature_set_repo.clone(), prefix_cache.clone(), + SessionOverrideRegistry::new(), )); // Create ConnectionService mock @@ -155,6 +156,7 @@ pub fn test_feature_service() -> ( feature_repo.clone(), feature_set_repo.clone(), prefix_cache, + SessionOverrideRegistry::new(), )); (service, feature_repo, feature_set_repo) diff --git a/tests/rust/tests/integration/feature_routing.rs b/tests/rust/tests/integration/feature_routing.rs index c749eb22..a29593c7 100644 --- a/tests/rust/tests/integration/feature_routing.rs +++ b/tests/rust/tests/integration/feature_routing.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use uuid::Uuid; use mcpmux_core::{FeatureSetRepository, ServerFeature, ServerFeatureRepository}; -use mcpmux_gateway::{FeatureService, PrefixCacheService}; +use mcpmux_gateway::{FeatureService, PrefixCacheService, SessionOverrideRegistry}; use tests::mocks::{MockFeatureSetRepository, MockServerFeatureRepository}; // Helper to create test features @@ -38,6 +38,7 @@ fn create_feature_service( feature_repo as Arc, feature_set_repo as Arc, prefix_cache, + SessionOverrideRegistry::new(), ) } diff --git a/tests/rust/tests/integration/mcp_flows.rs b/tests/rust/tests/integration/mcp_flows.rs index 77c1cc09..04f23d40 100644 --- a/tests/rust/tests/integration/mcp_flows.rs +++ b/tests/rust/tests/integration/mcp_flows.rs @@ -17,7 +17,7 @@ use mcpmux_core::{ FeatureSet, FeatureSetMember, FeatureSetRepository, FeatureType, MemberMode, MemberType, ServerFeature, ServerFeatureRepository, }; -use mcpmux_gateway::{FeatureService, PrefixCacheService}; +use mcpmux_gateway::{FeatureService, PrefixCacheService, SessionOverrideRegistry}; use tests::mocks::{MockFeatureSetRepository, MockServerFeatureRepository}; // Helper functions @@ -56,6 +56,7 @@ impl TestContext { Arc::clone(&feature_repo) as Arc, Arc::clone(&feature_set_repo) as Arc, Arc::clone(&prefix_cache), + SessionOverrideRegistry::new(), ); Self { @@ -156,7 +157,7 @@ async fn test_list_tools_with_all_grant() { // Simulate tools/list with grant let tools = ctx .service - .get_tools_for_grants(&ctx.space_id, &[all_fs_id]) + .get_tools_for_grants(&ctx.space_id, &[all_fs_id], None) .await .unwrap(); @@ -192,7 +193,7 @@ async fn test_list_tools_with_restricted_grant() { let tools = ctx .service - .get_tools_for_grants(&ctx.space_id, &[custom_fs_id]) + .get_tools_for_grants(&ctx.space_id, &[custom_fs_id], None) .await .unwrap(); @@ -235,7 +236,7 @@ async fn test_call_tool_unauthorized() { let tools = ctx .service - .get_tools_for_grants(&ctx.space_id, &[empty_fs_id]) + .get_tools_for_grants(&ctx.space_id, &[empty_fs_id], None) .await .unwrap(); @@ -261,7 +262,7 @@ async fn test_list_resources_with_grant() { let resources = ctx .service - .get_resources_for_grants(&ctx.space_id, &[all_fs_id]) + .get_resources_for_grants(&ctx.space_id, &[all_fs_id], None) .await .unwrap(); @@ -304,7 +305,7 @@ async fn test_resource_custom_uri_scheme() { let resources = ctx .service - .get_resources_for_grants(&ctx.space_id, &[all_fs_id]) + .get_resources_for_grants(&ctx.space_id, &[all_fs_id], None) .await .unwrap(); @@ -334,7 +335,7 @@ async fn test_list_prompts_with_grant() { let prompts = ctx .service - .get_prompts_for_grants(&ctx.space_id, &[all_fs_id]) + .get_prompts_for_grants(&ctx.space_id, &[all_fs_id], None) .await .unwrap(); @@ -396,17 +397,17 @@ async fn test_server_provides_multiple_feature_types() { // Filter by type let tools = ctx .service - .get_tools_for_grants(&ctx.space_id, &[all_fs_id.clone()]) + .get_tools_for_grants(&ctx.space_id, &[all_fs_id.clone()], None) .await .unwrap(); let prompts = ctx .service - .get_prompts_for_grants(&ctx.space_id, &[all_fs_id.clone()]) + .get_prompts_for_grants(&ctx.space_id, &[all_fs_id.clone()], None) .await .unwrap(); let resources = ctx .service - .get_resources_for_grants(&ctx.space_id, &[all_fs_id]) + .get_resources_for_grants(&ctx.space_id, &[all_fs_id], None) .await .unwrap(); @@ -441,7 +442,7 @@ async fn test_aggregate_tools_from_multiple_servers() { let tools = ctx .service - .get_tools_for_grants(&ctx.space_id, &[all_fs_id]) + .get_tools_for_grants(&ctx.space_id, &[all_fs_id], None) .await .unwrap(); @@ -471,7 +472,7 @@ async fn test_partial_server_grant() { let tools = ctx .service - .get_tools_for_grants(&ctx.space_id, &[server_all_a_id]) + .get_tools_for_grants(&ctx.space_id, &[server_all_a_id], None) .await .unwrap(); @@ -519,11 +520,12 @@ async fn test_features_dont_leak_between_spaces() { feature_repo as Arc, feature_set_repo as Arc, prefix_cache, + SessionOverrideRegistry::new(), ); // Query work space let work_tools = service - .get_tools_for_grants(&space_work, &[work_all_id]) + .get_tools_for_grants(&space_work, &[work_all_id], None) .await .unwrap(); @@ -562,6 +564,7 @@ async fn test_routing_is_space_scoped() { feature_repo as Arc, feature_set_repo as Arc, prefix_cache, + SessionOverrideRegistry::new(), ); // Resolve same qualified name in different spaces @@ -608,7 +611,7 @@ async fn test_unavailable_features_filtered_out() { let tools = ctx .service - .get_tools_for_grants(&ctx.space_id, &[all_fs_id]) + .get_tools_for_grants(&ctx.space_id, &[all_fs_id], None) .await .unwrap(); @@ -630,7 +633,7 @@ async fn test_server_disconnect_marks_features_unavailable() { // Initially available let tools_before = ctx .service - .get_tools_for_grants(&ctx.space_id, &[all_fs_id.clone()]) + .get_tools_for_grants(&ctx.space_id, &[all_fs_id.clone()], None) .await .unwrap(); assert_eq!(tools_before.len(), 2); @@ -644,7 +647,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_tools.rs b/tests/rust/tests/integration/meta_tools.rs index 4b81e6d5..0d56816c 100644 --- a/tests/rust/tests/integration/meta_tools.rs +++ b/tests/rust/tests/integration/meta_tools.rs @@ -11,14 +11,15 @@ use std::time::Duration; use futures::FutureExt; use mcpmux_core::{ - normalize_workspace_root, Client, DomainEvent, FeatureSet, FeatureSetRepository, - InboundMcpClientRepository, ServerFeature, ServerFeatureRepository, SpaceRepository, - WorkspaceBindingRepository, + normalize_workspace_root, Client, DomainEvent, FeatureSet, FeatureSetMember, + FeatureSetRepository, InboundMcpClientRepository, MemberMode, MemberType, ServerFeature, + ServerFeatureRepository, SpaceRepository, WorkspaceBindingRepository, }; use mcpmux_gateway::pool::FeatureService; use mcpmux_gateway::services::{ meta_tools, ApprovalBroker, ApprovalDecision, ApprovalPayload, ApprovalPublisher, - FeatureSetResolverService, MetaToolRegistry, PrefixCacheService, SessionRootsRegistry, + FeatureSetResolverService, MetaToolRegistry, PrefixCacheService, SessionOverrideRegistry, + SessionRootsRegistry, }; use mcpmux_storage::{ Database, InboundClientRepository, SqliteFeatureSetRepository, @@ -37,12 +38,15 @@ struct Fixture { feature_set_repo: Arc, binding_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, } impl Fixture { @@ -82,6 +86,7 @@ impl Fixture { feature2.description = Some("Deploy to Firebase".into()); server_feature_repo.upsert(&feature1).await.unwrap(); server_feature_repo.upsert(&feature2).await.unwrap(); + let github_tool_id = feature1.id; // The space's auto-seeded Default FS is the resolver's baseline // when no binding matches — no "set active FS" step needed. @@ -93,6 +98,7 @@ impl Fixture { client_repo.create(&client).await.unwrap(); let session_roots = SessionRootsRegistry::new(); + let session_overrides = SessionOverrideRegistry::new(); let session_id = "sess-meta".to_string(); let inbound_client_repo = Arc::new(InboundClientRepository::new(db.clone())); @@ -108,6 +114,7 @@ impl Fixture { server_feature_repo.clone(), feature_set_repo.clone(), prefix_cache, + session_overrides.clone(), )); let broker = Arc::new(ApprovalBroker::new().with_timeout(Duration::from_millis(500))); @@ -120,7 +127,7 @@ impl Fixture { binding_repo.clone(), server_feature_repo.clone(), resolver, - feature_service, + feature_service.clone(), session_roots.clone(), broker.clone(), tx, @@ -134,10 +141,13 @@ impl Fixture { feature_set_repo, binding_repo, session_roots, + session_overrides, + feature_service, space_id, client_id, session_id, fs_android_id, + github_tool_id, } } @@ -503,6 +513,7 @@ async fn bare_registry( server_feature_repo.clone(), feature_set_repo.clone(), prefix_cache, + SessionOverrideRegistry::new(), )); let (tx, rx) = broadcast::channel::(32); let registry = meta_tools::build_default_registry( @@ -615,6 +626,7 @@ async fn master_switch_toggles_registry_visibility() { server_feature_repo.clone(), feature_set_repo.clone(), prefix_cache, + SessionOverrideRegistry::new(), )); let (tx, _) = broadcast::channel::(16); let registry = meta_tools::build_default_registry( @@ -650,3 +662,78 @@ async fn master_switch_toggles_registry_visibility() { // Silence unused-import warnings from helper imports that only some tests exercise. #[allow(dead_code)] fn _unused(_: ApprovalPayload) {} + +// ============================================================================ +// Session override composition (Phase 1) +// ============================================================================ + +async fn github_only_fs(f: &Fixture) -> String { + let mut fs = FeatureSet::new_custom("GitHub only", f.space_id.to_string()); + fs.members.push(FeatureSetMember { + id: Uuid::new_v4().to_string(), + feature_set_id: fs.id.clone(), + member_type: MemberType::Feature, + member_id: f.github_tool_id.to_string(), + mode: MemberMode::Include, + }); + 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/streamable_http/gateway_notifications.rs b/tests/rust/tests/streamable_http/gateway_notifications.rs index 7ba1fc85..5c722b53 100644 --- a/tests/rust/tests/streamable_http/gateway_notifications.rs +++ b/tests/rust/tests/streamable_http/gateway_notifications.rs @@ -200,6 +200,7 @@ impl TestGateway { let notifier = Arc::new(MCPNotifier::new( services.feature_set_resolver.clone(), services.pool_services.feature_service.clone(), + services.session_overrides.clone(), )); // Start MCPNotifier listening for domain events From f29543aad281cff5cc8fbdd0feffdbb035824f0e Mon Sep 17 00:00:00 2001 From: crimsonsunset Date: Mon, 18 May 2026 23:06:26 -0600 Subject: [PATCH 05/48] feat(meta-tools): add mcpmux_list_servers read tool (Phase 2) Server-level manifest with binding/session status per installed server. Signed-off-by: crimsonsunset --- .../src/server/service_container.rs | 1 + .../src/services/meta_tools/mod.rs | 3 + .../src/services/meta_tools/registry.rs | 5 +- .../src/services/meta_tools/tools.rs | 126 ++++++++++++++++++ .../planning/dynamic-mcp-toggle-meta-tools.md | 19 +-- tests/rust/tests/integration/meta_tools.rs | 98 ++++++++++++++ 6 files changed, 242 insertions(+), 10 deletions(-) diff --git a/crates/mcpmux-gateway/src/server/service_container.rs b/crates/mcpmux-gateway/src/server/service_container.rs index c729ac15..1273fa79 100644 --- a/crates/mcpmux-gateway/src/server/service_container.rs +++ b/crates/mcpmux-gateway/src/server/service_container.rs @@ -134,6 +134,7 @@ impl ServiceContainer { feature_set_resolver.clone(), pool_services.feature_service.clone(), session_roots.clone(), + session_overrides.clone(), approval_broker.clone(), domain_event_tx.clone(), deps.settings_repo.clone(), diff --git a/crates/mcpmux-gateway/src/services/meta_tools/mod.rs b/crates/mcpmux-gateway/src/services/meta_tools/mod.rs index 49c151ec..4b4e75d7 100644 --- a/crates/mcpmux-gateway/src/services/meta_tools/mod.rs +++ b/crates/mcpmux-gateway/src/services/meta_tools/mod.rs @@ -55,6 +55,7 @@ pub fn build_default_registry( resolver: std::sync::Arc, feature_service: std::sync::Arc, 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>, @@ -68,6 +69,7 @@ pub fn build_default_registry( resolver, feature_service, session_roots, + session_overrides, approval_broker, domain_event_tx, settings_repo, @@ -77,6 +79,7 @@ pub fn build_default_registry( // Reads — no approval needed. registry.register(Box::new(tools::ListAllToolsTool)); registry.register(Box::new(tools::ListFeatureSetsTool)); + registry.register(Box::new(tools::ListServersTool)); // Both `describe_resolution` and `describe_workspace` were removed by // user request — the read surface is just the two list_* tools above, // which an LLM can stitch into the same picture without an extra hop. diff --git a/crates/mcpmux-gateway/src/services/meta_tools/registry.rs b/crates/mcpmux-gateway/src/services/meta_tools/registry.rs index 54307dd5..c1e3836d 100644 --- a/crates/mcpmux-gateway/src/services/meta_tools/registry.rs +++ b/crates/mcpmux-gateway/src/services/meta_tools/registry.rs @@ -19,7 +19,9 @@ use tokio::sync::broadcast; use super::approval::ApprovalBroker; use crate::pool::FeatureService; -use crate::services::{FeatureSetResolverService, SessionRootsRegistry}; +use crate::services::{ + FeatureSetResolverService, SessionOverrideRegistry, SessionRootsRegistry, +}; /// App-settings key that toggles the entire `mcpmux_*` namespace. /// Present + "false" → hidden; missing or anything else → enabled. @@ -39,6 +41,7 @@ pub struct MetaToolContext { pub resolver: Arc, pub feature_service: 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. diff --git a/crates/mcpmux-gateway/src/services/meta_tools/tools.rs b/crates/mcpmux-gateway/src/services/meta_tools/tools.rs index 95fc1112..71cac23f 100644 --- a/crates/mcpmux-gateway/src/services/meta_tools/tools.rs +++ b/crates/mcpmux-gateway/src/services/meta_tools/tools.rs @@ -9,12 +9,14 @@ use mcpmux_core::{ }; use rmcp::model::{CallToolResult, Content}; use serde_json::{json, Value}; +use std::collections::{HashMap, HashSet}; use tokio::sync::broadcast; use tracing::info; use uuid::Uuid; use super::approval::{ApprovalPayload, ApprovalScope}; use super::registry::{MetaTool, MetaToolCall, MetaToolError}; +use crate::services::ResolvedFeatureSet; /// Fire a `FeatureSetMembersChanged` event so MCPNotifier pushes a /// `tools/list_changed` notification to every connected client in the Space. @@ -64,6 +66,33 @@ async fn caller_space_id(call: &MetaToolCall<'_>) -> Result )) } +/// Full resolver output for the caller — space + binding FS ids + source. +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 // --------------------------------------------------------------------------- @@ -165,6 +194,103 @@ impl MetaTool for ListFeatureSetsTool { } } +// --------------------------------------------------------------------------- +// mcpmux_list_servers — read +// --------------------------------------------------------------------------- + +pub struct ListServersTool; + +#[async_trait] +impl MetaTool for ListServersTool { + fn name(&self) -> &'static str { + "mcpmux_list_servers" + } + + fn description(&self) -> &'static str { + "List every MCP server installed in the caller's resolved Space with \ + a coarse status per server: enabled_via_binding, enabled_via_session, \ + disabled_via_session, or inactive. 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 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, (display_name, tool_count))| { + let name = display_name.unwrap_or_else(|| id.clone()); + let status = derive_server_status( + &id, + &binding_servers, + &session_enabled, + &session_disabled, + ); + json!({ + "id": id, + "name": name, + "tool_count": tool_count, + "status": status, + }) + }) + .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 }))) + } +} + // --------------------------------------------------------------------------- // Writes — each goes through the ApprovalBroker before mutating state. // --------------------------------------------------------------------------- diff --git a/docs/planning/dynamic-mcp-toggle-meta-tools.md b/docs/planning/dynamic-mcp-toggle-meta-tools.md index d7333c84..f3c3db1c 100644 --- a/docs/planning/dynamic-mcp-toggle-meta-tools.md +++ b/docs/planning/dynamic-mcp-toggle-meta-tools.md @@ -1,7 +1,7 @@ # Dynamic MCP Toggling via Meta Tools **Last Updated:** May 19, 2026 -**Status:** Phase 1 complete — SessionOverrideRegistry + list-path composition wired; Phases 2–5 pending +**Status:** Phase 2 complete — `mcpmux_list_servers` read tool shipped; Phases 3–5 pending **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 @@ -187,17 +187,18 @@ Each write fires `tools/list_changed` per-peer via the existing `MCPNotifier::no - 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 +### Phase 2 — `mcpmux_list_servers` read tool ✅ -**Effort:** 1 evening +**Effort:** 1 evening +**Completed:** May 19, 2026 -- Add `ListServersTool` unit struct + `MetaTool` impl in `meta_tools/tools.rs`. -- Implementation: load `ServerFeature::list_for_space(caller_space_id)`, group by `server_id`, compute `tool_count = features.iter().filter(|f| f.feature_type == Tool).count()`, derive `status` per server by checking `binding`, `session_overrides.enabled`, `session_overrides.disabled` in order. -- JSON schema: empty `properties` (no args). -- Register in `build_default_registry` alongside the existing reads. -- Integration test: connect a fake session, call `mcpmux_list_servers`, assert response shape includes `status` enum values for both bound and unbound servers. +- [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:** An LLM calling `mcpmux_list_servers` from any session receives a server roster like `[{id: "github", name: "GitHub", tool_count: 24, status: "enabled_via_binding"}, {id: "firebase", name: "Firebase", tool_count: 18, status: "inactive"}, ...]`. No state mutation yet. +**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) diff --git a/tests/rust/tests/integration/meta_tools.rs b/tests/rust/tests/integration/meta_tools.rs index 0d56816c..d3df9c3f 100644 --- a/tests/rust/tests/integration/meta_tools.rs +++ b/tests/rust/tests/integration/meta_tools.rs @@ -129,6 +129,7 @@ impl Fixture { resolver, feature_service.clone(), session_roots.clone(), + session_overrides.clone(), broker.clone(), tx, None, @@ -260,6 +261,100 @@ async fn list_feature_sets_returns_space_contents() { assert_eq!(sets.len(), 3, "Default + 2 custom expected"); } +fn server_status(body: &Value, server_id: &str) -> String { + body.get("servers") + .unwrap() + .as_array() + .unwrap() + .iter() + .find(|s| s.get("id").and_then(|v| v.as_str()) == Some(server_id)) + .unwrap() + .get("status") + .unwrap() + .as_str() + .unwrap() + .to_string() +} + +async fn bind_github_only_to_session_root(f: &Fixture) -> String { + use mcpmux_core::WorkspaceBinding; + + let fs_id = github_only_fs(f).await; + let root = "/tmp/mcpmux-list-servers-test"; + f.session_roots.set_roots_capable(&f.session_id, true); + f.session_roots.set(&f.session_id, [root]); + let binding = WorkspaceBinding::new( + normalize_workspace_root(root), + f.space_id, + fs_id.clone(), + ); + f.binding_repo.create(&binding).await.unwrap(); + fs_id +} + +#[tokio::test(flavor = "multi_thread")] +async fn list_servers_marks_unbound_servers_inactive() { + let f = Fixture::new().await; + let result = f + .registry + .call( + "mcpmux_list_servers", + &f.client_id, + Some(&f.session_id), + json!({}), + ) + .await + .unwrap(); + assert!(!Fixture::is_error(&result)); + let body = Fixture::result_json(&result); + let servers = body.get("servers").unwrap().as_array().unwrap(); + assert_eq!(servers.len(), 2); + assert_eq!(server_status(&body, "github"), "inactive"); + assert_eq!(server_status(&body, "firebase"), "inactive"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn list_servers_shows_enabled_via_binding() { + let f = Fixture::new().await; + bind_github_only_to_session_root(&f).await; + + let result = f + .registry + .call( + "mcpmux_list_servers", + &f.client_id, + Some(&f.session_id), + json!({}), + ) + .await + .unwrap(); + let body = Fixture::result_json(&result); + assert_eq!(server_status(&body, "github"), "enabled_via_binding"); + assert_eq!(server_status(&body, "firebase"), "inactive"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn list_servers_shows_session_override_statuses() { + let f = Fixture::new().await; + bind_github_only_to_session_root(&f).await; + f.session_overrides.enable(&f.session_id, "firebase"); + f.session_overrides.disable(&f.session_id, "github"); + + let result = f + .registry + .call( + "mcpmux_list_servers", + &f.client_id, + Some(&f.session_id), + json!({}), + ) + .await + .unwrap(); + let body = Fixture::result_json(&result); + assert_eq!(server_status(&body, "github"), "disabled_via_session"); + assert_eq!(server_status(&body, "firebase"), "enabled_via_session"); +} + // `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 @@ -448,6 +543,7 @@ async fn registry_advertises_every_default_tool_with_annotations() { for expected in [ "mcpmux_list_all_tools", "mcpmux_list_feature_sets", + "mcpmux_list_servers", "mcpmux_create_feature_set", "mcpmux_bind_current_workspace", ] { @@ -525,6 +621,7 @@ async fn bare_registry( resolver, feature_service, SessionRootsRegistry::new(), + SessionOverrideRegistry::new(), Arc::new(ApprovalBroker::new()), tx.clone(), settings_repo, @@ -638,6 +735,7 @@ async fn master_switch_toggles_registry_visibility() { resolver, feature_service, SessionRootsRegistry::new(), + SessionOverrideRegistry::new(), Arc::new(ApprovalBroker::new()), tx, Some(settings_repo.clone()), From 0905d381e1982d0bd0c1bb97dc9514479fa33dab Mon Sep 17 00:00:00 2001 From: crimsonsunset Date: Mon, 18 May 2026 23:08:55 -0600 Subject: [PATCH 06/48] feat(meta-tools): add session-scope enable/disable server tools (Phase 3) mcpmux_enable_server and mcpmux_disable_server mutate SessionOverrideRegistry, audit as session_override when auto-allowed, and notify the calling session. Signed-off-by: crimsonsunset --- .../src/consumers/mcp_notifier.rs | 133 +++++++--- crates/mcpmux-gateway/src/mcp/handler.rs | 14 +- .../src/services/meta_tools/mod.rs | 12 +- .../src/services/meta_tools/registry.rs | 21 +- .../src/services/meta_tools/tools.rs | 242 +++++++++++++++++- .../planning/dynamic-mcp-toggle-meta-tools.md | 23 +- tests/rust/tests/integration/meta_tools.rs | 102 +++++++- 7 files changed, 486 insertions(+), 61 deletions(-) diff --git a/crates/mcpmux-gateway/src/consumers/mcp_notifier.rs b/crates/mcpmux-gateway/src/consumers/mcp_notifier.rs index 5820296c..0dfc7926 100644 --- a/crates/mcpmux-gateway/src/consumers/mcp_notifier.rs +++ b/crates/mcpmux-gateway/src/consumers/mcp_notifier.rs @@ -1064,45 +1064,100 @@ impl MCPNotifier { ); for (session_id, peer) in &live { - match peer.notify_tool_list_changed().await { - Ok(_) => debug!( - %session_id, - %client_id, - "[MCPNotifier] ✅ Sent tools/list_changed to session (per-client)" - ), - Err(e) => warn!( - %session_id, - %client_id, - error = ?e, - "[MCPNotifier] failed tools/list_changed" - ), - } - match peer.notify_prompt_list_changed().await { - Ok(_) => debug!( - %session_id, - %client_id, - "[MCPNotifier] ✅ Sent prompts/list_changed to session (per-client)" - ), - Err(e) => warn!( - %session_id, - %client_id, - error = ?e, - "[MCPNotifier] failed prompts/list_changed" - ), - } - match peer.notify_resource_list_changed().await { - Ok(_) => debug!( - %session_id, - %client_id, - "[MCPNotifier] ✅ Sent resources/list_changed to session (per-client)" - ), - Err(e) => warn!( - %session_id, - %client_id, - error = ?e, - "[MCPNotifier] failed resources/list_changed" - ), - } + self.send_all_lists_changed_to_peer(session_id, client_id, peer) + .await; + } + } + + /// Send all three list_changed notifications to one session, bypassing + /// space-level hash dedup. Used after session-scoped override mutations + /// so only the calling session refreshes its tool list. + pub async fn notify_session_lists_changed(&self, session_id: &str) { + if DISABLE_ALL_NOTIFICATIONS { + trace!( + %session_id, + "[MCPNotifier] 🚫 disabled — skipping session list_changed" + ); + return; + } + + let snapshot: Option<(String, Arc>)> = { + let sessions = self.sessions.read(); + sessions.get(session_id).and_then(|entry| { + if entry.has_active_stream { + Some((entry.client_id.clone(), entry.peer.clone())) + } else { + None + } + }) + }; + + let Some((client_id, peer)) = snapshot else { + debug!( + %session_id, + "[MCPNotifier] no active stream — skipping session list_changed" + ); + return; + }; + + if self.reap_dead_sessions(&[(session_id.to_string(), peer.clone())]).contains(&session_id.to_string()) { + return; + } + + info!( + %session_id, + %client_id, + "[MCPNotifier] 📤 session list_changed (override mutated)" + ); + self.send_all_lists_changed_to_peer(session_id, &client_id, &peer) + .await; + } + + /// Push tools/prompts/resources list_changed to a single peer. + async fn send_all_lists_changed_to_peer( + &self, + session_id: &str, + client_id: &str, + peer: &Peer, + ) { + match peer.notify_tool_list_changed().await { + Ok(_) => debug!( + %session_id, + %client_id, + "[MCPNotifier] ✅ Sent tools/list_changed to session" + ), + Err(e) => warn!( + %session_id, + %client_id, + error = ?e, + "[MCPNotifier] failed tools/list_changed" + ), + } + match peer.notify_prompt_list_changed().await { + Ok(_) => debug!( + %session_id, + %client_id, + "[MCPNotifier] ✅ Sent prompts/list_changed to session" + ), + Err(e) => warn!( + %session_id, + %client_id, + error = ?e, + "[MCPNotifier] failed prompts/list_changed" + ), + } + match peer.notify_resource_list_changed().await { + Ok(_) => debug!( + %session_id, + %client_id, + "[MCPNotifier] ✅ Sent resources/list_changed to session" + ), + Err(e) => warn!( + %session_id, + %client_id, + error = ?e, + "[MCPNotifier] failed resources/list_changed" + ), } } } diff --git a/crates/mcpmux-gateway/src/mcp/handler.rs b/crates/mcpmux-gateway/src/mcp/handler.rs index 3095c656..e94d4ca0 100644 --- a/crates/mcpmux-gateway/src/mcp/handler.rs +++ b/crates/mcpmux-gateway/src/mcp/handler.rs @@ -752,7 +752,19 @@ impl ServerHandler for McpMuxGatewayHandler { .call(¶ms.name, &oauth_ctx.client_id, session_id, args) .await { - Ok(result) => Ok(result), + Ok(result) => { + if matches!( + params.name.as_ref(), + "mcpmux_enable_server" | "mcpmux_disable_server" + ) { + 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()), }; } diff --git a/crates/mcpmux-gateway/src/services/meta_tools/mod.rs b/crates/mcpmux-gateway/src/services/meta_tools/mod.rs index 4b4e75d7..ebb23656 100644 --- a/crates/mcpmux-gateway/src/services/meta_tools/mod.rs +++ b/crates/mcpmux-gateway/src/services/meta_tools/mod.rs @@ -30,7 +30,10 @@ pub use approval::{ ApprovalScope, }; pub use diff::ToolDiff; -pub use registry::{MetaToolContext, MetaToolError, MetaToolRegistry, META_TOOLS_ENABLED_KEY}; +pub use registry::{ + MetaToolContext, MetaToolError, MetaToolRegistry, META_TOOLS_ENABLED_KEY, + SESSION_OVERRIDES_REQUIRE_APPROVAL_KEY, +}; /// Every built-in tool's name must start with this prefix so the handler /// can intercept it before routing to backend servers. @@ -80,10 +83,9 @@ pub fn build_default_registry( registry.register(Box::new(tools::ListAllToolsTool)); registry.register(Box::new(tools::ListFeatureSetsTool)); registry.register(Box::new(tools::ListServersTool)); - // Both `describe_resolution` and `describe_workspace` were removed by - // user request — the read surface is just the two list_* tools above, - // which an LLM can stitch into the same picture without an extra hop. - // Writes — gated by ApprovalBroker. + // Writes — gated by ApprovalBroker (or auto-allowed for session overrides). + registry.register(Box::new(tools::EnableServerTool)); + registry.register(Box::new(tools::DisableServerTool)); registry.register(Box::new(tools::CreateFeatureSetTool)); registry.register(Box::new(tools::BindCurrentWorkspaceTool)); std::sync::Arc::new(registry) diff --git a/crates/mcpmux-gateway/src/services/meta_tools/registry.rs b/crates/mcpmux-gateway/src/services/meta_tools/registry.rs index c1e3836d..4e1a82e0 100644 --- a/crates/mcpmux-gateway/src/services/meta_tools/registry.rs +++ b/crates/mcpmux-gateway/src/services/meta_tools/registry.rs @@ -5,7 +5,7 @@ //! `tools/list` response. use std::collections::HashMap; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use async_trait::async_trait; use mcpmux_core::{ @@ -27,6 +27,11 @@ use crate::services::{ /// 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 @@ -63,6 +68,9 @@ pub struct MetaToolCall<'a> { /// JSON arguments supplied in `CallToolRequestParams.arguments`. pub args: Value, pub ctx: &'a MetaToolContext, + /// Write tools set this before returning `Ok` to override the default + /// `"allow_once"` audit decision (e.g. `"session_override"`). + pub audit_decision: Arc>>, } /// Errors a meta tool can surface that map cleanly to `CallToolResult::error`. @@ -223,16 +231,25 @@ impl MetaToolRegistry { .get(name) .ok_or_else(|| MetaToolError::InvalidArgument(format!("unknown meta tool: {name}")))?; let is_write = tool.is_write(); + let audit_decision = Arc::new(Mutex::new(None)); let call = MetaToolCall { client_id, session_id, args: args.clone(), ctx: &self.ctx, + audit_decision: audit_decision.clone(), }; let result = tool.call(call).await; let (decision, summary) = match &result { - Ok(_) if is_write => ("allow_once", format!("{name} succeeded")), + Ok(_) if is_write => ( + audit_decision + .lock() + .ok() + .and_then(|g| *g) + .unwrap_or("allow_once"), + format!("{name} succeeded"), + ), Ok(_) => ("read", format!("{name} read")), Err(MetaToolError::ApprovalDenied) => ("deny", format!("{name} denied by user")), Err(MetaToolError::ApprovalTimedOut) => ("timeout", format!("{name} timed out")), diff --git a/crates/mcpmux-gateway/src/services/meta_tools/tools.rs b/crates/mcpmux-gateway/src/services/meta_tools/tools.rs index 71cac23f..d0508657 100644 --- a/crates/mcpmux-gateway/src/services/meta_tools/tools.rs +++ b/crates/mcpmux-gateway/src/services/meta_tools/tools.rs @@ -15,7 +15,9 @@ use tracing::info; use uuid::Uuid; use super::approval::{ApprovalPayload, ApprovalScope}; -use super::registry::{MetaTool, MetaToolCall, MetaToolError}; +use super::registry::{ + MetaTool, MetaToolCall, MetaToolError, SESSION_OVERRIDES_REQUIRE_APPROVAL_KEY, +}; use crate::services::ResolvedFeatureSet; /// Fire a `FeatureSetMembersChanged` event so MCPNotifier pushes a @@ -335,6 +337,244 @@ fn parse_uuid_arg(args: &Value, field: &str) -> Result { .map_err(|_| MetaToolError::InvalidArgument(format!("`{field}` is not a UUID: {s}"))) } +fn parse_string_arg(args: &Value, field: &str) -> Result { + args.get(field) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| MetaToolError::InvalidArgument(format!("missing `{field}`"))) +} + +/// Parse `scope` — only `"session"` is implemented in Phase 3. +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") => Err(MetaToolError::InvalidArgument( + "workspace scope not yet implemented; see Phase 4".into(), + )), + 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 for the current session only. The server's tools \ + appear on the next tools/list without changing workspace bindings. \ + Use mcpmux_list_servers first to see current status." + } + + 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 { + 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?; + 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.to_string(); + 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 for the current session only. Bound servers \ + are muted until re-enabled or the session ends. Use \ + mcpmux_list_servers to inspect status 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 { + 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?; + 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.to_string(); + 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) // --------------------------------------------------------------------------- diff --git a/docs/planning/dynamic-mcp-toggle-meta-tools.md b/docs/planning/dynamic-mcp-toggle-meta-tools.md index f3c3db1c..0da7a4da 100644 --- a/docs/planning/dynamic-mcp-toggle-meta-tools.md +++ b/docs/planning/dynamic-mcp-toggle-meta-tools.md @@ -1,7 +1,7 @@ # Dynamic MCP Toggling via Meta Tools **Last Updated:** May 19, 2026 -**Status:** Phase 2 complete — `mcpmux_list_servers` read tool shipped; Phases 3–5 pending +**Status:** Phase 3 complete — session-scope enable/disable meta tools; Phases 4–5 pending **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 @@ -200,20 +200,19 @@ Each write fires `tools/list_changed` per-peer via the existing `MCPNotifier::no **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) +### Phase 3 — `mcpmux_enable_server` / `mcpmux_disable_server` (session scope) ✅ -**Effort:** 1 day +**Effort:** 1 day +**Completed:** May 19, 2026 -- Add `EnableServerTool` + `DisableServerTool` to `meta_tools/tools.rs`. -- Args: `{ server_id: string, scope?: "session" | "workspace" (default "session") }`. -- Session-scope flow: validate `server_id` exists in caller's resolved Space → look up `gateway.session_overrides_require_approval` setting → if `false`, mutate registry directly; if `true`, route through `with_approval` first. -- Enable adds to `enabled` and removes from `disabled` (the two sets are mutually exclusive per server-id, last-write-wins). -- Disable mirror: adds to `disabled`, removes from `enabled`. -- After mutation: fire per-peer `tools/list_changed` via `notify_peer_lists_changed(client_id)`. Emit `MetaToolInvoked` with `decision: "session_override"` when auto-allowed, `"allow_once"` when approval was required. -- Reject `scope: "workspace"` with `MetaToolError::InvalidArgument("workspace scope not yet implemented; see Phase 4")` until Phase 4 lands. -- Integration tests: enable → tool appears in next `tools/list`, disable → tool disappears, both with the per-peer notify firing. +- [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:** From a fresh Cursor window (no binding, no overrides), an LLM calls `mcpmux_enable_server({"server_id": "github"})`. The GitHub tools appear in the next `tools/list`. The LLM uses them, then calls `mcpmux_disable_server({"server_id": "github"})` when done. Tools disappear. No DB writes; closing Cursor and reopening it = clean slate. +**Outcome:** LLM can toggle servers mid-session; tools appear/disappear on next `tools/list`. No DB writes. ### Phase 4 — Workspace-scope variants diff --git a/tests/rust/tests/integration/meta_tools.rs b/tests/rust/tests/integration/meta_tools.rs index d3df9c3f..fce2ae27 100644 --- a/tests/rust/tests/integration/meta_tools.rs +++ b/tests/rust/tests/integration/meta_tools.rs @@ -47,6 +47,7 @@ struct Fixture { session_id: String, fs_android_id: Uuid, github_tool_id: Uuid, + event_rx: broadcast::Receiver, } impl Fixture { @@ -118,7 +119,7 @@ impl Fixture { )); let broker = Arc::new(ApprovalBroker::new().with_timeout(Duration::from_millis(500))); - let (tx, _rx) = broadcast::channel::(32); + let (tx, event_rx) = broadcast::channel::(32); let registry = meta_tools::build_default_registry( client_repo.clone(), @@ -149,6 +150,7 @@ impl Fixture { session_id, fs_android_id, github_tool_id, + event_rx, } } @@ -355,6 +357,102 @@ async fn list_servers_shows_session_override_statuses() { assert_eq!(server_status(&body, "firebase"), "enabled_via_session"); } +#[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_rejects_workspace_scope() { + let f = Fixture::new().await; + let result = f + .call_tool_as_handler_would( + "mcpmux_enable_server", + json!({ "server_id": "github", "scope": "workspace" }), + ) + .await; + assert!(Fixture::is_error(&result)); + let body = Fixture::result_json(&result); + assert!( + body.get("message") + .and_then(|m| m.as_str()) + .unwrap_or("") + .contains("Phase 4") + ); +} + +#[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 @@ -544,6 +642,8 @@ async fn registry_advertises_every_default_tool_with_annotations() { "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", ] { From b29d5e8bbc3288952792c876cd5c141c1b5130a7 Mon Sep 17 00:00:00 2001 From: crimsonsunset Date: Mon, 18 May 2026 23:11:47 -0600 Subject: [PATCH 07/48] feat(meta-tools): add workspace-scope enable/disable server tools (Phase 4) Persist server-all FeatureSets on workspace bindings with approval; session list_changed only fires for session scope. Signed-off-by: crimsonsunset --- crates/mcpmux-gateway/src/mcp/handler.rs | 8 +- .../src/services/meta_tools/mod.rs | 1 + .../src/services/meta_tools/tools.rs | 43 ++- .../services/meta_tools/workspace_server.rs | 279 ++++++++++++++++++ .../planning/dynamic-mcp-toggle-meta-tools.md | 20 +- tests/rust/tests/integration/meta_tools.rs | 106 ++++++- 6 files changed, 423 insertions(+), 34 deletions(-) create mode 100644 crates/mcpmux-gateway/src/services/meta_tools/workspace_server.rs diff --git a/crates/mcpmux-gateway/src/mcp/handler.rs b/crates/mcpmux-gateway/src/mcp/handler.rs index e94d4ca0..b5242ef5 100644 --- a/crates/mcpmux-gateway/src/mcp/handler.rs +++ b/crates/mcpmux-gateway/src/mcp/handler.rs @@ -746,6 +746,11 @@ impl ServerHandler for McpMuxGatewayHandler { .arguments .map(|a| serde_json::to_value(a).unwrap_or(serde_json::Value::Null)) .unwrap_or(serde_json::Value::Null); + let scope = args + .get("scope") + .and_then(|v| v.as_str()) + .unwrap_or("session") + .to_string(); return match self .services .meta_tool_registry @@ -756,7 +761,8 @@ impl ServerHandler for McpMuxGatewayHandler { 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) diff --git a/crates/mcpmux-gateway/src/services/meta_tools/mod.rs b/crates/mcpmux-gateway/src/services/meta_tools/mod.rs index ebb23656..e4fc7908 100644 --- a/crates/mcpmux-gateway/src/services/meta_tools/mod.rs +++ b/crates/mcpmux-gateway/src/services/meta_tools/mod.rs @@ -24,6 +24,7 @@ pub mod approval; pub mod diff; mod registry; mod tools; +mod workspace_server; pub use approval::{ ApprovalBroker, ApprovalDecision, ApprovalPayload, ApprovalPublisher, ApprovalRequest, diff --git a/crates/mcpmux-gateway/src/services/meta_tools/tools.rs b/crates/mcpmux-gateway/src/services/meta_tools/tools.rs index d0508657..f16824d6 100644 --- a/crates/mcpmux-gateway/src/services/meta_tools/tools.rs +++ b/crates/mcpmux-gateway/src/services/meta_tools/tools.rs @@ -39,7 +39,7 @@ fn emit_tools_list_changed(event_tx: &broadcast::Sender, space_id: // Helpers // --------------------------------------------------------------------------- -fn text_result(v: Value) -> CallToolResult { +pub(crate) fn text_result(v: Value) -> CallToolResult { CallToolResult::success(vec![Content::text(v.to_string())]) } @@ -301,7 +301,7 @@ impl MetaTool for ListServersTool { /// mutation. Returns the broker's decision so the caller can proceed only /// on success. `mutate` is the thing that runs post-approval and is /// expected to emit `tools/list_changed` when relevant. -async fn with_approval( +pub(crate) async fn with_approval( call: &MetaToolCall<'_>, tool_name: &'static str, summary: String, @@ -344,13 +344,11 @@ fn parse_string_arg(args: &Value, field: &str) -> Result .ok_or_else(|| MetaToolError::InvalidArgument(format!("missing `{field}`"))) } -/// Parse `scope` — only `"session"` is implemented in Phase 3. +/// 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") => Err(MetaToolError::InvalidArgument( - "workspace scope not yet implemented; see Phase 4".into(), - )), + Some("workspace") => Ok("workspace"), Some(other) => Err(MetaToolError::InvalidArgument(format!( "invalid scope '{other}'; expected 'session' or 'workspace'" ))), @@ -408,9 +406,9 @@ impl MetaTool for EnableServerTool { } fn description(&self) -> &'static str { - "Enable an MCP server for the current session only. The server's tools \ - appear on the next tools/list without changing workspace bindings. \ - Use mcpmux_list_servers first to see current status." + "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 { @@ -433,16 +431,22 @@ impl MetaTool for EnableServerTool { } async fn call(&self, call: MetaToolCall<'_>) -> Result { - parse_scope(&call.args)?; + 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.to_string(); + let session_id_owned = session_id.clone(); let summary = format!("Enable server '{server_id}' for this session"); return with_approval( &call, @@ -496,9 +500,10 @@ impl MetaTool for DisableServerTool { } fn description(&self) -> &'static str { - "Disable an MCP server for the current session only. Bound servers \ - are muted until re-enabled or the session ends. Use \ - mcpmux_list_servers to inspect status first." + "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 { @@ -521,16 +526,22 @@ impl MetaTool for DisableServerTool { } async fn call(&self, call: MetaToolCall<'_>) -> Result { - parse_scope(&call.args)?; + 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.to_string(); + let session_id_owned = session_id.clone(); let summary = format!("Disable server '{server_id}' for this session"); return with_approval( &call, 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..9496d6c2 --- /dev/null +++ b/crates/mcpmux-gateway/src/services/meta_tools/workspace_server.rs @@ -0,0 +1,279 @@ +//! 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)) +} + +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/docs/planning/dynamic-mcp-toggle-meta-tools.md b/docs/planning/dynamic-mcp-toggle-meta-tools.md index 0da7a4da..adcdd7ab 100644 --- a/docs/planning/dynamic-mcp-toggle-meta-tools.md +++ b/docs/planning/dynamic-mcp-toggle-meta-tools.md @@ -1,7 +1,7 @@ # Dynamic MCP Toggling via Meta Tools **Last Updated:** May 19, 2026 -**Status:** Phase 3 complete — session-scope enable/disable meta tools; Phases 4–5 pending +**Status:** Phase 4 complete — workspace-scope enable/disable on bindings; Phase 5 pending **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 @@ -214,17 +214,19 @@ Each write fires `tools/list_changed` per-peer via the existing `MCPNotifier::no **Outcome:** LLM can toggle servers mid-session; tools appear/disappear on next `tools/list`. No DB writes. -### Phase 4 — Workspace-scope variants +### Phase 4 — Workspace-scope variants ✅ -**Effort:** 1 day +**Effort:** 1 day +**Completed:** May 19, 2026 -- Extend `EnableServerTool` / `DisableServerTool` to handle `scope: "workspace"`. -- Enable + workspace: requires the caller to have reported MCP roots (reuse `caller_space_id` + `session_roots.get` pattern from `BindCurrentWorkspaceTool`). If no binding exists for the first reported root, return `MetaToolError::InvalidArgument("no binding exists for this workspace; create one with mcpmux_create_feature_set + mcpmux_bind_current_workspace first")`. If a binding exists, look up its FS, add a `ServerAll`-typed `FeatureSet` for `server_id`, append its id to the binding's `feature_set_ids` list. -- Disable + workspace: remove the matching `ServerAll` FS from the binding's `feature_set_ids` if present; if the server's tools come from a custom FS (not a `ServerAll` row), reject with a message pointing the user at the Workspaces UI. -- Always require approval for workspace scope (no auto-allow setting). -- Integration test: enable + workspace persists across a session restart; disable + workspace removes from binding row. +- [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:** An LLM in a bound workspace adds a `ServerAll` FS layer to its binding via `mcpmux_enable_server({"server_id": "firebase", "scope": "workspace"})`, approves in the desktop dialog, and the next time it opens that folder Firebase tools are there without re-enabling. +**Outcome:** Workspace binding gains/loses persistent server-all FeatureSet layers via meta tools. ### Phase 5 — UI surface for session overrides diff --git a/tests/rust/tests/integration/meta_tools.rs b/tests/rust/tests/integration/meta_tools.rs index fce2ae27..39215ab4 100644 --- a/tests/rust/tests/integration/meta_tools.rs +++ b/tests/rust/tests/integration/meta_tools.rs @@ -405,8 +405,105 @@ async fn disable_server_removes_tools_from_list() { } #[tokio::test(flavor = "multi_thread")] -async fn enable_server_rejects_workspace_scope() { +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", @@ -414,13 +511,6 @@ async fn enable_server_rejects_workspace_scope() { ) .await; assert!(Fixture::is_error(&result)); - let body = Fixture::result_json(&result); - assert!( - body.get("message") - .and_then(|m| m.as_str()) - .unwrap_or("") - .contains("Phase 4") - ); } #[tokio::test(flavor = "multi_thread")] From b590b0b5ce5c4087ddb9e8e226caa26d94a8cba2 Mon Sep 17 00:00:00 2001 From: crimsonsunset Date: Tue, 19 May 2026 09:36:46 -0600 Subject: [PATCH 08/48] feat(meta-tools): session override UI and settings toggle (Phase 5) Expose session overrides in the Workspaces inspector with clear controls, wire list/clear Tauri commands, and add a settings gate for session-scope enable/disable approval. Signed-off-by: crimsonsunset --- .../desktop/src-tauri/src/commands/gateway.rs | 8 + apps/desktop/src-tauri/src/commands/mod.rs | 2 + .../src/commands/session_overrides.rs | 107 ++++++++++ .../src-tauri/src/commands/settings.rs | 38 ++++ apps/desktop/src-tauri/src/lib.rs | 4 + .../src/features/settings/SettingsPage.tsx | 54 +++++ .../features/workspaces/WorkspacesPage.tsx | 191 ++++++++++++++++++ apps/desktop/src/lib/api/sessionOverrides.ts | 60 ++++++ crates/mcpmux-gateway/src/server/mod.rs | 36 +++- .../src/services/session_roots.rs | 11 + .../planning/dynamic-mcp-toggle-meta-tools.md | 12 +- 11 files changed, 506 insertions(+), 17 deletions(-) create mode 100644 apps/desktop/src-tauri/src/commands/session_overrides.rs create mode 100644 apps/desktop/src/lib/api/sessionOverrides.ts diff --git a/apps/desktop/src-tauri/src/commands/gateway.rs b/apps/desktop/src-tauri/src/commands/gateway.rs index 9c04632e..f23d88e8 100644 --- a/apps/desktop/src-tauri/src/commands/gateway.rs +++ b/apps/desktop/src-tauri/src/commands/gateway.rs @@ -79,6 +79,10 @@ pub struct GatewayAppState { /// Surfaced to the desktop Workspaces tab so users can see + act on /// every folder connected clients are currently operating in. pub session_roots: Option>, + /// Session-scoped server enable/disable overrides (meta-tool mutations). + pub session_overrides: Option>, + /// Per-session list_changed bridge — used when the UI clears overrides. + pub mcp_notifier: Option>, } /// Gracefully shuts down a running gateway and waits for the axum task @@ -888,6 +892,8 @@ pub async fn start_gateway( let server_manager = server.server_manager(); let grant_service = server.grant_service(); let session_roots = server.session_roots(); + let session_overrides = server.session_overrides(); + let mcp_notifier = server.notification_bridge(); // Subscribe to OAuth completions BEFORE spawn so we don't miss early // events emitted during initial auto-connect. @@ -935,6 +941,8 @@ pub async fn start_gateway( state.grant_service = Some(grant_service); state.approval_broker = Some(approval_broker); state.session_roots = Some(session_roots); + state.session_overrides = Some(session_overrides); + state.mcp_notifier = Some(mcp_notifier); info!( "[Gateway] Started — url={}, event_emitter={}, grant_service={}", url, diff --git a/apps/desktop/src-tauri/src/commands/mod.rs b/apps/desktop/src-tauri/src/commands/mod.rs index bbfd57d9..692c5bd3 100644 --- a/apps/desktop/src-tauri/src/commands/mod.rs +++ b/apps/desktop/src-tauri/src/commands/mod.rs @@ -17,6 +17,7 @@ pub mod server; 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; @@ -35,6 +36,7 @@ pub use server::*; 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/session_overrides.rs b/apps/desktop/src-tauri/src/commands/session_overrides.rs new file mode 100644 index 00000000..f6c06cd4 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/session_overrides.rs @@ -0,0 +1,107 @@ +//! Tauri commands for inspecting and clearing session-scoped server overrides. + +use std::sync::Arc; + +use mcpmux_gateway::services::SessionOverrideEntry; +use serde::Serialize; +use tauri::{AppHandle, Emitter, State}; +use tokio::sync::RwLock; +use tracing::info; + +use super::gateway::GatewayAppState; + +/// Per-session override state surfaced to the Workspaces inspector. +#[derive(Debug, Clone, Serialize)] +pub struct SessionOverrideDto { + pub session_id: String, + pub enabled: Vec, + pub disabled: Vec, + pub roots: Vec, +} + +impl SessionOverrideDto { + fn from_entry(entry: SessionOverrideEntry, roots: Vec) -> Self { + Self { + session_id: entry.session_id, + enabled: entry.enabled, + disabled: entry.disabled, + roots, + } + } +} + +fn build_dtos(gateway: &GatewayAppState) -> Vec { + let Some(ref overrides) = gateway.session_overrides else { + return vec![]; + }; + let roots_by_session: std::collections::HashMap> = gateway + .session_roots + .as_ref() + .map(|reg| { + reg.list_all_sessions() + .into_iter() + .collect() + }) + .unwrap_or_default(); + + overrides + .list_all() + .into_iter() + .map(|entry| { + let roots = roots_by_session + .get(&entry.session_id) + .cloned() + .unwrap_or_default(); + SessionOverrideDto::from_entry(entry, roots) + }) + .collect() +} + +/// List override state for one session, or every session when `session_id` +/// is omitted. Returns an empty list when the gateway is not running. +#[tauri::command] +pub async fn list_session_overrides( + session_id: Option, + gateway_state: State<'_, Arc>>, +) -> Result, String> { + let guard = gateway_state.read().await; + let mut dtos = build_dtos(&guard); + if let Some(sid) = session_id { + dtos.retain(|d| d.session_id == sid); + } + Ok(dtos) +} + +/// Drop all enable/disable overrides for a session and push list_changed so +/// the client's tool list reverts to binding-only routing. +#[tauri::command] +pub async fn clear_session_overrides( + session_id: String, + gateway_state: State<'_, Arc>>, + app_handle: AppHandle, +) -> Result<(), String> { + let notifier = { + let guard = gateway_state.read().await; + let overrides = guard + .session_overrides + .as_ref() + .ok_or("Gateway is not running")?; + overrides.clear(&session_id); + guard.mcp_notifier.clone() + }; + + if let Some(notifier) = notifier { + notifier.notify_session_lists_changed(&session_id).await; + } + + info!("[session_overrides] cleared overrides for session {}", session_id); + + if let Err(e) = app_handle.emit( + "session-overrides-changed", + serde_json::json!({ "session_id": session_id }), + ) { + tracing::warn!("[session_overrides] failed to emit session-overrides-changed: {e}"); + } + + Ok(()) +} diff --git a/apps/desktop/src-tauri/src/commands/settings.rs b/apps/desktop/src-tauri/src/commands/settings.rs index 10299519..d0c95682 100644 --- a/apps/desktop/src-tauri/src/commands/settings.rs +++ b/apps/desktop/src-tauri/src/commands/settings.rs @@ -165,6 +165,44 @@ pub async fn set_meta_tools_enabled( Ok(()) } +/// Whether session-scope `mcpmux_enable_server` / `mcpmux_disable_server` +/// calls require approval. Default OFF (auto-allow). +#[tauri::command] +pub async fn get_session_overrides_require_approval( + app_state: State<'_, AppState>, +) -> Result { + match app_state + .settings_repository + .get("gateway.session_overrides_require_approval") + .await + { + Ok(Some(v)) => Ok(matches!(v.as_str(), "true" | "1")), + _ => Ok(false), + } +} + +/// Flip the session-override approval gate. Takes effect on the next +/// session-scope enable/disable meta-tool call. +#[tauri::command] +pub async fn set_session_overrides_require_approval( + require_approval: bool, + app_state: State<'_, AppState>, +) -> Result<(), String> { + app_state + .settings_repository + .set( + "gateway.session_overrides_require_approval", + if require_approval { "true" } else { "false" }, + ) + .await + .map_err(|e| format!("Failed to save session_overrides_require_approval: {}", e))?; + info!( + "[Settings] session_overrides_require_approval = {}", + require_approval + ); + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 08c7d44d..bca6e511 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -912,6 +912,10 @@ pub fn run() { commands::revoke_meta_tool_grant, commands::get_meta_tools_enabled, commands::set_meta_tools_enabled, + commands::get_session_overrides_require_approval, + commands::set_session_overrides_require_approval, + commands::list_session_overrides, + commands::clear_session_overrides, // Config export commands commands::preview_config_export, commands::export_config_to_file, diff --git a/apps/desktop/src/features/settings/SettingsPage.tsx b/apps/desktop/src/features/settings/SettingsPage.tsx index caccec21..7ea6a0f8 100644 --- a/apps/desktop/src/features/settings/SettingsPage.tsx +++ b/apps/desktop/src/features/settings/SettingsPage.tsx @@ -36,6 +36,10 @@ import { import { useAppStore, useTheme, useAnalyticsEnabled } from '@/stores'; import { UpdateChecker } from './UpdateChecker'; import { getMetaToolsEnabled, setMetaToolsEnabled } from '@/lib/api/metaTools'; +import { + getSessionOverridesRequireApproval, + setSessionOverridesRequireApproval, +} from '@/lib/api/sessionOverrides'; import { MetaToolAuditLog, MetaToolGrantsPanel } from '@/features/metaTools'; import { useGatewayControl } from '@/features/gateway/useGatewayControl'; import { CONTRIBUTE, openExternal } from '@/lib/contribute'; @@ -78,6 +82,10 @@ export function SettingsPage() { // Meta-tools master switch — gates the entire `mcpmux_*` namespace. const [metaToolsEnabled, setMetaToolsEnabledState] = useState(true); const [loadingMetaTools, setLoadingMetaTools] = useState(true); + const [sessionOverridesRequireApproval, setSessionOverridesRequireApprovalState] = + useState(false); + const [loadingSessionOverrideApproval, setLoadingSessionOverrideApproval] = + useState(true); // Gateway port — persisted user override, the default the app ships // with, and the port the currently-running gateway is bound to. When @@ -181,6 +189,12 @@ export function SettingsPage() { .then((v) => setMetaToolsEnabledState(v)) .catch((e) => console.error('Failed to load meta_tools_enabled', e)) .finally(() => setLoadingMetaTools(false)); + getSessionOverridesRequireApproval() + .then((v) => setSessionOverridesRequireApprovalState(v)) + .catch((e) => + console.error('Failed to load session_overrides_require_approval', e) + ) + .finally(() => setLoadingSessionOverrideApproval(false)); }, []); const handleToggleMetaTools = async (next: boolean) => { @@ -200,6 +214,23 @@ export function SettingsPage() { } }; + const handleToggleSessionOverrideApproval = async (next: boolean) => { + const previous = sessionOverridesRequireApproval; + setSessionOverridesRequireApprovalState(next); + try { + await setSessionOverridesRequireApproval(next); + success( + next ? 'Session overrides require approval' : 'Session overrides auto-allowed', + next + ? 'mcpmux_enable_server / mcpmux_disable_server (session scope) will prompt before applying.' + : 'Session-scope enable/disable applies immediately without a dialog.' + ); + } catch (e) { + setSessionOverridesRequireApprovalState(previous); + error('Failed to save setting', e instanceof Error ? e.message : String(e)); + } + }; + // Load logs path on mount useEffect(() => { const loadLogsPath = async () => { @@ -632,6 +663,29 @@ export function SettingsPage() { data-testid="meta-tools-enabled-switch" /> +
+
+ +
+ +

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

+
+
+ +
diff --git a/apps/desktop/src/features/workspaces/WorkspacesPage.tsx b/apps/desktop/src/features/workspaces/WorkspacesPage.tsx index 18b66666..cda9b4e0 100644 --- a/apps/desktop/src/features/workspaces/WorkspacesPage.tsx +++ b/apps/desktop/src/features/workspaces/WorkspacesPage.tsx @@ -19,6 +19,7 @@ import { Search, Server as ServerIcon, Trash2, + ToggleLeft, Wrench, X, } from 'lucide-react'; @@ -43,6 +44,12 @@ import { type WorkspaceBindingInput, type WorkspaceEffectiveFeatures, } from '@/lib/api/workspaceBindings'; +import { + clearSessionOverrides, + listSessionOverrides, + overridesForWorkspace, + type SessionOverride, +} from '@/lib/api/sessionOverrides'; import { isStarterFeatureSet, listFeatureSets, @@ -925,6 +932,19 @@ function InspectorPanel({ /> )} + + {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 && ( @@ -980,6 +1000,177 @@ function SaveStatusPill({ status }: { status: SaveStatus }) { ); } +// --------------------------------------------------------------------------- +// Session overrides — per-session enable/disable from meta tools +// --------------------------------------------------------------------------- + +/** + * Lists live sessions reporting this workspace root and any session-scoped + * server overrides applied via `mcpmux_enable_server` / `mcpmux_disable_server`. + */ +function SessionOverridesContent({ workspaceRoot }: { workspaceRoot: string }) { + const [entries, setEntries] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [clearingId, setClearingId] = useState(null); + + const reload = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const all = await listSessionOverrides(); + setEntries(overridesForWorkspace(all, workspaceRoot)); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + setEntries([]); + } finally { + setIsLoading(false); + } + }, [workspaceRoot]); + + useEffect(() => { + void reload(); + }, [reload]); + + useEffect(() => { + const unMeta = listen<{ tool_name?: string }>('meta-tool-invoked', (ev) => { + const name = ev.payload?.tool_name ?? ''; + if (name === 'mcpmux_enable_server' || name === 'mcpmux_disable_server') { + void reload(); + } + }); + const unOverrides = listen('session-overrides-changed', () => { + void reload(); + }); + return () => { + void unMeta.then((fn) => fn()); + void unOverrides.then((fn) => fn()); + }; + }, [reload]); + + const handleClear = async (sessionId: string) => { + setClearingId(sessionId); + try { + await clearSessionOverrides(sessionId); + await reload(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setClearingId(null); + } + }; + + if (isLoading) { + return ( +
+ + Loading session overrides… +
+ ); + } + + if (error) { + return ( +

+ {error} +

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

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

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

+ {shortId} +

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

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

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

+ Enabled (session) +

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

+ Disabled (session) +

+
+ {entry.disabled.map((id) => ( + + {id} + + ))} +
+
+ )} +
+ ); + })} +
+ ); +} + // --------------------------------------------------------------------------- // Effective features — what tools / prompts / resources this folder sees // right now, grouped by backend server so the user can see at a glance 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/crates/mcpmux-gateway/src/server/mod.rs b/crates/mcpmux-gateway/src/server/mod.rs index 8bd55dc9..64124e38 100644 --- a/crates/mcpmux-gateway/src/server/mod.rs +++ b/crates/mcpmux-gateway/src/server/mod.rs @@ -84,6 +84,9 @@ pub struct GatewayServer { config: GatewayConfig, state: Arc>, services: ServiceContainer, + /// Shared with the MCP handler and the desktop layer for session-scoped + /// list_changed pushes after override mutations. + notification_bridge: Arc, } impl GatewayServer { @@ -118,12 +121,19 @@ impl GatewayServer { // Initialize all services using DI container (pass domain event sender for non-blocking emission) let services = ServiceContainer::initialize(&dependencies, domain_event_tx, state.clone()); + let notification_bridge = Arc::new(MCPNotifier::new( + services.feature_set_resolver.clone(), + services.pool_services.feature_service.clone(), + services.session_overrides.clone(), + )); + info!("[Gateway] Services initialized successfully"); Self { config, state, services, + notification_bridge, } } @@ -185,6 +195,16 @@ impl GatewayServer { self.services.session_roots.clone() } + /// Session-scoped enable/disable overrides (meta-tool mutations). + pub fn session_overrides(&self) -> Arc { + self.services.session_overrides.clone() + } + + /// Notification bridge for per-session list_changed after override clears. + pub fn notification_bridge(&self) -> Arc { + self.notification_bridge.clone() + } + /// Get the OAuth manager pub fn oauth_manager(&self) -> Arc { self.services.pool_services.oauth_manager.clone() @@ -226,19 +246,11 @@ impl GatewayServer { base_url: self.config.base_url(), }; - // Create MCP notifier (session-keyed fanout, consults the same - // FeatureSet resolver the request handlers use). - let notification_bridge = Arc::new(MCPNotifier::new( - self.services.feature_set_resolver.clone(), - self.services.pool_services.feature_service.clone(), - self.services.session_overrides.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) @@ -256,8 +268,10 @@ impl GatewayServer { } // Create MCP handler - let handler = - McpMuxGatewayHandler::new(Arc::new(self.services.clone()), notification_bridge.clone()); + let handler = McpMuxGatewayHandler::new( + Arc::new(self.services.clone()), + self.notification_bridge.clone(), + ); // Create STATEFUL MCP service (full Streamable HTTP per spec 2025-11-25) // stateful_mode: true means: diff --git a/crates/mcpmux-gateway/src/services/session_roots.rs b/crates/mcpmux-gateway/src/services/session_roots.rs index ee8a4426..62c189a1 100644 --- a/crates/mcpmux-gateway/src/services/session_roots.rs +++ b/crates/mcpmux-gateway/src/services/session_roots.rs @@ -179,6 +179,17 @@ impl SessionRootsRegistry { out } + /// Snapshot of every session with reported roots (for UI inspection). + pub fn list_all_sessions(&self) -> Vec<(String, Vec)> { + let mut out: Vec<(String, Vec)> = self + .map + .iter() + .map(|entry| (entry.key().clone(), entry.value().clone())) + .collect(); + out.sort_by(|a, b| a.0.cmp(&b.0)); + out + } + /// Current number of tracked sessions. Test helper; cheap to call but /// not useful in hot paths. #[cfg(test)] diff --git a/docs/planning/dynamic-mcp-toggle-meta-tools.md b/docs/planning/dynamic-mcp-toggle-meta-tools.md index adcdd7ab..cdbe63a4 100644 --- a/docs/planning/dynamic-mcp-toggle-meta-tools.md +++ b/docs/planning/dynamic-mcp-toggle-meta-tools.md @@ -1,7 +1,7 @@ # Dynamic MCP Toggling via Meta Tools **Last Updated:** May 19, 2026 -**Status:** Phase 4 complete — workspace-scope enable/disable on bindings; Phase 5 pending +**Status:** Phase 5 complete — UI surface for session overrides + settings toggle **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 @@ -232,11 +232,11 @@ Each write fires `tools/list_changed` per-peer via the existing `MCPNotifier::no **Effort:** 1 day -- New "Active session overrides" sub-panel inside `WorkspacesPage.tsx`'s live-session inspector: lists per-session `enabled`/`disabled` server ids alongside the reported roots. -- "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. -- New Tauri commands: `list_session_overrides(session_id) -> { enabled: string[], disabled: string[] }`, `clear_session_overrides(session_id)`. -- Settings checkbox under Gateway settings: "Require approval for session-scope overrides" — wires to `gateway.session_overrides_require_approval`. -- README + CHANGELOG entries describing the new meta-tools and the manifest-driven workflow. +- [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`. +- [ ] README + CHANGELOG entries describing the new meta-tools and the manifest-driven workflow. **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. From 5730b25b518a08b9c6d7ad1e8b99d5d7c7caa4d5 Mon Sep 17 00:00:00 2001 From: crimsonsunset Date: Tue, 19 May 2026 09:47:33 -0600 Subject: [PATCH 09/48] docs(meta-tools): close Phase 5 with README and planning reconciliation Document the mcpmux_* manifest workflow in README, reconcile the planning doc for validation/PR gate, and fix stale DCR and notification dedup tests. Signed-off-by: crimsonsunset --- README.md | 22 +++++++++++ .../src/commands/session_overrides.rs | 11 +++--- .../src/consumers/mcp_notifier.rs | 5 ++- crates/mcpmux-gateway/src/mcp/handler.rs | 6 ++- .../src/services/meta_tools/registry.rs | 4 +- .../src/services/meta_tools/tools.rs | 16 +++----- .../services/meta_tools/workspace_server.rs | 12 ++---- .../src/services/session_roots.rs | 3 +- .../planning/dynamic-mcp-toggle-meta-tools.md | 39 ++++++++++++++++--- tests/rust/tests/integration/meta_tools.rs | 24 ++++++------ tests/rust/tests/oauth/dcr.rs | 15 +++++-- .../streamable_http/gateway_notifications.rs | 5 ++- 12 files changed, 107 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index a4444377..ba825bdf 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,28 @@ Not every AI client should have the same power. Create Feature Sets — permissi ![Feature Sets — granular per-server tool selection](docs/screenshots/featureset-detail.png) +### Self-Management Meta Tools (mcpmux_*) + +Routing everything through one gateway endpoint means connected AI clients can see every backend tool at session start — even when the project only needs a handful. Workspace bindings pin stable per-folder toolsets, but they do not cover one-off needs ("enable Firebase for the next 15 minutes") or discovery-driven workflows where the LLM picks servers as it goes. + +McpMux exposes a built-in `mcpmux_*` tool namespace so the LLM can introspect and reshape its own tool surface mid-conversation: + +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. The gateway pushes `tools/list_changed` so the tool list refreshes without reconnecting. +3. Use **`scope: "session"`** (default) for ephemeral overrides that die with the MCP session, or **`scope: "workspace"`** to persistently add/remove a server from the workspace binding (workspace writes always require approval). + +| Tool | Type | Purpose | +| ---- | ---- | ------- | +| `mcpmux_list_all_tools` | read | Full tool roster in the resolved Space | +| `mcpmux_list_feature_sets` | read | FeatureSets available in the resolved Space | +| `mcpmux_list_servers` | read | Server-level manifest with status | +| `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 | +| `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/session_overrides.rs b/apps/desktop/src-tauri/src/commands/session_overrides.rs index f6c06cd4..6d12c131 100644 --- a/apps/desktop/src-tauri/src/commands/session_overrides.rs +++ b/apps/desktop/src-tauri/src/commands/session_overrides.rs @@ -37,11 +37,7 @@ fn build_dtos(gateway: &GatewayAppState) -> Vec { let roots_by_session: std::collections::HashMap> = gateway .session_roots .as_ref() - .map(|reg| { - reg.list_all_sessions() - .into_iter() - .collect() - }) + .map(|reg| reg.list_all_sessions().into_iter().collect()) .unwrap_or_default(); overrides @@ -94,7 +90,10 @@ pub async fn clear_session_overrides( notifier.notify_session_lists_changed(&session_id).await; } - info!("[session_overrides] cleared overrides for session {}", session_id); + info!( + "[session_overrides] cleared overrides for session {}", + session_id + ); if let Err(e) = app_handle.emit( "session-overrides-changed", diff --git a/crates/mcpmux-gateway/src/consumers/mcp_notifier.rs b/crates/mcpmux-gateway/src/consumers/mcp_notifier.rs index 0dfc7926..ce865e87 100644 --- a/crates/mcpmux-gateway/src/consumers/mcp_notifier.rs +++ b/crates/mcpmux-gateway/src/consumers/mcp_notifier.rs @@ -1100,7 +1100,10 @@ impl MCPNotifier { return; }; - if self.reap_dead_sessions(&[(session_id.to_string(), peer.clone())]).contains(&session_id.to_string()) { + if self + .reap_dead_sessions(&[(session_id.to_string(), peer.clone())]) + .contains(&session_id.to_string()) + { return; } diff --git a/crates/mcpmux-gateway/src/mcp/handler.rs b/crates/mcpmux-gateway/src/mcp/handler.rs index b5242ef5..e3414842 100644 --- a/crates/mcpmux-gateway/src/mcp/handler.rs +++ b/crates/mcpmux-gateway/src/mcp/handler.rs @@ -676,7 +676,11 @@ impl ServerHandler for McpMuxGatewayHandler { .services .pool_services .feature_service - .get_tools_for_grants(&space_id.to_string(), &feature_set_ids, session_id_owned.as_deref()) + .get_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))?; diff --git a/crates/mcpmux-gateway/src/services/meta_tools/registry.rs b/crates/mcpmux-gateway/src/services/meta_tools/registry.rs index 4e1a82e0..85515bb3 100644 --- a/crates/mcpmux-gateway/src/services/meta_tools/registry.rs +++ b/crates/mcpmux-gateway/src/services/meta_tools/registry.rs @@ -19,9 +19,7 @@ use tokio::sync::broadcast; use super::approval::ApprovalBroker; use crate::pool::FeatureService; -use crate::services::{ - FeatureSetResolverService, SessionOverrideRegistry, SessionRootsRegistry, -}; +use crate::services::{FeatureSetResolverService, SessionOverrideRegistry, SessionRootsRegistry}; /// App-settings key that toggles the entire `mcpmux_*` namespace. /// Present + "false" → hidden; missing or anything else → enabled. diff --git a/crates/mcpmux-gateway/src/services/meta_tools/tools.rs b/crates/mcpmux-gateway/src/services/meta_tools/tools.rs index f16824d6..4c626423 100644 --- a/crates/mcpmux-gateway/src/services/meta_tools/tools.rs +++ b/crates/mcpmux-gateway/src/services/meta_tools/tools.rs @@ -386,11 +386,9 @@ async fn validate_server_in_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()) - }) + call.session_id.map(|s| s.to_string()).ok_or_else(|| { + MetaToolError::InvalidArgument("session scope requires an MCP session id".into()) + }) } // --------------------------------------------------------------------------- @@ -472,9 +470,7 @@ impl MetaTool for EnableServerTool { .await; } - call.ctx - .session_overrides - .enable(&session_id, &server_id); + call.ctx.session_overrides.enable(&session_id, &server_id); if let Ok(mut decision) = call.audit_decision.lock() { *decision = Some("session_override"); } @@ -567,9 +563,7 @@ impl MetaTool for DisableServerTool { .await; } - call.ctx - .session_overrides - .disable(&session_id, &server_id); + call.ctx.session_overrides.disable(&session_id, &server_id); if let Ok(mut decision) = call.audit_decision.lock() { *decision = Some("session_override"); } diff --git a/crates/mcpmux-gateway/src/services/meta_tools/workspace_server.rs b/crates/mcpmux-gateway/src/services/meta_tools/workspace_server.rs index 9496d6c2..13434880 100644 --- a/crates/mcpmux-gateway/src/services/meta_tools/workspace_server.rs +++ b/crates/mcpmux-gateway/src/services/meta_tools/workspace_server.rs @@ -24,14 +24,10 @@ 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 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(), diff --git a/crates/mcpmux-gateway/src/services/session_roots.rs b/crates/mcpmux-gateway/src/services/session_roots.rs index 62c189a1..71a8cf97 100644 --- a/crates/mcpmux-gateway/src/services/session_roots.rs +++ b/crates/mcpmux-gateway/src/services/session_roots.rs @@ -159,8 +159,7 @@ impl SessionRootsRegistry { if unchanged { return false; } - self.last_resolution - .insert(session_id.to_string(), new_val); + self.last_resolution.insert(session_id.to_string(), new_val); true } diff --git a/docs/planning/dynamic-mcp-toggle-meta-tools.md b/docs/planning/dynamic-mcp-toggle-meta-tools.md index cdbe63a4..5556e658 100644 --- a/docs/planning/dynamic-mcp-toggle-meta-tools.md +++ b/docs/planning/dynamic-mcp-toggle-meta-tools.md @@ -1,7 +1,7 @@ # Dynamic MCP Toggling via Meta Tools **Last Updated:** May 19, 2026 -**Status:** Phase 5 complete — UI surface for session overrides + settings toggle +**Status:** Feature complete — pending validation + PR **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 @@ -162,8 +162,12 @@ Each write fires `tools/list_changed` per-peer via the existing `MCPNotifier::no | [`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/workspace_binding.rs`](../../apps/desktop/src-tauri/src/commands/workspace_binding.rs) | New Tauri commands: `list_session_overrides(session_id)`, `clear_session_overrides(session_id)`. Read-only + clear; mutation happens via the MCP tool, not the UI. | -| [`apps/desktop/src/lib/api/workspaceBindings.ts`](../../apps/desktop/src/lib/api/workspaceBindings.ts) | TS wrappers for the two new commands. | +| [`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. | --- @@ -228,7 +232,7 @@ Each write fires `tools/list_changed` per-peer via the existing `MCPNotifier::no **Outcome:** Workspace binding gains/loses persistent server-all FeatureSet layers via meta tools. -### Phase 5 — UI surface for session overrides +### Phase 5 — UI surface for session overrides ✅ **Effort:** 1 day @@ -236,12 +240,31 @@ Each write fires `tools/list_changed` per-peer via the existing `MCPNotifier::no - [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`. -- [ ] README + CHANGELOG entries describing the new meta-tools and the manifest-driven workflow. +- [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 | @@ -283,3 +306,9 @@ Each write fires `tools/list_changed` per-peer via the existing `MCPNotifier::no ## 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 19, 2026 closeout:** +- Phases 1–5 implemented on `feat/dynamic-mcp-toggle-meta-tools`. +- 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. diff --git a/tests/rust/tests/integration/meta_tools.rs b/tests/rust/tests/integration/meta_tools.rs index 39215ab4..4e693e2c 100644 --- a/tests/rust/tests/integration/meta_tools.rs +++ b/tests/rust/tests/integration/meta_tools.rs @@ -285,11 +285,7 @@ async fn bind_github_only_to_session_root(f: &Fixture) -> String { 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(), - ); + let binding = WorkspaceBinding::new(normalize_workspace_root(root), f.space_id, fs_id.clone()); f.binding_repo.create(&binding).await.unwrap(); fs_id } @@ -486,11 +482,7 @@ async fn disable_server_workspace_removes_server_all_from_binding() { let tools = f .feature_service - .get_tools_for_grants( - &f.space_id.to_string(), - &binding.feature_set_ids, - None, - ) + .get_tools_for_grants(&f.space_id.to_string(), &binding.feature_set_ids, None) .await .unwrap(); assert_eq!(tools.len(), 1); @@ -502,7 +494,8 @@ 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"]); + f.session_roots + .set(&f.session_id, ["/tmp/unbound-workspace"]); let result = f .call_tool_as_handler_would( @@ -992,7 +985,11 @@ async fn session_override_disable_mutes_bound_server() { let before = f .feature_service - .get_tools_for_grants(&f.space_id.to_string(), &[fs_id.clone()], Some(&f.session_id)) + .get_tools_for_grants( + &f.space_id.to_string(), + &[fs_id.clone()], + Some(&f.session_id), + ) .await .unwrap(); assert_eq!(before.len(), 1); @@ -1021,7 +1018,8 @@ async fn session_override_additive_over_binding() { .unwrap(); assert_eq!(tools.len(), 2); - let servers: std::collections::HashSet<_> = tools.iter().map(|t| t.server_id.as_str()).collect(); + 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/oauth/dcr.rs b/tests/rust/tests/oauth/dcr.rs index d6ca2345..7d132c59 100644 --- a/tests/rust/tests/oauth/dcr.rs +++ b/tests/rust/tests/oauth/dcr.rs @@ -71,11 +71,20 @@ fn test_external_https_rejected() { } #[test] -fn test_mixed_valid_invalid_rejected() { - // One invalid URI should fail the whole validation +fn test_mixed_valid_invalid_skips_invalid() { + // Invalid URIs are skipped — clients like Cursor send a mix and only use valid ones. let uris = vec![ "http://127.0.0.1:8080/callback".to_string(), - "https://evil.com/steal".to_string(), // invalid + "https://evil.com/steal".to_string(), + ]; + assert!(validate_redirect_uris(&uris).is_ok()); +} + +#[test] +fn test_all_invalid_rejected() { + let uris = vec![ + "https://evil.com/steal".to_string(), + "http://example.com/callback".to_string(), ]; assert!(validate_redirect_uris(&uris).is_err()); } diff --git a/tests/rust/tests/streamable_http/gateway_notifications.rs b/tests/rust/tests/streamable_http/gateway_notifications.rs index 5c722b53..834e0530 100644 --- a/tests/rust/tests/streamable_http/gateway_notifications.rs +++ b/tests/rust/tests/streamable_http/gateway_notifications.rs @@ -581,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 { @@ -595,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)" ); From 726492efc7775c49a8add0361c3e9703d6961734 Mon Sep 17 00:00:00 2001 From: crimsonsunset Date: Tue, 19 May 2026 10:49:43 -0600 Subject: [PATCH 10/48] feat(ui): add rename/edit for spaces, feature sets, and workspace bindings Expose update_space and editable panels for Spaces and Feature Sets. Add optional workspace binding labels with migration 016 so folders can have friendly display names separate from their paths. Signed-off-by: crimsonsunset --- .../src-tauri/src/commands/feature_set.rs | 4 - apps/desktop/src-tauri/src/commands/space.rs | 54 +++++ .../src/commands/workspace_binding.rs | 20 +- apps/desktop/src-tauri/src/lib.rs | 1 + .../features/featuresets/FeatureSetPanel.tsx | 115 ++++++++- .../src/features/spaces/SpacePanel.tsx | 227 ++++++++++++++++++ .../src/features/spaces/SpacesPage.tsx | 86 +++---- .../features/workspaces/WorkspacesPage.tsx | 82 ++++++- apps/desktop/src/lib/api/spaces.ts | 14 ++ apps/desktop/src/lib/api/workspaceBindings.ts | 4 + .../src/domain/workspace_binding.rs | 3 + .../mcpmux-core/src/service/space_service.rs | 29 +++ crates/mcpmux-storage/src/database.rs | 5 + .../016_workspace_binding_label.sql | 2 + .../workspace_binding_repository.rs | 19 +- 15 files changed, 592 insertions(+), 73 deletions(-) create mode 100644 apps/desktop/src/features/spaces/SpacePanel.tsx create mode 100644 crates/mcpmux-storage/src/migrations/016_workspace_binding_label.sql diff --git a/apps/desktop/src-tauri/src/commands/feature_set.rs b/apps/desktop/src-tauri/src/commands/feature_set.rs index 7ecd8e82..2f81a7b4 100644 --- a/apps/desktop/src-tauri/src/commands/feature_set.rs +++ b/apps/desktop/src-tauri/src/commands/feature_set.rs @@ -263,10 +263,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; } diff --git a/apps/desktop/src-tauri/src/commands/space.rs b/apps/desktop/src-tauri/src/commands/space.rs index 7b547878..8d8b3ea2 100644 --- a/apps/desktop/src-tauri/src/commands/space.rs +++ b/apps/desktop/src-tauri/src/commands/space.rs @@ -7,6 +7,7 @@ //! viewing in its own Zustand store (frontend-only state). use mcpmux_core::Space; +use serde::Deserialize; use std::sync::Arc; use tauri::{AppHandle, State}; use tokio::sync::RwLock; @@ -103,6 +104,59 @@ pub async fn create_space( Ok(space) } +/// Partial update payload for a Space (name, icon, description). +#[derive(Debug, Deserialize)] +pub struct UpdateSpaceInput { + pub name: Option, + pub icon: Option, + pub description: Option, +} + +/// Update a space's display metadata. +#[tauri::command] +pub async fn update_space( + id: String, + input: UpdateSpaceInput, + app: AppHandle, + state: State<'_, AppState>, + gateway_state: State<'_, Arc>>, +) -> Result { + let uuid = Uuid::parse_str(&id).map_err(|e| e.to_string())?; + + let name = input + .name + .map(|n| n.trim().to_string()) + .filter(|n| !n.is_empty()); + let icon = input + .icon + .map(|i| i.trim().to_string()) + .filter(|i| !i.is_empty()); + let description = input.description.map(|d| d.trim().to_string()); + + let space = state + .space_service + .update(uuid, name, icon, description) + .await + .map_err(|e| e.to_string())?; + + let gw_state = gateway_state.read().await; + if let Some(ref gw) = gw_state.gateway_state { + let gw = gw.read().await; + gw.emit_domain_event(mcpmux_core::DomainEvent::SpaceUpdated { + space_id: space.id, + name: space.name.clone(), + }); + } + + if let Err(e) = tray::update_tray_spaces(&app, &state).await { + warn!("Failed to update tray menu: {}", e); + } + + info!("[update_space] Space '{}' updated successfully", space.name); + + Ok(space) +} + /// Delete a space. #[tauri::command] pub async fn delete_space( diff --git a/apps/desktop/src-tauri/src/commands/workspace_binding.rs b/apps/desktop/src-tauri/src/commands/workspace_binding.rs index 2d5a083c..7615a3d1 100644 --- a/apps/desktop/src-tauri/src/commands/workspace_binding.rs +++ b/apps/desktop/src-tauri/src/commands/workspace_binding.rs @@ -54,6 +54,7 @@ async fn emit_binding_changed( pub struct WorkspaceBindingDto { pub id: String, pub workspace_root: String, + pub label: Option, pub space_id: String, pub feature_set_ids: Vec, pub created_at: String, @@ -65,6 +66,7 @@ impl From for WorkspaceBindingDto { Self { id: b.id.to_string(), workspace_root: b.workspace_root, + label: b.label, space_id: b.space_id.to_string(), feature_set_ids: b.feature_set_ids, created_at: b.created_at.to_rfc3339(), @@ -80,10 +82,18 @@ impl From for WorkspaceBindingDto { #[derive(Debug, Deserialize)] pub struct WorkspaceBindingInput { pub workspace_root: String, + pub label: Option, pub space_id: String, pub feature_set_ids: Vec, } +fn normalize_label(label: &Option) -> Option { + label + .as_ref() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + fn parse_space_id(input: &WorkspaceBindingInput) -> Result { Uuid::parse_str(&input.space_id).map_err(|e| format!("bad space_id: {e}")) } @@ -194,7 +204,8 @@ pub async fn create_workspace_binding( let feature_set_ids = validate_fs_list(&input)?; let normalized = normalize_and_validate(&input.workspace_root)?; - let binding = WorkspaceBinding::new_multi(normalized.clone(), space_id, feature_set_ids); + let mut binding = WorkspaceBinding::new_multi(normalized.clone(), space_id, feature_set_ids); + binding.label = normalize_label(&input.label); state .workspace_binding_repository @@ -241,9 +252,16 @@ pub async fn update_workspace_binding( .ok_or_else(|| format!("binding not found: {}", id))?; let old_space_id = existing.space_id; + let label = if input.label.is_some() { + normalize_label(&input.label) + } else { + existing.label + }; + let updated = WorkspaceBinding { id: existing.id, workspace_root: normalized, + label, space_id, feature_set_ids, created_at: existing.created_at, diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index bca6e511..70069a04 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -848,6 +848,7 @@ pub fn run() { commands::list_spaces, commands::get_space, commands::create_space, + commands::update_space, commands::delete_space, commands::open_space_config_file, commands::read_space_config, diff --git a/apps/desktop/src/features/featuresets/FeatureSetPanel.tsx b/apps/desktop/src/features/featuresets/FeatureSetPanel.tsx index b9926131..38406dd3 100644 --- a/apps/desktop/src/features/featuresets/FeatureSetPanel.tsx +++ b/apps/desktop/src/features/featuresets/FeatureSetPanel.tsx @@ -21,7 +21,11 @@ import { } from 'lucide-react'; import { Button, useToast, ToastContainer, useConfirm } from '@mcpmux/ui'; import type { FeatureSet, AddMemberInput } from '@/lib/api/featureSets'; -import { isStarterFeatureSet, 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'; @@ -45,6 +49,11 @@ export function FeatureSetPanel({ featureSet, spaceId, onClose, onDelete, onUpda 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(); @@ -68,6 +77,13 @@ export function FeatureSetPanel({ featureSet, spaceId, onClose, onDelete, onUpda 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 () => { setIsLoading(true); @@ -167,6 +183,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); @@ -251,7 +305,7 @@ export function FeatureSetPanel({ featureSet, spaceId, onClose, onDelete, onUpda

- {featureSet.name} + {displayName}

-

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

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

{space.name}

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

+ {error} +

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

-
+
{space.is_default && ( )} - {!space.is_default && ( - - )} + + +
@@ -216,6 +195,27 @@ export function SpacesPage() {
+ {selectedSpace && ( + <> +
setSelectedSpaceId(null)} + /> + setSelectedSpaceId(null)} + onSaved={(updated) => { + updateSpaceInStore(updated.id, updated); + setSelectedSpaceId(updated.id); + }} + onDeleted={(id) => { + removeSpace(id); + setSelectedSpaceId(null); + }} + /> + + )} + {/* Create Modal */} {showCreateModal && (
diff --git a/apps/desktop/src/features/workspaces/WorkspacesPage.tsx b/apps/desktop/src/features/workspaces/WorkspacesPage.tsx index cda9b4e0..f99d64e5 100644 --- a/apps/desktop/src/features/workspaces/WorkspacesPage.tsx +++ b/apps/desktop/src/features/workspaces/WorkspacesPage.tsx @@ -234,8 +234,10 @@ export function WorkspacesPage() { .map((id) => fsById.get(id)?.name ?? '') .join(' ') : ''; + const label = e.binding?.label?.toLowerCase() ?? ''; return ( e.root.toLowerCase().includes(q) || + label.includes(q) || spaceName.toLowerCase().includes(q) || fsNames.toLowerCase().includes(q) ); @@ -469,11 +471,31 @@ function formatFsList(names: string[]): string { * a no-op edit. `feature_set_ids` order matters (it's the operator- * chosen render order, not just a set), so we compare positionally. */ +function normalizeLabel(label: string | null | undefined): string | null { + const trimmed = label?.trim() ?? ''; + return trimmed.length > 0 ? trimmed : null; +} + +/** + * Primary title for a workspace entry — label when set, otherwise the path. + */ +function entryDisplayTitle(entry: Entry): string { + const label = entry.binding?.label?.trim(); + if (label) return label; + return entry.root; +} + function sameBindingInput( a: WorkspaceBindingInput, - b: { workspace_root: string; space_id: string; feature_set_ids: string[] } + b: { + workspace_root: string; + label?: string | null; + space_id: string; + feature_set_ids: string[]; + } ): boolean { if (a.workspace_root.trim() !== b.workspace_root.trim()) return false; + if (normalizeLabel(a.label) !== normalizeLabel(b.label)) return false; if (a.space_id !== b.space_id) return false; if (a.feature_set_ids.length !== b.feature_set_ids.length) return false; return a.feature_set_ids.every((id, i) => id === b.feature_set_ids[i]); @@ -584,11 +606,18 @@ function EntryCard({ {entry.kind === 'mapped-live' && Live}

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

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

+ {entry.root} +

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

{title}

-

- {subtitle} -

+

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

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

+ {entry.root} +

+ )} + {isNew && ( +

{subtitle}

+ )}
+ + +
+
+ +

+ 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..fe85261f 100644 --- a/apps/desktop/src/features/servers/ServerActionMenu.tsx +++ b/apps/desktop/src/features/servers/ServerActionMenu.tsx @@ -11,7 +11,7 @@ */ 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'; export interface ServerActionMenuProps { serverId: string; @@ -20,11 +20,14 @@ export interface ServerActionMenuProps { 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; } @@ -35,11 +38,13 @@ export function ServerActionMenu({ isOAuth, isEnabled, isConnected: _isConnected, + canCloneAccount = false, onConfigure, onRefresh, onReconnect, onViewLogs, onViewDefinition, + onCloneAccount, onUninstall, }: ServerActionMenuProps) { const [isOpen, setIsOpen] = useState(false); @@ -163,6 +168,19 @@ export function ServerActionMenu({ View Definition + {/* Add another account - registry/manual installs only, not clones-of-clones */} + {canCloneAccount && onCloneAccount && ( + + )} + {/* Separator */}
diff --git a/apps/desktop/src/features/servers/ServersPage.tsx b/apps/desktop/src/features/servers/ServersPage.tsx index 9ae658c0..5ff631fb 100644 --- a/apps/desktop/src/features/servers/ServersPage.tsx +++ b/apps/desktop/src/features/servers/ServersPage.tsx @@ -21,6 +21,7 @@ import { FolderOpen, } from 'lucide-react'; import { ServerActionMenu } from './ServerActionMenu'; +import { CloneAccountModal } from './CloneAccountModal'; import type { ServerViewModel, ServerDefinition, InstalledServerState, InputDefinition } from '../../types/registry'; import type { ServerFeature } from '@/lib/api/serverFeatures'; import { listServerFeaturesByServer } from '@/lib/api/serverFeatures'; @@ -36,12 +37,36 @@ 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'; + +/** 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 => { @@ -70,6 +95,7 @@ function mergeDefinitionsWithStates( 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 + cloned_from: state ? getInstalledCloneLineage(state) : undefined, env_overrides: state?.env_overrides ?? {}, args_append: state?.args_append ?? [], extra_headers: state?.extra_headers ?? {}, @@ -79,7 +105,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 { +function createOfflineServerViewModel(state: InstalledServerState): ServerViewModelWithClone { // Try to use cached definition first (proper offline support) if (state.cached_definition) { try { @@ -104,6 +130,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 ?? {}, @@ -140,6 +167,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 ?? {}, @@ -162,7 +190,7 @@ interface ConfigModalState { } export function ServersPage() { - const [installedServers, setInstalledServers] = useState([]); + const [installedServers, setInstalledServers] = useState([]); const [gatewayRunning, setGatewayRunning] = useState(false); const [gatewayUrl, setGatewayUrl] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -189,6 +217,9 @@ 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); // Config editor state const [editConfigSpace, setEditConfigSpace] = useState<{ id: string; name: string } | null>(null); @@ -327,7 +358,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 @@ -595,7 +626,6 @@ 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 = {}; @@ -613,6 +643,60 @@ export function ServersPage() { }); }; + /** + * 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, + 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; @@ -1028,7 +1112,10 @@ export function ServersPage() { {server.transport.type} {/* Installation Source Badge */} - +
{/* Show runtime message inline (from ServerManager events) */} @@ -1169,11 +1256,13 @@ export function ServersPage() { } isEnabled={server.enabled} isConnected={serverAction === 'running' || serverAction === 'connected_auto'} + canCloneAccount={canCloneServer(server)} onConfigure={() => handleConfigureClick(server)} onRefresh={() => handleRefresh(server)} onReconnect={() => handleReconnect(server)} onViewLogs={() => setLogViewerServer({ id: server.id, name: server.name })} onViewDefinition={() => setDefinitionServer({ id: server.id, name: server.name })} + onCloneAccount={() => setCloneModalServer(server)} onUninstall={() => handleUninstall(server)} /> @@ -1291,6 +1380,17 @@ export function ServersPage() { )} + {/* Clone Account Modal */} + {cloneModalServer && viewSpace && ( + setCloneModalServer(null)} + onCloned={handleCloneComplete} + /> + )} + {/* Configuration Modal */} {configModal.open && configModal.server && (
diff --git a/apps/desktop/src/lib/api/serverClone.ts b/apps/desktop/src/lib/api/serverClone.ts new file mode 100644 index 00000000..a1131d14 --- /dev/null +++ b/apps/desktop/src/lib/api/serverClone.ts @@ -0,0 +1,88 @@ +/** + * 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. + */ +export async function cloneServer( + spaceId: string, + sourceServerId: string, + suffix: string, + alias?: string +): Promise { + return invoke('clone_server', { + spaceId, + sourceServerId, + suffix, + alias: alias ?? 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, + }); +} + +/** + * 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, '-'); +} From a45d34c10f8ffb1c338b48ce25d33aa3df4cc91c Mon Sep 17 00:00:00 2001 From: crimsonsunset Date: Sat, 23 May 2026 10:39:12 -0600 Subject: [PATCH 19/48] =?UTF-8?q?feat(server-clone):=20Phase=203=20?= =?UTF-8?q?=E2=80=94=20Meta-tool=20+=20docs=20surfacing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Autonomous decisions: - Added InstalledServerRepository to MetaToolContext so mcpmux_list_servers can resolve cloned_from without a core type change (field already on InstalledServer) - Omitted cloned_from from non-clone rows rather than emitting null - Skipped jsg-tech-check migration doc (out of repo per orchestrator) - Updated planning doc status/checkboxes to match Phases 1–3 completion Signed-off-by: crimsonsunset --- .../src/server/service_container.rs | 1 + .../src/services/meta_tools/mod.rs | 2 + .../src/services/meta_tools/registry.rs | 5 +- .../src/services/meta_tools/tools.rs | 22 ++++- docs/guide/servers.mdx | 27 ++++++ docs/planning/server-account-clones.md | 28 +++--- tests/rust/tests/integration/meta_tools.rs | 91 ++++++++++++++++++- 7 files changed, 152 insertions(+), 24 deletions(-) diff --git a/crates/mcpmux-gateway/src/server/service_container.rs b/crates/mcpmux-gateway/src/server/service_container.rs index 1273fa79..54e39b6b 100644 --- a/crates/mcpmux-gateway/src/server/service_container.rs +++ b/crates/mcpmux-gateway/src/server/service_container.rs @@ -131,6 +131,7 @@ impl ServiceContainer { deps.feature_set_repo.clone(), deps.workspace_binding_repo.clone(), deps.feature_repo.clone(), + deps.installed_server_repo.clone(), feature_set_resolver.clone(), pool_services.feature_service.clone(), session_roots.clone(), diff --git a/crates/mcpmux-gateway/src/services/meta_tools/mod.rs b/crates/mcpmux-gateway/src/services/meta_tools/mod.rs index e4fc7908..1c0a39ef 100644 --- a/crates/mcpmux-gateway/src/services/meta_tools/mod.rs +++ b/crates/mcpmux-gateway/src/services/meta_tools/mod.rs @@ -56,6 +56,7 @@ pub fn build_default_registry( feature_set_repo: std::sync::Arc, binding_repo: std::sync::Arc, server_feature_repo: std::sync::Arc, + installed_server_repo: std::sync::Arc, resolver: std::sync::Arc, feature_service: std::sync::Arc, session_roots: std::sync::Arc, @@ -70,6 +71,7 @@ pub fn build_default_registry( feature_set_repo, binding_repo, server_feature_repo, + installed_server_repo, resolver, feature_service, session_roots, diff --git a/crates/mcpmux-gateway/src/services/meta_tools/registry.rs b/crates/mcpmux-gateway/src/services/meta_tools/registry.rs index 85515bb3..583b9eaa 100644 --- a/crates/mcpmux-gateway/src/services/meta_tools/registry.rs +++ b/crates/mcpmux-gateway/src/services/meta_tools/registry.rs @@ -9,8 +9,8 @@ use std::sync::{Arc, Mutex}; use async_trait::async_trait; use mcpmux_core::{ - DomainEvent, FeatureSetRepository, InboundMcpClientRepository, ServerFeatureRepository, - SpaceRepository, WorkspaceBindingRepository, + DomainEvent, FeatureSetRepository, InboundMcpClientRepository, InstalledServerRepository, + ServerFeatureRepository, SpaceRepository, WorkspaceBindingRepository, }; use rmcp::model::{CallToolResult, Tool}; use serde_json::Value; @@ -41,6 +41,7 @@ pub struct MetaToolContext { pub feature_set_repo: Arc, pub binding_repo: Arc, pub server_feature_repo: Arc, + pub installed_server_repo: Arc, pub resolver: Arc, pub feature_service: Arc, pub session_roots: Arc, diff --git a/crates/mcpmux-gateway/src/services/meta_tools/tools.rs b/crates/mcpmux-gateway/src/services/meta_tools/tools.rs index 4c626423..e0d0170c 100644 --- a/crates/mcpmux-gateway/src/services/meta_tools/tools.rs +++ b/crates/mcpmux-gateway/src/services/meta_tools/tools.rs @@ -211,7 +211,8 @@ impl MetaTool for ListServersTool { 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. Use before enable/disable to see \ + 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." } @@ -250,6 +251,17 @@ impl MetaTool for ListServersTool { .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()))?; + let cloned_from_by_server: HashMap> = installed + .into_iter() + .map(|s| (s.server_id, s.cloned_from)) + .collect(); + let mut by_server: HashMap, usize)> = HashMap::new(); for feature in &features { if feature.feature_type != FeatureType::Tool { @@ -274,12 +286,16 @@ impl MetaTool for ListServersTool { &session_enabled, &session_disabled, ); - json!({ + let mut entry = json!({ "id": id, "name": name, "tool_count": tool_count, "status": status, - }) + }); + if let Some(cloned_from) = cloned_from_by_server.get(&id).and_then(|v| v.as_ref()) { + entry["cloned_from"] = json!(cloned_from); + } + entry }) .collect(); servers.sort_by(|a, b| { 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/server-account-clones.md b/docs/planning/server-account-clones.md index 3c0df921..1883ad16 100644 --- a/docs/planning/server-account-clones.md +++ b/docs/planning/server-account-clones.md @@ -1,8 +1,8 @@ # Server Account Clones (UI-Assisted Multi-Account) **Last Updated:** May 23, 2026 -**Status:** Planning — decisions locked, not started -**Branch:** TBD — file after planning review +**Status:** In progress — Phases 1–3 complete, Phase 4 pending +**Branch:** `feat/server-account-clones` **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) @@ -163,16 +163,16 @@ Multi-account need? **Effort:** ~1 day -- [ ] Migration: `cloned_from TEXT NULL` on `installed_servers` -- [ ] `InstalledServer.cloned_from` field + repo round-trip -- [ ] `ServerAppService::clone_server`: +- [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)` -- [ ] Unit tests: happy path, collision, missing source, suffix normalization (no underscores) -- [ ] Tauri command `clone_server(space_id, source_server_id, suffix, alias?)` +- [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. @@ -180,10 +180,10 @@ Multi-account need? **Effort:** ~1 day -- [ ] `CloneAccountModal` — suffix field with suggestions (`work`, `personal`, `prod`, `staging`), live alias preview, inline collision error -- [ ] `ServerActionMenu` → "Add another account…" on registry and manual installs (not on clones) -- [ ] Post-clone flow: open existing `ConfigEditorModal` for credential entry before enable -- [ ] `SourceBadge` shows clone lineage +- [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. @@ -192,9 +192,9 @@ Multi-account need? **Effort:** ~0.5 day -- [ ] `mcpmux_list_servers` returns optional `cloned_from` for clone rows -- [ ] `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) +- [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. diff --git a/tests/rust/tests/integration/meta_tools.rs b/tests/rust/tests/integration/meta_tools.rs index 4e693e2c..f8329b03 100644 --- a/tests/rust/tests/integration/meta_tools.rs +++ b/tests/rust/tests/integration/meta_tools.rs @@ -12,8 +12,9 @@ use std::time::Duration; use futures::FutureExt; use mcpmux_core::{ normalize_workspace_root, Client, DomainEvent, FeatureSet, FeatureSetMember, - FeatureSetRepository, InboundMcpClientRepository, MemberMode, MemberType, ServerFeature, - ServerFeatureRepository, SpaceRepository, WorkspaceBindingRepository, + FeatureSetRepository, InboundMcpClientRepository, InstalledServer, InstalledServerRepository, + MemberMode, MemberType, ServerFeature, ServerFeatureRepository, SpaceRepository, + WorkspaceBindingRepository, }; use mcpmux_gateway::pool::FeatureService; use mcpmux_gateway::services::{ @@ -22,9 +23,9 @@ use mcpmux_gateway::services::{ SessionRootsRegistry, }; use mcpmux_storage::{ - Database, InboundClientRepository, SqliteFeatureSetRepository, - SqliteInboundMcpClientRepository, SqliteServerFeatureRepository, SqliteSpaceRepository, - SqliteWorkspaceBindingRepository, + generate_master_key, Database, FieldEncryptor, InboundClientRepository, + SqliteFeatureSetRepository, SqliteInboundMcpClientRepository, SqliteInstalledServerRepository, + SqliteServerFeatureRepository, SqliteSpaceRepository, SqliteWorkspaceBindingRepository, }; use serde_json::{json, Value}; use tokio::sync::{broadcast, Mutex}; @@ -37,6 +38,7 @@ struct Fixture { client_repo: Arc, feature_set_repo: Arc, binding_repo: Arc, + installed_server_repo: Arc, session_roots: Arc, session_overrides: Arc, feature_service: Arc, @@ -50,6 +52,11 @@ struct Fixture { 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())); @@ -63,6 +70,9 @@ impl Fixture { Arc::new(SqliteWorkspaceBindingRepository::new(db.clone())); let server_feature_repo: Arc = Arc::new(SqliteServerFeatureRepository::new(db.clone())); + let installed_server_repo: Arc = Arc::new( + SqliteInstalledServerRepository::new(db.clone(), test_encryptor()), + ); let default_space = space_repo.get_default().await.unwrap().unwrap(); let space_id = default_space.id; @@ -127,6 +137,7 @@ impl Fixture { feature_set_repo.clone(), binding_repo.clone(), server_feature_repo.clone(), + installed_server_repo.clone(), resolver, feature_service.clone(), session_roots.clone(), @@ -142,6 +153,7 @@ impl Fixture { client_repo, feature_set_repo, binding_repo, + installed_server_repo, session_roots, session_overrides, feature_service, @@ -353,6 +365,67 @@ async fn list_servers_shows_session_override_statuses() { 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; @@ -774,6 +847,9 @@ async fn bare_registry( Arc::new(SqliteWorkspaceBindingRepository::new(db.clone())); let server_feature_repo: Arc = Arc::new(SqliteServerFeatureRepository::new(db.clone())); + let installed_server_repo: Arc = Arc::new( + SqliteInstalledServerRepository::new(db.clone(), test_encryptor()), + ); let _space = space_repo.get_default().await.unwrap().unwrap(); let client = Client::new("c", "t"); @@ -801,6 +877,7 @@ async fn bare_registry( feature_set_repo, binding_repo, server_feature_repo, + installed_server_repo, resolver, feature_service, SessionRootsRegistry::new(), @@ -894,6 +971,9 @@ async fn master_switch_toggles_registry_visibility() { Arc::new(SqliteWorkspaceBindingRepository::new(db.clone())); let server_feature_repo: Arc = Arc::new(SqliteServerFeatureRepository::new(db.clone())); + let installed_server_repo: Arc = Arc::new( + SqliteInstalledServerRepository::new(db.clone(), test_encryptor()), + ); let inbound_client_repo = Arc::new(InboundClientRepository::new(db.clone())); let resolver = Arc::new(FeatureSetResolverService::new( space_repo.clone(), @@ -915,6 +995,7 @@ async fn master_switch_toggles_registry_visibility() { feature_set_repo, binding_repo, server_feature_repo, + installed_server_repo, resolver, feature_service, SessionRootsRegistry::new(), From c090948b111b64a0790cf29b34714c8a2c341e8b Mon Sep 17 00:00:00 2001 From: crimsonsunset Date: Sat, 23 May 2026 10:42:06 -0600 Subject: [PATCH 20/48] =?UTF-8?q?feat(server-clone):=20Phase=204=20?= =?UTF-8?q?=E2=80=94=20Validation=20+=20edge=20cases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Autonomous decisions: - PrefixCache reads alias from cached_definition before registry lookup so clone suffix aliases resolve at connect time - Uninstall-source UX: three-action modal (cancel / source-only / uninstall all) via UninstallSourceWithClonesDialog - list_clone_dependents filters list_for_space by cloned_from — no new repo method needed Signed-off-by: crimsonsunset --- .../src-tauri/src/commands/server_clone.rs | 18 + apps/desktop/src-tauri/src/lib.rs | 1 + .../src/features/servers/ServersPage.tsx | 128 +++++- .../UninstallSourceWithClonesDialog.tsx | 92 +++++ apps/desktop/src/lib/api/serverClone.ts | 13 + crates/mcpmux-core/src/application/server.rs | 84 ++++ .../src/services/prefix_cache.rs | 51 ++- docs/planning/server-account-clones.md | 12 +- tests/rust/tests/integration/mod.rs | 1 + tests/rust/tests/integration/server_clone.rs | 378 ++++++++++++++++++ 10 files changed, 740 insertions(+), 38 deletions(-) create mode 100644 apps/desktop/src/features/servers/UninstallSourceWithClonesDialog.tsx create mode 100644 tests/rust/tests/integration/server_clone.rs diff --git a/apps/desktop/src-tauri/src/commands/server_clone.rs b/apps/desktop/src-tauri/src/commands/server_clone.rs index 3f9dc664..91344ba5 100644 --- a/apps/desktop/src-tauri/src/commands/server_clone.rs +++ b/apps/desktop/src-tauri/src/commands/server_clone.rs @@ -73,3 +73,21 @@ pub async fn suggest_clone_suffix( .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/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 9b4b4491..03d17b96 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -873,6 +873,7 @@ pub fn run() { 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, diff --git a/apps/desktop/src/features/servers/ServersPage.tsx b/apps/desktop/src/features/servers/ServersPage.tsx index 5ff631fb..b2341730 100644 --- a/apps/desktop/src/features/servers/ServersPage.tsx +++ b/apps/desktop/src/features/servers/ServersPage.tsx @@ -22,6 +22,7 @@ import { } from 'lucide-react'; import { ServerActionMenu } from './ServerActionMenu'; import { CloneAccountModal } from './CloneAccountModal'; +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'; @@ -38,6 +39,7 @@ 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 }; @@ -220,6 +222,12 @@ export function ServersPage() { // 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); @@ -795,31 +803,103 @@ export function ServersPage() { } }; - const handleUninstall = async (server: ServerViewModel) => { + const performUninstall = async (serverIds: string[]) => { + const { uninstallServer } = await import('@/lib/api/registry'); + const { disconnectServer } = await import('@/lib/api/gateway'); + + if (gatewayRunning && viewSpace) { + for (const serverId of serverIds) { + const target = installedServers.find((entry) => entry.id === serverId); + if (!target?.enabled) { + continue; + } + + try { + await disconnectServer(serverId, viewSpace.id); + } catch (error) { + console.warn(`[ServersPage] Failed to disconnect server from gateway:`, error); + } + } + } + + for (const serverId of serverIds) { + await uninstallServer(serverId, viewSpace?.id ?? ''); + } + + await loadData(); + }; + + const handleUninstall = async (server: ServerViewModelWithClone) => { + if (!viewSpace) { + return; + } + + if (!server.cloned_from) { + try { + const dependents = await listCloneDependents(viewSpace.id, server.id); + if (dependents.length > 0) { + setUninstallClonesDialog({ server, dependents }); + return; + } + } catch (error) { + showToast(String(error), 'error'); + return; + } + } + const { getUninstallLabel } = await import('@/components/SourceBadge'); const actionLabel = getUninstallLabel(server.installation_source); setActionLoading(`uninstall-${server.id}`); try { - const { uninstallServer } = await import('@/lib/api/registry'); - const { disconnectServer } = await import('@/lib/api/gateway'); - - if (gatewayRunning && server.enabled && viewSpace) { - try { - await disconnectServer(server.id, viewSpace.id); - } catch (e) { - console.warn(`[ServersPage] Failed to disconnect server from gateway:`, e); - } - } - - // ServerAppService handles source-aware cleanup automatically: - // - UserConfig: removes from JSON file + DB - // - Registry/ManualEntry: just removes from DB - await uninstallServer(server.id, viewSpace?.id ?? ''); - await loadData(); + await performUninstall([server.id]); showToast(`${server.name} ${actionLabel.toLowerCase()}ed`, 'success'); - } catch (e) { - showToast(String(e), 'error'); + } catch (error) { + showToast(String(error), 'error'); + } finally { + setActionLoading(null); + } + }; + + const handleUninstallSourceOnly = async () => { + if (!uninstallClonesDialog) { + return; + } + + const { server } = uninstallClonesDialog; + const { getUninstallLabel } = await import('@/components/SourceBadge'); + const actionLabel = getUninstallLabel(server.installation_source); + + setUninstallClonesDialog(null); + setActionLoading(`uninstall-${server.id}`); + try { + await performUninstall([server.id]); + showToast(`${server.name} ${actionLabel.toLowerCase()}ed`, 'success'); + } catch (error) { + showToast(String(error), 'error'); + } finally { + setActionLoading(null); + } + }; + + const handleUninstallAllWithClones = async () => { + if (!uninstallClonesDialog) { + return; + } + + const { server, dependents } = uninstallClonesDialog; + const serverIds = [...dependents.map((dependent) => dependent.server_id), server.id]; + + setUninstallClonesDialog(null); + setActionLoading(`uninstall-${server.id}`); + try { + await performUninstall(serverIds); + showToast( + `${server.name} and ${dependents.length} clone${dependents.length === 1 ? '' : 's'} uninstalled`, + 'success' + ); + } catch (error) { + showToast(String(error), 'error'); } finally { setActionLoading(null); } @@ -930,6 +1010,16 @@ export function ServersPage() { return (
{gatewayControl.ConfirmDialogElement} + {uninstallClonesDialog && ( + setUninstallClonesDialog(null)} + onUninstallSourceOnly={handleUninstallSourceOnly} + onUninstallAll={handleUninstallAllWithClones} + /> + )} {/* Header */}
diff --git a/apps/desktop/src/features/servers/UninstallSourceWithClonesDialog.tsx b/apps/desktop/src/features/servers/UninstallSourceWithClonesDialog.tsx new file mode 100644 index 00000000..92b393fb --- /dev/null +++ b/apps/desktop/src/features/servers/UninstallSourceWithClonesDialog.tsx @@ -0,0 +1,92 @@ +import { AlertCircle } from 'lucide-react'; + +export interface CloneDependentSummary { + server_id: string; + server_name?: 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) => dependent.server_name ?? dependent.server_id + ); + 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/lib/api/serverClone.ts b/apps/desktop/src/lib/api/serverClone.ts index a1131d14..21943685 100644 --- a/apps/desktop/src/lib/api/serverClone.ts +++ b/apps/desktop/src/lib/api/serverClone.ts @@ -58,6 +58,19 @@ export async function suggestCloneSuffix( }); } +/** + * 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). */ diff --git a/crates/mcpmux-core/src/application/server.rs b/crates/mcpmux-core/src/application/server.rs index 757b7472..8e20458c 100644 --- a/crates/mcpmux-core/src/application/server.rs +++ b/crates/mcpmux-core/src/application/server.rs @@ -183,6 +183,19 @@ impl ServerAppService { .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, @@ -785,4 +798,75 @@ mod tests { 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 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-gateway/src/services/prefix_cache.rs b/crates/mcpmux-gateway/src/services/prefix_cache.rs index 2a85d95b..e631252f 100644 --- a/crates/mcpmux-gateway/src/services/prefix_cache.rs +++ b/crates/mcpmux-gateway/src/services/prefix_cache.rs @@ -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/docs/planning/server-account-clones.md b/docs/planning/server-account-clones.md index 1883ad16..83c91ee7 100644 --- a/docs/planning/server-account-clones.md +++ b/docs/planning/server-account-clones.md @@ -1,7 +1,7 @@ # Server Account Clones (UI-Assisted Multi-Account) **Last Updated:** May 23, 2026 -**Status:** In progress — Phases 1–3 complete, Phase 4 pending +**Status:** In progress — Phases 1–4 complete, Phase 5 optional **Branch:** `feat/server-account-clones` **Base branch:** `main` **Issue:** TBD — file after planning review @@ -202,11 +202,11 @@ Multi-account need? **Effort:** ~0.5 day -- [ ] Integration test: two clones in one Space, distinct prefixes, both connect with different env -- [ ] Uninstall clone does not affect source -- [ ] Uninstall source warns if clones exist (list dependents, offer bulk uninstall) -- [ ] Prefix collision: two different registry servers cannot claim same alias (existing behavior — verify clones don't break it) -- [ ] `pnpm validate` + targeted Rust/TS tests +- [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. diff --git a/tests/rust/tests/integration/mod.rs b/tests/rust/tests/integration/mod.rs index c1f1acb3..15e23754 100644 --- a/tests/rust/tests/integration/mod.rs +++ b/tests/rust/tests/integration/mod.rs @@ -12,4 +12,5 @@ mod feature_routing; mod feature_set_resolver; mod mcp_flows; 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..4ced4658 --- /dev/null +++ b/tests/rust/tests/integration/server_clone.rs @@ -0,0 +1,378 @@ +//! 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) + .await + .expect("clone work"); + let clone_personal = fixture + .service + .clone_server(space_id, "posthog", "personal", 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, + ) + .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, + ) + .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) + .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) + .await + .unwrap(); + fixture + .service + .clone_server(space_id, "posthog", "personal", 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 + ); +} From b7016de4b155d2954d01dac01448379f777de573 Mon Sep 17 00:00:00 2001 From: crimsonsunset Date: Sat, 23 May 2026 10:42:48 -0600 Subject: [PATCH 21/48] chore(fmt): apply cargo fmt after validate sweep Autonomous decisions: - Commit fmt-only diffs from pnpm validate cargo fmt --all Signed-off-by: crimsonsunset --- .../src-tauri/src/commands/server_clone.rs | 7 +- crates/mcpmux-core/src/application/server.rs | 34 +++++---- .../workspace_binding_repository.rs | 9 ++- tests/rust/tests/integration/meta_tools.rs | 8 +-- tests/rust/tests/integration/server_clone.rs | 70 ++++++++++--------- 5 files changed, 65 insertions(+), 63 deletions(-) diff --git a/apps/desktop/src-tauri/src/commands/server_clone.rs b/apps/desktop/src-tauri/src/commands/server_clone.rs index 91344ba5..4b8c0778 100644 --- a/apps/desktop/src-tauri/src/commands/server_clone.rs +++ b/apps/desktop/src-tauri/src/commands/server_clone.rs @@ -23,12 +23,7 @@ pub async fn clone_server( 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(), - ) + .clone_server(space_uuid, &source_server_id, &suffix, alias.as_deref()) .await .map_err(|e| e.to_string()) } diff --git a/crates/mcpmux-core/src/application/server.rs b/crates/mcpmux-core/src/application/server.rs index 8e20458c..63477909 100644 --- a/crates/mcpmux-core/src/application/server.rs +++ b/crates/mcpmux-core/src/application/server.rs @@ -8,7 +8,9 @@ use std::sync::Arc; use tracing::{info, warn}; use uuid::Uuid; -use crate::domain::{DomainEvent, InstallationSource, InstalledServer, ServerDefinition, UserServerEntry}; +use crate::domain::{ + DomainEvent, InstallationSource, InstalledServer, ServerDefinition, UserServerEntry, +}; use crate::event_bus::EventSender; use crate::repository::{CredentialRepository, InstalledServerRepository, ServerFeatureRepository}; @@ -769,7 +771,8 @@ mod tests { assert_eq!(cloned.server_id, "posthog-mywork"); assert_eq!( - cloned.get_definition() + cloned + .get_definition() .and_then(|definition| definition.alias), Some("mywork".to_string()) ); @@ -829,7 +832,10 @@ mod tests { .expect("dependents lookup"); assert_eq!(dependents.len(), 2); - let ids: Vec<_> = dependents.iter().map(|server| server.server_id.as_str()).collect(); + let ids: Vec<_> = dependents + .iter() + .map(|server| server.server_id.as_str()) + .collect(); assert!(ids.contains(&"posthog-work")); assert!(ids.contains(&"posthog-personal")); } @@ -856,17 +862,15 @@ mod tests { .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() - ); + 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-storage/src/repositories/workspace_binding_repository.rs b/crates/mcpmux-storage/src/repositories/workspace_binding_repository.rs index 0e7077bc..d03b3774 100644 --- a/crates/mcpmux-storage/src/repositories/workspace_binding_repository.rs +++ b/crates/mcpmux-storage/src/repositories/workspace_binding_repository.rs @@ -140,8 +140,7 @@ impl SqliteWorkspaceBindingRepository { Ok(()) } - const SELECT_COLS: &'static str = - "id, workspace_root, label, space_id, created_at, updated_at"; + const SELECT_COLS: &'static str = "id, workspace_root, label, space_id, created_at, updated_at"; /// Fetch bindings + their FeatureSet lists in two queries. /// `where_clause` is appended to the binding SELECT (use `""` for none); @@ -376,7 +375,11 @@ mod tests { #[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 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(); diff --git a/tests/rust/tests/integration/meta_tools.rs b/tests/rust/tests/integration/meta_tools.rs index f8329b03..f75c23b4 100644 --- a/tests/rust/tests/integration/meta_tools.rs +++ b/tests/rust/tests/integration/meta_tools.rs @@ -371,12 +371,8 @@ async fn list_servers_includes_cloned_from_for_clone_installs() { 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).await.unwrap(); + let posthog_work = InstalledServer::new(&space_id, "posthog-work").with_cloned_from("posthog"); f.installed_server_repo .install(&posthog_work) .await diff --git a/tests/rust/tests/integration/server_clone.rs b/tests/rust/tests/integration/server_clone.rs index 4ced4658..2eb0bae1 100644 --- a/tests/rust/tests/integration/server_clone.rs +++ b/tests/rust/tests/integration/server_clone.rs @@ -5,8 +5,8 @@ use std::sync::Arc; use mcpmux_core::{ application::ServerAppService, EventBus, InstalledServer, InstalledServerRepository, - ServerDefinition, ServerDiscoveryService, ServerFeature, ServerFeatureRepository, - ServerSource, SpaceRepository, TransportConfig, TransportMetadata, + ServerDefinition, ServerDiscoveryService, ServerFeature, ServerFeatureRepository, ServerSource, + SpaceRepository, TransportConfig, TransportMetadata, }; use mcpmux_gateway::{FeatureService, PrefixCacheService, SessionOverrideRegistry}; use mcpmux_storage::{ @@ -38,21 +38,18 @@ impl CloneFixture { 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 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 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()), @@ -240,7 +237,10 @@ async fn two_clones_have_distinct_prefixes_and_env() { Some("work-account") ); assert_eq!( - stored_personal.input_values.get("ACCOUNT").map(String::as_str), + stored_personal + .input_values + .get("ACCOUNT") + .map(String::as_str), Some("personal-account") ); assert_eq!(clone_work.cloned_from.as_deref(), Some("posthog")); @@ -272,22 +272,18 @@ async fn uninstall_clone_does_not_affect_source() { .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() - ); + 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")] @@ -339,11 +335,19 @@ async fn clone_prefixes_do_not_break_existing_alias_uniqueness() { 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(&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_definition(&env_stdio_definition( + "posthog-work", + "PostHog (work)", + "work", + )) .with_cloned_from("posthog"); fixture.installed_server_repo.install(&clone).await.unwrap(); From 308963b64f69b1b098ed0d769f4b8ce3dd301f44 Mon Sep 17 00:00:00 2001 From: crimsonsunset Date: Sat, 23 May 2026 12:13:58 -0600 Subject: [PATCH 22/48] fix(desktop): restore custom titlebar window dragging Replace Electron-style -webkit-app-region with Tauri 2 data-tauri-drag-region and enable acceptFirstMouse for drag on unfocused macOS windows. Signed-off-by: crimsonsunset --- apps/desktop/src-tauri/tauri.conf.json | 3 ++- apps/desktop/src/App.tsx | 22 ++++++++++++------- apps/desktop/src/index.css | 5 ++++- .../ui/src/components/layout/AppShell.tsx | 4 ++-- 4 files changed, 22 insertions(+), 12 deletions(-) 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 cd953cb8..50351835 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -296,16 +296,22 @@ function AppContent() { ); const titleBar = ( -
- - - Mcp - Mux - -
+
+
+ + + Mcp + Mux + +
+
+ + {/* Copy All */} + {/* Clear Logs */}
{viewSpace && ( - +
+ {installedServers.length > 0 && ( + <> + + + + )} + +
)}
@@ -1321,8 +1377,11 @@ export function ServersPage() { )} - {/* Disable button - shown when enabled and connected/running */} - {server.enabled && (serverAction === 'running' || serverAction === 'connected_auto') && ( + {/* Disable button - enabled servers that are connected, idle, or stuck in error */} + {server.enabled && + (serverAction === 'running' || + serverAction === 'connected_auto' || + serverAction === 'error') && ( - {open && ( -
- {items.map((item) => ( - - ))} -
- )} -
+ + + + + + {items.map((item) => ( + openExternal(item.href)} + /> + ))} + + ); } 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/ServerActionMenu.tsx b/apps/desktop/src/features/servers/ServerActionMenu.tsx index fe85261f..6e0116e4 100644 --- a/apps/desktop/src/features/servers/ServerActionMenu.tsx +++ b/apps/desktop/src/features/servers/ServerActionMenu.tsx @@ -1,17 +1,11 @@ -/** - * 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, Copy } from 'lucide-react'; +import { + DropdownMenu, + DropdownMenuAction, + DropdownMenuContent, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@mcpmux/ui'; export interface ServerActionMenuProps { serverId: string; @@ -31,6 +25,9 @@ export interface ServerActionMenuProps { onUninstall: () => void; } +/** + * Overflow menu for per-server actions (configure, logs, uninstall, etc.). + */ export function ServerActionMenu({ serverId, serverName: _serverName, @@ -47,155 +44,63 @@ export function ServerActionMenu({ 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 */} - - - {/* Add another account - registry/manual installs only, not clones-of-clones */} - {canCloneAccount && onCloneAccount && ( - - )} - - {/* Separator */} -
- - {/* Uninstall - always visible, destructive */} - -
- )} -
+ + + + + {hasInputs && ( + + )} + {isEnabled && ( + + )} + {isOAuth && isEnabled && ( + + )} + + + {canCloneAccount && onCloneAccount && ( + + )} + + + + ); } diff --git a/apps/desktop/src/features/servers/ServersFiltersPopover.tsx b/apps/desktop/src/features/servers/ServersFiltersPopover.tsx new file mode 100644 index 00000000..5c803255 --- /dev/null +++ b/apps/desktop/src/features/servers/ServersFiltersPopover.tsx @@ -0,0 +1,144 @@ +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 0c390bf4..d7016e65 100644 --- a/apps/desktop/src/features/servers/ServersPage.tsx +++ b/apps/desktop/src/features/servers/ServersPage.tsx @@ -21,13 +21,24 @@ import { FolderOpen, UnfoldVertical, FoldVertical, + Search, } from 'lucide-react'; +import { Button, SearchField } from '@mcpmux/ui'; import { ServerActionMenu } from './ServerActionMenu'; import { CloneAccountModal } from './CloneAccountModal'; +import { AddServerMenu } from './AddServerMenu'; +import { ServersFiltersPopover } from './ServersFiltersPopover'; 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 { + groupFeaturesByServerId, + serverMatchesFilters, + type ServerActionKey, + type StatusFilterKey, + type TransportFilter, +} from './servers-page.helpers'; import type { ConnectionStatus, ServerStatusResponse } from '@/lib/api/serverManager'; import { getServerStatuses as fetchServerStatuses } from '@/lib/api/serverManager'; import { useViewSpace, useNavigateTo } from '@/stores'; @@ -195,6 +206,9 @@ interface ConfigModalState { export function ServersPage() { 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); @@ -412,6 +426,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 { @@ -552,6 +575,45 @@ export function ServersPage() { const expandableServerCount = installedServers.filter(isServerExpandable).length; const hasExpandedServers = expandedServers.size > 0; + /** 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); @@ -1048,82 +1110,105 @@ export function ServersPage() { /> )} {/* Header */} -
-
-

My Servers

-

- Manage your installed MCP servers -

-
- {viewSpace && ( -
- {installedServers.length > 0 && ( - <> - + )} +
+ + {viewSpace && ( +
+ {installedServers.length > 0 && ( + <> + - - - )} - -
- )} -
- - {/* Gateway Status */} -
-
-
- - - {gatewayRunning ? 'Gateway Running' : 'Gateway Stopped'} - - {gatewayRunning && ( - - {gatewayUrl} - - )} -
- {!gatewayRunning && ( - + + + )} + 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 */} @@ -1131,18 +1216,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}`; @@ -1166,21 +1260,33 @@ export function ServersPage() { > {/* Server Header */}
-
-
- {/* Expand/Collapse button for connected servers */} - {isConnected && ( - + )}
@@ -1280,7 +1386,11 @@ export function ServersPage() { Connection error ·
{/* Actions - horizontal row with primary and secondary actions */} -
+
event.stopPropagation()} + > {/* Primary action button */} {serverAction === 'enable' && ( + ); + } +); + +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..3097d7b9 --- /dev/null +++ b/packages/ui/src/components/common/HoverTooltip.tsx @@ -0,0 +1,49 @@ +import { type ReactNode } from 'react'; +import { cn } from '../../lib/cn'; + +export interface HoverTooltipProps { + children: ReactNode; + title: string; + lines?: string[]; + side?: 'top' | 'bottom'; + className?: string; + hidden?: boolean; + 'data-testid'?: string; +} + +/** + * Wraps a control and shows a tooltip panel on hover (hidden while `hidden` is true). + */ +export function HoverTooltip({ + children, + title, + lines = [], + side = 'top', + className, + hidden = false, + 'data-testid': testId, +}: HoverTooltipProps) { + return ( +
+ + {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/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..f27fc6ab 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 } 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'; From 621563309a5d2e4813163411cb11f6441f120a3e Mon Sep 17 00:00:00 2001 From: crimsonsunset Date: Mon, 25 May 2026 09:49:57 -0600 Subject: [PATCH 26/48] feat(desktop): add server count summary and smarter hover tooltips Show installed/connected/disabled/error counts beside My Servers, move filter clear-all outside the popover, and flip HoverTooltip placement from viewport space. Signed-off-by: crimsonsunset --- .../features/servers/ServersCountSummary.tsx | 35 ++++ .../servers/ServersFiltersPopover.tsx | 79 +++++---- .../src/features/servers/ServersPage.tsx | 14 +- .../features/servers/servers-page.helpers.ts | 70 ++++++++ .../ui/src/components/common/HoverTooltip.tsx | 155 +++++++++++++++++- packages/ui/src/index.ts | 2 +- 6 files changed, 303 insertions(+), 52 deletions(-) create mode 100644 apps/desktop/src/features/servers/ServersCountSummary.tsx 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 index 5c803255..9ad723ef 100644 --- a/apps/desktop/src/features/servers/ServersFiltersPopover.tsx +++ b/apps/desktop/src/features/servers/ServersFiltersPopover.tsx @@ -49,34 +49,46 @@ export function ServersFiltersPopover({ data-testid="servers-filters-tooltip" className="flex-shrink-0" > - - +
+ {activeCount > 0 && ( - - + )} + + + + +

Transport

@@ -121,24 +133,9 @@ export function ServersFiltersPopover({ Combine status filters (e.g. Connected + Error). All = no status filter.

- - {activeCount > 0 && ( - - )} - - + + +
); } diff --git a/apps/desktop/src/features/servers/ServersPage.tsx b/apps/desktop/src/features/servers/ServersPage.tsx index d7016e65..fdc486d0 100644 --- a/apps/desktop/src/features/servers/ServersPage.tsx +++ b/apps/desktop/src/features/servers/ServersPage.tsx @@ -28,11 +28,13 @@ import { ServerActionMenu } from './ServerActionMenu'; 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 { listServerFeatures, listServerFeaturesByServer } from '@/lib/api/serverFeatures'; import { + computeServerCountSummary, groupFeaturesByServerId, serverMatchesFilters, type ServerActionKey, @@ -574,6 +576,9 @@ export function ServersPage() { 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) => @@ -1112,8 +1117,13 @@ export function ServersPage() { {/* Header */}
-
-

My Servers

+
+
+

+ My Servers +

+ +

Manage your installed MCP servers

diff --git a/apps/desktop/src/features/servers/servers-page.helpers.ts b/apps/desktop/src/features/servers/servers-page.helpers.ts index c60ea45e..6976c450 100644 --- a/apps/desktop/src/features/servers/servers-page.helpers.ts +++ b/apps/desktop/src/features/servers/servers-page.helpers.ts @@ -144,6 +144,76 @@ export function countActiveServerFilters( /** * 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 diff --git a/packages/ui/src/components/common/HoverTooltip.tsx b/packages/ui/src/components/common/HoverTooltip.tsx index 3097d7b9..a17cff6f 100644 --- a/packages/ui/src/components/common/HoverTooltip.tsx +++ b/packages/ui/src/components/common/HoverTooltip.tsx @@ -1,39 +1,178 @@ -import { type ReactNode } from 'react'; +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[]; - side?: 'top' | 'bottom'; + /** 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 = 'top', + 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); + } + }} + >