Skip to content

Commit 2cc4ccb

Browse files
Limit HTTP request bodies before MCP middleware parsing
Add WithMaxBodySize middleware that bounds the request body via http.MaxBytesReader (with a fast Content-Length rejection when known), registered first in RegisterMiddleware so it runs before any other middleware or the MCP SDK reads or buffers the body. WithMCPParse and WithScopeChallenge now return a clear 413 "request body too large" response when their body read hits the limit, instead of silently continuing. Defaults to 10 MiB, overridable via ServerConfig.MaxRequestBodyBytes. Fixes #3102 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent c54cd63 commit 2cc4ccb

9 files changed

Lines changed: 422 additions & 0 deletions

File tree

pkg/http/handler.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,9 @@ func NewHTTPMcpHandler(
132132

133133
func (h *Handler) RegisterMiddleware(r chi.Router) {
134134
r.Use(
135+
// Must run first: bounds the request body before any other
136+
// middleware (or the MCP SDK) reads or buffers it.
137+
middleware.WithMaxBodySize(h.maxRequestBodyBytes()),
135138
middleware.ExtractUserToken(h.oauthCfg),
136139
middleware.WithRequestConfig,
137140
middleware.WithMCPParse(),
@@ -143,6 +146,15 @@ func (h *Handler) RegisterMiddleware(r chi.Router) {
143146
}
144147
}
145148

149+
// maxRequestBodyBytes returns the configured request-body size limit, or
150+
// middleware.DefaultMaxRequestBodyBytes if none was configured.
151+
func (h *Handler) maxRequestBodyBytes() int64 {
152+
if h.config != nil && h.config.MaxRequestBodyBytes > 0 {
153+
return h.config.MaxRequestBodyBytes
154+
}
155+
return middleware.DefaultMaxRequestBodyBytes
156+
}
157+
146158
// RegisterRoutes registers the routes for the MCP server
147159
// URL-based values take precedence over header-based values
148160
func (h *Handler) RegisterRoutes(r chi.Router) {

pkg/http/handler_test.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1287,3 +1287,87 @@ func TestUIMetaStrippedWhenClientLacksCapability(t *testing.T) {
12871287
require.Len(t, unknown, 1)
12881288
require.NotNil(t, unknown[0].Tool.Meta["ui"], "_meta.ui should be preserved when capability is unknown and FF is on")
12891289
}
1290+
1291+
// TestRegisterMiddleware_MaxRequestBodySize verifies that RegisterMiddleware
1292+
// wires the body-size limit ahead of the body-consuming middleware, so an
1293+
// oversized request never reaches the MCP server, and that requests within
1294+
// the configured limit (including exactly at the boundary) still succeed.
1295+
func TestRegisterMiddleware_MaxRequestBodySize(t *testing.T) {
1296+
const limit = 256
1297+
1298+
apiHost, err := utils.NewAPIHost("https://api.github.com")
1299+
require.NoError(t, err)
1300+
1301+
buildBody := func(size int) string {
1302+
payload := `{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"pad":"PADDING"}}`
1303+
if len(payload) >= size {
1304+
return payload
1305+
}
1306+
pad := strings.Repeat("x", size-len(payload))
1307+
return strings.Replace(payload, "PADDING", "PADDING"+pad, 1)
1308+
}
1309+
1310+
newHandler := func(t *testing.T, mcpServerFactoryCalled *bool) http.Handler {
1311+
t.Helper()
1312+
handler := NewHTTPMcpHandler(
1313+
context.Background(),
1314+
&ServerConfig{Version: "test", MaxRequestBodyBytes: limit},
1315+
nil,
1316+
translations.NullTranslationHelper,
1317+
slog.Default(),
1318+
apiHost,
1319+
WithInventoryFactory(func(_ *http.Request) (*inventory.Inventory, error) {
1320+
return inventory.NewBuilder().Build()
1321+
}),
1322+
WithGitHubMCPServerFactory(func(_ *http.Request, _ github.ToolDependencies, _ *inventory.Inventory, _ *github.MCPServerConfig) (*mcp.Server, error) {
1323+
if mcpServerFactoryCalled != nil {
1324+
*mcpServerFactoryCalled = true
1325+
}
1326+
return mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.0.1"}, nil), nil
1327+
}),
1328+
WithScopeFetcher(allScopesFetcher{}),
1329+
)
1330+
1331+
r := chi.NewRouter()
1332+
handler.RegisterMiddleware(r)
1333+
handler.RegisterRoutes(r)
1334+
return r
1335+
}
1336+
1337+
t.Run("oversized request is rejected before reaching the MCP server", func(t *testing.T) {
1338+
var mcpServerFactoryCalled bool
1339+
r := newHandler(t, &mcpServerFactoryCalled)
1340+
1341+
body := buildBody(limit + 1)
1342+
require.Greater(t, len(body), limit)
1343+
1344+
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
1345+
req.Header.Set(headers.AuthorizationHeader, strings.Join([]string{"ghs", "test-token"}, "_"))
1346+
1347+
rr := httptest.NewRecorder()
1348+
r.ServeHTTP(rr, req)
1349+
1350+
assert.Equal(t, http.StatusRequestEntityTooLarge, rr.Code)
1351+
assert.Contains(t, rr.Body.String(), "request body too large")
1352+
assert.False(t, mcpServerFactoryCalled, "the MCP server should never be constructed for an oversized request")
1353+
})
1354+
1355+
t.Run("boundary-size request at the configured limit succeeds", func(t *testing.T) {
1356+
var mcpServerFactoryCalled bool
1357+
r := newHandler(t, &mcpServerFactoryCalled)
1358+
1359+
body := buildBody(limit)
1360+
require.Len(t, body, limit)
1361+
1362+
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
1363+
req.Header.Set(headers.ContentTypeHeader, headers.ContentTypeJSON)
1364+
req.Header.Set(headers.AcceptHeader, strings.Join([]string{headers.ContentTypeJSON, headers.ContentTypeEventStream}, ", "))
1365+
req.Header.Set(headers.AuthorizationHeader, strings.Join([]string{"ghs", "test-token"}, "_"))
1366+
1367+
rr := httptest.NewRecorder()
1368+
r.ServeHTTP(rr, req)
1369+
1370+
assert.Equal(t, http.StatusOK, rr.Code, "response body: %s", rr.Body.String())
1371+
assert.True(t, mcpServerFactoryCalled, "the MCP server should be constructed for an allowed request")
1372+
})
1373+
}

pkg/http/middleware/body_limit.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package middleware
2+
3+
import (
4+
"errors"
5+
"net/http"
6+
)
7+
8+
// DefaultMaxRequestBodyBytes bounds the size of HTTP request bodies accepted
9+
// by the MCP endpoints when no explicit limit is configured.
10+
const DefaultMaxRequestBodyBytes int64 = 10 << 20 // 10 MiB
11+
12+
// WithMaxBodySize returns middleware that bounds the size of the request
13+
// body. It must be registered before any middleware that reads or buffers
14+
// the body (e.g. WithMCPParse, WithScopeChallenge) so that an oversized
15+
// payload is rejected before it is ever fully buffered in memory, rather than
16+
// relying on a size guard applied later by the MCP SDK or a downstream
17+
// handler.
18+
//
19+
// When Content-Length is known and already exceeds maxBytes, the request is
20+
// rejected immediately without touching the body. Otherwise the body is
21+
// wrapped with http.MaxBytesReader, so any subsequent read (including
22+
// chunked or unknown-length bodies) fails with a *http.MaxBytesError once
23+
// maxBytes have been consumed.
24+
func WithMaxBodySize(maxBytes int64) func(http.Handler) http.Handler {
25+
return func(next http.Handler) http.Handler {
26+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
27+
if r.ContentLength > maxBytes {
28+
writeRequestTooLarge(w)
29+
return
30+
}
31+
32+
if r.Body != nil {
33+
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
34+
}
35+
36+
next.ServeHTTP(w, r)
37+
})
38+
}
39+
}
40+
41+
// writeRequestTooLarge writes the standard "request body too large" response.
42+
// Every middleware that reads the request body should use this so oversized
43+
// requests get a consistent, clear response regardless of which layer
44+
// detects the overflow.
45+
func writeRequestTooLarge(w http.ResponseWriter) {
46+
http.Error(w, "request body too large", http.StatusRequestEntityTooLarge)
47+
}
48+
49+
// isMaxBytesError reports whether err resulted from a body exceeding the
50+
// limit applied by WithMaxBodySize, as opposed to some other read failure.
51+
func isMaxBytesError(err error) bool {
52+
var maxBytesErr *http.MaxBytesError
53+
return errors.As(err, &maxBytesErr)
54+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package middleware
2+
3+
import (
4+
"io"
5+
"net/http"
6+
"net/http/httptest"
7+
"strings"
8+
"testing"
9+
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
// unknownLengthBody wraps a reader without exposing a Len method, so
15+
// httptest.NewRequest cannot infer Content-Length from it. This mirrors a
16+
// chunked-transfer-encoded request, where the body size is unknown upfront.
17+
func unknownLengthBody(s string) io.Reader {
18+
return io.NopCloser(strings.NewReader(s))
19+
}
20+
21+
func TestWithMaxBodySize(t *testing.T) {
22+
const limit = 16
23+
24+
t.Run("allowed request under the limit passes through", func(t *testing.T) {
25+
var nextCalled bool
26+
var readBody string
27+
var readErr error
28+
29+
next := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
30+
nextCalled = true
31+
b, err := io.ReadAll(r.Body)
32+
readBody, readErr = string(b), err
33+
})
34+
35+
handler := WithMaxBodySize(limit)(next)
36+
37+
req := httptest.NewRequest(http.MethodPost, "/mcp", strings.NewReader("short"))
38+
rr := httptest.NewRecorder()
39+
handler.ServeHTTP(rr, req)
40+
41+
assert.True(t, nextCalled, "next handler should be called for an allowed request")
42+
require.NoError(t, readErr)
43+
assert.Equal(t, "short", readBody)
44+
assert.Equal(t, http.StatusOK, rr.Code)
45+
})
46+
47+
t.Run("boundary size exactly at the limit is allowed", func(t *testing.T) {
48+
body := strings.Repeat("a", limit)
49+
50+
var nextCalled bool
51+
var readBody string
52+
var readErr error
53+
54+
next := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
55+
nextCalled = true
56+
b, err := io.ReadAll(r.Body)
57+
readBody, readErr = string(b), err
58+
})
59+
60+
handler := WithMaxBodySize(limit)(next)
61+
62+
req := httptest.NewRequest(http.MethodPost, "/mcp", strings.NewReader(body))
63+
rr := httptest.NewRecorder()
64+
handler.ServeHTTP(rr, req)
65+
66+
assert.True(t, nextCalled, "next handler should be called when the body is exactly at the limit")
67+
require.NoError(t, readErr, "reading exactly maxBytes should not error")
68+
assert.Equal(t, body, readBody, "the full boundary-size body should be readable")
69+
})
70+
71+
t.Run("oversized request with known Content-Length is rejected before next runs", func(t *testing.T) {
72+
body := strings.Repeat("a", limit+1)
73+
74+
var nextCalled bool
75+
next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
76+
nextCalled = true
77+
})
78+
79+
handler := WithMaxBodySize(limit)(next)
80+
81+
req := httptest.NewRequest(http.MethodPost, "/mcp", strings.NewReader(body))
82+
require.Equal(t, int64(limit+1), req.ContentLength, "test setup: Content-Length should be known")
83+
84+
rr := httptest.NewRecorder()
85+
handler.ServeHTTP(rr, req)
86+
87+
assert.False(t, nextCalled, "next handler must not run for an oversized request")
88+
assert.Equal(t, http.StatusRequestEntityTooLarge, rr.Code)
89+
assert.Contains(t, rr.Body.String(), "request body too large")
90+
})
91+
92+
t.Run("oversized request with unknown length fails on downstream read", func(t *testing.T) {
93+
body := strings.Repeat("a", limit+1)
94+
95+
var nextCalled bool
96+
var readErr error
97+
98+
next := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
99+
nextCalled = true
100+
_, readErr = io.ReadAll(r.Body)
101+
})
102+
103+
handler := WithMaxBodySize(limit)(next)
104+
105+
req := httptest.NewRequest(http.MethodPost, "/mcp", unknownLengthBody(body))
106+
require.Equal(t, int64(-1), req.ContentLength, "test setup: Content-Length should be unknown")
107+
108+
rr := httptest.NewRecorder()
109+
handler.ServeHTTP(rr, req)
110+
111+
assert.True(t, nextCalled, "next handler still runs; the limit is enforced on read")
112+
require.Error(t, readErr)
113+
assert.True(t, isMaxBytesError(readErr), "expected a *http.MaxBytesError, got %v", readErr)
114+
})
115+
}

pkg/http/middleware/mcp_parse.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ func WithMCPParse() func(http.Handler) http.Handler {
5454
// Read the request body
5555
body, err := io.ReadAll(r.Body)
5656
if err != nil {
57+
if isMaxBytesError(err) {
58+
writeRequestTooLarge(w)
59+
return
60+
}
5761
// Log but continue - don't block requests on parse errors
5862
next.ServeHTTP(w, r)
5963
return

pkg/http/middleware/mcp_parse_test.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,3 +189,68 @@ func TestWithMCPParse_BodyRestoration(t *testing.T) {
189189

190190
assert.Equal(t, originalBody, capturedBody, "body should be restored for downstream handlers")
191191
}
192+
193+
// TestWithMCPParse_WithMaxBodySize composes the body-size limit with
194+
// WithMCPParse, mirroring the production middleware ordering where
195+
// WithMaxBodySize runs first. It verifies that an oversized body is rejected
196+
// with a clear 413 before parsing runs, while requests within the limit
197+
// (including exactly at the boundary) still parse and preserve the body.
198+
func TestWithMCPParse_WithMaxBodySize(t *testing.T) {
199+
const limit = 128
200+
201+
buildBody := func(size int) string {
202+
payload := `{"jsonrpc":"2.0","method":"tools/call","params":{"name":"test_tool","arguments":{"pad":"PADDING"}}}`
203+
if len(payload) >= size {
204+
return payload
205+
}
206+
// Pad the JSON with a longer string value so we can hit an exact byte size.
207+
pad := strings.Repeat("x", size-len(payload))
208+
return strings.Replace(payload, "PADDING", "PADDING"+pad, 1)
209+
}
210+
211+
t.Run("oversized body is rejected before parsing", func(t *testing.T) {
212+
body := buildBody(limit + 1)
213+
require.Greater(t, len(body), limit)
214+
215+
var nextCalled bool
216+
nextHandler := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
217+
nextCalled = true
218+
})
219+
220+
handler := WithMaxBodySize(limit)(WithMCPParse()(nextHandler))
221+
222+
req := httptest.NewRequest(http.MethodPost, "/mcp", strings.NewReader(body))
223+
rr := httptest.NewRecorder()
224+
handler.ServeHTTP(rr, req)
225+
226+
assert.False(t, nextCalled, "downstream handler must not run for an oversized request")
227+
assert.Equal(t, http.StatusRequestEntityTooLarge, rr.Code)
228+
assert.Contains(t, rr.Body.String(), "request body too large")
229+
})
230+
231+
t.Run("boundary-size body is parsed and preserved", func(t *testing.T) {
232+
body := buildBody(limit)
233+
require.Len(t, body, limit)
234+
235+
var capturedInfo *ghcontext.MCPMethodInfo
236+
var capturedBody string
237+
nextHandler := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
238+
capturedInfo, _ = ghcontext.MCPMethod(r.Context())
239+
b, err := io.ReadAll(r.Body)
240+
require.NoError(t, err)
241+
capturedBody = string(b)
242+
})
243+
244+
handler := WithMaxBodySize(limit)(WithMCPParse()(nextHandler))
245+
246+
req := httptest.NewRequest(http.MethodPost, "/mcp", strings.NewReader(body))
247+
rr := httptest.NewRecorder()
248+
handler.ServeHTTP(rr, req)
249+
250+
assert.Equal(t, http.StatusOK, rr.Code)
251+
require.NotNil(t, capturedInfo, "MCPMethodInfo should be parsed for an allowed request")
252+
assert.Equal(t, "tools/call", capturedInfo.Method)
253+
assert.Equal(t, "test_tool", capturedInfo.ItemName)
254+
assert.Equal(t, body, capturedBody, "body should be preserved for downstream handlers")
255+
})
256+
}

pkg/http/middleware/scope_challenge.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ func WithScopeChallenge(oauthCfg *oauth.Config, scopeFetcher scopes.FetcherInter
5454
// Fallback: parse the request body directly
5555
body, err := io.ReadAll(r.Body)
5656
if err != nil {
57+
if isMaxBytesError(err) {
58+
writeRequestTooLarge(w)
59+
return
60+
}
5761
next.ServeHTTP(w, r)
5862
return
5963
}

0 commit comments

Comments
 (0)