feat: workspace-root-driven FeatureSet routing + Workspaces tab#151
Open
its-mash wants to merge 24 commits into
Open
feat: workspace-root-driven FeatureSet routing + Workspaces tab#151its-mash wants to merge 24 commits into
its-mash wants to merge 24 commits into
Conversation
rmcp went from 0.17.0 to 1.x with a hard cutover: most of its public model and transport types picked up `#[non_exhaustive]`, `OAuthTokenResponse` switched from `EmptyExtraTokenFields` to `VendorExtraTokenFields`, and `StreamableHttpServerConfig` gained a required `allowed_hosts` field. This commit migrates every call site in the gateway, MCP adapter, credential store, and streamable-HTTP tests onto the new constructors (`Implementation::new`, `ClientInfo::new`, `ServerInfo::new`, `InitializeResult::new`, `CallToolRequestParams::new`, `ReadResourceResult::new`, `GetPromptRequestParams::new`, `OAuthClientConfig::new`, `StoredCredentials::new`, `CallToolResult::success` / `::error`, `AuthorizationSession::for_scope_upgrade`, `StreamableHttpServerConfig::default()`), bumps the `tests/rust` crate to the same rmcp 1.5 so only one version is linked into the workspace, and resolves a handful of latent clippy warnings (`op_ref` in keychain_dpapi test, unused `use super::*` in shell_env on Windows) that became reachable via `--all-targets`. Signed-off-by: Mohammod Al Amin Ashik <[email protected]>
Adds the storage surface for project-oriented FeatureSet selection. Behaviour
is unchanged at runtime; nothing reads the new columns yet. This lets later
commits plug in the resolver under a shadow-mode flag with zero risk to the
existing per-client grants path.
Migration 002 (forward-compatible additive only):
* inbound_clients.pinned_feature_set_id — chosen at approval time
* inbound_clients.pinned_space_id — backfilled from locked_space_id
* spaces.active_feature_set_id — backfilled from each space's
existing Default FS so day-one resolver behaviour matches today
* workspace_bindings table — (space_id, workspace_root) -> fs_id
Core:
* Space.active_feature_set_id
* Client.pinned_space_id + pinned_feature_set_id
* new WorkspaceBinding entity + normalize_workspace_root +
longest_prefix_match helpers (with Windows drive-letter folding,
file:// scheme stripping, percent-decode, trailing-separator trim)
* SpaceRepository::set_active_feature_set
* InboundMcpClientRepository::set_pin
* new WorkspaceBindingRepository trait (CRUD + find_longest_prefix_match)
Storage:
* SqliteSpaceRepository + SqliteInboundMcpClientRepository round-trip
the new columns
* SqliteWorkspaceBindingRepository + test that longest-prefix matching
picks the deepest binding when multiple candidates share prefixes
Old per-client grants (client_grants table, Client.grants field,
ConnectionMode enum, grant_feature_set/revoke_feature_set/etc.) remain
in place untouched — they'll be removed once the resolver flips out of
shadow mode.
Signed-off-by: Mohammod Al Amin Ashik <[email protected]>
…ode)
Introduces the runtime surface for the FeatureSet resolver v2.
Decisions are computed and logged on every initialize but NOT yet
enforced — the existing AuthorizationService::get_client_grants path
remains authoritative until a follow-up commit flips the switch.
New services:
* SessionRootsRegistry — DashMap keyed by mcp-session-id, stores
already-normalized workspace roots reported by the peer.
* FeatureSetResolverService — resolves one of:
Pin (client.pinned_feature_set_id)
WorkspaceBinding (longest-prefix match against session roots)
SpaceActive (space.active_feature_set_id fallback)
Deny (no pin / no binding / no active FS)
Gateway wiring:
* GatewayDependencies gains inbound_mcp_client_repo +
workspace_binding_repo, both wired to the SQLite repos so no DI
boilerplate is required at call sites.
* ServiceContainer exposes feature_set_resolver + session_roots.
* McpMuxGatewayHandler::on_initialized now:
- when the peer declared `roots` capability, spawns a task to call
peer.list_roots(), normalizes + stores the URIs in the registry,
then emits a shadow-mode log of the resolver's decision.
- when no roots are declared, resolves immediately against
pin / space-active so the shadow log still fires.
Peer is cloned via Arc so notifications continue to be delivered.
Normalization fix: normalize_workspace_root("") now returns "" so
SessionRootsRegistry::set can filter out empty inputs without needing
to know the target OS's root sentinel.
Shadow-mode log format (grep-friendly):
[FeatureSetResolver][shadow] resolved
client_id=… session_id=… feature_set_id=… source={Pin|WorkspaceBinding|SpaceActive|Deny}
Signed-off-by: Mohammod Al Amin Ashik <[email protected]>
Switches the gateway from per-client grants to the FeatureSetResolver
(pin > workspace binding > space-active FS) and lays the Tauri/UI
surface so users can actually drive the new model.
Enforcement flip:
* AuthorizationService::get_client_grants now delegates to
FeatureSetResolverService; `client_grants` table is no longer
consulted. Shadow-mode logging is removed in favour of the real path.
* Call sites in mcp/handler.rs (list_tools, list_prompts, list_resources,
get_prompt, read_resource, call_tool) and server/handlers.rs thread
`mcp-session-id` through so workspace-binding resolution works.
* Legacy repo methods (grant_feature_set / revoke_feature_set /
get_grants_for_space / get_all_grants / has_grants_for_space /
set_grants_for_space) are retained as no-ops for API compat — Tauri
commands, GrantService, PermissionAppService, ClientService, and
existing tests keep compiling without the dropped table.
Migration 003:
* DROP TABLE client_grants (the dead column `inbound_clients.grants`
is left for now to avoid a second schema bump on older SQLite;
unused in reads and writes already).
Domain/API:
* `AppState` gains `workspace_binding_repository`.
* SpaceService::set_active_feature_set.
* New Tauri commands:
- set_space_active_feature_set
- update_client_pin
- list_workspace_bindings
- list_workspace_bindings_for_space
- create_workspace_binding
- update_workspace_binding
- delete_workspace_binding
* TS bindings (`lib/api/spaces.ts`, `clients.ts`, `workspaceBindings.ts`)
expose the new fields + commands.
UI:
* FeatureSets page: each card gains an "Active" badge (green ring +
pill) when it's the Space's fallback, and a "Set Active" link in the
footer that swaps it optimistically.
* New Workspaces page (`features/workspaces/WorkspacesPage.tsx`):
lists bindings for the current Space, with a form to create one
(root + FeatureSet dropdown) and delete buttons per row. Paths
are normalized Rust-side before storage so Windows/Unix/file://
inputs all compare consistently.
Approval-dialog wiring-in (Step 3) is intentionally scoped down: the
backend now accepts `update_client_pin` on an existing client, which is
what the Connections UI will call once each approval needs to pick a
Space + optional FS pin. Full dialog restyle is deferred — the
underlying command + storage are ready.
Tests:
* New integration suite `integration::feature_set_resolver` with 8
tests over real SQLite-backed repos:
- falls through to space-active when no pin and no roots
- pin wins over space-active
- pin wins over workspace binding
- workspace binding beats space-active when no pin
- deny when no pin / no binding / no space-active
- longest-prefix wins across nested bindings
- falls through when roots don't match any binding
- deny for unknown client
* All existing integration/database/streamable_http/oauth tests
continue to pass (193 total).
Signed-off-by: Mohammod Al Amin Ashik <[email protected]>
Migration 003 drops the `client_grants` table and its repository methods are now no-op shims, so the 6 tests in `tests/database/inbound_client.rs` that exercised the legacy grant flow had nothing to assert against. Replaced with a pointer to the new resolver decision-table tests in `tests/integration/feature_set_resolver.rs`. Signed-off-by: Mohammod Al Amin Ashik <[email protected]>
…e approval
Exposes a small built-in toolset (`mcpmux_*`) alongside every backend
tool so LLMs can introspect and, with explicit user approval, reshape
their own session's FeatureSet. Enabled by default.
Read tools (no approval — always advertised):
* mcpmux_list_all_tools — unfiltered view across connected servers
* mcpmux_list_feature_sets — space's FSes w/ is_active + is_pinned
* mcpmux_describe_resolution — current FS + why (pin | binding | active)
* mcpmux_describe_workspace — reported MCP roots + matching binding
Write tools (each gated by native desktop approval + diff preview):
* mcpmux_pin_this_session — caller-scope, sets pinned_feature_set_id
* mcpmux_create_feature_set — compose a custom FS from qualified names
* mcpmux_bind_current_workspace — persistent WorkspaceBinding (space-wide)
* mcpmux_set_space_active — flips space fallback (affects everyone)
Gateway plumbing (`crates/mcpmux-gateway/src/services/meta_tools/`):
* MetaTool trait + MetaToolRegistry. Handler intercepts `mcpmux_*` before
routing, so meta tools are always visible regardless of the caller's
resolved FS.
* ApprovalBroker: session-scoped rate limit (10/min/client), oneshot
request/response with 60s default timeout, session-only "always allow"
cache keyed by (client_id, tool_name) — deliberately NOT persisted so
gateway restarts re-prompt.
* ToolDiff: before/after qualified-name comparison (via FeatureService)
so approval dialogs show "68 tools removed, 0 added" instead of
abstract FeatureSet names.
* Write path emits `FeatureSetMembersChanged` → MCPNotifier pushes
`tools/list_changed` → caller re-fetches the trimmed toolset in the
next turn.
Desktop (Tauri + React):
* `commands/meta_tool_approval.rs` Tauri commands:
- respond_to_meta_tool_approval(request_id, decision)
- list_meta_tool_grants / revoke_meta_tool_grant
* `start_gateway` attaches a publisher that emits
`meta-tool-approval-request` events to the frontend.
* `<MetaToolApprovalDialog>` — global React component (mounted once from
`App.tsx`). Renders the summary + affect-other-clients warning +
tool-list diff (+added / −removed, color-coded), with
[Allow once] / [Always for this session] / [Deny] buttons. Queues
concurrent requests.
* GatewayAppState gains `approval_broker: Option<Arc<ApprovalBroker>>`,
populated on gateway start.
Tests (20 new passing):
* `services::meta_tools::approval::tests` — 6 unit tests covering
always-allow short-circuit, publisher allow/deny/timeout, headless
no-desktop, and always-scope persistence across calls.
* `tests/integration/meta_tools.rs` — 14 end-to-end tests with real
SQLite repos + auto-approving publisher:
- list_all_tools / list_feature_sets / describe_resolution /
describe_workspace return correct payloads
- write w/o publisher → approval_required
- pin_this_session allow → pin persists; deny → unchanged
- always-allow decision bypasses subsequent publisher calls
- create_feature_set persists members only after approval
- bind_current_workspace fails without roots; normalizes on success
- set_space_active updates Space fallback
- invalid UUID arg rejected
- registry advertises all 8 tools with destructive_hint annotations
Other:
* `gateway_notifications::test_client_can_list_tools_after_notification`
updated to filter `mcpmux_*` from its "empty toolset" assertion — meta
tools are always present.
* Total test count: 9 (mcpmux), 123 (core), 104 (gateway lib incl. the
6 approval tests), 66 (integration incl. 14 meta-tool tests), plus
all existing suites green.
Signed-off-by: Mohammod Al Amin Ashik <[email protected]>
Closes out the deferred scope from the previous meta-tools commit.
Audit trail — DomainEvent::MetaToolInvoked
* Added to core DomainEvent enum with payload { client_id, session_id,
tool_name, decision, resolved_feature_set_id, summary }.
* MetaToolRegistry::call emits one event per invocation (read → "read";
write → "allow_once" | "deny" | "timeout" | "approval_required" |
"rate_limited" | "invalid_args" | "error"), so every tool is logged
without each tool having to remember to do it.
* Desktop domain-event bridge maps it to a new `meta-tool-invoked`
Tauri channel distinct from backend server notifications.
Master switch — `gateway.meta_tools_enabled`
* New setting key (default ON). Read via
MetaToolRegistry::is_enabled() which the MCP handler checks in both
list_tools (hide tools) and call_tool (fall through to feature-set
routing → "tool not found").
* Two new Tauri commands: get_meta_tools_enabled / set_meta_tools_enabled.
* Wired through dependencies.rs + service_container.rs +
build_default_registry — `settings_repo: Option<Arc<dyn
AppSettingsRepository>>` is threaded as a new context field.
Desktop UI (SettingsPage gains a Self-management Tools section)
* Master-switch toggle with copy explaining scope.
* <MetaToolGrantsPanel> — lists session-scoped "always-allow" grants
backed by list_meta_tool_grants / revoke_meta_tool_grant. Polls every
10s in case a dialog click or an external revoke changes state.
* <MetaToolAuditLog> — global listener for the `meta-tool-invoked`
event; ring-buffer of the last 50 calls rendered with per-decision
iconography (Eye/green/red/amber) and elapsed timestamps.
* lib/api/metaTools.ts — typed wrappers for the new commands +
respondToMetaToolApproval for tests.
E2E (WebDriverIO)
* tests/e2e/specs/meta-tools.wdio.ts — three specs:
- master-switch round-trips via Tauri invoke (settings page)
- grants panel + audit log mount in the settings section
- synthetic `meta-tool-approval-request` surfaces the dialog;
clicking Deny dismisses it. Covers the exact bridge tested in
production (event → React → respond_to_meta_tool_approval).
Rust integration tests (3 new, 17 passing total in the suite)
* read_tool_emits_meta_tool_invoked_with_decision_read — verifies
a read-tool call drops "read" on the bus.
* denied_write_emits_meta_tool_invoked_with_decision_deny — verifies
a write without publisher surfaces "approval_required" on the bus.
* master_switch_toggles_registry_visibility — flips the setting
on/off and confirms is_enabled() tracks it; missing key defaults on.
Test totals now: 9 (mcpmux), 123 (core), 104 (gateway lib), 79 (database),
31 (gateway) + 69 (integration incl. 17 meta-tool tests) + 16
(streamable_http) + 53 (oauth) + 17 (security) + mcpmux-mcp 7, all green.
pnpm validate: fmt + clippy + check + eslint + typecheck all clean.
Signed-off-by: Mohammod Al Amin Ashik <[email protected]>
…ding
Three UX fixes driven by first real-app testing.
FeatureSets page — Active state is now recognisable
* Top-right corner ribbon with a gradient (emerald → green), Zap icon,
uppercase "ACTIVE" label, and a soft outer glow. Hard to miss, reads
"premium" not "toast notification".
* Active cards get a green ring + emerald-tinted background wash + a
stronger drop shadow so the whole card feels elevated, not just badged.
* "Applied to this Space" caption replaces the footer Set-Active button
when a card is Active — users don't have to hunt for what the ribbon
means.
* New explainer banner above the grid ("One Active FeatureSet per Space…")
so the feature is self-documenting.
"Set Active" button is now obviously a button
* Proper pill with emerald border, hover-fill gradient, Zap icon. No
longer competes visually with "Configure".
* Loading state (spinner + "Activating…") fires during the Tauri
round-trip so a slow backend doesn't feel like a no-op.
* Triple event-guards on click (stopPropagation + preventDefault +
nativeEvent.stopImmediatePropagation) so no wrapping card onClick can
hijack the gesture. onMouseDown is also guarded.
* Success toast now says "{name} is now Active" + describes scope so
users know what they just did.
Clients page — empty state is a guided three-step onboarding
* Replaces the "No clients connected" card with a numbered walk-through:
1. Add mcpmux to your IDE (ConnectIDEs grid, embedded)
2. Restart / reconnect the MCP server in that IDE
3. Approve the connection here — "on this page" pill
* ConnectIDEs is reused from the Dashboard so users never leave the
page to finish the flow. Gateway URL + status are fetched on mount.
* When the gateway is stopped, an amber warning box appears inside the
card telling users it needs to be running, otherwise the IDE will
hang at `initialize` (the exact bug that motivated this fix).
* ConnectIDEs header description now reminds users to restart the MCP
server in the IDE after add, and that approval shows up in this app.
Cleanup
* Drop a stale `eslint-disable no-console` directive in
MetaToolApprovalDialog that the linter flagged as unused.
pnpm typecheck + lint clean; no new warnings introduced. Existing 31
warnings are all pre-existing useEffect dependency suppressions.
Signed-off-by: Mohammod Al Amin Ashik <[email protected]>
The previous empty-state flow lumped "Add to VS Code" (auto-install via
deep link) and "Copy config" (clipboard-only — user has to paste) into the
same numbered list, making users think clicking any card auto-installed.
The copy-paste tiles only put text on the clipboard; nothing happens in
the target IDE until the user pastes it into a config file and restarts.
Split into two labeled tracks on the Clients empty state:
**Track A — One-click** (VS Code, Cursor):
1. Click → Add to X — IDE opens and registers mcpmux
2. New chat / reload MCP
3. Approve here
**Track B — Manual** (Windsurf, Claude Code, JetBrains, Android Studio):
1. Click → Copy config / Copy command (clipboard)
2. Paste into the IDE's MCP config file (or run the command)
3. Restart / reload MCP in the IDE
4. Approve here
Also tightened the ConnectIDEs popover so each card's blurb tells the
user exactly what pressing the button will do + what they must do next:
* deep_link → "Opens the IDE and registers mcpmux automatically.
Start a new chat / reload MCP, then approve on the
Clients page."
* copy_command → "Copies a terminal command… Run it, restart the IDE,
approve on the Clients page."
* copy_config → "Copies a JSON snippet… Paste into the IDE's MCP
config file, restart the IDE, approve on the
Clients page."
Card header description now says up-front that VS Code + Cursor are
one-click and everything else copies a config. The "Copied!" tick now
reads "Copied — paste & restart" so users don't think the button
finished the job.
Signed-off-by: Mohammod Al Amin Ashik <[email protected]>
Replaces the dual-track "Auto / Manual" breakdown with one beginner-oriented
walkthrough that shows every time there are no connected clients. The dense
side-by-side A/B tracks assumed the user already understood what deep-link
vs. copy-config means; we were overexplaining to first-time users instead
of just pointing at the IDE grid and saying "pick one, follow its prompt."
* One welcome Card with "Let's hook up your first IDE" heading, lead
sentence framing mcpmux ("one connection your AI client uses to reach
every MCP server"), and three numbered steps:
1. Pick your IDE below and follow its prompt.
2. Open or restart the IDE.
3. Approve the connection right here.
* Per-IDE "what this button does" detail lives where it belongs —
inside the ConnectIDEs card popover, so users see it at the point of
action rather than up-front.
* No dismiss / localStorage flag. If the Clients page is empty, the
user hasn't finished onboarding yet; showing the walkthrough every
time is the right default.
* Amber "gateway is stopped" inline warning is preserved — it solves
the hang-at-initialize issue we hit earlier.
Also dropped now-unused `RotateCcw` + `CheckCircle2` icon imports.
Signed-off-by: Mohammod Al Amin Ashik <[email protected]>
Two distinct bugs in the IDE install flow, both fixed:
1. The popover anchored to the icon with `top-full mt-2`, opening downward.
In the Clients empty state the grid sits near the bottom of its Card,
so the action button ended up below the scroll viewport and users had
to scroll to find it. Flipped to `bottom-full mb-2` + repositioned the
arrow to the bottom edge; the popover now floats above the icon.
2. The per-card "what you do next" blurb was a switch on the action type
(`deep_link` vs `copy_config` vs `copy_command`), which papered over
real per-IDE differences. In particular the generic "restart the IDE"
wording was wrong for several clients and missed the one-click
tripwires that the generic "start a chat" path would never surface.
Replaced with a per-entry `nextStep` string, each one written from the
actual IDE flow:
* VS Code — auto-starts the server; user only needs to run
"MCP: Show Installed Servers" → Start if it doesn't
come up on its own.
* Cursor — explicit toggle required in Settings → MCP Tools;
does NOT auto-start newly-added servers.
* Windsurf — Cascade → MCP settings, paste + Refresh or reload.
* Claude Code — `claude mcp add` needs a new session; existing
sessions need /restart.
* JetBrains / Android Studio — AI Assistant MCP config only reads
on IDE startup, full restart required.
* JSON — generic reminder that each client's reload path differs.
The lingering "Copied — paste & restart" chip now reads "Copied —
paste & follow above" so the text doesn't contradict nextStep for
IDEs that require a toggle instead of a restart.
pnpm typecheck + lint clean; warning count unchanged at 31.
Signed-off-by: Mohammod Al Amin Ashik <[email protected]>
Users who hit a dead-end ("searched for a server that isn't in the
registry", "found a bug", "wanted to suggest a feature") had no clear
place to send that signal — they'd just close the app. This commit
adds three coordinated entry points, all wired to the mcpmux and
mcp-servers GitHub repos via the Tauri opener plugin.
New shared module `lib/contribute.ts`
* Centralises every external URL (main repo, servers repo, marketing
site, bug-report, feature-request, request-server templates).
* `CONTRIBUTE.requestServer(searchTerm?)` URL-encodes the search term
into the GitHub issue title so a user coming from the empty-search
state lands on a pre-populated form.
* `openExternal(url)` wraps `openUrl` with the same opener-plugin
fallback OAuthConsentModal uses.
New shared components `components/Contribute.tsx`
* `<RequestServerCTA searchTerm?>` — inline gradient card used on the
empty-search state. Two-button footer: Request (gh issue) +
Contribute (mcp-servers CONTRIBUTING.md).
* `<ContributeMenu>` — dropdown with Request new server / Report bug /
Suggest feature / Open on GitHub. Reusable anywhere; lives in the
Registry header today.
Placements:
* Registry page — ContributeMenu in the header (always visible), and
RequestServerCTA rendered in the empty-results state with the
active searchQuery threaded into the issue title.
* Settings — new "Contribute & feedback" card with a 2-column grid
of Request-server / Report-bug / Suggest-feature / Open-on-GitHub
tiles (ContributeRow helper local to this file).
All links open in the user's default browser via the opener plugin; no
in-webview navigation. `pnpm typecheck` + lint clean (warnings unchanged
at 31).
Signed-off-by: Mohammod Al Amin Ashik <[email protected]>
Replace per-client FeatureSet grants and the "active space" / "active feature set" concepts with a simpler model: a session's reported workspace root maps to a (Space, FeatureSet) pair via WorkspaceBinding; unmapped roots fall back to the system default Space and its Default FS. Backend - Drop client_feature_set_grants table and the entire grant API surface. - Drop space.active_feature_set_id, set_active_space, get_active_space. - New WorkspaceBinding entity + repository with longest-prefix lookup, cross-platform path normalization, and validation. - FeatureSetResolverService: resolve(session_id) -> (space, fs, source). - SessionRootsRegistry tracks per-session roots + last-resolved FS so the gateway can fire a per-peer tools/prompts/resources list_changed when a session's resolution flips post-init (roots arrive after initialize and now match a binding). - New get_workspace_effective_features command surfaces resolved FS members enriched with each backend's connection status. - Migrations 004 -> 007: workspace_bindings table, drop client pins, collapse FS types, drop space.active_feature_set_id. Desktop UI - New Workspaces tab: cards for live-reported roots + persisted bindings, native directory picker, debounced cross-platform path validation, status filter. - WorkspaceBindingSheet pops on first connect when roots are unmapped. - Inspector panel matches the FeatureSetPanel pattern: collapsible Mapping section (auto-save on edit, explicit submit on create) and collapsible Effective Features section grouping resolved members by server with availability progress bars and type-coded feature rows. - SpaceSwitcher loses the Set Active affordance; system tray submenu loses its active-checkmark and renames to "Switch Space". - New ConnectionCard component owns the dashboard URL/start surface. Tests - New workspace_binding_events integration tests cover per-peer list_changed on resolution flip. - appStore tests collapse activeSpaceId/viewSpaceId into one, e2e helpers swap getActiveSpace -> getDefaultSpace, comprehensive specs drop set-active flows. Signed-off-by: Mohammod Al Amin Ashik <[email protected]>
… tools
Two related bugs surfaced when Claude Code (DCR-registered) connected to a
Space whose workspace root binding pointed at a different Space.
1. List / call handlers ignored the resolver
`list_tools`, `list_prompts`, `list_resources`, `get_prompt`,
`read_resource`, and `call_tool` all read grants via the resolver but
then queried features from `oauth_ctx.space_id` — which is the OAuth-
bound space, not the WorkspaceBinding's target. Result: when a binding
pointed elsewhere, every list returned 0 features (the FS lives in the
resolved space; the OAuth-context space has no FS by that id).
Fix: new `McpMuxGatewayHandler::resolve_routing(session_id)` helper that
returns `(Uuid /* resolved space */, Vec<String> /* fs ids */)`. All
six handlers route through it and use the resolved space everywhere
downstream. The OAuth-context space_id is now only used by `oauth_ctx`
itself for upstream auth — not for routing.
2. Meta-tool dispatcher rejected DCR client ids
`call_tool`'s meta-tool fast path tried to parse `oauth_ctx.client_id`
as a `Uuid`, then handed the UUID to `MetaToolRegistry::call`. For
Claude Code (and any other DCR-registered client) `client_id` is the
`client_metadata` URL — not a UUID. Result: every `mcpmux_*` tool
call failed with `"bad client_id"` before the tool could run.
Fix: meta-tool client_id is now treated as opaque `&str` end-to-end:
- `MetaToolCall.client_id: &'a Uuid` → `&'a str`
- `MetaToolRegistry::call(_, &Uuid, _, _)` → `&str`
- `ApprovalBroker` switches its DashMap keys + every public method
to `String` / `&str`. Always-allow grants and rate-limit buckets
still use the same opaque identity, just typed correctly.
- `respond_to_meta_tool_approval` and `revoke_meta_tool_grant`
Tauri commands drop the `Uuid::parse_str` step.
Includes a regression test (`url_client_id_works`) using
`https://claude.ai/oauth/claude-code-client-metadata` as the client id
to lock the URL form in.
Signed-off-by: Mohammod Al Amin Ashik <[email protected]>
… popup
Two bugs around meta-tool write approvals.
1. Approval publisher missing on auto-start
Only the manual `start_gateway` Tauri command attached the broker
publisher; the lib.rs auto-start path (which runs on every desktop
app launch when auto-start is enabled — i.e. virtually always) never
did. Result: even with the desktop app fully running, every write
meta tool (`mcpmux_create_feature_set`, `mcpmux_bind_current_workspace`,
…) returned `approval_required: no desktop attached to mcpmux gateway`.
Factored the publisher wiring into
`commands::gateway::attach_approval_publisher` and called it from both
paths. Also added the missing `state.approval_broker = Some(...)` in
the auto-start block so the desktop's grants-list / revoke commands
can reach the broker too.
2. Popups rendered behind other windows
When a meta-tool approval request fired or a session reported a root
that needs binding, the dialog/sheet rendered in whichever window the
user wasn't focused on. Added `focus_main_window(&app)` at two
chokepoints:
- In the approval publisher closure, before emitting the
`meta-tool-approval-request` event.
- In the domain-event bridge, when forwarding
`WorkspaceNeedsBinding` (which triggers the binding sheet).
`unminimize` + `show` + `set_focus` covers minimized, hidden behind
another app, and tray-hidden states.
Signed-off-by: Mohammod Al Amin Ashik <[email protected]>
…t, badge denominator
* Collapse `mcpmux_describe_resolution` into `mcpmux_describe_workspace`.
The split surfaced two reads with overlapping output and shipped a redundant
tool to LLMs. `describe_workspace` now returns a `resolution` block with
`feature_set_id`, `feature_set_name`, `source`, and `resolved_tool_count`.
* Fire `list_changed` on `ServerStatusChanged(Connected)`, not just
Disconnected. Reconnect flips per-feature `is_available`, which
`get_all_features_for_space` filters on, so the content hash legitimately
changes both ways. Without this, a backend reconnect after the client's
initial `tools/list` left the client view stuck without the freshly-available
tools. Loop concern mooted by the existing hash dedup.
* `WorkspacesPage` per-server badge now reads `{mapped}/{server total}` —
backend returns `server_totals` (HashMap of server_id -> per-type counts)
computed before the FS filter is applied. The old `3/3` was
`mapped/mapped`; the new badge tells the user "this FS includes 3 of the
10 cloudflare-docs tools available."
* `WorkspacesPage` listens for `workspace-binding-changed` so popup-driven
binding saves refresh the page live (previously stayed on `UNMAPPED`
until the user navigated away and back).
* Migration 008 enforces the default-space invariant: the seeded `My Space`
row is the canonical default; every other row gets `is_default = 0`.
Repairs DBs corrupted by the older `if no spaces exist, set_default()`
branch (now removed from `SpaceAppService::create`).
Signed-off-by: Mohammod Al Amin Ashik <[email protected]>
User feedback: the describe_* surface was redundant — `list_all_tools` and `list_feature_sets` already give an LLM enough to introspect the resolved state, and trimming the toolbar reduces visual noise on the client side. * Remove `DescribeWorkspaceTool` from `tools.rs` and its registration in `meta_tools/mod.rs`. * Drop the two `describe_workspace_*` integration tests; the resolver behavior they exercised is already covered by the `feature_set_resolver` integration suite. * Re-target the audit-emission test to `mcpmux_list_all_tools` (still a read tool, same `decision = "read"` audit path). * Update `registry_advertises_every_default_tool_with_annotations` to assert both describe_* tools are NOT advertised. Signed-off-by: Mohammod Al Amin Ashik <[email protected]>
…tive wording
Two bugs the user surfaced after exercising the live tools:
1. **Wrong space.** All four meta tools (`list_all_tools`,
`list_feature_sets`, `create_feature_set`, `bind_current_workspace`)
went through `caller_space_id`, which always returned the *default*
Space — meaning a client routed via WorkspaceBinding into a non-default
Space could still read/write FSes in the default Space. The tools must
stay inside the Space the resolver actually picked for that caller, so
`caller_space_id` now consults `FeatureSetResolverService::resolve` and
uses the resolved `space_id`. Falls back to the default Space when the
resolver returns no binding match.
2. **Stale tool descriptions.** Several descriptions still referenced
long-gone concepts:
* `list_feature_sets` claimed an `is_active` / `is_pinned` field on
each entry — neither exists in the response, and both concepts have
been removed from the model. Description now lists the actual fields
(`id`, `name`, `description`, `type`, `is_builtin`).
* `create_feature_set` told callers to follow up with
`mcpmux_pin_this_session` or `mcpmux_set_space_active` — neither tool
exists. New text points at `mcpmux_bind_current_workspace`, which is
the one mechanism that actually makes a FeatureSet take effect.
* `list_all_tools` mentioned "before deciding which tools to pin" —
trimmed to "before composing a custom FeatureSet".
* `bind_current_workspace` removed the "unless they have an explicit
pin" carve-out.
Signed-off-by: Mohammod Al Amin Ashik <[email protected]>
…hed resolver
Replaces the resolver's permissive Tier-2 fallback (which silently routed
unbound sessions through the default Space's Default FeatureSet) with a
capability-branched four-tier model. Roots-capable sessions route via
WorkspaceBinding or pend; rootless clients route via per-client grants
restored from the pre-resolver-v2 design; everything else denies.
Resolver (crates/mcpmux-gateway/src/services/feature_set_resolver.rs):
Tier 1 roots reported + binding match -> binding.feature_set_ids
Tier 1b roots reported + no binding -> Deny + WorkspaceNeedsBinding
Tier 1c declared roots, none yet -> PendingRoots (empty)
Tier 2 client declared rootless -> client_grants for (client, space)
Tier 3 no signal -> Deny
ResolvedFeatureSet now carries Vec<String> so multi-FS bindings and
multi-grant clients fan into a single union allow set. fingerprint()
gives change-detection a stable key.
Storage:
009 restore client_grants table (junction client_id x space x FS)
010 inbound_clients.reports_roots
011 inbound_clients.roots_capability_known (tri-state UI)
012 workspace_binding_feature_sets junction; recreate workspace_bindings
without the legacy single feature_set_id column
013 feature_set_type 'default' -> 'starter'
014 rewrite the auto-seeded Starter FS's stale 'Default' / 'fallback
feature set for this space' copy (only when row still matches the
seed exactly so renamed FSes are untouched)
Notifier (mcp_notifier.rs):
client_peers -> sessions, keyed on mcp-session-id. Fanout consults the
same FeatureSetResolverService the request handlers use, so a session
redirected to a non-default Space via a binding is matched correctly.
New ClientGrantChanged DomainEvent wired through the GrantService write
path; per-peer push covers grant edits without a reconnect.
Capability:
on_initialized stamps SessionRootsRegistry::set_roots_capable for every
session and InboundClientRepository::mark_roots_capability sticky-
positively for every client (a one-off rootless reconnect from a
normally-rooted client doesn't bounce the badge). The Clients UI
hides the per-client grants section entirely except for explicitly-
rootless clients.
Multi-FS bindings:
WorkspaceBinding.feature_set_id (single) -> feature_set_ids: Vec<String>.
Tauri create/update commands accept arrays, validate non-empty, and
dedup while preserving operator-chosen order. Inspector DTO surfaces
the full FS list (binding_id + feature_sets[]); Workspaces page
multi-select picker with always-on search and max-h scrolling. Same
search treatment ported to the per-client grants list.
Default -> Starter rename (FeatureSetType, copy, helper):
The 'Default' name implied a routing fallback that no longer exists.
Renamed throughout (DB + enum + helpers + UI). The id prefix
fs_default_<space> is preserved for FK stability. parse('default')
still resolves to Starter so a stale read during the migration window
is harmless; isStarterFeatureSet() helper accepts both.
Verified: cargo check --workspace, cargo clippy --workspace --all-targets
-- -D warnings, pnpm typecheck.
Signed-off-by: Mohammod Al Amin Ashik <[email protected]>
…utosave Five fixes pulled from a session-mapping debugging pass. 1. on_initialized retries list_roots(). The single-shot peer.list_roots() in the spawned init task had no retry. A transient transport blip left the session at PendingRoots forever, and roots-capable sessions saw only the meta tools. Loop with backoffs 100ms / 300ms / 800ms / 2s / 5s = 6 attempts, ~8s budget, retrying only on transport errors (Ok([]) is a valid 'no folder open' answer the client will follow up with a roots/list_changed when it has one). 2. On-demand probe in list_tools / list_prompts / list_resources. If a roots-capable session hits a list request before its init list_roots() landed (the visible 'Claude shows only 4 meta tools after opening a new workspace' bug), fire a 300ms-budget probe here, populate session_roots, then resolve routing. SessionRootsRegistry gains claim_probe(sid, throttle) so a burst of three list calls doesn't fan out three upstream peer.list_roots() — only the first in any 1s window wins. 3. Migration 015 rewrites the *other* legacy seeded copy. Migration 014 only caught the 'The fallback feature set for this space' variant set by space_repository.rs. The Default Space's row was seeded by migration 001 itself with 'Features automatically granted to all connected clients in this space' — directly the opposite of what's true under resolver v3. 015 rewrites that string with the same is_builtin + name + description guard so any operator-customized copy survives. 4. Starter FSes are editable. The two member-modification guards in commands/feature_set.rs (add_feature_set_member, set_feature_set_members) rejected feature_set_type='starter' because they only accepted 'default' and 'custom'. Result: the auto-seeded Starter FS was read-only — useless. Both guards now accept 'starter' (and keep 'default' as a legacy alias for any stale read pre-migration-013). Comment updated. 5. WorkspaceBinding autosave debounce + flush-on-close. Bumped debounce 600ms to 1500ms to coalesce multi-FS toggle bursts into one save. Stricter dedupe (compares against last-saved, not just initial — re-toggling A → B → A is a true no-op). Most importantly, pending edits now survive panel close: a separate unmount-only useEffect reads pendingPayloadRef and posts the IPC immediately if there's unsaved work, so closing the sheet right after typing no longer drops the change. Latest onSubmit / onSaveStatusChange are kept in refs so the unmount handler uses the freshest closures. cargo check + clippy (-D warnings) + pnpm typecheck all clean. Signed-off-by: Mohammod Al Amin Ashik <[email protected]>
rmcp 1.5 already appends resource to the authorize and token requests, so the gateway's add_resource_parameter wrapper produced ?resource=...&resource=... Supabase's authorize endpoint rejects the repeated key with "resource: Expected string, received array". Signed-off-by: Mohammod Al Amin Ashik <[email protected]>
rmcp's ServerHandler doesn't expose a session-close callback and the streamable-HTTP session manager owns the close path internally, so we have no obvious place to call unregister_session. Without GC the sessions map grows unbounded across the gateway's lifetime — every reconnect leaves stale entries that fanout iterates and the resolver attempts to re-route. What rmcp *does* give us is `Peer<R>::is_transport_closed()`, which flips true once the underlying transport has terminated. Reap lazily: each fanout / per-peer push snapshots the live session list, scans for closed peers, and removes them from both `sessions` and the `feature_set_resolver`'s `SessionRootsRegistry` in one pass. After the sweep the regular routing loop runs against the cleaned snapshot. Three call sites covered: get_peers_for_space (broadcasts), get_peers_for_space_with_streams (the variant used by the per-type notify_*_list_changed), and notify_peer_lists_changed (per-client push for resolution flips and grant edits). Logged at info level when dead > 0 so a future spike is visible. Adds `FeatureSetResolverService::session_roots()` accessor so the notifier can keep the two registries in sync — they were drifting silently otherwise. cargo check + clippy (-D warnings) clean. Signed-off-by: Mohammod Al Amin Ashik <[email protected]>
The 'Sent tools/list_changed notification' debug line was anonymous — the design routes per-session correctly (each Peer<R> we hand to notify_*_list_changed is the one we stored in SessionEntry, tied to exactly one mcp-session-id), but the log didn't prove it. With six concurrent sessions across two clients, an audit needed cross- referencing peer pointers, which is impractical. Thread session_id + client_id through the send paths: - get_peers_for_space_with_streams now returns Vec<(session_id, client_id, peer)> instead of two parallel Vecs; the third element lets every send_*_list_changed call log who got the push. - send_tools_list_changed, send_prompts_list_changed, send_resources_list_changed iterate the triples and tag each ✅ / Failed line with both ids. - notify_peer_lists_changed (per-client fanout for resolution flips and grant edits) also tags each ✅ / failed line with session_id. Drops the now-dead get_peers_for_space (plain-Vec variant) and the SpaceResolverService field — both unused once every fanout path goes through the streams variant. Constructor signature trimmed accordingly; both call sites (server/mod.rs + the gateway- notifications integration test) updated. cargo check + clippy (-D warnings) clean. Signed-off-by: Mohammod Al Amin Ashik <[email protected]>
The boolean `claim_probe` rate-limiter let the first list request enter the probe path, but **followers in the same burst skipped the probe entirely** and resolved to PendingRoots immediately — returning empty *before* the first probe's `peer.list_roots()` came back. Trace from a Claude Code claude-vscode session opening a new workspace: 02:17:19.950 resolver: roots-capable, roots pending (req 1) 02:17:19.950 resolver: roots-capable, roots pending (req 2 — skipped probe) 02:17:19.973 on-demand probe populated roots (req 1's probe finally landed) Two of the three list responses (tools/list + prompts/list + resources/list, fired within 1ms by Claude Code at init) returned empty. The probe success at +23ms triggered a notifications/ tools/list_changed which the CLI variant honors but the VS Code extension doesn't refetch on, so the empty initial list is what the panel kept showing. Replace the boolean with a per-session `tokio::sync::Mutex` for single-flight semantics. First request acquires the lock, fires the probe, populates `session_roots`. Followers await the same lock; on acquire, they re-check `session_roots.get(sid)` and exit early since the predecessor already populated. Net result: one upstream `peer.list_roots()` call, three list responses with the correct routing decision. Kept the cool-down semantic too: `should_throttle_probe` + `mark_probe_completed` rate-limit *sequential* probe attempts when the previous one errored, so a peer that's failing list_roots doesn't get hammered. Distinct from the lock — the lock is concurrency, the throttle is failure recovery. Doesn't help the upstream client bug (Claude Code claude-vscode doesn't refetch on tools/list_changed) but means the *first* response is correct, which is the only one some clients ever read. cargo check + clippy (-D warnings) clean. Signed-off-by: Mohammod Al Amin Ashik <[email protected]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
(Space, FeatureSet)pair viaWorkspaceBinding, with longest-prefix lookup and a fallback to the system default Space's Default FS.list_changed: when a session's resolution flips post-init (e.g. roots arrive afterinitializeand now match a binding), the gateway firestools/prompts/resources/list_changedto that single peer so its tool list re-syncs without any user action.FeatureSetPanelpremium pattern (collapsible Mapping with auto-save on edit, collapsible Effective Features grouped by server with availability progress bars and type-coded feature rows).mcpmux_*self-management tools with native approval flow + audit log + master switch + grants UI.What changed
Backend
WorkspaceBindingentity, repository (longest-prefix lookup, cross-platform normalization), and Tauri commands (list / create / update / delete / validate / get_workspace_effective_features).FeatureSetResolverService.resolve(session_id) -> (space, fs, source)with two tiers: binding match → default fallback.SessionRootsRegistrytracking per-session reported roots + last-resolved FS so we can detect resolution flips.MCPNotifier::notify_peer_lists_changed(client_id)bypassing the space-level hash dedup for per-session list-changed.client_feature_set_grantstable, the entire grant API surface,space.active_feature_set_id,set_active_space, andget_active_space.active_feature_set_idremoval.Desktop UI
WorkspacesPagewith status filter, search, and an inspector that mirrors theFeatureSetPanelaesthetic (border-2, gradient headers, icon-in-colored-box, count badges with selection-state colors, per-server availability progress bars, type-coded feature rows withWrench/MessageSquare/FileText).WorkspaceBindingSheetpops on first connect when reported roots are unmapped.BindingFormauto-saves on edit (debounced + sequence-numbered) and uses an explicit Create button on new bindings.SpaceSwitcherloses the Set Active affordance; system tray submenu drops its active-checkmark and renames to Switch Space.ConnectionCardowns the dashboard URL/start surface; newAutoStartConflictResolverhandles port-busy on launch.Tests
workspace_binding_events.rsintegration tests cover per-peerlist_changedon resolution flip.workspaces.wdio.tsspec.appStoretests collapseactiveSpaceId/viewSpaceIdinto one.getActiveSpace→getDefaultSpace; comprehensive specs drop set-active flows.feature_grants.rsintegration tests deleted (model gone).Test plan
cd mcp-mux && pnpm validate— fmt + clippy + eslint + typecheck cleancd mcp-mux && pnpm test:rust— unit + integration green on Linux CIcd mcp-mux && pnpm test:ts— vitest greencd mcp-mux && pnpm test:e2e— desktop E2E (Windows + macOS + Linux)tools/list_changedand re-fetches with the bound FS's tools.