Skip to content
Closed
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
25 changes: 24 additions & 1 deletion docs/agents/mcp-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,9 @@ type AddMcpServerResult =
```

- **`ready`** - Server connected and tools discovered
- **`authenticating`** - Server requires OAuth; redirect user to `authUrl`
- **`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. 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.

## OAuth Authentication

Expand Down Expand Up @@ -300,6 +302,27 @@ 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. When the agent wakes and a saved link can no longer complete (it expired, or its callback already ran), the agent reconnects instead of showing the dead link: saved tokens connect directly, and otherwise the server's next `401` produces a fresh `auth_url`.

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

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

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

try {
return await this.mcp.callTool({ serverId, name: "deploy", arguments: {} });
} catch (error) {
if (error instanceof MCPAuthorizationRequiredError) {
return { needsAuthorization: true, authUrl: error.authUrl };
}
throw error;
}
```

### Custom Callback Handling

For custom OAuth completion behavior:
Expand Down
64 changes: 24 additions & 40 deletions packages/agents/conformance/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import {
routeAgentRequest
} from "../src/index.ts";
import { MCPConnectionState } from "../src/mcp/client/connection.ts";
import { isUnauthorized, toErrorMessage } from "../src/mcp/client/errors.ts";
import {
MCPAuthorizationRequiredError,
toErrorMessage
} from "../src/mcp/client/errors.ts";
import {
createLegacyMcpHandler,
createMcpHandler,
Expand Down Expand Up @@ -157,7 +160,8 @@ export class ConformanceHost extends Agent<Env> {
}
}

await this.waitForReady(this._serverId);
const ready = await this.waitForReady(this._serverId);
if (ready.status === "auth") return ready;
await this.runScenarioSteps(scenario, this._serverId, context);
return { status: "done" };
} catch (error) {
Expand All @@ -169,15 +173,13 @@ export class ConformanceHost extends Agent<Env> {
return this.runScenario(scenario, serverUrl, undefined);
}

// A 401 can surface from any phase: connect, discovery (servers that
// allow unauthenticated initialize but protect tools/list), or a tool
// call (scope step-up). In all cases the SDK has already run the OAuth
// authorization request and handed the authorization URL to our
// provider — surface it so the driver can authorize in its fake
// browser and call /run again.
const reauth = this.requestReauth(error);
if (reauth) {
return reauth;
// A request that needs authorization again (scope step-up, or a 401
// whose refresh failed) leaves the server authenticating; surface the
// link so the driver can authorize in its fake browser and call /run
// again. Connect- and discovery-time authorization arrive through
// addMcpServer and waitForReady instead.
if (error instanceof MCPAuthorizationRequiredError) {
return { status: "auth", authUrl: error.authUrl };
}
throw error;
}
Expand Down Expand Up @@ -237,49 +239,31 @@ export class ConformanceHost extends Agent<Env> {
return true;
}

private requestReauth(error: unknown): RunResult | undefined {
if (!isUnauthorized(error)) {
return undefined;
}

// addMcpServer throws before returning an id when discovery fails, so
// fall back to looking the server up by name.
const serverId =
this._serverId ??
this.mcp.listServers().find((s) => s.name === SERVER_NAME)?.id;
if (!serverId) {
return undefined;
}
this._serverId = serverId;

const conn = this.mcp.mcpConnections[serverId];
const authUrl = conn?.options.transport.authProvider?.authUrl;
if (!conn || !authUrl) {
return undefined;
}

conn.connectionState = MCPConnectionState.AUTHENTICATING;
return { status: "auth", authUrl };
}

/**
* Wait for the connection to reach READY. After an OAuth callback the
* connection is re-established in the background (establishConnection), so
* the next /run call has to poll for the terminal state.
*/
// Generous timeout: on cold CI runners, connection retries (exponential
// backoff) plus the SDK's SSE retry interval can stack well past 25s.
private async waitForReady(serverId: string, timeoutMs = 50_000) {
private async waitForReady(
serverId: string,
timeoutMs = 50_000
): Promise<{ status: "ready" } | { status: "auth"; authUrl: string }> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const conn = this.mcp.mcpConnections[serverId];
if (conn) {
if (conn.connectionState === MCPConnectionState.READY) {
return conn;
return { status: "ready" };
}
if (conn.connectionState === MCPConnectionState.AUTHENTICATING) {
// Routed through requestReauth() by the caller's catch block.
throw new Error("Unauthorized: connection requires authorization");
// The reconnect after a callback needs another authorization (for
// example a discovery-time scope step-up); its link is persisted.
const authUrl = this.mcp
.listServers()
.find((server) => server.id === serverId)?.auth_url;
if (authUrl) return { status: "auth", authUrl };
}
if (conn.connectionState === MCPConnectionState.FAILED) {
throw new Error(
Expand Down
Loading
Loading