Skip to content

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

Merged
LukasParke merged 58 commits into
mainfrom
feat/mcp-2026-07-28-prep
Aug 4, 2026
Merged

feat(mcp)!: support both MCP protocol revisions (2025-11-25 and 2026-07-28)#86
LukasParke merged 58 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.

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

This comment was marked as resolved.

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

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

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.

@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.

cortex panel verdict: comment — details in the consolidated review comment.

devin-ai-integration[bot]

This comment was marked as resolved.

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.

@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.

cortex panel verdict: request changes — details in the consolidated review comment.

devin-ai-integration[bot]

This comment was marked as resolved.

…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.
@LukasParke

Copy link
Copy Markdown
Contributor Author

Re: cortex review on 75740cc

README Node 20 (fixed, 328c23a): the install section now states the Node 20+ floor and where it comes from, so consumers learn it during setup rather than from an install warning.

cacheCredentials JSDoc (fixed, 328c23a): the hover doc no longer promises a persisted "session". It now says exactly what is stored (bearer/header values or the OAuth provider's current tokens) and that session ids are never serialized — SEP-2567 removed protocol sessions and a rehydrate always performs a fresh handshake.

list_resources cacheMode configurability (nit — not taking): keeping 'refresh' unconditional is deliberate, and the comment block above listRequestOptions carries the full reasoning. A listing's one job is to say what exists now: a model that creates a resource and immediately lists must see it, or the write reads as silently failed. Making that configurable adds a public API surface whose only use is opting into confusing-the-model, to save a round trip on a call that is already cheap (name + URI + metadata — the contents, which are the expensive part, still honour the server's ttlMs). If a real workload surfaces where listing volume matters, a scoped option can ship then without a breaking change — the default would stay 'refresh' either way.

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

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

This comment was marked as resolved.

cortex-github-agent[bot]

This comment was marked as resolved.

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.

@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.

cortex panel verdict: comment — details in the consolidated review comment.

Comment thread packages/mcp/src/resource-tools.ts
devin-ai-integration[bot]

This comment was marked as resolved.

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

This comment was marked as resolved.

…, 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.

@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.

cortex panel verdict: approve — details in the consolidated review comment.

@LukasParke LukasParke added the cortex-keep-updated cortex keeps this PR up to date with its base branch label Aug 4, 2026
@LukasParke
LukasParke merged commit 53d71cc into main Aug 4, 2026
6 checks passed
@LukasParke
LukasParke deleted the feat/mcp-2026-07-28-prep branch August 4, 2026 14:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cortex-keep-updated cortex keeps this PR up to date with its base branch

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant