From 548a7a69d727c989dbc9e1f06ec5622f0785bb15 Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Fri, 10 Apr 2026 02:15:03 +0530 Subject: [PATCH 1/2] refactor(react): extract secret-backed header auth UI --- .../graphql/src/react/AddGraphqlSource.tsx | 257 +++++-------- .../openapi/src/react/AddOpenApiSource.tsx | 337 +---------------- .../react/src/plugins/secret-header-auth.tsx | 350 ++++++++++++++++++ .../src/plugins/use-secret-picker-secrets.tsx | 21 ++ 4 files changed, 469 insertions(+), 496 deletions(-) create mode 100644 packages/react/src/plugins/secret-header-auth.tsx create mode 100644 packages/react/src/plugins/use-secret-picker-secrets.tsx diff --git a/packages/plugins/graphql/src/react/AddGraphqlSource.tsx b/packages/plugins/graphql/src/react/AddGraphqlSource.tsx index 8626d0b3b3..27c84efe8e 100644 --- a/packages/plugins/graphql/src/react/AddGraphqlSource.tsx +++ b/packages/plugins/graphql/src/react/AddGraphqlSource.tsx @@ -1,10 +1,9 @@ import { useState } from "react"; -import { useAtomSet, useAtomValue, useAtomRefresh, Result } from "@effect-atom/atom-react"; +import { useAtomSet } from "@effect-atom/atom-react"; -import { secretsAtom, setSecret } from "@executor/react/api/atoms"; import { useScope } from "@executor/react/api/scope-context"; -import { SecretPicker, type SecretPickerSecret } from "@executor/react/plugins/secret-picker"; -import { SecretId } from "@executor/sdk"; +import { SecretHeaderAuthRow } from "@executor/react/plugins/secret-header-auth"; +import { useSecretPickerSecrets } from "@executor/react/plugins/use-secret-picker-secrets"; import { Button } from "@executor/react/components/button"; import { Input } from "@executor/react/components/input"; import { Label } from "@executor/react/components/label"; @@ -12,144 +11,19 @@ import { Spinner } from "@executor/react/components/spinner"; import { addGraphqlSource } from "./atoms"; import type { HeaderValue } from "../sdk/types"; -// --------------------------------------------------------------------------- -// Inline secret creation -// --------------------------------------------------------------------------- +type HeaderEntry = { + name: string; + prefix?: string; + presetKey?: string; + secretId: string | null; +}; -function InlineCreateSecret(props: { - headerName: string; - suggestedId: string; - onCreated: (secretId: string) => void; - onCancel: () => void; -}) { - const [secretId, setSecretId] = useState(props.suggestedId); - const [secretName, setSecretName] = useState(props.headerName); - const [secretValue, setSecretValue] = useState(""); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); - const scopeId = useScope(); - const doSet = useAtomSet(setSecret, { mode: "promise" }); - const refreshSecrets = useAtomRefresh(secretsAtom(scopeId)); - - const handleSave = async () => { - if (!secretId.trim() || !secretValue.trim()) return; - setSaving(true); - setError(null); - try { - await doSet({ - path: { scopeId }, - payload: { - id: SecretId.make(secretId.trim()), - name: secretName.trim() || secretId.trim(), - value: secretValue.trim(), - purpose: `Auth header: ${props.headerName}`, - }, - }); - refreshSecrets(); - props.onCreated(secretId.trim()); - } catch (e) { - setError(e instanceof Error ? e.message : "Failed to save secret"); - setSaving(false); - } - }; - - return ( -
-

New secret

-
-
- - setSecretId((e.target as HTMLInputElement).value)} - placeholder="my-api-token" - className="h-8 text-xs font-mono" - /> -
-
- - setSecretName((e.target as HTMLInputElement).value)} - placeholder="API Token" - className="h-8 text-xs" - /> -
-
-
- - setSecretValue((e.target as HTMLInputElement).value)} - placeholder="paste your token or key..." - className="h-8 text-xs font-mono" - /> -
- {error &&

{error}

} -
- - -
-
- ); -} - -// --------------------------------------------------------------------------- -// Auth header row -// --------------------------------------------------------------------------- - -function AuthHeaderRow(props: { - selectedSecretId: string | null; - onSelect: (secretId: string) => void; - existingSecrets: readonly SecretPickerSecret[]; -}) { - const [creating, setCreating] = useState(false); - const { selectedSecretId, onSelect, existingSecrets } = props; - - if (creating) { - return ( - { - onSelect(id); - setCreating(false); - }} - onCancel={() => setCreating(false)} - /> - ); - } - - return ( -
-
-
- -
- -
-
- ); -} - -// --------------------------------------------------------------------------- -// Main component -// --------------------------------------------------------------------------- +const initialHeader = (): HeaderEntry => ({ + name: "Authorization", + prefix: "Bearer ", + presetKey: "bearer", + secretId: null, +}); export default function AddGraphqlSource(props: { onComplete: () => void; @@ -158,34 +32,53 @@ export default function AddGraphqlSource(props: { }) { const [endpoint, setEndpoint] = useState(props.initialUrl ?? ""); const [namespace, setNamespace] = useState(""); - const [authSecretId, setAuthSecretId] = useState(null); + const [headers, setHeaders] = useState([initialHeader()]); const [adding, setAdding] = useState(false); const [addError, setAddError] = useState(null); const scopeId = useScope(); const doAdd = useAtomSet(addGraphqlSource, { mode: "promise" }); - const secrets = useAtomValue(secretsAtom(scopeId)); + const secretList = useSecretPickerSecrets(); - const secretList: readonly SecretPickerSecret[] = Result.match(secrets, { - onInitial: () => [] as SecretPickerSecret[], - onFailure: () => [] as SecretPickerSecret[], - onSuccess: ({ value }) => - value.map((s) => ({ - id: s.id, - name: s.name, - provider: s.provider ? String(s.provider) : undefined, - })), - }); + const headersValid = headers.every( + (header) => header.name.trim() && header.secretId, + ); + const canAdd = + endpoint.trim().length > 0 && (headers.length === 0 || headersValid); + + const updateHeader = ( + index: number, + update: Partial<{ name: string; prefix?: string; presetKey?: string; secretId: string | null }>, + ) => { + setHeaders((current) => + current.map((header, i) => (i === index ? { ...header, ...update } : header)), + ); + }; - const canAdd = endpoint.trim().length > 0; + const removeHeader = (index: number) => { + setHeaders((current) => current.filter((_, i) => i !== index)); + }; + + const addHeader = () => { + setHeaders((current) => [ + ...current, + { name: "", prefix: undefined, presetKey: undefined, secretId: null }, + ]); + }; const handleAdd = async () => { setAdding(true); setAddError(null); try { - const headers: Record = {}; - if (authSecretId) { - headers["Authorization"] = { secretId: authSecretId, prefix: "Bearer " }; + const headerMap: Record = {}; + for (const header of headers) { + const name = header.name.trim(); + if (name && header.secretId) { + headerMap[name] = { + secretId: header.secretId, + ...(header.prefix ? { prefix: header.prefix } : {}), + }; + } } await doAdd({ @@ -193,7 +86,7 @@ export default function AddGraphqlSource(props: { payload: { endpoint: endpoint.trim(), namespace: namespace.trim() || undefined, - ...(Object.keys(headers).length > 0 ? { headers } : {}), + ...(Object.keys(headerMap).length > 0 ? { headers: headerMap } : {}), }, }); props.onComplete(); @@ -239,17 +132,43 @@ export default function AddGraphqlSource(props: { {/* Authentication */}
- -

- Select a secret for the Bearer token sent with every request, including introspection. -

- +
+
+ +

+ Secret-backed headers sent with every request, including introspection. +

+
+ +
+ + {headers.length > 0 && ( +
+ {headers.map((header, index) => ( + updateHeader(index, update)} + onSelectSecret={(secretId) => updateHeader(index, { secretId })} + onRemove={() => removeHeader(index)} + existingSecrets={secretList} + /> + ))} +
+ )}
{/* Error */} diff --git a/packages/plugins/openapi/src/react/AddOpenApiSource.tsx b/packages/plugins/openapi/src/react/AddOpenApiSource.tsx index f5e3833929..b0cb381a5b 100644 --- a/packages/plugins/openapi/src/react/AddOpenApiSource.tsx +++ b/packages/plugins/openapi/src/react/AddOpenApiSource.tsx @@ -1,11 +1,10 @@ import { useState, useEffect, useRef } from "react"; -import { useAtomSet, useAtomValue, useAtomRefresh, Result } from "@effect-atom/atom-react"; +import { useAtomSet } from "@effect-atom/atom-react"; import { Option } from "effect"; -import { secretsAtom, setSecret, resolveSecret } from "@executor/react/api/atoms"; import { useScope } from "@executor/react/api/scope-context"; -import { SecretPicker, type SecretPickerSecret } from "@executor/react/plugins/secret-picker"; -import { SecretId } from "@executor/sdk"; +import { SecretHeaderAuthRow, defaultHeaderAuthPresets } from "@executor/react/plugins/secret-header-auth"; +import { useSecretPickerSecrets } from "@executor/react/plugins/use-secret-picker-secrets"; import { Button } from "@executor/react/components/button"; import { Input } from "@executor/react/components/input"; import { Label } from "@executor/react/components/label"; @@ -17,309 +16,6 @@ import { previewOpenApiSpec, addOpenApiSpec } from "./atoms"; import type { SpecPreview, HeaderPreset } from "../sdk/preview"; import type { HeaderValue } from "../sdk/types"; -// --------------------------------------------------------------------------- -// Inline secret creation -// --------------------------------------------------------------------------- - -function InlineCreateSecret(props: { - headerName: string; - suggestedId: string; - onCreated: (secretId: string) => void; - onCancel: () => void; -}) { - const [secretId, setSecretId] = useState(props.suggestedId); - const [secretName, setSecretName] = useState(props.headerName); - const [secretValue, setSecretValue] = useState(""); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); - const scopeId = useScope(); - const doSet = useAtomSet(setSecret, { mode: "promise" }); - const refreshSecrets = useAtomRefresh(secretsAtom(scopeId)); - - const handleSave = async () => { - if (!secretId.trim() || !secretValue.trim()) return; - setSaving(true); - setError(null); - try { - await doSet({ - path: { scopeId }, - payload: { - id: SecretId.make(secretId.trim()), - name: secretName.trim() || secretId.trim(), - value: secretValue.trim(), - purpose: `Auth header: ${props.headerName}`, - }, - }); - refreshSecrets(); - props.onCreated(secretId.trim()); - } catch (e) { - setError(e instanceof Error ? e.message : "Failed to save secret"); - setSaving(false); - } - }; - - return ( -
-

New secret

-
-
- - setSecretId((e.target as HTMLInputElement).value)} - placeholder="my-api-token" - className="h-8 text-xs font-mono" - /> -
-
- - setSecretName((e.target as HTMLInputElement).value)} - placeholder="API Token" - className="h-8 text-xs" - /> -
-
-
- - setSecretValue((e.target as HTMLInputElement).value)} - placeholder="paste your token or key…" - className="h-8 text-xs font-mono" - /> -
- {error &&

{error}

} -
- - -
-
- ); -} - -// --------------------------------------------------------------------------- -// Header value preview — shows what the header will look like -// --------------------------------------------------------------------------- - -type ResolveState = - | { status: "hidden" } - | { status: "loading" } - | { status: "revealed"; value: string } - | { status: "error" }; - -function HeaderValuePreview(props: { - headerName: string; - secretId: string; - prefix?: string; -}) { - const { headerName, secretId, prefix } = props; - const scopeId = useScope(); - const [state, setState] = useState({ status: "hidden" }); - const doResolve = useAtomSet(resolveSecret, { mode: "promise" }); - - const handleToggle = async () => { - if (state.status === "revealed") { - setState({ status: "hidden" }); - return; - } - setState({ status: "loading" }); - try { - const result = await doResolve({ - path: { - scopeId, - secretId: SecretId.make(secretId), - }, - }); - setState({ status: "revealed", value: result.value }); - } catch { - setState({ status: "error" }); - } - }; - - const displayValue = - state.status === "revealed" ? state.value - : state.status === "error" ? "failed to resolve" - : "•".repeat(12); - const isLoading = state.status === "loading"; - const isRevealed = state.status === "revealed"; - - return ( -
- {headerName}: - - {prefix && {prefix}} - {displayValue} - - -
- ); -} - -// --------------------------------------------------------------------------- -// Header presets -// --------------------------------------------------------------------------- - -const HEADER_PRESETS = [ - { key: "bearer", label: "Bearer Token", name: "Authorization", prefix: "Bearer " }, - { key: "basic", label: "Basic Auth", name: "Authorization", prefix: "Basic " }, - { key: "api-key", label: "API Key", name: "X-API-Key" }, - { key: "auth-token", label: "Auth Token", name: "X-Auth-Token" }, - { key: "access-token", label: "Access Token", name: "X-Access-Token" }, - { key: "cookie", label: "Cookie", name: "Cookie" }, - { key: "custom", label: "Custom", name: "" }, -] as const; - -// --------------------------------------------------------------------------- -// Custom header row — pick a preset, then pick a secret -// --------------------------------------------------------------------------- - -function CustomHeaderRow(props: { - name: string; - prefix?: string; - presetKey?: string; - secretId: string | null; - onChange: (update: { name: string; prefix?: string; presetKey?: string }) => void; - onSelectSecret: (secretId: string) => void; - onRemove: () => void; - existingSecrets: readonly SecretPickerSecret[]; -}) { - const [creating, setCreating] = useState(false); - const { name, prefix, presetKey, secretId, onChange, onSelectSecret, onRemove, existingSecrets } = props; - - const isCustom = presetKey === "custom"; - const suggestedId = name.toLowerCase().replace(/[^a-z0-9]+/g, "-") || "custom-header"; - - if (creating) { - return ( - { - onSelectSecret(id); - setCreating(false); - }} - onCancel={() => setCreating(false)} - /> - ); - } - - return ( -
-
- - -
- - {/* Preset chips */} -
- {HEADER_PRESETS.map((p) => ( - - ))} -
- - {/* Name + prefix fields — always visible once a preset is picked */} - {presetKey !== undefined && ( -
-
- - onChange({ name: (e.target as HTMLInputElement).value, prefix, presetKey: isCustom ? "custom" : presetKey })} - placeholder="Authorization" - className="h-8 text-xs font-mono" - /> -
-
- - onChange({ name, prefix: (e.target as HTMLInputElement).value || undefined, presetKey: isCustom ? "custom" : presetKey })} - placeholder="Bearer " - className="h-8 text-xs font-mono" - /> -
-
- )} - - {/* Secret picker */} - {presetKey !== undefined && name.trim() && ( -
-
- -
- -
- )} - - {/* Preview */} - {secretId && name.trim() && ( - - )} -
- ); -} - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -334,13 +30,11 @@ function prefixForHeader(preset: HeaderPreset, headerName: string): string | und } function matchPresetKey(name: string, prefix?: string): string { - if (name === "Authorization" && prefix === "Bearer ") return "bearer"; - if (name === "Authorization" && prefix === "Basic ") return "basic"; - if (name === "X-API-Key") return "api-key"; - if (name === "X-Auth-Token") return "auth-token"; - if (name === "X-Access-Token") return "access-token"; - if (name === "Cookie") return "cookie"; - return "custom"; + const preset = + defaultHeaderAuthPresets.find((entry) => entry.name === name && entry.prefix === prefix) + ?? defaultHeaderAuthPresets.find((entry) => entry.name === name && entry.prefix === undefined); + + return preset?.key ?? "custom"; } function presetEntriesFromHeaderPreset(preset: HeaderPreset) { @@ -385,7 +79,7 @@ export default function AddOpenApiSource(props: { const scopeId = useScope(); const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promise" }); const doAdd = useAtomSet(addOpenApiSpec, { mode: "promise" }); - const secrets = useAtomValue(secretsAtom(scopeId)); + const secretList = useSecretPickerSecrets(); const autoAnalyzed = useRef(false); useEffect(() => { @@ -395,17 +89,6 @@ export default function AddOpenApiSource(props: { } }, []); // eslint-disable-line react-hooks/exhaustive-deps - const secretList: readonly SecretPickerSecret[] = Result.match(secrets, { - onInitial: () => [] as SecretPickerSecret[], - onFailure: () => [] as SecretPickerSecret[], - onSuccess: ({ value }) => - value.map((s) => ({ - id: s.id, - name: s.name, - provider: s.provider ? String(s.provider) : undefined, - })), - }); - // ---- Derived state ---- const presets = preview?.headerPresets ?? []; @@ -695,7 +378,7 @@ export default function AddOpenApiSource(props: { {presetIndex !== -1 && customHeaders.length > 0 && (
{customHeaders.map((ch, i) => ( - + + + + + + ) : ( + + + + + ); +} + +function InlineCreateSecret(props: { + headerName: string; + suggestedId: string; + onCreated: (secretId: string) => void; + onCancel: () => void; +}) { + const [secretId, setSecretId] = useState(props.suggestedId); + const [secretName, setSecretName] = useState(props.headerName); + const [secretValue, setSecretValue] = useState(""); + const [secretRevealed, setSecretRevealed] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const scopeId = useScope(); + const doSet = useAtomSet(setSecret, { mode: "promise" }); + const refreshSecrets = useAtomRefresh(secretsAtom(scopeId)); + + const handleSave = async () => { + if (!secretId.trim() || !secretValue.trim()) return; + setSaving(true); + setError(null); + try { + await doSet({ + path: { scopeId }, + payload: { + id: SecretId.make(secretId.trim()), + name: secretName.trim() || secretId.trim(), + value: secretValue.trim(), + purpose: `Auth header: ${props.headerName}`, + }, + }); + refreshSecrets(); + props.onCreated(secretId.trim()); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to save secret"); + setSaving(false); + } + }; + + return ( +
+

New secret

+
+
+ + setSecretId((e.target as HTMLInputElement).value)} + placeholder="my-api-token" + className="h-8 text-xs font-mono" + /> +
+
+ + setSecretName((e.target as HTMLInputElement).value)} + placeholder="API Token" + className="h-8 text-xs" + /> +
+
+
+ +
+ setSecretValue((e.target as HTMLInputElement).value)} + placeholder="paste your token or key…" + className="h-8 pr-8 text-xs font-mono" + /> + +
+
+ {error &&

{error}

} +
+ + +
+
+ ); +} + +type ResolveState = + | { status: "hidden" } + | { status: "loading" } + | { status: "revealed"; value: string } + | { status: "error" }; + +function HeaderValuePreview(props: { + headerName: string; + secretId: string; + prefix?: string; +}) { + const { headerName, secretId, prefix } = props; + const scopeId = useScope(); + const [state, setState] = useState({ status: "hidden" }); + const doResolve = useAtomSet(resolveSecret, { mode: "promise" }); + + const handleToggle = async () => { + if (state.status === "revealed") { + setState({ status: "hidden" }); + return; + } + setState({ status: "loading" }); + try { + const result = await doResolve({ + path: { + scopeId, + secretId: SecretId.make(secretId), + }, + }); + setState({ status: "revealed", value: result.value }); + } catch { + setState({ status: "error" }); + } + }; + + const displayValue = + state.status === "revealed" ? state.value + : state.status === "error" ? "failed to resolve" + : "•".repeat(12); + const isLoading = state.status === "loading"; + const isRevealed = state.status === "revealed"; + + return ( +
+ {headerName}: + + {prefix && {prefix}} + {displayValue} + + +
+ ); +} + +export function SecretHeaderAuthRow(props: { + name: string; + prefix?: string; + presetKey?: string; + secretId: string | null; + onChange: (update: { name: string; prefix?: string; presetKey?: string }) => void; + onSelectSecret: (secretId: string) => void; + existingSecrets: readonly SecretPickerSecret[]; + presets?: readonly HeaderAuthPreset[]; + onRemove?: () => void; + removeLabel?: string; + label?: string; +}) { + const [creating, setCreating] = useState(false); + const { + name, + prefix, + presetKey, + secretId, + onChange, + onSelectSecret, + existingSecrets, + presets = defaultHeaderAuthPresets, + onRemove, + removeLabel = "Remove", + label = "Header", + } = props; + + const isCustom = presetKey === "custom"; + const suggestedId = name.toLowerCase().replace(/[^a-z0-9]+/g, "-") || "custom-header"; + + if (creating) { + return ( + { + onSelectSecret(id); + setCreating(false); + }} + onCancel={() => setCreating(false)} + /> + ); + } + + return ( +
+
+ + {onRemove && ( + + )} +
+ +
+ {presets.map((preset) => ( + + ))} +
+ + {presetKey !== undefined && ( +
+
+ + + onChange({ + name: (e.target as HTMLInputElement).value, + prefix, + presetKey: isCustom ? "custom" : presetKey, + }) + } + placeholder="Authorization" + className="h-8 text-xs font-mono" + /> +
+
+ + + onChange({ + name, + prefix: (e.target as HTMLInputElement).value || undefined, + presetKey: isCustom ? "custom" : presetKey, + }) + } + placeholder="Bearer " + className="h-8 text-xs font-mono" + /> +
+
+ )} + + {presetKey !== undefined && name.trim() && ( +
+
+ +
+ +
+ )} + + {secretId && name.trim() && ( + + )} +
+ ); +} diff --git a/packages/react/src/plugins/use-secret-picker-secrets.tsx b/packages/react/src/plugins/use-secret-picker-secrets.tsx new file mode 100644 index 0000000000..cf3d2c8035 --- /dev/null +++ b/packages/react/src/plugins/use-secret-picker-secrets.tsx @@ -0,0 +1,21 @@ +import { useAtomValue, Result } from "@effect-atom/atom-react"; + +import { secretsAtom } from "../api/atoms"; +import { useScope } from "../api/scope-context"; +import type { SecretPickerSecret } from "./secret-picker"; + +export function useSecretPickerSecrets(): readonly SecretPickerSecret[] { + const scopeId = useScope(); + const secrets = useAtomValue(secretsAtom(scopeId)); + + return Result.match(secrets, { + onInitial: () => [] as SecretPickerSecret[], + onFailure: () => [] as SecretPickerSecret[], + onSuccess: ({ value }) => + value.map((secret) => ({ + id: secret.id, + name: secret.name, + provider: secret.provider ? String(secret.provider) : undefined, + })), + }); +} From 0907972eb2b27c066830afa1104226d1f1fd4da6 Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Fri, 10 Apr 2026 02:15:16 +0530 Subject: [PATCH 2/2] feat(mcp): support manual headers for remote sources --- .../plugins/mcp/src/react/AddMcpSource.tsx | 308 +++++++++++++++--- 1 file changed, 262 insertions(+), 46 deletions(-) diff --git a/packages/plugins/mcp/src/react/AddMcpSource.tsx b/packages/plugins/mcp/src/react/AddMcpSource.tsx index 92e3d2b09f..c9b3bb9303 100644 --- a/packages/plugins/mcp/src/react/AddMcpSource.tsx +++ b/packages/plugins/mcp/src/react/AddMcpSource.tsx @@ -6,7 +6,10 @@ import { Button } from "@executor/react/components/button"; import { Input } from "@executor/react/components/input"; import { Label } from "@executor/react/components/label"; import { Badge } from "@executor/react/components/badge"; +import { RadioGroup, RadioGroupItem } from "@executor/react/components/radio-group"; import { Spinner } from "@executor/react/components/spinner"; +import { SecretHeaderAuthRow } from "@executor/react/plugins/secret-header-auth"; +import { useSecretPickerSecrets } from "@executor/react/plugins/use-secret-picker-secrets"; import { probeMcpEndpoint, addMcpSource, startMcpOAuth } from "./atoms"; import { mcpPresets, type McpPreset } from "../sdk/presets"; @@ -40,6 +43,13 @@ type ProbeResult = { serverName: string | null; }; +type RemoteAuthMode = "none" | "header" | "oauth2"; + +type PlainHeader = { + name: string; + value: string; +}; + type State = | { step: "url"; url: string } | { step: "probing"; url: string } @@ -230,6 +240,21 @@ export default function AddMcpSource(props: { const doProbe = useAtomSet(probeMcpEndpoint, { mode: "promise" }); const doAdd = useAtomSet(addMcpSource, { mode: "promise" }); const doStartOAuth = useAtomSet(startMcpOAuth, { mode: "promise" }); + const secretList = useSecretPickerSecrets(); + + const [remoteAuthMode, setRemoteAuthMode] = useState("none"); + const [remoteHeaderAuth, setRemoteHeaderAuth] = useState<{ + name: string; + prefix?: string; + presetKey?: string; + secretId: string | null; + }>({ + name: "Authorization", + prefix: "Bearer ", + presetKey: "bearer", + secretId: null, + }); + const [remoteHeaders, setRemoteHeaders] = useState([]); const probe = "probe" in state ? state.probe : null; const tokens = "tokens" in state ? state.tokens : null; @@ -237,8 +262,16 @@ export default function AddMcpSource(props: { 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 canUseNone = probe?.requiresOAuth !== true; + const headerAuthComplete = Boolean(remoteHeaderAuth.name.trim() && remoteHeaderAuth.secretId); + const remoteHeadersComplete = remoteHeaders.every( + (header) => header.name.trim() && header.value.trim(), + ); + const authReady = + remoteAuthMode === "none" ? canUseNone + : remoteAuthMode === "header" ? headerAuthComplete + : tokens !== null; + const canAdd = Boolean(probe) && authReady && remoteHeadersComplete && !isAdding && !isOAuthBusy; const error = state.step === "error" ? state.error : null; // ---- Remote actions ---- @@ -250,6 +283,7 @@ export default function AddMcpSource(props: { path: { scopeId }, payload: { endpoint: state.url.trim() }, }); + setRemoteAuthMode(result.requiresOAuth ? "oauth2" : "none"); dispatch({ type: "probe-ok", probe: result }); } catch (e) { dispatch({ type: "probe-fail", error: e instanceof Error ? e.message : "Failed to connect" }); @@ -316,13 +350,15 @@ export default function AddMcpSource(props: { 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 + const auth = + remoteAuthMode === "header" + ? { + kind: "header" as const, + headerName: remoteHeaderAuth.name.trim(), + secretId: remoteHeaderAuth.secretId!, + ...(remoteHeaderAuth.prefix ? { prefix: remoteHeaderAuth.prefix } : {}), + } + : remoteAuthMode === "oauth2" && tokens ? { kind: "oauth2" as const, accessTokenSecretId: tokens.accessTokenSecretId, @@ -331,14 +367,28 @@ export default function AddMcpSource(props: { expiresAt: tokens.expiresAt, scope: tokens.scope, } - : { kind: "none" as const }, + : { kind: "none" as const }; + const headers = Object.fromEntries( + remoteHeaders + .map((header) => [header.name.trim(), header.value.trim()] as const) + .filter(([name, value]) => name && value), + ); + + await doAdd({ + path: { scopeId }, + payload: { + transport: "remote" as const, + name: probe.serverName ?? probe.name, + endpoint: state.url.trim(), + auth, + ...(Object.keys(headers).length > 0 ? { headers } : {}), }, }); props.onComplete(); } catch (e) { dispatch({ type: "add-fail", error: e instanceof Error ? e.message : "Failed to add source" }); } - }, [probe, tokens, state.url, doAdd, props]); + }, [probe, remoteAuthMode, remoteHeaderAuth, remoteHeaders, tokens, state.url, doAdd, props]); // ---- Stdio actions ---- @@ -483,51 +533,217 @@ export default function AddMcpSource(props: {
)} - {/* OAuth section */} - {probe?.requiresOAuth && !tokens && ( + {/* Authentication */} + {probe && (
- {state.step === "probed" && ( - + + + setRemoteAuthMode(value as RemoteAuthMode)} + className="gap-1.5" + > + {!probe.requiresOAuth && ( + + )} + + + + {probe.requiresOAuth && ( + + )} + + + {remoteAuthMode === "header" && ( + + setRemoteHeaderAuth((current) => ({ + ...current, + ...update, + })) + } + onSelectSecret={(secretId) => + setRemoteHeaderAuth((current) => ({ + ...current, + secretId, + })) + } + existingSecrets={secretList} + /> )} - {state.step === "oauth-starting" && ( -
- - Starting authorization… -
+ {probe.requiresOAuth && remoteAuthMode === "oauth2" && !tokens && ( + <> + {state.step === "probed" && ( + + )} + + {state.step === "oauth-starting" && ( +
+ + Starting authorization… +
+ )} + + {state.step === "oauth-waiting" && ( +
+ + Waiting for authorization in popup… + +
+ )} + )} - {state.step === "oauth-waiting" && ( -
- - Waiting for authorization in popup… - + {probe.requiresOAuth && remoteAuthMode === "oauth2" && tokens && ( +
+ + + + Authenticated
)} + + {remoteAuthMode === "none" && probe.requiresOAuth && ( +

+ This server requires authentication before it can be added. +

+ )}
)} - {/* OAuth success */} - {tokens && ( -
- - - - Authenticated -
+ {/* Additional headers */} + {probe && ( +
+
+
+ +

+ Plaintext headers sent with every request. Use authentication for secret-backed auth headers. +

+
+ +
+ + {remoteHeaders.length > 0 && ( +
+ {remoteHeaders.map((header, index) => ( +
+
+ + +
+
+
+ + + setRemoteHeaders((headers) => + headers.map((current, headerIndex) => + headerIndex === index + ? { ...current, name: (event.target as HTMLInputElement).value } + : current, + ), + ) + } + placeholder="X-Organization-Id" + className="h-8 text-xs font-mono" + /> +
+
+ + + setRemoteHeaders((headers) => + headers.map((current, headerIndex) => + headerIndex === index + ? { ...current, value: (event.target as HTMLInputElement).value } + : current, + ), + ) + } + placeholder="workspace-id" + className="h-8 text-xs font-mono" + /> +
+
+
+ ))} +
+ )} +
)} {/* Error */}