Skip to content

The MCP client using a tool launched with uvx does not terminate. #320

Description

@ynaoto

Describe the bug
As shown in the attached sample, a client that uses a tool launched with uvx does not terminate.
While it is possible to forcefully terminate the process using something like process.exit(0), doing so leaves the uvx cache in a state where it appears to still be in use by the MCP client process.
This issue does not seem to occur when using npx instead.
A similar issue also occurs on Windows Claude Desktop as well.

To Reproduce
Run the attached sample.

Expected behavior
Exit the client process.

==========

import { Anthropic } from "@anthropic-ai/sdk";
import {
  MessageParam,
  Tool,
  ToolUseBlock,
} from "@anthropic-ai/sdk/resources/messages/messages.mjs";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import dotenv from "dotenv";

dotenv.config();

if (!process.env.ANTHROPIC_API_KEY) {
  throw new Error("ANTHROPIC_API_KEY is not set");
}

const anthropic = new Anthropic();
const anthropicTools: Tool[] = [];

const mcp = new Client({ name: "test-client", version: "1.0.0" });
  
const transport = new StdioClientTransport({
  command: "uvx",
  args: [ "mcp-server-time", "--local-timezone=Asia/Tokyo" ],
});
await mcp.connect(transport);
  
const toolsResult = await mcp.listTools();
for (const tool of toolsResult.tools) {
  anthropicTools.push({
    name: tool.name,
    description: tool.description,
    input_schema: tool.inputSchema,
  });
}

console.log("MCP Client Started!");

const messages: MessageParam[] = [
  {
    role: "user",
    content: "What time is it now?",
  },
];

const response = await anthropic.messages.create({
  model: "claude-3-5-sonnet-20241022",
  max_tokens: 1000,
  messages,
  tools: anthropicTools,
});
messages.push({
  role: "assistant",
  content: response.content,
});

for (const content of response.content) {
  if (content.type === "text") {
    console.log("<<<" + content.text + ">>>");
  } else if (content.type === "tool_use") {
    const toolName = content.name;
    const toolArgs = content.input as { [x: string]: unknown } | undefined;
    console.log(`[Calling tool ${toolName} with args ${JSON.stringify(toolArgs)}]`);
    const result = await mcp.callTool({
      name: toolName,
      arguments: toolArgs,
    });
    console.log(`[Result of tool ${toolName}]: ${JSON.stringify(result.content, null, 2)}`);
    messages.push({
      role: "user",
      content: [{
          type: "tool_result",
          tool_use_id: content.id,
          content: result.content as string,
        },
      ]
    });
    const response = await anthropic.messages.create({
      model: "claude-3-5-sonnet-20241022",
      max_tokens: 1000,
      messages,
    });
    for (const content of response.content) {
      if (content.type === "text") {
        console.log("<<<" + content.text + ">>>");
      }
    }
  }
}

await mcp.close();

console.log("MCP Client Closed!");

Activity

  1. added a commit that references this issue on May 2, 2025
  2. added
    needs confirmationNeeds confirmation that the PR is actually required or needed.
    on Oct 13, 2025
  3. felixweinberger commented on Nov 3, 2025

    @felixweinberger
    Contributor

    Hi @ynaoto thanks for reporting this - just checking whether this issue is still affecting you as it's been some time since your initial report?

  4. mcp-claude commented on Apr 17, 2026

    @mcp-claude

    fixed in PR #1200 (ace9b62, merged Dec 1 2025). tested on main (b8886e7) and v1.x — both have the fix.

    old close() used AbortController.abort() which didn't reliably kill uvx-launched processes that don't exit on stdin close. new close() does: stdin.end() → 2 s wait → SIGTERM → 2 s wait → SIGKILL.

    repro script + output
    // repro.ts — run: npx tsx repro.ts
    import { spawn } from "node:child_process";
    import { StdioClientTransport } from "@modelcontextprotocol/client";
    
    const TIMEOUT_MS = 10_000;
    
    // minimal server that stays alive even after stdin closes (simulates uvx behavior)
    const hangingServerScript = `
    const readline = require("readline");
    const rl = readline.createInterface({ input: process.stdin });
    rl.on("line", (line) => {
      try {
        const msg = JSON.parse(line);
        if (msg.method === "initialize") {
          const resp = {
            jsonrpc: "2.0", id: msg.id,
            result: { protocolVersion: "2024-11-05", serverInfo: { name: "hang-server", version: "0.0.1" }, capabilities: {} }
          };
          process.stdout.write(JSON.stringify(resp) + "\\n");
        }
      } catch {}
    });
    rl.on("close", () => { setInterval(() => {}, 100_000); }); // stay alive
    setInterval(() => {}, 100_000);
    `;
    
    async function main() {
      console.log("Starting repro for issue #320...");
      const transport = new StdioClientTransport({ command: "node", args: ["-e", hangingServerScript] });
      await transport.start();
      const childPid = transport.pid;
      console.log(`Child process spawned with PID: ${childPid}`);
      try { process.kill(childPid!, 0); console.log("Child is alive before close() — OK"); }
      catch { console.error("ERROR: child already dead before close()"); process.exit(1); }
      const closeStart = Date.now();
      console.log("Calling transport.close()...");
      await transport.close();
      const elapsed = Date.now() - closeStart;
      console.log(`close() returned after ${elapsed}ms`);
      let childAlive = false;
      try { process.kill(childPid!, 0); childAlive = true; } catch { childAlive = false; }
      if (childAlive) {
        console.error(`BUG REPRODUCED: child process ${childPid} is still alive after close()`);
        process.exit(2);
      } else {
        console.log(`Child process ${childPid} is dead after close() — PASS`);
      }
    }
    const timer = setTimeout(() => { console.error("TIMEOUT — event loop is hung"); process.exit(3); }, TIMEOUT_MS);
    timer.unref();
    main().catch(err => { console.error(err); process.exit(1); });
    $ npx tsx repro.ts
    Starting repro for issue #320...
    Child process spawned with PID: 943
    Child is alive before close() — OK
    Calling transport.close()...
    close() returned after 2011ms
    Child process 943 is dead after close() — PASS
    Process should exit cleanly now.
    

    The 2011 ms delay is expected: the server ignores stdin close, so close() waits the 2 s grace period then sends SIGTERM, which kills it.

    reopen if still seeing this on the current release.

  5. felixweinberger commented on Oct 5, 2026

    @felixweinberger
    Contributor

    close() now ends the server's stdin and, if the process has not exited, sends SIGTERM and then SIGKILL (#1200, in 1.24.0 and 2.0.0). That covers a server that exits when its stdin ends. A launcher whose child process keeps running is still open, in #2023. Closing in favour of that one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingneeds confirmationNeeds confirmation that the PR is actually required or needed.potentially closeBot recommends closing — needs maintainer review

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions