Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 10 additions & 8 deletions config/config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 ─────────────────────────────────────────────
#
Expand Down
3 changes: 3 additions & 0 deletions crates/labby-gateway/src/gateway/manager/config_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
14 changes: 11 additions & 3 deletions crates/labby-gateway/src/gateway/manager/tests/config_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down
5 changes: 4 additions & 1 deletion crates/labby-gateway/src/upstream/pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
17 changes: 14 additions & 3 deletions crates/labby-gateway/src/upstream/pool/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion crates/labby-runtime/src/catalog_notify.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion crates/labby-runtime/src/code_mode_app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ pub struct CodeModeAppState {

impl Default for CodeModeAppState {
fn default() -> Self {
Self::new(true)
Self::new(false)
}
}

Expand Down Expand Up @@ -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);
Expand Down
63 changes: 32 additions & 31 deletions crates/labby-runtime/src/gateway_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,36 +95,34 @@ fn default_mcp_scopes() -> Vec<String> {

// ─── 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.
Expand Down Expand Up @@ -195,7 +193,8 @@ pub struct CodeModeConfig {
pub trusted_read_only_tools: Vec<String>,
/// 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")]
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down
11 changes: 10 additions & 1 deletion crates/labby/src/api/services/gateway.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());

Expand Down
24 changes: 21 additions & 3 deletions crates/labby/src/cli/serve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
22 changes: 15 additions & 7 deletions crates/labby/src/mcp/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,12 @@ For normal services, `dispatch/<service>/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
Expand Down Expand Up @@ -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.
Expand All @@ -227,7 +232,10 @@ Resources are read-only. Do not use them for mutations.
- any other `ui://<upstream>/…` — 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

Expand Down
Loading
Loading