History

ralph

Rhys Sullivan committed 7 months ago

Commit 01ee9a61 parent 263c9a378 files · +13644 −179Browse files

Unified · Split

78 files
  1. .executor/executor.jsonc +2 −2
  2. README.md +15 −6
  3. apps/executor/package.json +3 −0
  4. apps/executor/src/cli/main.ts +41 −6
  5. apps/web/package.json +3 −0
  6. apps/web/src/source-plugins/index.ts +12 −0
  7. apps/web/src/source-plugins/pages.tsx +82 −23
  8. apps/web/src/source-plugins/tool-explorer.tsx +473 −0
  9. apps/web/src/views/home.tsx +16 −6
  10. bun.lock +226 −0
  11. packages/hosts/mcp/src/index.ts +5 −7
  12. packages/platform/sdk/src/executor-effect.ts +31 −4
  13. packages/platform/sdk/src/plugins.ts +26 −7
  14. packages/platform/sdk/src/runtime/catalog/source/sync.ts +2 −2
  15. packages/platform/sdk/src/runtime/execution/scope/tool-invoker.ts +10 −3
  16. packages/platform/sdk/src/runtime/sources/executor-tools.ts +240 −20
  17. packages/platform/sdk/src/runtime/sources/source-plugins/index.ts +1 −0
  18. packages/platform/server/package.json +9 −0
  19. packages/platform/server/src/google-discovery-oauth-session-storage.ts +40 −0
  20. packages/platform/server/src/google-discovery-source-storage.ts +43 −0
  21. packages/platform/server/src/graphql-source-storage.ts +43 −0
  22. packages/platform/server/src/index.ts +43 −1
  23. packages/platform/server/src/json-file-storage.ts +79 −0
  24. packages/platform/server/src/mcp-oauth-session-storage.ts +40 −0
  25. packages/platform/server/src/mcp-source-storage.ts +43 −0
  26. plugins/google-discovery/http/index.ts +316 −0
  27. plugins/google-discovery/http/package.json +26 −0
  28. plugins/google-discovery/http/tsconfig.json +15 −0
  29. plugins/google-discovery/react/index.tsx +792 −0
  30. plugins/google-discovery/react/package.json +27 −0
  31. plugins/google-discovery/react/tsconfig.json +16 −0
  32. plugins/google-discovery/sdk/catalog.ts +390 −0
  33. plugins/google-discovery/sdk/discovery.ts +94 −0
  34. plugins/google-discovery/sdk/document.ts +549 −0
  35. plugins/google-discovery/sdk/index.ts +914 −0
  36. plugins/google-discovery/sdk/package.json +29 −0
  37. plugins/google-discovery/sdk/tools.ts +531 −0
  38. plugins/google-discovery/sdk/tsconfig.json +15 −0
  39. plugins/google-discovery/sdk/types.ts +129 −0
  40. plugins/google-discovery/shared/index.ts +155 −0
  41. plugins/google-discovery/shared/package.json +24 −0
  42. plugins/google-discovery/shared/tsconfig.json +15 −0
  43. plugins/graphql/http/index.ts +187 −0
  44. plugins/graphql/http/package.json +26 −0
  45. plugins/graphql/http/tsconfig.json +15 −0
  46. plugins/graphql/react/index.tsx +518 −0
  47. plugins/graphql/react/package.json +27 −0
  48. plugins/graphql/react/tsconfig.json +16 −0
  49. plugins/graphql/sdk/catalog.ts +317 −0
  50. plugins/graphql/sdk/discovery.ts +107 −0
  51. plugins/graphql/sdk/graphql-tools.ts +1737 −0
  52. plugins/graphql/sdk/index.ts +567 −0
  53. plugins/graphql/sdk/package.json +28 −0
  54. plugins/graphql/sdk/provider-data.ts +25 −0
  55. plugins/graphql/sdk/tsconfig.json +15 −0
  56. plugins/graphql/shared/index.ts +64 −0
  57. plugins/graphql/shared/package.json +24 −0
  58. plugins/graphql/shared/tsconfig.json +15 −0
  59. plugins/mcp/http/index.ts +301 −0
  60. plugins/mcp/http/package.json +26 −0
  61. plugins/mcp/http/tsconfig.json +15 −0
  62. plugins/mcp/react/index.tsx +793 −0
  63. plugins/mcp/react/package.json +27 −0
  64. plugins/mcp/react/tsconfig.json +16 −0
  65. plugins/mcp/sdk/catalog.ts +285 −0
  66. plugins/mcp/sdk/connection-pool.ts +151 −0
  67. plugins/mcp/sdk/connection.ts +222 −0
  68. plugins/mcp/sdk/discovery.ts +84 −0
  69. plugins/mcp/sdk/elicitation-bridge.ts +119 −0
  70. plugins/mcp/sdk/index.ts +1021 −0
  71. plugins/mcp/sdk/manifest.ts +363 −0
  72. plugins/mcp/sdk/package.json +29 −0
  73. plugins/mcp/sdk/tools.ts +581 −0
  74. plugins/mcp/sdk/tsconfig.json +13 −0
  75. plugins/mcp/shared/index.ts +163 −0
  76. plugins/mcp/shared/package.json +24 −0
  77. plugins/mcp/shared/tsconfig.json +15 −0
  78. plugins/openapi/sdk/index.ts +143 −92
.executor/executor.jsonc+2−2
@@ -1,8 +1,8 @@
11 {
22 "sources": {
33 "vercel-api": {
4− "kind": "openapi",
5− "name": "Vercel API"
4+ "name": "Vercel API",
5+ "kind": "openapi"
66 }
77 }
88 }
README.md+15−6
@@ -229,24 +229,33 @@ Examples: · 228 unmodified lines
229229
230230 ```ts
231231 return await tools.executor.sources.add({
232− endpoint: "https://example.com/mcp",
232+ kind: "mcp",
233233 name: "Example",
234− namespace: "example",
234+ endpoint: "https://example.com/mcp",
235+ transport: "auto",
236+ queryParams: null,
237+ headers: null,
238+ command: null,
239+ args: null,
240+ env: null,
241+ cwd: null,
242+ auth: { kind: "none" },
235243 });
236244 ```
237245
238246 ```ts
239247 return await tools.executor.sources.add({
240248 kind: "openapi",
241− endpoint: "https://api.github.com",
249+ name: "GitHub",
242250 specUrl:
243251 "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json",
244− name: "GitHub",
245− namespace: "github",
252+ baseUrl: null,
253+ auth: { kind: "none" },
246254 });
247255 ```
248256
249−For HTTP-style sources, `executor` can drive the credential flow for you.
257+`executor.sources.add(...)` accepts the final plugin-specific source config.
258+Browser OAuth and popup-driven auth flows live on the plugin-owned HTTP and web surfaces.
250259
251260 ## How execution works
252261
apps/executor/package.json+3−0
@@ -38,6 +38,9 @@ · 37 unmodified lines
3838 "@effect/platform-node": "catalog:",
3939 "@executor/codemode-core": "workspace:*",
4040 "@executor/executor-mcp": "workspace:*",
41+ "@executor/plugin-google-discovery-sdk": "workspace:*",
42+ "@executor/plugin-graphql-sdk": "workspace:*",
43+ "@executor/plugin-mcp-sdk": "workspace:*",
4144 "@executor/plugin-openapi-sdk": "workspace:*",
4245 "@executor/platform-api": "workspace:*",
4346 "@executor/platform-internal": "workspace:*",
apps/executor/src/cli/main.ts+41−6
@@ -14,12 +14,21 @@ import { · 13 unmodified lines
1414 type ExecutorApiEffectClient as ExecutorApiClient,
1515 } from "@executor/platform-api/effect";
1616 import { runExecutorMcpStdioServer } from "@executor/executor-mcp";
17+import {
18+ googleDiscoverySdkPlugin,
19+} from "@executor/plugin-google-discovery-sdk";
20+import {
21+ graphqlSdkPlugin,
22+} from "@executor/plugin-graphql-sdk";
23+import {
24+ mcpSdkPlugin,
25+} from "@executor/plugin-mcp-sdk";
1726 import {
1827 openApiSdkPlugin,
1928 } from "@executor/plugin-openapi-sdk";
2029 import { createWorkspaceExecutorAdminToolMap } from "@executor/platform-internal";
2130 import {
22− EXECUTOR_SOURCES_ADD_HELP_LINES,
31+ getExecutorSourcesAddHelpLines,
2332 RuntimeExecutionResolverService,
2433 } from "@executor/platform-sdk/runtime";
2534 import {
@@ -40,6 +49,11 @@ import * as Option from "effect/Option"; · 14 unmodified lines
4049 import * as Cause from "effect/Cause";
4150
4251 import {
52+ createFileGoogleDiscoveryOAuthSessionStorage,
53+ createFileGoogleDiscoverySourceStorage,
54+ createFileGraphqlSourceStorage,
55+ createFileMcpOAuthSessionStorage,
56+ createFileMcpSourceStorage,
4357 createFileOpenApiSourceStorage,
4458 createLocalExecutorServer,
4559 DEFAULT_SERVER_BASE_URL,
@@ -216,13 +230,13 @@ const buildWorkflowText = (namespaces: readonly string[] = []): string => · 170 unmodified lines
216230 '1) const matches = await tools.discover({ query: "<intent>", limit: 12 });',
217231 "2) const details = await tools.describe.tool({ path, includeSchemas: true });",
218232 "3) Call selected tools.<path>(input).",
219− "4) Source plugins are not registered in this build.",
220− ...EXECUTOR_SOURCES_ADD_HELP_LINES,
233+ "4) Use source plugins to inspect or add API sources.",
234+ ...getExecutorSourcesAddHelpLines(),
221235 "5) If execution pauses for interaction, resume it with `executor resume --execution-id ...`.",
222236 "Do not use fetch; use tools.* only.",
223237 ].join("\n");
224238
225−const DEFAULT_RUN_WORKFLOW = buildWorkflowText();
239+const getDefaultRunWorkflow = () => buildWorkflowText();
226240
227241 const indentBlock = (value: string, prefix: string = " "): string =>
228242 value
@@ -267,7 +281,7 @@ const buildRunWorkflowText = ( · 38 unmodified lines
267281 catalog?: ToolCatalog,
268282 ): Effect.Effect<string, Error, never> => {
269283 if (!catalog) {
270− return Effect.succeed(DEFAULT_RUN_WORKFLOW);
284+ return Effect.succeed(getDefaultRunWorkflow());
271285 }
272286
273287 return catalog.listNamespaces({ limit: 200 }).pipe(
@@ -289,6 +303,27 @@ const loadRunWorkflowText = (): Effect.Effect<string, Error, never> => · 15 unmodified lines
289303 localDataDir: DEFAULT_LOCAL_DATA_DIR,
290304 }),
291305 plugins: [
306+ graphqlSdkPlugin({
307+ storage: createFileGraphqlSourceStorage({
308+ rootDir: `${DEFAULT_LOCAL_DATA_DIR}/plugins/graphql/sources`,
309+ }),
310+ }),
311+ googleDiscoverySdkPlugin({
312+ storage: createFileGoogleDiscoverySourceStorage({
313+ rootDir: `${DEFAULT_LOCAL_DATA_DIR}/plugins/google-discovery/sources`,
314+ }),
315+ oauthSessions: createFileGoogleDiscoveryOAuthSessionStorage({
316+ rootDir: `${DEFAULT_LOCAL_DATA_DIR}/plugins/google-discovery/oauth-sessions`,
317+ }),
318+ }),
319+ mcpSdkPlugin({
320+ storage: createFileMcpSourceStorage({
321+ rootDir: `${DEFAULT_LOCAL_DATA_DIR}/plugins/mcp/sources`,
322+ }),
323+ oauthSessions: createFileMcpOAuthSessionStorage({
324+ rootDir: `${DEFAULT_LOCAL_DATA_DIR}/plugins/mcp/oauth-sessions`,
325+ }),
326+ }),
292327 openApiSdkPlugin({
293328 storage: createFileOpenApiSourceStorage({
294329 rootDir: `${DEFAULT_LOCAL_DATA_DIR}/plugins/openapi/sources`,
@@ -318,7 +353,7 @@ const loadRunWorkflowText = (): Effect.Effect<string, Error, never> => · 23 unmodified lines
318353 Effect.catchAllCause((cause) =>
319354 Effect.succeed(
320355 [
321− DEFAULT_RUN_WORKFLOW,
356+ getDefaultRunWorkflow(),
322357 "",
323358 formatCatalogUnavailableMessage(cause),
324359 ].join("\n"),
apps/web/package.json+3−0
@@ -9,6 +9,9 @@ · 8 unmodified lines
99 "start": "NODE_ENV=production bun src/server.ts"
1010 },
1111 "dependencies": {
12+ "@executor/plugin-google-discovery-react": "workspace:*",
13+ "@executor/plugin-graphql-react": "workspace:*",
14+ "@executor/plugin-mcp-react": "workspace:*",
1215 "@executor/plugin-openapi-react": "workspace:*",
1316 "@executor/react": "workspace:*",
1417 "@executor/server": "workspace:*",
apps/web/src/source-plugins/index.ts+12−0
@@ -1,3 +1,12 @@
1+import {
2+ GoogleDiscoveryReactPlugin,
3+} from "@executor/plugin-google-discovery-react";
4+import {
5+ GraphqlReactPlugin,
6+} from "@executor/plugin-graphql-react";
7+import {
8+ McpReactPlugin,
9+} from "@executor/plugin-mcp-react";
110 import {
211 OpenApiReactPlugin,
312 } from "@executor/plugin-openapi-react";
@@ -7,6 +16,9 @@ import type { · 3 unmodified lines
716 } from "./types";
817
918 const frontendPlugins = [
19+ McpReactPlugin,
20+ GraphqlReactPlugin,
21+ GoogleDiscoveryReactPlugin,
1022 OpenApiReactPlugin,
1123 ] as const satisfies readonly ExecutorFrontendPlugin[];
1224
apps/web/src/source-plugins/pages.tsx+82−23
@@ -1,9 +1,11 @@
1+import { useEffect, useState } from "react";
12 import { useSource } from "@executor/react";
23 import { LoadableBlock } from "../components/loadable";
34 import { SourcePluginsResetState } from "../components/source-plugins-reset-state";
45 import {
56 getDefaultSourceFrontendType,
67 getSourceFrontendType,
8+ registeredSourceFrontendTypes,
79 } from "./index";
810
911 export type SourcePluginRouteSearch = {
@@ -12,38 +14,73 @@ export type SourcePluginRouteSearch = { · 2 unmodified lines
1214 query?: string;
1315 };
1416
15−export function SourcePluginAddPage() {
16− const definition = getDefaultSourceFrontendType();
17− if (definition === null) {
18− return (
19− <SourcePluginsResetState
20− title="Add Source is intentionally blank"
21− message="No source plugins are registered in this build."
22− />
23− );
17+const SourcePluginPicker = (props: {
18+ selectedKind: string | null;
19+ onSelect: (kind: string) => void;
20+}) => {
21+ if (registeredSourceFrontendTypes.length <= 1) {
22+ return null;
2423 }
2524
2625 return (
27− <div className="mx-auto max-w-4xl px-6 py-10 lg:px-10 lg:py-14">
28− <div className="mb-8">
29− <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">
30− Source Plugin
31− </div>
32− <h1 className="mt-5 font-display text-3xl tracking-tight text-foreground lg:text-4xl">
26+ <div className="mb-8 flex flex-wrap gap-2">
27+ {registeredSourceFrontendTypes.map((definition) => (
28+ <button
29+ key={definition.kind}
30+ type="button"
31+ onClick={() => props.onSelect(definition.kind)}
32+ className={
33+ props.selectedKind === definition.kind
34+ ? "rounded-full border border-primary/30 bg-primary/10 px-3 py-1.5 text-xs font-medium text-foreground"
35+ : "rounded-full border border-border bg-muted px-3 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
36+ }
37+ >
3338 {definition.displayName}
34− </h1>
35− </div>
36− {definition.renderAddPage()}
39+ </button>
40+ ))}
3741 </div>
3842 );
39−}
43+};
44+
45+const useSelectedSourceFrontendType = () => {
46+ const defaultDefinition = getDefaultSourceFrontendType();
47+ const [selectedKind, setSelectedKind] = useState<string | null>(
48+ defaultDefinition?.kind ?? null,
49+ );
50+
51+ useEffect(() => {
52+ if (defaultDefinition === null) {
53+ setSelectedKind(null);
54+ return;
55+ }
56+
57+ if (
58+ selectedKind === null
59+ || getSourceFrontendType(selectedKind) === null
60+ ) {
61+ setSelectedKind(defaultDefinition.kind);
62+ }
63+ }, [defaultDefinition, selectedKind]);
64+
65+ return {
66+ selectedKind,
67+ setSelectedKind,
68+ definition:
69+ (selectedKind ? getSourceFrontendType(selectedKind) : null)
70+ ?? defaultDefinition,
71+ };
72+};
73+
74+const SourcePluginAddLayout = (props: {
75+ compact?: boolean;
76+}) => {
77+ const { definition, selectedKind, setSelectedKind } =
78+ useSelectedSourceFrontendType();
4079
41−export function SourcePluginCreatePage() {
42− const definition = getDefaultSourceFrontendType();
4380 if (definition === null) {
4481 return (
4582 <SourcePluginsResetState
46− title="New source creation is disabled"
83+ title="Source plugins are unavailable"
4784 message="No source plugins are registered in this build."
4885 />
4986 );
@@ -51,9 +88,31 @@ export function SourcePluginCreatePage() { · 1 unmodified line
5188
5289 return (
5390 <div className="mx-auto max-w-4xl px-6 py-10 lg:px-10 lg:py-14">
54− {definition.renderAddPage()}
91+ {!props.compact && (
92+ <div className="mb-8">
93+ <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">
94+ Source Plugin
95+ </div>
96+ <h1 className="mt-5 font-display text-3xl tracking-tight text-foreground lg:text-4xl">
97+ {definition.displayName}
98+ </h1>
99+ </div>
100+ )}
101+ <SourcePluginPicker
102+ selectedKind={selectedKind}
103+ onSelect={setSelectedKind}
104+ />
105+ {props.compact ? definition.renderAddPage() : definition.renderAddPage()}
55106 </div>
56107 );
108+};
109+
110+export function SourcePluginAddPage() {
111+ return <SourcePluginAddLayout />;
112+}
113+
114+export function SourcePluginCreatePage() {
115+ return <SourcePluginAddLayout compact />;
57116 }
58117
59118 export function SourcePluginEditPage(input: {
apps/web/src/source-plugins/tool-explorer.tsx+473−0
@@ -0,0 +1,473 @@
1+import { startTransition, useEffect, useState, type ReactNode } from "react";
2+import type {
3+ Loadable,
4+ SourceInspection,
5+ SourceInspectionToolDetail,
6+} from "@executor/react";
7+import {
8+ useSourceDiscovery,
9+ useSourceInspection,
10+ useSourceToolDetail,
11+} from "@executor/react";
12+import { DocumentPanel } from "../components/document-panel";
13+import {
14+ EmptyState,
15+ LoadableBlock,
16+} from "../components/loadable";
17+import { Markdown } from "../components/markdown";
18+import {
19+ IconCheck,
20+ IconCopy,
21+ IconPencil,
22+ IconSearch,
23+ IconTool,
24+} from "../components/icons";
25+import { Badge, MethodBadge } from "../components/ui/badge";
26+import { cn } from "../lib/utils";
27+
28+export type SourceToolExplorerSearch = {
29+ tab?: "model" | "discover";
30+ tool?: string;
31+ query?: string;
32+};
33+
34+const highlight = (text: string, query: string) => {
35+ const trimmed = query.trim().toLowerCase();
36+ if (!trimmed) {
37+ return text;
38+ }
39+
40+ const index = text.toLowerCase().indexOf(trimmed);
41+ if (index === -1) {
42+ return text;
43+ }
44+
45+ return (
46+ <>
47+ <span>{text.slice(0, index)}</span>
48+ <mark className="rounded-sm bg-primary/20 px-px text-foreground">
49+ {text.slice(index, index + trimmed.length)}
50+ </mark>
51+ <span>{text.slice(index + trimmed.length)}</span>
52+ </>
53+ );
54+};
55+
56+const ToolDetailPanel = (props: {
57+ detail: SourceInspectionToolDetail;
58+}) => {
59+ const [copied, setCopied] = useState<string | null>(null);
60+ const copy = async (value: string, key: string) => {
61+ await navigator.clipboard.writeText(value);
62+ setCopied(key);
63+ window.setTimeout(() => setCopied(null), 1200);
64+ };
65+
66+ return (
67+ <div className="space-y-4">
68+ <div className="flex items-start justify-between gap-3 rounded-xl border border-border/70 bg-card/60 p-4">
69+ <div className="min-w-0">
70+ <div className="flex items-center gap-2">
71+ <h3 className="truncate font-mono text-sm font-medium text-foreground">
72+ {props.detail.summary.path}
73+ </h3>
74+ <button
75+ type="button"
76+ onClick={() => {
77+ void copy(props.detail.summary.path, "path");
78+ }}
79+ className="rounded p-1 text-muted-foreground/50 transition-colors hover:text-foreground"
80+ >
81+ {copied === "path" ? <IconCheck /> : <IconCopy />}
82+ </button>
83+ </div>
84+ <div className="mt-2 flex flex-wrap items-center gap-2">
85+ {props.detail.summary.method && (
86+ <MethodBadge method={props.detail.summary.method} />
87+ )}
88+ {props.detail.summary.group && (
89+ <Badge variant="outline">{props.detail.summary.group}</Badge>
90+ )}
91+ {props.detail.summary.protocol && (
92+ <Badge variant="muted">{props.detail.summary.protocol}</Badge>
93+ )}
94+ </div>
95+ </div>
96+ </div>
97+
98+ {props.detail.summary.description && (
99+ <div className="rounded-xl border border-border/70 bg-card/40 p-4">
100+ <Markdown>{props.detail.summary.description}</Markdown>
101+ </div>
102+ )}
103+
104+ <div className="grid gap-4 xl:grid-cols-2">
105+ <DocumentPanel
106+ title="Input"
107+ body={
108+ props.detail.contract.input.typeDeclaration
109+ ?? props.detail.contract.input.typePreview
110+ ?? null
111+ }
112+ lang="typescript"
113+ empty="No input."
114+ />
115+ <DocumentPanel
116+ title="Output"
117+ body={
118+ props.detail.contract.output.typeDeclaration
119+ ?? props.detail.contract.output.typePreview
120+ ?? null
121+ }
122+ lang="typescript"
123+ empty="No output."
124+ />
125+ </div>
126+
127+ <DocumentPanel
128+ title="Call Signature"
129+ body={props.detail.contract.callSignature}
130+ lang="typescript"
131+ empty="No signature."
132+ />
133+
134+ <div className="grid gap-4 xl:grid-cols-2">
135+ <DocumentPanel
136+ title="Input Schema"
137+ body={props.detail.contract.input.schemaJson}
138+ empty="No input schema."
139+ compact
140+ />
141+ <DocumentPanel
142+ title="Output Schema"
143+ body={props.detail.contract.output.schemaJson}
144+ empty="No output schema."
145+ compact
146+ />
147+ </div>
148+ </div>
149+ );
150+};
151+
152+const DiscoveryPanel = (props: {
153+ sourceId: string;
154+ query: string;
155+ onOpenTool: (toolPath: string) => void;
156+ onQueryChange: (query: string) => void;
157+}) => {
158+ const [draftQuery, setDraftQuery] = useState(props.query);
159+ const results = useSourceDiscovery({
160+ sourceId: props.sourceId,
161+ query: props.query,
162+ limit: 20,
163+ });
164+
165+ useEffect(() => {
166+ setDraftQuery(props.query);
167+ }, [props.query]);
168+
169+ return (
170+ <div className="space-y-4">
171+ <form
172+ className="flex items-center gap-2"
173+ onSubmit={(event) => {
174+ event.preventDefault();
175+ props.onQueryChange(draftQuery.trim());
176+ }}
177+ >
178+ <div className="relative flex-1">
179+ <IconSearch className="absolute left-3 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground/40" />
180+ <input
181+ value={draftQuery}
182+ onChange={(event) => setDraftQuery(event.target.value)}
183+ placeholder="Search tools..."
184+ className="h-9 w-full rounded-lg border border-input bg-background pl-9 pr-3 text-sm outline-none transition-colors placeholder:text-muted-foreground/40 focus:border-ring focus:ring-1 focus:ring-ring/25"
185+ />
186+ </div>
187+ <button
188+ type="submit"
189+ className="inline-flex h-9 items-center rounded-lg border border-input bg-card px-3 text-xs font-medium text-foreground transition-colors hover:bg-accent/50"
190+ >
191+ Search
192+ </button>
193+ </form>
194+
195+ <LoadableBlock loadable={results} loading="Searching tools...">
196+ {(loaded) =>
197+ loaded.query.length === 0 ? (
198+ <EmptyState
199+ title="Search this source"
200+ description="Enter a query to find relevant tools."
201+ />
202+ ) : loaded.results.length === 0 ? (
203+ <EmptyState
204+ title="No results"
205+ description="Try different search terms."
206+ />
207+ ) : (
208+ <div className="space-y-2">
209+ {loaded.results.map((item, index) => (
210+ <button
211+ key={item.path}
212+ type="button"
213+ onClick={() => props.onOpenTool(item.path)}
214+ className="w-full rounded-xl border border-border/70 bg-card/50 p-3 text-left transition-colors hover:border-primary/30 hover:bg-card"
215+ >
216+ <div className="flex items-center justify-between gap-3">
217+ <div className="flex min-w-0 items-center gap-2">
218+ <span className="inline-flex size-5 items-center justify-center rounded-md bg-muted text-[10px] text-muted-foreground">
219+ {index + 1}
220+ </span>
221+ <span className="truncate font-mono text-xs text-foreground">
222+ {item.path}
223+ </span>
224+ </div>
225+ <span className="font-mono text-[11px] text-muted-foreground/60">
226+ {item.score.toFixed(2)}
227+ </span>
228+ </div>
229+ {item.description && (
230+ <div className="mt-2 text-xs text-muted-foreground">
231+ {item.description}
232+ </div>
233+ )}
234+ </button>
235+ ))}
236+ </div>
237+ )
238+ }
239+ </LoadableBlock>
240+ </div>
241+ );
242+};
243+
244+export const SourceToolExplorer = (props: {
245+ sourceId: string;
246+ title: string;
247+ kind: string;
248+ search: SourceToolExplorerSearch;
249+ navigate?: (search: {
250+ tab: "model" | "discover";
251+ tool?: string;
252+ query?: string;
253+ }) => void | Promise<void>;
254+ actions?: ReactNode;
255+ summary?: ReactNode;
256+}) => {
257+ const inspection = useSourceInspection(props.sourceId);
258+ const tab = props.search.tab === "discover" ? "discover" : "model";
259+ const toolPath = props.search.tool ?? null;
260+ const detail = useSourceToolDetail(props.sourceId, tab === "model" ? toolPath : null);
261+ const [filter, setFilter] = useState("");
262+ const navigate = props.navigate;
263+
264+ useEffect(() => {
265+ if (
266+ tab !== "model"
267+ || inspection.status !== "ready"
268+ || props.search.tool
269+ || !navigate
270+ ) {
271+ return;
272+ }
273+
274+ const firstTool = inspection.data.tools[0]?.path;
275+ if (!firstTool) {
276+ return;
277+ }
278+
279+ startTransition(() => {
280+ void navigate({
281+ tab: "model",
282+ tool: firstTool,
283+ query: props.search.query,
284+ });
285+ });
286+ }, [inspection, navigate, props.search.query, props.search.tool, tab]);
287+
288+ return (
289+ <LoadableBlock loadable={inspection} loading="Loading source...">
290+ {(loadedInspection) => {
291+ const selectedTool =
292+ loadedInspection.tools.find((candidate) => candidate.path === toolPath)
293+ ?? loadedInspection.tools[0]
294+ ?? null;
295+ const visibleTools = loadedInspection.tools.filter((tool) => {
296+ const query = filter.trim().toLowerCase();
297+ if (!query) {
298+ return true;
299+ }
300+
301+ return [
302+ tool.path,
303+ tool.method ?? "",
304+ tool.inputTypePreview ?? "",
305+ tool.outputTypePreview ?? "",
306+ ]
307+ .join(" ")
308+ .toLowerCase()
309+ .includes(query);
310+ });
311+
312+ return (
313+ <div className="space-y-4">
314+ <div className="rounded-2xl border border-border bg-card px-5 py-4">
315+ <div className="flex items-start justify-between gap-4">
316+ <div className="min-w-0">
317+ <div className="flex flex-wrap items-center gap-2">
318+ <h2 className="truncate text-lg font-semibold text-foreground">
319+ {props.title}
320+ </h2>
321+ <Badge variant="outline">{props.kind}</Badge>
322+ <Badge variant="muted">
323+ {loadedInspection.toolCount} tool{loadedInspection.toolCount === 1 ? "" : "s"}
324+ </Badge>
325+ </div>
326+ {props.summary && (
327+ <div className="mt-3 text-sm text-muted-foreground">
328+ {props.summary}
329+ </div>
330+ )}
331+ </div>
332+ <div className="flex items-center gap-2">
333+ <div className="inline-flex rounded-lg bg-muted p-0.5">
334+ {(["model", "discover"] as const).map((entry) => (
335+ <button
336+ key={entry}
337+ type="button"
338+ onClick={() => {
339+ if (!navigate) {
340+ return;
341+ }
342+
343+ startTransition(() => {
344+ void navigate({
345+ tab: entry,
346+ tool: entry === "model" ? (selectedTool?.path ?? undefined) : props.search.tool,
347+ query: props.search.query,
348+ });
349+ });
350+ }}
351+ className={cn(
352+ "rounded-md px-3 py-1 text-xs font-medium transition-colors",
353+ tab === entry
354+ ? "bg-background text-foreground shadow-sm"
355+ : "text-muted-foreground hover:text-foreground",
356+ )}
357+ >
358+ {entry === "model" ? "Tools" : "Search"}
359+ </button>
360+ ))}
361+ </div>
362+ {props.actions}
363+ </div>
364+ </div>
365+ </div>
366+
367+ {tab === "discover" ? (
368+ <DiscoveryPanel
369+ sourceId={props.sourceId}
370+ query={props.search.query ?? ""}
371+ onQueryChange={(query) => {
372+ if (!navigate) {
373+ return;
374+ }
375+
376+ startTransition(() => {
377+ void navigate({
378+ tab: "discover",
379+ tool: props.search.tool,
380+ query,
381+ });
382+ });
383+ }}
384+ onOpenTool={(path) => {
385+ if (!navigate) {
386+ return;
387+ }
388+
389+ startTransition(() => {
390+ void navigate({
391+ tab: "model",
392+ tool: path,
393+ query: props.search.query,
394+ });
395+ });
396+ }}
397+ />
398+ ) : (
399+ <div className="grid gap-4 lg:grid-cols-[18rem_minmax(0,1fr)]">
400+ <div className="space-y-3 rounded-2xl border border-border bg-card p-4">
401+ <div className="relative">
402+ <IconSearch className="absolute left-3 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground/40" />
403+ <input
404+ value={filter}
405+ onChange={(event) => setFilter(event.target.value)}
406+ placeholder="Filter tools..."
407+ className="h-9 w-full rounded-lg border border-input bg-background pl-9 pr-3 text-sm outline-none transition-colors placeholder:text-muted-foreground/40 focus:border-ring focus:ring-1 focus:ring-ring/25"
408+ />
409+ </div>
410+ <div className="space-y-1">
411+ {visibleTools.length === 0 ? (
412+ <EmptyState
413+ title="No tools match"
414+ description="Try a different filter."
415+ />
416+ ) : (
417+ visibleTools.map((tool) => (
418+ <button
419+ key={tool.path}
420+ type="button"
421+ onClick={() => {
422+ if (!navigate) {
423+ return;
424+ }
425+
426+ startTransition(() => {
427+ void navigate({
428+ tab: "model",
429+ tool: tool.path,
430+ query: props.search.query,
431+ });
432+ });
433+ }}
434+ className={cn(
435+ "flex w-full items-center gap-2 rounded-lg border px-3 py-2 text-left transition-colors",
436+ selectedTool?.path === tool.path
437+ ? "border-primary/30 bg-primary/8"
438+ : "border-transparent bg-muted/30 hover:border-border hover:bg-card",
439+ )}
440+ >
441+ <IconTool className="size-3.5 shrink-0 text-muted-foreground/60" />
442+ <span className="min-w-0 flex-1 truncate font-mono text-xs text-foreground">
443+ {highlight(tool.path, filter)}
444+ </span>
445+ {tool.method && <MethodBadge method={tool.method} />}
446+ </button>
447+ ))
448+ )}
449+ </div>
450+ </div>
451+
452+ <div className="rounded-2xl border border-border bg-card p-4">
453+ <LoadableBlock loadable={detail} loading="Loading tool...">
454+ {(loadedDetail) =>
455+ loadedDetail ? (
456+ <ToolDetailPanel detail={loadedDetail} />
457+ ) : (
458+ <EmptyState
459+ title="Select a tool"
460+ description="Choose a tool from the list to inspect its contract."
461+ />
462+ )
463+ }
464+ </LoadableBlock>
465+ </div>
466+ </div>
467+ )}
468+ </div>
469+ );
470+ }}
471+ </LoadableBlock>
472+ );
473+};
apps/web/src/views/home.tsx+16−6
@@ -4,6 +4,7 @@ import { LoadableBlock } from "../components/loadable"; · 3 unmodified lines
44 import { Badge } from "../components/ui/badge";
55 import { Button } from "../components/ui/button";
66 import { SourcePluginsResetState } from "../components/source-plugins-reset-state";
7+import { registeredSourceFrontendTypes } from "../source-plugins";
78
89 export function HomePage() {
910 const sources = useSources();
@@ -16,13 +17,22 @@ export function HomePage() { · 6 unmodified lines
1617 Sources
1718 </div>
1819 <h1 className="mt-5 font-display text-3xl tracking-tight text-foreground lg:text-4xl">
19− OpenAPI is now mounted as a source plugin
20+ Source plugins now own source-specific product behavior
2021 </h1>
2122 <p className="mt-3 max-w-3xl text-sm leading-6 text-muted-foreground">
22− The product shell is still in transition, but the first plugin-backed
23− surface is wired in for OpenAPI so the frontend and backend boundaries
24− are visible in the app again.
23+ Add, edit, inspect, and authorize sources through plugin-owned UI and
24+ API surfaces. The core app stays generic while each plugin carries its
25+ own transport, auth, and storage behavior.
2526 </p>
27+ {registeredSourceFrontendTypes.length > 0 && (
28+ <div className="mt-5 flex flex-wrap gap-2">
29+ {registeredSourceFrontendTypes.map((definition) => (
30+ <Badge key={definition.kind} variant="outline">
31+ {definition.displayName}
32+ </Badge>
33+ ))}
34+ </div>
35+ )}
2636 </div>
2737
2838 <div className="mt-8">
@@ -36,11 +46,11 @@ export function HomePage() { · 7 unmodified lines
3646 ) : items.length === 0 ? (
3747 <SourcePluginsResetState
3848 title="No sources connected yet"
39− message="The OpenAPI source plugin is registered. Use the add flow to inspect the plugin-owned UI surface."
49+ message="Use the add flow to connect an OpenAPI, GraphQL, MCP, or Google Discovery source through its plugin-owned surface."
4050 action={(
4151 <Link to="/sources/add">
4252 <Button size="sm" variant="outline">
43− Open OpenAPI Plugin
53+ Add Source
4454 </Button>
4555 </Link>
4656 )}
bun.lock+226−0
@@ -32,6 +32,9 @@ · 31 unmodified lines
3232 "@executor/platform-internal": "workspace:*",
3333 "@executor/platform-sdk": "workspace:*",
3434 "@executor/platform-sdk-file": "workspace:*",
35+ "@executor/plugin-google-discovery-sdk": "workspace:*",
36+ "@executor/plugin-graphql-sdk": "workspace:*",
37+ "@executor/plugin-mcp-sdk": "workspace:*",
3538 "@executor/plugin-openapi-sdk": "workspace:*",
3639 "@executor/server": "workspace:*",
3740 "effect": "catalog:",
@@ -52,6 +55,9 @@ · 14 unmodified lines
5255 "name": "@executor/web",
5356 "version": "0.0.0",
5457 "dependencies": {
58+ "@executor/plugin-google-discovery-react": "workspace:*",
59+ "@executor/plugin-graphql-react": "workspace:*",
60+ "@executor/plugin-mcp-react": "workspace:*",
5561 "@executor/plugin-openapi-react": "workspace:*",
5662 "@executor/react": "workspace:*",
5763 "@executor/server": "workspace:*",
@@ -394,6 +400,15 @@ · 336 unmodified lines
394400 "@executor/platform-internal": "workspace:*",
395401 "@executor/platform-sdk": "workspace:*",
396402 "@executor/platform-sdk-file": "workspace:*",
403+ "@executor/plugin-google-discovery-http": "workspace:*",
404+ "@executor/plugin-google-discovery-sdk": "workspace:*",
405+ "@executor/plugin-google-discovery-shared": "workspace:*",
406+ "@executor/plugin-graphql-http": "workspace:*",
407+ "@executor/plugin-graphql-sdk": "workspace:*",
408+ "@executor/plugin-graphql-shared": "workspace:*",
409+ "@executor/plugin-mcp-http": "workspace:*",
410+ "@executor/plugin-mcp-sdk": "workspace:*",
411+ "@executor/plugin-mcp-shared": "workspace:*",
397412 "@executor/plugin-openapi-http": "workspace:*",
398413 "@executor/plugin-openapi-sdk": "workspace:*",
399414 "@executor/plugin-openapi-shared": "workspace:*",
@@ -425,6 +440,191 @@ · 25 unmodified lines
425440 "bun-types": "catalog:",
426441 },
427442 },
443+ "plugins/google-discovery/http": {
444+ "name": "@executor/plugin-google-discovery-http",
445+ "version": "0.0.0",
446+ "dependencies": {
447+ "@effect/platform": "catalog:",
448+ "@executor/platform-api": "workspace:*",
449+ "@executor/platform-sdk": "workspace:*",
450+ "@executor/plugin-google-discovery-shared": "workspace:*",
451+ "effect": "catalog:",
452+ },
453+ "devDependencies": {
454+ "@types/node": "catalog:",
455+ "bun-types": "catalog:",
456+ },
457+ },
458+ "plugins/google-discovery/react": {
459+ "name": "@executor/plugin-google-discovery-react",
460+ "version": "0.0.0",
461+ "dependencies": {
462+ "@executor/plugin-google-discovery-http": "workspace:*",
463+ "@executor/plugin-google-discovery-shared": "workspace:*",
464+ "@executor/react": "workspace:*",
465+ "@tanstack/react-router": "^1.132.7",
466+ "react": "^19.2.0",
467+ },
468+ "devDependencies": {
469+ "@types/node": "catalog:",
470+ "@types/react": "^19.2.10",
471+ "bun-types": "catalog:",
472+ },
473+ },
474+ "plugins/google-discovery/sdk": {
475+ "name": "@executor/plugin-google-discovery-sdk",
476+ "version": "0.0.0",
477+ "dependencies": {
478+ "@effect/platform": "catalog:",
479+ "@executor/auth-oauth2": "workspace:*",
480+ "@executor/codemode-core": "workspace:*",
481+ "@executor/ir": "workspace:*",
482+ "@executor/platform-sdk": "workspace:*",
483+ "@executor/plugin-google-discovery-shared": "workspace:*",
484+ "@executor/source-core": "workspace:*",
485+ "effect": "catalog:",
486+ },
487+ "devDependencies": {
488+ "@types/node": "catalog:",
489+ "bun-types": "catalog:",
490+ },
491+ },
492+ "plugins/google-discovery/shared": {
493+ "name": "@executor/plugin-google-discovery-shared",
494+ "version": "0.0.0",
495+ "dependencies": {
496+ "@executor/platform-sdk": "workspace:*",
497+ "@executor/source-core": "workspace:*",
498+ "effect": "catalog:",
499+ },
500+ "devDependencies": {
501+ "@types/node": "catalog:",
502+ "bun-types": "catalog:",
503+ },
504+ },
505+ "plugins/graphql/http": {
506+ "name": "@executor/plugin-graphql-http",
507+ "version": "0.0.0",
508+ "dependencies": {
509+ "@effect/platform": "catalog:",
510+ "@executor/platform-api": "workspace:*",
511+ "@executor/platform-sdk": "workspace:*",
512+ "@executor/plugin-graphql-shared": "workspace:*",
513+ "effect": "catalog:",
514+ },
515+ "devDependencies": {
516+ "@types/node": "catalog:",
517+ "bun-types": "catalog:",
518+ },
519+ },
520+ "plugins/graphql/react": {
521+ "name": "@executor/plugin-graphql-react",
522+ "version": "0.0.0",
523+ "dependencies": {
524+ "@executor/plugin-graphql-http": "workspace:*",
525+ "@executor/plugin-graphql-shared": "workspace:*",
526+ "@executor/react": "workspace:*",
527+ "@tanstack/react-router": "^1.132.7",
528+ "react": "^19.2.0",
529+ },
530+ "devDependencies": {
531+ "@types/node": "catalog:",
532+ "@types/react": "^19.2.10",
533+ "bun-types": "catalog:",
534+ },
535+ },
536+ "plugins/graphql/sdk": {
537+ "name": "@executor/plugin-graphql-sdk",
538+ "version": "0.0.0",
539+ "dependencies": {
540+ "@executor/codemode-core": "workspace:*",
541+ "@executor/ir": "workspace:*",
542+ "@executor/platform-sdk": "workspace:*",
543+ "@executor/plugin-graphql-shared": "workspace:*",
544+ "@executor/source-core": "workspace:*",
545+ "effect": "catalog:",
546+ "graphql": "^16.12.0",
547+ },
548+ "devDependencies": {
549+ "@types/node": "catalog:",
550+ "bun-types": "catalog:",
551+ },
552+ },
553+ "plugins/graphql/shared": {
554+ "name": "@executor/plugin-graphql-shared",
555+ "version": "0.0.0",
556+ "dependencies": {
557+ "@executor/platform-sdk": "workspace:*",
558+ "@executor/source-core": "workspace:*",
559+ "effect": "catalog:",
560+ },
561+ "devDependencies": {
562+ "@types/node": "catalog:",
563+ "bun-types": "catalog:",
564+ },
565+ },
566+ "plugins/mcp/http": {
567+ "name": "@executor/plugin-mcp-http",
568+ "version": "0.0.0",
569+ "dependencies": {
570+ "@effect/platform": "catalog:",
571+ "@executor/platform-api": "workspace:*",
572+ "@executor/platform-sdk": "workspace:*",
573+ "@executor/plugin-mcp-shared": "workspace:*",
574+ "effect": "catalog:",
575+ },
576+ "devDependencies": {
577+ "@types/node": "catalog:",
578+ "bun-types": "catalog:",
579+ },
580+ },
581+ "plugins/mcp/react": {
582+ "name": "@executor/plugin-mcp-react",
583+ "version": "0.0.0",
584+ "dependencies": {
585+ "@executor/plugin-mcp-http": "workspace:*",
586+ "@executor/plugin-mcp-shared": "workspace:*",
587+ "@executor/react": "workspace:*",
588+ "@tanstack/react-router": "^1.132.7",
589+ "react": "^19.2.0",
590+ },
591+ "devDependencies": {
592+ "@types/node": "catalog:",
593+ "@types/react": "^19.2.10",
594+ "bun-types": "catalog:",
595+ },
596+ },
597+ "plugins/mcp/sdk": {
598+ "name": "@executor/plugin-mcp-sdk",
599+ "version": "0.0.0",
600+ "dependencies": {
601+ "@executor/auth-mcp-oauth": "workspace:*",
602+ "@executor/codemode-core": "workspace:*",
603+ "@executor/ir": "workspace:*",
604+ "@executor/platform-sdk": "workspace:*",
605+ "@executor/plugin-mcp-shared": "workspace:*",
606+ "@executor/source-core": "workspace:*",
607+ "@modelcontextprotocol/sdk": "^1.26.0",
608+ "effect": "catalog:",
609+ },
610+ "devDependencies": {
611+ "@types/node": "catalog:",
612+ "bun-types": "catalog:",
613+ },
614+ },
615+ "plugins/mcp/shared": {
616+ "name": "@executor/plugin-mcp-shared",
617+ "version": "0.0.0",
618+ "dependencies": {
619+ "@executor/platform-sdk": "workspace:*",
620+ "@executor/source-core": "workspace:*",
621+ "effect": "catalog:",
622+ },
623+ "devDependencies": {
624+ "@types/node": "catalog:",
625+ "bun-types": "catalog:",
626+ },
627+ },
428628 "plugins/openapi/http": {
429629 "name": "@executor/plugin-openapi-http",
430630 "version": "0.0.0",
@@ -717,6 +917,30 @@ · 286 unmodified lines
717917
718918 "@executor/platform-sdk-file": ["@executor/platform-sdk-file@workspace:packages/platform/sdk-file"],
719919
920+ "@executor/plugin-google-discovery-http": ["@executor/plugin-google-discovery-http@workspace:plugins/google-discovery/http"],
921+
922+ "@executor/plugin-google-discovery-react": ["@executor/plugin-google-discovery-react@workspace:plugins/google-discovery/react"],
923+
924+ "@executor/plugin-google-discovery-sdk": ["@executor/plugin-google-discovery-sdk@workspace:plugins/google-discovery/sdk"],
925+
926+ "@executor/plugin-google-discovery-shared": ["@executor/plugin-google-discovery-shared@workspace:plugins/google-discovery/shared"],
927+
928+ "@executor/plugin-graphql-http": ["@executor/plugin-graphql-http@workspace:plugins/graphql/http"],
929+
930+ "@executor/plugin-graphql-react": ["@executor/plugin-graphql-react@workspace:plugins/graphql/react"],
931+
932+ "@executor/plugin-graphql-sdk": ["@executor/plugin-graphql-sdk@workspace:plugins/graphql/sdk"],
933+
934+ "@executor/plugin-graphql-shared": ["@executor/plugin-graphql-shared@workspace:plugins/graphql/shared"],
935+
936+ "@executor/plugin-mcp-http": ["@executor/plugin-mcp-http@workspace:plugins/mcp/http"],
937+
938+ "@executor/plugin-mcp-react": ["@executor/plugin-mcp-react@workspace:plugins/mcp/react"],
939+
940+ "@executor/plugin-mcp-sdk": ["@executor/plugin-mcp-sdk@workspace:plugins/mcp/sdk"],
941+
942+ "@executor/plugin-mcp-shared": ["@executor/plugin-mcp-shared@workspace:plugins/mcp/shared"],
943+
720944 "@executor/plugin-openapi-http": ["@executor/plugin-openapi-http@workspace:plugins/openapi/http"],
721945
722946 "@executor/plugin-openapi-react": ["@executor/plugin-openapi-react@workspace:plugins/openapi/react"],
@@ -1373,6 +1597,8 @@ · 650 unmodified lines
13731597
13741598 "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
13751599
1600+ "graphql": ["graphql@16.13.1", "", {}, "sha512-gGgrVCoDKlIZ8fIqXBBb0pPKqDgki0Z/FSKNiQzSGj2uEYHr1tq5wmBegGwJx6QB5S5cM0khSBpi/JFHMCvsmQ=="],
1601+
13761602 "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
13771603
13781604 "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
packages/hosts/mcp/src/index.ts+5−7
@@ -2,7 +2,7 @@ import type { · 1 unmodified line
22 ExecutionEnvelope,
33 } from "@executor/platform-sdk/schema";
44 import {
5− EXECUTOR_SOURCES_ADD_HELP_LINES,
5+ getExecutorSourcesAddHelpLines,
66 RuntimeSourceCatalogStoreService,
77 createExecution,
88 getExecution,
@@ -172,14 +172,12 @@ const buildExecuteWorkflowText = ( · 163 unmodified lines
172172 '1) const matches = await tools.discover({ query: "<intent>", limit: 12 });',
173173 "2) const details = await tools.describe.tool({ path, includeSchemas: true });",
174174 "3) Call selected tools.<path>(input).",
175− "4) Source plugins are not registered in this build.",
176− ...EXECUTOR_SOURCES_ADD_HELP_LINES,
175+ "4) Use source plugins to inspect or add API sources.",
176+ ...getExecutorSourcesAddHelpLines(),
177177 "5) If execution pauses for interaction, resume it with the returned resumePayload or the available resume flow.",
178178 "Do not use fetch; use tools.* only.",
179179 ].join("\n");
180180
181−const defaultExecuteDescription = buildExecuteWorkflowText();
182−
183181 const loadExecuteDescription = (runtime: ExecutorRuntime): Promise<string> =>
184182 runControlPlane(
185183 runtime,
@@ -203,11 +201,11 @@ const loadExecuteDescription = (runtime: ExecutorRuntime): Promise<string> => · 17 unmodified lines
203201 .filter((catalog) => catalog.toolPaths.length > 0);
204202
205203 if (sourceToolExamples.length === 0) {
206− return defaultExecuteDescription;
204+ return buildExecuteWorkflowText();
207205 }
208206
209207 return buildExecuteWorkflowText(sourceToolExamples);
210− }).pipe(Effect.catchAll(() => Effect.succeed(defaultExecuteDescription))),
208+ }).pipe(Effect.catchAll(() => Effect.succeed(buildExecuteWorkflowText()))),
211209 );
212210
213211 const summarizeExecution = (execution: ExecutionEnvelope["execution"]): string => {
packages/platform/sdk/src/executor-effect.ts+31−4
@@ -285,6 +285,31 @@ export const createExecutorEffect = < · 284 unmodified lines
285285 cause instanceof Error ? cause : new Error(String(cause)),
286286 ),
287287 );
288+ const providePluginExtensionValue = (value: unknown): unknown => {
289+ if (Effect.isEffect(value)) {
290+ return providePluginHostEffect(value);
291+ }
292+
293+ if (typeof value === "function") {
294+ return (...args: Array<unknown>) =>
295+ providePluginExtensionValue(value(...args));
296+ }
297+
298+ if (Array.isArray(value)) {
299+ return value.map(providePluginExtensionValue);
300+ }
301+
302+ if (value !== null && typeof value === "object") {
303+ return Object.fromEntries(
304+ Object.entries(value).map(([key, nestedValue]) => [
305+ key,
306+ providePluginExtensionValue(nestedValue),
307+ ]),
308+ );
309+ }
310+
311+ return value;
312+ };
288313 const host: ExecutorSdkPluginHost = {
289314 sources: {
290315 create: ({ source }) =>
@@ -330,10 +355,12 @@ export const createExecutorEffect = < · 39 unmodified lines
330355 const extensions = Object.fromEntries(
331356 (options.plugins ?? []).map((plugin) => [
332357 plugin.key,
333− plugin.extendExecutor?.({
334− executor,
335− host,
336− }) ?? {},
358+ providePluginExtensionValue(
359+ plugin.extendExecutor?.({
360+ executor,
361+ host,
362+ }) ?? {},
363+ ),
337364 ]),
338365 );
339366
packages/platform/sdk/src/plugins.ts+26−7
@@ -11,6 +11,7 @@ import type { · 10 unmodified lines
1111 SourceSyncInput,
1212 } from "@executor/source-core";
1313 import * as Effect from "effect/Effect";
14+import type * as Schema from "effect/Schema";
1415
1516 export type ExecutorSdkPlugin<
1617 TKey extends string = string,
@@ -18,6 +19,7 @@ export type ExecutorSdkPlugin< · 1 unmodified line
1819 > = {
1920 key: TKey;
2021 sources?: readonly SourcePluginRuntime[];
22+ sourceConnectors?: readonly ExecutorSourceConnector<any>[];
2123 extendExecutor?: (input: {
2224 executor: ExecutorEffect & Record<string, unknown>;
2325 host: ExecutorSdkPluginHost;
@@ -31,16 +33,28 @@ export type ExecutorSdkPluginHost = { · 7 unmodified lines
3133 ExecutorSource,
3234 "id" | "scopeId" | "createdAt" | "updatedAt"
3335 >;
34− }) => Effect.Effect<ExecutorSource, Error, never>;
35− get: (sourceId: ExecutorSource["id"]) => Effect.Effect<ExecutorSource, Error, never>;
36− save: (source: ExecutorSource) => Effect.Effect<ExecutorSource, Error, never>;
36+ }) => Effect.Effect<ExecutorSource, Error, any>;
37+ get: (sourceId: ExecutorSource["id"]) => Effect.Effect<ExecutorSource, Error, any>;
38+ save: (source: ExecutorSource) => Effect.Effect<ExecutorSource, Error, any>;
3739 refreshCatalog: (
3840 sourceId: ExecutorSource["id"],
39− ) => Effect.Effect<ExecutorSource, Error, never>;
40− remove: (sourceId: ExecutorSource["id"]) => Effect.Effect<boolean, Error, never>;
41+ ) => Effect.Effect<ExecutorSource, Error, any>;
42+ remove: (sourceId: ExecutorSource["id"]) => Effect.Effect<boolean, Error, any>;
4143 };
4244 };
4345
46+export type ExecutorSourceConnector<TInput = unknown> = {
47+ kind: string;
48+ displayName: string;
49+ inputSchema: Schema.Schema<TInput, any, never>;
50+ inputSignatureWidth?: number;
51+ helpText?: readonly string[];
52+ createSource: (input: {
53+ args: TInput;
54+ host: ExecutorSdkPluginHost;
55+ }) => Effect.Effect<ExecutorSource, Error, any>;
56+};
57+
4458 export type ExecutorSdkPluginExtensions<
4559 TPlugins extends readonly ExecutorSdkPlugin<any, any>[],
4660 > = {
@@ -59,21 +73,25 @@ export type SourcePluginRuntime = { · 12 unmodified lines
5973 }) => Record<string, unknown>;
6074 getIrModel: (
6175 input: SourceSyncInput,
62− ) => Effect.Effect<SourceCatalogSyncResult, Error, never>;
76+ ) => Effect.Effect<SourceCatalogSyncResult, Error, any>;
6377 invoke: (
6478 input: SourceInvokeInput,
65− ) => Effect.Effect<SourceInvokeResult, Error, never>;
79+ ) => Effect.Effect<SourceInvokeResult, Error, any>;
6680 };
6781
6882 export const registerExecutorSdkPlugins = (
6983 plugins: readonly ExecutorSdkPlugin[],
7084 ) => {
7185 const sourcePlugins = new Map<string, SourcePluginRuntime>();
86+ const sourceConnectors = new Map<string, ExecutorSourceConnector<any>>();
7287
7388 for (const plugin of plugins) {
7489 for (const source of plugin.sources ?? []) {
7590 sourcePlugins.set(source.kind, source);
7691 }
92+ for (const connector of plugin.sourceConnectors ?? []) {
93+ sourceConnectors.set(connector.kind, connector);
94+ }
7795 }
7896
7997 const getSourcePlugin = (kind: string) => {
@@ -91,6 +109,7 @@ export const registerExecutorSdkPlugins = ( · 11 unmodified lines
91109 return {
92110 plugins,
93111 sourcePlugins: [...sourcePlugins.values()],
112+ sourceConnectors: [...sourceConnectors.values()],
94113 getSourcePlugin,
95114 getSourcePluginForSource,
96115 sourcePluginCatalogKind: (kind: string): SourceCatalogKind =>
packages/platform/sdk/src/runtime/catalog/source/sync.ts+2−2
@@ -56,7 +56,7 @@ export type RuntimeSourceCatalogSyncShape = { · 55 unmodified lines
5656 sync: (input: {
5757 source: Source;
5858 actorScopeId?: ScopeId | null;
59− }) => Effect.Effect<void, Error, never>;
59+ }) => Effect.Effect<void, Error, any>;
6060 };
6161
6262 export class RuntimeSourceCatalogSyncService extends Context.Tag(
@@ -83,7 +83,7 @@ const syncSourceCatalogWithDeps = ( · 20 unmodified lines
8383 source: Source;
8484 actorScopeId?: ScopeId | null;
8585 },
86−): Effect.Effect<void, Error, never> =>
86+): Effect.Effect<void, Error, any> =>
8787 Effect.gen(function* () {
8888 yield* ensureRuntimeCatalogSyncWorkspace(deps, input.source.scopeId);
8989
packages/platform/sdk/src/runtime/execution/scope/tool-invoker.ts+10−3
@@ -129,6 +129,9 @@ export const createScopeToolInvoker = (input: { · 128 unmodified lines
129129 const executorTools = createExecutorToolMap({
130130 scopeId: input.scopeId,
131131 actorScopeId: input.actorScopeId,
132+ executorStateStore: input.executorStateStore,
133+ sourceStore: input.sourceStore,
134+ sourceCatalogSyncService: input.sourceCatalogSyncService,
132135 installationStore: input.installationStore,
133136 scopeConfigStore: input.scopeConfigStore,
134137 scopeStateStore: input.scopeStateStore,
@@ -236,9 +239,13 @@ export const createScopeToolInvoker = (input: { · 101 unmodified lines
236239 catalog,
237240 toolInvoker: {
238241 invoke: ({ path, args, context }) => {
239− const effect: Effect.Effect<unknown, unknown, never> = authoredToolPaths.has(path)
240− ? authoredInvoker.invoke({ path, args, context }) as Effect.Effect<unknown, unknown, never>
241− : invokePersistedTool({ path, args, context });
242+ const effect = authoredToolPaths.has(path)
243+ ? authoredInvoker.invoke({ path, args, context })
244+ : invokePersistedTool({ path, args, context }) as Effect.Effect<
245+ unknown,
246+ unknown,
247+ never
248+ >;
242249
243250 return provideRuntimeLocalScope(effect, input.runtimeLocalScope);
244251 },
packages/platform/sdk/src/runtime/sources/executor-tools.ts+240−20
@@ -1,47 +1,267 @@
1−import type { ToolMap } from "@executor/codemode-core";
2−import { SourceSchema } from "#schema";
1+import { toTool, type ToolMap } from "@executor/codemode-core";
2+import {
3+ type ScopeId,
4+ SourceSchema,
5+ type Source,
6+} from "#schema";
7+import * as Effect from "effect/Effect";
8+import * as Layer from "effect/Layer";
9+import * as Option from "effect/Option";
10+import * as Schema from "effect/Schema";
311
12+import {
13+ createManagedSourceRecord,
14+ getSource,
15+ refreshManagedSourceCatalog,
16+ removeSource,
17+ saveManagedSourceRecord,
18+} from "../../sources/operations";
419 import {
520 deriveSchemaJson,
621 deriveSchemaTypeSignature,
722 } from "../catalog/schema-type-signature";
8−import type {
9− InstallationStoreShape,
10− SourceArtifactStoreShape,
11− ScopeConfigStoreShape,
12− ScopeStateStoreShape,
23+import {
24+ RuntimeSourceCatalogSyncService,
25+} from "../catalog/source/sync";
26+import {
27+ ExecutorStateStore,
28+ type ExecutorStateStoreShape,
29+} from "../executor-state-store";
30+import {
31+ provideOptionalRuntimeLocalScope,
32+ type RuntimeLocalScopeState,
33+} from "../scope/runtime-context";
34+import {
35+ type InstallationStoreShape,
36+ makeLocalStorageLayer,
37+ type SourceArtifactStoreShape,
38+ type ScopeConfigStoreShape,
39+ type ScopeStateStoreShape,
1340 } from "../scope/storage";
41+import {
42+ type RuntimeSourceStore,
43+ RuntimeSourceStoreService,
44+} from "./source-store";
45+import {
46+ registeredSourceConnectors,
47+} from "./source-plugins";
48+import type {
49+ ExecutorSdkPluginHost,
50+ ExecutorSourceConnector,
51+} from "../../plugins";
52+
53+const createExecutorSourcesAddSchema = (): Schema.Schema<any, any, never> => {
54+ const connectors = registeredSourceConnectors();
55+ if (connectors.length === 0) {
56+ return Schema.Unknown;
57+ }
1458
15−export const EXECUTOR_SOURCES_ADD_INPUT_HINT =
16− "Source plugins are not registered in this build.";
59+ if (connectors.length === 1) {
60+ return connectors[0]!.inputSchema;
61+ }
62+
63+ return Schema.Union(
64+ ...(connectors.map((connector) => connector.inputSchema) as [
65+ Schema.Schema<any, any, never>,
66+ Schema.Schema<any, any, never>,
67+ ...Array<Schema.Schema<any, any, never>>,
68+ ])
69+ );
70+};
71+
72+export const getExecutorSourcesAddInputHint = (): string =>
73+ deriveSchemaTypeSignature(createExecutorSourcesAddSchema(), 340);
1774
1875 export const EXECUTOR_SOURCES_ADD_OUTPUT_SIGNATURE = deriveSchemaTypeSignature(
1976 SourceSchema,
2077 260,
2178 );
2279
23−export const EXECUTOR_SOURCES_ADD_INPUT_SCHEMA = {};
80+export const getExecutorSourcesAddInputSchemaJson = (): Record<string, unknown> =>
81+ deriveSchemaJson(createExecutorSourcesAddSchema()) ?? {};
2482
2583 export const EXECUTOR_SOURCES_ADD_OUTPUT_SCHEMA = deriveSchemaJson(
2684 SourceSchema,
2785 ) ?? {};
2886
29−export const EXECUTOR_SOURCES_ADD_HELP_LINES = [
30− "Source plugins are not registered in this build.",
31−] as const;
87+export const getExecutorSourcesAddHelpLines = (): readonly string[] => {
88+ const connectors = registeredSourceConnectors();
89+ if (connectors.length === 0) {
90+ return ["No source plugins are registered in this build."] as const;
91+ }
3292
33−export const buildExecutorSourcesAddDescription = (): string =>
34− "Source plugins are not registered in this build.";
93+ return [
94+ "Source add input shapes:",
95+ ...connectors.flatMap((connector) => [
96+ `- ${connector.displayName}: ${deriveSchemaTypeSignature(
97+ connector.inputSchema,
98+ connector.inputSignatureWidth ?? 260,
99+ )}`,
100+ ...(connector.helpText ?? []).map((line) => ` ${line}`),
101+ ]),
102+ ];
103+};
104+
105+export const buildExecutorSourcesAddDescription = (): string => {
106+ const connectors = registeredSourceConnectors();
107+ if (connectors.length === 0) {
108+ return "No source plugins are registered in this build.";
109+ }
110+
111+ return [
112+ "Add a source using one of the registered source plugins.",
113+ ...getExecutorSourcesAddHelpLines(),
114+ ].join("\n");
115+};
116+
117+const createSourceConnectorHost = (input: {
118+ scopeId: ScopeId;
119+ actorScopeId: ScopeId;
120+}): ExecutorSdkPluginHost => ({
121+ sources: {
122+ create: ({ source }) =>
123+ createManagedSourceRecord({
124+ scopeId: input.scopeId,
125+ actorScopeId: input.actorScopeId,
126+ source,
127+ }),
128+ get: (sourceId) =>
129+ getSource({
130+ scopeId: input.scopeId,
131+ sourceId,
132+ actorScopeId: input.actorScopeId,
133+ }),
134+ save: (source) =>
135+ saveManagedSourceRecord({
136+ actorScopeId: input.actorScopeId,
137+ source,
138+ }),
139+ refreshCatalog: (sourceId) =>
140+ refreshManagedSourceCatalog({
141+ scopeId: input.scopeId,
142+ sourceId,
143+ actorScopeId: input.actorScopeId,
144+ }),
145+ remove: (sourceId) =>
146+ removeSource({
147+ scopeId: input.scopeId,
148+ sourceId,
149+ }).pipe(Effect.map((result) => result.removed)),
150+ },
151+});
152+
153+const runExecutorSourceEffect = async <A>(
154+ effect: Effect.Effect<A, unknown, any>,
155+ input: {
156+ executorStateStore: ExecutorStateStoreShape;
157+ sourceStore: RuntimeSourceStore;
158+ sourceCatalogSyncService: Effect.Effect.Success<
159+ typeof RuntimeSourceCatalogSyncService
160+ >;
161+ installationStore: InstallationStoreShape;
162+ scopeConfigStore: ScopeConfigStoreShape;
163+ scopeStateStore: ScopeStateStoreShape;
164+ sourceArtifactStore: SourceArtifactStoreShape;
165+ runtimeLocalScope: RuntimeLocalScopeState | null;
166+ },
167+): Promise<A> => {
168+ const servicesLayer = Layer.mergeAll(
169+ makeLocalStorageLayer({
170+ installationStore: input.installationStore,
171+ scopeConfigStore: input.scopeConfigStore,
172+ scopeStateStore: input.scopeStateStore,
173+ sourceArtifactStore: input.sourceArtifactStore,
174+ }),
175+ Layer.succeed(ExecutorStateStore, input.executorStateStore),
176+ Layer.succeed(RuntimeSourceStoreService, input.sourceStore),
177+ Layer.succeed(
178+ RuntimeSourceCatalogSyncService,
179+ input.sourceCatalogSyncService,
180+ ),
181+ );
182+
183+ return Effect.runPromise(
184+ provideOptionalRuntimeLocalScope(
185+ effect.pipe(Effect.provide(servicesLayer)),
186+ input.runtimeLocalScope,
187+ ) as Effect.Effect<A, unknown, never>,
188+ );
189+};
190+
191+const resolveSourceConnector = (
192+ connectors: readonly ExecutorSourceConnector[],
193+ args: unknown,
194+):
195+ | {
196+ connector: ExecutorSourceConnector;
197+ parsedArgs: unknown;
198+ }
199+ | null => {
200+ for (const connector of connectors) {
201+ const parsed = Schema.decodeUnknownOption(connector.inputSchema)(args);
202+ if (Option.isSome(parsed)) {
203+ return {
204+ connector,
205+ parsedArgs: parsed.value,
206+ };
207+ }
208+ }
209+
210+ return null;
211+};
212+
213+const toSerializableValue = <A>(value: A): A =>
214+ JSON.parse(JSON.stringify(value)) as A;
35215
36216 export const createExecutorToolMap = (input: {
37− scopeId?: string;
38− actorScopeId?: string;
217+ scopeId: ScopeId;
218+ actorScopeId: ScopeId;
219+ executorStateStore: ExecutorStateStoreShape;
220+ sourceStore: RuntimeSourceStore;
221+ sourceCatalogSyncService: Effect.Effect.Success<
222+ typeof RuntimeSourceCatalogSyncService
223+ >;
39224 installationStore: InstallationStoreShape;
40225 scopeConfigStore: ScopeConfigStoreShape;
41226 scopeStateStore: ScopeStateStoreShape;
42227 sourceArtifactStore: SourceArtifactStoreShape;
43− runtimeLocalScope?: unknown;
228+ runtimeLocalScope: RuntimeLocalScopeState | null;
44229 }): ToolMap => {
45− void input;
46− return {};
230+ const connectors = registeredSourceConnectors();
231+ if (connectors.length === 0) {
232+ return {};
233+ }
234+
235+ const host = createSourceConnectorHost({
236+ scopeId: input.scopeId,
237+ actorScopeId: input.actorScopeId,
238+ });
239+
240+ return {
241+ "executor.sources.add": toTool({
242+ tool: {
243+ description: buildExecutorSourcesAddDescription(),
244+ inputSchema: Schema.standardSchemaV1(createExecutorSourcesAddSchema()),
245+ outputSchema: Schema.standardSchemaV1(SourceSchema),
246+ execute: async (args: unknown): Promise<Source> => {
247+ const matched = resolveSourceConnector(connectors, args);
248+ if (matched === null) {
249+ throw new Error(
250+ "executor.sources.add input did not match a registered source plugin.",
251+ );
252+ }
253+
254+ const createdSource = await runExecutorSourceEffect(
255+ matched.connector.createSource({
256+ args: matched.parsedArgs,
257+ host,
258+ }),
259+ input,
260+ );
261+
262+ return toSerializableValue(createdSource);
263+ },
264+ },
265+ }),
266+ };
47267 };
packages/platform/sdk/src/runtime/sources/source-plugins/index.ts+1−0
@@ -27,6 +27,7 @@ export const configureExecutorSourcePlugins = ( · 26 unmodified lines
2727 };
2828
2929 export const registeredSourcePlugins = () => registry.sourcePlugins;
30+export const registeredSourceConnectors = () => registry.sourceConnectors;
3031
3132 export const getSourcePlugin = (kind: string) => registry.getSourcePlugin(kind);
3233 export const getSourcePluginForSource = (
packages/platform/server/package.json+9−0
@@ -15,6 +15,15 @@ · 14 unmodified lines
1515 "@effect/platform": "catalog:",
1616 "@effect/platform-node": "catalog:",
1717 "@executor/executor-mcp": "workspace:*",
18+ "@executor/plugin-google-discovery-http": "workspace:*",
19+ "@executor/plugin-google-discovery-shared": "workspace:*",
20+ "@executor/plugin-google-discovery-sdk": "workspace:*",
21+ "@executor/plugin-graphql-http": "workspace:*",
22+ "@executor/plugin-graphql-shared": "workspace:*",
23+ "@executor/plugin-graphql-sdk": "workspace:*",
24+ "@executor/plugin-mcp-http": "workspace:*",
25+ "@executor/plugin-mcp-shared": "workspace:*",
26+ "@executor/plugin-mcp-sdk": "workspace:*",
1827 "@executor/plugin-openapi-http": "workspace:*",
1928 "@executor/plugin-openapi-shared": "workspace:*",
2029 "@executor/plugin-openapi-sdk": "workspace:*",
packages/platform/server/src/google-discovery-oauth-session-storage.ts+40−0
@@ -0,0 +1,40 @@
1+import type { GoogleDiscoveryOAuthSessionStorage } from "@executor/plugin-google-discovery-sdk";
2+import {
3+ GoogleDiscoveryOAuthSessionSchema,
4+} from "@executor/plugin-google-discovery-shared";
5+
6+import {
7+ pluginSessionStoragePath,
8+ readJsonFile,
9+ removeJsonFile,
10+ writeJsonFile,
11+} from "./json-file-storage";
12+
13+export const createFileGoogleDiscoveryOAuthSessionStorage = (input: {
14+ rootDir: string;
15+}): GoogleDiscoveryOAuthSessionStorage => ({
16+ get: (sessionId) =>
17+ readJsonFile({
18+ path: pluginSessionStoragePath({
19+ rootDir: input.rootDir,
20+ sessionId,
21+ }),
22+ schema: GoogleDiscoveryOAuthSessionSchema,
23+ }),
24+ put: ({ sessionId, value }) =>
25+ writeJsonFile({
26+ path: pluginSessionStoragePath({
27+ rootDir: input.rootDir,
28+ sessionId,
29+ }),
30+ schema: GoogleDiscoveryOAuthSessionSchema,
31+ value,
32+ }),
33+ remove: (sessionId) =>
34+ removeJsonFile(
35+ pluginSessionStoragePath({
36+ rootDir: input.rootDir,
37+ sessionId,
38+ }),
39+ ),
40+});
packages/platform/server/src/google-discovery-source-storage.ts+43−0
@@ -0,0 +1,43 @@
1+import type { GoogleDiscoverySourceStorage } from "@executor/plugin-google-discovery-sdk";
2+import {
3+ GoogleDiscoveryStoredSourceDataSchema,
4+} from "@executor/plugin-google-discovery-shared";
5+
6+import {
7+ pluginSourceStoragePath,
8+ readJsonFile,
9+ removeJsonFile,
10+ writeJsonFile,
11+} from "./json-file-storage";
12+
13+export const createFileGoogleDiscoverySourceStorage = (input: {
14+ rootDir: string;
15+}): GoogleDiscoverySourceStorage => ({
16+ get: ({ scopeId, sourceId }) =>
17+ readJsonFile({
18+ path: pluginSourceStoragePath({
19+ rootDir: input.rootDir,
20+ scopeId,
21+ sourceId,
22+ }),
23+ schema: GoogleDiscoveryStoredSourceDataSchema,
24+ }),
25+ put: ({ scopeId, sourceId, value }) =>
26+ writeJsonFile({
27+ path: pluginSourceStoragePath({
28+ rootDir: input.rootDir,
29+ scopeId,
30+ sourceId,
31+ }),
32+ schema: GoogleDiscoveryStoredSourceDataSchema,
33+ value,
34+ }),
35+ remove: ({ scopeId, sourceId }) =>
36+ removeJsonFile(
37+ pluginSourceStoragePath({
38+ rootDir: input.rootDir,
39+ scopeId,
40+ sourceId,
41+ }),
42+ ),
43+});
packages/platform/server/src/graphql-source-storage.ts+43−0
@@ -0,0 +1,43 @@
1+import type { GraphqlSourceStorage } from "@executor/plugin-graphql-sdk";
2+import {
3+ GraphqlStoredSourceDataSchema,
4+} from "@executor/plugin-graphql-shared";
5+
6+import {
7+ pluginSourceStoragePath,
8+ readJsonFile,
9+ removeJsonFile,
10+ writeJsonFile,
11+} from "./json-file-storage";
12+
13+export const createFileGraphqlSourceStorage = (input: {
14+ rootDir: string;
15+}): GraphqlSourceStorage => ({
16+ get: ({ scopeId, sourceId }) =>
17+ readJsonFile({
18+ path: pluginSourceStoragePath({
19+ rootDir: input.rootDir,
20+ scopeId,
21+ sourceId,
22+ }),
23+ schema: GraphqlStoredSourceDataSchema,
24+ }),
25+ put: ({ scopeId, sourceId, value }) =>
26+ writeJsonFile({
27+ path: pluginSourceStoragePath({
28+ rootDir: input.rootDir,
29+ scopeId,
30+ sourceId,
31+ }),
32+ schema: GraphqlStoredSourceDataSchema,
33+ value,
34+ }),
35+ remove: ({ scopeId, sourceId }) =>
36+ removeJsonFile(
37+ pluginSourceStoragePath({
38+ rootDir: input.rootDir,
39+ scopeId,
40+ sourceId,
41+ }),
42+ ),
43+});
packages/platform/server/src/index.ts+43−1
@@ -8,6 +8,12 @@ import { dirname, extname, resolve } from "node:path"; · 7 unmodified lines
88 import { Readable } from "node:stream";
99 import { FileSystem, HttpApiBuilder, HttpServer } from "@effect/platform";
1010 import { NodeFileSystem } from "@effect/platform-node";
11+import { googleDiscoveryHttpPlugin } from "@executor/plugin-google-discovery-http";
12+import { googleDiscoverySdkPlugin } from "@executor/plugin-google-discovery-sdk";
13+import { graphqlHttpPlugin } from "@executor/plugin-graphql-http";
14+import { graphqlSdkPlugin } from "@executor/plugin-graphql-sdk";
15+import { mcpHttpPlugin } from "@executor/plugin-mcp-http";
16+import { mcpSdkPlugin } from "@executor/plugin-mcp-sdk";
1117 import { openApiHttpPlugin } from "@executor/plugin-openapi-http";
1218 import {
1319 openApiSdkPlugin,
@@ -48,8 +54,18 @@ import { · 34 unmodified lines
4854 tracingSearchUrl,
4955 } from "./tracing";
5056 import { platformServerEffectError } from "./effect-errors";
57+import { createFileGoogleDiscoveryOAuthSessionStorage } from "./google-discovery-oauth-session-storage";
58+import { createFileGoogleDiscoverySourceStorage } from "./google-discovery-source-storage";
59+import { createFileGraphqlSourceStorage } from "./graphql-source-storage";
60+import { createFileMcpOAuthSessionStorage } from "./mcp-oauth-session-storage";
61+import { createFileMcpSourceStorage } from "./mcp-source-storage";
5162 import { createFileOpenApiSourceStorage } from "./openapi-source-storage";
5263
64+export { createFileGoogleDiscoveryOAuthSessionStorage } from "./google-discovery-oauth-session-storage";
65+export { createFileGoogleDiscoverySourceStorage } from "./google-discovery-source-storage";
66+export { createFileGraphqlSourceStorage } from "./graphql-source-storage";
67+export { createFileMcpOAuthSessionStorage } from "./mcp-oauth-session-storage";
68+export { createFileMcpSourceStorage } from "./mcp-source-storage";
5369 export { createFileOpenApiSourceStorage } from "./openapi-source-storage";
5470
5571 export {
@@ -114,7 +130,12 @@ type ExecutorMcpHandler = ReturnType<typeof createExecutorMcpRequestHandler>; · 58 unmodified lines
114130 const EXECUTOR_NPM_DIST_TAGS_PATHNAME = "/v1/app/npm/dist-tags";
115131 const EXECUTOR_NPM_DIST_TAGS_URL =
116132 "https://registry.npmjs.org/-/package/executor/dist-tags";
117−const executorHttpPlugins = [openApiHttpPlugin()] as const;
133+const executorHttpPlugins = [
134+ graphqlHttpPlugin(),
135+ googleDiscoveryHttpPlugin(),
136+ mcpHttpPlugin(),
137+ openApiHttpPlugin(),
138+] as const;
118139
119140 const disposeExecutor = (executor: Executor) =>
120141 Effect.tryPromise({
@@ -134,6 +155,27 @@ const createExecutorRuntime = ( · 13 unmodified lines
134155 localDataDir,
135156 }),
136157 plugins: [
158+ graphqlSdkPlugin({
159+ storage: createFileGraphqlSourceStorage({
160+ rootDir: resolve(localDataDir, "plugins", "graphql", "sources"),
161+ }),
162+ }),
163+ googleDiscoverySdkPlugin({
164+ storage: createFileGoogleDiscoverySourceStorage({
165+ rootDir: resolve(localDataDir, "plugins", "google-discovery", "sources"),
166+ }),
167+ oauthSessions: createFileGoogleDiscoveryOAuthSessionStorage({
168+ rootDir: resolve(localDataDir, "plugins", "google-discovery", "oauth-sessions"),
169+ }),
170+ }),
171+ mcpSdkPlugin({
172+ storage: createFileMcpSourceStorage({
173+ rootDir: resolve(localDataDir, "plugins", "mcp", "sources"),
174+ }),
175+ oauthSessions: createFileMcpOAuthSessionStorage({
176+ rootDir: resolve(localDataDir, "plugins", "mcp", "oauth-sessions"),
177+ }),
178+ }),
137179 openApiSdkPlugin({
138180 storage: createFileOpenApiSourceStorage({
139181 rootDir: resolve(localDataDir, "plugins", "openapi", "sources"),
packages/platform/server/src/json-file-storage.ts+79−0
@@ -0,0 +1,79 @@
1+import { dirname, join } from "node:path";
2+import { FileSystem } from "@effect/platform";
3+import { NodeFileSystem } from "@effect/platform-node";
4+import * as Effect from "effect/Effect";
5+import * as Schema from "effect/Schema";
6+
7+const toError = (cause: unknown): Error =>
8+ cause instanceof Error ? cause : new Error(String(cause));
9+
10+const bindNodeFileSystem = <A, E>(
11+ effect: Effect.Effect<A, E, FileSystem.FileSystem>,
12+): Effect.Effect<A, E, never> =>
13+ effect.pipe(Effect.provide(NodeFileSystem.layer));
14+
15+export const readJsonFile = <TSchema extends Schema.Schema<any, any, never>>(input: {
16+ path: string;
17+ schema: TSchema;
18+}): Effect.Effect<Schema.Schema.Type<TSchema> | null, Error, never> => {
19+ const decode = Schema.decodeUnknownSync(input.schema);
20+
21+ return bindNodeFileSystem(
22+ Effect.gen(function* () {
23+ const fileSystem = yield* FileSystem.FileSystem;
24+ const exists = yield* fileSystem.exists(input.path).pipe(Effect.mapError(toError));
25+ if (!exists) {
26+ return null;
27+ }
28+
29+ const contents = yield* fileSystem.readFileString(input.path, "utf8").pipe(
30+ Effect.mapError(toError),
31+ );
32+
33+ return decode(JSON.parse(contents));
34+ }),
35+ );
36+};
37+
38+export const writeJsonFile = <TSchema extends Schema.Schema<any, any, never>>(input: {
39+ path: string;
40+ schema: TSchema;
41+ value: Schema.Schema.Type<TSchema>;
42+}): Effect.Effect<void, Error, never> => {
43+ const encode = Schema.encodeSync(input.schema);
44+
45+ return bindNodeFileSystem(
46+ Effect.gen(function* () {
47+ const fileSystem = yield* FileSystem.FileSystem;
48+ yield* fileSystem.makeDirectory(dirname(input.path), {
49+ recursive: true,
50+ }).pipe(Effect.mapError(toError));
51+ yield* fileSystem.writeFileString(
52+ input.path,
53+ `${JSON.stringify(encode(input.value), null, 2)}\n`,
54+ ).pipe(Effect.mapError(toError));
55+ }),
56+ );
57+};
58+
59+export const removeJsonFile = (path: string): Effect.Effect<void, Error, never> =>
60+ bindNodeFileSystem(
61+ Effect.gen(function* () {
62+ const fileSystem = yield* FileSystem.FileSystem;
63+ const exists = yield* fileSystem.exists(path).pipe(Effect.mapError(toError));
64+ if (exists) {
65+ yield* fileSystem.remove(path).pipe(Effect.mapError(toError));
66+ }
67+ }),
68+ );
69+
70+export const pluginSourceStoragePath = (input: {
71+ rootDir: string;
72+ scopeId: string;
73+ sourceId: string;
74+}) => join(input.rootDir, input.scopeId, `${input.sourceId}.json`);
75+
76+export const pluginSessionStoragePath = (input: {
77+ rootDir: string;
78+ sessionId: string;
79+}) => join(input.rootDir, `${input.sessionId}.json`);
packages/platform/server/src/mcp-oauth-session-storage.ts+40−0
@@ -0,0 +1,40 @@
1+import type { McpOAuthSessionStorage } from "@executor/plugin-mcp-sdk";
2+import {
3+ McpOAuthSessionSchema,
4+} from "@executor/plugin-mcp-shared";
5+
6+import {
7+ pluginSessionStoragePath,
8+ readJsonFile,
9+ removeJsonFile,
10+ writeJsonFile,
11+} from "./json-file-storage";
12+
13+export const createFileMcpOAuthSessionStorage = (input: {
14+ rootDir: string;
15+}): McpOAuthSessionStorage => ({
16+ get: (sessionId) =>
17+ readJsonFile({
18+ path: pluginSessionStoragePath({
19+ rootDir: input.rootDir,
20+ sessionId,
21+ }),
22+ schema: McpOAuthSessionSchema,
23+ }),
24+ put: ({ sessionId, value }) =>
25+ writeJsonFile({
26+ path: pluginSessionStoragePath({
27+ rootDir: input.rootDir,
28+ sessionId,
29+ }),
30+ schema: McpOAuthSessionSchema,
31+ value,
32+ }),
33+ remove: (sessionId) =>
34+ removeJsonFile(
35+ pluginSessionStoragePath({
36+ rootDir: input.rootDir,
37+ sessionId,
38+ }),
39+ ),
40+});
packages/platform/server/src/mcp-source-storage.ts+43−0
@@ -0,0 +1,43 @@
1+import type { McpSourceStorage } from "@executor/plugin-mcp-sdk";
2+import {
3+ McpStoredSourceDataSchema,
4+} from "@executor/plugin-mcp-shared";
5+
6+import {
7+ pluginSourceStoragePath,
8+ readJsonFile,
9+ removeJsonFile,
10+ writeJsonFile,
11+} from "./json-file-storage";
12+
13+export const createFileMcpSourceStorage = (input: {
14+ rootDir: string;
15+}): McpSourceStorage => ({
16+ get: ({ scopeId, sourceId }) =>
17+ readJsonFile({
18+ path: pluginSourceStoragePath({
19+ rootDir: input.rootDir,
20+ scopeId,
21+ sourceId,
22+ }),
23+ schema: McpStoredSourceDataSchema,
24+ }),
25+ put: ({ scopeId, sourceId, value }) =>
26+ writeJsonFile({
27+ path: pluginSourceStoragePath({
28+ rootDir: input.rootDir,
29+ scopeId,
30+ sourceId,
31+ }),
32+ schema: McpStoredSourceDataSchema,
33+ value,
34+ }),
35+ remove: ({ scopeId, sourceId }) =>
36+ removeJsonFile(
37+ pluginSourceStoragePath({
38+ rootDir: input.rootDir,
39+ scopeId,
40+ sourceId,
41+ }),
42+ ),
43+});
plugins/google-discovery/http/index.ts+316−0
@@ -0,0 +1,316 @@
1+import {
2+ HttpApi,
3+ HttpApiBuilder,
4+ HttpApiEndpoint,
5+ HttpApiGroup,
6+ HttpApiSchema,
7+ HttpServerResponse,
8+} from "@effect/platform";
9+import {
10+ ControlPlaneBadRequestError,
11+ ControlPlaneForbiddenError,
12+ ControlPlaneNotFoundError,
13+ ControlPlaneStorageError,
14+ type ExecutorHttpApiExtension,
15+ type ExecutorHttpPlugin,
16+} from "@executor/platform-api";
17+import { resolveRequestedLocalWorkspace } from "@executor/platform-api/local-context";
18+import {
19+ ScopeIdSchema,
20+ SourceIdSchema,
21+ SourceSchema,
22+ type Source,
23+} from "@executor/platform-sdk/schema";
24+import * as Effect from "effect/Effect";
25+import * as Schema from "effect/Schema";
26+
27+import {
28+ GoogleDiscoveryConnectInputSchema,
29+ GoogleDiscoveryOAuthPopupResultSchema,
30+ GoogleDiscoverySourceConfigPayloadSchema,
31+ GoogleDiscoveryStartOAuthInputSchema,
32+ GoogleDiscoveryStartOAuthResultSchema,
33+ type GoogleDiscoveryConnectInput,
34+ type GoogleDiscoveryOAuthPopupResult,
35+ type GoogleDiscoverySourceConfigPayload,
36+ type GoogleDiscoveryStartOAuthInput,
37+ type GoogleDiscoveryStartOAuthResult,
38+ type GoogleDiscoveryUpdateSourceInput,
39+} from "@executor/plugin-google-discovery-shared";
40+
41+type GoogleDiscoveryExecutorExtension = {
42+ googleDiscovery: {
43+ createSource: (
44+ input: GoogleDiscoveryConnectInput,
45+ ) => Effect.Effect<Source, Error>;
46+ getSourceConfig: (
47+ sourceId: Source["id"],
48+ ) => Effect.Effect<GoogleDiscoverySourceConfigPayload, Error>;
49+ updateSource: (
50+ input: GoogleDiscoveryUpdateSourceInput,
51+ ) => Effect.Effect<Source, Error>;
52+ removeSource: (
53+ sourceId: Source["id"],
54+ ) => Effect.Effect<boolean, Error>;
55+ startOAuth: (
56+ input: GoogleDiscoveryStartOAuthInput,
57+ ) => Effect.Effect<GoogleDiscoveryStartOAuthResult, Error>;
58+ completeOAuth: (input: {
59+ state: string;
60+ code?: string;
61+ error?: string;
62+ errorDescription?: string;
63+ }) => Effect.Effect<Extract<GoogleDiscoveryOAuthPopupResult, { ok: true }>, Error>;
64+ };
65+};
66+
67+const workspaceIdParam = HttpApiSchema.param("workspaceId", ScopeIdSchema);
68+const sourceIdParam = HttpApiSchema.param("sourceId", SourceIdSchema);
69+const htmlSchema = HttpApiSchema.Text({
70+ contentType: "text/html",
71+});
72+
73+const callbackParamsSchema = Schema.Struct({
74+ state: Schema.String,
75+ code: Schema.optional(Schema.String),
76+ error: Schema.optional(Schema.String),
77+ error_description: Schema.optional(Schema.String),
78+});
79+
80+export const GoogleDiscoveryHttpGroup = HttpApiGroup.make("googleDiscovery")
81+ .add(
82+ HttpApiEndpoint.post("createSource")`/workspaces/${workspaceIdParam}/plugins/google-discovery/sources`
83+ .setPayload(GoogleDiscoveryConnectInputSchema)
84+ .addSuccess(SourceSchema)
85+ .addError(ControlPlaneBadRequestError)
86+ .addError(ControlPlaneForbiddenError)
87+ .addError(ControlPlaneStorageError),
88+ )
89+ .add(
90+ HttpApiEndpoint.get("getSourceConfig")`/workspaces/${workspaceIdParam}/plugins/google-discovery/sources/${sourceIdParam}`
91+ .addSuccess(GoogleDiscoverySourceConfigPayloadSchema)
92+ .addError(ControlPlaneBadRequestError)
93+ .addError(ControlPlaneForbiddenError)
94+ .addError(ControlPlaneNotFoundError)
95+ .addError(ControlPlaneStorageError),
96+ )
97+ .add(
98+ HttpApiEndpoint.put("updateSource")`/workspaces/${workspaceIdParam}/plugins/google-discovery/sources/${sourceIdParam}`
99+ .setPayload(GoogleDiscoverySourceConfigPayloadSchema)
100+ .addSuccess(SourceSchema)
101+ .addError(ControlPlaneBadRequestError)
102+ .addError(ControlPlaneForbiddenError)
103+ .addError(ControlPlaneNotFoundError)
104+ .addError(ControlPlaneStorageError),
105+ )
106+ .add(
107+ HttpApiEndpoint.del("removeSource")`/workspaces/${workspaceIdParam}/plugins/google-discovery/sources/${sourceIdParam}`
108+ .addSuccess(Schema.Struct({ removed: Schema.Boolean }))
109+ .addError(ControlPlaneBadRequestError)
110+ .addError(ControlPlaneForbiddenError)
111+ .addError(ControlPlaneNotFoundError)
112+ .addError(ControlPlaneStorageError),
113+ )
114+ .add(
115+ HttpApiEndpoint.post("startOAuth")`/workspaces/${workspaceIdParam}/plugins/google-discovery/oauth/start`
116+ .setPayload(GoogleDiscoveryStartOAuthInputSchema)
117+ .addSuccess(GoogleDiscoveryStartOAuthResultSchema)
118+ .addError(ControlPlaneBadRequestError)
119+ .addError(ControlPlaneForbiddenError)
120+ .addError(ControlPlaneStorageError),
121+ )
122+ .add(
123+ HttpApiEndpoint.get("oauthCallback")`/plugins/google-discovery/oauth/callback`
124+ .setUrlParams(callbackParamsSchema)
125+ .addSuccess(htmlSchema)
126+ .addError(ControlPlaneBadRequestError)
127+ .addError(ControlPlaneStorageError),
128+ )
129+ .prefix("/v1");
130+
131+export const googleDiscoveryHttpApiExtension = {
132+ key: "googleDiscovery",
133+ group: GoogleDiscoveryHttpGroup,
134+} satisfies ExecutorHttpApiExtension<typeof GoogleDiscoveryHttpGroup>;
135+
136+const GoogleDiscoveryHttpApi = HttpApi.make("executor").add(GoogleDiscoveryHttpGroup);
137+
138+const toBadRequestError = (operation: string, cause: unknown) =>
139+ new ControlPlaneBadRequestError({
140+ operation,
141+ message: cause instanceof Error ? cause.message : String(cause),
142+ details: cause instanceof Error ? cause.stack ?? cause.message : String(cause),
143+ });
144+
145+const toStorageError = (operation: string, cause: unknown) =>
146+ new ControlPlaneStorageError({
147+ operation,
148+ message: cause instanceof Error ? cause.message : String(cause),
149+ details: cause instanceof Error ? cause.stack ?? cause.message : String(cause),
150+ });
151+
152+const toNotFoundError = (operation: string, cause: unknown) =>
153+ new ControlPlaneNotFoundError({
154+ operation,
155+ message: cause instanceof Error ? cause.message : String(cause),
156+ details: cause instanceof Error ? cause.stack ?? cause.message : String(cause),
157+ });
158+
159+const mapPluginStorageError = (operation: string, cause: unknown) => {
160+ const message = cause instanceof Error ? cause.message : String(cause);
161+ if (message.includes("not found") || message.includes("Not found")) {
162+ return toNotFoundError(operation, cause);
163+ }
164+
165+ return toStorageError(operation, cause);
166+};
167+
168+const escapeHtml = (value: string): string =>
169+ value
170+ .replaceAll("&", "&amp;")
171+ .replaceAll("<", "&lt;")
172+ .replaceAll(">", "&gt;")
173+ .replaceAll('"', "&quot;")
174+ .replaceAll("'", "&#39;");
175+
176+const popupDocument = (payload: GoogleDiscoveryOAuthPopupResult): string => {
177+ const serialized = JSON.stringify(payload)
178+ .replaceAll("<", "\\u003c")
179+ .replaceAll(">", "\\u003e")
180+ .replaceAll("&", "\\u0026");
181+ const title = payload.ok ? "Google OAuth connected" : "Google OAuth failed";
182+ const message = payload.ok
183+ ? "Google credentials are ready. Return to the source form to finish saving."
184+ : payload.error;
185+
186+ return `<!doctype html>
187+<html lang="en">
188+ <head>
189+ <meta charset="utf-8" />
190+ <meta name="viewport" content="width=device-width, initial-scale=1" />
191+ <title>${escapeHtml(title)}</title>
192+ </head>
193+ <body>
194+ <main>
195+ <h1>${escapeHtml(title)}</h1>
196+ <p>${escapeHtml(message)}</p>
197+ </main>
198+ <script>
199+ (() => {
200+ const payload = ${serialized};
201+ try {
202+ window.localStorage.setItem("executor:google-discovery-oauth:" + (payload.ok ? payload.sessionId : "failed"), JSON.stringify(payload));
203+ } catch {}
204+ try {
205+ if (window.opener) {
206+ window.opener.postMessage(payload, window.location.origin);
207+ }
208+ } finally {
209+ window.setTimeout(() => window.close(), 120);
210+ }
211+ })();
212+ </script>
213+ </body>
214+</html>`;
215+};
216+
217+export const googleDiscoveryHttpPlugin = (): ExecutorHttpPlugin<
218+ typeof GoogleDiscoveryHttpGroup,
219+ GoogleDiscoveryExecutorExtension
220+> => ({
221+ key: "googleDiscovery",
222+ group: GoogleDiscoveryHttpGroup,
223+ build: ({ executor }) =>
224+ HttpApiBuilder.group(GoogleDiscoveryHttpApi, "googleDiscovery", (handlers) =>
225+ handlers
226+ .handle("createSource", ({ path, payload }) =>
227+ resolveRequestedLocalWorkspace(
228+ "googleDiscovery.createSource",
229+ path.workspaceId,
230+ ).pipe(
231+ Effect.flatMap(() => executor.googleDiscovery.createSource(payload)),
232+ Effect.mapError((cause) =>
233+ toStorageError("googleDiscovery.createSource", cause)
234+ ),
235+ )
236+ )
237+ .handle("getSourceConfig", ({ path }) =>
238+ resolveRequestedLocalWorkspace(
239+ "googleDiscovery.getSourceConfig",
240+ path.workspaceId,
241+ ).pipe(
242+ Effect.flatMap(() =>
243+ executor.googleDiscovery.getSourceConfig(path.sourceId)
244+ ),
245+ Effect.mapError((cause) =>
246+ mapPluginStorageError("googleDiscovery.getSourceConfig", cause)
247+ ),
248+ )
249+ )
250+ .handle("updateSource", ({ path, payload }) =>
251+ resolveRequestedLocalWorkspace(
252+ "googleDiscovery.updateSource",
253+ path.workspaceId,
254+ ).pipe(
255+ Effect.flatMap(() =>
256+ executor.googleDiscovery.updateSource({
257+ sourceId: path.sourceId,
258+ config: payload,
259+ })
260+ ),
261+ Effect.mapError((cause) =>
262+ mapPluginStorageError("googleDiscovery.updateSource", cause)
263+ ),
264+ )
265+ )
266+ .handle("removeSource", ({ path }) =>
267+ resolveRequestedLocalWorkspace(
268+ "googleDiscovery.removeSource",
269+ path.workspaceId,
270+ ).pipe(
271+ Effect.flatMap(() =>
272+ executor.googleDiscovery.removeSource(path.sourceId)
273+ ),
274+ Effect.map((removed) => ({ removed })),
275+ Effect.mapError((cause) =>
276+ mapPluginStorageError("googleDiscovery.removeSource", cause)
277+ ),
278+ )
279+ )
280+ .handle("startOAuth", ({ path, payload }) =>
281+ resolveRequestedLocalWorkspace(
282+ "googleDiscovery.startOAuth",
283+ path.workspaceId,
284+ ).pipe(
285+ Effect.flatMap(() => executor.googleDiscovery.startOAuth(payload)),
286+ Effect.mapError((cause) =>
287+ toStorageError("googleDiscovery.startOAuth", cause)
288+ ),
289+ )
290+ )
291+ .handle("oauthCallback", ({ urlParams }) =>
292+ executor.googleDiscovery.completeOAuth({
293+ state: urlParams.state,
294+ code: urlParams.code,
295+ error: urlParams.error,
296+ errorDescription: urlParams.error_description,
297+ }).pipe(
298+ Effect.map((payload) => popupDocument(payload)),
299+ Effect.mapError((cause) =>
300+ toStorageError("googleDiscovery.oauthCallback", cause)
301+ ),
302+ Effect.catchAll((error) =>
303+ Effect.succeed(
304+ popupDocument({
305+ type: "executor:oauth-result",
306+ ok: false,
307+ sessionId: null,
308+ error: error.message,
309+ }),
310+ )
311+ ),
312+ Effect.flatMap((html) => HttpServerResponse.html(html)),
313+ )
314+ )
315+ ),
316+});
plugins/google-discovery/http/package.json+26−0
@@ -0,0 +1,26 @@
1+{
2+ "name": "@executor/plugin-google-discovery-http",
3+ "private": true,
4+ "type": "module",
5+ "exports": {
6+ ".": "./index.ts"
7+ },
8+ "files": [
9+ "index.ts"
10+ ],
11+ "scripts": {
12+ "typecheck": "bunx tsc --noEmit -p tsconfig.json"
13+ },
14+ "dependencies": {
15+ "@effect/platform": "catalog:",
16+ "@executor/platform-api": "workspace:*",
17+ "@executor/platform-sdk": "workspace:*",
18+ "@executor/plugin-google-discovery-shared": "workspace:*",
19+ "effect": "catalog:"
20+ },
21+ "devDependencies": {
22+ "@types/node": "catalog:",
23+ "bun-types": "catalog:"
24+ },
25+ "version": "0.0.0"
26+}
plugins/google-discovery/http/tsconfig.json+15−0
@@ -0,0 +1,15 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2022",
4+ "module": "ESNext",
5+ "moduleResolution": "Bundler",
6+ "strict": true,
7+ "skipLibCheck": true,
8+ "noEmit": true,
9+ "lib": ["ES2022"],
10+ "types": ["bun-types", "node"]
11+ },
12+ "include": [
13+ "index.ts"
14+ ]
15+}
plugins/google-discovery/react/index.tsx+792−0
@@ -0,0 +1,792 @@
1+import { startTransition, useMemo, useState, type ReactNode } from "react";
2+import { useNavigate } from "@tanstack/react-router";
3+import type { Source } from "@executor/react";
4+import {
5+ defineExecutorPluginHttpApiClient,
6+ Result,
7+ useAtomSet,
8+ useAtomValue,
9+ useExecutorMutation,
10+ useLocalInstallation,
11+ useSecrets,
12+} from "@executor/react";
13+
14+import {
15+ googleDiscoveryHttpApiExtension,
16+} from "@executor/plugin-google-discovery-http";
17+import {
18+ defaultGoogleDiscoveryUrl,
19+ type GoogleDiscoveryConnectInput,
20+ type GoogleDiscoveryConnectionAuth,
21+ type GoogleDiscoveryOAuthPopupResult,
22+ type GoogleDiscoveryStartOAuthInput,
23+} from "@executor/plugin-google-discovery-shared";
24+import { IconPencil } from "../../../apps/web/src/components/icons";
25+import { SourceToolExplorer } from "../../../apps/web/src/source-plugins/tool-explorer";
26+
27+type FrontendSourceTypeDefinition = {
28+ kind: string;
29+ displayName: string;
30+ description?: string;
31+ renderAddPage: () => ReactNode;
32+ renderEditPage?: (input: { source: Source }) => ReactNode;
33+ renderDetailPage?: (input: {
34+ source: Source;
35+ route: {
36+ search?: unknown;
37+ navigate?: unknown;
38+ };
39+ }) => ReactNode;
40+};
41+
42+type FrontendPluginRegisterApi = {
43+ sources: {
44+ registerType: (definition: FrontendSourceTypeDefinition) => void;
45+ };
46+};
47+
48+type RouteToolSearch = {
49+ tab?: "model" | "discover";
50+ tool?: string;
51+ query?: string;
52+};
53+
54+const OAUTH_STORAGE_PREFIX = "executor:google-discovery-oauth:";
55+const OAUTH_TIMEOUT_MS = 2 * 60_000;
56+
57+const getGoogleDiscoveryHttpClient =
58+ defineExecutorPluginHttpApiClient<"GoogleDiscoveryReactHttpClient">()(
59+ "GoogleDiscoveryReactHttpClient",
60+ [googleDiscoveryHttpApiExtension] as const,
61+ );
62+
63+const defaultGoogleInput = (): GoogleDiscoveryConnectInput => ({
64+ name: "Google Sheets",
65+ service: "sheets",
66+ version: "v4",
67+ discoveryUrl: defaultGoogleDiscoveryUrl("sheets", "v4"),
68+ defaultHeaders: null,
69+ scopes: [],
70+ auth: {
71+ kind: "none",
72+ },
73+});
74+
75+const stringifyStringMap = (
76+ value: Record<string, string> | null | undefined,
77+): string =>
78+ !value || Object.keys(value).length === 0
79+ ? ""
80+ : JSON.stringify(value, null, 2);
81+
82+const parseStringMap = (value: string): Record<string, string> | null => {
83+ const trimmed = value.trim();
84+ if (!trimmed) {
85+ return null;
86+ }
87+
88+ const parsed = JSON.parse(trimmed) as unknown;
89+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
90+ throw new Error("Headers must be a JSON object.");
91+ }
92+
93+ const entries = Object.entries(parsed as Record<string, unknown>);
94+ if (!entries.every(([, entry]) => typeof entry === "string")) {
95+ throw new Error("All header values must be strings.");
96+ }
97+
98+ return Object.fromEntries(entries as Array<[string, string]>);
99+};
100+
101+const stringifyScopes = (value: ReadonlyArray<string>): string =>
102+ value.join("\n");
103+
104+const parseScopes = (value: string): Array<string> =>
105+ value
106+ .split(/[\n,]/)
107+ .map((entry) => entry.trim())
108+ .filter(Boolean);
109+
110+const authSecretValue = (
111+ input: GoogleDiscoveryConnectionAuth,
112+): string =>
113+ input.kind === "bearer"
114+ ? JSON.stringify(input.tokenSecretRef)
115+ : "";
116+
117+const clientSecretValue = (
118+ input: GoogleDiscoveryConnectionAuth,
119+): string =>
120+ input.kind === "oauth2" && input.clientSecretRef
121+ ? JSON.stringify(input.clientSecretRef)
122+ : "";
123+
124+const waitForOauthPopupResult = async (
125+ sessionId: string,
126+): Promise<GoogleDiscoveryOAuthPopupResult> =>
127+ new Promise((resolve, reject) => {
128+ const storageKey = `${OAUTH_STORAGE_PREFIX}${sessionId}`;
129+ const startedAt = Date.now();
130+
131+ const cleanup = () => {
132+ window.removeEventListener("message", handleMessage);
133+ window.clearInterval(intervalId);
134+ };
135+
136+ const finish = (result: GoogleDiscoveryOAuthPopupResult) => {
137+ cleanup();
138+ try {
139+ window.localStorage.removeItem(storageKey);
140+ } catch {}
141+ resolve(result);
142+ };
143+
144+ const handleMessage = (event: MessageEvent) => {
145+ if (event.origin !== window.location.origin) {
146+ return;
147+ }
148+
149+ const data = event.data as GoogleDiscoveryOAuthPopupResult | undefined;
150+ if (!data || data.type !== "executor:oauth-result") {
151+ return;
152+ }
153+
154+ if (data.ok && data.sessionId !== sessionId) {
155+ return;
156+ }
157+
158+ finish(data);
159+ };
160+
161+ window.addEventListener("message", handleMessage);
162+ const intervalId = window.setInterval(() => {
163+ if (Date.now() - startedAt > OAUTH_TIMEOUT_MS) {
164+ cleanup();
165+ reject(new Error("Timed out waiting for Google OAuth to finish."));
166+ return;
167+ }
168+
169+ try {
170+ const raw = window.localStorage.getItem(storageKey);
171+ if (!raw) {
172+ return;
173+ }
174+
175+ finish(JSON.parse(raw) as GoogleDiscoveryOAuthPopupResult);
176+ } catch {
177+ // Ignore malformed local storage and continue polling.
178+ }
179+ }, 400);
180+ });
181+
182+const navigateFromPluginRoute = (
183+ navigate: unknown,
184+ next: {
185+ tab: "model" | "discover";
186+ tool?: string;
187+ query?: string;
188+ },
189+) => {
190+ const routeNavigate = navigate as
191+ | ((input: {
192+ search: {
193+ tab: "model" | "discover";
194+ tool?: string;
195+ query?: string;
196+ };
197+ }) => void | Promise<void>)
198+ | undefined;
199+ if (routeNavigate) {
200+ void routeNavigate({
201+ search: next,
202+ });
203+ }
204+};
205+
206+function GoogleDiscoverySourceForm(props: {
207+ initialValue: GoogleDiscoveryConnectInput;
208+ mode: "create" | "edit";
209+ onSubmit: (input: GoogleDiscoveryConnectInput) => Promise<void>;
210+}) {
211+ const installation = useLocalInstallation();
212+ const client = getGoogleDiscoveryHttpClient();
213+ const secrets = useSecrets();
214+ const startOAuth = useAtomSet(
215+ client.mutation("googleDiscovery", "startOAuth"),
216+ { mode: "promise" },
217+ );
218+ const submitMutation = useExecutorMutation<GoogleDiscoveryConnectInput, void>(props.onSubmit);
219+ const [name, setName] = useState(props.initialValue.name);
220+ const [service, setService] = useState(props.initialValue.service);
221+ const [version, setVersion] = useState(props.initialValue.version);
222+ const [discoveryUrl, setDiscoveryUrl] = useState(props.initialValue.discoveryUrl ?? "");
223+ const [headersText, setHeadersText] = useState(
224+ stringifyStringMap(props.initialValue.defaultHeaders),
225+ );
226+ const [scopesText, setScopesText] = useState(
227+ stringifyScopes(props.initialValue.scopes),
228+ );
229+ const [authKind, setAuthKind] = useState<GoogleDiscoveryConnectionAuth["kind"]>(
230+ props.initialValue.auth.kind,
231+ );
232+ const [bearerSecretRef, setBearerSecretRef] = useState(
233+ authSecretValue(props.initialValue.auth),
234+ );
235+ const [clientId, setClientId] = useState(
236+ props.initialValue.auth.kind === "oauth2" ? props.initialValue.auth.clientId : "",
237+ );
238+ const [clientSecretRef, setClientSecretRef] = useState(
239+ clientSecretValue(props.initialValue.auth),
240+ );
241+ const [oauthAuth, setOauthAuth] = useState<
242+ Extract<GoogleDiscoveryConnectionAuth, { kind: "oauth2" }> | null
243+ >(props.initialValue.auth.kind === "oauth2" ? props.initialValue.auth : null);
244+ const [error, setError] = useState<string | null>(null);
245+ const [oauthStatus, setOauthStatus] = useState<"idle" | "pending" | "connected">(
246+ props.initialValue.auth.kind === "oauth2" ? "connected" : "idle",
247+ );
248+
249+ const runOauth = async () => {
250+ if (installation.status !== "ready") {
251+ throw new Error("Workspace is still loading.");
252+ }
253+
254+ const payload: GoogleDiscoveryStartOAuthInput = {
255+ service: service.trim(),
256+ version: version.trim(),
257+ discoveryUrl: discoveryUrl.trim() || null,
258+ defaultHeaders: parseStringMap(headersText),
259+ scopes: parseScopes(scopesText),
260+ clientId: clientId.trim(),
261+ clientSecretRef: clientSecretRef
262+ ? JSON.parse(clientSecretRef)
263+ : null,
264+ clientAuthentication: clientSecretRef ? "client_secret_post" : "none",
265+ redirectUrl: new URL(
266+ "/v1/plugins/google-discovery/oauth/callback",
267+ window.location.origin,
268+ ).toString(),
269+ };
270+
271+ const started = await startOAuth({
272+ path: {
273+ workspaceId: installation.data.scopeId,
274+ },
275+ payload,
276+ });
277+
278+ const popup = window.open(
279+ started.authorizationUrl,
280+ "executor-google-discovery-oauth",
281+ "width=560,height=760,noopener,noreferrer",
282+ );
283+ if (!popup) {
284+ throw new Error("Failed opening Google OAuth popup.");
285+ }
286+
287+ const result = await waitForOauthPopupResult(started.sessionId);
288+ if (!result.ok) {
289+ throw new Error(result.error);
290+ }
291+
292+ setScopesText(stringifyScopes(result.auth.scopes));
293+ setOauthAuth(result.auth);
294+ setOauthStatus("connected");
295+ };
296+
297+ return (
298+ <div className="space-y-4 rounded-2xl border border-border bg-card p-5">
299+ <div>
300+ <h2 className="text-lg font-semibold text-foreground">
301+ {props.mode === "create"
302+ ? "Connect Google Discovery Source"
303+ : "Edit Google Discovery Source"}
304+ </h2>
305+ <p className="mt-1 text-sm text-muted-foreground">
306+ Google Discovery now owns its API metadata and OAuth flow inside the plugin.
307+ </p>
308+ </div>
309+
310+ <div className="grid gap-4 md:grid-cols-2">
311+ <label className="grid gap-1.5">
312+ <span className="text-xs font-medium text-foreground">Name</span>
313+ <input
314+ value={name}
315+ onChange={(event) => setName(event.target.value)}
316+ 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"
317+ />
318+ </label>
319+ <label className="grid gap-1.5">
320+ <span className="text-xs font-medium text-foreground">Service</span>
321+ <input
322+ value={service}
323+ onChange={(event) => setService(event.target.value)}
324+ 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"
325+ />
326+ </label>
327+ <label className="grid gap-1.5">
328+ <span className="text-xs font-medium text-foreground">Version</span>
329+ <input
330+ value={version}
331+ onChange={(event) => setVersion(event.target.value)}
332+ 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"
333+ />
334+ </label>
335+ <label className="grid gap-1.5">
336+ <span className="text-xs font-medium text-foreground">Discovery URL</span>
337+ <input
338+ value={discoveryUrl}
339+ onChange={(event) => setDiscoveryUrl(event.target.value)}
340+ placeholder={defaultGoogleDiscoveryUrl(service || "sheets", version || "v4")}
341+ 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"
342+ />
343+ </label>
344+ </div>
345+
346+ <label className="grid gap-1.5">
347+ <span className="text-xs font-medium text-foreground">Default Headers</span>
348+ <textarea
349+ value={headersText}
350+ onChange={(event) => setHeadersText(event.target.value)}
351+ rows={4}
352+ 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"
353+ />
354+ </label>
355+
356+ <label className="grid gap-1.5">
357+ <span className="text-xs font-medium text-foreground">Scopes</span>
358+ <textarea
359+ value={scopesText}
360+ onChange={(event) => setScopesText(event.target.value)}
361+ rows={4}
362+ placeholder="Leave blank to infer from the discovery document."
363+ 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"
364+ />
365+ </label>
366+
367+ <label className="grid gap-1.5">
368+ <span className="text-xs font-medium text-foreground">Auth</span>
369+ <select
370+ value={authKind}
371+ onChange={(event) =>
372+ setAuthKind(event.target.value as GoogleDiscoveryConnectionAuth["kind"])}
373+ 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"
374+ >
375+ <option value="none">None</option>
376+ <option value="bearer">Bearer Secret</option>
377+ <option value="oauth2">Google OAuth</option>
378+ </select>
379+ </label>
380+
381+ {authKind === "bearer" && (
382+ <label className="grid gap-1.5">
383+ <span className="text-xs font-medium text-foreground">Secret</span>
384+ <select
385+ value={bearerSecretRef}
386+ onChange={(event) => setBearerSecretRef(event.target.value)}
387+ 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"
388+ >
389+ <option value="">Select a secret</option>
390+ {secrets.status === "ready" &&
391+ secrets.data.map((secret) => (
392+ <option
393+ key={`${secret.providerId}:${secret.id}`}
394+ value={JSON.stringify({
395+ providerId: secret.providerId,
396+ handle: secret.id,
397+ })}
398+ >
399+ {secret.name ?? secret.id}
400+ </option>
401+ ))}
402+ </select>
403+ </label>
404+ )}
405+
406+ {authKind === "oauth2" && (
407+ <div className="space-y-4 rounded-xl border border-border/70 bg-background/50 p-4">
408+ <label className="grid gap-1.5">
409+ <span className="text-xs font-medium text-foreground">Client ID</span>
410+ <input
411+ value={clientId}
412+ onChange={(event) => setClientId(event.target.value)}
413+ 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"
414+ />
415+ </label>
416+ <label className="grid gap-1.5">
417+ <span className="text-xs font-medium text-foreground">Client Secret</span>
418+ <select
419+ value={clientSecretRef}
420+ onChange={(event) => setClientSecretRef(event.target.value)}
421+ 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+ >
423+ <option value="">Public client / no secret</option>
424+ {secrets.status === "ready" &&
425+ secrets.data.map((secret) => (
426+ <option
427+ key={`${secret.providerId}:${secret.id}`}
428+ value={JSON.stringify({
429+ providerId: secret.providerId,
430+ handle: secret.id,
431+ })}
432+ >
433+ {secret.name ?? secret.id}
434+ </option>
435+ ))}
436+ </select>
437+ </label>
438+ <div className="flex items-center gap-3">
439+ <button
440+ type="button"
441+ onClick={() => {
442+ void (async () => {
443+ setError(null);
444+ setOauthStatus("pending");
445+ try {
446+ await runOauth();
447+ } catch (cause) {
448+ setError(cause instanceof Error ? cause.message : "Google OAuth failed.");
449+ setOauthStatus("idle");
450+ return;
451+ }
452+ })();
453+ }}
454+ disabled={oauthStatus === "pending"}
455+ 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"
456+ >
457+ {oauthStatus === "pending"
458+ ? "Connecting..."
459+ : oauthStatus === "connected"
460+ ? "Reconnect OAuth"
461+ : "Connect OAuth"}
462+ </button>
463+ <div className="text-xs text-muted-foreground">
464+ {oauthStatus === "connected"
465+ ? "OAuth tokens are ready and will be saved with this source."
466+ : "The plugin opens Google OAuth in a popup and stores token refs locally."}
467+ </div>
468+ </div>
469+ </div>
470+ )}
471+
472+ {error && (
473+ <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-3 text-sm text-destructive">
474+ {error}
475+ </div>
476+ )}
477+
478+ <div className="flex items-center gap-3">
479+ <button
480+ type="button"
481+ onClick={() => {
482+ void (async () => {
483+ setError(null);
484+ try {
485+ const auth: GoogleDiscoveryConnectionAuth =
486+ authKind === "none"
487+ ? { kind: "none" }
488+ : authKind === "bearer"
489+ ? {
490+ kind: "bearer",
491+ tokenSecretRef: JSON.parse(bearerSecretRef),
492+ }
493+ : oauthAuth
494+ ? oauthAuth
495+ : (() => {
496+ throw new Error("Finish Google OAuth before saving.");
497+ })();
498+
499+ await submitMutation.mutateAsync({
500+ name: name.trim(),
501+ service: service.trim(),
502+ version: version.trim(),
503+ discoveryUrl: discoveryUrl.trim() || null,
504+ defaultHeaders: parseStringMap(headersText),
505+ scopes: parseScopes(scopesText),
506+ auth,
507+ });
508+ } catch (cause) {
509+ setError(
510+ cause instanceof Error
511+ ? cause.message
512+ : "Failed saving Google Discovery source.",
513+ );
514+ }
515+ })();
516+ }}
517+ disabled={submitMutation.status === "pending"}
518+ 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"
519+ >
520+ {submitMutation.status === "pending"
521+ ? props.mode === "create" ? "Creating..." : "Saving..."
522+ : props.mode === "create" ? "Create Source" : "Save Changes"}
523+ </button>
524+ <div className="text-xs text-muted-foreground">
525+ {props.mode === "create"
526+ ? "The discovery document is imported as soon as the source is created."
527+ : "Saving refreshes the imported Google API model."}
528+ </div>
529+ </div>
530+ </div>
531+ );
532+}
533+
534+function GoogleDiscoveryAddPage() {
535+ const navigate = useNavigate();
536+ const installation = useLocalInstallation();
537+ const client = getGoogleDiscoveryHttpClient();
538+ const createSource = useAtomSet(
539+ client.mutation("googleDiscovery", "createSource"),
540+ { mode: "promise" },
541+ );
542+
543+ if (installation.status !== "ready") {
544+ return <div className="text-sm text-muted-foreground">Loading workspace...</div>;
545+ }
546+
547+ return (
548+ <GoogleDiscoverySourceForm
549+ initialValue={defaultGoogleInput()}
550+ mode="create"
551+ onSubmit={async (input) => {
552+ const source = await createSource({
553+ path: {
554+ workspaceId: installation.data.scopeId,
555+ },
556+ payload: input,
557+ reactivityKeys: {
558+ sources: [installation.data.scopeId],
559+ },
560+ });
561+
562+ startTransition(() => {
563+ void navigate({
564+ to: "/sources/$sourceId",
565+ params: {
566+ sourceId: source.id,
567+ },
568+ search: {
569+ tab: "model",
570+ },
571+ });
572+ });
573+ }}
574+ />
575+ );
576+}
577+
578+function GoogleDiscoveryEditPage(props: {
579+ source: Source;
580+}) {
581+ const navigate = useNavigate();
582+ const installation = useLocalInstallation();
583+ const client = getGoogleDiscoveryHttpClient();
584+ const configResult = useAtomValue(
585+ installation.status === "ready"
586+ ? client.query("googleDiscovery", "getSourceConfig", {
587+ path: {
588+ workspaceId: installation.data.scopeId,
589+ sourceId: props.source.id,
590+ },
591+ reactivityKeys: {
592+ source: [installation.data.scopeId, props.source.id],
593+ },
594+ timeToLive: "30 seconds",
595+ })
596+ : client.query("local", "installation", {
597+ timeToLive: "1 second",
598+ }) as never,
599+ );
600+ const updateSource = useAtomSet(
601+ client.mutation("googleDiscovery", "updateSource"),
602+ { mode: "promise" },
603+ );
604+
605+ if (installation.status !== "ready") {
606+ return <div className="text-sm text-muted-foreground">Loading workspace...</div>;
607+ }
608+
609+ if (Result.isFailure(configResult)) {
610+ return (
611+ <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-3 text-sm text-destructive">
612+ Failed loading Google Discovery config.
613+ </div>
614+ );
615+ }
616+
617+ if (!Result.isSuccess(configResult)) {
618+ return <div className="text-sm text-muted-foreground">Loading Google Discovery config...</div>;
619+ }
620+
621+ return (
622+ <GoogleDiscoverySourceForm
623+ initialValue={configResult.value}
624+ mode="edit"
625+ onSubmit={async (input) => {
626+ const source = await updateSource({
627+ path: {
628+ workspaceId: installation.data.scopeId,
629+ sourceId: props.source.id,
630+ },
631+ payload: input,
632+ reactivityKeys: {
633+ sources: [installation.data.scopeId],
634+ source: [installation.data.scopeId, props.source.id],
635+ sourceInspection: [installation.data.scopeId, props.source.id],
636+ sourceInspectionTool: [installation.data.scopeId, props.source.id],
637+ sourceDiscovery: [installation.data.scopeId, props.source.id],
638+ },
639+ });
640+
641+ startTransition(() => {
642+ void navigate({
643+ to: "/sources/$sourceId",
644+ params: {
645+ sourceId: source.id,
646+ },
647+ search: {
648+ tab: "model",
649+ },
650+ });
651+ });
652+ }}
653+ />
654+ );
655+}
656+
657+function GoogleDiscoveryDetailPage(props: {
658+ source: Source;
659+ route: {
660+ search?: unknown;
661+ navigate?: unknown;
662+ };
663+}) {
664+ const navigate = useNavigate();
665+ const installation = useLocalInstallation();
666+ const client = getGoogleDiscoveryHttpClient();
667+ const removeSource = useAtomSet(
668+ client.mutation("googleDiscovery", "removeSource"),
669+ { mode: "promise" },
670+ );
671+ const configResult = useAtomValue(
672+ installation.status === "ready"
673+ ? client.query("googleDiscovery", "getSourceConfig", {
674+ path: {
675+ workspaceId: installation.data.scopeId,
676+ sourceId: props.source.id,
677+ },
678+ reactivityKeys: {
679+ source: [installation.data.scopeId, props.source.id],
680+ },
681+ timeToLive: "30 seconds",
682+ })
683+ : client.query("local", "installation", {
684+ timeToLive: "1 second",
685+ }) as never,
686+ );
687+ const summary = useMemo(() => {
688+ if (!Result.isSuccess(configResult)) {
689+ return null;
690+ }
691+
692+ const config = configResult.value;
693+ return (
694+ <div className="space-y-1">
695+ <div className="font-mono text-xs text-foreground">
696+ {config.discoveryUrl ?? defaultGoogleDiscoveryUrl(config.service, config.version)}
697+ </div>
698+ <div>
699+ API: <span className="text-foreground">{config.service} {config.version}</span>
700+ </div>
701+ <div>
702+ Auth: <span className="text-foreground">{config.auth.kind}</span>
703+ </div>
704+ </div>
705+ );
706+ }, [configResult]);
707+
708+ if (installation.status !== "ready") {
709+ return <div className="text-sm text-muted-foreground">Loading workspace...</div>;
710+ }
711+
712+ return (
713+ <SourceToolExplorer
714+ sourceId={props.source.id}
715+ title={props.source.name}
716+ kind={props.source.kind}
717+ search={(props.route.search ?? {}) as RouteToolSearch}
718+ navigate={(next) => navigateFromPluginRoute(props.route.navigate, next)}
719+ summary={summary}
720+ actions={(
721+ <>
722+ <button
723+ type="button"
724+ onClick={() =>
725+ void navigate({
726+ to: "/sources/$sourceId/edit",
727+ params: {
728+ sourceId: props.source.id,
729+ },
730+ })}
731+ className="inline-flex h-9 items-center gap-1.5 rounded-lg border border-input bg-card px-3 text-sm font-medium text-foreground transition-colors hover:bg-accent/50"
732+ >
733+ <IconPencil className="size-3.5" />
734+ Edit
735+ </button>
736+ <button
737+ type="button"
738+ onClick={() => {
739+ const confirmed = window.confirm(
740+ `Delete Google Discovery source "${props.source.name}"?`,
741+ );
742+ if (!confirmed) {
743+ return;
744+ }
745+
746+ void removeSource({
747+ path: {
748+ workspaceId: installation.data.scopeId,
749+ sourceId: props.source.id,
750+ },
751+ reactivityKeys: {
752+ sources: [installation.data.scopeId],
753+ source: [installation.data.scopeId, props.source.id],
754+ sourceInspection: [installation.data.scopeId, props.source.id],
755+ sourceInspectionTool: [installation.data.scopeId, props.source.id],
756+ sourceDiscovery: [installation.data.scopeId, props.source.id],
757+ },
758+ }).then(() => {
759+ startTransition(() => {
760+ void navigate({
761+ to: "/",
762+ });
763+ });
764+ });
765+ }}
766+ 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"
767+ >
768+ Delete
769+ </button>
770+ </>
771+ )}
772+ />
773+ );
774+}
775+
776+export const GoogleDiscoveryReactPlugin = {
777+ key: "google_discovery",
778+ register(api: FrontendPluginRegisterApi) {
779+ api.sources.registerType({
780+ kind: "google_discovery",
781+ displayName: "Google Discovery",
782+ description: "Import Google APIs from discovery documents with plugin-owned OAuth.",
783+ renderAddPage: () => <GoogleDiscoveryAddPage />,
784+ renderEditPage: ({ source }) => (
785+ <GoogleDiscoveryEditPage source={source} />
786+ ),
787+ renderDetailPage: ({ source, route }) => (
788+ <GoogleDiscoveryDetailPage source={source} route={route} />
789+ ),
790+ });
791+ },
792+};
plugins/google-discovery/react/package.json+27−0
@@ -0,0 +1,27 @@
1+{
2+ "name": "@executor/plugin-google-discovery-react",
3+ "private": true,
4+ "type": "module",
5+ "exports": {
6+ ".": "./index.tsx"
7+ },
8+ "files": [
9+ "index.tsx"
10+ ],
11+ "scripts": {
12+ "typecheck": "bunx tsc --noEmit -p tsconfig.json"
13+ },
14+ "dependencies": {
15+ "@executor/plugin-google-discovery-http": "workspace:*",
16+ "@executor/plugin-google-discovery-shared": "workspace:*",
17+ "@executor/react": "workspace:*",
18+ "@tanstack/react-router": "^1.132.7",
19+ "react": "^19.2.0"
20+ },
21+ "devDependencies": {
22+ "@types/node": "catalog:",
23+ "@types/react": "^19.2.10",
24+ "bun-types": "catalog:"
25+ },
26+ "version": "0.0.0"
27+}
plugins/google-discovery/react/tsconfig.json+16−0
@@ -0,0 +1,16 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2022",
4+ "module": "ESNext",
5+ "moduleResolution": "Bundler",
6+ "strict": true,
7+ "skipLibCheck": true,
8+ "noEmit": true,
9+ "jsx": "react-jsx",
10+ "lib": ["ES2022", "DOM"],
11+ "types": ["bun-types", "node"]
12+ },
13+ "include": [
14+ "index.tsx"
15+ ]
16+}
plugins/google-discovery/sdk/catalog.ts+390−0
@@ -0,0 +1,390 @@
1+import { createCatalogSnapshotV1FromFragments } from "@executor/ir/catalog";
2+import {
3+ CapabilityIdSchema,
4+ DocumentIdSchema,
5+ ExecutableIdSchema,
6+ ParameterSymbolIdSchema,
7+ RequestBodySymbolIdSchema,
8+ ResponseSymbolIdSchema,
9+ ScopeIdSchema,
10+ SecuritySchemeSymbolIdSchema,
11+} from "@executor/ir/ids";
12+import type {
13+ AuthRequirement,
14+ Capability,
15+ CatalogSnapshotV1,
16+ ParameterSymbol,
17+ ResponseSymbol,
18+ Scope,
19+ SecuritySchemeSymbol,
20+ Executable,
21+} from "@executor/ir/model";
22+import {
23+ type BaseCatalogOperationInput,
24+ type CatalogFragmentBuilder,
25+ type CatalogSourceDocumentInput,
26+ type JsonSchemaImporter,
27+ type Source,
28+ EXECUTABLE_BINDING_VERSION,
29+ buildCatalogFragment,
30+ createCatalogImportMetadata,
31+ docsFrom,
32+ groupedSchemaForParameter,
33+ interactionForEffect,
34+ mutableRecord,
35+ provenanceFor,
36+ requestBodySchemaFromInput,
37+ responseSetFromSingleResponse,
38+ stableHash,
39+ toolPathSegments,
40+} from "@executor/source-core";
41+
42+import type { GoogleDiscoveryToolProviderData } from "./types";
43+
44+export type GoogleDiscoveryCatalogOperationInput = BaseCatalogOperationInput & {
45+ providerData: GoogleDiscoveryToolProviderData;
46+};
47+
48+const googleDiscoveryServerSpecs = (
49+ operation: GoogleDiscoveryCatalogOperationInput | undefined,
50+): NonNullable<NonNullable<Scope["defaults"]>["servers"]> | undefined => {
51+ const rootUrl = operation?.providerData.invocation.rootUrl;
52+ if (!rootUrl) {
53+ return undefined;
54+ }
55+
56+ const servicePath = operation?.providerData.invocation.servicePath ?? "";
57+ return [
58+ {
59+ url: new URL(servicePath || "", rootUrl).toString(),
60+ },
61+ ];
62+};
63+
64+const createHttpCapabilityFromGoogleDiscovery = (input: {
65+ catalog: CatalogFragmentBuilder;
66+ source: Pick<Source, "id" | "kind" | "name" | "namespace">;
67+ documentId: ReturnType<typeof DocumentIdSchema.make>;
68+ serviceScopeId: ReturnType<typeof ScopeIdSchema.make>;
69+ operation: GoogleDiscoveryCatalogOperationInput;
70+ importer: JsonSchemaImporter;
71+}) => {
72+ const toolPath = toolPathSegments(
73+ input.source,
74+ input.operation.providerData.toolId,
75+ );
76+ const capabilityId = CapabilityIdSchema.make(
77+ `cap_${stableHash({
78+ sourceId: input.source.id,
79+ toolId: input.operation.providerData.toolId,
80+ })}`,
81+ );
82+ const executableId = ExecutableIdSchema.make(
83+ `exec_${stableHash({
84+ sourceId: input.source.id,
85+ toolId: input.operation.providerData.toolId,
86+ protocol: "http",
87+ })}`,
88+ );
89+ const inputSchema = input.operation.inputSchema ?? {};
90+ const outputSchema = input.operation.outputSchema ?? {};
91+
92+ const authSchemeId =
93+ input.operation.providerData.invocation.scopes.length > 0
94+ ? SecuritySchemeSymbolIdSchema.make(
95+ `security_${stableHash({
96+ sourceId: input.source.id,
97+ scopes: input.operation.providerData.invocation.scopes,
98+ })}`,
99+ )
100+ : undefined;
101+
102+ if (authSchemeId && !input.catalog.symbols[authSchemeId]) {
103+ const scopeDescriptions = Object.fromEntries(
104+ input.operation.providerData.invocation.scopes.map((scope) => [
105+ scope,
106+ input.operation.providerData.invocation.scopeDescriptions?.[scope] ??
107+ scope,
108+ ]),
109+ );
110+ mutableRecord(input.catalog.symbols)[authSchemeId] = {
111+ id: authSchemeId,
112+ kind: "securityScheme",
113+ schemeType: "oauth2",
114+ docs: docsFrom({
115+ summary: "OAuth 2.0",
116+ description: "Imported from Google Discovery scopes.",
117+ }),
118+ oauth: {
119+ flows: {},
120+ scopes: scopeDescriptions,
121+ },
122+ synthetic: false,
123+ provenance: provenanceFor(input.documentId, "#/googleDiscovery/security"),
124+ } satisfies SecuritySchemeSymbol;
125+ }
126+
127+ input.operation.providerData.invocation.parameters.forEach((parameter) => {
128+ const parameterId = ParameterSymbolIdSchema.make(
129+ `param_${stableHash({
130+ capabilityId,
131+ location: parameter.location,
132+ name: parameter.name,
133+ })}`,
134+ );
135+ const parameterSchema =
136+ groupedSchemaForParameter(
137+ inputSchema,
138+ parameter.location,
139+ parameter.name,
140+ ) ??
141+ (parameter.repeated
142+ ? {
143+ type: "array",
144+ items: {
145+ type: parameter.type === "integer" ? "integer" : "string",
146+ ...(parameter.enum ? { enum: parameter.enum } : {}),
147+ },
148+ }
149+ : {
150+ type: parameter.type === "integer" ? "integer" : "string",
151+ ...(parameter.enum ? { enum: parameter.enum } : {}),
152+ });
153+
154+ mutableRecord(input.catalog.symbols)[parameterId] = {
155+ id: parameterId,
156+ kind: "parameter",
157+ name: parameter.name,
158+ location: parameter.location,
159+ required: parameter.required,
160+ ...(docsFrom({
161+ description: parameter.description,
162+ })
163+ ? {
164+ docs: docsFrom({
165+ description: parameter.description,
166+ })!,
167+ }
168+ : {}),
169+ schemaShapeId: input.importer.importSchema(
170+ parameterSchema,
171+ `#/googleDiscovery/${input.operation.providerData.toolId}/parameter/${parameter.location}/${parameter.name}`,
172+ inputSchema,
173+ ),
174+ synthetic: false,
175+ provenance: provenanceFor(
176+ input.documentId,
177+ `#/googleDiscovery/${input.operation.providerData.toolId}/parameter/${parameter.location}/${parameter.name}`,
178+ ),
179+ } satisfies ParameterSymbol;
180+ });
181+
182+ const requestBodyId =
183+ input.operation.providerData.invocation.requestSchemaId ||
184+ requestBodySchemaFromInput(inputSchema) !== undefined
185+ ? RequestBodySymbolIdSchema.make(
186+ `request_body_${stableHash({ capabilityId })}`,
187+ )
188+ : undefined;
189+
190+ if (requestBodyId) {
191+ const requestBodySchema =
192+ requestBodySchemaFromInput(inputSchema) ?? inputSchema;
193+ mutableRecord(input.catalog.symbols)[requestBodyId] = {
194+ id: requestBodyId,
195+ kind: "requestBody",
196+ contents: [
197+ {
198+ mediaType: "application/json",
199+ shapeId: input.importer.importSchema(
200+ requestBodySchema,
201+ `#/googleDiscovery/${input.operation.providerData.toolId}/requestBody`,
202+ inputSchema,
203+ ),
204+ },
205+ ],
206+ synthetic: false,
207+ provenance: provenanceFor(
208+ input.documentId,
209+ `#/googleDiscovery/${input.operation.providerData.toolId}/requestBody`,
210+ ),
211+ };
212+ }
213+
214+ const responseId = ResponseSymbolIdSchema.make(
215+ `response_${stableHash({ capabilityId })}`,
216+ );
217+ mutableRecord(input.catalog.symbols)[responseId] = {
218+ id: responseId,
219+ kind: "response",
220+ ...(docsFrom({
221+ description: input.operation.description,
222+ })
223+ ? {
224+ docs: docsFrom({
225+ description: input.operation.description,
226+ })!,
227+ }
228+ : {}),
229+ ...(input.operation.outputSchema !== undefined
230+ ? {
231+ contents: [
232+ {
233+ mediaType: "application/json",
234+ shapeId: input.importer.importSchema(
235+ outputSchema,
236+ `#/googleDiscovery/${input.operation.providerData.toolId}/response`,
237+ outputSchema,
238+ ),
239+ },
240+ ],
241+ }
242+ : {}),
243+ synthetic: false,
244+ provenance: provenanceFor(
245+ input.documentId,
246+ `#/googleDiscovery/${input.operation.providerData.toolId}/response`,
247+ ),
248+ } satisfies ResponseSymbol;
249+
250+ const traits: Array<"upload" | "download"> = [];
251+ if (input.operation.providerData.invocation.supportsMediaUpload) {
252+ traits.push("upload");
253+ }
254+ if (input.operation.providerData.invocation.supportsMediaDownload) {
255+ traits.push("download");
256+ }
257+
258+ const responseSetId = responseSetFromSingleResponse({
259+ catalog: input.catalog,
260+ responseId,
261+ provenance: provenanceFor(
262+ input.documentId,
263+ `#/googleDiscovery/${input.operation.providerData.toolId}/responseSet`,
264+ ),
265+ traits,
266+ });
267+
268+ const callShapeId =
269+ input.operation.inputSchema !== undefined
270+ ? input.importer.importSchema(
271+ input.operation.inputSchema,
272+ `#/googleDiscovery/${input.operation.providerData.toolId}/call`,
273+ input.operation.inputSchema,
274+ )
275+ : input.importer.importSchema(
276+ {
277+ type: "object",
278+ additionalProperties: false,
279+ },
280+ `#/googleDiscovery/${input.operation.providerData.toolId}/call`,
281+ );
282+
283+ mutableRecord(input.catalog.executables)[executableId] = {
284+ id: executableId,
285+ capabilityId,
286+ scopeId: input.serviceScopeId,
287+ pluginKey: "google_discovery",
288+ bindingVersion: EXECUTABLE_BINDING_VERSION,
289+ binding: input.operation.providerData,
290+ projection: {
291+ responseSetId,
292+ callShapeId,
293+ },
294+ display: {
295+ protocol: "http",
296+ method: input.operation.providerData.invocation.method.toUpperCase(),
297+ pathTemplate: input.operation.providerData.invocation.path,
298+ operationId: input.operation.providerData.methodId,
299+ group: input.operation.providerData.group,
300+ leaf: input.operation.providerData.leaf,
301+ rawToolId: input.operation.providerData.rawToolId,
302+ title: input.operation.title ?? null,
303+ summary: input.operation.description ?? null,
304+ },
305+ synthetic: false,
306+ provenance: provenanceFor(
307+ input.documentId,
308+ `#/googleDiscovery/${input.operation.providerData.toolId}/executable`,
309+ ),
310+ } satisfies Executable;
311+
312+ const effect = input.operation.effect;
313+ const auth: AuthRequirement = authSchemeId
314+ ? {
315+ kind: "scheme",
316+ schemeId: authSchemeId,
317+ scopes: input.operation.providerData.invocation.scopes,
318+ }
319+ : { kind: "none" };
320+
321+ mutableRecord(input.catalog.capabilities)[capabilityId] = {
322+ id: capabilityId,
323+ serviceScopeId: input.serviceScopeId,
324+ surface: {
325+ toolPath,
326+ ...(input.operation.title ? { title: input.operation.title } : {}),
327+ ...(input.operation.description
328+ ? { summary: input.operation.description }
329+ : {}),
330+ tags: [
331+ "google",
332+ input.operation.providerData.service,
333+ input.operation.providerData.version,
334+ ],
335+ },
336+ semantics: {
337+ effect,
338+ safe: effect === "read",
339+ idempotent: effect === "read" || effect === "delete",
340+ destructive: effect === "delete",
341+ },
342+ auth,
343+ interaction: interactionForEffect(effect),
344+ executableIds: [executableId],
345+ synthetic: false,
346+ provenance: provenanceFor(
347+ input.documentId,
348+ `#/googleDiscovery/${input.operation.providerData.toolId}/capability`,
349+ ),
350+ } satisfies Capability;
351+};
352+
353+export const createGoogleDiscoveryCatalogFragment = (input: {
354+ source: Source;
355+ documents: readonly CatalogSourceDocumentInput[];
356+ operations: readonly GoogleDiscoveryCatalogOperationInput[];
357+}) =>
358+ buildCatalogFragment({
359+ source: input.source,
360+ documents: input.documents,
361+ serviceScopeDefaults: (() => {
362+ const servers = googleDiscoveryServerSpecs(input.operations[0]);
363+ return servers ? { servers } : undefined;
364+ })(),
365+ registerOperations: ({ catalog, documentId, serviceScopeId, importer }) => {
366+ for (const operation of input.operations) {
367+ createHttpCapabilityFromGoogleDiscovery({
368+ catalog,
369+ source: input.source,
370+ documentId,
371+ serviceScopeId,
372+ operation,
373+ importer,
374+ });
375+ }
376+ },
377+ });
378+
379+export const createGoogleDiscoveryCatalogSnapshot = (input: {
380+ source: Source;
381+ documents: readonly CatalogSourceDocumentInput[];
382+ operations: readonly GoogleDiscoveryCatalogOperationInput[];
383+}): CatalogSnapshotV1 =>
384+ createCatalogSnapshotV1FromFragments({
385+ import: createCatalogImportMetadata({
386+ source: input.source,
387+ pluginKey: "google_discovery",
388+ }),
389+ fragments: [createGoogleDiscoveryCatalogFragment(input)],
390+ });
plugins/google-discovery/sdk/discovery.ts+94−0
@@ -0,0 +1,94 @@
1+import {
2+ executeHttpProbe,
3+ namespaceFromSourceName,
4+ noneAuthInference,
5+ supportedAuthInference,
6+ trimOrNull,
7+ type SourceAuthInference,
8+ type SourceDiscoveryProbeInput,
9+ type SourceDiscoveryResult,
10+} from "@executor/source-core";
11+import * as Either from "effect/Either";
12+import * as Effect from "effect/Effect";
13+
14+import { extractGoogleDiscoveryManifest } from "./document";
15+
16+const googleDiscoveryEndpoint = (input: {
17+ rootUrl: string;
18+ servicePath: string;
19+}): string => {
20+ try {
21+ return new URL(input.servicePath || "", input.rootUrl).toString();
22+ } catch {
23+ return input.rootUrl;
24+ }
25+};
26+
27+const inferGoogleDiscoveryAuth = (input: {
28+ scopes: ReadonlyArray<string>;
29+}): SourceAuthInference =>
30+ input.scopes.length > 0
31+ ? supportedAuthInference("oauth2", {
32+ confidence: "high",
33+ reason: "Google Discovery document declares OAuth scopes",
34+ headerName: "Authorization",
35+ prefix: "Bearer ",
36+ parameterName: null,
37+ parameterLocation: null,
38+ oauthAuthorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth",
39+ oauthTokenUrl: "https://oauth2.googleapis.com/token",
40+ oauthScopes: [...input.scopes],
41+ })
42+ : noneAuthInference(
43+ "Google Discovery document does not declare OAuth scopes",
44+ "medium",
45+ );
46+
47+export const detectGoogleDiscoverySource = (
48+ input: SourceDiscoveryProbeInput,
49+): Effect.Effect<SourceDiscoveryResult | null, never, never> =>
50+ Effect.gen(function* () {
51+ const response = yield* Effect.either(executeHttpProbe({
52+ method: "GET",
53+ url: input.normalizedUrl,
54+ headers: input.headers,
55+ }));
56+
57+ if (Either.isLeft(response)) {
58+ return null;
59+ }
60+
61+ if (response.right.status < 200 || response.right.status >= 300) {
62+ return null;
63+ }
64+
65+ const manifest = yield* Effect.either(
66+ extractGoogleDiscoveryManifest(input.normalizedUrl, response.right.text),
67+ );
68+ if (Either.isLeft(manifest)) {
69+ return null;
70+ }
71+
72+ const endpoint = googleDiscoveryEndpoint({
73+ rootUrl: manifest.right.rootUrl,
74+ servicePath: manifest.right.servicePath,
75+ });
76+ const name = trimOrNull(manifest.right.title)
77+ ?? `${manifest.right.service}.${manifest.right.versionName}.googleapis.com`;
78+ const oauthScopes = Object.keys(manifest.right.oauthScopes ?? {});
79+
80+ return {
81+ detectedKind: "plugin",
82+ confidence: "high",
83+ endpoint,
84+ specUrl: input.normalizedUrl,
85+ name,
86+ namespace: namespaceFromSourceName(manifest.right.service),
87+ transport: null,
88+ authInference: inferGoogleDiscoveryAuth({
89+ scopes: oauthScopes,
90+ }),
91+ toolCount: manifest.right.methods.length,
92+ warnings: [],
93+ } satisfies SourceDiscoveryResult;
94+ }).pipe(Effect.catchAll(() => Effect.succeed(null)));
plugins/google-discovery/sdk/document.ts+549−0
@@ -0,0 +1,549 @@
1+import { sha256Hex } from "@executor/codemode-core";
2+import * as Effect from "effect/Effect";
3+
4+import type {
5+ GoogleDiscoveryHttpMethod,
6+ GoogleDiscoveryManifestMethod,
7+ GoogleDiscoveryMethodParameter,
8+ GoogleDiscoverySchemaRefTable,
9+ GoogleDiscoveryToolManifest,
10+} from "./types";
11+
12+type JsonObject = Record<string, unknown>;
13+
14+type DiscoveryParameter = {
15+ location?: unknown;
16+ type?: unknown;
17+ required?: unknown;
18+ repeated?: unknown;
19+ description?: unknown;
20+ enum?: unknown;
21+ default?: unknown;
22+ $ref?: unknown;
23+ items?: unknown;
24+};
25+
26+type DiscoverySchema = {
27+ id?: unknown;
28+ type?: unknown;
29+ description?: unknown;
30+ properties?: unknown;
31+ items?: unknown;
32+ additionalProperties?: unknown;
33+ enum?: unknown;
34+ format?: unknown;
35+ readOnly?: unknown;
36+ default?: unknown;
37+ $ref?: unknown;
38+};
39+
40+type DiscoveryMethod = {
41+ id?: unknown;
42+ description?: unknown;
43+ httpMethod?: unknown;
44+ path?: unknown;
45+ flatPath?: unknown;
46+ parameters?: unknown;
47+ request?: unknown;
48+ response?: unknown;
49+ scopes?: unknown;
50+ mediaUpload?: unknown;
51+ supportsMediaDownload?: unknown;
52+};
53+
54+type DiscoveryResource = {
55+ methods?: unknown;
56+ resources?: unknown;
57+};
58+
59+type DiscoveryDocument = {
60+ name?: unknown;
61+ version?: unknown;
62+ title?: unknown;
63+ description?: unknown;
64+ rootUrl?: unknown;
65+ servicePath?: unknown;
66+ batchPath?: unknown;
67+ documentationLink?: unknown;
68+ resources?: unknown;
69+ methods?: unknown;
70+ schemas?: unknown;
71+ auth?: unknown;
72+};
73+
74+const asRecord = (value: unknown): JsonObject =>
75+ typeof value === "object" && value !== null && !Array.isArray(value)
76+ ? value as JsonObject
77+ : {};
78+
79+const asString = (value: unknown): string | null =>
80+ typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
81+
82+const asBoolean = (value: unknown): boolean | null =>
83+ typeof value === "boolean" ? value : null;
84+
85+const asStringArray = (value: unknown): ReadonlyArray<string> =>
86+ Array.isArray(value)
87+ ? value.flatMap((entry) => {
88+ const stringValue = asString(entry);
89+ return stringValue ? [stringValue] : [];
90+ })
91+ : [];
92+
93+const parseJson = (value: string | undefined): unknown | undefined => {
94+ if (!value) {
95+ return undefined;
96+ }
97+
98+ try {
99+ return JSON.parse(value) as unknown;
100+ } catch {
101+ return undefined;
102+ }
103+};
104+
105+const contentHash = (value: string): string =>
106+ sha256Hex(value);
107+
108+const escapeJsonPointerSegment = (value: string): string =>
109+ value.replaceAll("~", "~0").replaceAll("/", "~1");
110+
111+const schemaRefKey = (schemaId: string): string =>
112+ `#/$defs/google/${escapeJsonPointerSegment(schemaId)}`;
113+
114+const normalizeHttpMethod = (value: unknown): GoogleDiscoveryHttpMethod => {
115+ const method = asString(value)?.toLowerCase();
116+ switch (method) {
117+ case "get":
118+ case "put":
119+ case "post":
120+ case "delete":
121+ case "patch":
122+ case "head":
123+ case "options":
124+ return method;
125+ default:
126+ throw new Error(`Unsupported Google Discovery HTTP method: ${String(value)}`);
127+ }
128+};
129+
130+const googleSchemaToJsonSchema = (input: {
131+ schema: DiscoverySchema | DiscoveryParameter | unknown;
132+ topLevelSchemas: Readonly<Record<string, DiscoverySchema>>;
133+}): unknown => {
134+ const schema = asRecord(input.schema) as DiscoverySchema;
135+ const ref = asString(schema.$ref);
136+ if (ref) {
137+ return { $ref: schemaRefKey(ref) };
138+ }
139+
140+ const description = asString(schema.description);
141+ const format = asString(schema.format);
142+ const type = asString(schema.type);
143+ const enumValues = asStringArray(schema.enum);
144+ const defaultValue =
145+ typeof schema.default === "string"
146+ || typeof schema.default === "number"
147+ || typeof schema.default === "boolean"
148+ ? schema.default
149+ : undefined;
150+ const readOnly = asBoolean(schema.readOnly);
151+
152+ const base: Record<string, unknown> = {
153+ ...(description ? { description } : {}),
154+ ...(format ? { format } : {}),
155+ ...(enumValues.length > 0 ? { enum: [...enumValues] } : {}),
156+ ...(defaultValue !== undefined ? { default: defaultValue } : {}),
157+ ...(readOnly === true ? { readOnly: true } : {}),
158+ };
159+
160+ if (type === "any") {
161+ return base;
162+ }
163+
164+ if (type === "array") {
165+ const items = googleSchemaToJsonSchema({
166+ schema: schema.items,
167+ topLevelSchemas: input.topLevelSchemas,
168+ });
169+ return {
170+ ...base,
171+ type: "array",
172+ items: items ?? {},
173+ };
174+ }
175+
176+ const properties = asRecord(schema.properties);
177+ const additionalProperties = schema.additionalProperties;
178+ if (
179+ type === "object"
180+ || Object.keys(properties).length > 0
181+ || additionalProperties !== undefined
182+ ) {
183+ const convertedProperties = Object.fromEntries(
184+ Object.entries(properties).map(([key, propertySchema]) => [
185+ key,
186+ googleSchemaToJsonSchema({
187+ schema: propertySchema,
188+ topLevelSchemas: input.topLevelSchemas,
189+ }),
190+ ]),
191+ );
192+
193+ const convertedAdditionalProperties =
194+ additionalProperties === undefined
195+ ? undefined
196+ : additionalProperties === true
197+ ? true
198+ : googleSchemaToJsonSchema({
199+ schema: additionalProperties,
200+ topLevelSchemas: input.topLevelSchemas,
201+ });
202+
203+ return {
204+ ...base,
205+ type: "object",
206+ ...(Object.keys(convertedProperties).length > 0
207+ ? { properties: convertedProperties }
208+ : {}),
209+ ...(convertedAdditionalProperties !== undefined
210+ ? { additionalProperties: convertedAdditionalProperties }
211+ : {}),
212+ };
213+ }
214+
215+ if (type === "boolean" || type === "number" || type === "integer" || type === "string") {
216+ return {
217+ ...base,
218+ type,
219+ };
220+ }
221+
222+ return Object.keys(base).length > 0 ? base : {};
223+};
224+
225+const parameterToJsonSchema = (input: {
226+ parameter: DiscoveryParameter;
227+ topLevelSchemas: Readonly<Record<string, DiscoverySchema>>;
228+}): unknown => {
229+ const schema = googleSchemaToJsonSchema({
230+ schema: input.parameter,
231+ topLevelSchemas: input.topLevelSchemas,
232+ });
233+ const repeated = input.parameter.repeated === true;
234+ if (!repeated) {
235+ return schema;
236+ }
237+
238+ return {
239+ type: "array",
240+ items: schema,
241+ };
242+};
243+
244+const methodRequestSchemaJson = (input: {
245+ method: DiscoveryMethod;
246+ topLevelSchemas: Readonly<Record<string, DiscoverySchema>>;
247+}): string | undefined => {
248+ const methodRecord = asRecord(input.method);
249+ const parameters = asRecord(methodRecord.parameters);
250+ const properties: Record<string, unknown> = {};
251+ const required: string[] = [];
252+
253+ for (const [name, rawParameter] of Object.entries(parameters)) {
254+ const parameter = asRecord(rawParameter) as DiscoveryParameter;
255+ properties[name] = parameterToJsonSchema({
256+ parameter,
257+ topLevelSchemas: input.topLevelSchemas,
258+ });
259+ if (parameter.required === true) {
260+ required.push(name);
261+ }
262+ }
263+
264+ const requestRef = asString(asRecord(methodRecord.request).$ref);
265+ if (requestRef) {
266+ const requestSchema = input.topLevelSchemas[requestRef];
267+ if (requestSchema) {
268+ properties.body = googleSchemaToJsonSchema({
269+ schema: requestSchema,
270+ topLevelSchemas: input.topLevelSchemas,
271+ });
272+ } else {
273+ properties.body = { $ref: schemaRefKey(requestRef) };
274+ }
275+ }
276+
277+ if (Object.keys(properties).length === 0) {
278+ return undefined;
279+ }
280+
281+ return JSON.stringify({
282+ type: "object",
283+ properties,
284+ ...(required.length > 0 ? { required } : {}),
285+ additionalProperties: false,
286+ });
287+};
288+
289+const methodResponseSchemaJson = (input: {
290+ method: DiscoveryMethod;
291+ topLevelSchemas: Readonly<Record<string, DiscoverySchema>>;
292+}): string | undefined => {
293+ const responseRef = asString(asRecord(asRecord(input.method).response).$ref);
294+ if (!responseRef) {
295+ return undefined;
296+ }
297+
298+ const responseSchema = input.topLevelSchemas[responseRef];
299+ const schemaJson = responseSchema
300+ ? googleSchemaToJsonSchema({
301+ schema: responseSchema,
302+ topLevelSchemas: input.topLevelSchemas,
303+ })
304+ : { $ref: schemaRefKey(responseRef) };
305+
306+ return JSON.stringify(schemaJson);
307+};
308+
309+const parameterRecordFromMethod = (
310+ method: DiscoveryMethod,
311+): ReadonlyArray<GoogleDiscoveryMethodParameter> =>
312+ Object.entries(asRecord(asRecord(method).parameters))
313+ .flatMap(([name, rawParameter]) => {
314+ const parameter = asRecord(rawParameter) as DiscoveryParameter;
315+ const location = asString(parameter.location);
316+ if (location !== "path" && location !== "query" && location !== "header") {
317+ return [];
318+ }
319+
320+ return [{
321+ name,
322+ location,
323+ required: parameter.required === true,
324+ repeated: parameter.repeated === true,
325+ description: asString(parameter.description),
326+ type: asString(parameter.type) ?? asString(parameter.$ref),
327+ ...(asStringArray(parameter.enum).length > 0
328+ ? { enum: [...asStringArray(parameter.enum)] }
329+ : {}),
330+ ...(asString(parameter.default) ? { default: asString(parameter.default)! } : {}),
331+ } satisfies GoogleDiscoveryMethodParameter];
332+ });
333+
334+const scopesRecordFromDocument = (
335+ document: DiscoveryDocument,
336+): Record<string, string> | undefined => {
337+ const scopes = asRecord(
338+ asRecord(
339+ asRecord(document.auth).oauth2,
340+ ).scopes,
341+ );
342+
343+ const normalized = Object.fromEntries(
344+ Object.entries(scopes).flatMap(([scope, rawValue]) => {
345+ const description = asString(asRecord(rawValue).description) ?? "";
346+ return [[scope, description]];
347+ }),
348+ );
349+
350+ return Object.keys(normalized).length > 0 ? normalized : undefined;
351+};
352+
353+const manifestMethodFromDiscoveryMethod = (input: {
354+ service: string;
355+ version: string;
356+ rootUrl: string;
357+ servicePath: string;
358+ topLevelSchemas: Readonly<Record<string, DiscoverySchema>>;
359+ method: DiscoveryMethod;
360+}): GoogleDiscoveryManifestMethod | null => {
361+ const methodId = asString(input.method.id);
362+ const path = asString(input.method.path);
363+ if (!methodId || !path) {
364+ return null;
365+ }
366+
367+ const method = normalizeHttpMethod(input.method.httpMethod);
368+ const rawToolId = methodId;
369+ const toolId = rawToolId.startsWith(`${input.service}.`)
370+ ? rawToolId.slice(input.service.length + 1)
371+ : rawToolId;
372+ const segments = toolId.split(".").filter((segment) => segment.length > 0);
373+ const leaf = segments.at(-1) ?? toolId;
374+ const group = segments.length > 1 ? segments.slice(0, -1).join(".") : null;
375+ const responseRef = asString(asRecord(asRecord(input.method).response).$ref);
376+ const requestRef = asString(asRecord(asRecord(input.method).request).$ref);
377+ const mediaUpload = asRecord(input.method.mediaUpload);
378+
379+ return {
380+ toolId,
381+ rawToolId,
382+ methodId,
383+ name: toolId,
384+ description: asString(input.method.description),
385+ group,
386+ leaf,
387+ method,
388+ path,
389+ flatPath: asString(input.method.flatPath),
390+ parameters: parameterRecordFromMethod(input.method),
391+ requestSchemaId: requestRef,
392+ responseSchemaId: responseRef,
393+ scopes: [...asStringArray(input.method.scopes)],
394+ supportsMediaUpload: Object.keys(mediaUpload).length > 0,
395+ supportsMediaDownload: asBoolean(input.method.supportsMediaDownload) === true,
396+ ...(methodRequestSchemaJson({
397+ method: input.method,
398+ topLevelSchemas: input.topLevelSchemas,
399+ })
400+ ? {
401+ inputSchema: parseJson(
402+ methodRequestSchemaJson({
403+ method: input.method,
404+ topLevelSchemas: input.topLevelSchemas,
405+ }),
406+ ),
407+ }
408+ : {}),
409+ ...(methodResponseSchemaJson({
410+ method: input.method,
411+ topLevelSchemas: input.topLevelSchemas,
412+ })
413+ ? {
414+ outputSchema: parseJson(
415+ methodResponseSchemaJson({
416+ method: input.method,
417+ topLevelSchemas: input.topLevelSchemas,
418+ }),
419+ ),
420+ }
421+ : {}),
422+ };
423+};
424+
425+const collectDiscoveryMethods = (input: {
426+ service: string;
427+ version: string;
428+ rootUrl: string;
429+ servicePath: string;
430+ topLevelSchemas: Readonly<Record<string, DiscoverySchema>>;
431+ resource: DiscoveryResource | JsonObject | unknown;
432+}): ReadonlyArray<GoogleDiscoveryManifestMethod> => {
433+ const resource = asRecord(input.resource) as DiscoveryResource;
434+ const methods = Object.values(asRecord(resource.methods)).flatMap((method) => {
435+ try {
436+ const manifestMethod = manifestMethodFromDiscoveryMethod({
437+ service: input.service,
438+ version: input.version,
439+ rootUrl: input.rootUrl,
440+ servicePath: input.servicePath,
441+ topLevelSchemas: input.topLevelSchemas,
442+ method: asRecord(method) as DiscoveryMethod,
443+ });
444+ return manifestMethod ? [manifestMethod] : [];
445+ } catch {
446+ return [];
447+ }
448+ });
449+ const nested = Object.values(asRecord(resource.resources)).flatMap((nestedResource) =>
450+ collectDiscoveryMethods({
451+ ...input,
452+ resource: nestedResource,
453+ }));
454+
455+ return [...methods, ...nested];
456+};
457+
458+export const extractGoogleDiscoveryManifest = (
459+ sourceName: string,
460+ discoveryInput: string | JsonObject,
461+): Effect.Effect<GoogleDiscoveryToolManifest, Error, never> =>
462+ Effect.try({
463+ try: () => {
464+ const document = typeof discoveryInput === "string"
465+ ? JSON.parse(discoveryInput) as DiscoveryDocument
466+ : discoveryInput as DiscoveryDocument;
467+
468+ const service = asString(document.name);
469+ const versionName = asString(document.version);
470+ const rootUrl = asString(document.rootUrl);
471+ const servicePath = typeof document.servicePath === "string" ? document.servicePath : "";
472+
473+ if (!service || !versionName || !rootUrl) {
474+ throw new Error(`Invalid Google Discovery document for ${sourceName}`);
475+ }
476+
477+ const schemas = Object.fromEntries(
478+ Object.entries(asRecord(document.schemas)).map(([schemaId, rawSchema]) => [
479+ schemaId,
480+ asRecord(rawSchema) as DiscoverySchema,
481+ ]),
482+ );
483+ const schemaRefTable = Object.fromEntries(
484+ Object.entries(schemas).map(([schemaId, schema]) => [
485+ schemaRefKey(schemaId),
486+ JSON.stringify(
487+ googleSchemaToJsonSchema({
488+ schema,
489+ topLevelSchemas: schemas,
490+ }),
491+ ),
492+ ]),
493+ ) satisfies GoogleDiscoverySchemaRefTable;
494+ const methods = [
495+ ...Object.values(asRecord(document.methods)).flatMap((method) => {
496+ const manifestMethod = manifestMethodFromDiscoveryMethod({
497+ service,
498+ version: versionName,
499+ rootUrl,
500+ servicePath,
501+ topLevelSchemas: schemas,
502+ method: asRecord(method) as DiscoveryMethod,
503+ });
504+ return manifestMethod ? [manifestMethod] : [];
505+ }),
506+ ...Object.values(asRecord(document.resources)).flatMap((resource) =>
507+ collectDiscoveryMethods({
508+ service,
509+ version: versionName,
510+ rootUrl,
511+ servicePath,
512+ topLevelSchemas: schemas,
513+ resource,
514+ })),
515+ ].sort((left, right) => left.toolId.localeCompare(right.toolId));
516+
517+ const sourceHash = contentHash(
518+ typeof discoveryInput === "string"
519+ ? discoveryInput
520+ : JSON.stringify(discoveryInput),
521+ );
522+
523+ return {
524+ version: 1,
525+ sourceHash,
526+ service,
527+ versionName,
528+ title: asString(document.title),
529+ description: asString(document.description),
530+ rootUrl,
531+ servicePath,
532+ batchPath: asString(document.batchPath),
533+ documentationLink: asString(document.documentationLink),
534+ ...(Object.keys(schemaRefTable).length > 0 ? { schemaRefTable } : {}),
535+ ...(scopesRecordFromDocument(document)
536+ ? { oauthScopes: scopesRecordFromDocument(document)! }
537+ : {}),
538+ methods,
539+ };
540+ },
541+ catch: (cause) =>
542+ cause instanceof Error
543+ ? cause
544+ : new Error(`Failed to extract Google Discovery manifest: ${String(cause)}`),
545+ });
546+
547+export const compileGoogleDiscoveryToolDefinitions = (
548+ manifest: GoogleDiscoveryToolManifest,
549+): Array<GoogleDiscoveryManifestMethod> => [...manifest.methods];
plugins/google-discovery/sdk/index.ts+914−0
@@ -0,0 +1,914 @@
1+import * as Effect from "effect/Effect";
2+import * as Schema from "effect/Schema";
3+
4+import {
5+ buildOAuth2AuthorizationUrl,
6+ createPkceCodeVerifier,
7+ exchangeOAuth2AuthorizationCode,
8+ refreshOAuth2AccessToken,
9+} from "@executor/auth-oauth2";
10+import {
11+ createCatalogImportMetadata,
12+ createSourceCatalogSyncResult,
13+ SourceCredentialRequiredError,
14+} from "@executor/source-core";
15+import type { Source } from "@executor/platform-sdk/schema";
16+import type {
17+ ExecutorSdkPlugin,
18+ ExecutorSdkPluginHost,
19+ ExecutorSourceConnector,
20+ SourcePluginRuntime,
21+} from "@executor/platform-sdk/plugins";
22+import {
23+ SecretMaterialDeleterService,
24+ SecretMaterialResolverService,
25+ SecretMaterialStorerService,
26+ SecretMaterialUpdaterService,
27+ provideExecutorRuntime,
28+} from "@executor/platform-sdk/runtime";
29+import {
30+ GoogleDiscoveryConnectionAuthSchema,
31+ GoogleDiscoveryOAuthSessionSchema,
32+ defaultGoogleDiscoveryUrl,
33+ deriveGoogleDiscoveryNamespace,
34+ GoogleDiscoveryStoredSourceDataSchema,
35+ type GoogleDiscoveryConnectInput,
36+ type GoogleDiscoveryConnectionAuth,
37+ type GoogleDiscoveryOAuthPopupResult,
38+ type GoogleDiscoveryOAuthSession,
39+ type GoogleDiscoverySourceConfigPayload,
40+ type GoogleDiscoveryStartOAuthInput,
41+ type GoogleDiscoveryStartOAuthResult,
42+ type GoogleDiscoveryStoredSourceData,
43+ type GoogleDiscoveryUpdateSourceInput,
44+} from "@executor/plugin-google-discovery-shared";
45+
46+import {
47+ createGoogleDiscoveryCatalogFragment,
48+ type GoogleDiscoveryCatalogOperationInput,
49+} from "./catalog";
50+import {
51+ compileGoogleDiscoveryToolDefinitions,
52+ extractGoogleDiscoveryManifest,
53+} from "./document";
54+import {
55+ buildGoogleDiscoveryToolPresentation,
56+} from "./tools";
57+import {
58+ GoogleDiscoveryToolProviderDataSchema,
59+ type GoogleDiscoveryToolManifest,
60+ type GoogleDiscoveryToolProviderData,
61+} from "./types";
62+
63+const decodeStoredSourceData = Schema.decodeUnknownSync(
64+ GoogleDiscoveryStoredSourceDataSchema,
65+);
66+const decodeSession = Schema.decodeUnknownSync(GoogleDiscoveryOAuthSessionSchema);
67+const decodeProviderData = Schema.decodeUnknownSync(GoogleDiscoveryToolProviderDataSchema);
68+
69+export type GoogleDiscoverySourceStorage = {
70+ get: (input: {
71+ scopeId: string;
72+ sourceId: string;
73+ }) => Effect.Effect<GoogleDiscoveryStoredSourceData | null, Error, never>;
74+ put: (input: {
75+ scopeId: string;
76+ sourceId: string;
77+ value: GoogleDiscoveryStoredSourceData;
78+ }) => Effect.Effect<void, Error, never>;
79+ remove?: (input: {
80+ scopeId: string;
81+ sourceId: string;
82+ }) => Effect.Effect<void, Error, never>;
83+};
84+
85+export type GoogleDiscoveryOAuthSessionStorage = {
86+ get: (sessionId: string) => Effect.Effect<GoogleDiscoveryOAuthSession | null, Error, never>;
87+ put: (input: {
88+ sessionId: string;
89+ value: GoogleDiscoveryOAuthSession;
90+ }) => Effect.Effect<void, Error, never>;
91+ remove?: (sessionId: string) => Effect.Effect<void, Error, never>;
92+};
93+
94+export type GoogleDiscoverySdk = {
95+ getSourceConfig: (
96+ sourceId: Source["id"],
97+ ) => Effect.Effect<GoogleDiscoverySourceConfigPayload, Error>;
98+ createSource: (
99+ input: GoogleDiscoveryConnectInput,
100+ ) => Effect.Effect<Source, Error>;
101+ updateSource: (
102+ input: GoogleDiscoveryUpdateSourceInput,
103+ ) => Effect.Effect<Source, Error>;
104+ removeSource: (
105+ sourceId: Source["id"],
106+ ) => Effect.Effect<boolean, Error>;
107+ startOAuth: (
108+ input: GoogleDiscoveryStartOAuthInput,
109+ ) => Effect.Effect<GoogleDiscoveryStartOAuthResult, Error>;
110+ completeOAuth: (input: {
111+ state: string;
112+ code?: string;
113+ error?: string;
114+ errorDescription?: string;
115+ }) => Effect.Effect<Extract<GoogleDiscoveryOAuthPopupResult, { ok: true }>, Error>;
116+};
117+
118+const GoogleDiscoveryExecutorAddInputSchema = Schema.Struct({
119+ kind: Schema.Literal("google_discovery"),
120+ name: Schema.String,
121+ service: Schema.String,
122+ version: Schema.String,
123+ discoveryUrl: Schema.NullOr(Schema.String),
124+ defaultHeaders: Schema.NullOr(Schema.Record({ key: Schema.String, value: Schema.String })),
125+ scopes: Schema.Array(Schema.String),
126+ auth: GoogleDiscoveryConnectionAuthSchema,
127+});
128+
129+type GoogleDiscoveryExecutorAddInput =
130+ typeof GoogleDiscoveryExecutorAddInputSchema.Type;
131+
132+const GOOGLE_AUTHORIZATION_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth";
133+const GOOGLE_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
134+const OAUTH_REFRESH_SKEW_MS = 60_000;
135+
136+const responseHeadersRecord = (response: Response): Record<string, string> => {
137+ const headers: Record<string, string> = {};
138+ response.headers.forEach((value, key) => {
139+ headers[key] = value;
140+ });
141+ return headers;
142+};
143+
144+const decodeResponseBody = async (response: Response): Promise<unknown> => {
145+ const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
146+ const text = await response.text();
147+ if (text.trim().length === 0) {
148+ return null;
149+ }
150+ if (contentType.includes("application/json") || contentType.includes("+json")) {
151+ try {
152+ return JSON.parse(text) as unknown;
153+ } catch {
154+ return text;
155+ }
156+ }
157+ return text;
158+};
159+
160+const asRecord = (value: unknown): Record<string, unknown> =>
161+ typeof value === "object" && value !== null && !Array.isArray(value)
162+ ? (value as Record<string, unknown>)
163+ : {};
164+
165+const stringValuesFromParameter = (
166+ value: unknown,
167+ repeated: boolean,
168+): string[] => {
169+ if (value === undefined || value === null) {
170+ return [];
171+ }
172+ if (Array.isArray(value)) {
173+ const normalized = value.flatMap((entry) =>
174+ entry === undefined || entry === null ? [] : [String(entry)],
175+ );
176+ return repeated ? normalized : [normalized.join(",")];
177+ }
178+ if (
179+ typeof value === "string" ||
180+ typeof value === "number" ||
181+ typeof value === "boolean"
182+ ) {
183+ return [String(value)];
184+ }
185+ return [JSON.stringify(value)];
186+};
187+
188+const replacePathParameters = (input: {
189+ pathTemplate: string;
190+ args: Record<string, unknown>;
191+ parameters: ReadonlyArray<
192+ GoogleDiscoveryToolProviderData["invocation"]["parameters"][number]
193+ >;
194+}): string =>
195+ input.pathTemplate.replaceAll(/\{([^}]+)\}/g, (_, name: string) => {
196+ const parameter = input.parameters.find(
197+ (entry) => entry.location === "path" && entry.name === name,
198+ );
199+ const rawValue = input.args[name];
200+ if ((rawValue === undefined || rawValue === null) && parameter?.required) {
201+ throw new Error(`Missing required path parameter: ${name}`);
202+ }
203+
204+ const values = stringValuesFromParameter(rawValue, false);
205+ return values.length > 0 ? encodeURIComponent(values[0]!) : "";
206+ });
207+
208+const resolveGoogleDiscoveryBaseUrl = (input: {
209+ providerData: GoogleDiscoveryToolProviderData;
210+}): string =>
211+ new URL(
212+ input.providerData.invocation.servicePath || "",
213+ input.providerData.invocation.rootUrl,
214+ ).toString();
215+
216+const googleDiscoveryCatalogOperationFromDefinition = (input: {
217+ manifest: Parameters<typeof compileGoogleDiscoveryToolDefinitions>[0];
218+ definition: ReturnType<typeof compileGoogleDiscoveryToolDefinitions>[number];
219+}): GoogleDiscoveryCatalogOperationInput => {
220+ const presentation = buildGoogleDiscoveryToolPresentation({
221+ manifest: input.manifest,
222+ definition: input.definition,
223+ });
224+
225+ return {
226+ toolId: input.definition.toolId,
227+ title: input.definition.name,
228+ description: input.definition.description,
229+ effect:
230+ input.definition.method === "get" || input.definition.method === "head"
231+ ? "read"
232+ : input.definition.method === "delete"
233+ ? "delete"
234+ : "write",
235+ inputSchema: presentation.inputSchema,
236+ outputSchema: presentation.outputSchema,
237+ providerData: presentation.providerData as GoogleDiscoveryToolProviderData,
238+ };
239+};
240+
241+const computeMaximalScopes = (
242+ manifest: GoogleDiscoveryToolManifest,
243+): ReadonlyArray<string> => {
244+ const topLevelScopes = Object.keys(manifest.oauthScopes ?? {});
245+ if (topLevelScopes.length === 0) {
246+ return [];
247+ }
248+
249+ const scopeToMethods = new Map<string, Set<string>>();
250+ for (const scope of topLevelScopes) {
251+ scopeToMethods.set(scope, new Set());
252+ }
253+ for (const method of manifest.methods) {
254+ for (const scope of method.scopes) {
255+ scopeToMethods.get(scope)?.add(method.methodId);
256+ }
257+ }
258+
259+ return topLevelScopes.filter((scope) => {
260+ const methods = scopeToMethods.get(scope);
261+ if (!methods || methods.size === 0) {
262+ return true;
263+ }
264+
265+ return !topLevelScopes.some((other) => {
266+ if (other === scope) {
267+ return false;
268+ }
269+
270+ const otherMethods = scopeToMethods.get(other);
271+ if (!otherMethods || otherMethods.size <= methods.size) {
272+ return false;
273+ }
274+
275+ for (const method of methods) {
276+ if (!otherMethods.has(method)) {
277+ return false;
278+ }
279+ }
280+
281+ return true;
282+ });
283+ });
284+};
285+
286+const fetchGoogleDiscoveryDocumentWithHeaders = (input: {
287+ url: string;
288+ headers?: Readonly<Record<string, string>>;
289+}): Effect.Effect<string, Error> =>
290+ Effect.tryPromise({
291+ try: async () => {
292+ const response = await fetch(input.url, {
293+ headers: input.headers,
294+ signal: AbortSignal.timeout(15_000),
295+ });
296+ if (response.status === 401 || response.status === 403) {
297+ throw new SourceCredentialRequiredError(
298+ "import",
299+ `Google Discovery fetch requires credentials (status ${response.status})`,
300+ );
301+ }
302+ if (!response.ok) {
303+ throw new Error(`Google Discovery fetch failed with status ${response.status}`);
304+ }
305+ return response.text();
306+ },
307+ catch: (cause) =>
308+ cause instanceof Error ? cause : new Error(String(cause)),
309+ });
310+
311+const inferScopesFromDiscoveryDocument = (input: {
312+ service: string;
313+ document: string;
314+}): Effect.Effect<ReadonlyArray<string>, Error> =>
315+ Effect.gen(function* () {
316+ const manifest = yield* extractGoogleDiscoveryManifest(input.service, input.document);
317+ return computeMaximalScopes(manifest);
318+ });
319+
320+const resolveOauthAccessToken = (input: {
321+ scopeId: string;
322+ sourceId: string;
323+ auth: Extract<GoogleDiscoveryConnectionAuth, { kind: "oauth2" }>;
324+ storage: GoogleDiscoverySourceStorage;
325+}): Effect.Effect<string, Error, any> =>
326+ Effect.gen(function* () {
327+ const now = Date.now();
328+ const needsRefresh =
329+ input.auth.refreshTokenRef !== null &&
330+ input.auth.expiresAt !== null &&
331+ input.auth.expiresAt <= now + OAUTH_REFRESH_SKEW_MS;
332+ if (!needsRefresh) {
333+ const resolveSecretMaterial = yield* SecretMaterialResolverService;
334+ return yield* resolveSecretMaterial({
335+ ref: input.auth.accessTokenRef,
336+ });
337+ }
338+
339+ const resolveSecretMaterial = yield* SecretMaterialResolverService;
340+ const updateSecretMaterial = yield* SecretMaterialUpdaterService;
341+ const storeSecretMaterial = yield* SecretMaterialStorerService;
342+ const refreshToken = yield* resolveSecretMaterial({
343+ ref: input.auth.refreshTokenRef!,
344+ });
345+ const clientSecret = input.auth.clientSecretRef
346+ ? yield* resolveSecretMaterial({
347+ ref: input.auth.clientSecretRef,
348+ })
349+ : null;
350+ const tokenResponse = yield* refreshOAuth2AccessToken({
351+ tokenEndpoint: input.auth.tokenEndpoint,
352+ clientId: input.auth.clientId,
353+ clientAuthentication: input.auth.clientAuthentication,
354+ clientSecret,
355+ refreshToken,
356+ scopes: input.auth.scopes,
357+ });
358+
359+ yield* updateSecretMaterial({
360+ ref: input.auth.accessTokenRef,
361+ value: tokenResponse.access_token,
362+ });
363+
364+ let refreshTokenRef = input.auth.refreshTokenRef;
365+ if (tokenResponse.refresh_token) {
366+ if (refreshTokenRef) {
367+ yield* updateSecretMaterial({
368+ ref: refreshTokenRef,
369+ value: tokenResponse.refresh_token,
370+ });
371+ } else {
372+ refreshTokenRef = yield* storeSecretMaterial({
373+ purpose: "oauth_refresh_token",
374+ value: tokenResponse.refresh_token,
375+ name: `${input.sourceId} Google Refresh Token`,
376+ });
377+ }
378+ }
379+
380+ const stored = yield* input.storage.get({
381+ scopeId: input.scopeId,
382+ sourceId: input.sourceId,
383+ });
384+ if (stored) {
385+ yield* input.storage.put({
386+ scopeId: input.scopeId,
387+ sourceId: input.sourceId,
388+ value: decodeStoredSourceData({
389+ ...stored,
390+ auth: {
391+ ...stored.auth,
392+ ...(stored.auth.kind === "oauth2"
393+ ? {
394+ refreshTokenRef,
395+ expiresAt:
396+ typeof tokenResponse.expires_in === "number"
397+ ? Date.now() + tokenResponse.expires_in * 1000
398+ : stored.auth.expiresAt,
399+ }
400+ : {}),
401+ },
402+ }),
403+ });
404+ }
405+
406+ return tokenResponse.access_token;
407+ });
408+
409+const resolveGoogleAuthHeaders = (input: {
410+ scopeId: string;
411+ sourceId: string;
412+ stored: GoogleDiscoveryStoredSourceData;
413+ storage: GoogleDiscoverySourceStorage;
414+}): Effect.Effect<Record<string, string>, Error, any> =>
415+ Effect.gen(function* () {
416+ if (input.stored.auth.kind === "none") {
417+ return {
418+ ...(input.stored.defaultHeaders ?? {}),
419+ };
420+ }
421+
422+ if (input.stored.auth.kind === "bearer") {
423+ const resolveSecretMaterial = yield* SecretMaterialResolverService;
424+ const token = yield* resolveSecretMaterial({
425+ ref: input.stored.auth.tokenSecretRef,
426+ });
427+ return {
428+ ...(input.stored.defaultHeaders ?? {}),
429+ authorization: `Bearer ${token.trim()}`,
430+ };
431+ }
432+
433+ const token = yield* resolveOauthAccessToken({
434+ scopeId: input.scopeId,
435+ sourceId: input.sourceId,
436+ auth: input.stored.auth,
437+ storage: input.storage,
438+ });
439+
440+ return {
441+ ...(input.stored.defaultHeaders ?? {}),
442+ authorization: `Bearer ${token.trim()}`,
443+ };
444+ });
445+
446+const storedSourceDataFromInput = (
447+ input: GoogleDiscoveryConnectInput,
448+): GoogleDiscoveryStoredSourceData =>
449+ decodeStoredSourceData({
450+ service: input.service.trim(),
451+ version: input.version.trim(),
452+ discoveryUrl: input.discoveryUrl?.trim() || defaultGoogleDiscoveryUrl(input.service, input.version),
453+ defaultHeaders: input.defaultHeaders,
454+ scopes: [...input.scopes],
455+ auth: input.auth,
456+ });
457+
458+const sourceConfigFromStored = (
459+ source: Source,
460+ stored: GoogleDiscoveryStoredSourceData,
461+): GoogleDiscoverySourceConfigPayload => ({
462+ name: source.name,
463+ service: stored.service,
464+ version: stored.version,
465+ discoveryUrl: stored.discoveryUrl,
466+ defaultHeaders: stored.defaultHeaders,
467+ scopes: [...stored.scopes],
468+ auth: stored.auth,
469+});
470+
471+const createGoogleDiscoverySourceSdk = (
472+ options: {
473+ storage: GoogleDiscoverySourceStorage;
474+ oauthSessions: GoogleDiscoveryOAuthSessionStorage;
475+ },
476+ host: ExecutorSdkPluginHost,
477+) => ({
478+ getSourceConfig: (sourceId: Source["id"]) =>
479+ Effect.gen(function* () {
480+ const source = yield* host.sources.get(sourceId);
481+ if (source.kind !== "google_discovery") {
482+ return yield* Effect.fail(
483+ new Error(`Source ${sourceId} is not a Google Discovery source.`),
484+ );
485+ }
486+
487+ const stored = yield* options.storage.get({
488+ scopeId: source.scopeId,
489+ sourceId: source.id,
490+ });
491+ if (stored === null) {
492+ return yield* Effect.fail(
493+ new Error(`Google Discovery source storage missing for ${source.id}`),
494+ );
495+ }
496+
497+ return sourceConfigFromStored(source, stored);
498+ }),
499+ createSource: (input: GoogleDiscoveryConnectInput) =>
500+ Effect.gen(function* () {
501+ const stored = storedSourceDataFromInput(input);
502+ const createdSource = yield* host.sources.create({
503+ source: {
504+ name: input.name.trim(),
505+ kind: "google_discovery",
506+ status: "connected",
507+ enabled: true,
508+ namespace: deriveGoogleDiscoveryNamespace(input.service),
509+ },
510+ });
511+
512+ yield* options.storage.put({
513+ scopeId: createdSource.scopeId,
514+ sourceId: createdSource.id,
515+ value: stored,
516+ });
517+
518+ return yield* host.sources.refreshCatalog(createdSource.id);
519+ }),
520+ updateSource: (input: GoogleDiscoveryUpdateSourceInput) =>
521+ Effect.gen(function* () {
522+ const source = yield* host.sources.get(input.sourceId as Source["id"]);
523+ if (source.kind !== "google_discovery") {
524+ return yield* Effect.fail(
525+ new Error(`Source ${input.sourceId} is not a Google Discovery source.`),
526+ );
527+ }
528+
529+ const stored = storedSourceDataFromInput(input.config);
530+ const savedSource = yield* host.sources.save({
531+ ...source,
532+ name: input.config.name.trim(),
533+ namespace: deriveGoogleDiscoveryNamespace(input.config.service),
534+ });
535+
536+ yield* options.storage.put({
537+ scopeId: savedSource.scopeId,
538+ sourceId: savedSource.id,
539+ value: stored,
540+ });
541+
542+ return yield* host.sources.refreshCatalog(savedSource.id);
543+ }),
544+ removeSource: (sourceId: Source["id"]) =>
545+ Effect.gen(function* () {
546+ const source = yield* host.sources.get(sourceId);
547+ if (source.kind !== "google_discovery") {
548+ return yield* Effect.fail(
549+ new Error(`Source ${sourceId} is not a Google Discovery source.`),
550+ );
551+ }
552+
553+ const stored = yield* options.storage.get({
554+ scopeId: source.scopeId,
555+ sourceId: source.id,
556+ });
557+ const deleteSecretMaterial = yield* SecretMaterialDeleterService;
558+ if (stored?.auth.kind === "oauth2") {
559+ yield* Effect.either(deleteSecretMaterial(stored.auth.accessTokenRef));
560+ if (stored.auth.refreshTokenRef) {
561+ yield* Effect.either(deleteSecretMaterial(stored.auth.refreshTokenRef));
562+ }
563+ }
564+
565+ if (options.storage.remove) {
566+ yield* options.storage.remove({
567+ scopeId: source.scopeId,
568+ sourceId: source.id,
569+ });
570+ }
571+
572+ return yield* host.sources.remove(source.id);
573+ }),
574+});
575+
576+const googleDiscoverySourceConnector = (
577+ options: {
578+ storage: GoogleDiscoverySourceStorage;
579+ oauthSessions: GoogleDiscoveryOAuthSessionStorage;
580+ },
581+): ExecutorSourceConnector<GoogleDiscoveryExecutorAddInput> => ({
582+ kind: "google_discovery",
583+ displayName: "Google Discovery",
584+ inputSchema: GoogleDiscoveryExecutorAddInputSchema,
585+ inputSignatureWidth: 320,
586+ helpText: [
587+ "Provide the Google API service and version plus the final auth configuration.",
588+ "OAuth browser flows stay on the plugin-owned HTTP/UI surfaces.",
589+ ],
590+ createSource: ({ args, host }) =>
591+ createGoogleDiscoverySourceSdk(options, host).createSource(args),
592+});
593+
594+const createGoogleDiscoverySourceRuntime = (options: {
595+ storage: GoogleDiscoverySourceStorage;
596+}): SourcePluginRuntime => ({
597+ kind: "google_discovery",
598+ displayName: "Google Discovery",
599+ catalogKind: "imported",
600+ catalogIdentity: ({ source }) => ({
601+ kind: "google_discovery",
602+ sourceId: source.id,
603+ }),
604+ getIrModel: ({ source }) =>
605+ Effect.gen(function* () {
606+ const stored = yield* options.storage.get({
607+ scopeId: source.scopeId,
608+ sourceId: source.id,
609+ });
610+ if (stored === null) {
611+ return createSourceCatalogSyncResult({
612+ fragment: {
613+ version: "ir.v1.fragment",
614+ },
615+ importMetadata: {
616+ ...createCatalogImportMetadata({
617+ source,
618+ pluginKey: "google_discovery",
619+ }),
620+ importerVersion: "ir.v1.google_discovery",
621+ sourceConfigHash: "missing",
622+ },
623+ sourceHash: null,
624+ });
625+ }
626+
627+ const headers = yield* resolveGoogleAuthHeaders({
628+ scopeId: source.scopeId,
629+ sourceId: source.id,
630+ stored,
631+ storage: options.storage,
632+ });
633+ const document = yield* fetchGoogleDiscoveryDocumentWithHeaders({
634+ url: stored.discoveryUrl,
635+ headers,
636+ });
637+ const manifest = yield* extractGoogleDiscoveryManifest(source.name, document);
638+ const definitions = compileGoogleDiscoveryToolDefinitions(manifest);
639+ const operations = definitions.map((definition) =>
640+ googleDiscoveryCatalogOperationFromDefinition({
641+ manifest,
642+ definition,
643+ })
644+ );
645+ const now = Date.now();
646+
647+ return createSourceCatalogSyncResult({
648+ fragment: createGoogleDiscoveryCatalogFragment({
649+ source,
650+ documents: [
651+ {
652+ documentKind: "google_discovery",
653+ documentKey: stored.discoveryUrl,
654+ contentText: document,
655+ fetchedAt: now,
656+ },
657+ ],
658+ operations,
659+ }),
660+ importMetadata: {
661+ ...createCatalogImportMetadata({
662+ source,
663+ pluginKey: "google_discovery",
664+ }),
665+ importerVersion: "ir.v1.google_discovery",
666+ },
667+ sourceHash: manifest.sourceHash,
668+ });
669+ }),
670+ invoke: (input) =>
671+ Effect.gen(function* () {
672+ const stored = yield* options.storage.get({
673+ scopeId: input.source.scopeId,
674+ sourceId: input.source.id,
675+ });
676+ if (stored === null) {
677+ return yield* Effect.fail(
678+ new Error(`Google Discovery source storage missing for ${input.source.id}`),
679+ );
680+ }
681+
682+ const providerData = decodeProviderData(
683+ input.executable.binding,
684+ ) as GoogleDiscoveryToolProviderData;
685+ const args = asRecord(input.args);
686+ const headers: Record<string, string> = yield* resolveGoogleAuthHeaders({
687+ scopeId: input.source.scopeId,
688+ sourceId: input.source.id,
689+ stored,
690+ storage: options.storage,
691+ });
692+ const requestUrl = new URL(
693+ replacePathParameters({
694+ pathTemplate: providerData.invocation.path,
695+ args,
696+ parameters: providerData.invocation.parameters,
697+ }),
698+ resolveGoogleDiscoveryBaseUrl({
699+ providerData,
700+ }),
701+ );
702+
703+ let body: string | undefined;
704+ for (const parameter of providerData.invocation.parameters) {
705+ const rawValue = args[parameter.name];
706+ if (rawValue === undefined || rawValue === null) {
707+ continue;
708+ }
709+
710+ if (parameter.location === "query") {
711+ for (const entry of stringValuesFromParameter(rawValue, parameter.repeated)) {
712+ requestUrl.searchParams.append(parameter.name, entry);
713+ }
714+ continue;
715+ }
716+
717+ if (parameter.location === "header") {
718+ headers[parameter.name] = stringValuesFromParameter(rawValue, false)[0] ?? "";
719+ }
720+ }
721+
722+ if (providerData.invocation.requestSchemaId) {
723+ const payload = args.body ?? args.input;
724+ if (payload !== undefined) {
725+ headers["content-type"] = "application/json";
726+ body = JSON.stringify(payload);
727+ }
728+ }
729+
730+ const response = yield* Effect.tryPromise({
731+ try: () =>
732+ fetch(requestUrl.toString(), {
733+ method: providerData.invocation.method.toUpperCase(),
734+ headers,
735+ ...(body ? { body } : {}),
736+ }),
737+ catch: (cause) =>
738+ cause instanceof Error ? cause : new Error(String(cause)),
739+ });
740+ const responseBody = yield* Effect.tryPromise({
741+ try: () => decodeResponseBody(response),
742+ catch: (cause) =>
743+ cause instanceof Error ? cause : new Error(String(cause)),
744+ });
745+
746+ return {
747+ data: response.ok ? responseBody : null,
748+ error: response.ok ? null : responseBody,
749+ headers: responseHeadersRecord(response),
750+ status: response.status,
751+ };
752+ }),
753+});
754+
755+export const googleDiscoverySdkPlugin = (options: {
756+ storage: GoogleDiscoverySourceStorage;
757+ oauthSessions: GoogleDiscoveryOAuthSessionStorage;
758+}): ExecutorSdkPlugin<"googleDiscovery", GoogleDiscoverySdk> => ({
759+ key: "googleDiscovery",
760+ sources: [createGoogleDiscoverySourceRuntime(options)],
761+ sourceConnectors: [googleDiscoverySourceConnector(options)],
762+ extendExecutor: ({ host, executor }) => {
763+ const sourceSdk = createGoogleDiscoverySourceSdk(options, host);
764+ const provideRuntime = <A>(
765+ effect: Effect.Effect<A, Error, any>,
766+ ): Effect.Effect<A, Error, never> =>
767+ provideExecutorRuntime(effect, executor.runtime);
768+
769+ return {
770+ getSourceConfig: (sourceId) =>
771+ provideRuntime(sourceSdk.getSourceConfig(sourceId)),
772+ createSource: (input) =>
773+ provideRuntime(sourceSdk.createSource(input)),
774+ updateSource: (input) =>
775+ provideRuntime(sourceSdk.updateSource(input)),
776+ removeSource: (sourceId) =>
777+ provideRuntime(sourceSdk.removeSource(sourceId)),
778+ startOAuth: (input) =>
779+ provideRuntime(
780+ Effect.gen(function* () {
781+ const discoveryUrl =
782+ input.discoveryUrl?.trim()
783+ || defaultGoogleDiscoveryUrl(input.service, input.version);
784+ const scopes =
785+ input.scopes.length > 0
786+ ? [...new Set(input.scopes.map((scope) => scope.trim()).filter(Boolean))]
787+ : yield* fetchGoogleDiscoveryDocumentWithHeaders({
788+ url: discoveryUrl,
789+ headers: input.defaultHeaders ?? {},
790+ }).pipe(
791+ Effect.flatMap((document) =>
792+ inferScopesFromDiscoveryDocument({
793+ service: input.service,
794+ document,
795+ })
796+ ),
797+ );
798+ const sessionId = `gdisc_oauth_${crypto.randomUUID()}`;
799+ const codeVerifier = createPkceCodeVerifier();
800+
801+ yield* options.oauthSessions.put({
802+ sessionId,
803+ value: decodeSession({
804+ service: input.service.trim(),
805+ version: input.version.trim(),
806+ discoveryUrl,
807+ defaultHeaders: input.defaultHeaders,
808+ scopes: [...scopes],
809+ clientId: input.clientId.trim(),
810+ clientSecretRef: input.clientSecretRef,
811+ clientAuthentication: input.clientAuthentication,
812+ redirectUrl: input.redirectUrl,
813+ codeVerifier,
814+ }),
815+ });
816+
817+ return {
818+ sessionId,
819+ authorizationUrl: buildOAuth2AuthorizationUrl({
820+ authorizationEndpoint: GOOGLE_AUTHORIZATION_ENDPOINT,
821+ clientId: input.clientId.trim(),
822+ redirectUri: input.redirectUrl,
823+ scopes,
824+ state: sessionId,
825+ codeVerifier,
826+ extraParams: {
827+ access_type: "offline",
828+ prompt: "consent",
829+ include_granted_scopes: "true",
830+ },
831+ }),
832+ scopes: [...scopes],
833+ };
834+ }),
835+ ),
836+ completeOAuth: (input) =>
837+ provideRuntime(
838+ Effect.gen(function* () {
839+ if (input.error) {
840+ return yield* Effect.fail(
841+ new Error(
842+ input.errorDescription || input.error || "Google OAuth failed",
843+ ),
844+ );
845+ }
846+ if (!input.code) {
847+ return yield* Effect.fail(new Error("Missing Google OAuth code."));
848+ }
849+
850+ const session = yield* options.oauthSessions.get(input.state);
851+ if (session === null) {
852+ return yield* Effect.fail(
853+ new Error(`Google OAuth session not found: ${input.state}`),
854+ );
855+ }
856+
857+ const resolveSecretMaterial = yield* SecretMaterialResolverService;
858+ const storeSecretMaterial = yield* SecretMaterialStorerService;
859+ const clientSecret = session.clientSecretRef
860+ ? yield* resolveSecretMaterial({
861+ ref: session.clientSecretRef,
862+ })
863+ : null;
864+ const tokenResponse = yield* exchangeOAuth2AuthorizationCode({
865+ tokenEndpoint: GOOGLE_TOKEN_ENDPOINT,
866+ clientId: session.clientId,
867+ clientAuthentication: session.clientAuthentication,
868+ clientSecret,
869+ redirectUri: session.redirectUrl,
870+ codeVerifier: session.codeVerifier,
871+ code: input.code,
872+ });
873+ const accessTokenRef = yield* storeSecretMaterial({
874+ purpose: "oauth_access_token",
875+ value: tokenResponse.access_token,
876+ name: `${session.service} Google Access Token`,
877+ });
878+ const refreshTokenRef = tokenResponse.refresh_token
879+ ? yield* storeSecretMaterial({
880+ purpose: "oauth_refresh_token",
881+ value: tokenResponse.refresh_token,
882+ name: `${session.service} Google Refresh Token`,
883+ })
884+ : null;
885+
886+ if (options.oauthSessions.remove) {
887+ yield* options.oauthSessions.remove(input.state);
888+ }
889+
890+ return {
891+ type: "executor:oauth-result",
892+ ok: true as const,
893+ sessionId: input.state,
894+ auth: {
895+ kind: "oauth2" as const,
896+ clientId: session.clientId,
897+ clientSecretRef: session.clientSecretRef,
898+ clientAuthentication: session.clientAuthentication,
899+ authorizationEndpoint: GOOGLE_AUTHORIZATION_ENDPOINT,
900+ tokenEndpoint: GOOGLE_TOKEN_ENDPOINT,
901+ scopes: [...session.scopes],
902+ accessTokenRef,
903+ refreshTokenRef,
904+ expiresAt:
905+ typeof tokenResponse.expires_in === "number"
906+ ? Date.now() + tokenResponse.expires_in * 1000
907+ : null,
908+ },
909+ };
910+ }),
911+ ),
912+ };
913+ },
914+});
plugins/google-discovery/sdk/package.json+29−0
@@ -0,0 +1,29 @@
1+{
2+ "name": "@executor/plugin-google-discovery-sdk",
3+ "private": true,
4+ "type": "module",
5+ "exports": {
6+ ".": "./index.ts"
7+ },
8+ "files": [
9+ "*.ts"
10+ ],
11+ "scripts": {
12+ "typecheck": "bunx tsc --noEmit -p tsconfig.json"
13+ },
14+ "dependencies": {
15+ "@effect/platform": "catalog:",
16+ "@executor/auth-oauth2": "workspace:*",
17+ "@executor/codemode-core": "workspace:*",
18+ "@executor/ir": "workspace:*",
19+ "@executor/platform-sdk": "workspace:*",
20+ "@executor/plugin-google-discovery-shared": "workspace:*",
21+ "@executor/source-core": "workspace:*",
22+ "effect": "catalog:"
23+ },
24+ "devDependencies": {
25+ "@types/node": "catalog:",
26+ "bun-types": "catalog:"
27+ },
28+ "version": "0.0.0"
29+}
plugins/google-discovery/sdk/tools.ts+531−0
@@ -0,0 +1,531 @@
1+import {
2+ FetchHttpClient,
3+ HttpClient,
4+ type HttpClientResponse,
5+ HttpClientRequest,
6+} from "@effect/platform";
7+import {
8+ applyCookiePlacementsToHeaders,
9+ applyHttpQueryPlacementsToUrl,
10+ applyJsonBodyPlacements,
11+ standardSchemaFromJsonSchema,
12+ typeSignatureFromSchema,
13+ toTool,
14+ type HttpRequestPlacements,
15+ type ToolMetadata,
16+} from "@executor/codemode-core";
17+import { sourceCoreEffectError } from "@executor/source-core";
18+import * as Effect from "effect/Effect";
19+import * as Layer from "effect/Layer";
20+import * as Schema from "effect/Schema";
21+
22+import type {
23+ GoogleDiscoveryManifestMethod,
24+ GoogleDiscoverySchemaRefTable,
25+ GoogleDiscoveryToolDefinition,
26+} from "./types";
27+import {
28+ GoogleDiscoveryToolProviderDataSchema,
29+} from "./types";
30+import * as Either from "effect/Either";
31+
32+const decodeGoogleDiscoveryToolProviderDataJson = Schema.decodeUnknownEither(
33+ GoogleDiscoveryToolProviderDataSchema,
34+);
35+
36+export const googleDiscoveryProviderDataFromDefinition = (
37+ input: {
38+ service: string;
39+ version: string;
40+ rootUrl: string;
41+ servicePath: string;
42+ oauthScopes?: Readonly<Record<string, string>>;
43+ definition: GoogleDiscoveryManifestMethod;
44+ },
45+): typeof GoogleDiscoveryToolProviderDataSchema.Type => ({
46+ kind: "google_discovery",
47+ service: input.service,
48+ version: input.version,
49+ toolId: input.definition.toolId,
50+ rawToolId: input.definition.rawToolId,
51+ methodId: input.definition.methodId,
52+ group: input.definition.group,
53+ leaf: input.definition.leaf,
54+ invocation: {
55+ method: input.definition.method,
56+ path: input.definition.path,
57+ flatPath: input.definition.flatPath,
58+ rootUrl: input.rootUrl,
59+ servicePath: input.servicePath,
60+ parameters: input.definition.parameters,
61+ requestSchemaId: input.definition.requestSchemaId,
62+ responseSchemaId: input.definition.responseSchemaId,
63+ scopes: input.definition.scopes,
64+ ...(input.oauthScopes
65+ ? {
66+ scopeDescriptions: Object.fromEntries(
67+ input.definition.scopes.flatMap((scope) =>
68+ input.oauthScopes?.[scope] !== undefined
69+ ? [[scope, input.oauthScopes[scope]!]]
70+ : [],
71+ ),
72+ ),
73+ }
74+ : {}),
75+ supportsMediaUpload: input.definition.supportsMediaUpload,
76+ supportsMediaDownload: input.definition.supportsMediaDownload,
77+ },
78+ });
79+
80+const decodeGoogleDiscoverySchemaRefTableJson = Schema.decodeUnknownEither(
81+ Schema.parseJson(
82+ Schema.Record({
83+ key: Schema.String,
84+ value: Schema.Unknown,
85+ }),
86+ ),
87+);
88+
89+const asRecord = (value: unknown): Record<string, unknown> =>
90+ typeof value === "object" && value !== null && !Array.isArray(value)
91+ ? value as Record<string, unknown>
92+ : {};
93+
94+const normalizeCredentialPlacements = (input: {
95+ credentialHeaders?: Readonly<Record<string, string>>;
96+ credentialPlacements?: HttpRequestPlacements;
97+}): HttpRequestPlacements => ({
98+ headers: {
99+ ...input.credentialHeaders,
100+ ...input.credentialPlacements?.headers,
101+ },
102+ queryParams: input.credentialPlacements?.queryParams,
103+ cookies: input.credentialPlacements?.cookies,
104+ bodyValues: input.credentialPlacements?.bodyValues,
105+});
106+
107+const setNestedSchemaProperty = (
108+ target: Record<string, unknown>,
109+ path: readonly string[],
110+ value: unknown,
111+): void => {
112+ if (path.length === 0) {
113+ return;
114+ }
115+
116+ const [head, ...rest] = path;
117+ if (!head) {
118+ return;
119+ }
120+
121+ if (rest.length === 0) {
122+ target[head] = value;
123+ return;
124+ }
125+
126+ const next = asRecord(target[head]);
127+ target[head] = next;
128+ setNestedSchemaProperty(next, rest, value);
129+};
130+
131+const materializeSchemaWithRefDefinitions = (input: {
132+ schema: unknown;
133+ refTable?: Readonly<Record<string, unknown>>;
134+}): Record<string, unknown> => {
135+ if (input.schema === undefined || input.schema === null) {
136+ return {};
137+ }
138+
139+ const rootSchema = asRecord(input.schema);
140+
141+ if (!input.refTable || Object.keys(input.refTable).length === 0) {
142+ return rootSchema;
143+ }
144+
145+ const defsRoot = asRecord(rootSchema.$defs);
146+ for (const [ref, value] of Object.entries(input.refTable)) {
147+ if (!ref.startsWith("#/$defs/")) {
148+ continue;
149+ }
150+
151+ const materializedValue =
152+ typeof value === "string"
153+ ? (() => {
154+ try {
155+ return JSON.parse(value) as unknown;
156+ } catch {
157+ return value;
158+ }
159+ })()
160+ : value;
161+
162+ const path = ref
163+ .slice("#/$defs/".length)
164+ .split("/")
165+ .filter((segment) => segment.length > 0);
166+
167+ setNestedSchemaProperty(defsRoot, path, materializedValue);
168+ }
169+
170+ return Object.keys(defsRoot).length > 0
171+ ? { ...rootSchema, $defs: defsRoot }
172+ : rootSchema;
173+};
174+
175+const decodeResponseBody = (response: HttpClientResponse.HttpClientResponse) =>
176+ Effect.gen(function* () {
177+ const contentType = response.headers["content-type"]?.toLowerCase() ?? "";
178+ const text = yield* response.text.pipe(
179+ Effect.mapError((cause) =>
180+ cause instanceof Error ? cause : new Error(String(cause)),
181+ ),
182+ );
183+
184+ if (text.trim().length === 0) {
185+ return null;
186+ }
187+
188+ if (contentType.includes("application/json") || contentType.includes("+json")) {
189+ try {
190+ return JSON.parse(text) as unknown;
191+ } catch {
192+ return text;
193+ }
194+ }
195+
196+ return text;
197+ });
198+
199+const stringValuesFromParameter = (
200+ value: unknown,
201+ repeated: boolean,
202+): string[] => {
203+ if (value === undefined || value === null) {
204+ return [];
205+ }
206+
207+ if (Array.isArray(value)) {
208+ const normalized = value
209+ .flatMap((entry) =>
210+ entry === undefined || entry === null ? [] : [String(entry)],
211+ );
212+ return repeated ? normalized : [normalized.join(",")];
213+ }
214+
215+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
216+ return [String(value)];
217+ }
218+
219+ return [JSON.stringify(value)];
220+};
221+
222+const replacePathParameters = (input: {
223+ pathTemplate: string;
224+ args: Record<string, unknown>;
225+ parameters: ReadonlyArray<GoogleDiscoveryManifestMethod["parameters"][number]>;
226+}): string =>
227+ input.pathTemplate.replaceAll(/\{([^}]+)\}/g, (_, name: string) => {
228+ const parameter = input.parameters.find(
229+ (entry) => entry.location === "path" && entry.name === name,
230+ );
231+ const rawValue = input.args[name];
232+ if ((rawValue === undefined || rawValue === null) && parameter?.required) {
233+ throw new Error(`Missing required path parameter: ${name}`);
234+ }
235+
236+ const values = stringValuesFromParameter(rawValue, false);
237+ if (values.length === 0) {
238+ return "";
239+ }
240+
241+ return encodeURIComponent(values[0]!);
242+ });
243+
244+const resolveBaseUrl = (input: {
245+ rootUrl: string;
246+ servicePath: string;
247+ baseUrl?: string | undefined;
248+}): string => {
249+ if (input.baseUrl) {
250+ return new URL(input.baseUrl).toString();
251+ }
252+
253+ return new URL(input.servicePath || "", input.rootUrl).toString();
254+};
255+
256+const buildGoogleDiscoveryRequest = (input: {
257+ definition: GoogleDiscoveryToolDefinition;
258+ args: Record<string, unknown>;
259+ defaultHeaders: Readonly<Record<string, string>>;
260+ credentialPlacements: HttpRequestPlacements;
261+ baseUrl?: string | undefined;
262+ providerData: typeof GoogleDiscoveryToolProviderDataSchema.Type;
263+}): {
264+ method: string;
265+ url: URL;
266+ headers: Record<string, string>;
267+ body?: string;
268+} => {
269+ const providerData = decodeGoogleDiscoveryToolProviderDataJson(input.providerData);
270+ if (Either.isLeft(providerData)) {
271+ throw new Error("Invalid Google Discovery provider data");
272+ }
273+
274+ const invocation = providerData.right.invocation;
275+ const resolvedPath = replacePathParameters({
276+ pathTemplate: invocation.path,
277+ args: input.args,
278+ parameters: input.definition.parameters,
279+ });
280+ const url = new URL(
281+ resolvedPath.replace(/^\//, ""),
282+ resolveBaseUrl({
283+ rootUrl: invocation.rootUrl,
284+ servicePath: invocation.servicePath,
285+ baseUrl: input.baseUrl,
286+ }),
287+ );
288+ const headers: Record<string, string> = {
289+ ...input.defaultHeaders,
290+ };
291+
292+ for (const parameter of input.definition.parameters) {
293+ if (parameter.location === "path") {
294+ continue;
295+ }
296+
297+ const rawValue = input.args[parameter.name];
298+ if ((rawValue === undefined || rawValue === null) && parameter.required) {
299+ throw new Error(`Missing required ${parameter.location} parameter: ${parameter.name}`);
300+ }
301+
302+ const values = stringValuesFromParameter(rawValue, parameter.repeated);
303+ if (values.length === 0) {
304+ continue;
305+ }
306+
307+ switch (parameter.location) {
308+ case "query":
309+ for (const value of values) {
310+ url.searchParams.append(parameter.name, value);
311+ }
312+ break;
313+ case "header":
314+ headers[parameter.name] = parameter.repeated ? values.join(",") : values[0]!;
315+ break;
316+ }
317+ }
318+
319+ const urlWithAuth = applyHttpQueryPlacementsToUrl({
320+ url,
321+ queryParams: input.credentialPlacements.queryParams,
322+ });
323+ const headersWithCookies = applyCookiePlacementsToHeaders({
324+ headers,
325+ cookies: input.credentialPlacements.cookies,
326+ });
327+ for (const [key, value] of Object.entries(input.credentialPlacements.headers ?? {})) {
328+ headersWithCookies[key] = value;
329+ }
330+
331+ let body: string | undefined;
332+ if (input.definition.requestSchemaId !== null) {
333+ const rawBody = input.args.body;
334+ const bodyValues = input.credentialPlacements.bodyValues ?? {};
335+ const hasBodyValues = Object.keys(bodyValues).length > 0;
336+ if (rawBody !== undefined || hasBodyValues) {
337+ body = JSON.stringify(
338+ applyJsonBodyPlacements({
339+ body: rawBody !== undefined ? rawBody : {},
340+ bodyValues,
341+ label: `${input.definition.method.toUpperCase()} ${input.definition.path}`,
342+ }),
343+ );
344+ if (!("content-type" in headersWithCookies)) {
345+ headersWithCookies["content-type"] = "application/json";
346+ }
347+ }
348+ }
349+
350+ return {
351+ method: input.definition.method.toUpperCase(),
352+ url: urlWithAuth,
353+ headers: headersWithCookies,
354+ ...(body !== undefined ? { body } : {}),
355+ };
356+};
357+
358+export type CreateGoogleDiscoveryToolFromDefinitionInput = {
359+ definition: GoogleDiscoveryToolDefinition;
360+ service: string;
361+ version: string;
362+ rootUrl: string;
363+ servicePath: string;
364+ oauthScopes?: Readonly<Record<string, string>>;
365+ path: string;
366+ sourceKey: string;
367+ defaultHeaders?: Readonly<Record<string, string>>;
368+ credentialHeaders?: Readonly<Record<string, string>>;
369+ credentialPlacements?: HttpRequestPlacements;
370+ schemaRefTable?: Readonly<GoogleDiscoverySchemaRefTable>;
371+ baseUrl?: string;
372+ httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>;
373+};
374+
375+export type GoogleDiscoveryToolPresentation = {
376+ inputTypePreview: string;
377+ outputTypePreview: string;
378+ inputSchema?: unknown;
379+ outputSchema?: unknown;
380+ providerData: typeof GoogleDiscoveryToolProviderDataSchema.Type;
381+};
382+
383+export const buildGoogleDiscoveryToolPresentation = (input: {
384+ manifest: {
385+ service: string;
386+ versionName: string;
387+ rootUrl: string;
388+ servicePath: string;
389+ oauthScopes?: Readonly<Record<string, string>>;
390+ schemaRefTable?: Readonly<GoogleDiscoverySchemaRefTable>;
391+ };
392+ definition: GoogleDiscoveryToolDefinition;
393+}): GoogleDiscoveryToolPresentation => {
394+ const refTable = Object.fromEntries(
395+ Object.entries(input.manifest.schemaRefTable ?? {}).map(([ref, value]) => {
396+ try {
397+ return [ref, JSON.parse(value) as unknown];
398+ } catch {
399+ return [ref, value];
400+ }
401+ }),
402+ );
403+ const inputSchema =
404+ input.definition.inputSchema === undefined
405+ ? undefined
406+ : materializeSchemaWithRefDefinitions({
407+ schema: input.definition.inputSchema,
408+ refTable,
409+ });
410+ const outputSchema =
411+ input.definition.outputSchema === undefined
412+ ? undefined
413+ : materializeSchemaWithRefDefinitions({
414+ schema: input.definition.outputSchema,
415+ refTable,
416+ });
417+
418+ return {
419+ inputTypePreview: typeSignatureFromSchema(inputSchema, "unknown", Infinity),
420+ outputTypePreview: typeSignatureFromSchema(outputSchema, "unknown", Infinity),
421+ ...(inputSchema !== undefined ? { inputSchema } : {}),
422+ ...(outputSchema !== undefined ? { outputSchema } : {}),
423+ providerData: googleDiscoveryProviderDataFromDefinition({
424+ service: input.manifest.service,
425+ version: input.manifest.versionName,
426+ rootUrl: input.manifest.rootUrl,
427+ servicePath: input.manifest.servicePath,
428+ oauthScopes: input.manifest.oauthScopes,
429+ definition: input.definition,
430+ }),
431+ };
432+};
433+
434+export const createGoogleDiscoveryToolFromDefinition = (
435+ input: CreateGoogleDiscoveryToolFromDefinitionInput,
436+) => {
437+ const presentation = buildGoogleDiscoveryToolPresentation({
438+ manifest: {
439+ service: input.service,
440+ versionName: input.version,
441+ rootUrl: input.rootUrl,
442+ servicePath: input.servicePath,
443+ oauthScopes: input.oauthScopes,
444+ schemaRefTable: input.schemaRefTable,
445+ },
446+ definition: input.definition,
447+ });
448+ const providerData = presentation.providerData;
449+ const credentialPlacements = normalizeCredentialPlacements({
450+ credentialHeaders: input.credentialHeaders,
451+ credentialPlacements: input.credentialPlacements,
452+ });
453+ const httpClientLayer = input.httpClientLayer ?? FetchHttpClient.layer;
454+
455+ const metadata: ToolMetadata = {
456+ interaction: input.definition.method === "get" || input.definition.method === "head"
457+ ? "auto"
458+ : "required",
459+ contract: {
460+ inputTypePreview: presentation.inputTypePreview,
461+ outputTypePreview: presentation.outputTypePreview,
462+ ...(presentation.inputSchema !== undefined
463+ ? { inputSchema: presentation.inputSchema }
464+ : {}),
465+ ...(presentation.outputSchema !== undefined
466+ ? { outputSchema: presentation.outputSchema }
467+ : {}),
468+ },
469+ sourceKey: input.sourceKey,
470+ pluginKind: "google_discovery",
471+ pluginData: providerData,
472+ };
473+
474+ return toTool({
475+ tool: {
476+ description: input.definition.description ?? undefined,
477+ inputSchema: standardSchemaFromJsonSchema(presentation.inputSchema ?? {}),
478+ execute: (args: unknown) =>
479+ Effect.runPromise(
480+ (Effect.gen(function* () {
481+ const request = buildGoogleDiscoveryRequest({
482+ definition: input.definition,
483+ args: asRecord(args),
484+ defaultHeaders: input.defaultHeaders ?? {},
485+ credentialPlacements,
486+ baseUrl: input.baseUrl,
487+ providerData,
488+ });
489+ let clientRequest = HttpClientRequest.make(
490+ request.method as Parameters<typeof HttpClientRequest.make>[0],
491+ )(request.url, {
492+ headers: request.headers,
493+ });
494+
495+ if (request.body !== undefined) {
496+ clientRequest = HttpClientRequest.bodyText(
497+ clientRequest,
498+ request.body,
499+ request.headers["content-type"],
500+ );
501+ }
502+
503+ const client = yield* HttpClient.HttpClient;
504+ const response = yield* client.execute(clientRequest).pipe(
505+ Effect.mapError((cause) =>
506+ cause instanceof Error ? cause : new Error(String(cause)),
507+ ),
508+ );
509+ const body = yield* decodeResponseBody(response);
510+
511+ if (response.status < 200 || response.status >= 300) {
512+ return yield* sourceCoreEffectError("google-discovery/tools",
513+ `Google Discovery request failed with HTTP ${response.status}: ${typeof body === "string" ? body : JSON.stringify(body)}`,
514+ );
515+ }
516+
517+ return body;
518+ }).pipe(Effect.provide(httpClientLayer)) as Effect.Effect<
519+ unknown,
520+ Error,
521+ never
522+ >),
523+ ),
524+ },
525+ metadata,
526+ });
527+};
528+
529+export {
530+ decodeGoogleDiscoverySchemaRefTableJson,
531+};
plugins/google-discovery/sdk/tsconfig.json+15−0
@@ -0,0 +1,15 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2022",
4+ "module": "ESNext",
5+ "moduleResolution": "Bundler",
6+ "strict": true,
7+ "skipLibCheck": true,
8+ "noEmit": true,
9+ "lib": ["ES2022"],
10+ "types": ["bun-types", "node"]
11+ },
12+ "include": [
13+ "*.ts"
14+ ]
15+}
plugins/google-discovery/sdk/types.ts+129−0
@@ -0,0 +1,129 @@
1+import { Schema } from "effect";
2+
3+export const GOOGLE_DISCOVERY_HTTP_METHODS = [
4+ "get",
5+ "put",
6+ "post",
7+ "delete",
8+ "patch",
9+ "head",
10+ "options",
11+] as const;
12+
13+export const GoogleDiscoveryHttpMethodSchema = Schema.Literal(
14+ ...GOOGLE_DISCOVERY_HTTP_METHODS,
15+);
16+
17+export const GoogleDiscoveryParameterLocationSchema = Schema.Literal(
18+ "path",
19+ "query",
20+ "header",
21+);
22+
23+export const GoogleDiscoveryMethodParameterSchema = Schema.Struct({
24+ name: Schema.String,
25+ location: GoogleDiscoveryParameterLocationSchema,
26+ required: Schema.Boolean,
27+ repeated: Schema.Boolean,
28+ description: Schema.NullOr(Schema.String),
29+ type: Schema.NullOr(Schema.String),
30+ enum: Schema.optional(Schema.Array(Schema.String)),
31+ default: Schema.optional(Schema.String),
32+});
33+
34+export const GoogleDiscoveryInvocationPayloadSchema = Schema.Struct({
35+ method: GoogleDiscoveryHttpMethodSchema,
36+ path: Schema.String,
37+ flatPath: Schema.NullOr(Schema.String),
38+ rootUrl: Schema.String,
39+ servicePath: Schema.String,
40+ parameters: Schema.Array(GoogleDiscoveryMethodParameterSchema),
41+ requestSchemaId: Schema.NullOr(Schema.String),
42+ responseSchemaId: Schema.NullOr(Schema.String),
43+ scopes: Schema.Array(Schema.String),
44+ scopeDescriptions: Schema.optional(
45+ Schema.Record({
46+ key: Schema.String,
47+ value: Schema.String,
48+ }),
49+ ),
50+ supportsMediaUpload: Schema.Boolean,
51+ supportsMediaDownload: Schema.Boolean,
52+});
53+
54+export const GoogleDiscoveryToolProviderDataSchema = Schema.Struct({
55+ kind: Schema.Literal("google_discovery"),
56+ service: Schema.String,
57+ version: Schema.String,
58+ toolId: Schema.String,
59+ rawToolId: Schema.String,
60+ methodId: Schema.String,
61+ group: Schema.NullOr(Schema.String),
62+ leaf: Schema.String,
63+ invocation: GoogleDiscoveryInvocationPayloadSchema,
64+});
65+
66+export const GoogleDiscoveryManifestMethodSchema = Schema.Struct({
67+ toolId: Schema.String,
68+ rawToolId: Schema.String,
69+ methodId: Schema.String,
70+ name: Schema.String,
71+ description: Schema.NullOr(Schema.String),
72+ group: Schema.NullOr(Schema.String),
73+ leaf: Schema.String,
74+ method: GoogleDiscoveryHttpMethodSchema,
75+ path: Schema.String,
76+ flatPath: Schema.NullOr(Schema.String),
77+ parameters: Schema.Array(GoogleDiscoveryMethodParameterSchema),
78+ requestSchemaId: Schema.NullOr(Schema.String),
79+ responseSchemaId: Schema.NullOr(Schema.String),
80+ scopes: Schema.Array(Schema.String),
81+ supportsMediaUpload: Schema.Boolean,
82+ supportsMediaDownload: Schema.Boolean,
83+ inputSchema: Schema.optional(Schema.Unknown),
84+ outputSchema: Schema.optional(Schema.Unknown),
85+});
86+
87+export const GoogleDiscoverySchemaRefTableSchema = Schema.Record({
88+ key: Schema.String,
89+ value: Schema.String,
90+});
91+
92+export const GoogleDiscoveryToolManifestSchema = Schema.Struct({
93+ version: Schema.Literal(1),
94+ sourceHash: Schema.String,
95+ service: Schema.String,
96+ versionName: Schema.String,
97+ title: Schema.NullOr(Schema.String),
98+ description: Schema.NullOr(Schema.String),
99+ rootUrl: Schema.String,
100+ servicePath: Schema.String,
101+ batchPath: Schema.NullOr(Schema.String),
102+ documentationLink: Schema.NullOr(Schema.String),
103+ oauthScopes: Schema.optional(
104+ Schema.Record({
105+ key: Schema.String,
106+ value: Schema.String,
107+ }),
108+ ),
109+ schemaRefTable: Schema.optional(GoogleDiscoverySchemaRefTableSchema),
110+ methods: Schema.Array(GoogleDiscoveryManifestMethodSchema),
111+});
112+
113+export type GoogleDiscoveryHttpMethod = typeof GoogleDiscoveryHttpMethodSchema.Type;
114+export type GoogleDiscoveryParameterLocation =
115+ typeof GoogleDiscoveryParameterLocationSchema.Type;
116+export type GoogleDiscoveryMethodParameter =
117+ typeof GoogleDiscoveryMethodParameterSchema.Type;
118+export type GoogleDiscoveryInvocationPayload =
119+ typeof GoogleDiscoveryInvocationPayloadSchema.Type;
120+export type GoogleDiscoveryToolProviderData =
121+ typeof GoogleDiscoveryToolProviderDataSchema.Type;
122+export type GoogleDiscoveryManifestMethod =
123+ typeof GoogleDiscoveryManifestMethodSchema.Type;
124+export type GoogleDiscoverySchemaRefTable =
125+ typeof GoogleDiscoverySchemaRefTableSchema.Type;
126+export type GoogleDiscoveryToolManifest =
127+ typeof GoogleDiscoveryToolManifestSchema.Type;
128+
129+export type GoogleDiscoveryToolDefinition = GoogleDiscoveryManifestMethod;
plugins/google-discovery/shared/index.ts+155−0
@@ -0,0 +1,155 @@
1+import * as Schema from "effect/Schema";
2+
3+import {
4+ SecretRefSchema,
5+ StringMapSchema,
6+} from "@executor/platform-sdk/schema";
7+
8+export const GoogleDiscoveryOAuthClientAuthenticationSchema = Schema.Literal(
9+ "none",
10+ "client_secret_post",
11+);
12+
13+export const GoogleDiscoveryConnectionAuthSchema = Schema.Union(
14+ Schema.Struct({
15+ kind: Schema.Literal("none"),
16+ }),
17+ Schema.Struct({
18+ kind: Schema.Literal("bearer"),
19+ tokenSecretRef: SecretRefSchema,
20+ }),
21+ Schema.Struct({
22+ kind: Schema.Literal("oauth2"),
23+ clientId: Schema.String,
24+ clientSecretRef: Schema.NullOr(SecretRefSchema),
25+ clientAuthentication: GoogleDiscoveryOAuthClientAuthenticationSchema,
26+ authorizationEndpoint: Schema.String,
27+ tokenEndpoint: Schema.String,
28+ scopes: Schema.Array(Schema.String),
29+ accessTokenRef: SecretRefSchema,
30+ refreshTokenRef: Schema.NullOr(SecretRefSchema),
31+ expiresAt: Schema.NullOr(Schema.Number),
32+ }),
33+);
34+
35+export const GoogleDiscoveryConnectInputSchema = Schema.Struct({
36+ name: Schema.String,
37+ service: Schema.String,
38+ version: Schema.String,
39+ discoveryUrl: Schema.NullOr(Schema.String),
40+ defaultHeaders: Schema.NullOr(StringMapSchema),
41+ scopes: Schema.Array(Schema.String),
42+ auth: GoogleDiscoveryConnectionAuthSchema,
43+});
44+
45+export const GoogleDiscoverySourceConfigPayloadSchema =
46+ GoogleDiscoveryConnectInputSchema;
47+
48+export const GoogleDiscoveryUpdateSourceInputSchema = Schema.Struct({
49+ sourceId: Schema.String,
50+ config: GoogleDiscoverySourceConfigPayloadSchema,
51+});
52+
53+export const GoogleDiscoveryStoredSourceDataSchema = Schema.Struct({
54+ service: Schema.String,
55+ version: Schema.String,
56+ discoveryUrl: Schema.String,
57+ defaultHeaders: Schema.NullOr(StringMapSchema),
58+ scopes: Schema.Array(Schema.String),
59+ auth: GoogleDiscoveryConnectionAuthSchema,
60+});
61+
62+export const GoogleDiscoveryStartOAuthInputSchema = Schema.Struct({
63+ service: Schema.String,
64+ version: Schema.String,
65+ discoveryUrl: Schema.NullOr(Schema.String),
66+ defaultHeaders: Schema.NullOr(StringMapSchema),
67+ scopes: Schema.Array(Schema.String),
68+ clientId: Schema.String,
69+ clientSecretRef: Schema.NullOr(SecretRefSchema),
70+ clientAuthentication: GoogleDiscoveryOAuthClientAuthenticationSchema,
71+ redirectUrl: Schema.String,
72+});
73+
74+export const GoogleDiscoveryStartOAuthResultSchema = Schema.Struct({
75+ sessionId: Schema.String,
76+ authorizationUrl: Schema.String,
77+ scopes: Schema.Array(Schema.String),
78+});
79+
80+export const GoogleDiscoveryOAuthSessionSchema = Schema.Struct({
81+ service: Schema.String,
82+ version: Schema.String,
83+ discoveryUrl: Schema.String,
84+ defaultHeaders: Schema.NullOr(StringMapSchema),
85+ scopes: Schema.Array(Schema.String),
86+ clientId: Schema.String,
87+ clientSecretRef: Schema.NullOr(SecretRefSchema),
88+ clientAuthentication: GoogleDiscoveryOAuthClientAuthenticationSchema,
89+ redirectUrl: Schema.String,
90+ codeVerifier: Schema.String,
91+});
92+
93+const GoogleDiscoveryOAuthPopupSuccessAuthSchema = Schema.Struct({
94+ kind: Schema.Literal("oauth2"),
95+ clientId: Schema.String,
96+ clientSecretRef: Schema.NullOr(SecretRefSchema),
97+ clientAuthentication: GoogleDiscoveryOAuthClientAuthenticationSchema,
98+ authorizationEndpoint: Schema.String,
99+ tokenEndpoint: Schema.String,
100+ scopes: Schema.Array(Schema.String),
101+ accessTokenRef: SecretRefSchema,
102+ refreshTokenRef: Schema.NullOr(SecretRefSchema),
103+ expiresAt: Schema.NullOr(Schema.Number),
104+});
105+
106+export const GoogleDiscoveryOAuthPopupResultSchema = Schema.Union(
107+ Schema.Struct({
108+ type: Schema.Literal("executor:oauth-result"),
109+ ok: Schema.Literal(true),
110+ sessionId: Schema.String,
111+ auth: GoogleDiscoveryOAuthPopupSuccessAuthSchema,
112+ }),
113+ Schema.Struct({
114+ type: Schema.Literal("executor:oauth-result"),
115+ ok: Schema.Literal(false),
116+ sessionId: Schema.Null,
117+ error: Schema.String,
118+ }),
119+);
120+
121+export type GoogleDiscoveryOAuthClientAuthentication =
122+ typeof GoogleDiscoveryOAuthClientAuthenticationSchema.Type;
123+export type GoogleDiscoveryConnectionAuth =
124+ typeof GoogleDiscoveryConnectionAuthSchema.Type;
125+export type GoogleDiscoveryConnectInput =
126+ typeof GoogleDiscoveryConnectInputSchema.Type;
127+export type GoogleDiscoverySourceConfigPayload =
128+ typeof GoogleDiscoverySourceConfigPayloadSchema.Type;
129+export type GoogleDiscoveryUpdateSourceInput =
130+ typeof GoogleDiscoveryUpdateSourceInputSchema.Type;
131+export type GoogleDiscoveryStoredSourceData =
132+ typeof GoogleDiscoveryStoredSourceDataSchema.Type;
133+export type GoogleDiscoveryStartOAuthInput =
134+ typeof GoogleDiscoveryStartOAuthInputSchema.Type;
135+export type GoogleDiscoveryStartOAuthResult =
136+ typeof GoogleDiscoveryStartOAuthResultSchema.Type;
137+export type GoogleDiscoveryOAuthSession =
138+ typeof GoogleDiscoveryOAuthSessionSchema.Type;
139+export type GoogleDiscoveryOAuthPopupResult =
140+ typeof GoogleDiscoveryOAuthPopupResultSchema.Type;
141+
142+export const defaultGoogleDiscoveryUrl = (
143+ service: string,
144+ version: string,
145+): string =>
146+ `https://www.googleapis.com/discovery/v1/apis/${encodeURIComponent(service)}/${encodeURIComponent(version)}/rest`;
147+
148+export const deriveGoogleDiscoveryNamespace = (
149+ service: string,
150+): string =>
151+ `google.${service
152+ .trim()
153+ .toLowerCase()
154+ .replace(/[^a-z0-9]+/g, ".")
155+ .replace(/^\.+|\.+$/g, "")}`;
plugins/google-discovery/shared/package.json+24−0
@@ -0,0 +1,24 @@
1+{
2+ "name": "@executor/plugin-google-discovery-shared",
3+ "private": true,
4+ "type": "module",
5+ "exports": {
6+ ".": "./index.ts"
7+ },
8+ "files": [
9+ "index.ts"
10+ ],
11+ "scripts": {
12+ "typecheck": "bunx tsc --noEmit -p tsconfig.json"
13+ },
14+ "dependencies": {
15+ "@executor/platform-sdk": "workspace:*",
16+ "@executor/source-core": "workspace:*",
17+ "effect": "catalog:"
18+ },
19+ "devDependencies": {
20+ "@types/node": "catalog:",
21+ "bun-types": "catalog:"
22+ },
23+ "version": "0.0.0"
24+}
plugins/google-discovery/shared/tsconfig.json+15−0
@@ -0,0 +1,15 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2022",
4+ "module": "ESNext",
5+ "moduleResolution": "Bundler",
6+ "strict": true,
7+ "skipLibCheck": true,
8+ "noEmit": true,
9+ "lib": ["ES2022"],
10+ "types": ["bun-types", "node"]
11+ },
12+ "include": [
13+ "index.ts"
14+ ]
15+}
plugins/graphql/http/index.ts+187−0
@@ -0,0 +1,187 @@
1+import {
2+ HttpApi,
3+ HttpApiBuilder,
4+ HttpApiEndpoint,
5+ HttpApiGroup,
6+ HttpApiSchema,
7+} from "@effect/platform";
8+import {
9+ ControlPlaneBadRequestError,
10+ ControlPlaneForbiddenError,
11+ ControlPlaneNotFoundError,
12+ ControlPlaneStorageError,
13+ type ExecutorHttpApiExtension,
14+ type ExecutorHttpPlugin,
15+} from "@executor/platform-api";
16+import { resolveRequestedLocalWorkspace } from "@executor/platform-api/local-context";
17+import {
18+ ScopeIdSchema,
19+ SourceIdSchema,
20+ SourceSchema,
21+ type Source,
22+} from "@executor/platform-sdk/schema";
23+import * as Effect from "effect/Effect";
24+import * as Schema from "effect/Schema";
25+
26+import {
27+ GraphqlConnectInputSchema,
28+ GraphqlSourceConfigPayloadSchema,
29+ type GraphqlConnectInput,
30+ type GraphqlSourceConfigPayload,
31+ type GraphqlUpdateSourceInput,
32+} from "@executor/plugin-graphql-shared";
33+
34+type GraphqlExecutorExtension = {
35+ graphql: {
36+ createSource: (
37+ input: GraphqlConnectInput,
38+ ) => Effect.Effect<Source, Error>;
39+ getSourceConfig: (
40+ sourceId: Source["id"],
41+ ) => Effect.Effect<GraphqlSourceConfigPayload, Error>;
42+ updateSource: (
43+ input: GraphqlUpdateSourceInput,
44+ ) => Effect.Effect<Source, Error>;
45+ removeSource: (
46+ sourceId: Source["id"],
47+ ) => Effect.Effect<boolean, Error>;
48+ };
49+};
50+
51+const workspaceIdParam = HttpApiSchema.param("workspaceId", ScopeIdSchema);
52+const sourceIdParam = HttpApiSchema.param("sourceId", SourceIdSchema);
53+
54+export const GraphqlHttpGroup = HttpApiGroup.make("graphql")
55+ .add(
56+ HttpApiEndpoint.post("createSource")`/workspaces/${workspaceIdParam}/plugins/graphql/sources`
57+ .setPayload(GraphqlConnectInputSchema)
58+ .addSuccess(SourceSchema)
59+ .addError(ControlPlaneBadRequestError)
60+ .addError(ControlPlaneForbiddenError)
61+ .addError(ControlPlaneStorageError),
62+ )
63+ .add(
64+ HttpApiEndpoint.get("getSourceConfig")`/workspaces/${workspaceIdParam}/plugins/graphql/sources/${sourceIdParam}`
65+ .addSuccess(GraphqlSourceConfigPayloadSchema)
66+ .addError(ControlPlaneBadRequestError)
67+ .addError(ControlPlaneForbiddenError)
68+ .addError(ControlPlaneNotFoundError)
69+ .addError(ControlPlaneStorageError),
70+ )
71+ .add(
72+ HttpApiEndpoint.put("updateSource")`/workspaces/${workspaceIdParam}/plugins/graphql/sources/${sourceIdParam}`
73+ .setPayload(GraphqlSourceConfigPayloadSchema)
74+ .addSuccess(SourceSchema)
75+ .addError(ControlPlaneBadRequestError)
76+ .addError(ControlPlaneForbiddenError)
77+ .addError(ControlPlaneNotFoundError)
78+ .addError(ControlPlaneStorageError),
79+ )
80+ .add(
81+ HttpApiEndpoint.del("removeSource")`/workspaces/${workspaceIdParam}/plugins/graphql/sources/${sourceIdParam}`
82+ .addSuccess(Schema.Struct({ removed: Schema.Boolean }))
83+ .addError(ControlPlaneBadRequestError)
84+ .addError(ControlPlaneForbiddenError)
85+ .addError(ControlPlaneNotFoundError)
86+ .addError(ControlPlaneStorageError),
87+ )
88+ .prefix("/v1");
89+
90+export const graphqlHttpApiExtension = {
91+ key: "graphql",
92+ group: GraphqlHttpGroup,
93+} satisfies ExecutorHttpApiExtension<typeof GraphqlHttpGroup>;
94+
95+const GraphqlHttpApi = HttpApi.make("executor").add(GraphqlHttpGroup);
96+
97+const toBadRequestError = (operation: string, cause: unknown) =>
98+ new ControlPlaneBadRequestError({
99+ operation,
100+ message: cause instanceof Error ? cause.message : String(cause),
101+ details: cause instanceof Error ? cause.stack ?? cause.message : String(cause),
102+ });
103+
104+const toStorageError = (operation: string, cause: unknown) =>
105+ new ControlPlaneStorageError({
106+ operation,
107+ message: cause instanceof Error ? cause.message : String(cause),
108+ details: cause instanceof Error ? cause.stack ?? cause.message : String(cause),
109+ });
110+
111+const toNotFoundError = (operation: string, cause: unknown) =>
112+ new ControlPlaneNotFoundError({
113+ operation,
114+ message: cause instanceof Error ? cause.message : String(cause),
115+ details: cause instanceof Error ? cause.stack ?? cause.message : String(cause),
116+ });
117+
118+const mapPluginStorageError = (operation: string, cause: unknown) => {
119+ const message = cause instanceof Error ? cause.message : String(cause);
120+ if (message.includes("not found") || message.includes("Not found")) {
121+ return toNotFoundError(operation, cause);
122+ }
123+
124+ return toStorageError(operation, cause);
125+};
126+
127+export const graphqlHttpPlugin = (): ExecutorHttpPlugin<
128+ typeof GraphqlHttpGroup,
129+ GraphqlExecutorExtension
130+> => ({
131+ key: "graphql",
132+ group: GraphqlHttpGroup,
133+ build: ({ executor }) =>
134+ HttpApiBuilder.group(GraphqlHttpApi, "graphql", (handlers) =>
135+ handlers
136+ .handle("createSource", ({ path, payload }) =>
137+ resolveRequestedLocalWorkspace(
138+ "graphql.createSource",
139+ path.workspaceId,
140+ ).pipe(
141+ Effect.flatMap(() => executor.graphql.createSource(payload)),
142+ Effect.mapError((cause) =>
143+ toStorageError("graphql.createSource", cause)
144+ ),
145+ )
146+ )
147+ .handle("getSourceConfig", ({ path }) =>
148+ resolveRequestedLocalWorkspace(
149+ "graphql.getSourceConfig",
150+ path.workspaceId,
151+ ).pipe(
152+ Effect.flatMap(() => executor.graphql.getSourceConfig(path.sourceId)),
153+ Effect.mapError((cause) =>
154+ mapPluginStorageError("graphql.getSourceConfig", cause)
155+ ),
156+ )
157+ )
158+ .handle("updateSource", ({ path, payload }) =>
159+ resolveRequestedLocalWorkspace(
160+ "graphql.updateSource",
161+ path.workspaceId,
162+ ).pipe(
163+ Effect.flatMap(() =>
164+ executor.graphql.updateSource({
165+ sourceId: path.sourceId,
166+ config: payload,
167+ })
168+ ),
169+ Effect.mapError((cause) =>
170+ mapPluginStorageError("graphql.updateSource", cause)
171+ ),
172+ )
173+ )
174+ .handle("removeSource", ({ path }) =>
175+ resolveRequestedLocalWorkspace(
176+ "graphql.removeSource",
177+ path.workspaceId,
178+ ).pipe(
179+ Effect.flatMap(() => executor.graphql.removeSource(path.sourceId)),
180+ Effect.map((removed) => ({ removed })),
181+ Effect.mapError((cause) =>
182+ mapPluginStorageError("graphql.removeSource", cause)
183+ ),
184+ )
185+ )
186+ ),
187+});
plugins/graphql/http/package.json+26−0
@@ -0,0 +1,26 @@
1+{
2+ "name": "@executor/plugin-graphql-http",
3+ "private": true,
4+ "type": "module",
5+ "exports": {
6+ ".": "./index.ts"
7+ },
8+ "files": [
9+ "index.ts"
10+ ],
11+ "scripts": {
12+ "typecheck": "bunx tsc --noEmit -p tsconfig.json"
13+ },
14+ "dependencies": {
15+ "@effect/platform": "catalog:",
16+ "@executor/platform-api": "workspace:*",
17+ "@executor/platform-sdk": "workspace:*",
18+ "@executor/plugin-graphql-shared": "workspace:*",
19+ "effect": "catalog:"
20+ },
21+ "devDependencies": {
22+ "@types/node": "catalog:",
23+ "bun-types": "catalog:"
24+ },
25+ "version": "0.0.0"
26+}
plugins/graphql/http/tsconfig.json+15−0
@@ -0,0 +1,15 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2022",
4+ "module": "ESNext",
5+ "moduleResolution": "Bundler",
6+ "strict": true,
7+ "skipLibCheck": true,
8+ "noEmit": true,
9+ "lib": ["ES2022"],
10+ "types": ["bun-types", "node"]
11+ },
12+ "include": [
13+ "index.ts"
14+ ]
15+}
plugins/graphql/react/index.tsx+518−0
@@ -0,0 +1,518 @@
1+import { startTransition, useMemo, useState, type ReactNode } from "react";
2+import { useNavigate } from "@tanstack/react-router";
3+import type {
4+ Source,
5+} from "@executor/react";
6+import {
7+ defineExecutorPluginHttpApiClient,
8+ Result,
9+ useAtomSet,
10+ useAtomValue,
11+ useExecutorMutation,
12+ useLocalInstallation,
13+ useSecrets,
14+} from "@executor/react";
15+
16+import {
17+ graphqlHttpApiExtension,
18+} from "@executor/plugin-graphql-http";
19+import {
20+ type GraphqlConnectInput,
21+ type GraphqlConnectionAuth,
22+} from "@executor/plugin-graphql-shared";
23+import { SourceToolExplorer } from "../../../apps/web/src/source-plugins/tool-explorer";
24+import { IconPencil } from "../../../apps/web/src/components/icons";
25+
26+type FrontendSourceTypeDefinition = {
27+ kind: string;
28+ displayName: string;
29+ description?: string;
30+ renderAddPage: () => ReactNode;
31+ renderEditPage?: (input: { source: Source }) => ReactNode;
32+ renderDetailPage?: (input: {
33+ source: Source;
34+ route: {
35+ search?: unknown;
36+ navigate?: unknown;
37+ };
38+ }) => ReactNode;
39+};
40+
41+type FrontendPluginRegisterApi = {
42+ sources: {
43+ registerType: (definition: FrontendSourceTypeDefinition) => void;
44+ };
45+};
46+
47+type RouteToolSearch = {
48+ tab?: "model" | "discover";
49+ tool?: string;
50+ query?: string;
51+};
52+
53+const getGraphqlHttpClient = defineExecutorPluginHttpApiClient<"GraphqlReactHttpClient">()(
54+ "GraphqlReactHttpClient",
55+ [graphqlHttpApiExtension] as const,
56+);
57+
58+const defaultGraphqlInput = (): GraphqlConnectInput => ({
59+ name: "My GraphQL Source",
60+ endpoint: "https://example.com/graphql",
61+ defaultHeaders: null,
62+ auth: {
63+ kind: "none",
64+ },
65+});
66+
67+const parseStringMap = (value: string): Record<string, string> | null => {
68+ const trimmed = value.trim();
69+ if (!trimmed) {
70+ return null;
71+ }
72+
73+ const parsed = JSON.parse(trimmed) as unknown;
74+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
75+ throw new Error("Headers must be a JSON object.");
76+ }
77+
78+ const entries = Object.entries(parsed as Record<string, unknown>);
79+ if (!entries.every(([, entry]) => typeof entry === "string")) {
80+ throw new Error("All header values must be strings.");
81+ }
82+
83+ return Object.fromEntries(entries as Array<[string, string]>);
84+};
85+
86+const stringifyStringMap = (
87+ value: Record<string, string> | null | undefined,
88+): string =>
89+ !value || Object.keys(value).length === 0
90+ ? ""
91+ : JSON.stringify(value, null, 2);
92+
93+const secretValue = (input: GraphqlConnectionAuth): string =>
94+ input.kind === "bearer"
95+ ? JSON.stringify(input.tokenSecretRef)
96+ : "";
97+
98+const authFromSecretValue = (
99+ authKind: GraphqlConnectionAuth["kind"],
100+ value: string,
101+): GraphqlConnectionAuth => {
102+ if (authKind === "none") {
103+ return {
104+ kind: "none",
105+ };
106+ }
107+
108+ if (!value) {
109+ throw new Error("Select a secret for bearer auth.");
110+ }
111+
112+ return {
113+ kind: "bearer",
114+ tokenSecretRef: JSON.parse(value) as GraphqlConnectionAuth & { tokenSecretRef: never }["tokenSecretRef"],
115+ };
116+};
117+
118+function GraphqlSourceForm(props: {
119+ initialValue: GraphqlConnectInput;
120+ mode: "create" | "edit";
121+ onSubmit: (input: GraphqlConnectInput) => Promise<void>;
122+}) {
123+ const secrets = useSecrets();
124+ const submitMutation = useExecutorMutation<GraphqlConnectInput, void>(props.onSubmit);
125+ const [name, setName] = useState(props.initialValue.name);
126+ const [endpoint, setEndpoint] = useState(props.initialValue.endpoint);
127+ const [headersText, setHeadersText] = useState(
128+ stringifyStringMap(props.initialValue.defaultHeaders),
129+ );
130+ const [authKind, setAuthKind] = useState<GraphqlConnectionAuth["kind"]>(
131+ props.initialValue.auth.kind,
132+ );
133+ const [secretRef, setSecretRef] = useState(secretValue(props.initialValue.auth));
134+ const [error, setError] = useState<string | null>(null);
135+
136+ return (
137+ <div className="space-y-4 rounded-2xl border border-border bg-card p-5">
138+ <div>
139+ <h2 className="text-lg font-semibold text-foreground">
140+ {props.mode === "create" ? "Connect GraphQL Source" : "Edit GraphQL Source"}
141+ </h2>
142+ <p className="mt-1 text-sm text-muted-foreground">
143+ GraphQL is now mounted as a plugin. The source record stays generic; endpoint and auth
144+ live entirely in plugin storage.
145+ </p>
146+ </div>
147+
148+ <label className="grid gap-1.5">
149+ <span className="text-xs font-medium text-foreground">Name</span>
150+ <input
151+ value={name}
152+ onChange={(event) => setName(event.target.value)}
153+ 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"
154+ />
155+ </label>
156+
157+ <label className="grid gap-1.5">
158+ <span className="text-xs font-medium text-foreground">Endpoint</span>
159+ <input
160+ value={endpoint}
161+ onChange={(event) => setEndpoint(event.target.value)}
162+ 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"
163+ />
164+ </label>
165+
166+ <label className="grid gap-1.5">
167+ <span className="text-xs font-medium text-foreground">Default Headers</span>
168+ <textarea
169+ value={headersText}
170+ onChange={(event) => setHeadersText(event.target.value)}
171+ rows={5}
172+ placeholder='{"x-api-version":"2026-03-23"}'
173+ 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"
174+ />
175+ </label>
176+
177+ <label className="grid gap-1.5">
178+ <span className="text-xs font-medium text-foreground">Auth</span>
179+ <select
180+ value={authKind}
181+ onChange={(event) =>
182+ setAuthKind(event.target.value as GraphqlConnectionAuth["kind"])}
183+ 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"
184+ >
185+ <option value="none">None</option>
186+ <option value="bearer">Bearer Secret</option>
187+ </select>
188+ </label>
189+
190+ {authKind === "bearer" && (
191+ <label className="grid gap-1.5">
192+ <span className="text-xs font-medium text-foreground">Secret</span>
193+ <select
194+ value={secretRef}
195+ onChange={(event) => setSecretRef(event.target.value)}
196+ 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"
197+ >
198+ <option value="">Select a secret</option>
199+ {secrets.status === "ready" &&
200+ secrets.data.map((secret) => (
201+ <option
202+ key={`${secret.providerId}:${secret.id}`}
203+ value={JSON.stringify({
204+ providerId: secret.providerId,
205+ handle: secret.id,
206+ })}
207+ >
208+ {secret.name ?? secret.id}
209+ </option>
210+ ))}
211+ </select>
212+ </label>
213+ )}
214+
215+ {error && (
216+ <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-3 text-sm text-destructive">
217+ {error}
218+ </div>
219+ )}
220+
221+ <div className="flex items-center gap-3">
222+ <button
223+ type="button"
224+ onClick={() => {
225+ void (async () => {
226+ setError(null);
227+ try {
228+ await submitMutation.mutateAsync({
229+ name: name.trim(),
230+ endpoint: endpoint.trim(),
231+ defaultHeaders: parseStringMap(headersText),
232+ auth: authFromSecretValue(authKind, secretRef),
233+ });
234+ } catch (cause) {
235+ setError(cause instanceof Error ? cause.message : "Failed saving GraphQL source.");
236+ }
237+ })();
238+ }}
239+ disabled={submitMutation.status === "pending"}
240+ 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"
241+ >
242+ {submitMutation.status === "pending"
243+ ? props.mode === "create" ? "Creating..." : "Saving..."
244+ : props.mode === "create" ? "Create Source" : "Save Changes"}
245+ </button>
246+ <div className="text-xs text-muted-foreground">
247+ {props.mode === "create"
248+ ? "The plugin will introspect the endpoint immediately after creation."
249+ : "Saving refreshes the imported schema and tool catalog."}
250+ </div>
251+ </div>
252+ </div>
253+ );
254+}
255+
256+function GraphqlAddPage() {
257+ const navigate = useNavigate();
258+ const installation = useLocalInstallation();
259+ const client = getGraphqlHttpClient();
260+ const createSource = useAtomSet(
261+ client.mutation("graphql", "createSource"),
262+ { mode: "promise" },
263+ );
264+
265+ if (installation.status !== "ready") {
266+ return <div className="text-sm text-muted-foreground">Loading workspace...</div>;
267+ }
268+
269+ return (
270+ <GraphqlSourceForm
271+ initialValue={defaultGraphqlInput()}
272+ mode="create"
273+ onSubmit={async (input) => {
274+ const source = await createSource({
275+ path: {
276+ workspaceId: installation.data.scopeId,
277+ },
278+ payload: input,
279+ reactivityKeys: {
280+ sources: [installation.data.scopeId],
281+ },
282+ });
283+
284+ startTransition(() => {
285+ void navigate({
286+ to: "/sources/$sourceId",
287+ params: {
288+ sourceId: source.id,
289+ },
290+ search: {
291+ tab: "model",
292+ },
293+ });
294+ });
295+ }}
296+ />
297+ );
298+}
299+
300+function GraphqlEditPage(props: {
301+ source: Source;
302+}) {
303+ const navigate = useNavigate();
304+ const installation = useLocalInstallation();
305+ const client = getGraphqlHttpClient();
306+ const configResult = useAtomValue(
307+ installation.status === "ready"
308+ ? client.query("graphql", "getSourceConfig", {
309+ path: {
310+ workspaceId: installation.data.scopeId,
311+ sourceId: props.source.id,
312+ },
313+ reactivityKeys: {
314+ source: [installation.data.scopeId, props.source.id],
315+ },
316+ timeToLive: "30 seconds",
317+ })
318+ : client.query("local", "installation", {
319+ timeToLive: "1 second",
320+ }) as never,
321+ );
322+ const updateSource = useAtomSet(
323+ client.mutation("graphql", "updateSource"),
324+ { mode: "promise" },
325+ );
326+
327+ if (installation.status !== "ready") {
328+ return <div className="text-sm text-muted-foreground">Loading workspace...</div>;
329+ }
330+
331+ if (Result.isFailure(configResult)) {
332+ return (
333+ <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-3 text-sm text-destructive">
334+ Failed loading GraphQL config.
335+ </div>
336+ );
337+ }
338+
339+ if (!Result.isSuccess(configResult)) {
340+ return <div className="text-sm text-muted-foreground">Loading GraphQL config...</div>;
341+ }
342+
343+ return (
344+ <GraphqlSourceForm
345+ initialValue={configResult.value}
346+ mode="edit"
347+ onSubmit={async (input) => {
348+ const source = await updateSource({
349+ path: {
350+ workspaceId: installation.data.scopeId,
351+ sourceId: props.source.id,
352+ },
353+ payload: input,
354+ reactivityKeys: {
355+ sources: [installation.data.scopeId],
356+ source: [installation.data.scopeId, props.source.id],
357+ sourceInspection: [installation.data.scopeId, props.source.id],
358+ sourceInspectionTool: [installation.data.scopeId, props.source.id],
359+ sourceDiscovery: [installation.data.scopeId, props.source.id],
360+ },
361+ });
362+
363+ startTransition(() => {
364+ void navigate({
365+ to: "/sources/$sourceId",
366+ params: {
367+ sourceId: source.id,
368+ },
369+ search: {
370+ tab: "model",
371+ },
372+ });
373+ });
374+ }}
375+ />
376+ );
377+}
378+
379+function GraphqlDetailPage(props: {
380+ source: Source;
381+ route: {
382+ search?: unknown;
383+ navigate?: unknown;
384+ };
385+}) {
386+ const navigate = useNavigate();
387+ const installation = useLocalInstallation();
388+ const client = getGraphqlHttpClient();
389+ const removeSource = useAtomSet(
390+ client.mutation("graphql", "removeSource"),
391+ { mode: "promise" },
392+ );
393+ const configResult = useAtomValue(
394+ installation.status === "ready"
395+ ? client.query("graphql", "getSourceConfig", {
396+ path: {
397+ workspaceId: installation.data.scopeId,
398+ sourceId: props.source.id,
399+ },
400+ reactivityKeys: {
401+ source: [installation.data.scopeId, props.source.id],
402+ },
403+ timeToLive: "30 seconds",
404+ })
405+ : client.query("local", "installation", {
406+ timeToLive: "1 second",
407+ }) as never,
408+ );
409+ const summary = useMemo(() => {
410+ if (!Result.isSuccess(configResult)) {
411+ return null;
412+ }
413+
414+ const config = configResult.value;
415+ return (
416+ <div className="space-y-1">
417+ <div className="font-mono text-xs text-foreground">{config.endpoint}</div>
418+ <div>
419+ Auth: <span className="text-foreground">{config.auth.kind}</span>
420+ </div>
421+ </div>
422+ );
423+ }, [configResult]);
424+
425+ if (installation.status !== "ready") {
426+ return <div className="text-sm text-muted-foreground">Loading workspace...</div>;
427+ }
428+
429+ return (
430+ <SourceToolExplorer
431+ sourceId={props.source.id}
432+ title={props.source.name}
433+ kind={props.source.kind}
434+ search={(props.route.search ?? {}) as RouteToolSearch}
435+ navigate={(next) => {
436+ const routeNavigate = props.route.navigate as
437+ | ((input: {
438+ search: {
439+ tab: "model" | "discover";
440+ tool?: string;
441+ query?: string;
442+ };
443+ }) => void | Promise<void>)
444+ | undefined;
445+ if (routeNavigate) {
446+ void routeNavigate({ search: next });
447+ }
448+ }}
449+ summary={summary}
450+ actions={(
451+ <>
452+ <button
453+ type="button"
454+ onClick={() =>
455+ void navigate({
456+ to: "/sources/$sourceId/edit",
457+ params: {
458+ sourceId: props.source.id,
459+ },
460+ })}
461+ className="inline-flex h-9 items-center gap-1.5 rounded-lg border border-input bg-card px-3 text-sm font-medium text-foreground transition-colors hover:bg-accent/50"
462+ >
463+ <IconPencil className="size-3.5" />
464+ Edit
465+ </button>
466+ <button
467+ type="button"
468+ onClick={() => {
469+ const confirmed = window.confirm(`Delete GraphQL source "${props.source.name}"?`);
470+ if (!confirmed) {
471+ return;
472+ }
473+
474+ void removeSource({
475+ path: {
476+ workspaceId: installation.data.scopeId,
477+ sourceId: props.source.id,
478+ },
479+ reactivityKeys: {
480+ sources: [installation.data.scopeId],
481+ source: [installation.data.scopeId, props.source.id],
482+ sourceInspection: [installation.data.scopeId, props.source.id],
483+ sourceInspectionTool: [installation.data.scopeId, props.source.id],
484+ sourceDiscovery: [installation.data.scopeId, props.source.id],
485+ },
486+ }).then(() => {
487+ startTransition(() => {
488+ void navigate({
489+ to: "/",
490+ });
491+ });
492+ });
493+ }}
494+ 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"
495+ >
496+ Delete
497+ </button>
498+ </>
499+ )}
500+ />
501+ );
502+}
503+
504+export const GraphqlReactPlugin = {
505+ key: "graphql",
506+ register(api: FrontendPluginRegisterApi) {
507+ api.sources.registerType({
508+ kind: "graphql",
509+ displayName: "GraphQL",
510+ description: "Introspect a GraphQL endpoint into typed query and mutation tools.",
511+ renderAddPage: () => <GraphqlAddPage />,
512+ renderEditPage: ({ source }) => <GraphqlEditPage source={source} />,
513+ renderDetailPage: ({ source, route }) => (
514+ <GraphqlDetailPage source={source} route={route} />
515+ ),
516+ });
517+ },
518+};
plugins/graphql/react/package.json+27−0
@@ -0,0 +1,27 @@
1+{
2+ "name": "@executor/plugin-graphql-react",
3+ "private": true,
4+ "type": "module",
5+ "exports": {
6+ ".": "./index.tsx"
7+ },
8+ "files": [
9+ "index.tsx"
10+ ],
11+ "scripts": {
12+ "typecheck": "bunx tsc --noEmit -p tsconfig.json"
13+ },
14+ "dependencies": {
15+ "@executor/plugin-graphql-http": "workspace:*",
16+ "@executor/plugin-graphql-shared": "workspace:*",
17+ "@executor/react": "workspace:*",
18+ "@tanstack/react-router": "^1.132.7",
19+ "react": "^19.2.0"
20+ },
21+ "devDependencies": {
22+ "@types/node": "catalog:",
23+ "@types/react": "^19.2.10",
24+ "bun-types": "catalog:"
25+ },
26+ "version": "0.0.0"
27+}
plugins/graphql/react/tsconfig.json+16−0
@@ -0,0 +1,16 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2022",
4+ "module": "ESNext",
5+ "moduleResolution": "Bundler",
6+ "strict": true,
7+ "skipLibCheck": true,
8+ "noEmit": true,
9+ "jsx": "react-jsx",
10+ "lib": ["ES2022", "DOM"],
11+ "types": ["bun-types", "node"]
12+ },
13+ "include": [
14+ "index.tsx"
15+ ]
16+}
plugins/graphql/sdk/catalog.ts+317−0
@@ -0,0 +1,317 @@
1+import { createCatalogSnapshotV1FromFragments } from "@executor/ir/catalog";
2+import {
3+ CapabilityIdSchema,
4+ DocumentIdSchema,
5+ ExecutableIdSchema,
6+ ResponseSymbolIdSchema,
7+ ScopeIdSchema,
8+} from "@executor/ir/ids";
9+import type {
10+ Capability,
11+ CatalogSnapshotV1,
12+ ResponseSymbol,
13+ Executable,
14+} from "@executor/ir/model";
15+import {
16+ type BaseCatalogOperationInput,
17+ type CatalogFragmentBuilder,
18+ type CatalogSourceDocumentInput,
19+ type JsonSchemaImporter,
20+ type Source,
21+ EXECUTABLE_BINDING_VERSION,
22+ asJsonRecord,
23+ buildCatalogFragment,
24+ createCatalogImportMetadata,
25+ docsFrom,
26+ interactionForEffect,
27+ mutableRecord,
28+ provenanceFor,
29+ responseSetFromSingleResponse,
30+ schemaWithMergedDefs,
31+ stableHash,
32+ toolPathSegments,
33+} from "@executor/source-core";
34+
35+import type { GraphqlToolProviderData } from "./provider-data";
36+
37+export type GraphqlCatalogOperationInput = BaseCatalogOperationInput & {
38+ providerData: GraphqlToolProviderData;
39+};
40+
41+const graphqlErrorItemsJsonSchema = (): Record<string, unknown> => ({
42+ type: "array",
43+ items: {
44+ type: "object",
45+ properties: {
46+ message: {
47+ type: "string",
48+ description: "GraphQL error message.",
49+ },
50+ path: {
51+ type: "array",
52+ description: "Path to the field that produced the error.",
53+ items: {
54+ anyOf: [{ type: "string" }, { type: "number" }],
55+ },
56+ },
57+ locations: {
58+ type: "array",
59+ description: "Source locations for the error in the GraphQL document.",
60+ items: {
61+ type: "object",
62+ properties: {
63+ line: { type: "number" },
64+ column: { type: "number" },
65+ },
66+ required: ["line", "column"],
67+ additionalProperties: false,
68+ },
69+ },
70+ extensions: {
71+ type: "object",
72+ description: "Additional provider-specific GraphQL error metadata.",
73+ additionalProperties: true,
74+ },
75+ },
76+ required: ["message"],
77+ additionalProperties: true,
78+ },
79+});
80+
81+const graphqlProjectedResultDataSchema = (input: {
82+ toolKind: "request" | "field";
83+ outputSchema: unknown;
84+}): Record<string, unknown> => {
85+ if (input.toolKind !== "field") {
86+ return asJsonRecord(input.outputSchema);
87+ }
88+
89+ const outputSchema = asJsonRecord(input.outputSchema);
90+ const dataSchema = asJsonRecord(asJsonRecord(outputSchema.properties).data);
91+
92+ return Object.keys(dataSchema).length > 0
93+ ? schemaWithMergedDefs(dataSchema, input.outputSchema)
94+ : outputSchema;
95+};
96+
97+const graphqlProjectedResultErrorSchema = (input: {
98+ toolKind: "request" | "field";
99+ outputSchema: unknown;
100+}): Record<string, unknown> => {
101+ if (input.toolKind !== "field") {
102+ return graphqlErrorItemsJsonSchema();
103+ }
104+
105+ const outputSchema = asJsonRecord(input.outputSchema);
106+ const errorsSchema = asJsonRecord(
107+ asJsonRecord(outputSchema.properties).errors,
108+ );
109+
110+ return Object.keys(errorsSchema).length > 0
111+ ? schemaWithMergedDefs(errorsSchema, input.outputSchema)
112+ : graphqlErrorItemsJsonSchema();
113+};
114+
115+const createGraphqlCapability = (input: {
116+ catalog: CatalogFragmentBuilder;
117+ source: Pick<Source, "id" | "name" | "namespace">;
118+ documentId: ReturnType<typeof DocumentIdSchema.make>;
119+ serviceScopeId: ReturnType<typeof ScopeIdSchema.make>;
120+ operation: GraphqlCatalogOperationInput;
121+ importer: JsonSchemaImporter;
122+}) => {
123+ const toolPath = toolPathSegments(
124+ input.source,
125+ input.operation.providerData.toolId,
126+ );
127+ const capabilityId = CapabilityIdSchema.make(
128+ `cap_${stableHash({
129+ sourceId: input.source.id,
130+ toolId: input.operation.providerData.toolId,
131+ })}`,
132+ );
133+ const executableId = ExecutableIdSchema.make(
134+ `exec_${stableHash({
135+ sourceId: input.source.id,
136+ toolId: input.operation.providerData.toolId,
137+ protocol: "graphql",
138+ })}`,
139+ );
140+ const callShapeId =
141+ input.operation.inputSchema !== undefined
142+ ? input.importer.importSchema(
143+ input.operation.inputSchema,
144+ `#/graphql/${input.operation.providerData.toolId}/call`,
145+ )
146+ : input.importer.importSchema(
147+ {
148+ type: "object",
149+ additionalProperties: true,
150+ },
151+ `#/graphql/${input.operation.providerData.toolId}/call`,
152+ );
153+ const resultDataShapeId =
154+ input.operation.outputSchema !== undefined
155+ ? input.importer.importSchema(
156+ graphqlProjectedResultDataSchema({
157+ toolKind: input.operation.providerData.toolKind,
158+ outputSchema: input.operation.outputSchema,
159+ }),
160+ `#/graphql/${input.operation.providerData.toolId}/data`,
161+ )
162+ : input.importer.importSchema(
163+ {
164+ type: "object",
165+ additionalProperties: true,
166+ },
167+ `#/graphql/${input.operation.providerData.toolId}/data`,
168+ );
169+ const resultErrorShapeId = input.importer.importSchema(
170+ graphqlProjectedResultErrorSchema({
171+ toolKind: input.operation.providerData.toolKind,
172+ outputSchema: input.operation.outputSchema,
173+ }),
174+ `#/graphql/${input.operation.providerData.toolId}/errors`,
175+ );
176+
177+ const responseId = ResponseSymbolIdSchema.make(
178+ `response_${stableHash({ capabilityId })}`,
179+ );
180+ mutableRecord(input.catalog.symbols)[responseId] = {
181+ id: responseId,
182+ kind: "response",
183+ ...(docsFrom({
184+ description: input.operation.description,
185+ })
186+ ? {
187+ docs: docsFrom({
188+ description: input.operation.description,
189+ })!,
190+ }
191+ : {}),
192+ contents: [
193+ {
194+ mediaType: "application/json",
195+ shapeId: resultDataShapeId,
196+ },
197+ ],
198+ synthetic: false,
199+ provenance: provenanceFor(
200+ input.documentId,
201+ `#/graphql/${input.operation.providerData.toolId}/response`,
202+ ),
203+ } satisfies ResponseSymbol;
204+
205+ const responseSetId = responseSetFromSingleResponse({
206+ catalog: input.catalog,
207+ responseId,
208+ provenance: provenanceFor(
209+ input.documentId,
210+ `#/graphql/${input.operation.providerData.toolId}/responseSet`,
211+ ),
212+ });
213+
214+ mutableRecord(input.catalog.executables)[executableId] = {
215+ id: executableId,
216+ capabilityId,
217+ scopeId: input.serviceScopeId,
218+ pluginKey: "graphql",
219+ bindingVersion: EXECUTABLE_BINDING_VERSION,
220+ binding: input.operation.providerData,
221+ projection: {
222+ responseSetId,
223+ callShapeId,
224+ resultDataShapeId,
225+ resultErrorShapeId,
226+ },
227+ display: {
228+ protocol: "graphql",
229+ method: input.operation.providerData.operationType ?? "query",
230+ pathTemplate:
231+ input.operation.providerData.fieldName ??
232+ input.operation.providerData.leaf ??
233+ input.operation.providerData.toolId,
234+ operationId:
235+ input.operation.providerData.fieldName ??
236+ input.operation.providerData.leaf ??
237+ input.operation.providerData.toolId,
238+ group: input.operation.providerData.group,
239+ leaf: input.operation.providerData.leaf,
240+ rawToolId: input.operation.providerData.toolId,
241+ title: input.operation.title ?? null,
242+ summary: input.operation.description ?? null,
243+ },
244+ synthetic: false,
245+ provenance: provenanceFor(
246+ input.documentId,
247+ `#/graphql/${input.operation.providerData.toolId}/executable`,
248+ ),
249+ } satisfies Executable;
250+
251+ const effect = input.operation.effect;
252+
253+ mutableRecord(input.catalog.capabilities)[capabilityId] = {
254+ id: capabilityId,
255+ serviceScopeId: input.serviceScopeId,
256+ surface: {
257+ toolPath,
258+ ...(input.operation.title ? { title: input.operation.title } : {}),
259+ ...(input.operation.description
260+ ? { summary: input.operation.description }
261+ : {}),
262+ ...(input.operation.providerData.group
263+ ? { tags: [input.operation.providerData.group] }
264+ : {}),
265+ },
266+ semantics: {
267+ effect,
268+ safe: effect === "read",
269+ idempotent: effect === "read",
270+ destructive: false,
271+ },
272+ auth: { kind: "none" },
273+ interaction: interactionForEffect(effect),
274+ executableIds: [executableId],
275+ synthetic: false,
276+ provenance: provenanceFor(
277+ input.documentId,
278+ `#/graphql/${input.operation.providerData.toolId}/capability`,
279+ ),
280+ } satisfies Capability;
281+};
282+
283+export const createGraphqlCatalogFragment = (input: {
284+ source: Source;
285+ documents: readonly CatalogSourceDocumentInput[];
286+ operations: readonly GraphqlCatalogOperationInput[];
287+}) =>
288+ buildCatalogFragment({
289+ source: input.source,
290+ documents: input.documents,
291+ resourceDialectUri: "https://spec.graphql.org/",
292+ registerOperations: ({ catalog, documentId, serviceScopeId, importer }) => {
293+ for (const operation of input.operations) {
294+ createGraphqlCapability({
295+ catalog,
296+ source: input.source,
297+ documentId,
298+ serviceScopeId,
299+ operation,
300+ importer,
301+ });
302+ }
303+ },
304+ });
305+
306+export const createGraphqlCatalogSnapshot = (input: {
307+ source: Source;
308+ documents: readonly CatalogSourceDocumentInput[];
309+ operations: readonly GraphqlCatalogOperationInput[];
310+}): CatalogSnapshotV1 =>
311+ createCatalogSnapshotV1FromFragments({
312+ import: createCatalogImportMetadata({
313+ source: input.source,
314+ pluginKey: "graphql",
315+ }),
316+ fragments: [createGraphqlCatalogFragment(input)],
317+ });
plugins/graphql/sdk/discovery.ts+107−0
@@ -0,0 +1,107 @@
1+import {
2+ defaultNameFromEndpoint,
3+ executeHttpProbe,
4+ isRecord,
5+ namespaceFromSourceName,
6+ noneAuthInference,
7+ parseChallengeAuthInference,
8+ tryParseJson,
9+ type SourceDiscoveryProbeInput,
10+ type SourceDiscoveryResult,
11+} from "@executor/source-core";
12+import * as Either from "effect/Either";
13+import * as Effect from "effect/Effect";
14+
15+import { GRAPHQL_INTROSPECTION_QUERY } from "./graphql-tools";
16+
17+const looksLikeGraphqlEndpoint = (value: string): boolean => {
18+ try {
19+ const url = new URL(value);
20+ return /graphql/i.test(`${url.hostname}${url.pathname}`);
21+ } catch {
22+ return /graphql/i.test(value);
23+ }
24+};
25+
26+export const detectGraphqlSource = (
27+ input: SourceDiscoveryProbeInput,
28+): Effect.Effect<SourceDiscoveryResult | null, never, never> =>
29+ Effect.gen(function* () {
30+ const response = yield* Effect.either(executeHttpProbe({
31+ method: "POST",
32+ url: input.normalizedUrl,
33+ headers: {
34+ accept: "application/graphql-response+json, application/json",
35+ "content-type": "application/json",
36+ ...input.headers,
37+ },
38+ body: JSON.stringify({ query: GRAPHQL_INTROSPECTION_QUERY }),
39+ }));
40+
41+ if (Either.isLeft(response)) {
42+ return null;
43+ }
44+
45+ const parsed = tryParseJson(response.right.text);
46+ const contentType = (response.right.headers["content-type"] ?? "").toLowerCase();
47+ const data = isRecord(parsed) && isRecord(parsed.data) ? parsed.data : null;
48+ if (data && isRecord(data.__schema)) {
49+ const name = defaultNameFromEndpoint(input.normalizedUrl);
50+ return {
51+ detectedKind: "plugin",
52+ confidence: "high",
53+ endpoint: input.normalizedUrl,
54+ specUrl: null,
55+ name,
56+ namespace: namespaceFromSourceName(name),
57+ transport: null,
58+ authInference: noneAuthInference(
59+ "GraphQL introspection succeeded without an advertised auth requirement",
60+ "medium",
61+ ),
62+ toolCount: null,
63+ warnings: [],
64+ } satisfies SourceDiscoveryResult;
65+ }
66+
67+ const errors = isRecord(parsed) && Array.isArray(parsed.errors)
68+ ? parsed.errors
69+ : [];
70+ const graphqlErrors = errors
71+ .map((error) => isRecord(error) ? (typeof error.message === "string" ? error.message : null) : null)
72+ .filter((message): message is string => message !== null);
73+
74+ const mediumConfidenceGraphql =
75+ contentType.includes("application/graphql-response+json")
76+ || (looksLikeGraphqlEndpoint(input.normalizedUrl) && response.right.status >= 400 && response.right.status < 500)
77+ || graphqlErrors.some((message) => /introspection|graphql|query/i.test(message));
78+
79+ if (!mediumConfidenceGraphql) {
80+ return null;
81+ }
82+
83+ const name = defaultNameFromEndpoint(input.normalizedUrl);
84+ return {
85+ detectedKind: "plugin",
86+ confidence: data ? "high" : "medium",
87+ endpoint: input.normalizedUrl,
88+ specUrl: null,
89+ name,
90+ namespace: namespaceFromSourceName(name),
91+ transport: null,
92+ authInference:
93+ response.right.status === 401 || response.right.status === 403
94+ ? parseChallengeAuthInference(
95+ response.right.headers,
96+ "GraphQL endpoint rejected introspection and did not advertise a concrete auth scheme",
97+ )
98+ : noneAuthInference(
99+ graphqlErrors.length > 0
100+ ? `GraphQL endpoint responded with errors during introspection: ${graphqlErrors[0]}`
101+ : "GraphQL endpoint shape detected",
102+ "medium",
103+ ),
104+ toolCount: null,
105+ warnings: graphqlErrors.length > 0 ? [graphqlErrors[0]!] : [],
106+ } satisfies SourceDiscoveryResult;
107+ }).pipe(Effect.catchAll(() => Effect.succeed(null)));
plugins/graphql/sdk/graphql-tools.ts+1737−0
@@ -0,0 +1,1737 @@
1+import {
2+ applyCookiePlacementsToHeaders,
3+ applyHttpQueryPlacementsToUrl,
4+ applyJsonBodyPlacements,
5+ sha256Hex,
6+ standardSchemaFromJsonSchema,
7+ toTool,
8+ type HttpRequestPlacements,
9+ type ToolDescriptor,
10+ type ToolMap,
11+ type ToolMetadata,
12+ type ToolPath,
13+ typeSignatureFromSchema,
14+} from "@executor/codemode-core";
15+import { sourceCoreEffectError } from "@executor/source-core";
16+import {
17+ GraphqlToolProviderDataSchema,
18+ type GraphqlToolProviderData,
19+} from "./provider-data";
20+import * as Either from "effect/Either";
21+import * as Effect from "effect/Effect";
22+import * as Schema from "effect/Schema";
23+import {
24+ buildClientSchema,
25+ getIntrospectionQuery,
26+ getNamedType,
27+ isEnumType,
28+ isInputObjectType,
29+ isInterfaceType,
30+ isLeafType,
31+ isListType,
32+ isNonNullType,
33+ isObjectType,
34+ isScalarType,
35+ isUnionType,
36+ type GraphQLArgument,
37+ type GraphQLInputObjectType,
38+ type GraphQLInputType,
39+ type GraphQLNamedType,
40+ type GraphQLOutputType,
41+ type GraphQLSchema,
42+ type IntrospectionQuery,
43+} from "graphql";
44+import * as HashMap from "effect/HashMap";
45+import * as Option from "effect/Option";
46+
47+type JsonSchema = Record<string, unknown>;
48+type GraphqlSchemaRefTable = Record<string, string>;
49+
50+type GraphqlOperationType = "query" | "mutation";
51+
52+const GRAPHQL_PRESENTATION_TYPE_MAX_LENGTH = 320;
53+
54+type GraphqlManifestToolBase = {
55+ toolId: string;
56+ rawToolId: string | null;
57+ toolName: string;
58+ description: string | null;
59+ inputSchema?: JsonSchema;
60+ outputSchema?: JsonSchema;
61+ exampleInput?: unknown;
62+};
63+
64+export type GraphqlRequestToolManifestEntry = GraphqlManifestToolBase & {
65+ kind: "request";
66+};
67+
68+export type GraphqlFieldToolManifestEntry = GraphqlManifestToolBase & {
69+ kind: "field";
70+ group: GraphqlOperationType;
71+ leaf: string;
72+ fieldName: string;
73+ operationType: GraphqlOperationType;
74+ operationName: string;
75+ operationDocument: string;
76+ searchTerms: readonly string[];
77+};
78+
79+export type GraphqlToolManifestEntry =
80+ | GraphqlRequestToolManifestEntry
81+ | GraphqlFieldToolManifestEntry;
82+
83+export type GraphqlToolManifest = {
84+ version: 2;
85+ sourceHash: string;
86+ queryTypeName: string | null;
87+ mutationTypeName: string | null;
88+ subscriptionTypeName: string | null;
89+ schemaRefTable?: GraphqlSchemaRefTable;
90+ tools: readonly GraphqlToolManifestEntry[];
91+};
92+
93+export type GraphqlToolDefinition = {
94+ toolId: string;
95+ rawToolId: string | null;
96+ name: string;
97+ description: string;
98+ group: string | null;
99+ leaf: string | null;
100+ fieldName: string | null;
101+ operationType: GraphqlOperationType | null;
102+ operationName: string | null;
103+ operationDocument: string | null;
104+ searchTerms: readonly string[];
105+};
106+
107+export type GraphqlToolPresentation = {
108+ inputTypePreview: string;
109+ outputTypePreview: string;
110+ inputSchema?: JsonSchema;
111+ outputSchema?: JsonSchema;
112+ exampleInput?: unknown;
113+ providerData: GraphqlToolProviderData;
114+};
115+
116+type GraphqlToolPresentationResolver = {
117+ resolve(definition: GraphqlToolDefinition): GraphqlToolPresentation;
118+};
119+
120+type SelectedGraphqlOutput = {
121+ selectionSet: string;
122+ schema: JsonSchema;
123+};
124+
125+type GraphqlHttpInvocation = {
126+ endpoint: string;
127+ path: string;
128+ defaultHeaders?: Readonly<Record<string, string>>;
129+ credentialHeaders?: Readonly<Record<string, string>>;
130+ credentialPlacements?: HttpRequestPlacements;
131+ query: string;
132+ variables?: Record<string, unknown>;
133+ operationName?: string;
134+ requestHeaders?: Record<string, string>;
135+};
136+
137+const BLOCKED_RESPONSE_HEADER_NAMES = new Set([
138+ "authorization",
139+ "authentication-info",
140+ "cookie",
141+ "proxy-authenticate",
142+ "proxy-authorization",
143+ "set-cookie",
144+ "set-cookie2",
145+ "www-authenticate",
146+ "x-api-key",
147+ "x-auth-token",
148+ "x-csrf-token",
149+]);
150+
151+const PREFERRED_LEAF_FIELD_NAMES = [
152+ "id",
153+ "identifier",
154+ "key",
155+ "slug",
156+ "name",
157+ "title",
158+ "number",
159+ "url",
160+ "state",
161+ "status",
162+ "success",
163+] as const;
164+
165+const PREFERRED_NESTED_FIELD_NAMES = [
166+ "node",
167+ "nodes",
168+ "edge",
169+ "edges",
170+ "pageInfo",
171+ "viewer",
172+ "user",
173+ "users",
174+ "team",
175+ "teams",
176+ "project",
177+ "projects",
178+ "organization",
179+ "issue",
180+ "issues",
181+ "creator",
182+ "assignee",
183+ "items",
184+] as const;
185+
186+const asToolPath = (value: string): ToolPath => value as ToolPath;
187+
188+const asRecord = (value: unknown): Record<string, unknown> =>
189+ typeof value === "object" && value !== null && !Array.isArray(value)
190+ ? (value as Record<string, unknown>)
191+ : {};
192+
193+const asString = (value: unknown): string | null =>
194+ typeof value === "string" && value.trim().length > 0 ? value : null;
195+
196+const asStringRecord = (value: unknown): Record<string, string> => {
197+ const record = asRecord(value);
198+ const normalized: Record<string, string> = {};
199+
200+ for (const [key, entry] of Object.entries(record)) {
201+ if (typeof entry === "string") {
202+ normalized[key] = entry;
203+ }
204+ }
205+
206+ return normalized;
207+};
208+
209+const sanitizeResponseHeaders = (
210+ headers: Readonly<Record<string, string>>,
211+): Record<string, string> => {
212+ const sanitized: Record<string, string> = {};
213+
214+ for (const [rawName, rawValue] of Object.entries(headers)) {
215+ const name = rawName.trim().toLowerCase();
216+ if (name.length === 0 || BLOCKED_RESPONSE_HEADER_NAMES.has(name)) {
217+ continue;
218+ }
219+
220+ sanitized[name] = rawValue;
221+ }
222+
223+ return sanitized;
224+};
225+
226+const normalizeHttpUrl = (value: string): string => {
227+ const trimmed = value.trim();
228+ if (trimmed.length === 0) {
229+ throw new Error("GraphQL endpoint is empty");
230+ }
231+
232+ const parsed = new URL(trimmed);
233+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
234+ throw new Error("GraphQL endpoint must be http or https");
235+ }
236+
237+ return parsed.toString();
238+};
239+
240+const graphqlToolError = (message: string, cause?: unknown): Error =>
241+ new Error(cause instanceof Error ? `${message}: ${cause.message}` : message);
242+
243+const parseGraphqlResponseBody = async (
244+ response: Response,
245+): Promise<unknown> => {
246+ const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
247+ if (contentType.includes("application/json")) {
248+ return await response.json();
249+ }
250+
251+ return await response.text();
252+};
253+
254+const hasGraphqlErrors = (value: unknown): boolean =>
255+ Array.isArray(asRecord(value).errors);
256+
257+const splitWords = (value: string): Array<string> =>
258+ value
259+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
260+ .replace(/([A-Z]+)([A-Z][a-z0-9]+)/g, "$1 $2")
261+ .replace(/[^a-zA-Z0-9]+/g, " ")
262+ .trim()
263+ .split(/\s+/)
264+ .filter((part) => part.length > 0);
265+
266+const toCamelCase = (value: string): string => {
267+ const words = splitWords(value).map((part) => part.toLowerCase());
268+ if (words.length === 0) {
269+ return "tool";
270+ }
271+
272+ const [first, ...rest] = words;
273+ return `${first}${rest.map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`).join("")}`;
274+};
275+
276+const toPascalCase = (value: string): string => {
277+ const camel = toCamelCase(value);
278+ return `${camel[0]?.toUpperCase() ?? ""}${camel.slice(1)}`;
279+};
280+
281+const toTitleCase = (value: string): string =>
282+ splitWords(value)
283+ .map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`)
284+ .join(" ");
285+
286+const withSchemaDescription = (
287+ schema: JsonSchema,
288+ description: string | null | undefined,
289+): JsonSchema => {
290+ const trimmed = description?.trim();
291+ return trimmed ? { ...schema, description: trimmed } : schema;
292+};
293+
294+const withSchemaDefault = (schema: JsonSchema, value: unknown): JsonSchema =>
295+ value !== undefined ? { ...schema, default: value } : schema;
296+
297+const withSchemaDeprecation = (
298+ schema: JsonSchema,
299+ deprecationReason: string | null | undefined,
300+): JsonSchema => {
301+ const trimmed = deprecationReason?.trim();
302+ return trimmed
303+ ? { ...schema, deprecated: true, "x-deprecationReason": trimmed }
304+ : schema;
305+};
306+
307+const introspectionQueryFromDocument = (
308+ documentText: string,
309+): IntrospectionQuery => {
310+ let parsed: unknown;
311+ try {
312+ parsed = JSON.parse(documentText) as unknown;
313+ } catch (cause) {
314+ throw new Error(
315+ `GraphQL document is not valid JSON: ${cause instanceof Error ? cause.message : String(cause)}`,
316+ );
317+ }
318+
319+ const record = asRecord(parsed);
320+ if (Array.isArray(record.errors) && record.errors.length > 0) {
321+ const messages = record.errors
322+ .map((entry) => asString(asRecord(entry).message))
323+ .filter((message): message is string => message !== null);
324+ throw new Error(
325+ messages.length > 0
326+ ? `GraphQL introspection returned errors: ${messages.join("; ")}`
327+ : "GraphQL introspection returned errors",
328+ );
329+ }
330+
331+ const data = record.data;
332+ if (!data || typeof data !== "object" || !("__schema" in data)) {
333+ throw new Error("GraphQL introspection document is missing data.__schema");
334+ }
335+
336+ return data as IntrospectionQuery;
337+};
338+
339+const GRAPHQL_REQUEST_INPUT_SCHEMA = {
340+ type: "object",
341+ properties: {
342+ query: {
343+ type: "string",
344+ description: "GraphQL query or mutation document.",
345+ },
346+ variables: {
347+ type: "object",
348+ description: "Optional GraphQL variables.",
349+ additionalProperties: true,
350+ },
351+ operationName: {
352+ type: "string",
353+ description: "Optional GraphQL operation name.",
354+ },
355+ headers: {
356+ type: "object",
357+ description: "Optional per-request headers.",
358+ additionalProperties: {
359+ type: "string",
360+ },
361+ },
362+ },
363+ required: ["query"],
364+ additionalProperties: false,
365+} as const;
366+
367+const GRAPHQL_REQUEST_OUTPUT_SCHEMA = {
368+ type: "object",
369+ properties: {
370+ status: {
371+ type: "number",
372+ },
373+ headers: {
374+ type: "object",
375+ additionalProperties: {
376+ type: "string",
377+ },
378+ },
379+ body: {},
380+ },
381+ required: ["status", "headers", "body"],
382+ additionalProperties: false,
383+} as const;
384+
385+export const GRAPHQL_INTROSPECTION_QUERY = getIntrospectionQuery({
386+ descriptions: true,
387+ inputValueDeprecation: true,
388+ schemaDescription: true,
389+});
390+
391+const scalarInputSchema = (name: string): JsonSchema => {
392+ switch (name) {
393+ case "String":
394+ case "ID":
395+ case "Date":
396+ case "DateTime":
397+ case "DateTimeOrDuration":
398+ case "Duration":
399+ case "UUID":
400+ case "TimelessDate":
401+ case "TimelessDateOrDuration":
402+ case "URI":
403+ return { type: "string" };
404+ case "Int":
405+ case "Float":
406+ return { type: "number" };
407+ case "Boolean":
408+ return { type: "boolean" };
409+ case "JSONObject":
410+ return {
411+ type: "object",
412+ additionalProperties: true,
413+ };
414+ case "JSONString":
415+ return { type: "string" };
416+ case "JSON":
417+ return {};
418+ default:
419+ return {};
420+ }
421+};
422+
423+const scalarExampleValue = (name: string): unknown => {
424+ switch (name) {
425+ case "String":
426+ return "value";
427+ case "ID":
428+ return "id";
429+ case "Date":
430+ return "2026-03-08";
431+ case "DateTime":
432+ case "DateTimeOrDuration":
433+ return "2026-03-08T00:00:00.000Z";
434+ case "UUID":
435+ return "00000000-0000-0000-0000-000000000000";
436+ case "TimelessDate":
437+ case "TimelessDateOrDuration":
438+ return "2026-03-08";
439+ case "Duration":
440+ return "P1D";
441+ case "URI":
442+ return "https://example.com";
443+ case "Int":
444+ return 1;
445+ case "Float":
446+ return 1.5;
447+ case "Boolean":
448+ return true;
449+ case "JSONObject":
450+ return {};
451+ case "JSONString":
452+ return "{}";
453+ default:
454+ return {};
455+ }
456+};
457+
458+const GRAPHQL_SCHEMA_REF_PREFIX = "#/$defs/graphql";
459+
460+const graphqlScalarRef = (name: string): string =>
461+ `${GRAPHQL_SCHEMA_REF_PREFIX}/scalars/${name}`;
462+
463+const graphqlEnumRef = (name: string): string =>
464+ `${GRAPHQL_SCHEMA_REF_PREFIX}/enums/${name}`;
465+
466+const graphqlInputObjectRef = (name: string): string =>
467+ `${GRAPHQL_SCHEMA_REF_PREFIX}/input/${name}`;
468+
469+const graphqlOutputTypeRef = (name: string, depth: number): string =>
470+ `${GRAPHQL_SCHEMA_REF_PREFIX}/output/${depth === 0 ? name : `${name}__depth${depth}`}`;
471+
472+const graphqlTypenameOnlyOutputRef = (): string =>
473+ `${GRAPHQL_SCHEMA_REF_PREFIX}/output/GraphqlTypenameOnly`;
474+
475+const schemaRef = (ref: string): JsonSchema => ({
476+ $ref: ref,
477+});
478+
479+const typenameOnlyObjectSchema = (): JsonSchema => ({
480+ type: "object",
481+ properties: {
482+ __typename: { type: "string" },
483+ },
484+ required: ["__typename"],
485+ additionalProperties: false,
486+});
487+
488+const createGraphqlSchemaRefTableBuilder = () => {
489+ const refTable: GraphqlSchemaRefTable = {};
490+ const outputSelectionsByRef = new Map<string, SelectedGraphqlOutput>();
491+
492+ const defineRefSchema = (ref: string, schema: JsonSchema): void => {
493+ refTable[ref] = JSON.stringify(schema);
494+ };
495+
496+ const ensureTypenameOnlyOutputRef = (): JsonSchema => {
497+ const ref = graphqlTypenameOnlyOutputRef();
498+ if (!(ref in refTable)) {
499+ defineRefSchema(ref, typenameOnlyObjectSchema());
500+ }
501+
502+ return schemaRef(ref);
503+ };
504+
505+ const ensureScalarRef = (name: string): JsonSchema => {
506+ const ref = graphqlScalarRef(name);
507+ if (!(ref in refTable)) {
508+ defineRefSchema(ref, scalarInputSchema(name));
509+ }
510+
511+ return schemaRef(ref);
512+ };
513+
514+ const ensureEnumRef = (
515+ name: string,
516+ values: readonly string[],
517+ ): JsonSchema => {
518+ const ref = graphqlEnumRef(name);
519+ if (!(ref in refTable)) {
520+ defineRefSchema(ref, {
521+ type: "string",
522+ enum: [...values],
523+ });
524+ }
525+
526+ return schemaRef(ref);
527+ };
528+
529+ const ensureInputObjectRef = (
530+ namedType: GraphQLInputObjectType,
531+ ): JsonSchema => {
532+ const ref = graphqlInputObjectRef(namedType.name);
533+ if (!(ref in refTable)) {
534+ defineRefSchema(ref, {});
535+
536+ const fields = Object.values(namedType.getFields());
537+ const properties = Object.fromEntries(
538+ fields.map((field) => {
539+ const schema = inputSchemaForType(field.type);
540+ const enriched = withSchemaDeprecation(
541+ withSchemaDefault(
542+ withSchemaDescription(schema, field.description),
543+ field.defaultValue,
544+ ),
545+ field.deprecationReason,
546+ );
547+ return [field.name, enriched];
548+ }),
549+ );
550+ const required = fields
551+ .filter(
552+ (field) =>
553+ isNonNullType(field.type) && field.defaultValue === undefined,
554+ )
555+ .map((field) => field.name);
556+
557+ defineRefSchema(ref, {
558+ type: "object",
559+ properties,
560+ ...(required.length > 0 ? { required } : {}),
561+ additionalProperties: false,
562+ });
563+ }
564+
565+ return schemaRef(ref);
566+ };
567+
568+ const inputSchemaForType = (type: GraphQLInputType): JsonSchema => {
569+ if (isNonNullType(type)) {
570+ return inputSchemaForType(type.ofType);
571+ }
572+
573+ if (isListType(type)) {
574+ return {
575+ type: "array",
576+ items: inputSchemaForType(type.ofType),
577+ };
578+ }
579+
580+ const namedType = getNamedType(type);
581+ if (isScalarType(namedType)) {
582+ return ensureScalarRef(namedType.name);
583+ }
584+
585+ if (isEnumType(namedType)) {
586+ return ensureEnumRef(
587+ namedType.name,
588+ namedType.getValues().map((value) => value.name),
589+ );
590+ }
591+
592+ if (isInputObjectType(namedType)) {
593+ return ensureInputObjectRef(namedType);
594+ }
595+
596+ return {};
597+ };
598+
599+ const selectedOutputForNamedType = (
600+ namedType: GraphQLNamedType,
601+ depth: number,
602+ ): SelectedGraphqlOutput => {
603+ if (isScalarType(namedType)) {
604+ return {
605+ selectionSet: "",
606+ schema: ensureScalarRef(namedType.name),
607+ };
608+ }
609+
610+ if (isEnumType(namedType)) {
611+ return {
612+ selectionSet: "",
613+ schema: ensureEnumRef(
614+ namedType.name,
615+ namedType.getValues().map((value) => value.name),
616+ ),
617+ };
618+ }
619+
620+ const ref = graphqlOutputTypeRef(namedType.name, depth);
621+ const cached = outputSelectionsByRef.get(ref);
622+ if (cached) {
623+ return cached;
624+ }
625+
626+ if (isUnionType(namedType)) {
627+ const result = {
628+ selectionSet: "{ __typename }",
629+ schema: ensureTypenameOnlyOutputRef(),
630+ } satisfies SelectedGraphqlOutput;
631+ outputSelectionsByRef.set(ref, result);
632+ return result;
633+ }
634+
635+ if (!isObjectType(namedType) && !isInterfaceType(namedType)) {
636+ return {
637+ selectionSet: "{ __typename }",
638+ schema: ensureTypenameOnlyOutputRef(),
639+ };
640+ }
641+
642+ const fallback = {
643+ selectionSet: "{ __typename }",
644+ schema: ensureTypenameOnlyOutputRef(),
645+ } satisfies SelectedGraphqlOutput;
646+ outputSelectionsByRef.set(ref, fallback);
647+
648+ if (depth >= 2) {
649+ return fallback;
650+ }
651+
652+ const fields = Object.values(namedType.getFields()).filter(
653+ (field) => !field.name.startsWith("__"),
654+ );
655+ const leafFields = fields.filter((field) =>
656+ isLeafType(getNamedType(field.type)),
657+ );
658+ const nestedFields = fields.filter(
659+ (field) => !isLeafType(getNamedType(field.type)),
660+ );
661+
662+ const selectedLeafFields = pickPreferredFields({
663+ fields: leafFields,
664+ preferredNames: PREFERRED_LEAF_FIELD_NAMES,
665+ limit: 3,
666+ });
667+ const selectedNestedFields = pickPreferredFields({
668+ fields: nestedFields,
669+ preferredNames: PREFERRED_NESTED_FIELD_NAMES,
670+ limit: 2,
671+ });
672+ const selectedFields = dedupeFields([
673+ ...selectedLeafFields,
674+ ...selectedNestedFields,
675+ ]);
676+
677+ if (selectedFields.length === 0) {
678+ return fallback;
679+ }
680+
681+ const selectionParts: string[] = [];
682+ const properties: Record<string, JsonSchema> = {};
683+ const required: string[] = [];
684+
685+ for (const field of selectedFields) {
686+ const selected = selectedOutputForType(field.type, depth + 1);
687+ const propertySchema = withSchemaDeprecation(
688+ withSchemaDescription(selected.schema, field.description),
689+ field.deprecationReason,
690+ );
691+
692+ properties[field.name] = propertySchema;
693+ required.push(field.name);
694+ selectionParts.push(
695+ selected.selectionSet.length > 0
696+ ? `${field.name} ${selected.selectionSet}`
697+ : field.name,
698+ );
699+ }
700+
701+ properties.__typename = { type: "string" };
702+ required.push("__typename");
703+ selectionParts.push("__typename");
704+
705+ defineRefSchema(ref, {
706+ type: "object",
707+ properties,
708+ required,
709+ additionalProperties: false,
710+ });
711+
712+ const result = {
713+ selectionSet: `{ ${selectionParts.join(" ")} }`,
714+ schema: schemaRef(ref),
715+ } satisfies SelectedGraphqlOutput;
716+ outputSelectionsByRef.set(ref, result);
717+ return result;
718+ };
719+
720+ const selectedOutputForType = (
721+ type: GraphQLOutputType,
722+ depth = 0,
723+ ): SelectedGraphqlOutput => {
724+ if (isNonNullType(type)) {
725+ return selectedOutputForType(type.ofType, depth);
726+ }
727+
728+ if (isListType(type)) {
729+ const selectedItem = selectedOutputForType(type.ofType, depth);
730+ return {
731+ selectionSet: selectedItem.selectionSet,
732+ schema: {
733+ type: "array",
734+ items: selectedItem.schema,
735+ },
736+ };
737+ }
738+
739+ return selectedOutputForNamedType(getNamedType(type), depth);
740+ };
741+
742+ return {
743+ refTable,
744+ inputSchemaForType,
745+ selectedOutputForType,
746+ };
747+};
748+
749+const exampleValueForGraphqlInputType = (
750+ type: GraphQLInputType,
751+ depth = 0,
752+): unknown => {
753+ if (isNonNullType(type)) {
754+ return exampleValueForGraphqlInputType(type.ofType, depth);
755+ }
756+
757+ if (isListType(type)) {
758+ return [exampleValueForGraphqlInputType(type.ofType, depth + 1)];
759+ }
760+
761+ const namedType = getNamedType(type);
762+ if (isScalarType(namedType)) {
763+ return scalarExampleValue(namedType.name);
764+ }
765+
766+ if (isEnumType(namedType)) {
767+ return namedType.getValues()[0]?.name ?? "VALUE";
768+ }
769+
770+ if (isInputObjectType(namedType)) {
771+ if (depth >= 2) {
772+ return {};
773+ }
774+
775+ const fields = Object.values(namedType.getFields());
776+ const requiredFields = fields.filter(
777+ (field) => isNonNullType(field.type) && field.defaultValue === undefined,
778+ );
779+ const fieldsForExample =
780+ requiredFields.length > 0 ? requiredFields : fields.slice(0, 1);
781+
782+ return Object.fromEntries(
783+ fieldsForExample.map((field) => [
784+ field.name,
785+ field.defaultValue ??
786+ exampleValueForGraphqlInputType(field.type, depth + 1),
787+ ]),
788+ );
789+ }
790+
791+ return {};
792+};
793+
794+const dedupeFields = <T extends { name: string }>(
795+ fields: readonly T[],
796+): T[] => {
797+ const seen = new Set<string>();
798+ const deduped: T[] = [];
799+
800+ for (const field of fields) {
801+ if (seen.has(field.name)) {
802+ continue;
803+ }
804+
805+ seen.add(field.name);
806+ deduped.push(field);
807+ }
808+
809+ return deduped;
810+};
811+
812+const pickPreferredFields = <T extends { name: string }>(input: {
813+ fields: readonly T[];
814+ preferredNames: readonly string[];
815+ limit: number;
816+}): T[] => {
817+ const matches = input.preferredNames
818+ .map((name) => input.fields.find((field) => field.name === name))
819+ .filter((field): field is T => field !== undefined);
820+
821+ if (matches.length >= input.limit) {
822+ return dedupeFields(matches).slice(0, input.limit);
823+ }
824+
825+ return dedupeFields([...matches, ...input.fields]).slice(0, input.limit);
826+};
827+
828+const printGraphqlType = (type: GraphQLInputType): string => {
829+ if (isNonNullType(type)) {
830+ return `${printGraphqlType(type.ofType)}!`;
831+ }
832+
833+ if (isListType(type)) {
834+ return `[${printGraphqlType(type.ofType)}]`;
835+ }
836+
837+ return type.name;
838+};
839+
840+const inputSchemaForFieldArguments = (
841+ args: readonly GraphQLArgument[],
842+ bundleBuilder: ReturnType<typeof createGraphqlSchemaRefTableBuilder>,
843+): JsonSchema => {
844+ const properties = Object.fromEntries(
845+ args.map((arg) => {
846+ const schema = bundleBuilder.inputSchemaForType(arg.type);
847+ const enriched = withSchemaDeprecation(
848+ withSchemaDefault(
849+ withSchemaDescription(schema, arg.description),
850+ arg.defaultValue,
851+ ),
852+ arg.deprecationReason,
853+ );
854+ return [arg.name, enriched];
855+ }),
856+ );
857+ const required = args
858+ .filter((arg) => isNonNullType(arg.type) && arg.defaultValue === undefined)
859+ .map((arg) => arg.name);
860+
861+ return {
862+ type: "object",
863+ properties: {
864+ ...properties,
865+ headers: {
866+ type: "object",
867+ description: "Optional per-request headers.",
868+ additionalProperties: {
869+ type: "string",
870+ },
871+ },
872+ },
873+ ...(required.length > 0 ? { required } : {}),
874+ additionalProperties: false,
875+ };
876+};
877+
878+const outputEnvelopeSchemaForField = (
879+ fieldType: GraphQLOutputType,
880+ bundleBuilder: ReturnType<typeof createGraphqlSchemaRefTableBuilder>,
881+): JsonSchema => {
882+ const selectedOutput = bundleBuilder.selectedOutputForType(fieldType);
883+
884+ return {
885+ type: "object",
886+ properties: {
887+ data: withSchemaDescription(
888+ selectedOutput.schema,
889+ "Value returned for the selected GraphQL field.",
890+ ),
891+ errors: {
892+ type: "array",
893+ items: {
894+ type: "object",
895+ properties: {
896+ message: {
897+ type: "string",
898+ description: "GraphQL error message.",
899+ },
900+ path: {
901+ type: "array",
902+ description: "Path to the field that produced the error.",
903+ items: {
904+ anyOf: [
905+ { type: "string" },
906+ { type: "number" },
907+ ],
908+ },
909+ },
910+ locations: {
911+ type: "array",
912+ description: "Source locations for the error in the GraphQL document.",
913+ items: {
914+ type: "object",
915+ properties: {
916+ line: { type: "number" },
917+ column: { type: "number" },
918+ },
919+ required: ["line", "column"],
920+ additionalProperties: false,
921+ },
922+ },
923+ extensions: {
924+ type: "object",
925+ description: "Additional provider-specific GraphQL error metadata.",
926+ additionalProperties: true,
927+ },
928+ },
929+ required: ["message"],
930+ additionalProperties: true,
931+ },
932+ },
933+ },
934+ required: ["data", "errors"],
935+ additionalProperties: false,
936+ };
937+};
938+
939+const exampleInputForField = (
940+ args: readonly GraphQLArgument[],
941+): Record<string, unknown> | undefined => {
942+ if (args.length === 0) {
943+ return {};
944+ }
945+
946+ const requiredArgs = args.filter(
947+ (arg) => isNonNullType(arg.type) && arg.defaultValue === undefined,
948+ );
949+ const argsForExample =
950+ requiredArgs.length > 0 ? requiredArgs : args.slice(0, 1);
951+ const example = Object.fromEntries(
952+ argsForExample.map((arg) => [
953+ arg.name,
954+ arg.defaultValue ?? exampleValueForGraphqlInputType(arg.type),
955+ ]),
956+ );
957+
958+ return example;
959+};
960+
961+const buildGraphqlFieldOperationDocument = (input: {
962+ operationType: GraphqlOperationType;
963+ fieldName: string;
964+ args: readonly GraphQLArgument[];
965+ fieldType: GraphQLOutputType;
966+ bundleBuilder: ReturnType<typeof createGraphqlSchemaRefTableBuilder>;
967+}): { operationName: string; operationDocument: string } => {
968+ const selectedOutput = input.bundleBuilder.selectedOutputForType(
969+ input.fieldType,
970+ );
971+ const operationName = `${toPascalCase(input.operationType)}${toPascalCase(input.fieldName)}`;
972+ const variableDefinitions = input.args
973+ .map((arg) => `$${arg.name}: ${printGraphqlType(arg.type)}`)
974+ .join(", ");
975+ const fieldArguments = input.args
976+ .map((arg) => `${arg.name}: $${arg.name}`)
977+ .join(", ");
978+ const fieldCall =
979+ fieldArguments.length > 0
980+ ? `${input.fieldName}(${fieldArguments})`
981+ : input.fieldName;
982+ const selectionSuffix =
983+ selectedOutput.selectionSet.length > 0
984+ ? ` ${selectedOutput.selectionSet}`
985+ : "";
986+
987+ return {
988+ operationName,
989+ operationDocument: `${input.operationType} ${operationName}${variableDefinitions.length > 0 ? `(${variableDefinitions})` : ""} { ${fieldCall}${selectionSuffix} }`,
990+ };
991+};
992+
993+const defaultDescriptionForField = (input: {
994+ operationType: GraphqlOperationType;
995+ fieldName: string;
996+ description: string | null | undefined;
997+}): string => {
998+ const trimmed = input.description?.trim();
999+ if (trimmed) {
1000+ return trimmed;
1001+ }
1002+
1003+ return `Execute the GraphQL ${input.operationType} field '${input.fieldName}'.`;
1004+};
1005+
1006+const requestToolManifestEntry = (
1007+ sourceName: string,
1008+): GraphqlRequestToolManifestEntry => ({
1009+ kind: "request",
1010+ toolId: "request",
1011+ rawToolId: "request",
1012+ toolName: "GraphQL request",
1013+ description: `Execute a raw GraphQL request against ${sourceName}.`,
1014+ inputSchema: GRAPHQL_REQUEST_INPUT_SCHEMA,
1015+ outputSchema: GRAPHQL_REQUEST_OUTPUT_SCHEMA,
1016+ exampleInput: {
1017+ query: "query { __typename }",
1018+ },
1019+});
1020+
1021+type GraphqlFieldToolDraft = Omit<GraphqlFieldToolManifestEntry, "toolId"> & {
1022+ toolId: string;
1023+};
1024+
1025+const resolveGraphqlFieldToolIds = (
1026+ drafts: readonly GraphqlFieldToolDraft[],
1027+): GraphqlFieldToolManifestEntry[] => {
1028+ const staged = drafts.map((draft) => ({ ...draft }));
1029+ const requestToolId = "request";
1030+
1031+ const applyDuplicates = (
1032+ factory: (draft: GraphqlFieldToolDraft) => string,
1033+ ): void => {
1034+ const buckets = new Map<string, GraphqlFieldToolDraft[]>();
1035+
1036+ for (const draft of staged) {
1037+ const bucket = buckets.get(draft.toolId) ?? [];
1038+ bucket.push(draft);
1039+ buckets.set(draft.toolId, bucket);
1040+ }
1041+
1042+ for (const [toolId, bucket] of buckets.entries()) {
1043+ if (bucket.length < 2 && toolId !== requestToolId) {
1044+ continue;
1045+ }
1046+
1047+ for (const draft of bucket) {
1048+ draft.toolId = factory(draft);
1049+ }
1050+ }
1051+ };
1052+
1053+ applyDuplicates(
1054+ (draft) => `${draft.leaf}${toPascalCase(draft.operationType)}`,
1055+ );
1056+ applyDuplicates(
1057+ (draft) =>
1058+ `${draft.leaf}${toPascalCase(draft.operationType)}${sha256Hex(`${draft.group}:${draft.fieldName}`).slice(0, 6)}`,
1059+ );
1060+
1061+ return staged
1062+ .sort(
1063+ (left, right) =>
1064+ left.toolId.localeCompare(right.toolId) ||
1065+ left.fieldName.localeCompare(right.fieldName) ||
1066+ left.operationType.localeCompare(right.operationType),
1067+ )
1068+ .map((draft) => ({ ...draft }));
1069+};
1070+
1071+const fieldToolDraftsFromRootType = (input: {
1072+ rootType:
1073+ | ReturnType<GraphQLSchema["getQueryType"]>
1074+ | ReturnType<GraphQLSchema["getMutationType"]>;
1075+ operationType: GraphqlOperationType;
1076+ bundleBuilder: ReturnType<typeof createGraphqlSchemaRefTableBuilder>;
1077+}): GraphqlFieldToolDraft[] => {
1078+ if (!input.rootType) {
1079+ return [];
1080+ }
1081+
1082+ return Object.values(input.rootType.getFields())
1083+ .filter((field) => !field.name.startsWith("__"))
1084+ .map((field) => {
1085+ const leaf = toCamelCase(field.name);
1086+ const outputSchema = outputEnvelopeSchemaForField(
1087+ field.type,
1088+ input.bundleBuilder,
1089+ );
1090+ const inputSchema = inputSchemaForFieldArguments(
1091+ field.args,
1092+ input.bundleBuilder,
1093+ );
1094+ const { operationName, operationDocument } =
1095+ buildGraphqlFieldOperationDocument({
1096+ operationType: input.operationType,
1097+ fieldName: field.name,
1098+ args: field.args,
1099+ fieldType: field.type,
1100+ bundleBuilder: input.bundleBuilder,
1101+ });
1102+
1103+ return {
1104+ kind: "field",
1105+ toolId: leaf,
1106+ rawToolId: field.name,
1107+ toolName: toTitleCase(field.name),
1108+ description: defaultDescriptionForField({
1109+ operationType: input.operationType,
1110+ fieldName: field.name,
1111+ description: field.description,
1112+ }),
1113+ group: input.operationType,
1114+ leaf,
1115+ fieldName: field.name,
1116+ operationType: input.operationType,
1117+ operationName,
1118+ operationDocument,
1119+ inputSchema,
1120+ outputSchema,
1121+ exampleInput: exampleInputForField(field.args),
1122+ searchTerms: [
1123+ input.operationType,
1124+ field.name,
1125+ ...field.args.map((arg) => arg.name),
1126+ getNamedType(field.type).name,
1127+ ],
1128+ } satisfies GraphqlFieldToolDraft;
1129+ });
1130+};
1131+
1132+export const createGraphqlSourceHash = (documentText: string): string =>
1133+ sha256Hex(documentText);
1134+
1135+export const extractGraphqlManifest = (
1136+ sourceName: string,
1137+ documentText: string,
1138+): Effect.Effect<GraphqlToolManifest, Error, never> =>
1139+ Effect.try({
1140+ try: () => {
1141+ const introspection = introspectionQueryFromDocument(documentText);
1142+ const schema = buildClientSchema(introspection);
1143+ const sourceHash = createGraphqlSourceHash(documentText);
1144+ const bundleBuilder = createGraphqlSchemaRefTableBuilder();
1145+ const fieldTools = resolveGraphqlFieldToolIds([
1146+ ...fieldToolDraftsFromRootType({
1147+ rootType: schema.getQueryType(),
1148+ operationType: "query",
1149+ bundleBuilder,
1150+ }),
1151+ ...fieldToolDraftsFromRootType({
1152+ rootType: schema.getMutationType(),
1153+ operationType: "mutation",
1154+ bundleBuilder,
1155+ }),
1156+ ]);
1157+
1158+ return {
1159+ version: 2,
1160+ sourceHash,
1161+ queryTypeName: schema.getQueryType()?.name ?? null,
1162+ mutationTypeName: schema.getMutationType()?.name ?? null,
1163+ subscriptionTypeName: schema.getSubscriptionType()?.name ?? null,
1164+ ...(Object.keys(bundleBuilder.refTable).length > 0
1165+ ? { schemaRefTable: bundleBuilder.refTable }
1166+ : {}),
1167+ tools: [...fieldTools, requestToolManifestEntry(sourceName)],
1168+ } satisfies GraphqlToolManifest;
1169+ },
1170+ catch: (cause) =>
1171+ cause instanceof Error ? cause : new Error(String(cause)),
1172+ });
1173+
1174+export const compileGraphqlToolDefinitions = (
1175+ manifest: GraphqlToolManifest,
1176+): Array<GraphqlToolDefinition> =>
1177+ manifest.tools.map((tool) => ({
1178+ toolId: tool.toolId,
1179+ rawToolId: tool.rawToolId,
1180+ name: tool.toolName,
1181+ description: tool.description ?? `Execute ${tool.toolName}.`,
1182+ group: tool.kind === "field" ? tool.group : null,
1183+ leaf: tool.kind === "field" ? tool.leaf : null,
1184+ fieldName: tool.kind === "field" ? tool.fieldName : null,
1185+ operationType: tool.kind === "field" ? tool.operationType : null,
1186+ operationName: tool.kind === "field" ? tool.operationName : null,
1187+ operationDocument: tool.kind === "field" ? tool.operationDocument : null,
1188+ searchTerms:
1189+ tool.kind === "field"
1190+ ? tool.searchTerms
1191+ : ["request", "graphql", "query", "mutation"],
1192+ }));
1193+
1194+const materializedSchemaRefDefinitions = (refTable?: GraphqlSchemaRefTable): JsonSchema | undefined => {
1195+ if (!refTable || Object.keys(refTable).length === 0) {
1196+ return undefined;
1197+ }
1198+
1199+ const defsRoot: JsonSchema = {};
1200+ for (const [ref, value] of Object.entries(refTable)) {
1201+ if (!ref.startsWith("#/$defs/")) {
1202+ continue;
1203+ }
1204+
1205+ const materializedValue = typeof value === "string"
1206+ ? (() => {
1207+ try {
1208+ return JSON.parse(value) as unknown;
1209+ } catch {
1210+ return value;
1211+ }
1212+ })()
1213+ : value;
1214+ const path = ref
1215+ .slice("#/$defs/".length)
1216+ .split("/")
1217+ .filter((segment) => segment.length > 0);
1218+ setNestedSchemaProperty(defsRoot, path, materializedValue);
1219+ }
1220+
1221+ return Object.keys(defsRoot).length > 0 ? defsRoot : undefined;
1222+};
1223+
1224+const materializeManifestSchema = (input: {
1225+ schema: JsonSchema | undefined;
1226+ defsRoot: JsonSchema | undefined;
1227+ cache: WeakMap<object, JsonSchema>;
1228+}): JsonSchema | undefined => {
1229+ if (input.schema === undefined) {
1230+ return undefined;
1231+ }
1232+
1233+ if (input.defsRoot === undefined) {
1234+ return input.schema;
1235+ }
1236+
1237+ const cached = input.cache.get(input.schema);
1238+ if (cached) {
1239+ return cached;
1240+ }
1241+
1242+ const existingDefs = input.schema.$defs;
1243+ const materialized = existingDefs && typeof existingDefs === "object" && !Array.isArray(existingDefs)
1244+ ? { ...input.schema, $defs: { ...input.defsRoot, ...existingDefs } }
1245+ : { ...input.schema, $defs: input.defsRoot };
1246+
1247+ input.cache.set(input.schema, materialized);
1248+ return materialized;
1249+};
1250+
1251+const graphqlToolPresentationResolverCache = new WeakMap<GraphqlToolManifest, GraphqlToolPresentationResolver>();
1252+
1253+const graphqlToolPresentationResolver = (
1254+ manifest: GraphqlToolManifest,
1255+): GraphqlToolPresentationResolver => {
1256+ const cached = graphqlToolPresentationResolverCache.get(manifest);
1257+ if (cached) {
1258+ return cached;
1259+ }
1260+
1261+ const toolEntriesById = HashMap.fromIterable(
1262+ manifest.tools.map((tool) => [tool.toolId, tool] as const),
1263+ );
1264+ const defsRoot = materializedSchemaRefDefinitions(manifest.schemaRefTable);
1265+ const schemaCache = new WeakMap<object, JsonSchema>();
1266+ const presentationCache = new Map<string, GraphqlToolPresentation>();
1267+
1268+ const resolver: GraphqlToolPresentationResolver = {
1269+ resolve(definition) {
1270+ const existing = presentationCache.get(definition.toolId);
1271+ if (existing) {
1272+ return existing;
1273+ }
1274+
1275+ const entry = Option.getOrUndefined(HashMap.get(toolEntriesById, definition.toolId));
1276+ const inputSchema = materializeManifestSchema({
1277+ schema: entry?.inputSchema,
1278+ defsRoot,
1279+ cache: schemaCache,
1280+ });
1281+ const outputSchema = materializeManifestSchema({
1282+ schema: entry?.outputSchema,
1283+ defsRoot,
1284+ cache: schemaCache,
1285+ });
1286+
1287+ const presentation = {
1288+ inputTypePreview: typeSignatureFromSchema(
1289+ inputSchema,
1290+ "unknown",
1291+ GRAPHQL_PRESENTATION_TYPE_MAX_LENGTH,
1292+ ),
1293+ outputTypePreview: typeSignatureFromSchema(
1294+ outputSchema,
1295+ "unknown",
1296+ GRAPHQL_PRESENTATION_TYPE_MAX_LENGTH,
1297+ ),
1298+ ...(inputSchema !== undefined ? { inputSchema } : {}),
1299+ ...(outputSchema !== undefined ? { outputSchema } : {}),
1300+ ...(entry?.exampleInput !== undefined
1301+ ? { exampleInput: entry.exampleInput }
1302+ : {}),
1303+ providerData: {
1304+ kind: "graphql",
1305+ toolKind: entry?.kind ?? "request",
1306+ toolId: definition.toolId,
1307+ rawToolId: definition.rawToolId,
1308+ group: definition.group,
1309+ leaf: definition.leaf,
1310+ fieldName: definition.fieldName,
1311+ operationType: definition.operationType,
1312+ operationName: definition.operationName,
1313+ operationDocument: definition.operationDocument,
1314+ queryTypeName: manifest.queryTypeName,
1315+ mutationTypeName: manifest.mutationTypeName,
1316+ subscriptionTypeName: manifest.subscriptionTypeName,
1317+ } satisfies GraphqlToolProviderData,
1318+ } satisfies GraphqlToolPresentation;
1319+
1320+ presentationCache.set(definition.toolId, presentation);
1321+ return presentation;
1322+ },
1323+ };
1324+
1325+ graphqlToolPresentationResolverCache.set(manifest, resolver);
1326+ return resolver;
1327+};
1328+
1329+export const buildGraphqlToolPresentation = (input: {
1330+ manifest: GraphqlToolManifest;
1331+ definition: GraphqlToolDefinition;
1332+}): GraphqlToolPresentation => {
1333+ return graphqlToolPresentationResolver(input.manifest).resolve(input.definition);
1334+};
1335+
1336+const decodeGraphqlToolProviderData = Schema.decodeUnknownEither(
1337+ GraphqlToolProviderDataSchema,
1338+);
1339+
1340+export const decodeGraphqlSchemaRefTableJson = Schema.decodeUnknownEither(
1341+ Schema.parseJson(
1342+ Schema.Record({
1343+ key: Schema.String,
1344+ value: Schema.Unknown,
1345+ }),
1346+ ),
1347+);
1348+
1349+const setNestedSchemaProperty = (
1350+ target: Record<string, unknown>,
1351+ path: readonly string[],
1352+ value: unknown,
1353+): void => {
1354+ if (path.length === 0) {
1355+ return;
1356+ }
1357+
1358+ const [head, ...rest] = path;
1359+ if (!head) {
1360+ return;
1361+ }
1362+
1363+ if (rest.length === 0) {
1364+ target[head] = value;
1365+ return;
1366+ }
1367+
1368+ const next = asRecord(target[head]);
1369+ target[head] = next;
1370+ setNestedSchemaProperty(next, rest, value);
1371+};
1372+
1373+const materializeSchemaWithRefDefinitions = (input: {
1374+ schema: unknown;
1375+ refTable?: Readonly<Record<string, unknown>>;
1376+}): Record<string, unknown> => {
1377+ if (input.schema === undefined || input.schema === null) {
1378+ return {};
1379+ }
1380+
1381+ const rootSchema = asRecord(input.schema);
1382+
1383+ if (!input.refTable || Object.keys(input.refTable).length === 0) {
1384+ return rootSchema;
1385+ }
1386+
1387+ const defsRoot = asRecord(rootSchema.$defs);
1388+ for (const [ref, value] of Object.entries(input.refTable)) {
1389+ if (!ref.startsWith("#/$defs/")) {
1390+ continue;
1391+ }
1392+
1393+ const materializedValue =
1394+ typeof value === "string"
1395+ ? (() => {
1396+ try {
1397+ return JSON.parse(value) as unknown;
1398+ } catch {
1399+ return value;
1400+ }
1401+ })()
1402+ : value;
1403+ const path = ref
1404+ .slice("#/$defs/".length)
1405+ .split("/")
1406+ .filter((segment) => segment.length > 0);
1407+ setNestedSchemaProperty(defsRoot, path, materializedValue);
1408+ }
1409+
1410+ return Object.keys(defsRoot).length > 0
1411+ ? { ...rootSchema, $defs: defsRoot }
1412+ : rootSchema;
1413+};
1414+
1415+export const createGraphqlToolFromPersistedOperation = (input: {
1416+ path: string;
1417+ sourceKey: string;
1418+ endpoint: string;
1419+ description?: string;
1420+ inputSchema?: unknown;
1421+ outputSchema?: unknown;
1422+ exampleInput?: unknown;
1423+ providerData: unknown;
1424+ schemaRefTable?: Readonly<Record<string, unknown>>;
1425+ defaultHeaders?: Readonly<Record<string, string>>;
1426+ credentialHeaders?: Readonly<Record<string, string>>;
1427+ credentialPlacements?: HttpRequestPlacements;
1428+}) => {
1429+ const decodedProviderData = decodeGraphqlToolProviderData(
1430+ input.providerData,
1431+ );
1432+ if (Either.isLeft(decodedProviderData)) {
1433+ throw new Error("Invalid GraphQL provider data");
1434+ }
1435+
1436+ const providerData = decodedProviderData.right;
1437+ const inputSchema = materializeSchemaWithRefDefinitions({
1438+ schema: input.inputSchema,
1439+ refTable: input.schemaRefTable,
1440+ });
1441+
1442+ const metadata: ToolMetadata = {
1443+ interaction:
1444+ providerData.toolKind === "request"
1445+ ? "auto"
1446+ : providerData.operationType === "query"
1447+ ? "auto"
1448+ : "required",
1449+ contract: {
1450+ inputTypePreview: typeSignatureFromSchema(
1451+ input.inputSchema,
1452+ "unknown",
1453+ GRAPHQL_PRESENTATION_TYPE_MAX_LENGTH,
1454+ ),
1455+ outputTypePreview: typeSignatureFromSchema(
1456+ input.outputSchema,
1457+ "unknown",
1458+ GRAPHQL_PRESENTATION_TYPE_MAX_LENGTH,
1459+ ),
1460+ ...(input.inputSchema !== undefined
1461+ ? { inputSchema: input.inputSchema }
1462+ : {}),
1463+ ...(input.outputSchema !== undefined
1464+ ? { outputSchema: input.outputSchema }
1465+ : {}),
1466+ ...(input.exampleInput !== undefined
1467+ ? { exampleInput: input.exampleInput }
1468+ : {}),
1469+ },
1470+ sourceKey: input.sourceKey,
1471+ pluginKind: "graphql",
1472+ pluginData: providerData,
1473+ };
1474+
1475+ return toTool({
1476+ tool: {
1477+ description: input.description,
1478+ inputSchema: standardSchemaFromJsonSchema(inputSchema),
1479+ execute: (args: unknown) => {
1480+ if (
1481+ providerData.toolKind === "field" &&
1482+ providerData.fieldName &&
1483+ providerData.operationType &&
1484+ providerData.operationName &&
1485+ providerData.operationDocument
1486+ ) {
1487+ return Effect.runPromise(
1488+ invokeGraphqlFieldTool({
1489+ entry: {
1490+ fieldName: providerData.fieldName,
1491+ operationName: providerData.operationName,
1492+ operationDocument: providerData.operationDocument,
1493+ },
1494+ endpoint: input.endpoint,
1495+ path: input.path,
1496+ defaultHeaders: input.defaultHeaders,
1497+ credentialHeaders: input.credentialHeaders,
1498+ credentialPlacements: input.credentialPlacements,
1499+ args,
1500+ }),
1501+ );
1502+ }
1503+
1504+ return Effect.runPromise(
1505+ invokeRawGraphqlTool({
1506+ endpoint: input.endpoint,
1507+ path: input.path,
1508+ defaultHeaders: input.defaultHeaders,
1509+ credentialHeaders: input.credentialHeaders,
1510+ credentialPlacements: input.credentialPlacements,
1511+ args,
1512+ }),
1513+ );
1514+ },
1515+ },
1516+ metadata,
1517+ });
1518+};
1519+
1520+export const graphqlToolDescriptorFromDefinition = (input: {
1521+ manifest: GraphqlToolManifest;
1522+ definition: GraphqlToolDefinition;
1523+ path: string;
1524+ sourceKey: string;
1525+ includeSchemas: boolean;
1526+}): ToolDescriptor => {
1527+ const presentation = buildGraphqlToolPresentation({
1528+ manifest: input.manifest,
1529+ definition: input.definition,
1530+ });
1531+
1532+ return {
1533+ path: asToolPath(input.path),
1534+ sourceKey: input.sourceKey,
1535+ description: input.definition.description,
1536+ interaction:
1537+ input.definition.operationType === "query" ? "auto" : "required",
1538+ contract: {
1539+ inputTypePreview: presentation.inputTypePreview,
1540+ outputTypePreview: presentation.outputTypePreview,
1541+ ...(input.includeSchemas && presentation.inputSchema !== undefined
1542+ ? { inputSchema: presentation.inputSchema }
1543+ : {}),
1544+ ...(input.includeSchemas && presentation.outputSchema !== undefined
1545+ ? { outputSchema: presentation.outputSchema }
1546+ : {}),
1547+ ...(presentation.exampleInput !== undefined
1548+ ? { exampleInput: presentation.exampleInput }
1549+ : {}),
1550+ },
1551+ pluginKind: "graphql",
1552+ pluginData: presentation.providerData,
1553+ };
1554+};
1555+
1556+const invokeGraphqlHttpRequest = (input: GraphqlHttpInvocation) =>
1557+ Effect.gen(function* () {
1558+ const endpoint = applyHttpQueryPlacementsToUrl({
1559+ url: input.endpoint,
1560+ queryParams: input.credentialPlacements?.queryParams,
1561+ }).toString();
1562+ const requestBody = applyJsonBodyPlacements({
1563+ body: {
1564+ query: input.query,
1565+ ...(input.variables !== undefined
1566+ ? { variables: input.variables }
1567+ : {}),
1568+ ...(input.operationName
1569+ ? { operationName: input.operationName }
1570+ : {}),
1571+ },
1572+ bodyValues: input.credentialPlacements?.bodyValues,
1573+ label: `GraphQL ${input.path}`,
1574+ });
1575+ const headers = applyCookiePlacementsToHeaders({
1576+ headers: {
1577+ "content-type": "application/json",
1578+ ...input.defaultHeaders,
1579+ ...input.requestHeaders,
1580+ ...input.credentialHeaders,
1581+ ...input.credentialPlacements?.headers,
1582+ },
1583+ cookies: input.credentialPlacements?.cookies,
1584+ });
1585+
1586+ const response = yield* Effect.tryPromise({
1587+ try: () =>
1588+ fetch(endpoint, {
1589+ method: "POST",
1590+ headers: new Headers(headers),
1591+ body: JSON.stringify(requestBody),
1592+ }),
1593+ catch: (cause) =>
1594+ graphqlToolError(`GraphQL request failed for ${input.path}`, cause),
1595+ });
1596+ const body = yield* Effect.tryPromise({
1597+ try: () => parseGraphqlResponseBody(response),
1598+ catch: (cause) =>
1599+ graphqlToolError(
1600+ `Failed decoding GraphQL response for ${input.path}`,
1601+ cause,
1602+ ),
1603+ });
1604+
1605+ const responseHeaders: Record<string, string> = {};
1606+ response.headers.forEach((value, key) => {
1607+ responseHeaders[key] = value;
1608+ });
1609+
1610+ return {
1611+ status: response.status,
1612+ headers: sanitizeResponseHeaders(responseHeaders),
1613+ body,
1614+ isError: response.status >= 400 || hasGraphqlErrors(body),
1615+ };
1616+ });
1617+
1618+const invokeRawGraphqlTool = (input: {
1619+ endpoint: string;
1620+ path: string;
1621+ defaultHeaders?: Readonly<Record<string, string>>;
1622+ credentialHeaders?: Readonly<Record<string, string>>;
1623+ credentialPlacements?: HttpRequestPlacements;
1624+ args: unknown;
1625+}) =>
1626+ Effect.gen(function* () {
1627+ const record = asRecord(input.args);
1628+ const query = asString(record.query);
1629+ if (query === null) {
1630+ return yield* sourceCoreEffectError("graphql/graphql-tools", `GraphQL query must be a non-empty string for ${input.path}`);
1631+ }
1632+
1633+ return yield* invokeGraphqlHttpRequest({
1634+ endpoint: input.endpoint,
1635+ path: input.path,
1636+ defaultHeaders: input.defaultHeaders,
1637+ credentialHeaders: input.credentialHeaders,
1638+ credentialPlacements: input.credentialPlacements,
1639+ query,
1640+ variables:
1641+ record.variables !== undefined ? asRecord(record.variables) : undefined,
1642+ operationName: asString(record.operationName) ?? undefined,
1643+ requestHeaders: asStringRecord(record.headers),
1644+ });
1645+ });
1646+
1647+const withoutUndefinedEntries = (
1648+ record: Readonly<Record<string, unknown>>,
1649+): Record<string, unknown> =>
1650+ Object.fromEntries(
1651+ Object.entries(record).filter(([, value]) => value !== undefined),
1652+ );
1653+
1654+const invokeGraphqlFieldTool = (input: {
1655+ entry: Pick<
1656+ GraphqlFieldToolManifestEntry,
1657+ "fieldName" | "operationName" | "operationDocument"
1658+ >;
1659+ endpoint: string;
1660+ path: string;
1661+ defaultHeaders?: Readonly<Record<string, string>>;
1662+ credentialHeaders?: Readonly<Record<string, string>>;
1663+ credentialPlacements?: HttpRequestPlacements;
1664+ args: unknown;
1665+}) =>
1666+ Effect.gen(function* () {
1667+ const record = asRecord(input.args);
1668+ const requestHeaders = asStringRecord(record.headers);
1669+ const variables = withoutUndefinedEntries(
1670+ Object.fromEntries(
1671+ Object.entries(record).filter(([key]) => key !== "headers"),
1672+ ),
1673+ );
1674+ const response = yield* invokeGraphqlHttpRequest({
1675+ endpoint: input.endpoint,
1676+ path: input.path,
1677+ defaultHeaders: input.defaultHeaders,
1678+ credentialHeaders: input.credentialHeaders,
1679+ credentialPlacements: input.credentialPlacements,
1680+ query: input.entry.operationDocument,
1681+ variables,
1682+ operationName: input.entry.operationName,
1683+ requestHeaders,
1684+ });
1685+ const bodyRecord = asRecord(response.body);
1686+ const dataRecord = asRecord(bodyRecord.data);
1687+ const errors = Array.isArray(bodyRecord.errors) ? bodyRecord.errors : [];
1688+
1689+ return {
1690+ data: dataRecord[input.entry.fieldName] ?? null,
1691+ errors,
1692+ isError: response.status >= 400 || errors.length > 0,
1693+ };
1694+ });
1695+
1696+export const createGraphqlToolsFromManifest = (input: {
1697+ manifest: GraphqlToolManifest;
1698+ endpoint: string;
1699+ namespace: string;
1700+ sourceKey: string;
1701+ defaultHeaders?: Readonly<Record<string, string>>;
1702+ credentialHeaders?: Readonly<Record<string, string>>;
1703+ credentialPlacements?: HttpRequestPlacements;
1704+}): ToolMap => {
1705+ const endpoint = normalizeHttpUrl(input.endpoint);
1706+ const definitions = compileGraphqlToolDefinitions(input.manifest);
1707+
1708+ return Object.fromEntries(
1709+ definitions.map((definition) => {
1710+ const presentation = buildGraphqlToolPresentation({
1711+ manifest: input.manifest,
1712+ definition,
1713+ });
1714+ const path = input.namespace
1715+ ? `${input.namespace}.${definition.toolId}`
1716+ : definition.toolId;
1717+
1718+ return [
1719+ path,
1720+ createGraphqlToolFromPersistedOperation({
1721+ path,
1722+ sourceKey: input.sourceKey,
1723+ endpoint,
1724+ description: definition.description,
1725+ inputSchema: presentation.inputSchema,
1726+ outputSchema: presentation.outputSchema,
1727+ exampleInput: presentation.exampleInput,
1728+ providerData: presentation.providerData,
1729+ schemaRefTable: input.manifest.schemaRefTable,
1730+ defaultHeaders: input.defaultHeaders,
1731+ credentialHeaders: input.credentialHeaders,
1732+ credentialPlacements: input.credentialPlacements,
1733+ }),
1734+ ] as const;
1735+ }),
1736+ );
1737+};
plugins/graphql/sdk/index.ts+567−0
@@ -0,0 +1,567 @@
1+import {
2+ applyCookiePlacementsToHeaders,
3+ applyHttpQueryPlacementsToUrl,
4+ applyJsonBodyPlacements,
5+} from "@executor/codemode-core";
6+import * as Effect from "effect/Effect";
7+import * as Schema from "effect/Schema";
8+
9+import {
10+ createCatalogImportMetadata,
11+ createSourceCatalogSyncResult,
12+ SourceCredentialRequiredError,
13+} from "@executor/source-core";
14+import type { Source } from "@executor/platform-sdk/schema";
15+import type {
16+ ExecutorSdkPlugin,
17+ ExecutorSdkPluginHost,
18+ ExecutorSourceConnector,
19+ SourcePluginRuntime,
20+} from "@executor/platform-sdk/plugins";
21+import {
22+ SecretMaterialResolverService,
23+ provideExecutorRuntime,
24+} from "@executor/platform-sdk/runtime";
25+import {
26+ deriveGraphqlNamespace,
27+ GraphqlConnectionAuthSchema,
28+ GraphqlStoredSourceDataSchema,
29+ type GraphqlConnectInput,
30+ type GraphqlSourceConfigPayload,
31+ type GraphqlStoredSourceData,
32+ type GraphqlUpdateSourceInput,
33+} from "@executor/plugin-graphql-shared";
34+
35+import {
36+ createGraphqlCatalogFragment,
37+ type GraphqlCatalogOperationInput,
38+} from "./catalog";
39+import {
40+ compileGraphqlToolDefinitions,
41+ extractGraphqlManifest,
42+ GRAPHQL_INTROSPECTION_QUERY,
43+ buildGraphqlToolPresentation,
44+} from "./graphql-tools";
45+import {
46+ GraphqlToolProviderDataSchema,
47+ type GraphqlToolProviderData,
48+} from "./provider-data";
49+
50+const decodeStoredSourceData = Schema.decodeUnknownSync(GraphqlStoredSourceDataSchema);
51+const decodeProviderData = Schema.decodeUnknownSync(GraphqlToolProviderDataSchema);
52+
53+export type GraphqlSourceStorage = {
54+ get: (input: {
55+ scopeId: string;
56+ sourceId: string;
57+ }) => Effect.Effect<GraphqlStoredSourceData | null, Error, never>;
58+ put: (input: {
59+ scopeId: string;
60+ sourceId: string;
61+ value: GraphqlStoredSourceData;
62+ }) => Effect.Effect<void, Error, never>;
63+ remove?: (input: {
64+ scopeId: string;
65+ sourceId: string;
66+ }) => Effect.Effect<void, Error, never>;
67+};
68+
69+export type GraphqlSdk = {
70+ getSourceConfig: (
71+ sourceId: Source["id"],
72+ ) => Effect.Effect<GraphqlSourceConfigPayload, Error>;
73+ createSource: (
74+ input: GraphqlConnectInput,
75+ ) => Effect.Effect<Source, Error>;
76+ updateSource: (
77+ input: GraphqlUpdateSourceInput,
78+ ) => Effect.Effect<Source, Error>;
79+ removeSource: (
80+ sourceId: Source["id"],
81+ ) => Effect.Effect<boolean, Error>;
82+};
83+
84+const GraphqlExecutorAddInputSchema = Schema.Struct({
85+ kind: Schema.Literal("graphql"),
86+ name: Schema.String,
87+ endpoint: Schema.String,
88+ defaultHeaders: Schema.NullOr(Schema.Record({ key: Schema.String, value: Schema.String })),
89+ auth: GraphqlConnectionAuthSchema,
90+});
91+
92+type GraphqlExecutorAddInput = typeof GraphqlExecutorAddInputSchema.Type;
93+
94+const asRecord = (value: unknown): Record<string, unknown> =>
95+ typeof value === "object" && value !== null && !Array.isArray(value)
96+ ? (value as Record<string, unknown>)
97+ : {};
98+
99+const asString = (value: unknown): string | null =>
100+ typeof value === "string" && value.trim().length > 0 ? value : null;
101+
102+const asStringRecord = (value: unknown): Record<string, string> =>
103+ Object.fromEntries(
104+ Object.entries(asRecord(value)).flatMap(([key, entry]) =>
105+ typeof entry === "string" ? [[key, entry]] : [],
106+ ),
107+ );
108+
109+const withoutUndefinedEntries = (
110+ record: Readonly<Record<string, unknown>>,
111+): Record<string, unknown> =>
112+ Object.fromEntries(
113+ Object.entries(record).filter(([, value]) => value !== undefined),
114+ );
115+
116+const responseHeadersRecord = (response: Response): Record<string, string> => {
117+ const headers: Record<string, string> = {};
118+ response.headers.forEach((value, key) => {
119+ headers[key] = value;
120+ });
121+ return headers;
122+};
123+
124+const parseGraphqlResponseBody = async (
125+ response: Response,
126+): Promise<unknown> => {
127+ const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
128+ if (contentType.includes("application/json")) {
129+ return response.json();
130+ }
131+
132+ return response.text();
133+};
134+
135+const resolveBearerToken = (
136+ auth: GraphqlStoredSourceData["auth"],
137+): Effect.Effect<string | null, Error, any> =>
138+ auth.kind === "none"
139+ ? Effect.succeed(null)
140+ : Effect.flatMap(SecretMaterialResolverService, (resolveSecretMaterial) =>
141+ resolveSecretMaterial({
142+ ref: auth.tokenSecretRef,
143+ }).pipe(Effect.map((token) => token.trim()))
144+ );
145+
146+const resolveGraphqlHeaders = (
147+ stored: GraphqlStoredSourceData,
148+): Effect.Effect<Record<string, string>, Error, any> =>
149+ Effect.gen(function* () {
150+ const bearerToken = yield* resolveBearerToken(stored.auth);
151+ return {
152+ ...(stored.defaultHeaders ?? {}),
153+ ...(bearerToken
154+ ? {
155+ authorization: `Bearer ${bearerToken}`,
156+ }
157+ : {}),
158+ };
159+ });
160+
161+const fetchGraphqlIntrospectionDocumentWithHeaders = (input: {
162+ endpoint: string;
163+ headers: Readonly<Record<string, string>>;
164+}): Effect.Effect<string, Error> =>
165+ Effect.tryPromise({
166+ try: async () => {
167+ let response: Response;
168+ try {
169+ response = await fetch(input.endpoint, {
170+ method: "POST",
171+ headers: {
172+ accept: "application/graphql-response+json, application/json",
173+ "content-type": "application/json",
174+ ...input.headers,
175+ },
176+ body: JSON.stringify({ query: GRAPHQL_INTROSPECTION_QUERY }),
177+ signal: AbortSignal.timeout(15_000),
178+ });
179+ } catch (cause) {
180+ if (
181+ cause instanceof Error &&
182+ (cause.name === "AbortError" || cause.name === "TimeoutError")
183+ ) {
184+ throw new Error("GraphQL introspection timed out after 15000ms");
185+ }
186+ throw cause;
187+ }
188+
189+ const text = await response.text();
190+ let parsed: unknown;
191+ try {
192+ parsed = JSON.parse(text);
193+ } catch (cause) {
194+ throw new Error(
195+ `GraphQL introspection endpoint did not return JSON: ${
196+ cause instanceof Error ? cause.message : String(cause)
197+ }`,
198+ );
199+ }
200+
201+ if (!response.ok) {
202+ if (response.status === 401 || response.status === 403) {
203+ throw new SourceCredentialRequiredError(
204+ "import",
205+ `GraphQL introspection requires credentials (status ${response.status})`,
206+ );
207+ }
208+
209+ throw new Error(
210+ `GraphQL introspection failed with status ${response.status}`,
211+ );
212+ }
213+
214+ return JSON.stringify(parsed, null, 2);
215+ },
216+ catch: (cause) =>
217+ cause instanceof Error ? cause : new Error(String(cause)),
218+ });
219+
220+const graphqlCatalogOperationFromDefinition = (input: {
221+ definition: ReturnType<typeof compileGraphqlToolDefinitions>[number];
222+ manifest: Parameters<typeof buildGraphqlToolPresentation>[0]["manifest"];
223+}): GraphqlCatalogOperationInput => {
224+ const presentation = buildGraphqlToolPresentation({
225+ manifest: input.manifest,
226+ definition: input.definition,
227+ });
228+
229+ return {
230+ toolId: input.definition.toolId,
231+ title: input.definition.name,
232+ description: input.definition.description,
233+ effect: input.definition.operationType === "query" ? "read" : "write",
234+ inputSchema: presentation.inputSchema,
235+ outputSchema: presentation.outputSchema,
236+ providerData: presentation.providerData as GraphqlToolProviderData,
237+ };
238+};
239+
240+const storedSourceDataFromInput = (
241+ input: GraphqlConnectInput,
242+): GraphqlStoredSourceData =>
243+ decodeStoredSourceData({
244+ endpoint: input.endpoint.trim(),
245+ defaultHeaders: input.defaultHeaders,
246+ auth: input.auth,
247+ });
248+
249+const sourceConfigFromStored = (
250+ source: Source,
251+ stored: GraphqlStoredSourceData,
252+): GraphqlSourceConfigPayload => ({
253+ name: source.name,
254+ endpoint: stored.endpoint,
255+ defaultHeaders: stored.defaultHeaders,
256+ auth: stored.auth,
257+});
258+
259+const createGraphqlSourceSdk = (
260+ options: {
261+ storage: GraphqlSourceStorage;
262+ },
263+ host: ExecutorSdkPluginHost,
264+) => ({
265+ getSourceConfig: (sourceId: Source["id"]) =>
266+ Effect.gen(function* () {
267+ const source = yield* host.sources.get(sourceId);
268+ if (source.kind !== "graphql") {
269+ return yield* Effect.fail(
270+ new Error(`Source ${sourceId} is not a GraphQL source.`),
271+ );
272+ }
273+
274+ const stored = yield* options.storage.get({
275+ scopeId: source.scopeId,
276+ sourceId: source.id,
277+ });
278+ if (stored === null) {
279+ return yield* Effect.fail(
280+ new Error(`GraphQL source storage missing for ${source.id}`),
281+ );
282+ }
283+
284+ return sourceConfigFromStored(source, stored);
285+ }),
286+ createSource: (input: GraphqlConnectInput) =>
287+ Effect.gen(function* () {
288+ const stored = storedSourceDataFromInput(input);
289+ const createdSource = yield* host.sources.create({
290+ source: {
291+ name: input.name.trim(),
292+ kind: "graphql",
293+ status: "connected",
294+ enabled: true,
295+ namespace: deriveGraphqlNamespace({
296+ endpoint: input.endpoint,
297+ title: input.name,
298+ }),
299+ },
300+ });
301+
302+ yield* options.storage.put({
303+ scopeId: createdSource.scopeId,
304+ sourceId: createdSource.id,
305+ value: stored,
306+ });
307+
308+ return yield* host.sources.refreshCatalog(createdSource.id);
309+ }),
310+ updateSource: (input: GraphqlUpdateSourceInput) =>
311+ Effect.gen(function* () {
312+ const source = yield* host.sources.get(input.sourceId as Source["id"]);
313+ if (source.kind !== "graphql") {
314+ return yield* Effect.fail(
315+ new Error(`Source ${input.sourceId} is not a GraphQL source.`),
316+ );
317+ }
318+
319+ const stored = storedSourceDataFromInput(input.config);
320+ const savedSource = yield* host.sources.save({
321+ ...source,
322+ name: input.config.name.trim(),
323+ namespace: deriveGraphqlNamespace({
324+ endpoint: input.config.endpoint,
325+ title: input.config.name,
326+ }),
327+ });
328+
329+ yield* options.storage.put({
330+ scopeId: savedSource.scopeId,
331+ sourceId: savedSource.id,
332+ value: stored,
333+ });
334+
335+ return yield* host.sources.refreshCatalog(savedSource.id);
336+ }),
337+ removeSource: (sourceId: Source["id"]) =>
338+ Effect.gen(function* () {
339+ const source = yield* host.sources.get(sourceId);
340+ if (source.kind !== "graphql") {
341+ return yield* Effect.fail(
342+ new Error(`Source ${sourceId} is not a GraphQL source.`),
343+ );
344+ }
345+
346+ if (options.storage.remove) {
347+ yield* options.storage.remove({
348+ scopeId: source.scopeId,
349+ sourceId: source.id,
350+ });
351+ }
352+
353+ return yield* host.sources.remove(source.id);
354+ }),
355+});
356+
357+const graphqlSourceConnector = (
358+ options: {
359+ storage: GraphqlSourceStorage;
360+ },
361+): ExecutorSourceConnector<GraphqlExecutorAddInput> => ({
362+ kind: "graphql",
363+ displayName: "GraphQL",
364+ inputSchema: GraphqlExecutorAddInputSchema,
365+ inputSignatureWidth: 300,
366+ helpText: [
367+ "Point `endpoint` at the GraphQL HTTP endpoint and choose `auth.kind`.",
368+ ],
369+ createSource: ({ args, host }) =>
370+ createGraphqlSourceSdk(options, host).createSource(args),
371+});
372+
373+const createGraphqlSourceRuntime = (
374+ options: {
375+ storage: GraphqlSourceStorage;
376+ },
377+): SourcePluginRuntime => ({
378+ kind: "graphql",
379+ displayName: "GraphQL",
380+ catalogKind: "imported",
381+ catalogIdentity: ({ source }) => ({
382+ kind: "graphql",
383+ sourceId: source.id,
384+ }),
385+ getIrModel: ({ source }) =>
386+ Effect.gen(function* () {
387+ const stored = yield* options.storage.get({
388+ scopeId: source.scopeId,
389+ sourceId: source.id,
390+ });
391+
392+ if (stored === null) {
393+ return createSourceCatalogSyncResult({
394+ fragment: {
395+ version: "ir.v1.fragment",
396+ },
397+ importMetadata: {
398+ ...createCatalogImportMetadata({
399+ source,
400+ pluginKey: "graphql",
401+ }),
402+ importerVersion: "ir.v1.graphql",
403+ sourceConfigHash: "missing",
404+ },
405+ sourceHash: null,
406+ });
407+ }
408+
409+ const headers = yield* resolveGraphqlHeaders(stored);
410+ const graphqlDocument = yield* fetchGraphqlIntrospectionDocumentWithHeaders({
411+ endpoint: stored.endpoint,
412+ headers,
413+ });
414+ const manifest = yield* extractGraphqlManifest(source.name, graphqlDocument);
415+ const definitions = yield* Effect.sync(() =>
416+ compileGraphqlToolDefinitions(manifest),
417+ );
418+ const operations = definitions.map((definition) =>
419+ graphqlCatalogOperationFromDefinition({
420+ definition,
421+ manifest,
422+ }),
423+ );
424+ const now = Date.now();
425+
426+ return createSourceCatalogSyncResult({
427+ fragment: createGraphqlCatalogFragment({
428+ source,
429+ documents: [
430+ {
431+ documentKind: "graphql_introspection",
432+ documentKey: stored.endpoint,
433+ contentText: graphqlDocument,
434+ fetchedAt: now,
435+ },
436+ ],
437+ operations,
438+ }),
439+ importMetadata: {
440+ ...createCatalogImportMetadata({
441+ source,
442+ pluginKey: "graphql",
443+ }),
444+ importerVersion: "ir.v1.graphql",
445+ },
446+ sourceHash: manifest.sourceHash,
447+ });
448+ }),
449+ invoke: (input) =>
450+ Effect.gen(function* () {
451+ const stored = yield* options.storage.get({
452+ scopeId: input.source.scopeId,
453+ sourceId: input.source.id,
454+ });
455+ if (stored === null) {
456+ return yield* Effect.fail(
457+ new Error(`GraphQL source storage missing for ${input.source.id}`),
458+ );
459+ }
460+
461+ const providerData = decodeProviderData(
462+ input.executable.binding,
463+ ) as GraphqlToolProviderData;
464+ const args = asRecord(input.args);
465+ const headers = {
466+ "content-type": "application/json",
467+ ...(yield* resolveGraphqlHeaders(stored)),
468+ ...asStringRecord(args.headers),
469+ };
470+ const endpoint = applyHttpQueryPlacementsToUrl({
471+ url: stored.endpoint,
472+ }).toString();
473+
474+ const isRawRequest =
475+ providerData.toolKind === "request"
476+ || typeof providerData.operationDocument !== "string"
477+ || providerData.operationDocument.trim().length === 0;
478+ const query = isRawRequest
479+ ? (() => {
480+ const value = asString(args.query);
481+ if (value === null) {
482+ throw new Error("GraphQL request tools require args.query");
483+ }
484+ return value;
485+ })()
486+ : providerData.operationDocument!;
487+ const variables = isRawRequest
488+ ? args.variables !== undefined
489+ ? asRecord(args.variables)
490+ : undefined
491+ : withoutUndefinedEntries(
492+ Object.fromEntries(
493+ Object.entries(args).filter(([key]) => key !== "headers"),
494+ ),
495+ );
496+ const operationName = isRawRequest
497+ ? (asString(args.operationName) ?? undefined)
498+ : (providerData.operationName ?? undefined);
499+
500+ const response = yield* Effect.tryPromise({
501+ try: () =>
502+ fetch(endpoint, {
503+ method: "POST",
504+ headers: applyCookiePlacementsToHeaders({
505+ headers,
506+ cookies: {},
507+ }),
508+ body: JSON.stringify(
509+ applyJsonBodyPlacements({
510+ body: {
511+ query,
512+ ...(variables ? { variables } : {}),
513+ ...(operationName ? { operationName } : {}),
514+ },
515+ bodyValues: {},
516+ label: `GraphQL invocation ${stored.endpoint}`,
517+ }),
518+ ),
519+ }),
520+ catch: (cause) =>
521+ cause instanceof Error ? cause : new Error(String(cause)),
522+ });
523+ const body = yield* Effect.tryPromise({
524+ try: () => parseGraphqlResponseBody(response),
525+ catch: (cause) =>
526+ cause instanceof Error ? cause : new Error(String(cause)),
527+ });
528+ const bodyRecord = asRecord(body);
529+ const hasErrors = Array.isArray(bodyRecord.errors) && bodyRecord.errors.length > 0;
530+
531+ return {
532+ data: response.ok ? (bodyRecord.data ?? body) : null,
533+ error:
534+ response.ok && !hasErrors
535+ ? null
536+ : bodyRecord.errors ?? body,
537+ headers: responseHeadersRecord(response),
538+ status: response.status,
539+ };
540+ }),
541+});
542+
543+export const graphqlSdkPlugin = (options: {
544+ storage: GraphqlSourceStorage;
545+}): ExecutorSdkPlugin<"graphql", GraphqlSdk> => ({
546+ key: "graphql",
547+ sources: [createGraphqlSourceRuntime(options)],
548+ sourceConnectors: [graphqlSourceConnector(options)],
549+ extendExecutor: ({ host, executor }) => {
550+ const sourceSdk = createGraphqlSourceSdk(options, host);
551+ const provideRuntime = <A>(
552+ effect: Effect.Effect<A, Error, any>,
553+ ): Effect.Effect<A, Error, never> =>
554+ provideExecutorRuntime(effect, executor.runtime);
555+
556+ return {
557+ getSourceConfig: (sourceId) =>
558+ provideRuntime(sourceSdk.getSourceConfig(sourceId)),
559+ createSource: (input) =>
560+ provideRuntime(sourceSdk.createSource(input)),
561+ updateSource: (input) =>
562+ provideRuntime(sourceSdk.updateSource(input)),
563+ removeSource: (sourceId) =>
564+ provideRuntime(sourceSdk.removeSource(sourceId)),
565+ };
566+ },
567+});
plugins/graphql/sdk/package.json+28−0
@@ -0,0 +1,28 @@
1+{
2+ "name": "@executor/plugin-graphql-sdk",
3+ "private": true,
4+ "type": "module",
5+ "exports": {
6+ ".": "./index.ts"
7+ },
8+ "files": [
9+ "*.ts"
10+ ],
11+ "scripts": {
12+ "typecheck": "bunx tsc --noEmit -p tsconfig.json"
13+ },
14+ "dependencies": {
15+ "@executor/codemode-core": "workspace:*",
16+ "@executor/ir": "workspace:*",
17+ "@executor/platform-sdk": "workspace:*",
18+ "@executor/plugin-graphql-shared": "workspace:*",
19+ "@executor/source-core": "workspace:*",
20+ "effect": "catalog:",
21+ "graphql": "^16.12.0"
22+ },
23+ "devDependencies": {
24+ "@types/node": "catalog:",
25+ "bun-types": "catalog:"
26+ },
27+ "version": "0.0.0"
28+}
plugins/graphql/sdk/provider-data.ts+25−0
@@ -0,0 +1,25 @@
1+import * as Schema from "effect/Schema";
2+
3+export const GraphqlToolKindSchema = Schema.Literal("request", "field");
4+
5+export const GraphqlOperationTypeSchema = Schema.Literal("query", "mutation");
6+
7+export const GraphqlToolProviderDataSchema = Schema.Struct({
8+ kind: Schema.Literal("graphql"),
9+ toolKind: GraphqlToolKindSchema,
10+ toolId: Schema.String,
11+ rawToolId: Schema.NullOr(Schema.String),
12+ group: Schema.NullOr(Schema.String),
13+ leaf: Schema.NullOr(Schema.String),
14+ fieldName: Schema.NullOr(Schema.String),
15+ operationType: Schema.NullOr(GraphqlOperationTypeSchema),
16+ operationName: Schema.NullOr(Schema.String),
17+ operationDocument: Schema.NullOr(Schema.String),
18+ queryTypeName: Schema.NullOr(Schema.String),
19+ mutationTypeName: Schema.NullOr(Schema.String),
20+ subscriptionTypeName: Schema.NullOr(Schema.String),
21+});
22+
23+export type GraphqlToolKind = typeof GraphqlToolKindSchema.Type;
24+export type GraphqlOperationType = typeof GraphqlOperationTypeSchema.Type;
25+export type GraphqlToolProviderData = typeof GraphqlToolProviderDataSchema.Type;
plugins/graphql/sdk/tsconfig.json+15−0
@@ -0,0 +1,15 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2022",
4+ "module": "ESNext",
5+ "moduleResolution": "Bundler",
6+ "strict": true,
7+ "skipLibCheck": true,
8+ "noEmit": true,
9+ "lib": ["ES2022"],
10+ "types": ["bun-types", "node"]
11+ },
12+ "include": [
13+ "*.ts"
14+ ]
15+}
plugins/graphql/shared/index.ts+64−0
@@ -0,0 +1,64 @@
1+import * as Schema from "effect/Schema";
2+
3+import {
4+ SecretRefSchema,
5+ StringMapSchema,
6+} from "@executor/platform-sdk/schema";
7+import {
8+ defaultNameFromEndpoint,
9+ namespaceFromSourceName,
10+} from "@executor/source-core";
11+
12+export const GraphqlConnectionAuthSchema = Schema.Union(
13+ Schema.Struct({
14+ kind: Schema.Literal("none"),
15+ }),
16+ Schema.Struct({
17+ kind: Schema.Literal("bearer"),
18+ tokenSecretRef: SecretRefSchema,
19+ }),
20+);
21+
22+export const GraphqlConnectInputSchema = Schema.Struct({
23+ name: Schema.String,
24+ endpoint: Schema.String,
25+ defaultHeaders: Schema.NullOr(StringMapSchema),
26+ auth: GraphqlConnectionAuthSchema,
27+});
28+
29+export const GraphqlSourceConfigPayloadSchema = GraphqlConnectInputSchema;
30+
31+export const GraphqlUpdateSourceInputSchema = Schema.Struct({
32+ sourceId: Schema.String,
33+ config: GraphqlSourceConfigPayloadSchema,
34+});
35+
36+export const GraphqlStoredSourceDataSchema = Schema.Struct({
37+ endpoint: Schema.String,
38+ defaultHeaders: Schema.NullOr(StringMapSchema),
39+ auth: GraphqlConnectionAuthSchema,
40+});
41+
42+export type GraphqlConnectionAuth = typeof GraphqlConnectionAuthSchema.Type;
43+export type GraphqlConnectInput = typeof GraphqlConnectInputSchema.Type;
44+export type GraphqlSourceConfigPayload =
45+ typeof GraphqlSourceConfigPayloadSchema.Type;
46+export type GraphqlStoredSourceData =
47+ typeof GraphqlStoredSourceDataSchema.Type;
48+export type GraphqlUpdateSourceInput =
49+ typeof GraphqlUpdateSourceInputSchema.Type;
50+
51+export const deriveGraphqlNamespace = (input: {
52+ endpoint: string;
53+ title?: string | null;
54+}): string | null => {
55+ if (input.title && input.title.trim().length > 0) {
56+ return namespaceFromSourceName(input.title);
57+ }
58+
59+ try {
60+ return namespaceFromSourceName(defaultNameFromEndpoint(input.endpoint));
61+ } catch {
62+ return null;
63+ }
64+};
plugins/graphql/shared/package.json+24−0
@@ -0,0 +1,24 @@
1+{
2+ "name": "@executor/plugin-graphql-shared",
3+ "private": true,
4+ "type": "module",
5+ "exports": {
6+ ".": "./index.ts"
7+ },
8+ "files": [
9+ "index.ts"
10+ ],
11+ "scripts": {
12+ "typecheck": "bunx tsc --noEmit -p tsconfig.json"
13+ },
14+ "dependencies": {
15+ "@executor/platform-sdk": "workspace:*",
16+ "@executor/source-core": "workspace:*",
17+ "effect": "catalog:"
18+ },
19+ "devDependencies": {
20+ "@types/node": "catalog:",
21+ "bun-types": "catalog:"
22+ },
23+ "version": "0.0.0"
24+}
plugins/graphql/shared/tsconfig.json+15−0
@@ -0,0 +1,15 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2022",
4+ "module": "ESNext",
5+ "moduleResolution": "Bundler",
6+ "strict": true,
7+ "skipLibCheck": true,
8+ "noEmit": true,
9+ "lib": ["ES2022"],
10+ "types": ["bun-types", "node"]
11+ },
12+ "include": [
13+ "index.ts"
14+ ]
15+}
plugins/mcp/http/index.ts+301−0
@@ -0,0 +1,301 @@
1+import {
2+ HttpApi,
3+ HttpApiBuilder,
4+ HttpApiEndpoint,
5+ HttpApiGroup,
6+ HttpApiSchema,
7+ HttpServerResponse,
8+} from "@effect/platform";
9+import {
10+ ControlPlaneBadRequestError,
11+ ControlPlaneForbiddenError,
12+ ControlPlaneNotFoundError,
13+ ControlPlaneStorageError,
14+ type ExecutorHttpApiExtension,
15+ type ExecutorHttpPlugin,
16+} from "@executor/platform-api";
17+import { resolveRequestedLocalWorkspace } from "@executor/platform-api/local-context";
18+import {
19+ ScopeIdSchema,
20+ SourceIdSchema,
21+ SourceSchema,
22+ type Source,
23+} from "@executor/platform-sdk/schema";
24+import * as Effect from "effect/Effect";
25+import * as Schema from "effect/Schema";
26+
27+import {
28+ McpConnectInputSchema,
29+ McpOAuthPopupResultSchema,
30+ McpSourceConfigPayloadSchema,
31+ McpStartOAuthInputSchema,
32+ McpStartOAuthResultSchema,
33+ type McpConnectInput,
34+ type McpOAuthPopupResult,
35+ type McpSourceConfigPayload,
36+ type McpStartOAuthInput,
37+ type McpStartOAuthResult,
38+ type McpUpdateSourceInput,
39+} from "@executor/plugin-mcp-shared";
40+
41+type McpExecutorExtension = {
42+ mcp: {
43+ createSource: (
44+ input: McpConnectInput,
45+ ) => Effect.Effect<Source, Error>;
46+ getSourceConfig: (
47+ sourceId: Source["id"],
48+ ) => Effect.Effect<McpSourceConfigPayload, Error>;
49+ updateSource: (
50+ input: McpUpdateSourceInput,
51+ ) => Effect.Effect<Source, Error>;
52+ removeSource: (
53+ sourceId: Source["id"],
54+ ) => Effect.Effect<boolean, Error>;
55+ startOAuth: (
56+ input: McpStartOAuthInput,
57+ ) => Effect.Effect<McpStartOAuthResult, Error>;
58+ completeOAuth: (input: {
59+ state: string;
60+ code?: string;
61+ error?: string;
62+ errorDescription?: string;
63+ }) => Effect.Effect<Extract<McpOAuthPopupResult, { ok: true }>, Error>;
64+ };
65+};
66+
67+const workspaceIdParam = HttpApiSchema.param("workspaceId", ScopeIdSchema);
68+const sourceIdParam = HttpApiSchema.param("sourceId", SourceIdSchema);
69+const htmlSchema = HttpApiSchema.Text({
70+ contentType: "text/html",
71+});
72+
73+const callbackParamsSchema = Schema.Struct({
74+ state: Schema.String,
75+ code: Schema.optional(Schema.String),
76+ error: Schema.optional(Schema.String),
77+ error_description: Schema.optional(Schema.String),
78+});
79+
80+export const McpHttpGroup = HttpApiGroup.make("mcp")
81+ .add(
82+ HttpApiEndpoint.post("createSource")`/workspaces/${workspaceIdParam}/plugins/mcp/sources`
83+ .setPayload(McpConnectInputSchema)
84+ .addSuccess(SourceSchema)
85+ .addError(ControlPlaneBadRequestError)
86+ .addError(ControlPlaneForbiddenError)
87+ .addError(ControlPlaneStorageError),
88+ )
89+ .add(
90+ HttpApiEndpoint.get("getSourceConfig")`/workspaces/${workspaceIdParam}/plugins/mcp/sources/${sourceIdParam}`
91+ .addSuccess(McpSourceConfigPayloadSchema)
92+ .addError(ControlPlaneBadRequestError)
93+ .addError(ControlPlaneForbiddenError)
94+ .addError(ControlPlaneNotFoundError)
95+ .addError(ControlPlaneStorageError),
96+ )
97+ .add(
98+ HttpApiEndpoint.put("updateSource")`/workspaces/${workspaceIdParam}/plugins/mcp/sources/${sourceIdParam}`
99+ .setPayload(McpSourceConfigPayloadSchema)
100+ .addSuccess(SourceSchema)
101+ .addError(ControlPlaneBadRequestError)
102+ .addError(ControlPlaneForbiddenError)
103+ .addError(ControlPlaneNotFoundError)
104+ .addError(ControlPlaneStorageError),
105+ )
106+ .add(
107+ HttpApiEndpoint.del("removeSource")`/workspaces/${workspaceIdParam}/plugins/mcp/sources/${sourceIdParam}`
108+ .addSuccess(Schema.Struct({ removed: Schema.Boolean }))
109+ .addError(ControlPlaneBadRequestError)
110+ .addError(ControlPlaneForbiddenError)
111+ .addError(ControlPlaneNotFoundError)
112+ .addError(ControlPlaneStorageError),
113+ )
114+ .add(
115+ HttpApiEndpoint.post("startOAuth")`/workspaces/${workspaceIdParam}/plugins/mcp/oauth/start`
116+ .setPayload(McpStartOAuthInputSchema)
117+ .addSuccess(McpStartOAuthResultSchema)
118+ .addError(ControlPlaneBadRequestError)
119+ .addError(ControlPlaneForbiddenError)
120+ .addError(ControlPlaneStorageError),
121+ )
122+ .add(
123+ HttpApiEndpoint.get("oauthCallback")`/plugins/mcp/oauth/callback`
124+ .setUrlParams(callbackParamsSchema)
125+ .addSuccess(htmlSchema)
126+ .addError(ControlPlaneBadRequestError)
127+ .addError(ControlPlaneStorageError),
128+ )
129+ .prefix("/v1");
130+
131+export const mcpHttpApiExtension = {
132+ key: "mcp",
133+ group: McpHttpGroup,
134+} satisfies ExecutorHttpApiExtension<typeof McpHttpGroup>;
135+
136+const McpHttpApi = HttpApi.make("executor").add(McpHttpGroup);
137+
138+const toStorageError = (operation: string, cause: unknown) =>
139+ new ControlPlaneStorageError({
140+ operation,
141+ message: cause instanceof Error ? cause.message : String(cause),
142+ details: cause instanceof Error ? cause.stack ?? cause.message : String(cause),
143+ });
144+
145+const toNotFoundError = (operation: string, cause: unknown) =>
146+ new ControlPlaneNotFoundError({
147+ operation,
148+ message: cause instanceof Error ? cause.message : String(cause),
149+ details: cause instanceof Error ? cause.stack ?? cause.message : String(cause),
150+ });
151+
152+const mapPluginStorageError = (operation: string, cause: unknown) => {
153+ const message = cause instanceof Error ? cause.message : String(cause);
154+ if (message.includes("not found") || message.includes("Not found")) {
155+ return toNotFoundError(operation, cause);
156+ }
157+
158+ return toStorageError(operation, cause);
159+};
160+
161+const escapeHtml = (value: string): string =>
162+ value
163+ .replaceAll("&", "&amp;")
164+ .replaceAll("<", "&lt;")
165+ .replaceAll(">", "&gt;")
166+ .replaceAll('"', "&quot;")
167+ .replaceAll("'", "&#39;");
168+
169+const popupDocument = (payload: McpOAuthPopupResult): string => {
170+ const serialized = JSON.stringify(payload)
171+ .replaceAll("<", "\\u003c")
172+ .replaceAll(">", "\\u003e")
173+ .replaceAll("&", "\\u0026");
174+ const title = payload.ok ? "MCP OAuth connected" : "MCP OAuth failed";
175+ const message = payload.ok
176+ ? "MCP credentials are ready. Return to the source form to finish saving."
177+ : payload.error;
178+
179+ return `<!doctype html>
180+<html lang="en">
181+ <head>
182+ <meta charset="utf-8" />
183+ <meta name="viewport" content="width=device-width, initial-scale=1" />
184+ <title>${escapeHtml(title)}</title>
185+ </head>
186+ <body>
187+ <main>
188+ <h1>${escapeHtml(title)}</h1>
189+ <p>${escapeHtml(message)}</p>
190+ </main>
191+ <script>
192+ (() => {
193+ const payload = ${serialized};
194+ try {
195+ window.localStorage.setItem("executor:mcp-oauth:" + (payload.ok ? payload.sessionId : "failed"), JSON.stringify(payload));
196+ } catch {}
197+ try {
198+ if (window.opener) {
199+ window.opener.postMessage(payload, window.location.origin);
200+ }
201+ } finally {
202+ window.setTimeout(() => window.close(), 120);
203+ }
204+ })();
205+ </script>
206+ </body>
207+</html>`;
208+};
209+
210+export const mcpHttpPlugin = (): ExecutorHttpPlugin<
211+ typeof McpHttpGroup,
212+ McpExecutorExtension
213+> => ({
214+ key: "mcp",
215+ group: McpHttpGroup,
216+ build: ({ executor }) =>
217+ HttpApiBuilder.group(McpHttpApi, "mcp", (handlers) =>
218+ handlers
219+ .handle("createSource", ({ path, payload }) =>
220+ resolveRequestedLocalWorkspace(
221+ "mcp.createSource",
222+ path.workspaceId,
223+ ).pipe(
224+ Effect.flatMap(() => executor.mcp.createSource(payload)),
225+ Effect.mapError((cause) => toStorageError("mcp.createSource", cause)),
226+ )
227+ )
228+ .handle("getSourceConfig", ({ path }) =>
229+ resolveRequestedLocalWorkspace(
230+ "mcp.getSourceConfig",
231+ path.workspaceId,
232+ ).pipe(
233+ Effect.flatMap(() => executor.mcp.getSourceConfig(path.sourceId)),
234+ Effect.mapError((cause) =>
235+ mapPluginStorageError("mcp.getSourceConfig", cause)
236+ ),
237+ )
238+ )
239+ .handle("updateSource", ({ path, payload }) =>
240+ resolveRequestedLocalWorkspace(
241+ "mcp.updateSource",
242+ path.workspaceId,
243+ ).pipe(
244+ Effect.flatMap(() =>
245+ executor.mcp.updateSource({
246+ sourceId: path.sourceId,
247+ config: payload,
248+ })
249+ ),
250+ Effect.mapError((cause) =>
251+ mapPluginStorageError("mcp.updateSource", cause)
252+ ),
253+ )
254+ )
255+ .handle("removeSource", ({ path }) =>
256+ resolveRequestedLocalWorkspace(
257+ "mcp.removeSource",
258+ path.workspaceId,
259+ ).pipe(
260+ Effect.flatMap(() => executor.mcp.removeSource(path.sourceId)),
261+ Effect.map((removed) => ({ removed })),
262+ Effect.mapError((cause) =>
263+ mapPluginStorageError("mcp.removeSource", cause)
264+ ),
265+ )
266+ )
267+ .handle("startOAuth", ({ path, payload }) =>
268+ resolveRequestedLocalWorkspace(
269+ "mcp.startOAuth",
270+ path.workspaceId,
271+ ).pipe(
272+ Effect.flatMap(() => executor.mcp.startOAuth(payload)),
273+ Effect.mapError((cause) => toStorageError("mcp.startOAuth", cause)),
274+ )
275+ )
276+ .handle("oauthCallback", ({ urlParams }) =>
277+ executor.mcp.completeOAuth({
278+ state: urlParams.state,
279+ code: urlParams.code,
280+ error: urlParams.error,
281+ errorDescription: urlParams.error_description,
282+ }).pipe(
283+ Effect.map((payload) => popupDocument(payload)),
284+ Effect.mapError((cause) =>
285+ toStorageError("mcp.oauthCallback", cause)
286+ ),
287+ Effect.catchAll((error) =>
288+ Effect.succeed(
289+ popupDocument({
290+ type: "executor:oauth-result",
291+ ok: false,
292+ sessionId: null,
293+ error: error.message,
294+ }),
295+ )
296+ ),
297+ Effect.flatMap((html) => HttpServerResponse.html(html)),
298+ )
299+ )
300+ ),
301+});
plugins/mcp/http/package.json+26−0
@@ -0,0 +1,26 @@
1+{
2+ "name": "@executor/plugin-mcp-http",
3+ "private": true,
4+ "type": "module",
5+ "exports": {
6+ ".": "./index.ts"
7+ },
8+ "files": [
9+ "index.ts"
10+ ],
11+ "scripts": {
12+ "typecheck": "bunx tsc --noEmit -p tsconfig.json"
13+ },
14+ "dependencies": {
15+ "@effect/platform": "catalog:",
16+ "@executor/platform-api": "workspace:*",
17+ "@executor/platform-sdk": "workspace:*",
18+ "@executor/plugin-mcp-shared": "workspace:*",
19+ "effect": "catalog:"
20+ },
21+ "devDependencies": {
22+ "@types/node": "catalog:",
23+ "bun-types": "catalog:"
24+ },
25+ "version": "0.0.0"
26+}
plugins/mcp/http/tsconfig.json+15−0
@@ -0,0 +1,15 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2022",
4+ "module": "ESNext",
5+ "moduleResolution": "Bundler",
6+ "strict": true,
7+ "skipLibCheck": true,
8+ "noEmit": true,
9+ "lib": ["ES2022", "DOM"],
10+ "types": ["bun-types", "node"]
11+ },
12+ "include": [
13+ "index.ts"
14+ ]
15+}
plugins/mcp/react/index.tsx+793−0
@@ -0,0 +1,793 @@
1+import { startTransition, useMemo, useState, type ReactNode } from "react";
2+import { useNavigate } from "@tanstack/react-router";
3+import type { Source } from "@executor/react";
4+import {
5+ Result,
6+ defineExecutorPluginHttpApiClient,
7+ useAtomSet,
8+ useAtomValue,
9+ useExecutorMutation,
10+ useLocalInstallation,
11+} from "@executor/react";
12+
13+import {
14+ mcpHttpApiExtension,
15+} from "@executor/plugin-mcp-http";
16+import {
17+ type McpConnectInput,
18+ type McpConnectionAuth,
19+ type McpOAuthPopupResult,
20+ type McpStartOAuthInput,
21+} from "@executor/plugin-mcp-shared";
22+import { IconPencil } from "../../../apps/web/src/components/icons";
23+import { SourceToolExplorer } from "../../../apps/web/src/source-plugins/tool-explorer";
24+import {
25+ asMcpRemoteTransportValue,
26+ defaultMcpRemoteTransportFields,
27+ defaultMcpStdioTransportFields,
28+ setMcpTransportFieldsTransport,
29+ type McpTransportFields,
30+ type McpTransportValue,
31+} from "../../../apps/web/src/views/mcp-transport-state";
32+import {
33+ parseJsonStringArray,
34+ parseJsonStringMap,
35+} from "../../../apps/web/src/views/json-form";
36+
37+type FrontendSourceTypeDefinition = {
38+ kind: string;
39+ displayName: string;
40+ description?: string;
41+ renderAddPage: () => ReactNode;
42+ renderEditPage?: (input: { source: Source }) => ReactNode;
43+ renderDetailPage?: (input: {
44+ source: Source;
45+ route: {
46+ search?: unknown;
47+ navigate?: unknown;
48+ };
49+ }) => ReactNode;
50+};
51+
52+type FrontendPluginRegisterApi = {
53+ sources: {
54+ registerType: (definition: FrontendSourceTypeDefinition) => void;
55+ };
56+};
57+
58+type RouteToolSearch = {
59+ tab?: "model" | "discover";
60+ tool?: string;
61+ query?: string;
62+};
63+
64+const OAUTH_STORAGE_PREFIX = "executor:mcp-oauth:";
65+const OAUTH_TIMEOUT_MS = 2 * 60_000;
66+
67+const getMcpHttpClient = defineExecutorPluginHttpApiClient<"McpReactHttpClient">()(
68+ "McpReactHttpClient",
69+ [mcpHttpApiExtension] as const,
70+);
71+
72+const defaultMcpInput = (): McpConnectInput => ({
73+ name: "My MCP Source",
74+ endpoint: "https://example.com/mcp",
75+ transport: "auto",
76+ queryParams: null,
77+ headers: null,
78+ command: null,
79+ args: null,
80+ env: null,
81+ cwd: null,
82+ auth: {
83+ kind: "none",
84+ },
85+});
86+
87+const stringifyStringMap = (
88+ value: Record<string, string> | null | undefined,
89+): string =>
90+ !value || Object.keys(value).length === 0
91+ ? ""
92+ : JSON.stringify(value, null, 2);
93+
94+const stringifyStringArray = (
95+ value: ReadonlyArray<string> | null | undefined,
96+): string =>
97+ !value || value.length === 0 ? "" : JSON.stringify(value, null, 2);
98+
99+const transportFieldsFromInput = (input: McpConnectInput): McpTransportFields =>
100+ input.transport === "stdio" || input.command
101+ ? defaultMcpStdioTransportFields({
102+ command: input.command ?? "",
103+ argsText: stringifyStringArray(input.args),
104+ envText: stringifyStringMap(input.env),
105+ cwd: input.cwd ?? "",
106+ })
107+ : {
108+ ...defaultMcpRemoteTransportFields(
109+ asMcpRemoteTransportValue(input.transport),
110+ ),
111+ queryParamsText: stringifyStringMap(input.queryParams),
112+ headersText: stringifyStringMap(input.headers),
113+ };
114+
115+const waitForOauthPopupResult = async (
116+ sessionId: string,
117+): Promise<McpOAuthPopupResult> =>
118+ new Promise((resolve, reject) => {
119+ const storageKey = `${OAUTH_STORAGE_PREFIX}${sessionId}`;
120+ const startedAt = Date.now();
121+
122+ const cleanup = () => {
123+ window.removeEventListener("message", handleMessage);
124+ window.clearInterval(intervalId);
125+ };
126+
127+ const finish = (result: McpOAuthPopupResult) => {
128+ cleanup();
129+ try {
130+ window.localStorage.removeItem(storageKey);
131+ } catch {}
132+ resolve(result);
133+ };
134+
135+ const handleMessage = (event: MessageEvent) => {
136+ if (event.origin !== window.location.origin) {
137+ return;
138+ }
139+
140+ const data = event.data as McpOAuthPopupResult | undefined;
141+ if (!data || data.type !== "executor:oauth-result") {
142+ return;
143+ }
144+
145+ if (data.ok && data.sessionId !== sessionId) {
146+ return;
147+ }
148+
149+ finish(data);
150+ };
151+
152+ window.addEventListener("message", handleMessage);
153+ const intervalId = window.setInterval(() => {
154+ if (Date.now() - startedAt > OAUTH_TIMEOUT_MS) {
155+ cleanup();
156+ reject(new Error("Timed out waiting for MCP OAuth to finish."));
157+ return;
158+ }
159+
160+ try {
161+ const raw = window.localStorage.getItem(storageKey);
162+ if (!raw) {
163+ return;
164+ }
165+
166+ finish(JSON.parse(raw) as McpOAuthPopupResult);
167+ } catch {
168+ // Ignore malformed local storage and continue polling.
169+ }
170+ }, 400);
171+ });
172+
173+const navigateFromPluginRoute = (
174+ navigate: unknown,
175+ next: {
176+ tab: "model" | "discover";
177+ tool?: string;
178+ query?: string;
179+ },
180+) => {
181+ const routeNavigate = navigate as
182+ | ((input: {
183+ search: {
184+ tab: "model" | "discover";
185+ tool?: string;
186+ query?: string;
187+ };
188+ }) => void | Promise<void>)
189+ | undefined;
190+ if (routeNavigate) {
191+ void routeNavigate({
192+ search: next,
193+ });
194+ }
195+};
196+
197+function McpSourceForm(props: {
198+ initialValue: McpConnectInput;
199+ mode: "create" | "edit";
200+ onSubmit: (input: McpConnectInput) => Promise<void>;
201+}) {
202+ const installation = useLocalInstallation();
203+ const client = getMcpHttpClient();
204+ const startOAuth = useAtomSet(
205+ client.mutation("mcp", "startOAuth"),
206+ { mode: "promise" },
207+ );
208+ const submitMutation = useExecutorMutation<McpConnectInput, void>(props.onSubmit);
209+ const [name, setName] = useState(props.initialValue.name);
210+ const [endpoint, setEndpoint] = useState(props.initialValue.endpoint ?? "");
211+ const [transportFields, setTransportFields] = useState<McpTransportFields>(
212+ transportFieldsFromInput(props.initialValue),
213+ );
214+ const [authKind, setAuthKind] = useState<McpConnectionAuth["kind"]>(
215+ props.initialValue.auth.kind,
216+ );
217+ const [oauthAuth, setOauthAuth] = useState<
218+ Extract<McpConnectionAuth, { kind: "oauth2" }> | null
219+ >(
220+ props.initialValue.auth.kind === "oauth2"
221+ ? props.initialValue.auth
222+ : null,
223+ );
224+ const [oauthStatus, setOauthStatus] = useState<"idle" | "pending" | "connected">(
225+ props.initialValue.auth.kind === "oauth2" ? "connected" : "idle",
226+ );
227+ const [error, setError] = useState<string | null>(null);
228+
229+ const isStdio = transportFields.transport === "stdio";
230+
231+ const runOauth = async () => {
232+ if (installation.status !== "ready") {
233+ throw new Error("Workspace is still loading.");
234+ }
235+ if (isStdio) {
236+ throw new Error("MCP OAuth is only available for remote MCP transports.");
237+ }
238+
239+ const payload: McpStartOAuthInput = {
240+ endpoint: endpoint.trim(),
241+ queryParams: parseJsonStringMap("Query params", transportFields.queryParamsText),
242+ redirectUrl: new URL(
243+ "/v1/plugins/mcp/oauth/callback",
244+ window.location.origin,
245+ ).toString(),
246+ };
247+
248+ const started = await startOAuth({
249+ path: {
250+ workspaceId: installation.data.scopeId,
251+ },
252+ payload,
253+ });
254+
255+ const popup = window.open(
256+ started.authorizationUrl,
257+ "executor-mcp-oauth",
258+ "width=560,height=760,noopener,noreferrer",
259+ );
260+ if (!popup) {
261+ throw new Error("Failed opening MCP OAuth popup.");
262+ }
263+
264+ const result = await waitForOauthPopupResult(started.sessionId);
265+ if (!result.ok) {
266+ throw new Error(result.error);
267+ }
268+
269+ setOauthAuth(result.auth);
270+ setOauthStatus("connected");
271+ };
272+
273+ const buildInput = (): McpConnectInput => ({
274+ name: name.trim(),
275+ endpoint: isStdio ? null : (endpoint.trim() || null),
276+ transport:
277+ transportFields.transport === ""
278+ ? null
279+ : transportFields.transport,
280+ queryParams: isStdio
281+ ? null
282+ : parseJsonStringMap("Query params", transportFields.queryParamsText),
283+ headers: isStdio
284+ ? null
285+ : parseJsonStringMap("Headers", transportFields.headersText),
286+ command: isStdio ? transportFields.command.trim() || null : null,
287+ args: isStdio
288+ ? parseJsonStringArray("Args", transportFields.argsText)
289+ : null,
290+ env: isStdio
291+ ? parseJsonStringMap("Environment", transportFields.envText)
292+ : null,
293+ cwd: isStdio ? transportFields.cwd.trim() || null : null,
294+ auth:
295+ authKind === "oauth2"
296+ ? oauthAuth ?? (() => {
297+ throw new Error("Complete MCP OAuth before saving.");
298+ })()
299+ : { kind: "none" },
300+ });
301+
302+ return (
303+ <div className="space-y-4 rounded-2xl border border-border bg-card p-5">
304+ <div>
305+ <h2 className="text-lg font-semibold text-foreground">
306+ {props.mode === "create" ? "Connect MCP Source" : "Edit MCP Source"}
307+ </h2>
308+ <p className="mt-1 text-sm text-muted-foreground">
309+ MCP now owns its transport config, OAuth flow, and tool surface inside the plugin.
310+ </p>
311+ </div>
312+
313+ <label className="grid gap-1.5">
314+ <span className="text-xs font-medium text-foreground">Name</span>
315+ <input
316+ value={name}
317+ onChange={(event) => setName(event.target.value)}
318+ 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"
319+ />
320+ </label>
321+
322+ <label className="grid gap-1.5">
323+ <span className="text-xs font-medium text-foreground">Transport</span>
324+ <select
325+ value={transportFields.transport}
326+ onChange={(event) => {
327+ const nextTransport = event.target.value as McpTransportValue;
328+ setTransportFields((current) =>
329+ setMcpTransportFieldsTransport(current, nextTransport)
330+ );
331+ if (nextTransport === "stdio") {
332+ setAuthKind("none");
333+ setOauthAuth(null);
334+ setOauthStatus("idle");
335+ }
336+ }}
337+ 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"
338+ >
339+ <option value="">Auto (remote)</option>
340+ <option value="auto">Auto</option>
341+ <option value="streamable-http">Streamable HTTP</option>
342+ <option value="sse">SSE</option>
343+ <option value="stdio">stdio</option>
344+ </select>
345+ </label>
346+
347+ {isStdio ? (
348+ <div className="grid gap-4 md:grid-cols-2">
349+ <label className="grid gap-1.5 md:col-span-2">
350+ <span className="text-xs font-medium text-foreground">Command</span>
351+ <input
352+ value={transportFields.command}
353+ onChange={(event) =>
354+ setTransportFields({
355+ ...transportFields,
356+ command: event.target.value,
357+ })}
358+ 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"
359+ />
360+ </label>
361+
362+ <label className="grid gap-1.5">
363+ <span className="text-xs font-medium text-foreground">Args</span>
364+ <textarea
365+ value={transportFields.argsText}
366+ onChange={(event) =>
367+ setTransportFields({
368+ ...transportFields,
369+ argsText: event.target.value,
370+ })}
371+ rows={4}
372+ placeholder='["server.js","--port","8787"]'
373+ 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"
374+ />
375+ </label>
376+
377+ <label className="grid gap-1.5">
378+ <span className="text-xs font-medium text-foreground">Environment</span>
379+ <textarea
380+ value={transportFields.envText}
381+ onChange={(event) =>
382+ setTransportFields({
383+ ...transportFields,
384+ envText: event.target.value,
385+ })}
386+ rows={4}
387+ placeholder='{"NODE_ENV":"production"}'
388+ 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"
389+ />
390+ </label>
391+
392+ <label className="grid gap-1.5 md:col-span-2">
393+ <span className="text-xs font-medium text-foreground">Working Directory</span>
394+ <input
395+ value={transportFields.cwd}
396+ onChange={(event) =>
397+ setTransportFields({
398+ ...transportFields,
399+ cwd: event.target.value,
400+ })}
401+ 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"
402+ />
403+ </label>
404+ </div>
405+ ) : (
406+ <div className="grid gap-4 md:grid-cols-2">
407+ <label className="grid gap-1.5 md:col-span-2">
408+ <span className="text-xs font-medium text-foreground">Endpoint</span>
409+ <input
410+ value={endpoint}
411+ onChange={(event) => setEndpoint(event.target.value)}
412+ 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"
413+ />
414+ </label>
415+
416+ <label className="grid gap-1.5">
417+ <span className="text-xs font-medium text-foreground">Query Params</span>
418+ <textarea
419+ value={transportFields.queryParamsText}
420+ onChange={(event) =>
421+ setTransportFields({
422+ ...transportFields,
423+ queryParamsText: event.target.value,
424+ })}
425+ rows={4}
426+ placeholder='{"transport":"streamable-http"}'
427+ 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"
428+ />
429+ </label>
430+
431+ <label className="grid gap-1.5">
432+ <span className="text-xs font-medium text-foreground">Headers</span>
433+ <textarea
434+ value={transportFields.headersText}
435+ onChange={(event) =>
436+ setTransportFields({
437+ ...transportFields,
438+ headersText: event.target.value,
439+ })}
440+ rows={4}
441+ placeholder='{"x-api-key":"..."}'
442+ 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"
443+ />
444+ </label>
445+ </div>
446+ )}
447+
448+ {!isStdio && (
449+ <label className="grid gap-1.5">
450+ <span className="text-xs font-medium text-foreground">Auth</span>
451+ <select
452+ value={authKind}
453+ onChange={(event) => {
454+ const nextKind = event.target.value as McpConnectionAuth["kind"];
455+ setAuthKind(nextKind);
456+ if (nextKind !== "oauth2") {
457+ setOauthAuth(null);
458+ setOauthStatus("idle");
459+ }
460+ }}
461+ 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"
462+ >
463+ <option value="none">None</option>
464+ <option value="oauth2">OAuth 2.0</option>
465+ </select>
466+ </label>
467+ )}
468+
469+ {!isStdio && authKind === "oauth2" && (
470+ <div className="rounded-xl border border-border/70 bg-muted/40 p-4">
471+ <div className="flex flex-wrap items-center justify-between gap-3">
472+ <div>
473+ <div className="text-sm font-medium text-foreground">
474+ MCP OAuth
475+ </div>
476+ <div className="mt-1 text-xs text-muted-foreground">
477+ Authenticate directly against the MCP server and keep the token refs in plugin storage.
478+ </div>
479+ </div>
480+ <button
481+ type="button"
482+ onClick={() => {
483+ setError(null);
484+ setOauthStatus("pending");
485+ void runOauth()
486+ .catch((cause) => {
487+ setOauthStatus("idle");
488+ setError(
489+ cause instanceof Error ? cause.message : String(cause),
490+ );
491+ });
492+ }}
493+ 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"
494+ >
495+ {oauthStatus === "connected" ? "Reconnect OAuth" : "Connect OAuth"}
496+ </button>
497+ </div>
498+ <div className="mt-3 text-xs text-muted-foreground">
499+ Status: <span className="text-foreground">{oauthStatus}</span>
500+ </div>
501+ </div>
502+ )}
503+
504+ {error && (
505+ <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-3 text-sm text-destructive">
506+ {error}
507+ </div>
508+ )}
509+
510+ <div className="flex items-center justify-end gap-3">
511+ <button
512+ type="button"
513+ onClick={() => {
514+ setError(null);
515+ void submitMutation
516+ .mutateAsync(buildInput())
517+ .catch((cause: unknown) =>
518+ setError(cause instanceof Error ? cause.message : String(cause))
519+ );
520+ }}
521+ disabled={submitMutation.status === "pending"}
522+ 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"
523+ >
524+ {submitMutation.status === "pending"
525+ ? props.mode === "create"
526+ ? "Creating..."
527+ : "Saving..."
528+ : props.mode === "create"
529+ ? "Create Source"
530+ : "Save Changes"}
531+ </button>
532+ </div>
533+ </div>
534+ );
535+}
536+
537+function McpAddPage() {
538+ const navigate = useNavigate();
539+ const installation = useLocalInstallation();
540+ const client = getMcpHttpClient();
541+ const createSource = useAtomSet(
542+ client.mutation("mcp", "createSource"),
543+ { mode: "promise" },
544+ );
545+
546+ if (installation.status !== "ready") {
547+ return <div className="text-sm text-muted-foreground">Loading workspace...</div>;
548+ }
549+
550+ return (
551+ <McpSourceForm
552+ initialValue={defaultMcpInput()}
553+ mode="create"
554+ onSubmit={async (input) => {
555+ const source = await createSource({
556+ path: {
557+ workspaceId: installation.data.scopeId,
558+ },
559+ payload: input,
560+ reactivityKeys: {
561+ sources: [installation.data.scopeId],
562+ },
563+ });
564+
565+ startTransition(() => {
566+ void navigate({
567+ to: "/sources/$sourceId",
568+ params: {
569+ sourceId: source.id,
570+ },
571+ search: {
572+ tab: "model",
573+ },
574+ });
575+ });
576+ }}
577+ />
578+ );
579+}
580+
581+function McpEditPage(props: {
582+ source: Source;
583+}) {
584+ const navigate = useNavigate();
585+ const installation = useLocalInstallation();
586+ const client = getMcpHttpClient();
587+ const configResult = useAtomValue(
588+ installation.status === "ready"
589+ ? client.query("mcp", "getSourceConfig", {
590+ path: {
591+ workspaceId: installation.data.scopeId,
592+ sourceId: props.source.id,
593+ },
594+ reactivityKeys: {
595+ source: [installation.data.scopeId, props.source.id],
596+ },
597+ timeToLive: "30 seconds",
598+ })
599+ : client.query("local", "installation", {
600+ timeToLive: "1 second",
601+ }) as never,
602+ );
603+ const updateSource = useAtomSet(
604+ client.mutation("mcp", "updateSource"),
605+ { mode: "promise" },
606+ );
607+
608+ if (installation.status !== "ready") {
609+ return <div className="text-sm text-muted-foreground">Loading workspace...</div>;
610+ }
611+
612+ if (Result.isFailure(configResult)) {
613+ return (
614+ <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-3 text-sm text-destructive">
615+ Failed loading MCP config.
616+ </div>
617+ );
618+ }
619+
620+ if (!Result.isSuccess(configResult)) {
621+ return <div className="text-sm text-muted-foreground">Loading MCP config...</div>;
622+ }
623+
624+ return (
625+ <McpSourceForm
626+ initialValue={configResult.value}
627+ mode="edit"
628+ onSubmit={async (input) => {
629+ const source = await updateSource({
630+ path: {
631+ workspaceId: installation.data.scopeId,
632+ sourceId: props.source.id,
633+ },
634+ payload: input,
635+ reactivityKeys: {
636+ sources: [installation.data.scopeId],
637+ source: [installation.data.scopeId, props.source.id],
638+ sourceInspection: [installation.data.scopeId, props.source.id],
639+ sourceInspectionTool: [installation.data.scopeId, props.source.id],
640+ sourceDiscovery: [installation.data.scopeId, props.source.id],
641+ },
642+ });
643+
644+ startTransition(() => {
645+ void navigate({
646+ to: "/sources/$sourceId",
647+ params: {
648+ sourceId: source.id,
649+ },
650+ search: {
651+ tab: "model",
652+ },
653+ });
654+ });
655+ }}
656+ />
657+ );
658+}
659+
660+function McpDetailPage(props: {
661+ source: Source;
662+ route: {
663+ search?: unknown;
664+ navigate?: unknown;
665+ };
666+}) {
667+ const navigate = useNavigate();
668+ const installation = useLocalInstallation();
669+ const client = getMcpHttpClient();
670+ const removeSource = useAtomSet(
671+ client.mutation("mcp", "removeSource"),
672+ { mode: "promise" },
673+ );
674+ const configResult = useAtomValue(
675+ installation.status === "ready"
676+ ? client.query("mcp", "getSourceConfig", {
677+ path: {
678+ workspaceId: installation.data.scopeId,
679+ sourceId: props.source.id,
680+ },
681+ reactivityKeys: {
682+ source: [installation.data.scopeId, props.source.id],
683+ },
684+ timeToLive: "30 seconds",
685+ })
686+ : client.query("local", "installation", {
687+ timeToLive: "1 second",
688+ }) as never,
689+ );
690+ const summary = useMemo(() => {
691+ if (!Result.isSuccess(configResult)) {
692+ return null;
693+ }
694+
695+ const config = configResult.value;
696+ const location = config.transport === "stdio"
697+ ? config.command ?? "stdio"
698+ : config.endpoint ?? "remote";
699+
700+ return (
701+ <div className="space-y-1">
702+ <div className="font-mono text-xs text-foreground">{location}</div>
703+ <div>
704+ Transport: <span className="text-foreground">{config.transport ?? "auto"}</span>
705+ </div>
706+ <div>
707+ Auth: <span className="text-foreground">{config.auth.kind}</span>
708+ </div>
709+ </div>
710+ );
711+ }, [configResult]);
712+
713+ if (installation.status !== "ready") {
714+ return <div className="text-sm text-muted-foreground">Loading workspace...</div>;
715+ }
716+
717+ return (
718+ <SourceToolExplorer
719+ sourceId={props.source.id}
720+ title={props.source.name}
721+ kind={props.source.kind}
722+ search={(props.route.search ?? {}) as RouteToolSearch}
723+ navigate={(next) => navigateFromPluginRoute(props.route.navigate, next)}
724+ summary={summary}
725+ actions={(
726+ <>
727+ <button
728+ type="button"
729+ onClick={() =>
730+ void navigate({
731+ to: "/sources/$sourceId/edit",
732+ params: {
733+ sourceId: props.source.id,
734+ },
735+ })}
736+ className="inline-flex h-9 items-center gap-1.5 rounded-lg border border-input bg-card px-3 text-sm font-medium text-foreground transition-colors hover:bg-accent/50"
737+ >
738+ <IconPencil className="size-3.5" />
739+ Edit
740+ </button>
741+ <button
742+ type="button"
743+ onClick={() => {
744+ const confirmed = window.confirm(`Delete MCP source "${props.source.name}"?`);
745+ if (!confirmed) {
746+ return;
747+ }
748+
749+ void removeSource({
750+ path: {
751+ workspaceId: installation.data.scopeId,
752+ sourceId: props.source.id,
753+ },
754+ reactivityKeys: {
755+ sources: [installation.data.scopeId],
756+ source: [installation.data.scopeId, props.source.id],
757+ sourceInspection: [installation.data.scopeId, props.source.id],
758+ sourceInspectionTool: [installation.data.scopeId, props.source.id],
759+ sourceDiscovery: [installation.data.scopeId, props.source.id],
760+ },
761+ }).then(() => {
762+ startTransition(() => {
763+ void navigate({
764+ to: "/",
765+ });
766+ });
767+ });
768+ }}
769+ 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"
770+ >
771+ Delete
772+ </button>
773+ </>
774+ )}
775+ />
776+ );
777+}
778+
779+export const McpReactPlugin = {
780+ key: "mcp",
781+ register(api: FrontendPluginRegisterApi) {
782+ api.sources.registerType({
783+ kind: "mcp",
784+ displayName: "MCP",
785+ description: "Connect remote or local MCP servers with plugin-owned OAuth.",
786+ renderAddPage: () => <McpAddPage />,
787+ renderEditPage: ({ source }) => <McpEditPage source={source} />,
788+ renderDetailPage: ({ source, route }) => (
789+ <McpDetailPage source={source} route={route} />
790+ ),
791+ });
792+ },
793+};
plugins/mcp/react/package.json+27−0
@@ -0,0 +1,27 @@
1+{
2+ "name": "@executor/plugin-mcp-react",
3+ "private": true,
4+ "type": "module",
5+ "exports": {
6+ ".": "./index.tsx"
7+ },
8+ "files": [
9+ "index.tsx"
10+ ],
11+ "scripts": {
12+ "typecheck": "bunx tsc --noEmit -p tsconfig.json"
13+ },
14+ "dependencies": {
15+ "@executor/plugin-mcp-http": "workspace:*",
16+ "@executor/plugin-mcp-shared": "workspace:*",
17+ "@executor/react": "workspace:*",
18+ "@tanstack/react-router": "^1.132.7",
19+ "react": "^19.2.0"
20+ },
21+ "devDependencies": {
22+ "@types/node": "catalog:",
23+ "@types/react": "^19.2.10",
24+ "bun-types": "catalog:"
25+ },
26+ "version": "0.0.0"
27+}
plugins/mcp/react/tsconfig.json+16−0
@@ -0,0 +1,16 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2022",
4+ "module": "ESNext",
5+ "moduleResolution": "Bundler",
6+ "strict": true,
7+ "skipLibCheck": true,
8+ "noEmit": true,
9+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
10+ "jsx": "react-jsx",
11+ "types": ["bun-types", "node"]
12+ },
13+ "include": [
14+ "index.tsx"
15+ ]
16+}
plugins/mcp/sdk/catalog.ts+285−0
@@ -0,0 +1,285 @@
1+import { createCatalogSnapshotV1FromFragments } from "@executor/ir/catalog";
2+import {
3+ CapabilityIdSchema,
4+ DocumentIdSchema,
5+ ExecutableIdSchema,
6+ ResponseSymbolIdSchema,
7+ ScopeIdSchema,
8+} from "@executor/ir/ids";
9+import type {
10+ Capability,
11+ CatalogSnapshotV1,
12+ ResponseSymbol,
13+ Executable,
14+} from "@executor/ir/model";
15+import {
16+ type BaseCatalogOperationInput,
17+ type CatalogFragmentBuilder,
18+ type CatalogSourceDocumentInput,
19+ type JsonSchemaImporter,
20+ type Source,
21+ EXECUTABLE_BINDING_VERSION,
22+ buildCatalogFragment,
23+ createCatalogImportMetadata,
24+ docsFrom,
25+ interactionForEffect,
26+ isObjectLikeJsonSchema,
27+ mutableRecord,
28+ provenanceFor,
29+ responseSetFromSingleResponse,
30+ schemaWithMergedDefs,
31+ stableHash,
32+ toolPathSegments,
33+} from "@executor/source-core";
34+
35+import type {
36+ McpServerMetadata,
37+ McpToolAnnotations,
38+ McpToolExecution,
39+} from "./manifest";
40+
41+export type McpCatalogOperationInput = BaseCatalogOperationInput & {
42+ providerData: {
43+ toolId: string;
44+ toolName: string;
45+ displayTitle: string;
46+ title: string | null;
47+ description: string | null;
48+ annotations: McpToolAnnotations | null;
49+ execution: McpToolExecution | null;
50+ icons: unknown[] | null;
51+ meta: unknown;
52+ rawTool: unknown;
53+ server: McpServerMetadata | null;
54+ };
55+};
56+
57+const mcpResumeSupport = (
58+ execution: McpCatalogOperationInput["providerData"]["execution"],
59+): boolean =>
60+ execution?.taskSupport === "optional" ||
61+ execution?.taskSupport === "required";
62+
63+const mcpSemanticsForOperation = (input: {
64+ effect: McpCatalogOperationInput["effect"];
65+ annotations: McpCatalogOperationInput["providerData"]["annotations"];
66+}): Capability["semantics"] => {
67+ const safe = input.effect === "read";
68+
69+ return {
70+ effect: input.effect,
71+ safe,
72+ idempotent: safe || input.annotations?.idempotentHint === true,
73+ destructive: safe ? false : input.annotations?.destructiveHint !== false,
74+ };
75+};
76+
77+const createMcpCapability = (input: {
78+ catalog: CatalogFragmentBuilder;
79+ source: Pick<Source, "id" | "name" | "namespace">;
80+ documentId: ReturnType<typeof DocumentIdSchema.make>;
81+ serviceScopeId: ReturnType<typeof ScopeIdSchema.make>;
82+ operation: McpCatalogOperationInput;
83+ importer: JsonSchemaImporter;
84+}) => {
85+ const toolPath = toolPathSegments(
86+ input.source,
87+ input.operation.providerData.toolId,
88+ );
89+ const capabilityId = CapabilityIdSchema.make(
90+ `cap_${stableHash({
91+ sourceId: input.source.id,
92+ toolId: input.operation.providerData.toolId,
93+ })}`,
94+ );
95+ const executableId = ExecutableIdSchema.make(
96+ `exec_${stableHash({
97+ sourceId: input.source.id,
98+ toolId: input.operation.providerData.toolId,
99+ protocol: "mcp",
100+ })}`,
101+ );
102+ const outputShapeId =
103+ input.operation.outputSchema !== undefined
104+ ? input.importer.importSchema(
105+ input.operation.outputSchema,
106+ `#/mcp/${input.operation.providerData.toolId}/output`,
107+ )
108+ : undefined;
109+ const callShapeId =
110+ input.operation.inputSchema === undefined
111+ ? input.importer.importSchema(
112+ {
113+ type: "object",
114+ properties: {},
115+ additionalProperties: false,
116+ },
117+ `#/mcp/${input.operation.providerData.toolId}/call`,
118+ )
119+ : isObjectLikeJsonSchema(input.operation.inputSchema)
120+ ? input.importer.importSchema(
121+ input.operation.inputSchema,
122+ `#/mcp/${input.operation.providerData.toolId}/call`,
123+ input.operation.inputSchema,
124+ )
125+ : input.importer.importSchema(
126+ schemaWithMergedDefs(
127+ {
128+ type: "object",
129+ properties: {
130+ input: input.operation.inputSchema,
131+ },
132+ required: ["input"],
133+ additionalProperties: false,
134+ },
135+ input.operation.inputSchema,
136+ ),
137+ `#/mcp/${input.operation.providerData.toolId}/call`,
138+ );
139+ const resultStatusShapeId = input.importer.importSchema(
140+ { type: "null" },
141+ `#/mcp/${input.operation.providerData.toolId}/status`,
142+ );
143+
144+ const responseId = ResponseSymbolIdSchema.make(
145+ `response_${stableHash({ capabilityId })}`,
146+ );
147+ mutableRecord(input.catalog.symbols)[responseId] = {
148+ id: responseId,
149+ kind: "response",
150+ ...(docsFrom({
151+ description:
152+ input.operation.providerData.description ?? input.operation.description,
153+ })
154+ ? {
155+ docs: docsFrom({
156+ description:
157+ input.operation.providerData.description ??
158+ input.operation.description,
159+ })!,
160+ }
161+ : {}),
162+ ...(outputShapeId
163+ ? {
164+ contents: [
165+ {
166+ mediaType: "application/json",
167+ shapeId: outputShapeId,
168+ },
169+ ],
170+ }
171+ : {}),
172+ synthetic: false,
173+ provenance: provenanceFor(
174+ input.documentId,
175+ `#/mcp/${input.operation.providerData.toolId}/response`,
176+ ),
177+ } satisfies ResponseSymbol;
178+ const responseSetId = responseSetFromSingleResponse({
179+ catalog: input.catalog,
180+ responseId,
181+ provenance: provenanceFor(
182+ input.documentId,
183+ `#/mcp/${input.operation.providerData.toolId}/responseSet`,
184+ ),
185+ });
186+
187+ mutableRecord(input.catalog.executables)[executableId] = {
188+ id: executableId,
189+ capabilityId,
190+ scopeId: input.serviceScopeId,
191+ pluginKey: "mcp",
192+ bindingVersion: EXECUTABLE_BINDING_VERSION,
193+ binding: input.operation.providerData,
194+ projection: {
195+ responseSetId,
196+ callShapeId,
197+ ...(outputShapeId ? { resultDataShapeId: outputShapeId } : {}),
198+ resultStatusShapeId,
199+ },
200+ display: {
201+ protocol: "mcp",
202+ method: null,
203+ pathTemplate: null,
204+ operationId: input.operation.providerData.toolName,
205+ group: null,
206+ leaf: input.operation.providerData.toolName,
207+ rawToolId: input.operation.providerData.toolId,
208+ title: input.operation.providerData.displayTitle,
209+ summary:
210+ input.operation.providerData.description ??
211+ input.operation.description ??
212+ null,
213+ },
214+ synthetic: false,
215+ provenance: provenanceFor(
216+ input.documentId,
217+ `#/mcp/${input.operation.providerData.toolId}/executable`,
218+ ),
219+ } satisfies Executable;
220+
221+ const interaction = interactionForEffect(input.operation.effect);
222+ mutableRecord(input.catalog.capabilities)[capabilityId] = {
223+ id: capabilityId,
224+ serviceScopeId: input.serviceScopeId,
225+ surface: {
226+ toolPath,
227+ title: input.operation.providerData.displayTitle,
228+ ...(input.operation.providerData.description
229+ ? { summary: input.operation.providerData.description }
230+ : {}),
231+ },
232+ semantics: mcpSemanticsForOperation({
233+ effect: input.operation.effect,
234+ annotations: input.operation.providerData.annotations,
235+ }),
236+ auth: { kind: "none" },
237+ interaction: {
238+ ...interaction,
239+ resume: {
240+ supported: mcpResumeSupport(input.operation.providerData.execution),
241+ },
242+ },
243+ executableIds: [executableId],
244+ synthetic: false,
245+ provenance: provenanceFor(
246+ input.documentId,
247+ `#/mcp/${input.operation.providerData.toolId}/capability`,
248+ ),
249+ } satisfies Capability;
250+};
251+
252+export const createMcpCatalogFragment = (input: {
253+ source: Source;
254+ documents: readonly CatalogSourceDocumentInput[];
255+ operations: readonly McpCatalogOperationInput[];
256+}) =>
257+ buildCatalogFragment({
258+ source: input.source,
259+ documents: input.documents,
260+ registerOperations: ({ catalog, documentId, serviceScopeId, importer }) => {
261+ for (const operation of input.operations) {
262+ createMcpCapability({
263+ catalog,
264+ source: input.source,
265+ documentId,
266+ serviceScopeId,
267+ operation,
268+ importer,
269+ });
270+ }
271+ },
272+ });
273+
274+export const createMcpCatalogSnapshot = (input: {
275+ source: Source;
276+ documents: readonly CatalogSourceDocumentInput[];
277+ operations: readonly McpCatalogOperationInput[];
278+}): CatalogSnapshotV1 =>
279+ createCatalogSnapshotV1FromFragments({
280+ import: createCatalogImportMetadata({
281+ source: input.source,
282+ pluginKey: "mcp",
283+ }),
284+ fragments: [createMcpCatalogFragment(input)],
285+ });
plugins/mcp/sdk/connection-pool.ts+151−0
@@ -0,0 +1,151 @@
1+import * as Data from "effect/Data";
2+import * as Effect from "effect/Effect";
3+import * as Exit from "effect/Exit";
4+import * as Scope from "effect/Scope";
5+
6+import type { McpConnection, McpConnector } from "./tools";
7+
8+export class McpConnectionPoolError extends Data.TaggedError("McpConnectionPoolError")<{
9+ readonly operation: "connect" | "close";
10+ readonly message: string;
11+ readonly cause: unknown;
12+}> {}
13+
14+type PoolEntry = {
15+ scope: Scope.CloseableScope;
16+ connection: McpConnector;
17+};
18+
19+const pooledRuns = new Map<string, Map<string, PoolEntry>>();
20+
21+const mcpConnectionPoolError = (input: {
22+ operation: "connect" | "close";
23+ message: string;
24+ cause: unknown;
25+}): McpConnectionPoolError => new McpConnectionPoolError(input);
26+
27+const deletePoolEntry = (runId: string, sourceKey: string, entry: PoolEntry) => {
28+ const runEntries = pooledRuns.get(runId);
29+ if (!runEntries || runEntries.get(sourceKey) !== entry) {
30+ return;
31+ }
32+
33+ runEntries.delete(sourceKey);
34+ if (runEntries.size === 0) {
35+ pooledRuns.delete(runId);
36+ }
37+};
38+
39+const closePooledConnection = (
40+ connection: McpConnection,
41+): Effect.Effect<void, never, never> =>
42+ Effect.tryPromise({
43+ try: () => Promise.resolve(connection.close?.()),
44+ catch: (cause) =>
45+ mcpConnectionPoolError({
46+ operation: "close",
47+ message: "Failed closing pooled MCP connection",
48+ cause,
49+ }),
50+ }).pipe(Effect.ignore);
51+
52+const createPoolEntry = (connect: McpConnector): PoolEntry => {
53+ const scope = Effect.runSync(Scope.make());
54+ const connection = Effect.runSync(
55+ Effect.cached(
56+ Effect.acquireRelease(
57+ connect.pipe(
58+ Effect.mapError((cause) =>
59+ mcpConnectionPoolError({
60+ operation: "connect",
61+ message: "Failed creating pooled MCP connection",
62+ cause,
63+ })),
64+ ),
65+ closePooledConnection,
66+ ).pipe(Scope.extend(scope)),
67+ ),
68+ );
69+
70+ return {
71+ scope,
72+ connection,
73+ };
74+};
75+
76+const getOrCreatePoolEntry = (input: {
77+ runId: string;
78+ sourceKey: string;
79+ connect: McpConnector;
80+}): PoolEntry => {
81+ const existing = pooledRuns.get(input.runId)?.get(input.sourceKey);
82+ if (existing) {
83+ return existing;
84+ }
85+
86+ let runEntries = pooledRuns.get(input.runId);
87+ if (!runEntries) {
88+ runEntries = new Map<string, PoolEntry>();
89+ pooledRuns.set(input.runId, runEntries);
90+ }
91+
92+ const entry = createPoolEntry(input.connect);
93+ entry.connection = entry.connection.pipe(
94+ Effect.tapError(() =>
95+ Effect.sync(() => {
96+ deletePoolEntry(input.runId, input.sourceKey, entry);
97+ }).pipe(
98+ Effect.zipRight(closePoolEntry(entry)),
99+ )),
100+ );
101+ runEntries.set(input.sourceKey, entry);
102+ return entry;
103+};
104+
105+const closePoolEntry = (entry: PoolEntry): Effect.Effect<void, never, never> =>
106+ Scope.close(entry.scope, Exit.void).pipe(Effect.ignore);
107+
108+export const createPooledMcpConnector = (input: {
109+ connect: McpConnector;
110+ runId?: string;
111+ sourceKey?: string;
112+}): McpConnector => {
113+ if (!input.runId || !input.sourceKey) {
114+ return input.connect;
115+ }
116+
117+ return Effect.gen(function* () {
118+ const entry = getOrCreatePoolEntry({
119+ runId: input.runId!,
120+ sourceKey: input.sourceKey!,
121+ connect: input.connect,
122+ });
123+ const connection = yield* entry.connection;
124+ return {
125+ client: connection.client,
126+ close: async () => undefined,
127+ };
128+ });
129+};
130+
131+export const clearMcpConnectionPoolRun = (
132+ runId: string,
133+): Effect.Effect<void, never, never> => {
134+ const runEntries = pooledRuns.get(runId);
135+ if (!runEntries) {
136+ return Effect.void;
137+ }
138+
139+ pooledRuns.delete(runId);
140+ return Effect.forEach([...runEntries.values()], closePoolEntry, {
141+ discard: true,
142+ });
143+};
144+
145+export const clearAllMcpConnectionPools = (): Effect.Effect<void, never, never> => {
146+ const runEntries = [...pooledRuns.values()].flatMap((entries) => [...entries.values()]);
147+ pooledRuns.clear();
148+ return Effect.forEach(runEntries, closePoolEntry, {
149+ discard: true,
150+ });
151+};
plugins/mcp/sdk/connection.ts+222−0
@@ -0,0 +1,222 @@
1+import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
2+import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3+import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
4+import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
5+import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
6+import * as Data from "effect/Data";
7+import * as Effect from "effect/Effect";
8+
9+import type { McpConnection, McpConnector } from "./tools";
10+
11+export type McpTransportPreference = "auto" | "streamable-http" | "sse" | "stdio";
12+
13+export type CreateSdkMcpConnectorInput = {
14+ endpoint?: string;
15+ transport?: McpTransportPreference;
16+ queryParams?: Record<string, string>;
17+ headers?: Record<string, string>;
18+ authProvider?: OAuthClientProvider;
19+ clientName?: string;
20+ clientVersion?: string;
21+ command?: string;
22+ args?: ReadonlyArray<string>;
23+ env?: Record<string, string>;
24+ cwd?: string;
25+};
26+
27+export class McpConnectionError extends Data.TaggedError("McpConnectionError")<{
28+ readonly transport: McpTransportPreference;
29+ readonly message: string;
30+ readonly cause: unknown;
31+}> {}
32+
33+export const isMcpStdioTransport = (
34+ input: Pick<CreateSdkMcpConnectorInput, "transport" | "command">,
35+): boolean =>
36+ input.transport === "stdio"
37+ || (typeof input.command === "string" && input.command.trim().length > 0);
38+
39+const mcpConnectionError = (input: {
40+ transport: McpTransportPreference;
41+ message: string;
42+ cause: unknown;
43+}): McpConnectionError => new McpConnectionError(input);
44+
45+const createEndpoint = (input: {
46+ endpoint: string | undefined;
47+ queryParams: Record<string, string>;
48+ transport: Exclude<McpTransportPreference, "stdio">;
49+}): Effect.Effect<URL, McpConnectionError, never> =>
50+ Effect.try({
51+ try: () => {
52+ if (!input.endpoint) {
53+ throw new Error("MCP endpoint is required for HTTP/SSE transports");
54+ }
55+
56+ const url = new URL(input.endpoint);
57+
58+ for (const [key, value] of Object.entries(input.queryParams)) {
59+ url.searchParams.set(key, value);
60+ }
61+
62+ return url;
63+ },
64+ catch: (cause) =>
65+ mcpConnectionError({
66+ transport: input.transport,
67+ message: "Failed building MCP endpoint URL",
68+ cause,
69+ }),
70+ });
71+
72+type FetchInput = Parameters<typeof fetch>[0];
73+type FetchInit = Parameters<typeof fetch>[1];
74+
75+const mergeHeadersForFetch = (
76+ input: FetchInput,
77+ init: FetchInit,
78+ headers: Record<string, string>,
79+): Promise<Response> => {
80+ const mergedHeaders = new Headers(init?.headers ?? {});
81+
82+ for (const [key, value] of Object.entries(headers)) {
83+ mergedHeaders.set(key, value);
84+ }
85+
86+ return fetch(input, {
87+ ...init,
88+ headers: mergedHeaders,
89+ });
90+};
91+
92+const connectionFromClient = (client: Client): McpConnection => ({
93+ client,
94+ close: () => client.close(),
95+});
96+
97+const closeClient = (client: Client): Effect.Effect<void, never, never> =>
98+ Effect.tryPromise({
99+ try: () => client.close(),
100+ catch: (cause) =>
101+ mcpConnectionError({
102+ transport: "auto",
103+ message: "Failed closing MCP client",
104+ cause,
105+ }),
106+ }).pipe(Effect.ignore);
107+
108+const connectClient = (input: {
109+ createClient: () => Client;
110+ transport: McpTransportPreference;
111+ createTransport: () => Parameters<Client["connect"]>[0];
112+}): Effect.Effect<McpConnection, McpConnectionError, never> =>
113+ Effect.gen(function* () {
114+ const client = input.createClient();
115+ const transportInstance = input.createTransport();
116+
117+ return yield* Effect.tryPromise({
118+ try: () => client.connect(transportInstance),
119+ catch: (cause) =>
120+ mcpConnectionError({
121+ transport: input.transport,
122+ message:
123+ `Failed connecting to MCP server via ${input.transport}: ${
124+ cause instanceof Error ? cause.message : String(cause)
125+ }`,
126+ cause,
127+ }),
128+ }).pipe(
129+ Effect.as(connectionFromClient(client)),
130+ Effect.onError(() => closeClient(client)),
131+ );
132+ });
133+
134+export const createSdkMcpConnector = (
135+ input: CreateSdkMcpConnectorInput,
136+): McpConnector => {
137+ const headers = input.headers ?? {};
138+ const transport = isMcpStdioTransport(input)
139+ ? "stdio"
140+ : (input.transport ?? "auto");
141+ const requestInit = Object.keys(headers).length > 0
142+ ? { headers }
143+ : undefined;
144+
145+ const createClient = () =>
146+ new Client(
147+ {
148+ name: input.clientName ?? "executor-codemode-mcp",
149+ version: input.clientVersion ?? "0.1.0",
150+ },
151+ { capabilities: { elicitation: { form: {}, url: {} } } },
152+ );
153+
154+ return Effect.gen(function* () {
155+ if (transport === "stdio") {
156+ const command = input.command?.trim();
157+ if (!command) {
158+ return yield* mcpConnectionError({
159+ transport: "stdio",
160+ message: "MCP stdio transport requires a command",
161+ cause: new Error("Missing MCP stdio command"),
162+ });
163+ }
164+
165+ return yield* connectClient({
166+ createClient,
167+ transport: "stdio",
168+ createTransport: () =>
169+ new StdioClientTransport({
170+ command,
171+ args: input.args ? [...input.args] : undefined,
172+ env: input.env,
173+ cwd: input.cwd?.trim().length ? input.cwd.trim() : undefined,
174+ }),
175+ });
176+ }
177+
178+ const endpoint = yield* createEndpoint({
179+ endpoint: input.endpoint,
180+ queryParams: input.queryParams ?? {},
181+ transport,
182+ });
183+
184+ const connectStreamableHttp = connectClient({
185+ createClient,
186+ transport: "streamable-http",
187+ createTransport: () =>
188+ new StreamableHTTPClientTransport(endpoint, {
189+ requestInit,
190+ authProvider: input.authProvider,
191+ }),
192+ });
193+
194+ if (transport === "streamable-http") {
195+ return yield* connectStreamableHttp;
196+ }
197+
198+ const connectSse = connectClient({
199+ createClient,
200+ transport: "sse",
201+ createTransport: () =>
202+ new SSEClientTransport(endpoint, {
203+ authProvider: input.authProvider,
204+ requestInit,
205+ eventSourceInit: requestInit
206+ ? {
207+ fetch: (requestInput: FetchInput, requestOptions: FetchInit) =>
208+ mergeHeadersForFetch(requestInput, requestOptions, headers),
209+ }
210+ : undefined,
211+ }),
212+ });
213+
214+ if (transport === "sse") {
215+ return yield* connectSse;
216+ }
217+
218+ return yield* connectStreamableHttp.pipe(
219+ Effect.catchAll(() => connectSse),
220+ );
221+ });
222+};
plugins/mcp/sdk/discovery.ts+84−0
@@ -0,0 +1,84 @@
1+import {
2+ defaultNameFromEndpoint,
3+ namespaceFromSourceName,
4+ noneAuthInference,
5+ supportedAuthInference,
6+ type SourceDiscoveryProbeInput,
7+ type SourceDiscoveryResult,
8+} from "@executor/source-core";
9+import { startMcpOAuthAuthorization } from "@executor/auth-mcp-oauth";
10+import * as Either from "effect/Either";
11+import * as Effect from "effect/Effect";
12+
13+import { createSdkMcpConnector } from "./connection";
14+import { discoverMcpToolsFromConnector } from "./tools";
15+
16+export const detectMcpSource = (
17+ input: SourceDiscoveryProbeInput,
18+): Effect.Effect<SourceDiscoveryResult | null, never, never> =>
19+ Effect.gen(function* () {
20+ const connector = createSdkMcpConnector({
21+ endpoint: input.normalizedUrl,
22+ headers: input.headers,
23+ transport: "auto",
24+ });
25+
26+ const discovered = yield* Effect.either(discoverMcpToolsFromConnector({
27+ connect: connector,
28+ sourceKey: "discovery",
29+ namespace: namespaceFromSourceName(defaultNameFromEndpoint(input.normalizedUrl)),
30+ }));
31+
32+ if (Either.isRight(discovered)) {
33+ const name = defaultNameFromEndpoint(input.normalizedUrl);
34+ return {
35+ detectedKind: "plugin",
36+ confidence: "high",
37+ endpoint: input.normalizedUrl,
38+ specUrl: null,
39+ name,
40+ namespace: namespaceFromSourceName(name),
41+ transport: "auto",
42+ authInference: noneAuthInference(
43+ "MCP tool discovery succeeded without an advertised auth requirement",
44+ "medium",
45+ ),
46+ toolCount: discovered.right.manifest.tools.length,
47+ warnings: [],
48+ } satisfies SourceDiscoveryResult;
49+ }
50+
51+ const oauthProbe = yield* Effect.either(startMcpOAuthAuthorization({
52+ endpoint: input.normalizedUrl,
53+ redirectUrl: "http://127.0.0.1/executor/discovery/oauth/callback",
54+ state: "source-discovery",
55+ }));
56+
57+ if (Either.isLeft(oauthProbe)) {
58+ return null;
59+ }
60+
61+ const name = defaultNameFromEndpoint(input.normalizedUrl);
62+ return {
63+ detectedKind: "plugin",
64+ confidence: "high",
65+ endpoint: input.normalizedUrl,
66+ specUrl: null,
67+ name,
68+ namespace: namespaceFromSourceName(name),
69+ transport: "auto",
70+ authInference: supportedAuthInference("oauth2", {
71+ confidence: "high",
72+ reason: "MCP endpoint advertised OAuth during discovery",
73+ headerName: "Authorization",
74+ prefix: "Bearer ",
75+ parameterName: null,
76+ parameterLocation: null,
77+ oauthAuthorizationUrl: oauthProbe.right.authorizationUrl,
78+ oauthTokenUrl: oauthProbe.right.authorizationServerUrl,
79+ oauthScopes: [],
80+ }),
81+ toolCount: null,
82+ warnings: ["OAuth is required before MCP tools can be listed."],
83+ } satisfies SourceDiscoveryResult;
84+ }).pipe(Effect.catchAll(() => Effect.succeed(null)));
plugins/mcp/sdk/elicitation-bridge.ts+119−0
@@ -0,0 +1,119 @@
1+import {
2+ ElicitRequestSchema,
3+ UrlElicitationRequiredError,
4+} from "@modelcontextprotocol/sdk/types.js";
5+import * as Either from "effect/Either";
6+import * as Option from "effect/Option";
7+import * as ParseResult from "effect/ParseResult";
8+import * as Schema from "effect/Schema";
9+
10+import type {
11+ ElicitationRequest,
12+ ElicitationResponse,
13+ ToolExecutionContext,
14+ ToolPath,
15+} from "@executor/codemode-core";
16+
17+const UnknownRecordSchema = Schema.Record({
18+ key: Schema.String,
19+ value: Schema.Unknown,
20+});
21+
22+const decodeUnknownRecordOption = Schema.decodeUnknownOption(UnknownRecordSchema);
23+
24+export const readUnknownRecord = (input: unknown): Record<string, unknown> => {
25+ const decoded = decodeUnknownRecordOption(input);
26+ return Option.isSome(decoded) ? decoded.value : {};
27+};
28+
29+const RawMcpElicitationParamsSchema = Schema.Struct({
30+ mode: Schema.optional(Schema.Union(Schema.Literal("form"), Schema.Literal("url"))),
31+ message: Schema.optional(Schema.String),
32+ requestedSchema: Schema.optional(UnknownRecordSchema),
33+ url: Schema.optional(Schema.String),
34+ elicitationId: Schema.optional(Schema.String),
35+ id: Schema.optional(Schema.String),
36+});
37+
38+const decodeMcpElicitationParamsEither = Schema.decodeUnknownEither(
39+ RawMcpElicitationParamsSchema,
40+);
41+
42+export const readMcpElicitationRequest = (input: unknown): ElicitationRequest => {
43+ const decoded = decodeMcpElicitationParamsEither(input);
44+ if (Either.isLeft(decoded)) {
45+ throw new Error(
46+ `Invalid MCP elicitation request params: ${ParseResult.TreeFormatter.formatErrorSync(decoded.left)}`,
47+ );
48+ }
49+
50+ const params = decoded.right;
51+ const message = params.message ?? "";
52+
53+ if (params.mode === "url") {
54+ return {
55+ mode: "url",
56+ message,
57+ url: params.url ?? "",
58+ elicitationId: params.elicitationId ?? params.id ?? "",
59+ };
60+ }
61+
62+ return {
63+ mode: "form",
64+ message,
65+ requestedSchema: params.requestedSchema ?? {},
66+ };
67+};
68+
69+export const toMcpElicitationResponse = (
70+ response: ElicitationResponse,
71+): { action: "accept" | "decline" | "cancel"; content?: Record<string, unknown> } =>
72+ response.action === "accept"
73+ ? {
74+ action: "accept",
75+ ...(response.content ? { content: response.content } : {}),
76+ }
77+ : { action: response.action };
78+
79+type McpClientWithElicitationHandler = {
80+ setRequestHandler: (
81+ schema: typeof ElicitRequestSchema,
82+ handler: (request: { params: unknown }) => Promise<unknown>,
83+ ) => void;
84+};
85+
86+export const hasElicitationRequestHandler = (
87+ value: unknown,
88+): value is McpClientWithElicitationHandler =>
89+ typeof (value as { setRequestHandler?: unknown }).setRequestHandler === "function";
90+
91+export const createInteractionId = (input: {
92+ path: ToolPath;
93+ invocation?: ToolExecutionContext["invocation"];
94+ elicitation: ElicitationRequest;
95+ sequence?: number;
96+}): string => {
97+ if (input.elicitation.mode === "url" && input.elicitation.elicitationId.length > 0) {
98+ return input.elicitation.elicitationId;
99+ }
100+
101+ const parts = [
102+ input.invocation?.runId,
103+ input.invocation?.callId,
104+ input.path,
105+ "mcp",
106+ typeof input.sequence === "number" ? String(input.sequence) : undefined,
107+ ].filter(
108+ (part): part is string => typeof part === "string" && part.length > 0,
109+ );
110+
111+ return parts.join(":");
112+};
113+
114+export const isUrlElicitationRequiredError = (
115+ cause: unknown,
116+): cause is UrlElicitationRequiredError =>
117+ cause instanceof UrlElicitationRequiredError
118+ && Array.isArray(cause.elicitations)
119+ && cause.elicitations.length > 0;
plugins/mcp/sdk/index.ts+1021−0
@@ -0,0 +1,1021 @@
1+import type {
2+ OAuthClientProvider,
3+ OAuthDiscoveryState,
4+} from "@modelcontextprotocol/sdk/client/auth.js";
5+import type {
6+ OAuthClientInformationMixed,
7+ OAuthTokens,
8+} from "@modelcontextprotocol/sdk/shared/auth.js";
9+import type {
10+ ToolExecutionContext,
11+ ToolInput,
12+ ToolPath,
13+} from "@executor/codemode-core";
14+import {
15+ exchangeMcpOAuthAuthorizationCode,
16+ startMcpOAuthAuthorization,
17+} from "@executor/auth-mcp-oauth";
18+import {
19+ contentHash,
20+ createCatalogImportMetadata,
21+ createSourceCatalogSyncResult,
22+} from "@executor/source-core";
23+import type { Source } from "@executor/platform-sdk/schema";
24+import type {
25+ ExecutorSdkPlugin,
26+ ExecutorSdkPluginHost,
27+ ExecutorSourceConnector,
28+ SourcePluginRuntime,
29+} from "@executor/platform-sdk/plugins";
30+import {
31+ SecretMaterialDeleterService,
32+ SecretMaterialResolverService,
33+ SecretMaterialStorerService,
34+ SecretMaterialUpdaterService,
35+ provideExecutorRuntime,
36+} from "@executor/platform-sdk/runtime";
37+import {
38+ McpConnectionAuthSchema,
39+ McpOAuthSessionSchema,
40+ McpStoredSourceDataSchema,
41+ deriveMcpNamespace,
42+ resolveMcpEndpoint,
43+ type McpConnectInput,
44+ type McpConnectionAuth,
45+ type McpOAuthPopupResult,
46+ type McpOAuthSession,
47+ type McpSourceConfigPayload,
48+ type McpStartOAuthInput,
49+ type McpStartOAuthResult,
50+ type McpStoredSourceData,
51+ type McpUpdateSourceInput,
52+} from "@executor/plugin-mcp-shared";
53+import * as Effect from "effect/Effect";
54+import * as Schema from "effect/Schema";
55+
56+import {
57+ createMcpCatalogFragment,
58+ type McpCatalogOperationInput,
59+} from "./catalog";
60+import {
61+ createPooledMcpConnector,
62+} from "./connection-pool";
63+import {
64+ createSdkMcpConnector,
65+ isMcpStdioTransport,
66+} from "./connection";
67+import {
68+ joinToolPath,
69+ type McpServerMetadata,
70+ type McpToolManifestEntry,
71+} from "./manifest";
72+import {
73+ createMcpToolsFromManifest,
74+ discoverMcpToolsFromConnector,
75+} from "./tools";
76+
77+const decodeStoredSourceData = Schema.decodeUnknownSync(McpStoredSourceDataSchema);
78+const decodeSession = Schema.decodeUnknownSync(McpOAuthSessionSchema);
79+
80+const McpExecutableBindingSchema = Schema.Struct({
81+ toolId: Schema.String,
82+ toolName: Schema.String,
83+ displayTitle: Schema.String,
84+ title: Schema.NullOr(Schema.String),
85+ description: Schema.NullOr(Schema.String),
86+ annotations: Schema.NullOr(Schema.Unknown),
87+ execution: Schema.NullOr(Schema.Unknown),
88+ icons: Schema.NullOr(Schema.Unknown),
89+ meta: Schema.NullOr(Schema.Unknown),
90+ rawTool: Schema.NullOr(Schema.Unknown),
91+ server: Schema.NullOr(Schema.Unknown),
92+});
93+
94+type McpExecutableBinding = typeof McpExecutableBindingSchema.Type;
95+
96+const decodeProviderData = Schema.decodeUnknownSync(McpExecutableBindingSchema);
97+
98+export type McpSourceStorage = {
99+ get: (input: {
100+ scopeId: string;
101+ sourceId: string;
102+ }) => Effect.Effect<McpStoredSourceData | null, Error, never>;
103+ put: (input: {
104+ scopeId: string;
105+ sourceId: string;
106+ value: McpStoredSourceData;
107+ }) => Effect.Effect<void, Error, never>;
108+ remove?: (input: {
109+ scopeId: string;
110+ sourceId: string;
111+ }) => Effect.Effect<void, Error, never>;
112+};
113+
114+export type McpOAuthSessionStorage = {
115+ get: (sessionId: string) => Effect.Effect<McpOAuthSession | null, Error, never>;
116+ put: (input: {
117+ sessionId: string;
118+ value: McpOAuthSession;
119+ }) => Effect.Effect<void, Error, never>;
120+ remove?: (sessionId: string) => Effect.Effect<void, Error, never>;
121+};
122+
123+export type McpSdk = {
124+ getSourceConfig: (
125+ sourceId: Source["id"],
126+ ) => Effect.Effect<McpSourceConfigPayload, Error>;
127+ createSource: (
128+ input: McpConnectInput,
129+ ) => Effect.Effect<Source, Error>;
130+ updateSource: (
131+ input: McpUpdateSourceInput,
132+ ) => Effect.Effect<Source, Error>;
133+ removeSource: (
134+ sourceId: Source["id"],
135+ ) => Effect.Effect<boolean, Error>;
136+ startOAuth: (
137+ input: McpStartOAuthInput,
138+ ) => Effect.Effect<McpStartOAuthResult, Error>;
139+ completeOAuth: (input: {
140+ state: string;
141+ code?: string;
142+ error?: string;
143+ errorDescription?: string;
144+ }) => Effect.Effect<Extract<McpOAuthPopupResult, { ok: true }>, Error>;
145+};
146+
147+const McpExecutorAddInputSchema = Schema.Struct({
148+ kind: Schema.optional(Schema.Literal("mcp")),
149+ name: Schema.String,
150+ endpoint: Schema.NullOr(Schema.String),
151+ transport: Schema.NullOr(Schema.Literal("streamable-http", "sse", "stdio", "auto")),
152+ queryParams: Schema.NullOr(Schema.Record({ key: Schema.String, value: Schema.String })),
153+ headers: Schema.NullOr(Schema.Record({ key: Schema.String, value: Schema.String })),
154+ command: Schema.NullOr(Schema.String),
155+ args: Schema.NullOr(Schema.Array(Schema.String)),
156+ env: Schema.NullOr(Schema.Record({ key: Schema.String, value: Schema.String })),
157+ cwd: Schema.NullOr(Schema.String),
158+ auth: McpConnectionAuthSchema,
159+});
160+
161+type McpExecutorAddInput = typeof McpExecutorAddInputSchema.Type;
162+
163+export type McpSdkPluginOptions = {
164+ storage: McpSourceStorage;
165+ oauthSessions: McpOAuthSessionStorage;
166+};
167+
168+const asRecord = (value: unknown): Record<string, unknown> =>
169+ typeof value === "object" && value !== null && !Array.isArray(value)
170+ ? (value as Record<string, unknown>)
171+ : {};
172+
173+const trimOrNull = (value: string | null | undefined): string | null => {
174+ if (typeof value !== "string") {
175+ return null;
176+ }
177+
178+ const trimmed = value.trim();
179+ return trimmed.length > 0 ? trimmed : null;
180+};
181+
182+const normalizeStringRecord = (
183+ value: Readonly<Record<string, string>> | null | undefined,
184+): Record<string, string> | null => {
185+ if (!value || Object.keys(value).length === 0) {
186+ return null;
187+ }
188+
189+ const normalized = Object.fromEntries(
190+ Object.entries(value).flatMap(([key, entry]) => {
191+ const trimmedKey = key.trim();
192+ const trimmedValue = entry.trim();
193+ return trimmedKey.length > 0 && trimmedValue.length > 0
194+ ? [[trimmedKey, trimmedValue]]
195+ : [];
196+ }),
197+ );
198+
199+ return Object.keys(normalized).length > 0 ? normalized : null;
200+};
201+
202+const normalizeStringArray = (
203+ value: ReadonlyArray<string> | null | undefined,
204+): Array<string> | null => {
205+ if (!value || value.length === 0) {
206+ return null;
207+ }
208+
209+ const normalized = value
210+ .map((entry) => entry.trim())
211+ .filter((entry) => entry.length > 0);
212+
213+ return normalized.length > 0 ? normalized : null;
214+};
215+
216+const normalizeStoredSourceData = (
217+ input: McpConnectInput | McpSourceConfigPayload,
218+): Effect.Effect<McpStoredSourceData, Error, never> =>
219+ Effect.gen(function* () {
220+ const endpoint = trimOrNull(input.endpoint);
221+ const command = trimOrNull(input.command);
222+ const transport = input.transport ?? null;
223+ const queryParams = normalizeStringRecord(input.queryParams);
224+ const headers = normalizeStringRecord(input.headers);
225+ const args = normalizeStringArray(input.args);
226+ const env = normalizeStringRecord(input.env);
227+ const cwd = trimOrNull(input.cwd);
228+ const stdio = isMcpStdioTransport({
229+ transport: transport ?? undefined,
230+ command: command ?? undefined,
231+ });
232+
233+ if (stdio) {
234+ if (command === null) {
235+ return yield* Effect.fail(
236+ new Error("MCP stdio transport requires a command."),
237+ );
238+ }
239+
240+ if (queryParams !== null) {
241+ return yield* Effect.fail(
242+ new Error("MCP stdio transport does not support query params."),
243+ );
244+ }
245+
246+ if (headers !== null) {
247+ return yield* Effect.fail(
248+ new Error("MCP stdio transport does not support request headers."),
249+ );
250+ }
251+
252+ return decodeStoredSourceData({
253+ endpoint: null,
254+ transport: "stdio",
255+ queryParams: null,
256+ headers: null,
257+ command,
258+ args,
259+ env,
260+ cwd,
261+ auth: input.auth,
262+ });
263+ }
264+
265+ if (endpoint === null) {
266+ return yield* Effect.fail(
267+ new Error("MCP remote transports require an endpoint."),
268+ );
269+ }
270+
271+ if (command !== null || args !== null || env !== null || cwd !== null) {
272+ return yield* Effect.fail(
273+ new Error('MCP process settings require transport "stdio".'),
274+ );
275+ }
276+
277+ return decodeStoredSourceData({
278+ endpoint,
279+ transport,
280+ queryParams,
281+ headers,
282+ command: null,
283+ args: null,
284+ env: null,
285+ cwd: null,
286+ auth: input.auth,
287+ });
288+ });
289+
290+const sourceConfigFromStored = (
291+ source: Source,
292+ stored: McpStoredSourceData,
293+): McpSourceConfigPayload => ({
294+ name: source.name,
295+ endpoint: stored.endpoint,
296+ transport: stored.transport,
297+ queryParams: stored.queryParams,
298+ headers: stored.headers,
299+ command: stored.command,
300+ args: stored.args,
301+ env: stored.env,
302+ cwd: stored.cwd,
303+ auth: stored.auth,
304+});
305+
306+const createMcpSourceSdk = (
307+ options: McpSdkPluginOptions,
308+ host: ExecutorSdkPluginHost,
309+) => ({
310+ getSourceConfig: (sourceId: Source["id"]) =>
311+ Effect.gen(function* () {
312+ const source = yield* host.sources.get(sourceId);
313+ if (source.kind !== "mcp") {
314+ return yield* Effect.fail(
315+ new Error(`Source ${sourceId} is not an MCP source.`),
316+ );
317+ }
318+
319+ const stored = yield* options.storage.get({
320+ scopeId: source.scopeId,
321+ sourceId: source.id,
322+ });
323+ if (stored === null) {
324+ return yield* Effect.fail(
325+ new Error(`MCP source storage missing for ${source.id}`),
326+ );
327+ }
328+
329+ return sourceConfigFromStored(source, stored);
330+ }),
331+ createSource: (input: McpConnectInput) =>
332+ Effect.gen(function* () {
333+ const stored = yield* normalizeStoredSourceData(input);
334+ const createdSource = yield* host.sources.create({
335+ source: {
336+ name: input.name.trim(),
337+ kind: "mcp",
338+ status: "connected",
339+ enabled: true,
340+ namespace: deriveMcpNamespace({
341+ name: input.name,
342+ endpoint: stored.endpoint,
343+ command: stored.command,
344+ }),
345+ },
346+ });
347+
348+ yield* options.storage.put({
349+ scopeId: createdSource.scopeId,
350+ sourceId: createdSource.id,
351+ value: stored,
352+ });
353+
354+ return yield* host.sources.refreshCatalog(createdSource.id);
355+ }),
356+ updateSource: (input: McpUpdateSourceInput) =>
357+ Effect.gen(function* () {
358+ const source = yield* host.sources.get(input.sourceId as Source["id"]);
359+ if (source.kind !== "mcp") {
360+ return yield* Effect.fail(
361+ new Error(`Source ${input.sourceId} is not an MCP source.`),
362+ );
363+ }
364+
365+ const stored = yield* normalizeStoredSourceData(input.config);
366+ const savedSource = yield* host.sources.save({
367+ ...source,
368+ name: input.config.name.trim(),
369+ namespace: deriveMcpNamespace({
370+ name: input.config.name,
371+ endpoint: stored.endpoint,
372+ command: stored.command,
373+ }),
374+ });
375+
376+ yield* options.storage.put({
377+ scopeId: savedSource.scopeId,
378+ sourceId: savedSource.id,
379+ value: stored,
380+ });
381+
382+ return yield* host.sources.refreshCatalog(savedSource.id);
383+ }),
384+ removeSource: (sourceId: Source["id"]) =>
385+ Effect.gen(function* () {
386+ const source = yield* host.sources.get(sourceId);
387+ if (source.kind !== "mcp") {
388+ return yield* Effect.fail(
389+ new Error(`Source ${sourceId} is not an MCP source.`),
390+ );
391+ }
392+
393+ const stored = yield* options.storage.get({
394+ scopeId: source.scopeId,
395+ sourceId: source.id,
396+ });
397+ if (stored?.auth.kind === "oauth2") {
398+ const deleteSecretMaterial = yield* SecretMaterialDeleterService;
399+ yield* Effect.either(deleteSecretMaterial(stored.auth.accessTokenRef));
400+ if (stored.auth.refreshTokenRef) {
401+ yield* Effect.either(deleteSecretMaterial(stored.auth.refreshTokenRef));
402+ }
403+ }
404+
405+ if (options.storage.remove) {
406+ yield* options.storage.remove({
407+ scopeId: source.scopeId,
408+ sourceId: source.id,
409+ });
410+ }
411+
412+ return yield* host.sources.remove(source.id);
413+ }),
414+});
415+
416+const mcpSourceConnector = (
417+ options: McpSdkPluginOptions,
418+): ExecutorSourceConnector<McpExecutorAddInput> => ({
419+ kind: "mcp",
420+ displayName: "MCP",
421+ inputSchema: McpExecutorAddInputSchema,
422+ inputSignatureWidth: 320,
423+ helpText: [
424+ "Remote MCP sources use `endpoint`; local stdio sources use `command` plus `transport: \"stdio\"`.",
425+ "OAuth browser setup stays on the plugin-owned HTTP/UI surfaces.",
426+ ],
427+ createSource: ({ args, host }) =>
428+ createMcpSourceSdk(options, host).createSource(args),
429+});
430+
431+const mcpCatalogOperationFromManifestEntry = (input: {
432+ entry: McpToolManifestEntry;
433+ server: McpServerMetadata | null | undefined;
434+}): McpCatalogOperationInput => ({
435+ toolId: input.entry.toolId,
436+ title:
437+ input.entry.displayTitle
438+ ?? input.entry.title
439+ ?? input.entry.toolName,
440+ description: input.entry.description ?? null,
441+ effect: input.entry.annotations?.readOnlyHint === true ? "read" : "write",
442+ inputSchema: input.entry.inputSchema,
443+ outputSchema: input.entry.outputSchema,
444+ providerData: {
445+ toolId: input.entry.toolId,
446+ toolName: input.entry.toolName,
447+ displayTitle:
448+ input.entry.displayTitle
449+ ?? input.entry.title
450+ ?? input.entry.toolName,
451+ title: input.entry.title ?? null,
452+ description: input.entry.description ?? null,
453+ annotations: input.entry.annotations ?? null,
454+ execution: input.entry.execution ?? null,
455+ icons: input.entry.icons ?? null,
456+ meta: input.entry.meta ?? null,
457+ rawTool: input.entry.rawTool ?? null,
458+ server: input.server ?? null,
459+ },
460+});
461+
462+const expiresAtFromTokens = (tokens: OAuthTokens): number | null =>
463+ typeof tokens.expires_in === "number" && Number.isFinite(tokens.expires_in)
464+ ? Date.now() + Math.max(0, tokens.expires_in) * 1000
465+ : null;
466+
467+const expiresInFromAuth = (
468+ auth: Extract<McpConnectionAuth, { kind: "oauth2" }>,
469+): number | undefined => {
470+ if (auth.expiresAt === null) {
471+ return undefined;
472+ }
473+
474+ return Math.max(0, Math.ceil((auth.expiresAt - Date.now()) / 1000));
475+};
476+
477+const createPersistedMcpAuthProvider = (input: {
478+ scopeId: string;
479+ sourceId: string;
480+ stored: McpStoredSourceData;
481+ storage: McpSourceStorage;
482+}): Effect.Effect<OAuthClientProvider, Error, any> =>
483+ Effect.gen(function* () {
484+ if (input.stored.auth.kind !== "oauth2") {
485+ return yield* Effect.fail(
486+ new Error(`Source ${input.sourceId} is not configured for MCP OAuth.`),
487+ );
488+ }
489+
490+ const resolveSecretMaterial = yield* SecretMaterialResolverService;
491+ const storeSecretMaterial = yield* SecretMaterialStorerService;
492+ const updateSecretMaterial = yield* SecretMaterialUpdaterService;
493+ const deleteSecretMaterial = yield* SecretMaterialDeleterService;
494+
495+ let currentStored = input.stored;
496+ let currentAuth = input.stored.auth;
497+
498+ const persistAuth = (
499+ nextAuth: Extract<McpConnectionAuth, { kind: "oauth2" }>,
500+ ) =>
501+ input.storage.put({
502+ scopeId: input.scopeId,
503+ sourceId: input.sourceId,
504+ value: {
505+ ...currentStored,
506+ auth: nextAuth,
507+ },
508+ }).pipe(
509+ Effect.tap(() =>
510+ Effect.sync(() => {
511+ currentAuth = nextAuth;
512+ currentStored = {
513+ ...currentStored,
514+ auth: nextAuth,
515+ };
516+ })
517+ ),
518+ );
519+
520+ return {
521+ get redirectUrl() {
522+ return currentAuth.redirectUri;
523+ },
524+
525+ get clientMetadata() {
526+ return {
527+ redirect_uris: [currentAuth.redirectUri],
528+ grant_types: ["authorization_code", "refresh_token"],
529+ response_types: ["code"],
530+ token_endpoint_auth_method: "none",
531+ client_name: "Executor Local",
532+ };
533+ },
534+
535+ clientInformation: () =>
536+ currentAuth.clientInformation as OAuthClientInformationMixed | undefined,
537+
538+ saveClientInformation: (clientInformation) =>
539+ Effect.runPromise(
540+ persistAuth({
541+ ...currentAuth,
542+ clientInformation:
543+ (clientInformation as Extract<McpConnectionAuth, { kind: "oauth2" }>["clientInformation"])
544+ ?? null,
545+ }),
546+ ).then(() => undefined),
547+
548+ tokens: async () => {
549+ const accessToken = await Effect.runPromise(
550+ resolveSecretMaterial({
551+ ref: currentAuth.accessTokenRef,
552+ }),
553+ );
554+ const refreshToken = currentAuth.refreshTokenRef
555+ ? await Effect.runPromise(
556+ resolveSecretMaterial({
557+ ref: currentAuth.refreshTokenRef,
558+ }),
559+ )
560+ : undefined;
561+
562+ return {
563+ access_token: accessToken,
564+ token_type: currentAuth.tokenType,
565+ ...(refreshToken ? { refresh_token: refreshToken } : {}),
566+ ...(currentAuth.scope ? { scope: currentAuth.scope } : {}),
567+ ...(expiresInFromAuth(currentAuth) !== undefined
568+ ? { expires_in: expiresInFromAuth(currentAuth) }
569+ : {}),
570+ } satisfies OAuthTokens;
571+ },
572+
573+ saveTokens: (tokens) =>
574+ Effect.runPromise(
575+ Effect.gen(function* () {
576+ yield* updateSecretMaterial({
577+ ref: currentAuth.accessTokenRef,
578+ value: tokens.access_token,
579+ });
580+
581+ let refreshTokenRef = currentAuth.refreshTokenRef;
582+ if (tokens.refresh_token) {
583+ if (refreshTokenRef) {
584+ yield* updateSecretMaterial({
585+ ref: refreshTokenRef,
586+ value: tokens.refresh_token,
587+ });
588+ } else {
589+ refreshTokenRef = yield* storeSecretMaterial({
590+ purpose: "oauth_refresh_token",
591+ value: tokens.refresh_token,
592+ name: `${input.sourceId} MCP Refresh Token`,
593+ });
594+ }
595+ }
596+
597+ yield* persistAuth({
598+ ...currentAuth,
599+ refreshTokenRef,
600+ tokenType: tokens.token_type ?? currentAuth.tokenType,
601+ expiresAt: expiresAtFromTokens(tokens) ?? currentAuth.expiresAt,
602+ scope: tokens.scope ?? currentAuth.scope,
603+ });
604+ }).pipe(
605+ Effect.catchAll((error) =>
606+ Effect.gen(function* () {
607+ if (currentAuth.refreshTokenRef && tokens.refresh_token === undefined) {
608+ yield* Effect.either(deleteSecretMaterial(currentAuth.refreshTokenRef));
609+ }
610+ return yield* Effect.fail(error);
611+ })
612+ ),
613+ ),
614+ ).then(() => undefined),
615+
616+ redirectToAuthorization: async (authorizationUrl) => {
617+ throw new Error(
618+ `MCP OAuth re-authorization is required for ${input.sourceId}: ${authorizationUrl.toString()}`,
619+ );
620+ },
621+
622+ saveCodeVerifier: () => undefined,
623+
624+ codeVerifier: () => {
625+ throw new Error("Persisted MCP OAuth sessions do not retain an active PKCE verifier.");
626+ },
627+
628+ saveDiscoveryState: (state) =>
629+ Effect.runPromise(
630+ persistAuth({
631+ ...currentAuth,
632+ resourceMetadataUrl: state.resourceMetadataUrl ?? null,
633+ authorizationServerUrl: state.authorizationServerUrl ?? null,
634+ resourceMetadata:
635+ (state.resourceMetadata as Extract<McpConnectionAuth, { kind: "oauth2" }>["resourceMetadata"])
636+ ?? null,
637+ authorizationServerMetadata:
638+ (state.authorizationServerMetadata as Extract<McpConnectionAuth, { kind: "oauth2" }>["authorizationServerMetadata"])
639+ ?? null,
640+ }),
641+ ).then(() => undefined),
642+
643+ discoveryState: () =>
644+ currentAuth.authorizationServerUrl === null
645+ ? undefined
646+ : {
647+ resourceMetadataUrl: currentAuth.resourceMetadataUrl ?? undefined,
648+ authorizationServerUrl: currentAuth.authorizationServerUrl,
649+ resourceMetadata:
650+ currentAuth.resourceMetadata as OAuthDiscoveryState["resourceMetadata"],
651+ authorizationServerMetadata:
652+ currentAuth.authorizationServerMetadata as OAuthDiscoveryState["authorizationServerMetadata"],
653+ },
654+ } satisfies OAuthClientProvider;
655+ });
656+
657+const createStoredMcpConnector = (input: {
658+ scopeId: string;
659+ sourceId: string;
660+ stored: McpStoredSourceData;
661+ storage: McpSourceStorage;
662+}): Effect.Effect<ReturnType<typeof createSdkMcpConnector>, Error, any> =>
663+ Effect.gen(function* () {
664+ const authProvider =
665+ input.stored.auth.kind === "oauth2"
666+ ? yield* createPersistedMcpAuthProvider(input)
667+ : undefined;
668+
669+ return createSdkMcpConnector({
670+ endpoint: input.stored.endpoint ?? undefined,
671+ transport: input.stored.transport ?? undefined,
672+ queryParams: input.stored.queryParams ?? undefined,
673+ headers: input.stored.headers ?? undefined,
674+ authProvider,
675+ command: input.stored.command ?? undefined,
676+ args: input.stored.args ?? undefined,
677+ env: input.stored.env ?? undefined,
678+ cwd: input.stored.cwd ?? undefined,
679+ });
680+ });
681+
682+const mcpDocumentKey = (stored: McpStoredSourceData): string =>
683+ stored.endpoint
684+ ?? (stored.command
685+ ? `stdio://${stored.command}`
686+ : "mcp");
687+
688+const createMcpSourceRuntime = (
689+ options: McpSdkPluginOptions,
690+): SourcePluginRuntime => ({
691+ kind: "mcp",
692+ displayName: "MCP",
693+ catalogKind: "imported",
694+ catalogIdentity: ({ source }) => ({
695+ kind: "mcp",
696+ sourceId: source.id,
697+ }),
698+ getIrModel: ({ source }) =>
699+ Effect.gen(function* () {
700+ const stored = yield* options.storage.get({
701+ scopeId: source.scopeId,
702+ sourceId: source.id,
703+ });
704+ if (stored === null) {
705+ return createSourceCatalogSyncResult({
706+ fragment: {
707+ version: "ir.v1.fragment",
708+ },
709+ importMetadata: {
710+ ...createCatalogImportMetadata({
711+ source,
712+ pluginKey: "mcp",
713+ }),
714+ importerVersion: "ir.v1.mcp",
715+ sourceConfigHash: "missing",
716+ },
717+ sourceHash: null,
718+ });
719+ }
720+
721+ const connector = yield* createStoredMcpConnector({
722+ scopeId: source.scopeId,
723+ sourceId: source.id,
724+ stored,
725+ storage: options.storage,
726+ });
727+ const discovered = yield* discoverMcpToolsFromConnector({
728+ connect: connector,
729+ namespace:
730+ source.namespace
731+ ?? deriveMcpNamespace({
732+ name: source.name,
733+ endpoint: stored.endpoint,
734+ command: stored.command,
735+ })
736+ ?? undefined,
737+ sourceKey: source.id,
738+ }).pipe(
739+ Effect.mapError((cause) =>
740+ cause instanceof Error
741+ ? cause
742+ : new Error(String(cause)),
743+ ),
744+ );
745+ const manifestJson = JSON.stringify(discovered.manifest);
746+ const now = Date.now();
747+
748+ return createSourceCatalogSyncResult({
749+ fragment: createMcpCatalogFragment({
750+ source,
751+ documents: [
752+ {
753+ documentKind: "mcp_manifest",
754+ documentKey: mcpDocumentKey(stored),
755+ contentText: manifestJson,
756+ fetchedAt: now,
757+ },
758+ ],
759+ operations: discovered.manifest.tools.map((entry) =>
760+ mcpCatalogOperationFromManifestEntry({
761+ entry,
762+ server: discovered.manifest.server,
763+ })
764+ ),
765+ }),
766+ importMetadata: {
767+ ...createCatalogImportMetadata({
768+ source,
769+ pluginKey: "mcp",
770+ }),
771+ importerVersion: "ir.v1.mcp",
772+ },
773+ sourceHash: contentHash(manifestJson),
774+ });
775+ }),
776+ invoke: (input) =>
777+ Effect.gen(function* () {
778+ const stored = yield* options.storage.get({
779+ scopeId: input.source.scopeId,
780+ sourceId: input.source.id,
781+ });
782+ if (stored === null) {
783+ return yield* Effect.fail(
784+ new Error(`MCP source storage missing for ${input.source.id}`),
785+ );
786+ }
787+
788+ const providerData = decodeProviderData(
789+ input.executable.binding,
790+ ) as McpExecutableBinding;
791+ const connector = createPooledMcpConnector({
792+ connect: yield* createStoredMcpConnector({
793+ scopeId: input.source.scopeId,
794+ sourceId: input.source.id,
795+ stored,
796+ storage: options.storage,
797+ }),
798+ runId:
799+ typeof input.context?.runId === "string" && input.context.runId.length > 0
800+ ? input.context.runId
801+ : undefined,
802+ sourceKey: input.source.id,
803+ });
804+ const manifest = {
805+ version: 2 as const,
806+ server: providerData.server as McpServerMetadata | null,
807+ tools: [
808+ {
809+ toolId: providerData.toolId,
810+ toolName: providerData.toolName,
811+ displayTitle: providerData.displayTitle,
812+ title: providerData.title,
813+ description: providerData.description,
814+ annotations: providerData.annotations as McpToolManifestEntry["annotations"],
815+ execution: providerData.execution as McpToolManifestEntry["execution"],
816+ icons: providerData.icons as McpToolManifestEntry["icons"],
817+ meta: providerData.meta,
818+ rawTool: providerData.rawTool,
819+ inputSchema: input.descriptor.contract?.inputSchema,
820+ outputSchema: input.descriptor.contract?.outputSchema,
821+ },
822+ ],
823+ };
824+ const tools = createMcpToolsFromManifest({
825+ manifest,
826+ connect: connector,
827+ namespace: input.source.namespace ?? undefined,
828+ sourceKey: input.source.id,
829+ });
830+ const toolPath = joinToolPath(input.source.namespace ?? undefined, providerData.toolId);
831+ const entry = (
832+ tools[toolPath]
833+ ?? tools[providerData.toolId]
834+ ?? tools[providerData.toolName]
835+ ) as ToolInput | undefined;
836+ const definition =
837+ entry && typeof entry === "object" && entry !== null && "tool" in entry
838+ ? entry.tool
839+ : entry;
840+
841+ if (!definition) {
842+ return yield* Effect.fail(
843+ new Error(`Missing MCP tool definition for ${providerData.toolName}`),
844+ );
845+ }
846+
847+ const inputShape = input.executable.projection.callShapeId
848+ ? input.catalog.symbols[input.executable.projection.callShapeId]
849+ : undefined;
850+ const payload =
851+ inputShape?.kind === "shape" && inputShape.node.type !== "object"
852+ ? asRecord(input.args).input
853+ : input.args;
854+ const executionContext: ToolExecutionContext | undefined =
855+ input.onElicitation
856+ ? {
857+ path: input.descriptor.path as ToolPath,
858+ sourceKey: input.source.id,
859+ metadata: {
860+ sourceKey: input.source.id,
861+ interaction: input.descriptor.interaction,
862+ contract: {
863+ ...(input.descriptor.contract?.inputSchema !== undefined
864+ ? { inputSchema: input.descriptor.contract.inputSchema }
865+ : {}),
866+ ...(input.descriptor.contract?.outputSchema !== undefined
867+ ? { outputSchema: input.descriptor.contract.outputSchema }
868+ : {}),
869+ },
870+ pluginKind: input.descriptor.pluginKind,
871+ pluginData: input.descriptor.pluginData,
872+ },
873+ invocation: input.context,
874+ onElicitation: input.onElicitation,
875+ }
876+ : undefined;
877+ const result = yield* Effect.tryPromise({
878+ try: async () =>
879+ await definition.execute(asRecord(payload), executionContext),
880+ catch: (cause) =>
881+ cause instanceof Error ? cause : new Error(String(cause)),
882+ });
883+ const resultRecord = asRecord(result);
884+ const isError = resultRecord.isError === true;
885+
886+ return {
887+ data: isError ? null : (result ?? null),
888+ error: isError ? result : null,
889+ headers: {},
890+ status: null,
891+ };
892+ }),
893+});
894+
895+export const mcpSdkPlugin = (
896+ options: McpSdkPluginOptions,
897+): ExecutorSdkPlugin<"mcp", McpSdk> => ({
898+ key: "mcp",
899+ sources: [createMcpSourceRuntime(options)],
900+ sourceConnectors: [mcpSourceConnector(options)],
901+ extendExecutor: ({ host, executor }) => {
902+ const sourceSdk = createMcpSourceSdk(options, host);
903+ const provideRuntime = <A>(
904+ effect: Effect.Effect<A, Error, any>,
905+ ): Effect.Effect<A, Error, never> =>
906+ provideExecutorRuntime(effect, executor.runtime);
907+
908+ return {
909+ getSourceConfig: (sourceId) =>
910+ provideRuntime(sourceSdk.getSourceConfig(sourceId)),
911+ createSource: (input) =>
912+ provideRuntime(sourceSdk.createSource(input)),
913+ updateSource: (input) =>
914+ provideRuntime(sourceSdk.updateSource(input)),
915+ removeSource: (sourceId) =>
916+ provideRuntime(sourceSdk.removeSource(sourceId)),
917+ startOAuth: (input) =>
918+ provideRuntime(
919+ Effect.gen(function* () {
920+ const endpoint = resolveMcpEndpoint({
921+ endpoint: input.endpoint.trim(),
922+ queryParams: input.queryParams,
923+ });
924+ const sessionId = `mcp_oauth_${crypto.randomUUID()}`;
925+ const started = yield* startMcpOAuthAuthorization({
926+ endpoint,
927+ redirectUrl: input.redirectUrl,
928+ state: sessionId,
929+ });
930+
931+ yield* options.oauthSessions.put({
932+ sessionId,
933+ value: decodeSession({
934+ endpoint,
935+ redirectUrl: input.redirectUrl,
936+ codeVerifier: started.codeVerifier,
937+ resourceMetadataUrl: started.resourceMetadataUrl,
938+ authorizationServerUrl: started.authorizationServerUrl,
939+ resourceMetadata: started.resourceMetadata,
940+ authorizationServerMetadata: started.authorizationServerMetadata,
941+ clientInformation: started.clientInformation,
942+ }),
943+ });
944+
945+ return {
946+ sessionId,
947+ authorizationUrl: started.authorizationUrl,
948+ };
949+ }),
950+ ),
951+ completeOAuth: (input) =>
952+ provideRuntime(
953+ Effect.gen(function* () {
954+ if (input.error) {
955+ return yield* Effect.fail(
956+ new Error(input.errorDescription || input.error || "MCP OAuth failed"),
957+ );
958+ }
959+ if (!input.code) {
960+ return yield* Effect.fail(new Error("Missing MCP OAuth code."));
961+ }
962+
963+ const session = yield* options.oauthSessions.get(input.state);
964+ if (session === null) {
965+ return yield* Effect.fail(
966+ new Error(`MCP OAuth session not found: ${input.state}`),
967+ );
968+ }
969+
970+ const exchanged = yield* exchangeMcpOAuthAuthorizationCode({
971+ session,
972+ code: input.code,
973+ });
974+ const storeSecretMaterial = yield* SecretMaterialStorerService;
975+ const accessTokenRef = yield* storeSecretMaterial({
976+ purpose: "oauth_access_token",
977+ value: exchanged.tokens.access_token,
978+ name: "MCP Access Token",
979+ });
980+ const refreshTokenRef = exchanged.tokens.refresh_token
981+ ? yield* storeSecretMaterial({
982+ purpose: "oauth_refresh_token",
983+ value: exchanged.tokens.refresh_token,
984+ name: "MCP Refresh Token",
985+ })
986+ : null;
987+
988+ if (options.oauthSessions.remove) {
989+ yield* options.oauthSessions.remove(input.state);
990+ }
991+
992+ return {
993+ type: "executor:oauth-result",
994+ ok: true,
995+ sessionId: input.state,
996+ auth: {
997+ kind: "oauth2",
998+ redirectUri: session.redirectUrl,
999+ accessTokenRef,
1000+ refreshTokenRef,
1001+ tokenType: exchanged.tokens.token_type ?? "Bearer",
1002+ expiresAt: expiresAtFromTokens(exchanged.tokens),
1003+ scope: exchanged.tokens.scope ?? null,
1004+ resourceMetadataUrl:
1005+ exchanged.resourceMetadataUrl ?? session.resourceMetadataUrl,
1006+ authorizationServerUrl:
1007+ exchanged.authorizationServerUrl ?? session.authorizationServerUrl,
1008+ resourceMetadata:
1009+ exchanged.resourceMetadata ?? session.resourceMetadata,
1010+ authorizationServerMetadata:
1011+ exchanged.authorizationServerMetadata
1012+ ?? session.authorizationServerMetadata,
1013+ clientInformation:
1014+ exchanged.clientInformation ?? session.clientInformation,
1015+ },
1016+ };
1017+ }),
1018+ ),
1019+ };
1020+ },
1021+});
plugins/mcp/sdk/manifest.ts+363−0
@@ -0,0 +1,363 @@
1+import * as Option from "effect/Option";
2+import * as Schema from "effect/Schema";
3+
4+import type { ToolPath } from "@executor/codemode-core";
5+
6+export type McpToolAnnotations = {
7+ title: string | null;
8+ readOnlyHint: boolean | null;
9+ destructiveHint: boolean | null;
10+ idempotentHint: boolean | null;
11+ openWorldHint: boolean | null;
12+};
13+
14+export type McpToolExecution = {
15+ taskSupport: "forbidden" | "optional" | "required" | null;
16+};
17+
18+export type McpServerInfo = {
19+ name: string;
20+ version: string;
21+ title: string | null;
22+ description: string | null;
23+ websiteUrl: string | null;
24+ icons: unknown[] | null;
25+};
26+
27+export type McpServerCapabilities = {
28+ experimental: Record<string, unknown> | null;
29+ logging: boolean;
30+ completions: boolean;
31+ prompts: {
32+ listChanged: boolean;
33+ } | null;
34+ resources: {
35+ subscribe: boolean;
36+ listChanged: boolean;
37+ } | null;
38+ tools: {
39+ listChanged: boolean;
40+ } | null;
41+ tasks: {
42+ list: boolean;
43+ cancel: boolean;
44+ toolCall: boolean;
45+ } | null;
46+};
47+
48+export type McpServerMetadata = {
49+ info: McpServerInfo | null;
50+ capabilities: McpServerCapabilities | null;
51+ instructions: string | null;
52+ rawInfo: unknown;
53+ rawCapabilities: unknown;
54+};
55+
56+export type McpListToolsMetadata = {
57+ nextCursor: string | null;
58+ meta: unknown;
59+ rawResult: unknown;
60+};
61+
62+export type McpToolManifestEntry = {
63+ toolId: string;
64+ toolName: string;
65+ description: string | null;
66+ title?: string | null;
67+ displayTitle?: string | null;
68+ annotations?: McpToolAnnotations | null;
69+ execution?: McpToolExecution | null;
70+ icons?: unknown[] | null;
71+ meta?: unknown;
72+ rawTool?: unknown;
73+ inputSchema?: unknown;
74+ outputSchema?: unknown;
75+};
76+
77+export type McpToolManifest = {
78+ version: 2;
79+ server?: McpServerMetadata | null;
80+ listTools?: McpListToolsMetadata | null;
81+ tools: readonly McpToolManifestEntry[];
82+};
83+
84+const sanitizeToolId = (value: string): string => {
85+ const normalized = value
86+ .trim()
87+ .toLowerCase()
88+ .replace(/[^a-z0-9]+/g, "_")
89+ .replace(/^_+|_+$/g, "");
90+
91+ return normalized.length > 0 ? normalized : "tool";
92+};
93+
94+const uniqueToolId = (value: string, byBase: Map<string, number>): string => {
95+ const base = sanitizeToolId(value);
96+ const count = (byBase.get(base) ?? 0) + 1;
97+ byBase.set(base, count);
98+
99+ return count === 1 ? base : `${base}_${count}`;
100+};
101+
102+const asObject = (value: unknown): Record<string, unknown> =>
103+ value !== null && typeof value === "object" && !Array.isArray(value)
104+ ? (value as Record<string, unknown>)
105+ : {};
106+
107+const asOptionalObject = (value: unknown): Record<string, unknown> | null => {
108+ const objectValue = asObject(value);
109+ return Object.keys(objectValue).length > 0 ? objectValue : null;
110+};
111+
112+const hasOwn = (value: Record<string, unknown>, key: string): boolean =>
113+ Object.prototype.hasOwnProperty.call(value, key);
114+
115+const asString = (value: unknown): string | null =>
116+ typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
117+
118+const asBoolean = (value: unknown): boolean | null =>
119+ typeof value === "boolean" ? value : null;
120+
121+const asUnknownArray = (value: unknown): unknown[] | null =>
122+ Array.isArray(value) ? value : null;
123+
124+const ToolAnnotationsSchema = Schema.Struct({
125+ title: Schema.optional(Schema.NullOr(Schema.String)),
126+ readOnlyHint: Schema.optional(Schema.Boolean),
127+ destructiveHint: Schema.optional(Schema.Boolean),
128+ idempotentHint: Schema.optional(Schema.Boolean),
129+ openWorldHint: Schema.optional(Schema.Boolean),
130+});
131+
132+const ToolExecutionSchema = Schema.Struct({
133+ taskSupport: Schema.optional(
134+ Schema.Literal("forbidden", "optional", "required"),
135+ ),
136+});
137+
138+const ListedMcpToolSchema = Schema.Struct({
139+ name: Schema.String,
140+ title: Schema.optional(Schema.NullOr(Schema.String)),
141+ description: Schema.optional(Schema.NullOr(Schema.String)),
142+ inputSchema: Schema.optional(Schema.Unknown),
143+ parameters: Schema.optional(Schema.Unknown),
144+ outputSchema: Schema.optional(Schema.Unknown),
145+ annotations: Schema.optional(ToolAnnotationsSchema),
146+ execution: Schema.optional(ToolExecutionSchema),
147+ icons: Schema.optional(Schema.Unknown),
148+ _meta: Schema.optional(Schema.Unknown),
149+});
150+
151+const ListToolsResultSchema = Schema.Struct({
152+ tools: Schema.Array(ListedMcpToolSchema),
153+ nextCursor: Schema.optional(Schema.NullOr(Schema.String)),
154+ _meta: Schema.optional(Schema.Unknown),
155+});
156+
157+const decodeListToolsResultOption = Schema.decodeUnknownOption(ListToolsResultSchema);
158+
159+const readListedTools = (value: unknown): ReadonlyArray<typeof ListedMcpToolSchema.Type> => {
160+ const decoded = decodeListToolsResultOption(value);
161+ if (Option.isNone(decoded)) {
162+ return [];
163+ }
164+
165+ return decoded.value.tools;
166+};
167+
168+const normalizeToolAnnotations = (value: unknown): McpToolAnnotations | null => {
169+ const objectValue = asOptionalObject(value);
170+ if (objectValue === null) {
171+ return null;
172+ }
173+
174+ const annotations = {
175+ title: asString(objectValue.title),
176+ readOnlyHint: asBoolean(objectValue.readOnlyHint),
177+ destructiveHint: asBoolean(objectValue.destructiveHint),
178+ idempotentHint: asBoolean(objectValue.idempotentHint),
179+ openWorldHint: asBoolean(objectValue.openWorldHint),
180+ } satisfies McpToolAnnotations;
181+
182+ return Object.values(annotations).some((entry) => entry !== null)
183+ ? annotations
184+ : null;
185+};
186+
187+const normalizeToolExecution = (value: unknown): McpToolExecution | null => {
188+ const objectValue = asOptionalObject(value);
189+ if (objectValue === null) {
190+ return null;
191+ }
192+
193+ const taskSupport = objectValue.taskSupport;
194+ if (
195+ taskSupport !== "forbidden"
196+ && taskSupport !== "optional"
197+ && taskSupport !== "required"
198+ ) {
199+ return null;
200+ }
201+
202+ return {
203+ taskSupport,
204+ };
205+};
206+
207+const normalizeServerInfo = (value: unknown): McpServerInfo | null => {
208+ const objectValue = asOptionalObject(value);
209+ const name = objectValue ? asString(objectValue.name) : null;
210+ const version = objectValue ? asString(objectValue.version) : null;
211+ if (name === null || version === null) {
212+ return null;
213+ }
214+
215+ return {
216+ name,
217+ version,
218+ title: asString(objectValue?.title),
219+ description: asString(objectValue?.description),
220+ websiteUrl: asString(objectValue?.websiteUrl),
221+ icons: asUnknownArray(objectValue?.icons),
222+ };
223+};
224+
225+const normalizeServerCapabilities = (value: unknown): McpServerCapabilities | null => {
226+ const objectValue = asOptionalObject(value);
227+ if (objectValue === null) {
228+ return null;
229+ }
230+
231+ const prompts = asObject(objectValue.prompts);
232+ const resources = asObject(objectValue.resources);
233+ const tools = asObject(objectValue.tools);
234+ const tasks = asObject(objectValue.tasks);
235+ const taskRequests = asObject(tasks.requests);
236+ const taskToolRequests = asObject(taskRequests.tools);
237+
238+ return {
239+ experimental:
240+ hasOwn(objectValue, "experimental")
241+ ? asObject(objectValue.experimental)
242+ : null,
243+ logging: hasOwn(objectValue, "logging"),
244+ completions: hasOwn(objectValue, "completions"),
245+ prompts:
246+ !hasOwn(objectValue, "prompts")
247+ ? null
248+ : {
249+ listChanged: asBoolean(prompts.listChanged) ?? false,
250+ },
251+ resources:
252+ !hasOwn(objectValue, "resources")
253+ ? null
254+ : {
255+ subscribe: asBoolean(resources.subscribe) ?? false,
256+ listChanged: asBoolean(resources.listChanged) ?? false,
257+ },
258+ tools:
259+ !hasOwn(objectValue, "tools")
260+ ? null
261+ : {
262+ listChanged: asBoolean(tools.listChanged) ?? false,
263+ },
264+ tasks:
265+ !hasOwn(objectValue, "tasks")
266+ ? null
267+ : {
268+ list: hasOwn(tasks, "list"),
269+ cancel: hasOwn(tasks, "cancel"),
270+ toolCall: hasOwn(taskToolRequests, "call"),
271+ },
272+ };
273+};
274+
275+const normalizeServerMetadata = (input?: {
276+ serverInfo?: unknown;
277+ serverCapabilities?: unknown;
278+ instructions?: string | null | undefined;
279+}): McpServerMetadata | null => {
280+ const info = normalizeServerInfo(input?.serverInfo);
281+ const capabilities = normalizeServerCapabilities(input?.serverCapabilities);
282+ const instructions = asString(input?.instructions);
283+ const rawInfo = input?.serverInfo ?? null;
284+ const rawCapabilities = input?.serverCapabilities ?? null;
285+
286+ if (
287+ info === null
288+ && capabilities === null
289+ && instructions === null
290+ && rawInfo === null
291+ && rawCapabilities === null
292+ ) {
293+ return null;
294+ }
295+
296+ return {
297+ info,
298+ capabilities,
299+ instructions,
300+ rawInfo,
301+ rawCapabilities,
302+ };
303+};
304+
305+export const extractMcpToolManifestFromListToolsResult = (
306+ listToolsResult: unknown,
307+ metadata?: {
308+ serverInfo?: unknown;
309+ serverCapabilities?: unknown;
310+ instructions?: string | null | undefined;
311+ },
312+): McpToolManifest => {
313+ const byBase = new Map<string, number>();
314+ const rawListTools = asObject(listToolsResult);
315+ const rawListedTools = Array.isArray(rawListTools.tools) ? rawListTools.tools : [];
316+
317+ const tools = readListedTools(listToolsResult)
318+ .map((tool, index): McpToolManifestEntry | null => {
319+ const toolName = tool.name.trim();
320+ if (toolName.length === 0) {
321+ return null;
322+ }
323+
324+ const title = asString(tool.title);
325+ const annotations = normalizeToolAnnotations(tool.annotations);
326+ const displayTitle = title ?? annotations?.title ?? toolName;
327+
328+ return {
329+ toolId: uniqueToolId(toolName, byBase),
330+ toolName,
331+ title,
332+ displayTitle,
333+ description: tool.description ?? null,
334+ annotations,
335+ execution: normalizeToolExecution(tool.execution),
336+ icons: asUnknownArray(tool.icons),
337+ meta: tool._meta ?? null,
338+ rawTool: rawListedTools[index] ?? null,
339+ inputSchema: tool.inputSchema ?? tool.parameters,
340+ outputSchema: tool.outputSchema,
341+ };
342+ })
343+ .filter((tool): tool is McpToolManifestEntry => tool !== null);
344+
345+ return {
346+ version: 2,
347+ server: normalizeServerMetadata(metadata),
348+ listTools: {
349+ nextCursor: asString(rawListTools.nextCursor),
350+ meta: rawListTools._meta ?? null,
351+ rawResult: listToolsResult,
352+ },
353+ tools,
354+ };
355+};
356+
357+export const joinToolPath = (namespace: string | undefined, toolId: string): ToolPath => {
358+ if (!namespace || namespace.trim().length === 0) {
359+ return toolId as ToolPath;
360+ }
361+
362+ return `${namespace}.${toolId}` as ToolPath;
363+};
plugins/mcp/sdk/package.json+29−0
@@ -0,0 +1,29 @@
1+{
2+ "name": "@executor/plugin-mcp-sdk",
3+ "private": true,
4+ "type": "module",
5+ "exports": {
6+ ".": "./index.ts"
7+ },
8+ "files": [
9+ "*.ts"
10+ ],
11+ "scripts": {
12+ "typecheck": "bunx tsc --noEmit -p tsconfig.json"
13+ },
14+ "dependencies": {
15+ "@executor/auth-mcp-oauth": "workspace:*",
16+ "@executor/codemode-core": "workspace:*",
17+ "@executor/ir": "workspace:*",
18+ "@executor/platform-sdk": "workspace:*",
19+ "@executor/plugin-mcp-shared": "workspace:*",
20+ "@executor/source-core": "workspace:*",
21+ "@modelcontextprotocol/sdk": "^1.26.0",
22+ "effect": "catalog:"
23+ },
24+ "devDependencies": {
25+ "@types/node": "catalog:",
26+ "bun-types": "catalog:"
27+ },
28+ "version": "0.0.0"
29+}
plugins/mcp/sdk/tools.ts+581−0
@@ -0,0 +1,581 @@
1+import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
2+import * as Data from "effect/Data";
3+import * as Either from "effect/Either";
4+import * as Effect from "effect/Effect";
5+import * as Cause from "effect/Cause";
6+import * as Exit from "effect/Exit";
7+import * as PartitionedSemaphore from "effect/PartitionedSemaphore";
8+
9+import {
10+ standardSchemaFromJsonSchema,
11+ toTool,
12+ type ElicitationRequest,
13+ type ElicitationResponse,
14+ type ToolInvocationContext,
15+ type ToolExecutionContext,
16+ type ToolMap,
17+ type ToolMetadata,
18+ type ToolPath,
19+ unknownInputSchema,
20+} from "@executor/codemode-core";
21+
22+import {
23+ createInteractionId,
24+ hasElicitationRequestHandler,
25+ isUrlElicitationRequiredError,
26+ readMcpElicitationRequest,
27+ readUnknownRecord,
28+ toMcpElicitationResponse,
29+} from "./elicitation-bridge";
30+import {
31+ extractMcpToolManifestFromListToolsResult,
32+ joinToolPath,
33+ type McpToolManifest,
34+ type McpToolManifestEntry,
35+} from "./manifest";
36+
37+export type { McpToolManifest, McpToolManifestEntry };
38+export { extractMcpToolManifestFromListToolsResult };
39+
40+export type McpClientLike = {
41+ listTools: () => Promise<unknown>;
42+ callTool: (input: {
43+ name: string;
44+ arguments?: Record<string, unknown>;
45+ }) => Promise<unknown>;
46+ getServerCapabilities?: () => unknown;
47+ getServerVersion?: () => unknown;
48+ getInstructions?: () => string | undefined;
49+};
50+
51+export type McpConnection = {
52+ client: McpClientLike;
53+ close?: () => Promise<void>;
54+};
55+
56+export type McpConnector = Effect.Effect<McpConnection, unknown, never>;
57+
58+export type McpDiscoveryElicitationContext = {
59+ onElicitation: NonNullable<ToolExecutionContext["onElicitation"]>;
60+ path: ToolPath;
61+ sourceKey: string;
62+ args: Record<string, unknown>;
63+ metadata?: ToolMetadata;
64+ invocation?: ToolInvocationContext;
65+};
66+
67+type McpDiscoveryStage = "connect" | "list_tools" | "call_tool";
68+
69+export class McpToolsError extends Data.TaggedError("McpToolsError")<{
70+ stage: McpDiscoveryStage;
71+ message: string;
72+ details: string | null;
73+}> {}
74+
75+const EXECUTION_SUSPENDED_SENTINEL = "__EXECUTION_SUSPENDED__";
76+
77+const causeText = (cause: unknown): string => {
78+ if (cause instanceof Error) {
79+ return `${cause.message}\n${cause.stack ?? ""}`;
80+ }
81+
82+ if (typeof cause === "string") {
83+ return cause;
84+ }
85+
86+ try {
87+ return JSON.stringify(cause);
88+ } catch {
89+ return String(cause);
90+ }
91+};
92+
93+const isExecutionSuspendedCause = (cause: unknown): boolean =>
94+ causeText(cause).includes(EXECUTION_SUSPENDED_SENTINEL);
95+
96+const toDetails = (cause: unknown): string =>
97+ cause instanceof Error ? cause.message : String(cause);
98+
99+const inputSchemaFromManifest = (inputSchema: unknown) => {
100+ if (inputSchema === undefined || inputSchema === null) {
101+ return unknownInputSchema;
102+ }
103+
104+ try {
105+ return standardSchemaFromJsonSchema(inputSchema, {
106+ vendor: "mcp",
107+ fallback: unknownInputSchema,
108+ });
109+ } catch {
110+ return unknownInputSchema;
111+ }
112+};
113+
114+const closeConnection = (connection: McpConnection): Effect.Effect<void> =>
115+ Effect.tryPromise({
116+ try: () => connection.close?.() ?? Promise.resolve(),
117+ catch: (cause) =>
118+ cause instanceof Error ? cause : new Error(String(cause ?? "mcp connection close failed")),
119+ }).pipe(
120+ Effect.asVoid,
121+ Effect.catchAll(() => Effect.void),
122+ );
123+
124+const withConnectionEffect = <A, E>(input: {
125+ connect: McpConnector;
126+ onConnectError: (cause: unknown) => McpToolsError;
127+ run: (connection: McpConnection) => Effect.Effect<A, E>;
128+}): Effect.Effect<A, E | McpToolsError> =>
129+ Effect.acquireUseRelease(
130+ input.connect.pipe(Effect.mapError(input.onConnectError)),
131+ input.run,
132+ closeConnection,
133+ );
134+
135+const elicitationClientSemaphore = PartitionedSemaphore.makeUnsafe<McpClientLike>({
136+ permits: 1,
137+});
138+
139+const withElicitationClientLock = <A, E>(
140+ client: McpClientLike,
141+ effect: Effect.Effect<A, E>,
142+): Effect.Effect<A, E> => elicitationClientSemaphore.withPermits(client, 1)(effect);
143+
144+const resolveElicitationResponse = (input: {
145+ toolName: string;
146+ onElicitation: NonNullable<ToolExecutionContext["onElicitation"]>;
147+ interactionId: string;
148+ path: ToolPath;
149+ sourceKey: string;
150+ args: Record<string, unknown>;
151+ executionContext?: ToolExecutionContext;
152+ elicitation: ElicitationRequest;
153+}): Effect.Effect<ElicitationResponse, McpToolsError> =>
154+ input.onElicitation({
155+ interactionId: input.interactionId,
156+ path: input.path,
157+ sourceKey: input.sourceKey,
158+ args: input.args,
159+ metadata: input.executionContext?.metadata,
160+ context: input.executionContext?.invocation,
161+ elicitation: input.elicitation,
162+ }).pipe(
163+ Effect.mapError((cause) =>
164+ isExecutionSuspendedCause(cause)
165+ ? (cause as McpToolsError)
166+ : new McpToolsError({
167+ stage: "call_tool",
168+ message: `Failed resolving elicitation for ${input.toolName}`,
169+ details: toDetails(cause),
170+ })),
171+ );
172+
173+const installMcpElicitationHandler = (input: {
174+ client: McpClientLike;
175+ toolName: string;
176+ onElicitation: NonNullable<ToolExecutionContext["onElicitation"]>;
177+ path: ToolPath;
178+ sourceKey: string;
179+ args: Record<string, unknown>;
180+ executionContext?: ToolExecutionContext;
181+}): Effect.Effect<void, McpToolsError> => {
182+ const client = input.client;
183+ if (!hasElicitationRequestHandler(client)) {
184+ return Effect.succeed(undefined);
185+ }
186+
187+ return Effect.try({
188+ try: () => {
189+ let sequence = 0;
190+ client.setRequestHandler(ElicitRequestSchema, (request: { params: unknown }) => {
191+ sequence += 1;
192+
193+ return Effect.runPromise(
194+ Effect.try({
195+ try: () => readMcpElicitationRequest(request.params),
196+ catch: (cause) =>
197+ new McpToolsError({
198+ stage: "call_tool",
199+ message: `Failed parsing MCP elicitation for ${input.toolName}`,
200+ details: toDetails(cause),
201+ }),
202+ }).pipe(
203+ Effect.flatMap((elicitation) =>
204+ resolveElicitationResponse({
205+ toolName: input.toolName,
206+ onElicitation: input.onElicitation,
207+ interactionId: createInteractionId({
208+ path: input.path,
209+ invocation: input.executionContext?.invocation,
210+ elicitation,
211+ sequence,
212+ }),
213+ path: input.path,
214+ sourceKey: input.sourceKey,
215+ args: input.args,
216+ executionContext: input.executionContext,
217+ elicitation,
218+ }),
219+ ),
220+ Effect.map(toMcpElicitationResponse),
221+ Effect.catchAll((error) => {
222+ if (isExecutionSuspendedCause(error)) {
223+ return Effect.fail(error);
224+ }
225+
226+ console.error(
227+ `[mcp-tools] elicitation failed for ${input.toolName}, treating as cancel:`,
228+ error instanceof Error ? error.message : String(error),
229+ );
230+ return Effect.succeed({ action: "cancel" as const });
231+ }),
232+ ),
233+ );
234+ });
235+ },
236+ catch: (cause) =>
237+ cause instanceof McpToolsError
238+ ? cause
239+ : new McpToolsError({
240+ stage: "call_tool",
241+ message: `Failed installing elicitation handler for ${input.toolName}`,
242+ details: toDetails(cause),
243+ }),
244+ });
245+};
246+
247+const resolveUrlElicitations = (input: {
248+ cause: {
249+ elicitations: ReadonlyArray<ElicitationRequest>;
250+ };
251+ toolName: string;
252+ onElicitation: NonNullable<ToolExecutionContext["onElicitation"]>;
253+ path: ToolPath;
254+ sourceKey: string;
255+ args: Record<string, unknown>;
256+ executionContext?: ToolExecutionContext;
257+}): Effect.Effect<void, McpToolsError> =>
258+ Effect.forEach(
259+ input.cause.elicitations,
260+ (elicitation) =>
261+ resolveElicitationResponse({
262+ toolName: input.toolName,
263+ onElicitation: input.onElicitation,
264+ interactionId: createInteractionId({
265+ path: input.path,
266+ invocation: input.executionContext?.invocation,
267+ elicitation,
268+ }),
269+ path: input.path,
270+ sourceKey: input.sourceKey,
271+ args: input.args,
272+ executionContext: input.executionContext,
273+ elicitation,
274+ }).pipe(
275+ Effect.flatMap((response) =>
276+ response.action === "accept"
277+ ? Effect.succeed(undefined)
278+ : Effect.fail(
279+ new McpToolsError({
280+ stage: "call_tool",
281+ message: `URL elicitation was not accepted for ${input.toolName}`,
282+ details: response.action,
283+ }),
284+ )),
285+ ),
286+ { discard: true },
287+ );
288+
289+const callMcpTool = (input: {
290+ client: McpClientLike;
291+ toolName: string;
292+ args: Record<string, unknown>;
293+}): Effect.Effect<unknown, unknown> =>
294+ Effect.tryPromise({
295+ try: () =>
296+ input.client.callTool({
297+ name: input.toolName,
298+ arguments: input.args,
299+ }),
300+ catch: (cause) => cause,
301+ });
302+
303+const toToolExecutionContext = (
304+ input: McpDiscoveryElicitationContext | undefined,
305+): ToolExecutionContext | undefined =>
306+ input
307+ ? {
308+ path: input.path,
309+ sourceKey: input.sourceKey,
310+ metadata: input.metadata,
311+ invocation: input.invocation,
312+ onElicitation: input.onElicitation,
313+ }
314+ : undefined;
315+
316+const runMcpListToolsEffect = (input: {
317+ connection: McpConnection;
318+ mcpDiscoveryElicitation?: McpDiscoveryElicitationContext;
319+}): Effect.Effect<unknown, McpToolsError> =>
320+ Effect.gen(function* () {
321+ const executionContext = toToolExecutionContext(input.mcpDiscoveryElicitation);
322+
323+ if (input.mcpDiscoveryElicitation) {
324+ yield* installMcpElicitationHandler({
325+ client: input.connection.client,
326+ toolName: "tools/list",
327+ onElicitation: input.mcpDiscoveryElicitation.onElicitation,
328+ path: input.mcpDiscoveryElicitation.path,
329+ sourceKey: input.mcpDiscoveryElicitation.sourceKey,
330+ args: input.mcpDiscoveryElicitation.args,
331+ executionContext,
332+ });
333+ }
334+
335+ let retries = 0;
336+ while (true) {
337+ const attempt = yield* Effect.either(
338+ Effect.tryPromise({
339+ try: () => input.connection.client.listTools(),
340+ catch: (cause) => cause,
341+ }),
342+ );
343+
344+ if (Either.isRight(attempt)) {
345+ return attempt.right;
346+ }
347+
348+ if (isExecutionSuspendedCause(attempt.left)) {
349+ return yield* (attempt.left as McpToolsError);
350+ }
351+
352+ if (
353+ input.mcpDiscoveryElicitation
354+ && isUrlElicitationRequiredError(attempt.left)
355+ && retries < 2
356+ ) {
357+ yield* resolveUrlElicitations({
358+ cause: attempt.left,
359+ toolName: "tools/list",
360+ onElicitation: input.mcpDiscoveryElicitation.onElicitation,
361+ path: input.mcpDiscoveryElicitation.path,
362+ sourceKey: input.mcpDiscoveryElicitation.sourceKey,
363+ args: input.mcpDiscoveryElicitation.args,
364+ executionContext,
365+ });
366+ retries += 1;
367+ continue;
368+ }
369+
370+ return yield* new McpToolsError({
371+ stage: "list_tools",
372+ message: "Failed listing MCP tools",
373+ details: toDetails(attempt.left),
374+ });
375+ }
376+ });
377+
378+const runMcpToolCallEffect = (input: {
379+ connection: McpConnection;
380+ toolName: string;
381+ path: ToolPath;
382+ sourceKey: string;
383+ args: Record<string, unknown>;
384+ executionContext?: ToolExecutionContext;
385+}): Effect.Effect<unknown, McpToolsError> =>
386+ Effect.gen(function* () {
387+ const onElicitation = input.executionContext?.onElicitation;
388+ if (onElicitation) {
389+ yield* installMcpElicitationHandler({
390+ client: input.connection.client,
391+ toolName: input.toolName,
392+ onElicitation,
393+ path: input.path,
394+ sourceKey: input.sourceKey,
395+ args: input.args,
396+ executionContext: input.executionContext,
397+ });
398+ }
399+
400+ let retries = 0;
401+ while (true) {
402+ const attempt = yield* Effect.either(
403+ callMcpTool({
404+ client: input.connection.client,
405+ toolName: input.toolName,
406+ args: input.args,
407+ }),
408+ );
409+
410+ if (Either.isRight(attempt)) {
411+ return attempt.right;
412+ }
413+
414+ if (isExecutionSuspendedCause(attempt.left)) {
415+ return yield* (attempt.left as McpToolsError);
416+ }
417+
418+ if (
419+ onElicitation
420+ && isUrlElicitationRequiredError(attempt.left)
421+ && retries < 2
422+ ) {
423+ yield* resolveUrlElicitations({
424+ cause: attempt.left,
425+ toolName: input.toolName,
426+ onElicitation,
427+ path: input.path,
428+ sourceKey: input.sourceKey,
429+ args: input.args,
430+ executionContext: input.executionContext,
431+ });
432+ retries += 1;
433+ continue;
434+ }
435+
436+ return yield* new McpToolsError({
437+ stage: "call_tool",
438+ message: `Failed invoking MCP tool: ${input.toolName}`,
439+ details: toDetails(attempt.left),
440+ });
441+ }
442+ });
443+
444+export const createMcpConnectorFromClient = (
445+ client: McpClientLike,
446+): McpConnector =>
447+ Effect.succeed({
448+ client,
449+ close: async () => undefined,
450+ });
451+
452+export const createMcpToolsFromManifest = (input: {
453+ manifest: McpToolManifest;
454+ connect: McpConnector;
455+ namespace?: string;
456+ sourceKey?: string;
457+}): ToolMap => {
458+ const sourceKey = input.sourceKey ?? "mcp.generated";
459+
460+ return Object.fromEntries(
461+ input.manifest.tools.map((entry) => {
462+ const path = joinToolPath(input.namespace, entry.toolId);
463+
464+ return [
465+ path,
466+ toTool({
467+ tool: {
468+ description: entry.description ?? `MCP tool: ${entry.toolName}`,
469+ inputSchema: inputSchemaFromManifest(entry.inputSchema),
470+ execute: async (args: unknown, executionContext?: ToolExecutionContext) => {
471+ const exit = await Effect.runPromiseExit(
472+ withConnectionEffect({
473+ connect: input.connect,
474+ onConnectError: (cause) =>
475+ new McpToolsError({
476+ stage: "connect",
477+ message: `Failed connecting to MCP server for ${entry.toolName}`,
478+ details: toDetails(cause),
479+ }),
480+ run: (connection) => {
481+ const payloadArgs = readUnknownRecord(args);
482+ const callEffect = runMcpToolCallEffect({
483+ connection,
484+ toolName: entry.toolName,
485+ path,
486+ sourceKey,
487+ args: payloadArgs,
488+ executionContext,
489+ });
490+
491+ return executionContext?.onElicitation
492+ ? withElicitationClientLock(connection.client, callEffect)
493+ : callEffect;
494+ },
495+ }),
496+ );
497+ if (Exit.isSuccess(exit)) return exit.value;
498+ throw Cause.squash(exit.cause);
499+ },
500+ },
501+ metadata: {
502+ sourceKey,
503+ contract: {
504+ ...(entry.inputSchema !== undefined
505+ ? { inputSchema: entry.inputSchema }
506+ : {}),
507+ ...(entry.outputSchema !== undefined
508+ ? { outputSchema: entry.outputSchema }
509+ : {}),
510+ },
511+ },
512+ }),
513+ ] as const;
514+ }),
515+ );
516+};
517+
518+export const discoverMcpToolsFromConnector = (input: {
519+ connect: McpConnector;
520+ namespace?: string;
521+ sourceKey?: string;
522+ mcpDiscoveryElicitation?: McpDiscoveryElicitationContext;
523+}): Effect.Effect<{ manifest: McpToolManifest; tools: ToolMap }, McpToolsError> =>
524+ Effect.gen(function* () {
525+ const listed = yield* withConnectionEffect({
526+ connect: input.connect,
527+ onConnectError: (cause) =>
528+ new McpToolsError({
529+ stage: "connect",
530+ message: "Failed connecting to MCP server",
531+ details: toDetails(cause),
532+ }),
533+ run: (connection) => {
534+ const listEffect = runMcpListToolsEffect({
535+ connection,
536+ mcpDiscoveryElicitation: input.mcpDiscoveryElicitation,
537+ });
538+
539+ const settledListEffect = input.mcpDiscoveryElicitation
540+ ? withElicitationClientLock(connection.client, listEffect)
541+ : listEffect;
542+
543+ return Effect.map(settledListEffect, (listed) => ({
544+ listed,
545+ serverInfo: connection.client.getServerVersion?.(),
546+ serverCapabilities: connection.client.getServerCapabilities?.(),
547+ instructions: connection.client.getInstructions?.(),
548+ }));
549+ },
550+ });
551+
552+ const manifest = extractMcpToolManifestFromListToolsResult(
553+ listed.listed,
554+ {
555+ serverInfo: listed.serverInfo,
556+ serverCapabilities: listed.serverCapabilities,
557+ instructions: listed.instructions,
558+ },
559+ );
560+
561+ return {
562+ manifest,
563+ tools: createMcpToolsFromManifest({
564+ manifest,
565+ connect: input.connect,
566+ namespace: input.namespace,
567+ sourceKey: input.sourceKey,
568+ }),
569+ };
570+ });
571+
572+export const discoverMcpToolsFromClient = (input: {
573+ client: McpClientLike;
574+ namespace?: string;
575+ sourceKey?: string;
576+}): Effect.Effect<{ manifest: McpToolManifest; tools: ToolMap }, McpToolsError> =>
577+ discoverMcpToolsFromConnector({
578+ connect: createMcpConnectorFromClient(input.client),
579+ namespace: input.namespace,
580+ sourceKey: input.sourceKey,
581+ });
plugins/mcp/sdk/tsconfig.json+13−0
@@ -0,0 +1,13 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2022",
4+ "module": "ESNext",
5+ "moduleResolution": "Bundler",
6+ "strict": true,
7+ "skipLibCheck": true,
8+ "noEmit": true,
9+ "lib": ["ES2022", "DOM"],
10+ "types": ["bun-types", "node"]
11+ },
12+ "include": ["*.ts"]
13+}
plugins/mcp/shared/index.ts+163−0
@@ -0,0 +1,163 @@
1+import * as Schema from "effect/Schema";
2+
3+import {
4+ SecretRefSchema,
5+} from "@executor/platform-sdk/schema";
6+import {
7+ JsonObjectSchema,
8+ SourceTransportSchema,
9+ StringArraySchema,
10+ StringMapSchema,
11+ defaultNameFromEndpoint,
12+ namespaceFromSourceName,
13+} from "@executor/source-core";
14+
15+export const McpConnectionAuthSchema = Schema.Union(
16+ Schema.Struct({
17+ kind: Schema.Literal("none"),
18+ }),
19+ Schema.Struct({
20+ kind: Schema.Literal("oauth2"),
21+ redirectUri: Schema.String,
22+ accessTokenRef: SecretRefSchema,
23+ refreshTokenRef: Schema.NullOr(SecretRefSchema),
24+ tokenType: Schema.String,
25+ expiresAt: Schema.NullOr(Schema.Number),
26+ scope: Schema.NullOr(Schema.String),
27+ resourceMetadataUrl: Schema.NullOr(Schema.String),
28+ authorizationServerUrl: Schema.NullOr(Schema.String),
29+ resourceMetadata: Schema.NullOr(JsonObjectSchema),
30+ authorizationServerMetadata: Schema.NullOr(JsonObjectSchema),
31+ clientInformation: Schema.NullOr(JsonObjectSchema),
32+ }),
33+);
34+
35+export const McpConnectInputSchema = Schema.Struct({
36+ name: Schema.String,
37+ endpoint: Schema.NullOr(Schema.String),
38+ transport: Schema.NullOr(SourceTransportSchema),
39+ queryParams: Schema.NullOr(StringMapSchema),
40+ headers: Schema.NullOr(StringMapSchema),
41+ command: Schema.NullOr(Schema.String),
42+ args: Schema.NullOr(StringArraySchema),
43+ env: Schema.NullOr(StringMapSchema),
44+ cwd: Schema.NullOr(Schema.String),
45+ auth: McpConnectionAuthSchema,
46+});
47+
48+export const McpSourceConfigPayloadSchema = McpConnectInputSchema;
49+
50+export const McpUpdateSourceInputSchema = Schema.Struct({
51+ sourceId: Schema.String,
52+ config: McpSourceConfigPayloadSchema,
53+});
54+
55+export const McpStoredSourceDataSchema = Schema.Struct({
56+ endpoint: Schema.NullOr(Schema.String),
57+ transport: Schema.NullOr(SourceTransportSchema),
58+ queryParams: Schema.NullOr(StringMapSchema),
59+ headers: Schema.NullOr(StringMapSchema),
60+ command: Schema.NullOr(Schema.String),
61+ args: Schema.NullOr(StringArraySchema),
62+ env: Schema.NullOr(StringMapSchema),
63+ cwd: Schema.NullOr(Schema.String),
64+ auth: McpConnectionAuthSchema,
65+});
66+
67+export const McpStartOAuthInputSchema = Schema.Struct({
68+ endpoint: Schema.String,
69+ queryParams: Schema.NullOr(StringMapSchema),
70+ redirectUrl: Schema.String,
71+});
72+
73+export const McpStartOAuthResultSchema = Schema.Struct({
74+ sessionId: Schema.String,
75+ authorizationUrl: Schema.String,
76+});
77+
78+export const McpOAuthSessionSchema = Schema.Struct({
79+ endpoint: Schema.String,
80+ redirectUrl: Schema.String,
81+ codeVerifier: Schema.String,
82+ resourceMetadataUrl: Schema.NullOr(Schema.String),
83+ authorizationServerUrl: Schema.NullOr(Schema.String),
84+ resourceMetadata: Schema.NullOr(JsonObjectSchema),
85+ authorizationServerMetadata: Schema.NullOr(JsonObjectSchema),
86+ clientInformation: Schema.NullOr(JsonObjectSchema),
87+});
88+
89+const McpOAuthPopupAuthSchema = Schema.Struct({
90+ kind: Schema.Literal("oauth2"),
91+ redirectUri: Schema.String,
92+ accessTokenRef: SecretRefSchema,
93+ refreshTokenRef: Schema.NullOr(SecretRefSchema),
94+ tokenType: Schema.String,
95+ expiresAt: Schema.NullOr(Schema.Number),
96+ scope: Schema.NullOr(Schema.String),
97+ resourceMetadataUrl: Schema.NullOr(Schema.String),
98+ authorizationServerUrl: Schema.NullOr(Schema.String),
99+ resourceMetadata: Schema.NullOr(JsonObjectSchema),
100+ authorizationServerMetadata: Schema.NullOr(JsonObjectSchema),
101+ clientInformation: Schema.NullOr(JsonObjectSchema),
102+});
103+
104+export const McpOAuthPopupResultSchema = Schema.Union(
105+ Schema.Struct({
106+ type: Schema.Literal("executor:oauth-result"),
107+ ok: Schema.Literal(true),
108+ sessionId: Schema.String,
109+ auth: McpOAuthPopupAuthSchema,
110+ }),
111+ Schema.Struct({
112+ type: Schema.Literal("executor:oauth-result"),
113+ ok: Schema.Literal(false),
114+ sessionId: Schema.Null,
115+ error: Schema.String,
116+ }),
117+);
118+
119+export type McpConnectionAuth = typeof McpConnectionAuthSchema.Type;
120+export type McpConnectInput = typeof McpConnectInputSchema.Type;
121+export type McpSourceConfigPayload = typeof McpSourceConfigPayloadSchema.Type;
122+export type McpUpdateSourceInput = typeof McpUpdateSourceInputSchema.Type;
123+export type McpStoredSourceData = typeof McpStoredSourceDataSchema.Type;
124+export type McpStartOAuthInput = typeof McpStartOAuthInputSchema.Type;
125+export type McpStartOAuthResult = typeof McpStartOAuthResultSchema.Type;
126+export type McpOAuthSession = typeof McpOAuthSessionSchema.Type;
127+export type McpOAuthPopupResult = typeof McpOAuthPopupResultSchema.Type;
128+
129+export const deriveMcpNamespace = (input: {
130+ name?: string | null;
131+ endpoint?: string | null;
132+ command?: string | null;
133+}): string | null => {
134+ if (input.name && input.name.trim().length > 0) {
135+ return namespaceFromSourceName(input.name);
136+ }
137+
138+ if (input.endpoint && input.endpoint.trim().length > 0) {
139+ try {
140+ return namespaceFromSourceName(defaultNameFromEndpoint(input.endpoint));
141+ } catch {
142+ // Fall through to command inference.
143+ }
144+ }
145+
146+ if (input.command && input.command.trim().length > 0) {
147+ const commandName = input.command.trim().split(/[\\/]/).pop() ?? input.command.trim();
148+ return namespaceFromSourceName(commandName);
149+ }
150+
151+ return null;
152+};
153+
154+export const resolveMcpEndpoint = (input: {
155+ endpoint: string;
156+ queryParams?: Readonly<Record<string, string>> | null;
157+}): string => {
158+ const url = new URL(input.endpoint);
159+ for (const [key, value] of Object.entries(input.queryParams ?? {})) {
160+ url.searchParams.set(key, value);
161+ }
162+ return url.toString();
163+};
plugins/mcp/shared/package.json+24−0
@@ -0,0 +1,24 @@
1+{
2+ "name": "@executor/plugin-mcp-shared",
3+ "private": true,
4+ "type": "module",
5+ "exports": {
6+ ".": "./index.ts"
7+ },
8+ "files": [
9+ "index.ts"
10+ ],
11+ "scripts": {
12+ "typecheck": "bunx tsc --noEmit -p tsconfig.json"
13+ },
14+ "dependencies": {
15+ "@executor/platform-sdk": "workspace:*",
16+ "@executor/source-core": "workspace:*",
17+ "effect": "catalog:"
18+ },
19+ "devDependencies": {
20+ "@types/node": "catalog:",
21+ "bun-types": "catalog:"
22+ },
23+ "version": "0.0.0"
24+}
plugins/mcp/shared/tsconfig.json+15−0
@@ -0,0 +1,15 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2022",
4+ "module": "ESNext",
5+ "moduleResolution": "Bundler",
6+ "strict": true,
7+ "skipLibCheck": true,
8+ "noEmit": true,
9+ "lib": ["ES2022", "DOM"],
10+ "types": ["bun-types", "node"]
11+ },
12+ "include": [
13+ "index.ts"
14+ ]
15+}
plugins/openapi/sdk/index.ts+143−92
@@ -9,9 +9,12 @@ import { · 8 unmodified lines
99 import type { Source } from "@executor/platform-sdk/schema";
1010 import type {
1111 ExecutorSdkPlugin,
12+ ExecutorSdkPluginHost,
13+ ExecutorSourceConnector,
1214 SourcePluginRuntime,
1315 } from "@executor/platform-sdk/plugins";
1416 import {
17+ OpenApiConnectionAuthSchema,
1518 deriveOpenApiNamespace,
1619 previewOpenApiDocument,
1720 type OpenApiConnectInput,
@@ -90,6 +93,16 @@ export type OpenApiSdkPluginOptions = { · 72 unmodified lines
9093 secrets?: OpenApiSecrets;
9194 };
9295
96+const OpenApiExecutorAddInputSchema = Schema.Struct({
97+ kind: Schema.Literal("openapi"),
98+ name: Schema.String,
99+ specUrl: Schema.String,
100+ baseUrl: Schema.NullOr(Schema.String),
101+ auth: OpenApiConnectionAuthSchema,
102+});
103+
104+type OpenApiExecutorAddInput = typeof OpenApiExecutorAddInputSchema.Type;
105+
93106 const createStoredSourceData = (
94107 input: OpenApiConnectInput,
95108 ): OpenApiStoredSourceData => ({
@@ -249,6 +262,117 @@ const responseHeadersRecord = (response: Response): Record<string, string> => { · 153 unmodified lines
249262 return headers;
250263 };
251264
265+const createOpenApiSourceSdk = (
266+ options: OpenApiSdkPluginOptions,
267+ host: ExecutorSdkPluginHost,
268+) => ({
269+ getSourceConfig: (sourceId: Source["id"]) =>
270+ Effect.gen(function* () {
271+ const source = yield* host.sources.get(sourceId);
272+ if (source.kind !== "openapi") {
273+ return yield* Effect.fail(
274+ new Error(`Source ${sourceId} is not an OpenAPI source.`),
275+ );
276+ }
277+
278+ const stored = yield* options.storage.get({
279+ scopeId: source.scopeId,
280+ sourceId: source.id,
281+ });
282+ if (stored === null) {
283+ return yield* Effect.fail(
284+ new Error(`OpenAPI source storage missing for ${source.id}`),
285+ );
286+ }
287+
288+ return configFromStoredSourceData(source, stored);
289+ }),
290+ createSource: (input: OpenApiConnectInput) =>
291+ Effect.gen(function* () {
292+ const stored = createStoredSourceData(input);
293+ const createdSource = yield* host.sources.create({
294+ source: {
295+ name: input.name.trim(),
296+ kind: "openapi",
297+ status: "connected",
298+ enabled: true,
299+ namespace: deriveOpenApiNamespace({
300+ specUrl: input.specUrl,
301+ title: input.name,
302+ }),
303+ },
304+ });
305+
306+ yield* options.storage.put({
307+ scopeId: createdSource.scopeId,
308+ sourceId: createdSource.id,
309+ value: stored,
310+ });
311+
312+ return yield* host.sources.refreshCatalog(createdSource.id);
313+ }),
314+ updateSource: (input: OpenApiUpdateSourceInput) =>
315+ Effect.gen(function* () {
316+ const source = yield* host.sources.get(input.sourceId as Source["id"]);
317+ if (source.kind !== "openapi") {
318+ return yield* Effect.fail(
319+ new Error(`Source ${input.sourceId} is not an OpenAPI source.`),
320+ );
321+ }
322+
323+ const nextStored = createStoredSourceData(input.config);
324+ const savedSource = yield* host.sources.save({
325+ ...source,
326+ name: input.config.name.trim(),
327+ namespace: deriveOpenApiNamespace({
328+ specUrl: input.config.specUrl,
329+ title: input.config.name,
330+ }),
331+ });
332+
333+ yield* options.storage.put({
334+ scopeId: savedSource.scopeId,
335+ sourceId: savedSource.id,
336+ value: nextStored,
337+ });
338+
339+ return yield* host.sources.refreshCatalog(savedSource.id);
340+ }),
341+ removeSource: (sourceId: Source["id"]) =>
342+ Effect.gen(function* () {
343+ const source = yield* host.sources.get(sourceId);
344+ if (source.kind !== "openapi") {
345+ return yield* Effect.fail(
346+ new Error(`Source ${sourceId} is not an OpenAPI source.`),
347+ );
348+ }
349+
350+ if (options.storage.remove) {
351+ yield* options.storage.remove({
352+ scopeId: source.scopeId,
353+ sourceId: source.id,
354+ });
355+ }
356+
357+ return yield* host.sources.remove(source.id);
358+ }),
359+});
360+
361+const openApiSourceConnector = (
362+ options: OpenApiSdkPluginOptions,
363+): ExecutorSourceConnector<OpenApiExecutorAddInput> => ({
364+ kind: "openapi",
365+ displayName: "OpenAPI",
366+ inputSchema: OpenApiExecutorAddInputSchema,
367+ inputSignatureWidth: 280,
368+ helpText: [
369+ "Provide the OpenAPI document URL and optional base URL override.",
370+ "Use `auth.kind = \"bearer\"` with a stored secret ref when required.",
371+ ],
372+ createSource: ({ args, host }) =>
373+ createOpenApiSourceSdk(options, host).createSource(args),
374+});
375+
252376 const decodeResponseBody = async (response: Response): Promise<unknown> => {
253377 if (response.status === 204) {
254378 return null;
@@ -541,102 +665,29 @@ export const openApiSdkPlugin = ( · 286 unmodified lines
541665 ): ExecutorSdkPlugin<"openapi", OpenApiSdk> => ({
542666 key: "openapi",
543667 sources: [createOpenApiSourceRuntime(options)],
544− extendExecutor: ({ host }) => ({
668+ sourceConnectors: [openApiSourceConnector(options)],
669+ extendExecutor: ({ host, executor }) => {
670+ const sourceSdk = createOpenApiSourceSdk(options, host);
671+ const provideRuntime = <A>(
672+ effect: Effect.Effect<A, Error, any>,
673+ ): Effect.Effect<A, Error, never> =>
674+ effect.pipe(Effect.provide(executor.runtime.managedRuntime));
675+
676+ return {
545677 previewDocument: (input) =>
546678 Effect.tryPromise({
547679 try: () => previewOpenApiDocument(input),
548680 catch: (cause) =>
549681 cause instanceof Error ? cause : new Error(String(cause)),
550682 }),
551− getSourceConfig: (sourceId) =>
552− Effect.gen(function* () {
553− const source = yield* host.sources.get(sourceId);
554− if (source.kind !== "openapi") {
555− return yield* Effect.fail(
556− new Error(`Source ${sourceId} is not an OpenAPI source.`),
557− );
558− }
559−
560− const stored = yield* options.storage.get({
561− scopeId: source.scopeId,
562− sourceId: source.id,
563− });
564− if (stored === null) {
565− return yield* Effect.fail(
566− new Error(`OpenAPI source storage missing for ${source.id}`),
567− );
568− }
569−
570− return configFromStoredSourceData(source, stored);
571− }),
572− createSource: (input) =>
573− Effect.gen(function* () {
574− const stored = createStoredSourceData(input);
575− const createdSource = yield* host.sources.create({
576− source: {
577− name: input.name.trim(),
578− kind: "openapi",
579− status: "connected",
580− enabled: true,
581− namespace: deriveOpenApiNamespace({
582− specUrl: input.specUrl,
583− title: input.name,
584− }),
585− },
586− });
587−
588− yield* options.storage.put({
589− scopeId: createdSource.scopeId,
590− sourceId: createdSource.id,
591− value: stored,
592− });
593−
594− return yield* host.sources.refreshCatalog(createdSource.id);
595− }),
596− updateSource: (input) =>
597− Effect.gen(function* () {
598− const source = yield* host.sources.get(input.sourceId as Source["id"]);
599− if (source.kind !== "openapi") {
600− return yield* Effect.fail(
601− new Error(`Source ${input.sourceId} is not an OpenAPI source.`),
602− );
603− }
604−
605− const nextStored = createStoredSourceData(input.config);
606− const savedSource = yield* host.sources.save({
607− ...source,
608− name: input.config.name.trim(),
609− namespace: deriveOpenApiNamespace({
610− specUrl: input.config.specUrl,
611− title: input.config.name,
612− }),
613− });
614−
615− yield* options.storage.put({
616− scopeId: savedSource.scopeId,
617− sourceId: savedSource.id,
618− value: nextStored,
619− });
620−
621− return yield* host.sources.refreshCatalog(savedSource.id);
622− }),
623− removeSource: (sourceId) =>
624− Effect.gen(function* () {
625− const source = yield* host.sources.get(sourceId);
626− if (source.kind !== "openapi") {
627− return yield* Effect.fail(
628− new Error(`Source ${sourceId} is not an OpenAPI source.`),
629− );
630− }
631−
632− if (options.storage.remove) {
633− yield* options.storage.remove({
634− scopeId: source.scopeId,
635− sourceId: source.id,
636− });
637− }
638−
639− return yield* host.sources.remove(source.id);
640− }),
641− }),
683+ getSourceConfig: (sourceId) =>
684+ provideRuntime(sourceSdk.getSourceConfig(sourceId)),
685+ createSource: (input) =>
686+ provideRuntime(sourceSdk.createSource(input)),
687+ updateSource: (input) =>
688+ provideRuntime(sourceSdk.updateSource(input)),
689+ removeSource: (sourceId) =>
690+ provideRuntime(sourceSdk.removeSource(sourceId)),
691+ };
692+ },
642693 });

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

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

Repository code

Go to file
t