From 8b2b300ad0402ba86f8986d72c210e1003d6579f Mon Sep 17 00:00:00 2001 From: Harlan GitHub Agent Date: Fri, 2 Oct 2026 08:16:15 +1000 Subject: [PATCH] fix(sentry): drop the MCP probe 406 from beforeSend --- server/plugins/sentry.ts | 9 ++-- shared/sentry.ts | 15 +++++++ test/unit/sentry-cache-write-filter.test.ts | 49 ++++++++++++++++++++- 3 files changed, 68 insertions(+), 5 deletions(-) diff --git a/server/plugins/sentry.ts b/server/plugins/sentry.ts index 44cd8ce9..92a756f6 100644 --- a/server/plugins/sentry.ts +++ b/server/plugins/sentry.ts @@ -1,10 +1,10 @@ import { createSentryInitOptions } from '@harlan-zw/nuxt-sentry/server' import { SDK_VERSION } from '@sentry/cloudflare' import { sentryCloudflareNitroPlugin } from '@sentry/nuxt/module/plugins' -import { isBestEffortCacheWriteError, isExpectedUpstreamOutageError, scrubSentryBreadcrumb, scrubSentryEvent } from '../../shared/sentry' +import { isBestEffortCacheWriteError, isExpectedUpstreamOutageError, isMcpAcceptProbeError, scrubSentryBreadcrumb, scrubSentryEvent } from '../../shared/sentry' function isDroppedSignature(error: unknown): boolean { - return isBestEffortCacheWriteError(error) || isExpectedUpstreamOutageError(error) + return isBestEffortCacheWriteError(error) || isExpectedUpstreamOutageError(error) || isMcpAcceptProbeError(error) } export default defineNitroPlugin((nitroApp) => { @@ -28,8 +28,9 @@ export default defineNitroPlugin((nitroApp) => { // Nitro's route cache catches its own KV write failures and forwards the // caught error here as unhandled (SKILLD-17). The registry handlers' // intended upstream-outage 503s land here the same way (SKILLD-11, - // SKILLD-1E). Both are failure classes already recorded as wide events, - // so drop the duplicates and keep the wide events as the visibility path. + // SKILLD-1E), as does the MCP transport's intended probe 406 (SKILLD-35). + // All are failure classes already answered or recorded, so drop the + // duplicates and keep the wide events as the visibility path. beforeSend(event, hint) { const values = event.exception?.values ?? [] if (isDroppedSignature(hint.originalException) || values.some(isDroppedSignature)) diff --git a/shared/sentry.ts b/shared/sentry.ts index c2f1f43c..06594d9b 100644 --- a/shared/sentry.ts +++ b/shared/sentry.ts @@ -108,6 +108,21 @@ export function isClosedBroadcastChannelError(error: unknown): boolean { return message === 'Failed to execute \'postMessage\' on \'BroadcastChannel\': Channel is closed' } +/** + * The message prefix the MCP streamable transport rejects a probe with + * ('Not Acceptable: Client must accept text/event-stream' for an Accept-less + * GET, and the same prefix for a POST that misses one of the two accepted + * types, Sentry SKILLD-35). The transport answers the probe with an intended + * 406, then Nitro forwards its thrown error to Sentry as unhandled, so every + * bot probe would archive a duplicate of a response the client already got. + * The server Sentry config drops exactly that prefix in `beforeSend`; every + * other error still reaches Sentry. + */ +export function isMcpAcceptProbeError(error: unknown): boolean { + const message = sentryExceptionMessage(error) + return typeof message === 'string' && message.startsWith('Not Acceptable: Client must accept') +} + /** * A URL without its query string or fragment. Query strings carry OAuth codes, * signed tokens, and search terms, so no URL reaches Sentry with one. diff --git a/test/unit/sentry-cache-write-filter.test.ts b/test/unit/sentry-cache-write-filter.test.ts index 86d3197b..775343a4 100644 --- a/test/unit/sentry-cache-write-filter.test.ts +++ b/test/unit/sentry-cache-write-filter.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { isBestEffortCacheWriteError, isClosedBroadcastChannelError, isExpectedUpstreamOutageError } from '../../shared/sentry' +import { isBestEffortCacheWriteError, isClosedBroadcastChannelError, isExpectedUpstreamOutageError, isMcpAcceptProbeError } from '../../shared/sentry' describe('isClosedBroadcastChannelError', () => { const closed = 'Failed to execute \'postMessage\' on \'BroadcastChannel\': Channel is closed' @@ -82,6 +82,34 @@ describe('isExpectedUpstreamOutageError', () => { }) }) +describe('isMcpAcceptProbeError', () => { + it('matches the SKILLD-35 Accept-less GET probe the transport rejects', () => { + expect(isMcpAcceptProbeError(new Error('Not Acceptable: Client must accept text/event-stream'))).toBe(true) + }) + + it('matches the POST probe that misses one of the two accepted types', () => { + expect(isMcpAcceptProbeError(new Error('Not Acceptable: Client must accept both application/json and text/event-stream'))).toBe(true) + }) + + it('matches the Sentry-serialized exception, whose message arrives as value', () => { + expect(isMcpAcceptProbeError({ + type: 'Error', + value: 'Not Acceptable: Client must accept text/event-stream', + mechanism: { handled: false, type: 'auto.function.nuxt.nitro' }, + })).toBe(true) + }) + + it('ignores the page 406, whose message names the page content types instead', () => { + expect(isMcpAcceptProbeError(new Error('Supported types: text/html, text/markdown, text/plain'))).toBe(false) + }) + + it('ignores unrelated errors and values that are not errors', () => { + expect(isMcpAcceptProbeError(new Error('KV PUT failed: 429 Too Many Requests'))).toBe(false) + expect(isMcpAcceptProbeError('Not Acceptable: Client must accept text/event-stream')).toBe(false) + expect(isMcpAcceptProbeError(undefined)).toBe(false) + }) +}) + const { sentryCloudflareNitroPlugin } = vi.hoisted(() => ({ sentryCloudflareNitroPlugin: vi.fn(), })) @@ -138,6 +166,21 @@ describe('sentry beforeSend cache-write filter', () => { expect(beforeSend(event, {})).toBeNull() }) + it('drops the intended MCP 406 the transport already answered to the probe', () => { + const event: SentryEventFixture = { + exception: { values: [{ type: 'Error', value: 'Not Acceptable: Client must accept text/event-stream' }] }, + } + expect(beforeSend(event, { originalException: new Error('Not Acceptable: Client must accept text/event-stream') })) + .toBeNull() + }) + + it('drops the MCP probe on the serialized exception alone, when the original error is absent', () => { + const event: SentryEventFixture = { + exception: { values: [{ type: 'Error', value: 'Not Acceptable: Client must accept text/event-stream' }] }, + } + expect(beforeSend(event, {})).toBeNull() + }) + it('keeps every other event', () => { const event: SentryEventFixture = { exception: { values: [{ type: 'Error', value: 'D1_ERROR: D1 DB is overloaded.' }] }, @@ -148,5 +191,9 @@ describe('sentry beforeSend cache-write filter', () => { exception: { values: [{ type: 'Error', value: 'Skill source is gone upstream' }] }, } expect(beforeSend(goneEvent, {})).toBe(goneEvent) + const page406Event: SentryEventFixture = { + exception: { values: [{ type: 'Error', value: 'Supported types: text/html, text/markdown, text/plain' }] }, + } + expect(beforeSend(page406Event, {})).toBe(page406Event) }) })