fix: don't block MCP initialize handshake on embedding warm-up - #22
Open
hxzmn7f5dp-jpg wants to merge 1 commit into
Open
fix: don't block MCP initialize handshake on embedding warm-up#22hxzmn7f5dp-jpg wants to merge 1 commit into
hxzmn7f5dp-jpg wants to merge 1 commit into
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Don't block the MCP
initializehandshake on embedding warm-up.Why
PrivateJournalServer.run()previously did:That
await generateMissingEmbeddings()transitively requires the entire@xenova/transformers+onnxruntime-webmodule graph (~37 MB of wasm, deep ESM dep tree) to load synchronously before the server can ACK the MCPinitializerequest. On Node 25.x with cold caches, this has been observed to crash the server process withETIMEDOUTinside Node's synchronous ESM source loader: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
src/server.ts— Connect the stdio transport first, then kick offgenerateMissingEmbeddings()as a background task via a privatewarmEmbeddingsInBackground()method. The MCP server can serve every tool exceptsearch_journalwithout the model loaded;search_journalalready lazy-initializes viaEmbeddingService.initialize(). The startup backfill is best-effort warm-up — there's no behavioral reason to gate the handshake on it.src/embeddings.ts— Convert the top-levelimport { pipeline } from '@xenova/transformers'into a dynamicawait import('@xenova/transformers')insidedoInitialize(). The type-only import preserves theFeatureExtractionPipelinetyping forextractor. Without this, the transformers ESM dep tree is loaded the moment anything importsembeddings.ts(whichserver.tsdoes transitively viajournal.tsandsearch.ts), so the change in (1) alone wouldn't be enough on Node 25.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 assertingrun()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.tsis added tocollectCoverageFrominjest.config.cjs.Verification
Smoke-tested against a real MCP
initializemessage via stdio: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_journalnow pays the model-load cost itself if no journal-write or read has happened in the meantime. Previously the firstsearch_journalpaid 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.