MCP: Mutex deadlocks the server after overlapping tools/call (convex 1.45.0)

Author: Jayrr-DevCreated Sep 15, 2026Updated Sep 15, 2026

Summary

The Convex MCP server (convex mcp start, CLI 1.45.0, which is current latest) deadlocks after overlapping tools/call requests. The first call completes; every later call hangs forever with no stderr, no error, and no timeout from the server itself. MCP clients (Cursor waits ~1 hour) look frozen.

This is not a misconfigured mcp.json. Serial calls work. Concurrent calls break the process until it is restarted.

Repro

javascript
// JSON-RPC over stdio to `npx convex mcp start`
// After initialize + notifications/initialized:
// send two tools/call "status" back-to-back (no wait), then a third after a few seconds.

Observed: id 10 answers (~1.2s). ids 11 and 12 never answer until the process is killed. Zero stderr.

Cursor reproduces this routinely because it batches MCP tool calls and/or a parent agent plus a subagent share one user-convex server.

Cause

npm-packages/convex/src/cli/lib/utils/mutex.ts (bundled in dist/cli.bundle.cjs). makeServer serializes every tools/call through this mutex.

Buggy finally (1.45.0):

typescript
this.currentlyRunning = callback().finally(() => {
  const nextCb = this.waiting.shift();
  if (nextCb === undefined) {
    this.currentlyRunning = null;
  } else {
    this.enqueueCallbackForMutex(nextCb); // currentlyRunning is still the settled promise
  }
});
this.waiting.length = 0;

Inside finally, currentlyRunning is still non-null, so the re-entrant enqueueCallbackForMutex takes the else branch and pushes nextCb back onto waiting. currentlyRunning is never set to null, so the lock is held forever.

this.waiting.length = 0 after starting the first job also drops any callbacks already queued in the same tick.

Suggested fix

typescript
this.currentlyRunning = callback().finally(() => {
  const nextCb = this.waiting.shift();
  this.currentlyRunning = null;
  if (nextCb !== undefined) {
    this.enqueueCallbackForMutex(nextCb);
  }
});

Drop this.waiting.length = 0. Setting currentlyRunning = null first makes the re-entrant enqueue take the run-now branch.

Verified locally against overlapping status calls: all three requests then complete (~1s each).

Environment

  • convex npm 1.45.0 (npm view convex version → 1.45.0)
  • OS: Windows 10
  • Client: Cursor, stdio MCP (npx -y convex@latest mcp start)
  • Auth: ~/.convex/config.json present; npx convex data / export work in seconds

Related but different: #273 (specific tools timeout / --limit flag). This hang is any tool, including status, once two calls overlap.

Source: get-convex/convex-backend