branch:
McpServers.tsx
19644 bytesRaw
import { useEffect, useRef, useState } from "react";
import {
  GearIcon,
  KeyIcon,
  EyeIcon,
  EyeSlashIcon,
  ArrowsClockwiseIcon,
  CaretDownIcon
} from "@phosphor-icons/react";
import { Button } from "@cloudflare/kumo";
import { Streamdown } from "streamdown";
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
import type { Prompt } from "@modelcontextprotocol/sdk/types.js";
import type { Resource } from "@modelcontextprotocol/sdk/types.js";
import type { Playground, PlaygroundState } from "../server";
import type { useAgent } from "agents/react";
import LocalhostWarningModal from "./LocalhostWarningModal";
import { McpInfoIcon } from "./Icons";

export type McpServerInfo = {
  id: string;
  name?: string;
  url?: string;
  state: string;
  error?: string | null;
};

export type McpServersComponentState = {
  servers: McpServerInfo[];
  tools: Tool[];
  prompts: Prompt[];
  resources: Resource[];
};

type McpServersProps = {
  agent: ReturnType<typeof useAgent<Playground, PlaygroundState>>;
  mcpState: McpServersComponentState;
  mcpLogs: Array<{ timestamp: number; status: string; serverUrl?: string }>;
};

export function McpServers({ agent, mcpState, mcpLogs }: McpServersProps) {
  const [serverUrl, setServerUrl] = useState("");
  const [showSettings, setShowSettings] = useState(false);
  const [showLocalhostWarning, setShowLocalhostWarning] = useState(false);
  const [error, setError] = useState<string>("");
  const [isConnecting, setIsConnecting] = useState(false);
  const [disconnectingServerId, setDisconnectingServerId] = useState<
    string | null
  >(null);

  const hasConnectingServer = mcpState.servers.some(
    (s) =>
      s.state === "discovering" ||
      s.state === "connecting" ||
      s.state === "connected" ||
      s.state === "authenticating"
  );

  const authenticatingServer = mcpState.servers.find(
    (s) => s.state === "authenticating"
  );

  const logRef = useRef<HTMLDivElement>(null);
  const [showAuth, setShowAuth] = useState<boolean>(false);
  const [headerKey, setHeaderKey] = useState<string>(() => {
    return sessionStorage.getItem("mcpHeaderKey") || "Authorization";
  });
  const [bearerToken, setBearerToken] = useState<string>(() => {
    return sessionStorage.getItem("mcpBearerToken") || "";
  });
  const [showToken, setShowToken] = useState<boolean>(false);
  const [expandedTools, setExpandedTools] = useState<Set<string>>(new Set());

  const toggleToolExpansion = (toolName: string) => {
    setExpandedTools((prev) => {
      const newSet = new Set(prev);
      if (newSet.has(toolName)) {
        newSet.delete(toolName);
      } else {
        newSet.add(toolName);
      }
      return newSet;
    });
  };

  const clearAuthFields = () => {
    setHeaderKey("Authorization");
    setBearerToken("");
    sessionStorage.removeItem("mcpHeaderKey");
    sessionStorage.removeItem("mcpBearerToken");
  };

  const handleConnect = async () => {
    if (!serverUrl) {
      setError("Please enter a server URL");
      return;
    }

    try {
      const url = new URL(serverUrl);
      const hostname = url.hostname.toLowerCase();
      if (
        hostname === "localhost" ||
        hostname === "127.0.0.1" ||
        hostname === "0.0.0.0" ||
        hostname === "::1"
      ) {
        setShowLocalhostWarning(true);
        return;
      }
    } catch (_err) {
      // Invalid URL, let the server handle it
    }

    setIsConnecting(true);
    setError("");

    try {
      let headers: Record<string, string> | undefined;
      if (headerKey && bearerToken) {
        headers = {
          [headerKey]: `Bearer ${bearerToken}`
        };
      }

      const result = (await agent.stub.connectMCPServer(serverUrl, headers)) as
        | { authUrl?: string }
        | undefined;

      if (result?.authUrl) {
        openOAuthPopup(result.authUrl);
      }

      setServerUrl("");
      clearAuthFields();
      setShowAuth(false);
    } catch (err: unknown) {
      console.error("[McpServers] Connection error:", err);
      setError(
        err instanceof Error ? err.message : "Failed to connect to MCP server"
      );
    } finally {
      setIsConnecting(false);
    }
  };

  const handleDisconnect = async (serverId: string) => {
    setDisconnectingServerId(serverId);
    setError("");

    try {
      await agent.stub.disconnectMCPServer(serverId);
    } catch (err: unknown) {
      console.error("[McpServers] Disconnect error:", err);
      setError(
        err instanceof Error
          ? err.message
          : "Failed to disconnect from MCP server"
      );
    } finally {
      setDisconnectingServerId(null);
    }
  };

  const openOAuthPopup = (authUrl: string) => {
    window.open(
      authUrl,
      "mcpOAuthWindow",
      "width=600,height=800,resizable=yes,scrollbars=yes,toolbar=yes,menubar=no,location=no,directories=no,status=yes"
    );
  };

  // Auto-scroll debug log when new entries arrive
  useEffect(() => {
    if (logRef.current) {
      logRef.current.scrollTop = logRef.current.scrollHeight;
    }
  }, [mcpLogs]);

  const statusColors: Record<string, string> = {
    discovering:
      "bg-blue-100 text-blue-700 border-blue-300 dark:bg-blue-500/15 dark:text-blue-400 dark:border-blue-400/30",
    authenticating:
      "bg-amber-100 text-amber-700 border-amber-300 dark:bg-amber-500/15 dark:text-amber-400 dark:border-amber-400/30",
    connecting:
      "bg-blue-100 text-blue-700 border-blue-300 dark:bg-blue-500/15 dark:text-blue-400 dark:border-blue-400/30",
    connected:
      "bg-green-100 text-green-700 border-green-300 dark:bg-green-500/15 dark:text-green-400 dark:border-green-400/30",
    ready:
      "bg-green-100 text-green-700 border-green-300 dark:bg-green-500/15 dark:text-green-400 dark:border-green-400/30",
    failed:
      "bg-red-100 text-red-700 border-red-300 dark:bg-red-500/15 dark:text-red-400 dark:border-red-400/30",
    "not-connected":
      "bg-gray-100 text-gray-700 border-gray-300 dark:bg-gray-500/15 dark:text-gray-400 dark:border-gray-400/30"
  };

  const statusLabel: Record<string, string> = {
    discovering: "Discovering",
    authenticating: "Authenticating",
    connecting: "Connecting",
    connected: "Connected",
    ready: "Ready",
    failed: "Failed",
    "not-connected": "Not Connected"
  };

  const getStatusBadge = (state: string) => (
    <span
      data-testid="status"
      className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium border ${statusColors[state] || "bg-gray-500/15 text-gray-600 dark:text-gray-400 border-gray-400/30"}`}
    >
      {statusLabel[state] || "Unknown"}
    </span>
  );

  return (
    <section className="bg-kumo-base px-3 py-2">
      <div className="flex items-center">
        <span className="text-xs font-semibold text-kumo-secondary uppercase tracking-wide">
          MCP Servers
        </span>
        <div className="ml-2 mt-0.5">
          <a
            href="https://developers.cloudflare.com/agents/guides/remote-mcp-server/"
            target="_blank"
            rel="noopener noreferrer"
            title="Learn more about MCP Servers"
          >
            <McpInfoIcon />
          </a>
        </div>
        <Button
          variant="secondary"
          size="sm"
          className="ml-auto"
          onClick={() => setShowSettings(!showSettings)}
          icon={<GearIcon size={14} />}
          title="Debug Log"
        />
      </div>

      {error && (
        <div className="mt-2 flex items-start gap-2 p-2 rounded-md bg-red-500/10 border border-red-300 text-xs text-kumo-danger">
          <span className="flex-1">{error}</span>
          <button
            type="button"
            onClick={() => setError("")}
            className="text-kumo-secondary hover:text-kumo-default shrink-0"
          >
            ×
          </button>
        </div>
      )}

      <div className="mt-2">
        {/* Add new server form */}
        <div className="relative mb-2">
          <div className="flex space-x-1.5">
            <input
              type="text"
              className="grow p-1.5 text-sm border border-kumo-line rounded-md bg-kumo-base text-kumo-default hover:bg-kumo-tint focus:outline-none focus:ring-1 focus:ring-kumo-ring"
              placeholder="MCP server URL"
              value={serverUrl}
              onChange={(e) => setServerUrl(e.target.value)}
            />
            <button
              type="button"
              className={`p-1.5 border rounded-md transition-colors ${
                showAuth || (headerKey && bearerToken)
                  ? "border-orange-400 bg-orange-500/10 text-orange-500"
                  : "border-kumo-line text-kumo-secondary hover:bg-kumo-tint"
              }`}
              onClick={() => setShowAuth(!showAuth)}
              title="Authentication settings"
            >
              <KeyIcon size={16} />
            </button>
            <button
              type="button"
              className="bg-ai-loop bg-size-[200%_100%] hover:animate-gradient-background text-white rounded-md shadow-sm py-1.5 px-3 text-xs font-medium disabled:opacity-50"
              onClick={
                authenticatingServer
                  ? () => handleDisconnect(authenticatingServer.id)
                  : handleConnect
              }
              disabled={
                isConnecting ||
                (hasConnectingServer && !authenticatingServer) ||
                (!serverUrl && !authenticatingServer)
              }
            >
              {authenticatingServer
                ? "Cancel"
                : isConnecting || hasConnectingServer
                  ? "..."
                  : "Add"}
            </button>
          </div>

          {/* Auth dropdown */}
          {showAuth && (
            <div className="absolute z-10 mt-1.5 w-full bg-kumo-base border border-kumo-line rounded-md shadow-lg p-2.5 space-y-2">
              <div>
                <label
                  htmlFor="header-name"
                  className="block text-[11px] font-medium text-kumo-secondary mb-0.5"
                >
                  Header Name
                </label>
                <input
                  type="text"
                  className="w-full p-1.5 border border-kumo-line rounded-md text-xs bg-kumo-base text-kumo-default hover:bg-kumo-tint focus:outline-none focus:ring-1 focus:ring-kumo-ring"
                  placeholder="e.g., Authorization, X-API-Key"
                  value={headerKey}
                  onChange={(e) => {
                    const newValue = e.target.value;
                    setHeaderKey(newValue);
                    sessionStorage.setItem("mcpHeaderKey", newValue);
                  }}
                />
              </div>
              <div>
                <label
                  htmlFor="bearer-value"
                  className="block text-[11px] font-medium text-kumo-secondary mb-0.5"
                >
                  Bearer Value
                </label>
                <div className="relative">
                  <input
                    type={showToken ? "text" : "password"}
                    className="w-full p-1.5 pr-8 border border-kumo-line rounded-md text-xs bg-kumo-base text-kumo-default hover:bg-kumo-tint focus:outline-none focus:ring-1 focus:ring-kumo-ring"
                    placeholder="API key or token"
                    value={bearerToken}
                    onChange={(e) => {
                      const newValue = e.target.value;
                      setBearerToken(newValue);
                      sessionStorage.setItem("mcpBearerToken", newValue);
                    }}
                  />
                  <button
                    type="button"
                    className="absolute inset-y-0 right-0 pr-2 flex items-center text-kumo-secondary hover:text-kumo-default"
                    onClick={() => setShowToken(!showToken)}
                  >
                    {showToken ? (
                      <EyeSlashIcon size={14} />
                    ) : (
                      <EyeIcon size={14} />
                    )}
                  </button>
                </div>
              </div>
              {headerKey && bearerToken && (
                <div className="text-[11px] text-kumo-secondary">
                  Will send: {headerKey}: Bearer •••••••
                </div>
              )}
            </div>
          )}
        </div>

        {/* Connected Servers List */}
        {mcpState.servers.length > 0 && (
          <div className="mb-2 space-y-1.5">
            {mcpState.servers.map((server) => (
              <div
                key={server.id}
                className={`p-1.5 border rounded-md ${
                  server.state === "failed"
                    ? "border-red-300 bg-red-500/10"
                    : "border-kumo-line bg-kumo-control"
                }`}
              >
                <div className="flex items-center justify-between">
                  <div className="flex items-center space-x-1.5 min-w-0 flex-1">
                    {getStatusBadge(server.state)}
                    <span
                      className="text-xs text-kumo-secondary truncate"
                      title={server.url}
                    >
                      {server.url}
                    </span>
                  </div>
                  <button
                    type="button"
                    className="ml-1.5 shrink-0 inline-flex items-center justify-center size-5 rounded bg-red-100 text-red-600 hover:bg-red-200 dark:bg-red-500/15 dark:text-red-400 dark:hover:bg-red-500/25 disabled:opacity-50 text-xs font-medium"
                    onClick={() => handleDisconnect(server.id)}
                    disabled={disconnectingServerId === server.id}
                  >
                    {disconnectingServerId === server.id ? "..." : "×"}
                  </button>
                </div>
                {server.state === "failed" && server.error && (
                  <div className="mt-1 text-[11px] text-kumo-danger wrap-break-word">
                    {server.error}
                  </div>
                )}
              </div>
            ))}
          </div>
        )}

        {/* Debug Log */}
        {showSettings && (
          <div className="mt-2">
            <div className="font-medium text-xs block mb-1 text-kumo-default">
              Debug Log
            </div>
            <div
              ref={logRef}
              className="border border-kumo-line rounded-md p-1.5 bg-kumo-control h-32 overflow-y-auto font-mono text-[11px]"
            >
              {mcpLogs.map((log) => {
                const level =
                  log.status === "failed"
                    ? "error"
                    : log.status === "connecting" ||
                        log.status === "connected" ||
                        log.status === "discovering" ||
                        log.status === "authenticating" ||
                        log.status === "ready"
                      ? "info"
                      : "debug";

                const time = new Date(log.timestamp).toLocaleTimeString();

                return (
                  <div
                    key={log.timestamp}
                    className={`py-0.5 ${
                      level === "debug"
                        ? "text-kumo-inactive"
                        : level === "info"
                          ? "text-kumo-info"
                          : "text-kumo-danger"
                    }`}
                  >
                    [{level}] {time} - {log.status}
                  </div>
                );
              })}
            </div>
          </div>
        )}

        {/* Available Tools */}
        {mcpState.servers.some(
          (s) =>
            s.state === "connected" ||
            s.state === "ready" ||
            s.state === "discovering"
        ) && (
          <div className="mt-2 border border-kumo-line rounded-md bg-kumo-tint p-2">
            <div className="flex items-center justify-between mb-1.5">
              <div className="text-xs font-medium text-kumo-default">
                Tools ({mcpState.tools.length})
              </div>
              <button
                type="button"
                onClick={async () => {
                  for (const server of mcpState.servers) {
                    if (server.state === "ready") {
                      try {
                        await agent.stub.refreshMcpTools(server.id);
                      } catch (err) {
                        console.error(
                          "[McpServers] Failed to refresh tools:",
                          err
                        );
                      }
                    }
                  }
                }}
                className="p-1 hover:bg-kumo-interact text-kumo-default rounded transition-colors"
                title="Refresh server capabilities"
                aria-label="Refresh server capabilities"
              >
                <ArrowsClockwiseIcon size={14} />
              </button>
            </div>
            {mcpState.tools.length > 0 ? (
              <div className="space-y-1 max-h-48 overflow-y-auto pr-1">
                {mcpState.tools.map((tool: Tool) => {
                  const isExpanded = expandedTools.has(tool.name);
                  return (
                    <div
                      key={tool.name}
                      className="bg-kumo-base rounded border border-kumo-line"
                    >
                      <button
                        type="button"
                        onClick={() => toggleToolExpansion(tool.name)}
                        className="w-full flex items-center justify-between p-1.5 text-left hover:bg-kumo-tint rounded transition-colors"
                      >
                        <div className="font-medium text-[11px] text-kumo-default">
                          {tool.name.replace("tool_", "").replace(/_/g, " ")}
                        </div>
                        {tool.description && (
                          <CaretDownIcon
                            size={10}
                            className={`text-kumo-secondary shrink-0 ml-1.5 transition-transform ${
                              isExpanded ? "rotate-180" : ""
                            }`}
                          />
                        )}
                      </button>
                      {tool.description && isExpanded && (
                        <div className="px-1.5 pb-1.5 text-[11px] text-kumo-secondary border-t border-kumo-line pt-1.5">
                          <Streamdown
                            className="sd-theme"
                            mode="static"
                            controls={false}
                          >
                            {tool.description}
                          </Streamdown>
                        </div>
                      )}
                    </div>
                  );
                })}
              </div>
            ) : (
              <div className="text-xs text-kumo-secondary text-center py-3">
                {mcpState.servers.some((s) => s.state === "discovering")
                  ? "Discovering tools..."
                  : "No tools available. Click refresh."}
              </div>
            )}
          </div>
        )}
      </div>

      <LocalhostWarningModal
        visible={showLocalhostWarning}
        handleHide={() => setShowLocalhostWarning(false)}
      />
    </section>
  );
}