Skip to content
Closed
5 changes: 5 additions & 0 deletions .changeset/mcp-client-reauthorization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agents": minor
---

MCP client: requests that need the user to authorize again throw `MCPAuthorizationRequiredError` with a link, and failed servers stay `failed` until the new `reconnectMcpServer(id)`. See [MCP client](https://github.com/cloudflare/agents/blob/main/docs/agents/mcp-client.md#failures-and-reconnecting).
10 changes: 3 additions & 7 deletions design/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,8 @@ The SDK v2 client negotiates Stateless first and falls back to Legacy on the sam
| Module | Owns |
| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `servers.ts` | Each server's life: register, connect, discover, restore, settle, reconnect, close, remove, migrate. `afterFailure` is the one place that decides retry or stop. |
| `authorization.ts` | Links and OAuth: entering `authenticating` (round limit, spent links), callbacks, request recovery, link expiry, provider creation and storage |
| `authorization.ts` | Links and OAuth: entering `authenticating` (round limit, spent links), callbacks, request recovery, provider creation and storage |
| `status.ts` | The transitions both share: stop with a recorded failure, ready, save the session |
| `jobs.ts` | The durable `connect` and `auth-expiry` jobs and the reconnect policy; schedules, moves and cancels, decides nothing |
| `elicitation.ts` | Elicitation handlers and the capabilities they advertise |
| `legacy-connect.ts` | The deprecated `connect()` |
| `server-store.ts` | Every SQL statement over `cf_agents_mcp_servers` |
Expand All @@ -108,14 +107,11 @@ The SDK v2 client negotiates Stateless first and falls back to Legacy on the sam

Connect, discovery, establish, OAuth callback completion and restore for one server run through its lane, so they never interleave on one connection.

Background work that must happen even if the isolate dies lives in the Lifecycle job queue (`jobs.ts`), not in memory:

- `connect:<serverId>`: after a failed connect or discovery, the next attempt on a 30s → 30m backoff. Each run is one bounded attempt. Restore leaves a server with a pending job `failed` (showing the job's last error) instead of connecting early. Ready or `authenticating` cancels it; 24h without success records a `reconnect-exhausted` failure. A refused authorization is never retried: a 401 no link can answer, a 403 left after any step-up, or an OAuth error other than `server_error` / `temporarily_unavailable` records an `authorization-failed` failure instead of scheduling the job. `migrateServerId` moves both jobs to the new id.
- `auth-expiry:<serverId>`: armed with every persisted `auth_url`. If the link's `state` can no longer be redeemed when it fires, the server records an `authorization-expired` failure instead of minting a new link. The OAuth callback clears `auth_url` before it redeems the state, so a dead link found on wake was never completed and is recorded the same way.
A connect (add, restore, after the OAuth callback, explicit reconnect) makes the server's quick in-place attempts (`retry`, 3 by default) and then settles. A failure is recorded and stays: nothing retries on a timer, and restore shows it without connecting. This matches Claude Code: a few quick attempts, then `failed` until someone reconnects. A refused authorization skips even the quick attempts: a 401 no link can answer, a 403 left after any step-up, or an OAuth error other than `server_error` / `temporarily_unavailable` records `authorization-failed`; anything else records `connect-failed`.

Re-authorization follows the spec's step-up retry limit (`authorization-rounds.ts`): rounds are counted per requested scope set, a round counts once its callback completed and the server asked for the same scopes again, and a fourth round within an hour records an `authorization-exhausted` failure instead of showing a link. Several links for one pending ask count as one round.

A recorded failure (`failure` column, added on first use) is terminal: restore shows it without connecting, and only an explicit reconnect (`reconnectMcpServer` / `reconnectServer`, or the manager's `connectToServer` and `establishConnection`) clears it. `addMcpServer` for a registered server reconnects it when it isn't connected, at most once per first backoff step (apps call it from `onStart` and `onConnect`), and only an `authorization-expired` failure is cleared that way; refused, denied, step-up-exhausted and given-up servers wait for `reconnectMcpServer`. A denied or failed callback, a link older than 24h, and a 401 right after a completed authorization are recorded the same way; the manager remembers the state each callback redeemed so that link is never shown again. A server is `authenticating` exactly when the manager has persisted the `auth_url` its provider produced; connect, discovery and request-time re-authorization (scope step-up, failed refresh) all enter that state through one transition. Restore trusts a persisted `auth_url` only while its OAuth `state` can still be redeemed, and otherwise reconnects. Work that outlives `removeServer` (a connect, discovery or code exchange) checks it still owns the connection before writing anything back.
A recorded failure (`failure` column, added on first use) is terminal: restore shows it without connecting, and only an explicit reconnect (`reconnectMcpServer` / `reconnectServer`, or the manager's `connectToServer` and `establishConnection`) clears it. `addMcpServer` for a registered server never retries a failure (apps call it from `onStart` and `onConnect`); it throws the recorded error. A waiting server keeps the link it was given until someone reconnects it: whether the link still works is the authorization server's call, and the callback reports it when it does not. A denied or failed callback and a 401 right after a completed authorization are recorded the same way; the manager remembers the state each callback redeemed so that link is never shown again. A server is `authenticating` exactly when the manager has persisted the `auth_url` its provider produced; connect, discovery and request-time re-authorization (scope step-up, failed refresh) all enter that state through one transition. Restore puts a server with a persisted `auth_url` back in `authenticating` on that link, as on main. Work that outlives `removeServer` (a connect, discovery or code exchange) checks it still owns the connection before writing anything back.

## Verification matrix

Expand Down
5 changes: 3 additions & 2 deletions design/test-coverage-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,10 @@ transport against an OAuth-protected MCP server served through msw, with
callbacks routed through the worker, Lifecycle jobs driven by alarms, and
object restarts between steps. It covers mid-session step-up, refused
refreshes, the step-up round limit, removal and re-registration races,
durable reconnects and authorization link expiry.
connection failures that stay failed until reconnected, and waiting links
that stay until reconnected.
`packages/agents/src/tests/mcp/client-capability.test.ts` covers the same
reconnect and expiry jobs for a standalone `MCPClientManager` on a plain
failures and links for a standalone `MCPClientManager` on a plain
Lifecycle object.

### Accepted coverage gaps
Expand Down
50 changes: 20 additions & 30 deletions docs/agents/mcp-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ This weakens OAuth mix-up protection. Do not enable it for unknown servers or as

### Retry Options

Configure retry behavior for connection and reconnection attempts:
Configure how many quick attempts a connection makes before it counts as failed:

```typescript
await this.addMcpServer("github", "https://mcp.github.com/mcp", {
Expand All @@ -174,36 +174,27 @@ await this.addMcpServer("github", "https://mcp.github.com/mcp", {
});
```

These options are persisted and used when reconnecting after hibernation or after OAuth completion. Default: 3 attempts, 500ms base delay, 5s max delay. See [Retries](./retries.md) for more details.
These options are persisted and used for every connection attempt: when the server is added, when it is restored after hibernation, after OAuth completes, and on `reconnectMcpServer()`. Default: 3 attempts, 500ms base delay, 5s max delay. See [Retries](./retries.md) for more details.

### Automatic reconnects
### Failures and reconnecting

When a connection or discovery attempt fails (the server is down, times out, or returns an error), the manager keeps trying in the background on a schedule stored in the Durable Object's job queue, so it survives hibernation and eviction: after 30 seconds, 1, 2, 5 and 10 minutes, then every 30 minutes. The server shows `failed` with the last error in between, including after a wake for some other reason: the agent waits for the scheduled attempt rather than connecting early. Once it connects, the schedule is dropped.
Each connection makes a few quick attempts with the [retry options](#retry-options). If they all fail (the server is down, times out, or returns an error), the server is `failed` with the error, and the failure is recorded with the server. Nothing retries it on a timer, and later wakes show the same `failed` state without contacting the server. The server stays failed until something asks to connect it again.

Retrying only helps when the failure can pass, so the manager does not retry when authorization was refused:
When a wake finds a server that was not failed, the agent connects it again in the background with the same quick attempts. If the server is unreachable at that moment, it is `failed` from then on.

Retrying cannot help when authorization was refused, so these failures skip the quick attempts too:

- The server answered `401` and the agent cannot ask the user, for example a revoked static token passed in `transport.headers`, or no OAuth callback configured. When it can ask, the server moves to `authenticating` with a new link instead.
- The server answered `403`. A `403` with `error="insufficient_scope"` still starts a [step-up](#re-authorization-during-a-session) first; it only counts as refused when no step-up is possible.
- The authorization server refused with an OAuth error such as `invalid_target`, `invalid_scope` or `access_denied`. An expired or revoked refresh token (`invalid_grant`) starts a new authorization instead. `server_error` and `temporarily_unavailable` are retried like any other outage.

- The authorization server refused with an OAuth error such as `invalid_target`, `invalid_scope` or `access_denied`. An expired or revoked refresh token (`invalid_grant`) starts a new authorization instead. `server_error` and `temporarily_unavailable` count as an outage.
- The authorization server rejected dynamic client registration with a `4xx` other than `429`.
- The server still answers `401` right after the user completed an authorization, for example because it rejects tokens from its own authorization server. The link that was just used is never shown again.

The server is marked `failed` with `Authorization was refused: …`, and the failure is recorded the same way as below.

A callback where the user denied access, or whose code could not be exchanged, is recorded too, so a later wake does not start another flow the user just turned down. An authorization link stays pending for at most 24 hours, even when a custom OAuth provider would still accept its `state`.

After 24 hours without a successful connection, the manager stops and records the failure with the server. Later wakes show the same `failed` state without trying again. `removeMcpServer()` cancels any pending reconnect.
The server is marked `failed` with `Authorization was refused: …`. A callback where the user denied access, or whose code could not be exchanged, is recorded the same way, so a later wake does not start another flow the user just turned down.

#### Reconnecting on request

Calling `addMcpServer()` for a server that is already registered reconnects it if it is not connected, so calling it from `onStart`, `onConnect` or `onRequest` brings a server back once it can be reached:

- `ready`, or a link still waiting for the user: returned as is.
- `failed` from something that can pass (network, `5xx`, timeout) or a link that expired unused: connects now and returns the result, including a fresh `authUrl`. It tries at most once per first backoff step (30 seconds by default); a call sooner than that throws the current error.
- `failed` for a reason only a person can fix (authorization was refused or denied, the step-up limit was reached, or reconnecting gave up after 24 hours): throws that reason without trying again.

To try any failed server again because the user asked, for example from a "Reconnect" button, call `reconnectMcpServer(id)`. It forgets why the server stopped, drops pending reconnects and connects now, with no rate limit. It returns the same result as `addMcpServer()`, including a fresh `authUrl` when the user needs to authorize again.
To try a failed server again, for example from a "Reconnect" button, call `reconnectMcpServer(id)`. It forgets why the server failed and its pending link, then connects now. It returns the same result as `addMcpServer()`, including a fresh `authUrl` when the user needs to authorize again.

```typescript
import { Agent, callable } from "agents";
Expand All @@ -216,16 +207,11 @@ class MyAgent extends Agent<Env> {
}
```

A standalone `MCPClientManager` can change the schedule:
Calling `addMcpServer()` for a server that is already registered does not retry a failure:

```typescript
new MCPClientManager("my-client", "1.0.0", {
reconnect: {
delaysMs: [10_000, 60_000, 600_000], // the last delay repeats
giveUpAfterMs: 6 * 60 * 60 * 1000
}
});
```
- `ready`: returned as is.
- `authenticating`: returns the link the server is waiting on.
- `failed`: throws the recorded error, which names `reconnectMcpServer()`.

### Protocol negotiation and elicitation

Expand Down Expand Up @@ -297,7 +283,7 @@ type AddMcpServerResult =
- **`ready`** - Server connected and tools discovered
- **`authenticating`** - Server requires OAuth; redirect user to `authUrl`. This includes servers that accept the MCP handshake without a token but require one to list tools, prompts or resources.

Calling `addMcpServer()` again for a server that is already registered returns its current state, and reconnects it when it is not connected (see [Reconnecting on request](#reconnecting-on-request)). If the server is still connecting (for example while it is restored after the agent wakes), the call waits for that attempt and returns its result. Pass a stable `id` so the server keeps the same id everywhere.
Calling `addMcpServer()` again for a server that is already registered returns its current state, replaces a link that expired, and throws for a failed server (see [Reconnecting on request](#reconnecting-on-request)). If the server is still connecting (for example while it is restored after the agent wakes), the call waits for that attempt and returns its result. Pass a stable `id` so the server keeps the same id everywhere.

### Best practices

Expand Down Expand Up @@ -362,12 +348,16 @@ For example: `https://my-worker.workers.dev/agents/my-agent/default/callback`

OAuth tokens are securely stored in SQLite and persist across agent restarts.

An authorization link stays valid for 10 minutes. If the user does not complete it in time, the server moves to `failed` with the error "Authorization expired before it was completed", its `auth_url` is cleared, and it stays failed across wakes. Call `reconnectMcpServer(id)` to get a fresh link. If a callback already redeemed the link and the agent restarted before it finished, the agent reconnects with the saved tokens instead.
A server waiting for authorization keeps its link, across wakes too, until the user completes it or you call `reconnectMcpServer(id)`. Whether an old link still works is up to the authorization server and the OAuth provider (the default provider accepts a callback for 10 minutes). If the user completes a link that no longer works, the callback reports the error and the server keeps waiting; call `reconnectMcpServer(id)` to get a fresh link. If a callback already redeemed the link and the agent restarted before it finished, the agent reconnects with the saved tokens instead.

### Re-authorization during a session

A request can need authorization again after the server is `ready`: a tool call that needs a scope the grant lacks (`403 insufficient_scope`), or a `401` whose token refresh fails. `callTool()`, `readResource()` and `getPrompt()` then throw `MCPAuthorizationRequiredError`. The server moves to `authenticating` and its `auth_url` is saved, so clients see the new link through `getMcpServers()` like at connect time. Send the user to `error.authUrl`; the normal OAuth callback completes the flow and reconnects. Retry the request when the server is `ready` again. Tools from `getAITools()` call `callTool()`, so their `execute` throws the same error. If no usable link was produced (for example, a custom OAuth provider that does not put a fresh `state` in each authorization URL, or a callback for this server is already being processed), the call throws the SDK's plain `UnauthorizedError` as before. `MCPAuthorizationRequiredError` extends `UnauthorizedError`, so existing `instanceof UnauthorizedError` checks keep matching.

During a scope step-up the session keeps its current grant, so requests it already covers keep working while the user decides. If the user declines, the server goes back to `ready` on that grant, and the next request that needs the scope asks again.

A request to a server that is waiting for its first authorization throws `MCPAuthorizationRequiredError` with the link it is waiting on.

Calls for a `serverId` with no connection (never registered, removed, or still being restored) throw `MCPConnectionNotFoundError`.

Re-authorization is limited, as the MCP authorization spec asks. If the user completes three authorizations for the same scopes within an hour and the server still asks for them (for example because the authorization server never grants a scope), the next challenge does not produce a link. The server moves to `failed` with an error naming the scopes, the request throws the SDK's `UnauthorizedError`, and the server stays failed until you call `reconnectMcpServer(id)`.
Expand Down
9 changes: 3 additions & 6 deletions docs/agents/retries.md
Original file line number Diff line number Diff line change
Expand Up @@ -325,20 +325,17 @@ With `maxAttempts: 5` and `baseDelayMs: 500`:

### MCP Server Retries

When adding an MCP server, you can configure retry options for connection and reconnection attempts:
When adding an MCP server, you can configure how many quick attempts each connection makes:

```typescript
await this.addMcpServer("github", "https://mcp.github.com", {
retry: { maxAttempts: 5, baseDelayMs: 1000, maxDelayMs: 10000 }
});
```

These options are persisted and used for the quick in-place attempts when:
These options are persisted and used for every connection attempt: adding the server, restoring it after hibernation, establishing it after OAuth completes, and `reconnectMcpServer()`.

- Restoring server connections after hibernation
- Establishing connections after OAuth completion

Default: 3 attempts, 500ms base delay, 5s max delay. If those attempts fail, the server keeps reconnecting in the background on a durable schedule, unless authorization was refused; see [Automatic reconnects](./mcp-client.md#automatic-reconnects).
Default: 3 attempts, 500ms base delay, 5s max delay. If those attempts fail, the server is `failed` until you call `reconnectMcpServer()`; see [Failures and reconnecting](./mcp-client.md#failures-and-reconnecting).

### Internal Retries

Expand Down
Loading
Loading