From 2a69caf2331acf2f875c1e86fb16e99da444fdee Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Wed, 28 Oct 2026 14:14:47 -0700 Subject: [PATCH] fix(telemetry): Report createMcpHandler errors from the Worker The Worker built its MCP handler without an onerror callback, so faults the handler catches itself never reached Sentry. Two cases had no signal at all: failures it answers with a 500 from inside the handler, and subscriptions/listen calls refused at the 1,024-stream limit, which answer HTTP 200 with a JSON-RPC error and so look like success in the app.server.response metric. The callback captures everything except the client rejections the handler also routes there (unsupported protocol revision, failed header or envelope checks, wrong content type), which already answer 4xx. Co-authored-by: Claude --- TELEMETRY.md | 7 +++++++ __tests__/telemetry.test.ts | 21 +++++++++++++++++++++ src/telemetry.ts | 17 +++++++++++++++++ src/worker.ts | 8 +++++++- 4 files changed, 52 insertions(+), 1 deletion(-) 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. */