Skip to content

Commit f29543a

Browse files
committed
feat(meta-tools): add mcpmux_list_servers read tool (Phase 2)
Server-level manifest with binding/session status per installed server. Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent e72b64e commit f29543a

6 files changed

Lines changed: 242 additions & 10 deletions

File tree

crates/mcpmux-gateway/src/server/service_container.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ impl ServiceContainer {
134134
feature_set_resolver.clone(),
135135
pool_services.feature_service.clone(),
136136
session_roots.clone(),
137+
session_overrides.clone(),
137138
approval_broker.clone(),
138139
domain_event_tx.clone(),
139140
deps.settings_repo.clone(),

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ pub fn build_default_registry(
5555
resolver: std::sync::Arc<crate::services::FeatureSetResolverService>,
5656
feature_service: std::sync::Arc<crate::pool::FeatureService>,
5757
session_roots: std::sync::Arc<crate::services::SessionRootsRegistry>,
58+
session_overrides: std::sync::Arc<crate::services::SessionOverrideRegistry>,
5859
approval_broker: std::sync::Arc<ApprovalBroker>,
5960
domain_event_tx: tokio::sync::broadcast::Sender<mcpmux_core::DomainEvent>,
6061
settings_repo: Option<std::sync::Arc<dyn mcpmux_core::AppSettingsRepository>>,
@@ -68,6 +69,7 @@ pub fn build_default_registry(
6869
resolver,
6970
feature_service,
7071
session_roots,
72+
session_overrides,
7173
approval_broker,
7274
domain_event_tx,
7375
settings_repo,
@@ -77,6 +79,7 @@ pub fn build_default_registry(
7779
// Reads — no approval needed.
7880
registry.register(Box::new(tools::ListAllToolsTool));
7981
registry.register(Box::new(tools::ListFeatureSetsTool));
82+
registry.register(Box::new(tools::ListServersTool));
8083
// Both `describe_resolution` and `describe_workspace` were removed by
8184
// user request — the read surface is just the two list_* tools above,
8285
// which an LLM can stitch into the same picture without an extra hop.

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ use tokio::sync::broadcast;
1919

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

2426
/// App-settings key that toggles the entire `mcpmux_*` namespace.
2527
/// Present + "false" → hidden; missing or anything else → enabled.
@@ -39,6 +41,7 @@ pub struct MetaToolContext {
3941
pub resolver: Arc<FeatureSetResolverService>,
4042
pub feature_service: Arc<FeatureService>,
4143
pub session_roots: Arc<SessionRootsRegistry>,
44+
pub session_overrides: Arc<SessionOverrideRegistry>,
4245
pub approval_broker: Arc<ApprovalBroker>,
4346
/// Broadcast domain events (e.g. ToolsChanged) so MCPNotifier can push
4447
/// `tools/list_changed` to connected peers after a write mutates state.

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

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@ use mcpmux_core::{
99
};
1010
use rmcp::model::{CallToolResult, Content};
1111
use serde_json::{json, Value};
12+
use std::collections::{HashMap, HashSet};
1213
use tokio::sync::broadcast;
1314
use tracing::info;
1415
use uuid::Uuid;
1516

1617
use super::approval::{ApprovalPayload, ApprovalScope};
1718
use super::registry::{MetaTool, MetaToolCall, MetaToolError};
19+
use crate::services::ResolvedFeatureSet;
1820

1921
/// Fire a `FeatureSetMembersChanged` event so MCPNotifier pushes a
2022
/// `tools/list_changed` notification to every connected client in the Space.
@@ -64,6 +66,33 @@ async fn caller_space_id(call: &MetaToolCall<'_>) -> Result<Uuid, MetaToolError>
6466
))
6567
}
6668

69+
/// Full resolver output for the caller — space + binding FS ids + source.
70+
async fn caller_resolution(call: &MetaToolCall<'_>) -> Result<ResolvedFeatureSet, MetaToolError> {
71+
call.ctx
72+
.resolver
73+
.resolve(call.session_id, Some(call.client_id))
74+
.await
75+
.map_err(|e| MetaToolError::Internal(e.to_string()))
76+
}
77+
78+
/// Derive the manifest status for one server in the caller's session.
79+
fn derive_server_status(
80+
server_id: &str,
81+
binding_servers: &HashSet<String>,
82+
session_enabled: &HashSet<String>,
83+
session_disabled: &HashSet<String>,
84+
) -> &'static str {
85+
if session_disabled.contains(server_id) {
86+
"disabled_via_session"
87+
} else if session_enabled.contains(server_id) && !binding_servers.contains(server_id) {
88+
"enabled_via_session"
89+
} else if binding_servers.contains(server_id) {
90+
"enabled_via_binding"
91+
} else {
92+
"inactive"
93+
}
94+
}
95+
6796
// ---------------------------------------------------------------------------
6897
// mcpmux_list_all_tools — read
6998
// ---------------------------------------------------------------------------
@@ -165,6 +194,103 @@ impl MetaTool for ListFeatureSetsTool {
165194
}
166195
}
167196

197+
// ---------------------------------------------------------------------------
198+
// mcpmux_list_servers — read
199+
// ---------------------------------------------------------------------------
200+
201+
pub struct ListServersTool;
202+
203+
#[async_trait]
204+
impl MetaTool for ListServersTool {
205+
fn name(&self) -> &'static str {
206+
"mcpmux_list_servers"
207+
}
208+
209+
fn description(&self) -> &'static str {
210+
"List every MCP server installed in the caller's resolved Space with \
211+
a coarse status per server: enabled_via_binding, enabled_via_session, \
212+
disabled_via_session, or inactive. Use before enable/disable to see \
213+
current routing state without loading every tool."
214+
}
215+
216+
fn input_schema(&self) -> Value {
217+
json!({ "type": "object", "properties": {} })
218+
}
219+
220+
async fn call(&self, call: MetaToolCall<'_>) -> Result<CallToolResult, MetaToolError> {
221+
let resolved = caller_resolution(&call).await?;
222+
let space_id = resolved
223+
.space_id
224+
.ok_or_else(|| MetaToolError::Internal("space missing".into()))?;
225+
226+
let binding_features = call
227+
.ctx
228+
.feature_service
229+
.resolve_feature_sets(&space_id.to_string(), &resolved.feature_set_ids)
230+
.await?;
231+
let binding_servers: HashSet<String> = binding_features
232+
.iter()
233+
.map(|f| f.server_id.clone())
234+
.collect();
235+
236+
let session_enabled = call
237+
.session_id
238+
.map(|sid| call.ctx.session_overrides.enabled_set(sid))
239+
.unwrap_or_default();
240+
let session_disabled = call
241+
.session_id
242+
.map(|sid| call.ctx.session_overrides.disabled_set(sid))
243+
.unwrap_or_default();
244+
245+
let features = call
246+
.ctx
247+
.server_feature_repo
248+
.list_for_space(&space_id.to_string())
249+
.await?;
250+
251+
let mut by_server: HashMap<String, (Option<String>, usize)> = HashMap::new();
252+
for feature in &features {
253+
if feature.feature_type != FeatureType::Tool {
254+
continue;
255+
}
256+
let entry = by_server
257+
.entry(feature.server_id.clone())
258+
.or_insert((None, 0));
259+
if entry.0.is_none() {
260+
entry.0 = feature.display_name.clone();
261+
}
262+
entry.1 += 1;
263+
}
264+
265+
let mut servers: Vec<Value> = by_server
266+
.into_iter()
267+
.map(|(id, (display_name, tool_count))| {
268+
let name = display_name.unwrap_or_else(|| id.clone());
269+
let status = derive_server_status(
270+
&id,
271+
&binding_servers,
272+
&session_enabled,
273+
&session_disabled,
274+
);
275+
json!({
276+
"id": id,
277+
"name": name,
278+
"tool_count": tool_count,
279+
"status": status,
280+
})
281+
})
282+
.collect();
283+
servers.sort_by(|a, b| {
284+
a.get("id")
285+
.and_then(|v| v.as_str())
286+
.unwrap_or("")
287+
.cmp(b.get("id").and_then(|v| v.as_str()).unwrap_or(""))
288+
});
289+
290+
Ok(text_result(json!({ "servers": servers })))
291+
}
292+
}
293+
168294
// ---------------------------------------------------------------------------
169295
// Writes — each goes through the ApprovalBroker before mutating state.
170296
// ---------------------------------------------------------------------------

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

Lines changed: 10 additions & 9 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 1 complete — SessionOverrideRegistry + list-path composition wired; Phases 2–5 pending
4+
**Status:** Phase 2 complete — `mcpmux_list_servers` read tool shipped; Phases 3–5 pending
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
@@ -187,17 +187,18 @@ Each write fires `tools/list_changed` per-peer via the existing `MCPNotifier::no
187187
- Server-level composition loads **all available** features for each effective `server_id` (not FS-partial tool subsets).
188188
- Fixed pre-existing DashMap deadlock in `SessionRootsRegistry::record_resolution` (`get` guard must not overlap `insert` on the same map).
189189

190-
### Phase 2 — `mcpmux_list_servers` read tool
190+
### Phase 2 — `mcpmux_list_servers` read tool
191191

192-
**Effort:** 1 evening
192+
**Effort:** 1 evening
193+
**Completed:** May 19, 2026
193194

194-
- Add `ListServersTool` unit struct + `MetaTool` impl in `meta_tools/tools.rs`.
195-
- Implementation: load `ServerFeature::list_for_space(caller_space_id)`, group by `server_id`, compute `tool_count = features.iter().filter(|f| f.feature_type == Tool).count()`, derive `status` per server by checking `binding`, `session_overrides.enabled`, `session_overrides.disabled` in order.
196-
- JSON schema: empty `properties` (no args).
197-
- Register in `build_default_registry` alongside the existing reads.
198-
- Integration test: connect a fake session, call `mcpmux_list_servers`, assert response shape includes `status` enum values for both bound and unbound servers.
195+
- [x] `ListServersTool` in `meta_tools/tools.rs` — groups `ServerFeature::list_for_space` by `server_id`, counts tools, derives status
196+
- [x] Status enum: `enabled_via_binding | enabled_via_session | disabled_via_session | inactive` (binding → session-enabled → session-disabled priority)
197+
- [x] `SessionOverrideRegistry` plumbed into `MetaToolContext` for status derivation
198+
- [x] Registered in `build_default_registry`
199+
- [x] Integration tests: inactive (no binding), `enabled_via_binding`, session override statuses
199200

200-
**Outcome:** An LLM calling `mcpmux_list_servers` from any session receives a server roster like `[{id: "github", name: "GitHub", tool_count: 24, status: "enabled_via_binding"}, {id: "firebase", name: "Firebase", tool_count: 18, status: "inactive"}, ...]`. No state mutation yet.
201+
**Outcome:** LLM calls `mcpmux_list_servers` and gets a server roster with per-server status. No state mutation.
201202

202203
### Phase 3 — `mcpmux_enable_server` / `mcpmux_disable_server` (session scope)
203204

tests/rust/tests/integration/meta_tools.rs

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ impl Fixture {
129129
resolver,
130130
feature_service.clone(),
131131
session_roots.clone(),
132+
session_overrides.clone(),
132133
broker.clone(),
133134
tx,
134135
None,
@@ -260,6 +261,100 @@ async fn list_feature_sets_returns_space_contents() {
260261
assert_eq!(sets.len(), 3, "Default + 2 custom expected");
261262
}
262263

264+
fn server_status(body: &Value, server_id: &str) -> String {
265+
body.get("servers")
266+
.unwrap()
267+
.as_array()
268+
.unwrap()
269+
.iter()
270+
.find(|s| s.get("id").and_then(|v| v.as_str()) == Some(server_id))
271+
.unwrap()
272+
.get("status")
273+
.unwrap()
274+
.as_str()
275+
.unwrap()
276+
.to_string()
277+
}
278+
279+
async fn bind_github_only_to_session_root(f: &Fixture) -> String {
280+
use mcpmux_core::WorkspaceBinding;
281+
282+
let fs_id = github_only_fs(f).await;
283+
let root = "/tmp/mcpmux-list-servers-test";
284+
f.session_roots.set_roots_capable(&f.session_id, true);
285+
f.session_roots.set(&f.session_id, [root]);
286+
let binding = WorkspaceBinding::new(
287+
normalize_workspace_root(root),
288+
f.space_id,
289+
fs_id.clone(),
290+
);
291+
f.binding_repo.create(&binding).await.unwrap();
292+
fs_id
293+
}
294+
295+
#[tokio::test(flavor = "multi_thread")]
296+
async fn list_servers_marks_unbound_servers_inactive() {
297+
let f = Fixture::new().await;
298+
let result = f
299+
.registry
300+
.call(
301+
"mcpmux_list_servers",
302+
&f.client_id,
303+
Some(&f.session_id),
304+
json!({}),
305+
)
306+
.await
307+
.unwrap();
308+
assert!(!Fixture::is_error(&result));
309+
let body = Fixture::result_json(&result);
310+
let servers = body.get("servers").unwrap().as_array().unwrap();
311+
assert_eq!(servers.len(), 2);
312+
assert_eq!(server_status(&body, "github"), "inactive");
313+
assert_eq!(server_status(&body, "firebase"), "inactive");
314+
}
315+
316+
#[tokio::test(flavor = "multi_thread")]
317+
async fn list_servers_shows_enabled_via_binding() {
318+
let f = Fixture::new().await;
319+
bind_github_only_to_session_root(&f).await;
320+
321+
let result = f
322+
.registry
323+
.call(
324+
"mcpmux_list_servers",
325+
&f.client_id,
326+
Some(&f.session_id),
327+
json!({}),
328+
)
329+
.await
330+
.unwrap();
331+
let body = Fixture::result_json(&result);
332+
assert_eq!(server_status(&body, "github"), "enabled_via_binding");
333+
assert_eq!(server_status(&body, "firebase"), "inactive");
334+
}
335+
336+
#[tokio::test(flavor = "multi_thread")]
337+
async fn list_servers_shows_session_override_statuses() {
338+
let f = Fixture::new().await;
339+
bind_github_only_to_session_root(&f).await;
340+
f.session_overrides.enable(&f.session_id, "firebase");
341+
f.session_overrides.disable(&f.session_id, "github");
342+
343+
let result = f
344+
.registry
345+
.call(
346+
"mcpmux_list_servers",
347+
&f.client_id,
348+
Some(&f.session_id),
349+
json!({}),
350+
)
351+
.await
352+
.unwrap();
353+
let body = Fixture::result_json(&result);
354+
assert_eq!(server_status(&body, "github"), "disabled_via_session");
355+
assert_eq!(server_status(&body, "firebase"), "enabled_via_session");
356+
}
357+
263358
// `describe_resolution` and `describe_workspace` were both removed at the
264359
// user's request — the read surface is now just `list_all_tools` and
265360
// `list_feature_sets`. Behavior previously asserted here is covered by
@@ -448,6 +543,7 @@ async fn registry_advertises_every_default_tool_with_annotations() {
448543
for expected in [
449544
"mcpmux_list_all_tools",
450545
"mcpmux_list_feature_sets",
546+
"mcpmux_list_servers",
451547
"mcpmux_create_feature_set",
452548
"mcpmux_bind_current_workspace",
453549
] {
@@ -525,6 +621,7 @@ async fn bare_registry(
525621
resolver,
526622
feature_service,
527623
SessionRootsRegistry::new(),
624+
SessionOverrideRegistry::new(),
528625
Arc::new(ApprovalBroker::new()),
529626
tx.clone(),
530627
settings_repo,
@@ -638,6 +735,7 @@ async fn master_switch_toggles_registry_visibility() {
638735
resolver,
639736
feature_service,
640737
SessionRootsRegistry::new(),
738+
SessionOverrideRegistry::new(),
641739
Arc::new(ApprovalBroker::new()),
642740
tx,
643741
Some(settings_repo.clone()),

0 commit comments

Comments
 (0)