branch:
ClientDemo.tsx
10706 bytesRaw
import { useAgent } from "agents/react";
import { useState } from "react";
import { Button, Input, InputArea, Surface, Text } from "@cloudflare/kumo";
import { DemoWrapper } from "../../layout";
import {
  LogPanel,
  ConnectionStatus,
  CodeExplanation,
  HighlightedJson,
  type CodeSection
} from "../../components";
import { useLogs, useUserId, useToast } from "../../hooks";
import type { McpClientAgent, McpClientState } from "./mcp-client-agent";

const codeSections: CodeSection[] = [
  {
    title: "Connect to external MCP servers",
    description:
      "Use this.addMcpServer() to connect your agent to any MCP server. The connection persists across restarts — the agent automatically reconnects.",
    code: `import { Agent, callable } from "agents";

class McpClientAgent extends Agent<Env> {
  @callable()
  async connectToServer(url: string) {
    const result = await this.addMcpServer("my-server", url);
    return result; // { id: "...", state: "ready" }
  }
}`
  },
  {
    title: "Reactive MCP updates with onMcpUpdate",
    description:
      "On the client, useAgent provides an onMcpUpdate callback that fires whenever the agent's MCP state changes — tools, resources, and server status arrive automatically after connecting. No need to poll.",
    code: `const agent = useAgent({
  agent: "mcp-client-agent",
  name: "demo",
  onMcpUpdate: (mcpState) => {
    // Fires automatically when MCP servers connect/disconnect
    console.log("Tools:", mcpState.tools);
    console.log("Resources:", mcpState.resources);
    console.log("Servers:", mcpState.servers);
  },
});

// Call tools via @callable on the agent
await agent.call("callTool", [toolName, serverId, args]);`
  }
];

interface ToolInfo {
  name: string;
  description?: string;
  inputSchema?: unknown;
  serverId?: string;
}

export function McpClientDemo() {
  const userId = useUserId();
  const { logs, addLog, clearLogs } = useLogs();
  const { toast } = useToast();
  const [mcpUrl, setMcpUrl] = useState(`${window.location.origin}/mcp-server`);
  const [isConnecting, setIsConnecting] = useState(false);
  const [isConnected, setIsConnected] = useState(false);
  const [tools, setTools] = useState<ToolInfo[]>([]);
  const [resources, setResources] = useState<unknown[]>([]);
  const [selectedTool, setSelectedTool] = useState<string | null>(null);
  const [argsText, setArgsText] = useState("{}");
  const [toolResult, setToolResult] = useState<unknown>(null);
  const [isCallingTool, setIsCallingTool] = useState(false);

  const agent = useAgent<McpClientAgent, McpClientState>({
    agent: "mcp-client-agent",
    name: `mcp-client-${userId}`,
    onOpen: () => addLog("info", "connected"),
    onClose: () => addLog("info", "disconnected"),
    onError: () => addLog("error", "error", "Connection error"),
    onStateUpdate: (newState) => {
      if (newState?.connectedServer) {
        setIsConnected(true);
      } else {
        setIsConnected(false);
        setTools([]);
        setResources([]);
      }
    },
    onMcpUpdate: (mcpState) => {
      const discoveredTools = (mcpState.tools ?? []) as ToolInfo[];
      setTools(discoveredTools);
      setResources(mcpState.resources ?? []);
      addLog("in", "mcp_update", {
        tools: discoveredTools.length,
        resources: (mcpState.resources ?? []).length,
        servers: Object.keys(mcpState.servers ?? {}).length
      });
    }
  });

  const handleConnect = async () => {
    if (!mcpUrl.trim()) return;
    setIsConnecting(true);
    addLog("out", "connectToServer", { url: mcpUrl });

    try {
      const result = await agent.call("connectToServer", [mcpUrl]);
      addLog("in", "connected", result);
      setIsConnected(true);
      toast("Connected to MCP server", "success");
    } catch (e) {
      addLog("error", "error", e instanceof Error ? e.message : String(e));
    } finally {
      setIsConnecting(false);
    }
  };

  const handleDisconnect = async () => {
    addLog("out", "disconnectServer");
    try {
      await agent.call("disconnectServer");
      setIsConnected(false);
      setTools([]);
      setResources([]);
      setSelectedTool(null);
      setToolResult(null);
      addLog("in", "disconnected");
      toast("Disconnected", "info");
    } catch (e) {
      addLog("error", "error", e instanceof Error ? e.message : String(e));
    }
  };

  const handleCallTool = async () => {
    if (!selectedTool) return;
    let args: Record<string, unknown>;
    try {
      args = JSON.parse(argsText);
    } catch {
      addLog("error", "error", "Invalid JSON in arguments");
      return;
    }

    const tool = tools.find((t) => t.name === selectedTool);
    const serverId = tool?.serverId ?? "";

    setIsCallingTool(true);
    setToolResult(null);
    addLog("out", "callTool", { name: selectedTool, serverId, args });

    try {
      const result = await agent.call("callTool", [
        selectedTool,
        serverId,
        args
      ]);
      addLog("in", "tool_result", result);
      setToolResult(result);
      toast(selectedTool + " called", "success");
    } catch (e) {
      addLog("error", "error", e instanceof Error ? e.message : String(e));
    } finally {
      setIsCallingTool(false);
    }
  };

  const handleSelectTool = (name: string) => {
    setSelectedTool(name);
    setToolResult(null);
    setArgsText("{}");
  };

  return (
    <DemoWrapper
      title="MCP Client"
      description={
        <>
          This agent connects to external MCP servers using{" "}
          <code className="text-xs bg-kumo-fill px-1 py-0.5 rounded">
            this.addMcpServer()
          </code>
          . Tools and resources are discovered automatically via the{" "}
          <code className="text-xs bg-kumo-fill px-1 py-0.5 rounded">
            onMcpUpdate
          </code>{" "}
          callback — no polling needed. Try connecting to the playground's own
          MCP server.
        </>
      }
      statusIndicator={
        <ConnectionStatus
          status={
            agent.readyState === WebSocket.OPEN ? "connected" : "connecting"
          }
        />
      }
    >
      <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
        {/* Controls */}
        <div className="space-y-6">
          {/* Connect */}
          <Surface className="p-4 rounded-lg ring ring-kumo-line">
            <div className="mb-4">
              <Text variant="heading3">Connect to MCP Server</Text>
            </div>
            <div className="flex gap-2 mb-3">
              <Input
                aria-label="MCP server URL"
                type="text"
                value={mcpUrl}
                onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
                  setMcpUrl(e.target.value)
                }
                className="flex-1 font-mono text-xs"
                placeholder="https://..."
                disabled={isConnected}
              />
            </div>
            {isConnected ? (
              <Button
                variant="destructive"
                onClick={handleDisconnect}
                className="w-full"
              >
                Disconnect
              </Button>
            ) : (
              <Button
                variant="primary"
                onClick={handleConnect}
                disabled={isConnecting || !mcpUrl.trim()}
                className="w-full"
              >
                {isConnecting ? "Connecting..." : "Connect"}
              </Button>
            )}
          </Surface>

          {/* Discovered Tools */}
          {tools.length > 0 && (
            <Surface className="p-4 rounded-lg ring ring-kumo-line">
              <div className="mb-4">
                <Text variant="heading3">
                  Discovered Tools ({tools.length})
                </Text>
              </div>
              <div className="space-y-2">
                {tools.map((tool) => (
                  <button
                    key={tool.name}
                    type="button"
                    onClick={() => handleSelectTool(tool.name)}
                    className={`w-full text-left p-3 rounded border transition-colors ${
                      selectedTool === tool.name
                        ? "border-kumo-brand bg-kumo-elevated"
                        : "border-kumo-line hover:border-kumo-interact"
                    }`}
                  >
                    <code className="text-sm font-semibold text-kumo-default">
                      {tool.name}
                    </code>
                    {tool.description && (
                      <div className="mt-1">
                        <Text variant="secondary" size="xs">
                          {tool.description}
                        </Text>
                      </div>
                    )}
                  </button>
                ))}
              </div>
            </Surface>
          )}

          {/* Discovered Resources */}
          {resources.length > 0 ? (
            <Surface className="p-4 rounded-lg ring ring-kumo-line">
              <div className="mb-2">
                <Text variant="heading3">Resources ({resources.length})</Text>
              </div>
              <HighlightedJson data={resources} />
            </Surface>
          ) : null}

          {/* Call Tool */}
          {selectedTool && (
            <Surface className="p-4 rounded-lg ring ring-kumo-line">
              <div className="mb-4">
                <Text variant="heading3">Call: {selectedTool}</Text>
              </div>
              <InputArea
                aria-label="Tool arguments (JSON)"
                value={argsText}
                onChange={(e) => setArgsText(e.target.value)}
                className="w-full h-20 font-mono text-sm mb-3"
              />
              <Button
                variant="primary"
                onClick={handleCallTool}
                disabled={isCallingTool}
                className="w-full"
              >
                {isCallingTool ? "Calling..." : `Call ${selectedTool}`}
              </Button>
            </Surface>
          )}

          {/* Tool Result */}
          {toolResult !== null && (
            <Surface className="p-4 rounded-lg ring ring-kumo-line">
              <div className="mb-2">
                <Text variant="heading3">Result</Text>
              </div>
              <HighlightedJson data={toolResult} />
            </Surface>
          )}
        </div>

        {/* Logs */}
        <div className="space-y-6">
          <LogPanel logs={logs} onClear={clearLogs} maxHeight="600px" />
        </div>
      </div>

      <CodeExplanation sections={codeSections} />
    </DemoWrapper>
  );
}