diff --git a/packages/plugins/google-discovery/src/api/group.ts b/packages/plugins/google-discovery/src/api/group.ts index ae49991212..b1bdfec5ca 100644 --- a/packages/plugins/google-discovery/src/api/group.ts +++ b/packages/plugins/google-discovery/src/api/group.ts @@ -15,7 +15,7 @@ const AuthPayload = Schema.Union( }), Schema.Struct({ kind: Schema.Literal("oauth2"), - clientId: Schema.String, + clientIdSecretId: Schema.String, clientSecretSecretId: Schema.NullOr(Schema.String), accessTokenSecretId: Schema.String, refreshTokenSecretId: Schema.NullOr(Schema.String), @@ -30,6 +30,13 @@ const ProbePayload = Schema.Struct({ discoveryUrl: Schema.String, }); +const ProbeOperation = Schema.Struct({ + toolPath: Schema.String, + method: Schema.String, + pathTemplate: Schema.String, + description: Schema.NullOr(Schema.String), +}); + const ProbeResponse = Schema.Struct({ name: Schema.String, title: Schema.NullOr(Schema.String), @@ -37,6 +44,7 @@ const ProbeResponse = Schema.Struct({ version: Schema.String, toolCount: Schema.Number, scopes: Schema.Array(Schema.String), + operations: Schema.Array(ProbeOperation), }); const AddSourcePayload = Schema.Struct({ @@ -54,7 +62,7 @@ const AddSourceResponse = Schema.Struct({ const StartOAuthPayload = Schema.Struct({ name: Schema.String, discoveryUrl: Schema.String, - clientId: Schema.String, + clientIdSecretId: Schema.String, clientSecretSecretId: Schema.optional(Schema.NullOr(Schema.String)), redirectUrl: Schema.String, scopes: Schema.optional(Schema.Array(Schema.String)), @@ -74,7 +82,7 @@ const CompleteOAuthPayload = Schema.Struct({ const CompleteOAuthResponse = Schema.Struct({ kind: Schema.Literal("oauth2"), - clientId: Schema.String, + clientIdSecretId: Schema.String, clientSecretSecretId: Schema.NullOr(Schema.String), accessTokenSecretId: Schema.String, refreshTokenSecretId: Schema.NullOr(Schema.String), diff --git a/packages/plugins/google-discovery/src/api/handlers.ts b/packages/plugins/google-discovery/src/api/handlers.ts index 69b6169965..a18460eaab 100644 --- a/packages/plugins/google-discovery/src/api/handlers.ts +++ b/packages/plugins/google-discovery/src/api/handlers.ts @@ -130,7 +130,7 @@ export const GoogleDiscoveryHandlers = HttpApiBuilder.group( return yield* ext.startOAuth({ name: payload.name, discoveryUrl: payload.discoveryUrl, - clientId: payload.clientId, + clientIdSecretId: payload.clientIdSecretId, clientSecretSecretId: payload.clientSecretSecretId, redirectUrl: payload.redirectUrl, scopes: payload.scopes, diff --git a/packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx b/packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx index fe198927bd..749b295325 100644 --- a/packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx +++ b/packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx @@ -7,17 +7,40 @@ import { SecretPicker, type SecretPickerSecret } from "@executor/react/plugins/s import { SecretId } from "@executor/sdk"; import { Badge } from "@executor/react/components/badge"; import { Button } from "@executor/react/components/button"; +import { + CardStack, + CardStackContent, + CardStackEntryField, +} from "@executor/react/components/card-stack"; +import { + SourceIdentityFields, + useSourceIdentity, +} from "@executor/react/plugins/source-identity"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@executor/react/components/collapsible"; +import { + Field, + FieldContent, + FieldDescription, + FieldGroup, + FieldLabel, + FieldLegend, + FieldSet, + FieldTitle, +} from "@executor/react/components/field"; +import { FilterTabs } from "@executor/react/components/filter-tabs"; +import { FloatActions } from "@executor/react/components/float-actions"; import { Input } from "@executor/react/components/input"; import { Label } from "@executor/react/components/label"; import { RadioGroup, RadioGroupItem } from "@executor/react/components/radio-group"; -import { Spinner } from "@executor/react/components/spinner"; +import { IOSSpinner, Spinner } from "@executor/react/components/spinner"; import { addGoogleDiscoverySource, probeGoogleDiscovery, startGoogleDiscoveryOAuth } from "./atoms"; +type GoogleAuthKind = "none" | "oauth2"; + // --------------------------------------------------------------------------- // Inline secret creation // --------------------------------------------------------------------------- @@ -61,40 +84,40 @@ function InlineCreateSecret(props: { return (
-

New secret

+

New secret

- + setSecretIdValue((e.target as HTMLInputElement).value)} placeholder="google-client-secret" - className="h-8 text-sm font-mono" + className="h-8 text-xs font-mono" />
-
- + setSecretValue((e.target as HTMLInputElement).value)} placeholder="paste your client secret…" - className="h-8 text-sm font-mono" + className="h-8 text-xs font-mono" />
- {error &&

{error}

} + {error &&

{error}

}
- {clientSecretSecretId && ( + {clearable && secretId && ( @@ -315,6 +343,13 @@ function GoogleServiceIcon(props: { readonly service: string; readonly className ); } +type ProbeOperation = { + toolPath: string; + method: string; + pathTemplate: string; + description: string | null; +}; + type ProbeResult = { name: string; title: string | null; @@ -322,11 +357,12 @@ type ProbeResult = { version: string; toolCount: number; scopes: readonly string[]; + operations: readonly ProbeOperation[]; }; type OAuthAuth = { kind: "oauth2"; - clientId: string; + clientIdSecretId: string; clientSecretSecretId: string | null; accessTokenSecretId: string; refreshTokenSecretId: string | null; @@ -411,16 +447,18 @@ export default function AddGoogleDiscoverySource(props: { const [discoveryUrl, setDiscoveryUrl] = useState( props.initialUrl ?? defaultTemplate.discoveryUrl, ); - const [name, setName] = useState(props.initialUrl ? "" : defaultTemplate.name); const [selectedTemplateId, setSelectedTemplateId] = useState( props.initialUrl ? "" : defaultTemplate.id, ); const selectedTemplate = GOOGLE_DISCOVERY_TEMPLATES.find((template) => template.id === selectedTemplateId) ?? null; - const [authKind, setAuthKind] = useState<"none" | "oauth2">("oauth2"); - const [clientId, setClientId] = useState(""); + const [authKind, setAuthKind] = useState("oauth2"); + const [clientIdSecretId, setClientIdSecretId] = useState(null); const [clientSecretSecretId, setClientSecretSecretId] = useState(null); const [probe, setProbe] = useState(null); + const identity = useSourceIdentity({ + fallbackName: probe?.name ?? selectedTemplate?.name ?? "", + }); const [oauthAuth, setOauthAuth] = useState(null); const [loadingProbe, setLoadingProbe] = useState(false); const [startingOAuth, setStartingOAuth] = useState(false); @@ -446,17 +484,20 @@ export default function AddGoogleDiscoverySource(props: { })), }); - const applyTemplate = useCallback((template: GoogleDiscoveryTemplate) => { - setSelectedTemplateId(template.id); - setDiscoveryUrl(template.discoveryUrl); - setName(template.name); - setClientSecretSecretId(null); - setProbe(null); - setOauthAuth(null); - setError(null); - setShowScopes(false); - setAuthKind("oauth2"); - }, []); + const applyTemplate = useCallback( + (template: GoogleDiscoveryTemplate) => { + setSelectedTemplateId(template.id); + setDiscoveryUrl(template.discoveryUrl); + identity.reset(); + setClientSecretSecretId(null); + setProbe(null); + setOauthAuth(null); + setError(null); + setShowScopes(false); + setAuthKind("oauth2"); + }, + [identity], + ); const handleProbe = useCallback(async () => { setLoadingProbe(true); @@ -468,10 +509,11 @@ export default function AddGoogleDiscoverySource(props: { path: { scopeId }, payload: { discoveryUrl: discoveryUrl.trim() }, }); - setProbe({ ...result, scopes: [...result.scopes] }); - if (!name.trim()) { - setName(result.name); - } + setProbe({ + ...result, + scopes: [...result.scopes], + operations: [...result.operations], + }); if (result.scopes.length === 0) { setAuthKind("none"); } @@ -481,20 +523,30 @@ export default function AddGoogleDiscoverySource(props: { } finally { setLoadingProbe(false); } - }, [discoveryUrl, doProbe, name, scopeId]); + }, [discoveryUrl, doProbe, scopeId]); - const autoProbed = useRef(false); + // 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 discovery URL changes (debounced). Clearing the + // previous probe in the onChange handler resets the preview so a new run + // will be triggered. useEffect(() => { - if (props.initialUrl && !autoProbed.current) { - autoProbed.current = true; - handleProbe(); - } - }, []); // eslint-disable-line react-hooks/exhaustive-deps + const trimmed = discoveryUrl.trim(); + if (!trimmed) return; + if (probe) return; + const handle = setTimeout(() => { + handleProbeRef.current(); + }, 400); + return () => clearTimeout(handle); + }, [discoveryUrl, probe]); const oauthCleanup = useRef<(() => void) | null>(null); const handleStartOAuth = useCallback(async () => { - if (!probe) return; + if (!probe || !clientIdSecretId) return; oauthCleanup.current?.(); oauthCleanup.current = null; setStartingOAuth(true); @@ -503,9 +555,9 @@ export default function AddGoogleDiscoverySource(props: { const response = await doStartOAuth({ path: { scopeId }, payload: { - name: name.trim() || probe.name, + name: identity.name.trim() || probe.name, discoveryUrl: discoveryUrl.trim(), - clientId: clientId.trim(), + clientIdSecretId, clientSecretSecretId, redirectUrl: `${window.location.origin}/api/google-discovery/oauth/callback`, scopes: probe.scopes, @@ -520,7 +572,7 @@ export default function AddGoogleDiscoverySource(props: { if (result.ok) { setOauthAuth({ kind: "oauth2", - clientId: result.clientId, + clientIdSecretId: result.clientIdSecretId, clientSecretSecretId: result.clientSecretSecretId, accessTokenSecretId: result.accessTokenSecretId, refreshTokenSecretId: result.refreshTokenSecretId, @@ -544,7 +596,7 @@ export default function AddGoogleDiscoverySource(props: { setStartingOAuth(false); setError(e instanceof Error ? e.message : "Failed to start OAuth"); } - }, [probe, doStartOAuth, scopeId, name, discoveryUrl, clientId, clientSecretSecretId]); + }, [probe, doStartOAuth, scopeId, identity, discoveryUrl, clientIdSecretId, clientSecretSecretId]); const handleCancelOAuth = useCallback(() => { oauthCleanup.current?.(); @@ -560,8 +612,9 @@ export default function AddGoogleDiscoverySource(props: { await doAdd({ path: { scopeId }, payload: { - name: name.trim() || probe.name, + name: identity.name.trim() || probe.name, discoveryUrl: discoveryUrl.trim(), + namespace: identity.namespace.trim() || undefined, auth: authKind === "oauth2" ? (oauthAuth ?? { kind: "none" as const }) @@ -573,100 +626,85 @@ export default function AddGoogleDiscoverySource(props: { setError(e instanceof Error ? e.message : "Failed to add source"); setAdding(false); } - }, [probe, doAdd, name, discoveryUrl, authKind, oauthAuth, props, scopeId]); + }, [probe, doAdd, identity, discoveryUrl, authKind, oauthAuth, props, scopeId]); const addDisabled = !probe || adding || (authKind === "oauth2" && (!canUseOAuth || oauthAuth === null)); return ( -
+

Add Google Discovery Source

-

+

Connect a Google API from its Discovery document and register its methods as tools.

-
-
- - - Select a Google API to prefill the source. - -
-
- {GOOGLE_DISCOVERY_TEMPLATES.map((template) => { - const selected = template.id === selectedTemplateId; - return ( - - ); - })} -
-
+ )} +
+ -
- -
- { - setSelectedTemplateId(""); - setDiscoveryUrl((e.target as HTMLInputElement).value); - }} - placeholder="https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest" - className="flex-1 font-mono text-sm" - /> - -
-
+ + -
- - setName((e.target as HTMLInputElement).value)} - placeholder="Google Sheets" - /> -
+ {probe && (
@@ -680,7 +718,7 @@ export default function AddGoogleDiscoverySource(props: {

{probe.title ?? probe.name}

-

+

{probe.service} · {probe.version}

@@ -693,45 +731,44 @@ export default function AddGoogleDiscoverySource(props: { )} -
- setAuthKind(value as "none" | "oauth2")} - className="flex items-center gap-4" - > -
- - -
-
- - -
-
+
+
+ Authentication + + tabs={[ + { value: "none", label: "None" }, + { value: "oauth2", label: "OAuth" }, + ]} + value={authKind} + onChange={setAuthKind} + /> +
{authKind === "oauth2" && (
-
- - setClientId((e.target as HTMLInputElement).value)} - placeholder="1234567890-abc.apps.googleusercontent.com" - /> -
- +
-

+

{canUseOAuth ? `${probe?.scopes.length ?? 0} scopes will be requested from Google.` : "This API does not advertise OAuth scopes."} @@ -752,7 +789,7 @@ export default function AddGoogleDiscoverySource(props: {

)} -
- -
+
); } diff --git a/packages/plugins/google-discovery/src/sdk/invoke.ts b/packages/plugins/google-discovery/src/sdk/invoke.ts index 3e2059343f..9614ab4174 100644 --- a/packages/plugins/google-discovery/src/sdk/invoke.ts +++ b/packages/plugins/google-discovery/src/sdk/invoke.ts @@ -137,8 +137,21 @@ const resolveOAuthAccessToken = (input: { ), ); + const clientId = yield* input.secrets + .resolve(auth.clientIdSecretId as SecretId, input.scopeId) + .pipe( + Effect.mapError( + () => + new ToolInvocationError({ + toolId: "" as ToolId, + message: "Failed to resolve Google OAuth client ID", + cause: undefined, + }), + ), + ); + const refreshed = yield* refreshAccessToken({ - clientId: auth.clientId, + clientId, clientSecret, refreshToken, scopes: auth.scopes, @@ -199,7 +212,7 @@ const resolveOAuthAccessToken = (input: { ...input.source, auth: { kind: "oauth2", - clientId: auth.clientId, + clientIdSecretId: auth.clientIdSecretId, clientSecretSecretId: auth.clientSecretSecretId, accessTokenSecretId: auth.accessTokenSecretId, refreshTokenSecretId, diff --git a/packages/plugins/google-discovery/src/sdk/plugin.test.ts b/packages/plugins/google-discovery/src/sdk/plugin.test.ts index 3e6e149ea5..9b5bb948c4 100644 --- a/packages/plugins/google-discovery/src/sdk/plugin.test.ts +++ b/packages/plugins/google-discovery/src/sdk/plugin.test.ts @@ -160,11 +160,20 @@ describe("Google Discovery plugin", () => { ), ); + await Effect.runPromise( + executor.secrets.set({ + id: SecretId.make("google-client-id"), + name: "Google Client ID", + value: "client-123", + purpose: "google_oauth_client_id", + }), + ); + const result = await Effect.runPromise( executor.googleDiscovery.startOAuth({ name: "Google Drive", discoveryUrl, - clientId: "client-123", + clientIdSecretId: "google-client-id", redirectUrl: "http://localhost/callback", }), ); @@ -189,6 +198,15 @@ describe("Google Discovery plugin", () => { ), ); + await Effect.runPromise( + executor.secrets.set({ + id: SecretId.make("google-client-id"), + name: "Google Client ID", + value: "client-123", + purpose: "google_oauth_client_id", + }), + ); + await Effect.runPromise( executor.secrets.set({ id: SecretId.make("google-client-secret"), @@ -231,7 +249,7 @@ describe("Google Discovery plugin", () => { executor.googleDiscovery.startOAuth({ name: "Google Drive", discoveryUrl, - clientId: "client-123", + clientIdSecretId: "google-client-id", clientSecretSecretId: "google-client-secret", redirectUrl: "http://localhost/callback", }), @@ -245,7 +263,7 @@ describe("Google Discovery plugin", () => { ); expect(auth.kind).toBe("oauth2"); - expect(auth.clientId).toBe("client-123"); + expect(auth.clientIdSecretId).toBe("google-client-id"); expect(auth.refreshTokenSecretId).not.toBeNull(); const accessToken = await Effect.runPromise( @@ -283,6 +301,15 @@ describe("Google Discovery plugin", () => { }), ); + await Effect.runPromise( + executor.secrets.set({ + id: SecretId.make("drive-client-id"), + name: "Drive Client ID", + value: "client-123", + purpose: "google_oauth_client_id", + }), + ); + const result = await Effect.runPromise( executor.googleDiscovery.addSource({ name: "Google Drive", @@ -290,7 +317,7 @@ describe("Google Discovery plugin", () => { namespace: "drive", auth: { kind: "oauth2", - clientId: "client-123", + clientIdSecretId: "drive-client-id", clientSecretSecretId: null, accessTokenSecretId: "drive-access-token", refreshTokenSecretId: null, diff --git a/packages/plugins/google-discovery/src/sdk/plugin.ts b/packages/plugins/google-discovery/src/sdk/plugin.ts index cafad3c58a..2ee10313d5 100644 --- a/packages/plugins/google-discovery/src/sdk/plugin.ts +++ b/packages/plugins/google-discovery/src/sdk/plugin.ts @@ -35,6 +35,13 @@ import type { } from "./types"; import { GoogleDiscoveryStoredSourceData as GoogleDiscoveryStoredSourceDataSchema } from "./types"; +export interface GoogleDiscoveryProbeOperation { + readonly toolPath: string; + readonly method: string; + readonly pathTemplate: string; + readonly description: string | null; +} + export interface GoogleDiscoveryProbeResult { readonly name: string; readonly title: string | null; @@ -42,6 +49,7 @@ export interface GoogleDiscoveryProbeResult { readonly version: string; readonly toolCount: number; readonly scopes: readonly string[]; + readonly operations: readonly GoogleDiscoveryProbeOperation[]; } export interface GoogleDiscoveryAddSourceInput { @@ -54,7 +62,7 @@ export interface GoogleDiscoveryAddSourceInput { export interface GoogleDiscoveryOAuthStartInput { readonly name: string; readonly discoveryUrl: string; - readonly clientId: string; + readonly clientIdSecretId: string; readonly clientSecretSecretId?: string | null; readonly redirectUrl: string; readonly scopes?: readonly string[]; @@ -74,7 +82,7 @@ export interface GoogleDiscoveryOAuthCompleteInput { export interface GoogleDiscoveryOAuthAuthResult { readonly kind: "oauth2"; - readonly clientId: string; + readonly clientIdSecretId: string; readonly clientSecretSecretId: string | null; readonly accessTokenSecretId: string; readonly refreshTokenSecretId: string | null; @@ -368,6 +376,12 @@ export const googleDiscoveryPlugin = (options?: { const scopes = Object.keys( manifest.oauthScopes._tag === "Some" ? manifest.oauthScopes.value : {}, ).sort(); + const operations = manifest.methods.map((method) => ({ + toolPath: method.toolPath, + method: method.binding.method, + pathTemplate: method.binding.pathTemplate, + description: method.description._tag === "Some" ? method.description.value : null, + })); return { name: manifest.title._tag === "Some" @@ -378,6 +392,7 @@ export const googleDiscoveryPlugin = (options?: { version: manifest.version, toolCount: manifest.methods.length, scopes, + operations, }; }), @@ -435,12 +450,22 @@ export const googleDiscoveryPlugin = (options?: { message: "This Google Discovery document does not declare any OAuth scopes", }); } + const clientId = yield* ctx.secrets + .resolve(SecretId.make(input.clientIdSecretId), ctx.scope.id) + .pipe( + Effect.mapError( + (error) => + new GoogleDiscoveryOAuthError({ + message: error.message, + }), + ), + ); const sessionId = randomUUID(); const codeVerifier = createPkceCodeVerifier(); yield* bindingStore.putOAuthSession(sessionId, { discoveryUrl: normalizeDiscoveryUrl(input.discoveryUrl), name: input.name, - clientId: input.clientId, + clientIdSecretId: input.clientIdSecretId, clientSecretSecretId: input.clientSecretSecretId ?? null, redirectUrl: input.redirectUrl, scopes, @@ -449,7 +474,7 @@ export const googleDiscoveryPlugin = (options?: { return { sessionId, authorizationUrl: buildGoogleAuthorizationUrl({ - clientId: input.clientId, + clientId, redirectUrl: input.redirectUrl, scopes, state: sessionId, @@ -480,8 +505,18 @@ export const googleDiscoveryPlugin = (options?: { }); } + const clientId = yield* ctx.secrets + .resolve(SecretId.make(session.clientIdSecretId), ctx.scope.id) + .pipe( + Effect.mapError( + (error) => + new GoogleDiscoveryOAuthError({ + message: error.message, + }), + ), + ); const tokenResponse = yield* exchangeAuthorizationCode({ - clientId: session.clientId, + clientId, clientSecret: session.clientSecretSecretId === null ? null @@ -516,7 +551,7 @@ export const googleDiscoveryPlugin = (options?: { : null; return { kind: "oauth2" as const, - clientId: session.clientId, + clientIdSecretId: session.clientIdSecretId, clientSecretSecretId: session.clientSecretSecretId, accessTokenSecretId: accessTokenRef.id, refreshTokenSecretId: refreshTokenRef?.id ?? null, diff --git a/packages/plugins/google-discovery/src/sdk/types.ts b/packages/plugins/google-discovery/src/sdk/types.ts index 19f746340c..0a30a65ed7 100644 --- a/packages/plugins/google-discovery/src/sdk/types.ts +++ b/packages/plugins/google-discovery/src/sdk/types.ts @@ -66,7 +66,7 @@ export const GoogleDiscoveryAuth = Schema.Union( }), Schema.Struct({ kind: Schema.Literal("oauth2"), - clientId: Schema.String, + clientIdSecretId: Schema.String, clientSecretSecretId: Schema.NullOr(Schema.String), accessTokenSecretId: Schema.String, refreshTokenSecretId: Schema.NullOr(Schema.String), @@ -108,7 +108,7 @@ export interface GoogleDiscoverySourceMeta { export const GoogleDiscoveryOAuthSession = Schema.Struct({ discoveryUrl: Schema.String, name: Schema.String, - clientId: Schema.String, + clientIdSecretId: Schema.String, clientSecretSecretId: Schema.NullOr(Schema.String), redirectUrl: Schema.String, scopes: Schema.Array(Schema.String), diff --git a/packages/plugins/graphql/src/api/group.ts b/packages/plugins/graphql/src/api/group.ts index 6d01ff7ab1..a28e938529 100644 --- a/packages/plugins/graphql/src/api/group.ts +++ b/packages/plugins/graphql/src/api/group.ts @@ -18,12 +18,14 @@ const namespaceParam = HttpApiSchema.param("namespace", Schema.String); const AddSourcePayload = Schema.Struct({ endpoint: Schema.String, + name: Schema.optional(Schema.String), introspectionJson: Schema.optional(Schema.String), namespace: Schema.optional(Schema.String), headers: Schema.optional(Schema.Record({ key: Schema.String, value: Schema.Unknown })), }); const UpdateSourcePayload = Schema.Struct({ + name: Schema.optional(Schema.String), endpoint: Schema.optional(Schema.String), headers: Schema.optional(Schema.Record({ key: Schema.String, value: Schema.Unknown })), }); diff --git a/packages/plugins/graphql/src/api/handlers.ts b/packages/plugins/graphql/src/api/handlers.ts index bc398bacdd..ab57cd0a12 100644 --- a/packages/plugins/graphql/src/api/handlers.ts +++ b/packages/plugins/graphql/src/api/handlers.ts @@ -31,6 +31,7 @@ export const GraphqlHandlers = HttpApiBuilder.group(ExecutorApiWithGraphql, "gra const ext = yield* GraphqlExtensionService; const result = yield* ext.addSource({ endpoint: payload.endpoint, + name: payload.name, introspectionJson: payload.introspectionJson, namespace: payload.namespace, headers: payload.headers as Record | undefined, @@ -51,6 +52,7 @@ export const GraphqlHandlers = HttpApiBuilder.group(ExecutorApiWithGraphql, "gra Effect.gen(function* () { const ext = yield* GraphqlExtensionService; yield* ext.updateSource(path.namespace, { + name: payload.name, endpoint: payload.endpoint, headers: payload.headers as Record | undefined, } as GraphqlUpdateSourceInput); diff --git a/packages/plugins/graphql/src/react/AddGraphqlSource.tsx b/packages/plugins/graphql/src/react/AddGraphqlSource.tsx index aa28c771a1..adc75b83df 100644 --- a/packages/plugins/graphql/src/react/AddGraphqlSource.tsx +++ b/packages/plugins/graphql/src/react/AddGraphqlSource.tsx @@ -2,23 +2,28 @@ import { useState } from "react"; import { useAtomSet } from "@effect-atom/atom-react"; import { useScope } from "@executor/react/api/scope-context"; -import { SecretHeaderAuthRow } from "@executor/react/plugins/secret-header-auth"; +import { HeadersList } from "@executor/react/plugins/headers-list"; +import { type HeaderState } from "@executor/react/plugins/secret-header-auth"; +import { + displayNameFromUrl, + SourceIdentityFields, + useSourceIdentity, +} from "@executor/react/plugins/source-identity"; import { useSecretPickerSecrets } from "@executor/react/plugins/use-secret-picker-secrets"; import { Button } from "@executor/react/components/button"; +import { + CardStack, + CardStackContent, + CardStackEntryField, +} from "@executor/react/components/card-stack"; +import { FieldLabel } from "@executor/react/components/field"; +import { FloatActions } from "@executor/react/components/float-actions"; import { Input } from "@executor/react/components/input"; -import { Label } from "@executor/react/components/label"; import { Spinner } from "@executor/react/components/spinner"; import { addGraphqlSource } from "./atoms"; import type { HeaderValue } from "../sdk/types"; -type HeaderEntry = { - name: string; - prefix?: string; - presetKey?: string; - secretId: string | null; -}; - -const initialHeader = (): HeaderEntry => ({ +const initialHeader = (): HeaderState => ({ name: "Authorization", prefix: "Bearer ", presetKey: "bearer", @@ -31,8 +36,10 @@ export default function AddGraphqlSource(props: { initialUrl?: string; }) { const [endpoint, setEndpoint] = useState(props.initialUrl ?? ""); - const [namespace, setNamespace] = useState(""); - const [headers, setHeaders] = useState([initialHeader()]); + const identity = useSourceIdentity({ + fallbackName: displayNameFromUrl(endpoint) ?? "", + }); + const [headers, setHeaders] = useState([initialHeader()]); const [adding, setAdding] = useState(false); const [addError, setAddError] = useState(null); @@ -43,26 +50,6 @@ export default function AddGraphqlSource(props: { 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 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); @@ -82,7 +69,8 @@ export default function AddGraphqlSource(props: { path: { scopeId }, payload: { endpoint: endpoint.trim(), - namespace: namespace.trim() || undefined, + name: identity.name.trim() || undefined, + namespace: identity.namespace.trim() || undefined, ...(Object.keys(headerMap).length > 0 ? { headers: headerMap } : {}), }, }); @@ -94,89 +82,47 @@ export default function AddGraphqlSource(props: { }; return ( -
+

Add GraphQL Source

- {/* Endpoint */} -
- - setEndpoint((e.target as HTMLInputElement).value)} - placeholder="https://api.example.com/graphql" - className="font-mono text-sm" - /> -

- The endpoint will be introspected to discover available queries and mutations. -

-
- - {/* Namespace */} -
- - setNamespace((e.target as HTMLInputElement).value)} - placeholder="my_api" - className="font-mono text-sm" - /> -

- A prefix for the tool names. Derived from the endpoint hostname if not provided. -

-
- - {/* Authentication */} -
-
-
- -

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

-
- -
+ setEndpoint((e.target as HTMLInputElement).value)} + placeholder="https://api.example.com/graphql" + className="font-mono text-sm" + /> + + + + + - {headers.length > 0 && ( -
- {headers.map((header, index) => ( - updateHeader(index, update)} - onSelectSecret={(secretId) => updateHeader(index, { secretId })} - onRemove={() => removeHeader(index)} - existingSecrets={secretList} - /> - ))} -
- )} +
+ Headers +
{/* Error */} {addError && (
-

{addError}

+

{addError}

)} - {/* Actions */} -
+ @@ -184,7 +130,7 @@ export default function AddGraphqlSource(props: { {adding && } {adding ? "Adding..." : "Add source"} -
+
); } diff --git a/packages/plugins/graphql/src/react/EditGraphqlSource.tsx b/packages/plugins/graphql/src/react/EditGraphqlSource.tsx index 7185411d52..b946263d77 100644 --- a/packages/plugins/graphql/src/react/EditGraphqlSource.tsx +++ b/packages/plugins/graphql/src/react/EditGraphqlSource.tsx @@ -4,14 +4,23 @@ import { graphqlSourceAtom, updateGraphqlSource } from "./atoms"; import { useScope } from "@executor/react/api/scope-context"; import { useSecretPickerSecrets } from "@executor/react/plugins/use-secret-picker-secrets"; import { - SecretHeaderAuthRow, headerValueToState, headersFromState, type HeaderState, } from "@executor/react/plugins/secret-header-auth"; +import { HeadersList } from "@executor/react/plugins/headers-list"; +import { + SourceIdentityFields, + useSourceIdentity, +} from "@executor/react/plugins/source-identity"; import { Button } from "@executor/react/components/button"; +import { + CardStack, + CardStackContent, + CardStackEntryField, +} from "@executor/react/components/card-stack"; +import { FieldLabel } from "@executor/react/components/field"; import { Input } from "@executor/react/components/input"; -import { Label } from "@executor/react/components/label"; import { Badge } from "@executor/react/components/badge"; import type { StoredSourceSchemaType } from "../sdk/stored-source"; @@ -29,6 +38,10 @@ function EditForm(props: { const refreshSource = useAtomRefresh(graphqlSourceAtom(scopeId, props.sourceId)); const secretList = useSecretPickerSecrets(); + const identity = useSourceIdentity({ + fallbackName: props.initial.name, + fallbackNamespace: props.initial.namespace, + }); const [endpoint, setEndpoint] = useState(props.initial.config.endpoint); const [headers, setHeaders] = useState(() => Object.entries(props.initial.config.headers ?? {}).map(([name, value]) => @@ -39,8 +52,10 @@ function EditForm(props: { const [error, setError] = useState(null); const [dirty, setDirty] = useState(false); - const updateHeader = (index: number, update: Partial) => { - setHeaders((prev) => prev.map((h, i) => (i === index ? { ...h, ...update } : h))); + const identityDirty = identity.name.trim() !== props.initial.name.trim(); + + const handleHeadersChange = (next: HeaderState[]) => { + setHeaders(next); setDirty(true); }; @@ -51,6 +66,7 @@ function EditForm(props: { await doUpdate({ path: { scopeId, namespace: props.sourceId }, payload: { + name: identity.name.trim() || undefined, endpoint: endpoint.trim() || undefined, headers: headersFromState(headers), }, @@ -83,48 +99,31 @@ function EditForm(props: {
-
- - { - setEndpoint((e.target as HTMLInputElement).value); - setDirty(true); - }} - placeholder="https://api.example.com/graphql" - className="font-mono text-sm" - /> -
+ + + + + + { + setEndpoint((e.target as HTMLInputElement).value); + setDirty(true); + }} + placeholder="https://api.example.com/graphql" + className="font-mono text-sm" + /> + + +
- - {headers.map((h, i) => ( - updateHeader(i, update)} - onSelectSecret={(secretId) => updateHeader(i, { secretId })} - onRemove={() => { - setHeaders((prev) => prev.filter((_, j) => j !== i)); - setDirty(true); - }} - existingSecrets={secretList} - /> - ))} - + Headers +
{error && ( @@ -137,7 +136,7 @@ function EditForm(props: { -
diff --git a/packages/plugins/graphql/src/sdk/plugin.ts b/packages/plugins/graphql/src/sdk/plugin.ts index dd52340388..58abfe9f0c 100644 --- a/packages/plugins/graphql/src/sdk/plugin.ts +++ b/packages/plugins/graphql/src/sdk/plugin.ts @@ -45,6 +45,8 @@ export type HeaderValue = HeaderValueValue; export interface GraphqlSourceConfig { /** The GraphQL endpoint URL */ readonly endpoint: string; + /** Display name for the source. Falls back to namespace if not provided. */ + readonly name?: string; /** Optional: introspection JSON text (if endpoint doesn't support introspection) */ readonly introspectionJson?: string; /** Namespace for the tools (derived from endpoint if not provided) */ @@ -58,6 +60,7 @@ export interface GraphqlSourceConfig { // --------------------------------------------------------------------------- export interface GraphqlUpdateSourceInput { + readonly name?: string; readonly endpoint?: string; readonly headers?: Record; } @@ -87,6 +90,7 @@ export interface GraphqlPluginExtension { const AddSourceInputSchema = Schema.Struct({ endpoint: Schema.String, + name: Schema.optional(Schema.String), introspectionJson: Schema.optional(Schema.String), namespace: Schema.optional(Schema.String), headers: Schema.optional(Schema.Record({ key: Schema.String, value: HeaderValueSchema })), @@ -379,7 +383,7 @@ export const graphqlPlugin = (options?: { yield* operationStore.putSource({ namespace, - name: namespace, + name: config.name?.trim() || namespace, config: { endpoint: config.endpoint, introspectionJson: config.introspectionJson, @@ -457,7 +461,7 @@ export const graphqlPlugin = (options?: { yield* operationStore.putSource({ namespace, - name: existing.name, + name: input.name?.trim() || existing.name, config: updatedConfig, invocationConfig: newInvocationConfig, }); diff --git a/packages/plugins/mcp/src/api/group.ts b/packages/plugins/mcp/src/api/group.ts index b53e84b2cf..6828134755 100644 --- a/packages/plugins/mcp/src/api/group.ts +++ b/packages/plugins/mcp/src/api/group.ts @@ -70,6 +70,7 @@ const AddSourcePayload = Schema.Union(AddRemoteSourcePayload, AddStdioSourcePayl // --------------------------------------------------------------------------- const UpdateSourcePayload = Schema.Struct({ + name: Schema.optional(Schema.String), endpoint: Schema.optional(Schema.String), headers: Schema.optional(StringMap), queryParams: Schema.optional(StringMap), diff --git a/packages/plugins/mcp/src/api/handlers.ts b/packages/plugins/mcp/src/api/handlers.ts index 20f352b01a..a8f19f9229 100644 --- a/packages/plugins/mcp/src/api/handlers.ts +++ b/packages/plugins/mcp/src/api/handlers.ts @@ -236,6 +236,7 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand Effect.gen(function* () { const ext = yield* McpExtensionService; yield* ext.updateSource(path.namespace, { + name: payload.name, endpoint: payload.endpoint, headers: payload.headers, queryParams: payload.queryParams, diff --git a/packages/plugins/mcp/src/react/AddMcpSource.tsx b/packages/plugins/mcp/src/react/AddMcpSource.tsx index f7e59cf72a..6216b6e2b7 100644 --- a/packages/plugins/mcp/src/react/AddMcpSource.tsx +++ b/packages/plugins/mcp/src/react/AddMcpSource.tsx @@ -1,16 +1,39 @@ -import { useReducer, useCallback, useEffect, useRef, useState } from "react"; +import { useReducer, useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { useAtomSet } from "@effect-atom/atom-react"; import { useScope } from "@executor/react/api/scope-context"; import { Button } from "@executor/react/components/button"; +import { + CardStack, + CardStackContent, + CardStackEntry, + CardStackEntryActions, + CardStackEntryContent, + CardStackEntryDescription, + CardStackEntryField, + CardStackEntryMedia, + CardStackEntryTitle, +} from "@executor/react/components/card-stack"; +import { FieldError, FieldLabel } from "@executor/react/components/field"; +import { FilterTabs } from "@executor/react/components/filter-tabs"; +import { FloatActions } from "@executor/react/components/float-actions"; 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 { Skeleton } from "@executor/react/components/skeleton"; +import { SourceFavicon } from "@executor/react/components/source-favicon"; +import { IOSSpinner, Spinner } from "@executor/react/components/spinner"; import { Textarea } from "@executor/react/components/textarea"; -import { SecretHeaderAuthRow } from "@executor/react/plugins/secret-header-auth"; +import { HeadersList } from "@executor/react/plugins/headers-list"; +import { type HeaderState } from "@executor/react/plugins/secret-header-auth"; +import { + displayNameFromUrl, + SourceIdentityFields, + useSourceIdentity, +} from "@executor/react/plugins/source-identity"; import { useSecretPickerSecrets } from "@executor/react/plugins/use-secret-picker-secrets"; + +type RemoteAuthMode = "none" | "header" | "oauth2"; import { probeMcpEndpoint, addMcpSource, startMcpOAuth } from "./atoms"; import { mcpPresets, type McpPreset } from "../sdk/presets"; @@ -44,8 +67,6 @@ type ProbeResult = { serverName: string | null; }; -type RemoteAuthMode = "none" | "header" | "oauth2"; - type PlainHeader = { name: string; value: string; @@ -56,9 +77,19 @@ type State = | { 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-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: "adding"; + url: string; + probe: ProbeResult; + tokens: OAuthTokens | null; + } | { step: "error"; url: string; @@ -95,7 +126,13 @@ function reducer(state: State, action: Action): State { 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 }; + 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; @@ -116,7 +153,12 @@ function reducer(state: State, action: Action): State { case "oauth-ok": if (state.step !== "oauth-waiting") return state; - return { step: "oauth-done", url: state.url, probe: state.probe, tokens: action.tokens }; + 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; @@ -154,7 +196,12 @@ function reducer(state: State, action: Action): State { if (state.step !== "error") return state; return state.probe ? state.tokens - ? { step: "oauth-done", url: state.url, probe: state.probe, tokens: 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 }; } @@ -169,8 +216,17 @@ function reducer(state: State, action: Action): State { // --------------------------------------------------------------------------- type OAuthPopupResult = - | ({ type: "executor:oauth-result"; ok: true; sessionId: string } & OAuthTokens) - | { type: "executor:oauth-result"; ok: false; sessionId: null; error: string }; + | ({ + type: "executor:oauth-result"; + ok: true; + sessionId: string; + } & OAuthTokens) + | { + type: "executor:oauth-result"; + ok: false; + sessionId: null; + error: string; + }; const OAUTH_RESULT_CHANNEL = "executor:mcp-oauth-result"; @@ -246,7 +302,9 @@ export default function AddMcpSource(props: { isStdioPreset && preset.args ? preset.args.join(" ") : "", ); const [stdioEnv, setStdioEnv] = useState(""); - const [stdioName, setStdioName] = useState(isStdioPreset ? preset.name : ""); + const stdioIdentity = useSourceIdentity({ + fallbackName: isStdioPreset ? preset.name : stdioCommand, + }); const [stdioAdding, setStdioAdding] = useState(false); const [stdioError, setStdioError] = useState(null); @@ -268,27 +326,29 @@ export default function AddMcpSource(props: { 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 [remoteAuthHeaders, setRemoteAuthHeaders] = useState([ + { + 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; - const isIdle = state.step === "url"; + + const remoteIdentity = useSourceIdentity({ + fallbackName: + probe?.serverName ?? probe?.name ?? displayNameFromUrl(state.url) ?? "", + }); const isProbing = state.step === "probing"; const isAdding = state.step === "adding"; const isOAuthBusy = state.step === "oauth-starting" || state.step === "oauth-waiting"; const canUseNone = probe?.requiresOAuth !== true; - const headerAuthComplete = Boolean(remoteHeaderAuth.name.trim() && remoteHeaderAuth.secretId); + const remoteAuthHeader = remoteAuthHeaders[0]; + const headerAuthComplete = Boolean(remoteAuthHeader?.name.trim() && remoteAuthHeader?.secretId); const remoteHeadersComplete = remoteHeaders.every( (header) => header.name.trim() && header.value.trim(), ); @@ -299,7 +359,10 @@ export default function AddMcpSource(props: { ? headerAuthComplete : tokens !== null; const canAdd = Boolean(probe) && authReady && remoteHeadersComplete && !isAdding && !isOAuthBusy; - const error = state.step === "error" ? state.error : null; + // 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 ---- @@ -313,17 +376,30 @@ export default function AddMcpSource(props: { 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" }); + dispatch({ + type: "probe-fail", + error: e instanceof Error ? e.message : "Failed to connect", + }); } }, [state.url, scopeId, doProbe]); - const autoProbed = useRef(false); + // 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" && remoteUrl && !autoProbed.current) { - autoProbed.current = true; - handleProbe(); - } - }, []); // eslint-disable-line react-hooks/exhaustive-deps + 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 oauthCleanup = useRef<(() => void) | null>(null); @@ -380,13 +456,14 @@ export default function AddMcpSource(props: { if (!probe) return; dispatch({ type: "add-start" }); try { + const headerAuth = remoteAuthHeaders[0]; const auth = - remoteAuthMode === "header" + remoteAuthMode === "header" && headerAuth?.secretId ? { kind: "header" as const, - headerName: remoteHeaderAuth.name.trim(), - secretId: remoteHeaderAuth.secretId!, - ...(remoteHeaderAuth.prefix ? { prefix: remoteHeaderAuth.prefix } : {}), + headerName: headerAuth.name.trim(), + secretId: headerAuth.secretId, + ...(headerAuth.prefix ? { prefix: headerAuth.prefix } : {}), } : remoteAuthMode === "oauth2" && tokens ? { @@ -408,7 +485,8 @@ export default function AddMcpSource(props: { path: { scopeId }, payload: { transport: "remote" as const, - name: probe.serverName ?? probe.name, + name: remoteIdentity.name.trim() || probe.serverName || probe.name, + namespace: remoteIdentity.namespace.trim() || undefined, endpoint: state.url.trim(), auth, ...(Object.keys(headers).length > 0 ? { headers } : {}), @@ -424,8 +502,9 @@ export default function AddMcpSource(props: { }, [ probe, remoteAuthMode, - remoteHeaderAuth, + remoteAuthHeaders, remoteHeaders, + remoteIdentity, tokens, state.url, doAdd, @@ -468,7 +547,8 @@ export default function AddMcpSource(props: { path: { scopeId }, payload: { transport: "stdio" as const, - name: stdioName.trim() || cmd, + name: stdioIdentity.name.trim() || cmd, + namespace: stdioIdentity.namespace.trim() || undefined, command: cmd, args: parseStdioArgs(stdioArgs), env: parseStdioEnv(stdioEnv), @@ -479,15 +559,15 @@ export default function AddMcpSource(props: { setStdioError(e instanceof Error ? e.message : "Failed to add source"); setStdioAdding(false); } - }, [stdioCommand, stdioArgs, stdioEnv, stdioName, doAdd, scopeId, props]); + }, [stdioCommand, stdioArgs, stdioEnv, stdioIdentity, doAdd, scopeId, props]); // ---- Render ---- return ( -
+

Add MCP Source

-

+

Connect to an MCP server to discover and use its tools.

@@ -522,200 +602,147 @@ export default function AddMcpSource(props: { {transport === "remote" ? ( <> + {/* Server info card (shown above URL input after probing) */} + {probe ? ( + + + + + + + + {probe.serverName ?? probe.name} + + {probe.connected + ? `${probe.toolCount} tool${probe.toolCount !== 1 ? "s" : ""} available` + : "OAuth required to discover tools"} + + + + {probe.connected ? ( + + Connected + + ) : ( + + OAuth required + + )} + + + + + ) : isProbing ? ( + + + + + + + + + + + + + + + + + ) : null} + {/* URL input */} -
- -
- - 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 && ( - - )} -
-

- Supports Streamable HTTP and SSE transports. -

-
+
+ {probeError && {probeError}} + + + - {/* Server info card */} {probe && ( -
-
- - - - -
-
-

- {probe.serverName ?? probe.name} -

-

- {probe.connected - ? `${probe.toolCount} tool${probe.toolCount !== 1 ? "s" : ""} available` - : "OAuth required to discover tools"} -

-
- {probe.connected ? ( - - Connected - - ) : ( - - OAuth required - - )} -
+ )} {/* Authentication */} {probe && (
- - - setRemoteAuthMode(value as RemoteAuthMode)} - className="gap-1.5" - > - {!probe.requiresOAuth && ( - - )} - - - - {probe.requiresOAuth && ( - - )} - +
+ Authentication + + tabs={ + probe.requiresOAuth + ? [ + { value: "header", label: "Header" }, + { value: "oauth2", label: "OAuth" }, + ] + : [ + { value: "none", label: "None" }, + { value: "header", label: "Header" }, + ] + } + value={remoteAuthMode} + onChange={setRemoteAuthMode} + /> +
{remoteAuthMode === "header" && ( - - setRemoteHeaderAuth((current) => ({ - ...current, - ...update, - })) - } - onSelectSecret={(secretId) => - setRemoteHeaderAuth((current) => ({ - ...current, - secretId, - })) - } + )} - {probe.requiresOAuth && remoteAuthMode === "oauth2" && !tokens && ( + {remoteAuthMode === "oauth2" && ( <> - {state.step === "probed" && ( - )} - {state.step === "oauth-starting" && ( -
+ {!tokens && state.step === "oauth-starting" && ( +
- Starting authorization… + Starting authorization…
)} - {state.step === "oauth-waiting" && ( -
+ {!tokens && state.step === "oauth-waiting" && ( +
Waiting for authorization in popup… @@ -730,30 +757,24 @@ export default function AddMcpSource(props: {
)} - - )} - - {probe.requiresOAuth && remoteAuthMode === "oauth2" && tokens && ( -
- - - - - Authenticated - -
- )} - {remoteAuthMode === "none" && probe.requiresOAuth && ( -

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

+ {tokens && ( +
+ + + + + Authenticated + +
+ )} + )}
)} @@ -761,107 +782,110 @@ export default function AddMcpSource(props: { {/* Additional headers */} {probe && (
-
-
- -

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

-
- +
+ +

+ 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-sm 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-sm font-mono" - /> -
-
-
- ))} -
- )} + + + {remoteHeaders.length === 0 ? ( + No headers} + onClick={() => + setRemoteHeaders((headers) => [...headers, { name: "", value: "" }]) + } + /> + ) : ( + <> + {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" + /> +
+
+
+ ))} + + setRemoteHeaders((headers) => [...headers, { name: "", value: "" }]) + } + /> + + )} +
+
)} - {/* Error */} - {error && ( + {/* Error (OAuth / add source). Probe errors show inline on the field. */} + {otherError && (
-

{error}

+

{otherError}

+ + + {(probe || isProbing) && ( -
- )} - - {/* Cancel when nothing probed yet */} - {!probe && !isProbing && ( -
- -
-
- )} + )} + ) : ( <> {/* Stdio form */} -
-
- - setStdioCommand((e.target as HTMLInputElement).value)} - placeholder="npx" - className="font-mono text-sm" - /> -

- The executable to run (e.g. npx, uvx, node). -

-
+ + + + setStdioCommand((e.target as HTMLInputElement).value)} + placeholder="npx" + className="font-mono text-sm" + /> + -
- - setStdioArgs((e.target as HTMLInputElement).value)} - placeholder="-y chrome-devtools-mcp@latest" - className="font-mono text-sm" - /> -

- Space-separated arguments passed to the command. -

-
+ + setStdioArgs((e.target as HTMLInputElement).value)} + placeholder="-y chrome-devtools-mcp@latest" + className="font-mono text-sm" + /> + -
- - setStdioName((e.target as HTMLInputElement).value)} - placeholder="My MCP Server" - className="text-sm" - /> -
+ +