ADR-0018: Adopt MCP SDK v2 and serve protocol revision 2026-07-28 dual-era
| Field | Value |
|---|---|
| Status | accepted |
| Date | 2026-07-29 |
| Review by | 2027-07-28 |
| Schema version | 0.1.0 |
| Reversibility | two-way-door |
| Blast radius | team |
| Scope | component |
| Tags | mcp, dependencies, protocol, compatibility, security |
| Deciders | @mbeacom |
| Authored by | agent-drafted |
| Ratified by | @mbeacom |
| Review tier | async |
| Review reason | Replaces the MCP server’s only third-party runtime dependency with a new upstream major line and changes what @adrkit/mcp puts on the wire for clients that ask for the new revision. The tool surface, schemas, and 2025-era bytes are unchanged, so no published API contract moves, but the dependency and protocol commitments affect every consumer of the published package. |
| External refs | MCP specification revision 2026-07-28 changelog, Node.js Adapter for Hono serve-static path traversal on Windows |
| Relates to | 0007, 0010, 0014, 0017 |
| Affects | path:packages/mcp/**, path:scripts/audit-gate.ts, path:scripts/check-deps.ts |
| Source | docs/adr/0018-adopt-mcp-sdk-v2-and-serve-protocol-revision-2026-07-28-dual-era.md |
Context
Section titled “Context”On 2026-07-28 the Model Context Protocol published specification revision
2026-07-28. Its headline change is a stateless protocol core: the
initialize / notifications/initialized handshake and the Mcp-Session-Id
header are gone, every request carries its own protocol version and client
capabilities in _meta, a new server/discover RPC advertises capabilities up
front, and every result carries a resultType. Server-initiated requests are
replaced by the Multi Round-Trip Request pattern; ping, logging/setLevel, and
resources/subscribe are removed; Roots, Sampling, and Logging are deprecated
under a new twelve-month deprecation policy.
The day before, the TypeScript SDK shipped v2 as a package split. The single
@modelcontextprotocol/sdk@1.x became @modelcontextprotocol/core, /client,
/server, and framework adapters. @modelcontextprotocol/sdk@1.29.0 — the exact
pin research §R0 chose in Phase 5, when v2 was still beta — is now the end of the
v1 line.
Three forces make this a decision rather than a routine bump.
The dependency surface is not equivalent. @modelcontextprotocol/sdk@1.29.0
declares thirteen runtime dependencies, including Express, Hono,
@hono/node-server, Ajv, cors, jose, and zod-to-json-schema — an HTTP and
auth stack that a local stdio server never executes but always installs.
@modelcontextprotocol/server@2.0.0 declares two: @modelcontextprotocol/core
and zod. That difference is the direct cause of the consumer exposure
ADR-0017
recorded and accepted until 2026-10-31: a consumer installing @adrkit/mcp@0.2.1
resolved a vulnerable @hono/node-server through the SDK, which adrkit could not
fix because the SDK ranged it below the patched major.
Upgrading the SDK does not, by itself, change the wire. v2 keeps speaking the
2025-era protocol unless a server opts in. A hand-wired
server.connect(new StdioServerTransport()) — what @adrkit/mcp does today —
serves only the 2025 era no matter which SDK version is installed. Serving
2026-07-28 on stdio requires the connection-pinned serveStdio(factory) entry.
So “update the dependency” and “support the new revision” are two decisions, and
only the second is visible to agents.
Most deployed clients are still 2025-era. The revision is one day old. Choosing modern-only would strand every client that has not migrated.
adrkit’s exposure to the spec’s breaking changes is unusually small: the server is local, read-only, stdio-only, and uses none of what changed — no sessions, no roots, sampling, logging, prompts, resources, subscriptions, tasks, HTTP, or auth. Its entire surface is four read-only tools.
Decision
Section titled “Decision”We will migrate @adrkit/mcp to @modelcontextprotocol/server@2.0.0 and serve
both protocol eras on the same stdio connection, with the client choosing.
start()hands a closure-private server factory toserveStdio(...)with the defaultlegacy: 'serve'. The opening exchange selects the era and pins one factory instance for the connection’s lifetime:server/discover(or any request carrying a 2026_metaenvelope) gets2026-07-28; aninitializehandshake is served exactly as before.- The four tools are registered once and served identically to both eras. Names,
input and output schemas, annotations, structured results, cursor semantics, and
rendered text do not vary by era. The 2025-era wire is unchanged from 0.2.1
except for
tools/listordering (next bullet) — asserted directly against a real spawned subprocess, unsorted. @modelcontextprotocol/client@2.0.0is a development-only dependency. It drives the in-process and real-stdio conformance harnesses and is never imported by shipped code;scripts/check-deps.tsenforces that split.zodtightens from^4to^4.2.0. v2 converts schemas through the authoring instance’s~standard.jsonSchema, added in zod 4.2.0. A^4range still satisfies v2’s peer and installs, and zod 4.0–4.1 does not fail outright — the SDK falls back to its own bundledz.toJSONSchema()with a one-time[mcp-sdk]stderr warning. But.describe()field descriptions live in the authoring zod’s registry, so the fallback silently drops them from the advertised JSON Schema. A degraded tool catalog that still returns0is exactly the fail-quiet shape ADR-0016 rejects, so the declared range excludes the versions that take that path rather than relying on the resolved version happening to be new enough.- We adopt SEP-2549 cache fields deliberately rather than accepting the SDK’s
conservative
ttlMs: 0default:tools/listandserver/discoverare served withttlMs: 300000, cacheScope: "public". Both are immutable for the life of the process and carry no corpus content or caller identity. Corpus reads are never cacheable — everytools/callstill loads a fresh projection. tools/listadvertises the four tools in lexicographic order, satisfying the revision’s deterministic-order SHOULD without relying on registration accident. The SDK serves registration order on both eras, so this is the one 2025-era wire change in this migration. MCP modelstoolsas an unordered set, so no client behavior depends on it — but the previous order was an artifact of the order the fourregisterXcalls happened to be written in, and nothing observed it. It is now asserted unsorted on both eras.
We will also retire the ADR-0017 consumer-advisory acceptance and the root
overrides it stood behind, because this migration satisfies that acceptance’s
own recorded resolvesWhen clause (“…or adrkit removes that transitive path”).
Neither @hono/node-server nor fast-uri resolves anywhere in the tree after the
swap, so the overrides block and the acceptance entry both describe an exposure
that no longer exists. Leaving them in place would be the inverse of ADR-0016’s
concern: a gate reporting a finding it can no longer observe.
Options considered
Section titled “Options considered”Option A: SDK v2 + serveStdio serving both eras (chosen)
Section titled “Option A: SDK v2 + serveStdio serving both eras (chosen)”| Dimension | Assessment |
|---|---|
| Client compatibility | Widest. 2025-era clients are unaffected; 2026-era clients get the stateless revision. The client decides, per connection. |
| Consumer security | Removes the Express/Hono/Ajv/auth stack from the published dependency graph, closing GHSA-frvp-7c67-39w9 for consumers ahead of its accepted expiry. |
| Migration cost | Contained. Import paths, outputSchema wrapping, one lifecycle rewrite, and a test-client strictness flag. The tool logic is untouched. |
| Cost | Carries two wire eras in one binary for as long as the SDK supports both, and the era-selection logic is upstream code we do not own. |
Option B: SDK v2, stay on the 2025 era
Section titled “Option B: SDK v2, stay on the 2025 era”Pros: Strictly smaller change; captures the entire dependency-surface and security win, which is the more urgent half. No new wire behavior to validate.
Cons: Silently misleading. The package would ship the SDK that speaks
2026-07-28 while answering server/discover with a method-not-found, so a
2026-era agent harness sees a server that looks migrated and is not. It also
defers, rather than avoids, the serveStdio work — the 2025 era is on a
twelve-month deprecation clock.
Option C: SDK v2, legacy: 'reject' (2026-07-28 only)
Section titled “Option C: SDK v2, legacy: 'reject' (2026-07-28 only)”Pros: One wire era to reason about and test; no legacy surface to carry.
Cons: Breaks every currently deployed client on a one-day-old revision, for a package whose maturity section openly states it has no external adopters yet. The compatibility cost is real and immediate; the simplification is speculative.
Option D: Do nothing — stay on @modelcontextprotocol/sdk@1.29.0
Section titled “Option D: Do nothing — stay on @modelcontextprotocol/sdk@1.29.0”Pros: No work, no risk of regression.
Cons: Pins the package to a frozen v1 line that will not receive the new revision, and keeps shipping consumers a thirteen-dependency HTTP/auth stack the server never runs — including the advisory ADR-0017 could only accept, not fix. The accepted exposure expires 2026-10-31 and would then fail CI closed with no upstream remedy available.
Trade-offs
Section titled “Trade-offs”We take on an upstream major-version migration one day after it shipped, against a
specification one day old. That is early. The mitigations are that adrkit’s MCP
surface is four read-only tools that touch none of the changed features, that the
2025-era wire is asserted unchanged apart from tools/list ordering — so the blast
radius of a v2 regression is bounded to clients that explicitly opt into the new era
— and that the migration strictly shrinks the third-party attack surface rather than
growing it.
That ordering caveat is deliberate and worth naming rather than rounding off, since
it is the single exception to an otherwise unchanged legacy wire. MCP models tools
as an unordered set, so it is not a compatibility surface; the risk is not that a
client breaks but that “unchanged” gets read as absolute. Both eras now assert the
order unsorted, so the exception is enforced rather than asserted.
We also carry two wire eras. The era-selection logic lives in serveStdio, not in
adrkit, so a defect there is upstream and not directly fixable by us — which is
why both eras are exercised against a real spawned subprocess rather than only
in-process.
Finally, the cacheScope: "public" hint asserts that the tool catalog is safe for
shared caches. That is true today because the catalog is static package metadata,
and it stops being true if a future tool surface ever varies by caller or corpus.
Any such change must revisit the hint.
Consequences
Section titled “Consequences”- Easier: Consumers install two transitive packages instead of a web framework
stack.
bun auditis clean with nooverridesin effect, and the audit gate reports no known consumer exposure because there is none. - Easier: 2026-era agent harnesses reach the corpus with no handshake, and can cache the tool catalog instead of re-listing it.
- Harder: Two wire eras must stay covered. Era coverage cannot be asserted
in-process —
InMemoryTransport.createLinkedPair()links 2025-era instances only — so the 2026-era evidence requires spawning the real bin. - How we would know this was wrong: a 2025-era client that worked against
@adrkit/mcp@0.2.1fails or observes a changed response against this build; atools/callreturns stale corpus data because something cacheable leaked a corpus read; or@modelcontextprotocol/server@2.xproves less stable than the frozen v1 line it replaced, measured by regressions traced to SDK behavior rather than adrkit code. - Revisit if: the SDK removes 2025-era serving (at which point
legacy: 'serve'becomes moot and Option C becomes the only option), a fifth tool or any caller-varying tool metadata is introduced (which invalidatescacheScope: "public"), or the MCP registry begins advertising a server’s supported revisions, whichpackages/mcp/server.jsonshould then declare.
The minimumReleaseAge window
Section titled “The minimumReleaseAge window”bunfig.toml sets minimumReleaseAge = 259200 (three days). The v2 packages
published 2026-07-27T23:55Z, so they cannot be freshly resolved until
2026-07-30T23:55Z. bun.lock was therefore generated with a one-off
bun install --minimum-release-age=0.
Measured, not assumed — with the lockfile committed and node_modules deleted:
| Command | Result |
|---|---|
bun install --frozen-lockfile (CI) |
succeeds |
bun install (contributor, no flag) |
succeeds; leaves bun.lock byte-identical |
bun update @modelcontextprotocol/*, or resolving without a lockfile |
blocked until 2026-07-30T23:55Z |
The gate applies at resolution, not to entries a lockfile already pins. So the committed lockfile is exactly what a normal-policy resolution produces, nothing in the documented clean-clone flow is blocked, and the lockfile does not need regenerating.
We considered adding a standing
minimumReleaseAgeExcludes = ["@modelcontextprotocol/server", ...] to
bunfig.toml (verified working on Bun 1.3.14) and rejected it. The key is a
permanent waiver, not a one-time one: it would exempt every future
@modelcontextprotocol/* release from the soak, including releases nobody has
reviewed yet. A compromised MCP SDK executes inside every agent harness that
installs @adrkit/mcp, which makes these the packages the soak is most worth
keeping on — a poor thing to trade away permanently to save one day on one
migration. The constraint expires on its own and blocks nothing in the meantime.
Action items
Section titled “Action items”- Ratify or reject this proposed record. Ratified by @mbeacom, 2026-07-31.
- Re-run MCP Inspector dogfood against both eras before release, as Phase 5
did for the 2025 era. Done — both eras, via the Inspector’s per-server
protocolEraconfig. (A first attempt on 2026-08-01 wrongly concluded the Inspector could not reach the 2026 era; corrected 2026-08-02.) See the post-release verification below. - Decide whether
packages/mcp/server.jsonshould advertise the supported protocol revisions once the registry schema supports it. Decided 2026-08-01: not yet possible. See below.
Post-release verification (2026-08-01, against published @adrkit/mcp@0.3.0)
Section titled “Post-release verification (2026-08-01, against published @adrkit/mcp@0.3.0)”Corrected 2026-08-02. The first version of this section claimed the official MCP Inspector could not reach the 2026 era. That was wrong, and so was the cause it proposed. The Inspector does negotiate
2026-07-28against this server; the era is simply opt-in per server rather than a CLI flag, and the first check was run against the default configuration without looking for one. The corrected result is below. The original text is not preserved because it asserted a false fact about a third-party tool.
Both eras were exercised against the published npm artifact — not the working tree — over real stdio, against this repository’s own 18-record corpus.
The Inspector reaches both eras; the era is per-server config
Section titled “The Inspector reaches both eras; the era is per-server config”Official MCP Inspector 2.0.0 (published 2026-07-28, the first release built on
SDK v2) defaults to the 2025 era. That default is inherited from the SDK
(versionNegotiation: options.versionNegotiation ?? { mode: 'legacy' }) and is
not a limitation — the era is selected per server through the Inspector’s own
config, with a protocolEra key alongside command/args:
{ "mcpServers": { "adrkit": { "type": "stdio", "command": "adrkit-mcp", "args": ["--cwd", "/path/to/repo"], "protocolEra": "modern" } }}protocolEra accepts legacy, auto, or modern; the Inspector maps modern
to the SDK’s { pin: '2026-07-28' } and auto to { mode: 'auto' }. There is no
equivalent CLI flag, which is what made it easy to miss.
Verified on the wire by teeing the client’s stdin into a log:
protocolEra |
Frames the Inspector sent |
|---|---|
| (absent — default) | initialize (offering 2025-11-25), notifications/initialized, tools/list |
modern |
server/discover @ 2026-07-28, tools/list @ 2026-07-28 |
auto |
server/discover @ 2026-07-28, tools/list @ 2026-07-28 |
The auto row is the strongest single piece of evidence in this record: a
third-party client’s negotiation probe succeeds against this server and
selects the modern era on its own. That exercises server/discover as a real
capability-detection round trip, not just as a method that answers.
Both eras, all four tools, identical results
Section titled “Both eras, all four tools, identical results”Driven through the Inspector on each era against the same published binary and the same corpus:
| Tool | 2025 era | 2026-07-28 |
|---|---|---|
search_decisions (query: "bun") |
results — 5 decisions |
results — 5 decisions |
get_decision (ref: "0018") |
found — this record, accepted |
found — this record, accepted |
get_decision_context (packages/mcp/src/server.ts) |
matches — 1 governing |
matches — 1 governing |
list_superseded |
entries — 0 |
entries — 0 |
Rendered text was byte-identical across eras for all four. That is this record’s central claim — results do not vary by era — now observed through a third-party client against the published artifact, not only in this repository’s test suite.
A conforming SDK v2 client pinned to { pin: '2026-07-28' } was also run
directly against the same binary and reported negotiated era: modern with
server identity {"name":"@adrkit/mcp","version":"0.3.0"} and the same four
outcomes.
server.json will not advertise protocol revisions
Section titled “server.json will not advertise protocol revisions”Corrected 2026-08-01. This section first said there was “nowhere to put the claim”. That is false — there is an extension point. The first check looked for a named protocol field, found none, and concluded no mechanism existed. The conclusion below is unchanged; the reason for it is not.
2025-12-11 remains the only published registry schema
(https://static.modelcontextprotocol.io/schemas/2026-01-01/… and …/latest/…
both 404), and it defines no first-class protocolVersion / protocolVersions /
supportedVersions / revision field.
It does, however, define a general extension point on ServerDetail:
_meta["io.modelcontextprotocol.registry/publisher-provided"], an
additionalProperties: true object described as “publisher-provided metadata for
downstream registries”. A dev.adrkit/... key could physically carry the
revisions today.
We will not use it, for three reasons:
- No consumer and no convention. The key would be ours alone. Nothing reads it, and inventing a private spelling for a protocol-level fact invites a different spelling to win later.
- It creates a second source of truth that can go stale. The registry record
is republished per release; the served revisions are a property of the running
binary. Drop legacy-era serving in some future version and the blob keeps
asserting two eras until someone remembers to republish.
server/discovercannot drift, because it is the server answering for itself. - Verifying the registry preserves and returns the field would require
republishing, which
docs/DISTRIBUTION.mdbinds to a human action. Claiming it works without that check would be the same unverified-assertion shape this record already had to correct once.
Re-check when the registry defines a first-class field for supported
revisions — at which point the claim has an agreed spelling and a consumer.
Until then supported revisions are discoverable at runtime via the
server/discover RPC this server already answers, which the Inspector’s auto
probe above demonstrates working end to end.