Skip to content

Commit 5730b25

Browse files
committed
docs(meta-tools): close Phase 5 with README and planning reconciliation
Document the mcpmux_* manifest workflow in README, reconcile the planning doc for validation/PR gate, and fix stale DCR and notification dedup tests. Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent b590b0b commit 5730b25

12 files changed

Lines changed: 107 additions & 55 deletions

File tree

README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,28 @@ Not every AI client should have the same power. Create Feature Sets — permissi
113113

114114
![Feature Sets — granular per-server tool selection](docs/screenshots/featureset-detail.png)
115115

116+
### Self-Management Meta Tools (mcpmux_*)
117+
118+
Routing everything through one gateway endpoint means connected AI clients can see every backend tool at session start — even when the project only needs a handful. Workspace bindings pin stable per-folder toolsets, but they do not cover one-off needs ("enable Firebase for the next 15 minutes") or discovery-driven workflows where the LLM picks servers as it goes.
119+
120+
McpMux exposes a built-in `mcpmux_*` tool namespace so the LLM can introspect and reshape its own tool surface mid-conversation:
121+
122+
1. Call **`mcpmux_list_servers`** — server-level manifest with per-server status: `enabled_via_binding`, `enabled_via_session`, `disabled_via_session`, or `inactive`.
123+
2. Call **`mcpmux_enable_server`** or **`mcpmux_disable_server`** — toggle servers on or off. The gateway pushes `tools/list_changed` so the tool list refreshes without reconnecting.
124+
3. Use **`scope: "session"`** (default) for ephemeral overrides that die with the MCP session, or **`scope: "workspace"`** to persistently add/remove a server from the workspace binding (workspace writes always require approval).
125+
126+
| Tool | Type | Purpose |
127+
| ---- | ---- | ------- |
128+
| `mcpmux_list_all_tools` | read | Full tool roster in the resolved Space |
129+
| `mcpmux_list_feature_sets` | read | FeatureSets available in the resolved Space |
130+
| `mcpmux_list_servers` | read | Server-level manifest with status |
131+
| `mcpmux_enable_server` | write | Enable a server (session or workspace scope) |
132+
| `mcpmux_disable_server` | write | Disable a server (session or workspace scope) |
133+
| `mcpmux_create_feature_set` | write | Create a custom FeatureSet |
134+
| `mcpmux_bind_current_workspace` | write | Bind the session's workspace root to FeatureSets |
135+
136+
In the desktop app: **Settings → Self-management tools** toggles the whole namespace and optional approval for session-scope overrides. **Workspaces → live folder inspector → Active session overrides** shows per-session enabled/disabled servers and lets you clear overrides with one click.
137+
116138
### See and Manage Every Connected Client
117139

118140
Cursor, VS Code, Windsurf, Claude Code — see every AI client connected to your gateway in real time. Click any client to manage its workspace, grant or revoke feature sets, and see exactly which tools it can access. New clients authenticate via OAuth with a one-click approval flow.

apps/desktop/src-tauri/src/commands/session_overrides.rs

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,7 @@ fn build_dtos(gateway: &GatewayAppState) -> Vec<SessionOverrideDto> {
3737
let roots_by_session: std::collections::HashMap<String, Vec<String>> = gateway
3838
.session_roots
3939
.as_ref()
40-
.map(|reg| {
41-
reg.list_all_sessions()
42-
.into_iter()
43-
.collect()
44-
})
40+
.map(|reg| reg.list_all_sessions().into_iter().collect())
4541
.unwrap_or_default();
4642

4743
overrides
@@ -94,7 +90,10 @@ pub async fn clear_session_overrides(
9490
notifier.notify_session_lists_changed(&session_id).await;
9591
}
9692

97-
info!("[session_overrides] cleared overrides for session {}", session_id);
93+
info!(
94+
"[session_overrides] cleared overrides for session {}",
95+
session_id
96+
);
9897

9998
if let Err(e) = app_handle.emit(
10099
"session-overrides-changed",

crates/mcpmux-gateway/src/consumers/mcp_notifier.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1100,7 +1100,10 @@ impl MCPNotifier {
11001100
return;
11011101
};
11021102

1103-
if self.reap_dead_sessions(&[(session_id.to_string(), peer.clone())]).contains(&session_id.to_string()) {
1103+
if self
1104+
.reap_dead_sessions(&[(session_id.to_string(), peer.clone())])
1105+
.contains(&session_id.to_string())
1106+
{
11041107
return;
11051108
}
11061109

crates/mcpmux-gateway/src/mcp/handler.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -676,7 +676,11 @@ impl ServerHandler for McpMuxGatewayHandler {
676676
.services
677677
.pool_services
678678
.feature_service
679-
.get_tools_for_grants(&space_id.to_string(), &feature_set_ids, session_id_owned.as_deref())
679+
.get_tools_for_grants(
680+
&space_id.to_string(),
681+
&feature_set_ids,
682+
session_id_owned.as_deref(),
683+
)
680684
.await
681685
.map_err(|e| McpError::internal_error(format!("Failed to get tools: {}", e), None))?;
682686

crates/mcpmux-gateway/src/services/meta_tools/registry.rs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,7 @@ use tokio::sync::broadcast;
1919

2020
use super::approval::ApprovalBroker;
2121
use crate::pool::FeatureService;
22-
use crate::services::{
23-
FeatureSetResolverService, SessionOverrideRegistry, SessionRootsRegistry,
24-
};
22+
use crate::services::{FeatureSetResolverService, SessionOverrideRegistry, SessionRootsRegistry};
2523

2624
/// App-settings key that toggles the entire `mcpmux_*` namespace.
2725
/// Present + "false" → hidden; missing or anything else → enabled.

crates/mcpmux-gateway/src/services/meta_tools/tools.rs

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -386,11 +386,9 @@ async fn validate_server_in_space(
386386
}
387387

388388
fn require_session_id(call: &MetaToolCall<'_>) -> Result<String, MetaToolError> {
389-
call.session_id
390-
.map(|s| s.to_string())
391-
.ok_or_else(|| {
392-
MetaToolError::InvalidArgument("session scope requires an MCP session id".into())
393-
})
389+
call.session_id.map(|s| s.to_string()).ok_or_else(|| {
390+
MetaToolError::InvalidArgument("session scope requires an MCP session id".into())
391+
})
394392
}
395393

396394
// ---------------------------------------------------------------------------
@@ -472,9 +470,7 @@ impl MetaTool for EnableServerTool {
472470
.await;
473471
}
474472

475-
call.ctx
476-
.session_overrides
477-
.enable(&session_id, &server_id);
473+
call.ctx.session_overrides.enable(&session_id, &server_id);
478474
if let Ok(mut decision) = call.audit_decision.lock() {
479475
*decision = Some("session_override");
480476
}
@@ -567,9 +563,7 @@ impl MetaTool for DisableServerTool {
567563
.await;
568564
}
569565

570-
call.ctx
571-
.session_overrides
572-
.disable(&session_id, &server_id);
566+
call.ctx.session_overrides.disable(&session_id, &server_id);
573567
if let Ok(mut decision) = call.audit_decision.lock() {
574568
*decision = Some("session_override");
575569
}

crates/mcpmux-gateway/src/services/meta_tools/workspace_server.rs

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,10 @@ async fn resolve_workspace_binding(
2424
call: &MetaToolCall<'_>,
2525
space_id: Uuid,
2626
) -> Result<(WorkspaceBinding, String), MetaToolError> {
27-
let session_id = call
28-
.session_id
29-
.ok_or_else(|| MetaToolError::InvalidArgument("workspace scope requires an MCP session id".into()))?;
30-
let roots = call
31-
.ctx
32-
.session_roots
33-
.get(session_id)
34-
.unwrap_or_default();
27+
let session_id = call.session_id.ok_or_else(|| {
28+
MetaToolError::InvalidArgument("workspace scope requires an MCP session id".into())
29+
})?;
30+
let roots = call.ctx.session_roots.get(session_id).unwrap_or_default();
3531
let root = roots.into_iter().next().ok_or_else(|| {
3632
MetaToolError::InvalidArgument(
3733
"caller did not report any MCP roots; cannot resolve workspace".into(),

crates/mcpmux-gateway/src/services/session_roots.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,8 +159,7 @@ impl SessionRootsRegistry {
159159
if unchanged {
160160
return false;
161161
}
162-
self.last_resolution
163-
.insert(session_id.to_string(), new_val);
162+
self.last_resolution.insert(session_id.to_string(), new_val);
164163
true
165164
}
166165

docs/planning/dynamic-mcp-toggle-meta-tools.md

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Dynamic MCP Toggling via Meta Tools
22

33
**Last Updated:** May 19, 2026
4-
**Status:** Phase 5 complete — UI surface for session overrides + settings toggle
4+
**Status:** Feature complete — pending validation + PR
55
**Branch:** `feat/dynamic-mcp-toggle-meta-tools`
66
**Base branch:** `feat/workspace-root-routing` ([upstream PR #151](https://github.com/mcpmux/mcp-mux/pull/151))
77
**Issue:** TBD — file after planning review
@@ -162,8 +162,12 @@ Each write fires `tools/list_changed` per-peer via the existing `MCPNotifier::no
162162
| [`crates/mcpmux-gateway/src/consumers/mcp_notifier.rs`](../../crates/mcpmux-gateway/src/consumers/mcp_notifier.rs) | In the session-reap pass, also call `SessionOverrideRegistry::remove(session_id)` alongside `SessionRootsRegistry::remove`. |
163163
| [`crates/mcpmux-core/src/domain/event.rs`](../../crates/mcpmux-core/src/domain/event.rs) | No new variant — `MetaToolInvoked` already carries `decision: String`. Document `"session_override"` as a valid value in the doc comment. |
164164
| [`apps/desktop/src/features/workspaces/WorkspacesPage.tsx`](../../apps/desktop/src/features/workspaces/WorkspacesPage.tsx) | New "Active session overrides" sub-panel under the live-session inspector: per-session list of enabled / disabled server_ids with a "clear" button. |
165-
| [`apps/desktop/src-tauri/src/commands/workspace_binding.rs`](../../apps/desktop/src-tauri/src/commands/workspace_binding.rs) | New Tauri commands: `list_session_overrides(session_id)`, `clear_session_overrides(session_id)`. Read-only + clear; mutation happens via the MCP tool, not the UI. |
166-
| [`apps/desktop/src/lib/api/workspaceBindings.ts`](../../apps/desktop/src/lib/api/workspaceBindings.ts) | TS wrappers for the two new commands. |
165+
| [`apps/desktop/src-tauri/src/commands/session_overrides.rs`](../../apps/desktop/src-tauri/src/commands/session_overrides.rs) | Tauri commands: `list_session_overrides`, `clear_session_overrides`. Read-only + clear; mutation via MCP tools. |
166+
| [`apps/desktop/src-tauri/src/commands/settings.rs`](../../apps/desktop/src-tauri/src/commands/settings.rs) | `get/set_session_overrides_require_approval` settings commands. |
167+
| [`apps/desktop/src-tauri/src/commands/gateway.rs`](../../apps/desktop/src-tauri/src/commands/gateway.rs) | Wire `session_overrides` + `mcp_notifier` into `GatewayAppState` on gateway start. |
168+
| [`apps/desktop/src/lib/api/sessionOverrides.ts`](../../apps/desktop/src/lib/api/sessionOverrides.ts) | TS wrappers for session override commands + workspace root matching helpers. |
169+
| [`crates/mcpmux-gateway/src/server/mod.rs`](../../crates/mcpmux-gateway/src/server/mod.rs) | `session_overrides()`, `notification_bridge()` accessors; shared `MCPNotifier` instance. |
170+
| [`README.md`](../../README.md) | Self-Management Meta Tools feature subsection. |
167171

168172
---
169173

@@ -228,20 +232,39 @@ Each write fires `tools/list_changed` per-peer via the existing `MCPNotifier::no
228232

229233
**Outcome:** Workspace binding gains/loses persistent server-all FeatureSet layers via meta tools.
230234

231-
### Phase 5 — UI surface for session overrides
235+
### Phase 5 — UI surface for session overrides
232236

233237
**Effort:** 1 day
234238

235239
- [x] New "Active session overrides" sub-panel inside `WorkspacesPage.tsx`'s live-session inspector: lists per-session `enabled`/`disabled` server ids alongside the reported roots.
236240
- [x] "Clear all overrides" button per session — calls the new `clear_session_overrides` Tauri command. Useful when a session got into a weird state and the user wants a clean default-routing read.
237241
- [x] New Tauri commands: `list_session_overrides(session_id) -> { enabled: string[], disabled: string[] }`, `clear_session_overrides(session_id)`.
238242
- [x] Settings checkbox under Gateway settings: "Require approval for session-scope overrides" — wires to `gateway.session_overrides_require_approval`.
239-
- [ ] README + CHANGELOG entries describing the new meta-tools and the manifest-driven workflow.
243+
- [x] README section describing the new meta-tools and manifest-driven workflow ([README.md](../../README.md)).
244+
- [x] CHANGELOG — release-please from conventional `feat(meta-tools):` commits; no manual edit to `CHANGELOG.md`.
240245

241246
**Outcome:** From the Workspaces tab, a user can see at a glance "session abc123 has GitHub enabled (session) and Firebase disabled (session)" and clear them with one click. The new approval-required setting is discoverable in Gateway settings without reading docs.
242247

243248
---
244249

250+
## Pre-PR validation
251+
252+
Do **not** open a PR until all automated checks pass and the production build is verified manually.
253+
254+
| Step | Command | Purpose |
255+
| ---- | ------- | ------- |
256+
| Full validate | `pnpm validate` | fmt, clippy, check, eslint, typecheck |
257+
| Rust tests | `pnpm test:rust` | unit + integration (`meta_tools.rs`) |
258+
| TS tests | `pnpm test:ts` | vitest |
259+
| Production build | `pnpm build` | Tauri build on current platform |
260+
| Manual smoke (recommended) | Run app, exercise Workspaces overrides panel + Settings toggles | UX verification |
261+
262+
Optional (slow / env-dependent): `pnpm test:e2e`, `pnpm test:e2e:web`.
263+
264+
**PR target:** `feat/workspace-root-routing` (stacked on [PR #151](https://github.com/mcpmux/mcp-mux/pull/151)).
265+
266+
---
267+
245268
## Out of scope
246269

247270
| Item | Reason |
@@ -283,3 +306,9 @@ Each write fires `tools/list_changed` per-peer via the existing `MCPNotifier::no
283306
## Reconciliation
284307

285308
This doc is the source of truth for what gets built. When implementation completes, update the **Status** field at the top and reconcile any deviations (extra files, dropped phases, scope changes) per [`update-planning-md`](~/.cursor/commands/update-planning-md.md).
309+
310+
**May 19, 2026 closeout:**
311+
- Phases 1–5 implemented on `feat/dynamic-mcp-toggle-meta-tools`.
312+
- Tauri commands landed in `session_overrides.rs` (not `workspace_binding.rs` as originally planned).
313+
- CHANGELOG handled by release-please; README updated in-repo.
314+
- Pre-PR validation gate documented above; PR blocked until validate + tests + build pass.

tests/rust/tests/integration/meta_tools.rs

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -285,11 +285,7 @@ async fn bind_github_only_to_session_root(f: &Fixture) -> String {
285285
let root = "/tmp/mcpmux-list-servers-test";
286286
f.session_roots.set_roots_capable(&f.session_id, true);
287287
f.session_roots.set(&f.session_id, [root]);
288-
let binding = WorkspaceBinding::new(
289-
normalize_workspace_root(root),
290-
f.space_id,
291-
fs_id.clone(),
292-
);
288+
let binding = WorkspaceBinding::new(normalize_workspace_root(root), f.space_id, fs_id.clone());
293289
f.binding_repo.create(&binding).await.unwrap();
294290
fs_id
295291
}
@@ -486,11 +482,7 @@ async fn disable_server_workspace_removes_server_all_from_binding() {
486482

487483
let tools = f
488484
.feature_service
489-
.get_tools_for_grants(
490-
&f.space_id.to_string(),
491-
&binding.feature_set_ids,
492-
None,
493-
)
485+
.get_tools_for_grants(&f.space_id.to_string(), &binding.feature_set_ids, None)
494486
.await
495487
.unwrap();
496488
assert_eq!(tools.len(), 1);
@@ -502,7 +494,8 @@ async fn enable_server_workspace_requires_binding() {
502494
let f = Fixture::new().await;
503495
f.attach_auto_publisher(ApprovalDecision::AllowOnce);
504496
f.session_roots.set_roots_capable(&f.session_id, true);
505-
f.session_roots.set(&f.session_id, ["/tmp/unbound-workspace"]);
497+
f.session_roots
498+
.set(&f.session_id, ["/tmp/unbound-workspace"]);
506499

507500
let result = f
508501
.call_tool_as_handler_would(
@@ -992,7 +985,11 @@ async fn session_override_disable_mutes_bound_server() {
992985

993986
let before = f
994987
.feature_service
995-
.get_tools_for_grants(&f.space_id.to_string(), &[fs_id.clone()], Some(&f.session_id))
988+
.get_tools_for_grants(
989+
&f.space_id.to_string(),
990+
&[fs_id.clone()],
991+
Some(&f.session_id),
992+
)
996993
.await
997994
.unwrap();
998995
assert_eq!(before.len(), 1);
@@ -1021,7 +1018,8 @@ async fn session_override_additive_over_binding() {
10211018
.unwrap();
10221019

10231020
assert_eq!(tools.len(), 2);
1024-
let servers: std::collections::HashSet<_> = tools.iter().map(|t| t.server_id.as_str()).collect();
1021+
let servers: std::collections::HashSet<_> =
1022+
tools.iter().map(|t| t.server_id.as_str()).collect();
10251023
assert!(servers.contains("github"));
10261024
assert!(servers.contains("firebase"));
10271025
}

0 commit comments

Comments
 (0)