Skip to content

mcp: add Headers and MaxResponseBytes to StreamableClientTransport#926

Open
pja-ant wants to merge 2 commits intomainfrom
pja/streamable-transport-headers-maxbytes
Open

mcp: add Headers and MaxResponseBytes to StreamableClientTransport#926
pja-ant wants to merge 2 commits intomainfrom
pja/streamable-transport-headers-maxbytes

Conversation

@pja-ant
Copy link
Copy Markdown

@pja-ant pja-ant commented May 1, 2026

Summary

Adds two configuration options to StreamableClientTransport:

  • Headers http.Header — additional HTTP headers to send with every request to the MCP endpoint (useful for API keys, tracing IDs, tenant identifiers). Transport-managed headers (Content-Type, Accept, Authorization, Mcp-Protocol-Version, Mcp-Session-Id) take precedence; user-supplied keys are canonicalized before the precedence check so non-canonical spellings cannot produce duplicate headers.
  • MaxResponseBytes int64 — caps POST response body size via http.MaxBytesReader to protect clients from unbounded payloads. Zero means no limit; does not apply to the standalone SSE stream.

Test plan

  • TestStreamableClientHeaders — custom headers are forwarded on POST/GET/DELETE; non-canonical content-type does not override the transport-set value
  • TestStreamableClientMaxResponseBytes — tool call succeeds under a large limit and fails when the response exceeds the limit
  • go test ./mcp/ passes
  • go vet ./mcp/ clean

@pja-ant pja-ant marked this pull request as ready for review May 1, 2026 15:37
pja-ant added 2 commits May 1, 2026 16:38
The header merge loop indexed req.Header by raw map key, so a
non-canonical key like "content-type" would miss the canonical entry
written by req.Header.Set and be sent as a duplicate. Canonicalize
before lookup, and add tests for Headers and MaxResponseBytes.
@pja-ant pja-ant force-pushed the pja/streamable-transport-headers-maxbytes branch from dbb02b7 to f612d1a Compare May 1, 2026 15:39
Comment thread mcp/streamable.go
Comment on lines +1838 to +1840
if err == nil && c.maxResponseBytes > 0 {
resp.Body = http.MaxBytesReader(nil, resp.Body, c.maxResponseBytes)
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The MaxResponseBytes limit is applied inside doRequest() for the initial POST response, this means that when handleSSE() reconnects after a stream interruption (line 2019), the new response body has no size protection.
I think we should apply the limit in connectSSE() as well, or it that an intentional exclusion?

@maciej-kisiel
Copy link
Copy Markdown
Contributor

Hi @pja-ant,

I believe both these use cases can be already achieved via the *http.Client parameter of the transport. Below you can see AI-generated code snippets that serve the same purpose. In general, we are quite hesitant to increase API surface if the behavior can already be achieved with the existing one. Do you have any data point that would justify this?


AI-generated snippets below:

  1. Custom Headers via http.Client.Transport
type headerTransport struct {
	base    http.RoundTripper
	headers http.Header
}
func (t *headerTransport) RoundTrip(req *http.Request) (*http.Response, error) {
	for k, vs := range t.headers {
		if _, ok := req.Header[http.CanonicalHeaderKey(k)]; !ok {
			req.Header[http.CanonicalHeaderKey(k)] = vs
		}
	}
	return t.base.RoundTrip(req)
}
// Usage:
transport := &mcp.StreamableClientTransport{
	Endpoint: "https://example.com/mcp",
	HTTPClient: &http.Client{
		Transport: &headerTransport{
			base: http.DefaultTransport,
			headers: http.Header{
				"X-Custom": {"custom-value"},
			},
		},
	},
}

This achieves the exact same "add headers, but don't override transport-set headers" behavior, because setMCPHeaders runs before client.Do, so by the time the RoundTripper sees the request, the transport-managed headers (Content-Type, Accept, Authorization, Mcp-Protocol-Version, Mcp-Session-Id) are already set. The if _, ok := req.Header[...]; !ok guard gives identical precedence semantics.

  1. MaxResponseBytes via http.Client.Transport
type maxBytesTransport struct {
	base     http.RoundTripper
	maxBytes int64
}
func (t *maxBytesTransport) RoundTrip(req *http.Request) (*http.Response, error) {
	resp, err := t.base.RoundTrip(req)
	if err != nil {
		return nil, err
	}
	if t.maxBytes > 0 {
		resp.Body = http.MaxBytesReader(nil, resp.Body, t.maxBytes)
	}
	return resp, err
}
// Usage:
transport := &mcp.StreamableClientTransport{
	Endpoint: "https://example.com/mcp",
	HTTPClient: &http.Client{
		Transport: &maxBytesTransport{
			base:     http.DefaultTransport,
			maxBytes: 1 << 20, // 1 MiB
		},
	},
}

This is functionally equivalent. One minor difference: the PR's version only applies MaxResponseBytes to POST responses (not SSE GET or DELETE), while the RoundTripper approach applies to all requests. However, the DELETE response body is typically empty and the SSE stream would just error once the limit is hit — both arguably reasonable behaviors. If you truly wanted to match the PR's POST-only semantics, you could check req.Method == http.MethodPost in the RoundTrip.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants