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
- Value
+ Value
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}
}
Cancel
@@ -115,21 +138,26 @@ function InlineCreateSecret(props: {
// Client secret field with inline creation
// ---------------------------------------------------------------------------
-function ClientSecretField(props: {
- clientSecretSecretId: string | null;
+function SecretBackedField(props: {
+ label: string;
+ suggestedSecretId: string;
+ headerName: string;
+ secretId: string | null;
onSelect: (secretId: string | null) => void;
secretList: readonly SecretPickerSecret[];
+ placeholder: string;
+ clearable?: boolean;
}) {
const [creating, setCreating] = useState(false);
- const { clientSecretSecretId, onSelect, secretList } = props;
+ const { label, secretId, onSelect, secretList, placeholder, clearable = true } = props;
if (creating) {
return (
-
OAuth Client Secret
+
{label}
{
onSelect(id);
setCreating(false);
@@ -142,20 +170,20 @@ function ClientSecretField(props: {
return (
-
OAuth Client Secret
+
{label}
setCreating(true)}>
+ New
- {clientSecretSecretId && (
+ {clearable && secretId && (
onSelect(null)}>
Clear
@@ -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.
-
-
- Presets
-
- Select a Google API to prefill the source.
-
-
-
- {GOOGLE_DISCOVERY_TEMPLATES.map((template) => {
- const selected = template.id === selectedTemplateId;
- return (
-
applyTemplate(template)}
- className={`relative h-auto rounded-xl border px-4 py-3 text-left transition-colors ${
- selected
- ? "border-primary bg-primary/5 shadow-[0_0_0_1px_rgba(0,0,0,0.02)]"
- : "border-border bg-card hover:border-primary/30 hover:bg-card/80"
- }`}
- >
- {selected && (
-
- Selected
-
- )}
-
-
-
-
-
-
{template.name}
-
{template.summary}
-
-
-
-
- {template.service} · {template.version}
-
-
+
+
+ Presets
+ Select a Google API to prefill the source.
+ {
+ const template = GOOGLE_DISCOVERY_TEMPLATES.find((t) => t.id === value);
+ if (template) applyTemplate(template);
+ }}
+ className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3"
+ >
+ {GOOGLE_DISCOVERY_TEMPLATES.map((template) => {
+ const inputId = `google-discovery-preset-${template.id}`;
+ return (
+
+
+
+
+ {template.name}
+
+ {template.summary}
+
+
+
+
+
+ );
+ })}
+
+
+
+
+
+
+
+
+
{
+ setSelectedTemplateId("");
+ setDiscoveryUrl((e.target as HTMLInputElement).value);
+ setProbe(null);
+ setOauthAuth(null);
+ setError(null);
+ }}
+ placeholder="https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest"
+ className="w-full pr-9 font-mono text-sm"
+ />
+ {loadingProbe && (
+
+
-
- );
- })}
-
-
+ )}
+
+
-
+
+
-
+
{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"
- >
-
-
-
- No auth
-
-
-
-
-
- OAuth 2.0
-
-
-
+
+
+ Authentication
+
+ tabs={[
+ { value: "none", label: "None" },
+ { value: "oauth2", label: "OAuth" },
+ ]}
+ value={authKind}
+ onChange={setAuthKind}
+ />
+
{authKind === "oauth2" && (
-
- OAuth Client ID
- 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: {
{startingOAuth ? (
<>
@@ -782,7 +819,7 @@ export default function AddGoogleDiscoverySource(props: {
{(probe?.scopes ?? []).map((scope) => (
{scope}
@@ -806,14 +843,15 @@ export default function AddGoogleDiscoverySource(props: {
)}
-
-
+
+
Cancel
+ {adding && }
{adding ? "Adding…" : "Add Source"}
-
+
);
}
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 */}
-
-
- {/* Namespace */}
-
-
- {/* Authentication */}
-
-
-
-
- Authentication (optional)
-
-
- Secret-backed headers sent with every request, including introspection.
-
-
-
+
+
- + Add header
-
-
+ 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}
- />
- ))}
-
- )}
+
{/* Error */}
{addError && (
-
{addError}
+
{addError}
)}
- {/* Actions */}
-
+
Cancel
@@ -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"
+ />
+
+
+
- Headers
- {headers.map((h, i) => (
- updateHeader(i, update)}
- onSelectSecret={(secretId) => updateHeader(i, { secretId })}
- onRemove={() => {
- setHeaders((prev) => prev.filter((_, j) => j !== i));
- setDirty(true);
- }}
- existingSecrets={secretList}
- />
- ))}
- {
- setHeaders((prev) => [...prev, { name: "", secretId: null }]);
- setDirty(true);
- }}
- >
- + Add header
-
+ Headers
+
{error && (
@@ -137,7 +136,7 @@ function EditForm(props: {
Cancel
-
+
{saving ? "Saving…" : "Save changes"}
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 */}
-
- Server URL
-
-
- 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 && (
-
- {isProbing ? (
- <>
- Connecting…
- >
- ) : (
- "Connect"
+
+
+
+
+
+ dispatch({
+ type: "set-url",
+ url: (e.target as HTMLInputElement).value,
+ })
+ }
+ placeholder="https://mcp.example.com"
+ className="w-full pr-9 font-mono text-sm"
+ aria-invalid={probeError ? true : undefined}
+ />
+ {isProbing && (
+
+
+
)}
-
- )}
-
-
- 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 && (
- Authentication
-
- setRemoteAuthMode(value as RemoteAuthMode)}
- className="gap-1.5"
- >
- {!probe.requiresOAuth && (
-
-
- None
-
- no auth header
-
-
- )}
-
-
-
- Header
-
- use a secret-backed auth header
-
-
-
- {probe.requiresOAuth && (
-
-
- OAuth
-
- sign in with the server's OAuth flow
-
-
- )}
-
+
+ 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" && (
-
-
-
-
-
- Sign in with OAuth
+ {!tokens && state.step === "probed" && (
+
+ Sign in
)}
- {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 && (
-
-
-
Additional headers
-
- Plaintext headers sent with every request. Use authentication for secret-backed
- auth headers.
-
-
-
- setRemoteHeaders((headers) => [...headers, { name: "", value: "" }])
- }
- >
- + Add header
-
+
+
Additional headers
+
+ Plaintext headers sent with every request. Use authentication for secret-backed
+ auth headers.
+
- {remoteHeaders.length > 0 && (
-
- {remoteHeaders.map((header, index) => (
-
-
-
- Header
-
-
- setRemoteHeaders((headers) =>
- headers.filter((_, headerIndex) => headerIndex !== index),
- )
- }
- >
- Remove
-
-
-
-
- ))}
-
- )}
+
+
+ {remoteHeaders.length === 0 ? (
+ No headers}
+ onClick={() =>
+ setRemoteHeaders((headers) => [...headers, { name: "", value: "" }])
+ }
+ />
+ ) : (
+ <>
+ {remoteHeaders.map((header, index) => (
+
+
+
+ Header
+
+
+ setRemoteHeaders((headers) =>
+ headers.filter((_, headerIndex) => headerIndex !== index),
+ )
+ }
+ >
+ Remove
+
+
+
+
+
+ Name
+
+
+ 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"
+ />
+
+
+
+ Value
+
+
+ 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 && (
)}
- {/* Actions */}
- {(probe || isProbing) && (
-
-
- Cancel
-
+
+
+ Cancel
+
+ {(probe || isProbing) && (
{isAdding ? (
<>
@@ -889,86 +912,67 @@ export default function AddMcpSource(props: {
"Add source"
)}
-
- )}
-
- {/* Cancel when nothing probed yet */}
- {!probe && !isProbing && (
-
- )}
+ )}
+
>
) : (
<>
{/* Stdio form */}
-
+
{/* Stdio error */}
{stdioError && (
-
{stdioError}
+
{stdioError}
)}
- {/* Stdio actions */}
-
+
Cancel
@@ -981,9 +985,35 @@ export default function AddMcpSource(props: {
"Add source"
)}
-
+
>
)}
);
}
+
+function AddPlainHeaderRow({
+ onClick,
+ leading,
+}: {
+ readonly onClick: () => void;
+ readonly leading?: ReactNode;
+}) {
+ return (
+ // oxlint-disable-next-line react/forbid-elements
+
{
+ event.stopPropagation();
+ onClick();
+ }}
+ aria-label="Add header"
+ className="flex w-full items-center justify-between gap-4 px-4 py-3 text-sm text-muted-foreground outline-none transition-[background-color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] hover:bg-accent/40 focus-visible:bg-accent/40"
+ >
+ {leading}
+
+
+
+
+ );
+}
diff --git a/packages/plugins/mcp/src/react/EditMcpSource.tsx b/packages/plugins/mcp/src/react/EditMcpSource.tsx
index a89591d487..cba1c9d8d2 100644
--- a/packages/plugins/mcp/src/react/EditMcpSource.tsx
+++ b/packages/plugins/mcp/src/react/EditMcpSource.tsx
@@ -2,7 +2,16 @@ import { useState } from "react";
import { useAtomValue, useAtomSet, useAtomRefresh, Result } from "@effect-atom/atom-react";
import { mcpSourceAtom, updateMcpSource } from "./atoms";
import { useScope } from "@executor/react/api/scope-context";
+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 { Input } from "@executor/react/components/input";
import { Label } from "@executor/react/components/label";
import { Badge } from "@executor/react/components/badge";
@@ -30,6 +39,10 @@ function RemoteEditForm(props: {
const doUpdate = useAtomSet(updateMcpSource, { mode: "promise" });
const refreshSource = useAtomRefresh(mcpSourceAtom(scopeId, props.sourceId));
+ const identity = useSourceIdentity({
+ fallbackName: props.initial.name,
+ fallbackNamespace: props.initial.namespace,
+ });
const [endpoint, setEndpoint] = useState(props.initial.config.endpoint);
const [headerEntries, setHeaderEntries] = useState
(() =>
Object.entries(props.initial.config.headers ?? {}).map(([name, value]) => ({
@@ -41,6 +54,8 @@ function RemoteEditForm(props: {
const [error, setError] = useState(null);
const [dirty, setDirty] = useState(false);
+ const identityDirty = identity.name.trim() !== props.initial.name.trim();
+
const updateHeader = (index: number, field: "name" | "value", val: string) => {
setHeaderEntries((prev) =>
prev.map((entry, i) => (i === index ? { ...entry, [field]: val } : entry)),
@@ -71,6 +86,7 @@ function RemoteEditForm(props: {
await doUpdate({
path: { scopeId, namespace: props.sourceId },
payload: {
+ name: identity.name.trim() || undefined,
endpoint: endpoint.trim() || undefined,
headers: headersObj,
},
@@ -103,19 +119,24 @@ function RemoteEditForm(props: {
+
+
{/* Endpoint */}
-
+
+
+
+ {
+ setEndpoint((e.target as HTMLInputElement).value);
+ setDirty(true);
+ }}
+ placeholder="https://mcp.example.com"
+ className="font-mono text-sm"
+ />
+
+
+
{/* Headers */}
@@ -159,7 +180,7 @@ function RemoteEditForm(props: {
Cancel
-
+
{saving ? "Saving…" : "Save changes"}
diff --git a/packages/plugins/mcp/src/sdk/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts
index 69b7e8c6c4..3f0bfb30de 100644
--- a/packages/plugins/mcp/src/sdk/plugin.ts
+++ b/packages/plugins/mcp/src/sdk/plugin.ts
@@ -92,6 +92,7 @@ export interface McpProbeResult {
}
export interface McpUpdateSourceInput {
+ readonly name?: string;
readonly endpoint?: string;
readonly headers?: Record
;
readonly queryParams?: Record;
@@ -717,7 +718,7 @@ export const mcpPlugin = (options?: {
yield* bindingStore.putSource({
namespace,
- name: existing.name,
+ name: input.name?.trim() || existing.name,
config: updatedConfig,
});
});
diff --git a/packages/plugins/onepassword/src/react/OnePasswordSettings.tsx b/packages/plugins/onepassword/src/react/OnePasswordSettings.tsx
index a5eb2daf98..04f1daa994 100644
--- a/packages/plugins/onepassword/src/react/OnePasswordSettings.tsx
+++ b/packages/plugins/onepassword/src/react/OnePasswordSettings.tsx
@@ -21,6 +21,12 @@ import {
DialogFooter,
DialogClose,
} from "@executor/react/components/dialog";
+import {
+ CardStackEntry,
+ CardStackEntryActions,
+ CardStackEntryContent,
+ CardStackEntryDescription,
+} from "@executor/react/components/card-stack";
import {
onepasswordConfigAtom,
@@ -74,7 +80,7 @@ function VaultPicker(props: {
if (!account) {
return (
-
+
Enter account details to load vaults.
);
@@ -90,7 +96,7 @@ function VaultPicker(props: {
if (v) props.onVaultSelect(v.id, v.name);
}}
>
-
+
@@ -103,7 +109,7 @@ function VaultPicker(props: {
{error && (
@@ -182,7 +188,7 @@ function ConfigDialog(props: {
{isEdit ? "Edit 1Password" : "Connect 1Password"}
-
+
Link a vault to resolve secrets via the 1Password desktop app or a service account.
@@ -190,14 +196,14 @@ function ConfigDialog(props: {
{/* Auth method */}
-
+
Auth method
setAuthKind(v as "desktop-app" | "service-account")}
>
-
+
@@ -209,16 +215,16 @@ function ConfigDialog(props: {
{/* Account / token */}
-
+
{authKind === "desktop-app" ? "Account domain" : "Token secret ID"}
setAccountName((e.target as HTMLInputElement).value)}
- className="font-mono text-xs h-9"
+ className="font-mono text-[13px] h-9"
/>
-
+
{authKind === "desktop-app"
? "Requires the 1Password desktop app with biometric unlock."
: "Reference an executor secret that holds the service account token."}
@@ -227,7 +233,7 @@ function ConfigDialog(props: {
{/* Vault */}
-
+
Vault
- {vaultId && {vaultId}
}
+ {vaultId && {vaultId}
}
{/* Display name */}
-
+
Display name
setVaultName((e.target as HTMLInputElement).value)}
- className="text-sm h-9"
+ className="text-[13px] h-9"
/>
{error && (
)}
@@ -317,92 +323,70 @@ export default function OnePasswordSettings() {
});
return (
-
- {/* Header */}
-
-
-
-
1Password
- {isLoading ? (
-
- ) : isError ? (
-
- Error
-
- ) : config ? (
-
- Connected
-
- ) : (
-
- Not configured
-
- )}
-
-
- {config && (
-
- setConfigOpen(true)}
- >
- Edit
-
-
- Disconnect
-
-
- )}
-
-
- {/* Body */}
-
- {isLoading ? (
-
- ) : isError ? (
-
-
Failed to load configuration
-
- ) : config ? (
-
-
Auth
-
- {config.auth.kind === "desktop-app" ? config.auth.accountName : "service-account"}
-
-
Vault
-
-
{config.name}
-
- {config.vaultId}
+ <>
+
+
+ {isLoading ? (
+ Loading…
+ ) : isError ? (
+
+ Failed to load configuration
+
+ ) : config ? (
+
+
Auth
+
+ {config.auth.kind === "desktop-app" ? config.auth.accountName : "service-account"}
+
Vault
+
+ {config.name}
+
+ {config.vaultId}
+
+
-
- ) : (
-
-
+ ) : (
+
Resolve secrets from your 1Password vault.
-
-
setConfigOpen(true)}
- >
- Connect
-
-
- )}
-
+
+ )}
+
+
+ {config ? (
+ <>
+ setConfigOpen(true)}
+ >
+ Edit
+
+
+ Disconnect
+
+ >
+ ) : (
+ !isLoading &&
+ !isError && (
+ setConfigOpen(true)}
+ >
+ Add 1Password
+
+ )
+ )}
+
+
{configOpen && (
)}
-
+ >
);
}
diff --git a/packages/plugins/openapi/src/api/group.ts b/packages/plugins/openapi/src/api/group.ts
index 092d931c6c..4037a9f436 100644
--- a/packages/plugins/openapi/src/api/group.ts
+++ b/packages/plugins/openapi/src/api/group.ts
@@ -30,6 +30,7 @@ const PreviewSpecPayload = Schema.Struct({
});
const UpdateSourcePayload = Schema.Struct({
+ name: Schema.optional(Schema.String),
baseUrl: Schema.optional(Schema.String),
headers: Schema.optional(Schema.Record({ key: Schema.String, value: Schema.Unknown })),
});
diff --git a/packages/plugins/openapi/src/api/handlers.ts b/packages/plugins/openapi/src/api/handlers.ts
index ad4f28c856..5fd814e72f 100644
--- a/packages/plugins/openapi/src/api/handlers.ts
+++ b/packages/plugins/openapi/src/api/handlers.ts
@@ -58,6 +58,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope
Effect.gen(function* () {
const ext = yield* OpenApiExtensionService;
yield* ext.updateSource(path.namespace, {
+ name: payload.name,
baseUrl: payload.baseUrl,
headers: payload.headers as Record
| undefined,
} as OpenApiUpdateSourceInput);
diff --git a/packages/plugins/openapi/src/react/AddOpenApiSource.tsx b/packages/plugins/openapi/src/react/AddOpenApiSource.tsx
index 9759d5e260..fb458a8071 100644
--- a/packages/plugins/openapi/src/react/AddOpenApiSource.tsx
+++ b/packages/plugins/openapi/src/react/AddOpenApiSource.tsx
@@ -1,20 +1,36 @@
-import { useState, useEffect, useRef } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
import { useAtomSet } from "@effect-atom/atom-react";
import { Option } from "effect";
import { useScope } from "@executor/react/api/scope-context";
+import { HeadersList } from "@executor/react/plugins/headers-list";
import {
- SecretHeaderAuthRow,
- defaultHeaderAuthPresets,
+ matchPresetKey,
+ type HeaderState,
} from "@executor/react/plugins/secret-header-auth";
+import {
+ 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,
+ CardStackEntry,
+ CardStackEntryContent,
+ CardStackEntryDescription,
+ CardStackEntryField,
+ CardStackEntryTitle,
+} 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 { Textarea } from "@executor/react/components/textarea";
-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 { IOSSpinner, Spinner } from "@executor/react/components/spinner";
import { previewOpenApiSpec, addOpenApiSpec } from "./atoms";
import type { SpecPreview, HeaderPreset } from "../sdk/preview";
import type { HeaderValue } from "../sdk/types";
@@ -32,20 +48,12 @@ function prefixForHeader(preset: HeaderPreset, headerName: string): string | und
return undefined;
}
-function matchPresetKey(name: string, prefix?: string): string {
- 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) {
+function entriesFromSpecPreset(preset: HeaderPreset): HeaderState[] {
return preset.secretHeaders.map((headerName) => {
const prefix = prefixForHeader(preset, headerName);
return {
name: headerName,
- secretId: null as string | null,
+ secretId: null,
prefix,
presetKey: matchPresetKey(headerName, prefix),
fromPreset: true,
@@ -71,20 +79,16 @@ export default function AddOpenApiSource(props: {
// After analysis
const [preview, setPreview] = useState(null);
const [baseUrl, setBaseUrl] = useState("");
- const [namespace, setNamespace] = useState(props.initialNamespace ?? "");
- const [sourceName, setSourceName] = useState("");
+ const identity = useSourceIdentity({
+ fallbackName: preview ? Option.getOrElse(preview.title, () => "") : "",
+ fallbackNamespace: props.initialNamespace,
+ });
// Auth
- const [presetIndex, setPresetIndex] = useState(0);
- const [customHeaders, setCustomHeaders] = useState<
- Array<{
- name: string;
- secretId: string | null;
- prefix?: string;
- presetKey?: string;
- fromPreset?: boolean;
- }>
- >([]);
+ // `selectedStrategy` is an index into `preview.headerPresets`, or -1 for
+ // "None", or -2 for "Custom" (user-managed headers, no spec preset).
+ const [selectedStrategy, setSelectedStrategy] = useState(-1);
+ const [customHeaders, setCustomHeaders] = useState([]);
// Submit
const [adding, setAdding] = useState(false);
@@ -94,21 +98,47 @@ export default function AddOpenApiSource(props: {
const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promise" });
const doAdd = useAtomSet(addOpenApiSpec, { mode: "promise" });
const secretList = useSecretPickerSecrets();
- const autoAnalyzed = useRef(false);
+ // Keep the latest handleAnalyze in a ref so the debounced effect doesn't
+ // need it as a dependency (it closes over fresh state).
+ const handleAnalyzeRef = useRef<() => void>(() => {});
+
+ // Auto-analyze whenever the spec input changes, with a short debounce so
+ // typing/pasting doesn't fire a request on every keystroke.
useEffect(() => {
- if (props.initialUrl && !autoAnalyzed.current) {
- autoAnalyzed.current = true;
- handleAnalyze();
- }
- }, []); // eslint-disable-line react-hooks/exhaustive-deps
+ const trimmed = specUrl.trim();
+ if (!trimmed) return;
+ if (preview) return;
+ const handle = setTimeout(() => {
+ handleAnalyzeRef.current();
+ }, 400);
+ return () => clearTimeout(handle);
+ }, [specUrl, preview]);
// ---- Derived state ----
- const presets = preview?.headerPresets ?? [];
- const hasAuth = presets.length > 0;
const servers = (preview?.servers ?? []) as Array<{ url?: string }>;
+ // Derive a favicon URL from the spec URL (if the user entered one — raw
+ // JSON/YAML content will fail URL parsing and yield null). Uses Google's
+ // favicon service so we don't depend on the domain serving /favicon.ico.
+ const faviconUrl = useMemo(() => {
+ try {
+ const trimmed = specUrl.trim();
+ if (!trimmed) return null;
+ const u = new URL(trimmed);
+ if (u.protocol !== "http:" && u.protocol !== "https:") return null;
+ return `https://www.google.com/s2/favicons?domain=${u.hostname}&sz=64`;
+ } catch {
+ return null;
+ }
+ }, [specUrl]);
+
+ const [faviconFailed, setFaviconFailed] = useState(false);
+ useEffect(() => {
+ setFaviconFailed(false);
+ }, [faviconUrl]);
+
const allHeaders: Record = {};
for (const ch of customHeaders) {
if (ch.name.trim() && ch.secretId) {
@@ -140,28 +170,17 @@ export default function AddOpenApiSource(props: {
});
setPreview(result);
- // Derive defaults from the title
- const title = Option.getOrElse(result.title, () => "api");
- if (!sourceName) setSourceName(title);
- if (!props.initialNamespace) {
- setNamespace(
- title
- .toLowerCase()
- .replace(/[^a-z0-9]+/g, "_")
- .replace(/^_+|_+$/g, "") || "api",
- );
- }
-
const firstUrl = (result.servers as Array<{ url?: string }>)?.[0]?.url;
if (firstUrl) setBaseUrl(firstUrl);
- const newPresetIndex = result.headerPresets.length > 0 ? 0 : -1;
- setPresetIndex(newPresetIndex);
- setCustomHeaders(
- newPresetIndex >= 0
- ? presetEntriesFromHeaderPreset(result.headerPresets[newPresetIndex])
- : [],
- );
+ const firstPreset = result.headerPresets[0];
+ if (firstPreset) {
+ setSelectedStrategy(0);
+ setCustomHeaders(entriesFromSpecPreset(firstPreset));
+ } else {
+ setSelectedStrategy(-1);
+ setCustomHeaders([]);
+ }
} catch (e) {
setAnalyzeError(e instanceof Error ? e.message : "Failed to parse spec");
} finally {
@@ -169,41 +188,33 @@ export default function AddOpenApiSource(props: {
}
};
- const selectPreset = (index: number) => {
- setPresetIndex(index);
+ handleAnalyzeRef.current = handleAnalyze;
+
+ const selectStrategy = (index: number) => {
+ setSelectedStrategy(index);
if (index === -1) {
- // "None" — clear everything
setCustomHeaders([]);
- } else if (index === -2) {
- // "Custom" — keep user headers, drop preset-derived, seed if empty
- const userHeaders = customHeaders.filter((h) => !h.fromPreset);
- setCustomHeaders(
- userHeaders.length > 0 ? userHeaders : [{ name: "", secretId: null, presetKey: undefined }],
- );
- } else {
- // Preset strategy — replace preset-derived headers, keep user headers
- const preset = presets[index];
+ return;
+ }
+ if (index === -2) {
+ // Drop preset-derived headers, keep user headers (seed one if empty).
const userHeaders = customHeaders.filter((h) => !h.fromPreset);
- setCustomHeaders(
- preset ? [...presetEntriesFromHeaderPreset(preset), ...userHeaders] : userHeaders,
- );
+ setCustomHeaders(userHeaders.length > 0 ? userHeaders : []);
+ return;
}
+ const preset = preview?.headerPresets[index];
+ if (!preset) return;
+ const userHeaders = customHeaders.filter((h) => !h.fromPreset);
+ setCustomHeaders([...entriesFromSpecPreset(preset), ...userHeaders]);
};
- const addCustomHeader = () => {
- if (presetIndex === -1) setPresetIndex(-2);
- setCustomHeaders([...customHeaders, { name: "", secretId: null, presetKey: undefined }]);
- };
-
- const updateCustomHeader = (
- index: number,
- update: Partial<{ name: string; secretId: string | null; prefix?: string; presetKey?: string }>,
- ) => {
- setCustomHeaders(customHeaders.map((ch, i) => (i === index ? { ...ch, ...update } : ch)));
- };
-
- const removeCustomHeader = (index: number) => {
- setCustomHeaders(customHeaders.filter((_, i) => i !== index));
+ const handleHeadersChange = (next: HeaderState[]) => {
+ setCustomHeaders(next);
+ // If user drops all preset-derived headers and adds their own, mark as
+ // Custom so the strategy picker reflects it.
+ if (selectedStrategy >= 0 && next.every((h) => !h.fromPreset)) {
+ setSelectedStrategy(next.length === 0 ? -1 : -2);
+ }
};
const handleAdd = async () => {
@@ -214,8 +225,8 @@ export default function AddOpenApiSource(props: {
path: { scopeId },
payload: {
spec: specUrl,
- name: sourceName.trim() || undefined,
- namespace: namespace.trim() || undefined,
+ name: identity.name.trim() || undefined,
+ namespace: identity.namespace.trim() || undefined,
baseUrl: baseUrl.trim() || undefined,
...(hasHeaders ? { headers: allHeaders } : {}),
},
@@ -230,274 +241,227 @@ export default function AddOpenApiSource(props: {
// ---- Render ----
return (
-
+
Add OpenAPI Source
{/* ── Spec input ── */}
-
+
+
+
+
+
+
+
+
+ {/* ── Title card (shown below spec input after analysis) ── */}
+ {preview ? (
+
+
+
+ {faviconUrl && !faviconFailed && (
+ setFaviconFailed(true)}
+ />
+ )}
+
+
+ {Option.getOrElse(preview.title, () => "API")}
+
+
+ {Option.getOrElse(preview.version, () => "")}
+ {Option.isSome(preview.version) && " · "}
+ {preview.operationCount} operation
+ {preview.operationCount !== 1 ? "s" : ""}
+ {preview.tags.length > 0 &&
+ ` · ${preview.tags.length} tag${preview.tags.length !== 1 ? "s" : ""}`}
+
+
+
+
+
+ ) : analyzing ? (
+
+
+
+
+
+
+
+
+
+
+
+ ) : null}
+
+ {analyzeError && (
+
+ )}
{/* ── Everything below appears after analysis ── */}
{preview && (
<>
- {/* API info */}
-
-
-
- {Option.getOrElse(preview.title, () => "API")}
-
-
- {Option.getOrElse(preview.version, () => "")}
- {Option.isSome(preview.version) && " · "}
- {preview.operationCount} operation{preview.operationCount !== 1 ? "s" : ""}
- {preview.tags.length > 0 &&
- ` · ${preview.tags.length} tag${preview.tags.length !== 1 ? "s" : ""}`}
-
-
- {preview.tags.length > 0 && (
-
- {preview.tags.slice(0, 4).map((tag) => (
-
- {tag}
-
- ))}
- {preview.tags.length > 4 && (
-
- +{preview.tags.length - 4}
-
- )}
-
- )}
-
-
- {/* Name */}
-
-
- {/* Namespace */}
-
+
{/* Base URL */}
-
+ {!baseUrl.trim() && (
+
+ A base URL is required to make requests.
+
+ )}
+
+
+
- {/* Authentication */}
- Authentication
-
- {/* Strategy picker */}
- {hasAuth && (
+ Authentication
+ {preview.headerPresets.length > 0 && (
selectPreset(Number(v))}
+ value={String(selectedStrategy)}
+ onValueChange={(value) => selectStrategy(Number(value))}
className="gap-1.5"
>
- {presets.map((preset, i) => (
+ {preview.headerPresets.map((preset, i) => (
-
- {preset.label}
- {preset.secretHeaders.length > 0 && (
-
- {preset.secretHeaders.length} header
- {preset.secretHeaders.length > 1 ? "s" : ""}
-
- )}
+
+
+
{preset.label}
+ {preset.secretHeaders.length > 0 && (
+
+ {preset.secretHeaders.join(" · ")}
+
+ )}
+
))}
-
Custom
-
- configure manually
-
-
None
- skip auth
)}
-
- {/* All headers — preset-derived and user-added (hidden when None) */}
- {presetIndex !== -1 && customHeaders.length > 0 && (
-
- {customHeaders.map((ch, i) => (
- updateCustomHeader(i, update)}
- onSelectSecret={(secretId) => updateCustomHeader(i, { secretId })}
- onRemove={() => removeCustomHeader(i)}
- existingSecrets={secretList}
- />
- ))}
-
- )}
-
- {(!hasAuth || presetIndex === -2) && (
-
- + Add header
-
+ {(preview.headerPresets.length === 0 || selectedStrategy !== -1) && (
+
)}
{/* Add error */}
{addError && (
-
{addError}
+
{addError}
)}
-
- {/* Actions */}
-
-
- Cancel
-
-
- {adding && }
- {adding ? "Adding…" : "Add source"}
-
-
>
)}
- {/* Cancel when no preview yet */}
- {!preview && (
-
-
- Cancel
+
+
+ Cancel
+
+ {preview && (
+
+ {adding && }
+ {adding ? "Adding…" : "Add source"}
-
-
- )}
+ )}
+
);
}
diff --git a/packages/plugins/openapi/src/react/EditOpenApiSource.tsx b/packages/plugins/openapi/src/react/EditOpenApiSource.tsx
index 8fa6c3d171..7ce70234b6 100644
--- a/packages/plugins/openapi/src/react/EditOpenApiSource.tsx
+++ b/packages/plugins/openapi/src/react/EditOpenApiSource.tsx
@@ -4,14 +4,23 @@ import { openApiSourceAtom, updateOpenApiSource } 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(openApiSourceAtom(scopeId, props.sourceId));
const secretList = useSecretPickerSecrets();
+ const identity = useSourceIdentity({
+ fallbackName: props.initial.name,
+ fallbackNamespace: props.initial.namespace,
+ });
const [baseUrl, setBaseUrl] = useState(props.initial.config.baseUrl ?? "");
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,
baseUrl: baseUrl.trim() || undefined,
headers: headersFromState(headers),
},
@@ -83,48 +99,31 @@ function EditForm(props: {
-
+
+
+
+
+
+ {
+ setBaseUrl((e.target as HTMLInputElement).value);
+ setDirty(true);
+ }}
+ placeholder="https://api.example.com"
+ className="font-mono text-sm"
+ />
+
+
+
- Headers
- {headers.map((h, i) => (
- updateHeader(i, update)}
- onSelectSecret={(secretId) => updateHeader(i, { secretId })}
- onRemove={() => {
- setHeaders((prev) => prev.filter((_, j) => j !== i));
- setDirty(true);
- }}
- existingSecrets={secretList}
- />
- ))}
- {
- setHeaders((prev) => [...prev, { name: "", secretId: null }]);
- setDirty(true);
- }}
- >
- + Add header
-
+ Headers
+
{error && (
@@ -137,7 +136,7 @@ function EditForm(props: {
Cancel
-
+
{saving ? "Saving…" : "Save changes"}
diff --git a/packages/plugins/openapi/src/sdk/index.ts b/packages/plugins/openapi/src/sdk/index.ts
index 3da80ed1ad..5daa454ce5 100644
--- a/packages/plugins/openapi/src/sdk/index.ts
+++ b/packages/plugins/openapi/src/sdk/index.ts
@@ -10,7 +10,14 @@ export {
} from "./operation-store";
export { makeKvOperationStore, makeInMemoryOperationStore } from "./kv-operation-store";
export { withConfigFile } from "./config-file-store";
-export { previewSpec, SecurityScheme, AuthStrategy, HeaderPreset, SpecPreview } from "./preview";
+export {
+ previewSpec,
+ SecurityScheme,
+ AuthStrategy,
+ HeaderPreset,
+ PreviewOperation,
+ SpecPreview,
+} from "./preview";
export { DocResolver, resolveBaseUrl, preferredContent } from "./openapi-utils";
export { OpenApiParseError, OpenApiExtractionError, OpenApiInvocationError } from "./errors";
diff --git a/packages/plugins/openapi/src/sdk/plugin.ts b/packages/plugins/openapi/src/sdk/plugin.ts
index 5e57306f84..aa84ee144f 100644
--- a/packages/plugins/openapi/src/sdk/plugin.ts
+++ b/packages/plugins/openapi/src/sdk/plugin.ts
@@ -50,6 +50,7 @@ export interface OpenApiSpecConfig {
// ---------------------------------------------------------------------------
export interface OpenApiUpdateSourceInput {
+ readonly name?: string;
readonly baseUrl?: string;
readonly headers?: Record;
}
@@ -363,7 +364,7 @@ export const openApiPlugin = (options?: {
yield* operationStore.putSource({
namespace,
- name: existing.name,
+ name: input.name?.trim() || existing.name,
config: updatedConfig,
invocationConfig: newInvocationConfig,
});
diff --git a/packages/plugins/openapi/src/sdk/preview.ts b/packages/plugins/openapi/src/sdk/preview.ts
index afa291291e..3fa98440f9 100644
--- a/packages/plugins/openapi/src/sdk/preview.ts
+++ b/packages/plugins/openapi/src/sdk/preview.ts
@@ -3,7 +3,7 @@ import { Schema } from "effect";
import { parse } from "./parse";
import { extract } from "./extract";
-import type { ExtractionResult } from "./types";
+import { HttpMethod, type ExtractionResult } from "./types";
// ---------------------------------------------------------------------------
// Security scheme — what the spec declares it needs
@@ -45,6 +45,19 @@ export class HeaderPreset extends Schema.Class("HeaderPreset")({
secretHeaders: Schema.Array(Schema.String),
}) {}
+// ---------------------------------------------------------------------------
+// Preview operation — lightweight shape for the add-source UI list
+// ---------------------------------------------------------------------------
+
+export class PreviewOperation extends Schema.Class("PreviewOperation")({
+ operationId: Schema.String,
+ method: HttpMethod,
+ path: Schema.String,
+ summary: Schema.optionalWith(Schema.String, { as: "Option" }),
+ tags: Schema.Array(Schema.String),
+ deprecated: Schema.Boolean,
+}) {}
+
// ---------------------------------------------------------------------------
// Spec preview — everything the frontend needs
// ---------------------------------------------------------------------------
@@ -55,6 +68,8 @@ export class SpecPreview extends Schema.Class("SpecPreview")({
/** Reuses ServerInfo from extraction */
servers: Schema.Array(Schema.Unknown),
operationCount: Schema.Number,
+ /** Lightweight operation list for the add-source UI */
+ operations: Schema.Array(PreviewOperation),
tags: Schema.Array(Schema.String),
securitySchemes: Schema.Array(SecurityScheme),
/** Valid auth strategies (each is a set of schemes used together) */
@@ -172,6 +187,17 @@ export const previewSpec = Effect.fn("OpenApi.previewSpec")(function* (specText:
version: result.version,
servers: result.servers as unknown as readonly unknown[],
operationCount: result.operations.length,
+ operations: result.operations.map(
+ (op) =>
+ new PreviewOperation({
+ operationId: op.operationId,
+ method: op.method,
+ path: op.pathTemplate,
+ summary: op.summary,
+ tags: op.tags,
+ deprecated: op.deprecated,
+ }),
+ ),
tags: collectTags(result),
securitySchemes,
authStrategies,
diff --git a/packages/react/src/pages/sources-add.tsx b/packages/react/src/pages/sources-add.tsx
index 954c9ba8a8..e566da59ad 100644
--- a/packages/react/src/pages/sources-add.tsx
+++ b/packages/react/src/pages/sources-add.tsx
@@ -25,7 +25,7 @@ export function SourcesAddPage(props: {
if (!plugin) {
return (
-
+
@@ -49,8 +49,8 @@ export function SourcesAddPage(props: {
const AddComponent = plugin.add;
return (
-
-
+
+
Loading…}>
= {
openapi: "openapi",
@@ -72,7 +85,7 @@ export function SourcesPage(props: { sourcePlugins: readonly SourcePlugin[] }) {
Sources
-
+
Tool providers available in this workspace.
@@ -80,55 +93,63 @@ export function SourcesPage(props: { sourcePlugins: readonly SourcePlugin[] }) {
{/* URL detection input */}
-
- {
- setUrl((e.target as HTMLInputElement).value);
- setError(null);
- }}
- onKeyDown={(e) => {
- if (e.key === "Enter") handleDetect();
- }}
- placeholder="Paste a URL to auto-detect source type..."
- disabled={detecting}
- className="flex-1"
- />
-
- {detecting ? "Detecting..." : "Detect"}
-
-
- {error &&
{error}
}
-
- Or add manually:
- {sourcePlugins.map((p) => (
-
+
+
- {p.label}
-
- ))}
-
+
+ {
+ setUrl((e.target as HTMLInputElement).value);
+ setError(null);
+ }}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") handleDetect();
+ }}
+ placeholder="https://..."
+ disabled={detecting}
+ className="flex-1"
+ />
+
+ {detecting ? "Detecting..." : "Detect"}
+
+
+
+ Or add manually:{" "}
+ {sourcePlugins.map((p) => (
+
+ {p.label}
+
+ ))}
+
+
+
+
-
-
-
+
+
+
{Result.match(sources, {
onInitial: () =>
Loading…
,
onFailure: () =>
Failed to load sources
,
onSuccess: ({ value }) => {
- const builtInSources = value.filter((source) => source.runtime);
const connectedSources = value.filter((source) => !source.runtime);
return value.length === 0 ? (
-
+
-
No sources yet
-
+
No sources yet
+
Add a source to get started.
) : (
-
- {builtInSources.length > 0 && (
-
-
-
Built-in
-
- Runtime sources exposed by the loaded executor plugins.
-
-
-
-
- )}
-
+
{connectedSources.length > 0 && (
-
-
Connected
-
- User-configured sources available in this workspace.
-
-
)}
@@ -173,6 +176,10 @@ export function SourcesPage(props: { sourcePlugins: readonly SourcePlugin[] }) {
);
},
})}
+
+
+
+
);
@@ -182,47 +189,22 @@ export function SourcesPage(props: { sourcePlugins: readonly SourcePlugin[] }) {
// Preset grid
// ---------------------------------------------------------------------------
-type PresetEntry = { preset: SourcePreset; pluginKey: string; pluginLabel: string };
-
-function PresetCard({ preset, pluginKey, pluginLabel }: PresetEntry) {
- const search: Record
= { preset: preset.id };
- if (preset.url) search.url = preset.url;
-
- return (
-
-
- {preset.icon ? (
-
- ) : (
-
-
-
- )}
-
-
-
- {preset.name}
-
- {pluginLabel}
-
-
-
{preset.summary}
-
-
- );
-}
+type PresetEntry = {
+ preset: SourcePreset;
+ pluginKey: string;
+ pluginLabel: string;
+};
function PresetGrid(props: { plugins: readonly SourcePlugin[] }) {
const allPresets = useMemo(() => {
const entries: PresetEntry[] = [];
for (const plugin of props.plugins) {
for (const preset of plugin.presets ?? []) {
- entries.push({ preset, pluginKey: plugin.key, pluginLabel: plugin.label });
+ entries.push({
+ preset,
+ pluginKey: plugin.key,
+ pluginLabel: plugin.label,
+ });
}
}
return entries;
@@ -232,17 +214,46 @@ function PresetGrid(props: { plugins: readonly SourcePlugin[] }) {
return (
-
-
Popular sources
-
- One-click setup for common APIs and services.
-
-
-
- {allPresets.map((entry) => (
-
- ))}
-
+
+ Popular sources
+
+ {allPresets.map(({ preset, pluginKey, pluginLabel }) => {
+ const search: Record = { preset: preset.id };
+ if (preset.url) search.url = preset.url;
+ return (
+
+
+
+ {preset.icon ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ {preset.name}
+ {preset.summary}
+
+
+ {pluginLabel}
+
+
+
+ );
+ })}
+
+
);
}
@@ -252,48 +263,43 @@ function PresetGrid(props: { plugins: readonly SourcePlugin[] }) {
// ---------------------------------------------------------------------------
function SourceGrid(props: {
- sources: readonly { id: string; name: string; kind: string; runtime?: boolean }[];
+ sources: readonly {
+ id: string;
+ name: string;
+ kind: string;
+ runtime?: boolean;
+ }[];
}) {
return (
-
- {props.sources.map((s) => (
-
-
-
-
-
-
{s.name}
-
- {s.runtime && (
-
- built-in
-
- )}
-
- {s.kind}
-
-
-
-
{s.id}
-
-
-
- ))}
-
+
+ Connected
+
+ {props.sources.map((s) => (
+
+
+
+
+
+
+
+
+
+ {s.name}
+ {s.id}
+
+
+ {s.runtime && built-in }
+ {s.kind}
+
+
+
+ ))}
+
+
);
}
diff --git a/packages/react/src/plugins/headers-list.tsx b/packages/react/src/plugins/headers-list.tsx
new file mode 100644
index 0000000000..b3a3220dd7
--- /dev/null
+++ b/packages/react/src/plugins/headers-list.tsx
@@ -0,0 +1,167 @@
+import { useState, type ReactNode } from "react";
+import { PlusIcon } from "lucide-react";
+
+import { Button } from "../components/button";
+import {
+ CardStack,
+ CardStackContent,
+ CardStackEmpty,
+ CardStackEntry,
+} from "../components/card-stack";
+import {
+ defaultHeaderAuthPresets,
+ type HeaderAuthPreset,
+ type HeaderState,
+ SecretHeaderAuthRow,
+} from "./secret-header-auth";
+import type { SecretPickerSecret } from "./secret-picker";
+
+export interface HeadersListProps {
+ readonly headers: readonly HeaderState[];
+ readonly onHeadersChange: (headers: HeaderState[]) => void;
+ readonly existingSecrets?: readonly SecretPickerSecret[];
+ /** Presets offered in the quick-add picker. Defaults to `defaultHeaderAuthPresets`. */
+ readonly presets?: readonly HeaderAuthPreset[];
+ /** When true, only allow a single header (hide add button, disable remove). */
+ readonly singleHeader?: boolean;
+ /** Text shown in the empty state. */
+ readonly emptyLabel?: ReactNode;
+}
+
+export function HeadersList({
+ headers,
+ onHeadersChange,
+ existingSecrets = [],
+ presets = defaultHeaderAuthPresets,
+ singleHeader = false,
+ emptyLabel = "No headers",
+}: HeadersListProps) {
+ const [picking, setPicking] = useState(false);
+ const canAddMore = !singleHeader || headers.length === 0;
+
+ const addHeaderFromPreset = (preset: HeaderAuthPreset) => {
+ onHeadersChange([
+ ...headers,
+ {
+ name: preset.name,
+ prefix: preset.prefix,
+ presetKey: preset.key,
+ secretId: null,
+ },
+ ]);
+ setPicking(false);
+ };
+
+ const updateHeader = (
+ index: number,
+ update: Partial<{
+ name: string;
+ secretId: string | null;
+ prefix?: string;
+ presetKey?: string;
+ }>,
+ ) => {
+ onHeadersChange(
+ headers.map((entry, i) => (i === index ? { ...entry, ...update } : entry)),
+ );
+ };
+
+ const removeHeader = (index: number) => {
+ onHeadersChange(headers.filter((_, i) => i !== index));
+ };
+
+ return (
+
+
+ {picking ? (
+ setPicking(false)}
+ />
+ ) : headers.length === 0 ? (
+ canAddMore ? (
+ {emptyLabel}} onClick={() => setPicking(true)} />
+ ) : (
+
+ {emptyLabel}
+
+ )
+ ) : (
+ <>
+ {headers.map((header, index) => (
+ updateHeader(index, update)}
+ onSelectSecret={(secretId) => updateHeader(index, { secretId })}
+ onRemove={singleHeader ? undefined : () => removeHeader(index)}
+ existingSecrets={existingSecrets}
+ />
+ ))}
+ {canAddMore && setPicking(true)} />}
+ >
+ )}
+
+
+ );
+}
+
+interface AddHeaderRowProps {
+ readonly onClick: () => void;
+ readonly leading?: ReactNode;
+}
+
+function AddHeaderRow({ onClick, leading }: AddHeaderRowProps) {
+ return (
+ // oxlint-disable-next-line react/forbid-elements
+ {
+ event.stopPropagation();
+ onClick();
+ }}
+ aria-label="Add header"
+ className="flex w-full items-center justify-between gap-4 px-4 py-3 text-sm text-muted-foreground outline-none transition-[background-color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] hover:bg-accent/40 focus-visible:bg-accent/40"
+ >
+ {leading}
+
+
+ );
+}
+
+interface HeaderPresetPickerProps {
+ readonly presets: readonly HeaderAuthPreset[];
+ readonly onPick: (preset: HeaderAuthPreset) => void;
+ readonly onCancel: () => void;
+}
+
+function HeaderPresetPicker({ presets, onPick, onCancel }: HeaderPresetPickerProps) {
+ return (
+
+ {presets.map((preset) => (
+ onPick(preset)}
+ >
+ {preset.label}
+
+ ))}
+
+ Cancel
+
+
+ );
+}
diff --git a/packages/react/src/plugins/secret-header-auth.tsx b/packages/react/src/plugins/secret-header-auth.tsx
index e62827931e..ae0dac751e 100644
--- a/packages/react/src/plugins/secret-header-auth.tsx
+++ b/packages/react/src/plugins/secret-header-auth.tsx
@@ -1,11 +1,11 @@
-import { useState } from "react";
+import { useId, useState } from "react";
import { useAtomRefresh, useAtomSet } from "@effect-atom/atom-react";
import { secretsAtom, setSecret, resolveSecret } from "../api/atoms";
import { useScope } from "../api/scope-context";
import { Button } from "../components/button";
+import { Field, FieldError, FieldGroup, FieldLabel } from "../components/field";
import { Input } from "../components/input";
-import { Label } from "../components/label";
import { Spinner } from "../components/spinner";
import { SecretPicker, type SecretPickerSecret } from "./secret-picker";
import { SecretId } from "@executor/sdk";
@@ -74,6 +74,9 @@ function InlineCreateSecret(props: {
const scopeId = useScope();
const doSet = useAtomSet(setSecret, { mode: "promise" });
const refreshSecrets = useAtomRefresh(secretsAtom(scopeId));
+ const secretIdInputId = useId();
+ const secretNameInputId = useId();
+ const secretValueInputId = useId();
const handleSave = async () => {
if (!secretId.trim() || !secretValue.trim()) return;
@@ -98,53 +101,55 @@ function InlineCreateSecret(props: {
};
return (
-
-
New secret
-
-
-
Value
-
-
setSecretValue((e.target as HTMLInputElement).value)}
- placeholder="paste your token or key…"
- className="h-8 pr-8 text-xs font-mono"
- />
-
setSecretRevealed((revealed) => !revealed)}
- aria-label={secretRevealed ? "Hide secret value" : "Reveal secret value"}
- >
-
-
+
- {error &&
{error}
}
+
+ Value
+
+ setSecretValue((e.target as HTMLInputElement).value)}
+ placeholder="paste your token or key…"
+ className="pr-9 font-mono"
+ />
+ setSecretRevealed((revealed) => !revealed)}
+ aria-label={secretRevealed ? "Hide secret value" : "Reveal secret value"}
+ >
+
+
+
+ {error && {error} }
+
+
Cancel
@@ -286,12 +291,13 @@ export function SecretHeaderAuthRow(props: {
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 nameInputId = useId();
+ const prefixInputId = useId();
const {
name,
prefix,
@@ -300,35 +306,34 @@ export function SecretHeaderAuthRow(props: {
onChange,
onSelectSecret,
existingSecrets,
- presets = defaultHeaderAuthPresets,
onRemove,
removeLabel = "Remove",
label = "Header",
} = props;
- const isCustom = presetKey === "custom";
+ const isCustom = presetKey === "custom" || presetKey === undefined;
const suggestedId = name.toLowerCase().replace(/[^a-z0-9]+/g, "-") || "custom-header";
if (creating) {
return (
- {
- onSelectSecret(id);
- setCreating(false);
- }}
- onCancel={() => setCreating(false)}
- />
+
+ {
+ onSelectSecret(id);
+ setCreating(false);
+ }}
+ onCancel={() => setCreating(false)}
+ />
+
);
}
return (
-
-
-
- {label}
-
+
+
+
{label}
{onRemove && (
-
- {presets.map((preset) => (
-
+
+
+ Name
+
onChange({
- name: preset.name,
- prefix: preset.prefix,
- presetKey: preset.key,
+ name: (e.target as HTMLInputElement).value,
+ prefix,
+ presetKey: isCustom ? "custom" : presetKey,
})
}
- className={`rounded-md border px-2 py-1 text-xs font-medium transition-colors ${
- presetKey === preset.key
- ? "border-primary/50 bg-primary/10 text-primary"
- : "border-border bg-background text-muted-foreground hover:text-foreground hover:bg-accent/50"
- }`}
- >
- {preset.label}
-
- ))}
-
-
- {presetKey !== undefined && (
-
- )}
+ placeholder="Authorization"
+ className="font-mono"
+ />
+
+
+
+ Prefix (optional)
+
+
+ onChange({
+ name,
+ prefix: (e.target as HTMLInputElement).value || undefined,
+ presetKey: isCustom ? "custom" : presetKey,
+ })
+ }
+ placeholder="Bearer "
+ className="font-mono"
+ />
+
+
- {presetKey !== undefined && name.trim() && (
-
-
-
-
-
setCreating(true)}
- >
- + New
-
+
+
+
- )}
+
setCreating(true)}
+ >
+ + New
+
+
{secretId && name.trim() && (
diff --git a/packages/react/src/plugins/source-identity.tsx b/packages/react/src/plugins/source-identity.tsx
new file mode 100644
index 0000000000..49187bfec4
--- /dev/null
+++ b/packages/react/src/plugins/source-identity.tsx
@@ -0,0 +1,159 @@
+import { useCallback, useState } from "react";
+import { parse } from "tldts";
+
+import {
+ CardStack,
+ CardStackContent,
+ CardStackEntryField,
+} from "../components/card-stack";
+import { Input } from "../components/input";
+
+// ---------------------------------------------------------------------------
+// Slug helper
+// ---------------------------------------------------------------------------
+
+/**
+ * Normalizes a display name into a valid namespace identifier: lowercase
+ * snake_case, only `[a-z0-9_]`, no leading/trailing underscores. Produces
+ * strings that are safe to use as TypeScript/tool-name prefixes.
+ */
+export function slugifyNamespace(input: string): string {
+ return input
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "_")
+ .replace(/^_+|_+$/g, "");
+}
+
+/**
+ * Derives a display-name candidate from a URL by extracting its apex domain
+ * label (e.g. `https://api.shopify.com/graphql` → `"Shopify"`) and
+ * title-casing it. Returns `null` if the URL has no parseable domain.
+ */
+export function displayNameFromUrl(url: string): string | null {
+ const trimmed = url.trim();
+ if (!trimmed) return null;
+ const parsed = parse(trimmed);
+ const label = parsed.domainWithoutSuffix;
+ if (!label) return null;
+ return label.charAt(0).toUpperCase() + label.slice(1);
+}
+
+// ---------------------------------------------------------------------------
+// Hook — owns the name + namespace state with namespace auto-derivation
+// ---------------------------------------------------------------------------
+
+export interface SourceIdentity {
+ /** Display name — the user's override if they've typed one, otherwise the fallback. */
+ readonly name: string;
+ /** Namespace — the user's override if they've typed one, otherwise slugified from `name`. */
+ readonly namespace: string;
+ readonly setName: (name: string) => void;
+ readonly setNamespace: (namespace: string) => void;
+ /** Clears any user overrides so both fields return to deriving from the fallback. */
+ readonly reset: () => void;
+}
+
+export interface UseSourceIdentityOptions {
+ /**
+ * Fallback display name — used when the user hasn't typed one. Pass a
+ * value computed from the caller's reactive state (probe result, URL
+ * apex domain, template default, etc.) and it'll flow through to `name`
+ * automatically.
+ */
+ readonly fallbackName?: string;
+ /** Fallback namespace — defaults to `slugifyNamespace(fallbackName ?? "")`. */
+ readonly fallbackNamespace?: string;
+}
+
+/**
+ * Manages a display name and a derived namespace. Both fields are pure
+ * derived state: the user's `setName` / `setNamespace` call stores an
+ * override, otherwise the hook returns the caller-supplied fallback
+ * (passed fresh on every render). Call `reset()` to drop overrides.
+ */
+export function useSourceIdentity(options?: UseSourceIdentityOptions): SourceIdentity {
+ const [nameOverride, setNameOverride] = useState
(null);
+ const [namespaceOverride, setNamespaceOverride] = useState(null);
+
+ const fallbackName = options?.fallbackName ?? "";
+ const name = nameOverride ?? fallbackName;
+ const fallbackNamespace = options?.fallbackNamespace ?? slugifyNamespace(name);
+ const namespace = namespaceOverride ?? fallbackNamespace;
+
+ const setName = useCallback((next: string) => {
+ setNameOverride(next);
+ }, []);
+
+ const setNamespace = useCallback((next: string) => {
+ setNamespaceOverride(slugifyNamespace(next));
+ }, []);
+
+ const reset = useCallback(() => {
+ setNameOverride(null);
+ setNamespaceOverride(null);
+ }, []);
+
+ return { name, namespace, setName, setNamespace, reset };
+}
+
+// ---------------------------------------------------------------------------
+// UI — two fields, wrapped in a shared CardStack
+// ---------------------------------------------------------------------------
+
+export interface SourceIdentityFieldsProps {
+ readonly identity: SourceIdentity;
+ readonly namePlaceholder?: string;
+ readonly namespacePlaceholder?: string;
+ readonly nameLabel?: string;
+ readonly namespaceHint?: string;
+ /**
+ * When true, the namespace field is rendered disabled — useful on Edit
+ * forms, where the namespace is the source's identity and changing it
+ * would require a delete + recreate flow.
+ */
+ readonly namespaceReadOnly?: boolean;
+}
+
+export function SourceIdentityFields({
+ identity,
+ namePlaceholder = "e.g. Sentry API",
+ namespacePlaceholder = "sentry_api",
+ nameLabel = "Display Name",
+ namespaceHint,
+ namespaceReadOnly = false,
+}: SourceIdentityFieldsProps) {
+ const effectiveNamespaceHint =
+ namespaceHint ??
+ (namespaceReadOnly
+ ? "The namespace is part of the source's identity and cannot be changed."
+ : "Prefix for the tool names. Auto-derived from the display name.");
+
+ return (
+
+
+
+ identity.setName((e.target as HTMLInputElement).value)}
+ placeholder={namePlaceholder}
+ className="text-sm"
+ />
+
+
+ identity.setNamespace((e.target as HTMLInputElement).value)}
+ placeholder={namespacePlaceholder}
+ className="font-mono text-sm"
+ disabled={namespaceReadOnly}
+ />
+
+
+
+ );
+}