Skip to content

fix(mcpProxy): propagate server send errors back to client as JSON-RPC errors - #297

Open
sahilm-ti wants to merge 2 commits into
geelen:mainfrom
sahilm-ti:fix/propagate-server-send-errors-to-client-clean
Open

fix(mcpProxy): propagate server send errors back to client as JSON-RPC errors#297
sahilm-ti wants to merge 2 commits into
geelen:mainfrom
sahilm-ti:fix/propagate-server-send-errors-to-client-clean

Conversation

@sahilm-ti

Copy link
Copy Markdown

Fixes #293.

Problem

When transportToServer.send(message) rejects due to a transport-level error (HTTP 4xx/5xx, connection refused, timeout, etc.), mcpProxy catches the error via onServerError, logs it to stderr, and returns. The local client's awaiting request receives no response and parks indefinitely.

message.id is not passed to onServerError so it cannot send a response even if it wanted to. The ignoredTools path in the same function already demonstrates the correct pattern — it sends a JSON-RPC error back to the client — but that pattern wasn't applied to the general error case.

Fix

Capture message.id before dispatching, then use it in the .catch() handler to send a JSON-RPC error back to the client:

const requestId = 'id' in message ? (message as any).id : undefined

transportToServer.send(message).catch((error: Error) => {
  onServerError(error)            // existing stderr logging preserved
  if (requestId !== undefined) {  // notifications have no id — skip
    transportToClient.send({
      jsonrpc: '2.0',
      id: requestId,
      error: { code: -32603, message: error.message ?? '...' },
    }).catch(onClientError)
  }
})

Notes

  • Notifications (messages with no id) are unaffected
  • Existing onServerError stderr logging is unchanged
  • tsc --noEmit passes cleanly
  • No existing tests cover mcpProxy directly; happy to add one if wanted

sahilm-ti and others added 2 commits June 24, 2026 23:54
…C errors

When transportToServer.send(message) rejects (e.g. due to a transport-level
HTTP error on the remote endpoint), the error was caught by onServerError which
logs it to stderr and returns — leaving the client's awaiting request with no
response. Any client blocking on that request id parks indefinitely.

Fix: capture message.id before dispatching to the server, then in the catch
handler send a JSON-RPC error response back to the client so the awaiter is
woken immediately.

Notifications (no id) are unaffected. The existing onServerError logging is
preserved so the error still appears in stderr for diagnostics.
Pattern 2 of the 429 hang: when _startOrAuthSse (SSE GET) receives a 429
it throws StreamableHTTPError, caught by the transport's catch block which
calls this.onerror?.().  This routes to mcpProxy's onServerError, which
previously only logged -- leaving any in-flight requests parked indefinitely.

Fix: track all in-flight request ids in a pendingRequests Map within
mcpProxy scope.  When a message with an id is sent to the server it is
added to the map; when a server response arrives (onmessage) or the
send().catch() fires (Pattern 1) it is removed.  onServerError now drains
the entire map, sending a JSON-RPC -32603 error for each pending id so
client awaiters are unblocked immediately.

This covers Pattern 2 (bare 'code: 429' in proxy logs with no 'Connection
error:' prefix, child stays alive, session hangs until client disconnects).
Pattern 1 (send().catch() path) is already handled; the pendingRequests
delete in that path ensures no double-send.

103 unit tests pass.
@sahilm-ai

Copy link
Copy Markdown

Update: also fixes Pattern 2 (SSE/onerror path)

Testing after the original commit revealed a second 429 hang path that this PR did NOT fix:

Pattern 1 (fixed by original commit): transportToServer.send() rejects → .catch() fires → onServerError called → new code sends JSON-RPC error back

Pattern 2 (now also fixed): _startOrAuthSse (SSE GET) gets a 429 → throws StreamableHTTPError → caught in transport's try/catch → this.onerror?.() fires → routes to mcpProxy's onServerErrorwas still log-only, session hung indefinitely

Pattern 2 appeared in proxy logs as bare code: 429 lines with no Connection error: prefix and no child_process_exited — child process stayed alive while sessions parked until client disconnect (~15s in test; indefinitely in prod).

Fix (just pushed): Added a pendingRequests: Map<id, true> in mcpProxy scope:

  • Message sent to server → id registered in map
  • Server response arrives (onmessage) → id removed
  • send().catch() fires (Pattern 1) → id removed + error sent back
  • onServerError (Pattern 2) → drain entire map, send JSON-RPC -32603 for each pending id, clear map

Verified: 5× 40-session parallel hammer test (mix of ns_getrecord + ns_runcustom): all 5 runs OK=40 ERR=0 HUNG=0. Previously Run 2 had HUNG=1 from this path. 103 unit tests pass.

@pvalkone

Copy link
Copy Markdown

I hit #293 independently and verified this PR against it (79f5f10). It does fix the hang, but Pattern 1 and Pattern 2 aren't mutually exclusive, and when both fire for the same request the client gets two responses for one ID.

Repro, any non-initialize first message triggers a transport error:

(echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'; sleep 20) \
  | node dist/proxy.js https://mcp.atlassian.com/v1/mcp/authv2

On main stdout stays empty and the caller parks (the hang observed in #293). On this branch with stdout and stderr interleaved:

[Local→Remote] tools/list
Error from remote server: StreamableHTTPError: ... {"code":-32600,"message":"Request must be an initialize request if no session ID is provided."}
{"jsonrpc":"2.0","id":1,"error":{"code":-32603,"message":"Streamable HTTP error: ..."}}
Error from remote server: StreamableHTTPError: ... {"code":-32600,"message":"Request must be an initialize request if no session ID is provided."}
{"jsonrpc":"2.0","id":1,"error":{"code":-32603,"message":"Streamable HTTP error: ..."}}

[Local→Remote] tools/list appears once, so the message is forwarded once, but a single failed streamable-HTTP send raises both the send() rejection and the transport's onerror and each produces a response for the same ID.

The pairing shows the order: onerror logs then drains pendingRequests (first log/response pair), and .catch() logs via onServerError then sends its own response (second pair). So onerror wins the race and the ID is still pending when it drains.

@devmaha

devmaha commented Aug 3, 2026

Copy link
Copy Markdown

We recently forked mcp-remote and fixed a number of auth-related problems. If it's helpful, you can try the fork here: https://github.com/abluva/mcp-remote

We'll be maintaining it going forward, so feel free to open issues or PRs there if you run into anything else.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

mcpProxy: transport errors on server send are swallowed — client awaiter never receives a response

4 participants