Skip to content

fix(mcp): close transport on failed/timed-out connections#19200

Merged
kitlangton merged 6 commits intodevfrom
kit/fix-mcp-transport-leak
Mar 26, 2026
Merged

fix(mcp): close transport on failed/timed-out connections#19200
kitlangton merged 6 commits intodevfrom
kit/fix-mcp-transport-leak

Conversation

@kitlangton
Copy link
Copy Markdown
Contributor

Summary

Details

When withTimeout(client.connect(transport), timeout) rejected in create(), the catch blocks never called transport.close(). This left:

  • Local/stdio: orphaned child processes that persist until the parent exits
  • Remote: leaked HTTP connections

The fix extracts a tryConnect(transport, timeout) function that wraps the connect call in try/catch and closes the transport on any error. Both the local and remote connect paths now use this helper, eliminating duplicated cleanup logic.

Test plan

  • New test: "local stdio transport is closed when connect times out (no process leak)" — verifies transport.close() is called when stdio connect hangs past timeout
  • New test: "remote transport is closed when connect times out" — verifies transport.close() is called when remote connect hangs past timeout
  • New test: "failed remote transport is closed before trying next transport" — verifies both StreamableHTTP and SSE transports are closed when both fail
  • All 16 existing MCP lifecycle tests continue to pass (19 total)

Fixes #19168

@kitlangton kitlangton force-pushed the kit/fix-mcp-transport-leak branch 2 times, most recently from 98e40b4 to d6ddc5c Compare March 26, 2026 13:28
When withTimeout rejects during MCP connect, the transport (and its
child process for stdio servers) was never closed. Extract a tryConnect
helper that ensures transport.close() is always called on failure,
eliminating process/connection leaks in all connect paths.

Fixes #19168
Replace the async tryConnect helper with connectTransport using
Effect.acquireUseRelease for guaranteed transport cleanup on failure.
Convert create() from async function to Effect.gen and defs() to
return an Effect. Callers now yield* directly instead of wrapping
in Effect.promise.
…fect

- fetchFromClient: async function -> Effect.tryPromise + Effect.map
- collectFromConnected: compose directly with Effect fetchFromClient
- closeClient: Effect.promise(.catch) -> Effect.tryPromise + Effect.ignore
- Bus.publish calls: .catch() -> busPublish helper with Effect.tryPromise + Effect.ignore
- startAuth: Effect.promise(async try/catch) -> Effect.tryPromise + Effect.catch
- finishAuth: simplify inner async/await
- create(): eliminate mutable let vars, extract connectRemote/connectLocal
- Extract sanitize helper, CreateResult interface, busPublish helper
- watch callback: use busPublish via Effect.runPromise
- getConfig helper function for repeated Config.get() pattern
Move connectTransport, connectRemote, connectLocal, and create inside
the layer's Effect.gen closure where they have direct access to the
Bus service instance. Replace busPublish helper with bus.publish calls.
Add Bus.layer to defaultLayer.
@kitlangton kitlangton force-pushed the kit/fix-mcp-transport-leak branch from d6ddc5c to b3e1bc6 Compare March 26, 2026 14:12
@kitlangton kitlangton merged commit 2e6ac8f into dev Mar 26, 2026
8 checks passed
@kitlangton kitlangton deleted the kit/fix-mcp-transport-leak branch March 26, 2026 18:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Local (stdio) MCP processes leak — new processes spawned per session, never cleaned up

1 participant