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
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 isn't
// 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
28 changes: 26 additions & 2 deletions docs/agents/mcp-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,15 @@ 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 isn't 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.
Calling `addMcpServer()` again for a server that is already registered returns its current state, and reconnects it when it isn't 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.
Comment thread
mattzcarey marked this conversation as resolved.
Resolved

### Best practices

- Add servers with a stable `id`, from a callable the user triggers or from `onStart`.
- Render each server from `getMcpServers()` (`onMcpUpdate` in `useAgent`): send the user to `auth_url` while it is `authenticating`, and show `error` with a **Reconnect** button that calls `reconnectMcpServer(id)` while it is `failed`.
- Catch [`MCPAuthorizationRequiredError`](#re-authorization-during-a-session) from `callTool()`, `readResource()` and `getPrompt()`, and give the user `error.authUrl`.
- Call [`waitForConnections()`](#waiting-for-connections) before relying on restored tools. `AIChatAgent` waits up to 10 seconds by default; in Think, set `waitForMcpConnections`.
- Close the OAuth popup with [`configureOAuthCallback()`](#oauth-authentication).

## OAuth Authentication

Expand Down Expand Up @@ -578,6 +586,7 @@ function Dashboard() {
const [tools, setTools] = useState([]);
const [servers, setServers] = useState({});

// reconnectServer is a @callable() that calls this.reconnectMcpServer(id)
const agent = useAgent({
agent: "MyAgent",
onMcpUpdate: (mcpState) => {
Expand All @@ -591,7 +600,20 @@ function Dashboard() {
<h2>Connected Servers</h2>
{Object.entries(servers).map(([id, server]) => (
<div key={id}>
{server.name}: {server.connectionState}
{server.name}: {server.state}
{server.state === "authenticating" && server.auth_url && (
<a href={server.auth_url} target="_blank">
Authorize
</a>
)}
{server.state === "failed" && (
<>
{server.error}
<button onClick={() => agent.call("reconnectServer", [id])}>
Reconnect
</button>
</>
)}
</div>
))}

Expand Down Expand Up @@ -676,6 +698,8 @@ This is useful when you need to call `this.mcp.getAITools()` immediately after t

### Error Recovery

On an `Agent`, retry a failed server with [`reconnectMcpServer(id)`](#reconnecting-on-request). On a standalone manager, `connectToServer()` is the explicit retry: it clears a recorded failure and the step-up budget before connecting.

```typescript
async retryConnection(serverId: string) {
const result = await this.mcp.connectToServer(serverId);
Expand Down
4 changes: 2 additions & 2 deletions docs/agents/retries.md
Original file line number Diff line number Diff line change
Expand Up @@ -333,12 +333,12 @@ await this.addMcpServer("github", "https://mcp.github.com", {
});
```

These options are persisted and used when:
These options are persisted and used for the quick in-place attempts when:

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

Default: 3 attempts, 500ms base delay, 5s max delay.
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).

### Internal Retries

Expand Down
25 changes: 25 additions & 0 deletions examples/ai-chat/src/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
PoweredByCloudflare
} from "@cloudflare/kumo";
import {
ArrowClockwiseIcon,
PaperPlaneRightIcon,
StopIcon,
TrashIcon,
Expand Down Expand Up @@ -218,6 +219,20 @@ function Chat() {
}
};

const handleReconnectServer = async (serverId: string) => {
try {
const result = (await agent.call("reconnectServer", [serverId])) as {
state: string;
authUrl?: string;
};
if (result.state === "authenticating" && result.authUrl) {
window.open(result.authUrl, "oauth", "width=600,height=800");
}
} catch (e) {
console.error("Failed to reconnect MCP server:", e);
}
};

const handleRemoveServer = async (serverId: string) => {
try {
await agent.call("removeServer", [serverId]);
Expand Down Expand Up @@ -421,6 +436,16 @@ function Chat() {
Auth
</Button>
)}
{server.state === "failed" && (
<Button
variant="secondary"
size="sm"
icon={<ArrowClockwiseIcon size={12} />}
onClick={() => handleReconnectServer(id)}
>
Reconnect
</Button>
)}
<Button
variant="ghost"
size="sm"
Expand Down
9 changes: 9 additions & 0 deletions examples/ai-chat/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,15 @@ export class ChatAgent extends AIChatAgent {
return await this.addMcpServer(name, url);
}

/**
* Try a failed server again: forgets why it stopped and connects now,
* returning a new authorization link if the server needs one.
*/
@callable()
async reconnectServer(serverId: string) {
return await this.reconnectMcpServer(serverId);
}

@callable()
async removeServer(serverId: string) {
await this.removeMcpServer(serverId);
Expand Down
28 changes: 28 additions & 0 deletions examples/codemode/src/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
PlugsConnectedIcon,
PlusIcon,
CubeIcon,
ArrowClockwiseIcon,
SpinnerGapIcon,
SignInIcon,
MoonIcon,
Expand Down Expand Up @@ -246,6 +247,7 @@ function SettingsPanel({
mcpState,
onAddMcp,
onRemoveMcp,
onReconnectMcp,
onRefreshMcpTools,
mcpLoading
}: {
Expand All @@ -254,6 +256,7 @@ function SettingsPanel({
mcpState: MCPServersState;
onAddMcp: (url: string, name?: string) => Promise<void>;
onRemoveMcp: (serverId: string) => Promise<void>;
onReconnectMcp: (serverId: string) => Promise<void>;
onRefreshMcpTools: () => void;
mcpLoading: boolean;
}) {
Expand Down Expand Up @@ -412,6 +415,16 @@ function SettingsPanel({
Authorize
</Button>
)}
{server.state === "failed" && (
<Button
variant="secondary"
size="sm"
icon={<ArrowClockwiseIcon size={12} />}
onClick={() => onReconnectMcp(serverId)}
>
Reconnect
</Button>
)}
{tools.length > 0 && (
<Badge
variant="secondary"
Expand Down Expand Up @@ -606,6 +619,20 @@ function App() {
[agent, refreshMcpTools]
);

const handleReconnectMcp = useCallback(
async (serverId: string) => {
const result = (await agent.call("reconnectMcp", [serverId])) as {
state: string;
authUrl?: string;
};
if (result.state === "authenticating" && result.authUrl) {
window.open(result.authUrl, "oauth", "width=600,height=800");
}
await refreshMcpTools();
},
[agent, refreshMcpTools]
);

const handleRemoveMcp = useCallback(
async (serverId: string) => {
await agent.call("removeMcp", [serverId]);
Expand Down Expand Up @@ -802,6 +829,7 @@ function App() {
mcpState={mcpState}
onAddMcp={handleAddMcp}
onRemoveMcp={handleRemoveMcp}
onReconnectMcp={handleReconnectMcp}
onRefreshMcpTools={refreshMcpTools}
mcpLoading={mcpLoading}
/>
Expand Down
5 changes: 5 additions & 0 deletions examples/codemode/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ export class Codemode extends AIChatAgent<Env> {
}));
}

@callable({ description: "Try a failed MCP server again" })
async reconnectMcp(serverId: string) {
return await this.reconnectMcpServer(serverId);
}

@callable({ description: "Remove an MCP server" })
async removeMcp(serverId: string) {
await this.removeMcpServer(serverId);
Expand Down
35 changes: 28 additions & 7 deletions examples/mcp-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ An Agent that acts as an MCP **client** — dynamically connects to remote MCP s
- **`onMcpUpdate`** — real-time state updates pushed to the React frontend via WebSocket
- **`this.mcp.configureElicitationHandlers`** — handles both Stateless Elicitation (`input_required` through MRTR) and Legacy Elicitation (pushed `elicitation/create`); the Agent broadcasts requests to the browser and the human's answer resolves the pending operation via a `@callable` method
- **OAuth popup flow** — `configureOAuthCallback` with a custom handler that closes the popup after auth
- **`agentFetch`** — making HTTP requests to the Agent's custom endpoints from the client
- **Re-authorization during a session** — a tool call that needs the user to authorize again (a scope step-up, or a token that can no longer be refreshed) catches `MCPAuthorizationRequiredError` and opens the new link
- **`reconnectMcpServer`** — a "Reconnect" button for a server that stopped. Adding the server again also reconnects it, except when it stopped for a reason only a person can fix (authorization refused or denied, the step-up limit)

## Running

Expand Down Expand Up @@ -52,17 +53,37 @@ export class MyAgent extends Agent {
});
}

async onRequest(request) {
// Custom endpoints for the frontend
if (url.pathname.endsWith("add-mcp")) {
const { name, url } = await request.json();
await this.addMcpServer(name, url);
return new Response("Ok");
@callable()
async addServer(name: string, url: string) {
await this.addMcpServer(name, url);
}

@callable()
async reconnectServer(serverId: string) {
// Returns a new authUrl when the server needs the user again
return await this.reconnectMcpServer(serverId);
}

@callable()
async callTool(
serverId: string,
name: string,
args: Record<string, unknown>
) {
try {
return await this.mcp.callTool({ serverId, name, arguments: args });
} catch (error) {
if (error instanceof MCPAuthorizationRequiredError) {
return { authorizationRequired: true, authUrl: error.authUrl };
}
throw error;
}
}
}
```

The browser renders each server from the `cf_agent_mcp_servers` state: an **Authorize** button while it is `authenticating` with an `auth_url`, and the error with a **Reconnect** button while it is `failed`.

### Client side

The React frontend uses `useAgent` with `onMcpUpdate` to receive real-time server state:
Expand Down
52 changes: 50 additions & 2 deletions examples/mcp-client/src/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
PoweredByCloudflare
} from "@cloudflare/kumo";
import {
ArrowClockwiseIcon,
PlusIcon,
PlugIcon,
PlugsConnectedIcon,
Expand All @@ -28,6 +29,19 @@ import { nanoid } from "nanoid";
import type { PendingElicitation } from "./server";
import "./styles.css";

/** The `callTool` result when the server needs the user to authorize. */
function needsAuthorization(
value: unknown
): value is { authorizationRequired: true; authUrl: string } {
return (
typeof value === "object" &&
value !== null &&
"authorizationRequired" in value &&
"authUrl" in value &&
typeof value.authUrl === "string"
);
}

let sessionId = localStorage.getItem("sessionId");
if (!sessionId) {
sessionId = nanoid(8);
Expand Down Expand Up @@ -367,11 +381,24 @@ function App() {
await agent.call("respondToElicitation", [id, result]);
};

const runTool = (
// A tool that needs the user to authorize again returns the link; open
// it so the user can approve, then run the tool again.
const runTool = async (
serverId: string,
name: string,
args: Record<string, unknown>
) => agent.call("callTool", [serverId, name, args]);
) => {
const result: unknown = await agent.call("callTool", [
serverId,
name,
args
]);
if (needsAuthorization(result)) {
openPopup(result.authUrl);
return "Authorization needed. Approve it in the popup, then run the tool again.";
}
return result;
};

function openPopup(authUrl: string) {
window.open(
Expand Down Expand Up @@ -400,6 +427,17 @@ function App() {
await agent.call("disconnectServer", [serverId]);
};

const handleReconnect = async (serverId: string) => {
const result = (await agent.call("reconnectServer", [serverId])) as {
state: string;
authUrl?: string;
};
// The server may need the user to authorize again.
if (result.state === "authenticating" && result.authUrl) {
openPopup(result.authUrl);
}
};

const serverEntries = Object.entries(mcpState.servers);

return (
Expand Down Expand Up @@ -557,6 +595,16 @@ function App() {
Authorize
</Button>
)}
{server.state === "failed" && (
<Button
variant="secondary"
size="sm"
icon={<ArrowClockwiseIcon size={14} />}
onClick={() => handleReconnect(id)}
>
Reconnect
</Button>
)}
<Button
variant="ghost"
size="sm"
Expand Down
Loading
Loading