From e79012eb825548ff8129c5322a114437ead37476 Mon Sep 17 00:00:00 2001 From: Roger Meier Date: Sun, 18 Oct 2026 19:32:10 +0200 Subject: [PATCH 1/3] fix(server): raise HTTP request body limit Allow large MCP note payloads by default while keeping the parser limit configurable for remote deployments. Related code-ops/code!8255 --- .env.example | 2 + config.ts | 2 + docs/configuration/environment-variables.md | 17 ++++ index.ts | 3 +- test/note-body-length-schema.test.ts | 23 ++++++ test/streamable-http-body-limit.test.ts | 91 +++++++++++++++++++++ 6 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 test/note-body-length-schema.test.ts create mode 100644 test/streamable-http-body-limit.test.ts diff --git a/.env.example b/.env.example index 0550ce91b..2c4a0678c 100644 --- a/.env.example +++ b/.env.example @@ -36,6 +36,8 @@ ISSUE_IID=1 # 3. **Stdio** (if `SSE!=true` and `STREAMABLE_HTTP!=true`) SSE=true STREAMABLE_HTTP=false +# Maximum encoded JSON request size for HTTP transports. Default: 10mb +# GITLAB_MCP_REQUEST_BODY_LIMIT=10mb # MCP Server Host With SSE Transport and Streamable Http Transport HOST=127.0.0.1 diff --git a/config.ts b/config.ts index b33470297..b6fd64e5f 100644 --- a/config.ts +++ b/config.ts @@ -122,6 +122,8 @@ export const SSE = getConfig("sse", "SSE") === "true"; export const STREAMABLE_HTTP = getConfig("streamable-http", "STREAMABLE_HTTP") === "true"; export const REMOTE_AUTHORIZATION = getConfig("remote-auth", "REMOTE_AUTHORIZATION") === "true"; export const GITLAB_MCP_OAUTH = getConfig("mcp-oauth", "GITLAB_MCP_OAUTH") === "true"; +export const GITLAB_MCP_REQUEST_BODY_LIMIT = + process.env.GITLAB_MCP_REQUEST_BODY_LIMIT?.trim() || "10mb"; export const GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY = getConfig( "allow-unauthenticated-tool-discovery", diff --git a/docs/configuration/environment-variables.md b/docs/configuration/environment-variables.md index 0a0e1c8fd..63f4130c0 100644 --- a/docs/configuration/environment-variables.md +++ b/docs/configuration/environment-variables.md @@ -870,6 +870,23 @@ LOG_FORMAT=json LOG_LEVEL=warn npm start ## Transport and Server Runtime +### `GITLAB_MCP_REQUEST_BODY_LIMIT` + +Maximum encoded JSON request size accepted by Streamable HTTP and SSE transports. +The limit applies to the complete JSON-RPC request, including its envelope and all +arguments, rather than only to note text. Values use the syntax accepted by +Express, such as `512kb`, `10mb`, or a byte count. + +Default: + +- `10mb` + +The default accommodates GitLab's 1,000,000-character note limit even when JSON +escaping or UTF-8 encoding makes the request several times larger than its +character count. Lower the value only when clients do not need large note, file, +or CI configuration payloads. Reverse proxies can impose a separate request-size +limit and must be configured independently. + ### `STREAMABLE_HTTP` Set to `true` to run the Streamable HTTP transport. diff --git a/index.ts b/index.ts index b01fadafd..ff87f3d2c 100644 --- a/index.ts +++ b/index.ts @@ -10,6 +10,7 @@ import { GITLAB_CA_CERT_PATH, GITLAB_JOB_TOKEN, GITLAB_MCP_OAUTH, + GITLAB_MCP_REQUEST_BODY_LIMIT, GITLAB_OAUTH_APP_ID, GITLAB_OAUTH_SCOPES, GITLAB_OAUTH_CALLBACK_PROXY, @@ -16803,7 +16804,7 @@ async function startStreamableHTTPServer(): Promise { } app.use("/mcp", requireMcpHostAndOrigin); - app.use(express.json()); + app.use(express.json({ limit: GITLAB_MCP_REQUEST_BODY_LIMIT })); registerDownloadProxy(app, buildDownloadProxyDeps()); diff --git a/test/note-body-length-schema.test.ts b/test/note-body-length-schema.test.ts new file mode 100644 index 000000000..c9a38bff5 --- /dev/null +++ b/test/note-body-length-schema.test.ts @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { CreateDraftNoteSchema, CreateMergeRequestNoteSchema } from "../schemas.js"; + +const GITLAB_NOTE_CHARACTER_LIMIT = 1_000_000; + +test("merge request note input schemas accept GitLab's note character limit", () => { + const body = "a".repeat(GITLAB_NOTE_CHARACTER_LIMIT); + + const note = CreateMergeRequestNoteSchema.parse({ + project_id: "group/project", + merge_request_iid: 1, + body, + }); + const draft = CreateDraftNoteSchema.parse({ + project_id: "group/project", + merge_request_iid: 1, + body, + }); + + assert.equal(note.body.length, GITLAB_NOTE_CHARACTER_LIMIT); + assert.equal(draft.body.length, GITLAB_NOTE_CHARACTER_LIMIT); +}); diff --git a/test/streamable-http-body-limit.test.ts b/test/streamable-http-body-limit.test.ts new file mode 100644 index 000000000..699f0f235 --- /dev/null +++ b/test/streamable-http-body-limit.test.ts @@ -0,0 +1,91 @@ +import assert from "node:assert/strict"; +import { afterEach, describe, test } from "node:test"; +import { + cleanupServers, + findAvailablePort, + launchServer, + ServerInstance, + TransportMode, +} from "./utils/server-launcher.js"; + +const HOST = process.env.HOST || "127.0.0.1"; +const GITLAB_NOTE_CHARACTER_LIMIT = 1_000_000; +const TWENTY_KIB = 20 * 1024; + +const servers: ServerInstance[] = []; + +function noteRequest(bodyLength: number): string { + return JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "create_merge_request_note", + arguments: { + project_id: "group/project", + merge_request_iid: 1, + body: "a".repeat(bodyLength), + }, + }, + }); +} + +function noteRequestWithEncodedLength(encodedLength: number): string { + const emptyRequest = noteRequest(0); + const fixedLength = Buffer.byteLength(emptyRequest); + + assert.ok(encodedLength >= fixedLength); + const request = noteRequest(encodedLength - fixedLength); + assert.equal(Buffer.byteLength(request), encodedLength); + return request; +} + +async function startServer(basePort: number, env: Record = {}): Promise { + const port = await findAvailablePort(basePort); + const server = await launchServer({ + mode: TransportMode.STREAMABLE_HTTP, + port, + env: { + GITLAB_API_URL: "https://gitlab.example.com/api/v4", + REMOTE_AUTHORIZATION: "true", + ...env, + }, + }); + servers.push(server); + return `http://${HOST}:${port}/mcp`; +} + +async function postNoteRequest(mcpUrl: string, body: string): Promise { + return fetch(mcpUrl, { + method: "POST", + headers: { + Accept: "application/json, text/event-stream", + "Content-Type": "application/json", + }, + body, + }); +} + +afterEach(() => { + cleanupServers(servers); + servers.length = 0; +}); + +describe("Streamable HTTP request body limit", () => { + test("parses a note at GitLab's character limit by default", async () => { + const mcpUrl = await startServer(4590); + const response = await postNoteRequest(mcpUrl, noteRequest(GITLAB_NOTE_CHARACTER_LIMIT)); + + assert.equal(response.status, 401); + }); + + test("honors a configured request body limit", async () => { + const mcpUrl = await startServer(4600, { GITLAB_MCP_REQUEST_BODY_LIMIT: "20kb" }); + + const accepted = await postNoteRequest(mcpUrl, noteRequestWithEncodedLength(TWENTY_KIB)); + assert.equal(accepted.status, 401); + + const rejected = await postNoteRequest(mcpUrl, noteRequestWithEncodedLength(TWENTY_KIB + 1)); + assert.equal(rejected.status, 413); + }); +}); From 2f00d262b3910d53f34217cfc3858ed3e8dbe4d1 Mon Sep 17 00:00:00 2001 From: Roger Meier Date: Sun, 18 Oct 2026 20:34:29 +0200 Subject: [PATCH 2/3] fix(server): scope remote body parsers Apply the configurable limit to authenticated SSE messages and parse Streamable HTTP payloads only after request controls. Related code-ops/code!8255 --- docs/configuration/environment-variables.md | 3 +- index.ts | 28 +++++++---- test/sse-session-limits.test.ts | 56 +++++++++++++++++++++ test/streamable-http-body-limit.test.ts | 19 +++++++ 4 files changed, 95 insertions(+), 11 deletions(-) diff --git a/docs/configuration/environment-variables.md b/docs/configuration/environment-variables.md index 63f4130c0..d2f6faa4d 100644 --- a/docs/configuration/environment-variables.md +++ b/docs/configuration/environment-variables.md @@ -885,7 +885,8 @@ The default accommodates GitLab's 1,000,000-character note limit even when JSON escaping or UTF-8 encoding makes the request several times larger than its character count. Lower the value only when clients do not need large note, file, or CI configuration payloads. Reverse proxies can impose a separate request-size -limit and must be configured independently. +limit and must be configured independently. The larger parser is mounted only on +MCP message routes, after their transport-level request controls. ### `STREAMABLE_HTTP` diff --git a/index.ts b/index.ts index ff87f3d2c..f3cd4e52b 100644 --- a/index.ts +++ b/index.ts @@ -15949,6 +15949,7 @@ function buildDownloadProxyDeps(): DownloadProxyDependencies { async function startSSEServer(): Promise { const { express, rateLimit, ipKeyGenerator, SSEServerTransport } = await loadRemoteHttpStack(); const app = express(); + const sseJsonBodyParser = express.json({ limit: GITLAB_MCP_REQUEST_BODY_LIMIT }); const sseAuthToken = getConfig("sse-auth-token", "SSE_AUTH_TOKEN"); if (MCP_TRUST_PROXY) { @@ -16131,16 +16132,22 @@ async function startSSEServer(): Promise { } ); - app.post("/messages", rejectDnsRebinding, requireSseAuth, async (req: Request, res: Response) => { - const sessionId = req.query.sessionId as string; - const transport = transports[sessionId]; - if (transport) { - sessionLastActivity.set(sessionId, Date.now()); - await transport.handlePostMessage(req, res); - } else { - res.status(400).send("No transport found for sessionId"); + app.post( + "/messages", + rejectDnsRebinding, + requireSseAuth, + sseJsonBodyParser, + async (req: Request, res: Response) => { + const sessionId = req.query.sessionId as string; + const transport = transports[sessionId]; + if (transport) { + sessionLastActivity.set(sessionId, Date.now()); + await transport.handlePostMessage(req, res, req.body); + } else { + res.status(400).send("No transport found for sessionId"); + } } - }); + ); registerDownloadProxy(app, buildDownloadProxyDeps()); @@ -16804,7 +16811,7 @@ async function startStreamableHTTPServer(): Promise { } app.use("/mcp", requireMcpHostAndOrigin); - app.use(express.json({ limit: GITLAB_MCP_REQUEST_BODY_LIMIT })); + const mcpJsonBodyParser = express.json({ limit: GITLAB_MCP_REQUEST_BODY_LIMIT }); registerDownloadProxy(app, buildDownloadProxyDeps()); @@ -17095,6 +17102,7 @@ async function startStreamableHTTPServer(): Promise { mcpRequestRateLimit, mcpBearerAuth, rejectForeignSession, + mcpJsonBodyParser, async (req: Request, res: Response) => { const sessionId = readMcpSessionIdHeader(req); const publicBaseUrl = getForwardedPublicBaseUrl(req, MCP_TRUST_PROXY); diff --git a/test/sse-session-limits.test.ts b/test/sse-session-limits.test.ts index 6a15d379c..506e11a25 100644 --- a/test/sse-session-limits.test.ts +++ b/test/sse-session-limits.test.ts @@ -142,6 +142,22 @@ async function postSseMessage(port: number, sessionId: string, body: unknown): P return response.status; } +async function postRawSseMessage(port: number, sessionId: string, body: string): Promise { + const response = await fetch(`http://${LOOPBACK}:${port}/messages?sessionId=${sessionId}`, { + method: "POST", + headers: { Authorization: `Bearer ${SSE_TOKEN}`, "Content-Type": "application/json" }, + body, + }); + await response.arrayBuffer(); + return response.status; +} + +function sseMessageWithEncodedLength(encodedLength: number): string { + const message = JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }); + assert.ok(encodedLength >= Buffer.byteLength(message)); + return message + " ".repeat(encodedLength - Buffer.byteLength(message)); +} + async function getSseStatus(port: number): Promise { const response = await fetch(`http://${LOOPBACK}:${port}/sse`, { headers: { Authorization: `Bearer ${SSE_TOKEN}`, Accept: "text/event-stream" }, @@ -178,6 +194,46 @@ afterEach(() => { }); describe("SSE session limits", { concurrency: 1 }, () => { + test("accepts messages above the SDK's fallback 4 MiB limit", async () => { + const port = await findAvailablePort(4490); + startSseServer({ MAX_REQUESTS_PER_MINUTE: UNTHROTTLED }, port); + await waitForHealth(port); + + const connection = openSseConnection(port); + const sessionId = await readSessionId(connection); + + assert.equal( + await postRawSseMessage(port, sessionId, sseMessageWithEncodedLength(5 * 1024 * 1024)), + 202 + ); + connection.close(); + }); + + test("honors the configured request body limit for messages", async () => { + const port = await findAvailablePort(4495); + startSseServer( + { + GITLAB_MCP_REQUEST_BODY_LIMIT: "20kb", + MAX_REQUESTS_PER_MINUTE: UNTHROTTLED, + }, + port + ); + await waitForHealth(port); + + const connection = openSseConnection(port); + const sessionId = await readSessionId(connection); + + assert.equal( + await postRawSseMessage(port, sessionId, sseMessageWithEncodedLength(20 * 1024)), + 202 + ); + assert.equal( + await postRawSseMessage(port, sessionId, sseMessageWithEncodedLength(20 * 1024 + 1)), + 413 + ); + connection.close(); + }); + test("rejects new SSE connections once MAX_SESSIONS is reached", async () => { const port = await findAvailablePort(4500); startSseServer({ MAX_SESSIONS: "2", MAX_REQUESTS_PER_MINUTE: UNTHROTTLED }, port); diff --git a/test/streamable-http-body-limit.test.ts b/test/streamable-http-body-limit.test.ts index 699f0f235..8e2ae4d5c 100644 --- a/test/streamable-http-body-limit.test.ts +++ b/test/streamable-http-body-limit.test.ts @@ -87,5 +87,24 @@ describe("Streamable HTTP request body limit", () => { const rejected = await postNoteRequest(mcpUrl, noteRequestWithEncodedLength(TWENTY_KIB + 1)); assert.equal(rejected.status, 413); + + const unrelatedRoute = await postNoteRequest( + mcpUrl.replace(/\/mcp$/, "/health"), + noteRequestWithEncodedLength(TWENTY_KIB + 1) + ); + assert.equal(unrelatedRoute.status, 404); + }); + + test("rate-limits an oversized request before parsing it", async () => { + const mcpUrl = await startServer(4610, { + GITLAB_MCP_REQUEST_BODY_LIMIT: "20kb", + MAX_REQUESTS_PER_MINUTE: "1", + }); + + const first = await postNoteRequest(mcpUrl, noteRequest(1)); + assert.equal(first.status, 401); + + const rateLimited = await postNoteRequest(mcpUrl, noteRequestWithEncodedLength(TWENTY_KIB + 1)); + assert.equal(rateLimited.status, 429); }); }); From 4b6d64df06cfcd6128d9c752e1d83643eb41f18b Mon Sep 17 00:00:00 2001 From: Roger Meier Date: Sun, 18 Oct 2026 22:28:00 +0200 Subject: [PATCH 3/3] fix(server): validate SSE sessions before parsing Reject messages for unknown SSE sessions before reading their request bodies. Related code-ops/code!8255 --- index.ts | 10 ++++++++++ test/sse-session-limits.test.ts | 4 ++++ 2 files changed, 14 insertions(+) diff --git a/index.ts b/index.ts index f3cd4e52b..fa1e0fa5b 100644 --- a/index.ts +++ b/index.ts @@ -16132,10 +16132,20 @@ async function startSSEServer(): Promise { } ); + const requireSseSession = (req: Request, res: Response, next: NextFunction) => { + const sessionId = req.query.sessionId; + if (typeof sessionId !== "string" || !transports[sessionId]) { + res.status(400).send("No transport found for sessionId"); + return; + } + next(); + }; + app.post( "/messages", rejectDnsRebinding, requireSseAuth, + requireSseSession, sseJsonBodyParser, async (req: Request, res: Response) => { const sessionId = req.query.sessionId as string; diff --git a/test/sse-session-limits.test.ts b/test/sse-session-limits.test.ts index 506e11a25..a344f4ca5 100644 --- a/test/sse-session-limits.test.ts +++ b/test/sse-session-limits.test.ts @@ -223,6 +223,10 @@ describe("SSE session limits", { concurrency: 1 }, () => { const connection = openSseConnection(port); const sessionId = await readSessionId(connection); + assert.equal( + await postRawSseMessage(port, "missing", sseMessageWithEncodedLength(20 * 1024 + 1)), + 400 + ); assert.equal( await postRawSseMessage(port, sessionId, sseMessageWithEncodedLength(20 * 1024)), 202