From 0d9b8be3fd07ad1efe4c26fbb3f9fcdfb375e194 Mon Sep 17 00:00:00 2001 From: fluzko Date: Wed, 29 Jul 2026 10:05:35 -0300 Subject: [PATCH 01/39] feat: add [mcp] config section --- src/config.rs | 216 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 216 insertions(+) diff --git a/src/config.rs b/src/config.rs index 84b74bb7..8eb6bcc1 100644 --- a/src/config.rs +++ b/src/config.rs @@ -92,6 +92,10 @@ pub struct Config { #[serde(default, skip_serializing_if = "TelemetryConfig::is_default")] pub telemetry: TelemetryConfig, + /// MCP meta-server settings. + #[serde(default, skip_serializing_if = "McpConfig::is_default")] + pub mcp: McpConfig, + /// Agents configured for this user. #[serde(default, rename = "agent")] pub agents: Vec, @@ -148,6 +152,132 @@ impl Default for LoggingConfig { } } +/// Settings for the MCP meta-server (`cargo agents mcp-serve`). +/// +/// Two groups of knobs with different owners. The sandbox limits +/// (`script-*`, `max-*`) protect the user's session from a runaway +/// agent-authored script, so a plugin may not raise its own ceiling. The +/// server timings (`server-startup-timeout-secs`, `tool-call-timeout-secs`) +/// describe a backing server, so plugins may override those per entry. +/// +/// `#[serde(default)]` on the container means every missing key falls back to +/// the value in [`McpConfig::default`], which is the single source of truth. +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] +#[serde(default, deny_unknown_fields)] +pub struct McpConfig { + /// Register the meta-server and serve `mcp-serve`. + pub enabled: bool, + + /// Wall-clock ceiling for one `execute` call. + /// + /// Bounds everything inside it, so it must exceed + /// `tool-call-timeout-secs` or that knob can never fire. + #[serde(rename = "script-timeout-secs")] + pub script_timeout_secs: u64, + + /// Memory ceiling for the JS runtime. + #[serde(rename = "script-memory-limit-mb")] + pub script_memory_limit_mb: u64, + + /// Stack ceiling for the JS runtime. The engine thread's own stack is + /// sized above this, so deep recursion throws rather than segfaulting. + #[serde(rename = "script-stack-limit-kb")] + pub script_stack_limit_kb: u64, + + /// Maximum backing-server tool calls one `execute` may make. + #[serde(rename = "max-tool-calls")] + pub max_tool_calls: u32, + + /// Maximum tool calls in flight at once from one script. Also caps how + /// many cold backing servers may be spawned concurrently. + #[serde(rename = "max-concurrent-tool-calls")] + pub max_concurrent_tool_calls: u32, + + /// Ceiling on a serialized `execute` return value. Oversized results are + /// truncated with a marker rather than rejected, so a script that already + /// performed side effects does not lose its work. + #[serde(rename = "max-result-bytes")] + pub max_result_bytes: usize, + + /// Ceiling on captured `console` output for one `execute`. + #[serde(rename = "max-console-bytes")] + pub max_console_bytes: usize, + + /// Backstop ceiling on a `list_tools` response. Not the primary size + /// control — `list_tools` returns an index by default. + #[serde(rename = "max-declaration-bytes")] + pub max_declaration_bytes: usize, + + /// Ceiling on spawning a backing server and completing its handshake. + #[serde(rename = "server-startup-timeout-secs")] + pub server_startup_timeout_secs: u64, + + /// Ceiling on a single backing-server tool call. + #[serde(rename = "tool-call-timeout-secs")] + pub tool_call_timeout_secs: u64, + + /// Restart attempts before a backing server is marked permanently failed. + #[serde(rename = "max-server-restarts")] + pub max_server_restarts: u32, + + /// How long a backing server must stay connected before its restart + /// counter resets. Without this a server that dies rarely still exhausts + /// its budget eventually. + #[serde(rename = "restart-stable-reset-secs")] + pub restart_stable_reset_secs: u64, + + /// How long a backing server gets to exit gracefully before its process + /// group is killed. + #[serde(rename = "shutdown-grace-secs")] + pub shutdown_grace_secs: u64, + + /// How long a cached `tools/list` payload stays valid on disk, letting a + /// later session render declarations without spawning anything. + #[serde(rename = "declaration-cache-ttl-secs")] + pub declaration_cache_ttl_secs: u64, + + /// Poll interval for re-fetching a backing server's tool list. 0 relies + /// solely on `notifications/tools/list_changed`. + #[serde(rename = "tool-discovery-interval-secs")] + pub tool_discovery_interval_secs: u64, + + /// Expose only tools annotated `readOnlyHint`, and reject the rest at + /// dispatch. The annotation is self-declared by the backing server, so + /// this is a guardrail against agent mistakes, not a security boundary. + #[serde(rename = "read-only")] + pub read_only: bool, +} + +impl Default for McpConfig { + fn default() -> Self { + Self { + enabled: true, + script_timeout_secs: 120, + script_memory_limit_mb: 64, + script_stack_limit_kb: 1024, + max_tool_calls: 100, + max_concurrent_tool_calls: 4, + max_result_bytes: 32 * 1024, + max_console_bytes: 8 * 1024, + max_declaration_bytes: 64 * 1024, + server_startup_timeout_secs: 30, + tool_call_timeout_secs: 60, + max_server_restarts: 5, + restart_stable_reset_secs: 300, + shutdown_grace_secs: 5, + declaration_cache_ttl_secs: 86_400, + tool_discovery_interval_secs: 0, + read_only: false, + } + } +} + +impl McpConfig { + fn is_default(&self) -> bool { + *self == McpConfig::default() + } +} + impl Default for Config { fn default() -> Self { Self { @@ -157,6 +287,7 @@ impl Default for Config { hook_scope: HookScope::default(), auto_update: AutoUpdate::default(), telemetry: TelemetryConfig::default(), + mcp: McpConfig::default(), agents: Vec::new(), logging: LoggingConfig::default(), defaults: DefaultsConfig::default(), @@ -180,6 +311,8 @@ struct RawConfig { auto_update: AutoUpdate, #[serde(default)] telemetry: TelemetryConfig, + #[serde(default)] + mcp: McpConfig, #[serde(default, rename = "agent")] agents: Vec, #[serde(default)] @@ -205,6 +338,7 @@ impl RawConfig { hook_scope: self.hook_scope, auto_update: self.auto_update, telemetry: self.telemetry, + mcp: self.mcp, agents: self.agents, logging: self.logging, defaults: self.defaults, @@ -222,6 +356,7 @@ impl From for RawConfig { hook_scope: config.hook_scope, auto_update: config.auto_update, telemetry: config.telemetry, + mcp: config.mcp, agents: config.agents, logging: config.logging, defaults: config.defaults, @@ -730,6 +865,87 @@ mod tests { ); } + #[test] + fn parse_mcp_defaults() { + let config = parse_config(""); + let mcp = &config.mcp; + assert!(mcp.enabled); + assert!(!mcp.read_only); + assert_eq!(mcp.script_timeout_secs, 120); + assert_eq!(mcp.tool_call_timeout_secs, 60); + assert_eq!(mcp.server_startup_timeout_secs, 30); + assert_eq!(mcp.max_result_bytes, 32 * 1024); + assert_eq!(mcp.max_server_restarts, 5); + assert_eq!(mcp.tool_discovery_interval_secs, 0); + } + + /// An `[mcp]` table sets only the keys it names; the rest keep their defaults. + #[test] + fn parse_mcp_partial_table_keeps_other_defaults() { + let config = parse_config(indoc! {" + [mcp] + read-only = true + max-tool-calls = 7 + "}); + assert!(config.mcp.read_only); + assert_eq!(config.mcp.max_tool_calls, 7); + assert_eq!( + config.mcp.script_timeout_secs, + McpConfig::default().script_timeout_secs, + "unnamed keys should keep their defaults, got: {:#?}", + config.mcp + ); + } + + #[test] + fn parse_mcp_disabled() { + let config = parse_config(indoc! {" + [mcp] + enabled = false + "}); + assert!(!config.mcp.enabled); + } + + /// A misspelled key is rejected rather than silently ignored. + #[test] + fn parse_mcp_rejects_unknown_key() { + let err = toml::from_str::(indoc! {" + [mcp] + script-timout-secs = 30 + "}) + .expect_err("misspelled key should not parse"); + assert!( + err.to_string().contains("script-timout-secs"), + "error should name the offending key, got: {err}" + ); + } + + #[test] + fn default_mcp_is_omitted_from_serialized_config() { + let config = Config::default(); + let serialized = toml::to_string_pretty(&config).unwrap(); + assert!( + !serialized.contains("[mcp]"), + "default mcp settings should not be written to config.toml: {serialized}" + ); + } + + #[test] + fn customized_mcp_is_written_to_serialized_config() { + let config = Config { + mcp: McpConfig { + read_only: true, + ..McpConfig::default() + }, + ..Config::default() + }; + let serialized = toml::to_string_pretty(&config).unwrap(); + assert!( + serialized.contains("read-only = true"), + "customized mcp settings should round-trip: {serialized}" + ); + } + #[test] fn parse_agents_syncing_disabled() { let config = parse_config(indoc! {" From a60e53c15ce476921fc79b8c1bcfc5b07226317d Mon Sep 17 00:00:00 2001 From: fluzko Date: Wed, 29 Jul 2026 10:18:06 -0300 Subject: [PATCH 02/39] feat: validate mcp timeout ordering --- src/config.rs | 94 ++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 81 insertions(+), 13 deletions(-) diff --git a/src/config.rs b/src/config.rs index 8eb6bcc1..70b3144b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -276,6 +276,19 @@ impl McpConfig { fn is_default(&self) -> bool { *self == McpConfig::default() } + + /// The script deadline bounds every tool call made inside it, so a + /// per-call timeout at or above it can never fire. + fn validate(&self) -> anyhow::Result<()> { + if self.tool_call_timeout_secs >= self.script_timeout_secs { + anyhow::bail!( + "[mcp] tool-call-timeout-secs ({}) must be less than script-timeout-secs ({})", + self.tool_call_timeout_secs, + self.script_timeout_secs + ); + } + Ok(()) + } } impl Default for Config { @@ -330,8 +343,9 @@ impl Default for RawConfig { } impl RawConfig { - fn validate(self) -> Config { - Config { + fn validate(self) -> anyhow::Result { + self.mcp.validate()?; + Ok(Config { auto_sync: self.auto_sync, agents_syncing: self.agents_syncing, sync_debounce_secs: self.sync_debounce_secs, @@ -343,7 +357,7 @@ impl RawConfig { logging: self.logging, defaults: self.defaults, plugin_source: self.plugin_source, - } + }) } } @@ -676,15 +690,19 @@ fn resolve_logs_dir(config_dir: &Path) -> PathBuf { /// Load config from a config directory. fn load_config_from(config_dir: &Path) -> Config { let path = config_dir.join("config.toml"); - match fs::read_to_string(&path) { - Ok(contents) => toml::from_str::(&contents) - .unwrap_or_else(|e| { - eprintln!("warning: failed to parse {}: {e}", path.display()); - RawConfig::default() - }) - .validate(), - Err(_) => Config::default(), - } + let Ok(contents) = fs::read_to_string(&path) else { + return Config::default(); + }; + toml::from_str::(&contents) + .unwrap_or_else(|e| { + eprintln!("warning: failed to parse {}: {e}", path.display()); + RawConfig::default() + }) + .validate() + .unwrap_or_else(|e| { + eprintln!("warning: invalid config in {}: {e}", path.display()); + Config::default() + }) } fn default_true() -> bool { @@ -705,7 +723,18 @@ mod tests { use indoc::indoc; fn parse_config(toml: &str) -> Config { - toml::from_str::(toml).unwrap().validate() + toml::from_str::(toml) + .unwrap() + .validate() + .unwrap() + } + + fn validate_err(toml: &str) -> String { + toml::from_str::(toml) + .unwrap() + .validate() + .expect_err("config should have been rejected") + .to_string() } #[test] @@ -920,6 +949,45 @@ mod tests { ); } + /// A per-call timeout at or above the script deadline could never fire, + /// so it is rejected rather than silently ignored. + #[test] + fn mcp_rejects_tool_call_timeout_at_or_above_script_timeout() { + for (call, script) in [(60u64, 60u64), (120, 30)] { + let err = validate_err(&format!( + "[mcp]\nscript-timeout-secs = {script}\ntool-call-timeout-secs = {call}\n" + )); + assert!( + err.contains("tool-call-timeout-secs") && err.contains("script-timeout-secs"), + "error should name both knobs for call={call} script={script}, got: {err}" + ); + } + } + + #[test] + fn mcp_accepts_tool_call_timeout_below_script_timeout() { + let config = parse_config(indoc! {" + [mcp] + script-timeout-secs = 30 + tool-call-timeout-secs = 29 + "}); + assert_eq!(config.mcp.tool_call_timeout_secs, 29); + } + + /// Raising only the per-call timeout must not silently pass by sitting + /// under the *default* script deadline the user never touched. + #[test] + fn mcp_rejects_raised_tool_call_timeout_against_default_script_timeout() { + let err = validate_err(indoc! {" + [mcp] + tool-call-timeout-secs = 600 + "}); + assert!( + err.contains("600"), + "error should quote the offending value, got: {err}" + ); + } + #[test] fn default_mcp_is_omitted_from_serialized_config() { let config = Config::default(); From 68e91d6fb37145dbde3bdda63c26606f42c8aedf Mon Sep 17 00:00:00 2001 From: fluzko Date: Wed, 29 Jul 2026 11:46:48 -0300 Subject: [PATCH 03/39] feat: add per-server mcp overrides --- src/plugins.rs | 203 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 203 insertions(+) diff --git a/src/plugins.rs b/src/plugins.rs index bff6a952..9829d340 100644 --- a/src/plugins.rs +++ b/src/plugins.rs @@ -28,10 +28,75 @@ pub struct PluginMcpServer { skip_serializing_if = "crate::predicate::PredicateSet::is_empty" )] pub predicates: crate::predicate::PredicateSet, + + /// Plugin-authored timing and tool-visibility overrides for this server. + #[serde(flatten)] + pub overrides: McpServerOverrides, + #[serde(flatten)] pub server: McpServerEntry, } +/// Per-server settings a plugin author may set, overriding the user's `[mcp]` +/// defaults. +/// +/// These describe the *server* — how slow it is to start, which of its tools +/// are worth exposing — which is the plugin author's knowledge. Sandbox limits +/// stay user-owned: a plugin must not be able to raise its own ceiling. +/// +/// The timeouts are clamped against the user's `script-timeout-secs` when a +/// server is dispatched, not here, because a manifest cannot see user config +/// and must not fail to parse because a user lowered their own limit. +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)] +pub struct McpServerOverrides { + /// Ceiling on spawning this server and completing its handshake. + #[serde( + default, + rename = "startup-timeout-secs", + skip_serializing_if = "Option::is_none" + )] + pub startup_timeout_secs: Option, + + /// Ceiling on a single tool call to this server. + #[serde( + default, + rename = "tool-call-timeout-secs", + skip_serializing_if = "Option::is_none" + )] + pub tool_call_timeout_secs: Option, + + /// Expose only these tools. Mutually exclusive with `disabled-tools`. + /// + /// An empty list is distinct from absence: it exposes nothing. + #[serde( + default, + rename = "enabled-tools", + skip_serializing_if = "Option::is_none" + )] + pub enabled_tools: Option>, + + /// Expose everything except these tools. Mutually exclusive with + /// `enabled-tools`. + #[serde( + default, + rename = "disabled-tools", + skip_serializing_if = "Option::is_none" + )] + pub disabled_tools: Option>, +} + +impl McpServerOverrides { + fn validate(&self, server_name: &str) -> Result<()> { + if self.enabled_tools.is_some() && self.disabled_tools.is_some() { + bail!( + "mcp server `{server_name}` sets both `enabled-tools` and `disabled-tools`; \ + use one or the other" + ); + } + Ok(()) + } +} + #[derive(Debug, Deserialize)] struct RawPluginMcpServer { #[serde(default, rename = "depends-on")] @@ -41,6 +106,19 @@ struct RawPluginMcpServer { crates: Option, #[serde(default)] predicates: crate::predicate::PredicateSet, + + // Override fields are named siblings rather than a second `flatten`: + // two flattened fields make serde hand the same residual map to both, and + // the untagged `McpServer` enum cannot deserialize from it. + #[serde(default, rename = "startup-timeout-secs")] + startup_timeout_secs: Option, + #[serde(default, rename = "tool-call-timeout-secs")] + tool_call_timeout_secs: Option, + #[serde(default, rename = "enabled-tools")] + enabled_tools: Option>, + #[serde(default, rename = "disabled-tools")] + disabled_tools: Option>, + #[serde(flatten)] server: McpServerEntry, } @@ -48,13 +126,31 @@ struct RawPluginMcpServer { impl RawPluginMcpServer { fn validate(self) -> Result { reject_crates_field(&self.crates)?; + let overrides = McpServerOverrides { + startup_timeout_secs: self.startup_timeout_secs, + tool_call_timeout_secs: self.tool_call_timeout_secs, + enabled_tools: self.enabled_tools, + disabled_tools: self.disabled_tools, + }; + overrides.validate(server_name(&self.server))?; Ok(PluginMcpServer { predicates: crate::predicate::PredicateSet::merged(self.depends_on, self.predicates), + overrides, server: self.server, }) } } +/// Name of an MCP server entry, whatever its transport. +fn server_name(server: &McpServerEntry) -> &str { + match server { + McpServer::Stdio(s) => &s.name, + McpServer::Http(s) => &s.name, + McpServer::Sse(s) => &s.name, + _ => "", + } +} + /// Shared rejection for the retired `crates` field, with a migration hint. fn reject_crates_field(crates: &Option) -> Result<()> { if crates.is_some() { @@ -3332,6 +3428,113 @@ mod tests { assert!(plugin.mcp_servers.is_empty()); } + #[test] + fn mcp_server_without_overrides_leaves_them_unset() { + let plugin = from_str(indoc! {r#" + name = "p" + depends-on = ["*"] + + [[mcp_servers]] + name = "sqlx" + command = "/usr/bin/true" + args = [] + env = [] + "#}) + .expect("parse"); + assert_eq!( + plugin.mcp_servers[0].overrides, + McpServerOverrides::default(), + "absent overrides should stay None, got: {:#?}", + plugin.mcp_servers[0].overrides + ); + } + + #[test] + fn mcp_server_parses_timing_and_tool_overrides() { + let plugin = from_str(indoc! {r#" + name = "p" + depends-on = ["*"] + + [[mcp_servers]] + name = "sqlx" + command = "/usr/bin/true" + args = [] + env = [] + startup-timeout-secs = 45 + tool-call-timeout-secs = 90 + enabled-tools = ["query", "explain"] + "#}) + .expect("parse"); + let o = &plugin.mcp_servers[0].overrides; + assert_eq!(o.startup_timeout_secs, Some(45)); + assert_eq!(o.tool_call_timeout_secs, Some(90)); + assert_eq!( + o.enabled_tools.as_deref(), + Some(&["query".to_string(), "explain".to_string()][..]) + ); + assert_eq!(o.disabled_tools, None); + } + + /// An empty allow-list means "expose nothing", which is distinct from + /// omitting the field. + #[test] + fn mcp_server_empty_enabled_tools_is_not_absence() { + let plugin = from_str(indoc! {r#" + name = "p" + depends-on = ["*"] + + [[mcp_servers]] + name = "sqlx" + command = "/usr/bin/true" + args = [] + env = [] + enabled-tools = [] + "#}) + .expect("parse"); + assert_eq!(plugin.mcp_servers[0].overrides.enabled_tools, Some(vec![])); + } + + #[test] + fn mcp_server_with_both_tool_lists_errors() { + let err = from_str(indoc! {r#" + name = "p" + depends-on = ["*"] + + [[mcp_servers]] + name = "sqlx" + command = "/usr/bin/true" + args = [] + env = [] + enabled-tools = ["query"] + disabled-tools = ["drop"] + "#}) + .expect_err("both tool lists should be rejected"); + assert!( + err.to_string().contains("sqlx"), + "error should name the offending server, got: {err}" + ); + } + + #[test] + fn mcp_server_overrides_work_on_http_transport() { + let plugin = from_str(indoc! {r#" + name = "p" + depends-on = ["*"] + + [[mcp_servers]] + type = "http" + name = "remote" + url = "http://localhost:8080/mcp" + headers = [] + tool-call-timeout-secs = 15 + "#}) + .expect("parse"); + assert_eq!( + plugin.mcp_servers[0].overrides.tool_call_timeout_secs, + Some(15) + ); + } + #[test] fn mcp_entry_stdio() { let entry: McpServerEntry = toml::from_str(indoc! {r#" From ab6e9ca11ebaacd9b522962aecdc234b0e1460d8 Mon Sep 17 00:00:00 2001 From: fluzko Date: Wed, 29 Jul 2026 12:06:58 -0300 Subject: [PATCH 04/39] feat: add schema-to-typescript type mapping --- src/lib.rs | 1 + src/mcp/mod.rs | 9 + src/mcp/schema_to_ts.rs | 381 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 391 insertions(+) create mode 100644 src/mcp/mod.rs create mode 100644 src/mcp/schema_to_ts.rs diff --git a/src/lib.rs b/src/lib.rs index 6b1bf965..8c90453f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,7 @@ pub mod help_render; pub mod hook; pub mod hook_schema; pub(crate) mod installation; +pub mod mcp; pub mod output; pub mod plugins; pub mod pm; diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs new file mode 100644 index 00000000..f602419a --- /dev/null +++ b/src/mcp/mod.rs @@ -0,0 +1,9 @@ +//! The MCP meta-server. +//! +//! Symposium registers a single MCP server with each configured agent. That +//! server exposes the tools of every applicable plugin MCP server, instead of +//! each plugin being registered separately. +//! +//! See the [MCP meta-server RFD](../../md/rfds/mcp-meta-server/README.md). + +pub mod schema_to_ts; diff --git a/src/mcp/schema_to_ts.rs b/src/mcp/schema_to_ts.rs new file mode 100644 index 00000000..ebed6ae9 --- /dev/null +++ b/src/mcp/schema_to_ts.rs @@ -0,0 +1,381 @@ +//! JSON Schema to TypeScript type expressions. +//! +//! Backing MCP servers describe their tool parameters with JSON Schema, but the +//! agent writes JavaScript against those tools. Models read TypeScript +//! declarations far more reliably than raw JSON Schema, so schemas are rendered +//! as `.d.ts`-style type expressions. +//! +//! Two rules govern everything here: +//! +//! * **Never fail.** Schemas arrive from arbitrary third-party servers across +//! several JSON Schema dialects. A construct we do not recognize renders as +//! `unknown` and generation continues. +//! * **`unknown`, not `any`.** `unknown` forces the model to narrow the value +//! rather than silently assuming a shape that may not hold. + +use serde_json::{Map, Value}; + +/// Rendered when a schema is missing, unrecognized, or unconstrained. +const UNKNOWN: &str = "unknown"; + +/// Render a JSON Schema as a TypeScript type expression. +pub fn render_type(schema: &Value) -> String { + render_at(schema, 0) +} + +fn render_at(schema: &Value, indent: usize) -> String { + match schema { + // A schema may be a bare boolean: `true` accepts anything, `false` + // accepts nothing. + Value::Bool(true) => UNKNOWN.to_string(), + Value::Bool(false) => "never".to_string(), + Value::Object(map) => render_object_schema(map, indent), + // Anything else is malformed; degrade rather than fail. + _ => UNKNOWN.to_string(), + } +} + +fn render_object_schema(schema: &Map, indent: usize) -> String { + if schema.is_empty() { + return UNKNOWN.to_string(); + } + + // `enum` constrains the value regardless of any declared `type`, so it + // wins over the `type` dispatch below. + if let Some(Value::Array(values)) = schema.get("enum") { + return render_enum(values); + } + + let Some(Value::String(ty)) = schema.get("type") else { + return UNKNOWN.to_string(); + }; + + match ty.as_str() { + "string" => "string".to_string(), + // JSON Schema separates integers from other numbers; TypeScript does not. + "number" | "integer" => "number".to_string(), + "boolean" => "boolean".to_string(), + "null" => "null".to_string(), + "array" => render_array(schema, indent), + "object" => render_struct(schema, indent), + _ => UNKNOWN.to_string(), + } +} + +fn render_array(schema: &Map, indent: usize) -> String { + let Some(items) = schema.get("items") else { + return format!("{UNKNOWN}[]"); + }; + let inner = render_at(items, indent); + // `A | B[]` would parse as `A | (B[])`, so a union element needs parens. + if inner.contains('|') && !inner.starts_with('(') { + format!("({inner})[]") + } else { + format!("{inner}[]") + } +} + +fn render_struct(schema: &Map, indent: usize) -> String { + let Some(Value::Object(properties)) = schema.get("properties") else { + // An object with no declared properties is an open map. Note that + // `additionalProperties: false` is deliberately ignored: it constrains + // what may be *sent*, not the shape of what is described, and treating + // it as unrecognized would degrade every ordinary object to `unknown`. + return format!("Record"); + }; + + if properties.is_empty() { + return "{}".to_string(); + } + + let required: Vec<&str> = match schema.get("required") { + Some(Value::Array(names)) => names.iter().filter_map(Value::as_str).collect(), + _ => Vec::new(), + }; + + let pad = " ".repeat(indent + 1); + let close_pad = " ".repeat(indent); + let mut out = String::from("{\n"); + + for (name, subschema) in properties { + if let Some(doc) = doc_comment(subschema) { + out.push_str(&format!("{pad}/** {doc} */\n")); + } + let optional = if required.contains(&name.as_str()) { + "" + } else { + "?" + }; + let rendered = render_at(subschema, indent + 1); + out.push_str(&format!( + "{pad}{}{optional}: {rendered};\n", + property_key(name) + )); + } + + out.push_str(&close_pad); + out.push('}'); + out +} + +fn render_enum(values: &[Value]) -> String { + if values.is_empty() { + return "never".to_string(); + } + let mut rendered: Vec = values.iter().map(render_literal).collect(); + rendered.dedup(); + rendered.join(" | ") +} + +/// Render a JSON value as a TypeScript literal type. +fn render_literal(value: &Value) -> String { + match value { + Value::String(s) => format!("{s:?}"), + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + Value::Null => "null".to_string(), + // An object or array literal has no TypeScript literal-type spelling. + _ => UNKNOWN.to_string(), + } +} + +/// A property's `description`, collapsed to one line and safe inside a JSDoc +/// block. +fn doc_comment(schema: &Value) -> Option { + let text = schema.get("description")?.as_str()?.trim(); + if text.is_empty() { + return None; + } + // `*/` inside a JSDoc comment would end it early. + let collapsed = text.split_whitespace().collect::>().join(" "); + Some(collapsed.replace("*/", "* /")) +} + +/// Quote a property name unless it is a valid JavaScript identifier. +/// +/// Tool schemas routinely use names that are not identifiers (`content-type`, +/// `2fa`), and those are legal as quoted property keys. +fn property_key(name: &str) -> String { + if is_js_identifier(name) { + name.to_string() + } else { + format!("{name:?}") + } +} + +fn is_js_identifier(name: &str) -> bool { + let mut chars = name.chars(); + let Some(first) = chars.next() else { + return false; + }; + if !(first.is_ascii_alphabetic() || first == '_' || first == '$') { + return false; + } + chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$') +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn render(schema: Value) -> String { + render_type(&schema) + } + + // -- scalars -- + + #[test] + fn renders_scalar_types() { + assert_eq!(render(json!({"type": "string"})), "string"); + assert_eq!(render(json!({"type": "boolean"})), "boolean"); + assert_eq!(render(json!({"type": "null"})), "null"); + } + + /// JSON Schema distinguishes integers from other numbers; TypeScript has + /// only `number`. + #[test] + fn renders_integer_as_number() { + assert_eq!(render(json!({"type": "number"})), "number"); + assert_eq!(render(json!({"type": "integer"})), "number"); + } + + // -- degradation -- + + #[test] + fn renders_unconstrained_schemas_as_unknown() { + assert_eq!(render(json!({})), "unknown"); + assert_eq!(render(json!(true)), "unknown"); + assert_eq!(render(json!({"type": "not-a-type"})), "unknown"); + assert_eq!(render(json!({"minimum": 3})), "unknown"); + } + + #[test] + fn renders_uninhabited_schemas_as_never() { + assert_eq!(render(json!(false)), "never"); + assert_eq!(render(json!({"enum": []})), "never"); + } + + // -- arrays -- + + #[test] + fn renders_arrays() { + assert_eq!( + render(json!({"type": "array", "items": {"type": "string"}})), + "string[]" + ); + assert_eq!(render(json!({"type": "array"})), "unknown[]"); + } + + /// `A | B[]` parses as `A | (B[])`, so a union element must be parenthesized. + #[test] + fn parenthesizes_union_array_elements() { + let out = render(json!({ + "type": "array", + "items": {"enum": ["a", "b"]}, + })); + assert_eq!(out, r#"("a" | "b")[]"#); + } + + // -- enums -- + + #[test] + fn renders_string_enum_as_literal_union() { + assert_eq!(render(json!({"enum": ["a", "b"]})), r#""a" | "b""#); + } + + /// Enum members are not always strings, and a declared `type` must not + /// override the narrower `enum` constraint. + #[test] + fn renders_mixed_enum_and_ignores_declared_type() { + assert_eq!(render(json!({"enum": [1, true, null]})), "1 | true | null"); + assert_eq!( + render(json!({"type": "boolean", "enum": [true]})), + "true", + "enum should win over type" + ); + } + + // -- objects -- + + #[test] + fn renders_object_with_required_and_optional_properties() { + let out = render(json!({ + "type": "object", + "properties": { + "sql": {"type": "string"}, + "limit": {"type": "integer"}, + }, + "required": ["sql"], + })); + assert_eq!(out, "{\n limit?: number;\n sql: string;\n}"); + } + + /// Optionality comes from `required` alone. Nothing else marks a property + /// optional. + #[test] + fn properties_are_optional_when_required_is_absent() { + let out = render(json!({ + "type": "object", + "properties": {"a": {"type": "string"}}, + })); + assert_eq!(out, "{\n a?: string;\n}"); + } + + /// `additionalProperties: false` is the single most common construct in + /// real tool schemas. Degrading on it would turn every ordinary object into + /// `unknown`. + #[test] + fn additional_properties_false_does_not_degrade_the_object() { + let out = render(json!({ + "type": "object", + "properties": {"a": {"type": "string"}}, + "required": ["a"], + "additionalProperties": false, + })); + assert_eq!(out, "{\n a: string;\n}"); + } + + #[test] + fn renders_object_without_properties_as_open_map() { + assert_eq!(render(json!({"type": "object"})), "Record"); + assert_eq!(render(json!({"type": "object", "properties": {}})), "{}"); + } + + #[test] + fn renders_nested_objects_with_indentation() { + let out = render(json!({ + "type": "object", + "properties": { + "outer": { + "type": "object", + "properties": {"inner": {"type": "string"}}, + "required": ["inner"], + }, + }, + "required": ["outer"], + })); + assert_eq!( + out, "{\n outer: {\n inner: string;\n };\n}", + "nested braces should indent, got:\n{out}" + ); + } + + // -- property names -- + + /// Tool schemas use property names that are not JavaScript identifiers. + #[test] + fn quotes_property_names_that_are_not_identifiers() { + let out = render(json!({ + "type": "object", + "properties": { + "content-type": {"type": "string"}, + "2fa": {"type": "boolean"}, + "ok_name": {"type": "string"}, + }, + })); + assert!(out.contains(r#""content-type"?: string;"#), "got:\n{out}"); + assert!(out.contains(r#""2fa"?: boolean;"#), "got:\n{out}"); + assert!( + out.contains("ok_name?: string;"), + "valid identifiers stay unquoted, got:\n{out}" + ); + } + + // -- documentation -- + + #[test] + fn renders_description_as_jsdoc() { + let out = render(json!({ + "type": "object", + "properties": { + "sql": {"type": "string", "description": "The query to run"}, + }, + })); + assert_eq!(out, "{\n /** The query to run */\n sql?: string;\n}"); + } + + /// A description containing `*/` would close the JSDoc block early. + #[test] + fn escapes_comment_terminator_in_description() { + let out = render(json!({ + "type": "object", + "properties": { + "a": {"type": "string", "description": "ends the block */ here"}, + }, + })); + assert!(!out.contains("*/ here"), "got:\n{out}"); + assert!(out.contains("* / here"), "got:\n{out}"); + } + + #[test] + fn collapses_multiline_descriptions() { + let out = render(json!({ + "type": "object", + "properties": { + "a": {"type": "string", "description": "first line\n second line"}, + }, + })); + assert!(out.contains("/** first line second line */"), "got:\n{out}"); + } +} From eaefbcea9b833fa1c7455c0fcf03a28716a73e39 Mon Sep 17 00:00:00 2001 From: fluzko Date: Wed, 29 Jul 2026 12:23:14 -0300 Subject: [PATCH 05/39] feat: map union and record schema forms --- src/mcp/schema_to_ts.rs | 267 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 247 insertions(+), 20 deletions(-) diff --git a/src/mcp/schema_to_ts.rs b/src/mcp/schema_to_ts.rs index ebed6ae9..3bc46689 100644 --- a/src/mcp/schema_to_ts.rs +++ b/src/mcp/schema_to_ts.rs @@ -40,17 +40,58 @@ fn render_object_schema(schema: &Map, indent: usize) -> String { return UNKNOWN.to_string(); } - // `enum` constrains the value regardless of any declared `type`, so it - // wins over the `type` dispatch below. + let base = render_constrained(schema, indent); + + // `nullable` is an OpenAPI 3.0 extension rather than JSON Schema, but the + // schemars 0.8 line emits it for optional fields and those servers are + // deployed today. + if schema.get("nullable") == Some(&Value::Bool(true)) && base != "null" { + return join_union(vec![base, "null".to_string()]); + } + base +} + +/// Dispatch on whichever constraint keyword the schema uses, narrowest first. +fn render_constrained(schema: &Map, indent: usize) -> String { + // A single permitted value is narrower than anything else present. + if let Some(value) = schema.get("const") { + return render_literal(value); + } + + // `enum` constrains the value regardless of any declared `type`. if let Some(Value::Array(values)) = schema.get("enum") { return render_enum(values); } - let Some(Value::String(ty)) = schema.get("type") else { - return UNKNOWN.to_string(); - }; + // Composition keywords. `allOf` combines constraints; `anyOf`/`oneOf` + // offer alternatives. A single-element `allOf` — the shape generators emit + // to attach a description to a reference — collapses to its one member. + if let Some(Value::Array(members)) = schema.get("allOf") { + return render_intersection(members, indent); + } + for key in ["anyOf", "oneOf"] { + if let Some(Value::Array(members)) = schema.get(key) { + return render_union(members, indent); + } + } + + match schema.get("type") { + Some(Value::String(ty)) => render_with_type(schema, ty, indent), + // A type may be a list of alternatives, e.g. `["string", "null"]`. + Some(Value::Array(types)) => { + let parts = types + .iter() + .filter_map(Value::as_str) + .map(|ty| render_with_type(schema, ty, indent)) + .collect(); + join_union(parts) + } + _ => UNKNOWN.to_string(), + } +} - match ty.as_str() { +fn render_with_type(schema: &Map, ty: &str, indent: usize) -> String { + match ty { "string" => "string".to_string(), // JSON Schema separates integers from other numbers; TypeScript does not. "number" | "integer" => "number".to_string(), @@ -62,26 +103,77 @@ fn render_object_schema(schema: &Map, indent: usize) -> String { } } +fn render_union(members: &[Value], indent: usize) -> String { + let parts = members.iter().map(|m| render_at(m, indent)).collect(); + join_union(parts) +} + +fn render_intersection(members: &[Value], indent: usize) -> String { + let mut parts: Vec = Vec::new(); + for member in members { + let rendered = parenthesize_if_composite(render_at(member, indent)); + if !parts.contains(&rendered) { + parts.push(rendered); + } + } + match parts.len() { + 0 => UNKNOWN.to_string(), + 1 => parts.pop().unwrap_or_default(), + _ => parts.join(" & "), + } +} + +/// Join alternatives into a union, dropping duplicates and flattening the +/// `unknown` case — `T | unknown` is just `unknown`. +fn join_union(parts: Vec) -> String { + let mut unique: Vec = Vec::new(); + for part in parts { + if part == UNKNOWN { + return UNKNOWN.to_string(); + } + if !unique.contains(&part) { + unique.push(part); + } + } + match unique.len() { + 0 => UNKNOWN.to_string(), + 1 => unique.pop().unwrap_or_default(), + _ => unique.join(" | "), + } +} + +/// Parenthesize a union so it binds correctly inside a larger type. +fn parenthesize_if_composite(rendered: String) -> String { + if rendered.contains(" | ") && !rendered.starts_with('(') { + format!("({rendered})") + } else { + rendered + } +} + fn render_array(schema: &Map, indent: usize) -> String { let Some(items) = schema.get("items") else { return format!("{UNKNOWN}[]"); }; - let inner = render_at(items, indent); // `A | B[]` would parse as `A | (B[])`, so a union element needs parens. - if inner.contains('|') && !inner.starts_with('(') { - format!("({inner})[]") - } else { - format!("{inner}[]") - } + let inner = parenthesize_if_composite(render_at(items, indent)); + format!("{inner}[]") } fn render_struct(schema: &Map, indent: usize) -> String { let Some(Value::Object(properties)) = schema.get("properties") else { - // An object with no declared properties is an open map. Note that - // `additionalProperties: false` is deliberately ignored: it constrains - // what may be *sent*, not the shape of what is described, and treating - // it as unrecognized would degrade every ordinary object to `unknown`. - return format!("Record"); + // No declared properties: the schema describes a map, and + // `additionalProperties` types its values. + // + // `propertyNames` is deliberately ignored — it constrains keys, which + // TypeScript index signatures cannot express. + return match schema.get("additionalProperties") { + Some(Value::Bool(false)) => "{}".to_string(), + Some(value @ Value::Object(_)) => { + format!("Record", render_at(value, indent)) + } + _ => format!("Record"), + }; }; if properties.is_empty() { @@ -122,9 +214,7 @@ fn render_enum(values: &[Value]) -> String { if values.is_empty() { return "never".to_string(); } - let mut rendered: Vec = values.iter().map(render_literal).collect(); - rendered.dedup(); - rendered.join(" | ") + join_union(values.iter().map(render_literal).collect()) } /// Render a JSON value as a TypeScript literal type. @@ -256,6 +346,143 @@ mod tests { ); } + // -- unions and composition -- + + /// The dominant Python generator emits this for every optional field, and + /// it is the single most common construct the naive mapping would drop. + #[test] + fn renders_nullable_anyof_as_union() { + let out = render(json!({ + "anyOf": [{"type": "string"}, {"type": "null"}], + "default": null, + })); + assert_eq!(out, "string | null"); + } + + /// Optionality is decided by `required`. A nullable field that *is* + /// required stays non-optional; only its type gains `null`. + #[test] + fn nullable_union_does_not_imply_optional() { + let out = render(json!({ + "type": "object", + "properties": { + "a": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + }, + "required": ["a"], + })); + assert_eq!(out, "{\n a: string | null;\n}"); + } + + #[test] + fn renders_oneof_as_union() { + let out = render(json!({ + "oneOf": [{"type": "string"}, {"type": "number"}], + })); + assert_eq!(out, "string | number"); + } + + #[test] + fn renders_allof_as_intersection() { + let out = render(json!({ + "allOf": [ + {"type": "object", "properties": {"a": {"type": "string"}}}, + {"type": "object", "properties": {"b": {"type": "number"}}}, + ], + })); + assert_eq!(out, "{\n a?: string;\n} & {\n b?: number;\n}"); + } + + /// Generators wrap a single member in `allOf` to attach a sibling + /// description. That must collapse rather than render a one-sided `&`. + #[test] + fn collapses_single_member_allof() { + let out = render(json!({ + "allOf": [{"type": "string"}], + "description": "wrapped", + })); + assert_eq!(out, "string"); + } + + /// `T | unknown` is just `unknown`, and repeated alternatives collapse. + #[test] + fn union_absorbs_unknown_and_drops_duplicates() { + assert_eq!( + render(json!({"anyOf": [{"type": "string"}, {}]})), + "unknown" + ); + assert_eq!( + render(json!({"anyOf": [{"type": "string"}, {"type": "string"}]})), + "string" + ); + } + + // -- type as a list -- + + #[test] + fn renders_array_valued_type_as_union() { + assert_eq!(render(json!({"type": ["string", "null"]})), "string | null"); + assert_eq!( + render(json!({"type": ["string", "number", "boolean"]})), + "string | number | boolean" + ); + } + + // -- const -- + + #[test] + fn renders_const_as_literal() { + assert_eq!(render(json!({"const": "resource"})), r#""resource""#); + assert_eq!(render(json!({"const": 3})), "3"); + } + + // -- nullable (OpenAPI spelling) -- + + /// One deployed Rust generator marks optional fields with `nullable` + /// rather than a union or a null-typed alternative. + #[test] + fn renders_openapi_nullable_as_union() { + let out = render(json!({ + "type": "array", + "items": {"type": "string"}, + "nullable": true, + })); + assert_eq!(out, "string[] | null"); + } + + #[test] + fn nullable_null_type_does_not_repeat_null() { + assert_eq!(render(json!({"type": "null", "nullable": true})), "null"); + } + + // -- records -- + + #[test] + fn renders_typed_additional_properties_as_record() { + let out = render(json!({ + "type": "object", + "additionalProperties": {"type": "string"}, + })); + assert_eq!(out, "Record"); + } + + /// `propertyNames` constrains keys, which a TypeScript index signature + /// cannot express, so it is ignored rather than degrading the type. + #[test] + fn ignores_property_names_constraint() { + let out = render(json!({ + "type": "object", + "propertyNames": {"type": "string"}, + "additionalProperties": {"type": "string"}, + })); + assert_eq!(out, "Record"); + } + + #[test] + fn renders_closed_empty_object_as_empty_type() { + let out = render(json!({"type": "object", "additionalProperties": false})); + assert_eq!(out, "{}"); + } + // -- objects -- #[test] From 928a1f270926e0e575e5b015e76991ec66f77c51 Mon Sep 17 00:00:00 2001 From: fluzko Date: Wed, 29 Jul 2026 12:30:27 -0300 Subject: [PATCH 06/39] feat: resolve $ref into named typescript types --- src/mcp/schema_to_ts.rs | 454 ++++++++++++++++++++++++++++++++++------ 1 file changed, 385 insertions(+), 69 deletions(-) diff --git a/src/mcp/schema_to_ts.rs b/src/mcp/schema_to_ts.rs index 3bc46689..4bf2bfb0 100644 --- a/src/mcp/schema_to_ts.rs +++ b/src/mcp/schema_to_ts.rs @@ -14,33 +14,115 @@ //! rather than silently assuming a shape that may not hold. use serde_json::{Map, Value}; +use std::collections::BTreeMap; /// Rendered when a schema is missing, unrecognized, or unconstrained. const UNKNOWN: &str = "unknown"; -/// Render a JSON Schema as a TypeScript type expression. -pub fn render_type(schema: &Value) -> String { - render_at(schema, 0) +/// Deepest nesting we will follow before giving up. +/// +/// Schemas come from third parties and may nest arbitrarily; this keeps a +/// pathological one from exhausting the stack. +const MAX_DEPTH: usize = 64; + +/// Total subschemas we will render for one tool. +/// +/// Composition keywords can fan out multiplicatively, so a depth cap alone is +/// not enough to bound the work. +const MAX_NODES: usize = 10_000; + +/// Renders schemas to TypeScript, accumulating the named types they reference. +/// +/// One renderer is shared across all of a server's tools so a type defined in +/// several tools' `$defs` is emitted once. +#[derive(Debug, Default)] +pub struct TypeRenderer { + /// Type name to rendered body, ordered so output is stable. + named: BTreeMap, +} + +impl TypeRenderer { + pub fn new() -> Self { + Self::default() + } + + /// Render `schema` as a type expression, registering any named types it + /// references. `$ref` pointers resolve against `schema` itself. + pub fn render(&mut self, schema: &Value) -> String { + let mut cx = Cx { + root: schema, + named: &mut self.named, + in_progress: Vec::new(), + budget: MAX_NODES, + }; + render_at(&mut cx, schema, 0, 0) + } + + /// The named type declarations collected so far, in name order. + pub fn declarations(&self) -> String { + let mut out = String::new(); + for (name, body) in &self.named { + // An object body is an interface; anything else is an alias. + if body.starts_with('{') { + out.push_str(&format!("interface {name} {body}\n\n")); + } else { + out.push_str(&format!("type {name} = {body};\n\n")); + } + } + out + } + + pub fn is_empty(&self) -> bool { + self.named.is_empty() + } } -fn render_at(schema: &Value, indent: usize) -> String { +/// Per-render state: the document `$ref` resolves against, the shared type +/// table, the names currently being rendered, and the remaining node budget. +struct Cx<'a> { + root: &'a Value, + named: &'a mut BTreeMap, + in_progress: Vec, + budget: usize, +} + +fn render_at(cx: &mut Cx, schema: &Value, indent: usize, depth: usize) -> String { + if depth > MAX_DEPTH || cx.budget == 0 { + return UNKNOWN.to_string(); + } + cx.budget -= 1; + match schema { // A schema may be a bare boolean: `true` accepts anything, `false` // accepts nothing. Value::Bool(true) => UNKNOWN.to_string(), Value::Bool(false) => "never".to_string(), - Value::Object(map) => render_object_schema(map, indent), + Value::Object(map) => render_object_schema(cx, map, indent, depth), // Anything else is malformed; degrade rather than fail. _ => UNKNOWN.to_string(), } } -fn render_object_schema(schema: &Map, indent: usize) -> String { +fn render_object_schema( + cx: &mut Cx, + schema: &Map, + indent: usize, + depth: usize, +) -> String { if schema.is_empty() { return UNKNOWN.to_string(); } - let base = render_constrained(schema, indent); + // A `$ref` wins over every sibling keyword. Note that siblings do appear + // alongside it: one deployed generator emits `description` next to `$ref`, + // and a resolver that returned the target directly would drop it. The + // description is read from the referring schema by the caller, so it + // survives. + if let Some(Value::String(pointer)) = schema.get("$ref") { + return render_ref(cx, pointer, depth); + } + + let base = render_constrained(cx, schema, indent, depth); // `nullable` is an OpenAPI 3.0 extension rather than JSON Schema, but the // schemars 0.8 line emits it for optional fields and those servers are @@ -51,8 +133,81 @@ fn render_object_schema(schema: &Map, indent: usize) -> String { base } +/// Resolve a `$ref`, emitting the target as a named type and returning its +/// name. +/// +/// Naming the type is what makes recursive schemas work: a self-reference +/// resolves to a name that is already being defined, rather than expanding +/// forever. +fn render_ref(cx: &mut Cx, pointer: &str, depth: usize) -> String { + let Some(name) = type_name_for(pointer) else { + // A pointer we cannot name (an external URL, say) is unresolvable. + return UNKNOWN.to_string(); + }; + + // Already emitted, or currently being emitted higher up the stack. The + // latter is the cycle case; returning the name closes the loop. + if cx.named.contains_key(&name) || cx.in_progress.contains(&name) { + return name; + } + + let Some(target) = resolve_pointer(cx.root, pointer) else { + return UNKNOWN.to_string(); + }; + // Reserve the name before rendering the body, so a self-reference + // encountered inside sees it as in progress. + cx.in_progress.push(name.clone()); + let body = render_at(cx, &target.clone(), 0, depth + 1); + cx.in_progress.pop(); + cx.named.insert(name.clone(), body); + name +} + +/// Follow a JSON pointer such as `#/$defs/Message` within `root`. +fn resolve_pointer<'a>(root: &'a Value, pointer: &str) -> Option<&'a Value> { + let path = pointer.strip_prefix('#')?; + if path.is_empty() || path == "/" { + return Some(root); + } + let mut current = root; + for raw in path.trim_start_matches('/').split('/') { + // Per RFC 6901, `~1` is an escaped `/` and `~0` an escaped `~`. + let key = raw.replace("~1", "/").replace("~0", "~"); + current = current.get(&key)?; + } + Some(current) +} + +/// Derive a TypeScript type name from a pointer's last segment. +fn type_name_for(pointer: &str) -> Option { + if !pointer.starts_with('#') { + return None; + } + let last = pointer.rsplit('/').next()?; + let cleaned: String = last + .replace("~1", "/") + .replace("~0", "~") + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect(); + if cleaned.is_empty() { + return None; + } + // A type name may not start with a digit. + if cleaned.starts_with(|c: char| c.is_ascii_digit()) { + Some(format!("_{cleaned}")) + } else { + Some(cleaned) + } +} + /// Dispatch on whichever constraint keyword the schema uses, narrowest first. -fn render_constrained(schema: &Map, indent: usize) -> String { +fn render_constrained( + cx: &mut Cx, + schema: &Map, + indent: usize, + depth: usize, +) -> String { // A single permitted value is narrower than anything else present. if let Some(value) = schema.get("const") { return render_literal(value); @@ -67,22 +222,22 @@ fn render_constrained(schema: &Map, indent: usize) -> String { // offer alternatives. A single-element `allOf` — the shape generators emit // to attach a description to a reference — collapses to its one member. if let Some(Value::Array(members)) = schema.get("allOf") { - return render_intersection(members, indent); + return render_intersection(cx, members, indent, depth); } for key in ["anyOf", "oneOf"] { if let Some(Value::Array(members)) = schema.get(key) { - return render_union(members, indent); + return render_union(cx, members, indent, depth); } } match schema.get("type") { - Some(Value::String(ty)) => render_with_type(schema, ty, indent), + Some(Value::String(ty)) => render_with_type(cx, schema, ty, indent, depth), // A type may be a list of alternatives, e.g. `["string", "null"]`. Some(Value::Array(types)) => { let parts = types .iter() .filter_map(Value::as_str) - .map(|ty| render_with_type(schema, ty, indent)) + .map(|ty| render_with_type(cx, schema, ty, indent, depth)) .collect(); join_union(parts) } @@ -90,77 +245,35 @@ fn render_constrained(schema: &Map, indent: usize) -> String { } } -fn render_with_type(schema: &Map, ty: &str, indent: usize) -> String { +fn render_with_type( + cx: &mut Cx, + schema: &Map, + ty: &str, + indent: usize, + depth: usize, +) -> String { match ty { "string" => "string".to_string(), // JSON Schema separates integers from other numbers; TypeScript does not. "number" | "integer" => "number".to_string(), "boolean" => "boolean".to_string(), "null" => "null".to_string(), - "array" => render_array(schema, indent), - "object" => render_struct(schema, indent), + "array" => render_array(cx, schema, indent, depth), + "object" => render_struct(cx, schema, indent, depth), _ => UNKNOWN.to_string(), } } -fn render_union(members: &[Value], indent: usize) -> String { - let parts = members.iter().map(|m| render_at(m, indent)).collect(); - join_union(parts) -} - -fn render_intersection(members: &[Value], indent: usize) -> String { - let mut parts: Vec = Vec::new(); - for member in members { - let rendered = parenthesize_if_composite(render_at(member, indent)); - if !parts.contains(&rendered) { - parts.push(rendered); - } - } - match parts.len() { - 0 => UNKNOWN.to_string(), - 1 => parts.pop().unwrap_or_default(), - _ => parts.join(" & "), - } -} - -/// Join alternatives into a union, dropping duplicates and flattening the -/// `unknown` case — `T | unknown` is just `unknown`. -fn join_union(parts: Vec) -> String { - let mut unique: Vec = Vec::new(); - for part in parts { - if part == UNKNOWN { - return UNKNOWN.to_string(); - } - if !unique.contains(&part) { - unique.push(part); - } - } - match unique.len() { - 0 => UNKNOWN.to_string(), - 1 => unique.pop().unwrap_or_default(), - _ => unique.join(" | "), - } -} - -/// Parenthesize a union so it binds correctly inside a larger type. -fn parenthesize_if_composite(rendered: String) -> String { - if rendered.contains(" | ") && !rendered.starts_with('(') { - format!("({rendered})") - } else { - rendered - } -} - -fn render_array(schema: &Map, indent: usize) -> String { +fn render_array(cx: &mut Cx, schema: &Map, indent: usize, depth: usize) -> String { let Some(items) = schema.get("items") else { return format!("{UNKNOWN}[]"); }; // `A | B[]` would parse as `A | (B[])`, so a union element needs parens. - let inner = parenthesize_if_composite(render_at(items, indent)); + let inner = parenthesize_if_composite(render_at(cx, items, indent, depth + 1)); format!("{inner}[]") } -fn render_struct(schema: &Map, indent: usize) -> String { +fn render_struct(cx: &mut Cx, schema: &Map, indent: usize, depth: usize) -> String { let Some(Value::Object(properties)) = schema.get("properties") else { // No declared properties: the schema describes a map, and // `additionalProperties` types its values. @@ -170,7 +283,10 @@ fn render_struct(schema: &Map, indent: usize) -> String { return match schema.get("additionalProperties") { Some(Value::Bool(false)) => "{}".to_string(), Some(value @ Value::Object(_)) => { - format!("Record", render_at(value, indent)) + format!( + "Record", + render_at(cx, value, indent, depth + 1) + ) } _ => format!("Record"), }; @@ -198,7 +314,7 @@ fn render_struct(schema: &Map, indent: usize) -> String { } else { "?" }; - let rendered = render_at(subschema, indent + 1); + let rendered = render_at(cx, subschema, indent + 1, depth + 1); out.push_str(&format!( "{pad}{}{optional}: {rendered};\n", property_key(name) @@ -210,6 +326,29 @@ fn render_struct(schema: &Map, indent: usize) -> String { out } +fn render_union(cx: &mut Cx, members: &[Value], indent: usize, depth: usize) -> String { + let parts = members + .iter() + .map(|m| render_at(cx, m, indent, depth + 1)) + .collect(); + join_union(parts) +} + +fn render_intersection(cx: &mut Cx, members: &[Value], indent: usize, depth: usize) -> String { + let mut parts: Vec = Vec::new(); + for member in members { + let rendered = parenthesize_if_composite(render_at(cx, member, indent, depth + 1)); + if !parts.contains(&rendered) { + parts.push(rendered); + } + } + match parts.len() { + 0 => UNKNOWN.to_string(), + 1 => parts.pop().unwrap_or_default(), + _ => parts.join(" & "), + } +} + fn render_enum(values: &[Value]) -> String { if values.is_empty() { return "never".to_string(); @@ -217,6 +356,34 @@ fn render_enum(values: &[Value]) -> String { join_union(values.iter().map(render_literal).collect()) } +/// Join alternatives into a union, dropping duplicates and flattening the +/// `unknown` case — `T | unknown` is just `unknown`. +fn join_union(parts: Vec) -> String { + let mut unique: Vec = Vec::new(); + for part in parts { + if part == UNKNOWN { + return UNKNOWN.to_string(); + } + if !unique.contains(&part) { + unique.push(part); + } + } + match unique.len() { + 0 => UNKNOWN.to_string(), + 1 => unique.pop().unwrap_or_default(), + _ => unique.join(" | "), + } +} + +/// Parenthesize a union so it binds correctly inside a larger type. +fn parenthesize_if_composite(rendered: String) -> String { + if rendered.contains(" | ") && !rendered.starts_with('(') { + format!("({rendered})") + } else { + rendered + } +} + /// Render a JSON value as a TypeScript literal type. fn render_literal(value: &Value) -> String { match value { @@ -270,7 +437,7 @@ mod tests { use serde_json::json; fn render(schema: Value) -> String { - render_type(&schema) + TypeRenderer::new().render(&schema) } // -- scalars -- @@ -346,6 +513,155 @@ mod tests { ); } + // -- references and named types -- + + /// Render a schema and return both the expression and the declarations of + /// every named type it pulled in. + fn render_with_defs(schema: Value) -> (String, String) { + let mut r = TypeRenderer::new(); + let expr = r.render(&schema); + (expr, r.declarations()) + } + + #[test] + fn resolves_ref_into_a_named_interface() { + let (expr, defs) = render_with_defs(json!({ + "type": "object", + "properties": {"msg": {"$ref": "#/$defs/Message"}}, + "required": ["msg"], + "$defs": { + "Message": { + "type": "object", + "properties": {"text": {"type": "string"}}, + "required": ["text"], + }, + }, + })); + assert_eq!(expr, "{\n msg: Message;\n}"); + assert_eq!(defs, "interface Message {\n text: string;\n}\n\n"); + } + + /// The older dialect spells the same thing `definitions`. + #[test] + fn resolves_legacy_definitions_pointer() { + let (expr, defs) = render_with_defs(json!({ + "$ref": "#/definitions/Role", + "definitions": {"Role": {"enum": ["admin", "user"]}}, + })); + assert_eq!(expr, "Role"); + assert_eq!(defs, "type Role = \"admin\" | \"user\";\n\n"); + } + + /// Naming the target is what makes recursion terminate: the self-reference + /// resolves to a name that is already being defined. + #[test] + fn recursive_ref_terminates() { + let (expr, defs) = render_with_defs(json!({ + "$ref": "#/$defs/Node", + "$defs": { + "Node": { + "type": "object", + "properties": { + "children": {"type": "array", "items": {"$ref": "#/$defs/Node"}}, + }, + }, + }, + })); + assert_eq!(expr, "Node"); + assert!( + defs.contains("children?: Node[];"), + "self-reference should render as the name, got:\n{defs}" + ); + } + + /// Two schemas referring to each other must not expand forever. + #[test] + fn mutually_recursive_refs_terminate() { + let (_, defs) = render_with_defs(json!({ + "$ref": "#/$defs/A", + "$defs": { + "A": {"type": "object", "properties": {"b": {"$ref": "#/$defs/B"}}}, + "B": {"type": "object", "properties": {"a": {"$ref": "#/$defs/A"}}}, + }, + })); + assert!(defs.contains("interface A"), "got:\n{defs}"); + assert!(defs.contains("interface B"), "got:\n{defs}"); + } + + /// One deployed generator emits `description` beside `$ref`. Resolving the + /// reference must not discard it. + #[test] + fn ref_with_sibling_description_keeps_the_description() { + let (expr, _) = render_with_defs(json!({ + "type": "object", + "properties": { + "msg": { + "$ref": "#/$defs/Message", + "description": "the message", + }, + }, + "$defs": {"Message": {"type": "string"}}, + })); + assert!(expr.contains("/** the message */"), "got:\n{expr}"); + assert!(expr.contains("msg?: Message;"), "got:\n{expr}"); + } + + /// A type shared by two properties is emitted once. + #[test] + fn shared_ref_is_emitted_once() { + let (_, defs) = render_with_defs(json!({ + "type": "object", + "properties": { + "from": {"$ref": "#/$defs/User"}, + "to": {"$ref": "#/$defs/User"}, + }, + "$defs": {"User": {"type": "object", "properties": {"id": {"type": "string"}}}}, + })); + assert_eq!(defs.matches("interface User").count(), 1, "got:\n{defs}"); + } + + #[test] + fn unresolvable_refs_degrade_to_unknown() { + assert_eq!(render(json!({"$ref": "#/$defs/Missing"})), "unknown"); + assert_eq!( + render(json!({"$ref": "https://example.com/schema.json"})), + "unknown", + "an external reference cannot be resolved offline" + ); + } + + /// A renderer is shared across a server's tools, so a type defined in two + /// tools' `$defs` is emitted once. + #[test] + fn named_types_accumulate_across_renders() { + let mut r = TypeRenderer::new(); + for _ in 0..2 { + r.render(&json!({ + "$ref": "#/$defs/Shared", + "$defs": {"Shared": {"type": "string"}}, + })); + } + assert_eq!(r.declarations().matches("type Shared").count(), 1); + } + + // -- traversal bounds -- + + /// Schemas arrive from third parties; deep nesting must not exhaust the + /// stack. + #[test] + fn deep_nesting_is_bounded() { + let mut schema = json!({"type": "string"}); + for _ in 0..(MAX_DEPTH + 50) { + schema = json!({"type": "array", "items": schema}); + } + let out = render(schema); + assert!(out.ends_with("[]"), "should still render, got: {out}"); + assert!( + out.contains(UNKNOWN), + "the bound should show up as unknown at the bottom, got: {out}" + ); + } + // -- unions and composition -- /// The dominant Python generator emits this for every optional field, and From d390df5e85700716b9bc67c16411e5aaae32ab2b Mon Sep 17 00:00:00 2001 From: fluzko Date: Wed, 29 Jul 2026 12:42:51 -0300 Subject: [PATCH 07/39] feat: render tool namespaces as typescript declarations --- src/mcp/declarations.rs | 281 ++++++++++++++++++++++++++++++++++++++++ src/mcp/mod.rs | 1 + src/mcp/schema_to_ts.rs | 15 ++- 3 files changed, 295 insertions(+), 2 deletions(-) create mode 100644 src/mcp/declarations.rs diff --git a/src/mcp/declarations.rs b/src/mcp/declarations.rs new file mode 100644 index 00000000..3927e0ee --- /dev/null +++ b/src/mcp/declarations.rs @@ -0,0 +1,281 @@ +//! Rendering a backing server's tools as TypeScript declarations. +//! +//! Each server becomes one object of methods, so a script reads as +//! `await sqlx.query({ sql: "..." })`. The object form — rather than a +//! `declare namespace` — is what lets a tool whose wire name is not a +//! JavaScript identifier still be declared, since an object type accepts +//! quoted method names. + +use serde_json::Value; + +use super::schema_to_ts::{TypeRenderer, jsdoc_text}; + +/// One tool as advertised by a backing server. +#[derive(Debug, Clone, Copy)] +pub struct ToolDecl<'a> { + /// The name used on the wire, which need not be a JavaScript identifier. + pub name: &'a str, + pub description: Option<&'a str>, + pub input_schema: Option<&'a Value>, +} + +/// Render one server's tools as a declaration block. +pub fn render_server(server: &str, tools: &[ToolDecl]) -> String { + let mut types = TypeRenderer::new(); + let mut methods = String::new(); + let mut used: Vec = Vec::new(); + + for tool in tools { + let params = render_params(&mut types, tool.input_schema); + + // A name that is already a valid identifier needs no alias. One that + // is not gets both spellings, so `sqlx["migrate-status"]` and + // `sqlx.migrate_status` both dispatch. + let quoted = format!("{:?}", tool.name); + let alias = (!is_js_identifier(tool.name)).then(|| unique(sanitize(tool.name), &mut used)); + let primary = if alias.is_some() { + quoted + } else { + unique(tool.name.to_string(), &mut used) + }; + + for (index, key) in std::iter::once(&primary).chain(alias.iter()).enumerate() { + if let Some(doc) = tool.description.and_then(jsdoc_text) { + methods.push_str(&format!(" /** {doc} */\n")); + } + // The alias is the same tool reached by a different spelling; say + // so rather than leaving the reader to infer it. + if index == 1 { + methods.push_str(&format!(" /** Alias for {}. */\n", tool.name)); + } + methods.push_str(&format!(" {key}({params}): Promise;\n")); + } + } + + let mut out = types.declarations(); + out.push_str(&format!( + "declare const {}: {{\n{methods}}};\n", + sanitize(server) + )); + out +} + +/// Render a tool's parameter list. +/// +/// A tool with no properties takes no argument at all, and one whose +/// properties are all optional takes an optional argument — both save the +/// model from passing an empty object. +fn render_params(types: &mut TypeRenderer, schema: Option<&Value>) -> String { + let Some(schema) = schema else { + return String::new(); + }; + if !has_properties(schema) { + return String::new(); + } + let optional = if has_required(schema) { "" } else { "?" }; + // Indent one level: the type sits inside the server object's braces. + format!("params{optional}: {}", types.render_indented(schema, 1)) +} + +fn has_properties(schema: &Value) -> bool { + schema + .get("properties") + .and_then(Value::as_object) + .is_some_and(|p| !p.is_empty()) +} + +fn has_required(schema: &Value) -> bool { + schema + .get("required") + .and_then(Value::as_array) + .is_some_and(|r| !r.is_empty()) +} + +/// Coerce a wire name into a JavaScript identifier. +fn sanitize(name: &str) -> String { + let mut out: String = name + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect(); + if out.is_empty() { + return "_".to_string(); + } + if out.starts_with(|c: char| c.is_ascii_digit()) { + out.insert(0, '_'); + } + out +} + +/// Keep names distinct. Two wire names differing only in punctuation sanitize +/// to the same identifier; this is rare but must not silently drop a tool. +fn unique(name: String, used: &mut Vec) -> String { + let mut candidate = name.clone(); + let mut n = 2; + while used.contains(&candidate) { + candidate = format!("{name}_{n}"); + n += 1; + } + used.push(candidate.clone()); + candidate +} + +fn is_js_identifier(name: &str) -> bool { + let mut chars = name.chars(); + let Some(first) = chars.next() else { + return false; + }; + if !(first.is_ascii_alphabetic() || first == '_' || first == '$') { + return false; + } + chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$') +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn tool<'a>(name: &'a str, schema: &'a Value) -> ToolDecl<'a> { + ToolDecl { + name, + description: None, + input_schema: Some(schema), + } + } + + #[test] + fn renders_a_tool_with_parameters() { + let schema = json!({ + "type": "object", + "properties": {"sql": {"type": "string"}}, + "required": ["sql"], + }); + let out = render_server("sqlx", &[tool("query", &schema)]); + assert_eq!( + out, + "declare const sqlx: {\n query(params: {\n sql: string;\n }): Promise;\n};\n" + ); + } + + /// Return types are deliberately untyped: almost no server in the wild + /// declares an output schema, so promising a shape would be a lie. + #[test] + fn every_tool_returns_a_promise_of_unknown() { + let schema = json!({"type": "object", "properties": {"a": {"type": "string"}}}); + let out = render_server("s", &[tool("t", &schema)]); + assert!(out.contains("): Promise;"), "got:\n{out}"); + } + + /// A tool taking nothing should not force the model to pass `{}`. + #[test] + fn tool_without_properties_takes_no_argument() { + let out = render_server("s", &[tool("ping", &json!({"type": "object"}))]); + assert!(out.contains("ping(): Promise;"), "got:\n{out}"); + } + + #[test] + fn tool_with_only_optional_properties_takes_an_optional_argument() { + let schema = json!({ + "type": "object", + "properties": {"limit": {"type": "integer"}}, + }); + let out = render_server("s", &[tool("list", &schema)]); + assert!(out.contains("list(params?: {"), "got:\n{out}"); + } + + #[test] + fn missing_input_schema_takes_no_argument() { + let out = render_server( + "s", + &[ToolDecl { + name: "t", + description: None, + input_schema: None, + }], + ); + assert!(out.contains("t(): Promise;"), "got:\n{out}"); + } + + // -- naming -- + + /// The protocol's own reference server names 17 of its 18 tools with + /// hyphens, so this is the common case, not an edge case. + #[test] + fn hyphenated_tool_gets_both_spellings() { + let out = render_server("s", &[tool("get-sum", &json!({}))]); + assert!( + out.contains(r#" "get-sum"(): Promise;"#), + "the wire name must stay callable, got:\n{out}" + ); + assert!( + out.contains(" get_sum(): Promise;"), + "a dotted alias should also work, got:\n{out}" + ); + assert!(out.contains("/** Alias for get-sum. */"), "got:\n{out}"); + } + + #[test] + fn identifier_tool_is_not_aliased() { + let out = render_server("s", &[tool("query", &json!({}))]); + assert_eq!(out.matches("Promise").count(), 1, "got:\n{out}"); + assert!(!out.contains('"'), "no quoting needed, got:\n{out}"); + } + + #[test] + fn leading_digit_name_is_prefixed() { + let out = render_server("s", &[tool("2fa", &json!({}))]); + assert!(out.contains(r#""2fa"()"#), "got:\n{out}"); + assert!(out.contains("_2fa()"), "got:\n{out}"); + } + + /// Two wire names that sanitize to the same identifier must both survive. + #[test] + fn colliding_aliases_are_disambiguated() { + let out = render_server( + "s", + &[tool("get-sum", &json!({})), tool("get.sum", &json!({}))], + ); + assert!(out.contains("get_sum("), "got:\n{out}"); + assert!(out.contains("get_sum_2("), "got:\n{out}"); + assert!(out.contains(r#""get.sum"("#), "got:\n{out}"); + } + + #[test] + fn server_name_is_sanitized() { + let out = render_server("sea-orm", &[tool("t", &json!({}))]); + assert!(out.starts_with("declare const sea_orm: {"), "got:\n{out}"); + } + + // -- documentation and shared types -- + + #[test] + fn renders_tool_description_as_jsdoc() { + let out = render_server( + "s", + &[ToolDecl { + name: "t", + description: Some("Does a\nthing"), + input_schema: None, + }], + ); + assert!(out.contains("/** Does a thing */"), "got:\n{out}"); + } + + /// Named types are hoisted above the object so several tools can share + /// them. + #[test] + fn named_types_are_emitted_once_above_the_server() { + let schema = json!({ + "type": "object", + "properties": {"user": {"$ref": "#/$defs/User"}}, + "required": ["user"], + "$defs": {"User": {"type": "object", "properties": {"id": {"type": "string"}}}}, + }); + let out = render_server("s", &[tool("a", &schema), tool("b", &schema)]); + assert_eq!(out.matches("interface User").count(), 1, "got:\n{out}"); + assert!( + out.find("interface User") < out.find("declare const s"), + "types must precede the server object, got:\n{out}" + ); + } +} diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index f602419a..e49c1e08 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -6,4 +6,5 @@ //! //! See the [MCP meta-server RFD](../../md/rfds/mcp-meta-server/README.md). +pub mod declarations; pub mod schema_to_ts; diff --git a/src/mcp/schema_to_ts.rs b/src/mcp/schema_to_ts.rs index 4bf2bfb0..fa719797 100644 --- a/src/mcp/schema_to_ts.rs +++ b/src/mcp/schema_to_ts.rs @@ -49,13 +49,19 @@ impl TypeRenderer { /// Render `schema` as a type expression, registering any named types it /// references. `$ref` pointers resolve against `schema` itself. pub fn render(&mut self, schema: &Value) -> String { + self.render_indented(schema, 0) + } + + /// Render at a given nesting level, so a type embedded inside another + /// block lines up with it. + pub fn render_indented(&mut self, schema: &Value, indent: usize) -> String { let mut cx = Cx { root: schema, named: &mut self.named, in_progress: Vec::new(), budget: MAX_NODES, }; - render_at(&mut cx, schema, 0, 0) + render_at(&mut cx, schema, indent, 0) } /// The named type declarations collected so far, in name order. @@ -399,7 +405,12 @@ fn render_literal(value: &Value) -> String { /// A property's `description`, collapsed to one line and safe inside a JSDoc /// block. fn doc_comment(schema: &Value) -> Option { - let text = schema.get("description")?.as_str()?.trim(); + jsdoc_text(schema.get("description")?.as_str()?) +} + +/// Collapse text to one line and make it safe inside a JSDoc block. +pub(crate) fn jsdoc_text(text: &str) -> Option { + let text = text.trim(); if text.is_empty() { return None; } From 9a774220f29db717a35b95c5588d2e184bcbd794 Mon Sep 17 00:00:00 2001 From: fluzko Date: Wed, 29 Jul 2026 13:11:40 -0300 Subject: [PATCH 08/39] test(mcp): snapshot declarations for real servers --- src/mcp/corpus_tests.rs | 113 +++ src/mcp/mod.rs | 3 + src/mcp/testdata/everything.d.ts | 141 +++ src/mcp/testdata/everything.tools.json | 394 +++++++++ src/mcp/testdata/filesystem.d.ts | 80 ++ src/mcp/testdata/filesystem.tools.json | 666 ++++++++++++++ src/mcp/testdata/memory.d.ts | 71 ++ src/mcp/testdata/memory.tools.json | 706 +++++++++++++++ src/mcp/testdata/playwright.d.ts | 211 +++++ src/mcp/testdata/playwright.tools.json | 823 ++++++++++++++++++ src/mcp/testdata/sequentialthinking.d.ts | 23 + .../testdata/sequentialthinking.tools.json | 105 +++ 12 files changed, 3336 insertions(+) create mode 100644 src/mcp/corpus_tests.rs create mode 100644 src/mcp/testdata/everything.d.ts create mode 100644 src/mcp/testdata/everything.tools.json create mode 100644 src/mcp/testdata/filesystem.d.ts create mode 100644 src/mcp/testdata/filesystem.tools.json create mode 100644 src/mcp/testdata/memory.d.ts create mode 100644 src/mcp/testdata/memory.tools.json create mode 100644 src/mcp/testdata/playwright.d.ts create mode 100644 src/mcp/testdata/playwright.tools.json create mode 100644 src/mcp/testdata/sequentialthinking.d.ts create mode 100644 src/mcp/testdata/sequentialthinking.tools.json diff --git a/src/mcp/corpus_tests.rs b/src/mcp/corpus_tests.rs new file mode 100644 index 00000000..5343914d --- /dev/null +++ b/src/mcp/corpus_tests.rs @@ -0,0 +1,113 @@ +//! Snapshots of generated declarations for real MCP servers. +//! +//! The payloads in `testdata/` are verbatim `tools/list` responses captured +//! from published servers, chosen to span four independent schema generators: +//! two flavours of the TypeScript `zod` toolchain (draft-07 and 2020-12), +//! plus the reference servers' own hand-written schemas. +//! +//! These are the tests that catch a dialect we handle wrongly. A construct +//! that starts rendering as `unknown` shows up as a diff in the checked-in +//! `.d.ts`, which the synthetic unit tests cannot detect because they only +//! cover shapes we already thought of. +//! +//! Regenerate with `UPDATE_EXPECT=1 cargo test`. + +use expect_test::expect_file; +use serde_json::Value; + +use super::declarations::{ToolDecl, render_server}; + +/// Render a captured `tools/list` payload as one server's declarations. +fn render_corpus(payload: &str, server: &str) -> String { + let parsed: Value = serde_json::from_str(payload).expect("payload should be valid JSON"); + let tools = parsed["tools"] + .as_array() + .expect("payload should carry a `tools` array"); + + let decls: Vec = tools + .iter() + .map(|tool| ToolDecl { + name: tool["name"].as_str().unwrap_or_default(), + description: tool["description"].as_str(), + input_schema: tool.get("inputSchema"), + }) + .collect(); + + render_server(server, &decls) +} + +/// Every corpus entry renders something for every tool, and nothing panics. +/// +/// The per-server snapshots below cover the content; this covers the +/// invariant that generation never fails, whatever a server sends. +#[test] +fn every_tool_in_the_corpus_produces_a_declaration() { + for (payload, server) in [ + (include_str!("testdata/everything.tools.json"), "everything"), + (include_str!("testdata/filesystem.tools.json"), "filesystem"), + (include_str!("testdata/memory.tools.json"), "memory"), + (include_str!("testdata/playwright.tools.json"), "playwright"), + ( + include_str!("testdata/sequentialthinking.tools.json"), + "sequentialthinking", + ), + ] { + let parsed: Value = serde_json::from_str(payload).unwrap(); + let expected = parsed["tools"].as_array().unwrap().len(); + let rendered = render_corpus(payload, server); + let found = rendered.matches("): Promise;").count(); + assert!( + found >= expected, + "{server}: expected at least {expected} declarations, found {found}" + ); + } +} + +/// The protocol's own test server. Names 17 of its 18 tools with hyphens, so +/// this is also the identifier-handling snapshot. +#[test] +fn everything_server() { + expect_file!["testdata/everything.d.ts"].assert_eq(&render_corpus( + include_str!("testdata/everything.tools.json"), + "everything", + )); +} + +/// Carries the corpus's only union-with-literal and declares output schemas +/// on every tool. +#[test] +fn filesystem_server() { + expect_file!["testdata/filesystem.d.ts"].assert_eq(&render_corpus( + include_str!("testdata/filesystem.tools.json"), + "filesystem", + )); +} + +/// Deeply nested schemas: the most schema nodes per byte in the corpus. +#[test] +fn memory_server() { + expect_file!["testdata/memory.d.ts"].assert_eq(&render_corpus( + include_str!("testdata/memory.tools.json"), + "memory", + )); +} + +/// The 2020-12 dialect, and the corpus's only typed `additionalProperties` +/// and `propertyNames`. +#[test] +fn playwright_server() { + expect_file!["testdata/playwright.d.ts"].assert_eq(&render_corpus( + include_str!("testdata/playwright.tools.json"), + "playwright", + )); +} + +/// One tool with a very large description — the size-control case, where the +/// payload is dominated by prose rather than by schema. +#[test] +fn sequentialthinking_server() { + expect_file!["testdata/sequentialthinking.d.ts"].assert_eq(&render_corpus( + include_str!("testdata/sequentialthinking.tools.json"), + "sequentialthinking", + )); +} diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index e49c1e08..0afd92df 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -8,3 +8,6 @@ pub mod declarations; pub mod schema_to_ts; + +#[cfg(test)] +mod corpus_tests; diff --git a/src/mcp/testdata/everything.d.ts b/src/mcp/testdata/everything.d.ts new file mode 100644 index 00000000..8a84d683 --- /dev/null +++ b/src/mcp/testdata/everything.d.ts @@ -0,0 +1,141 @@ +declare const everything: { + /** Echoes back the input string */ + echo(params: { + /** Message to echo */ + message: string; + }): Promise; + /** Demonstrates how annotations can be used to provide metadata about content. */ + "get-annotated-message"(params: { + /** Whether to include an example image */ + includeImage?: boolean; + /** Type of message to demonstrate different annotation patterns */ + messageType: "error" | "success" | "debug"; + }): Promise; + /** Demonstrates how annotations can be used to provide metadata about content. */ + /** Alias for get-annotated-message. */ + get_annotated_message(params: { + /** Whether to include an example image */ + includeImage?: boolean; + /** Type of message to demonstrate different annotation patterns */ + messageType: "error" | "success" | "debug"; + }): Promise; + /** Returns all environment variables, helpful for debugging MCP server configuration */ + "get-env"(): Promise; + /** Returns all environment variables, helpful for debugging MCP server configuration */ + /** Alias for get-env. */ + get_env(): Promise; + /** Returns up to ten resource links that reference different types of resources */ + "get-resource-links"(params?: { + /** Number of resource links to return (1-10) */ + count?: number; + }): Promise; + /** Returns up to ten resource links that reference different types of resources */ + /** Alias for get-resource-links. */ + get_resource_links(params?: { + /** Number of resource links to return (1-10) */ + count?: number; + }): Promise; + /** Returns a resource reference that can be used by MCP clients */ + "get-resource-reference"(params?: { + /** ID of the text resource to fetch */ + resourceId?: number; + resourceType?: "Text" | "Blob"; + }): Promise; + /** Returns a resource reference that can be used by MCP clients */ + /** Alias for get-resource-reference. */ + get_resource_reference(params?: { + /** ID of the text resource to fetch */ + resourceId?: number; + resourceType?: "Text" | "Blob"; + }): Promise; + /** Returns structured content along with an output schema for client data validation */ + "get-structured-content"(params: { + /** Choose city */ + location: "New York" | "Chicago" | "Los Angeles"; + }): Promise; + /** Returns structured content along with an output schema for client data validation */ + /** Alias for get-structured-content. */ + get_structured_content(params: { + /** Choose city */ + location: "New York" | "Chicago" | "Los Angeles"; + }): Promise; + /** Returns the sum of two numbers */ + "get-sum"(params: { + /** First number */ + a: number; + /** Second number */ + b: number; + }): Promise; + /** Returns the sum of two numbers */ + /** Alias for get-sum. */ + get_sum(params: { + /** First number */ + a: number; + /** Second number */ + b: number; + }): Promise; + /** Returns a tiny MCP logo image. */ + "get-tiny-image"(): Promise; + /** Returns a tiny MCP logo image. */ + /** Alias for get-tiny-image. */ + get_tiny_image(): Promise; + /** Compresses a single file using gzip compression. Depending upon the selected output type, returns either the compressed data as a gzipped resource or a resource link, allowing it to be downloaded in a subsequent request during the current session. */ + "gzip-file-as-resource"(params?: { + /** URL or data URI of the file content to compress */ + data?: string; + /** Name of the output file */ + name?: string; + /** How the resulting gzipped file should be returned. 'resourceLink' returns a link to a resource that can be read later, 'resource' returns a full resource object. */ + outputType?: "resourceLink" | "resource"; + }): Promise; + /** Compresses a single file using gzip compression. Depending upon the selected output type, returns either the compressed data as a gzipped resource or a resource link, allowing it to be downloaded in a subsequent request during the current session. */ + /** Alias for gzip-file-as-resource. */ + gzip_file_as_resource(params?: { + /** URL or data URI of the file content to compress */ + data?: string; + /** Name of the output file */ + name?: string; + /** How the resulting gzipped file should be returned. 'resourceLink' returns a link to a resource that can be read later, 'resource' returns a full resource object. */ + outputType?: "resourceLink" | "resource"; + }): Promise; + /** Toggles simulated, random-leveled logging on or off. */ + "toggle-simulated-logging"(): Promise; + /** Toggles simulated, random-leveled logging on or off. */ + /** Alias for toggle-simulated-logging. */ + toggle_simulated_logging(): Promise; + /** Toggles simulated resource subscription updates on or off. */ + "toggle-subscriber-updates"(): Promise; + /** Toggles simulated resource subscription updates on or off. */ + /** Alias for toggle-subscriber-updates. */ + toggle_subscriber_updates(): Promise; + /** Demonstrates a long running operation with progress updates. */ + "trigger-long-running-operation"(params?: { + /** Duration of the operation in seconds */ + duration?: number; + /** Number of steps in the operation */ + steps?: number; + }): Promise; + /** Demonstrates a long running operation with progress updates. */ + /** Alias for trigger-long-running-operation. */ + trigger_long_running_operation(params?: { + /** Duration of the operation in seconds */ + duration?: number; + /** Number of steps in the operation */ + steps?: number; + }): Promise; + /** Simulates a deep research operation that gathers, analyzes, and synthesizes information. Demonstrates MCP task-based operations with progress through multiple stages. If 'ambiguous' is true and client supports elicitation, sends an elicitation request for clarification. */ + "simulate-research-query"(params: { + /** Simulate an ambiguous query that requires clarification (triggers input_required status) */ + ambiguous?: boolean; + /** The research topic to investigate */ + topic: string; + }): Promise; + /** Simulates a deep research operation that gathers, analyzes, and synthesizes information. Demonstrates MCP task-based operations with progress through multiple stages. If 'ambiguous' is true and client supports elicitation, sends an elicitation request for clarification. */ + /** Alias for simulate-research-query. */ + simulate_research_query(params: { + /** Simulate an ambiguous query that requires clarification (triggers input_required status) */ + ambiguous?: boolean; + /** The research topic to investigate */ + topic: string; + }): Promise; +}; diff --git a/src/mcp/testdata/everything.tools.json b/src/mcp/testdata/everything.tools.json new file mode 100644 index 00000000..215ee3d8 --- /dev/null +++ b/src/mcp/testdata/everything.tools.json @@ -0,0 +1,394 @@ +{ + "tools": [ + { + "name": "echo", + "title": "Echo Tool", + "description": "Echoes back the input string", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Message to echo" + } + }, + "required": [ + "message" + ] + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "get-annotated-message", + "title": "Get Annotated Message Tool", + "description": "Demonstrates how annotations can be used to provide metadata about content.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "messageType": { + "type": "string", + "enum": [ + "error", + "success", + "debug" + ], + "description": "Type of message to demonstrate different annotation patterns" + }, + "includeImage": { + "default": false, + "description": "Whether to include an example image", + "type": "boolean" + } + }, + "required": [ + "messageType" + ] + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "get-env", + "title": "Print Environment Tool", + "description": "Returns all environment variables, helpful for debugging MCP server configuration", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {} + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "get-resource-links", + "title": "Get Resource Links Tool", + "description": "Returns up to ten resource links that reference different types of resources", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "count": { + "default": 3, + "description": "Number of resource links to return (1-10)", + "type": "number", + "minimum": 1, + "maximum": 10 + } + } + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "get-resource-reference", + "title": "Get Resource Reference Tool", + "description": "Returns a resource reference that can be used by MCP clients", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "resourceType": { + "default": "Text", + "type": "string", + "enum": [ + "Text", + "Blob" + ] + }, + "resourceId": { + "default": 1, + "description": "ID of the text resource to fetch", + "type": "number" + } + } + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "get-structured-content", + "title": "Get Structured Content Tool", + "description": "Returns structured content along with an output schema for client data validation", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "location": { + "type": "string", + "enum": [ + "New York", + "Chicago", + "Los Angeles" + ], + "description": "Choose city" + } + }, + "required": [ + "location" + ] + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + }, + "outputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "temperature": { + "type": "number", + "description": "Temperature in celsius" + }, + "conditions": { + "type": "string", + "description": "Weather conditions description" + }, + "humidity": { + "type": "number", + "description": "Humidity percentage" + } + }, + "required": [ + "temperature", + "conditions", + "humidity" + ], + "additionalProperties": false + } + }, + { + "name": "get-sum", + "title": "Get Sum Tool", + "description": "Returns the sum of two numbers", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "a": { + "type": "number", + "description": "First number" + }, + "b": { + "type": "number", + "description": "Second number" + } + }, + "required": [ + "a", + "b" + ] + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "get-tiny-image", + "title": "Get Tiny Image Tool", + "description": "Returns a tiny MCP logo image.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {} + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "gzip-file-as-resource", + "title": "GZip File as Resource Tool", + "description": "Compresses a single file using gzip compression. Depending upon the selected output type, returns either the compressed data as a gzipped resource or a resource link, allowing it to be downloaded in a subsequent request during the current session.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "name": { + "default": "README.md.gz", + "type": "string", + "description": "Name of the output file" + }, + "data": { + "default": "https://raw.githubusercontent.com/modelcontextprotocol/servers/refs/heads/main/README.md", + "type": "string", + "format": "uri", + "description": "URL or data URI of the file content to compress" + }, + "outputType": { + "default": "resourceLink", + "description": "How the resulting gzipped file should be returned. 'resourceLink' returns a link to a resource that can be read later, 'resource' returns a full resource object.", + "type": "string", + "enum": [ + "resourceLink", + "resource" + ] + } + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": true + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "toggle-simulated-logging", + "title": "Toggle Simulated Logging", + "description": "Toggles simulated, random-leveled logging on or off.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {} + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "toggle-subscriber-updates", + "title": "Toggle Subscriber Updates", + "description": "Toggles simulated resource subscription updates on or off.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {} + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "trigger-long-running-operation", + "title": "Trigger Long Running Operation Tool", + "description": "Demonstrates a long running operation with progress updates.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "duration": { + "default": 10, + "description": "Duration of the operation in seconds", + "type": "number" + }, + "steps": { + "default": 5, + "description": "Number of steps in the operation", + "type": "number" + } + } + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + } + }, + { + "name": "simulate-research-query", + "title": "Simulate Research Query", + "description": "Simulates a deep research operation that gathers, analyzes, and synthesizes information. Demonstrates MCP task-based operations with progress through multiple stages. If 'ambiguous' is true and client supports elicitation, sends an elicitation request for clarification.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "topic": { + "type": "string", + "description": "The research topic to investigate" + }, + "ambiguous": { + "default": false, + "description": "Simulate an ambiguous query that requires clarification (triggers input_required status)", + "type": "boolean" + } + }, + "required": [ + "topic" + ] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "execution": { + "taskSupport": "required" + } + } + ] +} diff --git a/src/mcp/testdata/filesystem.d.ts b/src/mcp/testdata/filesystem.d.ts new file mode 100644 index 00000000..caf4324a --- /dev/null +++ b/src/mcp/testdata/filesystem.d.ts @@ -0,0 +1,80 @@ +declare const filesystem: { + /** Read the complete contents of a file as text. DEPRECATED: Use read_text_file instead. */ + read_file(params: { + /** If provided, returns only the first N lines of the file */ + head?: number; + path: string; + /** If provided, returns only the last N lines of the file */ + tail?: number; + }): Promise; + /** Read the complete contents of a file from the file system as text. Handles various text encodings and provides detailed error messages if the file cannot be read. Use this tool when you need to examine the contents of a single file. Use the 'head' parameter to read only the first N lines of a file, or the 'tail' parameter to read only the last N lines of a file. Operates on the file as text regardless of extension. Only works within allowed directories. */ + read_text_file(params: { + /** If provided, returns only the first N lines of the file */ + head?: number; + path: string; + /** If provided, returns only the last N lines of the file */ + tail?: number; + }): Promise; + /** Read a file and return it as a base64-encoded content block with its MIME type. Image and audio files are returned as image/audio content; any other file type is returned as an embedded resource. Only works within allowed directories. */ + read_media_file(params: { + path: string; + }): Promise; + /** Read the contents of multiple files simultaneously. This is more efficient than reading files one by one when you need to analyze or compare multiple files. Each file's content is returned with its path as a reference. Failed reads for individual files won't stop the entire operation. Only works within allowed directories. */ + read_multiple_files(params: { + /** Array of file paths to read. Each path must be a string pointing to a valid file within allowed directories. */ + paths: string[]; + }): Promise; + /** Create a new file or completely overwrite an existing file with new content. Use with caution as it will overwrite existing files without warning. Handles text content with proper encoding. Only works within allowed directories. */ + write_file(params: { + content: string; + path: string; + }): Promise; + /** Make line-based edits to a text file. Each edit replaces exact line sequences with new content. Returns a git-style diff showing the changes made. Only works within allowed directories. */ + edit_file(params: { + /** Preview changes using git-style diff format */ + dryRun?: boolean; + edits: { + /** Text to replace with */ + newText: string; + /** Text to search for - must match exactly */ + oldText: string; + }[]; + path: string; + }): Promise; + /** Create a new directory or ensure a directory exists. Can create multiple nested directories in one operation. If the directory already exists, this operation will succeed silently. Perfect for setting up directory structures for projects or ensuring required paths exist. Only works within allowed directories. */ + create_directory(params: { + path: string; + }): Promise; + /** Get a detailed listing of all files and directories in a specified path. Results clearly distinguish between files and directories with [FILE] and [DIR] prefixes. This tool is essential for understanding directory structure and finding specific files within a directory. Only works within allowed directories. */ + list_directory(params: { + path: string; + }): Promise; + /** Get a detailed listing of all files and directories in a specified path, including sizes. Results clearly distinguish between files and directories with [FILE] and [DIR] prefixes. This tool is useful for understanding directory structure and finding specific files within a directory. Only works within allowed directories. */ + list_directory_with_sizes(params: { + path: string; + /** Sort entries by name or size */ + sortBy?: "name" | "size"; + }): Promise; + /** Get a recursive tree view of files and directories as a JSON structure. Each entry includes 'name', 'type' (file/directory), and 'children' for directories. Files have no children array, while directories always have a children array (which may be empty). The output is formatted with 2-space indentation for readability. Only works within allowed directories. */ + directory_tree(params: { + excludePatterns?: string[]; + path: string; + }): Promise; + /** Move or rename files and directories. Can move files between directories and rename them in a single operation. If the destination exists, the operation will fail. Works across different directories and can be used for simple renaming within the same directory. Both source and destination must be within allowed directories. */ + move_file(params: { + destination: string; + source: string; + }): Promise; + /** Recursively search for files and directories matching a pattern. The patterns should be glob-style patterns that match paths relative to the working directory. Use pattern like '*.ext' to match files in current directory, and '** /*.ext' to match files in all subdirectories. Returns full paths to all matching items. Great for finding files when you don't know their exact location. Only searches within allowed directories. */ + search_files(params: { + excludePatterns?: string[]; + path: string; + pattern: string; + }): Promise; + /** Retrieve detailed metadata about a file or directory. Returns comprehensive information including size, creation time, last modified time, permissions, and type. This tool is perfect for understanding file characteristics without reading the actual content. Only works within allowed directories. */ + get_file_info(params: { + path: string; + }): Promise; + /** Returns the list of directories that this server is allowed to access. Subdirectories within these allowed directories are also accessible. Use this to understand which directories and their nested paths are available before trying to access files. */ + list_allowed_directories(): Promise; +}; diff --git a/src/mcp/testdata/filesystem.tools.json b/src/mcp/testdata/filesystem.tools.json new file mode 100644 index 00000000..32f3b838 --- /dev/null +++ b/src/mcp/testdata/filesystem.tools.json @@ -0,0 +1,666 @@ +{ + "tools": [ + { + "name": "read_file", + "title": "Read File (Deprecated)", + "description": "Read the complete contents of a file as text. DEPRECATED: Use read_text_file instead.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "tail": { + "description": "If provided, returns only the last N lines of the file", + "type": "number" + }, + "head": { + "description": "If provided, returns only the first N lines of the file", + "type": "number" + } + }, + "required": [ + "path" + ] + }, + "annotations": { + "readOnlyHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + }, + "outputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "content": { + "type": "string" + } + }, + "required": [ + "content" + ], + "additionalProperties": false + } + }, + { + "name": "read_text_file", + "title": "Read Text File", + "description": "Read the complete contents of a file from the file system as text. Handles various text encodings and provides detailed error messages if the file cannot be read. Use this tool when you need to examine the contents of a single file. Use the 'head' parameter to read only the first N lines of a file, or the 'tail' parameter to read only the last N lines of a file. Operates on the file as text regardless of extension. Only works within allowed directories.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "tail": { + "description": "If provided, returns only the last N lines of the file", + "type": "number" + }, + "head": { + "description": "If provided, returns only the first N lines of the file", + "type": "number" + } + }, + "required": [ + "path" + ] + }, + "annotations": { + "readOnlyHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + }, + "outputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "content": { + "type": "string" + } + }, + "required": [ + "content" + ], + "additionalProperties": false + } + }, + { + "name": "read_media_file", + "title": "Read Media File", + "description": "Read a file and return it as a base64-encoded content block with its MIME type. Image and audio files are returned as image/audio content; any other file type is returned as an embedded resource. Only works within allowed directories.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ] + }, + "annotations": { + "readOnlyHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + }, + "outputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "image", + "audio" + ] + }, + "data": { + "type": "string" + }, + "mimeType": { + "type": "string" + } + }, + "required": [ + "type", + "data", + "mimeType" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "resource" + }, + "resource": { + "type": "object", + "properties": { + "uri": { + "type": "string" + }, + "mimeType": { + "type": "string" + }, + "blob": { + "type": "string" + } + }, + "required": [ + "uri", + "blob" + ], + "additionalProperties": false + } + }, + "required": [ + "type", + "resource" + ], + "additionalProperties": false + } + ] + } + } + }, + "required": [ + "content" + ], + "additionalProperties": false + } + }, + { + "name": "read_multiple_files", + "title": "Read Multiple Files", + "description": "Read the contents of multiple files simultaneously. This is more efficient than reading files one by one when you need to analyze or compare multiple files. Each file's content is returned with its path as a reference. Failed reads for individual files won't stop the entire operation. Only works within allowed directories.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "paths": { + "minItems": 1, + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of file paths to read. Each path must be a string pointing to a valid file within allowed directories." + } + }, + "required": [ + "paths" + ] + }, + "annotations": { + "readOnlyHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + }, + "outputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "content": { + "type": "string" + } + }, + "required": [ + "content" + ], + "additionalProperties": false + } + }, + { + "name": "write_file", + "title": "Write File", + "description": "Create a new file or completely overwrite an existing file with new content. Use with caution as it will overwrite existing files without warning. Handles text content with proper encoding. Only works within allowed directories.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "content": { + "type": "string" + } + }, + "required": [ + "path", + "content" + ] + }, + "annotations": { + "readOnlyHint": false, + "idempotentHint": true, + "destructiveHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + }, + "outputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "content": { + "type": "string" + } + }, + "required": [ + "content" + ], + "additionalProperties": false + } + }, + { + "name": "edit_file", + "title": "Edit File", + "description": "Make line-based edits to a text file. Each edit replaces exact line sequences with new content. Returns a git-style diff showing the changes made. Only works within allowed directories.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "edits": { + "type": "array", + "items": { + "type": "object", + "properties": { + "oldText": { + "type": "string", + "description": "Text to search for - must match exactly" + }, + "newText": { + "type": "string", + "description": "Text to replace with" + } + }, + "required": [ + "oldText", + "newText" + ] + } + }, + "dryRun": { + "default": false, + "description": "Preview changes using git-style diff format", + "type": "boolean" + } + }, + "required": [ + "path", + "edits" + ] + }, + "annotations": { + "readOnlyHint": false, + "idempotentHint": false, + "destructiveHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + }, + "outputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "content": { + "type": "string" + } + }, + "required": [ + "content" + ], + "additionalProperties": false + } + }, + { + "name": "create_directory", + "title": "Create Directory", + "description": "Create a new directory or ensure a directory exists. Can create multiple nested directories in one operation. If the directory already exists, this operation will succeed silently. Perfect for setting up directory structures for projects or ensuring required paths exist. Only works within allowed directories.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ] + }, + "annotations": { + "readOnlyHint": false, + "idempotentHint": true, + "destructiveHint": false, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + }, + "outputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "content": { + "type": "string" + } + }, + "required": [ + "content" + ], + "additionalProperties": false + } + }, + { + "name": "list_directory", + "title": "List Directory", + "description": "Get a detailed listing of all files and directories in a specified path. Results clearly distinguish between files and directories with [FILE] and [DIR] prefixes. This tool is essential for understanding directory structure and finding specific files within a directory. Only works within allowed directories.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ] + }, + "annotations": { + "readOnlyHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + }, + "outputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "content": { + "type": "string" + } + }, + "required": [ + "content" + ], + "additionalProperties": false + } + }, + { + "name": "list_directory_with_sizes", + "title": "List Directory with Sizes", + "description": "Get a detailed listing of all files and directories in a specified path, including sizes. Results clearly distinguish between files and directories with [FILE] and [DIR] prefixes. This tool is useful for understanding directory structure and finding specific files within a directory. Only works within allowed directories.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "sortBy": { + "default": "name", + "description": "Sort entries by name or size", + "type": "string", + "enum": [ + "name", + "size" + ] + } + }, + "required": [ + "path" + ] + }, + "annotations": { + "readOnlyHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + }, + "outputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "content": { + "type": "string" + } + }, + "required": [ + "content" + ], + "additionalProperties": false + } + }, + { + "name": "directory_tree", + "title": "Directory Tree", + "description": "Get a recursive tree view of files and directories as a JSON structure. Each entry includes 'name', 'type' (file/directory), and 'children' for directories. Files have no children array, while directories always have a children array (which may be empty). The output is formatted with 2-space indentation for readability. Only works within allowed directories.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "excludePatterns": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "path" + ] + }, + "annotations": { + "readOnlyHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + }, + "outputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "content": { + "type": "string" + } + }, + "required": [ + "content" + ], + "additionalProperties": false + } + }, + { + "name": "move_file", + "title": "Move File", + "description": "Move or rename files and directories. Can move files between directories and rename them in a single operation. If the destination exists, the operation will fail. Works across different directories and can be used for simple renaming within the same directory. Both source and destination must be within allowed directories.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "source": { + "type": "string" + }, + "destination": { + "type": "string" + } + }, + "required": [ + "source", + "destination" + ] + }, + "annotations": { + "readOnlyHint": false, + "idempotentHint": false, + "destructiveHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + }, + "outputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "content": { + "type": "string" + } + }, + "required": [ + "content" + ], + "additionalProperties": false + } + }, + { + "name": "search_files", + "title": "Search Files", + "description": "Recursively search for files and directories matching a pattern. The patterns should be glob-style patterns that match paths relative to the working directory. Use pattern like '*.ext' to match files in current directory, and '**/*.ext' to match files in all subdirectories. Returns full paths to all matching items. Great for finding files when you don't know their exact location. Only searches within allowed directories.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "pattern": { + "type": "string" + }, + "excludePatterns": { + "default": [], + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "path", + "pattern" + ] + }, + "annotations": { + "readOnlyHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + }, + "outputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "content": { + "type": "string" + } + }, + "required": [ + "content" + ], + "additionalProperties": false + } + }, + { + "name": "get_file_info", + "title": "Get File Info", + "description": "Retrieve detailed metadata about a file or directory. Returns comprehensive information including size, creation time, last modified time, permissions, and type. This tool is perfect for understanding file characteristics without reading the actual content. Only works within allowed directories.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ] + }, + "annotations": { + "readOnlyHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + }, + "outputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "content": { + "type": "string" + } + }, + "required": [ + "content" + ], + "additionalProperties": false + } + }, + { + "name": "list_allowed_directories", + "title": "List Allowed Directories", + "description": "Returns the list of directories that this server is allowed to access. Subdirectories within these allowed directories are also accessible. Use this to understand which directories and their nested paths are available before trying to access files.", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {} + }, + "annotations": { + "readOnlyHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + }, + "outputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "content": { + "type": "string" + } + }, + "required": [ + "content" + ], + "additionalProperties": false + } + } + ] +} diff --git a/src/mcp/testdata/memory.d.ts b/src/mcp/testdata/memory.d.ts new file mode 100644 index 00000000..58bc5eea --- /dev/null +++ b/src/mcp/testdata/memory.d.ts @@ -0,0 +1,71 @@ +declare const memory: { + /** Create multiple new entities in the knowledge graph */ + create_entities(params: { + entities: { + /** The type of the entity */ + entityType: string; + /** The name of the entity */ + name: string; + /** An array of observation contents associated with the entity */ + observations: string[]; + }[]; + }): Promise; + /** Create multiple new relations between entities in the knowledge graph. Relations should be in active voice */ + create_relations(params: { + relations: { + /** The name of the entity where the relation starts */ + from: string; + /** The type of the relation */ + relationType: string; + /** The name of the entity where the relation ends */ + to: string; + }[]; + }): Promise; + /** Add new observations to existing entities in the knowledge graph */ + add_observations(params: { + observations: { + /** An array of observation contents to add */ + contents: string[]; + /** The name of the entity to add the observations to */ + entityName: string; + }[]; + }): Promise; + /** Delete multiple entities and their associated relations from the knowledge graph */ + delete_entities(params: { + /** An array of entity names to delete */ + entityNames: string[]; + }): Promise; + /** Delete specific observations from entities in the knowledge graph */ + delete_observations(params: { + deletions: { + /** The name of the entity containing the observations */ + entityName: string; + /** An array of observations to delete */ + observations: string[]; + }[]; + }): Promise; + /** Delete multiple relations from the knowledge graph */ + delete_relations(params: { + /** An array of relations to delete */ + relations: { + /** The name of the entity where the relation starts */ + from: string; + /** The type of the relation */ + relationType: string; + /** The name of the entity where the relation ends */ + to: string; + }[]; + }): Promise; + /** Read the entire knowledge graph */ + read_graph(): Promise; + /** Search for nodes in the knowledge graph based on a query */ + search_nodes(params: { + /** The search query to match against entity names, types, and observation content */ + query: string; + }): Promise; + /** Open specific nodes in the knowledge graph by their names */ + open_nodes(params: { + /** An array of entity names to retrieve */ + names: string[]; + }): Promise; +}; diff --git a/src/mcp/testdata/memory.tools.json b/src/mcp/testdata/memory.tools.json new file mode 100644 index 00000000..0802be5a --- /dev/null +++ b/src/mcp/testdata/memory.tools.json @@ -0,0 +1,706 @@ +{ + "tools": [ + { + "name": "create_entities", + "title": "Create Entities", + "description": "Create multiple new entities in the knowledge graph", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "entities": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the entity" + }, + "entityType": { + "type": "string", + "description": "The type of the entity" + }, + "observations": { + "type": "array", + "items": { + "type": "string" + }, + "description": "An array of observation contents associated with the entity" + } + }, + "required": [ + "name", + "entityType", + "observations" + ] + } + } + }, + "required": [ + "entities" + ] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + }, + "outputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "entities": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the entity" + }, + "entityType": { + "type": "string", + "description": "The type of the entity" + }, + "observations": { + "type": "array", + "items": { + "type": "string" + }, + "description": "An array of observation contents associated with the entity" + } + }, + "required": [ + "name", + "entityType", + "observations" + ], + "additionalProperties": false + } + } + }, + "required": [ + "entities" + ], + "additionalProperties": false + } + }, + { + "name": "create_relations", + "title": "Create Relations", + "description": "Create multiple new relations between entities in the knowledge graph. Relations should be in active voice", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "relations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "from": { + "type": "string", + "description": "The name of the entity where the relation starts" + }, + "to": { + "type": "string", + "description": "The name of the entity where the relation ends" + }, + "relationType": { + "type": "string", + "description": "The type of the relation" + } + }, + "required": [ + "from", + "to", + "relationType" + ] + } + } + }, + "required": [ + "relations" + ] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + }, + "outputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "relations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "from": { + "type": "string", + "description": "The name of the entity where the relation starts" + }, + "to": { + "type": "string", + "description": "The name of the entity where the relation ends" + }, + "relationType": { + "type": "string", + "description": "The type of the relation" + } + }, + "required": [ + "from", + "to", + "relationType" + ], + "additionalProperties": false + } + } + }, + "required": [ + "relations" + ], + "additionalProperties": false + } + }, + { + "name": "add_observations", + "title": "Add Observations", + "description": "Add new observations to existing entities in the knowledge graph", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "observations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "entityName": { + "type": "string", + "description": "The name of the entity to add the observations to" + }, + "contents": { + "type": "array", + "items": { + "type": "string" + }, + "description": "An array of observation contents to add" + } + }, + "required": [ + "entityName", + "contents" + ] + } + } + }, + "required": [ + "observations" + ] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + }, + "outputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "results": { + "type": "array", + "items": { + "type": "object", + "properties": { + "entityName": { + "type": "string" + }, + "addedObservations": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "entityName", + "addedObservations" + ], + "additionalProperties": false + } + } + }, + "required": [ + "results" + ], + "additionalProperties": false + } + }, + { + "name": "delete_entities", + "title": "Delete Entities", + "description": "Delete multiple entities and their associated relations from the knowledge graph", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "entityNames": { + "type": "array", + "items": { + "type": "string" + }, + "description": "An array of entity names to delete" + } + }, + "required": [ + "entityNames" + ] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + }, + "outputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" + } + }, + "required": [ + "success", + "message" + ], + "additionalProperties": false + } + }, + { + "name": "delete_observations", + "title": "Delete Observations", + "description": "Delete specific observations from entities in the knowledge graph", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "deletions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "entityName": { + "type": "string", + "description": "The name of the entity containing the observations" + }, + "observations": { + "type": "array", + "items": { + "type": "string" + }, + "description": "An array of observations to delete" + } + }, + "required": [ + "entityName", + "observations" + ] + } + } + }, + "required": [ + "deletions" + ] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + }, + "outputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" + } + }, + "required": [ + "success", + "message" + ], + "additionalProperties": false + } + }, + { + "name": "delete_relations", + "title": "Delete Relations", + "description": "Delete multiple relations from the knowledge graph", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "relations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "from": { + "type": "string", + "description": "The name of the entity where the relation starts" + }, + "to": { + "type": "string", + "description": "The name of the entity where the relation ends" + }, + "relationType": { + "type": "string", + "description": "The type of the relation" + } + }, + "required": [ + "from", + "to", + "relationType" + ] + }, + "description": "An array of relations to delete" + } + }, + "required": [ + "relations" + ] + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + }, + "outputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" + } + }, + "required": [ + "success", + "message" + ], + "additionalProperties": false + } + }, + { + "name": "read_graph", + "title": "Read Graph", + "description": "Read the entire knowledge graph", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": {} + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + }, + "outputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "entities": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the entity" + }, + "entityType": { + "type": "string", + "description": "The type of the entity" + }, + "observations": { + "type": "array", + "items": { + "type": "string" + }, + "description": "An array of observation contents associated with the entity" + } + }, + "required": [ + "name", + "entityType", + "observations" + ], + "additionalProperties": false + } + }, + "relations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "from": { + "type": "string", + "description": "The name of the entity where the relation starts" + }, + "to": { + "type": "string", + "description": "The name of the entity where the relation ends" + }, + "relationType": { + "type": "string", + "description": "The type of the relation" + } + }, + "required": [ + "from", + "to", + "relationType" + ], + "additionalProperties": false + } + } + }, + "required": [ + "entities", + "relations" + ], + "additionalProperties": false + } + }, + { + "name": "search_nodes", + "title": "Search Nodes", + "description": "Search for nodes in the knowledge graph based on a query", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "The search query to match against entity names, types, and observation content" + } + }, + "required": [ + "query" + ] + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + }, + "outputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "entities": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the entity" + }, + "entityType": { + "type": "string", + "description": "The type of the entity" + }, + "observations": { + "type": "array", + "items": { + "type": "string" + }, + "description": "An array of observation contents associated with the entity" + } + }, + "required": [ + "name", + "entityType", + "observations" + ], + "additionalProperties": false + } + }, + "relations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "from": { + "type": "string", + "description": "The name of the entity where the relation starts" + }, + "to": { + "type": "string", + "description": "The name of the entity where the relation ends" + }, + "relationType": { + "type": "string", + "description": "The type of the relation" + } + }, + "required": [ + "from", + "to", + "relationType" + ], + "additionalProperties": false + } + } + }, + "required": [ + "entities", + "relations" + ], + "additionalProperties": false + } + }, + { + "name": "open_nodes", + "title": "Open Nodes", + "description": "Open specific nodes in the knowledge graph by their names", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "names": { + "type": "array", + "items": { + "type": "string" + }, + "description": "An array of entity names to retrieve" + } + }, + "required": [ + "names" + ] + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + }, + "outputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "entities": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the entity" + }, + "entityType": { + "type": "string", + "description": "The type of the entity" + }, + "observations": { + "type": "array", + "items": { + "type": "string" + }, + "description": "An array of observation contents associated with the entity" + } + }, + "required": [ + "name", + "entityType", + "observations" + ], + "additionalProperties": false + } + }, + "relations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "from": { + "type": "string", + "description": "The name of the entity where the relation starts" + }, + "to": { + "type": "string", + "description": "The name of the entity where the relation ends" + }, + "relationType": { + "type": "string", + "description": "The type of the relation" + } + }, + "required": [ + "from", + "to", + "relationType" + ], + "additionalProperties": false + } + } + }, + "required": [ + "entities", + "relations" + ], + "additionalProperties": false + } + } + ] +} diff --git a/src/mcp/testdata/playwright.d.ts b/src/mcp/testdata/playwright.d.ts new file mode 100644 index 00000000..d09e7d7f --- /dev/null +++ b/src/mcp/testdata/playwright.d.ts @@ -0,0 +1,211 @@ +declare const playwright: { + /** Close the page */ + browser_close(): Promise; + /** Resize the browser window */ + browser_resize(params: { + /** Height of the browser window */ + height: number; + /** Width of the browser window */ + width: number; + }): Promise; + /** Returns all console messages */ + browser_console_messages(params: { + /** Return all console messages since the beginning of the session, not just since the last navigation. Defaults to false. */ + all?: boolean; + /** Filename to save the console messages to. If not provided, messages are returned as text. */ + filename?: string; + /** Level of the console messages to return. Each level includes the messages of more severe levels. Defaults to "info". */ + level: "error" | "warning" | "info" | "debug"; + }): Promise; + /** Handle a dialog */ + browser_handle_dialog(params: { + /** Whether to accept the dialog. */ + accept: boolean; + /** The text of the prompt in case of a prompt dialog. */ + promptText?: string; + }): Promise; + /** Evaluate JavaScript expression on page or element */ + browser_evaluate(params: { + /** Human-readable element description used to obtain permission to interact with the element */ + element?: string; + /** Filename to save the result to. If not provided, result is returned as text. */ + filename?: string; + /** () => { /* code * / } or (element) => { /* code * / } when element is provided */ + function: string; + /** Exact target element reference from the page snapshot, or a unique element selector */ + target?: string; + }): Promise; + /** Upload one or multiple files */ + browser_file_upload(params?: { + /** The absolute paths to the files to upload. Can be single file or multiple files. If omitted, file chooser is cancelled. */ + paths?: string[]; + }): Promise; + /** Drop files or MIME-typed data onto an element, as if dragged from outside the page. At least one of "paths" or "data" must be provided. */ + browser_drop(params: { + /** Data to drop, as a map of MIME type to string value (e.g. {"text/plain": "hello", "text/uri-list": "https://example.com"}). */ + data?: Record; + /** Human-readable element description used to obtain permission to interact with the element */ + element?: string; + /** Absolute paths to files to drop onto the element. */ + paths?: string[]; + /** Exact target element reference from the page snapshot, or a unique element selector */ + target: string; + }): Promise; + /** Search the accessibility snapshot of the current page for text or a regular expression. Returns matching snapshot nodes with a few lines of surrounding context (like search snippets), each shown under its path from the root of the tree, which is cheaper than capturing the whole snapshot when you only need to locate an element and its ref. */ + browser_find(params?: { + /** Regular expression to search for in the page snapshot. Matching is case-sensitive by default; wrap the pattern in slashes to add flags, e.g. "/error/i" for case-insensitive. Provide either text or regex, not both. */ + regex?: string; + /** Plain text to search for in the page snapshot (case-insensitive substring match). Provide either text or regex, not both. */ + text?: string; + }): Promise; + /** Fill multiple form fields */ + browser_fill_form(params: { + /** Fields to fill in */ + fields: ({ + /** Human-readable element description used to obtain permission to interact with the element */ + element?: string; + /** Human-readable field name */ + name: string; + /** Exact target element reference from the page snapshot, or a unique element selector */ + target: string; + /** Type of the field */ + type: "textbox" | "checkbox" | "radio" | "combobox" | "slider"; + /** Value to fill in the field. If the field is a checkbox, the value should be `true` or `false`. If the field is a combobox, the value should be the text of the option. */ + value: string; + })[]; + }): Promise; + /** Press a key on the keyboard */ + browser_press_key(params: { + /** Name of the key to press or a character to generate, such as `ArrowLeft` or `a` */ + key: string; + }): Promise; + /** Type text into editable element */ + browser_type(params: { + /** Human-readable element description used to obtain permission to interact with the element */ + element?: string; + /** Whether to type one character at a time. Useful for triggering key handlers in the page. By default entire text is filled in at once. */ + slowly?: boolean; + /** Whether to submit entered text (press Enter after) */ + submit?: boolean; + /** Exact target element reference from the page snapshot, or a unique element selector */ + target: string; + /** Text to type into the element */ + text: string; + }): Promise; + /** Navigate to a URL */ + browser_navigate(params: { + /** The URL to navigate to */ + url: string; + }): Promise; + /** Go back to the previous page in the history */ + browser_navigate_back(): Promise; + /** Returns a numbered list of network requests since loading the page. Use browser_network_request with the number to get full details. */ + browser_network_requests(params: { + /** Filename to save the network requests to. If not provided, requests are returned as text. */ + filename?: string; + /** Only return requests whose URL matches this regexp (e.g. "/api/.*user"). */ + filter?: string; + /** Whether to include successful static resources like images, fonts, scripts, etc. Defaults to false. */ + static: boolean; + }): Promise; + /** Returns full details (headers and body) of a single network request, or a single part if `part` is set. Use the number from browser_network_requests. */ + browser_network_request(params: { + /** Filename to save the result to. If not provided, output is returned as text. */ + filename?: string; + /** 1-based index of the request, as printed by browser_network_requests. */ + index: number; + /** Return only this part of the request. Omit to return full details. */ + part?: "request-headers" | "request-body" | "response-headers" | "response-body"; + }): Promise; + /** Run a Playwright code snippet. Unsafe: executes arbitrary JavaScript in the Playwright server process and is RCE-equivalent. */ + browser_run_code_unsafe(params?: { + /** A JavaScript function containing Playwright code to execute. It will be invoked with a single argument, page, which you can use for any page interaction. For example: `async (page) => { await page.getByRole('button', { name: 'Submit' }).click(); return await page.title(); }` */ + code?: string; + /** Load code from the specified file. If both code and filename are provided, code will be ignored. */ + filename?: string; + }): Promise; + /** Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions. */ + browser_take_screenshot(params: { + /** Human-readable element description used to obtain permission to interact with the element */ + element?: string; + /** File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg}` if not specified. Prefer relative file names to stay within the output directory. */ + filename?: string; + /** When true, takes a screenshot of the full scrollable page, instead of the currently visible viewport. Cannot be used with element screenshots. */ + fullPage?: boolean; + /** Image resolution scale. "css" produces a screenshot sized in CSS pixels (smaller, consistent across devices). "device" produces a high-resolution screenshot using device pixels (larger, accounts for the device pixel ratio). Default is css. */ + scale: "css" | "device"; + /** Exact target element reference from the page snapshot, or a unique element selector */ + target?: string; + /** Image format for the screenshot. Default is png. */ + type: "png" | "jpeg"; + }): Promise; + /** Capture accessibility snapshot of the current page, this is better than screenshot */ + browser_snapshot(params?: { + /** Include each element's bounding box as [box=x,y,width,height] in the snapshot. Coordinates are viewport-relative, in CSS pixels (Element.getBoundingClientRect) */ + boxes?: boolean; + /** Limit the depth of the snapshot tree */ + depth?: number; + /** Save snapshot to markdown file instead of returning it in the response. */ + filename?: string; + /** Exact target element reference from the page snapshot, or a unique element selector */ + target?: string; + }): Promise; + /** Perform click on a web page */ + browser_click(params: { + /** Button to click, defaults to left */ + button?: "left" | "right" | "middle"; + /** Whether to perform a double click instead of a single click */ + doubleClick?: boolean; + /** Human-readable element description used to obtain permission to interact with the element */ + element?: string; + /** Modifier keys to press */ + modifiers?: ("Alt" | "Control" | "ControlOrMeta" | "Meta" | "Shift")[]; + /** Exact target element reference from the page snapshot, or a unique element selector */ + target: string; + }): Promise; + /** Perform drag and drop between two elements */ + browser_drag(params: { + /** Human-readable target element description used to obtain the permission to interact with the element */ + endElement?: string; + /** Exact target element reference from the page snapshot, or a unique element selector */ + endTarget: string; + /** Human-readable source element description used to obtain the permission to interact with the element */ + startElement?: string; + /** Exact target element reference from the page snapshot, or a unique element selector */ + startTarget: string; + }): Promise; + /** Hover over element on page */ + browser_hover(params: { + /** Human-readable element description used to obtain permission to interact with the element */ + element?: string; + /** Exact target element reference from the page snapshot, or a unique element selector */ + target: string; + }): Promise; + /** Select an option in a dropdown */ + browser_select_option(params: { + /** Human-readable element description used to obtain permission to interact with the element */ + element?: string; + /** Exact target element reference from the page snapshot, or a unique element selector */ + target: string; + /** Array of values to select in the dropdown. This can be a single value or multiple values. */ + values: string[]; + }): Promise; + /** List, create, close, or select a browser tab. */ + browser_tabs(params: { + /** Operation to perform */ + action: "list" | "new" | "close" | "select"; + /** Tab index, used for close/select. If omitted for close, current tab is closed. */ + index?: number; + /** URL to navigate to in the new tab, used for new. */ + url?: string; + }): Promise; + /** Wait for text to appear or disappear or a specified time to pass */ + browser_wait_for(params?: { + /** The text to wait for */ + text?: string; + /** The text to wait for to disappear */ + textGone?: string; + /** The time to wait in seconds */ + time?: number; + }): Promise; +}; diff --git a/src/mcp/testdata/playwright.tools.json b/src/mcp/testdata/playwright.tools.json new file mode 100644 index 00000000..f72c3fec --- /dev/null +++ b/src/mcp/testdata/playwright.tools.json @@ -0,0 +1,823 @@ +{ + "tools": [ + { + "name": "browser_close", + "description": "Close the page", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "annotations": { + "title": "Close browser", + "readOnlyHint": false, + "destructiveHint": true, + "openWorldHint": true + } + }, + { + "name": "browser_resize", + "description": "Resize the browser window", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "width": { + "type": "number", + "description": "Width of the browser window" + }, + "height": { + "type": "number", + "description": "Height of the browser window" + } + }, + "required": [ + "width", + "height" + ], + "additionalProperties": false + }, + "annotations": { + "title": "Resize browser window", + "readOnlyHint": false, + "destructiveHint": true, + "openWorldHint": true + } + }, + { + "name": "browser_console_messages", + "description": "Returns all console messages", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "level": { + "default": "info", + "description": "Level of the console messages to return. Each level includes the messages of more severe levels. Defaults to \"info\".", + "type": "string", + "enum": [ + "error", + "warning", + "info", + "debug" + ] + }, + "all": { + "description": "Return all console messages since the beginning of the session, not just since the last navigation. Defaults to false.", + "type": "boolean" + }, + "filename": { + "description": "Filename to save the console messages to. If not provided, messages are returned as text.", + "type": "string" + } + }, + "required": [ + "level" + ], + "additionalProperties": false + }, + "annotations": { + "title": "Get console messages", + "readOnlyHint": true, + "destructiveHint": false, + "openWorldHint": true + } + }, + { + "name": "browser_handle_dialog", + "description": "Handle a dialog", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "accept": { + "type": "boolean", + "description": "Whether to accept the dialog." + }, + "promptText": { + "description": "The text of the prompt in case of a prompt dialog.", + "type": "string" + } + }, + "required": [ + "accept" + ], + "additionalProperties": false + }, + "annotations": { + "title": "Handle a dialog", + "readOnlyHint": false, + "destructiveHint": true, + "openWorldHint": true + } + }, + { + "name": "browser_evaluate", + "description": "Evaluate JavaScript expression on page or element", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "element": { + "description": "Human-readable element description used to obtain permission to interact with the element", + "type": "string" + }, + "target": { + "description": "Exact target element reference from the page snapshot, or a unique element selector", + "type": "string" + }, + "function": { + "type": "string", + "description": "() => { /* code */ } or (element) => { /* code */ } when element is provided" + }, + "filename": { + "description": "Filename to save the result to. If not provided, result is returned as text.", + "type": "string" + } + }, + "required": [ + "function" + ], + "additionalProperties": false + }, + "annotations": { + "title": "Evaluate JavaScript", + "readOnlyHint": false, + "destructiveHint": true, + "openWorldHint": true + } + }, + { + "name": "browser_file_upload", + "description": "Upload one or multiple files", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "paths": { + "description": "The absolute paths to the files to upload. Can be single file or multiple files. If omitted, file chooser is cancelled.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "annotations": { + "title": "Upload files", + "readOnlyHint": false, + "destructiveHint": true, + "openWorldHint": true + } + }, + { + "name": "browser_drop", + "description": "Drop files or MIME-typed data onto an element, as if dragged from outside the page. At least one of \"paths\" or \"data\" must be provided.", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "element": { + "description": "Human-readable element description used to obtain permission to interact with the element", + "type": "string" + }, + "target": { + "type": "string", + "description": "Exact target element reference from the page snapshot, or a unique element selector" + }, + "paths": { + "description": "Absolute paths to files to drop onto the element.", + "type": "array", + "items": { + "type": "string" + } + }, + "data": { + "description": "Data to drop, as a map of MIME type to string value (e.g. {\"text/plain\": \"hello\", \"text/uri-list\": \"https://example.com\"}).", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "target" + ], + "additionalProperties": false + }, + "annotations": { + "title": "Drop files or data onto an element", + "readOnlyHint": false, + "destructiveHint": true, + "openWorldHint": true + } + }, + { + "name": "browser_find", + "description": "Search the accessibility snapshot of the current page for text or a regular expression. Returns matching snapshot nodes with a few lines of surrounding context (like search snippets), each shown under its path from the root of the tree, which is cheaper than capturing the whole snapshot when you only need to locate an element and its ref.", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "description": "Plain text to search for in the page snapshot (case-insensitive substring match). Provide either text or regex, not both.", + "type": "string" + }, + "regex": { + "description": "Regular expression to search for in the page snapshot. Matching is case-sensitive by default; wrap the pattern in slashes to add flags, e.g. \"/error/i\" for case-insensitive. Provide either text or regex, not both.", + "type": "string" + } + }, + "additionalProperties": false + }, + "annotations": { + "title": "Find in page snapshot", + "readOnlyHint": true, + "destructiveHint": false, + "openWorldHint": true + } + }, + { + "name": "browser_fill_form", + "description": "Fill multiple form fields", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "fields": { + "type": "array", + "items": { + "type": "object", + "properties": { + "element": { + "description": "Human-readable element description used to obtain permission to interact with the element", + "type": "string" + }, + "target": { + "type": "string", + "description": "Exact target element reference from the page snapshot, or a unique element selector" + }, + "name": { + "type": "string", + "description": "Human-readable field name" + }, + "type": { + "type": "string", + "enum": [ + "textbox", + "checkbox", + "radio", + "combobox", + "slider" + ], + "description": "Type of the field" + }, + "value": { + "type": "string", + "description": "Value to fill in the field. If the field is a checkbox, the value should be `true` or `false`. If the field is a combobox, the value should be the text of the option." + } + }, + "required": [ + "target", + "name", + "type", + "value" + ], + "additionalProperties": false + }, + "description": "Fields to fill in" + } + }, + "required": [ + "fields" + ], + "additionalProperties": false + }, + "annotations": { + "title": "Fill form", + "readOnlyHint": false, + "destructiveHint": true, + "openWorldHint": true + } + }, + { + "name": "browser_press_key", + "description": "Press a key on the keyboard", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "Name of the key to press or a character to generate, such as `ArrowLeft` or `a`" + } + }, + "required": [ + "key" + ], + "additionalProperties": false + }, + "annotations": { + "title": "Press a key", + "readOnlyHint": false, + "destructiveHint": true, + "openWorldHint": true + } + }, + { + "name": "browser_type", + "description": "Type text into editable element", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "element": { + "description": "Human-readable element description used to obtain permission to interact with the element", + "type": "string" + }, + "target": { + "type": "string", + "description": "Exact target element reference from the page snapshot, or a unique element selector" + }, + "text": { + "type": "string", + "description": "Text to type into the element" + }, + "submit": { + "description": "Whether to submit entered text (press Enter after)", + "type": "boolean" + }, + "slowly": { + "description": "Whether to type one character at a time. Useful for triggering key handlers in the page. By default entire text is filled in at once.", + "type": "boolean" + } + }, + "required": [ + "target", + "text" + ], + "additionalProperties": false + }, + "annotations": { + "title": "Type text", + "readOnlyHint": false, + "destructiveHint": true, + "openWorldHint": true + } + }, + { + "name": "browser_navigate", + "description": "Navigate to a URL", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "The URL to navigate to" + } + }, + "required": [ + "url" + ], + "additionalProperties": false + }, + "annotations": { + "title": "Navigate to a URL", + "readOnlyHint": false, + "destructiveHint": true, + "openWorldHint": true + } + }, + { + "name": "browser_navigate_back", + "description": "Go back to the previous page in the history", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "annotations": { + "title": "Go back", + "readOnlyHint": false, + "destructiveHint": true, + "openWorldHint": true + } + }, + { + "name": "browser_network_requests", + "description": "Returns a numbered list of network requests since loading the page. Use browser_network_request with the number to get full details.", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "static": { + "default": false, + "description": "Whether to include successful static resources like images, fonts, scripts, etc. Defaults to false.", + "type": "boolean" + }, + "filter": { + "description": "Only return requests whose URL matches this regexp (e.g. \"/api/.*user\").", + "type": "string" + }, + "filename": { + "description": "Filename to save the network requests to. If not provided, requests are returned as text.", + "type": "string" + } + }, + "required": [ + "static" + ], + "additionalProperties": false + }, + "annotations": { + "title": "List network requests", + "readOnlyHint": true, + "destructiveHint": false, + "openWorldHint": true + } + }, + { + "name": "browser_network_request", + "description": "Returns full details (headers and body) of a single network request, or a single part if `part` is set. Use the number from browser_network_requests.", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "index": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991, + "description": "1-based index of the request, as printed by browser_network_requests." + }, + "part": { + "description": "Return only this part of the request. Omit to return full details.", + "type": "string", + "enum": [ + "request-headers", + "request-body", + "response-headers", + "response-body" + ] + }, + "filename": { + "description": "Filename to save the result to. If not provided, output is returned as text.", + "type": "string" + } + }, + "required": [ + "index" + ], + "additionalProperties": false + }, + "annotations": { + "title": "Show network request details", + "readOnlyHint": true, + "destructiveHint": false, + "openWorldHint": true + } + }, + { + "name": "browser_run_code_unsafe", + "description": "Run a Playwright code snippet. Unsafe: executes arbitrary JavaScript in the Playwright server process and is RCE-equivalent.", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "code": { + "description": "A JavaScript function containing Playwright code to execute. It will be invoked with a single argument, page, which you can use for any page interaction. For example: `async (page) => { await page.getByRole('button', { name: 'Submit' }).click(); return await page.title(); }`", + "type": "string" + }, + "filename": { + "description": "Load code from the specified file. If both code and filename are provided, code will be ignored.", + "type": "string" + } + }, + "additionalProperties": false + }, + "annotations": { + "title": "Run Playwright code (unsafe)", + "readOnlyHint": false, + "destructiveHint": true, + "openWorldHint": true + } + }, + { + "name": "browser_take_screenshot", + "description": "Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions.", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "element": { + "description": "Human-readable element description used to obtain permission to interact with the element", + "type": "string" + }, + "target": { + "description": "Exact target element reference from the page snapshot, or a unique element selector", + "type": "string" + }, + "type": { + "default": "png", + "description": "Image format for the screenshot. Default is png.", + "type": "string", + "enum": [ + "png", + "jpeg" + ] + }, + "filename": { + "description": "File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg}` if not specified. Prefer relative file names to stay within the output directory.", + "type": "string" + }, + "fullPage": { + "description": "When true, takes a screenshot of the full scrollable page, instead of the currently visible viewport. Cannot be used with element screenshots.", + "type": "boolean" + }, + "scale": { + "default": "css", + "description": "Image resolution scale. \"css\" produces a screenshot sized in CSS pixels (smaller, consistent across devices). \"device\" produces a high-resolution screenshot using device pixels (larger, accounts for the device pixel ratio). Default is css.", + "type": "string", + "enum": [ + "css", + "device" + ] + } + }, + "required": [ + "type", + "scale" + ], + "additionalProperties": false + }, + "annotations": { + "title": "Take a screenshot", + "readOnlyHint": true, + "destructiveHint": false, + "openWorldHint": true + } + }, + { + "name": "browser_snapshot", + "description": "Capture accessibility snapshot of the current page, this is better than screenshot", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "target": { + "description": "Exact target element reference from the page snapshot, or a unique element selector", + "type": "string" + }, + "filename": { + "description": "Save snapshot to markdown file instead of returning it in the response.", + "type": "string" + }, + "depth": { + "description": "Limit the depth of the snapshot tree", + "type": "number" + }, + "boxes": { + "description": "Include each element's bounding box as [box=x,y,width,height] in the snapshot. Coordinates are viewport-relative, in CSS pixels (Element.getBoundingClientRect)", + "type": "boolean" + } + }, + "additionalProperties": false + }, + "annotations": { + "title": "Page snapshot", + "readOnlyHint": true, + "destructiveHint": false, + "openWorldHint": true + } + }, + { + "name": "browser_click", + "description": "Perform click on a web page", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "element": { + "description": "Human-readable element description used to obtain permission to interact with the element", + "type": "string" + }, + "target": { + "type": "string", + "description": "Exact target element reference from the page snapshot, or a unique element selector" + }, + "doubleClick": { + "description": "Whether to perform a double click instead of a single click", + "type": "boolean" + }, + "button": { + "description": "Button to click, defaults to left", + "type": "string", + "enum": [ + "left", + "right", + "middle" + ] + }, + "modifiers": { + "description": "Modifier keys to press", + "type": "array", + "items": { + "type": "string", + "enum": [ + "Alt", + "Control", + "ControlOrMeta", + "Meta", + "Shift" + ] + } + } + }, + "required": [ + "target" + ], + "additionalProperties": false + }, + "annotations": { + "title": "Click", + "readOnlyHint": false, + "destructiveHint": true, + "openWorldHint": true + } + }, + { + "name": "browser_drag", + "description": "Perform drag and drop between two elements", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "startElement": { + "description": "Human-readable source element description used to obtain the permission to interact with the element", + "type": "string" + }, + "startTarget": { + "type": "string", + "description": "Exact target element reference from the page snapshot, or a unique element selector" + }, + "endElement": { + "description": "Human-readable target element description used to obtain the permission to interact with the element", + "type": "string" + }, + "endTarget": { + "type": "string", + "description": "Exact target element reference from the page snapshot, or a unique element selector" + } + }, + "required": [ + "startTarget", + "endTarget" + ], + "additionalProperties": false + }, + "annotations": { + "title": "Drag mouse", + "readOnlyHint": false, + "destructiveHint": true, + "openWorldHint": true + } + }, + { + "name": "browser_hover", + "description": "Hover over element on page", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "element": { + "description": "Human-readable element description used to obtain permission to interact with the element", + "type": "string" + }, + "target": { + "type": "string", + "description": "Exact target element reference from the page snapshot, or a unique element selector" + } + }, + "required": [ + "target" + ], + "additionalProperties": false + }, + "annotations": { + "title": "Hover mouse", + "readOnlyHint": false, + "destructiveHint": true, + "openWorldHint": true + } + }, + { + "name": "browser_select_option", + "description": "Select an option in a dropdown", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "element": { + "description": "Human-readable element description used to obtain permission to interact with the element", + "type": "string" + }, + "target": { + "type": "string", + "description": "Exact target element reference from the page snapshot, or a unique element selector" + }, + "values": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of values to select in the dropdown. This can be a single value or multiple values." + } + }, + "required": [ + "target", + "values" + ], + "additionalProperties": false + }, + "annotations": { + "title": "Select option", + "readOnlyHint": false, + "destructiveHint": true, + "openWorldHint": true + } + }, + { + "name": "browser_tabs", + "description": "List, create, close, or select a browser tab.", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "list", + "new", + "close", + "select" + ], + "description": "Operation to perform" + }, + "index": { + "description": "Tab index, used for close/select. If omitted for close, current tab is closed.", + "type": "number" + }, + "url": { + "description": "URL to navigate to in the new tab, used for new.", + "type": "string" + } + }, + "required": [ + "action" + ], + "additionalProperties": false + }, + "annotations": { + "title": "Manage tabs", + "readOnlyHint": false, + "destructiveHint": true, + "openWorldHint": true + } + }, + { + "name": "browser_wait_for", + "description": "Wait for text to appear or disappear or a specified time to pass", + "inputSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "time": { + "description": "The time to wait in seconds", + "type": "number" + }, + "text": { + "description": "The text to wait for", + "type": "string" + }, + "textGone": { + "description": "The text to wait for to disappear", + "type": "string" + } + }, + "additionalProperties": false + }, + "annotations": { + "title": "Wait for", + "readOnlyHint": true, + "destructiveHint": false, + "openWorldHint": true + } + } + ] +} diff --git a/src/mcp/testdata/sequentialthinking.d.ts b/src/mcp/testdata/sequentialthinking.d.ts new file mode 100644 index 00000000..b920deea --- /dev/null +++ b/src/mcp/testdata/sequentialthinking.d.ts @@ -0,0 +1,23 @@ +declare const sequentialthinking: { + /** A detailed tool for dynamic and reflective problem-solving through thoughts. This tool helps analyze problems through a flexible thinking process that can adapt and evolve. Each thought can build on, question, or revise previous insights as understanding deepens. When to use this tool: - Breaking down complex problems into steps - Planning and design with room for revision - Analysis that might need course correction - Problems where the full scope might not be clear initially - Problems that require a multi-step solution - Tasks that need to maintain context over multiple steps - Situations where irrelevant information needs to be filtered out Key features: - You can adjust total_thoughts up or down as you progress - You can question or revise previous thoughts - You can add more thoughts even after reaching what seemed like the end - You can express uncertainty and explore alternative approaches - Not every thought needs to build linearly - you can branch or backtrack - Generates a solution hypothesis - Verifies the hypothesis based on the Chain of Thought steps - Repeats the process until satisfied - Provides a correct answer Parameters explained: - thought: Your current thinking step, which can include: * Regular analytical steps * Revisions of previous thoughts * Questions about previous decisions * Realizations about needing more analysis * Changes in approach * Hypothesis generation * Hypothesis verification - nextThoughtNeeded: True if you need more thinking, even if at what seemed like the end - thoughtNumber: Current number in sequence (can go beyond initial total if needed) - totalThoughts: Current estimate of thoughts needed (can be adjusted up/down) - isRevision: A boolean indicating if this thought revises previous thinking - revisesThought: If is_revision is true, which thought number is being reconsidered - branchFromThought: If branching, which thought number is the branching point - branchId: Identifier for the current branch (if any) - needsMoreThoughts: If reaching end but realizing more thoughts needed You should: 1. Start with an initial estimate of needed thoughts, but be ready to adjust 2. Feel free to question or revise previous thoughts 3. Don't hesitate to add more thoughts if needed, even at the "end" 4. Express uncertainty when present 5. Mark thoughts that revise previous thinking or branch into new paths 6. Ignore information that is irrelevant to the current step 7. Generate a solution hypothesis when appropriate 8. Verify the hypothesis based on the Chain of Thought steps 9. Repeat the process until satisfied with the solution 10. Provide a single, ideally correct answer as the final output 11. Only set nextThoughtNeeded to false when truly done and a satisfactory answer is reached */ + sequentialthinking(params: { + /** Branching point thought number */ + branchFromThought?: number; + /** Branch identifier */ + branchId?: string; + /** Whether this revises previous thinking */ + isRevision?: boolean; + /** If more thoughts are needed */ + needsMoreThoughts?: boolean; + /** Whether another thought step is needed */ + nextThoughtNeeded?: boolean; + /** Which thought is being reconsidered */ + revisesThought?: number; + /** Your current thinking step */ + thought: string; + /** Current thought number (numeric value, e.g., 1, 2, 3) */ + thoughtNumber: number; + /** Estimated total thoughts needed (numeric value, e.g., 5, 10) */ + totalThoughts: number; + }): Promise; +}; diff --git a/src/mcp/testdata/sequentialthinking.tools.json b/src/mcp/testdata/sequentialthinking.tools.json new file mode 100644 index 00000000..eeb16da2 --- /dev/null +++ b/src/mcp/testdata/sequentialthinking.tools.json @@ -0,0 +1,105 @@ +{ + "tools": [ + { + "name": "sequentialthinking", + "title": "Sequential Thinking", + "description": "A detailed tool for dynamic and reflective problem-solving through thoughts.\nThis tool helps analyze problems through a flexible thinking process that can adapt and evolve.\nEach thought can build on, question, or revise previous insights as understanding deepens.\n\nWhen to use this tool:\n- Breaking down complex problems into steps\n- Planning and design with room for revision\n- Analysis that might need course correction\n- Problems where the full scope might not be clear initially\n- Problems that require a multi-step solution\n- Tasks that need to maintain context over multiple steps\n- Situations where irrelevant information needs to be filtered out\n\nKey features:\n- You can adjust total_thoughts up or down as you progress\n- You can question or revise previous thoughts\n- You can add more thoughts even after reaching what seemed like the end\n- You can express uncertainty and explore alternative approaches\n- Not every thought needs to build linearly - you can branch or backtrack\n- Generates a solution hypothesis\n- Verifies the hypothesis based on the Chain of Thought steps\n- Repeats the process until satisfied\n- Provides a correct answer\n\nParameters explained:\n- thought: Your current thinking step, which can include:\n * Regular analytical steps\n * Revisions of previous thoughts\n * Questions about previous decisions\n * Realizations about needing more analysis\n * Changes in approach\n * Hypothesis generation\n * Hypothesis verification\n- nextThoughtNeeded: True if you need more thinking, even if at what seemed like the end\n- thoughtNumber: Current number in sequence (can go beyond initial total if needed)\n- totalThoughts: Current estimate of thoughts needed (can be adjusted up/down)\n- isRevision: A boolean indicating if this thought revises previous thinking\n- revisesThought: If is_revision is true, which thought number is being reconsidered\n- branchFromThought: If branching, which thought number is the branching point\n- branchId: Identifier for the current branch (if any)\n- needsMoreThoughts: If reaching end but realizing more thoughts needed\n\nYou should:\n1. Start with an initial estimate of needed thoughts, but be ready to adjust\n2. Feel free to question or revise previous thoughts\n3. Don't hesitate to add more thoughts if needed, even at the \"end\"\n4. Express uncertainty when present\n5. Mark thoughts that revise previous thinking or branch into new paths\n6. Ignore information that is irrelevant to the current step\n7. Generate a solution hypothesis when appropriate\n8. Verify the hypothesis based on the Chain of Thought steps\n9. Repeat the process until satisfied with the solution\n10. Provide a single, ideally correct answer as the final output\n11. Only set nextThoughtNeeded to false when truly done and a satisfactory answer is reached", + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "thought": { + "type": "string", + "description": "Your current thinking step" + }, + "nextThoughtNeeded": { + "description": "Whether another thought step is needed", + "type": "boolean" + }, + "thoughtNumber": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991, + "description": "Current thought number (numeric value, e.g., 1, 2, 3)" + }, + "totalThoughts": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991, + "description": "Estimated total thoughts needed (numeric value, e.g., 5, 10)" + }, + "isRevision": { + "description": "Whether this revises previous thinking", + "type": "boolean" + }, + "revisesThought": { + "description": "Which thought is being reconsidered", + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "branchFromThought": { + "description": "Branching point thought number", + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "branchId": { + "description": "Branch identifier", + "type": "string" + }, + "needsMoreThoughts": { + "description": "If more thoughts are needed", + "type": "boolean" + } + }, + "required": [ + "thought", + "thoughtNumber", + "totalThoughts" + ] + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false + }, + "execution": { + "taskSupport": "forbidden" + }, + "outputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "thoughtNumber": { + "type": "number" + }, + "totalThoughts": { + "type": "number" + }, + "nextThoughtNeeded": { + "type": "boolean" + }, + "branches": { + "type": "array", + "items": { + "type": "string" + } + }, + "thoughtHistoryLength": { + "type": "number" + } + }, + "required": [ + "thoughtNumber", + "totalThoughts", + "nextThoughtNeeded", + "branches", + "thoughtHistoryLength" + ], + "additionalProperties": false + } + } + ] +} From 5addcbdd7233e12a2254d64c9831b7a8bfb79d0b Mon Sep 17 00:00:00 2001 From: fluzko Date: Wed, 29 Jul 2026 13:24:13 -0300 Subject: [PATCH 09/39] feat(mcp): add javascript sandbox with execution limits --- Cargo.lock | 133 ++++++++++++++- Cargo.toml | 1 + src/mcp/mod.rs | 1 + src/mcp/sandbox.rs | 410 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 543 insertions(+), 2 deletions(-) create mode 100644 src/mcp/sandbox.rs diff --git a/Cargo.lock b/Cargo.lock index 667fbfe6..530c35a8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -65,6 +65,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -136,6 +142,17 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9" +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -407,6 +424,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "convert_case" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -542,7 +568,7 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ - "convert_case", + "convert_case 0.10.0", "proc-macro2", "quote", "rustc_version", @@ -654,6 +680,25 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "expect-test" version = "1.5.1" @@ -716,6 +761,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -922,7 +973,7 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -931,6 +982,17 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + [[package]] name = "heck" version = "0.5.0" @@ -1631,6 +1693,15 @@ dependencies = [ "syn", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.11+spec-1.1.0", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1824,6 +1895,15 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "relative-path" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bca40a312222d8ba74837cb474edef44b37f561da5f773981007a10bbaa992b0" +dependencies = [ + "serde", +] + [[package]] name = "reqwest" version = "0.12.28" @@ -1953,6 +2033,54 @@ dependencies = [ "syn", ] +[[package]] +name = "rquickjs" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e04e4eedfb060b503b5f0a2644abb890b0b3620d3fb674f9455f230014964e4" +dependencies = [ + "rquickjs-core", + "rquickjs-macro", +] + +[[package]] +name = "rquickjs-core" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e4f499ac5b943d97ee6dbc44f23c2c10426f420f7d2f1793d6318911b6608c" +dependencies = [ + "async-lock", + "hashbrown 0.17.1", + "relative-path", + "rquickjs-sys", +] + +[[package]] +name = "rquickjs-macro" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbcc8219b70ee2faa08d5339f47f15741cba2ce0cb9640e8495f2ab51293f50" +dependencies = [ + "convert_case 0.11.0", + "fnv", + "ident_case", + "indexmap", + "proc-macro-crate", + "proc-macro2", + "quote", + "rquickjs-core", + "syn", +] + +[[package]] +name = "rquickjs-sys" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13ac243b86a74120814ef7e9e30ad5a2c1199b7b9963b1cf7c84e4cdc1cad99" +dependencies = [ + "cc", +] + [[package]] name = "rustc-hash" version = "2.1.1" @@ -2441,6 +2569,7 @@ dependencies = [ "indoc", "regex", "reqwest 0.12.28", + "rquickjs", "sacp", "semver", "serde", diff --git a/Cargo.toml b/Cargo.toml index 61091afd..84647a14 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,6 +53,7 @@ dialoguer = "0.12.0" toml_edit = "0.25.11" url = "2.5.8" symposium-install = { version = "0.1.0", path = "symposium-install", features = ["clap"] } +rquickjs = { version = "0.12.2", features = ["futures", "macro"] } [dev-dependencies] diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index 0afd92df..2d903946 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -7,6 +7,7 @@ //! See the [MCP meta-server RFD](../../md/rfds/mcp-meta-server/README.md). pub mod declarations; +pub mod sandbox; pub mod schema_to_ts; #[cfg(test)] diff --git a/src/mcp/sandbox.rs b/src/mcp/sandbox.rs new file mode 100644 index 00000000..2a92a63a --- /dev/null +++ b/src/mcp/sandbox.rs @@ -0,0 +1,410 @@ +//! The JavaScript sandbox scripts run in. +//! +//! A script is model-written and untrusted in the sense that matters here: it +//! may loop forever, allocate without bound, or recurse until the stack gives +//! out. None of those may take the agent's session with them. +//! +//! Two properties do the work: +//! +//! * **Deny by construction.** A bare QuickJS context has no filesystem, +//! network, process or module loader — not because they are switched off, +//! but because nothing registers them. There is no allowlist to get wrong. +//! * **Two layers of deadline.** An interrupt handler stops a script spinning +//! in the interpreter; an outer timeout catches one blocked awaiting a host +//! call, where the interpreter is not running and the interrupt cannot fire. +//! Neither alone is sufficient. +//! +//! The interrupt raises an exception the script cannot catch, so +//! `try { while(true){} } catch(e) {}` still terminates. + +use std::time::{Duration, Instant}; + +use rquickjs::{AsyncContext, AsyncRuntime, CatchResultExt, Ctx}; +use serde::Serialize; +use serde_json::Value; + +/// Extra room beyond the JavaScript stack limit for the interpreter's own +/// frames. Without it a script that hits the JS limit would instead overflow +/// the OS thread stack, which is a crash rather than an exception. +const STACK_HEADROOM: usize = 1 << 20; + +/// How long the outer deadline waits past the interrupt deadline. +/// +/// The interrupt produces a precise error, so give it a moment to win before +/// the blunt outer timeout fires. +const OUTER_GRACE: Duration = Duration::from_millis(250); + +/// Bounds on one script execution. +#[derive(Debug, Clone, Copy)] +pub struct Limits { + pub timeout: Duration, + pub memory_bytes: usize, + pub stack_bytes: usize, +} + +impl Default for Limits { + fn default() -> Self { + Self { + timeout: Duration::from_secs(120), + memory_bytes: 64 << 20, + stack_bytes: 1 << 20, + } + } +} + +/// Why a script produced no value. +/// +/// Serializes to a tagged object so the model can tell a limit it exceeded +/// from a mistake in its own code, and retry accordingly. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "error", rename_all = "snake_case")] +pub enum SandboxError { + ScriptTimeout { limit_secs: u64 }, + MemoryExhausted { limit_mb: u64 }, + ScriptError { message: String }, + Internal { message: String }, +} + +impl std::fmt::Display for SandboxError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::ScriptTimeout { limit_secs } => { + write!(f, "script exceeded its {limit_secs}s deadline") + } + Self::MemoryExhausted { limit_mb } => { + write!(f, "script exceeded its {limit_mb}MB memory limit") + } + Self::ScriptError { message } | Self::Internal { message } => write!(f, "{message}"), + } + } +} + +impl std::error::Error for SandboxError {} + +/// Runs one script under [`Limits`]. +#[derive(Debug, Clone, Copy, Default)] +pub struct Sandbox { + limits: Limits, +} + +impl Sandbox { + pub fn new(limits: Limits) -> Self { + Self { limits } + } + + /// Evaluate `script` and return its value as JSON. + /// + /// The engine runs on its own thread: QuickJS is not `Send`, and the + /// thread lets the stack be sized against the configured limit. A fresh + /// runtime per call means no state survives from one script to the next. + pub async fn eval(&self, script: &str) -> Result { + let limits = self.limits; + let script = script.to_string(); + let (tx, rx) = tokio::sync::oneshot::channel(); + + let spawned = std::thread::Builder::new() + .name("mcp-sandbox".to_string()) + .stack_size(limits.stack_bytes + STACK_HEADROOM) + .spawn(move || { + let outcome = match tokio::runtime::Builder::new_current_thread() + .enable_time() + .build() + { + Ok(rt) => rt.block_on(run(&script, limits)), + Err(e) => Err(SandboxError::Internal { + message: format!("could not start sandbox runtime: {e}"), + }), + }; + // A closed receiver means the outer deadline already fired. + let _ = tx.send(outcome); + }); + + if let Err(e) = spawned { + return Err(SandboxError::Internal { + message: format!("could not start sandbox thread: {e}"), + }); + } + + // The outer layer. A script awaiting a host call that never resolves + // leaves the interpreter idle, so the interrupt handler never runs and + // only this can end it. + match tokio::time::timeout(limits.timeout + OUTER_GRACE, rx).await { + Ok(Ok(outcome)) => outcome, + Ok(Err(_)) => Err(SandboxError::Internal { + message: "sandbox thread ended without reporting".to_string(), + }), + Err(_) => Err(SandboxError::ScriptTimeout { + limit_secs: limits.timeout.as_secs(), + }), + } + } +} + +async fn run(script: &str, limits: Limits) -> Result { + let runtime = AsyncRuntime::new().map_err(internal)?; + runtime.set_memory_limit(limits.memory_bytes).await; + runtime.set_max_stack_size(limits.stack_bytes).await; + + // The inner layer. Fires while the interpreter is running, and raises an + // exception the script cannot catch. + let deadline = Instant::now() + limits.timeout; + runtime + .set_interrupt_handler(Some(Box::new(move || Instant::now() >= deadline))) + .await; + + let context = AsyncContext::full(&runtime).await.map_err(internal)?; + let outcome = AsyncContext::async_with(&context, async |ctx| evaluate(ctx, script).await).await; + + // Settle any promises the script left pending before deciding the result. + runtime.idle().await; + + let Err(message) = outcome else { + return outcome.map_err(|e| classify(e, deadline, limits, 0)); + }; + let allocated = runtime.memory_usage().await.malloc_size.max(0) as usize; + Err(classify(message, deadline, limits, allocated)) +} + +async fn evaluate<'js>(ctx: Ctx<'js>, script: &str) -> Result { + // Evaluated as a plain script rather than a module: scripts arrive + // normalized into a self-calling async function, so the value is already a + // promise and top-level `await` is never needed. + let value: rquickjs::Value<'js> = ctx + .eval(script.as_bytes().to_vec()) + .catch(&ctx) + .map_err(|e| e.to_string())?; + + // A script is normally an async function call, so the value is a promise. + let resolved = match value.as_promise() { + Some(promise) => promise + .clone() + .into_future::() + .await + .catch(&ctx) + .map_err(|e| e.to_string())?, + None => value, + }; + + to_json(&ctx, resolved) +} + +/// Convert a JavaScript value to JSON via the engine's own serializer, so +/// `toJSON` and nested structures behave as the script author expects. +fn to_json<'js>(ctx: &Ctx<'js>, value: rquickjs::Value<'js>) -> Result { + if value.is_undefined() { + return Ok(Value::Null); + } + let encoded = ctx + .json_stringify(value) + .catch(ctx) + .map_err(|e| e.to_string())?; + let Some(encoded) = encoded else { + // `JSON.stringify` yields nothing for values with no JSON form. + return Ok(Value::Null); + }; + let text = encoded.to_string().map_err(|e| e.to_string())?; + serde_json::from_str(&text).map_err(|e| format!("result was not valid JSON: {e}")) +} + +/// Decide which limit, if any, a failure represents. +/// +/// QuickJS surfaces both an interrupt and an allocation failure as ordinary +/// exceptions, so the cause has to be recovered from the surrounding state +/// rather than the message. An exhausted heap in particular throws a null +/// value, because there is no memory left to build an error object with. +fn classify(message: String, deadline: Instant, limits: Limits, allocated: usize) -> SandboxError { + if Instant::now() >= deadline { + return SandboxError::ScriptTimeout { + limit_secs: limits.timeout.as_secs(), + }; + } + // Still holding most of the budget when the exception surfaced. + if allocated * 10 >= limits.memory_bytes * 8 { + return SandboxError::MemoryExhausted { + limit_mb: (limits.memory_bytes / (1 << 20)) as u64, + }; + } + SandboxError::ScriptError { message } +} + +fn internal(e: impl std::fmt::Display) -> SandboxError { + SandboxError::Internal { + message: e.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn fast() -> Limits { + Limits { + timeout: Duration::from_millis(300), + ..Limits::default() + } + } + + async fn eval(script: &str) -> Result { + Sandbox::new(fast()).eval(script).await + } + + #[tokio::test] + async fn evaluates_an_expression() { + assert_eq!(eval("1 + 1").await.unwrap(), json!(2)); + } + + #[tokio::test] + async fn returns_structured_values() { + let out = eval(r#"({ rows: [{ id: 1 }], ok: true })"#).await.unwrap(); + assert_eq!(out, json!({"rows": [{"id": 1}], "ok": true})); + } + + #[tokio::test] + async fn awaits_a_returned_promise() { + let out = eval("(async () => 42)()").await.unwrap(); + assert_eq!(out, json!(42)); + } + + #[tokio::test] + async fn undefined_becomes_null() { + assert_eq!(eval("undefined").await.unwrap(), Value::Null); + } + + // -- deadlines -- + + #[tokio::test] + async fn cpu_bound_loop_hits_the_deadline() { + let err = eval("while (true) {}").await.unwrap_err(); + assert_eq!(err, SandboxError::ScriptTimeout { limit_secs: 0 }); + } + + /// The interrupt raises an exception the script cannot catch. Were it + /// catchable, a model could sit inside `try`/`catch` and never yield. + #[tokio::test] + async fn script_cannot_catch_the_deadline() { + let err = + eval(r#"(() => { try { while (true) {} } catch (e) { return "swallowed"; } })()"#) + .await + .unwrap_err(); + assert!( + matches!(err, SandboxError::ScriptTimeout { .. }), + "deadline must not be catchable, got: {err:?}" + ); + } + + #[tokio::test] + async fn deadline_is_enforced_promptly() { + let started = Instant::now(); + let _ = eval("while (true) {}").await; + assert!( + started.elapsed() < Duration::from_secs(3), + "took {:?}", + started.elapsed() + ); + } + + // -- memory -- + + /// Guards against the limit silently becoming a no-op: rquickjs documents + /// `set_memory_limit` as inert when a custom allocator is in use, which a + /// future feature change could enable without any other visible effect. + #[tokio::test] + async fn allocation_is_bounded() { + let sandbox = Sandbox::new(Limits { + memory_bytes: 1 << 20, + ..fast() + }); + let err = sandbox + .eval("const a = []; while (true) { a.push(new Array(10000)); }") + .await + .unwrap_err(); + assert!( + matches!( + err, + SandboxError::MemoryExhausted { .. } | SandboxError::ScriptTimeout { .. } + ), + "unbounded allocation must not succeed, got: {err:?}" + ); + } + + /// Deep recursion must raise a JavaScript exception rather than overflow + /// the thread's own stack, which would abort the process. + #[tokio::test] + async fn recursion_is_bounded() { + let err = eval("(function f() { return f(); })()").await.unwrap_err(); + assert!( + matches!(err, SandboxError::ScriptError { .. }), + "got: {err:?}" + ); + } + + // -- errors -- + + #[tokio::test] + async fn script_errors_carry_their_message() { + let err = eval(r#"throw new Error("boom")"#).await.unwrap_err(); + let SandboxError::ScriptError { message } = &err else { + panic!("expected a script error, got: {err:?}"); + }; + assert!(message.contains("boom"), "got: {message}"); + } + + #[tokio::test] + async fn syntax_errors_are_reported() { + let err = eval("this is not javascript").await.unwrap_err(); + assert!( + matches!(err, SandboxError::ScriptError { .. }), + "got: {err:?}" + ); + } + + /// The model is told what it hit, in a form it can act on. + #[tokio::test] + async fn errors_serialize_as_tagged_objects() { + let err = SandboxError::ScriptTimeout { limit_secs: 120 }; + assert_eq!( + serde_json::to_value(&err).unwrap(), + json!({"error": "script_timeout", "limit_secs": 120}) + ); + } + + // -- ambient capabilities -- + + /// Nothing registers these; the assertion guards against a dependency + /// bump quietly introducing one. + #[tokio::test] + async fn host_capabilities_are_absent() { + for global in [ + "fetch", + "require", + "process", + "XMLHttpRequest", + "WebSocket", + "globalThis.os", + "globalThis.std", + ] { + let out = eval(&format!("typeof {global}")).await.unwrap(); + assert_eq!(out, json!("undefined"), "{global} should not exist"); + } + } + + #[tokio::test] + async fn modules_cannot_be_imported() { + let err = eval(r#"import("fs")"#).await.unwrap_err(); + assert!( + matches!(err, SandboxError::ScriptError { .. }), + "got: {err:?}" + ); + } + + /// Each call gets a fresh runtime, so nothing a script leaves behind can + /// influence the next one. + #[tokio::test] + async fn state_does_not_leak_between_scripts() { + let sandbox = Sandbox::new(fast()); + sandbox.eval("globalThis.leaked = 1").await.unwrap(); + let out = sandbox.eval("typeof globalThis.leaked").await.unwrap(); + assert_eq!(out, json!("undefined")); + } +} From 7a6b819111348f50bbaaa653676373d2c8649e66 Mon Sep 17 00:00:00 2001 From: fluzko Date: Wed, 29 Jul 2026 14:31:56 -0300 Subject: [PATCH 10/39] feat(mcp): normalize model-written scripts --- src/mcp/mod.rs | 1 + src/mcp/normalize.rs | 267 +++++++++++++++++++++++++++++++++++++++++++ src/mcp/sandbox.rs | 32 +++++- 3 files changed, 296 insertions(+), 4 deletions(-) create mode 100644 src/mcp/normalize.rs diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index 2d903946..c0da83d9 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -7,6 +7,7 @@ //! See the [MCP meta-server RFD](../../md/rfds/mcp-meta-server/README.md). pub mod declarations; +pub mod normalize; pub mod sandbox; pub mod schema_to_ts; diff --git a/src/mcp/normalize.rs b/src/mcp/normalize.rs new file mode 100644 index 00000000..01947a76 --- /dev/null +++ b/src/mcp/normalize.rs @@ -0,0 +1,267 @@ +//! Turning model-written source into something the engine can evaluate. +//! +//! A model handed TypeScript declarations tends to reply with TypeScript, or +//! with a fenced code block, or with a bare statement body that has no value. +//! QuickJS accepts none of those. Without this step the feature fails on the +//! first script anyone writes. +//! +//! Two forms have to work and cannot be told apart without parsing: +//! +//! ```text +//! await sqlx.query({ sql: "..." }) // an expression, whose value is the result +//! const r = await sqlx.query(...); // a statement body, whose value is its `return` +//! return r.rows; +//! ``` +//! +//! Rather than parse JavaScript in Rust, the generated program tries the +//! expression form and falls back to the statement form, using `new Function` +//! to compile each without running it. The engine's own parser decides. + +/// Global the source is handed over on. +/// +/// The program reads it once and deletes it, so a script never sees it. +pub const SOURCE_GLOBAL: &str = "__symposiumSource"; + +/// The program that runs a model-written script. +/// +/// Deliberately a constant with no interpolation. The source arrives as a +/// JavaScript value set by the host, not as text formatted into this string, +/// so there is no position in which a caller-supplied value could become +/// code. Keeping it `&'static str` means adding one later cannot be done +/// quietly — the type has to change first. +pub const PROGRAM: &str = r#"(async () => { + const __src = globalThis.__symposiumSource; + // Out of reach before any model-written code runs. + delete globalThis.__symposiumSource; + + let __run; + try { + // Concise body: the value is the expression itself, and `await` is in + // scope because the wrapper is async. + __run = new Function("return (async () => (" + __src + "\n))();"); + } catch (e1) { + try { + // Block body: the value is whatever the source returns. + __run = new Function("return (async () => {" + __src + "\n})();"); + } catch (e2) { + throw new SyntaxError( + "script did not parse as JavaScript (" + e2.message + "). " + + "Write plain JavaScript: no type annotations, interfaces, or generics." + ); + } + } + + // Settle first: the expression form may evaluate to a function the model + // meant to be called, as in `async () => { ... }`, and that is only + // visible once the wrapper's own promise resolves. + const __value = await __run(); + return typeof __value === "function" ? await __value() : __value; +})()"#; + +/// Remove the packaging a model puts around code: markdown fences and a +/// module export. +pub fn prepare(source: &str) -> String { + let mut text = strip_fence(source.trim()); + for prefix in ["export default ", "export default\n"] { + if let Some(rest) = text.strip_prefix(prefix) { + text = rest.trim().to_string(); + break; + } + } + // A module export is often written as a statement, leaving a stray + // terminator once the keyword is gone. + text.trim().trim_end_matches(';').trim().to_string() +} + +fn strip_fence(text: &str) -> String { + let Some(rest) = text.strip_prefix("```") else { + return text.to_string(); + }; + // The opening fence may carry a language tag, which runs to end of line. + let body = match rest.split_once('\n') { + Some((_lang, body)) => body, + // A fence with no newline has no code in it. + None => return String::new(), + }; + body.trim_end() + .strip_suffix("```") + .unwrap_or(body) + .trim() + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mcp::sandbox::{Limits, Sandbox}; + use serde_json::{Value, json}; + use std::time::Duration; + + /// Normalization is only meaningful through the engine, so the tests run + /// what they produce. + async fn run(source: &str) -> Result { + let sandbox = Sandbox::new(Limits { + timeout: Duration::from_secs(2), + ..Limits::default() + }); + sandbox.run_script(source).await.map_err(|e| e.to_string()) + } + + // -- the two forms -- + + #[tokio::test] + async fn evaluates_a_bare_expression() { + assert_eq!(run("1 + 1").await.unwrap(), json!(2)); + } + + #[tokio::test] + async fn evaluates_a_statement_body() { + let out = run("const a = 20; const b = 22; return a + b;") + .await + .unwrap(); + assert_eq!(out, json!(42)); + } + + /// A model told to write an async arrow writes one; it has to be called + /// rather than returned as a function. + #[tokio::test] + async fn calls_an_async_arrow() { + assert_eq!(run("async () => 42").await.unwrap(), json!(42)); + assert_eq!( + run("async () => { return { ok: true }; }").await.unwrap(), + json!({"ok": true}) + ); + } + + #[tokio::test] + async fn awaits_inside_either_form() { + assert_eq!( + run("await Promise.resolve(7)").await.unwrap(), + json!(7), + "expression form" + ); + assert_eq!( + run("const v = await Promise.resolve(7); return v * 2;") + .await + .unwrap(), + json!(14), + "statement form" + ); + } + + /// An object literal at the start of a statement body is a block, not a + /// value, so the expression form must be tried first. + #[tokio::test] + async fn object_literal_is_not_read_as_a_block() { + assert_eq!(run("({ a: 1 })").await.unwrap(), json!({"a": 1})); + } + + // -- packaging -- + + #[tokio::test] + async fn strips_fenced_code_blocks() { + for fenced in [ + "```\n1 + 1\n```", + "```js\n1 + 1\n```", + "```javascript\n1 + 1\n```", + "```typescript\n1 + 1\n```", + ] { + assert_eq!(run(fenced).await.unwrap(), json!(2), "failed on: {fenced}"); + } + } + + #[tokio::test] + async fn strips_export_default() { + assert_eq!(run("export default async () => 5").await.unwrap(), json!(5)); + } + + #[tokio::test] + async fn strips_a_fence_around_an_export() { + let out = run("```js\nexport default async () => {\n return 9;\n}\n```") + .await + .unwrap(); + assert_eq!(out, json!(9)); + } + + // -- failure -- + + /// The predicted failure: the model is shown TypeScript declarations and + /// replies in TypeScript, which QuickJS cannot parse. The message has to + /// say so, or the model has nothing to correct. + #[tokio::test] + async fn typescript_syntax_is_reported_clearly() { + let err = run("async (name: string) => name.length") + .await + .unwrap_err(); + assert!( + err.contains("no type annotations"), + "error should name the likely cause, got: {err}" + ); + } + + #[tokio::test] + async fn runtime_errors_propagate() { + let err = run(r#"throw new Error("boom")"#).await.unwrap_err(); + assert!(err.contains("boom"), "got: {err}"); + } + + /// An empty script is not an error; it simply has no value. + #[tokio::test] + async fn empty_source_yields_null() { + assert_eq!(run("").await.unwrap(), Value::Null); + assert_eq!(run("```js\n```").await.unwrap(), Value::Null); + } + + // -- embedding safety -- + + /// The wrapper's own bindings live in a closure. A `new Function` body + /// sees globals only, never the enclosing lexical scope, so a script + /// cannot read or overwrite them. + #[tokio::test] + async fn wrapper_locals_are_not_visible_to_the_script() { + for probe in ["typeof __src", "typeof __run", "typeof __value"] { + assert_eq!(run(probe).await.unwrap(), json!("undefined"), "{probe}"); + } + } + + /// These are legal raw in a JSON string but were illegal raw in a + /// JavaScript string literal until ES2019, so the embedding depends on + /// the engine accepting them. + #[tokio::test] + async fn line_separators_survive_embedding() { + let out = run("return \"a\u{2028}b\u{2029}c\";").await.unwrap(); + assert_eq!(out, json!("a\u{2028}b\u{2029}c")); + } + + /// A script can garble the wrapper's structure — it is concatenated into + /// a source string and compiled, which is the whole point. What matters + /// is that doing so crosses no boundary: the escaped code runs in the + /// same sandbox, with the same absence of host capabilities. + #[tokio::test] + async fn escaping_the_wrapper_reaches_no_capabilities() { + let out = run(r#"1)); globalThis.reached = typeof fetch; ((async () => (2"#).await; + match out { + Err(e) => assert!(e.contains("did not parse"), "got: {e}"), + Ok(_) => { + let probe = run("typeof fetch").await.unwrap(); + assert_eq!(probe, json!("undefined")); + } + } + } + + // -- escaping -- + + /// The source is embedded in the generated program as a string literal, + /// so quotes, backslashes and newlines in the model's code must survive. + #[tokio::test] + async fn source_containing_quotes_survives_embedding() { + let out = run(r#"return "he said \"hi\"\n";"#).await.unwrap(); + assert_eq!(out, json!("he said \"hi\"\n")); + } + + #[tokio::test] + async fn source_containing_a_template_literal_survives() { + let out = run("const n = 2; return `n is ${n}`;").await.unwrap(); + assert_eq!(out, json!("n is 2")); + } +} diff --git a/src/mcp/sandbox.rs b/src/mcp/sandbox.rs index 2a92a63a..aba83fa7 100644 --- a/src/mcp/sandbox.rs +++ b/src/mcp/sandbox.rs @@ -23,6 +23,8 @@ use rquickjs::{AsyncContext, AsyncRuntime, CatchResultExt, Ctx}; use serde::Serialize; use serde_json::Value; +use super::normalize; + /// Extra room beyond the JavaScript stack limit for the interpreter's own /// frames. Without it a script that hits the JS limit would instead overflow /// the OS thread stack, which is a crash rather than an exception. @@ -98,8 +100,22 @@ impl Sandbox { /// thread lets the stack be sized against the configured limit. A fresh /// runtime per call means no state survives from one script to the next. pub async fn eval(&self, script: &str) -> Result { + self.eval_inner(script, None).await + } + + /// Run a model-written script. + /// + /// The source is handed to the engine as a value rather than spliced into + /// program text. + pub async fn run_script(&self, source: &str) -> Result { + self.eval_inner(normalize::PROGRAM, Some(&normalize::prepare(source))) + .await + } + + async fn eval_inner(&self, script: &str, source: Option<&str>) -> Result { let limits = self.limits; let script = script.to_string(); + let source = source.map(str::to_string); let (tx, rx) = tokio::sync::oneshot::channel(); let spawned = std::thread::Builder::new() @@ -110,7 +126,7 @@ impl Sandbox { .enable_time() .build() { - Ok(rt) => rt.block_on(run(&script, limits)), + Ok(rt) => rt.block_on(run(&script, source.as_deref(), limits)), Err(e) => Err(SandboxError::Internal { message: format!("could not start sandbox runtime: {e}"), }), @@ -140,7 +156,7 @@ impl Sandbox { } } -async fn run(script: &str, limits: Limits) -> Result { +async fn run(script: &str, source: Option<&str>, limits: Limits) -> Result { let runtime = AsyncRuntime::new().map_err(internal)?; runtime.set_memory_limit(limits.memory_bytes).await; runtime.set_max_stack_size(limits.stack_bytes).await; @@ -153,7 +169,8 @@ async fn run(script: &str, limits: Limits) -> Result { .await; let context = AsyncContext::full(&runtime).await.map_err(internal)?; - let outcome = AsyncContext::async_with(&context, async |ctx| evaluate(ctx, script).await).await; + let outcome = + AsyncContext::async_with(&context, async |ctx| evaluate(ctx, script, source).await).await; // Settle any promises the script left pending before deciding the result. runtime.idle().await; @@ -165,7 +182,14 @@ async fn run(script: &str, limits: Limits) -> Result { Err(classify(message, deadline, limits, allocated)) } -async fn evaluate<'js>(ctx: Ctx<'js>, script: &str) -> Result { +async fn evaluate<'js>(ctx: Ctx<'js>, script: &str, source: Option<&str>) -> Result { + if let Some(source) = source { + ctx.globals() + .set(normalize::SOURCE_GLOBAL, source) + .catch(&ctx) + .map_err(|e| e.to_string())?; + } + // Evaluated as a plain script rather than a module: scripts arrive // normalized into a self-calling async function, so the value is already a // promise and top-level `await` is never needed. From 4a4cc19edbfda5ab1fc1ed785af1c798933e796d Mon Sep 17 00:00:00 2001 From: fluzko Date: Wed, 29 Jul 2026 14:39:24 -0300 Subject: [PATCH 11/39] feat(mcp): dispatch tool calls from sandbox --- src/mcp/dispatch.rs | 371 ++++++++++++++++++++++++++++++++++++++++++++ src/mcp/mod.rs | 1 + src/mcp/sandbox.rs | 64 ++++++-- 3 files changed, 427 insertions(+), 9 deletions(-) create mode 100644 src/mcp/dispatch.rs diff --git a/src/mcp/dispatch.rs b/src/mcp/dispatch.rs new file mode 100644 index 00000000..a3e0ce5a --- /dev/null +++ b/src/mcp/dispatch.rs @@ -0,0 +1,371 @@ +//! Reaching backing servers from inside the sandbox. +//! +//! A script calls `await sqlx.query({...})`. That name is an object installed +//! by the host, whose methods are Rust closures. Each closure hands the call +//! to whoever is driving the sandbox and waits for the answer. +//! +//! The call crosses a runtime boundary. The engine runs on its own thread +//! with its own current-thread runtime, while backing servers are child +//! processes owned by the main runtime, and their I/O can only be polled +//! there. A channel is what keeps each on the runtime it belongs to. +//! +//! Namespaces are built through the object API rather than by generating +//! JavaScript, so server and tool names — which come from plugin manifests — +//! never reach a code position. + +use rquickjs::function::{Async, Opt}; +use rquickjs::{CatchResultExt, Ctx, Function, Object}; +use serde_json::Value; +use tokio::sync::{mpsc, oneshot}; + +/// A tool call made by a script, waiting on its result. +#[derive(Debug)] +pub struct ToolCall { + /// Server name as declared by the plugin, not the sanitized spelling. + pub server: String, + /// Tool name as it goes on the wire. + pub tool: String, + /// The single argument the script passed, or null. + pub args: Value, + pub reply: oneshot::Sender>, +} + +/// Channel a sandbox sends its tool calls to. +pub type CallSender = mpsc::UnboundedSender; +/// The receiving half, serviced by whoever drives the sandbox. +pub type CallReceiver = mpsc::UnboundedReceiver; + +pub fn channel() -> (CallSender, CallReceiver) { + mpsc::unbounded_channel() +} + +/// One tool reachable on a namespace. +#[derive(Debug, Clone)] +pub struct Binding { + /// Property name in JavaScript. A tool whose wire name is not an + /// identifier is bound twice, under the quoted name and a sanitized one. + pub key: String, + /// Name to send to the backing server. + pub wire_name: String, +} + +/// One backing server as the script sees it. +#[derive(Debug, Clone)] +pub struct Namespace { + /// Global the object is installed on. + pub key: String, + /// Server name to send with each call. + pub server: String, + pub bindings: Vec, +} + +/// Install one global object per namespace. +pub fn install<'js>( + ctx: &Ctx<'js>, + namespaces: &[Namespace], + calls: &CallSender, +) -> Result<(), String> { + for namespace in namespaces { + let object = Object::new(ctx.clone()) + .catch(ctx) + .map_err(|e| e.to_string())?; + + for binding in &namespace.bindings { + let function = tool_function(ctx, &namespace.server, &binding.wire_name, calls)?; + object + .set(binding.key.as_str(), function) + .catch(ctx) + .map_err(|e| e.to_string())?; + } + + ctx.globals() + .set(namespace.key.as_str(), object) + .catch(ctx) + .map_err(|e| e.to_string())?; + } + Ok(()) +} + +fn tool_function<'js>( + ctx: &Ctx<'js>, + server: &str, + tool: &str, + calls: &CallSender, +) -> Result, String> { + let server = server.to_string(); + let tool = tool.to_string(); + let calls = calls.clone(); + + Function::new( + ctx.clone(), + Async(move |ctx: Ctx<'js>, args: Opt>| { + let server = server.clone(); + let tool = tool.clone(); + let calls = calls.clone(); + async move { + let args = match args.0 { + Some(value) => to_json(&ctx, value)?, + None => Value::Null, + }; + + let (reply, answer) = oneshot::channel(); + calls + .send(ToolCall { + server: server.clone(), + tool: tool.clone(), + args, + reply, + }) + .map_err(|_| throw(&ctx, "tool dispatch is no longer available"))?; + + // A dropped sender means the caller abandoned the script; + // surfacing it as an exception lets the script's own error + // handling run. + let result = answer + .await + .map_err(|_| throw(&ctx, &format!("{server}.{tool} did not answer")))?; + + // A failing tool throws, so a script uses ordinary try/catch + // rather than inspecting a result shape. + let value = result.map_err(|message| throw(&ctx, &message))?; + from_json(&ctx, &value) + } + }), + ) + .catch(ctx) + .map_err(|e| e.to_string()) +} + +fn throw(ctx: &Ctx<'_>, message: &str) -> rquickjs::Error { + rquickjs::Exception::throw_message(ctx, message) +} + +/// Convert a JavaScript value to JSON through the engine's own serializer. +fn to_json<'js>(ctx: &Ctx<'js>, value: rquickjs::Value<'js>) -> rquickjs::Result { + if value.is_undefined() || value.is_null() { + return Ok(Value::Null); + } + let Some(encoded) = ctx.json_stringify(value)? else { + return Ok(Value::Null); + }; + let text = encoded.to_string()?; + serde_json::from_str(&text).map_err(|_| rquickjs::Error::Unknown) +} + +/// Convert JSON back into a JavaScript value. +fn from_json<'js>(ctx: &Ctx<'js>, value: &Value) -> rquickjs::Result> { + ctx.json_parse(value.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mcp::sandbox::{Limits, Sandbox}; + use serde_json::json; + use std::sync::{Arc, Mutex}; + use std::time::Duration; + + fn namespace(server: &str, tools: &[&str]) -> Namespace { + Namespace { + key: server.to_string(), + server: server.to_string(), + bindings: tools + .iter() + .map(|t| Binding { + key: t.to_string(), + wire_name: t.to_string(), + }) + .collect(), + } + } + + /// Drive a script, answering every tool call with `responder`, and record + /// what was asked. + async fn run_with( + source: &str, + namespaces: Vec, + responder: impl Fn(&ToolCall) -> Result + Send + 'static, + ) -> (Result, Vec<(String, String, Value)>) { + let (calls, mut receiver) = channel(); + let seen = Arc::new(Mutex::new(Vec::new())); + + let recorded = Arc::clone(&seen); + let pump = tokio::spawn(async move { + while let Some(call) = receiver.recv().await { + recorded.lock().unwrap().push(( + call.server.clone(), + call.tool.clone(), + call.args.clone(), + )); + let answer = responder(&call); + let _ = call.reply.send(answer); + } + }); + + let sandbox = Sandbox::new(Limits { + timeout: Duration::from_secs(5), + ..Limits::default() + }); + let outcome = sandbox + .run_script_with(source, &namespaces, calls) + .await + .map_err(|e| e.to_string()); + + pump.await.unwrap(); + let asked = seen.lock().unwrap().clone(); + (outcome, asked) + } + + #[tokio::test] + async fn script_calls_a_tool_and_receives_its_result() { + let (out, asked) = run_with( + r#"await sqlx.query({ sql: "SELECT 1" })"#, + vec![namespace("sqlx", &["query"])], + |_| Ok(json!({"rows": [{"n": 1}]})), + ) + .await; + + assert_eq!(out.unwrap(), json!({"rows": [{"n": 1}]})); + assert_eq!( + asked, + vec![( + "sqlx".to_string(), + "query".to_string(), + json!({"sql": "SELECT 1"}) + )] + ); + } + + /// The point of running code rather than proxying one call at a time: + /// several calls, and the filtering between them, happen without the + /// intermediate results ever leaving the sandbox. + #[tokio::test] + async fn script_composes_several_calls() { + let (out, asked) = run_with( + r#" + const all = await sqlx.query({ sql: "SELECT" }); + const keep = all.rows.filter(r => r.n > 1); + const out = []; + for (const row of keep) { + out.push(await sqlx.explain({ n: row.n })); + } + return out; + "#, + vec![namespace("sqlx", &["query", "explain"])], + |call| match call.tool.as_str() { + "query" => Ok(json!({"rows": [{"n": 1}, {"n": 2}, {"n": 3}]})), + _ => Ok(json!({"plan": call.args["n"]})), + }, + ) + .await; + + assert_eq!(out.unwrap(), json!([{"plan": 2}, {"plan": 3}])); + assert_eq!(asked.len(), 3, "one query and two explains"); + } + + #[tokio::test] + async fn tool_failure_throws_into_the_script() { + let (out, _) = run_with( + r#" + try { + await sqlx.query({}); + return "no throw"; + } catch (e) { + return "caught: " + e.message; + } + "#, + vec![namespace("sqlx", &["query"])], + |_| Err("table not found".to_string()), + ) + .await; + + assert_eq!(out.unwrap(), json!("caught: table not found")); + } + + /// An uncaught tool failure ends the script rather than resolving to a + /// value the model might mistake for success. + #[tokio::test] + async fn uncaught_tool_failure_fails_the_script() { + let (out, _) = run_with( + r#"await sqlx.query({})"#, + vec![namespace("sqlx", &["query"])], + |_| Err("boom".to_string()), + ) + .await; + + let err = out.unwrap_err(); + assert!(err.contains("boom"), "got: {err}"); + } + + #[tokio::test] + async fn calling_without_arguments_sends_null() { + let (out, asked) = run_with( + "await clock.now()", + vec![namespace("clock", &["now"])], + |_| Ok(json!(123)), + ) + .await; + + assert_eq!(out.unwrap(), json!(123)); + assert_eq!(asked[0].2, Value::Null); + } + + /// A tool whose wire name is not a JavaScript identifier is reachable + /// under both spellings, and both dispatch to the same wire name. + #[tokio::test] + async fn both_spellings_dispatch_to_the_wire_name() { + let ns = Namespace { + key: "sqlx".to_string(), + server: "sqlx".to_string(), + bindings: vec![ + Binding { + key: "migrate-status".to_string(), + wire_name: "migrate-status".to_string(), + }, + Binding { + key: "migrate_status".to_string(), + wire_name: "migrate-status".to_string(), + }, + ], + }; + let (out, asked) = run_with( + r#" + const a = await sqlx["migrate-status"](); + const b = await sqlx.migrate_status(); + return [a, b]; + "#, + vec![ns], + |_| Ok(json!("ok")), + ) + .await; + + assert_eq!(out.unwrap(), json!(["ok", "ok"])); + assert!( + asked.iter().all(|(_, tool, _)| tool == "migrate-status"), + "both spellings must send the wire name, got: {asked:?}" + ); + } + + #[tokio::test] + async fn several_servers_are_separate_namespaces() { + let (out, asked) = run_with( + "return [await a.ping(), await b.ping()];", + vec![namespace("a", &["ping"]), namespace("b", &["ping"])], + |call| Ok(json!(call.server)), + ) + .await; + + assert_eq!(out.unwrap(), json!(["a", "b"])); + assert_eq!(asked.len(), 2); + } + + /// Nothing beyond the declared namespaces appears. + #[tokio::test] + async fn undeclared_servers_are_absent() { + let (out, _) = run_with("typeof other", vec![namespace("sqlx", &["query"])], |_| { + Ok(Value::Null) + }) + .await; + assert_eq!(out.unwrap(), json!("undefined")); + } +} diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index c0da83d9..1a22a9d4 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -7,6 +7,7 @@ //! See the [MCP meta-server RFD](../../md/rfds/mcp-meta-server/README.md). pub mod declarations; +pub mod dispatch; pub mod normalize; pub mod sandbox; pub mod schema_to_ts; diff --git a/src/mcp/sandbox.rs b/src/mcp/sandbox.rs index aba83fa7..11712530 100644 --- a/src/mcp/sandbox.rs +++ b/src/mcp/sandbox.rs @@ -23,7 +23,7 @@ use rquickjs::{AsyncContext, AsyncRuntime, CatchResultExt, Ctx}; use serde::Serialize; use serde_json::Value; -use super::normalize; +use super::{dispatch, normalize}; /// Extra room beyond the JavaScript stack limit for the interpreter's own /// frames. Without it a script that hits the JS limit would instead overflow @@ -100,7 +100,7 @@ impl Sandbox { /// thread lets the stack be sized against the configured limit. A fresh /// runtime per call means no state survives from one script to the next. pub async fn eval(&self, script: &str) -> Result { - self.eval_inner(script, None).await + self.eval_inner(script, None, Vec::new(), None).await } /// Run a model-written script. @@ -108,11 +108,33 @@ impl Sandbox { /// The source is handed to the engine as a value rather than spliced into /// program text. pub async fn run_script(&self, source: &str) -> Result { - self.eval_inner(normalize::PROGRAM, Some(&normalize::prepare(source))) + self.run_script_with(source, &[], dispatch::channel().0) .await } - async fn eval_inner(&self, script: &str, source: Option<&str>) -> Result { + /// Run a script with backing servers in scope. + pub async fn run_script_with( + &self, + source: &str, + namespaces: &[dispatch::Namespace], + calls: dispatch::CallSender, + ) -> Result { + self.eval_inner( + normalize::PROGRAM, + Some(&normalize::prepare(source)), + namespaces.to_vec(), + Some(calls), + ) + .await + } + + async fn eval_inner( + &self, + script: &str, + source: Option<&str>, + namespaces: Vec, + calls: Option, + ) -> Result { let limits = self.limits; let script = script.to_string(); let source = source.map(str::to_string); @@ -126,7 +148,13 @@ impl Sandbox { .enable_time() .build() { - Ok(rt) => rt.block_on(run(&script, source.as_deref(), limits)), + Ok(rt) => rt.block_on(run( + &script, + source.as_deref(), + &namespaces, + calls.as_ref(), + limits, + )), Err(e) => Err(SandboxError::Internal { message: format!("could not start sandbox runtime: {e}"), }), @@ -156,7 +184,13 @@ impl Sandbox { } } -async fn run(script: &str, source: Option<&str>, limits: Limits) -> Result { +async fn run( + script: &str, + source: Option<&str>, + namespaces: &[dispatch::Namespace], + calls: Option<&dispatch::CallSender>, + limits: Limits, +) -> Result { let runtime = AsyncRuntime::new().map_err(internal)?; runtime.set_memory_limit(limits.memory_bytes).await; runtime.set_max_stack_size(limits.stack_bytes).await; @@ -169,8 +203,10 @@ async fn run(script: &str, source: Option<&str>, limits: Limits) -> Result, limits: Limits) -> Result(ctx: Ctx<'js>, script: &str, source: Option<&str>) -> Result { +async fn evaluate<'js>( + ctx: Ctx<'js>, + script: &str, + source: Option<&str>, + namespaces: &[dispatch::Namespace], + calls: Option<&dispatch::CallSender>, +) -> Result { + if let Some(calls) = calls { + dispatch::install(&ctx, namespaces, calls)?; + } + if let Some(source) = source { ctx.globals() .set(normalize::SOURCE_GLOBAL, source) From 1c8dbeca86a514503d068351ee7555d4c6d69986 Mon Sep 17 00:00:00 2001 From: fluzko Date: Wed, 29 Jul 2026 14:55:14 -0300 Subject: [PATCH 12/39] feat(mcp): capture console output and bound results --- src/mcp/console.rs | 201 +++++++++++++++++++++++++++++++++++++++++++ src/mcp/dispatch.rs | 8 +- src/mcp/mod.rs | 1 + src/mcp/normalize.rs | 7 +- src/mcp/sandbox.rs | 157 +++++++++++++++++++++++++++++---- 5 files changed, 352 insertions(+), 22 deletions(-) create mode 100644 src/mcp/console.rs diff --git a/src/mcp/console.rs b/src/mcp/console.rs new file mode 100644 index 00000000..b62df4c1 --- /dev/null +++ b/src/mcp/console.rs @@ -0,0 +1,201 @@ +//! Capturing what a script logs. +//! +//! Scripts are written blind — the model cannot step through one — so +//! `console.log` is the only way it can see what happened on the way to a +//! result. The output is captured rather than printed: the process speaks +//! JSON-RPC on stdout, and anything written there would corrupt the stream. +//! +//! The buffer is bounded. A loop that logs on every iteration would otherwise +//! fill the agent's context with noise, which is the cost this whole design +//! exists to avoid. + +use std::cell::RefCell; +use std::rc::Rc; + +use rquickjs::function::Rest; +use rquickjs::{CatchResultExt, Ctx, Function, Object}; + +/// Console output from one script. +#[derive(Debug, Default)] +pub struct Log { + entries: Vec, + bytes: usize, + limit: usize, + dropped: bool, +} + +/// Shared with the host functions installed in the context. +/// +/// `Rc` over `Arc` since the engine and its closures live on one thread. +pub type Shared = Rc>; + +impl Log { + pub fn shared(limit: usize) -> Shared { + Rc::new(RefCell::new(Self { + limit, + ..Self::default() + })) + } + + fn push(&mut self, line: String) { + if self.bytes + line.len() > self.limit { + self.dropped = true; + return; + } + self.bytes += line.len(); + self.entries.push(line); + } + + pub fn entries(&self) -> &[String] { + &self.entries + } + + /// Whether anything was discarded for want of budget. + pub fn dropped(&self) -> bool { + self.dropped + } +} + +/// Install a `console` object backed by `log`. +pub fn install<'js>(ctx: &Ctx<'js>, log: &Shared) -> Result<(), String> { + let console = Object::new(ctx.clone()) + .catch(ctx) + .map_err(|e| e.to_string())?; + + // `debug` and `trace` are deliberately included: a model reaching for + // them should not hit an undefined method mid-script. + for (method, prefix) in [ + ("log", ""), + ("info", ""), + ("debug", ""), + ("trace", ""), + ("warn", "[warn] "), + ("error", "[error] "), + ] { + let log = Rc::clone(log); + let prefix = prefix.to_string(); + let function = Function::new( + ctx.clone(), + move |ctx: Ctx<'js>, args: Rest>| { + let line = format!("{prefix}{}", format_args_for_log(&ctx, &args.0)); + log.borrow_mut().push(line); + }, + ) + .catch(ctx) + .map_err(|e| e.to_string())?; + + console + .set(method, function) + .catch(ctx) + .map_err(|e| e.to_string())?; + } + + ctx.globals() + .set("console", console) + .catch(ctx) + .map_err(|e| e.to_string()) +} + +/// Render call arguments the way a developer console would: strings bare, +/// everything else as JSON. +fn format_args_for_log<'js>(ctx: &Ctx<'js>, args: &[rquickjs::Value<'js>]) -> String { + args.iter() + .map(|value| { + if let Some(text) = value.as_string() { + return text.to_string().unwrap_or_default(); + } + if value.is_undefined() { + return "undefined".to_string(); + } + match ctx.json_stringify(value.clone()) { + Ok(Some(encoded)) => encoded.to_string().unwrap_or_default(), + // A value with no JSON form, such as a function. + _ => "undefined".to_string(), + } + }) + .collect::>() + .join(" ") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mcp::sandbox::{Limits, Sandbox}; + use std::time::Duration; + + async fn logs_of(source: &str, limit: usize) -> (Vec, bool) { + let sandbox = Sandbox::new(Limits { + timeout: Duration::from_secs(5), + max_console_bytes: limit, + ..Limits::default() + }); + let outcome = sandbox.run_script(source).await.unwrap(); + (outcome.logs, outcome.logs_dropped) + } + + #[tokio::test] + async fn captures_log_output_in_order() { + let (logs, dropped) = + logs_of(r#"console.log("a"); console.log("b"); return 1;"#, 1024).await; + assert_eq!(logs, vec!["a".to_string(), "b".to_string()]); + assert!(!dropped); + } + + #[tokio::test] + async fn renders_values_as_json_and_strings_bare() { + let (logs, _) = logs_of( + r#"console.log("n =", 1, { a: [true, null] }); return 0;"#, + 1024, + ) + .await; + assert_eq!(logs, vec![r#"n = 1 {"a":[true,null]}"#.to_string()]); + } + + #[tokio::test] + async fn marks_severity_for_warn_and_error() { + let (logs, _) = logs_of(r#"console.warn("w"); console.error("e"); return 0;"#, 1024).await; + assert_eq!(logs, vec!["[warn] w".to_string(), "[error] e".to_string()]); + } + + /// A logging loop must not be able to fill the agent's context. + #[tokio::test] + async fn output_is_bounded() { + let (logs, dropped) = logs_of( + r#"for (let i = 0; i < 10000; i++) console.log("noise"); return "done";"#, + 64, + ) + .await; + assert!(dropped, "the budget should have been exhausted"); + assert!( + logs.join("").len() <= 64, + "kept {} bytes, over budget", + logs.join("").len() + ); + } + + /// Exhausting the budget bounds the output, not the script. + #[tokio::test] + async fn exhausted_budget_does_not_fail_the_script() { + let sandbox = Sandbox::new(Limits { + timeout: Duration::from_secs(5), + max_console_bytes: 8, + ..Limits::default() + }); + let outcome = sandbox + .run_script(r#"console.log("a much longer line than the budget"); return 42;"#) + .await + .unwrap(); + assert_eq!(outcome.json, "42"); + assert!(outcome.logs_dropped); + } + + #[tokio::test] + async fn console_methods_a_model_may_reach_for_all_exist() { + let (logs, _) = logs_of( + r#"["log","info","debug","trace","warn","error"].forEach(m => console[m]("x")); return 0;"#, + 1024, + ) + .await; + assert_eq!(logs.len(), 6, "got: {logs:?}"); + } +} diff --git a/src/mcp/dispatch.rs b/src/mcp/dispatch.rs index a3e0ce5a..c510c122 100644 --- a/src/mcp/dispatch.rs +++ b/src/mcp/dispatch.rs @@ -206,10 +206,10 @@ mod tests { timeout: Duration::from_secs(5), ..Limits::default() }); - let outcome = sandbox - .run_script_with(source, &namespaces, calls) - .await - .map_err(|e| e.to_string()); + let outcome = match sandbox.run_script_with(source, &namespaces, calls).await { + Ok(o) => o.value().map_err(|e| e.to_string()), + Err(e) => Err(e.to_string()), + }; pump.await.unwrap(); let asked = seen.lock().unwrap().clone(); diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index 1a22a9d4..6322cedd 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -6,6 +6,7 @@ //! //! See the [MCP meta-server RFD](../../md/rfds/mcp-meta-server/README.md). +pub mod console; pub mod declarations; pub mod dispatch; pub mod normalize; diff --git a/src/mcp/normalize.rs b/src/mcp/normalize.rs index 01947a76..61fc6161 100644 --- a/src/mcp/normalize.rs +++ b/src/mcp/normalize.rs @@ -104,7 +104,12 @@ mod tests { timeout: Duration::from_secs(2), ..Limits::default() }); - sandbox.run_script(source).await.map_err(|e| e.to_string()) + sandbox + .run_script(source) + .await + .map_err(|e| e.to_string())? + .value() + .map_err(|e| e.to_string()) } // -- the two forms -- diff --git a/src/mcp/sandbox.rs b/src/mcp/sandbox.rs index 11712530..2633428e 100644 --- a/src/mcp/sandbox.rs +++ b/src/mcp/sandbox.rs @@ -23,7 +23,7 @@ use rquickjs::{AsyncContext, AsyncRuntime, CatchResultExt, Ctx}; use serde::Serialize; use serde_json::Value; -use super::{dispatch, normalize}; +use super::{console, dispatch, normalize}; /// Extra room beyond the JavaScript stack limit for the interpreter's own /// frames. Without it a script that hits the JS limit would instead overflow @@ -42,6 +42,11 @@ pub struct Limits { pub timeout: Duration, pub memory_bytes: usize, pub stack_bytes: usize, + /// Ceiling on the serialized result. Exceeding it truncates rather than + /// fails: a script that already called tools should not lose its work. + pub max_result_bytes: usize, + /// Ceiling on captured console output. + pub max_console_bytes: usize, } impl Default for Limits { @@ -50,6 +55,8 @@ impl Default for Limits { timeout: Duration::from_secs(120), memory_bytes: 64 << 20, stack_bytes: 1 << 20, + max_result_bytes: 32 << 10, + max_console_bytes: 8 << 10, } } } @@ -83,6 +90,28 @@ impl std::fmt::Display for SandboxError { impl std::error::Error for SandboxError {} +/// What a script produced. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Outcome { + /// The result, serialized. A prefix of it when `truncated_from` is set. + pub json: String, + /// Byte length of the untruncated result, when it did not fit. + pub truncated_from: Option, + /// Console output, in the order it was written. + pub logs: Vec, + /// Whether logging stopped for want of budget. + pub logs_dropped: bool, +} + +impl Outcome { + /// Parse the result back, for callers that know it was not truncated. + pub fn value(&self) -> Result { + serde_json::from_str(&self.json).map_err(|e| SandboxError::Internal { + message: format!("result was not valid JSON: {e}"), + }) + } +} + /// Runs one script under [`Limits`]. #[derive(Debug, Clone, Copy, Default)] pub struct Sandbox { @@ -100,14 +129,16 @@ impl Sandbox { /// thread lets the stack be sized against the configured limit. A fresh /// runtime per call means no state survives from one script to the next. pub async fn eval(&self, script: &str) -> Result { - self.eval_inner(script, None, Vec::new(), None).await + self.eval_inner(script, None, Vec::new(), None) + .await? + .value() } /// Run a model-written script. /// /// The source is handed to the engine as a value rather than spliced into /// program text. - pub async fn run_script(&self, source: &str) -> Result { + pub async fn run_script(&self, source: &str) -> Result { self.run_script_with(source, &[], dispatch::channel().0) .await } @@ -118,7 +149,7 @@ impl Sandbox { source: &str, namespaces: &[dispatch::Namespace], calls: dispatch::CallSender, - ) -> Result { + ) -> Result { self.eval_inner( normalize::PROGRAM, Some(&normalize::prepare(source)), @@ -134,7 +165,7 @@ impl Sandbox { source: Option<&str>, namespaces: Vec, calls: Option, - ) -> Result { + ) -> Result { let limits = self.limits; let script = script.to_string(); let source = source.map(str::to_string); @@ -190,7 +221,7 @@ async fn run( namespaces: &[dispatch::Namespace], calls: Option<&dispatch::CallSender>, limits: Limits, -) -> Result { +) -> Result { let runtime = AsyncRuntime::new().map_err(internal)?; runtime.set_memory_limit(limits.memory_bytes).await; runtime.set_max_stack_size(limits.stack_bytes).await; @@ -202,20 +233,56 @@ async fn run( .set_interrupt_handler(Some(Box::new(move || Instant::now() >= deadline))) .await; + let log = console::Log::shared(limits.max_console_bytes); let context = AsyncContext::full(&runtime).await.map_err(internal)?; let outcome = AsyncContext::async_with(&context, async |ctx| { - evaluate(ctx, script, source, namespaces, calls).await + evaluate(ctx, script, source, namespaces, calls, &log).await }) .await; // Settle any promises the script left pending before deciding the result. runtime.idle().await; - let Err(message) = outcome else { - return outcome.map_err(|e| classify(e, deadline, limits, 0)); + let (logs, logs_dropped) = { + let log = log.borrow(); + (log.entries().to_vec(), log.dropped()) }; - let allocated = runtime.memory_usage().await.malloc_size.max(0) as usize; - Err(classify(message, deadline, limits, allocated)) + + let json = match outcome { + Ok(json) => json, + Err(message) => { + let allocated = runtime.memory_usage().await.malloc_size.max(0) as usize; + return Err(classify(message, deadline, limits, allocated)); + } + }; + + let (json, truncated_from) = truncate(json, limits.max_result_bytes); + Ok(Outcome { + json, + truncated_from, + logs, + logs_dropped, + }) +} + +/// Bound the serialized result. +/// +/// Truncating rather than rejecting keeps whatever the script achieved: it may +/// already have called tools with side effects, and an error would discard +/// that along with the data. The cut lands on a character boundary, so the +/// prefix is still valid text even though it is no longer valid JSON. +fn truncate(json: String, limit: usize) -> (String, Option) { + if json.len() <= limit { + return (json, None); + } + let original = json.len(); + let mut end = limit; + while end > 0 && !json.is_char_boundary(end) { + end -= 1; + } + let mut cut = json; + cut.truncate(end); + (cut, Some(original)) } async fn evaluate<'js>( @@ -224,7 +291,9 @@ async fn evaluate<'js>( source: Option<&str>, namespaces: &[dispatch::Namespace], calls: Option<&dispatch::CallSender>, -) -> Result { + log: &console::Shared, +) -> Result { + console::install(&ctx, log)?; if let Some(calls) = calls { dispatch::install(&ctx, namespaces, calls)?; } @@ -260,9 +329,9 @@ async fn evaluate<'js>( /// Convert a JavaScript value to JSON via the engine's own serializer, so /// `toJSON` and nested structures behave as the script author expects. -fn to_json<'js>(ctx: &Ctx<'js>, value: rquickjs::Value<'js>) -> Result { +fn to_json<'js>(ctx: &Ctx<'js>, value: rquickjs::Value<'js>) -> Result { if value.is_undefined() { - return Ok(Value::Null); + return Ok("null".to_string()); } let encoded = ctx .json_stringify(value) @@ -270,10 +339,9 @@ fn to_json<'js>(ctx: &Ctx<'js>, value: rquickjs::Value<'js>) -> Result Date: Wed, 29 Jul 2026 15:17:05 -0300 Subject: [PATCH 13/39] test(mcp): add configurable mock mcp server --- Cargo.lock | 149 ++++++++++++++++++++-- Cargo.toml | 1 + examples/mock-mcp-server.rs | 248 ++++++++++++++++++++++++++++++++++++ src/mcp/console.rs | 1 - src/mcp/normalize.rs | 1 - 5 files changed, 390 insertions(+), 10 deletions(-) create mode 100644 examples/mock-mcp-server.rs diff --git a/Cargo.lock b/Cargo.lock index 530c35a8..8af8073e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -204,6 +204,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b25655df2c3cdd83c5e5b293b88acd880332b2ddadd7c30ac43144fdc0033da9" + [[package]] name = "bitflags" version = "2.11.0" @@ -1101,7 +1107,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -1410,9 +1416,9 @@ checksum = "b3a6a8c165077efc8f3a971534c50ea6a1a18b329ef4a66e897a7e3a1494565f" [[package]] name = "libc" -version = "0.2.183" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libredox" @@ -1510,6 +1516,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1711,6 +1729,20 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "process-wrap" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e842efad9119158434d193c6682e2ebee4b44d6ad801d7b349623b3f57cdf55" +dependencies = [ + "futures", + "indexmap", + "nix", + "tokio", + "tracing", + "windows", +] + [[package]] name = "quinn" version = "0.11.9" @@ -1910,7 +1942,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-core", @@ -1950,7 +1982,7 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "encoding_rs", "futures-core", @@ -2005,19 +2037,44 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67d69668de0b0ccd9cc435f700f3b39a7861863cf37a15e1f304ea78688a4826" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", + "chrono", + "futures", + "pastey", + "pin-project-lite", + "rmcp-macros 1.5.0", + "schemars", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "rmcp" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcd2b6dd3b18129368955f32661a7718969e8c152c7d8866434c09cf15a512e0" +dependencies = [ + "async-trait", + "base64 0.23.0", "chrono", "futures", "pastey", "pin-project-lite", - "rmcp-macros", + "process-wrap", + "rmcp-macros 3.0.0", "schemars", "serde", "serde_json", "thiserror 2.0.18", "tokio", + "tokio-stream", "tokio-util", "tracing", + "uuid", ] [[package]] @@ -2033,6 +2090,19 @@ dependencies = [ "syn", ] +[[package]] +name = "rmcp-macros" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1aa4b9345795260a43fc23d6d05e096407c8b953903f673af7c4404b49fb2d6" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "serde_json", + "syn", +] + [[package]] name = "rquickjs" version = "0.12.2" @@ -2209,7 +2279,7 @@ dependencies = [ "futures", "futures-concurrency", "jsonrpcmsg", - "rmcp", + "rmcp 1.5.0", "rustc-hash", "sacp-derive", "schemars", @@ -2569,6 +2639,7 @@ dependencies = [ "indoc", "regex", "reqwest 0.12.28", + "rmcp 3.0.0", "rquickjs", "sacp", "semver", @@ -2850,6 +2921,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -3332,6 +3414,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -3345,6 +3448,17 @@ dependencies = [ "windows-strings", ] +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -3373,6 +3487,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + [[package]] name = "windows-registry" version = "0.6.1" @@ -3486,6 +3610,15 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" diff --git a/Cargo.toml b/Cargo.toml index 84647a14..d8338406 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,6 +60,7 @@ rquickjs = { version = "0.12.2", features = ["futures", "macro"] } assert_matches = "1.5" expect-test = "1.5.1" indoc = "2.0.7" +rmcp = { version = "3", features = ["server", "client", "transport-io", "transport-child-process", "macros"] } symposium-testlib = { path = "symposium-testlib" } [workspace] diff --git a/examples/mock-mcp-server.rs b/examples/mock-mcp-server.rs new file mode 100644 index 00000000..34e509cf --- /dev/null +++ b/examples/mock-mcp-server.rs @@ -0,0 +1,248 @@ +//! A configurable MCP server, for exercising the meta-server against +//! behavior real servers exhibit but cannot be asked to reproduce on demand. +//! +//! Driven entirely by a JSON config, so one binary plays every role a test +//! needs — a slow start, a hang, a crash partway through, a response too big +//! to pass on, an older protocol version: +//! +//! ```json +//! { +//! "name": "sqlx", +//! "startup_delay_ms": 0, +//! "fail_startup_times": 0, +//! "tools": [ +//! { "name": "query", "inputSchema": { "type": "object" }, +//! "behavior": { "kind": "echo" } }, +//! { "name": "slow", "behavior": { "kind": "hang" } }, +//! { "name": "boom", "behavior": { "kind": "crash_after", "calls": 1 } } +//! ] +//! } +//! ``` +//! +//! An `examples/` target rather than a `[[bin]]`: `cargo test` builds it, but +//! it is not installed alongside `cargo-agents`. +//! +//! `fail_startup_times` counts across runs via a sibling file, so a restart +//! policy can be tested without the harness having to rewrite the config. + +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use rmcp::handler::server::ServerHandler; +use rmcp::model::*; +use rmcp::service::RequestContext; +use rmcp::{ErrorData as McpError, RoleServer, ServiceExt}; +use serde::Deserialize; +use serde_json::{Map, Value, json}; + +#[derive(Debug, Deserialize)] +struct Config { + #[serde(default = "default_name")] + name: String, + /// Delay before serving, for exercising a startup timeout. + #[serde(default)] + startup_delay_ms: u64, + /// Exit during startup this many times before succeeding, for exercising + /// restart policy. + #[serde(default)] + fail_startup_times: usize, + /// Protocol version to report. Older versions predate structured output. + #[serde(default)] + protocol_version: Option, + #[serde(default)] + tools: Vec, +} + +fn default_name() -> String { + "mock".to_string() +} + +#[derive(Debug, Clone, Deserialize)] +struct ToolConfig { + name: String, + #[serde(default)] + description: Option, + #[serde(rename = "inputSchema", default)] + input_schema: Option, + #[serde(rename = "outputSchema", default)] + output_schema: Option, + #[serde(default)] + behavior: Behavior, +} + +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(tag = "kind", rename_all = "snake_case")] +enum Behavior { + /// Return the arguments as structured content. + #[default] + Echo, + /// Return a fixed string. + Text { text: String }, + /// Return a tool error, as distinct from a protocol error. + Error { message: String }, + /// Never answer, for exercising a per-call timeout. + Hang, + /// Answer normally, then exit the process once `calls` have been served. + CrashAfter { calls: usize }, + /// Return a payload of the given size, for exercising a result cap. + Bloat { bytes: usize }, + /// Answer after a delay. + Delay { ms: u64, text: String }, +} + +#[derive(Clone)] +struct Mock { + config: Arc, + calls: Arc, +} + +impl ServerHandler for Mock { + fn get_info(&self) -> ServerInfo { + let mut info = ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + .with_server_info(Implementation::new(self.config.name.clone(), "0.0.0")); + if let Some(version) = &self.config.protocol_version { + // The field is private and there is no constructor from a string; + // its `Deserialize` is the supported way in. + if let Ok(parsed) = serde_json::from_value(json!(version)) { + info = info.with_protocol_version(parsed); + } + } + info + } + + async fn list_tools( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + let tools = self + .config + .tools + .iter() + .map(|tool| { + let schema = tool + .input_schema + .clone() + .unwrap_or_else(|| json!({"type": "object"})); + let mut definition = Tool::new( + tool.name.clone(), + tool.description.clone().unwrap_or_default(), + as_object(schema), + ); + if let Some(output) = tool.output_schema.clone() { + definition = definition.with_raw_output_schema(Arc::new(as_object(output))); + } + definition + }) + .collect(); + Ok(ListToolsResult::with_all_items(tools)) + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + _context: RequestContext, + ) -> Result { + let Some(tool) = self + .config + .tools + .iter() + .find(|t| t.name == request.name.as_ref()) + else { + return Err(McpError::invalid_params( + format!("no such tool: {}", request.name), + None, + )); + }; + + let args = request.arguments.map(Value::Object).unwrap_or(Value::Null); + let served = self.calls.fetch_add(1, Ordering::SeqCst) + 1; + + match &tool.behavior { + Behavior::Echo => Ok(CallToolResult::structured(args).into()), + Behavior::Text { text } => { + Ok(CallToolResult::success(vec![ContentBlock::text(text)]).into()) + } + Behavior::Error { message } => { + Ok(CallToolResult::error(vec![ContentBlock::text(message)]).into()) + } + Behavior::Hang => { + // Outlives any plausible test timeout without pinning a CPU. + std::future::pending::<()>().await; + unreachable!() + } + Behavior::Delay { ms, text } => { + tokio::time::sleep(std::time::Duration::from_millis(*ms)).await; + Ok(CallToolResult::success(vec![ContentBlock::text(text)]).into()) + } + Behavior::Bloat { bytes } => { + Ok(CallToolResult::structured(json!({ "blob": "x".repeat(*bytes) })).into()) + } + Behavior::CrashAfter { calls } => { + if served > *calls { + // Abrupt, as a real server dying mid-session would be. + std::process::exit(70); + } + Ok(CallToolResult::structured(json!({ "served": served })).into()) + } + } + } +} + +fn as_object(value: Value) -> Map { + match value { + Value::Object(map) => map, + _ => Map::new(), + } +} + +/// Count startup failures across runs. +/// +/// A restart policy can only be tested if the server fails a fixed number of +/// times and then recovers, which needs state the process itself does not +/// keep. +fn should_fail_startup(config_path: &PathBuf, budget: usize) -> bool { + if budget == 0 { + return false; + } + let counter = config_path.with_extension("startups"); + let so_far: usize = std::fs::read_to_string(&counter) + .ok() + .and_then(|t| t.trim().parse().ok()) + .unwrap_or(0); + let _ = std::fs::write(&counter, (so_far + 1).to_string()); + so_far < budget +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let mut args = std::env::args().skip(1); + let mut config_path: Option = None; + while let Some(arg) = args.next() { + if arg == "--config" { + config_path = args.next().map(PathBuf::from); + } + } + let Some(config_path) = config_path else { + anyhow::bail!("usage: mock-mcp-server --config "); + }; + + let config: Config = serde_json::from_str(&std::fs::read_to_string(&config_path)?)?; + + if should_fail_startup(&config_path, config.fail_startup_times) { + eprintln!("mock-mcp-server: failing startup on purpose"); + std::process::exit(1); + } + if config.startup_delay_ms > 0 { + tokio::time::sleep(std::time::Duration::from_millis(config.startup_delay_ms)).await; + } + + let mock = Mock { + config: Arc::new(config), + calls: Arc::new(AtomicUsize::new(0)), + }; + let service = mock.serve(rmcp::transport::io::stdio()).await?; + service.waiting().await?; + Ok(()) +} diff --git a/src/mcp/console.rs b/src/mcp/console.rs index b62df4c1..55db598b 100644 --- a/src/mcp/console.rs +++ b/src/mcp/console.rs @@ -119,7 +119,6 @@ fn format_args_for_log<'js>(ctx: &Ctx<'js>, args: &[rquickjs::Value<'js>]) -> St #[cfg(test)] mod tests { - use super::*; use crate::mcp::sandbox::{Limits, Sandbox}; use std::time::Duration; diff --git a/src/mcp/normalize.rs b/src/mcp/normalize.rs index 61fc6161..a24cbe4e 100644 --- a/src/mcp/normalize.rs +++ b/src/mcp/normalize.rs @@ -92,7 +92,6 @@ fn strip_fence(text: &str) -> String { #[cfg(test)] mod tests { - use super::*; use crate::mcp::sandbox::{Limits, Sandbox}; use serde_json::{Value, json}; use std::time::Duration; From 699a20f2ef8441de8de9a6ec7ac0eeca4004c894 Mon Sep 17 00:00:00 2001 From: fluzko Date: Wed, 29 Jul 2026 15:28:45 -0300 Subject: [PATCH 14/39] feat(mcp): connect to backing mcp servers --- Cargo.toml | 2 +- src/mcp/client.rs | 352 ++++++++++++++++++++++++++++++++++++++++++++++ src/mcp/mod.rs | 1 + 3 files changed, 354 insertions(+), 1 deletion(-) create mode 100644 src/mcp/client.rs diff --git a/Cargo.toml b/Cargo.toml index d8338406..a49a1ffa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,13 +54,13 @@ toml_edit = "0.25.11" url = "2.5.8" symposium-install = { version = "0.1.0", path = "symposium-install", features = ["clap"] } rquickjs = { version = "0.12.2", features = ["futures", "macro"] } +rmcp = { version = "3", features = ["server", "client", "transport-io", "transport-child-process", "macros"] } [dev-dependencies] assert_matches = "1.5" expect-test = "1.5.1" indoc = "2.0.7" -rmcp = { version = "3", features = ["server", "client", "transport-io", "transport-child-process", "macros"] } symposium-testlib = { path = "symposium-testlib" } [workspace] diff --git a/src/mcp/client.rs b/src/mcp/client.rs new file mode 100644 index 00000000..47840f1a --- /dev/null +++ b/src/mcp/client.rs @@ -0,0 +1,352 @@ +//! Talking to one backing MCP server. +//! +//! Everything protocol-shaped is confined here. The rest of `mcp` works in +//! `serde_json::Value`, so a future SDK change touches this file and nothing +//! else — worth insisting on, given the SDK moved a major version during this +//! work. +//! +//! Two behaviors are less obvious than they look: +//! +//! * **A tool failure is not a protocol failure.** A server reports "table +//! not found" as a successful response carrying an error flag. Conflating +//! the two loses the message the model needs. +//! * **A result has to be unwrapped.** The wire form is a content envelope, +//! but a script wants the value. See [`unwrap_result`]. + +use std::path::PathBuf; +use std::time::Duration; + +use rmcp::ServiceExt; +use rmcp::model::{CallToolRequestParams, CallToolResult, ContentBlock, ProtocolVersion, Tool}; +use rmcp::service::{RoleClient, RunningService}; +use rmcp::transport::TokioChildProcess; +use serde_json::{Map, Value}; + +/// Everything needed to start a backing server. +#[derive(Debug, Clone)] +pub struct SpawnSpec { + pub name: String, + pub command: PathBuf, + pub args: Vec, + pub env: Vec<(String, String)>, + pub startup_timeout: Duration, +} + +/// Why talking to a backing server failed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ClientError { + /// Spawn or handshake did not finish in time. + StartupTimeout { server: String, limit_secs: u64 }, + /// The process could not be started, or died during the handshake. + StartupFailed { server: String, detail: String }, + /// A single call did not finish in time. + CallTimeout { + server: String, + tool: String, + limit_secs: u64, + }, + /// The connection broke, or the server rejected the request. + Protocol { server: String, detail: String }, + /// The tool ran and reported failure. Distinct from the above: the + /// message is the server's own, and belongs in front of the model. + Tool { message: String }, +} + +impl std::fmt::Display for ClientError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::StartupTimeout { server, limit_secs } => { + write!(f, "{server} did not start within {limit_secs}s") + } + Self::StartupFailed { server, detail } => { + write!(f, "{server} failed to start: {detail}") + } + Self::CallTimeout { + server, + tool, + limit_secs, + } => write!(f, "{server}.{tool} did not answer within {limit_secs}s"), + Self::Protocol { server, detail } => write!(f, "{server}: {detail}"), + Self::Tool { message } => write!(f, "{message}"), + } + } +} + +impl std::error::Error for ClientError {} + +/// A connected backing server. +pub struct BackingServer { + name: String, + service: RunningService, + protocol_version: ProtocolVersion, +} + +impl BackingServer { + /// Spawn the server and complete the handshake. + /// + /// The SDK waits on the handshake indefinitely, so the deadline is + /// imposed here. A server fetched on first use can spend most of its + /// budget just downloading. + pub async fn spawn(spec: &SpawnSpec) -> Result { + let mut command = tokio::process::Command::new(&spec.command); + command.args(&spec.args); + for (key, value) in &spec.env { + command.env(key, value); + } + + // Stderr is captured from spawn rather than after the handshake: + // when startup fails, its tail is the only account of why. + let (transport, stderr) = TokioChildProcess::builder(command) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(|e| ClientError::StartupFailed { + server: spec.name.clone(), + detail: e.to_string(), + })?; + + let started = tokio::time::timeout(spec.startup_timeout, ().serve(transport)).await; + + let service = match started { + Ok(Ok(service)) => service, + Ok(Err(e)) => { + let detail = match drain(stderr).await { + Some(tail) if !tail.is_empty() => format!("{e}: {tail}"), + _ => e.to_string(), + }; + return Err(ClientError::StartupFailed { + server: spec.name.clone(), + detail, + }); + } + Err(_) => { + return Err(ClientError::StartupTimeout { + server: spec.name.clone(), + limit_secs: spec.startup_timeout.as_secs(), + }); + } + }; + + let protocol_version = service + .peer_info() + .map(|info| info.protocol_version.clone()) + .unwrap_or_default(); + + Ok(Self { + name: spec.name.clone(), + service, + protocol_version, + }) + } + + pub fn name(&self) -> &str { + &self.name + } + + /// The version actually negotiated. + /// + /// Worth recording rather than assuming: structured output arrived in + /// 2025-06-18, and servers pinning an earlier version are deployed today. + pub fn protocol_version(&self) -> &ProtocolVersion { + &self.protocol_version + } + + pub async fn list_tools(&self) -> Result, ClientError> { + self.service + .list_all_tools() + .await + .map_err(|e| ClientError::Protocol { + server: self.name.clone(), + detail: e.to_string(), + }) + } + + pub async fn call( + &self, + tool: &str, + args: Value, + timeout: Duration, + ) -> Result { + let params = CallToolRequestParams::new(tool.to_string()); + let params = match args { + Value::Object(map) => params.with_arguments(map), + Value::Null => params, + // A non-object argument has no place in the wire form. + other => params.with_arguments(Map::from_iter([("value".to_string(), other)])), + }; + + let response = tokio::time::timeout(timeout, self.service.call_tool(params)) + .await + .map_err(|_| ClientError::CallTimeout { + server: self.name.clone(), + tool: tool.to_string(), + limit_secs: timeout.as_secs(), + })? + .map_err(|e| ClientError::Protocol { + server: self.name.clone(), + detail: e.to_string(), + })?; + + unwrap_result(&response) + } + + /// Close the connection, giving the server a chance to exit cleanly. + pub async fn shutdown(self) { + let _ = self.service.cancel().await; + } +} + +/// Reduce a tool response to the value a script should see. +/// +/// The order matters. A server may set the error flag *and* return structured +/// content; checking content first would return the payload and silently drop +/// the failure. +pub fn unwrap_result(result: &CallToolResult) -> Result { + if result.is_error.unwrap_or(false) { + return Err(ClientError::Tool { + message: joined_text(&result.content).unwrap_or_else(|| "tool failed".to_string()), + }); + } + + if let Some(structured) = &result.structured_content { + return Ok(unwrap_framework_envelope(structured.clone())); + } + + // All-text is the common case, and multi-block text results are ordinary. + if let Some(text) = joined_text(&result.content) { + return Ok(serde_json::from_str(&text).unwrap_or(Value::String(text))); + } + + // Mixed or binary content has no scalar form; hand back the envelope + // rather than inventing one. + Ok(serde_json::to_value(&result.content).unwrap_or(Value::Null)) +} + +/// Unwrap the single-key envelope one major server framework adds around +/// non-object return values. +/// +/// It wraps any scalar or list in `{"result": ...}` and flags it on the +/// output schema. Passing that through would hand the model a wrapper it +/// never asked for, and the framework is common enough that this is not an +/// edge case. +fn unwrap_framework_envelope(value: Value) -> Value { + let Value::Object(map) = &value else { + return value; + }; + if map.len() == 1 { + if let Some(inner) = map.get("result") { + return inner.clone(); + } + } + value +} + +fn joined_text(content: &[ContentBlock]) -> Option { + let mut parts = Vec::new(); + for block in content { + match block { + ContentBlock::Text(text) => parts.push(text.text.clone()), + // A single non-text block means this is not a text result. + _ => return None, + } + } + (!parts.is_empty()).then(|| parts.join("\n")) +} + +async fn drain(stderr: Option) -> Option { + use tokio::io::AsyncReadExt; + let mut stderr = stderr?; + let mut buffer = Vec::new(); + let _ = tokio::time::timeout(Duration::from_millis(200), stderr.read_to_end(&mut buffer)).await; + let text = String::from_utf8_lossy(&buffer).trim().to_string(); + // Only the tail is useful, and an unbounded one could carry secrets far. + Some( + text.chars() + .rev() + .take(400) + .collect::>() + .into_iter() + .rev() + .collect(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use rmcp::model::{CallToolResult, ContentBlock}; + use serde_json::json; + + fn text_result(parts: &[&str]) -> CallToolResult { + CallToolResult::success(parts.iter().map(|t| ContentBlock::text(*t)).collect()) + } + + // -- unwrapping -- + + #[test] + fn structured_content_is_returned_directly() { + let result = CallToolResult::structured(json!({"rows": [1, 2]})); + assert_eq!(unwrap_result(&result).unwrap(), json!({"rows": [1, 2]})); + } + + /// A server can report failure *and* attach structured content. Reading + /// the content first would return a payload and lose the error. + #[test] + fn error_flag_wins_over_structured_content() { + let mut result = CallToolResult::structured(json!({"rows": []})); + result.is_error = Some(true); + result.content = vec![ContentBlock::text("table not found")]; + + let err = unwrap_result(&result).unwrap_err(); + assert_eq!( + err, + ClientError::Tool { + message: "table not found".to_string() + } + ); + } + + #[test] + fn text_that_parses_as_json_is_parsed() { + let result = text_result(&[r#"{"ok": true}"#]); + assert_eq!(unwrap_result(&result).unwrap(), json!({"ok": true})); + } + + #[test] + fn plain_text_is_returned_as_a_string() { + let result = text_result(&["up to date"]); + assert_eq!(unwrap_result(&result).unwrap(), json!("up to date")); + } + + /// Multi-block text results are ordinary, not an edge case. + #[test] + fn several_text_blocks_are_joined() { + let result = text_result(&["line one", "line two"]); + assert_eq!(unwrap_result(&result).unwrap(), json!("line one\nline two")); + } + + /// One widely used server framework wraps every non-object return value + /// in a single-key envelope. + #[test] + fn framework_result_envelope_is_unwrapped() { + let result = CallToolResult::structured(json!({"result": [1, 2, 3]})); + assert_eq!(unwrap_result(&result).unwrap(), json!([1, 2, 3])); + } + + /// An ordinary object that happens to have one key is not an envelope + /// unless that key is the envelope's. + #[test] + fn single_key_objects_are_not_mistaken_for_envelopes() { + let result = CallToolResult::structured(json!({"rows": [1]})); + assert_eq!(unwrap_result(&result).unwrap(), json!({"rows": [1]})); + } + + #[test] + fn error_without_text_still_reports_failure() { + let mut result = CallToolResult::success(vec![]); + result.is_error = Some(true); + assert!(matches!( + unwrap_result(&result).unwrap_err(), + ClientError::Tool { .. } + )); + } +} diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index 6322cedd..6582b5b8 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -6,6 +6,7 @@ //! //! See the [MCP meta-server RFD](../../md/rfds/mcp-meta-server/README.md). +pub mod client; pub mod console; pub mod declarations; pub mod dispatch; From 04d751574fb5e95ea547b9ba36373f0f91b8dd7d Mon Sep 17 00:00:00 2001 From: fluzko Date: Wed, 29 Jul 2026 15:28:51 -0300 Subject: [PATCH 15/39] feat(mcp): supervise backing server lifecycle --- src/mcp/mod.rs | 1 + src/mcp/supervisor.rs | 489 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 490 insertions(+) create mode 100644 src/mcp/supervisor.rs diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index 6582b5b8..8e986377 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -13,6 +13,7 @@ pub mod dispatch; pub mod normalize; pub mod sandbox; pub mod schema_to_ts; +pub mod supervisor; #[cfg(test)] mod corpus_tests; diff --git a/src/mcp/supervisor.rs b/src/mcp/supervisor.rs new file mode 100644 index 00000000..60310ca4 --- /dev/null +++ b/src/mcp/supervisor.rs @@ -0,0 +1,489 @@ +//! Keeping one backing server available. +//! +//! Servers are started on first use, not at session start: most sessions +//! touch few of the servers a workspace declares, and starting the rest buys +//! nothing. +//! +//! Restarting needs more care than a retry counter. A linear budget burns out +//! in seconds against a server that is briefly unavailable — blocked on a +//! build lock, say — so backoff is exponential. And a server that runs for +//! hours before dying once should not be treated as one that has never +//! worked, so a long-lived connection clears the count. +//! +//! What is *not* retried is the tool call. A call that dies mid-flight may +//! already have had its effect, so the failure is reported and the restart +//! happens on the next call instead. + +use std::time::{Duration, Instant}; + +use rmcp::model::Tool; +use serde_json::Value; + +use super::client::{BackingServer, ClientError, SpawnSpec}; + +/// How hard to try keeping a server up. +#[derive(Debug, Clone, Copy)] +pub struct RestartPolicy { + pub max_restarts: u32, + pub base_backoff: Duration, + pub max_backoff: Duration, + /// How long a connection must last before its failures are forgiven. + pub stable_reset: Duration, + /// How long a server gets to exit cleanly before being killed. + pub shutdown_grace: Duration, +} + +impl Default for RestartPolicy { + fn default() -> Self { + Self { + max_restarts: 5, + base_backoff: Duration::from_secs(1), + max_backoff: Duration::from_secs(60), + stable_reset: Duration::from_secs(300), + shutdown_grace: Duration::from_secs(5), + } + } +} + +/// Where a supervised server stands. +/// +/// Transitions consume the value, so a state cannot be reached from one that +/// does not lead to it. +enum State { + /// Not started. The normal resting state until a tool is called. + Cold, + Ready { + server: Box, + since: Instant, + }, + /// Out of restarts. Terminal for the session. + Failed { reason: String }, +} + +impl State { + fn name(&self) -> &'static str { + match self { + Self::Cold => "cold", + Self::Ready { .. } => "ready", + Self::Failed { .. } => "failed", + } + } +} + +pub struct Supervisor { + spec: SpawnSpec, + policy: RestartPolicy, + state: State, + /// Failed start attempts since the last success that lasted. + attempts: u32, + /// When the next start attempt may happen. + retry_after: Option, +} + +impl Supervisor { + /// Register a server without starting it. + pub fn new(spec: SpawnSpec, policy: RestartPolicy) -> Self { + Self { + spec, + policy, + state: State::Cold, + attempts: 0, + retry_after: None, + } + } + + pub fn name(&self) -> &str { + &self.spec.name + } + + /// Current state, for reporting. + pub fn state_name(&self) -> &'static str { + self.state.name() + } + + /// Whether a process is running right now. + pub fn is_running(&self) -> bool { + matches!(self.state, State::Ready { .. }) + } + + pub async fn list_tools(&mut self) -> Result, ClientError> { + self.ensure_ready().await?; + let State::Ready { server, .. } = &self.state else { + unreachable!("ensure_ready returned without a connection") + }; + let outcome = server.list_tools().await; + self.note_connection_health(&outcome); + outcome + } + + /// Call a tool, starting the server if needed. + /// + /// A call that fails because the connection broke is **not** retried: it + /// may already have taken effect, and repeating it could take effect + /// twice. The server is left cold so the next call starts a new one. + pub async fn call( + &mut self, + tool: &str, + args: Value, + timeout: Duration, + ) -> Result { + self.ensure_ready().await?; + let State::Ready { server, .. } = &self.state else { + unreachable!("ensure_ready returned without a connection") + }; + let outcome = server.call(tool, args, timeout).await; + self.note_connection_health(&outcome); + outcome + } + + /// A broken connection means the process is gone; drop it so the next + /// call starts a fresh one. A tool's own failure says nothing about the + /// connection. + fn note_connection_health(&mut self, outcome: &Result) { + if !matches!(outcome, Err(ClientError::Protocol { .. })) { + return; + } + let State::Ready { since, .. } = &self.state else { + return; + }; + // A connection that lasted has earned a clean slate. + if since.elapsed() >= self.policy.stable_reset { + self.attempts = 0; + } + self.state = State::Cold; + } + + async fn ensure_ready(&mut self) -> Result<(), ClientError> { + match &self.state { + State::Ready { .. } => return Ok(()), + State::Failed { reason } => { + return Err(ClientError::StartupFailed { + server: self.spec.name.clone(), + detail: reason.clone(), + }); + } + State::Cold => {} + } + + if let Some(at) = self.retry_after { + let now = Instant::now(); + if now < at { + tokio::time::sleep(at - now).await; + } + } + + match BackingServer::spawn(&self.spec).await { + Ok(server) => { + self.state = State::Ready { + server: Box::new(server), + since: Instant::now(), + }; + self.retry_after = None; + Ok(()) + } + Err(e) => { + self.attempts += 1; + if self.attempts > self.policy.max_restarts { + self.state = State::Failed { + reason: e.to_string(), + }; + } else { + self.retry_after = Some(Instant::now() + self.backoff()); + } + Err(e) + } + } + } + + /// Exponential, capped. A failing server should not be hammered, but a + /// transient failure should not cost a whole session either. + fn backoff(&self) -> Duration { + let shift = self.attempts.saturating_sub(1).min(16); + self.policy + .base_backoff + .saturating_mul(1u32 << shift) + .min(self.policy.max_backoff) + } + + /// Close the connection, giving the server a chance to exit cleanly + /// before its process group is killed on drop. + pub async fn shutdown(&mut self) { + let previous = std::mem::replace(&mut self.state, State::Cold); + if let State::Ready { server, .. } = previous { + // A hung shutdown must not wedge the session; the drop that + // follows kills the process group regardless. + let _ = tokio::time::timeout(self.policy.shutdown_grace, server.shutdown()).await; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::path::PathBuf; + + /// Locate the mock server built alongside the test binary. + fn mock_binary() -> PathBuf { + let mut dir = std::env::current_exe().expect("test binary path"); + dir.pop(); // deps/ + if dir.ends_with("deps") { + dir.pop(); + } + dir.join("examples").join("mock-mcp-server") + } + + struct Fixture { + _dir: tempfile::TempDir, + config: PathBuf, + } + + fn fixture(config: Value) -> Fixture { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("mock.json"); + std::fs::write(&path, config.to_string()).unwrap(); + Fixture { + _dir: dir, + config: path, + } + } + + fn spec(fixture: &Fixture, startup_timeout: Duration) -> SpawnSpec { + SpawnSpec { + name: "mock".to_string(), + command: mock_binary(), + args: vec!["--config".to_string(), fixture.config.display().to_string()], + env: Vec::new(), + startup_timeout, + } + } + + fn fast_policy() -> RestartPolicy { + RestartPolicy { + base_backoff: Duration::from_millis(10), + max_backoff: Duration::from_millis(50), + shutdown_grace: Duration::from_millis(500), + ..RestartPolicy::default() + } + } + + fn echo_config() -> Value { + json!({ + "name": "mock", + "tools": [{"name": "echo", "behavior": {"kind": "echo"}}] + }) + } + + #[tokio::test(flavor = "multi_thread")] + async fn server_is_not_started_until_a_tool_is_called() { + let f = fixture(echo_config()); + let mut sup = Supervisor::new(spec(&f, Duration::from_secs(10)), fast_policy()); + + assert_eq!(sup.state_name(), "cold"); + assert!(!sup.is_running()); + + sup.call("echo", json!({"a": 1}), Duration::from_secs(5)) + .await + .unwrap(); + assert!(sup.is_running(), "first call should have started it"); + sup.shutdown().await; + } + + #[tokio::test(flavor = "multi_thread")] + async fn calls_reach_the_server() { + let f = fixture(echo_config()); + let mut sup = Supervisor::new(spec(&f, Duration::from_secs(10)), fast_policy()); + + let out = sup + .call("echo", json!({"a": 1}), Duration::from_secs(5)) + .await + .unwrap(); + assert_eq!(out, json!({"a": 1})); + sup.shutdown().await; + } + + #[tokio::test(flavor = "multi_thread")] + async fn tools_are_listed() { + let f = fixture(echo_config()); + let mut sup = Supervisor::new(spec(&f, Duration::from_secs(10)), fast_policy()); + + let tools = sup.list_tools().await.unwrap(); + assert_eq!(tools.len(), 1); + assert_eq!(tools[0].name.as_ref(), "echo"); + sup.shutdown().await; + } + + /// A server that fails a few times and then works should end up working. + #[tokio::test(flavor = "multi_thread")] + async fn transient_startup_failures_are_retried() { + let mut config = echo_config(); + config["fail_startup_times"] = json!(2); + let f = fixture(config); + let mut sup = Supervisor::new(spec(&f, Duration::from_secs(10)), fast_policy()); + + for _ in 0..2 { + assert!( + sup.call("echo", json!({}), Duration::from_secs(5)) + .await + .is_err(), + "the first attempts are expected to fail" + ); + assert_eq!(sup.state_name(), "cold", "still retryable"); + } + + let out = sup + .call("echo", json!({"n": 1}), Duration::from_secs(5)) + .await; + assert!(out.is_ok(), "should recover once the server stops failing"); + sup.shutdown().await; + } + + /// A server that never starts must stop being retried, or every later + /// call pays the startup cost again. + #[tokio::test(flavor = "multi_thread")] + async fn permanent_startup_failure_becomes_terminal() { + let mut config = echo_config(); + config["fail_startup_times"] = json!(999); + let f = fixture(config); + let policy = RestartPolicy { + max_restarts: 2, + ..fast_policy() + }; + let mut sup = Supervisor::new(spec(&f, Duration::from_secs(10)), policy); + + for _ in 0..3 { + let _ = sup.call("echo", json!({}), Duration::from_secs(5)).await; + } + assert_eq!( + sup.state_name(), + "failed", + "should give up after exhausting its restarts" + ); + + // A terminal server answers immediately rather than trying again. + let started = Instant::now(); + let err = sup + .call("echo", json!({}), Duration::from_secs(5)) + .await + .unwrap_err(); + assert!(matches!(err, ClientError::StartupFailed { .. })); + assert!( + started.elapsed() < Duration::from_secs(1), + "a failed server should not be retried" + ); + } + + /// Backoff grows so a crash-looping server is not hammered, but stays + /// capped so recovery is not delayed for minutes. + #[test] + fn backoff_grows_and_is_capped() { + let mut sup = Supervisor::new( + SpawnSpec { + name: "x".into(), + command: "true".into(), + args: vec![], + env: vec![], + startup_timeout: Duration::from_secs(1), + }, + RestartPolicy { + base_backoff: Duration::from_secs(1), + max_backoff: Duration::from_secs(8), + ..RestartPolicy::default() + }, + ); + + let mut seen = Vec::new(); + for attempt in 1..=6 { + sup.attempts = attempt; + seen.push(sup.backoff()); + } + assert_eq!( + seen, + vec![ + Duration::from_secs(1), + Duration::from_secs(2), + Duration::from_secs(4), + Duration::from_secs(8), + Duration::from_secs(8), + Duration::from_secs(8), + ] + ); + } + + /// A call that dies mid-flight may already have taken effect, so it is + /// reported rather than repeated. The next call starts a fresh server. + #[tokio::test(flavor = "multi_thread")] + async fn a_call_lost_to_a_crash_is_not_repeated() { + let f = fixture(json!({ + "name": "mock", + "tools": [{"name": "boom", "behavior": {"kind": "crash_after", "calls": 1}}] + })); + let mut sup = Supervisor::new(spec(&f, Duration::from_secs(10)), fast_policy()); + + let first = sup.call("boom", json!({}), Duration::from_secs(5)).await; + assert!(first.is_ok(), "the first call is served: {first:?}"); + + let second = sup.call("boom", json!({}), Duration::from_secs(5)).await; + assert!( + matches!(second, Err(ClientError::Protocol { .. })), + "a lost call is reported, got: {second:?}" + ); + assert_eq!( + sup.state_name(), + "cold", + "the dead process should be dropped, not reused" + ); + sup.shutdown().await; + } + + #[tokio::test(flavor = "multi_thread")] + async fn a_hanging_tool_times_out() { + let f = fixture(json!({ + "name": "mock", + "tools": [{"name": "slow", "behavior": {"kind": "hang"}}] + })); + let mut sup = Supervisor::new(spec(&f, Duration::from_secs(10)), fast_policy()); + + let err = sup + .call("slow", json!({}), Duration::from_millis(300)) + .await + .unwrap_err(); + assert!( + matches!(err, ClientError::CallTimeout { .. }), + "got: {err:?}" + ); + sup.shutdown().await; + } + + #[tokio::test(flavor = "multi_thread")] + async fn a_slow_start_times_out() { + let mut config = echo_config(); + config["startup_delay_ms"] = json!(5000); + let f = fixture(config); + let mut sup = Supervisor::new(spec(&f, Duration::from_millis(200)), fast_policy()); + + let err = sup + .call("echo", json!({}), Duration::from_secs(5)) + .await + .unwrap_err(); + assert!( + matches!(err, ClientError::StartupTimeout { .. }), + "got: {err:?}" + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn shutdown_leaves_no_connection() { + let f = fixture(echo_config()); + let mut sup = Supervisor::new(spec(&f, Duration::from_secs(10)), fast_policy()); + + sup.call("echo", json!({}), Duration::from_secs(5)) + .await + .unwrap(); + assert!(sup.is_running()); + + sup.shutdown().await; + assert!(!sup.is_running()); + assert_eq!(sup.state_name(), "cold"); + } +} From 03d7f1911ee4d6eda22894e41cc8c60d0d5e981a Mon Sep 17 00:00:00 2001 From: fluzko Date: Wed, 29 Jul 2026 15:49:25 -0300 Subject: [PATCH 16/39] feat(mcp): add mcp-serve subcommand --- src/bin/cargo-agents.rs | 35 +++++- src/cli.rs | 9 ++ src/mcp/mod.rs | 1 + src/mcp/server.rs | 253 +++++++++++++++++++++++++++++++++++++++ tests/mcp_meta_server.rs | 176 +++++++++++++++++++++++++++ 5 files changed, 470 insertions(+), 4 deletions(-) create mode 100644 src/mcp/server.rs create mode 100644 tests/mcp_meta_server.rs diff --git a/src/bin/cargo-agents.rs b/src/bin/cargo-agents.rs index ea44826f..cae999df 100644 --- a/src/bin/cargo-agents.rs +++ b/src/bin/cargo-agents.rs @@ -54,7 +54,19 @@ async fn main() -> ExitCode { // Always install the report layer. Mode determines output format: // --json → accumulate JSON array; -v → stderr trace; default → stdout. - let (mode, level) = if cli.json { + // `mcp-serve` owns stdout for JSON-RPC. Reporting there would corrupt the + // stream, so it goes to stderr whatever the flags say. + let is_mcp_serve = matches!(cli.command, Some(Commands::McpServe)); + let (mode, level) = if is_mcp_serve { + ( + report::ReportMode::Verbose, + if cli.verbose { + tracing::Level::DEBUG + } else { + tracing::Level::INFO + }, + ) + } else if cli.json { let level = if cli.verbose { tracing::Level::DEBUG } else { @@ -79,6 +91,7 @@ async fn main() -> ExitCode { Some(Commands::Hook { agent, event }) => { tracing::debug!(?agent, ?event, "cargo agents hook"); } + Some(Commands::McpServe) => tracing::debug!("cargo agents mcp-serve"), Some(Commands::SelfUpdate) => tracing::info!("cargo agents self-update"), Some(Commands::CrateInfo { name, version }) => { tracing::debug!(%name, version = ?version, "cargo agents crate-info"); @@ -98,7 +111,7 @@ async fn main() -> ExitCode { // Hook commands are quiet by default (they're invoked by the agent, not the user). // JSON mode also suppresses human output (only JSON goes to stdout). let is_hook = matches!(cli.command, Some(Commands::Hook { .. })); - let out = if cli.quiet || is_hook || cli.json { + let out = if cli.quiet || is_hook || cli.json || is_mcp_serve { Output::quiet() } else { Output::normal() @@ -115,17 +128,23 @@ async fn main() -> ExitCode { } _ => cli.update, }; - plugins::ensure_plugin_sources(&sym, source_update).await; + // Skipped for `mcp-serve`: a client may spawn a throwaway copy to probe + // the server before the real session, so startup must not fetch or write. + if !is_mcp_serve { + plugins::ensure_plugin_sources(&sym, source_update).await; + } // Auto-update = "on": check for updates and re-exec if a new binary was // installed. Skipped for self-update (which always checks explicitly) // and for hooks (session-start injects the warn nudge into hook output; // the "on" re-exec for hooks is handled here). - if !matches!(cli.command, Some(Commands::SelfUpdate)) && !is_hook { + // Re-exec during an MCP session would drop the client's connection. + if !matches!(cli.command, Some(Commands::SelfUpdate)) && !is_hook && !is_mcp_serve { if self_update::maybe_check_for_update(&sym, &out).await { self_update::re_exec(); } } else if is_hook + && !is_mcp_serve && sym.config.auto_update == config::AutoUpdate::On && self_update::maybe_check_for_update(&sym, &Output::quiet()).await { @@ -136,6 +155,14 @@ async fn main() -> ExitCode { // Commands that need direct I/O (stdin/stdout) stay in the binary Some(Commands::Hook { agent, event }) => hook::run(&sym, agent, event).await, + Some(Commands::McpServe) => match symposium::mcp::server::serve(Vec::new()).await { + Ok(()) => ExitCode::SUCCESS, + Err(err) => { + eprintln!("Error: {err:#}"); + ExitCode::FAILURE + } + }, + Some(Commands::Plugin { command }) => { let code = handle_plugin_command(&sym, command).await; let events = report_handle.drain(); diff --git a/src/cli.rs b/src/cli.rs index 1e3e58fc..1ee2246e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -86,6 +86,10 @@ pub enum Commands { event: hook::HookEvent, }, + /// MCP server entry point invoked by your agent (internal) + #[command(hide = true, name = "mcp-serve")] + McpServe, + /// Manage plugins Plugin { #[command(subcommand)] @@ -198,6 +202,11 @@ pub async fn run( } match cmd { + // Served by the binary, which owns stdio. + Commands::McpServe => Err(anyhow::anyhow!( + "mcp-serve must be run from the command line, not through the library" + )), + Commands::Init { agents, remove_agents, diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index 8e986377..672c0e12 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -13,6 +13,7 @@ pub mod dispatch; pub mod normalize; pub mod sandbox; pub mod schema_to_ts; +pub mod server; pub mod supervisor; #[cfg(test)] diff --git a/src/mcp/server.rs b/src/mcp/server.rs new file mode 100644 index 00000000..6f24e3d9 --- /dev/null +++ b/src/mcp/server.rs @@ -0,0 +1,253 @@ +//! The meta-server itself. +//! +//! One MCP server is registered with the agent, exposing two tools rather +//! than every plugin server's tools directly: `list_tools` describes what is +//! available, `execute` runs a script against it. +//! +//! Two constraints shape the process: +//! +//! * **stdout carries JSON-RPC.** Anything else written there corrupts the +//! stream, so all reporting goes to stderr. +//! * **Startup has no side effects.** Some clients probe a server by +//! spawning a throwaway copy before the real session, so anything done at +//! startup happens twice, from a process nobody will talk to. + +use std::sync::Arc; + +use rmcp::handler::server::ServerHandler; +use rmcp::model::{ + CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, Implementation, + ListToolsResult, PaginatedRequestParams, ServerCapabilities, ServerInfo, Tool, ToolAnnotations, +}; +use rmcp::service::RequestContext; +use rmcp::{ErrorData as McpError, RoleServer, ServiceExt}; +use serde_json::{Map, Value, json}; + +/// Tool that describes what is reachable. +pub const LIST_TOOLS: &str = "list_tools"; +/// Tool that runs a script. +pub const EXECUTE: &str = "execute"; + +/// Serves the two meta-tools over stdio. +#[derive(Debug, Default, Clone)] +pub struct MetaServer { + /// Names of the backing servers this workspace makes available. + servers: Arc>, +} + +impl MetaServer { + pub fn new(servers: Vec) -> Self { + Self { + servers: Arc::new(servers), + } + } + + /// The inventory carried in `execute`'s description. + /// + /// Naming the servers costs a few tokens each and saves a round trip: a + /// small workspace needs no discovery call at all. Their tools are not + /// listed here, so the cost grows with servers rather than with schemas. + fn execute_description(&self) -> String { + let mut text = String::from( + "Run a JavaScript program with the workspace's MCP tools in scope.\n\n\ + Each server is an object whose methods return promises, so a program \ + can call several tools, filter between them, and return only what \ + matters:\n\n \ + const rows = await sqlx.query({ sql: \"SELECT id FROM users\" });\n \ + return rows.filter(r => r.id > 100);\n\n\ + Write an async arrow function or a statement body that returns a value. \ + Write plain JavaScript: no type annotations, interfaces, or generics.\n\n", + ); + if self.servers.is_empty() { + text.push_str( + "No MCP servers apply to this workspace. Nothing is in scope for `execute`.", + ); + } else { + text.push_str(&format!( + "Servers in scope: {}.\nCall `{LIST_TOOLS}` for their tools and signatures.", + self.servers.join(", ") + )); + } + text + } + + fn tool_definitions(&self) -> Vec { + let list = Tool::new( + LIST_TOOLS, + "Describe the MCP tools available in this workspace. Returns names and \ + descriptions by default; pass a filter or a higher detail level for full \ + TypeScript signatures.", + object_schema(json!({ + "type": "object", + "properties": { + "servers": { + "type": "array", "items": {"type": "string"}, + "description": "Restrict to these servers." + }, + "tools": { + "type": "array", "items": {"type": "string"}, + "description": "Restrict to these tool names." + }, + "pattern": { + "type": "string", + "description": "Glob matched against tool names." + }, + "detail": { + "type": "string", "enum": ["names", "signatures", "full"], + "description": "How much to return. Defaults to names." + } + } + })), + ) + .annotate(ToolAnnotations::new().read_only(true)); + + let execute = Tool::new( + EXECUTE, + self.execute_description(), + object_schema(json!({ + "type": "object", + "properties": { + "script": { + "type": "string", + "description": "The JavaScript program to run." + } + }, + "required": ["script"] + })), + ) + // A script can call any tool in scope, so the annotation has to + // describe the worst of them. Marking it read-only would let a + // filtering client treat arbitrary tool use as safe. + .annotate( + ToolAnnotations::new() + .read_only(false) + .destructive(true) + .open_world(true), + ); + + vec![list, execute] + } +} + +impl ServerHandler for MetaServer { + fn get_info(&self) -> ServerInfo { + // The negotiated version is left to the SDK: answering with a version + // the client did not offer is a hard error that closes the transport. + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + .with_server_info(Implementation::new("symposium", env!("CARGO_PKG_VERSION"))) + .with_instructions( + "Symposium exposes this workspace's MCP tools through two tools. \ + Start with `execute`; its description names the servers in scope.", + ) + } + + async fn list_tools( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + Ok(ListToolsResult::with_all_items(self.tool_definitions())) + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + _context: RequestContext, + ) -> Result { + match request.name.as_ref() { + LIST_TOOLS => { + let body = if self.servers.is_empty() { + "No MCP servers apply to this workspace.".to_string() + } else { + format!("Servers in scope: {}.", self.servers.join(", ")) + }; + Ok(CallToolResult::success(vec![ContentBlock::text(body)]).into()) + } + EXECUTE => Ok(CallToolResult::error(vec![ContentBlock::text( + "No MCP servers apply to this workspace, so there is nothing to call.", + )]) + .into()), + other => Err(McpError::invalid_params( + format!("no such tool: {other}. Available: {LIST_TOOLS}, {EXECUTE}"), + None, + )), + } + } +} + +fn object_schema(value: Value) -> Map { + match value { + Value::Object(map) => map, + _ => Map::new(), + } +} + +/// Serve until the client disconnects. +pub async fn serve(servers: Vec) -> anyhow::Result<()> { + let service = MetaServer::new(servers) + .serve(rmcp::transport::io::stdio()) + .await?; + service.waiting().await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn advertises_exactly_two_tools() { + let names: Vec = MetaServer::default() + .tool_definitions() + .iter() + .map(|t| t.name.to_string()) + .collect(); + assert_eq!(names, vec![LIST_TOOLS.to_string(), EXECUTE.to_string()]); + } + + /// A client filtering on read-only annotations must not conclude that + /// running arbitrary code is safe. + #[test] + fn execute_is_annotated_as_destructive() { + let tools = MetaServer::default().tool_definitions(); + let execute = tools.iter().find(|t| t.name == EXECUTE).unwrap(); + let annotations = execute.annotations.as_ref().expect("annotations"); + assert_eq!(annotations.read_only_hint, Some(false)); + assert_eq!(annotations.destructive_hint, Some(true)); + assert_eq!(annotations.open_world_hint, Some(true)); + } + + #[test] + fn list_tools_is_annotated_read_only() { + let tools = MetaServer::default().tool_definitions(); + let list = tools.iter().find(|t| t.name == LIST_TOOLS).unwrap(); + assert_eq!( + list.annotations.as_ref().and_then(|a| a.read_only_hint), + Some(true) + ); + } + + /// The inventory rides in the description so a small workspace needs no + /// discovery round trip. + #[test] + fn execute_description_names_servers_in_scope() { + let server = MetaServer::new(vec!["sqlx".into(), "sea_orm".into()]); + let text = server.execute_description(); + assert!(text.contains("Servers in scope: sqlx, sea_orm."), "{text}"); + assert!(text.contains(LIST_TOOLS), "should point at the detail tool"); + } + + #[test] + fn execute_description_is_explicit_when_nothing_applies() { + let text = MetaServer::default().execute_description(); + assert!(text.contains("No MCP servers apply"), "{text}"); + } + + /// The model is shown TypeScript declarations, so it has to be told not + /// to reply in TypeScript. + #[test] + fn execute_description_warns_against_type_annotations() { + let text = MetaServer::default().execute_description(); + assert!(text.contains("no type annotations"), "{text}"); + } +} diff --git a/tests/mcp_meta_server.rs b/tests/mcp_meta_server.rs new file mode 100644 index 00000000..eef2660f --- /dev/null +++ b/tests/mcp_meta_server.rs @@ -0,0 +1,176 @@ +//! `cargo agents mcp-serve` spoken to as a real MCP client over stdio. +//! +//! The unit tests cover what the server decides; these cover that a client +//! can actually talk to it — process spawn, handshake, framing, and the +//! stdout discipline the transport depends on. + +use std::path::PathBuf; +use std::process::Stdio; +use std::time::Duration; + +use rmcp::ServiceExt; +use rmcp::model::CallToolRequestParams; +use rmcp::transport::TokioChildProcess; + +fn binary() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_cargo-agents")) +} + +/// Point the binary at an empty config directory, so a developer's own +/// settings cannot change what a test sees. +fn isolated_home() -> tempfile::TempDir { + tempfile::tempdir().expect("temp dir") +} + +async fn connect(home: &tempfile::TempDir) -> rmcp::service::RunningService { + let mut command = tokio::process::Command::new(binary()); + command + .arg("mcp-serve") + .env("SYMPOSIUM_HOME", home.path()) + .stderr(Stdio::null()); + + let transport = TokioChildProcess::new(command).expect("spawn mcp-serve"); + tokio::time::timeout(Duration::from_secs(30), ().serve(transport)) + .await + .expect("handshake timed out") + .expect("handshake failed") +} + +/// The point of the design: an agent sees two tools, not every plugin +/// server's tools. +#[tokio::test(flavor = "multi_thread")] +async fn advertises_two_tools() { + let home = isolated_home(); + let client = connect(&home).await; + + let mut names: Vec = client + .list_all_tools() + .await + .expect("tools/list") + .into_iter() + .map(|t| t.name.to_string()) + .collect(); + names.sort(); + + assert_eq!(names, vec!["execute".to_string(), "list_tools".to_string()]); + let _ = client.cancel().await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn reports_itself_as_symposium() { + let home = isolated_home(); + let client = connect(&home).await; + + let info = client.peer_info().expect("server info"); + let server_info = info.server_info.as_ref().expect("server implementation"); + assert_eq!(server_info.name, "symposium"); + assert!( + info.capabilities.tools.is_some(), + "tools capability must be advertised" + ); + let _ = client.cancel().await; +} + +/// A client filtering on read-only annotations must not mistake arbitrary +/// code execution for a safe operation. +#[tokio::test(flavor = "multi_thread")] +async fn execute_is_annotated_as_destructive() { + let home = isolated_home(); + let client = connect(&home).await; + + let tools = client.list_all_tools().await.expect("tools/list"); + let execute = tools + .iter() + .find(|t| t.name.as_ref() == "execute") + .expect("execute tool"); + let annotations = execute.annotations.as_ref().expect("annotations"); + + assert_eq!(annotations.read_only_hint, Some(false)); + assert_eq!(annotations.destructive_hint, Some(true)); + let _ = client.cancel().await; +} + +/// With nothing applicable, the tools still answer — and say so, rather than +/// failing in a way that reads as a broken connection. +#[tokio::test(flavor = "multi_thread")] +async fn list_tools_answers_when_nothing_applies() { + let home = isolated_home(); + let client = connect(&home).await; + + let result = client + .call_tool(CallToolRequestParams::new("list_tools")) + .await + .expect("list_tools should answer"); + + assert_ne!(result.is_error, Some(true), "answering is not an error"); + let _ = client.cancel().await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn unknown_tool_names_the_available_ones() { + let home = isolated_home(); + let client = connect(&home).await; + + let err = client + .call_tool(CallToolRequestParams::new("nonexistent")) + .await + .expect_err("an unknown tool is a protocol error"); + let text = err.to_string(); + + assert!( + text.contains("list_tools") && text.contains("execute"), + "the error should name what is available, got: {text}" + ); + let _ = client.cancel().await; +} + +/// The transport is newline-delimited JSON, so anything else written to +/// stdout corrupts the stream. Reporting output is the likely offender, since +/// every other subcommand sends it there. +#[tokio::test(flavor = "multi_thread")] +async fn stdout_carries_only_json_rpc() { + let home = isolated_home(); + + let mut child = tokio::process::Command::new(binary()) + .arg("mcp-serve") + // Verbose reporting would go to stdout for any other subcommand. + .arg("--verbose") + .env("SYMPOSIUM_HOME", home.path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn"); + + use tokio::io::AsyncWriteExt; + let mut stdin = child.stdin.take().expect("stdin"); + stdin + .write_all( + concat!( + r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"probe","version":"0"}}}"#, + "\n", + r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#, + "\n", + r#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#, + "\n", + ) + .as_bytes(), + ) + .await + .expect("write"); + drop(stdin); + + let output = tokio::time::timeout(Duration::from_secs(30), child.wait_with_output()) + .await + .expect("server should exit on stdin close") + .expect("output"); + + let stdout = String::from_utf8(output.stdout).expect("utf-8"); + let lines: Vec<&str> = stdout.lines().filter(|l| !l.trim().is_empty()).collect(); + + assert_eq!(lines.len(), 2, "one response per request, got: {stdout}"); + for line in lines { + serde_json::from_str::(line) + .unwrap_or_else(|e| panic!("stdout line is not JSON ({e}): {line}")); + } +} From 51a59da39577f88af2fa26b132614e8df7e6101e Mon Sep 17 00:00:00 2001 From: fluzko Date: Wed, 29 Jul 2026 16:00:12 -0300 Subject: [PATCH 17/39] feat(mcp): resolve applicable backing servers --- src/bin/cargo-agents.rs | 27 +++- src/mcp/mod.rs | 1 + src/mcp/resolve.rs | 339 ++++++++++++++++++++++++++++++++++++++++ src/plugins.rs | 15 +- 4 files changed, 375 insertions(+), 7 deletions(-) create mode 100644 src/mcp/resolve.rs diff --git a/src/bin/cargo-agents.rs b/src/bin/cargo-agents.rs index cae999df..66ec3272 100644 --- a/src/bin/cargo-agents.rs +++ b/src/bin/cargo-agents.rs @@ -155,13 +155,28 @@ async fn main() -> ExitCode { // Commands that need direct I/O (stdin/stdout) stay in the binary Some(Commands::Hook { agent, event }) => hook::run(&sym, agent, event).await, - Some(Commands::McpServe) => match symposium::mcp::server::serve(Vec::new()).await { - Ok(()) => ExitCode::SUCCESS, - Err(err) => { - eprintln!("Error: {err:#}"); - ExitCode::FAILURE + Some(Commands::McpServe) => { + let resolution = symposium::mcp::resolve::resolve(&sym, &cwd); + for rejection in &resolution.rejected { + tracing::warn!( + server = %rejection.server, + "skipping mcp server: {}", + rejection.reason + ); } - }, + let names = resolution + .servers + .iter() + .map(|s| s.name().to_string()) + .collect(); + match symposium::mcp::server::serve(names).await { + Ok(()) => ExitCode::SUCCESS, + Err(err) => { + eprintln!("Error: {err:#}"); + ExitCode::FAILURE + } + } + } Some(Commands::Plugin { command }) => { let code = handle_plugin_command(&sym, command).await; diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index 672c0e12..2a61f1cb 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -11,6 +11,7 @@ pub mod console; pub mod declarations; pub mod dispatch; pub mod normalize; +pub mod resolve; pub mod sandbox; pub mod schema_to_ts; pub mod server; diff --git a/src/mcp/resolve.rs b/src/mcp/resolve.rs new file mode 100644 index 00000000..5293f11e --- /dev/null +++ b/src/mcp/resolve.rs @@ -0,0 +1,339 @@ +//! Deciding which backing servers a workspace makes available. +//! +//! The same predicate filtering that decides which skills install decides +//! which MCP servers are in scope, so a workspace only ever sees tools +//! belonging to crates it actually depends on. That conditionality is the +//! thing no MCP primitive can express, and it is why the meta-server exists. +//! +//! Nothing is started here. Resolution is a read of the plugin registry; +//! processes begin on first use. + +use std::path::Path; +use std::time::Duration; + +use sacp::schema::McpServer; + +use crate::config::Symposium; +use crate::mcp::client::SpawnSpec; +use crate::mcp::server::{EXECUTE, LIST_TOOLS}; +use crate::plugins::McpServerOverrides; +use crate::pm::PackageManager; + +/// A backing server, ready to be started on demand. +#[derive(Debug, Clone)] +pub struct ResolvedServer { + pub spec: SpawnSpec, + /// Ceiling on one call to this server, already reconciled with the + /// user's script deadline. + pub tool_call_timeout: Duration, + pub enabled_tools: Option>, + pub disabled_tools: Option>, +} + +impl ResolvedServer { + pub fn name(&self) -> &str { + &self.spec.name + } + + /// Whether a plugin's filters let this tool through. + pub fn exposes(&self, tool: &str) -> bool { + if let Some(allow) = &self.enabled_tools { + return allow.iter().any(|t| t == tool); + } + if let Some(deny) = &self.disabled_tools { + return !deny.iter().any(|t| t == tool); + } + true + } +} + +/// What resolution produced, including what it had to refuse. +#[derive(Debug, Default)] +pub struct Resolution { + pub servers: Vec, + /// Servers that could not be used, and why. Reported rather than + /// swallowed: a server silently missing looks like a broken workspace. + pub rejected: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Rejection { + pub server: String, + pub reason: String, +} + +/// Resolve the servers applicable to the workspace containing `cwd`. +pub fn resolve(sym: &Symposium, cwd: &Path) -> Resolution { + let mut deps = sym.workspace_deps(cwd); + let Some(loaded) = deps.load() else { + // Outside a Rust workspace there is nothing to condition on. + return Resolution::default(); + }; + let loaded = loaded.clone(); + let registry = crate::plugins::load_registry_with_workspace(sym, Some(&loaded)); + + let dep_ids = crate::pm::CargoPm.list_deps(&loaded.crates); + let mut ctx = crate::predicate::PredicateContext::new(&dep_ids); + + let mut entries: Vec<(&crate::plugins::PluginMcpServer, String)> = Vec::new(); + for plugin in ®istry.plugins { + if !plugin.applies(&mut ctx) { + continue; + } + let owner = plugin.plugin.name.clone(); + for entry in plugin.plugin.applicable_mcp_entries(&mut ctx) { + entries.push((entry, owner.clone())); + } + } + + build(entries, sym.config.mcp.script_timeout_secs) +} + +/// Turn applicable manifest entries into runnable servers. +fn build( + entries: Vec<(&crate::plugins::PluginMcpServer, String)>, + script_timeout_secs: u64, +) -> Resolution { + let mut resolution = Resolution::default(); + // Which plugin claimed each name, so a clash can name both sides. + let mut claimed: Vec<(String, String)> = Vec::new(); + + for (entry, owner) in entries { + let name = server_name(&entry.server).to_string(); + + // The meta-server's own tools live in the same namespace as the + // servers it exposes; a backing server taking one would shadow it. + if name == LIST_TOOLS || name == EXECUTE { + resolution.rejected.push(Rejection { + server: name, + reason: format!("`{owner}` uses a name reserved by the meta-server"), + }); + continue; + } + + // First-wins would silently drop one plugin's server, and a warning + // on a stdio server's stderr is invisible. Refusing names both. + if let Some((_, first)) = claimed.iter().find(|(n, _)| *n == name) { + resolution.rejected.push(Rejection { + server: name.clone(), + reason: format!("declared by both `{first}` and `{owner}`"), + }); + continue; + } + + let McpServer::Stdio(stdio) = &entry.server else { + resolution.rejected.push(Rejection { + server: name, + reason: "only stdio servers are supported".to_string(), + }); + continue; + }; + + claimed.push((name.clone(), owner)); + resolution.servers.push(ResolvedServer { + spec: SpawnSpec { + name: name.clone(), + command: stdio.command.clone(), + args: stdio.args.clone(), + env: stdio + .env + .iter() + .map(|v| (v.name.clone(), v.value.clone())) + .collect(), + startup_timeout: Duration::from_secs( + entry.overrides.startup_timeout_secs.unwrap_or(30), + ), + }, + tool_call_timeout: call_timeout(&entry.overrides, script_timeout_secs), + enabled_tools: entry.overrides.enabled_tools.clone(), + disabled_tools: entry.overrides.disabled_tools.clone(), + }); + } + + resolution.servers.sort_by(|a, b| a.name().cmp(b.name())); + resolution +} + +/// Reconcile a plugin's call timeout with the user's script deadline. +/// +/// A plugin author cannot see the user's configuration, so an override +/// longer than the whole script budget is clamped rather than rejected — +/// refusing to load a server because a user lowered their own limit would +/// punish the wrong person. +fn call_timeout(overrides: &McpServerOverrides, script_timeout_secs: u64) -> Duration { + let requested = overrides.tool_call_timeout_secs.unwrap_or(60); + // Leave the script deadline strictly larger, or the call timeout could + // never fire. + let ceiling = script_timeout_secs.saturating_sub(1).max(1); + Duration::from_secs(requested.min(ceiling)) +} + +fn server_name(server: &McpServer) -> &str { + match server { + McpServer::Stdio(s) => &s.name, + McpServer::Http(s) => &s.name, + McpServer::Sse(s) => &s.name, + _ => "", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::plugins::PluginMcpServer; + use sacp::schema::McpServerStdio; + + fn stdio(name: &str) -> PluginMcpServer { + PluginMcpServer { + predicates: Default::default(), + overrides: McpServerOverrides::default(), + server: McpServer::Stdio(McpServerStdio::new(name, "/usr/bin/true")), + } + } + + fn resolve_all(entries: Vec<(&PluginMcpServer, &str)>, script_secs: u64) -> Resolution { + build( + entries + .into_iter() + .map(|(e, owner)| (e, owner.to_string())) + .collect(), + script_secs, + ) + } + + #[test] + fn stdio_servers_become_spawnable() { + let entry = stdio("sqlx"); + let out = resolve_all(vec![(&entry, "db-plugin")], 120); + + assert_eq!(out.servers.len(), 1); + assert_eq!(out.servers[0].name(), "sqlx"); + assert!(out.rejected.is_empty()); + } + + /// A silently missing server looks like a broken workspace, so refusals + /// are reported. + #[test] + fn http_servers_are_refused_with_a_reason() { + let entry = PluginMcpServer { + predicates: Default::default(), + overrides: McpServerOverrides::default(), + server: McpServer::Http(sacp::schema::McpServerHttp::new( + "remote", + "http://localhost:8080/mcp", + )), + }; + let out = resolve_all(vec![(&entry, "p")], 120); + + assert!(out.servers.is_empty()); + assert_eq!(out.rejected.len(), 1); + assert!(out.rejected[0].reason.contains("stdio")); + } + + /// First-wins would drop one plugin's server silently, and a warning on + /// a stdio server's stderr is invisible. + #[test] + fn duplicate_names_are_refused_naming_both_plugins() { + let a = stdio("sqlx"); + let b = stdio("sqlx"); + let out = resolve_all(vec![(&a, "first-plugin"), (&b, "second-plugin")], 120); + + assert_eq!(out.servers.len(), 1, "the first still works"); + assert_eq!(out.rejected.len(), 1); + let reason = &out.rejected[0].reason; + assert!( + reason.contains("first-plugin") && reason.contains("second-plugin"), + "both sides should be named, got: {reason}" + ); + } + + /// A backing server called `execute` would shadow the meta-server's own + /// tool. + #[test] + fn reserved_names_are_refused() { + for name in [LIST_TOOLS, EXECUTE] { + let entry = stdio(name); + let out = resolve_all(vec![(&entry, "p")], 120); + assert!(out.servers.is_empty(), "{name} should be refused"); + assert!(out.rejected[0].reason.contains("reserved")); + } + } + + #[test] + fn per_server_timeouts_are_honored() { + let mut entry = stdio("slow"); + entry.overrides.startup_timeout_secs = Some(45); + entry.overrides.tool_call_timeout_secs = Some(90); + let out = resolve_all(vec![(&entry, "p")], 300); + + assert_eq!(out.servers[0].spec.startup_timeout, Duration::from_secs(45)); + assert_eq!(out.servers[0].tool_call_timeout, Duration::from_secs(90)); + } + + /// A plugin author cannot see the user's configuration, so an override + /// beyond the script budget is clamped rather than refused. + #[test] + fn call_timeout_is_clamped_below_the_script_deadline() { + let mut entry = stdio("slow"); + entry.overrides.tool_call_timeout_secs = Some(600); + let out = resolve_all(vec![(&entry, "p")], 30); + + assert_eq!( + out.servers[0].tool_call_timeout, + Duration::from_secs(29), + "must stay strictly under the script deadline or it can never fire" + ); + } + + // -- tool filters -- + + #[test] + fn an_allow_list_hides_everything_else() { + let mut entry = stdio("sqlx"); + entry.overrides.enabled_tools = Some(vec!["query".into()]); + let out = resolve_all(vec![(&entry, "p")], 120); + + assert!(out.servers[0].exposes("query")); + assert!(!out.servers[0].exposes("drop_table")); + } + + #[test] + fn a_deny_list_hides_only_what_it_names() { + let mut entry = stdio("sqlx"); + entry.overrides.disabled_tools = Some(vec!["drop_table".into()]); + let out = resolve_all(vec![(&entry, "p")], 120); + + assert!(out.servers[0].exposes("query")); + assert!(!out.servers[0].exposes("drop_table")); + } + + /// An empty allow-list means nothing, which is different from declaring + /// no filter at all. + #[test] + fn an_empty_allow_list_exposes_nothing() { + let mut entry = stdio("sqlx"); + entry.overrides.enabled_tools = Some(vec![]); + let out = resolve_all(vec![(&entry, "p")], 120); + + assert!(!out.servers[0].exposes("query")); + } + + #[test] + fn without_filters_every_tool_is_exposed() { + let entry = stdio("sqlx"); + let out = resolve_all(vec![(&entry, "p")], 120); + assert!(out.servers[0].exposes("anything")); + } + + /// Order must not depend on registry iteration, or the inventory shown + /// to the model would shift between sessions. + #[test] + fn servers_are_ordered_by_name() { + let b = stdio("b-server"); + let a = stdio("a-server"); + let out = resolve_all(vec![(&b, "p"), (&a, "p")], 120); + + let names: Vec<&str> = out.servers.iter().map(|s| s.name()).collect(); + assert_eq!(names, vec!["a-server", "b-server"]); + } +} diff --git a/src/plugins.rs b/src/plugins.rs index 9829d340..ce3427d5 100644 --- a/src/plugins.rs +++ b/src/plugins.rs @@ -618,10 +618,23 @@ impl Plugin { &self, ctx: &mut crate::predicate::PredicateContext, ) -> Vec { + self.applicable_mcp_entries(ctx) + .into_iter() + .map(|s| s.server.clone()) + .collect() + } + + /// Applicable MCP servers with their per-server overrides intact. + /// + /// Registration only needs the transport details, but running a server + /// needs the timings and tool filters its plugin declared. + pub fn applicable_mcp_entries( + &self, + ctx: &mut crate::predicate::PredicateContext, + ) -> Vec<&PluginMcpServer> { self.mcp_servers .iter() .filter(|s| s.predicates.evaluate(ctx)) - .map(|s| s.server.clone()) .collect() } } From b230d247f889d70533877192cf1ebd279c44cd57 Mon Sep 17 00:00:00 2001 From: fluzko Date: Wed, 29 Jul 2026 16:30:28 -0300 Subject: [PATCH 18/39] feat(mcp): serve tool declarations from backing servers --- src/bin/cargo-agents.rs | 21 +- src/mcp/catalog.rs | 421 +++++++++++++++++++++++++++++++ src/mcp/declarations.rs | 33 ++- src/mcp/mod.rs | 1 + src/mcp/server.rs | 53 ++-- src/mcp/testdata/everything.d.ts | 12 - 6 files changed, 498 insertions(+), 43 deletions(-) create mode 100644 src/mcp/catalog.rs diff --git a/src/bin/cargo-agents.rs b/src/bin/cargo-agents.rs index 66ec3272..00ddcc5d 100644 --- a/src/bin/cargo-agents.rs +++ b/src/bin/cargo-agents.rs @@ -164,12 +164,21 @@ async fn main() -> ExitCode { rejection.reason ); } - let names = resolution - .servers - .iter() - .map(|s| s.name().to_string()) - .collect(); - match symposium::mcp::server::serve(names).await { + let catalog = std::sync::Arc::new(symposium::mcp::catalog::Catalog::new( + resolution.servers, + symposium::mcp::supervisor::RestartPolicy { + max_restarts: sym.config.mcp.max_server_restarts, + stable_reset: std::time::Duration::from_secs( + sym.config.mcp.restart_stable_reset_secs, + ), + shutdown_grace: std::time::Duration::from_secs( + sym.config.mcp.shutdown_grace_secs, + ), + ..Default::default() + }, + sym.config.mcp.read_only, + )); + match symposium::mcp::server::serve(catalog).await { Ok(()) => ExitCode::SUCCESS, Err(err) => { eprintln!("Error: {err:#}"); diff --git a/src/mcp/catalog.rs b/src/mcp/catalog.rs new file mode 100644 index 00000000..a5f8412a --- /dev/null +++ b/src/mcp/catalog.rs @@ -0,0 +1,421 @@ +//! What the workspace's tools look like to the model. +//! +//! `list_tools` answers with an index by default — one line per tool — and +//! full TypeScript declarations only when asked. Truncating a full dump would +//! be lossy and would vary with the workspace; an index is lossless and +//! strictly smaller. A single mainstream server's schemas can fill a whole +//! declaration budget on their own, so this is the common case, not a +//! precaution. +//! +//! Descriptions require a live `tools/list`, so the first call starts the +//! servers it needs. Cold start is paid at first disclosure rather than at +//! session start. + +use std::time::Duration; + +use rmcp::model::Tool; +use serde_json::Value; +use tokio::sync::Mutex; + +use super::declarations::{ToolDecl, render_server}; +use super::resolve::ResolvedServer; +use super::supervisor::{RestartPolicy, Supervisor}; + +/// How much to say about each tool. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Detail { + /// Name and description. Enough to choose; not enough to call. + #[default] + Names, + /// One TypeScript signature per tool. + Signatures, + /// Full declarations, including the types parameters refer to. + Full, +} + +impl Detail { + pub fn parse(value: Option<&str>) -> Self { + match value { + Some("signatures") => Self::Signatures, + Some("full") => Self::Full, + _ => Self::Names, + } + } +} + +/// Which tools to describe. +#[derive(Debug, Clone, Default)] +pub struct Query { + pub servers: Option>, + pub tools: Option>, + pub pattern: Option, + pub detail: Detail, +} + +impl Query { + /// Read a query from the tool's arguments, ignoring anything unrecognized. + pub fn from_arguments(args: &Value) -> Self { + Self { + servers: string_list(args.get("servers")), + tools: string_list(args.get("tools")), + pattern: args + .get("pattern") + .and_then(Value::as_str) + .map(str::to_string), + detail: Detail::parse(args.get("detail").and_then(Value::as_str)), + } + } + + /// Naming specific servers or tools implies wanting their details. + fn effective_detail(&self) -> Detail { + if self.detail == Detail::Names && (self.servers.is_some() || self.tools.is_some()) { + Detail::Full + } else { + self.detail + } + } + + fn wants_server(&self, name: &str) -> bool { + self.servers + .as_ref() + .is_none_or(|only| only.iter().any(|s| s == name)) + } + + fn wants_tool(&self, name: &str) -> bool { + if let Some(only) = &self.tools { + if !only.iter().any(|t| t == name) { + return false; + } + } + match &self.pattern { + Some(pattern) => glob_matches(pattern, name), + None => true, + } + } +} + +/// The workspace's backing servers, described on demand. +pub struct Catalog { + entries: Vec, + read_only: bool, + /// Filter entries the caller named that match no server. + known_names: Vec, +} + +struct Entry { + resolved: ResolvedServer, + supervisor: Mutex, +} + +impl Catalog { + pub fn new(servers: Vec, policy: RestartPolicy, read_only: bool) -> Self { + let known_names = servers.iter().map(|s| s.name().to_string()).collect(); + let entries = servers + .into_iter() + .map(|resolved| Entry { + supervisor: Mutex::new(Supervisor::new(resolved.spec.clone(), policy)), + resolved, + }) + .collect(); + Self { + entries, + read_only, + known_names, + } + } + + pub fn server_names(&self) -> Vec { + self.known_names.clone() + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Describe the matching tools. + pub async fn describe(&self, query: &Query) -> String { + if self.entries.is_empty() { + return "No MCP servers apply to this workspace.".to_string(); + } + + let mut sections = Vec::new(); + let mut problems = Vec::new(); + + for entry in &self.entries { + if !query.wants_server(entry.resolved.name()) { + continue; + } + + let tools = { + let mut supervisor = entry.supervisor.lock().await; + supervisor.list_tools().await + }; + let tools = match tools { + Ok(tools) => tools, + Err(e) => { + // A server that will not start is reported in place, so + // the absence of its tools has a visible reason. + problems.push(format!("{}: {e}", entry.resolved.name())); + continue; + } + }; + + let visible: Vec<&Tool> = tools + .iter() + .filter(|t| entry.resolved.exposes(t.name.as_ref())) + .filter(|t| !self.read_only || is_read_only(t)) + .filter(|t| query.wants_tool(t.name.as_ref())) + .collect(); + + if visible.is_empty() { + // Silence here reads as a broken connection, so say why. + if !tools.is_empty() && !query_narrows(query) { + problems.push(format!( + "{}: no tools visible ({} hidden by filters)", + entry.resolved.name(), + tools.len() + )); + } + continue; + } + + sections.push(render( + entry.resolved.name(), + &visible, + query.effective_detail(), + )); + } + + // Naming a server that does not exist is a mistake worth surfacing + // rather than answering with silence. + if let Some(requested) = &query.servers { + for name in requested { + if !self.known_names.iter().any(|k| k == name) { + problems.push(format!( + "no server named `{name}`. Available: {}", + self.known_names.join(", ") + )); + } + } + } + + if sections.is_empty() && problems.is_empty() { + return "No tools matched.".to_string(); + } + + let mut out = sections.join("\n"); + if !problems.is_empty() { + if !out.is_empty() { + out.push('\n'); + } + out.push_str("Problems:\n"); + for problem in problems { + out.push_str(&format!(" {problem}\n")); + } + } + out + } + + /// Call a tool on a backing server, honoring its filters. + pub async fn call(&self, server: &str, tool: &str, args: Value) -> Result { + let Some(entry) = self.entries.iter().find(|e| e.resolved.name() == server) else { + return Err(format!( + "no server named `{server}`. Available: {}", + self.known_names.join(", ") + )); + }; + if !entry.resolved.exposes(tool) { + return Err(format!( + "`{server}.{tool}` is not exposed by this workspace" + )); + } + + let timeout = entry.resolved.tool_call_timeout; + let mut supervisor = entry.supervisor.lock().await; + supervisor + .call(tool, args, timeout) + .await + .map_err(|e| e.to_string()) + } + + /// Close every running server. + pub async fn shutdown(&self) { + for entry in &self.entries { + entry.supervisor.lock().await.shutdown().await; + } + } + + /// How long a script may run against this catalog's servers. + pub fn max_call_timeout(&self) -> Duration { + self.entries + .iter() + .map(|e| e.resolved.tool_call_timeout) + .max() + .unwrap_or_default() + } +} + +fn query_narrows(query: &Query) -> bool { + query.tools.is_some() || query.pattern.is_some() +} + +fn is_read_only(tool: &Tool) -> bool { + tool.annotations + .as_ref() + .and_then(|a| a.read_only_hint) + .unwrap_or(false) +} + +fn render(server: &str, tools: &[&Tool], detail: Detail) -> String { + match detail { + Detail::Names => { + let mut out = format!("{server}:\n"); + for tool in tools { + match tool.description.as_deref().map(str::trim) { + Some(text) if !text.is_empty() => { + out.push_str(&format!(" {} - {}\n", tool.name, first_line(text))); + } + _ => out.push_str(&format!(" {}\n", tool.name)), + } + } + out + } + Detail::Signatures | Detail::Full => { + // The schemas are owned so the declaration renderer, which works + // in plain JSON, never sees a protocol type. + let schemas: Vec> = tools + .iter() + .map(|tool| { + (detail == Detail::Full).then(|| Value::Object((*tool.input_schema).clone())) + }) + .collect(); + let decls: Vec = tools + .iter() + .zip(&schemas) + .map(|(tool, schema)| ToolDecl { + name: tool.name.as_ref(), + description: tool.description.as_deref(), + // Signatures name the parameter; full spells out its shape. + input_schema: schema.as_ref(), + }) + .collect(); + render_server(server, &decls) + } + } +} + +fn first_line(text: &str) -> String { + text.lines().next().unwrap_or_default().trim().to_string() +} + +fn string_list(value: Option<&Value>) -> Option> { + let array = value?.as_array()?; + Some( + array + .iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect(), + ) +} + +/// Match a name against a pattern where `*` stands for any run of characters. +/// +/// A dependency for this would be more machinery than the feature is worth. +fn glob_matches(pattern: &str, name: &str) -> bool { + let parts: Vec<&str> = pattern.split('*').collect(); + if parts.len() == 1 { + return pattern == name; + } + let mut rest = name; + for (index, part) in parts.iter().enumerate() { + if part.is_empty() { + continue; + } + match index { + // A pattern not starting with `*` must match from the front. + 0 => match rest.strip_prefix(part) { + Some(tail) => rest = tail, + None => return false, + }, + _ if index == parts.len() - 1 => { + // The final piece must land at the end. + return rest.ends_with(part) && rest.len() >= part.len(); + } + _ => match rest.find(part) { + Some(at) => rest = &rest[at + part.len()..], + None => return false, + }, + } + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detail_defaults_to_names() { + assert_eq!(Detail::parse(None), Detail::Names); + assert_eq!(Detail::parse(Some("nonsense")), Detail::Names); + assert_eq!(Detail::parse(Some("full")), Detail::Full); + assert_eq!(Detail::parse(Some("signatures")), Detail::Signatures); + } + + /// Asking about specific servers or tools is a request for detail; making + /// the caller also pass `detail` would be a needless second round trip. + #[test] + fn naming_a_target_implies_wanting_detail() { + let query = Query { + servers: Some(vec!["sqlx".into()]), + ..Query::default() + }; + assert_eq!(query.effective_detail(), Detail::Full); + + let unfiltered = Query::default(); + assert_eq!(unfiltered.effective_detail(), Detail::Names); + } + + #[test] + fn explicit_detail_is_respected_even_when_filtering() { + let query = Query { + servers: Some(vec!["sqlx".into()]), + detail: Detail::Signatures, + ..Query::default() + }; + assert_eq!(query.effective_detail(), Detail::Signatures); + } + + #[test] + fn arguments_are_read_leniently() { + let query = Query::from_arguments(&serde_json::json!({ + "servers": ["a", "b"], + "pattern": "get_*", + "detail": "full", + "unrecognized": 1 + })); + assert_eq!(query.servers, Some(vec!["a".into(), "b".into()])); + assert_eq!(query.pattern.as_deref(), Some("get_*")); + assert_eq!(query.detail, Detail::Full); + assert_eq!(query.tools, None); + } + + // -- glob -- + + #[test] + fn glob_matches_prefixes_suffixes_and_middles() { + assert!(glob_matches("get_*", "get_user")); + assert!(!glob_matches("get_*", "set_user")); + assert!(glob_matches("*_user", "get_user")); + assert!(!glob_matches("*_user", "get_team")); + assert!(glob_matches("get_*_by_*", "get_user_by_id")); + assert!(glob_matches("*", "anything")); + } + + #[test] + fn glob_without_a_wildcard_is_an_exact_match() { + assert!(glob_matches("query", "query")); + assert!(!glob_matches("query", "query2")); + } +} diff --git a/src/mcp/declarations.rs b/src/mcp/declarations.rs index 3927e0ee..8381e1c0 100644 --- a/src/mcp/declarations.rs +++ b/src/mcp/declarations.rs @@ -40,12 +40,13 @@ pub fn render_server(server: &str, tools: &[ToolDecl]) -> String { }; for (index, key) in std::iter::once(&primary).chain(alias.iter()).enumerate() { - if let Some(doc) = tool.description.and_then(jsdoc_text) { - methods.push_str(&format!(" /** {doc} */\n")); - } - // The alias is the same tool reached by a different spelling; say - // so rather than leaving the reader to infer it. - if index == 1 { + if index == 0 { + if let Some(doc) = tool.description.and_then(jsdoc_text) { + methods.push_str(&format!(" /** {doc} */\n")); + } + } else { + // The alias is the same tool under a different spelling. + // Repeating the description would double it in the output. methods.push_str(&format!(" /** Alias for {}. */\n", tool.name)); } methods.push_str(&format!(" {key}({params}): Promise;\n")); @@ -214,6 +215,26 @@ mod tests { assert!(out.contains("/** Alias for get-sum. */"), "got:\n{out}"); } + /// The alias carries only its cross-reference; repeating the description + /// would print it twice for one tool. + #[test] + fn alias_does_not_repeat_the_description() { + let out = render_server( + "s", + &[ToolDecl { + name: "get-sum", + description: Some("Adds numbers"), + input_schema: None, + }], + ); + assert_eq!( + out.matches("Adds numbers").count(), + 1, + "description should appear once, got:\n{out}" + ); + assert!(out.contains("/** Alias for get-sum. */"), "got:\n{out}"); + } + #[test] fn identifier_tool_is_not_aliased() { let out = render_server("s", &[tool("query", &json!({}))]); diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index 2a61f1cb..b65910d0 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -6,6 +6,7 @@ //! //! See the [MCP meta-server RFD](../../md/rfds/mcp-meta-server/README.md). +pub mod catalog; pub mod client; pub mod console; pub mod declarations; diff --git a/src/mcp/server.rs b/src/mcp/server.rs index 6f24e3d9..5ddb77aa 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -29,16 +29,18 @@ pub const LIST_TOOLS: &str = "list_tools"; pub const EXECUTE: &str = "execute"; /// Serves the two meta-tools over stdio. -#[derive(Debug, Default, Clone)] +#[derive(Clone)] pub struct MetaServer { - /// Names of the backing servers this workspace makes available. + catalog: Arc, + /// Names of the backing servers, cached for the tool descriptions. servers: Arc>, } impl MetaServer { - pub fn new(servers: Vec) -> Self { + pub fn new(catalog: Arc) -> Self { Self { - servers: Arc::new(servers), + servers: Arc::new(catalog.server_names()), + catalog, } } @@ -156,15 +158,13 @@ impl ServerHandler for MetaServer { ) -> Result { match request.name.as_ref() { LIST_TOOLS => { - let body = if self.servers.is_empty() { - "No MCP servers apply to this workspace.".to_string() - } else { - format!("Servers in scope: {}.", self.servers.join(", ")) - }; + let args = request.arguments.map(Value::Object).unwrap_or(Value::Null); + let query = super::catalog::Query::from_arguments(&args); + let body = self.catalog.describe(&query).await; Ok(CallToolResult::success(vec![ContentBlock::text(body)]).into()) } EXECUTE => Ok(CallToolResult::error(vec![ContentBlock::text( - "No MCP servers apply to this workspace, so there is nothing to call.", + "`execute` is not wired up yet.", )]) .into()), other => Err(McpError::invalid_params( @@ -183,21 +183,36 @@ fn object_schema(value: Value) -> Map { } /// Serve until the client disconnects. -pub async fn serve(servers: Vec) -> anyhow::Result<()> { - let service = MetaServer::new(servers) +pub async fn serve(catalog: Arc) -> anyhow::Result<()> { + let service = MetaServer::new(Arc::clone(&catalog)) .serve(rmcp::transport::io::stdio()) .await?; - service.waiting().await?; + let outcome = service.waiting().await; + // Backing servers outlive the connection otherwise; their process groups + // die with this process, but a clean close lets them exit on their own. + catalog.shutdown().await; + outcome?; Ok(()) } #[cfg(test)] mod tests { use super::*; + use crate::mcp::catalog::Catalog; + use crate::mcp::supervisor::RestartPolicy; + + /// A server with no backing processes; enough to inspect what it + /// advertises. + fn test_server(names: &[&str]) -> MetaServer { + let catalog = Catalog::new(Vec::new(), RestartPolicy::default(), false); + let mut server = MetaServer::new(Arc::new(catalog)); + server.servers = Arc::new(names.iter().map(|n| n.to_string()).collect()); + server + } #[test] fn advertises_exactly_two_tools() { - let names: Vec = MetaServer::default() + let names: Vec = test_server(&[]) .tool_definitions() .iter() .map(|t| t.name.to_string()) @@ -209,7 +224,7 @@ mod tests { /// running arbitrary code is safe. #[test] fn execute_is_annotated_as_destructive() { - let tools = MetaServer::default().tool_definitions(); + let tools = test_server(&[]).tool_definitions(); let execute = tools.iter().find(|t| t.name == EXECUTE).unwrap(); let annotations = execute.annotations.as_ref().expect("annotations"); assert_eq!(annotations.read_only_hint, Some(false)); @@ -219,7 +234,7 @@ mod tests { #[test] fn list_tools_is_annotated_read_only() { - let tools = MetaServer::default().tool_definitions(); + let tools = test_server(&[]).tool_definitions(); let list = tools.iter().find(|t| t.name == LIST_TOOLS).unwrap(); assert_eq!( list.annotations.as_ref().and_then(|a| a.read_only_hint), @@ -231,7 +246,7 @@ mod tests { /// discovery round trip. #[test] fn execute_description_names_servers_in_scope() { - let server = MetaServer::new(vec!["sqlx".into(), "sea_orm".into()]); + let server = test_server(&["sqlx", "sea_orm"]); let text = server.execute_description(); assert!(text.contains("Servers in scope: sqlx, sea_orm."), "{text}"); assert!(text.contains(LIST_TOOLS), "should point at the detail tool"); @@ -239,7 +254,7 @@ mod tests { #[test] fn execute_description_is_explicit_when_nothing_applies() { - let text = MetaServer::default().execute_description(); + let text = test_server(&[]).execute_description(); assert!(text.contains("No MCP servers apply"), "{text}"); } @@ -247,7 +262,7 @@ mod tests { /// to reply in TypeScript. #[test] fn execute_description_warns_against_type_annotations() { - let text = MetaServer::default().execute_description(); + let text = test_server(&[]).execute_description(); assert!(text.contains("no type annotations"), "{text}"); } } diff --git a/src/mcp/testdata/everything.d.ts b/src/mcp/testdata/everything.d.ts index 8a84d683..081c9561 100644 --- a/src/mcp/testdata/everything.d.ts +++ b/src/mcp/testdata/everything.d.ts @@ -11,7 +11,6 @@ declare const everything: { /** Type of message to demonstrate different annotation patterns */ messageType: "error" | "success" | "debug"; }): Promise; - /** Demonstrates how annotations can be used to provide metadata about content. */ /** Alias for get-annotated-message. */ get_annotated_message(params: { /** Whether to include an example image */ @@ -21,7 +20,6 @@ declare const everything: { }): Promise; /** Returns all environment variables, helpful for debugging MCP server configuration */ "get-env"(): Promise; - /** Returns all environment variables, helpful for debugging MCP server configuration */ /** Alias for get-env. */ get_env(): Promise; /** Returns up to ten resource links that reference different types of resources */ @@ -29,7 +27,6 @@ declare const everything: { /** Number of resource links to return (1-10) */ count?: number; }): Promise; - /** Returns up to ten resource links that reference different types of resources */ /** Alias for get-resource-links. */ get_resource_links(params?: { /** Number of resource links to return (1-10) */ @@ -41,7 +38,6 @@ declare const everything: { resourceId?: number; resourceType?: "Text" | "Blob"; }): Promise; - /** Returns a resource reference that can be used by MCP clients */ /** Alias for get-resource-reference. */ get_resource_reference(params?: { /** ID of the text resource to fetch */ @@ -53,7 +49,6 @@ declare const everything: { /** Choose city */ location: "New York" | "Chicago" | "Los Angeles"; }): Promise; - /** Returns structured content along with an output schema for client data validation */ /** Alias for get-structured-content. */ get_structured_content(params: { /** Choose city */ @@ -66,7 +61,6 @@ declare const everything: { /** Second number */ b: number; }): Promise; - /** Returns the sum of two numbers */ /** Alias for get-sum. */ get_sum(params: { /** First number */ @@ -76,7 +70,6 @@ declare const everything: { }): Promise; /** Returns a tiny MCP logo image. */ "get-tiny-image"(): Promise; - /** Returns a tiny MCP logo image. */ /** Alias for get-tiny-image. */ get_tiny_image(): Promise; /** Compresses a single file using gzip compression. Depending upon the selected output type, returns either the compressed data as a gzipped resource or a resource link, allowing it to be downloaded in a subsequent request during the current session. */ @@ -88,7 +81,6 @@ declare const everything: { /** How the resulting gzipped file should be returned. 'resourceLink' returns a link to a resource that can be read later, 'resource' returns a full resource object. */ outputType?: "resourceLink" | "resource"; }): Promise; - /** Compresses a single file using gzip compression. Depending upon the selected output type, returns either the compressed data as a gzipped resource or a resource link, allowing it to be downloaded in a subsequent request during the current session. */ /** Alias for gzip-file-as-resource. */ gzip_file_as_resource(params?: { /** URL or data URI of the file content to compress */ @@ -100,12 +92,10 @@ declare const everything: { }): Promise; /** Toggles simulated, random-leveled logging on or off. */ "toggle-simulated-logging"(): Promise; - /** Toggles simulated, random-leveled logging on or off. */ /** Alias for toggle-simulated-logging. */ toggle_simulated_logging(): Promise; /** Toggles simulated resource subscription updates on or off. */ "toggle-subscriber-updates"(): Promise; - /** Toggles simulated resource subscription updates on or off. */ /** Alias for toggle-subscriber-updates. */ toggle_subscriber_updates(): Promise; /** Demonstrates a long running operation with progress updates. */ @@ -115,7 +105,6 @@ declare const everything: { /** Number of steps in the operation */ steps?: number; }): Promise; - /** Demonstrates a long running operation with progress updates. */ /** Alias for trigger-long-running-operation. */ trigger_long_running_operation(params?: { /** Duration of the operation in seconds */ @@ -130,7 +119,6 @@ declare const everything: { /** The research topic to investigate */ topic: string; }): Promise; - /** Simulates a deep research operation that gathers, analyzes, and synthesizes information. Demonstrates MCP task-based operations with progress through multiple stages. If 'ambiguous' is true and client supports elicitation, sends an elicitation request for clarification. */ /** Alias for simulate-research-query. */ simulate_research_query(params: { /** Simulate an ambiguous query that requires clarification (triggers input_required status) */ From 09fcd483219ed7835c285e32a1598e4d64e3d77e Mon Sep 17 00:00:00 2001 From: fluzko Date: Wed, 29 Jul 2026 16:39:16 -0300 Subject: [PATCH 19/39] feat(mcp): run scripts against backing servers --- src/bin/cargo-agents.rs | 9 +- src/mcp/catalog.rs | 74 ++++++++++++++- src/mcp/declarations.rs | 24 +++++ src/mcp/server.rs | 100 ++++++++++++++++++-- tests/mcp_meta_server.rs | 199 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 396 insertions(+), 10 deletions(-) diff --git a/src/bin/cargo-agents.rs b/src/bin/cargo-agents.rs index 00ddcc5d..769ad13a 100644 --- a/src/bin/cargo-agents.rs +++ b/src/bin/cargo-agents.rs @@ -178,7 +178,14 @@ async fn main() -> ExitCode { }, sym.config.mcp.read_only, )); - match symposium::mcp::server::serve(catalog).await { + let limits = symposium::mcp::sandbox::Limits { + timeout: std::time::Duration::from_secs(sym.config.mcp.script_timeout_secs), + memory_bytes: (sym.config.mcp.script_memory_limit_mb as usize) << 20, + stack_bytes: (sym.config.mcp.script_stack_limit_kb as usize) << 10, + max_result_bytes: sym.config.mcp.max_result_bytes, + max_console_bytes: sym.config.mcp.max_console_bytes, + }; + match symposium::mcp::server::serve(catalog, limits).await { Ok(()) => ExitCode::SUCCESS, Err(err) => { eprintln!("Error: {err:#}"); diff --git a/src/mcp/catalog.rs b/src/mcp/catalog.rs index a5f8412a..93db9375 100644 --- a/src/mcp/catalog.rs +++ b/src/mcp/catalog.rs @@ -17,7 +17,8 @@ use rmcp::model::Tool; use serde_json::Value; use tokio::sync::Mutex; -use super::declarations::{ToolDecl, render_server}; +use super::declarations::{ToolDecl, binding_keys, render_server}; +use super::dispatch::{Binding, Namespace}; use super::resolve::ResolvedServer; use super::supervisor::{RestartPolicy, Supervisor}; @@ -216,6 +217,56 @@ impl Catalog { out } + /// The namespaces a script sees, one per server. + /// + /// Building these needs each server's tool list, so this starts the + /// servers that are not already running. + pub async fn namespaces(&self) -> (Vec, Vec) { + let mut namespaces = Vec::new(); + let mut problems = Vec::new(); + + for entry in &self.entries { + let tools = { + let mut supervisor = entry.supervisor.lock().await; + supervisor.list_tools().await + }; + let tools = match tools { + Ok(tools) => tools, + Err(e) => { + problems.push(format!("{}: {e}", entry.resolved.name())); + continue; + } + }; + + let bindings: Vec = tools + .iter() + .filter(|t| entry.resolved.exposes(t.name.as_ref())) + .filter(|t| !self.read_only || is_read_only(t)) + .flat_map(|t| { + // Both spellings reach the same wire name, so a model can + // use whichever the declarations showed it. + binding_keys(t.name.as_ref()) + .into_iter() + .map(move |key| Binding { + key, + wire_name: t.name.to_string(), + }) + }) + .collect(); + + if bindings.is_empty() { + continue; + } + namespaces.push(Namespace { + key: namespace_key(entry.resolved.name()), + server: entry.resolved.name().to_string(), + bindings, + }); + } + + (namespaces, problems) + } + /// Call a tool on a backing server, honoring its filters. pub async fn call(&self, server: &str, tool: &str, args: Value) -> Result { let Some(entry) = self.entries.iter().find(|e| e.resolved.name() == server) else { @@ -255,6 +306,18 @@ impl Catalog { } } +/// A server's name as a JavaScript global. +fn namespace_key(server: &str) -> String { + let mut out: String = server + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect(); + if out.starts_with(|c: char| c.is_ascii_digit()) { + out.insert(0, '_'); + } + out +} + fn query_narrows(query: &Query) -> bool { query.tools.is_some() || query.pattern.is_some() } @@ -355,6 +418,15 @@ fn glob_matches(pattern: &str, name: &str) -> bool { mod tests { use super::*; + /// The global a script uses must be a legal identifier even when the + /// server's declared name is not. + #[test] + fn namespace_keys_are_identifiers() { + assert_eq!(namespace_key("sqlx"), "sqlx"); + assert_eq!(namespace_key("sea-orm"), "sea_orm"); + assert_eq!(namespace_key("2fa"), "_2fa"); + } + #[test] fn detail_defaults_to_names() { assert_eq!(Detail::parse(None), Detail::Names); diff --git a/src/mcp/declarations.rs b/src/mcp/declarations.rs index 8381e1c0..9c365341 100644 --- a/src/mcp/declarations.rs +++ b/src/mcp/declarations.rs @@ -61,6 +61,19 @@ pub fn render_server(server: &str, tools: &[ToolDecl]) -> String { out } +/// The keys a tool is reachable under in JavaScript. +/// +/// A wire name that is already an identifier needs one key. One that is not +/// gets two — the quoted wire name and a sanitized alias — so both +/// `sqlx["migrate-status"]` and `sqlx.migrate_status` dispatch. +pub fn binding_keys(name: &str) -> Vec { + if is_js_identifier(name) { + vec![name.to_string()] + } else { + vec![name.to_string(), sanitize(name)] + } +} + /// Render a tool's parameter list. /// /// A tool with no properties takes no argument at all, and one whose @@ -267,6 +280,17 @@ mod tests { assert!(out.starts_with("declare const sea_orm: {"), "got:\n{out}"); } + /// The keys used to build the runtime namespace must match the ones the + /// declarations advertise, or a model would call a name that is not there. + #[test] + fn binding_keys_match_the_declared_names() { + assert_eq!(binding_keys("query"), vec!["query".to_string()]); + assert_eq!( + binding_keys("get-sum"), + vec!["get-sum".to_string(), "get_sum".to_string()] + ); + } + // -- documentation and shared types -- #[test] diff --git a/src/mcp/server.rs b/src/mcp/server.rs index 5ddb77aa..5782d50a 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -34,13 +34,59 @@ pub struct MetaServer { catalog: Arc, /// Names of the backing servers, cached for the tool descriptions. servers: Arc>, + limits: super::sandbox::Limits, } impl MetaServer { - pub fn new(catalog: Arc) -> Self { + pub fn new(catalog: Arc, limits: super::sandbox::Limits) -> Self { Self { servers: Arc::new(catalog.server_names()), catalog, + limits, + } + } + + /// Run a model-written script with the workspace's tools in scope. + async fn execute(&self, script: &str) -> CallToolResult { + let (namespaces, problems) = self.catalog.namespaces().await; + if namespaces.is_empty() { + let mut message = + String::from("No MCP tools are in scope, so there is nothing to call."); + for problem in &problems { + message.push_str(&format!("\n {problem}")); + } + return CallToolResult::error(vec![ContentBlock::text(message)]); + } + + // Tool calls cross back to this runtime: backing servers are child + // processes owned here, and their I/O cannot be polled from the + // engine's thread. + let (calls, mut receiver) = super::dispatch::channel(); + let catalog = Arc::clone(&self.catalog); + let pump = tokio::spawn(async move { + while let Some(call) = receiver.recv().await { + let answer = catalog.call(&call.server, &call.tool, call.args).await; + let _ = call.reply.send(answer); + } + }); + + let outcome = super::sandbox::Sandbox::new(self.limits) + .run_script_with(script, &namespaces, calls) + .await; + // The sender is dropped with the sandbox, ending the pump. + let _ = pump.await; + + match outcome { + Ok(outcome) => CallToolResult::success(vec![ContentBlock::text(render_outcome( + &outcome, &problems, + ))]), + Err(e) => { + // Tagged rather than prose, so the model can tell a limit it + // exceeded from a mistake in its own code. + let detail = + serde_json::to_string(&e).unwrap_or_else(|_| format!("{{\"error\":\"{e}\"}}")); + CallToolResult::error(vec![ContentBlock::text(detail)]) + } } } @@ -163,10 +209,16 @@ impl ServerHandler for MetaServer { let body = self.catalog.describe(&query).await; Ok(CallToolResult::success(vec![ContentBlock::text(body)]).into()) } - EXECUTE => Ok(CallToolResult::error(vec![ContentBlock::text( - "`execute` is not wired up yet.", - )]) - .into()), + EXECUTE => { + let args = request.arguments.map(Value::Object).unwrap_or(Value::Null); + let Some(script) = args.get("script").and_then(Value::as_str) else { + return Err(McpError::invalid_params( + "`execute` requires a `script` string", + None, + )); + }; + Ok(self.execute(script).await.into()) + } other => Err(McpError::invalid_params( format!("no such tool: {other}. Available: {LIST_TOOLS}, {EXECUTE}"), None, @@ -175,6 +227,35 @@ impl ServerHandler for MetaServer { } } +/// Present a script's result to the model. +/// +/// Console output and truncation are reported alongside the value rather than +/// folded into it, so a script that logged its way to an answer is readable +/// without the log being mistaken for the answer. +fn render_outcome(outcome: &super::sandbox::Outcome, problems: &[String]) -> String { + let mut text = outcome.json.clone(); + + if let Some(original) = outcome.truncated_from { + text.push_str(&format!( + "\n\n[result truncated from {original} bytes. Return less data \ + — filter or select fields inside the script.]" + )); + } + if !outcome.logs.is_empty() { + text.push_str("\n\nconsole:\n"); + for line in &outcome.logs { + text.push_str(&format!(" {line}\n")); + } + if outcome.logs_dropped { + text.push_str(" [further output dropped]\n"); + } + } + for problem in problems { + text.push_str(&format!("\n[{problem}]")); + } + text +} + fn object_schema(value: Value) -> Map { match value { Value::Object(map) => map, @@ -183,8 +264,11 @@ fn object_schema(value: Value) -> Map { } /// Serve until the client disconnects. -pub async fn serve(catalog: Arc) -> anyhow::Result<()> { - let service = MetaServer::new(Arc::clone(&catalog)) +pub async fn serve( + catalog: Arc, + limits: super::sandbox::Limits, +) -> anyhow::Result<()> { + let service = MetaServer::new(Arc::clone(&catalog), limits) .serve(rmcp::transport::io::stdio()) .await?; let outcome = service.waiting().await; @@ -205,7 +289,7 @@ mod tests { /// advertises. fn test_server(names: &[&str]) -> MetaServer { let catalog = Catalog::new(Vec::new(), RestartPolicy::default(), false); - let mut server = MetaServer::new(Arc::new(catalog)); + let mut server = MetaServer::new(Arc::new(catalog), crate::mcp::sandbox::Limits::default()); server.servers = Arc::new(names.iter().map(|n| n.to_string()).collect()); server } diff --git a/tests/mcp_meta_server.rs b/tests/mcp_meta_server.rs index eef2660f..d1d0a15c 100644 --- a/tests/mcp_meta_server.rs +++ b/tests/mcp_meta_server.rs @@ -124,6 +124,205 @@ async fn unknown_tool_names_the_available_ones() { let _ = client.cancel().await; } +/// A workspace with one real backing MCP server behind a plugin manifest. +struct Workspace { + _dir: tempfile::TempDir, + home: PathBuf, + root: PathBuf, +} + +fn mock_binary() -> PathBuf { + let mut dir = binary(); + dir.pop(); + dir.join("examples").join("mock-mcp-server") +} + +fn workspace_with_backing_server() -> Workspace { + let dir = tempfile::tempdir().expect("temp dir"); + let base = dir.path().to_path_buf(); + let home = base.join("home"); + let root = base.join("ws"); + std::fs::create_dir_all(home.join("plugins/db")).unwrap(); + std::fs::create_dir_all(root.join("src")).unwrap(); + + let mock_config = base.join("mock.json"); + std::fs::write( + &mock_config, + serde_json::json!({ + "name": "sqlx", + "tools": [ + {"name": "query", "description": "Run a SQL query", + "inputSchema": {"type": "object", + "properties": {"sql": {"type": "string"}}, "required": ["sql"]}, + "behavior": {"kind": "echo"}}, + {"name": "migrate-status", "description": "Show migrations", + "behavior": {"kind": "text", "text": "up to date"}} + ] + }) + .to_string(), + ) + .unwrap(); + + std::fs::write( + home.join("config.toml"), + "hook-scope = \"project\"\n[defaults]\nsymposium-recommendations = false\n", + ) + .unwrap(); + std::fs::write( + home.join("plugins/db/SYMPOSIUM.toml"), + format!( + "name = \"db-plugin\"\ndepends-on = [\"*\"]\n\n\ + [[mcp_servers]]\nname = \"sqlx\"\ncommand = {:?}\n\ + args = [\"--config\", {:?}]\nenv = []\n", + mock_binary().display().to_string(), + mock_config.display().to_string(), + ), + ) + .unwrap(); + std::fs::write( + root.join("Cargo.toml"), + "[package]\nname = \"e2e\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\ + [dependencies]\nserde = \"1\"\n", + ) + .unwrap(); + std::fs::write(root.join("src/lib.rs"), "// lib\n").unwrap(); + + Workspace { + _dir: dir, + home, + root, + } +} + +async fn connect_in(workspace: &Workspace) -> rmcp::service::RunningService { + let mut command = tokio::process::Command::new(binary()); + command + .arg("mcp-serve") + .current_dir(&workspace.root) + .env("SYMPOSIUM_HOME", &workspace.home) + .stderr(Stdio::null()); + + let transport = TokioChildProcess::new(command).expect("spawn"); + tokio::time::timeout(Duration::from_secs(60), ().serve(transport)) + .await + .expect("handshake timed out") + .expect("handshake failed") +} + +fn text_of(result: &rmcp::model::CallToolResult) -> String { + result + .content + .iter() + .filter_map(|block| match block { + rmcp::model::ContentBlock::Text(t) => Some(t.text.clone()), + _ => None, + }) + .collect::>() + .join("\n") +} + +/// Tools of a workspace's backing servers, discovered through the plugin +/// registry and a live `tools/list`. +#[tokio::test(flavor = "multi_thread")] +async fn lists_tools_of_a_backing_server() { + let workspace = workspace_with_backing_server(); + let client = connect_in(&workspace).await; + + let result = client + .call_tool(CallToolRequestParams::new("list_tools")) + .await + .expect("list_tools"); + let text = text_of(&result); + + assert!(text.contains("sqlx:"), "got: {text}"); + assert!(text.contains("query"), "got: {text}"); + assert!(text.contains("migrate-status"), "got: {text}"); + let _ = client.cancel().await; +} + +/// Naming a server asks for its signatures, without a second round trip. +#[tokio::test(flavor = "multi_thread")] +async fn naming_a_server_returns_declarations() { + let workspace = workspace_with_backing_server(); + let client = connect_in(&workspace).await; + + let result = client + .call_tool(CallToolRequestParams::new("list_tools").with_arguments( + serde_json::Map::from_iter([("servers".to_string(), serde_json::json!(["sqlx"]))]), + )) + .await + .expect("list_tools"); + let text = text_of(&result); + + assert!(text.contains("declare const sqlx"), "got: {text}"); + assert!(text.contains("Promise"), "got: {text}"); + assert!( + text.contains(r#""migrate-status""#) && text.contains("migrate_status"), + "both spellings should be declared, got: {text}" + ); + let _ = client.cancel().await; +} + +/// The design's whole claim: several tool calls, the filtering between them, +/// and one round trip — with intermediate data never reaching the agent. +#[tokio::test(flavor = "multi_thread")] +async fn a_script_composes_calls_in_one_round_trip() { + let workspace = workspace_with_backing_server(); + let client = connect_in(&workspace).await; + + let script = r#" + const a = await sqlx.query({ sql: "SELECT 1" }); + console.log("intermediate", a); + const b = await sqlx["migrate-status"](); + return { echoed: a.sql, status: b }; + "#; + let result = client + .call_tool(CallToolRequestParams::new("execute").with_arguments( + serde_json::Map::from_iter([("script".to_string(), serde_json::json!(script))]), + )) + .await + .expect("execute"); + let text = text_of(&result); + + assert_ne!(result.is_error, Some(true), "got: {text}"); + assert!(text.contains(r#""echoed":"SELECT 1""#), "got: {text}"); + assert!(text.contains(r#""status":"up to date""#), "got: {text}"); + assert!( + text.contains("console:") && text.contains("intermediate"), + "console output should be reported, got: {text}" + ); + let _ = client.cancel().await; +} + +/// A limit the script exceeded is reported in a form it can act on, rather +/// than as prose it has to interpret. +#[tokio::test(flavor = "multi_thread")] +async fn exceeding_a_limit_reports_a_tagged_error() { + let workspace = workspace_with_backing_server(); + std::fs::write( + workspace.home.join("config.toml"), + "hook-scope = \"project\"\n[defaults]\nsymposium-recommendations = false\n\ + [mcp]\nscript-timeout-secs = 2\ntool-call-timeout-secs = 1\n", + ) + .unwrap(); + let client = connect_in(&workspace).await; + + let result = client + .call_tool(CallToolRequestParams::new("execute").with_arguments( + serde_json::Map::from_iter([( + "script".to_string(), + serde_json::json!("while (true) {}"), + )]), + )) + .await + .expect("execute should answer, not fail"); + let text = text_of(&result); + + assert_eq!(result.is_error, Some(true), "got: {text}"); + assert!(text.contains("script_timeout"), "got: {text}"); + let _ = client.cancel().await; +} + /// The transport is newline-delimited JSON, so anything else written to /// stdout corrupts the stream. Reporting output is the likely offender, since /// every other subcommand sends it there. From 4b24d65139f7f038672d12b889cdb65aca43b22f Mon Sep 17 00:00:00 2001 From: fluzko Date: Wed, 29 Jul 2026 16:45:45 -0300 Subject: [PATCH 20/39] feat(mcp): register the meta-server instead of plugin servers --- src/sync.rs | 44 +++++++++++++++++++++++++++-- tests/init_sync.rs | 69 ++++++++++++++++++++++++++++++++-------------- 2 files changed, 89 insertions(+), 24 deletions(-) diff --git a/src/sync.rs b/src/sync.rs index 628fefd2..02333157 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -330,14 +330,14 @@ pub async fn sync(sym: &Symposium, deps: &mut WorkspaceDeps, update: UpdateLevel // Collect MCP servers from applicable plugins, filtered by workspace deps let dep_ids = crate::pm::CargoPm.list_deps(&workspace); let mut ctx = crate::predicate::PredicateContext::new(&dep_ids); - let mut mcp_servers: Vec = Vec::new(); + let mut plugin_servers: Vec = Vec::new(); for p in ®istry.plugins { if p.applies(&mut ctx) { - mcp_servers.extend(p.plugin.applicable_mcp_servers(&mut ctx)); + plugin_servers.extend(p.plugin.applicable_mcp_servers(&mut ctx)); } } - let server_names: Vec<&str> = mcp_servers + let plugin_server_names: Vec<&str> = plugin_servers .iter() .map(|s| match s { sacp::schema::McpServer::Stdio(s) => s.name.as_str(), @@ -347,6 +347,28 @@ pub async fn sync(sym: &Symposium, deps: &mut WorkspaceDeps, update: UpdateLevel }) .collect(); + // One entry, not one per plugin. The agent then loads two tool schemas + // instead of every plugin server's, and the workspace's own tools stay + // out of `.claude/` and its equivalents. + let mcp_servers = if sym.config.mcp.enabled { + vec![meta_server_entry()] + } else { + plugin_servers.clone() + }; + // Whichever set is not in use has to be removed, or entries written by a + // previous configuration linger in agent config forever. + let stale_names: Vec<&str> = if sym.config.mcp.enabled { + plugin_server_names.clone() + } else { + vec![META_SERVER_NAME] + }; + // Every name this sync could have written, for reaping dropped agents. + let server_names: Vec<&str> = plugin_server_names + .iter() + .copied() + .chain(std::iter::once(META_SERVER_NAME)) + .collect(); + // Sync each configured agent let agent_names: Vec = sym.config.agents.iter().map(|a| a.name.clone()).collect(); @@ -382,6 +404,7 @@ pub async fn sync(sym: &Symposium, deps: &mut WorkspaceDeps, update: UpdateLevel agent .register_hooks(&hook_root, sym, out) .context("failed to register hooks")?; + let _ = agent.unregister_global_mcp_servers(&hook_root, &stale_names, out); agent .register_global_mcp_servers(&hook_root, &mcp_servers, out) .context("failed to register MCP servers")?; @@ -526,6 +549,21 @@ pub async fn sync(sym: &Symposium, deps: &mut WorkspaceDeps, update: UpdateLevel Ok(()) } +/// Name of the single entry written into agent configuration. +pub const META_SERVER_NAME: &str = "symposium"; + +/// The meta-server as an agent configuration entry. +/// +/// Named by command rather than absolute path, matching how hooks are +/// registered: an absolute path breaks the moment the binary is reinstalled +/// somewhere else. +fn meta_server_entry() -> sacp::schema::McpServer { + sacp::schema::McpServer::Stdio( + sacp::schema::McpServerStdio::new(META_SERVER_NAME, "cargo-agents") + .args(vec!["mcp-serve".to_string()]), + ) +} + /// Register global hooks for all configured agents. /// Register hooks for all configured agents. Uses `home_dir` (global scope). /// Called from `init` after writing the user config. diff --git a/tests/init_sync.rs b/tests/init_sync.rs index 357fc5d8..e449d324 100644 --- a/tests/init_sync.rs +++ b/tests/init_sync.rs @@ -411,9 +411,11 @@ async fn add_agent_is_additive() { .unwrap(); } -/// `sync` filters MCP servers by their `depends-on` predicates. +/// One entry is written, not one per plugin: the agent loads two tool +/// schemas rather than every plugin server's, and the workspace's own tools +/// stay out of agent configuration. #[tokio::test] -async fn sync_filters_mcp_servers_by_crates() { +async fn sync_registers_only_the_meta_server() { with_fixture( TestMode::SimulationOnly, &["mcp-filtering0", "workspace0"], @@ -422,29 +424,54 @@ async fn sync_filters_mcp_servers_by_crates() { ctx.symposium(&["sync"]).await?; let workspace_root = ctx.workspace_root.as_ref().unwrap(); - let settings_path = workspace_root.join(".claude/settings.json"); - let settings = std::fs::read_to_string(&settings_path)?; + let settings: serde_json::Value = serde_json::from_str(&std::fs::read_to_string( + workspace_root.join(".claude/settings.json"), + )?)?; + let servers = settings["mcpServers"] + .as_object() + .expect("mcpServers should be an object"); - // always-server (depends-on = ["*"]) → registered - assert!( - settings.contains("always-server"), - "wildcard MCP server should be registered" - ); - // serde-server (depends-on = ["serde"]) → registered (serde is in workspace0) - assert!( - settings.contains("serde-server"), - "serde MCP server should be registered" - ); - // inherited-server (no crates, inherits from plugin) → registered - assert!( - settings.contains("inherited-server"), - "inherited MCP server should be registered" - ); - // missing-crate-server (depends-on = ["reqwest"]) → NOT registered + let names: Vec<&String> = servers.keys().collect(); + assert_eq!(names, vec!["symposium"], "got: {names:?}"); + assert_eq!(servers["symposium"]["args"][0], "mcp-serve"); + + Ok(()) + }, + ) + .await + .unwrap(); +} + +/// Turning the meta-server off restores per-plugin registration, still +/// filtered by `depends-on`. +#[tokio::test] +async fn sync_registers_plugin_servers_when_the_meta_server_is_disabled() { + with_fixture( + TestMode::SimulationOnly, + &["mcp-filtering0", "workspace0"], + async |mut ctx| { + let config = ctx.sym.config_dir().join("config.toml"); + let existing = std::fs::read_to_string(&config)?; + std::fs::write(&config, format!("{existing}\n[mcp]\nenabled = false\n"))?; + ctx.sym = symposium::config::Symposium::from_dir(ctx.sym.config_dir()); + + ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["sync"]).await?; + + let workspace_root = ctx.workspace_root.as_ref().unwrap(); + let settings = std::fs::read_to_string(workspace_root.join(".claude/settings.json"))?; + + // depends-on = ["*"], and ["serde"] which workspace0 provides. + assert!(settings.contains("always-server"), "got: {settings}"); + assert!(settings.contains("serde-server"), "got: {settings}"); + // Inherits the plugin's own predicate. + assert!(settings.contains("inherited-server"), "got: {settings}"); + // depends-on = ["reqwest"], which the workspace does not have. assert!( !settings.contains("missing-crate-server"), - "reqwest MCP server should NOT be registered" + "got: {settings}" ); + assert!(!settings.contains("\"symposium\""), "got: {settings}"); Ok(()) }, From f7dca0bb1f54fcc140dc6a78e69648fe2d2dfdd5 Mon Sep 17 00:00:00 2001 From: fluzko Date: Thu, 30 Jul 2026 11:15:39 -0300 Subject: [PATCH 21/39] refactor(mcp): own the [[mcp_servers]] manifest shape --- src/mcp/client.rs | 8 +- src/mcp/resolve.rs | 49 +- src/mcp/supervisor.rs | 22 + src/plugins.rs | 498 +++++++++++++++--- src/sync.rs | 2 +- .../plugins/mcp-plugin/SYMPOSIUM.toml | 4 - tests/mcp_meta_server.rs | 2 +- 7 files changed, 475 insertions(+), 110 deletions(-) diff --git a/src/mcp/client.rs b/src/mcp/client.rs index 47840f1a..398883d8 100644 --- a/src/mcp/client.rs +++ b/src/mcp/client.rs @@ -29,6 +29,9 @@ pub struct SpawnSpec { pub command: PathBuf, pub args: Vec, pub env: Vec<(String, String)>, + /// Directory to run in. A server that reads the project it serves needs + /// this; four of the seven client config formats have it. + pub cwd: Option, pub startup_timeout: Duration, } @@ -93,6 +96,9 @@ impl BackingServer { for (key, value) in &spec.env { command.env(key, value); } + if let Some(cwd) = &spec.cwd { + command.current_dir(cwd); + } // Stderr is captured from spawn rather than after the handshake: // when startup fails, its tail is the only account of why. @@ -169,7 +175,7 @@ impl BackingServer { let params = CallToolRequestParams::new(tool.to_string()); let params = match args { Value::Object(map) => params.with_arguments(map), - Value::Null => params, + Value::Null => params.with_arguments(Map::new()), // A non-object argument has no place in the wire form. other => params.with_arguments(Map::from_iter([("value".to_string(), other)])), }; diff --git a/src/mcp/resolve.rs b/src/mcp/resolve.rs index 5293f11e..ca4432cf 100644 --- a/src/mcp/resolve.rs +++ b/src/mcp/resolve.rs @@ -11,7 +11,7 @@ use std::path::Path; use std::time::Duration; -use sacp::schema::McpServer; +use crate::plugins::{McpTransport, StdioCommand}; use crate::config::Symposium; use crate::mcp::client::SpawnSpec; @@ -99,7 +99,7 @@ fn build( let mut claimed: Vec<(String, String)> = Vec::new(); for (entry, owner) in entries { - let name = server_name(&entry.server).to_string(); + let name = entry.name.clone(); // The meta-server's own tools live in the same namespace as the // servers it exposes; a backing server taking one would shadow it. @@ -121,25 +121,35 @@ fn build( continue; } - let McpServer::Stdio(stdio) = &entry.server else { + let McpTransport::Stdio(stdio) = &entry.transport else { resolution.rejected.push(Rejection { server: name, reason: "only stdio servers are supported".to_string(), }); continue; }; + let StdioCommand::Path(command) = &stdio.command else { + // Resolving an installation needs the acquire pipeline, which the + // caller runs before building specs. + resolution.rejected.push(Rejection { + server: name, + reason: "installation-backed servers are resolved before this point".to_string(), + }); + continue; + }; claimed.push((name.clone(), owner)); resolution.servers.push(ResolvedServer { spec: SpawnSpec { name: name.clone(), - command: stdio.command.clone(), + command: command.clone(), args: stdio.args.clone(), env: stdio .env .iter() - .map(|v| (v.name.clone(), v.value.clone())) + .map(|(name, value)| (name.clone(), value.clone())) .collect(), + cwd: stdio.cwd.clone(), startup_timeout: Duration::from_secs( entry.overrides.startup_timeout_secs.unwrap_or(30), ), @@ -168,26 +178,23 @@ fn call_timeout(overrides: &McpServerOverrides, script_timeout_secs: u64) -> Dur Duration::from_secs(requested.min(ceiling)) } -fn server_name(server: &McpServer) -> &str { - match server { - McpServer::Stdio(s) => &s.name, - McpServer::Http(s) => &s.name, - McpServer::Sse(s) => &s.name, - _ => "", - } -} - #[cfg(test)] mod tests { use super::*; use crate::plugins::PluginMcpServer; - use sacp::schema::McpServerStdio; fn stdio(name: &str) -> PluginMcpServer { PluginMcpServer { + name: name.to_string(), predicates: Default::default(), overrides: McpServerOverrides::default(), - server: McpServer::Stdio(McpServerStdio::new(name, "/usr/bin/true")), + transport: McpTransport::Stdio(crate::plugins::StdioServer { + command: StdioCommand::Path("/usr/bin/true".into()), + args: Vec::new(), + env: Default::default(), + cwd: None, + }), + requirements: Vec::new(), } } @@ -216,12 +223,14 @@ mod tests { #[test] fn http_servers_are_refused_with_a_reason() { let entry = PluginMcpServer { + name: "remote".to_string(), predicates: Default::default(), overrides: McpServerOverrides::default(), - server: McpServer::Http(sacp::schema::McpServerHttp::new( - "remote", - "http://localhost:8080/mcp", - )), + transport: McpTransport::Http(crate::plugins::RemoteServer { + url: "http://localhost:8080/mcp".to_string(), + headers: Default::default(), + }), + requirements: Vec::new(), }; let out = resolve_all(vec![(&entry, "p")], 120); diff --git a/src/mcp/supervisor.rs b/src/mcp/supervisor.rs index 60310ca4..b23b17f5 100644 --- a/src/mcp/supervisor.rs +++ b/src/mcp/supervisor.rs @@ -254,6 +254,7 @@ mod tests { command: mock_binary(), args: vec!["--config".to_string(), fixture.config.display().to_string()], env: Vec::new(), + cwd: None, startup_timeout, } } @@ -383,6 +384,7 @@ mod tests { command: "true".into(), args: vec![], env: vec![], + cwd: None, startup_timeout: Duration::from_secs(1), }, RestartPolicy { @@ -436,6 +438,26 @@ mod tests { sup.shutdown().await; } + /// A tool with no parameters must still be sent an object. The spec makes + /// `arguments` optional, but zod-based servers reject an absent field, and + /// those are a large share of what is published. + #[tokio::test(flavor = "multi_thread")] + async fn a_tool_taking_no_arguments_is_sent_an_empty_object() { + let f = fixture(json!({ + "name": "mock", + "tools": [{"name": "ping", "behavior": {"kind": "echo"}}] + })); + let mut sup = Supervisor::new(spec(&f, Duration::from_secs(10)), fast_policy()); + + // `echo` returns whatever arguments it received. + let out = sup + .call("ping", Value::Null, Duration::from_secs(5)) + .await + .unwrap(); + assert_eq!(out, json!({}), "an object, not null"); + sup.shutdown().await; + } + #[tokio::test(flavor = "multi_thread")] async fn a_hanging_tool_times_out() { let f = fixture(json!({ diff --git a/src/plugins.rs b/src/plugins.rs index ce3427d5..b74d42ba 100644 --- a/src/plugins.rs +++ b/src/plugins.rs @@ -12,17 +12,22 @@ use crate::skills::skill_origin_hash; use symposium_install::Source; use sacp::schema::McpServer; +use std::collections::BTreeMap; /// An MCP server entry in a plugin manifest. -pub type McpServerEntry = McpServer; - -/// An MCP server entry with optional activation predicates. /// -/// The server's `depends-on` and `predicates` fields are merged into one -/// [`PredicateSet`](crate::predicate::PredicateSet); the server is only -/// registered when that set holds (ANDed with the plugin-level set). +/// Deliberately our own type rather than ACP's `McpServer`. That is a wire +/// type for ACP session setup, and using it as a manifest schema forced +/// authors to write `args = []` and `env = []` on every entry — neither field +/// carries a serde default, and because the type was flattened, omitting one +/// surfaced as `data did not match any variant of untagged enum McpServer` +/// rather than naming the field. No MCP client in the ecosystem requires +/// either. #[derive(Debug, Clone, Serialize)] pub struct PluginMcpServer { + /// Name the server is known by, both to a script and in agent config. + pub name: String, + #[serde( default, skip_serializing_if = "crate::predicate::PredicateSet::is_empty" @@ -33,8 +38,105 @@ pub struct PluginMcpServer { #[serde(flatten)] pub overrides: McpServerOverrides, - #[serde(flatten)] - pub server: McpServerEntry, + pub transport: McpTransport, + + /// Installations to acquire before this server is first started. + /// + /// The way a package-runner server pre-fetches: an installation carrying + /// only `install_commands` warms a cache without the download landing on + /// the first tool call. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub requirements: Vec, +} + +/// How to reach a server. +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum McpTransport { + Stdio(StdioServer), + Http(RemoteServer), + Sse(RemoteServer), +} + +/// A server run as a child process. +#[derive(Debug, Clone, Serialize)] +pub struct StdioServer { + pub command: StdioCommand, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub args: Vec, + /// Environment for the child. + /// + /// A map, matching every MCP client's config format — and the form we have + /// to write into agent configuration anyway. Ordered so written output is + /// deterministic. + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub env: BTreeMap, + /// Directory to run in, resolved against the workspace root. + #[serde(skip_serializing_if = "Option::is_none")] + pub cwd: Option, +} + +/// Where a stdio server's executable comes from. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum StdioCommand { + /// A command resolved through `PATH`, or an absolute path. + Path(PathBuf), + /// The name of an `[[installations]]` entry, acquired before first use. + Installation(String), +} + +/// A server reached over HTTP. +#[derive(Debug, Clone, Serialize)] +pub struct RemoteServer { + pub url: String, + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub headers: BTreeMap, +} + +impl PluginMcpServer { + /// The entry as ACP models it, for writing into agent configuration. + /// + /// One direction only: ACP's shape is an output format here, not our + /// source of truth. + pub fn to_acp_entry(&self) -> McpServer { + match &self.transport { + McpTransport::Stdio(stdio) => McpServer::Stdio( + sacp::schema::McpServerStdio::new( + self.name.clone(), + match &stdio.command { + StdioCommand::Path(path) => path.clone(), + // An unacquired installation has no path yet; the name + // is the best available placeholder. + StdioCommand::Installation(name) => PathBuf::from(name), + }, + ) + .args(stdio.args.clone()) + .env( + stdio + .env + .iter() + .map(|(name, value)| sacp::schema::EnvVariable::new(name, value)) + .collect(), + ), + ), + McpTransport::Http(remote) => McpServer::Http( + sacp::schema::McpServerHttp::new(self.name.clone(), remote.url.clone()) + .headers(header_list(&remote.headers)), + ), + McpTransport::Sse(remote) => McpServer::Sse( + sacp::schema::McpServerSse::new(self.name.clone(), remote.url.clone()) + .headers(header_list(&remote.headers)), + ), + } + } +} + +fn header_list(headers: &BTreeMap) -> Vec { + headers + .iter() + .map(|(name, value)| sacp::schema::HttpHeader::new(name, value)) + .collect() } /// Per-server settings a plugin author may set, overriding the user's `[mcp]` @@ -97,8 +199,16 @@ impl McpServerOverrides { } } +/// The manifest form: what a plugin author writes. +/// +/// `args`, `env`, `headers` and `cwd` all default, so the minimum entry is a +/// name and a command. `deny_unknown_fields` means a typo names the key +/// instead of being swallowed. #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct RawPluginMcpServer { + name: String, + #[serde(default, rename = "depends-on")] depends_on: Option, /// Rejected: renamed to `depends-on`. @@ -107,9 +217,6 @@ struct RawPluginMcpServer { #[serde(default)] predicates: crate::predicate::PredicateSet, - // Override fields are named siblings rather than a second `flatten`: - // two flattened fields make serde hand the same residual map to both, and - // the untagged `McpServer` enum cannot deserialize from it. #[serde(default, rename = "startup-timeout-secs")] startup_timeout_secs: Option, #[serde(default, rename = "tool-call-timeout-secs")] @@ -119,38 +226,107 @@ struct RawPluginMcpServer { #[serde(default, rename = "disabled-tools")] disabled_tools: Option>, - #[serde(flatten)] - server: McpServerEntry, + /// Executable to run, resolved through `PATH` unless absolute. + #[serde(default)] + command: Option, + /// An `[[installations]]` entry providing the executable. Acquired before + /// the server is first started. + #[serde(default)] + installation: Option, + #[serde(default)] + args: Vec, + #[serde(default)] + env: BTreeMap, + #[serde(default)] + cwd: Option, + + /// Reached over HTTP instead of as a child process. + #[serde(default)] + url: Option, + /// `http` (the default for a `url`) or `sse`. + #[serde(default)] + transport: Option, + #[serde(default)] + headers: BTreeMap, + + /// Installations to acquire before first use. + #[serde(default)] + requirements: Vec, } impl RawPluginMcpServer { fn validate(self) -> Result { reject_crates_field(&self.crates)?; + let name = self.name; + let overrides = McpServerOverrides { startup_timeout_secs: self.startup_timeout_secs, tool_call_timeout_secs: self.tool_call_timeout_secs, enabled_tools: self.enabled_tools, disabled_tools: self.disabled_tools, }; - overrides.validate(server_name(&self.server))?; + overrides.validate(&name)?; + + let is_remote = self.url.is_some(); + let is_local = self.command.is_some() || self.installation.is_some(); + if is_remote && is_local { + bail!( + "mcp server `{name}` sets both `url` and a local command; \ + a server is reached one way or the other" + ); + } + + let transport = if let Some(url) = self.url { + if !self.args.is_empty() || !self.env.is_empty() || self.cwd.is_some() { + bail!( + "mcp server `{name}` sets `args`, `env` or `cwd` alongside `url`; \ + those apply only to a server run as a child process" + ); + } + let remote = RemoteServer { + url, + headers: self.headers, + }; + match self.transport.as_deref() { + None | Some("http") => McpTransport::Http(remote), + Some("sse") => McpTransport::Sse(remote), + Some(other) => bail!( + "mcp server `{name}` has unknown transport `{other}`; expected `http` or `sse`" + ), + } + } else { + if !self.headers.is_empty() { + bail!("mcp server `{name}` sets `headers` without a `url`"); + } + let command = match (self.command, self.installation) { + (Some(_), Some(_)) => bail!( + "mcp server `{name}` sets both `command` and `installation`; \ + use one or the other" + ), + (Some(path), None) => StdioCommand::Path(path), + (None, Some(installation)) => StdioCommand::Installation(installation), + (None, None) => { + bail!("mcp server `{name}` needs a `command`, an `installation`, or a `url`") + } + }; + McpTransport::Stdio(StdioServer { + command, + args: self.args, + env: self.env, + cwd: self.cwd, + }) + }; + Ok(PluginMcpServer { + name, predicates: crate::predicate::PredicateSet::merged(self.depends_on, self.predicates), overrides, - server: self.server, + transport, + requirements: self.requirements, }) } } -/// Name of an MCP server entry, whatever its transport. -fn server_name(server: &McpServerEntry) -> &str { - match server { - McpServer::Stdio(s) => &s.name, - McpServer::Http(s) => &s.name, - McpServer::Sse(s) => &s.name, - _ => "", - } -} - /// Shared rejection for the retired `crates` field, with a migration hint. fn reject_crates_field(crates: &Option) -> Result<()> { if crates.is_some() { @@ -617,10 +793,10 @@ impl Plugin { pub fn applicable_mcp_servers( &self, ctx: &mut crate::predicate::PredicateContext, - ) -> Vec { + ) -> Vec { self.applicable_mcp_entries(ctx) .into_iter() - .map(|s| s.server.clone()) + .map(PluginMcpServer::to_acp_entry) .collect() } @@ -1483,8 +1659,15 @@ fn load_registry_impl( Err(e) => { tracing::warn!(error = %e, "failed to load plugin"); warnings.push(LoadWarning { - path: dir.join(".toml"), - message: format!("failed to load plugin: {e}"), + // The source directory, not a fabricated file + // name: the failing path is already in the + // error's context. + path: dir.clone(), + // `{e:#}` walks the whole chain. Plain `{e}` + // printed only the outermost context — which + // named the file and nothing about what was + // wrong with it. + message: format!("failed to load plugin: {e:#}"), }); } } @@ -2749,7 +2932,6 @@ mod tests { name = "server" command = "/usr/bin/true" args = ["--stdio"] - env = [] crates = ["serde"] "#}, ] { @@ -3435,6 +3617,42 @@ mod tests { ); } + /// A failed manifest has to say what was wrong with it. The outermost + /// context names the file; the cause is what an author can act on. + #[test] + fn manifest_load_errors_carry_their_cause() { + use crate::test_utils::{File, instantiate_fixture}; + let tmp = instantiate_fixture(&[File( + "broken/SYMPOSIUM.toml", + indoc! {r#" + name = "broken" + depends-on = ["*"] + + [[mcp_servers]] + name = "s" + command = "x" + startup_timeout_secs = 30 + "#}, + )]); + + let contents = scan_source_dir(tmp.path(), "test").expect("scan"); + let err = contents + .plugins + .into_iter() + .find_map(Result::err) + .expect("the manifest should have failed"); + + let chained = format!("{err:#}"); + assert!( + chained.contains("SYMPOSIUM.toml"), + "should name the file, got: {chained}" + ); + assert!( + chained.contains("startup_timeout_secs"), + "should name the offending key, got: {chained}" + ); + } + #[test] fn parse_manifest_with_no_mcp_servers() { let plugin = from_str(SAMPLE).expect("parse"); @@ -3450,8 +3668,6 @@ mod tests { [[mcp_servers]] name = "sqlx" command = "/usr/bin/true" - args = [] - env = [] "#}) .expect("parse"); assert_eq!( @@ -3471,8 +3687,6 @@ mod tests { [[mcp_servers]] name = "sqlx" command = "/usr/bin/true" - args = [] - env = [] startup-timeout-secs = 45 tool-call-timeout-secs = 90 enabled-tools = ["query", "explain"] @@ -3499,8 +3713,6 @@ mod tests { [[mcp_servers]] name = "sqlx" command = "/usr/bin/true" - args = [] - env = [] enabled-tools = [] "#}) .expect("parse"); @@ -3516,8 +3728,6 @@ mod tests { [[mcp_servers]] name = "sqlx" command = "/usr/bin/true" - args = [] - env = [] enabled-tools = ["query"] disabled-tools = ["drop"] "#}) @@ -3535,10 +3745,8 @@ mod tests { depends-on = ["*"] [[mcp_servers]] - type = "http" name = "remote" url = "http://localhost:8080/mcp" - headers = [] tool-call-timeout-secs = 15 "#}) .expect("parse"); @@ -3548,70 +3756,194 @@ mod tests { ); } + fn mcp_entry(toml_src: &str) -> Result { + toml::from_str::(toml_src)?.validate() + } + + /// The minimum an author should have to write. Previously this needed + /// `args = []` and `env = []` as well, because the manifest flattened a + /// wire type whose fields carry no serde defaults. #[test] - fn mcp_entry_stdio() { - let entry: McpServerEntry = toml::from_str(indoc! {r#" - name = "my-server" - command = "/usr/local/bin/my-server" + fn mcp_entry_needs_only_a_name_and_command() { + let entry = mcp_entry(indoc! {r#" + name = "sqlx" + command = "sqlx-mcp" + "#}) + .expect("parse"); + + assert_eq!(entry.name, "sqlx"); + let McpTransport::Stdio(stdio) = &entry.transport else { + panic!("expected stdio, got {:#?}", entry.transport); + }; + assert_eq!(stdio.command, StdioCommand::Path("sqlx-mcp".into())); + assert!(stdio.args.is_empty()); + assert!(stdio.env.is_empty()); + assert_eq!(stdio.cwd, None); + } + + /// A map, matching every MCP client's config format, and written as an + /// inline table so it stays visibly per-server. + #[test] + fn mcp_entry_reads_env_as_a_map() { + let entry = mcp_entry(indoc! {r#" + name = "sqlx" + command = "sqlx-mcp" args = ["--stdio"] - env = [] + env = { RUST_LOG = "debug", API_KEY = "x" } + cwd = "crates/db" "#}) .expect("parse"); - expect_test::expect![[r#" - Stdio( - McpServerStdio { - name: "my-server", - command: "/usr/local/bin/my-server", - args: [ - "--stdio", - ], - env: [], - meta: None, - }, - )"#]] - .assert_eq(&format!("{entry:#?}")); + + let McpTransport::Stdio(stdio) = &entry.transport else { + panic!("expected stdio"); + }; + assert_eq!(stdio.args, vec!["--stdio".to_string()]); + assert_eq!(stdio.env.get("RUST_LOG").map(String::as_str), Some("debug")); + assert_eq!(stdio.env.get("API_KEY").map(String::as_str), Some("x")); + assert_eq!(stdio.cwd, Some("crates/db".into())); } #[test] - fn mcp_entry_http() { - let entry: McpServerEntry = toml::from_str(indoc! {r#" - type = "http" - name = "my-server" + fn mcp_entry_accepts_an_installation_instead_of_a_command() { + let entry = mcp_entry(indoc! {r#" + name = "sqlx" + installation = "sqlx-mcp" + "#}) + .expect("parse"); + + let McpTransport::Stdio(stdio) = &entry.transport else { + panic!("expected stdio"); + }; + assert_eq!( + stdio.command, + StdioCommand::Installation("sqlx-mcp".to_string()) + ); + } + + #[test] + fn mcp_entry_rejects_both_command_and_installation() { + let err = mcp_entry(indoc! {r#" + name = "sqlx" + command = "sqlx-mcp" + installation = "sqlx-mcp" + "#}) + .expect_err("both should be rejected"); + assert!(err.to_string().contains("one or the other"), "got: {err}"); + } + + #[test] + fn mcp_entry_needs_some_way_to_reach_the_server() { + let err = mcp_entry(indoc! {r#" + name = "sqlx" + "#}) + .expect_err("no transport should be rejected"); + assert!(err.to_string().contains("needs a `command`"), "got: {err}"); + } + + /// A typo should name the key, not report a mismatch on a type the author + /// never mentioned. + #[test] + fn mcp_entry_rejects_an_unknown_key_by_name() { + let err = toml::from_str::(indoc! {r#" + name = "sqlx" + command = "sqlx-mcp" + startup_timeout_secs = 30 + "#}) + .expect_err("misspelled key should not parse"); + assert!( + err.to_string().contains("startup_timeout_secs"), + "got: {err}" + ); + } + + // -- remote transports -- + + #[test] + fn mcp_entry_defaults_a_url_to_http() { + let entry = mcp_entry(indoc! {r#" + name = "remote" url = "http://localhost:8080/mcp" - headers = [] + headers = { Authorization = "Bearer x" } "#}) .expect("parse"); - expect_test::expect![[r#" - Http( - McpServerHttp { - name: "my-server", - url: "http://localhost:8080/mcp", - headers: [], - meta: None, - }, - )"#]] - .assert_eq(&format!("{entry:#?}")); + + let McpTransport::Http(remote) = &entry.transport else { + panic!("expected http, got {:#?}", entry.transport); + }; + assert_eq!(remote.url, "http://localhost:8080/mcp"); + assert_eq!( + remote.headers.get("Authorization").map(String::as_str), + Some("Bearer x") + ); } #[test] - fn mcp_entry_sse() { - let entry: McpServerEntry = toml::from_str(indoc! {r#" - type = "sse" - name = "my-server" + fn mcp_entry_accepts_sse_transport() { + let entry = mcp_entry(indoc! {r#" + name = "remote" url = "http://localhost:8080/sse" - headers = [] + transport = "sse" + "#}) + .expect("parse"); + assert!(matches!(entry.transport, McpTransport::Sse(_))); + } + + #[test] + fn mcp_entry_rejects_child_process_fields_on_a_url() { + let err = mcp_entry(indoc! {r#" + name = "remote" + url = "http://localhost:8080/mcp" + args = ["--stdio"] + "#}) + .expect_err("args make no sense for a url"); + assert!(err.to_string().contains("child process"), "got: {err}"); + } + + #[test] + fn mcp_entry_rejects_a_url_and_a_command_together() { + let err = mcp_entry(indoc! {r#" + name = "remote" + url = "http://localhost:8080/mcp" + command = "thing" + "#}) + .expect_err("one way or the other"); + assert!( + err.to_string().contains("one way or the other"), + "got: {err}" + ); + } + + /// Agent configuration is still written in ACP's shape, so the + /// conversion out of our own type has to be exercised. + #[test] + fn mcp_entry_converts_to_the_acp_shape() { + let entry = mcp_entry(indoc! {r#" + name = "my-server" + command = "/usr/local/bin/my-server" + args = ["--stdio"] + env = { API_KEY = "x" } "#}) .expect("parse"); + expect_test::expect![[r#" - Sse( - McpServerSse { + Stdio( + McpServerStdio { name: "my-server", - url: "http://localhost:8080/sse", - headers: [], + command: "/usr/local/bin/my-server", + args: [ + "--stdio", + ], + env: [ + EnvVariable { + name: "API_KEY", + value: "x", + meta: None, + }, + ], meta: None, }, )"#]] - .assert_eq(&format!("{entry:#?}")); + .assert_eq(&format!("{:#?}", entry.to_acp_entry())); } /// Cargo-installed binary referenced by name as the hook's command. diff --git a/src/sync.rs b/src/sync.rs index 02333157..6dac7a85 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -572,7 +572,7 @@ pub fn register_hooks(sym: &Symposium, out: &Output) -> Result<()> { let mcp_servers: Vec = registry .plugins .iter() - .flat_map(|p| p.plugin.mcp_servers.iter().map(|s| s.server.clone())) + .flat_map(|p| p.plugin.mcp_servers.iter().map(|s| s.to_acp_entry())) .collect(); let server_names: Vec<&str> = mcp_servers diff --git a/tests/fixtures/mcp-filtering0/dot-symposium/plugins/mcp-plugin/SYMPOSIUM.toml b/tests/fixtures/mcp-filtering0/dot-symposium/plugins/mcp-plugin/SYMPOSIUM.toml index 1217c3e8..a35952a8 100644 --- a/tests/fixtures/mcp-filtering0/dot-symposium/plugins/mcp-plugin/SYMPOSIUM.toml +++ b/tests/fixtures/mcp-filtering0/dot-symposium/plugins/mcp-plugin/SYMPOSIUM.toml @@ -6,24 +6,20 @@ name = "always-server" depends-on = ["*"] command = "/usr/bin/true" args = ["--stdio"] -env = [] [[mcp_servers]] name = "serde-server" depends-on = ["serde"] command = "/usr/bin/true" args = ["--stdio"] -env = [] [[mcp_servers]] name = "missing-crate-server" depends-on = ["reqwest"] command = "/usr/bin/true" args = ["--stdio"] -env = [] [[mcp_servers]] name = "inherited-server" command = "/usr/bin/true" args = ["--stdio"] -env = [] diff --git a/tests/mcp_meta_server.rs b/tests/mcp_meta_server.rs index d1d0a15c..817f6886 100644 --- a/tests/mcp_meta_server.rs +++ b/tests/mcp_meta_server.rs @@ -173,7 +173,7 @@ fn workspace_with_backing_server() -> Workspace { format!( "name = \"db-plugin\"\ndepends-on = [\"*\"]\n\n\ [[mcp_servers]]\nname = \"sqlx\"\ncommand = {:?}\n\ - args = [\"--config\", {:?}]\nenv = []\n", + args = [\"--config\", {:?}]\n", mock_binary().display().to_string(), mock_config.display().to_string(), ), From 9a47ddc6c8a2281e7d135a7a7c5a0f84ddec98db Mon Sep 17 00:00:00 2001 From: fluzko Date: Thu, 30 Jul 2026 11:22:09 -0300 Subject: [PATCH 22/39] fix(mcp): write env and headers as objects --- src/agents/mcp_server_registration.rs | 41 +++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/src/agents/mcp_server_registration.rs b/src/agents/mcp_server_registration.rs index 7eee1f61..fc919022 100644 --- a/src/agents/mcp_server_registration.rs +++ b/src/agents/mcp_server_registration.rs @@ -39,6 +39,15 @@ fn server_name(server: &McpServer) -> &str { /// Http/Sse: `{"url": "...", "headers": [...]}` /// /// `env` and `headers` are omitted when empty. +/// Render name/value pairs as a JSON object. +fn pairs_to_object<'a>(pairs: impl Iterator) -> serde_json::Value { + serde_json::Value::Object( + pairs + .map(|(name, value)| (name.clone(), serde_json::Value::String(value.clone()))) + .collect(), + ) +} + fn server_to_json(server: &McpServer) -> serde_json::Value { match server { McpServer::Stdio(s) => { @@ -47,21 +56,24 @@ fn server_to_json(server: &McpServer) -> serde_json::Value { "args": s.args, }); if !s.env.is_empty() { - v["env"] = serde_json::to_value(&s.env).unwrap(); + // A map, not ACP's `[{name, value}]`. Every MCP client reads + // `env` as an object, so serializing the wire type directly + // produced config none of them could use. + v["env"] = pairs_to_object(s.env.iter().map(|e| (&e.name, &e.value))); } v } McpServer::Http(s) => { let mut v = json!({ "url": s.url }); if !s.headers.is_empty() { - v["headers"] = serde_json::to_value(&s.headers).unwrap(); + v["headers"] = pairs_to_object(s.headers.iter().map(|h| (&h.name, &h.value))); } v } McpServer::Sse(s) => { let mut v = json!({ "url": s.url }); if !s.headers.is_empty() { - v["headers"] = serde_json::to_value(&s.headers).unwrap(); + v["headers"] = pairs_to_object(s.headers.iter().map(|h| (&h.name, &h.value))); } v } @@ -551,6 +563,29 @@ mod tests { vec!["symposium"] } + /// Every MCP client reads `env` as an object. Serializing ACP's + /// `Vec` directly produced `[{"name":..,"value":..}]`, which + /// none of them understand. Unobservable while the only registered entry + /// carries no env, hence the direct test. + #[test] + fn env_and_headers_are_written_as_objects() { + let stdio = McpServer::Stdio( + McpServerStdio::new("s", "/bin/true") + .env(vec![sacp::schema::EnvVariable::new("API_KEY", "x")]), + ); + assert_eq!(server_to_json(&stdio)["env"], json!({"API_KEY": "x"})); + + let http = McpServer::Http( + sacp::schema::McpServerHttp::new("s", "http://localhost/mcp").headers(vec![ + sacp::schema::HttpHeader::new("Authorization", "Bearer x"), + ]), + ); + assert_eq!( + server_to_json(&http)["headers"], + json!({"Authorization": "Bearer x"}) + ); + } + // -- Claude MCP (also covers Gemini and Kiro via delegation) -- #[test] From 923ba13e2cb8f7a053657476a695cc5e85f1c03a Mon Sep 17 00:00:00 2001 From: fluzko Date: Thu, 30 Jul 2026 11:32:15 -0300 Subject: [PATCH 23/39] feat(mcp): acquire installation-backed servers on first use --- src/bin/cargo-agents.rs | 1 + src/mcp/catalog.rs | 82 +++++++--- src/mcp/resolve.rs | 323 +++++++++++++++++++++++++++++++++++----- src/mcp/server.rs | 4 +- 4 files changed, 353 insertions(+), 57 deletions(-) diff --git a/src/bin/cargo-agents.rs b/src/bin/cargo-agents.rs index 769ad13a..15c71198 100644 --- a/src/bin/cargo-agents.rs +++ b/src/bin/cargo-agents.rs @@ -165,6 +165,7 @@ async fn main() -> ExitCode { ); } let catalog = std::sync::Arc::new(symposium::mcp::catalog::Catalog::new( + std::sync::Arc::new(sym.clone()), resolution.servers, symposium::mcp::supervisor::RestartPolicy { max_restarts: sym.config.mcp.max_server_restarts, diff --git a/src/mcp/catalog.rs b/src/mcp/catalog.rs index 93db9375..b2b9a1a8 100644 --- a/src/mcp/catalog.rs +++ b/src/mcp/catalog.rs @@ -11,8 +11,11 @@ //! servers it needs. Cold start is paid at first disclosure rather than at //! session start. +use std::sync::Arc; use std::time::Duration; +use crate::config::Symposium; + use rmcp::model::Tool; use serde_json::Value; use tokio::sync::Mutex; @@ -99,32 +102,62 @@ impl Query { pub struct Catalog { entries: Vec, read_only: bool, + policy: RestartPolicy, + /// Needed to acquire an installation-backed server on first use. + sym: Arc, /// Filter entries the caller named that match no server. known_names: Vec, } struct Entry { resolved: ResolvedServer, - supervisor: Mutex, + /// Absent until first use. Building it may acquire an installation, which + /// must not happen at startup — a client may spawn a throwaway copy of the + /// meta-server just to probe it. + supervisor: Mutex>, } impl Catalog { - pub fn new(servers: Vec, policy: RestartPolicy, read_only: bool) -> Self { - let known_names = servers.iter().map(|s| s.name().to_string()).collect(); + pub fn new( + sym: Arc, + servers: Vec, + policy: RestartPolicy, + read_only: bool, + ) -> Self { + let known_names = servers.iter().map(|s| s.name.clone()).collect(); let entries = servers .into_iter() .map(|resolved| Entry { - supervisor: Mutex::new(Supervisor::new(resolved.spec.clone(), policy)), + supervisor: Mutex::new(None), resolved, }) .collect(); Self { entries, read_only, + policy, + sym, known_names, } } + /// The supervisor for an entry, acquiring what it needs the first time. + async fn supervisor_for<'a>( + &self, + entry: &'a Entry, + ) -> Result>, String> { + let mut guard = entry.supervisor.lock().await; + if guard.is_none() { + let spec = entry + .resolved + .spawn_spec(&self.sym) + .await + .map_err(|e| format!("{e:#}"))?; + *guard = Some(Supervisor::new(spec, self.policy)); + } + Ok(guard) + } + pub fn server_names(&self) -> Vec { self.known_names.clone() } @@ -143,20 +176,23 @@ impl Catalog { let mut problems = Vec::new(); for entry in &self.entries { - if !query.wants_server(entry.resolved.name()) { + if !query.wants_server(entry.resolved.name.as_str()) { continue; } - let tools = { - let mut supervisor = entry.supervisor.lock().await; - supervisor.list_tools().await + let tools = match self.supervisor_for(entry).await { + Ok(mut guard) => match guard.as_mut() { + Some(supervisor) => supervisor.list_tools().await.map_err(|e| e.to_string()), + None => unreachable!("supervisor_for leaves it present"), + }, + Err(e) => Err(e), }; let tools = match tools { Ok(tools) => tools, Err(e) => { // A server that will not start is reported in place, so // the absence of its tools has a visible reason. - problems.push(format!("{}: {e}", entry.resolved.name())); + problems.push(format!("{}: {e}", entry.resolved.name.as_str())); continue; } }; @@ -173,7 +209,7 @@ impl Catalog { if !tools.is_empty() && !query_narrows(query) { problems.push(format!( "{}: no tools visible ({} hidden by filters)", - entry.resolved.name(), + entry.resolved.name.as_str(), tools.len() )); } @@ -181,7 +217,7 @@ impl Catalog { } sections.push(render( - entry.resolved.name(), + entry.resolved.name.as_str(), &visible, query.effective_detail(), )); @@ -226,14 +262,17 @@ impl Catalog { let mut problems = Vec::new(); for entry in &self.entries { - let tools = { - let mut supervisor = entry.supervisor.lock().await; - supervisor.list_tools().await + let tools = match self.supervisor_for(entry).await { + Ok(mut guard) => match guard.as_mut() { + Some(supervisor) => supervisor.list_tools().await.map_err(|e| e.to_string()), + None => unreachable!("supervisor_for leaves it present"), + }, + Err(e) => Err(e), }; let tools = match tools { Ok(tools) => tools, Err(e) => { - problems.push(format!("{}: {e}", entry.resolved.name())); + problems.push(format!("{}: {e}", entry.resolved.name.as_str())); continue; } }; @@ -258,8 +297,8 @@ impl Catalog { continue; } namespaces.push(Namespace { - key: namespace_key(entry.resolved.name()), - server: entry.resolved.name().to_string(), + key: namespace_key(entry.resolved.name.as_str()), + server: entry.resolved.name.as_str().to_string(), bindings, }); } @@ -269,7 +308,7 @@ impl Catalog { /// Call a tool on a backing server, honoring its filters. pub async fn call(&self, server: &str, tool: &str, args: Value) -> Result { - let Some(entry) = self.entries.iter().find(|e| e.resolved.name() == server) else { + let Some(entry) = self.entries.iter().find(|e| e.resolved.name == server) else { return Err(format!( "no server named `{server}`. Available: {}", self.known_names.join(", ") @@ -282,7 +321,8 @@ impl Catalog { } let timeout = entry.resolved.tool_call_timeout; - let mut supervisor = entry.supervisor.lock().await; + let mut guard = self.supervisor_for(entry).await?; + let supervisor = guard.as_mut().expect("supervisor_for leaves it present"); supervisor .call(tool, args, timeout) .await @@ -292,7 +332,9 @@ impl Catalog { /// Close every running server. pub async fn shutdown(&self) { for entry in &self.entries { - entry.supervisor.lock().await.shutdown().await; + if let Some(supervisor) = entry.supervisor.lock().await.as_mut() { + supervisor.shutdown().await; + } } } diff --git a/src/mcp/resolve.rs b/src/mcp/resolve.rs index ca4432cf..f1281b82 100644 --- a/src/mcp/resolve.rs +++ b/src/mcp/resolve.rs @@ -8,7 +8,7 @@ //! Nothing is started here. Resolution is a read of the plugin registry; //! processes begin on first use. -use std::path::Path; +use std::path::{Path, PathBuf}; use std::time::Duration; use crate::plugins::{McpTransport, StdioCommand}; @@ -19,20 +19,95 @@ use crate::mcp::server::{EXECUTE, LIST_TOOLS}; use crate::plugins::McpServerOverrides; use crate::pm::PackageManager; +/// Where a server's executable comes from. +/// +/// An installation is carried as its definition rather than a path: acquiring +/// it means downloading or installing, and startup has to stay side-effect +/// free. The same shape the hook layer uses. +#[derive(Debug, Clone)] +pub enum ServerCommand { + Path(PathBuf), + Installation(Box), +} + /// A backing server, ready to be started on demand. #[derive(Debug, Clone)] pub struct ResolvedServer { - pub spec: SpawnSpec, + pub name: String, + pub command: ServerCommand, + pub args: Vec, + pub env: Vec<(String, String)>, + pub cwd: Option, + pub startup_timeout: Duration, /// Ceiling on one call to this server, already reconciled with the /// user's script deadline. pub tool_call_timeout: Duration, pub enabled_tools: Option>, pub disabled_tools: Option>, + /// Acquired before the server is first started, so a package-runner + /// download does not land on the first tool call. + pub requirements: Vec, } impl ResolvedServer { - pub fn name(&self) -> &str { - &self.spec.name + /// Acquire what this server needs and produce its spawn spec. + /// + /// Deferred to first use rather than done at resolve time: a client may + /// spawn a throwaway copy of the meta-server to probe it, and startup must + /// not download anything. + pub async fn spawn_spec(&self, sym: &Symposium) -> anyhow::Result { + // Dispatch-time acquisition serves the cache; the SessionStart prewarm + // is what forces a freshness check once per session. + let update = symposium_install::UpdateLevel::None; + + // Requirements first, so a warmed cache is in place before the command + // runs. A failure here only means the cost lands later. + for requirement in &self.requirements { + if let Err(e) = + crate::installation::acquire_installation(sym, requirement, None, None, update) + .await + { + tracing::warn!( + server = %self.name, + requirement = %requirement.name, + error = %e, + "failed to acquire mcp server requirement" + ); + } + } + + let (command, mut args) = match &self.command { + ServerCommand::Path(path) => (path.clone(), Vec::new()), + ServerCommand::Installation(installation) => { + let acquired = crate::installation::acquire_installation( + sym, + installation, + None, + None, + update, + ) + .await?; + let label = format!("mcp server `{}`", self.name); + match crate::installation::resolve_runnable(acquired, &label)? { + symposium_install::Runnable::Exec(path) => (path, Vec::new()), + // A script is run through a shell, as hooks are. + symposium_install::Runnable::Script(path) => ( + PathBuf::from("sh"), + vec![path.to_string_lossy().into_owned()], + ), + } + } + }; + args.extend(self.args.iter().cloned()); + + Ok(SpawnSpec { + name: self.name.clone(), + command, + args, + env: self.env.clone(), + cwd: self.cwd.clone(), + startup_timeout: self.startup_timeout, + }) } /// Whether a plugin's filters let this tool through. @@ -75,30 +150,43 @@ pub fn resolve(sym: &Symposium, cwd: &Path) -> Resolution { let dep_ids = crate::pm::CargoPm.list_deps(&loaded.crates); let mut ctx = crate::predicate::PredicateContext::new(&dep_ids); - let mut entries: Vec<(&crate::plugins::PluginMcpServer, String)> = Vec::new(); + let mut entries: Vec = Vec::new(); for plugin in ®istry.plugins { if !plugin.applies(&mut ctx) { continue; } - let owner = plugin.plugin.name.clone(); for entry in plugin.plugin.applicable_mcp_entries(&mut ctx) { - entries.push((entry, owner.clone())); + entries.push(Candidate { + entry, + owner: plugin.plugin.name.clone(), + plugin: &plugin.plugin, + }); } } build(entries, sym.config.mcp.script_timeout_secs) } +/// An applicable entry together with the plugin that declared it, whose +/// `[[installations]]` its `installation` and `requirements` name. +struct Candidate<'a> { + entry: &'a crate::plugins::PluginMcpServer, + owner: String, + plugin: &'a crate::plugins::Plugin, +} + /// Turn applicable manifest entries into runnable servers. -fn build( - entries: Vec<(&crate::plugins::PluginMcpServer, String)>, - script_timeout_secs: u64, -) -> Resolution { +fn build(entries: Vec>, script_timeout_secs: u64) -> Resolution { let mut resolution = Resolution::default(); // Which plugin claimed each name, so a clash can name both sides. let mut claimed: Vec<(String, String)> = Vec::new(); - for (entry, owner) in entries { + for Candidate { + entry, + owner, + plugin, + } in entries + { let name = entry.name.clone(); // The meta-server's own tools live in the same namespace as the @@ -128,39 +216,69 @@ fn build( }); continue; }; - let StdioCommand::Path(command) = &stdio.command else { - // Resolving an installation needs the acquire pipeline, which the - // caller runs before building specs. + let command = match &stdio.command { + StdioCommand::Path(path) => ServerCommand::Path(path.clone()), + StdioCommand::Installation(installation) => { + match plugin.get_installation(installation) { + Some(found) => ServerCommand::Installation(Box::new(found.clone())), + None => { + resolution.rejected.push(Rejection { + server: name, + reason: format!( + "`{owner}` names installation `{installation}`, which it does not declare" + ), + }); + continue; + } + } + } + }; + + // Named requirements have to exist too, or a warmup silently does + // nothing. + let mut requirements = Vec::new(); + let mut missing = None; + for requirement in &entry.requirements { + match plugin.get_installation(requirement) { + Some(found) => requirements.push(found.clone()), + None => { + missing = Some(requirement.clone()); + break; + } + } + } + if let Some(requirement) = missing { resolution.rejected.push(Rejection { server: name, - reason: "installation-backed servers are resolved before this point".to_string(), + reason: format!( + "`{owner}` names requirement `{requirement}`, which it does not declare" + ), }); continue; - }; + } claimed.push((name.clone(), owner)); resolution.servers.push(ResolvedServer { - spec: SpawnSpec { - name: name.clone(), - command: command.clone(), - args: stdio.args.clone(), - env: stdio - .env - .iter() - .map(|(name, value)| (name.clone(), value.clone())) - .collect(), - cwd: stdio.cwd.clone(), - startup_timeout: Duration::from_secs( - entry.overrides.startup_timeout_secs.unwrap_or(30), - ), - }, + name: name.clone(), + command, + args: stdio.args.clone(), + env: stdio + .env + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect(), + cwd: stdio.cwd.clone(), + startup_timeout: Duration::from_secs( + entry.overrides.startup_timeout_secs.unwrap_or(30), + ), tool_call_timeout: call_timeout(&entry.overrides, script_timeout_secs), enabled_tools: entry.overrides.enabled_tools.clone(), disabled_tools: entry.overrides.disabled_tools.clone(), + requirements, }); } - resolution.servers.sort_by(|a, b| a.name().cmp(b.name())); + resolution.servers.sort_by(|a, b| a.name.cmp(&b.name)); resolution } @@ -198,11 +316,52 @@ mod tests { } } + /// A plugin carrying the given entries, so `installation` and + /// `requirements` have somewhere to resolve against. + fn owner_plugin(installations: Vec) -> crate::plugins::Plugin { + crate::plugins::Plugin { + name: "db-plugin".to_string(), + predicates: Default::default(), + installations, + hooks: Vec::new(), + skills: Vec::new(), + mcp_servers: Vec::new(), + subcommands: Default::default(), + custom_predicates: Vec::new(), + chained: Vec::new(), + } + } + + fn resolve_with( + entries: Vec<&PluginMcpServer>, + plugin: &crate::plugins::Plugin, + script_secs: u64, + ) -> Resolution { + build( + entries + .into_iter() + .map(|entry| Candidate { + entry, + owner: plugin.name.clone(), + plugin, + }) + .collect(), + script_secs, + ) + } + + /// Entries paired with the plugin name that declared each, against a + /// plugin declaring no installations. fn resolve_all(entries: Vec<(&PluginMcpServer, &str)>, script_secs: u64) -> Resolution { + let plugin = owner_plugin(Vec::new()); build( entries .into_iter() - .map(|(e, owner)| (e, owner.to_string())) + .map(|(entry, owner)| Candidate { + entry, + owner: owner.to_string(), + plugin: &plugin, + }) .collect(), script_secs, ) @@ -214,7 +373,7 @@ mod tests { let out = resolve_all(vec![(&entry, "db-plugin")], 120); assert_eq!(out.servers.len(), 1); - assert_eq!(out.servers[0].name(), "sqlx"); + assert_eq!(out.servers[0].name, "sqlx"); assert!(out.rejected.is_empty()); } @@ -275,7 +434,7 @@ mod tests { entry.overrides.tool_call_timeout_secs = Some(90); let out = resolve_all(vec![(&entry, "p")], 300); - assert_eq!(out.servers[0].spec.startup_timeout, Duration::from_secs(45)); + assert_eq!(out.servers[0].startup_timeout, Duration::from_secs(45)); assert_eq!(out.servers[0].tool_call_timeout, Duration::from_secs(90)); } @@ -294,6 +453,98 @@ mod tests { ); } + // -- installations -- + + fn installation(name: &str, install_commands: Vec) -> crate::plugins::Installation { + crate::plugins::Installation { + name: name.to_string(), + requirements: Vec::new(), + install_commands, + source: None, + executable: Some("/usr/bin/true".to_string()), + script: None, + args: Vec::new(), + } + } + + fn installation_backed( + name: &str, + installation: &str, + requirements: &[&str], + ) -> PluginMcpServer { + PluginMcpServer { + name: name.to_string(), + predicates: Default::default(), + overrides: McpServerOverrides::default(), + transport: McpTransport::Stdio(crate::plugins::StdioServer { + command: StdioCommand::Installation(installation.to_string()), + args: Vec::new(), + env: Default::default(), + cwd: None, + }), + requirements: requirements.iter().map(|r| r.to_string()).collect(), + } + } + + /// The definition is carried, not resolved: acquiring means downloading, + /// and that must not happen while resolving. + #[test] + fn an_installation_backed_server_carries_its_definition() { + let entry = installation_backed("sqlx", "sqlx-mcp", &[]); + let plugin = owner_plugin(vec![installation("sqlx-mcp", vec![])]); + let out = resolve_with(vec![&entry], &plugin, 120); + + assert_eq!(out.servers.len(), 1, "rejected: {:?}", out.rejected); + assert!(matches!( + out.servers[0].command, + ServerCommand::Installation(_) + )); + } + + #[test] + fn an_unknown_installation_is_refused_naming_it() { + let entry = installation_backed("sqlx", "missing", &[]); + let plugin = owner_plugin(Vec::new()); + let out = resolve_with(vec![&entry], &plugin, 120); + + assert!(out.servers.is_empty()); + assert!( + out.rejected[0].reason.contains("missing"), + "got: {:?}", + out.rejected + ); + } + + /// A warmup that names nothing would silently do nothing, so the + /// reference has to be checked. + #[test] + fn an_unknown_requirement_is_refused_naming_it() { + let entry = installation_backed("sqlx", "sqlx-mcp", &["not-declared"]); + let plugin = owner_plugin(vec![installation("sqlx-mcp", vec![])]); + let out = resolve_with(vec![&entry], &plugin, 120); + + assert!(out.servers.is_empty()); + assert!( + out.rejected[0].reason.contains("not-declared"), + "got: {:?}", + out.rejected + ); + } + + #[test] + fn requirements_are_carried_for_acquisition() { + let entry = installation_backed("sqlx", "sqlx-mcp", &["warmup"]); + let plugin = owner_plugin(vec![ + installation("sqlx-mcp", vec![]), + installation("warmup", vec!["true".to_string()]), + ]); + let out = resolve_with(vec![&entry], &plugin, 120); + + assert_eq!(out.servers.len(), 1, "rejected: {:?}", out.rejected); + assert_eq!(out.servers[0].requirements.len(), 1); + assert_eq!(out.servers[0].requirements[0].name, "warmup"); + } + // -- tool filters -- #[test] @@ -342,7 +593,7 @@ mod tests { let a = stdio("a-server"); let out = resolve_all(vec![(&b, "p"), (&a, "p")], 120); - let names: Vec<&str> = out.servers.iter().map(|s| s.name()).collect(); + let names: Vec<&str> = out.servers.iter().map(|s| s.name.as_str()).collect(); assert_eq!(names, vec!["a-server", "b-server"]); } } diff --git a/src/mcp/server.rs b/src/mcp/server.rs index 5782d50a..161eaf6b 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -288,7 +288,9 @@ mod tests { /// A server with no backing processes; enough to inspect what it /// advertises. fn test_server(names: &[&str]) -> MetaServer { - let catalog = Catalog::new(Vec::new(), RestartPolicy::default(), false); + let tmp = tempfile::tempdir().expect("temp dir"); + let sym = Arc::new(crate::config::Symposium::from_dir(tmp.path())); + let catalog = Catalog::new(sym, Vec::new(), RestartPolicy::default(), false); let mut server = MetaServer::new(Arc::new(catalog), crate::mcp::sandbox::Limits::default()); server.servers = Arc::new(names.iter().map(|n| n.to_string()).collect()); server From 4a92f2fc9d67c730409e04d909dd2d59bee96af8 Mon Sep 17 00:00:00 2001 From: fluzko Date: Thu, 30 Jul 2026 11:39:38 -0300 Subject: [PATCH 24/39] feat(mcp): prewarm servers at session start --- src/hook.rs | 17 ++++++++ src/mcp/resolve.rs | 95 ++++++++++++++++++++++++++++++++++++++++++- src/mcp/supervisor.rs | 48 ++++++++++++++++++++++ 3 files changed, 159 insertions(+), 1 deletion(-) diff --git a/src/hook.rs b/src/hook.rs index 62605d3a..3872bda9 100644 --- a/src/hook.rs +++ b/src/hook.rs @@ -251,6 +251,7 @@ pub async fn execute_hook( // event network cost. Best-effort; gated by `auto-sync`. if session_start && sym.config.auto_sync { prewarm_hook_sources(sym, &mut deps).await; + prewarm_mcp_servers(sym, &mut deps).await; } // Builtin dispatch → symposium output → host agent output as Value @@ -453,6 +454,22 @@ async fn prewarm_hook_sources(sym: &Symposium, deps: &mut WorkspaceDeps) { } } +/// Acquire what MCP servers need, once per session. +/// +/// Unlike hooks, a declared `requirements` entry is acquired eagerly: that is +/// the author asking for a warm cache, and the alternative is the download +/// landing on the agent's first tool call. +async fn prewarm_mcp_servers(sym: &Symposium, deps: &mut WorkspaceDeps) { + if !sym.config.mcp.enabled { + return; + } + let Some(loaded) = deps.load().cloned() else { + return; + }; + let resolution = crate::mcp::resolve::resolve_loaded(sym, &loaded); + crate::mcp::resolve::prewarm(sym, &resolution).await; +} + /// Built-in hook logic on canonical symposium types. pub async fn dispatch_builtin( sym: &Symposium, diff --git a/src/mcp/resolve.rs b/src/mcp/resolve.rs index f1281b82..433b6c48 100644 --- a/src/mcp/resolve.rs +++ b/src/mcp/resolve.rs @@ -145,7 +145,15 @@ pub fn resolve(sym: &Symposium, cwd: &Path) -> Resolution { return Resolution::default(); }; let loaded = loaded.clone(); - let registry = crate::plugins::load_registry_with_workspace(sym, Some(&loaded)); + resolve_loaded(sym, &loaded) +} + +/// Resolve against a workspace already loaded by the caller. +pub fn resolve_loaded( + sym: &Symposium, + loaded: &symposium_sdk::workspace::LoadedWorkspace, +) -> Resolution { + let registry = crate::plugins::load_registry_with_workspace(sym, Some(loaded)); let dep_ids = crate::pm::CargoPm.list_deps(&loaded.crates); let mut ctx = crate::predicate::PredicateContext::new(&dep_ids); @@ -167,6 +175,50 @@ pub fn resolve(sym: &Symposium, cwd: &Path) -> Resolution { build(entries, sym.config.mcp.script_timeout_secs) } +/// Acquire what applicable servers need, once per session. +/// +/// Two different intents, treated differently: +/// +/// * **`requirements`** are acquired eagerly. Declaring one *is* the author +/// saying "warm this up" — it is how a package-runner server avoids paying +/// its download on the first tool call. +/// * **`installation`** commands are only refreshed if already present, as +/// hooks are. Installing every declared server eagerly would fetch tools a +/// session may never touch. +/// +/// Best-effort throughout: a failure here only means the cost lands later. +pub async fn prewarm(sym: &Symposium, resolution: &Resolution) { + let update = symposium_install::UpdateLevel::Check; + + for server in &resolution.servers { + for requirement in &server.requirements { + if let Err(e) = + crate::installation::acquire_installation(sym, requirement, None, None, update) + .await + { + tracing::debug!( + server = %server.name, + requirement = %requirement.name, + error = %e, + "prewarm: requirement acquisition failed" + ); + } + } + + if let ServerCommand::Installation(installation) = &server.command { + if let Err(e) = + crate::installation::refresh_installation_if_present(sym, installation, None).await + { + tracing::debug!( + server = %server.name, + error = %e, + "prewarm: command refresh failed" + ); + } + } + } +} + /// An applicable entry together with the plugin that declared it, whose /// `[[installations]]` its `installation` and `requirements` name. struct Candidate<'a> { @@ -545,6 +597,47 @@ mod tests { assert_eq!(out.servers[0].requirements[0].name, "warmup"); } + /// A declared requirement is acquired eagerly: that is the author asking + /// for a warm cache, and the alternative is the download landing on the + /// agent's first tool call. + #[tokio::test] + async fn prewarm_runs_declared_requirements() { + let tmp = tempfile::tempdir().unwrap(); + let marker = tmp.path().join("warmed"); + let sym = Symposium::from_dir(tmp.path()); + + let entry = installation_backed("sqlx", "sqlx-mcp", &["warmup"]); + let plugin = owner_plugin(vec![ + installation("sqlx-mcp", vec![]), + installation("warmup", vec![format!("touch {}", marker.display())]), + ]); + let resolution = resolve_with(vec![&entry], &plugin, 120); + + assert!(!marker.exists(), "resolving must not run anything"); + prewarm(&sym, &resolution).await; + assert!( + marker.exists(), + "the warmup should have run at prewarm time" + ); + } + + /// A warmup that fails only means the cost lands later, so it must not + /// take the session with it. + #[tokio::test] + async fn prewarm_survives_a_failing_requirement() { + let tmp = tempfile::tempdir().unwrap(); + let sym = Symposium::from_dir(tmp.path()); + + let entry = installation_backed("sqlx", "sqlx-mcp", &["warmup"]); + let plugin = owner_plugin(vec![ + installation("sqlx-mcp", vec![]), + installation("warmup", vec!["exit 1".to_string()]), + ]); + let resolution = resolve_with(vec![&entry], &plugin, 120); + + prewarm(&sym, &resolution).await; + } + // -- tool filters -- #[test] diff --git a/src/mcp/supervisor.rs b/src/mcp/supervisor.rs index b23b17f5..2e871a5e 100644 --- a/src/mcp/supervisor.rs +++ b/src/mcp/supervisor.rs @@ -458,6 +458,54 @@ mod tests { sup.shutdown().await; } + /// A server that reads the project it serves needs to run inside it. + /// Four of the seven client config formats carry this for that reason. + /// + /// Proved with a *relative* config path: the server can only find its + /// config if the working directory was applied. + #[tokio::test(flavor = "multi_thread")] + async fn a_server_runs_in_its_configured_directory() { + let f = fixture(json!({ + "name": "mock", + "tools": [{"name": "echo", "behavior": {"kind": "echo"}}] + })); + let dir = f.config.parent().expect("fixture dir").to_path_buf(); + + let relative = SpawnSpec { + name: "mock".to_string(), + command: mock_binary(), + args: vec!["--config".to_string(), "mock.json".to_string()], + env: Vec::new(), + cwd: Some(dir.clone()), + startup_timeout: Duration::from_secs(10), + }; + let mut sup = Supervisor::new(relative, fast_policy()); + sup.call("echo", json!({"ok": 1}), Duration::from_secs(5)) + .await + .expect("a relative config resolves only from the right directory"); + sup.shutdown().await; + + // Without it, the same relative path cannot be found. + let mut without_cwd = Supervisor::new( + SpawnSpec { + name: "mock".to_string(), + command: mock_binary(), + args: vec!["--config".to_string(), "mock.json".to_string()], + env: Vec::new(), + cwd: None, + startup_timeout: Duration::from_secs(10), + }, + fast_policy(), + ); + assert!( + without_cwd + .call("echo", json!({}), Duration::from_secs(5)) + .await + .is_err(), + "the relative path should not resolve from the test's own directory" + ); + } + #[tokio::test(flavor = "multi_thread")] async fn a_hanging_tool_times_out() { let f = fixture(json!({ From 4cd3f9bc1af5511dfa4f89f989092e1015e4942b Mon Sep 17 00:00:00 2001 From: fluzko Date: Thu, 30 Jul 2026 12:37:59 -0300 Subject: [PATCH 25/39] fix(mcp): report refused servers to the model --- src/bin/cargo-agents.rs | 2 +- src/mcp/catalog.rs | 20 ++++++++++++++++---- src/mcp/server.rs | 2 +- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/bin/cargo-agents.rs b/src/bin/cargo-agents.rs index 15c71198..ab133c53 100644 --- a/src/bin/cargo-agents.rs +++ b/src/bin/cargo-agents.rs @@ -166,7 +166,7 @@ async fn main() -> ExitCode { } let catalog = std::sync::Arc::new(symposium::mcp::catalog::Catalog::new( std::sync::Arc::new(sym.clone()), - resolution.servers, + resolution, symposium::mcp::supervisor::RestartPolicy { max_restarts: sym.config.mcp.max_server_restarts, stable_reset: std::time::Duration::from_secs( diff --git a/src/mcp/catalog.rs b/src/mcp/catalog.rs index b2b9a1a8..adcf3a82 100644 --- a/src/mcp/catalog.rs +++ b/src/mcp/catalog.rs @@ -22,7 +22,7 @@ use tokio::sync::Mutex; use super::declarations::{ToolDecl, binding_keys, render_server}; use super::dispatch::{Binding, Namespace}; -use super::resolve::ResolvedServer; +use super::resolve::{Rejection, Resolution, ResolvedServer}; use super::supervisor::{RestartPolicy, Supervisor}; /// How much to say about each tool. @@ -102,6 +102,10 @@ impl Query { pub struct Catalog { entries: Vec, read_only: bool, + /// Servers that could not be used at all. Reported to the model rather + /// than only logged: a server silently missing looks like a workspace + /// that never declared it. + rejected: Vec, policy: RestartPolicy, /// Needed to acquire an installation-backed server on first use. sym: Arc, @@ -120,10 +124,11 @@ struct Entry { impl Catalog { pub fn new( sym: Arc, - servers: Vec, + resolution: Resolution, policy: RestartPolicy, read_only: bool, ) -> Self { + let Resolution { servers, rejected } = resolution; let known_names = servers.iter().map(|s| s.name.clone()).collect(); let entries = servers .into_iter() @@ -135,6 +140,7 @@ impl Catalog { Self { entries, read_only, + rejected, policy, sym, known_names, @@ -168,12 +174,18 @@ impl Catalog { /// Describe the matching tools. pub async fn describe(&self, query: &Query) -> String { - if self.entries.is_empty() { + if self.entries.is_empty() && self.rejected.is_empty() { return "No MCP servers apply to this workspace.".to_string(); } let mut sections = Vec::new(); - let mut problems = Vec::new(); + // Refusals first: they explain an absence the model would otherwise + // have to infer. + let mut problems: Vec = self + .rejected + .iter() + .map(|r| format!("{}: {}", r.server, r.reason)) + .collect(); for entry in &self.entries { if !query.wants_server(entry.resolved.name.as_str()) { diff --git a/src/mcp/server.rs b/src/mcp/server.rs index 161eaf6b..ea70b7d6 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -290,7 +290,7 @@ mod tests { fn test_server(names: &[&str]) -> MetaServer { let tmp = tempfile::tempdir().expect("temp dir"); let sym = Arc::new(crate::config::Symposium::from_dir(tmp.path())); - let catalog = Catalog::new(sym, Vec::new(), RestartPolicy::default(), false); + let catalog = Catalog::new(sym, Default::default(), RestartPolicy::default(), false); let mut server = MetaServer::new(Arc::new(catalog), crate::mcp::sandbox::Limits::default()); server.servers = Arc::new(names.iter().map(|n| n.to_string()).collect()); server From cb4dda9e271d91f3eebe729f3fa196fe3d8e9bce Mon Sep 17 00:00:00 2001 From: fluzko Date: Thu, 30 Jul 2026 14:44:25 -0300 Subject: [PATCH 26/39] fix(mcp): make docs and tests match the code --- src/agents/mcp_server_registration.rs | 12 ++++---- src/config.rs | 26 +---------------- src/mcp/client.rs | 24 ++++++++++++---- src/mcp/corpus_tests.rs | 2 +- src/mcp/declarations.rs | 2 +- src/mcp/resolve.rs | 40 +++++++++++++++++++++++++-- src/mcp/sandbox.rs | 16 ++++++----- 7 files changed, 74 insertions(+), 48 deletions(-) diff --git a/src/agents/mcp_server_registration.rs b/src/agents/mcp_server_registration.rs index fc919022..3c3a7c00 100644 --- a/src/agents/mcp_server_registration.rs +++ b/src/agents/mcp_server_registration.rs @@ -33,12 +33,6 @@ fn server_name(server: &McpServer) -> &str { } } -/// Convert an McpServer to the JSON value agents expect in their config. -/// -/// Stdio: `{"command": "...", "args": [...], "env": [...]}` -/// Http/Sse: `{"url": "...", "headers": [...]}` -/// -/// `env` and `headers` are omitted when empty. /// Render name/value pairs as a JSON object. fn pairs_to_object<'a>(pairs: impl Iterator) -> serde_json::Value { serde_json::Value::Object( @@ -48,6 +42,12 @@ fn pairs_to_object<'a>(pairs: impl Iterator) -> ) } +/// Convert an McpServer to the JSON value agents expect in their config. +/// +/// Stdio: `{"command": "...", "args": [...], "env": {...}}` +/// Http/Sse: `{"url": "...", "headers": {...}}` +/// +/// `env` and `headers` are omitted when empty. fn server_to_json(server: &McpServer) -> serde_json::Value { match server { McpServer::Stdio(s) => { diff --git a/src/config.rs b/src/config.rs index 70b3144b..9a22de37 100644 --- a/src/config.rs +++ b/src/config.rs @@ -188,11 +188,6 @@ pub struct McpConfig { #[serde(rename = "max-tool-calls")] pub max_tool_calls: u32, - /// Maximum tool calls in flight at once from one script. Also caps how - /// many cold backing servers may be spawned concurrently. - #[serde(rename = "max-concurrent-tool-calls")] - pub max_concurrent_tool_calls: u32, - /// Ceiling on a serialized `execute` return value. Oversized results are /// truncated with a marker rather than rejected, so a script that already /// performed side effects does not lose its work. @@ -203,11 +198,6 @@ pub struct McpConfig { #[serde(rename = "max-console-bytes")] pub max_console_bytes: usize, - /// Backstop ceiling on a `list_tools` response. Not the primary size - /// control — `list_tools` returns an index by default. - #[serde(rename = "max-declaration-bytes")] - pub max_declaration_bytes: usize, - /// Ceiling on spawning a backing server and completing its handshake. #[serde(rename = "server-startup-timeout-secs")] pub server_startup_timeout_secs: u64, @@ -231,16 +221,6 @@ pub struct McpConfig { #[serde(rename = "shutdown-grace-secs")] pub shutdown_grace_secs: u64, - /// How long a cached `tools/list` payload stays valid on disk, letting a - /// later session render declarations without spawning anything. - #[serde(rename = "declaration-cache-ttl-secs")] - pub declaration_cache_ttl_secs: u64, - - /// Poll interval for re-fetching a backing server's tool list. 0 relies - /// solely on `notifications/tools/list_changed`. - #[serde(rename = "tool-discovery-interval-secs")] - pub tool_discovery_interval_secs: u64, - /// Expose only tools annotated `readOnlyHint`, and reject the rest at /// dispatch. The annotation is self-declared by the backing server, so /// this is a guardrail against agent mistakes, not a security boundary. @@ -256,17 +236,13 @@ impl Default for McpConfig { script_memory_limit_mb: 64, script_stack_limit_kb: 1024, max_tool_calls: 100, - max_concurrent_tool_calls: 4, max_result_bytes: 32 * 1024, max_console_bytes: 8 * 1024, - max_declaration_bytes: 64 * 1024, server_startup_timeout_secs: 30, tool_call_timeout_secs: 60, max_server_restarts: 5, restart_stable_reset_secs: 300, shutdown_grace_secs: 5, - declaration_cache_ttl_secs: 86_400, - tool_discovery_interval_secs: 0, read_only: false, } } @@ -905,7 +881,7 @@ mod tests { assert_eq!(mcp.server_startup_timeout_secs, 30); assert_eq!(mcp.max_result_bytes, 32 * 1024); assert_eq!(mcp.max_server_restarts, 5); - assert_eq!(mcp.tool_discovery_interval_secs, 0); + assert_eq!(mcp.shutdown_grace_secs, 5); } /// An `[mcp]` table sets only the keys it names; the rest keep their defaults. diff --git a/src/mcp/client.rs b/src/mcp/client.rs index 398883d8..861ce4c4 100644 --- a/src/mcp/client.rs +++ b/src/mcp/client.rs @@ -38,8 +38,13 @@ pub struct SpawnSpec { /// Why talking to a backing server failed. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ClientError { - /// Spawn or handshake did not finish in time. - StartupTimeout { server: String, limit_secs: u64 }, + /// Spawn or handshake did not finish in time. `detail` carries whatever + /// the server managed to say on stderr before it stalled. + StartupTimeout { + server: String, + limit_secs: u64, + detail: Option, + }, /// The process could not be started, or died during the handshake. StartupFailed { server: String, detail: String }, /// A single call did not finish in time. @@ -58,9 +63,14 @@ pub enum ClientError { impl std::fmt::Display for ClientError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::StartupTimeout { server, limit_secs } => { - write!(f, "{server} did not start within {limit_secs}s") - } + Self::StartupTimeout { + server, + limit_secs, + detail, + } => match detail { + Some(tail) => write!(f, "{server} did not start within {limit_secs}s: {tail}"), + None => write!(f, "{server} did not start within {limit_secs}s"), + }, Self::StartupFailed { server, detail } => { write!(f, "{server} failed to start: {detail}") } @@ -125,9 +135,13 @@ impl BackingServer { }); } Err(_) => { + // A server that hung mid-handshake has usually said why on + // stderr, and the timeout alone does not carry that. + let detail = drain(stderr).await.filter(|tail| !tail.is_empty()); return Err(ClientError::StartupTimeout { server: spec.name.clone(), limit_secs: spec.startup_timeout.as_secs(), + detail, }); } }; diff --git a/src/mcp/corpus_tests.rs b/src/mcp/corpus_tests.rs index 5343914d..bf534cc9 100644 --- a/src/mcp/corpus_tests.rs +++ b/src/mcp/corpus_tests.rs @@ -63,7 +63,7 @@ fn every_tool_in_the_corpus_produces_a_declaration() { } } -/// The protocol's own test server. Names 17 of its 18 tools with hyphens, so +/// The protocol's own test server. Names 12 of its 13 tools with hyphens, so /// this is also the identifier-handling snapshot. #[test] fn everything_server() { diff --git a/src/mcp/declarations.rs b/src/mcp/declarations.rs index 9c365341..2f400b19 100644 --- a/src/mcp/declarations.rs +++ b/src/mcp/declarations.rs @@ -212,7 +212,7 @@ mod tests { // -- naming -- - /// The protocol's own reference server names 17 of its 18 tools with + /// The protocol's own reference server names 12 of its 13 tools with /// hyphens, so this is the common case, not an edge case. #[test] fn hyphenated_tool_gets_both_spellings() { diff --git a/src/mcp/resolve.rs b/src/mcp/resolve.rs index 433b6c48..5aefc53c 100644 --- a/src/mcp/resolve.rs +++ b/src/mcp/resolve.rs @@ -172,7 +172,7 @@ pub fn resolve_loaded( } } - build(entries, sym.config.mcp.script_timeout_secs) + build(entries, &loaded.root, sym.config.mcp.script_timeout_secs) } /// Acquire what applicable servers need, once per session. @@ -228,7 +228,11 @@ struct Candidate<'a> { } /// Turn applicable manifest entries into runnable servers. -fn build(entries: Vec>, script_timeout_secs: u64) -> Resolution { +/// +/// `root` anchors a relative `cwd`. A plugin author writing `cwd = +/// "crates/db"` means the workspace's `crates/db`; they cannot know what +/// directory the agent happened to be launched from. +fn build(entries: Vec>, root: &Path, script_timeout_secs: u64) -> Resolution { let mut resolution = Resolution::default(); // Which plugin claimed each name, so a clash can name both sides. let mut claimed: Vec<(String, String)> = Vec::new(); @@ -319,7 +323,7 @@ fn build(entries: Vec>, script_timeout_secs: u64) -> Resolution { .iter() .map(|(key, value)| (key.clone(), value.clone())) .collect(), - cwd: stdio.cwd.clone(), + cwd: stdio.cwd.as_ref().map(|dir| root.join(dir)), startup_timeout: Duration::from_secs( entry.overrides.startup_timeout_secs.unwrap_or(30), ), @@ -398,6 +402,7 @@ mod tests { plugin, }) .collect(), + Path::new("/ws"), script_secs, ) } @@ -415,10 +420,39 @@ mod tests { plugin: &plugin, }) .collect(), + Path::new("/ws"), script_secs, ) } + /// A plugin author writes `cwd` against the workspace, not against + /// whatever directory the agent happened to start in. + #[test] + fn relative_cwd_resolves_against_the_workspace_root() { + let mut entry = stdio("sqlx"); + if let McpTransport::Stdio(s) = &mut entry.transport { + s.cwd = Some("crates/db".into()); + } + let out = resolve_all(vec![(&entry, "db-plugin")], 120); + assert_eq!( + out.servers[0].cwd.as_deref(), + Some(Path::new("/ws/crates/db")), + "got: {:?}", + out.servers[0].cwd + ); + } + + /// An absolute `cwd` is the author being explicit; leave it alone. + #[test] + fn absolute_cwd_is_left_as_written() { + let mut entry = stdio("sqlx"); + if let McpTransport::Stdio(s) = &mut entry.transport { + s.cwd = Some("/opt/db".into()); + } + let out = resolve_all(vec![(&entry, "db-plugin")], 120); + assert_eq!(out.servers[0].cwd.as_deref(), Some(Path::new("/opt/db"))); + } + #[test] fn stdio_servers_become_spawnable() { let entry = stdio("sqlx"); diff --git a/src/mcp/sandbox.rs b/src/mcp/sandbox.rs index 2633428e..d23729f9 100644 --- a/src/mcp/sandbox.rs +++ b/src/mcp/sandbox.rs @@ -445,8 +445,13 @@ mod tests { // -- memory -- /// Guards against the limit silently becoming a no-op: rquickjs documents - /// `set_memory_limit` as inert when a custom allocator is in use, which a - /// future feature change could enable without any other visible effect. + /// `set_memory_limit` as inert when a custom allocator is in use, and + /// feature unification means a transitive dependency could enable + /// `rquickjs/rust-alloc` without any other visible effect. + /// + /// The assertion has to be `MemoryExhausted` alone. Accepting a timeout + /// too would let exactly that regression pass: with the limit inert, the + /// loop simply runs until the deadline. #[tokio::test] async fn allocation_is_bounded() { let sandbox = Sandbox::new(Limits { @@ -458,11 +463,8 @@ mod tests { .await .unwrap_err(); assert!( - matches!( - err, - SandboxError::MemoryExhausted { .. } | SandboxError::ScriptTimeout { .. } - ), - "unbounded allocation must not succeed, got: {err:?}" + matches!(err, SandboxError::MemoryExhausted { .. }), + "the memory limit must be what stops this, got: {err:?}" ); } From 52897e0867a658de24fc5bafd191163c606555f6 Mon Sep 17 00:00:00 2001 From: fluzko Date: Thu, 30 Jul 2026 14:55:55 -0300 Subject: [PATCH 27/39] fix(mcp): keep install command output off stdout --- src/installation.rs | 81 ++++++++++++++++++++++++++++++++++++-- tests/mcp_meta_server.rs | 85 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+), 4 deletions(-) diff --git a/src/installation.rs b/src/installation.rs index afd5d381..4dd7f9fc 100644 --- a/src/installation.rs +++ b/src/installation.rs @@ -14,20 +14,50 @@ use symposium_install::{Runnable, acquire_source, make_executable}; /// Run a list of post-install shell commands sequentially. Stops at the first /// failure. +/// +/// Output is captured rather than inherited. Under `mcp-serve` our stdout is +/// the JSON-RPC channel, so a command as ordinary as the documented `npx -y +/// --help` warmup would write a line of prose into the middle of the +/// protocol stream and end the session. Nothing is lost by capturing: install +/// chatter is never the user's answer, and on failure the tail below is a +/// better account than interleaved output was. pub async fn run_install_commands(commands: &[String]) -> Result<()> { for cmd in commands { - let status = tokio::process::Command::new("sh") + let output = tokio::process::Command::new("sh") .arg("-c") .arg(cmd) - .status() + .output() .await?; - if !status.success() { - bail!("install command `{cmd}` exited with {status}"); + if !output.status.success() { + let status = output.status; + match failure_tail(&output) { + Some(tail) => bail!("install command `{cmd}` exited with {status}: {tail}"), + None => bail!("install command `{cmd}` exited with {status}"), + } } } Ok(()) } +/// The most useful trailing output from a failed install command. +/// +/// stderr first, since that is where a shell puts its complaint; stdout only +/// when stderr said nothing. Bounded so a verbose build does not become the +/// error message. +fn failure_tail(output: &std::process::Output) -> Option { + const MAX: usize = 2000; + let pick = [&output.stderr, &output.stdout] + .into_iter() + .map(|stream| String::from_utf8_lossy(stream).trim().to_string()) + .find(|text| !text.is_empty())?; + + let mut start = pick.len().saturating_sub(MAX); + while start > 0 && !pick.is_char_boundary(start) { + start += 1; + } + Some(pick[start..].to_string()) +} + /// Per-installation snapshot the dispatcher builds for the command and each /// requirement. Drives env-var wiring for the spawned hook process: /// `$SYMPOSIUM_DIR_`, `$SYMPOSIUM_`, and the `$PATH` prefix. @@ -178,3 +208,46 @@ pub fn resolve_runnable(installation: AcquiredInstallation, label: &str) -> Resu }; Ok(runnable) } + +#[cfg(test)] +mod tests { + use super::*; + + /// Capturing output must not cost the diagnosis: when an install command + /// fails, what it printed is the only account of why. + #[tokio::test] + async fn a_failing_install_command_reports_its_stderr() { + let err = run_install_commands(&["echo trouble-here >&2; exit 3".to_string()]) + .await + .expect_err("a non-zero exit must fail"); + let message = err.to_string(); + assert!(message.contains("trouble-here"), "got: {message}"); + } + + /// stdout is the fallback when the command said nothing on stderr. + #[tokio::test] + async fn a_failing_install_command_falls_back_to_stdout() { + let err = run_install_commands(&["echo only-on-stdout; exit 1".to_string()]) + .await + .expect_err("a non-zero exit must fail"); + assert!(err.to_string().contains("only-on-stdout"), "got: {err}"); + } + + /// A quiet failure still names the command rather than reporting nothing. + #[tokio::test] + async fn a_silent_failure_still_names_the_command() { + let err = run_install_commands(&["exit 7".to_string()]) + .await + .expect_err("a non-zero exit must fail"); + assert!(err.to_string().contains("exit 7"), "got: {err}"); + } + + #[tokio::test] + async fn commands_stop_at_the_first_failure() { + let dir = tempfile::tempdir().expect("temp dir"); + let marker = dir.path().join("second-ran"); + let commands = vec!["exit 1".to_string(), format!("touch {}", marker.display())]; + assert!(run_install_commands(&commands).await.is_err()); + assert!(!marker.exists(), "the second command should not have run"); + } +} diff --git a/tests/mcp_meta_server.rs b/tests/mcp_meta_server.rs index 817f6886..bae45aa4 100644 --- a/tests/mcp_meta_server.rs +++ b/tests/mcp_meta_server.rs @@ -373,3 +373,88 @@ async fn stdout_carries_only_json_rpc() { .unwrap_or_else(|e| panic!("stdout line is not JSON ({e}): {line}")); } } + +/// An `install_commands` entry runs a shell command, and the documented +/// warmup pattern for a package-runner server is `npx -y --help` — +/// which prints. Inheriting our stdout would put that prose between two +/// JSON-RPC frames and end the session. +#[tokio::test(flavor = "multi_thread")] +async fn install_command_output_stays_off_stdout() { + const MARKER: &str = "SYMPOSIUM-INSTALL-STDOUT-MARKER"; + + let home = isolated_home(); + let plugin_dir = home.path().join("plugins").join("noisy"); + std::fs::create_dir_all(&plugin_dir).expect("plugin dir"); + std::fs::write( + plugin_dir.join("SYMPOSIUM.toml"), + format!( + "name = \"noisy\"\n\ + depends-on = [\"*\"]\n\ + \n\ + [[installations]]\n\ + name = \"warmup\"\n\ + install_commands = [\"echo {MARKER}\"]\n\ + \n\ + [[mcp_servers]]\n\ + name = \"noisy-server\"\n\ + depends-on = [\"*\"]\n\ + command = \"/usr/bin/true\"\n\ + requirements = [\"warmup\"]\n" + ), + ) + .expect("write manifest"); + + // Server resolution needs a Rust workspace to condition on. + let workspace = tempfile::tempdir().expect("workspace"); + std::fs::write( + workspace.path().join("Cargo.toml"), + "[package]\nname = \"probe\"\nversion = \"0.0.0\"\nedition = \"2021\"\n", + ) + .expect("write Cargo.toml"); + std::fs::create_dir_all(workspace.path().join("src")).expect("src dir"); + std::fs::write(workspace.path().join("src/lib.rs"), "").expect("write lib.rs"); + + let mut child = tokio::process::Command::new(binary()) + .arg("mcp-serve") + .current_dir(workspace.path()) + .env("SYMPOSIUM_HOME", home.path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn"); + + use tokio::io::AsyncWriteExt; + let mut stdin = child.stdin.take().expect("stdin"); + stdin + .write_all( + concat!( + r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"probe","version":"0"}}}"#, + "\n", + r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#, + "\n", + // Starting the server is what acquires its requirements. + r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list_tools","arguments":{"detail":"full"}}}"#, + "\n", + ) + .as_bytes(), + ) + .await + .expect("write"); + drop(stdin); + + let output = tokio::time::timeout(Duration::from_secs(60), child.wait_with_output()) + .await + .expect("server should exit on stdin close") + .expect("output"); + + let stdout = String::from_utf8(output.stdout).expect("utf-8"); + assert!( + !stdout.contains(MARKER), + "install command output reached the protocol stream:\n{stdout}" + ); + for line in stdout.lines().filter(|l| !l.trim().is_empty()) { + serde_json::from_str::(line) + .unwrap_or_else(|e| panic!("stdout line is not JSON ({e}): {line}")); + } +} From 90dc40437fe78d3c640b8fb7c2650bd3b03eed91 Mon Sep 17 00:00:00 2001 From: fluzko Date: Thu, 30 Jul 2026 15:05:39 -0300 Subject: [PATCH 28/39] fix(mcp): always answer, even when a script leaves work pending --- src/mcp/sandbox.rs | 126 +++++++++++++++++++++++++++++++++++---- src/mcp/server.rs | 9 ++- tests/mcp_meta_server.rs | 41 +++++++++++++ 3 files changed, 161 insertions(+), 15 deletions(-) diff --git a/src/mcp/sandbox.rs b/src/mcp/sandbox.rs index d23729f9..f8861919 100644 --- a/src/mcp/sandbox.rs +++ b/src/mcp/sandbox.rs @@ -30,10 +30,10 @@ use super::{console, dispatch, normalize}; /// the OS thread stack, which is a crash rather than an exception. const STACK_HEADROOM: usize = 1 << 20; -/// How long the outer deadline waits past the interrupt deadline. +/// How long each deadline layer waits past the one inside it. /// /// The interrupt produces a precise error, so give it a moment to win before -/// the blunt outer timeout fires. +/// the blunter layers fire. const OUTER_GRACE: Duration = Duration::from_millis(250); /// Bounds on one script execution. @@ -179,13 +179,32 @@ impl Sandbox { .enable_time() .build() { - Ok(rt) => rt.block_on(run( - &script, - source.as_deref(), - &namespaces, - calls.as_ref(), - limits, - )), + // The middle deadline, and the one that keeps this thread + // from outliving the call. The interrupt handler only + // fires while the interpreter is running, so a script that + // settles into an unresolvable promise — `return new + // Promise(() => {})`, or a tool call left un-awaited — + // parks here with nothing to interrupt. Abandoning the + // future drops the runtime, and with it the tool-call + // sender the caller's dispatch pump is waiting on. + Ok(rt) => rt.block_on(async { + let bounded = tokio::time::timeout( + limits.timeout + OUTER_GRACE, + run( + &script, + source.as_deref(), + &namespaces, + calls.as_ref(), + limits, + ), + ); + match bounded.await { + Ok(outcome) => outcome, + Err(_) => Err(SandboxError::ScriptTimeout { + limit_secs: limits.timeout.as_secs(), + }), + } + }), Err(e) => Err(SandboxError::Internal { message: format!("could not start sandbox runtime: {e}"), }), @@ -200,10 +219,11 @@ impl Sandbox { }); } - // The outer layer. A script awaiting a host call that never resolves - // leaves the interpreter idle, so the interrupt handler never runs and - // only this can end it. - match tokio::time::timeout(limits.timeout + OUTER_GRACE, rx).await { + // The outermost layer, and a backstop rather than the working + // deadline: the in-thread bound above should already have reported. + // This catches a thread that died without sending, and is given room + // to lose that race so the precise error wins. + match tokio::time::timeout(limits.timeout + OUTER_GRACE * 2, rx).await { Ok(Ok(outcome)) => outcome, Ok(Err(_)) => Err(SandboxError::Internal { message: "sandbox thread ended without reporting".to_string(), @@ -442,6 +462,86 @@ mod tests { ); } + /// The interrupt handler only fires while the interpreter is running, so + /// a script that parks on a promise nothing will ever settle leaves + /// nothing to interrupt. Only the surrounding deadlines can end it. + #[tokio::test] + async fn a_promise_that_never_settles_still_reports() { + let started = Instant::now(); + let err = Sandbox::new(fast()) + .run_script("return new Promise(() => {})") + .await + .unwrap_err(); + + assert!( + matches!(err, SandboxError::ScriptTimeout { .. }), + "got: {err:?}" + ); + assert!( + started.elapsed() < Duration::from_secs(3), + "took {:?}", + started.elapsed() + ); + } + + /// The engine thread owns the only tool-call sender, so a thread that + /// outlives its script keeps the caller's dispatch pump alive forever — + /// which is what left `execute` never answering. Observing the channel + /// close is how we know the thread actually went away. + #[tokio::test] + async fn a_wedged_script_releases_the_dispatch_channel() { + let (calls, mut receiver) = dispatch::channel(); + let err = Sandbox::new(fast()) + .run_script_with("return new Promise(() => {})", &[], calls) + .await + .unwrap_err(); + assert!( + matches!(err, SandboxError::ScriptTimeout { .. }), + "got: {err:?}" + ); + + let closed = tokio::time::timeout(Duration::from_secs(5), receiver.recv()) + .await + .expect("the sandbox thread must drop its sender, not hold it"); + assert!( + closed.is_none(), + "channel should be closed, got: {closed:?}" + ); + } + + /// A tool call the script never awaited must not keep the engine past its + /// deadline: the reply can only come from a pump the caller stops driving + /// once the script is over. + #[tokio::test] + async fn an_unawaited_tool_call_does_not_outlive_the_deadline() { + let (calls, _receiver) = dispatch::channel(); + let namespaces = vec![dispatch::Namespace { + key: "srv".to_string(), + server: "srv".to_string(), + bindings: vec![dispatch::Binding { + key: "go".to_string(), + wire_name: "go".to_string(), + }], + }]; + + let started = Instant::now(); + // No `await`, and nothing is servicing the channel. + let err = Sandbox::new(fast()) + .run_script_with("srv.go({}); return 1;", &namespaces, calls) + .await + .unwrap_err(); + + assert!( + matches!(err, SandboxError::ScriptTimeout { .. }), + "got: {err:?}" + ); + assert!( + started.elapsed() < Duration::from_secs(3), + "took {:?}", + started.elapsed() + ); + } + // -- memory -- /// Guards against the limit silently becoming a no-op: rquickjs documents diff --git a/src/mcp/server.rs b/src/mcp/server.rs index ea70b7d6..4c0c6b26 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -73,8 +73,13 @@ impl MetaServer { let outcome = super::sandbox::Sandbox::new(self.limits) .run_script_with(script, &namespaces, calls) .await; - // The sender is dropped with the sandbox, ending the pump. - let _ = pump.await; + // Aborted rather than awaited. The sandbox owns the only senders, so + // waiting for the channel to close means waiting on the engine thread + // — and if that thread is wedged, this would never return and the + // request would go unanswered entirely. Once the script is over there + // is nothing left worth pumping: anything still queued is a call the + // script never awaited, past a deadline that has already expired. + pump.abort(); match outcome { Ok(outcome) => CallToolResult::success(vec![ContentBlock::text(render_outcome( diff --git a/tests/mcp_meta_server.rs b/tests/mcp_meta_server.rs index bae45aa4..3c4bd88b 100644 --- a/tests/mcp_meta_server.rs +++ b/tests/mcp_meta_server.rs @@ -323,6 +323,47 @@ async fn exceeding_a_limit_reports_a_tagged_error() { let _ = client.cancel().await; } +/// A script can end with work outstanding — a promise nothing settles, or a +/// tool call it forgot to await. The engine thread owns the only tool-call +/// sender, so waiting for the dispatch pump to drain means waiting on that +/// thread, and the request goes unanswered rather than reporting a timeout. +/// Forgetting an `await` is an ordinary mistake, so this has to hold. +#[tokio::test(flavor = "multi_thread")] +async fn a_script_left_pending_still_answers() { + let workspace = workspace_with_backing_server(); + std::fs::write( + workspace.home.join("config.toml"), + "hook-scope = \"project\"\n[defaults]\nsymposium-recommendations = false\n\ + [mcp]\nscript-timeout-secs = 2\ntool-call-timeout-secs = 1\n", + ) + .unwrap(); + let client = connect_in(&workspace).await; + + for script in [ + "return new Promise(() => {});", + // Dispatched but never awaited, so its reply is still outstanding + // when the script's value is already decided. + "sqlx.query({ sql: \"SELECT 1\" }); return \"done\";", + ] { + let result = tokio::time::timeout( + Duration::from_secs(30), + client.call_tool(CallToolRequestParams::new("execute").with_arguments( + serde_json::Map::from_iter([("script".to_string(), serde_json::json!(script))]), + )), + ) + .await + .unwrap_or_else(|_| panic!("execute never answered for: {script}")) + .expect("execute should answer, not fail"); + + let text = text_of(&result); + assert!( + text.contains("script_timeout") || result.is_error != Some(true), + "expected an answer either way, got: {text}" + ); + } + let _ = client.cancel().await; +} + /// The transport is newline-delimited JSON, so anything else written to /// stdout corrupts the stream. Reporting output is the likely offender, since /// every other subcommand sends it there. From a6cd8ca069db21aa8d13a2bbfa385230f2e6f78f Mon Sep 17 00:00:00 2001 From: fluzko Date: Thu, 30 Jul 2026 15:12:26 -0300 Subject: [PATCH 29/39] fix(mcp): derive declared and bound tool names from one table --- src/mcp/catalog.rs | 25 +++--- src/mcp/declarations.rs | 179 ++++++++++++++++++++++++++++++++------- tests/mcp_meta_server.rs | 94 ++++++++++++++++---- 3 files changed, 241 insertions(+), 57 deletions(-) diff --git a/src/mcp/catalog.rs b/src/mcp/catalog.rs index adcf3a82..84027c26 100644 --- a/src/mcp/catalog.rs +++ b/src/mcp/catalog.rs @@ -20,7 +20,7 @@ use rmcp::model::Tool; use serde_json::Value; use tokio::sync::Mutex; -use super::declarations::{ToolDecl, binding_keys, render_server}; +use super::declarations::{ToolDecl, binding_table, render_server}; use super::dispatch::{Binding, Namespace}; use super::resolve::{Rejection, Resolution, ResolvedServer}; use super::supervisor::{RestartPolicy, Supervisor}; @@ -289,19 +289,22 @@ impl Catalog { } }; - let bindings: Vec = tools + // The same table the declarations are rendered from, so every + // name the model was shown is a name that dispatches. + let visible: Vec<&str> = tools .iter() .filter(|t| entry.resolved.exposes(t.name.as_ref())) .filter(|t| !self.read_only || is_read_only(t)) - .flat_map(|t| { - // Both spellings reach the same wire name, so a model can - // use whichever the declarations showed it. - binding_keys(t.name.as_ref()) - .into_iter() - .map(move |key| Binding { - key, - wire_name: t.name.to_string(), - }) + .map(|t| t.name.as_ref()) + .collect(); + + let bindings: Vec = binding_table(visible) + .into_iter() + .flat_map(|b| { + b.keys.into_iter().map(move |key| Binding { + key, + wire_name: b.wire_name.clone(), + }) }) .collect(); diff --git a/src/mcp/declarations.rs b/src/mcp/declarations.rs index 2f400b19..ed070c6b 100644 --- a/src/mcp/declarations.rs +++ b/src/mcp/declarations.rs @@ -19,27 +19,68 @@ pub struct ToolDecl<'a> { pub input_schema: Option<&'a Value>, } +/// The JavaScript keys one tool answers to, primary first. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ToolBinding { + /// Name to send on the wire. + pub wire_name: String, + /// Property keys, as they appear on the namespace object. A wire name + /// that is already an identifier has one; anything else also gets a + /// sanitized alias, so `sqlx["migrate-status"]` and + /// `sqlx.migrate_status` both dispatch. + pub keys: Vec, +} + +/// Assign JavaScript keys to a server's tools. +/// +/// The single source of both the declarations and the runtime bindings. +/// Rendering and dispatch derived their names separately once, and disagreed: +/// only the renderer disambiguated collisions, so it could advertise a name +/// that nothing bound while a colliding pair silently overwrote each other. +/// A name the model is shown has to be a name it can call. +/// +/// Primaries are assigned before aliases, so a tool keeps its own spelling +/// rather than losing it to another tool's sanitized form. +pub fn binding_table<'a>(names: impl IntoIterator) -> Vec { + let mut used: Vec = Vec::new(); + let mut table: Vec = Vec::new(); + + for name in names { + // Tool names are unique per server by the protocol. A server that + // advertises one twice leaves the second unaddressable on the wire + // regardless, so declaring it again would only be a duplicate member. + if table.iter().any(|b| b.wire_name == name) { + continue; + } + table.push(ToolBinding { + wire_name: name.to_string(), + keys: vec![unique(name.to_string(), &mut used)], + }); + } + + for binding in &mut table { + if !is_js_identifier(&binding.wire_name) { + binding + .keys + .push(unique(sanitize(&binding.wire_name), &mut used)); + } + } + + table +} + /// Render one server's tools as a declaration block. pub fn render_server(server: &str, tools: &[ToolDecl]) -> String { let mut types = TypeRenderer::new(); let mut methods = String::new(); - let mut used: Vec = Vec::new(); - - for tool in tools { - let params = render_params(&mut types, tool.input_schema); - // A name that is already a valid identifier needs no alias. One that - // is not gets both spellings, so `sqlx["migrate-status"]` and - // `sqlx.migrate_status` both dispatch. - let quoted = format!("{:?}", tool.name); - let alias = (!is_js_identifier(tool.name)).then(|| unique(sanitize(tool.name), &mut used)); - let primary = if alias.is_some() { - quoted - } else { - unique(tool.name.to_string(), &mut used) + for binding in binding_table(tools.iter().map(|t| t.name)) { + let Some(tool) = tools.iter().find(|t| t.name == binding.wire_name) else { + continue; }; + let params = render_params(&mut types, tool.input_schema); - for (index, key) in std::iter::once(&primary).chain(alias.iter()).enumerate() { + for (index, key) in binding.keys.iter().enumerate() { if index == 0 { if let Some(doc) = tool.description.and_then(jsdoc_text) { methods.push_str(&format!(" /** {doc} */\n")); @@ -47,9 +88,13 @@ pub fn render_server(server: &str, tools: &[ToolDecl]) -> String { } else { // The alias is the same tool under a different spelling. // Repeating the description would double it in the output. - methods.push_str(&format!(" /** Alias for {}. */\n", tool.name)); + let name = jsdoc_text(tool.name).unwrap_or_else(|| "the same tool".to_string()); + methods.push_str(&format!(" /** Alias for {name}. */\n")); } - methods.push_str(&format!(" {key}({params}): Promise;\n")); + methods.push_str(&format!( + " {}({params}): Promise;\n", + render_key(key) + )); } } @@ -61,16 +106,12 @@ pub fn render_server(server: &str, tools: &[ToolDecl]) -> String { out } -/// The keys a tool is reachable under in JavaScript. -/// -/// A wire name that is already an identifier needs one key. One that is not -/// gets two — the quoted wire name and a sanitized alias — so both -/// `sqlx["migrate-status"]` and `sqlx.migrate_status` dispatch. -pub fn binding_keys(name: &str) -> Vec { - if is_js_identifier(name) { - vec![name.to_string()] +/// A property key as it must be written in a type literal. +fn render_key(key: &str) -> String { + if is_js_identifier(key) { + key.to_string() } else { - vec![name.to_string(), sanitize(name)] + format!("{key:?}") } } @@ -280,17 +321,95 @@ mod tests { assert!(out.starts_with("declare const sea_orm: {"), "got:\n{out}"); } - /// The keys used to build the runtime namespace must match the ones the - /// declarations advertise, or a model would call a name that is not there. + /// Every key the declarations advertise has to be one dispatch binds. + /// These were derived separately once and disagreed. + fn declared_keys(out: &str) -> Vec { + out.lines() + .filter_map(|line| line.trim().strip_suffix("): Promise;")) + .filter_map(|line| line.split('(').next()) + .map(|key| key.trim_matches('"').to_string()) + .collect() + } + #[test] - fn binding_keys_match_the_declared_names() { - assert_eq!(binding_keys("query"), vec!["query".to_string()]); + fn an_identifier_name_needs_no_alias() { + let table = binding_table(["query"]); + assert_eq!(table[0].keys, vec!["query".to_string()]); + } + + #[test] + fn a_hyphenated_name_is_bound_under_both_spellings() { + let table = binding_table(["get-sum"]); assert_eq!( - binding_keys("get-sum"), + table[0].keys, vec!["get-sum".to_string(), "get_sum".to_string()] ); } + /// The case that was broken: the renderer disambiguated and dispatch did + /// not, so the declarations advertised `get_sum_2`, nothing bound it, and + /// `get_sum` was bound twice with one silently shadowing the other. + #[test] + fn a_colliding_alias_never_shadows_a_real_tool() { + let table = binding_table(["get-sum", "get_sum"]); + + assert_eq!(table[0].wire_name, "get-sum"); + assert_eq!( + table[0].keys, + vec!["get-sum".to_string(), "get_sum_2".to_string()], + "the alias must yield to the tool that owns the name" + ); + assert_eq!(table[1].wire_name, "get_sum"); + assert_eq!( + table[1].keys, + vec!["get_sum".to_string()], + "a real tool keeps its own spelling" + ); + + let mut keys: Vec<&String> = table.iter().flat_map(|b| &b.keys).collect(); + let before = keys.len(); + keys.sort(); + keys.dedup(); + assert_eq!(before, keys.len(), "every key must be distinct: {keys:?}"); + } + + /// The renderer and the dispatcher read the same table, so what is + /// declared is exactly what is bound. + #[test] + fn declared_names_are_the_bound_names() { + let schema = json!({}); + let tools = [ + tool("get-sum", &schema), + tool("get_sum", &schema), + tool("query", &schema), + ]; + let out = render_server("s", &tools); + + let bound: Vec = binding_table(tools.iter().map(|t| t.name)) + .into_iter() + .flat_map(|b| b.keys) + .collect(); + + assert_eq!(declared_keys(&out), bound, "got:\n{out}"); + } + + /// A server advertising one name twice would otherwise emit a duplicate + /// member, which is a TypeScript error. The second is unreachable on the + /// wire either way. + #[test] + fn a_repeated_wire_name_is_declared_once() { + let schema = json!({}); + let tools = [tool("get-sum", &schema), tool("get-sum", &schema)]; + let out = render_server("s", &tools); + + assert_eq!(binding_table(tools.iter().map(|t| t.name)).len(), 1); + assert_eq!( + declared_keys(&out), + vec!["get-sum".to_string(), "get_sum".to_string()], + "got:\n{out}" + ); + } + // -- documentation and shared types -- #[test] diff --git a/tests/mcp_meta_server.rs b/tests/mcp_meta_server.rs index 3c4bd88b..a39ca04b 100644 --- a/tests/mcp_meta_server.rs +++ b/tests/mcp_meta_server.rs @@ -138,6 +138,20 @@ fn mock_binary() -> PathBuf { } fn workspace_with_backing_server() -> Workspace { + workspace_serving(serde_json::json!({ + "name": "sqlx", + "tools": [ + {"name": "query", "description": "Run a SQL query", + "inputSchema": {"type": "object", + "properties": {"sql": {"type": "string"}}, "required": ["sql"]}, + "behavior": {"kind": "echo"}}, + {"name": "migrate-status", "description": "Show migrations", + "behavior": {"kind": "text", "text": "up to date"}} + ] + })) +} + +fn workspace_serving(mock: serde_json::Value) -> Workspace { let dir = tempfile::tempdir().expect("temp dir"); let base = dir.path().to_path_buf(); let home = base.join("home"); @@ -146,22 +160,7 @@ fn workspace_with_backing_server() -> Workspace { std::fs::create_dir_all(root.join("src")).unwrap(); let mock_config = base.join("mock.json"); - std::fs::write( - &mock_config, - serde_json::json!({ - "name": "sqlx", - "tools": [ - {"name": "query", "description": "Run a SQL query", - "inputSchema": {"type": "object", - "properties": {"sql": {"type": "string"}}, "required": ["sql"]}, - "behavior": {"kind": "echo"}}, - {"name": "migrate-status", "description": "Show migrations", - "behavior": {"kind": "text", "text": "up to date"}} - ] - }) - .to_string(), - ) - .unwrap(); + std::fs::write(&mock_config, mock.to_string()).unwrap(); std::fs::write( home.join("config.toml"), @@ -294,6 +293,69 @@ async fn a_script_composes_calls_in_one_round_trip() { let _ = client.cancel().await; } +/// Every name in the declarations has to dispatch to the tool it was +/// declared for. Rendering and dispatch derived their names separately once: +/// only the renderer disambiguated, so it advertised a name nothing bound +/// while the colliding pair silently overwrote each other. +#[tokio::test(flavor = "multi_thread")] +async fn a_declared_name_reaches_the_tool_it_was_declared_for() { + let workspace = workspace_serving(serde_json::json!({ + "name": "sqlx", + "tools": [ + {"name": "get-sum", "description": "hyphenated", + "behavior": {"kind": "text", "text": "FROM-HYPHENATED"}}, + {"name": "get_sum", "description": "underscored", + "behavior": {"kind": "text", "text": "FROM-UNDERSCORED"}} + ] + })); + let client = connect_in(&workspace).await; + + let declarations = text_of( + &client + .call_tool(CallToolRequestParams::new("list_tools").with_arguments( + serde_json::Map::from_iter([("detail".to_string(), serde_json::json!("full"))]), + )) + .await + .expect("list_tools"), + ); + + // Whatever spelling the alias ended up with, calling it must reach the + // hyphenated tool, and the plain name must reach the underscored one. + let script = r#" + return { + viaAlias: await sqlx.get_sum_2(), + viaQuoted: await sqlx["get-sum"](), + viaOwnName: await sqlx.get_sum(), + }; + "#; + let result = client + .call_tool(CallToolRequestParams::new("execute").with_arguments( + serde_json::Map::from_iter([("script".to_string(), serde_json::json!(script))]), + )) + .await + .expect("execute"); + let text = text_of(&result); + + assert!( + declarations.contains("get_sum_2"), + "the alias should be declared, got: {declarations}" + ); + assert_ne!(result.is_error, Some(true), "got: {text}"); + assert!( + text.contains(r#""viaAlias":"FROM-HYPHENATED""#), + "the declared alias must reach the hyphenated tool, got: {text}" + ); + assert!( + text.contains(r#""viaQuoted":"FROM-HYPHENATED""#), + "got: {text}" + ); + assert!( + text.contains(r#""viaOwnName":"FROM-UNDERSCORED""#), + "a real tool must keep its own name, got: {text}" + ); + let _ = client.cancel().await; +} + /// A limit the script exceeded is reported in a form it can act on, rather /// than as prose it has to interpret. #[tokio::test(flavor = "multi_thread")] From 61d6b7a7801ac5580b2317e807f73562f08efcab Mon Sep 17 00:00:00 2001 From: fluzko Date: Thu, 30 Jul 2026 15:19:32 -0300 Subject: [PATCH 30/39] test(mcp): cover the schema constructs the corpus lacks --- .github/workflows/ci.yml | 7 + src/mcp/corpus_tests.rs | 100 ++++++++++++-- src/mcp/testdata/constructs.d.ts | 59 ++++++++ src/mcp/testdata/constructs.tools.json | 181 +++++++++++++++++++++++++ 4 files changed, 337 insertions(+), 10 deletions(-) create mode 100644 src/mcp/testdata/constructs.d.ts create mode 100644 src/mcp/testdata/constructs.tools.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f9248f2f..7a03f630 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,6 +78,13 @@ jobs: with: targets: ${{ matrix.target || '' }} + # The generated TypeScript declarations are type-checked with `tsc`. + # That test skips when no Node is reachable, so it must not skip here. + - name: Install Node + uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Install musl tools if: matrix.target == 'x86_64-unknown-linux-musl' run: | diff --git a/src/mcp/corpus_tests.rs b/src/mcp/corpus_tests.rs index bf534cc9..e72766ed 100644 --- a/src/mcp/corpus_tests.rs +++ b/src/mcp/corpus_tests.rs @@ -17,6 +17,19 @@ use serde_json::Value; use super::declarations::{ToolDecl, render_server}; +/// Every captured payload, with the server name its declarations use. +const CORPUS: &[(&str, &str)] = &[ + (include_str!("testdata/constructs.tools.json"), "constructs"), + (include_str!("testdata/everything.tools.json"), "everything"), + (include_str!("testdata/filesystem.tools.json"), "filesystem"), + (include_str!("testdata/memory.tools.json"), "memory"), + (include_str!("testdata/playwright.tools.json"), "playwright"), + ( + include_str!("testdata/sequentialthinking.tools.json"), + "sequentialthinking", + ), +]; + /// Render a captured `tools/list` payload as one server's declarations. fn render_corpus(payload: &str, server: &str) -> String { let parsed: Value = serde_json::from_str(payload).expect("payload should be valid JSON"); @@ -42,16 +55,7 @@ fn render_corpus(payload: &str, server: &str) -> String { /// invariant that generation never fails, whatever a server sends. #[test] fn every_tool_in_the_corpus_produces_a_declaration() { - for (payload, server) in [ - (include_str!("testdata/everything.tools.json"), "everything"), - (include_str!("testdata/filesystem.tools.json"), "filesystem"), - (include_str!("testdata/memory.tools.json"), "memory"), - (include_str!("testdata/playwright.tools.json"), "playwright"), - ( - include_str!("testdata/sequentialthinking.tools.json"), - "sequentialthinking", - ), - ] { + for (payload, server) in CORPUS { let parsed: Value = serde_json::from_str(payload).unwrap(); let expected = parsed["tools"].as_array().unwrap().len(); let rendered = render_corpus(payload, server); @@ -111,3 +115,79 @@ fn sequentialthinking_server() { "sequentialthinking", )); } + +/// The constructs the captured corpus happens not to contain, and which the +/// RFD cites as the reason for hand-rolling the mapping in the first place: +/// `$ref`/`$defs`, the composition keywords, tuples, and type-less schemas. +/// +/// Hand-built rather than captured. That is a weakness — a real payload +/// cannot be argued with — but every one of these shapes is what pydantic, +/// zod or schemars emits for an ordinary Rust or Python model, and none of +/// them appear in anything we managed to capture. +#[test] +fn construct_coverage() { + expect_file!["testdata/constructs.d.ts"].assert_eq(&render_corpus( + include_str!("testdata/constructs.tools.json"), + "constructs", + )); +} + +/// The generated declarations have to be valid TypeScript. +/// +/// Nothing else checks this: `expect_file!` locks the bytes we emit, not +/// whether a compiler accepts them, so a snapshot can be reviewed, approved +/// and still be a parse error. What the model receives is only useful if it +/// parses. +/// +/// Skips when no TypeScript is reachable, so a offline `cargo test` still +/// runs; CI installs Node so it does not skip there. +#[test] +#[ignore = "declarations do not type-check yet; un-ignored by the commit that fixes the last one"] +fn declarations_type_check() { + let dir = tempfile::tempdir().expect("temp dir"); + let mut files = Vec::new(); + for (payload, server) in CORPUS { + // One file per server: a name collision between two servers' types is + // theirs to have, not an error we should invent. + let name = format!("{server}.d.ts"); + std::fs::write(dir.path().join(&name), render_corpus(payload, server)) + .expect("write declarations"); + files.push(name); + } + + let run = std::process::Command::new("npx") + .args([ + "-y", + "-p", + "typescript@5", + "tsc", + "--noEmit", + "--strict", + "--skipLibCheck", + ]) + .args(&files) + .current_dir(dir.path()) + .output(); + + let Ok(output) = run else { + eprintln!("skipping declarations_type_check: no npx on PATH"); + return; + }; + let report = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + + // tsc reports every complaint as `error TS`. A failure carrying + // none of those is the toolchain not running — no network for the + // download, say — and must not be reported as a type error. + if !output.status.success() && !report.contains("error TS") { + eprintln!("skipping declarations_type_check: tsc did not run:\n{report}"); + return; + } + assert!( + output.status.success(), + "generated declarations do not type-check:\n{report}" + ); +} diff --git a/src/mcp/testdata/constructs.d.ts b/src/mcp/testdata/constructs.d.ts new file mode 100644 index 00000000..f098f7d8 --- /dev/null +++ b/src/mcp/testdata/constructs.d.ts @@ -0,0 +1,59 @@ +interface Config { + host: string; +} + +type Json = string | Json[]; + +interface Merged { + a?: string; +} & { + b?: number; +} + +interface Shape { + Circle: number; +} | { + Square: number; +} + +declare const constructs: { + /** Input is a $ref to a named model, the shape pydantic emits for a root model. */ + pydantic_root_model(): Promise; + /** A serde externally-tagged enum, the most common schemars-generated $def. */ + schemars_tagged_enum(params: { + shape: Shape; + }): Promise; + /** oneOf beside properties: the 'provide one of these' idiom. */ + either_path_or_url(params?: unknown): Promise; + /** allOf alongside local properties; both belong in the result. */ + all_of_with_siblings(params: { + shared: number; + }): Promise; + /** Defines #/$defs/Config. Generators re-emit $defs per tool, so the name is not unique across a server. */ + config_first(params: { + config: Config; + }): Promise; + /** Defines #/$defs/Config with a different body. Must not collapse into the first. */ + config_second(params: { + config: Config; + }): Promise; + /** properties without a type keyword; type is not required by JSON Schema. */ + no_declared_type(params: unknown): Promise; + /** The pydantic optional spelling. A control: this one already works. */ + nullable_via_any_of(params: { + revision: string | null; + }): Promise; + /** Both tuple spellings. zod emits the first on draft-07 targets and the second on 2020-12. */ + tuple_shapes(params?: { + draft07?: unknown[]; + modern?: never[]; + }): Promise; + /** Self-reference through a union, the shape a JSON-value type takes. */ + recursive_defs(params: { + tree: Json; + }): Promise; + /** A $def whose body is an intersection rather than a single object. */ + merged_object_def(params: { + merged: Merged; + }): Promise; +}; diff --git a/src/mcp/testdata/constructs.tools.json b/src/mcp/testdata/constructs.tools.json new file mode 100644 index 00000000..86612d3f --- /dev/null +++ b/src/mcp/testdata/constructs.tools.json @@ -0,0 +1,181 @@ +{ + "_comment": "Hand-built, unlike the rest of testdata/. Every construct here is one the captured corpus happens not to contain and the RFD nonetheless cites as motivation for hand-rolling the mapping: $ref/$defs, allOf/anyOf/oneOf, tuples, and type-less schemas. Grouped one tool per construct so a regression names itself.", + "tools": [ + { + "name": "pydantic_root_model", + "description": "Input is a $ref to a named model, the shape pydantic emits for a root model.", + "inputSchema": { + "$ref": "#/$defs/SearchInput", + "$defs": { + "SearchInput": { + "type": "object", + "properties": { + "query": { "type": "string" }, + "limit": { "type": "integer", "default": 10 } + }, + "required": ["query"] + } + } + } + }, + { + "name": "schemars_tagged_enum", + "description": "A serde externally-tagged enum, the most common schemars-generated $def.", + "inputSchema": { + "type": "object", + "properties": { "shape": { "$ref": "#/definitions/Shape" } }, + "required": ["shape"], + "definitions": { + "Shape": { + "oneOf": [ + { + "type": "object", + "properties": { "Circle": { "type": "number" } }, + "required": ["Circle"] + }, + { + "type": "object", + "properties": { "Square": { "type": "number" } }, + "required": ["Square"] + } + ] + } + } + } + }, + { + "name": "either_path_or_url", + "description": "oneOf beside properties: the 'provide one of these' idiom.", + "inputSchema": { + "type": "object", + "properties": { + "path": { "type": "string" }, + "url": { "type": "string" } + }, + "oneOf": [{ "required": ["path"] }, { "required": ["url"] }] + } + }, + { + "name": "all_of_with_siblings", + "description": "allOf alongside local properties; both belong in the result.", + "inputSchema": { + "type": "object", + "properties": { "local": { "type": "string" } }, + "required": ["local"], + "allOf": [ + { + "type": "object", + "properties": { "shared": { "type": "number" } }, + "required": ["shared"] + } + ] + } + }, + { + "name": "config_first", + "description": "Defines #/$defs/Config. Generators re-emit $defs per tool, so the name is not unique across a server.", + "inputSchema": { + "type": "object", + "properties": { "config": { "$ref": "#/$defs/Config" } }, + "required": ["config"], + "$defs": { + "Config": { + "type": "object", + "properties": { "host": { "type": "string" } }, + "required": ["host"] + } + } + } + }, + { + "name": "config_second", + "description": "Defines #/$defs/Config with a different body. Must not collapse into the first.", + "inputSchema": { + "type": "object", + "properties": { "config": { "$ref": "#/$defs/Config" } }, + "required": ["config"], + "$defs": { + "Config": { + "type": "object", + "properties": { "retries": { "type": "number" } }, + "required": ["retries"] + } + } + } + }, + { + "name": "no_declared_type", + "description": "properties without a type keyword; type is not required by JSON Schema.", + "inputSchema": { + "properties": { + "name": { "type": "string" }, + "tags": { "items": { "type": "string" } } + }, + "required": ["name"] + } + }, + { + "name": "nullable_via_any_of", + "description": "The pydantic optional spelling. A control: this one already works.", + "inputSchema": { + "type": "object", + "properties": { + "revision": { "anyOf": [{ "type": "string" }, { "type": "null" }] } + }, + "required": ["revision"] + } + }, + { + "name": "tuple_shapes", + "description": "Both tuple spellings. zod emits the first on draft-07 targets and the second on 2020-12.", + "inputSchema": { + "type": "object", + "properties": { + "draft07": { + "type": "array", + "items": [{ "type": "string" }, { "type": "number" }] + }, + "modern": { + "type": "array", + "prefixItems": [{ "type": "string" }, { "type": "number" }], + "items": false + } + } + } + }, + { + "name": "recursive_defs", + "description": "Self-reference through a union, the shape a JSON-value type takes.", + "inputSchema": { + "type": "object", + "properties": { "tree": { "$ref": "#/$defs/Json" } }, + "required": ["tree"], + "$defs": { + "Json": { + "anyOf": [ + { "type": "string" }, + { "type": "array", "items": { "$ref": "#/$defs/Json" } } + ] + } + } + } + }, + { + "name": "merged_object_def", + "description": "A $def whose body is an intersection rather than a single object.", + "inputSchema": { + "type": "object", + "properties": { "merged": { "$ref": "#/$defs/Merged" } }, + "required": ["merged"], + "$defs": { + "Merged": { + "allOf": [ + { "type": "object", "properties": { "a": { "type": "string" } } }, + { "type": "object", "properties": { "b": { "type": "number" } } } + ] + } + } + } + } + ] +} From f99a35256e6f05320e52f3020aff33aaf2284ac5 Mon Sep 17 00:00:00 2001 From: fluzko Date: Thu, 30 Jul 2026 15:28:08 -0300 Subject: [PATCH 31/39] fix(mcp): declare named types as aliases, not interfaces --- src/mcp/corpus_tests.rs | 8 +- src/mcp/declarations.rs | 4 +- src/mcp/schema_to_ts.rs | 266 ++++++++++++++++++++++++++++--- src/mcp/testdata/constructs.d.ts | 12 +- 4 files changed, 260 insertions(+), 30 deletions(-) diff --git a/src/mcp/corpus_tests.rs b/src/mcp/corpus_tests.rs index e72766ed..55fbdd93 100644 --- a/src/mcp/corpus_tests.rs +++ b/src/mcp/corpus_tests.rs @@ -139,10 +139,14 @@ fn construct_coverage() { /// and still be a parse error. What the model receives is only useful if it /// parses. /// -/// Skips when no TypeScript is reachable, so a offline `cargo test` still +/// Note what this does *not* catch: `tsc` stops at the first syntax errors +/// and never reaches type checking, so a declaration that parses but +/// describes the wrong shape passes here. The snapshots above are what cover +/// that. Neither check subsumes the other. +/// +/// Skips when no TypeScript is reachable, so an offline `cargo test` still /// runs; CI installs Node so it does not skip there. #[test] -#[ignore = "declarations do not type-check yet; un-ignored by the commit that fixes the last one"] fn declarations_type_check() { let dir = tempfile::tempdir().expect("temp dir"); let mut files = Vec::new(); diff --git a/src/mcp/declarations.rs b/src/mcp/declarations.rs index ed070c6b..ebffff9e 100644 --- a/src/mcp/declarations.rs +++ b/src/mcp/declarations.rs @@ -436,9 +436,9 @@ mod tests { "$defs": {"User": {"type": "object", "properties": {"id": {"type": "string"}}}}, }); let out = render_server("s", &[tool("a", &schema), tool("b", &schema)]); - assert_eq!(out.matches("interface User").count(), 1, "got:\n{out}"); + assert_eq!(out.matches("type User =").count(), 1, "got:\n{out}"); assert!( - out.find("interface User") < out.find("declare const s"), + out.find("type User =") < out.find("declare const s"), "types must precede the server object, got:\n{out}" ); } diff --git a/src/mcp/schema_to_ts.rs b/src/mcp/schema_to_ts.rs index fa719797..1f0967fd 100644 --- a/src/mcp/schema_to_ts.rs +++ b/src/mcp/schema_to_ts.rs @@ -65,15 +65,21 @@ impl TypeRenderer { } /// The named type declarations collected so far, in name order. + /// + /// Always a type alias, never an `interface`. Choosing between them meant + /// deciding whether a body was a single object literal, and that was done + /// by testing its first character — which a union or intersection of + /// object types also passes, producing `interface Shape { … } | { … }`. + /// That is a syntax error, and since the declarations are one file, it + /// took every other type down with it. A serde externally-tagged enum is + /// exactly that shape, so it was reachable from any ordinary Rust server. + /// + /// An alias is valid for every body, object literals included, so the + /// choice is not worth the failure mode. pub fn declarations(&self) -> String { let mut out = String::new(); for (name, body) in &self.named { - // An object body is an interface; anything else is an alias. - if body.starts_with('{') { - out.push_str(&format!("interface {name} {body}\n\n")); - } else { - out.push_str(&format!("type {name} = {body};\n\n")); - } + out.push_str(&format!("type {name} = {body};\n\n")); } out } @@ -165,6 +171,16 @@ fn render_ref(cx: &mut Cx, pointer: &str, depth: usize) -> String { cx.in_progress.push(name.clone()); let body = render_at(cx, &target.clone(), 0, depth + 1); cx.in_progress.pop(); + + // `type A = A` is a circular alias, which TypeScript rejects outright + // (TS2456). It is what `{"$ref": "#"}` produces, and what is left of a + // union whose only other members were self-references. + let body = if body == name { + UNKNOWN.to_string() + } else { + body + }; + cx.named.insert(name.clone(), body); name } @@ -199,14 +215,77 @@ fn type_name_for(pointer: &str) -> Option { if cleaned.is_empty() { return None; } - // A type name may not start with a digit. - if cleaned.starts_with(|c: char| c.is_ascii_digit()) { + // A type name may not start with a digit, and may not be a reserved word + // or a built-in type: `#/$defs/default` would declare `type default =`, + // and `#/$defs/string` is rejected as a redeclaration. + if cleaned.starts_with(|c: char| c.is_ascii_digit()) || is_reserved_type_name(&cleaned) { Some(format!("_{cleaned}")) } else { Some(cleaned) } } +/// Names TypeScript will not accept as a declared type. +/// +/// Both halves matter and fail differently: a keyword is a parse error, while +/// a built-in type name is rejected as a redeclaration. `$defs` keys come +/// from the server's own field names, so `default`, `object` and `string` are +/// all plausible. +fn is_reserved_type_name(name: &str) -> bool { + const RESERVED: &[&str] = &[ + // Built-in and intrinsic types. + "any", + "bigint", + "boolean", + "never", + "null", + "number", + "object", + "string", + "symbol", + "undefined", + "unknown", + "void", + // Keywords that cannot begin a declaration name. + "break", + "case", + "catch", + "class", + "const", + "continue", + "debugger", + "default", + "delete", + "do", + "else", + "enum", + "export", + "extends", + "false", + "finally", + "for", + "function", + "if", + "import", + "in", + "instanceof", + "new", + "return", + "super", + "switch", + "this", + "throw", + "true", + "try", + "typeof", + "var", + "void", + "while", + "with", + ]; + RESERVED.contains(&name) +} + /// Dispatch on whichever constraint keyword the schema uses, narrowest first. fn render_constrained( cx: &mut Cx, @@ -333,17 +412,24 @@ fn render_struct(cx: &mut Cx, schema: &Map, indent: usize, depth: } fn render_union(cx: &mut Cx, members: &[Value], indent: usize, depth: usize) -> String { - let parts = members - .iter() - .map(|m| render_at(cx, m, indent, depth + 1)) - .collect(); + let mut parts = Vec::new(); + for member in members { + let rendered = render_at(cx, member, indent, depth + 1); + if !is_direct_self_reference(cx, &rendered) { + parts.push(rendered); + } + } join_union(parts) } fn render_intersection(cx: &mut Cx, members: &[Value], indent: usize, depth: usize) -> String { let mut parts: Vec = Vec::new(); for member in members { - let rendered = parenthesize_if_composite(render_at(cx, member, indent, depth + 1)); + let rendered = render_at(cx, member, indent, depth + 1); + if is_direct_self_reference(cx, &rendered) { + continue; + } + let rendered = parenthesize_if_composite(rendered); if !parts.contains(&rendered) { parts.push(rendered); } @@ -355,6 +441,17 @@ fn render_intersection(cx: &mut Cx, members: &[Value], indent: usize, depth: usi } } +/// Whether a rendered member is the very type currently being defined. +/// +/// `type A = A | string` is a circular alias TypeScript rejects (TS2456), +/// and a member that *is* the whole type constrains nothing, so dropping it +/// is both legal and faithful. Only a bare name counts: `type Json = string | +/// Json[]` is legitimate recursion, because the reference sits inside an +/// array rather than being the alternative itself. +fn is_direct_self_reference(cx: &Cx, rendered: &str) -> bool { + cx.in_progress.iter().any(|name| name == rendered) +} + fn render_enum(values: &[Value]) -> String { if values.is_empty() { return "never".to_string(); @@ -381,9 +478,16 @@ fn join_union(parts: Vec) -> String { } } -/// Parenthesize a union so it binds correctly inside a larger type. +/// Parenthesize a composite so it binds correctly inside a larger type. +/// +/// `A | B[]` parses as `A | (B[])`, and `A & B[]` as `A & (B[])`, so both +/// need wrapping before a suffix is attached. Only unions were guarded +/// before, which left an array of an intersection meaning the wrong thing +/// while still being valid syntax — the kind of error nothing downstream +/// reports. fn parenthesize_if_composite(rendered: String) -> String { - if rendered.contains(" | ") && !rendered.starts_with('(') { + let composite = rendered.contains(" | ") || rendered.contains(" & "); + if composite && !rendered.starts_with('(') { format!("({rendered})") } else { rendered @@ -505,6 +609,24 @@ mod tests { assert_eq!(out, r#"("a" | "b")[]"#); } + /// The same hazard one keyword over: `A & B[]` parses as `A & (B[])`. + /// Unlike the union case this stayed valid syntax, so nothing downstream + /// reported it — the type was simply wrong. + #[test] + fn parenthesizes_intersection_array_elements() { + let out = render(json!({ + "type": "array", + "items": { + "allOf": [ + {"type": "object", "properties": {"a": {"type": "string"}}}, + {"type": "object", "properties": {"b": {"type": "number"}}}, + ], + }, + })); + assert!(out.starts_with('('), "should be parenthesized, got: {out}"); + assert!(out.ends_with(")[]"), "got: {out}"); + } + // -- enums -- #[test] @@ -535,7 +657,7 @@ mod tests { } #[test] - fn resolves_ref_into_a_named_interface() { + fn resolves_ref_into_a_named_type() { let (expr, defs) = render_with_defs(json!({ "type": "object", "properties": {"msg": {"$ref": "#/$defs/Message"}}, @@ -549,7 +671,111 @@ mod tests { }, })); assert_eq!(expr, "{\n msg: Message;\n}"); - assert_eq!(defs, "interface Message {\n text: string;\n}\n\n"); + assert_eq!(defs, "type Message = {\n text: string;\n};\n\n"); + } + + /// A serde externally-tagged enum — the most common schemars-generated + /// `$def`. Its body is a union of objects, so it starts with `{` without + /// being an object literal; declaring it as an `interface` produced + /// `interface Shape { … } | { … }`, a syntax error that took the whole + /// declaration file down with it. + #[test] + fn a_union_bodied_named_type_is_declared_as_an_alias() { + let (_, defs) = render_with_defs(json!({ + "type": "object", + "properties": {"shape": {"$ref": "#/$defs/Shape"}}, + "$defs": { + "Shape": { + "oneOf": [ + {"type": "object", "properties": {"Circle": {"type": "number"}}, + "required": ["Circle"]}, + {"type": "object", "properties": {"Square": {"type": "number"}}, + "required": ["Square"]}, + ], + }, + }, + })); + assert!(defs.starts_with("type Shape = {"), "got:\n{defs}"); + assert!(defs.contains("} | {"), "got:\n{defs}"); + assert!(!defs.contains("interface"), "got:\n{defs}"); + } + + /// Same shape, one keyword over: an intersection body. + #[test] + fn an_intersection_bodied_named_type_is_declared_as_an_alias() { + let (_, defs) = render_with_defs(json!({ + "type": "object", + "properties": {"merged": {"$ref": "#/$defs/Merged"}}, + "$defs": { + "Merged": { + "allOf": [ + {"type": "object", "properties": {"a": {"type": "string"}}}, + {"type": "object", "properties": {"b": {"type": "number"}}}, + ], + }, + }, + })); + assert!(defs.starts_with("type Merged = {"), "got:\n{defs}"); + assert!(defs.contains("} & {"), "got:\n{defs}"); + } + + /// `type A = A | string` is a circular alias TypeScript rejects (TS2456). + /// A member that is the whole type constrains nothing, so it drops out. + #[test] + fn a_direct_self_reference_does_not_produce_a_circular_alias() { + let (_, defs) = render_with_defs(json!({ + "type": "object", + "properties": {"a": {"$ref": "#/$defs/A"}}, + "$defs": { + "A": {"anyOf": [{"$ref": "#/$defs/A"}, {"type": "string"}]}, + }, + })); + assert_eq!(defs, "type A = string;\n\n", "got:\n{defs}"); + } + + /// Recursion through a constructor is legitimate and must survive: the + /// reference sits inside an array rather than being the alternative. + #[test] + fn recursion_through_an_array_is_kept() { + let (_, defs) = render_with_defs(json!({ + "type": "object", + "properties": {"tree": {"$ref": "#/$defs/Json"}}, + "$defs": { + "Json": { + "anyOf": [ + {"type": "string"}, + {"type": "array", "items": {"$ref": "#/$defs/Json"}}, + ], + }, + }, + })); + assert_eq!(defs, "type Json = string | Json[];\n\n", "got:\n{defs}"); + } + + /// A pointer to the whole document refers to itself and can only be + /// `unknown`. + #[test] + fn a_root_self_reference_degrades() { + let (_, defs) = render_with_defs(json!({"$ref": "#"})); + assert!(!defs.contains("_ = _"), "circular alias, got:\n{defs}"); + } + + /// `$defs` keys are the server's own field names, so a reserved word is + /// plausible. `type default =` is a parse error and `type string =` is a + /// redeclaration. + #[test] + fn reserved_words_are_not_used_as_type_names() { + for word in ["default", "string", "function", "enum"] { + let (_, defs) = render_with_defs(json!({ + "type": "object", + "properties": {"v": {"$ref": format!("#/$defs/{word}")}}, + "$defs": {word: {"type": "string"}}, + })); + assert!( + defs.starts_with(&format!("type _{word} =")), + "`{word}` should be prefixed, got:\n{defs}" + ); + } } /// The older dialect spells the same thing `definitions`. @@ -595,8 +821,8 @@ mod tests { "B": {"type": "object", "properties": {"a": {"$ref": "#/$defs/A"}}}, }, })); - assert!(defs.contains("interface A"), "got:\n{defs}"); - assert!(defs.contains("interface B"), "got:\n{defs}"); + assert!(defs.contains("type A ="), "got:\n{defs}"); + assert!(defs.contains("type B ="), "got:\n{defs}"); } /// One deployed generator emits `description` beside `$ref`. Resolving the @@ -628,7 +854,7 @@ mod tests { }, "$defs": {"User": {"type": "object", "properties": {"id": {"type": "string"}}}}, })); - assert_eq!(defs.matches("interface User").count(), 1, "got:\n{defs}"); + assert_eq!(defs.matches("type User =").count(), 1, "got:\n{defs}"); } #[test] diff --git a/src/mcp/testdata/constructs.d.ts b/src/mcp/testdata/constructs.d.ts index f098f7d8..34ea605c 100644 --- a/src/mcp/testdata/constructs.d.ts +++ b/src/mcp/testdata/constructs.d.ts @@ -1,20 +1,20 @@ -interface Config { +type Config = { host: string; -} +}; type Json = string | Json[]; -interface Merged { +type Merged = { a?: string; } & { b?: number; -} +}; -interface Shape { +type Shape = { Circle: number; } | { Square: number; -} +}; declare const constructs: { /** Input is a $ref to a named model, the shape pydantic emits for a root model. */ From cd161ca95eb3c8cb6862284a4f1fac09f2569960 Mon Sep 17 00:00:00 2001 From: fluzko Date: Thu, 30 Jul 2026 16:25:11 -0300 Subject: [PATCH 32/39] docs(mcp): trim comments to what the code cannot say --- src/installation.rs | 21 ++++++------------- src/mcp/catalog.rs | 4 ++-- src/mcp/client.rs | 6 ++---- src/mcp/corpus_tests.rs | 38 ++++++++++++--------------------- src/mcp/declarations.rs | 28 ++++++++----------------- src/mcp/resolve.rs | 8 ++----- src/mcp/sandbox.rs | 44 +++++++++++++-------------------------- src/mcp/schema_to_ts.rs | 45 +++++++++++----------------------------- src/mcp/server.rs | 9 +++----- tests/mcp_meta_server.rs | 23 ++++++-------------- 10 files changed, 69 insertions(+), 157 deletions(-) diff --git a/src/installation.rs b/src/installation.rs index 4dd7f9fc..d8cb1617 100644 --- a/src/installation.rs +++ b/src/installation.rs @@ -15,12 +15,9 @@ use symposium_install::{Runnable, acquire_source, make_executable}; /// Run a list of post-install shell commands sequentially. Stops at the first /// failure. /// -/// Output is captured rather than inherited. Under `mcp-serve` our stdout is -/// the JSON-RPC channel, so a command as ordinary as the documented `npx -y -/// --help` warmup would write a line of prose into the middle of the -/// protocol stream and end the session. Nothing is lost by capturing: install -/// chatter is never the user's answer, and on failure the tail below is a -/// better account than interleaved output was. +/// Output is captured, not inherited: under `mcp-serve` stdout is the +/// JSON-RPC channel, and a warmup as ordinary as `npx -y --help` +/// prints there. pub async fn run_install_commands(commands: &[String]) -> Result<()> { for cmd in commands { let output = tokio::process::Command::new("sh") @@ -39,11 +36,8 @@ pub async fn run_install_commands(commands: &[String]) -> Result<()> { Ok(()) } -/// The most useful trailing output from a failed install command. -/// -/// stderr first, since that is where a shell puts its complaint; stdout only -/// when stderr said nothing. Bounded so a verbose build does not become the -/// error message. +/// Trailing output from a failed install command: stderr first, stdout as +/// fallback, bounded so a verbose build does not become the error message. fn failure_tail(output: &std::process::Output) -> Option { const MAX: usize = 2000; let pick = [&output.stderr, &output.stdout] @@ -213,8 +207,7 @@ pub fn resolve_runnable(installation: AcquiredInstallation, label: &str) -> Resu mod tests { use super::*; - /// Capturing output must not cost the diagnosis: when an install command - /// fails, what it printed is the only account of why. + /// What a failing command printed is the only account of why. #[tokio::test] async fn a_failing_install_command_reports_its_stderr() { let err = run_install_commands(&["echo trouble-here >&2; exit 3".to_string()]) @@ -224,7 +217,6 @@ mod tests { assert!(message.contains("trouble-here"), "got: {message}"); } - /// stdout is the fallback when the command said nothing on stderr. #[tokio::test] async fn a_failing_install_command_falls_back_to_stdout() { let err = run_install_commands(&["echo only-on-stdout; exit 1".to_string()]) @@ -233,7 +225,6 @@ mod tests { assert!(err.to_string().contains("only-on-stdout"), "got: {err}"); } - /// A quiet failure still names the command rather than reporting nothing. #[tokio::test] async fn a_silent_failure_still_names_the_command() { let err = run_install_commands(&["exit 7".to_string()]) diff --git a/src/mcp/catalog.rs b/src/mcp/catalog.rs index 84027c26..242ff540 100644 --- a/src/mcp/catalog.rs +++ b/src/mcp/catalog.rs @@ -289,8 +289,8 @@ impl Catalog { } }; - // The same table the declarations are rendered from, so every - // name the model was shown is a name that dispatches. + // The table the declarations are rendered from, so every name the + // model was shown dispatches. let visible: Vec<&str> = tools .iter() .filter(|t| entry.resolved.exposes(t.name.as_ref())) diff --git a/src/mcp/client.rs b/src/mcp/client.rs index 861ce4c4..b7227223 100644 --- a/src/mcp/client.rs +++ b/src/mcp/client.rs @@ -38,8 +38,7 @@ pub struct SpawnSpec { /// Why talking to a backing server failed. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ClientError { - /// Spawn or handshake did not finish in time. `detail` carries whatever - /// the server managed to say on stderr before it stalled. + /// Spawn or handshake did not finish in time; `detail` is the stderr tail. StartupTimeout { server: String, limit_secs: u64, @@ -135,8 +134,7 @@ impl BackingServer { }); } Err(_) => { - // A server that hung mid-handshake has usually said why on - // stderr, and the timeout alone does not carry that. + // A server that hung mid-handshake usually said why on stderr. let detail = drain(stderr).await.filter(|tail| !tail.is_empty()); return Err(ClientError::StartupTimeout { server: spec.name.clone(), diff --git a/src/mcp/corpus_tests.rs b/src/mcp/corpus_tests.rs index 55fbdd93..a8bbdc63 100644 --- a/src/mcp/corpus_tests.rs +++ b/src/mcp/corpus_tests.rs @@ -116,14 +116,9 @@ fn sequentialthinking_server() { )); } -/// The constructs the captured corpus happens not to contain, and which the -/// RFD cites as the reason for hand-rolling the mapping in the first place: -/// `$ref`/`$defs`, the composition keywords, tuples, and type-less schemas. -/// -/// Hand-built rather than captured. That is a weakness — a real payload -/// cannot be argued with — but every one of these shapes is what pydantic, -/// zod or schemars emits for an ordinary Rust or Python model, and none of -/// them appear in anything we managed to capture. +/// `$ref`/`$defs`, the composition keywords, tuples and type-less schemas — +/// what pydantic, zod and schemars emit for an ordinary model, and the one +/// payload here that is hand-built rather than captured. #[test] fn construct_coverage() { expect_file!["testdata/constructs.d.ts"].assert_eq(&render_corpus( @@ -132,27 +127,21 @@ fn construct_coverage() { )); } -/// The generated declarations have to be valid TypeScript. -/// -/// Nothing else checks this: `expect_file!` locks the bytes we emit, not -/// whether a compiler accepts them, so a snapshot can be reviewed, approved -/// and still be a parse error. What the model receives is only useful if it -/// parses. +/// The generated declarations have to parse: `expect_file!` locks the bytes, +/// not whether a compiler accepts them. /// -/// Note what this does *not* catch: `tsc` stops at the first syntax errors -/// and never reaches type checking, so a declaration that parses but -/// describes the wrong shape passes here. The snapshots above are what cover -/// that. Neither check subsumes the other. +/// `tsc` stops at syntax errors and never reaches type checking, so a +/// declaration that parses with the wrong shape passes here — the snapshots +/// above are what cover that. /// -/// Skips when no TypeScript is reachable, so an offline `cargo test` still -/// runs; CI installs Node so it does not skip there. +/// Skips when no TypeScript is reachable; CI installs Node so it does not +/// skip there. #[test] fn declarations_type_check() { let dir = tempfile::tempdir().expect("temp dir"); let mut files = Vec::new(); for (payload, server) in CORPUS { - // One file per server: a name collision between two servers' types is - // theirs to have, not an error we should invent. + // One file per server: two servers may legitimately name a type alike. let name = format!("{server}.d.ts"); std::fs::write(dir.path().join(&name), render_corpus(payload, server)) .expect("write declarations"); @@ -183,9 +172,8 @@ fn declarations_type_check() { String::from_utf8_lossy(&output.stderr), ); - // tsc reports every complaint as `error TS`. A failure carrying - // none of those is the toolchain not running — no network for the - // download, say — and must not be reported as a type error. + // A failure carrying no `error TS` is the toolchain not running + // (no network for the download, say), not a type error. if !output.status.success() && !report.contains("error TS") { eprintln!("skipping declarations_type_check: tsc did not run:\n{report}"); return; diff --git a/src/mcp/declarations.rs b/src/mcp/declarations.rs index ebffff9e..ccc1bdc3 100644 --- a/src/mcp/declarations.rs +++ b/src/mcp/declarations.rs @@ -33,22 +33,18 @@ pub struct ToolBinding { /// Assign JavaScript keys to a server's tools. /// -/// The single source of both the declarations and the runtime bindings. -/// Rendering and dispatch derived their names separately once, and disagreed: -/// only the renderer disambiguated collisions, so it could advertise a name -/// that nothing bound while a colliding pair silently overwrote each other. -/// A name the model is shown has to be a name it can call. +/// The single source of both the declarations and the runtime bindings, so a +/// name the model is shown is one it can call. /// -/// Primaries are assigned before aliases, so a tool keeps its own spelling +/// Two passes: primaries before aliases, so a tool keeps its own spelling /// rather than losing it to another tool's sanitized form. pub fn binding_table<'a>(names: impl IntoIterator) -> Vec { let mut used: Vec = Vec::new(); let mut table: Vec = Vec::new(); for name in names { - // Tool names are unique per server by the protocol. A server that - // advertises one twice leaves the second unaddressable on the wire - // regardless, so declaring it again would only be a duplicate member. + // Tool names are unique per server by the protocol; a repeat is + // unaddressable on the wire either way. if table.iter().any(|b| b.wire_name == name) { continue; } @@ -321,8 +317,7 @@ mod tests { assert!(out.starts_with("declare const sea_orm: {"), "got:\n{out}"); } - /// Every key the declarations advertise has to be one dispatch binds. - /// These were derived separately once and disagreed. + /// The method names in a rendered declaration block. fn declared_keys(out: &str) -> Vec { out.lines() .filter_map(|line| line.trim().strip_suffix("): Promise;")) @@ -346,9 +341,7 @@ mod tests { ); } - /// The case that was broken: the renderer disambiguated and dispatch did - /// not, so the declarations advertised `get_sum_2`, nothing bound it, and - /// `get_sum` was bound twice with one silently shadowing the other. + /// `get-sum` sanitizes to `get_sum`, which another tool already owns. #[test] fn a_colliding_alias_never_shadows_a_real_tool() { let table = binding_table(["get-sum", "get_sum"]); @@ -373,8 +366,7 @@ mod tests { assert_eq!(before, keys.len(), "every key must be distinct: {keys:?}"); } - /// The renderer and the dispatcher read the same table, so what is - /// declared is exactly what is bound. + /// A name the model is shown must be one dispatch binds. #[test] fn declared_names_are_the_bound_names() { let schema = json!({}); @@ -393,9 +385,7 @@ mod tests { assert_eq!(declared_keys(&out), bound, "got:\n{out}"); } - /// A server advertising one name twice would otherwise emit a duplicate - /// member, which is a TypeScript error. The second is unreachable on the - /// wire either way. + /// Two members of the same name is a TypeScript error (TS2300). #[test] fn a_repeated_wire_name_is_declared_once() { let schema = json!({}); diff --git a/src/mcp/resolve.rs b/src/mcp/resolve.rs index 5aefc53c..df7320a9 100644 --- a/src/mcp/resolve.rs +++ b/src/mcp/resolve.rs @@ -229,9 +229,8 @@ struct Candidate<'a> { /// Turn applicable manifest entries into runnable servers. /// -/// `root` anchors a relative `cwd`. A plugin author writing `cwd = -/// "crates/db"` means the workspace's `crates/db`; they cannot know what -/// directory the agent happened to be launched from. +/// `root` anchors a relative `cwd`: a plugin author cannot know what +/// directory the agent was launched from. fn build(entries: Vec>, root: &Path, script_timeout_secs: u64) -> Resolution { let mut resolution = Resolution::default(); // Which plugin claimed each name, so a clash can name both sides. @@ -425,8 +424,6 @@ mod tests { ) } - /// A plugin author writes `cwd` against the workspace, not against - /// whatever directory the agent happened to start in. #[test] fn relative_cwd_resolves_against_the_workspace_root() { let mut entry = stdio("sqlx"); @@ -442,7 +439,6 @@ mod tests { ); } - /// An absolute `cwd` is the author being explicit; leave it alone. #[test] fn absolute_cwd_is_left_as_written() { let mut entry = stdio("sqlx"); diff --git a/src/mcp/sandbox.rs b/src/mcp/sandbox.rs index f8861919..4a82cb4f 100644 --- a/src/mcp/sandbox.rs +++ b/src/mcp/sandbox.rs @@ -179,14 +179,10 @@ impl Sandbox { .enable_time() .build() { - // The middle deadline, and the one that keeps this thread - // from outliving the call. The interrupt handler only - // fires while the interpreter is running, so a script that - // settles into an unresolvable promise — `return new - // Promise(() => {})`, or a tool call left un-awaited — - // parks here with nothing to interrupt. Abandoning the - // future drops the runtime, and with it the tool-call - // sender the caller's dispatch pump is waiting on. + // The interrupt only fires while the interpreter runs, so + // a script parked on an unresolved promise has nothing to + // interrupt. Abandoning the future drops the runtime, and + // with it the tool-call sender the caller's pump awaits. Ok(rt) => rt.block_on(async { let bounded = tokio::time::timeout( limits.timeout + OUTER_GRACE, @@ -219,10 +215,8 @@ impl Sandbox { }); } - // The outermost layer, and a backstop rather than the working - // deadline: the in-thread bound above should already have reported. - // This catches a thread that died without sending, and is given room - // to lose that race so the precise error wins. + // Backstop for a thread that died without reporting. Given room to + // lose the race, so the precise error above wins. match tokio::time::timeout(limits.timeout + OUTER_GRACE * 2, rx).await { Ok(Ok(outcome)) => outcome, Ok(Err(_)) => Err(SandboxError::Internal { @@ -462,9 +456,7 @@ mod tests { ); } - /// The interrupt handler only fires while the interpreter is running, so - /// a script that parks on a promise nothing will ever settle leaves - /// nothing to interrupt. Only the surrounding deadlines can end it. + /// Nothing to interrupt: the deadline is the only thing that can end it. #[tokio::test] async fn a_promise_that_never_settles_still_reports() { let started = Instant::now(); @@ -484,10 +476,8 @@ mod tests { ); } - /// The engine thread owns the only tool-call sender, so a thread that - /// outlives its script keeps the caller's dispatch pump alive forever — - /// which is what left `execute` never answering. Observing the channel - /// close is how we know the thread actually went away. + /// The engine thread owns the only tool-call sender, so the channel + /// closing is how a caller knows the thread went away. #[tokio::test] async fn a_wedged_script_releases_the_dispatch_channel() { let (calls, mut receiver) = dispatch::channel(); @@ -509,9 +499,7 @@ mod tests { ); } - /// A tool call the script never awaited must not keep the engine past its - /// deadline: the reply can only come from a pump the caller stops driving - /// once the script is over. + /// An un-awaited call must not keep the engine past its deadline. #[tokio::test] async fn an_unawaited_tool_call_does_not_outlive_the_deadline() { let (calls, _receiver) = dispatch::channel(); @@ -544,14 +532,10 @@ mod tests { // -- memory -- - /// Guards against the limit silently becoming a no-op: rquickjs documents - /// `set_memory_limit` as inert when a custom allocator is in use, and - /// feature unification means a transitive dependency could enable - /// `rquickjs/rust-alloc` without any other visible effect. - /// - /// The assertion has to be `MemoryExhausted` alone. Accepting a timeout - /// too would let exactly that regression pass: with the limit inert, the - /// loop simply runs until the deadline. + /// `set_memory_limit` is inert under a custom allocator, and feature + /// unification means a transitive dependency could enable + /// `rquickjs/rust-alloc` invisibly. `MemoryExhausted` alone: a timeout + /// would also be reached with the limit inert. #[tokio::test] async fn allocation_is_bounded() { let sandbox = Sandbox::new(Limits { diff --git a/src/mcp/schema_to_ts.rs b/src/mcp/schema_to_ts.rs index 1f0967fd..7d4d2ec6 100644 --- a/src/mcp/schema_to_ts.rs +++ b/src/mcp/schema_to_ts.rs @@ -66,16 +66,9 @@ impl TypeRenderer { /// The named type declarations collected so far, in name order. /// - /// Always a type alias, never an `interface`. Choosing between them meant - /// deciding whether a body was a single object literal, and that was done - /// by testing its first character — which a union or intersection of - /// object types also passes, producing `interface Shape { … } | { … }`. - /// That is a syntax error, and since the declarations are one file, it - /// took every other type down with it. A serde externally-tagged enum is - /// exactly that shape, so it was reachable from any ordinary Rust server. - /// - /// An alias is valid for every body, object literals included, so the - /// choice is not worth the failure mode. + /// Always an alias: a body may be a union or intersection of objects (a + /// serde externally-tagged enum, say), and `interface` accepts only a + /// single object literal. pub fn declarations(&self) -> String { let mut out = String::new(); for (name, body) in &self.named { @@ -172,9 +165,7 @@ fn render_ref(cx: &mut Cx, pointer: &str, depth: usize) -> String { let body = render_at(cx, &target.clone(), 0, depth + 1); cx.in_progress.pop(); - // `type A = A` is a circular alias, which TypeScript rejects outright - // (TS2456). It is what `{"$ref": "#"}` produces, and what is left of a - // union whose only other members were self-references. + // `type A = A` is circular (TS2456); `{"$ref": "#"}` produces it. let body = if body == name { UNKNOWN.to_string() } else { @@ -215,9 +206,7 @@ fn type_name_for(pointer: &str) -> Option { if cleaned.is_empty() { return None; } - // A type name may not start with a digit, and may not be a reserved word - // or a built-in type: `#/$defs/default` would declare `type default =`, - // and `#/$defs/string` is rejected as a redeclaration. + // A type name may not start with a digit, nor be a reserved word. if cleaned.starts_with(|c: char| c.is_ascii_digit()) || is_reserved_type_name(&cleaned) { Some(format!("_{cleaned}")) } else { @@ -225,12 +214,8 @@ fn type_name_for(pointer: &str) -> Option { } } -/// Names TypeScript will not accept as a declared type. -/// -/// Both halves matter and fail differently: a keyword is a parse error, while -/// a built-in type name is rejected as a redeclaration. `$defs` keys come -/// from the server's own field names, so `default`, `object` and `string` are -/// all plausible. +/// Names TypeScript will not accept as a declared type. `$defs` keys are the +/// server's own field names, so `default` and `string` are both plausible. fn is_reserved_type_name(name: &str) -> bool { const RESERVED: &[&str] = &[ // Built-in and intrinsic types. @@ -441,13 +426,11 @@ fn render_intersection(cx: &mut Cx, members: &[Value], indent: usize, depth: usi } } -/// Whether a rendered member is the very type currently being defined. +/// Whether a rendered member is the type currently being defined. /// -/// `type A = A | string` is a circular alias TypeScript rejects (TS2456), -/// and a member that *is* the whole type constrains nothing, so dropping it -/// is both legal and faithful. Only a bare name counts: `type Json = string | -/// Json[]` is legitimate recursion, because the reference sits inside an -/// array rather than being the alternative itself. +/// `type A = A | string` is circular (TS2456), and a member that *is* the +/// whole type constrains nothing. Only a bare name counts: `type Json = +/// string | Json[]` is legitimate recursion. fn is_direct_self_reference(cx: &Cx, rendered: &str) -> bool { cx.in_progress.iter().any(|name| name == rendered) } @@ -480,11 +463,7 @@ fn join_union(parts: Vec) -> String { /// Parenthesize a composite so it binds correctly inside a larger type. /// -/// `A | B[]` parses as `A | (B[])`, and `A & B[]` as `A & (B[])`, so both -/// need wrapping before a suffix is attached. Only unions were guarded -/// before, which left an array of an intersection meaning the wrong thing -/// while still being valid syntax — the kind of error nothing downstream -/// reports. +/// `A | B[]` parses as `A | (B[])`, and `A & B[]` as `A & (B[])`. fn parenthesize_if_composite(rendered: String) -> String { let composite = rendered.contains(" | ") || rendered.contains(" & "); if composite && !rendered.starts_with('(') { diff --git a/src/mcp/server.rs b/src/mcp/server.rs index 4c0c6b26..88df27fa 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -73,12 +73,9 @@ impl MetaServer { let outcome = super::sandbox::Sandbox::new(self.limits) .run_script_with(script, &namespaces, calls) .await; - // Aborted rather than awaited. The sandbox owns the only senders, so - // waiting for the channel to close means waiting on the engine thread - // — and if that thread is wedged, this would never return and the - // request would go unanswered entirely. Once the script is over there - // is nothing left worth pumping: anything still queued is a call the - // script never awaited, past a deadline that has already expired. + // Aborted, not awaited: the sandbox owns the only senders, so waiting + // for the channel to close means waiting on the engine thread. Once + // the script is over, anything still queued is past its deadline. pump.abort(); match outcome { diff --git a/tests/mcp_meta_server.rs b/tests/mcp_meta_server.rs index a39ca04b..264f3627 100644 --- a/tests/mcp_meta_server.rs +++ b/tests/mcp_meta_server.rs @@ -293,10 +293,8 @@ async fn a_script_composes_calls_in_one_round_trip() { let _ = client.cancel().await; } -/// Every name in the declarations has to dispatch to the tool it was -/// declared for. Rendering and dispatch derived their names separately once: -/// only the renderer disambiguated, so it advertised a name nothing bound -/// while the colliding pair silently overwrote each other. +/// Every name in the declarations has to dispatch to the tool it was declared +/// for, including a sanitized alias that collided with a real tool's name. #[tokio::test(flavor = "multi_thread")] async fn a_declared_name_reaches_the_tool_it_was_declared_for() { let workspace = workspace_serving(serde_json::json!({ @@ -319,8 +317,6 @@ async fn a_declared_name_reaches_the_tool_it_was_declared_for() { .expect("list_tools"), ); - // Whatever spelling the alias ended up with, calling it must reach the - // hyphenated tool, and the plain name must reach the underscored one. let script = r#" return { viaAlias: await sqlx.get_sum_2(), @@ -385,11 +381,8 @@ async fn exceeding_a_limit_reports_a_tagged_error() { let _ = client.cancel().await; } -/// A script can end with work outstanding — a promise nothing settles, or a -/// tool call it forgot to await. The engine thread owns the only tool-call -/// sender, so waiting for the dispatch pump to drain means waiting on that -/// thread, and the request goes unanswered rather than reporting a timeout. -/// Forgetting an `await` is an ordinary mistake, so this has to hold. +/// A script can end with work outstanding: a promise nothing settles, or a +/// tool call it forgot to await. Both must still produce a reply. #[tokio::test(flavor = "multi_thread")] async fn a_script_left_pending_still_answers() { let workspace = workspace_with_backing_server(); @@ -403,8 +396,7 @@ async fn a_script_left_pending_still_answers() { for script in [ "return new Promise(() => {});", - // Dispatched but never awaited, so its reply is still outstanding - // when the script's value is already decided. + // Dispatched but never awaited. "sqlx.query({ sql: \"SELECT 1\" }); return \"done\";", ] { let result = tokio::time::timeout( @@ -477,10 +469,7 @@ async fn stdout_carries_only_json_rpc() { } } -/// An `install_commands` entry runs a shell command, and the documented -/// warmup pattern for a package-runner server is `npx -y --help` — -/// which prints. Inheriting our stdout would put that prose between two -/// JSON-RPC frames and end the session. +/// An install command prints, and under `mcp-serve` stdout is the protocol. #[tokio::test(flavor = "multi_thread")] async fn install_command_output_stays_off_stdout() { const MARKER: &str = "SYMPOSIUM-INSTALL-STDOUT-MARKER"; From ed78969d856c2a5300d994e7bd5b07f24e09ea38 Mon Sep 17 00:00:00 2001 From: fluzko Date: Thu, 30 Jul 2026 17:25:17 -0300 Subject: [PATCH 33/39] feat(mcp): start a backing server when a script calls it --- examples/mock-mcp-server.rs | 15 +++ src/mcp/catalog.rs | 145 +++++++++++++++---------- src/mcp/dispatch.rs | 211 ++++++++++++++++++++++++------------ src/mcp/sandbox.rs | 4 - src/mcp/server.rs | 2 +- tests/mcp_meta_server.rs | 86 +++++++++++++++ 6 files changed, 330 insertions(+), 133 deletions(-) diff --git a/examples/mock-mcp-server.rs b/examples/mock-mcp-server.rs index 34e509cf..4506f81d 100644 --- a/examples/mock-mcp-server.rs +++ b/examples/mock-mcp-server.rs @@ -50,6 +50,10 @@ struct Config { /// Protocol version to report. Older versions predate structured output. #[serde(default)] protocol_version: Option, + /// File to append a line to on every start. Lets a test assert that a + /// server was *not* started, which process counting cannot do reliably. + #[serde(default)] + startup_log: Option, #[serde(default)] tools: Vec, } @@ -230,6 +234,17 @@ async fn main() -> anyhow::Result<()> { let config: Config = serde_json::from_str(&std::fs::read_to_string(&config_path)?)?; + if let Some(path) = &config.startup_log { + use std::io::Write; + if let Ok(mut file) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + { + let _ = writeln!(file, "{}", config.name); + } + } + if should_fail_startup(&config_path, config.fail_startup_times) { eprintln!("mock-mcp-server: failing startup on purpose"); std::process::exit(1); diff --git a/src/mcp/catalog.rs b/src/mcp/catalog.rs index 242ff540..25da3910 100644 --- a/src/mcp/catalog.rs +++ b/src/mcp/catalog.rs @@ -20,8 +20,8 @@ use rmcp::model::Tool; use serde_json::Value; use tokio::sync::Mutex; -use super::declarations::{ToolDecl, binding_table, render_server}; -use super::dispatch::{Binding, Namespace}; +use super::declarations::{ToolBinding, ToolDecl, binding_table, render_server}; +use super::dispatch::Namespace; use super::resolve::{Rejection, Resolution, ResolvedServer}; use super::supervisor::{RestartPolicy, Supervisor}; @@ -267,79 +267,64 @@ impl Catalog { /// The namespaces a script sees, one per server. /// - /// Building these needs each server's tool list, so this starts the - /// servers that are not already running. - pub async fn namespaces(&self) -> (Vec, Vec) { - let mut namespaces = Vec::new(); - let mut problems = Vec::new(); - - for entry in &self.entries { - let tools = match self.supervisor_for(entry).await { - Ok(mut guard) => match guard.as_mut() { - Some(supervisor) => supervisor.list_tools().await.map_err(|e| e.to_string()), - None => unreachable!("supervisor_for leaves it present"), - }, - Err(e) => Err(e), - }; - let tools = match tools { - Ok(tools) => tools, - Err(e) => { - problems.push(format!("{}: {e}", entry.resolved.name.as_str())); - continue; - } - }; - - // The table the declarations are rendered from, so every name the - // model was shown dispatches. - let visible: Vec<&str> = tools - .iter() - .filter(|t| entry.resolved.exposes(t.name.as_ref())) - .filter(|t| !self.read_only || is_read_only(t)) - .map(|t| t.name.as_ref()) - .collect(); - - let bindings: Vec = binding_table(visible) - .into_iter() - .flat_map(|b| { - b.keys.into_iter().map(move |key| Binding { - key, - wire_name: b.wire_name.clone(), - }) - }) - .collect(); - - if bindings.is_empty() { - continue; - } - namespaces.push(Namespace { + /// Nothing is started here. Each namespace resolves its tools when the + /// script first calls one, so a script that touches one server does not + /// wait on -- or fail because of -- the others. + pub fn namespaces(&self) -> (Vec, Vec) { + let namespaces = self + .entries + .iter() + .map(|entry| Namespace { key: namespace_key(entry.resolved.name.as_str()), server: entry.resolved.name.as_str().to_string(), - bindings, - }); - } + }) + .collect(); + + // A refused server never becomes a namespace, so its absence needs a + // reason here rather than at call time. + let problems = self + .rejected + .iter() + .map(|r| format!("{}: {}", r.server, r.reason)) + .collect(); (namespaces, problems) } /// Call a tool on a backing server, honoring its filters. - pub async fn call(&self, server: &str, tool: &str, args: Value) -> Result { + /// `key` is the property name the script used, which may be a sanitized + /// alias. Resolving it here rather than when the namespace was built is + /// what lets a server stay cold until something calls it. + pub async fn call(&self, server: &str, key: &str, args: Value) -> Result { let Some(entry) = self.entries.iter().find(|e| e.resolved.name == server) else { return Err(format!( "no server named `{server}`. Available: {}", self.known_names.join(", ") )); }; - if !entry.resolved.exposes(tool) { - return Err(format!( - "`{server}.{tool}` is not exposed by this workspace" - )); - } let timeout = entry.resolved.tool_call_timeout; let mut guard = self.supervisor_for(entry).await?; let supervisor = guard.as_mut().expect("supervisor_for leaves it present"); + + let tools = supervisor.list_tools().await.map_err(|e| e.to_string())?; + let visible: Vec<&str> = tools + .iter() + .filter(|t| entry.resolved.exposes(t.name.as_ref())) + .filter(|t| !self.read_only || is_read_only(t)) + .map(|t| t.name.as_ref()) + .collect(); + + // The same table the declarations are rendered from, so every name + // the model was shown resolves. + let table = binding_table(visible); + let Some(binding) = table.iter().find(|b| b.keys.iter().any(|k| k == key)) else { + return Err(unknown_tool(server, key, &table)); + }; + let wire_name = binding.wire_name.clone(); + supervisor - .call(tool, args, timeout) + .call(&wire_name, args, timeout) .await .map_err(|e| e.to_string()) } @@ -379,6 +364,52 @@ fn query_narrows(query: &Query) -> bool { query.tools.is_some() || query.pattern.is_some() } +/// Report a name the script used that no tool answers to. +/// +/// The proxy hands back a callable for any property, so this is where a typo +/// surfaces. Naming the nearest match makes it recoverable inside the script. +fn unknown_tool(server: &str, key: &str, table: &[ToolBinding]) -> String { + let mut names: Vec<&str> = table + .iter() + .flat_map(|b| b.keys.iter().map(String::as_str)) + .collect(); + names.sort_unstable(); + + match closest(key, &names) { + Some(nearest) => format!( + "`{server}` has no tool `{key}`. Closest match: `{nearest}`. Call `{list}` for the full list.", + list = crate::mcp::server::LIST_TOOLS + ), + None => format!( + "`{server}` exposes no tools. Call `{list}` to see what is available.", + list = crate::mcp::server::LIST_TOOLS + ), + } +} + +/// The candidate sharing the longest prefix with `key`, which catches the +/// mistakes a model actually makes: a wrong suffix or a dropped separator. +fn closest<'a>(key: &str, candidates: &[&'a str]) -> Option<&'a str> { + let normalize = |s: &str| s.to_ascii_lowercase().replace(['-', '_'], ""); + let target = normalize(key); + candidates + .iter() + .copied() + .max_by_key(|candidate| { + let other = normalize(candidate); + let shared = target + .chars() + .zip(other.chars()) + .take_while(|(a, b)| a == b) + .count(); + (shared, usize::MAX - other.len()) + }) + .filter(|candidate| { + let other = normalize(candidate); + target.chars().next() == other.chars().next() + }) +} + fn is_read_only(tool: &Tool) -> bool { tool.annotations .as_ref() diff --git a/src/mcp/dispatch.rs b/src/mcp/dispatch.rs index c510c122..be734066 100644 --- a/src/mcp/dispatch.rs +++ b/src/mcp/dispatch.rs @@ -13,7 +13,7 @@ //! JavaScript, so server and tool names — which come from plugin manifests — //! never reach a code position. -use rquickjs::function::{Async, Opt}; +use rquickjs::function::{Async, Constructor, Opt}; use rquickjs::{CatchResultExt, Ctx, Function, Object}; use serde_json::Value; use tokio::sync::{mpsc, oneshot}; @@ -23,7 +23,8 @@ use tokio::sync::{mpsc, oneshot}; pub struct ToolCall { /// Server name as declared by the plugin, not the sanitized spelling. pub server: String, - /// Tool name as it goes on the wire. + /// The property name the script used. May be a sanitized alias, so the + /// host resolves it against the server's tool list before dispatching. pub tool: String, /// The single argument the script passed, or null. pub args: Value, @@ -39,16 +40,6 @@ pub fn channel() -> (CallSender, CallReceiver) { mpsc::unbounded_channel() } -/// One tool reachable on a namespace. -#[derive(Debug, Clone)] -pub struct Binding { - /// Property name in JavaScript. A tool whose wire name is not an - /// identifier is bound twice, under the quoted name and a sanitized one. - pub key: String, - /// Name to send to the backing server. - pub wire_name: String, -} - /// One backing server as the script sees it. #[derive(Debug, Clone)] pub struct Namespace { @@ -56,36 +47,111 @@ pub struct Namespace { pub key: String, /// Server name to send with each call. pub server: String, - pub bindings: Vec, } -/// Install one global object per namespace. +/// Install one global per namespace. +/// +/// Each is a proxy rather than an object of functions, so nothing has to be +/// known about a server before a script runs. A server starts when a script +/// calls one of its tools, not when the script begins — otherwise one +/// unreachable server delays, or fails, a script that never mentions it. +/// +/// The cost is that a property lookup cannot say whether a tool exists: the +/// proxy hands back a callable for any plausible name and the host resolves +/// it on call. pub fn install<'js>( ctx: &Ctx<'js>, namespaces: &[Namespace], calls: &CallSender, ) -> Result<(), String> { + let proxy: Constructor = ctx + .globals() + .get("Proxy") + .catch(ctx) + .map_err(|e| e.to_string())?; + for namespace in namespaces { - let object = Object::new(ctx.clone()) + let target = Object::new(ctx.clone()) .catch(ctx) .map_err(|e| e.to_string())?; - for binding in &namespace.bindings { - let function = tool_function(ctx, &namespace.server, &binding.wire_name, calls)?; - object - .set(binding.key.as_str(), function) - .catch(ctx) - .map_err(|e| e.to_string())?; - } + let handler = Object::new(ctx.clone()) + .catch(ctx) + .map_err(|e| e.to_string())?; + handler + .set("get", get_trap(ctx, &namespace.server, calls)?) + .catch(ctx) + .map_err(|e| e.to_string())?; + + let installed: Object = proxy + .construct((target, handler)) + .catch(ctx) + .map_err(|e| e.to_string())?; ctx.globals() - .set(namespace.key.as_str(), object) + .set(namespace.key.as_str(), installed) .catch(ctx) .map_err(|e| e.to_string())?; } Ok(()) } +/// Property names the proxy must not answer to. +/// +/// `then` is the load-bearing one: the engine looks for it on any value that +/// is awaited or resolved, so answering it makes the namespace itself a +/// thenable and `await sqlx` hangs on a tool call named `then`. The rest are +/// names the runtime or a serializer reaches for on its own. +fn is_reserved_property(key: &str) -> bool { + matches!( + key, + "then" + | "catch" + | "finally" + | "constructor" + | "prototype" + | "__proto__" + | "toJSON" + | "toString" + | "valueOf" + | "inspect" + ) +} + +fn get_trap<'js>( + ctx: &Ctx<'js>, + server: &str, + calls: &CallSender, +) -> Result, String> { + let server = server.to_string(); + let calls = calls.clone(); + + Function::new( + ctx.clone(), + move |ctx: Ctx<'js>, + _target: rquickjs::Value<'js>, + property: rquickjs::Value<'js>| + -> rquickjs::Result> { + let undefined = rquickjs::Value::new_undefined(ctx.clone()); + + // A symbol key is the runtime asking about the object itself. + let Some(name) = property.as_string() else { + return Ok(undefined); + }; + let key = name.to_string()?; + if is_reserved_property(&key) { + return Ok(undefined); + } + + let function = tool_function(&ctx, &server, &key, &calls) + .map_err(|e| rquickjs::Exception::throw_message(&ctx, &e))?; + Ok(function.into_value()) + }, + ) + .catch(ctx) + .map_err(|e| e.to_string()) +} + fn tool_function<'js>( ctx: &Ctx<'js>, server: &str, @@ -165,17 +231,10 @@ mod tests { use std::sync::{Arc, Mutex}; use std::time::Duration; - fn namespace(server: &str, tools: &[&str]) -> Namespace { + fn namespace(server: &str) -> Namespace { Namespace { key: server.to_string(), server: server.to_string(), - bindings: tools - .iter() - .map(|t| Binding { - key: t.to_string(), - wire_name: t.to_string(), - }) - .collect(), } } @@ -220,7 +279,7 @@ mod tests { async fn script_calls_a_tool_and_receives_its_result() { let (out, asked) = run_with( r#"await sqlx.query({ sql: "SELECT 1" })"#, - vec![namespace("sqlx", &["query"])], + vec![namespace("sqlx")], |_| Ok(json!({"rows": [{"n": 1}]})), ) .await; @@ -251,7 +310,7 @@ mod tests { } return out; "#, - vec![namespace("sqlx", &["query", "explain"])], + vec![namespace("sqlx")], |call| match call.tool.as_str() { "query" => Ok(json!({"rows": [{"n": 1}, {"n": 2}, {"n": 3}]})), _ => Ok(json!({"plan": call.args["n"]})), @@ -274,7 +333,7 @@ mod tests { return "caught: " + e.message; } "#, - vec![namespace("sqlx", &["query"])], + vec![namespace("sqlx")], |_| Err("table not found".to_string()), ) .await; @@ -286,11 +345,9 @@ mod tests { /// value the model might mistake for success. #[tokio::test] async fn uncaught_tool_failure_fails_the_script() { - let (out, _) = run_with( - r#"await sqlx.query({})"#, - vec![namespace("sqlx", &["query"])], - |_| Err("boom".to_string()), - ) + let (out, _) = run_with(r#"await sqlx.query({})"#, vec![namespace("sqlx")], |_| { + Err("boom".to_string()) + }) .await; let err = out.unwrap_err(); @@ -299,58 +356,73 @@ mod tests { #[tokio::test] async fn calling_without_arguments_sends_null() { - let (out, asked) = run_with( - "await clock.now()", - vec![namespace("clock", &["now"])], - |_| Ok(json!(123)), - ) + let (out, asked) = run_with("await clock.now()", vec![namespace("clock")], |_| { + Ok(json!(123)) + }) .await; assert_eq!(out.unwrap(), json!(123)); assert_eq!(asked[0].2, Value::Null); } - /// A tool whose wire name is not a JavaScript identifier is reachable - /// under both spellings, and both dispatch to the same wire name. + /// Both spellings reach the host, each carrying the name the script + /// wrote. Mapping an alias to its wire name needs the server's tool list, + /// so it happens where that list lives. #[tokio::test] - async fn both_spellings_dispatch_to_the_wire_name() { - let ns = Namespace { - key: "sqlx".to_string(), - server: "sqlx".to_string(), - bindings: vec![ - Binding { - key: "migrate-status".to_string(), - wire_name: "migrate-status".to_string(), - }, - Binding { - key: "migrate_status".to_string(), - wire_name: "migrate-status".to_string(), - }, - ], - }; + async fn both_spellings_reach_the_host_as_written() { let (out, asked) = run_with( r#" const a = await sqlx["migrate-status"](); const b = await sqlx.migrate_status(); return [a, b]; "#, - vec![ns], + vec![namespace("sqlx")], |_| Ok(json!("ok")), ) .await; assert_eq!(out.unwrap(), json!(["ok", "ok"])); - assert!( - asked.iter().all(|(_, tool, _)| tool == "migrate-status"), - "both spellings must send the wire name, got: {asked:?}" - ); + let keys: Vec<&str> = asked.iter().map(|(_, tool, _)| tool.as_str()).collect(); + assert_eq!(keys, vec!["migrate-status", "migrate_status"]); + } + + /// The engine looks for `then` on anything awaited. A namespace that + /// answers it becomes a thenable, and `await sqlx` dispatches a tool call + /// named `then` instead of resolving. + #[tokio::test] + async fn a_namespace_is_not_a_thenable() { + let (out, asked) = run_with( + "const v = await sqlx; return typeof v;", + vec![namespace("sqlx")], + |_| Ok(json!("ok")), + ) + .await; + + assert_eq!(out.unwrap(), json!("object")); + assert!(asked.is_empty(), "awaiting a namespace called: {asked:?}"); + } + + /// Nothing is installed per tool, so a name the script invents still + /// reaches the host, which is where it can be reported against the real + /// tool list. + #[tokio::test] + async fn an_unknown_name_reaches_the_host() { + let (_, asked) = run_with( + "try { await sqlx.nonexistent(); } catch (e) {} return 1;", + vec![namespace("sqlx")], + |_| Err("no such tool".to_string()), + ) + .await; + + assert_eq!(asked.len(), 1); + assert_eq!(asked[0].1, "nonexistent"); } #[tokio::test] async fn several_servers_are_separate_namespaces() { let (out, asked) = run_with( "return [await a.ping(), await b.ping()];", - vec![namespace("a", &["ping"]), namespace("b", &["ping"])], + vec![namespace("a"), namespace("b")], |call| Ok(json!(call.server)), ) .await; @@ -362,10 +434,7 @@ mod tests { /// Nothing beyond the declared namespaces appears. #[tokio::test] async fn undeclared_servers_are_absent() { - let (out, _) = run_with("typeof other", vec![namespace("sqlx", &["query"])], |_| { - Ok(Value::Null) - }) - .await; + let (out, _) = run_with("typeof other", vec![namespace("sqlx")], |_| Ok(Value::Null)).await; assert_eq!(out.unwrap(), json!("undefined")); } } diff --git a/src/mcp/sandbox.rs b/src/mcp/sandbox.rs index 4a82cb4f..509715eb 100644 --- a/src/mcp/sandbox.rs +++ b/src/mcp/sandbox.rs @@ -506,10 +506,6 @@ mod tests { let namespaces = vec![dispatch::Namespace { key: "srv".to_string(), server: "srv".to_string(), - bindings: vec![dispatch::Binding { - key: "go".to_string(), - wire_name: "go".to_string(), - }], }]; let started = Instant::now(); diff --git a/src/mcp/server.rs b/src/mcp/server.rs index 88df27fa..1e84c714 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -48,7 +48,7 @@ impl MetaServer { /// Run a model-written script with the workspace's tools in scope. async fn execute(&self, script: &str) -> CallToolResult { - let (namespaces, problems) = self.catalog.namespaces().await; + let (namespaces, problems) = self.catalog.namespaces(); if namespaces.is_empty() { let mut message = String::from("No MCP tools are in scope, so there is nothing to call."); diff --git a/tests/mcp_meta_server.rs b/tests/mcp_meta_server.rs index 264f3627..074f6648 100644 --- a/tests/mcp_meta_server.rs +++ b/tests/mcp_meta_server.rs @@ -352,6 +352,92 @@ async fn a_declared_name_reaches_the_tool_it_was_declared_for() { let _ = client.cancel().await; } +/// A script that touches one server must not start the others. Building +/// namespaces used to need every server's tool list, so a script paid for +/// -- and could be blocked by -- servers it never mentioned. +#[tokio::test(flavor = "multi_thread")] +async fn a_script_starts_only_the_servers_it_calls() { + let dir = tempfile::tempdir().expect("temp dir"); + let base = dir.path().to_path_buf(); + let home = base.join("home"); + let root = base.join("ws"); + let started = base.join("started.log"); + std::fs::create_dir_all(home.join("plugins/db")).unwrap(); + std::fs::create_dir_all(root.join("src")).unwrap(); + + let mut configs = Vec::new(); + for name in ["used", "unused"] { + let path = base.join(format!("{name}.json")); + std::fs::write( + &path, + serde_json::json!({ + "name": name, + "startup_log": started, + "tools": [ + {"name": "ping", "description": "answer", + "behavior": {"kind": "text", "text": name}} + ] + }) + .to_string(), + ) + .unwrap(); + configs.push(path); + } + + let mut manifest = String::from("name = \"db-plugin\"\ndepends-on = [\"*\"]\n"); + for (name, config) in ["used", "unused"].iter().zip(&configs) { + manifest.push_str(&format!( + "\n[[mcp_servers]]\nname = {:?}\ncommand = {:?}\nargs = [\"--config\", {:?}]\n", + name, + mock_binary().display().to_string(), + config.display().to_string(), + )); + } + std::fs::write(home.join("plugins/db/SYMPOSIUM.toml"), manifest).unwrap(); + std::fs::write( + home.join("config.toml"), + "hook-scope = \"project\"\n[defaults]\nsymposium-recommendations = false\n", + ) + .unwrap(); + std::fs::write( + root.join("Cargo.toml"), + "[package]\nname = \"e2e\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\ + [dependencies]\nserde = \"1\"\n", + ) + .unwrap(); + std::fs::write(root.join("src/lib.rs"), "// lib\n").unwrap(); + + let workspace = Workspace { + _dir: dir, + home, + root, + }; + let client = connect_in(&workspace).await; + + let result = client + .call_tool(CallToolRequestParams::new("execute").with_arguments( + serde_json::Map::from_iter([( + "script".to_string(), + serde_json::json!("return await used.ping();"), + )]), + )) + .await + .expect("execute"); + let text = text_of(&result); + assert!(text.contains("used"), "got: {text}"); + + let log = std::fs::read_to_string(&started).unwrap_or_default(); + assert!( + log.contains("used"), + "the called server should have started" + ); + assert!( + !log.contains("unused"), + "a server the script never named was started: {log}" + ); + let _ = client.cancel().await; +} + /// A limit the script exceeded is reported in a form it can act on, rather /// than as prose it has to interpret. #[tokio::test(flavor = "multi_thread")] From 74e1133af2fbf20d8d8a3a02d4be562803280a5e Mon Sep 17 00:00:00 2001 From: fluzko Date: Thu, 30 Jul 2026 18:04:41 -0300 Subject: [PATCH 34/39] feat(mcp): re-resolve the workspace when it changes mid-session --- examples/mock-mcp-server.rs | 4 +- src/bin/cargo-agents.rs | 1 + src/mcp/catalog.rs | 205 +++++++++++++++++++++++++-------- src/mcp/dispatch.rs | 44 +++---- src/mcp/server.rs | 46 +++++--- symposium-sdk/src/workspace.rs | 8 +- tests/mcp_meta_server.rs | 116 ++++++++++++++++++- 7 files changed, 332 insertions(+), 92 deletions(-) diff --git a/examples/mock-mcp-server.rs b/examples/mock-mcp-server.rs index 4506f81d..a57e41f3 100644 --- a/examples/mock-mcp-server.rs +++ b/examples/mock-mcp-server.rs @@ -50,8 +50,8 @@ struct Config { /// Protocol version to report. Older versions predate structured output. #[serde(default)] protocol_version: Option, - /// File to append a line to on every start. Lets a test assert that a - /// server was *not* started, which process counting cannot do reliably. + /// Appended to on every start, so a test can assert a server did *not* + /// start. #[serde(default)] startup_log: Option, #[serde(default)] diff --git a/src/bin/cargo-agents.rs b/src/bin/cargo-agents.rs index ab133c53..c8dba1c8 100644 --- a/src/bin/cargo-agents.rs +++ b/src/bin/cargo-agents.rs @@ -178,6 +178,7 @@ async fn main() -> ExitCode { ..Default::default() }, sym.config.mcp.read_only, + cwd.clone(), )); let limits = symposium::mcp::sandbox::Limits { timeout: std::time::Duration::from_secs(sym.config.mcp.script_timeout_secs), diff --git a/src/mcp/catalog.rs b/src/mcp/catalog.rs index 25da3910..7e224bc3 100644 --- a/src/mcp/catalog.rs +++ b/src/mcp/catalog.rs @@ -22,7 +22,7 @@ use tokio::sync::Mutex; use super::declarations::{ToolBinding, ToolDecl, binding_table, render_server}; use super::dispatch::Namespace; -use super::resolve::{Rejection, Resolution, ResolvedServer}; +use super::resolve::{Rejection, Resolution, ResolvedServer, ServerCommand}; use super::supervisor::{RestartPolicy, Supervisor}; /// How much to say about each tool. @@ -98,19 +98,29 @@ impl Query { } } -/// The workspace's backing servers, described on demand. -pub struct Catalog { +/// What the workspace resolved to, swapped as a whole so a refresh is never +/// observed half-applied. +struct CatalogState { entries: Vec, - read_only: bool, /// Servers that could not be used at all. Reported to the model rather /// than only logged: a server silently missing looks like a workspace /// that never declared it. rejected: Vec, + /// Filter entries the caller named that match no server. + known_names: Vec, +} + +/// The workspace's backing servers, described on demand. +pub struct Catalog { + state: std::sync::RwLock>, + read_only: bool, policy: RestartPolicy, /// Needed to acquire an installation-backed server on first use. sym: Arc, - /// Filter entries the caller named that match no server. - known_names: Vec, + /// Where the session is running, so the workspace can be resolved again. + cwd: std::path::PathBuf, + /// Modification time of `Cargo.lock` when the state was last built. + resolved_at: std::sync::Mutex>, } struct Entry { @@ -118,7 +128,9 @@ struct Entry { /// Absent until first use. Building it may acquire an installation, which /// must not happen at startup — a client may spawn a throwaway copy of the /// meta-server just to probe it. - supervisor: Mutex>, + /// + /// Shared so a refresh can carry a running server into the new state. + supervisor: Arc>>, } impl Catalog { @@ -127,23 +139,65 @@ impl Catalog { resolution: Resolution, policy: RestartPolicy, read_only: bool, + cwd: std::path::PathBuf, ) -> Self { - let Resolution { servers, rejected } = resolution; - let known_names = servers.iter().map(|s| s.name.clone()).collect(); - let entries = servers - .into_iter() - .map(|resolved| Entry { - supervisor: Mutex::new(None), - resolved, - }) - .collect(); + let resolved_at = cargo_lock_mtime(&cwd); Self { - entries, + state: std::sync::RwLock::new(Arc::new(CatalogState::new(resolution, &[]))), read_only, - rejected, policy, sym, - known_names, + cwd, + resolved_at: std::sync::Mutex::new(resolved_at), + } + } + + fn state(&self) -> Arc { + Arc::clone(&self.state.read().expect("catalog state lock")) + } + + /// Rebuild the server set when the workspace changed under us, so a + /// dependency added mid-session exposes its tools without a restart. + /// + /// Servers whose spawn is unchanged are carried across still running. + async fn refresh_if_stale(&self) { + if !self.sym.config.auto_sync { + return; + } + let Some(mtime) = cargo_lock_mtime(&self.cwd) else { + return; + }; + { + let seen = self.resolved_at.lock().expect("catalog mtime lock"); + if *seen == Some(mtime) { + return; + } + } + + let resolution = crate::mcp::resolve::resolve(&self.sym, &self.cwd); + let previous = self.state(); + let next = Arc::new(CatalogState::new(resolution, &previous.entries)); + + // Anything the new state did not adopt is no longer applicable. + let dropped: Vec>>> = previous + .entries + .iter() + .filter(|old| { + !next + .entries + .iter() + .any(|new| Arc::ptr_eq(&new.supervisor, &old.supervisor)) + }) + .map(|old| Arc::clone(&old.supervisor)) + .collect(); + + *self.state.write().expect("catalog state lock") = next; + *self.resolved_at.lock().expect("catalog mtime lock") = Some(mtime); + + for supervisor in dropped { + if let Some(running) = supervisor.lock().await.as_mut() { + running.shutdown().await; + } } } @@ -165,29 +219,31 @@ impl Catalog { } pub fn server_names(&self) -> Vec { - self.known_names.clone() + self.state().known_names.clone() } pub fn is_empty(&self) -> bool { - self.entries.is_empty() + self.state().entries.is_empty() } /// Describe the matching tools. pub async fn describe(&self, query: &Query) -> String { - if self.entries.is_empty() && self.rejected.is_empty() { + self.refresh_if_stale().await; + let state = self.state(); + if state.entries.is_empty() && state.rejected.is_empty() { return "No MCP servers apply to this workspace.".to_string(); } let mut sections = Vec::new(); // Refusals first: they explain an absence the model would otherwise // have to infer. - let mut problems: Vec = self + let mut problems: Vec = state .rejected .iter() .map(|r| format!("{}: {}", r.server, r.reason)) .collect(); - for entry in &self.entries { + for entry in &state.entries { if !query.wants_server(entry.resolved.name.as_str()) { continue; } @@ -239,10 +295,10 @@ impl Catalog { // rather than answering with silence. if let Some(requested) = &query.servers { for name in requested { - if !self.known_names.iter().any(|k| k == name) { + if !state.known_names.iter().any(|k| k == name) { problems.push(format!( "no server named `{name}`. Available: {}", - self.known_names.join(", ") + state.known_names.join(", ") )); } } @@ -267,11 +323,11 @@ impl Catalog { /// The namespaces a script sees, one per server. /// - /// Nothing is started here. Each namespace resolves its tools when the - /// script first calls one, so a script that touches one server does not - /// wait on -- or fail because of -- the others. - pub fn namespaces(&self) -> (Vec, Vec) { - let namespaces = self + /// Nothing is started here; a namespace resolves its tools on first call. + pub async fn namespaces(&self) -> (Vec, Vec) { + self.refresh_if_stale().await; + let state = self.state(); + let namespaces = state .entries .iter() .map(|entry| Namespace { @@ -281,8 +337,8 @@ impl Catalog { .collect(); // A refused server never becomes a namespace, so its absence needs a - // reason here rather than at call time. - let problems = self + // reason here. + let problems = state .rejected .iter() .map(|r| format!("{}: {}", r.server, r.reason)) @@ -293,13 +349,14 @@ impl Catalog { /// Call a tool on a backing server, honoring its filters. /// `key` is the property name the script used, which may be a sanitized - /// alias. Resolving it here rather than when the namespace was built is - /// what lets a server stay cold until something calls it. + /// alias. Resolving it needs the tool list, so this is what starts the + /// server. pub async fn call(&self, server: &str, key: &str, args: Value) -> Result { - let Some(entry) = self.entries.iter().find(|e| e.resolved.name == server) else { + let state = self.state(); + let Some(entry) = state.entries.iter().find(|e| e.resolved.name == server) else { return Err(format!( "no server named `{server}`. Available: {}", - self.known_names.join(", ") + state.known_names.join(", ") )); }; @@ -315,8 +372,7 @@ impl Catalog { .map(|t| t.name.as_ref()) .collect(); - // The same table the declarations are rendered from, so every name - // the model was shown resolves. + // The same table the declarations are rendered from. let table = binding_table(visible); let Some(binding) = table.iter().find(|b| b.keys.iter().any(|k| k == key)) else { return Err(unknown_tool(server, key, &table)); @@ -331,7 +387,7 @@ impl Catalog { /// Close every running server. pub async fn shutdown(&self) { - for entry in &self.entries { + for entry in &self.state().entries { if let Some(supervisor) = entry.supervisor.lock().await.as_mut() { supervisor.shutdown().await; } @@ -340,7 +396,8 @@ impl Catalog { /// How long a script may run against this catalog's servers. pub fn max_call_timeout(&self) -> Duration { - self.entries + self.state() + .entries .iter() .map(|e| e.resolved.tool_call_timeout) .max() @@ -364,10 +421,66 @@ fn query_narrows(query: &Query) -> bool { query.tools.is_some() || query.pattern.is_some() } -/// Report a name the script used that no tool answers to. +impl CatalogState { + /// Adopts any still-matching server from `previous`, so a running child + /// survives a refresh. + fn new(resolution: Resolution, previous: &[Entry]) -> Self { + let Resolution { servers, rejected } = resolution; + let known_names = servers.iter().map(|s| s.name.clone()).collect(); + let entries = servers + .into_iter() + .map(|resolved| { + let supervisor = previous + .iter() + .find(|old| same_spawn(&old.resolved, &resolved)) + .map(|old| Arc::clone(&old.supervisor)) + .unwrap_or_default(); + Entry { + resolved, + supervisor, + } + }) + .collect(); + Self { + entries, + rejected, + known_names, + } + } +} + +/// Whether two resolutions describe the same child process. Only the spawn +/// matters; anything else that moved in the manifest does not. +fn same_spawn(a: &ResolvedServer, b: &ResolvedServer) -> bool { + a.name == b.name + && a.args == b.args + && a.env == b.env + && a.cwd == b.cwd + && match (&a.command, &b.command) { + (ServerCommand::Path(x), ServerCommand::Path(y)) => x == y, + (ServerCommand::Installation(x), ServerCommand::Installation(y)) => x.name == y.name, + _ => false, + } +} + +/// `Cargo.lock`'s modification time, searched upward from `cwd`. Walked +/// rather than asked of cargo: this runs on every describe. +fn cargo_lock_mtime(cwd: &std::path::Path) -> Option { + let mut dir = Some(cwd); + while let Some(current) = dir { + let candidate = current.join("Cargo.lock"); + if let Ok(meta) = std::fs::metadata(&candidate) { + return meta.modified().ok(); + } + dir = current.parent(); + } + None +} + +/// Report a name no tool answers to. /// -/// The proxy hands back a callable for any property, so this is where a typo -/// surfaces. Naming the nearest match makes it recoverable inside the script. +/// The proxy answers any property, so a typo only surfaces here. Naming the +/// nearest match makes it recoverable inside the script. fn unknown_tool(server: &str, key: &str, table: &[ToolBinding]) -> String { let mut names: Vec<&str> = table .iter() @@ -387,8 +500,8 @@ fn unknown_tool(server: &str, key: &str, table: &[ToolBinding]) -> String { } } -/// The candidate sharing the longest prefix with `key`, which catches the -/// mistakes a model actually makes: a wrong suffix or a dropped separator. +/// The candidate sharing the longest prefix with `key`: a wrong suffix or a +/// dropped separator. fn closest<'a>(key: &str, candidates: &[&'a str]) -> Option<&'a str> { let normalize = |s: &str| s.to_ascii_lowercase().replace(['-', '_'], ""); let target = normalize(key); diff --git a/src/mcp/dispatch.rs b/src/mcp/dispatch.rs index be734066..fe032b6f 100644 --- a/src/mcp/dispatch.rs +++ b/src/mcp/dispatch.rs @@ -1,7 +1,7 @@ //! Reaching backing servers from inside the sandbox. //! -//! A script calls `await sqlx.query({...})`. That name is an object installed -//! by the host, whose methods are Rust closures. Each closure hands the call +//! A script calls `await sqlx.query({...})`. That name is a proxy installed +//! by the host; a property lookup yields a Rust closure that hands the call //! to whoever is driving the sandbox and waits for the answer. //! //! The call crosses a runtime boundary. The engine runs on its own thread @@ -12,6 +12,9 @@ //! Namespaces are built through the object API rather than by generating //! JavaScript, so server and tool names — which come from plugin manifests — //! never reach a code position. +//! +//! Nothing is known about a server until a script names one of its tools, so +//! a script does not wait on servers it never mentions. use rquickjs::function::{Async, Constructor, Opt}; use rquickjs::{CatchResultExt, Ctx, Function, Object}; @@ -23,8 +26,8 @@ use tokio::sync::{mpsc, oneshot}; pub struct ToolCall { /// Server name as declared by the plugin, not the sanitized spelling. pub server: String, - /// The property name the script used. May be a sanitized alias, so the - /// host resolves it against the server's tool list before dispatching. + /// The property name the script used. May be a sanitized alias; the host + /// resolves it against the server's tool list. pub tool: String, /// The single argument the script passed, or null. pub args: Value, @@ -51,14 +54,9 @@ pub struct Namespace { /// Install one global per namespace. /// -/// Each is a proxy rather than an object of functions, so nothing has to be -/// known about a server before a script runs. A server starts when a script -/// calls one of its tools, not when the script begins — otherwise one -/// unreachable server delays, or fails, a script that never mentions it. -/// -/// The cost is that a property lookup cannot say whether a tool exists: the -/// proxy hands back a callable for any plausible name and the host resolves -/// it on call. +/// A proxy answers any property with a callable, so nothing about a server +/// need be known here. The cost is that a lookup cannot say whether a tool +/// exists; the host decides that when the call arrives. pub fn install<'js>( ctx: &Ctx<'js>, namespaces: &[Namespace], @@ -98,10 +96,9 @@ pub fn install<'js>( /// Property names the proxy must not answer to. /// -/// `then` is the load-bearing one: the engine looks for it on any value that -/// is awaited or resolved, so answering it makes the namespace itself a -/// thenable and `await sqlx` hangs on a tool call named `then`. The rest are -/// names the runtime or a serializer reaches for on its own. +/// `then` is why this exists: the engine looks for it on anything awaited, so +/// answering makes the namespace a thenable and `await sqlx` dispatches a +/// tool call named `then`. The rest the runtime reaches for on its own. fn is_reserved_property(key: &str) -> bool { matches!( key, @@ -365,9 +362,8 @@ mod tests { assert_eq!(asked[0].2, Value::Null); } - /// Both spellings reach the host, each carrying the name the script - /// wrote. Mapping an alias to its wire name needs the server's tool list, - /// so it happens where that list lives. + /// Both spellings reach the host as written; mapping an alias to its + /// wire name needs the tool list, so it happens where that list is. #[tokio::test] async fn both_spellings_reach_the_host_as_written() { let (out, asked) = run_with( @@ -386,9 +382,8 @@ mod tests { assert_eq!(keys, vec!["migrate-status", "migrate_status"]); } - /// The engine looks for `then` on anything awaited. A namespace that - /// answers it becomes a thenable, and `await sqlx` dispatches a tool call - /// named `then` instead of resolving. + /// A namespace that answers `then` becomes a thenable, and `await sqlx` + /// dispatches a tool call named `then` instead of resolving. #[tokio::test] async fn a_namespace_is_not_a_thenable() { let (out, asked) = run_with( @@ -402,9 +397,8 @@ mod tests { assert!(asked.is_empty(), "awaiting a namespace called: {asked:?}"); } - /// Nothing is installed per tool, so a name the script invents still - /// reaches the host, which is where it can be reported against the real - /// tool list. + /// A name the script invents still reaches the host, which is where it + /// can be checked against the real tool list. #[tokio::test] async fn an_unknown_name_reaches_the_host() { let (_, asked) = run_with( diff --git a/src/mcp/server.rs b/src/mcp/server.rs index 1e84c714..30f66a3c 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -32,23 +32,17 @@ pub const EXECUTE: &str = "execute"; #[derive(Clone)] pub struct MetaServer { catalog: Arc, - /// Names of the backing servers, cached for the tool descriptions. - servers: Arc>, limits: super::sandbox::Limits, } impl MetaServer { pub fn new(catalog: Arc, limits: super::sandbox::Limits) -> Self { - Self { - servers: Arc::new(catalog.server_names()), - catalog, - limits, - } + Self { catalog, limits } } /// Run a model-written script with the workspace's tools in scope. async fn execute(&self, script: &str) -> CallToolResult { - let (namespaces, problems) = self.catalog.namespaces(); + let (namespaces, problems) = self.catalog.namespaces().await; if namespaces.is_empty() { let mut message = String::from("No MCP tools are in scope, so there is nothing to call."); @@ -98,6 +92,8 @@ impl MetaServer { /// small workspace needs no discovery call at all. Their tools are not /// listed here, so the cost grows with servers rather than with schemas. fn execute_description(&self) -> String { + // Read rather than cached: the set can change mid-session. + let servers = self.catalog.server_names(); let mut text = String::from( "Run a JavaScript program with the workspace's MCP tools in scope.\n\n\ Each server is an object whose methods return promises, so a program \ @@ -108,14 +104,14 @@ impl MetaServer { Write an async arrow function or a statement body that returns a value. \ Write plain JavaScript: no type annotations, interfaces, or generics.\n\n", ); - if self.servers.is_empty() { + if servers.is_empty() { text.push_str( "No MCP servers apply to this workspace. Nothing is in scope for `execute`.", ); } else { text.push_str(&format!( "Servers in scope: {}.\nCall `{LIST_TOOLS}` for their tools and signatures.", - self.servers.join(", ") + servers.join(", ") )); } text @@ -292,10 +288,32 @@ mod tests { fn test_server(names: &[&str]) -> MetaServer { let tmp = tempfile::tempdir().expect("temp dir"); let sym = Arc::new(crate::config::Symposium::from_dir(tmp.path())); - let catalog = Catalog::new(sym, Default::default(), RestartPolicy::default(), false); - let mut server = MetaServer::new(Arc::new(catalog), crate::mcp::sandbox::Limits::default()); - server.servers = Arc::new(names.iter().map(|n| n.to_string()).collect()); - server + let resolution = crate::mcp::resolve::Resolution { + servers: names + .iter() + .map(|name| crate::mcp::resolve::ResolvedServer { + name: name.to_string(), + command: crate::mcp::resolve::ServerCommand::Path("/usr/bin/true".into()), + args: Vec::new(), + env: Vec::new(), + cwd: None, + startup_timeout: std::time::Duration::from_secs(30), + tool_call_timeout: std::time::Duration::from_secs(60), + enabled_tools: None, + disabled_tools: None, + requirements: Vec::new(), + }) + .collect(), + rejected: Vec::new(), + }; + let catalog = Catalog::new( + sym, + resolution, + RestartPolicy::default(), + false, + tmp.path().to_path_buf(), + ); + MetaServer::new(Arc::new(catalog), crate::mcp::sandbox::Limits::default()) } #[test] diff --git a/symposium-sdk/src/workspace.rs b/symposium-sdk/src/workspace.rs index df9bf411..b0082c24 100644 --- a/symposium-sdk/src/workspace.rs +++ b/symposium-sdk/src/workspace.rs @@ -230,8 +230,11 @@ pub fn workspace_dir_name(workspace_root: &Path) -> String { format!("{tail}-{hash}") } -/// Get a file's mtime as seconds since the Unix epoch. +/// Get a file's mtime as nanoseconds since the Unix epoch. /// Returns `None` if the file doesn't exist or its metadata can't be read. +/// +/// Nanoseconds, not seconds: callers ask "did this change since I last +/// looked", and an edit followed by a check is well inside one second. pub fn file_mtime(path: &Path) -> Option { let meta = fs::metadata(path).ok()?; let mtime = meta.modified().ok()?; @@ -239,7 +242,8 @@ pub fn file_mtime(path: &Path) -> Option { mtime .duration_since(SystemTime::UNIX_EPOCH) .unwrap_or_default() - .as_secs(), + .as_nanos() + .min(u64::MAX as u128) as u64, ) } diff --git a/tests/mcp_meta_server.rs b/tests/mcp_meta_server.rs index 074f6648..a5030d29 100644 --- a/tests/mcp_meta_server.rs +++ b/tests/mcp_meta_server.rs @@ -352,9 +352,8 @@ async fn a_declared_name_reaches_the_tool_it_was_declared_for() { let _ = client.cancel().await; } -/// A script that touches one server must not start the others. Building -/// namespaces used to need every server's tool list, so a script paid for -/// -- and could be blocked by -- servers it never mentioned. +/// A script that touches one server must not start the others, or it pays +/// for -- and can be blocked by -- servers it never mentions. #[tokio::test(flavor = "multi_thread")] async fn a_script_starts_only_the_servers_it_calls() { let dir = tempfile::tempdir().expect("temp dir"); @@ -438,6 +437,117 @@ async fn a_script_starts_only_the_servers_it_calls() { let _ = client.cancel().await; } +/// A dependency added mid-session exposes its tools without a restart, and a +/// server that is still applicable is carried across rather than restarted -- +/// its startup log must still show a single start. +#[tokio::test(flavor = "multi_thread")] +async fn a_dependency_added_mid_session_appears() { + let dir = tempfile::tempdir().expect("temp dir"); + let base = dir.path().to_path_buf(); + let home = base.join("home"); + let root = base.join("ws"); + let started = base.join("started.log"); + std::fs::create_dir_all(home.join("plugins/db")).unwrap(); + std::fs::create_dir_all(root.join("src")).unwrap(); + + let mut manifest = String::from("name = \"db-plugin\"\ndepends-on = [\"*\"]\n"); + for (name, dep) in [("always", "serde"), ("later", "regex")] { + let config = base.join(format!("{name}.json")); + std::fs::write( + &config, + serde_json::json!({ + "name": name, + "startup_log": started, + "tools": [ + {"name": "ping", "description": "answer", + "behavior": {"kind": "text", "text": name}} + ] + }) + .to_string(), + ) + .unwrap(); + manifest.push_str(&format!( + "\n[[mcp_servers]]\nname = {:?}\ndepends-on = [{:?}]\ncommand = {:?}\n\ + args = [\"--config\", {:?}]\n", + name, + dep, + mock_binary().display().to_string(), + config.display().to_string(), + )); + } + std::fs::write(home.join("plugins/db/SYMPOSIUM.toml"), manifest).unwrap(); + std::fs::write( + home.join("config.toml"), + "hook-scope = \"project\"\n[defaults]\nsymposium-recommendations = false\n", + ) + .unwrap(); + + let cargo_toml = root.join("Cargo.toml"); + std::fs::write( + &cargo_toml, + "[package]\nname = \"e2e\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\ + [dependencies]\nserde = \"1\"\n", + ) + .unwrap(); + std::fs::write(root.join("src/lib.rs"), "// lib\n").unwrap(); + + let workspace = Workspace { + _dir: dir, + home, + root: root.clone(), + }; + let client = connect_in(&workspace).await; + + let first = text_of( + &client + .call_tool(CallToolRequestParams::new("list_tools")) + .await + .expect("list_tools"), + ); + assert!(first.contains("always"), "got: {first}"); + assert!( + !first.contains("later"), + "not a dependency yet, got: {first}" + ); + + // Add the dependency the second server is gated on, exactly as `cargo + // add` would, and let cargo rewrite the lock file. + std::fs::write( + &cargo_toml, + "[package]\nname = \"e2e\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\ + [dependencies]\nserde = \"1\"\nregex = \"1\"\n", + ) + .unwrap(); + let generated = std::process::Command::new("cargo") + .args(["generate-lockfile", "--offline"]) + .current_dir(&root) + .output(); + if !generated.map(|o| o.status.success()).unwrap_or(false) { + eprintln!("skipping: cargo could not resolve `regex` offline"); + let _ = client.cancel().await; + return; + } + + let second = text_of( + &client + .call_tool(CallToolRequestParams::new("list_tools")) + .await + .expect("list_tools"), + ); + assert!( + second.contains("later"), + "a newly applicable server should appear, got: {second}" + ); + + let log = std::fs::read_to_string(&started).unwrap_or_default(); + assert_eq!( + log.matches("always").count(), + 1, + "the still-applicable server was restarted: {log}" + ); + let _ = client.cancel().await; +} + /// A limit the script exceeded is reported in a form it can act on, rather /// than as prose it has to interpret. #[tokio::test(flavor = "multi_thread")] From 4638417429c71bb16225a778981d8f4fe89ac093 Mon Sep 17 00:00:00 2001 From: fluzko Date: Thu, 30 Jul 2026 18:11:12 -0300 Subject: [PATCH 35/39] docs(mcp): record where the implementation differs from the RFD --- md/rfds/mcp-meta-server/README.md | 77 +++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/md/rfds/mcp-meta-server/README.md b/md/rfds/mcp-meta-server/README.md index 3955252d..e1a2ff6f 100644 --- a/md/rfds/mcp-meta-server/README.md +++ b/md/rfds/mcp-meta-server/README.md @@ -343,3 +343,80 @@ Re-resolve the workspace on `list_tools` when `Cargo.lock` mtime has changed sin - [ ] Track `Cargo.lock` mtime at startup - [ ] On `list_tools`, check mtime; if changed and auto-sync enabled, re-resolve - [ ] Integration test: modify fixture's `Cargo.lock` mid-session, verify index updates + + +# Implementation vs design + +Everything here came out of two things, surveying what shipping MCP servers actually emit, and using the feature. + +### The capability index moved from `list_tools` to `execute` + +As designed, the server inventory lives in `list_tools`'s description, so a model must call `list_tools` before it knows anything at all. It now lives in `execute`'s description instead. + +The inventory costs about twenty tokens per server and is what a model needs to decide whether to look further. Putting it on the tool it will actually call removes the discovery round trip entirely for a small workspace, which is the common case. + +### `list_tools` returns an index by default, and truncation was dropped + +The design truncates the index past a byte budget. Truncation is lossy and varies with the workspace, so the same question gets a different answer in different projects. + +Instead `list_tools` answers with a name-and-description index and returns full declarations only when asked, by naming servers or tools, or by an explicit detail level. An index is lossless and strictly smaller than a truncated dump. + +The sizing that forces this is that one mainstream server's `tools/list` runs about 49 KB. The extreme case is not many tools but one tool with a very long description, so the reference "sequential thinking" server is 4.6 KB for a single tool. + +### Tools are declared as an object, not a namespace + +The design shows `declare namespace sqlx { function query(...) }`. Shipped declarations use an object instead: `declare const sqlx: { query(...) }`. + +A TypeScript namespace cannot declare a member whose name is not a valid identifier, and hyphenated tool names are ordinary. An object type accepts quoted keys, so a hyphenated tool can be both declared and called. Each such tool is bound under both spellings. + +### Results are unwrapped, and return types are `unknown` rather than `any` + +The design's own example calls `.rows.map(...)` on a tool result, which only works if the host unwraps MCP's result envelope, but the design never says it does, while declaring return types `any`. + +Both halves changed. Results are unwrapped through an explicit ladder that checks the error flag _first_ (a server can report an error _and_ structured content, and checking content first swallows the error), and one popular Python framework's extra result wrapper is unwrapped too, since it survives a proxy hop. + +Return types are `Promise`. `unknown` forces a model to narrow the value rather than assume a shape; `any` invites the assumption. Typing returns from a server's declared output schema is deliberately not built. +Adoption is roughly 3 tools in 330 surveyed, and it is bimodal rather than uniformly absent, so the seam is worth keeping and the compiler is not worth writing. + +### The type-mapping table + +Measured against real captured tool lists, the table in this document sends **well under 2% of schema nodes** to `any`, for several servers, none at all. So motivation is narrower: one generator family, pydantic, systematically emits two constructs the table drops (an optional spelled as a union with null, and named type references), and everything else is a long tail. + +One construct absent from the table turned out to matter more than any listed: `additionalProperties: false` is the single most common thing in real schemas, and it must be **ignored**. A naive "unrecognized keyword becomes `unknown`" rule turns every ordinary object from three of the surveyed servers into `unknown`. + +### Model-written code normalization before running + +The model is handed TypeScript declarations and its output is run in a JavaScript engine that cannot parse a type annotation. Markdown fences, `export default`, a bare expression versus a statement body, and a named function that is never called are all ordinary things a model produces. + +Submitted source is normalized before evaluation, using the engine's own parser to decide between an expression and a statement body rather than guessing. + +### Timeouts + +A memory cap does not stop an infinite loop, and a timeout on the host does not stop a script that is spinning inside the interpreter. + +Two layers: an interrupt that fires while the interpreter runs and raises an error the script **cannot catch**, plus an outer deadline for a script blocked awaiting a host call, where the interpreter is idle and the interrupt can never fire. Neither alone is sufficient. Result size and console output are bounded too (an unbounded tool result lands directly in the agent's context, defeating this document's own thesis). +The default budget is 120 seconds rather than 30. A script that composes several calls against servers that each take seconds is the case the feature exists for. + +### A crashed server and duplicate name + +The design has a `Dead` state restarted on the next call, which crash-loops forever against a server that cannot start. There is now a restart cap with exponential backoff and a terminal failed state, plus a stability reset so a server that fails rarely is not permanently condemned. + +The design also resolves two plugins claiming one server name by first-registered-wins with a warning. That silently drops one plugin's entire server, and a warning on a stdio server's stderr is invisible. Both are now refused by name, as is a server taking a name the meta-server itself uses. Refusals appear in `list_tools` output rather than only in a log, so a real session showed that a refusal nobody can see is no better than the silent drop it replaced. + +### v1 is stdio only + +The design says the meta-server bridges whatever transport a backing server declares. HTTP and SSE entries parse but are refused with a reason. + +Two causes: the Rust SDK has no legacy SSE client, and SSE is still the default for URL-configured servers in most comparable projects; and forwarding credentials to a remote endpoint is an exfiltration surface not considered. + +### Symposium owns the plugin manifest shape + +Not in the original plan at all, but it came out of using the feature. Every entry needed empty `args` and `env` written out explicitly, and omitting either produced an error that named no field. + +The cause was one type doing three jobs: a wire format, a manifest schema, and a config-file schema. Symposium now defines the manifest shape itself giving optional fields default, environment is written as a table rather than a list of pairs, unknown keys are rejected by name, and a server can name an installation to acquire before it starts. + +### Registration flips last, not first + +The design's first step rewrites agent config to the single meta-server entry, before the meta-server exists. A release cut between that step and a working `mcp-serve` ships broken MCP for every user who takes it. + +It is now the last step, gated on a config flag, which also keeps the old behavior measurable against the new one without reverting code. From c8493f7f64603c7a63d644d5828a920cd10fc934 Mon Sep 17 00:00:00 2001 From: fluzko Date: Tue, 11 Aug 2026 17:19:37 -0300 Subject: [PATCH 36/39] feat(mcp): type tool returns from their output schema --- Cargo.lock | 265 ++++++++++++++++++++++- Cargo.toml | 1 + md/design/module-structure.md | 3 +- md/rfds/mcp-meta-server/README.md | 21 +- src/mcp/catalog.rs | 62 +++++- src/mcp/corpus_tests.rs | 5 +- src/mcp/declarations.rs | 120 +++++++++- src/mcp/mod.rs | 1 + src/mcp/server.rs | 27 ++- src/mcp/testdata/everything.d.ts | 18 +- src/mcp/testdata/filesystem.d.ts | 67 ++++-- src/mcp/testdata/memory.d.ts | 101 ++++++++- src/mcp/testdata/sequentialthinking.d.ts | 8 +- src/mcp/validate.rs | 169 +++++++++++++++ tests/mcp_meta_server.rs | 207 +++++++++++++++++- 15 files changed, 1025 insertions(+), 50 deletions(-) create mode 100644 src/mcp/validate.rs diff --git a/Cargo.lock b/Cargo.lock index 3142c4c1..4bd9e267 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -56,6 +56,20 @@ dependencies = [ "strum", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -210,6 +224,21 @@ version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b25655df2c3cdd83c5e5b293b88acd880332b2ddadd7c30ac43144fdc0033da9" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "2.11.0" @@ -225,6 +254,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + [[package]] name = "boxfnonce" version = "0.1.1" @@ -237,6 +272,12 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "bytes" version = "1.11.1" @@ -544,6 +585,12 @@ dependencies = [ "syn", ] +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "deflate64" version = "0.1.12" @@ -655,6 +702,15 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + [[package]] name = "encode_unicode" version = "1.0.0" @@ -715,6 +771,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "fancy-regex" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "476de73bddf2ef8490aa4ee8f1cf40b430bf1d56c48c22080e5186952cd580e6" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -755,6 +822,17 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + [[package]] name = "fnv" version = "1.0.7" @@ -782,6 +860,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fraction" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872" +dependencies = [ + "lazy_static", + "num", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -1396,6 +1484,58 @@ dependencies = [ "serde_json", ] +[[package]] +name = "jsonschema" +version = "0.49.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ec8a241beed129f06114aa68007e905ca350e7baeb6e17a7631bb7978d91b2" +dependencies = [ + "ahash", + "bytecount", + "data-encoding", + "email_address", + "fancy-regex", + "fraction", + "getrandom 0.3.4", + "idna", + "itoa", + "jsonschema-regex", + "jsonschema-value", + "num-cmp", + "num-traits", + "percent-encoding", + "referencing", + "regex", + "serde", + "serde_json", + "strum", + "unicode-general-category", + "uuid-simd", +] + +[[package]] +name = "jsonschema-regex" +version = "0.49.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91994f45017ed5e66aa8e59b8415f4cb033a6380d7200387b7cf117595fbdf85" +dependencies = [ + "regex-syntax", +] + +[[package]] +name = "jsonschema-value" +version = "0.49.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ec7637f83e510868ae6ed625f7ebfbbde4554ee8ce49854caa5126a8b9b9ecb" +dependencies = [ + "ahash", + "bytecount", + "fraction", + "num-cmp", + "num-traits", + "serde_json", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1489,6 +1629,12 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "micromap" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" + [[package]] name = "mime" version = "0.3.17" @@ -1537,12 +1683,81 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1576,6 +1791,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "parking" version = "2.2.1" @@ -1898,6 +2119,23 @@ dependencies = [ "syn", ] +[[package]] +name = "referencing" +version = "0.49.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6efa2154ea6f5ce0fdecdd2a8d18f2fa1a39a8fbba91564f555a592e4dce8278" +dependencies = [ + "ahash", + "fluent-uri", + "getrandom 0.3.4", + "hashbrown 0.17.1", + "itoa", + "micromap", + "parking_lot", + "percent-encoding", + "serde_json", +] + [[package]] name = "regex" version = "1.12.3" @@ -1912,9 +2150,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -2638,6 +2876,7 @@ dependencies = [ "flate2", "home", "indoc", + "jsonschema", "regex", "reqwest 0.12.28", "rmcp 3.0.0", @@ -3146,6 +3385,12 @@ version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -3217,6 +3462,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "valuable" version = "0.1.1" @@ -3229,6 +3484,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index 0db1c02a..47b519fe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,6 +56,7 @@ url = "2.5.8" symposium-install = { version = "0.1.0", path = "symposium-install", features = ["clap"] } rquickjs = { version = "0.12.2", features = ["futures", "macro"] } rmcp = { version = "3", features = ["server", "client", "transport-io", "transport-child-process", "macros"] } +jsonschema = { version = "0.49.9", default-features = false } [dev-dependencies] diff --git a/md/design/module-structure.md b/md/design/module-structure.md index 098aeb12..c504b000 100644 --- a/md/design/module-structure.md +++ b/md/design/module-structure.md @@ -161,7 +161,8 @@ One MCP server is registered with each agent (see [`sync.rs`](#syncrs--synchroni - `client.rs` — the only protocol-shaped file; the rest of the module works in `serde_json::Value`. Distinguishes a tool failure (a successful response carrying an error flag) from a protocol failure, and unwraps the content envelope into the value a script wants. - `sandbox.rs` / `console.rs` / `normalize.rs` — the QuickJS sandbox a script runs in (deny-by-construction: no filesystem, network, process or module loader is ever registered; two layers of deadline, since an interpreter interrupt cannot fire while a script awaits a host call), the bounded `console` capture, and the normalization that turns model-written TypeScript / fenced / statement-body source into something the engine can evaluate. - `dispatch.rs` — how a script reaches a backing server. Namespaces are built through the object API rather than by generating JavaScript, so manifest-supplied server and tool names never reach a code position; a channel keeps the engine thread and the servers' I/O each on the runtime they belong to. -- `declarations.rs` / `schema_to_ts.rs` — rendering a server's tools as TypeScript. Each server is an object of methods (not a `declare namespace`), so a tool whose wire name is not a JS identifier can still be declared. Schema conversion never fails (an unrecognized construct becomes `unknown`, never `any`, so the model must narrow). `testdata/` holds real-world tool dumps and expected `.d.ts` output, checked by `corpus_tests.rs`. +- `declarations.rs` / `schema_to_ts.rs` — rendering a server's tools as TypeScript. Each server is an object of methods (not a `declare namespace`), so a tool whose wire name is not a JS identifier can still be declared. Schema conversion never fails (an unrecognized construct becomes `unknown`, never `any`, so the model must narrow). A tool declaring an `outputSchema` renders that as its return type; one that does not stays `Promise`. `testdata/` holds real-world tool dumps and expected `.d.ts` output, checked by `corpus_tests.rs`, whose `declarations_type_check` runs the generated declarations through real `tsc`. +- `validate.rs` — checking a result against the `outputSchema` its tool declared, called from `Catalog::call` after unwrapping (that is the value a script receives). A mismatch is **tagged, never fatal**, since answering with text where an object was declared is ordinary MCP and the value is usually still readable: `Catalog::call` returns a `CallOutcome { value, notice }`, the script gets the value unchanged, and `server.rs`'s call pump collects the notice — deduplicated, one tool being one fact — into the same `problems` list `execute` already reports back. The notice is one line (`.: result off-shape, treat as unknown`) rather than prose, because a correction costing more context than the type it corrects would defeat the design; the failure detail goes to the log. Remote `$ref`s are never resolved, so a third-party schema cannot trigger an outbound request, and an uncompilable schema is not checked at all. ### `state.rs` — persistent state diff --git a/md/rfds/mcp-meta-server/README.md b/md/rfds/mcp-meta-server/README.md index e1a2ff6f..8cf3b084 100644 --- a/md/rfds/mcp-meta-server/README.md +++ b/md/rfds/mcp-meta-server/README.md @@ -369,14 +369,29 @@ The design shows `declare namespace sqlx { function query(...) }`. Shipped decla A TypeScript namespace cannot declare a member whose name is not a valid identifier, and hyphenated tool names are ordinary. An object type accepts quoted keys, so a hyphenated tool can be both declared and called. Each such tool is bound under both spellings. -### Results are unwrapped, and return types are `unknown` rather than `any` +### Results are unwrapped, and a declared output schema becomes the return type The design's own example calls `.rows.map(...)` on a tool result, which only works if the host unwraps MCP's result envelope, but the design never says it does, while declaring return types `any`. Both halves changed. Results are unwrapped through an explicit ladder that checks the error flag _first_ (a server can report an error _and_ structured content, and checking content first swallows the error), and one popular Python framework's extra result wrapper is unwrapped too, since it survives a proxy hop. -Return types are `Promise`. `unknown` forces a model to narrow the value rather than assume a shape; `any` invites the assumption. Typing returns from a server's declared output schema is deliberately not built. -Adoption is roughly 3 tools in 330 surveyed, and it is bimodal rather than uniformly absent, so the seam is worth keeping and the compiler is not worth writing. +A tool that declares an `outputSchema` gets that schema as its return type; one that does not stays `Promise`. `unknown` forces a model to narrow the value rather than assume a shape; `any` invites the assumption. + +Typing the return raises a problem the design does not consider: nothing obliges a server to send what it declared. The unwrap ladder has four exits, and only the structured-content one can match an `outputSchema`. A server that answers with text lands on the text exit, and answering with text is not a defect, it is ordinary. So a declared type is a statement of intent, not a guarantee. + +The declared schema is therefore checked against the value at call time, and a mismatch is **tagged, never fatal**. Failing the call would discard a result the model can usually still read: `"count: 3"` is not the declared object but plainly carries the answer. Instead the value is passed through untouched, and one line rides back with it: + +``` +[memory.search_nodes: result off-shape, treat as unknown] +``` + +That is the one thing the model cannot work out for itself. Without it, it meets the breach as an `undefined` property, blames its own code, and typically spends another round trip probing the tool. + +The tag is deliberately not prose. A correction that costs more context than the type it corrects would defeat the purpose of the whole design, so which field was wrong is left out: the value accompanies the tag, so the model can read what it actually got, and the full failure list goes to the log where it costs nothing and is still there for whoever is debugging the server. The tag also rides the same channel as the other problems `execute` reports, so it never alters the value the script sees, and it is deduplicated: one tool answering off-shape in a loop is one fact about that tool. + +Two further limits: remote `$ref`s are never resolved, since fetching a URL a third-party schema names would turn a declaration into an outbound request; and a schema that cannot be compiled is not checked at all, matching the renderer's own rule that generation never fails whatever a server sends. + +Adoption is worth stating plainly, because an earlier draft of this section got it wrong. In the captured corpus 25 of 72 tools declare an output schema, and it is bimodal rather than rare: two of the six servers declare one on every tool, three declare none. Output schemas are also a recent protocol addition, so the share is more likely to grow than shrink. ### The type-mapping table diff --git a/src/mcp/catalog.rs b/src/mcp/catalog.rs index 2c8c3029..efdeabd8 100644 --- a/src/mcp/catalog.rs +++ b/src/mcp/catalog.rs @@ -123,6 +123,18 @@ pub struct Catalog { resolved_at: std::sync::Mutex>, } +/// What one tool call produced. +/// +/// The notice is carried beside the value rather than folded into it: wrapping +/// the result would change the shape the script sees, and the script is not who +/// the notice is for. +pub struct CallOutcome { + pub value: Value, + /// Set when the server's answer did not match the output schema it + /// declared, so the caller can tell the model its type did not hold. + pub notice: Option, +} + struct Entry { resolved: ResolvedServer, /// Absent until first use. Building it may acquire an installation, which @@ -351,7 +363,7 @@ impl Catalog { /// `key` is the property name the script used, which may be a sanitized /// alias. Resolving it needs the tool list, so this is what starts the /// server. - pub async fn call(&self, server: &str, key: &str, args: Value) -> Result { + pub async fn call(&self, server: &str, key: &str, args: Value) -> Result { let state = self.state(); let Some(entry) = state.entries.iter().find(|e| e.resolved.name == server) else { return Err(format!( @@ -379,10 +391,32 @@ impl Catalog { }; let wire_name = binding.wire_name.clone(); - supervisor + // Taken before the call so the tool list, which the mutable borrow of + // the supervisor ends, is still in hand. + let declared = tools + .iter() + .find(|t| t.name.as_ref() == wire_name) + .and_then(|t| t.output_schema.clone()); + + let value = supervisor .call(&wire_name, args, timeout) .await - .map_err(|e| e.to_string()) + .map_err(|e| e.to_string())?; + + // Checked after unwrapping, since that is the value a script actually + // receives. A mismatch is reported, never fatal: the value may still be + // readable, and the model is the one who can decide (see + // [`crate::mcp::validate`]). + let notice = declared.and_then(|schema| { + crate::mcp::validate::check_result( + server, + &wire_name, + &Value::Object((*schema).clone()), + &value, + ) + }); + + Ok(CallOutcome { value, notice }) } /// Close every running server. @@ -546,21 +580,33 @@ fn render(server: &str, tools: &[&Tool], detail: Detail) -> String { } Detail::Signatures | Detail::Full => { // The schemas are owned so the declaration renderer, which works - // in plain JSON, never sees a protocol type. - let schemas: Vec> = tools + // in plain JSON, never sees a protocol type. Output schemas follow + // the input schema's gate: signatures name a tool's shape-free + // form, so spelling out the return there while hiding the + // parameter would be inconsistent. + let schemas: Vec<(Option, Option)> = tools .iter() .map(|tool| { - (detail == Detail::Full).then(|| Value::Object((*tool.input_schema).clone())) + if detail != Detail::Full { + return (None, None); + } + ( + Some(Value::Object((*tool.input_schema).clone())), + tool.output_schema + .as_ref() + .map(|schema| Value::Object((**schema).clone())), + ) }) .collect(); let decls: Vec = tools .iter() .zip(&schemas) - .map(|(tool, schema)| ToolDecl { + .map(|(tool, (input, output))| ToolDecl { name: tool.name.as_ref(), description: tool.description.as_deref(), // Signatures name the parameter; full spells out its shape. - input_schema: schema.as_ref(), + input_schema: input.as_ref(), + output_schema: output.as_ref(), }) .collect(); render_server(server, &decls) diff --git a/src/mcp/corpus_tests.rs b/src/mcp/corpus_tests.rs index a8bbdc63..af299095 100644 --- a/src/mcp/corpus_tests.rs +++ b/src/mcp/corpus_tests.rs @@ -43,6 +43,7 @@ fn render_corpus(payload: &str, server: &str) -> String { name: tool["name"].as_str().unwrap_or_default(), description: tool["description"].as_str(), input_schema: tool.get("inputSchema"), + output_schema: tool.get("outputSchema"), }) .collect(); @@ -59,7 +60,9 @@ fn every_tool_in_the_corpus_produces_a_declaration() { let parsed: Value = serde_json::from_str(payload).unwrap(); let expected = parsed["tools"].as_array().unwrap().len(); let rendered = render_corpus(payload, server); - let found = rendered.matches("): Promise;").count(); + // Return-type agnostic: a tool that declares an output schema renders + // `Promise<{ ... }>` rather than `Promise`. + let found = rendered.matches("): Promise<").count(); assert!( found >= expected, "{server}: expected at least {expected} declarations, found {found}" diff --git a/src/mcp/declarations.rs b/src/mcp/declarations.rs index ccc1bdc3..a65396d2 100644 --- a/src/mcp/declarations.rs +++ b/src/mcp/declarations.rs @@ -17,6 +17,10 @@ pub struct ToolDecl<'a> { pub name: &'a str, pub description: Option<&'a str>, pub input_schema: Option<&'a Value>, + /// The tool's declared output schema, when it has one. Rendered as the + /// return type, and validated against the returned value at call time so + /// the declaration is a claim the host enforces rather than a hint. + pub output_schema: Option<&'a Value>, } /// The JavaScript keys one tool answers to, primary first. @@ -75,6 +79,7 @@ pub fn render_server(server: &str, tools: &[ToolDecl]) -> String { continue; }; let params = render_params(&mut types, tool.input_schema); + let result = render_result(&mut types, tool.output_schema); for (index, key) in binding.keys.iter().enumerate() { if index == 0 { @@ -87,10 +92,7 @@ pub fn render_server(server: &str, tools: &[ToolDecl]) -> String { let name = jsdoc_text(tool.name).unwrap_or_else(|| "the same tool".to_string()); methods.push_str(&format!(" /** Alias for {name}. */\n")); } - methods.push_str(&format!( - " {}({params}): Promise;\n", - render_key(key) - )); + methods.push_str(&format!(" {}({params}): {result};\n", render_key(key))); } } @@ -128,6 +130,23 @@ fn render_params(types: &mut TypeRenderer, schema: Option<&Value>) -> String { format!("params{optional}: {}", types.render_indented(schema, 1)) } +/// Render a tool's return type. +/// +/// A declared output schema becomes the return type; a tool without one stays +/// `unknown`, which forces the model to narrow rather than assume a shape. +/// +/// Nothing obliges a server to send what it declared, so this is intent rather +/// than guarantee. The same schema is checked against the value at call time +/// and a mismatch is tagged, not refused — see [`crate::mcp::validate`]. +fn render_result(types: &mut TypeRenderer, schema: Option<&Value>) -> String { + match schema { + // Same indent as the parameter type: both sit inside the server + // object's braces. + Some(schema) => format!("Promise<{}>", types.render_indented(schema, 1)), + None => "Promise".to_string(), + } +} + fn has_properties(schema: &Value) -> bool { schema .get("properties") @@ -191,6 +210,7 @@ mod tests { name, description: None, input_schema: Some(schema), + output_schema: None, } } @@ -208,15 +228,98 @@ mod tests { ); } - /// Return types are deliberately untyped: almost no server in the wild - /// declares an output schema, so promising a shape would be a lie. + /// A tool that declares no output schema stays untyped. `unknown` forces + /// the model to narrow rather than assume a shape nobody promised. #[test] - fn every_tool_returns_a_promise_of_unknown() { + fn a_tool_without_an_output_schema_returns_unknown() { let schema = json!({"type": "object", "properties": {"a": {"type": "string"}}}); let out = render_server("s", &[tool("t", &schema)]); assert!(out.contains("): Promise;"), "got:\n{out}"); } + fn typed_tool<'a>(name: &'a str, input: &'a Value, output: &'a Value) -> ToolDecl<'a> { + ToolDecl { + name, + description: None, + input_schema: Some(input), + output_schema: Some(output), + } + } + + #[test] + fn a_declared_output_schema_becomes_the_return_type() { + let input = json!({"type": "object", "properties": {"q": {"type": "string"}}}); + let output = json!({ + "type": "object", + "properties": {"count": {"type": "integer"}}, + "required": ["count"], + }); + let out = render_server("s", &[typed_tool("search", &input, &output)]); + assert!( + out.contains("): Promise<{\n count: number;\n }>;"), + "got:\n{out}" + ); + assert!( + !out.contains("Promise"), + "a declared shape should replace `unknown`:\n{out}" + ); + } + + /// A named type in an output schema goes through the same hoisting as one + /// in a parameter schema, so it is declared once above the server object. + #[test] + fn a_named_type_in_an_output_schema_is_hoisted() { + let input = json!({"type": "object"}); + let output = json!({ + "type": "object", + "properties": {"hit": {"$ref": "#/definitions/Hit"}}, + "definitions": { + "Hit": { + "title": "Hit", + "type": "object", + "properties": {"score": {"type": "number"}}, + }, + }, + }); + let out = render_server("s", &[typed_tool("find", &input, &output)]); + let server_at = out.find("declare const s:").expect("server declared"); + let hit_at = out.find("Hit").expect("named type rendered"); + assert!( + hit_at < server_at, + "the named type should precede the server object:\n{out}" + ); + } + + /// Both spellings of a hyphenated tool are the same tool, so both carry the + /// same return type. + #[test] + fn an_aliased_tool_carries_its_return_type_on_both_spellings() { + let input = json!({"type": "object"}); + let output = json!({"type": "object", "properties": {"ok": {"type": "boolean"}}}); + let out = render_server("s", &[typed_tool("get-sum", &input, &output)]); + let typed = out.matches("Promise<{").count(); + assert_eq!(typed, 2, "both spellings should be typed:\n{out}"); + } + + /// The renderer's never-fail rule applies to output schemas too: a + /// construct it does not understand degrades rather than panicking. + #[test] + fn an_unrecognized_output_construct_degrades() { + let input = json!({"type": "object"}); + let output = json!({"not-a-real-keyword": ["whatever"]}); + let out = render_server("s", &[typed_tool("t", &input, &output)]); + assert!(out.contains("): Promise<"), "got:\n{out}"); + } + + /// A non-object output schema is still worth declaring. + #[test] + fn a_scalar_output_schema_is_declared() { + let input = json!({"type": "object"}); + let output = json!({"type": "string"}); + let out = render_server("s", &[typed_tool("name", &input, &output)]); + assert!(out.contains("): Promise;"), "got:\n{out}"); + } + /// A tool taking nothing should not force the model to pass `{}`. #[test] fn tool_without_properties_takes_no_argument() { @@ -242,6 +345,7 @@ mod tests { name: "t", description: None, input_schema: None, + output_schema: None, }], ); assert!(out.contains("t(): Promise;"), "got:\n{out}"); @@ -275,6 +379,7 @@ mod tests { name: "get-sum", description: Some("Adds numbers"), input_schema: None, + output_schema: None, }], ); assert_eq!( @@ -410,6 +515,7 @@ mod tests { name: "t", description: Some("Does a\nthing"), input_schema: None, + output_schema: None, }], ); assert!(out.contains("/** Does a thing */"), "got:\n{out}"); diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index b65910d0..0a9fede8 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -17,6 +17,7 @@ pub mod sandbox; pub mod schema_to_ts; pub mod server; pub mod supervisor; +pub mod validate; #[cfg(test)] mod corpus_tests; diff --git a/src/mcp/server.rs b/src/mcp/server.rs index 30f66a3c..8f1f2edd 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -57,9 +57,29 @@ impl MetaServer { // engine's thread. let (calls, mut receiver) = super::dispatch::channel(); let catalog = Arc::clone(&self.catalog); + // A server whose answer did not match its declared output schema is + // reported alongside the result. Collected here because the notice is + // for the model, not for the script, which receives the value either + // way. + let notices: Arc>> = + Arc::new(std::sync::Mutex::new(Vec::new())); + let collected = Arc::clone(¬ices); let pump = tokio::spawn(async move { while let Some(call) = receiver.recv().await { - let answer = catalog.call(&call.server, &call.tool, call.args).await; + let answer = match catalog.call(&call.server, &call.tool, call.args).await { + Ok(outcome) => { + if let Some(notice) = outcome.notice { + let mut seen = collected.lock().expect("notice lock"); + // The same tool answering off-shape ten times is + // one fact about that tool, not ten. + if !seen.contains(¬ice) { + seen.push(notice); + } + } + Ok(outcome.value) + } + Err(e) => Err(e), + }; let _ = call.reply.send(answer); } }); @@ -72,6 +92,11 @@ impl MetaServer { // the script is over, anything still queued is past its deadline. pump.abort(); + // Schema mismatches join the pre-existing problems, so everything the + // model needs to interpret the result arrives with it. + let mut problems = problems; + problems.extend(notices.lock().expect("notice lock").drain(..)); + match outcome { Ok(outcome) => CallToolResult::success(vec![ContentBlock::text(render_outcome( &outcome, &problems, diff --git a/src/mcp/testdata/everything.d.ts b/src/mcp/testdata/everything.d.ts index 081c9561..0b21aafc 100644 --- a/src/mcp/testdata/everything.d.ts +++ b/src/mcp/testdata/everything.d.ts @@ -48,12 +48,26 @@ declare const everything: { "get-structured-content"(params: { /** Choose city */ location: "New York" | "Chicago" | "Los Angeles"; - }): Promise; + }): Promise<{ + /** Weather conditions description */ + conditions: string; + /** Humidity percentage */ + humidity: number; + /** Temperature in celsius */ + temperature: number; + }>; /** Alias for get-structured-content. */ get_structured_content(params: { /** Choose city */ location: "New York" | "Chicago" | "Los Angeles"; - }): Promise; + }): Promise<{ + /** Weather conditions description */ + conditions: string; + /** Humidity percentage */ + humidity: number; + /** Temperature in celsius */ + temperature: number; + }>; /** Returns the sum of two numbers */ "get-sum"(params: { /** First number */ diff --git a/src/mcp/testdata/filesystem.d.ts b/src/mcp/testdata/filesystem.d.ts index caf4324a..fcbe6ae5 100644 --- a/src/mcp/testdata/filesystem.d.ts +++ b/src/mcp/testdata/filesystem.d.ts @@ -6,7 +6,9 @@ declare const filesystem: { path: string; /** If provided, returns only the last N lines of the file */ tail?: number; - }): Promise; + }): Promise<{ + content: string; + }>; /** Read the complete contents of a file from the file system as text. Handles various text encodings and provides detailed error messages if the file cannot be read. Use this tool when you need to examine the contents of a single file. Use the 'head' parameter to read only the first N lines of a file, or the 'tail' parameter to read only the last N lines of a file. Operates on the file as text regardless of extension. Only works within allowed directories. */ read_text_file(params: { /** If provided, returns only the first N lines of the file */ @@ -14,21 +16,40 @@ declare const filesystem: { path: string; /** If provided, returns only the last N lines of the file */ tail?: number; - }): Promise; + }): Promise<{ + content: string; + }>; /** Read a file and return it as a base64-encoded content block with its MIME type. Image and audio files are returned as image/audio content; any other file type is returned as an embedded resource. Only works within allowed directories. */ read_media_file(params: { path: string; - }): Promise; + }): Promise<{ + content: ({ + data: string; + mimeType: string; + type: "image" | "audio"; + } | { + resource: { + blob: string; + mimeType?: string; + uri: string; + }; + type: "resource"; + })[]; + }>; /** Read the contents of multiple files simultaneously. This is more efficient than reading files one by one when you need to analyze or compare multiple files. Each file's content is returned with its path as a reference. Failed reads for individual files won't stop the entire operation. Only works within allowed directories. */ read_multiple_files(params: { /** Array of file paths to read. Each path must be a string pointing to a valid file within allowed directories. */ paths: string[]; - }): Promise; + }): Promise<{ + content: string; + }>; /** Create a new file or completely overwrite an existing file with new content. Use with caution as it will overwrite existing files without warning. Handles text content with proper encoding. Only works within allowed directories. */ write_file(params: { content: string; path: string; - }): Promise; + }): Promise<{ + content: string; + }>; /** Make line-based edits to a text file. Each edit replaces exact line sequences with new content. Returns a git-style diff showing the changes made. Only works within allowed directories. */ edit_file(params: { /** Preview changes using git-style diff format */ @@ -40,41 +61,59 @@ declare const filesystem: { oldText: string; }[]; path: string; - }): Promise; + }): Promise<{ + content: string; + }>; /** Create a new directory or ensure a directory exists. Can create multiple nested directories in one operation. If the directory already exists, this operation will succeed silently. Perfect for setting up directory structures for projects or ensuring required paths exist. Only works within allowed directories. */ create_directory(params: { path: string; - }): Promise; + }): Promise<{ + content: string; + }>; /** Get a detailed listing of all files and directories in a specified path. Results clearly distinguish between files and directories with [FILE] and [DIR] prefixes. This tool is essential for understanding directory structure and finding specific files within a directory. Only works within allowed directories. */ list_directory(params: { path: string; - }): Promise; + }): Promise<{ + content: string; + }>; /** Get a detailed listing of all files and directories in a specified path, including sizes. Results clearly distinguish between files and directories with [FILE] and [DIR] prefixes. This tool is useful for understanding directory structure and finding specific files within a directory. Only works within allowed directories. */ list_directory_with_sizes(params: { path: string; /** Sort entries by name or size */ sortBy?: "name" | "size"; - }): Promise; + }): Promise<{ + content: string; + }>; /** Get a recursive tree view of files and directories as a JSON structure. Each entry includes 'name', 'type' (file/directory), and 'children' for directories. Files have no children array, while directories always have a children array (which may be empty). The output is formatted with 2-space indentation for readability. Only works within allowed directories. */ directory_tree(params: { excludePatterns?: string[]; path: string; - }): Promise; + }): Promise<{ + content: string; + }>; /** Move or rename files and directories. Can move files between directories and rename them in a single operation. If the destination exists, the operation will fail. Works across different directories and can be used for simple renaming within the same directory. Both source and destination must be within allowed directories. */ move_file(params: { destination: string; source: string; - }): Promise; + }): Promise<{ + content: string; + }>; /** Recursively search for files and directories matching a pattern. The patterns should be glob-style patterns that match paths relative to the working directory. Use pattern like '*.ext' to match files in current directory, and '** /*.ext' to match files in all subdirectories. Returns full paths to all matching items. Great for finding files when you don't know their exact location. Only searches within allowed directories. */ search_files(params: { excludePatterns?: string[]; path: string; pattern: string; - }): Promise; + }): Promise<{ + content: string; + }>; /** Retrieve detailed metadata about a file or directory. Returns comprehensive information including size, creation time, last modified time, permissions, and type. This tool is perfect for understanding file characteristics without reading the actual content. Only works within allowed directories. */ get_file_info(params: { path: string; - }): Promise; + }): Promise<{ + content: string; + }>; /** Returns the list of directories that this server is allowed to access. Subdirectories within these allowed directories are also accessible. Use this to understand which directories and their nested paths are available before trying to access files. */ - list_allowed_directories(): Promise; + list_allowed_directories(): Promise<{ + content: string; + }>; }; diff --git a/src/mcp/testdata/memory.d.ts b/src/mcp/testdata/memory.d.ts index 58bc5eea..abe7828e 100644 --- a/src/mcp/testdata/memory.d.ts +++ b/src/mcp/testdata/memory.d.ts @@ -9,7 +9,16 @@ declare const memory: { /** An array of observation contents associated with the entity */ observations: string[]; }[]; - }): Promise; + }): Promise<{ + entities: { + /** The type of the entity */ + entityType: string; + /** The name of the entity */ + name: string; + /** An array of observation contents associated with the entity */ + observations: string[]; + }[]; + }>; /** Create multiple new relations between entities in the knowledge graph. Relations should be in active voice */ create_relations(params: { relations: { @@ -20,7 +29,16 @@ declare const memory: { /** The name of the entity where the relation ends */ to: string; }[]; - }): Promise; + }): Promise<{ + relations: { + /** The name of the entity where the relation starts */ + from: string; + /** The type of the relation */ + relationType: string; + /** The name of the entity where the relation ends */ + to: string; + }[]; + }>; /** Add new observations to existing entities in the knowledge graph */ add_observations(params: { observations: { @@ -29,12 +47,20 @@ declare const memory: { /** The name of the entity to add the observations to */ entityName: string; }[]; - }): Promise; + }): Promise<{ + results: { + addedObservations: string[]; + entityName: string; + }[]; + }>; /** Delete multiple entities and their associated relations from the knowledge graph */ delete_entities(params: { /** An array of entity names to delete */ entityNames: string[]; - }): Promise; + }): Promise<{ + message: string; + success: boolean; + }>; /** Delete specific observations from entities in the knowledge graph */ delete_observations(params: { deletions: { @@ -43,7 +69,10 @@ declare const memory: { /** An array of observations to delete */ observations: string[]; }[]; - }): Promise; + }): Promise<{ + message: string; + success: boolean; + }>; /** Delete multiple relations from the knowledge graph */ delete_relations(params: { /** An array of relations to delete */ @@ -55,17 +84,71 @@ declare const memory: { /** The name of the entity where the relation ends */ to: string; }[]; - }): Promise; + }): Promise<{ + message: string; + success: boolean; + }>; /** Read the entire knowledge graph */ - read_graph(): Promise; + read_graph(): Promise<{ + entities: { + /** The type of the entity */ + entityType: string; + /** The name of the entity */ + name: string; + /** An array of observation contents associated with the entity */ + observations: string[]; + }[]; + relations: { + /** The name of the entity where the relation starts */ + from: string; + /** The type of the relation */ + relationType: string; + /** The name of the entity where the relation ends */ + to: string; + }[]; + }>; /** Search for nodes in the knowledge graph based on a query */ search_nodes(params: { /** The search query to match against entity names, types, and observation content */ query: string; - }): Promise; + }): Promise<{ + entities: { + /** The type of the entity */ + entityType: string; + /** The name of the entity */ + name: string; + /** An array of observation contents associated with the entity */ + observations: string[]; + }[]; + relations: { + /** The name of the entity where the relation starts */ + from: string; + /** The type of the relation */ + relationType: string; + /** The name of the entity where the relation ends */ + to: string; + }[]; + }>; /** Open specific nodes in the knowledge graph by their names */ open_nodes(params: { /** An array of entity names to retrieve */ names: string[]; - }): Promise; + }): Promise<{ + entities: { + /** The type of the entity */ + entityType: string; + /** The name of the entity */ + name: string; + /** An array of observation contents associated with the entity */ + observations: string[]; + }[]; + relations: { + /** The name of the entity where the relation starts */ + from: string; + /** The type of the relation */ + relationType: string; + /** The name of the entity where the relation ends */ + to: string; + }[]; + }>; }; diff --git a/src/mcp/testdata/sequentialthinking.d.ts b/src/mcp/testdata/sequentialthinking.d.ts index b920deea..a8631085 100644 --- a/src/mcp/testdata/sequentialthinking.d.ts +++ b/src/mcp/testdata/sequentialthinking.d.ts @@ -19,5 +19,11 @@ declare const sequentialthinking: { thoughtNumber: number; /** Estimated total thoughts needed (numeric value, e.g., 5, 10) */ totalThoughts: number; - }): Promise; + }): Promise<{ + branches: string[]; + nextThoughtNeeded: boolean; + thoughtHistoryLength: number; + thoughtNumber: number; + totalThoughts: number; + }>; }; diff --git a/src/mcp/validate.rs b/src/mcp/validate.rs new file mode 100644 index 00000000..00131948 --- /dev/null +++ b/src/mcp/validate.rs @@ -0,0 +1,169 @@ +//! Checking a tool's result against the shape it declared. +//! +//! A tool's `outputSchema` becomes its TypeScript return type, but nothing in +//! MCP obliges a server to honor it, and answering with plain text is ordinary +//! rather than a defect. So the type is intent, not guarantee. +//! +//! **A mismatch never fails the call.** `"count: 3"` is not the declared object +//! but plainly carries the answer, and refusing it would throw that away. The +//! value passes through untouched and the model is told the shape did not hold — +//! the one thing it cannot work out for itself. Told nothing, it meets the +//! breach as an `undefined` property, blames its own code, and usually spends +//! another round trip probing the tool. +//! +//! **The notice is a tag, not prose.** A correction costing more context than +//! the type it corrects would defeat the design. Which field was wrong is left +//! out: the value travels with the tag, so the model can read what it got, and +//! the failure list goes to the log for whoever is debugging the server. That +//! is also why this is one validity check rather than an enumeration needing a +//! size cap. +//! +//! Two limits worth knowing: +//! +//! * **Remote `$ref`s are never resolved**, since fetching a URL a third-party +//! schema names would turn a declaration into an outbound request. The +//! dependency is built without its HTTP resolver, so such a reference simply +//! makes the schema uncompilable. +//! * **An uncompilable schema is not checked at all**, matching the renderer's +//! rule that generation never fails whatever a server sends. Nothing +//! meaningful was declared, so nothing is owed. +//! +//! Schemas are compiled per call rather than cached: compilation is microseconds +//! against schemas this size, the call it guards just paid a subprocess round +//! trip, and a cache would need invalidating whenever a restarted server +//! re-advertised its tools. + +use serde_json::Value; + +/// Check `value` against `schema`, returning a tag for the model if it does not +/// conform. +/// +/// `None` covers both "conforms" and "cannot be checked". Callers cannot +/// distinguish them, deliberately: neither is something to tell the model. +pub fn check_result(server: &str, tool: &str, schema: &Value, value: &Value) -> Option { + let validator = match jsonschema::validator_for(schema) { + Ok(validator) => validator, + Err(e) => { + tracing::debug!( + server, + tool, + error = %e, + "output schema could not be compiled; result not checked" + ); + return None; + } + }; + + if validator.is_valid(value) { + return None; + } + + // Detail the tag deliberately omits, for whoever debugs the server. + tracing::debug!( + server, + tool, + failures = %validator + .iter_errors(value) + .map(|e| e.to_string()) + .collect::>() + .join("; "), + "result did not match the declared output schema" + ); + + Some(format!( + "{server}.{tool}: result off-shape, treat as unknown" + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn schema() -> Value { + json!({ + "type": "object", + "properties": {"count": {"type": "number"}}, + "required": ["count"], + }) + } + + #[test] + fn a_conforming_value_is_not_remarked_on() { + assert_eq!( + check_result("s", "t", &schema(), &json!({"count": 1})), + None + ); + } + + #[test] + fn a_mismatch_names_the_tool_and_says_what_to_do() { + let tag = check_result("memory", "search", &schema(), &json!({"count": "one"})) + .expect("a string is not a number"); + assert_eq!(tag, "memory.search: result off-shape, treat as unknown"); + } + + #[test] + fn a_missing_required_field_is_a_mismatch() { + assert!( + check_result("s", "t", &schema(), &json!({})).is_some(), + "the required field is absent" + ); + } + + /// The case that must never fail a call: the server answered with text, so + /// the unwrap ladder handed back a string where an object was declared. + /// Ordinary MCP, not a defect, so the value stands and the model is tagged. + #[test] + fn an_unstructured_answer_is_tagged_not_rejected() { + let tag = check_result("s", "t", &schema(), &json!("count: 3")) + .expect("a string is not the declared object"); + assert!(tag.contains("treat as unknown"), "got: {tag}"); + } + + /// Servers commonly put JSON in a text block. The unwrap ladder parses it, + /// so it arrives as structured data and satisfies the schema. + #[test] + fn json_delivered_as_text_still_conforms() { + let parsed: Value = serde_json::from_str(r#"{"count": 2}"#).unwrap(); + assert_eq!(check_result("s", "t", &schema(), &parsed), None); + } + + /// The renderer never fails on a schema it cannot understand, so neither + /// does the checker. Nothing meaningful was declared, so nothing is owed. + #[test] + fn an_uncompilable_schema_is_not_checked() { + let bogus = json!({"$ref": "https://example.invalid/nope.json"}); + assert_eq!( + check_result("s", "t", &bogus, &json!("anything")), + None, + "an unresolvable reference must not produce a tag" + ); + } + + #[test] + fn a_permissive_schema_accepts_anything() { + let open = json!({"type": "object"}); + assert_eq!( + check_result("s", "t", &open, &json!({"whatever": true})), + None + ); + } + + /// However wrong the value is, the tag is one short line. A result whose + /// every element fails would otherwise crowd out the result it accompanies. + #[test] + fn the_tag_is_the_same_size_however_many_failures() { + let strict = json!({"type": "array", "items": {"type": "number"}}); + let one_bad: Value = json!(["x"]); + let all_bad: Value = (0..50).map(|_| json!("x")).collect(); + + let first = check_result("s", "t", &strict, &one_bad).expect("one item is wrong"); + let second = check_result("s", "t", &strict, &all_bad).expect("every item is wrong"); + assert_eq!( + first, second, + "the tag must not grow with the failure count" + ); + assert!(first.len() < 80, "tag should stay small: {}", first.len()); + } +} diff --git a/tests/mcp_meta_server.rs b/tests/mcp_meta_server.rs index a5030d29..5ae95e6e 100644 --- a/tests/mcp_meta_server.rs +++ b/tests/mcp_meta_server.rs @@ -152,6 +152,13 @@ fn workspace_with_backing_server() -> Workspace { } fn workspace_serving(mock: serde_json::Value) -> Workspace { + workspace_serving_as("sqlx", mock) +} + +/// As [`workspace_serving`], with the manifest's server name spelled out. That +/// name, not the one the mock reports in its handshake, is the namespace a +/// script addresses. +fn workspace_serving_as(server: &str, mock: serde_json::Value) -> Workspace { let dir = tempfile::tempdir().expect("temp dir"); let base = dir.path().to_path_buf(); let home = base.join("home"); @@ -171,8 +178,9 @@ fn workspace_serving(mock: serde_json::Value) -> Workspace { home.join("plugins/db/SYMPOSIUM.toml"), format!( "name = \"db-plugin\"\ndepends-on = [\"*\"]\n\n\ - [[mcp_servers]]\nname = \"sqlx\"\ncommand = {:?}\n\ + [[mcp_servers]]\nname = {:?}\ncommand = {:?}\n\ args = [\"--config\", {:?}]\n", + server, mock_binary().display().to_string(), mock_config.display().to_string(), ), @@ -746,3 +754,200 @@ async fn install_command_output_stays_off_stdout() { .unwrap_or_else(|e| panic!("stdout line is not JSON ({e}): {line}")); } } + +// -- declared output schemas -- + +/// A workspace whose backing server declares an output schema on some tools. +/// +/// `echo` returns the arguments as structured content, so a script decides what +/// the server sends back. That is what lets one mock cover both a conforming +/// answer and a violating one. +fn workspace_with_typed_server() -> Workspace { + workspace_serving_as( + "typed", + serde_json::json!({ + "name": "typed", + "tools": [ + {"name": "count", "description": "Count things", + "inputSchema": {"type": "object", + "properties": {"count": {"type": "number"}}, "required": ["count"]}, + "outputSchema": {"type": "object", + "properties": {"count": {"type": "number"}}, "required": ["count"]}, + "behavior": {"kind": "echo"}}, + {"name": "untyped", "description": "Declares no output shape", + "inputSchema": {"type": "object", "properties": {"a": {"type": "string"}}}, + "behavior": {"kind": "echo"}}, + {"name": "unstructured", "description": "Declares a shape, answers with text", + "inputSchema": {"type": "object"}, + "outputSchema": {"type": "object", + "properties": {"count": {"type": "number"}}, "required": ["count"]}, + "behavior": {"kind": "text", "text": "count: 3"}} + ] + }), + ) +} + +async fn execute( + client: &rmcp::service::RunningService, + script: &str, +) -> rmcp::model::CallToolResult { + client + .call_tool(CallToolRequestParams::new("execute").with_arguments( + serde_json::Map::from_iter([("script".to_string(), serde_json::json!(script))]), + )) + .await + .expect("execute should answer, not fail") +} + +/// The declared shape reaches the model as the tool's return type. +#[tokio::test(flavor = "multi_thread")] +async fn a_declared_output_schema_is_shown_as_the_return_type() { + let workspace = workspace_with_typed_server(); + let client = connect_in(&workspace).await; + + let result = client + .call_tool(CallToolRequestParams::new("list_tools").with_arguments( + serde_json::Map::from_iter([("servers".to_string(), serde_json::json!(["typed"]))]), + )) + .await + .expect("list_tools"); + let text = text_of(&result); + + assert!( + text.contains("count(params: {") && text.contains("): Promise<{"), + "the typed tool should declare its return shape, got: {text}" + ); + assert!( + text.contains("untyped(params?: {\n a?: string;\n }): Promise;"), + "a tool without an output schema stays unknown, got: {text}" + ); + let _ = client.cancel().await; +} + +/// A conforming answer passes through untouched. +#[tokio::test(flavor = "multi_thread")] +async fn a_conforming_result_is_returned() { + let workspace = workspace_with_typed_server(); + let client = connect_in(&workspace).await; + + let result = execute(&client, r#"return await typed.count({ count: 3 });"#).await; + let text = text_of(&result); + + assert_ne!(result.is_error, Some(true), "got: {text}"); + assert!(text.contains(r#""count":3"#), "got: {text}"); + let _ = client.cancel().await; +} + +/// A server that declares a shape and sends another does not fail the call. +/// The value stands and the model is told the shape did not hold, because the +/// value is often still readable and only the model can decide. +#[tokio::test(flavor = "multi_thread")] +async fn a_violating_result_is_passed_through_with_a_notice() { + let workspace = workspace_with_typed_server(); + let client = connect_in(&workspace).await; + + // `echo` reflects the arguments, so this makes the server answer with a + // string where its own schema promised a number. + let result = execute(&client, r#"return await typed.count({ count: "three" });"#).await; + let text = text_of(&result); + + assert_ne!( + result.is_error, + Some(true), + "a schema mismatch must not fail the call, got: {text}" + ); + assert!( + text.contains(r#""count":"three""#), + "the value should reach the script unchanged, got: {text}" + ); + assert!( + text.contains("[typed.count: result off-shape, treat as unknown]"), + "the model should be tagged, tersely, got: {text}" + ); +} + +/// The case from the requirement: a tool declares `{ count: number }` and +/// answers `"count: 3"` as text. Ordinary MCP, so the text is handed over for +/// the model to read rather than refused. +#[tokio::test(flavor = "multi_thread")] +async fn an_unstructured_answer_is_handed_over_to_be_read() { + let workspace = workspace_with_typed_server(); + let client = connect_in(&workspace).await; + + let result = execute(&client, r#"return await typed.unstructured();"#).await; + let text = text_of(&result); + + assert_ne!( + result.is_error, + Some(true), + "text where an object was declared must not fail, got: {text}" + ); + assert!( + text.contains("count: 3"), + "the script should receive the text, got: {text}" + ); + assert!( + text.contains("[typed.unstructured: result off-shape, treat as unknown]"), + "the model should be tagged, tersely, got: {text}" + ); +} + +/// A mismatch is not an exception, so a script that destructures the declared +/// shape keeps running and simply finds nothing there. +#[tokio::test(flavor = "multi_thread")] +async fn a_violation_does_not_throw_in_the_script() { + let workspace = workspace_with_typed_server(); + let client = connect_in(&workspace).await; + + let script = r#" + const r = await typed.unstructured(); + return { raw: r, declared: r.count ?? "absent", threw: false }; + "#; + let result = execute(&client, script).await; + let text = text_of(&result); + + assert_ne!(result.is_error, Some(true), "got: {text}"); + assert!(text.contains(r#""threw":false"#), "got: {text}"); + assert!( + text.contains(r#""declared":"absent""#), + "destructuring the declared shape should find nothing, got: {text}" + ); + assert!(text.contains(r#""raw":"count: 3""#), "got: {text}"); +} + +/// One tool answering off-shape repeatedly is one fact about that tool. The +/// notice must not be repeated per call, or a loop would crowd out the result. +#[tokio::test(flavor = "multi_thread")] +async fn a_repeated_violation_is_reported_once() { + let workspace = workspace_with_typed_server(); + let client = connect_in(&workspace).await; + + let script = r#" + for (let i = 0; i < 4; i++) { await typed.unstructured(); } + return "done"; + "#; + let result = execute(&client, script).await; + let text = text_of(&result); + + assert_ne!(result.is_error, Some(true), "got: {text}"); + assert_eq!( + text.matches("result off-shape").count(), + 1, + "the tag should appear once, got: {text}" + ); +} + +/// A tool that declares nothing is unaffected: no shape was promised, so +/// nothing is enforced. +#[tokio::test(flavor = "multi_thread")] +async fn an_untyped_tool_is_not_checked() { + let workspace = workspace_with_typed_server(); + let client = connect_in(&workspace).await; + + let result = execute(&client, r#"return await typed.untyped({ a: "anything" });"#).await; + let text = text_of(&result); + + assert_ne!(result.is_error, Some(true), "got: {text}"); + assert!(text.contains(r#""a":"anything""#), "got: {text}"); + let _ = client.cancel().await; +} From 523e2660c5f50f099e1a8079c2e2a16bc38fe921 Mon Sep 17 00:00:00 2001 From: fluzko Date: Tue, 11 Aug 2026 18:25:24 -0300 Subject: [PATCH 37/39] feat(mcp): accept alternate spellings of tool names --- md/design/module-structure.md | 2 +- md/rfds/mcp-meta-server/README.md | 4 + src/mcp/catalog.rs | 17 +++- src/mcp/declarations.rs | 160 +++++++++++++++++++++++++++++- tests/mcp_meta_server.rs | 68 +++++++++++++ 5 files changed, 244 insertions(+), 7 deletions(-) diff --git a/md/design/module-structure.md b/md/design/module-structure.md index c504b000..d7e339a1 100644 --- a/md/design/module-structure.md +++ b/md/design/module-structure.md @@ -161,7 +161,7 @@ One MCP server is registered with each agent (see [`sync.rs`](#syncrs--synchroni - `client.rs` — the only protocol-shaped file; the rest of the module works in `serde_json::Value`. Distinguishes a tool failure (a successful response carrying an error flag) from a protocol failure, and unwraps the content envelope into the value a script wants. - `sandbox.rs` / `console.rs` / `normalize.rs` — the QuickJS sandbox a script runs in (deny-by-construction: no filesystem, network, process or module loader is ever registered; two layers of deadline, since an interpreter interrupt cannot fire while a script awaits a host call), the bounded `console` capture, and the normalization that turns model-written TypeScript / fenced / statement-body source into something the engine can evaluate. - `dispatch.rs` — how a script reaches a backing server. Namespaces are built through the object API rather than by generating JavaScript, so manifest-supplied server and tool names never reach a code position; a channel keeps the engine thread and the servers' I/O each on the runtime they belong to. -- `declarations.rs` / `schema_to_ts.rs` — rendering a server's tools as TypeScript. Each server is an object of methods (not a `declare namespace`), so a tool whose wire name is not a JS identifier can still be declared. Schema conversion never fails (an unrecognized construct becomes `unknown`, never `any`, so the model must narrow). A tool declaring an `outputSchema` renders that as its return type; one that does not stays `Promise`. `testdata/` holds real-world tool dumps and expected `.d.ts` output, checked by `corpus_tests.rs`, whose `declarations_type_check` runs the generated declarations through real `tsc`. +- `declarations.rs` / `schema_to_ts.rs` — rendering a server's tools as TypeScript. Each server is an object of methods (not a `declare namespace`), so a tool whose wire name is not a JS identifier can still be declared. Schema conversion never fails (an unrecognized construct becomes `unknown`, never `any`, so the model must narrow). A tool declaring an `outputSchema` renders that as its return type; one that does not stays `Promise`. `binding_table` is the single source of both declarations and runtime bindings, and `resolve_key` (used by `Catalog::call`) makes lookup tolerant of case and punctuation, so a model reaching for `createEntities` still lands `create_entities` — declared names match exactly first, an ambiguous fallback is refused rather than guessed, and nothing extra is declared. `testdata/` holds real-world tool dumps and expected `.d.ts` output, checked by `corpus_tests.rs`, whose `declarations_type_check` runs the generated declarations through real `tsc`. - `validate.rs` — checking a result against the `outputSchema` its tool declared, called from `Catalog::call` after unwrapping (that is the value a script receives). A mismatch is **tagged, never fatal**, since answering with text where an object was declared is ordinary MCP and the value is usually still readable: `Catalog::call` returns a `CallOutcome { value, notice }`, the script gets the value unchanged, and `server.rs`'s call pump collects the notice — deduplicated, one tool being one fact — into the same `problems` list `execute` already reports back. The notice is one line (`.: result off-shape, treat as unknown`) rather than prose, because a correction costing more context than the type it corrects would defeat the design; the failure detail goes to the log. Remote `$ref`s are never resolved, so a third-party schema cannot trigger an outbound request, and an uncompilable schema is not checked at all. ### `state.rs` — persistent state diff --git a/md/rfds/mcp-meta-server/README.md b/md/rfds/mcp-meta-server/README.md index 8cf3b084..329e0a0f 100644 --- a/md/rfds/mcp-meta-server/README.md +++ b/md/rfds/mcp-meta-server/README.md @@ -369,6 +369,10 @@ The design shows `declare namespace sqlx { function query(...) }`. Shipped decla A TypeScript namespace cannot declare a member whose name is not a valid identifier, and hyphenated tool names are ordinary. An object type accepts quoted keys, so a hyphenated tool can be both declared and called. Each such tool is bound under both spellings. +Declared names are the server's own, not camelCased into TypeScript style. Nothing in the captured corpus is camelCase (58 tools snake_case, 12 kebab-case), and snake_case is already a valid identifier, so camelCasing would rename the primary spelling of most tools and force the wire name to be carried as a second declared method just to stay visible — roughly doubling the listing for them, in a design whose point is context economy. It would also hide the name the server itself reports errors by. + +What that would have bought is had for nothing instead: a model trained on TypeScript may reach for `createEntities` when shown `create_entities`, so **lookup is tolerant of case and punctuation** while the declaration is untouched. `create_entities`, `createEntities` and `create-entities` all reach the same tool. A declared spelling always matches exactly first, so a server exposing two tools that differ only in punctuation keeps them distinct, and an ambiguous fallback is refused by name rather than guessed. Only tools in the visible set resolve, so a filtered tool cannot be summoned under another spelling. + ### Results are unwrapped, and a declared output schema becomes the return type The design's own example calls `.rows.map(...)` on a tool result, which only works if the host unwraps MCP's result envelope, but the design never says it does, while declaring return types `any`. diff --git a/src/mcp/catalog.rs b/src/mcp/catalog.rs index efdeabd8..1a4781cd 100644 --- a/src/mcp/catalog.rs +++ b/src/mcp/catalog.rs @@ -20,7 +20,9 @@ use rmcp::model::Tool; use serde_json::Value; use tokio::sync::Mutex; -use super::declarations::{ToolBinding, ToolDecl, binding_table, render_server}; +use super::declarations::{ + KeyMatch, ToolBinding, ToolDecl, binding_table, render_server, resolve_key, +}; use super::dispatch::Namespace; use super::resolve::{Rejection, Resolution, ResolvedServer, ServerCommand}; use super::supervisor::{RestartPolicy, Supervisor}; @@ -386,10 +388,17 @@ impl Catalog { // The same table the declarations are rendered from. let table = binding_table(visible); - let Some(binding) = table.iter().find(|b| b.keys.iter().any(|k| k == key)) else { - return Err(unknown_tool(server, key, &table)); + let wire_name = match resolve_key(&table, key) { + KeyMatch::One(binding) => binding.wire_name.clone(), + KeyMatch::Ambiguous(names) => { + return Err(format!( + "`{server}` has more than one tool spelled like `{key}`: {}. \ + Use one of those names exactly.", + names.join(", ") + )); + } + KeyMatch::None => return Err(unknown_tool(server, key, &table)), }; - let wire_name = binding.wire_name.clone(); // Taken before the call so the tool list, which the mutable borrow of // the supervisor ends, is still in hand. diff --git a/src/mcp/declarations.rs b/src/mcp/declarations.rs index a65396d2..291112db 100644 --- a/src/mcp/declarations.rs +++ b/src/mcp/declarations.rs @@ -18,8 +18,7 @@ pub struct ToolDecl<'a> { pub description: Option<&'a str>, pub input_schema: Option<&'a Value>, /// The tool's declared output schema, when it has one. Rendered as the - /// return type, and validated against the returned value at call time so - /// the declaration is a claim the host enforces rather than a hint. + /// return type, and checked against the returned value at call time. pub output_schema: Option<&'a Value>, } @@ -69,6 +68,55 @@ pub fn binding_table<'a>(names: impl IntoIterator) -> Vec String { + name.chars() + .filter(char::is_ascii_alphanumeric) + .map(|c| c.to_ascii_lowercase()) + .collect() +} + +/// What the property name a script used resolved to. +pub enum KeyMatch<'a> { + One(&'a ToolBinding), + /// Wire names of tools whose spellings collapse together. + Ambiguous(Vec<&'a str>), + None, +} + +/// Resolve a script's property name to a tool. +/// +/// Declared spellings match exactly first, so two tools differing only in +/// punctuation stay distinct. Everything else falls back to normalized +/// matching, which is what lets a model reach a snake_case tool by the +/// camelCase name TypeScript habit suggests. An ambiguous fallback is refused +/// rather than guessed. +pub fn resolve_key<'a>(table: &'a [ToolBinding], key: &str) -> KeyMatch<'a> { + if let Some(binding) = table.iter().find(|b| b.keys.iter().any(|k| k == key)) { + return KeyMatch::One(binding); + } + + let wanted = normalized_key(key); + if wanted.is_empty() { + return KeyMatch::None; + } + + let mut hits = table + .iter() + .filter(|b| b.keys.iter().any(|k| normalized_key(k) == wanted)); + + match (hits.next(), hits.next()) { + (Some(binding), None) => KeyMatch::One(binding), + (Some(first), Some(second)) => { + let mut names = vec![first.wire_name.as_str(), second.wire_name.as_str()]; + names.extend(hits.map(|b| b.wire_name.as_str())); + KeyMatch::Ambiguous(names) + } + _ => KeyMatch::None, + } +} + /// Render one server's tools as a declaration block. pub fn render_server(server: &str, tools: &[ToolDecl]) -> String { let mut types = TypeRenderer::new(); @@ -538,4 +586,112 @@ mod tests { "types must precede the server object, got:\n{out}" ); } + + // -- tolerant key resolution -- + + fn table(names: &[&str]) -> Vec { + binding_table(names.iter().copied()) + } + + fn resolved<'a>(table: &'a [ToolBinding], key: &str) -> &'a str { + match resolve_key(table, key) { + KeyMatch::One(binding) => binding.wire_name.as_str(), + KeyMatch::Ambiguous(names) => panic!("ambiguous: {names:?}"), + KeyMatch::None => panic!("`{key}` did not resolve"), + } + } + + #[test] + fn a_declared_spelling_resolves_to_its_own_tool() { + let t = table(&["create_entities", "get-sum"]); + assert_eq!(resolved(&t, "create_entities"), "create_entities"); + assert_eq!(resolved(&t, "get-sum"), "get-sum"); + assert_eq!(resolved(&t, "get_sum"), "get-sum"); + } + + #[test] + fn a_camel_case_key_reaches_a_snake_case_tool() { + let t = table(&["create_entities"]); + assert_eq!(resolved(&t, "createEntities"), "create_entities"); + } + + #[test] + fn a_camel_case_key_reaches_a_kebab_case_tool() { + let t = table(&["get-annotated-message"]); + assert_eq!(resolved(&t, "getAnnotatedMessage"), "get-annotated-message"); + } + + #[test] + fn punctuation_and_case_are_both_ignored() { + let t = table(&["browser_console_messages"]); + for key in [ + "browserConsoleMessages", + "browser-console-messages", + "BrowserConsoleMessages", + "BROWSER_CONSOLE_MESSAGES", + ] { + assert_eq!(resolved(&t, key), "browser_console_messages", "key: {key}"); + } + } + + /// A server exposing two spellings as separate tools must keep them apart, + /// so an exact hit is never diverted by the tolerant fallback. + #[test] + fn an_exact_match_wins_over_a_normalized_one() { + let t = table(&["read_file", "readFile"]); + assert_eq!(resolved(&t, "read_file"), "read_file"); + assert_eq!(resolved(&t, "readFile"), "readFile"); + } + + /// With no exact hit, two tools that collapse together are refused rather + /// than guessed between. + #[test] + fn colliding_spellings_are_refused() { + let t = table(&["read_file", "readFile"]); + match resolve_key(&t, "read-file") { + KeyMatch::Ambiguous(names) => { + assert!(names.contains(&"read_file"), "got {names:?}"); + assert!(names.contains(&"readFile"), "got {names:?}"); + } + other => panic!( + "expected ambiguity, got {}", + match other { + KeyMatch::One(b) => format!("One({})", b.wire_name), + KeyMatch::None => "None".to_string(), + KeyMatch::Ambiguous(_) => unreachable!(), + } + ), + } + } + + #[test] + fn an_unrelated_key_does_not_resolve() { + let t = table(&["create_entities"]); + assert!(matches!(resolve_key(&t, "deleteEntities"), KeyMatch::None)); + } + + /// Only what the table carries is reachable, so a tool filtered out of the + /// visible set cannot be summoned by an alternate spelling. + #[test] + fn a_tool_absent_from_the_table_is_unreachable() { + let t = table(&["read_file"]); + assert!(matches!(resolve_key(&t, "writeFile"), KeyMatch::None)); + assert!(matches!(resolve_key(&t, "write_file"), KeyMatch::None)); + } + + #[test] + fn a_key_with_no_alphanumerics_does_not_resolve() { + let t = table(&["read_file"]); + assert!(matches!(resolve_key(&t, "___"), KeyMatch::None)); + assert!(matches!(resolve_key(&t, ""), KeyMatch::None)); + } + + /// The sanitized alias of a hyphenated tool is a real declared key, so it + /// resolves exactly rather than through the fallback. + #[test] + fn a_sanitized_alias_still_resolves_to_its_tool() { + let t = table(&["migrate-status"]); + assert_eq!(resolved(&t, "migrate_status"), "migrate-status"); + assert_eq!(resolved(&t, "migrateStatus"), "migrate-status"); + } } diff --git a/tests/mcp_meta_server.rs b/tests/mcp_meta_server.rs index 5ae95e6e..69aaa785 100644 --- a/tests/mcp_meta_server.rs +++ b/tests/mcp_meta_server.rs @@ -774,6 +774,9 @@ fn workspace_with_typed_server() -> Workspace { "outputSchema": {"type": "object", "properties": {"count": {"type": "number"}}, "required": ["count"]}, "behavior": {"kind": "echo"}}, + {"name": "count_items", "description": "A snake_case tool name", + "inputSchema": {"type": "object", "properties": {"bin": {"type": "string"}}}, + "behavior": {"kind": "echo"}}, {"name": "untyped", "description": "Declares no output shape", "inputSchema": {"type": "object", "properties": {"a": {"type": "string"}}}, "behavior": {"kind": "echo"}}, @@ -951,3 +954,68 @@ async fn an_untyped_tool_is_not_checked() { assert!(text.contains(r#""a":"anything""#), "got: {text}"); let _ = client.cancel().await; } + +/// Tool names come from third-party servers and none in the wild are camelCase, +/// so a model reaching for one out of TypeScript habit must still land the call. +#[tokio::test(flavor = "multi_thread")] +async fn a_camel_case_spelling_reaches_a_snake_case_tool() { + let workspace = workspace_with_typed_server(); + let client = connect_in(&workspace).await; + + let result = execute(&client, r#"return await typed.countItems({ bin: "A1" });"#).await; + let text = text_of(&result); + + assert_ne!(result.is_error, Some(true), "got: {text}"); + assert!(text.contains(r#""bin":"A1""#), "got: {text}"); + let _ = client.cancel().await; +} + +/// The declared spelling is what the model is shown, and it keeps working. +#[tokio::test(flavor = "multi_thread")] +async fn the_declared_spelling_still_reaches_its_tool() { + let workspace = workspace_with_typed_server(); + let client = connect_in(&workspace).await; + + let result = execute(&client, r#"return await typed.count_items({ bin: "A1" });"#).await; + let text = text_of(&result); + + assert_ne!(result.is_error, Some(true), "got: {text}"); + assert!(text.contains(r#""bin":"A1""#), "got: {text}"); + let _ = client.cancel().await; +} + +/// Tolerant lookup must not invent tools: an unknown name still fails, with the +/// existing did-you-mean rather than a silent wrong call. +#[tokio::test(flavor = "multi_thread")] +async fn an_unknown_tool_name_still_fails() { + let workspace = workspace_with_typed_server(); + let client = connect_in(&workspace).await; + + let result = execute(&client, r#"return await typed.deleteEverything();"#).await; + let text = text_of(&result); + + assert_eq!(result.is_error, Some(true), "got: {text}"); + let _ = client.cancel().await; +} + +/// Only declared names appear; a tolerated spelling is not advertised. +#[tokio::test(flavor = "multi_thread")] +async fn an_alternate_spelling_is_not_declared() { + let workspace = workspace_with_typed_server(); + let client = connect_in(&workspace).await; + + let result = client + .call_tool(CallToolRequestParams::new("list_tools").with_arguments( + serde_json::Map::from_iter([("servers".to_string(), serde_json::json!(["typed"]))]), + )) + .await + .expect("list_tools"); + let text = text_of(&result); + + assert!(text.contains("count_items("), "got: {text}"); + assert!( + !text.contains("countItems"), + "the camelCase spelling must not be declared, got: {text}" + ); + let _ = client.cancel().await; +} From ffa011f09ddc269cfd62fe6505c7717ca589edb0 Mon Sep 17 00:00:00 2001 From: fluzko Date: Wed, 12 Aug 2026 14:45:20 -0300 Subject: [PATCH 38/39] feat(mcp): gate meta-server behind [experiments] mcp-meta-server --- md/design/configuration-loading.md | 2 + md/design/module-structure.md | 10 ++- md/reference/configuration.md | 134 +++++++++++++++++++---------- md/reference/plugin-definition.md | 11 ++- md/rfds/mcp-meta-server/README.md | 6 +- src/bin/cargo-agents.rs | 10 +++ src/config.rs | 106 ++++++++++++++++++++--- src/hook.rs | 2 +- src/sync.rs | 12 +-- symposium-testlib/src/lib.rs | 10 +++ tests/init_sync.rs | 65 ++++++++++---- tests/mcp_meta_server.rs | 62 ++++++++++--- 12 files changed, 332 insertions(+), 98 deletions(-) diff --git a/md/design/configuration-loading.md b/md/design/configuration-loading.md index 4d239b57..34783a67 100644 --- a/md/design/configuration-loading.md +++ b/md/design/configuration-loading.md @@ -9,3 +9,5 @@ See the [configuration reference](../reference/configuration.md#directory-resolu ## Config loading The user config (`~/.symposium/config.toml`) is loaded once at startup into the `Symposium` struct. The file is deserialized into `RawConfig`, then validated into the runtime `Config` used by the rest of the code. If the file is missing or empty, defaults are used. If parsing fails, a warning is printed and defaults are used. + +That last rule sets the cost of `deny_unknown_fields`, which most sections carry: one misspelled key does not fall back to the default for *that key*, it falls back to the default for the *whole config* — agents, registries and hook scope included. It is the right trade for a typo (a silently ignored setting is worse), but it means removing a key the code once accepted is a breaking change for anyone still naming it. `[experiments]` is where that bites, since experiments are expected to disappear: a graduated flag stays accepted-and-ignored for a release rather than being deleted. diff --git a/md/design/module-structure.md b/md/design/module-structure.md index d7e339a1..4ffbc7af 100644 --- a/md/design/module-structure.md +++ b/md/design/module-structure.md @@ -6,7 +6,9 @@ Symposium is a Rust crate with both a library (`src/lib.rs`) and a binary (`src/ Everything hangs off the `Symposium` struct, which wraps the parsed `Config` with resolved paths for config, cache, and log directories. Two constructors: `from_environment()` for production and `from_dir()` for tests. -Defines the user-wide `Config` (stored at `~/.symposium/config.toml`) with `[[agent]]` entries, logging, `[[registry]]` entries (`[[plugin-source]]` is the retired spelling, still accepted), defaults, `auto-update` (off/warn/on, default on), and the `[plugins]` enablement section. User config is deserialized through `RawConfig` and validated into the runtime `Config`; runtime code does not deserialize `Config` directly. Provides `registry_instances()` to build the effective registry `PmInstance`s directly (the builtin recommendations entry, the builtin `user-plugins` entry, then the configured ones): a git `[[registry]]` entry becomes a `GitPm`, a path entry a `PathPm`, each a trust root named for its registry (that name is what its plugins are attributed to). There is no `ResolvedRegistry`/`content_dir` intermediate — a git registry's cache directory and its refresh live on the `GitPm` itself. `package_managers(deps)` prepends the fixed cargo transport (a `CargoPm` built over the shared `deps` resolver) to those to make the active `PmRegistry`. `detached_managers()` is the workspace-independent form (registry listing, crates.io search) — its cargo transport is built over a detached resolver that never runs `cargo metadata`. The `workspace_deps(cwd)` factory is the standard way to create a `WorkspaceDeps` — it wires in `cargo_override` and `cache_dir` so callers get both the `SYMPOSIUM_CARGO` override and cross-invocation disk caching, and returns it as an `Arc` so a `CargoPm` can hold one. +Defines the user-wide `Config` (stored at `~/.symposium/config.toml`) with `[[agent]]` entries, logging, `[[registry]]` entries (`[[plugin-source]]` is the retired spelling, still accepted), defaults, `auto-update` (off/warn/on, default on), the `[plugins]` enablement section, and the `[experiments]` opt-in flags. User config is deserialized through `RawConfig` and validated into the runtime `Config`; runtime code does not deserialize `Config` directly. + +`ExperimentsConfig` is where an unfinished feature is switched on. Every key is a bool defaulting to false, and the section serializes only when something is set, so an untouched config never mentions it. Unknown keys are rejected (`deny_unknown_fields`) like everywhere else, which makes *retiring* an experiment a compatibility event: `load_config_from` degrades a parse failure to a warning plus **all** defaults, so deleting a key outright would silently unconfigure a user still naming it. Keep a graduated key accepted-and-ignored for a release instead. Today the only entry is `mcp-meta-server` (see [`mcp/`](#mcp--mcp-meta-server)). Provides `registry_instances()` to build the effective registry `PmInstance`s directly (the builtin recommendations entry, the builtin `user-plugins` entry, then the configured ones): a git `[[registry]]` entry becomes a `GitPm`, a path entry a `PathPm`, each a trust root named for its registry (that name is what its plugins are attributed to). There is no `ResolvedRegistry`/`content_dir` intermediate — a git registry's cache directory and its refresh live on the `GitPm` itself. `package_managers(deps)` prepends the fixed cargo transport (a `CargoPm` built over the shared `deps` resolver) to those to make the active `PmRegistry`. `detached_managers()` is the workspace-independent form (registry listing, crates.io search) — its cargo transport is built over a detached resolver that never runs `cargo metadata`. The `workspace_deps(cwd)` factory is the standard way to create a `WorkspaceDeps` — it wires in `cargo_override` and `cache_dir` so callers get both the `SYMPOSIUM_CARGO` override and cross-invocation disk caching, and returns it as an `Arc` so a `CargoPm` can hold one. `PluginsConfig` (the `[plugins]` section) is the config surface of the [enablement axis](#discoveryrs--dependency-discovery-and-enablement): `auto-enable` (dependency names pre-consented to, `"*"` for all), `use` (`UseEntry::Global(name)` or `{ name, workspace }` — the durable record of a deliberate enablement, scoped to one workspace or to all), and `disable` (names pruned from enablement, which is also where a declined discovery is recorded). Its query methods — `used_names_in(root)`, `is_auto_enabled`, `is_disabled`, `is_used_in` — all match names hyphen/underscore-insensitively, since these are user-typed package names; `has_enablement_entries` is the cheap "could enablement pull in a crate plugin?" check the hook path uses to decide whether to resolve the crate graph. The lists are plain `Vec`s so a later `cargo agents use` can add and remove entries and call `save_config`. @@ -30,7 +32,7 @@ Two entry points: `sync(sym, cwd)` for standalone CLI use (creates its own `Work `sync` takes an `UpdateLevel` that it threads into skill resolution (`skills::collect_skills`), controlling how aggressively `source.git` skill groups are re-fetched. Callers choose: the auto-sync path passes `Check` on `SessionStart` (refresh) and `None` otherwise (debounced); the binary's global `--update` flag feeds manual `cargo agents sync`. -MCP registration reads the same active plugin set skills do, so a crate-sourced plugin's servers are in scope exactly like a registry plugin's. What gets *written* depends on `[mcp] enabled` (default on): on, a single `symposium` entry (`META_SERVER_NAME` / `meta_server_entry`) naming `cargo-agents mcp-serve`, with the individual plugin servers left to the [meta-server](#mcp--mcp-meta-server) to start on demand; off, the plugin server entries themselves. Whichever set is not in use is unregistered in the same pass (`stale_names`), so switching the flag does not leave entries behind, and the reap list covers both so a dropped agent is cleaned either way. The entry names `cargo-agents` as a command rather than an absolute path, matching how hooks are registered — an absolute path breaks the moment the binary is reinstalled elsewhere. +MCP registration reads the same active plugin set skills do, so a crate-sourced plugin's servers are in scope exactly like a registry plugin's. What gets *written* depends on the `[experiments] mcp-meta-server` flag (default **off**): off, the plugin server entries themselves; on, a single `symposium` entry (`META_SERVER_NAME` / `meta_server_entry`) naming `cargo-agents mcp-serve`, with the individual plugin servers left to the [meta-server](#mcp--mcp-meta-server) to start on demand. Whichever set is not in use is unregistered in the same pass (`stale_names`), so switching the flag does not leave entries behind, and the reap list covers both so a dropped agent is cleaned either way. The entry names `cargo-agents` as a command rather than an absolute path, matching how hooks are registered — an absolute path breaks the moment the binary is reinstalled elsewhere. ### `plugins.rs` — plugin registry @@ -152,7 +154,9 @@ Builtin dispatch currently only acts on `SessionStart`, where `handle_session_st ### `mcp/` — MCP meta-server -One MCP server is registered with each agent (see [`sync.rs`](#syncrs--synchronization-command)); it exposes two tools — `list_tools` and `execute` — rather than every plugin server's tools directly. The agent writes a JavaScript snippet against the backing servers' tools and gets one result, instead of the whole workspace's tool schemas being loaded into its context up front. See the [MCP meta-server RFD](../rfds/mcp-meta-server/README.md) for the design rationale; every file carries a module doc covering its own reasoning. +Gated behind `[experiments] mcp-meta-server`, off by default. With it on, one MCP server is registered with each agent (see [`sync.rs`](#syncrs--synchronization-command)); it exposes two tools — `list_tools` and `execute` — rather than every plugin server's tools directly. The agent writes a JavaScript snippet against the backing servers' tools and gets one result, instead of the whole workspace's tool schemas being loaded into its context up front. See the [MCP meta-server RFD](../rfds/mcp-meta-server/README.md) for the design rationale; every file carries a module doc covering its own reasoning. + +The flag is read at three points, all outside this module: `sync.rs` (which entries get written), `hook.rs` (`prewarm_mcp_servers`), and the binary's `McpServe` arm — which refuses to serve when the flag is off, so nothing can talk to a meta-server the user never opted into. The tuning knobs in `[mcp]` (`McpConfig`) are meaningless until the flag is on. - `server.rs` — the meta-server process. Two constraints shape it: stdout carries JSON-RPC (all reporting goes to stderr, which is why the binary forces `ReportMode::Verbose` for `mcp-serve`), and startup must have no side effects (clients probe a server by spawning a throwaway copy, so the binary also skips `ensure_registries` and the self-update re-exec for `mcp-serve`). - `resolve.rs` — which backing servers the workspace makes available. `resolve` / `resolve_with_deps` build the predicate context and run the **active plugin set** (`plugins::active_plugins`, the same set skills and hooks use, so a crate-sourced plugin's servers are in scope), then filter each plugin's `applicable_mcp_entries` and reconcile each entry's author-declared timeouts against the user's `script-timeout-secs`. Nothing is started here — a `ResolvedServer` acquires its installation and produces a `SpawnSpec` on first use. `prewarm` is the `SessionStart` warm-up: `requirements` are acquired eagerly (declaring one *is* the author asking for a warm cache), while `installation` commands are only refreshed if already present, as hooks are. diff --git a/md/reference/configuration.md b/md/reference/configuration.md index ffcdae98..14864356 100644 --- a/md/reference/configuration.md +++ b/md/reference/configuration.md @@ -34,12 +34,12 @@ path = "my-plugins" ## Top-level keys -| Key | Type | Default | Description | -|-----|------|---------|-------------| -| `auto-sync` | bool | `true` | Automatically run `cargo agents sync` during hook invocations. When enabled, skills are kept in sync with workspace dependencies without manual intervention. | -| `agents-syncing` | bool | `true` | Include each workspace plugin's `.agents/skills/` default skill group, so skills you author there install into every configured agent's skill directory (such as `.claude/skills/` or `.kiro/skills/`). Skills that symposium itself installed — identified by the `.symposium` marker file — are never treated as sources. See [Workspace skills](../workspace-skills.md) for the user-guide overview, or [Agents syncing](#agents-syncing-mirror-user-authored-skills) below for details. | -| `hook-scope` | string | `"global"` | Where agent hooks are installed. `"global"` writes to the user's home directory (e.g., `~/`). `"project"` writes to the project directory, keeping hooks local to the workspace. | -| `auto-update` | string | `"on"` | Controls automatic update behavior. `"off"` disables update checks entirely. `"warn"` checks the registry (at most once per 24 hours) and prints a message when a newer version is available. `"on"` automatically installs the update via `cargo install` and re-executes the command with the new binary. | +| Key | Type | Default | Description | +| ---------------- | ------ | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `auto-sync` | bool | `true` | Automatically run `cargo agents sync` during hook invocations. When enabled, skills are kept in sync with workspace dependencies without manual intervention. | +| `agents-syncing` | bool | `true` | Include each workspace plugin's `.agents/skills/` default skill group, so skills you author there install into every configured agent's skill directory (such as `.claude/skills/` or `.kiro/skills/`). Skills that symposium itself installed — identified by the `.symposium` marker file — are never treated as sources. See [Workspace skills](../workspace-skills.md) for the user-guide overview, or [Agents syncing](#agents-syncing-mirror-user-authored-skills) below for details. | +| `hook-scope` | string | `"global"` | Where agent hooks are installed. `"global"` writes to the user's home directory (e.g., `~/`). `"project"` writes to the project directory, keeping hooks local to the workspace. | +| `auto-update` | string | `"on"` | Controls automatic update behavior. `"off"` disables update checks entirely. `"warn"` checks the registry (at most once per 24 hours) and prints a message when a newer version is available. `"on"` automatically installs the update via `cargo install` and re-executes the command with the new binary. | ### Agents syncing: mirror user-authored skills @@ -51,12 +51,12 @@ predicates = ["workspace-member()"] source.path = ".agents/skills" ``` -Skills you author in `.agents/skills/` therefore flow through the same pipeline as every other skill and install into each configured agent's own skill directory, so a single authored copy is visible to every agent. The `workspace-member()` gate is what keeps these maintainer skills from installing for *dependents* of a published crate — they apply only while working in the workspace itself. +Skills you author in `.agents/skills/` therefore flow through the same pipeline as every other skill and install into each configured agent's own skill directory, so a single authored copy is visible to every agent. The `workspace-member()` gate is what keeps these maintainer skills from installing for _dependents_ of a published crate — they apply only while working in the workspace itself. Two `.symposium`-marker rules keep sources and copies distinct (symposium never writes a marker into a source, only into directories it installs): - Skill discovery skips marker-bearing directories, so copies symposium installed into `.agents/skills/` (for agents that read it natively) are never re-discovered as sources. -- For an agent whose skill directory *is* `.agents/skills/`, a skill whose source already sits at its install slot is left in place — nothing is copied. +- For an agent whose skill directory _is_ `.agents/skills/`, a skill whose source already sits at its install slot is left in place — nothing is copied. Installed copies receive the same marker and `*` `.gitignore` that plugin-installed skills get, which means: updates to the source are re-copied on each sync; removing the source removes the copies on the next sync (the normal stale-skill reap); disabling `agents-syncing = false` does the same; and a pre-existing user-managed directory in a target is never overwritten (the skill installs under a suffixed name instead). @@ -72,14 +72,14 @@ Registering hooks at the project level requires you to run `cargo agents sync` w Each `[[agent]]` entry identifies an agent you use. You can configure multiple agents. -| Key | Type | Default | Description | -|-----|------|---------|-------------| -| `name` | string | *(required)* | Agent name: `claude`, `codex`, `copilot`, `gemini`, `goose`, `kiro`, or `opencode`. | +| Key | Type | Default | Description | +| ------ | ------ | ------------ | ----------------------------------------------------------------------------------- | +| `name` | string | _(required)_ | Agent name: `claude`, `codex`, `copilot`, `gemini`, `goose`, `kiro`, or `opencode`. | ## `[logging]` -| Key | Type | Default | Description | -|-----|------|---------|-------------| +| Key | Type | Default | Description | +| ------- | ------ | -------- | --------------------------------------------------------------------- | | `level` | string | `"info"` | Minimum log level. One of: `trace`, `debug`, `info`, `warn`, `error`. | ## `[telemetry]` @@ -91,8 +91,8 @@ and share the data yourself with `cargo agents telemetry show`. The preference is also collected during `cargo agents init`. See the [telemetry design chapter](../design/telemetry.md) for the event format. -| Key | Type | Default | Description | -|-----|------|---------|-------------| +| Key | Type | Default | Description | +| --------- | ---- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `enabled` | bool | `false` | Record anonymous usage events (session starts, prompts, tool usage — counts and metadata only, no prompt or command content). Toggle with `cargo agents telemetry enable` / `disable`. | ```toml @@ -100,41 +100,83 @@ is also collected during `cargo agents init`. See the enabled = true ``` +## `[experiments]` + +Unfinished features you can opt into. Every key defaults to `false` and carries +no stability promise: an experiment may change shape, or be withdrawn, between +releases. + +| Key | Type | Default | Description | +| ----------------- | ---- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `mcp-meta-server` | bool | `false` | Register a single `symposium` MCP entry (`cargo agents mcp-serve`) with each agent instead of one entry per plugin server, and let that server start the backing servers on demand. See [How registration works](./plugin-definition.md#how-registration-works). With the flag off, `cargo agents mcp-serve` refuses to start. | + +```toml +[experiments] +mcp-meta-server = true +``` + +A key this binary does not recognize — a typo, or an experiment that has since +been withdrawn — fails to parse. Symposium warns and then runs with **every** setting at its default for that invocation, so fix or delete the key rather than leaving it in place. + +## `[mcp]` + +Tuning for the MCP meta-server: sandbox ceilings for one `execute` call and +timings for the servers behind it. These take effect only when +`[experiments] mcp-meta-server` is on. The sandbox limits (`script-*`, `max-*`) +protect your session from a runaway agent-authored script, so a plugin cannot +raise them; the per-server timings can be lowered by a plugin's own +`[[mcp_servers]]` entry, never raised. + +| Key | Type | Default | Description | +| ----------------------------- | ------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `script-timeout-secs` | integer | `120` | Wall-clock ceiling for one `execute` call. Bounds every tool call inside it, so it must exceed `tool-call-timeout-secs`. | +| `script-memory-limit-mb` | integer | `64` | Memory ceiling for the JavaScript runtime. | +| `script-stack-limit-kb` | integer | `1024` | Stack ceiling for the JavaScript runtime. | +| `max-tool-calls` | integer | `100` | Most backing-server tool calls one `execute` may make. | +| `max-result-bytes` | integer | `32768` | Ceiling on a serialized `execute` return value. Oversized results are truncated with a marker, not rejected. | +| `max-console-bytes` | integer | `8192` | Ceiling on captured `console` output for one `execute`. | +| `server-startup-timeout-secs` | integer | `30` | Ceiling on spawning a backing server and completing its handshake. | +| `tool-call-timeout-secs` | integer | `60` | Ceiling on a single backing-server tool call. Must be below `script-timeout-secs`. | +| `max-server-restarts` | integer | `5` | Restart attempts before a backing server is marked permanently failed. | +| `restart-stable-reset-secs` | integer | `300` | How long a backing server must stay connected before its restart counter resets. | +| `shutdown-grace-secs` | integer | `5` | How long a backing server gets to exit before its process group is killed. | +| `read-only` | bool | `false` | Expose only tools annotated `readOnlyHint` and reject the rest at dispatch. The annotation is self-declared by the backing server, so this guards against agent mistakes, not against a hostile server. | + ## `[defaults]` Controls the two built-in registries. Both are enabled by default. -| Key | Type | Default | Description | -|-----|------|---------|-------------| -| `symposium-recommendations` | bool | `true` | Fetch plugins from the [symposium-dev/recommendations](https://github.com/symposium-dev/recommendations) repository. | -| `user-plugins` | bool | `true` | Scan `~/.symposium/plugins/` for user-defined plugins. | +| Key | Type | Default | Description | +| --------------------------- | ---- | ------- | -------------------------------------------------------------------------------------------------------------------- | +| `symposium-recommendations` | bool | `true` | Fetch plugins from the [symposium-dev/recommendations](https://github.com/symposium-dev/recommendations) repository. | +| `user-plugins` | bool | `true` | Scan `~/.symposium/plugins/` for user-defined plugins. | ## `[[registry]]` Defines additional registries — directories or repositories offering plugins. Each entry must have exactly one of `git` or `path`. `[[plugin-source]]` is the retired spelling of this table and is still accepted. -| Key | Type | Default | Description | -|-----|------|---------|-------------| -| `name` | string | *(required)* | A name for this registry. Used in logs and cache paths, and to attribute the plugins loaded from it. | -| `git` | string | — | Repository URL. Fetched and cached under `~/.symposium/cache/plugin-sources/`, then read as a local directory. | -| `path` | string | — | Local directory containing plugins. Relative paths are resolved from `~/.symposium/`. | -| `auto-update` | bool | `true` | Check for updates on startup. Only applies to `git` registries. | +| Key | Type | Default | Description | +| ------------- | ------ | ------------ | -------------------------------------------------------------------------------------------------------------- | +| `name` | string | _(required)_ | A name for this registry. Used in logs and cache paths, and to attribute the plugins loaded from it. | +| `git` | string | — | Repository URL. Fetched and cached under `~/.symposium/cache/plugin-sources/`, then read as a local directory. | +| `path` | string | — | Local directory containing plugins. Relative paths are resolved from `~/.symposium/`. | +| `auto-update` | bool | `true` | Check for updates on startup. Only applies to `git` registries. | ## `[plugins]` -Enablement: which plugins are allowed to run at all, as distinct from the [predicates](./predicates.md) that decide *when* an enabled plugin applies. +Enablement: which plugins are allowed to run at all, as distinct from the [predicates](./predicates.md) that decide _when_ an enabled plugin applies. Symposium trusts two things without asking: the workspace you are in, and the [registries](#registry) it is configured with. A registry exists to curate plugins, so enabling one is the act of accepting its curation. Both built-in registries count here and are on by default — `user-plugins` is your own directory, while `symposium-recommendations` is a list curated by the Symposium project and trusted until you turn it off in [`[defaults]`](#defaults). -Your dependency list is deliberately not a trust root. Depending on a crate means compiling its code; it should not silently let the crate's author add instructions to your agent. So a plugin embedded in a dependency runs only once you say so, and a registry plugin that names no dependency anywhere is *dormant* — loaded and listed, but inactive — until you enable it by name. +Your dependency list is deliberately not a trust root. Depending on a crate means compiling its code; it should not silently let the crate's author add instructions to your agent. So a plugin embedded in a dependency runs only once you say so, and a registry plugin that names no dependency anywhere is _dormant_ — loaded and listed, but inactive — until you enable it by name. -Trust follows whoever supplies the *content*, not the package the content is about: a registry entry recommending a plugin for `serde` is the registry's own content and is trusted, while `serde`'s embedded plugin is not. One consequence is worth knowing: a trusted plugin may name a crate with a [`[[plugins]]` chained reference](./plugin-definition.md), and that crate's plugin content then loads without a `[plugins]` entry of its own — the registry is vouching for it. +Trust follows whoever supplies the _content_, not the package the content is about: a registry entry recommending a plugin for `serde` is the registry's own content and is trusted, while `serde`'s embedded plugin is not. One consequence is worth knowing: a trusted plugin may name a crate with a [`[[plugins]]` chained reference](./plugin-definition.md), and that crate's plugin content then loads without a `[plugins]` entry of its own — the registry is vouching for it. -| Key | Type | Default | Description | -|-----|------|---------|-------------| -| `auto-enable` | array of strings | `[]` | Dependency names whose embedded plugins load without being asked about. `"*"` pre-consents to every dependency. | -| `use` | array | `[]` | Plugins enabled deliberately. Each entry is either a plain name (enabled in every workspace) or `{ name = "...", workspace = "/path" }` (enabled only while working in that workspace root). | -| `disable` | array of strings | `[]` | Names that must never be enabled. Takes precedence over `auto-enable`, including over `"*"`. | +| Key | Type | Default | Description | +| ------------- | ---------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `auto-enable` | array of strings | `[]` | Dependency names whose embedded plugins load without being asked about. `"*"` pre-consents to every dependency. | +| `use` | array | `[]` | Plugins enabled deliberately. Each entry is either a plain name (enabled in every workspace) or `{ name = "...", workspace = "/path" }` (enabled only while working in that workspace root). | +| `disable` | array of strings | `[]` | Names that must never be enabled. Takes precedence over `auto-enable`, including over `"*"`. | Names are matched hyphen/underscore-insensitively, like crate names: `widget-lib` and `widget_lib` are the same entry. @@ -153,21 +195,21 @@ You rarely edit this section by hand. [`cargo agents use`](./cargo-agents-use.md User-wide data lives under `~/.symposium/` by default. Override with environment variables: -| | Config | Cache | Logs | -|---|---|---|---| -| `SYMPOSIUM_HOME` | `$SYMPOSIUM_HOME/` | `$SYMPOSIUM_HOME/cache/` | `$SYMPOSIUM_HOME/logs/` | -| XDG | `$XDG_CONFIG_HOME/symposium/` | `$XDG_CACHE_HOME/symposium/` | `$XDG_STATE_HOME/symposium/logs/` | -| Default | `~/.symposium/` | `~/.symposium/cache/` | `~/.symposium/logs/` | +| | Config | Cache | Logs | +| ---------------- | ----------------------------- | ---------------------------- | --------------------------------- | +| `SYMPOSIUM_HOME` | `$SYMPOSIUM_HOME/` | `$SYMPOSIUM_HOME/cache/` | `$SYMPOSIUM_HOME/logs/` | +| XDG | `$XDG_CONFIG_HOME/symposium/` | `$XDG_CACHE_HOME/symposium/` | `$XDG_STATE_HOME/symposium/logs/` | +| Default | `~/.symposium/` | `~/.symposium/cache/` | `~/.symposium/logs/` | `SYMPOSIUM_HOME` takes precedence over XDG variables. ## File locations -| Path | Purpose | -|------|---------| -| `~/.symposium/config.toml` | User configuration | -| `~/.symposium/state.toml` | Persistent state (binary version stamp, last update check) | -| `~/.symposium/telemetry/` | Telemetry event log, one JSONL file per day (created when `[telemetry] enabled = true` and events are recorded) | -| `~/.symposium/plugins/` | User-defined plugins | -| `~/.symposium/cache/` | Cache directory (crate sources, plugin sources) | -| `~/.symposium/logs/` | Log files (one per invocation, timestamped) | +| Path | Purpose | +| -------------------------- | --------------------------------------------------------------------------------------------------------------- | +| `~/.symposium/config.toml` | User configuration | +| `~/.symposium/state.toml` | Persistent state (binary version stamp, last update check) | +| `~/.symposium/telemetry/` | Telemetry event log, one JSONL file per day (created when `[telemetry] enabled = true` and events are recorded) | +| `~/.symposium/plugins/` | User-defined plugins | +| `~/.symposium/cache/` | Cache directory (crate sources, plugin sources) | +| `~/.symposium/logs/` | Log files (one per invocation, timestamped) | diff --git a/md/reference/plugin-definition.md b/md/reference/plugin-definition.md index 99440b5d..053611a2 100644 --- a/md/reference/plugin-definition.md +++ b/md/reference/plugin-definition.md @@ -538,9 +538,16 @@ transport = "sse" ### How registration works -With the meta-server enabled (`[mcp] enabled`, the default), `sync` writes a single `symposium` entry into each agent's config instead of one entry per server. The agent sees two tools; the servers behind them are started on demand when a script calls one, and a server whose predicates do not hold is never started at all. Declaring a server is therefore all a plugin does — there is no per-agent MCP configuration to maintain. +By default, `sync` writes each applicable entry into the agent's own MCP config, handling the format differences per agent. -Setting `[mcp] enabled = false` restores the direct path: each applicable entry is written into the agent's own MCP config, and Symposium handles the format differences. +Enabling the [MCP meta-server experiment](./configuration.md#experiments) instead writes a single `symposium` entry into each agent's config: + +```toml +[experiments] +mcp-meta-server = true +``` + +The agent then sees two tools; the servers behind them are started on demand when a script calls one, and a server whose predicates do not hold is never started at all. Declaring a server is all a plugin does either way — there is no per-agent MCP configuration to maintain. | Agent | Config location | Key | |-------|----------------|-----| diff --git a/md/rfds/mcp-meta-server/README.md b/md/rfds/mcp-meta-server/README.md index 329e0a0f..9c672bc2 100644 --- a/md/rfds/mcp-meta-server/README.md +++ b/md/rfds/mcp-meta-server/README.md @@ -173,7 +173,9 @@ If the index exceeds a reasonable size (TBD, likely ~2000 chars), the descriptio ### Registration mechanics -During `init`/`sync`, Symposium writes a single MCP entry named `"symposium"` pointing to `cargo-agents mcp-serve`. The entry is identified by its well-known name — no additional ownership markers are needed. Individual plugin server entries are never written to agent config. +With the experiment on (`[experiments] mcp-meta-server = true`; off by default), `init`/`sync` writes a single MCP entry named `"symposium"` pointing to `cargo-agents mcp-serve`. The entry is identified by its well-known name — no additional ownership markers are needed, and individual plugin server entries are not written to agent config. + +The flag is the whole rollout mechanism, so both directions have to hold: turning it on unregisters the per-plugin entries, turning it off unregisters `"symposium"` and puts them back, and `cargo agents mcp-serve` refuses to start while it is off. Nobody talks to a meta-server they did not opt into. ### Agent compatibility @@ -438,4 +440,4 @@ The cause was one type doing three jobs: a wire format, a manifest schema, and a The design's first step rewrites agent config to the single meta-server entry, before the meta-server exists. A release cut between that step and a working `mcp-serve` ships broken MCP for every user who takes it. -It is now the last step, gated on a config flag, which also keeps the old behavior measurable against the new one without reverting code. +It is now the last step, gated on `[experiments] mcp-meta-server`, which defaults to off — so merging the meta-server changes nothing for a user who does not ask for it, and the old behavior stays measurable against the new one without reverting code. diff --git a/src/bin/cargo-agents.rs b/src/bin/cargo-agents.rs index 4e2af1aa..7b274252 100644 --- a/src/bin/cargo-agents.rs +++ b/src/bin/cargo-agents.rs @@ -162,6 +162,16 @@ async fn main() -> ExitCode { // Commands that need direct I/O (stdin/stdout) stay in the binary Some(Commands::Hook { agent, event }) => hook::run(&sym, agent, event).await, + Some(Commands::McpServe) if !sym.config.experiments.mcp_meta_server => { + // stderr, never stdout: an enabled run owns stdout for JSON-RPC. + eprintln!( + "Error: the MCP meta-server is experimental and off by default. \ + Enable it in {}:\n\n[experiments]\nmcp-meta-server = true", + sym.config_dir().join("config.toml").display() + ); + ExitCode::FAILURE + } + Some(Commands::McpServe) => { let resolution = symposium::mcp::resolve::resolve(&sym, &cwd).await; for rejection in &resolution.rejected { diff --git a/src/config.rs b/src/config.rs index cef837e3..3c46c87e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -93,6 +93,10 @@ pub struct Config { #[serde(default, skip_serializing_if = "TelemetryConfig::is_default")] pub telemetry: TelemetryConfig, + /// Opt-in unstable features. All off by default. + #[serde(default, skip_serializing_if = "ExperimentsConfig::is_default")] + pub experiments: ExperimentsConfig, + /// MCP meta-server settings. #[serde(default, skip_serializing_if = "McpConfig::is_default")] pub mcp: McpConfig, @@ -274,6 +278,30 @@ impl Default for LoggingConfig { } } +/// Opt-in unstable features (`[experiments]`). +/// +/// Every key defaults to off and carries no stability promise: an experiment +/// may change shape, or be withdrawn, between releases. Unknown keys are +/// rejected like in every other section, which makes retiring an experiment a +/// compatibility event — keep its key accepted-and-ignored for a release +/// rather than deleting it outright, since a config naming a key this binary +/// does not know fails to parse and falls back to *all* defaults. +#[derive(Debug, Deserialize, Serialize, Clone, Copy, Default, PartialEq, Eq)] +#[serde(default, deny_unknown_fields)] +pub struct ExperimentsConfig { + /// Register the MCP meta-server (`cargo agents mcp-serve`) as the single + /// agent-facing MCP entry, in place of each plugin's own servers, and + /// serve `mcp-serve`. + #[serde(rename = "mcp-meta-server")] + pub mcp_meta_server: bool, +} + +impl ExperimentsConfig { + fn is_default(&self) -> bool { + *self == ExperimentsConfig::default() + } +} + /// Settings for the MCP meta-server (`cargo agents mcp-serve`). /// /// Two groups of knobs with different owners. The sandbox limits @@ -284,12 +312,13 @@ impl Default for LoggingConfig { /// /// `#[serde(default)]` on the container means every missing key falls back to /// the value in [`McpConfig::default`], which is the single source of truth. +/// +/// Whether the meta-server runs at all is not decided here: that is +/// [`ExperimentsConfig::mcp_meta_server`]. These knobs only take effect once +/// the experiment is on. #[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)] #[serde(default, deny_unknown_fields)] pub struct McpConfig { - /// Register the meta-server and serve `mcp-serve`. - pub enabled: bool, - /// Wall-clock ceiling for one `execute` call. /// /// Bounds everything inside it, so it must exceed @@ -353,7 +382,6 @@ pub struct McpConfig { impl Default for McpConfig { fn default() -> Self { Self { - enabled: true, script_timeout_secs: 120, script_memory_limit_mb: 64, script_stack_limit_kb: 1024, @@ -398,6 +426,7 @@ impl Default for Config { hook_scope: HookScope::default(), auto_update: AutoUpdate::default(), telemetry: TelemetryConfig::default(), + experiments: ExperimentsConfig::default(), mcp: McpConfig::default(), plugins: PluginsConfig::default(), agents: Vec::new(), @@ -424,6 +453,8 @@ struct RawConfig { #[serde(default)] telemetry: TelemetryConfig, #[serde(default)] + experiments: ExperimentsConfig, + #[serde(default)] mcp: McpConfig, #[serde(default)] plugins: PluginsConfig, @@ -453,6 +484,7 @@ impl RawConfig { hook_scope: self.hook_scope, auto_update: self.auto_update, telemetry: self.telemetry, + experiments: self.experiments, mcp: self.mcp, plugins: self.plugins, agents: self.agents, @@ -472,6 +504,7 @@ impl From for RawConfig { hook_scope: config.hook_scope, auto_update: config.auto_update, telemetry: config.telemetry, + experiments: config.experiments, mcp: config.mcp, plugins: config.plugins, agents: config.agents, @@ -1067,11 +1100,65 @@ mod tests { ); } + #[test] + fn experiments_are_off_by_default() { + let config = parse_config(""); + assert!(!config.experiments.mcp_meta_server); + } + + #[test] + fn parse_experiments_mcp_meta_server() { + let config = parse_config(indoc! {" + [experiments] + mcp-meta-server = true + "}); + assert!(config.experiments.mcp_meta_server); + } + + /// A misspelled experiment is rejected rather than silently doing nothing: + /// the alternative is a user who believes a feature is on when it is not. + #[test] + fn parse_experiments_rejects_unknown_key() { + let err = toml::from_str::(indoc! {" + [experiments] + mcp-meta-servr = true + "}) + .expect_err("misspelled experiment should not parse"); + assert!( + err.to_string().contains("mcp-meta-servr"), + "error should name the offending key, got: {err}" + ); + } + + #[test] + fn default_experiments_are_omitted_from_serialized_config() { + let config = Config::default(); + let serialized = toml::to_string_pretty(&config).unwrap(); + assert!( + !serialized.contains("[experiments]"), + "default (off) experiments should not be written to config.toml: {serialized}" + ); + } + + #[test] + fn customized_experiments_are_written_to_serialized_config() { + let config = Config { + experiments: ExperimentsConfig { + mcp_meta_server: true, + }, + ..Config::default() + }; + let serialized = toml::to_string_pretty(&config).unwrap(); + assert!( + serialized.contains("mcp-meta-server = true"), + "customized experiments should round-trip: {serialized}" + ); + } + #[test] fn parse_mcp_defaults() { let config = parse_config(""); let mcp = &config.mcp; - assert!(mcp.enabled); assert!(!mcp.read_only); assert_eq!(mcp.script_timeout_secs, 120); assert_eq!(mcp.tool_call_timeout_secs, 60); @@ -1099,15 +1186,6 @@ mod tests { ); } - #[test] - fn parse_mcp_disabled() { - let config = parse_config(indoc! {" - [mcp] - enabled = false - "}); - assert!(!config.mcp.enabled); - } - /// A misspelled key is rejected rather than silently ignored. #[test] fn parse_mcp_rejects_unknown_key() { diff --git a/src/hook.rs b/src/hook.rs index 239ca9f8..439acfaa 100644 --- a/src/hook.rs +++ b/src/hook.rs @@ -488,7 +488,7 @@ async fn prewarm_hook_sources(sym: &Symposium, deps: &Arc) { /// the author asking for a warm cache, and the alternative is the download /// landing on the agent's first tool call. async fn prewarm_mcp_servers(sym: &Symposium, deps: &Arc) { - if !sym.config.mcp.enabled { + if !sym.config.experiments.mcp_meta_server { return; } if deps.load().is_none() { diff --git a/src/sync.rs b/src/sync.rs index 0c987d42..1a1bd589 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -371,17 +371,19 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve }) .collect(); - // One entry, not one per plugin. The agent then loads two tool schemas - // instead of every plugin server's, and the workspace's own tools stay - // out of `.claude/` and its equivalents. - let mcp_servers = if sym.config.mcp.enabled { + // With the meta-server experiment on: one entry, not one per plugin. The + // agent then loads two tool schemas instead of every plugin server's, and + // the workspace's own tools stay out of `.claude/` and its equivalents. + // Off (the default): each applicable plugin server is registered directly. + let meta_server = sym.config.experiments.mcp_meta_server; + let mcp_servers = if meta_server { vec![meta_server_entry()] } else { plugin_servers.clone() }; // Whichever set is not in use has to be removed, or entries written by a // previous configuration linger in agent config forever. - let stale_names: Vec<&str> = if sym.config.mcp.enabled { + let stale_names: Vec<&str> = if meta_server { plugin_server_names.clone() } else { vec![META_SERVER_NAME] diff --git a/symposium-testlib/src/lib.rs b/symposium-testlib/src/lib.rs index c345c876..d2d08bd8 100644 --- a/symposium-testlib/src/lib.rs +++ b/symposium-testlib/src/lib.rs @@ -221,6 +221,16 @@ impl Drop for TestContext { } impl TestContext { + /// Turn on an `[experiments]` flag in the fixture's `config.toml` and + /// reload, so the test exercises a feature that is off by default. + pub fn enable_experiment(&mut self, key: &str) -> anyhow::Result<()> { + let path = self.sym.config_dir().join("config.toml"); + let existing = std::fs::read_to_string(&path).unwrap_or_default(); + std::fs::write(&path, format!("{existing}\n[experiments]\n{key} = true\n"))?; + self.sym = Symposium::from_dir(self.sym.config_dir()); + Ok(()) + } + /// Run a `symposium` CLI command in-process, returning captured output. pub async fn symposium(&mut self, args: &[&str]) -> anyhow::Result { let mut full_args = vec!["cargo-agents"]; diff --git a/tests/init_sync.rs b/tests/init_sync.rs index 21c2bcb1..011930ae 100644 --- a/tests/init_sync.rs +++ b/tests/init_sync.rs @@ -415,15 +415,16 @@ async fn add_agent_is_additive() { .unwrap(); } -/// One entry is written, not one per plugin: the agent loads two tool -/// schemas rather than every plugin server's, and the workspace's own tools -/// stay out of agent configuration. +/// With the experiment on, one entry is written, not one per plugin: the agent +/// loads two tool schemas rather than every plugin server's, and the +/// workspace's own tools stay out of agent configuration. #[tokio::test] -async fn sync_registers_only_the_meta_server() { +async fn sync_registers_only_the_meta_server_when_the_experiment_is_on() { with_fixture( TestMode::SimulationOnly, &["mcp-filtering0", "workspace0"], async |mut ctx| { + ctx.enable_experiment("mcp-meta-server")?; ctx.symposium(&["init", "--add-agent", "claude"]).await?; ctx.symposium(&["sync"]).await?; @@ -446,19 +447,14 @@ async fn sync_registers_only_the_meta_server() { .unwrap(); } -/// Turning the meta-server off restores per-plugin registration, still -/// filtered by `depends-on`. +/// Without the meta-server experiment — the default — each applicable plugin +/// server is registered directly, still filtered by `depends-on`. #[tokio::test] -async fn sync_registers_plugin_servers_when_the_meta_server_is_disabled() { +async fn sync_registers_plugin_servers_by_default() { with_fixture( TestMode::SimulationOnly, &["mcp-filtering0", "workspace0"], async |mut ctx| { - let config = ctx.sym.config_dir().join("config.toml"); - let existing = std::fs::read_to_string(&config)?; - std::fs::write(&config, format!("{existing}\n[mcp]\nenabled = false\n"))?; - ctx.sym = symposium::config::Symposium::from_dir(ctx.sym.config_dir()); - ctx.symposium(&["init", "--add-agent", "claude"]).await?; ctx.symposium(&["sync"]).await?; @@ -484,6 +480,44 @@ async fn sync_registers_plugin_servers_when_the_meta_server_is_disabled() { .unwrap(); } +/// Turning the experiment off after a sync with it on has to *undo* the +/// registration, or the agent keeps calling a meta-server the user disabled. +#[tokio::test] +async fn sync_swaps_registration_when_the_experiment_is_turned_off() { + with_fixture( + TestMode::SimulationOnly, + &["mcp-filtering0", "workspace0"], + async |mut ctx| { + ctx.enable_experiment("mcp-meta-server")?; + ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["sync"]).await?; + + let settings_path = ctx + .workspace_root + .as_ref() + .unwrap() + .join(".claude/settings.json"); + let settings = std::fs::read_to_string(&settings_path)?; + assert!(settings.contains("\"symposium\""), "got: {settings}"); + assert!(!settings.contains("always-server"), "got: {settings}"); + + let config = ctx.sym.config_dir().join("config.toml"); + let enabled = std::fs::read_to_string(&config)?; + std::fs::write(&config, enabled.replace("mcp-meta-server = true", ""))?; + ctx.sym = symposium::config::Symposium::from_dir(ctx.sym.config_dir()); + ctx.symposium(&["sync"]).await?; + + let settings = std::fs::read_to_string(&settings_path)?; + assert!(!settings.contains("\"symposium\""), "got: {settings}"); + assert!(settings.contains("always-server"), "got: {settings}"); + + Ok(()) + }, + ) + .await + .unwrap(); +} + /// `sync` does not install skills targeting transitive dependencies. /// workspace0 has tokio as a direct dep; mio is a transitive dep of tokio. #[tokio::test] @@ -740,9 +774,9 @@ async fn sync_installs_skill_via_crate_manifest() { /// reference is available to the agent — a crate-sourced plugin's MCP servers /// flow through the active plugin set, not just its skills. /// -/// With the meta-server on (the default) the agent's config carries only the -/// `symposium` entry, so what proves the server reached the agent is that -/// resolution finds it behind that entry. +/// Run with the meta-server experiment on, where the agent's config carries +/// only the `symposium` entry — so what proves the server reached the agent is +/// that resolution finds it behind that entry. /// /// Fixture layout: /// - `facet-host` depends on `crate-f` (path dep) @@ -755,6 +789,7 @@ async fn sync_registers_mcp_server_from_chained_crate() { TestMode::SimulationOnly, &["crate-facets0"], async |mut ctx| { + ctx.enable_experiment("mcp-meta-server")?; ctx.symposium(&["init", "--add-agent", "claude"]).await?; ctx.symposium(&["sync"]).await?; diff --git a/tests/mcp_meta_server.rs b/tests/mcp_meta_server.rs index 69aaa785..758013e1 100644 --- a/tests/mcp_meta_server.rs +++ b/tests/mcp_meta_server.rs @@ -16,12 +16,26 @@ fn binary() -> PathBuf { PathBuf::from(env!("CARGO_BIN_EXE_cargo-agents")) } -/// Point the binary at an empty config directory, so a developer's own -/// settings cannot change what a test sees. +/// Point the binary at a config directory carrying nothing but the +/// meta-server experiment, so a developer's own settings cannot change what a +/// test sees. The flag is required: `mcp-serve` refuses to start without it. fn isolated_home() -> tempfile::TempDir { - tempfile::tempdir().expect("temp dir") + let home = tempfile::tempdir().expect("temp dir"); + std::fs::write(home.path().join("config.toml"), EXPERIMENT).expect("write config.toml"); + home } +/// Turns the meta-server on. Every config a test writes carries it, since the +/// experiment is off by default. +const EXPERIMENT: &str = "[experiments]\nmcp-meta-server = true\n"; + +/// Base fixture config: project-scoped hooks, no recommendations registry, and +/// the meta-server experiment on. +const BASE_CONFIG: &str = concat!( + "hook-scope = \"project\"\n[defaults]\nsymposium-recommendations = false\n", + "[experiments]\nmcp-meta-server = true\n", +); + async fn connect(home: &tempfile::TempDir) -> rmcp::service::RunningService { let mut command = tokio::process::Command::new(binary()); command @@ -36,6 +50,36 @@ async fn connect(home: &tempfile::TempDir) -> rmcp::service::RunningService Workspace { std::fs::write( home.join("config.toml"), - "hook-scope = \"project\"\n[defaults]\nsymposium-recommendations = false\n", + BASE_CONFIG, ) .unwrap(); std::fs::write( @@ -403,7 +447,7 @@ async fn a_script_starts_only_the_servers_it_calls() { std::fs::write(home.join("plugins/db/SYMPOSIUM.toml"), manifest).unwrap(); std::fs::write( home.join("config.toml"), - "hook-scope = \"project\"\n[defaults]\nsymposium-recommendations = false\n", + BASE_CONFIG, ) .unwrap(); std::fs::write( @@ -486,7 +530,7 @@ async fn a_dependency_added_mid_session_appears() { std::fs::write(home.join("plugins/db/SYMPOSIUM.toml"), manifest).unwrap(); std::fs::write( home.join("config.toml"), - "hook-scope = \"project\"\n[defaults]\nsymposium-recommendations = false\n", + BASE_CONFIG, ) .unwrap(); @@ -563,8 +607,7 @@ async fn exceeding_a_limit_reports_a_tagged_error() { let workspace = workspace_with_backing_server(); std::fs::write( workspace.home.join("config.toml"), - "hook-scope = \"project\"\n[defaults]\nsymposium-recommendations = false\n\ - [mcp]\nscript-timeout-secs = 2\ntool-call-timeout-secs = 1\n", + format!("{BASE_CONFIG}[mcp]\nscript-timeout-secs = 2\ntool-call-timeout-secs = 1\n"), ) .unwrap(); let client = connect_in(&workspace).await; @@ -592,8 +635,7 @@ async fn a_script_left_pending_still_answers() { let workspace = workspace_with_backing_server(); std::fs::write( workspace.home.join("config.toml"), - "hook-scope = \"project\"\n[defaults]\nsymposium-recommendations = false\n\ - [mcp]\nscript-timeout-secs = 2\ntool-call-timeout-secs = 1\n", + format!("{BASE_CONFIG}[mcp]\nscript-timeout-secs = 2\ntool-call-timeout-secs = 1\n"), ) .unwrap(); let client = connect_in(&workspace).await; From fcb85fde3ab918d1f5e3736e1af6b0d36114808a Mon Sep 17 00:00:00 2001 From: fluzko Date: Thu, 13 Aug 2026 09:38:36 -0300 Subject: [PATCH 39/39] fix(mcp): write MCP entries where each agent reads them --- md/design/agent-details/README.md | 48 +- md/design/agent-details/claude-code.md | 21 +- md/design/agent-details/goose.md | 40 +- md/design/module-structure.md | 2 + src/agents/mcp_server_registration.rs | 635 +++++++++++++++++++++---- src/agents/mod.rs | 425 ++++++++++------- src/sync.rs | 57 ++- tests/init_sync.rs | 6 +- 8 files changed, 946 insertions(+), 288 deletions(-) diff --git a/md/design/agent-details/README.md b/md/design/agent-details/README.md index 37f6b048..4fb49455 100644 --- a/md/design/agent-details/README.md +++ b/md/design/agent-details/README.md @@ -145,12 +145,42 @@ Symposium uses the vendor-neutral `.agents/skills/` path whenever the agent supp Relevant if symposium exposes functionality via MCP. -| Agent | MCP config location | Format | -|---|---|---| -| Claude Code | `.claude/settings.json` (`mcpServers` key) | JSON | -| GitHub Copilot | `.vscode/mcp.json` (VS Code), `~/.copilot/mcp-config.json` (CLI) | JSON | -| Gemini CLI | `.gemini/settings.json` (`mcpServers` key) | JSON | -| Codex CLI | `.codex/config.toml` / `~/.codex/config.toml` (`mcp_servers` key) | TOML | -| Kiro | `.kiro/settings/mcp.json`, `~/.kiro/settings/mcp.json` | JSON | -| OpenCode | `opencode.json` (`mcp` key) | JSON | -| Goose | `~/.config/goose/config.yaml` (`extensions` key) | YAML | +An agent's MCP file is usually **not** the file its hooks live in, and the +per-agent entry shapes differ more than they look. `Agent::mcp_config_path` +encodes this table; every row marked verified was confirmed by asking the tool +(` mcp list` reporting an entry symposium wrote, and ` mcp add` +showing which file it chooses), because a wrong guess here fails silently - +symposium reports success for a file the agent never reads. + +| Agent | Project scope | User scope | Entry shape | Verified | +|---|---|---|---|---| +| Claude Code | `/.mcp.json` | `~/.claude.json` | `mcpServers.` = `{command, args}` | yes | +| Gemini CLI | `.gemini/settings.json` | `~/.gemini/settings.json` | `mcpServers.` = `{command, args}` | yes | +| OpenCode | `/opencode.json` | `~/.config/opencode/opencode.json` | `mcp.` = `{type: "local", command: [bin, ...args], enabled, environment}` | yes | +| Codex CLI | *(none - user scope only)* | `~/.codex/config.toml` | `[mcp_servers.]` = `command`, `args` | yes | +| GitHub Copilot CLI | *(none - user scope only)* | `~/.copilot/mcp-config.json` | `mcpServers.` = `{command, args}` | yes | +| Kiro | `.kiro/settings/mcp.json` | `~/.kiro/settings/mcp.json` | `mcpServers.` = `{command, args}` | no (GUI only) | +| Goose | *(none - user scope only)* | `~/.config/goose/config.yaml` | `extensions.` = `{name, type: stdio, cmd, args, enabled, envs}` | yes | + +Notes that cost real debugging time: + +- Claude Code ignores `mcpServers` in `settings.json` at both scopes - that file + is hooks only. See [Claude Code](./claude-code.md#mcp-server-registration). +- The Copilot **CLI** requires the `mcpServers` wrapper; entries written bare at + the top level make it reject the whole file (`mcpServers: Required`), taking + the user's own servers down with it. `.vscode/mcp.json` belongs to the VS Code + extension, a different product symposium does not currently target. +- OpenCode rejects the entire config file for a wrong entry shape, so its + serializer is separate: the command is one array, and env vars go under + `environment` (an `env` key parses but never reaches the child). +- Codex, Copilot CLI and Goose have no project-level MCP config, so project + scope resolves to their user-level file rather than to a file nobody reads. +- Goose uses `cmd`, not `command`, and requires a `type`; the nested + `provider: mcp` / `config:` form it once got is rejected outright. Remote is + `type: streamable_http` with `uri` - Goose has no `sse` variant. +- `env`/`headers` must be **maps**. ACP models them as `[{name, value}]` pairs, + and a list is skipped or rejected - silently, for an entry that differs from a + working one only by carrying env. Codex takes env as a TOML table, Goose as + `envs`, OpenCode as `environment`, everyone else as `env`. +- The Copilot CLI also needs an explicit `type` (`local`/`http`/`sse`), or a + remote entry never appears. diff --git a/md/design/agent-details/claude-code.md b/md/design/agent-details/claude-code.md index 05268cb7..e9641f10 100644 --- a/md/design/agent-details/claude-code.md +++ b/md/design/agent-details/claude-code.md @@ -181,13 +181,15 @@ Decision precedence across parallel hooks: **deny > defer > ask > allow**. The ` ## MCP Server Registration In addition to hooks, symposium registers itself as an MCP server in the -agent's settings file. This provides an alternative integration path +agent's MCP configuration. This provides an alternative integration path alongside the hook-based approach. ### Configuration structure -The MCP server entry is added under `mcpServers` in the same settings -file used for hooks: +The entry goes under `mcpServers` - but **not** in the `settings.json` that +carries hooks. Claude Code does not read `mcpServers` from a settings file at +either scope; entries written there are silently ignored (`claude mcp list` +shows nothing, and a session sees no tools). MCP has its own two files: ```json { @@ -200,8 +202,17 @@ file used for hooks: } ``` -- **Project-level**: `.claude/settings.json` -- **User-level**: `~/.claude/settings.json` +- **Project-level**: `/.mcp.json` +- **User-level**: `~/.claude.json` (or `$CLAUDE_CONFIG_DIR/.claude.json`) + +Confirmed against the CLI: `claude mcp add -s project` writes the former, +`-s user` the latter. + +A server in a project `.mcp.json` starts out **pending approval** - Claude asks +before trusting a server a repository supplies. Symposium deliberately does not +pre-approve its own entries (that would mean overriding a trust prompt on the +user's behalf); approve once via `/mcp`, or add the name to +`enabledMcpjsonServers` in your own settings if you want it standing. Registration is idempotent — if the entry already exists with the correct values, no changes are made. If the entry exists but has stale diff --git a/md/design/agent-details/goose.md b/md/design/agent-details/goose.md index d2aa6ffe..d304f831 100644 --- a/md/design/agent-details/goose.md +++ b/md/design/agent-details/goose.md @@ -61,19 +61,47 @@ the Goose config file. ### Configuration structure -The MCP server entry is added under `extensions` in the YAML config: +The MCP server entry is added under `extensions` in the YAML config. Goose's +schema has its own vocabulary - a `type` discriminant, the binary under `cmd` +(not `command`), and env as an `envs` map: ```yaml extensions: symposium: - provider: mcp - config: - command: /path/to/cargo-agents - args: [mcp] + name: symposium + type: stdio + cmd: /path/to/cargo-agents + args: [mcp] + enabled: true + envs: + TOKEN: abc +``` + +A remote server is `type: streamable_http` with the endpoint under `uri`. There +is no `sse` variant - the accepted set is `stdio`, `builtin`, `platform`, +`streamable_http`, `frontend`, `inline_python`, which `goose recipe validate` +will list back at you for a wrong one: + +```yaml +extensions: + remote: + name: remote + type: streamable_http + uri: https://example.com/mcp + enabled: true + headers: + Authorization: Bearer t ``` -- **Project-level**: `.goose/config.yaml` - **User-level**: `~/.config/goose/config.yaml` +- **Project-level**: none - Goose reads only the user-level file, so + project-scoped registration resolves there. + +Two ways to check a change without a configured LLM provider: `goose recipe +validate` on a recipe holding the entry (it deserializes the same +`ExtensionConfig`), and `goose run --text hi`, whose startup warnings name each +extension it tried to launch. `goose info -v` only echoes the raw YAML, so it +cannot tell an accepted entry from a rejected one. Registration is idempotent — if the entry already exists with the correct values, no changes are made. Stale entries are updated in place. diff --git a/md/design/module-structure.md b/md/design/module-structure.md index d2590a0b..4925caa0 100644 --- a/md/design/module-structure.md +++ b/md/design/module-structure.md @@ -18,6 +18,8 @@ The cargo-workspace resolution is **CargoPm's**, so it lives in the cargo PM's m Centralizes agent-specific knowledge: hook registration file paths, skill installation directories, and hook registration logic for each supported agent (Claude Code, GitHub Copilot, Gemini CLI, Codex CLI, Kiro, OpenCode, Goose). Handles the differences between agents — e.g., Claude Code uses `.claude/skills/` and Kiro uses `.kiro/skills/`, while Copilot, Gemini, Codex, OpenCode, and Goose use the vendor-neutral `.agents/skills/`. OpenCode and Goose are skills-only agents (no hook registration). +MCP registration is its own axis, and the one place where guessing is expensive: an agent's MCP file is usually *not* its hooks file, so a wrong target fails **silently** - symposium reports success for a file the agent never reads. `Agent::mcp_config_path(scope, project_root, home)` is the single seam holding that per-agent truth (the table lives in [agent details](./agent-details/README.md#mcp-server-configuration)), with `register_mcp_servers` / `unregister_mcp_servers` as the only entry points; there is no separate project/global pair of functions to keep in sync. `McpScope` is deliberately distinct from `HookScope`: Claude, Gemini, OpenCode and Kiro have a project-level MCP file, while Codex, the Copilot CLI and Goose read only a user-level one, so `supports_project_mcp_scope` reports when project scope is downgraded rather than writing a file nobody reads. Entry *shape* varies too - OpenCode needs `{type, command: [bin, ...args], enabled}` and rejects its whole config file otherwise, which is why it has its own serializer (`opencode_server_to_json`) instead of the shared `{command, args}` one. + ### `init.rs` — initialization command Implements `cargo agents init`. Prompts for agents (or accepts `--add-agent`/`--remove-agent` flags), hook scope, auto-update behavior, and opt-in [telemetry](./telemetry.md); writes user config; and registers global hooks. diff --git a/src/agents/mcp_server_registration.rs b/src/agents/mcp_server_registration.rs index 7eee1f61..a8c5c741 100644 --- a/src/agents/mcp_server_registration.rs +++ b/src/agents/mcp_server_registration.rs @@ -35,10 +35,11 @@ fn server_name(server: &McpServer) -> &str { /// Convert an McpServer to the JSON value agents expect in their config. /// -/// Stdio: `{"command": "...", "args": [...], "env": [...]}` -/// Http/Sse: `{"url": "...", "headers": [...]}` +/// Stdio: `{"command": "...", "args": [...], "env": {...}}` +/// Http/Sse: `{"url": "...", "headers": {...}}` /// -/// `env` and `headers` are omitted when empty. +/// `env`/`headers` are objects, not ACP's `[{name, value}]` list: a list is +/// skipped or rejected, silently for an entry that only differs by carrying env. fn server_to_json(server: &McpServer) -> serde_json::Value { match server { McpServer::Stdio(s) => { @@ -47,21 +48,21 @@ fn server_to_json(server: &McpServer) -> serde_json::Value { "args": s.args, }); if !s.env.is_empty() { - v["env"] = serde_json::to_value(&s.env).unwrap(); + v["env"] = pairs_to_object(s.env.iter().map(|e| (&e.name, &e.value))); } v } McpServer::Http(s) => { let mut v = json!({ "url": s.url }); if !s.headers.is_empty() { - v["headers"] = serde_json::to_value(&s.headers).unwrap(); + v["headers"] = pairs_to_object(s.headers.iter().map(|h| (&h.name, &h.value))); } v } McpServer::Sse(s) => { let mut v = json!({ "url": s.url }); if !s.headers.is_empty() { - v["headers"] = serde_json::to_value(&s.headers).unwrap(); + v["headers"] = pairs_to_object(s.headers.iter().map(|h| (&h.name, &h.value))); } v } @@ -69,6 +70,70 @@ fn server_to_json(server: &McpServer) -> serde_json::Value { } } +/// [`server_to_json`] plus the explicit `type` its own `mcp add` writes. +/// Without it a remote entry is not picked up at all. +fn copilot_server_to_json(server: &McpServer) -> serde_json::Value { + let mut v = server_to_json(server); + v["type"] = json!(match server { + McpServer::Stdio(_) => "local", + McpServer::Sse(_) => "sse", + _ => "http", + }); + v +} + +fn pairs_to_object<'a>(pairs: impl Iterator) -> serde_json::Value { + serde_json::Value::Object( + pairs + .map(|(name, value)| (name.clone(), serde_json::Value::String(value.clone()))) + .collect(), + ) +} + +/// OpenCode rejects the whole config file given the common `{command, args}` +/// shape: `type` is required, the command is one array of binary plus args, and +/// env goes under `environment` (an `env` key parses but never reaches the child). +fn opencode_server_to_json(server: &McpServer) -> serde_json::Value { + match server { + McpServer::Stdio(s) => { + let mut command = vec![s.command.to_string_lossy().into_owned()]; + command.extend(s.args.iter().cloned()); + let mut v = json!({ + "type": "local", + "command": command, + "enabled": true, + }); + if !s.env.is_empty() { + v["environment"] = pairs_to_object(s.env.iter().map(|e| (&e.name, &e.value))); + } + v + } + McpServer::Http(s) => { + let mut v = json!({ "type": "remote", "url": s.url, "enabled": true }); + if !s.headers.is_empty() { + v["headers"] = pairs_to_object(s.headers.iter().map(|h| (&h.name, &h.value))); + } + v + } + McpServer::Sse(s) => { + let mut v = json!({ "type": "remote", "url": s.url, "enabled": true }); + if !s.headers.is_empty() { + v["headers"] = pairs_to_object(s.headers.iter().map(|h| (&h.name, &h.value))); + } + v + } + _ => panic!("unsupported McpServer variant"), + } +} + +/// Render a table as it would appear in a file, sub-tables included, unlike +/// `Table::to_string()` which renders only the table's own values. +fn render_toml_entry(table: &toml_edit::Table) -> String { + let mut doc = toml_edit::DocumentMut::new(); + doc["entry"] = toml_edit::Item::Table(table.clone()); + doc.to_string() +} + /// Result of upserting an MCP server entry. enum UpsertResult { AlreadyCorrect, @@ -77,16 +142,25 @@ enum UpsertResult { } /// Upsert a single MCP server entry into a JSON object container. +/// +/// An existing `"enabled": false` is preserved: it is how a user turns one +/// server off, and sync runs per hook event. fn upsert_json_mcp_entry( container: &mut serde_json::Value, name: &str, expected: &serde_json::Value, ) -> UpsertResult { if let Some(existing) = container.get(name) { - if existing == expected { + let mut expected = expected.clone(); + if existing.get("enabled") == Some(&serde_json::Value::Bool(false)) + && expected.get("enabled").is_some() + { + expected["enabled"] = serde_json::Value::Bool(false); + } + if *existing == expected { return UpsertResult::AlreadyCorrect; } - container[name] = expected.clone(); + container[name] = expected; UpsertResult::Updated } else { container[name] = expected.clone(); @@ -107,6 +181,18 @@ fn register_json_mcp_servers( servers: &[McpServer], container_key: Option<&str>, out: &Output, +) -> Result<()> { + register_json_mcp_servers_with(config_path, servers, container_key, server_to_json, out) +} + +/// As [`register_json_mcp_servers`], for an agent whose entry shape differs +/// from the common `{command, args}` one. +fn register_json_mcp_servers_with( + config_path: &Path, + servers: &[McpServer], + container_key: Option<&str>, + to_json: fn(&McpServer) -> serde_json::Value, + out: &Output, ) -> Result<()> { let display = display_path(config_path); let mut config = load_json_or_empty(config_path)?; @@ -127,7 +213,7 @@ fn register_json_mcp_servers( let mut changed = false; for server in servers { let name = server_name(server); - let expected = server_to_json(server); + let expected = to_json(server); match upsert_json_mcp_entry(container, name, &expected) { UpsertResult::AlreadyCorrect => { out.already_ok(format!("{display}: {name} MCP server already configured")); @@ -233,49 +319,68 @@ pub(super) fn register_codex_mcp_servers( let mut changed = false; for server in servers { let name = server_name(server); - let McpServer::Stdio(stdio) = server else { - out.info(format!( - "{display}: skipping non-stdio MCP server {name} (Codex only supports stdio)" - )); - continue; - }; - let cmd = stdio.command.to_string_lossy().to_string(); - let needs_update = if let Some(existing) = doc["mcp_servers"].get(name) { - let cmd_ok = existing.get("command").and_then(|v| v.as_str()) == Some(&cmd); - let args_ok = existing - .get("args") - .and_then(|v| v.as_array()) - .is_some_and(|a| { - a.iter() - .map(|v| v.as_str().unwrap_or("")) - .collect::>() - == stdio.args.iter().map(|s| s.as_str()).collect::>() - }); - if cmd_ok && args_ok { - out.already_ok(format!("{display}: {name} MCP server already configured")); - false - } else { - true + // Built before the comparison: an entry can differ by `env` or `url`, + // not just command/args. + let mut server_table = toml_edit::Table::new(); + match server { + McpServer::Stdio(stdio) => { + server_table["command"] = + toml_edit::value(stdio.command.to_string_lossy().to_string()); + let mut args = toml_edit::Array::new(); + for arg in &stdio.args { + args.push(arg.as_str()); + } + server_table["args"] = toml_edit::value(args); + if !stdio.env.is_empty() { + let mut env = toml_edit::Table::new(); + for var in &stdio.env { + env[var.name.as_str()] = toml_edit::value(var.value.as_str()); + } + server_table["env"] = toml_edit::Item::Table(env); + } } - } else { - true - }; - - if needs_update { - let mut server_table = toml_edit::Table::new(); - server_table["command"] = toml_edit::value(&cmd); - let mut args = toml_edit::Array::new(); - for arg in &stdio.args { - args.push(arg.as_str()); + // A bare `url` means streamable HTTP, per `codex mcp add --url`. + McpServer::Http(http) => { + server_table["url"] = toml_edit::value(http.url.as_str()); + if !http.headers.is_empty() { + // Reported, not dropped: an absent auth header surfaces later + // as an opaque connect failure. + out.info(format!( + "{display}: {name} headers not registered (Codex config has no header field); \ + the server may fail to authenticate" + )); + } + } + // Only one remote form exists, so SSE would be spoken to as + // streamable HTTP. Skipped rather than written wrong. + McpServer::Sse(_) => { + out.info(format!( + "{display}: skipping SSE MCP server {name} (Codex supports streamable HTTP only)" + )); + continue; + } + _ => { + out.info(format!("{display}: skipping unsupported MCP server {name}")); + continue; } - server_table["args"] = toml_edit::value(args); - let is_new = doc["mcp_servers"].get(name).is_none(); - doc["mcp_servers"][name] = toml_edit::Item::Table(server_table); - let verb = if is_new { "added" } else { "updated" }; - out.done(format!("{display}: {verb} {name} MCP server")); - changed = true; } + + let rendered = render_toml_entry(&server_table); + let already_correct = doc["mcp_servers"] + .get(name) + .and_then(|existing| existing.as_table()) + .is_some_and(|existing| render_toml_entry(existing) == rendered); + if already_correct { + out.already_ok(format!("{display}: {name} MCP server already configured")); + continue; + } + + let is_new = doc["mcp_servers"].get(name).is_none(); + doc["mcp_servers"][name] = toml_edit::Item::Table(server_table); + let verb = if is_new { "added" } else { "updated" }; + out.done(format!("{display}: {verb} {name} MCP server")); + changed = true; } if changed { @@ -318,13 +423,22 @@ pub(super) fn unregister_codex_mcp_servers( Ok(()) } -/// Copilot: top-level `` in mcp.json +/// Copilot: `mcpServers.` in mcp-config.json. +/// +/// The wrapper is not optional: bare at the top level, the CLI refuses the +/// whole file with `mcpServers: Required`, taking the user's own servers with it. pub(super) fn register_copilot_mcp_servers( path: &Path, servers: &[McpServer], out: &Output, ) -> Result<()> { - register_json_mcp_servers(path, servers, None, out) + register_json_mcp_servers_with( + path, + servers, + Some("mcpServers"), + copilot_server_to_json, + out, + ) } pub(super) fn unregister_copilot_mcp_servers( @@ -332,7 +446,7 @@ pub(super) fn unregister_copilot_mcp_servers( names: &[&str], out: &Output, ) -> Result<()> { - unregister_json_mcp_servers(path, names, None, out) + unregister_json_mcp_servers(path, names, Some("mcpServers"), out) } /// Gemini CLI: same format as Claude (`mcpServers.`) @@ -365,6 +479,109 @@ pub(super) fn unregister_kiro_mcp_servers(path: &Path, names: &[&str], out: &Out unregister_claude_mcp_servers(path, names, out) } +/// One Goose `extensions.` block. +/// +/// Goose's schema, per `goose recipe validate`: a `type` discriminant (`stdio` / +/// `streamable_http`, no `sse`), the binary under `cmd` not `command`, `envs` as +/// a map, and the name repeated inside the entry. +fn goose_extension_yaml(server: &McpServer) -> Option { + let quote = |s: &str| format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")); + let yaml_list = |items: &[String]| { + let quoted: Vec<_> = items.iter().map(|a| quote(a)).collect(); + format!("[{}]", quoted.join(", ")) + }; + let map_block = |pairs: Vec<(&String, &String)>, indent: &str| { + pairs + .iter() + .map(|(k, v)| format!("\n{indent}{}: {}", k, quote(v))) + .collect::() + }; + + match server { + McpServer::Stdio(stdio) => { + let name = &stdio.name; + let cmd = quote(&stdio.command.to_string_lossy()); + let args = yaml_list(&stdio.args); + let envs = if stdio.env.is_empty() { + String::new() + } else { + format!( + "\n envs:{}", + map_block( + stdio.env.iter().map(|e| (&e.name, &e.value)).collect(), + " " + ) + ) + }; + Some(formatdoc! {" + {name}: + name: {name} + type: stdio + cmd: {cmd} + args: {args} + enabled: true{envs} + "}) + } + // Goose calls remote MCP `streamable_http`, and the endpoint is `uri`. + McpServer::Http(http) => Some(goose_remote_yaml( + &http.name, + &http.url, + http.headers.iter().map(|h| (&h.name, &h.value)).collect(), + )), + // No `sse` variant exists, and writing one as streamable HTTP yields an + // entry that cannot connect. Caller reports it as unsupported. + McpServer::Sse(_) => None, + _ => None, + } +} + +/// Does `content` carry this extension with `enabled: false`? Scans the entry's +/// own block, so another extension's `enabled: false` is not mistaken for it. +fn goose_extension_disabled(content: &str, name: &str) -> bool { + let needle = format!("{name}:"); + let mut indent = 0; + let mut in_section = false; + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let line_indent = line.len() - trimmed.len(); + if in_section && line_indent <= indent { + break; + } + if in_section && trimmed == "enabled: false" { + return true; + } + if !in_section && trimmed.starts_with(&needle) { + indent = line_indent; + in_section = true; + } + } + false +} + +fn goose_remote_yaml(name: &str, uri: &str, headers: Vec<(&String, &String)>) -> String { + let quote = |s: &str| format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")); + let uri = quote(uri); + let headers_block = if headers.is_empty() { + String::new() + } else { + let pairs: String = headers + .iter() + .map(|(k, v)| format!("\n {}: {}", k, quote(v))) + .collect(); + format!("\n headers:{pairs}") + }; + formatdoc! {" + {name}: + name: {name} + type: streamable_http + uri: {uri} + enabled: true{headers_block} + "} +} + /// Goose: `extensions.` in config.yaml (string manipulation to preserve comments) pub(super) fn register_goose_mcp_servers( config_path: &Path, @@ -382,28 +599,20 @@ pub(super) fn register_goose_mcp_servers( let mut changed = false; for server in servers { let name = server_name(server); - let McpServer::Stdio(stdio) = server else { - out.info(format!( - "{display}: skipping non-stdio MCP server {name} (Goose extensions use stdio)" - )); - continue; + let snippet = match goose_extension_yaml(server) { + Some(snippet) => snippet, + None => { + out.info(format!("{display}: skipping unsupported MCP server {name}")); + continue; + } }; - let cmd = stdio.command.to_string_lossy(); - let quoted_args: Vec<_> = stdio - .args - .iter() - .map(|a| format!("\"{}\"", a.replace('"', "\\\""))) - .collect(); - let args_yaml = format!("[{}]", quoted_args.join(", ")); - - let snippet = formatdoc! {" - {name}: - provider: mcp - config: - command: \"{cmd}\" - args: {args_yaml} - "}; + // Keep a user's `enabled: false`; sync runs per hook event. + let snippet = if goose_extension_disabled(&content, name) { + snippet.replace("enabled: true", "enabled: false") + } else { + snippet + }; let needle = format!("{name}:"); let already_exists = content.contains(&needle); @@ -524,7 +733,7 @@ pub(super) fn register_opencode_mcp_servers( servers: &[McpServer], out: &Output, ) -> Result<()> { - register_json_mcp_servers(path, servers, Some("mcp"), out) + register_json_mcp_servers_with(path, servers, Some("mcp"), opencode_server_to_json, out) } pub(super) fn unregister_opencode_mcp_servers( @@ -551,6 +760,197 @@ mod tests { vec!["symposium"] } + /// The two shapes that were dropped or mis-serialized: env, and headers. + fn env_and_remote_servers() -> Vec { + use sacp::schema::{EnvVariable, HttpHeader, McpServerHttp}; + vec![ + McpServer::Stdio( + McpServerStdio::new("withenv", "/bin/server") + .env(vec![EnvVariable::new("TOKEN", "abc")]), + ), + McpServer::Http( + McpServerHttp::new("remote", "http://localhost:8080/mcp") + .headers(vec![HttpHeader::new("Authorization", "Bearer t")]), + ), + ] + } + + /// A pair-list `env` is skipped or rejected by every client, silently when + /// the entry differs from a working one only by carrying env. + #[test] + fn env_and_headers_are_objects_not_pair_lists() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("settings.json"); + register_claude_mcp_servers(&path, &env_and_remote_servers(), &Output::quiet()).unwrap(); + + let config: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(config["mcpServers"]["withenv"]["env"]["TOKEN"], "abc"); + assert_eq!( + config["mcpServers"]["remote"]["headers"]["Authorization"], + "Bearer t" + ); + } + + /// The Copilot CLI needs an explicit `type`; without it a remote entry is + /// not picked up at all. + #[test] + fn copilot_entries_carry_a_type() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("mcp-config.json"); + register_copilot_mcp_servers(&path, &env_and_remote_servers(), &Output::quiet()).unwrap(); + + let config: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(config["mcpServers"]["withenv"]["type"], "local"); + assert_eq!(config["mcpServers"]["remote"]["type"], "http"); + assert_eq!(config["mcpServers"]["withenv"]["env"]["TOKEN"], "abc"); + } + + /// Codex takes env as a TOML table and a remote server as a bare `url`, + /// matching `codex mcp add --env` / `--url`. + #[test] + fn codex_writes_env_table_and_remote_url() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("config.toml"); + register_codex_mcp_servers(&path, &env_and_remote_servers(), &Output::quiet()).unwrap(); + + let written = fs::read_to_string(&path).unwrap(); + let doc: toml::Table = toml::from_str(&written).unwrap(); + let servers = doc["mcp_servers"].as_table().unwrap(); + assert_eq!( + servers["withenv"]["env"]["TOKEN"].as_str(), + Some("abc"), + "got: {written}" + ); + assert_eq!( + servers["remote"]["url"].as_str(), + Some("http://localhost:8080/mcp"), + "got: {written}" + ); + } + + /// The comparison must see the `env` sub-table, or a rotated token stays + /// stale forever. + #[test] + fn codex_updates_a_changed_env_value() { + use sacp::schema::EnvVariable; + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("config.toml"); + let with = |value: &str| { + vec![McpServer::Stdio( + McpServerStdio::new("s", "/bin/server").env(vec![EnvVariable::new("TOKEN", value)]), + )] + }; + register_codex_mcp_servers(&path, &with("old"), &Output::quiet()).unwrap(); + register_codex_mcp_servers(&path, &with("new"), &Output::quiet()).unwrap(); + + let written = fs::read_to_string(&path).unwrap(); + let doc: toml::Table = toml::from_str(&written).unwrap(); + assert_eq!( + doc["mcp_servers"]["s"]["env"]["TOKEN"].as_str(), + Some("new"), + "got: {written}" + ); + } + + /// `enabled: false` is how a user turns one server off for the agents whose + /// schema has the field. Sync runs per hook event, so re-asserting `true` + /// would make the choice impossible to keep. + #[test] + fn a_user_disabled_server_stays_disabled() { + let tmp = tempfile::tempdir().unwrap(); + + let opencode = tmp.path().join("opencode.json"); + save_json( + &opencode, + &json!({"mcp": {"symposium": {"type": "local", "command": ["/old"], "enabled": false}}}), + ) + .unwrap(); + register_opencode_mcp_servers(&opencode, &test_servers(), &Output::quiet()).unwrap(); + let config: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&opencode).unwrap()).unwrap(); + assert_eq!( + config["mcp"]["symposium"]["enabled"], false, + "got: {config:#}" + ); + // The rest of the entry is still brought up to date. + assert_eq!( + config["mcp"]["symposium"]["command"][0], + "/usr/local/bin/cargo-agents" + ); + + let goose = tmp.path().join("config.yaml"); + fs::write( + &goose, + "extensions:\n symposium:\n name: symposium\n type: stdio\n cmd: \"/old\"\n args: []\n enabled: false\n", + ) + .unwrap(); + register_goose_mcp_servers(&goose, &test_servers(), &Output::quiet()).unwrap(); + let content = fs::read_to_string(&goose).unwrap(); + let doc: serde_yaml_ng::Value = serde_yaml_ng::from_str(&content).unwrap(); + assert_eq!( + doc["extensions"]["symposium"]["enabled"].as_bool(), + Some(false), + "got: {content}" + ); + assert_eq!( + doc["extensions"]["symposium"]["cmd"].as_str(), + Some("/usr/local/bin/cargo-agents"), + "got: {content}" + ); + } + + /// An SSE endpoint written as streamable HTTP could never connect, so it is + /// reported as unsupported instead of registered wrong. + #[test] + fn sse_servers_are_skipped_where_the_transport_does_not_exist() { + use sacp::schema::McpServerSse; + let sse = vec![McpServer::Sse(McpServerSse::new( + "streamy", + "http://localhost:8080/sse", + ))]; + let tmp = tempfile::tempdir().unwrap(); + + let goose = tmp.path().join("config.yaml"); + register_goose_mcp_servers(&goose, &sse, &Output::quiet()).unwrap(); + let goose_content = fs::read_to_string(&goose).unwrap_or_default(); + assert!( + !goose_content.contains("streamable_http"), + "got: {goose_content}" + ); + + let codex = tmp.path().join("config.toml"); + register_codex_mcp_servers(&codex, &sse, &Output::quiet()).unwrap(); + let codex_content = fs::read_to_string(&codex).unwrap_or_default(); + assert!(!codex_content.contains("streamy"), "got: {codex_content}"); + } + + /// Registering the same Goose entry twice must leave the file byte-identical: + /// hook auto-sync runs this per event, and the update path rewrites the + /// user's whole config.yaml. + #[test] + fn goose_registration_leaves_the_file_untouched_when_unchanged() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("config.yaml"); + register_goose_mcp_servers(&path, &test_servers(), &Output::quiet()).unwrap(); + let first = fs::read_to_string(&path).unwrap(); + register_goose_mcp_servers(&path, &test_servers(), &Output::quiet()).unwrap(); + assert_eq!(first, fs::read_to_string(&path).unwrap()); + } + + /// Re-registering an unchanged entry must not rewrite the file, including + /// for the fields the comparison used to ignore. + #[test] + fn codex_registration_is_idempotent_for_env_and_url() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("config.toml"); + register_codex_mcp_servers(&path, &env_and_remote_servers(), &Output::quiet()).unwrap(); + let first = fs::read_to_string(&path).unwrap(); + register_codex_mcp_servers(&path, &env_and_remote_servers(), &Output::quiet()).unwrap(); + assert_eq!(first, fs::read_to_string(&path).unwrap()); + } + // -- Claude MCP (also covers Gemini and Kiro via delegation) -- #[test] @@ -713,11 +1113,13 @@ mod tests { let config: serde_json::Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + // The `mcpServers` wrapper is required: bare entries make the CLI + // reject the entire file. assert_eq!( - config["symposium"]["command"], + config["mcpServers"]["symposium"]["command"], "/usr/local/bin/cargo-agents" ); - assert_eq!(config["symposium"]["args"][0], "mcp"); + assert_eq!(config["mcpServers"]["symposium"]["args"][0], "mcp"); } #[test] @@ -729,14 +1131,14 @@ mod tests { let config: serde_json::Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); - assert_eq!(config.as_object().unwrap().len(), 1); + assert_eq!(config["mcpServers"].as_object().unwrap().len(), 1); } #[test] fn register_copilot_updates_stale() { let tmp = tempfile::tempdir().unwrap(); let path = tmp.path().join("mcp.json"); - let stale = json!({"symposium": {"command": "/old/path", "args": ["mcp"]}}); + let stale = json!({"mcpServers": {"symposium": {"command": "/old/path", "args": ["mcp"]}}}); save_json(&path, &stale).unwrap(); register_copilot_mcp_servers(&path, &test_servers(), &Output::quiet()).unwrap(); @@ -744,7 +1146,7 @@ mod tests { let config: serde_json::Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); assert_eq!( - config["symposium"]["command"], + config["mcpServers"]["symposium"]["command"], "/usr/local/bin/cargo-agents" ); } @@ -758,7 +1160,7 @@ mod tests { let config: serde_json::Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); - assert!(config.get("symposium").is_none()); + assert!(config["mcpServers"].get("symposium").is_none()); } // -- Goose MCP (YAML) -- @@ -772,10 +1174,44 @@ mod tests { let content = fs::read_to_string(&path).unwrap(); let doc: serde_yaml_ng::Value = serde_yaml_ng::from_str(&content).unwrap(); let ext = &doc["extensions"]["symposium"]; - assert_eq!(ext["provider"].as_str().unwrap(), "mcp"); + // Goose's schema: a `type` discriminant and `cmd`, not a nested + // `provider`/`config` pair - which it rejects with `missing field type`. + assert_eq!(ext["type"].as_str().unwrap(), "stdio"); + assert_eq!(ext["name"].as_str().unwrap(), "symposium"); + assert_eq!(ext["cmd"].as_str().unwrap(), "/usr/local/bin/cargo-agents"); + assert_eq!(ext["args"][0].as_str().unwrap(), "mcp"); + assert_eq!(ext["enabled"].as_bool().unwrap(), true); + } + + /// Env vars were dropped entirely before, so a server needing them got + /// none - silently. + #[test] + fn register_goose_writes_envs_and_remote() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("config.yaml"); + register_goose_mcp_servers(&path, &env_and_remote_servers(), &Output::quiet()).unwrap(); + + let content = fs::read_to_string(&path).unwrap(); + let doc: serde_yaml_ng::Value = serde_yaml_ng::from_str(&content).unwrap(); assert_eq!( - ext["config"]["command"].as_str().unwrap(), - "/usr/local/bin/cargo-agents" + doc["extensions"]["withenv"]["envs"]["TOKEN"] + .as_str() + .unwrap(), + "abc", + "got: {content}" + ); + // Goose calls remote MCP `streamable_http` and the endpoint `uri`; + // there is no `sse` variant. + let remote = &doc["extensions"]["remote"]; + assert_eq!(remote["type"].as_str().unwrap(), "streamable_http"); + assert_eq!( + remote["uri"].as_str().unwrap(), + "http://localhost:8080/mcp", + "got: {content}" + ); + assert_eq!( + remote["headers"]["Authorization"].as_str().unwrap(), + "Bearer t" ); } @@ -813,7 +1249,8 @@ mod tests { fn register_goose_updates_stale() { let tmp = tempfile::tempdir().unwrap(); let path = tmp.path().join("config.yaml"); - // Write a config with an old binary path + // The pre-fix shape, which is what an upgrading user has on disk: it + // has to be replaced, not merged with. fs::write(&path, "extensions:\n symposium:\n provider: mcp\n config:\n command: \"/old/path\"\n args: [\"mcp\"]\n").unwrap(); register_goose_mcp_servers(&path, &test_servers(), &Output::quiet()).unwrap(); @@ -821,11 +1258,13 @@ mod tests { let content = fs::read_to_string(&path).unwrap(); let doc: serde_yaml_ng::Value = serde_yaml_ng::from_str(&content).unwrap(); assert_eq!( - doc["extensions"]["symposium"]["config"]["command"] - .as_str() - .unwrap(), + doc["extensions"]["symposium"]["cmd"].as_str().unwrap(), "/usr/local/bin/cargo-agents", ); + assert!( + doc["extensions"]["symposium"]["provider"].is_null(), + "the rejected shape must not survive: {content}" + ); // Still exactly one extension assert_eq!(doc["extensions"].as_mapping().unwrap().len(), 1); } @@ -844,11 +1283,13 @@ mod tests { // Must be valid YAML let doc: serde_yaml_ng::Value = serde_yaml_ng::from_str(&content).unwrap(); assert_eq!( - doc["extensions"]["test-server"]["config"]["command"] - .as_str() - .unwrap(), + doc["extensions"]["test-server"]["cmd"].as_str().unwrap(), "/path with spaces/symposium", ); + assert_eq!( + doc["extensions"]["test-server"]["args"][0].as_str().unwrap(), + "--flag:value", + ); } // -- OpenCode MCP -- @@ -861,11 +1302,15 @@ mod tests { let config: serde_json::Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); - assert_eq!( - config["mcp"]["symposium"]["command"], - "/usr/local/bin/cargo-agents" - ); - assert_eq!(config["mcp"]["symposium"]["args"][0], "mcp"); + // OpenCode's own shape: a `type`, and the command as one array rather + // than a command plus separate `args`. Anything else and it rejects + // the whole config file. + let entry = &config["mcp"]["symposium"]; + assert_eq!(entry["type"], "local"); + assert_eq!(entry["command"][0], "/usr/local/bin/cargo-agents"); + assert_eq!(entry["command"][1], "mcp"); + assert_eq!(entry["enabled"], true); + assert!(entry.get("args").is_none(), "got: {entry}"); } #[test] @@ -884,6 +1329,8 @@ mod tests { fn register_opencode_updates_stale() { let tmp = tempfile::tempdir().unwrap(); let path = tmp.path().join("opencode.json"); + // The stale entry is in the pre-fix shape, which is exactly what an + // upgrading user has on disk: it must be rewritten, not left alone. let stale = json!({"mcp": {"symposium": {"command": "/old/path", "args": ["mcp"]}}}); save_json(&path, &stale).unwrap(); @@ -892,7 +1339,7 @@ mod tests { let config: serde_json::Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); assert_eq!( - config["mcp"]["symposium"]["command"], + config["mcp"]["symposium"]["command"][0], "/usr/local/bin/cargo-agents" ); } diff --git a/src/agents/mod.rs b/src/agents/mod.rs index 722645ee..e34c4b95 100644 --- a/src/agents/mod.rs +++ b/src/agents/mod.rs @@ -15,6 +15,19 @@ use serde_json::json; use crate::config::Symposium; use crate::output::{Output, display_path}; +/// Which of an agent's two MCP configuration levels to write. +/// +/// Distinct from [`crate::config::HookScope`]: an agent may support one level +/// and not the other, so this is a preference, not a guarantee (see +/// [`Agent::supports_project_mcp_scope`]). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum McpScope { + /// Applies to one workspace. + Project, + /// Applies to every project this user opens. + User, +} + /// Supported AI agents. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Agent { @@ -191,188 +204,124 @@ impl Agent { // MCP server registration // ----------------------------------------------------------------------- - /// Register MCP servers in the project-level agent config. - pub fn register_project_mcp_servers( - &self, - project_root: &Path, - servers: &[sacp::schema::McpServer], - out: &Output, - ) -> Result<()> { - match self { - Agent::Claude => mcp_server_registration::register_claude_mcp_servers( - &project_root.join(".claude").join("settings.json"), - servers, - out, - ), - Agent::Codex => mcp_server_registration::register_codex_mcp_servers( - &project_root.join(".codex").join("config.toml"), - servers, - out, - ), - Agent::Copilot => mcp_server_registration::register_copilot_mcp_servers( - &project_root.join(".vscode").join("mcp.json"), - servers, - out, - ), - Agent::Gemini => mcp_server_registration::register_gemini_mcp_servers( - &project_root.join(".gemini").join("settings.json"), - servers, - out, - ), - Agent::Kiro => mcp_server_registration::register_kiro_mcp_servers( - &project_root.join(".kiro").join("settings").join("mcp.json"), - servers, - out, - ), - Agent::Goose => mcp_server_registration::register_goose_mcp_servers( - &project_root.join(".goose").join("config.yaml"), - servers, - out, - ), - Agent::OpenCode => mcp_server_registration::register_opencode_mcp_servers( - &project_root.join("opencode.json"), - servers, - out, - ), + /// Where an agent reads MCP servers from. + /// + /// Deliberately not the file its *hooks* live in: several agents keep the + /// two apart, and writing MCP entries into the hooks file means the agent + /// never sees them. + /// + /// Honors each tool's relocation env var (`CLAUDE_CONFIG_DIR`, + /// `XDG_CONFIG_HOME`), or a user who moved their config gets a file the + /// agent never reads. + pub fn mcp_config_path(&self, scope: McpScope, project_root: &Path, home: &Path) -> PathBuf { + let env_dir = |name: &str| { + std::env::var_os(name) + .filter(|v| !v.is_empty()) + .map(PathBuf::from) + }; + let xdg_config = || { + env_dir("XDG_CONFIG_HOME").unwrap_or_else(|| home.join(".config")) + }; + + match (self, scope) { + // Project MCP is `.mcp.json`; the user-level file is `.claude.json`. + // Neither is `settings.json`, which holds hooks. + (Agent::Claude, McpScope::Project) => project_root.join(".mcp.json"), + (Agent::Claude, McpScope::User) => env_dir("CLAUDE_CONFIG_DIR") + .unwrap_or_else(|| home.to_path_buf()) + .join(".claude.json"), + + (Agent::Gemini, McpScope::Project) => project_root.join(".gemini").join("settings.json"), + (Agent::Gemini, McpScope::User) => home.join(".gemini").join("settings.json"), + + (Agent::OpenCode, McpScope::Project) => project_root.join("opencode.json"), + (Agent::OpenCode, McpScope::User) => { + xdg_config().join("opencode").join("opencode.json") + } + + (Agent::Kiro, McpScope::Project) => { + project_root.join(".kiro").join("settings").join("mcp.json") + } + (Agent::Kiro, McpScope::User) => home.join(".kiro").join("settings").join("mcp.json"), + + // No project-level MCP config exists for these: the CLI reads the + // user-level file only (Codex and Copilot verified by asking them + // from a project holding an entry: both reported none). Project + // scope therefore resolves to the same user-level file rather than + // to a file nobody reads. + (Agent::Codex, _) => home.join(".codex").join("config.toml"), + (Agent::Copilot, _) => home.join(".copilot").join("mcp-config.json"), + (Agent::Goose, _) => xdg_config().join("goose").join("config.yaml"), } } - /// Register MCP servers in the global agent config. - pub fn register_global_mcp_servers( - &self, - home: &Path, - servers: &[sacp::schema::McpServer], - out: &Output, - ) -> Result<()> { - tracing::debug!(agent = %self.config_name(), count = servers.len(), "registering MCP servers"); - match self { - Agent::Claude => mcp_server_registration::register_claude_mcp_servers( - &home.join(".claude").join("settings.json"), - servers, - out, - ), - Agent::Codex => mcp_server_registration::register_codex_mcp_servers( - &home.join(".codex").join("config.toml"), - servers, - out, - ), - Agent::Copilot => mcp_server_registration::register_copilot_mcp_servers( - &home.join(".copilot").join("mcp-config.json"), - servers, - out, - ), - Agent::Gemini => mcp_server_registration::register_gemini_mcp_servers( - &home.join(".gemini").join("settings.json"), - servers, - out, - ), - Agent::Kiro => mcp_server_registration::register_kiro_mcp_servers( - &home.join(".kiro").join("settings").join("mcp.json"), - servers, - out, - ), - Agent::Goose => mcp_server_registration::register_goose_mcp_servers( - &home.join(".config").join("goose").join("config.yaml"), - servers, - out, - ), - Agent::OpenCode => mcp_server_registration::register_opencode_mcp_servers( - &home.join(".config").join("opencode").join("opencode.json"), - servers, - out, - ), - } + /// Whether this agent honors project-scoped MCP registration at all. + /// + /// `false` means [`Self::mcp_config_path`] ignores the requested scope and + /// answers with the user-level file, which callers may want to report. + pub fn supports_project_mcp_scope(&self) -> bool { + matches!( + self, + Agent::Claude | Agent::Gemini | Agent::OpenCode | Agent::Kiro + ) } - /// Remove MCP servers from the project-level agent config. - pub fn unregister_project_mcp_servers( + /// Register MCP servers in the agent's config for `scope`. + pub fn register_mcp_servers( &self, + scope: McpScope, project_root: &Path, - names: &[&str], + home: &Path, + servers: &[sacp::schema::McpServer], out: &Output, ) -> Result<()> { + tracing::debug!(agent = %self.config_name(), count = servers.len(), ?scope, "registering MCP servers"); + let path = self.mcp_config_path(scope, project_root, home); match self { - Agent::Claude => mcp_server_registration::unregister_claude_mcp_servers( - &project_root.join(".claude").join("settings.json"), - names, - out, - ), - Agent::Codex => mcp_server_registration::unregister_codex_mcp_servers( - &project_root.join(".codex").join("config.toml"), - names, - out, - ), - Agent::Copilot => mcp_server_registration::unregister_copilot_mcp_servers( - &project_root.join(".vscode").join("mcp.json"), - names, - out, - ), - Agent::Gemini => mcp_server_registration::unregister_gemini_mcp_servers( - &project_root.join(".gemini").join("settings.json"), - names, - out, - ), - Agent::Kiro => mcp_server_registration::unregister_kiro_mcp_servers( - &project_root.join(".kiro").join("settings").join("mcp.json"), - names, - out, - ), - Agent::Goose => mcp_server_registration::unregister_goose_mcp_servers( - &project_root.join(".goose").join("config.yaml"), - names, - out, - ), - Agent::OpenCode => mcp_server_registration::unregister_opencode_mcp_servers( - &project_root.join("opencode.json"), - names, - out, - ), + Agent::Claude => { + mcp_server_registration::register_claude_mcp_servers(&path, servers, out) + } + Agent::Codex => mcp_server_registration::register_codex_mcp_servers(&path, servers, out), + Agent::Copilot => { + mcp_server_registration::register_copilot_mcp_servers(&path, servers, out) + } + Agent::Gemini => { + mcp_server_registration::register_gemini_mcp_servers(&path, servers, out) + } + Agent::Kiro => mcp_server_registration::register_kiro_mcp_servers(&path, servers, out), + Agent::Goose => mcp_server_registration::register_goose_mcp_servers(&path, servers, out), + Agent::OpenCode => { + mcp_server_registration::register_opencode_mcp_servers(&path, servers, out) + } } } - /// Remove MCP servers from the global agent config. - pub fn unregister_global_mcp_servers( + /// Remove MCP servers from the agent's config for `scope`. + pub fn unregister_mcp_servers( &self, + scope: McpScope, + project_root: &Path, home: &Path, names: &[&str], out: &Output, ) -> Result<()> { + let path = self.mcp_config_path(scope, project_root, home); match self { - Agent::Claude => mcp_server_registration::unregister_claude_mcp_servers( - &home.join(".claude").join("settings.json"), - names, - out, - ), - Agent::Codex => mcp_server_registration::unregister_codex_mcp_servers( - &home.join(".codex").join("config.toml"), - names, - out, - ), - Agent::Copilot => mcp_server_registration::unregister_copilot_mcp_servers( - &home.join(".copilot").join("mcp-config.json"), - names, - out, - ), - Agent::Gemini => mcp_server_registration::unregister_gemini_mcp_servers( - &home.join(".gemini").join("settings.json"), - names, - out, - ), - Agent::Kiro => mcp_server_registration::unregister_kiro_mcp_servers( - &home.join(".kiro").join("settings").join("mcp.json"), - names, - out, - ), - Agent::Goose => mcp_server_registration::unregister_goose_mcp_servers( - &home.join(".config").join("goose").join("config.yaml"), - names, - out, - ), - Agent::OpenCode => mcp_server_registration::unregister_opencode_mcp_servers( - &home.join(".config").join("opencode").join("opencode.json"), - names, - out, - ), + Agent::Claude => { + mcp_server_registration::unregister_claude_mcp_servers(&path, names, out) + } + Agent::Codex => mcp_server_registration::unregister_codex_mcp_servers(&path, names, out), + Agent::Copilot => { + mcp_server_registration::unregister_copilot_mcp_servers(&path, names, out) + } + Agent::Gemini => { + mcp_server_registration::unregister_gemini_mcp_servers(&path, names, out) + } + Agent::Kiro => mcp_server_registration::unregister_kiro_mcp_servers(&path, names, out), + Agent::Goose => mcp_server_registration::unregister_goose_mcp_servers(&path, names, out), + Agent::OpenCode => { + mcp_server_registration::unregister_opencode_mcp_servers(&path, names, out) + } } } @@ -1055,12 +1004,27 @@ fn load_json_or_empty(path: &Path) -> Result { } } +/// Write JSON config, replacing the file atomically. +/// +/// Temp file plus rename, because some of these are live agent state (Claude +/// Code rewrites `~/.claude.json` throughout a session) and a truncating write +/// that loses a race leaves a document the agent cannot parse. +/// +/// Bounds torn reads, not lost updates. What keeps that window from mattering is +/// that registration writes only when an entry actually differs. fn save_json(path: &Path, value: &serde_json::Value) -> Result<()> { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } let contents = serde_json::to_string_pretty(value)?; - fs::write(path, contents)?; + + // Pid-suffixed so two concurrent syncs cannot share a temp path. + let temp = path.with_extension(format!("symposium-tmp-{}", std::process::id())); + fs::write(&temp, contents)?; + if let Err(e) = fs::rename(&temp, path) { + let _ = fs::remove_file(&temp); + return Err(e.into()); + } Ok(()) } @@ -1077,6 +1041,139 @@ mod tests { assert!(Agent::from_config_name("unknown").is_err()); } + /// Each path was confirmed by asking the tool itself, not read off docs: a + /// wrong one fails silently, since symposium reports success for writing a + /// file the agent never reads. + #[test] + fn mcp_config_paths_match_what_each_agent_reads() { + let project = Path::new("/project"); + let home = Path::new("/home/user"); + let cases = [ + (Agent::Claude, "/project/.mcp.json", "/home/user/.claude.json"), + ( + Agent::Gemini, + "/project/.gemini/settings.json", + "/home/user/.gemini/settings.json", + ), + ( + Agent::OpenCode, + "/project/opencode.json", + "/home/user/.config/opencode/opencode.json", + ), + ( + Agent::Kiro, + "/project/.kiro/settings/mcp.json", + "/home/user/.kiro/settings/mcp.json", + ), + ]; + for (agent, project_path, user_path) in cases { + assert_eq!( + agent.mcp_config_path(McpScope::Project, project, home), + PathBuf::from(project_path), + "{agent:?} project scope" + ); + // Env-relocatable on purpose; mutating env here would race other tests. + if !relocated_by_env(agent) { + assert_eq!( + agent.mcp_config_path(McpScope::User, project, home), + PathBuf::from(user_path), + "{agent:?} user scope" + ); + } + assert!(agent.supports_project_mcp_scope(), "{agent:?}"); + } + } + + /// Is this agent's user-scope path redirected by an env var right now? + fn relocated_by_env(agent: Agent) -> bool { + let set = |name: &str| std::env::var_os(name).is_some_and(|v| !v.is_empty()); + match agent { + Agent::Claude => set("CLAUDE_CONFIG_DIR"), + Agent::OpenCode | Agent::Goose => set("XDG_CONFIG_HOME"), + _ => false, + } + } + + #[test] + fn claude_user_path_follows_claude_config_dir() { + let path = Agent::Claude.mcp_config_path( + McpScope::User, + Path::new("/project"), + Path::new("/home/user"), + ); + match std::env::var_os("CLAUDE_CONFIG_DIR").filter(|v| !v.is_empty()) { + Some(dir) => assert_eq!(path, PathBuf::from(dir).join(".claude.json")), + None => assert_eq!(path, PathBuf::from("/home/user/.claude.json")), + } + } + + /// These CLIs read only their user-level file, so project scope resolves + /// there rather than to a project file they would ignore. + #[test] + fn agents_without_project_mcp_scope_fall_back_to_the_user_file() { + let project = Path::new("/project"); + let home = Path::new("/home/user"); + for (agent, expected) in [ + (Agent::Codex, "/home/user/.codex/config.toml"), + (Agent::Copilot, "/home/user/.copilot/mcp-config.json"), + (Agent::Goose, "/home/user/.config/goose/config.yaml"), + ] { + assert!(!agent.supports_project_mcp_scope(), "{agent:?}"); + for scope in [McpScope::Project, McpScope::User] { + let path = agent.mcp_config_path(scope, project, home); + if !relocated_by_env(agent) { + assert_eq!(path, PathBuf::from(expected), "{agent:?} {scope:?}"); + } else { + // Still the point of the test: both scopes agree. + assert_eq!( + path, + agent.mcp_config_path(McpScope::User, project, home), + "{agent:?} {scope:?}" + ); + } + } + } + } + + /// Compared per agent against *its own* hook file: a global "never + /// `.claude/settings.json`" check would pass while pointing Codex at + /// `.codex/hooks.json`. + /// + /// Gemini is the legitimate exception - `.gemini/settings.json` carries both, + /// confirmed by `gemini mcp list` reading entries written beside the hooks. + #[test] + fn mcp_config_is_never_the_agents_own_hooks_file() { + let project = Path::new("/project"); + let home = Path::new("/home/user"); + for &agent in Agent::all() { + if agent == Agent::Gemini { + continue; + } + for (scope, root) in [ + (McpScope::Project, project), + (McpScope::User, home), + ] { + let mcp = agent.mcp_config_path(scope, project, home); + for hooks in hook_paths_for(agent, root) { + assert_ne!(mcp, hooks, "{agent:?} {scope:?} writes MCP into its hooks file"); + } + } + } + } + + /// Mirrors the targets [`Agent::register_hooks`] writes. + fn hook_paths_for(agent: Agent, root: &Path) -> Vec { + match agent { + Agent::Claude => vec![root.join(".claude").join("settings.json")], + Agent::Codex => vec![root.join(".codex").join("hooks.json")], + Agent::Copilot => vec![root.join(".github").join("hooks")], + Agent::Gemini => vec![root.join(".gemini").join("settings.json")], + Agent::Kiro => vec![root.join(".kiro").join("agents")], + // Skills-only agents register no hooks at all. + Agent::Goose | Agent::OpenCode => vec![], + } + } + #[test] fn claude_project_skill_dir() { let root = Path::new("/project"); diff --git a/src/sync.rs b/src/sync.rs index 71d534c8..56a167e8 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -402,12 +402,32 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve crate::config::HookScope::Project => project_root.clone(), }; - // Register hooks and MCP servers + // MCP does not follow `hook_root`: an agent's MCP file differs from its + // hooks file, so the target is resolved per agent instead. agent .register_hooks(&hook_root, sym, out) .context("failed to register hooks")?; + let mcp_scope = match sym.config.hook_scope { + crate::config::HookScope::Global => crate::agents::McpScope::User, + crate::config::HookScope::Project => crate::agents::McpScope::Project, + }; + // Reported, not logged: the user asked for project scope and is getting a + // machine-wide entry. + if mcp_scope == crate::agents::McpScope::Project + && !agent.supports_project_mcp_scope() + && !mcp_servers.is_empty() + { + tracing::info!( + report = %crate::report::ReportEvent::Info { + message: format!( + "{} has no project-level MCP config; registering its servers at user level", + agent.display_name(), + ), + }, + ); + } agent - .register_global_mcp_servers(&hook_root, &mcp_servers, out) + .register_mcp_servers(mcp_scope, &project_root, sym.home_dir(), &mcp_servers, out) .context("failed to register MCP servers")?; for (skill_name, origin_hash, skill_source) in &to_install { @@ -531,11 +551,23 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve } } - // Unregister hooks/MCP for agents no longer configured + // Both scopes: entries may have been written under either, and a leftover + // one keeps pointing the agent at a server this workspace no longer offers. for &agent in Agent::all() { if !agent_names.contains(&agent.config_name().to_string()) { agent.unregister_hooks(sym.home_dir(), sym, out); - let _ = agent.unregister_global_mcp_servers(sym.home_dir(), &server_names, out); + for scope in [ + crate::agents::McpScope::Project, + crate::agents::McpScope::User, + ] { + let _ = agent.unregister_mcp_servers( + scope, + &project_root, + sym.home_dir(), + &server_names, + out, + ); + } } } @@ -573,17 +605,26 @@ pub async fn register_hooks(sym: &Symposium, out: &Output) -> Result<()> { let agent_names: Vec = sym.config.agents.iter().map(|a| a.name.clone()).collect(); + // `init` has no workspace in hand, so this is the user-level pass only; + // the project-level file is written by the first `sync` in a workspace. + let home = sym.home_dir(); for agent_name in &agent_names { let agent = Agent::from_config_name(agent_name)?; - agent.register_hooks(sym.home_dir(), sym, out)?; - agent.register_global_mcp_servers(sym.home_dir(), &mcp_servers, out)?; + agent.register_hooks(home, sym, out)?; + agent.register_mcp_servers(crate::agents::McpScope::User, home, home, &mcp_servers, out)?; } // Unregister hooks for agents no longer configured for &agent in Agent::all() { if !agent_names.contains(&agent.config_name().to_string()) { - agent.unregister_hooks(sym.home_dir(), sym, out); - let _ = agent.unregister_global_mcp_servers(sym.home_dir(), &server_names, out); + agent.unregister_hooks(home, sym, out); + let _ = agent.unregister_mcp_servers( + crate::agents::McpScope::User, + home, + home, + &server_names, + out, + ); } } diff --git a/tests/init_sync.rs b/tests/init_sync.rs index 21576512..4b1b39c8 100644 --- a/tests/init_sync.rs +++ b/tests/init_sync.rs @@ -426,7 +426,9 @@ async fn sync_filters_mcp_servers_by_crates() { ctx.symposium(&["sync"]).await?; let workspace_root = ctx.workspace_root.as_ref().unwrap(); - let settings_path = workspace_root.join(".claude/settings.json"); + // `.mcp.json`, not the `settings.json` that carries hooks: Claude + // does not read `mcpServers` from the latter. + let settings_path = workspace_root.join(".mcp.json"); let settings = std::fs::read_to_string(&settings_path)?; // always-server (depends-on = ["*"]) → registered @@ -728,7 +730,7 @@ async fn sync_registers_mcp_server_from_chained_crate() { ctx.symposium(&["sync"]).await?; let workspace_root = ctx.workspace_root.as_ref().unwrap(); - let settings = std::fs::read_to_string(workspace_root.join(".claude/settings.json"))?; + let settings = std::fs::read_to_string(workspace_root.join(".mcp.json"))?; assert!( settings.contains("facet-server"), "chained crate's MCP server should be registered:\n{settings}"