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 @@
1{1{
2 "sources": {2 "sources": {
3 "vercel-api": {3 "vercel-api": {
4 "kind": "openapi",4 "name": "Vercel API",
5 "name": "Vercel API"5 "kind": "openapi"
6 }6 }
7 }7 }
8}8}
README.md+15−6
@@ -229,24 +229,33 @@ Examples: · 228 unmodified lines
229 229
230```ts230```ts
231return await tools.executor.sources.add({231return await tools.executor.sources.add({
232 endpoint: "https://example.com/mcp",232 kind: "mcp",
233 name: "Example",233 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" },
235});243});
236```244```
237 245
238```ts246```ts
239return await tools.executor.sources.add({247return await tools.executor.sources.add({
240 kind: "openapi",248 kind: "openapi",
241 endpoint: "https://api.github.com",249 name: "GitHub",
242 specUrl:250 specUrl:
243 "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json",251 "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json",
244 name: "GitHub",252 baseUrl: null,
245 namespace: "github",253 auth: { kind: "none" },
246});254});
247```255```
248 256
249For HTTP-style sources, `executor` can drive the credential flow for you.257`executor.sources.add(...)` accepts the final plugin-specific source config.
258Browser OAuth and popup-driven auth flows live on the plugin-owned HTTP and web surfaces.
250 259
251## How execution works260## How execution works
252 261
apps/executor/package.json+3−0
@@ -38,6 +38,9 @@ · 37 unmodified lines
38 "@effect/platform-node": "catalog:",38 "@effect/platform-node": "catalog:",
39 "@executor/codemode-core": "workspace:*",39 "@executor/codemode-core": "workspace:*",
40 "@executor/executor-mcp": "workspace:*",40 "@executor/executor-mcp": "workspace:*",
41 "@executor/plugin-google-discovery-sdk": "workspace:*",
42 "@executor/plugin-graphql-sdk": "workspace:*",
43 "@executor/plugin-mcp-sdk": "workspace:*",
41 "@executor/plugin-openapi-sdk": "workspace:*",44 "@executor/plugin-openapi-sdk": "workspace:*",
42 "@executor/platform-api": "workspace:*",45 "@executor/platform-api": "workspace:*",
43 "@executor/platform-internal": "workspace:*",46 "@executor/platform-internal": "workspace:*",
apps/executor/src/cli/main.ts+41−6
@@ -14,12 +14,21 @@ import { · 13 unmodified lines
14 type ExecutorApiEffectClient as ExecutorApiClient,14 type ExecutorApiEffectClient as ExecutorApiClient,
15} from "@executor/platform-api/effect";15} from "@executor/platform-api/effect";
16import { runExecutorMcpStdioServer } from "@executor/executor-mcp";16import { runExecutorMcpStdioServer } from "@executor/executor-mcp";
17import {
18 googleDiscoverySdkPlugin,
19} from "@executor/plugin-google-discovery-sdk";
20import {
21 graphqlSdkPlugin,
22} from "@executor/plugin-graphql-sdk";
23import {
24 mcpSdkPlugin,
25} from "@executor/plugin-mcp-sdk";
17import {26import {
18 openApiSdkPlugin,27 openApiSdkPlugin,
19} from "@executor/plugin-openapi-sdk";28} from "@executor/plugin-openapi-sdk";
20import { createWorkspaceExecutorAdminToolMap } from "@executor/platform-internal";29import { createWorkspaceExecutorAdminToolMap } from "@executor/platform-internal";
21import {30import {
22 EXECUTOR_SOURCES_ADD_HELP_LINES,31 getExecutorSourcesAddHelpLines,
23 RuntimeExecutionResolverService,32 RuntimeExecutionResolverService,
24} from "@executor/platform-sdk/runtime";33} from "@executor/platform-sdk/runtime";
25import {34import {
@@ -40,6 +49,11 @@ import * as Option from "effect/Option"; · 14 unmodified lines
40import * as Cause from "effect/Cause";49import * as Cause from "effect/Cause";
41 50
42import {51import {
52 createFileGoogleDiscoveryOAuthSessionStorage,
53 createFileGoogleDiscoverySourceStorage,
54 createFileGraphqlSourceStorage,
55 createFileMcpOAuthSessionStorage,
56 createFileMcpSourceStorage,
43 createFileOpenApiSourceStorage,57 createFileOpenApiSourceStorage,
44 createLocalExecutorServer,58 createLocalExecutorServer,
45 DEFAULT_SERVER_BASE_URL,59 DEFAULT_SERVER_BASE_URL,
@@ -216,13 +230,13 @@ const buildWorkflowText = (namespaces: readonly string[] = []): string => · 170 unmodified lines
216 '1) const matches = await tools.discover({ query: "<intent>", limit: 12 });',230 '1) const matches = await tools.discover({ query: "<intent>", limit: 12 });',
217 "2) const details = await tools.describe.tool({ path, includeSchemas: true });",231 "2) const details = await tools.describe.tool({ path, includeSchemas: true });",
218 "3) Call selected tools.<path>(input).",232 "3) Call selected tools.<path>(input).",
219 "4) Source plugins are not registered in this build.",233 "4) Use source plugins to inspect or add API sources.",
220 ...EXECUTOR_SOURCES_ADD_HELP_LINES,234 ...getExecutorSourcesAddHelpLines(),
221 "5) If execution pauses for interaction, resume it with `executor resume --execution-id ...`.",235 "5) If execution pauses for interaction, resume it with `executor resume --execution-id ...`.",
222 "Do not use fetch; use tools.* only.",236 "Do not use fetch; use tools.* only.",
223 ].join("\n");237 ].join("\n");
224 238
225const DEFAULT_RUN_WORKFLOW = buildWorkflowText();239const getDefaultRunWorkflow = () => buildWorkflowText();
226 240
227const indentBlock = (value: string, prefix: string = " "): string =>241const indentBlock = (value: string, prefix: string = " "): string =>
228 value242 value
@@ -267,7 +281,7 @@ const buildRunWorkflowText = ( · 38 unmodified lines
267 catalog?: ToolCatalog,281 catalog?: ToolCatalog,
268): Effect.Effect<string, Error, never> => {282): Effect.Effect<string, Error, never> => {
269 if (!catalog) {283 if (!catalog) {
270 return Effect.succeed(DEFAULT_RUN_WORKFLOW);284 return Effect.succeed(getDefaultRunWorkflow());
271 }285 }
272 286
273 return catalog.listNamespaces({ limit: 200 }).pipe(287 return catalog.listNamespaces({ limit: 200 }).pipe(
@@ -289,6 +303,27 @@ const loadRunWorkflowText = (): Effect.Effect<string, Error, never> => · 15 unmodified lines
289 localDataDir: DEFAULT_LOCAL_DATA_DIR,303 localDataDir: DEFAULT_LOCAL_DATA_DIR,
290 }),304 }),
291 plugins: [305 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 }),
292 openApiSdkPlugin({327 openApiSdkPlugin({
293 storage: createFileOpenApiSourceStorage({328 storage: createFileOpenApiSourceStorage({
294 rootDir: `${DEFAULT_LOCAL_DATA_DIR}/plugins/openapi/sources`,329 rootDir: `${DEFAULT_LOCAL_DATA_DIR}/plugins/openapi/sources`,
@@ -318,7 +353,7 @@ const loadRunWorkflowText = (): Effect.Effect<string, Error, never> => · 23 unmodified lines
318 Effect.catchAllCause((cause) =>353 Effect.catchAllCause((cause) =>
319 Effect.succeed(354 Effect.succeed(
320 [355 [
321 DEFAULT_RUN_WORKFLOW,356 getDefaultRunWorkflow(),
322 "",357 "",
323 formatCatalogUnavailableMessage(cause),358 formatCatalogUnavailableMessage(cause),
324 ].join("\n"),359 ].join("\n"),
apps/web/package.json+3−0
@@ -9,6 +9,9 @@ · 8 unmodified lines
9 "start": "NODE_ENV=production bun src/server.ts"9 "start": "NODE_ENV=production bun src/server.ts"
10 },10 },
11 "dependencies": {11 "dependencies": {
12 "@executor/plugin-google-discovery-react": "workspace:*",
13 "@executor/plugin-graphql-react": "workspace:*",
14 "@executor/plugin-mcp-react": "workspace:*",
12 "@executor/plugin-openapi-react": "workspace:*",15 "@executor/plugin-openapi-react": "workspace:*",
13 "@executor/react": "workspace:*",16 "@executor/react": "workspace:*",
14 "@executor/server": "workspace:*",17 "@executor/server": "workspace:*",
apps/web/src/source-plugins/index.ts+12−0
@@ -1,3 +1,12 @@
1import {
2 GoogleDiscoveryReactPlugin,
3} from "@executor/plugin-google-discovery-react";
4import {
5 GraphqlReactPlugin,
6} from "@executor/plugin-graphql-react";
7import {
8 McpReactPlugin,
9} from "@executor/plugin-mcp-react";
1import {10import {
2 OpenApiReactPlugin,11 OpenApiReactPlugin,
3} from "@executor/plugin-openapi-react";12} from "@executor/plugin-openapi-react";
@@ -7,6 +16,9 @@ import type { · 3 unmodified lines
7} from "./types";16} from "./types";
8 17
9const frontendPlugins = [18const frontendPlugins = [
19 McpReactPlugin,
20 GraphqlReactPlugin,
21 GoogleDiscoveryReactPlugin,
10 OpenApiReactPlugin,22 OpenApiReactPlugin,
11] as const satisfies readonly ExecutorFrontendPlugin[];23] as const satisfies readonly ExecutorFrontendPlugin[];
12 24
apps/web/src/source-plugins/pages.tsx+82−23
@@ -1,9 +1,11 @@
1import { useEffect, useState } from "react";
1import { useSource } from "@executor/react";2import { useSource } from "@executor/react";
2import { LoadableBlock } from "../components/loadable";3import { LoadableBlock } from "../components/loadable";
3import { SourcePluginsResetState } from "../components/source-plugins-reset-state";4import { SourcePluginsResetState } from "../components/source-plugins-reset-state";
4import {5import {
5 getDefaultSourceFrontendType,6 getDefaultSourceFrontendType,
6 getSourceFrontendType,7 getSourceFrontendType,
8 registeredSourceFrontendTypes,
7} from "./index";9} from "./index";
8 10
9export type SourcePluginRouteSearch = {11export type SourcePluginRouteSearch = {
@@ -12,38 +14,73 @@ export type SourcePluginRouteSearch = { · 2 unmodified lines
12 query?: string;14 query?: string;
13};15};
14 16
15export function SourcePluginAddPage() {17const SourcePluginPicker = (props: {
16 const definition = getDefaultSourceFrontendType();18 selectedKind: string | null;
17 if (definition === null) {19 onSelect: (kind: string) => void;
18 return (20}) => {
19 <SourcePluginsResetState21 if (registeredSourceFrontendTypes.length <= 1) {
20 title="Add Source is intentionally blank"22 return null;
21 message="No source plugins are registered in this build."
22 />
23 );
24 }23 }
25 24
26 return (25 return (
27 <div className="mx-auto max-w-4xl px-6 py-10 lg:px-10 lg:py-14">26 <div className="mb-8 flex flex-wrap gap-2">
28 <div className="mb-8">27 {registeredSourceFrontendTypes.map((definition) => (
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">28 <button
30 Source Plugin29 key={definition.kind}
31 </div>30 type="button"
32 <h1 className="mt-5 font-display text-3xl tracking-tight text-foreground lg:text-4xl">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 >
33 {definition.displayName}38 {definition.displayName}
34 </h1>39 </button>
35 </div>40 ))}
36 {definition.renderAddPage()}
37 </div>41 </div>
38 );42 );
39}43};
44
45const 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
74const SourcePluginAddLayout = (props: {
75 compact?: boolean;
76}) => {
77 const { definition, selectedKind, setSelectedKind } =
78 useSelectedSourceFrontendType();
40 79
41export function SourcePluginCreatePage() {
42 const definition = getDefaultSourceFrontendType();
43 if (definition === null) {80 if (definition === null) {
44 return (81 return (
45 <SourcePluginsResetState82 <SourcePluginsResetState
46 title="New source creation is disabled"83 title="Source plugins are unavailable"
47 message="No source plugins are registered in this build."84 message="No source plugins are registered in this build."
48 />85 />
49 );86 );
@@ -51,9 +88,31 @@ export function SourcePluginCreatePage() { · 1 unmodified line
51 88
52 return (89 return (
53 <div className="mx-auto max-w-4xl px-6 py-10 lg:px-10 lg:py-14">90 <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()}
55 </div>106 </div>
56 );107 );
108};
109
110export function SourcePluginAddPage() {
111 return <SourcePluginAddLayout />;
112}
113
114export function SourcePluginCreatePage() {
115 return <SourcePluginAddLayout compact />;
57}116}
58 117
59export function SourcePluginEditPage(input: {118export function SourcePluginEditPage(input: {
apps/web/src/source-plugins/tool-explorer.tsx+473−0
@@ -0,0 +1,473 @@
1import { startTransition, useEffect, useState, type ReactNode } from "react";
2import type {
3 Loadable,
4 SourceInspection,
5 SourceInspectionToolDetail,
6} from "@executor/react";
7import {
8 useSourceDiscovery,
9 useSourceInspection,
10 useSourceToolDetail,
11} from "@executor/react";
12import { DocumentPanel } from "../components/document-panel";
13import {
14 EmptyState,
15 LoadableBlock,
16} from "../components/loadable";
17import { Markdown } from "../components/markdown";
18import {
19 IconCheck,
20 IconCopy,
21 IconPencil,
22 IconSearch,
23 IconTool,
24} from "../components/icons";
25import { Badge, MethodBadge } from "../components/ui/badge";
26import { cn } from "../lib/utils";
27
28export type SourceToolExplorerSearch = {
29 tab?: "model" | "discover";
30 tool?: string;
31 query?: string;
32};
33
34const 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
56const 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
152const 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
244export 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
4import { Badge } from "../components/ui/badge";4import { Badge } from "../components/ui/badge";
5import { Button } from "../components/ui/button";5import { Button } from "../components/ui/button";
6import { SourcePluginsResetState } from "../components/source-plugins-reset-state";6import { SourcePluginsResetState } from "../components/source-plugins-reset-state";
7import { registeredSourceFrontendTypes } from "../source-plugins";
7 8
8export function HomePage() {9export function HomePage() {
9 const sources = useSources();10 const sources = useSources();
@@ -16,13 +17,22 @@ export function HomePage() { · 6 unmodified lines
16 Sources17 Sources
17 </div>18 </div>
18 <h1 className="mt-5 font-display text-3xl tracking-tight text-foreground lg:text-4xl">19 <h1 className="mt-5 font-display text-3xl tracking-tight text-foreground lg:text-4xl">
19 OpenAPI is now mounted as a source plugin20 Source plugins now own source-specific product behavior
20 </h1>21 </h1>
21 <p className="mt-3 max-w-3xl text-sm leading-6 text-muted-foreground">22 <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-backed23 Add, edit, inspect, and authorize sources through plugin-owned UI and
23 surface is wired in for OpenAPI so the frontend and backend boundaries24 API surfaces. The core app stays generic while each plugin carries its
24 are visible in the app again.25 own transport, auth, and storage behavior.
25 </p>26 </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 )}
26 </div>36 </div>
27 37
28 <div className="mt-8">38 <div className="mt-8">
@@ -36,11 +46,11 @@ export function HomePage() { · 7 unmodified lines
36 ) : items.length === 0 ? (46 ) : items.length === 0 ? (
37 <SourcePluginsResetState47 <SourcePluginsResetState
38 title="No sources connected yet"48 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."
40 action={(50 action={(
41 <Link to="/sources/add">51 <Link to="/sources/add">
42 <Button size="sm" variant="outline">52 <Button size="sm" variant="outline">
43 Open OpenAPI Plugin53 Add Source
44 </Button>54 </Button>
45 </Link>55 </Link>
46 )}56 )}
bun.lock+226−0
@@ -32,6 +32,9 @@ · 31 unmodified lines
32 "@executor/platform-internal": "workspace:*",32 "@executor/platform-internal": "workspace:*",
33 "@executor/platform-sdk": "workspace:*",33 "@executor/platform-sdk": "workspace:*",
34 "@executor/platform-sdk-file": "workspace:*",34 "@executor/platform-sdk-file": "workspace:*",
35 "@executor/plugin-google-discovery-sdk": "workspace:*",
36 "@executor/plugin-graphql-sdk": "workspace:*",
37 "@executor/plugin-mcp-sdk": "workspace:*",
35 "@executor/plugin-openapi-sdk": "workspace:*",38 "@executor/plugin-openapi-sdk": "workspace:*",
36 "@executor/server": "workspace:*",39 "@executor/server": "workspace:*",
37 "effect": "catalog:",40 "effect": "catalog:",
@@ -52,6 +55,9 @@ · 14 unmodified lines
52 "name": "@executor/web",55 "name": "@executor/web",
53 "version": "0.0.0",56 "version": "0.0.0",
54 "dependencies": {57 "dependencies": {
58 "@executor/plugin-google-discovery-react": "workspace:*",
59 "@executor/plugin-graphql-react": "workspace:*",
60 "@executor/plugin-mcp-react": "workspace:*",
55 "@executor/plugin-openapi-react": "workspace:*",61 "@executor/plugin-openapi-react": "workspace:*",
56 "@executor/react": "workspace:*",62 "@executor/react": "workspace:*",
57 "@executor/server": "workspace:*",63 "@executor/server": "workspace:*",
@@ -394,6 +400,15 @@ · 336 unmodified lines
394 "@executor/platform-internal": "workspace:*",400 "@executor/platform-internal": "workspace:*",
395 "@executor/platform-sdk": "workspace:*",401 "@executor/platform-sdk": "workspace:*",
396 "@executor/platform-sdk-file": "workspace:*",402 "@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:*",
397 "@executor/plugin-openapi-http": "workspace:*",412 "@executor/plugin-openapi-http": "workspace:*",
398 "@executor/plugin-openapi-sdk": "workspace:*",413 "@executor/plugin-openapi-sdk": "workspace:*",
399 "@executor/plugin-openapi-shared": "workspace:*",414 "@executor/plugin-openapi-shared": "workspace:*",
@@ -425,6 +440,191 @@ · 25 unmodified lines
425 "bun-types": "catalog:",440 "bun-types": "catalog:",
426 },441 },
427 },442 },
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 },
428 "plugins/openapi/http": {628 "plugins/openapi/http": {
429 "name": "@executor/plugin-openapi-http",629 "name": "@executor/plugin-openapi-http",
430 "version": "0.0.0",630 "version": "0.0.0",
@@ -717,6 +917,30 @@ · 286 unmodified lines
717 917
718 "@executor/platform-sdk-file": ["@executor/platform-sdk-file@workspace:packages/platform/sdk-file"],918 "@executor/platform-sdk-file": ["@executor/platform-sdk-file@workspace:packages/platform/sdk-file"],
719 919
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
720 "@executor/plugin-openapi-http": ["@executor/plugin-openapi-http@workspace:plugins/openapi/http"],944 "@executor/plugin-openapi-http": ["@executor/plugin-openapi-http@workspace:plugins/openapi/http"],
721 945
722 "@executor/plugin-openapi-react": ["@executor/plugin-openapi-react@workspace:plugins/openapi/react"],946 "@executor/plugin-openapi-react": ["@executor/plugin-openapi-react@workspace:plugins/openapi/react"],
@@ -1373,6 +1597,8 @@ · 650 unmodified lines
1373 1597
1374 "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],1598 "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
1375 1599
1600 "graphql": ["graphql@16.13.1", "", {}, "sha512-gGgrVCoDKlIZ8fIqXBBb0pPKqDgki0Z/FSKNiQzSGj2uEYHr1tq5wmBegGwJx6QB5S5cM0khSBpi/JFHMCvsmQ=="],
1601
1376 "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],1602 "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
1377 1603
1378 "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],1604 "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
2 ExecutionEnvelope,2 ExecutionEnvelope,
3} from "@executor/platform-sdk/schema";3} from "@executor/platform-sdk/schema";
4import {4import {
5 EXECUTOR_SOURCES_ADD_HELP_LINES,5 getExecutorSourcesAddHelpLines,
6 RuntimeSourceCatalogStoreService,6 RuntimeSourceCatalogStoreService,
7 createExecution,7 createExecution,
8 getExecution,8 getExecution,
@@ -172,14 +172,12 @@ const buildExecuteWorkflowText = ( · 163 unmodified lines
172 '1) const matches = await tools.discover({ query: "<intent>", limit: 12 });',172 '1) const matches = await tools.discover({ query: "<intent>", limit: 12 });',
173 "2) const details = await tools.describe.tool({ path, includeSchemas: true });",173 "2) const details = await tools.describe.tool({ path, includeSchemas: true });",
174 "3) Call selected tools.<path>(input).",174 "3) Call selected tools.<path>(input).",
175 "4) Source plugins are not registered in this build.",175 "4) Use source plugins to inspect or add API sources.",
176 ...EXECUTOR_SOURCES_ADD_HELP_LINES,176 ...getExecutorSourcesAddHelpLines(),
177 "5) If execution pauses for interaction, resume it with the returned resumePayload or the available resume flow.",177 "5) If execution pauses for interaction, resume it with the returned resumePayload or the available resume flow.",
178 "Do not use fetch; use tools.* only.",178 "Do not use fetch; use tools.* only.",
179 ].join("\n");179 ].join("\n");
180 180
181const defaultExecuteDescription = buildExecuteWorkflowText();
182
183const loadExecuteDescription = (runtime: ExecutorRuntime): Promise<string> =>181const loadExecuteDescription = (runtime: ExecutorRuntime): Promise<string> =>
184 runControlPlane(182 runControlPlane(
185 runtime,183 runtime,
@@ -203,11 +201,11 @@ const loadExecuteDescription = (runtime: ExecutorRuntime): Promise<string> => · 17 unmodified lines
203 .filter((catalog) => catalog.toolPaths.length > 0);201 .filter((catalog) => catalog.toolPaths.length > 0);
204 202
205 if (sourceToolExamples.length === 0) {203 if (sourceToolExamples.length === 0) {
206 return defaultExecuteDescription;204 return buildExecuteWorkflowText();
207 }205 }
208 206
209 return buildExecuteWorkflowText(sourceToolExamples);207 return buildExecuteWorkflowText(sourceToolExamples);
210 }).pipe(Effect.catchAll(() => Effect.succeed(defaultExecuteDescription))),208 }).pipe(Effect.catchAll(() => Effect.succeed(buildExecuteWorkflowText()))),
211 );209 );
212 210
213const summarizeExecution = (execution: ExecutionEnvelope["execution"]): string => {211const summarizeExecution = (execution: ExecutionEnvelope["execution"]): string => {
packages/platform/sdk/src/executor-effect.ts+31−4
@@ -285,6 +285,31 @@ export const createExecutorEffect = < · 284 unmodified lines
285 cause instanceof Error ? cause : new Error(String(cause)),285 cause instanceof Error ? cause : new Error(String(cause)),
286 ),286 ),
287 );287 );
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 };
288 const host: ExecutorSdkPluginHost = {313 const host: ExecutorSdkPluginHost = {
289 sources: {314 sources: {
290 create: ({ source }) =>315 create: ({ source }) =>
@@ -330,10 +355,12 @@ export const createExecutorEffect = < · 39 unmodified lines
330 const extensions = Object.fromEntries(355 const extensions = Object.fromEntries(
331 (options.plugins ?? []).map((plugin) => [356 (options.plugins ?? []).map((plugin) => [
332 plugin.key,357 plugin.key,
333 plugin.extendExecutor?.({358 providePluginExtensionValue(
334 executor,359 plugin.extendExecutor?.({
335 host,360 executor,
336 }) ?? {},361 host,
362 }) ?? {},
363 ),
337 ]),364 ]),
338 );365 );
339 366
packages/platform/sdk/src/plugins.ts+26−7
@@ -11,6 +11,7 @@ import type { · 10 unmodified lines
11 SourceSyncInput,11 SourceSyncInput,
12} from "@executor/source-core";12} from "@executor/source-core";
13import * as Effect from "effect/Effect";13import * as Effect from "effect/Effect";
14import type * as Schema from "effect/Schema";
14 15
15export type ExecutorSdkPlugin<16export type ExecutorSdkPlugin<
16 TKey extends string = string,17 TKey extends string = string,
@@ -18,6 +19,7 @@ export type ExecutorSdkPlugin< · 1 unmodified line
18> = {19> = {
19 key: TKey;20 key: TKey;
20 sources?: readonly SourcePluginRuntime[];21 sources?: readonly SourcePluginRuntime[];
22 sourceConnectors?: readonly ExecutorSourceConnector<any>[];
21 extendExecutor?: (input: {23 extendExecutor?: (input: {
22 executor: ExecutorEffect & Record<string, unknown>;24 executor: ExecutorEffect & Record<string, unknown>;
23 host: ExecutorSdkPluginHost;25 host: ExecutorSdkPluginHost;
@@ -31,16 +33,28 @@ export type ExecutorSdkPluginHost = { · 7 unmodified lines
31 ExecutorSource,33 ExecutorSource,
32 "id" | "scopeId" | "createdAt" | "updatedAt"34 "id" | "scopeId" | "createdAt" | "updatedAt"
33 >;35 >;
34 }) => Effect.Effect<ExecutorSource, Error, never>;36 }) => Effect.Effect<ExecutorSource, Error, any>;
35 get: (sourceId: ExecutorSource["id"]) => Effect.Effect<ExecutorSource, Error, never>;37 get: (sourceId: ExecutorSource["id"]) => Effect.Effect<ExecutorSource, Error, any>;
36 save: (source: ExecutorSource) => Effect.Effect<ExecutorSource, Error, never>;38 save: (source: ExecutorSource) => Effect.Effect<ExecutorSource, Error, any>;
37 refreshCatalog: (39 refreshCatalog: (
38 sourceId: ExecutorSource["id"],40 sourceId: ExecutorSource["id"],
39 ) => Effect.Effect<ExecutorSource, Error, never>;41 ) => Effect.Effect<ExecutorSource, Error, any>;
40 remove: (sourceId: ExecutorSource["id"]) => Effect.Effect<boolean, Error, never>;42 remove: (sourceId: ExecutorSource["id"]) => Effect.Effect<boolean, Error, any>;
41 };43 };
42};44};
43 45
46export 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
44export type ExecutorSdkPluginExtensions<58export type ExecutorSdkPluginExtensions<
45 TPlugins extends readonly ExecutorSdkPlugin<any, any>[],59 TPlugins extends readonly ExecutorSdkPlugin<any, any>[],
46> = {60> = {
@@ -59,21 +73,25 @@ export type SourcePluginRuntime = { · 12 unmodified lines
59 }) => Record<string, unknown>;73 }) => Record<string, unknown>;
60 getIrModel: (74 getIrModel: (
61 input: SourceSyncInput,75 input: SourceSyncInput,
62 ) => Effect.Effect<SourceCatalogSyncResult, Error, never>;76 ) => Effect.Effect<SourceCatalogSyncResult, Error, any>;
63 invoke: (77 invoke: (
64 input: SourceInvokeInput,78 input: SourceInvokeInput,
65 ) => Effect.Effect<SourceInvokeResult, Error, never>;79 ) => Effect.Effect<SourceInvokeResult, Error, any>;
66};80};
67 81
68export const registerExecutorSdkPlugins = (82export const registerExecutorSdkPlugins = (
69 plugins: readonly ExecutorSdkPlugin[],83 plugins: readonly ExecutorSdkPlugin[],
70) => {84) => {
71 const sourcePlugins = new Map<string, SourcePluginRuntime>();85 const sourcePlugins = new Map<string, SourcePluginRuntime>();
86 const sourceConnectors = new Map<string, ExecutorSourceConnector<any>>();
72 87
73 for (const plugin of plugins) {88 for (const plugin of plugins) {
74 for (const source of plugin.sources ?? []) {89 for (const source of plugin.sources ?? []) {
75 sourcePlugins.set(source.kind, source);90 sourcePlugins.set(source.kind, source);
76 }91 }
92 for (const connector of plugin.sourceConnectors ?? []) {
93 sourceConnectors.set(connector.kind, connector);
94 }
77 }95 }
78 96
79 const getSourcePlugin = (kind: string) => {97 const getSourcePlugin = (kind: string) => {
@@ -91,6 +109,7 @@ export const registerExecutorSdkPlugins = ( · 11 unmodified lines
91 return {109 return {
92 plugins,110 plugins,
93 sourcePlugins: [...sourcePlugins.values()],111 sourcePlugins: [...sourcePlugins.values()],
112 sourceConnectors: [...sourceConnectors.values()],
94 getSourcePlugin,113 getSourcePlugin,
95 getSourcePluginForSource,114 getSourcePluginForSource,
96 sourcePluginCatalogKind: (kind: string): SourceCatalogKind =>115 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
56 sync: (input: {56 sync: (input: {
57 source: Source;57 source: Source;
58 actorScopeId?: ScopeId | null;58 actorScopeId?: ScopeId | null;
59 }) => Effect.Effect<void, Error, never>;59 }) => Effect.Effect<void, Error, any>;
60};60};
61 61
62export class RuntimeSourceCatalogSyncService extends Context.Tag(62export class RuntimeSourceCatalogSyncService extends Context.Tag(
@@ -83,7 +83,7 @@ const syncSourceCatalogWithDeps = ( · 20 unmodified lines
83 source: Source;83 source: Source;
84 actorScopeId?: ScopeId | null;84 actorScopeId?: ScopeId | null;
85 },85 },
86): Effect.Effect<void, Error, never> =>86): Effect.Effect<void, Error, any> =>
87 Effect.gen(function* () {87 Effect.gen(function* () {
88 yield* ensureRuntimeCatalogSyncWorkspace(deps, input.source.scopeId);88 yield* ensureRuntimeCatalogSyncWorkspace(deps, input.source.scopeId);
89 89
packages/platform/sdk/src/runtime/execution/scope/tool-invoker.ts+10−3
@@ -129,6 +129,9 @@ export const createScopeToolInvoker = (input: { · 128 unmodified lines
129 const executorTools = createExecutorToolMap({129 const executorTools = createExecutorToolMap({
130 scopeId: input.scopeId,130 scopeId: input.scopeId,
131 actorScopeId: input.actorScopeId,131 actorScopeId: input.actorScopeId,
132 executorStateStore: input.executorStateStore,
133 sourceStore: input.sourceStore,
134 sourceCatalogSyncService: input.sourceCatalogSyncService,
132 installationStore: input.installationStore,135 installationStore: input.installationStore,
133 scopeConfigStore: input.scopeConfigStore,136 scopeConfigStore: input.scopeConfigStore,
134 scopeStateStore: input.scopeStateStore,137 scopeStateStore: input.scopeStateStore,
@@ -236,9 +239,13 @@ export const createScopeToolInvoker = (input: { · 101 unmodified lines
236 catalog,239 catalog,
237 toolInvoker: {240 toolInvoker: {
238 invoke: ({ path, args, context }) => {241 invoke: ({ path, args, context }) => {
239 const effect: Effect.Effect<unknown, unknown, never> = authoredToolPaths.has(path)242 const effect = authoredToolPaths.has(path)
240 ? authoredInvoker.invoke({ path, args, context }) as Effect.Effect<unknown, unknown, never>243 ? authoredInvoker.invoke({ path, args, context })
241 : invokePersistedTool({ path, args, context });244 : invokePersistedTool({ path, args, context }) as Effect.Effect<
245 unknown,
246 unknown,
247 never
248 >;
242 249
243 return provideRuntimeLocalScope(effect, input.runtimeLocalScope);250 return provideRuntimeLocalScope(effect, input.runtimeLocalScope);
244 },251 },
packages/platform/sdk/src/runtime/sources/executor-tools.ts+240−20
@@ -1,47 +1,267 @@
1import type { ToolMap } from "@executor/codemode-core";1import { toTool, type ToolMap } from "@executor/codemode-core";
2import { SourceSchema } from "#schema";2import {
3 type ScopeId,
4 SourceSchema,
5 type Source,
6} from "#schema";
7import * as Effect from "effect/Effect";
8import * as Layer from "effect/Layer";
9import * as Option from "effect/Option";
10import * as Schema from "effect/Schema";
3 11
12import {
13 createManagedSourceRecord,
14 getSource,
15 refreshManagedSourceCatalog,
16 removeSource,
17 saveManagedSourceRecord,
18} from "../../sources/operations";
4import {19import {
5 deriveSchemaJson,20 deriveSchemaJson,
6 deriveSchemaTypeSignature,21 deriveSchemaTypeSignature,
7} from "../catalog/schema-type-signature";22} from "../catalog/schema-type-signature";
8import type {23import {
9 InstallationStoreShape,24 RuntimeSourceCatalogSyncService,
10 SourceArtifactStoreShape,25} from "../catalog/source/sync";
11 ScopeConfigStoreShape,26import {
12 ScopeStateStoreShape,27 ExecutorStateStore,
28 type ExecutorStateStoreShape,
29} from "../executor-state-store";
30import {
31 provideOptionalRuntimeLocalScope,
32 type RuntimeLocalScopeState,
33} from "../scope/runtime-context";
34import {
35 type InstallationStoreShape,
36 makeLocalStorageLayer,
37 type SourceArtifactStoreShape,
38 type ScopeConfigStoreShape,
39 type ScopeStateStoreShape,
13} from "../scope/storage";40} from "../scope/storage";
41import {
42 type RuntimeSourceStore,
43 RuntimeSourceStoreService,
44} from "./source-store";
45import {
46 registeredSourceConnectors,
47} from "./source-plugins";
48import type {
49 ExecutorSdkPluginHost,
50 ExecutorSourceConnector,
51} from "../../plugins";
52
53const createExecutorSourcesAddSchema = (): Schema.Schema<any, any, never> => {
54 const connectors = registeredSourceConnectors();
55 if (connectors.length === 0) {
56 return Schema.Unknown;
57 }
14 58
15export const EXECUTOR_SOURCES_ADD_INPUT_HINT =59 if (connectors.length === 1) {
16 "Source plugins are not registered in this build.";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
72export const getExecutorSourcesAddInputHint = (): string =>
73 deriveSchemaTypeSignature(createExecutorSourcesAddSchema(), 340);
17 74
18export const EXECUTOR_SOURCES_ADD_OUTPUT_SIGNATURE = deriveSchemaTypeSignature(75export const EXECUTOR_SOURCES_ADD_OUTPUT_SIGNATURE = deriveSchemaTypeSignature(
19 SourceSchema,76 SourceSchema,
20 260,77 260,
21);78);
22 79
23export const EXECUTOR_SOURCES_ADD_INPUT_SCHEMA = {};80export const getExecutorSourcesAddInputSchemaJson = (): Record<string, unknown> =>
81 deriveSchemaJson(createExecutorSourcesAddSchema()) ?? {};
24 82
25export const EXECUTOR_SOURCES_ADD_OUTPUT_SCHEMA = deriveSchemaJson(83export const EXECUTOR_SOURCES_ADD_OUTPUT_SCHEMA = deriveSchemaJson(
26 SourceSchema,84 SourceSchema,
27) ?? {};85) ?? {};
28 86
29export const EXECUTOR_SOURCES_ADD_HELP_LINES = [87export const getExecutorSourcesAddHelpLines = (): readonly string[] => {
30 "Source plugins are not registered in this build.",88 const connectors = registeredSourceConnectors();
31] as const;89 if (connectors.length === 0) {
90 return ["No source plugins are registered in this build."] as const;
91 }
32 92
33export const buildExecutorSourcesAddDescription = (): string =>93 return [
34 "Source plugins are not registered in this build.";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
105export 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
117const 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
153const 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
191const 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
213const toSerializableValue = <A>(value: A): A =>
214 JSON.parse(JSON.stringify(value)) as A;
35 215
36export const createExecutorToolMap = (input: {216export const createExecutorToolMap = (input: {
37 scopeId?: string;217 scopeId: ScopeId;
38 actorScopeId?: string;218 actorScopeId: ScopeId;
219 executorStateStore: ExecutorStateStoreShape;
220 sourceStore: RuntimeSourceStore;
221 sourceCatalogSyncService: Effect.Effect.Success<
222 typeof RuntimeSourceCatalogSyncService
223 >;
39 installationStore: InstallationStoreShape;224 installationStore: InstallationStoreShape;
40 scopeConfigStore: ScopeConfigStoreShape;225 scopeConfigStore: ScopeConfigStoreShape;
41 scopeStateStore: ScopeStateStoreShape;226 scopeStateStore: ScopeStateStoreShape;
42 sourceArtifactStore: SourceArtifactStoreShape;227 sourceArtifactStore: SourceArtifactStoreShape;
43 runtimeLocalScope?: unknown;228 runtimeLocalScope: RuntimeLocalScopeState | null;
44}): ToolMap => {229}): ToolMap => {
45 void input;230 const connectors = registeredSourceConnectors();
46 return {};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 };
47};267};
packages/platform/sdk/src/runtime/sources/source-plugins/index.ts+1−0
@@ -27,6 +27,7 @@ export const configureExecutorSourcePlugins = ( · 26 unmodified lines
27};27};
28 28
29export const registeredSourcePlugins = () => registry.sourcePlugins;29export const registeredSourcePlugins = () => registry.sourcePlugins;
30export const registeredSourceConnectors = () => registry.sourceConnectors;
30 31
31export const getSourcePlugin = (kind: string) => registry.getSourcePlugin(kind);32export const getSourcePlugin = (kind: string) => registry.getSourcePlugin(kind);
32export const getSourcePluginForSource = (33export const getSourcePluginForSource = (
packages/platform/server/package.json+9−0
@@ -15,6 +15,15 @@ · 14 unmodified lines
15 "@effect/platform": "catalog:",15 "@effect/platform": "catalog:",
16 "@effect/platform-node": "catalog:",16 "@effect/platform-node": "catalog:",
17 "@executor/executor-mcp": "workspace:*",17 "@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:*",
18 "@executor/plugin-openapi-http": "workspace:*",27 "@executor/plugin-openapi-http": "workspace:*",
19 "@executor/plugin-openapi-shared": "workspace:*",28 "@executor/plugin-openapi-shared": "workspace:*",
20 "@executor/plugin-openapi-sdk": "workspace:*",29 "@executor/plugin-openapi-sdk": "workspace:*",
packages/platform/server/src/google-discovery-oauth-session-storage.ts+40−0
@@ -0,0 +1,40 @@
1import type { GoogleDiscoveryOAuthSessionStorage } from "@executor/plugin-google-discovery-sdk";
2import {
3 GoogleDiscoveryOAuthSessionSchema,
4} from "@executor/plugin-google-discovery-shared";
5
6import {
7 pluginSessionStoragePath,
8 readJsonFile,
9 removeJsonFile,
10 writeJsonFile,
11} from "./json-file-storage";
12
13export 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 @@
1import type { GoogleDiscoverySourceStorage } from "@executor/plugin-google-discovery-sdk";
2import {
3 GoogleDiscoveryStoredSourceDataSchema,
4} from "@executor/plugin-google-discovery-shared";
5
6import {
7 pluginSourceStoragePath,
8 readJsonFile,
9 removeJsonFile,
10 writeJsonFile,
11} from "./json-file-storage";
12
13export 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 @@
1import type { GraphqlSourceStorage } from "@executor/plugin-graphql-sdk";
2import {
3 GraphqlStoredSourceDataSchema,
4} from "@executor/plugin-graphql-shared";
5
6import {
7 pluginSourceStoragePath,
8 readJsonFile,
9 removeJsonFile,
10 writeJsonFile,
11} from "./json-file-storage";
12
13export 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
8import { Readable } from "node:stream";8import { Readable } from "node:stream";
9import { FileSystem, HttpApiBuilder, HttpServer } from "@effect/platform";9import { FileSystem, HttpApiBuilder, HttpServer } from "@effect/platform";
10import { NodeFileSystem } from "@effect/platform-node";10import { NodeFileSystem } from "@effect/platform-node";
11import { googleDiscoveryHttpPlugin } from "@executor/plugin-google-discovery-http";
12import { googleDiscoverySdkPlugin } from "@executor/plugin-google-discovery-sdk";
13import { graphqlHttpPlugin } from "@executor/plugin-graphql-http";
14import { graphqlSdkPlugin } from "@executor/plugin-graphql-sdk";
15import { mcpHttpPlugin } from "@executor/plugin-mcp-http";
16import { mcpSdkPlugin } from "@executor/plugin-mcp-sdk";
11import { openApiHttpPlugin } from "@executor/plugin-openapi-http";17import { openApiHttpPlugin } from "@executor/plugin-openapi-http";
12import {18import {
13 openApiSdkPlugin,19 openApiSdkPlugin,
@@ -48,8 +54,18 @@ import { · 34 unmodified lines
48 tracingSearchUrl,54 tracingSearchUrl,
49} from "./tracing";55} from "./tracing";
50import { platformServerEffectError } from "./effect-errors";56import { platformServerEffectError } from "./effect-errors";
57import { createFileGoogleDiscoveryOAuthSessionStorage } from "./google-discovery-oauth-session-storage";
58import { createFileGoogleDiscoverySourceStorage } from "./google-discovery-source-storage";
59import { createFileGraphqlSourceStorage } from "./graphql-source-storage";
60import { createFileMcpOAuthSessionStorage } from "./mcp-oauth-session-storage";
61import { createFileMcpSourceStorage } from "./mcp-source-storage";
51import { createFileOpenApiSourceStorage } from "./openapi-source-storage";62import { createFileOpenApiSourceStorage } from "./openapi-source-storage";
52 63
64export { createFileGoogleDiscoveryOAuthSessionStorage } from "./google-discovery-oauth-session-storage";
65export { createFileGoogleDiscoverySourceStorage } from "./google-discovery-source-storage";
66export { createFileGraphqlSourceStorage } from "./graphql-source-storage";
67export { createFileMcpOAuthSessionStorage } from "./mcp-oauth-session-storage";
68export { createFileMcpSourceStorage } from "./mcp-source-storage";
53export { createFileOpenApiSourceStorage } from "./openapi-source-storage";69export { createFileOpenApiSourceStorage } from "./openapi-source-storage";
54 70
55export {71export {
@@ -114,7 +130,12 @@ type ExecutorMcpHandler = ReturnType<typeof createExecutorMcpRequestHandler>; · 58 unmodified lines
114const EXECUTOR_NPM_DIST_TAGS_PATHNAME = "/v1/app/npm/dist-tags";130const EXECUTOR_NPM_DIST_TAGS_PATHNAME = "/v1/app/npm/dist-tags";
115const EXECUTOR_NPM_DIST_TAGS_URL =131const EXECUTOR_NPM_DIST_TAGS_URL =
116 "https://registry.npmjs.org/-/package/executor/dist-tags";132 "https://registry.npmjs.org/-/package/executor/dist-tags";
117const executorHttpPlugins = [openApiHttpPlugin()] as const;133const executorHttpPlugins = [
134 graphqlHttpPlugin(),
135 googleDiscoveryHttpPlugin(),
136 mcpHttpPlugin(),
137 openApiHttpPlugin(),
138] as const;
118 139
119const disposeExecutor = (executor: Executor) =>140const disposeExecutor = (executor: Executor) =>
120 Effect.tryPromise({141 Effect.tryPromise({
@@ -134,6 +155,27 @@ const createExecutorRuntime = ( · 13 unmodified lines
134 localDataDir,155 localDataDir,
135 }),156 }),
136 plugins: [157 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 }),
137 openApiSdkPlugin({179 openApiSdkPlugin({
138 storage: createFileOpenApiSourceStorage({180 storage: createFileOpenApiSourceStorage({
139 rootDir: resolve(localDataDir, "plugins", "openapi", "sources"),181 rootDir: resolve(localDataDir, "plugins", "openapi", "sources"),
packages/platform/server/src/json-file-storage.ts+79−0
@@ -0,0 +1,79 @@
1import { dirname, join } from "node:path";
2import { FileSystem } from "@effect/platform";
3import { NodeFileSystem } from "@effect/platform-node";
4import * as Effect from "effect/Effect";
5import * as Schema from "effect/Schema";
6
7const toError = (cause: unknown): Error =>
8 cause instanceof Error ? cause : new Error(String(cause));
9
10const 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
15export 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
38export 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
59export 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
70export const pluginSourceStoragePath = (input: {
71 rootDir: string;
72 scopeId: string;
73 sourceId: string;
74}) => join(input.rootDir, input.scopeId, `${input.sourceId}.json`);
75
76export 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 @@
1import type { McpOAuthSessionStorage } from "@executor/plugin-mcp-sdk";
2import {
3 McpOAuthSessionSchema,
4} from "@executor/plugin-mcp-shared";
5
6import {
7 pluginSessionStoragePath,
8 readJsonFile,
9 removeJsonFile,
10 writeJsonFile,
11} from "./json-file-storage";
12
13export 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 @@
1import type { McpSourceStorage } from "@executor/plugin-mcp-sdk";
2import {
3 McpStoredSourceDataSchema,
4} from "@executor/plugin-mcp-shared";
5
6import {
7 pluginSourceStoragePath,
8 readJsonFile,
9 removeJsonFile,
10 writeJsonFile,
11} from "./json-file-storage";
12
13export 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 @@
1import {
2 HttpApi,
3 HttpApiBuilder,
4 HttpApiEndpoint,
5 HttpApiGroup,
6 HttpApiSchema,
7 HttpServerResponse,
8} from "@effect/platform";
9import {
10 ControlPlaneBadRequestError,
11 ControlPlaneForbiddenError,
12 ControlPlaneNotFoundError,
13 ControlPlaneStorageError,
14 type ExecutorHttpApiExtension,
15 type ExecutorHttpPlugin,
16} from "@executor/platform-api";
17import { resolveRequestedLocalWorkspace } from "@executor/platform-api/local-context";
18import {
19 ScopeIdSchema,
20 SourceIdSchema,
21 SourceSchema,
22 type Source,
23} from "@executor/platform-sdk/schema";
24import * as Effect from "effect/Effect";
25import * as Schema from "effect/Schema";
26
27import {
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
41type 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
67const workspaceIdParam = HttpApiSchema.param("workspaceId", ScopeIdSchema);
68const sourceIdParam = HttpApiSchema.param("sourceId", SourceIdSchema);
69const htmlSchema = HttpApiSchema.Text({
70 contentType: "text/html",
71});
72
73const 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
80export 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
131export const googleDiscoveryHttpApiExtension = {
132 key: "googleDiscovery",
133 group: GoogleDiscoveryHttpGroup,
134} satisfies ExecutorHttpApiExtension<typeof GoogleDiscoveryHttpGroup>;
135
136const GoogleDiscoveryHttpApi = HttpApi.make("executor").add(GoogleDiscoveryHttpGroup);
137
138const 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
145const 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
152const 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
159const 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
168const escapeHtml = (value: string): string =>
169 value
170 .replaceAll("&", "&amp;")
171 .replaceAll("<", "&lt;")
172 .replaceAll(">", "&gt;")
173 .replaceAll('"', "&quot;")
174 .replaceAll("'", "&#39;");
175
176const 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
217export 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 @@
1import { startTransition, useMemo, useState, type ReactNode } from "react";
2import { useNavigate } from "@tanstack/react-router";
3import type { Source } from "@executor/react";
4import {
5 defineExecutorPluginHttpApiClient,
6 Result,
7 useAtomSet,
8 useAtomValue,
9 useExecutorMutation,
10 useLocalInstallation,
11 useSecrets,
12} from "@executor/react";
13
14import {
15 googleDiscoveryHttpApiExtension,
16} from "@executor/plugin-google-discovery-http";
17import {
18 defaultGoogleDiscoveryUrl,
19 type GoogleDiscoveryConnectInput,
20 type GoogleDiscoveryConnectionAuth,
21 type GoogleDiscoveryOAuthPopupResult,
22 type GoogleDiscoveryStartOAuthInput,
23} from "@executor/plugin-google-discovery-shared";
24import { IconPencil } from "../../../apps/web/src/components/icons";
25import { SourceToolExplorer } from "../../../apps/web/src/source-plugins/tool-explorer";
26
27type 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
42type FrontendPluginRegisterApi = {
43 sources: {
44 registerType: (definition: FrontendSourceTypeDefinition) => void;
45 };
46};
47
48type RouteToolSearch = {
49 tab?: "model" | "discover";
50 tool?: string;
51 query?: string;
52};
53
54const OAUTH_STORAGE_PREFIX = "executor:google-discovery-oauth:";
55const OAUTH_TIMEOUT_MS = 2 * 60_000;
56
57const getGoogleDiscoveryHttpClient =
58 defineExecutorPluginHttpApiClient<"GoogleDiscoveryReactHttpClient">()(
59 "GoogleDiscoveryReactHttpClient",
60 [googleDiscoveryHttpApiExtension] as const,
61 );
62
63const 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
75const 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
82const 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
101const stringifyScopes = (value: ReadonlyArray<string>): string =>
102 value.join("\n");
103
104const parseScopes = (value: string): Array<string> =>
105 value
106 .split(/[\n,]/)
107 .map((entry) => entry.trim())
108 .filter(Boolean);
109
110const authSecretValue = (
111 input: GoogleDiscoveryConnectionAuth,
112): string =>
113 input.kind === "bearer"
114 ? JSON.stringify(input.tokenSecretRef)
115 : "";
116
117const clientSecretValue = (
118 input: GoogleDiscoveryConnectionAuth,
119): string =>
120 input.kind === "oauth2" && input.clientSecretRef
121 ? JSON.stringify(input.clientSecretRef)
122 : "";
123
124const 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
182const 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
206function 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
534function 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
578function 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
657function 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
776export 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 @@
1import { createCatalogSnapshotV1FromFragments } from "@executor/ir/catalog";
2import {
3 CapabilityIdSchema,
4 DocumentIdSchema,
5 ExecutableIdSchema,
6 ParameterSymbolIdSchema,
7 RequestBodySymbolIdSchema,
8 ResponseSymbolIdSchema,
9 ScopeIdSchema,
10 SecuritySchemeSymbolIdSchema,
11} from "@executor/ir/ids";
12import type {
13 AuthRequirement,
14 Capability,
15 CatalogSnapshotV1,
16 ParameterSymbol,
17 ResponseSymbol,
18 Scope,
19 SecuritySchemeSymbol,
20 Executable,
21} from "@executor/ir/model";
22import {
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
42import type { GoogleDiscoveryToolProviderData } from "./types";
43
44export type GoogleDiscoveryCatalogOperationInput = BaseCatalogOperationInput & {
45 providerData: GoogleDiscoveryToolProviderData;
46};
47
48const 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
64const 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
353export 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
379export 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 @@
1import {
2 executeHttpProbe,
3 namespaceFromSourceName,
4 noneAuthInference,
5 supportedAuthInference,
6 trimOrNull,
7 type SourceAuthInference,
8 type SourceDiscoveryProbeInput,
9 type SourceDiscoveryResult,
10} from "@executor/source-core";
11import * as Either from "effect/Either";
12import * as Effect from "effect/Effect";
13
14import { extractGoogleDiscoveryManifest } from "./document";
15
16const 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
27const 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
47export 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 @@
1import { sha256Hex } from "@executor/codemode-core";
2import * as Effect from "effect/Effect";
3
4import type {
5 GoogleDiscoveryHttpMethod,
6 GoogleDiscoveryManifestMethod,
7 GoogleDiscoveryMethodParameter,
8 GoogleDiscoverySchemaRefTable,
9 GoogleDiscoveryToolManifest,
10} from "./types";
11
12type JsonObject = Record<string, unknown>;
13
14type 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
26type 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
40type 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
54type DiscoveryResource = {
55 methods?: unknown;
56 resources?: unknown;
57};
58
59type 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
74const asRecord = (value: unknown): JsonObject =>
75 typeof value === "object" && value !== null && !Array.isArray(value)
76 ? value as JsonObject
77 : {};
78
79const asString = (value: unknown): string | null =>
80 typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
81
82const asBoolean = (value: unknown): boolean | null =>
83 typeof value === "boolean" ? value : null;
84
85const 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
93const 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
105const contentHash = (value: string): string =>
106 sha256Hex(value);
107
108const escapeJsonPointerSegment = (value: string): string =>
109 value.replaceAll("~", "~0").replaceAll("/", "~1");
110
111const schemaRefKey = (schemaId: string): string =>
112 `#/$defs/google/${escapeJsonPointerSegment(schemaId)}`;
113
114const 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
130const 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
225const 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
244const 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
289const 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
309const 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
334const 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
353const 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
425const 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
458export 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
547export const compileGoogleDiscoveryToolDefinitions = (
548 manifest: GoogleDiscoveryToolManifest,
549): Array<GoogleDiscoveryManifestMethod> => [...manifest.methods];
plugins/google-discovery/sdk/index.ts+914−0
@@ -0,0 +1,914 @@
1import * as Effect from "effect/Effect";
2import * as Schema from "effect/Schema";
3
4import {
5 buildOAuth2AuthorizationUrl,
6 createPkceCodeVerifier,
7 exchangeOAuth2AuthorizationCode,
8 refreshOAuth2AccessToken,
9} from "@executor/auth-oauth2";
10import {
11 createCatalogImportMetadata,
12 createSourceCatalogSyncResult,
13 SourceCredentialRequiredError,
14} from "@executor/source-core";
15import type { Source } from "@executor/platform-sdk/schema";
16import type {
17 ExecutorSdkPlugin,
18 ExecutorSdkPluginHost,
19 ExecutorSourceConnector,
20 SourcePluginRuntime,
21} from "@executor/platform-sdk/plugins";
22import {
23 SecretMaterialDeleterService,
24 SecretMaterialResolverService,
25 SecretMaterialStorerService,
26 SecretMaterialUpdaterService,
27 provideExecutorRuntime,
28} from "@executor/platform-sdk/runtime";
29import {
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
46import {
47 createGoogleDiscoveryCatalogFragment,
48 type GoogleDiscoveryCatalogOperationInput,
49} from "./catalog";
50import {
51 compileGoogleDiscoveryToolDefinitions,
52 extractGoogleDiscoveryManifest,
53} from "./document";
54import {
55 buildGoogleDiscoveryToolPresentation,
56} from "./tools";
57import {
58 GoogleDiscoveryToolProviderDataSchema,
59 type GoogleDiscoveryToolManifest,
60 type GoogleDiscoveryToolProviderData,
61} from "./types";
62
63const decodeStoredSourceData = Schema.decodeUnknownSync(
64 GoogleDiscoveryStoredSourceDataSchema,
65);
66const decodeSession = Schema.decodeUnknownSync(GoogleDiscoveryOAuthSessionSchema);
67const decodeProviderData = Schema.decodeUnknownSync(GoogleDiscoveryToolProviderDataSchema);
68
69export 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
85export 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
94export 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
118const 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
129type GoogleDiscoveryExecutorAddInput =
130 typeof GoogleDiscoveryExecutorAddInputSchema.Type;
131
132const GOOGLE_AUTHORIZATION_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth";
133const GOOGLE_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
134const OAUTH_REFRESH_SKEW_MS = 60_000;
135
136const 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
144const 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
160const asRecord = (value: unknown): Record<string, unknown> =>
161 typeof value === "object" && value !== null && !Array.isArray(value)
162 ? (value as Record<string, unknown>)
163 : {};
164
165const 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
188const 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
208const resolveGoogleDiscoveryBaseUrl = (input: {
209 providerData: GoogleDiscoveryToolProviderData;
210}): string =>
211 new URL(
212 input.providerData.invocation.servicePath || "",
213 input.providerData.invocation.rootUrl,
214 ).toString();
215
216const 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
241const 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
286const 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
311const 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
320const 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
409const 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
446const 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
458const 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
471const 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
576const 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
594const 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
755export 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 @@
1import {
2 FetchHttpClient,
3 HttpClient,
4 type HttpClientResponse,
5 HttpClientRequest,
6} from "@effect/platform";
7import {
8 applyCookiePlacementsToHeaders,
9 applyHttpQueryPlacementsToUrl,
10 applyJsonBodyPlacements,
11 standardSchemaFromJsonSchema,
12 typeSignatureFromSchema,
13 toTool,
14 type HttpRequestPlacements,
15 type ToolMetadata,
16} from "@executor/codemode-core";
17import { sourceCoreEffectError } from "@executor/source-core";
18import * as Effect from "effect/Effect";
19import * as Layer from "effect/Layer";
20import * as Schema from "effect/Schema";
21
22import type {
23 GoogleDiscoveryManifestMethod,
24 GoogleDiscoverySchemaRefTable,
25 GoogleDiscoveryToolDefinition,
26} from "./types";
27import {
28 GoogleDiscoveryToolProviderDataSchema,
29} from "./types";
30import * as Either from "effect/Either";
31
32const decodeGoogleDiscoveryToolProviderDataJson = Schema.decodeUnknownEither(
33 GoogleDiscoveryToolProviderDataSchema,
34);
35
36export 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
80const decodeGoogleDiscoverySchemaRefTableJson = Schema.decodeUnknownEither(
81 Schema.parseJson(
82 Schema.Record({
83 key: Schema.String,
84 value: Schema.Unknown,
85 }),
86 ),
87);
88
89const asRecord = (value: unknown): Record<string, unknown> =>
90 typeof value === "object" && value !== null && !Array.isArray(value)
91 ? value as Record<string, unknown>
92 : {};
93
94const 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
107const 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
131const 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
175const 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
199const 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
222const 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
244const 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
256const 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
358export 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
375export type GoogleDiscoveryToolPresentation = {
376 inputTypePreview: string;
377 outputTypePreview: string;
378 inputSchema?: unknown;
379 outputSchema?: unknown;
380 providerData: typeof GoogleDiscoveryToolProviderDataSchema.Type;
381};
382
383export 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
434export 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
529export {
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 @@
1import { Schema } from "effect";
2
3export const GOOGLE_DISCOVERY_HTTP_METHODS = [
4 "get",
5 "put",
6 "post",
7 "delete",
8 "patch",
9 "head",
10 "options",
11] as const;
12
13export const GoogleDiscoveryHttpMethodSchema = Schema.Literal(
14 ...GOOGLE_DISCOVERY_HTTP_METHODS,
15);
16
17export const GoogleDiscoveryParameterLocationSchema = Schema.Literal(
18 "path",
19 "query",
20 "header",
21);
22
23export 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
34export 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
54export 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
66export 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
87export const GoogleDiscoverySchemaRefTableSchema = Schema.Record({
88 key: Schema.String,
89 value: Schema.String,
90});
91
92export 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
113export type GoogleDiscoveryHttpMethod = typeof GoogleDiscoveryHttpMethodSchema.Type;
114export type GoogleDiscoveryParameterLocation =
115 typeof GoogleDiscoveryParameterLocationSchema.Type;
116export type GoogleDiscoveryMethodParameter =
117 typeof GoogleDiscoveryMethodParameterSchema.Type;
118export type GoogleDiscoveryInvocationPayload =
119 typeof GoogleDiscoveryInvocationPayloadSchema.Type;
120export type GoogleDiscoveryToolProviderData =
121 typeof GoogleDiscoveryToolProviderDataSchema.Type;
122export type GoogleDiscoveryManifestMethod =
123 typeof GoogleDiscoveryManifestMethodSchema.Type;
124export type GoogleDiscoverySchemaRefTable =
125 typeof GoogleDiscoverySchemaRefTableSchema.Type;
126export type GoogleDiscoveryToolManifest =
127 typeof GoogleDiscoveryToolManifestSchema.Type;
128
129export type GoogleDiscoveryToolDefinition = GoogleDiscoveryManifestMethod;
plugins/google-discovery/shared/index.ts+155−0
@@ -0,0 +1,155 @@
1import * as Schema from "effect/Schema";
2
3import {
4 SecretRefSchema,
5 StringMapSchema,
6} from "@executor/platform-sdk/schema";
7
8export const GoogleDiscoveryOAuthClientAuthenticationSchema = Schema.Literal(
9 "none",
10 "client_secret_post",
11);
12
13export 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
35export 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
45export const GoogleDiscoverySourceConfigPayloadSchema =
46 GoogleDiscoveryConnectInputSchema;
47
48export const GoogleDiscoveryUpdateSourceInputSchema = Schema.Struct({
49 sourceId: Schema.String,
50 config: GoogleDiscoverySourceConfigPayloadSchema,
51});
52
53export 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
62export 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
74export const GoogleDiscoveryStartOAuthResultSchema = Schema.Struct({
75 sessionId: Schema.String,
76 authorizationUrl: Schema.String,
77 scopes: Schema.Array(Schema.String),
78});
79
80export 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
93const 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
106export 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
121export type GoogleDiscoveryOAuthClientAuthentication =
122 typeof GoogleDiscoveryOAuthClientAuthenticationSchema.Type;
123export type GoogleDiscoveryConnectionAuth =
124 typeof GoogleDiscoveryConnectionAuthSchema.Type;
125export type GoogleDiscoveryConnectInput =
126 typeof GoogleDiscoveryConnectInputSchema.Type;
127export type GoogleDiscoverySourceConfigPayload =
128 typeof GoogleDiscoverySourceConfigPayloadSchema.Type;
129export type GoogleDiscoveryUpdateSourceInput =
130 typeof GoogleDiscoveryUpdateSourceInputSchema.Type;
131export type GoogleDiscoveryStoredSourceData =
132 typeof GoogleDiscoveryStoredSourceDataSchema.Type;
133export type GoogleDiscoveryStartOAuthInput =
134 typeof GoogleDiscoveryStartOAuthInputSchema.Type;
135export type GoogleDiscoveryStartOAuthResult =
136 typeof GoogleDiscoveryStartOAuthResultSchema.Type;
137export type GoogleDiscoveryOAuthSession =
138 typeof GoogleDiscoveryOAuthSessionSchema.Type;
139export type GoogleDiscoveryOAuthPopupResult =
140 typeof GoogleDiscoveryOAuthPopupResultSchema.Type;
141
142export const defaultGoogleDiscoveryUrl = (
143 service: string,
144 version: string,
145): string =>
146 `https://www.googleapis.com/discovery/v1/apis/${encodeURIComponent(service)}/${encodeURIComponent(version)}/rest`;
147
148export 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 @@
1import {
2 HttpApi,
3 HttpApiBuilder,
4 HttpApiEndpoint,
5 HttpApiGroup,
6 HttpApiSchema,
7} from "@effect/platform";
8import {
9 ControlPlaneBadRequestError,
10 ControlPlaneForbiddenError,
11 ControlPlaneNotFoundError,
12 ControlPlaneStorageError,
13 type ExecutorHttpApiExtension,
14 type ExecutorHttpPlugin,
15} from "@executor/platform-api";
16import { resolveRequestedLocalWorkspace } from "@executor/platform-api/local-context";
17import {
18 ScopeIdSchema,
19 SourceIdSchema,
20 SourceSchema,
21 type Source,
22} from "@executor/platform-sdk/schema";
23import * as Effect from "effect/Effect";
24import * as Schema from "effect/Schema";
25
26import {
27 GraphqlConnectInputSchema,
28 GraphqlSourceConfigPayloadSchema,
29 type GraphqlConnectInput,
30 type GraphqlSourceConfigPayload,
31 type GraphqlUpdateSourceInput,
32} from "@executor/plugin-graphql-shared";
33
34type 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
51const workspaceIdParam = HttpApiSchema.param("workspaceId", ScopeIdSchema);
52const sourceIdParam = HttpApiSchema.param("sourceId", SourceIdSchema);
53
54export 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
90export const graphqlHttpApiExtension = {
91 key: "graphql",
92 group: GraphqlHttpGroup,
93} satisfies ExecutorHttpApiExtension<typeof GraphqlHttpGroup>;
94
95const GraphqlHttpApi = HttpApi.make("executor").add(GraphqlHttpGroup);
96
97const 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
104const 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
111const 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
118const 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
127export 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 @@
1import { startTransition, useMemo, useState, type ReactNode } from "react";
2import { useNavigate } from "@tanstack/react-router";
3import type {
4 Source,
5} from "@executor/react";
6import {
7 defineExecutorPluginHttpApiClient,
8 Result,
9 useAtomSet,
10 useAtomValue,
11 useExecutorMutation,
12 useLocalInstallation,
13 useSecrets,
14} from "@executor/react";
15
16import {
17 graphqlHttpApiExtension,
18} from "@executor/plugin-graphql-http";
19import {
20 type GraphqlConnectInput,
21 type GraphqlConnectionAuth,
22} from "@executor/plugin-graphql-shared";
23import { SourceToolExplorer } from "../../../apps/web/src/source-plugins/tool-explorer";
24import { IconPencil } from "../../../apps/web/src/components/icons";
25
26type 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
41type FrontendPluginRegisterApi = {
42 sources: {
43 registerType: (definition: FrontendSourceTypeDefinition) => void;
44 };
45};
46
47type RouteToolSearch = {
48 tab?: "model" | "discover";
49 tool?: string;
50 query?: string;
51};
52
53const getGraphqlHttpClient = defineExecutorPluginHttpApiClient<"GraphqlReactHttpClient">()(
54 "GraphqlReactHttpClient",
55 [graphqlHttpApiExtension] as const,
56);
57
58const defaultGraphqlInput = (): GraphqlConnectInput => ({
59 name: "My GraphQL Source",
60 endpoint: "https://example.com/graphql",
61 defaultHeaders: null,
62 auth: {
63 kind: "none",
64 },
65});
66
67const 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
86const 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
93const secretValue = (input: GraphqlConnectionAuth): string =>
94 input.kind === "bearer"
95 ? JSON.stringify(input.tokenSecretRef)
96 : "";
97
98const 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
118function 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
256function 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
300function 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
379function 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
504export 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 @@
1import { createCatalogSnapshotV1FromFragments } from "@executor/ir/catalog";
2import {
3 CapabilityIdSchema,
4 DocumentIdSchema,
5 ExecutableIdSchema,
6 ResponseSymbolIdSchema,
7 ScopeIdSchema,
8} from "@executor/ir/ids";
9import type {
10 Capability,
11 CatalogSnapshotV1,
12 ResponseSymbol,
13 Executable,
14} from "@executor/ir/model";
15import {
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
35import type { GraphqlToolProviderData } from "./provider-data";
36
37export type GraphqlCatalogOperationInput = BaseCatalogOperationInput & {
38 providerData: GraphqlToolProviderData;
39};
40
41const 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
81const 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
97const 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
115const 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
283export 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
306export 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 @@
1import {
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";
12import * as Either from "effect/Either";
13import * as Effect from "effect/Effect";
14
15import { GRAPHQL_INTROSPECTION_QUERY } from "./graphql-tools";
16
17const 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
26export 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 @@
1import {
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";
15import { sourceCoreEffectError } from "@executor/source-core";
16import {
17 GraphqlToolProviderDataSchema,
18 type GraphqlToolProviderData,
19} from "./provider-data";
20import * as Either from "effect/Either";
21import * as Effect from "effect/Effect";
22import * as Schema from "effect/Schema";
23import {
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";
44import * as HashMap from "effect/HashMap";
45import * as Option from "effect/Option";
46
47type JsonSchema = Record<string, unknown>;
48type GraphqlSchemaRefTable = Record<string, string>;
49
50type GraphqlOperationType = "query" | "mutation";
51
52const GRAPHQL_PRESENTATION_TYPE_MAX_LENGTH = 320;
53
54type 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
64export type GraphqlRequestToolManifestEntry = GraphqlManifestToolBase & {
65 kind: "request";
66};
67
68export 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
79export type GraphqlToolManifestEntry =
80 | GraphqlRequestToolManifestEntry
81 | GraphqlFieldToolManifestEntry;
82
83export 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
93export 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
107export type GraphqlToolPresentation = {
108 inputTypePreview: string;
109 outputTypePreview: string;
110 inputSchema?: JsonSchema;
111 outputSchema?: JsonSchema;
112 exampleInput?: unknown;
113 providerData: GraphqlToolProviderData;
114};
115
116type GraphqlToolPresentationResolver = {
117 resolve(definition: GraphqlToolDefinition): GraphqlToolPresentation;
118};
119
120type SelectedGraphqlOutput = {
121 selectionSet: string;
122 schema: JsonSchema;
123};
124
125type 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
137const 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
151const 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
165const 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
186const asToolPath = (value: string): ToolPath => value as ToolPath;
187
188const asRecord = (value: unknown): Record<string, unknown> =>
189 typeof value === "object" && value !== null && !Array.isArray(value)
190 ? (value as Record<string, unknown>)
191 : {};
192
193const asString = (value: unknown): string | null =>
194 typeof value === "string" && value.trim().length > 0 ? value : null;
195
196const 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
209const 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
226const 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
240const graphqlToolError = (message: string, cause?: unknown): Error =>
241 new Error(cause instanceof Error ? `${message}: ${cause.message}` : message);
242
243const 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
254const hasGraphqlErrors = (value: unknown): boolean =>
255 Array.isArray(asRecord(value).errors);
256
257const 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
266const 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
276const toPascalCase = (value: string): string => {
277 const camel = toCamelCase(value);
278 return `${camel[0]?.toUpperCase() ?? ""}${camel.slice(1)}`;
279};
280
281const toTitleCase = (value: string): string =>
282 splitWords(value)
283 .map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`)
284 .join(" ");
285
286const 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
294const withSchemaDefault = (schema: JsonSchema, value: unknown): JsonSchema =>
295 value !== undefined ? { ...schema, default: value } : schema;
296
297const 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
307const 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
339const 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
367const 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
385export const GRAPHQL_INTROSPECTION_QUERY = getIntrospectionQuery({
386 descriptions: true,
387 inputValueDeprecation: true,
388 schemaDescription: true,
389});
390
391const 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
423const 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
458const GRAPHQL_SCHEMA_REF_PREFIX = "#/$defs/graphql";
459
460const graphqlScalarRef = (name: string): string =>
461 `${GRAPHQL_SCHEMA_REF_PREFIX}/scalars/${name}`;
462
463const graphqlEnumRef = (name: string): string =>
464 `${GRAPHQL_SCHEMA_REF_PREFIX}/enums/${name}`;
465
466const graphqlInputObjectRef = (name: string): string =>
467 `${GRAPHQL_SCHEMA_REF_PREFIX}/input/${name}`;
468
469const graphqlOutputTypeRef = (name: string, depth: number): string =>
470 `${GRAPHQL_SCHEMA_REF_PREFIX}/output/${depth === 0 ? name : `${name}__depth${depth}`}`;
471
472const graphqlTypenameOnlyOutputRef = (): string =>
473 `${GRAPHQL_SCHEMA_REF_PREFIX}/output/GraphqlTypenameOnly`;
474
475const schemaRef = (ref: string): JsonSchema => ({
476 $ref: ref,
477});
478
479const typenameOnlyObjectSchema = (): JsonSchema => ({
480 type: "object",
481 properties: {
482 __typename: { type: "string" },
483 },
484 required: ["__typename"],
485 additionalProperties: false,
486});
487
488const 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
749const 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
794const 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
812const 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
828const 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
840const 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
878const 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
939const 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
961const 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
993const 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
1006const 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
1021type GraphqlFieldToolDraft = Omit<GraphqlFieldToolManifestEntry, "toolId"> & {
1022 toolId: string;
1023};
1024
1025const 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
1071const 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
1132export const createGraphqlSourceHash = (documentText: string): string =>
1133 sha256Hex(documentText);
1134
1135export 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
1174export 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
1194const 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
1224const 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
1251const graphqlToolPresentationResolverCache = new WeakMap<GraphqlToolManifest, GraphqlToolPresentationResolver>();
1252
1253const 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
1329export const buildGraphqlToolPresentation = (input: {
1330 manifest: GraphqlToolManifest;
1331 definition: GraphqlToolDefinition;
1332}): GraphqlToolPresentation => {
1333 return graphqlToolPresentationResolver(input.manifest).resolve(input.definition);
1334};
1335
1336const decodeGraphqlToolProviderData = Schema.decodeUnknownEither(
1337 GraphqlToolProviderDataSchema,
1338);
1339
1340export const decodeGraphqlSchemaRefTableJson = Schema.decodeUnknownEither(
1341 Schema.parseJson(
1342 Schema.Record({
1343 key: Schema.String,
1344 value: Schema.Unknown,
1345 }),
1346 ),
1347);
1348
1349const 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
1373const 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
1415export 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
1520export 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
1556const 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
1618const 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
1647const withoutUndefinedEntries = (
1648 record: Readonly<Record<string, unknown>>,
1649): Record<string, unknown> =>
1650 Object.fromEntries(
1651 Object.entries(record).filter(([, value]) => value !== undefined),
1652 );
1653
1654const 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
1696export 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 @@
1import {
2 applyCookiePlacementsToHeaders,
3 applyHttpQueryPlacementsToUrl,
4 applyJsonBodyPlacements,
5} from "@executor/codemode-core";
6import * as Effect from "effect/Effect";
7import * as Schema from "effect/Schema";
8
9import {
10 createCatalogImportMetadata,
11 createSourceCatalogSyncResult,
12 SourceCredentialRequiredError,
13} from "@executor/source-core";
14import type { Source } from "@executor/platform-sdk/schema";
15import type {
16 ExecutorSdkPlugin,
17 ExecutorSdkPluginHost,
18 ExecutorSourceConnector,
19 SourcePluginRuntime,
20} from "@executor/platform-sdk/plugins";
21import {
22 SecretMaterialResolverService,
23 provideExecutorRuntime,
24} from "@executor/platform-sdk/runtime";
25import {
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
35import {
36 createGraphqlCatalogFragment,
37 type GraphqlCatalogOperationInput,
38} from "./catalog";
39import {
40 compileGraphqlToolDefinitions,
41 extractGraphqlManifest,
42 GRAPHQL_INTROSPECTION_QUERY,
43 buildGraphqlToolPresentation,
44} from "./graphql-tools";
45import {
46 GraphqlToolProviderDataSchema,
47 type GraphqlToolProviderData,
48} from "./provider-data";
49
50const decodeStoredSourceData = Schema.decodeUnknownSync(GraphqlStoredSourceDataSchema);
51const decodeProviderData = Schema.decodeUnknownSync(GraphqlToolProviderDataSchema);
52
53export 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
69export 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
84const 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
92type GraphqlExecutorAddInput = typeof GraphqlExecutorAddInputSchema.Type;
93
94const asRecord = (value: unknown): Record<string, unknown> =>
95 typeof value === "object" && value !== null && !Array.isArray(value)
96 ? (value as Record<string, unknown>)
97 : {};
98
99const asString = (value: unknown): string | null =>
100 typeof value === "string" && value.trim().length > 0 ? value : null;
101
102const 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
109const withoutUndefinedEntries = (
110 record: Readonly<Record<string, unknown>>,
111): Record<string, unknown> =>
112 Object.fromEntries(
113 Object.entries(record).filter(([, value]) => value !== undefined),
114 );
115
116const 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
124const 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
135const 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
146const 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
161const 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
220const 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
240const storedSourceDataFromInput = (
241 input: GraphqlConnectInput,
242): GraphqlStoredSourceData =>
243 decodeStoredSourceData({
244 endpoint: input.endpoint.trim(),
245 defaultHeaders: input.defaultHeaders,
246 auth: input.auth,
247 });
248
249const 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
259const 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
357const 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
373const 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
543export 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 @@
1import * as Schema from "effect/Schema";
2
3export const GraphqlToolKindSchema = Schema.Literal("request", "field");
4
5export const GraphqlOperationTypeSchema = Schema.Literal("query", "mutation");
6
7export 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
23export type GraphqlToolKind = typeof GraphqlToolKindSchema.Type;
24export type GraphqlOperationType = typeof GraphqlOperationTypeSchema.Type;
25export 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 @@
1import * as Schema from "effect/Schema";
2
3import {
4 SecretRefSchema,
5 StringMapSchema,
6} from "@executor/platform-sdk/schema";
7import {
8 defaultNameFromEndpoint,
9 namespaceFromSourceName,
10} from "@executor/source-core";
11
12export 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
22export const GraphqlConnectInputSchema = Schema.Struct({
23 name: Schema.String,
24 endpoint: Schema.String,
25 defaultHeaders: Schema.NullOr(StringMapSchema),
26 auth: GraphqlConnectionAuthSchema,
27});
28
29export const GraphqlSourceConfigPayloadSchema = GraphqlConnectInputSchema;
30
31export const GraphqlUpdateSourceInputSchema = Schema.Struct({
32 sourceId: Schema.String,
33 config: GraphqlSourceConfigPayloadSchema,
34});
35
36export const GraphqlStoredSourceDataSchema = Schema.Struct({
37 endpoint: Schema.String,
38 defaultHeaders: Schema.NullOr(StringMapSchema),
39 auth: GraphqlConnectionAuthSchema,
40});
41
42export type GraphqlConnectionAuth = typeof GraphqlConnectionAuthSchema.Type;
43export type GraphqlConnectInput = typeof GraphqlConnectInputSchema.Type;
44export type GraphqlSourceConfigPayload =
45 typeof GraphqlSourceConfigPayloadSchema.Type;
46export type GraphqlStoredSourceData =
47 typeof GraphqlStoredSourceDataSchema.Type;
48export type GraphqlUpdateSourceInput =
49 typeof GraphqlUpdateSourceInputSchema.Type;
50
51export 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 @@
1import {
2 HttpApi,
3 HttpApiBuilder,
4 HttpApiEndpoint,
5 HttpApiGroup,
6 HttpApiSchema,
7 HttpServerResponse,
8} from "@effect/platform";
9import {
10 ControlPlaneBadRequestError,
11 ControlPlaneForbiddenError,
12 ControlPlaneNotFoundError,
13 ControlPlaneStorageError,
14 type ExecutorHttpApiExtension,
15 type ExecutorHttpPlugin,
16} from "@executor/platform-api";
17import { resolveRequestedLocalWorkspace } from "@executor/platform-api/local-context";
18import {
19 ScopeIdSchema,
20 SourceIdSchema,
21 SourceSchema,
22 type Source,
23} from "@executor/platform-sdk/schema";
24import * as Effect from "effect/Effect";
25import * as Schema from "effect/Schema";
26
27import {
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
41type 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
67const workspaceIdParam = HttpApiSchema.param("workspaceId", ScopeIdSchema);
68const sourceIdParam = HttpApiSchema.param("sourceId", SourceIdSchema);
69const htmlSchema = HttpApiSchema.Text({
70 contentType: "text/html",
71});
72
73const 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
80export 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
131export const mcpHttpApiExtension = {
132 key: "mcp",
133 group: McpHttpGroup,
134} satisfies ExecutorHttpApiExtension<typeof McpHttpGroup>;
135
136const McpHttpApi = HttpApi.make("executor").add(McpHttpGroup);
137
138const 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
145const 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
152const 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
161const escapeHtml = (value: string): string =>
162 value
163 .replaceAll("&", "&amp;")
164 .replaceAll("<", "&lt;")
165 .replaceAll(">", "&gt;")
166 .replaceAll('"', "&quot;")
167 .replaceAll("'", "&#39;");
168
169const 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
210export 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 @@
1import { startTransition, useMemo, useState, type ReactNode } from "react";
2import { useNavigate } from "@tanstack/react-router";
3import type { Source } from "@executor/react";
4import {
5 Result,
6 defineExecutorPluginHttpApiClient,
7 useAtomSet,
8 useAtomValue,
9 useExecutorMutation,
10 useLocalInstallation,
11} from "@executor/react";
12
13import {
14 mcpHttpApiExtension,
15} from "@executor/plugin-mcp-http";
16import {
17 type McpConnectInput,
18 type McpConnectionAuth,
19 type McpOAuthPopupResult,
20 type McpStartOAuthInput,
21} from "@executor/plugin-mcp-shared";
22import { IconPencil } from "../../../apps/web/src/components/icons";
23import { SourceToolExplorer } from "../../../apps/web/src/source-plugins/tool-explorer";
24import {
25 asMcpRemoteTransportValue,
26 defaultMcpRemoteTransportFields,
27 defaultMcpStdioTransportFields,
28 setMcpTransportFieldsTransport,
29 type McpTransportFields,
30 type McpTransportValue,
31} from "../../../apps/web/src/views/mcp-transport-state";
32import {
33 parseJsonStringArray,
34 parseJsonStringMap,
35} from "../../../apps/web/src/views/json-form";
36
37type 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
52type FrontendPluginRegisterApi = {
53 sources: {
54 registerType: (definition: FrontendSourceTypeDefinition) => void;
55 };
56};
57
58type RouteToolSearch = {
59 tab?: "model" | "discover";
60 tool?: string;
61 query?: string;
62};
63
64const OAUTH_STORAGE_PREFIX = "executor:mcp-oauth:";
65const OAUTH_TIMEOUT_MS = 2 * 60_000;
66
67const getMcpHttpClient = defineExecutorPluginHttpApiClient<"McpReactHttpClient">()(
68 "McpReactHttpClient",
69 [mcpHttpApiExtension] as const,
70);
71
72const 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
87const 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
94const stringifyStringArray = (
95 value: ReadonlyArray<string> | null | undefined,
96): string =>
97 !value || value.length === 0 ? "" : JSON.stringify(value, null, 2);
98
99const 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
115const 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
173const 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
197function 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
537function 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
581function 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
660function 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
779export 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 @@
1import { createCatalogSnapshotV1FromFragments } from "@executor/ir/catalog";
2import {
3 CapabilityIdSchema,
4 DocumentIdSchema,
5 ExecutableIdSchema,
6 ResponseSymbolIdSchema,
7 ScopeIdSchema,
8} from "@executor/ir/ids";
9import type {
10 Capability,
11 CatalogSnapshotV1,
12 ResponseSymbol,
13 Executable,
14} from "@executor/ir/model";
15import {
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
35import type {
36 McpServerMetadata,
37 McpToolAnnotations,
38 McpToolExecution,
39} from "./manifest";
40
41export 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
57const mcpResumeSupport = (
58 execution: McpCatalogOperationInput["providerData"]["execution"],
59): boolean =>
60 execution?.taskSupport === "optional" ||
61 execution?.taskSupport === "required";
62
63const 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
77const 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
252export 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
274export 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 @@
1import * as Data from "effect/Data";
2import * as Effect from "effect/Effect";
3import * as Exit from "effect/Exit";
4import * as Scope from "effect/Scope";
5
6import type { McpConnection, McpConnector } from "./tools";
7
8export class McpConnectionPoolError extends Data.TaggedError("McpConnectionPoolError")<{
9 readonly operation: "connect" | "close";
10 readonly message: string;
11 readonly cause: unknown;
12}> {}
13
14type PoolEntry = {
15 scope: Scope.CloseableScope;
16 connection: McpConnector;
17};
18
19const pooledRuns = new Map<string, Map<string, PoolEntry>>();
20
21const mcpConnectionPoolError = (input: {
22 operation: "connect" | "close";
23 message: string;
24 cause: unknown;
25}): McpConnectionPoolError => new McpConnectionPoolError(input);
26
27const 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
39const 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
52const 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
76const 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
105const closePoolEntry = (entry: PoolEntry): Effect.Effect<void, never, never> =>
106 Scope.close(entry.scope, Exit.void).pipe(Effect.ignore);
107
108export 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
131export 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
145export 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 @@
1import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
2import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
4import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
5import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
6import * as Data from "effect/Data";
7import * as Effect from "effect/Effect";
8
9import type { McpConnection, McpConnector } from "./tools";
10
11export type McpTransportPreference = "auto" | "streamable-http" | "sse" | "stdio";
12
13export 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
27export class McpConnectionError extends Data.TaggedError("McpConnectionError")<{
28 readonly transport: McpTransportPreference;
29 readonly message: string;
30 readonly cause: unknown;
31}> {}
32
33export 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
39const mcpConnectionError = (input: {
40 transport: McpTransportPreference;
41 message: string;
42 cause: unknown;
43}): McpConnectionError => new McpConnectionError(input);
44
45const 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
72type FetchInput = Parameters<typeof fetch>[0];
73type FetchInit = Parameters<typeof fetch>[1];
74
75const 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
92const connectionFromClient = (client: Client): McpConnection => ({
93 client,
94 close: () => client.close(),
95});
96
97const 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
108const 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
134export 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 @@
1import {
2 defaultNameFromEndpoint,
3 namespaceFromSourceName,
4 noneAuthInference,
5 supportedAuthInference,
6 type SourceDiscoveryProbeInput,
7 type SourceDiscoveryResult,
8} from "@executor/source-core";
9import { startMcpOAuthAuthorization } from "@executor/auth-mcp-oauth";
10import * as Either from "effect/Either";
11import * as Effect from "effect/Effect";
12
13import { createSdkMcpConnector } from "./connection";
14import { discoverMcpToolsFromConnector } from "./tools";
15
16export 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 @@
1import {
2 ElicitRequestSchema,
3 UrlElicitationRequiredError,
4} from "@modelcontextprotocol/sdk/types.js";
5import * as Either from "effect/Either";
6import * as Option from "effect/Option";
7import * as ParseResult from "effect/ParseResult";
8import * as Schema from "effect/Schema";
9
10import type {
11 ElicitationRequest,
12 ElicitationResponse,
13 ToolExecutionContext,
14 ToolPath,
15} from "@executor/codemode-core";
16
17const UnknownRecordSchema = Schema.Record({
18 key: Schema.String,
19 value: Schema.Unknown,
20});
21
22const decodeUnknownRecordOption = Schema.decodeUnknownOption(UnknownRecordSchema);
23
24export const readUnknownRecord = (input: unknown): Record<string, unknown> => {
25 const decoded = decodeUnknownRecordOption(input);
26 return Option.isSome(decoded) ? decoded.value : {};
27};
28
29const 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
38const decodeMcpElicitationParamsEither = Schema.decodeUnknownEither(
39 RawMcpElicitationParamsSchema,
40);
41
42export 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
69export 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
79type McpClientWithElicitationHandler = {
80 setRequestHandler: (
81 schema: typeof ElicitRequestSchema,
82 handler: (request: { params: unknown }) => Promise<unknown>,
83 ) => void;
84};
85
86export const hasElicitationRequestHandler = (
87 value: unknown,
88): value is McpClientWithElicitationHandler =>
89 typeof (value as { setRequestHandler?: unknown }).setRequestHandler === "function";
90
91export 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
114export 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 @@
1import type {
2 OAuthClientProvider,
3 OAuthDiscoveryState,
4} from "@modelcontextprotocol/sdk/client/auth.js";
5import type {
6 OAuthClientInformationMixed,
7 OAuthTokens,
8} from "@modelcontextprotocol/sdk/shared/auth.js";
9import type {
10 ToolExecutionContext,
11 ToolInput,
12 ToolPath,
13} from "@executor/codemode-core";
14import {
15 exchangeMcpOAuthAuthorizationCode,
16 startMcpOAuthAuthorization,
17} from "@executor/auth-mcp-oauth";
18import {
19 contentHash,
20 createCatalogImportMetadata,
21 createSourceCatalogSyncResult,
22} from "@executor/source-core";
23import type { Source } from "@executor/platform-sdk/schema";
24import type {
25 ExecutorSdkPlugin,
26 ExecutorSdkPluginHost,
27 ExecutorSourceConnector,
28 SourcePluginRuntime,
29} from "@executor/platform-sdk/plugins";
30import {
31 SecretMaterialDeleterService,
32 SecretMaterialResolverService,
33 SecretMaterialStorerService,
34 SecretMaterialUpdaterService,
35 provideExecutorRuntime,
36} from "@executor/platform-sdk/runtime";
37import {
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";
53import * as Effect from "effect/Effect";
54import * as Schema from "effect/Schema";
55
56import {
57 createMcpCatalogFragment,
58 type McpCatalogOperationInput,
59} from "./catalog";
60import {
61 createPooledMcpConnector,
62} from "./connection-pool";
63import {
64 createSdkMcpConnector,
65 isMcpStdioTransport,
66} from "./connection";
67import {
68 joinToolPath,
69 type McpServerMetadata,
70 type McpToolManifestEntry,
71} from "./manifest";
72import {
73 createMcpToolsFromManifest,
74 discoverMcpToolsFromConnector,
75} from "./tools";
76
77const decodeStoredSourceData = Schema.decodeUnknownSync(McpStoredSourceDataSchema);
78const decodeSession = Schema.decodeUnknownSync(McpOAuthSessionSchema);
79
80const 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
94type McpExecutableBinding = typeof McpExecutableBindingSchema.Type;
95
96const decodeProviderData = Schema.decodeUnknownSync(McpExecutableBindingSchema);
97
98export 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
114export 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
123export 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
147const 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
161type McpExecutorAddInput = typeof McpExecutorAddInputSchema.Type;
162
163export type McpSdkPluginOptions = {
164 storage: McpSourceStorage;
165 oauthSessions: McpOAuthSessionStorage;
166};
167
168const asRecord = (value: unknown): Record<string, unknown> =>
169 typeof value === "object" && value !== null && !Array.isArray(value)
170 ? (value as Record<string, unknown>)
171 : {};
172
173const 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
182const 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
202const 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
216const 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
290const 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
306const 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
416const 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
431const 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
462const 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
467const 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
477const 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
657const 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
682const mcpDocumentKey = (stored: McpStoredSourceData): string =>
683 stored.endpoint
684 ?? (stored.command
685 ? `stdio://${stored.command}`
686 : "mcp");
687
688const 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
895export 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 @@
1import * as Option from "effect/Option";
2import * as Schema from "effect/Schema";
3
4import type { ToolPath } from "@executor/codemode-core";
5
6export type McpToolAnnotations = {
7 title: string | null;
8 readOnlyHint: boolean | null;
9 destructiveHint: boolean | null;
10 idempotentHint: boolean | null;
11 openWorldHint: boolean | null;
12};
13
14export type McpToolExecution = {
15 taskSupport: "forbidden" | "optional" | "required" | null;
16};
17
18export 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
27export 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
48export type McpServerMetadata = {
49 info: McpServerInfo | null;
50 capabilities: McpServerCapabilities | null;
51 instructions: string | null;
52 rawInfo: unknown;
53 rawCapabilities: unknown;
54};
55
56export type McpListToolsMetadata = {
57 nextCursor: string | null;
58 meta: unknown;
59 rawResult: unknown;
60};
61
62export 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
77export type McpToolManifest = {
78 version: 2;
79 server?: McpServerMetadata | null;
80 listTools?: McpListToolsMetadata | null;
81 tools: readonly McpToolManifestEntry[];
82};
83
84const 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
94const 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
102const asObject = (value: unknown): Record<string, unknown> =>
103 value !== null && typeof value === "object" && !Array.isArray(value)
104 ? (value as Record<string, unknown>)
105 : {};
106
107const asOptionalObject = (value: unknown): Record<string, unknown> | null => {
108 const objectValue = asObject(value);
109 return Object.keys(objectValue).length > 0 ? objectValue : null;
110};
111
112const hasOwn = (value: Record<string, unknown>, key: string): boolean =>
113 Object.prototype.hasOwnProperty.call(value, key);
114
115const asString = (value: unknown): string | null =>
116 typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
117
118const asBoolean = (value: unknown): boolean | null =>
119 typeof value === "boolean" ? value : null;
120
121const asUnknownArray = (value: unknown): unknown[] | null =>
122 Array.isArray(value) ? value : null;
123
124const 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
132const ToolExecutionSchema = Schema.Struct({
133 taskSupport: Schema.optional(
134 Schema.Literal("forbidden", "optional", "required"),
135 ),
136});
137
138const 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
151const ListToolsResultSchema = Schema.Struct({
152 tools: Schema.Array(ListedMcpToolSchema),
153 nextCursor: Schema.optional(Schema.NullOr(Schema.String)),
154 _meta: Schema.optional(Schema.Unknown),
155});
156
157const decodeListToolsResultOption = Schema.decodeUnknownOption(ListToolsResultSchema);
158
159const 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
168const 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
187const 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
207const 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
225const 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
275const 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
305export 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
357export 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 @@
1import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
2import * as Data from "effect/Data";
3import * as Either from "effect/Either";
4import * as Effect from "effect/Effect";
5import * as Cause from "effect/Cause";
6import * as Exit from "effect/Exit";
7import * as PartitionedSemaphore from "effect/PartitionedSemaphore";
8
9import {
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
22import {
23 createInteractionId,
24 hasElicitationRequestHandler,
25 isUrlElicitationRequiredError,
26 readMcpElicitationRequest,
27 readUnknownRecord,
28 toMcpElicitationResponse,
29} from "./elicitation-bridge";
30import {
31 extractMcpToolManifestFromListToolsResult,
32 joinToolPath,
33 type McpToolManifest,
34 type McpToolManifestEntry,
35} from "./manifest";
36
37export type { McpToolManifest, McpToolManifestEntry };
38export { extractMcpToolManifestFromListToolsResult };
39
40export 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
51export type McpConnection = {
52 client: McpClientLike;
53 close?: () => Promise<void>;
54};
55
56export type McpConnector = Effect.Effect<McpConnection, unknown, never>;
57
58export 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
67type McpDiscoveryStage = "connect" | "list_tools" | "call_tool";
68
69export class McpToolsError extends Data.TaggedError("McpToolsError")<{
70 stage: McpDiscoveryStage;
71 message: string;
72 details: string | null;
73}> {}
74
75const EXECUTION_SUSPENDED_SENTINEL = "__EXECUTION_SUSPENDED__";
76
77const 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
93const isExecutionSuspendedCause = (cause: unknown): boolean =>
94 causeText(cause).includes(EXECUTION_SUSPENDED_SENTINEL);
95
96const toDetails = (cause: unknown): string =>
97 cause instanceof Error ? cause.message : String(cause);
98
99const 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
114const 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
124const 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
135const elicitationClientSemaphore = PartitionedSemaphore.makeUnsafe<McpClientLike>({
136 permits: 1,
137});
138
139const withElicitationClientLock = <A, E>(
140 client: McpClientLike,
141 effect: Effect.Effect<A, E>,
142): Effect.Effect<A, E> => elicitationClientSemaphore.withPermits(client, 1)(effect);
143
144const 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
173const 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
247const 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
289const 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
303const 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
316const 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
378const 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
444export const createMcpConnectorFromClient = (
445 client: McpClientLike,
446): McpConnector =>
447 Effect.succeed({
448 client,
449 close: async () => undefined,
450 });
451
452export 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
518export 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
572export 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 @@
1import * as Schema from "effect/Schema";
2
3import {
4 SecretRefSchema,
5} from "@executor/platform-sdk/schema";
6import {
7 JsonObjectSchema,
8 SourceTransportSchema,
9 StringArraySchema,
10 StringMapSchema,
11 defaultNameFromEndpoint,
12 namespaceFromSourceName,
13} from "@executor/source-core";
14
15export 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
35export 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
48export const McpSourceConfigPayloadSchema = McpConnectInputSchema;
49
50export const McpUpdateSourceInputSchema = Schema.Struct({
51 sourceId: Schema.String,
52 config: McpSourceConfigPayloadSchema,
53});
54
55export 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
67export const McpStartOAuthInputSchema = Schema.Struct({
68 endpoint: Schema.String,
69 queryParams: Schema.NullOr(StringMapSchema),
70 redirectUrl: Schema.String,
71});
72
73export const McpStartOAuthResultSchema = Schema.Struct({
74 sessionId: Schema.String,
75 authorizationUrl: Schema.String,
76});
77
78export 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
89const 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
104export 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
119export type McpConnectionAuth = typeof McpConnectionAuthSchema.Type;
120export type McpConnectInput = typeof McpConnectInputSchema.Type;
121export type McpSourceConfigPayload = typeof McpSourceConfigPayloadSchema.Type;
122export type McpUpdateSourceInput = typeof McpUpdateSourceInputSchema.Type;
123export type McpStoredSourceData = typeof McpStoredSourceDataSchema.Type;
124export type McpStartOAuthInput = typeof McpStartOAuthInputSchema.Type;
125export type McpStartOAuthResult = typeof McpStartOAuthResultSchema.Type;
126export type McpOAuthSession = typeof McpOAuthSessionSchema.Type;
127export type McpOAuthPopupResult = typeof McpOAuthPopupResultSchema.Type;
128
129export 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
154export 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
9import type { Source } from "@executor/platform-sdk/schema";9import type { Source } from "@executor/platform-sdk/schema";
10import type {10import type {
11 ExecutorSdkPlugin,11 ExecutorSdkPlugin,
12 ExecutorSdkPluginHost,
13 ExecutorSourceConnector,
12 SourcePluginRuntime,14 SourcePluginRuntime,
13} from "@executor/platform-sdk/plugins";15} from "@executor/platform-sdk/plugins";
14import {16import {
17 OpenApiConnectionAuthSchema,
15 deriveOpenApiNamespace,18 deriveOpenApiNamespace,
16 previewOpenApiDocument,19 previewOpenApiDocument,
17 type OpenApiConnectInput,20 type OpenApiConnectInput,
@@ -90,6 +93,16 @@ export type OpenApiSdkPluginOptions = { · 72 unmodified lines
90 secrets?: OpenApiSecrets;93 secrets?: OpenApiSecrets;
91};94};
92 95
96const 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
104type OpenApiExecutorAddInput = typeof OpenApiExecutorAddInputSchema.Type;
105
93const createStoredSourceData = (106const createStoredSourceData = (
94 input: OpenApiConnectInput,107 input: OpenApiConnectInput,
95): OpenApiStoredSourceData => ({108): OpenApiStoredSourceData => ({
@@ -249,6 +262,117 @@ const responseHeadersRecord = (response: Response): Record<string, string> => { · 153 unmodified lines
249 return headers;262 return headers;
250};263};
251 264
265const 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
361const 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
252const decodeResponseBody = async (response: Response): Promise<unknown> => {376const decodeResponseBody = async (response: Response): Promise<unknown> => {
253 if (response.status === 204) {377 if (response.status === 204) {
254 return null;378 return null;
@@ -541,102 +665,29 @@ export const openApiSdkPlugin = ( · 286 unmodified lines
541): ExecutorSdkPlugin<"openapi", OpenApiSdk> => ({665): ExecutorSdkPlugin<"openapi", OpenApiSdk> => ({
542 key: "openapi",666 key: "openapi",
543 sources: [createOpenApiSourceRuntime(options)],667 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 {
545 previewDocument: (input) =>677 previewDocument: (input) =>
546 Effect.tryPromise({678 Effect.tryPromise({
547 try: () => previewOpenApiDocument(input),679 try: () => previewOpenApiDocument(input),
548 catch: (cause) =>680 catch: (cause) =>
549 cause instanceof Error ? cause : new Error(String(cause)),681 cause instanceof Error ? cause : new Error(String(cause)),
550 }),682 }),
551 getSourceConfig: (sourceId) =>683 getSourceConfig: (sourceId) =>
552 Effect.gen(function* () {684 provideRuntime(sourceSdk.getSourceConfig(sourceId)),
553 const source = yield* host.sources.get(sourceId);685 createSource: (input) =>
554 if (source.kind !== "openapi") {686 provideRuntime(sourceSdk.createSource(input)),
555 return yield* Effect.fail(687 updateSource: (input) =>
556 new Error(`Source ${sourceId} is not an OpenAPI source.`),688 provideRuntime(sourceSdk.updateSource(input)),
557 );689 removeSource: (sourceId) =>
558 }690 provideRuntime(sourceSdk.removeSource(sourceId)),
559 691 };
560 const stored = yield* options.storage.get({692 },
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 }),
642});693});

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