Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions mcp/http_limits.go
Original file line number Diff line number Diff line change
@@ -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)
}
70 changes: 70 additions & 0 deletions mcp/http_limits_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
34 changes: 31 additions & 3 deletions mcp/sse.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
}
Expand Down Expand Up @@ -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.
Expand All @@ -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
}
Expand Down Expand Up @@ -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()
Expand Down
42 changes: 42 additions & 0 deletions mcp/streamable.go
Original file line number Diff line number Diff line change
Expand Up @@ -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].
Expand All @@ -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
}
Expand Down Expand Up @@ -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,
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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.
//
Expand Down Expand Up @@ -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{}),
Expand All @@ -585,6 +615,7 @@ type streamableServerConn struct {
stateless bool
jsonResponse bool
eventStore EventStore
maxBodyBytes int64 // 0 means unlimited

logger *slog.Logger

Expand Down Expand Up @@ -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
}
Expand Down