sse

package
v0.7.0 Latest Latest
Warning

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

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

Documentation

Overview

Package sse is the legacy HTTP+SSE transport, and it exists for exactly one reason: there are MCP servers that predate Streamable HTTP and still work.

Compatibility-only

This transport implements the 2024-11-05 spec's HTTP+SSE transport, which the specification has since replaced with Streamable HTTP (pkg/transport/- streamablehttp) and removed. Do not choose it for anything new. It is here so that a server nobody is going to update is reachable, and for nothing else.

It is opt-in and cannot be reached by accident. Nothing selects it, nothing falls back to it, and no configuration ever "upgrades" or "downgrades" into it: a caller gets this transport by importing this package and calling New, which is a decision with a name on it. That is deliberate. A client that silently accepted a legacy transport when a modern one failed would let a server choose which protocol it is talked to over, and the design lists accepting legacy SSE "only when explicitly configured" among the tolerances that are safe *because* they are explicit.

Compatibility-only is not permission to be lax

Being the legacy option buys this transport nothing. It does not weaken validation, auth, limits, or cancellation: it shares them, as code, with Streamable HTTP (internal/httpsec, internal/httpconn) rather than restating them. TLS is verified with a 1.2 floor, cleartext is refused to anything but loopback, credentials are attached per request and never sent off-origin, bodies are bounded whole, streams are bounded per frame and put on a clock, and no non-idempotent request is ever retried.

The endpoint event, which is this transport's real hazard

The legacy protocol has a shape Streamable HTTP does not: the client GETs a stream, and the *server's first event tells the client where to POST*. The SDK implements this faithfully — it resolves that event's data as a URL reference against the endpoint — which means a server can name an absolute URL and be believed.

That is an origin change chosen by the server, and it does not go anywhere near an http.Client's CheckRedirect, because it is not a redirect: it is a fresh request to whatever the server said. A transport that pinned only redirects would hand this module's credentials to any host a legacy server cared to name.

So the origin is pinned in the RoundTripper, where every request passes however its URL was chosen (httpsec.RoundTripper.Origin), and the pin is checked before a credential is attached. Both guards are installed: the redirect guard refuses the stdlib's hops, and the RoundTripper's refuses the server's. See TestPostEndpointCannotLeaveTheOrigin.

Retries

A tool call is never retried, and here that is a property of what the SDK's SSE client does not have rather than of anything switched off: it has no OAuthHandler to re-send a POST on a 401, and no stream resumption to replay. A POST carrying a call is issued once, and its failure is reported.

A dropped stream is not reconnected either. The legacy protocol has no Last-Event-ID resumption, so there is nothing safe to resume: the session ends and the client above rebuilds it, which is the client's decision and not this transport's to make quietly.

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.
	DefaultResponseHeaderTimeout = 30 * time.Second
	// DefaultFrameTimeout bounds one frame's arrival.
	DefaultFrameTimeout = 60 * time.Second
	// DefaultIdleConnTimeout bounds an unused pooled connection.
	DefaultIdleConnTimeout = 90 * time.Second
	// DefaultRequestTimeout bounds a whole non-streaming request.
	DefaultRequestTimeout = 10 * time.Second
)

Defaults applied when the corresponding Timeouts field is zero. They match streamablehttp's: the network is the same network.

Variables

This section is empty.

Functions

func New

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

New validates cfg and returns a legacy SSE TransportFactory.

Calling it is the explicit opt-in the package comment describes: this is the only way to get this transport, and there is no path that reaches it without a caller naming it.

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 server's SSE URL: https://, or http:// for a loopback
	// host only. It is the URL the stream is GET from; where messages are POSTed
	// is the server's to say, within this origin (see the package comment).
	//
	// It may carry a path and a query, 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. A non-zero Timeout is refused:
	// it is a deadline on a whole exchange, and it would sever the stream this
	// transport's whole session hangs on.
	HTTPClient *http.Client
	// Timeouts bounds the network. Zero fields select their defaults.
	Timeouts Timeouts
}

Config configures a legacy SSE transport.

It is deliberately the same shape as streamablehttp.Config. The transports differ in the protocol they speak, not in what a caller has to decide, and a caller moving off this one should have nothing to rewrite but the import.

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. 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.
	//
	// It is a completion deadline and not an idle one: a healthy SSE stream is
	// silent for hours by design, so a bound on silence would break the sessions
	// it is meant to protect, while silence *inside* a frame the server has
	// already begun is never legitimate.
	Frame time.Duration
	// IdleConn bounds how long a pooled connection is kept alive unused. Zero
	// means DefaultIdleConnTimeout.
	IdleConn time.Duration
	// Request bounds a whole request that cannot stream. Zero means
	// DefaultRequestTimeout.
	//
	// This transport makes no such request of its own — it GETs a stream and
	// POSTs messages — so it is close to vestigial here, and it is kept because
	// the bound belongs to the shared HTTP layer and a zero would mean
	// "unbounded" to it.
	Request time.Duration
}

Timeouts bounds every wait this transport's 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, for the reason streamablehttp.Timeouts gives and which is sharper here: this transport's session *is* a hanging GET, so an http.Client.Timeout would kill every session on a timer.

Jump to

Keyboard shortcuts

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