Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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).
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ Test locations:

Each test directory has its own `vitest.config.ts` and (for Workers tests) a `wrangler.jsonc`.

Integration tests fake HTTP servers with [msw](https://mswjs.io), not by mocking `globalThis.fetch` or injecting a fake `fetch`. See "Faking HTTP servers in integration tests" in `packages/agents/AGENTS.md`.

For a repo-wide rollup of **what proves feature X works, at which layer, and which CI run guards it** — plus the tracked skip/quarantine debt — see [`design/test-coverage-matrix.md`](design/test-coverage-matrix.md).

## Contributing
Expand Down
28 changes: 28 additions & 0 deletions design/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,34 @@ supporting modules live in `src/mcp/client/`; `types.ts`, `rpc.ts`, and

The SDK v2 client negotiates Stateless first and falls back to Legacy on the same connection. Agents persists the selected protocol and discovery result alongside resumable HTTP state. An in-flight elicitation handler and continuation remain memory-only; isolate restart rejects the operation and the caller retries it.

`MCPClientManager` (`src/mcp/client/manager.ts`) is the public API and the Lifecycle hooks; it decides nothing itself. It keeps one `MCPClientConnection` per server and hands every controller one `McpClientContext` (`context.ts`): the connections, the store, the lanes, storage, and the manager's events. Dependencies point one way, top to bottom:

| 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, provider creation and storage |
| `status.ts` | The transitions both share: stop with a recorded failure, ready, save the session |
| `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` |
| `server-lanes.ts` | One queue of connection work per server; re-entrant for nested calls |
| `authorization-turns.ts` | One OAuth flow at a time per connection, so concurrent 401s share one client and one link |
| `authorization-rounds.ts` | The step-up round count, as pure functions |
| `oauth-callback.ts` | Callback recognition and parsing, state checks, code exchange, response rendering |
| `catalog.ts`, `ai-tools.ts` | Paginated catalogs, namespaced listings and filters; AI SDK tool conversion |
| `url-policy.ts` | Stable server ids and the SSRF policy |
| `connection.ts` | Transport selection, the SDK client, catalogs and the connection-level state machine |

`servers.ts` reaches `connectToServer` and `discoverIfConnected` through the manager, so a subclass override (or a test spy) sees internal calls too.

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

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

| Surface | Evidence |
Expand Down
12 changes: 12 additions & 0 deletions design/test-coverage-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,18 @@ close through both the Stateless and Legacy compatibility client paths. Refer
to [`packages/agents/conformance/README.md`](../packages/agents/conformance/README.md)
for scenario counts and expected-failure rationale.

The MCP client's OAuth and connection recovery run end to end in
`packages/agents/src/tests/mcp/integration/`: an Agent with its default
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,
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
failures and links for a standalone `MCPClientManager` on a plain
Lifecycle object.

### Accepted coverage gaps

- **Chat recovery orphan-persist (c) tool-approval dedup:** no real-SIGKILL L4
Expand Down
9 changes: 6 additions & 3 deletions docs/agents/agent-class.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,9 +286,12 @@ Schedules are stored as jobs in the `cf_agents_jobs` SQL table, the Lifecycle-ow

```ts
class MyAgent extends Agent {
async onConnect() {
// Add an MCP server
await this.addMcpServer("GitHub", "https://mcp.example.com/sse");
@callable()
async addServer(url: string) {
// Calling this again for the same server reconnects it if it is not
// connected. A server stopped for a reason only a person can fix
// (authorization refused, for example) needs reconnectMcpServer(id).
return this.addMcpServer("GitHub", url, { id: "github" });
}
}
```
Expand Down
Loading
Loading