Skip to content

fix: unblock the MCP handshake, handle SIGTERM, stop shipping tests into dist - #33

Merged
conorbronsdon merged 2 commits into
mainfrom
fix/startup-and-shutdown
Aug 3, 2026
Merged

fix: unblock the MCP handshake, handle SIGTERM, stop shipping tests into dist#33
conorbronsdon merged 2 commits into
mainfrom
fix/startup-and-shutdown

Conversation

@conorbronsdon

@conorbronsdon conorbronsdon commented Aug 3, 2026

Copy link
Copy Markdown
Owner

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 at origin/main and 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 bounded

validateAuth() moved to after server.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 of initialize.

Independently, every fetch in the client now carries AbortSignal.timeout(...), defaulting to 30s and overridable with SUBSTACK_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 bare DOMException: The operation was aborted due to timeout. isAbortError() matches all three shapes an abort takes across runtimes: a DOMException named TimeoutError, an older undici AbortError, and a TypeError carrying the real reason on .cause.

Container initialize latency against a blackholed host (192.0.2.1, TEST-NET-1 — accepts nothing, refuses nothing):

before (origin/main) after
no credentials 1,907ms 480ms
blackholed host 11,182ms 492ms

Both still answer tools/list with 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=2000 logs this at 2s, well inside undici's 10s connect timeout:

Substack API error (408) at https://192.0.2.1/api/v1/post_management/drafts?...:
Request timed out after 2000ms with no response. The host may be unreachable, or
behind a proxy that drops packets instead of refusing the connection. Set
SUBSTACK_REQUEST_TIMEOUT_MS to raise the limit if the publication is just slow.

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. No tini — the five sibling servers don't use one and this repo shouldn't be the outlier.

before after
docker stop 10,334ms, exit 137 (SIGKILL) 288–357ms, exit 0
SIGINT n/a 312ms, exit 0
3× SIGTERM in a row n/a 610ms, exit 0, exactly one Shutting down line

docker top confirms PID 1 is node dist/index.js in 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. StdioServerTransport never watches for it — the process just exits when the event loop drains. That was fine before, but once validateAuth() 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's RUN rm -rf dist/__tests__ is gone.

$ rm -rf dist && npm run build
$ ls dist/
annotations.d.ts  annotations.js  annotations.js.map  api/  auth/  index.d.ts
index.js  index.js.map  login.d.ts  login.js  login.js.map  server.d.ts
server.js  server.js.map  utils/
$ test -e dist/__tests__ && echo PRESENT || echo ABSENT
ABSENT
$ find dist -iname '*test*' | wc -l
0

And inside the image built with the Dockerfile line removed:

$ docker run --rm --entrypoint sh substack-mcp:final -c "[ -e /app/dist/__tests__ ] && echo PRESENT || echo ABSENT"
ABSENT

This had a second symptom beyond dead weight in the image, and it is live in CI right now. A populated dist/ makes vitest run collect every suite twice, and ci.yml runs npm run build before npm test — so every CI run on main has 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 stale dist/.

What this costs, and how it's paid for: the build config is also what npm run lint used, 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. lint now points at a new tsconfig.lint.json (extends the build config, adds noEmit, covers all of src/). 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 lint clean — tsc --noEmit -p tsconfig.lint.json, exit 0, no output
  • npx tsc --noEmit clean against the build config too, exit 0
  • npm test passing — 165/165 across 8 files. main reports 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 build clean, dist/__tests__ absent (output above)
  • Reproduced both 11s delays against origin/main in a container, then re-measured this branch

The 15 new tests cover the timeout default, the explicit override, the nonsense-override fallback (0, -1, NaN), the abort → TimeoutError mapping end to end through request(), the bounded public-page scrape, and the must-still-fire complement: an ordinary TypeError: fetch failed must 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:

× attaches an AbortSignal to every request
× uses a 30s deadline by default
× honors an explicit timeout override
× falls back to the default for a nonsense override (0 / -1 / NaN)
× surfaces an aborted request as TimeoutError, not a bare DOMException
× bounds the public-page scrape too, and reports unavailable when it aborts

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

  • No new publish / delete / schedule capability for long-form posts
  • Any immediate-publish behavior (Notes) stays loudly documented in the tool description

Rebase note

Branched before #32 landed; rebased onto main at 10138b8. The conflicts were the client.test.ts import 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 of client.ts and 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

conorbronsdon and others added 2 commits August 3, 2026 02:10
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>
@conorbronsdon
conorbronsdon merged commit e6aaf72 into main Aug 3, 2026
2 checks passed
@conorbronsdon
conorbronsdon deleted the fix/startup-and-shutdown branch August 3, 2026 09:47
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.

Two 11-second delays: blocking auth check before connect, and SIGTERM ignored at PID 1

1 participant