From 5f20366abf6f03ff3db176206550df0150fdcfc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Theodor=20N=2E=20Eng=C3=B8y?= Date: Sat, 7 Feb 2026 23:40:59 +0100 Subject: [PATCH] mcp: add max_body_bytes guard for HTTP transports --- mcp/http_limits.go | 47 +++++++++++++++++++++++++++ mcp/http_limits_test.go | 70 +++++++++++++++++++++++++++++++++++++++++ mcp/sse.go | 34 ++++++++++++++++++-- mcp/streamable.go | 42 +++++++++++++++++++++++++ 4 files changed, 190 insertions(+), 3 deletions(-) create mode 100644 mcp/http_limits.go create mode 100644 mcp/http_limits_test.go diff --git a/mcp/http_limits.go b/mcp/http_limits.go new file mode 100644 index 00000000..7edc5237 --- /dev/null +++ b/mcp/http_limits.go @@ -0,0 +1,47 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package mcp + +import ( + "errors" + "net/http" +) + +// DefaultMaxBodyBytes is the default maximum size (in bytes) for HTTP request +// bodies accepted by the built-in SSE and streamable HTTP handlers. +// +// This limit exists to prevent accidental or malicious large requests from +// exhausting server resources. +const DefaultMaxBodyBytes int64 = 1_000_000 + +// effectiveMaxBodyBytes converts the user-configured maxBodyBytes value to an +// effective limit. +// +// Semantics: +// - maxBodyBytes == 0: use DefaultMaxBodyBytes +// - maxBodyBytes < 0: no limit +// - maxBodyBytes > 0: use maxBodyBytes +func effectiveMaxBodyBytes(maxBodyBytes int64) int64 { + switch { + case maxBodyBytes == 0: + return DefaultMaxBodyBytes + case maxBodyBytes < 0: + return 0 + default: + return maxBodyBytes + } +} + +func isMaxBytesError(err error) bool { + var mbe *http.MaxBytesError + return errors.As(err, &mbe) +} + +func writeRequestBodyTooLarge(w http.ResponseWriter) { + // Even though http.MaxBytesReader will try to close the connection after the + // limit is exceeded, explicitly request closure here too. + w.Header().Set("Connection", "close") + http.Error(w, "request body too large", http.StatusRequestEntityTooLarge) +} diff --git a/mcp/http_limits_test.go b/mcp/http_limits_test.go new file mode 100644 index 00000000..21c28b73 --- /dev/null +++ b/mcp/http_limits_test.go @@ -0,0 +1,70 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package mcp + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + "github.com/modelcontextprotocol/go-sdk/jsonrpc" +) + +func TestSSEServerTransport_MaxBodyBytes(t *testing.T) { + tpt := &SSEServerTransport{ + MaxBodyBytes: 16, + incoming: make(chan jsonrpc.Message, 1), + done: make(chan struct{}), + } + + req := httptest.NewRequest(http.MethodPost, "http://example.invalid/session", bytes.NewReader(bytes.Repeat([]byte("a"), 17))) + w := httptest.NewRecorder() + tpt.ServeHTTP(w, req) + + resp := w.Result() + resp.Body.Close() + if got, want := resp.StatusCode, http.StatusRequestEntityTooLarge; got != want { + t.Fatalf("status code: got %d, want %d", got, want) + } +} + +func TestStreamableHTTPHandler_MaxBodyBytes(t *testing.T) { + server := NewServer(testImpl, nil) + + tests := []struct { + name string + stateless bool + }{ + {name: "stateful", stateless: false}, + {name: "stateless", stateless: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + handler := NewStreamableHTTPHandler( + func(*http.Request) *Server { return server }, + &StreamableHTTPOptions{Stateless: tt.stateless, MaxBodyBytes: 16}, + ) + httpServer := httptest.NewServer(handler) + defer httpServer.Close() + + req, err := http.NewRequest(http.MethodPost, httpServer.URL, bytes.NewReader(bytes.Repeat([]byte("a"), 17))) + if err != nil { + t.Fatalf("NewRequest: %v", err) + } + req.Header.Set("Accept", "application/json, text/event-stream") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("Do: %v", err) + } + resp.Body.Close() + if got, want := resp.StatusCode, http.StatusRequestEntityTooLarge; got != want { + t.Fatalf("status code: got %d, want %d", got, want) + } + }) + } +} diff --git a/mcp/sse.go b/mcp/sse.go index e57dad10..3a75f50f 100644 --- a/mcp/sse.go +++ b/mcp/sse.go @@ -52,9 +52,15 @@ type SSEHandler struct { } // SSEOptions specifies options for an [SSEHandler]. -// for now, it is empty, but may be extended in future. // https://github.com/modelcontextprotocol/go-sdk/issues/507 -type SSEOptions struct{} +type SSEOptions struct { + // MaxBodyBytes limits the size of POST request bodies sent to the session + // endpoint. + // + // If zero, defaults to [DefaultMaxBodyBytes]. + // If negative, no limit is enforced. + MaxBodyBytes int64 +} // NewSSEHandler returns a new [SSEHandler] that creates and manages MCP // sessions created via incoming HTTP requests. @@ -78,6 +84,9 @@ func NewSSEHandler(getServer func(request *http.Request) *Server, opts *SSEOptio if opts != nil { s.opts = *opts } + if s.opts.MaxBodyBytes == 0 { + s.opts.MaxBodyBytes = DefaultMaxBodyBytes + } return s } @@ -111,6 +120,13 @@ type SSEServerTransport struct { // Response is the hanging response body to the incoming GET request. Response http.ResponseWriter + // MaxBodyBytes limits the size of POST request bodies served by this + // transport. + // + // If zero, defaults to [DefaultMaxBodyBytes]. + // If negative, no limit is enforced. + MaxBodyBytes int64 + // incoming is the queue of incoming messages. // It is never closed, and by convention, incoming is non-nil if and only if // the transport is connected. @@ -133,8 +149,20 @@ func (t *SSEServerTransport) ServeHTTP(w http.ResponseWriter, req *http.Request) } // Read and parse the message. + maxBodyBytes := effectiveMaxBodyBytes(t.MaxBodyBytes) + if maxBodyBytes > 0 { + if req.ContentLength > maxBodyBytes { + writeRequestBodyTooLarge(w) + return + } + req.Body = http.MaxBytesReader(w, req.Body, maxBodyBytes) + } data, err := io.ReadAll(req.Body) if err != nil { + if isMaxBytesError(err) { + writeRequestBodyTooLarge(w) + return + } http.Error(w, "failed to read body", http.StatusBadRequest) return } @@ -224,7 +252,7 @@ func (h *SSEHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { return } - transport := &SSEServerTransport{Endpoint: endpoint.RequestURI(), Response: w} + transport := &SSEServerTransport{Endpoint: endpoint.RequestURI(), Response: w, MaxBodyBytes: h.opts.MaxBodyBytes} // The session is terminated when the request exits. h.mu.Lock() diff --git a/mcp/streamable.go b/mcp/streamable.go index c6b96bb0..6eb15d83 100644 --- a/mcp/streamable.go +++ b/mcp/streamable.go @@ -161,6 +161,12 @@ type StreamableHTTPOptions struct { // // If SessionTimeout is the zero value, idle sessions are never closed. SessionTimeout time.Duration + + // MaxBodyBytes limits the size of POST request bodies served by this handler. + // + // If zero, defaults to [DefaultMaxBodyBytes]. + // If negative, no limit is enforced. + MaxBodyBytes int64 } // NewStreamableHTTPHandler returns a new [StreamableHTTPHandler]. @@ -180,6 +186,9 @@ func NewStreamableHTTPHandler(getServer func(*http.Request) *Server, opts *Strea if h.opts.Logger == nil { // ensure we have a logger h.opts.Logger = ensureLogger(nil) } + if h.opts.MaxBodyBytes == 0 { + h.opts.MaxBodyBytes = DefaultMaxBodyBytes + } return h } @@ -355,6 +364,7 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque SessionID: sessionID, Stateless: h.opts.Stateless, EventStore: h.opts.EventStore, + MaxBodyBytes: h.opts.MaxBodyBytes, jsonResponse: h.opts.JSONResponse, logger: h.opts.Logger, } @@ -372,8 +382,20 @@ func (h *StreamableHTTPHandler) ServeHTTP(w http.ResponseWriter, req *http.Reque { // TODO: verify that this allows protocol version negotiation for // stateless servers. + maxBodyBytes := effectiveMaxBodyBytes(h.opts.MaxBodyBytes) + if maxBodyBytes > 0 { + if req.ContentLength > maxBodyBytes { + writeRequestBodyTooLarge(w) + return + } + req.Body = http.MaxBytesReader(w, req.Body, maxBodyBytes) + } body, err := io.ReadAll(req.Body) if err != nil { + if isMaxBytesError(err) { + writeRequestBodyTooLarge(w) + return + } http.Error(w, "failed to read body", http.StatusInternalServerError) return } @@ -529,6 +551,13 @@ type StreamableServerTransport struct { // upon stream resumption. EventStore EventStore + // MaxBodyBytes limits the size of POST request bodies served by this + // transport. + // + // If zero, defaults to [DefaultMaxBodyBytes]. + // If negative, no limit is enforced. + MaxBodyBytes int64 + // jsonResponse, if set, tells the server to prefer to respond to requests // using application/json responses rather than text/event-stream. // @@ -562,6 +591,7 @@ func (t *StreamableServerTransport) Connect(ctx context.Context) (Connection, er stateless: t.Stateless, eventStore: t.EventStore, jsonResponse: t.jsonResponse, + maxBodyBytes: effectiveMaxBodyBytes(t.MaxBodyBytes), logger: ensureLogger(t.logger), // see #556: must be non-nil incoming: make(chan jsonrpc.Message, 10), done: make(chan struct{}), @@ -585,6 +615,7 @@ type streamableServerConn struct { stateless bool jsonResponse bool eventStore EventStore + maxBodyBytes int64 // 0 means unlimited logger *slog.Logger @@ -1023,8 +1054,19 @@ func (c *streamableServerConn) servePOST(w http.ResponseWriter, req *http.Reques } // Read incoming messages. + if c.maxBodyBytes > 0 { + if req.ContentLength > c.maxBodyBytes { + writeRequestBodyTooLarge(w) + return + } + req.Body = http.MaxBytesReader(w, req.Body, c.maxBodyBytes) + } body, err := io.ReadAll(req.Body) if err != nil { + if isMaxBytesError(err) { + writeRequestBodyTooLarge(w) + return + } http.Error(w, "failed to read body", http.StatusBadRequest) return }