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-step-up-retry-limit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agents": patch
---

MCP client: re-authorization stops after three completed authorizations for the same scopes, per the MCP authorization spec's step-up retry limit; the server is marked `failed` instead of showing another link. See [Re-authorization during a session](https://github.com/cloudflare/agents/blob/main/docs/agents/mcp-client.md#re-authorization-during-a-session).
2 changes: 2 additions & 0 deletions design/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ Background work that must happen even if the isolate dies lives in the Lifecycle
- `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.

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 (`addMcpServer`, `connectToServer`, `establishConnection`) clears it. 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.

## Verification matrix
Expand Down
2 changes: 2 additions & 0 deletions docs/agents/mcp-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,8 @@ A request can need authorization again after the server is `ready`: a tool call

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 `addMcpServer()` for it again.

```typescript
import { MCPAuthorizationRequiredError } from "agents/mcp/client";

Expand Down
12 changes: 6 additions & 6 deletions packages/agents/conformance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,12 @@ Every lane tests the same Agents `MCPClientManager` backed by the SDK v2 client.
The older lanes prove fallback interoperability rather than inferring it from
the current protocol lane.

| Command | Server protocol/referee selection | Scenarios | Current result |
| ------------------------------------ | --------------------------------- | --------- | ------------------------------ |
| `test:conformance:client:stateless` | `2026-07-28` | 33 | 31 clean / 2 expected failures |
| `test:conformance:client:2025-11-25` | `2025-11-25` | 19 | 18 clean / 1 expected failure |
| `test:conformance:client:2025-06-18` | `2025-06-18` | 5 | 5 clean |
| `test:conformance:client:2025-03-26` | `2025-03-26` OAuth/backcompat | 2 | 2 clean |
| Command | Server protocol/referee selection | Scenarios | Current result |
| ------------------------------------ | --------------------------------- | --------- | ----------------------------- |
| `test:conformance:client:stateless` | `2026-07-28` | 33 | 32 clean / 1 expected failure |
| `test:conformance:client:2025-11-25` | `2025-11-25` | 19 | 19 clean |
| `test:conformance:client:2025-06-18` | `2025-06-18` | 5 | 5 clean |
| `test:conformance:client:2025-03-26` | `2025-03-26` OAuth/backcompat | 2 | 2 clean |

The driver keeps an explicit manifest of the upstream scenarios it implements.
A new selected scenario missing from that manifest fails before the lane runs.
Expand Down
9 changes: 0 additions & 9 deletions packages/agents/conformance/baseline-client-2025-11-25.yml

This file was deleted.

8 changes: 0 additions & 8 deletions packages/agents/conformance/baseline-client-2026-07-28.yml
Original file line number Diff line number Diff line change
@@ -1,14 +1,6 @@
# SDK v2 client against the latest published conformance protocol revision.
# Every entry is executed by alpha.12; run-suite.mjs rejects unseen entries.
client:
# The default scope step-up budget produces four authorization attempts;
# the referee's SHOULD-level limit is three or fewer. Fixed by the
# step-up round limit in cloudflare/agents#2663; remove this entry with it.
# Impact if unchanged: a permanently unsatisfiable insufficient_scope
# challenge can send the user through one additional OAuth round-trip before
# the client stops retrying.
- auth/scope-retry-limit

# Referee bug (modelcontextprotocol/conformance#475): test_mrtr_no_result_type
# omits resultType after negotiating 2026-07-28. The released spec
# (basic/index, ResultType) requires 2026-07-28 servers to include it and
Expand Down
2 changes: 1 addition & 1 deletion packages/agents/conformance/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ case "$MODE" in
run_client 2026-07-28 conformance/baseline-client-2026-07-28.yml "$@"
;;
client-2025-11-25)
run_client 2025-11-25 conformance/baseline-client-2025-11-25.yml "$@"
run_client 2025-11-25 "" "$@"
;;
client-2025-06-18)
run_client 2025-06-18 "" "$@"
Expand Down
179 changes: 179 additions & 0 deletions packages/agents/src/mcp/client/authorization-rounds.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
/**
* The authorization retry limit from the MCP authorization spec
* (2026-07-28, "Step-Up Authorization Flow"): after a scope challenge the
* client re-authorizes and retries "no more than a few times", then treats
* the challenge as a permanent authorization failure, tracking attempts
* so it does not loop on the same request.
*
* A round is one authorization URL the user is sent to. Rounds are counted
* separately for each requested scope set, so challenges that alternate
* between scope sets each use up their own budget. A new round for a scope
* set only counts once its previous round completed (its callback exchanged
* a code) and the server asked for the same scopes again. Several links
* issued for one pending ask (for example two tool calls hitting the same
* step-up at once) are one round.
*/

/** Completed authorization rounds allowed for one scope set. */
export const MAX_AUTHORIZATION_ROUNDS = 3;

/** A scope set's rounds are forgotten this long after its latest round. */
export const AUTHORIZATION_ROUNDS_WINDOW_MS = 60 * 60 * 1000;

/** Rounds recorded for one requested scope set. */
export type ScopeRounds = {
/** Rounds started for this scope set within the window. */
readonly rounds: number;
/** Whether this scope set's latest round's callback exchanged a code. */
readonly completed: boolean;
/** When this scope set's latest round started, in epoch milliseconds. */
readonly at: number;
};

/** Authorization rounds recorded for one server. */
export type AuthorizationRounds = {
/** The scope set of the round started most recently. */
readonly latest: string;
/** Rounds per normalized scope set; `""` when no scope was requested. */
readonly byScope: Readonly<Record<string, ScopeRounds>>;
};

/** Whether the next authorization link may be shown. */
export type RoundDecision =
| { readonly _tag: "start"; readonly rounds: AuthorizationRounds }
| {
readonly _tag: "exhausted";
readonly rounds: number;
readonly scope: string;
};

/**
* The requested scope set of an authorization URL: the `scope` parameter's
* tokens, deduplicated and sorted, or an empty string when absent.
*/
export function requestedScope(authUrl: string): string {
let scope: string | null;
try {
scope = new URL(authUrl).searchParams.get("scope");
} catch {
return "";
}
if (!scope) return "";
return [...new Set(scope.split(/\s+/).filter(Boolean))].sort().join(" ");
}

/**
* Decide whether a new authorization link may be shown.
*
* @param previous - The server's recorded rounds, when any.
* @param scope - The requested scope set of the new link.
* @param now - The current time in epoch milliseconds.
* @returns The rounds to record, or that the limit is reached.
*/
export function startRound(
previous: AuthorizationRounds | undefined,
scope: string,
now: number
): RoundDecision {
const byScope = live(previous?.byScope ?? {}, now);
const current = entryFor(byScope, scope);
let next: ScopeRounds;
if (!current) {
next = { rounds: 1, completed: false, at: now };
} else if (!current.completed) {
// Another link for the round still waiting on the user.
next = { ...current, at: now };
} else if (current.rounds + 1 > MAX_AUTHORIZATION_ROUNDS) {
return { _tag: "exhausted", rounds: current.rounds, scope };
} else {
next = { rounds: current.rounds + 1, completed: false, at: now };
}
return {
_tag: "start",
rounds: { latest: scope, byScope: { ...byScope, [scope]: next } }
};
}

/** Record that the latest round's callback exchanged a code. */
export function completeRound(
previous: AuthorizationRounds | undefined
): AuthorizationRounds | undefined {
if (!previous) return undefined;
const current = entryFor(previous.byScope, previous.latest);
if (!current) return previous;
return {
latest: previous.latest,
byScope: {
...previous.byScope,
[previous.latest]: { ...current, completed: true }
}
};
}

/** Parse recorded rounds read back from storage. */
export function parseAuthorizationRounds(
value: unknown
): AuthorizationRounds | undefined {
if (
!isRecord(value) ||
typeof value.latest !== "string" ||
!isRecord(value.byScope)
) {
return undefined;
Comment thread
mattzcarey marked this conversation as resolved.
}
// Object.fromEntries defines own properties, so scope names such as
// `__proto__` or `constructor` are stored like any other.
const byScope = Object.fromEntries(
Object.entries(value.byScope).flatMap(([scope, entry]) =>
isRecord(entry) &&
typeof entry.rounds === "number" &&
typeof entry.completed === "boolean" &&
typeof entry.at === "number"
? [
[
scope,
{
rounds: entry.rounds,
completed: entry.completed,
at: entry.at
}
] as const
]
: []
)
);
return { latest: value.latest, byScope };
}

/** Message for a server that hit the limit. */
export function authorizationExhaustedMessage(
rounds: number,
scope: string
): string {
const scopes = scope ? ` for scope "${scope}"` : "";
return `The server still requires authorization${scopes} after ${rounds} completed authorizations. Stopped to avoid an authorization loop; reconnect the server to try again.`;
}

/** A scope set's own entry; never an inherited `Object` member. */
function entryFor(
byScope: Readonly<Record<string, ScopeRounds>>,
scope: string
): ScopeRounds | undefined {
return Object.hasOwn(byScope, scope) ? byScope[scope] : undefined;
}

/** Scope sets whose latest round is still inside the window. */
function live(
byScope: Readonly<Record<string, ScopeRounds>>,
now: number
): Record<string, ScopeRounds> {
return Object.fromEntries(
Object.entries(byScope).filter(
([, entry]) => now - entry.at <= AUTHORIZATION_ROUNDS_WINDOW_MS
)
);
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
Loading