diff --git a/Cargo.lock b/Cargo.lock index 7a9bce05ca..879339f3b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8063,6 +8063,8 @@ dependencies = [ "http 1.4.0", "http-body 1.0.1", "http-body-util", + "hyper 1.8.1", + "hyper-util", "oauth2", "pastey", "pin-project-lite", diff --git a/crates/goose-acp/src/server.rs b/crates/goose-acp/src/server.rs index bf902a9848..daa0eec826 100644 --- a/crates/goose-acp/src/server.rs +++ b/crates/goose-acp/src/server.rs @@ -164,6 +164,7 @@ fn mcp_server_to_extension_config(mcp_server: McpServer) -> Result anyhow::Result<()> { headers, description, timeout: Some(timeout), + socket: None, bundled: None, available_tools: Vec::new(), }, diff --git a/crates/goose-cli/src/recipes/secret_discovery.rs b/crates/goose-cli/src/recipes/secret_discovery.rs index 56bba32126..3a9a17f8ee 100644 --- a/crates/goose-cli/src/recipes/secret_discovery.rs +++ b/crates/goose-cli/src/recipes/secret_discovery.rs @@ -166,6 +166,7 @@ mod tests { env_keys: vec!["GITHUB_TOKEN".to_string(), "GITHUB_API_URL".to_string()], description: "github-mcp".to_string(), timeout: None, + socket: None, bundled: None, available_tools: Vec::new(), headers: HashMap::new(), @@ -262,6 +263,7 @@ mod tests { env_keys: vec!["API_KEY".to_string()], description: "service-a".to_string(), timeout: None, + socket: None, bundled: None, available_tools: Vec::new(), headers: HashMap::new(), @@ -321,6 +323,7 @@ mod tests { env_keys: vec!["PARENT_TOKEN".to_string()], description: "parent-ext".to_string(), timeout: None, + socket: None, bundled: None, available_tools: Vec::new(), headers: HashMap::new(), diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index 879585b916..535bc54b7a 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -369,6 +369,7 @@ impl CliSession { headers: HashMap::new(), description: goose::config::DEFAULT_EXTENSION_DESCRIPTION.to_string(), timeout: Some(timeout), + socket: None, bundled: None, available_tools: Vec::new(), } @@ -2160,6 +2161,7 @@ mod tests { headers: HashMap::new(), description: goose::config::DEFAULT_EXTENSION_DESCRIPTION.to_string(), timeout: Some(300), + socket: None, bundled: None, available_tools: vec![], } @@ -2175,6 +2177,7 @@ mod tests { headers: HashMap::new(), description: goose::config::DEFAULT_EXTENSION_DESCRIPTION.to_string(), timeout: Some(300), + socket: None, bundled: None, available_tools: vec![], } @@ -2190,6 +2193,7 @@ mod tests { headers: HashMap::new(), description: goose::config::DEFAULT_EXTENSION_DESCRIPTION.to_string(), timeout: Some(300), + socket: None, bundled: None, available_tools: vec![], } diff --git a/crates/goose-test-support/src/mcp.rs b/crates/goose-test-support/src/mcp.rs index ddd48f92ae..21daa584fa 100644 --- a/crates/goose-test-support/src/mcp.rs +++ b/crates/goose-test-support/src/mcp.rs @@ -9,8 +9,7 @@ use rmcp::transport::streamable_http_server::{ session::local::LocalSessionManager, StreamableHttpServerConfig, StreamableHttpService, }; use rmcp::{ - handler::server::router::tool::ToolRouter, tool, tool_handler, tool_router, - ErrorData as McpError, RoleServer, ServerHandler, Service, + tool, tool_handler, tool_router, ErrorData as McpError, RoleServer, ServerHandler, Service, }; use std::sync::Arc; use tokio::task::JoinHandle; @@ -88,23 +87,13 @@ impl> Service for ValidatingService { } } -#[derive(Clone)] -pub struct McpFixtureServer { - tool_router: ToolRouter, -} - -impl Default for McpFixtureServer { - fn default() -> Self { - Self::new() - } -} +#[derive(Clone, Default)] +pub struct McpFixtureServer; #[tool_router] impl McpFixtureServer { pub fn new() -> Self { - Self { - tool_router: Self::tool_router(), - } + Self } #[tool(description = "Get the code", annotations(read_only_hint = true))] @@ -121,7 +110,7 @@ impl McpFixtureServer { } } -#[tool_handler(router = self.tool_router)] +#[tool_handler] impl ServerHandler for McpFixtureServer { fn get_info(&self) -> ServerInfo { InitializeResult::new(ServerCapabilities::builder().enable_tools().build()) diff --git a/crates/goose/Cargo.toml b/crates/goose/Cargo.toml index cd0492ee41..0960319d76 100644 --- a/crates/goose/Cargo.toml +++ b/crates/goose/Cargo.toml @@ -67,6 +67,7 @@ rmcp = { workspace = true, features = [ "transport-child-process", "transport-streamable-http-client", "transport-streamable-http-client-reqwest", + "transport-streamable-http-client-unix-socket", ] } oauth2 = { version = "5.0", default-features = false } arboard = { workspace = true } diff --git a/crates/goose/src/acp/provider.rs b/crates/goose/src/acp/provider.rs index 40d0de55b6..a14496c628 100644 --- a/crates/goose/src/acp/provider.rs +++ b/crates/goose/src/acp/provider.rs @@ -1388,6 +1388,7 @@ mod tests { env_keys: vec![], headers: HashMap::from([("Authorization".into(), "Bearer ghp_xxxxxxxxxxxx".into())]), timeout: None, + socket: None, bundled: Some(false), available_tools: vec![], }, @@ -1441,6 +1442,7 @@ mod tests { env_keys: vec![], headers: HashMap::from([("Authorization".into(), "Bearer ghp_xxxxxxxxxxxx".into())]), timeout: None, + socket: None, bundled: Some(false), available_tools: vec![], }; diff --git a/crates/goose/src/agents/extension.rs b/crates/goose/src/agents/extension.rs index e30bdb334d..51349e0115 100644 --- a/crates/goose/src/agents/extension.rs +++ b/crates/goose/src/agents/extension.rs @@ -235,6 +235,12 @@ pub enum ExtensionConfig { // NOTE: set timeout to be optional for compatibility. // However, new configurations should include this field. timeout: Option, + /// Optional Unix domain socket path for HTTP-over-UDS transport. + /// When set, the HTTP connection is routed through this socket while + /// `uri` is used for the Host header and path. + /// Use `@name` for Linux abstract sockets. + #[serde(default)] + socket: Option, #[serde(default)] bundled: Option, #[serde(default)] @@ -307,6 +313,7 @@ impl ExtensionConfig { headers: HashMap::new(), description: description.into(), timeout: Some(timeout.into()), + socket: None, bundled: None, available_tools: Vec::new(), } @@ -460,6 +467,7 @@ impl ExtensionConfig { env_keys, headers, timeout, + socket, bundled, available_tools, } => { @@ -471,6 +479,7 @@ impl ExtensionConfig { (k, v) }) .collect(); + let socket = socket.map(|s| substitute_env_vars(&s, &merged)); Ok(Self::StreamableHttp { name, description, @@ -479,6 +488,7 @@ impl ExtensionConfig { env_keys: vec![], headers, timeout, + socket, bundled, available_tools, }) @@ -494,8 +504,14 @@ impl std::fmt::Display for ExtensionConfig { ExtensionConfig::Sse { name, .. } => { write!(f, "SSE({}: unsupported)", name) } - ExtensionConfig::StreamableHttp { name, uri, .. } => { - write!(f, "StreamableHttp({}: {})", name, uri) + ExtensionConfig::StreamableHttp { + name, uri, socket, .. + } => { + if let Some(socket) = socket { + write!(f, "StreamableHttp({}: {} via {})", name, uri, socket) + } else { + write!(f, "StreamableHttp({}: {})", name, uri) + } } ExtensionConfig::Stdio { name, cmd, args, .. @@ -679,6 +695,7 @@ available_tools: [] .into_iter() .collect(), timeout: None, + socket: None, bundled: None, available_tools: vec![], }, @@ -699,6 +716,7 @@ available_tools: [] .into_iter() .collect(), timeout: None, + socket: None, bundled: None, available_tools: vec![], } @@ -772,6 +790,7 @@ available_tools: [] .into_iter() .collect(), timeout: None, + socket: None, bundled: None, available_tools: vec![], }, @@ -789,6 +808,7 @@ available_tools: [] .into_iter() .collect(), timeout: None, + socket: None, bundled: None, available_tools: vec![], } @@ -803,6 +823,7 @@ available_tools: [] env_keys: vec!["MY_SECRET".into()], headers: std::collections::HashMap::new(), timeout: None, + socket: None, bundled: None, available_tools: vec![], }, @@ -818,6 +839,7 @@ available_tools: [] env_keys: vec![], headers: std::collections::HashMap::new(), timeout: None, + socket: None, bundled: None, available_tools: vec![], } @@ -867,4 +889,44 @@ available_tools: [] cfg.set("MY_SECRET", &"secret_value", true).unwrap(); assert_eq!(config.resolve(&cfg).await.unwrap(), expected); } + + #[test] + fn test_display_streamable_http_with_socket() { + let config = ExtensionConfig::StreamableHttp { + name: "test".into(), + description: String::new(), + uri: "http://localhost:8080/mcp".into(), + envs: extension::Envs::default(), + env_keys: vec![], + headers: std::collections::HashMap::new(), + timeout: None, + socket: Some("@egress.sock".to_string()), + bundled: None, + available_tools: vec![], + }; + assert_eq!( + config.to_string(), + "StreamableHttp(test: http://localhost:8080/mcp via @egress.sock)" + ); + } + + #[test] + fn test_display_streamable_http_without_socket() { + let config = ExtensionConfig::StreamableHttp { + name: "test".into(), + description: String::new(), + uri: "http://localhost:8080/mcp".into(), + envs: extension::Envs::default(), + env_keys: vec![], + headers: std::collections::HashMap::new(), + timeout: None, + socket: None, + bundled: None, + available_tools: vec![], + }; + assert_eq!( + config.to_string(), + "StreamableHttp(test: http://localhost:8080/mcp)" + ); + } } diff --git a/crates/goose/src/agents/extension_manager.rs b/crates/goose/src/agents/extension_manager.rs index 1320217dbf..e534c0a3b8 100644 --- a/crates/goose/src/agents/extension_manager.rs +++ b/crates/goose/src/agents/extension_manager.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use axum::http::{HeaderMap, HeaderName}; +use axum::http::{HeaderMap, HeaderName, HeaderValue}; use chrono::{DateTime, Utc}; use futures::stream::{FuturesUnordered, StreamExt}; use futures::{future, FutureExt}; @@ -315,16 +315,28 @@ fn should_attempt_oauth_fallback(res: &Result) }; if let Some(http_err) = error.downcast_ref::>() { - match http_err { + return match http_err { StreamableHttpError::AuthRequired(_) => true, StreamableHttpError::UnexpectedServerResponse(body) => body.starts_with("HTTP 401"), _ => false, - } - } else { - error - .to_string() - .contains("unexpected server response: HTTP 401") + }; } + + #[cfg(unix)] + if let Some(http_err) = error + .downcast_ref::>( + ) + { + return match http_err { + StreamableHttpError::AuthRequired(_) => true, + StreamableHttpError::UnexpectedServerResponse(body) => body.starts_with("HTTP 401"), + _ => false, + }; + } + + error + .to_string() + .contains("unexpected server response: HTTP 401") } /// Merge environment variables from direct envs and keychain-stored env_keys @@ -417,11 +429,34 @@ async fn create_streamable_http_client( timeout: Option, headers: &HashMap, name: &str, + socket: Option<&str>, provider: SharedProvider, client_name: String, capabilities: GooseMcpClientCapabilities, roots_dir: &std::path::Path, ) -> ExtensionResult> { + #[cfg(unix)] + if let Some(socket_path) = socket { + return create_unix_socket_http_client( + uri, + timeout, + headers, + name, + socket_path, + provider, + client_name, + capabilities, + roots_dir, + ) + .await; + } + #[cfg(not(unix))] + if socket.is_some() { + return Err(ExtensionError::ConfigError( + "Unix domain socket transport is not supported on this platform".to_string(), + )); + } + let mut default_headers = HeaderMap::new(); default_headers.insert(reqwest::header::USER_AGENT, GOOSE_USER_AGENT); @@ -493,6 +528,68 @@ async fn create_streamable_http_client( } } +#[cfg(unix)] +#[allow(clippy::too_many_arguments)] +async fn create_unix_socket_http_client( + uri: &str, + timeout: Option, + headers: &HashMap, + name: &str, + socket_path: &str, + provider: SharedProvider, + client_name: String, + capabilities: GooseMcpClientCapabilities, + roots_dir: &std::path::Path, +) -> ExtensionResult> { + use rmcp::transport::UnixSocketHttpClient; + + let unix_client = UnixSocketHttpClient::new(socket_path, uri); + + let mut custom_headers = std::collections::HashMap::::new(); + + custom_headers.insert( + HeaderName::from_static("user-agent"), + GOOSE_USER_AGENT + .to_str() + .unwrap_or("goose") + .parse() + .unwrap_or_else(|_| HeaderValue::from_static("goose")), + ); + + for (key, value) in headers { + let header_name = HeaderName::try_from(key) + .map_err(|_| ExtensionError::ConfigError(format!("invalid header: {}", key)))?; + let header_value = value + .parse::() + .map_err(|_| ExtensionError::ConfigError(format!("invalid header value: {}", key)))?; + custom_headers.insert(header_name, header_value); + } + + let config = StreamableHttpClientTransportConfig::with_uri(uri).custom_headers(custom_headers); + let transport = StreamableHttpClientTransport::with_client(unix_client, config); + + let timeout_duration = Duration::from_secs(resolve_timeout(timeout)); + + let client_res = McpClient::connect( + transport, + timeout_duration, + provider.clone(), + client_name.clone(), + capabilities.clone(), + roots_dir.to_path_buf(), + ) + .await; + + if should_attempt_oauth_fallback(&client_res) { + tracing::warn!( + "Extension '{}' returned 401 over Unix domain socket transport; \ + OAuth is not supported for UDS connections", + name, + ); + } + Ok(Box::new(client_res?)) +} + impl ExtensionManager { pub fn new( provider: SharedProvider, @@ -589,6 +686,7 @@ impl ExtensionManager { name, envs, env_keys, + socket, .. } => { let config = Config::global(); @@ -598,6 +696,7 @@ impl ExtensionManager { .iter() .map(|(k, v)| (k.clone(), substitute_env_vars(v, &all_envs))) .collect(); + let resolved_socket = socket.as_ref().map(|s| substitute_env_vars(s, &all_envs)); let capability = GooseMcpClientCapabilities { mcpui: self.capabilities.mcpui, }; @@ -607,6 +706,7 @@ impl ExtensionManager { *timeout, &resolved_headers, name, + resolved_socket.as_deref(), self.provider.clone(), self.client_name.clone(), capability, diff --git a/crates/goose/src/agents/mcp_client.rs b/crates/goose/src/agents/mcp_client.rs index 64cdddfc43..aa604d2dd3 100644 --- a/crates/goose/src/agents/mcp_client.rs +++ b/crates/goose/src/agents/mcp_client.rs @@ -327,10 +327,8 @@ impl ClientHandler for GooseClient { ActionRequiredManager::global() .request_and_wait(message, schema_value, Duration::from_secs(300)) .await - .map(|user_data| CreateElicitationResult { - action: ElicitationAction::Accept, - content: Some(user_data), - meta: None, + .map(|user_data| { + CreateElicitationResult::new(ElicitationAction::Accept).with_content(user_data) }) .map_err(|e| { ErrorData::new( diff --git a/crates/goose/src/providers/claude_code.rs b/crates/goose/src/providers/claude_code.rs index ecdb44bb4f..e8957c9659 100644 --- a/crates/goose/src/providers/claude_code.rs +++ b/crates/goose/src/providers/claude_code.rs @@ -1149,6 +1149,7 @@ mod tests { env_keys: vec![], headers: HashMap::from([("Authorization".into(), "Bearer token".into())]), timeout: None, + socket: None, bundled: Some(false), available_tools: vec![], }], @@ -1170,6 +1171,7 @@ mod tests { env_keys: vec![], headers: HashMap::new(), timeout: None, + socket: None, bundled: None, available_tools: vec![], }], diff --git a/crates/goose/src/providers/codex.rs b/crates/goose/src/providers/codex.rs index d121097c69..a2075f52aa 100644 --- a/crates/goose/src/providers/codex.rs +++ b/crates/goose/src/providers/codex.rs @@ -792,6 +792,7 @@ mod tests { env_keys: vec![], headers: HashMap::from([("Authorization".into(), "Bearer token".into())]), timeout: None, + socket: None, bundled: Some(false), available_tools: vec![], }, @@ -810,6 +811,7 @@ mod tests { env_keys: vec![], headers: HashMap::new(), timeout: None, + socket: None, bundled: None, available_tools: vec![], }, diff --git a/crates/goose/src/recipe/recipe_extension_adapter.rs b/crates/goose/src/recipe/recipe_extension_adapter.rs index 3b32c3e35d..1e1504734c 100644 --- a/crates/goose/src/recipe/recipe_extension_adapter.rs +++ b/crates/goose/src/recipe/recipe_extension_adapter.rs @@ -62,6 +62,8 @@ enum RecipeExtensionConfigInternal { headers: HashMap, timeout: Option, #[serde(default)] + socket: Option, + #[serde(default)] bundled: Option, #[serde(default)] available_tools: Vec, @@ -140,6 +142,7 @@ impl From for ExtensionConfig { env_keys, headers, timeout, + socket, bundled, available_tools }, diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index bcb1c33bf0..7260dfbeb5 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -5132,6 +5132,11 @@ "type": "string", "description": "The name used to identify this extension" }, + "socket": { + "type": "string", + "description": "Optional Unix domain socket path for HTTP-over-UDS transport.\nWhen set, the HTTP connection is routed through this socket while\n`uri` is used for the Host header and path.\nUse `@name` for Linux abstract sockets.", + "nullable": true + }, "timeout": { "type": "integer", "format": "int64", diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 9ef0e75544..a9e8d80bf3 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -405,6 +405,13 @@ export type ExtensionConfig = { * The name used to identify this extension */ name: string; + /** + * Optional Unix domain socket path for HTTP-over-UDS transport. + * When set, the HTTP connection is routed through this socket while + * `uri` is used for the Host header and path. + * Use `@name` for Linux abstract sockets. + */ + socket?: string | null; timeout?: number | null; type: 'streamable_http'; uri: string;