Skip to content

mcp(gateway): translate a remote JSON-RPC error into a tool error instead of returning the envelope #10036

Description

@JSONbored

⚠️ Definition of Done: this issue must be completed in full, in a single PR. Do not split this
work across multiple PRs, and do not defer any Deliverable below to a follow-up issue. A PR that
satisfies only some of the Deliverables, stubs a required test, or leaves a checkbox
partially-done does NOT resolve this issue and will be closed.

Context

Gateway mode mounts the remote server's tools onto the stdio server. Each proxied tool's handler is
packages/loopover-mcp/bin/loopover-mcp.ts:2577:

(async (input: unknown) => {
  // Forwarded verbatim to the remote's own tools/call: this layer routes, it does not interpret.
  const payload = await apiPost("/mcp", { jsonrpc: "2.0", id: Date.now(), method: "tools/call", params: { name: tool.name, arguments: input } });
  const result = (payload as { result?: unknown }).result;
  return result ?? payload;
}) as (...args: unknown[]) => Promise<unknown>,

apiPost throws only on a non-2xx HTTP status (packages/loopover-mcp/bin/loopover-mcp.ts:6002). A
JSON-RPC error is not a non-2xx: the remote runs with enableJsonResponse: true
(src/mcp/server.ts:680), so a request-level failure comes back as HTTP 200 with a body of
{ jsonrpc: "2.0", id, error: { code, message } } and no result key. result ?? payload therefore
hands that raw JSON-RPC envelope back to the MCP client as if it were the tool's answer. It is not a
CallToolResult at all — it carries no content, no structuredContent, and no isError.

This is a documented, deliberately-supported path, not a hypothetical. The comment immediately above, at
packages/loopover-mcp/bin/loopover-mcp.ts:2561, contemplates exactly the case that reaches it:

 * A tool absent from the registry (a remote running ahead of this package) still gets an inputSchema,
 * just a fully open one. ... An open object keeps the remote the only validator, which is where
 * validation belongs for a tool this package does not model.

For such a tool the proxy declares inputSchema: contract?.input ?? z.looseObject({})
(loopover-mcp.ts:2567), so any arguments pass local validation and are forwarded; the remote then
rejects them with a JSON-RPC -32602, and the caller receives {jsonrpc, id, error} as the tool result.
The same happens for any tool the remote stops serving between the gateway's tools/list discovery and a
later tools/call.

Second defect on the same line: wrapStdioToolHandler
(packages/loopover-mcp/lib/telemetry.ts:126) computes

const ok = result?.isError !== true;

The JSON-RPC error envelope has no isError, so ok is true. Every remote-refused proxied call is
recorded as a successful usage_event / $mcp_tool_call with transport: "proxied" and no
error_code — which makes gateway failure rate, the exact metric packages/loopover-contract/src/telemetry.ts:52
says the transport dimension exists to measure, unobservable.

Nothing covers this. test/unit/mcp-gateway.test.ts exercises only the pure helpers in
packages/loopover-mcp/lib/gateway.ts (discovery, the advisory, --no-remote); registerProxiedTool
and its handler have no test, and test/contract/validate-mcp.test.ts:233 boots the stdio server
without mounting the gateway, so no proxied tool is ever smoke-called.

Requirements

  • The proxy handler must inspect the JSON-RPC envelope it gets back. When the payload carries an error
    and no result, it must return a conformant MCP tool error result: isError: true, a content array
    with a text block, and structuredContent carrying the shared envelope shape
    { error: { code, message } } where code is a member of MCP_TELEMETRY_ERROR_CODES
    (packages/loopover-contract/src/telemetry.ts:28), resolved through the contract's existing
    resolveErrorCode. It must never return the raw {jsonrpc, id, error} object.
  • The remote's message must be carried through to the caller so the failure is diagnosable; the JSON-RPC
    numeric code must not be surfaced as the telemetry error_code (that dimension is a closed set).
  • A payload that carries neither result nor error must also produce an isError: true result rather
    than being returned verbatim.
  • With that in place, wrapStdioToolHandler's result?.isError !== true check must classify these calls
    as ok: false, so the proxied failure is recorded as a failure with a resolved error_code. Do not add
    a second telemetry call site — the existing wrapper is the chokepoint.
  • What must NOT change: the success path. When the remote answers with a result, that object is still
    returned verbatim; this layer routes and does not reshape a successful answer. The _meta.transport: "proxied" marker (loopover-mcp.ts:2570) and the "proxied" argument to wrapStdioToolHandler
    (loopover-mcp.ts:2583) stay exactly as they are.
  • What must NOT change: apiPost/apiFetch's existing throw-on-non-2xx behaviour, and the gateway's
    best-effort mount posture in packages/loopover-mcp/lib/gateway.ts — a failing tool call must still
    never prevent the stdio server from starting.

⚠️ Required pattern: mirror the miner's error envelope construction at
packages/loopover-miner/bin/loopover-miner-mcp.ts:188, which builds
{ error: { code: <closed-set code>, message } } and returns it with isError: true, and classify with
resolveErrorCode from @loopover/contract (packages/loopover-contract/src/telemetry.ts:287) rather
than a new local mapping. What does NOT satisfy this issue: (a) throwing the remote's message from the
proxy handler, which loses the structured envelope and turns a remote refusal into a local crash the
caller cannot distinguish from a transport failure; (b) adding the JSON-RPC check inside
wrapStdioToolHandler, which would apply it to local tools that never speak JSON-RPC; (c) a test-only
PR that asserts the current shape.

Deliverables

  • registerProxiedTool's handler in packages/loopover-mcp/bin/loopover-mcp.ts:2551-2586 returns a
    conformant isError: true result with a closed-set { error: { code, message } } envelope for a
    payload carrying error and no result, and for a payload carrying neither.
  • A regression test at test/unit/mcp-gateway-proxy.test.ts (new file) that stubs the API transport
    to return { jsonrpc: "2.0", id: 1, error: { code: -32602, message: "Tool loopover_x not found" } },
    calls a proxied tool, and asserts the returned result has isError === true, a non-empty content
    array, and structuredContent.error.code drawn from MCP_TELEMETRY_ERROR_CODES — named for this
    bug (REGRESSION: a remote JSON-RPC error must not be returned as the tool's result).
  • A test in the same file asserting that a payload with a result is still returned verbatim,
    unwrapped, with no added isError.
  • A test in test/unit/mcp-local-telemetry.test.ts asserting that a proxied call whose result carries
    isError: true is recorded with ok: false, transport: "proxied", and an error_code — the
    counterpart to the existing success-path assertion at line 366.

All Deliverables above are required in a single PR. A PR that satisfies only some of them — for
example returning isError: true without the structured { error: { code, message } } envelope, or
shaping the result without the telemetry assertions — does not resolve this issue.

Test Coverage Requirements

This repo enforces 99%+ Codecov patch coverage, branch-counted. vitest.config.ts's
coverage.include covers packages/loopover-mcp/bin/**/*.ts (line 120) and
packages/loopover-mcp/lib/**/*.ts (line 113), so both touched paths are measured and gated.
Every branch the change introduces needs both arms tested: result present vs absent, error present vs
absent, and the "neither present" fallback. Note that packages/loopover-mcp/bin/loopover-mcp.ts is
tested largely by subprocess spawn, which reports zero coverage — the new tests must drive
registerProxiedTool in-process (import the module and stub the transport, the way
test/contract/validate-mcp.test.ts:234 imports packages/loopover-mcp/bin/loopover-mcp) or the patch
gate will fail even though the behaviour is tested.

Expected Outcome

A gateway user whose call the remote refuses gets a readable MCP tool error instead of a raw JSON-RPC
envelope their client cannot render, and the proxied failure shows up in usage_event as a failure with a
cause rather than as a success.

Links & Resources

  • packages/loopover-mcp/bin/loopover-mcp.ts:2551registerProxiedTool
  • packages/loopover-mcp/bin/loopover-mcp.ts:2561 — the doc for the unmodelled-tool case that reaches this
  • packages/loopover-mcp/bin/loopover-mcp.ts:6002apiFetch throws only on a non-2xx
  • packages/loopover-mcp/lib/telemetry.ts:126ok = result?.isError !== true
  • packages/loopover-contract/src/telemetry.ts:287resolveErrorCode
  • packages/loopover-miner/bin/loopover-miner-mcp.ts:188 — the envelope shape to mirror

Metadata

Metadata

Assignees

No one assigned

    Labels

    gittensor:bugGittensor-scored bug fix — scores a 0.05x multiplier.help wantedExtra attention is needed

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions