****** MAJOR UPDATE ******
Finalised MCP spec and hardening pass.
0.6.0 was built against the release-candidate cut of the 2026-07-28 MCP revision. The spec has since been finalised, so this is the promised reconciliation pass - all divergences between RC and final closed - plus I performed a defect-hunt hardening pass across the whole gateway and increased the CI test suite from 36 to 73 tests!
Spec reconciliation
- Best-version negotiation, both directions. The gateway now speaks the newest revision each peer supports (four legacy revisions plus modern), replacing the oldest-dialect clamp. The
-32022version-retry loop a client could get trapped in is gone. - JSON-RPC batching for 2025-03-26 clients: 202 for notification-only batches, one JSON array for request batches, 400 for an empty batch. Era-gated by header, capped at 64 members.
- Header enforcement. An unknown
MCP-Protocol-Versionvalue is refused with a 400 (a 2025-06-18 spec MUST), andMcp-Param-*mirroring is validated against the body. - Era-aware upstream error relay. A legacy caller gets the upstream's JSON-RPC error verbatim - code, message and data. A modern caller gets spec-band codes with the original preserved under
data.upstreamCode/data.upstreamData. - Metadata fidelity.
title,annotations,outputSchema,_metaandexecutionforward through every definition path, and result-side_metarelays (identity keys stripped in funnel mode, not in wrap mode, wrap mode is true to the wrapped server). - Elicitation, both modes. URL-mode elicitations relay end-to-end in both eras, suspension requires the caller's declared elicitation capability, and the
-32042path relays with its payload intact. - Subscription lifecycle. Listen-agreed resource subscriptions are refcounted and released on re-listen, stream close, teardown and cancel. Previously they could leak forever.
- Rename migration. Renaming or aliasing a surfaced tool migrates exact-literal hook matchers and per-tool state atomically, and a rename that would silently detach a non-literal matcher is refused with the hook named. Gates can no longer be shed by renaming.
- Modern per-request
logLevelrestores era-correctly, a modern HTTP client closing mid-call now cancels the upstream work, a non-loopbackOriginis refused 403,--versionexists, and the config-home relocation hint actually prints.
Hardening
- The UI's per-tool gate switch now tells the truth. It can no longer delete a broader hook, adopt a hand-authored entry and report a script nothing runs, or let two tools sharing a register name silently own each other's gate (that is now a 409).
- The spawn isolation guard reads the environment exactly as the spawn does. Values are judged as the coerced string the child receives, a
__proto__key is refused outright as a carrier, and the store normalises configured environments to plain string-to-string maps. The guard covers args, cwd and env, with case-insensitive keys on every platform; option-string variables (NODE_OPTIONSand friends) are not path-checkable, so in funnel mode they are refused, with an explicit manually configurable per-upstream opt-out. - Every spawn path is guarded - including the wrap-time era probe, which previously built its client directly and ran no checks at all.
- A disabled upstream is now off everywhere. It vanishes from the lean list, both run paths and the curated surface, and its tools can no longer be resolved or executed. Previously they still could.
- A curated alias can no longer shadow a local tool. When a curated alias collides with a local tool's name, it is no longer advertised with the upstream's schema while the call runs the local tool instead.
- Optional auth fails closed on a blank issuer. A blank configured issuer no longer silently disables issuer binding; it is refused at request time, matching the existing audience guard.
- Reconnect backoff always escalates. An upstream that dies during a reconnect attempt's own tool-listing no longer re-arms the one-second timer and pins the gateway to a permanent one-second respawn loop. The backoff climbs 1s, 2s, 4s and on to the 30s keepalive as intended.
- Local tools have a clock and a ceiling. Script and shell tools time out at 120 s, kill their whole process tree, and bound their output in real bytes. A hanging local tool no longer starves every later request.
- Hooks fail closed. Hook output is capped, and a hook that floods past the cap reads as a refusal, not an allow.
- The panel stays responsive. The optional
joseinstall runs off the config-write chain on its own single-flight latch, config-mutating posts are serialised so concurrent writes cannot discard each other, and--config-dir=/pathis accepted. - A refused panel add or update unwinds cleanly. A rejected upstream add or tool update leaves no orphaned script and no half-written row, so the natural retry works.
- The activity log is bounded. It rotates at 5 MB with one older generation kept, and reading the tail reads a bounded slice rather than the whole file.
- Config writes never strand a temp file. The atomic writers remove their own temp on a failed write and sweep stale temps left by a killed process.
- Housekeeping: ~1,030 lines of unreferenced code removed.
SECURITY.mdsets out the trust model.
Verification
73 test files, all green. Verified beyond the unit suite against real SDK-based servers - wrap, funnel, gate and HTTP - with identity, tool relay, structured errors, notifications, pagination and renames byte-checked against a direct connection.
Node >= 18, zero runtime dependencies, as always.