MCP errors come in four flavours and three of them were invisible on the dashboard — HTTP 200 the whole way down: - Malformed JSON-RPC envelope - Tool not found (JSON-RPC `-32601`) - Invalid params (JSON-RPC `-32602`) - Tool handler returned `{ isError: true }` content The first three are "model fucked up" signals we want to alert on; the fourth is noisier but still useful. None of them raise at the HTTP layer or as an Effect failure, so neither the HTTP status nor the Effect span status caught them. Peek the DO's response body at the edge (`mcp.ts` dispatch path), parse the first JSON-RPC message out of either the SSE frame or the JSON envelope, and stamp onto the `mcp.request` span: - `mcp.rpc.is_error` — set when `jsonrpc: "2.0"` payload carries a structured `error` object - `mcp.rpc.error.code` — the JSON-RPC error code (-32601 / -32602 / -32603 / -32001 session-timeout / etc.) - `mcp.rpc.error.message` — truncated to 500 chars - `mcp.tool.result.is_error` — separately, for tool-content errors Only peeks POST/DELETE responses; GET is a long-lived SSE channel we don't want to consume. Requires `jsonrpc: "2.0"` to avoid false-positives on the edge's own auth-failure body (`{ error: "unauthorized" }` — not a JSON-RPC error). Verified live: after deploy, the `-32001 "Session timed out"` error the DO returns on unknown-session reconnects shows up in Axiom with all four attrs populated. Dashboard panels filtering on `mcp.rpc.is_error` + `mcp.rpc.error.code` follow in the next commit.
Unified · Split
| @@ -315,6 +315,83 @@ const authorizationServerMetadata = Effect.promise(async () => { · 314 unmodified lines | |||
| 315 | } | 315 | } |
| 316 | }); | 316 | }); |
| 317 | 317 | ||
| 318 | // --------------------------------------------------------------------------- | ||
| 319 | // Response-body peek for JSON-RPC error attrs | ||
| 320 | // --------------------------------------------------------------------------- | ||
| 321 | // | ||
| 322 | // The MCP protocol wraps protocol-level failures (malformed envelope, method | ||
| 323 | // not found, invalid params) as `{ error: { code, message } }` in the | ||
| 324 | // JSON-RPC response body with HTTP 200 — none of which surface at the HTTP | ||
| 325 | // layer or as an Effect failure. Same for tool results carrying | ||
| 326 | // `isError: true`. To make those visible in Axiom we peek the first | ||
| 327 | // JSON-RPC message out of the DO's response and stamp it onto the | ||
| 328 | // surrounding `mcp.request` span. | ||
| 329 | // | ||
| 330 | // Only applied to non-streaming response shapes (POST/DELETE). GET hops | ||
| 331 | // onto a long-lived SSE channel we don't want to consume. | ||
| 332 | // --------------------------------------------------------------------------- | ||
| 333 | |||
| 334 | type JsonRpcErrorBody = { | ||
| 335 | readonly jsonrpc?: string; | ||
| 336 | readonly error?: { readonly code?: number; readonly message?: string }; | ||
| 337 | readonly result?: { readonly isError?: boolean }; | ||
| 338 | }; | ||
| 339 | |||
| 340 | const parseFirstJsonRpc = (contentType: string, body: string): JsonRpcErrorBody | null => { | ||
| 341 | if (!body) return null; | ||
| 342 | try { | ||
| 343 | if (contentType.includes("text/event-stream")) { | ||
| 344 | // Grab the first `data:` line from the first SSE event. | ||
| 345 | for (const line of body.split(/\r?\n/)) { | ||
| 346 | if (line.startsWith("data:")) return JSON.parse(line.slice(5).trimStart()); | ||
| 347 | } | ||
| 348 | return null; | ||
| 349 | } | ||
| 350 | if (contentType.includes("application/json")) { | ||
| 351 | return JSON.parse(body); | ||
| 352 | } | ||
| 353 | return null; | ||
| 354 | } catch { | ||
| 355 | return null; | ||
| 356 | } | ||
| 357 | }; | ||
| 358 | |||
| 359 | const rpcResponseAttrs = (payload: JsonRpcErrorBody | null): Record<string, unknown> => { | ||
| 360 | // Require a JSON-RPC 2.0 envelope so we don't false-positive on other | ||
| 361 | // JSON shapes the edge happens to return (e.g. the auth-failure body | ||
| 362 | // `{ "error": "unauthorized" }` — not a JSON-RPC error). | ||
| 363 | if (!payload || payload.jsonrpc !== "2.0") return {}; | ||
| 364 | const attrs: Record<string, unknown> = {}; | ||
| 365 | const err = payload.error; | ||
| 366 | if (err && typeof err === "object") { | ||
| 367 | attrs["mcp.rpc.is_error"] = true; | ||
| 368 | if (typeof err.code === "number") attrs["mcp.rpc.error.code"] = err.code; | ||
| 369 | if (typeof err.message === "string") { | ||
| 370 | attrs["mcp.rpc.error.message"] = err.message.slice(0, 500); | ||
| 371 | } | ||
| 372 | } | ||
| 373 | if (payload.result?.isError === true) { | ||
| 374 | attrs["mcp.tool.result.is_error"] = true; | ||
| 375 | } | ||
| 376 | return attrs; | ||
| 377 | }; | ||
| 378 | |||
| 379 | const peekAndAnnotate = (response: Response): Effect.Effect<Response> => | ||
| 380 | Effect.gen(function* () { | ||
| 381 | if (!response.body) return response; | ||
| 382 | const text = yield* Effect.promise(() => response.text()); | ||
| 383 | const payload = parseFirstJsonRpc(response.headers.get("content-type") ?? "", text); | ||
| 384 | const attrs = rpcResponseAttrs(payload); | ||
| 385 | if (Object.keys(attrs).length > 0) { | ||
| 386 | yield* Effect.annotateCurrentSpan(attrs); | ||
| 387 | } | ||
| 388 | return new Response(text, { | ||
| 389 | status: response.status, | ||
| 390 | statusText: response.statusText, | ||
| 391 | headers: response.headers, | ||
| 392 | }); | ||
| 393 | }); | ||
| 394 | |||
| 318 | // --------------------------------------------------------------------------- | 395 | // --------------------------------------------------------------------------- |
| 319 | // DO dispatch | 396 | // DO dispatch |
| 320 | // --------------------------------------------------------------------------- | 397 | // --------------------------------------------------------------------------- |
| @@ -324,11 +401,13 @@ const authorizationServerMetadata = Effect.promise(async () => { · 3 unmodified lines | |||
| 324 | * with `HttpServerResponse.raw` lets streaming bodies (SSE) pass through | 401 | * with `HttpServerResponse.raw` lets streaming bodies (SSE) pass through |
| 325 | * `HttpApp.toWebHandler`'s conversion unchanged. | 402 | * `HttpApp.toWebHandler`'s conversion unchanged. |
| 326 | */ | 403 | */ |
| 327 | const forwardToExistingSession = (request: Request, sessionId: string) => | 404 | const forwardToExistingSession = (request: Request, sessionId: string, peek: boolean) => |
| 328 | Effect.promise(async () => { | 405 | Effect.gen(function* () { |
| 329 | const ns = env.MCP_SESSION; | 406 | const ns = env.MCP_SESSION; |
| 330 | const stub = ns.get(ns.idFromString(sessionId)); | 407 | const stub = ns.get(ns.idFromString(sessionId)); |
| 331 | return HttpServerResponse.raw(await stub.handleRequest(request)); | 408 | const raw = yield* Effect.promise(() => stub.handleRequest(request) as Promise<Response>); |
| 409 | const annotated = peek ? yield* peekAndAnnotate(raw) : raw; | ||
| 410 | return HttpServerResponse.raw(annotated); | ||
| 332 | }); | 411 | }); |
| 333 | 412 | ||
| 334 | const dispatchPost = (request: Request, token: VerifiedToken) => | 413 | const dispatchPost = (request: Request, token: VerifiedToken) => |
| @@ -339,26 +418,26 @@ const dispatchPost = (request: Request, token: VerifiedToken) => · 4 unmodified lines | |||
| 339 | } | 418 | } |
| 340 | 419 | ||
| 341 | const sessionId = request.headers.get("mcp-session-id"); | 420 | const sessionId = request.headers.get("mcp-session-id"); |
| 342 | if (sessionId) return yield* forwardToExistingSession(request, sessionId); | 421 | if (sessionId) return yield* forwardToExistingSession(request, sessionId, true); |
| 343 | 422 | ||
| 344 | return yield* Effect.promise(async () => { | 423 | const ns = env.MCP_SESSION; |
| 345 | const ns = env.MCP_SESSION; | 424 | const stub = ns.get(ns.newUniqueId()); |
| 346 | const stub = ns.get(ns.newUniqueId()); | 425 | yield* Effect.promise(() => stub.init({ organizationId })); |
| 347 | await stub.init({ organizationId }); | 426 | const raw = yield* Effect.promise(() => stub.handleRequest(request) as Promise<Response>); |
| 348 | return HttpServerResponse.raw(await stub.handleRequest(request)); | 427 | const annotated = yield* peekAndAnnotate(raw); |
| 349 | }); | 428 | return HttpServerResponse.raw(annotated); |
| 350 | }); | 429 | }); |
| 351 | 430 | ||
| 352 | const dispatchGet = (request: Request) => { | 431 | const dispatchGet = (request: Request) => { |
| 353 | const sessionId = request.headers.get("mcp-session-id"); | 432 | const sessionId = request.headers.get("mcp-session-id"); |
| 354 | if (!sessionId) return Effect.succeed(jsonRpcError(400, -32000, "mcp-session-id header required for SSE")); | 433 | if (!sessionId) return Effect.succeed(jsonRpcError(400, -32000, "mcp-session-id header required for SSE")); |
| 355 | return forwardToExistingSession(request, sessionId); | 434 | return forwardToExistingSession(request, sessionId, false); |
| 356 | }; | 435 | }; |
| 357 | 436 | ||
| 358 | const dispatchDelete = (request: Request) => { | 437 | const dispatchDelete = (request: Request) => { |
| 359 | const sessionId = request.headers.get("mcp-session-id"); | 438 | const sessionId = request.headers.get("mcp-session-id"); |
| 360 | if (!sessionId) return Effect.succeed(HttpServerResponse.empty({ status: 204 })); | 439 | if (!sessionId) return Effect.succeed(HttpServerResponse.empty({ status: 204 })); |
| 361 | return forwardToExistingSession(request, sessionId); | 440 | return forwardToExistingSession(request, sessionId, true); |
| 362 | }; | 441 | }; |
| 363 | 442 | ||
| 364 | // --------------------------------------------------------------------------- | 443 | // --------------------------------------------------------------------------- |