|
| 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 | +}); |
0 commit comments