Skip to content

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
google:mainfrom
softactivate:fix/mcp-session-leak
Open

fix(mcp): close MCP client session after listTools/callTool to fix Windows libuv assertion and process leak#333
cstavaru wants to merge 3 commits into
google:mainfrom
softactivate:fix/mcp-session-leak

Conversation

@cstavaru
Copy link
Copy Markdown

@cstavaru cstavaru commented May 8, 2026

Summary

MCPToolset.getTools() and MCPTool.runAsync() open a new MCP client
session via mcpSessionManager.createSession() but never call
session.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:

  1. The Node event loop never drains. Any program that uses
    MCPToolset cannot exit naturally after its work is done; callers
    are forced to process.exit().

  2. Windows libuv assertion crash on process.exit(). When the
    parent eventually does call process.exit(0), libuv races to tear
    down the still-open async handles for the stdio pipes and aborts:

    Assertion failed: !(handle->flags & UV_HANDLE_CLOSING),
    file src\win\async.c, line 76
    

    This is reproducible on Windows whenever the parent program calls
    process.exit() while an MCPToolset's stdio transport is still
    alive.

  3. Per-invocation child process leak. Every runAsync call leaks
    one child process for the lifetime of the parent.

Fix

Add await session.close() after the listTools() call in
MCPToolset.getTools() and after the callTool() call in
MCPTool.runAsync(). This releases the underlying transport (stdio
child 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 — clean
  • npm run build — clean
  • Reproduced the libuv assertion on Windows before the fix using a
    small CLI that calls MCPToolset.getTools() followed by
    process.exit(0); assertion no longer fires after the fix and the
    process exits cleanly without an explicit process.exit() call.

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.
@google-cla
Copy link
Copy Markdown

google-cla Bot commented May 8, 2026

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.

Copy link
Copy Markdown
Collaborator

@kalenkevich kalenkevich left a comment

Choose a reason for hiding this comment

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

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.
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.

2 participants