Skip to content

feat(mcp)!: support both MCP protocol revisions (2025-11-25 and 2026-07-28) - #86

Open
LukasParke wants to merge 41 commits into
mainfrom
feat/mcp-2026-07-28-prep
Open

feat(mcp)!: support both MCP protocol revisions (2025-11-25 and 2026-07-28)#86
LukasParke wants to merge 41 commits into
mainfrom
feat/mcp-2026-07-28-prep

Conversation

@LukasParke

@LukasParke LukasParke commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

@openrouter/mcp now works against any MCP server, whether it speaks 2025-11-25 or the new 2026-07-28 revision. Previously it only spoke the 2025 handshake.

// No configuration needed — the right revision is negotiated per server.
const mcp = await createMCPTools({ url: 'https://mcp.example.com/mcp' });

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 indeed 2025-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.

Server What goes on the wire (verified)
2026-07-28 server/discover, then requests with the _meta envelope + Mcp-Method header. No initialize — the handshake is removed (SEP-2575).
2025-11-25 and earlier server/discover, then fallback to initialize + 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

callTool lost its middle argument in v2. The old three-arg call would have put signal and onprogress in a slot the SDK doesn't read — disabling cancellation and progress streaming with every other test still passing. tests/unit/call-tool-shape.test.ts guards it; mutation-verified that restoring the old shape fails all 3 tests.

Commits

  1. chore(mcp)! — dependency swap; all compile-forced changes (specifiers, callTool arity, guard deletion, method-name-first handlers)
  2. test(mcp) — dual-era coverage over InMemoryTransport, before behavior changes
  3. feat(mcp)!protocolNegotiation, default 'auto'
  4. fix(mcp) — pre-existing staleness gap on the direct rehydrate path
  5. docs(mcp) — retire the deprecations this made false
  6. chore(mcp) — changeset for 1.0.0

Breaking changes

  • OAuth providers must satisfy v2's OAuthClientProvider: change the import specifier; tokens() now returns StoredOAuthTokens (same fields, so most providers compile unchanged). Now re-exported as MCPOAuthClientProvider so consumers stop depending on our dependency's path.
  • major1.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.
  • protocolNegotiation defaults to 'auto' (SDK default: 'legacy'), so every connection's first request is a server/discover probe. Not a connectivity break: with protocolNegotiation unset, 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

  • Self-reported clientInfo said 0.1.0 while the package was 0.0.1 — live in the published tarball. Now generated from package.json so it can't drift.
  • staleness.maxAgeMs was only checked by createMCPTools; a direct rehydrateMCPTools() replayed snapshots of any age.
  • onElicitation un-deprecated — it works on both revisions, since the multi-round-trip driver (SEP-2322) routes input_required through the same handler.

API example

import {
  createMCPTools,
  rehydrateMCPTools,
  MCPCacheWriteError,
  MCPStaleSnapshotError,
  type MCPOAuthClientProvider,
  type MCPProtocolRevision,
} from '@openrouter/mcp';

// Default: probes with `server/discover`, then speaks whichever revision the
// server offers. If the probe is refused — a gateway that rejects unknown
// methods — this retries once with the 2025-era handshake, so a server that
// worked before still connects. No configuration needed.
const mcp = await createMCPTools({ url: 'https://mcp.example.com/mcp' });

// Skip the probe. A performance choice now, not a compatibility one.
const legacy = await createMCPTools({
  url: 'https://mcp.example.com/mcp',
  protocolNegotiation: 'legacy',
});

// Explicit modes are honoured exactly — this fails rather than degrading.
const strict = await createMCPTools({
  url: 'https://mcp.example.com/mcp',
  protocolNegotiation: 'auto',
});

// New export: the two known revisions autocomplete and typo-check, while any
// other string still compiles, so pinning a future revision needs no cast.
const revision: MCPProtocolRevision = '2026-07-28';
await createMCPTools({
  url: 'https://mcp.example.com/mcp',
  protocolNegotiation: { pin: revision },
});

// New export: `staleness.maxAgeMs` is now enforced on every rehydrate path,
// including `reconnectOnExpiry: false`, which previously replayed silently.
try {
  await rehydrateMCPTools({
    snapshot,
    staleness: { maxAgeMs: 60_000 },
    reconnectOnExpiry: false,
  });
} catch (err) {
  if (err instanceof MCPStaleSnapshotError) {
    // Connection was fine, only the re-list failed — accept the cached tools.
    await rehydrateMCPTools({ snapshot, reconnectOnExpiry: false });
  } else {
    throw err;
  }
}

// OAuth providers: type against the new export rather than the SDK path.
const provider: MCPOAuthClientProvider = myProvider;
await createMCPTools({
  url: 'https://mcp.example.com/mcp',
  auth: { kind: 'oauth', provider },
});

// New option: probe ceiling (default 30s) — raise for slow cold starts.
await createMCPTools({ url: 'https://mcp.example.com/mcp', probeTimeoutMs: 60_000 });

// New export: cache writes are best-effort; catch MCPCacheWriteError from
// refresh() to treat a store outage as fatal anyway.
try {
  await mcp.refresh();
} catch (err) {
  if (!(err instanceof MCPCacheWriteError)) throw err;
}

Verification

  • 626 tests pass (521 agent + 105 mcp), lint + typecheck + structural gate clean
  • New protocol-era.test.ts runs a hand-rolled MCP server over InMemoryTransport — no network, no fixture process, no MCP_TEST_URL gate
  • Every new suite mutation-tested, including the review-driven fixes: removing the legacy retry fails 4 degradation tests and removing its explicit-mode guard fails 6 transport tests; reverting closeQuietly to a bare .catch() fails all four sync-throw tests; cacheMode: 'use' fails the forced-re-read test; re-adding sessionId forwarding fails the replay test; swapping the list_changed key for another valid method fails all four dispatch tests

Two field names I got wrong first and corrected: server/discover returns supportedVersions (not protocolVersions), and inputRequests is an object keyed by request id (not an array).

Tracked in DEV-738.

🤖 Generated with Claude Code

…-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>
devin-ai-integration[bot]

This comment was marked as resolved.

cortex-github-agent[bot]

This comment was marked as resolved.

cortex-github-agent[bot]

This comment was marked as resolved.

@cortex-github-agent cortex-github-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@LukasParke

Copy link
Copy Markdown
Contributor Author

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 2026-07-28 by default, with a note to re-run the wire capture to confirm before committing to it.

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>
devin-ai-integration[bot]

This comment was marked as resolved.

cortex-github-agent[bot]

This comment was marked as resolved.

@cortex-github-agent cortex-github-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

LukasParke and others added 6 commits July 29, 2026 10:48
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>
@LukasParke LukasParke changed the title fix(mcp): correct client version, cover transport fallback, flag 2026-07-28 deprecations feat(mcp)!: support both MCP protocol revisions (2025-11-25 and 2026-07-28) Jul 29, 2026
devin-ai-integration[bot]

This comment was marked as resolved.

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>
cortex-github-agent[bot]

This comment was marked as resolved.

cortex-github-agent[bot]

This comment was marked as resolved.

@cortex-github-agent cortex-github-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
cortex-github-agent[bot]

This comment was marked as resolved.

@cortex-github-agent cortex-github-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

devin-ai-integration[bot]

This comment was marked as resolved.

@devin-ai-integration

Copy link
Copy Markdown
Contributor

Re-review at e4c0273

Verified locally at this head: pnpm typecheck, pnpm lint, pnpm test all clean (590 tests: 521 agent + 69 mcp).

The release fix is correct

The chain publish.yaml → pnpm run version → changeset version && turbo run gen:version closes the Version-PR breakage: packages/mcp/src/version.ts is regenerated in the same commit that bumps package.json, so tests/unit/version.test.ts can't fail on the release PR. The gen:version turbo task (uncached, package.json in / src/version.ts out) generalizes it to any future package with committed generated output. packages/mcp is the only package with a gen:version script today, so turbo run gen:version is a no-op elsewhere.

Two nits on that commit:

  1. pnpm install --lockfile-only — agreeing with the suggestion to drop it, but for a weaker reason than "it re-resolves every range". All internal deps are workspace:*, so changeset version cannot change a single specifier, and pnpm install does not upgrade specifiers that the existing lockfile already satisfies. So the step is a no-op in the expected case rather than a silent-bump hazard — but a no-op in the release commit is still worth deleting, since the failure mode it does have (a lockfile diff nobody is reviewing) has no upside.
  2. turbo.json was reformatted wholesale — every array expanded to one element per line, turning a 5-line addition into a 61-line diff. pnpm run lint is biome check packages/*/src packages/*/tests, so nothing in CI formats root turbo.json; the reformat isn't required by any tool. Restoring the original compact style would make the diff show only the gen:version task.

setNotificationHandler('notifications/tools/list_changed', …) — this one is a false positive

The concern was that a compile-checked SDK schema value was replaced by a bare string that no test verifies, so an SDK rename could silently kill tools/list_changed auto-refresh with the suite green. The string is compile-checked. SDK v2's two-arg overload is generic over a literal union:

setNotificationHandler<M extends NotificationMethod>(
  method: M,
  handler: (notification: NotificationTypeMap[M]) => void | Promise<void>,
): void;

(NotificationMethod = Exclude<ClientNotification['method'] | ServerNotification['method'], TaskNotificationMethod>, index-D4xIIEF6.d.mts:708,2352.) The three-arg overload is the only one that takes method: string, and it requires a schema bundle as its second argument, which this call does not pass. Empirically, mutating the literal in the worktree and running tsc --noEmit:

src/mcp-connection.ts(127,33): error TS2345: Argument of type '"notifications/tools/list_changedX"'
  is not assignable to parameter of type 'NotificationMethod'.
src/mcp-connection.ts(120,5):  error TS2345: Argument of type '"elicitation/createX"'
  is not assignable to parameter of type 'RequestMethod'.

So a rename in the SDK breaks the build exactly as the old schema-value import did — this is not the callTool-arity class of silent failure, and the proposed "assert the literal equals whatever the SDK exports" guard is already enforced by the type system. The residual gap is narrower and worth stating precisely: nothing proves the handler fires, so an SDK change that keeps the method in the union but stops routing tool-list changes through notification handlers would go unnoticed. That's an InMemoryTransport test driving connect(), and it's a nice-to-have, not a blocker.

The 'auto' default — still open, and I agree it needs an answer

versionNegotiation.mode defaults to 'auto' where the SDK defaults to 'legacy', so every existing consumer's first request after upgrading becomes a server/discover probe. By this PR's own documentation a probe timeout over HTTP is an outage and rejects, and the SSE fallback re-probes and fails the same way — so a gateway that hangs or 5xx's on an unknown method goes from working to failing on a version bump.

The current head answers this with a code comment at mcp-connection.ts:174-178. That documents the failure mode where a reader of the fallback path will find it, but .changeset/mcp-dual-protocol-revision.md still lists only the OAuth type change under Breaking, so the consumer who reads the changelog and not the source learns nothing. At minimum that changeset needs a second Breaking bullet naming protocolNegotiation: 'legacy' as the escape hatch.

My preference is the retry: on connect failure under 'auto' when the caller didn't set protocolNegotiation explicitly, retry once with 'legacy' before surfacing MCPConnectionError. That makes 'auto' strictly additive — modern servers get the new era, everything else lands exactly where it does today — and it preserves the "point it at a server and it works" premise that motivated the non-SDK default in the first place. Defaulting to 'legacy' also resolves it, but contradicts that premise for a release. The cost of the retry is one extra attempt on the already-failing path, which is the path where latency matters least.

Changeset code example

.changeset/mcp-dual-protocol-revision.md adds a public option (protocolNegotiation) and a new export (MCPOAuthClientProvider) with no fenced code block. .agents/skills/public-api-examples/SKILL.md requires one in the changeset ("Always") for any public-API change. A three-line ts block showing createMCPTools({ url, protocolNegotiation: 'legacy' }) satisfies it and makes the generated CHANGELOG self-documenting.

structural-gate — the failure is inherited baseline drift, not this PR

Reproduced locally with sentrux 0.5.7. The gate compares against the committed .sentrux/baseline.json, not against main or HEAD~1, and v0.5.7's threshold is a coupling increase greater than 0.05.

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 main in 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.

@cortex-github-agent cortex-github-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@LukasParke

Copy link
Copy Markdown
Contributor Author

Converged — ready for your review at 58b85a9

Devin's re-review of this head came back clean: no new findings, first time in this session. Every actionable item from both reviewers is fixed and resolved. CI is 6/6 green.

What's left is yours, not mine

Three threads are deliberately open. None of them is unaddressed code — each needs a judgement I shouldn't make alone.

1. minor vs major — publishes 0.1.0 or 1.0.0, and can't be unpublished.
My read: minor is right. @openrouter/mcp is 0.0.1, and under semver §4 a pre-1.0 package makes no stability promise, so a minor is the conventional breaking vehicle. major would assert an API stability this package hasn't earned — it has shipped once, and this PR is itself a migration off an SDK major. The repo guideline saying "major for breaking" is written for @openrouter/agent, which is past 1.0. But it's a published artifact, so it's your call.

2. Whether 'auto' stays the default.
Three options, unchanged from earlier: (1) keep it, now documented as breaking; (2) retry once with 'legacy' when the caller didn't set protocolNegotiation explicitly — Devin's preference and mine, making 'auto' strictly additive; (3) default to 'legacy'. I haven't implemented (2) because it changes connect semantics on a ! release, and choosing between "documented breaking change" and "silently self-healing" is a product judgement about what this package promises.

3. Devin's pinned-SSE finding, folded into (2) rather than decided separately — it's a second instance of the same tradeoff. Under the 'auto' default, a pinned transport: 'sse' connection also probes, so the SSE fallback stops being an escape hatch for probe-hostile servers. If (2) is chosen this dissolves; if (1) is chosen, the changeset should name pinned SSE explicitly, since someone who pinned SSE for legacy-server reasons is the most likely to be hit and least likely to expect it.

One related follow-up I chose not to add: a probe-timeout option. The SDK default is bounded at 60s, so nothing hangs — but 60s is a long time to learn a gateway is hostile, and a caller with a latency budget can't shorten it. That escape hatch depends on how (2) lands, so deciding it first would be backwards.

What changed — 13 commits

Nine real bugs, seven of them silent (no error, green tests):

Bug Why it mattered
Failed-connect transport leak SDK doesn't close a transport whose start() threw; 'auto' makes that path common
Replay connection leak Same class, in rehydrate.ts
Staleness ignored under reconnectOnExpiry: false Served unbounded-age tools silently
refresh() returned cached tools v2's response cache made a documented "forced re-read" a lie, up to 24h
list_resources returned cached listings A model's own write looked like it had failed
Replayed sessionId dropped resource tools SDK skips negotiation entirely, leaving capabilities undefined without erroring
sessionId persisted to cache stores Bearer-equivalent credential written for no functionality
Sync-throw in teardown masked real errors And skipped the freshConnect fallback that used to self-heal
structural-gate failure My own refactor pushed rehydrateMCPTools to cc=17

Tests 590 → 619, every fix mutation-verified — I reverted each one to confirm its test genuinely fails. Three findings I pushed back on with evidence rather than changing code (the DELETE-on-close concern, read_resource's TTL, keeping SerializedMCPServer.sessionId on the type for old snapshots).

I was wrong twice and reversed myself: I'd argued the persisted sessionId was "inert data" (it's a credential — Devin's framing was right), and I fixed the sync-throw hazard in one file while leaving three other call sites on the old pattern.

Merge decision is yours — I have not merged

The branch is 11 commits behind main but conflict-free, and CI gates the merge ref, so what's green is the merged result.

…ship 1.0.0

Both open decisions resolved by @LukasParke: major release, and option 2 for
the `'auto'` default.

`'auto'` is now strictly additive. When the caller leaves
`protocolNegotiation` unset, a failed connect is retried once with
`'legacy'`, so a proxy, WAF, or gateway that hangs or 5xx's on an unknown
method connects exactly as it did before this package started probing. Modern
servers still get 2026-07-28. The cost is one extra attempt on a path that was
already failing, which is where latency matters least.

That also closes the pinned-SSE finding without separate work: all three
`makeClient` sites — pinned SSE, Streamable HTTP, and the SSE fallback — sit
under the retry, so the two-transport fallback stops being a single point of
failure against probe-hostile infrastructure.

The retry fires on any failure rather than only on probe-shaped errors.
Inspecting the cause would save a wasted attempt against a genuinely dead
server, but it would couple this to SDK error codes, and a reshaped error
would silently disable the degradation — the exact failure mode this review
kept surfacing.

An explicit `protocolNegotiation` is honoured verbatim, including `'auto'`:
naming a mode means accepting its failures, and silently overriding a
`{ pin }` would defeat pinning. So `'legacy'` becomes a performance choice
rather than a compatibility one, and the docs now say so.

`connect()` splits into a public wrapper owning the retry policy and
`connectWithNegotiation` performing exactly one mode, which also keeps it
under the complexity ceiling.

Seven tests, mutation-verified both directions: removing the retry fails 4,
and removing the explicit-mode guard fails 6 transport tests that would
otherwise silently double their attempt counts. The `probeHostile` fake
rejects any probing mode and accepts `'legacy'`, so a test cannot pass unless
the retry really switched modes.

Changeset is `major` — `1.0.0` (verified: semver.inc('0.0.1','major')). The
`'auto'` Breaking note is gone, since it is no longer a break; the OAuth
provider change remains the one breaking item.
devin-ai-integration[bot]

This comment was marked as resolved.

Devin caught two real problems with the degradation I added in 7fc4ce5, and
my own test was the evidence for the first.

**Attempt amplification.** `connectWithNegotiation` already walks Streamable
HTTP then SSE when no transport is pinned, so retrying the whole thing meant
up to four `connect()` calls against an unreachable server where it used to be
two — each with its own request timeout, enough to push a caller past its own
deadline. `expect(state.clientsCreated).toBe(4)` was sitting right there
asserting the amplification I had just documented as "one extra attempt".

The retry now pins the caller's transport preference, so it is a single
attempt: a probe-hostile server takes two attempts, a dead one three.

**Replayed side effects.** Retrying on any failure meant an
`UnauthorizedError` was retried too — re-driving an OAuth provider's
authorization flow, so a second `redirectToAuthorization` and a second PKCE
verifier overwriting the first. Credentials being rejected is not something a
different protocol revision fixes, so the retry is now skipped there. This is
the one place the error's identity is inspected; the retry still fires on any
other failure rather than trying to recognise probe errors, since that would
couple us to SDK error codes.

`isAuthFailure` walks the `cause` chain, because `connectWithNegotiation`
wraps transport errors — a top-level `instanceof` would miss the nested case
and retry anyway. Depth-capped so a cyclic cause cannot hang.

Corrected the counts in the changeset and README, which both understated the
cost. Four tests, mutation-verified: removing the transport pin fails two,
removing the auth skip fails one, and one asserts the nesting that makes the
chain walk necessary.
devin-ai-integration[bot]

This comment was marked as resolved.

…y attempt

Two more from Devin on the bounded retry — the second is a regression I
introduced one commit ago while fixing the first review round.

**The transport pin broke SSE-only legacy servers.** I pinned Streamable HTTP
to cap the attempt count. But a legacy server reachable only over SSE, behind
probe-hostile infrastructure, fails both transports under `'auto'` — and with
the retry pinned to HTTP it never gets offered SSE again. A server that
connected before this PR stopped connecting, which is precisely the regression
the degradation exists to prevent. The retry re-walks the ladder again.

That puts a genuinely dead server back at four dials, two per mode. I had
traded correctness for an attempt count without noticing, so the count is now
asserted as a deliberate decision instead: what matters is that it is a fixed
multiple, not a retry loop.

**The auth guard only saw the last failure.** On the two-transport path
`connectWithNegotiation` discarded `httpErr` entirely — the fallback wrapped
only `sseErr`. So an `UnauthorizedError` from Streamable HTTP followed by an
unrelated SSE failure (the same URL answering 404 to an SSE GET, never
reaching the auth path) left no auth error in the chain, and the retry
re-drove the OAuth flow anyway. Exactly the side effect the guard was written
to prevent, one commit after writing it.

`MCPConnectionError` now carries every underlying failure on `errors`, named
after `AggregateError` rather than inventing a field, and `isAuthFailure`
searches that tree instead of the `cause` spine.

Corrected the attempt counts in the changeset and README — the "three
attempts" claim I wrote last commit was wrong the moment the pin came off —
and added the compilable example the repo's public-API rule requires for the
new `errors` field.

Mutation-verified: re-pinning the transport fails 3 tests, and narrowing
`isAuthFailure` back to the cause spine fails the cross-transport one.
devin-ai-integration[bot]

This comment was marked as resolved.

…401/403

Two more from Devin, both on the retry I added two commits ago.

**`errors` was a partial record.** When the legacy retry also failed, its
rejection propagated untouched and the `'auto'` pass's failures vanished — half
the attempts gone, including any auth-shaped rejection `isAuthFailure` had not
matched. That directly contradicted the contract I had just written on
`MCPConnectionError.errors` ("every failure behind this one, in attempt
order"), so the docs promised a complete record while the code delivered half
of one. `connect()` now wraps a failed retry and concatenates both passes,
flattening nested `errors` so callers iterate a list of real attempts rather
than a tree of wrappers.

**The auth guard only recognised `UnauthorizedError`.** Devin asked me to check
the SDK's taxonomy, and the answer is that it is not one type: the
version-negotiation probe does not route 401/403 through the OAuth flow —
`classifyHttpError` turns them into `SdkHttpError` with
`ClientHttpAuthentication` / `ClientHttpForbidden`. So a probe rejected for auth
reasons was retried, re-driving the flow the guard exists to prevent, on the
most common auth path of all.

Now also matches a duck-typed `status` of 401 or 403. Reading the numeric
status rather than `instanceof SdkHttpError` plus an `SdkErrorCode` comparison:
the status is the stable half of that contract, and it additionally catches a
gateway surfacing 401/403 in some other error shape.

Three tests, each mutation-verified: dropping the aggregation fails the
attempt-count one; removing the status check fails the 403 one; widening the
status check to any number fails the 404 one, which exists so the guard cannot
over-match and silently disable the degradation.
devin-ai-integration[bot]

This comment was marked as resolved.

…ateError

Three from Devin's review of fb89f92.

**The probe could hang for minutes.** `makeClient` left `probe.timeoutMs`
unset, so the SDK gave the probe the full 60s request timeout. Under `'auto'`
the probe is the first request of every connection, and with the legacy retry
re-walking the ladder that is four attempts — ~4 minutes before
`createMCPTools()` rejects, on the path a caller gets with no configuration.
Now bounded at 5s, with `probeTimeoutMs` to raise it for a slow server. A probe
that misses the window isn't lost: it surfaces as a failure the legacy retry
handles, the same path a refusal takes.

This is the option I declined two rounds ago as premature pending the `'auto'`
decision. That decision is made, and Devin's arithmetic showed the exposure is
worse than I'd assumed — so it lands now.

**`errors` could contain wrappers.** `flattenAttempts` only unwrapped when
`errors` was non-empty, but a single-transport pass wraps its one failure with
only `cause` set. On the pinned-HTTP path the aggregate became two opaque
`MCPConnectionError`s, so a caller scanning for a rejected token had to know to
dig through `cause` on some entries and not others — and the field's own doc
promised real attempts.

**`isAuthFailure` ignored `AggregateError` members.** Node's happy-eyeballs
path reports a 401 as an `AggregateError` member rather than a `cause`, so a
spine-only walk missed it and re-drove the OAuth flow. Now reads any array
`errors`, which covers ours and `AggregateError` alike.

All three mutation-verified. The unwrap test initially passed against its own
mutation because I wrote it on the pinned-SSE path, which rethrows raw and never
had the bug; retargeted to pinned HTTP, where it fails without the fix.
devin-ai-integration[bot]

This comment was marked as resolved.

Two from Devin on 57f29e9. The first is a correctness bug I introduced one
commit ago, and my own justifying comment was the wrong reasoning.

**5s could make modern-only servers unreachable.** I argued the tight ceiling
was safe because "a probe that misses the window is not lost: the legacy retry
handles it". That holds for 2025-era servers only. On HTTP the SDK classifies a
probe timeout as an outage and rejects (`classifyProbeOutcome` returns a legacy
verdict for stdio alone), and the legacy retry then sends `initialize` —
removed in revision 2026-07-28 (SEP-2575). So a modern-only server slower than
the ceiling fails *both* passes and does not connect, where the SDK's own
default would have waited and succeeded. Serverless cold starts routinely
exceed a few seconds, which makes that a correctness bug rather than the
latency tradeoff I described.

30s instead: comfortably past a cold start, and still caps the
black-holed-gateway case at roughly half the SDK default rather than four times
it. The JSDoc now states that a probe timeout is unrecoverable instead of
claiming the opposite.

**Pinned SSE rethrew raw while every other path wrapped.** Pre-existing, but
the new `connect()` wrapper made it asymmetric: with `protocolNegotiation`
unset the outer retry aggregated it into an `MCPConnectionError`; with it set
the transport error escaped. Same server, same failure, different `catch`. Now
wrapped like everything else.

Also fixed a test that had started taking 10s in real time: it asserted the
probe bound via `connect`'s `timeout`, which our explicit `probe.timeoutMs` now
overrides, so it was waiting out the real default. Uses `probeTimeoutMs`.

Both mutation-verified: restoring 5s fails the ceiling test, restoring the raw
rethrow fails the wrapping test.
devin-ai-integration[bot]

This comment was marked as resolved.

…tatus

Two from Devin on 961bf65.

**Three JSDoc sites still said 5000** after I raised the constant to 30_000 —
types.ts, rehydrate.ts, and mcp-connection.ts — while the README, changeset,
and tests all said 30s. Anyone reading the IDE hover would have budgeted six
times less than the client actually waits, which is the worst kind of doc bug:
confidently wrong rather than absent.

Fixed, and added a test that pins the JSDoc against the value the code passes,
because a number duplicated across four files has now drifted once and will
again. It greps the source for `defaults to <n>` near the probe option and
compares against what the fake Client observed. Verified it catches the exact
drift Devin found.

**`isAuthStatus` now requires an `Error`.** Devin asked whether any SDK shape
attaches a non-authoritative `status`. Audited: only `SdkHttpError` exposes one,
and its value is the real response status — but the SDK does build log records
with `status: 0`, and a plain object riding in a `cause` is likelier to be a
response or payload than a rejection. Over-matching there would silently
suppress the retry and make a probe-hostile-but-authenticated server
unreachable, which is the regression the retry exists to prevent, so the guard
is worth narrowing even though nothing in v2 trips it today.

Both mutation-verified: reintroducing the 5000 doc fails the drift test,
loosening the `Error` check fails the payload test.
devin-ai-integration[bot]

This comment was marked as resolved.

…e ladder

Two from Devin on ea7474b.

**A store outage failed the whole call.** `refresh()` re-lists and then writes
back, so both steps surfaced identically — and the stale path converted either
into `MCPStaleSnapshotError`, discarding a connection whose tools had just been
read successfully and reporting it as a re-list failure. Worse, the recovery
this PR documents rehydrates through the same store, so it failed too: the
escape hatch was as broken as the thing it escaped.

`writeCache` now tags failures as `MCPCacheWriteError` (subclassing
`MCPCacheError`), the stale path treats that as survivable, and the
construction-time write is best-effort. A handle is fully usable without its
cache entry; the next rehydrate re-reads it. Callers who want a store outage to
be fatal can catch the new type.

**Auth failures fell through the transport ladder.** A 401 on Streamable HTTP
tried SSE with the same `authProvider`, re-entering the SDK's auth path for a
second `redirectToAuthorization` and overwriting the saved PKCE verifier. This
is the same duplicated side effect `connect()`'s retry guard was written to
prevent — I guarded the outer layer and left it intact one level down, then
wrote a test asserting both transports are tried on an auth failure without
noticing that was the hazard.

Three tests repointed: they used a failing cache write as their post-connect
failure trigger, which is no longer fatal. They now use a snapshot with
duplicate tool names, which `buildTools` genuinely rejects.

All three changes mutation-verified.
…t new API

Findings from a full self-review of the final state, requested after 14
incremental review rounds.

**The freshConnect fallback was a third unguarded reconnect layer.** The same
duplicated-OAuth hazard was fixed tonight at two layers — connect()'s legacy
retry skips auth failures, and the transport ladder short-circuits on them —
but rehydrateMCPTools' catch still routed *any* replay failure into
freshConnect with the same auth. A credential rejection on the replay
therefore re-drove the OAuth flow one layer up from both guards. The fallback
now consults the same isAuthFailure, exported (not via the package entrypoint)
so all three layers share one definition and a future widening applies
everywhere.

**MCPCacheWriteError and probeTimeoutMs shipped without the examples the repo
requires.** Same public-api-examples violation as MCPStaleSnapshotError
earlier tonight — new export and new public option, present in prose, absent
from every ts block in the changeset and from the PR body. Both now have
compile-verified examples in both places.

Reviewed and found sound, for the record: the connect flow's three layers
compose without double-teardown (closeQuietly is idempotent and each layer
closes only clients it created); flattenAttempts covers all four wrap shapes;
probeTimeoutMs threads through create/rehydrate/replay/freshConnect
identically to protocolNegotiation; the untouched modules (tool-wrapper,
elicitation, auth-resolver, result-mapper) check out — the progress-pump
generator in tool-wrapper cannot drop a trailing event because finalize flips
`done` only after the queue drain re-runs.
devin-ai-integration[bot]

This comment was marked as resolved.

Devin found the guard I widened two rounds ago cancelling the recovery in
exactly the scenario the recovery exists for. Proxies and WAFs commonly answer
an unknown method like `server/discover` with 403 — not just 5xx or a hang —
and `isAuthStatus` classified any 401/403 as a credential rejection. That
suppressed both the SSE fallback and the legacy retry, so a server behind such
a gateway failed outright where it connected before this PR.

The duplicated-side-effect rationale only holds for OAuth: a second
`redirectToAuthorization`, an overwritten PKCE verifier. With bearer, headers,
or no auth, a retry merely re-sends a request. So the duck-typed status check
is now consulted only when the caller configured `auth: { kind: 'oauth' }`,
threading the auth kind through `isAuthFailure` at all three guard sites.
`UnauthorizedError` stays unconditional — the SDK only throws it from the
provider-wrapped fetch and the authorization flow, so it inherently means a
provider is in play.

Two new tests pin the un-suppressed cases (no auth, bearer) and the existing
suppression tests now configure OAuth. Mutation-verified: un-scoping the
status check fails both new tests.
devin-ai-integration[bot]

This comment was marked as resolved.

Devin caught the guard passing vacuously. The regex required the digits
almost immediately after "efaults to", but mcp-connection.ts writes the
default as "Defaults to\n * `DEFAULT_PROBE_TIMEOUT_MS` (30000)" — symbol
name, newline, and parens in between — so the file produced zero matches and
the for-loop passed on emptiness. The test's stated purpose was to catch the
exact drift that had already happened, and it could not have caught it in one
of the three files it named.

Two changes: the pattern now tolerates up to 80 non-digit characters
(including newlines) between the phrase and the number, and each file must
produce at least one match — zero means the regex drifted from the prose, not
that the file went quiet.

Mutation-verified in the file that was previously uncovered: setting its doc
back to 5000 now fails the test.
devin-ai-integration[bot]

This comment was marked as resolved.

Devin: the auth short-circuit rethrows the first pass's error untouched, and
a single-transport pass builds that error with only `cause` set — so `errors`
was `[]` while the docs promised "every underlying failure, flat and in
attempt order". A caller iterating `errors` without also inspecting `cause`
saw nothing in precisely the auth case, where the rejection is the one thing
worth finding.

Fixed in the constructor rather than at the rethrow site: `errors` now
defaults to `[cause]` when no explicit list is given, so every construction
site gets the uniform contract and `flattenAttempts`' unwrap arm becomes a
formality rather than load-bearing. The doc's "Empty when only one transport
was attempted" claim is gone — it described the bug as if it were the design.
Changeset example comment corrected to match.

Test pins the exact shape Devin described: an auth failure on the implicit
default yields errors of length 1 containing the UnauthorizedError.
Mutation-verified — reverting the constructor default fails it.
devin-ai-integration[bot]

This comment was marked as resolved.

Two from Devin on ad8ea0a, both consequences of MCPCacheWriteError now being
a distinct, throwing type.

**A failed write silenced tool-change subscribers.** `refresh()` adopts the
new tools before it writes the snapshot back, so when the write rejected, the
auto-refresh handler's `.then()` never ran and listeners were never told —
while `handle.tools` already returned the new set. Subscribers stayed
permanently out of sync, and the handler's own comment ("listeners keep the
last good tool set") had become false: the set was already swapped. The
handler now converts MCPCacheWriteError into a notification with the current
tools; a failed re-list stays silent, which is correct because nothing was
swapped.

**A failed read was fatal while writes were best-effort.** `store.get` was
awaited unguarded in tryCacheHit, so a store blip on the lookup rejected
`createMCPTools()` when a plain miss would have connected fine — meaning the
release note's "a store outage leaves you with a working handle" only held if
the outage arrived after the read. A failing read is now a miss.

Both mutation-verified: re-gating notification on the write fails the
subscriber test; making the read fatal again fails the miss test.
devin-ai-integration[bot]

This comment was marked as resolved.

Devin: two behaviors of `reconnectOnExpiry` changed in this PR and neither
JSDoc site was updated — the hover text still promised "transparently fall
back on any connection failure".

1. An auth failure now bypasses the fallback (the fallback reuses the same
   auth, so retrying cannot succeed and would re-drive an OAuth flow); those
   reject with MCPCacheError instead.
2. An over-age snapshot (staleness.maxAgeMs) is now a fallback trigger, which
   the option's one-line doc never mentioned.

Both the field JSDoc and the rehydrateMCPTools block now state the full
trigger list and the auth exception, per the repo's changed-option-semantics
rule in public-api-examples/SKILL.md.
@LukasParke

Copy link
Copy Markdown
Contributor Author

The CHANGES_REQUESTED at 454d0bc is stale — its one blocker was addressed before 6080135, and head is now 92b5fe7 (main merged in). Walking the four findings:

🟠 major — notifications/tools/list_changed handler exercised by no test. Addressed exactly as narrowed. The seam exists: makeClientForTest is exported from mcp-connection.ts:150 (deliberately not re-exported from the package). The dispatch test is tools/list_changed dispatch in protocol-era.test.ts:406, which drives our own makeClientForTest client over a real linked InMemoryTransport pair, has the fake server emit notifications/tools/list_changed (line 448), and asserts the setToolListChangedHandler callback fires. The comment at :392 records the mutation check you asked for — mutating the key to notifications/tools/list_changedX fails the test. So both halves (key + dispatch) are now covered.

🟡 _info / DEFAULT_CLIENT_INFO not asserted — still open, not addressed in this PR.

🟡 failed Streamable HTTP client not close()d before SSE fallback — still open; you flagged it as pre-existing, and I'd rather not widen this PR's surface to fix it. Worth its own PR.

🟡 { pin: string } accepts any string — still open. MCPProtocolNegotiation is a union for the mode, but pin remains an unconstrained string, so a mistyped revision still typechecks.

Verification at head: @openrouter/agent 643 tests, @openrouter/mcp 127 tests, all passing, no type errors, lint clean.

Three minors left open above — flagging for a human call on whether any should block, since none is the original blocker.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant