branch:
client.tsx
13053 bytesRaw
import { useAgent } from "agents/react";
import { useCallback, useRef, useState } 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 {
  PlusIcon,
  PlugIcon,
  PlugsConnectedIcon,
  WrenchIcon,
  ChatTextIcon,
  DatabaseIcon,
  TrashIcon,
  SignInIcon,
  InfoIcon
} from "@phosphor-icons/react";
import type { MCPServersState } from "agents";
import { nanoid } from "nanoid";
import "./styles.css";

let sessionId = localStorage.getItem("sessionId");
if (!sessionId) {
  sessionId = nanoid(8);
  localStorage.setItem("sessionId", sessionId);
}

function App() {
  const [connectionStatus, setConnectionStatus] =
    useState<ConnectionStatus>("connecting");
  const mcpUrlInputRef = useRef<HTMLInputElement>(null);
  const mcpNameInputRef = useRef<HTMLInputElement>(null);
  const [mcpState, setMcpState] = useState<MCPServersState>({
    prompts: [],
    resources: [],
    servers: {},
    tools: []
  });

  const agent = useAgent({
    agent: "my-agent",
    name: sessionId!,
    onClose: useCallback(() => setConnectionStatus("disconnected"), []),
    onMcpUpdate: useCallback((mcpServers: MCPServersState) => {
      setMcpState(mcpServers);
    }, []),
    onOpen: useCallback(() => setConnectionStatus("connected"), [])
  });

  function openPopup(authUrl: string) {
    window.open(
      authUrl,
      "popupWindow",
      "width=600,height=800,resizable=yes,scrollbars=yes"
    );
  }

  const handleMcpSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    if (!mcpUrlInputRef.current || !mcpUrlInputRef.current.value.trim()) return;
    if (!mcpNameInputRef.current || !mcpNameInputRef.current.value.trim())
      return;

    const serverName = mcpNameInputRef.current.value;
    const serverUrl = mcpUrlInputRef.current.value;

    agent.call("addServer", [serverName, serverUrl]);

    mcpUrlInputRef.current.value = "";
    mcpNameInputRef.current.value = "";
  };

  const handleDisconnect = async (serverId: string) => {
    await agent.call("disconnectServer", [serverId]);
  };

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

  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">
            <PlugsConnectedIcon
              size={22}
              className="text-kumo-accent"
              weight="bold"
            />
            <h1 className="text-lg font-semibold text-kumo-default">
              MCP Client
            </h1>
          </div>
          <div className="flex items-center gap-3">
            <ConnectionIndicator status={connectionStatus} />
            <ModeToggle />
          </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>
                  MCP Client
                </Text>
                <span className="mt-1 block">
                  <Text size="xs" variant="secondary">
                    This Agent acts as an MCP client — dynamically connecting to
                    remote MCP servers, handling OAuth authentication
                    automatically, and aggregating tools, prompts, and resources
                    from all connected servers. Add a server URL below to get
                    started.
                  </Text>
                </span>
              </div>
            </div>
          </Surface>

          {/* Add Server Form */}
          <Surface className="p-4 rounded-xl ring ring-kumo-line">
            <div className="mb-3">
              <Text size="sm" bold>
                Connect to an MCP Server
              </Text>
            </div>
            <form onSubmit={handleMcpSubmit} className="flex gap-2 items-end">
              <div className="w-40">
                <label className="block text-xs text-kumo-subtle mb-1">
                  Name
                  <input
                    ref={mcpNameInputRef}
                    type="text"
                    placeholder="My Server"
                    className="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>
              <div className="flex-1">
                <label className="block text-xs text-kumo-subtle mb-1">
                  URL
                  <input
                    ref={mcpUrlInputRef}
                    type="text"
                    placeholder="https://example.com/mcp"
                    className="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"
                icon={<PlusIcon size={14} />}
              >
                Add
              </Button>
            </form>
          </Surface>

          {/* Connected Servers */}
          <section>
            <div className="flex items-center gap-2 mb-3">
              <PlugIcon size={18} weight="bold" className="text-kumo-subtle" />
              <Text size="base" bold>
                Servers
              </Text>
              <Badge variant="secondary">{serverEntries.length}</Badge>
            </div>
            {serverEntries.length === 0 ? (
              <Empty
                icon={<PlugIcon size={32} />}
                title="No servers connected"
                description="Add an MCP server URL above to get started."
              />
            ) : (
              <div className="space-y-2">
                {serverEntries.map(([id, server]) => (
                  <Surface
                    key={id}
                    className="p-4 rounded-xl ring ring-kumo-line"
                  >
                    <div className="flex items-start justify-between">
                      <div>
                        <div className="flex items-center gap-2">
                          <Text size="sm" bold>
                            {server.name}
                          </Text>
                          <Badge
                            variant={
                              server.state === "ready"
                                ? "primary"
                                : server.state === "failed"
                                  ? "destructive"
                                  : "secondary"
                            }
                          >
                            {server.state}
                          </Badge>
                        </div>
                        <span className="mt-0.5 font-mono block">
                          <Text size="xs" variant="secondary">
                            {server.server_url}
                          </Text>
                        </span>
                        {server.state === "failed" && server.error && (
                          <span className="text-red-500 mt-1 block">
                            <Text size="xs">{server.error}</Text>
                          </span>
                        )}
                      </div>
                      <div className="flex items-center gap-2">
                        {server.state === "authenticating" &&
                          server.auth_url && (
                            <Button
                              variant="primary"
                              size="sm"
                              icon={<SignInIcon size={14} />}
                              onClick={() =>
                                openPopup(server.auth_url as string)
                              }
                            >
                              Authorize
                            </Button>
                          )}
                        <Button
                          variant="ghost"
                          size="sm"
                          icon={<TrashIcon size={14} />}
                          onClick={() => handleDisconnect(id)}
                        />
                      </div>
                    </div>
                  </Surface>
                ))}
              </div>
            )}
          </section>

          {/* Aggregated Data */}
          {mcpState.tools.length > 0 && (
            <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">{mcpState.tools.length}</Badge>
              </div>
              <div className="space-y-2">
                {mcpState.tools.map((tool) => (
                  <Surface
                    key={`${tool.name}-${tool.serverId}`}
                    className="p-3 rounded-xl ring ring-kumo-line"
                  >
                    <div className="flex items-center gap-2">
                      <Text size="sm" bold>
                        {tool.name}
                      </Text>
                      <Badge variant="secondary">{tool.serverId}</Badge>
                    </div>
                    <pre className="text-xs mt-1 whitespace-pre-wrap break-words text-kumo-subtle font-mono">
                      {JSON.stringify(tool, null, 2)}
                    </pre>
                  </Surface>
                ))}
              </div>
            </section>
          )}

          {mcpState.prompts.length > 0 && (
            <section>
              <div className="flex items-center gap-2 mb-3">
                <ChatTextIcon
                  size={18}
                  weight="bold"
                  className="text-kumo-subtle"
                />
                <Text size="base" bold>
                  Prompts
                </Text>
                <Badge variant="secondary">{mcpState.prompts.length}</Badge>
              </div>
              <div className="space-y-2">
                {mcpState.prompts.map((prompt) => (
                  <Surface
                    key={`${prompt.name}-${prompt.serverId}`}
                    className="p-3 rounded-xl ring ring-kumo-line"
                  >
                    <Text size="sm" bold>
                      {prompt.name}
                    </Text>
                    <pre className="text-xs mt-1 whitespace-pre-wrap break-words text-kumo-subtle font-mono">
                      {JSON.stringify(prompt, null, 2)}
                    </pre>
                  </Surface>
                ))}
              </div>
            </section>
          )}

          {mcpState.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">{mcpState.resources.length}</Badge>
              </div>
              <div className="space-y-2">
                {mcpState.resources.map((resource) => (
                  <Surface
                    key={`${resource.name}-${resource.serverId}`}
                    className="p-3 rounded-xl ring ring-kumo-line"
                  >
                    <Text size="sm" bold>
                      {resource.name}
                    </Text>
                    <pre className="text-xs mt-1 whitespace-pre-wrap break-words text-kumo-subtle font-mono">
                      {JSON.stringify(resource, null, 2)}
                    </pre>
                  </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>
);