mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
feat(extensions): support Unix domain socket transport for StreamableHttp MCP (#7860)
This commit is contained in:
Generated
+2
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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![],
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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![],
|
||||
};
|
||||
|
||||
@@ -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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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![],
|
||||
}],
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user