You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
⚠️ 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.constpayload=awaitapiPost("/mcp",{jsonrpc: "2.0",id: Date.now(),method: "tools/call",params: {name: tool.name,arguments: input}});constresult=(payloadas{result?: unknown}).result;returnresult??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
constok=result?.isError!==true;
The JSON-RPC error envelope has no isError, so ok is true. Every remote-refused proxied call is
recorded as a successfulusage_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.
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:apiPostthrows only on a non-2xx HTTP status (packages/loopover-mcp/bin/loopover-mcp.ts:6002). AJSON-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 noresultkey.result ?? payloadthereforehands that raw JSON-RPC envelope back to the MCP client as if it were the tool's answer. It is not a
CallToolResultat all — it carries nocontent, nostructuredContent, and noisError.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: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 thenrejects 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/listdiscovery and alater
tools/call.Second defect on the same line:
wrapStdioToolHandler(
packages/loopover-mcp/lib/telemetry.ts:126) computesThe JSON-RPC error envelope has no
isError, sookistrue. Every remote-refused proxied call isrecorded as a successful
usage_event/$mcp_tool_callwithtransport: "proxied"and noerror_code— which makes gateway failure rate, the exact metricpackages/loopover-contract/src/telemetry.ts:52says the
transportdimension exists to measure, unobservable.Nothing covers this.
test/unit/mcp-gateway.test.tsexercises only the pure helpers inpackages/loopover-mcp/lib/gateway.ts(discovery, the advisory,--no-remote);registerProxiedTooland its handler have no test, and
test/contract/validate-mcp.test.ts:233boots the stdio serverwithout mounting the gateway, so no proxied tool is ever smoke-called.
Requirements
errorand no
result, it must return a conformant MCP tool error result:isError: true, acontentarraywith a text block, and
structuredContentcarrying the shared envelope shape{ error: { code, message } }wherecodeis a member ofMCP_TELEMETRY_ERROR_CODES(
packages/loopover-contract/src/telemetry.ts:28), resolved through the contract's existingresolveErrorCode. It must never return the raw{jsonrpc, id, error}object.messagemust be carried through to the caller so the failure is diagnosable; the JSON-RPCnumeric
codemust not be surfaced as the telemetryerror_code(that dimension is a closed set).resultnorerrormust also produce anisError: trueresult ratherthan being returned verbatim.
wrapStdioToolHandler'sresult?.isError !== truecheck must classify these callsas
ok: false, so the proxied failure is recorded as a failure with a resolvederror_code. Do not adda second telemetry call site — the existing wrapper is the chokepoint.
result, that object is stillreturned verbatim; this layer routes and does not reshape a successful answer. The
_meta.transport: "proxied"marker (loopover-mcp.ts:2570) and the"proxied"argument towrapStdioToolHandler(
loopover-mcp.ts:2583) stay exactly as they are.apiPost/apiFetch's existing throw-on-non-2xx behaviour, and the gateway'sbest-effort mount posture in
packages/loopover-mcp/lib/gateway.ts— a failing tool call must stillnever prevent the stdio server from starting.
Deliverables
registerProxiedTool's handler inpackages/loopover-mcp/bin/loopover-mcp.ts:2551-2586returns aconformant
isError: trueresult with a closed-set{ error: { code, message } }envelope for apayload carrying
errorand noresult, and for a payload carrying neither.test/unit/mcp-gateway-proxy.test.ts(new file) that stubs the API transportto 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-emptycontentarray, and
structuredContent.error.codedrawn fromMCP_TELEMETRY_ERROR_CODES— named for thisbug (
REGRESSION: a remote JSON-RPC error must not be returned as the tool's result).resultis still returned verbatim,unwrapped, with no added
isError.test/unit/mcp-local-telemetry.test.tsasserting that a proxied call whose result carriesisError: trueis recorded withok: false,transport: "proxied", and anerror_code— thecounterpart 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: truewithout the structured{ error: { code, message } }envelope, orshaping 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'scoverage.includecoverspackages/loopover-mcp/bin/**/*.ts(line 120) andpackages/loopover-mcp/lib/**/*.ts(line 113), so both touched paths are measured and gated.Every branch the change introduces needs both arms tested:
resultpresent vs absent,errorpresent vsabsent, and the "neither present" fallback. Note that
packages/loopover-mcp/bin/loopover-mcp.tsistested largely by subprocess spawn, which reports zero coverage — the new tests must drive
registerProxiedToolin-process (import the module and stub the transport, the waytest/contract/validate-mcp.test.ts:234importspackages/loopover-mcp/bin/loopover-mcp) or the patchgate 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_eventas a failure with acause rather than as a success.
Links & Resources
packages/loopover-mcp/bin/loopover-mcp.ts:2551—registerProxiedToolpackages/loopover-mcp/bin/loopover-mcp.ts:2561— the doc for the unmodelled-tool case that reaches thispackages/loopover-mcp/bin/loopover-mcp.ts:6002—apiFetchthrows only on a non-2xxpackages/loopover-mcp/lib/telemetry.ts:126—ok = result?.isError !== truepackages/loopover-contract/src/telemetry.ts:287—resolveErrorCodepackages/loopover-miner/bin/loopover-miner-mcp.ts:188— the envelope shape to mirror