413 lines · 15.2 KB · TypeScript
RawDownload
import { useReducer, useCallback, useEffect, useRef } from "react";import { useAtomSet } from "@effect-atom/atom-react"; import { useScope } from "@executor/react";import { Button } from "@executor/ui/components/button";import { Input } from "@executor/ui/components/input";import { Label } from "@executor/ui/components/label";import { Badge } from "@executor/ui/components/badge";import { Spinner } from "@executor/ui/components/spinner";import { probeMcpEndpoint, addMcpSource, startMcpOAuth } from "./atoms"; // ---------------------------------------------------------------------------// State machine// --------------------------------------------------------------------------- type OAuthTokens = {  accessTokenSecretId: string;  refreshTokenSecretId: string | null;  tokenType: string;  expiresAt: number | null;  scope: string | null;}; type ProbeResult = {  connected: boolean;  requiresOAuth: boolean;  name: string;  namespace: string;  toolCount: number | null;  serverName: string | null;}; type State =  | { step: "url"; url: string }  | { step: "probing"; url: string }  | { step: "probed"; url: string; probe: ProbeResult }  | { step: "oauth-starting"; url: string; probe: ProbeResult }  | { step: "oauth-waiting"; url: string; probe: ProbeResult; sessionId: string }  | { step: "oauth-done"; url: string; probe: ProbeResult; tokens: OAuthTokens }  | { step: "adding"; url: string; probe: ProbeResult; tokens: OAuthTokens | null }  | { step: "error"; url: string; probe: ProbeResult | null; tokens: OAuthTokens | null; error: string }; type Action =  | { type: "set-url"; url: string }  | { type: "probe-start" }  | { type: "probe-ok"; probe: ProbeResult }  | { type: "probe-fail"; error: string }  | { type: "oauth-start" }  | { type: "oauth-waiting"; sessionId: string }  | { type: "oauth-ok"; tokens: OAuthTokens }  | { type: "oauth-fail"; error: string }  | { type: "oauth-cancelled" }  | { type: "add-start" }  | { type: "add-fail"; error: string }  | { type: "retry" }; const init: State = { step: "url", url: "" }; function reducer(state: State, action: Action): State {  switch (action.type) {    case "set-url":      // Changing URL resets everything      return { step: "url", url: action.url };     case "probe-start":      return { step: "probing", url: state.url };     case "probe-ok":      return { step: "probed", url: state.url, probe: action.probe };     case "probe-fail":      return { step: "error", url: state.url, probe: null, tokens: null, error: action.error };     case "oauth-start":      if (state.step !== "probed" && state.step !== "error") return state;      return { step: "oauth-starting", url: state.url, probe: state.step === "probed" ? state.probe : state.probe! };     case "oauth-waiting":      if (state.step !== "oauth-starting") return state;      return { step: "oauth-waiting", url: state.url, probe: state.probe, sessionId: action.sessionId };     case "oauth-ok":      if (state.step !== "oauth-waiting") return state;      return { step: "oauth-done", url: state.url, probe: state.probe, tokens: action.tokens };     case "oauth-fail":      if (state.step !== "oauth-starting" && state.step !== "oauth-waiting") return state;      return { step: "error", url: state.url, probe: state.probe, tokens: null, error: action.error };     case "oauth-cancelled":      if (state.step !== "oauth-waiting") return state;      return { step: "probed", url: state.url, probe: state.probe };     case "add-start": {      const tokens =        state.step === "oauth-done" ? state.tokens        : state.step === "probed" ? null        : null;      const probe =        "probe" in state ? state.probe : null;      if (!probe) return state;      return { step: "adding", url: state.url, probe, tokens };    }     case "add-fail":      if (state.step !== "adding") return state;      return { step: "error", url: state.url, probe: state.probe, tokens: state.tokens, error: action.error };     case "retry": {      if (state.step !== "error") return state;      // Go back to probed if we have a probe, otherwise URL entry      return state.probe        ? state.tokens          ? { step: "oauth-done", url: state.url, probe: state.probe, tokens: state.tokens }          : { step: "probed", url: state.url, probe: state.probe }        : { step: "url", url: state.url };    }     default:      return state;  }} // ---------------------------------------------------------------------------// OAuth popup// --------------------------------------------------------------------------- type OAuthPopupResult =  | { type: "executor:oauth-result"; ok: true; sessionId: string } & OAuthTokens  | { type: "executor:oauth-result"; ok: false; sessionId: null; error: string }; function openOAuthPopup(  url: string,  onResult: (data: OAuthPopupResult) => void,  onClosed?: () => void,): void {  const w = 600, h = 700;  const left = window.screenX + (window.outerWidth - w) / 2;  const top = window.screenY + (window.outerHeight - h) / 2;  const popup = window.open(url, "mcp-oauth", `width=${w},height=${h},left=${left},top=${top},popup=1`);   let settled = false;  const settle = () => { settled = true; window.removeEventListener("message", onMsg); };   const onMsg = (e: MessageEvent) => {    if (e.origin === window.location.origin && e.data?.type === "executor:oauth-result" && !settled) {      settle();      onResult(e.data as OAuthPopupResult);    }  };  window.addEventListener("message", onMsg);   if (popup) {    const iv = setInterval(() => {      if (popup.closed) { clearInterval(iv); if (!settled) { settle(); onClosed?.(); } }    }, 500);  }} // ---------------------------------------------------------------------------// Component// --------------------------------------------------------------------------- export default function AddMcpSource(props: {  onComplete: () => void;  onCancel: () => void;  initialUrl?: string;}) {  const [state, dispatch] = useReducer(    reducer,    props.initialUrl ? { step: "url" as const, url: props.initialUrl } : init,  );   const scopeId = useScope();  const doProbe = useAtomSet(probeMcpEndpoint, { mode: "promise" });  const doAdd = useAtomSet(addMcpSource, { mode: "promise" });  const doStartOAuth = useAtomSet(startMcpOAuth, { mode: "promise" });   const probe = "probe" in state ? state.probe : null;  const tokens = "tokens" in state ? state.tokens : null;  const isIdle = state.step === "url";  const isProbing = state.step === "probing";  const isAdding = state.step === "adding";  const isOAuthBusy = state.step === "oauth-starting" || state.step === "oauth-waiting";  const needsOAuth = probe?.requiresOAuth === true && !tokens;  const canAdd = probe && !needsOAuth && !isAdding && !isOAuthBusy;  const error = state.step === "error" ? state.error : null;   // ---- Actions ----   const handleProbe = useCallback(async () => {    dispatch({ type: "probe-start" });    try {      const result = await doProbe({        path: { scopeId },        payload: { endpoint: state.url.trim() },      });      dispatch({ type: "probe-ok", probe: result });    } catch (e) {      dispatch({ type: "probe-fail", error: e instanceof Error ? e.message : "Failed to connect" });    }  }, [state.url, scopeId, doProbe]);   const autoProbed = useRef(false);  useEffect(() => {    if (props.initialUrl && !autoProbed.current) {      autoProbed.current = true;      handleProbe();    }  }, []); // eslint-disable-line react-hooks/exhaustive-deps   const handleOAuth = useCallback(async () => {    dispatch({ type: "oauth-start" });    try {      const redirectUrl = `${window.location.origin}/v1/mcp/oauth/callback`;      const result = await doStartOAuth({        path: { scopeId },        payload: { endpoint: state.url.trim(), redirectUrl },      });      dispatch({ type: "oauth-waiting", sessionId: result.sessionId });      openOAuthPopup(        result.authorizationUrl,        (data) => {          if (data.ok) {            dispatch({              type: "oauth-ok",              tokens: {                accessTokenSecretId: data.accessTokenSecretId,                refreshTokenSecretId: data.refreshTokenSecretId,                tokenType: data.tokenType,                expiresAt: data.expiresAt,                scope: data.scope,              },            });          } else {            dispatch({ type: "oauth-fail", error: data.error });          }        },        () => dispatch({ type: "oauth-cancelled" }),      );    } catch (e) {      dispatch({ type: "oauth-fail", error: e instanceof Error ? e.message : "Failed to start OAuth" });    }  }, [state.url, doStartOAuth]);   const handleAdd = useCallback(async () => {    if (!probe) return;    dispatch({ type: "add-start" });    try {      await doAdd({        path: { scopeId },        payload: {          transport: "remote" as const,          name: probe.serverName ?? probe.name,          endpoint: state.url.trim(),          auth: tokens            ? {                kind: "oauth2" as const,                accessTokenSecretId: tokens.accessTokenSecretId,                refreshTokenSecretId: tokens.refreshTokenSecretId,                tokenType: tokens.tokenType,                expiresAt: tokens.expiresAt,                scope: tokens.scope,              }            : { kind: "none" as const },        },      });      props.onComplete();    } catch (e) {      dispatch({ type: "add-fail", error: e instanceof Error ? e.message : "Failed to add source" });    }  }, [probe, tokens, state.url, doAdd, props]);   // ---- Render ----   return (    <div className="space-y-6">      <div>        <h1 className="text-xl font-semibold text-foreground">Add MCP Source</h1>        <p className="mt-1 text-[13px] text-muted-foreground">          Connect to a remote MCP server to discover and use its tools.        </p>      </div>       {/* URL input */}      <section className="space-y-2">        <Label>Server URL</Label>        <div className="flex gap-2">          <Input            value={state.url}            onChange={(e) => dispatch({ type: "set-url", url: (e.target as HTMLInputElement).value })}            placeholder="https://mcp.example.com"            className="flex-1 font-mono text-sm"            onKeyDown={(e) => {              if (e.key === "Enter" && state.url.trim() && isIdle) handleProbe();            }}            disabled={isProbing}          />          {!probe && (            <Button onClick={handleProbe} disabled={!state.url.trim() || isProbing}>              {isProbing ? <><Spinner className="size-3.5" /> Connecting…</> : "Connect"}            </Button>          )}        </div>        <p className="text-[12px] text-muted-foreground">          Supports Streamable HTTP and SSE transports.        </p>      </section>       {/* Server info card */}      {probe && (        <div className="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3">          <div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">            <svg viewBox="0 0 16 16" className="size-4" fill="none">              <rect x="2" y="3" width="12" height="10" rx="2" stroke="currentColor" strokeWidth="1.2" />              <path d="M5 7h6M5 9.5h4" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />            </svg>          </div>          <div className="min-w-0 flex-1">            <p className="truncate text-sm font-semibold text-card-foreground leading-none">              {probe.serverName ?? probe.name}            </p>            <p className="mt-1 text-[12px] text-muted-foreground leading-none">              {probe.connected                ? `${probe.toolCount} tool${probe.toolCount !== 1 ? "s" : ""} available`                : "OAuth required to discover tools"}            </p>          </div>          {probe.connected ? (            <Badge variant="outline" className="border-emerald-500/20 bg-emerald-500/10 text-[10px] text-emerald-600 dark:text-emerald-400">              Connected            </Badge>          ) : (            <Badge variant="outline" className="border-amber-500/20 bg-amber-500/10 text-[10px] text-amber-600 dark:text-amber-400">              OAuth required            </Badge>          )}        </div>      )}       {/* OAuth section */}      {probe?.requiresOAuth && !tokens && (        <section className="space-y-2.5">          {state.step === "probed" && (            <Button onClick={handleOAuth} className="w-full" variant="outline">              <svg viewBox="0 0 16 16" fill="none" className="mr-1.5 size-3.5">                <path d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1z" stroke="currentColor" strokeWidth="1.2" />                <path d="M8 4v4l2.5 1.5" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />              </svg>              Sign in with OAuth            </Button>          )}           {state.step === "oauth-starting" && (            <div className="flex items-center gap-2 rounded-lg border border-border bg-muted/30 px-3 py-2.5">              <Spinner className="size-3.5" />              <span className="text-xs text-muted-foreground">Starting authorization…</span>            </div>          )}           {state.step === "oauth-waiting" && (            <div className="flex items-center gap-2 rounded-lg border border-blue-500/30 bg-blue-500/5 px-3 py-2.5">              <Spinner className="size-3.5 text-blue-500" />              <span className="text-xs text-blue-600 dark:text-blue-400">Waiting for authorization in popup…</span>            </div>          )}        </section>      )}       {/* OAuth success */}      {tokens && (        <div className="flex items-center gap-2 rounded-lg border border-emerald-500/30 bg-emerald-500/5 px-3 py-2.5">          <svg viewBox="0 0 16 16" fill="none" className="size-3.5 text-emerald-500">            <path d="M3 8.5l3 3 7-7" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />          </svg>          <span className="text-xs font-medium text-emerald-600 dark:text-emerald-400">Authenticated</span>        </div>      )}       {/* Error */}      {error && (        <div className="space-y-2">          <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2">            <p className="text-[12px] text-destructive">{error}</p>          </div>          <Button variant="outline" size="sm" onClick={() => dispatch({ type: "retry" })} className="text-xs">            Try again          </Button>        </div>      )}       {/* Actions */}      {(probe || isProbing) && (        <div className="flex items-center justify-between border-t border-border pt-4">          <Button variant="ghost" onClick={props.onCancel} disabled={isAdding}>            Cancel          </Button>          <Button onClick={handleAdd} disabled={!canAdd}>            {isAdding ? <><Spinner className="size-3.5" /> Adding…</> : "Add source"}          </Button>        </div>      )}       {/* Cancel when nothing probed yet */}      {!probe && !isProbing && (        <div className="flex items-center justify-between pt-1">          <Button variant="ghost" onClick={props.onCancel}>Cancel</Button>          <div />        </div>      )}    </div>  );}