Skip to content

Commit c4289ce

Browse files
committed
feat(extensions): add Unix socket transport for StreamableHttp extensions
In some service mesh environments (e.g. Kubernetes with an Envoy sidecar), all outbound HTTP must route through a local proxy rather than making direct TCP connections to remote endpoints. Adds an optional `socket` field to `ExtensionConfig::StreamableHttp` so connections can be routed through a Unix domain socket, with `uri` preserved as the HTTP Host header and path. Implements `rmcp::StreamableHttpClient` for a new `UnixSocketHttpClient` using hyper + `tokio::net::UnixStream`, plugging into rmcp's existing `StreamableHttpClientTransport::with_client()` extension point. Supports both filesystem sockets and Linux abstract sockets (`@` prefix). Auth at the transport layer (e.g. mutual TLS via the sidecar) makes OAuth retry unnecessary on this path. Configured via `socket: "@egress.sock"` alongside the existing `uri` field in YAML config or the goosed API — no change for TCP-only deployments.
1 parent e727cea commit c4289ce

15 files changed

Lines changed: 617 additions & 3 deletions

File tree

Cargo.lock

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/goose-acp/src/server.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ fn mcp_server_to_extension_config(mcp_server: McpServer) -> Result<ExtensionConf
8686
.into_iter()
8787
.map(|h| (h.name, h.value))
8888
.collect(),
89+
socket: None,
8990
timeout: None,
9091
bundled: Some(false),
9192
available_tools: vec![],
@@ -1403,6 +1404,7 @@ mod tests {
14031404
"Authorization".into(),
14041405
"Bearer ghp_xxxxxxxxxxxx".into()
14051406
)]),
1407+
socket: None,
14061408
timeout: None,
14071409
bundled: Some(false),
14081410
available_tools: vec![],

crates/goose-cli/src/commands/configure.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1140,6 +1140,7 @@ fn configure_streamable_http_extension() -> anyhow::Result<()> {
11401140
envs: Envs::new(envs),
11411141
env_keys,
11421142
headers,
1143+
socket: None,
11431144
description,
11441145
timeout: Some(timeout),
11451146
bundled: None,

crates/goose-cli/src/recipes/secret_discovery.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ mod tests {
150150
bundled: None,
151151
available_tools: Vec::new(),
152152
headers: HashMap::new(),
153+
socket: None,
153154
},
154155
ExtensionConfig::Stdio {
155156
name: "slack-mcp".to_string(),
@@ -246,6 +247,7 @@ mod tests {
246247
bundled: None,
247248
available_tools: Vec::new(),
248249
headers: HashMap::new(),
250+
socket: None,
249251
},
250252
ExtensionConfig::Stdio {
251253
name: "service-b".to_string(),
@@ -305,6 +307,7 @@ mod tests {
305307
bundled: None,
306308
available_tools: Vec::new(),
307309
headers: HashMap::new(),
310+
socket: None,
308311
}]),
309312
sub_recipes: Some(vec![SubRecipe {
310313
name: "child-recipe".to_string(),

crates/goose-cli/src/session/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,7 @@ impl CliSession {
337337
envs: Envs::new(HashMap::new()),
338338
env_keys: Vec::new(),
339339
headers: HashMap::new(),
340+
socket: None,
340341
description: goose::config::DEFAULT_EXTENSION_DESCRIPTION.to_string(),
341342
timeout: Some(timeout),
342343
bundled: None,
@@ -2034,6 +2035,7 @@ mod tests {
20342035
envs: Envs::default(),
20352036
env_keys: vec![],
20362037
headers: HashMap::new(),
2038+
socket: None,
20372039
description: goose::config::DEFAULT_EXTENSION_DESCRIPTION.to_string(),
20382040
timeout: Some(300),
20392041
bundled: None,
@@ -2049,6 +2051,7 @@ mod tests {
20492051
envs: Envs::default(),
20502052
env_keys: vec![],
20512053
headers: HashMap::new(),
2054+
socket: None,
20522055
description: goose::config::DEFAULT_EXTENSION_DESCRIPTION.to_string(),
20532056
timeout: Some(300),
20542057
bundled: None,
@@ -2064,6 +2067,7 @@ mod tests {
20642067
envs: Envs::default(),
20652068
env_keys: vec![],
20662069
headers: HashMap::new(),
2070+
socket: None,
20672071
description: goose::config::DEFAULT_EXTENSION_DESCRIPTION.to_string(),
20682072
timeout: Some(300),
20692073
bundled: None,

crates/goose/Cargo.toml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,15 @@ pulldown-cmark = "0.13.0"
141141
llama-cpp-2 = { git = "https://github.com/jh-block/llama-cpp-rs.git", branch = "goose-patches", features = ["sampler"] }
142142
encoding_rs = "0.8.35"
143143

144+
# Unix domain socket HTTP transport for StreamableHttp extensions
145+
[target.'cfg(unix)'.dependencies]
146+
hyper = { version = "1", features = ["client", "http1"] }
147+
hyper-util = { version = "0.1", features = ["tokio"] }
148+
http-body-util = "0.1"
149+
sse-stream = "0.2"
150+
bytes = { workspace = true }
151+
http = { workspace = true }
152+
144153
[target.'cfg(target_os = "windows")'.dependencies]
145154
winapi = { version = "0.3", features = ["wincred"] }
146155

crates/goose/src/agents/extension.rs

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,11 @@ pub enum ExtensionConfig {
230230
env_keys: Vec<String>,
231231
#[serde(default)]
232232
headers: HashMap<String, String>,
233+
/// Unix domain socket path to route HTTP through (e.g. "@egress.sock" for Envoy sidecar).
234+
/// When set, the physical connection goes through this socket while `uri` is used for the
235+
/// HTTP Host header and path. Useful in K8s environments where DNS only resolves via Envoy.
236+
#[serde(default)]
237+
socket: Option<String>,
233238
// NOTE: set timeout to be optional for compatibility.
234239
// However, new configurations should include this field.
235240
timeout: Option<u64>,
@@ -301,6 +306,7 @@ impl ExtensionConfig {
301306
envs: Envs::default(),
302307
env_keys: Vec::new(),
303308
headers: HashMap::new(),
309+
socket: None,
304310
description: description.into(),
305311
timeout: Some(timeout.into()),
306312
bundled: None,
@@ -455,6 +461,7 @@ impl ExtensionConfig {
455461
envs,
456462
env_keys,
457463
headers,
464+
socket,
458465
timeout,
459466
bundled,
460467
available_tools,
@@ -474,6 +481,7 @@ impl ExtensionConfig {
474481
envs: Envs::new(merged),
475482
env_keys: vec![],
476483
headers,
484+
socket,
477485
timeout,
478486
bundled,
479487
available_tools,
@@ -490,9 +498,12 @@ impl std::fmt::Display for ExtensionConfig {
490498
ExtensionConfig::Sse { name, .. } => {
491499
write!(f, "SSE({}: unsupported)", name)
492500
}
493-
ExtensionConfig::StreamableHttp { name, uri, .. } => {
494-
write!(f, "StreamableHttp({}: {})", name, uri)
495-
}
501+
ExtensionConfig::StreamableHttp {
502+
name, uri, socket, ..
503+
} => match socket {
504+
Some(s) => write!(f, "StreamableHttp({}: {} via {})", name, uri, s),
505+
None => write!(f, "StreamableHttp({}: {})", name, uri),
506+
},
496507
ExtensionConfig::Stdio {
497508
name, cmd, args, ..
498509
} => {
@@ -665,6 +676,7 @@ available_tools: []
665676
)]
666677
.into_iter()
667678
.collect(),
679+
socket: None,
668680
timeout: None,
669681
bundled: None,
670682
available_tools: vec![],
@@ -685,6 +697,7 @@ available_tools: []
685697
)]
686698
.into_iter()
687699
.collect(),
700+
socket: None,
688701
timeout: None,
689702
bundled: None,
690703
available_tools: vec![],
@@ -758,6 +771,7 @@ available_tools: []
758771
)]
759772
.into_iter()
760773
.collect(),
774+
socket: None,
761775
timeout: None,
762776
bundled: None,
763777
available_tools: vec![],
@@ -775,6 +789,7 @@ available_tools: []
775789
headers: [("Authorization".to_string(), "Bearer secret_value".to_string())]
776790
.into_iter()
777791
.collect(),
792+
socket: None,
778793
timeout: None,
779794
bundled: None,
780795
available_tools: vec![],
@@ -825,4 +840,64 @@ available_tools: []
825840
cfg.set("MY_SECRET", &"secret_value", true).unwrap();
826841
assert_eq!(config.resolve(&cfg).await.unwrap(), expected);
827842
}
843+
844+
#[test]
845+
fn test_deserialize_streamable_http_with_socket() {
846+
let config: ExtensionConfig = serde_yaml::from_str(
847+
"type: streamable_http\nname: ai-app-info\ndescription: test\nuri: http://example.com/mcp\nsocket: \"@egress.sock\"\n",
848+
)
849+
.unwrap();
850+
if let ExtensionConfig::StreamableHttp { socket, .. } = config {
851+
assert_eq!(socket, Some("@egress.sock".to_string()));
852+
} else {
853+
panic!("unexpected variant");
854+
}
855+
}
856+
857+
#[test]
858+
fn test_deserialize_streamable_http_without_socket() {
859+
let config: ExtensionConfig = serde_yaml::from_str(
860+
"type: streamable_http\nname: ai-app-info\ndescription: test\nuri: http://example.com/mcp\n",
861+
)
862+
.unwrap();
863+
if let ExtensionConfig::StreamableHttp { socket, .. } = config {
864+
assert_eq!(socket, None);
865+
} else {
866+
panic!("unexpected variant");
867+
}
868+
}
869+
870+
#[test]
871+
fn test_display_streamable_http_without_socket() {
872+
let config = ExtensionConfig::streamable_http(
873+
"ai-app-info",
874+
"http://example.com/mcp",
875+
"test",
876+
300u64,
877+
);
878+
assert_eq!(
879+
format!("{config}"),
880+
"StreamableHttp(ai-app-info: http://example.com/mcp)"
881+
);
882+
}
883+
884+
#[test]
885+
fn test_display_streamable_http_with_socket() {
886+
let config = ExtensionConfig::StreamableHttp {
887+
name: "ai-app-info".to_string(),
888+
uri: "http://example.com/mcp".to_string(),
889+
description: "test".to_string(),
890+
timeout: Some(300),
891+
headers: Default::default(),
892+
envs: Default::default(),
893+
env_keys: vec![],
894+
socket: Some("@egress.sock".to_string()),
895+
bundled: None,
896+
available_tools: vec![],
897+
};
898+
assert_eq!(
899+
format!("{config}"),
900+
"StreamableHttp(ai-app-info: http://example.com/mcp via @egress.sock)"
901+
);
902+
}
828903
}

crates/goose/src/agents/extension_manager.rs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,7 @@ pub(crate) fn substitute_env_vars(value: &str, env_map: &HashMap<String, String>
393393
const GOOSE_USER_AGENT: reqwest::header::HeaderValue =
394394
reqwest::header::HeaderValue::from_static(concat!("goose/", env!("CARGO_PKG_VERSION")));
395395

396+
#[allow(clippy::too_many_arguments)]
396397
async fn create_streamable_http_client(
397398
uri: &str,
398399
timeout: Option<u64>,
@@ -401,7 +402,29 @@ async fn create_streamable_http_client(
401402
provider: SharedProvider,
402403
client_name: String,
403404
capabilities: GooseMcpClientCapabilities,
405+
socket: Option<&str>,
404406
) -> ExtensionResult<Box<dyn McpClientTrait>> {
407+
#[cfg(unix)]
408+
if let Some(socket_path) = socket {
409+
return create_unix_socket_http_client(
410+
uri,
411+
timeout,
412+
headers,
413+
socket_path,
414+
provider,
415+
client_name,
416+
capabilities,
417+
)
418+
.await;
419+
}
420+
421+
#[cfg(not(unix))]
422+
if socket.is_some() {
423+
return Err(ExtensionError::ConfigError(
424+
"Unix domain socket transport is not supported on this platform".to_string(),
425+
));
426+
}
427+
405428
let mut default_headers = HeaderMap::new();
406429

407430
default_headers.insert(reqwest::header::USER_AGENT, GOOSE_USER_AGENT);
@@ -476,6 +499,64 @@ async fn create_streamable_http_client(
476499
}
477500
}
478501

502+
/// Connect to a StreamableHttp MCP server via a Unix domain socket.
503+
/// OAuth retry is intentionally omitted — auth at the socket layer is typically
504+
/// handled by the sidecar proxy (e.g., Envoy mTLS) rather than HTTP Bearer tokens.
505+
#[cfg(unix)]
506+
async fn create_unix_socket_http_client(
507+
uri: &str,
508+
timeout: Option<u64>,
509+
headers: &HashMap<String, String>,
510+
socket_path: &str,
511+
provider: SharedProvider,
512+
client_name: String,
513+
capabilities: GooseMcpClientCapabilities,
514+
) -> ExtensionResult<Box<dyn McpClientTrait>> {
515+
use super::unix_socket_http_client::UnixSocketHttpClient;
516+
use http::header::HeaderValue;
517+
518+
#[cfg(not(target_os = "linux"))]
519+
if socket_path.starts_with('@') {
520+
return Err(ExtensionError::ConfigError(
521+
"Abstract Unix sockets (@-prefixed) are only supported on Linux".to_string(),
522+
));
523+
}
524+
525+
let mut default_headers = std::collections::HashMap::new();
526+
default_headers.insert(reqwest::header::USER_AGENT, GOOSE_USER_AGENT);
527+
for (key, value) in headers {
528+
let name = HeaderName::try_from(key)
529+
.map_err(|_| ExtensionError::ConfigError(format!("invalid header: {key}")))?;
530+
let val: HeaderValue = value
531+
.parse()
532+
.map_err(|_| ExtensionError::ConfigError(format!("invalid header value: {key}")))?;
533+
default_headers.insert(name, val);
534+
}
535+
536+
let unix_client = UnixSocketHttpClient::new(uri, socket_path, default_headers);
537+
let transport = StreamableHttpClientTransport::with_client(
538+
unix_client,
539+
StreamableHttpClientTransportConfig {
540+
uri: uri.into(),
541+
..Default::default()
542+
},
543+
);
544+
545+
let timeout_duration =
546+
Duration::from_secs(timeout.unwrap_or(crate::config::DEFAULT_EXTENSION_TIMEOUT));
547+
548+
Ok(Box::new(
549+
McpClient::connect(
550+
transport,
551+
timeout_duration,
552+
provider,
553+
client_name,
554+
capabilities,
555+
)
556+
.await?,
557+
))
558+
}
559+
479560
impl ExtensionManager {
480561
pub fn new(
481562
provider: SharedProvider,
@@ -556,6 +637,7 @@ impl ExtensionManager {
556637
name,
557638
envs,
558639
env_keys,
640+
socket,
559641
..
560642
} => {
561643
let config = Config::global();
@@ -576,6 +658,7 @@ impl ExtensionManager {
576658
self.provider.clone(),
577659
self.client_name.clone(),
578660
capability,
661+
socket.as_deref(),
579662
)
580663
.await?
581664
}

crates/goose/src/agents/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ pub(crate) mod subagent_handler;
2020
pub(crate) mod subagent_task_config;
2121
mod tool_execution;
2222
pub mod types;
23+
#[cfg(unix)]
24+
pub(crate) mod unix_socket_http_client;
2325
pub mod validate_extensions;
2426

2527
pub use agent::{Agent, AgentConfig, AgentEvent, ExtensionLoadResult, GoosePlatform};

0 commit comments

Comments
 (0)