Skip to content

Commit 0905d38

Browse files
committed
feat(meta-tools): add session-scope enable/disable server tools (Phase 3)
mcpmux_enable_server and mcpmux_disable_server mutate SessionOverrideRegistry, audit as session_override when auto-allowed, and notify the calling session. Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent f29543a commit 0905d38

7 files changed

Lines changed: 486 additions & 61 deletions

File tree

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

Lines changed: 94 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1064,45 +1064,100 @@ impl MCPNotifier {
10641064
);
10651065

10661066
for (session_id, peer) in &live {
1067-
match peer.notify_tool_list_changed().await {
1068-
Ok(_) => debug!(
1069-
%session_id,
1070-
%client_id,
1071-
"[MCPNotifier] ✅ Sent tools/list_changed to session (per-client)"
1072-
),
1073-
Err(e) => warn!(
1074-
%session_id,
1075-
%client_id,
1076-
error = ?e,
1077-
"[MCPNotifier] failed tools/list_changed"
1078-
),
1079-
}
1080-
match peer.notify_prompt_list_changed().await {
1081-
Ok(_) => debug!(
1082-
%session_id,
1083-
%client_id,
1084-
"[MCPNotifier] ✅ Sent prompts/list_changed to session (per-client)"
1085-
),
1086-
Err(e) => warn!(
1087-
%session_id,
1088-
%client_id,
1089-
error = ?e,
1090-
"[MCPNotifier] failed prompts/list_changed"
1091-
),
1092-
}
1093-
match peer.notify_resource_list_changed().await {
1094-
Ok(_) => debug!(
1095-
%session_id,
1096-
%client_id,
1097-
"[MCPNotifier] ✅ Sent resources/list_changed to session (per-client)"
1098-
),
1099-
Err(e) => warn!(
1100-
%session_id,
1101-
%client_id,
1102-
error = ?e,
1103-
"[MCPNotifier] failed resources/list_changed"
1104-
),
1105-
}
1067+
self.send_all_lists_changed_to_peer(session_id, client_id, peer)
1068+
.await;
1069+
}
1070+
}
1071+
1072+
/// Send all three list_changed notifications to one session, bypassing
1073+
/// space-level hash dedup. Used after session-scoped override mutations
1074+
/// so only the calling session refreshes its tool list.
1075+
pub async fn notify_session_lists_changed(&self, session_id: &str) {
1076+
if DISABLE_ALL_NOTIFICATIONS {
1077+
trace!(
1078+
%session_id,
1079+
"[MCPNotifier] 🚫 disabled — skipping session list_changed"
1080+
);
1081+
return;
1082+
}
1083+
1084+
let snapshot: Option<(String, Arc<Peer<RoleServer>>)> = {
1085+
let sessions = self.sessions.read();
1086+
sessions.get(session_id).and_then(|entry| {
1087+
if entry.has_active_stream {
1088+
Some((entry.client_id.clone(), entry.peer.clone()))
1089+
} else {
1090+
None
1091+
}
1092+
})
1093+
};
1094+
1095+
let Some((client_id, peer)) = snapshot else {
1096+
debug!(
1097+
%session_id,
1098+
"[MCPNotifier] no active stream — skipping session list_changed"
1099+
);
1100+
return;
1101+
};
1102+
1103+
if self.reap_dead_sessions(&[(session_id.to_string(), peer.clone())]).contains(&session_id.to_string()) {
1104+
return;
1105+
}
1106+
1107+
info!(
1108+
%session_id,
1109+
%client_id,
1110+
"[MCPNotifier] 📤 session list_changed (override mutated)"
1111+
);
1112+
self.send_all_lists_changed_to_peer(session_id, &client_id, &peer)
1113+
.await;
1114+
}
1115+
1116+
/// Push tools/prompts/resources list_changed to a single peer.
1117+
async fn send_all_lists_changed_to_peer(
1118+
&self,
1119+
session_id: &str,
1120+
client_id: &str,
1121+
peer: &Peer<RoleServer>,
1122+
) {
1123+
match peer.notify_tool_list_changed().await {
1124+
Ok(_) => debug!(
1125+
%session_id,
1126+
%client_id,
1127+
"[MCPNotifier] ✅ Sent tools/list_changed to session"
1128+
),
1129+
Err(e) => warn!(
1130+
%session_id,
1131+
%client_id,
1132+
error = ?e,
1133+
"[MCPNotifier] failed tools/list_changed"
1134+
),
1135+
}
1136+
match peer.notify_prompt_list_changed().await {
1137+
Ok(_) => debug!(
1138+
%session_id,
1139+
%client_id,
1140+
"[MCPNotifier] ✅ Sent prompts/list_changed to session"
1141+
),
1142+
Err(e) => warn!(
1143+
%session_id,
1144+
%client_id,
1145+
error = ?e,
1146+
"[MCPNotifier] failed prompts/list_changed"
1147+
),
1148+
}
1149+
match peer.notify_resource_list_changed().await {
1150+
Ok(_) => debug!(
1151+
%session_id,
1152+
%client_id,
1153+
"[MCPNotifier] ✅ Sent resources/list_changed to session"
1154+
),
1155+
Err(e) => warn!(
1156+
%session_id,
1157+
%client_id,
1158+
error = ?e,
1159+
"[MCPNotifier] failed resources/list_changed"
1160+
),
11061161
}
11071162
}
11081163
}

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -752,7 +752,19 @@ impl ServerHandler for McpMuxGatewayHandler {
752752
.call(&params.name, &oauth_ctx.client_id, session_id, args)
753753
.await
754754
{
755-
Ok(result) => Ok(result),
755+
Ok(result) => {
756+
if matches!(
757+
params.name.as_ref(),
758+
"mcpmux_enable_server" | "mcpmux_disable_server"
759+
) {
760+
if let Some(sid) = session_id {
761+
self.notification_bridge
762+
.notify_session_lists_changed(sid)
763+
.await;
764+
}
765+
}
766+
Ok(result)
767+
}
756768
Err(e) => Ok(e.into_call_tool_result()),
757769
};
758770
}

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

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,10 @@ pub use approval::{
3030
ApprovalScope,
3131
};
3232
pub use diff::ToolDiff;
33-
pub use registry::{MetaToolContext, MetaToolError, MetaToolRegistry, META_TOOLS_ENABLED_KEY};
33+
pub use registry::{
34+
MetaToolContext, MetaToolError, MetaToolRegistry, META_TOOLS_ENABLED_KEY,
35+
SESSION_OVERRIDES_REQUIRE_APPROVAL_KEY,
36+
};
3437

3538
/// Every built-in tool's name must start with this prefix so the handler
3639
/// can intercept it before routing to backend servers.
@@ -80,10 +83,9 @@ pub fn build_default_registry(
8083
registry.register(Box::new(tools::ListAllToolsTool));
8184
registry.register(Box::new(tools::ListFeatureSetsTool));
8285
registry.register(Box::new(tools::ListServersTool));
83-
// Both `describe_resolution` and `describe_workspace` were removed by
84-
// user request — the read surface is just the two list_* tools above,
85-
// which an LLM can stitch into the same picture without an extra hop.
86-
// Writes — gated by ApprovalBroker.
86+
// Writes — gated by ApprovalBroker (or auto-allowed for session overrides).
87+
registry.register(Box::new(tools::EnableServerTool));
88+
registry.register(Box::new(tools::DisableServerTool));
8789
registry.register(Box::new(tools::CreateFeatureSetTool));
8890
registry.register(Box::new(tools::BindCurrentWorkspaceTool));
8991
std::sync::Arc::new(registry)

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

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
//! `tools/list` response.
66
77
use std::collections::HashMap;
8-
use std::sync::Arc;
8+
use std::sync::{Arc, Mutex};
99

1010
use async_trait::async_trait;
1111
use mcpmux_core::{
@@ -27,6 +27,11 @@ use crate::services::{
2727
/// Present + "false" → hidden; missing or anything else → enabled.
2828
pub const META_TOOLS_ENABLED_KEY: &str = "gateway.meta_tools_enabled";
2929

30+
/// When `"true"`, session-scope enable/disable routes through the approval
31+
/// broker. Default (missing / unparseable): auto-allow.
32+
pub const SESSION_OVERRIDES_REQUIRE_APPROVAL_KEY: &str =
33+
"gateway.session_overrides_require_approval";
34+
3035
/// Context injected into every meta-tool invocation.
3136
///
3237
/// Cheap to clone (all `Arc`s); the registry holds one and hands references
@@ -63,6 +68,9 @@ pub struct MetaToolCall<'a> {
6368
/// JSON arguments supplied in `CallToolRequestParams.arguments`.
6469
pub args: Value,
6570
pub ctx: &'a MetaToolContext,
71+
/// Write tools set this before returning `Ok` to override the default
72+
/// `"allow_once"` audit decision (e.g. `"session_override"`).
73+
pub audit_decision: Arc<Mutex<Option<&'static str>>>,
6674
}
6775

6876
/// Errors a meta tool can surface that map cleanly to `CallToolResult::error`.
@@ -223,16 +231,25 @@ impl MetaToolRegistry {
223231
.get(name)
224232
.ok_or_else(|| MetaToolError::InvalidArgument(format!("unknown meta tool: {name}")))?;
225233
let is_write = tool.is_write();
234+
let audit_decision = Arc::new(Mutex::new(None));
226235
let call = MetaToolCall {
227236
client_id,
228237
session_id,
229238
args: args.clone(),
230239
ctx: &self.ctx,
240+
audit_decision: audit_decision.clone(),
231241
};
232242
let result = tool.call(call).await;
233243

234244
let (decision, summary) = match &result {
235-
Ok(_) if is_write => ("allow_once", format!("{name} succeeded")),
245+
Ok(_) if is_write => (
246+
audit_decision
247+
.lock()
248+
.ok()
249+
.and_then(|g| *g)
250+
.unwrap_or("allow_once"),
251+
format!("{name} succeeded"),
252+
),
236253
Ok(_) => ("read", format!("{name} read")),
237254
Err(MetaToolError::ApprovalDenied) => ("deny", format!("{name} denied by user")),
238255
Err(MetaToolError::ApprovalTimedOut) => ("timeout", format!("{name} timed out")),

0 commit comments

Comments
 (0)