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..d2f6faa4d 100644 --- a/docs/configuration/environment-variables.md +++ b/docs/configuration/environment-variables.md @@ -870,6 +870,24 @@ 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. The larger parser is mounted only on +MCP message routes, after their transport-level request controls. + ### `STREAMABLE_HTTP` Set to `true` to run the Streamable HTTP transport. diff --git a/index.ts b/index.ts index b01fadafd..fa1e0fa5b 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, @@ -15948,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) { @@ -16130,16 +16132,32 @@ 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 { + 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; + 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()); @@ -16803,7 +16821,7 @@ async function startStreamableHTTPServer(): Promise { } app.use("/mcp", requireMcpHostAndOrigin); - app.use(express.json()); + const mcpJsonBodyParser = express.json({ limit: GITLAB_MCP_REQUEST_BODY_LIMIT }); registerDownloadProxy(app, buildDownloadProxyDeps()); @@ -17094,6 +17112,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/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/sse-session-limits.test.ts b/test/sse-session-limits.test.ts index 6a15d379c..a344f4ca5 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,50 @@ 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, "missing", sseMessageWithEncodedLength(20 * 1024 + 1)), + 400 + ); + 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 new file mode 100644 index 000000000..8e2ae4d5c --- /dev/null +++ b/test/streamable-http-body-limit.test.ts @@ -0,0 +1,110 @@ +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); + + 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); + }); +});