Skip to content

Commit 48df803

Browse files
committed
fix: bound HTTP bodies before MCP adapter conversion
Reject unsupported POST media types before consuming uploads. Route accepted bodies through the bounded Express reader and always supply a parsed value to prevent the SDK raw-stream fallback. Keep parser and encoding failures in JSON-RPC form. Regression tests cover unfinished uploads, chunked and gzip size limits, empty bodies and unsupported encodings; all 56 tests pass.
1 parent 89cd75c commit 48df803

3 files changed

Lines changed: 118 additions & 5 deletions

File tree

docs/mcp-2026-07-28-upgrade.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ Verified against the registry on September 13, 2026: the server, Node, Express,
3535
- Added Host and Origin validation before the HTTP MCP handler.
3636
- Added application-level enforcement of the protocol-version header and both accepted response types, including `q=0` exclusions. These supplement SDK v2.0.0's header value validation.
3737
- Return JSON-RPC parse errors for malformed JSON and a JSON error for the 1 MB request limit; syntactically valid non-RPC JSON remains the SDK's responsibility.
38+
- Reject non-JSON or missing POST media types before reading the body. All accepted bodies pass through Express's 1 MB reader (including chunked and decompressed uploads); the Node adapter always receives a parsed value so its unbounded raw-stream fallback is never used. Unsupported encodings also return JSON-RPC errors.
3839
- Verified Base64 sentinel decoding for `Mcp-Name` and `x-mcp-header` parameter validation. Current EVM tools do not declare routing headers; an annotated test tool verifies missing, malformed, mismatched, and correctly encoded headers before handler execution.
3940
- Added MCP OAuth resource-server support for HTTP:
4041
- localhost can run without authorization
@@ -117,6 +118,7 @@ The automated MCP integration tests cover:
117118
- resource reads and a read-only tool call
118119
- final `HeaderMismatch` and `UnsupportedProtocolVersion` error codes
119120
- real Express HTTP requests covering media types, required headers, Host/Origin rejection, parser errors, removed methods, and the SDK client
121+
- unfinished uploads proving unsupported media types are rejected before body completion, plus chunked and gzip uploads exceeding the decoded size limit
120122
- packaged Node CLI clients exercising tools, resource reads, and prompts over modern and legacy stdio, plus startup exit-code propagation
121123
- local authorization opt-out, remote fail-closed behavior, OAuth metadata validation, RFC 7662 introspection, audience checks, and scopes
122124

src/server/http-app.ts

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import express, {
77
import {
88
bearerAuthChallengeResponse,
99
createMcpHandler,
10+
isJsonContentType,
1011
OAuthError,
1112
OAuthErrorCode,
1213
ProtocolErrorCode
@@ -77,9 +78,24 @@ export function createHttpApp(options: HttpAppOptions): HttpApp {
7778
return;
7879
}
7980

81+
if (req.method === "POST" && !isJsonContentType(req.get("Content-Type") ?? null)) {
82+
res.status(415).json({
83+
jsonrpc: "2.0",
84+
error: {
85+
code: ProtocolErrorCode.InvalidRequest,
86+
message: "Content-Type must be application/json"
87+
}
88+
});
89+
return;
90+
}
91+
8092
next();
8193
};
8294

95+
// Media types are checked above. Force every remaining body through this
96+
// bounded reader, including chunked uploads and decompressed JSON.
97+
const parseMcpBody = express.json({ limit: "1mb", strict: false, type: () => true });
98+
8399
const handleMcpRequest = (req: Request, res: Response) => {
84100
// The v2 entry validates header values but permits an absent version header.
85101
if (req.method === "POST" && !req.get("MCP-Protocol-Version")) {
@@ -107,7 +123,9 @@ export function createHttpApp(options: HttpAppOptions): HttpApp {
107123
return;
108124
}
109125

110-
void nodeHandler(req, res, req.body);
126+
// An undefined parsedBody makes the SDK buffer the raw stream without a limit.
127+
// Treat an absent body as invalid JSON-RPC instead of entering that fallback.
128+
void nodeHandler(req, res, req.body ?? null);
111129
};
112130

113131
if (oauthConfiguration) {
@@ -152,21 +170,21 @@ export function createHttpApp(options: HttpAppOptions): HttpApp {
152170
requiredScopes: oauthConfiguration.requiredScopes,
153171
resourceMetadataUrl
154172
}),
155-
express.json({ limit: "1mb", strict: false }),
173+
parseMcpBody,
156174
requireOperationScope,
157175
handleMcpRequest
158176
);
159177
} else {
160178
app.all(
161179
"/mcp",
162180
validateMcpRequest,
163-
express.json({ limit: "1mb", strict: false }),
181+
parseMcpBody,
164182
handleMcpRequest
165183
);
166184
}
167185

168186
app.use((error: unknown, _req: Request, res: Response, next: NextFunction) => {
169-
const parserError = error as { type?: string } | undefined;
187+
const parserError = error as { type?: string; status?: number } | undefined;
170188
if (parserError?.type === "entity.parse.failed") {
171189
res.status(400).json({
172190
jsonrpc: "2.0",
@@ -181,6 +199,13 @@ export function createHttpApp(options: HttpAppOptions): HttpApp {
181199
});
182200
return;
183201
}
202+
if (parserError?.status === 400 || parserError?.status === 415) {
203+
res.status(parserError.status).json({
204+
jsonrpc: "2.0",
205+
error: { code: ProtocolErrorCode.InvalidRequest, message: "Invalid request body or encoding" }
206+
});
207+
return;
208+
}
184209
next(error);
185210
});
186211

test/http-protocol.test.ts

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2-
import type { Server } from "node:http";
2+
import { request, type Server } from "node:http";
3+
import { gzipSync } from "node:zlib";
34
import type { AddressInfo } from "node:net";
45
import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
56
import { createHttpApp } from "../src/server/http-app.js";
@@ -65,6 +66,75 @@ function post(options: {
6566
}
6667

6768
describe("Streamable HTTP protocol boundary", () => {
69+
test("rejects unsupported media types before waiting for the request body", async () => {
70+
for (const contentType of [undefined, "text/plain", "text/plain; a=application/json"]) {
71+
for (const framing of ["length", "chunked"]) {
72+
const response = await new Promise<{ status: number; type?: string; body: string }>((resolve, reject) => {
73+
const req = request(endpoint, {
74+
method: "POST",
75+
headers: {
76+
"Accept": "application/json, text/event-stream",
77+
"MCP-Protocol-Version": MODERN_PROTOCOL_VERSION,
78+
"Mcp-Method": "server/discover",
79+
...(contentType ? { "Content-Type": contentType } : {}),
80+
...(framing === "length" ? { "Content-Length": 2 * 1024 * 1024 } : { "Transfer-Encoding": "chunked" })
81+
}
82+
});
83+
const deadline = setTimeout(() => {
84+
req.destroy();
85+
reject(new Error("Server waited for an unsupported request body"));
86+
}, 2000);
87+
req.on("error", error => {
88+
clearTimeout(deadline);
89+
reject(error);
90+
});
91+
req.on("response", res => {
92+
let body = "";
93+
res.setEncoding("utf8");
94+
res.on("data", chunk => { body += chunk; });
95+
res.on("end", () => {
96+
clearTimeout(deadline);
97+
resolve({ status: res.statusCode!, type: res.headers["content-type"], body });
98+
req.destroy();
99+
});
100+
});
101+
// Deliberately leave the upload unfinished: status-only tests miss buffering.
102+
req.write("{");
103+
});
104+
expect(response.status).toBe(415);
105+
expect(response.type).toContain("application/json");
106+
expect(JSON.parse(response.body).error.code).toBe(-32600);
107+
}
108+
}
109+
}, 15000);
110+
111+
test("limits chunked and decompressed JSON bodies", async () => {
112+
const body = JSON.stringify({ padding: "x".repeat(1100 * 1024) });
113+
const chunked = await new Promise<{ status: number; body: string }>((resolve, reject) => {
114+
const req = request(endpoint, {
115+
method: "POST",
116+
headers: { "Content-Type": "application/json", "Transfer-Encoding": "chunked" }
117+
}, res => {
118+
let responseBody = "";
119+
res.setEncoding("utf8");
120+
res.on("data", chunk => { responseBody += chunk; });
121+
res.on("end", () => resolve({ status: res.statusCode!, body: responseBody }));
122+
});
123+
req.on("error", reject);
124+
req.end(body);
125+
});
126+
expect(chunked.status).toBe(413);
127+
expect(JSON.parse(chunked.body).error.code).toBe(-32600);
128+
129+
const compressed = await fetch(endpoint, {
130+
method: "POST",
131+
headers: { "Content-Type": "application/json", "Content-Encoding": "gzip" },
132+
body: gzipSync(body)
133+
});
134+
expect(compressed.status).toBe(413);
135+
expect((await compressed.json() as { error: { code: number } }).error.code).toBe(-32600);
136+
});
137+
68138
test("serves an SDK client without protocol sessions", async () => {
69139
const client = new Client({ name: "http-test", version: "1.0.0" }, {
70140
versionNegotiation: { mode: { pin: MODERN_PROTOCOL_VERSION } }
@@ -92,6 +162,22 @@ describe("Streamable HTTP protocol boundary", () => {
92162
}));
93163
});
94164

165+
test("returns JSON-RPC errors for empty bodies and unsupported encodings", async () => {
166+
const empty = await post({ rawBody: "" });
167+
expect(empty.status).toBe(400);
168+
expect((await empty.json() as { error: { code: number } }).error.code).toBe(-32600);
169+
170+
for (const headers of [
171+
{ "Content-Type": "application/json; charset=iso-8859-1" },
172+
{ "Content-Encoding": "unsupported" }
173+
] as Record<string, string>[]) {
174+
const response = await post({ headers });
175+
expect(response.status).toBe(415);
176+
expect(response.headers.get("Content-Type")).toContain("application/json");
177+
expect((await response.json() as { error: { code: number } }).error.code).toBe(-32600);
178+
}
179+
});
180+
95181
test("rejects non-JSON media types and accepts JSON parameters", async () => {
96182
for (const type of ["text/plain", "text/plain; a=application/json"]) {
97183
const response = await post({ headers: { "Content-Type": type } });

0 commit comments

Comments
 (0)