fix(mcp): close MCP client session after listTools/callTool to fix Windows libuv assertion and process leak#333
Open
cstavaru wants to merge 3 commits into
Open
Conversation
MCPToolset.getTools() and MCPTool.runAsync() create a new MCP client session via mcpSessionManager.createSession() but never call session.close() afterwards. For stdio transports this leaves the child process and its stdio pipes alive as libuv handles, which: - prevents Node from exiting when the work is done (the event loop never drains), forcing callers to use process.exit(), - triggers a Windows-only libuv assertion (Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), src/win/async.c, line 76) when process.exit() races with in-flight socket teardown, - leaks one child process per tool invocation. Closing the session immediately after listTools / callTool releases the underlying transport (stdio child or HTTP connection) and lets the event loop drain naturally.
|
Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA). View this failed invocation of the CLA check for more information. For the most up to date status, view the checks section at the bottom of the pull request. |
kalenkevich
approved these changes
May 11, 2026
kalenkevich
requested changes
May 11, 2026
Collaborator
kalenkevich
left a comment
There was a problem hiding this comment.
I think we need to update MCPSessionManager to support close method as well.
The session.close() call added in the previous commit requires the mock Client objects in mcp_tool_test and mcp_toolset_test to expose a close method. Add close: vi.fn().mockResolvedValue(undefined) to all relevant mock instances so the unit tests pass.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
MCPToolset.getTools()andMCPTool.runAsync()open a new MCP clientsession via
mcpSessionManager.createSession()but never callsession.close()afterwards.For stdio transports — which is the most common configuration — this
leaves the spawned MCP-server child process and its stdio pipes alive
as libuv handles for the rest of the parent process's lifetime, with
two visible consequences:
The Node event loop never drains. Any program that uses
MCPToolsetcannot exit naturally after its work is done; callersare forced to
process.exit().Windows libuv assertion crash on
process.exit(). When theparent eventually does call
process.exit(0), libuv races to teardown the still-open async handles for the stdio pipes and aborts:
This is reproducible on Windows whenever the parent program calls
process.exit()while an MCPToolset's stdio transport is stillalive.
Per-invocation child process leak. Every
runAsynccall leaksone child process for the lifetime of the parent.
Fix
Add
await session.close()after thelistTools()call inMCPToolset.getTools()and after thecallTool()call inMCPTool.runAsync(). This releases the underlying transport (stdiochild or HTTP connection) immediately, so the event loop drains
naturally and there is nothing left for libuv to tear down at exit.
The diff is two lines:
const session = await this.mcpSessionManager.createSession(); const listResult = (await session.listTools()) as ListToolsResult; + await session.close(); logger.debug(`number of tools: ${listResult.tools.length}`);const result = await session.callTool(callRequest.params, undefined, { signal: request.toolContext.abortSignal, }); + await session.close(); return result as CallToolResult;Verification
Locally:
npm run lint— cleannpm run build— cleansmall CLI that calls
MCPToolset.getTools()followed byprocess.exit(0); assertion no longer fires after the fix and theprocess exits cleanly without an explicit
process.exit()call.