fix: unblock the MCP handshake, handle SIGTERM, stop shipping tests into dist - #33
Merged
Conversation
Three independent startup/shutdown defects from #31, all reproducible in a container. 1. validateAuth() ran before server.connect(), so the MCP handshake waited on a network round trip. A host that hangs rather than refuses (proxy, blackholed route) stalled `initialize` for undici's full connect timeout with no output at all. It now runs after connect, where its warn-only result belongs. Independently, every fetch is now bounded by an explicit 30s deadline (SUBSTACK_REQUEST_TIMEOUT_MS to override) — Node applies no request timeout of its own — and an aborted request surfaces as a typed TimeoutError naming the endpoint and the limit instead of a bare DOMException. Container, blackholed host: initialize 11,182ms -> 822ms. Credential-free path (what the Docker MCP registry check runs) unchanged: 14 tools, rc=0. 2. Nothing installed a SIGTERM handler, and the kernel ignores default-disposition signals for PID 1, so `docker stop` waited out its grace period and SIGKILLed. SIGTERM/SIGINT now close the transport and exit 0; the handler is idempotent and caps a hung close with a forced exit. No init process, so this repo stays consistent with its siblings. Container: docker stop 10,334ms/exit 137 -> 357ms/exit 0. Three SIGTERMs in a row produce one shutdown and exit 0. stdin EOF routes through the same path. The transport does not watch for it — the process just exits when the loop drains, which a pending request can hold open for the length of its timeout. 3. tsconfig had no test exclusion, so tsc emitted src/__tests__ into dist/, where the vitest imports cannot resolve because `npm ci --omit=dev` correctly drops vitest. Dead code in the published package, and a stale dist/ also made `vitest run` collect every suite twice (14 files/283 tests instead of 7/149). Excluding it at the tsconfig layer lets the Dockerfile drop its `RUN rm -rf dist/__tests__` workaround. Tests are still type-checked: `npm run lint` now uses tsconfig.lint.json, which covers all of src/, so the build-time exclusion did not quietly drop them from the type checker. Closes #31 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The branch changed package.json (the lint script now points at tsconfig.lint.json) without bumping the version. publish.yml triggers on any package.json change, so merging as-is would have fired a publish against 0.6.1 - already on npm - which fails and ships none of these fixes to users. Bumped to 0.6.2 and promoted the [Unreleased] CHANGELOG section so the publish that fires actually releases the handshake timeout, the SIGTERM/SIGINT handlers, and the tsconfig test exclusion. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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 & why
Closes #31 — all three items, independently fixed and independently measured.
Everything below was measured on this machine (Docker in WSL2,
node:22-alpine), building the repo's own Dockerfile atorigin/mainand at this branch. Before/after pairs use the same host and the same probe.1.
validateAuth()no longer blocks the handshake, and every fetch is boundedvalidateAuth()moved to afterserver.connect(transport). It only ever warned, so nothing about its result needed to precede the handshake — but awaiting it did, which put a network round trip in front ofinitialize.Independently, every
fetchin the client now carriesAbortSignal.timeout(...), defaulting to 30s and overridable withSUBSTACK_REQUEST_TIMEOUT_MS. Node applies no request timeout of its own, only undici's 10s connect timeout, so a host that accepts the connection and then goes silent could hang a tool call indefinitely — the handshake fix alone would not have covered that.A timed-out request raises the new
TimeoutError(synthetic 408, so it fits the shape every tool handler already renders) rather than a bareDOMException: The operation was aborted due to timeout.isAbortError()matches all three shapes an abort takes across runtimes: a DOMException namedTimeoutError, an older undiciAbortError, and aTypeErrorcarrying the real reason on.cause.Container
initializelatency against a blackholed host (192.0.2.1, TEST-NET-1 — accepts nothing, refuses nothing):origin/main)Both still answer
tools/listwith 14 tools and exit 0. The credential-free path the Docker MCP registry check exercises is unchanged in behavior.The timeout is real, not just wired up. A container pointed at the blackholed host with
SUBSTACK_REQUEST_TIMEOUT_MS=2000logs this at 2s, well inside undici's 10s connect timeout:The same container on the default 30s has logged nothing at the 5s mark — the control that shows the 2s result came from the override and not from something else.
2. SIGTERM/SIGINT handled, no init process
process.on("SIGTERM" | "SIGINT")closes the transport and exits 0. Notini— the five sibling servers don't use one and this repo shouldn't be the outlier.docker stopShutting downlinedocker topconfirms PID 1 isnode dist/index.jsin both images, so the before number is the real PID-1 signal-ignoring case rather than a normal process's default disposition.Idempotency is a flag plus an unref'd 2s forced-exit timer. Unref'd matters: it can never hold the loop open by itself, so it only fires when something else is still keeping the process alive — which is exactly the hang it guards against.
One addition worth flagging for review: stdin EOF routes through the same shutdown path.
StdioServerTransportnever watches for it — the process just exits when the event loop drains. That was fine before, but oncevalidateAuth()runs after connect, a client that disconnects while that request is in flight leaves the process lingering for the length of the network timeout. I measured it: with the handshake fix but no EOF handler, the blackhole case exited 10,641ms after stdin close. With it, 9ms locally and ~175ms in a container. Happy to drop this if you'd rather keep the natural drain — the other two fixes stand without it.3. Tests excluded at the tsconfig layer, Dockerfile workaround removed
"exclude": ["node_modules", "dist", "src/__tests__"], and #29'sRUN rm -rf dist/__tests__is gone.And inside the image built with the Dockerfile line removed:
This had a second symptom beyond dead weight in the image, and it is live in CI right now. A populated
dist/makesvitest runcollect every suite twice, andci.ymlrunsnpm run buildbeforenpm test— so every CI run onmainhas been running the whole suite twice without anyone noticing.main's most recent CI run reports 16 test files / 300 tests; its real count is 8 / 150. This branch's CI run reports 8 / 165. Same effect locally with a staledist/.What this costs, and how it's paid for: the build config is also what
npm run lintused, so excluding tests there would have silently dropped them from the type checker — and nothing else checks them, since vitest strips types with esbuild rather than checking them.lintnow points at a newtsconfig.lint.json(extends the build config, addsnoEmit, covers all ofsrc/). CONTRIBUTING and CLAUDE.md are updated to match. If you'd rather not carry a second tsconfig, the alternative is accepting that test files stop being type-checked at all.How verified
npm run lintclean —tsc --noEmit -p tsconfig.lint.json, exit 0, no outputnpx tsc --noEmitclean against the build config too, exit 0npm testpassing — 165/165 across 8 files.mainreports 300/300 across 16 files in CI, but that is the same suite counted twice (see README 'cannot publish' banner conflicts with create_note (which publishes immediately) #3); its real count is 150, so this branch adds 15.npm run buildclean,dist/__tests__absent (output above)origin/mainin a container, then re-measured this branchThe 15 new tests cover the timeout default, the explicit override, the nonsense-override fallback (0, -1, NaN), the abort →
TimeoutErrormapping end to end throughrequest(), the bounded public-page scrape, and the must-still-fire complement: an ordinaryTypeError: fetch failedmust not be reported as a timeout.I mutation-tested them rather than trusting green — removing the
signal:line and the abort mapping turns 8 of them red:Not verified: any behavior against a live Substack publication. These are startup/shutdown and transport-layer changes, and the one client change is a timeout that no live call should ever reach.
Safe-by-design checklist
Rebase note
Branched before #32 landed; rebased onto
mainat10138b8. The conflicts were theclient.test.tsimport line and the CHANGELOG heading, both resolved by keeping both sides.MAX_PAGE_SIZE/ANALYTICS_*and this branch's timeout code sit in different parts ofclient.tsand merged cleanly. Every measurement above was re-taken on the rebased tree. No version bump here — 0.6.1 was just released, so this sits under[Unreleased].🤖 Generated with Claude Code