From 7649fb7ff4c199a4d5fc4f6c7249dbe94f71c5c1 Mon Sep 17 00:00:00 2001 From: jmagar Date: Wed, 16 Sep 2026 22:08:43 -0400 Subject: [PATCH 1/3] fix(codemode): match upstream namespaces case-insensitively --- crates/labby-codemode/src/tests_discovery.rs | 11 +++++++ crates/labby-codemode/src/tests_ids_schema.rs | 13 ++++++++ crates/labby-codemode/src/types.rs | 30 +++++++++++++++---- crates/labby/src/mcp/call_tool_codemode.rs | 8 +++-- .../labby/src/mcp/call_tool_codemode/tests.rs | 12 ++++++++ 5 files changed, 65 insertions(+), 9 deletions(-) diff --git a/crates/labby-codemode/src/tests_discovery.rs b/crates/labby-codemode/src/tests_discovery.rs index 5ee5ebbbe..4286f5545 100644 --- a/crates/labby-codemode/src/tests_discovery.rs +++ b/crates/labby-codemode/src/tests_discovery.rs @@ -37,6 +37,17 @@ fn blank_query_browses_the_first_visible_tools() { assert_eq!(response.results.len(), 2); } +#[test] +fn namespace_scope_case_does_not_hide_live_tools() { + let entries = vec![tool("Axon", "axon", "unified axon tool")]; + let scope = ToolScope::scoped_namespaces(vec!["axon".into()], Vec::new()); + + let response = search_visible_tools(&entries, &scope, "axon", 50).unwrap(); + + assert_eq!(response.total, 1); + assert_eq!(response.results[0].namespace, "Axon"); +} + #[test] fn hidden_and_random_describe_are_identical() { let entries = vec![ diff --git a/crates/labby-codemode/src/tests_ids_schema.rs b/crates/labby-codemode/src/tests_ids_schema.rs index b8c400c26..19d2e8685 100644 --- a/crates/labby-codemode/src/tests_ids_schema.rs +++ b/crates/labby-codemode/src/tests_ids_schema.rs @@ -209,6 +209,19 @@ fn tool_scope_allows_only_selected_namespaces_and_tools() { assert!(!filter.allows("docker", "search_issues")); } +#[test] +fn tool_scope_namespace_matching_is_ascii_case_insensitive() { + let lowercase = ToolScope::new(vec!["axon".to_string()], Vec::new()); + let uppercase = ToolScope::new(vec!["Axon".to_string()], Vec::new()); + + assert!(lowercase.allows("Axon", "axon")); + assert!(uppercase.allows("axon", "axon")); + assert_eq!(lowercase.fingerprint(), uppercase.fingerprint()); + + let namespaced_tool = ToolScope::new(Vec::new(), vec!["axon::axon".to_string()]); + assert!(namespaced_tool.allows("Axon", "axon")); +} + #[test] fn capability_filter_fingerprint_is_structured_and_collision_resistant() { let first = ToolScope::new( diff --git a/crates/labby-codemode/src/types.rs b/crates/labby-codemode/src/types.rs index 6b2ea1ce1..3a0a6204f 100644 --- a/crates/labby-codemode/src/types.rs +++ b/crates/labby-codemode/src/types.rs @@ -1126,21 +1126,36 @@ impl ToolScope { namespaces: Vec, tools: Vec, ) -> Self { - fn clean_set(values: Vec) -> BTreeSet { + fn clean_namespace_set(values: Vec) -> BTreeSet { values .into_iter() - .map(|value| value.trim().to_string()) + .map(|value| value.trim().to_ascii_lowercase()) .filter(|value| !value.is_empty()) .collect() } - let namespaces = clean_set(namespaces); + fn clean_tool_set(values: Vec) -> BTreeSet { + values + .into_iter() + .map(|value| { + let value = value.trim(); + value.split_once("::").map_or_else( + || value.to_string(), + |(namespace, tool)| { + format!("{}::{tool}", namespace.trim().to_ascii_lowercase()) + }, + ) + }) + .filter(|value| !value.is_empty()) + .collect() + } + let namespaces = clean_namespace_set(namespaces); Self { namespaces: if namespaces.is_empty() { scoped_default } else { Some(namespaces) }, - tools: clean_set(tools), + tools: clean_tool_set(tools), access: CodeModeToolAccess::Full, } } @@ -1163,13 +1178,16 @@ impl ToolScope { /// Return whether a namespace/tool pair is included by the configured filters. #[must_use] pub fn allows(&self, namespace: &str, tool: &str) -> bool { + let normalized_namespace = namespace.trim().to_ascii_lowercase(); (self .namespaces .as_ref() - .is_none_or(|namespaces| namespaces.contains(namespace))) + .is_none_or(|namespaces| namespaces.contains(&normalized_namespace))) && (self.tools.is_empty() || self.tools.contains(tool) - || self.tools.contains(&namespaced_tool_id(namespace, tool))) + || self + .tools + .contains(&namespaced_tool_id(&normalized_namespace, tool))) } /// Return whether any namespace, tool, or read-only restriction is active. diff --git a/crates/labby/src/mcp/call_tool_codemode.rs b/crates/labby/src/mcp/call_tool_codemode.rs index 1bb499489..09e85b37b 100644 --- a/crates/labby/src/mcp/call_tool_codemode.rs +++ b/crates/labby/src/mcp/call_tool_codemode.rs @@ -520,9 +520,11 @@ fn route_scoped_capability_filter( ) -> Result { let requested_upstreams = string_array_arg(args, "upstreams")?; if let Some(allowed) = route_allowed - && requested_upstreams - .iter() - .any(|name| !allowed.contains(name)) + && requested_upstreams.iter().any(|name| { + !allowed + .iter() + .any(|allowed_name| allowed_name.eq_ignore_ascii_case(name)) + }) { return Err(DispatchToolError::Sdk { sdk_kind: "route_scope_denied".to_string(), diff --git a/crates/labby/src/mcp/call_tool_codemode/tests.rs b/crates/labby/src/mcp/call_tool_codemode/tests.rs index 18388f19f..297940455 100644 --- a/crates/labby/src/mcp/call_tool_codemode/tests.rs +++ b/crates/labby/src/mcp/call_tool_codemode/tests.rs @@ -139,6 +139,18 @@ fn scoped_capability_filter_rejects_disallowed_requested_upstreams() { assert_eq!(err.kind(), "route_scope_denied"); } +#[test] +fn scoped_capability_filter_accepts_case_variant_of_allowed_upstream() { + let mut args = serde_json::Map::new(); + args.insert("upstreams".to_string(), json!(["axon"])); + let allowed = std::collections::BTreeSet::from(["Axon".to_string()]); + + let filter = route_scoped_capability_filter(&args, Some(&allowed)) + .expect("namespace case must not change route authorization"); + + assert!(filter.allows("Axon", "axon")); +} + #[test] fn scoped_capability_filter_defaults_to_route_allowed_upstreams() { let args = serde_json::Map::new(); From 45eb3a39103f5820d6f1d6078710b3de0b71f463 Mon Sep 17 00:00:00 2001 From: jmagar Date: Wed, 16 Sep 2026 23:54:35 -0400 Subject: [PATCH 2/3] fix(codemode): resolve namespace casing safely --- crates/labby-codemode/src/tests_discovery.rs | 11 --- crates/labby-codemode/src/tests_ids_schema.rs | 13 --- crates/labby-codemode/src/types.rs | 30 ++----- .../src/gateway/manager/config_ops.rs | 16 +++- .../src/gateway/manager/tests/config_ops.rs | 4 + crates/labby-runtime/src/gateway_config.rs | 84 ++++++++++--------- crates/labby/src/app_assets.rs | 3 +- crates/labby/src/app_catalog.rs | 32 ++++++- crates/labby/src/cli/serve.rs | 13 ++- crates/labby/src/config.rs | 8 +- crates/labby/src/mcp/CLAUDE.md | 11 ++- crates/labby/src/mcp/assets/mcp_apps_app.html | 2 +- crates/labby/src/mcp/call_tool.rs | 12 ++- crates/labby/src/mcp/call_tool_codemode.rs | 77 +++++++++++++---- .../labby/src/mcp/call_tool_codemode/tests.rs | 53 ++++++++++-- crates/labby/src/mcp/handlers_resources.rs | 5 ++ crates/labby/src/mcp/handlers_tools.rs | 26 ++++-- crates/labby/src/mcp/handlers_tools/tests.rs | 54 +++++++++++- crates/labby/src/mcp/peer_contract.rs | 3 +- crates/labby/src/mcp/permanent_tools.rs | 15 +++- docs/services/GATEWAY.md | 17 ++-- docs/surfaces/MCP.md | 8 +- 22 files changed, 343 insertions(+), 154 deletions(-) diff --git a/crates/labby-codemode/src/tests_discovery.rs b/crates/labby-codemode/src/tests_discovery.rs index 4286f5545..5ee5ebbbe 100644 --- a/crates/labby-codemode/src/tests_discovery.rs +++ b/crates/labby-codemode/src/tests_discovery.rs @@ -37,17 +37,6 @@ fn blank_query_browses_the_first_visible_tools() { assert_eq!(response.results.len(), 2); } -#[test] -fn namespace_scope_case_does_not_hide_live_tools() { - let entries = vec![tool("Axon", "axon", "unified axon tool")]; - let scope = ToolScope::scoped_namespaces(vec!["axon".into()], Vec::new()); - - let response = search_visible_tools(&entries, &scope, "axon", 50).unwrap(); - - assert_eq!(response.total, 1); - assert_eq!(response.results[0].namespace, "Axon"); -} - #[test] fn hidden_and_random_describe_are_identical() { let entries = vec![ diff --git a/crates/labby-codemode/src/tests_ids_schema.rs b/crates/labby-codemode/src/tests_ids_schema.rs index 19d2e8685..b8c400c26 100644 --- a/crates/labby-codemode/src/tests_ids_schema.rs +++ b/crates/labby-codemode/src/tests_ids_schema.rs @@ -209,19 +209,6 @@ fn tool_scope_allows_only_selected_namespaces_and_tools() { assert!(!filter.allows("docker", "search_issues")); } -#[test] -fn tool_scope_namespace_matching_is_ascii_case_insensitive() { - let lowercase = ToolScope::new(vec!["axon".to_string()], Vec::new()); - let uppercase = ToolScope::new(vec!["Axon".to_string()], Vec::new()); - - assert!(lowercase.allows("Axon", "axon")); - assert!(uppercase.allows("axon", "axon")); - assert_eq!(lowercase.fingerprint(), uppercase.fingerprint()); - - let namespaced_tool = ToolScope::new(Vec::new(), vec!["axon::axon".to_string()]); - assert!(namespaced_tool.allows("Axon", "axon")); -} - #[test] fn capability_filter_fingerprint_is_structured_and_collision_resistant() { let first = ToolScope::new( diff --git a/crates/labby-codemode/src/types.rs b/crates/labby-codemode/src/types.rs index 3a0a6204f..6b2ea1ce1 100644 --- a/crates/labby-codemode/src/types.rs +++ b/crates/labby-codemode/src/types.rs @@ -1126,36 +1126,21 @@ impl ToolScope { namespaces: Vec, tools: Vec, ) -> Self { - fn clean_namespace_set(values: Vec) -> BTreeSet { + fn clean_set(values: Vec) -> BTreeSet { values .into_iter() - .map(|value| value.trim().to_ascii_lowercase()) + .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) .collect() } - fn clean_tool_set(values: Vec) -> BTreeSet { - values - .into_iter() - .map(|value| { - let value = value.trim(); - value.split_once("::").map_or_else( - || value.to_string(), - |(namespace, tool)| { - format!("{}::{tool}", namespace.trim().to_ascii_lowercase()) - }, - ) - }) - .filter(|value| !value.is_empty()) - .collect() - } - let namespaces = clean_namespace_set(namespaces); + let namespaces = clean_set(namespaces); Self { namespaces: if namespaces.is_empty() { scoped_default } else { Some(namespaces) }, - tools: clean_tool_set(tools), + tools: clean_set(tools), access: CodeModeToolAccess::Full, } } @@ -1178,16 +1163,13 @@ impl ToolScope { /// Return whether a namespace/tool pair is included by the configured filters. #[must_use] pub fn allows(&self, namespace: &str, tool: &str) -> bool { - let normalized_namespace = namespace.trim().to_ascii_lowercase(); (self .namespaces .as_ref() - .is_none_or(|namespaces| namespaces.contains(&normalized_namespace))) + .is_none_or(|namespaces| namespaces.contains(namespace))) && (self.tools.is_empty() || self.tools.contains(tool) - || self - .tools - .contains(&namespaced_tool_id(&normalized_namespace, tool))) + || self.tools.contains(&namespaced_tool_id(namespace, tool))) } /// Return whether any namespace, tool, or read-only restriction is active. diff --git a/crates/labby-gateway/src/gateway/manager/config_ops.rs b/crates/labby-gateway/src/gateway/manager/config_ops.rs index f7f0d7461..448adbd94 100644 --- a/crates/labby-gateway/src/gateway/manager/config_ops.rs +++ b/crates/labby-gateway/src/gateway/manager/config_ops.rs @@ -1,7 +1,7 @@ //! Config reads/writes for upstream entries: `add`, `batch_add`, `update`, //! `remove`, service env config, and the code-mode config mutation. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use tokio::time::Instant; @@ -98,6 +98,17 @@ impl GatewayManager { self.config.read().await.clone() } + /// Return the configured upstream names without cloning the full gateway config. + pub async fn upstream_names(&self) -> BTreeSet { + self.config + .read() + .await + .upstream + .iter() + .map(|upstream| upstream.name.clone()) + .collect() + } + /// Return the current visibility switches for Labby-owned MCP Apps. pub async fn mcp_apps_config(&self) -> McpAppsConfig { self.config.read().await.mcp_apps @@ -532,6 +543,7 @@ impl GatewayManager { match target { "manager" => cfg.mcp_apps.manager = enabled, "codemode" => cfg.code_mode.mcp_ui_enabled = enabled, + "skill_library" => cfg.mcp_apps.skill_library = 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, @@ -539,6 +551,7 @@ impl GatewayManager { "all" => { cfg.mcp_apps.manager = enabled; cfg.code_mode.mcp_ui_enabled = enabled; + cfg.mcp_apps.skill_library = enabled; cfg.mcp_apps.gateway_status = enabled; cfg.mcp_apps.server_logs = enabled; cfg.mcp_apps.add_server = enabled; @@ -581,6 +594,7 @@ impl GatewayManager { enabled, manager = current.mcp_apps.manager, code_mode = current.code_mode.mcp_ui_enabled, + skill_library = current.mcp_apps.skill_library, add_server = current.mcp_apps.add_server, server_logs = current.mcp_apps.server_logs, gateway_status = current.mcp_apps.gateway_status, 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 6ddf1b83a..bc8521160 100644 --- a/crates/labby-gateway/src/gateway/manager/tests/config_ops.rs +++ b/crates/labby-gateway/src/gateway/manager/tests/config_ops.rs @@ -904,6 +904,7 @@ async fn mcp_app_visibility_setting_persists_notifies_and_skips_pool_rebuild() { let mut initial = GatewayConfig::default(); initial.code_mode.mcp_ui_enabled = true; initial.mcp_apps.manager = true; + initial.mcp_apps.skill_library = true; initial.mcp_apps.gateway_status = true; initial.mcp_apps.server_logs = true; initial.mcp_apps.add_server = true; @@ -922,6 +923,7 @@ async fn mcp_app_visibility_setting_persists_notifies_and_skips_pool_rebuild() { assert!(!updated.mcp_apps.manager); assert!(!updated.code_mode.mcp_ui_enabled); + assert!(!updated.mcp_apps.skill_library); assert!(!updated.mcp_apps.gateway_status); assert!(!updated.mcp_apps.server_logs); assert!(!updated.mcp_apps.add_server); @@ -951,6 +953,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.skill_library); assert!(!persisted.mcp_apps.gateway_status); assert!(!persisted.mcp_apps.server_logs); assert!(!persisted.mcp_apps.add_server); @@ -961,6 +964,7 @@ async fn mcp_app_visibility_setting_persists_notifies_and_skips_pool_rebuild() { assert!(!restarted.code_mode_app_state().is_enabled()); let restarted_apps = restarted.mcp_apps_config().await; assert!(!restarted_apps.manager); + assert!(!restarted_apps.skill_library); assert!(!restarted_apps.gateway_status); assert!(!restarted_apps.server_logs); assert!(!restarted_apps.add_server); diff --git a/crates/labby-runtime/src/gateway_config.rs b/crates/labby-runtime/src/gateway_config.rs index 12dc15537..a47263f61 100644 --- a/crates/labby-runtime/src/gateway_config.rs +++ b/crates/labby-runtime/src/gateway_config.rs @@ -110,35 +110,40 @@ fn default_mcp_scopes() -> Vec { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] 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 = "default_true")] + /// The control tool itself remains available when this is false. Fresh installs keep the UI opt-in. + #[serde(default)] pub manager: bool, + /// Attach the Skill Library UI metadata/resources to the `artifacts` service. + /// The underlying text service remains available when this is false. + #[serde(default)] + pub skill_library: bool, /// Advertise the synthetic Add Server app tool and its UI resources. - /// Fresh installs expose the complete Labby app surface by default. - #[serde(default = "default_true")] + /// Fresh installs keep Labby-owned MCP App UIs opt-in. + #[serde(default)] pub add_server: bool, /// Attach the Server Logs app metadata and advertise its UI resources. - /// Fresh installs expose the complete Labby app surface by default. - #[serde(default = "default_true")] + /// Fresh installs keep Labby-owned MCP App UIs opt-in. + #[serde(default)] pub server_logs: bool, /// Advertise the synthetic Gateway Status app tool and its UI resources. - /// Fresh installs expose the complete Labby app surface by default. - #[serde(default = "default_true")] + /// Fresh installs keep Labby-owned MCP App UIs opt-in. + #[serde(default)] pub gateway_status: bool, /// Advertise the schema-backed Settings app tool and its UI resources. - /// Fresh installs expose the complete Labby app surface by default. - #[serde(default = "default_true")] + /// Fresh installs keep Labby-owned MCP App UIs opt-in. + #[serde(default)] pub settings: bool, } impl Default for McpAppsConfig { fn default() -> Self { Self { - manager: true, - add_server: true, - server_logs: true, - gateway_status: true, - settings: true, + manager: false, + skill_library: false, + add_server: false, + server_logs: false, + gateway_status: false, + settings: false, } } } @@ -215,8 +220,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. - /// The inspector is enabled by default so Code Mode has a useful first-run UI. - #[serde(default = "default_true")] + /// The inspector is opt-in on fresh installs, like the other Labby-owned MCP App UIs. + #[serde(default)] pub mcp_ui_enabled: bool, /// Whether Code Mode call traces include redacted/capped tool params. #[serde(default = "default_code_mode_trace_params")] @@ -286,7 +291,7 @@ impl Default for CodeModeConfig { Self { enabled: true, 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(), @@ -2396,7 +2401,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); @@ -2457,51 +2462,50 @@ client_secret_env = "SECRET" } #[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.manager); - assert!(cfg.add_server); - assert!(cfg.server_logs); - assert!(cfg.gateway_status); - assert!(cfg.settings); + assert!(!cfg.manager); + assert!(!cfg.skill_library); + assert!(!cfg.add_server); + assert!(!cfg.server_logs); + assert!(!cfg.gateway_status); + assert!(!cfg.settings); } #[test] fn documented_labby_app_defaults_match_code() { // GATEWAY.md is the operator-facing statement of the Labby-owned app - // surface defaults. Code Mode and every managed MCP App now default - // on, so the doc must not still promise an off-by-default posture. + // surface defaults. Code Mode execution stays on, while every Labby-owned + // MCP App UI is opt-in by default. let doc = include_str!("../../../docs/services/GATEWAY.md"); assert!(CodeModeConfig::default().enabled); - assert!(CodeModeConfig::default().mcp_ui_enabled); + assert!(!CodeModeConfig::default().mcp_ui_enabled); assert_eq!( McpAppsConfig::default(), McpAppsConfig { - manager: true, - add_server: true, - server_logs: true, - gateway_status: true, - settings: true, + manager: false, + skill_library: false, + add_server: false, + server_logs: false, + gateway_status: false, + settings: false, } ); assert!( - !doc.contains("defaults to `false` and must be explicitly enabled"), - "docs/services/GATEWAY.md still documents the retired off-by-default app posture" - ); - assert!( - doc.contains("defaults to `true`") || doc.contains("enabled by default"), - "docs/services/GATEWAY.md must state that Labby-owned app surfaces default on" + doc.contains("default to `false`") || doc.contains("opt-in"), + "docs/services/GATEWAY.md must state that Labby-owned app surfaces default off" ); } #[test] fn mcp_apps_config_supports_independent_visibility_switches() { let cfg: McpAppsConfig = toml::from_str( - "manager = true\nadd_server = false\nserver_logs = true\ngateway_status = false\nsettings = false\n", + "manager = true\nskill_library = true\nadd_server = false\nserver_logs = true\ngateway_status = false\nsettings = false\n", ) .unwrap(); assert!(cfg.manager); + assert!(cfg.skill_library); assert!(!cfg.add_server); assert!(cfg.server_logs); assert!(!cfg.gateway_status); diff --git a/crates/labby/src/app_assets.rs b/crates/labby/src/app_assets.rs index 6c4a7caab..ffa3ee3f0 100644 --- a/crates/labby/src/app_assets.rs +++ b/crates/labby/src/app_assets.rs @@ -44,7 +44,8 @@ pub(crate) const SETTINGS_APP_URI: &str = "ui://lab/settings/editor"; pub(crate) const SETTINGS_APP_SKYBRIDGE_URI: &str = "ui://lab/settings/editor.skybridge"; pub(crate) const SETTINGS_APP_HTML: &str = include_str!("mcp/assets/settings_app.html"); -/// Always-on MCP App manager used to control the other Labby-owned app surfaces. +/// Opt-in MCP App manager UI used to control the other Labby-owned app surfaces. +/// The text-only `mcp_app` control tool remains always available. #[cfg(feature = "gateway")] pub(crate) const MCP_APPS_APP_URI: &str = "ui://lab/apps/manage"; #[cfg(feature = "gateway")] diff --git a/crates/labby/src/app_catalog.rs b/crates/labby/src/app_catalog.rs index 79a283a40..5fb7e8d02 100644 --- a/crates/labby/src/app_catalog.rs +++ b/crates/labby/src/app_catalog.rs @@ -67,7 +67,9 @@ pub(crate) fn enabled_versions( add("server-logs", &SERVER_LOGS_APP_VERSION); } #[cfg(feature = "skills")] - add("skill-library", &SKILL_LIBRARY_APP_VERSION); + if config.skill_library { + add("skill-library", &SKILL_LIBRARY_APP_VERSION); + } #[cfg(feature = "gateway")] { if config.add_server { @@ -85,3 +87,31 @@ pub(crate) fn enabled_versions( } rows } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_visibility_publishes_no_labby_app_revisions() { + assert!(enabled_versions(false, McpAppsConfig::default()).is_empty()); + } + + #[cfg(feature = "skills")] + #[test] + fn skill_library_revision_follows_visibility_switch() { + let mut config = McpAppsConfig::default(); + assert!( + enabled_versions(false, config) + .iter() + .all(|(id, _)| id != "skill-library") + ); + + config.skill_library = true; + assert!( + enabled_versions(false, config) + .iter() + .any(|(id, _)| id == "skill-library") + ); + } +} diff --git a/crates/labby/src/cli/serve.rs b/crates/labby/src/cli/serve.rs index 898c932fa..83790d496 100644 --- a/crates/labby/src/cli/serve.rs +++ b/crates/labby/src/cli/serve.rs @@ -992,10 +992,9 @@ fn resolve_web_ui_auth_disabled( /// Name the Labby-owned app surfaces whose `config.toml` section is absent /// and which therefore run at their on-by-default posture. One INFO line at -/// startup, only when something is inherited, so an install upgraded from a -/// release where Code Mode and the MCP App UIs defaulted off can see why they -/// appeared. A missing file inherits everything; an unreadable one is the -/// loader's error to report. +/// startup, only when something is inherited, so an operator can see which +/// surface defaults were applied. A missing file inherits everything; an +/// unreadable one is the loader's error to report. fn log_inherited_app_surface_defaults(config_path: &Path, config: &LabConfig) { let raw = match std::fs::read_to_string(config_path) { Ok(raw) => raw, @@ -1013,13 +1012,13 @@ fn log_inherited_app_surface_defaults(config_path: &Path, config: &LabConfig) { code_mode_enabled = config.code_mode.enabled, code_mode_ui_enabled = config.code_mode.mcp_ui_enabled, mcp_apps_manager = config.mcp_apps.manager, + mcp_apps_skill_library = config.mcp_apps.skill_library, mcp_apps_add_server = config.mcp_apps.add_server, mcp_apps_server_logs = config.mcp_apps.server_logs, mcp_apps_gateway_status = config.mcp_apps.gateway_status, mcp_apps_settings = config.mcp_apps.settings, - "config.toml declares no [code_mode] or [mcp_apps] section; Code Mode and \ - the Labby-owned MCP App UIs default to enabled — set the switches to \ - false to opt out" + "config.toml inherits app surface defaults: text Code Mode stays enabled, \ + while Labby-owned MCP App UIs default off and must be explicitly enabled" ); } diff --git a/crates/labby/src/config.rs b/crates/labby/src/config.rs index 19d93464c..ee6362783 100644 --- a/crates/labby/src/config.rs +++ b/crates/labby/src/config.rs @@ -2146,10 +2146,10 @@ fn load_toml_from_paths(candidates: &[PathBuf]) -> Result { } /// Labby-owned app-surface sections that `raw` (a `config.toml` document) -/// does not declare and therefore inherits at their on-by-default posture: -/// `code_mode` (Code Mode plus its inspector UI) and `mcp_apps` (every -/// Labby-owned MCP App UI). Startup names these once so an install upgraded -/// from a release where they defaulted off sees the change. Unparseable input +/// does not declare and therefore inherits from code defaults. `code_mode` +/// keeps text execution enabled while its inspector UI defaults off; `mcp_apps` +/// keeps every Labby-owned MCP App UI opt-in. Startup names inherited sections +/// once so operators can see which defaults were applied. Unparseable input /// yields nothing; the config loader owns that error. #[must_use] pub fn inherited_app_surface_sections(raw: &str) -> Vec<&'static str> { diff --git a/crates/labby/src/mcp/CLAUDE.md b/crates/labby/src/mcp/CLAUDE.md index 54075b998..56f496b01 100644 --- a/crates/labby/src/mcp/CLAUDE.md +++ b/crates/labby/src/mcp/CLAUDE.md @@ -77,10 +77,9 @@ For normal services, `dispatch//dispatch.rs` owns action routing, catal 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-available root-gateway control tool for the manager UI, - inspector, Gateway Status, Server Logs, Add Server, and Settings surfaces; it + inspector, Skill Library, Gateway Status, Server Logs, Add Server, and Settings surfaces; it supports per-app and `all` `status|enable|disable` operations. Its own manager - UI is enabled by default and may be disabled, but the text-only control tool remains - available. App mutations require `lab:admin`, are gateway-scoped, and schedule + UI is opt-in by default, while 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 @@ -220,9 +219,9 @@ 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, and direct reads fail as unknown. All Labby-owned app UIs are - enabled by default; a disabled surface must not remain reachable through a cached URI. -- `ui://lab/mcp-apps/manager` — the default-enabled UI for the always-available `mcp_app` + resources, and direct reads fail as unknown. Labby-owned app UIs are opt-in; + a disabled surface must not remain reachable through a cached URI. +- `ui://lab/apps/manage` — 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 diff --git a/crates/labby/src/mcp/assets/mcp_apps_app.html b/crates/labby/src/mcp/assets/mcp_apps_app.html index 0b7733c12..239095508 100644 --- a/crates/labby/src/mcp/assets/mcp_apps_app.html +++ b/crates/labby/src/mcp/assets/mcp_apps_app.html @@ -24,7 +24,7 @@