Skip to content
Draft
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
1 change: 1 addition & 0 deletions examples/codemode/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ declare namespace Cloudflare {
LOADER: WorkerLoader;
AI: Ai;
OPENAI_API_KEY: string;
HOST?: string;
Codemode: DurableObjectNamespace<import("./src/server").Codemode>;
}
}
Expand Down
242 changes: 235 additions & 7 deletions examples/codemode/src/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ import {
CheckCircleIcon,
CircleNotchIcon,
BrainIcon,
CaretDownIcon
CaretDownIcon,
PlugsConnectedIcon,
PlusIcon,
CubeIcon,
SpinnerGapIcon
} from "@phosphor-icons/react";
import {
ModeToggle,
Expand All @@ -37,6 +41,12 @@ import {
import { ThemeProvider } from "@cloudflare/agents-ui/hooks";
import type { ExecutorType } from "./server";

interface McpTool {
serverId: string;
name: string;
description?: string;
}

interface ToolPart {
type: string;
toolCallId?: string;
Expand Down Expand Up @@ -249,23 +259,59 @@ function SettingsPanel({
executor,
onExecutorChange,
loading,
onClose
onClose,
mcpTools,
onAddMcp,
onRemoveMcp,
onRefreshMcpTools,
mcpLoading
}: {
executor: ExecutorType;
onExecutorChange: (e: ExecutorType) => void;
loading: boolean;
onClose: () => void;
mcpTools: McpTool[];
onAddMcp: (url: string, name?: string) => Promise<void>;
onRemoveMcp: (serverId: string) => Promise<void>;
onRefreshMcpTools: () => void;
mcpLoading: boolean;
}) {
const [mcpUrl, setMcpUrl] = useState("");
const [mcpName, setMcpName] = useState("");
const [addingMcp, setAddingMcp] = useState(false);

const handleAddMcp = async () => {
if (!mcpUrl.trim()) return;
setAddingMcp(true);
try {
await onAddMcp(mcpUrl.trim(), mcpName.trim() || undefined);
setMcpUrl("");
setMcpName("");
} finally {
setAddingMcp(false);
}
};

// Group MCP tools by server
const toolsByServer = mcpTools.reduce(
(acc, tool) => {
if (!acc[tool.serverId]) acc[tool.serverId] = [];
acc[tool.serverId].push(tool);
return acc;
},
{} as Record<string, McpTool[]>
);

return (
<>
<button
type="button"
className="fixed inset-0 bg-black/40 z-40"
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-40"
onClick={onClose}
aria-label="Close settings"
/>
<aside className="fixed top-0 right-0 bottom-0 w-[360px] max-w-[90vw] bg-kumo-base border-l border-kumo-line z-50 flex flex-col shadow-xl">
<div className="flex items-center justify-between px-5 py-4 border-b border-kumo-line">
<aside className="fixed top-0 right-0 bottom-0 w-[400px] max-w-[90vw] bg-kumo-base border-l border-kumo-line z-50 flex flex-col shadow-2xl">
<div className="flex items-center justify-between px-5 py-4 border-b border-kumo-line bg-gradient-to-r from-kumo-base to-kumo-elevated">
<Text variant="heading3">Settings</Text>
<Button
variant="ghost"
Expand All @@ -278,12 +324,13 @@ function SettingsPanel({
</div>

<div className="flex-1 overflow-y-auto px-5 py-4 space-y-6">
{/* Executor Section */}
<div>
<span className="text-xs font-semibold text-kumo-secondary mb-2 block uppercase tracking-wider">
Executor
</span>
<select
className="w-full px-3 py-2 bg-kumo-elevated border border-kumo-line rounded-lg text-kumo-default text-sm outline-none focus:ring-2 focus:ring-kumo-ring"
className="w-full px-3 py-2 bg-kumo-elevated border border-kumo-line rounded-lg text-kumo-default text-sm outline-none focus:ring-2 focus:ring-kumo-ring transition-all"
value={executor}
onChange={(e) => onExecutorChange(e.target.value as ExecutorType)}
disabled={loading}
Expand All @@ -301,9 +348,138 @@ function SettingsPanel({
</div>
</div>

{/* MCP Servers Section */}
<div className="relative">
<div className="absolute -inset-3 bg-gradient-to-br from-orange-500/5 via-transparent to-amber-500/5 rounded-2xl -z-10" />
<div className="flex items-center gap-2 mb-3">
<PlugsConnectedIcon
size={16}
className="text-orange-500"
weight="duotone"
/>
<span className="text-xs font-semibold text-kumo-secondary uppercase tracking-wider">
MCP Servers
</span>
</div>

<div className="space-y-3 p-3 bg-kumo-elevated/50 rounded-xl border border-kumo-line">
<div className="space-y-2">
<input
type="text"
value={mcpUrl}
onChange={(e) => setMcpUrl(e.target.value)}
placeholder="https://docs.mcp.cloudflare.com/mcp"
className="w-full px-3 py-2.5 bg-kumo-base border border-kumo-line rounded-lg text-kumo-default text-sm outline-none focus:ring-2 focus:ring-orange-500/30 focus:border-orange-500/50 transition-all placeholder:text-kumo-inactive"
disabled={addingMcp}
/>
<input
type="text"
value={mcpName}
onChange={(e) => setMcpName(e.target.value)}
placeholder="Server name (optional)"
className="w-full px-3 py-2 bg-kumo-base border border-kumo-line rounded-lg text-kumo-default text-xs outline-none focus:ring-2 focus:ring-orange-500/30 focus:border-orange-500/50 transition-all placeholder:text-kumo-inactive"
disabled={addingMcp}
/>
<Button
variant="secondary"
size="sm"
onClick={handleAddMcp}
disabled={!mcpUrl.trim() || addingMcp}
loading={addingMcp}
icon={<PlusIcon size={14} />}
className="w-full !bg-gradient-to-r !from-orange-500/10 !to-amber-500/10 hover:!from-orange-500/20 hover:!to-amber-500/20 !border-orange-500/30"
>
Connect MCP Server
</Button>
</div>

{Object.keys(toolsByServer).length > 0 && (
<div className="pt-3 border-t border-kumo-line space-y-3">
<div className="flex items-center justify-between">
<Text size="xs" variant="secondary" bold>
Connected Servers
</Text>
<button
type="button"
onClick={onRefreshMcpTools}
disabled={mcpLoading}
className="text-xs text-kumo-secondary hover:text-kumo-default transition-colors flex items-center gap-1"
>
{mcpLoading ? (
<SpinnerGapIcon size={12} className="animate-spin" />
) : (
"Refresh"
)}
</button>
</div>
{Object.entries(toolsByServer).map(([serverId, tools]) => (
<div
key={serverId}
className="bg-kumo-base rounded-lg border border-kumo-line overflow-hidden"
>
<div className="flex items-center gap-2 px-3 py-2 bg-gradient-to-r from-orange-500/10 to-transparent border-b border-kumo-line">
<CubeIcon
size={14}
className="text-orange-500"
weight="duotone"
/>
<span className="truncate flex-1">
<Text size="xs" bold>
{serverId}
</Text>
</span>
<Badge variant="secondary" className="text-[10px]">
{tools.length} tools
</Badge>
<button
type="button"
onClick={() => onRemoveMcp(serverId)}
className="p-1 text-kumo-inactive hover:text-red-500 transition-colors"
title="Remove server"
>
<XIcon size={12} />
</button>
</div>
<div className="divide-y divide-kumo-line max-h-32 overflow-y-auto">
{tools.map((tool) => (
<div
key={`${tool.serverId}-${tool.name}`}
className="px-3 py-1.5 hover:bg-kumo-elevated transition-colors"
>
<span className="text-[11px] font-mono text-orange-400/90 block">
{tool.name}
</span>
{tool.description && (
<span className="text-[10px] text-kumo-secondary line-clamp-1">
{tool.description}
</span>
)}
</div>
))}
</div>
</div>
))}
</div>
)}

{Object.keys(toolsByServer).length === 0 && (
<div className="text-center py-4">
<PlugsConnectedIcon
size={24}
className="text-kumo-inactive mx-auto mb-2"
/>
<Text size="xs" variant="secondary">
No MCP servers connected
</Text>
</div>
)}
</div>
</div>

{/* Available Functions Section */}
<div>
<span className="text-xs font-semibold text-kumo-secondary mb-2 block uppercase tracking-wider">
Available Functions
Built-in Functions
</span>
<div className="border border-kumo-line rounded-lg overflow-hidden divide-y divide-kumo-line">
{TOOLS.map((tool) => (
Expand Down Expand Up @@ -333,6 +509,8 @@ function App() {
const [settingsOpen, setSettingsOpen] = useState(false);
const [connectionStatus, setConnectionStatus] =
useState<ConnectionStatus>("connecting");
const [mcpTools, setMcpTools] = useState<McpTool[]>([]);
const [mcpLoading, setMcpLoading] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);

const agent = useAgent({
Expand All @@ -357,6 +535,51 @@ function App() {
[agent]
);

const refreshMcpTools = useCallback(async () => {
setMcpLoading(true);
try {
const tools = await agent.call("listMcpTools", []);
setMcpTools(tools as McpTool[]);
} catch (err) {
console.error("Failed to list MCP tools:", err);
} finally {
setMcpLoading(false);
}
}, [agent]);

const handleAddMcp = useCallback(
async (url: string, name?: string) => {
try {
await agent.call("addMcp", [url, name]);
// Refresh tools list after adding server
await refreshMcpTools();
} catch (err) {
console.error("Failed to add MCP server:", err);
throw err;
}
},
[agent, refreshMcpTools]
);

const handleRemoveMcp = useCallback(
async (serverId: string) => {
try {
await agent.call("removeMcp", [serverId]);
await refreshMcpTools();
} catch (err) {
console.error("Failed to remove MCP server:", err);
}
},
[agent, refreshMcpTools]
);

// Load MCP tools when settings panel opens
useEffect(() => {
if (settingsOpen && isConnected) {
refreshMcpTools();
}
}, [settingsOpen, isConnected, refreshMcpTools]);

const send = useCallback(() => {
const text = input.trim();
if (!text || isStreaming) return;
Expand Down Expand Up @@ -543,6 +766,11 @@ function App() {
onExecutorChange={handleExecutorChange}
loading={isStreaming}
onClose={() => setSettingsOpen(false)}
mcpTools={mcpTools}
onAddMcp={handleAddMcp}
onRemoveMcp={handleRemoveMcp}
onRefreshMcpTools={refreshMcpTools}
mcpLoading={mcpLoading}
/>
)}
</div>
Expand Down
40 changes: 38 additions & 2 deletions examples/codemode/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,38 @@ export class Codemode extends AIChatAgent<Env> {

@callable({ description: "Get tool type definitions" })
getToolTypes() {
return generateTypes(this.tools);
// Merge local tools with MCP tools for type generation
const mcpTools = this.mcp.getAITools();
const allTools = { ...this.tools, ...mcpTools };
return generateTypes(allTools);
}

@callable({ description: "Add an MCP server to get additional tools" })
async addMcp(url: string, name?: string) {
const serverName = name || `mcp-${Date.now()}`;
// Use HOST if provided, otherwise it will be derived from the request
// For @callable methods (WebSocket RPC), there's no request context,
// so HOST must be set in wrangler.jsonc vars for production
await this.addMcpServer(serverName, url, {
callbackHost: this.env.HOST
});
return { success: true, name: serverName };
}

@callable({ description: "List connected MCP servers and their tools" })
listMcpTools() {
const tools = this.mcp.listTools();
return tools.map((t) => ({
serverId: t.serverId,
name: t.name,
description: t.description
}));
}

@callable({ description: "Remove an MCP server" })
async removeMcp(serverId: string) {
await this.mcp.removeServer(serverId);
return { success: true, removed: serverId };
}

createExecutor(): Executor {
Expand All @@ -72,8 +103,13 @@ export class Codemode extends AIChatAgent<Env> {
const workersai = createWorkersAI({ binding: this.env.AI });

const executor = this.createExecutor();

// Merge local tools with MCP tools
const mcpTools = this.mcp.getAITools();
const allTools = { ...this.tools, ...mcpTools };

const codemode = createCodeTool({
tools: this.tools,
tools: allTools,
executor
});

Expand Down
3 changes: 3 additions & 0 deletions examples/codemode/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
"assets": {
"directory": "public"
},
"vars": {
"HOST": "http://localhost:5173"
},
"durable_objects": {
"bindings": [
{
Expand Down
2 changes: 1 addition & 1 deletion examples/playground/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -629,7 +629,7 @@ Tests HMAC-signed email replies for secure routing.
- **Expected**:
- Detail shows the reply body
- Green "Signed" badge displayed
- Note about X-Agent-* headers shown
- Note about X-Agent-\* headers shown

#### Test 6: Secure Reply Routing (Deployed Only)

Expand Down
Loading
Loading