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 @@
1{1{
2 "sources": {2 "sources": {
3 "vercel-api": {3 "axiom-mcp": {
4 "name": "Vercel API",4 "name": "Axiom MCP",
5 "kind": "openapi"5 "namespace": "axiom.mcp",
6 },
7 "axiom": {
8 "kind": "mcp"6 "kind": "mcp"
9 },7 },
10 "linear": {8 "google-calendar": {
11 "kind": "graphql"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"
12 }27 }
13 }28 }
14}29}
.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
57 createLocalExecutorServer,57 createLocalExecutorServer,
58 DEFAULT_SERVER_BASE_URL,58 DEFAULT_SERVER_BASE_URL,
59 DEFAULT_SERVER_HOST,59 DEFAULT_SERVER_HOST,
60 DEFAULT_LOCAL_DATA_DIR,
61 DEFAULT_SERVER_LOG_FILE,60 DEFAULT_SERVER_LOG_FILE,
62 DEFAULT_SERVER_PID_FILE,61 DEFAULT_SERVER_PID_FILE,
63 DEFAULT_SERVER_PORT,62 DEFAULT_SERVER_PORT,
@@ -66,6 +65,7 @@ import { · 2 unmodified lines
66 runLocalExecutorServer,65 runLocalExecutorServer,
67} from "@executor/server";66} from "@executor/server";
68import {67import {
68 resolveCliLocalDataDir,
69 resolveRuntimeWebAssetsDir,69 resolveRuntimeWebAssetsDir,
70 resolveSelfCommand,70 resolveSelfCommand,
71} from "./runtime-paths";71} from "./runtime-paths";
@@ -178,6 +178,7 @@ const getBootstrapClient = (baseUrl: string = DEFAULT_SERVER_BASE_URL) => · 106 unmodified lines
178const decodeExecutionId = Schema.decodeUnknown(ExecutionIdSchema);178const decodeExecutionId = Schema.decodeUnknown(ExecutionIdSchema);
179const require = createRequire(import.meta.url);179const require = createRequire(import.meta.url);
180const CLI_NAME = "executor";180const CLI_NAME = "executor";
181const CLI_LOCAL_DATA_DIR = resolveCliLocalDataDir();
181const CLI_VERSION = (() => {182const CLI_VERSION = (() => {
182 const candidatePaths = [183 const candidatePaths = [
183 "../package.json",184 "../package.json",
@@ -299,33 +300,33 @@ const loadRunWorkflowText = (): Effect.Effect<string, Error, never> => · 115 unmodified lines
299 Effect.acquireUseRelease(300 Effect.acquireUseRelease(
300 createExecutorEffect({301 createExecutorEffect({
301 backend: createLocalExecutorBackend({302 backend: createLocalExecutorBackend({
302 localDataDir: DEFAULT_LOCAL_DATA_DIR,303 localDataDir: CLI_LOCAL_DATA_DIR,
303 }),304 }),
304 plugins: [305 plugins: [
305 graphqlSdkPlugin({306 graphqlSdkPlugin({
306 storage: createFileGraphqlSourceStorage({307 storage: createFileGraphqlSourceStorage({
307 rootDir: `${DEFAULT_LOCAL_DATA_DIR}/plugins/graphql/sources`,308 rootDir: `${CLI_LOCAL_DATA_DIR}/plugins/graphql/sources`,
308 }),309 }),
309 }),310 }),
310 googleDiscoverySdkPlugin({311 googleDiscoverySdkPlugin({
311 storage: createFileGoogleDiscoverySourceStorage({312 storage: createFileGoogleDiscoverySourceStorage({
312 rootDir: `${DEFAULT_LOCAL_DATA_DIR}/plugins/google-discovery/sources`,313 rootDir: `${CLI_LOCAL_DATA_DIR}/plugins/google-discovery/sources`,
313 }),314 }),
314 oauthSessions: createFileGoogleDiscoveryOAuthSessionStorage({315 oauthSessions: createFileGoogleDiscoveryOAuthSessionStorage({
315 rootDir: `${DEFAULT_LOCAL_DATA_DIR}/plugins/google-discovery/oauth-sessions`,316 rootDir: `${CLI_LOCAL_DATA_DIR}/plugins/google-discovery/oauth-sessions`,
316 }),317 }),
317 }),318 }),
318 mcpSdkPlugin({319 mcpSdkPlugin({
319 storage: createFileMcpSourceStorage({320 storage: createFileMcpSourceStorage({
320 rootDir: `${DEFAULT_LOCAL_DATA_DIR}/plugins/mcp/sources`,321 rootDir: `${CLI_LOCAL_DATA_DIR}/plugins/mcp/sources`,
321 }),322 }),
322 oauthSessions: createFileMcpOAuthSessionStorage({323 oauthSessions: createFileMcpOAuthSessionStorage({
323 rootDir: `${DEFAULT_LOCAL_DATA_DIR}/plugins/mcp/oauth-sessions`,324 rootDir: `${CLI_LOCAL_DATA_DIR}/plugins/mcp/oauth-sessions`,
324 }),325 }),
325 }),326 }),
326 openApiSdkPlugin({327 openApiSdkPlugin({
327 storage: createFileOpenApiSourceStorage({328 storage: createFileOpenApiSourceStorage({
328 rootDir: `${DEFAULT_LOCAL_DATA_DIR}/plugins/openapi/sources`,329 rootDir: `${CLI_LOCAL_DATA_DIR}/plugins/openapi/sources`,
329 }),330 }),
330 }),331 }),
331 ] as const,332 ] as const,
@@ -476,7 +477,7 @@ const getDefaultServerOptions = (port: number = DEFAULT_SERVER_PORT) => { · 144 unmodified lines
476 return {477 return {
477 host: DEFAULT_SERVER_HOST,478 host: DEFAULT_SERVER_HOST,
478 port,479 port,
479 localDataDir: DEFAULT_LOCAL_DATA_DIR,480 localDataDir: CLI_LOCAL_DATA_DIR,
480 pidFile: DEFAULT_SERVER_PID_FILE,481 pidFile: DEFAULT_SERVER_PID_FILE,
481 ui: assetsDir ? { assetsDir } : undefined,482 ui: assetsDir ? { assetsDir } : undefined,
482 };483 };
@@ -638,7 +639,7 @@ const getServerStatus = ( · 155 unmodified lines
638 pid,639 pid,
639 pidRunning,640 pidRunning,
640 logFile,641 logFile,
641 localDataDir: DEFAULT_LOCAL_DATA_DIR,642 localDataDir: CLI_LOCAL_DATA_DIR,
642 webAssetsDir: resolveRuntimeWebAssetsDir(),643 webAssetsDir: resolveRuntimeWebAssetsDir(),
643 installation,644 installation,
644 } satisfies LocalServerStatus;645 } satisfies LocalServerStatus;
apps/executor/src/cli/runtime-paths.test.ts+47−0
@@ -0,0 +1,47 @@
1import { join } from "node:path";
2import { describe, expect, it } from "@effect/vitest";
3
4import {
5 DEFAULT_EXECUTOR_DATA_DIR,
6 DEFAULT_LOCAL_DATA_DIR,
7} from "@executor/server";
8
9import {
10 resolveCliLocalDataDir,
11} from "./runtime-paths";
12
13describe("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 @@
1import { existsSync } from "node:fs";1import { existsSync } from "node:fs";
2import { dirname, extname, resolve } from "node:path";2import { dirname, extname, join, resolve } from "node:path";
3import { fileURLToPath } from "node:url";3import { fileURLToPath } from "node:url";
4 4
5import {5import {
6 DEFAULT_EXECUTOR_DATA_DIR,
7 DEFAULT_LOCAL_DATA_DIR,
8 EXECUTOR_LOCAL_DATA_DIR_ENV,
6 EXECUTOR_WEB_ASSETS_DIR_ENV,9 EXECUTOR_WEB_ASSETS_DIR_ENV,
7} from "@executor/server";10} from "@executor/server";
8 11
@@ -36,6 +39,8 @@ const getSourceEntrypoint = (): string | null => { · 27 unmodified lines
36 : null;39 : null;
37};40};
38 41
42const isSourceCliLaunch = (): boolean => getSourceEntrypoint() !== null;
43
39const resolveBundledNodeLauncher = (): string | null => {44const resolveBundledNodeLauncher = (): string | null => {
40 const candidate = resolve(sourceDir, "executor.js");45 const candidate = resolve(sourceDir, "executor.js");
41 return existsSync(candidate) ? candidate : null;46 return existsSync(candidate) ? candidate : null;
@@ -76,6 +81,16 @@ export const resolveSelfCommand = (args: readonly string[]): readonly string[] = · 34 unmodified lines
76 : [process.execPath, sourceEntrypoint, ...args];81 : [process.execPath, sourceEntrypoint, ...args];
77};82};
78 83
84export 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
79export const resolveRuntimeWebAssetsDir = (): string | null => {94export const resolveRuntimeWebAssetsDir = (): string | null => {
80 const explicit = resolveIfExists(process.env[EXECUTOR_WEB_ASSETS_DIR_ENV]);95 const explicit = resolveIfExists(process.env[EXECUTOR_WEB_ASSETS_DIR_ENV]);
81 if (explicit) {96 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
4 4
5type LocalMcpInstallMode = "stdio" | "http";5type LocalMcpInstallMode = "stdio" | "http";
6 6
7const isDevBuild = import.meta.env.DEV;
8
7export function LocalMcpInstallCard(props: {9export function LocalMcpInstallCard(props: {
8 title?: string;10 title?: string;
9 description?: string;11 description?: string;
@@ -17,7 +19,9 @@ export function LocalMcpInstallCard(props: { · 7 unmodified lines
17 }, []);19 }, []);
18 20
19 const command = mode === "stdio"21 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"'
21 : origin25 : origin
22 ? `npx add-mcp "${origin}/mcp" --transport http --name "executor"`26 ? `npx add-mcp "${origin}/mcp" --transport http --name "executor"`
23 : 'npx add-mcp "<this-server>/mcp" --transport http --name "executor"';27 : 'npx add-mcp "<this-server>/mcp" --transport http --name "executor"';
@@ -57,9 +61,22 @@ export function LocalMcpInstallCard(props: { · 33 unmodified lines
57 </div>61 </div>
58 <CodeBlock code={command} lang="bash" className="rounded-xl border border-border bg-background/70" />62 <CodeBlock code={command} lang="bash" className="rounded-xl border border-border bg-background/70" />
59 {mode === "stdio" ? (63 {mode === "stdio" ? (
60 <p className="mt-3 text-[12px] text-muted-foreground">64 <div className="mt-3 space-y-1 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.65 {!isDevBuild ? (
62 </p>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>
63 ) : null}80 ) : null}
64 </section>81 </section>
65 );82 );
apps/web/src/components/shell.tsx+1−1
@@ -305,7 +305,7 @@ export function AppShell() { · 304 unmodified lines
305 </aside>305 </aside>
306 306
307 {/* Main content */}307 {/* 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">
309 <Outlet />309 <Outlet />
310 </main>310 </main>
311 </div>311 </div>
apps/web/src/components/source-plugins-reset-state.tsx+6−9
@@ -10,19 +10,16 @@ export function SourcePluginsResetState( · 9 unmodified lines
10 input: SourcePluginsResetStateProps,10 input: SourcePluginsResetStateProps,
11) {11) {
12 return (12 return (
13 <div className="flex-1 overflow-y-auto">13 <div className="min-h-0 flex-1 overflow-y-auto">
14 <div className="mx-auto max-w-3xl px-6 py-10 lg:px-10 lg:py-14">14 <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)]">15 <div className="rounded-2xl border border-border bg-card p-8">
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">16 <h2 className="font-display text-xl tracking-tight text-foreground">
17 Clean Slate
18 </div>
19 <h1 className="mt-5 font-display text-3xl tracking-tight text-foreground">
20 {input.title}17 {input.title}
21 </h1>18 </h2>
22 <p className="mt-3 max-w-2xl text-sm leading-6 text-muted-foreground">19 <p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
23 {input.message}20 {input.message}
24 </p>21 </p>
25 {input.action ? <div className="mt-6">{input.action}</div> : null}22 {input.action ? <div className="mt-5">{input.action}</div> : null}
26 </div>23 </div>
27 </div>24 </div>
28 </div>25 </div>
apps/web/src/main.tsx+4−0
@@ -144,15 +144,19 @@ const sourcePluginRoutes = registeredSourceFrontendTypes.flatMap((definition) => · 143 unmodified lines
144 const paths = createSourcePluginPaths(definition.key);144 const paths = createSourcePluginPaths(definition.key);
145 145
146 const AddRouteComponent = () => {146 const AddRouteComponent = () => {
147 const search = addRoute.useSearch();
147 const navigate = useNavigate();148 const navigate = useNavigate();
149 const navigateFromRoute = useNavigate({ from: addRoute.fullPath });
148 const navigation = createSourcePluginNavigation(definition, {150 const navigation = createSourcePluginNavigation(definition, {
149 navigateTo: (to, search) =>151 navigateTo: (to, search) =>
150 search === undefined ? navigate({ to }) : navigate({ to, search }),152 search === undefined ? navigate({ to }) : navigate({ to, search }),
153 updateSearch: (nextSearch) => navigateFromRoute({ search: nextSearch }),
151 });154 });
152 155
153 return (156 return (
154 <SourcePluginAddPage157 <SourcePluginAddPage
155 definitionKey={definition.key}158 definitionKey={definition.key}
159 search={search}
156 navigation={navigation}160 navigation={navigation}
157 />161 />
158 );162 );
apps/web/src/plugins/pages.tsx+220−73
@@ -1,4 +1,5 @@
1import { Link } from "@tanstack/react-router";1import { useState, type FormEvent } from "react";
2import { Link, useNavigate } from "@tanstack/react-router";
2import { useSource } from "@executor/react";3import { useSource } from "@executor/react";
3import {4import {
4 ExecutorPluginRouteProvider,5 ExecutorPluginRouteProvider,
@@ -15,6 +16,8 @@ import { · 10 unmodified lines
15 type SourcePluginRouteSearch,16 type SourcePluginRouteSearch,
16} from "@executor/react/plugins";17} from "@executor/react/plugins";
17 18
19import { DefaultSourceIcon } from "../components/source-favicon";
20import { LocalMcpInstallCard } from "../components/local-mcp-install-card";
18import { SourcePluginsResetState } from "../components/source-plugins-reset-state";21import { SourcePluginsResetState } from "../components/source-plugins-reset-state";
19import {22import {
20 getFrontendPlugin,23 getFrontendPlugin,
@@ -23,18 +26,23 @@ import { · 2 unmodified lines
23 getSourceFrontendTypeEntryByKey,26 getSourceFrontendTypeEntryByKey,
24 registeredSourceFrontendTypes,27 registeredSourceFrontendTypes,
25} from "./index";28} from "./index";
29import {
30 buildSourcePresetSearch,
31 resolveQuickSourceInput,
32 sourcePresets,
33} from "./source-presets";
26 34
27const FrontendPluginUnavailableState = () => (35const FrontendPluginUnavailableState = () => (
28 <SourcePluginsResetState36 <SourcePluginsResetState
29 title="Frontend plugins are unavailable"37 title="Plugins unavailable"
30 message="No frontend plugins are registered in this build."38 message="No plugins are available in this build."
31 />39 />
32);40);
33 41
34const SourcePluginUnavailableState = () => (42const SourcePluginUnavailableState = () => (
35 <SourcePluginsResetState43 <SourcePluginsResetState
36 title="Source plugins are unavailable"44 title="No source types available"
37 message="No source plugins are registered in this build."45 message="No source types are available in this build."
38 />46 />
39);47);
40 48
@@ -69,11 +77,69 @@ const SourcePluginRouteMismatchState = (props: { · 28 unmodified lines
69 actualDisplayName: string;77 actualDisplayName: string;
70}) => (78}) => (
71 <SourcePluginsResetState79 <SourcePluginsResetState
72 title="Source route mismatch"80 title="Wrong source type"
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.`}81 message={`This source uses ${props.actualDisplayName}, but you navigated to ${props.requestedDisplayName}. Open it from the sidebar instead.`}
74 />82 />
75);83);
76 84
85const 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
77export function ExecutorPluginRoutePage(input: {143export function ExecutorPluginRoutePage(input: {
78 pluginKey: string;144 pluginKey: string;
79 routeKey: FrontendPluginRouteDefinition["key"];145 routeKey: FrontendPluginRouteDefinition["key"];
@@ -90,8 +156,8 @@ export function ExecutorPluginRoutePage(input: { · 10 unmodified lines
90 if (routeEntry === null) {156 if (routeEntry === null) {
91 return (157 return (
92 <SourcePluginsResetState158 <SourcePluginsResetState
93 title="Plugin route is unavailable"159 title="Route unavailable"
94 message={`No plugin route is registered for "${input.routeKey}" in "${input.pluginKey}".`}160 message={`The route "${input.routeKey}" is not available for this plugin.`}
95 />161 />
96 );162 );
97 }163 }
@@ -120,46 +186,126 @@ export function SourcePluginsIndexPage() { · 22 unmodified lines
120 return <SourcePluginUnavailableState />;186 return <SourcePluginUnavailableState />;
121 }187 }
122 188
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
123 return (200 return (
124 <div className="mx-auto max-w-5xl px-6 py-10 lg:px-10 lg:py-14">201 <div className="min-h-0 flex-1 overflow-y-auto">
125 <div className="rounded-3xl border border-border bg-card p-8">202 <div className="mx-auto max-w-5xl px-6 py-10 lg:px-10 lg:py-14">
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">203 {/* Header */}
127 Source Plugins204 <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>
128 </div>211 </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>
137 212
138 <div className="mt-8 grid gap-4 md:grid-cols-2">213 <SourceQuickEntry />
139 {registeredSourceFrontendTypes.map((definition) => {214
140 const paths = createSourcePluginPaths(definition.key);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>
141 230
142 return (231 <div className="mt-5 space-y-8">
143 <Link232 {presetsByPlugin.map(({ definition, presets }) => {
144 key={definition.key}233 const paths = createSourcePluginPaths(definition.key);
145 to={paths.add}234
146 className="group rounded-3xl border border-border bg-card p-6 transition-colors hover:border-primary/30 hover:bg-card/90"235 return (
147 >236 <section key={definition.key}>
148 <div className="flex h-full flex-col">237 <h3 className="mb-3 text-sm font-semibold text-foreground">
149 <div className="text-lg font-semibold text-foreground">238 {definition.displayName}
150 {definition.displayName}239 </h3>
151 </div>240
152 <div className="mt-2 flex-1 text-sm leading-6 text-muted-foreground">241 <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
153 {definition.description242 {presets.map((preset) => (
154 ?? "Open the plugin-owned source flow."}243 <Link
155 </div>244 key={preset.id}
156 <div className="mt-6 text-xs font-medium uppercase tracking-[0.2em] text-primary">245 to={paths.add}
157 Open Plugin246 search={buildSourcePresetSearch(preset)}
158 </div>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"
159 </div>248 >
160 </Link>249 <div className="mb-2 flex items-center gap-2.5">
161 );250 <div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
162 })}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>
163 </div>309 </div>
164 </div>310 </div>
165 );311 );
@@ -167,6 +313,7 @@ export function SourcePluginsIndexPage() { · 1 unmodified line
167 313
168export function SourcePluginAddPage(props: {314export function SourcePluginAddPage(props: {
169 definitionKey: string;315 definitionKey: string;
316 search?: SourcePluginRouteSearch;
170 navigation: SourcePluginNavigation;317 navigation: SourcePluginNavigation;
171}) {318}) {
172 const entry = getSourceFrontendTypeEntryByKey(props.definitionKey);319 const entry = getSourceFrontendTypeEntryByKey(props.definitionKey);
@@ -175,6 +322,7 @@ export function SourcePluginAddPage(props: { · 2 unmodified lines
175 }322 }
176 323
177 const AddPage = entry.definition.renderAddPage;324 const AddPage = entry.definition.renderAddPage;
325 const search = props.search ?? {};
178 326
179 return (327 return (
180 <SourcePluginRouteProvider328 <SourcePluginRouteProvider
@@ -182,27 +330,26 @@ export function SourcePluginAddPage(props: { · 1 unmodified line
182 plugin: entry.plugin,330 plugin: entry.plugin,
183 definition: entry.definition,331 definition: entry.definition,
184 params: {},332 params: {},
185 search: {},333 search,
186 navigation: props.navigation,334 navigation: props.navigation,
187 }}335 }}
188 >336 >
189 <div className="mx-auto max-w-4xl px-6 py-10 lg:px-10 lg:py-14">337 <div className="min-h-0 flex-1 overflow-y-auto">
190 <div className="mb-8">338 <div className="mx-auto max-w-4xl px-6 py-10 lg:px-10 lg:py-14">
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">339 <div className="mb-8">
192 Source Plugin340 <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 )}
193 </div>348 </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>
203 349
204 <SourcePluginPicker activeKey={entry.definition.key} />350 <SourcePluginPicker activeKey={entry.definition.key} />
205 <AddPage />351 <AddPage key={JSON.stringify(search)} />
352 </div>
206 </div>353 </div>
207 </SourcePluginRouteProvider>354 </SourcePluginRouteProvider>
208 );355 );
@@ -229,8 +376,8 @@ export function SourcePluginEditPage(input: { · 20 unmodified lines
229 if (entry === null) {376 if (entry === null) {
230 return (377 return (
231 <SourcePluginsResetState378 <SourcePluginsResetState
232 title="Source plugin is unavailable"379 title="Source type unavailable"
233 message={`No frontend source plugin is registered for kind "${loadedSource.kind}".`}380 message={`No plugin is available for source type "${loadedSource.kind}".`}
234 />381 />
235 );382 );
236 }383 }
@@ -247,8 +394,8 @@ export function SourcePluginEditPage(input: { · 10 unmodified lines
247 if (entry.definition.renderEditPage === undefined) {394 if (entry.definition.renderEditPage === undefined) {
248 return (395 return (
249 <SourcePluginsResetState396 <SourcePluginsResetState
250 title="Source editing is disabled"397 title="Editing unavailable"
251 message={`No frontend source plugin is registered for kind "${loadedSource.kind}".`}398 message={`No plugin is available for source type "${loadedSource.kind}".`}
252 />399 />
253 );400 );
254 }401 }
@@ -298,8 +445,8 @@ export function SourcePluginDetailPage(input: { · 43 unmodified lines
298 if (entry === null) {445 if (entry === null) {
299 return (446 return (
300 <SourcePluginsResetState447 <SourcePluginsResetState
301 title="Source plugin is unavailable"448 title="Source type unavailable"
302 message={`No frontend source plugin is registered for kind "${loadedSource.kind}".`}449 message={`No plugin is available for source type "${loadedSource.kind}".`}
303 />450 />
304 );451 );
305 }452 }
@@ -316,8 +463,8 @@ export function SourcePluginDetailPage(input: { · 10 unmodified lines
316 if (entry.definition.renderDetailPage === undefined) {463 if (entry.definition.renderDetailPage === undefined) {
317 return (464 return (
318 <SourcePluginsResetState465 <SourcePluginsResetState
319 title="Source detail is disabled"466 title="Details unavailable"
320 message={`No frontend source plugin is registered for kind "${loadedSource.kind}".`}467 message={`No plugin is available for source type "${loadedSource.kind}".`}
321 />468 />
322 );469 );
323 }470 }
@@ -368,8 +515,8 @@ export function SourcePluginDetailChildPage(input: { · 44 unmodified lines
368 if (entry === null) {515 if (entry === null) {
369 return (516 return (
370 <SourcePluginsResetState517 <SourcePluginsResetState
371 title="Source plugin is unavailable"518 title="Source type unavailable"
372 message={`No frontend source plugin is registered for kind "${loadedSource.kind}".`}519 message={`No plugin is available for source type "${loadedSource.kind}".`}
373 />520 />
374 );521 );
375 }522 }
@@ -390,8 +537,8 @@ export function SourcePluginDetailChildPage(input: { · 14 unmodified lines
390 if (!detailRoute) {537 if (!detailRoute) {
391 return (538 return (
392 <SourcePluginsResetState539 <SourcePluginsResetState
393 title="Source route is unavailable"540 title="Route unavailable"
394 message={`No plugin-owned detail route is registered for "${input.routeKey}".`}541 message={`The route "${input.routeKey}" is not available for this source type.`}
395 />542 />
396 );543 );
397 }544 }
apps/web/src/plugins/source-presets.ts+657−0
@@ -0,0 +1,657 @@
1const GOOGLE_DISCOVERY_PLUGIN_KEY = "google-discovery" as const;
2const GOOGLE_DISCOVERY_SOURCE_KIND = "google_discovery" as const;
3
4export 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
50const 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
69export 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
360export 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
399const 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
412const tryParseUrl = (value: string): URL | null => {
413 try {
414 return new URL(value.trim());
415 } catch {
416 return null;
417 }
418};
419
420const 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
434const 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
477const 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
488const 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
494const 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
505const 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
537export type ResolvedQuickSourceInput = {
538 pluginKey: SourcePreset["pluginKey"];
539 search: Record<string, string>;
540};
541
542export 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
2import { useSources } from "@executor/react";2import { useSources } from "@executor/react";
3import { sourcePluginsIndexPath } from "@executor/react/plugins";3import { sourcePluginsIndexPath } from "@executor/react/plugins";
4import { LoadableBlock } from "../components/loadable";4import { LoadableBlock } from "../components/loadable";
5import { LocalMcpInstallCard } from "../components/local-mcp-install-card";
6import { SourceFavicon } from "../components/source-favicon";
5import { Badge } from "../components/ui/badge";7import { Badge } from "../components/ui/badge";
6import { Button } from "../components/ui/button";8import { Button } from "../components/ui/button";
7import { SourcePluginsResetState } from "../components/source-plugins-reset-state";9import { IconSources, IconPlus } from "../components/icons";
8import {10import {
9 getSourceFrontendPaths,11 getSourceFrontendPaths,
10 registeredSourceFrontendTypes,
11} from "../plugins";12} from "../plugins";
12 13
14const statusVariant = (status: string) =>
15 status === "connected"
16 ? "default" as const
17 : status === "error"
18 ? "destructive" as const
19 : "muted" as const;
20
13export function HomePage() {21export function HomePage() {
14 const sources = useSources();22 const sources = useSources();
15 23
16 return (24 return (
17 <div className="flex-1 overflow-y-auto">25 <div className="min-h-0 flex-1 overflow-y-auto">
18 <div className="mx-auto max-w-4xl px-6 py-10 lg:px-10 lg:py-14">26 <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">27 {/* Header */}
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">28 <div className="flex items-end justify-between mb-8">
21 Sources29 <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>
22 </div>36 </div>
23 <h1 className="mt-5 font-display text-3xl tracking-tight text-foreground lg:text-4xl">37 <Link to={sourcePluginsIndexPath}>
24 Source plugins now own source-specific product behavior38 <Button size="sm">
25 </h1>39 <IconPlus className="size-3.5" />
26 <p className="mt-3 max-w-3xl text-sm leading-6 text-muted-foreground">40 Add source
27 Add, edit, inspect, and authorize sources through plugin-owned UI and41 </Button>
28 API surfaces. The core app stays generic while each plugin carries its42 </Link>
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 )}
40 </div>43 </div>
41 44
42 <div className="mt-8">45 <LocalMcpInstallCard className="mb-8" />
43 <LoadableBlock loadable={sources} loading="Loading sources...">46
44 {(items) =>47 {/* Source list */}
45 !Array.isArray(items) ? (48 <LoadableBlock loadable={sources} loading="Loading sources...">
46 <SourcePluginsResetState49 {(items) =>
47 title="Unexpected sources payload"50 !Array.isArray(items) ? (
48 message="The sources list returned data in an unexpected shape."51 <div className="rounded-xl border border-destructive/20 bg-destructive/5 px-5 py-4 text-sm text-destructive">
49 />52 Sources returned an unexpected payload.
50 ) : items.length === 0 ? (53 </div>
51 <SourcePluginsResetState54 ) : items.length === 0 ? (
52 title="No sources connected yet"55 <div className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-border py-20">
53 message="Use the add flow to connect an OpenAPI, GraphQL, MCP, or Google Discovery source through its plugin-owned surface."56 <div className="flex size-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground mb-4">
54 action={(57 <IconSources className="size-5" />
55 <Link to={sourcePluginsIndexPath}>58 </div>
56 <Button size="sm" variant="outline">59 <p className="text-[14px] font-medium text-foreground/70 mb-1">
57 Add Source60 No sources yet
58 </Button>61 </p>
59 </Link>62 <p className="text-[13px] text-muted-foreground/60 mb-5">
60 )}63 Add a source to get started.
61 />64 </p>
62 ) : (65 <Link to={sourcePluginsIndexPath}>
63 <div className="grid gap-3">66 <Button size="sm">
64 {items.map((source) => {67 <IconPlus className="size-3.5" />
65 const paths = getSourceFrontendPaths(source.kind);68 Add source
66 const card = (69 </Button>
67 <div className="rounded-2xl border border-border bg-card px-5 py-4 transition-colors hover:border-primary/25 hover:bg-card/90">70 </Link>
68 <div className="flex items-start justify-between gap-3">71 </div>
69 <div>72 ) : (
70 <div className="text-sm font-semibold text-foreground">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">
71 {source.name}85 {source.name}
72 </div>86 </div>
73 <div className="mt-1 text-xs text-muted-foreground">87 <Badge variant={statusVariant(source.status)} className="shrink-0">
74 {source.kind}88 {source.status}
75 </div>89 </Badge>
90 </div>
91 <div className="mt-0.5 text-xs text-muted-foreground">
92 {source.kind}
76 </div>93 </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>
88 </div>94 </div>
89 </div>95 </div>
90 );96 </div>
91 97 );
92 if (!paths) {
93 return (
94 <div key={source.id}>
95 {card}
96 </div>
97 );
98 }
99 98
99 if (!paths) {
100 return (100 return (
101 <Link101 <div key={source.id}>
102 key={source.id}
103 to={paths.detail(source.id)}
104 search={{ tab: "model" }}
105 >
106 {card}102 {card}
107 </Link>103 </div>
108 );104 );
109 })}105 }
110 </div>106
111 )107 return (
112 }108 <Link
113 </LoadableBlock>109 key={source.id}
114 </div>110 to={paths.detail(source.id)}
111 search={{ tab: "model" }}
112 >
113 {card}
114 </Link>
115 );
116 })}
117 </div>
118 )
119 }
120 </LoadableBlock>
115 </div>121 </div>
116 </div>122 </div>
117 );123 );
apps/web/src/views/secrets.tsx+39−15
@@ -42,7 +42,7 @@ export function SecretsPage() { · 41 unmodified lines
42 : null;42 : null;
43 43
44 return (44 return (
45 <div className="flex-1 overflow-y-auto">45 <div className="min-h-0 flex-1 overflow-y-auto">
46 <div className="mx-auto max-w-3xl px-6 py-10 lg:px-10 lg:py-14">46 <div className="mx-auto max-w-3xl px-6 py-10 lg:px-10 lg:py-14">
47 {/* Header */}47 {/* Header */}
48 <div className="flex items-end justify-between mb-8">48 <div className="flex items-end justify-between mb-8">
@@ -259,19 +259,16 @@ function SecretRow(props: { · 210 unmodified lines
259 const { secret, providerLabel, isEditing } = props;259 const { secret, providerLabel, isEditing } = props;
260 const deleteSecret = useDeleteSecret();260 const deleteSecret = useDeleteSecret();
261 const [isDeleting, setIsDeleting] = useState(false);261 const [isDeleting, setIsDeleting] = useState(false);
262 const [confirmDelete, setConfirmDelete] = useState(false);
262 263
263 const handleDelete = async () => {264 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
269 setIsDeleting(true);265 setIsDeleting(true);
270 try {266 try {
271 await deleteSecret.mutateAsync(secret.id);267 await deleteSecret.mutateAsync(secret.id);
272 } catch {268 } catch {
273 // refresh will show the secret still there269 // refresh will show the secret still there
274 } finally {270 } finally {
271 setConfirmDelete(false);
275 setIsDeleting(false);272 setIsDeleting(false);
276 }273 }
277 };274 };
@@ -338,15 +335,42 @@ function SecretRow(props: { · 60 unmodified lines
338 <IconPencil className="size-3" />335 <IconPencil className="size-3" />
339 Edit336 Edit
340 </button>337 </button>
341 <button338 {confirmDelete ? (
342 type="button"339 <>
343 onClick={handleDelete}340 <span className="text-[10px] font-medium text-destructive">
344 disabled={isDeleting}341 Confirm delete?
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"342 </span>
346 >343 <button
347 {isDeleting ? <IconSpinner className="size-3" /> : <IconTrash className="size-3" />}344 type="button"
348 Delete345 onClick={() => setConfirmDelete(false)}
349 </button>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 )}
350 </div>374 </div>
351 </div>375 </div>
352 376
opencode.json+11−5
@@ -1,10 +1,16 @@
1{1{
2 "$schema": "https://opencode.ai/config.json",
3 "mcp": {2 "mcp": {
4 "executor": {3 "executor-stdio": {
5 "type": "remote",4 "type": "local",
6 "url": "http://localhost:8788/mcp",5 "command": [
7 "enabled": true6 "bun",
7 "run",
8 "executor",
9 "mcp",
10 "--stdio"
11 ],
12 "enabled": true,
13 "environment": {}
8 }14 }
9 }15 }
10}16}
packages/platform/sdk/src/runtime/execution/scope/environment.ts+27−0
@@ -47,6 +47,16 @@ import { · 46 unmodified lines
47import {47import {
48 RuntimeSourceStoreService,48 RuntimeSourceStoreService,
49} from "../../sources/source-store";49} from "../../sources/source-store";
50import {
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";
50export {60export {
51 createCodeExecutorForRuntime,61 createCodeExecutorForRuntime,
52 resolveConfiguredExecutionRuntime,62 resolveConfiguredExecutionRuntime,
@@ -74,6 +84,12 @@ export const createScopeExecutionEnvironmentResolver = · 21 unmodified lines
74 scopeConfigStore: ScopeConfigStoreShape;84 scopeConfigStore: ScopeConfigStoreShape;
75 scopeStateStore: ScopeStateStoreShape;85 scopeStateStore: ScopeStateStoreShape;
76 sourceArtifactStore: SourceArtifactStoreShape;86 sourceArtifactStore: SourceArtifactStoreShape;
87 secretMaterialServices: {
88 resolve: ResolveSecretMaterial;
89 store: StoreSecretMaterial;
90 delete: DeleteSecretMaterial;
91 update: UpdateSecretMaterial;
92 };
77 }): ResolveExecutionEnvironment =>93 }): ResolveExecutionEnvironment =>
78 ({ scopeId, actorScopeId, onElicitation }) =>94 ({ scopeId, actorScopeId, onElicitation }) =>
79 Effect.gen(function* () {95 Effect.gen(function* () {
@@ -99,6 +115,7 @@ export const createScopeExecutionEnvironmentResolver = · 19 unmodified lines
99 sourceArtifactStore: input.sourceArtifactStore,115 sourceArtifactStore: input.sourceArtifactStore,
100 runtimeLocalScope,116 runtimeLocalScope,
101 localToolRuntime,117 localToolRuntime,
118 secretMaterialServices: input.secretMaterialServices,
102 onElicitation,119 onElicitation,
103 });120 });
104 121
@@ -140,6 +157,10 @@ export const RuntimeExecutionResolverLive = ( · 35 unmodified lines
140 const scopeConfigStore = yield* ScopeConfigStore;157 const scopeConfigStore = yield* ScopeConfigStore;
141 const scopeStateStore = yield* ScopeStateStore;158 const scopeStateStore = yield* ScopeStateStore;
142 const sourceArtifactStore = yield* SourceArtifactStore;159 const sourceArtifactStore = yield* SourceArtifactStore;
160 const resolveSecretMaterial = yield* SecretMaterialResolverService;
161 const storeSecretMaterial = yield* SecretMaterialStorerService;
162 const deleteSecretMaterial = yield* SecretMaterialDeleterService;
163 const updateSecretMaterial = yield* SecretMaterialUpdaterService;
143 164
144 return createScopeExecutionEnvironmentResolver({165 return createScopeExecutionEnvironmentResolver({
145 executorStateStore,166 executorStateStore,
@@ -151,6 +172,12 @@ export const RuntimeExecutionResolverLive = ( · 5 unmodified lines
151 scopeConfigStore,172 scopeConfigStore,
152 scopeStateStore,173 scopeStateStore,
153 sourceArtifactStore,174 sourceArtifactStore,
175 secretMaterialServices: {
176 resolve: resolveSecretMaterial,
177 store: storeSecretMaterial,
178 delete: deleteSecretMaterial,
179 update: updateSecretMaterial,
180 },
154 });181 });
155 }),182 }),
156 );183 );
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
17import {17import {
18 RuntimeSourceCatalogStoreService,18 RuntimeSourceCatalogStoreService,
19} from "../../catalog/source/runtime";19} from "../../catalog/source/runtime";
20import {
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";
20import type {30import type {
21 RuntimeLocalScopeState,31 RuntimeLocalScopeState,
22} from "../../scope/runtime-context";32} from "../../scope/runtime-context";
@@ -59,6 +69,7 @@ import { · 36 unmodified lines
59import {69import {
60 runtimeEffectError,70 runtimeEffectError,
61} from "../../effect-errors";71} from "../../effect-errors";
72import * as Layer from "effect/Layer";
62 73
63export const createScopeToolInvoker = (input: {74export const createScopeToolInvoker = (input: {
64 scopeId: Source["scopeId"];75 scopeId: Source["scopeId"];
@@ -77,6 +88,12 @@ export const createScopeToolInvoker = (input: { · 12 unmodified lines
77 sourceArtifactStore: SourceArtifactStoreShape;88 sourceArtifactStore: SourceArtifactStoreShape;
78 runtimeLocalScope: RuntimeLocalScopeState | null;89 runtimeLocalScope: RuntimeLocalScopeState | null;
79 localToolRuntime: LocalToolRuntime;90 localToolRuntime: LocalToolRuntime;
91 secretMaterialServices: {
92 resolve: ResolveSecretMaterial;
93 store: StoreSecretMaterial;
94 delete: DeleteSecretMaterial;
95 update: UpdateSecretMaterial;
96 };
80 onElicitation?: Parameters<97 onElicitation?: Parameters<
81 typeof makeToolInvokerFromTools98 typeof makeToolInvokerFromTools
82 >[0]["onElicitation"];99 >[0]["onElicitation"];
@@ -89,8 +106,17 @@ export const createScopeToolInvoker = (input: { · 6 unmodified lines
89 scopeStateStore: input.scopeStateStore,106 scopeStateStore: input.scopeStateStore,
90 sourceArtifactStore: input.sourceArtifactStore,107 sourceArtifactStore: input.sourceArtifactStore,
91 });108 });
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 );
92 const provideWorkspaceStorage = <A, E, R>(effect: Effect.Effect<A, E, R>) =>115 const provideWorkspaceStorage = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
93 effect.pipe(Effect.provide(scopeStorageLayer));116 effect.pipe(Effect.provide(scopeStorageLayer));
117 const provideSecretMaterialServices = <A, E, R>(
118 effect: Effect.Effect<A, E, R>,
119 ) => effect.pipe(Effect.provide(secretMaterialLayer));
94 120
95 const executorTools = createExecutorToolMap({121 const executorTools = createExecutorToolMap({
96 scopeId: input.scopeId,122 scopeId: input.scopeId,
@@ -146,7 +172,8 @@ export const createScopeToolInvoker = (input: { · 49 unmodified lines
146 context?: Record<string, unknown>;172 context?: Record<string, unknown>;
147 }) =>173 }) =>
148 provideRuntimeLocalScope(174 provideRuntimeLocalScope(
149 provideWorkspaceStorage(175 provideSecretMaterialServices(
176 provideWorkspaceStorage(
150 Effect.gen(function* () {177 Effect.gen(function* () {
151 const catalogTool = yield* loadWorkspaceCatalogToolByPath({178 const catalogTool = yield* loadWorkspaceCatalogToolByPath({
152 scopeId: input.scopeId,179 scopeId: input.scopeId,
@@ -179,6 +206,7 @@ export const createScopeToolInvoker = (input: { · 26 unmodified lines
179 context: invocation.context,206 context: invocation.context,
180 });207 });
181 }),208 }),
209 ),
182 ),210 ),
183 input.runtimeLocalScope,211 input.runtimeLocalScope,
184 );212 );
plugins/google-discovery/http/index.ts+42−6
@@ -25,6 +25,7 @@ import * as Effect from "effect/Effect"; · 24 unmodified lines
25import * as Schema from "effect/Schema";25import * as Schema from "effect/Schema";
26 26
27import {27import {
28 GoogleDiscoveryStartBatchOAuthInputSchema,
28 GOOGLE_DISCOVERY_EXECUTOR_KEY,29 GOOGLE_DISCOVERY_EXECUTOR_KEY,
29 GOOGLE_DISCOVERY_OAUTH_STORAGE_PREFIX,30 GOOGLE_DISCOVERY_OAUTH_STORAGE_PREFIX,
30 GOOGLE_DISCOVERY_PLUGIN_KEY,31 GOOGLE_DISCOVERY_PLUGIN_KEY,
@@ -36,6 +37,7 @@ import { · 5 unmodified lines
36 type GoogleDiscoveryConnectInput,37 type GoogleDiscoveryConnectInput,
37 type GoogleDiscoveryOAuthPopupResult,38 type GoogleDiscoveryOAuthPopupResult,
38 type GoogleDiscoverySourceConfigPayload,39 type GoogleDiscoverySourceConfigPayload,
40 type GoogleDiscoveryStartBatchOAuthInput,
39 type GoogleDiscoveryStartOAuthInput,41 type GoogleDiscoveryStartOAuthInput,
40 type GoogleDiscoveryStartOAuthResult,42 type GoogleDiscoveryStartOAuthResult,
41 type GoogleDiscoveryUpdateSourceInput,43 type GoogleDiscoveryUpdateSourceInput,
@@ -58,6 +60,9 @@ type GoogleDiscoveryExecutorExtension = { · 16 unmodified lines
58 startOAuth: (60 startOAuth: (
59 input: GoogleDiscoveryStartOAuthInput,61 input: GoogleDiscoveryStartOAuthInput,
60 ) => Effect.Effect<GoogleDiscoveryStartOAuthResult, Error>;62 ) => Effect.Effect<GoogleDiscoveryStartOAuthResult, Error>;
63 startBatchOAuth: (
64 input: GoogleDiscoveryStartBatchOAuthInput,
65 ) => Effect.Effect<GoogleDiscoveryStartOAuthResult, Error>;
61 completeOAuth: (input: {66 completeOAuth: (input: {
62 state: string;67 state: string;
63 code?: string;68 code?: string;
@@ -122,6 +127,14 @@ export const GoogleDiscoveryHttpGroup = HttpApiGroup.make(GOOGLE_DISCOVERY_PLUGI · 58 unmodified lines
122 .addError(ControlPlaneForbiddenError)127 .addError(ControlPlaneForbiddenError)
123 .addError(ControlPlaneStorageError),128 .addError(ControlPlaneStorageError),
124 )129 )
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 )
125 .add(138 .add(
126 HttpApiEndpoint.get("oauthCallback")`/plugins/google-discovery/oauth/callback`139 HttpApiEndpoint.get("oauthCallback")`/plugins/google-discovery/oauth/callback`
127 .setUrlParams(callbackParamsSchema)140 .setUrlParams(callbackParamsSchema)
@@ -181,10 +194,13 @@ const popupDocument = (payload: GoogleDiscoveryOAuthPopupResult): string => { · 53 unmodified lines
181 .replaceAll("<", "\\u003c")194 .replaceAll("<", "\\u003c")
182 .replaceAll(">", "\\u003e")195 .replaceAll(">", "\\u003e")
183 .replaceAll("&", "\\u0026");196 .replaceAll("&", "\\u0026");
184 const title = payload.ok ? "Google OAuth connected" : "Google OAuth failed";197 const title = payload.ok ? "Connected" : "Connection failed";
185 const message = payload.ok198 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."
187 : payload.error;202 : payload.error;
203 const statusColor = payload.ok ? "#22c55e" : "#ef4444";
188 204
189 return `<!doctype html>205 return `<!doctype html>
190<html lang="en">206<html lang="en">
@@ -193,10 +209,17 @@ const popupDocument = (payload: GoogleDiscoveryOAuthPopupResult): string => { · 2 unmodified lines
193 <meta name="viewport" content="width=device-width, initial-scale=1" />209 <meta name="viewport" content="width=device-width, initial-scale=1" />
194 <title>${escapeHtml(title)}</title>210 <title>${escapeHtml(title)}</title>
195 </head>211 </head>
196 <body>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;">
197 <main>213 <main style="text-align: center; max-width: 360px; padding: 24px;">
198 <h1>${escapeHtml(title)}</h1>214 <div style="width: 40px; height: 40px; border-radius: 50%; background: ${statusColor}; margin: 0 auto 16px; display: flex; align-items: center; justify-content: center;">
199 <p>${escapeHtml(message)}</p>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>
200 </main>223 </main>
201 <script>224 <script>
202 (() => {225 (() => {
@@ -299,6 +322,19 @@ export const googleDiscoveryHttpPlugin = (): ExecutorHttpPlugin< · 96 unmodified lines
299 ),322 ),
300 )323 )
301 )324 )
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 )
302 .handle("oauthCallback", ({ urlParams }) =>338 .handle("oauthCallback", ({ urlParams }) =>
303 executor[GOOGLE_DISCOVERY_EXECUTOR_KEY].completeOAuth({339 executor[GOOGLE_DISCOVERY_EXECUTOR_KEY].completeOAuth({
304 state: urlParams.state,340 state: urlParams.state,
plugins/google-discovery/react/index.tsx+855−165
@@ -5,6 +5,7 @@ import { · 4 unmodified lines
5 Result,5 Result,
6 useAtomSet,6 useAtomSet,
7 useAtomValue,7 useAtomValue,
8 useCreateSecret,
8 useExecutorMutation,9 useExecutorMutation,
9 useLocalInstallation,10 useLocalInstallation,
10 useSecrets,11 useSecrets,
@@ -29,9 +30,11 @@ import { · 18 unmodified lines
29 GOOGLE_DISCOVERY_PLUGIN_KEY,30 GOOGLE_DISCOVERY_PLUGIN_KEY,
30 GOOGLE_DISCOVERY_SOURCE_KIND,31 GOOGLE_DISCOVERY_SOURCE_KIND,
31 defaultGoogleDiscoveryUrl,32 defaultGoogleDiscoveryUrl,
33 type GoogleDiscoveryBatchSourceInput,
32 type GoogleDiscoveryConnectInput,34 type GoogleDiscoveryConnectInput,
33 type GoogleDiscoveryConnectionAuth,35 type GoogleDiscoveryConnectionAuth,
34 type GoogleDiscoveryOAuthPopupResult,36 type GoogleDiscoveryOAuthPopupResult,
37 type GoogleDiscoveryStartBatchOAuthInput,
35 type GoogleDiscoveryStartOAuthInput,38 type GoogleDiscoveryStartOAuthInput,
36} from "@executor/plugin-google-discovery-shared";39} from "@executor/plugin-google-discovery-shared";
37 40
@@ -94,6 +97,266 @@ const defaultGoogleInput = (): GoogleDiscoveryConnectInput => ({ · 56 unmodified lines
94 },97 },
95});98});
96 99
100type GoogleDiscoveryBatchTemplate = {
101 id: string;
102 name: string;
103 summary: string;
104 service: string;
105 version: string;
106 discoveryUrl: string;
107};
108
109const googleDiscoveryTemplate = (
110 input: GoogleDiscoveryBatchTemplate,
111): GoogleDiscoveryBatchTemplate => ({
112 ...input,
113 discoveryUrl: input.discoveryUrl || defaultGoogleDiscoveryUrl(input.service, input.version),
114});
115
116const 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
271const 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
281const 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
327const 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
97const stringifyStringMap = (360const stringifyStringMap = (
98 value: Record<string, string> | null | undefined,361 value: Record<string, string> | null | undefined,
99): string =>362): string =>
@@ -201,6 +464,167 @@ const waitForOauthPopupResult = async ( · 101 unmodified lines
201 }, 400);464 }, 400);
202 });465 });
203 466
467const 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
489const CREATE_SECRET_VALUE = "__create_google_secret__";
490
491function 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
617const 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
204function GoogleDiscoverySourceForm(props: {628function GoogleDiscoverySourceForm(props: {
205 initialValue: GoogleDiscoveryConnectInput;629 initialValue: GoogleDiscoveryConnectInput;
206 mode: "create" | "edit";630 mode: "create" | "edit";
@@ -208,7 +632,6 @@ function GoogleDiscoverySourceForm(props: { · 1 unmodified line
208}) {632}) {
209 const installation = useLocalInstallation();633 const installation = useLocalInstallation();
210 const client = getGoogleDiscoveryHttpClient();634 const client = getGoogleDiscoveryHttpClient();
211 const secrets = useSecrets();
212 const startOAuth = useAtomSet(635 const startOAuth = useAtomSet(
213 client.mutation(GOOGLE_DISCOVERY_PLUGIN_KEY, "startOAuth"),636 client.mutation(GOOGLE_DISCOVERY_PLUGIN_KEY, "startOAuth"),
214 { mode: "promise" },637 { mode: "promise" },
@@ -249,6 +672,11 @@ function GoogleDiscoverySourceForm(props: { · 34 unmodified lines
249 throw new Error("Workspace is still loading.");672 throw new Error("Workspace is still loading.");
250 }673 }
251 674
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
252 const payload: GoogleDiscoveryStartOAuthInput = {680 const payload: GoogleDiscoveryStartOAuthInput = {
253 service: service.trim(),681 service: service.trim(),
254 version: version.trim(),682 version: version.trim(),
@@ -266,26 +694,29 @@ function GoogleDiscoverySourceForm(props: { · 11 unmodified lines
266 ).toString(),694 ).toString(),
267 };695 };
268 696
269 const started = await startOAuth({697 let started;
270 path: {698 try {
271 workspaceId: installation.data.scopeId,699 started = await startOAuth({
272 },700 path: {
273 payload,701 workspaceId: installation.data.scopeId,
274 });702 },
275 703 payload,
276 const popup = window.open(704 });
277 started.authorizationUrl,705 } catch (cause) {
278 "executor-google-discovery-oauth",706 popup.close();
279 "width=560,height=760,noopener,noreferrer",707 throw cause;
280 );
281 if (!popup) {
282 throw new Error("Failed opening Google OAuth popup.");
283 }708 }
284 709
710 popup.location.replace(started.authorizationUrl);
711 popup.focus();
712
285 const result = await waitForOauthPopupResult(started.sessionId);713 const result = await waitForOauthPopupResult(started.sessionId);
286 if (!result.ok) {714 if (!result.ok) {
287 throw new Error(result.error);715 throw new Error(result.error);
288 }716 }
717 if (result.mode !== "single") {
718 throw new Error("Unexpected batch OAuth result for single-source Google setup.");
719 }
289 720
290 setScopesText(stringifyScopes(result.auth.scopes));721 setScopesText(stringifyScopes(result.auth.scopes));
291 setOauthAuth(result.auth);722 setOauthAuth(result.auth);
@@ -293,82 +724,71 @@ function GoogleDiscoverySourceForm(props: { · 1 unmodified line
293 };724 };
294 725
295 return (726 return (
296 <div className="space-y-4 rounded-2xl border border-border bg-card p-5">727 <div className="space-y-6 rounded-xl border border-border p-6">
297 <div>728 <div className="grid gap-4 sm:grid-cols-2">
298 <h2 className="text-lg font-semibold text-foreground">729 <label className="grid gap-2 sm:col-span-2">
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">
310 <span className="text-xs font-medium text-foreground">Name</span>730 <span className="text-xs font-medium text-foreground">Name</span>
311 <input731 <input
312 value={name}732 value={name}
313 onChange={(event) => setName(event.target.value)}733 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"
315 />735 />
316 </label>736 </label>
317 <label className="grid gap-1.5">737 <label className="grid gap-2">
318 <span className="text-xs font-medium text-foreground">Service</span>738 <span className="text-xs font-medium text-foreground">Service</span>
319 <input739 <input
320 value={service}740 value={service}
321 onChange={(event) => setService(event.target.value)}741 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"
323 />743 />
324 </label>744 </label>
325 <label className="grid gap-1.5">745 <label className="grid gap-2">
326 <span className="text-xs font-medium text-foreground">Version</span>746 <span className="text-xs font-medium text-foreground">Version</span>
327 <input747 <input
328 value={version}748 value={version}
329 onChange={(event) => setVersion(event.target.value)}749 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"
331 />751 />
332 </label>752 </label>
333 <label className="grid gap-1.5">753 <label className="grid gap-2 sm:col-span-2">
334 <span className="text-xs font-medium text-foreground">Discovery URL</span>754 <span className="text-xs font-medium text-foreground">Discovery URL</span>
335 <input755 <input
336 value={discoveryUrl}756 value={discoveryUrl}
337 onChange={(event) => setDiscoveryUrl(event.target.value)}757 onChange={(event) => setDiscoveryUrl(event.target.value)}
338 placeholder={defaultGoogleDiscoveryUrl(service || "sheets", version || "v4")}758 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"
340 />760 />
341 </label>761 </label>
342 </div>762 </div>
343 763
344 <label className="grid gap-1.5">764 <label className="grid gap-2">
345 <span className="text-xs font-medium text-foreground">Default Headers</span>765 <span className="text-xs font-medium text-foreground">Default Headers</span>
346 <textarea766 <textarea
347 value={headersText}767 value={headersText}
348 onChange={(event) => setHeadersText(event.target.value)}768 onChange={(event) => setHeadersText(event.target.value)}
349 rows={4}769 rows={3}
350 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"770 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"
351 />771 />
352 </label>772 </label>
353 773
354 <label className="grid gap-1.5">774 <label className="grid gap-2">
355 <span className="text-xs font-medium text-foreground">Scopes</span>775 <span className="text-xs font-medium text-foreground">Scopes</span>
356 <textarea776 <textarea
357 value={scopesText}777 value={scopesText}
358 onChange={(event) => setScopesText(event.target.value)}778 onChange={(event) => setScopesText(event.target.value)}
359 rows={4}779 rows={3}
360 placeholder="Leave blank to infer from the discovery document."780 placeholder="Leave blank to infer from the discovery document."
361 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"781 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"
362 />782 />
363 </label>783 </label>
364 784
365 <label className="grid gap-1.5">785 <label className="grid gap-2">
366 <span className="text-xs font-medium text-foreground">Auth</span>786 <span className="text-xs font-medium text-foreground">Auth</span>
367 <select787 <select
368 value={authKind}788 value={authKind}
369 onChange={(event) =>789 onChange={(event) =>
370 setAuthKind(event.target.value as GoogleDiscoveryConnectionAuth["kind"])}790 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"
372 >792 >
373 <option value="none">None</option>793 <option value="none">None</option>
374 <option value="bearer">Bearer Secret</option>794 <option value="bearer">Bearer Secret</option>
@@ -377,62 +797,32 @@ function GoogleDiscoverySourceForm(props: { · 2 unmodified lines
377 </label>797 </label>
378 798
379 {authKind === "bearer" && (799 {authKind === "bearer" && (
380 <label className="grid gap-1.5">800 <div className="border-l-2 border-border pl-4">
381 <span className="text-xs font-medium text-foreground">Secret</span>801 <SecretSelectOrCreateField
382 <select802 label="Secret"
383 value={bearerSecretRef}803 value={bearerSecretRef}
384 onChange={(event) => setBearerSecretRef(event.target.value)}804 emptyLabel="Select a secret"
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"805 onChange={setBearerSecretRef}
386 >806 />
387 <option value="">Select a secret</option>807 </div>
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>
402 )}808 )}
403 809
404 {authKind === "oauth2" && (810 {authKind === "oauth2" && (
405 <div className="space-y-4 rounded-xl border border-border/70 bg-background/50 p-4">811 <div className="space-y-4 border-l-2 border-border pl-4">
406 <label className="grid gap-1.5">812 <label className="grid gap-2">
407 <span className="text-xs font-medium text-foreground">Client ID</span>813 <span className="text-xs font-medium text-foreground">Client ID</span>
408 <input814 <input
409 value={clientId}815 value={clientId}
410 onChange={(event) => setClientId(event.target.value)}816 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"
412 />818 />
413 </label>819 </label>
414 <label className="grid gap-1.5">820 <SecretSelectOrCreateField
415 <span className="text-xs font-medium text-foreground">Client Secret</span>821 label="Client Secret"
416 <select822 value={clientSecretRef}
417 value={clientSecretRef}823 emptyLabel="Public client / no secret"
418 onChange={(event) => setClientSecretRef(event.target.value)}824 onChange={setClientSecretRef}
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"825 />
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>
436 <div className="flex items-center gap-3">826 <div className="flex items-center gap-3">
437 <button827 <button
438 type="button"828 type="button"
@@ -455,25 +845,23 @@ function GoogleDiscoverySourceForm(props: { · 16 unmodified lines
455 {oauthStatus === "pending"845 {oauthStatus === "pending"
456 ? "Connecting..."846 ? "Connecting..."
457 : oauthStatus === "connected"847 : oauthStatus === "connected"
458 ? "Reconnect OAuth"848 ? "Reconnect"
459 : "Connect OAuth"}849 : "Connect with Google"}
460 </button>850 </button>
461 <div className="text-xs text-muted-foreground">851 {oauthStatus === "connected" && (
462 {oauthStatus === "connected"852 <span className="text-xs text-primary">Connected</span>
463 ? "OAuth tokens are ready and will be saved with this source."853 )}
464 : "The plugin opens Google OAuth in a popup and stores token refs locally."}
465 </div>
466 </div>854 </div>
467 </div>855 </div>
468 )}856 )}
469 857
470 {error && (858 {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">
472 {error}860 {error}
473 </div>861 </div>
474 )}862 )}
475 863
476 <div className="flex items-center gap-3">864 <div className="flex items-center justify-end">
477 <button865 <button
478 type="button"866 type="button"
479 onClick={() => {867 onClick={() => {
@@ -491,7 +879,7 @@ function GoogleDiscoverySourceForm(props: { · 11 unmodified lines
491 : oauthAuth879 : oauthAuth
492 ? oauthAuth880 ? oauthAuth
493 : (() => {881 : (() => {
494 throw new Error("Finish Google OAuth before saving.");882 throw new Error("Complete Google sign-in before saving.");
495 })();883 })();
496 884
497 await submitMutation.mutateAsync({885 await submitMutation.mutateAsync({
@@ -507,23 +895,271 @@ function GoogleDiscoverySourceForm(props: { · 9 unmodified lines
507 setError(895 setError(
508 cause instanceof Error896 cause instanceof Error
509 ? cause.message897 ? cause.message
510 : "Failed saving Google Discovery source.",898 : "Failed saving source.",
511 );899 );
512 }900 }
513 })();901 })();
514 }}902 }}
515 disabled={submitMutation.status === "pending"}903 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"
517 >905 >
518 {submitMutation.status === "pending"906 {submitMutation.status === "pending"
519 ? props.mode === "create" ? "Creating..." : "Saving..."907 ? props.mode === "create" ? "Creating..." : "Saving..."
520 : props.mode === "create" ? "Create Source" : "Save Changes"}908 : props.mode === "create" ? "Create Source" : "Save Changes"}
521 </button>909 </button>
910 </div>
911 </div>
912 );
913}
914
915function 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">
522 <div className="text-xs text-muted-foreground">1026 <div className="text-xs text-muted-foreground">
523 {props.mode === "create"1027 {selectedTemplates.length === 0
524 ? "The discovery document is imported as soon as the source is created."1028 ? "No Google APIs selected."
525 : "Saving refreshes the imported Google API model."}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}
526 </div>1141 </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>
527 </div>1163 </div>
528 </div>1164 </div>
529 );1165 );
@@ -531,6 +1167,7 @@ function GoogleDiscoverySourceForm(props: { · 1 unmodified line
531 1167
532function GoogleDiscoveryAddPage() {1168function GoogleDiscoveryAddPage() {
533 const navigation = useSourcePluginNavigation();1169 const navigation = useSourcePluginNavigation();
1170 const initialValue = googleInputFromSearch(useSourcePluginSearch());
534 const installation = useLocalInstallation();1171 const installation = useLocalInstallation();
535 const client = getGoogleDiscoveryHttpClient();1172 const client = getGoogleDiscoveryHttpClient();
536 const createSource = useAtomSet(1173 const createSource = useAtomSet(
@@ -543,27 +1180,52 @@ function GoogleDiscoveryAddPage() { · 6 unmodified lines
543 }1180 }
544 1181
545 return (1182 return (
546 <GoogleDiscoverySourceForm1183 <div className="space-y-6">
547 initialValue={defaultGoogleInput()}1184 <GoogleDiscoveryBatchConnectPanel
548 mode="create"1185 onConnected={(sources) => {
549 onSubmit={async (input) => {1186 startTransition(() => {
550 const source = await createSource({1187 if (sources.length === 1) {
551 path: {1188 void navigation.detail(sources[0]!.id, {
552 workspaceId: installation.data.scopeId,1189 tab: "model",
553 },1190 });
554 payload: input,1191 return;
555 reactivityKeys: {1192 }
556 sources: [installation.data.scopeId],
557 },
558 });
559 1193
560 startTransition(() => {1194 void navigation.home();
561 void navigation.detail(source.id, {
562 tab: "model",
563 });1195 });
564 });1196 }}
565 }}1197 />
566 />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>
567 );1229 );
568}1230}
569 1231
@@ -601,13 +1263,13 @@ function GoogleDiscoveryEditPage(props: { · 31 unmodified lines
601 if (Result.isFailure(configResult)) {1263 if (Result.isFailure(configResult)) {
602 return (1264 return (
603 <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-3 text-sm text-destructive">1265 <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.
605 </div>1267 </div>
606 );1268 );
607 }1269 }
608 1270
609 if (!Result.isSuccess(configResult)) {1271 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>;
611 }1273 }
612 1274
613 return (1275 return (
@@ -653,6 +1315,8 @@ function GoogleDiscoveryDetailPage(props: { · 39 unmodified lines
653 client.mutation(GOOGLE_DISCOVERY_PLUGIN_KEY, "removeSource"),1315 client.mutation(GOOGLE_DISCOVERY_PLUGIN_KEY, "removeSource"),
654 { mode: "promise" },1316 { mode: "promise" },
655 );1317 );
1318 const [confirmDelete, setConfirmDelete] = useState(false);
1319 const [isDeleting, setIsDeleting] = useState(false);
656 const configResult = useAtomValue(1320 const configResult = useAtomValue(
657 installation.status === "ready"1321 installation.status === "ready"
658 ? client.query(GOOGLE_DISCOVERY_PLUGIN_KEY, "getSourceConfig", {1322 ? client.query(GOOGLE_DISCOVERY_PLUGIN_KEY, "getSourceConfig", {
@@ -676,16 +1340,11 @@ function GoogleDiscoveryDetailPage(props: { · 17 unmodified lines
676 1340
677 const config = configResult.value;1341 const config = configResult.value;
678 return (1342 return (
679 <div className="space-y-1">1343 <div className="space-y-1 text-xs text-muted-foreground">
680 <div className="font-mono text-xs text-foreground">1344 <div className="text-foreground">{config.service} {config.version}</div>
1345 <div className="font-mono truncate">
681 {config.discoveryUrl ?? defaultGoogleDiscoveryUrl(config.service, config.version)}1346 {config.discoveryUrl ?? defaultGoogleDiscoveryUrl(config.service, config.version)}
682 </div>1347 </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>
689 </div>1348 </div>
690 );1349 );
691 }, [configResult]);1350 }, [configResult]);
@@ -694,6 +1353,35 @@ function GoogleDiscoveryDetailPage(props: { · 2 unmodified lines
694 return <div className="text-sm text-muted-foreground">Loading workspace...</div>;1353 return <div className="text-sm text-muted-foreground">Loading workspace...</div>;
695 }1354 }
696 1355
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
697 return (1385 return (
698 <SourceToolExplorer1386 <SourceToolExplorer
699 sourceId={props.source.id}1387 sourceId={props.source.id}
@@ -713,38 +1401,40 @@ function GoogleDiscoveryDetailPage(props: { · 13 unmodified lines
713 <IconPencil className="size-3.5" />1401 <IconPencil className="size-3.5" />
714 Edit1402 Edit
715 </button>1403 </button>
716 <button1404 {confirmDelete ? (
717 type="button"1405 <div className="flex items-center gap-2">
718 onClick={() => {1406 <span className="text-xs font-medium text-destructive">
719 const confirmed = window.confirm(1407 Confirm delete?
720 `Delete Google Discovery source "${props.source.name}"?`,1408 </span>
721 );1409 <button
722 if (!confirmed) {1410 type="button"
723 return;1411 onClick={() => setConfirmDelete(false)}
724 }1412 disabled={isDeleting}
725 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"
726 void removeSource({1414 >
727 path: {1415 Cancel
728 workspaceId: installation.data.scopeId,1416 </button>
729 sourceId: props.source.id,1417 <button
730 },1418 type="button"
731 reactivityKeys: {1419 onClick={() => {
732 sources: [installation.data.scopeId],1420 void handleDelete().catch(() => {});
733 source: [installation.data.scopeId, props.source.id],1421 }}
734 sourceInspection: [installation.data.scopeId, props.source.id],1422 disabled={isDeleting}
735 sourceInspectionTool: [installation.data.scopeId, props.source.id],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"
736 sourceDiscovery: [installation.data.scopeId, props.source.id],1424 >
737 },1425 {isDeleting ? "Deleting..." : "Delete"}
738 }).then(() => {1426 </button>
739 startTransition(() => {1427 </div>
740 void navigation.home();1428 ) : (
741 });1429 <button
742 });1430 type="button"
743 }}1431 onClick={() => setConfirmDelete(true)}
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"1432 disabled={isDeleting}
745 >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"
746 Delete1434 >
747 </button>1435 Delete
1436 </button>
1437 )}
748 </>1438 </>
749 )}1439 )}
750 />1440 />
@@ -755,7 +1445,7 @@ const googleDiscoverySourceType = defineFrontendSourceType({ · 4 unmodified lines
755 key: GOOGLE_DISCOVERY_PLUGIN_KEY,1445 key: GOOGLE_DISCOVERY_PLUGIN_KEY,
756 kind: GOOGLE_DISCOVERY_SOURCE_KIND,1446 kind: GOOGLE_DISCOVERY_SOURCE_KIND,
757 displayName: "Google Discovery",1447 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.",
759 getIconUrl: getGoogleDiscoveryIconUrl,1449 getIconUrl: getGoogleDiscoveryIconUrl,
760 renderAddPage: GoogleDiscoveryAddPage,1450 renderAddPage: GoogleDiscoveryAddPage,
761 renderEditPage: GoogleDiscoveryEditPage,1451 renderEditPage: GoogleDiscoveryEditPage,
plugins/google-discovery/sdk/index.ts+230−27
@@ -22,16 +22,19 @@ import { · 21 unmodified lines
22import {22import {
23 GOOGLE_DISCOVERY_EXECUTOR_KEY,23 GOOGLE_DISCOVERY_EXECUTOR_KEY,
24 GOOGLE_DISCOVERY_SOURCE_KIND,24 GOOGLE_DISCOVERY_SOURCE_KIND,
25 GoogleDiscoveryBatchSourceInputSchema,
25 GoogleDiscoveryConnectionAuthSchema,26 GoogleDiscoveryConnectionAuthSchema,
26 GoogleDiscoveryOAuthSessionSchema,27 GoogleDiscoveryOAuthSessionSchema,
27 defaultGoogleDiscoveryUrl,28 defaultGoogleDiscoveryUrl,
28 deriveGoogleDiscoveryNamespace,29 deriveGoogleDiscoveryNamespace,
29 GoogleDiscoveryStoredSourceDataSchema,30 GoogleDiscoveryStoredSourceDataSchema,
31 type GoogleDiscoveryBatchSourceInput,
30 type GoogleDiscoveryConnectInput,32 type GoogleDiscoveryConnectInput,
31 type GoogleDiscoveryConnectionAuth,33 type GoogleDiscoveryConnectionAuth,
32 type GoogleDiscoveryOAuthPopupResult,34 type GoogleDiscoveryOAuthPopupResult,
33 type GoogleDiscoveryOAuthSession,35 type GoogleDiscoveryOAuthSession,
34 type GoogleDiscoverySourceConfigPayload,36 type GoogleDiscoverySourceConfigPayload,
37 type GoogleDiscoveryStartBatchOAuthInput,
35 type GoogleDiscoveryStartOAuthInput,38 type GoogleDiscoveryStartOAuthInput,
36 type GoogleDiscoveryStartOAuthResult,39 type GoogleDiscoveryStartOAuthResult,
37 type GoogleDiscoveryStoredSourceData,40 type GoogleDiscoveryStoredSourceData,
@@ -64,6 +67,9 @@ import { · 26 unmodified lines
64const decodeStoredSourceData = Schema.decodeUnknownSync(67const decodeStoredSourceData = Schema.decodeUnknownSync(
65 GoogleDiscoveryStoredSourceDataSchema,68 GoogleDiscoveryStoredSourceDataSchema,
66);69);
70const decodeBatchSourceInput = Schema.decodeUnknownSync(
71 GoogleDiscoveryBatchSourceInputSchema,
72);
67const decodeSession = Schema.decodeUnknownSync(GoogleDiscoveryOAuthSessionSchema);73const decodeSession = Schema.decodeUnknownSync(GoogleDiscoveryOAuthSessionSchema);
68const decodeProviderData = Schema.decodeUnknownSync(GoogleDiscoveryToolProviderDataSchema);74const decodeProviderData = Schema.decodeUnknownSync(GoogleDiscoveryToolProviderDataSchema);
69 75
@@ -108,6 +114,9 @@ export type GoogleDiscoverySdk = { · 38 unmodified lines
108 startOAuth: (114 startOAuth: (
109 input: GoogleDiscoveryStartOAuthInput,115 input: GoogleDiscoveryStartOAuthInput,
110 ) => Effect.Effect<GoogleDiscoveryStartOAuthResult, Error>;116 ) => Effect.Effect<GoogleDiscoveryStartOAuthResult, Error>;
117 startBatchOAuth: (
118 input: GoogleDiscoveryStartBatchOAuthInput,
119 ) => Effect.Effect<GoogleDiscoveryStartOAuthResult, Error>;
111 completeOAuth: (input: {120 completeOAuth: (input: {
112 state: string;121 state: string;
113 code?: string;122 code?: string;
@@ -129,6 +138,10 @@ const GoogleDiscoveryExecutorAddInputSchema = Schema.Struct({ · 15 unmodified lines
129 138
130type GoogleDiscoveryExecutorAddInput =139type GoogleDiscoveryExecutorAddInput =
131 typeof GoogleDiscoveryExecutorAddInputSchema.Type;140 typeof GoogleDiscoveryExecutorAddInputSchema.Type;
141type GoogleDiscoveryOAuthAuth = Extract<
142 GoogleDiscoveryConnectionAuth,
143 { kind: "oauth2" }
144>;
132 145
133const GOOGLE_AUTHORIZATION_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth";146const GOOGLE_AUTHORIZATION_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth";
134const GOOGLE_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";147const GOOGLE_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
@@ -468,6 +481,70 @@ const sourceConfigFromStored = ( · 333 unmodified lines
468 auth: stored.auth,481 auth: stored.auth,
469});482});
470 483
484const 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
498const 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
531const 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
471const createGoogleDiscoverySourceSdk = (548const createGoogleDiscoverySourceSdk = (
472 options: {549 options: {
473 storage: GoogleDiscoverySourceStorage;550 storage: GoogleDiscoverySourceStorage;
@@ -801,6 +878,7 @@ export const googleDiscoverySdkPlugin = (options: { · 327 unmodified lines
801 yield* options.oauthSessions.put({878 yield* options.oauthSessions.put({
802 sessionId,879 sessionId,
803 value: decodeSession({880 value: decodeSession({
881 kind: "single",
804 service: input.service.trim(),882 service: input.service.trim(),
805 version: input.version.trim(),883 version: input.version.trim(),
806 discoveryUrl,884 discoveryUrl,
@@ -833,6 +911,65 @@ export const googleDiscoverySdkPlugin = (options: { · 26 unmodified lines
833 };911 };
834 }),912 }),
835 ),913 ),
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 ),
836 completeOAuth: (input) =>973 completeOAuth: (input) =>
837 provideRuntime(974 provideRuntime(
838 Effect.gen(function* () {975 Effect.gen(function* () {
@@ -870,18 +1007,94 @@ export const googleDiscoverySdkPlugin = (options: { · 31 unmodified lines
870 codeVerifier: session.codeVerifier,1007 codeVerifier: session.codeVerifier,
871 code: input.code,1008 code: input.code,
872 });1009 });
873 const accessTokenRef = yield* storeSecretMaterial({1010 const expiresAt =
874 purpose: "oauth_access_token",1011 typeof tokenResponse.expires_in === "number"
875 value: tokenResponse.access_token,1012 ? Date.now() + tokenResponse.expires_in * 1000
876 name: `${session.service} Google Access Token`,1013 : null;
877 });1014
878 const refreshTokenRef = tokenResponse.refresh_token1015 if (session.kind === "single") {
879 ? yield* storeSecretMaterial({1016 const accessTokenRef = yield* storeSecretMaterial({
880 purpose: "oauth_refresh_token",1017 purpose: "oauth_access_token",
881 value: tokenResponse.refresh_token,1018 value: tokenResponse.access_token,
882 name: `${session.service} Google Refresh Token`,1019 name: `${session.service} Google Access Token`,
883 })1020 });
884 : null;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 }
885 1098
886 if (options.oauthSessions.remove) {1099 if (options.oauthSessions.remove) {
887 yield* options.oauthSessions.remove(input.state);1100 yield* options.oauthSessions.remove(input.state);
@@ -891,21 +1104,11 @@ export const googleDiscoverySdkPlugin = (options: { · 3 unmodified lines
891 type: "executor:oauth-result",1104 type: "executor:oauth-result",
892 ok: true as const,1105 ok: true as const,
893 sessionId: input.state,1106 sessionId: input.state,
894 auth: {1107 mode: "batch" as const,
895 kind: "oauth2" as const,1108 sources: createdSources.map((source) => ({
896 clientId: session.clientId,1109 id: source.id,
897 clientSecretRef: session.clientSecretRef,1110 name: source.name,
898 clientAuthentication: session.clientAuthentication,1111 })),
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 },
909 };1112 };
910 }),1113 }),
911 ),1114 ),
plugins/google-discovery/shared/index.ts+61−1
@@ -79,18 +79,56 @@ export const GoogleDiscoveryStartOAuthInputSchema = Schema.Struct({ · 78 unmodified lines
79 redirectUrl: Schema.String,79 redirectUrl: Schema.String,
80});80});
81 81
82export 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
91export 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
82export const GoogleDiscoveryStartOAuthResultSchema = Schema.Struct({99export const GoogleDiscoveryStartOAuthResultSchema = Schema.Struct({
83 sessionId: Schema.String,100 sessionId: Schema.String,
84 authorizationUrl: Schema.String,101 authorizationUrl: Schema.String,
85 scopes: Schema.Array(Schema.String),102 scopes: Schema.Array(Schema.String),
86});103});
87 104
88export const GoogleDiscoveryOAuthSessionSchema = Schema.Struct({105const 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
119const GoogleDiscoveryBatchOAuthSessionSourceSchema = Schema.Struct({
120 name: Schema.String,
89 service: Schema.String,121 service: Schema.String,
90 version: Schema.String,122 version: Schema.String,
91 discoveryUrl: Schema.String,123 discoveryUrl: Schema.String,
92 defaultHeaders: Schema.NullOr(StringMapSchema),124 defaultHeaders: Schema.NullOr(StringMapSchema),
93 scopes: Schema.Array(Schema.String),125 scopes: Schema.Array(Schema.String),
126});
127
128const GoogleDiscoveryBatchOAuthSessionSchema = Schema.Struct({
129 kind: Schema.Literal("batch"),
130 sources: Schema.Array(GoogleDiscoveryBatchOAuthSessionSourceSchema),
131 scopes: Schema.Array(Schema.String),
94 clientId: Schema.String,132 clientId: Schema.String,
95 clientSecretRef: Schema.NullOr(SecretRefSchema),133 clientSecretRef: Schema.NullOr(SecretRefSchema),
96 clientAuthentication: GoogleDiscoveryOAuthClientAuthenticationSchema,134 clientAuthentication: GoogleDiscoveryOAuthClientAuthenticationSchema,
@@ -98,6 +136,11 @@ export const GoogleDiscoveryOAuthSessionSchema = Schema.Struct({ · 1 unmodified line
98 codeVerifier: Schema.String,136 codeVerifier: Schema.String,
99});137});
100 138
139export const GoogleDiscoveryOAuthSessionSchema = Schema.Union(
140 GoogleDiscoverySingleOAuthSessionSchema,
141 GoogleDiscoveryBatchOAuthSessionSchema,
142);
143
101const GoogleDiscoveryOAuthPopupSuccessAuthSchema = Schema.Struct({144const GoogleDiscoveryOAuthPopupSuccessAuthSchema = Schema.Struct({
102 kind: Schema.Literal("oauth2"),145 kind: Schema.Literal("oauth2"),
103 clientId: Schema.String,146 clientId: Schema.String,
@@ -111,13 +154,26 @@ const GoogleDiscoveryOAuthPopupSuccessAuthSchema = Schema.Struct({ · 7 unmodified lines
111 expiresAt: Schema.NullOr(Schema.Number),154 expiresAt: Schema.NullOr(Schema.Number),
112});155});
113 156
157const GoogleDiscoveryBatchOAuthPopupSourceSchema = Schema.Struct({
158 id: Schema.String,
159 name: Schema.String,
160});
161
114export const GoogleDiscoveryOAuthPopupResultSchema = Schema.Union(162export const GoogleDiscoveryOAuthPopupResultSchema = Schema.Union(
115 Schema.Struct({163 Schema.Struct({
116 type: Schema.Literal("executor:oauth-result"),164 type: Schema.Literal("executor:oauth-result"),
117 ok: Schema.Literal(true),165 ok: Schema.Literal(true),
118 sessionId: Schema.String,166 sessionId: Schema.String,
167 mode: Schema.Literal("single"),
119 auth: GoogleDiscoveryOAuthPopupSuccessAuthSchema,168 auth: GoogleDiscoveryOAuthPopupSuccessAuthSchema,
120 }),169 }),
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 }),
121 Schema.Struct({177 Schema.Struct({
122 type: Schema.Literal("executor:oauth-result"),178 type: Schema.Literal("executor:oauth-result"),
123 ok: Schema.Literal(false),179 ok: Schema.Literal(false),
@@ -140,6 +196,10 @@ export type GoogleDiscoveryStoredSourceData = · 16 unmodified lines
140 typeof GoogleDiscoveryStoredSourceDataSchema.Type;196 typeof GoogleDiscoveryStoredSourceDataSchema.Type;
141export type GoogleDiscoveryStartOAuthInput =197export type GoogleDiscoveryStartOAuthInput =
142 typeof GoogleDiscoveryStartOAuthInputSchema.Type;198 typeof GoogleDiscoveryStartOAuthInputSchema.Type;
199export type GoogleDiscoveryBatchSourceInput =
200 typeof GoogleDiscoveryBatchSourceInputSchema.Type;
201export type GoogleDiscoveryStartBatchOAuthInput =
202 typeof GoogleDiscoveryStartBatchOAuthInputSchema.Type;
143export type GoogleDiscoveryStartOAuthResult =203export type GoogleDiscoveryStartOAuthResult =
144 typeof GoogleDiscoveryStartOAuthResultSchema.Type;204 typeof GoogleDiscoveryStartOAuthResultSchema.Type;
145export type GoogleDiscoveryOAuthSession =205export type GoogleDiscoveryOAuthSession =
plugins/graphql/react/index.tsx+353−115
@@ -11,6 +11,7 @@ import { · 10 unmodified lines
11 Result,11 Result,
12 useAtomSet,12 useAtomSet,
13 useAtomValue,13 useAtomValue,
14 useCreateSecret,
14 useExecutorMutation,15 useExecutorMutation,
15 useLocalInstallation,16 useLocalInstallation,
16 usePrefetchToolDetail,17 usePrefetchToolDetail,
@@ -77,6 +78,53 @@ const defaultGraphqlInput = (): GraphqlConnectInput => ({ · 60 unmodified lines
77 },78 },
78});79});
79 80
81const DEFAULT_BEARER_HEADER_NAME = "Authorization";
82const DEFAULT_BEARER_PREFIX = "Bearer ";
83const CREATE_SECRET_VALUE = "__create_graphql_secret__";
84
85const 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
95const 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
109const 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
80const parseStringMap = (value: string): Record<string, string> | null => {128const parseStringMap = (value: string): Record<string, string> | null => {
81 const trimmed = value.trim();129 const trimmed = value.trim();
82 if (!trimmed) {130 if (!trimmed) {
@@ -108,9 +156,26 @@ const secretValue = (input: GraphqlConnectionAuth): string => · 25 unmodified lines
108 ? JSON.stringify(input.tokenSecretRef)156 ? JSON.stringify(input.tokenSecretRef)
109 : "";157 : "";
110 158
159const bearerHeaderNameValue = (input: GraphqlConnectionAuth): string =>
160 input.kind === "bearer"
161 ? input.headerName?.trim() || DEFAULT_BEARER_HEADER_NAME
162 : DEFAULT_BEARER_HEADER_NAME;
163
164const bearerPrefixValue = (input: GraphqlConnectionAuth): string =>
165 input.kind === "bearer"
166 ? input.prefix ?? DEFAULT_BEARER_PREFIX
167 : DEFAULT_BEARER_PREFIX;
168
169const trimToNull = (value: string): string | null => {
170 const trimmed = value.trim();
171 return trimmed.length > 0 ? trimmed : null;
172};
173
111const authFromSecretValue = (174const authFromSecretValue = (
112 authKind: GraphqlConnectionAuth["kind"],175 authKind: GraphqlConnectionAuth["kind"],
113 value: string,176 value: string,
177 headerName: string,
178 prefix: string,
114): GraphqlConnectionAuth => {179): GraphqlConnectionAuth => {
115 if (authKind === "none") {180 if (authKind === "none") {
116 return {181 return {
@@ -125,9 +190,139 @@ const authFromSecretValue = ( · 8 unmodified lines
125 return {190 return {
126 kind: "bearer",191 kind: "bearer",
127 tokenSecretRef: JSON.parse(value) as GraphqlConnectionAuth & { tokenSecretRef: never }["tokenSecretRef"],192 tokenSecretRef: JSON.parse(value) as GraphqlConnectionAuth & { tokenSecretRef: never }["tokenSecretRef"],
193 headerName: trimToNull(headerName),
194 prefix: prefix === DEFAULT_BEARER_PREFIX ? null : prefix,
128 };195 };
129};196};
130 197
198function 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
131const graphqlToolRoutePath = (toolPath: string): string =>326const graphqlToolRoutePath = (toolPath: string): string =>
132 `tool/${encodeURIComponent(toolPath)}`;327 `tool/${encodeURIComponent(toolPath)}`;
133 328
@@ -189,7 +384,6 @@ function GraphqlSourceForm(props: { · 55 unmodified lines
189 mode: "create" | "edit";384 mode: "create" | "edit";
190 onSubmit: (input: GraphqlConnectInput) => Promise<void>;385 onSubmit: (input: GraphqlConnectInput) => Promise<void>;
191}) {386}) {
192 const secrets = useSecrets();
193 const submitMutation = useExecutorMutation<GraphqlConnectInput, void>(props.onSubmit);387 const submitMutation = useExecutorMutation<GraphqlConnectInput, void>(props.onSubmit);
194 const [name, setName] = useState(props.initialValue.name);388 const [name, setName] = useState(props.initialValue.name);
195 const [endpoint, setEndpoint] = useState(props.initialValue.endpoint);389 const [endpoint, setEndpoint] = useState(props.initialValue.endpoint);
@@ -200,94 +394,102 @@ function GraphqlSourceForm(props: { · 4 unmodified lines
200 props.initialValue.auth.kind,394 props.initialValue.auth.kind,
201 );395 );
202 const [secretRef, setSecretRef] = useState(secretValue(props.initialValue.auth));396 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 );
203 const [error, setError] = useState<string | null>(null);403 const [error, setError] = useState<string | null>(null);
204 404
205 return (405 return (
206 <div className="space-y-4 rounded-2xl border border-border bg-card p-5">406 <div className="space-y-6 rounded-xl border border-border p-6">
207 <div>407 <div className="grid gap-4">
208 <h2 className="text-lg font-semibold text-foreground">408 <label className="grid gap-2">
209 {props.mode === "create" ? "Connect GraphQL Source" : "Edit GraphQL Source"}409 <span className="text-xs font-medium text-foreground">Name</span>
210 </h2>410 <input
211 <p className="mt-1 text-sm text-muted-foreground">411 value={name}
212 GraphQL is now mounted as a plugin. The source record stays generic; endpoint and auth412 onChange={(event) => setName(event.target.value)}
213 live entirely in plugin storage.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"
214 </p>414 />
215 </div>415 </label>
216 416
217 <label className="grid gap-1.5">417 <label className="grid gap-2">
218 <span className="text-xs font-medium text-foreground">Name</span>418 <span className="text-xs font-medium text-foreground">Endpoint</span>
219 <input419 <input
220 value={name}420 value={endpoint}
221 onChange={(event) => setName(event.target.value)}421 onChange={(event) => setEndpoint(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"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"
223 />423 />
224 </label>424 </label>
225 425
226 <label className="grid gap-1.5">426 <label className="grid gap-2">
227 <span className="text-xs font-medium text-foreground">Endpoint</span>427 <span className="text-xs font-medium text-foreground">Default Headers</span>
228 <input428 <textarea
229 value={endpoint}429 value={headersText}
230 onChange={(event) => setEndpoint(event.target.value)}430 onChange={(event) => setHeadersText(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"431 rows={3}
232 />432 placeholder='{"x-api-version":"2026-03-23"}'
233 </label>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"
234 434 />
235 <label className="grid gap-1.5">435 </label>
236 <span className="text-xs font-medium text-foreground">Default Headers</span>436
237 <textarea437 <label className="grid gap-2">
238 value={headersText}438 <span className="text-xs font-medium text-foreground">Auth</span>
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>
262 <select439 <select
263 value={secretRef}440 value={authKind}
264 onChange={(event) => setSecretRef(event.target.value)}441 onChange={(event) =>
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"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"
266 >444 >
267 <option value="">Select a secret</option>445 <option value="none">None</option>
268 {secrets.status === "ready" &&446 <option value="bearer">Bearer Secret</option>
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 ))}
280 </select>447 </select>
281 </label>448 </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>
283 485
284 {error && (486 {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">
286 {error}488 {error}
287 </div>489 </div>
288 )}490 )}
289 491
290 <div className="flex items-center gap-3">492 <div className="flex items-center justify-end">
291 <button493 <button
292 type="button"494 type="button"
293 onClick={() => {495 onClick={() => {
@@ -298,7 +500,12 @@ function GraphqlSourceForm(props: { · 4 unmodified lines
298 name: name.trim(),500 name: name.trim(),
299 endpoint: endpoint.trim(),501 endpoint: endpoint.trim(),
300 defaultHeaders: parseStringMap(headersText),502 defaultHeaders: parseStringMap(headersText),
301 auth: authFromSecretValue(authKind, secretRef),503 auth: authFromSecretValue(
504 authKind,
505 secretRef,
506 authHeaderName,
507 authPrefix,
508 ),
302 });509 });
303 } catch (cause) {510 } catch (cause) {
304 setError(cause instanceof Error ? cause.message : "Failed saving GraphQL source.");511 setError(cause instanceof Error ? cause.message : "Failed saving GraphQL source.");
@@ -306,17 +513,12 @@ function GraphqlSourceForm(props: { · 1 unmodified line
306 })();513 })();
307 }}514 }}
308 disabled={submitMutation.status === "pending"}515 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"
310 >517 >
311 {submitMutation.status === "pending"518 {submitMutation.status === "pending"
312 ? props.mode === "create" ? "Creating..." : "Saving..."519 ? props.mode === "create" ? "Creating..." : "Saving..."
313 : props.mode === "create" ? "Create Source" : "Save Changes"}520 : props.mode === "create" ? "Create Source" : "Save Changes"}
314 </button>521 </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>
320 </div>522 </div>
321 </div>523 </div>
322 );524 );
@@ -324,6 +526,7 @@ function GraphqlSourceForm(props: { · 1 unmodified line
324 526
325function GraphqlAddPage() {527function GraphqlAddPage() {
326 const navigation = useSourcePluginNavigation();528 const navigation = useSourcePluginNavigation();
529 const initialValue = graphqlInputFromSearch(useSourcePluginSearch());
327 const installation = useLocalInstallation();530 const installation = useLocalInstallation();
328 const client = getGraphqlHttpClient();531 const client = getGraphqlHttpClient();
329 const createSource = useAtomSet(532 const createSource = useAtomSet(
@@ -337,7 +540,7 @@ function GraphqlAddPage() { · 7 unmodified lines
337 540
338 return (541 return (
339 <GraphqlSourceForm542 <GraphqlSourceForm
340 initialValue={defaultGraphqlInput()}543 initialValue={initialValue}
341 mode="create"544 mode="create"
342 onSubmit={async (input) => {545 onSubmit={async (input) => {
343 const source = await createSource({546 const source = await createSource({
@@ -394,13 +597,13 @@ function GraphqlEditPage(props: { · 50 unmodified lines
394 if (Result.isFailure(configResult)) {597 if (Result.isFailure(configResult)) {
395 return (598 return (
396 <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-3 text-sm text-destructive">599 <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.
398 </div>601 </div>
399 );602 );
400 }603 }
401 604
402 if (!Result.isSuccess(configResult)) {605 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>;
404 }607 }
405 608
406 return (609 return (
@@ -479,6 +682,8 @@ function GraphqlDetailExplorer(props: { · 72 unmodified lines
479 props.source.id,682 props.source.id,
480 tab === "model" ? selectedToolPath : null,683 tab === "model" ? selectedToolPath : null,
481 );684 );
685 const [confirmDelete, setConfirmDelete] = useState(false);
686 const [isDeleting, setIsDeleting] = useState(false);
482 687
483 if (installation.status !== "ready") {688 if (installation.status !== "ready") {
484 return <div className="text-sm text-muted-foreground">Loading workspace...</div>;689 return <div className="text-sm text-muted-foreground">Loading workspace...</div>;
@@ -545,6 +750,35 @@ function GraphqlDetailExplorer(props: { · 60 unmodified lines
545 return navigation.detail(props.source.id, searchState);750 return navigation.detail(props.source.id, searchState);
546 };751 };
547 752
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
548 return (782 return (
549 <LoadableBlock loadable={inspection} loading="Loading source...">783 <LoadableBlock loadable={inspection} loading="Loading source...">
550 {(loadedInspection) => {784 {(loadedInspection) => {
@@ -594,36 +828,40 @@ function GraphqlDetailExplorer(props: { · 43 unmodified lines
594 <IconPencil className="size-3" />828 <IconPencil className="size-3" />
595 Edit829 Edit
596 </button>830 </button>
597 <button831 {confirmDelete ? (
598 type="button"832 <div className="flex items-center gap-2">
599 onClick={() => {833 <span className="text-[11px] font-medium text-destructive">
600 const confirmed = window.confirm(`Delete GraphQL source "${props.source.name}"?`);834 Confirm delete?
601 if (!confirmed) {835 </span>
602 return;836 <button
603 }837 type="button"
604 838 onClick={() => setConfirmDelete(false)}
605 void removeSource({839 disabled={isDeleting}
606 path: {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"
607 workspaceId: installation.data.scopeId,841 >
608 sourceId: props.source.id,842 Cancel
609 },843 </button>
610 reactivityKeys: {844 <button
611 sources: [installation.data.scopeId],845 type="button"
612 source: [installation.data.scopeId, props.source.id],846 onClick={() => {
613 sourceInspection: [installation.data.scopeId, props.source.id],847 void handleDelete().catch(() => {});
614 sourceInspectionTool: [installation.data.scopeId, props.source.id],848 }}
615 sourceDiscovery: [installation.data.scopeId, props.source.id],849 disabled={isDeleting}
616 },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"
617 }).then(() => {851 >
618 startTransition(() => {852 {isDeleting ? "Deleting..." : "Delete"}
619 void navigation.home();853 </button>
620 });854 </div>
621 });855 ) : (
622 }}856 <button
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"857 type="button"
624 >858 onClick={() => setConfirmDelete(true)}
625 Delete859 disabled={isDeleting}
626 </button>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 )}
627 </div>865 </div>
628 </div>866 </div>
629 867
plugins/graphql/sdk/index.ts+65−10
@@ -90,6 +90,37 @@ const GraphqlExecutorAddInputSchema = Schema.Struct({ · 89 unmodified lines
90 90
91type GraphqlExecutorAddInput = typeof GraphqlExecutorAddInputSchema.Type;91type GraphqlExecutorAddInput = typeof GraphqlExecutorAddInputSchema.Type;
92 92
93const 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
116const normalizeStoredSourceData = (
117 stored: GraphqlStoredSourceData,
118): GraphqlStoredSourceData =>
119 decodeStoredSourceData({
120 ...stored,
121 auth: normalizeGraphqlAuth(stored.auth as Record<string, unknown>),
122 });
123
93const asRecord = (value: unknown): Record<string, unknown> =>124const asRecord = (value: unknown): Record<string, unknown> =>
94 typeof value === "object" && value !== null && !Array.isArray(value)125 typeof value === "object" && value !== null && !Array.isArray(value)
95 ? (value as Record<string, unknown>)126 ? (value as Record<string, unknown>)
@@ -142,6 +173,17 @@ const resolveBearerToken = ( · 46 unmodified lines
142 }).pipe(Effect.map((token) => token.trim()))173 }).pipe(Effect.map((token) => token.trim()))
143 );174 );
144 175
176const 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
183const resolveBearerPrefix = (
184 auth: Extract<GraphqlStoredSourceData["auth"], { kind: "bearer" }>,
185): string => auth.prefix ?? "Bearer ";
186
145const resolveGraphqlHeaders = (187const resolveGraphqlHeaders = (
146 stored: GraphqlStoredSourceData,188 stored: GraphqlStoredSourceData,
147): Effect.Effect<Record<string, string>, Error, any> =>189): Effect.Effect<Record<string, string>, Error, any> =>
@@ -149,9 +191,10 @@ const resolveGraphqlHeaders = ( · 1 unmodified line
149 const bearerToken = yield* resolveBearerToken(stored.auth);191 const bearerToken = yield* resolveBearerToken(stored.auth);
150 return {192 return {
151 ...(stored.defaultHeaders ?? {}),193 ...(stored.defaultHeaders ?? {}),
152 ...(bearerToken194 ...(bearerToken && stored.auth.kind === "bearer"
153 ? {195 ? {
154 authorization: `Bearer ${bearerToken}`,196 [resolveBearerHeaderName(stored.auth)]:
197 `${resolveBearerPrefix(stored.auth)}${bearerToken}`,
155 }198 }
156 : {}),199 : {}),
157 };200 };
@@ -241,7 +284,14 @@ const storedSourceDataFromInput = ( · 83 unmodified lines
241 decodeStoredSourceData({284 decodeStoredSourceData({
242 endpoint: input.endpoint.trim(),285 endpoint: input.endpoint.trim(),
243 defaultHeaders: input.defaultHeaders,286 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,
245 });295 });
246 296
247const sourceConfigFromStored = (297const sourceConfigFromStored = (
@@ -279,7 +329,10 @@ const createGraphqlSourceSdk = ( · 31 unmodified lines
279 );329 );
280 }330 }
281 331
282 return sourceConfigFromStored(source, stored);332 return sourceConfigFromStored(
333 source,
334 normalizeStoredSourceData(stored),
335 );
283 }),336 }),
284 createSource: (input: GraphqlConnectInput) =>337 createSource: (input: GraphqlConnectInput) =>
285 Effect.gen(function* () {338 Effect.gen(function* () {
@@ -404,9 +457,10 @@ const createGraphqlSourceRuntime = ( · 118 unmodified lines
404 });457 });
405 }458 }
406 459
407 const headers = yield* resolveGraphqlHeaders(stored);460 const normalizedStored = normalizeStoredSourceData(stored);
461 const headers = yield* resolveGraphqlHeaders(normalizedStored);
408 const graphqlDocument = yield* fetchGraphqlIntrospectionDocumentWithHeaders({462 const graphqlDocument = yield* fetchGraphqlIntrospectionDocumentWithHeaders({
409 endpoint: stored.endpoint,463 endpoint: normalizedStored.endpoint,
410 headers,464 headers,
411 });465 });
412 const manifest = yield* extractGraphqlManifest(source.name, graphqlDocument);466 const manifest = yield* extractGraphqlManifest(source.name, graphqlDocument);
@@ -427,7 +481,7 @@ const createGraphqlSourceRuntime = ( · 14 unmodified lines
427 documents: [481 documents: [
428 {482 {
429 documentKind: "graphql_introspection",483 documentKind: "graphql_introspection",
430 documentKey: stored.endpoint,484 documentKey: normalizedStored.endpoint,
431 contentText: graphqlDocument,485 contentText: graphqlDocument,
432 fetchedAt: now,486 fetchedAt: now,
433 },487 },
@@ -456,17 +510,18 @@ const createGraphqlSourceRuntime = ( · 22 unmodified lines
456 );510 );
457 }511 }
458 512
513 const normalizedStored = normalizeStoredSourceData(stored);
459 const providerData = decodeProviderData(514 const providerData = decodeProviderData(
460 input.executable.binding,515 input.executable.binding,
461 ) as GraphqlToolProviderData;516 ) as GraphqlToolProviderData;
462 const args = asRecord(input.args);517 const args = asRecord(input.args);
463 const headers = {518 const headers = {
464 "content-type": "application/json",519 "content-type": "application/json",
465 ...(yield* resolveGraphqlHeaders(stored)),520 ...(yield* resolveGraphqlHeaders(normalizedStored)),
466 ...asStringRecord(args.headers),521 ...asStringRecord(args.headers),
467 };522 };
468 const endpoint = applyHttpQueryPlacementsToUrl({523 const endpoint = applyHttpQueryPlacementsToUrl({
469 url: stored.endpoint,524 url: normalizedStored.endpoint,
470 }).toString();525 }).toString();
471 526
472 const isRawRequest =527 const isRawRequest =
@@ -511,7 +566,7 @@ const createGraphqlSourceRuntime = ( · 38 unmodified lines
511 ...(operationName ? { operationName } : {}),566 ...(operationName ? { operationName } : {}),
512 },567 },
513 bodyValues: {},568 bodyValues: {},
514 label: `GraphQL invocation ${stored.endpoint}`,569 label: `GraphQL invocation ${normalizedStored.endpoint}`,
515 }),570 }),
516 ),571 ),
517 }),572 }),
plugins/graphql/shared/index.ts+2−0
@@ -16,6 +16,8 @@ export const GraphqlConnectionAuthSchema = Schema.Union( · 15 unmodified lines
16 Schema.Struct({16 Schema.Struct({
17 kind: Schema.Literal("bearer"),17 kind: Schema.Literal("bearer"),
18 tokenSecretRef: SecretRefSchema,18 tokenSecretRef: SecretRefSchema,
19 headerName: Schema.NullOr(Schema.String),
20 prefix: Schema.NullOr(Schema.String),
19 }),21 }),
20);22);
21 23
plugins/mcp/http/index.ts+38−6
@@ -26,11 +26,15 @@ import * as Schema from "effect/Schema"; · 25 unmodified lines
26 26
27import {27import {
28 McpConnectInputSchema,28 McpConnectInputSchema,
29 McpDiscoverInputSchema,
30 McpDiscoverResultSchema,
29 McpOAuthPopupResultSchema,31 McpOAuthPopupResultSchema,
30 McpSourceConfigPayloadSchema,32 McpSourceConfigPayloadSchema,
31 McpStartOAuthInputSchema,33 McpStartOAuthInputSchema,
32 McpStartOAuthResultSchema,34 McpStartOAuthResultSchema,
33 type McpConnectInput,35 type McpConnectInput,
36 type McpDiscoverInput,
37 type McpDiscoverResult,
34 type McpOAuthPopupResult,38 type McpOAuthPopupResult,
35 type McpSourceConfigPayload,39 type McpSourceConfigPayload,
36 type McpStartOAuthInput,40 type McpStartOAuthInput,
@@ -46,6 +50,9 @@ type McpExecutorExtension = { · 9 unmodified lines
46 getSourceConfig: (50 getSourceConfig: (
47 sourceId: Source["id"],51 sourceId: Source["id"],
48 ) => Effect.Effect<McpSourceConfigPayload, Error>;52 ) => Effect.Effect<McpSourceConfigPayload, Error>;
53 discoverSource: (
54 input: McpDiscoverInput,
55 ) => Effect.Effect<McpDiscoverResult, Error>;
49 updateSource: (56 updateSource: (
50 input: McpUpdateSourceInput,57 input: McpUpdateSourceInput,
51 ) => Effect.Effect<Source, Error>;58 ) => Effect.Effect<Source, Error>;
@@ -86,6 +93,14 @@ export const McpHttpGroup = HttpApiGroup.make("mcp") · 34 unmodified lines
86 .addError(ControlPlaneForbiddenError)93 .addError(ControlPlaneForbiddenError)
87 .addError(ControlPlaneStorageError),94 .addError(ControlPlaneStorageError),
88 )95 )
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 )
89 .add(104 .add(
90 HttpApiEndpoint.get("getSourceConfig")`/workspaces/${workspaceIdParam}/plugins/mcp/sources/${sourceIdParam}`105 HttpApiEndpoint.get("getSourceConfig")`/workspaces/${workspaceIdParam}/plugins/mcp/sources/${sourceIdParam}`
91 .addSuccess(McpSourceConfigPayloadSchema)106 .addSuccess(McpSourceConfigPayloadSchema)
@@ -171,10 +186,11 @@ const popupDocument = (payload: McpOAuthPopupResult): string => { · 79 unmodified lines
171 .replaceAll("<", "\\u003c")186 .replaceAll("<", "\\u003c")
172 .replaceAll(">", "\\u003e")187 .replaceAll(">", "\\u003e")
173 .replaceAll("&", "\\u0026");188 .replaceAll("&", "\\u0026");
174 const title = payload.ok ? "MCP OAuth connected" : "MCP OAuth failed";189 const title = payload.ok ? "Connected" : "Connection failed";
175 const message = payload.ok190 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."
177 : payload.error;192 : payload.error;
193 const statusColor = payload.ok ? "#22c55e" : "#ef4444";
178 194
179 return `<!doctype html>195 return `<!doctype html>
180<html lang="en">196<html lang="en">
@@ -183,10 +199,17 @@ const popupDocument = (payload: McpOAuthPopupResult): string => { · 2 unmodified lines
183 <meta name="viewport" content="width=device-width, initial-scale=1" />199 <meta name="viewport" content="width=device-width, initial-scale=1" />
184 <title>${escapeHtml(title)}</title>200 <title>${escapeHtml(title)}</title>
185 </head>201 </head>
186 <body>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;">
187 <main>203 <main style="text-align: center; max-width: 360px; padding: 24px;">
188 <h1>${escapeHtml(title)}</h1>204 <div style="width: 40px; height: 40px; border-radius: 50%; background: ${statusColor}; margin: 0 auto 16px; display: flex; align-items: center; justify-content: center;">
189 <p>${escapeHtml(message)}</p>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>
190 </main>213 </main>
191 <script>214 <script>
192 (() => {215 (() => {
@@ -225,6 +248,15 @@ export const mcpHttpPlugin = (): ExecutorHttpPlugin< · 32 unmodified lines
225 Effect.mapError((cause) => toStorageError("mcp.createSource", cause)),248 Effect.mapError((cause) => toStorageError("mcp.createSource", cause)),
226 )249 )
227 )250 )
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 )
228 .handle("getSourceConfig", ({ path }) =>260 .handle("getSourceConfig", ({ path }) =>
229 resolveRequestedLocalWorkspace(261 resolveRequestedLocalWorkspace(
230 "mcp.getSourceConfig",262 "mcp.getSourceConfig",
plugins/mcp/react/index.tsx+604−86
@@ -9,7 +9,10 @@ import { · 8 unmodified lines
9 useLocalInstallation,9 useLocalInstallation,
10} from "@executor/react";10} from "@executor/react";
11import {11import {
12 IconCheck,
12 IconPencil,13 IconPencil,
14 IconSearch,
15 IconSpinner,
13 SourceToolExplorer,16 SourceToolExplorer,
14 defineExecutorFrontendPlugin,17 defineExecutorFrontendPlugin,
15 defineFrontendSourceType,18 defineFrontendSourceType,
@@ -24,6 +27,8 @@ import { · 8 unmodified lines
24} from "@executor/plugin-mcp-http";27} from "@executor/plugin-mcp-http";
25import {28import {
26 type McpConnectInput,29 type McpConnectInput,
30 type McpDiscoverInput,
31 type McpDiscoverResult,
27 type McpConnectionAuth,32 type McpConnectionAuth,
28 type McpOAuthPopupResult,33 type McpOAuthPopupResult,
29 type McpStartOAuthInput,34 type McpStartOAuthInput,
@@ -64,6 +69,150 @@ const defaultMcpInput = (): McpConnectInput => ({ · 34 unmodified lines
64 },69 },
65});70});
66 71
72type McpQuickPreset = {
73 id: string;
74 name: string;
75 summary: string;
76 input: McpConnectInput;
77};
78
79type 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
89const defaultProbeAuthState = (): ProbeAuthState => ({
90 kind: "none",
91 token: "",
92 headerName: "Authorization",
93 prefix: "Bearer ",
94 username: "",
95 password: "",
96 headersText: "",
97});
98
99const 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
148const 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
158const 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
170const 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
178const 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
186const 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
67const stringifyStringMap = (216const stringifyStringMap = (
68 value: Record<string, string> | null | undefined,217 value: Record<string, string> | null | undefined,
69): string =>218): string =>
@@ -92,6 +241,49 @@ const transportFieldsFromInput = (input: McpConnectInput): McpTransportFields => · 22 unmodified lines
92 headersText: stringifyStringMap(input.headers),241 headersText: stringifyStringMap(input.headers),
93 };242 };
94 243
244const 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
95const waitForOauthPopupResult = async (287const waitForOauthPopupResult = async (
96 sessionId: string,288 sessionId: string,
97): Promise<McpOAuthPopupResult> =>289): Promise<McpOAuthPopupResult> =>
@@ -150,6 +342,28 @@ const waitForOauthPopupResult = async ( · 52 unmodified lines
150 }, 400);342 }, 400);
151 });343 });
152 344
345const 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
153function McpSourceForm(props: {367function McpSourceForm(props: {
154 initialValue: McpConnectInput;368 initialValue: McpConnectInput;
155 mode: "create" | "edit";369 mode: "create" | "edit";
@@ -157,6 +371,10 @@ function McpSourceForm(props: { · 1 unmodified line
157}) {371}) {
158 const installation = useLocalInstallation();372 const installation = useLocalInstallation();
159 const client = getMcpHttpClient();373 const client = getMcpHttpClient();
374 const discoverSource = useAtomSet(
375 client.mutation("mcp", "discoverSource"),
376 { mode: "promise" },
377 );
160 const startOAuth = useAtomSet(378 const startOAuth = useAtomSet(
161 client.mutation("mcp", "startOAuth"),379 client.mutation("mcp", "startOAuth"),
162 { mode: "promise" },380 { mode: "promise" },
@@ -181,9 +399,104 @@ function McpSourceForm(props: { · 18 unmodified lines
181 props.initialValue.auth.kind === "oauth2" ? "connected" : "idle",399 props.initialValue.auth.kind === "oauth2" ? "connected" : "idle",
182 );400 );
183 const [error, setError] = useState<string | null>(null);401 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 );
184 422
185 const isStdio = transportFields.transport === "stdio";423 const isStdio = transportFields.transport === "stdio";
186 424
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
187 const runOauth = async () => {500 const runOauth = async () => {
188 if (installation.status !== "ready") {501 if (installation.status !== "ready") {
189 throw new Error("Workspace is still loading.");502 throw new Error("Workspace is still loading.");
@@ -192,6 +505,11 @@ function McpSourceForm(props: { · 2 unmodified lines
192 throw new Error("MCP OAuth is only available for remote MCP transports.");505 throw new Error("MCP OAuth is only available for remote MCP transports.");
193 }506 }
194 507
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
195 const payload: McpStartOAuthInput = {513 const payload: McpStartOAuthInput = {
196 endpoint: endpoint.trim(),514 endpoint: endpoint.trim(),
197 queryParams: parseJsonStringMap("Query params", transportFields.queryParamsText),515 queryParams: parseJsonStringMap("Query params", transportFields.queryParamsText),
@@ -201,22 +519,22 @@ function McpSourceForm(props: { · 3 unmodified lines
201 ).toString(),519 ).toString(),
202 };520 };
203 521
204 const started = await startOAuth({522 let started;
205 path: {523 try {
206 workspaceId: installation.data.scopeId,524 started = await startOAuth({
207 },525 path: {
208 payload,526 workspaceId: installation.data.scopeId,
209 });527 },
210 528 payload,
211 const popup = window.open(529 });
212 started.authorizationUrl,530 } catch (cause) {
213 "executor-mcp-oauth",531 popup.close();
214 "width=560,height=760,noopener,noreferrer",532 throw cause;
215 );
216 if (!popup) {
217 throw new Error("Failed opening MCP OAuth popup.");
218 }533 }
219 534
535 popup.location.replace(started.authorizationUrl);
536 popup.focus();
537
220 const result = await waitForOauthPopupResult(started.sessionId);538 const result = await waitForOauthPopupResult(started.sessionId);
221 if (!result.ok) {539 if (!result.ok) {
222 throw new Error(result.error);540 throw new Error(result.error);
@@ -256,26 +574,189 @@ function McpSourceForm(props: { · 33 unmodified lines
256 });574 });
257 575
258 return (576 return (
259 <div className="space-y-4 rounded-2xl border border-border bg-card p-5">577 <div className="space-y-8">
260 <div>578 {props.mode === "create" && (
261 <h2 className="text-lg font-semibold text-foreground">579 <div className="rounded-xl border border-border p-6">
262 {props.mode === "create" ? "Connect MCP Source" : "Edit MCP Source"}580 <div className="text-sm font-medium text-foreground">Presets</div>
263 </h2>581 <div className="mt-3 grid gap-3 sm:grid-cols-2">
264 <p className="mt-1 text-sm text-muted-foreground">582 {mcpQuickPresets.map((preset) => {
265 MCP now owns its transport config, OAuth flow, and tool surface inside the plugin.583 const selected =
266 </p>584 preset.input.transport === "stdio"
267 </div>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>
268 667
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">
270 <span className="text-xs font-medium text-foreground">Name</span>751 <span className="text-xs font-medium text-foreground">Name</span>
271 <input752 <input
272 value={name}753 value={name}
273 onChange={(event) => setName(event.target.value)}754 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"
275 />756 />
276 </label>757 </label>
277 758
278 <label className="grid gap-1.5">759 <label className="grid gap-2">
279 <span className="text-xs font-medium text-foreground">Transport</span>760 <span className="text-xs font-medium text-foreground">Transport</span>
280 <select761 <select
281 value={transportFields.transport}762 value={transportFields.transport}
@@ -290,7 +771,7 @@ function McpSourceForm(props: { · 8 unmodified lines
290 setOauthStatus("idle");771 setOauthStatus("idle");
291 }772 }
292 }}773 }}
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"
294 >775 >
295 <option value="">Auto (remote)</option>776 <option value="">Auto (remote)</option>
296 <option value="auto">Auto</option>777 <option value="auto">Auto</option>
@@ -302,7 +783,7 @@ function McpSourceForm(props: { · 5 unmodified lines
302 783
303 {isStdio ? (784 {isStdio ? (
304 <div className="grid gap-4 md:grid-cols-2">785 <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">
306 <span className="text-xs font-medium text-foreground">Command</span>787 <span className="text-xs font-medium text-foreground">Command</span>
307 <input788 <input
308 value={transportFields.command}789 value={transportFields.command}
@@ -311,11 +792,11 @@ function McpSourceForm(props: { · 2 unmodified lines
311 ...transportFields,792 ...transportFields,
312 command: event.target.value,793 command: event.target.value,
313 })}794 })}
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"
315 />796 />
316 </label>797 </label>
317 798
318 <label className="grid gap-1.5">799 <label className="grid gap-2">
319 <span className="text-xs font-medium text-foreground">Args</span>800 <span className="text-xs font-medium text-foreground">Args</span>
320 <textarea801 <textarea
321 value={transportFields.argsText}802 value={transportFields.argsText}
@@ -324,13 +805,13 @@ function McpSourceForm(props: { · 2 unmodified lines
324 ...transportFields,805 ...transportFields,
325 argsText: event.target.value,806 argsText: event.target.value,
326 })}807 })}
327 rows={4}808 rows={3}
328 placeholder='["server.js","--port","8787"]'809 placeholder='["server.js","--port","8787"]'
329 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"810 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"
330 />811 />
331 </label>812 </label>
332 813
333 <label className="grid gap-1.5">814 <label className="grid gap-2">
334 <span className="text-xs font-medium text-foreground">Environment</span>815 <span className="text-xs font-medium text-foreground">Environment</span>
335 <textarea816 <textarea
336 value={transportFields.envText}817 value={transportFields.envText}
@@ -339,13 +820,13 @@ function McpSourceForm(props: { · 2 unmodified lines
339 ...transportFields,820 ...transportFields,
340 envText: event.target.value,821 envText: event.target.value,
341 })}822 })}
342 rows={4}823 rows={3}
343 placeholder='{"NODE_ENV":"production"}'824 placeholder='{"NODE_ENV":"production"}'
344 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"825 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"
345 />826 />
346 </label>827 </label>
347 828
348 <label className="grid gap-1.5 md:col-span-2">829 <label className="grid gap-2 md:col-span-2">
349 <span className="text-xs font-medium text-foreground">Working Directory</span>830 <span className="text-xs font-medium text-foreground">Working Directory</span>
350 <input831 <input
351 value={transportFields.cwd}832 value={transportFields.cwd}
@@ -354,22 +835,22 @@ function McpSourceForm(props: { · 2 unmodified lines
354 ...transportFields,835 ...transportFields,
355 cwd: event.target.value,836 cwd: event.target.value,
356 })}837 })}
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"
358 />839 />
359 </label>840 </label>
360 </div>841 </div>
361 ) : (842 ) : (
362 <div className="grid gap-4 md:grid-cols-2">843 <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">
364 <span className="text-xs font-medium text-foreground">Endpoint</span>845 <span className="text-xs font-medium text-foreground">Endpoint</span>
365 <input846 <input
366 value={endpoint}847 value={endpoint}
367 onChange={(event) => setEndpoint(event.target.value)}848 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"
369 />850 />
370 </label>851 </label>
371 852
372 <label className="grid gap-1.5">853 <label className="grid gap-2">
373 <span className="text-xs font-medium text-foreground">Query Params</span>854 <span className="text-xs font-medium text-foreground">Query Params</span>
374 <textarea855 <textarea
375 value={transportFields.queryParamsText}856 value={transportFields.queryParamsText}
@@ -378,13 +859,13 @@ function McpSourceForm(props: { · 2 unmodified lines
378 ...transportFields,859 ...transportFields,
379 queryParamsText: event.target.value,860 queryParamsText: event.target.value,
380 })}861 })}
381 rows={4}862 rows={3}
382 placeholder='{"transport":"streamable-http"}'863 placeholder='{"transport":"streamable-http"}'
383 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"864 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"
384 />865 />
385 </label>866 </label>
386 867
387 <label className="grid gap-1.5">868 <label className="grid gap-2">
388 <span className="text-xs font-medium text-foreground">Headers</span>869 <span className="text-xs font-medium text-foreground">Headers</span>
389 <textarea870 <textarea
390 value={transportFields.headersText}871 value={transportFields.headersText}
@@ -393,7 +874,7 @@ function McpSourceForm(props: { · 2 unmodified lines
393 ...transportFields,874 ...transportFields,
394 headersText: event.target.value,875 headersText: event.target.value,
395 })}876 })}
396 rows={4}877 rows={3}
397 placeholder='{"x-api-key":"..."}'878 placeholder='{"x-api-key":"..."}'
398 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"879 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"
399 />880 />
@@ -402,7 +883,7 @@ function McpSourceForm(props: { · 2 unmodified lines
402 )}883 )}
403 884
404 {!isStdio && (885 {!isStdio && (
405 <label className="grid gap-1.5">886 <label className="grid gap-2">
406 <span className="text-xs font-medium text-foreground">Auth</span>887 <span className="text-xs font-medium text-foreground">Auth</span>
407 <select888 <select
408 value={authKind}889 value={authKind}
@@ -414,7 +895,7 @@ function McpSourceForm(props: { · 5 unmodified lines
414 setOauthStatus("idle");895 setOauthStatus("idle");
415 }896 }
416 }}897 }}
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"
418 >899 >
419 <option value="none">None</option>900 <option value="none">None</option>
420 <option value="oauth2">OAuth 2.0</option>901 <option value="oauth2">OAuth 2.0</option>
@@ -423,14 +904,12 @@ function McpSourceForm(props: { · 2 unmodified lines
423 )}904 )}
424 905
425 {!isStdio && authKind === "oauth2" && (906 {!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">
427 <div className="flex flex-wrap items-center justify-between gap-3">908 <div className="flex flex-wrap items-center justify-between gap-3">
428 <div>909 <div>
429 <div className="text-sm font-medium text-foreground">910 <div className="text-sm font-medium text-foreground">OAuth</div>
430 MCP OAuth
431 </div>
432 <div className="mt-1 text-xs text-muted-foreground">911 <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.
434 </div>913 </div>
435 </div>914 </div>
436 <button915 <button
@@ -451,9 +930,11 @@ function McpSourceForm(props: { · 14 unmodified lines
451 {oauthStatus === "connected" ? "Reconnect OAuth" : "Connect OAuth"}930 {oauthStatus === "connected" ? "Reconnect OAuth" : "Connect OAuth"}
452 </button>931 </button>
453 </div>932 </div>
454 <div className="mt-3 text-xs text-muted-foreground">933 {oauthStatus === "connected" && (
455 Status: <span className="text-foreground">{oauthStatus}</span>934 <div className="mt-3 text-xs text-primary">
456 </div>935 Connected
936 </div>
937 )}
457 </div>938 </div>
458 )}939 )}
459 940
@@ -475,7 +956,7 @@ function McpSourceForm(props: { · 15 unmodified lines
475 );956 );
476 }}957 }}
477 disabled={submitMutation.status === "pending"}958 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"
479 >960 >
480 {submitMutation.status === "pending"961 {submitMutation.status === "pending"
481 ? props.mode === "create"962 ? props.mode === "create"
@@ -486,12 +967,14 @@ function McpSourceForm(props: { · 4 unmodified lines
486 : "Save Changes"}967 : "Save Changes"}
487 </button>968 </button>
488 </div>969 </div>
970 </div>
489 </div>971 </div>
490 );972 );
491}973}
492 974
493function McpAddPage() {975function McpAddPage() {
494 const navigation = useSourcePluginNavigation();976 const navigation = useSourcePluginNavigation();
977 const initialValue = mcpInputFromSearch(useSourcePluginSearch());
495 const installation = useLocalInstallation();978 const installation = useLocalInstallation();
496 const client = getMcpHttpClient();979 const client = getMcpHttpClient();
497 const createSource = useAtomSet(980 const createSource = useAtomSet(
@@ -505,7 +988,7 @@ function McpAddPage() { · 7 unmodified lines
505 988
506 return (989 return (
507 <McpSourceForm990 <McpSourceForm
508 initialValue={defaultMcpInput()}991 initialValue={initialValue}
509 mode="create"992 mode="create"
510 onSubmit={async (input) => {993 onSubmit={async (input) => {
511 const source = await createSource({994 const source = await createSource({
@@ -562,13 +1045,13 @@ function McpEditPage(props: { · 50 unmodified lines
562 if (Result.isFailure(configResult)) {1045 if (Result.isFailure(configResult)) {
563 return (1046 return (
564 <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-3 text-sm text-destructive">1047 <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.
566 </div>1049 </div>
567 );1050 );
568 }1051 }
569 1052
570 if (!Result.isSuccess(configResult)) {1053 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>;
572 }1055 }
573 1056
574 return (1057 return (
@@ -614,6 +1097,8 @@ function McpDetailPage(props: { · 39 unmodified lines
614 client.mutation("mcp", "removeSource"),1097 client.mutation("mcp", "removeSource"),
615 { mode: "promise" },1098 { mode: "promise" },
616 );1099 );
1100 const [confirmDelete, setConfirmDelete] = useState(false);
1101 const [isDeleting, setIsDeleting] = useState(false);
617 const configResult = useAtomValue(1102 const configResult = useAtomValue(
618 installation.status === "ready"1103 installation.status === "ready"
619 ? client.query("mcp", "getSourceConfig", {1104 ? client.query("mcp", "getSourceConfig", {
@@ -657,6 +1142,35 @@ function McpDetailPage(props: { · 37 unmodified lines
657 return <div className="text-sm text-muted-foreground">Loading workspace...</div>;1142 return <div className="text-sm text-muted-foreground">Loading workspace...</div>;
658 }1143 }
659 1144
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
660 return (1174 return (
661 <SourceToolExplorer1175 <SourceToolExplorer
662 sourceId={props.source.id}1176 sourceId={props.source.id}
@@ -676,36 +1190,40 @@ function McpDetailPage(props: { · 13 unmodified lines
676 <IconPencil className="size-3.5" />1190 <IconPencil className="size-3.5" />
677 Edit1191 Edit
678 </button>1192 </button>
679 <button1193 {confirmDelete ? (
680 type="button"1194 <div className="flex items-center gap-2">
681 onClick={() => {1195 <span className="text-xs font-medium text-destructive">
682 const confirmed = window.confirm(`Delete MCP source "${props.source.name}"?`);1196 Confirm delete?
683 if (!confirmed) {1197 </span>
684 return;1198 <button
685 }1199 type="button"
686 1200 onClick={() => setConfirmDelete(false)}
687 void removeSource({1201 disabled={isDeleting}
688 path: {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"
689 workspaceId: installation.data.scopeId,1203 >
690 sourceId: props.source.id,1204 Cancel
691 },1205 </button>
692 reactivityKeys: {1206 <button
693 sources: [installation.data.scopeId],1207 type="button"
694 source: [installation.data.scopeId, props.source.id],1208 onClick={() => {
695 sourceInspection: [installation.data.scopeId, props.source.id],1209 void handleDelete().catch(() => {});
696 sourceInspectionTool: [installation.data.scopeId, props.source.id],1210 }}
697 sourceDiscovery: [installation.data.scopeId, props.source.id],1211 disabled={isDeleting}
698 },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"
699 }).then(() => {1213 >
700 startTransition(() => {1214 {isDeleting ? "Deleting..." : "Delete"}
701 void navigation.home();1215 </button>
702 });1216 </div>
703 });1217 ) : (
704 }}1218 <button
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"1219 type="button"
706 >1220 onClick={() => setConfirmDelete(true)}
707 Delete1221 disabled={isDeleting}
708 </button>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 )}
709 </>1227 </>
710 )}1228 )}
711 />1229 />
@@ -716,7 +1234,7 @@ const mcpSourceType = defineFrontendSourceType({ · 4 unmodified lines
716 key: "mcp",1234 key: "mcp",
717 kind: "mcp",1235 kind: "mcp",
718 displayName: "MCP",1236 displayName: "MCP",
719 description: "Connect remote or local MCP servers with plugin-owned OAuth.",1237 description: "Connect remote or local MCP servers.",
720 renderAddPage: McpAddPage,1238 renderAddPage: McpAddPage,
721 renderEditPage: McpEditPage,1239 renderEditPage: McpEditPage,
722 renderDetailPage: McpDetailPage,1240 renderDetailPage: McpDetailPage,
plugins/mcp/sdk/index.ts+22−0
@@ -19,6 +19,9 @@ import { · 18 unmodified lines
19 contentHash,19 contentHash,
20 createCatalogImportMetadata,20 createCatalogImportMetadata,
21 createSourceCatalogSyncResult,21 createSourceCatalogSyncResult,
22 normalizeSourceDiscoveryUrl,
23 probeHeadersFromAuth,
24 type SourceDiscoveryResult,
22} from "@executor/source-core";25} from "@executor/source-core";
23import type { Source } from "@executor/platform-sdk/schema";26import type { Source } from "@executor/platform-sdk/schema";
24import type {27import type {
@@ -41,6 +44,8 @@ import { · 16 unmodified lines
41 deriveMcpNamespace,44 deriveMcpNamespace,
42 resolveMcpEndpoint,45 resolveMcpEndpoint,
43 type McpConnectInput,46 type McpConnectInput,
47 type McpDiscoverInput,
48 type McpDiscoverResult,
44 type McpConnectionAuth,49 type McpConnectionAuth,
45 type McpOAuthPopupResult,50 type McpOAuthPopupResult,
46 type McpOAuthSession,51 type McpOAuthSession,
@@ -57,6 +62,9 @@ import { · 10 unmodified lines
57 createMcpCatalogFragment,62 createMcpCatalogFragment,
58 type McpCatalogOperationInput,63 type McpCatalogOperationInput,
59} from "./catalog";64} from "./catalog";
65import {
66 detectMcpSource,
67} from "./discovery";
60import {68import {
61 createPooledMcpConnector,69 createPooledMcpConnector,
62} from "./connection-pool";70} from "./connection-pool";
@@ -124,6 +132,9 @@ export type McpSdk = { · 61 unmodified lines
124 getSourceConfig: (132 getSourceConfig: (
125 sourceId: Source["id"],133 sourceId: Source["id"],
126 ) => Effect.Effect<McpSourceConfigPayload, Error>;134 ) => Effect.Effect<McpSourceConfigPayload, Error>;
135 discoverSource: (
136 input: McpDiscoverInput,
137 ) => Effect.Effect<McpDiscoverResult, Error>;
127 createSource: (138 createSource: (
128 input: McpConnectInput,139 input: McpConnectInput,
129 ) => Effect.Effect<Source, Error>;140 ) => Effect.Effect<Source, Error>;
@@ -328,6 +339,15 @@ const createMcpSourceSdk = ( · 198 unmodified lines
328 339
329 return sourceConfigFromStored(source, stored);340 return sourceConfigFromStored(source, stored);
330 }),341 }),
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 }),
331 createSource: (input: McpConnectInput) =>351 createSource: (input: McpConnectInput) =>
332 Effect.gen(function* () {352 Effect.gen(function* () {
333 const stored = yield* normalizeStoredSourceData(input);353 const stored = yield* normalizeStoredSourceData(input);
@@ -908,6 +928,8 @@ export const mcpSdkPlugin = ( · 574 unmodified lines
908 return {928 return {
909 getSourceConfig: (sourceId) =>929 getSourceConfig: (sourceId) =>
910 provideRuntime(sourceSdk.getSourceConfig(sourceId)),930 provideRuntime(sourceSdk.getSourceConfig(sourceId)),
931 discoverSource: (input) =>
932 provideRuntime(sourceSdk.discoverSource(input)),
911 createSource: (input) =>933 createSource: (input) =>
912 provideRuntime(sourceSdk.createSource(input)),934 provideRuntime(sourceSdk.createSource(input)),
913 updateSource: (input) =>935 updateSource: (input) =>
plugins/mcp/shared/index.ts+11−0
@@ -5,6 +5,8 @@ import { · 4 unmodified lines
5} from "@executor/platform-sdk/schema";5} from "@executor/platform-sdk/schema";
6import {6import {
7 JsonObjectSchema,7 JsonObjectSchema,
8 SourceDiscoveryResultSchema,
9 SourceProbeAuthSchema,
8 SourceTransportSchema,10 SourceTransportSchema,
9 StringArraySchema,11 StringArraySchema,
10 StringMapSchema,12 StringMapSchema,
@@ -70,6 +72,13 @@ export const McpStartOAuthInputSchema = Schema.Struct({ · 59 unmodified lines
70 redirectUrl: Schema.String,72 redirectUrl: Schema.String,
71});73});
72 74
75export const McpDiscoverInputSchema = Schema.Struct({
76 endpoint: Schema.String,
77 probeAuth: Schema.optional(Schema.NullOr(SourceProbeAuthSchema)),
78});
79
80export const McpDiscoverResultSchema = Schema.NullOr(SourceDiscoveryResultSchema);
81
73export const McpStartOAuthResultSchema = Schema.Struct({82export const McpStartOAuthResultSchema = Schema.Struct({
74 sessionId: Schema.String,83 sessionId: Schema.String,
75 authorizationUrl: Schema.String,84 authorizationUrl: Schema.String,
@@ -123,6 +132,8 @@ export type McpUpdateSourceInput = typeof McpUpdateSourceInputSchema.Type; · 47 unmodified lines
123export type McpStoredSourceData = typeof McpStoredSourceDataSchema.Type;132export type McpStoredSourceData = typeof McpStoredSourceDataSchema.Type;
124export type McpStartOAuthInput = typeof McpStartOAuthInputSchema.Type;133export type McpStartOAuthInput = typeof McpStartOAuthInputSchema.Type;
125export type McpStartOAuthResult = typeof McpStartOAuthResultSchema.Type;134export type McpStartOAuthResult = typeof McpStartOAuthResultSchema.Type;
135export type McpDiscoverInput = typeof McpDiscoverInputSchema.Type;
136export type McpDiscoverResult = typeof McpDiscoverResultSchema.Type;
126export type McpOAuthSession = typeof McpOAuthSessionSchema.Type;137export type McpOAuthSession = typeof McpOAuthSessionSchema.Type;
127export type McpOAuthPopupResult = typeof McpOAuthPopupResultSchema.Type;138export type McpOAuthPopupResult = typeof McpOAuthPopupResultSchema.Type;
128 139
plugins/openapi/react/index.tsx+387−152
@@ -9,8 +9,10 @@ import { · 8 unmodified lines
9 defineExecutorPluginHttpApiClient,9 defineExecutorPluginHttpApiClient,
10 useAtomValue,10 useAtomValue,
11 useAtomSet,11 useAtomSet,
12 useCreateSecret,
12 useExecutorMutation,13 useExecutorMutation,
13 usePrefetchToolDetail,14 usePrefetchToolDetail,
15 useSecrets,
14 useSourceDiscovery,16 useSourceDiscovery,
15 useSourceInspection,17 useSourceInspection,
16 useSourceToolDetail,18 useSourceToolDetail,
@@ -62,6 +64,93 @@ const defaultOpenApiInput = (): OpenApiConnectInput => ({ · 45 unmodified lines
62 },64 },
63});65});
64 66
67const DEFAULT_BEARER_HEADER_NAME = "Authorization";
68const DEFAULT_BEARER_PREFIX = "Bearer ";
69const CREATE_SECRET_VALUE = "__create_openapi_secret__";
70
71const 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
81const 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
95const 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
113const 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
139const 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
65const getOpenApiHttpClient = defineExecutorPluginHttpApiClient<"OpenApiReactHttpClient">()(154const getOpenApiHttpClient = defineExecutorPluginHttpApiClient<"OpenApiReactHttpClient">()(
66 "OpenApiReactHttpClient",155 "OpenApiReactHttpClient",
67 [openApiHttpApiExtension] as const,156 [openApiHttpApiExtension] as const,
@@ -72,13 +161,8 @@ const Section = (props: { · 4 unmodified lines
72 title: string;161 title: string;
73 children: ReactNode;162 children: ReactNode;
74}) => (163}) => (
75 <section className="rounded-xl border border-border/70 bg-card/40 p-4">164 <section>
76 <div className="mb-3 flex items-center gap-2">165 <h2 className="mb-3 text-sm font-semibold text-foreground">{props.title}</h2>
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>
82 {props.children}166 {props.children}
83 </section>167 </section>
84);168);
@@ -174,9 +258,26 @@ const secretValue = (input: OpenApiConnectInput["auth"]): string => · 89 unmodified lines
174 ? JSON.stringify(input.tokenSecretRef)258 ? JSON.stringify(input.tokenSecretRef)
175 : "";259 : "";
176 260
261const bearerHeaderNameValue = (input: OpenApiConnectInput["auth"]): string =>
262 input.kind === "bearer"
263 ? input.headerName?.trim() || DEFAULT_BEARER_HEADER_NAME
264 : DEFAULT_BEARER_HEADER_NAME;
265
266const bearerPrefixValue = (input: OpenApiConnectInput["auth"]): string =>
267 input.kind === "bearer"
268 ? input.prefix ?? DEFAULT_BEARER_PREFIX
269 : DEFAULT_BEARER_PREFIX;
270
271const trimToNull = (value: string): string | null => {
272 const trimmed = value.trim();
273 return trimmed.length > 0 ? trimmed : null;
274};
275
177const authFromSecretValue = (276const authFromSecretValue = (
178 authKind: OpenApiConnectInput["auth"]["kind"],277 authKind: OpenApiConnectInput["auth"]["kind"],
179 value: string,278 value: string,
279 headerName: string,
280 prefix: string,
180): OpenApiConnectInput["auth"] => {281): OpenApiConnectInput["auth"] => {
181 if (authKind === "none") {282 if (authKind === "none") {
182 return {283 return {
@@ -191,23 +292,138 @@ const authFromSecretValue = ( · 8 unmodified lines
191 return {292 return {
192 kind: "bearer",293 kind: "bearer",
193 tokenSecretRef: JSON.parse(value) as OpenApiConnectInput["auth"] & { tokenSecretRef: never }["tokenSecretRef"],294 tokenSecretRef: JSON.parse(value) as OpenApiConnectInput["auth"] & { tokenSecretRef: never }["tokenSecretRef"],
295 headerName: trimToNull(headerName),
296 prefix: prefix === DEFAULT_BEARER_PREFIX ? null : prefix,
194 };297 };
195};298};
196 299
197const useAvailableSecrets = (300function SecretSelectOrCreateField(props: {
198 openApiHttpClient: ReturnType<typeof getOpenApiHttpClient>,301 label: string;
199) => {302 value: string;
200 const secretsResult = useAtomValue(303 emptyLabel: string;
201 openApiHttpClient.query("local", "listSecrets", {304 draftNamePlaceholder: string;
202 reactivityKeys: {305 draftValuePlaceholder: string;
203 secrets: [],306 onChange: (value: string) => void;
204 },307}) {
205 timeToLive: "1 minute",308 const secrets = useSecrets();
206 }),309 const createSecret = useCreateSecret();
207 );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 }
208 321
209 return Result.isSuccess(secretsResult) ? secretsResult.value : [];322 setShowCreate(false);
210};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}
211 427
212function OpenApiSourceForm(props: {428function OpenApiSourceForm(props: {
213 mode: "create" | "edit";429 mode: "create" | "edit";
@@ -217,7 +433,6 @@ function OpenApiSourceForm(props: { · 3 unmodified lines
217 onSubmit: (input: OpenApiConnectInput) => Promise<void>;433 onSubmit: (input: OpenApiConnectInput) => Promise<void>;
218}) {434}) {
219 const openApiHttpClient = getOpenApiHttpClient();435 const openApiHttpClient = getOpenApiHttpClient();
220 const availableSecrets = useAvailableSecrets(openApiHttpClient);
221 const previewDocument = useAtomSet(436 const previewDocument = useAtomSet(
222 openApiHttpClient.mutation("openapi", "previewDocument"),437 openApiHttpClient.mutation("openapi", "previewDocument"),
223 { mode: "promise" },438 { mode: "promise" },
@@ -232,6 +447,12 @@ function OpenApiSourceForm(props: { · 8 unmodified lines
232 const [tokenSecretRef, setTokenSecretRef] = useState(447 const [tokenSecretRef, setTokenSecretRef] = useState(
233 secretValue(props.initialValue.auth),448 secretValue(props.initialValue.auth),
234 );449 );
450 const [authHeaderName, setAuthHeaderName] = useState(
451 bearerHeaderNameValue(props.initialValue.auth),
452 );
453 const [authPrefix, setAuthPrefix] = useState(
454 bearerPrefixValue(props.initialValue.auth),
455 );
235 const [error, setError] = useState<string | null>(null);456 const [error, setError] = useState<string | null>(null);
236 const [preview, setPreview] = useState<OpenApiPreviewResponse | null>(null);457 const [preview, setPreview] = useState<OpenApiPreviewResponse | null>(null);
237 const [nameEdited, setNameEdited] = useState(false);458 const [nameEdited, setNameEdited] = useState(false);
@@ -332,7 +553,12 @@ function OpenApiSourceForm(props: { · 94 unmodified lines
332 }553 }
333 554
334 try {555 try {
335 const auth = authFromSecretValue(authKind, tokenSecretRef);556 const auth = authFromSecretValue(
557 authKind,
558 tokenSecretRef,
559 authHeaderName,
560 authPrefix,
561 );
336 await submitMutation.mutateAsync({562 await submitMutation.mutateAsync({
337 name: trimmedName,563 name: trimmedName,
338 specUrl: trimmedSpecUrl,564 specUrl: trimmedSpecUrl,
@@ -345,15 +571,11 @@ function OpenApiSourceForm(props: { · 6 unmodified lines
345 };571 };
346 572
347 return (573 return (
348 <div className="space-y-4">574 <div className="space-y-6 rounded-xl border border-border p-6">
349 <Section title="Connection">575 <Section title="Connection">
350 <p className="text-sm text-muted-foreground">576 <div className="grid gap-4">
351 This plugin owns its typed HTTP client and source payload shape. The app shell only577 <label className="grid gap-2">
352 mounts the registered page.578 <span className="text-xs font-medium text-foreground">Name</span>
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>
357 <input579 <input
358 value={name}580 value={name}
359 onChange={(event) => {581 onChange={(event) => {
@@ -361,12 +583,12 @@ function OpenApiSourceForm(props: { · 1 unmodified line
361 setName(event.target.value);583 setName(event.target.value);
362 }}584 }}
363 placeholder="GitHub REST"585 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"
365 />587 />
366 </label>588 </label>
367 589
368 <label className="grid gap-1.5">590 <label className="grid gap-2">
369 <span className="text-[12px] font-medium text-foreground">Spec URL</span>591 <span className="text-xs font-medium text-foreground">Spec URL</span>
370 <input592 <input
371 value={specUrl}593 value={specUrl}
372 onChange={(event) => setSpecUrl(event.target.value)}594 onChange={(event) => setSpecUrl(event.target.value)}
@@ -374,12 +596,12 @@ function OpenApiSourceForm(props: { · 1 unmodified line
374 void runPreview({ mode: "manual" });596 void runPreview({ mode: "manual" });
375 }}597 }}
376 placeholder="https://example.com/openapi.json"598 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"
378 />600 />
379 </label>601 </label>
380 602
381 <label className="grid gap-1.5">603 <label className="grid gap-2">
382 <span className="text-[12px] font-medium text-foreground">Base URL</span>604 <span className="text-xs font-medium text-foreground">Base URL</span>
383 <input605 <input
384 value={baseUrl}606 value={baseUrl}
385 onChange={(event) => {607 onChange={(event) => {
@@ -387,17 +609,17 @@ function OpenApiSourceForm(props: { · 1 unmodified line
387 setBaseUrl(event.target.value);609 setBaseUrl(event.target.value);
388 }}610 }}
389 placeholder="https://api.example.com"611 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"
391 />613 />
392 </label>614 </label>
393 615
394 <label className="grid gap-1.5">616 <label className="grid gap-2">
395 <span className="text-[12px] font-medium text-foreground">Auth</span>617 <span className="text-xs font-medium text-foreground">Auth</span>
396 <select618 <select
397 value={authKind}619 value={authKind}
398 onChange={(event) =>620 onChange={(event) =>
399 setAuthKind(event.target.value as OpenApiConnectInput["auth"]["kind"])}621 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"
401 >623 >
402 <option value="none">None</option>624 <option value="none">None</option>
403 <option value="bearer">Bearer Secret</option>625 <option value="bearer">Bearer Secret</option>
@@ -405,52 +627,54 @@ function OpenApiSourceForm(props: { · 1 unmodified line
405 </label>627 </label>
406 628
407 {authKind === "bearer" && (629 {authKind === "bearer" && (
408 <label className="grid gap-1.5">630 <div className="space-y-4 border-l-2 border-border pl-4">
409 <span className="text-[12px] font-medium text-foreground">Secret</span>631 <SecretSelectOrCreateField
410 <select632 label="Secret"
411 value={tokenSecretRef}633 value={tokenSecretRef}
412 onChange={(event) => setTokenSecretRef(event.target.value)}634 emptyLabel="Select a secret"
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"635 draftNamePlaceholder="OpenAPI bearer token"
414 >636 draftValuePlaceholder="Paste the token value"
415 <option value="">Select a secret</option>637 onChange={setTokenSecretRef}
416 {availableSecrets.map((secret) => (638 />
417 <option639
418 key={`${secret.providerId}:${secret.id}`}640 <div className="grid gap-4 sm:grid-cols-2">
419 value={JSON.stringify({641 <label className="grid gap-2">
420 providerId: secret.providerId,642 <span className="text-xs font-medium text-foreground">Header Name</span>
421 handle: secret.id,643 <input
422 })}644 value={authHeaderName}
423 >645 onChange={(event) => setAuthHeaderName(event.target.value)}
424 {secret.name || secret.id}646 placeholder={DEFAULT_BEARER_HEADER_NAME}
425 </option>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"
426 ))}648 />
427 </select>649 </label>
428 </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>
429 )}662 )}
430 </div>663 </div>
431 </Section>664 </Section>
432 665
433 <Section title="Preview">666 <Section title="Preview">
434 <p className="text-sm text-muted-foreground">667 <div className="flex items-center gap-3">
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">
439 <button668 <button
440 type="button"669 type="button"
441 onClick={() => {670 onClick={() => {
442 void runPreview({ mode: "manual" });671 void runPreview({ mode: "manual" });
443 }}672 }}
444 disabled={previewMutation.status === "pending" || submitMutation.status === "pending"}673 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"
446 >675 >
447 {previewMutation.status === "pending" ? "Previewing..." : "Preview OpenAPI Document"}676 {previewMutation.status === "pending" ? "Previewing..." : "Preview Spec"}
448 </button>677 </button>
449 {previewMutation.status === "pending" && (
450 <div className="text-xs text-muted-foreground">
451 Inferring from spec...
452 </div>
453 )}
454 {preview && (678 {preview && (
455 <div className="text-xs text-muted-foreground">679 <div className="text-xs text-muted-foreground">
456 {preview.operationCount} operations680 {preview.operationCount} operations
@@ -459,35 +683,31 @@ function OpenApiSourceForm(props: { · 2 unmodified lines
459 )}683 )}
460 </div>684 </div>
461 {preview && (685 {preview && (
462 <div className="mt-4 rounded-lg border border-border/70 bg-background/60 p-4 text-sm">686 <div className="mt-3 rounded-lg border border-border bg-muted/30 px-4 py-3 text-sm">
463 <div className="grid gap-2">687 <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-xs">
464 <div>688 <dt className="font-medium text-foreground">Title</dt>
465 <span className="font-medium text-foreground">Title:</span>{" "}689 <dd className="text-muted-foreground">{preview.title ?? "—"}</dd>
466 <span className="text-muted-foreground">{preview.title ?? "Unknown"}</span>690 <dt className="font-medium text-foreground">Version</dt>
467 </div>691 <dd className="text-muted-foreground">{preview.version ?? "—"}</dd>
468 <div>692 <dt className="font-medium text-foreground">Base URL</dt>
469 <span className="font-medium text-foreground">Version:</span>{" "}693 <dd className="font-mono text-muted-foreground">{preview.baseUrl ?? "—"}</dd>
470 <span className="text-muted-foreground">{preview.version ?? "Unknown"}</span>694 {preview.namespace && (
471 </div>695 <>
472 <div>696 <dt className="font-medium text-foreground">Namespace</dt>
473 <span className="font-medium text-foreground">Base URL:</span>{" "}697 <dd className="text-muted-foreground">{preview.namespace}</dd>
474 <span className="text-muted-foreground">{preview.baseUrl ?? "Not declared"}</span>698 </>
475 </div>699 )}
476 <div>700 {preview.securitySchemes.length > 0 && (
477 <span className="font-medium text-foreground">Namespace:</span>{" "}701 <>
478 <span className="text-muted-foreground">{preview.namespace ?? "Not inferred"}</span>702 <dt className="font-medium text-foreground">Auth</dt>
479 </div>703 <dd className="text-muted-foreground">
480 <div>704 {preview.securitySchemes
481 <span className="font-medium text-foreground">Auth:</span>{" "}705 .map((scheme) => `${scheme.name} (${previewSecuritySchemeLabel(scheme)})`)
482 <span className="text-muted-foreground">706 .join(", ")}
483 {preview.securitySchemes.length > 0707 </dd>
484 ? preview.securitySchemes708 </>
485 .map((scheme) => `${scheme.name} (${previewSecuritySchemeLabel(scheme)})`)709 )}
486 .join(", ")710 </dl>
487 : "No declared auth schemes"}
488 </span>
489 </div>
490 </div>
491 {preview.warnings.length > 0 && (711 {preview.warnings.length > 0 && (
492 <div className="mt-3 rounded-md border border-amber-300/40 bg-amber-100/20 px-3 py-2 text-xs text-amber-800">712 <div className="mt-3 rounded-md border border-amber-300/40 bg-amber-100/20 px-3 py-2 text-xs text-amber-800">
493 {preview.warnings.join(" ")}713 {preview.warnings.join(" ")}
@@ -497,38 +717,31 @@ function OpenApiSourceForm(props: { · 3 unmodified lines
497 )}717 )}
498 </Section>718 </Section>
499 719
500 <Section title={props.mode === "create" ? "Submit" : "Save"}>720 {error && (
501 {error && (721 <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-2.5 text-sm text-destructive">
502 <div className="mb-3 rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-2.5 text-[13px] text-destructive">722 {error}
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>
522 </div>723 </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>
524 </div>738 </div>
525 );739 );
526}740}
527 741
528function OpenApiAddSourcePage(props: {742function OpenApiAddSourcePage() {
529 initialValue: OpenApiConnectInput;
530}) {
531 const navigation = useSourcePluginNavigation();743 const navigation = useSourcePluginNavigation();
744 const initialValue = openApiInputFromSearch(useSourcePluginSearch());
532 const openApiHttpClient = getOpenApiHttpClient();745 const openApiHttpClient = getOpenApiHttpClient();
533 const createSource = useAtomSet(746 const createSource = useAtomSet(
534 openApiHttpClient.mutation("openapi", "createSource"),747 openApiHttpClient.mutation("openapi", "createSource"),
@@ -539,7 +752,7 @@ function OpenApiAddSourcePage(props: { · 4 unmodified lines
539 return (752 return (
540 <OpenApiSourceForm753 <OpenApiSourceForm
541 mode="create"754 mode="create"
542 initialValue={props.initialValue}755 initialValue={initialValue}
543 submitLabel="Create Source"756 submitLabel="Create Source"
544 busyLabel="Creating..."757 busyLabel="Creating..."
545 onSubmit={async (payload) => {758 onSubmit={async (payload) => {
@@ -587,17 +800,17 @@ function OpenApiEditSourcePage(props: { · 41 unmodified lines
587 if (!Result.isSuccess(configResult)) {800 if (!Result.isSuccess(configResult)) {
588 if (Result.isFailure(configResult)) {801 if (Result.isFailure(configResult)) {
589 return (802 return (
590 <Section title="OpenAPI Plugin Editor">803 <Section title="Edit Source">
591 <div className="rounded-lg border border-destructive/30 bg-destructive/8 p-4 text-sm text-destructive">804 <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.
593 </div>806 </div>
594 </Section>807 </Section>
595 );808 );
596 }809 }
597 810
598 return (811 return (
599 <Section title="OpenAPI Plugin Editor">812 <Section title="Edit Source">
600 <div className="text-sm text-muted-foreground">Loading plugin config...</div>813 <div className="text-sm text-muted-foreground">Loading configuration...</div>
601 </Section>814 </Section>
602 );815 );
603 }816 }
@@ -692,6 +905,7 @@ function OpenApiSourceDetailPage(props: { · 88 unmodified lines
692 limit: 20,905 limit: 20,
693 });906 });
694 const toolDetail = useSourceToolDetail(props.source.id, selectedToolPath);907 const toolDetail = useSourceToolDetail(props.source.id, selectedToolPath);
908 const [confirmDelete, setConfirmDelete] = useState(false);
695 909
696 const setRouteSearch = (next: {910 const setRouteSearch = (next: {
697 tab?: "model" | "discover";911 tab?: "model" | "discover";
@@ -781,23 +995,46 @@ function OpenApiSourceDetailPage(props: { · 83 unmodified lines
781 <IconPencil className="size-3" />995 <IconPencil className="size-3" />
782 Edit996 Edit
783 </button>997 </button>
784 <button998 {confirmDelete ? (
785 type="button"999 <div className="flex items-center gap-2">
786 onClick={() => {1000 <span className="text-[11px] font-medium text-destructive">
787 const confirmed = window.confirm(`Delete OpenAPI source "${props.source.name}"?`);1001 Confirm delete?
788 if (!confirmed) return;1002 </span>
789 1003 <button
790 void removeMutation.mutateAsync(props.source.id).then(() => {1004 type="button"
791 startTransition(() => {1005 onClick={() => setConfirmDelete(false)}
792 void navigation.home();1006 disabled={removeMutation.status === "pending"}
793 });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"
794 });1008 >
795 }}1009 Cancel
796 disabled={removeMutation.status === "pending"}1010 </button>
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"1011 <button
798 >1012 type="button"
799 {removeMutation.status === "pending" ? "Deleting..." : "Delete"}1013 onClick={() => {
800 </button>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 )}
801 </div>1038 </div>
802 </div>1039 </div>
803 1040
@@ -1425,9 +1662,7 @@ const openApiSourceType = defineFrontendSourceType({ · 621 unmodified lines
1425 key: "openapi",1662 key: "openapi",
1426 kind: "openapi",1663 kind: "openapi",
1427 displayName: "OpenAPI",1664 displayName: "OpenAPI",
1428 renderAddPage: () => (1665 renderAddPage: OpenApiAddSourcePage,
1429 <OpenApiAddSourcePage initialValue={defaultOpenApiInput()} />
1430 ),
1431 renderEditPage: OpenApiEditSourcePage,1666 renderEditPage: OpenApiEditSourcePage,
1432 renderDetailPage: OpenApiSourceDetailPage,1667 renderDetailPage: OpenApiSourceDetailPage,
1433});1668});
plugins/openapi/sdk/index.ts+81−16
@@ -102,12 +102,49 @@ const OpenApiExecutorAddInputSchema = Schema.Struct({ · 101 unmodified lines
102 102
103type OpenApiExecutorAddInput = typeof OpenApiExecutorAddInputSchema.Type;103type OpenApiExecutorAddInput = typeof OpenApiExecutorAddInputSchema.Type;
104 104
105const 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
128const normalizeStoredSourceData = (
129 stored: OpenApiStoredSourceData,
130): OpenApiStoredSourceData => ({
131 ...stored,
132 auth: normalizeOpenApiAuth(stored.auth as Record<string, unknown>),
133});
134
105const createStoredSourceData = (135const createStoredSourceData = (
106 input: OpenApiConnectInput,136 input: OpenApiConnectInput,
107): OpenApiStoredSourceData => ({137): OpenApiStoredSourceData => ({
108 specUrl: input.specUrl.trim(),138 specUrl: input.specUrl.trim(),
109 baseUrl: input.baseUrl?.trim() || null,139 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,
111 defaultHeaders: null,148 defaultHeaders: null,
112 etag: null,149 etag: null,
113 lastSyncAt: null,150 lastSyncAt: null,
@@ -284,7 +321,10 @@ const createOpenApiSourceSdk = ( · 170 unmodified lines
284 );321 );
285 }322 }
286 323
287 return configFromStoredSourceData(source, stored);324 return configFromStoredSourceData(
325 source,
326 normalizeStoredSourceData(stored),
327 );
288 }),328 }),
289 createSource: (input: OpenApiConnectInput) =>329 createSource: (input: OpenApiConnectInput) =>
290 Effect.gen(function* () {330 Effect.gen(function* () {
@@ -424,6 +464,17 @@ const resolveBearerToken = ( · 133 unmodified lines
424 );464 );
425};465};
426 466
467const 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
474const resolveBearerPrefix = (
475 auth: Extract<OpenApiStoredSourceData["auth"], { kind: "bearer" }>,
476): string => auth.prefix ?? "Bearer ";
477
427const openApiDocumentHeaders = (input: {478const openApiDocumentHeaders = (input: {
428 stored: OpenApiStoredSourceData;479 stored: OpenApiStoredSourceData;
429 bearerToken: string | null;480 bearerToken: string | null;
@@ -434,8 +485,15 @@ const openApiDocumentHeaders = (input: { · 4 unmodified lines
434 for (const [key, value] of Object.entries(input.stored.defaultHeaders ?? {})) {485 for (const [key, value] of Object.entries(input.stored.defaultHeaders ?? {})) {
435 headers.set(key, value);486 headers.set(key, value);
436 }487 }
437 if (input.bearerToken && input.bearerToken.length > 0) {488 if (
438 headers.set("authorization", `Bearer ${input.bearerToken}`);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 );
439 }497 }
440 if (input.etag) {498 if (input.etag) {
441 headers.set("if-none-match", input.etag);499 headers.set("if-none-match", input.etag);
@@ -542,17 +600,18 @@ const createOpenApiSourceRuntime = ( · 100 unmodified lines
542 });600 });
543 }601 }
544 602
545 const bearerToken = yield* resolveBearerToken(stored);603 const normalizedStored = normalizeStoredSourceData(stored);
604 const bearerToken = yield* resolveBearerToken(normalizedStored);
546 const fetched = yield* fetchOpenApiDocument({605 const fetched = yield* fetchOpenApiDocument({
547 stored,606 stored: normalizedStored,
548 bearerToken,607 bearerToken,
549 });608 });
550 const manifest = yield* extractOpenApiManifest(source.name, fetched.text, {609 const manifest = yield* extractOpenApiManifest(source.name, fetched.text, {
551 documentUrl: stored.specUrl,610 documentUrl: normalizedStored.specUrl,
552 loadDocument: async (url) => {611 loadDocument: async (url) => {
553 const response = await fetch(url, {612 const response = await fetch(url, {
554 headers: openApiDocumentHeaders({613 headers: openApiDocumentHeaders({
555 stored,614 stored: normalizedStored,
556 bearerToken,615 bearerToken,
557 }),616 }),
558 });617 });
@@ -571,7 +630,7 @@ const createOpenApiSourceRuntime = ( · 12 unmodified lines
571 scopeId: source.scopeId,630 scopeId: source.scopeId,
572 sourceId: source.id,631 sourceId: source.id,
573 value: {632 value: {
574 ...stored,633 ...normalizedStored,
575 etag: fetched.etag,634 etag: fetched.etag,
576 lastSyncAt: now,635 lastSyncAt: now,
577 },636 },
@@ -583,7 +642,7 @@ const createOpenApiSourceRuntime = ( · 5 unmodified lines
583 documents: [642 documents: [
584 {643 {
585 documentKind: "openapi",644 documentKind: "openapi",
586 documentKey: stored.specUrl,645 documentKey: normalizedStored.specUrl,
587 contentText: fetched.text,646 contentText: fetched.text,
588 fetchedAt: now,647 fetchedAt: now,
589 },648 },
@@ -596,7 +655,7 @@ const createOpenApiSourceRuntime = ( · 6 unmodified lines
596 pluginKey: "openapi",655 pluginKey: "openapi",
597 }),656 }),
598 importerVersion: "ir.v1.openapi",657 importerVersion: "ir.v1.openapi",
599 sourceConfigHash: stableSourceHash(stored),658 sourceConfigHash: stableSourceHash(normalizedStored),
600 },659 },
601 sourceHash: manifest.sourceHash,660 sourceHash: manifest.sourceHash,
602 });661 });
@@ -613,6 +672,7 @@ const createOpenApiSourceRuntime = ( · 10 unmodified lines
613 );672 );
614 }673 }
615 674
675 const normalizedStored = normalizeStoredSourceData(stored);
616 const providerData = decodeProviderData(676 const providerData = decodeProviderData(
617 input.executable.binding,677 input.executable.binding,
618 ) as OpenApiToolProviderData;678 ) as OpenApiToolProviderData;
@@ -623,7 +683,7 @@ const createOpenApiSourceRuntime = ( · 4 unmodified lines
623 providerData.invocation,683 providerData.invocation,
624 );684 );
625 const headers: Record<string, string> = {685 const headers: Record<string, string> = {
626 ...(stored.defaultHeaders ?? {}),686 ...(normalizedStored.defaultHeaders ?? {}),
627 };687 };
628 const queryEntries: Array<{688 const queryEntries: Array<{
629 name: string;689 name: string;
@@ -678,14 +738,19 @@ const createOpenApiSourceRuntime = ( · 48 unmodified lines
678 }738 }
679 }739 }
680 740
681 const bearerToken = yield* resolveBearerToken(stored);741 const bearerToken = yield* resolveBearerToken(normalizedStored);
682 if (bearerToken && bearerToken.length > 0) {742 if (
683 headers.authorization = `Bearer ${bearerToken}`;743 bearerToken &&
744 bearerToken.length > 0 &&
745 normalizedStored.auth.kind === "bearer"
746 ) {
747 headers[resolveBearerHeaderName(normalizedStored.auth)] =
748 `${resolveBearerPrefix(normalizedStored.auth)}${bearerToken}`;
684 }749 }
685 750
686 const requestUrl = resolveRequestUrl(751 const requestUrl = resolveRequestUrl(
687 resolveOpenApiBaseUrl({752 resolveOpenApiBaseUrl({
688 stored,753 stored: normalizedStored,
689 providerData,754 providerData,
690 }),755 }),
691 resolvedPath,756 resolvedPath,
plugins/openapi/shared/index.ts+2−0
@@ -14,6 +14,8 @@ export const OpenApiConnectionAuthSchema = Schema.Union( · 13 unmodified lines
14 Schema.Struct({14 Schema.Struct({
15 kind: Schema.Literal("bearer"),15 kind: Schema.Literal("bearer"),
16 tokenSecretRef: SecretRefSchema,16 tokenSecretRef: SecretRefSchema,
17 headerName: Schema.NullOr(Schema.String),
18 prefix: Schema.NullOr(Schema.String),
17 }),19 }),
18);20);
19 21