feat(buzz-acp): support multiple MCP servers

Signed-off-by: Luke Tornquist <tornquist@squareup.com>
This commit is contained in:
Luke Tornquist
2026-08-17 10:28:26 -04:00
parent f956e6fe06
commit 70d0c383b9
8 changed files with 255 additions and 53 deletions
+4
View File
@@ -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 (132).
# BUZZ_ACP_AGENTS=1
+23 -1
View File
@@ -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
+2 -2
View File
@@ -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,
+115 -1
View File
@@ -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<PathBuf>,
/// 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<String>,
pub mcp_command: String,
/// Additional ACP stdio MCP servers loaded from `mcp_servers_file`.
pub mcp_servers: Vec<McpServer>,
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<Vec<McpServer>, 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
+106 -49
View File
@@ -4999,60 +4999,65 @@ async fn run_models(args: ModelsArgs) -> Result<()> {
}
fn build_mcp_servers(config: &Config) -> Vec<McpServer> {
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,
@@ -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");
}
@@ -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");
}
@@ -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