import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAtomValue, useAtomSet } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import * as Exit from "effect/Exit"; import { IntegrationSlug } from "@executor-js/sdk/shared"; import type { EditSheetApplyResult, EditSheetSectionProps } from "@executor-js/sdk/client"; import { apiKeyMethodLabel, type AuthPlacement } from "@executor-js/sdk/http-auth"; import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; import { AuthMethodListEditor, useAuthMethodList, type AuthMethodRow, type AuthMethodSeed, } from "@executor-js/react/components/auth-method-list-editor"; import { CardStack, CardStackContent, CardStackEntryField, } from "@executor-js/react/components/card-stack"; import { Input } from "@executor-js/react/components/input"; import { Textarea } from "@executor-js/react/components/textarea"; import { errorMessageFromExit, FormErrorAlert } from "@executor-js/react/lib/integration-add"; import { configureMcpAuth, configureMcpServer, mcpServerAtom } from "./atoms"; import type { McpAuthMethod, McpCanonicalAuthMethodInput, McpIntegrationConfig, } from "../sdk/types"; import { editorValueFromMcpAuthMethod, mcpAuthMethodInputFromEditorValue, mcpWireAuthInput, } from "./auth-method-config"; import { formatStdioArgs, formatStdioEnv, parseStdioArgs, parseStdioEnv } from "./stdio-fields"; type McpServer = { readonly slug: IntegrationSlug; readonly description: string; readonly kind: string; readonly canRemove: boolean; readonly canRefresh: boolean; readonly config: McpIntegrationConfig; }; type McpRemoteConfig = Extract; const methodSeedLabel = (method: McpAuthMethod): string => { if (method.kind === "oauth2") return "OAuth"; if (method.kind === "apikey") return apiKeyMethodLabel(method); return "No authentication"; }; const samePlacements = ( a: readonly AuthPlacement[] | undefined, b: readonly AuthPlacement[] | undefined, ): boolean => { const left = a ?? []; const right = b ?? []; if (left.length !== right.length) return false; return left.every((placement: AuthPlacement, index: number) => { const other = right[index]; return ( other !== undefined && placement.carrier === other.carrier && placement.name === other.name && (placement.prefix ?? "") === (other.prefix ?? "") && (placement.variable ?? "") === (other.variable ?? "") && (placement.literal ?? null) === (other.literal ?? null) ); }); }; // --------------------------------------------------------------------------- // Remote edit — v2: the integration's endpoint is part of its identity // (opaque-to-core config); the editable surface is the declared auth-method // LIST, through the same shared editor as the add flow. Accounts (credentials) // are managed from the integration page's accounts hub. Rendered inside the // integration Edit sheet (plugin `editSheet` slot). // --------------------------------------------------------------------------- function RemoteEdit(props: { server: McpServer & { config: McpRemoteConfig }; onPendingChange?: EditSheetSectionProps["onPendingChange"]; }) { const { server } = props; const doConfigureAuth = useAtomSet(configureMcpAuth, { mode: "promiseExit" }); const seeds = useMemo( () => server.config.authenticationTemplate.map( (method: McpAuthMethod): AuthMethodSeed => ({ value: editorValueFromMcpAuthMethod(method), slug: method.slug, label: methodSeedLabel(method), }), ), [server.config.authenticationTemplate], ); const list = useAuthMethodList(seeds); const [error, setError] = useState(null); // The edited methods, slugs preserved for seeded rows so existing // connections (bound by template slug) stay attached. New rows omit the // slug — the backend assigns kind-based ones. const editedMethods = useMemo( () => list.rows.map((row: AuthMethodRow): McpCanonicalAuthMethodInput => { const input = mcpAuthMethodInputFromEditorValue(row.value); return row.seedSlug !== undefined ? { ...input, slug: row.seedSlug } : input; }), [list.rows], ); const methodsChanged = useMemo(() => { const stored = server.config.authenticationTemplate; if (editedMethods.length !== stored.length) return true; return editedMethods.some((method: McpCanonicalAuthMethodInput, index: number) => { const current = stored[index]; if (!current) return true; if ((method.slug ?? "") !== current.slug) return true; if (method.kind !== current.kind) return true; if (method.kind === "apikey" && current.kind === "apikey") { return !samePlacements(method.placements, current.placements); } return false; }); }, [editedMethods, server.config.authenticationTemplate]); // Staged apply, run by the sheet's Save when the method list changed. const applyStaged = useCallback(async (): Promise => { setError(null); const exit = await doConfigureAuth({ params: { slug: server.slug }, payload: { authenticationTemplate: editedMethods.length > 0 ? editedMethods.map(mcpWireAuthInput) : [{ kind: "none" as const }], mode: "replace", }, reactivityKeys: integrationWriteKeys, }); if (Exit.isFailure(exit)) { setError("Failed to update authentication methods"); return { ok: false }; } return { ok: true, summary: "Authentication methods updated." }; }, [doConfigureAuth, editedMethods, server.slug]); const onPendingChangeRef = useRef(props.onPendingChange); onPendingChangeRef.current = props.onPendingChange; useEffect(() => { onPendingChangeRef.current?.(methodsChanged ? applyStaged : null); return () => onPendingChangeRef.current?.(null); }, [methodsChanged, applyStaged]); return (

Authentication methods

Changes apply when you save. The endpoint ( {server.config.endpoint}) is part of the server's identity — remove and re-add to change it.

{error && }
); } // --------------------------------------------------------------------------- // Stdio edit — the command, its arguments, the working directory, and the // DECLARED static environment. Secret env vars are not edited here: a stdio // server declares them as a `stdio_env` auth method and their values live on // the connection, managed from the integration page's accounts hub. Changes // are staged and applied by the sheet's Save, like the remote editor above. // --------------------------------------------------------------------------- type McpStdioConfig = Extract; /** Order-independent identity for a declared env map, so re-ordering the lines * in the field is not reported as a change. */ const envIdentity = (env: Readonly> | undefined): string => Object.entries(env ?? {}) .sort(([a], [b]) => a.localeCompare(b)) .map(([key, value]) => `${key}=${value}`) .join("\n"); function StdioEdit(props: { server: McpServer & { config: McpStdioConfig }; onPendingChange?: EditSheetSectionProps["onPendingChange"]; }) { const { server } = props; const doConfigure = useAtomSet(configureMcpServer, { mode: "promiseExit" }); const [command, setCommand] = useState(server.config.command); const [args, setArgs] = useState(() => formatStdioArgs(server.config.args)); const [cwd, setCwd] = useState(server.config.cwd ?? ""); const [env, setEnv] = useState(() => formatStdioEnv(server.config.env)); const [error, setError] = useState(null); // The edited config. `configureServer` replaces the whole blob, so anything // this form does not surface (`versionNegotiation`, `authenticationTemplate`) // is carried through untouched. Empty optional fields are omitted rather than // written as `undefined`. const edited = useMemo(() => { const { args: _args, cwd: _cwd, env: _env, ...rest } = server.config; const nextArgs = parseStdioArgs(args); const nextEnv = parseStdioEnv(env); const nextCwd = cwd.trim(); return { ...rest, command: command.trim(), ...(nextArgs.length > 0 ? { args: nextArgs } : {}), ...(Object.keys(nextEnv).length > 0 ? { env: nextEnv } : {}), ...(nextCwd !== "" ? { cwd: nextCwd } : {}), }; }, [args, command, cwd, env, server.config]); const changed = edited.command !== server.config.command || formatStdioArgs(edited.args) !== formatStdioArgs(server.config.args) || (edited.cwd ?? "") !== (server.config.cwd ?? "") || envIdentity(edited.env) !== envIdentity(server.config.env); // Staged apply, run by the sheet's Save. Persisting the config re-runs // discovery on every connection, so the tool catalog matches the new command. const applyStaged = useCallback(async (): Promise => { setError(null); if (edited.command === "") { setError("A command is required."); return { ok: false }; } const exit = await doConfigure({ params: { slug: server.slug }, payload: { config: edited }, reactivityKeys: integrationWriteKeys, }); if (Exit.isFailure(exit)) { setError(errorMessageFromExit(exit, "Failed to update the server command")); return { ok: false }; } return { ok: true, summary: "Server command updated." }; }, [doConfigure, edited, server.slug]); const onPendingChangeRef = useRef(props.onPendingChange); onPendingChangeRef.current = props.onPendingChange; useEffect(() => { onPendingChangeRef.current?.(changed ? applyStaged : null); return () => onPendingChangeRef.current?.(null); }, [changed, applyStaged]); return (

Server command

Changes apply when you save. The server's tools are then rediscovered with the new command.

setCommand((e.target as HTMLInputElement).value)} placeholder="npx" className="font-mono text-sm" /> setArgs((e.target as HTMLInputElement).value)} placeholder="-y chrome-devtools-mcp@latest" className="font-mono text-sm" /> setCwd((e.target as HTMLInputElement).value)} placeholder="/path/to/server" className="font-mono text-sm" />