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: 5 additions & 4 deletions server/plugins/sentry.ts
Original file line number Diff line number Diff line change
@@ -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) => {
Expand All @@ -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))
Expand Down
15 changes: 15 additions & 0 deletions shared/sentry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
49 changes: 48 additions & 1 deletion test/unit/sentry-cache-write-filter.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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(),
}))
Expand Down Expand Up @@ -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.' }] },
Expand All @@ -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)
})
})