Skip to content

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

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.

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.

Amended 2026-09-28 — the exact pin moves to 2.1.0; see Amendment ratified by @mbeacom.

  • start() hands a closure-private server factory to serveStdio(...) with the default legacy: '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 _meta envelope) gets 2026-07-28; an initialize handshake 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/list ordering (next bullet) — asserted directly against a real spawned subprocess, unsorted.
  • @modelcontextprotocol/client@2.0.0 is a development-only dependency. It drives the in-process and real-stdio conformance harnesses and is never imported by shipped code; scripts/check-deps.ts enforces that split.
  • zod tightens from ^4 to ^4.2.0. v2 converts schemas through the authoring instance’s ~standard.jsonSchema, added in zod 4.2.0. A ^4 range still satisfies v2’s peer and installs, and zod 4.0–4.1 does not fail outright — the SDK falls back to its own bundled z.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 returns 0 is 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: 0 default: tools/list and server/discover are served with ttlMs: 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 — every tools/call still loads a fresh projection.
  • tools/list advertises 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 models tools as an unordered set, so no client behavior depends on it — but the previous order was an artifact of the order the four registerX calls 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.

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.

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.

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.

  • Easier: Consumers install two transitive packages instead of a web framework stack. bun audit is clean with no overrides in 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.1 fails or observes a changed response against this build; a tools/call returns stale corpus data because something cacheable leaked a corpus read; or @modelcontextprotocol/server@2.x proves less stable than the frozen v1 line it replaced, measured by regressions traced to SDK behavior rather than adrkit code. Amended 2026-09-28: “client” means one that follows the MCP stdio binding; see Amendment ratified by @mbeacom.
  • 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 invalidates cacheScope: "public"), or the MCP registry begins advertising a server’s supported revisions, which packages/mcp/server.json should then declare.

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.

  1. Ratify or reject this proposed record. Ratified by @mbeacom, 2026-07-31.
  2. 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 protocolEra config. (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.
  3. Decide whether packages/mcp/server.json should 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-28 against 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:

  1. 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.
  2. 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/discover cannot drift, because it is the server answering for itself.
  3. Verifying the registry preserves and returns the field would require republishing, which docs/DISTRIBUTION.md binds 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.

Verification against @modelcontextprotocol/server@2.1.0 (2026-09-27)

Section titled “Verification against @modelcontextprotocol/server@2.1.0 (2026-09-27)”

Dependabot proposed the 2.1.0 server and client as two separate pull requests (#225, #226). They share @modelcontextprotocol/core, so they were moved together and stay exact-pinned. bun.lock changed only in the three SDK entries. The integrity hashes match Dependabot’s, and bun install --frozen-lockfile accepts the result.

This run is against the built packages/mcp/dist/bin.js under Node 22, not a published artifact, because none exists yet. It uses this repository’s corpus of 42 records. After the next release, the post-release run above should be repeated against the published package.

What changed upstream, and what it touches here

Section titled “What changed upstream, and what it touches here”

Only one 2.1.0 change reaches a local stdio server with four read-only tools. typescript-sdk#2494 makes StdioServerTransport close itself when stdin ends, following the stdio binding’s “servers SHOULD exit promptly when their standard input is closed”. Requests still in flight at EOF are aborted and not answered. The other entries cover HTTP, OAuth/DPoP, scope challenges, request-id 0 handling, and client transports. adrkit uses none of them.

The change showed up first as #225’s own CI failure. Two bin.test.ts cases wrote every frame and then closed stdin before reading anything. Under 2.1.0 that lost responses, including the server/discover result in one CI run. Those tests now read every response they wait for before closing stdin, as scripts/smoke-node.mjs and scripts/smoke-container.mjs already did. A third case asserts that a client hanging up with a request still in flight gets a clean exit. All three check exit 0 and empty stderr. Each assertion was observed failing under a mutation: first with main returning 1, then with stdin closed before the responses were read (ADR-0016). Measured shutdown on Node and Bun, on both eras: exit 0, nothing on stderr, and the process gone within 5 ms of EOF.

On this record’s “how we would know this was wrong” clause: a 2025-era client sees no change. It keeps stdin open until it has its responses, and every response below is byte-identical to 2.0.0. A script that pipes frames in and closes stdin at once does see a change, because it can lose trailing responses. That behaviour was never part of the protocol, and the SDK now follows the binding’s shutdown rule. This record therefore treats it as outside what it guarantees, and flags it as a user-visible note in the changelog and in packages/mcp/README.md rather than working around it.

The Inspector reaches both eras through per-server config, headless

Section titled “The Inspector reaches both eras through per-server config, headless”

This run used official MCP Inspector 2.8.0, whose CLI mode is headless and which itself still ships SDK 2.0.0. That makes it an independent, older client against the 2.1.0 server. Each server entry ran the bin through a wrapper that tees the Inspector’s stdin to a log:

protocolEra Frames the Inspector sent
(absent — default) initialize (offering 2025-11-25), notifications/initialized, tools/list / tools/call
modern server/discover @ 2026-07-28, subscriptions/listen, tools/list / tools/call @ 2026-07-28
auto server/discover @ 2026-07-28, subscriptions/listen, tools/list / tools/call @ 2026-07-28

Inspector 2.8.0 also opens subscriptions/listen, which 2.0.0 did not. The reply log shows no error and no reply for it before the Inspector disconnected. It does not show whether the request was still open at that point or was aborted at hang-up. Inspector 2.8.0 also adds a --protocol-era legacy|auto|modern CLI flag, which produced the same server/discover opening as the config key.

Both eras, all four tools, identical results — and identical to 2.0.0

Section titled “Both eras, all four tools, identical results — and identical to 2.0.0”
Tool 2025 era 2026-07-28 (modern and auto)
search_decisions (query: "bun") results — 17 decisions results — 17 decisions
get_decision (ref: "0018") found — this record found — this record
get_decision_context (packages/mcp/src/server.ts) matches — 1 governing (0018) matches — 1 governing (0018)
list_superseded entries — 2 entries — 2

content and structuredContent were byte-identical across all three configurations for every tool. The tools/list result and every tools/call result were also byte-identical, apart from _meta, to the same Inspector calls made against a build of main on @modelcontextprotocol/server@2.0.0. That holds on both eras. An SDK 2.1.0 client pinned to { pin: '2026-07-28' } against the same built binary reported the negotiated version 2026-07-28 and the server identity {"name":"@adrkit/mcp","version":"0.14.0"}. It got the same four outcomes, byte-identical to an unpinned client, which negotiated 2025-11-25.

Not exercised: the Inspector’s web UI, and any artifact installed from npm.

Amendment ratified by @mbeacom (2026-09-28)

Section titled “Amendment ratified by @mbeacom (2026-09-28)”

Ratified by @mbeacom on 2026-09-28, after the verification above and the review of #242. The ratified text above is kept as written; this amends it in two places.

  1. The pin. @modelcontextprotocol/server and @modelcontextprotocol/client are exact-pinned at 2.1.0, moved together so that one @modelcontextprotocol/core resolves. The Decision’s “2.0.0” names the version this record adopted, not a ceiling. The rule it implies stands: each SDK bump stays excluded from Dependabot’s grouped updates and lands only after this record’s Inspector run is repeated on both eras and appended here.
  2. “Client” in How we would know this was wrong. It means a client that follows the MCP stdio binding and keeps stdin open until it has read its responses. A process that writes frames and closes stdin at once was never such a client. From 2.1.0 the server exits promptly on EOF, as the binding asks (typescript-sdk#2494), and that caller can lose trailing responses. This is accepted as outside the guarantee, not worked around. Working around it would mean replacing serveStdio, which the 2026-07-28 era depends on. The change is disclosed in the changelog and packages/mcp/README.md.

A protocol-following 2025-era client that fails or sees a changed response still falsifies this record, exactly as before.

Post-release verification (2026-09-28, against published @adrkit/mcp@0.15.0)

Section titled “Post-release verification (2026-09-28, against published @adrkit/mcp@0.15.0)”

The 2.1.0 verification above ran against a local build. This repeats it against the artifact users install: @adrkit/mcp@0.15.0 from npm, which resolves @modelcontextprotocol/server@2.1.0, installed into a scratch directory and launched as node node_modules/@adrkit/mcp/dist/bin.js with this repository as its working directory. The official MCP Inspector 2.8.0 drove it headless in CLI mode with --protocol-era legacy and --protocol-era modern.

Call Result, both eras
tools/list the four tools; identical once keys are sorted
search_decisions (query: bun) 17 items
get_decision (ref: 0018) found, accepted
get_decision_context (files: [packages/mcp/src/server.ts]) governing 0018
list_superseded 2 entries

Every tool payload (structuredContent and content) was byte-identical across the two eras. The one difference is the envelope the 2026-07-28 era adds to each result, _meta["io.modelcontextprotocol/serverInfo"], which named @adrkit/mcp 0.15.0. The counts match the pre-release run above.

Not exercised: the Inspector’s web UI.