Skip to content

Commit fa9789e

Browse files
authored
fix(mcp): shape a remote JSON-RPC error into a tool result in the gateway proxy (#10216)
registerProxiedTool's handler returned `result ?? payload` verbatim. The remote runs with enableJsonResponse, so a request-level failure (an unknown tool, bad arguments) still arrives as HTTP 200 with `{ jsonrpc, id, error }` and no `result` key -- apiPost only throws on a non-2xx status, so that raw envelope reached the caller as if it were the tool's own answer: no content, no isError, not a CallToolResult at all. wrapStdioToolHandler's `ok = result?.isError !== true` then read every one of those as a success, since the envelope carries no isError of its own. The handler now inspects what came back: a `result` is still returned unwrapped and unchanged, but an `error` (or a payload carrying neither) is shaped into a conformant isError:true result with a closed-set { error: { code, message } } envelope, classifying the remote's message through resolveErrorCode rather than surfacing its numeric JSON-RPC code as the telemetry error_code. Co-authored-by: bitfathers94 <237535319+bitfathers94@users.noreply.github.com>
1 parent 447dff1 commit fa9789e

5 files changed

Lines changed: 157 additions & 10 deletions

File tree

packages/loopover-mcp/bin/loopover-mcp.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ import {
170170
projectToolDefinition,
171171
ListPendingActionsStdioInput,
172172
} from "@loopover/contract/tools";
173-
import { AUTONOMY_LEVELS as MAINTAIN_AUTONOMY_LEVELS, MAINTAIN_ACTION_CLASSES, PROPOSE_ACTION_CLASSES, type ToolContract } from "@loopover/contract";
173+
import { AUTONOMY_LEVELS as MAINTAIN_AUTONOMY_LEVELS, MAINTAIN_ACTION_CLASSES, PROPOSE_ACTION_CLASSES, resolveErrorCode, type ToolContract } from "@loopover/contract";
174174
import { buildBranchAnalysisPayload, collectLocalDiff, collectLocalBranchMetadata, probeLocalScorer, referenceScorePreviewExample, resolveScorePreviewCommand, resolveWorkspaceCwd, sanitizeLocalScorerStatus, setupGuidanceForLocalScorer, isTestFile } from "../lib/local-branch.js";
175175
import { formatTable } from "../lib/format-table.js";
176176
import { argsWantJson, describeCliError, reportCliFailure } from "../lib/cli-error.js";
@@ -2579,7 +2579,21 @@ function registerProxiedTool(tool: RemoteToolDescriptor): void {
25792579
// Forwarded verbatim to the remote's own tools/call: this layer routes, it does not interpret.
25802580
const payload = await apiPost("/mcp", { jsonrpc: "2.0", id: Date.now(), method: "tools/call", params: { name: tool.name, arguments: input } });
25812581
const result = (payload as { result?: unknown }).result;
2582-
return result ?? payload;
2582+
if (result !== undefined) return result;
2583+
// `enableJsonResponse` (src/mcp/server.ts) means a request-level failure still arrives as HTTP 200,
2584+
// with a JSON-RPC `{ error }` envelope in place of `result` -- apiPost only throws on a non-2xx, so
2585+
// that envelope (or, degenerately, neither key at all) would otherwise be handed back verbatim as
2586+
// if it were the tool's own answer. Shape it into a real CallToolResult instead: the numeric
2587+
// JSON-RPC `code` is not a member of the closed telemetry vocabulary, so it is never surfaced as
2588+
// one -- resolveErrorCode reclassifies from the message, the same as every other server here.
2589+
const rpcError = (payload as { error?: { message?: unknown } }).error;
2590+
const message =
2591+
typeof rpcError?.message === "string" ? rpcError.message : "The remote MCP server returned neither a result nor an error for this call.";
2592+
return {
2593+
content: [{ type: "text" as const, text: message }],
2594+
structuredContent: { error: { code: resolveErrorCode(message), message } },
2595+
isError: true as const,
2596+
};
25832597
}) as (...args: unknown[]) => Promise<unknown>,
25842598
"proxied",
25852599
) as never,

test/unit/mcp-gateway-mount-inprocess.test.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -167,19 +167,24 @@ describe("mountRemoteTools against the real server (#9526)", () => {
167167
}
168168
});
169169

170-
it("hands back the WHOLE payload when the remote's envelope carries no `result`", async () => {
171-
// The `??` fallback, and a real posture rather than a defensive shrug: a remote that answers a shape
172-
// this package does not model must still reach the caller intact, so the caller can see what came back
173-
// instead of an empty success.
170+
it("shapes a resultless envelope into a conformant isError result rather than handing it back raw (#10036)", async () => {
171+
// A remote answering neither `result` nor `error` is not a CallToolResult either -- returning it
172+
// verbatim used to hand the client a bare `{ jsonrpc, id, note }` object with no `content`/`isError` at
173+
// all. It must get the same treatment as a JSON-RPC error: a readable isError:true result.
174174
await mod.mountRemoteTools({
175175
argv: ["--stdio"],
176176
fetchImpl: remoteToolsFetch([{ name: "loopover_gateway_resultless" }]),
177177
});
178178
const client = await connect("gateway-resultless");
179179
try {
180-
const raw = (await client.callTool({ name: "loopover_gateway_resultless", arguments: {} })) as { note?: string; isError?: boolean };
181-
expect(raw.isError).toBeFalsy();
182-
expect(raw.note, "the envelope itself reaches the caller when it carries no result").toBe("no result member");
180+
const result = (await client.callTool({ name: "loopover_gateway_resultless", arguments: {} })) as {
181+
isError?: boolean;
182+
content?: Array<{ type: string; text?: string }>;
183+
structuredContent?: { error?: { code?: string; message?: string } };
184+
};
185+
expect(result.isError).toBe(true);
186+
expect(result.content?.[0]?.text).toBeTruthy();
187+
expect(result.structuredContent?.error?.code).toBeTruthy();
183188
} finally {
184189
await client.close();
185190
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// REGRESSION (#10036): registerProxiedTool's handler used to hand a remote JSON-RPC error envelope
2+
// back to the caller AS IF it were the tool's own result. `apiPost` only throws on a non-2xx HTTP
3+
// status, and the remote runs with `enableJsonResponse: true`, so a request-level failure -- an
4+
// unknown tool, bad arguments, whatever -- comes back as HTTP 200 with `{ jsonrpc, id, error }` and no
5+
// `result` key. `result ?? payload` returned that raw envelope verbatim: no `content`, no `isError`, not
6+
// a CallToolResult at all.
7+
//
8+
// Drives the real `registerProxiedTool` in-process (mounted through `mountRemoteTools`, connected over
9+
// an in-memory transport) rather than unit-testing a helper pulled out for the occasion: the bug lived
10+
// in the handler closure itself, and `packages/loopover-mcp/bin/loopover-mcp.ts` reports zero coverage
11+
// under subprocess spawn, so only an in-process call attributes these lines to the patch (mirrors
12+
// test/contract/validate-mcp.test.ts, which imports this same module the same way).
13+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
14+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
15+
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
16+
import { MCP_TELEMETRY_ERROR_CODES } from "@loopover/contract";
17+
import type { GatewayFetch, RemoteToolDescriptor } from "../../packages/loopover-mcp/lib/gateway";
18+
19+
type ToolCallResult = { isError?: boolean; content?: Array<{ type: string; text?: string }>; structuredContent?: unknown };
20+
21+
const REMOTE_TOOL: RemoteToolDescriptor = {
22+
name: "loopover_gateway_proxy_probe",
23+
title: "Gateway proxy probe",
24+
description: "A remote-only tool this package does not model, mounted purely to exercise the proxy handler.",
25+
inputSchema: { type: "object" },
26+
};
27+
28+
/** Answers the gateway's OWN discovery call (`mountRemoteTools`'s `fetchImpl`) with one remote tool. */
29+
const discoveryFetch: GatewayFetch = async () => ({
30+
ok: true,
31+
status: 200,
32+
json: async () => ({ result: { tools: [REMOTE_TOOL] } }),
33+
});
34+
35+
/** What the proxied tool's own `tools/call` (routed through `apiPost`, i.e. the real global `fetch`) answers. */
36+
let nextCallResponse: unknown;
37+
38+
let client: Client;
39+
40+
beforeAll(async () => {
41+
vi.stubEnv("LOOPOVER_API_TOKEN", "test-session-token");
42+
vi.stubGlobal(
43+
"fetch",
44+
vi.fn(async () => ({
45+
ok: true,
46+
status: 200,
47+
headers: { get: () => null },
48+
text: async () => JSON.stringify(nextCallResponse),
49+
})),
50+
);
51+
52+
const { server, mountRemoteTools } = await import("../../packages/loopover-mcp/bin/loopover-mcp");
53+
const mounted = await mountRemoteTools({ argv: [], fetchImpl: discoveryFetch });
54+
if (mounted.status !== "mounted" || !mounted.tools.some((tool) => tool.name === REMOTE_TOOL.name)) {
55+
throw new Error(`expected ${REMOTE_TOOL.name} to mount, got: ${JSON.stringify(mounted)}`);
56+
}
57+
58+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
59+
client = new Client({ name: "mcp-gateway-proxy-test", version: "0.0.0" });
60+
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
61+
});
62+
63+
afterAll(async () => {
64+
await client.close().catch(() => undefined);
65+
vi.unstubAllEnvs();
66+
vi.unstubAllGlobals();
67+
});
68+
69+
describe("registerProxiedTool's handler translates the remote's JSON-RPC envelope (#10036)", () => {
70+
it("REGRESSION: a remote JSON-RPC error must not be returned as the tool's result", async () => {
71+
nextCallResponse = { jsonrpc: "2.0", id: 1, error: { code: -32602, message: "Tool loopover_x not found" } };
72+
73+
const result = (await client.callTool({ name: REMOTE_TOOL.name, arguments: {} })) as ToolCallResult;
74+
expect(result.isError).toBe(true);
75+
expect(result.content?.length).toBeGreaterThan(0);
76+
expect(result.content?.[0]?.text).toContain("Tool loopover_x not found");
77+
const structured = result.structuredContent as { error?: { code?: unknown; message?: unknown } };
78+
expect(structured.error?.message).toBe("Tool loopover_x not found");
79+
expect(MCP_TELEMETRY_ERROR_CODES).toContain(structured.error?.code);
80+
// The JSON-RPC numeric code is not part of the closed telemetry vocabulary and must never leak through.
81+
expect(structured.error?.code).not.toBe(-32602);
82+
});
83+
84+
it("a payload carrying neither result nor error also becomes an isError:true result", async () => {
85+
nextCallResponse = { jsonrpc: "2.0", id: 1 };
86+
87+
const result = (await client.callTool({ name: REMOTE_TOOL.name, arguments: {} })) as ToolCallResult;
88+
expect(result.isError).toBe(true);
89+
const structured = result.structuredContent as { error?: { code?: unknown; message?: unknown } };
90+
expect(MCP_TELEMETRY_ERROR_CODES).toContain(structured.error?.code);
91+
});
92+
93+
it("a payload carrying a result is still returned verbatim, unwrapped, with no added isError", async () => {
94+
nextCallResponse = {
95+
jsonrpc: "2.0",
96+
id: 1,
97+
result: { content: [{ type: "text", text: "hello from the remote" }], structuredContent: { ok: true } },
98+
};
99+
100+
const result = (await client.callTool({ name: REMOTE_TOOL.name, arguments: {} })) as ToolCallResult;
101+
expect(result.isError).toBeUndefined();
102+
expect(result.content?.[0]?.text).toBe("hello from the remote");
103+
expect(result.structuredContent).toEqual({ ok: true });
104+
});
105+
});

test/unit/mcp-local-telemetry.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,29 @@ describe("recordStdioToolTelemetry / wrapStdioToolHandler (#8690)", () => {
372372
expect(usage.properties).toMatchObject({ surface: "stdio", transport: "proxied" });
373373
});
374374

375+
// #10036: the counterpart to the assertion just above. registerProxiedTool's handler used to hand back
376+
// the remote's raw JSON-RPC `{ error }` envelope, which has no `isError`, so `ok = result?.isError !==
377+
// true` read every remote refusal as a SUCCESS -- a proxied failure recorded no differently from a
378+
// proxied success, with gateway failure rate unmeasurable. Now that the handler shapes a conformant
379+
// `isError: true` result with a closed-set envelope, this must record ok:false + the resolved error_code.
380+
it("wrapStdioToolHandler records a PROXIED remote refusal as a failure with a resolved error_code", async () => {
381+
vi.stubEnv("LOOPOVER_MCP_POSTHOG_API_KEY", "phc_test");
382+
const wrapped = wrapStdioToolHandler(
383+
"loopover_lint_pr_text",
384+
() => true,
385+
async () => ({
386+
isError: true,
387+
content: [{ type: "text", text: "Tool loopover_x not found" }],
388+
structuredContent: { error: { code: "not_found", message: "Tool loopover_x not found" } },
389+
}),
390+
"proxied",
391+
);
392+
await wrapped({});
393+
394+
const usage = h.captureSpy.mock.calls.map((entry) => entry[0] as CapturedMessage).find((message) => message.event === "usage_event")!;
395+
expect(usage.properties).toMatchObject({ surface: "stdio", transport: "proxied", ok: false, error_code: "not_found" });
396+
});
397+
375398
// #9659: the stdio wrapper passed NO error on the returned-failure path, so `resolveErrorCode(undefined)`
376399
// classified every one of them as `unknown_error` no matter what the tool told its caller.
377400
it("wrapStdioToolHandler resolves the error code from the result's own envelope", async () => {

test/unit/support/mcp-cli-harness.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,7 @@ export async function startFixtureServer(
270270
request.on("end", () => {
271271
const parsed = JSON.parse(raw || "{}") as { id?: unknown; params?: { name?: string; arguments?: unknown } };
272272
// A tool named `*_resultless` gets an envelope with NO `result` member, so the gateway's
273-
// "hand back the whole payload" fallback can be exercised against a real response rather than a
273+
// neither-result-nor-error handling (#10036) can be exercised against a real response rather than a
274274
// hand-built object. Keyed on the name because one fixture serves every gateway test.
275275
if (parsed.params?.name?.endsWith("_resultless")) {
276276
response.end(JSON.stringify({ jsonrpc: "2.0", id: parsed.id ?? 1, note: "no result member" }));

0 commit comments

Comments
 (0)