feat(mcp)!: support both MCP protocol revisions (2025-11-25 and 2026-07-28) - #86
Conversation
…-07-28 deprecations MCP protocol revision 2026-07-28 shipped today. This is the non-breaking groundwork; the migration itself is deliberately deferred. Why defer: no released SDK negotiates 2026-07-28 by default. Verified empirically against a local HTTP capture — @modelcontextprotocol/client@2.0.0 still sends the `initialize` handshake with protocolVersion "2025-11-25" and omits the Mcp-Method / Mcp-Name headers the new revision requires, and @modelcontextprotocol/core@2.0.0 does not export LATEST_PROTOCOL_VERSION at all (internal value is "2025-11-25"). Migrating today would restructure the dependency tree without changing a byte on the wire, while breaking published API. Changes: - Fix the self-reported client version: DEFAULT_CLIENT_INFO said '0.1.0' while the package is 0.0.1. The published 0.0.1 tarball ships this, so every server it connects to is told the wrong version. - Add tests/unit/mcp-connection.test.ts (9 cases) covering transport selection and the Streamable HTTP -> SSE fallback. Every existing unit test vi.mocks mcp-connection.js, so this path had no coverage; these fake the SDK transports instead so the real connect() runs. Verified the suite fails when the pinned-transport guard is broken. - Mark @deprecated, type-level only: SerializedMCPServer.sessionId (sessions removed, SEP-2567), CreateMCPToolsOptions.onElicitation (server-initiated elicitation removed for MRTR, SEP-2322), and MCPTransportKind 'sse' (HTTP+SSE deprecated, SEP-2596). - Document the negotiated revision and the full migration gap in the README. No runtime behavior changes and no breaking API changes. Co-Authored-By: Claude <noreply@anthropic.com>
There was a problem hiding this comment.
Summary
Non-breaking MCP groundwork: corrects the self-reported client version, adds the first real coverage for connect()'s transport selection and Streamable HTTP → SSE fallback, and adds @deprecated/README notes for surfaces that revision 2026-07-28 removes. Deferring the migration is well argued and the new tests are structured correctly (SDK transports faked, real connect() executed); the findings below are advisory, not blocking.
Findings (4)
🟡 minor · packages/mcp/src/mcp-connection.ts:21
The version fix is immediately re-broken by its own changeset: .changeset/mcp-2026-07-28-prep.md is a patch bump, so this ships as 0.0.2 while DEFAULT_CLIENT_INFO.version reads '0.0.1'. The "keep in sync" comment is unenforced — no test, typecheck, or lint rule catches divergence. Read the version from package.json or add a guard test asserting DEFAULT_CLIENT_INFO.version === pkg.version.
🟡 minor · packages/mcp/tests/unit/mcp-connection.test.ts:1-241
The 9 new cases never assert anything about clientInfo — neither the default value (the field this PR fixes) nor that options.clientInfo overrides it. The behavior being corrected is the one part of connect() left untested.
🟡 minor · packages/mcp/src/types.ts:98
@deprecated is applied to onElicitation (and SerializedMCPServer.sessionId, cache-types.ts:36) while they remain the only functional path under the pinned SDK and have no in-package replacement. This surfaces strikethrough/deprecation lint in consumers for correct usage; the explanatory prose conveys the same warning without that side effect.
🟡 minor · packages/mcp/src/mcp-connection.ts:150-160
Pre-existing, now newly covered: when the Streamable HTTP attempt fails, the half-initialized client/transport is discarded without close(), so any opened socket or abort controller leaks before the SSE fallback. The new fallback test asserts clientsCreated === 2 but nothing asserts the failed client was cleaned up — worth a follow-up assertion plus a close().catch(() => {}) in the catch block.
|
Migration tracked in DEV-738 (DevEx › Agent SDKs, Backlog). It carries the full gap analysis — removals with SEP numbers, the new required fields, error renumbering, affected surface in this package, and the published-package constraints. The explicit trigger to start that work is a released SDK that negotiates |
Replaces the hardcoded '0.0.1' in DEFAULT_CLIENT_INFO with a constant
generated from package.json, so the version we self-report to every MCP
server cannot drift from the package we actually publish.
package.json is the source of truth. `build` runs gen-version.mjs before
tsc, so a changesets version bump is picked up automatically before
publish (the release workflow runs `pnpm run build` ahead of
`changeset publish`).
src/version.ts is committed rather than gitignored: CI's lint, typecheck,
and unit-test jobs compile src without a build step, and turbo's
`dependsOn: ["^build"]` only builds upstream packages, so nothing would
regenerate it in those jobs. tests/unit/version.test.ts closes the gap by
failing when the committed constant drifts from package.json.
Verified:
- bump package.json to 0.1.0 without regenerating -> drift test fails
- run build -> file regenerates, test passes
- dist layout unchanged (esm/index.js, not esm/src/), all 6 export-map
paths resolve
- esm/version.js ships in the tarball; scripts/ does not
- 571 tests pass, lint and typecheck clean
Note: importing package.json directly was tried and rejected — it pulls
the file into the compilation, shifting the implicit rootDir so output
becomes esm/src/**, which invalidates every path in the export map.
module: "Node16" also rejects JSON import attributes.
Co-Authored-By: Claude <noreply@anthropic.com>
There was a problem hiding this comment.
Summary
Update replaces the hardcoded client version with generated src/version.ts + a drift-guard test, which resolves my main prior finding and does it properly (committed output, documented rationale, turbo inputs updated for scripts/**). One new wrinkle: the changesets "Version Packages" PR will fail version.test.ts because CI's test task only builds upstream deps, so nothing regenerates the file after the bump. Remaining items are the same advisory ones as before; nothing blocking.
Findings (4)
🟡 minor · packages/mcp/scripts/gen-version.mjs:14
NEW: changeset version bumps package.json without regenerating src/version.ts, and CI's pnpm run test does not run mcp's own build (turbo test uses dependsOn: ["^build"] = upstream only, per turbo.json:26). The auto-generated Version Packages PR will therefore fail version.test.ts every release until someone regenerates by hand. Fix at the source: version: pnpm exec changeset version && pnpm --filter @openrouter/mcp gen:version in .github/workflows/publish.yaml, so the regenerated file lands in the changesets commit. The published tarball is unaffected (publish.yaml runs pnpm run build first).
🟡 minor · packages/mcp/tests/unit/mcp-connection.test.ts:96-125
PARTIALLY RESOLVED / still open: version.test.ts now guards constant-vs-package.json drift, but no test asserts connect() actually passes DEFAULT_CLIENT_INFO to new Client(...), nor that options.clientInfo overrides it. The Client fake still discards its constructor argument, so a regression in the wiring (rather than in the constant) would go unnoticed.
🟡 minor · packages/mcp/src/types.ts:98
OPEN (unchanged): @deprecated on onElicitation, and on SerializedMCPServer.sessionId (cache/cache-types.ts:36-45), still marks the only functional path under the pinned SDK with no in-package migration target — strikethrough + downstream deprecation lint for correct usage. See my earlier thread for the suggested prose-only alternative.
🟡 minor · packages/mcp/src/mcp-connection.ts:150-165
OPEN (unchanged, pre-existing): the failed Streamable HTTP client/transport is discarded without close() before the SSE fallback, leaking any opened socket/abort controller; the new fallback test asserts clientsCreated === 2 but not cleanup.
Replaces @modelcontextprotocol/sdk@^1.29.0 with @modelcontextprotocol/client@^2.0.0. Every item here is compile-forced — the package does not build without all of them — so they land together. - All 8 source import sites plus 4 test specifiers collapse to the single '@modelcontextprotocol/client' package. Notably we do NOT add @modelcontextprotocol/core as a direct dependency: its '.' export is a zod-schema barrel (173 exports, all /Schema/), every type and value we use lives in `client`, and core arrives as a pinned transitive dep. - callTool loses its middle argument: v2 is `callTool(params, options)`. This is the one change with runtime rather than compile-time consequences — leaving the v1 three-arg form would have put `signal` and `onprogress` in a dropped slot, silently killing cancellation and progress streaming. Verified against v2's types: `TS2554: Expected 1-2 arguments, but got 3`. - setRequestHandler / setNotificationHandler are method-name-first in v2. Spec methods supply their own schema; passing a bare zod schema as the second argument crashes at runtime reading '~standard'. The elicitation handler is unchanged otherwise and now serves both protocol eras — on 2026-07-28 the multi-round-trip driver dispatches input_required through this same handler. - Deletes the isTransport runtime guard. It existed solely because SDK v1 typed `sessionId` as `string | undefined` rather than optional, which exactOptionalPropertyTypes rejected at the connect() call site. v2 declares it optional, so client.connect() typechecks directly and connectWith() inlines into its three call sites. - Collapses the three v1 module mocks in mcp-connection.test.ts into one factory on the unified package, and records versionNegotiation.mode per constructed Client so a later commit can assert the negotiation default with no network. customConditions: [] stays — eventsource and eventsource-parser still ship exports.source pointing at raw .ts, which is the original reason. Verified: typecheck clean, 50/50 unit tests pass, real (unmocked) build succeeds. No behavior change intended in this commit. Co-Authored-By: Claude <noreply@anthropic.com>
Two new suites, no source changes — so they characterize the SDK's
current behavior (versionNegotiation defaults to 'legacy') before the
next commit opts us into 'auto'.
tests/unit/protocol-era.test.ts (9 tests) runs a hand-rolled MCP server
over InMemoryTransport, so there is no network, no fixture process, and
no MCP_TEST_URL gate. It pins the facts the rest of the package depends
on:
- legacy server: server/discover probe, then initialize fallback,
getProtocolEra() === 'legacy'
- modern server: NO initialize at all, getProtocolEra() === 'modern'
- modern server still populates getServerVersion() and
getServerCapabilities() — handle.ts reads both synchronously, so if
the modern era left them empty, resource tools would silently vanish
and snapshots would lose serverInfo
- sessionId is undefined in the modern era (SEP-2567)
- input_required is fulfilled through the SAME registered
elicitation/create handler, then the call is retried (SEP-2322) —
this is what justifies keeping onElicitation rather than deprecating
it
- { pin } fails loudly when the revision is not offered
Two field names worth recording, both of which I got wrong first: the
server/discover result field is `supportedVersions` (not
`protocolVersions`), and `inputRequests` is an object keyed by request id
(not an array).
tests/unit/call-tool-shape.test.ts (3 tests) guards the v2 callTool
signature. That regression is silent rather than loud: with the v1
three-arg form, `signal` and `onprogress` land in a slot the SDK does not
read, so cancellation and progress stop working while every other test
still passes. Verified by mutation — restoring the three-arg call fails
all 3.
Both suites mutation-tested: dropping modern advertisement fails 4 era
tests; the MRTR handler-invocation assertion is load-bearing.
62/62 unit tests pass, typecheck and lint clean.
Co-Authored-By: Claude <noreply@anthropic.com>
Any MCP server now works out of the box, whether it speaks 2025-11-25 or
2026-07-28. Previously we only spoke the 2025 handshake.
Adds `protocolNegotiation?: 'legacy' | 'auto' | { pin: string }` to
CreateMCPToolsOptions and RehydrateMCPToolsOptions, mapped onto the SDK's
versionNegotiation. Defaults to 'auto' — the SDK itself defaults to
'legacy', but a library whose job is "point it at a server" should reach
both eras with no configuration.
Under 'auto' the client probes with server/discover, then either goes
modern (per-request _meta envelope, no handshake) or falls back to the
2025 initialize handshake. `'legacy'` skips the probe, which matters on
flaky servers: on HTTP a probe timeout is treated as an outage and
rejects, where 'legacy' may still connect. `{ pin }` fails loudly rather
than falling back.
The type is declared in transport-types.ts rather than re-exported from
the SDK, so this does not put an SDK type in our public API — the problem
MCPAuth already has with OAuthClientProvider.
Threaded through every path that reaches connect(), including
FORWARDED_REHYDRATE_KEYS. Omitting it there would have made a cache HIT
silently fall back to the default while a cache MISS honoured the caller
— the same bug class as the pre-existing staleness gap.
`inputRequired` is deliberately left unset: the SDK's defaults
(auto-fulfil on, 10 rounds) are what we want, and pinning them would
freeze values the SDK may tune.
4 new tests assert the 'auto' default, explicit 'legacy', pin
pass-through, and that the policy also applies to the SSE fallback
client. Mutation-verified: reverting the default to 'legacy' fails the
default test.
66/66 unit tests pass, typecheck and lint clean.
Co-Authored-By: Claude <noreply@anthropic.com>
Pre-existing gap, unrelated to the protocol migration — separated out so it doesn't read as migration fallout. `staleness.maxAgeMs` was only checked in `createMCPTools`'s cache-hit path (create-mcp-tools.ts). A caller holding their own snapshot and calling `rehydrateMCPTools()` directly got no staleness check at all — `rehydrate.ts` never read `cachedAt` — so tools of unbounded age were replayed silently. Adds `snapshotIsStale()` beside the existing `tokensExpired()` and folds it into the same guard, so a stale snapshot routes through `freshConnect` exactly like expired tokens or missing credentials already do. Also adds `staleness` to `RehydrateMCPToolsOptions` and to FORWARDED_REHYDRATE_KEYS. `toCreateOptions` deliberately does NOT forward it: that builds options for a fresh connect, which has no snapshot age to compare against. 3 tests cover within-maxAge replay, over-maxAge re-list, and no-maxAge replay-regardless-of-age. Mutation-verified: dropping the check fails the over-maxAge test. The fake client in rehydrate.test.ts gained a `listTools` stub, which is now reachable because a stale snapshot falls through to freshConnect. 69/69 unit tests pass, typecheck and lint clean. Co-Authored-By: Claude <noreply@anthropic.com>
PR #86 originally annotated three surfaces as doomed under 2026-07-28, on the premise that we could not speak that revision. Now that we negotiate both, one of those annotations was simply wrong and the other two needed re-tensing. - REMOVE the @deprecated on `onElicitation`. It works on both revisions: 2025-era servers send `elicitation/create`, and on 2026-07-28 the SDK's multi-round-trip driver routes `input_required` through the same handler. Replaced with an explanation of the dual mechanism. - KEEP `transport: 'sse'` deprecated — SEP-2596 stands regardless. - RE-TENSE the `sessionId` notes from "will be removed" to "is undefined on modern connections", and add matching notes on ConnectOptions and MCPConnection, which were newly no-ops against 2026-07-28 servers and carried no annotation at all. - ADD `MCPOAuthClientProvider`, a re-export of the SDK's OAuthClientProvider under our own name. That type is reachable through the public `MCPAuth` oauth variant, so consumers were importing it from the SDK directly — an import path that just changed under them. Now they can name it without depending on our dependency. - REWRITE the README protocol section. Its central claim — "no released SDK negotiates it by default ... migrating today would change our dependency tree without changing a single byte on the wire" — was the stated reason for not migrating, and is now false. Replaced with what actually happens on the wire per revision, how to override, and the three real behavioral differences. 69/69 unit tests pass, typecheck and lint clean. Co-Authored-By: Claude <noreply@anthropic.com>
Bumps minor rather than patch: this release breaks the public API (OAuth provider type) and pre-1.0 minors are the conventional vehicle. A patch would land 0.0.2, indistinguishable from the queued bugfix. Deletes .changeset/mcp-2026-07-28-prep.md and folds its still-true content into the new changeset. It asserted "no released SDK speaks 2026-07-28 by default yet" and claimed the onElicitation deprecation — both false in this same release, and shipping three release notes that contradict each other would be worse than one coherent one. Co-Authored-By: Claude <noreply@anthropic.com>
The structural gate failed with "Complex functions increased: 9 -> 10". The culprit was `toCreateOptions` at cc=16, one over the max_cc=15 in .sentrux/rules.toml: it had grown to 14 conditional spreads, two of them added by this PR (protocolNegotiation, and staleness reaching the options surface). Replaces the per-key spreads with a typed key list and one copy loop, mirroring `forwardedRehydrateOptions` in create-mcp-tools.ts which already forwards the same set in the opposite direction. `as const satisfies readonly (keyof RehydrateMCPToolsOptions & keyof CreateMCPToolsOptions)[]` means a key that isn't valid on both types fails to compile rather than silently dropping — the same guard the sibling helper uses. `staleness` is deliberately excluded: it compares a snapshot's age, and this builds options for the fresh-connect fallback which has no snapshot. Verified locally with sentrux 0.5.7 (same version CI pins): complex functions back to 9, and the 9 remaining are all pre-existing in packages/agent, untouched here. Behavior unchanged — 69/69 unit tests pass, typecheck and lint clean. Co-Authored-By: Claude <noreply@anthropic.com>
There was a problem hiding this comment.
Summary
The PR has been rewritten from deprecation prep into a breaking SDK-v2 migration that supports both protocol revisions, with the title, body and changeset now honestly reflecting that scope. The engineering is largely solid — the callTool arity catch and the dual-era InMemoryTransport suite are real value — but two things block: the new 'auto' default can break connections that work today and isn't listed as breaking, and our own client wiring (notably the string-keyed tools/list_changed handler) is not exercised against the real SDK anywhere.
Findings (7)
🟠 major · packages/mcp/src/mcp-connection.ts:110
NEW: defaulting versionNegotiation.mode to 'auto' flips behavior for every existing consumer, and by this PR's own docs (transport-types.ts:16-18) a probe timeout over HTTP "is treated as an outage and rejects, where 'legacy' may still connect" — the SSE fallback re-probes and fails identically (mcp-connection.ts:177-190). Servers/proxies that hang or 5xx on an unknown method regress from working to failing. The changeset lists only the OAuth type change as breaking. Either retry once with 'legacy' on probe failure, default to 'legacy' for a release, or document this as a second breaking change with the escape hatch named.
🟠 major · packages/mcp/src/mcp-connection.ts:127
NEW: setNotificationHandler('notifications/tools/list_changed', …) replaced a compile-checked SDK schema value with a bare string, and nothing verifies it. tests/unit/mcp-connection.test.ts:96 mocks the entire @modelcontextprotocol/client module (handlers are no-ops), and protocol-era.test.ts constructs its own Client instead of calling connect(). A wrong key leaves default-on autoRefreshOnListChanged silently dead with every test green — the same silent-failure class as the callTool arity bug, without a guard. Needs one InMemoryTransport test through our own connect()/makeClient, or an assertion tying the literal to the SDK's exported name.
🟡 minor · packages/mcp/scripts/gen-version.mjs:14
STILL OPEN (raised on the previous head): changeset version bumps package.json without regenerating src/version.ts, and CI's test task is dependsOn: ["^build"] (upstream only, turbo.json:26), so the auto-created Version Packages PR fails version.test.ts — now bumping 0.0.1 → 0.1.0, so it will fail on this release. Fix by appending && pnpm --filter @openrouter/mcp gen:version to the version: command in .github/workflows/publish.yaml.
🟡 minor · packages/mcp/tests/unit/mcp-connection.test.ts:265-306
STILL OPEN: the negotiation suite now records versionNegotiation.mode per client, but the fake Client still discards its first constructor argument, so nothing asserts DEFAULT_CLIENT_INFO reaches the SDK or that options.clientInfo overrides it — the behavior the generated version.ts exists to protect.
3 more finding(s)
🟡 minor · packages/mcp/src/mcp-connection.ts:177-190
STILL OPEN (pre-existing, now sharper): the failed Streamable HTTP client/transport is discarded without close() before the SSE fallback, leaking any socket or in-flight probe request; under 'auto' there is now an extra probe round trip in that window.
🟡 minor · packages/mcp/src/transport-types.ts:29-31
MCPProtocolNegotiation's { pin: string } accepts any string, so { pin: '2026-07-08' } typechecks and fails only at connect time. A literal union with a (string & {}) escape hatch would catch typos at compile time for the two known revisions.
🟡 minor · packages/mcp/tests/unit/protocol-era.test.ts:1-379
This suite characterizes the third-party SDK against a hand-rolled server, so the fake defines the contract it verifies: if v2 changes server/discover's shape, the tests keep passing while real servers diverge. Valuable as documentation of the negotiation model, but it exercises no src/ code — it should not be counted as coverage of this package's dual-era behavior.
The Version PR that changesets opens would have failed its own CI. Chain, all three links verified: - packages/mcp/src/version.ts is generated but committed (deliberately — see the docblock in scripts/gen-version.mjs: lint/typecheck/test run without a build step). - tests/unit/version.test.ts asserts PACKAGE_VERSION === package.json's version. - `changeset version` bumps package.json and commits, regenerating nothing. turbo's `test` task is `dependsOn: ["^build"]` — `^` is upstream deps only, so mcp's own build (which runs gen:version) never runs before its tests. Reproduced by bumping packages/mcp/package.json to 0.0.2 and running `turbo run test --filter=@openrouter/mcp`: AssertionError: expected '0.0.1' to be '0.0.2' Fixes it at the source rather than loosening the test, which is the part that actually catches drift. Adds a root `version` script that runs `changeset version`, then `turbo run gen:version`, then refreshes the lockfile — and points changesets/action at it. Same bump, same commit, generated file included. Also adds the `gen:version` turbo task (uncached; package.json in, src/version.ts out) so any future package with committed generated output is picked up automatically. Verified: same bump with the fix applied regenerates version.ts to 0.0.2 and the mcp suite passes 69/69. Reported independently by devin (x2) and cortex on #86.
There was a problem hiding this comment.
Summary
This update only touches the release plumbing — root version script, publish.yaml, and a turbo gen:version task — which cleanly resolves the Version-PR breakage I raised. Both blocking findings from the previous review are untouched: the 'auto' negotiation default can still fail where today's behavior succeeds and isn't listed as breaking, and the string-keyed notifications/tools/list_changed registration is still verified nowhere.
Findings (7)
🟠 major · packages/mcp/src/mcp-connection.ts:110
UNCHANGED / STILL OPEN: versionNegotiation.mode still defaults to 'auto', so every existing consumer starts probing with server/discover; per this PR's own docs a probe timeout over HTTP rejects where 'legacy' would have connected, and the SSE fallback re-probes identically (mcp-connection.ts:177-190). Still absent from the changeset's Breaking section. Fix per my inline thread: retry once with 'legacy' on probe failure, or default to 'legacy', or document it as breaking with the escape hatch named.
🟠 major · packages/mcp/src/mcp-connection.ts:127
UNCHANGED / STILL OPEN: setNotificationHandler('notifications/tools/list_changed', …) replaced a compile-checked SDK schema with a bare string that no test exercises — tests/unit/mcp-connection.test.ts:96 stubs the handler as a no-op and protocol-era.test.ts never calls our connect(). Default-on autoRefreshOnListChanged can be silently dead with the whole suite green.
🟡 minor · package.json:15
NEW: the version script appends pnpm install --lockfile-only, which re-resolves all ranges during changeset version. Since the bump changes no dependency ranges (internal deps are workspace:*), its only effect is to smuggle unrelated transitive resolution bumps into the auto-generated release commit. Drop it, or bump the lockfile in a separate reviewable PR.
🟡 minor · packages/mcp/tests/unit/mcp-connection.test.ts:265-306
STILL OPEN: the fake Client records versionNegotiation.mode but discards its first constructor argument, so nothing asserts DEFAULT_CLIENT_INFO reaches the SDK or that options.clientInfo overrides it — the behavior the new generated version.ts exists to protect.
3 more finding(s)
🟡 minor · packages/mcp/src/mcp-connection.ts:177-190
STILL OPEN (pre-existing): the failed Streamable HTTP client/transport is discarded without close() before the SSE fallback; under 'auto' that window now also contains an in-flight probe request.
🟡 minor · packages/mcp/src/transport-types.ts:29-31
STILL OPEN: { pin: string } accepts any string, so a mistyped revision typechecks and fails only at connect time.
⚪ nit · turbo.json:1-88
The file was reformatted to one array element per line, turning a 2-line functional change (gen:version task) into a ~50-line diff in a shared root config that pnpm lint does not cover. Worth reverting the cosmetic churn so the release-plumbing change reads on its own.
Re-review at
|
| tree | baseline coupling | measured | delta | gate |
|---|---|---|---|---|
origin/main |
0.4286 | 0.47 | +0.043 | ✓ passes (just under 0.05) |
| this PR | 0.4286 | 0.4837 | +0.055 | ✗ DEGRADED |
The baseline on both trees is the same stale file (133 import edges, recorded when the repo was less than half its current size). Main has already drifted +0.043 against it; this PR adds ~+0.005 more and tips it over.
That +0.005 is also not new coupling. Holding non-source files constant, base source measures 151 cross-module edges over 309 import edges (0.4790) and head measures 151 over 306 (0.4837) — the numerator is unchanged and the ratio moves because consolidating six @modelcontextprotocol/sdk/* subpath imports into one @modelcontextprotocol/client import removed three resolved edges. A denominator effect. The PR also takes cycles 1 → 0 and quality 5094 → 6088.
So the gate is firing on accumulated main drift with this PR's rounding error on top. The mechanism sentrux offers is sentrux gate --save (no delta allowance, no per-rule suppression, no baseline subcommand); precedent for using it is #73's fcbf9aa. Cleanest sequencing, and the human call I'd ask for:
- refresh the baseline on
mainin its own one-file PR, which retires the +0.043 that main has already accumulated and is reviewable as exactly that; - then this PR's own +0.005 passes on its merits and the gate keeps its regression-detecting power here.
Refreshing on this branch instead also turns the check green, but folds main's drift into an MCP PR where nobody is looking for it.
Verdict
The release fix is right. Blocking on the 'auto' default being documented as breaking (or fallback-retried); the notification-handler finding is refuted and shouldn't hold the PR. structural-gate needs the baseline decision above, not a code change.
…ample Two findings from devin's re-review of e4c0273. 1. `protocolNegotiation` defaulting to 'auto' where the SDK defaults to 'legacy' was documented only in a code comment at mcp-connection.ts. The changeset's Breaking section listed just the OAuth type change, so a consumer reading the CHANGELOG and not the source learned nothing about the riskier change. Now a second Breaking bullet names the failure mode (probe against a gateway that rejects unknown methods goes from working to failing) and the escape hatch. 2. `.agents/skills/public-api-examples/SKILL.md` requires a fenced example in the changeset for any public-API change. This adds `protocolNegotiation` and exports `MCPOAuthClientProvider`, and was prose-only. Shapes verified against source: protocolNegotiation at types.ts:51, `{ pin: string }` at transport-types.ts:31, MCPOAuthClientProvider exported from index.ts:4. Does not resolve whether 'auto' should stay the default — devin proposes a one-shot 'legacy' retry on connect failure, which is a design call for a human. This only stops the change being undocumented.
…o.json Both from review of e4c0273. - `pnpm install --lockfile-only` removed from the `version` script. cortex and devin both flagged it: `changeset version` only rewrites `version` fields and CHANGELOGs, and all internal deps are `workspace:*`, so there is nothing for it to pick up. It is a no-op in the expected case, and its one real effect — an unreviewed lockfile diff inside "chore: version packages" — has no upside. - turbo.json restored to the file's own compact style. My edit had gone through json.dumps(indent=2), expanding every array to one element per line and turning a 5-line addition into a 61-line diff. Nothing formats root turbo.json (`lint` is `turbo run lint`, which only covers packages/*/src and packages/*/tests), so the reformat was gratuitous. The semantic diff against main is now exactly two entries: the new `gen:version` task, and `scripts/**` added to `build.inputs` so a change to gen-version.mjs invalidates the build. Verified by comparing parsed JSON rather than the textual diff — the first pass at un-reformatting silently dropped `scripts/**`. Release fix re-verified after the edits: bump to 0.0.2 → gen:version regenerates → mcp suite 69/69.
There was a problem hiding this comment.
Summary
This update closes the 'auto' default finding the way I said would be acceptable — a second explicit Breaking bullet in the changeset naming the failure mode (gateways that hang or 5xx on unknown methods) and protocolNegotiation: 'legacy' as the exact opt-out — and drops the lockfile side effect plus the turbo.json reformatting churn. One blocking item is left untouched: the notifications/tools/list_changed handler key is verified by no test, so default-on auto-refresh can be silently dead.
Findings (4)
🟠 major · packages/mcp/src/mcp-connection.ts:127
STILL OPEN, now the only blocker: setNotificationHandler('notifications/tools/list_changed', …) replaced a compile-checked SDK schema value with a bare string that nothing exercises — tests/unit/mcp-connection.test.ts:96 stubs it as a no-op and protocol-era.test.ts drives its own Client, never our connect(). If the key or the dispatch path is wrong, default-on autoRefreshOnListChanged fails silently with all 590 tests green, the same class of bug as the callTool arity fix this PR guards. Narrowed ask: expose a seam (export makeClient, or let connect() accept a transport) and add one InMemoryTransport case where the fake server emits the notification and the setToolListChangedHandler callback fires. If v2 types the method parameter as a literal union, say so — mutating the string and showing typecheck fails would satisfy the key half, but the dispatch half still needs the test.
🟡 minor · packages/mcp/tests/unit/mcp-connection.test.ts:265-306
STILL OPEN: the fake Client records versionNegotiation.mode but discards _info, so nothing asserts DEFAULT_CLIENT_INFO reaches the SDK or that options.clientInfo overrides it — the behavior the generated version.ts exists to protect.
🟡 minor · packages/mcp/src/mcp-connection.ts:177-190
STILL OPEN (pre-existing): the failed Streamable HTTP client/transport is discarded without close() before the SSE fallback; under the new 'auto' default that window also holds an in-flight probe.
🟡 minor · packages/mcp/src/transport-types.ts:29-31
STILL OPEN: { pin: string } accepts any string, so a mistyped revision typechecks and only fails at connect time. A literal union with a (string & {}) escape hatch would catch the two known revisions' typos at compile time.
…ionId Devin: the read-back I added last round to keep the scrub from introducing credentials meant every warm cache hit read the store twice — tryCacheHit reads the entry, then maintainReplayedEntry read the same key again just to learn it had no sessionId, which is true of every entry this version writes. Double read load on Redis/DB for the common case, and the changeset's "warm hits no longer touch the store" claim was false the round after I wrote it. Cheap gate first: the scrub can only be needed when the INPUT snapshot carries a legacy sessionId. On the warm path the input IS the store's entry, so a sessionId-free input proves a sessionId-free store and we return before any store call. Only legacy entries pay the read-back, which still guards the introduce-credentials case for direct rehydrates whose snapshot came from elsewhere. Test pins the invariant directly: a modern warm replay performs zero store operations — gets and sets both counted. Mutation-verified.
Devin, two threads on f99c70b. **The 403 suppression was wrong for OAuth users too.** Devin re-raised the gateway-403 scenario, now scoped to OAuth — and on a second SDK audit the right cut is by status, not by auth kind. The PKCE side effects the guard protects (saveCodeVerifier, redirectToAuthorization) live exclusively behind the SDK transport's `status === 401 && authProvider` branch; a 403 never enters the OAuth flow at all, so a retry after a 403 re-drives nothing. But WAFs commonly answer unknown methods like server/discover with 403 — so suppressing on it made OAuth deployments behind such gateways permanently unreachable, in exchange for preventing a side effect that cannot occur. isAuthStatus now matches 401 only. The step-up-exhaustion 403 the SDK can throw is a genuine authz failure that will simply fail again under 'legacy' — one wasted retry there buys connectivity everywhere else. **Abort now stops the in-pass ladder deterministically.** The signal guard on the legacy retry didn't cover the SSE fallback inside a pass: an abort landing during the HTTP attempt still built and dialled the SSE client, relying on the SDK honouring the signal inside transport.start() promptly. An explicit aborted check before the fallback makes "abort means stop dialling" our invariant rather than an SDK timing property. Both mutation-verified: restoring 403-suppression fails the OAuth-403 test; removing the fallback guard fails the mid-attempt abort test.
Cortex and Devin, same finding from both sides an hour after d1f96d7: three doc surfaces still described the pre-narrowing behavior. The inline comment in isAuthFailure said "401/403 status counts" thirty lines below the isAuthStatus block that matches 401 only; the public reconnectOnExpiry JSDoc and the README's retry section both told consumers a 403 under OAuth suppresses the retry, when it now deliberately degrades. Fifth changed-semantics doc drift of this PR — the same shape every time: narrow the code, miss a prose site. All three now state the 401-only rule and its reasoning (PKCE side effects are 401-exclusive in the SDK; gateways 403 unknown methods). Grep for "401/403" across src and README comes back clean. Devin's turbo.json question (gen-version writing src/ while lint/typecheck/ test declare it as an input) is answered on the thread rather than changed: CI runs each task as a separate job, no package script combines build with a src-reading task in one turbo invocation, and the generator skips the write when the committed file matches — the committed-file test keeps that precondition true.
There was a problem hiding this comment.
cortex panel verdict: comment — details in the consolidated review comment.
Devin: the abort guards added to the SSE fallback and the legacy retry left the third reconnect layer unguarded — a cancelled rehydrate whose replay failed still dialled a full fresh connect and reported "failed to rehydrate" instead of the cancellation. Same fix as the other two layers: signal.aborted short-circuits the fallback. Test pins one dial and no fresh connect behind an abort; mutation-verified.
There was a problem hiding this comment.
cortex panel verdict: request changes — details in the consolidated review comment.
…ated token Devin: the OAuth maintenance write re-serialized the provider's tokens, and serializeServer stamps expiresAt as Date.now() + expires_in*1000 — but expires_in is relative to issuance, not to the serialize call. Re-persisting the SAME token on every replay pushed the recorded expiry forward each time, so tokensExpired() could never trip for a genuinely expired token. Now the rotation write carries the input snapshot's expiresAt verbatim when the access token is unchanged; a rotated token keeps its fresh restamp, since the SDK just obtained it. Both branches pinned by tests; mutation-verified. Also honestly scopes the _AssertExpiresInIsSeconds JSDoc: it catches a rename or retype, not a same-name-same-type semantics change — no static assert can.
…tials JSDoc cortex: the README install section never mentioned the new Node 20 floor, so Node 18/19 consumers would first learn of it from an install warning. And the cacheCredentials hover doc still promised to persist a "session" — sessionId is never serialized (SEP-2567; rehydrate always re-handshakes), so the JSDoc now says what is actually stored.
|
Re: cortex review on README Node 20 (fixed,
|
There was a problem hiding this comment.
cortex panel verdict: comment — details in the consolidated review comment.
…store-entry graft Two Devin findings on 328c23a: 1. writeCache built the snapshot inside the try that tags MCPCacheWriteError, so a rejection from the caller's own OAuth provider.tokens() wore the one error class every path treats as harmless — swallowed by the list_changed handler, ignored by refreshStaleReplay, dismissed by callers following the documented pattern. The payload is now built before the try; only the store op earns the tag. 2. The OAuth rotation write serialized this call's live state, so a direct rehydrate whose input snapshot was older than the store's entry (concurrent refresh() wrote since) rolled the entry back — older tool set, older cachedAt. It now reads the store first, like the scrub always has, and grafts only the token block onto the stored entry; skips when the stored entry never held tokens (no introducing credentials), preserves stored expiresAt for an unrotated token, and folds the sessionId scrub into the same write. Both mutation-verified; changeset updated.
Devin: the previous fix (untagging snapshot-build failures) created a gap its two consumers weren't ready for — the list_changed handler and refreshStaleReplay both keyed "the re-list succeeded, keep going" on `err instanceof MCPCacheWriteError`, so an untagged post-adoption failure (the caller's OAuth provider rejecting inside snapshot()) silently skipped the subscriber announcement and, on the stale path, closed a live connection while blaming the re-list. refresh() swaps `tools` to a fresh array exactly when the re-list succeeded, before persisting — so both sites now compare the reference they captured before the call instead of inspecting the error. Announce/survive whenever adoption happened, whatever broke afterwards. Both mutation-verified.
cortex round on 7678fff: 1. gen-version.mjs interpolated package.json's version into a single-quoted TS literal unescaped — a version containing a quote would inject code into the committed, import-executed src/version.ts. Now rejected up front by a semver charset allow-list ([0-9A-Za-z.+-]), which provably cannot break out of the literal; verified by driving a quote-bearing version through the script and watching it exit 1. (Allow-list over JSON.stringify: the double-quoted output would fight biome's single-quote formatting, and CI diffs the generated file.) 2. clientInfo's JSDoc said "sent during initialize" — a mechanism revision 2026-07-28 removed. It now describes both transports: the initialize handshake on 2025-11-25, the per-request _meta envelope on 2026-07-28.
There was a problem hiding this comment.
cortex panel verdict: comment — details in the consolidated review comment.
Devin: skipping the replay write-back also stopped keeping stored auth.headers in sync with the caller's live auth — the maintenance write only re-added OAuth token rotation, so a bearer/headers caller who rotated their API key kept warm-hitting the cache while the store held the old secret, which authFromSnapshot would later reconnect with on a credential-bearing rehydrate. New third maintenance case, under the exact invariants of the token graft: read the store back first, move only the header block onto the stored entry (tools/cachedAt stay the store's), skip when the stored entry never held headers (no introducing credentials), no write when the headers are unchanged, sessionId scrub composes into the same write. Keys on the CALLER's auth, not effectiveAuth — auth derived from the input snapshot is by definition not a rotation. Three tests pin rotated/unchanged/no-introduce; mutation-verified. Changeset updated to describe all three maintenance writes.
…, no field loss Devin: graftRotatedTokens still returned a rewritten entry for an UNROTATED token (expiry-preserved, but a store.set on every warm OAuth replay), unlike its sibling graftStaticHeaders which no-ops on equality. That broke the zero-store-ops warm-path claim for OAuth callers, re-touched TTL-extending stores the changeset says warm hits leave alone, and wholesale-replaced the stored token block — dropping fields the provider's tokens() no longer reports (e.g. a refreshToken held elsewhere) with no actual rotation. Same access token now means no graft at all; the expiry-preservation dance is gone because the case it served no longer writes. A rotated token still replaces the block with its fresh restamp. Mutation-verified; changeset updated.
There was a problem hiding this comment.
cortex panel verdict: approve — details in the consolidated review comment.
@openrouter/mcpnow works against any MCP server, whether it speaks2025-11-25or the new2026-07-28revision. Previously it only spoke the 2025 handshake.Correcting this PR's original premise
This PR started as deprecation prep, on my conclusion that no released SDK could speak
2026-07-28. That was wrong. I checked only@modelcontextprotocol/client@2.0.0's default wire behavior — which is indeed2025-11-25— and never looked for an opt-in. There is one:ClientOptions.versionNegotiation. So instead of annotating three surfaces as doomed, this PR makes both revisions work.How it works
protocolNegotiation?: 'legacy' | 'auto' | { pin: string }, defaulting to'auto'. The SDK itself defaults to'legacy'; a library whose job is "point it at a server" should reach both eras with nothing configured.2026-07-28server/discover, then requests with the_metaenvelope +Mcp-Methodheader. Noinitialize— the handshake is removed (SEP-2575).2025-11-25and earlierserver/discover, then fallback toinitialize+notifications/initialized, byte-equivalent to a 2025-only client.'legacy'skips the probe, which matters on flaky servers: over HTTP a probe timeout is an outage and rejects, where'legacy'may still connect.{ pin }fails loudly rather than falling back.A silent bug this caught
callToollost its middle argument in v2. The old three-arg call would have putsignalandonprogressin a slot the SDK doesn't read — disabling cancellation and progress streaming with every other test still passing.tests/unit/call-tool-shape.test.tsguards it; mutation-verified that restoring the old shape fails all 3 tests.Commits
chore(mcp)!— dependency swap; all compile-forced changes (specifiers,callToolarity, guard deletion, method-name-first handlers)test(mcp)— dual-era coverage overInMemoryTransport, before behavior changesfeat(mcp)!—protocolNegotiation, default'auto'fix(mcp)— pre-existing staleness gap on the direct rehydrate pathdocs(mcp)— retire the deprecations this made falsechore(mcp)— changeset for1.0.0Breaking changes
OAuthClientProvider: change the import specifier;tokens()now returnsStoredOAuthTokens(same fields, so most providers compile unchanged). Now re-exported asMCPOAuthClientProviderso consumers stop depending on our dependency's path.major→1.0.0, per @LukasParke's call. Two breaking notes and a dependency major justify declaring the API stable rather than shipping a pre-1.0 minor.protocolNegotiationdefaults to'auto'(SDK default:'legacy'), so every connection's first request is aserver/discoverprobe. Not a connectivity break: withprotocolNegotiationunset, a failed connect retries once with'legacy', so a probe-hostile proxy/WAF/gateway still connects as before. Setting it explicitly — including to'auto'— opts out of that retry.Also fixed
clientInfosaid0.1.0while the package was0.0.1— live in the published tarball. Now generated frompackage.jsonso it can't drift.staleness.maxAgeMswas only checked bycreateMCPTools; a directrehydrateMCPTools()replayed snapshots of any age.onElicitationun-deprecated — it works on both revisions, since the multi-round-trip driver (SEP-2322) routesinput_requiredthrough the same handler.API example
Verification
protocol-era.test.tsruns a hand-rolled MCP server overInMemoryTransport— no network, no fixture process, noMCP_TEST_URLgatecloseQuietlyto a bare.catch()fails all four sync-throw tests;cacheMode: 'use'fails the forced-re-read test; re-addingsessionIdforwarding fails the replay test; swapping thelist_changedkey for another valid method fails all four dispatch testsTwo field names I got wrong first and corrected:
server/discoverreturnssupportedVersions(notprotocolVersions), andinputRequestsis an object keyed by request id (not an array).Tracked in DEV-738.
🤖 Generated with Claude Code