diff --git a/.env.example b/.env.example index 0f7bbba6f..820d2e567 100644 --- a/.env.example +++ b/.env.example @@ -159,6 +159,10 @@ RUST_LOG=buzz_relay=debug,buzz_datastore=info,buzz_db=debug,buzz_auth=debug,buzz # Binary for an optional MCP server sidecar (e.g. buzz-dev-mcp for buzz-agent). # BUZZ_ACP_MCP_COMMAND= +# JSON array of additional ACP stdio MCP server definitions. These servers do +# not inherit Buzz credentials; declare any required env values in the file. +# BUZZ_ACP_MCP_SERVERS_FILE= + # Number of parallel agent subprocesses (1–32). # BUZZ_ACP_AGENTS=1 diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index e6164b02d..d52ed655c 100644 --- a/crates/buzz-acp/README.md +++ b/crates/buzz-acp/README.md @@ -110,13 +110,35 @@ All configuration is via environment variables (or CLI flags — every env var h | `BUZZ_RELAY_URL` | no | `ws://localhost:3000` | Relay WebSocket URL. | | `BUZZ_ACP_AGENT_COMMAND` | no | `goose` | Agent binary to spawn. | | `BUZZ_ACP_AGENT_ARGS` | no | `acp` | Agent arguments (comma-separated). | -| `BUZZ_ACP_MCP_COMMAND` | no | `""` (empty) | Path to an optional MCP server binary to provide to the agent subprocess. | +| `BUZZ_ACP_MCP_COMMAND` | no | `""` (empty) | Path to the Buzz MCP server binary. Buzz relay credentials are injected into this server. | +| `BUZZ_ACP_MCP_SERVERS_FILE` | no | — | Path to a JSON array of additional ACP stdio MCP server definitions. | | `BUZZ_ACP_IDLE_TIMEOUT` | no | `620` | Idle timeout: max seconds of silence before cancelling a turn. Resets on any agent stdout activity. | | `BUZZ_ACP_MAX_TURN_DURATION` | no | `7200` | Absolute wall-clock cap per turn (safety valve). | | `BUZZ_API_TOKEN` | no | — | API token (required if relay enforces token auth). | **Note:** `BUZZ_ACP_AGENT_ARGS` splits on commas. For args with values, use: `-c,key="value"`. +Additional MCP servers use the ACP stdio schema. Keep `BUZZ_ACP_MCP_COMMAND` set for the Buzz messaging/development MCP, then point `BUZZ_ACP_MCP_SERVERS_FILE` at a JSON file for any other servers: + +```json +[ + { + "name": "filesystem", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"], + "env": [{ "name": "LOG_LEVEL", "value": "info" }] + }, + { + "name": "search", + "command": "/usr/local/bin/search-mcp", + "args": [], + "env": [] + } +] +``` + +File-defined servers receive only the environment entries declared in the file. Buzz credentials are injected only into the legacy `BUZZ_ACP_MCP_COMMAND` server. + **Legacy env vars:** `BUZZ_ACP_PRIVATE_KEY`, `BUZZ_ACP_API_TOKEN`, and `BUZZ_ACP_TURN_TIMEOUT` (replaced by `BUZZ_ACP_IDLE_TIMEOUT`) are still accepted as fallbacks. ### Parallel Agents & Heartbeat diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index f04b8eeec..fd363a108 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -26,7 +26,7 @@ const MAX_LINE_SIZE: usize = 10_000_000; // 10 MB /// /// Corresponds to the `McpServerStdio` variant in the ACP schema. /// All four fields are **required** by the schema (`args` and `env` may be empty arrays). -#[derive(Debug, Clone, serde::Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)] pub struct McpServer { pub name: String, pub command: String, @@ -35,7 +35,7 @@ pub struct McpServer { } /// A single environment variable for an MCP server. -#[derive(Debug, Clone, serde::Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)] pub struct EnvVar { pub name: String, pub value: String, diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index f9e7bf1ed..528d2ac72 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -13,6 +13,7 @@ use thiserror::Error; use url::Url; use uuid::Uuid; +use crate::acp::McpServer; use crate::filter::SubscriptionRule; /// Default idle timeout (seconds) when neither `--idle-timeout` nor the @@ -261,6 +262,10 @@ pub struct CliArgs { #[arg(long, env = "BUZZ_ACP_MCP_COMMAND", default_value = "")] pub mcp_command: String, + /// JSON file containing additional ACP stdio MCP server definitions. + #[arg(long, env = "BUZZ_ACP_MCP_SERVERS_FILE")] + pub mcp_servers_file: Option, + /// Idle timeout: max seconds of silence before killing a turn. /// Resets on any agent stdout activity. #[arg(long, env = "BUZZ_ACP_IDLE_TIMEOUT")] @@ -507,6 +512,8 @@ pub struct Config { pub agent_command: String, pub agent_args: Vec, pub mcp_command: String, + /// Additional ACP stdio MCP servers loaded from `mcp_servers_file`. + pub mcp_servers: Vec, pub idle_timeout_secs: u64, pub max_turn_duration_secs: u64, pub agents: u32, @@ -867,6 +874,11 @@ impl Config { None }; + let mcp_servers = match &args.mcp_servers_file { + Some(path) => load_mcp_servers_file(path)?, + None => Vec::new(), + }; + if args.heartbeat_interval > 0 && args.heartbeat_interval < 10 { return Err(ConfigError::ConfigFile( "heartbeat interval must be 0 (disabled) or ≥10 seconds".into(), @@ -1077,6 +1089,7 @@ impl Config { agent_command, agent_args, mcp_command: args.mcp_command, + mcp_servers, idle_timeout_secs, max_turn_duration_secs, agents: args.agents, @@ -1143,12 +1156,13 @@ impl Config { format!(" allowed_respond_to=[{}]", modes.join(",")) }; format!( - "relay={} pubkey={} agent_cmd={} {} mcp_cmd={} idle_timeout={}s max_turn={}s agents={} heartbeat={}s subscribe={:?} dedup={:?} meh={:?} ignore_self={} context_limit={} max_turns_per_session={} presence={} typing={} memory={} model={} permission_mode={} {}{}", + "relay={} pubkey={} agent_cmd={} {} mcp_cmd={} additional_mcp_servers={} idle_timeout={}s max_turn={}s agents={} heartbeat={}s subscribe={:?} dedup={:?} meh={:?} ignore_self={} context_limit={} max_turns_per_session={} presence={} typing={} memory={} model={} permission_mode={} {}{}", self.relay_url, self.keys.public_key().to_hex(), self.agent_command, self.agent_args.join(" "), self.mcp_command, + self.mcp_servers.len(), self.idle_timeout_secs, self.max_turn_duration_secs, self.agents, @@ -1170,6 +1184,21 @@ impl Config { } } +fn load_mcp_servers_file(path: &std::path::Path) -> Result, ConfigError> { + let content = std::fs::read_to_string(path).map_err(|error| { + ConfigError::ConfigFile(format!( + "failed to read MCP servers file {}: {error}", + path.display() + )) + })?; + serde_json::from_str(&content).map_err(|error| { + ConfigError::ConfigFile(format!( + "invalid MCP servers file {}: {error}", + path.display() + )) + }) +} + #[derive(Debug, serde::Deserialize)] struct TomlConfig { #[serde(default)] @@ -1446,6 +1475,7 @@ fn rule_applies_to_channel(rule: &SubscriptionRule, channel_id: Uuid) -> bool { #[cfg(test)] mod tests { use super::*; + use crate::acp::{EnvVar, McpServer}; use crate::filter::{ChannelScope, SubscriptionRule}; use clap::{Parser, ValueEnum}; @@ -1457,6 +1487,7 @@ mod tests { agent_command: "goose".into(), agent_args: vec!["acp".into()], mcp_command: "".into(), + mcp_servers: vec![], idle_timeout_secs: DEFAULT_IDLE_TIMEOUT_SECS, max_turn_duration_secs: DEFAULT_MAX_TURN_DURATION_SECS, agents: 1, @@ -2771,6 +2802,89 @@ channels = "ALL" const TEST_PRIVATE_KEY: &str = "0000000000000000000000000000000000000000000000000000000000000001"; + #[test] + fn mcp_servers_file_loads_acp_stdio_definitions() { + let path = std::env::temp_dir().join(format!( + "buzz-acp-mcp-servers-{}.json", + uuid::Uuid::new_v4() + )); + std::fs::write( + &path, + r#"[ + { + "name": "filesystem", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"], + "env": [{"name": "LOG_LEVEL", "value": "debug"}] + }, + { + "name": "search", + "command": "/usr/local/bin/search-mcp", + "args": [], + "env": [] + } + ]"#, + ) + .expect("write MCP servers fixture"); + + let path_arg = path.display().to_string(); + let args = CliArgs::try_parse_from([ + "buzz-acp", + "--private-key", + TEST_PRIVATE_KEY, + "--mcp-servers-file", + &path_arg, + ]) + .expect("parse MCP servers file option"); + let config = Config::from_args(args).expect("load MCP servers file through Config"); + std::fs::remove_file(&path).expect("remove MCP servers fixture"); + + let servers = config.mcp_servers; + assert_eq!(servers.len(), 2); + assert_eq!(servers[0].name, "filesystem"); + assert_eq!(servers[0].command, "npx"); + assert_eq!( + servers[0].args, + [ + "-y", + "@modelcontextprotocol/server-filesystem", + "/workspace" + ] + ); + assert_eq!( + servers[0].env, + [EnvVar { + name: "LOG_LEVEL".into(), + value: "debug".into(), + }] + ); + assert_eq!( + servers[1], + McpServer { + name: "search".into(), + command: "/usr/local/bin/search-mcp".into(), + args: vec![], + env: vec![], + } + ); + } + + #[test] + fn mcp_servers_file_reports_invalid_json_with_its_path() { + let path = std::env::temp_dir().join(format!( + "buzz-acp-invalid-mcp-servers-{}.json", + uuid::Uuid::new_v4() + )); + std::fs::write(&path, "not json").expect("write invalid MCP servers fixture"); + + let error = load_mcp_servers_file(&path).expect_err("invalid JSON must fail"); + std::fs::remove_file(&path).expect("remove invalid MCP servers fixture"); + + let message = error.to_string(); + assert!(message.contains("invalid MCP servers file"), "{message}"); + assert!(message.contains(&path.display().to_string()), "{message}"); + } + #[test] fn allowed_respond_to_full_path_rejects_disallowed_mode() { // --allowed-respond-to=owner-only,allowlist + --respond-to=anyone → ConfigError diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 7fd40b83d..0f8975429 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -4999,60 +4999,65 @@ async fn run_models(args: ModelsArgs) -> Result<()> { } fn build_mcp_servers(config: &Config) -> Vec { + let mut servers = config.mcp_servers.clone(); if config.mcp_command.is_empty() { - return vec![]; + return servers; } - vec![McpServer { - name: std::path::Path::new(&config.mcp_command) - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("mcp") - .to_string(), - command: config.mcp_command.clone(), - args: vec![], - env: { - let mut env = vec![ - EnvVar { - name: "BUZZ_RELAY_URL".into(), - value: config.relay_url.clone(), - }, - EnvVar { - name: "BUZZ_PRIVATE_KEY".into(), - // bech32 encoding of a valid secret key is infallible. - // Panic here is correct: injecting a bogus secret would cause - // delayed, hard-to-diagnose agent failures downstream. - value: config - .keys - .secret_key() - .to_bech32() - .expect("secret key bech32 encoding should never fail"), - }, - ]; - // Forward BUZZ_AUTH_TAG (NIP-OA owner attestation credential) - // so the MCP server can attach it to every signed event. - if let Ok(auth_tag) = std::env::var("BUZZ_AUTH_TAG") { - if !auth_tag.is_empty() { - env.push(EnvVar { - name: "BUZZ_AUTH_TAG".into(), - value: auth_tag, - }); + servers.insert( + 0, + McpServer { + name: std::path::Path::new(&config.mcp_command) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("mcp") + .to_string(), + command: config.mcp_command.clone(), + args: vec![], + env: { + let mut env = vec![ + EnvVar { + name: "BUZZ_RELAY_URL".into(), + value: config.relay_url.clone(), + }, + EnvVar { + name: "BUZZ_PRIVATE_KEY".into(), + // bech32 encoding of a valid secret key is infallible. + // Panic here is correct: injecting a bogus secret would cause + // delayed, hard-to-diagnose agent failures downstream. + value: config + .keys + .secret_key() + .to_bech32() + .expect("secret key bech32 encoding should never fail"), + }, + ]; + // Forward BUZZ_AUTH_TAG (NIP-OA owner attestation credential) + // so the MCP server can attach it to every signed event. + if let Ok(auth_tag) = std::env::var("BUZZ_AUTH_TAG") { + if !auth_tag.is_empty() { + env.push(EnvVar { + name: "BUZZ_AUTH_TAG".into(), + value: auth_tag, + }); + } } - } - // Forward the agent's display name so dev-mcp can use it as the git - // author name instead of the raw npub. Read from the process env - // rather than Config: this is a pass-through of a contract owned - // upstream, and absent simply means dev-mcp falls back to the npub. - if let Ok(display_name) = std::env::var("BUZZ_ACP_DISPLAY_NAME") { - if !display_name.is_empty() { - env.push(EnvVar { - name: "BUZZ_ACP_DISPLAY_NAME".into(), - value: display_name, - }); + // Forward the agent's display name so dev-mcp can use it as the git + // author name instead of the raw npub. Read from the process env + // rather than Config: this is a pass-through of a contract owned + // upstream, and absent simply means dev-mcp falls back to the npub. + if let Ok(display_name) = std::env::var("BUZZ_ACP_DISPLAY_NAME") { + if !display_name.is_empty() { + env.push(EnvVar { + name: "BUZZ_ACP_DISPLAY_NAME".into(), + value: display_name, + }); + } } - } - env + env + }, }, - }] + ); + servers } #[cfg(test)] @@ -6728,6 +6733,7 @@ mod build_mcp_servers_tests { agent_command: "goose".into(), agent_args: vec!["acp".into()], mcp_command: "test-mcp-server".into(), + mcp_servers: vec![], idle_timeout_secs: config::DEFAULT_IDLE_TIMEOUT_SECS, max_turn_duration_secs: config::DEFAULT_MAX_TURN_DURATION_SECS, agents: 1, @@ -6787,6 +6793,56 @@ mod build_mcp_servers_tests { ); } + #[test] + fn configured_mcp_servers_are_appended_without_buzz_credentials() { + let mut config = test_config(); + config.mcp_servers = vec![McpServer { + name: "filesystem".into(), + command: "npx".into(), + args: vec![ + "-y".into(), + "@modelcontextprotocol/server-filesystem".into(), + ], + env: vec![EnvVar { + name: "MCP_ROOT".into(), + value: "/workspace".into(), + }], + }]; + + let servers = build_mcp_servers(&config); + + assert_eq!(servers.len(), 2); + assert_eq!(servers[0].name, "test-mcp-server"); + assert!(servers[0] + .env + .iter() + .any(|entry| entry.name == "BUZZ_PRIVATE_KEY")); + assert_eq!(servers[1], config.mcp_servers[0]); + assert!( + !servers[1] + .env + .iter() + .any(|entry| entry.name == "BUZZ_PRIVATE_KEY"), + "file-defined MCP servers must not inherit the agent's Buzz secret" + ); + } + + #[test] + fn configured_mcp_servers_work_without_legacy_mcp_command() { + let mut config = test_config(); + config.mcp_command.clear(); + config.mcp_servers = vec![McpServer { + name: "search".into(), + command: "search-mcp".into(), + args: vec![], + env: vec![], + }]; + + let servers = build_mcp_servers(&config); + + assert_eq!(servers, config.mcp_servers); + } + #[test] fn session_new_mcp_server_forwards_buzz_auth_tag() { let _guard = ENV_LOCK.lock().unwrap(); @@ -6951,6 +7007,7 @@ mod error_outcome_emission_tests { agent_command: "true".into(), agent_args: vec![], mcp_command: "test-mcp-server".into(), + mcp_servers: vec![], idle_timeout_secs: config::DEFAULT_IDLE_TIMEOUT_SECS, max_turn_duration_secs: config::DEFAULT_MAX_TURN_DURATION_SECS, agents: 1, diff --git a/crates/buzz-backend-kubernetes/src/env.rs b/crates/buzz-backend-kubernetes/src/env.rs index badff621e..e9dd7b4d5 100644 --- a/crates/buzz-backend-kubernetes/src/env.rs +++ b/crates/buzz-backend-kubernetes/src/env.rs @@ -31,6 +31,7 @@ const AUTHORITATIVE_KEYS: &[&str] = &[ "BUZZ_ACP_RESPOND_TO", "BUZZ_ACP_RESPOND_TO_ALLOWLIST", "BUZZ_ACP_MCP_COMMAND", + "BUZZ_ACP_MCP_SERVERS_FILE", "BUZZ_ACP_EXIT_AFTER_INACTIVITY", START_NONCE_KEY, ]; @@ -355,6 +356,7 @@ mod tests { "BUZZ_ACP_AGENT_OWNER": "cafe", "BUZZ_ACP_AGENT_COMMAND": "/bin/sh", "BUZZ_ACP_MCP_COMMAND": "/bin/sh", + "BUZZ_ACP_MCP_SERVERS_FILE": "/tmp/untrusted.json", "BUZZ_ACP_EXIT_AFTER_INACTIVITY": "0", }, "owner_pubkey": "beef" @@ -368,6 +370,7 @@ mod tests { assert_eq!(env["BUZZ_ACP_AGENT_OWNER"], "beef"); assert_eq!(env["BUZZ_ACP_AGENT_COMMAND"], "goose"); assert_eq!(env["BUZZ_ACP_MCP_COMMAND"], "buzz-dev-mcp"); + assert!(!env.contains_key("BUZZ_ACP_MCP_SERVERS_FILE")); assert_eq!(env["BUZZ_ACP_EXIT_AFTER_INACTIVITY"], "7200"); assert_eq!(env["BUZZ_MANAGED_AGENT_START_NONCE"], "gen0001"); } diff --git a/desktop/src-tauri/src/managed_agents/env_vars/tests.rs b/desktop/src-tauri/src/managed_agents/env_vars/tests.rs index f3de11ad2..dc8a6c1c6 100644 --- a/desktop/src-tauri/src/managed_agents/env_vars/tests.rs +++ b/desktop/src-tauri/src/managed_agents/env_vars/tests.rs @@ -183,6 +183,7 @@ fn reserved_keys_include_code_execution_surface() { "BUZZ_ACP_AGENT_COMMAND", "BUZZ_ACP_AGENT_ARGS", "BUZZ_ACP_MCP_COMMAND", + "BUZZ_ACP_MCP_SERVERS_FILE", ] { assert!(is_reserved_env_key(key), "{key} should be reserved"); } diff --git a/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs b/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs index afaaa2b4e..b38304550 100644 --- a/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs +++ b/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs @@ -41,6 +41,7 @@ pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[ "BUZZ_ACP_AGENT_COMMAND", "BUZZ_ACP_AGENT_ARGS", "BUZZ_ACP_MCP_COMMAND", + "BUZZ_ACP_MCP_SERVERS_FILE", // Control-plane parallelism: the Desktop resolves the effective // worker-pool size (applying any per-harness cap) and writes it into // launch.policy_env. A user-supplied BUZZ_ACP_AGENTS would bypass the