Skip to content

fix(mcp): drop background reconnects and link expiry: quick attempts, then failed until reconnected - #2778

Closed
mattzcarey wants to merge 8 commits into
fix/mcp-review-followupsfrom
fix/mcp-reconnect-like-claude-code
Closed

mattzcarey wants to merge 8 commits into
fix/mcp-review-followupsfrom
fix/mcp-reconnect-like-claude-code

Conversation

@mattzcarey

@mattzcarey mattzcarey commented Oct 29, 2026 •

Copy link
Copy Markdown
Member

Removes the background retry and link-expiry machinery from the MCP client. Servers now behave the way Claude Code's do: a connection makes a few quick attempts (the server's retry options, 3 by default), each one a connect plus discovery. If they all fail, the failure is recorded and the server stays failed across wakes until someone calls reconnectMcpServer(id). A server waiting for authorization keeps the link it was given until the user completes it or reconnects.

Stacked on #2761, part of the work on #2658. The stack had grown background work nobody asked for:

  • A durable reconnect schedule (feat(mcp): durable reconnect and authorization-expiry jobs #2662): it retried a failed server every 30 minutes for a day, each attempt waking the object. A server that is down usually stays down.
  • A link-expiry job: it recorded links as expired on a timer.
  • Link refresh in addMcpServer(): it swapped a dead link for a new one.

We can't know when a link expires. That's the authorization server's call, and when the user clicks a dead link the callback already reports it. main went the other way and recorded nothing, so every wake retried a failed server.

What goes:

  • the durable connect job, its backoff policy, the 24h give-up and the reconnect manager option
  • the auth-expiry job, the 24h link cap and the expired-link check on wake
  • addMcpServer()'s dead-link refresh and the authorization-expired reason
  • jobs.ts and the manager's onJob; the MCP client no longer uses the job queue

None of this has been released. Lifecycle logs and drops any leftover mcp job from a preview build.

What stays:

  • recorded failures and refusals
  • reconnectMcpServer()
  • the step-up limit and the mid-session re-authorization checks

addMcpServer() on a registered server returns ready or the pending link as is, and throws for a failed server. The error names reconnectMcpServer(). A stored link that isn't an absolute http(s) URL, which only happens with corrupted storage, still starts a new flow as on main.

The stack now ships one changeset, here. The lower PRs drop theirs, so the release notes describe only what changed for users.

New tests cover the new behaviour:

  • 3 attempts hit a down server before it counts as failed, counted by a new 503 counter on the msw server.
  • A catalog that fails once is retried. This test fails without the fix.
  • A server down on wake stays failed across a second wake without contacting the server, and addMcpServer() reports it without contacting the server either.
  • A waiting server's link survives wakes and addMcpServer() after it stops working. Clicking it reports the failure, and reconnecting gives a working link.

The MCP suite (700 tests), typecheck and lint pass locally. CI covers the rest.

@changeset-bot

changeset-bot Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 5843710

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
agents Minor
@cloudflare/agent-think Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

devin-ai-integration[bot]

This comment was marked as resolved.

@agent-think

agent-think Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

🟢 agents import sizes: 4 entry points changed, no growth

Entry point Exports Largest gzip change Size now
🟢 agents/mcp 15 resized up to -1.3 KiB (-0.35%) 361.6 KiB
🟢 agents/mcp/client 1 resized -1.2 KiB (-0.71%) 167.5 KiB
🟢 agents 5 resized up to -1.2 KiB (-0.44%) 272.2 KiB
🟢 agents/chat-sdk 3 resized up to -1.2 KiB (-0.42%) 274.5 KiB
Changed exports (24)
Import Gzip change Size now
🟢 agents/mcp#getMcpAuthContext -1.3 KiB (-0.35%) 361.6 KiB
🟢 agents/mcp#McpAgent -1.3 KiB (-0.35%) 361.6 KiB
🟢 agents/mcp#SSEEdgeClientTransport -1.3 KiB (-0.35%) 361.6 KiB
🟢 agents/mcp#StreamableHTTPEdgeClientTransport -1.3 KiB (-0.35%) 361.7 KiB
🟢 agents/mcp#RPC_DO_PREFIX -1.3 KiB (-0.34%) 361.5 KiB
🟢 agents/mcp#MCP_SERVER_ID_MAX_LENGTH -1.2 KiB (-0.34%) 361.6 KiB
🟢 agents/mcp#RPCServerTransport -1.2 KiB (-0.34%) 361.6 KiB
🟢 agents/mcp#DurableObjectEventStore -1.2 KiB (-0.34%) 361.6 KiB
🟢 agents/mcp#ElicitRequestSchema -1.2 KiB (-0.34%) 361.6 KiB
🟢 agents/mcp#normalizeServerId -1.2 KiB (-0.34%) 361.6 KiB
🟢 agents/mcp#WorkerTransport -1.2 KiB (-0.33%) 365.4 KiB
🟢 agents/mcp#RPCClientTransport -1.2 KiB (-0.34%) 361.6 KiB
🟢 agents/mcp/client#MCPClientManager -1.2 KiB (-0.71%) 167.5 KiB
🟢 agents#routeAgentEmail -1.2 KiB (-0.44%) 272.2 KiB
🟢 agents#getCurrentAgent -1.2 KiB (-0.44%) 272 KiB
🟢 agents#StreamingResponse -1.2 KiB (-0.43%) 272 KiB
🟢 agents#Agent -1.2 KiB (-0.43%) 272 KiB
🟢 agents/mcp#createMcpHandler -1.2 KiB (-0.29%) 408.8 KiB
🟢 agents#DEFAULT_AGENT_STATIC_OPTIONS -1.2 KiB (-0.43%) 272 KiB
🟢 agents/chat-sdk#ChatSdkStateAdapter -1.2 KiB (-0.42%) 274.5 KiB
🟢 agents/chat-sdk#createChatSdkState -1.2 KiB (-0.42%) 274.6 KiB
🟢 agents/chat-sdk#ChatSdkStateAgent -1.1 KiB (-0.42%) 273.7 KiB
🟢 agents/mcp#experimental_createMcpHandler -1.1 KiB (-0.28%) 396 KiB
🟢 agents/mcp#createLegacyMcpHandler -1.1 KiB (-0.28%) 395.9 KiB
How this works

Each runtime export is bundled on its own, minified, and gzipped. Changes smaller than 100 B, or smaller than 1% and 1 KiB, are ignored. Growth over 10% or 5 KiB is marked 🔴. This report is informational and does not fail CI. The workflow artifact contains every measurement.

Compared 3f5beb70 → 58437106 · workflow run · reported by agent-think[bot]

@mattzcarey mattzcarey changed the title fix(mcp): reconnect like Claude Code: quick attempts, then failed until reconnected fix(mcp): drop background reconnects and link expiry: quick attempts, then failed until reconnected Oct 29, 2026
devin-ai-integration[bot]

This comment was marked as resolved.

@mattzcarey
mattzcarey added this pull request to stack #2750 October 29, 2026 18:57
@mattzcarey
mattzcarey force-pushed the fix/mcp-reconnect-like-claude-code branch from f518263 to f5cdc9a Compare October 29, 2026 18:59
@pkg-pr-new

pkg-pr-new Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

agents

npm i https://pkg.pr.new/agents@2778

@cloudflare/ai-chat

npm i https://pkg.pr.new/@cloudflare/ai-chat@2778

@cloudflare/codemode

npm i https://pkg.pr.new/@cloudflare/codemode@2778

hono-agents

npm i https://pkg.pr.new/hono-agents@2778

@cloudflare/shell

npm i https://pkg.pr.new/@cloudflare/shell@2778

@cloudflare/think

npm i https://pkg.pr.new/@cloudflare/think@2778

@cloudflare/voice

npm i https://pkg.pr.new/@cloudflare/voice@2778

@cloudflare/worker-bundler

npm i https://pkg.pr.new/@cloudflare/worker-bundler@2778

commit: 5843710

…il reconnected

A connect makes the server's quick attempts (3 by default) and then settles.
A failure is recorded and stays across wakes; nothing retries on a timer.
reconnectMcpServer() is the retry. addMcpServer() throws for a failed
server, as on main, and replaces a dead or expired link with a fresh flow,
as on main. Removes the durable connect job, its backoff policy, the 24h
give-up and the reconnect option; the link-expiry job stays.
… discovery with connect

A server in authenticating keeps the link it was given, across wakes and
addMcpServer() calls, until someone reconnects it. Whether the link still
works is the authorization server's call; the callback reports it when it
does not. Removes the link-expiry job (and with it jobs.ts and the
manager's onJob), the 24h link cap, the expired-link check on wake, the
dead-link refresh in addMcpServer() and the authorization-expired reason.

Each quick attempt is now a connect plus discovery, so a catalog that fails
to load once does not leave the server failed.
@mattzcarey
mattzcarey force-pushed the fix/mcp-reconnect-like-claude-code branch from 3175815 to 273a474 Compare October 29, 2026 19:28
…py path

Older links after Reconnect, reopened callbacks, tool calls while a step-up
waits, Reconnect on a ready server or pressed twice, an unknown server, a
server that goes down mid-session, and a handshake that fails once.
devin-ai-integration[bot]

This comment was marked as resolved.

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

Devin Review found 2 new potential issues.

Devin Review

Comment on lines +263 to +269
if (
conn.connectionState === MCPConnectionState.AUTHENTICATING &&
!this.isLiveStepUp(serverId, conn)
) {
const authUrl = this.pendingLink(serverId, conn);
if (authUrl) {
throw new MCPAuthorizationRequiredError(serverId, authUrl, {

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.

🔴 Existing grant blocked after wake

When an agent wakes with a pending step-up, recoverRequest rejects even requests covered by the existing grant. liveStepUps is empty after restore, although the OAuth token and pending link persist.

Learn more

A scope step-up leaves a usable session on the connection while the user decides. restore rebuilds a pending connection as authenticating after a wake, but the in-memory liveStepUps map is not rebuilt. The new guard therefore rejects every call without trying the still-valid grant. A first authorization must still return the pending link immediately, so the guard needs a durable way to distinguish that case from a pending step-up.

Example: Authorize files:read, request write and leave its scope link open, then let the agent wake. read now returns MCPAuthorizationRequiredError instead of succeeding on the existing grant.

Recommended fix: Persist whether the pending flow started on an established session, restore that distinction with the connection, and use it in recoverRequest. Preserve the immediate link response for servers awaiting their first grant.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +462 to +465
const declined = params.get("error");
if (declined && this.isLiveStepUp(serverId, conn)) {
await consumeStaleState(serverId, provider, state);
return this.declineStepUp(serverId, conn, declined);

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.

🔴 Old denial cancels current step-up

When two step-up links exist, declining the older link makes declineStepUp clear the newer link. Approving the newer link then reports stale success without exchanging its code, leaving the scope ungranted.

Learn more

The authorization provider can issue several valid links before the user responds; the server saves the latest link as its active continuation. The callback checks that a state is valid but does not compare it to the active link. Denying an earlier link then clears the active link and moves the connection to ready; isPastAuthorization treats the later approval as already completed. That callback reports success without exchanging its authorization code.

Example: Two write requests produce links A and B. The user declines A and approves B; the agent reports B as authorized, but a later write still needs step-up.

Recommended fix: Match the callback state against the current pending authorization link before restoring the previous connection state. Consume earlier denied states without changing the active flow; keep B available until B completes or is declined.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

@mattzcarey

Copy link
Copy Markdown
Member Author

Replaced by #2782 (MCP client changes) and #2783 (examples).

@mattzcarey mattzcarey closed this Oct 30, 2026
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