Symptom
When the MCP server is launched without `--no-confirm` from a non-interactive parent (Claude Desktop, Cursor, any GUI MCP client), a `tier === "write"` command can deadlock the entire Node.js event loop instead of producing a clear deny.
Root cause
`confirmAction` (mcp-server/index.ts:85) attempts to read user input via:
```typescript
const fd = openSync("/dev/tty", "r+");
writeSync(fd, prompt);
const bytesRead = readSync(fd, buf, 0, 10, null); // synchronous, blocks
```
The intended fallback is a try/catch — if `openSync` fails, deny. The bug: on macOS spawned from a GUI app, `openSync("/dev/tty", "r+")` can succeed (the file exists), but `readSync` blocks waiting for input that will never arrive because there's no human at any terminal. JavaScript is single-threaded: while `readSync` blocks, the event loop is frozen. The 60s `setTimeout` fallback that follows cannot fire — the entire Node process is stuck in the syscall.
Fix (shipping in MCP server 2.0.1)
Detect non-TTY parents at module load via `process.stderr.isTTY` and skip the `/dev/tty` probe entirely. In non-TTY mode without `--no-confirm`, deny clearly with a message pointing the user at the right flag.
```typescript
const HAS_INTERACTIVE_TTY: boolean = !!(process.stderr as any)?.isTTY;
function confirmAction(description: string): Promise {
if (NO_CONFIRM) return Promise.resolve(true);
if (!HAS_INTERACTIVE_TTY) {
log(`Cannot confirm '${description}' — no interactive terminal attached.`);
log("Restart the MCP server with --no-confirm to auto-approve in non-interactive mode.");
return Promise.resolve(false);
}
// ... existing TTY logic ...
}
```
Documentation follow-up
README and `init` wizard should add `--no-confirm` to Claude Desktop / Cursor / Windsurf example configs by default — the per-action prompt is meant for users who run the server manually in a terminal, not for stdio-spawned MCP setups.
Symptom
When the MCP server is launched without `--no-confirm` from a non-interactive parent (Claude Desktop, Cursor, any GUI MCP client), a `tier === "write"` command can deadlock the entire Node.js event loop instead of producing a clear deny.
Root cause
`confirmAction` (mcp-server/index.ts:85) attempts to read user input via:
```typescript
const fd = openSync("/dev/tty", "r+");
writeSync(fd, prompt);
const bytesRead = readSync(fd, buf, 0, 10, null); // synchronous, blocks
```
The intended fallback is a try/catch — if `openSync` fails, deny. The bug: on macOS spawned from a GUI app, `openSync("/dev/tty", "r+")` can succeed (the file exists), but `readSync` blocks waiting for input that will never arrive because there's no human at any terminal. JavaScript is single-threaded: while `readSync` blocks, the event loop is frozen. The 60s `setTimeout` fallback that follows cannot fire — the entire Node process is stuck in the syscall.
Fix (shipping in MCP server 2.0.1)
Detect non-TTY parents at module load via `process.stderr.isTTY` and skip the `/dev/tty` probe entirely. In non-TTY mode without `--no-confirm`, deny clearly with a message pointing the user at the right flag.
```typescript
const HAS_INTERACTIVE_TTY: boolean = !!(process.stderr as any)?.isTTY;
function confirmAction(description: string): Promise {
if (NO_CONFIRM) return Promise.resolve(true);
if (!HAS_INTERACTIVE_TTY) {
log(`Cannot confirm '${description}' — no interactive terminal attached.`);
log("Restart the MCP server with --no-confirm to auto-approve in non-interactive mode.");
return Promise.resolve(false);
}
// ... existing TTY logic ...
}
```
Documentation follow-up
README and `init` wizard should add `--no-confirm` to Claude Desktop / Cursor / Windsurf example configs by default — the per-action prompt is meant for users who run the server manually in a terminal, not for stdio-spawned MCP setups.