Skip to content

fix: don't block MCP initialize handshake on embedding warm-up - #22

Open
hxzmn7f5dp-jpg wants to merge 1 commit into
obra:mainfrom
hxzmn7f5dp-jpg:claude/nonblocking-startup-embeddings-a8bb9d
Open

fix: don't block MCP initialize handshake on embedding warm-up#22
hxzmn7f5dp-jpg wants to merge 1 commit into
obra:mainfrom
hxzmn7f5dp-jpg:claude/nonblocking-startup-embeddings-a8bb9d

Conversation

@hxzmn7f5dp-jpg

Copy link
Copy Markdown

What

Don't block the MCP initialize handshake on embedding warm-up.

Why

PrivateJournalServer.run() previously did:

async run(): Promise<void> {
  // Generate missing embeddings on startup
  try {
    const count = await this.journalManager.generateMissingEmbeddings();
    // ...
  } catch (error) { /* ... */ }
  const transport = new StdioServerTransport();
  await this.server.connect(transport);
}

That await generateMissingEmbeddings() transitively requires the entire @xenova/transformers + onnxruntime-web module graph (~37 MB of wasm, deep ESM dep tree) to load synchronously before the server can ACK the MCP initialize request. On Node 25.x with cold caches, this has been observed to crash the server process with ETIMEDOUT inside Node's synchronous ESM source loader:

node:fs:732 → readSync → tryReadSync
            → readFileSync (node:fs:458:19)
            → getSourceSync (node:internal/modules/esm/load:37:14)
Error: ETIMEDOUT: connection timed out, read
  errno: -60, code: 'ETIMEDOUT', syscall: 'read'
Node.js v25.9.0

The user-visible symptom in Claude desktop is a "Server disconnected — could not attach to MCP server private-journal" toast on every cold launch, plus a red badge in Settings → Developer → private-journal. The client retries and the warmer second-attempt process usually succeeds, so tools eventually work — but the first process really does crash, and on slower disks the second-attempt window is also lost.

What changed

  1. src/server.ts — Connect the stdio transport first, then kick off generateMissingEmbeddings() as a background task via a private warmEmbeddingsInBackground() method. The MCP server can serve every tool except search_journal without the model loaded; search_journal already lazy-initializes via EmbeddingService.initialize(). The startup backfill is best-effort warm-up — there's no behavioral reason to gate the handshake on it.

  2. src/embeddings.ts — Convert the top-level import { pipeline } from '@xenova/transformers' into a dynamic await import('@xenova/transformers') inside doInitialize(). The type-only import preserves the FeatureExtractionPipeline typing for extractor. Without this, the transformers ESM dep tree is loaded the moment anything imports embeddings.ts (which server.ts does transitively via journal.ts and search.ts), so the change in (1) alone wouldn't be enough on Node 25.

  3. tests/server.test.ts — three new test cases:

    • run() returns before generateMissingEmbeddings resolves — proves the handshake is no longer gated on ML warm-up by stalling the embedding promise and asserting run() resolves anyway.
    • a thrown rejection from generateMissingEmbeddings does not crash the server — confirms backfill failures degrade gracefully (still log to stderr, transport still connects).
    • connect() is called before generateMissingEmbeddings() — locks in the transport-first ordering as a regression-resistant invariant.

    The MCP SDK is mocked at the module boundary so we test ordering, not protocol details. src/server.ts is added to collectCoverageFrom in jest.config.cjs.

Verification

Smoke-tested against a real MCP initialize message via stdio:

[+204ms] sending initialize...
[+207ms] STDOUT: {"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":{}},
                  "serverInfo":{"name":"private-journal-mcp","version":"1.0.0"}},
                  "jsonrpc":"2.0","id":0}
---server stderr (file)---
=== Private Journal MCP Server Debug Info ===
Node.js version: v25.9.0
Platform: darwin
...
Selected journal path: /Users/malcolm/.private-journal
===============================================
Checking for missing embeddings (background)...
---
VERDICT: initialize ACK at 207 ms

Before the patch the handshake didn't ACK at all on the same hardware (Node 25.9.0 / macOS Tahoe / cold cache); the process crashed with the ETIMEDOUT shown above. After: ACK in 207 ms, and the "(background)" log line shows the backfill kicking off after the handshake.

Risk

Behavioral surface change is small: the first call to search_journal now pays the model-load cost itself if no journal-write or read has happened in the meantime. Previously the first search_journal paid this cost too whenever the synchronous-startup race lost. Either way, EmbeddingService.initialize() already had the right "load on first use" semantics; we're just no longer pre-warming on the critical path.

Failure of the background backfill is logged to stderr and swallowed — same behavior as before, just deferred.

The server awaited generateMissingEmbeddings() inside run() before
connecting the stdio transport, which transitively required the entire
@xenova/transformers + onnxruntime-web module graph (~37 MB of wasm,
deep ESM dep tree) to load synchronously before the server could ACK
the MCP initialize handshake.

On Node 25.x with cold caches, this has been observed to crash the
process with ETIMEDOUT inside Node's synchronous ESM source loader:

    node:fs:732 → readFileSync (node:fs:458:19)
                → getSourceSync (node:internal/modules/esm/load:37:14)
    Error: ETIMEDOUT: connection timed out, read
      errno: -60, code: 'ETIMEDOUT', syscall: 'read'
    Node.js v25.9.0

Two changes:

1. src/server.ts — connect the stdio transport FIRST, then kick off
   generateMissingEmbeddings() as a background task. The MCP server
   can serve every tool except search_journal without the model
   loaded; search_journal already lazy-initializes via
   EmbeddingService.initialize(). Backfill is best-effort warm-up.

2. src/embeddings.ts — convert the top-level
   'import { pipeline } from @xenova/transformers' into a dynamic
   'await import(@xenova/transformers)' inside doInitialize(). The
   type-only import preserves FeatureExtractionPipeline typing.
   Without this, the transformers ESM dep tree is loaded the moment
   anything imports embeddings.ts (which server.ts does
   transitively), so the patch in (1) alone wouldn't be enough on
   Node 25.

Plus tests/server.test.ts covering: (a) run() resolves before
generateMissingEmbeddings() resolves, (b) a thrown rejection inside
backfill doesn't crash the server, (c) transport.connect() is called
before generateMissingEmbeddings().

Smoke-tested with a real MCP initialize message: server now ACKs in
~200ms instead of timing out at the client's handshake deadline.

Before:
  - Cold-boot 'Server disconnected' toast on every Claude desktop
    launch; tools intermittently unavailable on the first attempt;
    relaunched server usually wins because warm caches.

After:
  - initialize ACK in <250ms; embedding backfill runs after ACK with
    a clear 'Checking for missing embeddings (background)...' log
    line; transformers module graph not loaded until first
    search_journal call.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant