From 377b35ada7aa814fe564d90341f9a128b1f7a589 Mon Sep 17 00:00:00 2001 From: Piers Rollinson Date: Tue, 28 Jul 2026 02:23:51 -0700 Subject: [PATCH] Add Patina remote MCP connections Co-authored-by: Piers Rollinson Signed-off-by: Piers Rollinson --- Cargo.lock | 1 + crates/buzz-acp/Cargo.toml | 1 + crates/buzz-acp/src/acp.rs | 199 ++++- crates/buzz-acp/src/config.rs | 59 ++ crates/buzz-acp/src/lib.rs | 172 ++-- crates/buzz-acp/src/pool.rs | 140 ++- desktop/playwright.config.ts | 1 + desktop/src-tauri/src/commands/agents.rs | 7 + desktop/src-tauri/src/lib.rs | 12 +- desktop/src-tauri/src/managed_agents/mod.rs | 6 + .../src/managed_agents/remote_mcp.rs | 820 ++++++++++++++++++ .../src-tauri/src/managed_agents/runtime.rs | 32 +- desktop/src/features/agents/AGENTS.md | 10 + .../features/agents/ui/AgentConfigPanel.tsx | 1 + .../features/agents/ui/McpServersSection.tsx | 373 +++++++- desktop/src/shared/api/tauri.ts | 47 + desktop/src/shared/api/types.ts | 13 + desktop/src/testing/e2eBridge.ts | 95 ++ desktop/tests/e2e/patina-mcp.spec.ts | 75 ++ 19 files changed, 1951 insertions(+), 113 deletions(-) create mode 100644 desktop/src-tauri/src/managed_agents/remote_mcp.rs create mode 100644 desktop/tests/e2e/patina-mcp.spec.ts diff --git a/Cargo.lock b/Cargo.lock index 3b60dc4579..2f6fd1c3fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -791,6 +791,7 @@ dependencies = [ "tracing-subscriber", "url", "uuid", + "zeroize", ] [[package]] diff --git a/crates/buzz-acp/Cargo.toml b/crates/buzz-acp/Cargo.toml index d047849806..a45fdf53bf 100644 --- a/crates/buzz-acp/Cargo.toml +++ b/crates/buzz-acp/Cargo.toml @@ -61,6 +61,7 @@ tracing-subscriber = { workspace = true } # Error handling thiserror = { workspace = true } anyhow = { workspace = true } +zeroize = { workspace = true } # CLI clap = { version = "4", features = ["derive", "env"] } diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index c0147baf1b..fb068342c2 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -22,16 +22,87 @@ const MAX_LINE_SIZE: usize = 10_000_000; // 10 MB /// An MCP server configuration passed to `session/new`. /// -/// Corresponds to the `McpServerStdio` variant in the ACP schema. -/// All four fields are **required** by the schema (`args` and `env` may be empty arrays). +/// ACP models stdio servers as an untagged object and HTTP servers as an +/// object tagged with `type: "http"`, so the outer enum is untagged. #[derive(Debug, Clone, serde::Serialize)] -pub struct McpServer { +#[serde(untagged)] +pub enum McpServer { + /// A child-process MCP server. + Stdio(McpServerStdio), + /// A remote Streamable HTTP MCP server. + Http(McpServerHttp), +} + +impl McpServer { + /// Return the display name shared by both transport variants. + pub fn name(&self) -> &str { + match self { + Self::Stdio(server) => &server.name, + Self::Http(server) => &server.name, + } + } + + /// Whether this server requires ACP HTTP MCP support. + pub fn is_http(&self) -> bool { + matches!(self, Self::Http(_)) + } + + /// Return the stdio payload when this is a child-process server. + #[cfg(test)] + pub fn as_stdio(&self) -> Option<&McpServerStdio> { + match self { + Self::Stdio(server) => Some(server), + Self::Http(_) => None, + } + } +} + +/// ACP stdio MCP server. +/// +/// All four fields are required by the schema (`args` and `env` may be empty +/// arrays). +#[derive(Debug, Clone, serde::Serialize)] +pub struct McpServerStdio { pub name: String, pub command: String, pub args: Vec, pub env: Vec, } +/// ACP Streamable HTTP MCP server. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct McpServerHttp { + #[serde(rename = "type")] + pub transport: HttpTransport, + pub name: String, + pub url: String, + pub headers: Vec, +} + +/// The only remote transport ACP currently defines. +#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum HttpTransport { + Http, +} + +/// A single HTTP header for a remote MCP server. +#[derive(Clone, serde::Serialize, serde::Deserialize)] +pub struct HttpHeader { + pub name: String, + pub value: String, +} + +impl std::fmt::Debug for HttpHeader { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("HttpHeader") + .field("name", &self.name) + .field("value", &"[REDACTED]") + .finish() + } +} + /// A single environment variable for an MCP server. #[derive(Debug, Clone, serde::Serialize)] pub struct EnvVar { @@ -121,6 +192,42 @@ fn agent_error_from_json(error: &serde_json::Value) -> AcpError { AcpError::AgentError { code, message } } +/// Return a copy safe for logs and observer events. +/// +/// `session/new` is the only ACP request that carries MCP credentials. Both +/// stdio `env` values and HTTP `headers` values are replaced while names and +/// the surrounding wire shape remain available for diagnosis. +fn redact_acp_wire_message(message: &serde_json::Value) -> serde_json::Value { + let mut redacted = message.clone(); + if redacted.get("method").and_then(serde_json::Value::as_str) != Some("session/new") { + return redacted; + } + + let Some(servers) = redacted + .pointer_mut("/params/mcpServers") + .and_then(serde_json::Value::as_array_mut) + else { + return redacted; + }; + + for server in servers { + for key in ["env", "headers"] { + let Some(entries) = server + .get_mut(key) + .and_then(serde_json::Value::as_array_mut) + else { + continue; + }; + for entry in entries { + if let Some(value) = entry.get_mut("value") { + *value = serde_json::Value::String("[REDACTED]".to_string()); + } + } + } + } + redacted +} + fn build_initialize_params() -> serde_json::Value { serde_json::json!({ "protocolVersion": 2, @@ -958,17 +1065,21 @@ impl AcpClient { /// (e.g., it's stuck or dead), the write would otherwise block forever. async fn write_ndjson(&mut self, value: &serde_json::Value) -> Result<(), AcpError> { const WRITE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); - let line = serde_json::to_string(value)?; - tokio::time::timeout(WRITE_TIMEOUT, async { + use zeroize::Zeroize; + + let mut line = serde_json::to_string(value)?; + let write_result = tokio::time::timeout(WRITE_TIMEOUT, async { self.stdin.write_all(line.as_bytes()).await?; self.stdin.write_all(b"\n").await?; self.stdin.flush().await?; Ok::<(), std::io::Error>(()) }) - .await - .map_err(|_| AcpError::WriteTimeout(WRITE_TIMEOUT))? - .map_err(AcpError::Io)?; - self.observe("acp_write", value.clone()); + .await; + line.zeroize(); + write_result + .map_err(|_| AcpError::WriteTimeout(WRITE_TIMEOUT))? + .map_err(AcpError::Io)?; + self.observe("acp_write", redact_acp_wire_message(value)); Ok(()) } @@ -999,7 +1110,8 @@ impl AcpClient { "params": params, }); - tracing::debug!(target: "acp::wire", "→ {}", &serde_json::to_string(&msg).unwrap_or_default()); + let logged = redact_acp_wire_message(&msg); + tracing::debug!(target: "acp::wire", "→ {}", &serde_json::to_string(&logged).unwrap_or_default()); // Wrap write + read in a single timeout so a hung agent can't block forever. // We cannot use an async block that borrows `self` mutably across two awaits @@ -2196,7 +2308,7 @@ mod tests { #[test] fn session_new_mcp_server_has_required_fields() { // Schema requires name, command, args, env — all present, args/env may be empty. - let server = McpServer { + let server = McpServer::Stdio(McpServerStdio { name: "test-mcp".into(), command: "/usr/local/bin/test-mcp-server".into(), args: vec![], @@ -2210,7 +2322,7 @@ mod tests { value: "nsec1abc".into(), }, ], - }; + }); let serialized = serde_json::to_value(&server).unwrap(); assert_eq!(serialized["name"].as_str(), Some("test-mcp")); assert_eq!( @@ -2227,6 +2339,69 @@ mod tests { ); } + #[test] + fn session_new_http_mcp_server_matches_acp_wire_shape() { + let server = McpServer::Http(McpServerHttp { + transport: HttpTransport::Http, + name: "patina".into(), + url: "https://patina.so/mcp/acme".into(), + headers: vec![HttpHeader { + name: "Authorization".into(), + value: "Bearer pk_secret".into(), + }], + }); + + let serialized = serde_json::to_value(&server).unwrap(); + assert_eq!(serialized["type"], "http"); + assert_eq!(serialized["name"], "patina"); + assert_eq!(serialized["url"], "https://patina.so/mcp/acme"); + assert_eq!(serialized["headers"][0]["name"], "Authorization"); + assert_eq!(serialized["headers"][0]["value"], "Bearer pk_secret"); + } + + #[test] + fn session_new_wire_observation_redacts_mcp_secrets() { + let message = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "session/new", + "params": { + "cwd": "/tmp", + "mcpServers": [ + { + "name": "buzz-mcp", + "command": "buzz", + "args": [], + "env": [ + {"name": "BUZZ_PRIVATE_KEY", "value": "nsec_secret"} + ] + }, + { + "type": "http", + "name": "patina", + "url": "https://patina.so/mcp/acme", + "headers": [ + {"name": "Authorization", "value": "Bearer pk_secret"} + ] + } + ] + } + }); + + let redacted = redact_acp_wire_message(&message); + let rendered = redacted.to_string(); + assert!(!rendered.contains("nsec_secret")); + assert!(!rendered.contains("pk_secret")); + assert_eq!( + redacted["params"]["mcpServers"][0]["env"][0]["value"], + "[REDACTED]" + ); + assert_eq!( + redacted["params"]["mcpServers"][1]["headers"][0]["value"], + "[REDACTED]" + ); + } + #[test] fn session_prompt_request_format() { let prompt_text = "[Buzz @mention]\nChannel: test\nFrom: npub1...\nMessage: hello"; diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index 9a1b74c276..14220fe376 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -12,9 +12,23 @@ use nostr::Keys; use thiserror::Error; use url::Url; use uuid::Uuid; +use zeroize::Zeroize; +use crate::acp::McpServerHttp; use crate::filter::SubscriptionRule; +const MAX_REMOTE_MCP_STDIN_BYTES: usize = 64 * 1024; + +fn parse_remote_mcp_servers(input: &str) -> Result, ConfigError> { + if input.len() > MAX_REMOTE_MCP_STDIN_BYTES { + return Err(ConfigError::ConfigFile( + "remote MCP stdin payload exceeds 64 KiB".into(), + )); + } + serde_json::from_str(input) + .map_err(|error| ConfigError::ConfigFile(format!("remote MCP stdin JSON: {error}"))) +} + /// Default idle timeout (seconds) when neither `--idle-timeout` nor the /// deprecated `--turn-timeout` is set. /// @@ -261,6 +275,13 @@ pub struct CliArgs { #[arg(long, env = "BUZZ_ACP_MCP_COMMAND", default_value = "")] pub mcp_command: String, + /// Read a JSON array of remote HTTP MCP servers from stdin at startup. + /// + /// Desktop uses this one-shot private pipe so bearer headers never enter + /// process arguments or environment variables. + #[arg(long, env = "BUZZ_ACP_REMOTE_MCP_STDIN", default_value_t = false)] + pub remote_mcp_stdin: bool, + /// Idle timeout: max seconds of silence before killing a turn. /// Resets on any agent stdout activity. #[arg(long, env = "BUZZ_ACP_IDLE_TIMEOUT")] @@ -495,6 +516,7 @@ pub struct Config { pub agent_command: String, pub agent_args: Vec, pub mcp_command: String, + pub remote_mcp_servers: Vec, pub idle_timeout_secs: u64, pub max_turn_duration_secs: u64, pub agents: u32, @@ -1029,12 +1051,27 @@ impl Config { validate_multiple_event_handling(args.multiple_event_handling, args.dedup)?; + let remote_mcp_servers = if args.remote_mcp_stdin { + let mut input = String::new(); + let mut reader = std::io::Read::take( + std::io::stdin().lock(), + (MAX_REMOTE_MCP_STDIN_BYTES + 1) as u64, + ); + std::io::Read::read_to_string(&mut reader, &mut input)?; + let parsed = parse_remote_mcp_servers(&input); + input.zeroize(); + parsed? + } else { + Vec::new() + }; + let config = Config { keys, relay_url: args.relay_url, agent_command, agent_args, mcp_command: args.mcp_command, + remote_mcp_servers, idle_timeout_secs, max_turn_duration_secs, agents: args.agents, @@ -1413,6 +1450,7 @@ mod tests { agent_command: "goose".into(), agent_args: vec!["acp".into()], mcp_command: "".into(), + remote_mcp_servers: vec![], idle_timeout_secs: DEFAULT_IDLE_TIMEOUT_SECS, max_turn_duration_secs: DEFAULT_MAX_TURN_DURATION_SECS, agents: 1, @@ -1451,6 +1489,27 @@ mod tests { } } + #[test] + fn remote_mcp_stdin_parser_accepts_http_server_shape() { + let servers = parse_remote_mcp_servers( + r#"[{"type":"http","name":"patina","url":"https://patina.so/mcp/acme","headers":[{"name":"Authorization","value":"Bearer pk_secret"}]}]"#, + ) + .expect("valid ACP HTTP MCP payload should parse"); + + assert_eq!(servers.len(), 1); + assert_eq!(servers[0].name, "patina"); + assert_eq!(servers[0].headers[0].name, "Authorization"); + } + + #[test] + fn remote_mcp_stdin_parser_rejects_invalid_and_oversized_payloads() { + assert!(parse_remote_mcp_servers("not-json").is_err()); + let oversized = " ".repeat(MAX_REMOTE_MCP_STDIN_BYTES + 1); + let error = parse_remote_mcp_servers(&oversized) + .expect_err("oversized remote MCP payload must fail closed"); + assert!(error.to_string().contains("64 KiB")); + } + fn make_rule( name: &str, channels: ChannelScope, diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index b11d96d8f7..4de05917c3 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -18,7 +18,7 @@ use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::Arc; use std::time::Duration; -use acp::{AcpClient, EnvVar, McpServer}; +use acp::{AcpClient, EnvVar, McpServer, McpServerStdio}; use anyhow::Result; use buzz_core::kind::{ KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_STREAM_MESSAGE, @@ -1140,10 +1140,9 @@ fn any_respawn_in_flight(crash_history: &[SlotCircuit]) -> bool { /// Result of a background respawn task. struct RespawnResult { index: usize, - /// Tuple: (initialized client, protocol version, supports_goose_steer). - /// The third element is always `true` — the supervisor uses - /// try-and-tolerate for the steer extension. - result: Result<(AcpClient, u32, String)>, + /// Tuple: (initialized client, protocol version, agent name, + /// supports HTTP MCP). + result: Result<(AcpClient, u32, String, bool)>, } /// Outcome of a non-cancelling steer attempt, forwarded from a per-attempt @@ -1187,7 +1186,7 @@ impl RespawnGuard { /// Send the result and disarm the guard. Uses `try_send` (sync) so there /// is no await boundary between marking `sent` and actually enqueueing — /// cancellation cannot slip between the two. - fn send(mut self, result: Result<(AcpClient, u32, String)>) { + fn send(mut self, result: Result<(AcpClient, u32, String, bool)>) { // Invariant: try_send succeeds because the channel capacity equals the // slot count, and respawn_in_flight guarantees at most one outstanding // result per slot. If this ever fails, the channel sizing or the @@ -1786,7 +1785,7 @@ async fn tokio_main() -> Result<()> { while let Ok(rr) = respawn_rx.try_recv() { crash_history[rr.index].respawn_in_flight = false; match rr.result { - Ok((acp, protocol_version, agent_name)) => { + Ok((acp, protocol_version, agent_name, http_mcp_supported)) => { let agent = OwnedAgent { index: rr.index, acp, @@ -1797,6 +1796,7 @@ async fn tokio_main() -> Result<()> { agent_name, goose_system_prompt_supported: None, protocol_version, + http_mcp_supported, }; pool.return_agent(agent); tracing::info!(agent = rr.index, "respawn complete"); @@ -2666,7 +2666,7 @@ async fn tokio_main() -> Result<()> { // Drain any respawn results that completed before the abort. Explicitly // shut down returned agents instead of relying on AcpClient::Drop. while let Ok(rr) = respawn_rx.try_recv() { - if let Ok((mut acp, _, _)) = rr.result { + if let Ok((mut acp, _, _, _)) = rr.result { acp.shutdown().await; tracing::debug!(agent = rr.index, "reaped respawned agent on shutdown"); } @@ -3793,6 +3793,7 @@ async fn initialize_agent_pool( }), ); let agent_name = normalized_agent_name(&init_result); + let http_mcp_supported = pool::supports_http_mcp(&init_result); agent_slots.push(Some(OwnedAgent { index: i, acp, @@ -3803,6 +3804,7 @@ async fn initialize_agent_pool( agent_name, goose_system_prompt_supported: None, protocol_version, + http_mcp_supported, })); } Ok(Err(e)) => { @@ -3853,7 +3855,7 @@ async fn spawn_and_init( has_generated_codex_config: bool, agent_index: usize, observer: Option, -) -> Result<(AcpClient, u32, String)> { +) -> Result<(AcpClient, u32, String, bool)> { let mut acp = AcpClient::spawn(command, args, extra_env, has_generated_codex_config) .await .map_err(|e| anyhow::anyhow!("failed to spawn agent: {e}"))?; @@ -3871,7 +3873,8 @@ async fn spawn_and_init( }), ); let agent_name = normalized_agent_name(&init_result); - Ok((acp, protocol_version, agent_name)) + let http_mcp_supported = pool::supports_http_mcp(&init_result); + Ok((acp, protocol_version, agent_name, http_mcp_supported)) } Err(e) => { // Explicitly shut down the spawned child to prevent zombie/leak. @@ -4140,60 +4143,68 @@ async fn run_models(args: ModelsArgs) -> Result<()> { } fn build_mcp_servers(config: &Config) -> Vec { - if config.mcp_command.is_empty() { - return vec![]; - } - 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, - }); + let mut servers = Vec::new(); + if !config.mcp_command.is_empty() { + servers.push(McpServer::Stdio(McpServerStdio { + 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.extend( + config + .remote_mcp_servers + .iter() + .cloned() + .map(McpServer::Http), + ); + servers } #[cfg(test)] @@ -4963,6 +4974,7 @@ mod build_mcp_servers_tests { agent_command: "goose".into(), agent_args: vec!["acp".into()], mcp_command: "test-mcp-server".into(), + remote_mcp_servers: vec![], idle_timeout_secs: config::DEFAULT_IDLE_TIMEOUT_SECS, max_turn_duration_secs: config::DEFAULT_MAX_TURN_DURATION_SECS, agents: 1, @@ -5006,7 +5018,7 @@ mod build_mcp_servers_tests { let config = test_config(); let servers = build_mcp_servers(&config); assert_eq!(servers.len(), 1); - let server = &servers[0]; + let server = servers[0].as_stdio().expect("catalog MCP is stdio"); assert_eq!(server.name, "test-mcp-server"); let names: Vec<&str> = server.env.iter().map(|e| e.name.as_str()).collect(); @@ -5028,7 +5040,7 @@ mod build_mcp_servers_tests { let servers = build_mcp_servers(&config); std::env::remove_var("BUZZ_AUTH_TAG"); - let server = &servers[0]; + let server = servers[0].as_stdio().expect("catalog MCP is stdio"); let auth_tag_env = server.env.iter().find(|e| e.name == "BUZZ_AUTH_TAG"); assert!( auth_tag_env.is_some(), @@ -5045,7 +5057,7 @@ mod build_mcp_servers_tests { let servers = build_mcp_servers(&config); std::env::remove_var("BUZZ_AUTH_TAG"); - let server = &servers[0]; + let server = servers[0].as_stdio().expect("catalog MCP is stdio"); let has_auth_tag = server.env.iter().any(|e| e.name == "BUZZ_AUTH_TAG"); assert!(!has_auth_tag, "empty BUZZ_AUTH_TAG should not be forwarded"); } @@ -5059,6 +5071,8 @@ mod build_mcp_servers_tests { std::env::remove_var("BUZZ_ACP_DISPLAY_NAME"); let entry = servers[0] + .as_stdio() + .expect("catalog MCP is stdio") .env .iter() .find(|e| e.name == "BUZZ_ACP_DISPLAY_NAME"); @@ -5080,6 +5094,8 @@ mod build_mcp_servers_tests { // falls back to the npub when the key is missing or blank. assert!( !servers[0] + .as_stdio() + .expect("catalog MCP is stdio") .env .iter() .any(|e| e.name == "BUZZ_ACP_DISPLAY_NAME"), @@ -5097,6 +5113,8 @@ mod build_mcp_servers_tests { assert!( !servers[0] + .as_stdio() + .expect("catalog MCP is stdio") .env .iter() .any(|e| e.name == "BUZZ_ACP_DISPLAY_NAME"), @@ -5115,13 +5133,30 @@ mod build_mcp_servers_tests { ); } + #[test] + fn remote_mcp_server_is_appended_after_catalog_stdio_server() { + let mut config = test_config(); + config.remote_mcp_servers.push(acp::McpServerHttp { + transport: acp::HttpTransport::Http, + name: "patina".into(), + url: "https://patina.so/mcp/acme".into(), + headers: vec![], + }); + + let servers = build_mcp_servers(&config); + assert_eq!(servers.len(), 2); + assert_eq!(servers[0].name(), "test-mcp-server"); + assert_eq!(servers[1].name(), "patina"); + assert!(servers[1].is_http()); + } + #[test] fn absolute_path_mcp_command_uses_file_stem_as_name() { let mut config = test_config(); config.mcp_command = "/opt/bin/my-mcp-server".into(); let servers = build_mcp_servers(&config); assert_eq!(servers.len(), 1); - assert_eq!(servers[0].name, "my-mcp-server"); + assert_eq!(servers[0].name(), "my-mcp-server"); } #[test] @@ -5143,7 +5178,8 @@ mod build_mcp_servers_tests { let servers = build_mcp_servers(&config); assert_eq!(servers.len(), 1); assert_eq!( - servers[0].name, "mcp", + servers[0].name(), + "mcp", "Path::new(\".\").file_stem() is None — should fall back to \"mcp\"" ); } @@ -5184,6 +5220,7 @@ mod error_outcome_emission_tests { agent_command: "true".into(), agent_args: vec![], mcp_command: "test-mcp-server".into(), + remote_mcp_servers: vec![], idle_timeout_secs: config::DEFAULT_IDLE_TIMEOUT_SECS, max_turn_duration_secs: config::DEFAULT_MAX_TURN_DURATION_SECS, agents: 1, @@ -5256,6 +5293,7 @@ mod error_outcome_emission_tests { // Error branches under test never read this; 1 is the legacy // non-systemPrompt path, the simplest valid value. protocol_version: 1, + http_mcp_supported: false, } } diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 0c51fe954f..fb053f195d 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -168,6 +168,36 @@ pub struct OwnedAgent { pub goose_system_prompt_supported: Option, /// Protocol version reported by the agent in its initialize response. pub protocol_version: u32, + /// Whether the adapter advertises client-provided Streamable HTTP MCP + /// servers in its initialize capabilities. + pub http_mcp_supported: bool, +} + +pub(crate) fn supports_http_mcp(initialize_result: &serde_json::Value) -> bool { + initialize_result + .get("agentCapabilities") + .and_then(|capabilities| capabilities.get("mcpCapabilities")) + .and_then(|capabilities| capabilities.get("http")) + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) +} + +fn validate_mcp_transport_capabilities( + http_mcp_supported: bool, + runtime_name: &str, + servers: &[McpServer], +) -> Result<(), AcpError> { + if http_mcp_supported { + return Ok(()); + } + if let Some(server) = servers.iter().find(|server| server.is_http()) { + return Err(AcpError::Protocol(format!( + "MCP server '{}' requires HTTP MCP support, but runtime '{}' did not advertise agentCapabilities.mcpCapabilities.http", + server.name(), + runtime_name + ))); + } + Ok(()) } fn has_system_prompt_support( @@ -854,6 +884,12 @@ async fn create_session_and_apply_model( agent_canvas: Option<&str>, channel_name: Option<&str>, ) -> Result { + validate_mcp_transport_capabilities( + agent.http_mcp_supported, + &agent.agent_name, + &ctx.mcp_servers, + )?; + // Build base_prompt + system_prompt + agent core + canvas metadata into a // single prompt. Standard protocol-v2 agents receive it in `session/new`; // Goose receives it through the custom request below. Legacy agents receive @@ -863,9 +899,12 @@ async fn create_session_and_apply_model( let is_goose = agent.agent_name == "goose"; let combined_system_prompt = with_canvas( with_core( - with_team( - framed_system_prompt(&ctx.cwd, ctx.base_prompt, ctx.system_prompt.as_deref()), - ctx.team_instructions.as_deref(), + with_remote_mcp_guidance( + with_team( + framed_system_prompt(&ctx.cwd, ctx.base_prompt, ctx.system_prompt.as_deref()), + ctx.team_instructions.as_deref(), + ), + &ctx.mcp_servers, ), agent_core, ), @@ -1244,6 +1283,26 @@ fn with_team(prompt: Option, instructions: Option<&str>) -> Option, mcp_servers: &[McpServer]) -> Option { + if !mcp_servers + .iter() + .any(|server| server.is_http() && server.name().eq_ignore_ascii_case("patina")) + { + return prompt; + } + + let guidance = "[Patina]\nUse Patina as the approved company context source: orient once, \ + bundle context before substantial work, cite the Patina artifact paths you relied on, \ + and state plainly when Patina has no relevant context."; + match prompt { + Some(prompt) => Some(format!("{prompt}\n\n{guidance}")), + None => Some(guidance.to_string()), + } +} + /// Append the agent's core memory section onto the framed system prompt. /// /// Core already carries its own `[Agent Memory — core]` header from @@ -3709,9 +3768,49 @@ async fn clear_reactions(rest: crate::relay::RestClient, event_ids: Vec) #[cfg(test)] mod tests { use super::*; + use crate::acp::{HttpTransport, McpServerHttp}; use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; use serde_json::json; + #[test] + fn initialize_capability_allows_http_mcp() { + let init = json!({ + "agentCapabilities": { + "mcpCapabilities": { + "http": true + } + } + }); + let servers = vec![McpServer::Http(McpServerHttp { + transport: HttpTransport::Http, + name: "patina".into(), + url: "https://patina.so/mcp/acme".into(), + headers: vec![], + })]; + + assert!( + validate_mcp_transport_capabilities(supports_http_mcp(&init), "codex", &servers) + .is_ok() + ); + } + + #[test] + fn initialize_without_http_capability_rejects_remote_server() { + let init = json!({ "agentCapabilities": { "mcpCapabilities": {} } }); + let servers = vec![McpServer::Http(McpServerHttp { + transport: HttpTransport::Http, + name: "patina".into(), + url: "https://patina.so/mcp/acme".into(), + headers: vec![], + })]; + + let error = + validate_mcp_transport_capabilities(supports_http_mcp(&init), "buzz-agent", &servers) + .expect_err("HTTP-incapable runtimes must fail before session/new"); + assert!(error.to_string().contains("patina")); + assert!(error.to_string().contains("HTTP MCP")); + } + // These pin the initial_message dispatch path (run_prompt_task, ~line 855): // a legacy agent WITH a base_prompt must get [Base] prepended to the user // message. This is the exact regression that shipped in the round-2 bug. @@ -5060,6 +5159,7 @@ mod tests { agent_name: "unknown".into(), goose_system_prompt_supported: None, protocol_version: 2, + http_mcp_supported: false, }; // Simulate dispatch: install a steer receiver (normally done by @@ -5118,6 +5218,7 @@ mod tests { agent_name: "unknown".into(), goose_system_prompt_supported: None, protocol_version: 2, + http_mcp_supported: false, }; // Simulate a completed turn: `steer_rx` was consumed by the read loop @@ -5416,6 +5517,39 @@ mod tests { assert!(result.is_none()); } + #[test] + fn patina_remote_mcp_adds_default_usage_guidance() { + let servers = vec![McpServer::Http(crate::acp::McpServerHttp { + transport: crate::acp::HttpTransport::Http, + name: "patina".into(), + url: "https://patina.so/mcp/acme".into(), + headers: vec![], + })]; + + let result = with_remote_mcp_guidance(Some("base content".into()), &servers) + .expect("Patina guidance should produce a prompt"); + + assert!(result.starts_with("base content\n\n[Patina]\n")); + assert!(result.contains("bundle context before substantial work")); + assert!(result.contains("cite the Patina artifact paths")); + assert!(result.contains("when Patina has no relevant context")); + } + + #[test] + fn non_patina_mcp_leaves_prompt_unchanged() { + let servers = vec![McpServer::Stdio(crate::acp::McpServerStdio { + name: "buzz-mcp".into(), + command: "buzz-dev-mcp".into(), + args: vec![], + env: vec![], + })]; + + assert_eq!( + with_remote_mcp_guidance(Some("base content".into()), &servers), + Some("base content".into()) + ); + } + // ── canvas_sections cache invalidation ─────────────────────────────────── #[test] diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 40dd5dd1b1..e9da83d68a 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -43,6 +43,7 @@ export default defineConfig({ "**/active-turn-resilience.spec.ts", "**/profile-active-turn.spec.ts", "**/config-bridge-screenshots.spec.ts", + "**/patina-mcp.spec.ts", "**/observer-feed-screenshots.spec.ts", "**/core-memory-screenshots.spec.ts", "**/activity-scope-label-screenshots.spec.ts", diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 16272ac28b..92394a9c5e 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -1338,6 +1338,13 @@ pub async fn delete_managed_agent( save_managed_agents(&app, &records)?; // Remove the agent's nsec from the keyring after the record is gone. crate::managed_agents::delete_agent_key(&pubkey); + if let Err(error) = + crate::managed_agents::delete_remote_mcp_connections_for_agent(&app, &pubkey) + { + eprintln!( + "buzz-desktop: failed to remove remote MCP connections for agent {pubkey}: {error}" + ); + } // Tombstone-after-validation: only reached past the deployed-remote // guard above and a confirmed removal — never orphan a live remote // deployment's relay record. Inside the lock, before the block closes diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 5346791ccf..5f163ef886 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -47,9 +47,10 @@ use huddle::{ set_voice_input_mode, speak_agent_message, start_huddle, start_stt_pipeline, }; use managed_agents::{ - backfill_persona_snapshots, ensure_nest, list_managed_agent_runtimes, - put_managed_agent_runtime_lifecycle, reconcile_managed_agent_runtimes, - restart_managed_agent_runtime, start_managed_agent_runtime, stop_managed_agent_runtime, + backfill_persona_snapshots, connect_patina, disconnect_remote_mcp, ensure_nest, + list_managed_agent_runtimes, list_remote_mcp_connections, put_managed_agent_runtime_lifecycle, + reconcile_managed_agent_runtimes, restart_managed_agent_runtime, set_remote_mcp_enabled, + start_managed_agent_runtime, stop_managed_agent_runtime, test_patina_connection, try_regenerate_nest, }; #[cfg(not(feature = "mesh-llm"))] @@ -792,6 +793,11 @@ pub fn run() { list_relay_agents, list_managed_agents, list_managed_agent_runtimes, + list_remote_mcp_connections, + connect_patina, + test_patina_connection, + set_remote_mcp_enabled, + disconnect_remote_mcp, start_managed_agent_runtime, stop_managed_agent_runtime, restart_managed_agent_runtime, diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index b0e86f8edb..7f54549fef 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -23,6 +23,7 @@ mod process_lifecycle; pub(crate) mod readiness; pub(crate) mod reconcile; mod relay_mesh; +mod remote_mcp; mod repos; mod restore; pub mod retention; @@ -65,6 +66,11 @@ pub(crate) use readiness::{ AgentReadiness, Requirement, }; pub use relay_mesh::*; +pub(crate) use remote_mcp::delete_remote_mcp_connections_for_agent; +pub use remote_mcp::{ + connect_patina, disconnect_remote_mcp, list_remote_mcp_connections, set_remote_mcp_enabled, + test_patina_connection, +}; pub use repos::{ effective_repos_dir, ensure_repos_symlink, resolve_repos_at_boot, validate_repos_dir, write_persisted_repos_dir, diff --git a/desktop/src-tauri/src/managed_agents/remote_mcp.rs b/desktop/src-tauri/src/managed_agents/remote_mcp.rs new file mode 100644 index 0000000000..baea5bc1b6 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/remote_mcp.rs @@ -0,0 +1,820 @@ +//! Per-agent remote MCP connections. +//! +//! Metadata is persisted separately from managed-agent definitions so remote +//! credentials never enter the relay-synced agent event or the React model. +//! Credential values live only in the OS keyring and are materialized into the +//! harness through a one-shot stdin pipe at process startup. + +use std::collections::BTreeSet; +use std::fs; + +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Manager}; +use zeroize::{Zeroize, Zeroizing}; + +use crate::app_state::AppState; +use crate::secret_store::SecretStore; + +use super::{ + atomic_write_json_restricted, load_managed_agents, managed_agent_runtime_keys, + managed_agents_base_dir, restart_managed_agent_runtime, +}; + +const PATINA_CONNECTION_ID: &str = "patina"; +const PATINA_ORIGIN: &str = "https://patina.so"; +const REMOTE_MCP_STORE_FILE: &str = "remote-mcp.json"; +const REQUIRED_PATINA_TOOLS: [&str; 4] = [ + "artifact_index", + "artifact_search", + "artifact_bundle", + "artifact_read", +]; +// Pinned to Patina's legacy, registry, and semantic command surfaces. The +// connection probe fails closed if any command tool is visible to the key. +const PATINA_WRITE_TOOLS: &[&str] = &[ + "annotate", + "feedback", + "record_session_read", + "cancel_session", + "revert_session", + "revert_session_manual", + "revert_window", + "report_bug", + "extract_memory", + "evaluate_schedule_triggers", + "record_metric_outcome", + "retract_node", + "link_record", + "apply_lens", + "fire_trigger", + "generate_outcome_candidates", + "open_session", + "record_action_decision", + "record_action_outcome", + "submit_session", + "define_record_type", + "create_record", + "update_record", + "artifact_put", + "artifact_revise", + "artifact_revert", + "artifact_move", + "artifact_archive", + "source_record", + "source_reread", + "source_archive", + "work_propose_promotion", + "knowledge_ratify", + "knowledge_request_changes", + "knowledge_reject", + "knowledge_flag_contradiction", + "decision_propose", + "decision_reverse", + "decision_reaffirm", + "decision_ratify", + "decision_request_changes", + "decision_reject", + "decision_run_record", + "decision_run_materialize", + "production_submit", + "production_request_changes", + "production_approve", + "production_prepare_publication", + "production_confirm_publication", + "production_confirm_retraction", + "citation_attach", + "citation_detach", + "external_ref_attach", + "external_ref_refresh", + "external_ref_detach", + "session_open", + "session_record_read", + "session_submit", + "session_comment_add", + "session_rebase", + "session_cancel", + "session_revert", +]; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct StoredRemoteMcpConnection { + id: String, + agent_pubkey: String, + provider: String, + name: String, + url: String, + workspace_slug: String, + workspace_name: Option, + principal_name: Option, + enabled: bool, + secret_ref: String, + created_at: String, + updated_at: String, + last_verified_at: String, +} + +/// Credential-free connection details returned to the webview. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RemoteMcpConnectionSummary { + pub id: String, + pub provider: String, + pub name: String, + pub url: String, + pub workspace_slug: String, + pub workspace_name: Option, + pub principal_name: Option, + pub enabled: bool, + pub status: String, + pub last_verified_at: String, +} + +impl From<&StoredRemoteMcpConnection> for RemoteMcpConnectionSummary { + fn from(connection: &StoredRemoteMcpConnection) -> Self { + Self { + id: connection.id.clone(), + provider: connection.provider.clone(), + name: connection.name.clone(), + url: connection.url.clone(), + workspace_slug: connection.workspace_slug.clone(), + workspace_name: connection.workspace_name.clone(), + principal_name: connection.principal_name.clone(), + enabled: connection.enabled, + status: if connection.enabled { + "connected".into() + } else { + "disabled".into() + }, + last_verified_at: connection.last_verified_at.clone(), + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PatinaKeyInfo { + workspace_name: Option, + workspace_slug: Option, + owner_type: String, + agent: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PatinaAgentInfo { + name: String, +} + +#[derive(Debug, Clone)] +struct PatinaProbeResult { + workspace_name: Option, + principal_name: Option, +} + +/// Secret-bearing ACP server payload. This type is serialized only into the +/// private stdin pipe consumed by `buzz-acp`. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RemoteMcpWireServer { + #[serde(rename = "type")] + transport: &'static str, + name: String, + url: String, + headers: Vec, +} + +impl RemoteMcpWireServer { + /// Clear credential-bearing header values once the startup payload has + /// been serialized into its independently zeroized byte buffer. + pub(crate) fn zeroize_secrets(&mut self) { + use zeroize::Zeroize; + + for header in &mut self.headers { + header.value.zeroize(); + } + } +} + +#[derive(Serialize)] +pub(crate) struct RemoteMcpWireHeader { + name: String, + value: String, +} + +pub(crate) struct RemoteMcpStartupPayload(Option>); + +impl RemoteMcpStartupPayload { + /// Consume the startup payload through the child pipe, then clear its + /// bytes whether the write succeeds or fails. + pub(crate) fn write_to(mut self, child: &mut std::process::Child) -> Result<(), String> { + let Some(mut payload) = self.0.take() else { + return Ok(()); + }; + use std::io::Write; + + let result = child + .stdin + .take() + .ok_or_else(|| "remote MCP startup pipe was not created".to_string()) + .and_then(|mut stdin| { + stdin + .write_all(&payload) + .and_then(|()| stdin.flush()) + .map_err(|error| format!("failed to send remote MCP startup payload: {error}")) + }); + payload.zeroize(); + result + } +} + +impl Drop for RemoteMcpStartupPayload { + fn drop(&mut self) { + if let Some(payload) = &mut self.0 { + payload.zeroize(); + } + } +} + +fn store_path(app: &AppHandle) -> Result { + managed_agents_base_dir(app).map(|directory| directory.join(REMOTE_MCP_STORE_FILE)) +} + +fn load_store(app: &AppHandle) -> Result, String> { + let path = store_path(app)?; + if !path.exists() { + return Ok(Vec::new()); + } + let content = fs::read_to_string(&path) + .map_err(|error| format!("failed to read remote MCP store: {error}"))?; + serde_json::from_str(&content) + .map_err(|error| format!("failed to parse remote MCP store: {error}")) +} + +fn save_store(app: &AppHandle, connections: &[StoredRemoteMcpConnection]) -> Result<(), String> { + let path = store_path(app)?; + let payload = serde_json::to_vec_pretty(connections) + .map_err(|error| format!("failed to serialize remote MCP store: {error}"))?; + atomic_write_json_restricted(&path, &payload) +} + +fn integration_secret_store() -> &'static SecretStore { + SecretStore::shared(crate::app_state::keyring_service()) +} + +fn secret_ref(agent_pubkey: &str, connection_id: &str) -> String { + format!( + "integration:mcp:{}:{}", + agent_pubkey.to_ascii_lowercase(), + connection_id + ) +} + +fn validate_workspace_slug(input: &str) -> Result { + let slug = input.trim().to_ascii_lowercase(); + let valid = !slug.is_empty() + && slug.len() <= 80 + && slug + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + && !slug.starts_with('-') + && !slug.ends_with('-'); + if !valid { + return Err( + "Patina workspace slug must use lowercase letters, numbers, and internal hyphens" + .into(), + ); + } + Ok(slug) +} + +fn validate_api_key(input: &str) -> Result<&str, String> { + let key = input.trim(); + if !key.starts_with("pk_") || key.len() < 12 { + return Err("Patina agent key must begin with pk_".into()); + } + Ok(key) +} + +fn patina_url(origin: &str, workspace_slug: &str) -> String { + format!("{origin}/mcp/{workspace_slug}") +} + +fn parse_mcp_response(body: &str) -> Result { + if let Ok(value) = serde_json::from_str(body) { + return Ok(value); + } + for line in body.lines() { + if let Some(data) = line.strip_prefix("data:") { + let data = data.trim(); + if data.is_empty() || data == "[DONE]" { + continue; + } + if let Ok(value) = serde_json::from_str(data) { + return Ok(value); + } + } + } + Err("Patina MCP returned neither JSON nor a JSON SSE data frame".into()) +} + +fn verify_patina_tools(response: &serde_json::Value) -> Result<(), String> { + let tools = response + .pointer("/result/tools") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| "Patina tools/list response is missing result.tools".to_string())?; + let names: BTreeSet<&str> = tools + .iter() + .filter_map(|tool| tool.get("name").and_then(serde_json::Value::as_str)) + .collect(); + let missing: Vec<&str> = REQUIRED_PATINA_TOOLS + .iter() + .copied() + .filter(|name| !names.contains(name)) + .collect(); + if !missing.is_empty() { + return Err(format!( + "Patina is missing required read tools: {}", + missing.join(", ") + )); + } + let writes: Vec<&str> = PATINA_WRITE_TOOLS + .iter() + .copied() + .filter(|name| names.contains(name)) + .collect(); + if !writes.is_empty() { + return Err(format!( + "Patina key is not viewer-scoped; write tools were exposed: {}", + writes.join(", ") + )); + } + Ok(()) +} + +async fn mcp_call( + client: &reqwest::Client, + endpoint: &str, + key: &str, + body: serde_json::Value, +) -> Result { + let response = client + .post(endpoint) + .bearer_auth(key) + .header("Accept", "application/json, text/event-stream") + .header("Content-Type", "application/json") + .json(&body) + .send() + .await + .map_err(|error| format!("Patina MCP unreachable: {error}"))?; + let status = response.status(); + let body = response + .text() + .await + .map_err(|error| format!("failed to read Patina MCP response: {error}"))?; + if !status.is_success() { + return Err(match status.as_u16() { + 401 | 403 => "Patina key is unauthorized, expired, or revoked".into(), + _ => format!("Patina MCP returned {status}"), + }); + } + parse_mcp_response(&body) +} + +async fn probe_patina_at( + client: &reqwest::Client, + origin: &str, + workspace_slug: &str, + key: &str, +) -> Result { + let key_info_response = client + .get(format!("{origin}/api/v1/auth/key-info")) + .bearer_auth(key) + .send() + .await + .map_err(|error| format!("Patina identity endpoint unreachable: {error}"))?; + let key_info_status = key_info_response.status(); + if !key_info_status.is_success() { + return Err(match key_info_status.as_u16() { + 401 | 403 => "Patina key is unauthorized, expired, or revoked".into(), + _ => format!("Patina identity endpoint returned {key_info_status}"), + }); + } + let key_info: PatinaKeyInfo = key_info_response + .json() + .await + .map_err(|error| format!("invalid Patina key-info response: {error}"))?; + if key_info.workspace_slug.as_deref() != Some(workspace_slug) { + return Err(format!( + "Patina key belongs to workspace '{}', not '{}'", + key_info.workspace_slug.as_deref().unwrap_or("unknown"), + workspace_slug + )); + } + if key_info.owner_type != "agent" { + return Err("Connect Patina requires an agent-owned viewer key".into()); + } + + let endpoint = patina_url(origin, workspace_slug); + let initialize = mcp_call( + client, + &endpoint, + key, + serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-11-25", + "capabilities": {}, + "clientInfo": { "name": "buzz", "version": env!("CARGO_PKG_VERSION") } + } + }), + ) + .await?; + if initialize + .pointer("/result/serverInfo/name") + .and_then(serde_json::Value::as_str) + != Some("patina") + { + return Err("MCP endpoint did not identify itself as Patina".into()); + } + let tools = mcp_call( + client, + &endpoint, + key, + serde_json::json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list", + "params": {} + }), + ) + .await?; + verify_patina_tools(&tools)?; + + Ok(PatinaProbeResult { + workspace_name: key_info.workspace_name, + principal_name: key_info.agent.map(|agent| agent.name), + }) +} + +async fn restart_running_agent_pairs(app: &AppHandle, pubkey: &str) -> Result<(), String> { + let keys = { + let state = app.state::(); + let runtimes = state + .managed_agent_processes + .lock() + .map_err(|error| error.to_string())?; + managed_agent_runtime_keys(&runtimes, pubkey) + }; + for key in keys { + let app = app.clone(); + tokio::task::spawn_blocking(move || { + restart_managed_agent_runtime(key.pubkey, key.relay_url, app) + }) + .await + .map_err(|error| format!("agent restart task failed: {error}"))??; + } + Ok(()) +} + +/// Materialize enabled remote servers for the harness startup pipe. +pub(crate) fn materialize_remote_mcp_servers( + app: &AppHandle, + agent_pubkey: &str, +) -> Result, String> { + let connections = load_store(app)?; + connections + .into_iter() + .filter(|connection| { + connection.enabled && connection.agent_pubkey.eq_ignore_ascii_case(agent_pubkey) + }) + .map(|connection| { + let token = Zeroizing::new( + integration_secret_store() + .load(&connection.secret_ref)? + .ok_or_else(|| { + format!( + "remote MCP credential '{}' is missing from the OS keyring", + connection.name + ) + })?, + ); + Ok(RemoteMcpWireServer { + transport: "http", + name: connection.name, + url: connection.url, + headers: vec![RemoteMcpWireHeader { + name: "Authorization".into(), + value: format!("Bearer {}", token.as_str()), + }], + }) + }) + .collect() +} + +/// Resolve remote secrets, configure the private child pipe, and retain only a +/// zeroizable serialized payload for the post-spawn write. +pub(crate) fn configure_remote_mcp_startup( + command: &mut std::process::Command, + app: &AppHandle, + agent_pubkey: &str, +) -> Result { + let mut servers = materialize_remote_mcp_servers(app, agent_pubkey)?; + let payload = if servers.is_empty() { + None + } else { + let result = serde_json::to_vec(&servers) + .map_err(|error| format!("failed to serialize remote MCP startup payload: {error}")); + for server in &mut servers { + server.zeroize_secrets(); + } + Some(result?) + }; + command.stdin(if payload.is_some() { + std::process::Stdio::piped() + } else { + std::process::Stdio::null() + }); + if payload.is_some() { + command.env("BUZZ_ACP_REMOTE_MCP_STDIN", "true"); + } else { + command.env_remove("BUZZ_ACP_REMOTE_MCP_STDIN"); + } + Ok(RemoteMcpStartupPayload(payload)) +} + +#[tauri::command] +pub fn list_remote_mcp_connections( + pubkey: String, + app: AppHandle, +) -> Result, String> { + Ok(load_store(&app)? + .iter() + .filter(|connection| connection.agent_pubkey.eq_ignore_ascii_case(&pubkey)) + .map(RemoteMcpConnectionSummary::from) + .collect()) +} + +/// Remove persisted remote-MCP metadata before deleting its keyring entries. +/// +/// Agent deletion uses this best-effort cleanup so an integration credential +/// cannot remain addressable after its owning managed agent is gone. +pub(crate) fn delete_remote_mcp_connections_for_agent( + app: &AppHandle, + agent_pubkey: &str, +) -> Result<(), String> { + let mut connections = load_store(app)?; + let removed_secret_refs: Vec = connections + .iter() + .filter(|connection| connection.agent_pubkey.eq_ignore_ascii_case(agent_pubkey)) + .map(|connection| connection.secret_ref.clone()) + .collect(); + if removed_secret_refs.is_empty() { + return Ok(()); + } + connections.retain(|connection| !connection.agent_pubkey.eq_ignore_ascii_case(agent_pubkey)); + save_store(app, &connections)?; + + let failures: Vec = removed_secret_refs + .into_iter() + .filter_map(|secret_ref| integration_secret_store().delete(&secret_ref).err()) + .collect(); + if failures.is_empty() { + Ok(()) + } else { + Err(format!( + "remote MCP metadata was removed, but {} OS-keyring credential(s) could not be deleted", + failures.len() + )) + } +} + +#[tauri::command] +pub async fn connect_patina( + pubkey: String, + workspace_slug: String, + api_key: String, + app: AppHandle, +) -> Result { + if !load_managed_agents(&app)? + .iter() + .any(|agent| agent.pubkey.eq_ignore_ascii_case(&pubkey)) + { + return Err(format!("agent {pubkey} not found")); + } + let workspace_slug = validate_workspace_slug(&workspace_slug)?; + let api_key = Zeroizing::new(api_key); + let api_key = validate_api_key(&api_key)?; + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(20)) + .build() + .map_err(|error| format!("failed to build Patina client: {error}"))?; + let probe = probe_patina_at(&client, PATINA_ORIGIN, &workspace_slug, api_key).await?; + + let mut connections = load_store(&app)?; + let secret_ref = secret_ref(&pubkey, PATINA_CONNECTION_ID); + let previous_secret = integration_secret_store() + .load(&secret_ref)? + .map(Zeroizing::new); + integration_secret_store().store(&secret_ref, api_key)?; + + let now = crate::util::now_iso(); + let connection = StoredRemoteMcpConnection { + id: PATINA_CONNECTION_ID.into(), + agent_pubkey: pubkey.clone(), + provider: "patina".into(), + name: "patina".into(), + url: patina_url(PATINA_ORIGIN, &workspace_slug), + workspace_slug, + workspace_name: probe.workspace_name, + principal_name: probe.principal_name, + enabled: true, + secret_ref: secret_ref.clone(), + created_at: connections + .iter() + .find(|connection| { + connection.agent_pubkey.eq_ignore_ascii_case(&pubkey) + && connection.id == PATINA_CONNECTION_ID + }) + .map(|connection| connection.created_at.clone()) + .unwrap_or_else(|| now.clone()), + updated_at: now.clone(), + last_verified_at: now, + }; + connections.retain(|existing| { + !(existing.agent_pubkey.eq_ignore_ascii_case(&pubkey) + && existing.id == PATINA_CONNECTION_ID) + }); + connections.push(connection.clone()); + if let Err(error) = save_store(&app, &connections) { + match previous_secret { + Some(previous) => { + let _ = integration_secret_store().store(&secret_ref, previous.as_str()); + } + None => { + let _ = integration_secret_store().delete(&secret_ref); + } + } + return Err(error); + } + restart_running_agent_pairs(&app, &pubkey).await?; + Ok(RemoteMcpConnectionSummary::from(&connection)) +} + +#[tauri::command] +pub async fn test_patina_connection( + pubkey: String, + app: AppHandle, +) -> Result { + let mut connections = load_store(&app)?; + let connection = connections + .iter_mut() + .find(|connection| { + connection.agent_pubkey.eq_ignore_ascii_case(&pubkey) + && connection.id == PATINA_CONNECTION_ID + }) + .ok_or_else(|| "Patina is not connected for this agent".to_string())?; + let api_key = Zeroizing::new( + integration_secret_store() + .load(&connection.secret_ref)? + .ok_or_else(|| "Patina credential is missing from the OS keyring".to_string())?, + ); + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(20)) + .build() + .map_err(|error| format!("failed to build Patina client: {error}"))?; + let probe = + probe_patina_at(&client, PATINA_ORIGIN, &connection.workspace_slug, &api_key).await?; + connection.workspace_name = probe.workspace_name; + connection.principal_name = probe.principal_name; + connection.last_verified_at = crate::util::now_iso(); + connection.updated_at = connection.last_verified_at.clone(); + let summary = RemoteMcpConnectionSummary::from(&*connection); + save_store(&app, &connections)?; + Ok(summary) +} + +#[tauri::command] +pub async fn set_remote_mcp_enabled( + pubkey: String, + connection_id: String, + enabled: bool, + app: AppHandle, +) -> Result { + let mut connections = load_store(&app)?; + let connection = connections + .iter_mut() + .find(|connection| { + connection.agent_pubkey.eq_ignore_ascii_case(&pubkey) && connection.id == connection_id + }) + .ok_or_else(|| format!("remote MCP connection {connection_id} not found"))?; + if enabled + && integration_secret_store() + .load(&connection.secret_ref)? + .is_none() + { + return Err("remote MCP credential is missing from the OS keyring".into()); + } + connection.enabled = enabled; + connection.updated_at = crate::util::now_iso(); + let summary = RemoteMcpConnectionSummary::from(&*connection); + save_store(&app, &connections)?; + restart_running_agent_pairs(&app, &pubkey).await?; + Ok(summary) +} + +#[tauri::command] +pub async fn disconnect_remote_mcp( + pubkey: String, + connection_id: String, + app: AppHandle, +) -> Result<(), String> { + let mut connections = load_store(&app)?; + let index = connections + .iter() + .position(|connection| { + connection.agent_pubkey.eq_ignore_ascii_case(&pubkey) && connection.id == connection_id + }) + .ok_or_else(|| format!("remote MCP connection {connection_id} not found"))?; + let removed = connections.remove(index); + let secret = integration_secret_store() + .load(&removed.secret_ref)? + .map(Zeroizing::new); + integration_secret_store().delete(&removed.secret_ref)?; + if let Err(error) = save_store(&app, &connections) { + if let Some(secret) = secret { + let _ = integration_secret_store().store(&removed.secret_ref, secret.as_str()); + } + return Err(error); + } + restart_running_agent_pairs(&app, &pubkey).await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn workspace_slug_is_normalized_and_restricted() { + assert_eq!(validate_workspace_slug(" Acme-Team ").unwrap(), "acme-team"); + assert!(validate_workspace_slug("https://patina.so/mcp/acme").is_err()); + assert!(validate_workspace_slug("../acme").is_err()); + } + + #[test] + fn persisted_connection_never_contains_credential_value() { + let connection = StoredRemoteMcpConnection { + id: "patina".into(), + agent_pubkey: "abc".into(), + provider: "patina".into(), + name: "patina".into(), + url: "https://patina.so/mcp/acme".into(), + workspace_slug: "acme".into(), + workspace_name: Some("Acme".into()), + principal_name: Some("Buzz Viewer".into()), + enabled: true, + secret_ref: "integration:mcp:abc:patina".into(), + created_at: "2026-07-28T00:00:00Z".into(), + updated_at: "2026-07-28T00:00:00Z".into(), + last_verified_at: "2026-07-28T00:00:00Z".into(), + }; + let json = serde_json::to_string(&connection).unwrap(); + assert!(!json.contains("pk_secret")); + assert!(!json.contains("apiKey")); + assert!(json.contains("secretRef")); + } + + #[test] + fn streamable_http_sse_response_is_parsed() { + let response = "event: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"serverInfo\":{\"name\":\"patina\"}}}\n\n"; + let parsed = parse_mcp_response(response).unwrap(); + assert_eq!(parsed["result"]["serverInfo"]["name"], "patina"); + } + + #[test] + fn viewer_tool_contract_rejects_missing_or_write_tools() { + let valid = serde_json::json!({ + "result": { + "tools": REQUIRED_PATINA_TOOLS.map(|name| serde_json::json!({"name": name})) + } + }); + assert!(verify_patina_tools(&valid).is_ok()); + + let missing = serde_json::json!({ + "result": { "tools": [{"name": "artifact_index"}] } + }); + assert!(verify_patina_tools(&missing).is_err()); + + let mut names = REQUIRED_PATINA_TOOLS.to_vec(); + names.extend(["create_record", "fire_trigger", "artifact_put"]); + let write = serde_json::json!({ + "result": { + "tools": names.into_iter().map(|name| serde_json::json!({"name": name})).collect::>() + } + }); + assert!(verify_patina_tools(&write).is_err()); + } +} diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index f3b4cb67fd..794e3a52f9 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -7,9 +7,10 @@ use super::agent_env::build_buzz_agent_provider_defaults; use crate::{ managed_agents::{ append_log_marker, known_acp_runtime, login_shell_path, managed_agent_log_path, - missing_command_message, normalize_agent_args, open_log_file, resolve_command, - spawn_key_refusal, KnownAcpRuntime, ManagedAgentPairRuntime, ManagedAgentRecord, - ManagedAgentRuntimeKey, ManagedAgentSummary, + missing_command_message, normalize_agent_args, open_log_file, + remote_mcp::configure_remote_mcp_startup, resolve_command, spawn_key_refusal, + KnownAcpRuntime, ManagedAgentPairRuntime, ManagedAgentRecord, ManagedAgentRuntimeKey, + ManagedAgentSummary, }, util::now_iso, }; @@ -545,15 +546,11 @@ pub fn spawn_agent_child( .map(|p| p.display().to_string()) .unwrap_or_else(|| effective_command.clone()); - // The caller supplies the explicit canonical pair relay. This is the only - // relay this child may connect to, regardless of the record/workspace default. + // The canonical pair relay is the only relay this child may connect to. let effective_relay_url = runtime_key.relay_url.clone(); - // Augment PATH for DMG launches so child processes can find: - // - bundled CLI via ~/.local/bin symlink - // - nvm-managed node/npm (nvm initializes only in interactive shells) - // - bundled sidecars (buzz, buzz-acp, etc.) via exe parent (Contents/MacOS/) - // - runtimes (node, python, etc.) via login shell PATH + // Augment DMG PATH with the bundled CLI/sidecars, nvm binaries, and + // runtimes inherited from the login shell. let nvm_bin = dirs::home_dir() .as_deref() .and_then(super::find_nvm_default_bin); @@ -567,10 +564,10 @@ pub fn spawn_agent_child( ); let mut command = std::process::Command::new(&resolved_acp_command); + let remote_mcp_startup = configure_remote_mcp_startup(&mut command, app, &record.pubkey)?; if let Some(home) = super::default_agent_workdir() { command.current_dir(home); } - command.stdin(std::process::Stdio::null()); command.stdout(std::process::Stdio::from(stdout)); command.stderr(std::process::Stdio::from(stderr)); if let Some(ref path) = augmented_path { @@ -904,18 +901,19 @@ pub fn spawn_agent_child( command.creation_flags(CREATE_NO_WINDOW); } - let child = command.spawn().map_err(|error| { + let mut child = command.spawn().map_err(|error| { format!( "failed to spawn `{}` for agent {}: {error}", resolved_acp_command.display(), record.name ) })?; - - // Stamp the effective spawn config so the summary builder can flag - // needs_restart when disk state drifts from what this process runs. - // `effective_relay_url` is already resolved, and resolution is idempotent, - // so it serves as the workspace-relay input here. + if let Err(error) = remote_mcp_startup.write_to(&mut child) { + let _ = terminate_process(child.id()); + let _ = child.wait(); + return Err(error); + } + // Stamp the effective spawn config so summaries can flag drift. let spawn_config_hash = super::spawn_hash::spawn_config_hash( record, &personas, diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 06e6c02acb..08005ac271 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -106,6 +106,14 @@ with a TypeScript lookup table or an id comparison in a component. Edit. In Edit, selecting Custom command keeps its required command field beside the harness picker rather than hiding it in Advanced. +10. **Remote MCP credentials remain Rust-owned and runtime capability is + negotiated.** The frontend may render credential-free connection metadata, + but must never hydrate a saved bearer token. Desktop persists only a keyring + reference, resolves the credential at spawn, and sends the one-shot HTTP MCP + payload to `buzz-acp` through its private stdin pipe. Compatibility comes + from the ACP runtime's `initialize.agentCapabilities.mcpCapabilities.http` + response; do not hard-code a frontend runtime allowlist. A missing credential + or absent HTTP capability fails closed before `session/new`. ## The tests that enforce this @@ -124,6 +132,8 @@ with a TypeScript lookup table or an id comparison in a component. acceptance coverage for readiness, failure states, defaults, navigation, successful-empty vs failed optional-model discovery, and persistence races. - Rust: `runtime_metadata_env_vars` tests pin spawn-time key application. +- `desktop/tests/e2e/patina-mcp.spec.ts` — remote-MCP connect lifecycle and the + invariant that reconnect opens with an empty credential input. ## Keep this file true diff --git a/desktop/src/features/agents/ui/AgentConfigPanel.tsx b/desktop/src/features/agents/ui/AgentConfigPanel.tsx index 3236b4d808..3d5d5d527c 100644 --- a/desktop/src/features/agents/ui/AgentConfigPanel.tsx +++ b/desktop/src/features/agents/ui/AgentConfigPanel.tsx @@ -425,6 +425,7 @@ export function AgentConfigPanel({ diff --git a/desktop/src/features/agents/ui/McpServersSection.tsx b/desktop/src/features/agents/ui/McpServersSection.tsx index 3e6c93ca5d..0abd2a72ef 100644 --- a/desktop/src/features/agents/ui/McpServersSection.tsx +++ b/desktop/src/features/agents/ui/McpServersSection.tsx @@ -1,26 +1,145 @@ -import type * as React from "react"; -import { CheckCircle2, CircleSlash } from "lucide-react"; -import type { ExtensionEntry } from "@/shared/api/types"; +import * as React from "react"; +import { + CheckCircle2, + CircleSlash, + Cloud, + Loader2, + Plus, + RefreshCw, + Unplug, +} from "lucide-react"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import type { ExtensionEntry, RemoteMcpConnection } from "@/shared/api/types"; +import { + connectPatina, + disconnectRemoteMcp, + listRemoteMcpConnections, + setRemoteMcpEnabled, + testPatinaConnection, +} from "@/shared/api/tauri"; import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; +import { Input } from "@/shared/ui/input"; type McpServersSectionProps = { extensions: ExtensionEntry[]; + pubkey: string; runtimeId: string | null; variant?: "compact" | "profile"; buzzAgentSlot?: React.ReactNode; }; +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + export function McpServersSection({ buzzAgentSlot, extensions, + pubkey, runtimeId, variant = "compact", }: McpServersSectionProps) { const isBuzzAgent = runtimeId === "buzz-agent"; + const [connections, setConnections] = React.useState( + [], + ); + const [loading, setLoading] = React.useState(true); + const [busyAction, setBusyAction] = React.useState(null); + const [showPatinaForm, setShowPatinaForm] = React.useState(false); + const [workspaceSlug, setWorkspaceSlug] = React.useState(""); + const [apiKey, setApiKey] = React.useState(""); + const [error, setError] = React.useState(null); + + const patina = connections.find( + (connection) => connection.provider === "patina", + ); + + React.useEffect(() => { + let cancelled = false; + setLoading(true); + setConnections([]); + setError(null); + setBusyAction(null); + setShowPatinaForm(false); + setWorkspaceSlug(""); + setApiKey(""); + listRemoteMcpConnections(pubkey) + .then((result) => { + if (!cancelled) { + setConnections(result); + } + }) + .catch((loadError) => { + if (!cancelled) { + setError(errorMessage(loadError)); + } + }) + .finally(() => { + if (!cancelled) { + setLoading(false); + } + }); + return () => { + cancelled = true; + }; + }, [pubkey]); - if (!isBuzzAgent && extensions.length === 0) { - return null; - } + const replaceConnection = React.useCallback((next: RemoteMcpConnection) => { + setConnections((current) => [ + ...current.filter((connection) => connection.id !== next.id), + next, + ]); + }, []); + + const submitPatina = async (event: React.FormEvent) => { + event.preventDefault(); + setError(null); + setBusyAction("connect"); + try { + const connection = await connectPatina({ + pubkey, + workspaceSlug, + apiKey, + }); + replaceConnection(connection); + setApiKey(""); + setShowPatinaForm(false); + } catch (connectError) { + setError(errorMessage(connectError)); + } finally { + setApiKey(""); + setBusyAction(null); + } + }; + + const runConnectionAction = async ( + action: "test" | "toggle" | "disconnect", + ) => { + if (!patina) { + return; + } + setError(null); + setBusyAction(action); + try { + if (action === "test") { + replaceConnection(await testPatinaConnection(pubkey)); + } else if (action === "toggle") { + replaceConnection( + await setRemoteMcpEnabled(pubkey, patina.id, !patina.enabled), + ); + } else { + await disconnectRemoteMcp(pubkey, patina.id); + setConnections((current) => + current.filter((connection) => connection.id !== patina.id), + ); + } + } catch (actionError) { + setError(errorMessage(actionError)); + } finally { + setBusyAction(null); + } + }; return (
-

- MCP Servers -

+

MCP Servers

+ {!patina && !showPatinaForm ? ( + + ) : null} +
{isBuzzAgent && buzzAgentSlot ? buzzAgentSlot : null} + {showPatinaForm ? ( +
+
+

+ Connect Patina +

+

+ Use an expiring, viewer-scoped Patina agent key. Buzz verifies + workspace scope and the read-only tool set before saving it to + your OS keyring. +

+
+ + +

+ HTTP MCP compatibility is verified when the agent starts. V1 is + designed for Codex ACP. +

+ +
+ + +
+
+ ) : null} + + {error ? ( +

+ {error} +

+ ) : null} + + {loading ? ( +

+ + Loading connections… +

+ ) : patina && !showPatinaForm ? ( + { + setWorkspaceSlug(patina.workspaceSlug); + setApiKey(""); + setError(null); + setShowPatinaForm(true); + }} + variant={variant} + /> + ) : null} + {extensions.length > 0 ? (
{extensions.map((extension) => ( @@ -50,7 +313,7 @@ export function McpServersSection({ /> ))}
- ) : ( + ) : !loading && !patina && !showPatinaForm ? (

No custom servers configured

+ ) : null} + + ); +} + +function PatinaConnectionRow({ + busyAction, + connection, + onAction, + onReconnect, + variant, +}: { + busyAction: string | null; + connection: RemoteMcpConnection; + onAction: (action: "test" | "toggle" | "disconnect") => Promise; + onReconnect: () => void; + variant: "compact" | "profile"; +}) { + const StatusIcon = connection.enabled ? CheckCircle2 : CircleSlash; + return ( +
+
+ + + + + + Patina · {connection.workspaceName ?? connection.workspaceSlug} + + + {connection.principalName ?? "Agent key"} · {connection.status} + + +
+
+ + + + +
); } diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index c57525480e..30dc4caec9 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -36,6 +36,7 @@ import type { InstallRuntimeResult, GitBashPrerequisite, RuntimeConfigSurface, + RemoteMcpConnection, } from "@/shared/api/types"; export * from "@/shared/api/tauriChannels"; @@ -865,6 +866,52 @@ export async function listManagedAgents(): Promise { fromRawManagedAgent, ); } + +export async function listRemoteMcpConnections( + pubkey: string, +): Promise { + return invokeTauri("list_remote_mcp_connections", { + pubkey, + }); +} + +export async function connectPatina(input: { + pubkey: string; + workspaceSlug: string; + apiKey: string; +}): Promise { + return invokeTauri("connect_patina", input); +} + +export async function testPatinaConnection( + pubkey: string, +): Promise { + return invokeTauri("test_patina_connection", { + pubkey, + }); +} + +export async function setRemoteMcpEnabled( + pubkey: string, + connectionId: string, + enabled: boolean, +): Promise { + return invokeTauri("set_remote_mcp_enabled", { + pubkey, + connectionId, + enabled, + }); +} + +export async function disconnectRemoteMcp( + pubkey: string, + connectionId: string, +): Promise { + return invokeTauri("disconnect_remote_mcp", { + pubkey, + connectionId, + }); +} export async function createManagedAgent(input: CreateManagedAgentInput) { const response = await invokeTauri( "create_managed_agent", diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index d14b66eebb..19f90fa210 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -689,6 +689,19 @@ export type ConfigSourceReport = { export type ExtensionEntry = { name: string; kind: string; enabled: boolean }; +export type RemoteMcpConnection = { + id: string; + provider: string; + name: string; + url: string; + workspaceSlug: string; + workspaceName: string | null; + principalName: string | null; + enabled: boolean; + status: "connected" | "disabled"; + lastVerifiedAt: string; +}; + export type NormalizedConfig = { model: NormalizedField | null; provider: NormalizedField | null; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 03c05fe877..48004b0ad3 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -95,6 +95,20 @@ type MockManagedAgentRuntimeSeed = { lifecycle?: MockManagedAgentRuntimeRow["lifecycle"]; }; +type MockRemoteMcpConnection = { + agentPubkey: string; + id: string; + provider: string; + name: string; + url: string; + workspaceSlug: string; + workspaceName: string | null; + principalName: string | null; + enabled: boolean; + status: "connected" | "disabled"; + lastVerifiedAt: string; +}; + type MockRelayAgentSeed = { pubkey: string; name: string; @@ -2085,6 +2099,7 @@ function resetMockRelayAgents(config?: E2eConfig) { function resetMockManagedAgents(config?: E2eConfig) { mockManagedAgents = []; + mockRemoteMcpConnections = []; mockManagedAgentRuntimes = (config?.mock?.managedAgentRuntimes ?? []).map( (seed) => ({ pubkey: seed.pubkey, @@ -2793,6 +2808,7 @@ let mockClosedChannelLiveSubscription = false; const realSockets = new Map(); let mockManagedAgents: MockManagedAgent[] = []; let mockManagedAgentRuntimes: MockManagedAgentRuntimeRow[] = []; +let mockRemoteMcpConnections: MockRemoteMcpConnection[] = []; // Mutable `save_subscriptions` table mirror — TEST-ONLY. // @@ -10459,6 +10475,85 @@ export function maybeInstallE2eTauriMocks() { } case "list_managed_agents": return handleListManagedAgents(activeConfig); + case "list_remote_mcp_connections": { + const { pubkey } = payload as { pubkey: string }; + return mockRemoteMcpConnections + .filter((connection) => connection.agentPubkey === pubkey) + .map(({ agentPubkey: _agentPubkey, ...connection }) => ({ + ...connection, + })); + } + case "connect_patina": { + const { apiKey, pubkey, workspaceSlug } = payload as { + apiKey: string; + pubkey: string; + workspaceSlug: string; + }; + if (!apiKey.startsWith("pk_")) { + throw new Error("Patina agent keys must start with pk_"); + } + const normalizedSlug = workspaceSlug.trim().toLowerCase(); + const connection: MockRemoteMcpConnection = { + agentPubkey: pubkey, + enabled: true, + id: "patina", + lastVerifiedAt: new Date().toISOString(), + name: "Patina", + principalName: "Buzz Viewer", + provider: "patina", + status: "connected", + url: `https://patina.so/mcp/${normalizedSlug}`, + workspaceName: "Patina Demo", + workspaceSlug: normalizedSlug, + }; + mockRemoteMcpConnections = [ + ...mockRemoteMcpConnections.filter( + (item) => item.agentPubkey !== pubkey || item.id !== connection.id, + ), + connection, + ]; + const { agentPubkey: _agentPubkey, ...summary } = connection; + return summary; + } + case "test_patina_connection": { + const { pubkey } = payload as { pubkey: string }; + const connection = mockRemoteMcpConnections.find( + (item) => item.agentPubkey === pubkey && item.id === "patina", + ); + if (!connection) { + throw new Error("Patina is not connected for this agent"); + } + connection.lastVerifiedAt = new Date().toISOString(); + const { agentPubkey: _agentPubkey, ...summary } = connection; + return { ...summary }; + } + case "set_remote_mcp_enabled": { + const { connectionId, enabled, pubkey } = payload as { + connectionId: string; + enabled: boolean; + pubkey: string; + }; + const connection = mockRemoteMcpConnections.find( + (item) => item.agentPubkey === pubkey && item.id === connectionId, + ); + if (!connection) { + throw new Error("Remote MCP connection was not found"); + } + connection.enabled = enabled; + connection.status = enabled ? "connected" : "disabled"; + const { agentPubkey: _agentPubkey, ...summary } = connection; + return { ...summary }; + } + case "disconnect_remote_mcp": { + const { connectionId, pubkey } = payload as { + connectionId: string; + pubkey: string; + }; + mockRemoteMcpConnections = mockRemoteMcpConnections.filter( + (item) => item.agentPubkey !== pubkey || item.id !== connectionId, + ); + return undefined; + } case "get_agent_memory": return handleGetAgentMemory( (payload as Parameters[0]) ?? {}, diff --git a/desktop/tests/e2e/patina-mcp.spec.ts b/desktop/tests/e2e/patina-mcp.spec.ts new file mode 100644 index 0000000000..7b35c5207e --- /dev/null +++ b/desktop/tests/e2e/patina-mcp.spec.ts @@ -0,0 +1,75 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; + +const AGENT_PUBKEY = TEST_IDENTITIES.tyler.pubkey; +const AGENT_NAME = "Patina Agent"; + +async function openRuntimePanel(page: import("@playwright/test").Page) { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.getByTestId("channel-agents").click(); + await expect(page.getByTestId("chat-title")).toHaveText("agents"); + + const messageRow = page.getByTestId("message-row").filter({ + has: page.getByText(AGENT_NAME, { exact: false }), + }); + await expect(messageRow.first()).toBeVisible({ timeout: 5_000 }); + await messageRow.first().getByRole("button").first().click(); + + const panel = page.getByTestId("user-profile-panel"); + await expect(panel).toBeVisible({ timeout: 10_000 }); + await panel.getByRole("tab", { name: "Runtime" }).click(); + await expect(panel.getByText("MCP Servers", { exact: true })).toBeVisible({ + timeout: 10_000, + }); + return panel; +} + +test.describe("Patina remote MCP", () => { + test("connects, clears the key, toggles, tests, and disconnects", async ({ + page, + }) => { + await installMockBridge(page, { + managedAgents: [ + { + channelNames: ["agents"], + name: AGENT_NAME, + pubkey: AGENT_PUBKEY, + status: "running", + }, + ], + }); + + const panel = await openRuntimePanel(page); + await panel.getByTestId("connect-patina").click(); + await panel.getByTestId("patina-workspace-slug").fill("Acme-Team"); + await panel.getByTestId("patina-api-key").fill("pk_viewer_secret"); + await panel.getByTestId("patina-test-connect").click(); + + const connection = panel.getByTestId("patina-connection"); + await expect(connection).toContainText("Patina · Patina Demo"); + await expect(connection).toContainText("Buzz Viewer · connected"); + + await connection.getByRole("button", { name: "Reconnect" }).click(); + await expect(panel.getByTestId("patina-workspace-slug")).toHaveValue( + "acme-team", + ); + await expect(panel.getByTestId("patina-api-key")).toHaveValue(""); + await panel.getByRole("button", { name: "Cancel" }).click(); + + await connection.getByRole("button", { name: "Test" }).click(); + await expect(connection).toContainText("connected"); + await connection.getByRole("button", { name: "Disable" }).click(); + await expect(connection).toContainText("disabled"); + await connection.getByRole("button", { name: "Enable" }).click(); + await expect(connection).toContainText("connected"); + + await connection.screenshot({ + path: "test-results/patina-mcp/connected.png", + }); + + await connection.getByRole("button", { name: "Disconnect" }).click(); + await expect(connection).not.toBeVisible(); + await expect(panel.getByTestId("connect-patina")).toBeVisible(); + }); +});