branch:
client.tsx
16685 bytesRaw
import { useState, useEffect, useCallback, useRef } from "react";
import { createRoot } from "react-dom/client";
import { ThemeProvider } from "@cloudflare/agents-ui/hooks";
import {
ConnectionIndicator,
ModeToggle,
PoweredByAgents,
type ConnectionStatus
} from "@cloudflare/agents-ui";
import { Button, Badge, Surface, Text, Empty } from "@cloudflare/kumo";
import {
WrenchIcon,
DatabaseIcon,
PaperPlaneRightIcon,
TrashIcon,
ArrowClockwiseIcon,
PlugIcon,
InfoIcon,
WarningCircleIcon,
CheckCircleIcon
} from "@phosphor-icons/react";
import "./styles.css";
interface McpTool {
name: string;
description?: string;
inputSchema?: {
type: string;
properties?: Record<string, { type: string; description?: string }>;
required?: string[];
};
}
interface McpResource {
name: string;
uri: string;
description?: string;
}
interface ServerInfo {
name: string;
version: string;
}
interface ToolResult {
label: string;
text: string;
isError: boolean;
timestamp: number;
}
interface JsonRpcResponse {
jsonrpc: string;
id?: number;
result?: Record<string, unknown>;
error?: { code: number; message: string };
}
let nextId = 0;
async function mcpFetch(
endpoint: string,
method: string,
params: Record<string, unknown>,
sessionId: string | null
): Promise<{ data: JsonRpcResponse | null; sessionId: string | null }> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream"
};
if (sessionId) headers["mcp-session-id"] = sessionId;
const body: Record<string, unknown> = { jsonrpc: "2.0", method, params };
const isNotification = method.startsWith("notifications/");
if (!isNotification) body.id = ++nextId;
const res = await fetch(endpoint, {
method: "POST",
headers,
body: JSON.stringify(body)
});
const newSessionId = res.headers.get("mcp-session-id") || sessionId;
if (isNotification || res.status === 202) {
return { data: null, sessionId: newSessionId };
}
const contentType = res.headers.get("content-type") || "";
let data: JsonRpcResponse;
if (contentType.includes("text/event-stream")) {
const text = await res.text();
const match = text.match(/^data: (.+)$/m);
data = match ? JSON.parse(match[1]) : { jsonrpc: "2.0" };
} else {
data = await res.json();
}
return { data, sessionId: newSessionId };
}
function ToolCard({
tool,
onCall
}: {
tool: McpTool;
onCall: (name: string, args: Record<string, unknown>) => Promise<void>;
}) {
const [args, setArgs] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(false);
const properties = tool.inputSchema?.properties || {};
const propertyEntries = Object.entries(properties);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
const typedArgs: Record<string, unknown> = {};
for (const [key, value] of Object.entries(args)) {
const prop = properties[key];
if (prop?.type === "number" || prop?.type === "integer") {
typedArgs[key] = Number(value);
} else if (prop?.type === "boolean") {
typedArgs[key] = value === "true";
} else {
typedArgs[key] = value;
}
}
await onCall(tool.name, typedArgs);
setLoading(false);
};
return (
<Surface className="p-4 rounded-xl ring ring-kumo-line">
<Text size="sm" bold>
{tool.name}
</Text>
{tool.description && (
<span className="mt-0.5 block">
<Text size="xs" variant="secondary">
{tool.description}
</Text>
</span>
)}
<form onSubmit={handleSubmit} className="mt-3 space-y-2">
{propertyEntries.map(([key, schema]) => (
<div key={key}>
<label className="block text-xs text-kumo-subtle mb-1">
{key}
{tool.inputSchema?.required?.includes(key) && (
<span className="text-red-500"> *</span>
)}
<input
type={
schema.type === "number" || schema.type === "integer"
? "number"
: "text"
}
value={args[key] || ""}
onChange={(e) =>
setArgs((prev) => ({
...prev,
[key]: e.target.value
}))
}
placeholder={schema.description || key}
className="mt-1 w-full px-3 py-1.5 text-sm rounded-lg border border-kumo-line bg-kumo-base text-kumo-default placeholder:text-kumo-inactive focus:outline-none focus:ring-1 focus:ring-kumo-accent"
/>
</label>
</div>
))}
<Button
type="submit"
variant="primary"
size="sm"
loading={loading}
icon={<PaperPlaneRightIcon size={14} />}
>
Call
</Button>
</form>
</Surface>
);
}
function App() {
const [status, setStatus] = useState<ConnectionStatus>("connecting");
const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null);
const [tools, setTools] = useState<McpTool[]>([]);
const [resources, setResources] = useState<McpResource[]>([]);
const [results, setResults] = useState<ToolResult[]>([]);
const sessionRef = useRef<string | null>(null);
const connect = useCallback(async () => {
try {
setStatus("connecting");
const init = await mcpFetch(
"/mcp",
"initialize",
{
protocolVersion: "2025-03-26",
capabilities: {},
clientInfo: {
name: "browser-tester",
version: "1.0.0"
}
},
null
);
sessionRef.current = init.sessionId;
const initResult = init.data?.result as
| { serverInfo?: ServerInfo }
| undefined;
setServerInfo(initResult?.serverInfo ?? null);
await mcpFetch("/mcp", "notifications/initialized", {}, init.sessionId);
const toolsRes = await mcpFetch("/mcp", "tools/list", {}, init.sessionId);
const toolsResult = toolsRes.data?.result as
| { tools?: McpTool[] }
| undefined;
setTools(toolsResult?.tools ?? []);
try {
const resourcesRes = await mcpFetch(
"/mcp",
"resources/list",
{},
init.sessionId
);
const resourcesResult = resourcesRes.data?.result as
| { resources?: McpResource[] }
| undefined;
setResources(resourcesResult?.resources ?? []);
} catch {
// Server may not support resources
}
setStatus("connected");
} catch {
setStatus("disconnected");
}
}, []);
useEffect(() => {
connect();
}, [connect]);
const handleCallTool = async (
name: string,
args: Record<string, unknown>
) => {
try {
const res = await mcpFetch(
"/mcp",
"tools/call",
{ name, arguments: args },
sessionRef.current
);
const result = res.data?.result as
| {
content?: Array<{ type: string; text?: string }>;
isError?: boolean;
}
| undefined;
const text = result?.content?.[0]?.text ?? JSON.stringify(result);
setResults((prev) => [
{
label: name,
text,
isError: result?.isError ?? false,
timestamp: Date.now()
},
...prev
]);
} catch (err) {
setResults((prev) => [
{
label: name,
text: err instanceof Error ? err.message : String(err),
isError: true,
timestamp: Date.now()
},
...prev
]);
}
};
const handleReadResource = async (uri: string) => {
try {
const res = await mcpFetch(
"/mcp",
"resources/read",
{ uri },
sessionRef.current
);
const result = res.data?.result as
| { contents?: Array<{ text?: string; uri?: string }> }
| undefined;
const text = result?.contents?.[0]?.text ?? JSON.stringify(result);
setResults((prev) => [
{ label: uri, text, isError: false, timestamp: Date.now() },
...prev
]);
} catch (err) {
setResults((prev) => [
{
label: uri,
text: err instanceof Error ? err.message : String(err),
isError: true,
timestamp: Date.now()
},
...prev
]);
}
};
return (
<div className="h-full flex flex-col bg-kumo-base">
<header className="px-5 py-4 border-b border-kumo-line">
<div className="max-w-3xl mx-auto flex items-center justify-between">
<div className="flex items-center gap-3">
<PlugIcon size={22} className="text-kumo-accent" weight="bold" />
<h1 className="text-lg font-semibold text-kumo-default">
{serverInfo?.name ?? "MCP Server"}
</h1>
{serverInfo && (
<Badge variant="secondary">v{serverInfo.version}</Badge>
)}
</div>
<div className="flex items-center gap-3">
<ConnectionIndicator status={status} />
<ModeToggle />
{status === "disconnected" && (
<Button
variant="secondary"
size="sm"
icon={<ArrowClockwiseIcon size={14} />}
onClick={connect}
>
Reconnect
</Button>
)}
</div>
</div>
</header>
<main className="flex-1 overflow-auto p-5">
<div className="max-w-3xl mx-auto space-y-8">
<Surface className="p-4 rounded-xl ring ring-kumo-line">
<div className="flex gap-3">
<InfoIcon
size={20}
weight="bold"
className="text-kumo-accent shrink-0 mt-0.5"
/>
<div>
<Text size="sm" bold>
Stateless MCP Server (createMcpHandler)
</Text>
<span className="mt-1 block">
<Text size="xs" variant="secondary">
The simplest way to run an MCP server on Cloudflare Workers.
Uses{" "}
<code className="text-xs px-1 py-0.5 rounded bg-kumo-elevated font-mono">
createMcpHandler
</code>{" "}
from the Agents SDK to wrap an{" "}
<code className="text-xs px-1 py-0.5 rounded bg-kumo-elevated font-mono">
McpServer
</code>{" "}
into a Worker-compatible fetch handler in one line — no
Durable Objects, no persistent state.
</Text>
</span>
</div>
</div>
</Surface>
{status === "disconnected" && (
<Empty
icon={<PlugIcon size={32} />}
title="Disconnected"
description="Could not connect to the MCP server. Make sure it is running and try reconnecting."
/>
)}
{status === "connected" && (
<>
<section>
<div className="flex items-center gap-2 mb-3">
<WrenchIcon
size={18}
weight="bold"
className="text-kumo-subtle"
/>
<Text size="base" bold>
Tools
</Text>
<Badge variant="secondary">{tools.length}</Badge>
</div>
{tools.length === 0 ? (
<Empty
icon={<WrenchIcon size={32} />}
title="No tools"
description="This server has no registered tools."
/>
) : (
<div className="space-y-3">
{tools.map((tool) => (
<ToolCard
key={tool.name}
tool={tool}
onCall={handleCallTool}
/>
))}
</div>
)}
</section>
{resources.length > 0 && (
<section>
<div className="flex items-center gap-2 mb-3">
<DatabaseIcon
size={18}
weight="bold"
className="text-kumo-subtle"
/>
<Text size="base" bold>
Resources
</Text>
<Badge variant="secondary">{resources.length}</Badge>
</div>
<div className="space-y-2">
{resources.map((r) => (
<Surface
key={r.uri}
className="p-3 rounded-xl ring ring-kumo-line flex items-center justify-between"
>
<div>
<Text size="sm" bold>
{r.name}
</Text>
<Text size="xs" variant="secondary">
{r.uri}
</Text>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => handleReadResource(r.uri)}
>
Read
</Button>
</Surface>
))}
</div>
</section>
)}
{results.length > 0 && (
<section>
<div className="flex items-center justify-between mb-3">
<Text size="base" bold>
Results
</Text>
<Button
variant="ghost"
size="sm"
icon={<TrashIcon size={14} />}
onClick={() => setResults([])}
>
Clear
</Button>
</div>
<div className="space-y-2">
{results.map((r) => (
<Surface
key={r.timestamp}
className={`p-3 rounded-xl ring ${r.isError ? "ring-red-500/30 bg-red-50 dark:bg-red-950/20" : "ring-kumo-line"}`}
>
<div className="flex items-start gap-2">
{r.isError ? (
<WarningCircleIcon
size={16}
weight="fill"
className="text-red-500 shrink-0 mt-0.5"
/>
) : (
<CheckCircleIcon
size={16}
weight="fill"
className="text-green-600 shrink-0 mt-0.5"
/>
)}
<div className="min-w-0 flex-1">
<Text size="xs" variant="secondary" bold>
{r.label}
</Text>
<p
className={`text-sm mt-0.5 whitespace-pre-wrap break-words ${r.isError ? "text-red-600 dark:text-red-400" : "text-kumo-default"}`}
>
{r.text}
</p>
</div>
<span className="text-[10px] text-kumo-inactive tabular-nums shrink-0">
{new Date(r.timestamp).toLocaleTimeString()}
</span>
</div>
</Surface>
))}
</div>
</section>
)}
</>
)}
</div>
</main>
<footer className="border-t border-kumo-line py-3">
<div className="flex justify-center">
<PoweredByAgents />
</div>
</footer>
</div>
);
}
createRoot(document.getElementById("root")!).render(
<ThemeProvider>
<App />
</ThemeProvider>
);