969 lines · 34.2 KB · TypeScript
RawDownload
import { useReducer, useCallback, useEffect, useRef, useState, type ReactNode } from "react";import { useAtomSet } from "@effect/atom-react";import * as Exit from "effect/Exit";import * as Option from "effect/Option";import * as Schema from "effect/Schema"; import { useScope } from "@executor-js/react/api/scope-context";import { Button } from "@executor-js/react/components/button";import {  CardStack,  CardStackContent,  CardStackEntry,  CardStackEntryField,} from "@executor-js/react/components/card-stack";import { FieldLabel } from "@executor-js/react/components/field";import { FilterTabs } from "@executor-js/react/components/filter-tabs";import { FloatActions } from "@executor-js/react/components/float-actions";import { Input } from "@executor-js/react/components/input";import { Label } from "@executor-js/react/components/label";import { Spinner } from "@executor-js/react/components/spinner";import { Textarea } from "@executor-js/react/components/textarea";import {  emptyHttpCredentials,  httpCredentialsValid,  HttpCredentialsEditor,  serializeScopedHttpCredentials,  serializeHttpCredentials,} from "@executor-js/react/plugins/http-credentials";import {  sourceDisplayNameFromUrl,  slugifyNamespace,  SourceIdentityFields,  useSourceIdentity,} from "@executor-js/react/plugins/source-identity";import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets";import {  oauthCallbackUrl,  oauthConnectionId,  useOAuthPopupFlow,  type OAuthCompletionPayload,} from "@executor-js/react/plugins/oauth-sign-in";import {  CredentialControlField,  CredentialUsageRow,  useCredentialTargetScope,} from "@executor-js/react/plugins/credential-target-scope"; type RemoteAuthMode = "none" | "oauth2";import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys";import { probeMcpEndpoint, addMcpSourceOptimistic } from "./atoms";import { McpRemoteSourceFields } from "./McpRemoteSourceFields";import { mcpPresets, type McpPreset } from "../sdk/presets";import { MCP_OAUTH_CONNECTION_SLOT, type McpCredentialInput } from "../sdk/types"; const ErrorMessage = Schema.Struct({ message: Schema.String });const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage); const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string =>  Option.match(Option.flatMap(Exit.findErrorOption(exit), decodeErrorMessage), {    onNone: () => fallback,    onSome: ({ message }) => message,  }); // ---------------------------------------------------------------------------// Preset lookup// --------------------------------------------------------------------------- function findPreset(id: string | undefined): McpPreset | undefined {  if (!id) return undefined;  return mcpPresets.find((p) => p.id === id);} // ---------------------------------------------------------------------------// State machine (remote flow)// --------------------------------------------------------------------------- type OAuthTokens = OAuthCompletionPayload; type ProbeResult = {  connected: boolean;  requiresOAuth: boolean;  supportsDynamicRegistration: boolean;  name: string;  namespace: string;  toolCount: number | null;  serverName: string | null;}; type PlainHeader = {  name: string;  value: string;}; type State =  | { step: "url"; url: string }  | { step: "probing"; url: string; probe: ProbeResult | null }  | { 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: "oauth-reset" }  | { 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":      return { step: "url", url: action.url };     case "probe-start":      return { step: "probing", url: state.url, probe: "probe" in state ? state.probe : null };     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 "oauth-reset":      if ("probe" in state && state.probe) {        return { step: "probed", url: state.url, probe: state.probe };      }      return state;     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;      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;  }} // ---------------------------------------------------------------------------// Component// --------------------------------------------------------------------------- export default function AddMcpSource(props: {  onComplete: () => void;  onCancel: () => void;  initialUrl?: string;  initialPreset?: string;  /** Whether the stdio transport is enabled on the server. */  allowStdio?: boolean;}) {  const allowStdio = props.allowStdio ?? false;  const rawPreset = findPreset(props.initialPreset);  // Drop stdio presets when stdio is disabled — the caller should have  // already filtered these out, but defence-in-depth.  const preset = rawPreset?.transport === "stdio" && !allowStdio ? undefined : rawPreset;  const isStdioPreset = preset?.transport === "stdio";   const [transport, setTransport] = useState<"remote" | "stdio">(    isStdioPreset && allowStdio ? "stdio" : "remote",  );   // --- Stdio state ---  const [stdioCommand, setStdioCommand] = useState(isStdioPreset ? preset.command : "");  const [stdioArgs, setStdioArgs] = useState(    isStdioPreset && preset.args ? preset.args.join(" ") : "",  );  const [stdioEnv, setStdioEnv] = useState("");  const stdioIdentity = useSourceIdentity({    fallbackName: isStdioPreset ? preset.name : stdioCommand,  });  const [stdioAdding, setStdioAdding] = useState(false);  const [stdioError, setStdioError] = useState<string | null>(null);   // --- Remote state ---  const remoteUrl =    !isStdioPreset && preset?.transport === undefined && preset?.url      ? preset.url      : (props.initialUrl ?? "");   const [state, dispatch] = useReducer(    reducer,    remoteUrl ? { step: "url" as const, url: remoteUrl } : init,  );   const scopeId = useScope();  const { credentialTargetScope: requestCredentialTargetScope } = useCredentialTargetScope();  const {    credentialTargetScope: oauthCredentialTargetScope,    setCredentialTargetScope: setOAuthCredentialTargetScope,    credentialScopeOptions,  } = useCredentialTargetScope();  const doProbe = useAtomSet(probeMcpEndpoint, { mode: "promiseExit" });  const doAdd = useAtomSet(addMcpSourceOptimistic(scopeId), {    mode: "promiseExit",  });  const secretList = useSecretPickerSecrets();  const oauth = useOAuthPopupFlow<OAuthCompletionPayload>({    popupName: "mcp-oauth",    popupBlockedMessage: "OAuth popup was blocked",    detectPopupClosed: false,    startErrorMessage: "Failed to start OAuth",  });   const [remoteAuthMode, setRemoteAuthMode] = useState<RemoteAuthMode>("none");  const [remoteHeaders, setRemoteHeaders] = useState<PlainHeader[]>([]);  const [remoteCredentials, setRemoteCredentials] = useState(() => emptyHttpCredentials());   const probe = "probe" in state ? state.probe : null;  const tokens = "tokens" in state ? state.tokens : null;   const remoteIdentity = useSourceIdentity({    fallbackName:      sourceDisplayNameFromUrl(state.url, "MCP") ?? probe?.serverName ?? probe?.name ?? "",  });  const isProbing = state.step === "probing";  const isAdding = state.step === "adding";  const isOAuthBusy =    state.step === "oauth-starting" || state.step === "oauth-waiting" || oauth.busy;  const canUseNone = probe?.requiresOAuth !== true || probe.supportsDynamicRegistration === false;  const remoteHeadersComplete = remoteHeaders.every(    (header) => header.name.trim() && header.value.trim(),  );  const remoteCredentialsComplete = httpCredentialsValid(remoteCredentials);  const authReady = remoteAuthMode === "none" ? canUseNone : tokens !== null;  const canAdd =    Boolean(probe) &&    authReady &&    remoteHeadersComplete &&    remoteCredentialsComplete &&    !isAdding &&    !isOAuthBusy;  // Probe failures are shown inline on the URL field; other failures  // (OAuth start, add source) render in the bottom error block.  const probeError = state.step === "error" && state.probe === null ? state.error : null;  const otherError = state.step === "error" && state.probe !== null ? state.error : null;   // ---- Remote actions ----   const handleProbe = useCallback(async () => {    dispatch({ type: "probe-start" });    const { headers, queryParams } = serializeHttpCredentials(remoteCredentials);    const exit = await doProbe({      params: { scopeId },      payload: {        endpoint: state.url.trim(),        ...(Object.keys(headers).length > 0 ? { headers } : {}),        ...(Object.keys(queryParams).length > 0 ? { queryParams } : {}),      },    });    if (Exit.isFailure(exit)) {      dispatch({        type: "probe-fail",        error: errorMessageFromExit(exit, "Failed to connect"),      });      return;    }    setRemoteAuthMode(exit.value.requiresOAuth ? "oauth2" : "none");    dispatch({ type: "probe-ok", probe: exit.value });  }, [state.url, scopeId, doProbe, remoteCredentials]);   // Keep the latest handleProbe in a ref so the debounced effect can call it  // without depending on its identity (which changes every render).  const handleProbeRef = useRef(handleProbe);  handleProbeRef.current = handleProbe;   // Auto-probe whenever the URL changes (debounced) while we're on the  // remote transport and not already probing/probed.  useEffect(() => {    if (transport !== "remote") return;    if (state.step !== "url") return;    const trimmed = state.url.trim();    if (!trimmed) return;    const handle = setTimeout(() => {      handleProbeRef.current();    }, 400);    return () => clearTimeout(handle);  }, [transport, state.step, state.url]);   const handleRemoteCredentialsChange = useCallback((next: typeof remoteCredentials) => {    setRemoteCredentials(next);  }, []);   const handleOAuth = useCallback(async () => {    dispatch({ type: "oauth-start" });    const namespaceSlug =      slugifyNamespace(remoteIdentity.namespace) ||      slugifyNamespace(probe?.namespace ?? "") ||      "mcp";    const { headers, queryParams } = serializeHttpCredentials(remoteCredentials);    await oauth.start({      payload: {        endpoint: state.url.trim(),        ...(Object.keys(headers).length > 0 ? { headers } : {}),        ...(Object.keys(queryParams).length > 0 ? { queryParams } : {}),        redirectUrl: oauthCallbackUrl(),        connectionId: oauthConnectionId({          pluginId: "mcp",          namespace: namespaceSlug,        }),        tokenScope: oauthCredentialTargetScope,        strategy: { kind: "dynamic-dcr" },        pluginId: "mcp",        identityLabel: `${remoteIdentity.name.trim() || probe?.serverName || probe?.name || "MCP"} OAuth`,      },      onSuccess: (result) => {        dispatch({          type: "oauth-ok",          tokens: {            connectionId: result.connectionId,            expiresAt: result.expiresAt,            scope: result.scope,          },        });      },      onAuthorizationStarted: (result) =>        dispatch({ type: "oauth-waiting", sessionId: result.sessionId }),      onError: (error) => dispatch({ type: "oauth-fail", error }),    });  }, [state.url, remoteIdentity, probe, remoteCredentials, oauth, oauthCredentialTargetScope]);   const handleCancelOAuth = useCallback(() => {    oauth.cancel();    dispatch({ type: "oauth-cancelled" });  }, [oauth]);   const handleAddRemote = useCallback(async () => {    if (!probe) return;    dispatch({ type: "add-start" });    const auth =      remoteAuthMode === "oauth2"        ? tokens          ? {              kind: "oauth2" as const,              connectionId: tokens.connectionId,            }          : {              kind: "oauth2" as const,              connectionSlot: MCP_OAUTH_CONNECTION_SLOT,            }        : { kind: "none" as const };    const headers = Object.fromEntries(      remoteHeaders        .map((header) => [header.name.trim(), header.value.trim()] as const)        .filter(([name, value]) => name && value),    );    const credentials = serializeScopedHttpCredentials(      remoteCredentials,      requestCredentialTargetScope,    );    const remoteRequestHeaders: Record<string, McpCredentialInput> = {      ...headers,      ...credentials.headers,    };    const displayName = remoteIdentity.name.trim() || probe.serverName || probe.name;    const slugNamespace = slugifyNamespace(remoteIdentity.namespace);    const exit = await doAdd({      params: { scopeId },      payload: {        targetScope: scopeId,        transport: "remote" as const,        name: displayName,        namespace: slugNamespace || undefined,        endpoint: state.url.trim(),        auth,        credentialTargetScope:          remoteAuthMode === "oauth2" && tokens            ? oauthCredentialTargetScope            : requestCredentialTargetScope,        ...(Object.keys(remoteRequestHeaders).length > 0 ? { headers: remoteRequestHeaders } : {}),        ...(Object.keys(credentials.queryParams).length > 0          ? { queryParams: credentials.queryParams }          : {}),      },      reactivityKeys: sourceWriteKeys,    });    if (Exit.isFailure(exit)) {      dispatch({        type: "add-fail",        error: errorMessageFromExit(exit, "Failed to add source"),      });      return;    }    props.onComplete();  }, [    probe,    remoteAuthMode,    remoteHeaders,    remoteCredentials,    remoteIdentity,    tokens,    state.url,    doAdd,    props,    scopeId,    requestCredentialTargetScope,    oauthCredentialTargetScope,  ]);   // ---- Stdio actions ----

Lines 1–500 of 969.