History

fix ui and stuff

Rhys Sullivan committed 7 months ago

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

Unified · Split

30 files
  1. .executor/executor.jsonc +22 −7
  2. .mcp.json +13 −0
  3. apps/executor/src/cli/main.ts +11 −10
  4. apps/executor/src/cli/runtime-paths.test.ts +47 −0
  5. apps/executor/src/cli/runtime-paths.ts +16 −1
  6. apps/web/src/components/local-mcp-install-card.tsx +21 −4
  7. apps/web/src/components/shell.tsx +1 −1
  8. apps/web/src/components/source-plugins-reset-state.tsx +6 −9
  9. apps/web/src/main.tsx +4 −0
  10. apps/web/src/plugins/pages.tsx +220 −73
  11. apps/web/src/plugins/source-presets.ts +657 −0
  12. apps/web/src/views/home.tsx +93 −87
  13. apps/web/src/views/secrets.tsx +39 −15
  14. opencode.json +11 −5
  15. packages/platform/sdk/src/runtime/execution/scope/environment.ts +27 −0
  16. packages/platform/sdk/src/runtime/execution/scope/tool-invoker.ts +29 −1
  17. plugins/google-discovery/http/index.ts +42 −6
  18. plugins/google-discovery/react/index.tsx +855 −165
  19. plugins/google-discovery/sdk/index.ts +230 −27
  20. plugins/google-discovery/shared/index.ts +61 −1
  21. plugins/graphql/react/index.tsx +353 −115
  22. plugins/graphql/sdk/index.ts +65 −10
  23. plugins/graphql/shared/index.ts +2 −0
  24. plugins/mcp/http/index.ts +38 −6
  25. plugins/mcp/react/index.tsx +604 −86
  26. plugins/mcp/sdk/index.ts +22 −0
  27. plugins/mcp/shared/index.ts +11 −0
  28. plugins/openapi/react/index.tsx +387 −152
  29. plugins/openapi/sdk/index.ts +81 −16
  30. plugins/openapi/shared/index.ts +2 −0
.executor/executor.jsonc+22−7
@@ -1,14 +1,29 @@
11 {
22 "sources": {
3− "vercel-api": {
4− "name": "Vercel API",
5− "kind": "openapi"
6− },
7− "axiom": {
3+ "axiom-mcp": {
4+ "name": "Axiom MCP",
5+ "namespace": "axiom.mcp",
86 "kind": "mcp"
97 },
10− "linear": {
11− "kind": "graphql"
8+ "google-calendar": {
9+ "name": "Google Calendar",
10+ "namespace": "google.calendar",
11+ "kind": "google_discovery"
12+ },
13+ "google-drive": {
14+ "name": "Google Drive",
15+ "namespace": "google.drive",
16+ "kind": "google_discovery"
17+ },
18+ "google-gmail": {
19+ "name": "Gmail",
20+ "namespace": "google.gmail",
21+ "kind": "google_discovery"
22+ },
23+ "chrome-devtools-mcp": {
24+ "kind": "mcp",
25+ "name": "Chrome DevTools MCP",
26+ "namespace": "chrome.devtools.mcp"
1227 }
1328 }
1429 }
.mcp.json+13−0
@@ -0,0 +1,13 @@
1+{
2+ "mcpServers": {
3+ "executor-stdio": {
4+ "command": "bun",
5+ "args": [
6+ "run",
7+ "executor",
8+ "mcp",
9+ "--stdio"
10+ ]
11+ }
12+ }
13+}
apps/executor/src/cli/main.ts+11−10
@@ -57,7 +57,6 @@ import { · 56 unmodified lines
5757 createLocalExecutorServer,
5858 DEFAULT_SERVER_BASE_URL,
5959 DEFAULT_SERVER_HOST,
60− DEFAULT_LOCAL_DATA_DIR,
6160 DEFAULT_SERVER_LOG_FILE,
6261 DEFAULT_SERVER_PID_FILE,
6362 DEFAULT_SERVER_PORT,
@@ -66,6 +65,7 @@ import { · 2 unmodified lines
6665 runLocalExecutorServer,
6766 } from "@executor/server";
6867 import {
68+ resolveCliLocalDataDir,
6969 resolveRuntimeWebAssetsDir,
7070 resolveSelfCommand,
7171 } from "./runtime-paths";
@@ -178,6 +178,7 @@ const getBootstrapClient = (baseUrl: string = DEFAULT_SERVER_BASE_URL) => · 106 unmodified lines
178178 const decodeExecutionId = Schema.decodeUnknown(ExecutionIdSchema);
179179 const require = createRequire(import.meta.url);
180180 const CLI_NAME = "executor";
181+const CLI_LOCAL_DATA_DIR = resolveCliLocalDataDir();
181182 const CLI_VERSION = (() => {
182183 const candidatePaths = [
183184 "../package.json",
@@ -299,33 +300,33 @@ const loadRunWorkflowText = (): Effect.Effect<string, Error, never> => · 115 unmodified lines
299300 Effect.acquireUseRelease(
300301 createExecutorEffect({
301302 backend: createLocalExecutorBackend({
302− localDataDir: DEFAULT_LOCAL_DATA_DIR,
303+ localDataDir: CLI_LOCAL_DATA_DIR,
303304 }),
304305 plugins: [
305306 graphqlSdkPlugin({
306307 storage: createFileGraphqlSourceStorage({
307− rootDir: `${DEFAULT_LOCAL_DATA_DIR}/plugins/graphql/sources`,
308+ rootDir: `${CLI_LOCAL_DATA_DIR}/plugins/graphql/sources`,
308309 }),
309310 }),
310311 googleDiscoverySdkPlugin({
311312 storage: createFileGoogleDiscoverySourceStorage({
312− rootDir: `${DEFAULT_LOCAL_DATA_DIR}/plugins/google-discovery/sources`,
313+ rootDir: `${CLI_LOCAL_DATA_DIR}/plugins/google-discovery/sources`,
313314 }),
314315 oauthSessions: createFileGoogleDiscoveryOAuthSessionStorage({
315− rootDir: `${DEFAULT_LOCAL_DATA_DIR}/plugins/google-discovery/oauth-sessions`,
316+ rootDir: `${CLI_LOCAL_DATA_DIR}/plugins/google-discovery/oauth-sessions`,
316317 }),
317318 }),
318319 mcpSdkPlugin({
319320 storage: createFileMcpSourceStorage({
320− rootDir: `${DEFAULT_LOCAL_DATA_DIR}/plugins/mcp/sources`,
321+ rootDir: `${CLI_LOCAL_DATA_DIR}/plugins/mcp/sources`,
321322 }),
322323 oauthSessions: createFileMcpOAuthSessionStorage({
323− rootDir: `${DEFAULT_LOCAL_DATA_DIR}/plugins/mcp/oauth-sessions`,
324+ rootDir: `${CLI_LOCAL_DATA_DIR}/plugins/mcp/oauth-sessions`,
324325 }),
325326 }),
326327 openApiSdkPlugin({
327328 storage: createFileOpenApiSourceStorage({
328− rootDir: `${DEFAULT_LOCAL_DATA_DIR}/plugins/openapi/sources`,
329+ rootDir: `${CLI_LOCAL_DATA_DIR}/plugins/openapi/sources`,
329330 }),
330331 }),
331332 ] as const,
@@ -476,7 +477,7 @@ const getDefaultServerOptions = (port: number = DEFAULT_SERVER_PORT) => { · 144 unmodified lines
476477 return {
477478 host: DEFAULT_SERVER_HOST,
478479 port,
479− localDataDir: DEFAULT_LOCAL_DATA_DIR,
480+ localDataDir: CLI_LOCAL_DATA_DIR,
480481 pidFile: DEFAULT_SERVER_PID_FILE,
481482 ui: assetsDir ? { assetsDir } : undefined,
482483 };
@@ -638,7 +639,7 @@ const getServerStatus = ( · 155 unmodified lines
638639 pid,
639640 pidRunning,
640641 logFile,
641− localDataDir: DEFAULT_LOCAL_DATA_DIR,
642+ localDataDir: CLI_LOCAL_DATA_DIR,
642643 webAssetsDir: resolveRuntimeWebAssetsDir(),
643644 installation,
644645 } satisfies LocalServerStatus;
apps/executor/src/cli/runtime-paths.test.ts+47−0
@@ -0,0 +1,47 @@
1+import { join } from "node:path";
2+import { describe, expect, it } from "@effect/vitest";
3+
4+import {
5+ DEFAULT_EXECUTOR_DATA_DIR,
6+ DEFAULT_LOCAL_DATA_DIR,
7+} from "@executor/server";
8+
9+import {
10+ resolveCliLocalDataDir,
11+} from "./runtime-paths";
12+
13+describe("runtime-paths", () => {
14+ it("uses the web dev local data dir for source-launched CLI runs", () => {
15+ const originalArgv1 = process.argv[1];
16+
17+ try {
18+ process.argv[1] = "/tmp/executor.ts";
19+
20+ expect(resolveCliLocalDataDir()).toBe(
21+ join(DEFAULT_EXECUTOR_DATA_DIR, "control-plane-web-dev"),
22+ );
23+ } finally {
24+ if (originalArgv1 === undefined) {
25+ delete process.argv[1];
26+ } else {
27+ process.argv[1] = originalArgv1;
28+ }
29+ }
30+ });
31+
32+ it("keeps the default local data dir for bundled launches", () => {
33+ const originalArgv1 = process.argv[1];
34+
35+ try {
36+ process.argv[1] = "/tmp/executor";
37+
38+ expect(resolveCliLocalDataDir()).toBe(DEFAULT_LOCAL_DATA_DIR);
39+ } finally {
40+ if (originalArgv1 === undefined) {
41+ delete process.argv[1];
42+ } else {
43+ process.argv[1] = originalArgv1;
44+ }
45+ }
46+ });
47+});
apps/executor/src/cli/runtime-paths.ts+16−1
@@ -1,8 +1,11 @@
11 import { existsSync } from "node:fs";
2−import { dirname, extname, resolve } from "node:path";
2+import { dirname, extname, join, resolve } from "node:path";
33 import { fileURLToPath } from "node:url";
44
55 import {
6+ DEFAULT_EXECUTOR_DATA_DIR,
7+ DEFAULT_LOCAL_DATA_DIR,
8+ EXECUTOR_LOCAL_DATA_DIR_ENV,
69 EXECUTOR_WEB_ASSETS_DIR_ENV,
710 } from "@executor/server";
811
@@ -36,6 +39,8 @@ const getSourceEntrypoint = (): string | null => { · 27 unmodified lines
3639 : null;
3740 };
3841
42+const isSourceCliLaunch = (): boolean => getSourceEntrypoint() !== null;
43+
3944 const resolveBundledNodeLauncher = (): string | null => {
4045 const candidate = resolve(sourceDir, "executor.js");
4146 return existsSync(candidate) ? candidate : null;
@@ -76,6 +81,16 @@ export const resolveSelfCommand = (args: readonly string[]): readonly string[] = · 34 unmodified lines
7681 : [process.execPath, sourceEntrypoint, ...args];
7782 };
7883
84+export const resolveCliLocalDataDir = (): string => {
85+ if (trim(process.env[EXECUTOR_LOCAL_DATA_DIR_ENV])) {
86+ return DEFAULT_LOCAL_DATA_DIR;
87+ }
88+
89+ return isSourceCliLaunch()
90+ ? join(DEFAULT_EXECUTOR_DATA_DIR, "control-plane-web-dev")
91+ : DEFAULT_LOCAL_DATA_DIR;
92+};
93+
7994 export const resolveRuntimeWebAssetsDir = (): string | null => {
8095 const explicit = resolveIfExists(process.env[EXECUTOR_WEB_ASSETS_DIR_ENV]);
8196 if (explicit) {
apps/web/src/components/local-mcp-install-card.tsx+21−4
@@ -4,6 +4,8 @@ import { cn } from "../lib/utils"; · 3 unmodified lines
44
55 type LocalMcpInstallMode = "stdio" | "http";
66
7+const isDevBuild = import.meta.env.DEV;
8+
79 export function LocalMcpInstallCard(props: {
810 title?: string;
911 description?: string;
@@ -17,7 +19,9 @@ export function LocalMcpInstallCard(props: { · 7 unmodified lines
1719 }, []);
1820
1921 const command = mode === "stdio"
20− ? 'npx add-mcp "executor mcp --stdio" --name "executor-stdio"'
22+ ? isDevBuild
23+ ? 'npx add-mcp "bun run executor mcp --stdio" --name "executor-stdio"'
24+ : 'npx add-mcp "executor mcp --stdio" --name "executor-stdio"'
2125 : origin
2226 ? `npx add-mcp "${origin}/mcp" --transport http --name "executor"`
2327 : 'npx add-mcp "<this-server>/mcp" --transport http --name "executor"';
@@ -57,9 +61,22 @@ export function LocalMcpInstallCard(props: { · 33 unmodified lines
5761 </div>
5862 <CodeBlock code={command} lang="bash" className="rounded-xl border border-border bg-background/70" />
5963 {mode === "stdio" ? (
60− <p className="mt-3 text-[12px] text-muted-foreground">
61− Requires the `executor` CLI to be available on your PATH. Uses a distinct MCP name to avoid colliding with an existing remote `executor` entry.
62− </p>
64+ <div className="mt-3 space-y-1 text-[12px] text-muted-foreground">
65+ {!isDevBuild ? (
66+ <p>
67+ Requires the `executor` CLI on your PATH. Uses a distinct MCP name to avoid colliding with an existing remote `executor` entry.
68+ </p>
69+ ) : (
70+ <>
71+ <p>
72+ Uses the repo-local dev CLI: <code>bun run executor mcp --stdio</code>.
73+ </p>
74+ <p>
75+ Run the `add-mcp` command from the repository root, or set your MCP client working directory to this repo before using the saved entry.
76+ </p>
77+ </>
78+ )}
79+ </div>
6380 ) : null}
6481 </section>
6582 );
apps/web/src/components/shell.tsx+1−1
@@ -305,7 +305,7 @@ export function AppShell() { · 304 unmodified lines
305305 </aside>
306306
307307 {/* Main content */}
308− <main className="flex flex-1 flex-col min-w-0 overflow-hidden">
308+ <main className="flex min-h-0 flex-1 flex-col min-w-0 overflow-hidden">
309309 <Outlet />
310310 </main>
311311 </div>
apps/web/src/components/source-plugins-reset-state.tsx+6−9
@@ -10,19 +10,16 @@ export function SourcePluginsResetState( · 9 unmodified lines
1010 input: SourcePluginsResetStateProps,
1111 ) {
1212 return (
13− <div className="flex-1 overflow-y-auto">
13+ <div className="min-h-0 flex-1 overflow-y-auto">
1414 <div className="mx-auto max-w-3xl px-6 py-10 lg:px-10 lg:py-14">
15− <div className="rounded-3xl border border-border bg-card p-8 shadow-[0_20px_60px_-40px_rgba(15,23,42,0.35)]">
16− <div className="inline-flex rounded-full border border-border bg-muted px-3 py-1 text-[11px] font-medium uppercase tracking-[0.2em] text-muted-foreground">
17− Clean Slate
18− </div>
19− <h1 className="mt-5 font-display text-3xl tracking-tight text-foreground">
15+ <div className="rounded-2xl border border-border bg-card p-8">
16+ <h2 className="font-display text-xl tracking-tight text-foreground">
2017 {input.title}
21− </h1>
22− <p className="mt-3 max-w-2xl text-sm leading-6 text-muted-foreground">
18+ </h2>
19+ <p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
2320 {input.message}
2421 </p>
25− {input.action ? <div className="mt-6">{input.action}</div> : null}
22+ {input.action ? <div className="mt-5">{input.action}</div> : null}
2623 </div>
2724 </div>
2825 </div>
apps/web/src/main.tsx+4−0
@@ -144,15 +144,19 @@ const sourcePluginRoutes = registeredSourceFrontendTypes.flatMap((definition) => · 143 unmodified lines
144144 const paths = createSourcePluginPaths(definition.key);
145145
146146 const AddRouteComponent = () => {
147+ const search = addRoute.useSearch();
147148 const navigate = useNavigate();
149+ const navigateFromRoute = useNavigate({ from: addRoute.fullPath });
148150 const navigation = createSourcePluginNavigation(definition, {
149151 navigateTo: (to, search) =>
150152 search === undefined ? navigate({ to }) : navigate({ to, search }),
153+ updateSearch: (nextSearch) => navigateFromRoute({ search: nextSearch }),
151154 });
152155
153156 return (
154157 <SourcePluginAddPage
155158 definitionKey={definition.key}
159+ search={search}
156160 navigation={navigation}
157161 />
158162 );
apps/web/src/plugins/pages.tsx+220−73
@@ -1,4 +1,5 @@
1−import { Link } from "@tanstack/react-router";
1+import { useState, type FormEvent } from "react";
2+import { Link, useNavigate } from "@tanstack/react-router";
23 import { useSource } from "@executor/react";
34 import {
45 ExecutorPluginRouteProvider,
@@ -15,6 +16,8 @@ import { · 10 unmodified lines
1516 type SourcePluginRouteSearch,
1617 } from "@executor/react/plugins";
1718
19+import { DefaultSourceIcon } from "../components/source-favicon";
20+import { LocalMcpInstallCard } from "../components/local-mcp-install-card";
1821 import { SourcePluginsResetState } from "../components/source-plugins-reset-state";
1922 import {
2023 getFrontendPlugin,
@@ -23,18 +26,23 @@ import { · 2 unmodified lines
2326 getSourceFrontendTypeEntryByKey,
2427 registeredSourceFrontendTypes,
2528 } from "./index";
29+import {
30+ buildSourcePresetSearch,
31+ resolveQuickSourceInput,
32+ sourcePresets,
33+} from "./source-presets";
2634
2735 const FrontendPluginUnavailableState = () => (
2836 <SourcePluginsResetState
29− title="Frontend plugins are unavailable"
30− message="No frontend plugins are registered in this build."
37+ title="Plugins unavailable"
38+ message="No plugins are available in this build."
3139 />
3240 );
3341
3442 const SourcePluginUnavailableState = () => (
3543 <SourcePluginsResetState
36− title="Source plugins are unavailable"
37− message="No source plugins are registered in this build."
44+ title="No source types available"
45+ message="No source types are available in this build."
3846 />
3947 );
4048
@@ -69,11 +77,69 @@ const SourcePluginRouteMismatchState = (props: { · 28 unmodified lines
6977 actualDisplayName: string;
7078 }) => (
7179 <SourcePluginsResetState
72− title="Source route mismatch"
73− message={`This source belongs to ${props.actualDisplayName}, but the current route is mounted for ${props.requestedDisplayName}. Reopen it from the source list to use the canonical plugin route.`}
80+ title="Wrong source type"
81+ message={`This source uses ${props.actualDisplayName}, but you navigated to ${props.requestedDisplayName}. Open it from the sidebar instead.`}
7482 />
7583 );
7684
85+const SourceQuickEntry = () => {
86+ const navigate = useNavigate();
87+ const [value, setValue] = useState("");
88+ const [error, setError] = useState<string | null>(null);
89+
90+ const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
91+ event.preventDefault();
92+
93+ const resolved = resolveQuickSourceInput(value);
94+ if (resolved === null) {
95+ setError(
96+ "Could not recognize that input. Try a GraphQL endpoint, MCP URL, Google Discovery URL, OpenAPI spec URL, or a local MCP command.",
97+ );
98+ return;
99+ }
100+
101+ setError(null);
102+ void navigate({
103+ to: createSourcePluginPaths(resolved.pluginKey).add,
104+ search: resolved.search,
105+ });
106+ };
107+
108+ return (
109+ <div className="rounded-2xl border border-border bg-card p-6">
110+ <h2 className="font-display text-lg font-semibold tracking-tight text-foreground">
111+ Quick add
112+ </h2>
113+ <p className="mt-1 text-sm leading-6 text-muted-foreground">
114+ Paste a URL or command and we'll detect the right plugin automatically.
115+ </p>
116+
117+ <form onSubmit={handleSubmit} className="mt-4">
118+ <div className="flex flex-col gap-3 sm:flex-row">
119+ <input
120+ value={value}
121+ onChange={(event) => setValue(event.target.value)}
122+ placeholder="https://api.github.com/graphql or npx -y chrome-devtools-mcp@latest"
123+ className="h-10 flex-1 rounded-lg border border-input bg-background px-3.5 text-sm text-foreground outline-none transition-colors placeholder:text-muted-foreground/45 focus:border-ring focus:ring-1 focus:ring-ring/25"
124+ />
125+ <button
126+ type="submit"
127+ className="inline-flex h-10 items-center justify-center rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90"
128+ >
129+ Add source
130+ </button>
131+ </div>
132+
133+ {error && (
134+ <div className="mt-3 rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-2.5 text-sm text-destructive">
135+ {error}
136+ </div>
137+ )}
138+ </form>
139+ </div>
140+ );
141+};
142+
77143 export function ExecutorPluginRoutePage(input: {
78144 pluginKey: string;
79145 routeKey: FrontendPluginRouteDefinition["key"];
@@ -90,8 +156,8 @@ export function ExecutorPluginRoutePage(input: { · 10 unmodified lines
90156 if (routeEntry === null) {
91157 return (
92158 <SourcePluginsResetState
93− title="Plugin route is unavailable"
94− message={`No plugin route is registered for "${input.routeKey}" in "${input.pluginKey}".`}
159+ title="Route unavailable"
160+ message={`The route "${input.routeKey}" is not available for this plugin.`}
95161 />
96162 );
97163 }
@@ -120,46 +186,126 @@ export function SourcePluginsIndexPage() { · 22 unmodified lines
120186 return <SourcePluginUnavailableState />;
121187 }
122188
189+ const hasMcpPlugin = registeredSourceFrontendTypes.some(
190+ (definition) => definition.key === "mcp",
191+ );
192+
193+ const presetsByPlugin = registeredSourceFrontendTypes
194+ .map((definition) => ({
195+ definition,
196+ presets: sourcePresets.filter((preset) => preset.pluginKey === definition.key),
197+ }))
198+ .filter((entry) => entry.presets.length > 0);
199+
123200 return (
124− <div className="mx-auto max-w-5xl px-6 py-10 lg:px-10 lg:py-14">
125− <div className="rounded-3xl border border-border bg-card p-8">
126− <div className="inline-flex rounded-full border border-border bg-muted px-3 py-1 text-[11px] font-medium uppercase tracking-[0.2em] text-muted-foreground">
127− Source Plugins
201+ <div className="min-h-0 flex-1 overflow-y-auto">
202+ <div className="mx-auto max-w-5xl px-6 py-10 lg:px-10 lg:py-14">
203+ {/* Header */}
204+ <div className="mb-8">
205+ <h1 className="font-display text-3xl tracking-tight text-foreground lg:text-4xl">
206+ Add source
207+ </h1>
208+ <p className="mt-1.5 text-[14px] text-muted-foreground">
209+ Connect a new tool provider to this workspace.
210+ </p>
128211 </div>
129− <h1 className="mt-5 font-display text-3xl tracking-tight text-foreground lg:text-4xl">
130− Choose a source plugin
131− </h1>
132− <p className="mt-3 max-w-3xl text-sm leading-6 text-muted-foreground">
133− Each plugin owns its own connection flow, detail routes, and UI.
134− Pick the plugin that should own this source.
135− </p>
136− </div>
137212
138− <div className="mt-8 grid gap-4 md:grid-cols-2">
139− {registeredSourceFrontendTypes.map((definition) => {
140− const paths = createSourcePluginPaths(definition.key);
213+ <SourceQuickEntry />
214+
215+ {hasMcpPlugin && (
216+ <LocalMcpInstallCard
217+ className="mt-8"
218+ title="Install this executor as MCP"
219+ description="Prefer a one-command setup? Install this local executor server into your MCP client, or add an external MCP source below."
220+ />
221+ )}
222+
223+ {/* Presets */}
224+ {presetsByPlugin.length > 0 && (
225+ <div className="mt-10">
226+ <h2 className="text-lg font-semibold text-foreground">Popular sources</h2>
227+ <p className="mt-1 text-sm leading-6 text-muted-foreground">
228+ One-click presets for common APIs and tools.
229+ </p>
141230
142− return (
143− <Link
144− key={definition.key}
145− to={paths.add}
146− className="group rounded-3xl border border-border bg-card p-6 transition-colors hover:border-primary/30 hover:bg-card/90"
147− >
148− <div className="flex h-full flex-col">
149− <div className="text-lg font-semibold text-foreground">
150− {definition.displayName}
151− </div>
152− <div className="mt-2 flex-1 text-sm leading-6 text-muted-foreground">
153− {definition.description
154− ?? "Open the plugin-owned source flow."}
155− </div>
156− <div className="mt-6 text-xs font-medium uppercase tracking-[0.2em] text-primary">
157− Open Plugin
158− </div>
159− </div>
160− </Link>
161− );
162− })}
231+ <div className="mt-5 space-y-8">
232+ {presetsByPlugin.map(({ definition, presets }) => {
233+ const paths = createSourcePluginPaths(definition.key);
234+
235+ return (
236+ <section key={definition.key}>
237+ <h3 className="mb-3 text-sm font-semibold text-foreground">
238+ {definition.displayName}
239+ </h3>
240+
241+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
242+ {presets.map((preset) => (
243+ <Link
244+ key={preset.id}
245+ to={paths.add}
246+ search={buildSourcePresetSearch(preset)}
247+ className="rounded-xl border border-border bg-card px-4 py-3 text-left transition-colors hover:border-primary/25 hover:bg-card/90"
248+ >
249+ <div className="mb-2 flex items-center gap-2.5">
250+ <div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
251+ <DefaultSourceIcon kind={preset.kind} className="size-4" />
252+ </div>
253+ <div className="min-w-0">
254+ <div className="truncate text-sm font-semibold text-foreground">
255+ {preset.name}
256+ </div>
257+ <div className="mt-0.5 text-[11px] text-muted-foreground">
258+ {preset.kind}
259+ </div>
260+ </div>
261+ </div>
262+ <div className="text-xs leading-5 text-muted-foreground">
263+ {preset.summary}
264+ </div>
265+ </Link>
266+ ))}
267+ </div>
268+ </section>
269+ );
270+ })}
271+ </div>
272+ </div>
273+ )}
274+
275+ {/* Browse by plugin type */}
276+ <div className="mt-10">
277+ <h2 className="text-lg font-semibold text-foreground">By type</h2>
278+ <p className="mt-1 text-sm leading-6 text-muted-foreground">
279+ Start from scratch with a specific plugin.
280+ </p>
281+
282+ <div className="mt-5 grid gap-3 md:grid-cols-2">
283+ {registeredSourceFrontendTypes.map((definition) => {
284+ const paths = createSourcePluginPaths(definition.key);
285+
286+ return (
287+ <Link
288+ key={definition.key}
289+ to={paths.add}
290+ className="group rounded-xl border border-border bg-card p-5 transition-colors hover:border-primary/25 hover:bg-card/90"
291+ >
292+ <div className="flex h-full flex-col">
293+ <div className="text-sm font-semibold text-foreground">
294+ {definition.displayName}
295+ </div>
296+ <div className="mt-1.5 flex-1 text-sm leading-6 text-muted-foreground">
297+ {definition.description
298+ ?? "Configure a new source from scratch."}
299+ </div>
300+ <div className="mt-4 text-xs font-medium text-primary">
301+ Get started &rarr;
302+ </div>
303+ </div>
304+ </Link>
305+ );
306+ })}
307+ </div>
308+ </div>
163309 </div>
164310 </div>
165311 );
@@ -167,6 +313,7 @@ export function SourcePluginsIndexPage() { · 1 unmodified line
167313
168314 export function SourcePluginAddPage(props: {
169315 definitionKey: string;
316+ search?: SourcePluginRouteSearch;
170317 navigation: SourcePluginNavigation;
171318 }) {
172319 const entry = getSourceFrontendTypeEntryByKey(props.definitionKey);
@@ -175,6 +322,7 @@ export function SourcePluginAddPage(props: { · 2 unmodified lines
175322 }
176323
177324 const AddPage = entry.definition.renderAddPage;
325+ const search = props.search ?? {};
178326
179327 return (
180328 <SourcePluginRouteProvider
@@ -182,27 +330,26 @@ export function SourcePluginAddPage(props: { · 1 unmodified line
182330 plugin: entry.plugin,
183331 definition: entry.definition,
184332 params: {},
185− search: {},
333+ search,
186334 navigation: props.navigation,
187335 }}
188336 >
189− <div className="mx-auto max-w-4xl px-6 py-10 lg:px-10 lg:py-14">
190− <div className="mb-8">
191− <div className="inline-flex rounded-full border border-border bg-muted px-3 py-1 text-[11px] font-medium uppercase tracking-[0.2em] text-muted-foreground">
192− Source Plugin
337+ <div className="min-h-0 flex-1 overflow-y-auto">
338+ <div className="mx-auto max-w-4xl px-6 py-10 lg:px-10 lg:py-14">
339+ <div className="mb-8">
340+ <h1 className="font-display text-3xl tracking-tight text-foreground lg:text-4xl">
341+ {entry.definition.displayName}
342+ </h1>
343+ {entry.definition.description && (
344+ <p className="mt-1.5 text-sm leading-6 text-muted-foreground">
345+ {entry.definition.description}
346+ </p>
347+ )}
193348 </div>
194− <h1 className="mt-5 font-display text-3xl tracking-tight text-foreground lg:text-4xl">
195− {entry.definition.displayName}
196− </h1>
197− {entry.definition.description && (
198− <p className="mt-3 max-w-2xl text-sm leading-6 text-muted-foreground">
199− {entry.definition.description}
200− </p>
201− )}
202− </div>
203349
204− <SourcePluginPicker activeKey={entry.definition.key} />
205− <AddPage />
350+ <SourcePluginPicker activeKey={entry.definition.key} />
351+ <AddPage key={JSON.stringify(search)} />
352+ </div>
206353 </div>
207354 </SourcePluginRouteProvider>
208355 );
@@ -229,8 +376,8 @@ export function SourcePluginEditPage(input: { · 20 unmodified lines
229376 if (entry === null) {
230377 return (
231378 <SourcePluginsResetState
232− title="Source plugin is unavailable"
233− message={`No frontend source plugin is registered for kind "${loadedSource.kind}".`}
379+ title="Source type unavailable"
380+ message={`No plugin is available for source type "${loadedSource.kind}".`}
234381 />
235382 );
236383 }
@@ -247,8 +394,8 @@ export function SourcePluginEditPage(input: { · 10 unmodified lines
247394 if (entry.definition.renderEditPage === undefined) {
248395 return (
249396 <SourcePluginsResetState
250− title="Source editing is disabled"
251− message={`No frontend source plugin is registered for kind "${loadedSource.kind}".`}
397+ title="Editing unavailable"
398+ message={`No plugin is available for source type "${loadedSource.kind}".`}
252399 />
253400 );
254401 }
@@ -298,8 +445,8 @@ export function SourcePluginDetailPage(input: { · 43 unmodified lines
298445 if (entry === null) {
299446 return (
300447 <SourcePluginsResetState
301− title="Source plugin is unavailable"
302− message={`No frontend source plugin is registered for kind "${loadedSource.kind}".`}
448+ title="Source type unavailable"
449+ message={`No plugin is available for source type "${loadedSource.kind}".`}
303450 />
304451 );
305452 }
@@ -316,8 +463,8 @@ export function SourcePluginDetailPage(input: { · 10 unmodified lines
316463 if (entry.definition.renderDetailPage === undefined) {
317464 return (
318465 <SourcePluginsResetState
319− title="Source detail is disabled"
320− message={`No frontend source plugin is registered for kind "${loadedSource.kind}".`}
466+ title="Details unavailable"
467+ message={`No plugin is available for source type "${loadedSource.kind}".`}
321468 />
322469 );
323470 }
@@ -368,8 +515,8 @@ export function SourcePluginDetailChildPage(input: { · 44 unmodified lines
368515 if (entry === null) {
369516 return (
370517 <SourcePluginsResetState
371− title="Source plugin is unavailable"
372− message={`No frontend source plugin is registered for kind "${loadedSource.kind}".`}
518+ title="Source type unavailable"
519+ message={`No plugin is available for source type "${loadedSource.kind}".`}
373520 />
374521 );
375522 }
@@ -390,8 +537,8 @@ export function SourcePluginDetailChildPage(input: { · 14 unmodified lines
390537 if (!detailRoute) {
391538 return (
392539 <SourcePluginsResetState
393− title="Source route is unavailable"
394− message={`No plugin-owned detail route is registered for "${input.routeKey}".`}
540+ title="Route unavailable"
541+ message={`The route "${input.routeKey}" is not available for this source type.`}
395542 />
396543 );
397544 }
apps/web/src/plugins/source-presets.ts+657−0
@@ -0,0 +1,657 @@
1+const GOOGLE_DISCOVERY_PLUGIN_KEY = "google-discovery" as const;
2+const GOOGLE_DISCOVERY_SOURCE_KIND = "google_discovery" as const;
3+
4+export type SourcePreset =
5+ | {
6+ id: string;
7+ pluginKey: "mcp";
8+ kind: "mcp";
9+ name: string;
10+ summary: string;
11+ previewUrl: string;
12+ endpoint?: string;
13+ transport?: "auto" | "streamable-http" | "sse" | "stdio";
14+ command?: string;
15+ args?: string[];
16+ env?: Record<string, string>;
17+ cwd?: string;
18+ }
19+ | {
20+ id: string;
21+ pluginKey: "openapi";
22+ kind: "openapi";
23+ name: string;
24+ summary: string;
25+ previewUrl: string;
26+ baseUrl: string;
27+ specUrl: string;
28+ }
29+ | {
30+ id: string;
31+ pluginKey: "graphql";
32+ kind: "graphql";
33+ name: string;
34+ summary: string;
35+ previewUrl: string;
36+ endpoint: string;
37+ }
38+ | {
39+ id: string;
40+ pluginKey: typeof GOOGLE_DISCOVERY_PLUGIN_KEY;
41+ kind: typeof GOOGLE_DISCOVERY_SOURCE_KIND;
42+ name: string;
43+ summary: string;
44+ previewUrl: string;
45+ service: string;
46+ version: string;
47+ discoveryUrl: string;
48+ };
49+
50+const googleDiscoveryPreset = (input: {
51+ id: string;
52+ name: string;
53+ summary: string;
54+ service: string;
55+ version: string;
56+ discoveryUrl: string;
57+}): SourcePreset => ({
58+ id: input.id,
59+ pluginKey: GOOGLE_DISCOVERY_PLUGIN_KEY,
60+ kind: GOOGLE_DISCOVERY_SOURCE_KIND,
61+ name: input.name,
62+ summary: input.summary,
63+ previewUrl: input.discoveryUrl,
64+ service: input.service,
65+ version: input.version,
66+ discoveryUrl: input.discoveryUrl,
67+});
68+
69+export const sourcePresets: ReadonlyArray<SourcePreset> = [
70+ {
71+ id: "deepwiki-mcp",
72+ pluginKey: "mcp",
73+ kind: "mcp",
74+ name: "DeepWiki MCP",
75+ summary: "Repository docs and knowledge graphs via MCP.",
76+ previewUrl: "https://mcp.deepwiki.com/mcp",
77+ endpoint: "https://mcp.deepwiki.com/mcp",
78+ transport: "auto",
79+ },
80+ {
81+ id: "axiom-mcp",
82+ pluginKey: "mcp",
83+ kind: "mcp",
84+ name: "Axiom MCP",
85+ summary: "Query, stream, and analyze logs, traces, and event data.",
86+ previewUrl: "https://mcp.axiom.co/mcp",
87+ endpoint: "https://mcp.axiom.co/mcp",
88+ transport: "auto",
89+ },
90+ {
91+ id: "neon-mcp",
92+ pluginKey: "mcp",
93+ kind: "mcp",
94+ name: "Neon MCP",
95+ summary: "Manage Postgres databases, branches, and queries via MCP.",
96+ previewUrl: "https://mcp.neon.tech/mcp",
97+ endpoint: "https://mcp.neon.tech/mcp",
98+ transport: "auto",
99+ },
100+ {
101+ id: "chrome-devtools-mcp",
102+ pluginKey: "mcp",
103+ kind: "mcp",
104+ name: "Chrome DevTools MCP",
105+ summary: "Debug a live Chrome browser session over a local MCP stdio transport.",
106+ previewUrl: "npx -y chrome-devtools-mcp@latest",
107+ transport: "stdio",
108+ command: "npx",
109+ args: ["-y", "chrome-devtools-mcp@latest"],
110+ },
111+ {
112+ id: "neon-api",
113+ pluginKey: "openapi",
114+ kind: "openapi",
115+ name: "Neon API",
116+ summary: "Projects, branches, endpoints, databases, and API keys.",
117+ previewUrl: "https://console.neon.tech/api/v2",
118+ baseUrl: "https://console.neon.tech/api/v2",
119+ specUrl: "https://neon.com/api_spec/release/v2.json",
120+ },
121+ {
122+ id: "github-rest",
123+ pluginKey: "openapi",
124+ kind: "openapi",
125+ name: "GitHub REST API",
126+ summary: "Repos, issues, pull requests, actions, and org settings.",
127+ previewUrl: "https://api.github.com",
128+ baseUrl: "https://api.github.com",
129+ specUrl: "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.yaml",
130+ },
131+ {
132+ id: "github-graphql",
133+ pluginKey: "graphql",
134+ kind: "graphql",
135+ name: "GitHub GraphQL",
136+ summary: "Issues, pull requests, discussions, and repository objects via GraphQL.",
137+ previewUrl: "https://api.github.com/graphql",
138+ endpoint: "https://api.github.com/graphql",
139+ },
140+ {
141+ id: "gitlab-graphql",
142+ pluginKey: "graphql",
143+ kind: "graphql",
144+ name: "GitLab GraphQL",
145+ summary: "Projects, merge requests, issues, CI pipelines, and users.",
146+ previewUrl: "https://gitlab.com/api/graphql",
147+ endpoint: "https://gitlab.com/api/graphql",
148+ },
149+ {
150+ id: "openai-api",
151+ pluginKey: "openapi",
152+ kind: "openapi",
153+ name: "OpenAI API",
154+ summary: "Models, files, responses, and fine-tuning.",
155+ previewUrl: "https://api.openai.com/v1",
156+ baseUrl: "https://api.openai.com/v1",
157+ specUrl: "https://app.stainless.com/api/spec/documented/openai/openapi.documented.yml",
158+ },
159+ {
160+ id: "vercel-api",
161+ pluginKey: "openapi",
162+ kind: "openapi",
163+ name: "Vercel API",
164+ summary: "Deployments, projects, domains, and environments.",
165+ previewUrl: "https://api.vercel.com",
166+ baseUrl: "https://api.vercel.com",
167+ specUrl: "https://openapi.vercel.sh",
168+ },
169+ {
170+ id: "stripe-api",
171+ pluginKey: "openapi",
172+ kind: "openapi",
173+ name: "Stripe API",
174+ summary: "Payments, billing, subscriptions, and invoices.",
175+ previewUrl: "https://api.stripe.com",
176+ baseUrl: "https://api.stripe.com",
177+ specUrl: "https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json",
178+ },
179+ {
180+ id: "linear-graphql",
181+ pluginKey: "graphql",
182+ kind: "graphql",
183+ name: "Linear GraphQL",
184+ summary: "Issues, teams, cycles, and projects.",
185+ previewUrl: "https://api.linear.app/graphql",
186+ endpoint: "https://api.linear.app/graphql",
187+ },
188+ {
189+ id: "monday-graphql",
190+ pluginKey: "graphql",
191+ kind: "graphql",
192+ name: "Monday GraphQL",
193+ summary: "Boards, items, updates, users, and workspace metadata.",
194+ previewUrl: "https://api.monday.com/v2",
195+ endpoint: "https://api.monday.com/v2",
196+ },
197+ {
198+ id: "anilist-graphql",
199+ pluginKey: "graphql",
200+ kind: "graphql",
201+ name: "AniList GraphQL",
202+ summary: "Anime, manga, characters, media lists, and recommendations.",
203+ previewUrl: "https://graphql.anilist.co",
204+ endpoint: "https://graphql.anilist.co",
205+ },
206+ googleDiscoveryPreset({
207+ id: "google-calendar",
208+ name: "Google Calendar",
209+ summary: "Calendars, events, ACLs, and scheduling workflows.",
210+ service: "calendar",
211+ version: "v3",
212+ discoveryUrl: "https://calendar-json.googleapis.com/$discovery/rest?version=v3",
213+ }),
214+ googleDiscoveryPreset({
215+ id: "google-drive",
216+ name: "Google Drive",
217+ summary: "Files, folders, permissions, comments, and shared drives.",
218+ service: "drive",
219+ version: "v3",
220+ discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/drive/v3/rest",
221+ }),
222+ googleDiscoveryPreset({
223+ id: "google-gmail",
224+ name: "Gmail",
225+ summary: "Messages, threads, labels, drafts, and mailbox automation.",
226+ service: "gmail",
227+ version: "v1",
228+ discoveryUrl: "https://gmail.googleapis.com/$discovery/rest?version=v1",
229+ }),
230+ googleDiscoveryPreset({
231+ id: "google-docs",
232+ name: "Google Docs",
233+ summary: "Documents, structural edits, text ranges, and formatting.",
234+ service: "docs",
235+ version: "v1",
236+ discoveryUrl: "https://docs.googleapis.com/$discovery/rest?version=v1",
237+ }),
238+ googleDiscoveryPreset({
239+ id: "google-sheets",
240+ name: "Google Sheets",
241+ summary: "Spreadsheets, values, ranges, formatting, and batch updates.",
242+ service: "sheets",
243+ version: "v4",
244+ discoveryUrl: "https://sheets.googleapis.com/$discovery/rest?version=v4",
245+ }),
246+ googleDiscoveryPreset({
247+ id: "google-slides",
248+ name: "Google Slides",
249+ summary: "Presentations, slides, page elements, and deck updates.",
250+ service: "slides",
251+ version: "v1",
252+ discoveryUrl: "https://slides.googleapis.com/$discovery/rest?version=v1",
253+ }),
254+ googleDiscoveryPreset({
255+ id: "google-forms",
256+ name: "Google Forms",
257+ summary: "Forms, questions, responses, quizzes, and form metadata.",
258+ service: "forms",
259+ version: "v1",
260+ discoveryUrl: "https://forms.googleapis.com/$discovery/rest?version=v1",
261+ }),
262+ googleDiscoveryPreset({
263+ id: "google-search-console",
264+ name: "Google Search Console",
265+ summary: "Sites, sitemaps, URL inspection, and search and Discover performance.",
266+ service: "searchconsole",
267+ version: "v1",
268+ discoveryUrl: "https://searchconsole.googleapis.com/$discovery/rest?version=v1",
269+ }),
270+ googleDiscoveryPreset({
271+ id: "google-people",
272+ name: "Google People",
273+ summary: "Contacts, profiles, directory people, and contact groups.",
274+ service: "people",
275+ version: "v1",
276+ discoveryUrl: "https://people.googleapis.com/$discovery/rest?version=v1",
277+ }),
278+ googleDiscoveryPreset({
279+ id: "google-tasks",
280+ name: "Google Tasks",
281+ summary: "Task lists, task items, notes, and due dates.",
282+ service: "tasks",
283+ version: "v1",
284+ discoveryUrl: "https://tasks.googleapis.com/$discovery/rest?version=v1",
285+ }),
286+ googleDiscoveryPreset({
287+ id: "google-chat",
288+ name: "Google Chat",
289+ summary: "Spaces, messages, members, reactions, and chat workflows.",
290+ service: "chat",
291+ version: "v1",
292+ discoveryUrl: "https://chat.googleapis.com/$discovery/rest?version=v1",
293+ }),
294+ googleDiscoveryPreset({
295+ id: "google-keep",
296+ name: "Google Keep",
297+ summary: "Notes, lists, attachments, and collaborative annotations.",
298+ service: "keep",
299+ version: "v1",
300+ discoveryUrl: "https://keep.googleapis.com/$discovery/rest?version=v1",
301+ }),
302+ googleDiscoveryPreset({
303+ id: "google-classroom",
304+ name: "Google Classroom",
305+ summary: "Courses, rosters, coursework, submissions, and grading data.",
306+ service: "classroom",
307+ version: "v1",
308+ discoveryUrl: "https://classroom.googleapis.com/$discovery/rest?version=v1",
309+ }),
310+ googleDiscoveryPreset({
311+ id: "google-admin-directory",
312+ name: "Google Admin Directory",
313+ summary: "Users, groups, org units, roles, and domain directory resources.",
314+ service: "admin",
315+ version: "directory_v1",
316+ discoveryUrl: "https://admin.googleapis.com/$discovery/rest?version=directory_v1",
317+ }),
318+ googleDiscoveryPreset({
319+ id: "google-admin-reports",
320+ name: "Google Admin Reports",
321+ summary: "Audit events, usage reports, and admin activity logs.",
322+ service: "admin",
323+ version: "reports_v1",
324+ discoveryUrl: "https://admin.googleapis.com/$discovery/rest?version=reports_v1",
325+ }),
326+ googleDiscoveryPreset({
327+ id: "google-apps-script",
328+ name: "Google Apps Script",
329+ summary: "Projects, deployments, script execution, and Apps Script metadata.",
330+ service: "script",
331+ version: "v1",
332+ discoveryUrl: "https://script.googleapis.com/$discovery/rest?version=v1",
333+ }),
334+ googleDiscoveryPreset({
335+ id: "google-bigquery",
336+ name: "Google BigQuery",
337+ summary: "Datasets, tables, jobs, routines, and analytical query workflows.",
338+ service: "bigquery",
339+ version: "v2",
340+ discoveryUrl: "https://bigquery.googleapis.com/$discovery/rest?version=v2",
341+ }),
342+ googleDiscoveryPreset({
343+ id: "google-cloud-resource-manager",
344+ name: "Google Cloud Resource Manager",
345+ summary: "Projects, folders, organizations, and IAM-oriented resource hierarchy.",
346+ service: "cloudresourcemanager",
347+ version: "v3",
348+ discoveryUrl: "https://cloudresourcemanager.googleapis.com/$discovery/rest?version=v3",
349+ }),
350+ googleDiscoveryPreset({
351+ id: "google-youtube-data",
352+ name: "YouTube Data",
353+ summary: "Channels, playlists, videos, comments, captions, and uploads.",
354+ service: "youtube",
355+ version: "v3",
356+ discoveryUrl: "https://youtube.googleapis.com/$discovery/rest?version=v3",
357+ }),
358+] as const;
359+
360+export const buildSourcePresetSearch = (
361+ preset: SourcePreset,
362+): Record<string, string> => {
363+ switch (preset.pluginKey) {
364+ case "openapi":
365+ return {
366+ preset: preset.id,
367+ presetName: preset.name,
368+ presetBaseUrl: preset.baseUrl,
369+ presetSpecUrl: preset.specUrl,
370+ };
371+ case "graphql":
372+ return {
373+ preset: preset.id,
374+ presetName: preset.name,
375+ presetEndpoint: preset.endpoint,
376+ };
377+ case "mcp":
378+ return {
379+ preset: preset.id,
380+ presetName: preset.name,
381+ ...(preset.endpoint ? { presetEndpoint: preset.endpoint } : {}),
382+ ...(preset.transport ? { presetTransport: preset.transport } : {}),
383+ ...(preset.command ? { presetCommand: preset.command } : {}),
384+ ...(preset.args ? { presetArgs: JSON.stringify(preset.args) } : {}),
385+ ...(preset.env ? { presetEnv: JSON.stringify(preset.env) } : {}),
386+ ...(preset.cwd ? { presetCwd: preset.cwd } : {}),
387+ };
388+ default:
389+ return {
390+ preset: preset.id,
391+ presetName: preset.name,
392+ presetService: preset.service,
393+ presetVersion: preset.version,
394+ presetDiscoveryUrl: preset.discoveryUrl,
395+ };
396+ }
397+};
398+
399+const normalizeComparableUrl = (value: string): string => {
400+ const trimmed = value.trim();
401+ try {
402+ const url = new URL(trimmed);
403+ if (url.pathname.length > 1 && url.pathname.endsWith("/")) {
404+ url.pathname = url.pathname.slice(0, -1);
405+ }
406+ return url.toString();
407+ } catch {
408+ return trimmed;
409+ }
410+};
411+
412+const tryParseUrl = (value: string): URL | null => {
413+ try {
414+ return new URL(value.trim());
415+ } catch {
416+ return null;
417+ }
418+};
419+
420+const sourceNameFromUrl = (url: URL, fallback: string): string => {
421+ const hostname = url.hostname.replace(/^www\./, "");
422+ if (hostname.length === 0) {
423+ return fallback;
424+ }
425+
426+ const head = hostname.split(".")[0]?.trim();
427+ if (!head) {
428+ return fallback;
429+ }
430+
431+ return `${head.charAt(0).toUpperCase()}${head.slice(1)} ${fallback}`;
432+};
433+
434+const parseGoogleDiscoveryUrl = (value: string): {
435+ service: string;
436+ version: string;
437+ discoveryUrl: string;
438+} | null => {
439+ const url = tryParseUrl(value);
440+ if (!url) {
441+ return null;
442+ }
443+
444+ const byDirectory = url.pathname.match(
445+ /^\/discovery\/v1\/apis\/([^/]+)\/([^/]+)\/rest$/,
446+ );
447+ if (byDirectory) {
448+ return {
449+ service: decodeURIComponent(byDirectory[1] ?? ""),
450+ version: decodeURIComponent(byDirectory[2] ?? ""),
451+ discoveryUrl: url.toString(),
452+ };
453+ }
454+
455+ const versionParam = url.searchParams.get("version")?.trim();
456+ const isHostScopedDiscovery =
457+ url.pathname === "/$discovery/rest"
458+ && url.hostname.endsWith(".googleapis.com")
459+ && url.hostname !== "www.googleapis.com";
460+
461+ if (versionParam && isHostScopedDiscovery) {
462+ const service = url.hostname.split(".")[0]?.trim();
463+ if (!service) {
464+ return null;
465+ }
466+
467+ return {
468+ service,
469+ version: versionParam,
470+ discoveryUrl: url.toString(),
471+ };
472+ }
473+
474+ return null;
475+};
476+
477+const isGraphqlUrl = (url: URL): boolean => {
478+ const path = url.pathname.toLowerCase();
479+ const hostname = url.hostname.toLowerCase();
480+ return (
481+ path.endsWith("/graphql")
482+ || path === "/graphql"
483+ || hostname.startsWith("graphql.")
484+ || hostname.includes("graphql")
485+ );
486+};
487+
488+const isMcpUrl = (url: URL): boolean => {
489+ const path = url.pathname.toLowerCase();
490+ const hostname = url.hostname.toLowerCase();
491+ return path.endsWith("/mcp") || path.includes("/mcp/") || hostname.startsWith("mcp.");
492+};
493+
494+const isLikelyOpenApiSpecUrl = (url: URL): boolean => {
495+ const path = url.pathname.toLowerCase();
496+ return (
497+ path.endsWith(".json")
498+ || path.endsWith(".yaml")
499+ || path.endsWith(".yml")
500+ || path.includes("openapi")
501+ || path.includes("swagger")
502+ );
503+};
504+
505+const findPresetByInput = (value: string): SourcePreset | null => {
506+ const comparable = normalizeComparableUrl(value);
507+ for (const preset of sourcePresets) {
508+ if (normalizeComparableUrl(preset.previewUrl) === comparable) {
509+ return preset;
510+ }
511+
512+ if (preset.pluginKey === "openapi") {
513+ if (normalizeComparableUrl(preset.baseUrl) === comparable) {
514+ return preset;
515+ }
516+ if (normalizeComparableUrl(preset.specUrl) === comparable) {
517+ return preset;
518+ }
519+ continue;
520+ }
521+
522+ if (preset.pluginKey === "graphql" || preset.pluginKey === "mcp") {
523+ if (preset.endpoint && normalizeComparableUrl(preset.endpoint) === comparable) {
524+ return preset;
525+ }
526+ continue;
527+ }
528+
529+ if (normalizeComparableUrl(preset.discoveryUrl) === comparable) {
530+ return preset;
531+ }
532+ }
533+
534+ return null;
535+};
536+
537+export type ResolvedQuickSourceInput = {
538+ pluginKey: SourcePreset["pluginKey"];
539+ search: Record<string, string>;
540+};
541+
542+export const resolveQuickSourceInput = (
543+ rawValue: string,
544+): ResolvedQuickSourceInput | null => {
545+ const trimmed = rawValue.trim();
546+ if (trimmed.length === 0) {
547+ return null;
548+ }
549+
550+ const commandParts =
551+ trimmed.match(/[^\s"]+|"([^"]*)"/g)?.map((part) =>
552+ part.startsWith("\"") && part.endsWith("\"")
553+ ? part.slice(1, -1)
554+ : part
555+ ) ?? null;
556+ if (commandParts && commandParts.length > 0) {
557+ const [command, ...args] = commandParts;
558+ const isKnownCommand =
559+ command === "npx"
560+ || command === "bunx"
561+ || command === "uvx"
562+ || (command === "pnpm" && args[0] === "dlx");
563+
564+ if (isKnownCommand) {
565+ return {
566+ pluginKey: "mcp",
567+ search: {
568+ presetName: "Local MCP",
569+ presetTransport: "stdio",
570+ presetCommand: command,
571+ ...(args.length > 0 ? { presetArgs: JSON.stringify(args) } : {}),
572+ quickInput: trimmed,
573+ },
574+ };
575+ }
576+ }
577+
578+ const preset = findPresetByInput(trimmed);
579+ if (preset) {
580+ return {
581+ pluginKey: preset.pluginKey,
582+ search: buildSourcePresetSearch(preset),
583+ };
584+ }
585+
586+ const googleDiscovery = parseGoogleDiscoveryUrl(trimmed);
587+ if (googleDiscovery) {
588+ const googlePreset = sourcePresets.find(
589+ (presetEntry) =>
590+ presetEntry.pluginKey === GOOGLE_DISCOVERY_PLUGIN_KEY
591+ && presetEntry.service === googleDiscovery.service
592+ && presetEntry.version === googleDiscovery.version,
593+ );
594+
595+ return {
596+ pluginKey: GOOGLE_DISCOVERY_PLUGIN_KEY,
597+ search: {
598+ ...(googlePreset ? buildSourcePresetSearch(googlePreset) : {}),
599+ presetName:
600+ googlePreset?.name
601+ ?? `Google ${googleDiscovery.service} ${googleDiscovery.version}`,
602+ presetService: googleDiscovery.service,
603+ presetVersion: googleDiscovery.version,
604+ presetDiscoveryUrl: googleDiscovery.discoveryUrl,
605+ quickInput: trimmed,
606+ },
607+ };
608+ }
609+
610+ const url = tryParseUrl(trimmed);
611+ if (!url) {
612+ return null;
613+ }
614+
615+ if (isGraphqlUrl(url)) {
616+ return {
617+ pluginKey: "graphql",
618+ search: {
619+ presetName: sourceNameFromUrl(url, "GraphQL"),
620+ presetEndpoint: trimmed,
621+ quickInput: trimmed,
622+ },
623+ };
624+ }
625+
626+ if (isMcpUrl(url)) {
627+ return {
628+ pluginKey: "mcp",
629+ search: {
630+ presetName: sourceNameFromUrl(url, "MCP"),
631+ presetEndpoint: trimmed,
632+ presetTransport: "auto",
633+ quickInput: trimmed,
634+ },
635+ };
636+ }
637+
638+ if (isLikelyOpenApiSpecUrl(url)) {
639+ return {
640+ pluginKey: "openapi",
641+ search: {
642+ presetName: sourceNameFromUrl(url, "OpenAPI"),
643+ presetSpecUrl: trimmed,
644+ quickInput: trimmed,
645+ },
646+ };
647+ }
648+
649+ return {
650+ pluginKey: "openapi",
651+ search: {
652+ presetName: sourceNameFromUrl(url, "OpenAPI"),
653+ presetBaseUrl: trimmed,
654+ quickInput: trimmed,
655+ },
656+ };
657+};
apps/web/src/views/home.tsx+93−87
@@ -2,116 +2,122 @@ import { Link } from "@tanstack/react-router"; · 1 unmodified line
22 import { useSources } from "@executor/react";
33 import { sourcePluginsIndexPath } from "@executor/react/plugins";
44 import { LoadableBlock } from "../components/loadable";
5+import { LocalMcpInstallCard } from "../components/local-mcp-install-card";
6+import { SourceFavicon } from "../components/source-favicon";
57 import { Badge } from "../components/ui/badge";
68 import { Button } from "../components/ui/button";
7−import { SourcePluginsResetState } from "../components/source-plugins-reset-state";
9+import { IconSources, IconPlus } from "../components/icons";
810 import {
911 getSourceFrontendPaths,
10− registeredSourceFrontendTypes,
1112 } from "../plugins";
1213
14+const statusVariant = (status: string) =>
15+ status === "connected"
16+ ? "default" as const
17+ : status === "error"
18+ ? "destructive" as const
19+ : "muted" as const;
20+
1321 export function HomePage() {
1422 const sources = useSources();
1523
1624 return (
17− <div className="flex-1 overflow-y-auto">
25+ <div className="min-h-0 flex-1 overflow-y-auto">
1826 <div className="mx-auto max-w-4xl px-6 py-10 lg:px-10 lg:py-14">
19− <div className="rounded-3xl border border-border bg-card p-8">
20− <div className="inline-flex rounded-full border border-border bg-muted px-3 py-1 text-[11px] font-medium uppercase tracking-[0.2em] text-muted-foreground">
21− Sources
27+ {/* Header */}
28+ <div className="flex items-end justify-between mb-8">
29+ <div>
30+ <h1 className="font-display text-3xl tracking-tight text-foreground lg:text-4xl">
31+ Sources
32+ </h1>
33+ <p className="mt-1.5 text-[14px] text-muted-foreground">
34+ Connected tool providers in this workspace.
35+ </p>
2236 </div>
23− <h1 className="mt-5 font-display text-3xl tracking-tight text-foreground lg:text-4xl">
24− Source plugins now own source-specific product behavior
25− </h1>
26− <p className="mt-3 max-w-3xl text-sm leading-6 text-muted-foreground">
27− Add, edit, inspect, and authorize sources through plugin-owned UI and
28− API surfaces. The core app stays generic while each plugin carries its
29− own transport, auth, and storage behavior.
30− </p>
31− {registeredSourceFrontendTypes.length > 0 && (
32− <div className="mt-5 flex flex-wrap gap-2">
33− {registeredSourceFrontendTypes.map((definition) => (
34− <Badge key={definition.key} variant="outline">
35− {definition.displayName}
36− </Badge>
37− ))}
38− </div>
39− )}
37+ <Link to={sourcePluginsIndexPath}>
38+ <Button size="sm">
39+ <IconPlus className="size-3.5" />
40+ Add source
41+ </Button>
42+ </Link>
4043 </div>
4144
42− <div className="mt-8">
43− <LoadableBlock loadable={sources} loading="Loading sources...">
44− {(items) =>
45− !Array.isArray(items) ? (
46− <SourcePluginsResetState
47− title="Unexpected sources payload"
48− message="The sources list returned data in an unexpected shape."
49− />
50− ) : items.length === 0 ? (
51− <SourcePluginsResetState
52− title="No sources connected yet"
53− message="Use the add flow to connect an OpenAPI, GraphQL, MCP, or Google Discovery source through its plugin-owned surface."
54− action={(
55− <Link to={sourcePluginsIndexPath}>
56− <Button size="sm" variant="outline">
57− Add Source
58− </Button>
59− </Link>
60− )}
61− />
62− ) : (
63− <div className="grid gap-3">
64− {items.map((source) => {
65− const paths = getSourceFrontendPaths(source.kind);
66− const card = (
67− <div className="rounded-2xl border border-border bg-card px-5 py-4 transition-colors hover:border-primary/25 hover:bg-card/90">
68− <div className="flex items-start justify-between gap-3">
69− <div>
70− <div className="text-sm font-semibold text-foreground">
45+ <LocalMcpInstallCard className="mb-8" />
46+
47+ {/* Source list */}
48+ <LoadableBlock loadable={sources} loading="Loading sources...">
49+ {(items) =>
50+ !Array.isArray(items) ? (
51+ <div className="rounded-xl border border-destructive/20 bg-destructive/5 px-5 py-4 text-sm text-destructive">
52+ Sources returned an unexpected payload.
53+ </div>
54+ ) : items.length === 0 ? (
55+ <div className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-border py-20">
56+ <div className="flex size-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground mb-4">
57+ <IconSources className="size-5" />
58+ </div>
59+ <p className="text-[14px] font-medium text-foreground/70 mb-1">
60+ No sources yet
61+ </p>
62+ <p className="text-[13px] text-muted-foreground/60 mb-5">
63+ Add a source to get started.
64+ </p>
65+ <Link to={sourcePluginsIndexPath}>
66+ <Button size="sm">
67+ <IconPlus className="size-3.5" />
68+ Add source
69+ </Button>
70+ </Link>
71+ </div>
72+ ) : (
73+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
74+ {items.map((source) => {
75+ const paths = getSourceFrontendPaths(source.kind);
76+ const card = (
77+ <div className="flex h-full flex-col rounded-2xl border border-border bg-card px-5 py-4 transition-colors hover:border-primary/25 hover:bg-card/90">
78+ <div className="flex items-start gap-3">
79+ <div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
80+ <SourceFavicon source={source} className="size-4" />
81+ </div>
82+ <div className="min-w-0 flex-1">
83+ <div className="flex items-start justify-between gap-2">
84+ <div className="truncate text-sm font-semibold text-foreground">
7185 {source.name}
7286 </div>
73− <div className="mt-1 text-xs text-muted-foreground">
74− {source.kind}
75− </div>
87+ <Badge variant={statusVariant(source.status)} className="shrink-0">
88+ {source.status}
89+ </Badge>
90+ </div>
91+ <div className="mt-0.5 text-xs text-muted-foreground">
92+ {source.kind}
7693 </div>
77− <Badge
78− variant={
79− source.status === "connected"
80− ? "default"
81− : source.status === "error"
82− ? "destructive"
83− : "muted"
84− }
85− >
86− {source.status}
87− </Badge>
8894 </div>
8995 </div>
90− );
91−
92− if (!paths) {
93− return (
94− <div key={source.id}>
95− {card}
96− </div>
97− );
98− }
96+ </div>
97+ );
9998
99+ if (!paths) {
100100 return (
101− <Link
102− key={source.id}
103− to={paths.detail(source.id)}
104− search={{ tab: "model" }}
105− >
101+ <div key={source.id}>
106102 {card}
107− </Link>
103+ </div>
108104 );
109− })}
110− </div>
111− )
112− }
113− </LoadableBlock>
114− </div>
105+ }
106+
107+ return (
108+ <Link
109+ key={source.id}
110+ to={paths.detail(source.id)}
111+ search={{ tab: "model" }}
112+ >
113+ {card}
114+ </Link>
115+ );
116+ })}
117+ </div>
118+ )
119+ }
120+ </LoadableBlock>
115121 </div>
116122 </div>
117123 );
apps/web/src/views/secrets.tsx+39−15
@@ -42,7 +42,7 @@ export function SecretsPage() { · 41 unmodified lines
4242 : null;
4343
4444 return (
45− <div className="flex-1 overflow-y-auto">
45+ <div className="min-h-0 flex-1 overflow-y-auto">
4646 <div className="mx-auto max-w-3xl px-6 py-10 lg:px-10 lg:py-14">
4747 {/* Header */}
4848 <div className="flex items-end justify-between mb-8">
@@ -259,19 +259,16 @@ function SecretRow(props: { · 210 unmodified lines
259259 const { secret, providerLabel, isEditing } = props;
260260 const deleteSecret = useDeleteSecret();
261261 const [isDeleting, setIsDeleting] = useState(false);
262+ const [confirmDelete, setConfirmDelete] = useState(false);
262263
263264 const handleDelete = async () => {
264− const confirmed = window.confirm(
265− `Delete "${secret.name || secret.id}"? This cannot be undone. Sources using this secret will lose access.`,
266− );
267− if (!confirmed) return;
268−
269265 setIsDeleting(true);
270266 try {
271267 await deleteSecret.mutateAsync(secret.id);
272268 } catch {
273269 // refresh will show the secret still there
274270 } finally {
271+ setConfirmDelete(false);
275272 setIsDeleting(false);
276273 }
277274 };
@@ -338,15 +335,42 @@ function SecretRow(props: { · 60 unmodified lines
338335 <IconPencil className="size-3" />
339336 Edit
340337 </button>
341− <button
342− type="button"
343− onClick={handleDelete}
344− disabled={isDeleting}
345− className="inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-[11px] font-medium text-muted-foreground transition-colors hover:text-destructive hover:bg-destructive/8 disabled:opacity-50"
346− >
347− {isDeleting ? <IconSpinner className="size-3" /> : <IconTrash className="size-3" />}
348− Delete
349− </button>
338+ {confirmDelete ? (
339+ <>
340+ <span className="text-[10px] font-medium text-destructive">
341+ Confirm delete?
342+ </span>
343+ <button
344+ type="button"
345+ onClick={() => setConfirmDelete(false)}
346+ disabled={isDeleting}
347+ className="inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground disabled:opacity-50"
348+ >
349+ Cancel
350+ </button>
351+ <button
352+ type="button"
353+ onClick={() => {
354+ void handleDelete();
355+ }}
356+ disabled={isDeleting}
357+ className="inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-[11px] font-medium text-destructive transition-colors hover:bg-destructive/8 disabled:opacity-50"
358+ >
359+ {isDeleting ? <IconSpinner className="size-3" /> : <IconTrash className="size-3" />}
360+ {isDeleting ? "Deleting..." : "Delete"}
361+ </button>
362+ </>
363+ ) : (
364+ <button
365+ type="button"
366+ onClick={() => setConfirmDelete(true)}
367+ disabled={isDeleting}
368+ className="inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-[11px] font-medium text-muted-foreground transition-colors hover:text-destructive hover:bg-destructive/8 disabled:opacity-50"
369+ >
370+ {isDeleting ? <IconSpinner className="size-3" /> : <IconTrash className="size-3" />}
371+ Delete
372+ </button>
373+ )}
350374 </div>
351375 </div>
352376
opencode.json+11−5
@@ -1,10 +1,16 @@
11 {
2− "$schema": "https://opencode.ai/config.json",
32 "mcp": {
4− "executor": {
5− "type": "remote",
6− "url": "http://localhost:8788/mcp",
7− "enabled": true
3+ "executor-stdio": {
4+ "type": "local",
5+ "command": [
6+ "bun",
7+ "run",
8+ "executor",
9+ "mcp",
10+ "--stdio"
11+ ],
12+ "enabled": true,
13+ "environment": {}
814 }
915 }
1016 }
packages/platform/sdk/src/runtime/execution/scope/environment.ts+27−0
@@ -47,6 +47,16 @@ import { · 46 unmodified lines
4747 import {
4848 RuntimeSourceStoreService,
4949 } from "../../sources/source-store";
50+import {
51+ SecretMaterialDeleterService,
52+ SecretMaterialResolverService,
53+ SecretMaterialStorerService,
54+ SecretMaterialUpdaterService,
55+ type DeleteSecretMaterial,
56+ type ResolveSecretMaterial,
57+ type StoreSecretMaterial,
58+ type UpdateSecretMaterial,
59+} from "../../scope/secret-material-providers";
5060 export {
5161 createCodeExecutorForRuntime,
5262 resolveConfiguredExecutionRuntime,
@@ -74,6 +84,12 @@ export const createScopeExecutionEnvironmentResolver = · 21 unmodified lines
7484 scopeConfigStore: ScopeConfigStoreShape;
7585 scopeStateStore: ScopeStateStoreShape;
7686 sourceArtifactStore: SourceArtifactStoreShape;
87+ secretMaterialServices: {
88+ resolve: ResolveSecretMaterial;
89+ store: StoreSecretMaterial;
90+ delete: DeleteSecretMaterial;
91+ update: UpdateSecretMaterial;
92+ };
7793 }): ResolveExecutionEnvironment =>
7894 ({ scopeId, actorScopeId, onElicitation }) =>
7995 Effect.gen(function* () {
@@ -99,6 +115,7 @@ export const createScopeExecutionEnvironmentResolver = · 19 unmodified lines
99115 sourceArtifactStore: input.sourceArtifactStore,
100116 runtimeLocalScope,
101117 localToolRuntime,
118+ secretMaterialServices: input.secretMaterialServices,
102119 onElicitation,
103120 });
104121
@@ -140,6 +157,10 @@ export const RuntimeExecutionResolverLive = ( · 35 unmodified lines
140157 const scopeConfigStore = yield* ScopeConfigStore;
141158 const scopeStateStore = yield* ScopeStateStore;
142159 const sourceArtifactStore = yield* SourceArtifactStore;
160+ const resolveSecretMaterial = yield* SecretMaterialResolverService;
161+ const storeSecretMaterial = yield* SecretMaterialStorerService;
162+ const deleteSecretMaterial = yield* SecretMaterialDeleterService;
163+ const updateSecretMaterial = yield* SecretMaterialUpdaterService;
143164
144165 return createScopeExecutionEnvironmentResolver({
145166 executorStateStore,
@@ -151,6 +172,12 @@ export const RuntimeExecutionResolverLive = ( · 5 unmodified lines
151172 scopeConfigStore,
152173 scopeStateStore,
153174 sourceArtifactStore,
175+ secretMaterialServices: {
176+ resolve: resolveSecretMaterial,
177+ store: storeSecretMaterial,
178+ delete: deleteSecretMaterial,
179+ update: updateSecretMaterial,
180+ },
154181 });
155182 }),
156183 );
packages/platform/sdk/src/runtime/execution/scope/tool-invoker.ts+29−1
@@ -17,6 +17,16 @@ import * as Effect from "effect/Effect"; · 16 unmodified lines
1717 import {
1818 RuntimeSourceCatalogStoreService,
1919 } from "../../catalog/source/runtime";
20+import {
21+ SecretMaterialDeleterService,
22+ SecretMaterialResolverService,
23+ SecretMaterialStorerService,
24+ SecretMaterialUpdaterService,
25+ type DeleteSecretMaterial,
26+ type ResolveSecretMaterial,
27+ type StoreSecretMaterial,
28+ type UpdateSecretMaterial,
29+} from "../../scope/secret-material-providers";
2030 import type {
2131 RuntimeLocalScopeState,
2232 } from "../../scope/runtime-context";
@@ -59,6 +69,7 @@ import { · 36 unmodified lines
5969 import {
6070 runtimeEffectError,
6171 } from "../../effect-errors";
72+import * as Layer from "effect/Layer";
6273
6374 export const createScopeToolInvoker = (input: {
6475 scopeId: Source["scopeId"];
@@ -77,6 +88,12 @@ export const createScopeToolInvoker = (input: { · 12 unmodified lines
7788 sourceArtifactStore: SourceArtifactStoreShape;
7889 runtimeLocalScope: RuntimeLocalScopeState | null;
7990 localToolRuntime: LocalToolRuntime;
91+ secretMaterialServices: {
92+ resolve: ResolveSecretMaterial;
93+ store: StoreSecretMaterial;
94+ delete: DeleteSecretMaterial;
95+ update: UpdateSecretMaterial;
96+ };
8097 onElicitation?: Parameters<
8198 typeof makeToolInvokerFromTools
8299 >[0]["onElicitation"];
@@ -89,8 +106,17 @@ export const createScopeToolInvoker = (input: { · 6 unmodified lines
89106 scopeStateStore: input.scopeStateStore,
90107 sourceArtifactStore: input.sourceArtifactStore,
91108 });
109+ const secretMaterialLayer = Layer.mergeAll(
110+ Layer.succeed(SecretMaterialResolverService, input.secretMaterialServices.resolve),
111+ Layer.succeed(SecretMaterialStorerService, input.secretMaterialServices.store),
112+ Layer.succeed(SecretMaterialDeleterService, input.secretMaterialServices.delete),
113+ Layer.succeed(SecretMaterialUpdaterService, input.secretMaterialServices.update),
114+ );
92115 const provideWorkspaceStorage = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
93116 effect.pipe(Effect.provide(scopeStorageLayer));
117+ const provideSecretMaterialServices = <A, E, R>(
118+ effect: Effect.Effect<A, E, R>,
119+ ) => effect.pipe(Effect.provide(secretMaterialLayer));
94120
95121 const executorTools = createExecutorToolMap({
96122 scopeId: input.scopeId,
@@ -146,7 +172,8 @@ export const createScopeToolInvoker = (input: { · 49 unmodified lines
146172 context?: Record<string, unknown>;
147173 }) =>
148174 provideRuntimeLocalScope(
149− provideWorkspaceStorage(
175+ provideSecretMaterialServices(
176+ provideWorkspaceStorage(
150177 Effect.gen(function* () {
151178 const catalogTool = yield* loadWorkspaceCatalogToolByPath({
152179 scopeId: input.scopeId,
@@ -179,6 +206,7 @@ export const createScopeToolInvoker = (input: { · 26 unmodified lines
179206 context: invocation.context,
180207 });
181208 }),
209+ ),
182210 ),
183211 input.runtimeLocalScope,
184212 );
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