diff --git a/TELEMETRY.md b/TELEMETRY.md index 61675e6..f92826c 100644 --- a/TELEMETRY.md +++ b/TELEMETRY.md @@ -133,6 +133,13 @@ Rates: that failure either as a wrapped `Parse error` message or, since MCP SDK 2.1, as the raw `SyntaxError` from `JSON.parse`, and both forms are dropped. Both HTTP responses are still counted by `app.server.response`. Other error events are retained. +- **`createMcpHandler` reports through its own `onerror`**, separate from the transport + hook above. It passes client rejections (unsupported protocol revision, failed header or + envelope checks, wrong content type) together with real faults. `isMcpClientRejection` + skips the rejections, which already answer 4xx. Everything else is captured: faults the + handler answers with a 500, and `subscriptions/listen` calls refused at the subscription + limit, which answer HTTP 200 with a JSON-RPC error and so are not visible in the + response metric. ## Privacy diff --git a/__tests__/telemetry.test.ts b/__tests__/telemetry.test.ts index 3e21179..c5bf8de 100644 --- a/__tests__/telemetry.test.ts +++ b/__tests__/telemetry.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from "vitest"; +import { UnsupportedProtocolVersionError } from "@modelcontextprotocol/server"; import { classifyMcpMethod, classifyMcpRequest, @@ -7,6 +8,7 @@ import { rootSampleRate, statusClass, errorDropReason, + isMcpClientRejection, HEARTBEAT_SPAN_KEEP_RATE, type ErrorEventLike, } from "../src/telemetry.js"; @@ -131,6 +133,25 @@ describe("resolveClientFamily", () => { }); }); +describe("isMcpClientRejection", () => { + it.each([ + new UnsupportedProtocolVersionError({ supported: ["2026-07-28"], requested: "1999-01-01" }), + new Error("Rejected inbound request (H3): MCP-Method header does not match the body"), + new Error("Rejected 2025-era request on a modern-only endpoint (L1): unsupported"), + new Error("Unsupported Media Type: Content-Type must be application/json"), + ])("skips the client rejection %s", (error) => { + expect(isMcpClientRejection(error)).toBe(true); + }); + + it.each([ + new Error("subscriptions/listen refused: subscription limit reached (1024)"), + new Error("Response for request ID 7 is undeliverable: per-request stream is disconnected"), + new TypeError("Cannot read properties of undefined"), + ])("captures the server fault %s", (error) => { + expect(isMcpClientRejection(error)).toBe(false); + }); +}); + describe("errorDropReason", () => { it("drops the expected MCP 406 raised for a GET without SSE support", () => { const event: ErrorEventLike = { diff --git a/src/telemetry.ts b/src/telemetry.ts index 26c558e..3a2ffbd 100644 --- a/src/telemetry.ts +++ b/src/telemetry.ts @@ -19,6 +19,8 @@ * unit-tested like redaction.ts; worker.ts owns the Sentry-specific wiring. */ +import { UnsupportedProtocolVersionError } from "@modelcontextprotocol/server"; + export type RouteGroup = "mcp" | "internal"; export type McpRequestKind = "heartbeat" | "tool_call" | "control" | "unknown"; @@ -244,6 +246,21 @@ export function errorDropReason(event: ErrorEventLike): string | null { return null; } +/** + * True for the client rejections that `createMcpHandler` passes to its `onerror` next to + * real faults: an unsupported protocol revision, a failed header or envelope check, or a + * wrong content type. The handler already answers each with a 4xx that + * `app.server.response` counts. Faults it answers with a 500, and refused + * `subscriptions/listen` calls, which it answers with HTTP 200, fall through to capture. + */ +export function isMcpClientRejection(error: Error): boolean { + return ( + error instanceof UnsupportedProtocolVersionError || + error.message.startsWith("Rejected ") || + error.message.startsWith("Unsupported Media Type") + ); +} + // --- Root span sampling (tracesSampler) --------------------------------------- /** diff --git a/src/worker.ts b/src/worker.ts index 4fa2ada..2f92ea6 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -19,6 +19,7 @@ import { classifyRoute, errorDropReason, isKnownMcpMethod, + isMcpClientRejection, resolveClientFamily, rootSampleRate, statusClass, @@ -151,7 +152,12 @@ const workerMcpHandler = createMcpHandler( enableFeedbackTool: true, }); }, - { legacy: "stateless" }, + { + legacy: "stateless", + onerror: (error) => { + if (!isMcpClientRejection(error)) Sentry.captureException(error); + }, + }, ); /** Best-effort pathname for a request URL, or null if it doesn't parse. */