From 2efc5865a3ccad35ccf22f55ccd402796855cd3d Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 16:53:05 +0000 Subject: [PATCH 1/3] fix(docs-mcp): Limit request bodies and tool input elements Bound /mcp and /internal/docs bodies at 64 KiB before JSON parsing, using both the declared Content-Length and a streamed byte count. Configure the MCP SDK's maxToolInputElements at 1,000 (GHSA-46jr-4wx8-v453). Fixes #1601 Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- apps/docs-mcp/README.md | 8 + .../src/__tests__/internal-api.test.ts | 68 ++++++ apps/docs-mcp/src/__tests__/mcp.test.ts | 214 +++++++++++++++--- apps/docs-mcp/src/__tests__/streams.ts | 15 ++ apps/docs-mcp/src/index.ts | 35 ++- apps/docs-mcp/src/mcp.ts | 16 +- apps/docs-mcp/src/request-limits.ts | 53 +++++ 7 files changed, 369 insertions(+), 40 deletions(-) create mode 100644 apps/docs-mcp/src/__tests__/streams.ts create mode 100644 apps/docs-mcp/src/request-limits.ts diff --git a/apps/docs-mcp/README.md b/apps/docs-mcp/README.md index bfb8b4dfa..55eed3505 100644 --- a/apps/docs-mcp/README.md +++ b/apps/docs-mcp/README.md @@ -32,6 +32,14 @@ limited to `frameworks`, `languages`, and `sentryConfigured`. Model failures and unverifiable answers retain the safe `DOCS_MODEL_UNAVAILABLE` and `DOCS_UNGROUNDED` error codes. +Both endpoints cap request bodies at 64 KiB before parsing JSON, whether or not +the request declares `Content-Length`. An oversized `/mcp` POST gets a 413 with a +JSON-RPC error (`code: -32000`); an oversized `/internal/docs` request gets a 413 +with `{ "error": "Request body must not exceed 65536 bytes" }`. MCP tool calls +also accept at most 1,000 array elements plus object keys across `arguments`; +larger calls return an `isError` result before the tool runs. Both budgets live +in `src/request-limits.ts`, with the sizing rationale. + The existing `sentry docs` CLI calls Init Server's authenticated `/api/docs/*` facade, which forwards requests through its `DOCS_MCP` binding. Moving this app does not change that contract. Retiring Init Server would require a separate diff --git a/apps/docs-mcp/src/__tests__/internal-api.test.ts b/apps/docs-mcp/src/__tests__/internal-api.test.ts index cfbb1ae86..7a34982f0 100644 --- a/apps/docs-mcp/src/__tests__/internal-api.test.ts +++ b/apps/docs-mcp/src/__tests__/internal-api.test.ts @@ -10,6 +10,22 @@ vi.mock("../internal-api", async (importOriginal) => ({ })); import { app } from "../index"; +import { MAX_REQUEST_BODY_BYTES } from "../request-limits"; +import { streamOf } from "./streams"; + +const TOO_LARGE = { + error: `Request body must not exceed ${MAX_REQUEST_BODY_BYTES} bytes`, +}; + +function docsRequest(body: BodyInit, headers: Record = {}) { + return new Request("https://docs-mcp.internal/internal/docs", { + body, + // Node requires `duplex` for streamed request bodies. + duplex: "half", + headers: { "content-type": "application/json", ...headers }, + method: "POST", + } as RequestInit); +} describe("private docs API", () => { beforeEach(() => { @@ -104,4 +120,56 @@ describe("private docs API", () => { error: "Docs API request failed", }); }); + test("accepts a request body at the size budget", async () => { + executeInternalDocsRequest.mockResolvedValue({ results: [] }); + const request = { operation: "search-docs", query: "" }; + const query = "a".repeat( + MAX_REQUEST_BODY_BYTES - JSON.stringify(request).length, + ); + + const response = await app.request( + docsRequest(JSON.stringify({ ...request, query })), + undefined, + { CF_VERSION_METADATA: { id: "test", tag: "test", timestamp: "test" } }, + ); + + expect(response.status).toBe(200); + expect(executeInternalDocsRequest).toHaveBeenCalledWith( + undefined, + { operation: "search-docs", query }, + "test", + ); + }); + + test("rejects a declared oversized body before reading it", async () => { + const response = await app.request( + docsRequest(streamOf(16), { + "content-length": String(MAX_REQUEST_BODY_BYTES + 1), + }), + ); + + expect(response.status).toBe(413); + await expect(response.json()).resolves.toEqual(TOO_LARGE); + expect(executeInternalDocsRequest).not.toHaveBeenCalled(); + }); + + test("rejects an oversized streamed body without Content-Length", async () => { + const request = docsRequest(streamOf(MAX_REQUEST_BODY_BYTES + 1)); + expect(request.headers.has("content-length")).toBe(false); + + const response = await app.request(request); + + expect(response.status).toBe(413); + await expect(response.json()).resolves.toEqual(TOO_LARGE); + expect(executeInternalDocsRequest).not.toHaveBeenCalled(); + }); + + test("still rejects malformed JSON as an invalid request", async () => { + const response = await app.request(docsRequest("{")); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + error: "Invalid docs API request", + }); + }); }); diff --git a/apps/docs-mcp/src/__tests__/mcp.test.ts b/apps/docs-mcp/src/__tests__/mcp.test.ts index abd98867a..271d46d87 100644 --- a/apps/docs-mcp/src/__tests__/mcp.test.ts +++ b/apps/docs-mcp/src/__tests__/mcp.test.ts @@ -2,22 +2,70 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { describe, expect, test, vi } from "vitest"; import { app } from "../index"; +import { + MAX_REQUEST_BODY_BYTES, + MAX_TOOL_INPUT_ELEMENTS, +} from "../request-limits"; +import { streamOf } from "./streams"; + +const TEST_ENV = { + CF_VERSION_METADATA: { id: "test", tag: "test", timestamp: "test" }, +}; + +async function connectClient(): Promise { + const client = new Client({ name: "docs-mcp-test", version: "1.0.0" }); + const transport = new StreamableHTTPClientTransport( + new URL("https://docs-mcp.internal/mcp"), + { + fetch: async (input, init) => + await app.request(new Request(input, init), undefined, TEST_ENV), + }, + ); + await client.connect(transport); + return client; +} + +function stubDocsUnavailable() { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response("Unavailable", { + status: 503, + statusText: "Service Unavailable", + }), + ), + ); +} + +function mcpRequest(body: BodyInit, headers: Record = {}) { + return new Request("https://docs-mcp.internal/mcp", { + body, + // Node requires `duplex` for streamed request bodies. + duplex: "half", + headers: { + accept: "application/json, text/event-stream", + "content-type": "application/json", + ...headers, + }, + method: "POST", + } as RequestInit); +} + +const PAYLOAD_TOO_LARGE = { + error: { + code: -32000, + message: `Payload Too Large: Request body must not exceed ${MAX_REQUEST_BODY_BYTES} bytes`, + }, + id: null, + jsonrpc: "2.0", +}; describe("private MCP transport", () => { test("initializes and lists the docs tools through the service binding", async () => { - const client = new Client({ name: "docs-mcp-test", version: "1.0.0" }); - const transport = new StreamableHTTPClientTransport( - new URL("https://docs-mcp.internal/mcp"), - { - fetch: async (input, init) => - await app.request(new Request(input, init), undefined, { - CF_VERSION_METADATA: { id: "test", tag: "test", timestamp: "test" }, - }), - }, - ); + const client = await connectClient(); try { - await client.connect(transport); const { tools } = await client.listTools(); expect(tools.map((tool) => tool.name).sort()).toEqual([ @@ -48,30 +96,10 @@ describe("private MCP transport", () => { name: "search-docs", }, ])("marks $name failures as MCP errors", async ({ args, error, name }) => { - vi.stubGlobal( - "fetch", - vi.fn( - async () => - new Response("Unavailable", { - status: 503, - statusText: "Service Unavailable", - }), - ), - ); - - const client = new Client({ name: "docs-mcp-test", version: "1.0.0" }); - const transport = new StreamableHTTPClientTransport( - new URL("https://docs-mcp.internal/mcp"), - { - fetch: async (input, init) => - await app.request(new Request(input, init), undefined, { - CF_VERSION_METADATA: { id: "test", tag: "test", timestamp: "test" }, - }), - }, - ); + stubDocsUnavailable(); + const client = await connectClient(); try { - await client.connect(transport); const result = await client.callTool({ arguments: args, name }); expect(result).toEqual({ @@ -102,4 +130,124 @@ describe("private MCP transport", () => { error: "Docs MCP is only available through its service binding", }); }); + test("runs a tool call at the input element budget", async () => { + stubDocsUnavailable(); + const client = await connectClient(); + + try { + // `libs` is one key, so this is exactly MAX_TOOL_INPUT_ELEMENTS. + const libs = Array.from( + { length: MAX_TOOL_INPUT_ELEMENTS - 1 }, + (_, i) => `lib-${i}`, + ); + const result = await client.callTool({ + arguments: { libs }, + name: "get-available-features", + }); + + // The handler ran and stopped at the missing API key, before any model call. + expect(result).toEqual({ + content: [ + { + text: JSON.stringify({ + error: + "Failed to list features: Set OPENROUTER_API_KEY on the docs-mcp worker", + }), + type: "text", + }, + ], + isError: true, + }); + } finally { + vi.unstubAllGlobals(); + await client.close(); + } + }); + + test("rejects a tool call over the input element budget before the handler runs", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const client = await connectClient(); + + try { + const libs = Array.from( + { length: MAX_TOOL_INPUT_ELEMENTS }, + (_, i) => `lib-${i}`, + ); + const result = await client.callTool({ + arguments: { libs }, + name: "get-available-features", + }); + + expect(result).toEqual({ + content: [ + { + text: `MCP error -32602: Invalid arguments for tool get-available-features: arguments contain more than the maximum of ${MAX_TOOL_INPUT_ELEMENTS} elements`, + type: "text", + }, + ], + isError: true, + }); + // The handler warms the doctree cache first, so no fetch means it never ran. + expect(fetchMock).not.toHaveBeenCalled(); + } finally { + vi.unstubAllGlobals(); + await client.close(); + } + }); + + test("accepts a request body at the size budget", async () => { + stubDocsUnavailable(); + try { + const request = { + id: 1, + jsonrpc: "2.0", + method: "tools/call", + params: { arguments: { query: "" }, name: "search-docs" }, + }; + const query = "a".repeat( + MAX_REQUEST_BODY_BYTES - JSON.stringify(request).length, + ); + const body = JSON.stringify({ + ...request, + params: { ...request.params, arguments: { query } }, + }); + expect(new TextEncoder().encode(body).byteLength).toBe( + MAX_REQUEST_BODY_BYTES, + ); + + const response = await app.request(mcpRequest(body), undefined, TEST_ENV); + + // The transport answers over SSE; the tool ran and hit the stubbed fetch. + expect(response.status).toBe(200); + const text = await response.text(); + expect(text).toContain('"id":1'); + expect(text).toContain("Failed to search docs"); + } finally { + vi.unstubAllGlobals(); + } + }); + + test("rejects a declared oversized body before reading it", async () => { + const response = await app.request( + mcpRequest(streamOf(16), { + "content-length": String(MAX_REQUEST_BODY_BYTES + 1), + }), + undefined, + TEST_ENV, + ); + + expect(response.status).toBe(413); + await expect(response.json()).resolves.toEqual(PAYLOAD_TOO_LARGE); + }); + + test("rejects an oversized streamed body without Content-Length", async () => { + const request = mcpRequest(streamOf(MAX_REQUEST_BODY_BYTES + 1)); + expect(request.headers.has("content-length")).toBe(false); + + const response = await app.request(request, undefined, TEST_ENV); + + expect(response.status).toBe(413); + await expect(response.json()).resolves.toEqual(PAYLOAD_TOO_LARGE); + }); }); diff --git a/apps/docs-mcp/src/__tests__/streams.ts b/apps/docs-mcp/src/__tests__/streams.ts new file mode 100644 index 000000000..5fa36d72b --- /dev/null +++ b/apps/docs-mcp/src/__tests__/streams.ts @@ -0,0 +1,15 @@ +/** A request body stream of at least `size` bytes, sent in 16 KiB chunks. */ +export function streamOf(size: number): ReadableStream { + const chunk = new Uint8Array(16 * 1024).fill(0x20); + let sent = 0; + return new ReadableStream({ + pull(controller) { + if (sent >= size) { + controller.close(); + return; + } + controller.enqueue(chunk); + sent += chunk.byteLength; + }, + }); +} diff --git a/apps/docs-mcp/src/index.ts b/apps/docs-mcp/src/index.ts index ec2ffd94e..e3ef14725 100644 --- a/apps/docs-mcp/src/index.ts +++ b/apps/docs-mcp/src/index.ts @@ -15,8 +15,11 @@ import { } from "./internal-api"; import { createMcpServer } from "./mcp"; import { trackToolCall, trackToolDuration } from "./metrics"; +import { MAX_REQUEST_BODY_BYTES, readBoundedBody } from "./request-limits"; import type { Env } from "./types"; +const BODY_TOO_LARGE_MESSAGE = `Request body must not exceed ${MAX_REQUEST_BODY_BYTES} bytes`; + export const app = new Hono<{ Bindings: Env }>().use(cors()); app.get("/health", (c) => c.json({ status: "ok" })); @@ -29,6 +32,26 @@ app.all("/mcp", async (c) => { ); } + // @hono/mcp parses POST bodies itself, so bound them before the transport + // reads them. It does not read bodies for other methods. + if (c.req.method === "POST" && c.req.raw.body !== null) { + const body = await readBoundedBody(c.req.raw); + if (!body) { + return c.json( + { + error: { + code: -32000, + message: `Payload Too Large: ${BODY_TOO_LARGE_MESSAGE}`, + }, + id: null, + jsonrpc: "2.0", + }, + 413, + ); + } + c.req.raw = new Request(c.req.raw, { body, method: "POST" }); + } + const transport = new StreamableHTTPTransport(); const mcp = createMcpServer( c.env.OPENROUTER_API_KEY, @@ -48,7 +71,17 @@ app.post("/internal/docs", async (c) => { ); } - const body = await c.req.json().catch(() => undefined); + const rawBody = await readBoundedBody(c.req.raw); + if (!rawBody) { + return c.json({ error: BODY_TOO_LARGE_MESSAGE }, 413); + } + + let body: unknown; + try { + body = JSON.parse(new TextDecoder().decode(rawBody)); + } catch { + body = undefined; + } const parsed = internalDocsRequestSchema.safeParse(body); if (!parsed.success) { return c.json({ error: "Invalid docs API request" }, 400); diff --git a/apps/docs-mcp/src/mcp.ts b/apps/docs-mcp/src/mcp.ts index 3c4ed4786..a2d4955de 100644 --- a/apps/docs-mcp/src/mcp.ts +++ b/apps/docs-mcp/src/mcp.ts @@ -11,6 +11,7 @@ import { searchDocsInputSchema, } from "./internal-api"; import { trackToolCall, trackToolDuration } from "./metrics"; +import { MAX_TOOL_INPUT_ELEMENTS } from "./request-limits"; import { getDoctree } from "./sentry-docs/doctree"; // ── Helpers ───────────────────────────────────────────────────────── @@ -149,12 +150,15 @@ export function createMcpServer( docsVersion?: string, ) { const mcp = Sentry.wrapMcpServerWithSentry( - new McpServer({ - description: - "Generates tailored Sentry integration docs by reading docs.sentry.io.", - name: "sentry-docs", - version: "1.0.0", - }), + new McpServer( + { + description: + "Generates tailored Sentry integration docs by reading docs.sentry.io.", + name: "sentry-docs", + version: "1.0.0", + }, + { maxToolInputElements: MAX_TOOL_INPUT_ELEMENTS }, + ), { recordInputs: true, recordOutputs: true, diff --git a/apps/docs-mcp/src/request-limits.ts b/apps/docs-mcp/src/request-limits.ts new file mode 100644 index 000000000..11ab341dd --- /dev/null +++ b/apps/docs-mcp/src/request-limits.ts @@ -0,0 +1,53 @@ +/** + * Input budgets for the docs Worker (GHSA-46jr-4wx8-v453). + * + * The largest bounded request is `query-docs`: a 2,000-character query plus at + * most 40 context strings of 100 characters. Even with every character escaped + * as `\uXXXX`, that stays under 40 KiB. The other inputs are short slugs, + * keywords, and a stack summary, so 64 KiB covers every supported request while + * keeping what one request can make the Worker parse small. + */ +export const MAX_REQUEST_BODY_BYTES = 64 * 1024; + +/** + * Largest combined number of array elements and object keys in one MCP + * `tools/call` `arguments` payload. `query-docs` peaks at about 45 (40 context + * strings plus their keys); `libs` and `features` lists hold tens of slugs. + * 1,000 leaves wide headroom for real codebases. + */ +export const MAX_TOOL_INPUT_ELEMENTS = 1_000; + +/** + * Reads the request body, stopping as soon as it exceeds + * MAX_REQUEST_BODY_BYTES. Returns `undefined` when the body is too large. + * + * A declared `Content-Length` is rejected before reading. Streamed bytes are + * still counted, so bodies without `Content-Length`, or with a wrong one, are + * bounded too. + */ +export async function readBoundedBody( + request: Request, +): Promise | undefined> { + if (Number(request.headers.get("content-length")) > MAX_REQUEST_BODY_BYTES) { + return undefined; + } + if (request.body === null) { + return new Uint8Array(); + } + + const reader = request.body.getReader(); + const body = new Uint8Array(MAX_REQUEST_BODY_BYTES); + let received = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) { + return body.subarray(0, received); + } + if (received + value.byteLength > MAX_REQUEST_BODY_BYTES) { + await reader.cancel(); + return undefined; + } + body.set(value, received); + received += value.byteLength; + } +} From b0aad47de1fe9f19d065384031c7bc4d7c7927ba Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 17:11:12 +0000 Subject: [PATCH 2/3] ref(toolkit-core): Share the bounded MCP request body reader Move the body-size guard into @sentry/toolkit-core/request-body so the hosted MCP server and Docs MCP use the same reader and JSON-RPC 413. Each server keeps its own byte budget. --- apps/docs-mcp/README.md | 3 +- apps/docs-mcp/package.json | 1 + apps/docs-mcp/src/index.ts | 30 +++---- apps/docs-mcp/src/request-limits.ts | 35 -------- packages/mcp-cloudflare/package.json | 1 + .../src/server/lib/mcp-handler.ts | 60 ++----------- packages/toolkit-core/package.json | 4 + .../toolkit-core/src/request-body.test.ts | 88 +++++++++++++++++++ packages/toolkit-core/src/request-body.ts | 86 ++++++++++++++++++ pnpm-lock.yaml | 6 ++ 10 files changed, 207 insertions(+), 107 deletions(-) create mode 100644 packages/toolkit-core/src/request-body.test.ts create mode 100644 packages/toolkit-core/src/request-body.ts diff --git a/apps/docs-mcp/README.md b/apps/docs-mcp/README.md index 55eed3505..2c62ee1d0 100644 --- a/apps/docs-mcp/README.md +++ b/apps/docs-mcp/README.md @@ -38,7 +38,8 @@ JSON-RPC error (`code: -32000`); an oversized `/internal/docs` request gets a 41 with `{ "error": "Request body must not exceed 65536 bytes" }`. MCP tool calls also accept at most 1,000 array elements plus object keys across `arguments`; larger calls return an `isError` result before the tool runs. Both budgets live -in `src/request-limits.ts`, with the sizing rationale. +in `src/request-limits.ts`, with the sizing rationale. The bounded body reader is +shared with the main MCP server through `@sentry/toolkit-core/request-body`. The existing `sentry docs` CLI calls Init Server's authenticated `/api/docs/*` facade, which forwards requests through its `DOCS_MCP` binding. Moving this app diff --git a/apps/docs-mcp/package.json b/apps/docs-mcp/package.json index e5f219a09..1b62d210f 100644 --- a/apps/docs-mcp/package.json +++ b/apps/docs-mcp/package.json @@ -17,6 +17,7 @@ "@modelcontextprotocol/sdk": "^1.31.0", "@openrouter/ai-sdk-provider": "^1.5.4", "@sentry/cloudflare": "^10.62.0", + "@sentry/toolkit-core": "workspace:*", "ai": "^5.0.0", "hono": "^4.13.11", "zod": "^4.6.6" diff --git a/apps/docs-mcp/src/index.ts b/apps/docs-mcp/src/index.ts index e3ef14725..91dccbc1e 100644 --- a/apps/docs-mcp/src/index.ts +++ b/apps/docs-mcp/src/index.ts @@ -4,6 +4,10 @@ */ import { StreamableHTTPTransport } from "@hono/mcp"; import * as Sentry from "@sentry/cloudflare"; +import { + boundMcpRequestBody, + readBoundedBody, +} from "@sentry/toolkit-core/request-body"; import { Hono } from "hono"; import { cors } from "hono/cors"; import { isDocsMcpServiceRequest } from "./access"; @@ -15,7 +19,7 @@ import { } from "./internal-api"; import { createMcpServer } from "./mcp"; import { trackToolCall, trackToolDuration } from "./metrics"; -import { MAX_REQUEST_BODY_BYTES, readBoundedBody } from "./request-limits"; +import { MAX_REQUEST_BODY_BYTES } from "./request-limits"; import type { Env } from "./types"; const BODY_TOO_LARGE_MESSAGE = `Request body must not exceed ${MAX_REQUEST_BODY_BYTES} bytes`; @@ -33,24 +37,12 @@ app.all("/mcp", async (c) => { } // @hono/mcp parses POST bodies itself, so bound them before the transport - // reads them. It does not read bodies for other methods. - if (c.req.method === "POST" && c.req.raw.body !== null) { - const body = await readBoundedBody(c.req.raw); - if (!body) { - return c.json( - { - error: { - code: -32000, - message: `Payload Too Large: ${BODY_TOO_LARGE_MESSAGE}`, - }, - id: null, - jsonrpc: "2.0", - }, - 413, - ); - } - c.req.raw = new Request(c.req.raw, { body, method: "POST" }); + // reads them. + const bounded = await boundMcpRequestBody(c.req.raw, MAX_REQUEST_BODY_BYTES); + if (bounded instanceof Response) { + return bounded; } + c.req.raw = bounded; const transport = new StreamableHTTPTransport(); const mcp = createMcpServer( @@ -71,7 +63,7 @@ app.post("/internal/docs", async (c) => { ); } - const rawBody = await readBoundedBody(c.req.raw); + const rawBody = await readBoundedBody(c.req.raw, MAX_REQUEST_BODY_BYTES); if (!rawBody) { return c.json({ error: BODY_TOO_LARGE_MESSAGE }, 413); } diff --git a/apps/docs-mcp/src/request-limits.ts b/apps/docs-mcp/src/request-limits.ts index 11ab341dd..6f1cf5eb7 100644 --- a/apps/docs-mcp/src/request-limits.ts +++ b/apps/docs-mcp/src/request-limits.ts @@ -16,38 +16,3 @@ export const MAX_REQUEST_BODY_BYTES = 64 * 1024; * 1,000 leaves wide headroom for real codebases. */ export const MAX_TOOL_INPUT_ELEMENTS = 1_000; - -/** - * Reads the request body, stopping as soon as it exceeds - * MAX_REQUEST_BODY_BYTES. Returns `undefined` when the body is too large. - * - * A declared `Content-Length` is rejected before reading. Streamed bytes are - * still counted, so bodies without `Content-Length`, or with a wrong one, are - * bounded too. - */ -export async function readBoundedBody( - request: Request, -): Promise | undefined> { - if (Number(request.headers.get("content-length")) > MAX_REQUEST_BODY_BYTES) { - return undefined; - } - if (request.body === null) { - return new Uint8Array(); - } - - const reader = request.body.getReader(); - const body = new Uint8Array(MAX_REQUEST_BODY_BYTES); - let received = 0; - for (;;) { - const { done, value } = await reader.read(); - if (done) { - return body.subarray(0, received); - } - if (received + value.byteLength > MAX_REQUEST_BODY_BYTES) { - await reader.cancel(); - return undefined; - } - body.set(value, received); - received += value.byteLength; - } -} diff --git a/packages/mcp-cloudflare/package.json b/packages/mcp-cloudflare/package.json index 082e66fba..18e5c8455 100644 --- a/packages/mcp-cloudflare/package.json +++ b/packages/mcp-cloudflare/package.json @@ -36,6 +36,7 @@ "@sentry/mcp-core": "workspace:*", "@sentry/mcp-server-mocks": "workspace:*", "@sentry/mcp-server-tsconfig": "workspace:*", + "@sentry/toolkit-core": "workspace:*", "@sentry/vite-plugin": "catalog:", "@tailwindcss/typography": "catalog:", "@tailwindcss/vite": "catalog:", diff --git a/packages/mcp-cloudflare/src/server/lib/mcp-handler.ts b/packages/mcp-cloudflare/src/server/lib/mcp-handler.ts index fef06e14b..0fda0b80e 100644 --- a/packages/mcp-cloudflare/src/server/lib/mcp-handler.ts +++ b/packages/mcp-cloudflare/src/server/lib/mcp-handler.ts @@ -22,6 +22,7 @@ import { } from "@sentry/mcp-core/skills"; import { logWarn } from "@sentry/mcp-core/telem/logging"; import type { ServerContext } from "@sentry/mcp-core/types"; +import { boundMcpRequestBody } from "@sentry/toolkit-core/request-body"; import { createMcpHandler } from "agents/mcp/server"; import { annotateResponseMetric } from "../metrics"; import { @@ -79,57 +80,6 @@ type AuthenticatedMcpContext = OAuthMcpContext | SentryBearerMcpContext; */ export const MAX_MCP_REQUEST_BODY_BYTES = 1024 * 1024; -function requestBodyTooLargeResponse(): Response { - return Response.json( - { - jsonrpc: "2.0", - error: { - code: -32000, - message: `Payload Too Large: Request body must not exceed ${MAX_MCP_REQUEST_BODY_BYTES} bytes`, - }, - id: null, - }, - { status: 413 }, - ); -} - -/** - * Buffers a POST body up to MAX_MCP_REQUEST_BODY_BYTES, or returns a 413. - * - * `createMcpHandler` from agents only forwards `maxRequestBodySize` to the SDK - * v2 lane. Its legacy-request classification and legacy compatibility lane - * still read up to the SDK's 4 MiB default, so the bound is enforced here for - * every lane. - */ -async function boundRequestBody(request: Request): Promise { - if (request.method !== "POST" || request.body === null) { - return request; - } - if ( - Number(request.headers.get("content-length")) > MAX_MCP_REQUEST_BODY_BYTES - ) { - return requestBodyTooLargeResponse(); - } - - const reader = request.body.getReader(); - const chunks: Uint8Array[] = []; - let received = 0; - for (;;) { - const { done, value } = await reader.read(); - if (done) { - break; - } - received += value.byteLength; - if (received > MAX_MCP_REQUEST_BODY_BYTES) { - await reader.cancel(); - return requestBodyTooLargeResponse(); - } - chunks.push(value); - } - - return new Request(request, { method: "POST", body: new Blob(chunks) }); -} - function getSkillGrantedAttributeName(skill: string): string { return `app.consent.skill.${skill.replaceAll("-", "_")}.granted`; } @@ -516,7 +466,13 @@ async function handleAuthenticatedMcpRequest( auth.kind === "oauth" ? auth.onUpstreamUnauthorized : undefined, }; - const boundedRequest = await boundRequestBody(request); + // `createMcpHandler` from agents only forwards `maxRequestBodySize` to the + // SDK v2 lane. Its legacy-request classification and legacy compatibility + // lane still read up to the SDK's 4 MiB default, so bound every lane here. + const boundedRequest = await boundMcpRequestBody( + request, + MAX_MCP_REQUEST_BODY_BYTES, + ); if (boundedRequest instanceof Response) { return boundedRequest; } diff --git a/packages/toolkit-core/package.json b/packages/toolkit-core/package.json index 063aece6a..43163b360 100644 --- a/packages/toolkit-core/package.json +++ b/packages/toolkit-core/package.json @@ -43,6 +43,10 @@ "types": "./src/region-cache.ts", "default": "./src/region-cache.ts" }, + "./request-body": { + "types": "./src/request-body.ts", + "default": "./src/request-body.ts" + }, "./resource-identity": { "types": "./src/resource-identity.ts", "default": "./src/resource-identity.ts" diff --git a/packages/toolkit-core/src/request-body.test.ts b/packages/toolkit-core/src/request-body.test.ts new file mode 100644 index 000000000..39aa8edaf --- /dev/null +++ b/packages/toolkit-core/src/request-body.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { boundMcpRequestBody, readBoundedBody } from "./request-body.js"; + +const LIMIT = 64; + +function streamOf(chunks: number[]): ReadableStream { + return new ReadableStream({ + start(controller) { + for (const size of chunks) { + controller.enqueue(new Uint8Array(size).fill(0x61)); + } + controller.close(); + }, + }); +} + +function post(body: BodyInit | null, headers: Record = {}) { + return new Request("https://example.test/mcp", { + body, + // Node requires `duplex` for streamed request bodies. + duplex: "half", + headers, + method: "POST", + } as RequestInit); +} + +describe("readBoundedBody", () => { + it("returns a body at the limit across chunks", async () => { + const body = await readBoundedBody(post(streamOf([40, 24])), LIMIT); + + expect(body).toEqual(new Uint8Array(LIMIT).fill(0x61)); + }); + + it("returns an empty body when the request has none", async () => { + const body = await readBoundedBody(post(null), LIMIT); + + expect(body).toEqual(new Uint8Array()); + }); + + it("rejects a declared Content-Length over the limit", async () => { + const request = post(streamOf([1]), { + "content-length": String(LIMIT + 1), + }); + + await expect(readBoundedBody(request, LIMIT)).resolves.toBeUndefined(); + }); + + it("rejects a streamed body over the limit without Content-Length", async () => { + const request = post(streamOf([40, 25])); + expect(request.headers.has("content-length")).toBe(false); + + await expect(readBoundedBody(request, LIMIT)).resolves.toBeUndefined(); + }); +}); + +describe("boundMcpRequestBody", () => { + it("replays a body within the limit", async () => { + const bounded = await boundMcpRequestBody(post('{"id":1}'), LIMIT); + + expect(bounded).toBeInstanceOf(Request); + await expect((bounded as Request).text()).resolves.toBe('{"id":1}'); + }); + + it("returns the JSON-RPC 413 for an oversized body", async () => { + const bounded = await boundMcpRequestBody( + post(streamOf([LIMIT + 1])), + LIMIT, + ); + + expect(bounded).toBeInstanceOf(Response); + const response = bounded as Response; + expect(response.status).toBe(413); + await expect(response.json()).resolves.toEqual({ + error: { + code: -32000, + message: `Payload Too Large: Request body must not exceed ${LIMIT} bytes`, + }, + id: null, + jsonrpc: "2.0", + }); + }); + + it("passes non-POST requests through unchanged", async () => { + const request = new Request("https://example.test/mcp", { method: "GET" }); + + await expect(boundMcpRequestBody(request, LIMIT)).resolves.toBe(request); + }); +}); diff --git a/packages/toolkit-core/src/request-body.ts b/packages/toolkit-core/src/request-body.ts new file mode 100644 index 000000000..002eeb817 --- /dev/null +++ b/packages/toolkit-core/src/request-body.ts @@ -0,0 +1,86 @@ +/** + * Request body limits shared by Toolkit's HTTP MCP servers + * (GHSA-46jr-4wx8-v453). Each server picks its own byte budget. + */ + +/** + * Reads a request body, stopping as soon as it exceeds `maxBytes`. Returns + * `undefined` when the body is too large. + * + * A declared `Content-Length` over the limit is rejected before reading. + * Streamed bytes are still counted, so bodies without `Content-Length`, or with + * a wrong one, are bounded too. + */ +export async function readBoundedBody( + request: Request, + maxBytes: number, +): Promise | undefined> { + if (Number(request.headers.get("content-length")) > maxBytes) { + return undefined; + } + if (request.body === null) { + return new Uint8Array(); + } + + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let received = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) { + break; + } + received += value.byteLength; + if (received > maxBytes) { + await reader.cancel(); + return undefined; + } + chunks.push(value); + } + + const body = new Uint8Array(received); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return body; +} + +/** The JSON-RPC 413 the MCP SDK returns for an oversized request body. */ +export function mcpPayloadTooLargeResponse(maxBytes: number): Response { + return Response.json( + { + jsonrpc: "2.0", + error: { + code: -32000, + message: `Payload Too Large: Request body must not exceed ${maxBytes} bytes`, + }, + id: null, + }, + { status: 413 }, + ); +} + +/** + * Buffers an MCP POST body up to `maxBytes` and returns a request that replays + * it, or the JSON-RPC 413. Other methods and bodiless requests pass through + * unchanged. + * + * Use this when the MCP transport parses the body itself and its own limit does + * not cover every path. + */ +export async function boundMcpRequestBody( + request: Request, + maxBytes: number, +): Promise { + if (request.method !== "POST" || request.body === null) { + return request; + } + + const body = await readBoundedBody(request, maxBytes); + if (!body) { + return mcpPayloadTooLargeResponse(maxBytes); + } + return new Request(request, { method: "POST", body }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 14aaba594..49e389fd3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -313,6 +313,9 @@ importers: '@sentry/cloudflare': specifier: ^10.62.0 version: 10.67.0(@opentelemetry/core@2.11.0(@opentelemetry/api@1.9.1)) + '@sentry/toolkit-core': + specifier: workspace:* + version: link:../../packages/toolkit-core ai: specifier: ^5.0.0 version: 5.0.216(zod@4.6.7) @@ -754,6 +757,9 @@ importers: '@sentry/mcp-server-tsconfig': specifier: workspace:* version: link:../mcp-server-tsconfig + '@sentry/toolkit-core': + specifier: workspace:* + version: link:../toolkit-core '@sentry/vite-plugin': specifier: 'catalog:' version: 4.6.1 From 1dfc68f1c581aa89ff6aee5cbb429a33196c9fde Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:14:44 +0000 Subject: [PATCH 3/3] ref(toolkit-core): Share MCP input limits across both servers Move MAX_MCP_REQUEST_BODY_BYTES and MAX_TOOL_INPUT_ELEMENTS into @sentry/toolkit-core/mcp-limits next to the body reader, so the hosted MCP server and Docs MCP use the same 1 MiB and 10,000-element budgets. Generic body-limit tests now live in toolkit-core; Docs MCP keeps only tests that its routes apply the limits. --- apps/docs-mcp/README.md | 16 +-- .../src/__tests__/internal-api.test.ts | 59 ++------ apps/docs-mcp/src/__tests__/mcp.test.ts | 128 +++--------------- apps/docs-mcp/src/__tests__/streams.ts | 15 -- apps/docs-mcp/src/index.ts | 10 +- apps/docs-mcp/src/mcp.ts | 2 +- apps/docs-mcp/src/request-limits.ts | 18 --- docs/cloudflare/architecture.md | 2 +- .../src/server/lib/mcp-handler.test.ts | 8 +- .../src/server/lib/mcp-handler.ts | 18 +-- packages/mcp-core/src/constants.ts | 9 -- packages/mcp-core/src/server.test.ts | 2 +- packages/mcp-core/src/server.ts | 3 +- packages/toolkit-core/package.json | 8 +- packages/toolkit-core/src/mcp-limits.test.ts | 108 +++++++++++++++ .../src/{request-body.ts => mcp-limits.ts} | 47 +++++-- .../toolkit-core/src/request-body.test.ts | 88 ------------ 17 files changed, 201 insertions(+), 340 deletions(-) delete mode 100644 apps/docs-mcp/src/__tests__/streams.ts delete mode 100644 apps/docs-mcp/src/request-limits.ts create mode 100644 packages/toolkit-core/src/mcp-limits.test.ts rename packages/toolkit-core/src/{request-body.ts => mcp-limits.ts} (50%) delete mode 100644 packages/toolkit-core/src/request-body.test.ts diff --git a/apps/docs-mcp/README.md b/apps/docs-mcp/README.md index 2c62ee1d0..5d4bfdc9b 100644 --- a/apps/docs-mcp/README.md +++ b/apps/docs-mcp/README.md @@ -32,14 +32,14 @@ limited to `frameworks`, `languages`, and `sentryConfigured`. Model failures and unverifiable answers retain the safe `DOCS_MODEL_UNAVAILABLE` and `DOCS_UNGROUNDED` error codes. -Both endpoints cap request bodies at 64 KiB before parsing JSON, whether or not -the request declares `Content-Length`. An oversized `/mcp` POST gets a 413 with a -JSON-RPC error (`code: -32000`); an oversized `/internal/docs` request gets a 413 -with `{ "error": "Request body must not exceed 65536 bytes" }`. MCP tool calls -also accept at most 1,000 array elements plus object keys across `arguments`; -larger calls return an `isError` result before the tool runs. Both budgets live -in `src/request-limits.ts`, with the sizing rationale. The bounded body reader is -shared with the main MCP server through `@sentry/toolkit-core/request-body`. +Both endpoints apply the same input limits as Toolkit's main MCP server, from +`@sentry/toolkit-core/mcp-limits`. Request bodies are capped at 1 MiB before JSON +parsing, whether or not the request declares `Content-Length`. An oversized +`/mcp` POST gets a 413 with a JSON-RPC error (`code: -32000`); an oversized +`/internal/docs` request gets a 413 with +`{ "error": "Request body must not exceed 1048576 bytes" }`. MCP tool calls accept +at most 10,000 array elements plus object keys across `arguments`; larger calls +return an `isError` result before the tool runs. The existing `sentry docs` CLI calls Init Server's authenticated `/api/docs/*` facade, which forwards requests through its `DOCS_MCP` binding. Moving this app diff --git a/apps/docs-mcp/src/__tests__/internal-api.test.ts b/apps/docs-mcp/src/__tests__/internal-api.test.ts index 7a34982f0..7c22fd086 100644 --- a/apps/docs-mcp/src/__tests__/internal-api.test.ts +++ b/apps/docs-mcp/src/__tests__/internal-api.test.ts @@ -10,21 +10,14 @@ vi.mock("../internal-api", async (importOriginal) => ({ })); import { app } from "../index"; -import { MAX_REQUEST_BODY_BYTES } from "../request-limits"; -import { streamOf } from "./streams"; +import { MAX_MCP_REQUEST_BODY_BYTES } from "@sentry/toolkit-core/mcp-limits"; -const TOO_LARGE = { - error: `Request body must not exceed ${MAX_REQUEST_BODY_BYTES} bytes`, -}; - -function docsRequest(body: BodyInit, headers: Record = {}) { +function docsRequest(body: string) { return new Request("https://docs-mcp.internal/internal/docs", { body, - // Node requires `duplex` for streamed request bodies. - duplex: "half", - headers: { "content-type": "application/json", ...headers }, + headers: { "content-type": "application/json" }, method: "POST", - } as RequestInit); + }); } describe("private docs API", () => { @@ -120,47 +113,17 @@ describe("private docs API", () => { error: "Docs API request failed", }); }); - test("accepts a request body at the size budget", async () => { - executeInternalDocsRequest.mockResolvedValue({ results: [] }); - const request = { operation: "search-docs", query: "" }; - const query = "a".repeat( - MAX_REQUEST_BODY_BYTES - JSON.stringify(request).length, - ); - + // The body limit itself is tested in toolkit-core; this checks the route + // applies it with its own error shape. + test("rejects an oversized body before parsing it", async () => { const response = await app.request( - docsRequest(JSON.stringify({ ...request, query })), - undefined, - { CF_VERSION_METADATA: { id: "test", tag: "test", timestamp: "test" } }, - ); - - expect(response.status).toBe(200); - expect(executeInternalDocsRequest).toHaveBeenCalledWith( - undefined, - { operation: "search-docs", query }, - "test", - ); - }); - - test("rejects a declared oversized body before reading it", async () => { - const response = await app.request( - docsRequest(streamOf(16), { - "content-length": String(MAX_REQUEST_BODY_BYTES + 1), - }), + docsRequest("a".repeat(MAX_MCP_REQUEST_BODY_BYTES + 1)), ); expect(response.status).toBe(413); - await expect(response.json()).resolves.toEqual(TOO_LARGE); - expect(executeInternalDocsRequest).not.toHaveBeenCalled(); - }); - - test("rejects an oversized streamed body without Content-Length", async () => { - const request = docsRequest(streamOf(MAX_REQUEST_BODY_BYTES + 1)); - expect(request.headers.has("content-length")).toBe(false); - - const response = await app.request(request); - - expect(response.status).toBe(413); - await expect(response.json()).resolves.toEqual(TOO_LARGE); + await expect(response.json()).resolves.toEqual({ + error: `Request body must not exceed ${MAX_MCP_REQUEST_BODY_BYTES} bytes`, + }); expect(executeInternalDocsRequest).not.toHaveBeenCalled(); }); diff --git a/apps/docs-mcp/src/__tests__/mcp.test.ts b/apps/docs-mcp/src/__tests__/mcp.test.ts index 271d46d87..e975c8ba7 100644 --- a/apps/docs-mcp/src/__tests__/mcp.test.ts +++ b/apps/docs-mcp/src/__tests__/mcp.test.ts @@ -3,10 +3,10 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/ import { describe, expect, test, vi } from "vitest"; import { app } from "../index"; import { - MAX_REQUEST_BODY_BYTES, + MAX_MCP_REQUEST_BODY_BYTES, MAX_TOOL_INPUT_ELEMENTS, -} from "../request-limits"; -import { streamOf } from "./streams"; + mcpPayloadTooLargeResponse, +} from "@sentry/toolkit-core/mcp-limits"; const TEST_ENV = { CF_VERSION_METADATA: { id: "test", tag: "test", timestamp: "test" }, @@ -38,29 +38,6 @@ function stubDocsUnavailable() { ); } -function mcpRequest(body: BodyInit, headers: Record = {}) { - return new Request("https://docs-mcp.internal/mcp", { - body, - // Node requires `duplex` for streamed request bodies. - duplex: "half", - headers: { - accept: "application/json, text/event-stream", - "content-type": "application/json", - ...headers, - }, - method: "POST", - } as RequestInit); -} - -const PAYLOAD_TOO_LARGE = { - error: { - code: -32000, - message: `Payload Too Large: Request body must not exceed ${MAX_REQUEST_BODY_BYTES} bytes`, - }, - id: null, - jsonrpc: "2.0", -}; - describe("private MCP transport", () => { test("initializes and lists the docs tools through the service binding", async () => { const client = await connectClient(); @@ -130,41 +107,9 @@ describe("private MCP transport", () => { error: "Docs MCP is only available through its service binding", }); }); - test("runs a tool call at the input element budget", async () => { - stubDocsUnavailable(); - const client = await connectClient(); - - try { - // `libs` is one key, so this is exactly MAX_TOOL_INPUT_ELEMENTS. - const libs = Array.from( - { length: MAX_TOOL_INPUT_ELEMENTS - 1 }, - (_, i) => `lib-${i}`, - ); - const result = await client.callTool({ - arguments: { libs }, - name: "get-available-features", - }); - - // The handler ran and stopped at the missing API key, before any model call. - expect(result).toEqual({ - content: [ - { - text: JSON.stringify({ - error: - "Failed to list features: Set OPENROUTER_API_KEY on the docs-mcp worker", - }), - type: "text", - }, - ], - isError: true, - }); - } finally { - vi.unstubAllGlobals(); - await client.close(); - } - }); - - test("rejects a tool call over the input element budget before the handler runs", async () => { + // Body and element limits themselves are tested in toolkit-core; these + // check that this server applies them. + test("rejects a tool call over the shared input element budget before the handler runs", async () => { const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); const client = await connectClient(); @@ -196,58 +141,23 @@ describe("private MCP transport", () => { } }); - test("accepts a request body at the size budget", async () => { - stubDocsUnavailable(); - try { - const request = { - id: 1, - jsonrpc: "2.0", - method: "tools/call", - params: { arguments: { query: "" }, name: "search-docs" }, - }; - const query = "a".repeat( - MAX_REQUEST_BODY_BYTES - JSON.stringify(request).length, - ); - const body = JSON.stringify({ - ...request, - params: { ...request.params, arguments: { query } }, - }); - expect(new TextEncoder().encode(body).byteLength).toBe( - MAX_REQUEST_BODY_BYTES, - ); - - const response = await app.request(mcpRequest(body), undefined, TEST_ENV); - - // The transport answers over SSE; the tool ran and hit the stubbed fetch. - expect(response.status).toBe(200); - const text = await response.text(); - expect(text).toContain('"id":1'); - expect(text).toContain("Failed to search docs"); - } finally { - vi.unstubAllGlobals(); - } - }); - - test("rejects a declared oversized body before reading it", async () => { + test("rejects an oversized body before the MCP transport reads it", async () => { const response = await app.request( - mcpRequest(streamOf(16), { - "content-length": String(MAX_REQUEST_BODY_BYTES + 1), - }), - undefined, + "https://docs-mcp.internal/mcp", + { + body: "a".repeat(MAX_MCP_REQUEST_BODY_BYTES + 1), + headers: { + accept: "application/json, text/event-stream", + "content-type": "application/json", + }, + method: "POST", + }, TEST_ENV, ); expect(response.status).toBe(413); - await expect(response.json()).resolves.toEqual(PAYLOAD_TOO_LARGE); - }); - - test("rejects an oversized streamed body without Content-Length", async () => { - const request = mcpRequest(streamOf(MAX_REQUEST_BODY_BYTES + 1)); - expect(request.headers.has("content-length")).toBe(false); - - const response = await app.request(request, undefined, TEST_ENV); - - expect(response.status).toBe(413); - await expect(response.json()).resolves.toEqual(PAYLOAD_TOO_LARGE); + await expect(response.json()).resolves.toEqual( + await mcpPayloadTooLargeResponse().json(), + ); }); }); diff --git a/apps/docs-mcp/src/__tests__/streams.ts b/apps/docs-mcp/src/__tests__/streams.ts deleted file mode 100644 index 5fa36d72b..000000000 --- a/apps/docs-mcp/src/__tests__/streams.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** A request body stream of at least `size` bytes, sent in 16 KiB chunks. */ -export function streamOf(size: number): ReadableStream { - const chunk = new Uint8Array(16 * 1024).fill(0x20); - let sent = 0; - return new ReadableStream({ - pull(controller) { - if (sent >= size) { - controller.close(); - return; - } - controller.enqueue(chunk); - sent += chunk.byteLength; - }, - }); -} diff --git a/apps/docs-mcp/src/index.ts b/apps/docs-mcp/src/index.ts index 91dccbc1e..d47dc342a 100644 --- a/apps/docs-mcp/src/index.ts +++ b/apps/docs-mcp/src/index.ts @@ -5,9 +5,10 @@ import { StreamableHTTPTransport } from "@hono/mcp"; import * as Sentry from "@sentry/cloudflare"; import { + MAX_MCP_REQUEST_BODY_BYTES, boundMcpRequestBody, readBoundedBody, -} from "@sentry/toolkit-core/request-body"; +} from "@sentry/toolkit-core/mcp-limits"; import { Hono } from "hono"; import { cors } from "hono/cors"; import { isDocsMcpServiceRequest } from "./access"; @@ -19,10 +20,9 @@ import { } from "./internal-api"; import { createMcpServer } from "./mcp"; import { trackToolCall, trackToolDuration } from "./metrics"; -import { MAX_REQUEST_BODY_BYTES } from "./request-limits"; import type { Env } from "./types"; -const BODY_TOO_LARGE_MESSAGE = `Request body must not exceed ${MAX_REQUEST_BODY_BYTES} bytes`; +const BODY_TOO_LARGE_MESSAGE = `Request body must not exceed ${MAX_MCP_REQUEST_BODY_BYTES} bytes`; export const app = new Hono<{ Bindings: Env }>().use(cors()); @@ -38,7 +38,7 @@ app.all("/mcp", async (c) => { // @hono/mcp parses POST bodies itself, so bound them before the transport // reads them. - const bounded = await boundMcpRequestBody(c.req.raw, MAX_REQUEST_BODY_BYTES); + const bounded = await boundMcpRequestBody(c.req.raw); if (bounded instanceof Response) { return bounded; } @@ -63,7 +63,7 @@ app.post("/internal/docs", async (c) => { ); } - const rawBody = await readBoundedBody(c.req.raw, MAX_REQUEST_BODY_BYTES); + const rawBody = await readBoundedBody(c.req.raw); if (!rawBody) { return c.json({ error: BODY_TOO_LARGE_MESSAGE }, 413); } diff --git a/apps/docs-mcp/src/mcp.ts b/apps/docs-mcp/src/mcp.ts index a2d4955de..5b984f152 100644 --- a/apps/docs-mcp/src/mcp.ts +++ b/apps/docs-mcp/src/mcp.ts @@ -1,5 +1,6 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import * as Sentry from "@sentry/cloudflare"; +import { MAX_TOOL_INPUT_ELEMENTS } from "@sentry/toolkit-core/mcp-limits"; import { type ZodRawShape, z } from "zod"; import { runFeaturesAgent } from "./agent/features-agent"; import { runTailorAgent } from "./agent/tailor-agent"; @@ -11,7 +12,6 @@ import { searchDocsInputSchema, } from "./internal-api"; import { trackToolCall, trackToolDuration } from "./metrics"; -import { MAX_TOOL_INPUT_ELEMENTS } from "./request-limits"; import { getDoctree } from "./sentry-docs/doctree"; // ── Helpers ───────────────────────────────────────────────────────── diff --git a/apps/docs-mcp/src/request-limits.ts b/apps/docs-mcp/src/request-limits.ts deleted file mode 100644 index 6f1cf5eb7..000000000 --- a/apps/docs-mcp/src/request-limits.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Input budgets for the docs Worker (GHSA-46jr-4wx8-v453). - * - * The largest bounded request is `query-docs`: a 2,000-character query plus at - * most 40 context strings of 100 characters. Even with every character escaped - * as `\uXXXX`, that stays under 40 KiB. The other inputs are short slugs, - * keywords, and a stack summary, so 64 KiB covers every supported request while - * keeping what one request can make the Worker parse small. - */ -export const MAX_REQUEST_BODY_BYTES = 64 * 1024; - -/** - * Largest combined number of array elements and object keys in one MCP - * `tools/call` `arguments` payload. `query-docs` peaks at about 45 (40 context - * strings plus their keys); `libs` and `features` lists hold tens of slugs. - * 1,000 leaves wide headroom for real codebases. - */ -export const MAX_TOOL_INPUT_ELEMENTS = 1_000; diff --git a/docs/cloudflare/architecture.md b/docs/cloudflare/architecture.md index 40d897198..b5efaa81f 100644 --- a/docs/cloudflare/architecture.md +++ b/docs/cloudflare/architecture.md @@ -171,7 +171,7 @@ SENTRY_CLIENT_SECRET = "..." # OAuth app secret 1. **Authentication**: OAuth tokens stored encrypted in KV 2. **Authorization**: Per-organization access control 3. **Rate Limiting**: Cloudflare rate limiter integration -4. **Request Limits**: `/mcp` POST bodies are capped at 1 MiB (`MAX_MCP_REQUEST_BODY_BYTES`), and tool-call arguments at 10,000 array elements plus object keys (`MAX_TOOL_INPUT_ELEMENTS`) +4. **Request Limits**: `/mcp` POST bodies are capped at 1 MiB (`MAX_MCP_REQUEST_BODY_BYTES`), and tool-call arguments at 10,000 array elements plus object keys (`MAX_TOOL_INPUT_ELEMENTS`). Both limits live in `@sentry/toolkit-core/mcp-limits`, shared with Docs MCP 5. **CORS**: Restricted to same-origin requests 6. **CSP**: Content Security Policy headers diff --git a/packages/mcp-cloudflare/src/server/lib/mcp-handler.test.ts b/packages/mcp-cloudflare/src/server/lib/mcp-handler.test.ts index 717e9e57e..809449976 100644 --- a/packages/mcp-cloudflare/src/server/lib/mcp-handler.test.ts +++ b/packages/mcp-cloudflare/src/server/lib/mcp-handler.test.ts @@ -21,11 +21,11 @@ vi.mock("@sentry/cloudflare", async (importOriginal) => ({ setUser: sentrySetUser, })); -import { MAX_TOOL_INPUT_ELEMENTS } from "@sentry/mcp-core/constants"; -import mcpHandler, { +import { MAX_MCP_REQUEST_BODY_BYTES, - handleSentryBearerMcpRequest, -} from "./mcp-handler"; + MAX_TOOL_INPUT_ELEMENTS, +} from "@sentry/toolkit-core/mcp-limits"; +import mcpHandler, { handleSentryBearerMcpRequest } from "./mcp-handler"; import { OAUTH_GRANT_AGE_BUCKET_ATTRIBUTE, OAUTH_GRANT_REVOKED_REASON_ATTRIBUTE, diff --git a/packages/mcp-cloudflare/src/server/lib/mcp-handler.ts b/packages/mcp-cloudflare/src/server/lib/mcp-handler.ts index 0fda0b80e..860420ffe 100644 --- a/packages/mcp-cloudflare/src/server/lib/mcp-handler.ts +++ b/packages/mcp-cloudflare/src/server/lib/mcp-handler.ts @@ -22,7 +22,10 @@ import { } from "@sentry/mcp-core/skills"; import { logWarn } from "@sentry/mcp-core/telem/logging"; import type { ServerContext } from "@sentry/mcp-core/types"; -import { boundMcpRequestBody } from "@sentry/toolkit-core/request-body"; +import { + MAX_MCP_REQUEST_BODY_BYTES, + boundMcpRequestBody, +} from "@sentry/toolkit-core/mcp-limits"; import { createMcpHandler } from "agents/mcp/server"; import { annotateResponseMetric } from "../metrics"; import { @@ -72,14 +75,6 @@ type SentryBearerMcpContext = { type AuthenticatedMcpContext = OAuthMcpContext | SentryBearerMcpContext; -/** - * Largest MCP POST body accepted over HTTP (GHSA-46jr-4wx8-v453). The SDK - * default is 4 MiB. The longest bounded tool text input is 4,096 characters and - * the rest are queries, slugs, and IDs, so 1 MiB covers every supported request - * with wide headroom while shrinking what one request can make the Worker parse. - */ -export const MAX_MCP_REQUEST_BODY_BYTES = 1024 * 1024; - function getSkillGrantedAttributeName(skill: string): string { return `app.consent.skill.${skill.replaceAll("-", "_")}.granted`; } @@ -469,10 +464,7 @@ async function handleAuthenticatedMcpRequest( // `createMcpHandler` from agents only forwards `maxRequestBodySize` to the // SDK v2 lane. Its legacy-request classification and legacy compatibility // lane still read up to the SDK's 4 MiB default, so bound every lane here. - const boundedRequest = await boundMcpRequestBody( - request, - MAX_MCP_REQUEST_BODY_BYTES, - ); + const boundedRequest = await boundMcpRequestBody(request); if (boundedRequest instanceof Response) { return boundedRequest; } diff --git a/packages/mcp-core/src/constants.ts b/packages/mcp-core/src/constants.ts index cda75fe49..85cef2495 100644 --- a/packages/mcp-core/src/constants.ts +++ b/packages/mcp-core/src/constants.ts @@ -9,15 +9,6 @@ */ export const MCP_SERVER_NAME = "Sentry MCP" as const; -/** - * Most array elements plus object keys accepted in one `tools/call` - * `arguments` payload (GHSA-46jr-4wx8-v453). Without a cap, a single HTTP - * request can exhaust the heap during input validation. The largest bounded - * tool inputs today hold ~100 elements, and realistic alert-rule payloads stay - * in the low hundreds, so this leaves wide headroom for legitimate calls. - */ -export const MAX_TOOL_INPUT_ELEMENTS = 10_000; - /** * Supported issue-search windows exposed by search_issues. * Intentionally bounded for query performance; lower-level API methods still accept arbitrary Sentry statsPeriod strings. diff --git a/packages/mcp-core/src/server.test.ts b/packages/mcp-core/src/server.test.ts index 359e2cba0..69667785e 100644 --- a/packages/mcp-core/src/server.test.ts +++ b/packages/mcp-core/src/server.test.ts @@ -10,7 +10,7 @@ import { import { HttpResponse, http } from "msw"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { z } from "zod"; -import { MAX_TOOL_INPUT_ELEMENTS } from "./constants"; +import { MAX_TOOL_INPUT_ELEMENTS } from "@sentry/toolkit-core/mcp-limits"; import { UserInputError } from "./errors"; import { structuredResult } from "./internal/tool-helpers/results"; import { buildServer } from "./server"; diff --git a/packages/mcp-core/src/server.ts b/packages/mcp-core/src/server.ts index faa7a7eaa..2c99ba6e3 100644 --- a/packages/mcp-core/src/server.ts +++ b/packages/mcp-core/src/server.ts @@ -30,7 +30,8 @@ import { } from "@sentry/core"; import { wrapMcpServerWithSentry } from "@sentry/core/server"; import { isApiAuthenticationErrorDeep } from "./api-client"; -import { MAX_TOOL_INPUT_ELEMENTS, MCP_SERVER_NAME } from "./constants"; +import { MAX_TOOL_INPUT_ELEMENTS } from "@sentry/toolkit-core/mcp-limits"; +import { MCP_SERVER_NAME } from "./constants"; import { formatErrorForUser, isExpectedToolError, diff --git a/packages/toolkit-core/package.json b/packages/toolkit-core/package.json index 43163b360..2465f3871 100644 --- a/packages/toolkit-core/package.json +++ b/packages/toolkit-core/package.json @@ -31,6 +31,10 @@ "types": "./src/metric-monitor.ts", "default": "./src/metric-monitor.ts" }, + "./mcp-limits": { + "types": "./src/mcp-limits.ts", + "default": "./src/mcp-limits.ts" + }, "./oauth-device": { "types": "./src/oauth-device.ts", "default": "./src/oauth-device.ts" @@ -43,10 +47,6 @@ "types": "./src/region-cache.ts", "default": "./src/region-cache.ts" }, - "./request-body": { - "types": "./src/request-body.ts", - "default": "./src/request-body.ts" - }, "./resource-identity": { "types": "./src/resource-identity.ts", "default": "./src/resource-identity.ts" diff --git a/packages/toolkit-core/src/mcp-limits.test.ts b/packages/toolkit-core/src/mcp-limits.test.ts new file mode 100644 index 000000000..3a4262b3e --- /dev/null +++ b/packages/toolkit-core/src/mcp-limits.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; +import { + MAX_MCP_REQUEST_BODY_BYTES, + boundMcpRequestBody, + readBoundedBody, +} from "./mcp-limits.js"; + +const CHUNK_BYTES = 64 * 1024; + +/** A streamed body of exactly `size` bytes of "a", sent in 64 KiB chunks. */ +function streamOf(size: number): ReadableStream { + let sent = 0; + return new ReadableStream({ + pull(controller) { + if (sent >= size) { + controller.close(); + return; + } + const chunk = new Uint8Array(Math.min(CHUNK_BYTES, size - sent)); + controller.enqueue(chunk.fill(0x61)); + sent += chunk.byteLength; + }, + }); +} + +function post(body: BodyInit | null, headers: Record = {}) { + return new Request("https://example.test/mcp", { + body, + // Node requires `duplex` for streamed request bodies. + duplex: "half", + headers, + method: "POST", + } as RequestInit); +} + +describe("readBoundedBody", () => { + it("returns a streamed body at the limit", async () => { + const body = await readBoundedBody( + post(streamOf(MAX_MCP_REQUEST_BODY_BYTES)), + ); + + expect(body?.byteLength).toBe(MAX_MCP_REQUEST_BODY_BYTES); + expect(body?.every((byte) => byte === 0x61)).toBe(true); + }); + + it("returns an empty body when the request has none", async () => { + await expect(readBoundedBody(post(null))).resolves.toEqual( + new Uint8Array(), + ); + }); + + it("rejects a declared Content-Length over the limit before reading", async () => { + const request = post(streamOf(1), { + "content-length": String(MAX_MCP_REQUEST_BODY_BYTES + 1), + }); + + await expect(readBoundedBody(request)).resolves.toBeUndefined(); + expect(request.bodyUsed).toBe(false); + }); + + it("rejects a streamed body over the limit without Content-Length", async () => { + const request = post(streamOf(MAX_MCP_REQUEST_BODY_BYTES + 1)); + expect(request.headers.has("content-length")).toBe(false); + + await expect(readBoundedBody(request)).resolves.toBeUndefined(); + }); + + it("rejects a body larger than its declared Content-Length", async () => { + const request = post(streamOf(MAX_MCP_REQUEST_BODY_BYTES + 1), { + "content-length": "1", + }); + + await expect(readBoundedBody(request)).resolves.toBeUndefined(); + }); +}); + +describe("boundMcpRequestBody", () => { + it("replays a body within the limit", async () => { + const bounded = await boundMcpRequestBody(post('{"id":1}')); + + expect(bounded).toBeInstanceOf(Request); + await expect((bounded as Request).text()).resolves.toBe('{"id":1}'); + }); + + it("returns the JSON-RPC 413 for an oversized body", async () => { + const bounded = await boundMcpRequestBody( + post(streamOf(MAX_MCP_REQUEST_BODY_BYTES + 1)), + ); + + expect(bounded).toBeInstanceOf(Response); + const response = bounded as Response; + expect(response.status).toBe(413); + await expect(response.json()).resolves.toEqual({ + error: { + code: -32000, + message: `Payload Too Large: Request body must not exceed ${MAX_MCP_REQUEST_BODY_BYTES} bytes`, + }, + id: null, + jsonrpc: "2.0", + }); + }); + + it("passes non-POST requests through unchanged", async () => { + const request = new Request("https://example.test/mcp", { method: "GET" }); + + await expect(boundMcpRequestBody(request)).resolves.toBe(request); + }); +}); diff --git a/packages/toolkit-core/src/request-body.ts b/packages/toolkit-core/src/mcp-limits.ts similarity index 50% rename from packages/toolkit-core/src/request-body.ts rename to packages/toolkit-core/src/mcp-limits.ts index 002eeb817..59ceaf4a6 100644 --- a/packages/toolkit-core/src/request-body.ts +++ b/packages/toolkit-core/src/mcp-limits.ts @@ -1,11 +1,28 @@ /** - * Request body limits shared by Toolkit's HTTP MCP servers - * (GHSA-46jr-4wx8-v453). Each server picks its own byte budget. + * Input limits shared by Toolkit's HTTP MCP servers (GHSA-46jr-4wx8-v453): + * the hosted Sentry MCP server and the Docs MCP Worker. */ /** - * Reads a request body, stopping as soon as it exceeds `maxBytes`. Returns - * `undefined` when the body is too large. + * Largest MCP POST body accepted over HTTP. The SDK default is 4 MiB. The + * longest bounded tool text input is 4,096 characters and the rest are + * queries, slugs, and IDs, so 1 MiB covers every supported request with wide + * headroom while shrinking what one request can make a Worker parse. + */ +export const MAX_MCP_REQUEST_BODY_BYTES = 1024 * 1024; + +/** + * Most array elements plus object keys accepted in one `tools/call` + * `arguments` payload. Without a cap, a single HTTP request can exhaust the + * heap during input validation. The largest bounded tool inputs today hold + * ~100 elements, and realistic alert-rule payloads stay in the low hundreds, + * so this leaves wide headroom for legitimate calls. + */ +export const MAX_TOOL_INPUT_ELEMENTS = 10_000; + +/** + * Reads a request body, stopping as soon as it exceeds + * MAX_MCP_REQUEST_BODY_BYTES. Returns `undefined` when the body is too large. * * A declared `Content-Length` over the limit is rejected before reading. * Streamed bytes are still counted, so bodies without `Content-Length`, or with @@ -13,9 +30,10 @@ */ export async function readBoundedBody( request: Request, - maxBytes: number, ): Promise | undefined> { - if (Number(request.headers.get("content-length")) > maxBytes) { + if ( + Number(request.headers.get("content-length")) > MAX_MCP_REQUEST_BODY_BYTES + ) { return undefined; } if (request.body === null) { @@ -31,7 +49,7 @@ export async function readBoundedBody( break; } received += value.byteLength; - if (received > maxBytes) { + if (received > MAX_MCP_REQUEST_BODY_BYTES) { await reader.cancel(); return undefined; } @@ -48,13 +66,13 @@ export async function readBoundedBody( } /** The JSON-RPC 413 the MCP SDK returns for an oversized request body. */ -export function mcpPayloadTooLargeResponse(maxBytes: number): Response { +export function mcpPayloadTooLargeResponse(): Response { return Response.json( { jsonrpc: "2.0", error: { code: -32000, - message: `Payload Too Large: Request body must not exceed ${maxBytes} bytes`, + message: `Payload Too Large: Request body must not exceed ${MAX_MCP_REQUEST_BODY_BYTES} bytes`, }, id: null, }, @@ -63,24 +81,23 @@ export function mcpPayloadTooLargeResponse(maxBytes: number): Response { } /** - * Buffers an MCP POST body up to `maxBytes` and returns a request that replays - * it, or the JSON-RPC 413. Other methods and bodiless requests pass through - * unchanged. + * Buffers an MCP POST body up to MAX_MCP_REQUEST_BODY_BYTES and returns a + * request that replays it, or the JSON-RPC 413. Other methods and bodiless + * requests pass through unchanged. * * Use this when the MCP transport parses the body itself and its own limit does * not cover every path. */ export async function boundMcpRequestBody( request: Request, - maxBytes: number, ): Promise { if (request.method !== "POST" || request.body === null) { return request; } - const body = await readBoundedBody(request, maxBytes); + const body = await readBoundedBody(request); if (!body) { - return mcpPayloadTooLargeResponse(maxBytes); + return mcpPayloadTooLargeResponse(); } return new Request(request, { method: "POST", body }); } diff --git a/packages/toolkit-core/src/request-body.test.ts b/packages/toolkit-core/src/request-body.test.ts deleted file mode 100644 index 39aa8edaf..000000000 --- a/packages/toolkit-core/src/request-body.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { boundMcpRequestBody, readBoundedBody } from "./request-body.js"; - -const LIMIT = 64; - -function streamOf(chunks: number[]): ReadableStream { - return new ReadableStream({ - start(controller) { - for (const size of chunks) { - controller.enqueue(new Uint8Array(size).fill(0x61)); - } - controller.close(); - }, - }); -} - -function post(body: BodyInit | null, headers: Record = {}) { - return new Request("https://example.test/mcp", { - body, - // Node requires `duplex` for streamed request bodies. - duplex: "half", - headers, - method: "POST", - } as RequestInit); -} - -describe("readBoundedBody", () => { - it("returns a body at the limit across chunks", async () => { - const body = await readBoundedBody(post(streamOf([40, 24])), LIMIT); - - expect(body).toEqual(new Uint8Array(LIMIT).fill(0x61)); - }); - - it("returns an empty body when the request has none", async () => { - const body = await readBoundedBody(post(null), LIMIT); - - expect(body).toEqual(new Uint8Array()); - }); - - it("rejects a declared Content-Length over the limit", async () => { - const request = post(streamOf([1]), { - "content-length": String(LIMIT + 1), - }); - - await expect(readBoundedBody(request, LIMIT)).resolves.toBeUndefined(); - }); - - it("rejects a streamed body over the limit without Content-Length", async () => { - const request = post(streamOf([40, 25])); - expect(request.headers.has("content-length")).toBe(false); - - await expect(readBoundedBody(request, LIMIT)).resolves.toBeUndefined(); - }); -}); - -describe("boundMcpRequestBody", () => { - it("replays a body within the limit", async () => { - const bounded = await boundMcpRequestBody(post('{"id":1}'), LIMIT); - - expect(bounded).toBeInstanceOf(Request); - await expect((bounded as Request).text()).resolves.toBe('{"id":1}'); - }); - - it("returns the JSON-RPC 413 for an oversized body", async () => { - const bounded = await boundMcpRequestBody( - post(streamOf([LIMIT + 1])), - LIMIT, - ); - - expect(bounded).toBeInstanceOf(Response); - const response = bounded as Response; - expect(response.status).toBe(413); - await expect(response.json()).resolves.toEqual({ - error: { - code: -32000, - message: `Payload Too Large: Request body must not exceed ${LIMIT} bytes`, - }, - id: null, - jsonrpc: "2.0", - }); - }); - - it("passes non-POST requests through unchanged", async () => { - const request = new Request("https://example.test/mcp", { method: "GET" }); - - await expect(boundMcpRequestBody(request, LIMIT)).resolves.toBe(request); - }); -});