feat: MCP Roots support (#7790)

This commit is contained in:
Alex Hancock
2026-03-11 14:35:49 -04:00
committed by GitHub
parent d04b761a9c
commit f2de8b5ccc
9 changed files with 126 additions and 54 deletions
Generated
+12 -11
View File
@@ -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",
+1 -1
View File
@@ -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"
+11 -1
View File
@@ -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 {})
}
+33 -31
View File
@@ -233,7 +233,7 @@ async fn child_process_client(
mut command: Command,
timeout: &Option<u64>,
provider: SharedProvider,
working_dir: Option<&PathBuf>,
working_dir: &PathBuf,
docker_container: Option<String>,
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<String, String>
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<u64>,
@@ -410,6 +405,7 @@ async fn create_streamable_http_client(
provider: SharedProvider,
client_name: String,
capabilities: GooseMcpClientCapabilities,
roots_dir: &std::path::Path,
) -> ExtensionResult<Box<dyn McpClientTrait>> {
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<dyn McpClientTrait> = 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();
+65 -6
View File
@@ -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<String> {
None
}
async fn update_working_dir(&self, _new_dir: PathBuf) -> Result<(), Error> {
Ok(())
}
}
pub struct GooseClient {
notification_handlers: Arc<Mutex<Vec<Sender<ServerNotification>>>>,
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<Option<String>>,
client_name: String,
capabilities: GooseMcpClientCapabilities,
working_dir: Arc<tokio::sync::RwLock<PathBuf>>,
}
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<tokio::sync::RwLock<PathBuf>> {
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<RoleClient>,
) -> Result<ListRootsResult, ErrorData> {
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<Self, ClientInitializeError>
where
T: IntoTransport<RoleClient, E, A>,
@@ -384,6 +408,7 @@ impl McpClient {
None,
client_name,
capabilities,
working_dir,
)
.await
}
@@ -395,6 +420,7 @@ impl McpClient {
docker_container: Option<String>,
client_name: String,
capabilities: GooseMcpClientCapabilities,
working_dir: PathBuf,
) -> Result<Self, ClientInitializeError>
where
T: IntoTransport<RoleClient, E, A>,
@@ -408,6 +434,7 @@ impl McpClient {
provider,
client_name.clone(),
capabilities.clone(),
working_dir,
);
let client: rmcp::service::RunningService<rmcp::RoleClient, GooseClient> =
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"));
}
}
@@ -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"
@@ -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"}
@@ -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:
@@ -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"}}}