History

feat(cloud): surface MCP/JSON-RPC protocol errors on mcp.request span

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.

Rhys Sullivan committed 6 months ago

Commit 2f827d81 parent 71860091 file · +91 −12Browse files

Unified · Split

apps/cloud/src/mcp.ts+91−12
@@ -315,6 +315,83 @@ const authorizationServerMetadata = Effect.promise(async () => { · 314 unmodified lines
315315 }
316316 });
317317
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+
318395 // ---------------------------------------------------------------------------
319396 // DO dispatch
320397 // ---------------------------------------------------------------------------
@@ -324,11 +401,13 @@ const authorizationServerMetadata = Effect.promise(async () => { · 3 unmodified lines
324401 * with `HttpServerResponse.raw` lets streaming bodies (SSE) pass through
325402 * `HttpApp.toWebHandler`'s conversion unchanged.
326403 */
327−const forwardToExistingSession = (request: Request, sessionId: string) =>
328− Effect.promise(async () => {
404+const forwardToExistingSession = (request: Request, sessionId: string, peek: boolean) =>
405+ Effect.gen(function* () {
329406 const ns = env.MCP_SESSION;
330407 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);
332411 });
333412
334413 const dispatchPost = (request: Request, token: VerifiedToken) =>
@@ -339,26 +418,26 @@ const dispatchPost = (request: Request, token: VerifiedToken) => · 4 unmodified lines
339418 }
340419
341420 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);
343422
344− return yield* Effect.promise(async () => {
345− const ns = env.MCP_SESSION;
346− const stub = ns.get(ns.newUniqueId());
347− await stub.init({ organizationId });
348− return HttpServerResponse.raw(await stub.handleRequest(request));
349− });
423+ const ns = env.MCP_SESSION;
424+ const stub = ns.get(ns.newUniqueId());
425+ yield* Effect.promise(() => stub.init({ organizationId }));
426+ const raw = yield* Effect.promise(() => stub.handleRequest(request) as Promise<Response>);
427+ const annotated = yield* peekAndAnnotate(raw);
428+ return HttpServerResponse.raw(annotated);
350429 });
351430
352431 const dispatchGet = (request: Request) => {
353432 const sessionId = request.headers.get("mcp-session-id");
354433 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);
356435 };
357436
358437 const dispatchDelete = (request: Request) => {
359438 const sessionId = request.headers.get("mcp-session-id");
360439 if (!sessionId) return Effect.succeed(HttpServerResponse.empty({ status: 204 }));
361− return forwardToExistingSession(request, sessionId);
440+ return forwardToExistingSession(request, sessionId, true);
362441 };
363442
364443 // ---------------------------------------------------------------------------