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