Skip to content

feat(transport): add Unix domain socket client for streamable HTTP#749

Open
wpfleger96 wants to merge 6 commits intomodelcontextprotocol:mainfrom
wpfleger96:main
Open

feat(transport): add Unix domain socket client for streamable HTTP#749
wpfleger96 wants to merge 6 commits intomodelcontextprotocol:mainfrom
wpfleger96:main

Conversation

@wpfleger96
Copy link

Adds UnixSocketHttpClient, a new StreamableHttpClient implementation that routes MCP HTTP traffic through a Unix domain socket using hyper + tokio::net::UnixStream, gated behind the transport-streamable-http-client-unix-socket feature.

MCP hosts running in Kubernetes environments with Envoy sidecars can't use TCP/DNS because outbound traffic must go through the sidecar proxy, which exposes an abstract Unix socket (e.g., @egress.sock). This unblocks any rmcp-based MCP host from connecting through a service mesh sidecar by setting the socket path at transport construction time. The goose equivalent lives in block/goose#7631 and will be replaced with this once merged.

  • Adds UnixSocketHttpClient with new(socket_path, uri) constructor and from_unix_socket / from_unix_socket_with_config convenience constructors on StreamableHttpClientTransport
  • Supports Linux abstract sockets (@name\0name) via spawn_blocking + std::os::unix::net since tokio::net::UnixStream lacks connect_addr; filesystem sockets work on all Unix targets
  • Extracts RESERVED_HEADERS, validate_custom_header, and extract_scope_from_header from reqwest/streamable_http_client.rs into common/http_header.rs so both HTTP client implementations share a single source of truth for header policy
  • Integration tests bind an axum MCP server to a real UnixListener and exercise handshake, custom header forwarding, and the convenience constructor

MCP hosts in Kubernetes environments with Envoy sidecars need to route
HTTP through Unix domain sockets because DNS-based URIs only resolve
via the proxy. Adds UnixSocketHttpClient implementing StreamableHttpClient
using hyper over tokio::net::UnixStream, gated behind the
transport-streamable-http-client-unix-socket feature.

Also extracts RESERVED_HEADERS, extract_scope_from_header, and
validate_custom_header into common/http_header.rs to share header
validation logic between the reqwest and unix socket implementations.
@github-actions github-actions bot added T-dependencies Dependencies related changes T-test Testing related changes T-config Configuration file changes T-core Core library changes T-transport Transport layer changes labels Mar 12, 2026
- Document one-connection-per-request behavior on UnixSocketHttpClient
- Reject empty socket paths and bare '@' in constructor with assert
- Add explicit dep:http to unix-socket feature for self-documenting deps
- Document MCP-Protocol-Version exception on RESERVED_HEADERS constant
- Fix test catch-all to echo request id instead of hardcoding 1
- Remove leftover sleep(100ms) in test_unix_socket_custom_headers
- Add blank line before macro comment in Cargo.toml
wpfleger96 added a commit to block/goose that referenced this pull request Mar 13, 2026
Replace goose local UnixSocketHttpClient with the upstream implementation
from rmcp transport-streamable-http-client-unix-socket feature
(modelcontextprotocol/rust-sdk#749). Deletes ~420 lines of local transport
code and delegates to rmcp for the Unix socket client, socket path
resolution, Host header derivation, and custom header validation.

Temporarily points rmcp at the wpfleger96/rust-sdk fork for testing.
Will switch back to crates.io once the upstream PR is merged and released.
@wpfleger96 wpfleger96 marked this pull request as ready for review March 13, 2026 15:30
@wpfleger96 wpfleger96 requested a review from a team as a code owner March 13, 2026 15:30
michaelneale
michaelneale previously approved these changes Mar 13, 2026
Copy link
Contributor

@michaelneale michaelneale left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it’s a great idea. Always like stdio when you could use it and sockets have a lot of the same security benefits

- Use std::io::Error::other() instead of Error::new(ErrorKind::Other)
  to satisfy clippy::io_other_error on newer nightly
- Use #[tokio::test(flavor = "current_thread")] for unix socket tests
  since axum's serve(UnixListener) requires spawn_local
- Gate validate_custom_header behind client-side-sse feature since it
  references http::HeaderName which isn't available with default features
@wpfleger96
Copy link
Author

@michaelneale thanks for the approval, looks like I need another one after fixing CI failures 😅

@jokemanfire
Copy link
Member

jokemanfire commented Mar 16, 2026

It's in example here. I don't know we need to wrap it , or just see this example. The abstract of transport is enough now?

@wpfleger96
Copy link
Author

👋 hey @jokemanfire I think the example you linked is actually a different use case than what this PR is trying to solve. It looks like that example uses a raw UnixStream as a byte-stream transport (JSON-RPC directly over the socket). That works when the MCP server itself listens on a Unix socket.

This PR solves a slightly different problem: routing Streamable HTTP traffic through a Unix domain socket, where the socket is a sidecar proxy (e.g., Envoy in Kubernetes). The client still needs to speak HTTP/1.1 with proper Host headers, status code handling, SSE streaming, auth headers, etc., which is why we need an HTTP client (hyper) layered over the UnixStream, rather than using it as a raw byte transport.

This is coming from a specific need I have in goose: I opened block/goose#7631 which adds Unix socket transport so MCP extensions can connect through Envoy sidecars in our Kubernetes environment, where DNS only resolves via the proxy. That PR in goose currently creates its own UnixSocketHttpClient directly, but I created this PR based on feedback on that one so that any MCP client that uses rmcp and needs to route MCP requests over UDS can benefit from this work not just goose

axum::serve(UnixListener) uses spawn_local on Linux, which panics
outside a LocalSet. Replace with manual hyper HTTP/1.1 server that
accepts connections directly from the UnixListener, avoiding the
spawn_local requirement entirely.
wpfleger96 added a commit to block/goose that referenced this pull request Mar 16, 2026
Replace goose local UnixSocketHttpClient with the upstream implementation
from rmcp transport-streamable-http-client-unix-socket feature
(modelcontextprotocol/rust-sdk#749). Deletes ~420 lines of local transport
code and delegates to rmcp for the Unix socket client, socket path
resolution, Host header derivation, and custom header validation.

Temporarily points rmcp at the wpfleger96/rust-sdk fork for testing.
Will switch back to crates.io once the upstream PR is merged and released.
Resolves conflict in reqwest/streamable_http_client.rs test imports:
kept our extract_scope_from_header removal (moved to http_header.rs),
added upstream's new parse_json_rpc_error and JsonRpcMessage imports.
@wpfleger96 wpfleger96 requested a review from michaelneale March 18, 2026 00:33
@wpfleger96
Copy link
Author

👋 @michaelneale @jamadeo @DaleSeo would appreciate a review here if any of you have a moment!

@DaleSeo
Copy link
Member

DaleSeo commented Mar 18, 2026

Hi @wpfleger96, could you please look into the failing tests in CI? Thanks!

@jamadeo
Copy link
Contributor

jamadeo commented Mar 18, 2026

What about using a reqwest client configured with a unix socket?

https://docs.rs/reqwest/latest/reqwest/struct.ClientBuilder.html#method.unix_socket

this can then be used with

pub fn with_client(client: C, config: StreamableHttpClientTransportConfig) -> Self {
as long as you have the reqwest feature

The local feature causes ().serve(transport) to use spawn_local, which
requires a LocalSet. Gate the integration tests with not(feature = "local")
to match every other integration test in the repo.
@wpfleger96
Copy link
Author

@DaleSeo just pushed the fix for failing tests if I could get another approval to let the checks run again!

@wpfleger96
Copy link
Author

@jamadeo the reason I went with a separate UnixSocketHttpClient is Linux abstract (non filesystem) Unix socket support - these live in a kernel namespace instead of the filesystem, which is how Envoy sidecars typically expose their proxy in K8s pods. reqwest::ClientBuilder::unix_socket() does work for filesystem Unix sockets and users who already have reqwest enabled can use that today with StreamableHttpClientTransport::with_client(), but for abstract sockets you'd have to manually construct an OsString with a leading null byte:

let mut bytes: Vec<u8> = vec![0];
bytes.extend_from_slice(b"egress.sock");
let path = PathBuf::from(OsString::from_vec(bytes));
let client = reqwest::Client::builder().unix_socket(path).build()?;

vs this PR:

let transport = StreamableHttpClientTransport::from_unix_socket("@egress.sock", "http://mcp-server.internal/mcp");

the @ prefix opts into the abstract socket namespace so without it the path is treated as a regular filesystem socket, and follows the same convention used by socat/ss/systemd

this feature is also independent of the reqwest feature flag, it only needs hyper + tokio

what do you think?

@jamadeo
Copy link
Contributor

jamadeo commented Mar 18, 2026

Couldn't it just be

let client = reqwest::Client::builder().unix_socket("\0egress.sock").build()?;

?

Or even if not, 4 lines does still seem pretty simple. It is nice that this doesn't require reqwest though.

@wpfleger96
Copy link
Author

ahhh yeah great point, I think you're right this would work:

let client = reqwest::Client::builder().unix_socket("\0egress.sock").build()?;

I didn't catch the faulty assumption about null bytes in strings, seems like this is fine in Rust (but not C)

so the main value of this PR at this point is really the reqwest-independence, plus any rmcp-based MCP host gets abstract socket support out of the box rather than implementing their own Unix socket HTTP clients

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

T-config Configuration file changes T-core Core library changes T-dependencies Dependencies related changes T-test Testing related changes T-transport Transport layer changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants