From bc409ebc6cd852afa40b7e9ed782e02668b7d1e2 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:40:16 +0000 Subject: [PATCH 1/3] feat(agent): add MCP server support --- docs/agent-mode-mcp.md | 82 ++ frontend/src-tauri/src/agent.rs | 837 +++++++++++++++- frontend/src-tauri/src/lib.rs | 4 + frontend/src/components/AgentMode.tsx | 237 ++++- .../src/components/agent/AgentMcpControls.tsx | 894 ++++++++++++++++++ frontend/src/services/agentMcpErrors.test.ts | 47 + frontend/src/services/agentMcpErrors.ts | 69 ++ frontend/src/services/agentRuntimeService.ts | 73 ++ 8 files changed, 2214 insertions(+), 29 deletions(-) create mode 100644 docs/agent-mode-mcp.md create mode 100644 frontend/src/components/agent/AgentMcpControls.tsx create mode 100644 frontend/src/services/agentMcpErrors.test.ts create mode 100644 frontend/src/services/agentMcpErrors.ts diff --git a/docs/agent-mode-mcp.md b/docs/agent-mode-mcp.md new file mode 100644 index 00000000..c0bb2672 --- /dev/null +++ b/docs/agent-mode-mcp.md @@ -0,0 +1,82 @@ +# Agent Mode MCP support + +This document describes Maple's first Model Context Protocol (MCP) integration in Agent Mode. The implementation intentionally follows the thin custom-extension experience in Goose Desktop and uses Maple's embedded Goose SDK directly. + +## MVP behavior + +Agent Mode supports user-configured MCP servers over: + +- Standard input/output (STDIO) +- Streamable HTTP + +The global MCP manager lets a user add, edit, delete, and choose default servers. The composer's MCP menu can search the configured list. Each definition has a name, optional description, positive timeout in seconds, and transport-specific settings: + +- STDIO: one command field plus optional environment variables +- Streamable HTTP: endpoint URL, optional environment variables, and optional request headers + +The STDIO command is tokenized into an executable and argument list. It is never passed to a shell. Saving a definition does not test or start it. + +Enabled defaults are copied into a new Agent session. Changing, renaming, disabling, or deleting a global definition affects future sessions only. The puzzle menu in the composer controls the current session: enabling connects immediately and persists only after a successful connection; disabling disconnects immediately and removes the server from that session's Goose snapshot. + +A connection failure is nonfatal. The session still opens and Maple reports the failed server. Goose persists only servers that connected successfully. + +## Persistence and security boundary + +MCP definitions are account-scoped in Maple's Agent Mode `config.json`, which is written with owner-only permissions on Unix systems. Existing Agent sessions store their selected extensions in that account's Goose session metadata. + +Environment and HTTP header values are stored in the account definition and copied into each selected session's extension snapshot. Freezing the complete configuration prevents a later credential change from being sent to an older session's endpoint or process. It also avoids Goose's process-global secret store, which cannot isolate the same environment-variable name by Maple account or session. + +The account directory is owner-only on Unix systems, but these values are not encrypted at rest. The UI obscures them while editing; obscuring a field is not encryption. Deleting an Agent session removes its snapshot, and clearing the account's local Agent data removes both its definitions and session store. + +Maple rejects Goose's disallowed process-overriding variables, empty or duplicate keys, duplicate case-insensitive HTTP header names, names that collide after Goose normalization, and the reserved `developer` extension name. + +## Compatibility boundary + +Maple currently inherits the MCP implementation and protocol negotiation from its pinned Goose SDK. At the time of this MVP, Goose advertises MCP revision `2025-03-26`. A modern `@modelcontextprotocol/server-everything` release using TypeScript SDK 1.29.0 was verified to negotiate that revision successfully over both supported transports. + +This MVP does not include: + +- Legacy HTTP+SSE transport +- Built-in or curated MCP servers +- A server catalog, import/export, deep links, or install recipes +- MCP connection tests, health history, or reconnect controls +- Tool allowlists, MCP resources/prompts UI, Unix sockets, or custom working directories +- Maple-owned OAuth configuration or guarantees +- A Maple-owned sampling or elicitation implementation + +Goose may automatically attempt browser OAuth after an HTTP authentication challenge. Maple does not expose controls for that inherited behavior, and Goose's OAuth credentials are not durable across Maple Agent runtime restarts. For this MVP, supported authentication is limited to STDIO environment variables and static Streamable HTTP headers (including headers that reference configured environment variables). + +## Deterministic smoke test + +Use the official Everything test server pinned to `2026.1.14`. + +STDIO definition: + +```text +Name: fixture_stdio +Command: npx --offline -y @modelcontextprotocol/server-everything@2026.1.14 stdio +Timeout: 30 +``` + +Remove `--offline` if the package is not already cached. + +For Streamable HTTP, start the server separately: + +```sh +PORT=33001 npx --offline -y \ + @modelcontextprotocol/server-everything@2026.1.14 streamableHttp +``` + +Then configure: + +```text +Name: fixture_http +Endpoint: http://127.0.0.1:33001/mcp +Timeout: 30 +``` + +For each transport, enable only that fixture in a new Agent session and ask Maple to call its prefixed `echo` tool with a unique marker. Confirm the visible tool request, arguments, tool result, and final answer all contain the same marker. Then verify that disabling it removes the tool from that session and that a stopped HTTP server produces a visible connection error without disabling Maple's built-in developer tools. + +## Follow-up direction + +Keep the compatibility policy conservative: support what the pinned Goose SDK interoperates with today, and change the advertised MCP revision or fork Goose only in response to a demonstrated server incompatibility. Likely follow-ups are a curated privacy-oriented catalog, durable and account-isolated OAuth, a stronger secret store for definitions and headers, server health/reconnect UX, and richer MCP capability surfaces. diff --git a/frontend/src-tauri/src/agent.rs b/frontend/src-tauri/src/agent.rs index 93a0c0e8..973300a4 100644 --- a/frontend/src-tauri/src/agent.rs +++ b/frontend/src-tauri/src/agent.rs @@ -4,6 +4,7 @@ mod shell_permission; use crate::proxy; use developer_tools::MapleDeveloperClient; use futures_util::StreamExt; +use goose::agents::extension::Envs; use goose::agents::{ Agent, AgentConfig as GooseAgentConfig, AgentEvent, ExtensionConfig, GoosePlatform, SessionConfig, @@ -59,6 +60,7 @@ const MAPLE_GOOSE_PERMISSION_CONFIG: &str = r#"user: "#; const RUN_SHUTDOWN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); const DEFAULT_AGENT_SESSION_TITLE: &str = "New agent session"; +const DEFAULT_MCP_TIMEOUT_SECONDS: u64 = 300; const MAX_AGENT_SESSION_TITLE_CHARS: usize = 80; const MAX_AGENT_ERROR_CHARS: usize = 1_200; static NEXT_RUN_ID: AtomicU64 = AtomicU64::new(1); @@ -88,6 +90,8 @@ pub struct AgentConfig { pub default_project_root: Option, #[serde(default = "default_agent_model")] pub default_model: String, + #[serde(default)] + pub mcp_servers: Vec, } fn default_agent_model() -> String { @@ -99,10 +103,77 @@ impl Default for AgentConfig { Self { default_project_root: None, default_model: default_agent_model(), + mcp_servers: Vec::new(), } } } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentMcpKeyValue { + pub key: String, + pub value: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum AgentMcpTransport { + Stdio { + command: String, + #[serde(default)] + environment: Vec, + }, + StreamableHttp { + url: String, + #[serde(default)] + environment: Vec, + #[serde(default)] + headers: Vec, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentMcpServer { + pub name: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub enabled: bool, + #[serde(default = "default_mcp_timeout_seconds")] + pub timeout_seconds: u64, + pub transport: AgentMcpTransport, +} + +fn default_mcp_timeout_seconds() -> u64 { + DEFAULT_MCP_TIMEOUT_SECONDS +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentMcpConnectionError { + pub name: String, + pub error: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentSessionMcpServer { + pub name: String, + pub description: String, + pub transport: String, + pub enabled: bool, + pub available: bool, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentSetSessionMcpServerRequest { + pub session_id: String, + pub name: String, + pub enabled: bool, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentStartRequest { @@ -136,6 +207,7 @@ pub struct AgentCreateSessionRequest { pub title: Option, pub model: Option, pub mode: Option, + pub mcp_server_names: Option>, } #[derive(Debug, Clone, Deserialize)] @@ -188,6 +260,7 @@ pub struct AgentSessionSummary { pub struct AgentSessionDetail { pub session: AgentSessionSummary, pub timeline: Vec, + pub mcp_errors: Vec, } #[derive(Debug, Clone, PartialEq, Serialize)] @@ -589,7 +662,8 @@ async fn start_runtime_for_user( } } - let agent_config = load_agent_config_inner(&app_handle, &user_id).unwrap_or_default(); + let mut agent_config = load_agent_config_inner(&app_handle, &user_id) + .map_err(|error| format!("Failed to load Agent config: {error}"))?; let request = request.unwrap_or(AgentStartRequest { project_root: None, model: None, @@ -607,7 +681,9 @@ async fn start_runtime_for_user( let project_root = resolve_project_root(request.project_root.as_deref(), &agent_config) .map_err(|e| format!("Failed to resolve Agent Mode project root: {e}"))?; - let model = request.model.unwrap_or(agent_config.default_model); + let model = request + .model + .unwrap_or_else(|| agent_config.default_model.clone()); let mode = request .mode .unwrap_or_else(|| DEFAULT_GOOSE_MODE.to_string()); @@ -632,7 +708,6 @@ async fn start_runtime_for_user( DEFAULT_GOOSE_MODE, &maple_proxy_base_url, )?; - let session_manager = Arc::new(SessionManager::new(goose_path_root.join("data"))); let permission_manager = Arc::new(PermissionManager::new(goose_path_root.join("config"))); let goose_config = GooseAgentConfig::new( @@ -668,10 +743,9 @@ async fn start_runtime_for_user( } let _ = save_recent_project_root_inner(&app_handle, &user_id, &project_root); - let mut next_config = load_agent_config_inner(&app_handle, &user_id).unwrap_or_default(); - next_config.default_project_root = Some(path_string(&project_root)); - next_config.default_model = model; - let _ = save_agent_config_inner(&app_handle, &user_id, &next_config); + agent_config.default_project_root = Some(path_string(&project_root)); + agent_config.default_model = model; + let _ = save_agent_config_inner(&app_handle, &user_id, &agent_config); emit_agent_event( &app_handle, @@ -796,7 +870,45 @@ pub async fn agent_save_config( let generation = account_generation(&state, &account_scope).await; let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; ensure_account_generation(&state, &account_scope, generation).await?; - save_agent_config_inner(&app_handle, &user_id, &config).map_err(|e| e.to_string()) + // MCP definitions have a dedicated mutation command. Preserve them here so + // a delayed project/model preference save cannot overwrite newer servers. + let mut next = load_agent_config_inner(&app_handle, &user_id).map_err(|e| e.to_string())?; + next.default_project_root = config.default_project_root; + next.default_model = config.default_model; + save_agent_config_inner(&app_handle, &user_id, &next).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn agent_list_mcp_servers( + app_handle: AppHandle, + state: State<'_, AgentRuntimeState>, + user_id: String, +) -> Result, String> { + let account_scope = account_scope(&user_id)?; + let generation = account_generation(&state, &account_scope).await; + let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; + ensure_account_generation(&state, &account_scope, generation).await?; + let config = load_agent_config_inner(&app_handle, &user_id).map_err(|e| e.to_string())?; + normalize_mcp_servers(config.mcp_servers) +} + +#[tauri::command] +pub async fn agent_save_mcp_servers( + app_handle: AppHandle, + state: State<'_, AgentRuntimeState>, + user_id: String, + servers: Vec, +) -> Result, String> { + let account_scope = account_scope(&user_id)?; + let generation = account_generation(&state, &account_scope).await; + let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; + ensure_account_generation(&state, &account_scope, generation).await?; + let servers = normalize_mcp_servers(servers)?; + let mut config = load_agent_config_inner(&app_handle, &user_id).map_err(|e| e.to_string())?; + config.mcp_servers = servers.clone(); + save_agent_config_inner(&app_handle, &user_id, &config).map_err(|e| e.to_string())?; + + Ok(servers) } #[tauri::command] @@ -843,6 +955,7 @@ pub async fn agent_create_session( title: None, model: None, mode: None, + mcp_server_names: None, }); let ( agent_manager, @@ -878,6 +991,16 @@ pub async fn agent_create_session( let mode = request.mode.unwrap_or(runtime_mode); let permission_mode = parse_user_permission_mode(&mode)?; let model = request.model.unwrap_or(runtime_model); + let configured_mcp = normalize_mcp_servers( + load_agent_config_inner(&app_handle, &user_id) + .map_err(|error| format!("Failed to load MCP servers: {error}"))? + .mcp_servers, + )?; + let selected_mcp = select_mcp_servers(&configured_mcp, request.mcp_server_names.as_deref())?; + let selected_extensions = selected_mcp + .iter() + .map(mcp_server_to_extension) + .collect::, _>>()?; let session = session_manager .create_session(root.clone(), title, SessionType::User, permission_mode) .await @@ -887,7 +1010,7 @@ pub async fn agent_create_session( .lock() .await .insert(session.id.clone(), permission_mode); - configure_session_agent( + let (agent, mut mcp_errors) = configure_session_agent( &agent_manager, &session_manager, &session, @@ -896,11 +1019,24 @@ pub async fn agent_create_session( false, ) .await?; + if !selected_extensions.is_empty() { + match agent + .add_extensions_bulk(selected_extensions, &session.id) + .await + { + Ok(results) => mcp_errors.extend(mcp_connection_errors(results)), + Err(error) => mcp_errors.push(AgentMcpConnectionError { + name: "MCP servers".to_string(), + error: error.to_string(), + }), + } + } let summary = session_summary(&session); let _ = save_recent_project_root_inner(&app_handle, &user_id, &root); let detail = AgentSessionDetail { session: summary.clone(), timeline: Vec::new(), + mcp_errors, }; emit_agent_event( &app_handle, @@ -999,9 +1135,155 @@ pub async fn agent_load_session( Ok(AgentSessionDetail { session: session_summary(&session), timeline, + mcp_errors: Vec::new(), }) } +#[tauri::command] +pub async fn agent_list_session_mcp_servers( + app_handle: AppHandle, + state: State<'_, AgentRuntimeState>, + user_id: String, + session_id: String, +) -> Result, String> { + let account_scope = account_scope(&user_id)?; + let generation = account_generation(&state, &account_scope).await; + let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; + ensure_account_generation(&state, &account_scope, generation).await?; + let session_manager = { + let runtime = state.inner.lock().await; + match runtime.as_ref() { + Some(current) => { + ensure_runtime_account(current, &account_scope)?; + Arc::clone(¤t.session_manager) + } + None => account_session_manager(&app_handle, &user_id)?, + } + }; + let session = session_manager + .get_session(session_id.trim(), false) + .await + .map_err(|error| format!("Failed to load Goose session: {error}"))?; + let configured = normalize_mcp_servers( + load_agent_config_inner(&app_handle, &user_id) + .map_err(|error| format!("Failed to load MCP servers: {error}"))? + .mcp_servers, + )?; + Ok(session_mcp_servers(&configured, &session)) +} + +#[tauri::command] +pub async fn agent_set_session_mcp_server_enabled( + app_handle: AppHandle, + state: State<'_, AgentRuntimeState>, + user_id: String, + request: AgentSetSessionMcpServerRequest, +) -> Result, String> { + let account_scope = account_scope(&user_id)?; + let generation = account_generation(&state, &account_scope).await; + let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; + ensure_account_generation(&state, &account_scope, generation).await?; + let _session_lifecycle_guard = state.session_lifecycle.lock().await; + let session_id = request.session_id.trim().to_string(); + let requested_key = goose::config::extensions::name_to_key(request.name.trim()); + if session_id.is_empty() { + return Err("Agent session ID cannot be empty".to_string()); + } + if requested_key.is_empty() || requested_key == "developer" { + return Err("That MCP server cannot be changed".to_string()); + } + + let (agent_manager, session_manager) = { + let runtime = state.inner.lock().await; + let current = runtime + .as_ref() + .ok_or_else(|| "Agent runtime is not running".to_string())?; + ensure_runtime_account(current, &account_scope)?; + if has_active_session_run(¤t.active_runs, &session_id) { + return Err("Stop the running agent before changing MCP servers".to_string()); + } + ( + Arc::clone(¤t.agent_manager), + Arc::clone(¤t.session_manager), + ) + }; + let configured = normalize_mcp_servers( + load_agent_config_inner(&app_handle, &user_id) + .map_err(|error| format!("Failed to load MCP servers: {error}"))? + .mcp_servers, + )?; + let _session = session_manager + .get_session(&session_id, false) + .await + .map_err(|error| format!("Failed to load Goose session: {error}"))?; + let manager_result = agent_manager + .get_or_create_agent_with_runtime_context( + session_id.clone(), + goose::execution::manager::RuntimeContext::default(), + ) + .await + .map_err(|error| format!("Failed to load Goose agent: {error}"))?; + for error in mcp_connection_errors(manager_result.extension_results) { + log::warn!( + "Failed to restore MCP server {}: {}", + error.name, + error.error + ); + } + let agent = manager_result.agent; + let active = agent.get_extension_configs().await; + let active_config = active + .iter() + .find(|config| mcp_transport_label(config).is_some() && config.key() == requested_key); + + if request.enabled { + if active_config.is_none() { + let server = configured + .iter() + .find(|server| { + goose::config::extensions::name_to_key(&server.name) == requested_key + }) + .ok_or_else(|| { + format!( + "MCP server '{}' is no longer configured and cannot be enabled", + request.name.trim() + ) + })?; + let extension = mcp_server_to_extension(server)?; + agent + .add_extension(extension, &session_id) + .await + .map_err(|error| { + format!("Failed to connect MCP server '{}': {error}", server.name) + })?; + } + } else if let Some(config) = active_config { + agent + .remove_extension(&config.name(), &session_id) + .await + .map_err(|error| { + format!( + "Failed to disconnect MCP server '{}': {error}", + request.name.trim() + ) + })?; + } else { + // A failed cold restore may already have removed the server from the + // live manager. Persist that authoritative state so the UI still gets + // a successful, durable disable operation. + agent + .persist_extension_state(&session_id) + .await + .map_err(|error| format!("Failed to save MCP session state: {error}"))?; + } + + let refreshed = session_manager + .get_session(&session_id, false) + .await + .map_err(|error| format!("Failed to reload Goose session: {error}"))?; + Ok(session_mcp_servers(&configured, &refreshed)) +} + #[tauri::command] pub async fn agent_delete_session( app_handle: AppHandle, @@ -1195,7 +1477,10 @@ pub async fn agent_send_message( }; let effective_mode = permission_mode.to_string(); - let setup_result: Result<(Arc, AgentTurnSnapshot), String> = async { + let setup_result: Result< + (Arc, AgentTurnSnapshot, Vec), + String, + > = async { let mut session = session_manager .get_session(&request.session_id, true) .await @@ -1241,7 +1526,7 @@ pub async fn agent_send_message( }, ); } - let agent = configure_session_agent( + let (agent, mcp_errors) = configure_session_agent( &agent_manager, &session_manager, &session, @@ -1250,10 +1535,10 @@ pub async fn agent_send_message( request.vision_capable, ) .await?; - Ok((agent, turn_snapshot)) + Ok((agent, turn_snapshot, mcp_errors)) } .await; - let (agent, task_turn_snapshot) = match setup_result { + let (agent, task_turn_snapshot, mcp_errors) = match setup_result { Ok(setup) => setup, Err(error) => { if seeded_permission_mode { @@ -1265,6 +1550,20 @@ pub async fn agent_send_message( return Err(error); } }; + if !mcp_errors.is_empty() { + emit_agent_event( + &app_handle, + AgentEventEnvelope { + event_type: "error".to_string(), + session_id: None, + run_id: Some(run_id.clone()), + item: None, + status: None, + session: None, + message: Some(format_mcp_connection_errors(&mcp_errors)), + }, + ); + } let app_handle_for_task = app_handle.clone(); let state_inner = Arc::clone(&state.inner); @@ -2275,11 +2574,16 @@ async fn configure_session_agent( model: &str, mode: &str, primary_model_supports_vision: bool, -) -> Result, String> { - let agent = agent_manager - .get_or_create_agent(session.id.clone()) +) -> Result<(Arc, Vec), String> { + let manager_result = agent_manager + .get_or_create_agent_with_runtime_context( + session.id.clone(), + goose::execution::manager::RuntimeContext::default(), + ) .await .map_err(|e| format!("Failed to get Goose agent for session {}: {e}", session.id))?; + let agent = manager_result.agent; + let mcp_errors = mcp_connection_errors(manager_result.extension_results); let provider = goose::providers::create_with_working_dir( "openai", Vec::new(), @@ -2337,7 +2641,7 @@ async fn configure_session_agent( .apply() .await .map_err(|e| format!("Failed to persist Agent permission mode: {e}"))?; - Ok(agent) + Ok((agent, mcp_errors)) } #[derive(Default)] @@ -3183,6 +3487,308 @@ fn parse_user_permission_mode(mode: &str) -> Result { } } +fn normalize_mcp_servers(mut servers: Vec) -> Result, String> { + let mut names = HashSet::new(); + + for server in &mut servers { + server.name = server.name.trim().to_string(); + server.description = server.description.trim().to_string(); + if server.name.is_empty() { + return Err("MCP server name cannot be empty".to_string()); + } + if server.name.chars().count() > 64 { + return Err(format!( + "MCP server name '{}' must be 64 characters or fewer", + server.name + )); + } + let key = goose::config::extensions::name_to_key(&server.name); + if key.is_empty() { + return Err(format!( + "MCP server name '{}' must contain a letter, number, underscore, or hyphen", + server.name + )); + } + if key == "developer" { + return Err("The MCP server name 'developer' is reserved by Maple".to_string()); + } + if !names.insert(key) { + return Err(format!( + "MCP server name '{}' conflicts with another configured server", + server.name + )); + } + if server.timeout_seconds == 0 { + return Err(format!( + "MCP server '{}' must have a timeout greater than zero", + server.name + )); + } + + let environment = match &mut server.transport { + AgentMcpTransport::Stdio { + command, + environment, + } => { + *command = command.trim().to_string(); + if command.is_empty() { + return Err(format!("MCP server '{}' requires a command", server.name)); + } + let parts = split_mcp_command(command, &server.name)?; + if parts.is_empty() || parts[0].is_empty() { + return Err(format!( + "MCP server '{}' requires an executable", + server.name + )); + } + validate_mcp_key_values(environment, &server.name, "environment variable", false)?; + environment + } + AgentMcpTransport::StreamableHttp { + url, + environment, + headers, + } => { + *url = url.trim().to_string(); + if url.is_empty() { + return Err(format!( + "MCP server '{}' requires an endpoint URL", + server.name + )); + } + validate_mcp_key_values(environment, &server.name, "environment variable", false)?; + validate_mcp_key_values(headers, &server.name, "HTTP header", true)?; + environment + } + }; + + for entry in environment { + let accepted = Envs::new(HashMap::from([(entry.key.clone(), entry.value.clone())])) + .get_env() + .contains_key(&entry.key); + if !accepted { + return Err(format!( + "MCP server '{}' cannot override the environment variable {}", + server.name, entry.key + )); + } + } + } + + Ok(servers) +} + +fn validate_mcp_key_values( + entries: &mut [AgentMcpKeyValue], + server_name: &str, + label: &str, + case_insensitive: bool, +) -> Result<(), String> { + let mut keys = HashSet::new(); + for entry in entries { + entry.key = entry.key.trim().to_string(); + if entry.key.is_empty() { + return Err(format!( + "MCP server '{server_name}' has an empty {label} name" + )); + } + if label == "HTTP header" && entry.key.chars().any(char::is_whitespace) { + return Err(format!( + "MCP server '{server_name}' HTTP header names cannot contain whitespace" + )); + } + let comparison_key = if case_insensitive { + entry.key.to_ascii_lowercase() + } else { + entry.key.clone() + }; + if !keys.insert(comparison_key) { + return Err(format!( + "MCP server '{server_name}' has a duplicate {label} named {}", + entry.key + )); + } + } + Ok(()) +} + +fn mcp_environment(server: &AgentMcpServer) -> &[AgentMcpKeyValue] { + match &server.transport { + AgentMcpTransport::Stdio { environment, .. } + | AgentMcpTransport::StreamableHttp { environment, .. } => environment, + } +} + +fn split_mcp_command(command: &str, server_name: &str) -> Result, String> { + goose::utils::split_command_args(command) + .map_err(|error| format!("MCP server '{server_name}' has an invalid command: {error}")) +} + +fn mcp_server_to_extension(server: &AgentMcpServer) -> Result { + let envs = Envs::new( + mcp_environment(server) + .iter() + .map(|entry| (entry.key.clone(), entry.value.clone())) + .collect(), + ); + match &server.transport { + AgentMcpTransport::Stdio { command, .. } => { + let mut parts = split_mcp_command(command, &server.name)?; + if parts.is_empty() { + return Err(format!("MCP server '{}' requires a command", server.name)); + } + let cmd = parts.remove(0); + Ok(ExtensionConfig::Stdio { + name: server.name.clone(), + description: server.description.clone(), + cmd, + args: parts, + envs, + env_keys: Vec::new(), + timeout: Some(server.timeout_seconds), + cwd: None, + bundled: Some(false), + available_tools: Vec::new(), + }) + } + AgentMcpTransport::StreamableHttp { url, headers, .. } => { + Ok(ExtensionConfig::StreamableHttp { + name: server.name.clone(), + description: server.description.clone(), + uri: url.clone(), + envs, + env_keys: Vec::new(), + headers: headers + .iter() + .map(|entry| (entry.key.clone(), entry.value.clone())) + .collect(), + timeout: Some(server.timeout_seconds), + socket: None, + bundled: Some(false), + available_tools: Vec::new(), + }) + } + } +} + +fn select_mcp_servers( + configured: &[AgentMcpServer], + requested_names: Option<&[String]>, +) -> Result, String> { + let Some(requested_names) = requested_names else { + return Ok(configured + .iter() + .filter(|server| server.enabled) + .cloned() + .collect()); + }; + let configured_by_key = configured + .iter() + .map(|server| (goose::config::extensions::name_to_key(&server.name), server)) + .collect::>(); + let mut selected = Vec::new(); + let mut seen = HashSet::new(); + for requested_name in requested_names { + let key = goose::config::extensions::name_to_key(requested_name.trim()); + if !seen.insert(key.clone()) { + continue; + } + let server = configured_by_key.get(&key).ok_or_else(|| { + format!( + "MCP server '{}' is no longer configured. Reopen the MCP menu and try again.", + requested_name.trim() + ) + })?; + selected.push((*server).clone()); + } + Ok(selected) +} + +fn mcp_connection_errors( + results: Vec, +) -> Vec { + results + .into_iter() + .filter_map(|result| { + (!result.success).then(|| AgentMcpConnectionError { + name: result.name, + error: result + .error + .unwrap_or_else(|| "Connection failed".to_string()), + }) + }) + .collect() +} + +fn format_mcp_connection_errors(errors: &[AgentMcpConnectionError]) -> String { + let details = errors + .iter() + .map(|error| format!("{}: {}", error.name, error.error)) + .collect::>() + .join("; "); + format!("Some MCP servers could not connect: {details}") +} + +fn mcp_transport_label(config: &ExtensionConfig) -> Option<&'static str> { + match config { + ExtensionConfig::Stdio { .. } => Some("stdio"), + ExtensionConfig::StreamableHttp { .. } => Some("streamable_http"), + _ => None, + } +} + +fn mcp_extension_description(config: &ExtensionConfig) -> String { + match config { + ExtensionConfig::Stdio { description, .. } + | ExtensionConfig::StreamableHttp { description, .. } => description.clone(), + _ => String::new(), + } +} + +fn session_mcp_servers( + configured: &[AgentMcpServer], + session: &Session, +) -> Vec { + let active = + goose::session::EnabledExtensionsState::from_extension_data(&session.extension_data) + .map(|state| state.extensions) + .unwrap_or_default(); + let active_keys = active + .iter() + .filter(|config| mcp_transport_label(config).is_some()) + .map(ExtensionConfig::key) + .collect::>(); + let mut entries = configured + .iter() + .map(|server| AgentSessionMcpServer { + name: server.name.clone(), + description: server.description.clone(), + transport: match server.transport { + AgentMcpTransport::Stdio { .. } => "stdio", + AgentMcpTransport::StreamableHttp { .. } => "streamable_http", + } + .to_string(), + enabled: active_keys.contains(&goose::config::extensions::name_to_key(&server.name)), + available: true, + }) + .collect::>(); + let configured_keys = configured + .iter() + .map(|server| goose::config::extensions::name_to_key(&server.name)) + .collect::>(); + entries.extend(active.iter().filter_map(|config| { + let transport = mcp_transport_label(config)?; + (!configured_keys.contains(&config.key())).then(|| AgentSessionMcpServer { + name: config.name(), + description: mcp_extension_description(config), + transport: transport.to_string(), + enabled: true, + available: false, + }) + })); + entries +} + fn stopped_status() -> AgentRuntimeStatus { AgentRuntimeStatus { running: false, @@ -3286,17 +3892,21 @@ fn load_agent_config_inner( user_id: &str, ) -> Result { let path = agent_config_dir(app_handle, user_id)?.join("config.json"); - if !path.exists() { - return Ok(AgentConfig::default()); - } - let contents = fs::read_to_string(path)?; - let mut config: AgentConfig = serde_json::from_str(&contents)?; + let mut config = load_agent_config_file(&path)?; if migrate_agent_config(&mut config) { save_agent_config_inner(app_handle, user_id, &config)?; } Ok(config) } +fn load_agent_config_file(path: &Path) -> Result { + if !path.exists() { + return Ok(AgentConfig::default()); + } + let contents = fs::read_to_string(path)?; + Ok(serde_json::from_str(&contents)?) +} + fn migrate_agent_config(config: &mut AgentConfig) -> bool { if config.default_model != LEGACY_AGENT_DEFAULT_MODEL { return false; @@ -3402,6 +4012,7 @@ mod tests { #[test] fn fresh_agent_config_defaults_to_glm() { assert_eq!(AgentConfig::default().default_model, DEFAULT_AGENT_MODEL); + assert!(AgentConfig::default().mcp_servers.is_empty()); let config: AgentConfig = serde_json::from_value(json!({ "defaultProjectRoot": null, @@ -3409,6 +4020,186 @@ mod tests { })) .expect("legacy config without a model should deserialize"); assert_eq!(config.default_model, DEFAULT_AGENT_MODEL); + assert!(config.mcp_servers.is_empty()); + } + + fn stdio_mcp(name: &str, enabled: bool) -> AgentMcpServer { + AgentMcpServer { + name: name.to_string(), + description: "Test server".to_string(), + enabled, + timeout_seconds: 30, + transport: AgentMcpTransport::Stdio { + command: "tool --flag 'two words'".to_string(), + environment: vec![AgentMcpKeyValue { + key: "MCP_TOKEN".to_string(), + value: "super-secret-value".to_string(), + }], + }, + } + } + + #[test] + fn mcp_stdio_command_and_environment_are_frozen_in_the_session_snapshot() { + let servers = normalize_mcp_servers(vec![stdio_mcp("My Server", true)]).unwrap(); + let config = mcp_server_to_extension(&servers[0]).unwrap(); + let ExtensionConfig::Stdio { + cmd, + args, + envs, + env_keys, + .. + } = &config + else { + panic!("expected stdio extension"); + }; + assert_eq!(cmd, "tool"); + assert_eq!(args, &["--flag", "two words"]); + assert_eq!( + envs.get_env().get("MCP_TOKEN").map(String::as_str), + Some("super-secret-value") + ); + assert!(env_keys.is_empty()); + + let persisted = serde_json::to_string(&config).unwrap(); + assert!(persisted.contains("super-secret-value")); + } + + #[test] + fn mcp_stdio_command_preserves_windows_paths_and_apostrophes() { + let cases = [ + ( + r"C:\tools\mcp.exe --arg value", + r"C:\tools\mcp.exe", + vec!["--arg", "value"], + ), + ( + r#""C:\Program Files\server\mcp.exe" --arg"#, + r"C:\Program Files\server\mcp.exe", + vec!["--arg"], + ), + ( + "O'Reilly wrote don't split", + "O'Reilly", + vec!["wrote", "don't", "split"], + ), + ]; + + for (command, expected_cmd, expected_args) in cases { + let mut server = stdio_mcp("portable", true); + let AgentMcpTransport::Stdio { + command: server_command, + .. + } = &mut server.transport + else { + unreachable!(); + }; + *server_command = command.to_string(); + let server = normalize_mcp_servers(vec![server]).unwrap().remove(0); + let ExtensionConfig::Stdio { cmd, args, .. } = + mcp_server_to_extension(&server).unwrap() + else { + panic!("expected stdio extension"); + }; + assert_eq!(cmd, expected_cmd); + assert_eq!(args, expected_args); + } + } + + #[test] + fn mcp_server_names_use_goose_normalization_and_reserve_developer() { + let duplicate = normalize_mcp_servers(vec![ + stdio_mcp("My Server", true), + stdio_mcp("myserver", false), + ]) + .unwrap_err(); + assert!(duplicate.contains("conflicts")); + + let reserved = normalize_mcp_servers(vec![stdio_mcp("Developer", true)]).unwrap_err(); + assert!(reserved.contains("reserved")); + } + + #[test] + fn mcp_validation_rejects_unsafe_env_and_duplicate_headers() { + let mut unsafe_server = stdio_mcp("unsafe", true); + let AgentMcpTransport::Stdio { environment, .. } = &mut unsafe_server.transport else { + unreachable!(); + }; + environment[0].key = "NODE_OPTIONS".to_string(); + assert!(normalize_mcp_servers(vec![unsafe_server]) + .unwrap_err() + .contains("cannot override")); + + let duplicate_headers = AgentMcpServer { + name: "http".to_string(), + description: String::new(), + enabled: true, + timeout_seconds: 30, + transport: AgentMcpTransport::StreamableHttp { + url: "http://127.0.0.1:3000/mcp".to_string(), + environment: Vec::new(), + headers: vec![ + AgentMcpKeyValue { + key: "Authorization".to_string(), + value: "first".to_string(), + }, + AgentMcpKeyValue { + key: "authorization".to_string(), + value: "second".to_string(), + }, + ], + }, + }; + assert!(normalize_mcp_servers(vec![duplicate_headers]) + .unwrap_err() + .contains("duplicate HTTP header")); + } + + #[test] + fn mcp_environment_values_are_independent_between_servers() { + let first = stdio_mcp("first", true); + let mut second = stdio_mcp("second", true); + let AgentMcpTransport::Stdio { environment, .. } = &mut second.transport else { + unreachable!(); + }; + environment[0].value = "different-value".to_string(); + + assert!(normalize_mcp_servers(vec![first, second]).is_ok()); + } + + #[test] + fn malformed_agent_config_is_rejected_without_being_rewritten() { + let test_root = std::env::temp_dir().join(format!( + "maple-agent-malformed-config-{}-{}", + std::process::id(), + unix_ms() + )); + let path = test_root.join("config.json"); + let original = br#"{"defaultModel":"glm-5-2","mcpServers":[{"transport":{"type":"future_transport"}}]}"#; + fs::create_dir_all(&test_root).unwrap(); + fs::write(&path, original).unwrap(); + + assert!(load_agent_config_file(&path).is_err()); + assert_eq!(fs::read(&path).unwrap(), original); + + let _ = fs::remove_dir_all(test_root); + } + + #[test] + fn mcp_selection_distinguishes_defaults_from_explicit_empty() { + let configured = normalize_mcp_servers(vec![ + stdio_mcp("default", true), + stdio_mcp("optional", false), + ]) + .unwrap(); + assert_eq!(select_mcp_servers(&configured, None).unwrap().len(), 1); + assert!(select_mcp_servers(&configured, Some(&[])) + .unwrap() + .is_empty()); + assert_eq!( + select_mcp_servers(&configured, Some(&["optional".to_string()])).unwrap()[0].name, + "optional" + ); } #[test] @@ -3526,6 +4317,7 @@ mod tests { let mut config = AgentConfig { default_project_root: Some("/tmp/project".to_string()), default_model: LEGACY_AGENT_DEFAULT_MODEL.to_string(), + mcp_servers: Vec::new(), }; assert!(migrate_agent_config(&mut config)); @@ -3539,6 +4331,7 @@ mod tests { let mut config = AgentConfig { default_project_root: None, default_model: model.to_string(), + mcp_servers: Vec::new(), }; assert!(!migrate_agent_config(&mut config)); diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index 121aa2b3..c5e199c5 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -80,11 +80,15 @@ pub fn run() { agent::agent_restart_runtime, agent::agent_load_config, agent::agent_save_config, + agent::agent_list_mcp_servers, + agent::agent_save_mcp_servers, agent::agent_list_recent_project_roots, agent::agent_save_recent_project_root, agent::agent_create_session, agent::agent_list_sessions, agent::agent_load_session, + agent::agent_list_session_mcp_servers, + agent::agent_set_session_mcp_server_enabled, agent::agent_delete_session, agent::agent_send_message, agent::agent_cancel_run, diff --git a/frontend/src/components/AgentMode.tsx b/frontend/src/components/AgentMode.tsx index 37968987..41f49f87 100644 --- a/frontend/src/components/AgentMode.tsx +++ b/frontend/src/components/AgentMode.tsx @@ -50,17 +50,25 @@ import { Sidebar, SidebarToggle } from "@/components/Sidebar"; import { MapleWordmark } from "@/components/MapleWordmark"; import { DeleteChatDialog } from "@/components/DeleteChatDialog"; import { UpgradePromptDialog } from "@/components/UpgradePromptDialog"; +import { AgentMcpMenu, AgentMcpServersDialog } from "@/components/agent/AgentMcpControls"; import { agentRuntimeService, awaitAgentAuthUser, type AgentConfig, type AgentEventEnvelope, + type AgentMcpServer, type AgentPermissionDecision, type AgentRuntimeStatus, + type AgentSessionMcpServer, type AgentSessionSummary, type AgentTimelineItem, type RecentProjectRoot } from "@/services/agentRuntimeService"; +import { + isMcpConnectionErrorEvent, + mcpConnectionErrorMessage, + userFacingAgentError +} from "@/services/agentMcpErrors"; import { AgentProxyManualConfigConflictError, AgentProxyReplacementSetupError, @@ -230,6 +238,14 @@ export function AgentMode({ userId }: { userId: string }) { const [model, setModel] = useState(DEFAULT_MODEL); const [mode, setMode] = useState(DEFAULT_MODE); const [timelineItems, setTimelineItems] = useState([]); + const [mcpServers, setMcpServers] = useState([]); + const [newChatMcpServerNames, setNewChatMcpServerNames] = useState>(() => new Set()); + const [sessionMcpServers, setSessionMcpServers] = useState([]); + const [sessionMcpServersSessionId, setSessionMcpServersSessionId] = useState(null); + const [isMcpServersDialogOpen, setIsMcpServersDialogOpen] = useState(false); + const [isMcpServersLoading, setIsMcpServersLoading] = useState(true); + const [isSessionMcpServersLoading, setIsSessionMcpServersLoading] = useState(false); + const [isMcpServerTogglePending, setIsMcpServerTogglePending] = useState(false); const [input, setInput] = useState(""); const [error, setError] = useState(null); const [hasManualProxyConflict, setHasManualProxyConflict] = useState(false); @@ -265,6 +281,8 @@ export function AgentMode({ userId }: { userId: string }) { const startRequestGenerationRef = useRef(0); const runStateGenerationRef = useRef(0); const isAgentModelLockedRef = useRef(false); + const mcpSessionLoadGenerationRef = useRef(0); + const mcpToggleGenerationRef = useRef(0); const applyAuthoritativeMode = useCallback((value: AgentPermissionMode) => { selectedModeRef.current = value; @@ -342,6 +360,39 @@ export function AgentMode({ userId }: { userId: string }) { const isAgentModelSelectionDisabled = areAgentSettingsLocked || isAgentModelLocked; const isAgentSendLocked = areAgentSettingsLocked; const isSending = Boolean(activeRunId) || isSubmitting; + const selectedNewChatMcpServerNames = useMemo( + () => + mcpServers + .filter((server) => newChatMcpServerNames.has(server.name)) + .map((server) => server.name), + [mcpServers, newChatMcpServerNames] + ); + const composerMcpServers = useMemo( + () => + activeSessionId + ? sessionMcpServersSessionId === activeSessionId + ? sessionMcpServers + : [] + : mcpServers.map((server) => ({ + name: server.name, + description: server.description, + transport: server.transport.type, + enabled: newChatMcpServerNames.has(server.name), + available: true + })), + [ + activeSessionId, + mcpServers, + newChatMcpServerNames, + sessionMcpServers, + sessionMcpServersSessionId + ] + ); + const isMcpToggleDisabled = + areAgentSettingsLocked || Boolean(activeRunId) || isMcpServerTogglePending; + const isComposerMcpLoading = activeSessionId + ? isSessionMcpServersLoading || sessionMcpServersSessionId !== activeSessionId + : isMcpServersLoading; const runningSessionIds = useMemo(() => { const ids = new Set(Object.keys(activeRunsBySession)); for (const sessionId of pendingSendSessionIds) { @@ -574,6 +625,110 @@ export function AgentMode({ userId }: { userId: string }) { }); }, [applyRuntimeStatus, refreshSessionList, trackAgentWorkflow, userId]); + const refreshSessionMcpServers = useCallback( + async (sessionId: string) => { + const generation = mcpSessionLoadGenerationRef.current + 1; + mcpSessionLoadGenerationRef.current = generation; + setIsSessionMcpServersLoading(true); + try { + const nextServers = await agentRuntimeService.listSessionMcpServers(userId, sessionId); + if ( + mcpSessionLoadGenerationRef.current === generation && + activeSessionIdRef.current === sessionId + ) { + setSessionMcpServers(nextServers); + setSessionMcpServersSessionId(sessionId); + } + return nextServers; + } finally { + if (mcpSessionLoadGenerationRef.current === generation) { + setIsSessionMcpServersLoading(false); + } + } + }, + [userId] + ); + + const saveMcpServers = useCallback( + async (nextServers: AgentMcpServer[]) => { + const savedServers = await agentRuntimeService.saveMcpServers(userId, nextServers); + setMcpServers(savedServers); + setNewChatMcpServerNames( + new Set(savedServers.filter((server) => server.enabled).map((server) => server.name)) + ); + + const sessionId = activeSessionIdRef.current; + if (sessionId) { + void refreshSessionMcpServers(sessionId).catch((loadError) => { + if (activeSessionIdRef.current === sessionId) { + setError(errorMessage(loadError)); + } + }); + } + }, + [refreshSessionMcpServers, userId] + ); + + const toggleMcpServer = useCallback( + (name: string, enabled: boolean) => { + const sessionId = activeSessionIdRef.current; + if (!sessionId) { + setNewChatMcpServerNames((current) => { + const next = new Set(current); + if (enabled) { + next.add(name); + } else { + next.delete(name); + } + return next; + }); + return; + } + + const toggleGeneration = mcpToggleGenerationRef.current + 1; + mcpToggleGenerationRef.current = toggleGeneration; + setError(null); + setIsMcpServerTogglePending(true); + void agentRuntimeService + .setSessionMcpServerEnabled(userId, sessionId, name, enabled) + .then((nextServers) => { + if (activeSessionIdRef.current === sessionId) { + setSessionMcpServers(nextServers); + setSessionMcpServersSessionId(sessionId); + } + }) + .catch((toggleError) => { + if (activeSessionIdRef.current === sessionId) { + setError(errorMessage(toggleError)); + } + }) + .finally(() => { + if (mcpToggleGenerationRef.current === toggleGeneration) { + setIsMcpServerTogglePending(false); + } + }); + }, + [userId] + ); + + useEffect(() => { + mcpToggleGenerationRef.current += 1; + setIsMcpServerTogglePending(false); + if (!activeSessionId) { + mcpSessionLoadGenerationRef.current += 1; + setSessionMcpServers([]); + setSessionMcpServersSessionId(null); + setIsSessionMcpServersLoading(false); + return; + } + + void refreshSessionMcpServers(activeSessionId).catch((loadError) => { + if (activeSessionIdRef.current === activeSessionId) { + setError(errorMessage(loadError)); + } + }); + }, [activeSessionId, refreshSessionMcpServers]); + useEffect(() => { let cancelled = false; const initializationGeneration = interactionGenerationRef.current; @@ -582,10 +737,11 @@ export function AgentMode({ userId }: { userId: string }) { if (!isTauriDesktop()) return; try { const runStateGeneration = runStateGenerationRef.current; - const [status, config, roots] = await Promise.all([ + const [status, config, roots, savedMcpServers] = await Promise.all([ agentRuntimeService.getRuntimeStatus(userId), agentRuntimeService.loadConfig(userId), - agentRuntimeService.listRecentProjectRoots(userId) + agentRuntimeService.listRecentProjectRoots(userId), + agentRuntimeService.listMcpServers(userId) ]); if (cancelled || interactionGenerationRef.current !== initializationGeneration) { return; @@ -593,6 +749,11 @@ export function AgentMode({ userId }: { userId: string }) { applyRuntimeStatus(status, runStateGeneration); setRecentRoots(roots); + setMcpServers(savedMcpServers); + setNewChatMcpServerNames( + new Set(savedMcpServers.filter((server) => server.enabled).map((server) => server.name)) + ); + setIsMcpServersLoading(false); const root = config.defaultProjectRoot || status.projectRoot || roots[0]?.path || ""; const nextModel = status.model || config.defaultModel || DEFAULT_MODEL; const nextMode = normalizeAgentPermissionMode(status.mode); @@ -652,7 +813,10 @@ export function AgentMode({ userId }: { userId: string }) { } }) .finally(() => { - if (!cancelled) setIsInitializing(false); + if (!cancelled) { + setIsInitializing(false); + setIsMcpServersLoading(false); + } }); return () => { cancelled = true; @@ -910,7 +1074,8 @@ export function AgentMode({ userId }: { userId: string }) { projectRoot, title: "New agent session", model: model || DEFAULT_MODEL, - mode: selectedModeRef.current + mode: selectedModeRef.current, + mcpServerNames: selectedNewChatMcpServerNames }); // Goose may reuse the newest deleted session ID. This detail represents // a new persisted session, so it supersedes any local deletion tombstone. @@ -934,12 +1099,22 @@ export function AgentMode({ userId }: { userId: string }) { setActiveSessionId(sessionId); applyAuthoritativeMode(normalizeAgentPermissionMode(detail.session.mode)); replaceSessionTimeline(sessionId, detail.timeline); + const mcpError = mcpConnectionErrorMessage(detail.mcpErrors); + if (mcpError) setError(mcpError); } } return sessionId; }, - [applyAuthoritativeMode, model, projectRoot, replaceSessionTimeline, startRuntime, userId] + [ + applyAuthoritativeMode, + model, + projectRoot, + replaceSessionTimeline, + selectedNewChatMcpServerNames, + startRuntime, + userId + ] ); const createSession = useCallback(async () => { @@ -956,7 +1131,8 @@ export function AgentMode({ userId }: { userId: string }) { projectRoot, title: "New agent session", model: model || DEFAULT_MODEL, - mode: selectedModeRef.current + mode: selectedModeRef.current, + mcpServerNames: selectedNewChatMcpServerNames }); }); deletedSessionIdsRef.current.delete(detail.session.id); @@ -975,6 +1151,8 @@ export function AgentMode({ userId }: { userId: string }) { setActiveSessionId(detail.session.id); applyAuthoritativeMode(normalizeAgentPermissionMode(detail.session.mode)); replaceSessionTimeline(detail.session.id, detail.timeline); + const mcpError = mcpConnectionErrorMessage(detail.mcpErrors); + if (mcpError) setError(mcpError); } } catch (createError) { if ( @@ -994,6 +1172,7 @@ export function AgentMode({ userId }: { userId: string }) { projectRoot, replaceSessionTimeline, runtimeStatus?.running, + selectedNewChatMcpServerNames, startRuntime, trackAgentWorkflow, userId @@ -1044,6 +1223,8 @@ export function AgentMode({ userId }: { userId: string }) { } applyAuthoritativeMode(normalizeAgentPermissionMode(detail.session.mode)); setTimelineItems(detail.timeline); + const mcpError = mcpConnectionErrorMessage(detail.mcpErrors); + if (mcpError) setError(mcpError); finishSessionSelection(selectionGeneration); try { @@ -1379,7 +1560,11 @@ export function AgentMode({ userId }: { userId: string }) { } case "error": if (event.message && !event.sessionId) { - setError(event.message); + setError(userFacingAgentError(event.message)); + const sessionId = activeSessionIdRef.current; + if (sessionId && isMcpConnectionErrorEvent(event.message)) { + void refreshSessionMcpServers(sessionId).catch(() => {}); + } } if (event.item && event.sessionId) { mergeSessionTimelineItem(event.sessionId, event.item); @@ -1413,6 +1598,7 @@ export function AgentMode({ userId }: { userId: string }) { mergeSessionTimelineItem, refreshSessionList, recordActiveRun, + refreshSessionMcpServers, replaceSessionTimeline, upsertSessionSummary, userId @@ -1503,6 +1689,14 @@ export function AgentMode({ userId }: { userId: string }) { /> ) : null} + +
{!isSidebarOpen && (
@@ -1576,7 +1770,10 @@ export function AgentMode({ userId }: { userId: string }) { isSendDisabled={isAgentSendLocked} isSending={isSending} isStarting={isStarting} + isMcpLoading={isComposerMcpLoading} + isMcpToggleDisabled={isMcpToggleDisabled} isModelSelectionDisabled={isAgentModelSelectionDisabled} + mcpServers={composerMcpServers} mode={mode} model={model} projectRoot={projectRoot} @@ -1585,6 +1782,8 @@ export function AgentMode({ userId }: { userId: string }) { onChooseProjectRoot={chooseProjectRoot} onInputChange={setInput} onKeyDown={handleKeyDown} + onManageMcpServers={() => setIsMcpServersDialogOpen(true)} + onMcpToggle={toggleMcpServer} onModeChange={selectMode} onModelChange={selectModel} onProjectRootChange={selectProjectRoot} @@ -1611,7 +1810,10 @@ export function AgentMode({ userId }: { userId: string }) { isSendDisabled={isAgentSendLocked} isSending={isSending} isStarting={isStarting} + isMcpLoading={isComposerMcpLoading} + isMcpToggleDisabled={isMcpToggleDisabled} isModelSelectionDisabled={isAgentModelSelectionDisabled} + mcpServers={composerMcpServers} mode={mode} model={model} projectRoot={projectRoot} @@ -1620,6 +1822,8 @@ export function AgentMode({ userId }: { userId: string }) { onChooseProjectRoot={chooseProjectRoot} onInputChange={setInput} onKeyDown={handleKeyDown} + onManageMcpServers={() => setIsMcpServersDialogOpen(true)} + onMcpToggle={toggleMcpServer} onModeChange={selectMode} onModelChange={selectModel} onProjectRootChange={selectProjectRoot} @@ -2002,7 +2206,10 @@ interface AgentComposerProps { isSendDisabled: boolean; isSending: boolean; isStarting: boolean; + isMcpLoading: boolean; + isMcpToggleDisabled: boolean; isModelSelectionDisabled: boolean; + mcpServers: AgentSessionMcpServer[]; mode: AgentPermissionMode; model: string; projectRoot: string; @@ -2011,6 +2218,8 @@ interface AgentComposerProps { onChooseProjectRoot: () => void; onInputChange: (value: string) => void; onKeyDown: (event: React.KeyboardEvent) => void; + onManageMcpServers: () => void; + onMcpToggle: (name: string, enabled: boolean) => void; onModeChange: (value: AgentPermissionMode) => void; onModelChange: (value: string) => void; onProjectRootChange: (value: string) => void; @@ -2024,7 +2233,10 @@ function AgentComposer({ isSendDisabled, isSending, isStarting, + isMcpLoading, + isMcpToggleDisabled, isModelSelectionDisabled, + mcpServers, mode, model, projectRoot, @@ -2033,6 +2245,8 @@ function AgentComposer({ onChooseProjectRoot, onInputChange, onKeyDown, + onManageMcpServers, + onMcpToggle, onModeChange, onModelChange, onProjectRootChange, @@ -2071,6 +2285,15 @@ function AgentComposer({ onModeChange={onModeChange} /> + + setQuery(event.target.value)} + onClick={(event) => event.stopPropagation()} + placeholder="Search servers" + aria-label="Search MCP servers" + className="h-8 pl-8" + /> +
+ +
+ {loading ? ( +

Loading MCP servers…

+ ) : filteredServers.length === 0 ? ( +

+ {servers.length === 0 ? "No MCP servers configured." : "No matching servers."} +

+ ) : ( + filteredServers.map((server) => ( + onToggle(server.name, checked === true)} + onSelect={(event) => event.preventDefault()} + className="items-start" + > +
+

{server.name}

+

+ {!server.available + ? "No longer available in your saved MCP servers" + : server.description || transportLabel(server.transport)} +

+
+
+ )) + )} +
+ + + + + Manage MCP servers… + + + + ); +} + +export function AgentMcpServersDialog({ + open, + servers, + disabled, + onOpenChange, + onSave +}: { + open: boolean; + servers: AgentMcpServer[]; + disabled: boolean; + onOpenChange: (open: boolean) => void; + onSave: (servers: AgentMcpServer[]) => Promise; +}) { + const [formServer, setFormServer] = useState(null); + const [editingIndex, setEditingIndex] = useState(null); + const [formBaseline, setFormBaseline] = useState(null); + const [isSaving, setIsSaving] = useState(false); + const [formError, setFormError] = useState(null); + const [pendingDiscardAction, setPendingDiscardAction] = useState( + null + ); + const [pendingDeleteIndex, setPendingDeleteIndex] = useState(null); + + useEffect(() => { + if (!open) { + setFormServer(null); + setFormBaseline(null); + setEditingIndex(null); + setFormError(null); + setPendingDiscardAction(null); + setPendingDeleteIndex(null); + } + }, [open]); + + const enabledServers = servers.filter((server) => server.enabled); + const availableServers = servers.filter((server) => !server.enabled); + const formIsDirty = + formServer !== null && + formBaseline !== null && + JSON.stringify(formServer) !== JSON.stringify(formBaseline); + + const resetForm = () => { + setFormServer(null); + setFormBaseline(null); + setEditingIndex(null); + setFormError(null); + }; + + const requestCloseForm = () => { + if (formIsDirty) { + setPendingDiscardAction("close_form"); + return; + } + resetForm(); + }; + + const discardForm = () => { + const action = pendingDiscardAction; + setPendingDiscardAction(null); + resetForm(); + if (action === "close_dialog") onOpenChange(false); + }; + + const persist = async (nextServers: AgentMcpServer[]) => { + setFormError(null); + setIsSaving(true); + try { + await onSave(nextServers); + } catch (error) { + setFormError(error instanceof Error ? error.message : String(error)); + throw error; + } finally { + setIsSaving(false); + } + }; + + const beginAdd = () => { + const server = newMcpServer(); + setEditingIndex(null); + setFormError(null); + setFormBaseline(cloneServer(server)); + setFormServer(server); + }; + + const beginEdit = (index: number) => { + setEditingIndex(index); + setFormError(null); + const server = cloneServer(servers[index]); + setFormBaseline(cloneServer(server)); + setFormServer(server); + }; + + const saveForm = async () => { + if (!formServer) return; + const validationError = validateServer(formServer, servers, editingIndex); + if (validationError) { + setFormError(validationError); + return; + } + + const normalized = normalizeServer(formServer); + const nextServers = + editingIndex === null + ? [...servers, normalized] + : servers.map((server, index) => (index === editingIndex ? normalized : server)); + try { + await persist(nextServers); + resetForm(); + } catch { + // The inline error keeps the form and its values available for correction. + } + }; + + const toggleDefault = async (index: number, enabled: boolean) => { + try { + await persist( + servers.map((server, candidateIndex) => + candidateIndex === index ? { ...server, enabled } : server + ) + ); + } catch { + // The inline error is enough; the authoritative list remains unchanged. + } + }; + + const deleteServer = (index: number) => setPendingDeleteIndex(index); + + const confirmDeleteServer = async () => { + if (pendingDeleteIndex === null) return; + const index = pendingDeleteIndex; + setPendingDeleteIndex(null); + try { + await persist(servers.filter((_, candidateIndex) => candidateIndex !== index)); + } catch { + // The inline error is enough; the authoritative list remains unchanged. + } + }; + + return ( + <> + { + if (isSaving) return; + if (!nextOpen && formIsDirty) { + setPendingDiscardAction("close_dialog"); + return; + } + onOpenChange(nextOpen); + }} + > + + {formServer ? ( + void saveForm()} + /> + ) : ( +
+ + MCP servers + + Add tools for Agent Mode over Standard IO or Streamable HTTP. Default servers are + used for future chats; existing chats keep their own selection. + + + +
+ {formError ? ( +

+ {formError} +

+ ) : null} + + void toggleDefault(index, enabled)} + /> + + void toggleDefault(index, enabled)} + /> +
+ + + + +
+ )} +
+
+ + !nextOpen && setPendingDiscardAction(null)} + > + + + Discard unsaved MCP changes? + + The values entered in this server form have not been saved. + + + + Keep editing + Discard changes + + + + + !nextOpen && setPendingDeleteIndex(null)} + > + + + Delete this MCP server? + + {pendingDeleteIndex === null + ? "This server will be removed from future chats." + : `“${servers[pendingDeleteIndex]?.name}” will be removed from future chats. Existing chats keep their current selection.`} + + + + Cancel + void confirmDeleteServer()} + > + Delete server + + + + + + ); +} + +function McpServerGroup({ + title, + description, + servers, + allServers, + disabled, + onEdit, + onDelete, + onToggle +}: { + title: string; + description: string; + servers: AgentMcpServer[]; + allServers: AgentMcpServer[]; + disabled: boolean; + onEdit: (index: number) => void; + onDelete: (index: number) => void; + onToggle: (index: number, enabled: boolean) => void; +}) { + return ( +
+
+

{title}

+

{description}

+
+ {servers.length === 0 ? ( +
+ No servers in this group. +
+ ) : ( +
+ {servers.map((server) => { + const index = allServers.indexOf(server); + const TransportIcon = server.transport.type === "stdio" ? Terminal : Globe2; + return ( +
+
+ +
+
+

{server.name}

+

+ {server.description || transportLabel(server.transport.type)} +

+
+ onToggle(index, enabled)} + disabled={disabled} + aria-label={`${server.enabled ? "Disable" : "Enable"} ${server.name} by default`} + /> + + +
+ ); + })} +
+ )} +
+ ); +} + +function McpServerForm({ + server, + isEditing, + disabled, + error, + onChange, + onCancel, + onSave +}: { + server: AgentMcpServer; + isEditing: boolean; + disabled: boolean; + error: string | null; + onChange: (server: AgentMcpServer) => void; + onCancel: () => void; + onSave: () => void; +}) { + const transport = server.transport; + const idPrefix = useId(); + const nameId = `${idPrefix}-name`; + const transportId = `${idPrefix}-transport`; + const descriptionId = `${idPrefix}-description`; + const commandId = `${idPrefix}-command`; + const endpointId = `${idPrefix}-endpoint`; + const timeoutId = `${idPrefix}-timeout`; + const setTransportType = (type: "stdio" | "streamable_http") => { + const environment = transport.environment; + onChange({ + ...server, + transport: + type === "stdio" + ? { type, command: "", environment } + : { type, url: "", environment, headers: [] } + }); + }; + + return ( +
{ + event.preventDefault(); + onSave(); + }} + > + + {isEditing ? "Edit MCP server" : "Add MCP server"} + + Maple saves this configuration without testing the connection. It will connect when a chat + enables the server. + + + +
+ {error ? ( +

+ {error} +

+ ) : null} + +
+ + onChange({ ...server, name: event.target.value })} + placeholder="My server" + disabled={disabled} + autoFocus + /> + + + + +
+ + + onChange({ ...server, description: event.target.value })} + placeholder="What this server helps the agent do" + disabled={disabled} + /> + + + {transport.type === "stdio" ? ( + + + onChange({ + ...server, + transport: { ...transport, command: event.target.value } + }) + } + placeholder="npx -y @modelcontextprotocol/server-everything stdio" + disabled={disabled} + spellCheck={false} + /> + + ) : ( + + + onChange({ + ...server, + transport: { ...transport, url: event.target.value } + }) + } + placeholder="http://127.0.0.1:3000/mcp" + disabled={disabled} + spellCheck={false} + /> + + )} + + + + onChange({ ...server, timeoutSeconds: Number(event.target.value) }) + } + disabled={disabled} + className="max-w-40" + /> + + + + onChange({ + ...server, + transport: { ...transport, environment } + }) + } + /> + + {transport.type === "streamable_http" ? ( + + onChange({ + ...server, + transport: { ...transport, headers } + }) + } + /> + ) : null} + +

+ Values are masked here and saved in this account’s local Agent data, including each chat’s + selected server snapshot. OAuth and legacy SSE transports are not supported in this first + version. +

+
+ + + + + +
+ ); +} + +function KeyValueFields({ + title, + pairs, + disabled, + keyPlaceholder = "VARIABLE_NAME", + onChange +}: { + title: string; + pairs: AgentMcpKeyValue[]; + disabled: boolean; + keyPlaceholder?: string; + onChange: (pairs: AgentMcpKeyValue[]) => void; +}) { + return ( +
+
+ + +
+ {pairs.length === 0 ? ( +

+ None configured. +

+ ) : ( +
+ {pairs.map((pair, index) => ( +
+ + onChange( + pairs.map((candidate, candidateIndex) => + candidateIndex === index + ? { ...candidate, key: event.target.value } + : candidate + ) + ) + } + placeholder={keyPlaceholder} + aria-label={`${title} key ${index + 1}`} + disabled={disabled} + spellCheck={false} + /> + + onChange( + pairs.map((candidate, candidateIndex) => + candidateIndex === index + ? { ...candidate, value: event.target.value } + : candidate + ) + ) + } + placeholder="Value" + aria-label={`${title} value ${index + 1}`} + disabled={disabled} + autoComplete="off" + /> + +
+ ))} +
+ )} +
+ ); +} + +function Field({ + label, + controlId, + hint, + required, + children +}: { + label: string; + controlId: string; + hint?: string; + required?: boolean; + children: ReactNode; +}) { + return ( +
+
+ + {hint ? {hint} : null} +
+ {children} +
+ ); +} + +function newMcpServer(): AgentMcpServer { + return { + name: "", + description: "", + enabled: true, + timeoutSeconds: DEFAULT_TIMEOUT_SECONDS, + transport: { type: "stdio", command: "", environment: [] } + }; +} + +function cloneServer(server: AgentMcpServer): AgentMcpServer { + return { + ...server, + transport: + server.transport.type === "stdio" + ? { + ...server.transport, + environment: server.transport.environment.map((pair) => ({ ...pair })) + } + : { + ...server.transport, + environment: server.transport.environment.map((pair) => ({ ...pair })), + headers: server.transport.headers.map((pair) => ({ ...pair })) + } + }; +} + +function normalizeServer(server: AgentMcpServer): AgentMcpServer { + const environment = normalizePairs(server.transport.environment); + return { + ...server, + name: server.name.trim(), + description: server.description.trim(), + timeoutSeconds: Math.floor(server.timeoutSeconds), + transport: + server.transport.type === "stdio" + ? { ...server.transport, command: server.transport.command.trim(), environment } + : { + ...server.transport, + url: server.transport.url.trim(), + environment, + headers: normalizePairs(server.transport.headers) + } + }; +} + +function normalizePairs(pairs: AgentMcpKeyValue[]): AgentMcpKeyValue[] { + return pairs + .filter((pair) => pair.key.trim() || pair.value) + .map((pair) => ({ key: pair.key.trim(), value: pair.value })); +} + +function validateServer( + server: AgentMcpServer, + servers: AgentMcpServer[], + editingIndex: number | null +): string | null { + const name = server.name.trim(); + if (!name) return "Enter a server name."; + if ( + servers.some( + (candidate, index) => + index !== editingIndex && candidate.name.trim().toLowerCase() === name.toLowerCase() + ) + ) { + return "Another MCP server already uses that name."; + } + if (!Number.isFinite(server.timeoutSeconds) || server.timeoutSeconds <= 0) { + return "Timeout must be a positive number of seconds."; + } + if (server.transport.type === "stdio" && !server.transport.command.trim()) { + return "Enter the command used to start this STDIO server."; + } + if (server.transport.type === "streamable_http" && !server.transport.url.trim()) { + return "Enter the Streamable HTTP endpoint URL."; + } + const pairs = [ + ...server.transport.environment, + ...(server.transport.type === "streamable_http" ? server.transport.headers : []) + ]; + if (pairs.some((pair) => !pair.key.trim() && Boolean(pair.value))) { + return "Every environment variable and HTTP header value needs a key."; + } + return null; +} + +function transportLabel(transport: "stdio" | "streamable_http"): string { + return transport === "stdio" ? "Standard IO (STDIO)" : "Streamable HTTP"; +} diff --git a/frontend/src/services/agentMcpErrors.test.ts b/frontend/src/services/agentMcpErrors.test.ts new file mode 100644 index 00000000..b5620a77 --- /dev/null +++ b/frontend/src/services/agentMcpErrors.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "bun:test"; +import { + conciseMcpError, + isMcpConnectionErrorEvent, + mcpConnectionErrorMessage, + userFacingAgentError +} from "./agentMcpErrors"; + +const RAW_HTTP_ERROR = + "failed to initialize MCP client: Send message error Transport [rmcp::transport::worker::WorkerTransport>] error: Client error: error sending request for url (http://127.0.0.1:33001/mcp), when send initialize request"; + +describe("MCP connection errors", () => { + test("replaces HTTP transport internals with an actionable message", () => { + const message = conciseMcpError(RAW_HTTP_ERROR); + + expect(message).toBe( + "Could not reach the Streamable HTTP endpoint. Check that the server is running and the URL is correct." + ); + expect(message).not.toContain("rmcp"); + expect(message).not.toContain("WorkerTransport"); + }); + + test("keeps the server name in structured session errors", () => { + const message = mcpConnectionErrorMessage([{ name: "fixture_http", error: RAW_HTTP_ERROR }]); + + expect(message).toContain("fixture_http"); + expect(message).toContain("Could not reach the Streamable HTTP endpoint"); + expect(message).not.toContain("rmcp"); + }); + + test("sanitizes the unstructured runtime event path", () => { + const message = userFacingAgentError( + `Some MCP servers could not connect: fixture_http: ${RAW_HTTP_ERROR}` + ); + + expect(message).toContain("Could not reach the Streamable HTTP endpoint"); + expect(message).not.toContain("rmcp"); + expect(message).not.toContain("WorkerTransport"); + }); + + test("leaves unrelated Agent errors unchanged", () => { + expect(userFacingAgentError("The selected model is unavailable")).toBe( + "The selected model is unavailable" + ); + expect(isMcpConnectionErrorEvent("The selected model is unavailable")).toBe(false); + }); +}); diff --git a/frontend/src/services/agentMcpErrors.ts b/frontend/src/services/agentMcpErrors.ts new file mode 100644 index 00000000..65a07447 --- /dev/null +++ b/frontend/src/services/agentMcpErrors.ts @@ -0,0 +1,69 @@ +import type { AgentMcpConnectionError } from "./agentRuntimeService"; + +const HTTP_UNREACHABLE_MESSAGE = + "Could not reach the Streamable HTTP endpoint. Check that the server is running and the URL is correct."; +const AUTHENTICATION_MESSAGE = + "The server rejected the connection. Check its authentication headers."; +const TIMEOUT_MESSAGE = "The connection timed out. Check that the server is running and reachable."; +const COMMAND_MESSAGE = + "Could not start the STDIO command. Check the executable path and arguments."; +const GENERIC_CONNECTION_MESSAGE = + "Connection failed. Check the server configuration and Maple logs."; +const MCP_EVENT_PREFIX = "Some MCP servers could not connect:"; + +export function mcpConnectionErrorMessage( + errors?: AgentMcpConnectionError[] | null +): string | null { + if (!errors?.length) return null; + const visibleErrors = errors + .slice(0, 3) + .map(({ name, error }) => `${name}: ${conciseMcpError(error)}`); + const remainingCount = errors.length - visibleErrors.length; + return `Some MCP servers could not connect: ${visibleErrors.join("; ")}${ + remainingCount > 0 ? `; and ${remainingCount} more` : "" + }`; +} + +export function userFacingAgentError(message: string): string { + const singleLine = message.replace(/\s+/g, " ").trim(); + if (!isMcpConnectionErrorEvent(singleLine)) return message; + + const detail = singleLine.slice(MCP_EVENT_PREFIX.length).trim(); + return `Some MCP servers could not connect. ${conciseMcpError(detail)}`; +} + +export function isMcpConnectionErrorEvent(message: string): boolean { + return message.replace(/\s+/g, " ").trim().startsWith(MCP_EVENT_PREFIX); +} + +export function conciseMcpError(error: string): string { + const singleLine = error.replace(/\s+/g, " ").trim(); + const normalized = singleLine.toLowerCase(); + + if (/\b(?:401|403)\b|unauthori[sz]ed|forbidden/.test(normalized)) { + return AUTHENTICATION_MESSAGE; + } + if (/timed? out|timeout/.test(normalized)) { + return TIMEOUT_MESSAGE; + } + if ( + /no such file or directory|failed to spawn|unable to spawn|executable (?:was )?not found/.test( + normalized + ) + ) { + return COMMAND_MESSAGE; + } + if ( + /error sending request for url|connection refused|failed to connect|tcp connect|connection reset/.test( + normalized + ) + ) { + return HTTP_UNREACHABLE_MESSAGE; + } + + const cleaned = singleLine.replace(/^failed to initialize mcp client:\s*/i, ""); + if (!cleaned || /rmcp::|workertransport|transport\s*\[/i.test(cleaned)) { + return GENERIC_CONNECTION_MESSAGE; + } + return cleaned.length > 180 ? `${cleaned.slice(0, 177)}…` : cleaned; +} diff --git a/frontend/src/services/agentRuntimeService.ts b/frontend/src/services/agentRuntimeService.ts index ecd2aa04..142e832c 100644 --- a/frontend/src/services/agentRuntimeService.ts +++ b/frontend/src/services/agentRuntimeService.ts @@ -7,6 +7,45 @@ export interface AgentConfig { defaultModel: string; } +export interface AgentMcpKeyValue { + key: string; + value: string; +} + +export type AgentMcpTransport = + | { + type: "stdio"; + command: string; + environment: AgentMcpKeyValue[]; + } + | { + type: "streamable_http"; + url: string; + environment: AgentMcpKeyValue[]; + headers: AgentMcpKeyValue[]; + }; + +export interface AgentMcpServer { + name: string; + description: string; + enabled: boolean; + timeoutSeconds: number; + transport: AgentMcpTransport; +} + +export interface AgentMcpConnectionError { + name: string; + error: string; +} + +export interface AgentSessionMcpServer { + name: string; + description: string; + transport: "stdio" | "streamable_http"; + enabled: boolean; + available: boolean; +} + export interface AgentStartRequest { projectRoot?: string | null; model?: string | null; @@ -32,6 +71,7 @@ export interface AgentCreateSessionRequest { title?: string | null; model?: string | null; mode?: string | null; + mcpServerNames?: string[] | null; } export interface AgentSessionSummary { @@ -61,6 +101,7 @@ export interface AgentTimelineItem { export interface AgentSessionDetail { session: AgentSessionSummary; timeline: AgentTimelineItem[]; + mcpErrors: AgentMcpConnectionError[]; } export interface AgentSendMessageRequest { @@ -117,6 +158,38 @@ class AgentRuntimeService { await this.invokeForUser(userId, "agent_save_config", { userId, config }); } + async listMcpServers(userId: string): Promise { + return await this.invokeForUser(userId, "agent_list_mcp_servers"); + } + + async saveMcpServers(userId: string, servers: AgentMcpServer[]): Promise { + return await this.invokeForUser(userId, "agent_save_mcp_servers", { + userId, + servers + }); + } + + async listSessionMcpServers(userId: string, sessionId: string): Promise { + return await this.invokeForUser( + userId, + "agent_list_session_mcp_servers", + { userId, sessionId } + ); + } + + async setSessionMcpServerEnabled( + userId: string, + sessionId: string, + name: string, + enabled: boolean + ): Promise { + return await this.invokeForUser( + userId, + "agent_set_session_mcp_server_enabled", + { userId, request: { sessionId, name, enabled } } + ); + } + async listRecentProjectRoots(userId: string): Promise { return await this.invokeForUser(userId, "agent_list_recent_project_roots"); } From 93138b40798cfda577bab2557eae9f958aeef033 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:51:01 +0000 Subject: [PATCH 2/3] fix(agent): harden MCP configuration handling --- frontend/src-tauri/src/agent.rs | 122 ++++++++++++++++-- frontend/src/components/AgentMode.tsx | 8 +- .../src/components/agent/AgentMcpControls.tsx | 11 +- frontend/src/services/agentMcpErrors.test.ts | 32 +++++ frontend/src/services/agentMcpServers.test.ts | 76 +++++++++++ frontend/src/services/agentMcpServers.ts | 48 +++++++ 6 files changed, 278 insertions(+), 19 deletions(-) create mode 100644 frontend/src/services/agentMcpServers.test.ts create mode 100644 frontend/src/services/agentMcpServers.ts diff --git a/frontend/src-tauri/src/agent.rs b/frontend/src-tauri/src/agent.rs index 973300a4..abdc29a9 100644 --- a/frontend/src-tauri/src/agent.rs +++ b/frontend/src-tauri/src/agent.rs @@ -63,6 +63,10 @@ const DEFAULT_AGENT_SESSION_TITLE: &str = "New agent session"; const DEFAULT_MCP_TIMEOUT_SECONDS: u64 = 300; const MAX_AGENT_SESSION_TITLE_CHARS: usize = 80; const MAX_AGENT_ERROR_CHARS: usize = 1_200; +const MAX_MCP_CONNECTION_ERRORS: usize = 3; +const MAX_MCP_SERVER_NAME_CHARS: usize = 64; +const MAX_MCP_CONNECTION_ERROR_CHARS: usize = 200; +const MCP_CONNECTION_ERROR_PREFIX: &str = "Some MCP servers could not connect:"; static NEXT_RUN_ID: AtomicU64 = AtomicU64::new(1); fn validate_session_model_lock( @@ -1001,6 +1005,7 @@ pub async fn agent_create_session( .iter() .map(mcp_server_to_extension) .collect::, _>>()?; + let selected_extension_keys = mcp_extension_keys(&selected_extensions); let session = session_manager .create_session(root.clone(), title, SessionType::User, permission_mode) .await @@ -1024,7 +1029,9 @@ pub async fn agent_create_session( .add_extensions_bulk(selected_extensions, &session.id) .await { - Ok(results) => mcp_errors.extend(mcp_connection_errors(results)), + Ok(results) => { + mcp_errors.extend(mcp_connection_errors(results, &selected_extension_keys)) + } Err(error) => mcp_errors.push(AgentMcpConnectionError { name: "MCP servers".to_string(), error: error.to_string(), @@ -1212,10 +1219,11 @@ pub async fn agent_set_session_mcp_server_enabled( .map_err(|error| format!("Failed to load MCP servers: {error}"))? .mcp_servers, )?; - let _session = session_manager + let session = session_manager .get_session(&session_id, false) .await .map_err(|error| format!("Failed to load Goose session: {error}"))?; + let session_mcp_keys = session_mcp_extension_keys(&session); let manager_result = agent_manager .get_or_create_agent_with_runtime_context( session_id.clone(), @@ -1223,7 +1231,7 @@ pub async fn agent_set_session_mcp_server_enabled( ) .await .map_err(|error| format!("Failed to load Goose agent: {error}"))?; - for error in mcp_connection_errors(manager_result.extension_results) { + for error in mcp_connection_errors(manager_result.extension_results, &session_mcp_keys) { log::warn!( "Failed to restore MCP server {}: {}", error.name, @@ -2575,6 +2583,7 @@ async fn configure_session_agent( mode: &str, primary_model_supports_vision: bool, ) -> Result<(Arc, Vec), String> { + let session_mcp_keys = session_mcp_extension_keys(session); let manager_result = agent_manager .get_or_create_agent_with_runtime_context( session.id.clone(), @@ -2583,7 +2592,7 @@ async fn configure_session_agent( .await .map_err(|e| format!("Failed to get Goose agent for session {}: {e}", session.id))?; let agent = manager_result.agent; - let mcp_errors = mcp_connection_errors(manager_result.extension_results); + let mcp_errors = mcp_connection_errors(manager_result.extension_results, &session_mcp_keys); let provider = goose::providers::create_with_working_dir( "openai", Vec::new(), @@ -3496,7 +3505,7 @@ fn normalize_mcp_servers(mut servers: Vec) -> Result 64 { + if server.name.chars().count() > MAX_MCP_SERVER_NAME_CHARS { return Err(format!( "MCP server name '{}' must be 64 characters or fewer", server.name @@ -3704,13 +3713,30 @@ fn select_mcp_servers( Ok(selected) } +fn mcp_extension_keys(configs: &[ExtensionConfig]) -> HashSet { + configs + .iter() + .filter(|config| mcp_transport_label(config).is_some()) + .map(ExtensionConfig::key) + .collect() +} + +fn session_mcp_extension_keys(session: &Session) -> HashSet { + goose::session::EnabledExtensionsState::from_extension_data(&session.extension_data) + .map(|state| mcp_extension_keys(&state.extensions)) + .unwrap_or_default() +} + fn mcp_connection_errors( results: Vec, + mcp_keys: &HashSet, ) -> Vec { results .into_iter() .filter_map(|result| { - (!result.success).then(|| AgentMcpConnectionError { + (!result.success + && mcp_keys.contains(&goose::config::extensions::name_to_key(&result.name))) + .then(|| AgentMcpConnectionError { name: result.name, error: result .error @@ -3721,12 +3747,25 @@ fn mcp_connection_errors( } fn format_mcp_connection_errors(errors: &[AgentMcpConnectionError]) -> String { - let details = errors + let mut details = errors .iter() - .map(|error| format!("{}: {}", error.name, error.error)) - .collect::>() - .join("; "); - format!("Some MCP servers could not connect: {details}") + .take(MAX_MCP_CONNECTION_ERRORS) + .map(|error| { + format!( + "{}: {}", + bounded_timeline_text(&error.name, MAX_MCP_SERVER_NAME_CHARS), + bounded_timeline_text(&error.error, MAX_MCP_CONNECTION_ERROR_CHARS) + ) + }) + .collect::>(); + let remaining = errors.len().saturating_sub(details.len()); + if remaining > 0 { + details.push(format!("and {remaining} more")); + } + bounded_timeline_text( + &format!("{MCP_CONNECTION_ERROR_PREFIX} {}", details.join("; ")), + MAX_AGENT_ERROR_CHARS, + ) } fn mcp_transport_label(config: &ExtensionConfig) -> Option<&'static str> { @@ -4167,6 +4206,67 @@ mod tests { assert!(normalize_mcp_servers(vec![first, second]).is_ok()); } + #[test] + fn mcp_connection_errors_exclude_non_mcp_extension_failures() { + let mcp_keys = HashSet::from(["fixturestdio".to_string()]); + let errors = mcp_connection_errors( + vec![ + goose::agents::ExtensionLoadResult { + name: "developer".to_string(), + success: false, + error: Some("built-in failed".to_string()), + }, + goose::agents::ExtensionLoadResult { + name: "Fixture STDIO".to_string(), + success: false, + error: Some("server failed".to_string()), + }, + goose::agents::ExtensionLoadResult { + name: "fixture_stdio".to_string(), + success: true, + error: None, + }, + ], + &mcp_keys, + ); + + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].name, "Fixture STDIO"); + assert_eq!(errors[0].error, "server failed"); + } + + #[test] + fn mcp_connection_error_events_are_bounded() { + let short = format_mcp_connection_errors(&[ + AgentMcpConnectionError { + name: "first".to_string(), + error: "one".to_string(), + }, + AgentMcpConnectionError { + name: "second".to_string(), + error: "two".to_string(), + }, + ]); + assert_eq!( + short, + "Some MCP servers could not connect: first: one; second: two" + ); + + let many = (0..5) + .map(|index| AgentMcpConnectionError { + name: format!("server-{index}"), + error: "🪿".repeat(MAX_MCP_CONNECTION_ERROR_CHARS + 50), + }) + .collect::>(); + let bounded = format_mcp_connection_errors(&many); + assert!(bounded.contains("server-0")); + assert!(bounded.contains("server-2")); + assert!(!bounded.contains("server-3")); + assert!(bounded.contains("and 2 more")); + assert!(bounded.contains('…')); + assert!(bounded.chars().count() <= MAX_AGENT_ERROR_CHARS); + } + #[test] fn malformed_agent_config_is_rejected_without_being_rewritten() { let test_root = std::env::temp_dir().join(format!( diff --git a/frontend/src/components/AgentMode.tsx b/frontend/src/components/AgentMode.tsx index 41f49f87..e8f9d7f1 100644 --- a/frontend/src/components/AgentMode.tsx +++ b/frontend/src/components/AgentMode.tsx @@ -69,6 +69,7 @@ import { mcpConnectionErrorMessage, userFacingAgentError } from "@/services/agentMcpErrors"; +import { reconcileNewChatMcpServerNames } from "@/services/agentMcpServers"; import { AgentProxyManualConfigConflictError, AgentProxyReplacementSetupError, @@ -651,10 +652,11 @@ export function AgentMode({ userId }: { userId: string }) { const saveMcpServers = useCallback( async (nextServers: AgentMcpServer[]) => { + const previousServers = mcpServers; const savedServers = await agentRuntimeService.saveMcpServers(userId, nextServers); setMcpServers(savedServers); - setNewChatMcpServerNames( - new Set(savedServers.filter((server) => server.enabled).map((server) => server.name)) + setNewChatMcpServerNames((current) => + reconcileNewChatMcpServerNames(previousServers, savedServers, current) ); const sessionId = activeSessionIdRef.current; @@ -666,7 +668,7 @@ export function AgentMode({ userId }: { userId: string }) { }); } }, - [refreshSessionMcpServers, userId] + [mcpServers, refreshSessionMcpServers, userId] ); const toggleMcpServer = useCallback( diff --git a/frontend/src/components/agent/AgentMcpControls.tsx b/frontend/src/components/agent/AgentMcpControls.tsx index 7525b459..f7c4a736 100644 --- a/frontend/src/components/agent/AgentMcpControls.tsx +++ b/frontend/src/components/agent/AgentMcpControls.tsx @@ -43,6 +43,7 @@ import type { AgentMcpServer, AgentSessionMcpServer } from "@/services/agentRuntimeService"; +import { gooseMcpServerKey, isValidMcpTimeoutSeconds } from "@/services/agentMcpServers"; const DEFAULT_TIMEOUT_SECONDS = 300; type PendingDiscardAction = "close_form" | "close_dialog"; @@ -836,7 +837,7 @@ function normalizeServer(server: AgentMcpServer): AgentMcpServer { ...server, name: server.name.trim(), description: server.description.trim(), - timeoutSeconds: Math.floor(server.timeoutSeconds), + timeoutSeconds: server.timeoutSeconds, transport: server.transport.type === "stdio" ? { ...server.transport, command: server.transport.command.trim(), environment } @@ -862,16 +863,16 @@ function validateServer( ): string | null { const name = server.name.trim(); if (!name) return "Enter a server name."; + const nameKey = gooseMcpServerKey(name); if ( servers.some( - (candidate, index) => - index !== editingIndex && candidate.name.trim().toLowerCase() === name.toLowerCase() + (candidate, index) => index !== editingIndex && gooseMcpServerKey(candidate.name) === nameKey ) ) { return "Another MCP server already uses that name."; } - if (!Number.isFinite(server.timeoutSeconds) || server.timeoutSeconds <= 0) { - return "Timeout must be a positive number of seconds."; + if (!isValidMcpTimeoutSeconds(server.timeoutSeconds)) { + return "Timeout must be a positive whole number of seconds."; } if (server.transport.type === "stdio" && !server.transport.command.trim()) { return "Enter the command used to start this STDIO server."; diff --git a/frontend/src/services/agentMcpErrors.test.ts b/frontend/src/services/agentMcpErrors.test.ts index b5620a77..bfb4d650 100644 --- a/frontend/src/services/agentMcpErrors.test.ts +++ b/frontend/src/services/agentMcpErrors.test.ts @@ -44,4 +44,36 @@ describe("MCP connection errors", () => { ); expect(isMcpConnectionErrorEvent("The selected model is unavailable")).toBe(false); }); + + test("classifies authentication, timeout, and STDIO startup failures", () => { + expect(conciseMcpError("HTTP 401 Unauthorized")).toBe( + "The server rejected the connection. Check its authentication headers." + ); + expect(conciseMcpError("HTTP 403 Forbidden")).toBe( + "The server rejected the connection. Check its authentication headers." + ); + expect(conciseMcpError("request timed out during initialize")).toBe( + "The connection timed out. Check that the server is running and reachable." + ); + expect(conciseMcpError("failed to spawn process: executable not found")).toBe( + "Could not start the STDIO command. Check the executable path and arguments." + ); + }); + + test("bounds aggregated errors and handles empty responses", () => { + const message = mcpConnectionErrorMessage([ + { name: "first", error: "first failure" }, + { name: "second", error: "second failure" }, + { name: "third", error: "third failure" }, + { name: "fourth", error: "fourth failure" } + ]); + + expect(message).toContain("first: first failure"); + expect(message).toContain("third: third failure"); + expect(message).not.toContain("fourth: fourth failure"); + expect(message).toContain("and 1 more"); + expect(mcpConnectionErrorMessage([])).toBeNull(); + expect(mcpConnectionErrorMessage(null)).toBeNull(); + expect(isMcpConnectionErrorEvent(message!)).toBe(true); + }); }); diff --git a/frontend/src/services/agentMcpServers.test.ts b/frontend/src/services/agentMcpServers.test.ts new file mode 100644 index 00000000..cb09f5e9 --- /dev/null +++ b/frontend/src/services/agentMcpServers.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from "bun:test"; +import type { AgentMcpServer } from "./agentRuntimeService"; +import { + gooseMcpServerKey, + isValidMcpTimeoutSeconds, + reconcileNewChatMcpServerNames +} from "./agentMcpServers"; + +function server(name: string, enabled: boolean): AgentMcpServer { + return { + name, + description: "", + enabled, + timeoutSeconds: 300, + transport: { type: "stdio", command: "fixture", environment: [] } + }; +} + +describe("Agent MCP server identity", () => { + test("matches Goose name normalization", () => { + expect(gooseMcpServerKey("My Server")).toBe("myserver"); + expect(gooseMcpServerKey("Foo.Bar")).toBe("foo_bar"); + expect(gooseMcpServerKey("foo/bar")).toBe("foo_bar"); + expect(gooseMcpServerKey("KEEP_me-2")).toBe("keep_me-2"); + expect(gooseMcpServerKey("café")).toBe("caf_"); + expect(gooseMcpServerKey("one\u0085two")).toBe("onetwo"); + expect(gooseMcpServerKey("one\ufefftwo")).toBe("one_two"); + }); + + test("accepts only positive whole-second timeouts", () => { + expect(isValidMcpTimeoutSeconds(1)).toBe(true); + expect(isValidMcpTimeoutSeconds(300)).toBe(true); + expect(isValidMcpTimeoutSeconds(0.5)).toBe(false); + expect(isValidMcpTimeoutSeconds(1.5)).toBe(false); + expect(isValidMcpTimeoutSeconds(Number.NaN)).toBe(false); + expect(isValidMcpTimeoutSeconds(Number.POSITIVE_INFINITY)).toBe(false); + }); +}); + +describe("new-chat MCP selection reconciliation", () => { + test("preserves explicit per-chat overrides across unrelated edits", () => { + const previous = [server("default_on", true), server("default_off", false)]; + const saved = previous.map((entry) => ({ ...entry, description: "edited" })); + + expect(reconcileNewChatMcpServerNames(previous, saved, new Set(["default_off"]))).toEqual( + new Set(["default_off"]) + ); + }); + + test("applies changed defaults when the pending selection had no override", () => { + const previous = [server("first", true), server("second", false)]; + const saved = [server("first", false), server("second", true)]; + + expect(reconcileNewChatMcpServerNames(previous, saved, new Set(["first"]))).toEqual( + new Set(["second"]) + ); + }); + + test("adds new defaults and drops deleted servers", () => { + const previous = [server("deleted", true)]; + const saved = [server("new_on", true), server("new_off", false)]; + + expect(reconcileNewChatMcpServerNames(previous, saved, new Set(["deleted"]))).toEqual( + new Set(["new_on"]) + ); + }); + + test("preserves selection across names with the same Goose key", () => { + const previous = [server("My Server", false)]; + const saved = [server("myserver", false)]; + + expect(reconcileNewChatMcpServerNames(previous, saved, new Set(["My Server"]))).toEqual( + new Set(["myserver"]) + ); + }); +}); diff --git a/frontend/src/services/agentMcpServers.ts b/frontend/src/services/agentMcpServers.ts new file mode 100644 index 00000000..2fc5501a --- /dev/null +++ b/frontend/src/services/agentMcpServers.ts @@ -0,0 +1,48 @@ +import type { AgentMcpServer } from "./agentRuntimeService"; + +// Mirror Goose's pinned name_to_key behavior so form feedback and pending-chat +// reconciliation use the same identity as the authoritative Rust boundary. +export function gooseMcpServerKey(name: string): string { + let key = ""; + for (const character of name) { + if (/[A-Za-z0-9_-]/.test(character)) { + key += character; + } else if (/\p{White_Space}/u.test(character)) { + continue; + } else { + key += "_"; + } + } + return key.toLowerCase(); +} + +export function isValidMcpTimeoutSeconds(value: number): boolean { + return Number.isSafeInteger(value) && value >= 1; +} + +export function reconcileNewChatMcpServerNames( + previousServers: AgentMcpServer[], + savedServers: AgentMcpServer[], + currentSelection: Set +): Set { + const previousByKey = new Map( + previousServers.map((server) => [gooseMcpServerKey(server.name), server]) + ); + const selectedKeys = new Set(Array.from(currentSelection, (name) => gooseMcpServerKey(name))); + const reconciled = new Set(); + + for (const server of savedServers) { + const key = gooseMcpServerKey(server.name); + const previous = previousByKey.get(key); + const wasSelected = selectedKeys.has(key); + const shouldSelect = previous + ? wasSelected === previous.enabled + ? server.enabled + : wasSelected + : server.enabled; + + if (shouldSelect) reconciled.add(server.name); + } + + return reconciled; +} From e1697c118c8f25df068549a36ec5ff8211ec4897 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:52:42 +0000 Subject: [PATCH 3/3] fix(agent): update Goose for empty-turn recovery --- frontend/src-tauri/Cargo.lock | 12 ++++++------ frontend/src-tauri/Cargo.toml | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock index 9c6ebec5..c4c6918f 100644 --- a/frontend/src-tauri/Cargo.lock +++ b/frontend/src-tauri/Cargo.lock @@ -2814,7 +2814,7 @@ dependencies = [ [[package]] name = "goose" version = "1.42.0" -source = "git+https://github.com/aaif-goose/goose.git?rev=b7eb1e9735833a7bf12ab92994a788fbc770f218#b7eb1e9735833a7bf12ab92994a788fbc770f218" +source = "git+https://github.com/aaif-goose/goose.git?rev=3c1fdd692cc8aaa5f09b9175410c09a09d4dfe49#3c1fdd692cc8aaa5f09b9175410c09a09d4dfe49" dependencies = [ "agent-client-protocol", "agent-client-protocol-http", @@ -2910,7 +2910,7 @@ dependencies = [ [[package]] name = "goose-acp-macros" version = "1.42.0" -source = "git+https://github.com/aaif-goose/goose.git?rev=b7eb1e9735833a7bf12ab92994a788fbc770f218#b7eb1e9735833a7bf12ab92994a788fbc770f218" +source = "git+https://github.com/aaif-goose/goose.git?rev=3c1fdd692cc8aaa5f09b9175410c09a09d4dfe49#3c1fdd692cc8aaa5f09b9175410c09a09d4dfe49" dependencies = [ "quote", "syn 2.0.108", @@ -2919,7 +2919,7 @@ dependencies = [ [[package]] name = "goose-download-manager" version = "0.1.0-alpha.0" -source = "git+https://github.com/aaif-goose/goose.git?rev=b7eb1e9735833a7bf12ab92994a788fbc770f218#b7eb1e9735833a7bf12ab92994a788fbc770f218" +source = "git+https://github.com/aaif-goose/goose.git?rev=3c1fdd692cc8aaa5f09b9175410c09a09d4dfe49#3c1fdd692cc8aaa5f09b9175410c09a09d4dfe49" dependencies = [ "anyhow", "once_cell", @@ -2933,7 +2933,7 @@ dependencies = [ [[package]] name = "goose-provider-types" version = "0.1.0-alpha.0" -source = "git+https://github.com/aaif-goose/goose.git?rev=b7eb1e9735833a7bf12ab92994a788fbc770f218#b7eb1e9735833a7bf12ab92994a788fbc770f218" +source = "git+https://github.com/aaif-goose/goose.git?rev=3c1fdd692cc8aaa5f09b9175410c09a09d4dfe49#3c1fdd692cc8aaa5f09b9175410c09a09d4dfe49" dependencies = [ "anyhow", "async-stream", @@ -2960,7 +2960,7 @@ dependencies = [ [[package]] name = "goose-providers" version = "0.1.0-alpha.0" -source = "git+https://github.com/aaif-goose/goose.git?rev=b7eb1e9735833a7bf12ab92994a788fbc770f218#b7eb1e9735833a7bf12ab92994a788fbc770f218" +source = "git+https://github.com/aaif-goose/goose.git?rev=3c1fdd692cc8aaa5f09b9175410c09a09d4dfe49#3c1fdd692cc8aaa5f09b9175410c09a09d4dfe49" dependencies = [ "anyhow", "async-stream", @@ -2985,7 +2985,7 @@ dependencies = [ [[package]] name = "goose-sdk-types" version = "0.1.0-alpha.0" -source = "git+https://github.com/aaif-goose/goose.git?rev=b7eb1e9735833a7bf12ab92994a788fbc770f218#b7eb1e9735833a7bf12ab92994a788fbc770f218" +source = "git+https://github.com/aaif-goose/goose.git?rev=3c1fdd692cc8aaa5f09b9175410c09a09d4dfe49#3c1fdd692cc8aaa5f09b9175410c09a09d4dfe49" dependencies = [ "agent-client-protocol", "agent-client-protocol-schema", diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index 0c8d165d..82a60226 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -74,7 +74,7 @@ sha2 = "0.10" # Pin Goose to an exact official upstream commit. Keep this as a git dependency # instead of a submodule so ordinary Maple checkouts do not need the full Goose # history. -goose = { git = "https://github.com/aaif-goose/goose.git", rev = "b7eb1e9735833a7bf12ab92994a788fbc770f218", package = "goose", default-features = false } +goose = { git = "https://github.com/aaif-goose/goose.git", rev = "3c1fdd692cc8aaa5f09b9175410c09a09d4dfe49", package = "goose", default-features = false } async-trait = "0.1" rmcp = { version = "=1.4.0", default-features = false } tauri-plugin-dialog = "2.7.1"