From 14cdf2ec0b4dbebed9c3dda2a21365782003e1a1 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Tani Date: Thu, 6 Aug 2026 17:17:48 -0300 Subject: [PATCH 1/2] Relay upstream JSON-RPC error bodies through clerk mcp run --- .changeset/mcp-run-relay-error-bodies.md | 5 ++ packages/cli-core/src/commands/mcp/README.md | 9 +++ .../cli-core/src/commands/mcp/run.test.ts | 60 ++++++++++++++++++- packages/cli-core/src/commands/mcp/run.ts | 32 ++++++++++ 4 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 .changeset/mcp-run-relay-error-bodies.md diff --git a/.changeset/mcp-run-relay-error-bodies.md b/.changeset/mcp-run-relay-error-bodies.md new file mode 100644 index 00000000..a3c739b1 --- /dev/null +++ b/.changeset/mcp-run-relay-error-bodies.md @@ -0,0 +1,5 @@ +--- +"clerk": patch +--- + +`clerk mcp run` now relays a structured JSON-RPC error from the upstream server verbatim instead of collapsing it into a generic -32000 error, so a client can see reserved codes like `HeaderMismatch` (-32020) and `UnsupportedProtocolVersion` (-32022) and drive the 2026-07-28 negotiation-retry flow. diff --git a/packages/cli-core/src/commands/mcp/README.md b/packages/cli-core/src/commands/mcp/README.md index cc79872b..bd366d49 100644 --- a/packages/cli-core/src/commands/mcp/README.md +++ b/packages/cli-core/src/commands/mcp/README.md @@ -182,6 +182,15 @@ once a session id exists, the same status is instead answered per-request as a JSON-RPC error (`-32001`, "requires authentication") and the bridge keeps running. +Any other non-2xx response is relayed to the client as-is when its body is a +well-formed JSON-RPC error (`jsonrpc: "2.0"`, an `id`, and an `error.code`) — +this is what lets the MCP-reserved codes (`-32020` `HeaderMismatch`, `-32021` +`MissingRequiredClientCapability`, `-32022` `UnsupportedProtocolVersion`) and +their `data.supported` payload reach the client so it can drive the +2026-07-28 negotiation-retry flow. A body that isn't valid JSON, or JSON that +isn't a JSON-RPC error, falls back to a generic `-32000` ("Upstream returned +HTTP ``."). + ### `clerk mcp uninstall` Remove the entry. For CLI-registered clients (claude, gemini, codex, openclaw, diff --git a/packages/cli-core/src/commands/mcp/run.test.ts b/packages/cli-core/src/commands/mcp/run.test.ts index 71c3c71c..0c90e07d 100644 --- a/packages/cli-core/src/commands/mcp/run.test.ts +++ b/packages/cli-core/src/commands/mcp/run.test.ts @@ -37,9 +37,9 @@ function stub(handler: (req: Recorded, postIndex: number) => Response): void { }); } -function json(payload: unknown, headers: Record = {}): Response { +function json(payload: unknown, headers: Record = {}, status = 200): Response { return new Response(JSON.stringify(payload), { - status: 200, + status, headers: { "content-type": "application/json", ...headers }, }); } @@ -393,6 +393,62 @@ describe("mcp run (stdio bridge)", () => { expect(out.join("")).toBe(""); }); + test("relays a structured JSON-RPC error body from a 400 upstream verbatim", async () => { + const upstreamError = { + jsonrpc: "2.0", + id: 1, + error: { + code: -32022, + message: "Unsupported protocol version", + data: { supported: ["2025-06-18", "2024-11-05"] }, + }, + }; + stub((req) => noServerStream(req) ?? json(upstreamError, {}, 400)); + const out: string[] = []; + + await mcpRun( + { url: URL }, + { input: lines({ jsonrpc: "2.0", id: 1, method: "tools/list" }), write: (c) => out.push(c) }, + ); + + expect(framesFrom(out)[0]).toEqual(upstreamError); + }); + + test("falls back to a generic -32000 when a 500 upstream returns a non-JSON (HTML) body", async () => { + stub( + (req) => + noServerStream(req) ?? + new Response("Internal Server Error", { + status: 500, + headers: { "content-type": "text/html" }, + }), + ); + const out: string[] = []; + + await mcpRun( + { url: URL }, + { input: lines({ jsonrpc: "2.0", id: 1, method: "tools/list" }), write: (c) => out.push(c) }, + ); + + const error = framesFrom(out)[0]?.error as { code?: number; message?: string } | undefined; + expect(error?.code).toBe(-32000); + expect(error?.message).toBe("Upstream returned HTTP 500."); + }); + + test("falls back to a generic -32000 when a 400 upstream returns JSON that isn't JSON-RPC", async () => { + stub((req) => noServerStream(req) ?? json({ ok: false, reason: "bad request" }, {}, 400)); + const out: string[] = []; + + await mcpRun( + { url: URL }, + { input: lines({ jsonrpc: "2.0", id: 1, method: "tools/list" }), write: (c) => out.push(c) }, + ); + + const error = framesFrom(out)[0]?.error as { code?: number; message?: string } | undefined; + expect(error?.code).toBe(-32000); + expect(error?.message).toBe("Upstream returned HTTP 400."); + }); + test("replies with -32000 when an SSE response stream dies before the reply", async () => { const body = new ReadableStream({ start(controller) { diff --git a/packages/cli-core/src/commands/mcp/run.ts b/packages/cli-core/src/commands/mcp/run.ts index 3219b28f..612de67a 100644 --- a/packages/cli-core/src/commands/mcp/run.ts +++ b/packages/cli-core/src/commands/mcp/run.ts @@ -191,6 +191,11 @@ async function dispatch(message: JSONRPCMessage, ctx: DispatchCtx): Promise { + const text = await readTextCapped(response, MAX_LINE_BYTES); + if (text === undefined || text.trim().length === 0) return false; + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return false; + } + if (!isJsonRpcErrorResponse(parsed)) return false; + await emitPayload(parsed); + return true; +} + +/** True when a parsed body is a well-formed JSON-RPC 2.0 error response. */ +function isJsonRpcErrorResponse(payload: unknown): boolean { + if (!isRecord(payload) || payload.jsonrpc !== "2.0" || !("id" in payload)) return false; + return isRecord(payload.error) && typeof payload.error.code === "number"; +} + function requestHeaders(session: Session): Record { return { "Content-Type": "application/json", From f36d4d48542bb54a3e6a51a1d972375bae894ace Mon Sep 17 00:00:00 2001 From: Rafael Thayto Tani Date: Fri, 21 Aug 2026 15:08:56 -0300 Subject: [PATCH 2/2] Keep notifications silent and survive mid-read body failures in error relay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback: relayUpstreamError emitted a reply frame even when the original message was a notification (no id), which JSON-RPC forbids — gate the relay on the request having an id, matching emitError's own guard. Also catch readTextCapped rejections so a body that dies mid-read falls back instead of escaping; today loggedFetch's non-ok clone().text() pre-read catches that failure first, but the relay path no longer depends on it. --- .../cli-core/src/commands/mcp/run.test.ts | 58 +++++++++++++++++++ packages/cli-core/src/commands/mcp/run.ts | 13 ++++- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/packages/cli-core/src/commands/mcp/run.test.ts b/packages/cli-core/src/commands/mcp/run.test.ts index 0c90e07d..467b960c 100644 --- a/packages/cli-core/src/commands/mcp/run.test.ts +++ b/packages/cli-core/src/commands/mcp/run.test.ts @@ -414,6 +414,64 @@ describe("mcp run (stdio bridge)", () => { expect(framesFrom(out)[0]).toEqual(upstreamError); }); + test("keeps a notification silent even when the upstream error body is a JSON-RPC error", async () => { + const upstreamError = { + jsonrpc: "2.0", + id: null, + error: { code: -32600, message: "Invalid notification" }, + }; + stub((req) => noServerStream(req) ?? json(upstreamError, {}, 400)); + const out: string[] = []; + + await mcpRun( + { url: URL }, + { + input: lines({ jsonrpc: "2.0", method: "notifications/initialized" }), + write: (c) => out.push(c), + }, + ); + + expect(out.join("")).toBe(""); + }); + + test("answers with a structured -32000 when the upstream error body dies mid-read", async () => { + // Error on the second pull, not in start(): erroring at construction + // surfaces as a fetch failure before the response is even returned. The + // regression under test is a body that dies while being read. Today that + // read happens inside loggedFetch's non-ok clone().text() (so its message + // wins); relayUpstreamError's own catch covers the same failure if that + // pre-read ever moves behind --verbose. Either way the invariant is: one + // structured -32000 reply, bridge stays alive. + let pulls = 0; + const body = new ReadableStream({ + pull(controller) { + pulls += 1; + if (pulls === 1) { + controller.enqueue(new TextEncoder().encode('{"jsonrpc":"2.0","id":1,')); + return; + } + controller.error(new Error("connection reset")); + }, + }); + stub( + (req) => + noServerStream(req) ?? + new Response(body, { status: 500, headers: { "content-type": "application/json" } }), + ); + const out: string[] = []; + + await mcpRun( + { url: URL }, + { input: lines({ jsonrpc: "2.0", id: 1, method: "tools/list" }), write: (c) => out.push(c) }, + ); + + const frames = framesFrom(out); + expect(frames).toHaveLength(1); + const error = frames[0]?.error as { code?: number; message?: string } | undefined; + expect(error?.code).toBe(-32000); + expect(error?.message).toStartWith("Upstream"); + }); + test("falls back to a generic -32000 when a 500 upstream returns a non-JSON (HTML) body", async () => { stub( (req) => diff --git a/packages/cli-core/src/commands/mcp/run.ts b/packages/cli-core/src/commands/mcp/run.ts index 612de67a..9fa71d2e 100644 --- a/packages/cli-core/src/commands/mcp/run.ts +++ b/packages/cli-core/src/commands/mcp/run.ts @@ -195,7 +195,9 @@ async function dispatch(message: JSONRPCMessage, ctx: DispatchCtx): Promise { - const text = await readTextCapped(response, MAX_LINE_BYTES); + let text: string | undefined; + try { + text = await readTextCapped(response, MAX_LINE_BYTES); + } catch { + // A body that dies mid-read is just an unreadable body — fall back rather + // than letting the rejection escape and take the whole bridge down. + return false; + } if (text === undefined || text.trim().length === 0) return false; let parsed: unknown; try {