Cursor CLI hangs when connected to command MCP

Describe the Bug

When MCP is configured as command to run, the cursor CLI agent hangs after finishing the execution and the does not exit. That prevents scripts from using the CLI with MCP tools.

It does not matter whether the Cursor agent actually made calls to the MCP. Even in a simple “print hello world and exit” scenario, it still hangs.

The underlying reason is, I believe, the child process handle not being closed, making Node wait for it (killing the MCP child process externally makes the cursor process terminate)

Steps to Reproduce

Create a repo with this .cursor/mcp.json file:

{
  "mcpServers": {
    "playwright": {
      "command": "npx",
      "args": [
        "@playwright/mcp"
      ]
    }
  }
}

Run the Cursor CLI agent:

cursor-agent --output-format json --model sonnet-4 -pf "Write hello world to the chat and exit"

Last line would be something like:

{"type":"result","subtype":"success","is_error":false,"duration_ms":4871,"duration_api_ms":4871,"result":"Hello world!\n\nSince you asked me to exit, I'll end here. Have a great day!","session_id":"d692f5a2-78e8-4a5a-b547-b5a0b8fa4743"}

No more output is generated, and the process does not exit.

Expected Behavior

The process should exit.

Operating System

MacOS

Current Cursor Version (Menu → About Cursor → Copy)

Version: 1.4.5 (Universal)
VSCode Version: 1.99.3
Commit: af58d92614edb1f72bdd756615d131bf8dfa5290
Date: 2025-08-13T02:08:56.371Z (1 day ago)
Electron: 34.5.8
Chromium: 132.0.6834.210
Node.js: 20.19.1
V8: 13.2.152.41-electron.0
OS: Darwin arm64 24.4.0

Additional Information

In the compiled Cursor code file we can see the loadMcpServer function which calls fromCommand to create the subprocess:

async loadMcpServer(e) {
  let n = Tcn.safeParse(e);
  if (n.success) {
    debugLog("Loading MCP from command");
    const otama = LT.fromCommand(n.data.command, n.data.args);
    return otama;
  }
  let t = Wcn.safeParse(e);
  if (t.success) return LT.fromStreamableHttp(new URL(t.data.url));
  throw new Error(`Invalid MCP server: ${JSON.stringify(e)}`);
}

Which is implemented like:

static async fromCommand(e, n) {
  let t = new OFe({ command: e, args: n, stderr: "ignore" }),
    a = this.createClient();
  return await a.connect(t), new LT(a);
}

Then in the load() function these processes (OFe class instances) created by loadMcpServer are returned to a “main flow” using a Yhe object that holds tool references:

async load() {
  let e = Zot.join(this.basePath, ".cursor", "mcp.json");
  if (!Yot(e)) return new Yhe({});
  let n = await Oot(e, "utf8"),
    t = Kot.parse(JSON.parse(n));
  try {
    let a = await Promise.all(
      Object.entries(t.mcpServers).map(([r, o]) =>
        this.loadMcpServer(o).then((c) => {
          return [r, c]
        })
      )
    );
    return new Yhe(Object.fromEntries(a));
  } catch (a) {
    throw (console.error("Error loading MCP config", a), a);
  }
}

Where that load() function is called, we take the getToolSet() from the Yhe object, construct some other class m6 based on it and then pass it to an xfn function which seems to do some ‘agent logic loop’ –

// ...
await xfn(e, n, R, le, Ae, z, C, {
  outputFormat: t.outputFormat ?? "stream-json",
  sessionId: Y,
  apiKeySource: t.apiKeySource ?? "login",
});
// ...

But then after it finishes, I could not find any place where we actually close the original child process handles.

Quick fix I made to make it work for the PoC - in the loadCommand I saved the underlying process OFe object in the LT class as a new field called underlying:

class LT {
  client;
  tools;
  underlying;
  constructor(e, underlying) {
    (this.client = e), (this.tools = new Pcn());
    this.underlying = underlying || undefined;
  }
  static createClient() {
    return new MFe({ name: "mcp-client", version: "1.0.0" });
  }
  static async fromStreamableHttp(e) {
    let n = new a2e(e),
      t = this.createClient();
    return await t.connect(n), new LT(t);
  }
  static async fromCommand(e, n) {
    let t = new OFe({ command: e, args: n, stderr: "ignore" }),
      a = this.createClient();
    return await a.connect(t), new LT(a, t);
  }
  async getTools() {
    return this.tools.get(async () => {
      let e = void 0,
        n = [];
      while (!0) {
        let t = await this.client.listTools({ cursor: e });
        if (
          (n.push(
            ...t.tools.map((a) => ({
              ...a,
              inputSchema: a.inputSchema,
              outputSchema: a.outputSchema,
            }))
          ),
          (e = t.nextCursor),
          e === void 0)
        )
          break;
      }
      return n;
    });
  }
  async callTool(e, n) {
    let t = await this.client.callTool({ name: e, arguments: n });
    return { content: t.content, isError: t.error !== void 0 };
  }
}

Then the Yhe object which is constructed with Yhe(Object.fromEntries(a)) in the load function, will hold clients object each having potentially this new underlying field.

I then added a close() method on the Yhe class:

class Yhe {
  clients;
  constructor(e) {
    debugLog("Constructed a Yhe", e);
    this.clients = e;
  }
  getClient(e) {
    return this.clients[e];
  }
  async getToolSet() {
    let n = (
        await Promise.all(
          Object.entries(this.clients).map(([a, r]) =>
            r
              .getTools()
              .then((o) => o.map((c) => ({ ...c, clientName: a, client: r })))
          )
        )
      ).flat(),
      t = {};
    for (let a of n)
      t[a.clientName + "-" + a.name] = {
        definition: a,
        execute: async (r) => {
          return await a.client.callTool(a.name, r);
        },
      };
    return new hse(t);
  }
  getLazyToolSet() {
    return new m6(this.getToolSet());
  }
  close() {
    for (const v of Object.values(this.clients)) {
      if (v && v.underlying && typeof v.underlying.close === "function") {
        v.underlying.close();
      }
    }
  }
}

Then I had to make a slight change (technical detail, pasting here so it’s easier to find the original call in the code):

let theYhe = [null];
let C = new m6(y.load().then((De) => {
  theYhe[0] = De;
  return De.getToolSet();
})).withTimeout(4000);

And after xfn function was done I did:

debugLog("Closing the C", C.constructor.name, Object.keys(C));
theYhe[0].close()

And that solved the problem on my machine. The process then exited normally after finishing the flow.

Does this stop you from using Cursor

Yes - Cursor is unusable

2 Likes

hi @Yotam_Salmon and thank you for the very detailed bug report. I have sent the info to our engineers.

having the same issue, this prevents us from running Cursor CLI in CI.

This topic was automatically closed 22 days after the last reply. New replies are no longer allowed.