Transport Streamable HTTP - ChristianPresley/zig-sdk GitHub Wiki

Transport Streamable HTTP

The Streamable HTTP transport carries each JSON-RPC request in one HTTP POST1. The server answers with one JSON message or with a stream of server-sent events (SSE). The HTTP headers mirror the method and the name of each request. This page describes the server side, the client side and the security defaults of the SDK.

Applies to: MCP 2026-07-28, Zig 0.16.0, zig-sdk 0.1.0. Nested header annotations, the header value checks, the cancellation at a closed connection, DPoP, auth_provider and status 429: zig-sdk 0.2.0. The time limits of the server: zig-sdk 0.3.0. The limit for each SSE event, the warnings of the client, Diagnostics.http_status and the proxy: zig-sdk 0.4.0.

The server

mcp.transport.http.Server serves one MCP server on one path.

var http_server: mcp.transport.http.Server = .init(io, gpa, &server, .{ .port = 3000 });
defer http_server.deinit();
try http_server.bind();
try http_server.serve();

bind() opens the listen socket. After bind(), bound_port has the port, also when you give port 0. serve() accepts connections until a call to shutdown(). shutdown() is safe to call from another task. It ends the active streams. It closes a connection that waits for a request at once, and a busy connection after limits.shutdown_grace.

The options:

Option Default Meaning
address 127.0.0.1 The address to bind. The default accepts loopback connections only.
port 3000 The port.
path /mcp The path of the MCP endpoint.
response_mode auto auto sends JSON or SSE as the request needs. sse always sends SSE. json never requires SSE in the Accept header.
allowed_origins empty The origins that the server accepts. Empty means loopback origins only.
allowed_hosts empty The host names that the server accepts in the Host header. Empty means loopback names and the bound address.
keepalive true Send an SSE comment on a listen stream every limits.http.sse_keepalive (15 s).
tls null Serve HTTPS with this TLS 1.3 server. See TLS and Certificates.
auth null Require an access token (Bearer or DPoP) and serve the protected resource metadata. See Authorization.

Requests and responses

The server accepts POST on the endpoint path only. The request body is one JSON-RPC message.

Request Response
A request with a result and no notification 200 with application/json
A request that sends notifications, or a listen stream 200 with text/event-stream
A notification 202 Accepted with no body
A response from the client 400 with -32600
Another path 404
A method other than POST 405 with Allow: POST
An Origin or a Host that the server does not accept 403 with -32600
A content type that is not application/json 415
An Accept header without both media types 406
A body larger than limits.http.max_body_bytes (4 MiB) 413
A request head larger than limits.http.max_head_bytes (16 KiB) 431
Text that is not JSON 400 with -32700
A header mismatch, or a protocol version that is not 2026-07-28 400 with the JSON-RPC error
An unknown method 404 with -32601

On a JSON response, the HTTP status follows the JSON-RPC error code. The codes -32700, -32600, -32602, -32020, -32021 and -32022 give 400. The code -32601 gives 404. The rate limit error -31429 gives 429 with a Retry-After header. All other responses give 200.

An SSE response has the headers cache-control: no-cache, no-transform and x-accel-buffering: no. Each event has the type message and one JSON-RPC message in its data2. The last event carries the response. Then the server closes the connection, because the stream and the connection end together.

Header mirroring

Every request carries MCP-Protocol-Version and Mcp-Method3. A request for a tool, a resource, a prompt or a task also carries Mcp-Name. Tool parameters with an x-mcp-header annotation also travel in Mcp-Param-* headers.

A value that is not plain visible ASCII travels as =?base64?...?=. The decoder requires standard base64 with padding. The server compares every header with the body before it runs the handler. A missing header or a different value gets -32020.

An x-mcp-header annotation can be on a nested property that a chain of properties keywords reaches from the root. The server reads the body value at that property path. The server also gives -32020 in these cases:

  • A raw value of Mcp-Method, Mcp-Name or Mcp-Param-* has a control character other than tab, or a byte that is not ASCII.
  • The body value of an annotated parameter is an integer outside the safe range of JavaScript, from -(2^53-1) to 2^53-1.

Cancellation

The server reads the socket while the handler of a request runs. When the client closes the connection, the server cancels the request, also when the handler writes nothing. The handler sees the cancellation with ctx.checkCancel(). After the cancellation, the server sends nothing more for the request, and it closes the connection. An idle listen stream also ends when the client closes the connection.

A client without headers that sends initialize gets the error for an unsupported protocol version, with the supported versions. The server supports 2026-07-28 only.

The client

mcp.transport.HttpClient.init(io, gpa, options) makes the client transport. transport() gives the transport for mcp.Client.

Option Default Meaning
url none The URL of the endpoint, with http or https.
extra_headers empty Headers that the client adds to every request.
max_response_bytes 4 MiB The maximum size of one message from the server: an application/json body, or one event of an SSE response. An SSE response has no limit of its total size.
poll_interval 50 ms How often a request checks for cancellation and its deadline.
auth null An OAuth 2.1 client that answers 401 and 403 challenges.
auth_provider null Another flow that answers the challenges, for example ClientCredentials or EnterpriseClient. It has priority over auth.
tls null The trust policy and the client certificate for https. Null uses the system trust store.
proxy .{ .environment = null } The HTTP proxy of the connections. The default connects directly. See Proxy.

Each request is one POST on its own HTTP/1.1 connection. The client mirrors the request into the Mcp-* headers. It learns the x-mcp-header annotations from tools/list results. It removes a tool with an invalid annotation from the list that the application sees, and it logs a warning.

The client refuses to mirror an integer outside the safe range with error.InvalidRequest. After a -32020 error for a tool call, the client reads tools/list again and sends the tool call one more time. See the Client Guide.

The client gives each SSE event to the caller when the event arrives. Thus progress notifications arrive without delay. A request without a timeout gets limits.request_timeout (60 s). At the end of the timeout, the client stops the request and closes its connection. The server then cancels the request.

Responses that the client cannot use

max_response_bytes applies to each event of an SSE response, and not to the sum of the events. Thus keep-alive comments, progress notifications and log messages do not stop a long tool call or a listen stream. Before version 0.4.0, the client ended an SSE response after max_response_bytes in total. Then a tool call with 50 progress notifications each second failed after approximately 11 minutes.

An application/json body or one SSE event over max_response_bytes makes the request fail with error.InvalidResponse. The transport logs a warning. The client does not send the request again, because the same request gets the same body again. The complete events before the large event go to the caller first.

The SSE parser of the client (sse.Parser) ignores an id field2 with more than sse.Parser.max_id_bytes (256 bytes). Each event gets a copy of the last event ID. Thus a server cannot send one long id line and then many small events that each use a copy of it.

A response without a JSON-RPC message also gives error.InvalidResponse. Examples are status 404 for a wrong path and an error page of a proxy. The transport logs a warning with the status and the content type. It also logs a warning for status 401 or 403 when the client has no authorization provider.

The warning has at most 100 bytes of the content type. A byte that is not visible ASCII or a space becomes a question mark. Thus a server cannot put a line break or an escape sequence into the log.

Client.Diagnostics.http_status has the HTTP status of the last response to a request, also when the request failed. Thus a caller can tell a wrong path, for example status 404, from a response that is not valid. Both give error.InvalidResponse.

Proxy

The option proxy sets the HTTP proxy of the client connections. The client asks the proxy for a CONNECT tunnel to the server4, also for an http URL. The tunnel carries the TLS handshake and the requests. Thus for an https URL, the proxy sees only the host and the port of the server, and not the requests and the tokens. For an http URL, the tunnel carries the requests without TLS. Each request opens its own connection, thus its own tunnel.

Value Meaning
.{ .environment = null } The default. The client reads no environment and connects directly, as before version 0.4.0.
.{ .environment = init.environ_map } The proxy variables of this environment map, for example the map of the std.process.Init of main. The map must stay valid and unchanged while the client lives.
.{ .explicit = .{ .url = "http://proxy.example:3128" } } This proxy, and not the environment. no_proxy names the hosts that the client connects to directly. With loopback = true, the proxy also gets the loopback hosts.
.none No proxy.

init selects the proxy for the URL. With an environment, the client uses these variables:

  • For an https URL, the first of https_proxy, HTTPS_PROXY, all_proxy and ALL_PROXY with a value.
  • For an http URL, the first of http_proxy, HTTP_PROXY, all_proxy and ALL_PROXY with a value. When REQUEST_METHOD has a value, the client ignores http_proxy and HTTP_PROXY. A web server that starts the process for a request sets REQUEST_METHOD, and the Proxy header of that request can set HTTP_PROXY.
  • no_proxy, else NO_PROXY: the hosts that the client connects to directly.

A proxy from the environment never gets a loopback host: localhost, a name under .localhost, an address in 127.0.0.0/8 and ::1. The same applies to an explicit proxy without loopback. The client does not resolve names.

mcp.transport.proxy.bypasses gives the match of a no_proxy list. A comma, a space or a tab separates the entries.

  • * matches each host.
  • A name matches the same host and each host under it, with or without a dot or *. at the start. Thus example.com matches api.example.com, but not badexample.com.
  • An IP address matches the same address. An address with a prefix length, such as 10.0.0.0/8, matches the range.
  • An entry with a port, such as example.com:8443, matches only that port.

The proxy URL has the form http://host:port, or http://user:password@host:port for a proxy with Basic authentication. Encode a reserved character of the user or the password with a percent sign. Without a scheme, the client adds http://. Without a port, the client uses port 80.

The user and the password go to the proxy only in the CONNECT request, in a proxy-authorization header with the Basic scheme. The log lines show only the host and the port of the proxy. The SDK has no TLS to the proxy and no socks5 proxy. A proxy URL that does not use http makes init fail with error.InvalidProxy. The warning in the log scope mcp_proxy names the source of the URL, but not its value.

When the proxy refuses the tunnel, the log scope mcp_proxy has a warning with the proxy, the target and the status. For status 407, the warning tells that the proxy URL has no user and password, or wrong ones. The exchange then fails with error.WriteFailed, and the client returns error.TransportFailed. Give the same proxy value to OAuthClient.Options.proxy, so that the authorization requests use the same proxy. See Authorization.

Security defaults

  • The server binds to 127.0.0.1.
  • A request head must arrive in limits.http.head_timeout (10 s), else the server answers HTTP 408. For the first request, the time includes the TLS handshake.
  • A body must arrive in limits.http.idle_timeout (60 s) after its head, else the server answers HTTP 408. A keep-alive connection without a new request in this time closes.
  • The server compares the Origin and Host headers with the allowed lists before it reads the body. This check prevents DNS rebinding attacks1.
  • With auth, the server checks the access token before it reads the body. The raw token never gets to a handler.
  • The server limits the body size, the head size and the number of connections. See Limits and Security.
  • The client limits the size of one JSON body and of one SSE event. Its log lines contain no control character from a header of the server.
  • The client uses a proxy only when the application gives an environment or a proxy URL. A proxy from the environment never gets a loopback host.

Before you bind to an address other than loopback, set allowed_origins and allowed_hosts. Behind a reverse proxy, read Deploy Reverse Proxy.

References

  1. MCP-HTTP-2026-07-28: Streamable HTTP transport, with the security warning about the Origin header.
  2. WHATWG-SSE: Server-sent events.
  3. MCP-SEP-2243: HTTP headers derived from the message.
  4. RFC9110: HTTP Semantics, with the CONNECT method in section 9.3.6.
⚠️ **GitHub.com Fallback** ⚠️