diff --git a/config/config.example.toml b/config/config.example.toml index 8926cd465..293cab909 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -153,7 +153,7 @@ # These are product knobs, not secrets/URLs/runtime-discovery values, so keep # them here rather than in .env. # enabled = false # default: false; advertises `codemode` -# mcp_ui_enabled = true # default: true; advertises `codemode_ui` and its app resources +# mcp_ui_enabled = false # default: false; opt in to `codemode_ui` and its app resources # trace_params = true # default: true; stores only redacted/capped call params in traces/history # `codemode` runs a JS async arrow function in the Javy/QuickJS sandbox with # in-sandbox discovery (`codemode.search()` / `codemode.describe()`), raw @@ -171,13 +171,15 @@ [mcp_apps] # Gateway-wide visibility switches for Labby-owned app surfaces other than the -# always-on `mcp_app` manager. Disabling a surface removes its app advertisement -# and UI resources; the underlying text/service capability remains available -# where one exists. The Code Mode inspector keeps its legacy switch above. -# add_server = true # default: true -# server_logs = true # default: true -# gateway_status = true # default: true -# settings = true # default: true +# always-available `mcp_app` control tool. Disabling a surface removes its app +# advertisement and UI resources; the underlying text/service capability remains +# available where one exists. The control tool remains text-only when its own UI +# is disabled. The Code Mode inspector keeps its legacy switch above. +# manager = false # default: false; opt in to the switchboard UI +# add_server = false # default: false; opt in when the app is ready for your deployment +# server_logs = false # default: false; underlying service tool remains available +# gateway_status = false # default: false +# settings = false # default: false # ── Code Mode `openapi` provider ───────────────────────────────────────────── # diff --git a/crates/labby-gateway/src/gateway/manager/config_ops.rs b/crates/labby-gateway/src/gateway/manager/config_ops.rs index e98767498..f7f0d7461 100644 --- a/crates/labby-gateway/src/gateway/manager/config_ops.rs +++ b/crates/labby-gateway/src/gateway/manager/config_ops.rs @@ -530,12 +530,14 @@ impl GatewayManager { let durable_previous = self.load_config_for_mutation().await?; let mut cfg = durable_previous.clone(); match target { + "manager" => cfg.mcp_apps.manager = enabled, "codemode" => cfg.code_mode.mcp_ui_enabled = enabled, "gateway_status" => cfg.mcp_apps.gateway_status = enabled, "server_logs" => cfg.mcp_apps.server_logs = enabled, "add_server" => cfg.mcp_apps.add_server = enabled, "settings" => cfg.mcp_apps.settings = enabled, "all" => { + cfg.mcp_apps.manager = enabled; cfg.code_mode.mcp_ui_enabled = enabled; cfg.mcp_apps.gateway_status = enabled; cfg.mcp_apps.server_logs = enabled; @@ -577,6 +579,7 @@ impl GatewayManager { action = "gateway.mcp_apps.set", target, enabled, + manager = current.mcp_apps.manager, code_mode = current.code_mode.mcp_ui_enabled, add_server = current.mcp_apps.add_server, server_logs = current.mcp_apps.server_logs, diff --git a/crates/labby-gateway/src/gateway/manager/tests/config_ops.rs b/crates/labby-gateway/src/gateway/manager/tests/config_ops.rs index c3e758e91..cd238799a 100644 --- a/crates/labby-gateway/src/gateway/manager/tests/config_ops.rs +++ b/crates/labby-gateway/src/gateway/manager/tests/config_ops.rs @@ -857,9 +857,14 @@ async fn mcp_app_visibility_setting_persists_notifies_and_skips_pool_rebuild() { let mut manager = GatewayManager::new(path.clone(), runtime.clone()); let (notify_tx, mut notify_rx) = tokio::sync::mpsc::unbounded_channel(); manager.set_notifier(crate::gateway::types::CatalogChangeNotifier::new(notify_tx)); - manager - .seed_config_unchecked_for_tests(GatewayConfig::default()) - .await; + let mut initial = GatewayConfig::default(); + initial.code_mode.mcp_ui_enabled = true; + initial.mcp_apps.manager = true; + initial.mcp_apps.gateway_status = true; + initial.mcp_apps.server_logs = true; + initial.mcp_apps.add_server = true; + initial.mcp_apps.settings = true; + manager.seed_config_unchecked_for_tests(initial).await; assert!(runtime.current_pool().await.is_none()); let updated = manager @@ -871,6 +876,7 @@ async fn mcp_app_visibility_setting_persists_notifies_and_skips_pool_rebuild() { .await .expect("persist MCP App visibility"); + assert!(!updated.mcp_apps.manager); assert!(!updated.code_mode.mcp_ui_enabled); assert!(!updated.mcp_apps.gateway_status); assert!(!updated.mcp_apps.server_logs); @@ -899,6 +905,7 @@ async fn mcp_app_visibility_setting_persists_notifies_and_skips_pool_rebuild() { ); let persisted = load_gateway_config(&path).expect("load persisted config"); + assert!(!persisted.mcp_apps.manager); assert!(!persisted.code_mode.mcp_ui_enabled); assert!(!persisted.mcp_apps.gateway_status); assert!(!persisted.mcp_apps.server_logs); @@ -909,6 +916,7 @@ async fn mcp_app_visibility_setting_persists_notifies_and_skips_pool_rebuild() { restarted.seed_config_unchecked_for_tests(persisted).await; assert!(!restarted.code_mode_app_state().is_enabled()); let restarted_apps = restarted.mcp_apps_config().await; + assert!(!restarted_apps.manager); assert!(!restarted_apps.gateway_status); assert!(!restarted_apps.server_logs); assert!(!restarted_apps.add_server); diff --git a/crates/labby-gateway/src/upstream/pool.rs b/crates/labby-gateway/src/upstream/pool.rs index eae1404a0..c3bd70770 100644 --- a/crates/labby-gateway/src/upstream/pool.rs +++ b/crates/labby-gateway/src/upstream/pool.rs @@ -107,7 +107,10 @@ pub use notifications::UpstreamNotificationEvent; pub use oauth_invalidation::OAuthSessionInvalidation; pub(crate) use stdio_stderr::install_upstream_stderr_level_default; pub use task_route::TaskRouteAuthorization; -pub use tools::{MAX_UPSTREAM_TOOLS, tool_has_mcp_app_ui_resource, tool_is_mcp_app_host_visible}; +pub use tools::{ + MAX_UPSTREAM_TOOLS, mcp_tool_is_mcp_app_host_visible, tool_has_mcp_app_ui_resource, + tool_is_mcp_app_host_visible, +}; // Catalog size caps are used by pool child modules directly via `super::tools::*`. // No external consumer references them through this path, so no `pub use` needed. diff --git a/crates/labby-gateway/src/upstream/pool/tools.rs b/crates/labby-gateway/src/upstream/pool/tools.rs index 38ce66774..5014f1a07 100644 --- a/crates/labby-gateway/src/upstream/pool/tools.rs +++ b/crates/labby-gateway/src/upstream/pool/tools.rs @@ -838,11 +838,18 @@ pub fn tool_has_mcp_app_ui_resource(tool: &UpstreamTool) -> bool { tool_mcp_app_ui_resource_uri(tool).is_some() } -pub fn tool_is_mcp_app_host_visible(tool: &UpstreamTool) -> bool { - if tool_has_mcp_app_ui_resource(tool) { +pub fn mcp_tool_is_mcp_app_host_visible(tool: &rmcp::model::Tool) -> bool { + let owns_ui_resource = tool + .meta + .as_ref() + .and_then(|meta| meta.0.get("ui")) + .and_then(|ui| ui.get("resourceUri")) + .and_then(Value::as_str) + .is_some_and(|uri| uri.starts_with("ui://")); + if owns_ui_resource { return true; } - let Some(meta) = tool.tool.meta.as_ref() else { + let Some(meta) = tool.meta.as_ref() else { return false; }; let app_visible = meta @@ -859,6 +866,10 @@ pub fn tool_is_mcp_app_host_visible(tool: &UpstreamTool) -> bool { app_visible || openai_widget_callback } +pub fn tool_is_mcp_app_host_visible(tool: &UpstreamTool) -> bool { + mcp_tool_is_mcp_app_host_visible(&tool.tool) +} + #[cfg(test)] mod tests { use std::collections::BTreeSet; diff --git a/crates/labby-runtime/src/catalog_notify.rs b/crates/labby-runtime/src/catalog_notify.rs index 79b5674f8..80f268860 100644 --- a/crates/labby-runtime/src/catalog_notify.rs +++ b/crates/labby-runtime/src/catalog_notify.rs @@ -40,7 +40,7 @@ pub const SOURCE_GATEWAY_ENRICH_HINT: &str = "gateway.enrich.hint_apply"; /// invalidate a client binding mid-turn. pub const SOURCE_MCP_CALL_CODEMODE: &str = "mcp.call.codemode"; -/// Catalog delta produced by the always-on `mcp_app` manager tool. +/// Catalog delta produced by the always-available `mcp_app` control tool. pub const SOURCE_MCP_CALL_MCP_APP: &str = "mcp.call.mcp_app"; /// Post-call catalog delta observed by a raw upstream proxy call. Same diff --git a/crates/labby-runtime/src/code_mode_app.rs b/crates/labby-runtime/src/code_mode_app.rs index e9c55a68e..829de1780 100644 --- a/crates/labby-runtime/src/code_mode_app.rs +++ b/crates/labby-runtime/src/code_mode_app.rs @@ -13,7 +13,7 @@ pub struct CodeModeAppState { impl Default for CodeModeAppState { fn default() -> Self { - Self::new(true) + Self::new(false) } } @@ -42,6 +42,11 @@ impl CodeModeAppState { mod tests { use super::CodeModeAppState; + #[test] + fn default_state_is_opt_in() { + assert!(!CodeModeAppState::default().is_enabled()); + } + #[test] fn cloned_handles_share_state() { let state = CodeModeAppState::new(true); diff --git a/crates/labby-runtime/src/gateway_config.rs b/crates/labby-runtime/src/gateway_config.rs index c22f9d170..178e13537 100644 --- a/crates/labby-runtime/src/gateway_config.rs +++ b/crates/labby-runtime/src/gateway_config.rs @@ -95,36 +95,34 @@ fn default_mcp_scopes() -> Vec { // ─── Lab-owned MCP Apps ────────────────────────────────────────────────────── -/// Visibility switches for Labby-owned MCP App surfaces other than the -/// always-on `mcp_app` manager. Code Mode keeps its existing -/// `CodeModeConfig::mcp_ui_enabled` field for backward-compatible config. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +/// Visibility switches for Labby-owned MCP App UI surfaces. The `mcp_app` +/// control tool stays available when its manager UI is disabled. Code Mode keeps +/// its existing `CodeModeConfig::mcp_ui_enabled` field for backward-compatible +/// config. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct McpAppsConfig { + /// Attach MCP App metadata to the always-available `mcp_app` control tool and advertise its UI resources. + /// The control tool itself remains available when this is false. + #[serde(default)] + pub manager: bool, /// Advertise the synthetic Add Server app tool and its UI resources. - #[serde(default = "default_true")] + /// Labby-owned app surfaces are opt-in by default. + #[serde(default)] pub add_server: bool, /// Attach the Server Logs app metadata and advertise its UI resources. - #[serde(default = "default_true")] + /// Labby-owned app surfaces are opt-in by default. + #[serde(default)] pub server_logs: bool, /// Advertise the synthetic Gateway Status app tool and its UI resources. - #[serde(default = "default_true")] + /// Labby-owned app surfaces are opt-in by default. + #[serde(default)] pub gateway_status: bool, /// Advertise the schema-backed Settings app tool and its UI resources. - #[serde(default = "default_true")] + /// Labby-owned app surfaces are opt-in by default. + #[serde(default)] pub settings: bool, } -impl Default for McpAppsConfig { - fn default() -> Self { - Self { - add_server: true, - server_logs: true, - gateway_status: true, - settings: true, - } - } -} - // ─── Code Mode ─────────────────────────────────────────────────────────────── /// Model-facing policy applied to oversized final Code Mode results. @@ -195,7 +193,8 @@ pub struct CodeModeConfig { pub trusted_read_only_tools: Vec, /// Whether the explicit `codemode_ui` MCP App tool and resources are advertised. /// The text-only `codemode` executor remains available when this is false. - #[serde(default = "default_true")] + /// Labby-owned MCP Apps are opt-in, so this defaults to false. + #[serde(default)] pub mcp_ui_enabled: bool, /// Whether Code Mode call traces include redacted/capped tool params. #[serde(default = "default_code_mode_trace_params")] @@ -265,7 +264,7 @@ impl Default for CodeModeConfig { Self { enabled: false, trusted_read_only_tools: Vec::new(), - mcp_ui_enabled: true, + mcp_ui_enabled: false, trace_params: default_code_mode_trace_params(), result_shape_policy: CodeModeResultShapePolicy::Off, timeout_ms: default_code_mode_timeout_ms(), @@ -2190,7 +2189,7 @@ client_secret_env = "SECRET" assert_eq!(cfg, expected); assert!(!cfg.enabled); assert!(cfg.trusted_read_only_tools.is_empty()); - assert!(cfg.mcp_ui_enabled); + assert!(!cfg.mcp_ui_enabled); assert!(cfg.trace_params); assert_eq!(cfg.timeout_ms, 30_000); assert_eq!(cfg.token_estimate_divisor, 4); @@ -2209,32 +2208,34 @@ client_secret_env = "SECRET" } #[test] - fn code_mode_mcp_ui_can_be_disabled_in_toml() { + fn code_mode_mcp_ui_can_be_enabled_in_toml() { let cfg: CodeModeConfig = toml::from_str( - "mcp_ui_enabled = false + "mcp_ui_enabled = true ", ) .unwrap(); - assert!(!cfg.mcp_ui_enabled); + assert!(cfg.mcp_ui_enabled); assert!(!cfg.enabled); } #[test] - fn mcp_apps_config_defaults_all_managed_apps_enabled() { + fn mcp_apps_config_defaults_all_managed_apps_disabled() { let cfg: McpAppsConfig = toml::from_str("").unwrap(); assert_eq!(cfg, McpAppsConfig::default()); - assert!(cfg.add_server); - assert!(cfg.server_logs); - assert!(cfg.gateway_status); - assert!(cfg.settings); + assert!(!cfg.manager); + assert!(!cfg.add_server); + assert!(!cfg.server_logs); + assert!(!cfg.gateway_status); + assert!(!cfg.settings); } #[test] fn mcp_apps_config_supports_independent_visibility_switches() { let cfg: McpAppsConfig = toml::from_str( - "add_server = false\nserver_logs = true\ngateway_status = false\nsettings = false\n", + "manager = true\nadd_server = false\nserver_logs = true\ngateway_status = false\nsettings = false\n", ) .unwrap(); + assert!(cfg.manager); assert!(!cfg.add_server); assert!(cfg.server_logs); assert!(!cfg.gateway_status); diff --git a/crates/labby/src/api/services/gateway.rs b/crates/labby/src/api/services/gateway.rs index 43a9eedde..8972c32ba 100644 --- a/crates/labby/src/api/services/gateway.rs +++ b/crates/labby/src/api/services/gateway.rs @@ -844,7 +844,16 @@ mod tests { async fn gateway_code_mode_mcp_ui_update_persists_via_api() { let (manager, path) = test_manager_with_path(); manager - .seed_config_unchecked_for_tests(LabConfig::default().to_gateway_config()) + .seed_config_unchecked_for_tests( + LabConfig { + code_mode: crate::config::CodeModeConfig { + mcp_ui_enabled: true, + ..crate::config::CodeModeConfig::default() + }, + ..LabConfig::default() + } + .to_gateway_config(), + ) .await; assert!(manager.code_mode_app_state().is_enabled()); diff --git a/crates/labby/src/cli/serve.rs b/crates/labby/src/cli/serve.rs index 0a3fe7f24..cb4941532 100644 --- a/crates/labby/src/cli/serve.rs +++ b/crates/labby/src/cli/serve.rs @@ -2592,13 +2592,31 @@ mod tests { .collect() } - let _guard = crate::config::process_code_mode_test_guard(); - crate::config::set_process_code_mode_enabled(true); + let tempdir = tempfile::tempdir().expect("tempdir"); + let manager = std::sync::Arc::new( + crate::dispatch::gateway::config_store::test_gateway_manager( + tempdir.path().join("gateway.toml"), + crate::dispatch::gateway::manager::GatewayRuntimeHandle::default(), + ), + ); + manager + .seed_config_unchecked_for_tests( + LabConfig { + code_mode: crate::config::CodeModeConfig { + enabled: true, + ..crate::config::CodeModeConfig::default() + }, + ..LabConfig::default() + } + .to_gateway_config(), + ) + .await; + let state = AppState::new().with_gateway_manager(manager); let notifier = PeerNotifier::default(); notifier.code_mode_app_state.set_enabled(false); let app = build_http_router( - AppState::new(), + state, None, None, &McpPreferences::default(), diff --git a/crates/labby/src/mcp/CLAUDE.md b/crates/labby/src/mcp/CLAUDE.md index e30a99dab..b38d8f784 100644 --- a/crates/labby/src/mcp/CLAUDE.md +++ b/crates/labby/src/mcp/CLAUDE.md @@ -76,10 +76,12 @@ For normal services, `dispatch//dispatch.rs` owns action routing, catal `codemode` has no static app descriptor but may return dynamic `_meta.ui` when an upstream call launches a nested MCP App. `codemode_ui` shares the same execution backend and owns the Code Mode inspector metadata. `mcp_app` - is the always-on root-gateway MCP App manager for the inspector, Gateway - Status, Server Logs, and Add Server surfaces; it supports per-app and `all` - `status|enable|disable` operations and cannot disable itself. App mutations - require `lab:admin`, are gateway-scoped, and schedule coalesced + is the always-available root-gateway control tool for the manager UI, + inspector, Gateway Status, Server Logs, Add Server, and Settings surfaces; it + supports per-app and `all` `status|enable|disable` operations. Its own manager + UI is opt-in and may be disabled, but the text-only control tool remains + available. App mutations require `lab:admin`, are gateway-scoped, and schedule + coalesced `tools/list_changed` plus `resources/list_changed` notifications after the open tool turn drains. `server_logs` keeps its text/service capability when its UI is hidden; `codemode` likewise remains text-only and executable when @@ -214,8 +216,11 @@ Resources are read-only. Do not use them for mutations. - `ui://lab/code-mode/*` — Lab's own Code Mode app resources, served locally from bundled HTML (`read_code_mode_app_resource_impl`). The app descriptors bind only to `codemode_ui`; disabling the app hides that tool and these - resources from discovery, while direct resource reads remain valid so cards - already rendered by a host do not break. + resources, and direct reads fail as unknown. All Labby-owned app UIs are + opt-in; a disabled surface must not remain reachable through a cached URI. +- `ui://lab/mcp-apps/manager` — the opt-in UI for the always-available `mcp_app` + control tool. Disabling this manager UI strips its tool metadata and resource + but does not remove the text-only control tool needed to restore app surfaces. - `ui://lab/gateway/add-server` — the admin-only Add Server app bound to the synthetic `add_server` tool. Its `test` and `create` callbacks delegate to `gateway.test` and `gateway.add`; do not duplicate gateway persistence logic. @@ -227,7 +232,10 @@ Resources are read-only. Do not use them for mutations. - any other `ui:///…` — an upstream mcp-ui widget resource (referenced by a tool result's `_meta.ui.resourceUri`). Routed to the owning upstream peer via `pool.read_upstream_ui_resource` (catalog reverse-lookup, native URI - preserved). See `resource_proxy.rs::read_upstream_ui_resource_impl`. + preserved). When synthetic Code Mode hides ordinary raw tools, upstream + MCP-App host-visible tools/callbacks still pass through automatically if the + route allows that upstream and `proxy_resources` is enabled. See + `resource_proxy.rs::read_upstream_ui_resource_impl`. ## Transport auth for fs diff --git a/crates/labby/src/mcp/assets/mcp_apps_app.html b/crates/labby/src/mcp/assets/mcp_apps_app.html index 154370525..33547121e 100644 --- a/crates/labby/src/mcp/assets/mcp_apps_app.html +++ b/crates/labby/src/mcp/assets/mcp_apps_app.html @@ -17,16 +17,16 @@
-

MCP Apps

Choose which Labby-owned app tools and UI resources are advertised by the gateway.

+

MCP Apps

Choose which Labby-owned app UI surfaces are advertised by the gateway.

Loading app state…

-

This manager stays available. Disabling an app removes its UI advertisement and resources without disabling the underlying text/service capability where one exists.

+

The mcp_app control tool stays available. Its manager UI can be disabled like every other Labby-owned app. Disabling an app removes its UI advertisement and resources without disabling the underlying text/service capability where one exists.