Skip to content

feat: workspace-root-driven FeatureSet routing + Workspaces tab#151

Open
its-mash wants to merge 24 commits into
mainfrom
feat/workspace-root-routing
Open

feat: workspace-root-driven FeatureSet routing + Workspaces tab#151
its-mash wants to merge 24 commits into
mainfrom
feat/workspace-root-routing

Conversation

@its-mash
Copy link
Copy Markdown
Member

Summary

  • Routing rewrite: replace per-client FeatureSet grants and the active space / active feature set concepts with a workspace-root-driven model — a session's reported root maps to a (Space, FeatureSet) pair via WorkspaceBinding, with longest-prefix lookup and a fallback to the system default Space's Default FS.
  • Per-peer list_changed: when a session's resolution flips post-init (e.g. roots arrive after initialize and now match a binding), the gateway fires tools/prompts/resources/list_changed to that single peer so its tool list re-syncs without any user action.
  • Workspaces tab + inspector: new desktop UI showing live-reported roots alongside persisted bindings, with a native directory picker, debounced cross-platform path validation, and an inspector panel matching the FeatureSetPanel premium pattern (collapsible Mapping with auto-save on edit, collapsible Effective Features grouped by server with availability progress bars and type-coded feature rows).
  • Meta-tools shipped earlier on this branch: mcpmux_* self-management tools with native approval flow + audit log + master switch + grants UI.
  • rmcp upgraded to v1.5.

What changed

Backend

  • New WorkspaceBinding entity, repository (longest-prefix lookup, cross-platform normalization), and Tauri commands (list / create / update / delete / validate / get_workspace_effective_features).
  • New FeatureSetResolverService.resolve(session_id) -> (space, fs, source) with two tiers: binding match → default fallback.
  • New SessionRootsRegistry tracking per-session reported roots + last-resolved FS so we can detect resolution flips.
  • New MCPNotifier::notify_peer_lists_changed(client_id) bypassing the space-level hash dedup for per-session list-changed.
  • Dropped client_feature_set_grants table, the entire grant API surface, space.active_feature_set_id, set_active_space, and get_active_space.
  • Migrations 003 → 007 covering schema introduction, shadow-mode, authoritative cutover, mode columns, client-pin removal, FS-type collapse, and active_feature_set_id removal.

Desktop UI

  • New WorkspacesPage with status filter, search, and an inspector that mirrors the FeatureSetPanel aesthetic (border-2, gradient headers, icon-in-colored-box, count badges with selection-state colors, per-server availability progress bars, type-coded feature rows with Wrench/MessageSquare/FileText).
  • WorkspaceBindingSheet pops on first connect when reported roots are unmapped.
  • BindingForm auto-saves on edit (debounced + sequence-numbered) and uses an explicit Create button on new bindings.
  • SpaceSwitcher loses the Set Active affordance; system tray submenu drops its active-checkmark and renames to Switch Space.
  • New ConnectionCard owns the dashboard URL/start surface; new AutoStartConflictResolver handles port-busy on launch.
  • Meta-tool approval dialog + grants UI shipped earlier on the branch.

Tests

  • New workspace_binding_events.rs integration tests cover per-peer list_changed on resolution flip.
  • New e2e workspaces.wdio.ts spec.
  • appStore tests collapse activeSpaceId / viewSpaceId into one.
  • e2e helpers swap getActiveSpacegetDefaultSpace; comprehensive specs drop set-active flows.
  • Old feature_grants.rs integration tests deleted (model gone).

Test plan

  • cd mcp-mux && pnpm validate — fmt + clippy + eslint + typecheck clean
  • cd mcp-mux && pnpm test:rust — unit + integration green on Linux CI
  • cd mcp-mux && pnpm test:ts — vitest green
  • cd mcp-mux && pnpm test:e2e — desktop E2E (Windows + macOS + Linux)
  • Manual: connect a client (VS Code), confirm roots flow through to the Workspaces tab as live entries, configure a binding, confirm the client receives tools/list_changed and re-fetches with the bound FS's tools.
  • Manual: configure a binding, disconnect a backend server in that FS, confirm the Effective Features inspector shows the affected features dimmed with availability progress bar reflecting partial availability.
  • Manual: edit an existing binding's FS picker, confirm auto-save fires (Saving → Saved pill) and the next gateway resolution serves the new FS.
  • Manual: verify migrations 003-007 apply cleanly on a fresh install AND on an existing DB carrying old grant rows.

its-mash added 24 commits April 20, 2026 17:50
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]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant