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
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
41 changes: 41 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 All @@ -429,6 +442,13 @@ function SettingsPanel({
<XIcon size={12} />
</button>
</div>
{server.state === "failed" && server.error && (
<div className="px-3 py-1.5 border-b border-kumo-line">
<Text size="xs" variant="secondary">
{server.error}
</Text>
</div>
)}
{tools.length > 0 && (
<div className="divide-y divide-kumo-line max-h-32 overflow-y-auto">
{tools.map((tool) => (
Expand Down Expand Up @@ -606,6 +626,26 @@ function App() {
[agent, refreshMcpTools]
);

const handleReconnectMcp = useCallback(
async (serverId: string) => {
try {
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");
}
} catch (err) {
// Still failed: the panel shows the server's error from onMcpUpdate.
console.error("Reconnect failed:", err);
} finally {
await refreshMcpTools();
}
},
[agent, refreshMcpTools]
);

const handleRemoveMcp = useCallback(
async (serverId: string) => {
await agent.call("removeMcp", [serverId]);
Expand Down Expand Up @@ -802,6 +842,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 failed server, or for a new authorization link. Adding the server again only reports its current state; nothing retries a failed server on its own

## 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
57 changes: 55 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,22 @@ function App() {
await agent.call("disconnectServer", [serverId]);
};

const handleReconnect = async (serverId: string) => {
try {
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);
}
} catch (e) {
// Still failed: the server's error shows from the MCP state broadcast.
console.error("Failed to reconnect MCP server:", e);
}
};

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

return (
Expand Down Expand Up @@ -557,6 +600,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
36 changes: 34 additions & 2 deletions examples/mcp-client/src/server.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { Agent, callable, routeAgentRequest } from "agents";
import type { ElicitRequest, ElicitResult } from "agents/mcp/client";
import {
MCPAuthorizationRequiredError,
type ElicitRequest,
type ElicitResult
} from "agents/mcp/client";

/**
* An elicitation forwarded to the browser, awaiting a human response.
Expand Down Expand Up @@ -94,23 +98,51 @@ export class MyAgent extends Agent {
}
}

/**
* Add a server. Calling it again for the same server returns its state
* as is; a failed server stays failed until reconnectServer(). The
* browser sees the state, the authorization link and any error through
* the `cf_agent_mcp_servers` broadcast.
*/
@callable()
async addServer(name: string, url: string) {
await this.addMcpServer(name, url);
}

/**
* The "Reconnect" button: try a server again. It forgets why the server
* failed and its pending link, and connects now, showing a new link if it
* needs one.
*/
@callable()
async reconnectServer(serverId: string) {
return await this.reconnectMcpServer(serverId);
}

@callable()
async disconnectServer(serverId: string) {
await this.removeMcpServer(serverId);
}

/**
* Run a tool. When the server needs the user to authorize again (a scope
* step-up, or a token that can no longer be refreshed), hand the link to
* the browser instead of failing; the server shows it too.
*/
@callable()
async callTool(
serverId: string,
name: string,
args: Record<string, unknown>
) {
return await this.mcp.callTool({ serverId, name, arguments: args });
try {
return await this.mcp.callTool({ serverId, name, arguments: args });
} catch (error) {
if (error instanceof MCPAuthorizationRequiredError) {
return { authorizationRequired: true, authUrl: error.authUrl };
}
throw error;
}
}
}

Expand Down
Loading
Loading