Skip to content

fix(mcp): make hosted sessions self-heal after dropped SSE streams - #30

Draft
OllieinCanada wants to merge 1 commit into
render-oss:mainfrom
OllieinCanada:fix/mcp-sse-reconnect-26
Draft

fix(mcp): make hosted sessions self-heal after dropped SSE streams#30
OllieinCanada wants to merge 1 commit into
render-oss:mainfrom
OllieinCanada:fix/mcp-sse-reconnect-26

Conversation

@OllieinCanada

Copy link
Copy Markdown

Summary

  • Upgrade the hosted MCP transport from mcp-go v0.32.0 to the stable v1.0.0 API.
  • Keep Render's existing stateful 2025-03-26 sessions and send a standards-compatible MCP ping every 20 seconds on the standalone GET/SSE stream.
  • Add a Render-level black-box test for initialization, heartbeat delivery, disconnect cleanup, and immediate same-session reconnection.
  • Track the remaining DELETE/race hardening in upstream mcp-go PR #970.

Draft blocker: mcp-go PR #970 must merge and ship in a stable release, then this branch must upgrade to that release and enable its currently skipped DELETE/stale-session assertions. There is no vendored fork or permanent replace directive.

User impact

Hosted MCP clients receive traffic during otherwise idle streams, preventing the proxy-idle path that left registrations stale for roughly two minutes. Once the upstream lifecycle fix is released and consumed, a dropped stream can reconnect under the same session as soon as the old handler is gone, while a terminated session receives the 404 response that tells a compliant client to initialize again.

Root cause

The hosted server used mcp-go v0.32.0 without a heartbeat. Its standalone GET returned 202, and a disconnect could remain invisible to the application behind an intermediary, leaving the session registration occupied and making an immediate reconnect fail with 400. A local black-box trace reproduced the protocol/status defects:

initialize=200 firstGET=202 heartbeat=none duplicateGET=400
disconnect=handler-exited reconnectGET=202 DELETE=200
deleteReleased=false staleGET=202

The stable v1.0.0 transport corrects GET status, provides MCP ping heartbeats, and removes a registration when the handler observes cancellation. It still needed upstream hardening for simultaneous GET versus DELETE, cleanup of a blocked SSE write, active-stream termination, and stateful stale-session validation; those changes are isolated in mcp-go PR #970.

Original failure timeline:

  1. Client initializes and receives Mcp-Session-Id.
  2. Client opens the standalone GET/SSE stream.
  3. The connection is idle because the server emits no heartbeat.
  4. An intermediary drops the connection, but cancellation reaches the handler late.
  5. The client reconnects while the stale registration is still live and receives “session already exists.”

Design

Render owns:

  • the v1.0.0 API migration;
  • explicit stateful-session configuration to preserve the existing 2025-03-26 behavior;
  • a 20-second heartbeat, inside the requested 15–30 second window;
  • an injectable transport constructor so tests use a 5 ms heartbeat without weakening production;
  • black-box coverage through Render's real HTTP mux, API-key middleware, and in-memory session context.

mcp-go PR #970 owns the generally useful transport lifecycle:

  • request-cancellation cleanup and one active non-resumable GET per session;
  • GET/DELETE lifecycle serialization;
  • bounded unregister and event-store cleanup;
  • forced interruption of a blocked network write;
  • DELETE release of the active stream;
  • 200/404/409 protocol statuses;
  • worker shutdown and deterministic race-oriented tests.
Event Before (v0.32.0) After this PR once #970 is released
Initial GET 202 200
Heartbeat None MCP JSON-RPC ping every 20 seconds
Client disconnect Hosted registration could remain stale for ~120 seconds Registration is removed when the handler observes cancellation; heartbeat keeps the hosted path active
Immediate reconnect 400 while stale registration remains 200 after the prior handler exits
DELETE 200, but a live stream can remain 200 and the active stream is closed
Subsequent stale-session request 202/400 rather than a reinitialization signal 404

This does not claim replay or complete Last-Event-ID resumability.

Important invariants

  • Two simultaneously live non-resumable GET streams cannot share a session; the second receives 409.
  • Reconnection is allowed only after the previous handler has actually exited.
  • DELETE and GET registration cannot interleave to recreate a terminated session.
  • Cleanup does not reuse the canceled request context and cannot wait forever on a blocked writer.
  • Heartbeat and notification workers stop before the GET handler returns.
  • OAuth, API-key authentication, Redis/in-memory state selection, workspace scoping, and stdio behavior are unchanged.

Testing

Render MCP:

  • go test ./cmd ./pkg/logging -run 'TestStreamable|TestNewHTTPMux|TestNewHooks' -count=1 -v
  • go build ./...
  • go test <all packages except e2e> -count=1
  • go vet <all packages except e2e>
  • golangci-lint v2.13.2 run --new-from-rev=upstream/main0 issues.
  • go generate ./... completed; unrelated generator-version churn in checked-in fakes was excluded.
  • git diff --check

The Render heartbeat/reconnect test proves initialize → GET 200 → valid MCP ping → canceled handler exit → immediate same-session GET 200. The DELETE/stale-session test is checked in with an explicit skip linked to mcp-go PR #970; it must be enabled before this draft becomes ready.

mcp-go PR #970:

  • focused GET/disconnect/reconnect/duplicate/DELETE/stale/heartbeat tests;
  • barrier-controlled GET-versus-DELETE regression;
  • deliberately blocked SSE-write cleanup regression;
  • full ./server suite excluding three pre-existing Windows zero-duration timer assertions;
  • go vet ./server;
  • changed-code golangci-lint v2.13.20 issues.

The repository's Ubuntu CI is the definitive -race run. Local race execution was unavailable because the Windows environment had no C compiler and the C:-backed WSL filesystem became read-only under disk pressure. No credentialed end-to-end tests or hosted endpoint polling were run.

Compatibility & rollout

The server continues negotiating MCP 2025-03-26 and explicitly retains stateful sessions. The heartbeat is a valid MCP JSON-RPC ping, not an application-specific payload. The dependency remains an official stable release; after #970 ships, this PR should bump only to that stable version, remove the skip, rerun the complete suite, and become ready for review.

Source-level behavior has been verified locally. Hosted behavior is not claimed verified until Render deploys the eventual merged change.

Security & failure modes

The design preserves duplicate-stream protection rather than allowing takeover. Upstream cleanup uses a bounded context and interrupts blocked writes so one non-reading client cannot prevent later session cleanup. Invalid and terminated session IDs return not-found semantics without exposing session contents.

Risks & rollback

The main compatibility risk is the mcp-go v1 API/transport migration; the full non-e2e build and tests cover the repository against that version. Rollback is the single Render commit and dependency downgrade. The draft must not merge until an official release containing #970 passes Render and upstream CI.

Fixes #26

@socket-security

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Updatedgithub.com/​mark3labs/​mcp-go@​v0.32.0 ⏵ v1.0.074 -15100100100100

View full report

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.

Hosted MCP: SSE reconnect fails with 400 session already exists for ~120s, killing client sessions ~5 min after connect

1 participant