Skip to content

Commit b29d5e8

Browse files
committed
feat(meta-tools): add workspace-scope enable/disable server tools (Phase 4)
Persist server-all FeatureSets on workspace bindings with approval; session list_changed only fires for session scope. Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent 0905d38 commit b29d5e8

6 files changed

Lines changed: 423 additions & 34 deletions

File tree

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -746,6 +746,11 @@ impl ServerHandler for McpMuxGatewayHandler {
746746
.arguments
747747
.map(|a| serde_json::to_value(a).unwrap_or(serde_json::Value::Null))
748748
.unwrap_or(serde_json::Value::Null);
749+
let scope = args
750+
.get("scope")
751+
.and_then(|v| v.as_str())
752+
.unwrap_or("session")
753+
.to_string();
749754
return match self
750755
.services
751756
.meta_tool_registry
@@ -756,7 +761,8 @@ impl ServerHandler for McpMuxGatewayHandler {
756761
if matches!(
757762
params.name.as_ref(),
758763
"mcpmux_enable_server" | "mcpmux_disable_server"
759-
) {
764+
) && scope == "session"
765+
{
760766
if let Some(sid) = session_id {
761767
self.notification_bridge
762768
.notify_session_lists_changed(sid)

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ pub mod approval;
2424
pub mod diff;
2525
mod registry;
2626
mod tools;
27+
mod workspace_server;
2728

2829
pub use approval::{
2930
ApprovalBroker, ApprovalDecision, ApprovalPayload, ApprovalPublisher, ApprovalRequest,

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

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ fn emit_tools_list_changed(event_tx: &broadcast::Sender<DomainEvent>, space_id:
3939
// Helpers
4040
// ---------------------------------------------------------------------------
4141

42-
fn text_result(v: Value) -> CallToolResult {
42+
pub(crate) fn text_result(v: Value) -> CallToolResult {
4343
CallToolResult::success(vec![Content::text(v.to_string())])
4444
}
4545

@@ -301,7 +301,7 @@ impl MetaTool for ListServersTool {
301301
/// mutation. Returns the broker's decision so the caller can proceed only
302302
/// on success. `mutate` is the thing that runs post-approval and is
303303
/// expected to emit `tools/list_changed` when relevant.
304-
async fn with_approval<F, Fut, T>(
304+
pub(crate) async fn with_approval<F, Fut, T>(
305305
call: &MetaToolCall<'_>,
306306
tool_name: &'static str,
307307
summary: String,
@@ -344,13 +344,11 @@ fn parse_string_arg(args: &Value, field: &str) -> Result<String, MetaToolError>
344344
.ok_or_else(|| MetaToolError::InvalidArgument(format!("missing `{field}`")))
345345
}
346346

347-
/// Parse `scope` — only `"session"` is implemented in Phase 3.
347+
/// Parse `scope` for enable/disable server tools.
348348
fn parse_scope(args: &Value) -> Result<&'static str, MetaToolError> {
349349
match args.get("scope").and_then(|v| v.as_str()) {
350350
None | Some("session") => Ok("session"),
351-
Some("workspace") => Err(MetaToolError::InvalidArgument(
352-
"workspace scope not yet implemented; see Phase 4".into(),
353-
)),
351+
Some("workspace") => Ok("workspace"),
354352
Some(other) => Err(MetaToolError::InvalidArgument(format!(
355353
"invalid scope '{other}'; expected 'session' or 'workspace'"
356354
))),
@@ -408,9 +406,9 @@ impl MetaTool for EnableServerTool {
408406
}
409407

410408
fn description(&self) -> &'static str {
411-
"Enable an MCP server for the current session only. The server's tools \
412-
appear on the next tools/list without changing workspace bindings. \
413-
Use mcpmux_list_servers first to see current status."
409+
"Enable an MCP server. Default scope is session (ephemeral). Use \
410+
scope: \"workspace\" to persist on the matched workspace binding \
411+
(requires approval). Use mcpmux_list_servers first."
414412
}
415413

416414
fn input_schema(&self) -> Value {
@@ -433,16 +431,22 @@ impl MetaTool for EnableServerTool {
433431
}
434432

435433
async fn call(&self, call: MetaToolCall<'_>) -> Result<CallToolResult, MetaToolError> {
436-
parse_scope(&call.args)?;
434+
let scope = parse_scope(&call.args)?;
437435
let server_id = parse_string_arg(&call.args, "server_id")?;
438436
let space_id = caller_space_id(&call).await?;
439437
validate_server_in_space(&call, space_id, &server_id).await?;
438+
439+
if scope == "workspace" {
440+
return super::workspace_server::enable_workspace_server(call, space_id, server_id)
441+
.await;
442+
}
443+
440444
let session_id = require_session_id(&call)?;
441445

442446
if session_overrides_require_approval(call.ctx).await {
443447
let overrides = call.ctx.session_overrides.clone();
444448
let server_id_for_closure = server_id.clone();
445-
let session_id_owned = session_id.to_string();
449+
let session_id_owned = session_id.clone();
446450
let summary = format!("Enable server '{server_id}' for this session");
447451
return with_approval(
448452
&call,
@@ -496,9 +500,10 @@ impl MetaTool for DisableServerTool {
496500
}
497501

498502
fn description(&self) -> &'static str {
499-
"Disable an MCP server for the current session only. Bound servers \
500-
are muted until re-enabled or the session ends. Use \
501-
mcpmux_list_servers to inspect status first."
503+
"Disable an MCP server. Default scope is session (ephemeral). Use \
504+
scope: \"workspace\" to remove the server-all layer from the \
505+
workspace binding (requires approval; custom FeatureSets must be \
506+
edited in the Workspaces UI)."
502507
}
503508

504509
fn input_schema(&self) -> Value {
@@ -521,16 +526,22 @@ impl MetaTool for DisableServerTool {
521526
}
522527

523528
async fn call(&self, call: MetaToolCall<'_>) -> Result<CallToolResult, MetaToolError> {
524-
parse_scope(&call.args)?;
529+
let scope = parse_scope(&call.args)?;
525530
let server_id = parse_string_arg(&call.args, "server_id")?;
526531
let space_id = caller_space_id(&call).await?;
527532
validate_server_in_space(&call, space_id, &server_id).await?;
533+
534+
if scope == "workspace" {
535+
return super::workspace_server::disable_workspace_server(call, space_id, server_id)
536+
.await;
537+
}
538+
528539
let session_id = require_session_id(&call)?;
529540

530541
if session_overrides_require_approval(call.ctx).await {
531542
let overrides = call.ctx.session_overrides.clone();
532543
let server_id_for_closure = server_id.clone();
533-
let session_id_owned = session_id.to_string();
544+
let session_id_owned = session_id.clone();
534545
let summary = format!("Disable server '{server_id}' for this session");
535546
return with_approval(
536547
&call,

0 commit comments

Comments
 (0)