diff --git a/apps/docs-mcp/README.md b/apps/docs-mcp/README.md index bfb8b4dfa..5d4bfdc9b 100644 --- a/apps/docs-mcp/README.md +++ b/apps/docs-mcp/README.md @@ -32,6 +32,15 @@ limited to `frameworks`, `languages`, and `sentryConfigured`. Model failures and unverifiable answers retain the safe `DOCS_MODEL_UNAVAILABLE` and `DOCS_UNGROUNDED` error codes. +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 does not change that contract. Retiring Init Server would require a separate 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/__tests__/internal-api.test.ts b/apps/docs-mcp/src/__tests__/internal-api.test.ts index cfbb1ae86..7c22fd086 100644 --- a/apps/docs-mcp/src/__tests__/internal-api.test.ts +++ b/apps/docs-mcp/src/__tests__/internal-api.test.ts @@ -10,6 +10,15 @@ vi.mock("../internal-api", async (importOriginal) => ({ })); import { app } from "../index"; +import { MAX_MCP_REQUEST_BODY_BYTES } from "@sentry/toolkit-core/mcp-limits"; + +function docsRequest(body: string) { + return new Request("https://docs-mcp.internal/internal/docs", { + body, + headers: { "content-type": "application/json" }, + method: "POST", + }); +} describe("private docs API", () => { beforeEach(() => { @@ -104,4 +113,26 @@ describe("private docs API", () => { error: "Docs API request failed", }); }); + // 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("a".repeat(MAX_MCP_REQUEST_BODY_BYTES + 1)), + ); + + expect(response.status).toBe(413); + await expect(response.json()).resolves.toEqual({ + error: `Request body must not exceed ${MAX_MCP_REQUEST_BODY_BYTES} bytes`, + }); + 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..e975c8ba7 100644 --- a/apps/docs-mcp/src/__tests__/mcp.test.ts +++ b/apps/docs-mcp/src/__tests__/mcp.test.ts @@ -2,22 +2,47 @@ 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_MCP_REQUEST_BODY_BYTES, + MAX_TOOL_INPUT_ELEMENTS, + mcpPayloadTooLargeResponse, +} from "@sentry/toolkit-core/mcp-limits"; + +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", + }), + ), + ); +} 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 +73,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 +107,57 @@ describe("private MCP transport", () => { error: "Docs MCP is only available through its service binding", }); }); + // 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(); + + 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("rejects an oversized body before the MCP transport reads it", async () => { + const response = await app.request( + "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( + await mcpPayloadTooLargeResponse().json(), + ); + }); }); diff --git a/apps/docs-mcp/src/index.ts b/apps/docs-mcp/src/index.ts index ec2ffd94e..d47dc342a 100644 --- a/apps/docs-mcp/src/index.ts +++ b/apps/docs-mcp/src/index.ts @@ -4,6 +4,11 @@ */ import { StreamableHTTPTransport } from "@hono/mcp"; import * as Sentry from "@sentry/cloudflare"; +import { + MAX_MCP_REQUEST_BODY_BYTES, + boundMcpRequestBody, + readBoundedBody, +} from "@sentry/toolkit-core/mcp-limits"; import { Hono } from "hono"; import { cors } from "hono/cors"; import { isDocsMcpServiceRequest } from "./access"; @@ -17,6 +22,8 @@ import { createMcpServer } from "./mcp"; import { trackToolCall, trackToolDuration } from "./metrics"; import type { Env } from "./types"; +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()); app.get("/health", (c) => c.json({ status: "ok" })); @@ -29,6 +36,14 @@ 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); + if (bounded instanceof Response) { + return bounded; + } + c.req.raw = bounded; + const transport = new StreamableHTTPTransport(); const mcp = createMcpServer( c.env.OPENROUTER_API_KEY, @@ -48,7 +63,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..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"; @@ -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/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/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.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 fef06e14b..860420ffe 100644 --- a/packages/mcp-cloudflare/src/server/lib/mcp-handler.ts +++ b/packages/mcp-cloudflare/src/server/lib/mcp-handler.ts @@ -22,6 +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 { + MAX_MCP_REQUEST_BODY_BYTES, + boundMcpRequestBody, +} from "@sentry/toolkit-core/mcp-limits"; import { createMcpHandler } from "agents/mcp/server"; import { annotateResponseMetric } from "../metrics"; import { @@ -71,65 +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 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 +461,10 @@ 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); 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 063aece6a..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" 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/mcp-limits.ts b/packages/toolkit-core/src/mcp-limits.ts new file mode 100644 index 000000000..59ceaf4a6 --- /dev/null +++ b/packages/toolkit-core/src/mcp-limits.ts @@ -0,0 +1,103 @@ +/** + * Input limits shared by Toolkit's HTTP MCP servers (GHSA-46jr-4wx8-v453): + * the hosted Sentry MCP server and the Docs MCP Worker. + */ + +/** + * 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 + * a wrong one, are bounded too. + */ +export async function readBoundedBody( + request: Request, +): Promise | undefined> { + if ( + Number(request.headers.get("content-length")) > MAX_MCP_REQUEST_BODY_BYTES + ) { + 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 > MAX_MCP_REQUEST_BODY_BYTES) { + 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(): 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 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, +): Promise { + if (request.method !== "POST" || request.body === null) { + return request; + } + + const body = await readBoundedBody(request); + if (!body) { + return mcpPayloadTooLargeResponse(); + } + 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