diff --git a/Cargo.lock b/Cargo.lock index 664095c43e..7d64509e66 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4327,7 +4327,7 @@ dependencies = [ "rayon", "regex", "reqwest 0.13.2", - "rmcp 1.1.0", + "rmcp 1.2.0", "rubato", "sacp", "schemars 1.2.1", @@ -4399,7 +4399,7 @@ dependencies = [ "goose-test-support", "http-body-util", "regex", - "rmcp 1.1.0", + "rmcp 1.2.0", "sacp", "schemars 1.2.1", "serde", @@ -4453,7 +4453,7 @@ dependencies = [ "rand 0.8.5", "regex", "reqwest 0.13.2", - "rmcp 1.1.0", + "rmcp 1.2.0", "rustyline", "serde", "serde_json", @@ -4497,7 +4497,7 @@ dependencies = [ "rayon", "regex", "reqwest 0.13.2", - "rmcp 1.1.0", + "rmcp 1.2.0", "schemars 1.2.1", "serde", "serde_json", @@ -4551,13 +4551,14 @@ dependencies = [ "rand 0.9.2", "rcgen", "reqwest 0.13.2", - "rmcp 1.1.0", + "rmcp 1.2.0", "rustls 0.23.36", "serde", "serde_json", "serde_path_to_error", "serde_yaml", "socket2 0.6.2", + "subtle", "tempfile", "thiserror 1.0.69", "tokio", @@ -4589,7 +4590,7 @@ name = "goose-test-support" version = "1.27.0" dependencies = [ "axum 0.7.9", - "rmcp 1.1.0", + "rmcp 1.2.0", "serde_json", "tokio", ] @@ -8154,9 +8155,9 @@ dependencies = [ [[package]] name = "rmcp" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2cb14cb9278a12eae884c9f3c0cfeca2cc28f361211206424a1d7abed95f090" +checksum = "ba6b9d2f0efe2258b23767f1f9e0054cfbcac9c2d6f81a031214143096d7864f" dependencies = [ "async-trait", "base64 0.22.1", @@ -8172,7 +8173,7 @@ dependencies = [ "process-wrap", "rand 0.10.0", "reqwest 0.13.2", - "rmcp-macros 1.1.0", + "rmcp-macros 1.2.0", "schemars 1.2.1", "serde", "serde_json", @@ -8215,9 +8216,9 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a02ea81d9482b07e1fe156ac7cf98b6823d51fb84531936a5e1cbb4eec31ad5" +checksum = "ab9d95d7ed26ad8306352b0d5f05b593222b272790564589790d210aa15caa9e" dependencies = [ "darling 0.23.0", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index 39881bd4b2..4e05de3595 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ uninlined_format_args = "allow" string_slice = "warn" [workspace.dependencies] -rmcp = { version = "1.1.0", features = ["schemars", "auth"] } +rmcp = { version = "1.2.0", features = ["schemars", "auth"] } sacp = "10.1.0" anyhow = "1.0" async-stream = "0.3" diff --git a/crates/goose-acp/src/server.rs b/crates/goose-acp/src/server.rs index 495e9bfcc1..ee92730844 100644 --- a/crates/goose-acp/src/server.rs +++ b/crates/goose-acp/src/server.rs @@ -1172,10 +1172,20 @@ impl GooseAcpAgent { } self.session_manager .update(&req.session_id) - .working_dir(path) + .working_dir(path.clone()) .apply() .await .map_err(|e| sacp::Error::internal_error().data(e.to_string()))?; + + // Notify MCP servers so roots stay in sync with the new working directory. + if let Some(session) = self.sessions.lock().await.get(&req.session_id) { + session + .agent + .extension_manager + .update_working_dir(&path) + .await; + } + Ok(EmptyResponse {}) } diff --git a/crates/goose/src/agents/extension_manager.rs b/crates/goose/src/agents/extension_manager.rs index cd2658199e..954221e8cf 100644 --- a/crates/goose/src/agents/extension_manager.rs +++ b/crates/goose/src/agents/extension_manager.rs @@ -233,7 +233,7 @@ async fn child_process_client( mut command: Command, timeout: &Option, provider: SharedProvider, - working_dir: Option<&PathBuf>, + working_dir: &PathBuf, docker_container: Option, client_name: String, capabilities: GooseMcpClientCapabilities, @@ -244,21 +244,14 @@ async fn child_process_client( command.env("PATH", path); } - // Use explicitly passed working_dir, falling back to GOOSE_WORKING_DIR env var - let effective_working_dir = working_dir - .map(|p| p.to_path_buf()) - .or_else(|| std::env::var("GOOSE_WORKING_DIR").ok().map(PathBuf::from)); - - if let Some(ref dir) = effective_working_dir { - if dir.exists() && dir.is_dir() { - tracing::info!("Setting MCP process working directory: {:?}", dir); - command.current_dir(dir); - } else { - tracing::warn!( - "Working directory doesn't exist or isn't a directory: {:?}", - dir - ); - } + if working_dir.exists() && working_dir.is_dir() { + tracing::info!("Setting MCP process working directory: {:?}", working_dir); + command.current_dir(working_dir); + } else { + tracing::warn!( + "Working directory doesn't exist or isn't a directory: {:?}", + working_dir + ); } let (transport, mut stderr) = TokioChildProcess::builder(command) @@ -281,6 +274,7 @@ async fn child_process_client( docker_container, client_name, capabilities, + working_dir.clone(), ) .await; @@ -402,6 +396,7 @@ pub(crate) fn substitute_env_vars(value: &str, env_map: &HashMap const GOOSE_USER_AGENT: reqwest::header::HeaderValue = reqwest::header::HeaderValue::from_static(concat!("goose/", env!("CARGO_PKG_VERSION"))); +#[allow(clippy::too_many_arguments)] async fn create_streamable_http_client( uri: &str, timeout: Option, @@ -410,6 +405,7 @@ async fn create_streamable_http_client( provider: SharedProvider, client_name: String, capabilities: GooseMcpClientCapabilities, + roots_dir: &std::path::Path, ) -> ExtensionResult> { let mut default_headers = HeaderMap::new(); @@ -447,6 +443,7 @@ async fn create_streamable_http_client( provider.clone(), client_name.clone(), capabilities.clone(), + roots_dir.to_path_buf(), ) .await; @@ -477,6 +474,7 @@ async fn create_streamable_http_client( provider, client_name, capabilities, + roots_dir.to_path_buf(), ) .await?, )) @@ -563,6 +561,11 @@ impl ExtensionManager { let mut temp_dir = None; + let effective_working_dir = working_dir + .clone() + .or_else(|| std::env::var("GOOSE_WORKING_DIR").ok().map(PathBuf::from)) + .unwrap_or_else(|| std::env::current_dir().unwrap_or_default()); + let client: Box = match &config { ExtensionConfig::Sse { .. } => { return Err(ExtensionError::ConfigError( @@ -597,6 +600,7 @@ impl ExtensionManager { self.provider.clone(), self.client_name.clone(), capability, + &effective_working_dir, ) .await? } @@ -646,10 +650,6 @@ impl ExtensionManager { .arg(&normalized_name); }); - let effective_working_dir = working_dir - .clone() - .unwrap_or_else(|| std::env::current_dir().unwrap_or_default()); - let capabilities = GooseMcpClientCapabilities { mcpui: self.capabilities.mcpui, }; @@ -658,7 +658,7 @@ impl ExtensionManager { command, &Some(timeout_secs), self.provider.clone(), - Some(&effective_working_dir), + &effective_working_dir, Some(container_id.to_string()), self.client_name.clone(), capabilities, @@ -681,6 +681,7 @@ impl ExtensionManager { self.provider.clone(), self.client_name.clone(), capabilities, + effective_working_dir.clone(), ) .await?, ) @@ -729,9 +730,6 @@ impl ExtensionManager { }) }; - let effective_working_dir = working_dir - .clone() - .unwrap_or_else(|| std::env::current_dir().unwrap_or_default()); let capabilities = GooseMcpClientCapabilities { mcpui: self.capabilities.mcpui, }; @@ -739,7 +737,7 @@ impl ExtensionManager { command, timeout, self.provider.clone(), - Some(&effective_working_dir), + &effective_working_dir, container.map(|c| c.id().to_string()), self.client_name.clone(), capabilities, @@ -767,11 +765,6 @@ impl ExtensionManager { command.arg("python").arg(file_path.to_str().unwrap()); }); - // Compute working_dir for InlinePython (runs as child process via uvx) - let effective_working_dir = working_dir - .clone() - .unwrap_or_else(|| std::env::current_dir().unwrap_or_default()); - let capabilities = GooseMcpClientCapabilities { mcpui: self.capabilities.mcpui, }; @@ -780,7 +773,7 @@ impl ExtensionManager { command, timeout, self.provider.clone(), - Some(&effective_working_dir), + &effective_working_dir, container.map(|c| c.id().to_string()), self.client_name.clone(), capabilities, @@ -854,6 +847,15 @@ impl ExtensionManager { Ok(()) } + pub async fn update_working_dir(&self, new_dir: &std::path::Path) { + let extensions = self.extensions.lock().await; + for (name, ext) in extensions.iter() { + if let Err(e) = ext.client.update_working_dir(new_dir.to_path_buf()).await { + tracing::warn!(extension = %name, error = %e, "failed to update roots"); + } + } + } + pub async fn get_extension_and_tool_counts(&self, session_id: &str) -> (usize, usize) { let enabled_extensions_count = self.extensions.lock().await.len(); diff --git a/crates/goose/src/agents/mcp_client.rs b/crates/goose/src/agents/mcp_client.rs index f3dbe86596..28a84c9498 100644 --- a/crates/goose/src/agents/mcp_client.rs +++ b/crates/goose/src/agents/mcp_client.rs @@ -3,8 +3,8 @@ use crate::agents::types::SharedProvider; use crate::session_context::{SESSION_ID_HEADER, WORKING_DIR_HEADER}; use rmcp::model::{ CreateElicitationRequestParams, CreateElicitationResult, ElicitationAction, ErrorCode, - ExtensionCapabilities, Extensions, JsonObject, LoggingMessageNotification, Meta, - SamplingMessageContent, + ExtensionCapabilities, Extensions, JsonObject, ListRootsResult, LoggingMessageNotification, + Meta, Root, SamplingMessageContent, }; /// MCP client implementation for Goose use rmcp::{ @@ -25,7 +25,7 @@ use rmcp::{ ClientHandler, ErrorData, Peer, RoleClient, ServiceError, ServiceExt, }; use serde_json::Value; -use std::{sync::Arc, time::Duration}; +use std::{path::PathBuf, sync::Arc, time::Duration}; use tokio::sync::{ mpsc::{self, Sender}, Mutex, @@ -100,17 +100,19 @@ pub trait McpClientTrait: Send + Sync { async fn get_moim(&self, _session_id: &str) -> Option { None } + + async fn update_working_dir(&self, _new_dir: PathBuf) -> Result<(), Error> { + Ok(()) + } } pub struct GooseClient { notification_handlers: Arc>>>, provider: SharedProvider, - /// Fallback session_id for server-initiated callbacks (e.g. sampling/createMessage) - /// that don't include the session_id in their MCP extensions metadata. - /// Set once on first request; never cleared (the id is invariant per McpClient). session_id: Mutex>, client_name: String, capabilities: GooseMcpClientCapabilities, + working_dir: Arc>, } impl GooseClient { @@ -119,6 +121,7 @@ impl GooseClient { provider: SharedProvider, client_name: String, capabilities: GooseMcpClientCapabilities, + working_dir: PathBuf, ) -> Self { GooseClient { notification_handlers: handlers, @@ -126,9 +129,14 @@ impl GooseClient { session_id: Mutex::new(None), client_name, capabilities, + working_dir: Arc::new(tokio::sync::RwLock::new(working_dir)), } } + pub fn shared_working_dir(&self) -> Arc> { + self.working_dir.clone() + } + async fn set_session_id(&self, session_id: &str) { let mut slot = self.session_id.lock().await; assert!( @@ -158,7 +166,21 @@ impl GooseClient { } } +fn working_dir_roots(dir: &std::path::Path) -> ListRootsResult { + let uri = url::Url::from_file_path(dir) + .map(|u| u.to_string()) + .unwrap_or_else(|()| format!("file://{}", dir.display())); + ListRootsResult::new(vec![Root::new(uri).with_name("working_directory")]) +} + impl ClientHandler for GooseClient { + async fn list_roots( + &self, + _context: RequestContext, + ) -> Result { + Ok(working_dir_roots(&self.working_dir.read().await)) + } + async fn on_progress( &self, params: rmcp::model::ProgressNotificationParam, @@ -337,6 +359,7 @@ impl ClientHandler for GooseClient { InitializeRequestParams::new( ClientCapabilities::builder() + .enable_roots() .enable_extensions_with(extensions) .enable_sampling() .enable_elicitation() @@ -372,6 +395,7 @@ impl McpClient { provider: SharedProvider, client_name: String, capabilities: GooseMcpClientCapabilities, + working_dir: PathBuf, ) -> Result where T: IntoTransport, @@ -384,6 +408,7 @@ impl McpClient { None, client_name, capabilities, + working_dir, ) .await } @@ -395,6 +420,7 @@ impl McpClient { docker_container: Option, client_name: String, capabilities: GooseMcpClientCapabilities, + working_dir: PathBuf, ) -> Result where T: IntoTransport, @@ -408,6 +434,7 @@ impl McpClient { provider, client_name.clone(), capabilities.clone(), + working_dir, ); let client: rmcp::service::RunningService = client.serve(transport).await?; @@ -426,6 +453,14 @@ impl McpClient { self.docker_container.as_deref() } + async fn do_update_working_dir(&self, new_dir: PathBuf) -> Result<(), Error> { + let client = self.client.lock().await; + let shared = client.service().shared_working_dir(); + *shared.write().await = new_dir; + client.peer().notify_roots_list_changed().await?; + Ok(()) + } + async fn send_request_with_context( &self, session_id: &str, @@ -639,6 +674,10 @@ impl McpClientTrait for McpClient { self.notification_subscribers.lock().await.push(tx); rx } + + async fn update_working_dir(&self, new_dir: PathBuf) -> Result<(), Error> { + self.do_update_working_dir(new_dir).await + } } /// Injects the given session_id and working_dir into Extensions._meta. @@ -736,6 +775,7 @@ mod tests { Arc::new(Mutex::new(None)), platform.to_string(), capabilities, + std::env::current_dir().unwrap_or_default(), ) } @@ -946,4 +986,23 @@ mod tests { assert_eq!(mime_types, &json!(["text/html;profile=mcp-app"])); } + + #[test] + fn test_client_capabilities_advertise_roots() { + let client = new_client(GoosePlatform::GooseCli); + let info = ClientHandler::get_info(&client); + assert!( + info.capabilities.roots.is_some(), + "client should advertise roots capability" + ); + } + + #[test] + fn test_working_dir_roots_returns_current_dir_as_root() { + let dir = PathBuf::from("/tmp/test-project"); + let result = working_dir_roots(&dir); + assert_eq!(result.roots.len(), 1); + assert_eq!(result.roots[0].uri, "file:///tmp/test-project"); + assert_eq!(result.roots[0].name.as_deref(), Some("working_directory")); + } } diff --git a/crates/goose/tests/mcp_replays/github-mcp-serverstdio b/crates/goose/tests/mcp_replays/github-mcp-serverstdio index 475cd405ba..3ed7300d73 100644 --- a/crates/goose/tests/mcp_replays/github-mcp-serverstdio +++ b/crates/goose/tests/mcp_replays/github-mcp-serverstdio @@ -1,4 +1,4 @@ -STDIN: {"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{"elicitation":{},"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}},"sampling":{}},"clientInfo":{"name":"goose-desktop","version":"0.0.0"}}} +STDIN: {"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{"elicitation":{},"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}},"roots": {},"sampling":{}},"clientInfo":{"name":"goose-desktop","version":"0.0.0"}}} STDERR: time=2025-12-11T17:58:47.636-05:00 level=INFO msg="starting server" version=0.24.1 host="" dynamicToolsets=false readOnly=false lockdownEnabled=false STDERR: GitHub MCP Server running on stdio STDERR: time=2025-12-11T17:58:47.640-05:00 level=INFO msg="server run start" diff --git a/crates/goose/tests/mcp_replays/npx-y@modelcontextprotocol_server-everything@2026.1.14 b/crates/goose/tests/mcp_replays/npx-y@modelcontextprotocol_server-everything@2026.1.14 index 0624c4ebed..efd2d7c6d8 100644 --- a/crates/goose/tests/mcp_replays/npx-y@modelcontextprotocol_server-everything@2026.1.14 +++ b/crates/goose/tests/mcp_replays/npx-y@modelcontextprotocol_server-everything@2026.1.14 @@ -1,4 +1,4 @@ -STDIN: {"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{"elicitation":{},"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}},"sampling":{}},"clientInfo":{"name":"goose-desktop","version":"0.0.0"}}} +STDIN: {"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{"elicitation":{},"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}},"roots": {},"sampling":{}},"clientInfo":{"name":"goose-desktop","version":"0.0.0"}}} STDERR: Starting default (STDIO) server... STDOUT: {"result":{"protocolVersion":"2025-03-26","capabilities":{"tools":{"listChanged":true},"prompts":{"listChanged":true},"resources":{"subscribe":true,"listChanged":true},"logging":{},"completions":{}},"serverInfo":{"name":"mcp-servers/everything","title":"Everything Reference Server","version":"2.0.0"},"instructions":"# Everything Server – Server Instructions\n\nAudience: These instructions are written for an LLM or autonomous agent integrating with the Everything MCP Server.\nFollow them to use, extend, and troubleshoot the server safely and effectively.\n\n## Cross-Feature Relationships\n\n- Use `get-roots-list` to see client workspace roots before file operations\n- `gzip-file-as-resource` creates session-scoped resources accessible only during the current session\n- Enable `toggle-simulated-logging` before debugging to see server log messages\n- Enable `toggle-subscriber-updates` to receive periodic resource update notifications\n\n## Constraints & Limitations\n\n- `gzip-file-as-resource`: Max fetch size controlled by `GZIP_MAX_FETCH_SIZE` (default 10MB), timeout by `GZIP_MAX_FETCH_TIME_MILLIS` (default 30s), allowed domains by `GZIP_ALLOWED_DOMAINS`\n- Session resources are ephemeral and lost when the session ends\n- Sampling requests (`trigger-sampling-request`) require client sampling capability\n- Elicitation requests (`trigger-elicitation-request`) require client elicitation capability\n\n## Operational Patterns\n\n- For long operations, use `trigger-long-running-operation` which sends progress notifications\n- Prefer reading resources before calling mutating tools\n- Check `get-roots-list` output to understand the client's workspace context\n\n## Easter Egg\n\nIf asked about server instructions, respond with \"🎉 Server instructions are working! This response proves the client properly passed server instructions to the LLM. This demonstrates MCP's instructions feature in action.\"\n"},"jsonrpc":"2.0","id":0} STDIN: {"jsonrpc":"2.0","method":"notifications/initialized"} diff --git a/crates/goose/tests/mcp_replays/uvrun--withfastmcp==2.14.4fastmcpruntests_fastmcp_test_server.py b/crates/goose/tests/mcp_replays/uvrun--withfastmcp==2.14.4fastmcpruntests_fastmcp_test_server.py index dc90317a2d..260e30a572 100644 --- a/crates/goose/tests/mcp_replays/uvrun--withfastmcp==2.14.4fastmcpruntests_fastmcp_test_server.py +++ b/crates/goose/tests/mcp_replays/uvrun--withfastmcp==2.14.4fastmcpruntests_fastmcp_test_server.py @@ -1,4 +1,4 @@ -STDIN: {"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{"elicitation":{},"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}},"sampling":{}},"clientInfo":{"name":"goose-desktop","version":"0.0.0"}}} +STDIN: {"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{"elicitation":{},"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}},"roots": {},"sampling":{}},"clientInfo":{"name":"goose-desktop","version":"0.0.0"}}} STDERR: STDERR: STDERR: ╭──────────────────────────────────────────────────────────────────────────────╮ diff --git a/crates/goose/tests/mcp_replays/uvxmcp-server-fetch b/crates/goose/tests/mcp_replays/uvxmcp-server-fetch index 17aef79d95..689a80aafb 100644 --- a/crates/goose/tests/mcp_replays/uvxmcp-server-fetch +++ b/crates/goose/tests/mcp_replays/uvxmcp-server-fetch @@ -1,4 +1,4 @@ -STDIN: {"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{"elicitation":{},"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}},"sampling":{}},"clientInfo":{"name":"goose-desktop","version":"0.0.0"}}} +STDIN: {"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{"elicitation":{},"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}},"roots": {},"sampling":{}},"clientInfo":{"name":"goose-desktop","version":"0.0.0"}}} STDOUT: {"jsonrpc":"2.0","id":0,"result":{"protocolVersion":"2025-03-26","capabilities":{"experimental":{},"prompts":{"listChanged":false},"tools":{"listChanged":false}},"serverInfo":{"name":"mcp-fetch","version":"1.25.0"}}} STDIN: {"jsonrpc":"2.0","method":"notifications/initialized"} STDIN: {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"_meta":{"agent-session-id":"test-session-id","progressToken":0},"name":"fetch","arguments":{"url":"https://example.com"}}}