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
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
334type JsonRpcErrorBody = {
335 readonly jsonrpc?: string;
336 readonly error?: { readonly code?: number; readonly message?: string };
337 readonly result?: { readonly isError?: boolean };
338};
339
340const 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
359const 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
379const 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 dispatch396// 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 through401 * with `HttpServerResponse.raw` lets streaming bodies (SSE) pass through
325 * `HttpApp.toWebHandler`'s conversion unchanged.402 * `HttpApp.toWebHandler`'s conversion unchanged.
326 */403 */
327const forwardToExistingSession = (request: Request, sessionId: string) =>404const 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
334const dispatchPost = (request: Request, token: VerifiedToken) =>413const 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
352const dispatchGet = (request: Request) => {431const 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
358const dispatchDelete = (request: Request) => {437const 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// ---------------------------------------------------------------------------