Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions apps/docs-mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions apps/docs-mcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
31 changes: 31 additions & 0 deletions apps/docs-mcp/src/__tests__/internal-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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",
});
});
});
124 changes: 91 additions & 33 deletions apps/docs-mcp/src/__tests__/mcp.test.ts
Comment thread
sentry-junior[bot] marked this conversation as resolved.
Resolved
Original file line number Diff line number Diff line change
Expand Up @@ -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<Client> {
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([
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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(),
);
});
});
27 changes: 26 additions & 1 deletion apps/docs-mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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" }));
Expand All @@ -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,
Expand All @@ -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);
Expand Down
16 changes: 10 additions & 6 deletions apps/docs-mcp/src/mcp.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 },
),
Comment on lines +153 to +161

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High severity vulnerability may affect your project—review required:
Line 153 lists a dependency (@modelcontextprotocol/server) with a known High severity vulnerability.

ℹ️ Why this matters

Affected versions of @modelcontextprotocol/sdk and @modelcontextprotocol/server are vulnerable to Allocation of Resources Without Limits or Throttling. McpServer places no limit on how many array elements and object keys it validates in tool-call arguments. A single unauthenticated HTTP request within the default 4 MiB body limit can exhaust the Node.js heap during validation and crash the server process (denial of service). After upgrading, also set the opt-in maxToolInputElements McpServer option and lower maxRequestBodySize.

References: GHSA

To resolve this comment:
Check if you expose the McpServer over an HTTP transport (Streamable HTTP, SSE, or createMcpHandler) rather than stdio only and it registers tools that accept input, especially arrays or records.

  • If you're affected, upgrade this dependency to at least version 2.3.0 at pnpm-lock.yaml.
  • If you're not affected, comment /fp we don't use this [condition]
💬 Ignore this finding

To ignore this, reply with:

  • /fp <comment> for false positive
  • /ar <comment> for acceptable risk
  • /other <comment> for all other reasons

You can view more details on this finding in the Semgrep AppSec Platform here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't apply to the Docs MCP Worker. The McpServer here comes from @modelcontextprotocol/sdk, which resolves to 1.32.2 through the workspace override, and this PR sets maxToolInputElements on it and caps request bodies at 64 KiB.

The @modelcontextprotocol/server@2.0.0 lockfile entry comes in through @mastra/core, which pins it to exactly 2.0.0. Mastra only imports it for types (dist/tools/types.d.ts), and none of its runtime JS references it. I bundled the Worker with wrangler deploy --dry-run --outdir to check: the only @modelcontextprotocol package in the bundle's source map is sdk@1.32.2. Upgrading the v2 server package wouldn't change what ships.

Leaving this thread open so a human can triage the finding in Semgrep.

{
recordInputs: true,
recordOutputs: true,
Expand Down
2 changes: 1 addition & 1 deletion docs/cloudflare/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions packages/mcp-cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:",
Expand Down
8 changes: 4 additions & 4 deletions packages/mcp-cloudflare/src/server/lib/mcp-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading