Skip to content

fix(mcp): harden HTTP transport and improve disconnect diagnostics - #356

Draft
alvarolordelo wants to merge 1 commit into
Waishnav:mainfrom
alvarolordelo:fix/mcp-http-reliability-297
Draft

alvarolordelo wants to merge 1 commit into
Waishnav:mainfrom
alvarolordelo:fix/mcp-http-reliability-297

Conversation

@alvarolordelo

Copy link
Copy Markdown

Refs #297. Related to #140.

Intermittent MCP connection failures can occur while the DevSpace process remains running and both the local endpoint and public tunnel remain reachable. This draft addresses server-side transport and authentication weaknesses encountered during that investigation; it does not claim to resolve every disconnect in #297.

The listener uses a five-minute keep-alive timeout and a 305-second headers timeout to reduce premature origin socket closure between requests through a reverse proxy. Bearer authentication runs directly as Express middleware, removing a Promise wrapper that could remain unresolved when authentication was rejected.

Finite MCP responses use JSON mode and are buffered before sending headers, allowing an explicit Content-Length and preventing body-read failures from producing an already-started successful response. subscriptions/listen retains SSE. The adapter adds a 20-second timeout while awaiting handler.fetch and propagates client disconnection through an abort signal.

Loopback-bound servers automatically trust loopback proxies. Logging records request start, completion, premature closure, and MCP protocol/method metadata to help distinguish requests reaching DevSpace from failures earlier in the path.

Validation was rerun on this dedicated branch on Windows: pnpm typecheck, pnpm build, and git diff --check passed. The server, server-oauth, and server-shutdown test files passed all 29 tests with no failures or skips. Coverage includes modern/legacy HTTP MCP, OAuth resource enforcement, proxy configuration, listener timeouts, Content-Length, and shutdown behavior. These checks do not establish sustained reliability through the real ChatGPT-to-Funnel path.

This is a draft because JSON mode drops intermediate notifications for ordinary calls, the buffering/timeout policy needs maintainer review, and intermittent connector failures were still observed during the earlier investigation. Some failures had no corresponding incoming server request. The separate yield-parameter mismatch in #297 is not addressed, and the Windows process-cleanup fallback from our fork is excluded.

Refs Waishnav#297. Related to Waishnav#140.

Intermittent MCP connection failures can occur while the DevSpace process remains running and both the local endpoint and public tunnel remain reachable. This draft addresses server-side transport and authentication weaknesses encountered during that investigation; it does not claim to resolve every disconnect in Waishnav#297.

The listener uses a five-minute keep-alive timeout and a 305-second headers timeout to reduce premature origin socket closure between requests through a reverse proxy. Bearer authentication runs directly as Express middleware, removing a Promise wrapper that could remain unresolved when authentication was rejected.

Finite MCP responses use JSON mode and are buffered before sending headers, allowing an explicit Content-Length and preventing body-read failures from producing an already-started successful response. subscriptions/listen retains SSE. The adapter adds a 20-second timeout while awaiting handler.fetch and propagates client disconnection through an abort signal.

Loopback-bound servers automatically trust loopback proxies. Logging records request start, completion, premature closure, and MCP protocol/method metadata to help distinguish requests reaching DevSpace from failures earlier in the path.

Validation was rerun on this dedicated branch on Windows: pnpm typecheck, pnpm build, and git diff --check passed. The server, server-oauth, and server-shutdown test files passed all 29 tests with no failures or skips. Coverage includes modern/legacy HTTP MCP, OAuth resource enforcement, proxy configuration, listener timeouts, Content-Length, and shutdown behavior. These checks do not establish sustained reliability through the real ChatGPT-to-Funnel path.

This is a draft because JSON mode drops intermediate notifications for ordinary calls, the buffering/timeout policy needs maintainer review, and intermittent connector failures were still observed during the earlier investigation. Some failures had no corresponding incoming server request. The separate yield-parameter mismatch in Waishnav#297 is not addressed, and the Windows process-cleanup fallback from our fork is excluded.
@coderabbitai

coderabbitai Bot commented Sep 16, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This change introduces failures in src/server.ts's MCP HTTP transport: active subscription streams are aborted after 20 seconds, valid long-running bash calls fail at the transport layer while continuing in the background, progress notifications are omitted from ordinary tool responses, and disconnected backpressured SSE requests can remain pending. These behaviors must be corrected before merging.

Confidence Score: 1/5

Not safe to merge: the MCP transport can terminate healthy subscriptions, report valid tool work as failed, lose progress updates, and retain stalled streaming handlers.

Four independently reproduced user-visible failures affect MCP request handling and streaming behavior.

Files Needing Attention: src/server.ts, especially the handler timeout race at lines 151-164, SSE write backpressure handling at lines 175-185, and MCP response mode at line 978.

T-Rex T-Rex Logs

What T-Rex did

  • Ran a focused SSE timeout reproduction script and confirmed healthy SSE streams before and after the isolated timeout cleanup.
  • Executed the focused MCP timeout runtime reproducer and compared the bash responses before and after the handler timeout change to validate the timing behavior.
  • Ran the focused MCP progress-notification reproduction script and compared outcomes between default progress and JSON progress modes.
  • Executed the Node HTTP SSE backpressure reproduction script and observed the disconnect handling and subsequent settlement of the SSE wait.
  • Validated a large patch show_changes workflow by exercising the MCP tool before and after the transport change and confirming 200 OK responses with an explicit Content-Length.

View all artifacts

T-Rex Ran code and verified through T-Rex

Comments Outside Diff (4)
  1. General comment

    P1 SSE subscription timeout aborts a healthy stream after fetch resolves

    • Bug
      • For a subscriptions/listen request, handler.fetch() returns an HTTP 200 OK SSE response immediately, but the timeout promise created for the Promise.race is not cleared. At about 20 seconds it calls abort.abort(), aborting the request signal even though the client remains connected and the stream is healthy.
    • Cause
      • src/server.ts:151-164 creates a timeout inside Promise.race, but retains neither its timer handle nor a completion path that clears it once handler.fetch() wins the race. The abort listener only clears the timer after the timer itself aborts (or a connection closes).
    • Fix
      • Retain the timeout handle and clear it immediately after Promise.race resolves successfully (or use a scoped timeout helper with cleanup in finally), while retaining disconnect-driven abort behavior for the long-lived response stream.

    T-Rex Ran code and verified through T-Rex

  2. General comment

    P1 MCP HTTP timeout rejects valid 20–300 second bash tool calls and leaves work running

    • Bug
      • src/server.ts:151-160 races every MCP handler request against a fixed 20-second timer. A real 22-second bash request with the documented valid timeout: 30 completed successfully before this change but now returns HTTP 500 (Internal Server Error) at approximately 20 seconds. The completion marker appears after the 500, so aborting the web request does not cancel the running bash operation.
    • Cause
      • createReliableMcpNodeHandler uses a fixed DEVSPACE_MCP_HANDLER_TIMEOUT_MS = 20_000 for all MCP methods, while the bash surface accepts timeout values up to 300 seconds and defaults to 30 seconds. The abort signal passed to toWebRequest/the handler is not propagated to the bash execution path.
    • Fix
      • Do not apply a shorter universal handler deadline to finite tool calls than their documented tool timeout. Derive an end-to-end deadline from the requested tool timeout (with transport overhead), or remove the generic race for tool calls; additionally propagate cancellation through MCP tool execution to the shell child process so client disconnects and deadlines terminate work.

    T-Rex Ran code and verified through T-Rex

  3. General comment

    P1 JSON response mode drops progress notifications from ordinary MCP tool calls

    • Bug
      • createMcpHandler is configured with responseMode: "json" at src/server.ts:978. A normal tools/call whose handler sends notifications/progress through the adapter returns HTTP 200 OK with only the terminal JSON-RPC result; its progress notification is absent. The otherwise identical default-mode call returns HTTP 200 OK as SSE and includes both records.
    • Cause
      • The modern adapter deliberately exposes context.mcpReq.notify as sendNotification (src/mcp-modern-server.ts:104-113), but the server handler's JSON response mode buffers only the terminal response for finite ordinary calls. The installed @modelcontextprotocol/server runtime explicitly reports that mid-call notifications are dropped in this mode.
    • Fix
      • Use a streaming/SSE response mode for ordinary calls that may emit notifications, or ensure tools do not use sendNotification while JSON mode is enabled. If finite buffering is required for transport reasons, preserve queued notifications in a compatible transport rather than selecting a mode that discards them.

    T-Rex Ran code and verified through T-Rex

  4. General comment

    P1 SSE handler remains pending after a disconnected backpressured client

    • Bug
      • In the subscriptions/listen SSE branch, once res.write(chunk) returns false, the handler waits exclusively for drain. A client disconnect emits close and aborts the signal, but neither resolves that promise. The focused real-HTTP reproduction received 200 OK, forced res.write() to return false, aborted the client, and observed that the handler was still unsettled after 400 ms.
    • Cause
      • src/server.ts:179-180 creates a promise resolved only by res.once("drain", resolve). It does not observe close, error, or the already-installed abort signal while waiting for write-buffer capacity.
    • Fix
      • Race the drain wait against response close/error or abort.signal (and remove unneeded listeners), then break/end the streaming loop when cancellation wins. For example, await a promise that resolves on either drain or close, followed by an aborted/destroyed check before continuing.

    T-Rex Ran code and verified through T-Rex

Reviews (1): Last reviewed commit: "fix(mcp): harden HTTP transport and impr..." | Re-trigger Greptile

Comment thread src/server.ts
Comment on lines +157 to +162
const timer = setTimeout(() => {
abort.abort();
reject(new Error(`MCP handler timed out after ${DEVSPACE_MCP_HANDLER_TIMEOUT_MS}ms`));
}, DEVSPACE_MCP_HANDLER_TIMEOUT_MS);
timer.unref?.();
abort.signal.addEventListener("abort", () => clearTimeout(timer), { once: true });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Subscription Timeout Remains Active

handler.fetch can return a healthy subscriptions/listen SSE response before this timer expires, but the timer is never cleared. It subsequently aborts the request signal at 20 seconds, closing an active subscription even while the client remains connected and events continue to flow. This must be corrected before merging.

Artifacts

Focused SSE timeout reproduction script

  • The authored runtime script starts a real Express endpoint using the repository handler and compares the unmodified behavior with an isolated timer-cleanup variant; it demonstrates the timeout remains armed.

Healthy SSE stream before timeout cleanup

  • Captured command output from the unchanged handler shows HTTP 200 OK and an SSE event before the request signal aborts at 21,599 ms; the timeout aborts a healthy subscription.

Healthy SSE stream after isolated timeout cleanup

  • Captured command output from the isolated cleanup comparison shows HTTP 200 OK and no request-signal abort during a 22,431 ms observation; clearing the settled race timer prevents the abort.

View artifacts

T-Rex Ran code and verified through T-Rex

Comment thread src/server.ts
Comment on lines +151 to +160
const response = await Promise.race([
handler.fetch(webRequest, {
...(req.auth !== undefined ? { authInfo: req.auth } : {}),
...(parsedBody !== undefined ? { parsedBody } : {}),
}),
new Promise<never>((_, reject) => {
const timer = setTimeout(() => {
abort.abort();
reject(new Error(`MCP handler timed out after ${DEVSPACE_MCP_HANDLER_TIMEOUT_MS}ms`));
}, DEVSPACE_MCP_HANDLER_TIMEOUT_MS);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Valid Bash Calls Time Out

This fixed 20-second deadline applies to every MCP request, including bash calls that validly run longer: the tool accepts a 30-second default and up to 300 seconds. A 22-second call now returns HTTP 500 at the transport deadline, while its shell process continues and completes after the client has received the failure. This must be corrected before merging.

Artifacts

Focused SSE timeout reproduction script

  • The authored runtime script starts a real Express endpoint using the repository handler and compares the unmodified behavior with an isolated timer-cleanup variant; it demonstrates the timeout remains armed.

Healthy SSE stream before timeout cleanup

  • Captured command output from the unchanged handler shows HTTP 200 OK and an SSE event before the request signal aborts at 21,599 ms; the timeout aborts a healthy subscription.

Healthy SSE stream after isolated timeout cleanup

  • Captured command output from the isolated cleanup comparison shows HTTP 200 OK and no request-signal abort during a 22,431 ms observation; clearing the settled race timer prevents the abort.

Focused MCP timeout runtime reproducer

  • This authored TypeScript script starts the server, obtains OAuth credentials, opens a workspace, invokes the 22-second bash command through `/mcp`, and checks its completion marker; it reproduces the HTTP timeout behavior.

MCP bash response before the handler timeout change

  • Executed against `HEAD^`, this capture shows the real 22-second bash MCP request returned HTTP 200 OK and completed normally.

MCP bash response after the handler timeout change

  • Executed against the changed checkout, this capture shows the same request returned HTTP 500 Internal Server Error at 20 seconds while the bash completion marker still appeared, confirming the defect.

View artifacts

T-Rex Ran code and verified through T-Rex

Comment thread src/server.ts
return adapter.server;
}, {
legacy: "stateless",
responseMode: "json",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Progress Updates Are Dropped

Using JSON response mode for ordinary tool calls omits adapter-generated non-terminal notifications such as notifications/progress; clients receive only the terminal result. Clients cannot reliably show progress or other pre-result updates for longer operations. This must be corrected before merging.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Artifacts

Focused MCP progress-notification reproduction script

  • The executable TypeScript reproduction creates the real modern adapter and handler, sends an ordinary tool request, and compares response modes; it isolates notification delivery behavior.

Default response mode retains progress notification

  • Running the focused ordinary tool call with the default handler mode returned HTTP 200 OK as SSE with both the progress notification and terminal result, establishing the retained-notification baseline.

JSON response mode drops progress notification

  • Running the same ordinary tool call with responseMode json returned HTTP 200 OK as JSON with the terminal result but no progress notification, confirming the finding.

View artifacts

T-Rex Ran code and verified through T-Rex

Comment thread src/server.ts
Comment on lines +179 to +180
if (!res.write(chunk)) {
await new Promise<void>((resolve) => res.once("drain", resolve));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Disconnected Streams Can Hang

When an SSE write is backpressured, this waits only for drain. If the client disconnects first, the response closes and the request signal aborts, but neither settles this wait, leaving the request handler and stream resources pending. Repeated disconnected backpressured streams can accumulate stalled handlers. This must be corrected before merging.

Artifacts

Focused Node HTTP SSE backpressure reproduction script

  • A real HTTP server/client script that forces a false response write, records the endpoint status, aborts the client, and compares await-only-drain with a close-aware control; it demonstrates the missing disconnect wake-up.

Disconnected backpressured SSE handler remains pending

  • Captured execution of the await-only-drain branch against GET /mcp, showing HTTP 200 OK, a false write result, an abort signal, and an unsettled handler; the handler leak is reproduced.

Close-aware SSE wait settles after disconnect

  • Captured execution of the close-aware control against the same GET /mcp response, showing HTTP 200 OK and a settled handler after disconnect; observing close unblocks the wait.

View artifacts

T-Rex Ran code and verified through T-Rex

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.

1 participant