History

fix ui and stuff

Rhys Sullivan committed 7 months ago

Commit 7be8c281 parent 54aa27330 files · +3970 −797Browse files

Showing the changes to plugins (14 of 30 files). Show all

Unified · Split

14 files
  1. plugins/google-discovery/http/index.ts +42 −6
  2. plugins/google-discovery/react/index.tsx +855 −165
  3. plugins/google-discovery/sdk/index.ts +230 −27
  4. plugins/google-discovery/shared/index.ts +61 −1
  5. plugins/graphql/react/index.tsx +353 −115
  6. plugins/graphql/sdk/index.ts +65 −10
  7. plugins/graphql/shared/index.ts +2 −0
  8. plugins/mcp/http/index.ts +38 −6
  9. plugins/mcp/react/index.tsx +604 −86
  10. plugins/mcp/sdk/index.ts +22 −0
  11. plugins/mcp/shared/index.ts +11 −0
  12. plugins/openapi/react/index.tsx +387 −152
  13. plugins/openapi/sdk/index.ts +81 −16
  14. plugins/openapi/shared/index.ts +2 −0
plugins/google-discovery/http/index.ts+42−6
@@ -25,6 +25,7 @@ import * as Effect from "effect/Effect"; · 24 unmodified lines
2525 import * as Schema from "effect/Schema";
2626
2727 import {
28+ GoogleDiscoveryStartBatchOAuthInputSchema,
2829 GOOGLE_DISCOVERY_EXECUTOR_KEY,
2930 GOOGLE_DISCOVERY_OAUTH_STORAGE_PREFIX,
3031 GOOGLE_DISCOVERY_PLUGIN_KEY,
@@ -36,6 +37,7 @@ import { · 5 unmodified lines
3637 type GoogleDiscoveryConnectInput,
3738 type GoogleDiscoveryOAuthPopupResult,
3839 type GoogleDiscoverySourceConfigPayload,
40+ type GoogleDiscoveryStartBatchOAuthInput,
3941 type GoogleDiscoveryStartOAuthInput,
4042 type GoogleDiscoveryStartOAuthResult,
4143 type GoogleDiscoveryUpdateSourceInput,
@@ -58,6 +60,9 @@ type GoogleDiscoveryExecutorExtension = { · 16 unmodified lines
5860 startOAuth: (
5961 input: GoogleDiscoveryStartOAuthInput,
6062 ) => Effect.Effect<GoogleDiscoveryStartOAuthResult, Error>;
63+ startBatchOAuth: (
64+ input: GoogleDiscoveryStartBatchOAuthInput,
65+ ) => Effect.Effect<GoogleDiscoveryStartOAuthResult, Error>;
6166 completeOAuth: (input: {
6267 state: string;
6368 code?: string;
@@ -122,6 +127,14 @@ export const GoogleDiscoveryHttpGroup = HttpApiGroup.make(GOOGLE_DISCOVERY_PLUGI · 58 unmodified lines
122127 .addError(ControlPlaneForbiddenError)
123128 .addError(ControlPlaneStorageError),
124129 )
130+ .add(
131+ HttpApiEndpoint.post("startBatchOAuth")`/workspaces/${workspaceIdParam}/plugins/google-discovery/oauth/start-batch`
132+ .setPayload(GoogleDiscoveryStartBatchOAuthInputSchema)
133+ .addSuccess(GoogleDiscoveryStartOAuthResultSchema)
134+ .addError(ControlPlaneBadRequestError)
135+ .addError(ControlPlaneForbiddenError)
136+ .addError(ControlPlaneStorageError),
137+ )
125138 .add(
126139 HttpApiEndpoint.get("oauthCallback")`/plugins/google-discovery/oauth/callback`
127140 .setUrlParams(callbackParamsSchema)
@@ -181,10 +194,13 @@ const popupDocument = (payload: GoogleDiscoveryOAuthPopupResult): string => { · 53 unmodified lines
181194 .replaceAll("<", "\\u003c")
182195 .replaceAll(">", "\\u003e")
183196 .replaceAll("&", "\\u0026");
184− const title = payload.ok ? "Google OAuth connected" : "Google OAuth failed";
197+ const title = payload.ok ? "Connected" : "Connection failed";
185198 const message = payload.ok
186− ? "Google credentials are ready. Return to the source form to finish saving."
199+ ? payload.mode === "batch"
200+ ? `Connected ${payload.sources.length} Google API${payload.sources.length === 1 ? "" : "s"}. This window will close automatically.`
201+ : "Google account connected. This window will close automatically."
187202 : payload.error;
203+ const statusColor = payload.ok ? "#22c55e" : "#ef4444";
188204
189205 return `<!doctype html>
190206 <html lang="en">
@@ -193,10 +209,17 @@ const popupDocument = (payload: GoogleDiscoveryOAuthPopupResult): string => { · 2 unmodified lines
193209 <meta name="viewport" content="width=device-width, initial-scale=1" />
194210 <title>${escapeHtml(title)}</title>
195211 </head>
196− <body>
197− <main>
198− <h1>${escapeHtml(title)}</h1>
199− <p>${escapeHtml(message)}</p>
212+ <body style="font-family: system-ui, -apple-system, sans-serif; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; background: #fafafa;">
213+ <main style="text-align: center; max-width: 360px; padding: 24px;">
214+ <div style="width: 40px; height: 40px; border-radius: 50%; background: ${statusColor}; margin: 0 auto 16px; display: flex; align-items: center; justify-content: center;">
215+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none">
216+ ${payload.ok
217+ ? '<path d="M6 10l3 3 5-6" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>'
218+ : '<path d="M7 7l6 6M13 7l-6 6" stroke="white" stroke-width="2" stroke-linecap="round"/>'}
219+ </svg>
220+ </div>
221+ <h1 style="margin: 0 0 8px; font-size: 18px; font-weight: 600; color: #111;">${escapeHtml(title)}</h1>
222+ <p style="margin: 0; font-size: 14px; color: #666; line-height: 1.5;">${escapeHtml(message)}</p>
200223 </main>
201224 <script>
202225 (() => {
@@ -299,6 +322,19 @@ export const googleDiscoveryHttpPlugin = (): ExecutorHttpPlugin< · 96 unmodified lines
299322 ),
300323 )
301324 )
325+ .handle("startBatchOAuth", ({ path, payload }) =>
326+ resolveRequestedLocalWorkspace(
327+ "googleDiscovery.startBatchOAuth",
328+ path.workspaceId,
329+ ).pipe(
330+ Effect.flatMap(() =>
331+ executor[GOOGLE_DISCOVERY_EXECUTOR_KEY].startBatchOAuth(payload)
332+ ),
333+ Effect.mapError((cause) =>
334+ toStorageError("googleDiscovery.startBatchOAuth", cause)
335+ ),
336+ )
337+ )
302338 .handle("oauthCallback", ({ urlParams }) =>
303339 executor[GOOGLE_DISCOVERY_EXECUTOR_KEY].completeOAuth({
304340 state: urlParams.state,
plugins/google-discovery/react/index.tsx+855−165
@@ -5,6 +5,7 @@ import { · 4 unmodified lines
55 Result,
66 useAtomSet,
77 useAtomValue,
8+ useCreateSecret,
89 useExecutorMutation,
910 useLocalInstallation,
1011 useSecrets,
@@ -29,9 +30,11 @@ import { · 18 unmodified lines
2930 GOOGLE_DISCOVERY_PLUGIN_KEY,
3031 GOOGLE_DISCOVERY_SOURCE_KIND,
3132 defaultGoogleDiscoveryUrl,
33+ type GoogleDiscoveryBatchSourceInput,
3234 type GoogleDiscoveryConnectInput,
3335 type GoogleDiscoveryConnectionAuth,
3436 type GoogleDiscoveryOAuthPopupResult,
37+ type GoogleDiscoveryStartBatchOAuthInput,
3538 type GoogleDiscoveryStartOAuthInput,
3639 } from "@executor/plugin-google-discovery-shared";
3740
@@ -94,6 +97,266 @@ const defaultGoogleInput = (): GoogleDiscoveryConnectInput => ({ · 56 unmodified lines
9497 },
9598 });
9699
100+type GoogleDiscoveryBatchTemplate = {
101+ id: string;
102+ name: string;
103+ summary: string;
104+ service: string;
105+ version: string;
106+ discoveryUrl: string;
107+};
108+
109+const googleDiscoveryTemplate = (
110+ input: GoogleDiscoveryBatchTemplate,
111+): GoogleDiscoveryBatchTemplate => ({
112+ ...input,
113+ discoveryUrl: input.discoveryUrl || defaultGoogleDiscoveryUrl(input.service, input.version),
114+});
115+
116+const GOOGLE_DISCOVERY_BATCH_TEMPLATES: ReadonlyArray<GoogleDiscoveryBatchTemplate> = [
117+ googleDiscoveryTemplate({
118+ id: "google-calendar",
119+ name: "Google Calendar",
120+ summary: "Calendars, events, ACLs, and scheduling workflows.",
121+ service: "calendar",
122+ version: "v3",
123+ discoveryUrl: "https://calendar-json.googleapis.com/$discovery/rest?version=v3",
124+ }),
125+ googleDiscoveryTemplate({
126+ id: "google-drive",
127+ name: "Google Drive",
128+ summary: "Files, folders, permissions, comments, and shared drives.",
129+ service: "drive",
130+ version: "v3",
131+ discoveryUrl: defaultGoogleDiscoveryUrl("drive", "v3"),
132+ }),
133+ googleDiscoveryTemplate({
134+ id: "google-gmail",
135+ name: "Gmail",
136+ summary: "Messages, threads, labels, drafts, and mailbox automation.",
137+ service: "gmail",
138+ version: "v1",
139+ discoveryUrl: "https://gmail.googleapis.com/$discovery/rest?version=v1",
140+ }),
141+ googleDiscoveryTemplate({
142+ id: "google-docs",
143+ name: "Google Docs",
144+ summary: "Documents, structural edits, text ranges, and formatting.",
145+ service: "docs",
146+ version: "v1",
147+ discoveryUrl: "https://docs.googleapis.com/$discovery/rest?version=v1",
148+ }),
149+ googleDiscoveryTemplate({
150+ id: "google-sheets",
151+ name: "Google Sheets",
152+ summary: "Spreadsheets, values, ranges, formatting, and batch updates.",
153+ service: "sheets",
154+ version: "v4",
155+ discoveryUrl: "https://sheets.googleapis.com/$discovery/rest?version=v4",
156+ }),
157+ googleDiscoveryTemplate({
158+ id: "google-slides",
159+ name: "Google Slides",
160+ summary: "Presentations, slides, page elements, and deck updates.",
161+ service: "slides",
162+ version: "v1",
163+ discoveryUrl: "https://slides.googleapis.com/$discovery/rest?version=v1",
164+ }),
165+ googleDiscoveryTemplate({
166+ id: "google-forms",
167+ name: "Google Forms",
168+ summary: "Forms, questions, responses, quizzes, and form metadata.",
169+ service: "forms",
170+ version: "v1",
171+ discoveryUrl: "https://forms.googleapis.com/$discovery/rest?version=v1",
172+ }),
173+ googleDiscoveryTemplate({
174+ id: "google-search-console",
175+ name: "Google Search Console",
176+ summary: "Sites, sitemaps, URL inspection, and search performance.",
177+ service: "searchconsole",
178+ version: "v1",
179+ discoveryUrl: "https://searchconsole.googleapis.com/$discovery/rest?version=v1",
180+ }),
181+ googleDiscoveryTemplate({
182+ id: "google-people",
183+ name: "Google People",
184+ summary: "Contacts, profiles, directory people, and contact groups.",
185+ service: "people",
186+ version: "v1",
187+ discoveryUrl: "https://people.googleapis.com/$discovery/rest?version=v1",
188+ }),
189+ googleDiscoveryTemplate({
190+ id: "google-tasks",
191+ name: "Google Tasks",
192+ summary: "Task lists, task items, notes, and due dates.",
193+ service: "tasks",
194+ version: "v1",
195+ discoveryUrl: "https://tasks.googleapis.com/$discovery/rest?version=v1",
196+ }),
197+ googleDiscoveryTemplate({
198+ id: "google-chat",
199+ name: "Google Chat",
200+ summary: "Spaces, messages, members, reactions, and chat workflows.",
201+ service: "chat",
202+ version: "v1",
203+ discoveryUrl: "https://chat.googleapis.com/$discovery/rest?version=v1",
204+ }),
205+ googleDiscoveryTemplate({
206+ id: "google-keep",
207+ name: "Google Keep",
208+ summary: "Notes, lists, attachments, and collaborative annotations.",
209+ service: "keep",
210+ version: "v1",
211+ discoveryUrl: "https://keep.googleapis.com/$discovery/rest?version=v1",
212+ }),
213+ googleDiscoveryTemplate({
214+ id: "google-classroom",
215+ name: "Google Classroom",
216+ summary: "Courses, rosters, coursework, submissions, and grading data.",
217+ service: "classroom",
218+ version: "v1",
219+ discoveryUrl: "https://classroom.googleapis.com/$discovery/rest?version=v1",
220+ }),
221+ googleDiscoveryTemplate({
222+ id: "google-admin-directory",
223+ name: "Google Admin Directory",
224+ summary: "Users, groups, org units, roles, and directory resources.",
225+ service: "admin",
226+ version: "directory_v1",
227+ discoveryUrl: "https://admin.googleapis.com/$discovery/rest?version=directory_v1",
228+ }),
229+ googleDiscoveryTemplate({
230+ id: "google-admin-reports",
231+ name: "Google Admin Reports",
232+ summary: "Audit events, usage reports, and admin activity logs.",
233+ service: "admin",
234+ version: "reports_v1",
235+ discoveryUrl: "https://admin.googleapis.com/$discovery/rest?version=reports_v1",
236+ }),
237+ googleDiscoveryTemplate({
238+ id: "google-apps-script",
239+ name: "Google Apps Script",
240+ summary: "Projects, deployments, execution, and script metadata.",
241+ service: "script",
242+ version: "v1",
243+ discoveryUrl: "https://script.googleapis.com/$discovery/rest?version=v1",
244+ }),
245+ googleDiscoveryTemplate({
246+ id: "google-bigquery",
247+ name: "Google BigQuery",
248+ summary: "Datasets, tables, jobs, routines, and analytics workflows.",
249+ service: "bigquery",
250+ version: "v2",
251+ discoveryUrl: "https://bigquery.googleapis.com/$discovery/rest?version=v2",
252+ }),
253+ googleDiscoveryTemplate({
254+ id: "google-cloud-resource-manager",
255+ name: "Google Cloud Resource Manager",
256+ summary: "Projects, folders, organizations, and IAM hierarchy.",
257+ service: "cloudresourcemanager",
258+ version: "v3",
259+ discoveryUrl: "https://cloudresourcemanager.googleapis.com/$discovery/rest?version=v3",
260+ }),
261+ googleDiscoveryTemplate({
262+ id: "google-youtube-data",
263+ name: "YouTube Data",
264+ summary: "Channels, playlists, videos, comments, captions, and uploads.",
265+ service: "youtube",
266+ version: "v3",
267+ discoveryUrl: "https://youtube.googleapis.com/$discovery/rest?version=v3",
268+ }),
269+];
270+
271+const presetString = (
272+ search: Record<string, unknown>,
273+ key: string,
274+): string | null => {
275+ const value = search[key];
276+ return typeof value === "string" && value.trim().length > 0
277+ ? value.trim()
278+ : null;
279+};
280+
281+const parsedGoogleDiscoveryInputFromUrl = (
282+ value: string | null,
283+): Pick<GoogleDiscoveryConnectInput, "name" | "service" | "version" | "discoveryUrl"> | null => {
284+ if (!value) {
285+ return null;
286+ }
287+
288+ try {
289+ const url = new URL(value);
290+ const byDirectory = url.pathname.match(/^\/discovery\/v1\/apis\/([^/]+)\/([^/]+)\/rest$/);
291+ if (byDirectory) {
292+ const service = decodeURIComponent(byDirectory[1] ?? "");
293+ const version = decodeURIComponent(byDirectory[2] ?? "");
294+ const knownTemplate = GOOGLE_DISCOVERY_BATCH_TEMPLATES.find(
295+ (entry) => entry.service === service && entry.version === version,
296+ );
297+ return {
298+ name: knownTemplate?.name ?? `Google ${service}`,
299+ service,
300+ version,
301+ discoveryUrl: url.toString(),
302+ };
303+ }
304+
305+ const version = url.searchParams.get("version")?.trim() ?? "";
306+ const isHostScopedDiscovery =
307+ url.pathname === "/$discovery/rest"
308+ && url.hostname.endsWith(".googleapis.com")
309+ && url.hostname !== "www.googleapis.com";
310+ if (version && isHostScopedDiscovery) {
311+ const service = url.hostname.split(".")[0] ?? "";
312+ const knownTemplate = GOOGLE_DISCOVERY_BATCH_TEMPLATES.find(
313+ (entry) => entry.service === service && entry.version === version,
314+ );
315+ return {
316+ name: knownTemplate?.name ?? `Google ${service}`,
317+ service,
318+ version,
319+ discoveryUrl: url.toString(),
320+ };
321+ }
322+ } catch {}
323+
324+ return null;
325+};
326+
327+const googleInputFromSearch = (
328+ search: Record<string, unknown>,
329+): GoogleDiscoveryConnectInput => {
330+ const defaults = defaultGoogleInput();
331+ const parsedFromInputUrl = parsedGoogleDiscoveryInputFromUrl(
332+ presetString(search, "inputUrl")
333+ ?? presetString(search, "input")
334+ ?? presetString(search, "url"),
335+ );
336+ const service =
337+ presetString(search, "presetService")
338+ ?? parsedFromInputUrl?.service
339+ ?? defaults.service;
340+ const version =
341+ presetString(search, "presetVersion")
342+ ?? parsedFromInputUrl?.version
343+ ?? defaults.version;
344+
345+ return {
346+ ...defaults,
347+ name:
348+ presetString(search, "presetName")
349+ ?? parsedFromInputUrl?.name
350+ ?? defaults.name,
351+ service,
352+ version,
353+ discoveryUrl:
354+ presetString(search, "presetDiscoveryUrl")
355+ ?? parsedFromInputUrl?.discoveryUrl
356+ ?? defaultGoogleDiscoveryUrl(service, version),
357+ };
358+};
359+
97360 const stringifyStringMap = (
98361 value: Record<string, string> | null | undefined,
99362 ): string =>
@@ -201,6 +464,167 @@ const waitForOauthPopupResult = async ( · 101 unmodified lines
201464 }, 400);
202465 });
203466
467+const openGoogleOauthPopup = (name: string): Window | null => {
468+ const popup = window.open("", name, "width=560,height=760");
469+ if (!popup) {
470+ return null;
471+ }
472+
473+ try {
474+ popup.document.title = "Sign in with Google";
475+ popup.document.body.innerHTML = `
476+ <main style="font-family: system-ui, -apple-system, sans-serif; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; margin: 0; color: #333;">
477+ <div style="width: 20px; height: 20px; border: 2px solid #e5e7eb; border-top-color: #6366f1; border-radius: 50%; animation: spin 0.7s linear infinite; margin-bottom: 16px;"></div>
478+ <p style="margin: 0; font-size: 14px; color: #888;">Redirecting to Google&hellip;</p>
479+ <style>@keyframes spin { to { transform: rotate(360deg); } }</style>
480+ </main>
481+ `;
482+ } catch {
483+ // Ignore browsers that don't allow writing to the popup document here.
484+ }
485+
486+ return popup;
487+};
488+
489+const CREATE_SECRET_VALUE = "__create_google_secret__";
490+
491+function SecretSelectOrCreateField(props: {
492+ label: string;
493+ value: string;
494+ emptyLabel: string;
495+ onChange: (value: string) => void;
496+}) {
497+ const secrets = useSecrets();
498+ const createSecret = useCreateSecret();
499+ const [draftName, setDraftName] = useState("");
500+ const [draftValue, setDraftValue] = useState("");
501+ const [createError, setCreateError] = useState<string | null>(null);
502+ const [showCreate, setShowCreate] = useState(false);
503+
504+ const handleSelectChange = (nextValue: string) => {
505+ if (nextValue === CREATE_SECRET_VALUE) {
506+ setShowCreate(true);
507+ props.onChange("");
508+ return;
509+ }
510+
511+ setShowCreate(false);
512+ setCreateError(null);
513+ props.onChange(nextValue);
514+ };
515+
516+ const handleCreate = async () => {
517+ const trimmedName = draftName.trim();
518+ if (!trimmedName) {
519+ setCreateError("Secret name is required.");
520+ return;
521+ }
522+ if (!draftValue.trim()) {
523+ setCreateError("Secret value is required.");
524+ return;
525+ }
526+
527+ try {
528+ setCreateError(null);
529+ const created = await createSecret.mutateAsync({
530+ name: trimmedName,
531+ value: draftValue,
532+ });
533+ props.onChange(JSON.stringify({
534+ providerId: created.providerId,
535+ handle: created.id,
536+ }));
537+ setDraftName("");
538+ setDraftValue("");
539+ setShowCreate(false);
540+ } catch (cause) {
541+ setCreateError(cause instanceof Error ? cause.message : "Failed creating secret.");
542+ }
543+ };
544+
545+ return (
546+ <div className="grid gap-2">
547+ <span className="text-xs font-medium text-foreground">{props.label}</span>
548+ <select
549+ value={showCreate ? CREATE_SECRET_VALUE : props.value}
550+ onChange={(event) => handleSelectChange(event.target.value)}
551+ className="h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
552+ >
553+ <option value="">{props.emptyLabel}</option>
554+ {secrets.status === "ready" &&
555+ secrets.data.map((secret) => (
556+ <option
557+ key={`${secret.providerId}:${secret.id}`}
558+ value={JSON.stringify({
559+ providerId: secret.providerId,
560+ handle: secret.id,
561+ })}
562+ >
563+ {secret.name ?? secret.id}
564+ </option>
565+ ))}
566+ <option value={CREATE_SECRET_VALUE}>Create new secret</option>
567+ </select>
568+
569+ {showCreate && (
570+ <div className="space-y-3 border-l-2 border-border pl-4">
571+ <input
572+ value={draftName}
573+ onChange={(event) => setDraftName(event.target.value)}
574+ placeholder="Google client secret"
575+ className="h-9 w-full rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
576+ />
577+ <textarea
578+ value={draftValue}
579+ onChange={(event) => setDraftValue(event.target.value)}
580+ rows={3}
581+ placeholder="Paste the secret value"
582+ className="w-full rounded-lg border border-input bg-background px-3 py-2 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
583+ />
584+ {createError && (
585+ <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-3 py-2 text-xs text-destructive">
586+ {createError}
587+ </div>
588+ )}
589+ <div className="flex items-center gap-2">
590+ <button
591+ type="button"
592+ onClick={() => {
593+ void handleCreate();
594+ }}
595+ disabled={createSecret.status === "pending"}
596+ className="inline-flex h-8 items-center justify-center rounded-lg bg-primary px-3 text-xs font-medium text-primary-foreground transition-opacity disabled:pointer-events-none disabled:opacity-50"
597+ >
598+ {createSecret.status === "pending" ? "Creating..." : "Create Secret"}
599+ </button>
600+ <button
601+ type="button"
602+ onClick={() => {
603+ setShowCreate(false);
604+ setCreateError(null);
605+ }}
606+ className="inline-flex h-8 items-center justify-center rounded-lg border border-input bg-card px-3 text-xs font-medium text-foreground transition-colors hover:bg-accent/50"
607+ >
608+ Cancel
609+ </button>
610+ </div>
611+ </div>
612+ )}
613+ </div>
614+ );
615+}
616+
617+const batchSourceInputFromTemplate = (
618+ template: GoogleDiscoveryBatchTemplate,
619+): GoogleDiscoveryBatchSourceInput => ({
620+ name: template.name,
621+ service: template.service,
622+ version: template.version,
623+ discoveryUrl: template.discoveryUrl,
624+ defaultHeaders: null,
625+ scopes: [],
626+});
627+
204628 function GoogleDiscoverySourceForm(props: {
205629 initialValue: GoogleDiscoveryConnectInput;
206630 mode: "create" | "edit";
@@ -208,7 +632,6 @@ function GoogleDiscoverySourceForm(props: { · 1 unmodified line
208632 }) {
209633 const installation = useLocalInstallation();
210634 const client = getGoogleDiscoveryHttpClient();
211− const secrets = useSecrets();
212635 const startOAuth = useAtomSet(
213636 client.mutation(GOOGLE_DISCOVERY_PLUGIN_KEY, "startOAuth"),
214637 { mode: "promise" },
@@ -249,6 +672,11 @@ function GoogleDiscoverySourceForm(props: { · 34 unmodified lines
249672 throw new Error("Workspace is still loading.");
250673 }
251674
675+ const popup = openGoogleOauthPopup("executor-google-discovery-oauth");
676+ if (!popup) {
677+ throw new Error("Failed opening Google OAuth popup. Allow popups for this site and try again.");
678+ }
679+
252680 const payload: GoogleDiscoveryStartOAuthInput = {
253681 service: service.trim(),
254682 version: version.trim(),
@@ -266,26 +694,29 @@ function GoogleDiscoverySourceForm(props: { · 11 unmodified lines
266694 ).toString(),
267695 };
268696
269− const started = await startOAuth({
270− path: {
271− workspaceId: installation.data.scopeId,
272− },
273− payload,
274− });
275−
276− const popup = window.open(
277− started.authorizationUrl,
278− "executor-google-discovery-oauth",
279− "width=560,height=760,noopener,noreferrer",
280− );
281− if (!popup) {
282− throw new Error("Failed opening Google OAuth popup.");
697+ let started;
698+ try {
699+ started = await startOAuth({
700+ path: {
701+ workspaceId: installation.data.scopeId,
702+ },
703+ payload,
704+ });
705+ } catch (cause) {
706+ popup.close();
707+ throw cause;
283708 }
284709
710+ popup.location.replace(started.authorizationUrl);
711+ popup.focus();
712+
285713 const result = await waitForOauthPopupResult(started.sessionId);
286714 if (!result.ok) {
287715 throw new Error(result.error);
288716 }
717+ if (result.mode !== "single") {
718+ throw new Error("Unexpected batch OAuth result for single-source Google setup.");
719+ }
289720
290721 setScopesText(stringifyScopes(result.auth.scopes));
291722 setOauthAuth(result.auth);
@@ -293,82 +724,71 @@ function GoogleDiscoverySourceForm(props: { · 1 unmodified line
293724 };
294725
295726 return (
296− <div className="space-y-4 rounded-2xl border border-border bg-card p-5">
297− <div>
298− <h2 className="text-lg font-semibold text-foreground">
299− {props.mode === "create"
300− ? "Connect Google Discovery Source"
301− : "Edit Google Discovery Source"}
302− </h2>
303− <p className="mt-1 text-sm text-muted-foreground">
304− Google Discovery now owns its API metadata and OAuth flow inside the plugin.
305− </p>
306− </div>
307−
308− <div className="grid gap-4 md:grid-cols-2">
309− <label className="grid gap-1.5">
727+ <div className="space-y-6 rounded-xl border border-border p-6">
728+ <div className="grid gap-4 sm:grid-cols-2">
729+ <label className="grid gap-2 sm:col-span-2">
310730 <span className="text-xs font-medium text-foreground">Name</span>
311731 <input
312732 value={name}
313733 onChange={(event) => setName(event.target.value)}
314− className="h-10 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
734+ className="h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
315735 />
316736 </label>
317− <label className="grid gap-1.5">
737+ <label className="grid gap-2">
318738 <span className="text-xs font-medium text-foreground">Service</span>
319739 <input
320740 value={service}
321741 onChange={(event) => setService(event.target.value)}
322− className="h-10 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
742+ className="h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
323743 />
324744 </label>
325− <label className="grid gap-1.5">
745+ <label className="grid gap-2">
326746 <span className="text-xs font-medium text-foreground">Version</span>
327747 <input
328748 value={version}
329749 onChange={(event) => setVersion(event.target.value)}
330− className="h-10 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
750+ className="h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
331751 />
332752 </label>
333− <label className="grid gap-1.5">
753+ <label className="grid gap-2 sm:col-span-2">
334754 <span className="text-xs font-medium text-foreground">Discovery URL</span>
335755 <input
336756 value={discoveryUrl}
337757 onChange={(event) => setDiscoveryUrl(event.target.value)}
338758 placeholder={defaultGoogleDiscoveryUrl(service || "sheets", version || "v4")}
339− className="h-10 rounded-lg border border-input bg-background px-3 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
759+ className="h-9 rounded-lg border border-input bg-background px-3 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
340760 />
341761 </label>
342762 </div>
343763
344− <label className="grid gap-1.5">
764+ <label className="grid gap-2">
345765 <span className="text-xs font-medium text-foreground">Default Headers</span>
346766 <textarea
347767 value={headersText}
348768 onChange={(event) => setHeadersText(event.target.value)}
349− rows={4}
769+ rows={3}
350770 className="rounded-lg border border-input bg-background px-3 py-2 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
351771 />
352772 </label>
353773
354− <label className="grid gap-1.5">
774+ <label className="grid gap-2">
355775 <span className="text-xs font-medium text-foreground">Scopes</span>
356776 <textarea
357777 value={scopesText}
358778 onChange={(event) => setScopesText(event.target.value)}
359− rows={4}
779+ rows={3}
360780 placeholder="Leave blank to infer from the discovery document."
361781 className="rounded-lg border border-input bg-background px-3 py-2 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
362782 />
363783 </label>
364784
365− <label className="grid gap-1.5">
785+ <label className="grid gap-2">
366786 <span className="text-xs font-medium text-foreground">Auth</span>
367787 <select
368788 value={authKind}
369789 onChange={(event) =>
370790 setAuthKind(event.target.value as GoogleDiscoveryConnectionAuth["kind"])}
371− className="h-10 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
791+ className="h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
372792 >
373793 <option value="none">None</option>
374794 <option value="bearer">Bearer Secret</option>
@@ -377,62 +797,32 @@ function GoogleDiscoverySourceForm(props: { · 2 unmodified lines
377797 </label>
378798
379799 {authKind === "bearer" && (
380− <label className="grid gap-1.5">
381− <span className="text-xs font-medium text-foreground">Secret</span>
382− <select
800+ <div className="border-l-2 border-border pl-4">
801+ <SecretSelectOrCreateField
802+ label="Secret"
383803 value={bearerSecretRef}
384− onChange={(event) => setBearerSecretRef(event.target.value)}
385− className="h-10 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
386− >
387− <option value="">Select a secret</option>
388− {secrets.status === "ready" &&
389− secrets.data.map((secret) => (
390− <option
391− key={`${secret.providerId}:${secret.id}`}
392− value={JSON.stringify({
393− providerId: secret.providerId,
394− handle: secret.id,
395− })}
396− >
397− {secret.name ?? secret.id}
398− </option>
399− ))}
400− </select>
401− </label>
804+ emptyLabel="Select a secret"
805+ onChange={setBearerSecretRef}
806+ />
807+ </div>
402808 )}
403809
404810 {authKind === "oauth2" && (
405− <div className="space-y-4 rounded-xl border border-border/70 bg-background/50 p-4">
406− <label className="grid gap-1.5">
811+ <div className="space-y-4 border-l-2 border-border pl-4">
812+ <label className="grid gap-2">
407813 <span className="text-xs font-medium text-foreground">Client ID</span>
408814 <input
409815 value={clientId}
410816 onChange={(event) => setClientId(event.target.value)}
411− className="h-10 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
817+ className="h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
412818 />
413819 </label>
414− <label className="grid gap-1.5">
415− <span className="text-xs font-medium text-foreground">Client Secret</span>
416− <select
417− value={clientSecretRef}
418− onChange={(event) => setClientSecretRef(event.target.value)}
419− className="h-10 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
420− >
421− <option value="">Public client / no secret</option>
422− {secrets.status === "ready" &&
423− secrets.data.map((secret) => (
424− <option
425− key={`${secret.providerId}:${secret.id}`}
426− value={JSON.stringify({
427− providerId: secret.providerId,
428− handle: secret.id,
429− })}
430− >
431− {secret.name ?? secret.id}
432− </option>
433− ))}
434− </select>
435− </label>
820+ <SecretSelectOrCreateField
821+ label="Client Secret"
822+ value={clientSecretRef}
823+ emptyLabel="Public client / no secret"
824+ onChange={setClientSecretRef}
825+ />
436826 <div className="flex items-center gap-3">
437827 <button
438828 type="button"
@@ -455,25 +845,23 @@ function GoogleDiscoverySourceForm(props: { · 16 unmodified lines
455845 {oauthStatus === "pending"
456846 ? "Connecting..."
457847 : oauthStatus === "connected"
458− ? "Reconnect OAuth"
459− : "Connect OAuth"}
848+ ? "Reconnect"
849+ : "Connect with Google"}
460850 </button>
461− <div className="text-xs text-muted-foreground">
462− {oauthStatus === "connected"
463− ? "OAuth tokens are ready and will be saved with this source."
464− : "The plugin opens Google OAuth in a popup and stores token refs locally."}
465− </div>
851+ {oauthStatus === "connected" && (
852+ <span className="text-xs text-primary">Connected</span>
853+ )}
466854 </div>
467855 </div>
468856 )}
469857
470858 {error && (
471− <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-3 text-sm text-destructive">
859+ <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-2.5 text-sm text-destructive">
472860 {error}
473861 </div>
474862 )}
475863
476− <div className="flex items-center gap-3">
864+ <div className="flex items-center justify-end">
477865 <button
478866 type="button"
479867 onClick={() => {
@@ -491,7 +879,7 @@ function GoogleDiscoverySourceForm(props: { · 11 unmodified lines
491879 : oauthAuth
492880 ? oauthAuth
493881 : (() => {
494− throw new Error("Finish Google OAuth before saving.");
882+ throw new Error("Complete Google sign-in before saving.");
495883 })();
496884
497885 await submitMutation.mutateAsync({
@@ -507,23 +895,271 @@ function GoogleDiscoverySourceForm(props: { · 9 unmodified lines
507895 setError(
508896 cause instanceof Error
509897 ? cause.message
510− : "Failed saving Google Discovery source.",
898+ : "Failed saving source.",
511899 );
512900 }
513901 })();
514902 }}
515903 disabled={submitMutation.status === "pending"}
516− className="inline-flex h-9 items-center justify-center rounded-lg bg-primary px-3 text-sm font-medium text-primary-foreground transition-opacity disabled:pointer-events-none disabled:opacity-50"
904+ className="inline-flex h-9 items-center justify-center rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90 disabled:pointer-events-none disabled:opacity-50"
517905 >
518906 {submitMutation.status === "pending"
519907 ? props.mode === "create" ? "Creating..." : "Saving..."
520908 : props.mode === "create" ? "Create Source" : "Save Changes"}
521909 </button>
910+ </div>
911+ </div>
912+ );
913+}
914+
915+function GoogleDiscoveryBatchConnectPanel(props: {
916+ onConnected: (sources: ReadonlyArray<{ id: string; name: string }>) => void;
917+}) {
918+ const installation = useLocalInstallation();
919+ const client = getGoogleDiscoveryHttpClient();
920+ const startBatchOAuth = useAtomSet(
921+ client.mutation(GOOGLE_DISCOVERY_PLUGIN_KEY, "startBatchOAuth"),
922+ { mode: "promise" },
923+ );
924+ const [selectedTemplateIds, setSelectedTemplateIds] = useState<
925+ ReadonlyArray<string>
926+ >([]);
927+ const [clientId, setClientId] = useState("");
928+ const [clientSecretRef, setClientSecretRef] = useState("");
929+ const [error, setError] = useState<string | null>(null);
930+ const [status, setStatus] = useState<"idle" | "connecting">("idle");
931+
932+ const selectedTemplates = useMemo(
933+ () =>
934+ GOOGLE_DISCOVERY_BATCH_TEMPLATES.filter((template) =>
935+ selectedTemplateIds.includes(template.id)
936+ ),
937+ [selectedTemplateIds],
938+ );
939+
940+ const toggleTemplate = (templateId: string) => {
941+ setSelectedTemplateIds((current) =>
942+ current.includes(templateId)
943+ ? current.filter((id) => id !== templateId)
944+ : [...current, templateId]
945+ );
946+ };
947+
948+ const handleConnect = async () => {
949+ if (installation.status !== "ready") {
950+ setError("Workspace is still loading.");
951+ return;
952+ }
953+ if (selectedTemplates.length === 0) {
954+ setError("Select at least one Google API.");
955+ return;
956+ }
957+ if (!clientId.trim()) {
958+ setError("Client ID is required for batch Google OAuth.");
959+ return;
960+ }
961+
962+ try {
963+ setError(null);
964+ setStatus("connecting");
965+ const popup = openGoogleOauthPopup("executor-google-discovery-batch-oauth");
966+ if (!popup) {
967+ throw new Error("Failed opening Google OAuth popup. Allow popups for this site and try again.");
968+ }
969+
970+ let started;
971+ try {
972+ started = await startBatchOAuth({
973+ path: {
974+ workspaceId: installation.data.scopeId,
975+ },
976+ payload: {
977+ sources: selectedTemplates.map(batchSourceInputFromTemplate),
978+ clientId: clientId.trim(),
979+ clientSecretRef: clientSecretRef
980+ ? JSON.parse(clientSecretRef)
981+ : null,
982+ clientAuthentication: clientSecretRef ? "client_secret_post" : "none",
983+ redirectUrl: new URL(
984+ GOOGLE_DISCOVERY_OAUTH_CALLBACK_PATH,
985+ window.location.origin,
986+ ).toString(),
987+ } satisfies GoogleDiscoveryStartBatchOAuthInput,
988+ });
989+ } catch (cause) {
990+ popup.close();
991+ throw cause;
992+ }
993+
994+ popup.location.replace(started.authorizationUrl);
995+ popup.focus();
996+
997+ const result = await waitForOauthPopupResult(started.sessionId);
998+ if (!result.ok) {
999+ throw new Error(result.error);
1000+ }
1001+ if (result.mode !== "batch") {
1002+ throw new Error("Unexpected single-source OAuth result for Google batch setup.");
1003+ }
1004+
1005+ setSelectedTemplateIds([]);
1006+ props.onConnected(result.sources);
1007+ } catch (cause) {
1008+ setError(cause instanceof Error ? cause.message : "Failed connecting Google APIs.");
1009+ } finally {
1010+ setStatus("idle");
1011+ }
1012+ };
1013+
1014+ return (
1015+ <div className="space-y-6 rounded-xl border border-border p-6">
1016+ <div>
1017+ <h2 className="text-base font-semibold text-foreground">
1018+ Connect multiple Google APIs
1019+ </h2>
1020+ <p className="mt-1 text-sm text-muted-foreground">
1021+ Select APIs below and connect them all with a single sign-in.
1022+ </p>
1023+ </div>
1024+
1025+ <div className="flex items-center justify-between gap-3">
5221026 <div className="text-xs text-muted-foreground">
523− {props.mode === "create"
524− ? "The discovery document is imported as soon as the source is created."
525− : "Saving refreshes the imported Google API model."}
1027+ {selectedTemplates.length === 0
1028+ ? "No Google APIs selected."
1029+ : `${selectedTemplates.length} Google API${selectedTemplates.length === 1 ? "" : "s"} selected.`}
1030+ </div>
1031+ <div className="flex items-center gap-2">
1032+ <button
1033+ type="button"
1034+ onClick={() => setSelectedTemplateIds([])}
1035+ className="inline-flex h-8 items-center justify-center rounded-lg border border-input bg-card px-3 text-xs font-medium text-foreground transition-colors hover:bg-accent/50"
1036+ >
1037+ Clear
1038+ </button>
1039+ <button
1040+ type="button"
1041+ onClick={() =>
1042+ setSelectedTemplateIds(
1043+ selectedTemplateIds.length === GOOGLE_DISCOVERY_BATCH_TEMPLATES.length
1044+ ? []
1045+ : GOOGLE_DISCOVERY_BATCH_TEMPLATES.map((template) => template.id),
1046+ )
1047+ }
1048+ className="inline-flex h-8 items-center justify-center rounded-lg border border-input bg-card px-3 text-xs font-medium text-foreground transition-colors hover:bg-accent/50"
1049+ >
1050+ {selectedTemplateIds.length === GOOGLE_DISCOVERY_BATCH_TEMPLATES.length
1051+ ? "Deselect all"
1052+ : "Select all"}
1053+ </button>
1054+ </div>
1055+ </div>
1056+
1057+ <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
1058+ {GOOGLE_DISCOVERY_BATCH_TEMPLATES.map((template) => {
1059+ const selected = selectedTemplateIds.includes(template.id);
1060+ const serviceKey = template.service.replaceAll(".", "");
1061+ const iconUrl = GOOGLE_DISCOVERY_SERVICE_ICONS[serviceKey] ?? null;
1062+
1063+ return (
1064+ <button
1065+ key={template.id}
1066+ type="button"
1067+ onClick={() => toggleTemplate(template.id)}
1068+ className={
1069+ selected
1070+ ? "rounded-lg border border-primary/30 bg-primary/5 px-4 py-3 text-left transition-colors"
1071+ : "rounded-lg border border-border px-4 py-3 text-left transition-colors hover:bg-accent/40"
1072+ }
1073+ >
1074+ <div className="flex items-start gap-3">
1075+ <div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted">
1076+ {iconUrl
1077+ ? (
1078+ <img
1079+ src={iconUrl}
1080+ alt=""
1081+ className="size-5 object-contain"
1082+ />
1083+ )
1084+ : (
1085+ <span className="text-sm font-semibold text-muted-foreground">
1086+ {template.name.slice(0, 1)}
1087+ </span>
1088+ )}
1089+ </div>
1090+ <div className="min-w-0 flex-1">
1091+ <div className="flex items-start justify-between gap-3">
1092+ <div className="min-w-0">
1093+ <div className="truncate text-sm font-semibold text-foreground">
1094+ {template.name}
1095+ </div>
1096+ <div className="mt-0.5 text-[11px] text-muted-foreground">
1097+ {template.service} {template.version}
1098+ </div>
1099+ </div>
1100+ <div
1101+ className={
1102+ selected
1103+ ? "mt-0.5 size-3 rounded-full bg-primary"
1104+ : "mt-0.5 size-3 rounded-full border border-border bg-background"
1105+ }
1106+ />
1107+ </div>
1108+ <div className="mt-2 text-xs leading-5 text-muted-foreground">
1109+ {template.summary}
1110+ </div>
1111+ </div>
1112+ </div>
1113+ </button>
1114+ );
1115+ })}
1116+ </div>
1117+
1118+ <div className="grid gap-4 md:grid-cols-2">
1119+ <label className="grid gap-2 md:col-span-2">
1120+ <span className="text-xs font-medium text-foreground">Client ID</span>
1121+ <input
1122+ value={clientId}
1123+ onChange={(event) => setClientId(event.target.value)}
1124+ placeholder="1234567890-abcdef.apps.googleusercontent.com"
1125+ className="h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
1126+ />
1127+ </label>
1128+ <div className="md:col-span-2">
1129+ <SecretSelectOrCreateField
1130+ label="Client Secret"
1131+ value={clientSecretRef}
1132+ emptyLabel="Public client / no secret"
1133+ onChange={setClientSecretRef}
1134+ />
1135+ </div>
1136+ </div>
1137+
1138+ {error && (
1139+ <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-3 text-sm text-destructive">
1140+ {error}
5261141 </div>
1142+ )}
1143+
1144+ <div className="flex items-center justify-end gap-3">
1145+ <button
1146+ type="button"
1147+ onClick={() => {
1148+ void handleConnect();
1149+ }}
1150+ disabled={
1151+ status === "connecting"
1152+ || selectedTemplates.length === 0
1153+ || clientId.trim().length === 0
1154+ }
1155+ className="inline-flex h-9 items-center justify-center rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90 disabled:pointer-events-none disabled:opacity-50"
1156+ >
1157+ {status === "connecting"
1158+ ? "Connecting..."
1159+ : selectedTemplates.length > 0
1160+ ? `Connect ${selectedTemplates.length} Google API${selectedTemplates.length === 1 ? "" : "s"}`
1161+ : "Connect Google APIs"}
1162+ </button>
5271163 </div>
5281164 </div>
5291165 );
@@ -531,6 +1167,7 @@ function GoogleDiscoverySourceForm(props: { · 1 unmodified line
5311167
5321168 function GoogleDiscoveryAddPage() {
5331169 const navigation = useSourcePluginNavigation();
1170+ const initialValue = googleInputFromSearch(useSourcePluginSearch());
5341171 const installation = useLocalInstallation();
5351172 const client = getGoogleDiscoveryHttpClient();
5361173 const createSource = useAtomSet(
@@ -543,27 +1180,52 @@ function GoogleDiscoveryAddPage() { · 6 unmodified lines
5431180 }
5441181
5451182 return (
546− <GoogleDiscoverySourceForm
547− initialValue={defaultGoogleInput()}
548− mode="create"
549− onSubmit={async (input) => {
550− const source = await createSource({
551− path: {
552− workspaceId: installation.data.scopeId,
553− },
554− payload: input,
555− reactivityKeys: {
556− sources: [installation.data.scopeId],
557− },
558− });
1183+ <div className="space-y-6">
1184+ <GoogleDiscoveryBatchConnectPanel
1185+ onConnected={(sources) => {
1186+ startTransition(() => {
1187+ if (sources.length === 1) {
1188+ void navigation.detail(sources[0]!.id, {
1189+ tab: "model",
1190+ });
1191+ return;
1192+ }
5591193
560− startTransition(() => {
561− void navigation.detail(source.id, {
562− tab: "model",
1194+ void navigation.home();
5631195 });
564− });
565− }}
566− />
1196+ }}
1197+ />
1198+
1199+ <div className="flex items-center gap-3">
1200+ <div className="h-px flex-1 bg-border" />
1201+ <div className="text-xs font-medium text-muted-foreground">
1202+ or add a single API
1203+ </div>
1204+ <div className="h-px flex-1 bg-border" />
1205+ </div>
1206+
1207+ <GoogleDiscoverySourceForm
1208+ initialValue={initialValue}
1209+ mode="create"
1210+ onSubmit={async (input) => {
1211+ const source = await createSource({
1212+ path: {
1213+ workspaceId: installation.data.scopeId,
1214+ },
1215+ payload: input,
1216+ reactivityKeys: {
1217+ sources: [installation.data.scopeId],
1218+ },
1219+ });
1220+
1221+ startTransition(() => {
1222+ void navigation.detail(source.id, {
1223+ tab: "model",
1224+ });
1225+ });
1226+ }}
1227+ />
1228+ </div>
5671229 );
5681230 }
5691231
@@ -601,13 +1263,13 @@ function GoogleDiscoveryEditPage(props: { · 31 unmodified lines
6011263 if (Result.isFailure(configResult)) {
6021264 return (
6031265 <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-3 text-sm text-destructive">
604− Failed loading Google Discovery config.
1266+ Failed loading source configuration.
6051267 </div>
6061268 );
6071269 }
6081270
6091271 if (!Result.isSuccess(configResult)) {
610− return <div className="text-sm text-muted-foreground">Loading Google Discovery config...</div>;
1272+ return <div className="text-sm text-muted-foreground">Loading configuration...</div>;
6111273 }
6121274
6131275 return (
@@ -653,6 +1315,8 @@ function GoogleDiscoveryDetailPage(props: { · 39 unmodified lines
6531315 client.mutation(GOOGLE_DISCOVERY_PLUGIN_KEY, "removeSource"),
6541316 { mode: "promise" },
6551317 );
1318+ const [confirmDelete, setConfirmDelete] = useState(false);
1319+ const [isDeleting, setIsDeleting] = useState(false);
6561320 const configResult = useAtomValue(
6571321 installation.status === "ready"
6581322 ? client.query(GOOGLE_DISCOVERY_PLUGIN_KEY, "getSourceConfig", {
@@ -676,16 +1340,11 @@ function GoogleDiscoveryDetailPage(props: { · 17 unmodified lines
6761340
6771341 const config = configResult.value;
6781342 return (
679− <div className="space-y-1">
680− <div className="font-mono text-xs text-foreground">
1343+ <div className="space-y-1 text-xs text-muted-foreground">
1344+ <div className="text-foreground">{config.service} {config.version}</div>
1345+ <div className="font-mono truncate">
6811346 {config.discoveryUrl ?? defaultGoogleDiscoveryUrl(config.service, config.version)}
6821347 </div>
683− <div>
684− API: <span className="text-foreground">{config.service} {config.version}</span>
685− </div>
686− <div>
687− Auth: <span className="text-foreground">{config.auth.kind}</span>
688− </div>
6891348 </div>
6901349 );
6911350 }, [configResult]);
@@ -694,6 +1353,35 @@ function GoogleDiscoveryDetailPage(props: { · 2 unmodified lines
6941353 return <div className="text-sm text-muted-foreground">Loading workspace...</div>;
6951354 }
6961355
1356+ const handleDelete = async () => {
1357+ if (isDeleting) {
1358+ return;
1359+ }
1360+
1361+ setIsDeleting(true);
1362+ try {
1363+ await removeSource({
1364+ path: {
1365+ workspaceId: installation.data.scopeId,
1366+ sourceId: props.source.id,
1367+ },
1368+ reactivityKeys: {
1369+ sources: [installation.data.scopeId],
1370+ source: [installation.data.scopeId, props.source.id],
1371+ sourceInspection: [installation.data.scopeId, props.source.id],
1372+ sourceInspectionTool: [installation.data.scopeId, props.source.id],
1373+ sourceDiscovery: [installation.data.scopeId, props.source.id],
1374+ },
1375+ });
1376+ startTransition(() => {
1377+ void navigation.home();
1378+ });
1379+ } finally {
1380+ setIsDeleting(false);
1381+ setConfirmDelete(false);
1382+ }
1383+ };
1384+
6971385 return (
6981386 <SourceToolExplorer
6991387 sourceId={props.source.id}
@@ -713,38 +1401,40 @@ function GoogleDiscoveryDetailPage(props: { · 13 unmodified lines
7131401 <IconPencil className="size-3.5" />
7141402 Edit
7151403 </button>
716− <button
717− type="button"
718− onClick={() => {
719− const confirmed = window.confirm(
720− `Delete Google Discovery source "${props.source.name}"?`,
721− );
722− if (!confirmed) {
723− return;
724− }
725−
726− void removeSource({
727− path: {
728− workspaceId: installation.data.scopeId,
729− sourceId: props.source.id,
730− },
731− reactivityKeys: {
732− sources: [installation.data.scopeId],
733− source: [installation.data.scopeId, props.source.id],
734− sourceInspection: [installation.data.scopeId, props.source.id],
735− sourceInspectionTool: [installation.data.scopeId, props.source.id],
736− sourceDiscovery: [installation.data.scopeId, props.source.id],
737− },
738− }).then(() => {
739− startTransition(() => {
740− void navigation.home();
741− });
742− });
743− }}
744− className="inline-flex h-9 items-center rounded-lg border border-destructive/25 bg-destructive/5 px-3 text-sm font-medium text-destructive transition-colors hover:bg-destructive/10"
745− >
746− Delete
747− </button>
1404+ {confirmDelete ? (
1405+ <div className="flex items-center gap-2">
1406+ <span className="text-xs font-medium text-destructive">
1407+ Confirm delete?
1408+ </span>
1409+ <button
1410+ type="button"
1411+ onClick={() => setConfirmDelete(false)}
1412+ disabled={isDeleting}
1413+ className="inline-flex h-9 items-center rounded-lg border border-input bg-card px-3 text-sm font-medium text-foreground transition-colors hover:bg-accent/50 disabled:pointer-events-none disabled:opacity-50"
1414+ >
1415+ Cancel
1416+ </button>
1417+ <button
1418+ type="button"
1419+ onClick={() => {
1420+ void handleDelete().catch(() => {});
1421+ }}
1422+ disabled={isDeleting}
1423+ className="inline-flex h-9 items-center rounded-lg border border-destructive/25 bg-destructive/5 px-3 text-sm font-medium text-destructive transition-colors hover:bg-destructive/10 disabled:pointer-events-none disabled:opacity-50"
1424+ >
1425+ {isDeleting ? "Deleting..." : "Delete"}
1426+ </button>
1427+ </div>
1428+ ) : (
1429+ <button
1430+ type="button"
1431+ onClick={() => setConfirmDelete(true)}
1432+ disabled={isDeleting}
1433+ className="inline-flex h-9 items-center rounded-lg border border-destructive/25 bg-destructive/5 px-3 text-sm font-medium text-destructive transition-colors hover:bg-destructive/10 disabled:pointer-events-none disabled:opacity-50"
1434+ >
1435+ Delete
1436+ </button>
1437+ )}
7481438 </>
7491439 )}
7501440 />
@@ -755,7 +1445,7 @@ const googleDiscoverySourceType = defineFrontendSourceType({ · 4 unmodified lines
7551445 key: GOOGLE_DISCOVERY_PLUGIN_KEY,
7561446 kind: GOOGLE_DISCOVERY_SOURCE_KIND,
7571447 displayName: "Google Discovery",
758− description: "Import Google APIs from discovery documents with plugin-owned OAuth.",
1448+ description: "Connect Google Workspace and Cloud APIs via discovery documents.",
7591449 getIconUrl: getGoogleDiscoveryIconUrl,
7601450 renderAddPage: GoogleDiscoveryAddPage,
7611451 renderEditPage: GoogleDiscoveryEditPage,
plugins/google-discovery/sdk/index.ts+230−27
@@ -22,16 +22,19 @@ import { · 21 unmodified lines
2222 import {
2323 GOOGLE_DISCOVERY_EXECUTOR_KEY,
2424 GOOGLE_DISCOVERY_SOURCE_KIND,
25+ GoogleDiscoveryBatchSourceInputSchema,
2526 GoogleDiscoveryConnectionAuthSchema,
2627 GoogleDiscoveryOAuthSessionSchema,
2728 defaultGoogleDiscoveryUrl,
2829 deriveGoogleDiscoveryNamespace,
2930 GoogleDiscoveryStoredSourceDataSchema,
31+ type GoogleDiscoveryBatchSourceInput,
3032 type GoogleDiscoveryConnectInput,
3133 type GoogleDiscoveryConnectionAuth,
3234 type GoogleDiscoveryOAuthPopupResult,
3335 type GoogleDiscoveryOAuthSession,
3436 type GoogleDiscoverySourceConfigPayload,
37+ type GoogleDiscoveryStartBatchOAuthInput,
3538 type GoogleDiscoveryStartOAuthInput,
3639 type GoogleDiscoveryStartOAuthResult,
3740 type GoogleDiscoveryStoredSourceData,
@@ -64,6 +67,9 @@ import { · 26 unmodified lines
6467 const decodeStoredSourceData = Schema.decodeUnknownSync(
6568 GoogleDiscoveryStoredSourceDataSchema,
6669 );
70+const decodeBatchSourceInput = Schema.decodeUnknownSync(
71+ GoogleDiscoveryBatchSourceInputSchema,
72+);
6773 const decodeSession = Schema.decodeUnknownSync(GoogleDiscoveryOAuthSessionSchema);
6874 const decodeProviderData = Schema.decodeUnknownSync(GoogleDiscoveryToolProviderDataSchema);
6975
@@ -108,6 +114,9 @@ export type GoogleDiscoverySdk = { · 38 unmodified lines
108114 startOAuth: (
109115 input: GoogleDiscoveryStartOAuthInput,
110116 ) => Effect.Effect<GoogleDiscoveryStartOAuthResult, Error>;
117+ startBatchOAuth: (
118+ input: GoogleDiscoveryStartBatchOAuthInput,
119+ ) => Effect.Effect<GoogleDiscoveryStartOAuthResult, Error>;
111120 completeOAuth: (input: {
112121 state: string;
113122 code?: string;
@@ -129,6 +138,10 @@ const GoogleDiscoveryExecutorAddInputSchema = Schema.Struct({ · 15 unmodified lines
129138
130139 type GoogleDiscoveryExecutorAddInput =
131140 typeof GoogleDiscoveryExecutorAddInputSchema.Type;
141+type GoogleDiscoveryOAuthAuth = Extract<
142+ GoogleDiscoveryConnectionAuth,
143+ { kind: "oauth2" }
144+>;
132145
133146 const GOOGLE_AUTHORIZATION_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth";
134147 const GOOGLE_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
@@ -468,6 +481,70 @@ const sourceConfigFromStored = ( · 333 unmodified lines
468481 auth: stored.auth,
469482 });
470483
484+const normalizeBatchSourceInput = (
485+ input: GoogleDiscoveryBatchSourceInput,
486+): GoogleDiscoveryBatchSourceInput =>
487+ decodeBatchSourceInput({
488+ name: input.name.trim(),
489+ service: input.service.trim(),
490+ version: input.version.trim(),
491+ discoveryUrl:
492+ input.discoveryUrl?.trim()
493+ || defaultGoogleDiscoveryUrl(input.service, input.version),
494+ defaultHeaders: input.defaultHeaders,
495+ scopes: [...new Set(input.scopes.map((scope) => scope.trim()).filter(Boolean))],
496+ });
497+
498+const createOAuthSourceData = (input: {
499+ service: string;
500+ version: string;
501+ discoveryUrl: string;
502+ defaultHeaders: Record<string, string> | null;
503+ scopes: ReadonlyArray<string>;
504+ clientId: string;
505+ clientSecretRef: GoogleDiscoveryOAuthAuth["clientSecretRef"];
506+ clientAuthentication: GoogleDiscoveryOAuthAuth["clientAuthentication"];
507+ accessTokenRef: GoogleDiscoveryOAuthAuth["accessTokenRef"];
508+ refreshTokenRef: GoogleDiscoveryOAuthAuth["refreshTokenRef"];
509+ expiresAt: number | null;
510+}): GoogleDiscoveryStoredSourceData =>
511+ decodeStoredSourceData({
512+ service: input.service,
513+ version: input.version,
514+ discoveryUrl: input.discoveryUrl,
515+ defaultHeaders: input.defaultHeaders,
516+ scopes: [...input.scopes],
517+ auth: {
518+ kind: "oauth2",
519+ clientId: input.clientId,
520+ clientSecretRef: input.clientSecretRef,
521+ clientAuthentication: input.clientAuthentication,
522+ authorizationEndpoint: GOOGLE_AUTHORIZATION_ENDPOINT,
523+ tokenEndpoint: GOOGLE_TOKEN_ENDPOINT,
524+ scopes: [...input.scopes],
525+ accessTokenRef: input.accessTokenRef,
526+ refreshTokenRef: input.refreshTokenRef,
527+ expiresAt: input.expiresAt,
528+ },
529+ });
530+
531+const resolveScopesForBatchSource = (input: {
532+ source: GoogleDiscoveryBatchSourceInput;
533+}): Effect.Effect<ReadonlyArray<string>, Error> =>
534+ input.source.scopes.length > 0
535+ ? Effect.succeed([...input.source.scopes])
536+ : fetchGoogleDiscoveryDocumentWithHeaders({
537+ url: input.source.discoveryUrl ?? defaultGoogleDiscoveryUrl(input.source.service, input.source.version),
538+ headers: input.source.defaultHeaders ?? {},
539+ }).pipe(
540+ Effect.flatMap((document) =>
541+ inferScopesFromDiscoveryDocument({
542+ service: input.source.service,
543+ document,
544+ })
545+ ),
546+ );
547+
471548 const createGoogleDiscoverySourceSdk = (
472549 options: {
473550 storage: GoogleDiscoverySourceStorage;
@@ -801,6 +878,7 @@ export const googleDiscoverySdkPlugin = (options: { · 327 unmodified lines
801878 yield* options.oauthSessions.put({
802879 sessionId,
803880 value: decodeSession({
881+ kind: "single",
804882 service: input.service.trim(),
805883 version: input.version.trim(),
806884 discoveryUrl,
@@ -833,6 +911,65 @@ export const googleDiscoverySdkPlugin = (options: { · 26 unmodified lines
833911 };
834912 }),
835913 ),
914+ startBatchOAuth: (input) =>
915+ provideRuntime(
916+ Effect.gen(function* () {
917+ const normalizedSources = input.sources.map((source) =>
918+ normalizeBatchSourceInput(source)
919+ );
920+ if (normalizedSources.length === 0) {
921+ return yield* Effect.fail(
922+ new Error("Select at least one Google API to connect."),
923+ );
924+ }
925+
926+ const inferredScopes = yield* Effect.forEach(
927+ normalizedSources,
928+ (source) =>
929+ resolveScopesForBatchSource({
930+ source,
931+ }),
932+ );
933+ const scopes = [...new Set(inferredScopes.flat())];
934+ const sessionId = `gdisc_oauth_${crypto.randomUUID()}`;
935+ const codeVerifier = createPkceCodeVerifier();
936+
937+ yield* options.oauthSessions.put({
938+ sessionId,
939+ value: decodeSession({
940+ kind: "batch",
941+ sources: normalizedSources.map((source, index) => ({
942+ ...source,
943+ scopes: inferredScopes[index] ?? [],
944+ })),
945+ scopes,
946+ clientId: input.clientId.trim(),
947+ clientSecretRef: input.clientSecretRef,
948+ clientAuthentication: input.clientAuthentication,
949+ redirectUrl: input.redirectUrl,
950+ codeVerifier,
951+ }),
952+ });
953+
954+ return {
955+ sessionId,
956+ authorizationUrl: buildOAuth2AuthorizationUrl({
957+ authorizationEndpoint: GOOGLE_AUTHORIZATION_ENDPOINT,
958+ clientId: input.clientId.trim(),
959+ redirectUri: input.redirectUrl,
960+ scopes,
961+ state: sessionId,
962+ codeVerifier,
963+ extraParams: {
964+ access_type: "offline",
965+ prompt: "consent",
966+ include_granted_scopes: "true",
967+ },
968+ }),
969+ scopes,
970+ };
971+ }),
972+ ),
836973 completeOAuth: (input) =>
837974 provideRuntime(
838975 Effect.gen(function* () {
@@ -870,18 +1007,94 @@ export const googleDiscoverySdkPlugin = (options: { · 31 unmodified lines
8701007 codeVerifier: session.codeVerifier,
8711008 code: input.code,
8721009 });
873− const accessTokenRef = yield* storeSecretMaterial({
874− purpose: "oauth_access_token",
875− value: tokenResponse.access_token,
876− name: `${session.service} Google Access Token`,
877− });
878− const refreshTokenRef = tokenResponse.refresh_token
879− ? yield* storeSecretMaterial({
880− purpose: "oauth_refresh_token",
881− value: tokenResponse.refresh_token,
882− name: `${session.service} Google Refresh Token`,
883− })
884− : null;
1010+ const expiresAt =
1011+ typeof tokenResponse.expires_in === "number"
1012+ ? Date.now() + tokenResponse.expires_in * 1000
1013+ : null;
1014+
1015+ if (session.kind === "single") {
1016+ const accessTokenRef = yield* storeSecretMaterial({
1017+ purpose: "oauth_access_token",
1018+ value: tokenResponse.access_token,
1019+ name: `${session.service} Google Access Token`,
1020+ });
1021+ const refreshTokenRef = tokenResponse.refresh_token
1022+ ? yield* storeSecretMaterial({
1023+ purpose: "oauth_refresh_token",
1024+ value: tokenResponse.refresh_token,
1025+ name: `${session.service} Google Refresh Token`,
1026+ })
1027+ : null;
1028+
1029+ if (options.oauthSessions.remove) {
1030+ yield* options.oauthSessions.remove(input.state);
1031+ }
1032+
1033+ return {
1034+ type: "executor:oauth-result",
1035+ ok: true as const,
1036+ sessionId: input.state,
1037+ mode: "single" as const,
1038+ auth: {
1039+ kind: "oauth2" as const,
1040+ clientId: session.clientId,
1041+ clientSecretRef: session.clientSecretRef,
1042+ clientAuthentication: session.clientAuthentication,
1043+ authorizationEndpoint: GOOGLE_AUTHORIZATION_ENDPOINT,
1044+ tokenEndpoint: GOOGLE_TOKEN_ENDPOINT,
1045+ scopes: [...session.scopes],
1046+ accessTokenRef,
1047+ refreshTokenRef,
1048+ expiresAt,
1049+ },
1050+ };
1051+ }
1052+
1053+ const createdSources = [];
1054+ for (const source of session.sources) {
1055+ const createdSource = yield* host.sources.create({
1056+ source: {
1057+ name: source.name.trim(),
1058+ kind: GOOGLE_DISCOVERY_SOURCE_KIND,
1059+ status: "connected",
1060+ enabled: true,
1061+ namespace: deriveGoogleDiscoveryNamespace(source.service),
1062+ },
1063+ });
1064+ const accessTokenRef = yield* storeSecretMaterial({
1065+ purpose: "oauth_access_token",
1066+ value: tokenResponse.access_token,
1067+ name: `${source.service} Google Access Token`,
1068+ });
1069+ const refreshTokenRef = tokenResponse.refresh_token
1070+ ? yield* storeSecretMaterial({
1071+ purpose: "oauth_refresh_token",
1072+ value: tokenResponse.refresh_token,
1073+ name: `${source.service} Google Refresh Token`,
1074+ })
1075+ : null;
1076+
1077+ yield* options.storage.put({
1078+ scopeId: createdSource.scopeId,
1079+ sourceId: createdSource.id,
1080+ value: createOAuthSourceData({
1081+ service: source.service,
1082+ version: source.version,
1083+ discoveryUrl: source.discoveryUrl,
1084+ defaultHeaders: source.defaultHeaders,
1085+ scopes: source.scopes.length > 0 ? source.scopes : session.scopes,
1086+ clientId: session.clientId,
1087+ clientSecretRef: session.clientSecretRef,
1088+ clientAuthentication: session.clientAuthentication,
1089+ accessTokenRef,
1090+ refreshTokenRef,
1091+ expiresAt,
1092+ }),
1093+ });
1094+
1095+ const refreshedSource = yield* host.sources.refreshCatalog(createdSource.id);
1096+ createdSources.push(refreshedSource);
1097+ }
8851098
8861099 if (options.oauthSessions.remove) {
8871100 yield* options.oauthSessions.remove(input.state);
@@ -891,21 +1104,11 @@ export const googleDiscoverySdkPlugin = (options: { · 3 unmodified lines
8911104 type: "executor:oauth-result",
8921105 ok: true as const,
8931106 sessionId: input.state,
894− auth: {
895− kind: "oauth2" as const,
896− clientId: session.clientId,
897− clientSecretRef: session.clientSecretRef,
898− clientAuthentication: session.clientAuthentication,
899− authorizationEndpoint: GOOGLE_AUTHORIZATION_ENDPOINT,
900− tokenEndpoint: GOOGLE_TOKEN_ENDPOINT,
901− scopes: [...session.scopes],
902− accessTokenRef,
903− refreshTokenRef,
904− expiresAt:
905− typeof tokenResponse.expires_in === "number"
906− ? Date.now() + tokenResponse.expires_in * 1000
907− : null,
908− },
1107+ mode: "batch" as const,
1108+ sources: createdSources.map((source) => ({
1109+ id: source.id,
1110+ name: source.name,
1111+ })),
9091112 };
9101113 }),
9111114 ),
plugins/google-discovery/shared/index.ts+61−1
@@ -79,18 +79,56 @@ export const GoogleDiscoveryStartOAuthInputSchema = Schema.Struct({ · 78 unmodified lines
7979 redirectUrl: Schema.String,
8080 });
8181
82+export const GoogleDiscoveryBatchSourceInputSchema = Schema.Struct({
83+ name: Schema.String,
84+ service: Schema.String,
85+ version: Schema.String,
86+ discoveryUrl: Schema.NullOr(Schema.String),
87+ defaultHeaders: Schema.NullOr(StringMapSchema),
88+ scopes: Schema.Array(Schema.String),
89+});
90+
91+export const GoogleDiscoveryStartBatchOAuthInputSchema = Schema.Struct({
92+ sources: Schema.Array(GoogleDiscoveryBatchSourceInputSchema),
93+ clientId: Schema.String,
94+ clientSecretRef: Schema.NullOr(SecretRefSchema),
95+ clientAuthentication: GoogleDiscoveryOAuthClientAuthenticationSchema,
96+ redirectUrl: Schema.String,
97+});
98+
8299 export const GoogleDiscoveryStartOAuthResultSchema = Schema.Struct({
83100 sessionId: Schema.String,
84101 authorizationUrl: Schema.String,
85102 scopes: Schema.Array(Schema.String),
86103 });
87104
88−export const GoogleDiscoveryOAuthSessionSchema = Schema.Struct({
105+const GoogleDiscoverySingleOAuthSessionSchema = Schema.Struct({
106+ kind: Schema.Literal("single"),
107+ service: Schema.String,
108+ version: Schema.String,
109+ discoveryUrl: Schema.String,
110+ defaultHeaders: Schema.NullOr(StringMapSchema),
111+ scopes: Schema.Array(Schema.String),
112+ clientId: Schema.String,
113+ clientSecretRef: Schema.NullOr(SecretRefSchema),
114+ clientAuthentication: GoogleDiscoveryOAuthClientAuthenticationSchema,
115+ redirectUrl: Schema.String,
116+ codeVerifier: Schema.String,
117+});
118+
119+const GoogleDiscoveryBatchOAuthSessionSourceSchema = Schema.Struct({
120+ name: Schema.String,
89121 service: Schema.String,
90122 version: Schema.String,
91123 discoveryUrl: Schema.String,
92124 defaultHeaders: Schema.NullOr(StringMapSchema),
93125 scopes: Schema.Array(Schema.String),
126+});
127+
128+const GoogleDiscoveryBatchOAuthSessionSchema = Schema.Struct({
129+ kind: Schema.Literal("batch"),
130+ sources: Schema.Array(GoogleDiscoveryBatchOAuthSessionSourceSchema),
131+ scopes: Schema.Array(Schema.String),
94132 clientId: Schema.String,
95133 clientSecretRef: Schema.NullOr(SecretRefSchema),
96134 clientAuthentication: GoogleDiscoveryOAuthClientAuthenticationSchema,
@@ -98,6 +136,11 @@ export const GoogleDiscoveryOAuthSessionSchema = Schema.Struct({ · 1 unmodified line
98136 codeVerifier: Schema.String,
99137 });
100138
139+export const GoogleDiscoveryOAuthSessionSchema = Schema.Union(
140+ GoogleDiscoverySingleOAuthSessionSchema,
141+ GoogleDiscoveryBatchOAuthSessionSchema,
142+);
143+
101144 const GoogleDiscoveryOAuthPopupSuccessAuthSchema = Schema.Struct({
102145 kind: Schema.Literal("oauth2"),
103146 clientId: Schema.String,
@@ -111,13 +154,26 @@ const GoogleDiscoveryOAuthPopupSuccessAuthSchema = Schema.Struct({ · 7 unmodified lines
111154 expiresAt: Schema.NullOr(Schema.Number),
112155 });
113156
157+const GoogleDiscoveryBatchOAuthPopupSourceSchema = Schema.Struct({
158+ id: Schema.String,
159+ name: Schema.String,
160+});
161+
114162 export const GoogleDiscoveryOAuthPopupResultSchema = Schema.Union(
115163 Schema.Struct({
116164 type: Schema.Literal("executor:oauth-result"),
117165 ok: Schema.Literal(true),
118166 sessionId: Schema.String,
167+ mode: Schema.Literal("single"),
119168 auth: GoogleDiscoveryOAuthPopupSuccessAuthSchema,
120169 }),
170+ Schema.Struct({
171+ type: Schema.Literal("executor:oauth-result"),
172+ ok: Schema.Literal(true),
173+ sessionId: Schema.String,
174+ mode: Schema.Literal("batch"),
175+ sources: Schema.Array(GoogleDiscoveryBatchOAuthPopupSourceSchema),
176+ }),
121177 Schema.Struct({
122178 type: Schema.Literal("executor:oauth-result"),
123179 ok: Schema.Literal(false),
@@ -140,6 +196,10 @@ export type GoogleDiscoveryStoredSourceData = · 16 unmodified lines
140196 typeof GoogleDiscoveryStoredSourceDataSchema.Type;
141197 export type GoogleDiscoveryStartOAuthInput =
142198 typeof GoogleDiscoveryStartOAuthInputSchema.Type;
199+export type GoogleDiscoveryBatchSourceInput =
200+ typeof GoogleDiscoveryBatchSourceInputSchema.Type;
201+export type GoogleDiscoveryStartBatchOAuthInput =
202+ typeof GoogleDiscoveryStartBatchOAuthInputSchema.Type;
143203 export type GoogleDiscoveryStartOAuthResult =
144204 typeof GoogleDiscoveryStartOAuthResultSchema.Type;
145205 export type GoogleDiscoveryOAuthSession =
plugins/graphql/react/index.tsx+353−115
@@ -11,6 +11,7 @@ import { · 10 unmodified lines
1111 Result,
1212 useAtomSet,
1313 useAtomValue,
14+ useCreateSecret,
1415 useExecutorMutation,
1516 useLocalInstallation,
1617 usePrefetchToolDetail,
@@ -77,6 +78,53 @@ const defaultGraphqlInput = (): GraphqlConnectInput => ({ · 60 unmodified lines
7778 },
7879 });
7980
81+const DEFAULT_BEARER_HEADER_NAME = "Authorization";
82+const DEFAULT_BEARER_PREFIX = "Bearer ";
83+const CREATE_SECRET_VALUE = "__create_graphql_secret__";
84+
85+const presetString = (
86+ search: Record<string, unknown>,
87+ key: string,
88+): string | null => {
89+ const value = search[key];
90+ return typeof value === "string" && value.trim().length > 0
91+ ? value.trim()
92+ : null;
93+};
94+
95+const firstSearchString = (
96+ search: Record<string, unknown>,
97+ keys: ReadonlyArray<string>,
98+): string | null => {
99+ for (const key of keys) {
100+ const value = presetString(search, key);
101+ if (value) {
102+ return value;
103+ }
104+ }
105+
106+ return null;
107+};
108+
109+const graphqlInputFromSearch = (
110+ search: Record<string, unknown>,
111+): GraphqlConnectInput => {
112+ const defaults = defaultGraphqlInput();
113+
114+ return {
115+ ...defaults,
116+ name: presetString(search, "presetName") ?? defaults.name,
117+ endpoint:
118+ firstSearchString(search, [
119+ "presetEndpoint",
120+ "endpoint",
121+ "inputUrl",
122+ "pastedUrl",
123+ "url",
124+ ]) ?? defaults.endpoint,
125+ };
126+};
127+
80128 const parseStringMap = (value: string): Record<string, string> | null => {
81129 const trimmed = value.trim();
82130 if (!trimmed) {
@@ -108,9 +156,26 @@ const secretValue = (input: GraphqlConnectionAuth): string => · 25 unmodified lines
108156 ? JSON.stringify(input.tokenSecretRef)
109157 : "";
110158
159+const bearerHeaderNameValue = (input: GraphqlConnectionAuth): string =>
160+ input.kind === "bearer"
161+ ? input.headerName?.trim() || DEFAULT_BEARER_HEADER_NAME
162+ : DEFAULT_BEARER_HEADER_NAME;
163+
164+const bearerPrefixValue = (input: GraphqlConnectionAuth): string =>
165+ input.kind === "bearer"
166+ ? input.prefix ?? DEFAULT_BEARER_PREFIX
167+ : DEFAULT_BEARER_PREFIX;
168+
169+const trimToNull = (value: string): string | null => {
170+ const trimmed = value.trim();
171+ return trimmed.length > 0 ? trimmed : null;
172+};
173+
111174 const authFromSecretValue = (
112175 authKind: GraphqlConnectionAuth["kind"],
113176 value: string,
177+ headerName: string,
178+ prefix: string,
114179 ): GraphqlConnectionAuth => {
115180 if (authKind === "none") {
116181 return {
@@ -125,9 +190,139 @@ const authFromSecretValue = ( · 8 unmodified lines
125190 return {
126191 kind: "bearer",
127192 tokenSecretRef: JSON.parse(value) as GraphqlConnectionAuth & { tokenSecretRef: never }["tokenSecretRef"],
193+ headerName: trimToNull(headerName),
194+ prefix: prefix === DEFAULT_BEARER_PREFIX ? null : prefix,
128195 };
129196 };
130197
198+function SecretSelectOrCreateField(props: {
199+ label: string;
200+ value: string;
201+ emptyLabel: string;
202+ draftNamePlaceholder: string;
203+ draftValuePlaceholder: string;
204+ onChange: (value: string) => void;
205+}) {
206+ const secrets = useSecrets();
207+ const createSecret = useCreateSecret();
208+ const [draftName, setDraftName] = useState("");
209+ const [draftValue, setDraftValue] = useState("");
210+ const [createError, setCreateError] = useState<string | null>(null);
211+ const [showCreate, setShowCreate] = useState(false);
212+
213+ const handleSelectChange = (nextValue: string) => {
214+ if (nextValue === CREATE_SECRET_VALUE) {
215+ setShowCreate(true);
216+ props.onChange("");
217+ return;
218+ }
219+
220+ setShowCreate(false);
221+ setCreateError(null);
222+ props.onChange(nextValue);
223+ };
224+
225+ const handleCreate = async () => {
226+ const trimmedName = draftName.trim();
227+ if (!trimmedName) {
228+ setCreateError("Secret name is required.");
229+ return;
230+ }
231+ if (!draftValue.trim()) {
232+ setCreateError("Secret value is required.");
233+ return;
234+ }
235+
236+ try {
237+ setCreateError(null);
238+ const created = await createSecret.mutateAsync({
239+ name: trimmedName,
240+ value: draftValue,
241+ });
242+ props.onChange(JSON.stringify({
243+ providerId: created.providerId,
244+ handle: created.id,
245+ }));
246+ setDraftName("");
247+ setDraftValue("");
248+ setShowCreate(false);
249+ } catch (cause) {
250+ setCreateError(cause instanceof Error ? cause.message : "Failed creating secret.");
251+ }
252+ };
253+
254+ return (
255+ <div className="grid gap-2">
256+ <span className="text-xs font-medium text-foreground">{props.label}</span>
257+ <select
258+ value={showCreate ? CREATE_SECRET_VALUE : props.value}
259+ onChange={(event) => handleSelectChange(event.target.value)}
260+ className="h-10 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
261+ >
262+ <option value="">{props.emptyLabel}</option>
263+ {secrets.status === "ready" &&
264+ secrets.data.map((secret) => (
265+ <option
266+ key={`${secret.providerId}:${secret.id}`}
267+ value={JSON.stringify({
268+ providerId: secret.providerId,
269+ handle: secret.id,
270+ })}
271+ >
272+ {secret.name ?? secret.id}
273+ </option>
274+ ))}
275+ <option value={CREATE_SECRET_VALUE}>Create new secret</option>
276+ </select>
277+
278+ {showCreate && (
279+ <div className="space-y-3 rounded-lg border border-border/70 bg-background/50 p-3">
280+ <input
281+ value={draftName}
282+ onChange={(event) => setDraftName(event.target.value)}
283+ placeholder={props.draftNamePlaceholder}
284+ className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
285+ />
286+ <textarea
287+ value={draftValue}
288+ onChange={(event) => setDraftValue(event.target.value)}
289+ rows={3}
290+ placeholder={props.draftValuePlaceholder}
291+ className="w-full rounded-lg border border-input bg-background px-3 py-2 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
292+ />
293+ {createError && (
294+ <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-3 py-2 text-xs text-destructive">
295+ {createError}
296+ </div>
297+ )}
298+ <div className="flex items-center gap-2">
299+ <button
300+ type="button"
301+ onClick={() => {
302+ void handleCreate();
303+ }}
304+ disabled={createSecret.status === "pending"}
305+ className="inline-flex h-8 items-center justify-center rounded-lg bg-primary px-3 text-xs font-medium text-primary-foreground transition-opacity disabled:pointer-events-none disabled:opacity-50"
306+ >
307+ {createSecret.status === "pending" ? "Creating..." : "Create Secret"}
308+ </button>
309+ <button
310+ type="button"
311+ onClick={() => {
312+ setShowCreate(false);
313+ setCreateError(null);
314+ }}
315+ className="inline-flex h-8 items-center justify-center rounded-lg border border-input bg-card px-3 text-xs font-medium text-foreground transition-colors hover:bg-accent/50"
316+ >
317+ Cancel
318+ </button>
319+ </div>
320+ </div>
321+ )}
322+ </div>
323+ );
324+}
325+
131326 const graphqlToolRoutePath = (toolPath: string): string =>
132327 `tool/${encodeURIComponent(toolPath)}`;
133328
@@ -189,7 +384,6 @@ function GraphqlSourceForm(props: { · 55 unmodified lines
189384 mode: "create" | "edit";
190385 onSubmit: (input: GraphqlConnectInput) => Promise<void>;
191386 }) {
192− const secrets = useSecrets();
193387 const submitMutation = useExecutorMutation<GraphqlConnectInput, void>(props.onSubmit);
194388 const [name, setName] = useState(props.initialValue.name);
195389 const [endpoint, setEndpoint] = useState(props.initialValue.endpoint);
@@ -200,94 +394,102 @@ function GraphqlSourceForm(props: { · 4 unmodified lines
200394 props.initialValue.auth.kind,
201395 );
202396 const [secretRef, setSecretRef] = useState(secretValue(props.initialValue.auth));
397+ const [authHeaderName, setAuthHeaderName] = useState(
398+ bearerHeaderNameValue(props.initialValue.auth),
399+ );
400+ const [authPrefix, setAuthPrefix] = useState(
401+ bearerPrefixValue(props.initialValue.auth),
402+ );
203403 const [error, setError] = useState<string | null>(null);
204404
205405 return (
206− <div className="space-y-4 rounded-2xl border border-border bg-card p-5">
207− <div>
208− <h2 className="text-lg font-semibold text-foreground">
209− {props.mode === "create" ? "Connect GraphQL Source" : "Edit GraphQL Source"}
210− </h2>
211− <p className="mt-1 text-sm text-muted-foreground">
212− GraphQL is now mounted as a plugin. The source record stays generic; endpoint and auth
213− live entirely in plugin storage.
214− </p>
215− </div>
406+ <div className="space-y-6 rounded-xl border border-border p-6">
407+ <div className="grid gap-4">
408+ <label className="grid gap-2">
409+ <span className="text-xs font-medium text-foreground">Name</span>
410+ <input
411+ value={name}
412+ onChange={(event) => setName(event.target.value)}
413+ className="h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
414+ />
415+ </label>
216416
217− <label className="grid gap-1.5">
218− <span className="text-xs font-medium text-foreground">Name</span>
219− <input
220− value={name}
221− onChange={(event) => setName(event.target.value)}
222− className="h-10 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
223− />
224− </label>
225−
226− <label className="grid gap-1.5">
227− <span className="text-xs font-medium text-foreground">Endpoint</span>
228− <input
229− value={endpoint}
230− onChange={(event) => setEndpoint(event.target.value)}
231− className="h-10 rounded-lg border border-input bg-background px-3 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
232− />
233− </label>
234−
235− <label className="grid gap-1.5">
236− <span className="text-xs font-medium text-foreground">Default Headers</span>
237− <textarea
238− value={headersText}
239− onChange={(event) => setHeadersText(event.target.value)}
240− rows={5}
241− placeholder='{"x-api-version":"2026-03-23"}'
242− className="rounded-lg border border-input bg-background px-3 py-2 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
243− />
244− </label>
245−
246− <label className="grid gap-1.5">
247− <span className="text-xs font-medium text-foreground">Auth</span>
248− <select
249− value={authKind}
250− onChange={(event) =>
251− setAuthKind(event.target.value as GraphqlConnectionAuth["kind"])}
252− className="h-10 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
253− >
254− <option value="none">None</option>
255− <option value="bearer">Bearer Secret</option>
256− </select>
257− </label>
258−
259− {authKind === "bearer" && (
260− <label className="grid gap-1.5">
261− <span className="text-xs font-medium text-foreground">Secret</span>
417+ <label className="grid gap-2">
418+ <span className="text-xs font-medium text-foreground">Endpoint</span>
419+ <input
420+ value={endpoint}
421+ onChange={(event) => setEndpoint(event.target.value)}
422+ className="h-9 rounded-lg border border-input bg-background px-3 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
423+ />
424+ </label>
425+
426+ <label className="grid gap-2">
427+ <span className="text-xs font-medium text-foreground">Default Headers</span>
428+ <textarea
429+ value={headersText}
430+ onChange={(event) => setHeadersText(event.target.value)}
431+ rows={3}
432+ placeholder='{"x-api-version":"2026-03-23"}'
433+ className="rounded-lg border border-input bg-background px-3 py-2 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
434+ />
435+ </label>
436+
437+ <label className="grid gap-2">
438+ <span className="text-xs font-medium text-foreground">Auth</span>
262439 <select
263− value={secretRef}
264− onChange={(event) => setSecretRef(event.target.value)}
265− className="h-10 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
440+ value={authKind}
441+ onChange={(event) =>
442+ setAuthKind(event.target.value as GraphqlConnectionAuth["kind"])}
443+ className="h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
266444 >
267− <option value="">Select a secret</option>
268− {secrets.status === "ready" &&
269− secrets.data.map((secret) => (
270− <option
271− key={`${secret.providerId}:${secret.id}`}
272− value={JSON.stringify({
273− providerId: secret.providerId,
274− handle: secret.id,
275− })}
276− >
277− {secret.name ?? secret.id}
278− </option>
279− ))}
445+ <option value="none">None</option>
446+ <option value="bearer">Bearer Secret</option>
280447 </select>
281448 </label>
282− )}
449+
450+ {authKind === "bearer" && (
451+ <div className="space-y-4 border-l-2 border-border pl-4">
452+ <SecretSelectOrCreateField
453+ label="Secret"
454+ value={secretRef}
455+ emptyLabel="Select a secret"
456+ draftNamePlaceholder="GraphQL bearer token"
457+ draftValuePlaceholder="Paste the token value"
458+ onChange={setSecretRef}
459+ />
460+
461+ <div className="grid gap-4 sm:grid-cols-2">
462+ <label className="grid gap-2">
463+ <span className="text-xs font-medium text-foreground">Header Name</span>
464+ <input
465+ value={authHeaderName}
466+ onChange={(event) => setAuthHeaderName(event.target.value)}
467+ placeholder={DEFAULT_BEARER_HEADER_NAME}
468+ className="h-9 rounded-lg border border-input bg-background px-3 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
469+ />
470+ </label>
471+
472+ <label className="grid gap-2">
473+ <span className="text-xs font-medium text-foreground">Prefix</span>
474+ <input
475+ value={authPrefix}
476+ onChange={(event) => setAuthPrefix(event.target.value)}
477+ placeholder={DEFAULT_BEARER_PREFIX}
478+ className="h-9 rounded-lg border border-input bg-background px-3 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
479+ />
480+ </label>
481+ </div>
482+ </div>
483+ )}
484+ </div>
283485
284486 {error && (
285− <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-3 text-sm text-destructive">
487+ <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-2.5 text-sm text-destructive">
286488 {error}
287489 </div>
288490 )}
289491
290− <div className="flex items-center gap-3">
492+ <div className="flex items-center justify-end">
291493 <button
292494 type="button"
293495 onClick={() => {
@@ -298,7 +500,12 @@ function GraphqlSourceForm(props: { · 4 unmodified lines
298500 name: name.trim(),
299501 endpoint: endpoint.trim(),
300502 defaultHeaders: parseStringMap(headersText),
301− auth: authFromSecretValue(authKind, secretRef),
503+ auth: authFromSecretValue(
504+ authKind,
505+ secretRef,
506+ authHeaderName,
507+ authPrefix,
508+ ),
302509 });
303510 } catch (cause) {
304511 setError(cause instanceof Error ? cause.message : "Failed saving GraphQL source.");
@@ -306,17 +513,12 @@ function GraphqlSourceForm(props: { · 1 unmodified line
306513 })();
307514 }}
308515 disabled={submitMutation.status === "pending"}
309− className="inline-flex h-9 items-center justify-center rounded-lg bg-primary px-3 text-sm font-medium text-primary-foreground transition-opacity disabled:pointer-events-none disabled:opacity-50"
516+ className="inline-flex h-9 items-center justify-center rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90 disabled:pointer-events-none disabled:opacity-50"
310517 >
311518 {submitMutation.status === "pending"
312519 ? props.mode === "create" ? "Creating..." : "Saving..."
313520 : props.mode === "create" ? "Create Source" : "Save Changes"}
314521 </button>
315− <div className="text-xs text-muted-foreground">
316− {props.mode === "create"
317− ? "The plugin will introspect the endpoint immediately after creation."
318− : "Saving refreshes the imported schema and tool catalog."}
319− </div>
320522 </div>
321523 </div>
322524 );
@@ -324,6 +526,7 @@ function GraphqlSourceForm(props: { · 1 unmodified line
324526
325527 function GraphqlAddPage() {
326528 const navigation = useSourcePluginNavigation();
529+ const initialValue = graphqlInputFromSearch(useSourcePluginSearch());
327530 const installation = useLocalInstallation();
328531 const client = getGraphqlHttpClient();
329532 const createSource = useAtomSet(
@@ -337,7 +540,7 @@ function GraphqlAddPage() { · 7 unmodified lines
337540
338541 return (
339542 <GraphqlSourceForm
340− initialValue={defaultGraphqlInput()}
543+ initialValue={initialValue}
341544 mode="create"
342545 onSubmit={async (input) => {
343546 const source = await createSource({
@@ -394,13 +597,13 @@ function GraphqlEditPage(props: { · 50 unmodified lines
394597 if (Result.isFailure(configResult)) {
395598 return (
396599 <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-3 text-sm text-destructive">
397− Failed loading GraphQL config.
600+ Failed loading source configuration.
398601 </div>
399602 );
400603 }
401604
402605 if (!Result.isSuccess(configResult)) {
403− return <div className="text-sm text-muted-foreground">Loading GraphQL config...</div>;
606+ return <div className="text-sm text-muted-foreground">Loading configuration...</div>;
404607 }
405608
406609 return (
@@ -479,6 +682,8 @@ function GraphqlDetailExplorer(props: { · 72 unmodified lines
479682 props.source.id,
480683 tab === "model" ? selectedToolPath : null,
481684 );
685+ const [confirmDelete, setConfirmDelete] = useState(false);
686+ const [isDeleting, setIsDeleting] = useState(false);
482687
483688 if (installation.status !== "ready") {
484689 return <div className="text-sm text-muted-foreground">Loading workspace...</div>;
@@ -545,6 +750,35 @@ function GraphqlDetailExplorer(props: { · 60 unmodified lines
545750 return navigation.detail(props.source.id, searchState);
546751 };
547752
753+ const handleDelete = async () => {
754+ if (isDeleting) {
755+ return;
756+ }
757+
758+ setIsDeleting(true);
759+ try {
760+ await removeSource({
761+ path: {
762+ workspaceId: installation.data.scopeId,
763+ sourceId: props.source.id,
764+ },
765+ reactivityKeys: {
766+ sources: [installation.data.scopeId],
767+ source: [installation.data.scopeId, props.source.id],
768+ sourceInspection: [installation.data.scopeId, props.source.id],
769+ sourceInspectionTool: [installation.data.scopeId, props.source.id],
770+ sourceDiscovery: [installation.data.scopeId, props.source.id],
771+ },
772+ });
773+ startTransition(() => {
774+ void navigation.home();
775+ });
776+ } finally {
777+ setIsDeleting(false);
778+ setConfirmDelete(false);
779+ }
780+ };
781+
548782 return (
549783 <LoadableBlock loadable={inspection} loading="Loading source...">
550784 {(loadedInspection) => {
@@ -594,36 +828,40 @@ function GraphqlDetailExplorer(props: { · 43 unmodified lines
594828 <IconPencil className="size-3" />
595829 Edit
596830 </button>
597− <button
598− type="button"
599− onClick={() => {
600− const confirmed = window.confirm(`Delete GraphQL source "${props.source.name}"?`);
601− if (!confirmed) {
602− return;
603− }
604−
605− void removeSource({
606− path: {
607− workspaceId: installation.data.scopeId,
608− sourceId: props.source.id,
609− },
610− reactivityKeys: {
611− sources: [installation.data.scopeId],
612− source: [installation.data.scopeId, props.source.id],
613− sourceInspection: [installation.data.scopeId, props.source.id],
614− sourceInspectionTool: [installation.data.scopeId, props.source.id],
615− sourceDiscovery: [installation.data.scopeId, props.source.id],
616− },
617− }).then(() => {
618− startTransition(() => {
619− void navigation.home();
620− });
621− });
622− }}
623− className="inline-flex items-center rounded-md border border-destructive/25 bg-destructive/5 px-2.5 py-1 text-[12px] font-medium text-destructive transition-colors hover:bg-destructive/10"
624− >
625− Delete
626− </button>
831+ {confirmDelete ? (
832+ <div className="flex items-center gap-2">
833+ <span className="text-[11px] font-medium text-destructive">
834+ Confirm delete?
835+ </span>
836+ <button
837+ type="button"
838+ onClick={() => setConfirmDelete(false)}
839+ disabled={isDeleting}
840+ className="inline-flex items-center rounded-md border border-border bg-card px-2.5 py-1 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
841+ >
842+ Cancel
843+ </button>
844+ <button
845+ type="button"
846+ onClick={() => {
847+ void handleDelete().catch(() => {});
848+ }}
849+ disabled={isDeleting}
850+ className="inline-flex items-center rounded-md border border-destructive/25 bg-destructive/5 px-2.5 py-1 text-[12px] font-medium text-destructive transition-colors hover:bg-destructive/10 disabled:pointer-events-none disabled:opacity-50"
851+ >
852+ {isDeleting ? "Deleting..." : "Delete"}
853+ </button>
854+ </div>
855+ ) : (
856+ <button
857+ type="button"
858+ onClick={() => setConfirmDelete(true)}
859+ disabled={isDeleting}
860+ className="inline-flex items-center rounded-md border border-destructive/25 bg-destructive/5 px-2.5 py-1 text-[12px] font-medium text-destructive transition-colors hover:bg-destructive/10 disabled:pointer-events-none disabled:opacity-50"
861+ >
862+ Delete
863+ </button>
864+ )}
627865 </div>
628866 </div>
629867
plugins/graphql/sdk/index.ts+65−10
@@ -90,6 +90,37 @@ const GraphqlExecutorAddInputSchema = Schema.Struct({ · 89 unmodified lines
9090
9191 type GraphqlExecutorAddInput = typeof GraphqlExecutorAddInputSchema.Type;
9292
93+const normalizeGraphqlAuth = (
94+ auth: Record<string, unknown>,
95+): GraphqlStoredSourceData["auth"] =>
96+ auth.kind === "bearer"
97+ ? {
98+ kind: "bearer",
99+ tokenSecretRef: auth.tokenSecretRef as Extract<
100+ GraphqlStoredSourceData["auth"],
101+ { kind: "bearer" }
102+ >["tokenSecretRef"],
103+ headerName:
104+ typeof auth.headerName === "string" || auth.headerName === null
105+ ? auth.headerName
106+ : null,
107+ prefix:
108+ typeof auth.prefix === "string" || auth.prefix === null
109+ ? auth.prefix
110+ : null,
111+ }
112+ : {
113+ kind: "none",
114+ };
115+
116+const normalizeStoredSourceData = (
117+ stored: GraphqlStoredSourceData,
118+): GraphqlStoredSourceData =>
119+ decodeStoredSourceData({
120+ ...stored,
121+ auth: normalizeGraphqlAuth(stored.auth as Record<string, unknown>),
122+ });
123+
93124 const asRecord = (value: unknown): Record<string, unknown> =>
94125 typeof value === "object" && value !== null && !Array.isArray(value)
95126 ? (value as Record<string, unknown>)
@@ -142,6 +173,17 @@ const resolveBearerToken = ( · 46 unmodified lines
142173 }).pipe(Effect.map((token) => token.trim()))
143174 );
144175
176+const resolveBearerHeaderName = (
177+ auth: Extract<GraphqlStoredSourceData["auth"], { kind: "bearer" }>,
178+): string => {
179+ const trimmed = auth.headerName?.trim();
180+ return trimmed && trimmed.length > 0 ? trimmed : "Authorization";
181+};
182+
183+const resolveBearerPrefix = (
184+ auth: Extract<GraphqlStoredSourceData["auth"], { kind: "bearer" }>,
185+): string => auth.prefix ?? "Bearer ";
186+
145187 const resolveGraphqlHeaders = (
146188 stored: GraphqlStoredSourceData,
147189 ): Effect.Effect<Record<string, string>, Error, any> =>
@@ -149,9 +191,10 @@ const resolveGraphqlHeaders = ( · 1 unmodified line
149191 const bearerToken = yield* resolveBearerToken(stored.auth);
150192 return {
151193 ...(stored.defaultHeaders ?? {}),
152− ...(bearerToken
194+ ...(bearerToken && stored.auth.kind === "bearer"
153195 ? {
154− authorization: `Bearer ${bearerToken}`,
196+ [resolveBearerHeaderName(stored.auth)]:
197+ `${resolveBearerPrefix(stored.auth)}${bearerToken}`,
155198 }
156199 : {}),
157200 };
@@ -241,7 +284,14 @@ const storedSourceDataFromInput = ( · 83 unmodified lines
241284 decodeStoredSourceData({
242285 endpoint: input.endpoint.trim(),
243286 defaultHeaders: input.defaultHeaders,
244− auth: input.auth,
287+ auth:
288+ input.auth.kind === "bearer"
289+ ? {
290+ ...input.auth,
291+ headerName: input.auth.headerName ?? null,
292+ prefix: input.auth.prefix ?? null,
293+ }
294+ : input.auth,
245295 });
246296
247297 const sourceConfigFromStored = (
@@ -279,7 +329,10 @@ const createGraphqlSourceSdk = ( · 31 unmodified lines
279329 );
280330 }
281331
282− return sourceConfigFromStored(source, stored);
332+ return sourceConfigFromStored(
333+ source,
334+ normalizeStoredSourceData(stored),
335+ );
283336 }),
284337 createSource: (input: GraphqlConnectInput) =>
285338 Effect.gen(function* () {
@@ -404,9 +457,10 @@ const createGraphqlSourceRuntime = ( · 118 unmodified lines
404457 });
405458 }
406459
407− const headers = yield* resolveGraphqlHeaders(stored);
460+ const normalizedStored = normalizeStoredSourceData(stored);
461+ const headers = yield* resolveGraphqlHeaders(normalizedStored);
408462 const graphqlDocument = yield* fetchGraphqlIntrospectionDocumentWithHeaders({
409− endpoint: stored.endpoint,
463+ endpoint: normalizedStored.endpoint,
410464 headers,
411465 });
412466 const manifest = yield* extractGraphqlManifest(source.name, graphqlDocument);
@@ -427,7 +481,7 @@ const createGraphqlSourceRuntime = ( · 14 unmodified lines
427481 documents: [
428482 {
429483 documentKind: "graphql_introspection",
430− documentKey: stored.endpoint,
484+ documentKey: normalizedStored.endpoint,
431485 contentText: graphqlDocument,
432486 fetchedAt: now,
433487 },
@@ -456,17 +510,18 @@ const createGraphqlSourceRuntime = ( · 22 unmodified lines
456510 );
457511 }
458512
513+ const normalizedStored = normalizeStoredSourceData(stored);
459514 const providerData = decodeProviderData(
460515 input.executable.binding,
461516 ) as GraphqlToolProviderData;
462517 const args = asRecord(input.args);
463518 const headers = {
464519 "content-type": "application/json",
465− ...(yield* resolveGraphqlHeaders(stored)),
520+ ...(yield* resolveGraphqlHeaders(normalizedStored)),
466521 ...asStringRecord(args.headers),
467522 };
468523 const endpoint = applyHttpQueryPlacementsToUrl({
469− url: stored.endpoint,
524+ url: normalizedStored.endpoint,
470525 }).toString();
471526
472527 const isRawRequest =
@@ -511,7 +566,7 @@ const createGraphqlSourceRuntime = ( · 38 unmodified lines
511566 ...(operationName ? { operationName } : {}),
512567 },
513568 bodyValues: {},
514− label: `GraphQL invocation ${stored.endpoint}`,
569+ label: `GraphQL invocation ${normalizedStored.endpoint}`,
515570 }),
516571 ),
517572 }),
plugins/graphql/shared/index.ts+2−0
@@ -16,6 +16,8 @@ export const GraphqlConnectionAuthSchema = Schema.Union( · 15 unmodified lines
1616 Schema.Struct({
1717 kind: Schema.Literal("bearer"),
1818 tokenSecretRef: SecretRefSchema,
19+ headerName: Schema.NullOr(Schema.String),
20+ prefix: Schema.NullOr(Schema.String),
1921 }),
2022 );
2123
plugins/mcp/http/index.ts+38−6
@@ -26,11 +26,15 @@ import * as Schema from "effect/Schema"; · 25 unmodified lines
2626
2727 import {
2828 McpConnectInputSchema,
29+ McpDiscoverInputSchema,
30+ McpDiscoverResultSchema,
2931 McpOAuthPopupResultSchema,
3032 McpSourceConfigPayloadSchema,
3133 McpStartOAuthInputSchema,
3234 McpStartOAuthResultSchema,
3335 type McpConnectInput,
36+ type McpDiscoverInput,
37+ type McpDiscoverResult,
3438 type McpOAuthPopupResult,
3539 type McpSourceConfigPayload,
3640 type McpStartOAuthInput,
@@ -46,6 +50,9 @@ type McpExecutorExtension = { · 9 unmodified lines
4650 getSourceConfig: (
4751 sourceId: Source["id"],
4852 ) => Effect.Effect<McpSourceConfigPayload, Error>;
53+ discoverSource: (
54+ input: McpDiscoverInput,
55+ ) => Effect.Effect<McpDiscoverResult, Error>;
4956 updateSource: (
5057 input: McpUpdateSourceInput,
5158 ) => Effect.Effect<Source, Error>;
@@ -86,6 +93,14 @@ export const McpHttpGroup = HttpApiGroup.make("mcp") · 34 unmodified lines
8693 .addError(ControlPlaneForbiddenError)
8794 .addError(ControlPlaneStorageError),
8895 )
96+ .add(
97+ HttpApiEndpoint.post("discoverSource")`/workspaces/${workspaceIdParam}/plugins/mcp/discover`
98+ .setPayload(McpDiscoverInputSchema)
99+ .addSuccess(McpDiscoverResultSchema)
100+ .addError(ControlPlaneBadRequestError)
101+ .addError(ControlPlaneForbiddenError)
102+ .addError(ControlPlaneStorageError),
103+ )
89104 .add(
90105 HttpApiEndpoint.get("getSourceConfig")`/workspaces/${workspaceIdParam}/plugins/mcp/sources/${sourceIdParam}`
91106 .addSuccess(McpSourceConfigPayloadSchema)
@@ -171,10 +186,11 @@ const popupDocument = (payload: McpOAuthPopupResult): string => { · 79 unmodified lines
171186 .replaceAll("<", "\\u003c")
172187 .replaceAll(">", "\\u003e")
173188 .replaceAll("&", "\\u0026");
174− const title = payload.ok ? "MCP OAuth connected" : "MCP OAuth failed";
189+ const title = payload.ok ? "Connected" : "Connection failed";
175190 const message = payload.ok
176− ? "MCP credentials are ready. Return to the source form to finish saving."
191+ ? "Authentication complete. This window will close automatically."
177192 : payload.error;
193+ const statusColor = payload.ok ? "#22c55e" : "#ef4444";
178194
179195 return `<!doctype html>
180196 <html lang="en">
@@ -183,10 +199,17 @@ const popupDocument = (payload: McpOAuthPopupResult): string => { · 2 unmodified lines
183199 <meta name="viewport" content="width=device-width, initial-scale=1" />
184200 <title>${escapeHtml(title)}</title>
185201 </head>
186− <body>
187− <main>
188− <h1>${escapeHtml(title)}</h1>
189− <p>${escapeHtml(message)}</p>
202+ <body style="font-family: system-ui, -apple-system, sans-serif; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; background: #fafafa;">
203+ <main style="text-align: center; max-width: 360px; padding: 24px;">
204+ <div style="width: 40px; height: 40px; border-radius: 50%; background: ${statusColor}; margin: 0 auto 16px; display: flex; align-items: center; justify-content: center;">
205+ <svg width="20" height="20" viewBox="0 0 20 20" fill="none">
206+ ${payload.ok
207+ ? '<path d="M6 10l3 3 5-6" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>'
208+ : '<path d="M7 7l6 6M13 7l-6 6" stroke="white" stroke-width="2" stroke-linecap="round"/>'}
209+ </svg>
210+ </div>
211+ <h1 style="margin: 0 0 8px; font-size: 18px; font-weight: 600; color: #111;">${escapeHtml(title)}</h1>
212+ <p style="margin: 0; font-size: 14px; color: #666; line-height: 1.5;">${escapeHtml(message)}</p>
190213 </main>
191214 <script>
192215 (() => {
@@ -225,6 +248,15 @@ export const mcpHttpPlugin = (): ExecutorHttpPlugin< · 32 unmodified lines
225248 Effect.mapError((cause) => toStorageError("mcp.createSource", cause)),
226249 )
227250 )
251+ .handle("discoverSource", ({ path, payload }) =>
252+ resolveRequestedLocalWorkspace(
253+ "mcp.discoverSource",
254+ path.workspaceId,
255+ ).pipe(
256+ Effect.flatMap(() => executor.mcp.discoverSource(payload)),
257+ Effect.mapError((cause) => toStorageError("mcp.discoverSource", cause)),
258+ )
259+ )
228260 .handle("getSourceConfig", ({ path }) =>
229261 resolveRequestedLocalWorkspace(
230262 "mcp.getSourceConfig",
plugins/mcp/react/index.tsx+604−86
@@ -9,7 +9,10 @@ import { · 8 unmodified lines
99 useLocalInstallation,
1010 } from "@executor/react";
1111 import {
12+ IconCheck,
1213 IconPencil,
14+ IconSearch,
15+ IconSpinner,
1316 SourceToolExplorer,
1417 defineExecutorFrontendPlugin,
1518 defineFrontendSourceType,
@@ -24,6 +27,8 @@ import { · 8 unmodified lines
2427 } from "@executor/plugin-mcp-http";
2528 import {
2629 type McpConnectInput,
30+ type McpDiscoverInput,
31+ type McpDiscoverResult,
2732 type McpConnectionAuth,
2833 type McpOAuthPopupResult,
2934 type McpStartOAuthInput,
@@ -64,6 +69,150 @@ const defaultMcpInput = (): McpConnectInput => ({ · 34 unmodified lines
6469 },
6570 });
6671
72+type McpQuickPreset = {
73+ id: string;
74+ name: string;
75+ summary: string;
76+ input: McpConnectInput;
77+};
78+
79+type ProbeAuthState = {
80+ kind: NonNullable<McpDiscoverInput["probeAuth"]>["kind"];
81+ token: string;
82+ headerName: string;
83+ prefix: string;
84+ username: string;
85+ password: string;
86+ headersText: string;
87+};
88+
89+const defaultProbeAuthState = (): ProbeAuthState => ({
90+ kind: "none",
91+ token: "",
92+ headerName: "Authorization",
93+ prefix: "Bearer ",
94+ username: "",
95+ password: "",
96+ headersText: "",
97+});
98+
99+const mcpQuickPresets: ReadonlyArray<McpQuickPreset> = [
100+ {
101+ id: "deepwiki-mcp",
102+ name: "DeepWiki MCP",
103+ summary: "Repository docs and knowledge graphs via a remote MCP endpoint.",
104+ input: {
105+ ...defaultMcpInput(),
106+ name: "DeepWiki MCP",
107+ endpoint: "https://mcp.deepwiki.com/mcp",
108+ transport: "auto",
109+ },
110+ },
111+ {
112+ id: "axiom-mcp",
113+ name: "Axiom MCP",
114+ summary: "Query and analyze logs and traces through Axiom's MCP server.",
115+ input: {
116+ ...defaultMcpInput(),
117+ name: "Axiom MCP",
118+ endpoint: "https://mcp.axiom.co/mcp",
119+ transport: "auto",
120+ },
121+ },
122+ {
123+ id: "neon-mcp",
124+ name: "Neon MCP",
125+ summary: "Manage databases, branches, and queries via Neon MCP.",
126+ input: {
127+ ...defaultMcpInput(),
128+ name: "Neon MCP",
129+ endpoint: "https://mcp.neon.tech/mcp",
130+ transport: "auto",
131+ },
132+ },
133+ {
134+ id: "chrome-devtools-mcp",
135+ name: "Chrome DevTools MCP",
136+ summary: "Launch the local Chrome DevTools MCP server over stdio.",
137+ input: {
138+ ...defaultMcpInput(),
139+ name: "Chrome DevTools MCP",
140+ endpoint: null,
141+ transport: "stdio",
142+ command: "npx",
143+ args: ["-y", "chrome-devtools-mcp@latest"],
144+ },
145+ },
146+];
147+
148+const presetString = (
149+ search: Record<string, unknown>,
150+ key: string,
151+): string | null => {
152+ const value = search[key];
153+ return typeof value === "string" && value.trim().length > 0
154+ ? value.trim()
155+ : null;
156+};
157+
158+const presetTransport = (
159+ search: Record<string, unknown>,
160+): McpConnectInput["transport"] => {
161+ const value = presetString(search, "presetTransport");
162+ return value === "auto" ||
163+ value === "streamable-http" ||
164+ value === "sse" ||
165+ value === "stdio"
166+ ? value
167+ : null;
168+};
169+
170+const presetJsonStringMap = (
171+ search: Record<string, unknown>,
172+ key: string,
173+): Record<string, string> | null => {
174+ const value = presetString(search, key);
175+ return value ? parseJsonStringMap(key, value) : null;
176+};
177+
178+const presetJsonStringArray = (
179+ search: Record<string, unknown>,
180+ key: string,
181+): Array<string> | null => {
182+ const value = presetString(search, key);
183+ return value ? parseJsonStringArray(key, value) : null;
184+};
185+
186+const mcpInputFromSearch = (
187+ search: Record<string, unknown>,
188+): McpConnectInput => {
189+ const defaults = defaultMcpInput();
190+ const command = presetString(search, "presetCommand");
191+ const args = presetJsonStringArray(search, "presetArgs");
192+ const env = presetJsonStringMap(search, "presetEnv");
193+ const cwd = presetString(search, "presetCwd");
194+ const queryParams = presetJsonStringMap(search, "presetQueryParams");
195+ const headers = presetJsonStringMap(search, "presetHeaders");
196+ const transport = presetTransport(search)
197+ ?? (command ? "stdio" : defaults.transport);
198+
199+ return {
200+ ...defaults,
201+ name: presetString(search, "presetName") ?? defaults.name,
202+ endpoint:
203+ command
204+ ? null
205+ : presetString(search, "presetEndpoint") ?? defaults.endpoint,
206+ transport,
207+ queryParams,
208+ headers,
209+ command,
210+ args,
211+ env,
212+ cwd,
213+ };
214+};
215+
67216 const stringifyStringMap = (
68217 value: Record<string, string> | null | undefined,
69218 ): string =>
@@ -92,6 +241,49 @@ const transportFieldsFromInput = (input: McpConnectInput): McpTransportFields => · 22 unmodified lines
92241 headersText: stringifyStringMap(input.headers),
93242 };
94243
244+const buildProbeAuth = (
245+ state: ProbeAuthState,
246+): McpDiscoverInput["probeAuth"] => {
247+ if (state.kind === "none") {
248+ return { kind: "none" };
249+ }
250+
251+ if (state.kind === "bearer") {
252+ if (!state.token.trim()) {
253+ throw new Error("Token is required for bearer discovery auth.");
254+ }
255+
256+ return {
257+ kind: "bearer",
258+ headerName: state.headerName.trim() || null,
259+ prefix: state.prefix.length > 0 ? state.prefix : null,
260+ token: state.token.trim(),
261+ };
262+ }
263+
264+ if (state.kind === "basic") {
265+ if (!state.username.trim()) {
266+ throw new Error("Username is required for basic discovery auth.");
267+ }
268+
269+ return {
270+ kind: "basic",
271+ username: state.username.trim(),
272+ password: state.password,
273+ };
274+ }
275+
276+ const headers = parseJsonStringMap("Discovery headers", state.headersText);
277+ if (!headers) {
278+ throw new Error("At least one discovery header is required.");
279+ }
280+
281+ return {
282+ kind: "headers",
283+ headers,
284+ };
285+};
286+
95287 const waitForOauthPopupResult = async (
96288 sessionId: string,
97289 ): Promise<McpOAuthPopupResult> =>
@@ -150,6 +342,28 @@ const waitForOauthPopupResult = async ( · 52 unmodified lines
150342 }, 400);
151343 });
152344
345+const openOauthPopup = (): Window | null => {
346+ const popup = window.open("", "executor-mcp-oauth", "width=560,height=760");
347+ if (!popup) {
348+ return null;
349+ }
350+
351+ try {
352+ popup.document.title = "Connecting";
353+ popup.document.body.innerHTML = `
354+ <main style="font-family: system-ui, -apple-system, sans-serif; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; margin: 0; color: #333;">
355+ <div style="width: 20px; height: 20px; border: 2px solid #e5e7eb; border-top-color: #6366f1; border-radius: 50%; animation: spin 0.7s linear infinite; margin-bottom: 16px;"></div>
356+ <p style="margin: 0; font-size: 14px; color: #888;">Redirecting to sign in&hellip;</p>
357+ <style>@keyframes spin { to { transform: rotate(360deg); } }</style>
358+ </main>
359+ `;
360+ } catch {
361+ // Ignore cases where the browser does not allow touching the popup document.
362+ }
363+
364+ return popup;
365+};
366+
153367 function McpSourceForm(props: {
154368 initialValue: McpConnectInput;
155369 mode: "create" | "edit";
@@ -157,6 +371,10 @@ function McpSourceForm(props: { · 1 unmodified line
157371 }) {
158372 const installation = useLocalInstallation();
159373 const client = getMcpHttpClient();
374+ const discoverSource = useAtomSet(
375+ client.mutation("mcp", "discoverSource"),
376+ { mode: "promise" },
377+ );
160378 const startOAuth = useAtomSet(
161379 client.mutation("mcp", "startOAuth"),
162380 { mode: "promise" },
@@ -181,9 +399,104 @@ function McpSourceForm(props: { · 18 unmodified lines
181399 props.initialValue.auth.kind === "oauth2" ? "connected" : "idle",
182400 );
183401 const [error, setError] = useState<string | null>(null);
402+ const [discoveryEndpoint, setDiscoveryEndpoint] = useState(
403+ props.initialValue.command ? "" : (props.initialValue.endpoint ?? ""),
404+ );
405+ const [showProbeAuth, setShowProbeAuth] = useState(false);
406+ const [probeAuth, setProbeAuth] = useState<ProbeAuthState>(defaultProbeAuthState);
407+ const [discoveryMessage, setDiscoveryMessage] = useState<string | null>(null);
408+ const discoverMutation = useExecutorMutation<McpDiscoverInput, McpDiscoverResult>(
409+ async (input) => {
410+ if (installation.status !== "ready") {
411+ throw new Error("Workspace is still loading.");
412+ }
413+
414+ return discoverSource({
415+ path: {
416+ workspaceId: installation.data.scopeId,
417+ },
418+ payload: input,
419+ });
420+ },
421+ );
184422
185423 const isStdio = transportFields.transport === "stdio";
186424
425+ const applyPreset = (preset: McpQuickPreset) => {
426+ setName(preset.input.name);
427+ setEndpoint(preset.input.endpoint ?? "");
428+ setTransportFields(transportFieldsFromInput(preset.input));
429+ setAuthKind(preset.input.auth.kind);
430+ setOauthAuth(preset.input.auth.kind === "oauth2" ? preset.input.auth : null);
431+ setOauthStatus(preset.input.auth.kind === "oauth2" ? "connected" : "idle");
432+ setDiscoveryEndpoint(preset.input.endpoint ?? "");
433+ setDiscoveryMessage(`Loaded ${preset.name}.`);
434+ setError(null);
435+ };
436+
437+ const applyDiscoveredRemoteSource = (result: NonNullable<McpDiscoverResult>) => {
438+ const remoteTransport =
439+ result.transport === "streamable-http" ||
440+ result.transport === "sse" ||
441+ result.transport === "auto"
442+ ? result.transport
443+ : "auto";
444+
445+ setName(result.name?.trim() || name);
446+ setEndpoint(result.endpoint);
447+ setTransportFields({
448+ ...defaultMcpRemoteTransportFields(remoteTransport),
449+ queryParamsText:
450+ transportFields.transport === "stdio" ? "" : transportFields.queryParamsText,
451+ headersText:
452+ transportFields.transport === "stdio" ? "" : transportFields.headersText,
453+ });
454+ if (result.authInference.supported && result.authInference.suggestedKind === "oauth2") {
455+ setAuthKind("oauth2");
456+ setDiscoveryMessage(
457+ result.warnings[0]
458+ ?? "The server advertised OAuth during discovery. Connect OAuth before saving.",
459+ );
460+ } else {
461+ setAuthKind("none");
462+ setOauthAuth(null);
463+ setOauthStatus("idle");
464+ setDiscoveryMessage(
465+ result.warnings[0]
466+ ?? `Discovered ${result.toolCount ?? "unknown"} MCP tools and prefilled the connection.`,
467+ );
468+ }
469+ setError(null);
470+ };
471+
472+ const handleDiscover = async () => {
473+ if (installation.status !== "ready") {
474+ setError("Workspace is still loading.");
475+ return;
476+ }
477+
478+ setError(null);
479+ setDiscoveryMessage(null);
480+
481+ try {
482+ const result = await discoverMutation.mutateAsync({
483+ endpoint: discoveryEndpoint.trim(),
484+ probeAuth: showProbeAuth ? buildProbeAuth(probeAuth) : { kind: "none" },
485+ });
486+
487+ if (result === null) {
488+ setDiscoveryMessage(
489+ "Could not verify this endpoint as MCP. You can still continue with manual setup.",
490+ );
491+ return;
492+ }
493+
494+ applyDiscoveredRemoteSource(result);
495+ } catch (cause) {
496+ setError(cause instanceof Error ? cause.message : String(cause));
497+ }
498+ };
499+
187500 const runOauth = async () => {
188501 if (installation.status !== "ready") {
189502 throw new Error("Workspace is still loading.");
@@ -192,6 +505,11 @@ function McpSourceForm(props: { · 2 unmodified lines
192505 throw new Error("MCP OAuth is only available for remote MCP transports.");
193506 }
194507
508+ const popup = openOauthPopup();
509+ if (!popup) {
510+ throw new Error("Failed opening MCP OAuth popup. Allow popups for this site and try again.");
511+ }
512+
195513 const payload: McpStartOAuthInput = {
196514 endpoint: endpoint.trim(),
197515 queryParams: parseJsonStringMap("Query params", transportFields.queryParamsText),
@@ -201,22 +519,22 @@ function McpSourceForm(props: { · 3 unmodified lines
201519 ).toString(),
202520 };
203521
204− const started = await startOAuth({
205− path: {
206− workspaceId: installation.data.scopeId,
207− },
208− payload,
209− });
210−
211− const popup = window.open(
212− started.authorizationUrl,
213− "executor-mcp-oauth",
214− "width=560,height=760,noopener,noreferrer",
215− );
216− if (!popup) {
217− throw new Error("Failed opening MCP OAuth popup.");
522+ let started;
523+ try {
524+ started = await startOAuth({
525+ path: {
526+ workspaceId: installation.data.scopeId,
527+ },
528+ payload,
529+ });
530+ } catch (cause) {
531+ popup.close();
532+ throw cause;
218533 }
219534
535+ popup.location.replace(started.authorizationUrl);
536+ popup.focus();
537+
220538 const result = await waitForOauthPopupResult(started.sessionId);
221539 if (!result.ok) {
222540 throw new Error(result.error);
@@ -256,26 +574,189 @@ function McpSourceForm(props: { · 33 unmodified lines
256574 });
257575
258576 return (
259− <div className="space-y-4 rounded-2xl border border-border bg-card p-5">
260− <div>
261− <h2 className="text-lg font-semibold text-foreground">
262− {props.mode === "create" ? "Connect MCP Source" : "Edit MCP Source"}
263− </h2>
264− <p className="mt-1 text-sm text-muted-foreground">
265− MCP now owns its transport config, OAuth flow, and tool surface inside the plugin.
266− </p>
267− </div>
577+ <div className="space-y-8">
578+ {props.mode === "create" && (
579+ <div className="rounded-xl border border-border p-6">
580+ <div className="text-sm font-medium text-foreground">Presets</div>
581+ <div className="mt-3 grid gap-3 sm:grid-cols-2">
582+ {mcpQuickPresets.map((preset) => {
583+ const selected =
584+ preset.input.transport === "stdio"
585+ ? transportFields.transport === "stdio"
586+ && transportFields.command === (preset.input.command ?? "")
587+ : !isStdio && endpoint.trim() === (preset.input.endpoint ?? "");
588+
589+ return (
590+ <button
591+ key={preset.id}
592+ type="button"
593+ onClick={() => applyPreset(preset)}
594+ className={
595+ selected
596+ ? "rounded-lg border border-primary/30 bg-primary/5 px-4 py-3 text-left"
597+ : "rounded-lg border border-border px-4 py-3 text-left transition-colors hover:bg-accent/40"
598+ }
599+ >
600+ <div className="flex items-center gap-2">
601+ {selected ? <IconCheck className="size-3.5 text-primary" /> : null}
602+ <div className="text-sm font-medium text-foreground">{preset.name}</div>
603+ </div>
604+ <div className="mt-1 text-xs text-muted-foreground">{preset.summary}</div>
605+ </button>
606+ );
607+ })}
608+ </div>
609+ </div>
610+ )}
611+
612+ {props.mode === "create" && (
613+ <div className="rounded-xl border border-border p-6">
614+ <div>
615+ <div className="flex items-center justify-between gap-3">
616+ <div className="text-sm font-medium text-foreground">Discover</div>
617+ <button
618+ type="button"
619+ onClick={() => setShowProbeAuth((current) => !current)}
620+ className="text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
621+ >
622+ {showProbeAuth ? "Hide auth" : "Need auth?"}
623+ </button>
624+ </div>
625+
626+ <div className="mt-2 flex flex-col gap-3 sm:flex-row">
627+ <input
628+ value={discoveryEndpoint}
629+ onChange={(event) => setDiscoveryEndpoint(event.target.value)}
630+ placeholder="https://mcp.example.com/mcp"
631+ className="h-9 flex-1 rounded-lg border border-input bg-background px-3 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
632+ />
633+ <button
634+ type="button"
635+ onClick={() => {
636+ void handleDiscover();
637+ }}
638+ disabled={discoverMutation.status === "pending"}
639+ className="inline-flex h-9 items-center justify-center gap-2 rounded-lg border border-input bg-card px-3 text-sm font-medium text-foreground transition-colors hover:bg-accent/50 disabled:pointer-events-none disabled:opacity-50"
640+ >
641+ {discoverMutation.status === "pending"
642+ ? <IconSpinner className="size-3.5" />
643+ : <IconSearch className="size-3.5" />}
644+ {discoverMutation.status === "pending" ? "Discovering..." : "Discover"}
645+ </button>
646+ </div>
647+
648+ {showProbeAuth && (
649+ <div className="mt-3 space-y-3 border-l-2 border-border pl-4">
650+ <label className="grid gap-2">
651+ <span className="text-xs font-medium text-foreground">Discovery auth</span>
652+ <select
653+ value={probeAuth.kind}
654+ onChange={(event) =>
655+ setProbeAuth((current) => ({
656+ ...current,
657+ kind: event.target.value as ProbeAuthState["kind"],
658+ }))}
659+ className="h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
660+ >
661+ <option value="none">None</option>
662+ <option value="bearer">Bearer</option>
663+ <option value="basic">Basic</option>
664+ <option value="headers">Custom headers</option>
665+ </select>
666+ </label>
268667
269− <label className="grid gap-1.5">
668+ {probeAuth.kind === "bearer" && (
669+ <div className="grid gap-4 md:grid-cols-2">
670+ <label className="grid gap-2">
671+ <span className="text-xs font-medium text-foreground">Header name</span>
672+ <input
673+ value={probeAuth.headerName}
674+ onChange={(event) =>
675+ setProbeAuth((current) => ({ ...current, headerName: event.target.value }))}
676+ className="h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
677+ />
678+ </label>
679+ <label className="grid gap-2">
680+ <span className="text-xs font-medium text-foreground">Prefix</span>
681+ <input
682+ value={probeAuth.prefix}
683+ onChange={(event) =>
684+ setProbeAuth((current) => ({ ...current, prefix: event.target.value }))}
685+ className="h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
686+ />
687+ </label>
688+ <label className="grid gap-2 md:col-span-2">
689+ <span className="text-xs font-medium text-foreground">Token</span>
690+ <input
691+ value={probeAuth.token}
692+ onChange={(event) =>
693+ setProbeAuth((current) => ({ ...current, token: event.target.value }))}
694+ className="h-9 rounded-lg border border-input bg-background px-3 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
695+ />
696+ </label>
697+ </div>
698+ )}
699+
700+ {probeAuth.kind === "basic" && (
701+ <div className="grid gap-4 md:grid-cols-2">
702+ <label className="grid gap-2">
703+ <span className="text-xs font-medium text-foreground">Username</span>
704+ <input
705+ value={probeAuth.username}
706+ onChange={(event) =>
707+ setProbeAuth((current) => ({ ...current, username: event.target.value }))}
708+ className="h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
709+ />
710+ </label>
711+ <label className="grid gap-2">
712+ <span className="text-xs font-medium text-foreground">Password</span>
713+ <input
714+ value={probeAuth.password}
715+ onChange={(event) =>
716+ setProbeAuth((current) => ({ ...current, password: event.target.value }))}
717+ className="h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
718+ type="password"
719+ />
720+ </label>
721+ </div>
722+ )}
723+
724+ {probeAuth.kind === "headers" && (
725+ <label className="grid gap-2">
726+ <span className="text-xs font-medium text-foreground">Headers JSON</span>
727+ <textarea
728+ value={probeAuth.headersText}
729+ onChange={(event) =>
730+ setProbeAuth((current) => ({ ...current, headersText: event.target.value }))}
731+ rows={3}
732+ placeholder='{"x-api-key":"..."}'
733+ className="rounded-lg border border-input bg-background px-3 py-2 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
734+ />
735+ </label>
736+ )}
737+ </div>
738+ )}
739+
740+ {discoveryMessage && (
741+ <div className="mt-2 text-xs text-muted-foreground">
742+ {discoveryMessage}
743+ </div>
744+ )}
745+ </div>
746+ </div>
747+ )}
748+
749+ <div className="space-y-6 rounded-xl border border-border p-6">
750+ <label className="grid gap-2">
270751 <span className="text-xs font-medium text-foreground">Name</span>
271752 <input
272753 value={name}
273754 onChange={(event) => setName(event.target.value)}
274− className="h-10 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
755+ className="h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
275756 />
276757 </label>
277758
278− <label className="grid gap-1.5">
759+ <label className="grid gap-2">
279760 <span className="text-xs font-medium text-foreground">Transport</span>
280761 <select
281762 value={transportFields.transport}
@@ -290,7 +771,7 @@ function McpSourceForm(props: { · 8 unmodified lines
290771 setOauthStatus("idle");
291772 }
292773 }}
293− className="h-10 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
774+ className="h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
294775 >
295776 <option value="">Auto (remote)</option>
296777 <option value="auto">Auto</option>
@@ -302,7 +783,7 @@ function McpSourceForm(props: { · 5 unmodified lines
302783
303784 {isStdio ? (
304785 <div className="grid gap-4 md:grid-cols-2">
305− <label className="grid gap-1.5 md:col-span-2">
786+ <label className="grid gap-2 md:col-span-2">
306787 <span className="text-xs font-medium text-foreground">Command</span>
307788 <input
308789 value={transportFields.command}
@@ -311,11 +792,11 @@ function McpSourceForm(props: { · 2 unmodified lines
311792 ...transportFields,
312793 command: event.target.value,
313794 })}
314− className="h-10 rounded-lg border border-input bg-background px-3 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
795+ className="h-9 rounded-lg border border-input bg-background px-3 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
315796 />
316797 </label>
317798
318− <label className="grid gap-1.5">
799+ <label className="grid gap-2">
319800 <span className="text-xs font-medium text-foreground">Args</span>
320801 <textarea
321802 value={transportFields.argsText}
@@ -324,13 +805,13 @@ function McpSourceForm(props: { · 2 unmodified lines
324805 ...transportFields,
325806 argsText: event.target.value,
326807 })}
327− rows={4}
808+ rows={3}
328809 placeholder='["server.js","--port","8787"]'
329810 className="rounded-lg border border-input bg-background px-3 py-2 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
330811 />
331812 </label>
332813
333− <label className="grid gap-1.5">
814+ <label className="grid gap-2">
334815 <span className="text-xs font-medium text-foreground">Environment</span>
335816 <textarea
336817 value={transportFields.envText}
@@ -339,13 +820,13 @@ function McpSourceForm(props: { · 2 unmodified lines
339820 ...transportFields,
340821 envText: event.target.value,
341822 })}
342− rows={4}
823+ rows={3}
343824 placeholder='{"NODE_ENV":"production"}'
344825 className="rounded-lg border border-input bg-background px-3 py-2 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
345826 />
346827 </label>
347828
348− <label className="grid gap-1.5 md:col-span-2">
829+ <label className="grid gap-2 md:col-span-2">
349830 <span className="text-xs font-medium text-foreground">Working Directory</span>
350831 <input
351832 value={transportFields.cwd}
@@ -354,22 +835,22 @@ function McpSourceForm(props: { · 2 unmodified lines
354835 ...transportFields,
355836 cwd: event.target.value,
356837 })}
357− className="h-10 rounded-lg border border-input bg-background px-3 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
838+ className="h-9 rounded-lg border border-input bg-background px-3 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
358839 />
359840 </label>
360841 </div>
361842 ) : (
362843 <div className="grid gap-4 md:grid-cols-2">
363− <label className="grid gap-1.5 md:col-span-2">
844+ <label className="grid gap-2 md:col-span-2">
364845 <span className="text-xs font-medium text-foreground">Endpoint</span>
365846 <input
366847 value={endpoint}
367848 onChange={(event) => setEndpoint(event.target.value)}
368− className="h-10 rounded-lg border border-input bg-background px-3 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
849+ className="h-9 rounded-lg border border-input bg-background px-3 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
369850 />
370851 </label>
371852
372− <label className="grid gap-1.5">
853+ <label className="grid gap-2">
373854 <span className="text-xs font-medium text-foreground">Query Params</span>
374855 <textarea
375856 value={transportFields.queryParamsText}
@@ -378,13 +859,13 @@ function McpSourceForm(props: { · 2 unmodified lines
378859 ...transportFields,
379860 queryParamsText: event.target.value,
380861 })}
381− rows={4}
862+ rows={3}
382863 placeholder='{"transport":"streamable-http"}'
383864 className="rounded-lg border border-input bg-background px-3 py-2 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
384865 />
385866 </label>
386867
387− <label className="grid gap-1.5">
868+ <label className="grid gap-2">
388869 <span className="text-xs font-medium text-foreground">Headers</span>
389870 <textarea
390871 value={transportFields.headersText}
@@ -393,7 +874,7 @@ function McpSourceForm(props: { · 2 unmodified lines
393874 ...transportFields,
394875 headersText: event.target.value,
395876 })}
396− rows={4}
877+ rows={3}
397878 placeholder='{"x-api-key":"..."}'
398879 className="rounded-lg border border-input bg-background px-3 py-2 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
399880 />
@@ -402,7 +883,7 @@ function McpSourceForm(props: { · 2 unmodified lines
402883 )}
403884
404885 {!isStdio && (
405− <label className="grid gap-1.5">
886+ <label className="grid gap-2">
406887 <span className="text-xs font-medium text-foreground">Auth</span>
407888 <select
408889 value={authKind}
@@ -414,7 +895,7 @@ function McpSourceForm(props: { · 5 unmodified lines
414895 setOauthStatus("idle");
415896 }
416897 }}
417− className="h-10 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
898+ className="h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
418899 >
419900 <option value="none">None</option>
420901 <option value="oauth2">OAuth 2.0</option>
@@ -423,14 +904,12 @@ function McpSourceForm(props: { · 2 unmodified lines
423904 )}
424905
425906 {!isStdio && authKind === "oauth2" && (
426− <div className="rounded-xl border border-border/70 bg-muted/40 p-4">
907+ <div className="border-l-2 border-border pl-4">
427908 <div className="flex flex-wrap items-center justify-between gap-3">
428909 <div>
429− <div className="text-sm font-medium text-foreground">
430− MCP OAuth
431− </div>
910+ <div className="text-sm font-medium text-foreground">OAuth</div>
432911 <div className="mt-1 text-xs text-muted-foreground">
433− Authenticate directly against the MCP server and keep the token refs in plugin storage.
912+ Authenticate with the MCP server's built-in OAuth flow.
434913 </div>
435914 </div>
436915 <button
@@ -451,9 +930,11 @@ function McpSourceForm(props: { · 14 unmodified lines
451930 {oauthStatus === "connected" ? "Reconnect OAuth" : "Connect OAuth"}
452931 </button>
453932 </div>
454− <div className="mt-3 text-xs text-muted-foreground">
455− Status: <span className="text-foreground">{oauthStatus}</span>
456− </div>
933+ {oauthStatus === "connected" && (
934+ <div className="mt-3 text-xs text-primary">
935+ Connected
936+ </div>
937+ )}
457938 </div>
458939 )}
459940
@@ -475,7 +956,7 @@ function McpSourceForm(props: { · 15 unmodified lines
475956 );
476957 }}
477958 disabled={submitMutation.status === "pending"}
478− className="inline-flex h-10 items-center rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-60"
959+ className="inline-flex h-9 items-center rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90 disabled:pointer-events-none disabled:opacity-50"
479960 >
480961 {submitMutation.status === "pending"
481962 ? props.mode === "create"
@@ -486,12 +967,14 @@ function McpSourceForm(props: { · 4 unmodified lines
486967 : "Save Changes"}
487968 </button>
488969 </div>
970+ </div>
489971 </div>
490972 );
491973 }
492974
493975 function McpAddPage() {
494976 const navigation = useSourcePluginNavigation();
977+ const initialValue = mcpInputFromSearch(useSourcePluginSearch());
495978 const installation = useLocalInstallation();
496979 const client = getMcpHttpClient();
497980 const createSource = useAtomSet(
@@ -505,7 +988,7 @@ function McpAddPage() { · 7 unmodified lines
505988
506989 return (
507990 <McpSourceForm
508− initialValue={defaultMcpInput()}
991+ initialValue={initialValue}
509992 mode="create"
510993 onSubmit={async (input) => {
511994 const source = await createSource({
@@ -562,13 +1045,13 @@ function McpEditPage(props: { · 50 unmodified lines
5621045 if (Result.isFailure(configResult)) {
5631046 return (
5641047 <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-3 text-sm text-destructive">
565− Failed loading MCP config.
1048+ Failed loading source configuration.
5661049 </div>
5671050 );
5681051 }
5691052
5701053 if (!Result.isSuccess(configResult)) {
571− return <div className="text-sm text-muted-foreground">Loading MCP config...</div>;
1054+ return <div className="text-sm text-muted-foreground">Loading configuration...</div>;
5721055 }
5731056
5741057 return (
@@ -614,6 +1097,8 @@ function McpDetailPage(props: { · 39 unmodified lines
6141097 client.mutation("mcp", "removeSource"),
6151098 { mode: "promise" },
6161099 );
1100+ const [confirmDelete, setConfirmDelete] = useState(false);
1101+ const [isDeleting, setIsDeleting] = useState(false);
6171102 const configResult = useAtomValue(
6181103 installation.status === "ready"
6191104 ? client.query("mcp", "getSourceConfig", {
@@ -657,6 +1142,35 @@ function McpDetailPage(props: { · 37 unmodified lines
6571142 return <div className="text-sm text-muted-foreground">Loading workspace...</div>;
6581143 }
6591144
1145+ const handleDelete = async () => {
1146+ if (isDeleting) {
1147+ return;
1148+ }
1149+
1150+ setIsDeleting(true);
1151+ try {
1152+ await removeSource({
1153+ path: {
1154+ workspaceId: installation.data.scopeId,
1155+ sourceId: props.source.id,
1156+ },
1157+ reactivityKeys: {
1158+ sources: [installation.data.scopeId],
1159+ source: [installation.data.scopeId, props.source.id],
1160+ sourceInspection: [installation.data.scopeId, props.source.id],
1161+ sourceInspectionTool: [installation.data.scopeId, props.source.id],
1162+ sourceDiscovery: [installation.data.scopeId, props.source.id],
1163+ },
1164+ });
1165+ startTransition(() => {
1166+ void navigation.home();
1167+ });
1168+ } finally {
1169+ setIsDeleting(false);
1170+ setConfirmDelete(false);
1171+ }
1172+ };
1173+
6601174 return (
6611175 <SourceToolExplorer
6621176 sourceId={props.source.id}
@@ -676,36 +1190,40 @@ function McpDetailPage(props: { · 13 unmodified lines
6761190 <IconPencil className="size-3.5" />
6771191 Edit
6781192 </button>
679− <button
680− type="button"
681− onClick={() => {
682− const confirmed = window.confirm(`Delete MCP source "${props.source.name}"?`);
683− if (!confirmed) {
684− return;
685− }
686−
687− void removeSource({
688− path: {
689− workspaceId: installation.data.scopeId,
690− sourceId: props.source.id,
691− },
692− reactivityKeys: {
693− sources: [installation.data.scopeId],
694− source: [installation.data.scopeId, props.source.id],
695− sourceInspection: [installation.data.scopeId, props.source.id],
696− sourceInspectionTool: [installation.data.scopeId, props.source.id],
697− sourceDiscovery: [installation.data.scopeId, props.source.id],
698− },
699− }).then(() => {
700− startTransition(() => {
701− void navigation.home();
702− });
703− });
704− }}
705− className="inline-flex h-9 items-center rounded-lg border border-destructive/25 bg-destructive/5 px-3 text-sm font-medium text-destructive transition-colors hover:bg-destructive/10"
706− >
707− Delete
708− </button>
1193+ {confirmDelete ? (
1194+ <div className="flex items-center gap-2">
1195+ <span className="text-xs font-medium text-destructive">
1196+ Confirm delete?
1197+ </span>
1198+ <button
1199+ type="button"
1200+ onClick={() => setConfirmDelete(false)}
1201+ disabled={isDeleting}
1202+ className="inline-flex h-9 items-center rounded-lg border border-input bg-card px-3 text-sm font-medium text-foreground transition-colors hover:bg-accent/50 disabled:pointer-events-none disabled:opacity-50"
1203+ >
1204+ Cancel
1205+ </button>
1206+ <button
1207+ type="button"
1208+ onClick={() => {
1209+ void handleDelete().catch(() => {});
1210+ }}
1211+ disabled={isDeleting}
1212+ className="inline-flex h-9 items-center rounded-lg border border-destructive/25 bg-destructive/5 px-3 text-sm font-medium text-destructive transition-colors hover:bg-destructive/10 disabled:pointer-events-none disabled:opacity-50"
1213+ >
1214+ {isDeleting ? "Deleting..." : "Delete"}
1215+ </button>
1216+ </div>
1217+ ) : (
1218+ <button
1219+ type="button"
1220+ onClick={() => setConfirmDelete(true)}
1221+ disabled={isDeleting}
1222+ className="inline-flex h-9 items-center rounded-lg border border-destructive/25 bg-destructive/5 px-3 text-sm font-medium text-destructive transition-colors hover:bg-destructive/10 disabled:pointer-events-none disabled:opacity-50"
1223+ >
1224+ Delete
1225+ </button>
1226+ )}
7091227 </>
7101228 )}
7111229 />
@@ -716,7 +1234,7 @@ const mcpSourceType = defineFrontendSourceType({ · 4 unmodified lines
7161234 key: "mcp",
7171235 kind: "mcp",
7181236 displayName: "MCP",
719− description: "Connect remote or local MCP servers with plugin-owned OAuth.",
1237+ description: "Connect remote or local MCP servers.",
7201238 renderAddPage: McpAddPage,
7211239 renderEditPage: McpEditPage,
7221240 renderDetailPage: McpDetailPage,
plugins/mcp/sdk/index.ts+22−0
@@ -19,6 +19,9 @@ import { · 18 unmodified lines
1919 contentHash,
2020 createCatalogImportMetadata,
2121 createSourceCatalogSyncResult,
22+ normalizeSourceDiscoveryUrl,
23+ probeHeadersFromAuth,
24+ type SourceDiscoveryResult,
2225 } from "@executor/source-core";
2326 import type { Source } from "@executor/platform-sdk/schema";
2427 import type {
@@ -41,6 +44,8 @@ import { · 16 unmodified lines
4144 deriveMcpNamespace,
4245 resolveMcpEndpoint,
4346 type McpConnectInput,
47+ type McpDiscoverInput,
48+ type McpDiscoverResult,
4449 type McpConnectionAuth,
4550 type McpOAuthPopupResult,
4651 type McpOAuthSession,
@@ -57,6 +62,9 @@ import { · 10 unmodified lines
5762 createMcpCatalogFragment,
5863 type McpCatalogOperationInput,
5964 } from "./catalog";
65+import {
66+ detectMcpSource,
67+} from "./discovery";
6068 import {
6169 createPooledMcpConnector,
6270 } from "./connection-pool";
@@ -124,6 +132,9 @@ export type McpSdk = { · 61 unmodified lines
124132 getSourceConfig: (
125133 sourceId: Source["id"],
126134 ) => Effect.Effect<McpSourceConfigPayload, Error>;
135+ discoverSource: (
136+ input: McpDiscoverInput,
137+ ) => Effect.Effect<McpDiscoverResult, Error>;
127138 createSource: (
128139 input: McpConnectInput,
129140 ) => Effect.Effect<Source, Error>;
@@ -328,6 +339,15 @@ const createMcpSourceSdk = ( · 198 unmodified lines
328339
329340 return sourceConfigFromStored(source, stored);
330341 }),
342+ discoverSource: (input: McpDiscoverInput) =>
343+ Effect.gen(function* () {
344+ const normalizedUrl = normalizeSourceDiscoveryUrl(input.endpoint);
345+ const discovered = yield* detectMcpSource({
346+ normalizedUrl,
347+ headers: probeHeadersFromAuth(input.probeAuth ?? null),
348+ });
349+ return discovered satisfies SourceDiscoveryResult | null;
350+ }),
331351 createSource: (input: McpConnectInput) =>
332352 Effect.gen(function* () {
333353 const stored = yield* normalizeStoredSourceData(input);
@@ -908,6 +928,8 @@ export const mcpSdkPlugin = ( · 574 unmodified lines
908928 return {
909929 getSourceConfig: (sourceId) =>
910930 provideRuntime(sourceSdk.getSourceConfig(sourceId)),
931+ discoverSource: (input) =>
932+ provideRuntime(sourceSdk.discoverSource(input)),
911933 createSource: (input) =>
912934 provideRuntime(sourceSdk.createSource(input)),
913935 updateSource: (input) =>
plugins/mcp/shared/index.ts+11−0
@@ -5,6 +5,8 @@ import { · 4 unmodified lines
55 } from "@executor/platform-sdk/schema";
66 import {
77 JsonObjectSchema,
8+ SourceDiscoveryResultSchema,
9+ SourceProbeAuthSchema,
810 SourceTransportSchema,
911 StringArraySchema,
1012 StringMapSchema,
@@ -70,6 +72,13 @@ export const McpStartOAuthInputSchema = Schema.Struct({ · 59 unmodified lines
7072 redirectUrl: Schema.String,
7173 });
7274
75+export const McpDiscoverInputSchema = Schema.Struct({
76+ endpoint: Schema.String,
77+ probeAuth: Schema.optional(Schema.NullOr(SourceProbeAuthSchema)),
78+});
79+
80+export const McpDiscoverResultSchema = Schema.NullOr(SourceDiscoveryResultSchema);
81+
7382 export const McpStartOAuthResultSchema = Schema.Struct({
7483 sessionId: Schema.String,
7584 authorizationUrl: Schema.String,
@@ -123,6 +132,8 @@ export type McpUpdateSourceInput = typeof McpUpdateSourceInputSchema.Type; · 47 unmodified lines
123132 export type McpStoredSourceData = typeof McpStoredSourceDataSchema.Type;
124133 export type McpStartOAuthInput = typeof McpStartOAuthInputSchema.Type;
125134 export type McpStartOAuthResult = typeof McpStartOAuthResultSchema.Type;
135+export type McpDiscoverInput = typeof McpDiscoverInputSchema.Type;
136+export type McpDiscoverResult = typeof McpDiscoverResultSchema.Type;
126137 export type McpOAuthSession = typeof McpOAuthSessionSchema.Type;
127138 export type McpOAuthPopupResult = typeof McpOAuthPopupResultSchema.Type;
128139
plugins/openapi/react/index.tsx+387−152
@@ -9,8 +9,10 @@ import { · 8 unmodified lines
99 defineExecutorPluginHttpApiClient,
1010 useAtomValue,
1111 useAtomSet,
12+ useCreateSecret,
1213 useExecutorMutation,
1314 usePrefetchToolDetail,
15+ useSecrets,
1416 useSourceDiscovery,
1517 useSourceInspection,
1618 useSourceToolDetail,
@@ -62,6 +64,93 @@ const defaultOpenApiInput = (): OpenApiConnectInput => ({ · 45 unmodified lines
6264 },
6365 });
6466
67+const DEFAULT_BEARER_HEADER_NAME = "Authorization";
68+const DEFAULT_BEARER_PREFIX = "Bearer ";
69+const CREATE_SECRET_VALUE = "__create_openapi_secret__";
70+
71+const presetString = (
72+ search: Record<string, unknown>,
73+ key: string,
74+): string | null => {
75+ const value = search[key];
76+ return typeof value === "string" && value.trim().length > 0
77+ ? value.trim()
78+ : null;
79+};
80+
81+const firstSearchString = (
82+ search: Record<string, unknown>,
83+ keys: ReadonlyArray<string>,
84+): string | null => {
85+ for (const key of keys) {
86+ const value = presetString(search, key);
87+ if (value) {
88+ return value;
89+ }
90+ }
91+
92+ return null;
93+};
94+
95+const looksLikeOpenApiSpecUrl = (value: string): boolean => {
96+ const lower = value.trim().toLowerCase();
97+ if (!/^https?:\/\//.test(lower)) {
98+ return false;
99+ }
100+
101+ return (
102+ lower.endsWith(".json")
103+ || lower.endsWith(".yaml")
104+ || lower.endsWith(".yml")
105+ || lower.includes("/openapi")
106+ || lower.includes("openapi.")
107+ || lower.includes("/swagger")
108+ || lower.includes("swagger.")
109+ || lower.includes("/api-docs")
110+ );
111+};
112+
113+const inferOpenApiUrls = (value: string): {
114+ specUrl: string;
115+ baseUrl: string | null;
116+} => {
117+ const trimmed = value.trim();
118+ if (!trimmed) {
119+ return {
120+ specUrl: defaultOpenApiInput().specUrl,
121+ baseUrl: defaultOpenApiInput().baseUrl,
122+ };
123+ }
124+
125+ if (looksLikeOpenApiSpecUrl(trimmed)) {
126+ return {
127+ specUrl: trimmed,
128+ baseUrl: null,
129+ };
130+ }
131+
132+ const normalizedBaseUrl = trimmed.replace(/\/+$/, "");
133+ return {
134+ specUrl: `${normalizedBaseUrl}/openapi.json`,
135+ baseUrl: normalizedBaseUrl,
136+ };
137+};
138+
139+const openApiInputFromSearch = (
140+ search: Record<string, unknown>,
141+): OpenApiConnectInput => {
142+ const defaults = defaultOpenApiInput();
143+ const genericUrl = firstSearchString(search, ["inputUrl", "pastedUrl", "url"]);
144+ const inferred = genericUrl ? inferOpenApiUrls(genericUrl) : defaults;
145+
146+ return {
147+ ...defaults,
148+ name: presetString(search, "presetName") ?? defaults.name,
149+ specUrl: presetString(search, "presetSpecUrl") ?? inferred.specUrl,
150+ baseUrl: presetString(search, "presetBaseUrl") ?? inferred.baseUrl,
151+ };
152+};
153+
65154 const getOpenApiHttpClient = defineExecutorPluginHttpApiClient<"OpenApiReactHttpClient">()(
66155 "OpenApiReactHttpClient",
67156 [openApiHttpApiExtension] as const,
@@ -72,13 +161,8 @@ const Section = (props: { · 4 unmodified lines
72161 title: string;
73162 children: ReactNode;
74163 }) => (
75− <section className="rounded-xl border border-border/70 bg-card/40 p-4">
76− <div className="mb-3 flex items-center gap-2">
77− <h2 className="text-sm font-semibold">{props.title}</h2>
78− <span className="inline-flex items-center rounded-full border border-transparent bg-secondary px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-secondary-foreground">
79− Plugin
80− </span>
81− </div>
164+ <section>
165+ <h2 className="mb-3 text-sm font-semibold text-foreground">{props.title}</h2>
82166 {props.children}
83167 </section>
84168 );
@@ -174,9 +258,26 @@ const secretValue = (input: OpenApiConnectInput["auth"]): string => · 89 unmodified lines
174258 ? JSON.stringify(input.tokenSecretRef)
175259 : "";
176260
261+const bearerHeaderNameValue = (input: OpenApiConnectInput["auth"]): string =>
262+ input.kind === "bearer"
263+ ? input.headerName?.trim() || DEFAULT_BEARER_HEADER_NAME
264+ : DEFAULT_BEARER_HEADER_NAME;
265+
266+const bearerPrefixValue = (input: OpenApiConnectInput["auth"]): string =>
267+ input.kind === "bearer"
268+ ? input.prefix ?? DEFAULT_BEARER_PREFIX
269+ : DEFAULT_BEARER_PREFIX;
270+
271+const trimToNull = (value: string): string | null => {
272+ const trimmed = value.trim();
273+ return trimmed.length > 0 ? trimmed : null;
274+};
275+
177276 const authFromSecretValue = (
178277 authKind: OpenApiConnectInput["auth"]["kind"],
179278 value: string,
279+ headerName: string,
280+ prefix: string,
180281 ): OpenApiConnectInput["auth"] => {
181282 if (authKind === "none") {
182283 return {
@@ -191,23 +292,138 @@ const authFromSecretValue = ( · 8 unmodified lines
191292 return {
192293 kind: "bearer",
193294 tokenSecretRef: JSON.parse(value) as OpenApiConnectInput["auth"] & { tokenSecretRef: never }["tokenSecretRef"],
295+ headerName: trimToNull(headerName),
296+ prefix: prefix === DEFAULT_BEARER_PREFIX ? null : prefix,
194297 };
195298 };
196299
197−const useAvailableSecrets = (
198− openApiHttpClient: ReturnType<typeof getOpenApiHttpClient>,
199−) => {
200− const secretsResult = useAtomValue(
201− openApiHttpClient.query("local", "listSecrets", {
202− reactivityKeys: {
203− secrets: [],
204− },
205− timeToLive: "1 minute",
206− }),
207− );
300+function SecretSelectOrCreateField(props: {
301+ label: string;
302+ value: string;
303+ emptyLabel: string;
304+ draftNamePlaceholder: string;
305+ draftValuePlaceholder: string;
306+ onChange: (value: string) => void;
307+}) {
308+ const secrets = useSecrets();
309+ const createSecret = useCreateSecret();
310+ const [draftName, setDraftName] = useState("");
311+ const [draftValue, setDraftValue] = useState("");
312+ const [createError, setCreateError] = useState<string | null>(null);
313+ const [showCreate, setShowCreate] = useState(false);
314+
315+ const handleSelectChange = (nextValue: string) => {
316+ if (nextValue === CREATE_SECRET_VALUE) {
317+ setShowCreate(true);
318+ props.onChange("");
319+ return;
320+ }
208321
209− return Result.isSuccess(secretsResult) ? secretsResult.value : [];
210−};
322+ setShowCreate(false);
323+ setCreateError(null);
324+ props.onChange(nextValue);
325+ };
326+
327+ const handleCreate = async () => {
328+ const trimmedName = draftName.trim();
329+ if (!trimmedName) {
330+ setCreateError("Secret name is required.");
331+ return;
332+ }
333+ if (!draftValue.trim()) {
334+ setCreateError("Secret value is required.");
335+ return;
336+ }
337+
338+ try {
339+ setCreateError(null);
340+ const created = await createSecret.mutateAsync({
341+ name: trimmedName,
342+ value: draftValue,
343+ });
344+ props.onChange(JSON.stringify({
345+ providerId: created.providerId,
346+ handle: created.id,
347+ }));
348+ setDraftName("");
349+ setDraftValue("");
350+ setShowCreate(false);
351+ } catch (cause) {
352+ setCreateError(cause instanceof Error ? cause.message : "Failed creating secret.");
353+ }
354+ };
355+
356+ return (
357+ <div className="grid gap-2">
358+ <span className="text-xs font-medium text-foreground">{props.label}</span>
359+ <select
360+ value={showCreate ? CREATE_SECRET_VALUE : props.value}
361+ onChange={(event) => handleSelectChange(event.target.value)}
362+ className="h-9 w-full rounded-lg border border-input bg-background px-3 text-sm text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
363+ >
364+ <option value="">{props.emptyLabel}</option>
365+ {secrets.status === "ready" &&
366+ secrets.data.map((secret) => (
367+ <option
368+ key={`${secret.providerId}:${secret.id}`}
369+ value={JSON.stringify({
370+ providerId: secret.providerId,
371+ handle: secret.id,
372+ })}
373+ >
374+ {secret.name ?? secret.id}
375+ </option>
376+ ))}
377+ <option value={CREATE_SECRET_VALUE}>Create new secret</option>
378+ </select>
379+
380+ {showCreate && (
381+ <div className="space-y-3 border-l-2 border-border pl-4">
382+ <input
383+ value={draftName}
384+ onChange={(event) => setDraftName(event.target.value)}
385+ placeholder={props.draftNamePlaceholder}
386+ className="h-9 w-full rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
387+ />
388+ <textarea
389+ value={draftValue}
390+ onChange={(event) => setDraftValue(event.target.value)}
391+ rows={3}
392+ placeholder={props.draftValuePlaceholder}
393+ className="w-full rounded-lg border border-input bg-background px-3 py-2 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
394+ />
395+ {createError && (
396+ <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-3 py-2 text-xs text-destructive">
397+ {createError}
398+ </div>
399+ )}
400+ <div className="flex items-center gap-2">
401+ <button
402+ type="button"
403+ onClick={() => {
404+ void handleCreate();
405+ }}
406+ disabled={createSecret.status === "pending"}
407+ className="inline-flex h-8 items-center justify-center rounded-lg bg-primary px-3 text-xs font-medium text-primary-foreground transition-opacity disabled:pointer-events-none disabled:opacity-50"
408+ >
409+ {createSecret.status === "pending" ? "Creating..." : "Create Secret"}
410+ </button>
411+ <button
412+ type="button"
413+ onClick={() => {
414+ setShowCreate(false);
415+ setCreateError(null);
416+ }}
417+ className="inline-flex h-8 items-center justify-center rounded-lg border border-input bg-card px-3 text-xs font-medium text-foreground transition-colors hover:bg-accent/50"
418+ >
419+ Cancel
420+ </button>
421+ </div>
422+ </div>
423+ )}
424+ </div>
425+ );
426+}
211427
212428 function OpenApiSourceForm(props: {
213429 mode: "create" | "edit";
@@ -217,7 +433,6 @@ function OpenApiSourceForm(props: { · 3 unmodified lines
217433 onSubmit: (input: OpenApiConnectInput) => Promise<void>;
218434 }) {
219435 const openApiHttpClient = getOpenApiHttpClient();
220− const availableSecrets = useAvailableSecrets(openApiHttpClient);
221436 const previewDocument = useAtomSet(
222437 openApiHttpClient.mutation("openapi", "previewDocument"),
223438 { mode: "promise" },
@@ -232,6 +447,12 @@ function OpenApiSourceForm(props: { · 8 unmodified lines
232447 const [tokenSecretRef, setTokenSecretRef] = useState(
233448 secretValue(props.initialValue.auth),
234449 );
450+ const [authHeaderName, setAuthHeaderName] = useState(
451+ bearerHeaderNameValue(props.initialValue.auth),
452+ );
453+ const [authPrefix, setAuthPrefix] = useState(
454+ bearerPrefixValue(props.initialValue.auth),
455+ );
235456 const [error, setError] = useState<string | null>(null);
236457 const [preview, setPreview] = useState<OpenApiPreviewResponse | null>(null);
237458 const [nameEdited, setNameEdited] = useState(false);
@@ -332,7 +553,12 @@ function OpenApiSourceForm(props: { · 94 unmodified lines
332553 }
333554
334555 try {
335− const auth = authFromSecretValue(authKind, tokenSecretRef);
556+ const auth = authFromSecretValue(
557+ authKind,
558+ tokenSecretRef,
559+ authHeaderName,
560+ authPrefix,
561+ );
336562 await submitMutation.mutateAsync({
337563 name: trimmedName,
338564 specUrl: trimmedSpecUrl,
@@ -345,15 +571,11 @@ function OpenApiSourceForm(props: { · 6 unmodified lines
345571 };
346572
347573 return (
348− <div className="space-y-4">
574+ <div className="space-y-6 rounded-xl border border-border p-6">
349575 <Section title="Connection">
350− <p className="text-sm text-muted-foreground">
351− This plugin owns its typed HTTP client and source payload shape. The app shell only
352− mounts the registered page.
353− </p>
354− <div className="mt-4 grid gap-4">
355− <label className="grid gap-1.5">
356− <span className="text-[12px] font-medium text-foreground">Name</span>
576+ <div className="grid gap-4">
577+ <label className="grid gap-2">
578+ <span className="text-xs font-medium text-foreground">Name</span>
357579 <input
358580 value={name}
359581 onChange={(event) => {
@@ -361,12 +583,12 @@ function OpenApiSourceForm(props: { · 1 unmodified line
361583 setName(event.target.value);
362584 }}
363585 placeholder="GitHub REST"
364− className="h-10 w-full rounded-lg border border-input bg-background px-3 text-[13px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25"
586+ className="h-9 w-full rounded-lg border border-input bg-background px-3 text-sm text-foreground outline-none transition-colors placeholder:text-muted-foreground/40 focus:border-ring focus:ring-1 focus:ring-ring/25"
365587 />
366588 </label>
367589
368− <label className="grid gap-1.5">
369− <span className="text-[12px] font-medium text-foreground">Spec URL</span>
590+ <label className="grid gap-2">
591+ <span className="text-xs font-medium text-foreground">Spec URL</span>
370592 <input
371593 value={specUrl}
372594 onChange={(event) => setSpecUrl(event.target.value)}
@@ -374,12 +596,12 @@ function OpenApiSourceForm(props: { · 1 unmodified line
374596 void runPreview({ mode: "manual" });
375597 }}
376598 placeholder="https://example.com/openapi.json"
377− className="h-10 w-full rounded-lg border border-input bg-background px-3 font-mono text-[12px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25"
599+ className="h-9 w-full rounded-lg border border-input bg-background px-3 font-mono text-xs text-foreground outline-none transition-colors placeholder:text-muted-foreground/40 focus:border-ring focus:ring-1 focus:ring-ring/25"
378600 />
379601 </label>
380602
381− <label className="grid gap-1.5">
382− <span className="text-[12px] font-medium text-foreground">Base URL</span>
603+ <label className="grid gap-2">
604+ <span className="text-xs font-medium text-foreground">Base URL</span>
383605 <input
384606 value={baseUrl}
385607 onChange={(event) => {
@@ -387,17 +609,17 @@ function OpenApiSourceForm(props: { · 1 unmodified line
387609 setBaseUrl(event.target.value);
388610 }}
389611 placeholder="https://api.example.com"
390− className="h-10 w-full rounded-lg border border-input bg-background px-3 font-mono text-[12px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25"
612+ className="h-9 w-full rounded-lg border border-input bg-background px-3 font-mono text-xs text-foreground outline-none transition-colors placeholder:text-muted-foreground/40 focus:border-ring focus:ring-1 focus:ring-ring/25"
391613 />
392614 </label>
393615
394− <label className="grid gap-1.5">
395− <span className="text-[12px] font-medium text-foreground">Auth</span>
616+ <label className="grid gap-2">
617+ <span className="text-xs font-medium text-foreground">Auth</span>
396618 <select
397619 value={authKind}
398620 onChange={(event) =>
399621 setAuthKind(event.target.value as OpenApiConnectInput["auth"]["kind"])}
400− className="h-10 w-full rounded-lg border border-input bg-background px-3 text-[13px] text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
622+ className="h-9 w-full rounded-lg border border-input bg-background px-3 text-sm text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
401623 >
402624 <option value="none">None</option>
403625 <option value="bearer">Bearer Secret</option>
@@ -405,52 +627,54 @@ function OpenApiSourceForm(props: { · 1 unmodified line
405627 </label>
406628
407629 {authKind === "bearer" && (
408− <label className="grid gap-1.5">
409− <span className="text-[12px] font-medium text-foreground">Secret</span>
410− <select
630+ <div className="space-y-4 border-l-2 border-border pl-4">
631+ <SecretSelectOrCreateField
632+ label="Secret"
411633 value={tokenSecretRef}
412− onChange={(event) => setTokenSecretRef(event.target.value)}
413− className="h-10 w-full rounded-lg border border-input bg-background px-3 text-[13px] text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
414− >
415− <option value="">Select a secret</option>
416− {availableSecrets.map((secret) => (
417− <option
418− key={`${secret.providerId}:${secret.id}`}
419− value={JSON.stringify({
420− providerId: secret.providerId,
421− handle: secret.id,
422− })}
423− >
424− {secret.name || secret.id}
425− </option>
426− ))}
427− </select>
428− </label>
634+ emptyLabel="Select a secret"
635+ draftNamePlaceholder="OpenAPI bearer token"
636+ draftValuePlaceholder="Paste the token value"
637+ onChange={setTokenSecretRef}
638+ />
639+
640+ <div className="grid gap-4 sm:grid-cols-2">
641+ <label className="grid gap-2">
642+ <span className="text-xs font-medium text-foreground">Header Name</span>
643+ <input
644+ value={authHeaderName}
645+ onChange={(event) => setAuthHeaderName(event.target.value)}
646+ placeholder={DEFAULT_BEARER_HEADER_NAME}
647+ className="h-9 w-full rounded-lg border border-input bg-background px-3 font-mono text-xs text-foreground outline-none transition-colors placeholder:text-muted-foreground/40 focus:border-ring focus:ring-1 focus:ring-ring/25"
648+ />
649+ </label>
650+
651+ <label className="grid gap-2">
652+ <span className="text-xs font-medium text-foreground">Prefix</span>
653+ <input
654+ value={authPrefix}
655+ onChange={(event) => setAuthPrefix(event.target.value)}
656+ placeholder={DEFAULT_BEARER_PREFIX}
657+ className="h-9 w-full rounded-lg border border-input bg-background px-3 font-mono text-xs text-foreground outline-none transition-colors placeholder:text-muted-foreground/40 focus:border-ring focus:ring-1 focus:ring-ring/25"
658+ />
659+ </label>
660+ </div>
661+ </div>
429662 )}
430663 </div>
431664 </Section>
432665
433666 <Section title="Preview">
434− <p className="text-sm text-muted-foreground">
435− Preview introspects the document and can pull out defaults like title and base URL from
436− the OpenAPI spec.
437− </p>
438− <div className="mt-3 flex items-center gap-3">
667+ <div className="flex items-center gap-3">
439668 <button
440669 type="button"
441670 onClick={() => {
442671 void runPreview({ mode: "manual" });
443672 }}
444673 disabled={previewMutation.status === "pending" || submitMutation.status === "pending"}
445− className="inline-flex h-7 items-center justify-center rounded-md border border-input bg-transparent px-2.5 text-xs font-medium text-foreground disabled:pointer-events-none disabled:opacity-50"
674+ className="inline-flex h-9 items-center justify-center rounded-lg border border-input bg-card px-3 text-sm font-medium text-foreground transition-colors hover:bg-accent/50 disabled:pointer-events-none disabled:opacity-50"
446675 >
447− {previewMutation.status === "pending" ? "Previewing..." : "Preview OpenAPI Document"}
676+ {previewMutation.status === "pending" ? "Previewing..." : "Preview Spec"}
448677 </button>
449− {previewMutation.status === "pending" && (
450− <div className="text-xs text-muted-foreground">
451− Inferring from spec...
452− </div>
453− )}
454678 {preview && (
455679 <div className="text-xs text-muted-foreground">
456680 {preview.operationCount} operations
@@ -459,35 +683,31 @@ function OpenApiSourceForm(props: { · 2 unmodified lines
459683 )}
460684 </div>
461685 {preview && (
462− <div className="mt-4 rounded-lg border border-border/70 bg-background/60 p-4 text-sm">
463− <div className="grid gap-2">
464− <div>
465− <span className="font-medium text-foreground">Title:</span>{" "}
466− <span className="text-muted-foreground">{preview.title ?? "Unknown"}</span>
467− </div>
468− <div>
469− <span className="font-medium text-foreground">Version:</span>{" "}
470− <span className="text-muted-foreground">{preview.version ?? "Unknown"}</span>
471− </div>
472− <div>
473− <span className="font-medium text-foreground">Base URL:</span>{" "}
474− <span className="text-muted-foreground">{preview.baseUrl ?? "Not declared"}</span>
475− </div>
476− <div>
477− <span className="font-medium text-foreground">Namespace:</span>{" "}
478− <span className="text-muted-foreground">{preview.namespace ?? "Not inferred"}</span>
479− </div>
480− <div>
481− <span className="font-medium text-foreground">Auth:</span>{" "}
482− <span className="text-muted-foreground">
483− {preview.securitySchemes.length > 0
484− ? preview.securitySchemes
485− .map((scheme) => `${scheme.name} (${previewSecuritySchemeLabel(scheme)})`)
486− .join(", ")
487− : "No declared auth schemes"}
488− </span>
489− </div>
490− </div>
686+ <div className="mt-3 rounded-lg border border-border bg-muted/30 px-4 py-3 text-sm">
687+ <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-xs">
688+ <dt className="font-medium text-foreground">Title</dt>
689+ <dd className="text-muted-foreground">{preview.title ?? "—"}</dd>
690+ <dt className="font-medium text-foreground">Version</dt>
691+ <dd className="text-muted-foreground">{preview.version ?? "—"}</dd>
692+ <dt className="font-medium text-foreground">Base URL</dt>
693+ <dd className="font-mono text-muted-foreground">{preview.baseUrl ?? "—"}</dd>
694+ {preview.namespace && (
695+ <>
696+ <dt className="font-medium text-foreground">Namespace</dt>
697+ <dd className="text-muted-foreground">{preview.namespace}</dd>
698+ </>
699+ )}
700+ {preview.securitySchemes.length > 0 && (
701+ <>
702+ <dt className="font-medium text-foreground">Auth</dt>
703+ <dd className="text-muted-foreground">
704+ {preview.securitySchemes
705+ .map((scheme) => `${scheme.name} (${previewSecuritySchemeLabel(scheme)})`)
706+ .join(", ")}
707+ </dd>
708+ </>
709+ )}
710+ </dl>
491711 {preview.warnings.length > 0 && (
492712 <div className="mt-3 rounded-md border border-amber-300/40 bg-amber-100/20 px-3 py-2 text-xs text-amber-800">
493713 {preview.warnings.join(" ")}
@@ -497,38 +717,31 @@ function OpenApiSourceForm(props: { · 3 unmodified lines
497717 )}
498718 </Section>
499719
500− <Section title={props.mode === "create" ? "Submit" : "Save"}>
501− {error && (
502− <div className="mb-3 rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-2.5 text-[13px] text-destructive">
503− {error}
504− </div>
505− )}
506− <div className="flex items-center gap-3">
507− <button
508− type="button"
509− onClick={() => {
510− void handleSubmit();
511− }}
512− disabled={submitMutation.status === "pending"}
513− className="inline-flex h-7 items-center justify-center rounded-md bg-primary px-2.5 text-xs font-medium text-primary-foreground disabled:pointer-events-none disabled:opacity-50"
514− >
515− {submitMutation.status === "pending" ? props.busyLabel : props.submitLabel}
516− </button>
517− <div className="text-xs text-muted-foreground">
518− {props.mode === "create"
519− ? "Creates a real source and immediately imports its tools."
520− : "Updates plugin-owned config and refreshes the imported model."}
521− </div>
720+ {error && (
721+ <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-2.5 text-sm text-destructive">
722+ {error}
522723 </div>
523− </Section>
724+ )}
725+
726+ <div className="flex items-center justify-end">
727+ <button
728+ type="button"
729+ onClick={() => {
730+ void handleSubmit();
731+ }}
732+ disabled={submitMutation.status === "pending"}
733+ className="inline-flex h-9 items-center justify-center rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90 disabled:pointer-events-none disabled:opacity-50"
734+ >
735+ {submitMutation.status === "pending" ? props.busyLabel : props.submitLabel}
736+ </button>
737+ </div>
524738 </div>
525739 );
526740 }
527741
528−function OpenApiAddSourcePage(props: {
529− initialValue: OpenApiConnectInput;
530−}) {
742+function OpenApiAddSourcePage() {
531743 const navigation = useSourcePluginNavigation();
744+ const initialValue = openApiInputFromSearch(useSourcePluginSearch());
532745 const openApiHttpClient = getOpenApiHttpClient();
533746 const createSource = useAtomSet(
534747 openApiHttpClient.mutation("openapi", "createSource"),
@@ -539,7 +752,7 @@ function OpenApiAddSourcePage(props: { · 4 unmodified lines
539752 return (
540753 <OpenApiSourceForm
541754 mode="create"
542− initialValue={props.initialValue}
755+ initialValue={initialValue}
543756 submitLabel="Create Source"
544757 busyLabel="Creating..."
545758 onSubmit={async (payload) => {
@@ -587,17 +800,17 @@ function OpenApiEditSourcePage(props: { · 41 unmodified lines
587800 if (!Result.isSuccess(configResult)) {
588801 if (Result.isFailure(configResult)) {
589802 return (
590− <Section title="OpenAPI Plugin Editor">
803+ <Section title="Edit Source">
591804 <div className="rounded-lg border border-destructive/30 bg-destructive/8 p-4 text-sm text-destructive">
592− Failed loading plugin config.
805+ Failed loading source configuration.
593806 </div>
594807 </Section>
595808 );
596809 }
597810
598811 return (
599− <Section title="OpenAPI Plugin Editor">
600− <div className="text-sm text-muted-foreground">Loading plugin config...</div>
812+ <Section title="Edit Source">
813+ <div className="text-sm text-muted-foreground">Loading configuration...</div>
601814 </Section>
602815 );
603816 }
@@ -692,6 +905,7 @@ function OpenApiSourceDetailPage(props: { · 88 unmodified lines
692905 limit: 20,
693906 });
694907 const toolDetail = useSourceToolDetail(props.source.id, selectedToolPath);
908+ const [confirmDelete, setConfirmDelete] = useState(false);
695909
696910 const setRouteSearch = (next: {
697911 tab?: "model" | "discover";
@@ -781,23 +995,46 @@ function OpenApiSourceDetailPage(props: { · 83 unmodified lines
781995 <IconPencil className="size-3" />
782996 Edit
783997 </button>
784− <button
785− type="button"
786− onClick={() => {
787− const confirmed = window.confirm(`Delete OpenAPI source "${props.source.name}"?`);
788− if (!confirmed) return;
789−
790− void removeMutation.mutateAsync(props.source.id).then(() => {
791− startTransition(() => {
792− void navigation.home();
793− });
794− });
795− }}
796− disabled={removeMutation.status === "pending"}
797− className="inline-flex items-center rounded-md border border-destructive/25 bg-destructive/5 px-2.5 py-1 text-[12px] font-medium text-destructive transition-colors hover:bg-destructive/10 disabled:pointer-events-none disabled:opacity-50"
798− >
799− {removeMutation.status === "pending" ? "Deleting..." : "Delete"}
800− </button>
998+ {confirmDelete ? (
999+ <div className="flex items-center gap-2">
1000+ <span className="text-[11px] font-medium text-destructive">
1001+ Confirm delete?
1002+ </span>
1003+ <button
1004+ type="button"
1005+ onClick={() => setConfirmDelete(false)}
1006+ disabled={removeMutation.status === "pending"}
1007+ className="inline-flex items-center rounded-md border border-border bg-card px-2.5 py-1 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
1008+ >
1009+ Cancel
1010+ </button>
1011+ <button
1012+ type="button"
1013+ onClick={() => {
1014+ void removeMutation.mutateAsync(props.source.id).then(() => {
1015+ startTransition(() => {
1016+ void navigation.home();
1017+ });
1018+ }).finally(() => {
1019+ setConfirmDelete(false);
1020+ });
1021+ }}
1022+ disabled={removeMutation.status === "pending"}
1023+ className="inline-flex items-center rounded-md border border-destructive/25 bg-destructive/5 px-2.5 py-1 text-[12px] font-medium text-destructive transition-colors hover:bg-destructive/10 disabled:pointer-events-none disabled:opacity-50"
1024+ >
1025+ {removeMutation.status === "pending" ? "Deleting..." : "Delete"}
1026+ </button>
1027+ </div>
1028+ ) : (
1029+ <button
1030+ type="button"
1031+ onClick={() => setConfirmDelete(true)}
1032+ disabled={removeMutation.status === "pending"}
1033+ className="inline-flex items-center rounded-md border border-destructive/25 bg-destructive/5 px-2.5 py-1 text-[12px] font-medium text-destructive transition-colors hover:bg-destructive/10 disabled:pointer-events-none disabled:opacity-50"
1034+ >
1035+ Delete
1036+ </button>
1037+ )}
8011038 </div>
8021039 </div>
8031040
@@ -1425,9 +1662,7 @@ const openApiSourceType = defineFrontendSourceType({ · 621 unmodified lines
14251662 key: "openapi",
14261663 kind: "openapi",
14271664 displayName: "OpenAPI",
1428− renderAddPage: () => (
1429− <OpenApiAddSourcePage initialValue={defaultOpenApiInput()} />
1430− ),
1665+ renderAddPage: OpenApiAddSourcePage,
14311666 renderEditPage: OpenApiEditSourcePage,
14321667 renderDetailPage: OpenApiSourceDetailPage,
14331668 });
plugins/openapi/sdk/index.ts+81−16
@@ -102,12 +102,49 @@ const OpenApiExecutorAddInputSchema = Schema.Struct({ · 101 unmodified lines
102102
103103 type OpenApiExecutorAddInput = typeof OpenApiExecutorAddInputSchema.Type;
104104
105+const normalizeOpenApiAuth = (
106+ auth: Record<string, unknown>,
107+): OpenApiStoredSourceData["auth"] =>
108+ auth.kind === "bearer"
109+ ? {
110+ kind: "bearer",
111+ tokenSecretRef: auth.tokenSecretRef as Extract<
112+ OpenApiStoredSourceData["auth"],
113+ { kind: "bearer" }
114+ >["tokenSecretRef"],
115+ headerName:
116+ typeof auth.headerName === "string" || auth.headerName === null
117+ ? auth.headerName
118+ : null,
119+ prefix:
120+ typeof auth.prefix === "string" || auth.prefix === null
121+ ? auth.prefix
122+ : null,
123+ }
124+ : {
125+ kind: "none",
126+ };
127+
128+const normalizeStoredSourceData = (
129+ stored: OpenApiStoredSourceData,
130+): OpenApiStoredSourceData => ({
131+ ...stored,
132+ auth: normalizeOpenApiAuth(stored.auth as Record<string, unknown>),
133+});
134+
105135 const createStoredSourceData = (
106136 input: OpenApiConnectInput,
107137 ): OpenApiStoredSourceData => ({
108138 specUrl: input.specUrl.trim(),
109139 baseUrl: input.baseUrl?.trim() || null,
110− auth: input.auth,
140+ auth:
141+ input.auth.kind === "bearer"
142+ ? {
143+ ...input.auth,
144+ headerName: input.auth.headerName ?? null,
145+ prefix: input.auth.prefix ?? null,
146+ }
147+ : input.auth,
111148 defaultHeaders: null,
112149 etag: null,
113150 lastSyncAt: null,
@@ -284,7 +321,10 @@ const createOpenApiSourceSdk = ( · 170 unmodified lines
284321 );
285322 }
286323
287− return configFromStoredSourceData(source, stored);
324+ return configFromStoredSourceData(
325+ source,
326+ normalizeStoredSourceData(stored),
327+ );
288328 }),
289329 createSource: (input: OpenApiConnectInput) =>
290330 Effect.gen(function* () {
@@ -424,6 +464,17 @@ const resolveBearerToken = ( · 133 unmodified lines
424464 );
425465 };
426466
467+const resolveBearerHeaderName = (
468+ auth: Extract<OpenApiStoredSourceData["auth"], { kind: "bearer" }>,
469+): string => {
470+ const trimmed = auth.headerName?.trim();
471+ return trimmed && trimmed.length > 0 ? trimmed : "Authorization";
472+};
473+
474+const resolveBearerPrefix = (
475+ auth: Extract<OpenApiStoredSourceData["auth"], { kind: "bearer" }>,
476+): string => auth.prefix ?? "Bearer ";
477+
427478 const openApiDocumentHeaders = (input: {
428479 stored: OpenApiStoredSourceData;
429480 bearerToken: string | null;
@@ -434,8 +485,15 @@ const openApiDocumentHeaders = (input: { · 4 unmodified lines
434485 for (const [key, value] of Object.entries(input.stored.defaultHeaders ?? {})) {
435486 headers.set(key, value);
436487 }
437− if (input.bearerToken && input.bearerToken.length > 0) {
438− headers.set("authorization", `Bearer ${input.bearerToken}`);
488+ if (
489+ input.bearerToken &&
490+ input.bearerToken.length > 0 &&
491+ input.stored.auth.kind === "bearer"
492+ ) {
493+ headers.set(
494+ resolveBearerHeaderName(input.stored.auth),
495+ `${resolveBearerPrefix(input.stored.auth)}${input.bearerToken}`,
496+ );
439497 }
440498 if (input.etag) {
441499 headers.set("if-none-match", input.etag);
@@ -542,17 +600,18 @@ const createOpenApiSourceRuntime = ( · 100 unmodified lines
542600 });
543601 }
544602
545− const bearerToken = yield* resolveBearerToken(stored);
603+ const normalizedStored = normalizeStoredSourceData(stored);
604+ const bearerToken = yield* resolveBearerToken(normalizedStored);
546605 const fetched = yield* fetchOpenApiDocument({
547− stored,
606+ stored: normalizedStored,
548607 bearerToken,
549608 });
550609 const manifest = yield* extractOpenApiManifest(source.name, fetched.text, {
551− documentUrl: stored.specUrl,
610+ documentUrl: normalizedStored.specUrl,
552611 loadDocument: async (url) => {
553612 const response = await fetch(url, {
554613 headers: openApiDocumentHeaders({
555− stored,
614+ stored: normalizedStored,
556615 bearerToken,
557616 }),
558617 });
@@ -571,7 +630,7 @@ const createOpenApiSourceRuntime = ( · 12 unmodified lines
571630 scopeId: source.scopeId,
572631 sourceId: source.id,
573632 value: {
574− ...stored,
633+ ...normalizedStored,
575634 etag: fetched.etag,
576635 lastSyncAt: now,
577636 },
@@ -583,7 +642,7 @@ const createOpenApiSourceRuntime = ( · 5 unmodified lines
583642 documents: [
584643 {
585644 documentKind: "openapi",
586− documentKey: stored.specUrl,
645+ documentKey: normalizedStored.specUrl,
587646 contentText: fetched.text,
588647 fetchedAt: now,
589648 },
@@ -596,7 +655,7 @@ const createOpenApiSourceRuntime = ( · 6 unmodified lines
596655 pluginKey: "openapi",
597656 }),
598657 importerVersion: "ir.v1.openapi",
599− sourceConfigHash: stableSourceHash(stored),
658+ sourceConfigHash: stableSourceHash(normalizedStored),
600659 },
601660 sourceHash: manifest.sourceHash,
602661 });
@@ -613,6 +672,7 @@ const createOpenApiSourceRuntime = ( · 10 unmodified lines
613672 );
614673 }
615674
675+ const normalizedStored = normalizeStoredSourceData(stored);
616676 const providerData = decodeProviderData(
617677 input.executable.binding,
618678 ) as OpenApiToolProviderData;
@@ -623,7 +683,7 @@ const createOpenApiSourceRuntime = ( · 4 unmodified lines
623683 providerData.invocation,
624684 );
625685 const headers: Record<string, string> = {
626− ...(stored.defaultHeaders ?? {}),
686+ ...(normalizedStored.defaultHeaders ?? {}),
627687 };
628688 const queryEntries: Array<{
629689 name: string;
@@ -678,14 +738,19 @@ const createOpenApiSourceRuntime = ( · 48 unmodified lines
678738 }
679739 }
680740
681− const bearerToken = yield* resolveBearerToken(stored);
682− if (bearerToken && bearerToken.length > 0) {
683− headers.authorization = `Bearer ${bearerToken}`;
741+ const bearerToken = yield* resolveBearerToken(normalizedStored);
742+ if (
743+ bearerToken &&
744+ bearerToken.length > 0 &&
745+ normalizedStored.auth.kind === "bearer"
746+ ) {
747+ headers[resolveBearerHeaderName(normalizedStored.auth)] =
748+ `${resolveBearerPrefix(normalizedStored.auth)}${bearerToken}`;
684749 }
685750
686751 const requestUrl = resolveRequestUrl(
687752 resolveOpenApiBaseUrl({
688− stored,
753+ stored: normalizedStored,
689754 providerData,
690755 }),
691756 resolvedPath,
plugins/openapi/shared/index.ts+2−0
@@ -14,6 +14,8 @@ export const OpenApiConnectionAuthSchema = Schema.Union( · 13 unmodified lines
1414 Schema.Struct({
1515 kind: Schema.Literal("bearer"),
1616 tokenSecretRef: SecretRefSchema,
17+ headerName: Schema.NullOr(Schema.String),
18+ prefix: Schema.NullOr(Schema.String),
1719 }),
1820 );
1921

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t