streamablehttp

package
v0.7.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 25, 2026 License: Apache-2.0 Imports: 12 Imported by: 0

Documentation

Overview

Package streamablehttp is the MCP Streamable HTTP transport: it speaks the protocol to a remote server over HTTP POST, with server-to-client messages arriving on SSE streams.

What this transport owns, and what it does not

It owns the HTTP client. It does not own the HTTP protocol: unlike the stdio transport — which declines the SDK's command transport because that transport starts the process itself, leaving no seam for a host that confines its servers — this package uses the SDK's StreamableClientTransport, because the SDK's HTTP transport has the seam stdio's lacked. It takes an *http.Client, and an *http.Client is enough to own everything this module needs to own:

  • credentials, via a RoundTripper that asks the auth seam per request;
  • TLS, via that client's transport, which this package builds or vets;
  • timeouts, likewise;
  • what may be buffered, via the response bodies that RoundTripper returns.

What is left over is the part that is fiddly and that nobody should write twice: the Mcp-Session-Id handshake, the Mcp-Protocol-Version header, the standalone SSE stream, resumption by Last-Event-ID, and the DELETE that ends a session. That is protocol, the SDK implements it, and this package uses the SDK for what the SDK is for.

Retries

A tool call is never retried. Not "retried carefully" — never.

This is a property of the wiring and not a promise made in a comment. The SDK's client re-sends a POST in exactly one place: when it is holding an OAuthHandler and a request comes back 401 or 403, it authorizes and sends the same POST again. This transport leaves OAuthHandler nil — permanently, and the auth seam here is a RoundTripper precisely so that it can stay nil — so the POST carrying a call is issued once and its failure is reported, whatever it was. There is no path that resends it, so there is no path that can double-execute a tool that charges a credit card.

What is retried is reading. A dropped SSE stream is reconnected with Last-Event-ID, which resumes the byte stream the server is already committed to sending; the request that caused it is not re-issued. That is the protocol's own resumption and it is safe by construction: it re-reads a reply, it does not re-run a call.

Trust

The server is untrusted and remote, which makes it strictly worse than the stdio child: it is anonymous until TLS says otherwise, it can hold a stream open forever, it can answer a small request with an unbounded body, and it can tell this client to go somewhere else. So TLS is verified always, cleartext is refused to anything but loopback, every response body is bounded before it is buffered, every stream is bounded per frame — because a total on a stream that is designed to live for a session is not a limit, it is an expiry date — and every frame is on a clock, because a server that starts a message and stops is otherwise a socket held forever.

A redirect that leaves the configured origin is refused outright, credential or no credential. This one is worth stating plainly because getting it wrong makes this package *worse* than the http.Client underneath it: the stdlib strips Authorization across origins, and a transport that attaches credentials in a RoundTripper — as this one does, so that they can be refreshed per request — runs below that logic and would put the header back. See httpsec.RedirectGuard.

Index

Constants

View Source
const (
	// DefaultDialTimeout bounds the TCP connect.
	DefaultDialTimeout = 10 * time.Second
	// DefaultTLSHandshakeTimeout bounds the TLS handshake.
	DefaultTLSHandshakeTimeout = 10 * time.Second
	// DefaultResponseHeaderTimeout bounds the wait for response headers. It is
	// generous because a legitimate tool call can be slow to start answering,
	// and a server that has said nothing for this long is not going to.
	DefaultResponseHeaderTimeout = 30 * time.Second
	// DefaultFrameTimeout bounds one frame's arrival. It is generous enough for
	// a large result over a poor link — the frame cap is a few mebibytes, and a
	// minute of it is a floor of tens of kilobytes a second — and short enough
	// that a server which has stopped mid-message is noticed the same minute.
	DefaultFrameTimeout = 60 * time.Second
	// DefaultIdleConnTimeout bounds an unused pooled connection.
	DefaultIdleConnTimeout = 90 * time.Second
	// DefaultRequestTimeout bounds a whole non-streaming request — the DELETE
	// that releases the session. It is shorter than the other bounds on
	// purpose: nothing reads the DELETE's answer, a server that has not
	// released a session in this long is not going to, and this is the bound a
	// cancelled caller waits out (see Timeouts.Request), so a generous value
	// here is paid for by the caller who already gave up.
	DefaultRequestTimeout = 10 * time.Second
)

Defaults applied when the corresponding Timeouts field is zero.

Variables

This section is empty.

Functions

func New

func New(cfg Config) (client.TransportFactory, error)

New validates cfg and returns a Streamable HTTP TransportFactory.

It fails closed: every violation is a *client.Error of class FailureInvalidConfig, and no connection is attempted, no credential is fetched and no name is resolved until Connect. Config errors name the offending field, and the endpoint's origin — which is not a secret — but never a header's value, a query string, or a token, which may be.

Types

type Config

type Config struct {
	// Endpoint is the MCP server's URL: https://, or http:// for a loopback
	// host only. It may carry a path and a query — "https://h/mcp?v=2" is an
	// ordinary endpoint — and neither ever appears in RedactedOrigin or in an
	// error, because a query string is a place people put tokens.
	//
	// It is validated once, by New, against exactly the rules auth.Key demands
	// of an origin, so a URL this transport accepts is a URL the token store
	// can key by.
	Endpoint string
	// Headers are static application-supplied headers, attached to every
	// request. A value may be a credential and is treated as one.
	//
	// They are applied before Auth, so a provider and a static header naming the
	// same field resolve in the provider's favour: the live credential wins over
	// the configured one.
	Headers []auth.Header
	// Auth supplies credential headers per request. Nil means no auth headers.
	//
	// It is consulted on every request rather than once per connection, which is
	// what makes an expiring credential work: a provider that refreshes returns
	// the new value on the next request without the connection noticing.
	Auth auth.HeaderProvider
	// HTTPClient is the client to use. Nil selects a client built from Timeouts
	// with TLS 1.2 as its floor, which is the expected case.
	//
	// A supplied client is vetted, not trusted: New refuses one whose transport
	// disables certificate verification, and the transport is cloned and given a
	// TLS 1.2 floor rather than being used as-is — so this package's guarantees
	// hold for a caller's client too, and the caller's client is not mutated. A
	// non-zero Timeout is refused: it is a deadline on a whole exchange, and it
	// would sever the streams this transport is built on.
	//
	// The default client honors the proxy environment (HTTP_PROXY, HTTPS_PROXY,
	// NO_PROXY), as http.DefaultTransport and pkg/auth do. That is worth knowing
	// for a transport this deliberate about what may see a credential: those
	// variables can route this traffic through a host the endpoint does not
	// name. It stays the default because it is what every Go HTTP client does
	// and operators rely on it, and because the exposure is narrow — an https
	// endpoint stays end-to-end encrypted through a proxy's CONNECT, so the
	// proxy sees the host but not the credential, and loopback is exempt.
	// Supply an HTTPClient whose transport sets Proxy: nil to opt out.
	HTTPClient *http.Client
	// Timeouts bounds the network. Zero fields select their defaults.
	Timeouts Timeouts
}

Config configures a Streamable HTTP transport.

type Timeouts

type Timeouts struct {
	// Dial bounds the TCP connect. Zero means DefaultDialTimeout.
	Dial time.Duration
	// TLSHandshake bounds the TLS handshake. Zero means
	// DefaultTLSHandshakeTimeout.
	TLSHandshake time.Duration
	// ResponseHeader bounds the wait for a response's headers, on every
	// request. This is the bound that catches a server which accepts a request
	// and then says nothing — it stops before the body, so it costs a stream
	// nothing. Zero means DefaultResponseHeaderTimeout.
	ResponseHeader time.Duration
	// Frame bounds how long one wire frame may take to arrive, measured from
	// its first byte to its last. Zero means DefaultFrameTimeout.
	//
	// This is the "idle timeout" the transport needs, expressed in the only
	// unit that means anything for a stream that is idle by design. A bound on
	// silence would be wrong: a healthy SSE stream says nothing for hours at a
	// time, and cutting it off for that would break the sessions it is meant to
	// protect. Silence inside a frame the server has already begun is the thing
	// that is never legitimate, and that is what this bounds — see frames.go.
	//
	// It is a completion deadline and not an idle one, deliberately: a deadline
	// that any byte resets is a deadline a server dribbling one byte at a time
	// never trips, which is the attack. A frame that legitimately needs longer
	// than this is a very large frame on a very slow link; raise it.
	Frame time.Duration
	// IdleConn bounds how long a pooled connection is kept alive unused. It is
	// about sockets this transport is not using; for the bound on a server that
	// has gone quiet mid-message, see Frame. Zero means
	// DefaultIdleConnTimeout.
	IdleConn time.Duration
	// Request bounds a whole request that cannot stream — in practice the DELETE
	// that ends the session at Close, which is the one request this transport
	// makes whose response is not, and cannot become, a stream. Zero means
	// DefaultRequestTimeout.
	//
	// It is the bound on how long a server can drag out a shutdown, and that is
	// not only Close's problem: the SDK closes the session itself when a
	// handshake fails, from inside the call, so a cancelled Initialize does not
	// return until the DELETE it triggers has finished or hit this. A server
	// that accepts a DELETE and never answers therefore delays a cancelled
	// caller by up to this long. Lower it if a caller must come back sooner;
	// it cannot be removed, because a DELETE with no bound would hang shutdown
	// outright.
	Request time.Duration
}

Timeouts bounds every wait the HTTP layer performs. Every field is explicit and defaulted; none is ever zero in a running transport.

There is deliberately no whole-request deadline for MCP traffic. The obvious one — http.Client.Timeout — cancels a response while its body is being read, which for this transport means killing the SSE stream a session is listening on, and killing a long tool call that is answering exactly as intended. Those two are bounded where the knowledge is: ResponseHeader catches a server that is not answering, the caller's context bounds the call (which is what the client's own Timeouts.Request sets), and Frame catches the case neither of those sees — a server that answers, starts a message, and stops.

Between them the bounds cover a request end to end: getting a socket (Dial, TLSHandshake), getting an answer (ResponseHeader), getting each message of it (Frame), and letting go (Request, IdleConn).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL