feat(extensions): support Unix domain socket transport for StreamableHttp MCP (#7860)

This commit is contained in:
Will Pfleger
2026-04-17 15:16:37 -04:00
committed by GitHub
parent 1a18c2748c
commit 7fa662bcac
16 changed files with 212 additions and 29 deletions
Generated
+2
View File
@@ -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",
+2
View File
@@ -164,6 +164,7 @@ fn mcp_server_to_extension_config(mcp_server: McpServer) -> Result<ExtensionConf
.map(|h| (h.name, h.value))
.collect(),
timeout,
socket: None,
bundled: Some(false),
available_tools: vec![],
})
@@ -2962,6 +2963,7 @@ mod tests {
"Bearer ghp_xxxxxxxxxxxx".into()
)]),
timeout: None,
socket: None,
bundled: Some(false),
available_tools: vec![],
})
@@ -1174,6 +1174,7 @@ fn configure_streamable_http_extension() -> anyhow::Result<()> {
headers,
description,
timeout: Some(timeout),
socket: None,
bundled: None,
available_tools: Vec::new(),
},
@@ -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(),
+4
View File
@@ -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![],
}
+5 -16
View File
@@ -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<S: Service<RoleServer>> Service<RoleServer> for ValidatingService<S> {
}
}
#[derive(Clone)]
pub struct McpFixtureServer {
tool_router: ToolRouter<McpFixtureServer>,
}
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())
+1
View File
@@ -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 }
+2
View File
@@ -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![],
};
+64 -2
View File
@@ -235,6 +235,12 @@ pub enum ExtensionConfig {
// NOTE: set timeout to be optional for compatibility.
// However, new configurations should include this field.
timeout: Option<u64>,
/// 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<String>,
#[serde(default)]
bundled: Option<bool>,
#[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)"
);
}
}
+107 -7
View File
@@ -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<McpClient, ClientInitializeError>)
};
if let Some(http_err) = error.downcast_ref::<StreamableHttpError<reqwest::Error>>() {
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::<StreamableHttpError<rmcp::transport::common::unix_socket::UnixSocketError>>(
)
{
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<u64>,
headers: &HashMap<String, String>,
name: &str,
socket: Option<&str>,
provider: SharedProvider,
client_name: String,
capabilities: GooseMcpClientCapabilities,
roots_dir: &std::path::Path,
) -> ExtensionResult<Box<dyn McpClientTrait>> {
#[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<u64>,
headers: &HashMap<String, String>,
name: &str,
socket_path: &str,
provider: SharedProvider,
client_name: String,
capabilities: GooseMcpClientCapabilities,
roots_dir: &std::path::Path,
) -> ExtensionResult<Box<dyn McpClientTrait>> {
use rmcp::transport::UnixSocketHttpClient;
let unix_client = UnixSocketHttpClient::new(socket_path, uri);
let mut custom_headers = std::collections::HashMap::<HeaderName, HeaderValue>::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::<HeaderValue>()
.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,
+2 -4
View File
@@ -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(
@@ -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![],
}],
+2
View File
@@ -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![],
},
@@ -62,6 +62,8 @@ enum RecipeExtensionConfigInternal {
headers: HashMap<String, String>,
timeout: Option<u64>,
#[serde(default)]
socket: Option<String>,
#[serde(default)]
bundled: Option<bool>,
#[serde(default)]
available_tools: Vec<String>,
@@ -140,6 +142,7 @@ impl From<RecipeExtensionConfigInternal> for ExtensionConfig {
env_keys,
headers,
timeout,
socket,
bundled,
available_tools
},
+5
View File
@@ -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",
+7
View File
@@ -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;