Unified · Split
| @@ -1,8 +1,8 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "sources": { |
| 3 | 3 | "vercel-api": { |
| 4 | − "kind": "openapi", | |
| 5 | − "name": "Vercel API" | |
| 4 | + "name": "Vercel API", | |
| 5 | + "kind": "openapi" | |
| 6 | 6 | } |
| 7 | 7 | } |
| 8 | 8 | } |
| @@ -229,24 +229,33 @@ Examples: · 228 unmodified lines | ||
| 229 | 229 | |
| 230 | 230 | ```ts |
| 231 | 231 | return await tools.executor.sources.add({ |
| 232 | − endpoint: "https://example.com/mcp", | |
| 232 | + kind: "mcp", | |
| 233 | 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 | 246 | ```ts |
| 239 | 247 | return await tools.executor.sources.add({ |
| 240 | 248 | kind: "openapi", |
| 241 | − endpoint: "https://api.github.com", | |
| 249 | + name: "GitHub", | |
| 242 | 250 | specUrl: |
| 243 | 251 | "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json", |
| 244 | − name: "GitHub", | |
| 245 | − namespace: "github", | |
| 252 | + baseUrl: null, | |
| 253 | + auth: { kind: "none" }, | |
| 246 | 254 | }); |
| 247 | 255 | ``` |
| 248 | 256 | |
| 249 | −For HTTP-style sources, `executor` can drive the credential flow for you. | |
| 257 | +`executor.sources.add(...)` accepts the final plugin-specific source config. | |
| 258 | +Browser OAuth and popup-driven auth flows live on the plugin-owned HTTP and web surfaces. | |
| 250 | 259 | |
| 251 | 260 | ## How execution works |
| 252 | 261 | |
| @@ -38,6 +38,9 @@ · 37 unmodified lines | ||
| 38 | 38 | "@effect/platform-node": "catalog:", |
| 39 | 39 | "@executor/codemode-core": "workspace:*", |
| 40 | 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 | 44 | "@executor/plugin-openapi-sdk": "workspace:*", |
| 42 | 45 | "@executor/platform-api": "workspace:*", |
| 43 | 46 | "@executor/platform-internal": "workspace:*", |
| @@ -14,12 +14,21 @@ import { · 13 unmodified lines | ||
| 14 | 14 | type ExecutorApiEffectClient as ExecutorApiClient, |
| 15 | 15 | } from "@executor/platform-api/effect"; |
| 16 | 16 | import { runExecutorMcpStdioServer } from "@executor/executor-mcp"; |
| 17 | +import { | |
| 18 | + googleDiscoverySdkPlugin, | |
| 19 | +} from "@executor/plugin-google-discovery-sdk"; | |
| 20 | +import { | |
| 21 | + graphqlSdkPlugin, | |
| 22 | +} from "@executor/plugin-graphql-sdk"; | |
| 23 | +import { | |
| 24 | + mcpSdkPlugin, | |
| 25 | +} from "@executor/plugin-mcp-sdk"; | |
| 17 | 26 | import { |
| 18 | 27 | openApiSdkPlugin, |
| 19 | 28 | } from "@executor/plugin-openapi-sdk"; |
| 20 | 29 | import { createWorkspaceExecutorAdminToolMap } from "@executor/platform-internal"; |
| 21 | 30 | import { |
| 22 | − EXECUTOR_SOURCES_ADD_HELP_LINES, | |
| 31 | + getExecutorSourcesAddHelpLines, | |
| 23 | 32 | RuntimeExecutionResolverService, |
| 24 | 33 | } from "@executor/platform-sdk/runtime"; |
| 25 | 34 | import { |
| @@ -40,6 +49,11 @@ import * as Option from "effect/Option"; · 14 unmodified lines | ||
| 40 | 49 | import * as Cause from "effect/Cause"; |
| 41 | 50 | |
| 42 | 51 | import { |
| 52 | + createFileGoogleDiscoveryOAuthSessionStorage, | |
| 53 | + createFileGoogleDiscoverySourceStorage, | |
| 54 | + createFileGraphqlSourceStorage, | |
| 55 | + createFileMcpOAuthSessionStorage, | |
| 56 | + createFileMcpSourceStorage, | |
| 43 | 57 | createFileOpenApiSourceStorage, |
| 44 | 58 | createLocalExecutorServer, |
| 45 | 59 | DEFAULT_SERVER_BASE_URL, |
| @@ -216,13 +230,13 @@ const buildWorkflowText = (namespaces: readonly string[] = []): string => · 170 unmodified lines | ||
| 216 | 230 | '1) const matches = await tools.discover({ query: "<intent>", limit: 12 });', |
| 217 | 231 | "2) const details = await tools.describe.tool({ path, includeSchemas: true });", |
| 218 | 232 | "3) Call selected tools.<path>(input).", |
| 219 | − "4) Source plugins are not registered in this build.", | |
| 220 | − ...EXECUTOR_SOURCES_ADD_HELP_LINES, | |
| 233 | + "4) Use source plugins to inspect or add API sources.", | |
| 234 | + ...getExecutorSourcesAddHelpLines(), | |
| 221 | 235 | "5) If execution pauses for interaction, resume it with `executor resume --execution-id ...`.", |
| 222 | 236 | "Do not use fetch; use tools.* only.", |
| 223 | 237 | ].join("\n"); |
| 224 | 238 | |
| 225 | −const DEFAULT_RUN_WORKFLOW = buildWorkflowText(); | |
| 239 | +const getDefaultRunWorkflow = () => buildWorkflowText(); | |
| 226 | 240 | |
| 227 | 241 | const indentBlock = (value: string, prefix: string = " "): string => |
| 228 | 242 | value |
| @@ -267,7 +281,7 @@ const buildRunWorkflowText = ( · 38 unmodified lines | ||
| 267 | 281 | catalog?: ToolCatalog, |
| 268 | 282 | ): Effect.Effect<string, Error, never> => { |
| 269 | 283 | if (!catalog) { |
| 270 | − return Effect.succeed(DEFAULT_RUN_WORKFLOW); | |
| 284 | + return Effect.succeed(getDefaultRunWorkflow()); | |
| 271 | 285 | } |
| 272 | 286 | |
| 273 | 287 | return catalog.listNamespaces({ limit: 200 }).pipe( |
| @@ -289,6 +303,27 @@ const loadRunWorkflowText = (): Effect.Effect<string, Error, never> => · 15 unmodified lines | ||
| 289 | 303 | localDataDir: DEFAULT_LOCAL_DATA_DIR, |
| 290 | 304 | }), |
| 291 | 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 | 327 | openApiSdkPlugin({ |
| 293 | 328 | storage: createFileOpenApiSourceStorage({ |
| 294 | 329 | rootDir: `${DEFAULT_LOCAL_DATA_DIR}/plugins/openapi/sources`, |
| @@ -318,7 +353,7 @@ const loadRunWorkflowText = (): Effect.Effect<string, Error, never> => · 23 unmodified lines | ||
| 318 | 353 | Effect.catchAllCause((cause) => |
| 319 | 354 | Effect.succeed( |
| 320 | 355 | [ |
| 321 | − DEFAULT_RUN_WORKFLOW, | |
| 356 | + getDefaultRunWorkflow(), | |
| 322 | 357 | "", |
| 323 | 358 | formatCatalogUnavailableMessage(cause), |
| 324 | 359 | ].join("\n"), |
| @@ -9,6 +9,9 @@ · 8 unmodified lines | ||
| 9 | 9 | "start": "NODE_ENV=production bun src/server.ts" |
| 10 | 10 | }, |
| 11 | 11 | "dependencies": { |
| 12 | + "@executor/plugin-google-discovery-react": "workspace:*", | |
| 13 | + "@executor/plugin-graphql-react": "workspace:*", | |
| 14 | + "@executor/plugin-mcp-react": "workspace:*", | |
| 12 | 15 | "@executor/plugin-openapi-react": "workspace:*", |
| 13 | 16 | "@executor/react": "workspace:*", |
| 14 | 17 | "@executor/server": "workspace:*", |
| @@ -1,3 +1,12 @@ | ||
| 1 | +import { | |
| 2 | + GoogleDiscoveryReactPlugin, | |
| 3 | +} from "@executor/plugin-google-discovery-react"; | |
| 4 | +import { | |
| 5 | + GraphqlReactPlugin, | |
| 6 | +} from "@executor/plugin-graphql-react"; | |
| 7 | +import { | |
| 8 | + McpReactPlugin, | |
| 9 | +} from "@executor/plugin-mcp-react"; | |
| 1 | 10 | import { |
| 2 | 11 | OpenApiReactPlugin, |
| 3 | 12 | } from "@executor/plugin-openapi-react"; |
| @@ -7,6 +16,9 @@ import type { · 3 unmodified lines | ||
| 7 | 16 | } from "./types"; |
| 8 | 17 | |
| 9 | 18 | const frontendPlugins = [ |
| 19 | + McpReactPlugin, | |
| 20 | + GraphqlReactPlugin, | |
| 21 | + GoogleDiscoveryReactPlugin, | |
| 10 | 22 | OpenApiReactPlugin, |
| 11 | 23 | ] as const satisfies readonly ExecutorFrontendPlugin[]; |
| 12 | 24 | |
| @@ -1,9 +1,11 @@ | ||
| 1 | +import { useEffect, useState } from "react"; | |
| 1 | 2 | import { useSource } from "@executor/react"; |
| 2 | 3 | import { LoadableBlock } from "../components/loadable"; |
| 3 | 4 | import { SourcePluginsResetState } from "../components/source-plugins-reset-state"; |
| 4 | 5 | import { |
| 5 | 6 | getDefaultSourceFrontendType, |
| 6 | 7 | getSourceFrontendType, |
| 8 | + registeredSourceFrontendTypes, | |
| 7 | 9 | } from "./index"; |
| 8 | 10 | |
| 9 | 11 | export type SourcePluginRouteSearch = { |
| @@ -12,38 +14,73 @@ export type SourcePluginRouteSearch = { · 2 unmodified lines | ||
| 12 | 14 | query?: string; |
| 13 | 15 | }; |
| 14 | 16 | |
| 15 | −export function SourcePluginAddPage() { | |
| 16 | − const definition = getDefaultSourceFrontendType(); | |
| 17 | − if (definition === null) { | |
| 18 | − return ( | |
| 19 | − <SourcePluginsResetState | |
| 20 | − title="Add Source is intentionally blank" | |
| 21 | − message="No source plugins are registered in this build." | |
| 22 | − /> | |
| 23 | − ); | |
| 17 | +const SourcePluginPicker = (props: { | |
| 18 | + selectedKind: string | null; | |
| 19 | + onSelect: (kind: string) => void; | |
| 20 | +}) => { | |
| 21 | + if (registeredSourceFrontendTypes.length <= 1) { | |
| 22 | + return null; | |
| 24 | 23 | } |
| 25 | 24 | |
| 26 | 25 | return ( |
| 27 | − <div className="mx-auto max-w-4xl px-6 py-10 lg:px-10 lg:py-14"> | |
| 28 | − <div className="mb-8"> | |
| 29 | − <div className="inline-flex rounded-full border border-border bg-muted px-3 py-1 text-[11px] font-medium uppercase tracking-[0.2em] text-muted-foreground"> | |
| 30 | − Source Plugin | |
| 31 | − </div> | |
| 32 | − <h1 className="mt-5 font-display text-3xl tracking-tight text-foreground lg:text-4xl"> | |
| 26 | + <div className="mb-8 flex flex-wrap gap-2"> | |
| 27 | + {registeredSourceFrontendTypes.map((definition) => ( | |
| 28 | + <button | |
| 29 | + key={definition.kind} | |
| 30 | + type="button" | |
| 31 | + onClick={() => props.onSelect(definition.kind)} | |
| 32 | + className={ | |
| 33 | + props.selectedKind === definition.kind | |
| 34 | + ? "rounded-full border border-primary/30 bg-primary/10 px-3 py-1.5 text-xs font-medium text-foreground" | |
| 35 | + : "rounded-full border border-border bg-muted px-3 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground" | |
| 36 | + } | |
| 37 | + > | |
| 33 | 38 | {definition.displayName} |
| 34 | − </h1> | |
| 35 | − </div> | |
| 36 | − {definition.renderAddPage()} | |
| 39 | + </button> | |
| 40 | + ))} | |
| 37 | 41 | </div> |
| 38 | 42 | ); |
| 39 | −} | |
| 43 | +}; | |
| 44 | + | |
| 45 | +const useSelectedSourceFrontendType = () => { | |
| 46 | + const defaultDefinition = getDefaultSourceFrontendType(); | |
| 47 | + const [selectedKind, setSelectedKind] = useState<string | null>( | |
| 48 | + defaultDefinition?.kind ?? null, | |
| 49 | + ); | |
| 50 | + | |
| 51 | + useEffect(() => { | |
| 52 | + if (defaultDefinition === null) { | |
| 53 | + setSelectedKind(null); | |
| 54 | + return; | |
| 55 | + } | |
| 56 | + | |
| 57 | + if ( | |
| 58 | + selectedKind === null | |
| 59 | + || getSourceFrontendType(selectedKind) === null | |
| 60 | + ) { | |
| 61 | + setSelectedKind(defaultDefinition.kind); | |
| 62 | + } | |
| 63 | + }, [defaultDefinition, selectedKind]); | |
| 64 | + | |
| 65 | + return { | |
| 66 | + selectedKind, | |
| 67 | + setSelectedKind, | |
| 68 | + definition: | |
| 69 | + (selectedKind ? getSourceFrontendType(selectedKind) : null) | |
| 70 | + ?? defaultDefinition, | |
| 71 | + }; | |
| 72 | +}; | |
| 73 | + | |
| 74 | +const SourcePluginAddLayout = (props: { | |
| 75 | + compact?: boolean; | |
| 76 | +}) => { | |
| 77 | + const { definition, selectedKind, setSelectedKind } = | |
| 78 | + useSelectedSourceFrontendType(); | |
| 40 | 79 | |
| 41 | −export function SourcePluginCreatePage() { | |
| 42 | − const definition = getDefaultSourceFrontendType(); | |
| 43 | 80 | if (definition === null) { |
| 44 | 81 | return ( |
| 45 | 82 | <SourcePluginsResetState |
| 46 | − title="New source creation is disabled" | |
| 83 | + title="Source plugins are unavailable" | |
| 47 | 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 | 89 | return ( |
| 53 | 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 | 106 | </div> |
| 56 | 107 | ); |
| 108 | +}; | |
| 109 | + | |
| 110 | +export function SourcePluginAddPage() { | |
| 111 | + return <SourcePluginAddLayout />; | |
| 112 | +} | |
| 113 | + | |
| 114 | +export function SourcePluginCreatePage() { | |
| 115 | + return <SourcePluginAddLayout compact />; | |
| 57 | 116 | } |
| 58 | 117 | |
| 59 | 118 | export function SourcePluginEditPage(input: { |
| @@ -0,0 +1,473 @@ | ||
| 1 | +import { startTransition, useEffect, useState, type ReactNode } from "react"; | |
| 2 | +import type { | |
| 3 | + Loadable, | |
| 4 | + SourceInspection, | |
| 5 | + SourceInspectionToolDetail, | |
| 6 | +} from "@executor/react"; | |
| 7 | +import { | |
| 8 | + useSourceDiscovery, | |
| 9 | + useSourceInspection, | |
| 10 | + useSourceToolDetail, | |
| 11 | +} from "@executor/react"; | |
| 12 | +import { DocumentPanel } from "../components/document-panel"; | |
| 13 | +import { | |
| 14 | + EmptyState, | |
| 15 | + LoadableBlock, | |
| 16 | +} from "../components/loadable"; | |
| 17 | +import { Markdown } from "../components/markdown"; | |
| 18 | +import { | |
| 19 | + IconCheck, | |
| 20 | + IconCopy, | |
| 21 | + IconPencil, | |
| 22 | + IconSearch, | |
| 23 | + IconTool, | |
| 24 | +} from "../components/icons"; | |
| 25 | +import { Badge, MethodBadge } from "../components/ui/badge"; | |
| 26 | +import { cn } from "../lib/utils"; | |
| 27 | + | |
| 28 | +export type SourceToolExplorerSearch = { | |
| 29 | + tab?: "model" | "discover"; | |
| 30 | + tool?: string; | |
| 31 | + query?: string; | |
| 32 | +}; | |
| 33 | + | |
| 34 | +const highlight = (text: string, query: string) => { | |
| 35 | + const trimmed = query.trim().toLowerCase(); | |
| 36 | + if (!trimmed) { | |
| 37 | + return text; | |
| 38 | + } | |
| 39 | + | |
| 40 | + const index = text.toLowerCase().indexOf(trimmed); | |
| 41 | + if (index === -1) { | |
| 42 | + return text; | |
| 43 | + } | |
| 44 | + | |
| 45 | + return ( | |
| 46 | + <> | |
| 47 | + <span>{text.slice(0, index)}</span> | |
| 48 | + <mark className="rounded-sm bg-primary/20 px-px text-foreground"> | |
| 49 | + {text.slice(index, index + trimmed.length)} | |
| 50 | + </mark> | |
| 51 | + <span>{text.slice(index + trimmed.length)}</span> | |
| 52 | + </> | |
| 53 | + ); | |
| 54 | +}; | |
| 55 | + | |
| 56 | +const ToolDetailPanel = (props: { | |
| 57 | + detail: SourceInspectionToolDetail; | |
| 58 | +}) => { | |
| 59 | + const [copied, setCopied] = useState<string | null>(null); | |
| 60 | + const copy = async (value: string, key: string) => { | |
| 61 | + await navigator.clipboard.writeText(value); | |
| 62 | + setCopied(key); | |
| 63 | + window.setTimeout(() => setCopied(null), 1200); | |
| 64 | + }; | |
| 65 | + | |
| 66 | + return ( | |
| 67 | + <div className="space-y-4"> | |
| 68 | + <div className="flex items-start justify-between gap-3 rounded-xl border border-border/70 bg-card/60 p-4"> | |
| 69 | + <div className="min-w-0"> | |
| 70 | + <div className="flex items-center gap-2"> | |
| 71 | + <h3 className="truncate font-mono text-sm font-medium text-foreground"> | |
| 72 | + {props.detail.summary.path} | |
| 73 | + </h3> | |
| 74 | + <button | |
| 75 | + type="button" | |
| 76 | + onClick={() => { | |
| 77 | + void copy(props.detail.summary.path, "path"); | |
| 78 | + }} | |
| 79 | + className="rounded p-1 text-muted-foreground/50 transition-colors hover:text-foreground" | |
| 80 | + > | |
| 81 | + {copied === "path" ? <IconCheck /> : <IconCopy />} | |
| 82 | + </button> | |
| 83 | + </div> | |
| 84 | + <div className="mt-2 flex flex-wrap items-center gap-2"> | |
| 85 | + {props.detail.summary.method && ( | |
| 86 | + <MethodBadge method={props.detail.summary.method} /> | |
| 87 | + )} | |
| 88 | + {props.detail.summary.group && ( | |
| 89 | + <Badge variant="outline">{props.detail.summary.group}</Badge> | |
| 90 | + )} | |
| 91 | + {props.detail.summary.protocol && ( | |
| 92 | + <Badge variant="muted">{props.detail.summary.protocol}</Badge> | |
| 93 | + )} | |
| 94 | + </div> | |
| 95 | + </div> | |
| 96 | + </div> | |
| 97 | + | |
| 98 | + {props.detail.summary.description && ( | |
| 99 | + <div className="rounded-xl border border-border/70 bg-card/40 p-4"> | |
| 100 | + <Markdown>{props.detail.summary.description}</Markdown> | |
| 101 | + </div> | |
| 102 | + )} | |
| 103 | + | |
| 104 | + <div className="grid gap-4 xl:grid-cols-2"> | |
| 105 | + <DocumentPanel | |
| 106 | + title="Input" | |
| 107 | + body={ | |
| 108 | + props.detail.contract.input.typeDeclaration | |
| 109 | + ?? props.detail.contract.input.typePreview | |
| 110 | + ?? null | |
| 111 | + } | |
| 112 | + lang="typescript" | |
| 113 | + empty="No input." | |
| 114 | + /> | |
| 115 | + <DocumentPanel | |
| 116 | + title="Output" | |
| 117 | + body={ | |
| 118 | + props.detail.contract.output.typeDeclaration | |
| 119 | + ?? props.detail.contract.output.typePreview | |
| 120 | + ?? null | |
| 121 | + } | |
| 122 | + lang="typescript" | |
| 123 | + empty="No output." | |
| 124 | + /> | |
| 125 | + </div> | |
| 126 | + | |
| 127 | + <DocumentPanel | |
| 128 | + title="Call Signature" | |
| 129 | + body={props.detail.contract.callSignature} | |
| 130 | + lang="typescript" | |
| 131 | + empty="No signature." | |
| 132 | + /> | |
| 133 | + | |
| 134 | + <div className="grid gap-4 xl:grid-cols-2"> | |
| 135 | + <DocumentPanel | |
| 136 | + title="Input Schema" | |
| 137 | + body={props.detail.contract.input.schemaJson} | |
| 138 | + empty="No input schema." | |
| 139 | + compact | |
| 140 | + /> | |
| 141 | + <DocumentPanel | |
| 142 | + title="Output Schema" | |
| 143 | + body={props.detail.contract.output.schemaJson} | |
| 144 | + empty="No output schema." | |
| 145 | + compact | |
| 146 | + /> | |
| 147 | + </div> | |
| 148 | + </div> | |
| 149 | + ); | |
| 150 | +}; | |
| 151 | + | |
| 152 | +const DiscoveryPanel = (props: { | |
| 153 | + sourceId: string; | |
| 154 | + query: string; | |
| 155 | + onOpenTool: (toolPath: string) => void; | |
| 156 | + onQueryChange: (query: string) => void; | |
| 157 | +}) => { | |
| 158 | + const [draftQuery, setDraftQuery] = useState(props.query); | |
| 159 | + const results = useSourceDiscovery({ | |
| 160 | + sourceId: props.sourceId, | |
| 161 | + query: props.query, | |
| 162 | + limit: 20, | |
| 163 | + }); | |
| 164 | + | |
| 165 | + useEffect(() => { | |
| 166 | + setDraftQuery(props.query); | |
| 167 | + }, [props.query]); | |
| 168 | + | |
| 169 | + return ( | |
| 170 | + <div className="space-y-4"> | |
| 171 | + <form | |
| 172 | + className="flex items-center gap-2" | |
| 173 | + onSubmit={(event) => { | |
| 174 | + event.preventDefault(); | |
| 175 | + props.onQueryChange(draftQuery.trim()); | |
| 176 | + }} | |
| 177 | + > | |
| 178 | + <div className="relative flex-1"> | |
| 179 | + <IconSearch className="absolute left-3 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground/40" /> | |
| 180 | + <input | |
| 181 | + value={draftQuery} | |
| 182 | + onChange={(event) => setDraftQuery(event.target.value)} | |
| 183 | + placeholder="Search tools..." | |
| 184 | + className="h-9 w-full rounded-lg border border-input bg-background pl-9 pr-3 text-sm outline-none transition-colors placeholder:text-muted-foreground/40 focus:border-ring focus:ring-1 focus:ring-ring/25" | |
| 185 | + /> | |
| 186 | + </div> | |
| 187 | + <button | |
| 188 | + type="submit" | |
| 189 | + className="inline-flex h-9 items-center rounded-lg border border-input bg-card px-3 text-xs font-medium text-foreground transition-colors hover:bg-accent/50" | |
| 190 | + > | |
| 191 | + Search | |
| 192 | + </button> | |
| 193 | + </form> | |
| 194 | + | |
| 195 | + <LoadableBlock loadable={results} loading="Searching tools..."> | |
| 196 | + {(loaded) => | |
| 197 | + loaded.query.length === 0 ? ( | |
| 198 | + <EmptyState | |
| 199 | + title="Search this source" | |
| 200 | + description="Enter a query to find relevant tools." | |
| 201 | + /> | |
| 202 | + ) : loaded.results.length === 0 ? ( | |
| 203 | + <EmptyState | |
| 204 | + title="No results" | |
| 205 | + description="Try different search terms." | |
| 206 | + /> | |
| 207 | + ) : ( | |
| 208 | + <div className="space-y-2"> | |
| 209 | + {loaded.results.map((item, index) => ( | |
| 210 | + <button | |
| 211 | + key={item.path} | |
| 212 | + type="button" | |
| 213 | + onClick={() => props.onOpenTool(item.path)} | |
| 214 | + className="w-full rounded-xl border border-border/70 bg-card/50 p-3 text-left transition-colors hover:border-primary/30 hover:bg-card" | |
| 215 | + > | |
| 216 | + <div className="flex items-center justify-between gap-3"> | |
| 217 | + <div className="flex min-w-0 items-center gap-2"> | |
| 218 | + <span className="inline-flex size-5 items-center justify-center rounded-md bg-muted text-[10px] text-muted-foreground"> | |
| 219 | + {index + 1} | |
| 220 | + </span> | |
| 221 | + <span className="truncate font-mono text-xs text-foreground"> | |
| 222 | + {item.path} | |
| 223 | + </span> | |
| 224 | + </div> | |
| 225 | + <span className="font-mono text-[11px] text-muted-foreground/60"> | |
| 226 | + {item.score.toFixed(2)} | |
| 227 | + </span> | |
| 228 | + </div> | |
| 229 | + {item.description && ( | |
| 230 | + <div className="mt-2 text-xs text-muted-foreground"> | |
| 231 | + {item.description} | |
| 232 | + </div> | |
| 233 | + )} | |
| 234 | + </button> | |
| 235 | + ))} | |
| 236 | + </div> | |
| 237 | + ) | |
| 238 | + } | |
| 239 | + </LoadableBlock> | |
| 240 | + </div> | |
| 241 | + ); | |
| 242 | +}; | |
| 243 | + | |
| 244 | +export const SourceToolExplorer = (props: { | |
| 245 | + sourceId: string; | |
| 246 | + title: string; | |
| 247 | + kind: string; | |
| 248 | + search: SourceToolExplorerSearch; | |
| 249 | + navigate?: (search: { | |
| 250 | + tab: "model" | "discover"; | |
| 251 | + tool?: string; | |
| 252 | + query?: string; | |
| 253 | + }) => void | Promise<void>; | |
| 254 | + actions?: ReactNode; | |
| 255 | + summary?: ReactNode; | |
| 256 | +}) => { | |
| 257 | + const inspection = useSourceInspection(props.sourceId); | |
| 258 | + const tab = props.search.tab === "discover" ? "discover" : "model"; | |
| 259 | + const toolPath = props.search.tool ?? null; | |
| 260 | + const detail = useSourceToolDetail(props.sourceId, tab === "model" ? toolPath : null); | |
| 261 | + const [filter, setFilter] = useState(""); | |
| 262 | + const navigate = props.navigate; | |
| 263 | + | |
| 264 | + useEffect(() => { | |
| 265 | + if ( | |
| 266 | + tab !== "model" | |
| 267 | + || inspection.status !== "ready" | |
| 268 | + || props.search.tool | |
| 269 | + || !navigate | |
| 270 | + ) { | |
| 271 | + return; | |
| 272 | + } | |
| 273 | + | |
| 274 | + const firstTool = inspection.data.tools[0]?.path; | |
| 275 | + if (!firstTool) { | |
| 276 | + return; | |
| 277 | + } | |
| 278 | + | |
| 279 | + startTransition(() => { | |
| 280 | + void navigate({ | |
| 281 | + tab: "model", | |
| 282 | + tool: firstTool, | |
| 283 | + query: props.search.query, | |
| 284 | + }); | |
| 285 | + }); | |
| 286 | + }, [inspection, navigate, props.search.query, props.search.tool, tab]); | |
| 287 | + | |
| 288 | + return ( | |
| 289 | + <LoadableBlock loadable={inspection} loading="Loading source..."> | |
| 290 | + {(loadedInspection) => { | |
| 291 | + const selectedTool = | |
| 292 | + loadedInspection.tools.find((candidate) => candidate.path === toolPath) | |
| 293 | + ?? loadedInspection.tools[0] | |
| 294 | + ?? null; | |
| 295 | + const visibleTools = loadedInspection.tools.filter((tool) => { | |
| 296 | + const query = filter.trim().toLowerCase(); | |
| 297 | + if (!query) { | |
| 298 | + return true; | |
| 299 | + } | |
| 300 | + | |
| 301 | + return [ | |
| 302 | + tool.path, | |
| 303 | + tool.method ?? "", | |
| 304 | + tool.inputTypePreview ?? "", | |
| 305 | + tool.outputTypePreview ?? "", | |
| 306 | + ] | |
| 307 | + .join(" ") | |
| 308 | + .toLowerCase() | |
| 309 | + .includes(query); | |
| 310 | + }); | |
| 311 | + | |
| 312 | + return ( | |
| 313 | + <div className="space-y-4"> | |
| 314 | + <div className="rounded-2xl border border-border bg-card px-5 py-4"> | |
| 315 | + <div className="flex items-start justify-between gap-4"> | |
| 316 | + <div className="min-w-0"> | |
| 317 | + <div className="flex flex-wrap items-center gap-2"> | |
| 318 | + <h2 className="truncate text-lg font-semibold text-foreground"> | |
| 319 | + {props.title} | |
| 320 | + </h2> | |
| 321 | + <Badge variant="outline">{props.kind}</Badge> | |
| 322 | + <Badge variant="muted"> | |
| 323 | + {loadedInspection.toolCount} tool{loadedInspection.toolCount === 1 ? "" : "s"} | |
| 324 | + </Badge> | |
| 325 | + </div> | |
| 326 | + {props.summary && ( | |
| 327 | + <div className="mt-3 text-sm text-muted-foreground"> | |
| 328 | + {props.summary} | |
| 329 | + </div> | |
| 330 | + )} | |
| 331 | + </div> | |
| 332 | + <div className="flex items-center gap-2"> | |
| 333 | + <div className="inline-flex rounded-lg bg-muted p-0.5"> | |
| 334 | + {(["model", "discover"] as const).map((entry) => ( | |
| 335 | + <button | |
| 336 | + key={entry} | |
| 337 | + type="button" | |
| 338 | + onClick={() => { | |
| 339 | + if (!navigate) { | |
| 340 | + return; | |
| 341 | + } | |
| 342 | + | |
| 343 | + startTransition(() => { | |
| 344 | + void navigate({ | |
| 345 | + tab: entry, | |
| 346 | + tool: entry === "model" ? (selectedTool?.path ?? undefined) : props.search.tool, | |
| 347 | + query: props.search.query, | |
| 348 | + }); | |
| 349 | + }); | |
| 350 | + }} | |
| 351 | + className={cn( | |
| 352 | + "rounded-md px-3 py-1 text-xs font-medium transition-colors", | |
| 353 | + tab === entry | |
| 354 | + ? "bg-background text-foreground shadow-sm" | |
| 355 | + : "text-muted-foreground hover:text-foreground", | |
| 356 | + )} | |
| 357 | + > | |
| 358 | + {entry === "model" ? "Tools" : "Search"} | |
| 359 | + </button> | |
| 360 | + ))} | |
| 361 | + </div> | |
| 362 | + {props.actions} | |
| 363 | + </div> | |
| 364 | + </div> | |
| 365 | + </div> | |
| 366 | + | |
| 367 | + {tab === "discover" ? ( | |
| 368 | + <DiscoveryPanel | |
| 369 | + sourceId={props.sourceId} | |
| 370 | + query={props.search.query ?? ""} | |
| 371 | + onQueryChange={(query) => { | |
| 372 | + if (!navigate) { | |
| 373 | + return; | |
| 374 | + } | |
| 375 | + | |
| 376 | + startTransition(() => { | |
| 377 | + void navigate({ | |
| 378 | + tab: "discover", | |
| 379 | + tool: props.search.tool, | |
| 380 | + query, | |
| 381 | + }); | |
| 382 | + }); | |
| 383 | + }} | |
| 384 | + onOpenTool={(path) => { | |
| 385 | + if (!navigate) { | |
| 386 | + return; | |
| 387 | + } | |
| 388 | + | |
| 389 | + startTransition(() => { | |
| 390 | + void navigate({ | |
| 391 | + tab: "model", | |
| 392 | + tool: path, | |
| 393 | + query: props.search.query, | |
| 394 | + }); | |
| 395 | + }); | |
| 396 | + }} | |
| 397 | + /> | |
| 398 | + ) : ( | |
| 399 | + <div className="grid gap-4 lg:grid-cols-[18rem_minmax(0,1fr)]"> | |
| 400 | + <div className="space-y-3 rounded-2xl border border-border bg-card p-4"> | |
| 401 | + <div className="relative"> | |
| 402 | + <IconSearch className="absolute left-3 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground/40" /> | |
| 403 | + <input | |
| 404 | + value={filter} | |
| 405 | + onChange={(event) => setFilter(event.target.value)} | |
| 406 | + placeholder="Filter tools..." | |
| 407 | + className="h-9 w-full rounded-lg border border-input bg-background pl-9 pr-3 text-sm outline-none transition-colors placeholder:text-muted-foreground/40 focus:border-ring focus:ring-1 focus:ring-ring/25" | |
| 408 | + /> | |
| 409 | + </div> | |
| 410 | + <div className="space-y-1"> | |
| 411 | + {visibleTools.length === 0 ? ( | |
| 412 | + <EmptyState | |
| 413 | + title="No tools match" | |
| 414 | + description="Try a different filter." | |
| 415 | + /> | |
| 416 | + ) : ( | |
| 417 | + visibleTools.map((tool) => ( | |
| 418 | + <button | |
| 419 | + key={tool.path} | |
| 420 | + type="button" | |
| 421 | + onClick={() => { | |
| 422 | + if (!navigate) { | |
| 423 | + return; | |
| 424 | + } | |
| 425 | + | |
| 426 | + startTransition(() => { | |
| 427 | + void navigate({ | |
| 428 | + tab: "model", | |
| 429 | + tool: tool.path, | |
| 430 | + query: props.search.query, | |
| 431 | + }); | |
| 432 | + }); | |
| 433 | + }} | |
| 434 | + className={cn( | |
| 435 | + "flex w-full items-center gap-2 rounded-lg border px-3 py-2 text-left transition-colors", | |
| 436 | + selectedTool?.path === tool.path | |
| 437 | + ? "border-primary/30 bg-primary/8" | |
| 438 | + : "border-transparent bg-muted/30 hover:border-border hover:bg-card", | |
| 439 | + )} | |
| 440 | + > | |
| 441 | + <IconTool className="size-3.5 shrink-0 text-muted-foreground/60" /> | |
| 442 | + <span className="min-w-0 flex-1 truncate font-mono text-xs text-foreground"> | |
| 443 | + {highlight(tool.path, filter)} | |
| 444 | + </span> | |
| 445 | + {tool.method && <MethodBadge method={tool.method} />} | |
| 446 | + </button> | |
| 447 | + )) | |
| 448 | + )} | |
| 449 | + </div> | |
| 450 | + </div> | |
| 451 | + | |
| 452 | + <div className="rounded-2xl border border-border bg-card p-4"> | |
| 453 | + <LoadableBlock loadable={detail} loading="Loading tool..."> | |
| 454 | + {(loadedDetail) => | |
| 455 | + loadedDetail ? ( | |
| 456 | + <ToolDetailPanel detail={loadedDetail} /> | |
| 457 | + ) : ( | |
| 458 | + <EmptyState | |
| 459 | + title="Select a tool" | |
| 460 | + description="Choose a tool from the list to inspect its contract." | |
| 461 | + /> | |
| 462 | + ) | |
| 463 | + } | |
| 464 | + </LoadableBlock> | |
| 465 | + </div> | |
| 466 | + </div> | |
| 467 | + )} | |
| 468 | + </div> | |
| 469 | + ); | |
| 470 | + }} | |
| 471 | + </LoadableBlock> | |
| 472 | + ); | |
| 473 | +}; | |
| @@ -4,6 +4,7 @@ import { LoadableBlock } from "../components/loadable"; · 3 unmodified lines | ||
| 4 | 4 | import { Badge } from "../components/ui/badge"; |
| 5 | 5 | import { Button } from "../components/ui/button"; |
| 6 | 6 | import { SourcePluginsResetState } from "../components/source-plugins-reset-state"; |
| 7 | +import { registeredSourceFrontendTypes } from "../source-plugins"; | |
| 7 | 8 | |
| 8 | 9 | export function HomePage() { |
| 9 | 10 | const sources = useSources(); |
| @@ -16,13 +17,22 @@ export function HomePage() { · 6 unmodified lines | ||
| 16 | 17 | Sources |
| 17 | 18 | </div> |
| 18 | 19 | <h1 className="mt-5 font-display text-3xl tracking-tight text-foreground lg:text-4xl"> |
| 19 | − OpenAPI is now mounted as a source plugin | |
| 20 | + Source plugins now own source-specific product behavior | |
| 20 | 21 | </h1> |
| 21 | 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-backed | |
| 23 | − surface is wired in for OpenAPI so the frontend and backend boundaries | |
| 24 | − are visible in the app again. | |
| 23 | + Add, edit, inspect, and authorize sources through plugin-owned UI and | |
| 24 | + API surfaces. The core app stays generic while each plugin carries its | |
| 25 | + own transport, auth, and storage behavior. | |
| 25 | 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 | 36 | </div> |
| 27 | 37 | |
| 28 | 38 | <div className="mt-8"> |
| @@ -36,11 +46,11 @@ export function HomePage() { · 7 unmodified lines | ||
| 36 | 46 | ) : items.length === 0 ? ( |
| 37 | 47 | <SourcePluginsResetState |
| 38 | 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 | 50 | action={( |
| 41 | 51 | <Link to="/sources/add"> |
| 42 | 52 | <Button size="sm" variant="outline"> |
| 43 | − Open OpenAPI Plugin | |
| 53 | + Add Source | |
| 44 | 54 | </Button> |
| 45 | 55 | </Link> |
| 46 | 56 | )} |
| @@ -32,6 +32,9 @@ · 31 unmodified lines | ||
| 32 | 32 | "@executor/platform-internal": "workspace:*", |
| 33 | 33 | "@executor/platform-sdk": "workspace:*", |
| 34 | 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 | 38 | "@executor/plugin-openapi-sdk": "workspace:*", |
| 36 | 39 | "@executor/server": "workspace:*", |
| 37 | 40 | "effect": "catalog:", |
| @@ -52,6 +55,9 @@ · 14 unmodified lines | ||
| 52 | 55 | "name": "@executor/web", |
| 53 | 56 | "version": "0.0.0", |
| 54 | 57 | "dependencies": { |
| 58 | + "@executor/plugin-google-discovery-react": "workspace:*", | |
| 59 | + "@executor/plugin-graphql-react": "workspace:*", | |
| 60 | + "@executor/plugin-mcp-react": "workspace:*", | |
| 55 | 61 | "@executor/plugin-openapi-react": "workspace:*", |
| 56 | 62 | "@executor/react": "workspace:*", |
| 57 | 63 | "@executor/server": "workspace:*", |
| @@ -394,6 +400,15 @@ · 336 unmodified lines | ||
| 394 | 400 | "@executor/platform-internal": "workspace:*", |
| 395 | 401 | "@executor/platform-sdk": "workspace:*", |
| 396 | 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 | 412 | "@executor/plugin-openapi-http": "workspace:*", |
| 398 | 413 | "@executor/plugin-openapi-sdk": "workspace:*", |
| 399 | 414 | "@executor/plugin-openapi-shared": "workspace:*", |
| @@ -425,6 +440,191 @@ · 25 unmodified lines | ||
| 425 | 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 | 628 | "plugins/openapi/http": { |
| 429 | 629 | "name": "@executor/plugin-openapi-http", |
| 430 | 630 | "version": "0.0.0", |
| @@ -717,6 +917,30 @@ · 286 unmodified lines | ||
| 717 | 917 | |
| 718 | 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 | 944 | "@executor/plugin-openapi-http": ["@executor/plugin-openapi-http@workspace:plugins/openapi/http"], |
| 721 | 945 | |
| 722 | 946 | "@executor/plugin-openapi-react": ["@executor/plugin-openapi-react@workspace:plugins/openapi/react"], |
| @@ -1373,6 +1597,8 @@ · 650 unmodified lines | ||
| 1373 | 1597 | |
| 1374 | 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 | 1602 | "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], |
| 1377 | 1603 | |
| 1378 | 1604 | "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], |
| @@ -2,7 +2,7 @@ import type { · 1 unmodified line | ||
| 2 | 2 | ExecutionEnvelope, |
| 3 | 3 | } from "@executor/platform-sdk/schema"; |
| 4 | 4 | import { |
| 5 | − EXECUTOR_SOURCES_ADD_HELP_LINES, | |
| 5 | + getExecutorSourcesAddHelpLines, | |
| 6 | 6 | RuntimeSourceCatalogStoreService, |
| 7 | 7 | createExecution, |
| 8 | 8 | getExecution, |
| @@ -172,14 +172,12 @@ const buildExecuteWorkflowText = ( · 163 unmodified lines | ||
| 172 | 172 | '1) const matches = await tools.discover({ query: "<intent>", limit: 12 });', |
| 173 | 173 | "2) const details = await tools.describe.tool({ path, includeSchemas: true });", |
| 174 | 174 | "3) Call selected tools.<path>(input).", |
| 175 | − "4) Source plugins are not registered in this build.", | |
| 176 | − ...EXECUTOR_SOURCES_ADD_HELP_LINES, | |
| 175 | + "4) Use source plugins to inspect or add API sources.", | |
| 176 | + ...getExecutorSourcesAddHelpLines(), | |
| 177 | 177 | "5) If execution pauses for interaction, resume it with the returned resumePayload or the available resume flow.", |
| 178 | 178 | "Do not use fetch; use tools.* only.", |
| 179 | 179 | ].join("\n"); |
| 180 | 180 | |
| 181 | −const defaultExecuteDescription = buildExecuteWorkflowText(); | |
| 182 | − | |
| 183 | 181 | const loadExecuteDescription = (runtime: ExecutorRuntime): Promise<string> => |
| 184 | 182 | runControlPlane( |
| 185 | 183 | runtime, |
| @@ -203,11 +201,11 @@ const loadExecuteDescription = (runtime: ExecutorRuntime): Promise<string> => · 17 unmodified lines | ||
| 203 | 201 | .filter((catalog) => catalog.toolPaths.length > 0); |
| 204 | 202 | |
| 205 | 203 | if (sourceToolExamples.length === 0) { |
| 206 | − return defaultExecuteDescription; | |
| 204 | + return buildExecuteWorkflowText(); | |
| 207 | 205 | } |
| 208 | 206 | |
| 209 | 207 | return buildExecuteWorkflowText(sourceToolExamples); |
| 210 | − }).pipe(Effect.catchAll(() => Effect.succeed(defaultExecuteDescription))), | |
| 208 | + }).pipe(Effect.catchAll(() => Effect.succeed(buildExecuteWorkflowText()))), | |
| 211 | 209 | ); |
| 212 | 210 | |
| 213 | 211 | const summarizeExecution = (execution: ExecutionEnvelope["execution"]): string => { |
| @@ -285,6 +285,31 @@ export const createExecutorEffect = < · 284 unmodified lines | ||
| 285 | 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 | 313 | const host: ExecutorSdkPluginHost = { |
| 289 | 314 | sources: { |
| 290 | 315 | create: ({ source }) => |
| @@ -330,10 +355,12 @@ export const createExecutorEffect = < · 39 unmodified lines | ||
| 330 | 355 | const extensions = Object.fromEntries( |
| 331 | 356 | (options.plugins ?? []).map((plugin) => [ |
| 332 | 357 | plugin.key, |
| 333 | − plugin.extendExecutor?.({ | |
| 334 | − executor, | |
| 335 | − host, | |
| 336 | − }) ?? {}, | |
| 358 | + providePluginExtensionValue( | |
| 359 | + plugin.extendExecutor?.({ | |
| 360 | + executor, | |
| 361 | + host, | |
| 362 | + }) ?? {}, | |
| 363 | + ), | |
| 337 | 364 | ]), |
| 338 | 365 | ); |
| 339 | 366 | |
| @@ -11,6 +11,7 @@ import type { · 10 unmodified lines | ||
| 11 | 11 | SourceSyncInput, |
| 12 | 12 | } from "@executor/source-core"; |
| 13 | 13 | import * as Effect from "effect/Effect"; |
| 14 | +import type * as Schema from "effect/Schema"; | |
| 14 | 15 | |
| 15 | 16 | export type ExecutorSdkPlugin< |
| 16 | 17 | TKey extends string = string, |
| @@ -18,6 +19,7 @@ export type ExecutorSdkPlugin< · 1 unmodified line | ||
| 18 | 19 | > = { |
| 19 | 20 | key: TKey; |
| 20 | 21 | sources?: readonly SourcePluginRuntime[]; |
| 22 | + sourceConnectors?: readonly ExecutorSourceConnector<any>[]; | |
| 21 | 23 | extendExecutor?: (input: { |
| 22 | 24 | executor: ExecutorEffect & Record<string, unknown>; |
| 23 | 25 | host: ExecutorSdkPluginHost; |
| @@ -31,16 +33,28 @@ export type ExecutorSdkPluginHost = { · 7 unmodified lines | ||
| 31 | 33 | ExecutorSource, |
| 32 | 34 | "id" | "scopeId" | "createdAt" | "updatedAt" |
| 33 | 35 | >; |
| 34 | − }) => Effect.Effect<ExecutorSource, Error, never>; | |
| 35 | − get: (sourceId: ExecutorSource["id"]) => Effect.Effect<ExecutorSource, Error, never>; | |
| 36 | − save: (source: ExecutorSource) => Effect.Effect<ExecutorSource, Error, never>; | |
| 36 | + }) => Effect.Effect<ExecutorSource, Error, any>; | |
| 37 | + get: (sourceId: ExecutorSource["id"]) => Effect.Effect<ExecutorSource, Error, any>; | |
| 38 | + save: (source: ExecutorSource) => Effect.Effect<ExecutorSource, Error, any>; | |
| 37 | 39 | refreshCatalog: ( |
| 38 | 40 | sourceId: ExecutorSource["id"], |
| 39 | − ) => Effect.Effect<ExecutorSource, Error, never>; | |
| 40 | − remove: (sourceId: ExecutorSource["id"]) => Effect.Effect<boolean, Error, never>; | |
| 41 | + ) => Effect.Effect<ExecutorSource, Error, any>; | |
| 42 | + remove: (sourceId: ExecutorSource["id"]) => Effect.Effect<boolean, Error, any>; | |
| 41 | 43 | }; |
| 42 | 44 | }; |
| 43 | 45 | |
| 46 | +export type ExecutorSourceConnector<TInput = unknown> = { | |
| 47 | + kind: string; | |
| 48 | + displayName: string; | |
| 49 | + inputSchema: Schema.Schema<TInput, any, never>; | |
| 50 | + inputSignatureWidth?: number; | |
| 51 | + helpText?: readonly string[]; | |
| 52 | + createSource: (input: { | |
| 53 | + args: TInput; | |
| 54 | + host: ExecutorSdkPluginHost; | |
| 55 | + }) => Effect.Effect<ExecutorSource, Error, any>; | |
| 56 | +}; | |
| 57 | + | |
| 44 | 58 | export type ExecutorSdkPluginExtensions< |
| 45 | 59 | TPlugins extends readonly ExecutorSdkPlugin<any, any>[], |
| 46 | 60 | > = { |
| @@ -59,21 +73,25 @@ export type SourcePluginRuntime = { · 12 unmodified lines | ||
| 59 | 73 | }) => Record<string, unknown>; |
| 60 | 74 | getIrModel: ( |
| 61 | 75 | input: SourceSyncInput, |
| 62 | − ) => Effect.Effect<SourceCatalogSyncResult, Error, never>; | |
| 76 | + ) => Effect.Effect<SourceCatalogSyncResult, Error, any>; | |
| 63 | 77 | invoke: ( |
| 64 | 78 | input: SourceInvokeInput, |
| 65 | − ) => Effect.Effect<SourceInvokeResult, Error, never>; | |
| 79 | + ) => Effect.Effect<SourceInvokeResult, Error, any>; | |
| 66 | 80 | }; |
| 67 | 81 | |
| 68 | 82 | export const registerExecutorSdkPlugins = ( |
| 69 | 83 | plugins: readonly ExecutorSdkPlugin[], |
| 70 | 84 | ) => { |
| 71 | 85 | const sourcePlugins = new Map<string, SourcePluginRuntime>(); |
| 86 | + const sourceConnectors = new Map<string, ExecutorSourceConnector<any>>(); | |
| 72 | 87 | |
| 73 | 88 | for (const plugin of plugins) { |
| 74 | 89 | for (const source of plugin.sources ?? []) { |
| 75 | 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 | 97 | const getSourcePlugin = (kind: string) => { |
| @@ -91,6 +109,7 @@ export const registerExecutorSdkPlugins = ( · 11 unmodified lines | ||
| 91 | 109 | return { |
| 92 | 110 | plugins, |
| 93 | 111 | sourcePlugins: [...sourcePlugins.values()], |
| 112 | + sourceConnectors: [...sourceConnectors.values()], | |
| 94 | 113 | getSourcePlugin, |
| 95 | 114 | getSourcePluginForSource, |
| 96 | 115 | sourcePluginCatalogKind: (kind: string): SourceCatalogKind => |
| @@ -56,7 +56,7 @@ export type RuntimeSourceCatalogSyncShape = { · 55 unmodified lines | ||
| 56 | 56 | sync: (input: { |
| 57 | 57 | source: Source; |
| 58 | 58 | actorScopeId?: ScopeId | null; |
| 59 | − }) => Effect.Effect<void, Error, never>; | |
| 59 | + }) => Effect.Effect<void, Error, any>; | |
| 60 | 60 | }; |
| 61 | 61 | |
| 62 | 62 | export class RuntimeSourceCatalogSyncService extends Context.Tag( |
| @@ -83,7 +83,7 @@ const syncSourceCatalogWithDeps = ( · 20 unmodified lines | ||
| 83 | 83 | source: Source; |
| 84 | 84 | actorScopeId?: ScopeId | null; |
| 85 | 85 | }, |
| 86 | −): Effect.Effect<void, Error, never> => | |
| 86 | +): Effect.Effect<void, Error, any> => | |
| 87 | 87 | Effect.gen(function* () { |
| 88 | 88 | yield* ensureRuntimeCatalogSyncWorkspace(deps, input.source.scopeId); |
| 89 | 89 | |
| @@ -129,6 +129,9 @@ export const createScopeToolInvoker = (input: { · 128 unmodified lines | ||
| 129 | 129 | const executorTools = createExecutorToolMap({ |
| 130 | 130 | scopeId: input.scopeId, |
| 131 | 131 | actorScopeId: input.actorScopeId, |
| 132 | + executorStateStore: input.executorStateStore, | |
| 133 | + sourceStore: input.sourceStore, | |
| 134 | + sourceCatalogSyncService: input.sourceCatalogSyncService, | |
| 132 | 135 | installationStore: input.installationStore, |
| 133 | 136 | scopeConfigStore: input.scopeConfigStore, |
| 134 | 137 | scopeStateStore: input.scopeStateStore, |
| @@ -236,9 +239,13 @@ export const createScopeToolInvoker = (input: { · 101 unmodified lines | ||
| 236 | 239 | catalog, |
| 237 | 240 | toolInvoker: { |
| 238 | 241 | invoke: ({ path, args, context }) => { |
| 239 | − const effect: Effect.Effect<unknown, unknown, never> = authoredToolPaths.has(path) | |
| 240 | − ? authoredInvoker.invoke({ path, args, context }) as Effect.Effect<unknown, unknown, never> | |
| 241 | − : invokePersistedTool({ path, args, context }); | |
| 242 | + const effect = authoredToolPaths.has(path) | |
| 243 | + ? authoredInvoker.invoke({ path, args, context }) | |
| 244 | + : invokePersistedTool({ path, args, context }) as Effect.Effect< | |
| 245 | + unknown, | |
| 246 | + unknown, | |
| 247 | + never | |
| 248 | + >; | |
| 242 | 249 | |
| 243 | 250 | return provideRuntimeLocalScope(effect, input.runtimeLocalScope); |
| 244 | 251 | }, |
| @@ -1,47 +1,267 @@ | ||
| 1 | −import type { ToolMap } from "@executor/codemode-core"; | |
| 2 | −import { SourceSchema } from "#schema"; | |
| 1 | +import { toTool, type ToolMap } from "@executor/codemode-core"; | |
| 2 | +import { | |
| 3 | + type ScopeId, | |
| 4 | + SourceSchema, | |
| 5 | + type Source, | |
| 6 | +} from "#schema"; | |
| 7 | +import * as Effect from "effect/Effect"; | |
| 8 | +import * as Layer from "effect/Layer"; | |
| 9 | +import * as Option from "effect/Option"; | |
| 10 | +import * as Schema from "effect/Schema"; | |
| 3 | 11 | |
| 12 | +import { | |
| 13 | + createManagedSourceRecord, | |
| 14 | + getSource, | |
| 15 | + refreshManagedSourceCatalog, | |
| 16 | + removeSource, | |
| 17 | + saveManagedSourceRecord, | |
| 18 | +} from "../../sources/operations"; | |
| 4 | 19 | import { |
| 5 | 20 | deriveSchemaJson, |
| 6 | 21 | deriveSchemaTypeSignature, |
| 7 | 22 | } from "../catalog/schema-type-signature"; |
| 8 | −import type { | |
| 9 | − InstallationStoreShape, | |
| 10 | − SourceArtifactStoreShape, | |
| 11 | − ScopeConfigStoreShape, | |
| 12 | − ScopeStateStoreShape, | |
| 23 | +import { | |
| 24 | + RuntimeSourceCatalogSyncService, | |
| 25 | +} from "../catalog/source/sync"; | |
| 26 | +import { | |
| 27 | + ExecutorStateStore, | |
| 28 | + type ExecutorStateStoreShape, | |
| 29 | +} from "../executor-state-store"; | |
| 30 | +import { | |
| 31 | + provideOptionalRuntimeLocalScope, | |
| 32 | + type RuntimeLocalScopeState, | |
| 33 | +} from "../scope/runtime-context"; | |
| 34 | +import { | |
| 35 | + type InstallationStoreShape, | |
| 36 | + makeLocalStorageLayer, | |
| 37 | + type SourceArtifactStoreShape, | |
| 38 | + type ScopeConfigStoreShape, | |
| 39 | + type ScopeStateStoreShape, | |
| 13 | 40 | } from "../scope/storage"; |
| 41 | +import { | |
| 42 | + type RuntimeSourceStore, | |
| 43 | + RuntimeSourceStoreService, | |
| 44 | +} from "./source-store"; | |
| 45 | +import { | |
| 46 | + registeredSourceConnectors, | |
| 47 | +} from "./source-plugins"; | |
| 48 | +import type { | |
| 49 | + ExecutorSdkPluginHost, | |
| 50 | + ExecutorSourceConnector, | |
| 51 | +} from "../../plugins"; | |
| 52 | + | |
| 53 | +const createExecutorSourcesAddSchema = (): Schema.Schema<any, any, never> => { | |
| 54 | + const connectors = registeredSourceConnectors(); | |
| 55 | + if (connectors.length === 0) { | |
| 56 | + return Schema.Unknown; | |
| 57 | + } | |
| 14 | 58 | |
| 15 | −export const EXECUTOR_SOURCES_ADD_INPUT_HINT = | |
| 16 | − "Source plugins are not registered in this build."; | |
| 59 | + if (connectors.length === 1) { | |
| 60 | + return connectors[0]!.inputSchema; | |
| 61 | + } | |
| 62 | + | |
| 63 | + return Schema.Union( | |
| 64 | + ...(connectors.map((connector) => connector.inputSchema) as [ | |
| 65 | + Schema.Schema<any, any, never>, | |
| 66 | + Schema.Schema<any, any, never>, | |
| 67 | + ...Array<Schema.Schema<any, any, never>>, | |
| 68 | + ]) | |
| 69 | + ); | |
| 70 | +}; | |
| 71 | + | |
| 72 | +export const getExecutorSourcesAddInputHint = (): string => | |
| 73 | + deriveSchemaTypeSignature(createExecutorSourcesAddSchema(), 340); | |
| 17 | 74 | |
| 18 | 75 | export const EXECUTOR_SOURCES_ADD_OUTPUT_SIGNATURE = deriveSchemaTypeSignature( |
| 19 | 76 | SourceSchema, |
| 20 | 77 | 260, |
| 21 | 78 | ); |
| 22 | 79 | |
| 23 | −export const EXECUTOR_SOURCES_ADD_INPUT_SCHEMA = {}; | |
| 80 | +export const getExecutorSourcesAddInputSchemaJson = (): Record<string, unknown> => | |
| 81 | + deriveSchemaJson(createExecutorSourcesAddSchema()) ?? {}; | |
| 24 | 82 | |
| 25 | 83 | export const EXECUTOR_SOURCES_ADD_OUTPUT_SCHEMA = deriveSchemaJson( |
| 26 | 84 | SourceSchema, |
| 27 | 85 | ) ?? {}; |
| 28 | 86 | |
| 29 | −export const EXECUTOR_SOURCES_ADD_HELP_LINES = [ | |
| 30 | − "Source plugins are not registered in this build.", | |
| 31 | −] as const; | |
| 87 | +export const getExecutorSourcesAddHelpLines = (): readonly string[] => { | |
| 88 | + const connectors = registeredSourceConnectors(); | |
| 89 | + if (connectors.length === 0) { | |
| 90 | + return ["No source plugins are registered in this build."] as const; | |
| 91 | + } | |
| 32 | 92 | |
| 33 | −export const buildExecutorSourcesAddDescription = (): string => | |
| 34 | − "Source plugins are not registered in this build."; | |
| 93 | + return [ | |
| 94 | + "Source add input shapes:", | |
| 95 | + ...connectors.flatMap((connector) => [ | |
| 96 | + `- ${connector.displayName}: ${deriveSchemaTypeSignature( | |
| 97 | + connector.inputSchema, | |
| 98 | + connector.inputSignatureWidth ?? 260, | |
| 99 | + )}`, | |
| 100 | + ...(connector.helpText ?? []).map((line) => ` ${line}`), | |
| 101 | + ]), | |
| 102 | + ]; | |
| 103 | +}; | |
| 104 | + | |
| 105 | +export const buildExecutorSourcesAddDescription = (): string => { | |
| 106 | + const connectors = registeredSourceConnectors(); | |
| 107 | + if (connectors.length === 0) { | |
| 108 | + return "No source plugins are registered in this build."; | |
| 109 | + } | |
| 110 | + | |
| 111 | + return [ | |
| 112 | + "Add a source using one of the registered source plugins.", | |
| 113 | + ...getExecutorSourcesAddHelpLines(), | |
| 114 | + ].join("\n"); | |
| 115 | +}; | |
| 116 | + | |
| 117 | +const createSourceConnectorHost = (input: { | |
| 118 | + scopeId: ScopeId; | |
| 119 | + actorScopeId: ScopeId; | |
| 120 | +}): ExecutorSdkPluginHost => ({ | |
| 121 | + sources: { | |
| 122 | + create: ({ source }) => | |
| 123 | + createManagedSourceRecord({ | |
| 124 | + scopeId: input.scopeId, | |
| 125 | + actorScopeId: input.actorScopeId, | |
| 126 | + source, | |
| 127 | + }), | |
| 128 | + get: (sourceId) => | |
| 129 | + getSource({ | |
| 130 | + scopeId: input.scopeId, | |
| 131 | + sourceId, | |
| 132 | + actorScopeId: input.actorScopeId, | |
| 133 | + }), | |
| 134 | + save: (source) => | |
| 135 | + saveManagedSourceRecord({ | |
| 136 | + actorScopeId: input.actorScopeId, | |
| 137 | + source, | |
| 138 | + }), | |
| 139 | + refreshCatalog: (sourceId) => | |
| 140 | + refreshManagedSourceCatalog({ | |
| 141 | + scopeId: input.scopeId, | |
| 142 | + sourceId, | |
| 143 | + actorScopeId: input.actorScopeId, | |
| 144 | + }), | |
| 145 | + remove: (sourceId) => | |
| 146 | + removeSource({ | |
| 147 | + scopeId: input.scopeId, | |
| 148 | + sourceId, | |
| 149 | + }).pipe(Effect.map((result) => result.removed)), | |
| 150 | + }, | |
| 151 | +}); | |
| 152 | + | |
| 153 | +const runExecutorSourceEffect = async <A>( | |
| 154 | + effect: Effect.Effect<A, unknown, any>, | |
| 155 | + input: { | |
| 156 | + executorStateStore: ExecutorStateStoreShape; | |
| 157 | + sourceStore: RuntimeSourceStore; | |
| 158 | + sourceCatalogSyncService: Effect.Effect.Success< | |
| 159 | + typeof RuntimeSourceCatalogSyncService | |
| 160 | + >; | |
| 161 | + installationStore: InstallationStoreShape; | |
| 162 | + scopeConfigStore: ScopeConfigStoreShape; | |
| 163 | + scopeStateStore: ScopeStateStoreShape; | |
| 164 | + sourceArtifactStore: SourceArtifactStoreShape; | |
| 165 | + runtimeLocalScope: RuntimeLocalScopeState | null; | |
| 166 | + }, | |
| 167 | +): Promise<A> => { | |
| 168 | + const servicesLayer = Layer.mergeAll( | |
| 169 | + makeLocalStorageLayer({ | |
| 170 | + installationStore: input.installationStore, | |
| 171 | + scopeConfigStore: input.scopeConfigStore, | |
| 172 | + scopeStateStore: input.scopeStateStore, | |
| 173 | + sourceArtifactStore: input.sourceArtifactStore, | |
| 174 | + }), | |
| 175 | + Layer.succeed(ExecutorStateStore, input.executorStateStore), | |
| 176 | + Layer.succeed(RuntimeSourceStoreService, input.sourceStore), | |
| 177 | + Layer.succeed( | |
| 178 | + RuntimeSourceCatalogSyncService, | |
| 179 | + input.sourceCatalogSyncService, | |
| 180 | + ), | |
| 181 | + ); | |
| 182 | + | |
| 183 | + return Effect.runPromise( | |
| 184 | + provideOptionalRuntimeLocalScope( | |
| 185 | + effect.pipe(Effect.provide(servicesLayer)), | |
| 186 | + input.runtimeLocalScope, | |
| 187 | + ) as Effect.Effect<A, unknown, never>, | |
| 188 | + ); | |
| 189 | +}; | |
| 190 | + | |
| 191 | +const resolveSourceConnector = ( | |
| 192 | + connectors: readonly ExecutorSourceConnector[], | |
| 193 | + args: unknown, | |
| 194 | +): | |
| 195 | + | { | |
| 196 | + connector: ExecutorSourceConnector; | |
| 197 | + parsedArgs: unknown; | |
| 198 | + } | |
| 199 | + | null => { | |
| 200 | + for (const connector of connectors) { | |
| 201 | + const parsed = Schema.decodeUnknownOption(connector.inputSchema)(args); | |
| 202 | + if (Option.isSome(parsed)) { | |
| 203 | + return { | |
| 204 | + connector, | |
| 205 | + parsedArgs: parsed.value, | |
| 206 | + }; | |
| 207 | + } | |
| 208 | + } | |
| 209 | + | |
| 210 | + return null; | |
| 211 | +}; | |
| 212 | + | |
| 213 | +const toSerializableValue = <A>(value: A): A => | |
| 214 | + JSON.parse(JSON.stringify(value)) as A; | |
| 35 | 215 | |
| 36 | 216 | export const createExecutorToolMap = (input: { |
| 37 | − scopeId?: string; | |
| 38 | − actorScopeId?: string; | |
| 217 | + scopeId: ScopeId; | |
| 218 | + actorScopeId: ScopeId; | |
| 219 | + executorStateStore: ExecutorStateStoreShape; | |
| 220 | + sourceStore: RuntimeSourceStore; | |
| 221 | + sourceCatalogSyncService: Effect.Effect.Success< | |
| 222 | + typeof RuntimeSourceCatalogSyncService | |
| 223 | + >; | |
| 39 | 224 | installationStore: InstallationStoreShape; |
| 40 | 225 | scopeConfigStore: ScopeConfigStoreShape; |
| 41 | 226 | scopeStateStore: ScopeStateStoreShape; |
| 42 | 227 | sourceArtifactStore: SourceArtifactStoreShape; |
| 43 | − runtimeLocalScope?: unknown; | |
| 228 | + runtimeLocalScope: RuntimeLocalScopeState | null; | |
| 44 | 229 | }): ToolMap => { |
| 45 | − void input; | |
| 46 | − return {}; | |
| 230 | + const connectors = registeredSourceConnectors(); | |
| 231 | + if (connectors.length === 0) { | |
| 232 | + return {}; | |
| 233 | + } | |
| 234 | + | |
| 235 | + const host = createSourceConnectorHost({ | |
| 236 | + scopeId: input.scopeId, | |
| 237 | + actorScopeId: input.actorScopeId, | |
| 238 | + }); | |
| 239 | + | |
| 240 | + return { | |
| 241 | + "executor.sources.add": toTool({ | |
| 242 | + tool: { | |
| 243 | + description: buildExecutorSourcesAddDescription(), | |
| 244 | + inputSchema: Schema.standardSchemaV1(createExecutorSourcesAddSchema()), | |
| 245 | + outputSchema: Schema.standardSchemaV1(SourceSchema), | |
| 246 | + execute: async (args: unknown): Promise<Source> => { | |
| 247 | + const matched = resolveSourceConnector(connectors, args); | |
| 248 | + if (matched === null) { | |
| 249 | + throw new Error( | |
| 250 | + "executor.sources.add input did not match a registered source plugin.", | |
| 251 | + ); | |
| 252 | + } | |
| 253 | + | |
| 254 | + const createdSource = await runExecutorSourceEffect( | |
| 255 | + matched.connector.createSource({ | |
| 256 | + args: matched.parsedArgs, | |
| 257 | + host, | |
| 258 | + }), | |
| 259 | + input, | |
| 260 | + ); | |
| 261 | + | |
| 262 | + return toSerializableValue(createdSource); | |
| 263 | + }, | |
| 264 | + }, | |
| 265 | + }), | |
| 266 | + }; | |
| 47 | 267 | }; |
| @@ -27,6 +27,7 @@ export const configureExecutorSourcePlugins = ( · 26 unmodified lines | ||
| 27 | 27 | }; |
| 28 | 28 | |
| 29 | 29 | export const registeredSourcePlugins = () => registry.sourcePlugins; |
| 30 | +export const registeredSourceConnectors = () => registry.sourceConnectors; | |
| 30 | 31 | |
| 31 | 32 | export const getSourcePlugin = (kind: string) => registry.getSourcePlugin(kind); |
| 32 | 33 | export const getSourcePluginForSource = ( |
| @@ -15,6 +15,15 @@ · 14 unmodified lines | ||
| 15 | 15 | "@effect/platform": "catalog:", |
| 16 | 16 | "@effect/platform-node": "catalog:", |
| 17 | 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 | 27 | "@executor/plugin-openapi-http": "workspace:*", |
| 19 | 28 | "@executor/plugin-openapi-shared": "workspace:*", |
| 20 | 29 | "@executor/plugin-openapi-sdk": "workspace:*", |
| @@ -0,0 +1,40 @@ | ||
| 1 | +import type { GoogleDiscoveryOAuthSessionStorage } from "@executor/plugin-google-discovery-sdk"; | |
| 2 | +import { | |
| 3 | + GoogleDiscoveryOAuthSessionSchema, | |
| 4 | +} from "@executor/plugin-google-discovery-shared"; | |
| 5 | + | |
| 6 | +import { | |
| 7 | + pluginSessionStoragePath, | |
| 8 | + readJsonFile, | |
| 9 | + removeJsonFile, | |
| 10 | + writeJsonFile, | |
| 11 | +} from "./json-file-storage"; | |
| 12 | + | |
| 13 | +export const createFileGoogleDiscoveryOAuthSessionStorage = (input: { | |
| 14 | + rootDir: string; | |
| 15 | +}): GoogleDiscoveryOAuthSessionStorage => ({ | |
| 16 | + get: (sessionId) => | |
| 17 | + readJsonFile({ | |
| 18 | + path: pluginSessionStoragePath({ | |
| 19 | + rootDir: input.rootDir, | |
| 20 | + sessionId, | |
| 21 | + }), | |
| 22 | + schema: GoogleDiscoveryOAuthSessionSchema, | |
| 23 | + }), | |
| 24 | + put: ({ sessionId, value }) => | |
| 25 | + writeJsonFile({ | |
| 26 | + path: pluginSessionStoragePath({ | |
| 27 | + rootDir: input.rootDir, | |
| 28 | + sessionId, | |
| 29 | + }), | |
| 30 | + schema: GoogleDiscoveryOAuthSessionSchema, | |
| 31 | + value, | |
| 32 | + }), | |
| 33 | + remove: (sessionId) => | |
| 34 | + removeJsonFile( | |
| 35 | + pluginSessionStoragePath({ | |
| 36 | + rootDir: input.rootDir, | |
| 37 | + sessionId, | |
| 38 | + }), | |
| 39 | + ), | |
| 40 | +}); | |
| @@ -0,0 +1,43 @@ | ||
| 1 | +import type { GoogleDiscoverySourceStorage } from "@executor/plugin-google-discovery-sdk"; | |
| 2 | +import { | |
| 3 | + GoogleDiscoveryStoredSourceDataSchema, | |
| 4 | +} from "@executor/plugin-google-discovery-shared"; | |
| 5 | + | |
| 6 | +import { | |
| 7 | + pluginSourceStoragePath, | |
| 8 | + readJsonFile, | |
| 9 | + removeJsonFile, | |
| 10 | + writeJsonFile, | |
| 11 | +} from "./json-file-storage"; | |
| 12 | + | |
| 13 | +export const createFileGoogleDiscoverySourceStorage = (input: { | |
| 14 | + rootDir: string; | |
| 15 | +}): GoogleDiscoverySourceStorage => ({ | |
| 16 | + get: ({ scopeId, sourceId }) => | |
| 17 | + readJsonFile({ | |
| 18 | + path: pluginSourceStoragePath({ | |
| 19 | + rootDir: input.rootDir, | |
| 20 | + scopeId, | |
| 21 | + sourceId, | |
| 22 | + }), | |
| 23 | + schema: GoogleDiscoveryStoredSourceDataSchema, | |
| 24 | + }), | |
| 25 | + put: ({ scopeId, sourceId, value }) => | |
| 26 | + writeJsonFile({ | |
| 27 | + path: pluginSourceStoragePath({ | |
| 28 | + rootDir: input.rootDir, | |
| 29 | + scopeId, | |
| 30 | + sourceId, | |
| 31 | + }), | |
| 32 | + schema: GoogleDiscoveryStoredSourceDataSchema, | |
| 33 | + value, | |
| 34 | + }), | |
| 35 | + remove: ({ scopeId, sourceId }) => | |
| 36 | + removeJsonFile( | |
| 37 | + pluginSourceStoragePath({ | |
| 38 | + rootDir: input.rootDir, | |
| 39 | + scopeId, | |
| 40 | + sourceId, | |
| 41 | + }), | |
| 42 | + ), | |
| 43 | +}); | |
| @@ -0,0 +1,43 @@ | ||
| 1 | +import type { GraphqlSourceStorage } from "@executor/plugin-graphql-sdk"; | |
| 2 | +import { | |
| 3 | + GraphqlStoredSourceDataSchema, | |
| 4 | +} from "@executor/plugin-graphql-shared"; | |
| 5 | + | |
| 6 | +import { | |
| 7 | + pluginSourceStoragePath, | |
| 8 | + readJsonFile, | |
| 9 | + removeJsonFile, | |
| 10 | + writeJsonFile, | |
| 11 | +} from "./json-file-storage"; | |
| 12 | + | |
| 13 | +export const createFileGraphqlSourceStorage = (input: { | |
| 14 | + rootDir: string; | |
| 15 | +}): GraphqlSourceStorage => ({ | |
| 16 | + get: ({ scopeId, sourceId }) => | |
| 17 | + readJsonFile({ | |
| 18 | + path: pluginSourceStoragePath({ | |
| 19 | + rootDir: input.rootDir, | |
| 20 | + scopeId, | |
| 21 | + sourceId, | |
| 22 | + }), | |
| 23 | + schema: GraphqlStoredSourceDataSchema, | |
| 24 | + }), | |
| 25 | + put: ({ scopeId, sourceId, value }) => | |
| 26 | + writeJsonFile({ | |
| 27 | + path: pluginSourceStoragePath({ | |
| 28 | + rootDir: input.rootDir, | |
| 29 | + scopeId, | |
| 30 | + sourceId, | |
| 31 | + }), | |
| 32 | + schema: GraphqlStoredSourceDataSchema, | |
| 33 | + value, | |
| 34 | + }), | |
| 35 | + remove: ({ scopeId, sourceId }) => | |
| 36 | + removeJsonFile( | |
| 37 | + pluginSourceStoragePath({ | |
| 38 | + rootDir: input.rootDir, | |
| 39 | + scopeId, | |
| 40 | + sourceId, | |
| 41 | + }), | |
| 42 | + ), | |
| 43 | +}); | |
| @@ -8,6 +8,12 @@ import { dirname, extname, resolve } from "node:path"; · 7 unmodified lines | ||
| 8 | 8 | import { Readable } from "node:stream"; |
| 9 | 9 | import { FileSystem, HttpApiBuilder, HttpServer } from "@effect/platform"; |
| 10 | 10 | import { NodeFileSystem } from "@effect/platform-node"; |
| 11 | +import { googleDiscoveryHttpPlugin } from "@executor/plugin-google-discovery-http"; | |
| 12 | +import { googleDiscoverySdkPlugin } from "@executor/plugin-google-discovery-sdk"; | |
| 13 | +import { graphqlHttpPlugin } from "@executor/plugin-graphql-http"; | |
| 14 | +import { graphqlSdkPlugin } from "@executor/plugin-graphql-sdk"; | |
| 15 | +import { mcpHttpPlugin } from "@executor/plugin-mcp-http"; | |
| 16 | +import { mcpSdkPlugin } from "@executor/plugin-mcp-sdk"; | |
| 11 | 17 | import { openApiHttpPlugin } from "@executor/plugin-openapi-http"; |
| 12 | 18 | import { |
| 13 | 19 | openApiSdkPlugin, |
| @@ -48,8 +54,18 @@ import { · 34 unmodified lines | ||
| 48 | 54 | tracingSearchUrl, |
| 49 | 55 | } from "./tracing"; |
| 50 | 56 | import { platformServerEffectError } from "./effect-errors"; |
| 57 | +import { createFileGoogleDiscoveryOAuthSessionStorage } from "./google-discovery-oauth-session-storage"; | |
| 58 | +import { createFileGoogleDiscoverySourceStorage } from "./google-discovery-source-storage"; | |
| 59 | +import { createFileGraphqlSourceStorage } from "./graphql-source-storage"; | |
| 60 | +import { createFileMcpOAuthSessionStorage } from "./mcp-oauth-session-storage"; | |
| 61 | +import { createFileMcpSourceStorage } from "./mcp-source-storage"; | |
| 51 | 62 | import { createFileOpenApiSourceStorage } from "./openapi-source-storage"; |
| 52 | 63 | |
| 64 | +export { createFileGoogleDiscoveryOAuthSessionStorage } from "./google-discovery-oauth-session-storage"; | |
| 65 | +export { createFileGoogleDiscoverySourceStorage } from "./google-discovery-source-storage"; | |
| 66 | +export { createFileGraphqlSourceStorage } from "./graphql-source-storage"; | |
| 67 | +export { createFileMcpOAuthSessionStorage } from "./mcp-oauth-session-storage"; | |
| 68 | +export { createFileMcpSourceStorage } from "./mcp-source-storage"; | |
| 53 | 69 | export { createFileOpenApiSourceStorage } from "./openapi-source-storage"; |
| 54 | 70 | |
| 55 | 71 | export { |
| @@ -114,7 +130,12 @@ type ExecutorMcpHandler = ReturnType<typeof createExecutorMcpRequestHandler>; · 58 unmodified lines | ||
| 114 | 130 | const EXECUTOR_NPM_DIST_TAGS_PATHNAME = "/v1/app/npm/dist-tags"; |
| 115 | 131 | const EXECUTOR_NPM_DIST_TAGS_URL = |
| 116 | 132 | "https://registry.npmjs.org/-/package/executor/dist-tags"; |
| 117 | −const executorHttpPlugins = [openApiHttpPlugin()] as const; | |
| 133 | +const executorHttpPlugins = [ | |
| 134 | + graphqlHttpPlugin(), | |
| 135 | + googleDiscoveryHttpPlugin(), | |
| 136 | + mcpHttpPlugin(), | |
| 137 | + openApiHttpPlugin(), | |
| 138 | +] as const; | |
| 118 | 139 | |
| 119 | 140 | const disposeExecutor = (executor: Executor) => |
| 120 | 141 | Effect.tryPromise({ |
| @@ -134,6 +155,27 @@ const createExecutorRuntime = ( · 13 unmodified lines | ||
| 134 | 155 | localDataDir, |
| 135 | 156 | }), |
| 136 | 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 | 179 | openApiSdkPlugin({ |
| 138 | 180 | storage: createFileOpenApiSourceStorage({ |
| 139 | 181 | rootDir: resolve(localDataDir, "plugins", "openapi", "sources"), |
| @@ -0,0 +1,79 @@ | ||
| 1 | +import { dirname, join } from "node:path"; | |
| 2 | +import { FileSystem } from "@effect/platform"; | |
| 3 | +import { NodeFileSystem } from "@effect/platform-node"; | |
| 4 | +import * as Effect from "effect/Effect"; | |
| 5 | +import * as Schema from "effect/Schema"; | |
| 6 | + | |
| 7 | +const toError = (cause: unknown): Error => | |
| 8 | + cause instanceof Error ? cause : new Error(String(cause)); | |
| 9 | + | |
| 10 | +const bindNodeFileSystem = <A, E>( | |
| 11 | + effect: Effect.Effect<A, E, FileSystem.FileSystem>, | |
| 12 | +): Effect.Effect<A, E, never> => | |
| 13 | + effect.pipe(Effect.provide(NodeFileSystem.layer)); | |
| 14 | + | |
| 15 | +export const readJsonFile = <TSchema extends Schema.Schema<any, any, never>>(input: { | |
| 16 | + path: string; | |
| 17 | + schema: TSchema; | |
| 18 | +}): Effect.Effect<Schema.Schema.Type<TSchema> | null, Error, never> => { | |
| 19 | + const decode = Schema.decodeUnknownSync(input.schema); | |
| 20 | + | |
| 21 | + return bindNodeFileSystem( | |
| 22 | + Effect.gen(function* () { | |
| 23 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 24 | + const exists = yield* fileSystem.exists(input.path).pipe(Effect.mapError(toError)); | |
| 25 | + if (!exists) { | |
| 26 | + return null; | |
| 27 | + } | |
| 28 | + | |
| 29 | + const contents = yield* fileSystem.readFileString(input.path, "utf8").pipe( | |
| 30 | + Effect.mapError(toError), | |
| 31 | + ); | |
| 32 | + | |
| 33 | + return decode(JSON.parse(contents)); | |
| 34 | + }), | |
| 35 | + ); | |
| 36 | +}; | |
| 37 | + | |
| 38 | +export const writeJsonFile = <TSchema extends Schema.Schema<any, any, never>>(input: { | |
| 39 | + path: string; | |
| 40 | + schema: TSchema; | |
| 41 | + value: Schema.Schema.Type<TSchema>; | |
| 42 | +}): Effect.Effect<void, Error, never> => { | |
| 43 | + const encode = Schema.encodeSync(input.schema); | |
| 44 | + | |
| 45 | + return bindNodeFileSystem( | |
| 46 | + Effect.gen(function* () { | |
| 47 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 48 | + yield* fileSystem.makeDirectory(dirname(input.path), { | |
| 49 | + recursive: true, | |
| 50 | + }).pipe(Effect.mapError(toError)); | |
| 51 | + yield* fileSystem.writeFileString( | |
| 52 | + input.path, | |
| 53 | + `${JSON.stringify(encode(input.value), null, 2)}\n`, | |
| 54 | + ).pipe(Effect.mapError(toError)); | |
| 55 | + }), | |
| 56 | + ); | |
| 57 | +}; | |
| 58 | + | |
| 59 | +export const removeJsonFile = (path: string): Effect.Effect<void, Error, never> => | |
| 60 | + bindNodeFileSystem( | |
| 61 | + Effect.gen(function* () { | |
| 62 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 63 | + const exists = yield* fileSystem.exists(path).pipe(Effect.mapError(toError)); | |
| 64 | + if (exists) { | |
| 65 | + yield* fileSystem.remove(path).pipe(Effect.mapError(toError)); | |
| 66 | + } | |
| 67 | + }), | |
| 68 | + ); | |
| 69 | + | |
| 70 | +export const pluginSourceStoragePath = (input: { | |
| 71 | + rootDir: string; | |
| 72 | + scopeId: string; | |
| 73 | + sourceId: string; | |
| 74 | +}) => join(input.rootDir, input.scopeId, `${input.sourceId}.json`); | |
| 75 | + | |
| 76 | +export const pluginSessionStoragePath = (input: { | |
| 77 | + rootDir: string; | |
| 78 | + sessionId: string; | |
| 79 | +}) => join(input.rootDir, `${input.sessionId}.json`); | |
| @@ -0,0 +1,40 @@ | ||
| 1 | +import type { McpOAuthSessionStorage } from "@executor/plugin-mcp-sdk"; | |
| 2 | +import { | |
| 3 | + McpOAuthSessionSchema, | |
| 4 | +} from "@executor/plugin-mcp-shared"; | |
| 5 | + | |
| 6 | +import { | |
| 7 | + pluginSessionStoragePath, | |
| 8 | + readJsonFile, | |
| 9 | + removeJsonFile, | |
| 10 | + writeJsonFile, | |
| 11 | +} from "./json-file-storage"; | |
| 12 | + | |
| 13 | +export const createFileMcpOAuthSessionStorage = (input: { | |
| 14 | + rootDir: string; | |
| 15 | +}): McpOAuthSessionStorage => ({ | |
| 16 | + get: (sessionId) => | |
| 17 | + readJsonFile({ | |
| 18 | + path: pluginSessionStoragePath({ | |
| 19 | + rootDir: input.rootDir, | |
| 20 | + sessionId, | |
| 21 | + }), | |
| 22 | + schema: McpOAuthSessionSchema, | |
| 23 | + }), | |
| 24 | + put: ({ sessionId, value }) => | |
| 25 | + writeJsonFile({ | |
| 26 | + path: pluginSessionStoragePath({ | |
| 27 | + rootDir: input.rootDir, | |
| 28 | + sessionId, | |
| 29 | + }), | |
| 30 | + schema: McpOAuthSessionSchema, | |
| 31 | + value, | |
| 32 | + }), | |
| 33 | + remove: (sessionId) => | |
| 34 | + removeJsonFile( | |
| 35 | + pluginSessionStoragePath({ | |
| 36 | + rootDir: input.rootDir, | |
| 37 | + sessionId, | |
| 38 | + }), | |
| 39 | + ), | |
| 40 | +}); | |
| @@ -0,0 +1,43 @@ | ||
| 1 | +import type { McpSourceStorage } from "@executor/plugin-mcp-sdk"; | |
| 2 | +import { | |
| 3 | + McpStoredSourceDataSchema, | |
| 4 | +} from "@executor/plugin-mcp-shared"; | |
| 5 | + | |
| 6 | +import { | |
| 7 | + pluginSourceStoragePath, | |
| 8 | + readJsonFile, | |
| 9 | + removeJsonFile, | |
| 10 | + writeJsonFile, | |
| 11 | +} from "./json-file-storage"; | |
| 12 | + | |
| 13 | +export const createFileMcpSourceStorage = (input: { | |
| 14 | + rootDir: string; | |
| 15 | +}): McpSourceStorage => ({ | |
| 16 | + get: ({ scopeId, sourceId }) => | |
| 17 | + readJsonFile({ | |
| 18 | + path: pluginSourceStoragePath({ | |
| 19 | + rootDir: input.rootDir, | |
| 20 | + scopeId, | |
| 21 | + sourceId, | |
| 22 | + }), | |
| 23 | + schema: McpStoredSourceDataSchema, | |
| 24 | + }), | |
| 25 | + put: ({ scopeId, sourceId, value }) => | |
| 26 | + writeJsonFile({ | |
| 27 | + path: pluginSourceStoragePath({ | |
| 28 | + rootDir: input.rootDir, | |
| 29 | + scopeId, | |
| 30 | + sourceId, | |
| 31 | + }), | |
| 32 | + schema: McpStoredSourceDataSchema, | |
| 33 | + value, | |
| 34 | + }), | |
| 35 | + remove: ({ scopeId, sourceId }) => | |
| 36 | + removeJsonFile( | |
| 37 | + pluginSourceStoragePath({ | |
| 38 | + rootDir: input.rootDir, | |
| 39 | + scopeId, | |
| 40 | + sourceId, | |
| 41 | + }), | |
| 42 | + ), | |
| 43 | +}); | |
| @@ -0,0 +1,316 @@ | ||
| 1 | +import { | |
| 2 | + HttpApi, | |
| 3 | + HttpApiBuilder, | |
| 4 | + HttpApiEndpoint, | |
| 5 | + HttpApiGroup, | |
| 6 | + HttpApiSchema, | |
| 7 | + HttpServerResponse, | |
| 8 | +} from "@effect/platform"; | |
| 9 | +import { | |
| 10 | + ControlPlaneBadRequestError, | |
| 11 | + ControlPlaneForbiddenError, | |
| 12 | + ControlPlaneNotFoundError, | |
| 13 | + ControlPlaneStorageError, | |
| 14 | + type ExecutorHttpApiExtension, | |
| 15 | + type ExecutorHttpPlugin, | |
| 16 | +} from "@executor/platform-api"; | |
| 17 | +import { resolveRequestedLocalWorkspace } from "@executor/platform-api/local-context"; | |
| 18 | +import { | |
| 19 | + ScopeIdSchema, | |
| 20 | + SourceIdSchema, | |
| 21 | + SourceSchema, | |
| 22 | + type Source, | |
| 23 | +} from "@executor/platform-sdk/schema"; | |
| 24 | +import * as Effect from "effect/Effect"; | |
| 25 | +import * as Schema from "effect/Schema"; | |
| 26 | + | |
| 27 | +import { | |
| 28 | + GoogleDiscoveryConnectInputSchema, | |
| 29 | + GoogleDiscoveryOAuthPopupResultSchema, | |
| 30 | + GoogleDiscoverySourceConfigPayloadSchema, | |
| 31 | + GoogleDiscoveryStartOAuthInputSchema, | |
| 32 | + GoogleDiscoveryStartOAuthResultSchema, | |
| 33 | + type GoogleDiscoveryConnectInput, | |
| 34 | + type GoogleDiscoveryOAuthPopupResult, | |
| 35 | + type GoogleDiscoverySourceConfigPayload, | |
| 36 | + type GoogleDiscoveryStartOAuthInput, | |
| 37 | + type GoogleDiscoveryStartOAuthResult, | |
| 38 | + type GoogleDiscoveryUpdateSourceInput, | |
| 39 | +} from "@executor/plugin-google-discovery-shared"; | |
| 40 | + | |
| 41 | +type GoogleDiscoveryExecutorExtension = { | |
| 42 | + googleDiscovery: { | |
| 43 | + createSource: ( | |
| 44 | + input: GoogleDiscoveryConnectInput, | |
| 45 | + ) => Effect.Effect<Source, Error>; | |
| 46 | + getSourceConfig: ( | |
| 47 | + sourceId: Source["id"], | |
| 48 | + ) => Effect.Effect<GoogleDiscoverySourceConfigPayload, Error>; | |
| 49 | + updateSource: ( | |
| 50 | + input: GoogleDiscoveryUpdateSourceInput, | |
| 51 | + ) => Effect.Effect<Source, Error>; | |
| 52 | + removeSource: ( | |
| 53 | + sourceId: Source["id"], | |
| 54 | + ) => Effect.Effect<boolean, Error>; | |
| 55 | + startOAuth: ( | |
| 56 | + input: GoogleDiscoveryStartOAuthInput, | |
| 57 | + ) => Effect.Effect<GoogleDiscoveryStartOAuthResult, Error>; | |
| 58 | + completeOAuth: (input: { | |
| 59 | + state: string; | |
| 60 | + code?: string; | |
| 61 | + error?: string; | |
| 62 | + errorDescription?: string; | |
| 63 | + }) => Effect.Effect<Extract<GoogleDiscoveryOAuthPopupResult, { ok: true }>, Error>; | |
| 64 | + }; | |
| 65 | +}; | |
| 66 | + | |
| 67 | +const workspaceIdParam = HttpApiSchema.param("workspaceId", ScopeIdSchema); | |
| 68 | +const sourceIdParam = HttpApiSchema.param("sourceId", SourceIdSchema); | |
| 69 | +const htmlSchema = HttpApiSchema.Text({ | |
| 70 | + contentType: "text/html", | |
| 71 | +}); | |
| 72 | + | |
| 73 | +const callbackParamsSchema = Schema.Struct({ | |
| 74 | + state: Schema.String, | |
| 75 | + code: Schema.optional(Schema.String), | |
| 76 | + error: Schema.optional(Schema.String), | |
| 77 | + error_description: Schema.optional(Schema.String), | |
| 78 | +}); | |
| 79 | + | |
| 80 | +export const GoogleDiscoveryHttpGroup = HttpApiGroup.make("googleDiscovery") | |
| 81 | + .add( | |
| 82 | + HttpApiEndpoint.post("createSource")`/workspaces/${workspaceIdParam}/plugins/google-discovery/sources` | |
| 83 | + .setPayload(GoogleDiscoveryConnectInputSchema) | |
| 84 | + .addSuccess(SourceSchema) | |
| 85 | + .addError(ControlPlaneBadRequestError) | |
| 86 | + .addError(ControlPlaneForbiddenError) | |
| 87 | + .addError(ControlPlaneStorageError), | |
| 88 | + ) | |
| 89 | + .add( | |
| 90 | + HttpApiEndpoint.get("getSourceConfig")`/workspaces/${workspaceIdParam}/plugins/google-discovery/sources/${sourceIdParam}` | |
| 91 | + .addSuccess(GoogleDiscoverySourceConfigPayloadSchema) | |
| 92 | + .addError(ControlPlaneBadRequestError) | |
| 93 | + .addError(ControlPlaneForbiddenError) | |
| 94 | + .addError(ControlPlaneNotFoundError) | |
| 95 | + .addError(ControlPlaneStorageError), | |
| 96 | + ) | |
| 97 | + .add( | |
| 98 | + HttpApiEndpoint.put("updateSource")`/workspaces/${workspaceIdParam}/plugins/google-discovery/sources/${sourceIdParam}` | |
| 99 | + .setPayload(GoogleDiscoverySourceConfigPayloadSchema) | |
| 100 | + .addSuccess(SourceSchema) | |
| 101 | + .addError(ControlPlaneBadRequestError) | |
| 102 | + .addError(ControlPlaneForbiddenError) | |
| 103 | + .addError(ControlPlaneNotFoundError) | |
| 104 | + .addError(ControlPlaneStorageError), | |
| 105 | + ) | |
| 106 | + .add( | |
| 107 | + HttpApiEndpoint.del("removeSource")`/workspaces/${workspaceIdParam}/plugins/google-discovery/sources/${sourceIdParam}` | |
| 108 | + .addSuccess(Schema.Struct({ removed: Schema.Boolean })) | |
| 109 | + .addError(ControlPlaneBadRequestError) | |
| 110 | + .addError(ControlPlaneForbiddenError) | |
| 111 | + .addError(ControlPlaneNotFoundError) | |
| 112 | + .addError(ControlPlaneStorageError), | |
| 113 | + ) | |
| 114 | + .add( | |
| 115 | + HttpApiEndpoint.post("startOAuth")`/workspaces/${workspaceIdParam}/plugins/google-discovery/oauth/start` | |
| 116 | + .setPayload(GoogleDiscoveryStartOAuthInputSchema) | |
| 117 | + .addSuccess(GoogleDiscoveryStartOAuthResultSchema) | |
| 118 | + .addError(ControlPlaneBadRequestError) | |
| 119 | + .addError(ControlPlaneForbiddenError) | |
| 120 | + .addError(ControlPlaneStorageError), | |
| 121 | + ) | |
| 122 | + .add( | |
| 123 | + HttpApiEndpoint.get("oauthCallback")`/plugins/google-discovery/oauth/callback` | |
| 124 | + .setUrlParams(callbackParamsSchema) | |
| 125 | + .addSuccess(htmlSchema) | |
| 126 | + .addError(ControlPlaneBadRequestError) | |
| 127 | + .addError(ControlPlaneStorageError), | |
| 128 | + ) | |
| 129 | + .prefix("/v1"); | |
| 130 | + | |
| 131 | +export const googleDiscoveryHttpApiExtension = { | |
| 132 | + key: "googleDiscovery", | |
| 133 | + group: GoogleDiscoveryHttpGroup, | |
| 134 | +} satisfies ExecutorHttpApiExtension<typeof GoogleDiscoveryHttpGroup>; | |
| 135 | + | |
| 136 | +const GoogleDiscoveryHttpApi = HttpApi.make("executor").add(GoogleDiscoveryHttpGroup); | |
| 137 | + | |
| 138 | +const toBadRequestError = (operation: string, cause: unknown) => | |
| 139 | + new ControlPlaneBadRequestError({ | |
| 140 | + operation, | |
| 141 | + message: cause instanceof Error ? cause.message : String(cause), | |
| 142 | + details: cause instanceof Error ? cause.stack ?? cause.message : String(cause), | |
| 143 | + }); | |
| 144 | + | |
| 145 | +const toStorageError = (operation: string, cause: unknown) => | |
| 146 | + new ControlPlaneStorageError({ | |
| 147 | + operation, | |
| 148 | + message: cause instanceof Error ? cause.message : String(cause), | |
| 149 | + details: cause instanceof Error ? cause.stack ?? cause.message : String(cause), | |
| 150 | + }); | |
| 151 | + | |
| 152 | +const toNotFoundError = (operation: string, cause: unknown) => | |
| 153 | + new ControlPlaneNotFoundError({ | |
| 154 | + operation, | |
| 155 | + message: cause instanceof Error ? cause.message : String(cause), | |
| 156 | + details: cause instanceof Error ? cause.stack ?? cause.message : String(cause), | |
| 157 | + }); | |
| 158 | + | |
| 159 | +const mapPluginStorageError = (operation: string, cause: unknown) => { | |
| 160 | + const message = cause instanceof Error ? cause.message : String(cause); | |
| 161 | + if (message.includes("not found") || message.includes("Not found")) { | |
| 162 | + return toNotFoundError(operation, cause); | |
| 163 | + } | |
| 164 | + | |
| 165 | + return toStorageError(operation, cause); | |
| 166 | +}; | |
| 167 | + | |
| 168 | +const escapeHtml = (value: string): string => | |
| 169 | + value | |
| 170 | + .replaceAll("&", "&") | |
| 171 | + .replaceAll("<", "<") | |
| 172 | + .replaceAll(">", ">") | |
| 173 | + .replaceAll('"', """) | |
| 174 | + .replaceAll("'", "'"); | |
| 175 | + | |
| 176 | +const popupDocument = (payload: GoogleDiscoveryOAuthPopupResult): string => { | |
| 177 | + const serialized = JSON.stringify(payload) | |
| 178 | + .replaceAll("<", "\\u003c") | |
| 179 | + .replaceAll(">", "\\u003e") | |
| 180 | + .replaceAll("&", "\\u0026"); | |
| 181 | + const title = payload.ok ? "Google OAuth connected" : "Google OAuth failed"; | |
| 182 | + const message = payload.ok | |
| 183 | + ? "Google credentials are ready. Return to the source form to finish saving." | |
| 184 | + : payload.error; | |
| 185 | + | |
| 186 | + return `<!doctype html> | |
| 187 | +<html lang="en"> | |
| 188 | + <head> | |
| 189 | + <meta charset="utf-8" /> | |
| 190 | + <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| 191 | + <title>${escapeHtml(title)}</title> | |
| 192 | + </head> | |
| 193 | + <body> | |
| 194 | + <main> | |
| 195 | + <h1>${escapeHtml(title)}</h1> | |
| 196 | + <p>${escapeHtml(message)}</p> | |
| 197 | + </main> | |
| 198 | + <script> | |
| 199 | + (() => { | |
| 200 | + const payload = ${serialized}; | |
| 201 | + try { | |
| 202 | + window.localStorage.setItem("executor:google-discovery-oauth:" + (payload.ok ? payload.sessionId : "failed"), JSON.stringify(payload)); | |
| 203 | + } catch {} | |
| 204 | + try { | |
| 205 | + if (window.opener) { | |
| 206 | + window.opener.postMessage(payload, window.location.origin); | |
| 207 | + } | |
| 208 | + } finally { | |
| 209 | + window.setTimeout(() => window.close(), 120); | |
| 210 | + } | |
| 211 | + })(); | |
| 212 | + </script> | |
| 213 | + </body> | |
| 214 | +</html>`; | |
| 215 | +}; | |
| 216 | + | |
| 217 | +export const googleDiscoveryHttpPlugin = (): ExecutorHttpPlugin< | |
| 218 | + typeof GoogleDiscoveryHttpGroup, | |
| 219 | + GoogleDiscoveryExecutorExtension | |
| 220 | +> => ({ | |
| 221 | + key: "googleDiscovery", | |
| 222 | + group: GoogleDiscoveryHttpGroup, | |
| 223 | + build: ({ executor }) => | |
| 224 | + HttpApiBuilder.group(GoogleDiscoveryHttpApi, "googleDiscovery", (handlers) => | |
| 225 | + handlers | |
| 226 | + .handle("createSource", ({ path, payload }) => | |
| 227 | + resolveRequestedLocalWorkspace( | |
| 228 | + "googleDiscovery.createSource", | |
| 229 | + path.workspaceId, | |
| 230 | + ).pipe( | |
| 231 | + Effect.flatMap(() => executor.googleDiscovery.createSource(payload)), | |
| 232 | + Effect.mapError((cause) => | |
| 233 | + toStorageError("googleDiscovery.createSource", cause) | |
| 234 | + ), | |
| 235 | + ) | |
| 236 | + ) | |
| 237 | + .handle("getSourceConfig", ({ path }) => | |
| 238 | + resolveRequestedLocalWorkspace( | |
| 239 | + "googleDiscovery.getSourceConfig", | |
| 240 | + path.workspaceId, | |
| 241 | + ).pipe( | |
| 242 | + Effect.flatMap(() => | |
| 243 | + executor.googleDiscovery.getSourceConfig(path.sourceId) | |
| 244 | + ), | |
| 245 | + Effect.mapError((cause) => | |
| 246 | + mapPluginStorageError("googleDiscovery.getSourceConfig", cause) | |
| 247 | + ), | |
| 248 | + ) | |
| 249 | + ) | |
| 250 | + .handle("updateSource", ({ path, payload }) => | |
| 251 | + resolveRequestedLocalWorkspace( | |
| 252 | + "googleDiscovery.updateSource", | |
| 253 | + path.workspaceId, | |
| 254 | + ).pipe( | |
| 255 | + Effect.flatMap(() => | |
| 256 | + executor.googleDiscovery.updateSource({ | |
| 257 | + sourceId: path.sourceId, | |
| 258 | + config: payload, | |
| 259 | + }) | |
| 260 | + ), | |
| 261 | + Effect.mapError((cause) => | |
| 262 | + mapPluginStorageError("googleDiscovery.updateSource", cause) | |
| 263 | + ), | |
| 264 | + ) | |
| 265 | + ) | |
| 266 | + .handle("removeSource", ({ path }) => | |
| 267 | + resolveRequestedLocalWorkspace( | |
| 268 | + "googleDiscovery.removeSource", | |
| 269 | + path.workspaceId, | |
| 270 | + ).pipe( | |
| 271 | + Effect.flatMap(() => | |
| 272 | + executor.googleDiscovery.removeSource(path.sourceId) | |
| 273 | + ), | |
| 274 | + Effect.map((removed) => ({ removed })), | |
| 275 | + Effect.mapError((cause) => | |
| 276 | + mapPluginStorageError("googleDiscovery.removeSource", cause) | |
| 277 | + ), | |
| 278 | + ) | |
| 279 | + ) | |
| 280 | + .handle("startOAuth", ({ path, payload }) => | |
| 281 | + resolveRequestedLocalWorkspace( | |
| 282 | + "googleDiscovery.startOAuth", | |
| 283 | + path.workspaceId, | |
| 284 | + ).pipe( | |
| 285 | + Effect.flatMap(() => executor.googleDiscovery.startOAuth(payload)), | |
| 286 | + Effect.mapError((cause) => | |
| 287 | + toStorageError("googleDiscovery.startOAuth", cause) | |
| 288 | + ), | |
| 289 | + ) | |
| 290 | + ) | |
| 291 | + .handle("oauthCallback", ({ urlParams }) => | |
| 292 | + executor.googleDiscovery.completeOAuth({ | |
| 293 | + state: urlParams.state, | |
| 294 | + code: urlParams.code, | |
| 295 | + error: urlParams.error, | |
| 296 | + errorDescription: urlParams.error_description, | |
| 297 | + }).pipe( | |
| 298 | + Effect.map((payload) => popupDocument(payload)), | |
| 299 | + Effect.mapError((cause) => | |
| 300 | + toStorageError("googleDiscovery.oauthCallback", cause) | |
| 301 | + ), | |
| 302 | + Effect.catchAll((error) => | |
| 303 | + Effect.succeed( | |
| 304 | + popupDocument({ | |
| 305 | + type: "executor:oauth-result", | |
| 306 | + ok: false, | |
| 307 | + sessionId: null, | |
| 308 | + error: error.message, | |
| 309 | + }), | |
| 310 | + ) | |
| 311 | + ), | |
| 312 | + Effect.flatMap((html) => HttpServerResponse.html(html)), | |
| 313 | + ) | |
| 314 | + ) | |
| 315 | + ), | |
| 316 | +}); | |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -0,0 +1,792 @@ | ||
| 1 | +import { startTransition, useMemo, useState, type ReactNode } from "react"; | |
| 2 | +import { useNavigate } from "@tanstack/react-router"; | |
| 3 | +import type { Source } from "@executor/react"; | |
| 4 | +import { | |
| 5 | + defineExecutorPluginHttpApiClient, | |
| 6 | + Result, | |
| 7 | + useAtomSet, | |
| 8 | + useAtomValue, | |
| 9 | + useExecutorMutation, | |
| 10 | + useLocalInstallation, | |
| 11 | + useSecrets, | |
| 12 | +} from "@executor/react"; | |
| 13 | + | |
| 14 | +import { | |
| 15 | + googleDiscoveryHttpApiExtension, | |
| 16 | +} from "@executor/plugin-google-discovery-http"; | |
| 17 | +import { | |
| 18 | + defaultGoogleDiscoveryUrl, | |
| 19 | + type GoogleDiscoveryConnectInput, | |
| 20 | + type GoogleDiscoveryConnectionAuth, | |
| 21 | + type GoogleDiscoveryOAuthPopupResult, | |
| 22 | + type GoogleDiscoveryStartOAuthInput, | |
| 23 | +} from "@executor/plugin-google-discovery-shared"; | |
| 24 | +import { IconPencil } from "../../../apps/web/src/components/icons"; | |
| 25 | +import { SourceToolExplorer } from "../../../apps/web/src/source-plugins/tool-explorer"; | |
| 26 | + | |
| 27 | +type FrontendSourceTypeDefinition = { | |
| 28 | + kind: string; | |
| 29 | + displayName: string; | |
| 30 | + description?: string; | |
| 31 | + renderAddPage: () => ReactNode; | |
| 32 | + renderEditPage?: (input: { source: Source }) => ReactNode; | |
| 33 | + renderDetailPage?: (input: { | |
| 34 | + source: Source; | |
| 35 | + route: { | |
| 36 | + search?: unknown; | |
| 37 | + navigate?: unknown; | |
| 38 | + }; | |
| 39 | + }) => ReactNode; | |
| 40 | +}; | |
| 41 | + | |
| 42 | +type FrontendPluginRegisterApi = { | |
| 43 | + sources: { | |
| 44 | + registerType: (definition: FrontendSourceTypeDefinition) => void; | |
| 45 | + }; | |
| 46 | +}; | |
| 47 | + | |
| 48 | +type RouteToolSearch = { | |
| 49 | + tab?: "model" | "discover"; | |
| 50 | + tool?: string; | |
| 51 | + query?: string; | |
| 52 | +}; | |
| 53 | + | |
| 54 | +const OAUTH_STORAGE_PREFIX = "executor:google-discovery-oauth:"; | |
| 55 | +const OAUTH_TIMEOUT_MS = 2 * 60_000; | |
| 56 | + | |
| 57 | +const getGoogleDiscoveryHttpClient = | |
| 58 | + defineExecutorPluginHttpApiClient<"GoogleDiscoveryReactHttpClient">()( | |
| 59 | + "GoogleDiscoveryReactHttpClient", | |
| 60 | + [googleDiscoveryHttpApiExtension] as const, | |
| 61 | + ); | |
| 62 | + | |
| 63 | +const defaultGoogleInput = (): GoogleDiscoveryConnectInput => ({ | |
| 64 | + name: "Google Sheets", | |
| 65 | + service: "sheets", | |
| 66 | + version: "v4", | |
| 67 | + discoveryUrl: defaultGoogleDiscoveryUrl("sheets", "v4"), | |
| 68 | + defaultHeaders: null, | |
| 69 | + scopes: [], | |
| 70 | + auth: { | |
| 71 | + kind: "none", | |
| 72 | + }, | |
| 73 | +}); | |
| 74 | + | |
| 75 | +const stringifyStringMap = ( | |
| 76 | + value: Record<string, string> | null | undefined, | |
| 77 | +): string => | |
| 78 | + !value || Object.keys(value).length === 0 | |
| 79 | + ? "" | |
| 80 | + : JSON.stringify(value, null, 2); | |
| 81 | + | |
| 82 | +const parseStringMap = (value: string): Record<string, string> | null => { | |
| 83 | + const trimmed = value.trim(); | |
| 84 | + if (!trimmed) { | |
| 85 | + return null; | |
| 86 | + } | |
| 87 | + | |
| 88 | + const parsed = JSON.parse(trimmed) as unknown; | |
| 89 | + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { | |
| 90 | + throw new Error("Headers must be a JSON object."); | |
| 91 | + } | |
| 92 | + | |
| 93 | + const entries = Object.entries(parsed as Record<string, unknown>); | |
| 94 | + if (!entries.every(([, entry]) => typeof entry === "string")) { | |
| 95 | + throw new Error("All header values must be strings."); | |
| 96 | + } | |
| 97 | + | |
| 98 | + return Object.fromEntries(entries as Array<[string, string]>); | |
| 99 | +}; | |
| 100 | + | |
| 101 | +const stringifyScopes = (value: ReadonlyArray<string>): string => | |
| 102 | + value.join("\n"); | |
| 103 | + | |
| 104 | +const parseScopes = (value: string): Array<string> => | |
| 105 | + value | |
| 106 | + .split(/[\n,]/) | |
| 107 | + .map((entry) => entry.trim()) | |
| 108 | + .filter(Boolean); | |
| 109 | + | |
| 110 | +const authSecretValue = ( | |
| 111 | + input: GoogleDiscoveryConnectionAuth, | |
| 112 | +): string => | |
| 113 | + input.kind === "bearer" | |
| 114 | + ? JSON.stringify(input.tokenSecretRef) | |
| 115 | + : ""; | |
| 116 | + | |
| 117 | +const clientSecretValue = ( | |
| 118 | + input: GoogleDiscoveryConnectionAuth, | |
| 119 | +): string => | |
| 120 | + input.kind === "oauth2" && input.clientSecretRef | |
| 121 | + ? JSON.stringify(input.clientSecretRef) | |
| 122 | + : ""; | |
| 123 | + | |
| 124 | +const waitForOauthPopupResult = async ( | |
| 125 | + sessionId: string, | |
| 126 | +): Promise<GoogleDiscoveryOAuthPopupResult> => | |
| 127 | + new Promise((resolve, reject) => { | |
| 128 | + const storageKey = `${OAUTH_STORAGE_PREFIX}${sessionId}`; | |
| 129 | + const startedAt = Date.now(); | |
| 130 | + | |
| 131 | + const cleanup = () => { | |
| 132 | + window.removeEventListener("message", handleMessage); | |
| 133 | + window.clearInterval(intervalId); | |
| 134 | + }; | |
| 135 | + | |
| 136 | + const finish = (result: GoogleDiscoveryOAuthPopupResult) => { | |
| 137 | + cleanup(); | |
| 138 | + try { | |
| 139 | + window.localStorage.removeItem(storageKey); | |
| 140 | + } catch {} | |
| 141 | + resolve(result); | |
| 142 | + }; | |
| 143 | + | |
| 144 | + const handleMessage = (event: MessageEvent) => { | |
| 145 | + if (event.origin !== window.location.origin) { | |
| 146 | + return; | |
| 147 | + } | |
| 148 | + | |
| 149 | + const data = event.data as GoogleDiscoveryOAuthPopupResult | undefined; | |
| 150 | + if (!data || data.type !== "executor:oauth-result") { | |
| 151 | + return; | |
| 152 | + } | |
| 153 | + | |
| 154 | + if (data.ok && data.sessionId !== sessionId) { | |
| 155 | + return; | |
| 156 | + } | |
| 157 | + | |
| 158 | + finish(data); | |
| 159 | + }; | |
| 160 | + | |
| 161 | + window.addEventListener("message", handleMessage); | |
| 162 | + const intervalId = window.setInterval(() => { | |
| 163 | + if (Date.now() - startedAt > OAUTH_TIMEOUT_MS) { | |
| 164 | + cleanup(); | |
| 165 | + reject(new Error("Timed out waiting for Google OAuth to finish.")); | |
| 166 | + return; | |
| 167 | + } | |
| 168 | + | |
| 169 | + try { | |
| 170 | + const raw = window.localStorage.getItem(storageKey); | |
| 171 | + if (!raw) { | |
| 172 | + return; | |
| 173 | + } | |
| 174 | + | |
| 175 | + finish(JSON.parse(raw) as GoogleDiscoveryOAuthPopupResult); | |
| 176 | + } catch { | |
| 177 | + // Ignore malformed local storage and continue polling. | |
| 178 | + } | |
| 179 | + }, 400); | |
| 180 | + }); | |
| 181 | + | |
| 182 | +const navigateFromPluginRoute = ( | |
| 183 | + navigate: unknown, | |
| 184 | + next: { | |
| 185 | + tab: "model" | "discover"; | |
| 186 | + tool?: string; | |
| 187 | + query?: string; | |
| 188 | + }, | |
| 189 | +) => { | |
| 190 | + const routeNavigate = navigate as | |
| 191 | + | ((input: { | |
| 192 | + search: { | |
| 193 | + tab: "model" | "discover"; | |
| 194 | + tool?: string; | |
| 195 | + query?: string; | |
| 196 | + }; | |
| 197 | + }) => void | Promise<void>) | |
| 198 | + | undefined; | |
| 199 | + if (routeNavigate) { | |
| 200 | + void routeNavigate({ | |
| 201 | + search: next, | |
| 202 | + }); | |
| 203 | + } | |
| 204 | +}; | |
| 205 | + | |
| 206 | +function GoogleDiscoverySourceForm(props: { | |
| 207 | + initialValue: GoogleDiscoveryConnectInput; | |
| 208 | + mode: "create" | "edit"; | |
| 209 | + onSubmit: (input: GoogleDiscoveryConnectInput) => Promise<void>; | |
| 210 | +}) { | |
| 211 | + const installation = useLocalInstallation(); | |
| 212 | + const client = getGoogleDiscoveryHttpClient(); | |
| 213 | + const secrets = useSecrets(); | |
| 214 | + const startOAuth = useAtomSet( | |
| 215 | + client.mutation("googleDiscovery", "startOAuth"), | |
| 216 | + { mode: "promise" }, | |
| 217 | + ); | |
| 218 | + const submitMutation = useExecutorMutation<GoogleDiscoveryConnectInput, void>(props.onSubmit); | |
| 219 | + const [name, setName] = useState(props.initialValue.name); | |
| 220 | + const [service, setService] = useState(props.initialValue.service); | |
| 221 | + const [version, setVersion] = useState(props.initialValue.version); | |
| 222 | + const [discoveryUrl, setDiscoveryUrl] = useState(props.initialValue.discoveryUrl ?? ""); | |
| 223 | + const [headersText, setHeadersText] = useState( | |
| 224 | + stringifyStringMap(props.initialValue.defaultHeaders), | |
| 225 | + ); | |
| 226 | + const [scopesText, setScopesText] = useState( | |
| 227 | + stringifyScopes(props.initialValue.scopes), | |
| 228 | + ); | |
| 229 | + const [authKind, setAuthKind] = useState<GoogleDiscoveryConnectionAuth["kind"]>( | |
| 230 | + props.initialValue.auth.kind, | |
| 231 | + ); | |
| 232 | + const [bearerSecretRef, setBearerSecretRef] = useState( | |
| 233 | + authSecretValue(props.initialValue.auth), | |
| 234 | + ); | |
| 235 | + const [clientId, setClientId] = useState( | |
| 236 | + props.initialValue.auth.kind === "oauth2" ? props.initialValue.auth.clientId : "", | |
| 237 | + ); | |
| 238 | + const [clientSecretRef, setClientSecretRef] = useState( | |
| 239 | + clientSecretValue(props.initialValue.auth), | |
| 240 | + ); | |
| 241 | + const [oauthAuth, setOauthAuth] = useState< | |
| 242 | + Extract<GoogleDiscoveryConnectionAuth, { kind: "oauth2" }> | null | |
| 243 | + >(props.initialValue.auth.kind === "oauth2" ? props.initialValue.auth : null); | |
| 244 | + const [error, setError] = useState<string | null>(null); | |
| 245 | + const [oauthStatus, setOauthStatus] = useState<"idle" | "pending" | "connected">( | |
| 246 | + props.initialValue.auth.kind === "oauth2" ? "connected" : "idle", | |
| 247 | + ); | |
| 248 | + | |
| 249 | + const runOauth = async () => { | |
| 250 | + if (installation.status !== "ready") { | |
| 251 | + throw new Error("Workspace is still loading."); | |
| 252 | + } | |
| 253 | + | |
| 254 | + const payload: GoogleDiscoveryStartOAuthInput = { | |
| 255 | + service: service.trim(), | |
| 256 | + version: version.trim(), | |
| 257 | + discoveryUrl: discoveryUrl.trim() || null, | |
| 258 | + defaultHeaders: parseStringMap(headersText), | |
| 259 | + scopes: parseScopes(scopesText), | |
| 260 | + clientId: clientId.trim(), | |
| 261 | + clientSecretRef: clientSecretRef | |
| 262 | + ? JSON.parse(clientSecretRef) | |
| 263 | + : null, | |
| 264 | + clientAuthentication: clientSecretRef ? "client_secret_post" : "none", | |
| 265 | + redirectUrl: new URL( | |
| 266 | + "/v1/plugins/google-discovery/oauth/callback", | |
| 267 | + window.location.origin, | |
| 268 | + ).toString(), | |
| 269 | + }; | |
| 270 | + | |
| 271 | + const started = await startOAuth({ | |
| 272 | + path: { | |
| 273 | + workspaceId: installation.data.scopeId, | |
| 274 | + }, | |
| 275 | + payload, | |
| 276 | + }); | |
| 277 | + | |
| 278 | + const popup = window.open( | |
| 279 | + started.authorizationUrl, | |
| 280 | + "executor-google-discovery-oauth", | |
| 281 | + "width=560,height=760,noopener,noreferrer", | |
| 282 | + ); | |
| 283 | + if (!popup) { | |
| 284 | + throw new Error("Failed opening Google OAuth popup."); | |
| 285 | + } | |
| 286 | + | |
| 287 | + const result = await waitForOauthPopupResult(started.sessionId); | |
| 288 | + if (!result.ok) { | |
| 289 | + throw new Error(result.error); | |
| 290 | + } | |
| 291 | + | |
| 292 | + setScopesText(stringifyScopes(result.auth.scopes)); | |
| 293 | + setOauthAuth(result.auth); | |
| 294 | + setOauthStatus("connected"); | |
| 295 | + }; | |
| 296 | + | |
| 297 | + return ( | |
| 298 | + <div className="space-y-4 rounded-2xl border border-border bg-card p-5"> | |
| 299 | + <div> | |
| 300 | + <h2 className="text-lg font-semibold text-foreground"> | |
| 301 | + {props.mode === "create" | |
| 302 | + ? "Connect Google Discovery Source" | |
| 303 | + : "Edit Google Discovery Source"} | |
| 304 | + </h2> | |
| 305 | + <p className="mt-1 text-sm text-muted-foreground"> | |
| 306 | + Google Discovery now owns its API metadata and OAuth flow inside the plugin. | |
| 307 | + </p> | |
| 308 | + </div> | |
| 309 | + | |
| 310 | + <div className="grid gap-4 md:grid-cols-2"> | |
| 311 | + <label className="grid gap-1.5"> | |
| 312 | + <span className="text-xs font-medium text-foreground">Name</span> | |
| 313 | + <input | |
| 314 | + value={name} | |
| 315 | + onChange={(event) => setName(event.target.value)} | |
| 316 | + className="h-10 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | |
| 317 | + /> | |
| 318 | + </label> | |
| 319 | + <label className="grid gap-1.5"> | |
| 320 | + <span className="text-xs font-medium text-foreground">Service</span> | |
| 321 | + <input | |
| 322 | + value={service} | |
| 323 | + onChange={(event) => setService(event.target.value)} | |
| 324 | + className="h-10 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | |
| 325 | + /> | |
| 326 | + </label> | |
| 327 | + <label className="grid gap-1.5"> | |
| 328 | + <span className="text-xs font-medium text-foreground">Version</span> | |
| 329 | + <input | |
| 330 | + value={version} | |
| 331 | + onChange={(event) => setVersion(event.target.value)} | |
| 332 | + className="h-10 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | |
| 333 | + /> | |
| 334 | + </label> | |
| 335 | + <label className="grid gap-1.5"> | |
| 336 | + <span className="text-xs font-medium text-foreground">Discovery URL</span> | |
| 337 | + <input | |
| 338 | + value={discoveryUrl} | |
| 339 | + onChange={(event) => setDiscoveryUrl(event.target.value)} | |
| 340 | + placeholder={defaultGoogleDiscoveryUrl(service || "sheets", version || "v4")} | |
| 341 | + className="h-10 rounded-lg border border-input bg-background px-3 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | |
| 342 | + /> | |
| 343 | + </label> | |
| 344 | + </div> | |
| 345 | + | |
| 346 | + <label className="grid gap-1.5"> | |
| 347 | + <span className="text-xs font-medium text-foreground">Default Headers</span> | |
| 348 | + <textarea | |
| 349 | + value={headersText} | |
| 350 | + onChange={(event) => setHeadersText(event.target.value)} | |
| 351 | + rows={4} | |
| 352 | + className="rounded-lg border border-input bg-background px-3 py-2 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | |
| 353 | + /> | |
| 354 | + </label> | |
| 355 | + | |
| 356 | + <label className="grid gap-1.5"> | |
| 357 | + <span className="text-xs font-medium text-foreground">Scopes</span> | |
| 358 | + <textarea | |
| 359 | + value={scopesText} | |
| 360 | + onChange={(event) => setScopesText(event.target.value)} | |
| 361 | + rows={4} | |
| 362 | + placeholder="Leave blank to infer from the discovery document." | |
| 363 | + className="rounded-lg border border-input bg-background px-3 py-2 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | |
| 364 | + /> | |
| 365 | + </label> | |
| 366 | + | |
| 367 | + <label className="grid gap-1.5"> | |
| 368 | + <span className="text-xs font-medium text-foreground">Auth</span> | |
| 369 | + <select | |
| 370 | + value={authKind} | |
| 371 | + onChange={(event) => | |
| 372 | + setAuthKind(event.target.value as GoogleDiscoveryConnectionAuth["kind"])} | |
| 373 | + className="h-10 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | |
| 374 | + > | |
| 375 | + <option value="none">None</option> | |
| 376 | + <option value="bearer">Bearer Secret</option> | |
| 377 | + <option value="oauth2">Google OAuth</option> | |
| 378 | + </select> | |
| 379 | + </label> | |
| 380 | + | |
| 381 | + {authKind === "bearer" && ( | |
| 382 | + <label className="grid gap-1.5"> | |
| 383 | + <span className="text-xs font-medium text-foreground">Secret</span> | |
| 384 | + <select | |
| 385 | + value={bearerSecretRef} | |
| 386 | + onChange={(event) => setBearerSecretRef(event.target.value)} | |
| 387 | + className="h-10 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | |
| 388 | + > | |
| 389 | + <option value="">Select a secret</option> | |
| 390 | + {secrets.status === "ready" && | |
| 391 | + secrets.data.map((secret) => ( | |
| 392 | + <option | |
| 393 | + key={`${secret.providerId}:${secret.id}`} | |
| 394 | + value={JSON.stringify({ | |
| 395 | + providerId: secret.providerId, | |
| 396 | + handle: secret.id, | |
| 397 | + })} | |
| 398 | + > | |
| 399 | + {secret.name ?? secret.id} | |
| 400 | + </option> | |
| 401 | + ))} | |
| 402 | + </select> | |
| 403 | + </label> | |
| 404 | + )} | |
| 405 | + | |
| 406 | + {authKind === "oauth2" && ( | |
| 407 | + <div className="space-y-4 rounded-xl border border-border/70 bg-background/50 p-4"> | |
| 408 | + <label className="grid gap-1.5"> | |
| 409 | + <span className="text-xs font-medium text-foreground">Client ID</span> | |
| 410 | + <input | |
| 411 | + value={clientId} | |
| 412 | + onChange={(event) => setClientId(event.target.value)} | |
| 413 | + className="h-10 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | |
| 414 | + /> | |
| 415 | + </label> | |
| 416 | + <label className="grid gap-1.5"> | |
| 417 | + <span className="text-xs font-medium text-foreground">Client Secret</span> | |
| 418 | + <select | |
| 419 | + value={clientSecretRef} | |
| 420 | + onChange={(event) => setClientSecretRef(event.target.value)} | |
| 421 | + className="h-10 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | |
| 422 | + > | |
| 423 | + <option value="">Public client / no secret</option> | |
| 424 | + {secrets.status === "ready" && | |
| 425 | + secrets.data.map((secret) => ( | |
| 426 | + <option | |
| 427 | + key={`${secret.providerId}:${secret.id}`} | |
| 428 | + value={JSON.stringify({ | |
| 429 | + providerId: secret.providerId, | |
| 430 | + handle: secret.id, | |
| 431 | + })} | |
| 432 | + > | |
| 433 | + {secret.name ?? secret.id} | |
| 434 | + </option> | |
| 435 | + ))} | |
| 436 | + </select> | |
| 437 | + </label> | |
| 438 | + <div className="flex items-center gap-3"> | |
| 439 | + <button | |
| 440 | + type="button" | |
| 441 | + onClick={() => { | |
| 442 | + void (async () => { | |
| 443 | + setError(null); | |
| 444 | + setOauthStatus("pending"); | |
| 445 | + try { | |
| 446 | + await runOauth(); | |
| 447 | + } catch (cause) { | |
| 448 | + setError(cause instanceof Error ? cause.message : "Google OAuth failed."); | |
| 449 | + setOauthStatus("idle"); | |
| 450 | + return; | |
| 451 | + } | |
| 452 | + })(); | |
| 453 | + }} | |
| 454 | + disabled={oauthStatus === "pending"} | |
| 455 | + className="inline-flex h-9 items-center justify-center rounded-lg border border-input bg-card px-3 text-sm font-medium text-foreground transition-colors hover:bg-accent/50 disabled:pointer-events-none disabled:opacity-50" | |
| 456 | + > | |
| 457 | + {oauthStatus === "pending" | |
| 458 | + ? "Connecting..." | |
| 459 | + : oauthStatus === "connected" | |
| 460 | + ? "Reconnect OAuth" | |
| 461 | + : "Connect OAuth"} | |
| 462 | + </button> | |
| 463 | + <div className="text-xs text-muted-foreground"> | |
| 464 | + {oauthStatus === "connected" | |
| 465 | + ? "OAuth tokens are ready and will be saved with this source." | |
| 466 | + : "The plugin opens Google OAuth in a popup and stores token refs locally."} | |
| 467 | + </div> | |
| 468 | + </div> | |
| 469 | + </div> | |
| 470 | + )} | |
| 471 | + | |
| 472 | + {error && ( | |
| 473 | + <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-3 text-sm text-destructive"> | |
| 474 | + {error} | |
| 475 | + </div> | |
| 476 | + )} | |
| 477 | + | |
| 478 | + <div className="flex items-center gap-3"> | |
| 479 | + <button | |
| 480 | + type="button" | |
| 481 | + onClick={() => { | |
| 482 | + void (async () => { | |
| 483 | + setError(null); | |
| 484 | + try { | |
| 485 | + const auth: GoogleDiscoveryConnectionAuth = | |
| 486 | + authKind === "none" | |
| 487 | + ? { kind: "none" } | |
| 488 | + : authKind === "bearer" | |
| 489 | + ? { | |
| 490 | + kind: "bearer", | |
| 491 | + tokenSecretRef: JSON.parse(bearerSecretRef), | |
| 492 | + } | |
| 493 | + : oauthAuth | |
| 494 | + ? oauthAuth | |
| 495 | + : (() => { | |
| 496 | + throw new Error("Finish Google OAuth before saving."); | |
| 497 | + })(); | |
| 498 | + | |
| 499 | + await submitMutation.mutateAsync({ | |
| 500 | + name: name.trim(), | |
| 501 | + service: service.trim(), | |
| 502 | + version: version.trim(), | |
| 503 | + discoveryUrl: discoveryUrl.trim() || null, | |
| 504 | + defaultHeaders: parseStringMap(headersText), | |
| 505 | + scopes: parseScopes(scopesText), | |
| 506 | + auth, | |
| 507 | + }); | |
| 508 | + } catch (cause) { | |
| 509 | + setError( | |
| 510 | + cause instanceof Error | |
| 511 | + ? cause.message | |
| 512 | + : "Failed saving Google Discovery source.", | |
| 513 | + ); | |
| 514 | + } | |
| 515 | + })(); | |
| 516 | + }} | |
| 517 | + disabled={submitMutation.status === "pending"} | |
| 518 | + className="inline-flex h-9 items-center justify-center rounded-lg bg-primary px-3 text-sm font-medium text-primary-foreground transition-opacity disabled:pointer-events-none disabled:opacity-50" | |
| 519 | + > | |
| 520 | + {submitMutation.status === "pending" | |
| 521 | + ? props.mode === "create" ? "Creating..." : "Saving..." | |
| 522 | + : props.mode === "create" ? "Create Source" : "Save Changes"} | |
| 523 | + </button> | |
| 524 | + <div className="text-xs text-muted-foreground"> | |
| 525 | + {props.mode === "create" | |
| 526 | + ? "The discovery document is imported as soon as the source is created." | |
| 527 | + : "Saving refreshes the imported Google API model."} | |
| 528 | + </div> | |
| 529 | + </div> | |
| 530 | + </div> | |
| 531 | + ); | |
| 532 | +} | |
| 533 | + | |
| 534 | +function GoogleDiscoveryAddPage() { | |
| 535 | + const navigate = useNavigate(); | |
| 536 | + const installation = useLocalInstallation(); | |
| 537 | + const client = getGoogleDiscoveryHttpClient(); | |
| 538 | + const createSource = useAtomSet( | |
| 539 | + client.mutation("googleDiscovery", "createSource"), | |
| 540 | + { mode: "promise" }, | |
| 541 | + ); | |
| 542 | + | |
| 543 | + if (installation.status !== "ready") { | |
| 544 | + return <div className="text-sm text-muted-foreground">Loading workspace...</div>; | |
| 545 | + } | |
| 546 | + | |
| 547 | + return ( | |
| 548 | + <GoogleDiscoverySourceForm | |
| 549 | + initialValue={defaultGoogleInput()} | |
| 550 | + mode="create" | |
| 551 | + onSubmit={async (input) => { | |
| 552 | + const source = await createSource({ | |
| 553 | + path: { | |
| 554 | + workspaceId: installation.data.scopeId, | |
| 555 | + }, | |
| 556 | + payload: input, | |
| 557 | + reactivityKeys: { | |
| 558 | + sources: [installation.data.scopeId], | |
| 559 | + }, | |
| 560 | + }); | |
| 561 | + | |
| 562 | + startTransition(() => { | |
| 563 | + void navigate({ | |
| 564 | + to: "/sources/$sourceId", | |
| 565 | + params: { | |
| 566 | + sourceId: source.id, | |
| 567 | + }, | |
| 568 | + search: { | |
| 569 | + tab: "model", | |
| 570 | + }, | |
| 571 | + }); | |
| 572 | + }); | |
| 573 | + }} | |
| 574 | + /> | |
| 575 | + ); | |
| 576 | +} | |
| 577 | + | |
| 578 | +function GoogleDiscoveryEditPage(props: { | |
| 579 | + source: Source; | |
| 580 | +}) { | |
| 581 | + const navigate = useNavigate(); | |
| 582 | + const installation = useLocalInstallation(); | |
| 583 | + const client = getGoogleDiscoveryHttpClient(); | |
| 584 | + const configResult = useAtomValue( | |
| 585 | + installation.status === "ready" | |
| 586 | + ? client.query("googleDiscovery", "getSourceConfig", { | |
| 587 | + path: { | |
| 588 | + workspaceId: installation.data.scopeId, | |
| 589 | + sourceId: props.source.id, | |
| 590 | + }, | |
| 591 | + reactivityKeys: { | |
| 592 | + source: [installation.data.scopeId, props.source.id], | |
| 593 | + }, | |
| 594 | + timeToLive: "30 seconds", | |
| 595 | + }) | |
| 596 | + : client.query("local", "installation", { | |
| 597 | + timeToLive: "1 second", | |
| 598 | + }) as never, | |
| 599 | + ); | |
| 600 | + const updateSource = useAtomSet( | |
| 601 | + client.mutation("googleDiscovery", "updateSource"), | |
| 602 | + { mode: "promise" }, | |
| 603 | + ); | |
| 604 | + | |
| 605 | + if (installation.status !== "ready") { | |
| 606 | + return <div className="text-sm text-muted-foreground">Loading workspace...</div>; | |
| 607 | + } | |
| 608 | + | |
| 609 | + if (Result.isFailure(configResult)) { | |
| 610 | + return ( | |
| 611 | + <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-3 text-sm text-destructive"> | |
| 612 | + Failed loading Google Discovery config. | |
| 613 | + </div> | |
| 614 | + ); | |
| 615 | + } | |
| 616 | + | |
| 617 | + if (!Result.isSuccess(configResult)) { | |
| 618 | + return <div className="text-sm text-muted-foreground">Loading Google Discovery config...</div>; | |
| 619 | + } | |
| 620 | + | |
| 621 | + return ( | |
| 622 | + <GoogleDiscoverySourceForm | |
| 623 | + initialValue={configResult.value} | |
| 624 | + mode="edit" | |
| 625 | + onSubmit={async (input) => { | |
| 626 | + const source = await updateSource({ | |
| 627 | + path: { | |
| 628 | + workspaceId: installation.data.scopeId, | |
| 629 | + sourceId: props.source.id, | |
| 630 | + }, | |
| 631 | + payload: input, | |
| 632 | + reactivityKeys: { | |
| 633 | + sources: [installation.data.scopeId], | |
| 634 | + source: [installation.data.scopeId, props.source.id], | |
| 635 | + sourceInspection: [installation.data.scopeId, props.source.id], | |
| 636 | + sourceInspectionTool: [installation.data.scopeId, props.source.id], | |
| 637 | + sourceDiscovery: [installation.data.scopeId, props.source.id], | |
| 638 | + }, | |
| 639 | + }); | |
| 640 | + | |
| 641 | + startTransition(() => { | |
| 642 | + void navigate({ | |
| 643 | + to: "/sources/$sourceId", | |
| 644 | + params: { | |
| 645 | + sourceId: source.id, | |
| 646 | + }, | |
| 647 | + search: { | |
| 648 | + tab: "model", | |
| 649 | + }, | |
| 650 | + }); | |
| 651 | + }); | |
| 652 | + }} | |
| 653 | + /> | |
| 654 | + ); | |
| 655 | +} | |
| 656 | + | |
| 657 | +function GoogleDiscoveryDetailPage(props: { | |
| 658 | + source: Source; | |
| 659 | + route: { | |
| 660 | + search?: unknown; | |
| 661 | + navigate?: unknown; | |
| 662 | + }; | |
| 663 | +}) { | |
| 664 | + const navigate = useNavigate(); | |
| 665 | + const installation = useLocalInstallation(); | |
| 666 | + const client = getGoogleDiscoveryHttpClient(); | |
| 667 | + const removeSource = useAtomSet( | |
| 668 | + client.mutation("googleDiscovery", "removeSource"), | |
| 669 | + { mode: "promise" }, | |
| 670 | + ); | |
| 671 | + const configResult = useAtomValue( | |
| 672 | + installation.status === "ready" | |
| 673 | + ? client.query("googleDiscovery", "getSourceConfig", { | |
| 674 | + path: { | |
| 675 | + workspaceId: installation.data.scopeId, | |
| 676 | + sourceId: props.source.id, | |
| 677 | + }, | |
| 678 | + reactivityKeys: { | |
| 679 | + source: [installation.data.scopeId, props.source.id], | |
| 680 | + }, | |
| 681 | + timeToLive: "30 seconds", | |
| 682 | + }) | |
| 683 | + : client.query("local", "installation", { | |
| 684 | + timeToLive: "1 second", | |
| 685 | + }) as never, | |
| 686 | + ); | |
| 687 | + const summary = useMemo(() => { | |
| 688 | + if (!Result.isSuccess(configResult)) { | |
| 689 | + return null; | |
| 690 | + } | |
| 691 | + | |
| 692 | + const config = configResult.value; | |
| 693 | + return ( | |
| 694 | + <div className="space-y-1"> | |
| 695 | + <div className="font-mono text-xs text-foreground"> | |
| 696 | + {config.discoveryUrl ?? defaultGoogleDiscoveryUrl(config.service, config.version)} | |
| 697 | + </div> | |
| 698 | + <div> | |
| 699 | + API: <span className="text-foreground">{config.service} {config.version}</span> | |
| 700 | + </div> | |
| 701 | + <div> | |
| 702 | + Auth: <span className="text-foreground">{config.auth.kind}</span> | |
| 703 | + </div> | |
| 704 | + </div> | |
| 705 | + ); | |
| 706 | + }, [configResult]); | |
| 707 | + | |
| 708 | + if (installation.status !== "ready") { | |
| 709 | + return <div className="text-sm text-muted-foreground">Loading workspace...</div>; | |
| 710 | + } | |
| 711 | + | |
| 712 | + return ( | |
| 713 | + <SourceToolExplorer | |
| 714 | + sourceId={props.source.id} | |
| 715 | + title={props.source.name} | |
| 716 | + kind={props.source.kind} | |
| 717 | + search={(props.route.search ?? {}) as RouteToolSearch} | |
| 718 | + navigate={(next) => navigateFromPluginRoute(props.route.navigate, next)} | |
| 719 | + summary={summary} | |
| 720 | + actions={( | |
| 721 | + <> | |
| 722 | + <button | |
| 723 | + type="button" | |
| 724 | + onClick={() => | |
| 725 | + void navigate({ | |
| 726 | + to: "/sources/$sourceId/edit", | |
| 727 | + params: { | |
| 728 | + sourceId: props.source.id, | |
| 729 | + }, | |
| 730 | + })} | |
| 731 | + className="inline-flex h-9 items-center gap-1.5 rounded-lg border border-input bg-card px-3 text-sm font-medium text-foreground transition-colors hover:bg-accent/50" | |
| 732 | + > | |
| 733 | + <IconPencil className="size-3.5" /> | |
| 734 | + Edit | |
| 735 | + </button> | |
| 736 | + <button | |
| 737 | + type="button" | |
| 738 | + onClick={() => { | |
| 739 | + const confirmed = window.confirm( | |
| 740 | + `Delete Google Discovery source "${props.source.name}"?`, | |
| 741 | + ); | |
| 742 | + if (!confirmed) { | |
| 743 | + return; | |
| 744 | + } | |
| 745 | + | |
| 746 | + void removeSource({ | |
| 747 | + path: { | |
| 748 | + workspaceId: installation.data.scopeId, | |
| 749 | + sourceId: props.source.id, | |
| 750 | + }, | |
| 751 | + reactivityKeys: { | |
| 752 | + sources: [installation.data.scopeId], | |
| 753 | + source: [installation.data.scopeId, props.source.id], | |
| 754 | + sourceInspection: [installation.data.scopeId, props.source.id], | |
| 755 | + sourceInspectionTool: [installation.data.scopeId, props.source.id], | |
| 756 | + sourceDiscovery: [installation.data.scopeId, props.source.id], | |
| 757 | + }, | |
| 758 | + }).then(() => { | |
| 759 | + startTransition(() => { | |
| 760 | + void navigate({ | |
| 761 | + to: "/", | |
| 762 | + }); | |
| 763 | + }); | |
| 764 | + }); | |
| 765 | + }} | |
| 766 | + className="inline-flex h-9 items-center rounded-lg border border-destructive/25 bg-destructive/5 px-3 text-sm font-medium text-destructive transition-colors hover:bg-destructive/10" | |
| 767 | + > | |
| 768 | + Delete | |
| 769 | + </button> | |
| 770 | + </> | |
| 771 | + )} | |
| 772 | + /> | |
| 773 | + ); | |
| 774 | +} | |
| 775 | + | |
| 776 | +export const GoogleDiscoveryReactPlugin = { | |
| 777 | + key: "google_discovery", | |
| 778 | + register(api: FrontendPluginRegisterApi) { | |
| 779 | + api.sources.registerType({ | |
| 780 | + kind: "google_discovery", | |
| 781 | + displayName: "Google Discovery", | |
| 782 | + description: "Import Google APIs from discovery documents with plugin-owned OAuth.", | |
| 783 | + renderAddPage: () => <GoogleDiscoveryAddPage />, | |
| 784 | + renderEditPage: ({ source }) => ( | |
| 785 | + <GoogleDiscoveryEditPage source={source} /> | |
| 786 | + ), | |
| 787 | + renderDetailPage: ({ source, route }) => ( | |
| 788 | + <GoogleDiscoveryDetailPage source={source} route={route} /> | |
| 789 | + ), | |
| 790 | + }); | |
| 791 | + }, | |
| 792 | +}; | |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -0,0 +1,390 @@ | ||
| 1 | +import { createCatalogSnapshotV1FromFragments } from "@executor/ir/catalog"; | |
| 2 | +import { | |
| 3 | + CapabilityIdSchema, | |
| 4 | + DocumentIdSchema, | |
| 5 | + ExecutableIdSchema, | |
| 6 | + ParameterSymbolIdSchema, | |
| 7 | + RequestBodySymbolIdSchema, | |
| 8 | + ResponseSymbolIdSchema, | |
| 9 | + ScopeIdSchema, | |
| 10 | + SecuritySchemeSymbolIdSchema, | |
| 11 | +} from "@executor/ir/ids"; | |
| 12 | +import type { | |
| 13 | + AuthRequirement, | |
| 14 | + Capability, | |
| 15 | + CatalogSnapshotV1, | |
| 16 | + ParameterSymbol, | |
| 17 | + ResponseSymbol, | |
| 18 | + Scope, | |
| 19 | + SecuritySchemeSymbol, | |
| 20 | + Executable, | |
| 21 | +} from "@executor/ir/model"; | |
| 22 | +import { | |
| 23 | + type BaseCatalogOperationInput, | |
| 24 | + type CatalogFragmentBuilder, | |
| 25 | + type CatalogSourceDocumentInput, | |
| 26 | + type JsonSchemaImporter, | |
| 27 | + type Source, | |
| 28 | + EXECUTABLE_BINDING_VERSION, | |
| 29 | + buildCatalogFragment, | |
| 30 | + createCatalogImportMetadata, | |
| 31 | + docsFrom, | |
| 32 | + groupedSchemaForParameter, | |
| 33 | + interactionForEffect, | |
| 34 | + mutableRecord, | |
| 35 | + provenanceFor, | |
| 36 | + requestBodySchemaFromInput, | |
| 37 | + responseSetFromSingleResponse, | |
| 38 | + stableHash, | |
| 39 | + toolPathSegments, | |
| 40 | +} from "@executor/source-core"; | |
| 41 | + | |
| 42 | +import type { GoogleDiscoveryToolProviderData } from "./types"; | |
| 43 | + | |
| 44 | +export type GoogleDiscoveryCatalogOperationInput = BaseCatalogOperationInput & { | |
| 45 | + providerData: GoogleDiscoveryToolProviderData; | |
| 46 | +}; | |
| 47 | + | |
| 48 | +const googleDiscoveryServerSpecs = ( | |
| 49 | + operation: GoogleDiscoveryCatalogOperationInput | undefined, | |
| 50 | +): NonNullable<NonNullable<Scope["defaults"]>["servers"]> | undefined => { | |
| 51 | + const rootUrl = operation?.providerData.invocation.rootUrl; | |
| 52 | + if (!rootUrl) { | |
| 53 | + return undefined; | |
| 54 | + } | |
| 55 | + | |
| 56 | + const servicePath = operation?.providerData.invocation.servicePath ?? ""; | |
| 57 | + return [ | |
| 58 | + { | |
| 59 | + url: new URL(servicePath || "", rootUrl).toString(), | |
| 60 | + }, | |
| 61 | + ]; | |
| 62 | +}; | |
| 63 | + | |
| 64 | +const createHttpCapabilityFromGoogleDiscovery = (input: { | |
| 65 | + catalog: CatalogFragmentBuilder; | |
| 66 | + source: Pick<Source, "id" | "kind" | "name" | "namespace">; | |
| 67 | + documentId: ReturnType<typeof DocumentIdSchema.make>; | |
| 68 | + serviceScopeId: ReturnType<typeof ScopeIdSchema.make>; | |
| 69 | + operation: GoogleDiscoveryCatalogOperationInput; | |
| 70 | + importer: JsonSchemaImporter; | |
| 71 | +}) => { | |
| 72 | + const toolPath = toolPathSegments( | |
| 73 | + input.source, | |
| 74 | + input.operation.providerData.toolId, | |
| 75 | + ); | |
| 76 | + const capabilityId = CapabilityIdSchema.make( | |
| 77 | + `cap_${stableHash({ | |
| 78 | + sourceId: input.source.id, | |
| 79 | + toolId: input.operation.providerData.toolId, | |
| 80 | + })}`, | |
| 81 | + ); | |
| 82 | + const executableId = ExecutableIdSchema.make( | |
| 83 | + `exec_${stableHash({ | |
| 84 | + sourceId: input.source.id, | |
| 85 | + toolId: input.operation.providerData.toolId, | |
| 86 | + protocol: "http", | |
| 87 | + })}`, | |
| 88 | + ); | |
| 89 | + const inputSchema = input.operation.inputSchema ?? {}; | |
| 90 | + const outputSchema = input.operation.outputSchema ?? {}; | |
| 91 | + | |
| 92 | + const authSchemeId = | |
| 93 | + input.operation.providerData.invocation.scopes.length > 0 | |
| 94 | + ? SecuritySchemeSymbolIdSchema.make( | |
| 95 | + `security_${stableHash({ | |
| 96 | + sourceId: input.source.id, | |
| 97 | + scopes: input.operation.providerData.invocation.scopes, | |
| 98 | + })}`, | |
| 99 | + ) | |
| 100 | + : undefined; | |
| 101 | + | |
| 102 | + if (authSchemeId && !input.catalog.symbols[authSchemeId]) { | |
| 103 | + const scopeDescriptions = Object.fromEntries( | |
| 104 | + input.operation.providerData.invocation.scopes.map((scope) => [ | |
| 105 | + scope, | |
| 106 | + input.operation.providerData.invocation.scopeDescriptions?.[scope] ?? | |
| 107 | + scope, | |
| 108 | + ]), | |
| 109 | + ); | |
| 110 | + mutableRecord(input.catalog.symbols)[authSchemeId] = { | |
| 111 | + id: authSchemeId, | |
| 112 | + kind: "securityScheme", | |
| 113 | + schemeType: "oauth2", | |
| 114 | + docs: docsFrom({ | |
| 115 | + summary: "OAuth 2.0", | |
| 116 | + description: "Imported from Google Discovery scopes.", | |
| 117 | + }), | |
| 118 | + oauth: { | |
| 119 | + flows: {}, | |
| 120 | + scopes: scopeDescriptions, | |
| 121 | + }, | |
| 122 | + synthetic: false, | |
| 123 | + provenance: provenanceFor(input.documentId, "#/googleDiscovery/security"), | |
| 124 | + } satisfies SecuritySchemeSymbol; | |
| 125 | + } | |
| 126 | + | |
| 127 | + input.operation.providerData.invocation.parameters.forEach((parameter) => { | |
| 128 | + const parameterId = ParameterSymbolIdSchema.make( | |
| 129 | + `param_${stableHash({ | |
| 130 | + capabilityId, | |
| 131 | + location: parameter.location, | |
| 132 | + name: parameter.name, | |
| 133 | + })}`, | |
| 134 | + ); | |
| 135 | + const parameterSchema = | |
| 136 | + groupedSchemaForParameter( | |
| 137 | + inputSchema, | |
| 138 | + parameter.location, | |
| 139 | + parameter.name, | |
| 140 | + ) ?? | |
| 141 | + (parameter.repeated | |
| 142 | + ? { | |
| 143 | + type: "array", | |
| 144 | + items: { | |
| 145 | + type: parameter.type === "integer" ? "integer" : "string", | |
| 146 | + ...(parameter.enum ? { enum: parameter.enum } : {}), | |
| 147 | + }, | |
| 148 | + } | |
| 149 | + : { | |
| 150 | + type: parameter.type === "integer" ? "integer" : "string", | |
| 151 | + ...(parameter.enum ? { enum: parameter.enum } : {}), | |
| 152 | + }); | |
| 153 | + | |
| 154 | + mutableRecord(input.catalog.symbols)[parameterId] = { | |
| 155 | + id: parameterId, | |
| 156 | + kind: "parameter", | |
| 157 | + name: parameter.name, | |
| 158 | + location: parameter.location, | |
| 159 | + required: parameter.required, | |
| 160 | + ...(docsFrom({ | |
| 161 | + description: parameter.description, | |
| 162 | + }) | |
| 163 | + ? { | |
| 164 | + docs: docsFrom({ | |
| 165 | + description: parameter.description, | |
| 166 | + })!, | |
| 167 | + } | |
| 168 | + : {}), | |
| 169 | + schemaShapeId: input.importer.importSchema( | |
| 170 | + parameterSchema, | |
| 171 | + `#/googleDiscovery/${input.operation.providerData.toolId}/parameter/${parameter.location}/${parameter.name}`, | |
| 172 | + inputSchema, | |
| 173 | + ), | |
| 174 | + synthetic: false, | |
| 175 | + provenance: provenanceFor( | |
| 176 | + input.documentId, | |
| 177 | + `#/googleDiscovery/${input.operation.providerData.toolId}/parameter/${parameter.location}/${parameter.name}`, | |
| 178 | + ), | |
| 179 | + } satisfies ParameterSymbol; | |
| 180 | + }); | |
| 181 | + | |
| 182 | + const requestBodyId = | |
| 183 | + input.operation.providerData.invocation.requestSchemaId || | |
| 184 | + requestBodySchemaFromInput(inputSchema) !== undefined | |
| 185 | + ? RequestBodySymbolIdSchema.make( | |
| 186 | + `request_body_${stableHash({ capabilityId })}`, | |
| 187 | + ) | |
| 188 | + : undefined; | |
| 189 | + | |
| 190 | + if (requestBodyId) { | |
| 191 | + const requestBodySchema = | |
| 192 | + requestBodySchemaFromInput(inputSchema) ?? inputSchema; | |
| 193 | + mutableRecord(input.catalog.symbols)[requestBodyId] = { | |
| 194 | + id: requestBodyId, | |
| 195 | + kind: "requestBody", | |
| 196 | + contents: [ | |
| 197 | + { | |
| 198 | + mediaType: "application/json", | |
| 199 | + shapeId: input.importer.importSchema( | |
| 200 | + requestBodySchema, | |
| 201 | + `#/googleDiscovery/${input.operation.providerData.toolId}/requestBody`, | |
| 202 | + inputSchema, | |
| 203 | + ), | |
| 204 | + }, | |
| 205 | + ], | |
| 206 | + synthetic: false, | |
| 207 | + provenance: provenanceFor( | |
| 208 | + input.documentId, | |
| 209 | + `#/googleDiscovery/${input.operation.providerData.toolId}/requestBody`, | |
| 210 | + ), | |
| 211 | + }; | |
| 212 | + } | |
| 213 | + | |
| 214 | + const responseId = ResponseSymbolIdSchema.make( | |
| 215 | + `response_${stableHash({ capabilityId })}`, | |
| 216 | + ); | |
| 217 | + mutableRecord(input.catalog.symbols)[responseId] = { | |
| 218 | + id: responseId, | |
| 219 | + kind: "response", | |
| 220 | + ...(docsFrom({ | |
| 221 | + description: input.operation.description, | |
| 222 | + }) | |
| 223 | + ? { | |
| 224 | + docs: docsFrom({ | |
| 225 | + description: input.operation.description, | |
| 226 | + })!, | |
| 227 | + } | |
| 228 | + : {}), | |
| 229 | + ...(input.operation.outputSchema !== undefined | |
| 230 | + ? { | |
| 231 | + contents: [ | |
| 232 | + { | |
| 233 | + mediaType: "application/json", | |
| 234 | + shapeId: input.importer.importSchema( | |
| 235 | + outputSchema, | |
| 236 | + `#/googleDiscovery/${input.operation.providerData.toolId}/response`, | |
| 237 | + outputSchema, | |
| 238 | + ), | |
| 239 | + }, | |
| 240 | + ], | |
| 241 | + } | |
| 242 | + : {}), | |
| 243 | + synthetic: false, | |
| 244 | + provenance: provenanceFor( | |
| 245 | + input.documentId, | |
| 246 | + `#/googleDiscovery/${input.operation.providerData.toolId}/response`, | |
| 247 | + ), | |
| 248 | + } satisfies ResponseSymbol; | |
| 249 | + | |
| 250 | + const traits: Array<"upload" | "download"> = []; | |
| 251 | + if (input.operation.providerData.invocation.supportsMediaUpload) { | |
| 252 | + traits.push("upload"); | |
| 253 | + } | |
| 254 | + if (input.operation.providerData.invocation.supportsMediaDownload) { | |
| 255 | + traits.push("download"); | |
| 256 | + } | |
| 257 | + | |
| 258 | + const responseSetId = responseSetFromSingleResponse({ | |
| 259 | + catalog: input.catalog, | |
| 260 | + responseId, | |
| 261 | + provenance: provenanceFor( | |
| 262 | + input.documentId, | |
| 263 | + `#/googleDiscovery/${input.operation.providerData.toolId}/responseSet`, | |
| 264 | + ), | |
| 265 | + traits, | |
| 266 | + }); | |
| 267 | + | |
| 268 | + const callShapeId = | |
| 269 | + input.operation.inputSchema !== undefined | |
| 270 | + ? input.importer.importSchema( | |
| 271 | + input.operation.inputSchema, | |
| 272 | + `#/googleDiscovery/${input.operation.providerData.toolId}/call`, | |
| 273 | + input.operation.inputSchema, | |
| 274 | + ) | |
| 275 | + : input.importer.importSchema( | |
| 276 | + { | |
| 277 | + type: "object", | |
| 278 | + additionalProperties: false, | |
| 279 | + }, | |
| 280 | + `#/googleDiscovery/${input.operation.providerData.toolId}/call`, | |
| 281 | + ); | |
| 282 | + | |
| 283 | + mutableRecord(input.catalog.executables)[executableId] = { | |
| 284 | + id: executableId, | |
| 285 | + capabilityId, | |
| 286 | + scopeId: input.serviceScopeId, | |
| 287 | + pluginKey: "google_discovery", | |
| 288 | + bindingVersion: EXECUTABLE_BINDING_VERSION, | |
| 289 | + binding: input.operation.providerData, | |
| 290 | + projection: { | |
| 291 | + responseSetId, | |
| 292 | + callShapeId, | |
| 293 | + }, | |
| 294 | + display: { | |
| 295 | + protocol: "http", | |
| 296 | + method: input.operation.providerData.invocation.method.toUpperCase(), | |
| 297 | + pathTemplate: input.operation.providerData.invocation.path, | |
| 298 | + operationId: input.operation.providerData.methodId, | |
| 299 | + group: input.operation.providerData.group, | |
| 300 | + leaf: input.operation.providerData.leaf, | |
| 301 | + rawToolId: input.operation.providerData.rawToolId, | |
| 302 | + title: input.operation.title ?? null, | |
| 303 | + summary: input.operation.description ?? null, | |
| 304 | + }, | |
| 305 | + synthetic: false, | |
| 306 | + provenance: provenanceFor( | |
| 307 | + input.documentId, | |
| 308 | + `#/googleDiscovery/${input.operation.providerData.toolId}/executable`, | |
| 309 | + ), | |
| 310 | + } satisfies Executable; | |
| 311 | + | |
| 312 | + const effect = input.operation.effect; | |
| 313 | + const auth: AuthRequirement = authSchemeId | |
| 314 | + ? { | |
| 315 | + kind: "scheme", | |
| 316 | + schemeId: authSchemeId, | |
| 317 | + scopes: input.operation.providerData.invocation.scopes, | |
| 318 | + } | |
| 319 | + : { kind: "none" }; | |
| 320 | + | |
| 321 | + mutableRecord(input.catalog.capabilities)[capabilityId] = { | |
| 322 | + id: capabilityId, | |
| 323 | + serviceScopeId: input.serviceScopeId, | |
| 324 | + surface: { | |
| 325 | + toolPath, | |
| 326 | + ...(input.operation.title ? { title: input.operation.title } : {}), | |
| 327 | + ...(input.operation.description | |
| 328 | + ? { summary: input.operation.description } | |
| 329 | + : {}), | |
| 330 | + tags: [ | |
| 331 | + "google", | |
| 332 | + input.operation.providerData.service, | |
| 333 | + input.operation.providerData.version, | |
| 334 | + ], | |
| 335 | + }, | |
| 336 | + semantics: { | |
| 337 | + effect, | |
| 338 | + safe: effect === "read", | |
| 339 | + idempotent: effect === "read" || effect === "delete", | |
| 340 | + destructive: effect === "delete", | |
| 341 | + }, | |
| 342 | + auth, | |
| 343 | + interaction: interactionForEffect(effect), | |
| 344 | + executableIds: [executableId], | |
| 345 | + synthetic: false, | |
| 346 | + provenance: provenanceFor( | |
| 347 | + input.documentId, | |
| 348 | + `#/googleDiscovery/${input.operation.providerData.toolId}/capability`, | |
| 349 | + ), | |
| 350 | + } satisfies Capability; | |
| 351 | +}; | |
| 352 | + | |
| 353 | +export const createGoogleDiscoveryCatalogFragment = (input: { | |
| 354 | + source: Source; | |
| 355 | + documents: readonly CatalogSourceDocumentInput[]; | |
| 356 | + operations: readonly GoogleDiscoveryCatalogOperationInput[]; | |
| 357 | +}) => | |
| 358 | + buildCatalogFragment({ | |
| 359 | + source: input.source, | |
| 360 | + documents: input.documents, | |
| 361 | + serviceScopeDefaults: (() => { | |
| 362 | + const servers = googleDiscoveryServerSpecs(input.operations[0]); | |
| 363 | + return servers ? { servers } : undefined; | |
| 364 | + })(), | |
| 365 | + registerOperations: ({ catalog, documentId, serviceScopeId, importer }) => { | |
| 366 | + for (const operation of input.operations) { | |
| 367 | + createHttpCapabilityFromGoogleDiscovery({ | |
| 368 | + catalog, | |
| 369 | + source: input.source, | |
| 370 | + documentId, | |
| 371 | + serviceScopeId, | |
| 372 | + operation, | |
| 373 | + importer, | |
| 374 | + }); | |
| 375 | + } | |
| 376 | + }, | |
| 377 | + }); | |
| 378 | + | |
| 379 | +export const createGoogleDiscoveryCatalogSnapshot = (input: { | |
| 380 | + source: Source; | |
| 381 | + documents: readonly CatalogSourceDocumentInput[]; | |
| 382 | + operations: readonly GoogleDiscoveryCatalogOperationInput[]; | |
| 383 | +}): CatalogSnapshotV1 => | |
| 384 | + createCatalogSnapshotV1FromFragments({ | |
| 385 | + import: createCatalogImportMetadata({ | |
| 386 | + source: input.source, | |
| 387 | + pluginKey: "google_discovery", | |
| 388 | + }), | |
| 389 | + fragments: [createGoogleDiscoveryCatalogFragment(input)], | |
| 390 | + }); | |
| @@ -0,0 +1,94 @@ | ||
| 1 | +import { | |
| 2 | + executeHttpProbe, | |
| 3 | + namespaceFromSourceName, | |
| 4 | + noneAuthInference, | |
| 5 | + supportedAuthInference, | |
| 6 | + trimOrNull, | |
| 7 | + type SourceAuthInference, | |
| 8 | + type SourceDiscoveryProbeInput, | |
| 9 | + type SourceDiscoveryResult, | |
| 10 | +} from "@executor/source-core"; | |
| 11 | +import * as Either from "effect/Either"; | |
| 12 | +import * as Effect from "effect/Effect"; | |
| 13 | + | |
| 14 | +import { extractGoogleDiscoveryManifest } from "./document"; | |
| 15 | + | |
| 16 | +const googleDiscoveryEndpoint = (input: { | |
| 17 | + rootUrl: string; | |
| 18 | + servicePath: string; | |
| 19 | +}): string => { | |
| 20 | + try { | |
| 21 | + return new URL(input.servicePath || "", input.rootUrl).toString(); | |
| 22 | + } catch { | |
| 23 | + return input.rootUrl; | |
| 24 | + } | |
| 25 | +}; | |
| 26 | + | |
| 27 | +const inferGoogleDiscoveryAuth = (input: { | |
| 28 | + scopes: ReadonlyArray<string>; | |
| 29 | +}): SourceAuthInference => | |
| 30 | + input.scopes.length > 0 | |
| 31 | + ? supportedAuthInference("oauth2", { | |
| 32 | + confidence: "high", | |
| 33 | + reason: "Google Discovery document declares OAuth scopes", | |
| 34 | + headerName: "Authorization", | |
| 35 | + prefix: "Bearer ", | |
| 36 | + parameterName: null, | |
| 37 | + parameterLocation: null, | |
| 38 | + oauthAuthorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth", | |
| 39 | + oauthTokenUrl: "https://oauth2.googleapis.com/token", | |
| 40 | + oauthScopes: [...input.scopes], | |
| 41 | + }) | |
| 42 | + : noneAuthInference( | |
| 43 | + "Google Discovery document does not declare OAuth scopes", | |
| 44 | + "medium", | |
| 45 | + ); | |
| 46 | + | |
| 47 | +export const detectGoogleDiscoverySource = ( | |
| 48 | + input: SourceDiscoveryProbeInput, | |
| 49 | +): Effect.Effect<SourceDiscoveryResult | null, never, never> => | |
| 50 | + Effect.gen(function* () { | |
| 51 | + const response = yield* Effect.either(executeHttpProbe({ | |
| 52 | + method: "GET", | |
| 53 | + url: input.normalizedUrl, | |
| 54 | + headers: input.headers, | |
| 55 | + })); | |
| 56 | + | |
| 57 | + if (Either.isLeft(response)) { | |
| 58 | + return null; | |
| 59 | + } | |
| 60 | + | |
| 61 | + if (response.right.status < 200 || response.right.status >= 300) { | |
| 62 | + return null; | |
| 63 | + } | |
| 64 | + | |
| 65 | + const manifest = yield* Effect.either( | |
| 66 | + extractGoogleDiscoveryManifest(input.normalizedUrl, response.right.text), | |
| 67 | + ); | |
| 68 | + if (Either.isLeft(manifest)) { | |
| 69 | + return null; | |
| 70 | + } | |
| 71 | + | |
| 72 | + const endpoint = googleDiscoveryEndpoint({ | |
| 73 | + rootUrl: manifest.right.rootUrl, | |
| 74 | + servicePath: manifest.right.servicePath, | |
| 75 | + }); | |
| 76 | + const name = trimOrNull(manifest.right.title) | |
| 77 | + ?? `${manifest.right.service}.${manifest.right.versionName}.googleapis.com`; | |
| 78 | + const oauthScopes = Object.keys(manifest.right.oauthScopes ?? {}); | |
| 79 | + | |
| 80 | + return { | |
| 81 | + detectedKind: "plugin", | |
| 82 | + confidence: "high", | |
| 83 | + endpoint, | |
| 84 | + specUrl: input.normalizedUrl, | |
| 85 | + name, | |
| 86 | + namespace: namespaceFromSourceName(manifest.right.service), | |
| 87 | + transport: null, | |
| 88 | + authInference: inferGoogleDiscoveryAuth({ | |
| 89 | + scopes: oauthScopes, | |
| 90 | + }), | |
| 91 | + toolCount: manifest.right.methods.length, | |
| 92 | + warnings: [], | |
| 93 | + } satisfies SourceDiscoveryResult; | |
| 94 | + }).pipe(Effect.catchAll(() => Effect.succeed(null))); | |
| @@ -0,0 +1,549 @@ | ||
| 1 | +import { sha256Hex } from "@executor/codemode-core"; | |
| 2 | +import * as Effect from "effect/Effect"; | |
| 3 | + | |
| 4 | +import type { | |
| 5 | + GoogleDiscoveryHttpMethod, | |
| 6 | + GoogleDiscoveryManifestMethod, | |
| 7 | + GoogleDiscoveryMethodParameter, | |
| 8 | + GoogleDiscoverySchemaRefTable, | |
| 9 | + GoogleDiscoveryToolManifest, | |
| 10 | +} from "./types"; | |
| 11 | + | |
| 12 | +type JsonObject = Record<string, unknown>; | |
| 13 | + | |
| 14 | +type DiscoveryParameter = { | |
| 15 | + location?: unknown; | |
| 16 | + type?: unknown; | |
| 17 | + required?: unknown; | |
| 18 | + repeated?: unknown; | |
| 19 | + description?: unknown; | |
| 20 | + enum?: unknown; | |
| 21 | + default?: unknown; | |
| 22 | + $ref?: unknown; | |
| 23 | + items?: unknown; | |
| 24 | +}; | |
| 25 | + | |
| 26 | +type DiscoverySchema = { | |
| 27 | + id?: unknown; | |
| 28 | + type?: unknown; | |
| 29 | + description?: unknown; | |
| 30 | + properties?: unknown; | |
| 31 | + items?: unknown; | |
| 32 | + additionalProperties?: unknown; | |
| 33 | + enum?: unknown; | |
| 34 | + format?: unknown; | |
| 35 | + readOnly?: unknown; | |
| 36 | + default?: unknown; | |
| 37 | + $ref?: unknown; | |
| 38 | +}; | |
| 39 | + | |
| 40 | +type DiscoveryMethod = { | |
| 41 | + id?: unknown; | |
| 42 | + description?: unknown; | |
| 43 | + httpMethod?: unknown; | |
| 44 | + path?: unknown; | |
| 45 | + flatPath?: unknown; | |
| 46 | + parameters?: unknown; | |
| 47 | + request?: unknown; | |
| 48 | + response?: unknown; | |
| 49 | + scopes?: unknown; | |
| 50 | + mediaUpload?: unknown; | |
| 51 | + supportsMediaDownload?: unknown; | |
| 52 | +}; | |
| 53 | + | |
| 54 | +type DiscoveryResource = { | |
| 55 | + methods?: unknown; | |
| 56 | + resources?: unknown; | |
| 57 | +}; | |
| 58 | + | |
| 59 | +type DiscoveryDocument = { | |
| 60 | + name?: unknown; | |
| 61 | + version?: unknown; | |
| 62 | + title?: unknown; | |
| 63 | + description?: unknown; | |
| 64 | + rootUrl?: unknown; | |
| 65 | + servicePath?: unknown; | |
| 66 | + batchPath?: unknown; | |
| 67 | + documentationLink?: unknown; | |
| 68 | + resources?: unknown; | |
| 69 | + methods?: unknown; | |
| 70 | + schemas?: unknown; | |
| 71 | + auth?: unknown; | |
| 72 | +}; | |
| 73 | + | |
| 74 | +const asRecord = (value: unknown): JsonObject => | |
| 75 | + typeof value === "object" && value !== null && !Array.isArray(value) | |
| 76 | + ? value as JsonObject | |
| 77 | + : {}; | |
| 78 | + | |
| 79 | +const asString = (value: unknown): string | null => | |
| 80 | + typeof value === "string" && value.trim().length > 0 ? value.trim() : null; | |
| 81 | + | |
| 82 | +const asBoolean = (value: unknown): boolean | null => | |
| 83 | + typeof value === "boolean" ? value : null; | |
| 84 | + | |
| 85 | +const asStringArray = (value: unknown): ReadonlyArray<string> => | |
| 86 | + Array.isArray(value) | |
| 87 | + ? value.flatMap((entry) => { | |
| 88 | + const stringValue = asString(entry); | |
| 89 | + return stringValue ? [stringValue] : []; | |
| 90 | + }) | |
| 91 | + : []; | |
| 92 | + | |
| 93 | +const parseJson = (value: string | undefined): unknown | undefined => { | |
| 94 | + if (!value) { | |
| 95 | + return undefined; | |
| 96 | + } | |
| 97 | + | |
| 98 | + try { | |
| 99 | + return JSON.parse(value) as unknown; | |
| 100 | + } catch { | |
| 101 | + return undefined; | |
| 102 | + } | |
| 103 | +}; | |
| 104 | + | |
| 105 | +const contentHash = (value: string): string => | |
| 106 | + sha256Hex(value); | |
| 107 | + | |
| 108 | +const escapeJsonPointerSegment = (value: string): string => | |
| 109 | + value.replaceAll("~", "~0").replaceAll("/", "~1"); | |
| 110 | + | |
| 111 | +const schemaRefKey = (schemaId: string): string => | |
| 112 | + `#/$defs/google/${escapeJsonPointerSegment(schemaId)}`; | |
| 113 | + | |
| 114 | +const normalizeHttpMethod = (value: unknown): GoogleDiscoveryHttpMethod => { | |
| 115 | + const method = asString(value)?.toLowerCase(); | |
| 116 | + switch (method) { | |
| 117 | + case "get": | |
| 118 | + case "put": | |
| 119 | + case "post": | |
| 120 | + case "delete": | |
| 121 | + case "patch": | |
| 122 | + case "head": | |
| 123 | + case "options": | |
| 124 | + return method; | |
| 125 | + default: | |
| 126 | + throw new Error(`Unsupported Google Discovery HTTP method: ${String(value)}`); | |
| 127 | + } | |
| 128 | +}; | |
| 129 | + | |
| 130 | +const googleSchemaToJsonSchema = (input: { | |
| 131 | + schema: DiscoverySchema | DiscoveryParameter | unknown; | |
| 132 | + topLevelSchemas: Readonly<Record<string, DiscoverySchema>>; | |
| 133 | +}): unknown => { | |
| 134 | + const schema = asRecord(input.schema) as DiscoverySchema; | |
| 135 | + const ref = asString(schema.$ref); | |
| 136 | + if (ref) { | |
| 137 | + return { $ref: schemaRefKey(ref) }; | |
| 138 | + } | |
| 139 | + | |
| 140 | + const description = asString(schema.description); | |
| 141 | + const format = asString(schema.format); | |
| 142 | + const type = asString(schema.type); | |
| 143 | + const enumValues = asStringArray(schema.enum); | |
| 144 | + const defaultValue = | |
| 145 | + typeof schema.default === "string" | |
| 146 | + || typeof schema.default === "number" | |
| 147 | + || typeof schema.default === "boolean" | |
| 148 | + ? schema.default | |
| 149 | + : undefined; | |
| 150 | + const readOnly = asBoolean(schema.readOnly); | |
| 151 | + | |
| 152 | + const base: Record<string, unknown> = { | |
| 153 | + ...(description ? { description } : {}), | |
| 154 | + ...(format ? { format } : {}), | |
| 155 | + ...(enumValues.length > 0 ? { enum: [...enumValues] } : {}), | |
| 156 | + ...(defaultValue !== undefined ? { default: defaultValue } : {}), | |
| 157 | + ...(readOnly === true ? { readOnly: true } : {}), | |
| 158 | + }; | |
| 159 | + | |
| 160 | + if (type === "any") { | |
| 161 | + return base; | |
| 162 | + } | |
| 163 | + | |
| 164 | + if (type === "array") { | |
| 165 | + const items = googleSchemaToJsonSchema({ | |
| 166 | + schema: schema.items, | |
| 167 | + topLevelSchemas: input.topLevelSchemas, | |
| 168 | + }); | |
| 169 | + return { | |
| 170 | + ...base, | |
| 171 | + type: "array", | |
| 172 | + items: items ?? {}, | |
| 173 | + }; | |
| 174 | + } | |
| 175 | + | |
| 176 | + const properties = asRecord(schema.properties); | |
| 177 | + const additionalProperties = schema.additionalProperties; | |
| 178 | + if ( | |
| 179 | + type === "object" | |
| 180 | + || Object.keys(properties).length > 0 | |
| 181 | + || additionalProperties !== undefined | |
| 182 | + ) { | |
| 183 | + const convertedProperties = Object.fromEntries( | |
| 184 | + Object.entries(properties).map(([key, propertySchema]) => [ | |
| 185 | + key, | |
| 186 | + googleSchemaToJsonSchema({ | |
| 187 | + schema: propertySchema, | |
| 188 | + topLevelSchemas: input.topLevelSchemas, | |
| 189 | + }), | |
| 190 | + ]), | |
| 191 | + ); | |
| 192 | + | |
| 193 | + const convertedAdditionalProperties = | |
| 194 | + additionalProperties === undefined | |
| 195 | + ? undefined | |
| 196 | + : additionalProperties === true | |
| 197 | + ? true | |
| 198 | + : googleSchemaToJsonSchema({ | |
| 199 | + schema: additionalProperties, | |
| 200 | + topLevelSchemas: input.topLevelSchemas, | |
| 201 | + }); | |
| 202 | + | |
| 203 | + return { | |
| 204 | + ...base, | |
| 205 | + type: "object", | |
| 206 | + ...(Object.keys(convertedProperties).length > 0 | |
| 207 | + ? { properties: convertedProperties } | |
| 208 | + : {}), | |
| 209 | + ...(convertedAdditionalProperties !== undefined | |
| 210 | + ? { additionalProperties: convertedAdditionalProperties } | |
| 211 | + : {}), | |
| 212 | + }; | |
| 213 | + } | |
| 214 | + | |
| 215 | + if (type === "boolean" || type === "number" || type === "integer" || type === "string") { | |
| 216 | + return { | |
| 217 | + ...base, | |
| 218 | + type, | |
| 219 | + }; | |
| 220 | + } | |
| 221 | + | |
| 222 | + return Object.keys(base).length > 0 ? base : {}; | |
| 223 | +}; | |
| 224 | + | |
| 225 | +const parameterToJsonSchema = (input: { | |
| 226 | + parameter: DiscoveryParameter; | |
| 227 | + topLevelSchemas: Readonly<Record<string, DiscoverySchema>>; | |
| 228 | +}): unknown => { | |
| 229 | + const schema = googleSchemaToJsonSchema({ | |
| 230 | + schema: input.parameter, | |
| 231 | + topLevelSchemas: input.topLevelSchemas, | |
| 232 | + }); | |
| 233 | + const repeated = input.parameter.repeated === true; | |
| 234 | + if (!repeated) { | |
| 235 | + return schema; | |
| 236 | + } | |
| 237 | + | |
| 238 | + return { | |
| 239 | + type: "array", | |
| 240 | + items: schema, | |
| 241 | + }; | |
| 242 | +}; | |
| 243 | + | |
| 244 | +const methodRequestSchemaJson = (input: { | |
| 245 | + method: DiscoveryMethod; | |
| 246 | + topLevelSchemas: Readonly<Record<string, DiscoverySchema>>; | |
| 247 | +}): string | undefined => { | |
| 248 | + const methodRecord = asRecord(input.method); | |
| 249 | + const parameters = asRecord(methodRecord.parameters); | |
| 250 | + const properties: Record<string, unknown> = {}; | |
| 251 | + const required: string[] = []; | |
| 252 | + | |
| 253 | + for (const [name, rawParameter] of Object.entries(parameters)) { | |
| 254 | + const parameter = asRecord(rawParameter) as DiscoveryParameter; | |
| 255 | + properties[name] = parameterToJsonSchema({ | |
| 256 | + parameter, | |
| 257 | + topLevelSchemas: input.topLevelSchemas, | |
| 258 | + }); | |
| 259 | + if (parameter.required === true) { | |
| 260 | + required.push(name); | |
| 261 | + } | |
| 262 | + } | |
| 263 | + | |
| 264 | + const requestRef = asString(asRecord(methodRecord.request).$ref); | |
| 265 | + if (requestRef) { | |
| 266 | + const requestSchema = input.topLevelSchemas[requestRef]; | |
| 267 | + if (requestSchema) { | |
| 268 | + properties.body = googleSchemaToJsonSchema({ | |
| 269 | + schema: requestSchema, | |
| 270 | + topLevelSchemas: input.topLevelSchemas, | |
| 271 | + }); | |
| 272 | + } else { | |
| 273 | + properties.body = { $ref: schemaRefKey(requestRef) }; | |
| 274 | + } | |
| 275 | + } | |
| 276 | + | |
| 277 | + if (Object.keys(properties).length === 0) { | |
| 278 | + return undefined; | |
| 279 | + } | |
| 280 | + | |
| 281 | + return JSON.stringify({ | |
| 282 | + type: "object", | |
| 283 | + properties, | |
| 284 | + ...(required.length > 0 ? { required } : {}), | |
| 285 | + additionalProperties: false, | |
| 286 | + }); | |
| 287 | +}; | |
| 288 | + | |
| 289 | +const methodResponseSchemaJson = (input: { | |
| 290 | + method: DiscoveryMethod; | |
| 291 | + topLevelSchemas: Readonly<Record<string, DiscoverySchema>>; | |
| 292 | +}): string | undefined => { | |
| 293 | + const responseRef = asString(asRecord(asRecord(input.method).response).$ref); | |
| 294 | + if (!responseRef) { | |
| 295 | + return undefined; | |
| 296 | + } | |
| 297 | + | |
| 298 | + const responseSchema = input.topLevelSchemas[responseRef]; | |
| 299 | + const schemaJson = responseSchema | |
| 300 | + ? googleSchemaToJsonSchema({ | |
| 301 | + schema: responseSchema, | |
| 302 | + topLevelSchemas: input.topLevelSchemas, | |
| 303 | + }) | |
| 304 | + : { $ref: schemaRefKey(responseRef) }; | |
| 305 | + | |
| 306 | + return JSON.stringify(schemaJson); | |
| 307 | +}; | |
| 308 | + | |
| 309 | +const parameterRecordFromMethod = ( | |
| 310 | + method: DiscoveryMethod, | |
| 311 | +): ReadonlyArray<GoogleDiscoveryMethodParameter> => | |
| 312 | + Object.entries(asRecord(asRecord(method).parameters)) | |
| 313 | + .flatMap(([name, rawParameter]) => { | |
| 314 | + const parameter = asRecord(rawParameter) as DiscoveryParameter; | |
| 315 | + const location = asString(parameter.location); | |
| 316 | + if (location !== "path" && location !== "query" && location !== "header") { | |
| 317 | + return []; | |
| 318 | + } | |
| 319 | + | |
| 320 | + return [{ | |
| 321 | + name, | |
| 322 | + location, | |
| 323 | + required: parameter.required === true, | |
| 324 | + repeated: parameter.repeated === true, | |
| 325 | + description: asString(parameter.description), | |
| 326 | + type: asString(parameter.type) ?? asString(parameter.$ref), | |
| 327 | + ...(asStringArray(parameter.enum).length > 0 | |
| 328 | + ? { enum: [...asStringArray(parameter.enum)] } | |
| 329 | + : {}), | |
| 330 | + ...(asString(parameter.default) ? { default: asString(parameter.default)! } : {}), | |
| 331 | + } satisfies GoogleDiscoveryMethodParameter]; | |
| 332 | + }); | |
| 333 | + | |
| 334 | +const scopesRecordFromDocument = ( | |
| 335 | + document: DiscoveryDocument, | |
| 336 | +): Record<string, string> | undefined => { | |
| 337 | + const scopes = asRecord( | |
| 338 | + asRecord( | |
| 339 | + asRecord(document.auth).oauth2, | |
| 340 | + ).scopes, | |
| 341 | + ); | |
| 342 | + | |
| 343 | + const normalized = Object.fromEntries( | |
| 344 | + Object.entries(scopes).flatMap(([scope, rawValue]) => { | |
| 345 | + const description = asString(asRecord(rawValue).description) ?? ""; | |
| 346 | + return [[scope, description]]; | |
| 347 | + }), | |
| 348 | + ); | |
| 349 | + | |
| 350 | + return Object.keys(normalized).length > 0 ? normalized : undefined; | |
| 351 | +}; | |
| 352 | + | |
| 353 | +const manifestMethodFromDiscoveryMethod = (input: { | |
| 354 | + service: string; | |
| 355 | + version: string; | |
| 356 | + rootUrl: string; | |
| 357 | + servicePath: string; | |
| 358 | + topLevelSchemas: Readonly<Record<string, DiscoverySchema>>; | |
| 359 | + method: DiscoveryMethod; | |
| 360 | +}): GoogleDiscoveryManifestMethod | null => { | |
| 361 | + const methodId = asString(input.method.id); | |
| 362 | + const path = asString(input.method.path); | |
| 363 | + if (!methodId || !path) { | |
| 364 | + return null; | |
| 365 | + } | |
| 366 | + | |
| 367 | + const method = normalizeHttpMethod(input.method.httpMethod); | |
| 368 | + const rawToolId = methodId; | |
| 369 | + const toolId = rawToolId.startsWith(`${input.service}.`) | |
| 370 | + ? rawToolId.slice(input.service.length + 1) | |
| 371 | + : rawToolId; | |
| 372 | + const segments = toolId.split(".").filter((segment) => segment.length > 0); | |
| 373 | + const leaf = segments.at(-1) ?? toolId; | |
| 374 | + const group = segments.length > 1 ? segments.slice(0, -1).join(".") : null; | |
| 375 | + const responseRef = asString(asRecord(asRecord(input.method).response).$ref); | |
| 376 | + const requestRef = asString(asRecord(asRecord(input.method).request).$ref); | |
| 377 | + const mediaUpload = asRecord(input.method.mediaUpload); | |
| 378 | + | |
| 379 | + return { | |
| 380 | + toolId, | |
| 381 | + rawToolId, | |
| 382 | + methodId, | |
| 383 | + name: toolId, | |
| 384 | + description: asString(input.method.description), | |
| 385 | + group, | |
| 386 | + leaf, | |
| 387 | + method, | |
| 388 | + path, | |
| 389 | + flatPath: asString(input.method.flatPath), | |
| 390 | + parameters: parameterRecordFromMethod(input.method), | |
| 391 | + requestSchemaId: requestRef, | |
| 392 | + responseSchemaId: responseRef, | |
| 393 | + scopes: [...asStringArray(input.method.scopes)], | |
| 394 | + supportsMediaUpload: Object.keys(mediaUpload).length > 0, | |
| 395 | + supportsMediaDownload: asBoolean(input.method.supportsMediaDownload) === true, | |
| 396 | + ...(methodRequestSchemaJson({ | |
| 397 | + method: input.method, | |
| 398 | + topLevelSchemas: input.topLevelSchemas, | |
| 399 | + }) | |
| 400 | + ? { | |
| 401 | + inputSchema: parseJson( | |
| 402 | + methodRequestSchemaJson({ | |
| 403 | + method: input.method, | |
| 404 | + topLevelSchemas: input.topLevelSchemas, | |
| 405 | + }), | |
| 406 | + ), | |
| 407 | + } | |
| 408 | + : {}), | |
| 409 | + ...(methodResponseSchemaJson({ | |
| 410 | + method: input.method, | |
| 411 | + topLevelSchemas: input.topLevelSchemas, | |
| 412 | + }) | |
| 413 | + ? { | |
| 414 | + outputSchema: parseJson( | |
| 415 | + methodResponseSchemaJson({ | |
| 416 | + method: input.method, | |
| 417 | + topLevelSchemas: input.topLevelSchemas, | |
| 418 | + }), | |
| 419 | + ), | |
| 420 | + } | |
| 421 | + : {}), | |
| 422 | + }; | |
| 423 | +}; | |
| 424 | + | |
| 425 | +const collectDiscoveryMethods = (input: { | |
| 426 | + service: string; | |
| 427 | + version: string; | |
| 428 | + rootUrl: string; | |
| 429 | + servicePath: string; | |
| 430 | + topLevelSchemas: Readonly<Record<string, DiscoverySchema>>; | |
| 431 | + resource: DiscoveryResource | JsonObject | unknown; | |
| 432 | +}): ReadonlyArray<GoogleDiscoveryManifestMethod> => { | |
| 433 | + const resource = asRecord(input.resource) as DiscoveryResource; | |
| 434 | + const methods = Object.values(asRecord(resource.methods)).flatMap((method) => { | |
| 435 | + try { | |
| 436 | + const manifestMethod = manifestMethodFromDiscoveryMethod({ | |
| 437 | + service: input.service, | |
| 438 | + version: input.version, | |
| 439 | + rootUrl: input.rootUrl, | |
| 440 | + servicePath: input.servicePath, | |
| 441 | + topLevelSchemas: input.topLevelSchemas, | |
| 442 | + method: asRecord(method) as DiscoveryMethod, | |
| 443 | + }); | |
| 444 | + return manifestMethod ? [manifestMethod] : []; | |
| 445 | + } catch { | |
| 446 | + return []; | |
| 447 | + } | |
| 448 | + }); | |
| 449 | + const nested = Object.values(asRecord(resource.resources)).flatMap((nestedResource) => | |
| 450 | + collectDiscoveryMethods({ | |
| 451 | + ...input, | |
| 452 | + resource: nestedResource, | |
| 453 | + })); | |
| 454 | + | |
| 455 | + return [...methods, ...nested]; | |
| 456 | +}; | |
| 457 | + | |
| 458 | +export const extractGoogleDiscoveryManifest = ( | |
| 459 | + sourceName: string, | |
| 460 | + discoveryInput: string | JsonObject, | |
| 461 | +): Effect.Effect<GoogleDiscoveryToolManifest, Error, never> => | |
| 462 | + Effect.try({ | |
| 463 | + try: () => { | |
| 464 | + const document = typeof discoveryInput === "string" | |
| 465 | + ? JSON.parse(discoveryInput) as DiscoveryDocument | |
| 466 | + : discoveryInput as DiscoveryDocument; | |
| 467 | + | |
| 468 | + const service = asString(document.name); | |
| 469 | + const versionName = asString(document.version); | |
| 470 | + const rootUrl = asString(document.rootUrl); | |
| 471 | + const servicePath = typeof document.servicePath === "string" ? document.servicePath : ""; | |
| 472 | + | |
| 473 | + if (!service || !versionName || !rootUrl) { | |
| 474 | + throw new Error(`Invalid Google Discovery document for ${sourceName}`); | |
| 475 | + } | |
| 476 | + | |
| 477 | + const schemas = Object.fromEntries( | |
| 478 | + Object.entries(asRecord(document.schemas)).map(([schemaId, rawSchema]) => [ | |
| 479 | + schemaId, | |
| 480 | + asRecord(rawSchema) as DiscoverySchema, | |
| 481 | + ]), | |
| 482 | + ); | |
| 483 | + const schemaRefTable = Object.fromEntries( | |
| 484 | + Object.entries(schemas).map(([schemaId, schema]) => [ | |
| 485 | + schemaRefKey(schemaId), | |
| 486 | + JSON.stringify( | |
| 487 | + googleSchemaToJsonSchema({ | |
| 488 | + schema, | |
| 489 | + topLevelSchemas: schemas, | |
| 490 | + }), | |
| 491 | + ), | |
| 492 | + ]), | |
| 493 | + ) satisfies GoogleDiscoverySchemaRefTable; | |
| 494 | + const methods = [ | |
| 495 | + ...Object.values(asRecord(document.methods)).flatMap((method) => { | |
| 496 | + const manifestMethod = manifestMethodFromDiscoveryMethod({ | |
| 497 | + service, | |
| 498 | + version: versionName, | |
| 499 | + rootUrl, | |
| 500 | + servicePath, | |
| 501 | + topLevelSchemas: schemas, | |
| 502 | + method: asRecord(method) as DiscoveryMethod, | |
| 503 | + }); | |
| 504 | + return manifestMethod ? [manifestMethod] : []; | |
| 505 | + }), | |
| 506 | + ...Object.values(asRecord(document.resources)).flatMap((resource) => | |
| 507 | + collectDiscoveryMethods({ | |
| 508 | + service, | |
| 509 | + version: versionName, | |
| 510 | + rootUrl, | |
| 511 | + servicePath, | |
| 512 | + topLevelSchemas: schemas, | |
| 513 | + resource, | |
| 514 | + })), | |
| 515 | + ].sort((left, right) => left.toolId.localeCompare(right.toolId)); | |
| 516 | + | |
| 517 | + const sourceHash = contentHash( | |
| 518 | + typeof discoveryInput === "string" | |
| 519 | + ? discoveryInput | |
| 520 | + : JSON.stringify(discoveryInput), | |
| 521 | + ); | |
| 522 | + | |
| 523 | + return { | |
| 524 | + version: 1, | |
| 525 | + sourceHash, | |
| 526 | + service, | |
| 527 | + versionName, | |
| 528 | + title: asString(document.title), | |
| 529 | + description: asString(document.description), | |
| 530 | + rootUrl, | |
| 531 | + servicePath, | |
| 532 | + batchPath: asString(document.batchPath), | |
| 533 | + documentationLink: asString(document.documentationLink), | |
| 534 | + ...(Object.keys(schemaRefTable).length > 0 ? { schemaRefTable } : {}), | |
| 535 | + ...(scopesRecordFromDocument(document) | |
| 536 | + ? { oauthScopes: scopesRecordFromDocument(document)! } | |
| 537 | + : {}), | |
| 538 | + methods, | |
| 539 | + }; | |
| 540 | + }, | |
| 541 | + catch: (cause) => | |
| 542 | + cause instanceof Error | |
| 543 | + ? cause | |
| 544 | + : new Error(`Failed to extract Google Discovery manifest: ${String(cause)}`), | |
| 545 | + }); | |
| 546 | + | |
| 547 | +export const compileGoogleDiscoveryToolDefinitions = ( | |
| 548 | + manifest: GoogleDiscoveryToolManifest, | |
| 549 | +): Array<GoogleDiscoveryManifestMethod> => [...manifest.methods]; | |
| @@ -0,0 +1,914 @@ | ||
| 1 | +import * as Effect from "effect/Effect"; | |
| 2 | +import * as Schema from "effect/Schema"; | |
| 3 | + | |
| 4 | +import { | |
| 5 | + buildOAuth2AuthorizationUrl, | |
| 6 | + createPkceCodeVerifier, | |
| 7 | + exchangeOAuth2AuthorizationCode, | |
| 8 | + refreshOAuth2AccessToken, | |
| 9 | +} from "@executor/auth-oauth2"; | |
| 10 | +import { | |
| 11 | + createCatalogImportMetadata, | |
| 12 | + createSourceCatalogSyncResult, | |
| 13 | + SourceCredentialRequiredError, | |
| 14 | +} from "@executor/source-core"; | |
| 15 | +import type { Source } from "@executor/platform-sdk/schema"; | |
| 16 | +import type { | |
| 17 | + ExecutorSdkPlugin, | |
| 18 | + ExecutorSdkPluginHost, | |
| 19 | + ExecutorSourceConnector, | |
| 20 | + SourcePluginRuntime, | |
| 21 | +} from "@executor/platform-sdk/plugins"; | |
| 22 | +import { | |
| 23 | + SecretMaterialDeleterService, | |
| 24 | + SecretMaterialResolverService, | |
| 25 | + SecretMaterialStorerService, | |
| 26 | + SecretMaterialUpdaterService, | |
| 27 | + provideExecutorRuntime, | |
| 28 | +} from "@executor/platform-sdk/runtime"; | |
| 29 | +import { | |
| 30 | + GoogleDiscoveryConnectionAuthSchema, | |
| 31 | + GoogleDiscoveryOAuthSessionSchema, | |
| 32 | + defaultGoogleDiscoveryUrl, | |
| 33 | + deriveGoogleDiscoveryNamespace, | |
| 34 | + GoogleDiscoveryStoredSourceDataSchema, | |
| 35 | + type GoogleDiscoveryConnectInput, | |
| 36 | + type GoogleDiscoveryConnectionAuth, | |
| 37 | + type GoogleDiscoveryOAuthPopupResult, | |
| 38 | + type GoogleDiscoveryOAuthSession, | |
| 39 | + type GoogleDiscoverySourceConfigPayload, | |
| 40 | + type GoogleDiscoveryStartOAuthInput, | |
| 41 | + type GoogleDiscoveryStartOAuthResult, | |
| 42 | + type GoogleDiscoveryStoredSourceData, | |
| 43 | + type GoogleDiscoveryUpdateSourceInput, | |
| 44 | +} from "@executor/plugin-google-discovery-shared"; | |
| 45 | + | |
| 46 | +import { | |
| 47 | + createGoogleDiscoveryCatalogFragment, | |
| 48 | + type GoogleDiscoveryCatalogOperationInput, | |
| 49 | +} from "./catalog"; | |
| 50 | +import { | |
| 51 | + compileGoogleDiscoveryToolDefinitions, | |
| 52 | + extractGoogleDiscoveryManifest, | |
| 53 | +} from "./document"; | |
| 54 | +import { | |
| 55 | + buildGoogleDiscoveryToolPresentation, | |
| 56 | +} from "./tools"; | |
| 57 | +import { | |
| 58 | + GoogleDiscoveryToolProviderDataSchema, | |
| 59 | + type GoogleDiscoveryToolManifest, | |
| 60 | + type GoogleDiscoveryToolProviderData, | |
| 61 | +} from "./types"; | |
| 62 | + | |
| 63 | +const decodeStoredSourceData = Schema.decodeUnknownSync( | |
| 64 | + GoogleDiscoveryStoredSourceDataSchema, | |
| 65 | +); | |
| 66 | +const decodeSession = Schema.decodeUnknownSync(GoogleDiscoveryOAuthSessionSchema); | |
| 67 | +const decodeProviderData = Schema.decodeUnknownSync(GoogleDiscoveryToolProviderDataSchema); | |
| 68 | + | |
| 69 | +export type GoogleDiscoverySourceStorage = { | |
| 70 | + get: (input: { | |
| 71 | + scopeId: string; | |
| 72 | + sourceId: string; | |
| 73 | + }) => Effect.Effect<GoogleDiscoveryStoredSourceData | null, Error, never>; | |
| 74 | + put: (input: { | |
| 75 | + scopeId: string; | |
| 76 | + sourceId: string; | |
| 77 | + value: GoogleDiscoveryStoredSourceData; | |
| 78 | + }) => Effect.Effect<void, Error, never>; | |
| 79 | + remove?: (input: { | |
| 80 | + scopeId: string; | |
| 81 | + sourceId: string; | |
| 82 | + }) => Effect.Effect<void, Error, never>; | |
| 83 | +}; | |
| 84 | + | |
| 85 | +export type GoogleDiscoveryOAuthSessionStorage = { | |
| 86 | + get: (sessionId: string) => Effect.Effect<GoogleDiscoveryOAuthSession | null, Error, never>; | |
| 87 | + put: (input: { | |
| 88 | + sessionId: string; | |
| 89 | + value: GoogleDiscoveryOAuthSession; | |
| 90 | + }) => Effect.Effect<void, Error, never>; | |
| 91 | + remove?: (sessionId: string) => Effect.Effect<void, Error, never>; | |
| 92 | +}; | |
| 93 | + | |
| 94 | +export type GoogleDiscoverySdk = { | |
| 95 | + getSourceConfig: ( | |
| 96 | + sourceId: Source["id"], | |
| 97 | + ) => Effect.Effect<GoogleDiscoverySourceConfigPayload, Error>; | |
| 98 | + createSource: ( | |
| 99 | + input: GoogleDiscoveryConnectInput, | |
| 100 | + ) => Effect.Effect<Source, Error>; | |
| 101 | + updateSource: ( | |
| 102 | + input: GoogleDiscoveryUpdateSourceInput, | |
| 103 | + ) => Effect.Effect<Source, Error>; | |
| 104 | + removeSource: ( | |
| 105 | + sourceId: Source["id"], | |
| 106 | + ) => Effect.Effect<boolean, Error>; | |
| 107 | + startOAuth: ( | |
| 108 | + input: GoogleDiscoveryStartOAuthInput, | |
| 109 | + ) => Effect.Effect<GoogleDiscoveryStartOAuthResult, Error>; | |
| 110 | + completeOAuth: (input: { | |
| 111 | + state: string; | |
| 112 | + code?: string; | |
| 113 | + error?: string; | |
| 114 | + errorDescription?: string; | |
| 115 | + }) => Effect.Effect<Extract<GoogleDiscoveryOAuthPopupResult, { ok: true }>, Error>; | |
| 116 | +}; | |
| 117 | + | |
| 118 | +const GoogleDiscoveryExecutorAddInputSchema = Schema.Struct({ | |
| 119 | + kind: Schema.Literal("google_discovery"), | |
| 120 | + name: Schema.String, | |
| 121 | + service: Schema.String, | |
| 122 | + version: Schema.String, | |
| 123 | + discoveryUrl: Schema.NullOr(Schema.String), | |
| 124 | + defaultHeaders: Schema.NullOr(Schema.Record({ key: Schema.String, value: Schema.String })), | |
| 125 | + scopes: Schema.Array(Schema.String), | |
| 126 | + auth: GoogleDiscoveryConnectionAuthSchema, | |
| 127 | +}); | |
| 128 | + | |
| 129 | +type GoogleDiscoveryExecutorAddInput = | |
| 130 | + typeof GoogleDiscoveryExecutorAddInputSchema.Type; | |
| 131 | + | |
| 132 | +const GOOGLE_AUTHORIZATION_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth"; | |
| 133 | +const GOOGLE_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token"; | |
| 134 | +const OAUTH_REFRESH_SKEW_MS = 60_000; | |
| 135 | + | |
| 136 | +const responseHeadersRecord = (response: Response): Record<string, string> => { | |
| 137 | + const headers: Record<string, string> = {}; | |
| 138 | + response.headers.forEach((value, key) => { | |
| 139 | + headers[key] = value; | |
| 140 | + }); | |
| 141 | + return headers; | |
| 142 | +}; | |
| 143 | + | |
| 144 | +const decodeResponseBody = async (response: Response): Promise<unknown> => { | |
| 145 | + const contentType = response.headers.get("content-type")?.toLowerCase() ?? ""; | |
| 146 | + const text = await response.text(); | |
| 147 | + if (text.trim().length === 0) { | |
| 148 | + return null; | |
| 149 | + } | |
| 150 | + if (contentType.includes("application/json") || contentType.includes("+json")) { | |
| 151 | + try { | |
| 152 | + return JSON.parse(text) as unknown; | |
| 153 | + } catch { | |
| 154 | + return text; | |
| 155 | + } | |
| 156 | + } | |
| 157 | + return text; | |
| 158 | +}; | |
| 159 | + | |
| 160 | +const asRecord = (value: unknown): Record<string, unknown> => | |
| 161 | + typeof value === "object" && value !== null && !Array.isArray(value) | |
| 162 | + ? (value as Record<string, unknown>) | |
| 163 | + : {}; | |
| 164 | + | |
| 165 | +const stringValuesFromParameter = ( | |
| 166 | + value: unknown, | |
| 167 | + repeated: boolean, | |
| 168 | +): string[] => { | |
| 169 | + if (value === undefined || value === null) { | |
| 170 | + return []; | |
| 171 | + } | |
| 172 | + if (Array.isArray(value)) { | |
| 173 | + const normalized = value.flatMap((entry) => | |
| 174 | + entry === undefined || entry === null ? [] : [String(entry)], | |
| 175 | + ); | |
| 176 | + return repeated ? normalized : [normalized.join(",")]; | |
| 177 | + } | |
| 178 | + if ( | |
| 179 | + typeof value === "string" || | |
| 180 | + typeof value === "number" || | |
| 181 | + typeof value === "boolean" | |
| 182 | + ) { | |
| 183 | + return [String(value)]; | |
| 184 | + } | |
| 185 | + return [JSON.stringify(value)]; | |
| 186 | +}; | |
| 187 | + | |
| 188 | +const replacePathParameters = (input: { | |
| 189 | + pathTemplate: string; | |
| 190 | + args: Record<string, unknown>; | |
| 191 | + parameters: ReadonlyArray< | |
| 192 | + GoogleDiscoveryToolProviderData["invocation"]["parameters"][number] | |
| 193 | + >; | |
| 194 | +}): string => | |
| 195 | + input.pathTemplate.replaceAll(/\{([^}]+)\}/g, (_, name: string) => { | |
| 196 | + const parameter = input.parameters.find( | |
| 197 | + (entry) => entry.location === "path" && entry.name === name, | |
| 198 | + ); | |
| 199 | + const rawValue = input.args[name]; | |
| 200 | + if ((rawValue === undefined || rawValue === null) && parameter?.required) { | |
| 201 | + throw new Error(`Missing required path parameter: ${name}`); | |
| 202 | + } | |
| 203 | + | |
| 204 | + const values = stringValuesFromParameter(rawValue, false); | |
| 205 | + return values.length > 0 ? encodeURIComponent(values[0]!) : ""; | |
| 206 | + }); | |
| 207 | + | |
| 208 | +const resolveGoogleDiscoveryBaseUrl = (input: { | |
| 209 | + providerData: GoogleDiscoveryToolProviderData; | |
| 210 | +}): string => | |
| 211 | + new URL( | |
| 212 | + input.providerData.invocation.servicePath || "", | |
| 213 | + input.providerData.invocation.rootUrl, | |
| 214 | + ).toString(); | |
| 215 | + | |
| 216 | +const googleDiscoveryCatalogOperationFromDefinition = (input: { | |
| 217 | + manifest: Parameters<typeof compileGoogleDiscoveryToolDefinitions>[0]; | |
| 218 | + definition: ReturnType<typeof compileGoogleDiscoveryToolDefinitions>[number]; | |
| 219 | +}): GoogleDiscoveryCatalogOperationInput => { | |
| 220 | + const presentation = buildGoogleDiscoveryToolPresentation({ | |
| 221 | + manifest: input.manifest, | |
| 222 | + definition: input.definition, | |
| 223 | + }); | |
| 224 | + | |
| 225 | + return { | |
| 226 | + toolId: input.definition.toolId, | |
| 227 | + title: input.definition.name, | |
| 228 | + description: input.definition.description, | |
| 229 | + effect: | |
| 230 | + input.definition.method === "get" || input.definition.method === "head" | |
| 231 | + ? "read" | |
| 232 | + : input.definition.method === "delete" | |
| 233 | + ? "delete" | |
| 234 | + : "write", | |
| 235 | + inputSchema: presentation.inputSchema, | |
| 236 | + outputSchema: presentation.outputSchema, | |
| 237 | + providerData: presentation.providerData as GoogleDiscoveryToolProviderData, | |
| 238 | + }; | |
| 239 | +}; | |
| 240 | + | |
| 241 | +const computeMaximalScopes = ( | |
| 242 | + manifest: GoogleDiscoveryToolManifest, | |
| 243 | +): ReadonlyArray<string> => { | |
| 244 | + const topLevelScopes = Object.keys(manifest.oauthScopes ?? {}); | |
| 245 | + if (topLevelScopes.length === 0) { | |
| 246 | + return []; | |
| 247 | + } | |
| 248 | + | |
| 249 | + const scopeToMethods = new Map<string, Set<string>>(); | |
| 250 | + for (const scope of topLevelScopes) { | |
| 251 | + scopeToMethods.set(scope, new Set()); | |
| 252 | + } | |
| 253 | + for (const method of manifest.methods) { | |
| 254 | + for (const scope of method.scopes) { | |
| 255 | + scopeToMethods.get(scope)?.add(method.methodId); | |
| 256 | + } | |
| 257 | + } | |
| 258 | + | |
| 259 | + return topLevelScopes.filter((scope) => { | |
| 260 | + const methods = scopeToMethods.get(scope); | |
| 261 | + if (!methods || methods.size === 0) { | |
| 262 | + return true; | |
| 263 | + } | |
| 264 | + | |
| 265 | + return !topLevelScopes.some((other) => { | |
| 266 | + if (other === scope) { | |
| 267 | + return false; | |
| 268 | + } | |
| 269 | + | |
| 270 | + const otherMethods = scopeToMethods.get(other); | |
| 271 | + if (!otherMethods || otherMethods.size <= methods.size) { | |
| 272 | + return false; | |
| 273 | + } | |
| 274 | + | |
| 275 | + for (const method of methods) { | |
| 276 | + if (!otherMethods.has(method)) { | |
| 277 | + return false; | |
| 278 | + } | |
| 279 | + } | |
| 280 | + | |
| 281 | + return true; | |
| 282 | + }); | |
| 283 | + }); | |
| 284 | +}; | |
| 285 | + | |
| 286 | +const fetchGoogleDiscoveryDocumentWithHeaders = (input: { | |
| 287 | + url: string; | |
| 288 | + headers?: Readonly<Record<string, string>>; | |
| 289 | +}): Effect.Effect<string, Error> => | |
| 290 | + Effect.tryPromise({ | |
| 291 | + try: async () => { | |
| 292 | + const response = await fetch(input.url, { | |
| 293 | + headers: input.headers, | |
| 294 | + signal: AbortSignal.timeout(15_000), | |
| 295 | + }); | |
| 296 | + if (response.status === 401 || response.status === 403) { | |
| 297 | + throw new SourceCredentialRequiredError( | |
| 298 | + "import", | |
| 299 | + `Google Discovery fetch requires credentials (status ${response.status})`, | |
| 300 | + ); | |
| 301 | + } | |
| 302 | + if (!response.ok) { | |
| 303 | + throw new Error(`Google Discovery fetch failed with status ${response.status}`); | |
| 304 | + } | |
| 305 | + return response.text(); | |
| 306 | + }, | |
| 307 | + catch: (cause) => | |
| 308 | + cause instanceof Error ? cause : new Error(String(cause)), | |
| 309 | + }); | |
| 310 | + | |
| 311 | +const inferScopesFromDiscoveryDocument = (input: { | |
| 312 | + service: string; | |
| 313 | + document: string; | |
| 314 | +}): Effect.Effect<ReadonlyArray<string>, Error> => | |
| 315 | + Effect.gen(function* () { | |
| 316 | + const manifest = yield* extractGoogleDiscoveryManifest(input.service, input.document); | |
| 317 | + return computeMaximalScopes(manifest); | |
| 318 | + }); | |
| 319 | + | |
| 320 | +const resolveOauthAccessToken = (input: { | |
| 321 | + scopeId: string; | |
| 322 | + sourceId: string; | |
| 323 | + auth: Extract<GoogleDiscoveryConnectionAuth, { kind: "oauth2" }>; | |
| 324 | + storage: GoogleDiscoverySourceStorage; | |
| 325 | +}): Effect.Effect<string, Error, any> => | |
| 326 | + Effect.gen(function* () { | |
| 327 | + const now = Date.now(); | |
| 328 | + const needsRefresh = | |
| 329 | + input.auth.refreshTokenRef !== null && | |
| 330 | + input.auth.expiresAt !== null && | |
| 331 | + input.auth.expiresAt <= now + OAUTH_REFRESH_SKEW_MS; | |
| 332 | + if (!needsRefresh) { | |
| 333 | + const resolveSecretMaterial = yield* SecretMaterialResolverService; | |
| 334 | + return yield* resolveSecretMaterial({ | |
| 335 | + ref: input.auth.accessTokenRef, | |
| 336 | + }); | |
| 337 | + } | |
| 338 | + | |
| 339 | + const resolveSecretMaterial = yield* SecretMaterialResolverService; | |
| 340 | + const updateSecretMaterial = yield* SecretMaterialUpdaterService; | |
| 341 | + const storeSecretMaterial = yield* SecretMaterialStorerService; | |
| 342 | + const refreshToken = yield* resolveSecretMaterial({ | |
| 343 | + ref: input.auth.refreshTokenRef!, | |
| 344 | + }); | |
| 345 | + const clientSecret = input.auth.clientSecretRef | |
| 346 | + ? yield* resolveSecretMaterial({ | |
| 347 | + ref: input.auth.clientSecretRef, | |
| 348 | + }) | |
| 349 | + : null; | |
| 350 | + const tokenResponse = yield* refreshOAuth2AccessToken({ | |
| 351 | + tokenEndpoint: input.auth.tokenEndpoint, | |
| 352 | + clientId: input.auth.clientId, | |
| 353 | + clientAuthentication: input.auth.clientAuthentication, | |
| 354 | + clientSecret, | |
| 355 | + refreshToken, | |
| 356 | + scopes: input.auth.scopes, | |
| 357 | + }); | |
| 358 | + | |
| 359 | + yield* updateSecretMaterial({ | |
| 360 | + ref: input.auth.accessTokenRef, | |
| 361 | + value: tokenResponse.access_token, | |
| 362 | + }); | |
| 363 | + | |
| 364 | + let refreshTokenRef = input.auth.refreshTokenRef; | |
| 365 | + if (tokenResponse.refresh_token) { | |
| 366 | + if (refreshTokenRef) { | |
| 367 | + yield* updateSecretMaterial({ | |
| 368 | + ref: refreshTokenRef, | |
| 369 | + value: tokenResponse.refresh_token, | |
| 370 | + }); | |
| 371 | + } else { | |
| 372 | + refreshTokenRef = yield* storeSecretMaterial({ | |
| 373 | + purpose: "oauth_refresh_token", | |
| 374 | + value: tokenResponse.refresh_token, | |
| 375 | + name: `${input.sourceId} Google Refresh Token`, | |
| 376 | + }); | |
| 377 | + } | |
| 378 | + } | |
| 379 | + | |
| 380 | + const stored = yield* input.storage.get({ | |
| 381 | + scopeId: input.scopeId, | |
| 382 | + sourceId: input.sourceId, | |
| 383 | + }); | |
| 384 | + if (stored) { | |
| 385 | + yield* input.storage.put({ | |
| 386 | + scopeId: input.scopeId, | |
| 387 | + sourceId: input.sourceId, | |
| 388 | + value: decodeStoredSourceData({ | |
| 389 | + ...stored, | |
| 390 | + auth: { | |
| 391 | + ...stored.auth, | |
| 392 | + ...(stored.auth.kind === "oauth2" | |
| 393 | + ? { | |
| 394 | + refreshTokenRef, | |
| 395 | + expiresAt: | |
| 396 | + typeof tokenResponse.expires_in === "number" | |
| 397 | + ? Date.now() + tokenResponse.expires_in * 1000 | |
| 398 | + : stored.auth.expiresAt, | |
| 399 | + } | |
| 400 | + : {}), | |
| 401 | + }, | |
| 402 | + }), | |
| 403 | + }); | |
| 404 | + } | |
| 405 | + | |
| 406 | + return tokenResponse.access_token; | |
| 407 | + }); | |
| 408 | + | |
| 409 | +const resolveGoogleAuthHeaders = (input: { | |
| 410 | + scopeId: string; | |
| 411 | + sourceId: string; | |
| 412 | + stored: GoogleDiscoveryStoredSourceData; | |
| 413 | + storage: GoogleDiscoverySourceStorage; | |
| 414 | +}): Effect.Effect<Record<string, string>, Error, any> => | |
| 415 | + Effect.gen(function* () { | |
| 416 | + if (input.stored.auth.kind === "none") { | |
| 417 | + return { | |
| 418 | + ...(input.stored.defaultHeaders ?? {}), | |
| 419 | + }; | |
| 420 | + } | |
| 421 | + | |
| 422 | + if (input.stored.auth.kind === "bearer") { | |
| 423 | + const resolveSecretMaterial = yield* SecretMaterialResolverService; | |
| 424 | + const token = yield* resolveSecretMaterial({ | |
| 425 | + ref: input.stored.auth.tokenSecretRef, | |
| 426 | + }); | |
| 427 | + return { | |
| 428 | + ...(input.stored.defaultHeaders ?? {}), | |
| 429 | + authorization: `Bearer ${token.trim()}`, | |
| 430 | + }; | |
| 431 | + } | |
| 432 | + | |
| 433 | + const token = yield* resolveOauthAccessToken({ | |
| 434 | + scopeId: input.scopeId, | |
| 435 | + sourceId: input.sourceId, | |
| 436 | + auth: input.stored.auth, | |
| 437 | + storage: input.storage, | |
| 438 | + }); | |
| 439 | + | |
| 440 | + return { | |
| 441 | + ...(input.stored.defaultHeaders ?? {}), | |
| 442 | + authorization: `Bearer ${token.trim()}`, | |
| 443 | + }; | |
| 444 | + }); | |
| 445 | + | |
| 446 | +const storedSourceDataFromInput = ( | |
| 447 | + input: GoogleDiscoveryConnectInput, | |
| 448 | +): GoogleDiscoveryStoredSourceData => | |
| 449 | + decodeStoredSourceData({ | |
| 450 | + service: input.service.trim(), | |
| 451 | + version: input.version.trim(), | |
| 452 | + discoveryUrl: input.discoveryUrl?.trim() || defaultGoogleDiscoveryUrl(input.service, input.version), | |
| 453 | + defaultHeaders: input.defaultHeaders, | |
| 454 | + scopes: [...input.scopes], | |
| 455 | + auth: input.auth, | |
| 456 | + }); | |
| 457 | + | |
| 458 | +const sourceConfigFromStored = ( | |
| 459 | + source: Source, | |
| 460 | + stored: GoogleDiscoveryStoredSourceData, | |
| 461 | +): GoogleDiscoverySourceConfigPayload => ({ | |
| 462 | + name: source.name, | |
| 463 | + service: stored.service, | |
| 464 | + version: stored.version, | |
| 465 | + discoveryUrl: stored.discoveryUrl, | |
| 466 | + defaultHeaders: stored.defaultHeaders, | |
| 467 | + scopes: [...stored.scopes], | |
| 468 | + auth: stored.auth, | |
| 469 | +}); | |
| 470 | + | |
| 471 | +const createGoogleDiscoverySourceSdk = ( | |
| 472 | + options: { | |
| 473 | + storage: GoogleDiscoverySourceStorage; | |
| 474 | + oauthSessions: GoogleDiscoveryOAuthSessionStorage; | |
| 475 | + }, | |
| 476 | + host: ExecutorSdkPluginHost, | |
| 477 | +) => ({ | |
| 478 | + getSourceConfig: (sourceId: Source["id"]) => | |
| 479 | + Effect.gen(function* () { | |
| 480 | + const source = yield* host.sources.get(sourceId); | |
| 481 | + if (source.kind !== "google_discovery") { | |
| 482 | + return yield* Effect.fail( | |
| 483 | + new Error(`Source ${sourceId} is not a Google Discovery source.`), | |
| 484 | + ); | |
| 485 | + } | |
| 486 | + | |
| 487 | + const stored = yield* options.storage.get({ | |
| 488 | + scopeId: source.scopeId, | |
| 489 | + sourceId: source.id, | |
| 490 | + }); | |
| 491 | + if (stored === null) { | |
| 492 | + return yield* Effect.fail( | |
| 493 | + new Error(`Google Discovery source storage missing for ${source.id}`), | |
| 494 | + ); | |
| 495 | + } | |
| 496 | + | |
| 497 | + return sourceConfigFromStored(source, stored); | |
| 498 | + }), | |
| 499 | + createSource: (input: GoogleDiscoveryConnectInput) => | |
| 500 | + Effect.gen(function* () { | |
| 501 | + const stored = storedSourceDataFromInput(input); | |
| 502 | + const createdSource = yield* host.sources.create({ | |
| 503 | + source: { | |
| 504 | + name: input.name.trim(), | |
| 505 | + kind: "google_discovery", | |
| 506 | + status: "connected", | |
| 507 | + enabled: true, | |
| 508 | + namespace: deriveGoogleDiscoveryNamespace(input.service), | |
| 509 | + }, | |
| 510 | + }); | |
| 511 | + | |
| 512 | + yield* options.storage.put({ | |
| 513 | + scopeId: createdSource.scopeId, | |
| 514 | + sourceId: createdSource.id, | |
| 515 | + value: stored, | |
| 516 | + }); | |
| 517 | + | |
| 518 | + return yield* host.sources.refreshCatalog(createdSource.id); | |
| 519 | + }), | |
| 520 | + updateSource: (input: GoogleDiscoveryUpdateSourceInput) => | |
| 521 | + Effect.gen(function* () { | |
| 522 | + const source = yield* host.sources.get(input.sourceId as Source["id"]); | |
| 523 | + if (source.kind !== "google_discovery") { | |
| 524 | + return yield* Effect.fail( | |
| 525 | + new Error(`Source ${input.sourceId} is not a Google Discovery source.`), | |
| 526 | + ); | |
| 527 | + } | |
| 528 | + | |
| 529 | + const stored = storedSourceDataFromInput(input.config); | |
| 530 | + const savedSource = yield* host.sources.save({ | |
| 531 | + ...source, | |
| 532 | + name: input.config.name.trim(), | |
| 533 | + namespace: deriveGoogleDiscoveryNamespace(input.config.service), | |
| 534 | + }); | |
| 535 | + | |
| 536 | + yield* options.storage.put({ | |
| 537 | + scopeId: savedSource.scopeId, | |
| 538 | + sourceId: savedSource.id, | |
| 539 | + value: stored, | |
| 540 | + }); | |
| 541 | + | |
| 542 | + return yield* host.sources.refreshCatalog(savedSource.id); | |
| 543 | + }), | |
| 544 | + removeSource: (sourceId: Source["id"]) => | |
| 545 | + Effect.gen(function* () { | |
| 546 | + const source = yield* host.sources.get(sourceId); | |
| 547 | + if (source.kind !== "google_discovery") { | |
| 548 | + return yield* Effect.fail( | |
| 549 | + new Error(`Source ${sourceId} is not a Google Discovery source.`), | |
| 550 | + ); | |
| 551 | + } | |
| 552 | + | |
| 553 | + const stored = yield* options.storage.get({ | |
| 554 | + scopeId: source.scopeId, | |
| 555 | + sourceId: source.id, | |
| 556 | + }); | |
| 557 | + const deleteSecretMaterial = yield* SecretMaterialDeleterService; | |
| 558 | + if (stored?.auth.kind === "oauth2") { | |
| 559 | + yield* Effect.either(deleteSecretMaterial(stored.auth.accessTokenRef)); | |
| 560 | + if (stored.auth.refreshTokenRef) { | |
| 561 | + yield* Effect.either(deleteSecretMaterial(stored.auth.refreshTokenRef)); | |
| 562 | + } | |
| 563 | + } | |
| 564 | + | |
| 565 | + if (options.storage.remove) { | |
| 566 | + yield* options.storage.remove({ | |
| 567 | + scopeId: source.scopeId, | |
| 568 | + sourceId: source.id, | |
| 569 | + }); | |
| 570 | + } | |
| 571 | + | |
| 572 | + return yield* host.sources.remove(source.id); | |
| 573 | + }), | |
| 574 | +}); | |
| 575 | + | |
| 576 | +const googleDiscoverySourceConnector = ( | |
| 577 | + options: { | |
| 578 | + storage: GoogleDiscoverySourceStorage; | |
| 579 | + oauthSessions: GoogleDiscoveryOAuthSessionStorage; | |
| 580 | + }, | |
| 581 | +): ExecutorSourceConnector<GoogleDiscoveryExecutorAddInput> => ({ | |
| 582 | + kind: "google_discovery", | |
| 583 | + displayName: "Google Discovery", | |
| 584 | + inputSchema: GoogleDiscoveryExecutorAddInputSchema, | |
| 585 | + inputSignatureWidth: 320, | |
| 586 | + helpText: [ | |
| 587 | + "Provide the Google API service and version plus the final auth configuration.", | |
| 588 | + "OAuth browser flows stay on the plugin-owned HTTP/UI surfaces.", | |
| 589 | + ], | |
| 590 | + createSource: ({ args, host }) => | |
| 591 | + createGoogleDiscoverySourceSdk(options, host).createSource(args), | |
| 592 | +}); | |
| 593 | + | |
| 594 | +const createGoogleDiscoverySourceRuntime = (options: { | |
| 595 | + storage: GoogleDiscoverySourceStorage; | |
| 596 | +}): SourcePluginRuntime => ({ | |
| 597 | + kind: "google_discovery", | |
| 598 | + displayName: "Google Discovery", | |
| 599 | + catalogKind: "imported", | |
| 600 | + catalogIdentity: ({ source }) => ({ | |
| 601 | + kind: "google_discovery", | |
| 602 | + sourceId: source.id, | |
| 603 | + }), | |
| 604 | + getIrModel: ({ source }) => | |
| 605 | + Effect.gen(function* () { | |
| 606 | + const stored = yield* options.storage.get({ | |
| 607 | + scopeId: source.scopeId, | |
| 608 | + sourceId: source.id, | |
| 609 | + }); | |
| 610 | + if (stored === null) { | |
| 611 | + return createSourceCatalogSyncResult({ | |
| 612 | + fragment: { | |
| 613 | + version: "ir.v1.fragment", | |
| 614 | + }, | |
| 615 | + importMetadata: { | |
| 616 | + ...createCatalogImportMetadata({ | |
| 617 | + source, | |
| 618 | + pluginKey: "google_discovery", | |
| 619 | + }), | |
| 620 | + importerVersion: "ir.v1.google_discovery", | |
| 621 | + sourceConfigHash: "missing", | |
| 622 | + }, | |
| 623 | + sourceHash: null, | |
| 624 | + }); | |
| 625 | + } | |
| 626 | + | |
| 627 | + const headers = yield* resolveGoogleAuthHeaders({ | |
| 628 | + scopeId: source.scopeId, | |
| 629 | + sourceId: source.id, | |
| 630 | + stored, | |
| 631 | + storage: options.storage, | |
| 632 | + }); | |
| 633 | + const document = yield* fetchGoogleDiscoveryDocumentWithHeaders({ | |
| 634 | + url: stored.discoveryUrl, | |
| 635 | + headers, | |
| 636 | + }); | |
| 637 | + const manifest = yield* extractGoogleDiscoveryManifest(source.name, document); | |
| 638 | + const definitions = compileGoogleDiscoveryToolDefinitions(manifest); | |
| 639 | + const operations = definitions.map((definition) => | |
| 640 | + googleDiscoveryCatalogOperationFromDefinition({ | |
| 641 | + manifest, | |
| 642 | + definition, | |
| 643 | + }) | |
| 644 | + ); | |
| 645 | + const now = Date.now(); | |
| 646 | + | |
| 647 | + return createSourceCatalogSyncResult({ | |
| 648 | + fragment: createGoogleDiscoveryCatalogFragment({ | |
| 649 | + source, | |
| 650 | + documents: [ | |
| 651 | + { | |
| 652 | + documentKind: "google_discovery", | |
| 653 | + documentKey: stored.discoveryUrl, | |
| 654 | + contentText: document, | |
| 655 | + fetchedAt: now, | |
| 656 | + }, | |
| 657 | + ], | |
| 658 | + operations, | |
| 659 | + }), | |
| 660 | + importMetadata: { | |
| 661 | + ...createCatalogImportMetadata({ | |
| 662 | + source, | |
| 663 | + pluginKey: "google_discovery", | |
| 664 | + }), | |
| 665 | + importerVersion: "ir.v1.google_discovery", | |
| 666 | + }, | |
| 667 | + sourceHash: manifest.sourceHash, | |
| 668 | + }); | |
| 669 | + }), | |
| 670 | + invoke: (input) => | |
| 671 | + Effect.gen(function* () { | |
| 672 | + const stored = yield* options.storage.get({ | |
| 673 | + scopeId: input.source.scopeId, | |
| 674 | + sourceId: input.source.id, | |
| 675 | + }); | |
| 676 | + if (stored === null) { | |
| 677 | + return yield* Effect.fail( | |
| 678 | + new Error(`Google Discovery source storage missing for ${input.source.id}`), | |
| 679 | + ); | |
| 680 | + } | |
| 681 | + | |
| 682 | + const providerData = decodeProviderData( | |
| 683 | + input.executable.binding, | |
| 684 | + ) as GoogleDiscoveryToolProviderData; | |
| 685 | + const args = asRecord(input.args); | |
| 686 | + const headers: Record<string, string> = yield* resolveGoogleAuthHeaders({ | |
| 687 | + scopeId: input.source.scopeId, | |
| 688 | + sourceId: input.source.id, | |
| 689 | + stored, | |
| 690 | + storage: options.storage, | |
| 691 | + }); | |
| 692 | + const requestUrl = new URL( | |
| 693 | + replacePathParameters({ | |
| 694 | + pathTemplate: providerData.invocation.path, | |
| 695 | + args, | |
| 696 | + parameters: providerData.invocation.parameters, | |
| 697 | + }), | |
| 698 | + resolveGoogleDiscoveryBaseUrl({ | |
| 699 | + providerData, | |
| 700 | + }), | |
| 701 | + ); | |
| 702 | + | |
| 703 | + let body: string | undefined; | |
| 704 | + for (const parameter of providerData.invocation.parameters) { | |
| 705 | + const rawValue = args[parameter.name]; | |
| 706 | + if (rawValue === undefined || rawValue === null) { | |
| 707 | + continue; | |
| 708 | + } | |
| 709 | + | |
| 710 | + if (parameter.location === "query") { | |
| 711 | + for (const entry of stringValuesFromParameter(rawValue, parameter.repeated)) { | |
| 712 | + requestUrl.searchParams.append(parameter.name, entry); | |
| 713 | + } | |
| 714 | + continue; | |
| 715 | + } | |
| 716 | + | |
| 717 | + if (parameter.location === "header") { | |
| 718 | + headers[parameter.name] = stringValuesFromParameter(rawValue, false)[0] ?? ""; | |
| 719 | + } | |
| 720 | + } | |
| 721 | + | |
| 722 | + if (providerData.invocation.requestSchemaId) { | |
| 723 | + const payload = args.body ?? args.input; | |
| 724 | + if (payload !== undefined) { | |
| 725 | + headers["content-type"] = "application/json"; | |
| 726 | + body = JSON.stringify(payload); | |
| 727 | + } | |
| 728 | + } | |
| 729 | + | |
| 730 | + const response = yield* Effect.tryPromise({ | |
| 731 | + try: () => | |
| 732 | + fetch(requestUrl.toString(), { | |
| 733 | + method: providerData.invocation.method.toUpperCase(), | |
| 734 | + headers, | |
| 735 | + ...(body ? { body } : {}), | |
| 736 | + }), | |
| 737 | + catch: (cause) => | |
| 738 | + cause instanceof Error ? cause : new Error(String(cause)), | |
| 739 | + }); | |
| 740 | + const responseBody = yield* Effect.tryPromise({ | |
| 741 | + try: () => decodeResponseBody(response), | |
| 742 | + catch: (cause) => | |
| 743 | + cause instanceof Error ? cause : new Error(String(cause)), | |
| 744 | + }); | |
| 745 | + | |
| 746 | + return { | |
| 747 | + data: response.ok ? responseBody : null, | |
| 748 | + error: response.ok ? null : responseBody, | |
| 749 | + headers: responseHeadersRecord(response), | |
| 750 | + status: response.status, | |
| 751 | + }; | |
| 752 | + }), | |
| 753 | +}); | |
| 754 | + | |
| 755 | +export const googleDiscoverySdkPlugin = (options: { | |
| 756 | + storage: GoogleDiscoverySourceStorage; | |
| 757 | + oauthSessions: GoogleDiscoveryOAuthSessionStorage; | |
| 758 | +}): ExecutorSdkPlugin<"googleDiscovery", GoogleDiscoverySdk> => ({ | |
| 759 | + key: "googleDiscovery", | |
| 760 | + sources: [createGoogleDiscoverySourceRuntime(options)], | |
| 761 | + sourceConnectors: [googleDiscoverySourceConnector(options)], | |
| 762 | + extendExecutor: ({ host, executor }) => { | |
| 763 | + const sourceSdk = createGoogleDiscoverySourceSdk(options, host); | |
| 764 | + const provideRuntime = <A>( | |
| 765 | + effect: Effect.Effect<A, Error, any>, | |
| 766 | + ): Effect.Effect<A, Error, never> => | |
| 767 | + provideExecutorRuntime(effect, executor.runtime); | |
| 768 | + | |
| 769 | + return { | |
| 770 | + getSourceConfig: (sourceId) => | |
| 771 | + provideRuntime(sourceSdk.getSourceConfig(sourceId)), | |
| 772 | + createSource: (input) => | |
| 773 | + provideRuntime(sourceSdk.createSource(input)), | |
| 774 | + updateSource: (input) => | |
| 775 | + provideRuntime(sourceSdk.updateSource(input)), | |
| 776 | + removeSource: (sourceId) => | |
| 777 | + provideRuntime(sourceSdk.removeSource(sourceId)), | |
| 778 | + startOAuth: (input) => | |
| 779 | + provideRuntime( | |
| 780 | + Effect.gen(function* () { | |
| 781 | + const discoveryUrl = | |
| 782 | + input.discoveryUrl?.trim() | |
| 783 | + || defaultGoogleDiscoveryUrl(input.service, input.version); | |
| 784 | + const scopes = | |
| 785 | + input.scopes.length > 0 | |
| 786 | + ? [...new Set(input.scopes.map((scope) => scope.trim()).filter(Boolean))] | |
| 787 | + : yield* fetchGoogleDiscoveryDocumentWithHeaders({ | |
| 788 | + url: discoveryUrl, | |
| 789 | + headers: input.defaultHeaders ?? {}, | |
| 790 | + }).pipe( | |
| 791 | + Effect.flatMap((document) => | |
| 792 | + inferScopesFromDiscoveryDocument({ | |
| 793 | + service: input.service, | |
| 794 | + document, | |
| 795 | + }) | |
| 796 | + ), | |
| 797 | + ); | |
| 798 | + const sessionId = `gdisc_oauth_${crypto.randomUUID()}`; | |
| 799 | + const codeVerifier = createPkceCodeVerifier(); | |
| 800 | + | |
| 801 | + yield* options.oauthSessions.put({ | |
| 802 | + sessionId, | |
| 803 | + value: decodeSession({ | |
| 804 | + service: input.service.trim(), | |
| 805 | + version: input.version.trim(), | |
| 806 | + discoveryUrl, | |
| 807 | + defaultHeaders: input.defaultHeaders, | |
| 808 | + scopes: [...scopes], | |
| 809 | + clientId: input.clientId.trim(), | |
| 810 | + clientSecretRef: input.clientSecretRef, | |
| 811 | + clientAuthentication: input.clientAuthentication, | |
| 812 | + redirectUrl: input.redirectUrl, | |
| 813 | + codeVerifier, | |
| 814 | + }), | |
| 815 | + }); | |
| 816 | + | |
| 817 | + return { | |
| 818 | + sessionId, | |
| 819 | + authorizationUrl: buildOAuth2AuthorizationUrl({ | |
| 820 | + authorizationEndpoint: GOOGLE_AUTHORIZATION_ENDPOINT, | |
| 821 | + clientId: input.clientId.trim(), | |
| 822 | + redirectUri: input.redirectUrl, | |
| 823 | + scopes, | |
| 824 | + state: sessionId, | |
| 825 | + codeVerifier, | |
| 826 | + extraParams: { | |
| 827 | + access_type: "offline", | |
| 828 | + prompt: "consent", | |
| 829 | + include_granted_scopes: "true", | |
| 830 | + }, | |
| 831 | + }), | |
| 832 | + scopes: [...scopes], | |
| 833 | + }; | |
| 834 | + }), | |
| 835 | + ), | |
| 836 | + completeOAuth: (input) => | |
| 837 | + provideRuntime( | |
| 838 | + Effect.gen(function* () { | |
| 839 | + if (input.error) { | |
| 840 | + return yield* Effect.fail( | |
| 841 | + new Error( | |
| 842 | + input.errorDescription || input.error || "Google OAuth failed", | |
| 843 | + ), | |
| 844 | + ); | |
| 845 | + } | |
| 846 | + if (!input.code) { | |
| 847 | + return yield* Effect.fail(new Error("Missing Google OAuth code.")); | |
| 848 | + } | |
| 849 | + | |
| 850 | + const session = yield* options.oauthSessions.get(input.state); | |
| 851 | + if (session === null) { | |
| 852 | + return yield* Effect.fail( | |
| 853 | + new Error(`Google OAuth session not found: ${input.state}`), | |
| 854 | + ); | |
| 855 | + } | |
| 856 | + | |
| 857 | + const resolveSecretMaterial = yield* SecretMaterialResolverService; | |
| 858 | + const storeSecretMaterial = yield* SecretMaterialStorerService; | |
| 859 | + const clientSecret = session.clientSecretRef | |
| 860 | + ? yield* resolveSecretMaterial({ | |
| 861 | + ref: session.clientSecretRef, | |
| 862 | + }) | |
| 863 | + : null; | |
| 864 | + const tokenResponse = yield* exchangeOAuth2AuthorizationCode({ | |
| 865 | + tokenEndpoint: GOOGLE_TOKEN_ENDPOINT, | |
| 866 | + clientId: session.clientId, | |
| 867 | + clientAuthentication: session.clientAuthentication, | |
| 868 | + clientSecret, | |
| 869 | + redirectUri: session.redirectUrl, | |
| 870 | + codeVerifier: session.codeVerifier, | |
| 871 | + code: input.code, | |
| 872 | + }); | |
| 873 | + const accessTokenRef = yield* storeSecretMaterial({ | |
| 874 | + purpose: "oauth_access_token", | |
| 875 | + value: tokenResponse.access_token, | |
| 876 | + name: `${session.service} Google Access Token`, | |
| 877 | + }); | |
| 878 | + const refreshTokenRef = tokenResponse.refresh_token | |
| 879 | + ? yield* storeSecretMaterial({ | |
| 880 | + purpose: "oauth_refresh_token", | |
| 881 | + value: tokenResponse.refresh_token, | |
| 882 | + name: `${session.service} Google Refresh Token`, | |
| 883 | + }) | |
| 884 | + : null; | |
| 885 | + | |
| 886 | + if (options.oauthSessions.remove) { | |
| 887 | + yield* options.oauthSessions.remove(input.state); | |
| 888 | + } | |
| 889 | + | |
| 890 | + return { | |
| 891 | + type: "executor:oauth-result", | |
| 892 | + ok: true as const, | |
| 893 | + sessionId: input.state, | |
| 894 | + auth: { | |
| 895 | + kind: "oauth2" as const, | |
| 896 | + clientId: session.clientId, | |
| 897 | + clientSecretRef: session.clientSecretRef, | |
| 898 | + clientAuthentication: session.clientAuthentication, | |
| 899 | + authorizationEndpoint: GOOGLE_AUTHORIZATION_ENDPOINT, | |
| 900 | + tokenEndpoint: GOOGLE_TOKEN_ENDPOINT, | |
| 901 | + scopes: [...session.scopes], | |
| 902 | + accessTokenRef, | |
| 903 | + refreshTokenRef, | |
| 904 | + expiresAt: | |
| 905 | + typeof tokenResponse.expires_in === "number" | |
| 906 | + ? Date.now() + tokenResponse.expires_in * 1000 | |
| 907 | + : null, | |
| 908 | + }, | |
| 909 | + }; | |
| 910 | + }), | |
| 911 | + ), | |
| 912 | + }; | |
| 913 | + }, | |
| 914 | +}); | |
| @@ -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 | +} | |
| @@ -0,0 +1,531 @@ | ||
| 1 | +import { | |
| 2 | + FetchHttpClient, | |
| 3 | + HttpClient, | |
| 4 | + type HttpClientResponse, | |
| 5 | + HttpClientRequest, | |
| 6 | +} from "@effect/platform"; | |
| 7 | +import { | |
| 8 | + applyCookiePlacementsToHeaders, | |
| 9 | + applyHttpQueryPlacementsToUrl, | |
| 10 | + applyJsonBodyPlacements, | |
| 11 | + standardSchemaFromJsonSchema, | |
| 12 | + typeSignatureFromSchema, | |
| 13 | + toTool, | |
| 14 | + type HttpRequestPlacements, | |
| 15 | + type ToolMetadata, | |
| 16 | +} from "@executor/codemode-core"; | |
| 17 | +import { sourceCoreEffectError } from "@executor/source-core"; | |
| 18 | +import * as Effect from "effect/Effect"; | |
| 19 | +import * as Layer from "effect/Layer"; | |
| 20 | +import * as Schema from "effect/Schema"; | |
| 21 | + | |
| 22 | +import type { | |
| 23 | + GoogleDiscoveryManifestMethod, | |
| 24 | + GoogleDiscoverySchemaRefTable, | |
| 25 | + GoogleDiscoveryToolDefinition, | |
| 26 | +} from "./types"; | |
| 27 | +import { | |
| 28 | + GoogleDiscoveryToolProviderDataSchema, | |
| 29 | +} from "./types"; | |
| 30 | +import * as Either from "effect/Either"; | |
| 31 | + | |
| 32 | +const decodeGoogleDiscoveryToolProviderDataJson = Schema.decodeUnknownEither( | |
| 33 | + GoogleDiscoveryToolProviderDataSchema, | |
| 34 | +); | |
| 35 | + | |
| 36 | +export const googleDiscoveryProviderDataFromDefinition = ( | |
| 37 | + input: { | |
| 38 | + service: string; | |
| 39 | + version: string; | |
| 40 | + rootUrl: string; | |
| 41 | + servicePath: string; | |
| 42 | + oauthScopes?: Readonly<Record<string, string>>; | |
| 43 | + definition: GoogleDiscoveryManifestMethod; | |
| 44 | + }, | |
| 45 | +): typeof GoogleDiscoveryToolProviderDataSchema.Type => ({ | |
| 46 | + kind: "google_discovery", | |
| 47 | + service: input.service, | |
| 48 | + version: input.version, | |
| 49 | + toolId: input.definition.toolId, | |
| 50 | + rawToolId: input.definition.rawToolId, | |
| 51 | + methodId: input.definition.methodId, | |
| 52 | + group: input.definition.group, | |
| 53 | + leaf: input.definition.leaf, | |
| 54 | + invocation: { | |
| 55 | + method: input.definition.method, | |
| 56 | + path: input.definition.path, | |
| 57 | + flatPath: input.definition.flatPath, | |
| 58 | + rootUrl: input.rootUrl, | |
| 59 | + servicePath: input.servicePath, | |
| 60 | + parameters: input.definition.parameters, | |
| 61 | + requestSchemaId: input.definition.requestSchemaId, | |
| 62 | + responseSchemaId: input.definition.responseSchemaId, | |
| 63 | + scopes: input.definition.scopes, | |
| 64 | + ...(input.oauthScopes | |
| 65 | + ? { | |
| 66 | + scopeDescriptions: Object.fromEntries( | |
| 67 | + input.definition.scopes.flatMap((scope) => | |
| 68 | + input.oauthScopes?.[scope] !== undefined | |
| 69 | + ? [[scope, input.oauthScopes[scope]!]] | |
| 70 | + : [], | |
| 71 | + ), | |
| 72 | + ), | |
| 73 | + } | |
| 74 | + : {}), | |
| 75 | + supportsMediaUpload: input.definition.supportsMediaUpload, | |
| 76 | + supportsMediaDownload: input.definition.supportsMediaDownload, | |
| 77 | + }, | |
| 78 | + }); | |
| 79 | + | |
| 80 | +const decodeGoogleDiscoverySchemaRefTableJson = Schema.decodeUnknownEither( | |
| 81 | + Schema.parseJson( | |
| 82 | + Schema.Record({ | |
| 83 | + key: Schema.String, | |
| 84 | + value: Schema.Unknown, | |
| 85 | + }), | |
| 86 | + ), | |
| 87 | +); | |
| 88 | + | |
| 89 | +const asRecord = (value: unknown): Record<string, unknown> => | |
| 90 | + typeof value === "object" && value !== null && !Array.isArray(value) | |
| 91 | + ? value as Record<string, unknown> | |
| 92 | + : {}; | |
| 93 | + | |
| 94 | +const normalizeCredentialPlacements = (input: { | |
| 95 | + credentialHeaders?: Readonly<Record<string, string>>; | |
| 96 | + credentialPlacements?: HttpRequestPlacements; | |
| 97 | +}): HttpRequestPlacements => ({ | |
| 98 | + headers: { | |
| 99 | + ...input.credentialHeaders, | |
| 100 | + ...input.credentialPlacements?.headers, | |
| 101 | + }, | |
| 102 | + queryParams: input.credentialPlacements?.queryParams, | |
| 103 | + cookies: input.credentialPlacements?.cookies, | |
| 104 | + bodyValues: input.credentialPlacements?.bodyValues, | |
| 105 | +}); | |
| 106 | + | |
| 107 | +const setNestedSchemaProperty = ( | |
| 108 | + target: Record<string, unknown>, | |
| 109 | + path: readonly string[], | |
| 110 | + value: unknown, | |
| 111 | +): void => { | |
| 112 | + if (path.length === 0) { | |
| 113 | + return; | |
| 114 | + } | |
| 115 | + | |
| 116 | + const [head, ...rest] = path; | |
| 117 | + if (!head) { | |
| 118 | + return; | |
| 119 | + } | |
| 120 | + | |
| 121 | + if (rest.length === 0) { | |
| 122 | + target[head] = value; | |
| 123 | + return; | |
| 124 | + } | |
| 125 | + | |
| 126 | + const next = asRecord(target[head]); | |
| 127 | + target[head] = next; | |
| 128 | + setNestedSchemaProperty(next, rest, value); | |
| 129 | +}; | |
| 130 | + | |
| 131 | +const materializeSchemaWithRefDefinitions = (input: { | |
| 132 | + schema: unknown; | |
| 133 | + refTable?: Readonly<Record<string, unknown>>; | |
| 134 | +}): Record<string, unknown> => { | |
| 135 | + if (input.schema === undefined || input.schema === null) { | |
| 136 | + return {}; | |
| 137 | + } | |
| 138 | + | |
| 139 | + const rootSchema = asRecord(input.schema); | |
| 140 | + | |
| 141 | + if (!input.refTable || Object.keys(input.refTable).length === 0) { | |
| 142 | + return rootSchema; | |
| 143 | + } | |
| 144 | + | |
| 145 | + const defsRoot = asRecord(rootSchema.$defs); | |
| 146 | + for (const [ref, value] of Object.entries(input.refTable)) { | |
| 147 | + if (!ref.startsWith("#/$defs/")) { | |
| 148 | + continue; | |
| 149 | + } | |
| 150 | + | |
| 151 | + const materializedValue = | |
| 152 | + typeof value === "string" | |
| 153 | + ? (() => { | |
| 154 | + try { | |
| 155 | + return JSON.parse(value) as unknown; | |
| 156 | + } catch { | |
| 157 | + return value; | |
| 158 | + } | |
| 159 | + })() | |
| 160 | + : value; | |
| 161 | + | |
| 162 | + const path = ref | |
| 163 | + .slice("#/$defs/".length) | |
| 164 | + .split("/") | |
| 165 | + .filter((segment) => segment.length > 0); | |
| 166 | + | |
| 167 | + setNestedSchemaProperty(defsRoot, path, materializedValue); | |
| 168 | + } | |
| 169 | + | |
| 170 | + return Object.keys(defsRoot).length > 0 | |
| 171 | + ? { ...rootSchema, $defs: defsRoot } | |
| 172 | + : rootSchema; | |
| 173 | +}; | |
| 174 | + | |
| 175 | +const decodeResponseBody = (response: HttpClientResponse.HttpClientResponse) => | |
| 176 | + Effect.gen(function* () { | |
| 177 | + const contentType = response.headers["content-type"]?.toLowerCase() ?? ""; | |
| 178 | + const text = yield* response.text.pipe( | |
| 179 | + Effect.mapError((cause) => | |
| 180 | + cause instanceof Error ? cause : new Error(String(cause)), | |
| 181 | + ), | |
| 182 | + ); | |
| 183 | + | |
| 184 | + if (text.trim().length === 0) { | |
| 185 | + return null; | |
| 186 | + } | |
| 187 | + | |
| 188 | + if (contentType.includes("application/json") || contentType.includes("+json")) { | |
| 189 | + try { | |
| 190 | + return JSON.parse(text) as unknown; | |
| 191 | + } catch { | |
| 192 | + return text; | |
| 193 | + } | |
| 194 | + } | |
| 195 | + | |
| 196 | + return text; | |
| 197 | + }); | |
| 198 | + | |
| 199 | +const stringValuesFromParameter = ( | |
| 200 | + value: unknown, | |
| 201 | + repeated: boolean, | |
| 202 | +): string[] => { | |
| 203 | + if (value === undefined || value === null) { | |
| 204 | + return []; | |
| 205 | + } | |
| 206 | + | |
| 207 | + if (Array.isArray(value)) { | |
| 208 | + const normalized = value | |
| 209 | + .flatMap((entry) => | |
| 210 | + entry === undefined || entry === null ? [] : [String(entry)], | |
| 211 | + ); | |
| 212 | + return repeated ? normalized : [normalized.join(",")]; | |
| 213 | + } | |
| 214 | + | |
| 215 | + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { | |
| 216 | + return [String(value)]; | |
| 217 | + } | |
| 218 | + | |
| 219 | + return [JSON.stringify(value)]; | |
| 220 | +}; | |
| 221 | + | |
| 222 | +const replacePathParameters = (input: { | |
| 223 | + pathTemplate: string; | |
| 224 | + args: Record<string, unknown>; | |
| 225 | + parameters: ReadonlyArray<GoogleDiscoveryManifestMethod["parameters"][number]>; | |
| 226 | +}): string => | |
| 227 | + input.pathTemplate.replaceAll(/\{([^}]+)\}/g, (_, name: string) => { | |
| 228 | + const parameter = input.parameters.find( | |
| 229 | + (entry) => entry.location === "path" && entry.name === name, | |
| 230 | + ); | |
| 231 | + const rawValue = input.args[name]; | |
| 232 | + if ((rawValue === undefined || rawValue === null) && parameter?.required) { | |
| 233 | + throw new Error(`Missing required path parameter: ${name}`); | |
| 234 | + } | |
| 235 | + | |
| 236 | + const values = stringValuesFromParameter(rawValue, false); | |
| 237 | + if (values.length === 0) { | |
| 238 | + return ""; | |
| 239 | + } | |
| 240 | + | |
| 241 | + return encodeURIComponent(values[0]!); | |
| 242 | + }); | |
| 243 | + | |
| 244 | +const resolveBaseUrl = (input: { | |
| 245 | + rootUrl: string; | |
| 246 | + servicePath: string; | |
| 247 | + baseUrl?: string | undefined; | |
| 248 | +}): string => { | |
| 249 | + if (input.baseUrl) { | |
| 250 | + return new URL(input.baseUrl).toString(); | |
| 251 | + } | |
| 252 | + | |
| 253 | + return new URL(input.servicePath || "", input.rootUrl).toString(); | |
| 254 | +}; | |
| 255 | + | |
| 256 | +const buildGoogleDiscoveryRequest = (input: { | |
| 257 | + definition: GoogleDiscoveryToolDefinition; | |
| 258 | + args: Record<string, unknown>; | |
| 259 | + defaultHeaders: Readonly<Record<string, string>>; | |
| 260 | + credentialPlacements: HttpRequestPlacements; | |
| 261 | + baseUrl?: string | undefined; | |
| 262 | + providerData: typeof GoogleDiscoveryToolProviderDataSchema.Type; | |
| 263 | +}): { | |
| 264 | + method: string; | |
| 265 | + url: URL; | |
| 266 | + headers: Record<string, string>; | |
| 267 | + body?: string; | |
| 268 | +} => { | |
| 269 | + const providerData = decodeGoogleDiscoveryToolProviderDataJson(input.providerData); | |
| 270 | + if (Either.isLeft(providerData)) { | |
| 271 | + throw new Error("Invalid Google Discovery provider data"); | |
| 272 | + } | |
| 273 | + | |
| 274 | + const invocation = providerData.right.invocation; | |
| 275 | + const resolvedPath = replacePathParameters({ | |
| 276 | + pathTemplate: invocation.path, | |
| 277 | + args: input.args, | |
| 278 | + parameters: input.definition.parameters, | |
| 279 | + }); | |
| 280 | + const url = new URL( | |
| 281 | + resolvedPath.replace(/^\//, ""), | |
| 282 | + resolveBaseUrl({ | |
| 283 | + rootUrl: invocation.rootUrl, | |
| 284 | + servicePath: invocation.servicePath, | |
| 285 | + baseUrl: input.baseUrl, | |
| 286 | + }), | |
| 287 | + ); | |
| 288 | + const headers: Record<string, string> = { | |
| 289 | + ...input.defaultHeaders, | |
| 290 | + }; | |
| 291 | + | |
| 292 | + for (const parameter of input.definition.parameters) { | |
| 293 | + if (parameter.location === "path") { | |
| 294 | + continue; | |
| 295 | + } | |
| 296 | + | |
| 297 | + const rawValue = input.args[parameter.name]; | |
| 298 | + if ((rawValue === undefined || rawValue === null) && parameter.required) { | |
| 299 | + throw new Error(`Missing required ${parameter.location} parameter: ${parameter.name}`); | |
| 300 | + } | |
| 301 | + | |
| 302 | + const values = stringValuesFromParameter(rawValue, parameter.repeated); | |
| 303 | + if (values.length === 0) { | |
| 304 | + continue; | |
| 305 | + } | |
| 306 | + | |
| 307 | + switch (parameter.location) { | |
| 308 | + case "query": | |
| 309 | + for (const value of values) { | |
| 310 | + url.searchParams.append(parameter.name, value); | |
| 311 | + } | |
| 312 | + break; | |
| 313 | + case "header": | |
| 314 | + headers[parameter.name] = parameter.repeated ? values.join(",") : values[0]!; | |
| 315 | + break; | |
| 316 | + } | |
| 317 | + } | |
| 318 | + | |
| 319 | + const urlWithAuth = applyHttpQueryPlacementsToUrl({ | |
| 320 | + url, | |
| 321 | + queryParams: input.credentialPlacements.queryParams, | |
| 322 | + }); | |
| 323 | + const headersWithCookies = applyCookiePlacementsToHeaders({ | |
| 324 | + headers, | |
| 325 | + cookies: input.credentialPlacements.cookies, | |
| 326 | + }); | |
| 327 | + for (const [key, value] of Object.entries(input.credentialPlacements.headers ?? {})) { | |
| 328 | + headersWithCookies[key] = value; | |
| 329 | + } | |
| 330 | + | |
| 331 | + let body: string | undefined; | |
| 332 | + if (input.definition.requestSchemaId !== null) { | |
| 333 | + const rawBody = input.args.body; | |
| 334 | + const bodyValues = input.credentialPlacements.bodyValues ?? {}; | |
| 335 | + const hasBodyValues = Object.keys(bodyValues).length > 0; | |
| 336 | + if (rawBody !== undefined || hasBodyValues) { | |
| 337 | + body = JSON.stringify( | |
| 338 | + applyJsonBodyPlacements({ | |
| 339 | + body: rawBody !== undefined ? rawBody : {}, | |
| 340 | + bodyValues, | |
| 341 | + label: `${input.definition.method.toUpperCase()} ${input.definition.path}`, | |
| 342 | + }), | |
| 343 | + ); | |
| 344 | + if (!("content-type" in headersWithCookies)) { | |
| 345 | + headersWithCookies["content-type"] = "application/json"; | |
| 346 | + } | |
| 347 | + } | |
| 348 | + } | |
| 349 | + | |
| 350 | + return { | |
| 351 | + method: input.definition.method.toUpperCase(), | |
| 352 | + url: urlWithAuth, | |
| 353 | + headers: headersWithCookies, | |
| 354 | + ...(body !== undefined ? { body } : {}), | |
| 355 | + }; | |
| 356 | +}; | |
| 357 | + | |
| 358 | +export type CreateGoogleDiscoveryToolFromDefinitionInput = { | |
| 359 | + definition: GoogleDiscoveryToolDefinition; | |
| 360 | + service: string; | |
| 361 | + version: string; | |
| 362 | + rootUrl: string; | |
| 363 | + servicePath: string; | |
| 364 | + oauthScopes?: Readonly<Record<string, string>>; | |
| 365 | + path: string; | |
| 366 | + sourceKey: string; | |
| 367 | + defaultHeaders?: Readonly<Record<string, string>>; | |
| 368 | + credentialHeaders?: Readonly<Record<string, string>>; | |
| 369 | + credentialPlacements?: HttpRequestPlacements; | |
| 370 | + schemaRefTable?: Readonly<GoogleDiscoverySchemaRefTable>; | |
| 371 | + baseUrl?: string; | |
| 372 | + httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>; | |
| 373 | +}; | |
| 374 | + | |
| 375 | +export type GoogleDiscoveryToolPresentation = { | |
| 376 | + inputTypePreview: string; | |
| 377 | + outputTypePreview: string; | |
| 378 | + inputSchema?: unknown; | |
| 379 | + outputSchema?: unknown; | |
| 380 | + providerData: typeof GoogleDiscoveryToolProviderDataSchema.Type; | |
| 381 | +}; | |
| 382 | + | |
| 383 | +export const buildGoogleDiscoveryToolPresentation = (input: { | |
| 384 | + manifest: { | |
| 385 | + service: string; | |
| 386 | + versionName: string; | |
| 387 | + rootUrl: string; | |
| 388 | + servicePath: string; | |
| 389 | + oauthScopes?: Readonly<Record<string, string>>; | |
| 390 | + schemaRefTable?: Readonly<GoogleDiscoverySchemaRefTable>; | |
| 391 | + }; | |
| 392 | + definition: GoogleDiscoveryToolDefinition; | |
| 393 | +}): GoogleDiscoveryToolPresentation => { | |
| 394 | + const refTable = Object.fromEntries( | |
| 395 | + Object.entries(input.manifest.schemaRefTable ?? {}).map(([ref, value]) => { | |
| 396 | + try { | |
| 397 | + return [ref, JSON.parse(value) as unknown]; | |
| 398 | + } catch { | |
| 399 | + return [ref, value]; | |
| 400 | + } | |
| 401 | + }), | |
| 402 | + ); | |
| 403 | + const inputSchema = | |
| 404 | + input.definition.inputSchema === undefined | |
| 405 | + ? undefined | |
| 406 | + : materializeSchemaWithRefDefinitions({ | |
| 407 | + schema: input.definition.inputSchema, | |
| 408 | + refTable, | |
| 409 | + }); | |
| 410 | + const outputSchema = | |
| 411 | + input.definition.outputSchema === undefined | |
| 412 | + ? undefined | |
| 413 | + : materializeSchemaWithRefDefinitions({ | |
| 414 | + schema: input.definition.outputSchema, | |
| 415 | + refTable, | |
| 416 | + }); | |
| 417 | + | |
| 418 | + return { | |
| 419 | + inputTypePreview: typeSignatureFromSchema(inputSchema, "unknown", Infinity), | |
| 420 | + outputTypePreview: typeSignatureFromSchema(outputSchema, "unknown", Infinity), | |
| 421 | + ...(inputSchema !== undefined ? { inputSchema } : {}), | |
| 422 | + ...(outputSchema !== undefined ? { outputSchema } : {}), | |
| 423 | + providerData: googleDiscoveryProviderDataFromDefinition({ | |
| 424 | + service: input.manifest.service, | |
| 425 | + version: input.manifest.versionName, | |
| 426 | + rootUrl: input.manifest.rootUrl, | |
| 427 | + servicePath: input.manifest.servicePath, | |
| 428 | + oauthScopes: input.manifest.oauthScopes, | |
| 429 | + definition: input.definition, | |
| 430 | + }), | |
| 431 | + }; | |
| 432 | +}; | |
| 433 | + | |
| 434 | +export const createGoogleDiscoveryToolFromDefinition = ( | |
| 435 | + input: CreateGoogleDiscoveryToolFromDefinitionInput, | |
| 436 | +) => { | |
| 437 | + const presentation = buildGoogleDiscoveryToolPresentation({ | |
| 438 | + manifest: { | |
| 439 | + service: input.service, | |
| 440 | + versionName: input.version, | |
| 441 | + rootUrl: input.rootUrl, | |
| 442 | + servicePath: input.servicePath, | |
| 443 | + oauthScopes: input.oauthScopes, | |
| 444 | + schemaRefTable: input.schemaRefTable, | |
| 445 | + }, | |
| 446 | + definition: input.definition, | |
| 447 | + }); | |
| 448 | + const providerData = presentation.providerData; | |
| 449 | + const credentialPlacements = normalizeCredentialPlacements({ | |
| 450 | + credentialHeaders: input.credentialHeaders, | |
| 451 | + credentialPlacements: input.credentialPlacements, | |
| 452 | + }); | |
| 453 | + const httpClientLayer = input.httpClientLayer ?? FetchHttpClient.layer; | |
| 454 | + | |
| 455 | + const metadata: ToolMetadata = { | |
| 456 | + interaction: input.definition.method === "get" || input.definition.method === "head" | |
| 457 | + ? "auto" | |
| 458 | + : "required", | |
| 459 | + contract: { | |
| 460 | + inputTypePreview: presentation.inputTypePreview, | |
| 461 | + outputTypePreview: presentation.outputTypePreview, | |
| 462 | + ...(presentation.inputSchema !== undefined | |
| 463 | + ? { inputSchema: presentation.inputSchema } | |
| 464 | + : {}), | |
| 465 | + ...(presentation.outputSchema !== undefined | |
| 466 | + ? { outputSchema: presentation.outputSchema } | |
| 467 | + : {}), | |
| 468 | + }, | |
| 469 | + sourceKey: input.sourceKey, | |
| 470 | + pluginKind: "google_discovery", | |
| 471 | + pluginData: providerData, | |
| 472 | + }; | |
| 473 | + | |
| 474 | + return toTool({ | |
| 475 | + tool: { | |
| 476 | + description: input.definition.description ?? undefined, | |
| 477 | + inputSchema: standardSchemaFromJsonSchema(presentation.inputSchema ?? {}), | |
| 478 | + execute: (args: unknown) => | |
| 479 | + Effect.runPromise( | |
| 480 | + (Effect.gen(function* () { | |
| 481 | + const request = buildGoogleDiscoveryRequest({ | |
| 482 | + definition: input.definition, | |
| 483 | + args: asRecord(args), | |
| 484 | + defaultHeaders: input.defaultHeaders ?? {}, | |
| 485 | + credentialPlacements, | |
| 486 | + baseUrl: input.baseUrl, | |
| 487 | + providerData, | |
| 488 | + }); | |
| 489 | + let clientRequest = HttpClientRequest.make( | |
| 490 | + request.method as Parameters<typeof HttpClientRequest.make>[0], | |
| 491 | + )(request.url, { | |
| 492 | + headers: request.headers, | |
| 493 | + }); | |
| 494 | + | |
| 495 | + if (request.body !== undefined) { | |
| 496 | + clientRequest = HttpClientRequest.bodyText( | |
| 497 | + clientRequest, | |
| 498 | + request.body, | |
| 499 | + request.headers["content-type"], | |
| 500 | + ); | |
| 501 | + } | |
| 502 | + | |
| 503 | + const client = yield* HttpClient.HttpClient; | |
| 504 | + const response = yield* client.execute(clientRequest).pipe( | |
| 505 | + Effect.mapError((cause) => | |
| 506 | + cause instanceof Error ? cause : new Error(String(cause)), | |
| 507 | + ), | |
| 508 | + ); | |
| 509 | + const body = yield* decodeResponseBody(response); | |
| 510 | + | |
| 511 | + if (response.status < 200 || response.status >= 300) { | |
| 512 | + return yield* sourceCoreEffectError("google-discovery/tools", | |
| 513 | + `Google Discovery request failed with HTTP ${response.status}: ${typeof body === "string" ? body : JSON.stringify(body)}`, | |
| 514 | + ); | |
| 515 | + } | |
| 516 | + | |
| 517 | + return body; | |
| 518 | + }).pipe(Effect.provide(httpClientLayer)) as Effect.Effect< | |
| 519 | + unknown, | |
| 520 | + Error, | |
| 521 | + never | |
| 522 | + >), | |
| 523 | + ), | |
| 524 | + }, | |
| 525 | + metadata, | |
| 526 | + }); | |
| 527 | +}; | |
| 528 | + | |
| 529 | +export { | |
| 530 | + decodeGoogleDiscoverySchemaRefTableJson, | |
| 531 | +}; | |
| @@ -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 | +} | |
| @@ -0,0 +1,129 @@ | ||
| 1 | +import { Schema } from "effect"; | |
| 2 | + | |
| 3 | +export const GOOGLE_DISCOVERY_HTTP_METHODS = [ | |
| 4 | + "get", | |
| 5 | + "put", | |
| 6 | + "post", | |
| 7 | + "delete", | |
| 8 | + "patch", | |
| 9 | + "head", | |
| 10 | + "options", | |
| 11 | +] as const; | |
| 12 | + | |
| 13 | +export const GoogleDiscoveryHttpMethodSchema = Schema.Literal( | |
| 14 | + ...GOOGLE_DISCOVERY_HTTP_METHODS, | |
| 15 | +); | |
| 16 | + | |
| 17 | +export const GoogleDiscoveryParameterLocationSchema = Schema.Literal( | |
| 18 | + "path", | |
| 19 | + "query", | |
| 20 | + "header", | |
| 21 | +); | |
| 22 | + | |
| 23 | +export const GoogleDiscoveryMethodParameterSchema = Schema.Struct({ | |
| 24 | + name: Schema.String, | |
| 25 | + location: GoogleDiscoveryParameterLocationSchema, | |
| 26 | + required: Schema.Boolean, | |
| 27 | + repeated: Schema.Boolean, | |
| 28 | + description: Schema.NullOr(Schema.String), | |
| 29 | + type: Schema.NullOr(Schema.String), | |
| 30 | + enum: Schema.optional(Schema.Array(Schema.String)), | |
| 31 | + default: Schema.optional(Schema.String), | |
| 32 | +}); | |
| 33 | + | |
| 34 | +export const GoogleDiscoveryInvocationPayloadSchema = Schema.Struct({ | |
| 35 | + method: GoogleDiscoveryHttpMethodSchema, | |
| 36 | + path: Schema.String, | |
| 37 | + flatPath: Schema.NullOr(Schema.String), | |
| 38 | + rootUrl: Schema.String, | |
| 39 | + servicePath: Schema.String, | |
| 40 | + parameters: Schema.Array(GoogleDiscoveryMethodParameterSchema), | |
| 41 | + requestSchemaId: Schema.NullOr(Schema.String), | |
| 42 | + responseSchemaId: Schema.NullOr(Schema.String), | |
| 43 | + scopes: Schema.Array(Schema.String), | |
| 44 | + scopeDescriptions: Schema.optional( | |
| 45 | + Schema.Record({ | |
| 46 | + key: Schema.String, | |
| 47 | + value: Schema.String, | |
| 48 | + }), | |
| 49 | + ), | |
| 50 | + supportsMediaUpload: Schema.Boolean, | |
| 51 | + supportsMediaDownload: Schema.Boolean, | |
| 52 | +}); | |
| 53 | + | |
| 54 | +export const GoogleDiscoveryToolProviderDataSchema = Schema.Struct({ | |
| 55 | + kind: Schema.Literal("google_discovery"), | |
| 56 | + service: Schema.String, | |
| 57 | + version: Schema.String, | |
| 58 | + toolId: Schema.String, | |
| 59 | + rawToolId: Schema.String, | |
| 60 | + methodId: Schema.String, | |
| 61 | + group: Schema.NullOr(Schema.String), | |
| 62 | + leaf: Schema.String, | |
| 63 | + invocation: GoogleDiscoveryInvocationPayloadSchema, | |
| 64 | +}); | |
| 65 | + | |
| 66 | +export const GoogleDiscoveryManifestMethodSchema = Schema.Struct({ | |
| 67 | + toolId: Schema.String, | |
| 68 | + rawToolId: Schema.String, | |
| 69 | + methodId: Schema.String, | |
| 70 | + name: Schema.String, | |
| 71 | + description: Schema.NullOr(Schema.String), | |
| 72 | + group: Schema.NullOr(Schema.String), | |
| 73 | + leaf: Schema.String, | |
| 74 | + method: GoogleDiscoveryHttpMethodSchema, | |
| 75 | + path: Schema.String, | |
| 76 | + flatPath: Schema.NullOr(Schema.String), | |
| 77 | + parameters: Schema.Array(GoogleDiscoveryMethodParameterSchema), | |
| 78 | + requestSchemaId: Schema.NullOr(Schema.String), | |
| 79 | + responseSchemaId: Schema.NullOr(Schema.String), | |
| 80 | + scopes: Schema.Array(Schema.String), | |
| 81 | + supportsMediaUpload: Schema.Boolean, | |
| 82 | + supportsMediaDownload: Schema.Boolean, | |
| 83 | + inputSchema: Schema.optional(Schema.Unknown), | |
| 84 | + outputSchema: Schema.optional(Schema.Unknown), | |
| 85 | +}); | |
| 86 | + | |
| 87 | +export const GoogleDiscoverySchemaRefTableSchema = Schema.Record({ | |
| 88 | + key: Schema.String, | |
| 89 | + value: Schema.String, | |
| 90 | +}); | |
| 91 | + | |
| 92 | +export const GoogleDiscoveryToolManifestSchema = Schema.Struct({ | |
| 93 | + version: Schema.Literal(1), | |
| 94 | + sourceHash: Schema.String, | |
| 95 | + service: Schema.String, | |
| 96 | + versionName: Schema.String, | |
| 97 | + title: Schema.NullOr(Schema.String), | |
| 98 | + description: Schema.NullOr(Schema.String), | |
| 99 | + rootUrl: Schema.String, | |
| 100 | + servicePath: Schema.String, | |
| 101 | + batchPath: Schema.NullOr(Schema.String), | |
| 102 | + documentationLink: Schema.NullOr(Schema.String), | |
| 103 | + oauthScopes: Schema.optional( | |
| 104 | + Schema.Record({ | |
| 105 | + key: Schema.String, | |
| 106 | + value: Schema.String, | |
| 107 | + }), | |
| 108 | + ), | |
| 109 | + schemaRefTable: Schema.optional(GoogleDiscoverySchemaRefTableSchema), | |
| 110 | + methods: Schema.Array(GoogleDiscoveryManifestMethodSchema), | |
| 111 | +}); | |
| 112 | + | |
| 113 | +export type GoogleDiscoveryHttpMethod = typeof GoogleDiscoveryHttpMethodSchema.Type; | |
| 114 | +export type GoogleDiscoveryParameterLocation = | |
| 115 | + typeof GoogleDiscoveryParameterLocationSchema.Type; | |
| 116 | +export type GoogleDiscoveryMethodParameter = | |
| 117 | + typeof GoogleDiscoveryMethodParameterSchema.Type; | |
| 118 | +export type GoogleDiscoveryInvocationPayload = | |
| 119 | + typeof GoogleDiscoveryInvocationPayloadSchema.Type; | |
| 120 | +export type GoogleDiscoveryToolProviderData = | |
| 121 | + typeof GoogleDiscoveryToolProviderDataSchema.Type; | |
| 122 | +export type GoogleDiscoveryManifestMethod = | |
| 123 | + typeof GoogleDiscoveryManifestMethodSchema.Type; | |
| 124 | +export type GoogleDiscoverySchemaRefTable = | |
| 125 | + typeof GoogleDiscoverySchemaRefTableSchema.Type; | |
| 126 | +export type GoogleDiscoveryToolManifest = | |
| 127 | + typeof GoogleDiscoveryToolManifestSchema.Type; | |
| 128 | + | |
| 129 | +export type GoogleDiscoveryToolDefinition = GoogleDiscoveryManifestMethod; | |
| @@ -0,0 +1,155 @@ | ||
| 1 | +import * as Schema from "effect/Schema"; | |
| 2 | + | |
| 3 | +import { | |
| 4 | + SecretRefSchema, | |
| 5 | + StringMapSchema, | |
| 6 | +} from "@executor/platform-sdk/schema"; | |
| 7 | + | |
| 8 | +export const GoogleDiscoveryOAuthClientAuthenticationSchema = Schema.Literal( | |
| 9 | + "none", | |
| 10 | + "client_secret_post", | |
| 11 | +); | |
| 12 | + | |
| 13 | +export const GoogleDiscoveryConnectionAuthSchema = Schema.Union( | |
| 14 | + Schema.Struct({ | |
| 15 | + kind: Schema.Literal("none"), | |
| 16 | + }), | |
| 17 | + Schema.Struct({ | |
| 18 | + kind: Schema.Literal("bearer"), | |
| 19 | + tokenSecretRef: SecretRefSchema, | |
| 20 | + }), | |
| 21 | + Schema.Struct({ | |
| 22 | + kind: Schema.Literal("oauth2"), | |
| 23 | + clientId: Schema.String, | |
| 24 | + clientSecretRef: Schema.NullOr(SecretRefSchema), | |
| 25 | + clientAuthentication: GoogleDiscoveryOAuthClientAuthenticationSchema, | |
| 26 | + authorizationEndpoint: Schema.String, | |
| 27 | + tokenEndpoint: Schema.String, | |
| 28 | + scopes: Schema.Array(Schema.String), | |
| 29 | + accessTokenRef: SecretRefSchema, | |
| 30 | + refreshTokenRef: Schema.NullOr(SecretRefSchema), | |
| 31 | + expiresAt: Schema.NullOr(Schema.Number), | |
| 32 | + }), | |
| 33 | +); | |
| 34 | + | |
| 35 | +export const GoogleDiscoveryConnectInputSchema = Schema.Struct({ | |
| 36 | + name: Schema.String, | |
| 37 | + service: Schema.String, | |
| 38 | + version: Schema.String, | |
| 39 | + discoveryUrl: Schema.NullOr(Schema.String), | |
| 40 | + defaultHeaders: Schema.NullOr(StringMapSchema), | |
| 41 | + scopes: Schema.Array(Schema.String), | |
| 42 | + auth: GoogleDiscoveryConnectionAuthSchema, | |
| 43 | +}); | |
| 44 | + | |
| 45 | +export const GoogleDiscoverySourceConfigPayloadSchema = | |
| 46 | + GoogleDiscoveryConnectInputSchema; | |
| 47 | + | |
| 48 | +export const GoogleDiscoveryUpdateSourceInputSchema = Schema.Struct({ | |
| 49 | + sourceId: Schema.String, | |
| 50 | + config: GoogleDiscoverySourceConfigPayloadSchema, | |
| 51 | +}); | |
| 52 | + | |
| 53 | +export const GoogleDiscoveryStoredSourceDataSchema = Schema.Struct({ | |
| 54 | + service: Schema.String, | |
| 55 | + version: Schema.String, | |
| 56 | + discoveryUrl: Schema.String, | |
| 57 | + defaultHeaders: Schema.NullOr(StringMapSchema), | |
| 58 | + scopes: Schema.Array(Schema.String), | |
| 59 | + auth: GoogleDiscoveryConnectionAuthSchema, | |
| 60 | +}); | |
| 61 | + | |
| 62 | +export const GoogleDiscoveryStartOAuthInputSchema = Schema.Struct({ | |
| 63 | + service: Schema.String, | |
| 64 | + version: Schema.String, | |
| 65 | + discoveryUrl: Schema.NullOr(Schema.String), | |
| 66 | + defaultHeaders: Schema.NullOr(StringMapSchema), | |
| 67 | + scopes: Schema.Array(Schema.String), | |
| 68 | + clientId: Schema.String, | |
| 69 | + clientSecretRef: Schema.NullOr(SecretRefSchema), | |
| 70 | + clientAuthentication: GoogleDiscoveryOAuthClientAuthenticationSchema, | |
| 71 | + redirectUrl: Schema.String, | |
| 72 | +}); | |
| 73 | + | |
| 74 | +export const GoogleDiscoveryStartOAuthResultSchema = Schema.Struct({ | |
| 75 | + sessionId: Schema.String, | |
| 76 | + authorizationUrl: Schema.String, | |
| 77 | + scopes: Schema.Array(Schema.String), | |
| 78 | +}); | |
| 79 | + | |
| 80 | +export const GoogleDiscoveryOAuthSessionSchema = Schema.Struct({ | |
| 81 | + service: Schema.String, | |
| 82 | + version: Schema.String, | |
| 83 | + discoveryUrl: Schema.String, | |
| 84 | + defaultHeaders: Schema.NullOr(StringMapSchema), | |
| 85 | + scopes: Schema.Array(Schema.String), | |
| 86 | + clientId: Schema.String, | |
| 87 | + clientSecretRef: Schema.NullOr(SecretRefSchema), | |
| 88 | + clientAuthentication: GoogleDiscoveryOAuthClientAuthenticationSchema, | |
| 89 | + redirectUrl: Schema.String, | |
| 90 | + codeVerifier: Schema.String, | |
| 91 | +}); | |
| 92 | + | |
| 93 | +const GoogleDiscoveryOAuthPopupSuccessAuthSchema = Schema.Struct({ | |
| 94 | + kind: Schema.Literal("oauth2"), | |
| 95 | + clientId: Schema.String, | |
| 96 | + clientSecretRef: Schema.NullOr(SecretRefSchema), | |
| 97 | + clientAuthentication: GoogleDiscoveryOAuthClientAuthenticationSchema, | |
| 98 | + authorizationEndpoint: Schema.String, | |
| 99 | + tokenEndpoint: Schema.String, | |
| 100 | + scopes: Schema.Array(Schema.String), | |
| 101 | + accessTokenRef: SecretRefSchema, | |
| 102 | + refreshTokenRef: Schema.NullOr(SecretRefSchema), | |
| 103 | + expiresAt: Schema.NullOr(Schema.Number), | |
| 104 | +}); | |
| 105 | + | |
| 106 | +export const GoogleDiscoveryOAuthPopupResultSchema = Schema.Union( | |
| 107 | + Schema.Struct({ | |
| 108 | + type: Schema.Literal("executor:oauth-result"), | |
| 109 | + ok: Schema.Literal(true), | |
| 110 | + sessionId: Schema.String, | |
| 111 | + auth: GoogleDiscoveryOAuthPopupSuccessAuthSchema, | |
| 112 | + }), | |
| 113 | + Schema.Struct({ | |
| 114 | + type: Schema.Literal("executor:oauth-result"), | |
| 115 | + ok: Schema.Literal(false), | |
| 116 | + sessionId: Schema.Null, | |
| 117 | + error: Schema.String, | |
| 118 | + }), | |
| 119 | +); | |
| 120 | + | |
| 121 | +export type GoogleDiscoveryOAuthClientAuthentication = | |
| 122 | + typeof GoogleDiscoveryOAuthClientAuthenticationSchema.Type; | |
| 123 | +export type GoogleDiscoveryConnectionAuth = | |
| 124 | + typeof GoogleDiscoveryConnectionAuthSchema.Type; | |
| 125 | +export type GoogleDiscoveryConnectInput = | |
| 126 | + typeof GoogleDiscoveryConnectInputSchema.Type; | |
| 127 | +export type GoogleDiscoverySourceConfigPayload = | |
| 128 | + typeof GoogleDiscoverySourceConfigPayloadSchema.Type; | |
| 129 | +export type GoogleDiscoveryUpdateSourceInput = | |
| 130 | + typeof GoogleDiscoveryUpdateSourceInputSchema.Type; | |
| 131 | +export type GoogleDiscoveryStoredSourceData = | |
| 132 | + typeof GoogleDiscoveryStoredSourceDataSchema.Type; | |
| 133 | +export type GoogleDiscoveryStartOAuthInput = | |
| 134 | + typeof GoogleDiscoveryStartOAuthInputSchema.Type; | |
| 135 | +export type GoogleDiscoveryStartOAuthResult = | |
| 136 | + typeof GoogleDiscoveryStartOAuthResultSchema.Type; | |
| 137 | +export type GoogleDiscoveryOAuthSession = | |
| 138 | + typeof GoogleDiscoveryOAuthSessionSchema.Type; | |
| 139 | +export type GoogleDiscoveryOAuthPopupResult = | |
| 140 | + typeof GoogleDiscoveryOAuthPopupResultSchema.Type; | |
| 141 | + | |
| 142 | +export const defaultGoogleDiscoveryUrl = ( | |
| 143 | + service: string, | |
| 144 | + version: string, | |
| 145 | +): string => | |
| 146 | + `https://www.googleapis.com/discovery/v1/apis/${encodeURIComponent(service)}/${encodeURIComponent(version)}/rest`; | |
| 147 | + | |
| 148 | +export const deriveGoogleDiscoveryNamespace = ( | |
| 149 | + service: string, | |
| 150 | +): string => | |
| 151 | + `google.${service | |
| 152 | + .trim() | |
| 153 | + .toLowerCase() | |
| 154 | + .replace(/[^a-z0-9]+/g, ".") | |
| 155 | + .replace(/^\.+|\.+$/g, "")}`; | |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -0,0 +1,187 @@ | ||
| 1 | +import { | |
| 2 | + HttpApi, | |
| 3 | + HttpApiBuilder, | |
| 4 | + HttpApiEndpoint, | |
| 5 | + HttpApiGroup, | |
| 6 | + HttpApiSchema, | |
| 7 | +} from "@effect/platform"; | |
| 8 | +import { | |
| 9 | + ControlPlaneBadRequestError, | |
| 10 | + ControlPlaneForbiddenError, | |
| 11 | + ControlPlaneNotFoundError, | |
| 12 | + ControlPlaneStorageError, | |
| 13 | + type ExecutorHttpApiExtension, | |
| 14 | + type ExecutorHttpPlugin, | |
| 15 | +} from "@executor/platform-api"; | |
| 16 | +import { resolveRequestedLocalWorkspace } from "@executor/platform-api/local-context"; | |
| 17 | +import { | |
| 18 | + ScopeIdSchema, | |
| 19 | + SourceIdSchema, | |
| 20 | + SourceSchema, | |
| 21 | + type Source, | |
| 22 | +} from "@executor/platform-sdk/schema"; | |
| 23 | +import * as Effect from "effect/Effect"; | |
| 24 | +import * as Schema from "effect/Schema"; | |
| 25 | + | |
| 26 | +import { | |
| 27 | + GraphqlConnectInputSchema, | |
| 28 | + GraphqlSourceConfigPayloadSchema, | |
| 29 | + type GraphqlConnectInput, | |
| 30 | + type GraphqlSourceConfigPayload, | |
| 31 | + type GraphqlUpdateSourceInput, | |
| 32 | +} from "@executor/plugin-graphql-shared"; | |
| 33 | + | |
| 34 | +type GraphqlExecutorExtension = { | |
| 35 | + graphql: { | |
| 36 | + createSource: ( | |
| 37 | + input: GraphqlConnectInput, | |
| 38 | + ) => Effect.Effect<Source, Error>; | |
| 39 | + getSourceConfig: ( | |
| 40 | + sourceId: Source["id"], | |
| 41 | + ) => Effect.Effect<GraphqlSourceConfigPayload, Error>; | |
| 42 | + updateSource: ( | |
| 43 | + input: GraphqlUpdateSourceInput, | |
| 44 | + ) => Effect.Effect<Source, Error>; | |
| 45 | + removeSource: ( | |
| 46 | + sourceId: Source["id"], | |
| 47 | + ) => Effect.Effect<boolean, Error>; | |
| 48 | + }; | |
| 49 | +}; | |
| 50 | + | |
| 51 | +const workspaceIdParam = HttpApiSchema.param("workspaceId", ScopeIdSchema); | |
| 52 | +const sourceIdParam = HttpApiSchema.param("sourceId", SourceIdSchema); | |
| 53 | + | |
| 54 | +export const GraphqlHttpGroup = HttpApiGroup.make("graphql") | |
| 55 | + .add( | |
| 56 | + HttpApiEndpoint.post("createSource")`/workspaces/${workspaceIdParam}/plugins/graphql/sources` | |
| 57 | + .setPayload(GraphqlConnectInputSchema) | |
| 58 | + .addSuccess(SourceSchema) | |
| 59 | + .addError(ControlPlaneBadRequestError) | |
| 60 | + .addError(ControlPlaneForbiddenError) | |
| 61 | + .addError(ControlPlaneStorageError), | |
| 62 | + ) | |
| 63 | + .add( | |
| 64 | + HttpApiEndpoint.get("getSourceConfig")`/workspaces/${workspaceIdParam}/plugins/graphql/sources/${sourceIdParam}` | |
| 65 | + .addSuccess(GraphqlSourceConfigPayloadSchema) | |
| 66 | + .addError(ControlPlaneBadRequestError) | |
| 67 | + .addError(ControlPlaneForbiddenError) | |
| 68 | + .addError(ControlPlaneNotFoundError) | |
| 69 | + .addError(ControlPlaneStorageError), | |
| 70 | + ) | |
| 71 | + .add( | |
| 72 | + HttpApiEndpoint.put("updateSource")`/workspaces/${workspaceIdParam}/plugins/graphql/sources/${sourceIdParam}` | |
| 73 | + .setPayload(GraphqlSourceConfigPayloadSchema) | |
| 74 | + .addSuccess(SourceSchema) | |
| 75 | + .addError(ControlPlaneBadRequestError) | |
| 76 | + .addError(ControlPlaneForbiddenError) | |
| 77 | + .addError(ControlPlaneNotFoundError) | |
| 78 | + .addError(ControlPlaneStorageError), | |
| 79 | + ) | |
| 80 | + .add( | |
| 81 | + HttpApiEndpoint.del("removeSource")`/workspaces/${workspaceIdParam}/plugins/graphql/sources/${sourceIdParam}` | |
| 82 | + .addSuccess(Schema.Struct({ removed: Schema.Boolean })) | |
| 83 | + .addError(ControlPlaneBadRequestError) | |
| 84 | + .addError(ControlPlaneForbiddenError) | |
| 85 | + .addError(ControlPlaneNotFoundError) | |
| 86 | + .addError(ControlPlaneStorageError), | |
| 87 | + ) | |
| 88 | + .prefix("/v1"); | |
| 89 | + | |
| 90 | +export const graphqlHttpApiExtension = { | |
| 91 | + key: "graphql", | |
| 92 | + group: GraphqlHttpGroup, | |
| 93 | +} satisfies ExecutorHttpApiExtension<typeof GraphqlHttpGroup>; | |
| 94 | + | |
| 95 | +const GraphqlHttpApi = HttpApi.make("executor").add(GraphqlHttpGroup); | |
| 96 | + | |
| 97 | +const toBadRequestError = (operation: string, cause: unknown) => | |
| 98 | + new ControlPlaneBadRequestError({ | |
| 99 | + operation, | |
| 100 | + message: cause instanceof Error ? cause.message : String(cause), | |
| 101 | + details: cause instanceof Error ? cause.stack ?? cause.message : String(cause), | |
| 102 | + }); | |
| 103 | + | |
| 104 | +const toStorageError = (operation: string, cause: unknown) => | |
| 105 | + new ControlPlaneStorageError({ | |
| 106 | + operation, | |
| 107 | + message: cause instanceof Error ? cause.message : String(cause), | |
| 108 | + details: cause instanceof Error ? cause.stack ?? cause.message : String(cause), | |
| 109 | + }); | |
| 110 | + | |
| 111 | +const toNotFoundError = (operation: string, cause: unknown) => | |
| 112 | + new ControlPlaneNotFoundError({ | |
| 113 | + operation, | |
| 114 | + message: cause instanceof Error ? cause.message : String(cause), | |
| 115 | + details: cause instanceof Error ? cause.stack ?? cause.message : String(cause), | |
| 116 | + }); | |
| 117 | + | |
| 118 | +const mapPluginStorageError = (operation: string, cause: unknown) => { | |
| 119 | + const message = cause instanceof Error ? cause.message : String(cause); | |
| 120 | + if (message.includes("not found") || message.includes("Not found")) { | |
| 121 | + return toNotFoundError(operation, cause); | |
| 122 | + } | |
| 123 | + | |
| 124 | + return toStorageError(operation, cause); | |
| 125 | +}; | |
| 126 | + | |
| 127 | +export const graphqlHttpPlugin = (): ExecutorHttpPlugin< | |
| 128 | + typeof GraphqlHttpGroup, | |
| 129 | + GraphqlExecutorExtension | |
| 130 | +> => ({ | |
| 131 | + key: "graphql", | |
| 132 | + group: GraphqlHttpGroup, | |
| 133 | + build: ({ executor }) => | |
| 134 | + HttpApiBuilder.group(GraphqlHttpApi, "graphql", (handlers) => | |
| 135 | + handlers | |
| 136 | + .handle("createSource", ({ path, payload }) => | |
| 137 | + resolveRequestedLocalWorkspace( | |
| 138 | + "graphql.createSource", | |
| 139 | + path.workspaceId, | |
| 140 | + ).pipe( | |
| 141 | + Effect.flatMap(() => executor.graphql.createSource(payload)), | |
| 142 | + Effect.mapError((cause) => | |
| 143 | + toStorageError("graphql.createSource", cause) | |
| 144 | + ), | |
| 145 | + ) | |
| 146 | + ) | |
| 147 | + .handle("getSourceConfig", ({ path }) => | |
| 148 | + resolveRequestedLocalWorkspace( | |
| 149 | + "graphql.getSourceConfig", | |
| 150 | + path.workspaceId, | |
| 151 | + ).pipe( | |
| 152 | + Effect.flatMap(() => executor.graphql.getSourceConfig(path.sourceId)), | |
| 153 | + Effect.mapError((cause) => | |
| 154 | + mapPluginStorageError("graphql.getSourceConfig", cause) | |
| 155 | + ), | |
| 156 | + ) | |
| 157 | + ) | |
| 158 | + .handle("updateSource", ({ path, payload }) => | |
| 159 | + resolveRequestedLocalWorkspace( | |
| 160 | + "graphql.updateSource", | |
| 161 | + path.workspaceId, | |
| 162 | + ).pipe( | |
| 163 | + Effect.flatMap(() => | |
| 164 | + executor.graphql.updateSource({ | |
| 165 | + sourceId: path.sourceId, | |
| 166 | + config: payload, | |
| 167 | + }) | |
| 168 | + ), | |
| 169 | + Effect.mapError((cause) => | |
| 170 | + mapPluginStorageError("graphql.updateSource", cause) | |
| 171 | + ), | |
| 172 | + ) | |
| 173 | + ) | |
| 174 | + .handle("removeSource", ({ path }) => | |
| 175 | + resolveRequestedLocalWorkspace( | |
| 176 | + "graphql.removeSource", | |
| 177 | + path.workspaceId, | |
| 178 | + ).pipe( | |
| 179 | + Effect.flatMap(() => executor.graphql.removeSource(path.sourceId)), | |
| 180 | + Effect.map((removed) => ({ removed })), | |
| 181 | + Effect.mapError((cause) => | |
| 182 | + mapPluginStorageError("graphql.removeSource", cause) | |
| 183 | + ), | |
| 184 | + ) | |
| 185 | + ) | |
| 186 | + ), | |
| 187 | +}); | |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -0,0 +1,518 @@ | ||
| 1 | +import { startTransition, useMemo, useState, type ReactNode } from "react"; | |
| 2 | +import { useNavigate } from "@tanstack/react-router"; | |
| 3 | +import type { | |
| 4 | + Source, | |
| 5 | +} from "@executor/react"; | |
| 6 | +import { | |
| 7 | + defineExecutorPluginHttpApiClient, | |
| 8 | + Result, | |
| 9 | + useAtomSet, | |
| 10 | + useAtomValue, | |
| 11 | + useExecutorMutation, | |
| 12 | + useLocalInstallation, | |
| 13 | + useSecrets, | |
| 14 | +} from "@executor/react"; | |
| 15 | + | |
| 16 | +import { | |
| 17 | + graphqlHttpApiExtension, | |
| 18 | +} from "@executor/plugin-graphql-http"; | |
| 19 | +import { | |
| 20 | + type GraphqlConnectInput, | |
| 21 | + type GraphqlConnectionAuth, | |
| 22 | +} from "@executor/plugin-graphql-shared"; | |
| 23 | +import { SourceToolExplorer } from "../../../apps/web/src/source-plugins/tool-explorer"; | |
| 24 | +import { IconPencil } from "../../../apps/web/src/components/icons"; | |
| 25 | + | |
| 26 | +type FrontendSourceTypeDefinition = { | |
| 27 | + kind: string; | |
| 28 | + displayName: string; | |
| 29 | + description?: string; | |
| 30 | + renderAddPage: () => ReactNode; | |
| 31 | + renderEditPage?: (input: { source: Source }) => ReactNode; | |
| 32 | + renderDetailPage?: (input: { | |
| 33 | + source: Source; | |
| 34 | + route: { | |
| 35 | + search?: unknown; | |
| 36 | + navigate?: unknown; | |
| 37 | + }; | |
| 38 | + }) => ReactNode; | |
| 39 | +}; | |
| 40 | + | |
| 41 | +type FrontendPluginRegisterApi = { | |
| 42 | + sources: { | |
| 43 | + registerType: (definition: FrontendSourceTypeDefinition) => void; | |
| 44 | + }; | |
| 45 | +}; | |
| 46 | + | |
| 47 | +type RouteToolSearch = { | |
| 48 | + tab?: "model" | "discover"; | |
| 49 | + tool?: string; | |
| 50 | + query?: string; | |
| 51 | +}; | |
| 52 | + | |
| 53 | +const getGraphqlHttpClient = defineExecutorPluginHttpApiClient<"GraphqlReactHttpClient">()( | |
| 54 | + "GraphqlReactHttpClient", | |
| 55 | + [graphqlHttpApiExtension] as const, | |
| 56 | +); | |
| 57 | + | |
| 58 | +const defaultGraphqlInput = (): GraphqlConnectInput => ({ | |
| 59 | + name: "My GraphQL Source", | |
| 60 | + endpoint: "https://example.com/graphql", | |
| 61 | + defaultHeaders: null, | |
| 62 | + auth: { | |
| 63 | + kind: "none", | |
| 64 | + }, | |
| 65 | +}); | |
| 66 | + | |
| 67 | +const parseStringMap = (value: string): Record<string, string> | null => { | |
| 68 | + const trimmed = value.trim(); | |
| 69 | + if (!trimmed) { | |
| 70 | + return null; | |
| 71 | + } | |
| 72 | + | |
| 73 | + const parsed = JSON.parse(trimmed) as unknown; | |
| 74 | + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { | |
| 75 | + throw new Error("Headers must be a JSON object."); | |
| 76 | + } | |
| 77 | + | |
| 78 | + const entries = Object.entries(parsed as Record<string, unknown>); | |
| 79 | + if (!entries.every(([, entry]) => typeof entry === "string")) { | |
| 80 | + throw new Error("All header values must be strings."); | |
| 81 | + } | |
| 82 | + | |
| 83 | + return Object.fromEntries(entries as Array<[string, string]>); | |
| 84 | +}; | |
| 85 | + | |
| 86 | +const stringifyStringMap = ( | |
| 87 | + value: Record<string, string> | null | undefined, | |
| 88 | +): string => | |
| 89 | + !value || Object.keys(value).length === 0 | |
| 90 | + ? "" | |
| 91 | + : JSON.stringify(value, null, 2); | |
| 92 | + | |
| 93 | +const secretValue = (input: GraphqlConnectionAuth): string => | |
| 94 | + input.kind === "bearer" | |
| 95 | + ? JSON.stringify(input.tokenSecretRef) | |
| 96 | + : ""; | |
| 97 | + | |
| 98 | +const authFromSecretValue = ( | |
| 99 | + authKind: GraphqlConnectionAuth["kind"], | |
| 100 | + value: string, | |
| 101 | +): GraphqlConnectionAuth => { | |
| 102 | + if (authKind === "none") { | |
| 103 | + return { | |
| 104 | + kind: "none", | |
| 105 | + }; | |
| 106 | + } | |
| 107 | + | |
| 108 | + if (!value) { | |
| 109 | + throw new Error("Select a secret for bearer auth."); | |
| 110 | + } | |
| 111 | + | |
| 112 | + return { | |
| 113 | + kind: "bearer", | |
| 114 | + tokenSecretRef: JSON.parse(value) as GraphqlConnectionAuth & { tokenSecretRef: never }["tokenSecretRef"], | |
| 115 | + }; | |
| 116 | +}; | |
| 117 | + | |
| 118 | +function GraphqlSourceForm(props: { | |
| 119 | + initialValue: GraphqlConnectInput; | |
| 120 | + mode: "create" | "edit"; | |
| 121 | + onSubmit: (input: GraphqlConnectInput) => Promise<void>; | |
| 122 | +}) { | |
| 123 | + const secrets = useSecrets(); | |
| 124 | + const submitMutation = useExecutorMutation<GraphqlConnectInput, void>(props.onSubmit); | |
| 125 | + const [name, setName] = useState(props.initialValue.name); | |
| 126 | + const [endpoint, setEndpoint] = useState(props.initialValue.endpoint); | |
| 127 | + const [headersText, setHeadersText] = useState( | |
| 128 | + stringifyStringMap(props.initialValue.defaultHeaders), | |
| 129 | + ); | |
| 130 | + const [authKind, setAuthKind] = useState<GraphqlConnectionAuth["kind"]>( | |
| 131 | + props.initialValue.auth.kind, | |
| 132 | + ); | |
| 133 | + const [secretRef, setSecretRef] = useState(secretValue(props.initialValue.auth)); | |
| 134 | + const [error, setError] = useState<string | null>(null); | |
| 135 | + | |
| 136 | + return ( | |
| 137 | + <div className="space-y-4 rounded-2xl border border-border bg-card p-5"> | |
| 138 | + <div> | |
| 139 | + <h2 className="text-lg font-semibold text-foreground"> | |
| 140 | + {props.mode === "create" ? "Connect GraphQL Source" : "Edit GraphQL Source"} | |
| 141 | + </h2> | |
| 142 | + <p className="mt-1 text-sm text-muted-foreground"> | |
| 143 | + GraphQL is now mounted as a plugin. The source record stays generic; endpoint and auth | |
| 144 | + live entirely in plugin storage. | |
| 145 | + </p> | |
| 146 | + </div> | |
| 147 | + | |
| 148 | + <label className="grid gap-1.5"> | |
| 149 | + <span className="text-xs font-medium text-foreground">Name</span> | |
| 150 | + <input | |
| 151 | + value={name} | |
| 152 | + onChange={(event) => setName(event.target.value)} | |
| 153 | + className="h-10 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | |
| 154 | + /> | |
| 155 | + </label> | |
| 156 | + | |
| 157 | + <label className="grid gap-1.5"> | |
| 158 | + <span className="text-xs font-medium text-foreground">Endpoint</span> | |
| 159 | + <input | |
| 160 | + value={endpoint} | |
| 161 | + onChange={(event) => setEndpoint(event.target.value)} | |
| 162 | + className="h-10 rounded-lg border border-input bg-background px-3 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | |
| 163 | + /> | |
| 164 | + </label> | |
| 165 | + | |
| 166 | + <label className="grid gap-1.5"> | |
| 167 | + <span className="text-xs font-medium text-foreground">Default Headers</span> | |
| 168 | + <textarea | |
| 169 | + value={headersText} | |
| 170 | + onChange={(event) => setHeadersText(event.target.value)} | |
| 171 | + rows={5} | |
| 172 | + placeholder='{"x-api-version":"2026-03-23"}' | |
| 173 | + className="rounded-lg border border-input bg-background px-3 py-2 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | |
| 174 | + /> | |
| 175 | + </label> | |
| 176 | + | |
| 177 | + <label className="grid gap-1.5"> | |
| 178 | + <span className="text-xs font-medium text-foreground">Auth</span> | |
| 179 | + <select | |
| 180 | + value={authKind} | |
| 181 | + onChange={(event) => | |
| 182 | + setAuthKind(event.target.value as GraphqlConnectionAuth["kind"])} | |
| 183 | + className="h-10 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | |
| 184 | + > | |
| 185 | + <option value="none">None</option> | |
| 186 | + <option value="bearer">Bearer Secret</option> | |
| 187 | + </select> | |
| 188 | + </label> | |
| 189 | + | |
| 190 | + {authKind === "bearer" && ( | |
| 191 | + <label className="grid gap-1.5"> | |
| 192 | + <span className="text-xs font-medium text-foreground">Secret</span> | |
| 193 | + <select | |
| 194 | + value={secretRef} | |
| 195 | + onChange={(event) => setSecretRef(event.target.value)} | |
| 196 | + className="h-10 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | |
| 197 | + > | |
| 198 | + <option value="">Select a secret</option> | |
| 199 | + {secrets.status === "ready" && | |
| 200 | + secrets.data.map((secret) => ( | |
| 201 | + <option | |
| 202 | + key={`${secret.providerId}:${secret.id}`} | |
| 203 | + value={JSON.stringify({ | |
| 204 | + providerId: secret.providerId, | |
| 205 | + handle: secret.id, | |
| 206 | + })} | |
| 207 | + > | |
| 208 | + {secret.name ?? secret.id} | |
| 209 | + </option> | |
| 210 | + ))} | |
| 211 | + </select> | |
| 212 | + </label> | |
| 213 | + )} | |
| 214 | + | |
| 215 | + {error && ( | |
| 216 | + <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-3 text-sm text-destructive"> | |
| 217 | + {error} | |
| 218 | + </div> | |
| 219 | + )} | |
| 220 | + | |
| 221 | + <div className="flex items-center gap-3"> | |
| 222 | + <button | |
| 223 | + type="button" | |
| 224 | + onClick={() => { | |
| 225 | + void (async () => { | |
| 226 | + setError(null); | |
| 227 | + try { | |
| 228 | + await submitMutation.mutateAsync({ | |
| 229 | + name: name.trim(), | |
| 230 | + endpoint: endpoint.trim(), | |
| 231 | + defaultHeaders: parseStringMap(headersText), | |
| 232 | + auth: authFromSecretValue(authKind, secretRef), | |
| 233 | + }); | |
| 234 | + } catch (cause) { | |
| 235 | + setError(cause instanceof Error ? cause.message : "Failed saving GraphQL source."); | |
| 236 | + } | |
| 237 | + })(); | |
| 238 | + }} | |
| 239 | + disabled={submitMutation.status === "pending"} | |
| 240 | + className="inline-flex h-9 items-center justify-center rounded-lg bg-primary px-3 text-sm font-medium text-primary-foreground transition-opacity disabled:pointer-events-none disabled:opacity-50" | |
| 241 | + > | |
| 242 | + {submitMutation.status === "pending" | |
| 243 | + ? props.mode === "create" ? "Creating..." : "Saving..." | |
| 244 | + : props.mode === "create" ? "Create Source" : "Save Changes"} | |
| 245 | + </button> | |
| 246 | + <div className="text-xs text-muted-foreground"> | |
| 247 | + {props.mode === "create" | |
| 248 | + ? "The plugin will introspect the endpoint immediately after creation." | |
| 249 | + : "Saving refreshes the imported schema and tool catalog."} | |
| 250 | + </div> | |
| 251 | + </div> | |
| 252 | + </div> | |
| 253 | + ); | |
| 254 | +} | |
| 255 | + | |
| 256 | +function GraphqlAddPage() { | |
| 257 | + const navigate = useNavigate(); | |
| 258 | + const installation = useLocalInstallation(); | |
| 259 | + const client = getGraphqlHttpClient(); | |
| 260 | + const createSource = useAtomSet( | |
| 261 | + client.mutation("graphql", "createSource"), | |
| 262 | + { mode: "promise" }, | |
| 263 | + ); | |
| 264 | + | |
| 265 | + if (installation.status !== "ready") { | |
| 266 | + return <div className="text-sm text-muted-foreground">Loading workspace...</div>; | |
| 267 | + } | |
| 268 | + | |
| 269 | + return ( | |
| 270 | + <GraphqlSourceForm | |
| 271 | + initialValue={defaultGraphqlInput()} | |
| 272 | + mode="create" | |
| 273 | + onSubmit={async (input) => { | |
| 274 | + const source = await createSource({ | |
| 275 | + path: { | |
| 276 | + workspaceId: installation.data.scopeId, | |
| 277 | + }, | |
| 278 | + payload: input, | |
| 279 | + reactivityKeys: { | |
| 280 | + sources: [installation.data.scopeId], | |
| 281 | + }, | |
| 282 | + }); | |
| 283 | + | |
| 284 | + startTransition(() => { | |
| 285 | + void navigate({ | |
| 286 | + to: "/sources/$sourceId", | |
| 287 | + params: { | |
| 288 | + sourceId: source.id, | |
| 289 | + }, | |
| 290 | + search: { | |
| 291 | + tab: "model", | |
| 292 | + }, | |
| 293 | + }); | |
| 294 | + }); | |
| 295 | + }} | |
| 296 | + /> | |
| 297 | + ); | |
| 298 | +} | |
| 299 | + | |
| 300 | +function GraphqlEditPage(props: { | |
| 301 | + source: Source; | |
| 302 | +}) { | |
| 303 | + const navigate = useNavigate(); | |
| 304 | + const installation = useLocalInstallation(); | |
| 305 | + const client = getGraphqlHttpClient(); | |
| 306 | + const configResult = useAtomValue( | |
| 307 | + installation.status === "ready" | |
| 308 | + ? client.query("graphql", "getSourceConfig", { | |
| 309 | + path: { | |
| 310 | + workspaceId: installation.data.scopeId, | |
| 311 | + sourceId: props.source.id, | |
| 312 | + }, | |
| 313 | + reactivityKeys: { | |
| 314 | + source: [installation.data.scopeId, props.source.id], | |
| 315 | + }, | |
| 316 | + timeToLive: "30 seconds", | |
| 317 | + }) | |
| 318 | + : client.query("local", "installation", { | |
| 319 | + timeToLive: "1 second", | |
| 320 | + }) as never, | |
| 321 | + ); | |
| 322 | + const updateSource = useAtomSet( | |
| 323 | + client.mutation("graphql", "updateSource"), | |
| 324 | + { mode: "promise" }, | |
| 325 | + ); | |
| 326 | + | |
| 327 | + if (installation.status !== "ready") { | |
| 328 | + return <div className="text-sm text-muted-foreground">Loading workspace...</div>; | |
| 329 | + } | |
| 330 | + | |
| 331 | + if (Result.isFailure(configResult)) { | |
| 332 | + return ( | |
| 333 | + <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-3 text-sm text-destructive"> | |
| 334 | + Failed loading GraphQL config. | |
| 335 | + </div> | |
| 336 | + ); | |
| 337 | + } | |
| 338 | + | |
| 339 | + if (!Result.isSuccess(configResult)) { | |
| 340 | + return <div className="text-sm text-muted-foreground">Loading GraphQL config...</div>; | |
| 341 | + } | |
| 342 | + | |
| 343 | + return ( | |
| 344 | + <GraphqlSourceForm | |
| 345 | + initialValue={configResult.value} | |
| 346 | + mode="edit" | |
| 347 | + onSubmit={async (input) => { | |
| 348 | + const source = await updateSource({ | |
| 349 | + path: { | |
| 350 | + workspaceId: installation.data.scopeId, | |
| 351 | + sourceId: props.source.id, | |
| 352 | + }, | |
| 353 | + payload: input, | |
| 354 | + reactivityKeys: { | |
| 355 | + sources: [installation.data.scopeId], | |
| 356 | + source: [installation.data.scopeId, props.source.id], | |
| 357 | + sourceInspection: [installation.data.scopeId, props.source.id], | |
| 358 | + sourceInspectionTool: [installation.data.scopeId, props.source.id], | |
| 359 | + sourceDiscovery: [installation.data.scopeId, props.source.id], | |
| 360 | + }, | |
| 361 | + }); | |
| 362 | + | |
| 363 | + startTransition(() => { | |
| 364 | + void navigate({ | |
| 365 | + to: "/sources/$sourceId", | |
| 366 | + params: { | |
| 367 | + sourceId: source.id, | |
| 368 | + }, | |
| 369 | + search: { | |
| 370 | + tab: "model", | |
| 371 | + }, | |
| 372 | + }); | |
| 373 | + }); | |
| 374 | + }} | |
| 375 | + /> | |
| 376 | + ); | |
| 377 | +} | |
| 378 | + | |
| 379 | +function GraphqlDetailPage(props: { | |
| 380 | + source: Source; | |
| 381 | + route: { | |
| 382 | + search?: unknown; | |
| 383 | + navigate?: unknown; | |
| 384 | + }; | |
| 385 | +}) { | |
| 386 | + const navigate = useNavigate(); | |
| 387 | + const installation = useLocalInstallation(); | |
| 388 | + const client = getGraphqlHttpClient(); | |
| 389 | + const removeSource = useAtomSet( | |
| 390 | + client.mutation("graphql", "removeSource"), | |
| 391 | + { mode: "promise" }, | |
| 392 | + ); | |
| 393 | + const configResult = useAtomValue( | |
| 394 | + installation.status === "ready" | |
| 395 | + ? client.query("graphql", "getSourceConfig", { | |
| 396 | + path: { | |
| 397 | + workspaceId: installation.data.scopeId, | |
| 398 | + sourceId: props.source.id, | |
| 399 | + }, | |
| 400 | + reactivityKeys: { | |
| 401 | + source: [installation.data.scopeId, props.source.id], | |
| 402 | + }, | |
| 403 | + timeToLive: "30 seconds", | |
| 404 | + }) | |
| 405 | + : client.query("local", "installation", { | |
| 406 | + timeToLive: "1 second", | |
| 407 | + }) as never, | |
| 408 | + ); | |
| 409 | + const summary = useMemo(() => { | |
| 410 | + if (!Result.isSuccess(configResult)) { | |
| 411 | + return null; | |
| 412 | + } | |
| 413 | + | |
| 414 | + const config = configResult.value; | |
| 415 | + return ( | |
| 416 | + <div className="space-y-1"> | |
| 417 | + <div className="font-mono text-xs text-foreground">{config.endpoint}</div> | |
| 418 | + <div> | |
| 419 | + Auth: <span className="text-foreground">{config.auth.kind}</span> | |
| 420 | + </div> | |
| 421 | + </div> | |
| 422 | + ); | |
| 423 | + }, [configResult]); | |
| 424 | + | |
| 425 | + if (installation.status !== "ready") { | |
| 426 | + return <div className="text-sm text-muted-foreground">Loading workspace...</div>; | |
| 427 | + } | |
| 428 | + | |
| 429 | + return ( | |
| 430 | + <SourceToolExplorer | |
| 431 | + sourceId={props.source.id} | |
| 432 | + title={props.source.name} | |
| 433 | + kind={props.source.kind} | |
| 434 | + search={(props.route.search ?? {}) as RouteToolSearch} | |
| 435 | + navigate={(next) => { | |
| 436 | + const routeNavigate = props.route.navigate as | |
| 437 | + | ((input: { | |
| 438 | + search: { | |
| 439 | + tab: "model" | "discover"; | |
| 440 | + tool?: string; | |
| 441 | + query?: string; | |
| 442 | + }; | |
| 443 | + }) => void | Promise<void>) | |
| 444 | + | undefined; | |
| 445 | + if (routeNavigate) { | |
| 446 | + void routeNavigate({ search: next }); | |
| 447 | + } | |
| 448 | + }} | |
| 449 | + summary={summary} | |
| 450 | + actions={( | |
| 451 | + <> | |
| 452 | + <button | |
| 453 | + type="button" | |
| 454 | + onClick={() => | |
| 455 | + void navigate({ | |
| 456 | + to: "/sources/$sourceId/edit", | |
| 457 | + params: { | |
| 458 | + sourceId: props.source.id, | |
| 459 | + }, | |
| 460 | + })} | |
| 461 | + className="inline-flex h-9 items-center gap-1.5 rounded-lg border border-input bg-card px-3 text-sm font-medium text-foreground transition-colors hover:bg-accent/50" | |
| 462 | + > | |
| 463 | + <IconPencil className="size-3.5" /> | |
| 464 | + Edit | |
| 465 | + </button> | |
| 466 | + <button | |
| 467 | + type="button" | |
| 468 | + onClick={() => { | |
| 469 | + const confirmed = window.confirm(`Delete GraphQL source "${props.source.name}"?`); | |
| 470 | + if (!confirmed) { | |
| 471 | + return; | |
| 472 | + } | |
| 473 | + | |
| 474 | + void removeSource({ | |
| 475 | + path: { | |
| 476 | + workspaceId: installation.data.scopeId, | |
| 477 | + sourceId: props.source.id, | |
| 478 | + }, | |
| 479 | + reactivityKeys: { | |
| 480 | + sources: [installation.data.scopeId], | |
| 481 | + source: [installation.data.scopeId, props.source.id], | |
| 482 | + sourceInspection: [installation.data.scopeId, props.source.id], | |
| 483 | + sourceInspectionTool: [installation.data.scopeId, props.source.id], | |
| 484 | + sourceDiscovery: [installation.data.scopeId, props.source.id], | |
| 485 | + }, | |
| 486 | + }).then(() => { | |
| 487 | + startTransition(() => { | |
| 488 | + void navigate({ | |
| 489 | + to: "/", | |
| 490 | + }); | |
| 491 | + }); | |
| 492 | + }); | |
| 493 | + }} | |
| 494 | + className="inline-flex h-9 items-center rounded-lg border border-destructive/25 bg-destructive/5 px-3 text-sm font-medium text-destructive transition-colors hover:bg-destructive/10" | |
| 495 | + > | |
| 496 | + Delete | |
| 497 | + </button> | |
| 498 | + </> | |
| 499 | + )} | |
| 500 | + /> | |
| 501 | + ); | |
| 502 | +} | |
| 503 | + | |
| 504 | +export const GraphqlReactPlugin = { | |
| 505 | + key: "graphql", | |
| 506 | + register(api: FrontendPluginRegisterApi) { | |
| 507 | + api.sources.registerType({ | |
| 508 | + kind: "graphql", | |
| 509 | + displayName: "GraphQL", | |
| 510 | + description: "Introspect a GraphQL endpoint into typed query and mutation tools.", | |
| 511 | + renderAddPage: () => <GraphqlAddPage />, | |
| 512 | + renderEditPage: ({ source }) => <GraphqlEditPage source={source} />, | |
| 513 | + renderDetailPage: ({ source, route }) => ( | |
| 514 | + <GraphqlDetailPage source={source} route={route} /> | |
| 515 | + ), | |
| 516 | + }); | |
| 517 | + }, | |
| 518 | +}; | |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -0,0 +1,317 @@ | ||
| 1 | +import { createCatalogSnapshotV1FromFragments } from "@executor/ir/catalog"; | |
| 2 | +import { | |
| 3 | + CapabilityIdSchema, | |
| 4 | + DocumentIdSchema, | |
| 5 | + ExecutableIdSchema, | |
| 6 | + ResponseSymbolIdSchema, | |
| 7 | + ScopeIdSchema, | |
| 8 | +} from "@executor/ir/ids"; | |
| 9 | +import type { | |
| 10 | + Capability, | |
| 11 | + CatalogSnapshotV1, | |
| 12 | + ResponseSymbol, | |
| 13 | + Executable, | |
| 14 | +} from "@executor/ir/model"; | |
| 15 | +import { | |
| 16 | + type BaseCatalogOperationInput, | |
| 17 | + type CatalogFragmentBuilder, | |
| 18 | + type CatalogSourceDocumentInput, | |
| 19 | + type JsonSchemaImporter, | |
| 20 | + type Source, | |
| 21 | + EXECUTABLE_BINDING_VERSION, | |
| 22 | + asJsonRecord, | |
| 23 | + buildCatalogFragment, | |
| 24 | + createCatalogImportMetadata, | |
| 25 | + docsFrom, | |
| 26 | + interactionForEffect, | |
| 27 | + mutableRecord, | |
| 28 | + provenanceFor, | |
| 29 | + responseSetFromSingleResponse, | |
| 30 | + schemaWithMergedDefs, | |
| 31 | + stableHash, | |
| 32 | + toolPathSegments, | |
| 33 | +} from "@executor/source-core"; | |
| 34 | + | |
| 35 | +import type { GraphqlToolProviderData } from "./provider-data"; | |
| 36 | + | |
| 37 | +export type GraphqlCatalogOperationInput = BaseCatalogOperationInput & { | |
| 38 | + providerData: GraphqlToolProviderData; | |
| 39 | +}; | |
| 40 | + | |
| 41 | +const graphqlErrorItemsJsonSchema = (): Record<string, unknown> => ({ | |
| 42 | + type: "array", | |
| 43 | + items: { | |
| 44 | + type: "object", | |
| 45 | + properties: { | |
| 46 | + message: { | |
| 47 | + type: "string", | |
| 48 | + description: "GraphQL error message.", | |
| 49 | + }, | |
| 50 | + path: { | |
| 51 | + type: "array", | |
| 52 | + description: "Path to the field that produced the error.", | |
| 53 | + items: { | |
| 54 | + anyOf: [{ type: "string" }, { type: "number" }], | |
| 55 | + }, | |
| 56 | + }, | |
| 57 | + locations: { | |
| 58 | + type: "array", | |
| 59 | + description: "Source locations for the error in the GraphQL document.", | |
| 60 | + items: { | |
| 61 | + type: "object", | |
| 62 | + properties: { | |
| 63 | + line: { type: "number" }, | |
| 64 | + column: { type: "number" }, | |
| 65 | + }, | |
| 66 | + required: ["line", "column"], | |
| 67 | + additionalProperties: false, | |
| 68 | + }, | |
| 69 | + }, | |
| 70 | + extensions: { | |
| 71 | + type: "object", | |
| 72 | + description: "Additional provider-specific GraphQL error metadata.", | |
| 73 | + additionalProperties: true, | |
| 74 | + }, | |
| 75 | + }, | |
| 76 | + required: ["message"], | |
| 77 | + additionalProperties: true, | |
| 78 | + }, | |
| 79 | +}); | |
| 80 | + | |
| 81 | +const graphqlProjectedResultDataSchema = (input: { | |
| 82 | + toolKind: "request" | "field"; | |
| 83 | + outputSchema: unknown; | |
| 84 | +}): Record<string, unknown> => { | |
| 85 | + if (input.toolKind !== "field") { | |
| 86 | + return asJsonRecord(input.outputSchema); | |
| 87 | + } | |
| 88 | + | |
| 89 | + const outputSchema = asJsonRecord(input.outputSchema); | |
| 90 | + const dataSchema = asJsonRecord(asJsonRecord(outputSchema.properties).data); | |
| 91 | + | |
| 92 | + return Object.keys(dataSchema).length > 0 | |
| 93 | + ? schemaWithMergedDefs(dataSchema, input.outputSchema) | |
| 94 | + : outputSchema; | |
| 95 | +}; | |
| 96 | + | |
| 97 | +const graphqlProjectedResultErrorSchema = (input: { | |
| 98 | + toolKind: "request" | "field"; | |
| 99 | + outputSchema: unknown; | |
| 100 | +}): Record<string, unknown> => { | |
| 101 | + if (input.toolKind !== "field") { | |
| 102 | + return graphqlErrorItemsJsonSchema(); | |
| 103 | + } | |
| 104 | + | |
| 105 | + const outputSchema = asJsonRecord(input.outputSchema); | |
| 106 | + const errorsSchema = asJsonRecord( | |
| 107 | + asJsonRecord(outputSchema.properties).errors, | |
| 108 | + ); | |
| 109 | + | |
| 110 | + return Object.keys(errorsSchema).length > 0 | |
| 111 | + ? schemaWithMergedDefs(errorsSchema, input.outputSchema) | |
| 112 | + : graphqlErrorItemsJsonSchema(); | |
| 113 | +}; | |
| 114 | + | |
| 115 | +const createGraphqlCapability = (input: { | |
| 116 | + catalog: CatalogFragmentBuilder; | |
| 117 | + source: Pick<Source, "id" | "name" | "namespace">; | |
| 118 | + documentId: ReturnType<typeof DocumentIdSchema.make>; | |
| 119 | + serviceScopeId: ReturnType<typeof ScopeIdSchema.make>; | |
| 120 | + operation: GraphqlCatalogOperationInput; | |
| 121 | + importer: JsonSchemaImporter; | |
| 122 | +}) => { | |
| 123 | + const toolPath = toolPathSegments( | |
| 124 | + input.source, | |
| 125 | + input.operation.providerData.toolId, | |
| 126 | + ); | |
| 127 | + const capabilityId = CapabilityIdSchema.make( | |
| 128 | + `cap_${stableHash({ | |
| 129 | + sourceId: input.source.id, | |
| 130 | + toolId: input.operation.providerData.toolId, | |
| 131 | + })}`, | |
| 132 | + ); | |
| 133 | + const executableId = ExecutableIdSchema.make( | |
| 134 | + `exec_${stableHash({ | |
| 135 | + sourceId: input.source.id, | |
| 136 | + toolId: input.operation.providerData.toolId, | |
| 137 | + protocol: "graphql", | |
| 138 | + })}`, | |
| 139 | + ); | |
| 140 | + const callShapeId = | |
| 141 | + input.operation.inputSchema !== undefined | |
| 142 | + ? input.importer.importSchema( | |
| 143 | + input.operation.inputSchema, | |
| 144 | + `#/graphql/${input.operation.providerData.toolId}/call`, | |
| 145 | + ) | |
| 146 | + : input.importer.importSchema( | |
| 147 | + { | |
| 148 | + type: "object", | |
| 149 | + additionalProperties: true, | |
| 150 | + }, | |
| 151 | + `#/graphql/${input.operation.providerData.toolId}/call`, | |
| 152 | + ); | |
| 153 | + const resultDataShapeId = | |
| 154 | + input.operation.outputSchema !== undefined | |
| 155 | + ? input.importer.importSchema( | |
| 156 | + graphqlProjectedResultDataSchema({ | |
| 157 | + toolKind: input.operation.providerData.toolKind, | |
| 158 | + outputSchema: input.operation.outputSchema, | |
| 159 | + }), | |
| 160 | + `#/graphql/${input.operation.providerData.toolId}/data`, | |
| 161 | + ) | |
| 162 | + : input.importer.importSchema( | |
| 163 | + { | |
| 164 | + type: "object", | |
| 165 | + additionalProperties: true, | |
| 166 | + }, | |
| 167 | + `#/graphql/${input.operation.providerData.toolId}/data`, | |
| 168 | + ); | |
| 169 | + const resultErrorShapeId = input.importer.importSchema( | |
| 170 | + graphqlProjectedResultErrorSchema({ | |
| 171 | + toolKind: input.operation.providerData.toolKind, | |
| 172 | + outputSchema: input.operation.outputSchema, | |
| 173 | + }), | |
| 174 | + `#/graphql/${input.operation.providerData.toolId}/errors`, | |
| 175 | + ); | |
| 176 | + | |
| 177 | + const responseId = ResponseSymbolIdSchema.make( | |
| 178 | + `response_${stableHash({ capabilityId })}`, | |
| 179 | + ); | |
| 180 | + mutableRecord(input.catalog.symbols)[responseId] = { | |
| 181 | + id: responseId, | |
| 182 | + kind: "response", | |
| 183 | + ...(docsFrom({ | |
| 184 | + description: input.operation.description, | |
| 185 | + }) | |
| 186 | + ? { | |
| 187 | + docs: docsFrom({ | |
| 188 | + description: input.operation.description, | |
| 189 | + })!, | |
| 190 | + } | |
| 191 | + : {}), | |
| 192 | + contents: [ | |
| 193 | + { | |
| 194 | + mediaType: "application/json", | |
| 195 | + shapeId: resultDataShapeId, | |
| 196 | + }, | |
| 197 | + ], | |
| 198 | + synthetic: false, | |
| 199 | + provenance: provenanceFor( | |
| 200 | + input.documentId, | |
| 201 | + `#/graphql/${input.operation.providerData.toolId}/response`, | |
| 202 | + ), | |
| 203 | + } satisfies ResponseSymbol; | |
| 204 | + | |
| 205 | + const responseSetId = responseSetFromSingleResponse({ | |
| 206 | + catalog: input.catalog, | |
| 207 | + responseId, | |
| 208 | + provenance: provenanceFor( | |
| 209 | + input.documentId, | |
| 210 | + `#/graphql/${input.operation.providerData.toolId}/responseSet`, | |
| 211 | + ), | |
| 212 | + }); | |
| 213 | + | |
| 214 | + mutableRecord(input.catalog.executables)[executableId] = { | |
| 215 | + id: executableId, | |
| 216 | + capabilityId, | |
| 217 | + scopeId: input.serviceScopeId, | |
| 218 | + pluginKey: "graphql", | |
| 219 | + bindingVersion: EXECUTABLE_BINDING_VERSION, | |
| 220 | + binding: input.operation.providerData, | |
| 221 | + projection: { | |
| 222 | + responseSetId, | |
| 223 | + callShapeId, | |
| 224 | + resultDataShapeId, | |
| 225 | + resultErrorShapeId, | |
| 226 | + }, | |
| 227 | + display: { | |
| 228 | + protocol: "graphql", | |
| 229 | + method: input.operation.providerData.operationType ?? "query", | |
| 230 | + pathTemplate: | |
| 231 | + input.operation.providerData.fieldName ?? | |
| 232 | + input.operation.providerData.leaf ?? | |
| 233 | + input.operation.providerData.toolId, | |
| 234 | + operationId: | |
| 235 | + input.operation.providerData.fieldName ?? | |
| 236 | + input.operation.providerData.leaf ?? | |
| 237 | + input.operation.providerData.toolId, | |
| 238 | + group: input.operation.providerData.group, | |
| 239 | + leaf: input.operation.providerData.leaf, | |
| 240 | + rawToolId: input.operation.providerData.toolId, | |
| 241 | + title: input.operation.title ?? null, | |
| 242 | + summary: input.operation.description ?? null, | |
| 243 | + }, | |
| 244 | + synthetic: false, | |
| 245 | + provenance: provenanceFor( | |
| 246 | + input.documentId, | |
| 247 | + `#/graphql/${input.operation.providerData.toolId}/executable`, | |
| 248 | + ), | |
| 249 | + } satisfies Executable; | |
| 250 | + | |
| 251 | + const effect = input.operation.effect; | |
| 252 | + | |
| 253 | + mutableRecord(input.catalog.capabilities)[capabilityId] = { | |
| 254 | + id: capabilityId, | |
| 255 | + serviceScopeId: input.serviceScopeId, | |
| 256 | + surface: { | |
| 257 | + toolPath, | |
| 258 | + ...(input.operation.title ? { title: input.operation.title } : {}), | |
| 259 | + ...(input.operation.description | |
| 260 | + ? { summary: input.operation.description } | |
| 261 | + : {}), | |
| 262 | + ...(input.operation.providerData.group | |
| 263 | + ? { tags: [input.operation.providerData.group] } | |
| 264 | + : {}), | |
| 265 | + }, | |
| 266 | + semantics: { | |
| 267 | + effect, | |
| 268 | + safe: effect === "read", | |
| 269 | + idempotent: effect === "read", | |
| 270 | + destructive: false, | |
| 271 | + }, | |
| 272 | + auth: { kind: "none" }, | |
| 273 | + interaction: interactionForEffect(effect), | |
| 274 | + executableIds: [executableId], | |
| 275 | + synthetic: false, | |
| 276 | + provenance: provenanceFor( | |
| 277 | + input.documentId, | |
| 278 | + `#/graphql/${input.operation.providerData.toolId}/capability`, | |
| 279 | + ), | |
| 280 | + } satisfies Capability; | |
| 281 | +}; | |
| 282 | + | |
| 283 | +export const createGraphqlCatalogFragment = (input: { | |
| 284 | + source: Source; | |
| 285 | + documents: readonly CatalogSourceDocumentInput[]; | |
| 286 | + operations: readonly GraphqlCatalogOperationInput[]; | |
| 287 | +}) => | |
| 288 | + buildCatalogFragment({ | |
| 289 | + source: input.source, | |
| 290 | + documents: input.documents, | |
| 291 | + resourceDialectUri: "https://spec.graphql.org/", | |
| 292 | + registerOperations: ({ catalog, documentId, serviceScopeId, importer }) => { | |
| 293 | + for (const operation of input.operations) { | |
| 294 | + createGraphqlCapability({ | |
| 295 | + catalog, | |
| 296 | + source: input.source, | |
| 297 | + documentId, | |
| 298 | + serviceScopeId, | |
| 299 | + operation, | |
| 300 | + importer, | |
| 301 | + }); | |
| 302 | + } | |
| 303 | + }, | |
| 304 | + }); | |
| 305 | + | |
| 306 | +export const createGraphqlCatalogSnapshot = (input: { | |
| 307 | + source: Source; | |
| 308 | + documents: readonly CatalogSourceDocumentInput[]; | |
| 309 | + operations: readonly GraphqlCatalogOperationInput[]; | |
| 310 | +}): CatalogSnapshotV1 => | |
| 311 | + createCatalogSnapshotV1FromFragments({ | |
| 312 | + import: createCatalogImportMetadata({ | |
| 313 | + source: input.source, | |
| 314 | + pluginKey: "graphql", | |
| 315 | + }), | |
| 316 | + fragments: [createGraphqlCatalogFragment(input)], | |
| 317 | + }); | |
| @@ -0,0 +1,107 @@ | ||
| 1 | +import { | |
| 2 | + defaultNameFromEndpoint, | |
| 3 | + executeHttpProbe, | |
| 4 | + isRecord, | |
| 5 | + namespaceFromSourceName, | |
| 6 | + noneAuthInference, | |
| 7 | + parseChallengeAuthInference, | |
| 8 | + tryParseJson, | |
| 9 | + type SourceDiscoveryProbeInput, | |
| 10 | + type SourceDiscoveryResult, | |
| 11 | +} from "@executor/source-core"; | |
| 12 | +import * as Either from "effect/Either"; | |
| 13 | +import * as Effect from "effect/Effect"; | |
| 14 | + | |
| 15 | +import { GRAPHQL_INTROSPECTION_QUERY } from "./graphql-tools"; | |
| 16 | + | |
| 17 | +const looksLikeGraphqlEndpoint = (value: string): boolean => { | |
| 18 | + try { | |
| 19 | + const url = new URL(value); | |
| 20 | + return /graphql/i.test(`${url.hostname}${url.pathname}`); | |
| 21 | + } catch { | |
| 22 | + return /graphql/i.test(value); | |
| 23 | + } | |
| 24 | +}; | |
| 25 | + | |
| 26 | +export const detectGraphqlSource = ( | |
| 27 | + input: SourceDiscoveryProbeInput, | |
| 28 | +): Effect.Effect<SourceDiscoveryResult | null, never, never> => | |
| 29 | + Effect.gen(function* () { | |
| 30 | + const response = yield* Effect.either(executeHttpProbe({ | |
| 31 | + method: "POST", | |
| 32 | + url: input.normalizedUrl, | |
| 33 | + headers: { | |
| 34 | + accept: "application/graphql-response+json, application/json", | |
| 35 | + "content-type": "application/json", | |
| 36 | + ...input.headers, | |
| 37 | + }, | |
| 38 | + body: JSON.stringify({ query: GRAPHQL_INTROSPECTION_QUERY }), | |
| 39 | + })); | |
| 40 | + | |
| 41 | + if (Either.isLeft(response)) { | |
| 42 | + return null; | |
| 43 | + } | |
| 44 | + | |
| 45 | + const parsed = tryParseJson(response.right.text); | |
| 46 | + const contentType = (response.right.headers["content-type"] ?? "").toLowerCase(); | |
| 47 | + const data = isRecord(parsed) && isRecord(parsed.data) ? parsed.data : null; | |
| 48 | + if (data && isRecord(data.__schema)) { | |
| 49 | + const name = defaultNameFromEndpoint(input.normalizedUrl); | |
| 50 | + return { | |
| 51 | + detectedKind: "plugin", | |
| 52 | + confidence: "high", | |
| 53 | + endpoint: input.normalizedUrl, | |
| 54 | + specUrl: null, | |
| 55 | + name, | |
| 56 | + namespace: namespaceFromSourceName(name), | |
| 57 | + transport: null, | |
| 58 | + authInference: noneAuthInference( | |
| 59 | + "GraphQL introspection succeeded without an advertised auth requirement", | |
| 60 | + "medium", | |
| 61 | + ), | |
| 62 | + toolCount: null, | |
| 63 | + warnings: [], | |
| 64 | + } satisfies SourceDiscoveryResult; | |
| 65 | + } | |
| 66 | + | |
| 67 | + const errors = isRecord(parsed) && Array.isArray(parsed.errors) | |
| 68 | + ? parsed.errors | |
| 69 | + : []; | |
| 70 | + const graphqlErrors = errors | |
| 71 | + .map((error) => isRecord(error) ? (typeof error.message === "string" ? error.message : null) : null) | |
| 72 | + .filter((message): message is string => message !== null); | |
| 73 | + | |
| 74 | + const mediumConfidenceGraphql = | |
| 75 | + contentType.includes("application/graphql-response+json") | |
| 76 | + || (looksLikeGraphqlEndpoint(input.normalizedUrl) && response.right.status >= 400 && response.right.status < 500) | |
| 77 | + || graphqlErrors.some((message) => /introspection|graphql|query/i.test(message)); | |
| 78 | + | |
| 79 | + if (!mediumConfidenceGraphql) { | |
| 80 | + return null; | |
| 81 | + } | |
| 82 | + | |
| 83 | + const name = defaultNameFromEndpoint(input.normalizedUrl); | |
| 84 | + return { | |
| 85 | + detectedKind: "plugin", | |
| 86 | + confidence: data ? "high" : "medium", | |
| 87 | + endpoint: input.normalizedUrl, | |
| 88 | + specUrl: null, | |
| 89 | + name, | |
| 90 | + namespace: namespaceFromSourceName(name), | |
| 91 | + transport: null, | |
| 92 | + authInference: | |
| 93 | + response.right.status === 401 || response.right.status === 403 | |
| 94 | + ? parseChallengeAuthInference( | |
| 95 | + response.right.headers, | |
| 96 | + "GraphQL endpoint rejected introspection and did not advertise a concrete auth scheme", | |
| 97 | + ) | |
| 98 | + : noneAuthInference( | |
| 99 | + graphqlErrors.length > 0 | |
| 100 | + ? `GraphQL endpoint responded with errors during introspection: ${graphqlErrors[0]}` | |
| 101 | + : "GraphQL endpoint shape detected", | |
| 102 | + "medium", | |
| 103 | + ), | |
| 104 | + toolCount: null, | |
| 105 | + warnings: graphqlErrors.length > 0 ? [graphqlErrors[0]!] : [], | |
| 106 | + } satisfies SourceDiscoveryResult; | |
| 107 | + }).pipe(Effect.catchAll(() => Effect.succeed(null))); | |
| @@ -0,0 +1,1737 @@ | ||
| 1 | +import { | |
| 2 | + applyCookiePlacementsToHeaders, | |
| 3 | + applyHttpQueryPlacementsToUrl, | |
| 4 | + applyJsonBodyPlacements, | |
| 5 | + sha256Hex, | |
| 6 | + standardSchemaFromJsonSchema, | |
| 7 | + toTool, | |
| 8 | + type HttpRequestPlacements, | |
| 9 | + type ToolDescriptor, | |
| 10 | + type ToolMap, | |
| 11 | + type ToolMetadata, | |
| 12 | + type ToolPath, | |
| 13 | + typeSignatureFromSchema, | |
| 14 | +} from "@executor/codemode-core"; | |
| 15 | +import { sourceCoreEffectError } from "@executor/source-core"; | |
| 16 | +import { | |
| 17 | + GraphqlToolProviderDataSchema, | |
| 18 | + type GraphqlToolProviderData, | |
| 19 | +} from "./provider-data"; | |
| 20 | +import * as Either from "effect/Either"; | |
| 21 | +import * as Effect from "effect/Effect"; | |
| 22 | +import * as Schema from "effect/Schema"; | |
| 23 | +import { | |
| 24 | + buildClientSchema, | |
| 25 | + getIntrospectionQuery, | |
| 26 | + getNamedType, | |
| 27 | + isEnumType, | |
| 28 | + isInputObjectType, | |
| 29 | + isInterfaceType, | |
| 30 | + isLeafType, | |
| 31 | + isListType, | |
| 32 | + isNonNullType, | |
| 33 | + isObjectType, | |
| 34 | + isScalarType, | |
| 35 | + isUnionType, | |
| 36 | + type GraphQLArgument, | |
| 37 | + type GraphQLInputObjectType, | |
| 38 | + type GraphQLInputType, | |
| 39 | + type GraphQLNamedType, | |
| 40 | + type GraphQLOutputType, | |
| 41 | + type GraphQLSchema, | |
| 42 | + type IntrospectionQuery, | |
| 43 | +} from "graphql"; | |
| 44 | +import * as HashMap from "effect/HashMap"; | |
| 45 | +import * as Option from "effect/Option"; | |
| 46 | + | |
| 47 | +type JsonSchema = Record<string, unknown>; | |
| 48 | +type GraphqlSchemaRefTable = Record<string, string>; | |
| 49 | + | |
| 50 | +type GraphqlOperationType = "query" | "mutation"; | |
| 51 | + | |
| 52 | +const GRAPHQL_PRESENTATION_TYPE_MAX_LENGTH = 320; | |
| 53 | + | |
| 54 | +type GraphqlManifestToolBase = { | |
| 55 | + toolId: string; | |
| 56 | + rawToolId: string | null; | |
| 57 | + toolName: string; | |
| 58 | + description: string | null; | |
| 59 | + inputSchema?: JsonSchema; | |
| 60 | + outputSchema?: JsonSchema; | |
| 61 | + exampleInput?: unknown; | |
| 62 | +}; | |
| 63 | + | |
| 64 | +export type GraphqlRequestToolManifestEntry = GraphqlManifestToolBase & { | |
| 65 | + kind: "request"; | |
| 66 | +}; | |
| 67 | + | |
| 68 | +export type GraphqlFieldToolManifestEntry = GraphqlManifestToolBase & { | |
| 69 | + kind: "field"; | |
| 70 | + group: GraphqlOperationType; | |
| 71 | + leaf: string; | |
| 72 | + fieldName: string; | |
| 73 | + operationType: GraphqlOperationType; | |
| 74 | + operationName: string; | |
| 75 | + operationDocument: string; | |
| 76 | + searchTerms: readonly string[]; | |
| 77 | +}; | |
| 78 | + | |
| 79 | +export type GraphqlToolManifestEntry = | |
| 80 | + | GraphqlRequestToolManifestEntry | |
| 81 | + | GraphqlFieldToolManifestEntry; | |
| 82 | + | |
| 83 | +export type GraphqlToolManifest = { | |
| 84 | + version: 2; | |
| 85 | + sourceHash: string; | |
| 86 | + queryTypeName: string | null; | |
| 87 | + mutationTypeName: string | null; | |
| 88 | + subscriptionTypeName: string | null; | |
| 89 | + schemaRefTable?: GraphqlSchemaRefTable; | |
| 90 | + tools: readonly GraphqlToolManifestEntry[]; | |
| 91 | +}; | |
| 92 | + | |
| 93 | +export type GraphqlToolDefinition = { | |
| 94 | + toolId: string; | |
| 95 | + rawToolId: string | null; | |
| 96 | + name: string; | |
| 97 | + description: string; | |
| 98 | + group: string | null; | |
| 99 | + leaf: string | null; | |
| 100 | + fieldName: string | null; | |
| 101 | + operationType: GraphqlOperationType | null; | |
| 102 | + operationName: string | null; | |
| 103 | + operationDocument: string | null; | |
| 104 | + searchTerms: readonly string[]; | |
| 105 | +}; | |
| 106 | + | |
| 107 | +export type GraphqlToolPresentation = { | |
| 108 | + inputTypePreview: string; | |
| 109 | + outputTypePreview: string; | |
| 110 | + inputSchema?: JsonSchema; | |
| 111 | + outputSchema?: JsonSchema; | |
| 112 | + exampleInput?: unknown; | |
| 113 | + providerData: GraphqlToolProviderData; | |
| 114 | +}; | |
| 115 | + | |
| 116 | +type GraphqlToolPresentationResolver = { | |
| 117 | + resolve(definition: GraphqlToolDefinition): GraphqlToolPresentation; | |
| 118 | +}; | |
| 119 | + | |
| 120 | +type SelectedGraphqlOutput = { | |
| 121 | + selectionSet: string; | |
| 122 | + schema: JsonSchema; | |
| 123 | +}; | |
| 124 | + | |
| 125 | +type GraphqlHttpInvocation = { | |
| 126 | + endpoint: string; | |
| 127 | + path: string; | |
| 128 | + defaultHeaders?: Readonly<Record<string, string>>; | |
| 129 | + credentialHeaders?: Readonly<Record<string, string>>; | |
| 130 | + credentialPlacements?: HttpRequestPlacements; | |
| 131 | + query: string; | |
| 132 | + variables?: Record<string, unknown>; | |
| 133 | + operationName?: string; | |
| 134 | + requestHeaders?: Record<string, string>; | |
| 135 | +}; | |
| 136 | + | |
| 137 | +const BLOCKED_RESPONSE_HEADER_NAMES = new Set([ | |
| 138 | + "authorization", | |
| 139 | + "authentication-info", | |
| 140 | + "cookie", | |
| 141 | + "proxy-authenticate", | |
| 142 | + "proxy-authorization", | |
| 143 | + "set-cookie", | |
| 144 | + "set-cookie2", | |
| 145 | + "www-authenticate", | |
| 146 | + "x-api-key", | |
| 147 | + "x-auth-token", | |
| 148 | + "x-csrf-token", | |
| 149 | +]); | |
| 150 | + | |
| 151 | +const PREFERRED_LEAF_FIELD_NAMES = [ | |
| 152 | + "id", | |
| 153 | + "identifier", | |
| 154 | + "key", | |
| 155 | + "slug", | |
| 156 | + "name", | |
| 157 | + "title", | |
| 158 | + "number", | |
| 159 | + "url", | |
| 160 | + "state", | |
| 161 | + "status", | |
| 162 | + "success", | |
| 163 | +] as const; | |
| 164 | + | |
| 165 | +const PREFERRED_NESTED_FIELD_NAMES = [ | |
| 166 | + "node", | |
| 167 | + "nodes", | |
| 168 | + "edge", | |
| 169 | + "edges", | |
| 170 | + "pageInfo", | |
| 171 | + "viewer", | |
| 172 | + "user", | |
| 173 | + "users", | |
| 174 | + "team", | |
| 175 | + "teams", | |
| 176 | + "project", | |
| 177 | + "projects", | |
| 178 | + "organization", | |
| 179 | + "issue", | |
| 180 | + "issues", | |
| 181 | + "creator", | |
| 182 | + "assignee", | |
| 183 | + "items", | |
| 184 | +] as const; | |
| 185 | + | |
| 186 | +const asToolPath = (value: string): ToolPath => value as ToolPath; | |
| 187 | + | |
| 188 | +const asRecord = (value: unknown): Record<string, unknown> => | |
| 189 | + typeof value === "object" && value !== null && !Array.isArray(value) | |
| 190 | + ? (value as Record<string, unknown>) | |
| 191 | + : {}; | |
| 192 | + | |
| 193 | +const asString = (value: unknown): string | null => | |
| 194 | + typeof value === "string" && value.trim().length > 0 ? value : null; | |
| 195 | + | |
| 196 | +const asStringRecord = (value: unknown): Record<string, string> => { | |
| 197 | + const record = asRecord(value); | |
| 198 | + const normalized: Record<string, string> = {}; | |
| 199 | + | |
| 200 | + for (const [key, entry] of Object.entries(record)) { | |
| 201 | + if (typeof entry === "string") { | |
| 202 | + normalized[key] = entry; | |
| 203 | + } | |
| 204 | + } | |
| 205 | + | |
| 206 | + return normalized; | |
| 207 | +}; | |
| 208 | + | |
| 209 | +const sanitizeResponseHeaders = ( | |
| 210 | + headers: Readonly<Record<string, string>>, | |
| 211 | +): Record<string, string> => { | |
| 212 | + const sanitized: Record<string, string> = {}; | |
| 213 | + | |
| 214 | + for (const [rawName, rawValue] of Object.entries(headers)) { | |
| 215 | + const name = rawName.trim().toLowerCase(); | |
| 216 | + if (name.length === 0 || BLOCKED_RESPONSE_HEADER_NAMES.has(name)) { | |
| 217 | + continue; | |
| 218 | + } | |
| 219 | + | |
| 220 | + sanitized[name] = rawValue; | |
| 221 | + } | |
| 222 | + | |
| 223 | + return sanitized; | |
| 224 | +}; | |
| 225 | + | |
| 226 | +const normalizeHttpUrl = (value: string): string => { | |
| 227 | + const trimmed = value.trim(); | |
| 228 | + if (trimmed.length === 0) { | |
| 229 | + throw new Error("GraphQL endpoint is empty"); | |
| 230 | + } | |
| 231 | + | |
| 232 | + const parsed = new URL(trimmed); | |
| 233 | + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { | |
| 234 | + throw new Error("GraphQL endpoint must be http or https"); | |
| 235 | + } | |
| 236 | + | |
| 237 | + return parsed.toString(); | |
| 238 | +}; | |
| 239 | + | |
| 240 | +const graphqlToolError = (message: string, cause?: unknown): Error => | |
| 241 | + new Error(cause instanceof Error ? `${message}: ${cause.message}` : message); | |
| 242 | + | |
| 243 | +const parseGraphqlResponseBody = async ( | |
| 244 | + response: Response, | |
| 245 | +): Promise<unknown> => { | |
| 246 | + const contentType = response.headers.get("content-type")?.toLowerCase() ?? ""; | |
| 247 | + if (contentType.includes("application/json")) { | |
| 248 | + return await response.json(); | |
| 249 | + } | |
| 250 | + | |
| 251 | + return await response.text(); | |
| 252 | +}; | |
| 253 | + | |
| 254 | +const hasGraphqlErrors = (value: unknown): boolean => | |
| 255 | + Array.isArray(asRecord(value).errors); | |
| 256 | + | |
| 257 | +const splitWords = (value: string): Array<string> => | |
| 258 | + value | |
| 259 | + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") | |
| 260 | + .replace(/([A-Z]+)([A-Z][a-z0-9]+)/g, "$1 $2") | |
| 261 | + .replace(/[^a-zA-Z0-9]+/g, " ") | |
| 262 | + .trim() | |
| 263 | + .split(/\s+/) | |
| 264 | + .filter((part) => part.length > 0); | |
| 265 | + | |
| 266 | +const toCamelCase = (value: string): string => { | |
| 267 | + const words = splitWords(value).map((part) => part.toLowerCase()); | |
| 268 | + if (words.length === 0) { | |
| 269 | + return "tool"; | |
| 270 | + } | |
| 271 | + | |
| 272 | + const [first, ...rest] = words; | |
| 273 | + return `${first}${rest.map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`).join("")}`; | |
| 274 | +}; | |
| 275 | + | |
| 276 | +const toPascalCase = (value: string): string => { | |
| 277 | + const camel = toCamelCase(value); | |
| 278 | + return `${camel[0]?.toUpperCase() ?? ""}${camel.slice(1)}`; | |
| 279 | +}; | |
| 280 | + | |
| 281 | +const toTitleCase = (value: string): string => | |
| 282 | + splitWords(value) | |
| 283 | + .map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`) | |
| 284 | + .join(" "); | |
| 285 | + | |
| 286 | +const withSchemaDescription = ( | |
| 287 | + schema: JsonSchema, | |
| 288 | + description: string | null | undefined, | |
| 289 | +): JsonSchema => { | |
| 290 | + const trimmed = description?.trim(); | |
| 291 | + return trimmed ? { ...schema, description: trimmed } : schema; | |
| 292 | +}; | |
| 293 | + | |
| 294 | +const withSchemaDefault = (schema: JsonSchema, value: unknown): JsonSchema => | |
| 295 | + value !== undefined ? { ...schema, default: value } : schema; | |
| 296 | + | |
| 297 | +const withSchemaDeprecation = ( | |
| 298 | + schema: JsonSchema, | |
| 299 | + deprecationReason: string | null | undefined, | |
| 300 | +): JsonSchema => { | |
| 301 | + const trimmed = deprecationReason?.trim(); | |
| 302 | + return trimmed | |
| 303 | + ? { ...schema, deprecated: true, "x-deprecationReason": trimmed } | |
| 304 | + : schema; | |
| 305 | +}; | |
| 306 | + | |
| 307 | +const introspectionQueryFromDocument = ( | |
| 308 | + documentText: string, | |
| 309 | +): IntrospectionQuery => { | |
| 310 | + let parsed: unknown; | |
| 311 | + try { | |
| 312 | + parsed = JSON.parse(documentText) as unknown; | |
| 313 | + } catch (cause) { | |
| 314 | + throw new Error( | |
| 315 | + `GraphQL document is not valid JSON: ${cause instanceof Error ? cause.message : String(cause)}`, | |
| 316 | + ); | |
| 317 | + } | |
| 318 | + | |
| 319 | + const record = asRecord(parsed); | |
| 320 | + if (Array.isArray(record.errors) && record.errors.length > 0) { | |
| 321 | + const messages = record.errors | |
| 322 | + .map((entry) => asString(asRecord(entry).message)) | |
| 323 | + .filter((message): message is string => message !== null); | |
| 324 | + throw new Error( | |
| 325 | + messages.length > 0 | |
| 326 | + ? `GraphQL introspection returned errors: ${messages.join("; ")}` | |
| 327 | + : "GraphQL introspection returned errors", | |
| 328 | + ); | |
| 329 | + } | |
| 330 | + | |
| 331 | + const data = record.data; | |
| 332 | + if (!data || typeof data !== "object" || !("__schema" in data)) { | |
| 333 | + throw new Error("GraphQL introspection document is missing data.__schema"); | |
| 334 | + } | |
| 335 | + | |
| 336 | + return data as IntrospectionQuery; | |
| 337 | +}; | |
| 338 | + | |
| 339 | +const GRAPHQL_REQUEST_INPUT_SCHEMA = { | |
| 340 | + type: "object", | |
| 341 | + properties: { | |
| 342 | + query: { | |
| 343 | + type: "string", | |
| 344 | + description: "GraphQL query or mutation document.", | |
| 345 | + }, | |
| 346 | + variables: { | |
| 347 | + type: "object", | |
| 348 | + description: "Optional GraphQL variables.", | |
| 349 | + additionalProperties: true, | |
| 350 | + }, | |
| 351 | + operationName: { | |
| 352 | + type: "string", | |
| 353 | + description: "Optional GraphQL operation name.", | |
| 354 | + }, | |
| 355 | + headers: { | |
| 356 | + type: "object", | |
| 357 | + description: "Optional per-request headers.", | |
| 358 | + additionalProperties: { | |
| 359 | + type: "string", | |
| 360 | + }, | |
| 361 | + }, | |
| 362 | + }, | |
| 363 | + required: ["query"], | |
| 364 | + additionalProperties: false, | |
| 365 | +} as const; | |
| 366 | + | |
| 367 | +const GRAPHQL_REQUEST_OUTPUT_SCHEMA = { | |
| 368 | + type: "object", | |
| 369 | + properties: { | |
| 370 | + status: { | |
| 371 | + type: "number", | |
| 372 | + }, | |
| 373 | + headers: { | |
| 374 | + type: "object", | |
| 375 | + additionalProperties: { | |
| 376 | + type: "string", | |
| 377 | + }, | |
| 378 | + }, | |
| 379 | + body: {}, | |
| 380 | + }, | |
| 381 | + required: ["status", "headers", "body"], | |
| 382 | + additionalProperties: false, | |
| 383 | +} as const; | |
| 384 | + | |
| 385 | +export const GRAPHQL_INTROSPECTION_QUERY = getIntrospectionQuery({ | |
| 386 | + descriptions: true, | |
| 387 | + inputValueDeprecation: true, | |
| 388 | + schemaDescription: true, | |
| 389 | +}); | |
| 390 | + | |
| 391 | +const scalarInputSchema = (name: string): JsonSchema => { | |
| 392 | + switch (name) { | |
| 393 | + case "String": | |
| 394 | + case "ID": | |
| 395 | + case "Date": | |
| 396 | + case "DateTime": | |
| 397 | + case "DateTimeOrDuration": | |
| 398 | + case "Duration": | |
| 399 | + case "UUID": | |
| 400 | + case "TimelessDate": | |
| 401 | + case "TimelessDateOrDuration": | |
| 402 | + case "URI": | |
| 403 | + return { type: "string" }; | |
| 404 | + case "Int": | |
| 405 | + case "Float": | |
| 406 | + return { type: "number" }; | |
| 407 | + case "Boolean": | |
| 408 | + return { type: "boolean" }; | |
| 409 | + case "JSONObject": | |
| 410 | + return { | |
| 411 | + type: "object", | |
| 412 | + additionalProperties: true, | |
| 413 | + }; | |
| 414 | + case "JSONString": | |
| 415 | + return { type: "string" }; | |
| 416 | + case "JSON": | |
| 417 | + return {}; | |
| 418 | + default: | |
| 419 | + return {}; | |
| 420 | + } | |
| 421 | +}; | |
| 422 | + | |
| 423 | +const scalarExampleValue = (name: string): unknown => { | |
| 424 | + switch (name) { | |
| 425 | + case "String": | |
| 426 | + return "value"; | |
| 427 | + case "ID": | |
| 428 | + return "id"; | |
| 429 | + case "Date": | |
| 430 | + return "2026-03-08"; | |
| 431 | + case "DateTime": | |
| 432 | + case "DateTimeOrDuration": | |
| 433 | + return "2026-03-08T00:00:00.000Z"; | |
| 434 | + case "UUID": | |
| 435 | + return "00000000-0000-0000-0000-000000000000"; | |
| 436 | + case "TimelessDate": | |
| 437 | + case "TimelessDateOrDuration": | |
| 438 | + return "2026-03-08"; | |
| 439 | + case "Duration": | |
| 440 | + return "P1D"; | |
| 441 | + case "URI": | |
| 442 | + return "https://example.com"; | |
| 443 | + case "Int": | |
| 444 | + return 1; | |
| 445 | + case "Float": | |
| 446 | + return 1.5; | |
| 447 | + case "Boolean": | |
| 448 | + return true; | |
| 449 | + case "JSONObject": | |
| 450 | + return {}; | |
| 451 | + case "JSONString": | |
| 452 | + return "{}"; | |
| 453 | + default: | |
| 454 | + return {}; | |
| 455 | + } | |
| 456 | +}; | |
| 457 | + | |
| 458 | +const GRAPHQL_SCHEMA_REF_PREFIX = "#/$defs/graphql"; | |
| 459 | + | |
| 460 | +const graphqlScalarRef = (name: string): string => | |
| 461 | + `${GRAPHQL_SCHEMA_REF_PREFIX}/scalars/${name}`; | |
| 462 | + | |
| 463 | +const graphqlEnumRef = (name: string): string => | |
| 464 | + `${GRAPHQL_SCHEMA_REF_PREFIX}/enums/${name}`; | |
| 465 | + | |
| 466 | +const graphqlInputObjectRef = (name: string): string => | |
| 467 | + `${GRAPHQL_SCHEMA_REF_PREFIX}/input/${name}`; | |
| 468 | + | |
| 469 | +const graphqlOutputTypeRef = (name: string, depth: number): string => | |
| 470 | + `${GRAPHQL_SCHEMA_REF_PREFIX}/output/${depth === 0 ? name : `${name}__depth${depth}`}`; | |
| 471 | + | |
| 472 | +const graphqlTypenameOnlyOutputRef = (): string => | |
| 473 | + `${GRAPHQL_SCHEMA_REF_PREFIX}/output/GraphqlTypenameOnly`; | |
| 474 | + | |
| 475 | +const schemaRef = (ref: string): JsonSchema => ({ | |
| 476 | + $ref: ref, | |
| 477 | +}); | |
| 478 | + | |
| 479 | +const typenameOnlyObjectSchema = (): JsonSchema => ({ | |
| 480 | + type: "object", | |
| 481 | + properties: { | |
| 482 | + __typename: { type: "string" }, | |
| 483 | + }, | |
| 484 | + required: ["__typename"], | |
| 485 | + additionalProperties: false, | |
| 486 | +}); | |
| 487 | + | |
| 488 | +const createGraphqlSchemaRefTableBuilder = () => { | |
| 489 | + const refTable: GraphqlSchemaRefTable = {}; | |
| 490 | + const outputSelectionsByRef = new Map<string, SelectedGraphqlOutput>(); | |
| 491 | + | |
| 492 | + const defineRefSchema = (ref: string, schema: JsonSchema): void => { | |
| 493 | + refTable[ref] = JSON.stringify(schema); | |
| 494 | + }; | |
| 495 | + | |
| 496 | + const ensureTypenameOnlyOutputRef = (): JsonSchema => { | |
| 497 | + const ref = graphqlTypenameOnlyOutputRef(); | |
| 498 | + if (!(ref in refTable)) { | |
| 499 | + defineRefSchema(ref, typenameOnlyObjectSchema()); | |
| 500 | + } | |
| 501 | + | |
| 502 | + return schemaRef(ref); | |
| 503 | + }; | |
| 504 | + | |
| 505 | + const ensureScalarRef = (name: string): JsonSchema => { | |
| 506 | + const ref = graphqlScalarRef(name); | |
| 507 | + if (!(ref in refTable)) { | |
| 508 | + defineRefSchema(ref, scalarInputSchema(name)); | |
| 509 | + } | |
| 510 | + | |
| 511 | + return schemaRef(ref); | |
| 512 | + }; | |
| 513 | + | |
| 514 | + const ensureEnumRef = ( | |
| 515 | + name: string, | |
| 516 | + values: readonly string[], | |
| 517 | + ): JsonSchema => { | |
| 518 | + const ref = graphqlEnumRef(name); | |
| 519 | + if (!(ref in refTable)) { | |
| 520 | + defineRefSchema(ref, { | |
| 521 | + type: "string", | |
| 522 | + enum: [...values], | |
| 523 | + }); | |
| 524 | + } | |
| 525 | + | |
| 526 | + return schemaRef(ref); | |
| 527 | + }; | |
| 528 | + | |
| 529 | + const ensureInputObjectRef = ( | |
| 530 | + namedType: GraphQLInputObjectType, | |
| 531 | + ): JsonSchema => { | |
| 532 | + const ref = graphqlInputObjectRef(namedType.name); | |
| 533 | + if (!(ref in refTable)) { | |
| 534 | + defineRefSchema(ref, {}); | |
| 535 | + | |
| 536 | + const fields = Object.values(namedType.getFields()); | |
| 537 | + const properties = Object.fromEntries( | |
| 538 | + fields.map((field) => { | |
| 539 | + const schema = inputSchemaForType(field.type); | |
| 540 | + const enriched = withSchemaDeprecation( | |
| 541 | + withSchemaDefault( | |
| 542 | + withSchemaDescription(schema, field.description), | |
| 543 | + field.defaultValue, | |
| 544 | + ), | |
| 545 | + field.deprecationReason, | |
| 546 | + ); | |
| 547 | + return [field.name, enriched]; | |
| 548 | + }), | |
| 549 | + ); | |
| 550 | + const required = fields | |
| 551 | + .filter( | |
| 552 | + (field) => | |
| 553 | + isNonNullType(field.type) && field.defaultValue === undefined, | |
| 554 | + ) | |
| 555 | + .map((field) => field.name); | |
| 556 | + | |
| 557 | + defineRefSchema(ref, { | |
| 558 | + type: "object", | |
| 559 | + properties, | |
| 560 | + ...(required.length > 0 ? { required } : {}), | |
| 561 | + additionalProperties: false, | |
| 562 | + }); | |
| 563 | + } | |
| 564 | + | |
| 565 | + return schemaRef(ref); | |
| 566 | + }; | |
| 567 | + | |
| 568 | + const inputSchemaForType = (type: GraphQLInputType): JsonSchema => { | |
| 569 | + if (isNonNullType(type)) { | |
| 570 | + return inputSchemaForType(type.ofType); | |
| 571 | + } | |
| 572 | + | |
| 573 | + if (isListType(type)) { | |
| 574 | + return { | |
| 575 | + type: "array", | |
| 576 | + items: inputSchemaForType(type.ofType), | |
| 577 | + }; | |
| 578 | + } | |
| 579 | + | |
| 580 | + const namedType = getNamedType(type); | |
| 581 | + if (isScalarType(namedType)) { | |
| 582 | + return ensureScalarRef(namedType.name); | |
| 583 | + } | |
| 584 | + | |
| 585 | + if (isEnumType(namedType)) { | |
| 586 | + return ensureEnumRef( | |
| 587 | + namedType.name, | |
| 588 | + namedType.getValues().map((value) => value.name), | |
| 589 | + ); | |
| 590 | + } | |
| 591 | + | |
| 592 | + if (isInputObjectType(namedType)) { | |
| 593 | + return ensureInputObjectRef(namedType); | |
| 594 | + } | |
| 595 | + | |
| 596 | + return {}; | |
| 597 | + }; | |
| 598 | + | |
| 599 | + const selectedOutputForNamedType = ( | |
| 600 | + namedType: GraphQLNamedType, | |
| 601 | + depth: number, | |
| 602 | + ): SelectedGraphqlOutput => { | |
| 603 | + if (isScalarType(namedType)) { | |
| 604 | + return { | |
| 605 | + selectionSet: "", | |
| 606 | + schema: ensureScalarRef(namedType.name), | |
| 607 | + }; | |
| 608 | + } | |
| 609 | + | |
| 610 | + if (isEnumType(namedType)) { | |
| 611 | + return { | |
| 612 | + selectionSet: "", | |
| 613 | + schema: ensureEnumRef( | |
| 614 | + namedType.name, | |
| 615 | + namedType.getValues().map((value) => value.name), | |
| 616 | + ), | |
| 617 | + }; | |
| 618 | + } | |
| 619 | + | |
| 620 | + const ref = graphqlOutputTypeRef(namedType.name, depth); | |
| 621 | + const cached = outputSelectionsByRef.get(ref); | |
| 622 | + if (cached) { | |
| 623 | + return cached; | |
| 624 | + } | |
| 625 | + | |
| 626 | + if (isUnionType(namedType)) { | |
| 627 | + const result = { | |
| 628 | + selectionSet: "{ __typename }", | |
| 629 | + schema: ensureTypenameOnlyOutputRef(), | |
| 630 | + } satisfies SelectedGraphqlOutput; | |
| 631 | + outputSelectionsByRef.set(ref, result); | |
| 632 | + return result; | |
| 633 | + } | |
| 634 | + | |
| 635 | + if (!isObjectType(namedType) && !isInterfaceType(namedType)) { | |
| 636 | + return { | |
| 637 | + selectionSet: "{ __typename }", | |
| 638 | + schema: ensureTypenameOnlyOutputRef(), | |
| 639 | + }; | |
| 640 | + } | |
| 641 | + | |
| 642 | + const fallback = { | |
| 643 | + selectionSet: "{ __typename }", | |
| 644 | + schema: ensureTypenameOnlyOutputRef(), | |
| 645 | + } satisfies SelectedGraphqlOutput; | |
| 646 | + outputSelectionsByRef.set(ref, fallback); | |
| 647 | + | |
| 648 | + if (depth >= 2) { | |
| 649 | + return fallback; | |
| 650 | + } | |
| 651 | + | |
| 652 | + const fields = Object.values(namedType.getFields()).filter( | |
| 653 | + (field) => !field.name.startsWith("__"), | |
| 654 | + ); | |
| 655 | + const leafFields = fields.filter((field) => | |
| 656 | + isLeafType(getNamedType(field.type)), | |
| 657 | + ); | |
| 658 | + const nestedFields = fields.filter( | |
| 659 | + (field) => !isLeafType(getNamedType(field.type)), | |
| 660 | + ); | |
| 661 | + | |
| 662 | + const selectedLeafFields = pickPreferredFields({ | |
| 663 | + fields: leafFields, | |
| 664 | + preferredNames: PREFERRED_LEAF_FIELD_NAMES, | |
| 665 | + limit: 3, | |
| 666 | + }); | |
| 667 | + const selectedNestedFields = pickPreferredFields({ | |
| 668 | + fields: nestedFields, | |
| 669 | + preferredNames: PREFERRED_NESTED_FIELD_NAMES, | |
| 670 | + limit: 2, | |
| 671 | + }); | |
| 672 | + const selectedFields = dedupeFields([ | |
| 673 | + ...selectedLeafFields, | |
| 674 | + ...selectedNestedFields, | |
| 675 | + ]); | |
| 676 | + | |
| 677 | + if (selectedFields.length === 0) { | |
| 678 | + return fallback; | |
| 679 | + } | |
| 680 | + | |
| 681 | + const selectionParts: string[] = []; | |
| 682 | + const properties: Record<string, JsonSchema> = {}; | |
| 683 | + const required: string[] = []; | |
| 684 | + | |
| 685 | + for (const field of selectedFields) { | |
| 686 | + const selected = selectedOutputForType(field.type, depth + 1); | |
| 687 | + const propertySchema = withSchemaDeprecation( | |
| 688 | + withSchemaDescription(selected.schema, field.description), | |
| 689 | + field.deprecationReason, | |
| 690 | + ); | |
| 691 | + | |
| 692 | + properties[field.name] = propertySchema; | |
| 693 | + required.push(field.name); | |
| 694 | + selectionParts.push( | |
| 695 | + selected.selectionSet.length > 0 | |
| 696 | + ? `${field.name} ${selected.selectionSet}` | |
| 697 | + : field.name, | |
| 698 | + ); | |
| 699 | + } | |
| 700 | + | |
| 701 | + properties.__typename = { type: "string" }; | |
| 702 | + required.push("__typename"); | |
| 703 | + selectionParts.push("__typename"); | |
| 704 | + | |
| 705 | + defineRefSchema(ref, { | |
| 706 | + type: "object", | |
| 707 | + properties, | |
| 708 | + required, | |
| 709 | + additionalProperties: false, | |
| 710 | + }); | |
| 711 | + | |
| 712 | + const result = { | |
| 713 | + selectionSet: `{ ${selectionParts.join(" ")} }`, | |
| 714 | + schema: schemaRef(ref), | |
| 715 | + } satisfies SelectedGraphqlOutput; | |
| 716 | + outputSelectionsByRef.set(ref, result); | |
| 717 | + return result; | |
| 718 | + }; | |
| 719 | + | |
| 720 | + const selectedOutputForType = ( | |
| 721 | + type: GraphQLOutputType, | |
| 722 | + depth = 0, | |
| 723 | + ): SelectedGraphqlOutput => { | |
| 724 | + if (isNonNullType(type)) { | |
| 725 | + return selectedOutputForType(type.ofType, depth); | |
| 726 | + } | |
| 727 | + | |
| 728 | + if (isListType(type)) { | |
| 729 | + const selectedItem = selectedOutputForType(type.ofType, depth); | |
| 730 | + return { | |
| 731 | + selectionSet: selectedItem.selectionSet, | |
| 732 | + schema: { | |
| 733 | + type: "array", | |
| 734 | + items: selectedItem.schema, | |
| 735 | + }, | |
| 736 | + }; | |
| 737 | + } | |
| 738 | + | |
| 739 | + return selectedOutputForNamedType(getNamedType(type), depth); | |
| 740 | + }; | |
| 741 | + | |
| 742 | + return { | |
| 743 | + refTable, | |
| 744 | + inputSchemaForType, | |
| 745 | + selectedOutputForType, | |
| 746 | + }; | |
| 747 | +}; | |
| 748 | + | |
| 749 | +const exampleValueForGraphqlInputType = ( | |
| 750 | + type: GraphQLInputType, | |
| 751 | + depth = 0, | |
| 752 | +): unknown => { | |
| 753 | + if (isNonNullType(type)) { | |
| 754 | + return exampleValueForGraphqlInputType(type.ofType, depth); | |
| 755 | + } | |
| 756 | + | |
| 757 | + if (isListType(type)) { | |
| 758 | + return [exampleValueForGraphqlInputType(type.ofType, depth + 1)]; | |
| 759 | + } | |
| 760 | + | |
| 761 | + const namedType = getNamedType(type); | |
| 762 | + if (isScalarType(namedType)) { | |
| 763 | + return scalarExampleValue(namedType.name); | |
| 764 | + } | |
| 765 | + | |
| 766 | + if (isEnumType(namedType)) { | |
| 767 | + return namedType.getValues()[0]?.name ?? "VALUE"; | |
| 768 | + } | |
| 769 | + | |
| 770 | + if (isInputObjectType(namedType)) { | |
| 771 | + if (depth >= 2) { | |
| 772 | + return {}; | |
| 773 | + } | |
| 774 | + | |
| 775 | + const fields = Object.values(namedType.getFields()); | |
| 776 | + const requiredFields = fields.filter( | |
| 777 | + (field) => isNonNullType(field.type) && field.defaultValue === undefined, | |
| 778 | + ); | |
| 779 | + const fieldsForExample = | |
| 780 | + requiredFields.length > 0 ? requiredFields : fields.slice(0, 1); | |
| 781 | + | |
| 782 | + return Object.fromEntries( | |
| 783 | + fieldsForExample.map((field) => [ | |
| 784 | + field.name, | |
| 785 | + field.defaultValue ?? | |
| 786 | + exampleValueForGraphqlInputType(field.type, depth + 1), | |
| 787 | + ]), | |
| 788 | + ); | |
| 789 | + } | |
| 790 | + | |
| 791 | + return {}; | |
| 792 | +}; | |
| 793 | + | |
| 794 | +const dedupeFields = <T extends { name: string }>( | |
| 795 | + fields: readonly T[], | |
| 796 | +): T[] => { | |
| 797 | + const seen = new Set<string>(); | |
| 798 | + const deduped: T[] = []; | |
| 799 | + | |
| 800 | + for (const field of fields) { | |
| 801 | + if (seen.has(field.name)) { | |
| 802 | + continue; | |
| 803 | + } | |
| 804 | + | |
| 805 | + seen.add(field.name); | |
| 806 | + deduped.push(field); | |
| 807 | + } | |
| 808 | + | |
| 809 | + return deduped; | |
| 810 | +}; | |
| 811 | + | |
| 812 | +const pickPreferredFields = <T extends { name: string }>(input: { | |
| 813 | + fields: readonly T[]; | |
| 814 | + preferredNames: readonly string[]; | |
| 815 | + limit: number; | |
| 816 | +}): T[] => { | |
| 817 | + const matches = input.preferredNames | |
| 818 | + .map((name) => input.fields.find((field) => field.name === name)) | |
| 819 | + .filter((field): field is T => field !== undefined); | |
| 820 | + | |
| 821 | + if (matches.length >= input.limit) { | |
| 822 | + return dedupeFields(matches).slice(0, input.limit); | |
| 823 | + } | |
| 824 | + | |
| 825 | + return dedupeFields([...matches, ...input.fields]).slice(0, input.limit); | |
| 826 | +}; | |
| 827 | + | |
| 828 | +const printGraphqlType = (type: GraphQLInputType): string => { | |
| 829 | + if (isNonNullType(type)) { | |
| 830 | + return `${printGraphqlType(type.ofType)}!`; | |
| 831 | + } | |
| 832 | + | |
| 833 | + if (isListType(type)) { | |
| 834 | + return `[${printGraphqlType(type.ofType)}]`; | |
| 835 | + } | |
| 836 | + | |
| 837 | + return type.name; | |
| 838 | +}; | |
| 839 | + | |
| 840 | +const inputSchemaForFieldArguments = ( | |
| 841 | + args: readonly GraphQLArgument[], | |
| 842 | + bundleBuilder: ReturnType<typeof createGraphqlSchemaRefTableBuilder>, | |
| 843 | +): JsonSchema => { | |
| 844 | + const properties = Object.fromEntries( | |
| 845 | + args.map((arg) => { | |
| 846 | + const schema = bundleBuilder.inputSchemaForType(arg.type); | |
| 847 | + const enriched = withSchemaDeprecation( | |
| 848 | + withSchemaDefault( | |
| 849 | + withSchemaDescription(schema, arg.description), | |
| 850 | + arg.defaultValue, | |
| 851 | + ), | |
| 852 | + arg.deprecationReason, | |
| 853 | + ); | |
| 854 | + return [arg.name, enriched]; | |
| 855 | + }), | |
| 856 | + ); | |
| 857 | + const required = args | |
| 858 | + .filter((arg) => isNonNullType(arg.type) && arg.defaultValue === undefined) | |
| 859 | + .map((arg) => arg.name); | |
| 860 | + | |
| 861 | + return { | |
| 862 | + type: "object", | |
| 863 | + properties: { | |
| 864 | + ...properties, | |
| 865 | + headers: { | |
| 866 | + type: "object", | |
| 867 | + description: "Optional per-request headers.", | |
| 868 | + additionalProperties: { | |
| 869 | + type: "string", | |
| 870 | + }, | |
| 871 | + }, | |
| 872 | + }, | |
| 873 | + ...(required.length > 0 ? { required } : {}), | |
| 874 | + additionalProperties: false, | |
| 875 | + }; | |
| 876 | +}; | |
| 877 | + | |
| 878 | +const outputEnvelopeSchemaForField = ( | |
| 879 | + fieldType: GraphQLOutputType, | |
| 880 | + bundleBuilder: ReturnType<typeof createGraphqlSchemaRefTableBuilder>, | |
| 881 | +): JsonSchema => { | |
| 882 | + const selectedOutput = bundleBuilder.selectedOutputForType(fieldType); | |
| 883 | + | |
| 884 | + return { | |
| 885 | + type: "object", | |
| 886 | + properties: { | |
| 887 | + data: withSchemaDescription( | |
| 888 | + selectedOutput.schema, | |
| 889 | + "Value returned for the selected GraphQL field.", | |
| 890 | + ), | |
| 891 | + errors: { | |
| 892 | + type: "array", | |
| 893 | + items: { | |
| 894 | + type: "object", | |
| 895 | + properties: { | |
| 896 | + message: { | |
| 897 | + type: "string", | |
| 898 | + description: "GraphQL error message.", | |
| 899 | + }, | |
| 900 | + path: { | |
| 901 | + type: "array", | |
| 902 | + description: "Path to the field that produced the error.", | |
| 903 | + items: { | |
| 904 | + anyOf: [ | |
| 905 | + { type: "string" }, | |
| 906 | + { type: "number" }, | |
| 907 | + ], | |
| 908 | + }, | |
| 909 | + }, | |
| 910 | + locations: { | |
| 911 | + type: "array", | |
| 912 | + description: "Source locations for the error in the GraphQL document.", | |
| 913 | + items: { | |
| 914 | + type: "object", | |
| 915 | + properties: { | |
| 916 | + line: { type: "number" }, | |
| 917 | + column: { type: "number" }, | |
| 918 | + }, | |
| 919 | + required: ["line", "column"], | |
| 920 | + additionalProperties: false, | |
| 921 | + }, | |
| 922 | + }, | |
| 923 | + extensions: { | |
| 924 | + type: "object", | |
| 925 | + description: "Additional provider-specific GraphQL error metadata.", | |
| 926 | + additionalProperties: true, | |
| 927 | + }, | |
| 928 | + }, | |
| 929 | + required: ["message"], | |
| 930 | + additionalProperties: true, | |
| 931 | + }, | |
| 932 | + }, | |
| 933 | + }, | |
| 934 | + required: ["data", "errors"], | |
| 935 | + additionalProperties: false, | |
| 936 | + }; | |
| 937 | +}; | |
| 938 | + | |
| 939 | +const exampleInputForField = ( | |
| 940 | + args: readonly GraphQLArgument[], | |
| 941 | +): Record<string, unknown> | undefined => { | |
| 942 | + if (args.length === 0) { | |
| 943 | + return {}; | |
| 944 | + } | |
| 945 | + | |
| 946 | + const requiredArgs = args.filter( | |
| 947 | + (arg) => isNonNullType(arg.type) && arg.defaultValue === undefined, | |
| 948 | + ); | |
| 949 | + const argsForExample = | |
| 950 | + requiredArgs.length > 0 ? requiredArgs : args.slice(0, 1); | |
| 951 | + const example = Object.fromEntries( | |
| 952 | + argsForExample.map((arg) => [ | |
| 953 | + arg.name, | |
| 954 | + arg.defaultValue ?? exampleValueForGraphqlInputType(arg.type), | |
| 955 | + ]), | |
| 956 | + ); | |
| 957 | + | |
| 958 | + return example; | |
| 959 | +}; | |
| 960 | + | |
| 961 | +const buildGraphqlFieldOperationDocument = (input: { | |
| 962 | + operationType: GraphqlOperationType; | |
| 963 | + fieldName: string; | |
| 964 | + args: readonly GraphQLArgument[]; | |
| 965 | + fieldType: GraphQLOutputType; | |
| 966 | + bundleBuilder: ReturnType<typeof createGraphqlSchemaRefTableBuilder>; | |
| 967 | +}): { operationName: string; operationDocument: string } => { | |
| 968 | + const selectedOutput = input.bundleBuilder.selectedOutputForType( | |
| 969 | + input.fieldType, | |
| 970 | + ); | |
| 971 | + const operationName = `${toPascalCase(input.operationType)}${toPascalCase(input.fieldName)}`; | |
| 972 | + const variableDefinitions = input.args | |
| 973 | + .map((arg) => `$${arg.name}: ${printGraphqlType(arg.type)}`) | |
| 974 | + .join(", "); | |
| 975 | + const fieldArguments = input.args | |
| 976 | + .map((arg) => `${arg.name}: $${arg.name}`) | |
| 977 | + .join(", "); | |
| 978 | + const fieldCall = | |
| 979 | + fieldArguments.length > 0 | |
| 980 | + ? `${input.fieldName}(${fieldArguments})` | |
| 981 | + : input.fieldName; | |
| 982 | + const selectionSuffix = | |
| 983 | + selectedOutput.selectionSet.length > 0 | |
| 984 | + ? ` ${selectedOutput.selectionSet}` | |
| 985 | + : ""; | |
| 986 | + | |
| 987 | + return { | |
| 988 | + operationName, | |
| 989 | + operationDocument: `${input.operationType} ${operationName}${variableDefinitions.length > 0 ? `(${variableDefinitions})` : ""} { ${fieldCall}${selectionSuffix} }`, | |
| 990 | + }; | |
| 991 | +}; | |
| 992 | + | |
| 993 | +const defaultDescriptionForField = (input: { | |
| 994 | + operationType: GraphqlOperationType; | |
| 995 | + fieldName: string; | |
| 996 | + description: string | null | undefined; | |
| 997 | +}): string => { | |
| 998 | + const trimmed = input.description?.trim(); | |
| 999 | + if (trimmed) { | |
| 1000 | + return trimmed; | |
| 1001 | + } | |
| 1002 | + | |
| 1003 | + return `Execute the GraphQL ${input.operationType} field '${input.fieldName}'.`; | |
| 1004 | +}; | |
| 1005 | + | |
| 1006 | +const requestToolManifestEntry = ( | |
| 1007 | + sourceName: string, | |
| 1008 | +): GraphqlRequestToolManifestEntry => ({ | |
| 1009 | + kind: "request", | |
| 1010 | + toolId: "request", | |
| 1011 | + rawToolId: "request", | |
| 1012 | + toolName: "GraphQL request", | |
| 1013 | + description: `Execute a raw GraphQL request against ${sourceName}.`, | |
| 1014 | + inputSchema: GRAPHQL_REQUEST_INPUT_SCHEMA, | |
| 1015 | + outputSchema: GRAPHQL_REQUEST_OUTPUT_SCHEMA, | |
| 1016 | + exampleInput: { | |
| 1017 | + query: "query { __typename }", | |
| 1018 | + }, | |
| 1019 | +}); | |
| 1020 | + | |
| 1021 | +type GraphqlFieldToolDraft = Omit<GraphqlFieldToolManifestEntry, "toolId"> & { | |
| 1022 | + toolId: string; | |
| 1023 | +}; | |
| 1024 | + | |
| 1025 | +const resolveGraphqlFieldToolIds = ( | |
| 1026 | + drafts: readonly GraphqlFieldToolDraft[], | |
| 1027 | +): GraphqlFieldToolManifestEntry[] => { | |
| 1028 | + const staged = drafts.map((draft) => ({ ...draft })); | |
| 1029 | + const requestToolId = "request"; | |
| 1030 | + | |
| 1031 | + const applyDuplicates = ( | |
| 1032 | + factory: (draft: GraphqlFieldToolDraft) => string, | |
| 1033 | + ): void => { | |
| 1034 | + const buckets = new Map<string, GraphqlFieldToolDraft[]>(); | |
| 1035 | + | |
| 1036 | + for (const draft of staged) { | |
| 1037 | + const bucket = buckets.get(draft.toolId) ?? []; | |
| 1038 | + bucket.push(draft); | |
| 1039 | + buckets.set(draft.toolId, bucket); | |
| 1040 | + } | |
| 1041 | + | |
| 1042 | + for (const [toolId, bucket] of buckets.entries()) { | |
| 1043 | + if (bucket.length < 2 && toolId !== requestToolId) { | |
| 1044 | + continue; | |
| 1045 | + } | |
| 1046 | + | |
| 1047 | + for (const draft of bucket) { | |
| 1048 | + draft.toolId = factory(draft); | |
| 1049 | + } | |
| 1050 | + } | |
| 1051 | + }; | |
| 1052 | + | |
| 1053 | + applyDuplicates( | |
| 1054 | + (draft) => `${draft.leaf}${toPascalCase(draft.operationType)}`, | |
| 1055 | + ); | |
| 1056 | + applyDuplicates( | |
| 1057 | + (draft) => | |
| 1058 | + `${draft.leaf}${toPascalCase(draft.operationType)}${sha256Hex(`${draft.group}:${draft.fieldName}`).slice(0, 6)}`, | |
| 1059 | + ); | |
| 1060 | + | |
| 1061 | + return staged | |
| 1062 | + .sort( | |
| 1063 | + (left, right) => | |
| 1064 | + left.toolId.localeCompare(right.toolId) || | |
| 1065 | + left.fieldName.localeCompare(right.fieldName) || | |
| 1066 | + left.operationType.localeCompare(right.operationType), | |
| 1067 | + ) | |
| 1068 | + .map((draft) => ({ ...draft })); | |
| 1069 | +}; | |
| 1070 | + | |
| 1071 | +const fieldToolDraftsFromRootType = (input: { | |
| 1072 | + rootType: | |
| 1073 | + | ReturnType<GraphQLSchema["getQueryType"]> | |
| 1074 | + | ReturnType<GraphQLSchema["getMutationType"]>; | |
| 1075 | + operationType: GraphqlOperationType; | |
| 1076 | + bundleBuilder: ReturnType<typeof createGraphqlSchemaRefTableBuilder>; | |
| 1077 | +}): GraphqlFieldToolDraft[] => { | |
| 1078 | + if (!input.rootType) { | |
| 1079 | + return []; | |
| 1080 | + } | |
| 1081 | + | |
| 1082 | + return Object.values(input.rootType.getFields()) | |
| 1083 | + .filter((field) => !field.name.startsWith("__")) | |
| 1084 | + .map((field) => { | |
| 1085 | + const leaf = toCamelCase(field.name); | |
| 1086 | + const outputSchema = outputEnvelopeSchemaForField( | |
| 1087 | + field.type, | |
| 1088 | + input.bundleBuilder, | |
| 1089 | + ); | |
| 1090 | + const inputSchema = inputSchemaForFieldArguments( | |
| 1091 | + field.args, | |
| 1092 | + input.bundleBuilder, | |
| 1093 | + ); | |
| 1094 | + const { operationName, operationDocument } = | |
| 1095 | + buildGraphqlFieldOperationDocument({ | |
| 1096 | + operationType: input.operationType, | |
| 1097 | + fieldName: field.name, | |
| 1098 | + args: field.args, | |
| 1099 | + fieldType: field.type, | |
| 1100 | + bundleBuilder: input.bundleBuilder, | |
| 1101 | + }); | |
| 1102 | + | |
| 1103 | + return { | |
| 1104 | + kind: "field", | |
| 1105 | + toolId: leaf, | |
| 1106 | + rawToolId: field.name, | |
| 1107 | + toolName: toTitleCase(field.name), | |
| 1108 | + description: defaultDescriptionForField({ | |
| 1109 | + operationType: input.operationType, | |
| 1110 | + fieldName: field.name, | |
| 1111 | + description: field.description, | |
| 1112 | + }), | |
| 1113 | + group: input.operationType, | |
| 1114 | + leaf, | |
| 1115 | + fieldName: field.name, | |
| 1116 | + operationType: input.operationType, | |
| 1117 | + operationName, | |
| 1118 | + operationDocument, | |
| 1119 | + inputSchema, | |
| 1120 | + outputSchema, | |
| 1121 | + exampleInput: exampleInputForField(field.args), | |
| 1122 | + searchTerms: [ | |
| 1123 | + input.operationType, | |
| 1124 | + field.name, | |
| 1125 | + ...field.args.map((arg) => arg.name), | |
| 1126 | + getNamedType(field.type).name, | |
| 1127 | + ], | |
| 1128 | + } satisfies GraphqlFieldToolDraft; | |
| 1129 | + }); | |
| 1130 | +}; | |
| 1131 | + | |
| 1132 | +export const createGraphqlSourceHash = (documentText: string): string => | |
| 1133 | + sha256Hex(documentText); | |
| 1134 | + | |
| 1135 | +export const extractGraphqlManifest = ( | |
| 1136 | + sourceName: string, | |
| 1137 | + documentText: string, | |
| 1138 | +): Effect.Effect<GraphqlToolManifest, Error, never> => | |
| 1139 | + Effect.try({ | |
| 1140 | + try: () => { | |
| 1141 | + const introspection = introspectionQueryFromDocument(documentText); | |
| 1142 | + const schema = buildClientSchema(introspection); | |
| 1143 | + const sourceHash = createGraphqlSourceHash(documentText); | |
| 1144 | + const bundleBuilder = createGraphqlSchemaRefTableBuilder(); | |
| 1145 | + const fieldTools = resolveGraphqlFieldToolIds([ | |
| 1146 | + ...fieldToolDraftsFromRootType({ | |
| 1147 | + rootType: schema.getQueryType(), | |
| 1148 | + operationType: "query", | |
| 1149 | + bundleBuilder, | |
| 1150 | + }), | |
| 1151 | + ...fieldToolDraftsFromRootType({ | |
| 1152 | + rootType: schema.getMutationType(), | |
| 1153 | + operationType: "mutation", | |
| 1154 | + bundleBuilder, | |
| 1155 | + }), | |
| 1156 | + ]); | |
| 1157 | + | |
| 1158 | + return { | |
| 1159 | + version: 2, | |
| 1160 | + sourceHash, | |
| 1161 | + queryTypeName: schema.getQueryType()?.name ?? null, | |
| 1162 | + mutationTypeName: schema.getMutationType()?.name ?? null, | |
| 1163 | + subscriptionTypeName: schema.getSubscriptionType()?.name ?? null, | |
| 1164 | + ...(Object.keys(bundleBuilder.refTable).length > 0 | |
| 1165 | + ? { schemaRefTable: bundleBuilder.refTable } | |
| 1166 | + : {}), | |
| 1167 | + tools: [...fieldTools, requestToolManifestEntry(sourceName)], | |
| 1168 | + } satisfies GraphqlToolManifest; | |
| 1169 | + }, | |
| 1170 | + catch: (cause) => | |
| 1171 | + cause instanceof Error ? cause : new Error(String(cause)), | |
| 1172 | + }); | |
| 1173 | + | |
| 1174 | +export const compileGraphqlToolDefinitions = ( | |
| 1175 | + manifest: GraphqlToolManifest, | |
| 1176 | +): Array<GraphqlToolDefinition> => | |
| 1177 | + manifest.tools.map((tool) => ({ | |
| 1178 | + toolId: tool.toolId, | |
| 1179 | + rawToolId: tool.rawToolId, | |
| 1180 | + name: tool.toolName, | |
| 1181 | + description: tool.description ?? `Execute ${tool.toolName}.`, | |
| 1182 | + group: tool.kind === "field" ? tool.group : null, | |
| 1183 | + leaf: tool.kind === "field" ? tool.leaf : null, | |
| 1184 | + fieldName: tool.kind === "field" ? tool.fieldName : null, | |
| 1185 | + operationType: tool.kind === "field" ? tool.operationType : null, | |
| 1186 | + operationName: tool.kind === "field" ? tool.operationName : null, | |
| 1187 | + operationDocument: tool.kind === "field" ? tool.operationDocument : null, | |
| 1188 | + searchTerms: | |
| 1189 | + tool.kind === "field" | |
| 1190 | + ? tool.searchTerms | |
| 1191 | + : ["request", "graphql", "query", "mutation"], | |
| 1192 | + })); | |
| 1193 | + | |
| 1194 | +const materializedSchemaRefDefinitions = (refTable?: GraphqlSchemaRefTable): JsonSchema | undefined => { | |
| 1195 | + if (!refTable || Object.keys(refTable).length === 0) { | |
| 1196 | + return undefined; | |
| 1197 | + } | |
| 1198 | + | |
| 1199 | + const defsRoot: JsonSchema = {}; | |
| 1200 | + for (const [ref, value] of Object.entries(refTable)) { | |
| 1201 | + if (!ref.startsWith("#/$defs/")) { | |
| 1202 | + continue; | |
| 1203 | + } | |
| 1204 | + | |
| 1205 | + const materializedValue = typeof value === "string" | |
| 1206 | + ? (() => { | |
| 1207 | + try { | |
| 1208 | + return JSON.parse(value) as unknown; | |
| 1209 | + } catch { | |
| 1210 | + return value; | |
| 1211 | + } | |
| 1212 | + })() | |
| 1213 | + : value; | |
| 1214 | + const path = ref | |
| 1215 | + .slice("#/$defs/".length) | |
| 1216 | + .split("/") | |
| 1217 | + .filter((segment) => segment.length > 0); | |
| 1218 | + setNestedSchemaProperty(defsRoot, path, materializedValue); | |
| 1219 | + } | |
| 1220 | + | |
| 1221 | + return Object.keys(defsRoot).length > 0 ? defsRoot : undefined; | |
| 1222 | +}; | |
| 1223 | + | |
| 1224 | +const materializeManifestSchema = (input: { | |
| 1225 | + schema: JsonSchema | undefined; | |
| 1226 | + defsRoot: JsonSchema | undefined; | |
| 1227 | + cache: WeakMap<object, JsonSchema>; | |
| 1228 | +}): JsonSchema | undefined => { | |
| 1229 | + if (input.schema === undefined) { | |
| 1230 | + return undefined; | |
| 1231 | + } | |
| 1232 | + | |
| 1233 | + if (input.defsRoot === undefined) { | |
| 1234 | + return input.schema; | |
| 1235 | + } | |
| 1236 | + | |
| 1237 | + const cached = input.cache.get(input.schema); | |
| 1238 | + if (cached) { | |
| 1239 | + return cached; | |
| 1240 | + } | |
| 1241 | + | |
| 1242 | + const existingDefs = input.schema.$defs; | |
| 1243 | + const materialized = existingDefs && typeof existingDefs === "object" && !Array.isArray(existingDefs) | |
| 1244 | + ? { ...input.schema, $defs: { ...input.defsRoot, ...existingDefs } } | |
| 1245 | + : { ...input.schema, $defs: input.defsRoot }; | |
| 1246 | + | |
| 1247 | + input.cache.set(input.schema, materialized); | |
| 1248 | + return materialized; | |
| 1249 | +}; | |
| 1250 | + | |
| 1251 | +const graphqlToolPresentationResolverCache = new WeakMap<GraphqlToolManifest, GraphqlToolPresentationResolver>(); | |
| 1252 | + | |
| 1253 | +const graphqlToolPresentationResolver = ( | |
| 1254 | + manifest: GraphqlToolManifest, | |
| 1255 | +): GraphqlToolPresentationResolver => { | |
| 1256 | + const cached = graphqlToolPresentationResolverCache.get(manifest); | |
| 1257 | + if (cached) { | |
| 1258 | + return cached; | |
| 1259 | + } | |
| 1260 | + | |
| 1261 | + const toolEntriesById = HashMap.fromIterable( | |
| 1262 | + manifest.tools.map((tool) => [tool.toolId, tool] as const), | |
| 1263 | + ); | |
| 1264 | + const defsRoot = materializedSchemaRefDefinitions(manifest.schemaRefTable); | |
| 1265 | + const schemaCache = new WeakMap<object, JsonSchema>(); | |
| 1266 | + const presentationCache = new Map<string, GraphqlToolPresentation>(); | |
| 1267 | + | |
| 1268 | + const resolver: GraphqlToolPresentationResolver = { | |
| 1269 | + resolve(definition) { | |
| 1270 | + const existing = presentationCache.get(definition.toolId); | |
| 1271 | + if (existing) { | |
| 1272 | + return existing; | |
| 1273 | + } | |
| 1274 | + | |
| 1275 | + const entry = Option.getOrUndefined(HashMap.get(toolEntriesById, definition.toolId)); | |
| 1276 | + const inputSchema = materializeManifestSchema({ | |
| 1277 | + schema: entry?.inputSchema, | |
| 1278 | + defsRoot, | |
| 1279 | + cache: schemaCache, | |
| 1280 | + }); | |
| 1281 | + const outputSchema = materializeManifestSchema({ | |
| 1282 | + schema: entry?.outputSchema, | |
| 1283 | + defsRoot, | |
| 1284 | + cache: schemaCache, | |
| 1285 | + }); | |
| 1286 | + | |
| 1287 | + const presentation = { | |
| 1288 | + inputTypePreview: typeSignatureFromSchema( | |
| 1289 | + inputSchema, | |
| 1290 | + "unknown", | |
| 1291 | + GRAPHQL_PRESENTATION_TYPE_MAX_LENGTH, | |
| 1292 | + ), | |
| 1293 | + outputTypePreview: typeSignatureFromSchema( | |
| 1294 | + outputSchema, | |
| 1295 | + "unknown", | |
| 1296 | + GRAPHQL_PRESENTATION_TYPE_MAX_LENGTH, | |
| 1297 | + ), | |
| 1298 | + ...(inputSchema !== undefined ? { inputSchema } : {}), | |
| 1299 | + ...(outputSchema !== undefined ? { outputSchema } : {}), | |
| 1300 | + ...(entry?.exampleInput !== undefined | |
| 1301 | + ? { exampleInput: entry.exampleInput } | |
| 1302 | + : {}), | |
| 1303 | + providerData: { | |
| 1304 | + kind: "graphql", | |
| 1305 | + toolKind: entry?.kind ?? "request", | |
| 1306 | + toolId: definition.toolId, | |
| 1307 | + rawToolId: definition.rawToolId, | |
| 1308 | + group: definition.group, | |
| 1309 | + leaf: definition.leaf, | |
| 1310 | + fieldName: definition.fieldName, | |
| 1311 | + operationType: definition.operationType, | |
| 1312 | + operationName: definition.operationName, | |
| 1313 | + operationDocument: definition.operationDocument, | |
| 1314 | + queryTypeName: manifest.queryTypeName, | |
| 1315 | + mutationTypeName: manifest.mutationTypeName, | |
| 1316 | + subscriptionTypeName: manifest.subscriptionTypeName, | |
| 1317 | + } satisfies GraphqlToolProviderData, | |
| 1318 | + } satisfies GraphqlToolPresentation; | |
| 1319 | + | |
| 1320 | + presentationCache.set(definition.toolId, presentation); | |
| 1321 | + return presentation; | |
| 1322 | + }, | |
| 1323 | + }; | |
| 1324 | + | |
| 1325 | + graphqlToolPresentationResolverCache.set(manifest, resolver); | |
| 1326 | + return resolver; | |
| 1327 | +}; | |
| 1328 | + | |
| 1329 | +export const buildGraphqlToolPresentation = (input: { | |
| 1330 | + manifest: GraphqlToolManifest; | |
| 1331 | + definition: GraphqlToolDefinition; | |
| 1332 | +}): GraphqlToolPresentation => { | |
| 1333 | + return graphqlToolPresentationResolver(input.manifest).resolve(input.definition); | |
| 1334 | +}; | |
| 1335 | + | |
| 1336 | +const decodeGraphqlToolProviderData = Schema.decodeUnknownEither( | |
| 1337 | + GraphqlToolProviderDataSchema, | |
| 1338 | +); | |
| 1339 | + | |
| 1340 | +export const decodeGraphqlSchemaRefTableJson = Schema.decodeUnknownEither( | |
| 1341 | + Schema.parseJson( | |
| 1342 | + Schema.Record({ | |
| 1343 | + key: Schema.String, | |
| 1344 | + value: Schema.Unknown, | |
| 1345 | + }), | |
| 1346 | + ), | |
| 1347 | +); | |
| 1348 | + | |
| 1349 | +const setNestedSchemaProperty = ( | |
| 1350 | + target: Record<string, unknown>, | |
| 1351 | + path: readonly string[], | |
| 1352 | + value: unknown, | |
| 1353 | +): void => { | |
| 1354 | + if (path.length === 0) { | |
| 1355 | + return; | |
| 1356 | + } | |
| 1357 | + | |
| 1358 | + const [head, ...rest] = path; | |
| 1359 | + if (!head) { | |
| 1360 | + return; | |
| 1361 | + } | |
| 1362 | + | |
| 1363 | + if (rest.length === 0) { | |
| 1364 | + target[head] = value; | |
| 1365 | + return; | |
| 1366 | + } | |
| 1367 | + | |
| 1368 | + const next = asRecord(target[head]); | |
| 1369 | + target[head] = next; | |
| 1370 | + setNestedSchemaProperty(next, rest, value); | |
| 1371 | +}; | |
| 1372 | + | |
| 1373 | +const materializeSchemaWithRefDefinitions = (input: { | |
| 1374 | + schema: unknown; | |
| 1375 | + refTable?: Readonly<Record<string, unknown>>; | |
| 1376 | +}): Record<string, unknown> => { | |
| 1377 | + if (input.schema === undefined || input.schema === null) { | |
| 1378 | + return {}; | |
| 1379 | + } | |
| 1380 | + | |
| 1381 | + const rootSchema = asRecord(input.schema); | |
| 1382 | + | |
| 1383 | + if (!input.refTable || Object.keys(input.refTable).length === 0) { | |
| 1384 | + return rootSchema; | |
| 1385 | + } | |
| 1386 | + | |
| 1387 | + const defsRoot = asRecord(rootSchema.$defs); | |
| 1388 | + for (const [ref, value] of Object.entries(input.refTable)) { | |
| 1389 | + if (!ref.startsWith("#/$defs/")) { | |
| 1390 | + continue; | |
| 1391 | + } | |
| 1392 | + | |
| 1393 | + const materializedValue = | |
| 1394 | + typeof value === "string" | |
| 1395 | + ? (() => { | |
| 1396 | + try { | |
| 1397 | + return JSON.parse(value) as unknown; | |
| 1398 | + } catch { | |
| 1399 | + return value; | |
| 1400 | + } | |
| 1401 | + })() | |
| 1402 | + : value; | |
| 1403 | + const path = ref | |
| 1404 | + .slice("#/$defs/".length) | |
| 1405 | + .split("/") | |
| 1406 | + .filter((segment) => segment.length > 0); | |
| 1407 | + setNestedSchemaProperty(defsRoot, path, materializedValue); | |
| 1408 | + } | |
| 1409 | + | |
| 1410 | + return Object.keys(defsRoot).length > 0 | |
| 1411 | + ? { ...rootSchema, $defs: defsRoot } | |
| 1412 | + : rootSchema; | |
| 1413 | +}; | |
| 1414 | + | |
| 1415 | +export const createGraphqlToolFromPersistedOperation = (input: { | |
| 1416 | + path: string; | |
| 1417 | + sourceKey: string; | |
| 1418 | + endpoint: string; | |
| 1419 | + description?: string; | |
| 1420 | + inputSchema?: unknown; | |
| 1421 | + outputSchema?: unknown; | |
| 1422 | + exampleInput?: unknown; | |
| 1423 | + providerData: unknown; | |
| 1424 | + schemaRefTable?: Readonly<Record<string, unknown>>; | |
| 1425 | + defaultHeaders?: Readonly<Record<string, string>>; | |
| 1426 | + credentialHeaders?: Readonly<Record<string, string>>; | |
| 1427 | + credentialPlacements?: HttpRequestPlacements; | |
| 1428 | +}) => { | |
| 1429 | + const decodedProviderData = decodeGraphqlToolProviderData( | |
| 1430 | + input.providerData, | |
| 1431 | + ); | |
| 1432 | + if (Either.isLeft(decodedProviderData)) { | |
| 1433 | + throw new Error("Invalid GraphQL provider data"); | |
| 1434 | + } | |
| 1435 | + | |
| 1436 | + const providerData = decodedProviderData.right; | |
| 1437 | + const inputSchema = materializeSchemaWithRefDefinitions({ | |
| 1438 | + schema: input.inputSchema, | |
| 1439 | + refTable: input.schemaRefTable, | |
| 1440 | + }); | |
| 1441 | + | |
| 1442 | + const metadata: ToolMetadata = { | |
| 1443 | + interaction: | |
| 1444 | + providerData.toolKind === "request" | |
| 1445 | + ? "auto" | |
| 1446 | + : providerData.operationType === "query" | |
| 1447 | + ? "auto" | |
| 1448 | + : "required", | |
| 1449 | + contract: { | |
| 1450 | + inputTypePreview: typeSignatureFromSchema( | |
| 1451 | + input.inputSchema, | |
| 1452 | + "unknown", | |
| 1453 | + GRAPHQL_PRESENTATION_TYPE_MAX_LENGTH, | |
| 1454 | + ), | |
| 1455 | + outputTypePreview: typeSignatureFromSchema( | |
| 1456 | + input.outputSchema, | |
| 1457 | + "unknown", | |
| 1458 | + GRAPHQL_PRESENTATION_TYPE_MAX_LENGTH, | |
| 1459 | + ), | |
| 1460 | + ...(input.inputSchema !== undefined | |
| 1461 | + ? { inputSchema: input.inputSchema } | |
| 1462 | + : {}), | |
| 1463 | + ...(input.outputSchema !== undefined | |
| 1464 | + ? { outputSchema: input.outputSchema } | |
| 1465 | + : {}), | |
| 1466 | + ...(input.exampleInput !== undefined | |
| 1467 | + ? { exampleInput: input.exampleInput } | |
| 1468 | + : {}), | |
| 1469 | + }, | |
| 1470 | + sourceKey: input.sourceKey, | |
| 1471 | + pluginKind: "graphql", | |
| 1472 | + pluginData: providerData, | |
| 1473 | + }; | |
| 1474 | + | |
| 1475 | + return toTool({ | |
| 1476 | + tool: { | |
| 1477 | + description: input.description, | |
| 1478 | + inputSchema: standardSchemaFromJsonSchema(inputSchema), | |
| 1479 | + execute: (args: unknown) => { | |
| 1480 | + if ( | |
| 1481 | + providerData.toolKind === "field" && | |
| 1482 | + providerData.fieldName && | |
| 1483 | + providerData.operationType && | |
| 1484 | + providerData.operationName && | |
| 1485 | + providerData.operationDocument | |
| 1486 | + ) { | |
| 1487 | + return Effect.runPromise( | |
| 1488 | + invokeGraphqlFieldTool({ | |
| 1489 | + entry: { | |
| 1490 | + fieldName: providerData.fieldName, | |
| 1491 | + operationName: providerData.operationName, | |
| 1492 | + operationDocument: providerData.operationDocument, | |
| 1493 | + }, | |
| 1494 | + endpoint: input.endpoint, | |
| 1495 | + path: input.path, | |
| 1496 | + defaultHeaders: input.defaultHeaders, | |
| 1497 | + credentialHeaders: input.credentialHeaders, | |
| 1498 | + credentialPlacements: input.credentialPlacements, | |
| 1499 | + args, | |
| 1500 | + }), | |
| 1501 | + ); | |
| 1502 | + } | |
| 1503 | + | |
| 1504 | + return Effect.runPromise( | |
| 1505 | + invokeRawGraphqlTool({ | |
| 1506 | + endpoint: input.endpoint, | |
| 1507 | + path: input.path, | |
| 1508 | + defaultHeaders: input.defaultHeaders, | |
| 1509 | + credentialHeaders: input.credentialHeaders, | |
| 1510 | + credentialPlacements: input.credentialPlacements, | |
| 1511 | + args, | |
| 1512 | + }), | |
| 1513 | + ); | |
| 1514 | + }, | |
| 1515 | + }, | |
| 1516 | + metadata, | |
| 1517 | + }); | |
| 1518 | +}; | |
| 1519 | + | |
| 1520 | +export const graphqlToolDescriptorFromDefinition = (input: { | |
| 1521 | + manifest: GraphqlToolManifest; | |
| 1522 | + definition: GraphqlToolDefinition; | |
| 1523 | + path: string; | |
| 1524 | + sourceKey: string; | |
| 1525 | + includeSchemas: boolean; | |
| 1526 | +}): ToolDescriptor => { | |
| 1527 | + const presentation = buildGraphqlToolPresentation({ | |
| 1528 | + manifest: input.manifest, | |
| 1529 | + definition: input.definition, | |
| 1530 | + }); | |
| 1531 | + | |
| 1532 | + return { | |
| 1533 | + path: asToolPath(input.path), | |
| 1534 | + sourceKey: input.sourceKey, | |
| 1535 | + description: input.definition.description, | |
| 1536 | + interaction: | |
| 1537 | + input.definition.operationType === "query" ? "auto" : "required", | |
| 1538 | + contract: { | |
| 1539 | + inputTypePreview: presentation.inputTypePreview, | |
| 1540 | + outputTypePreview: presentation.outputTypePreview, | |
| 1541 | + ...(input.includeSchemas && presentation.inputSchema !== undefined | |
| 1542 | + ? { inputSchema: presentation.inputSchema } | |
| 1543 | + : {}), | |
| 1544 | + ...(input.includeSchemas && presentation.outputSchema !== undefined | |
| 1545 | + ? { outputSchema: presentation.outputSchema } | |
| 1546 | + : {}), | |
| 1547 | + ...(presentation.exampleInput !== undefined | |
| 1548 | + ? { exampleInput: presentation.exampleInput } | |
| 1549 | + : {}), | |
| 1550 | + }, | |
| 1551 | + pluginKind: "graphql", | |
| 1552 | + pluginData: presentation.providerData, | |
| 1553 | + }; | |
| 1554 | +}; | |
| 1555 | + | |
| 1556 | +const invokeGraphqlHttpRequest = (input: GraphqlHttpInvocation) => | |
| 1557 | + Effect.gen(function* () { | |
| 1558 | + const endpoint = applyHttpQueryPlacementsToUrl({ | |
| 1559 | + url: input.endpoint, | |
| 1560 | + queryParams: input.credentialPlacements?.queryParams, | |
| 1561 | + }).toString(); | |
| 1562 | + const requestBody = applyJsonBodyPlacements({ | |
| 1563 | + body: { | |
| 1564 | + query: input.query, | |
| 1565 | + ...(input.variables !== undefined | |
| 1566 | + ? { variables: input.variables } | |
| 1567 | + : {}), | |
| 1568 | + ...(input.operationName | |
| 1569 | + ? { operationName: input.operationName } | |
| 1570 | + : {}), | |
| 1571 | + }, | |
| 1572 | + bodyValues: input.credentialPlacements?.bodyValues, | |
| 1573 | + label: `GraphQL ${input.path}`, | |
| 1574 | + }); | |
| 1575 | + const headers = applyCookiePlacementsToHeaders({ | |
| 1576 | + headers: { | |
| 1577 | + "content-type": "application/json", | |
| 1578 | + ...input.defaultHeaders, | |
| 1579 | + ...input.requestHeaders, | |
| 1580 | + ...input.credentialHeaders, | |
| 1581 | + ...input.credentialPlacements?.headers, | |
| 1582 | + }, | |
| 1583 | + cookies: input.credentialPlacements?.cookies, | |
| 1584 | + }); | |
| 1585 | + | |
| 1586 | + const response = yield* Effect.tryPromise({ | |
| 1587 | + try: () => | |
| 1588 | + fetch(endpoint, { | |
| 1589 | + method: "POST", | |
| 1590 | + headers: new Headers(headers), | |
| 1591 | + body: JSON.stringify(requestBody), | |
| 1592 | + }), | |
| 1593 | + catch: (cause) => | |
| 1594 | + graphqlToolError(`GraphQL request failed for ${input.path}`, cause), | |
| 1595 | + }); | |
| 1596 | + const body = yield* Effect.tryPromise({ | |
| 1597 | + try: () => parseGraphqlResponseBody(response), | |
| 1598 | + catch: (cause) => | |
| 1599 | + graphqlToolError( | |
| 1600 | + `Failed decoding GraphQL response for ${input.path}`, | |
| 1601 | + cause, | |
| 1602 | + ), | |
| 1603 | + }); | |
| 1604 | + | |
| 1605 | + const responseHeaders: Record<string, string> = {}; | |
| 1606 | + response.headers.forEach((value, key) => { | |
| 1607 | + responseHeaders[key] = value; | |
| 1608 | + }); | |
| 1609 | + | |
| 1610 | + return { | |
| 1611 | + status: response.status, | |
| 1612 | + headers: sanitizeResponseHeaders(responseHeaders), | |
| 1613 | + body, | |
| 1614 | + isError: response.status >= 400 || hasGraphqlErrors(body), | |
| 1615 | + }; | |
| 1616 | + }); | |
| 1617 | + | |
| 1618 | +const invokeRawGraphqlTool = (input: { | |
| 1619 | + endpoint: string; | |
| 1620 | + path: string; | |
| 1621 | + defaultHeaders?: Readonly<Record<string, string>>; | |
| 1622 | + credentialHeaders?: Readonly<Record<string, string>>; | |
| 1623 | + credentialPlacements?: HttpRequestPlacements; | |
| 1624 | + args: unknown; | |
| 1625 | +}) => | |
| 1626 | + Effect.gen(function* () { | |
| 1627 | + const record = asRecord(input.args); | |
| 1628 | + const query = asString(record.query); | |
| 1629 | + if (query === null) { | |
| 1630 | + return yield* sourceCoreEffectError("graphql/graphql-tools", `GraphQL query must be a non-empty string for ${input.path}`); | |
| 1631 | + } | |
| 1632 | + | |
| 1633 | + return yield* invokeGraphqlHttpRequest({ | |
| 1634 | + endpoint: input.endpoint, | |
| 1635 | + path: input.path, | |
| 1636 | + defaultHeaders: input.defaultHeaders, | |
| 1637 | + credentialHeaders: input.credentialHeaders, | |
| 1638 | + credentialPlacements: input.credentialPlacements, | |
| 1639 | + query, | |
| 1640 | + variables: | |
| 1641 | + record.variables !== undefined ? asRecord(record.variables) : undefined, | |
| 1642 | + operationName: asString(record.operationName) ?? undefined, | |
| 1643 | + requestHeaders: asStringRecord(record.headers), | |
| 1644 | + }); | |
| 1645 | + }); | |
| 1646 | + | |
| 1647 | +const withoutUndefinedEntries = ( | |
| 1648 | + record: Readonly<Record<string, unknown>>, | |
| 1649 | +): Record<string, unknown> => | |
| 1650 | + Object.fromEntries( | |
| 1651 | + Object.entries(record).filter(([, value]) => value !== undefined), | |
| 1652 | + ); | |
| 1653 | + | |
| 1654 | +const invokeGraphqlFieldTool = (input: { | |
| 1655 | + entry: Pick< | |
| 1656 | + GraphqlFieldToolManifestEntry, | |
| 1657 | + "fieldName" | "operationName" | "operationDocument" | |
| 1658 | + >; | |
| 1659 | + endpoint: string; | |
| 1660 | + path: string; | |
| 1661 | + defaultHeaders?: Readonly<Record<string, string>>; | |
| 1662 | + credentialHeaders?: Readonly<Record<string, string>>; | |
| 1663 | + credentialPlacements?: HttpRequestPlacements; | |
| 1664 | + args: unknown; | |
| 1665 | +}) => | |
| 1666 | + Effect.gen(function* () { | |
| 1667 | + const record = asRecord(input.args); | |
| 1668 | + const requestHeaders = asStringRecord(record.headers); | |
| 1669 | + const variables = withoutUndefinedEntries( | |
| 1670 | + Object.fromEntries( | |
| 1671 | + Object.entries(record).filter(([key]) => key !== "headers"), | |
| 1672 | + ), | |
| 1673 | + ); | |
| 1674 | + const response = yield* invokeGraphqlHttpRequest({ | |
| 1675 | + endpoint: input.endpoint, | |
| 1676 | + path: input.path, | |
| 1677 | + defaultHeaders: input.defaultHeaders, | |
| 1678 | + credentialHeaders: input.credentialHeaders, | |
| 1679 | + credentialPlacements: input.credentialPlacements, | |
| 1680 | + query: input.entry.operationDocument, | |
| 1681 | + variables, | |
| 1682 | + operationName: input.entry.operationName, | |
| 1683 | + requestHeaders, | |
| 1684 | + }); | |
| 1685 | + const bodyRecord = asRecord(response.body); | |
| 1686 | + const dataRecord = asRecord(bodyRecord.data); | |
| 1687 | + const errors = Array.isArray(bodyRecord.errors) ? bodyRecord.errors : []; | |
| 1688 | + | |
| 1689 | + return { | |
| 1690 | + data: dataRecord[input.entry.fieldName] ?? null, | |
| 1691 | + errors, | |
| 1692 | + isError: response.status >= 400 || errors.length > 0, | |
| 1693 | + }; | |
| 1694 | + }); | |
| 1695 | + | |
| 1696 | +export const createGraphqlToolsFromManifest = (input: { | |
| 1697 | + manifest: GraphqlToolManifest; | |
| 1698 | + endpoint: string; | |
| 1699 | + namespace: string; | |
| 1700 | + sourceKey: string; | |
| 1701 | + defaultHeaders?: Readonly<Record<string, string>>; | |
| 1702 | + credentialHeaders?: Readonly<Record<string, string>>; | |
| 1703 | + credentialPlacements?: HttpRequestPlacements; | |
| 1704 | +}): ToolMap => { | |
| 1705 | + const endpoint = normalizeHttpUrl(input.endpoint); | |
| 1706 | + const definitions = compileGraphqlToolDefinitions(input.manifest); | |
| 1707 | + | |
| 1708 | + return Object.fromEntries( | |
| 1709 | + definitions.map((definition) => { | |
| 1710 | + const presentation = buildGraphqlToolPresentation({ | |
| 1711 | + manifest: input.manifest, | |
| 1712 | + definition, | |
| 1713 | + }); | |
| 1714 | + const path = input.namespace | |
| 1715 | + ? `${input.namespace}.${definition.toolId}` | |
| 1716 | + : definition.toolId; | |
| 1717 | + | |
| 1718 | + return [ | |
| 1719 | + path, | |
| 1720 | + createGraphqlToolFromPersistedOperation({ | |
| 1721 | + path, | |
| 1722 | + sourceKey: input.sourceKey, | |
| 1723 | + endpoint, | |
| 1724 | + description: definition.description, | |
| 1725 | + inputSchema: presentation.inputSchema, | |
| 1726 | + outputSchema: presentation.outputSchema, | |
| 1727 | + exampleInput: presentation.exampleInput, | |
| 1728 | + providerData: presentation.providerData, | |
| 1729 | + schemaRefTable: input.manifest.schemaRefTable, | |
| 1730 | + defaultHeaders: input.defaultHeaders, | |
| 1731 | + credentialHeaders: input.credentialHeaders, | |
| 1732 | + credentialPlacements: input.credentialPlacements, | |
| 1733 | + }), | |
| 1734 | + ] as const; | |
| 1735 | + }), | |
| 1736 | + ); | |
| 1737 | +}; | |
| @@ -0,0 +1,567 @@ | ||
| 1 | +import { | |
| 2 | + applyCookiePlacementsToHeaders, | |
| 3 | + applyHttpQueryPlacementsToUrl, | |
| 4 | + applyJsonBodyPlacements, | |
| 5 | +} from "@executor/codemode-core"; | |
| 6 | +import * as Effect from "effect/Effect"; | |
| 7 | +import * as Schema from "effect/Schema"; | |
| 8 | + | |
| 9 | +import { | |
| 10 | + createCatalogImportMetadata, | |
| 11 | + createSourceCatalogSyncResult, | |
| 12 | + SourceCredentialRequiredError, | |
| 13 | +} from "@executor/source-core"; | |
| 14 | +import type { Source } from "@executor/platform-sdk/schema"; | |
| 15 | +import type { | |
| 16 | + ExecutorSdkPlugin, | |
| 17 | + ExecutorSdkPluginHost, | |
| 18 | + ExecutorSourceConnector, | |
| 19 | + SourcePluginRuntime, | |
| 20 | +} from "@executor/platform-sdk/plugins"; | |
| 21 | +import { | |
| 22 | + SecretMaterialResolverService, | |
| 23 | + provideExecutorRuntime, | |
| 24 | +} from "@executor/platform-sdk/runtime"; | |
| 25 | +import { | |
| 26 | + deriveGraphqlNamespace, | |
| 27 | + GraphqlConnectionAuthSchema, | |
| 28 | + GraphqlStoredSourceDataSchema, | |
| 29 | + type GraphqlConnectInput, | |
| 30 | + type GraphqlSourceConfigPayload, | |
| 31 | + type GraphqlStoredSourceData, | |
| 32 | + type GraphqlUpdateSourceInput, | |
| 33 | +} from "@executor/plugin-graphql-shared"; | |
| 34 | + | |
| 35 | +import { | |
| 36 | + createGraphqlCatalogFragment, | |
| 37 | + type GraphqlCatalogOperationInput, | |
| 38 | +} from "./catalog"; | |
| 39 | +import { | |
| 40 | + compileGraphqlToolDefinitions, | |
| 41 | + extractGraphqlManifest, | |
| 42 | + GRAPHQL_INTROSPECTION_QUERY, | |
| 43 | + buildGraphqlToolPresentation, | |
| 44 | +} from "./graphql-tools"; | |
| 45 | +import { | |
| 46 | + GraphqlToolProviderDataSchema, | |
| 47 | + type GraphqlToolProviderData, | |
| 48 | +} from "./provider-data"; | |
| 49 | + | |
| 50 | +const decodeStoredSourceData = Schema.decodeUnknownSync(GraphqlStoredSourceDataSchema); | |
| 51 | +const decodeProviderData = Schema.decodeUnknownSync(GraphqlToolProviderDataSchema); | |
| 52 | + | |
| 53 | +export type GraphqlSourceStorage = { | |
| 54 | + get: (input: { | |
| 55 | + scopeId: string; | |
| 56 | + sourceId: string; | |
| 57 | + }) => Effect.Effect<GraphqlStoredSourceData | null, Error, never>; | |
| 58 | + put: (input: { | |
| 59 | + scopeId: string; | |
| 60 | + sourceId: string; | |
| 61 | + value: GraphqlStoredSourceData; | |
| 62 | + }) => Effect.Effect<void, Error, never>; | |
| 63 | + remove?: (input: { | |
| 64 | + scopeId: string; | |
| 65 | + sourceId: string; | |
| 66 | + }) => Effect.Effect<void, Error, never>; | |
| 67 | +}; | |
| 68 | + | |
| 69 | +export type GraphqlSdk = { | |
| 70 | + getSourceConfig: ( | |
| 71 | + sourceId: Source["id"], | |
| 72 | + ) => Effect.Effect<GraphqlSourceConfigPayload, Error>; | |
| 73 | + createSource: ( | |
| 74 | + input: GraphqlConnectInput, | |
| 75 | + ) => Effect.Effect<Source, Error>; | |
| 76 | + updateSource: ( | |
| 77 | + input: GraphqlUpdateSourceInput, | |
| 78 | + ) => Effect.Effect<Source, Error>; | |
| 79 | + removeSource: ( | |
| 80 | + sourceId: Source["id"], | |
| 81 | + ) => Effect.Effect<boolean, Error>; | |
| 82 | +}; | |
| 83 | + | |
| 84 | +const GraphqlExecutorAddInputSchema = Schema.Struct({ | |
| 85 | + kind: Schema.Literal("graphql"), | |
| 86 | + name: Schema.String, | |
| 87 | + endpoint: Schema.String, | |
| 88 | + defaultHeaders: Schema.NullOr(Schema.Record({ key: Schema.String, value: Schema.String })), | |
| 89 | + auth: GraphqlConnectionAuthSchema, | |
| 90 | +}); | |
| 91 | + | |
| 92 | +type GraphqlExecutorAddInput = typeof GraphqlExecutorAddInputSchema.Type; | |
| 93 | + | |
| 94 | +const asRecord = (value: unknown): Record<string, unknown> => | |
| 95 | + typeof value === "object" && value !== null && !Array.isArray(value) | |
| 96 | + ? (value as Record<string, unknown>) | |
| 97 | + : {}; | |
| 98 | + | |
| 99 | +const asString = (value: unknown): string | null => | |
| 100 | + typeof value === "string" && value.trim().length > 0 ? value : null; | |
| 101 | + | |
| 102 | +const asStringRecord = (value: unknown): Record<string, string> => | |
| 103 | + Object.fromEntries( | |
| 104 | + Object.entries(asRecord(value)).flatMap(([key, entry]) => | |
| 105 | + typeof entry === "string" ? [[key, entry]] : [], | |
| 106 | + ), | |
| 107 | + ); | |
| 108 | + | |
| 109 | +const withoutUndefinedEntries = ( | |
| 110 | + record: Readonly<Record<string, unknown>>, | |
| 111 | +): Record<string, unknown> => | |
| 112 | + Object.fromEntries( | |
| 113 | + Object.entries(record).filter(([, value]) => value !== undefined), | |
| 114 | + ); | |
| 115 | + | |
| 116 | +const responseHeadersRecord = (response: Response): Record<string, string> => { | |
| 117 | + const headers: Record<string, string> = {}; | |
| 118 | + response.headers.forEach((value, key) => { | |
| 119 | + headers[key] = value; | |
| 120 | + }); | |
| 121 | + return headers; | |
| 122 | +}; | |
| 123 | + | |
| 124 | +const parseGraphqlResponseBody = async ( | |
| 125 | + response: Response, | |
| 126 | +): Promise<unknown> => { | |
| 127 | + const contentType = response.headers.get("content-type")?.toLowerCase() ?? ""; | |
| 128 | + if (contentType.includes("application/json")) { | |
| 129 | + return response.json(); | |
| 130 | + } | |
| 131 | + | |
| 132 | + return response.text(); | |
| 133 | +}; | |
| 134 | + | |
| 135 | +const resolveBearerToken = ( | |
| 136 | + auth: GraphqlStoredSourceData["auth"], | |
| 137 | +): Effect.Effect<string | null, Error, any> => | |
| 138 | + auth.kind === "none" | |
| 139 | + ? Effect.succeed(null) | |
| 140 | + : Effect.flatMap(SecretMaterialResolverService, (resolveSecretMaterial) => | |
| 141 | + resolveSecretMaterial({ | |
| 142 | + ref: auth.tokenSecretRef, | |
| 143 | + }).pipe(Effect.map((token) => token.trim())) | |
| 144 | + ); | |
| 145 | + | |
| 146 | +const resolveGraphqlHeaders = ( | |
| 147 | + stored: GraphqlStoredSourceData, | |
| 148 | +): Effect.Effect<Record<string, string>, Error, any> => | |
| 149 | + Effect.gen(function* () { | |
| 150 | + const bearerToken = yield* resolveBearerToken(stored.auth); | |
| 151 | + return { | |
| 152 | + ...(stored.defaultHeaders ?? {}), | |
| 153 | + ...(bearerToken | |
| 154 | + ? { | |
| 155 | + authorization: `Bearer ${bearerToken}`, | |
| 156 | + } | |
| 157 | + : {}), | |
| 158 | + }; | |
| 159 | + }); | |
| 160 | + | |
| 161 | +const fetchGraphqlIntrospectionDocumentWithHeaders = (input: { | |
| 162 | + endpoint: string; | |
| 163 | + headers: Readonly<Record<string, string>>; | |
| 164 | +}): Effect.Effect<string, Error> => | |
| 165 | + Effect.tryPromise({ | |
| 166 | + try: async () => { | |
| 167 | + let response: Response; | |
| 168 | + try { | |
| 169 | + response = await fetch(input.endpoint, { | |
| 170 | + method: "POST", | |
| 171 | + headers: { | |
| 172 | + accept: "application/graphql-response+json, application/json", | |
| 173 | + "content-type": "application/json", | |
| 174 | + ...input.headers, | |
| 175 | + }, | |
| 176 | + body: JSON.stringify({ query: GRAPHQL_INTROSPECTION_QUERY }), | |
| 177 | + signal: AbortSignal.timeout(15_000), | |
| 178 | + }); | |
| 179 | + } catch (cause) { | |
| 180 | + if ( | |
| 181 | + cause instanceof Error && | |
| 182 | + (cause.name === "AbortError" || cause.name === "TimeoutError") | |
| 183 | + ) { | |
| 184 | + throw new Error("GraphQL introspection timed out after 15000ms"); | |
| 185 | + } | |
| 186 | + throw cause; | |
| 187 | + } | |
| 188 | + | |
| 189 | + const text = await response.text(); | |
| 190 | + let parsed: unknown; | |
| 191 | + try { | |
| 192 | + parsed = JSON.parse(text); | |
| 193 | + } catch (cause) { | |
| 194 | + throw new Error( | |
| 195 | + `GraphQL introspection endpoint did not return JSON: ${ | |
| 196 | + cause instanceof Error ? cause.message : String(cause) | |
| 197 | + }`, | |
| 198 | + ); | |
| 199 | + } | |
| 200 | + | |
| 201 | + if (!response.ok) { | |
| 202 | + if (response.status === 401 || response.status === 403) { | |
| 203 | + throw new SourceCredentialRequiredError( | |
| 204 | + "import", | |
| 205 | + `GraphQL introspection requires credentials (status ${response.status})`, | |
| 206 | + ); | |
| 207 | + } | |
| 208 | + | |
| 209 | + throw new Error( | |
| 210 | + `GraphQL introspection failed with status ${response.status}`, | |
| 211 | + ); | |
| 212 | + } | |
| 213 | + | |
| 214 | + return JSON.stringify(parsed, null, 2); | |
| 215 | + }, | |
| 216 | + catch: (cause) => | |
| 217 | + cause instanceof Error ? cause : new Error(String(cause)), | |
| 218 | + }); | |
| 219 | + | |
| 220 | +const graphqlCatalogOperationFromDefinition = (input: { | |
| 221 | + definition: ReturnType<typeof compileGraphqlToolDefinitions>[number]; | |
| 222 | + manifest: Parameters<typeof buildGraphqlToolPresentation>[0]["manifest"]; | |
| 223 | +}): GraphqlCatalogOperationInput => { | |
| 224 | + const presentation = buildGraphqlToolPresentation({ | |
| 225 | + manifest: input.manifest, | |
| 226 | + definition: input.definition, | |
| 227 | + }); | |
| 228 | + | |
| 229 | + return { | |
| 230 | + toolId: input.definition.toolId, | |
| 231 | + title: input.definition.name, | |
| 232 | + description: input.definition.description, | |
| 233 | + effect: input.definition.operationType === "query" ? "read" : "write", | |
| 234 | + inputSchema: presentation.inputSchema, | |
| 235 | + outputSchema: presentation.outputSchema, | |
| 236 | + providerData: presentation.providerData as GraphqlToolProviderData, | |
| 237 | + }; | |
| 238 | +}; | |
| 239 | + | |
| 240 | +const storedSourceDataFromInput = ( | |
| 241 | + input: GraphqlConnectInput, | |
| 242 | +): GraphqlStoredSourceData => | |
| 243 | + decodeStoredSourceData({ | |
| 244 | + endpoint: input.endpoint.trim(), | |
| 245 | + defaultHeaders: input.defaultHeaders, | |
| 246 | + auth: input.auth, | |
| 247 | + }); | |
| 248 | + | |
| 249 | +const sourceConfigFromStored = ( | |
| 250 | + source: Source, | |
| 251 | + stored: GraphqlStoredSourceData, | |
| 252 | +): GraphqlSourceConfigPayload => ({ | |
| 253 | + name: source.name, | |
| 254 | + endpoint: stored.endpoint, | |
| 255 | + defaultHeaders: stored.defaultHeaders, | |
| 256 | + auth: stored.auth, | |
| 257 | +}); | |
| 258 | + | |
| 259 | +const createGraphqlSourceSdk = ( | |
| 260 | + options: { | |
| 261 | + storage: GraphqlSourceStorage; | |
| 262 | + }, | |
| 263 | + host: ExecutorSdkPluginHost, | |
| 264 | +) => ({ | |
| 265 | + getSourceConfig: (sourceId: Source["id"]) => | |
| 266 | + Effect.gen(function* () { | |
| 267 | + const source = yield* host.sources.get(sourceId); | |
| 268 | + if (source.kind !== "graphql") { | |
| 269 | + return yield* Effect.fail( | |
| 270 | + new Error(`Source ${sourceId} is not a GraphQL source.`), | |
| 271 | + ); | |
| 272 | + } | |
| 273 | + | |
| 274 | + const stored = yield* options.storage.get({ | |
| 275 | + scopeId: source.scopeId, | |
| 276 | + sourceId: source.id, | |
| 277 | + }); | |
| 278 | + if (stored === null) { | |
| 279 | + return yield* Effect.fail( | |
| 280 | + new Error(`GraphQL source storage missing for ${source.id}`), | |
| 281 | + ); | |
| 282 | + } | |
| 283 | + | |
| 284 | + return sourceConfigFromStored(source, stored); | |
| 285 | + }), | |
| 286 | + createSource: (input: GraphqlConnectInput) => | |
| 287 | + Effect.gen(function* () { | |
| 288 | + const stored = storedSourceDataFromInput(input); | |
| 289 | + const createdSource = yield* host.sources.create({ | |
| 290 | + source: { | |
| 291 | + name: input.name.trim(), | |
| 292 | + kind: "graphql", | |
| 293 | + status: "connected", | |
| 294 | + enabled: true, | |
| 295 | + namespace: deriveGraphqlNamespace({ | |
| 296 | + endpoint: input.endpoint, | |
| 297 | + title: input.name, | |
| 298 | + }), | |
| 299 | + }, | |
| 300 | + }); | |
| 301 | + | |
| 302 | + yield* options.storage.put({ | |
| 303 | + scopeId: createdSource.scopeId, | |
| 304 | + sourceId: createdSource.id, | |
| 305 | + value: stored, | |
| 306 | + }); | |
| 307 | + | |
| 308 | + return yield* host.sources.refreshCatalog(createdSource.id); | |
| 309 | + }), | |
| 310 | + updateSource: (input: GraphqlUpdateSourceInput) => | |
| 311 | + Effect.gen(function* () { | |
| 312 | + const source = yield* host.sources.get(input.sourceId as Source["id"]); | |
| 313 | + if (source.kind !== "graphql") { | |
| 314 | + return yield* Effect.fail( | |
| 315 | + new Error(`Source ${input.sourceId} is not a GraphQL source.`), | |
| 316 | + ); | |
| 317 | + } | |
| 318 | + | |
| 319 | + const stored = storedSourceDataFromInput(input.config); | |
| 320 | + const savedSource = yield* host.sources.save({ | |
| 321 | + ...source, | |
| 322 | + name: input.config.name.trim(), | |
| 323 | + namespace: deriveGraphqlNamespace({ | |
| 324 | + endpoint: input.config.endpoint, | |
| 325 | + title: input.config.name, | |
| 326 | + }), | |
| 327 | + }); | |
| 328 | + | |
| 329 | + yield* options.storage.put({ | |
| 330 | + scopeId: savedSource.scopeId, | |
| 331 | + sourceId: savedSource.id, | |
| 332 | + value: stored, | |
| 333 | + }); | |
| 334 | + | |
| 335 | + return yield* host.sources.refreshCatalog(savedSource.id); | |
| 336 | + }), | |
| 337 | + removeSource: (sourceId: Source["id"]) => | |
| 338 | + Effect.gen(function* () { | |
| 339 | + const source = yield* host.sources.get(sourceId); | |
| 340 | + if (source.kind !== "graphql") { | |
| 341 | + return yield* Effect.fail( | |
| 342 | + new Error(`Source ${sourceId} is not a GraphQL source.`), | |
| 343 | + ); | |
| 344 | + } | |
| 345 | + | |
| 346 | + if (options.storage.remove) { | |
| 347 | + yield* options.storage.remove({ | |
| 348 | + scopeId: source.scopeId, | |
| 349 | + sourceId: source.id, | |
| 350 | + }); | |
| 351 | + } | |
| 352 | + | |
| 353 | + return yield* host.sources.remove(source.id); | |
| 354 | + }), | |
| 355 | +}); | |
| 356 | + | |
| 357 | +const graphqlSourceConnector = ( | |
| 358 | + options: { | |
| 359 | + storage: GraphqlSourceStorage; | |
| 360 | + }, | |
| 361 | +): ExecutorSourceConnector<GraphqlExecutorAddInput> => ({ | |
| 362 | + kind: "graphql", | |
| 363 | + displayName: "GraphQL", | |
| 364 | + inputSchema: GraphqlExecutorAddInputSchema, | |
| 365 | + inputSignatureWidth: 300, | |
| 366 | + helpText: [ | |
| 367 | + "Point `endpoint` at the GraphQL HTTP endpoint and choose `auth.kind`.", | |
| 368 | + ], | |
| 369 | + createSource: ({ args, host }) => | |
| 370 | + createGraphqlSourceSdk(options, host).createSource(args), | |
| 371 | +}); | |
| 372 | + | |
| 373 | +const createGraphqlSourceRuntime = ( | |
| 374 | + options: { | |
| 375 | + storage: GraphqlSourceStorage; | |
| 376 | + }, | |
| 377 | +): SourcePluginRuntime => ({ | |
| 378 | + kind: "graphql", | |
| 379 | + displayName: "GraphQL", | |
| 380 | + catalogKind: "imported", | |
| 381 | + catalogIdentity: ({ source }) => ({ | |
| 382 | + kind: "graphql", | |
| 383 | + sourceId: source.id, | |
| 384 | + }), | |
| 385 | + getIrModel: ({ source }) => | |
| 386 | + Effect.gen(function* () { | |
| 387 | + const stored = yield* options.storage.get({ | |
| 388 | + scopeId: source.scopeId, | |
| 389 | + sourceId: source.id, | |
| 390 | + }); | |
| 391 | + | |
| 392 | + if (stored === null) { | |
| 393 | + return createSourceCatalogSyncResult({ | |
| 394 | + fragment: { | |
| 395 | + version: "ir.v1.fragment", | |
| 396 | + }, | |
| 397 | + importMetadata: { | |
| 398 | + ...createCatalogImportMetadata({ | |
| 399 | + source, | |
| 400 | + pluginKey: "graphql", | |
| 401 | + }), | |
| 402 | + importerVersion: "ir.v1.graphql", | |
| 403 | + sourceConfigHash: "missing", | |
| 404 | + }, | |
| 405 | + sourceHash: null, | |
| 406 | + }); | |
| 407 | + } | |
| 408 | + | |
| 409 | + const headers = yield* resolveGraphqlHeaders(stored); | |
| 410 | + const graphqlDocument = yield* fetchGraphqlIntrospectionDocumentWithHeaders({ | |
| 411 | + endpoint: stored.endpoint, | |
| 412 | + headers, | |
| 413 | + }); | |
| 414 | + const manifest = yield* extractGraphqlManifest(source.name, graphqlDocument); | |
| 415 | + const definitions = yield* Effect.sync(() => | |
| 416 | + compileGraphqlToolDefinitions(manifest), | |
| 417 | + ); | |
| 418 | + const operations = definitions.map((definition) => | |
| 419 | + graphqlCatalogOperationFromDefinition({ | |
| 420 | + definition, | |
| 421 | + manifest, | |
| 422 | + }), | |
| 423 | + ); | |
| 424 | + const now = Date.now(); | |
| 425 | + | |
| 426 | + return createSourceCatalogSyncResult({ | |
| 427 | + fragment: createGraphqlCatalogFragment({ | |
| 428 | + source, | |
| 429 | + documents: [ | |
| 430 | + { | |
| 431 | + documentKind: "graphql_introspection", | |
| 432 | + documentKey: stored.endpoint, | |
| 433 | + contentText: graphqlDocument, | |
| 434 | + fetchedAt: now, | |
| 435 | + }, | |
| 436 | + ], | |
| 437 | + operations, | |
| 438 | + }), | |
| 439 | + importMetadata: { | |
| 440 | + ...createCatalogImportMetadata({ | |
| 441 | + source, | |
| 442 | + pluginKey: "graphql", | |
| 443 | + }), | |
| 444 | + importerVersion: "ir.v1.graphql", | |
| 445 | + }, | |
| 446 | + sourceHash: manifest.sourceHash, | |
| 447 | + }); | |
| 448 | + }), | |
| 449 | + invoke: (input) => | |
| 450 | + Effect.gen(function* () { | |
| 451 | + const stored = yield* options.storage.get({ | |
| 452 | + scopeId: input.source.scopeId, | |
| 453 | + sourceId: input.source.id, | |
| 454 | + }); | |
| 455 | + if (stored === null) { | |
| 456 | + return yield* Effect.fail( | |
| 457 | + new Error(`GraphQL source storage missing for ${input.source.id}`), | |
| 458 | + ); | |
| 459 | + } | |
| 460 | + | |
| 461 | + const providerData = decodeProviderData( | |
| 462 | + input.executable.binding, | |
| 463 | + ) as GraphqlToolProviderData; | |
| 464 | + const args = asRecord(input.args); | |
| 465 | + const headers = { | |
| 466 | + "content-type": "application/json", | |
| 467 | + ...(yield* resolveGraphqlHeaders(stored)), | |
| 468 | + ...asStringRecord(args.headers), | |
| 469 | + }; | |
| 470 | + const endpoint = applyHttpQueryPlacementsToUrl({ | |
| 471 | + url: stored.endpoint, | |
| 472 | + }).toString(); | |
| 473 | + | |
| 474 | + const isRawRequest = | |
| 475 | + providerData.toolKind === "request" | |
| 476 | + || typeof providerData.operationDocument !== "string" | |
| 477 | + || providerData.operationDocument.trim().length === 0; | |
| 478 | + const query = isRawRequest | |
| 479 | + ? (() => { | |
| 480 | + const value = asString(args.query); | |
| 481 | + if (value === null) { | |
| 482 | + throw new Error("GraphQL request tools require args.query"); | |
| 483 | + } | |
| 484 | + return value; | |
| 485 | + })() | |
| 486 | + : providerData.operationDocument!; | |
| 487 | + const variables = isRawRequest | |
| 488 | + ? args.variables !== undefined | |
| 489 | + ? asRecord(args.variables) | |
| 490 | + : undefined | |
| 491 | + : withoutUndefinedEntries( | |
| 492 | + Object.fromEntries( | |
| 493 | + Object.entries(args).filter(([key]) => key !== "headers"), | |
| 494 | + ), | |
| 495 | + ); | |
| 496 | + const operationName = isRawRequest | |
| 497 | + ? (asString(args.operationName) ?? undefined) | |
| 498 | + : (providerData.operationName ?? undefined); | |
| 499 | + | |
| 500 | + const response = yield* Effect.tryPromise({ | |
| 501 | + try: () => | |
| 502 | + fetch(endpoint, { | |
| 503 | + method: "POST", | |
| 504 | + headers: applyCookiePlacementsToHeaders({ | |
| 505 | + headers, | |
| 506 | + cookies: {}, | |
| 507 | + }), | |
| 508 | + body: JSON.stringify( | |
| 509 | + applyJsonBodyPlacements({ | |
| 510 | + body: { | |
| 511 | + query, | |
| 512 | + ...(variables ? { variables } : {}), | |
| 513 | + ...(operationName ? { operationName } : {}), | |
| 514 | + }, | |
| 515 | + bodyValues: {}, | |
| 516 | + label: `GraphQL invocation ${stored.endpoint}`, | |
| 517 | + }), | |
| 518 | + ), | |
| 519 | + }), | |
| 520 | + catch: (cause) => | |
| 521 | + cause instanceof Error ? cause : new Error(String(cause)), | |
| 522 | + }); | |
| 523 | + const body = yield* Effect.tryPromise({ | |
| 524 | + try: () => parseGraphqlResponseBody(response), | |
| 525 | + catch: (cause) => | |
| 526 | + cause instanceof Error ? cause : new Error(String(cause)), | |
| 527 | + }); | |
| 528 | + const bodyRecord = asRecord(body); | |
| 529 | + const hasErrors = Array.isArray(bodyRecord.errors) && bodyRecord.errors.length > 0; | |
| 530 | + | |
| 531 | + return { | |
| 532 | + data: response.ok ? (bodyRecord.data ?? body) : null, | |
| 533 | + error: | |
| 534 | + response.ok && !hasErrors | |
| 535 | + ? null | |
| 536 | + : bodyRecord.errors ?? body, | |
| 537 | + headers: responseHeadersRecord(response), | |
| 538 | + status: response.status, | |
| 539 | + }; | |
| 540 | + }), | |
| 541 | +}); | |
| 542 | + | |
| 543 | +export const graphqlSdkPlugin = (options: { | |
| 544 | + storage: GraphqlSourceStorage; | |
| 545 | +}): ExecutorSdkPlugin<"graphql", GraphqlSdk> => ({ | |
| 546 | + key: "graphql", | |
| 547 | + sources: [createGraphqlSourceRuntime(options)], | |
| 548 | + sourceConnectors: [graphqlSourceConnector(options)], | |
| 549 | + extendExecutor: ({ host, executor }) => { | |
| 550 | + const sourceSdk = createGraphqlSourceSdk(options, host); | |
| 551 | + const provideRuntime = <A>( | |
| 552 | + effect: Effect.Effect<A, Error, any>, | |
| 553 | + ): Effect.Effect<A, Error, never> => | |
| 554 | + provideExecutorRuntime(effect, executor.runtime); | |
| 555 | + | |
| 556 | + return { | |
| 557 | + getSourceConfig: (sourceId) => | |
| 558 | + provideRuntime(sourceSdk.getSourceConfig(sourceId)), | |
| 559 | + createSource: (input) => | |
| 560 | + provideRuntime(sourceSdk.createSource(input)), | |
| 561 | + updateSource: (input) => | |
| 562 | + provideRuntime(sourceSdk.updateSource(input)), | |
| 563 | + removeSource: (sourceId) => | |
| 564 | + provideRuntime(sourceSdk.removeSource(sourceId)), | |
| 565 | + }; | |
| 566 | + }, | |
| 567 | +}); | |
| @@ -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 | +} | |
| @@ -0,0 +1,25 @@ | ||
| 1 | +import * as Schema from "effect/Schema"; | |
| 2 | + | |
| 3 | +export const GraphqlToolKindSchema = Schema.Literal("request", "field"); | |
| 4 | + | |
| 5 | +export const GraphqlOperationTypeSchema = Schema.Literal("query", "mutation"); | |
| 6 | + | |
| 7 | +export const GraphqlToolProviderDataSchema = Schema.Struct({ | |
| 8 | + kind: Schema.Literal("graphql"), | |
| 9 | + toolKind: GraphqlToolKindSchema, | |
| 10 | + toolId: Schema.String, | |
| 11 | + rawToolId: Schema.NullOr(Schema.String), | |
| 12 | + group: Schema.NullOr(Schema.String), | |
| 13 | + leaf: Schema.NullOr(Schema.String), | |
| 14 | + fieldName: Schema.NullOr(Schema.String), | |
| 15 | + operationType: Schema.NullOr(GraphqlOperationTypeSchema), | |
| 16 | + operationName: Schema.NullOr(Schema.String), | |
| 17 | + operationDocument: Schema.NullOr(Schema.String), | |
| 18 | + queryTypeName: Schema.NullOr(Schema.String), | |
| 19 | + mutationTypeName: Schema.NullOr(Schema.String), | |
| 20 | + subscriptionTypeName: Schema.NullOr(Schema.String), | |
| 21 | +}); | |
| 22 | + | |
| 23 | +export type GraphqlToolKind = typeof GraphqlToolKindSchema.Type; | |
| 24 | +export type GraphqlOperationType = typeof GraphqlOperationTypeSchema.Type; | |
| 25 | +export type GraphqlToolProviderData = typeof GraphqlToolProviderDataSchema.Type; | |
| @@ -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 | +} | |
| @@ -0,0 +1,64 @@ | ||
| 1 | +import * as Schema from "effect/Schema"; | |
| 2 | + | |
| 3 | +import { | |
| 4 | + SecretRefSchema, | |
| 5 | + StringMapSchema, | |
| 6 | +} from "@executor/platform-sdk/schema"; | |
| 7 | +import { | |
| 8 | + defaultNameFromEndpoint, | |
| 9 | + namespaceFromSourceName, | |
| 10 | +} from "@executor/source-core"; | |
| 11 | + | |
| 12 | +export const GraphqlConnectionAuthSchema = Schema.Union( | |
| 13 | + Schema.Struct({ | |
| 14 | + kind: Schema.Literal("none"), | |
| 15 | + }), | |
| 16 | + Schema.Struct({ | |
| 17 | + kind: Schema.Literal("bearer"), | |
| 18 | + tokenSecretRef: SecretRefSchema, | |
| 19 | + }), | |
| 20 | +); | |
| 21 | + | |
| 22 | +export const GraphqlConnectInputSchema = Schema.Struct({ | |
| 23 | + name: Schema.String, | |
| 24 | + endpoint: Schema.String, | |
| 25 | + defaultHeaders: Schema.NullOr(StringMapSchema), | |
| 26 | + auth: GraphqlConnectionAuthSchema, | |
| 27 | +}); | |
| 28 | + | |
| 29 | +export const GraphqlSourceConfigPayloadSchema = GraphqlConnectInputSchema; | |
| 30 | + | |
| 31 | +export const GraphqlUpdateSourceInputSchema = Schema.Struct({ | |
| 32 | + sourceId: Schema.String, | |
| 33 | + config: GraphqlSourceConfigPayloadSchema, | |
| 34 | +}); | |
| 35 | + | |
| 36 | +export const GraphqlStoredSourceDataSchema = Schema.Struct({ | |
| 37 | + endpoint: Schema.String, | |
| 38 | + defaultHeaders: Schema.NullOr(StringMapSchema), | |
| 39 | + auth: GraphqlConnectionAuthSchema, | |
| 40 | +}); | |
| 41 | + | |
| 42 | +export type GraphqlConnectionAuth = typeof GraphqlConnectionAuthSchema.Type; | |
| 43 | +export type GraphqlConnectInput = typeof GraphqlConnectInputSchema.Type; | |
| 44 | +export type GraphqlSourceConfigPayload = | |
| 45 | + typeof GraphqlSourceConfigPayloadSchema.Type; | |
| 46 | +export type GraphqlStoredSourceData = | |
| 47 | + typeof GraphqlStoredSourceDataSchema.Type; | |
| 48 | +export type GraphqlUpdateSourceInput = | |
| 49 | + typeof GraphqlUpdateSourceInputSchema.Type; | |
| 50 | + | |
| 51 | +export const deriveGraphqlNamespace = (input: { | |
| 52 | + endpoint: string; | |
| 53 | + title?: string | null; | |
| 54 | +}): string | null => { | |
| 55 | + if (input.title && input.title.trim().length > 0) { | |
| 56 | + return namespaceFromSourceName(input.title); | |
| 57 | + } | |
| 58 | + | |
| 59 | + try { | |
| 60 | + return namespaceFromSourceName(defaultNameFromEndpoint(input.endpoint)); | |
| 61 | + } catch { | |
| 62 | + return null; | |
| 63 | + } | |
| 64 | +}; | |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -0,0 +1,301 @@ | ||
| 1 | +import { | |
| 2 | + HttpApi, | |
| 3 | + HttpApiBuilder, | |
| 4 | + HttpApiEndpoint, | |
| 5 | + HttpApiGroup, | |
| 6 | + HttpApiSchema, | |
| 7 | + HttpServerResponse, | |
| 8 | +} from "@effect/platform"; | |
| 9 | +import { | |
| 10 | + ControlPlaneBadRequestError, | |
| 11 | + ControlPlaneForbiddenError, | |
| 12 | + ControlPlaneNotFoundError, | |
| 13 | + ControlPlaneStorageError, | |
| 14 | + type ExecutorHttpApiExtension, | |
| 15 | + type ExecutorHttpPlugin, | |
| 16 | +} from "@executor/platform-api"; | |
| 17 | +import { resolveRequestedLocalWorkspace } from "@executor/platform-api/local-context"; | |
| 18 | +import { | |
| 19 | + ScopeIdSchema, | |
| 20 | + SourceIdSchema, | |
| 21 | + SourceSchema, | |
| 22 | + type Source, | |
| 23 | +} from "@executor/platform-sdk/schema"; | |
| 24 | +import * as Effect from "effect/Effect"; | |
| 25 | +import * as Schema from "effect/Schema"; | |
| 26 | + | |
| 27 | +import { | |
| 28 | + McpConnectInputSchema, | |
| 29 | + McpOAuthPopupResultSchema, | |
| 30 | + McpSourceConfigPayloadSchema, | |
| 31 | + McpStartOAuthInputSchema, | |
| 32 | + McpStartOAuthResultSchema, | |
| 33 | + type McpConnectInput, | |
| 34 | + type McpOAuthPopupResult, | |
| 35 | + type McpSourceConfigPayload, | |
| 36 | + type McpStartOAuthInput, | |
| 37 | + type McpStartOAuthResult, | |
| 38 | + type McpUpdateSourceInput, | |
| 39 | +} from "@executor/plugin-mcp-shared"; | |
| 40 | + | |
| 41 | +type McpExecutorExtension = { | |
| 42 | + mcp: { | |
| 43 | + createSource: ( | |
| 44 | + input: McpConnectInput, | |
| 45 | + ) => Effect.Effect<Source, Error>; | |
| 46 | + getSourceConfig: ( | |
| 47 | + sourceId: Source["id"], | |
| 48 | + ) => Effect.Effect<McpSourceConfigPayload, Error>; | |
| 49 | + updateSource: ( | |
| 50 | + input: McpUpdateSourceInput, | |
| 51 | + ) => Effect.Effect<Source, Error>; | |
| 52 | + removeSource: ( | |
| 53 | + sourceId: Source["id"], | |
| 54 | + ) => Effect.Effect<boolean, Error>; | |
| 55 | + startOAuth: ( | |
| 56 | + input: McpStartOAuthInput, | |
| 57 | + ) => Effect.Effect<McpStartOAuthResult, Error>; | |
| 58 | + completeOAuth: (input: { | |
| 59 | + state: string; | |
| 60 | + code?: string; | |
| 61 | + error?: string; | |
| 62 | + errorDescription?: string; | |
| 63 | + }) => Effect.Effect<Extract<McpOAuthPopupResult, { ok: true }>, Error>; | |
| 64 | + }; | |
| 65 | +}; | |
| 66 | + | |
| 67 | +const workspaceIdParam = HttpApiSchema.param("workspaceId", ScopeIdSchema); | |
| 68 | +const sourceIdParam = HttpApiSchema.param("sourceId", SourceIdSchema); | |
| 69 | +const htmlSchema = HttpApiSchema.Text({ | |
| 70 | + contentType: "text/html", | |
| 71 | +}); | |
| 72 | + | |
| 73 | +const callbackParamsSchema = Schema.Struct({ | |
| 74 | + state: Schema.String, | |
| 75 | + code: Schema.optional(Schema.String), | |
| 76 | + error: Schema.optional(Schema.String), | |
| 77 | + error_description: Schema.optional(Schema.String), | |
| 78 | +}); | |
| 79 | + | |
| 80 | +export const McpHttpGroup = HttpApiGroup.make("mcp") | |
| 81 | + .add( | |
| 82 | + HttpApiEndpoint.post("createSource")`/workspaces/${workspaceIdParam}/plugins/mcp/sources` | |
| 83 | + .setPayload(McpConnectInputSchema) | |
| 84 | + .addSuccess(SourceSchema) | |
| 85 | + .addError(ControlPlaneBadRequestError) | |
| 86 | + .addError(ControlPlaneForbiddenError) | |
| 87 | + .addError(ControlPlaneStorageError), | |
| 88 | + ) | |
| 89 | + .add( | |
| 90 | + HttpApiEndpoint.get("getSourceConfig")`/workspaces/${workspaceIdParam}/plugins/mcp/sources/${sourceIdParam}` | |
| 91 | + .addSuccess(McpSourceConfigPayloadSchema) | |
| 92 | + .addError(ControlPlaneBadRequestError) | |
| 93 | + .addError(ControlPlaneForbiddenError) | |
| 94 | + .addError(ControlPlaneNotFoundError) | |
| 95 | + .addError(ControlPlaneStorageError), | |
| 96 | + ) | |
| 97 | + .add( | |
| 98 | + HttpApiEndpoint.put("updateSource")`/workspaces/${workspaceIdParam}/plugins/mcp/sources/${sourceIdParam}` | |
| 99 | + .setPayload(McpSourceConfigPayloadSchema) | |
| 100 | + .addSuccess(SourceSchema) | |
| 101 | + .addError(ControlPlaneBadRequestError) | |
| 102 | + .addError(ControlPlaneForbiddenError) | |
| 103 | + .addError(ControlPlaneNotFoundError) | |
| 104 | + .addError(ControlPlaneStorageError), | |
| 105 | + ) | |
| 106 | + .add( | |
| 107 | + HttpApiEndpoint.del("removeSource")`/workspaces/${workspaceIdParam}/plugins/mcp/sources/${sourceIdParam}` | |
| 108 | + .addSuccess(Schema.Struct({ removed: Schema.Boolean })) | |
| 109 | + .addError(ControlPlaneBadRequestError) | |
| 110 | + .addError(ControlPlaneForbiddenError) | |
| 111 | + .addError(ControlPlaneNotFoundError) | |
| 112 | + .addError(ControlPlaneStorageError), | |
| 113 | + ) | |
| 114 | + .add( | |
| 115 | + HttpApiEndpoint.post("startOAuth")`/workspaces/${workspaceIdParam}/plugins/mcp/oauth/start` | |
| 116 | + .setPayload(McpStartOAuthInputSchema) | |
| 117 | + .addSuccess(McpStartOAuthResultSchema) | |
| 118 | + .addError(ControlPlaneBadRequestError) | |
| 119 | + .addError(ControlPlaneForbiddenError) | |
| 120 | + .addError(ControlPlaneStorageError), | |
| 121 | + ) | |
| 122 | + .add( | |
| 123 | + HttpApiEndpoint.get("oauthCallback")`/plugins/mcp/oauth/callback` | |
| 124 | + .setUrlParams(callbackParamsSchema) | |
| 125 | + .addSuccess(htmlSchema) | |
| 126 | + .addError(ControlPlaneBadRequestError) | |
| 127 | + .addError(ControlPlaneStorageError), | |
| 128 | + ) | |
| 129 | + .prefix("/v1"); | |
| 130 | + | |
| 131 | +export const mcpHttpApiExtension = { | |
| 132 | + key: "mcp", | |
| 133 | + group: McpHttpGroup, | |
| 134 | +} satisfies ExecutorHttpApiExtension<typeof McpHttpGroup>; | |
| 135 | + | |
| 136 | +const McpHttpApi = HttpApi.make("executor").add(McpHttpGroup); | |
| 137 | + | |
| 138 | +const toStorageError = (operation: string, cause: unknown) => | |
| 139 | + new ControlPlaneStorageError({ | |
| 140 | + operation, | |
| 141 | + message: cause instanceof Error ? cause.message : String(cause), | |
| 142 | + details: cause instanceof Error ? cause.stack ?? cause.message : String(cause), | |
| 143 | + }); | |
| 144 | + | |
| 145 | +const toNotFoundError = (operation: string, cause: unknown) => | |
| 146 | + new ControlPlaneNotFoundError({ | |
| 147 | + operation, | |
| 148 | + message: cause instanceof Error ? cause.message : String(cause), | |
| 149 | + details: cause instanceof Error ? cause.stack ?? cause.message : String(cause), | |
| 150 | + }); | |
| 151 | + | |
| 152 | +const mapPluginStorageError = (operation: string, cause: unknown) => { | |
| 153 | + const message = cause instanceof Error ? cause.message : String(cause); | |
| 154 | + if (message.includes("not found") || message.includes("Not found")) { | |
| 155 | + return toNotFoundError(operation, cause); | |
| 156 | + } | |
| 157 | + | |
| 158 | + return toStorageError(operation, cause); | |
| 159 | +}; | |
| 160 | + | |
| 161 | +const escapeHtml = (value: string): string => | |
| 162 | + value | |
| 163 | + .replaceAll("&", "&") | |
| 164 | + .replaceAll("<", "<") | |
| 165 | + .replaceAll(">", ">") | |
| 166 | + .replaceAll('"', """) | |
| 167 | + .replaceAll("'", "'"); | |
| 168 | + | |
| 169 | +const popupDocument = (payload: McpOAuthPopupResult): string => { | |
| 170 | + const serialized = JSON.stringify(payload) | |
| 171 | + .replaceAll("<", "\\u003c") | |
| 172 | + .replaceAll(">", "\\u003e") | |
| 173 | + .replaceAll("&", "\\u0026"); | |
| 174 | + const title = payload.ok ? "MCP OAuth connected" : "MCP OAuth failed"; | |
| 175 | + const message = payload.ok | |
| 176 | + ? "MCP credentials are ready. Return to the source form to finish saving." | |
| 177 | + : payload.error; | |
| 178 | + | |
| 179 | + return `<!doctype html> | |
| 180 | +<html lang="en"> | |
| 181 | + <head> | |
| 182 | + <meta charset="utf-8" /> | |
| 183 | + <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| 184 | + <title>${escapeHtml(title)}</title> | |
| 185 | + </head> | |
| 186 | + <body> | |
| 187 | + <main> | |
| 188 | + <h1>${escapeHtml(title)}</h1> | |
| 189 | + <p>${escapeHtml(message)}</p> | |
| 190 | + </main> | |
| 191 | + <script> | |
| 192 | + (() => { | |
| 193 | + const payload = ${serialized}; | |
| 194 | + try { | |
| 195 | + window.localStorage.setItem("executor:mcp-oauth:" + (payload.ok ? payload.sessionId : "failed"), JSON.stringify(payload)); | |
| 196 | + } catch {} | |
| 197 | + try { | |
| 198 | + if (window.opener) { | |
| 199 | + window.opener.postMessage(payload, window.location.origin); | |
| 200 | + } | |
| 201 | + } finally { | |
| 202 | + window.setTimeout(() => window.close(), 120); | |
| 203 | + } | |
| 204 | + })(); | |
| 205 | + </script> | |
| 206 | + </body> | |
| 207 | +</html>`; | |
| 208 | +}; | |
| 209 | + | |
| 210 | +export const mcpHttpPlugin = (): ExecutorHttpPlugin< | |
| 211 | + typeof McpHttpGroup, | |
| 212 | + McpExecutorExtension | |
| 213 | +> => ({ | |
| 214 | + key: "mcp", | |
| 215 | + group: McpHttpGroup, | |
| 216 | + build: ({ executor }) => | |
| 217 | + HttpApiBuilder.group(McpHttpApi, "mcp", (handlers) => | |
| 218 | + handlers | |
| 219 | + .handle("createSource", ({ path, payload }) => | |
| 220 | + resolveRequestedLocalWorkspace( | |
| 221 | + "mcp.createSource", | |
| 222 | + path.workspaceId, | |
| 223 | + ).pipe( | |
| 224 | + Effect.flatMap(() => executor.mcp.createSource(payload)), | |
| 225 | + Effect.mapError((cause) => toStorageError("mcp.createSource", cause)), | |
| 226 | + ) | |
| 227 | + ) | |
| 228 | + .handle("getSourceConfig", ({ path }) => | |
| 229 | + resolveRequestedLocalWorkspace( | |
| 230 | + "mcp.getSourceConfig", | |
| 231 | + path.workspaceId, | |
| 232 | + ).pipe( | |
| 233 | + Effect.flatMap(() => executor.mcp.getSourceConfig(path.sourceId)), | |
| 234 | + Effect.mapError((cause) => | |
| 235 | + mapPluginStorageError("mcp.getSourceConfig", cause) | |
| 236 | + ), | |
| 237 | + ) | |
| 238 | + ) | |
| 239 | + .handle("updateSource", ({ path, payload }) => | |
| 240 | + resolveRequestedLocalWorkspace( | |
| 241 | + "mcp.updateSource", | |
| 242 | + path.workspaceId, | |
| 243 | + ).pipe( | |
| 244 | + Effect.flatMap(() => | |
| 245 | + executor.mcp.updateSource({ | |
| 246 | + sourceId: path.sourceId, | |
| 247 | + config: payload, | |
| 248 | + }) | |
| 249 | + ), | |
| 250 | + Effect.mapError((cause) => | |
| 251 | + mapPluginStorageError("mcp.updateSource", cause) | |
| 252 | + ), | |
| 253 | + ) | |
| 254 | + ) | |
| 255 | + .handle("removeSource", ({ path }) => | |
| 256 | + resolveRequestedLocalWorkspace( | |
| 257 | + "mcp.removeSource", | |
| 258 | + path.workspaceId, | |
| 259 | + ).pipe( | |
| 260 | + Effect.flatMap(() => executor.mcp.removeSource(path.sourceId)), | |
| 261 | + Effect.map((removed) => ({ removed })), | |
| 262 | + Effect.mapError((cause) => | |
| 263 | + mapPluginStorageError("mcp.removeSource", cause) | |
| 264 | + ), | |
| 265 | + ) | |
| 266 | + ) | |
| 267 | + .handle("startOAuth", ({ path, payload }) => | |
| 268 | + resolveRequestedLocalWorkspace( | |
| 269 | + "mcp.startOAuth", | |
| 270 | + path.workspaceId, | |
| 271 | + ).pipe( | |
| 272 | + Effect.flatMap(() => executor.mcp.startOAuth(payload)), | |
| 273 | + Effect.mapError((cause) => toStorageError("mcp.startOAuth", cause)), | |
| 274 | + ) | |
| 275 | + ) | |
| 276 | + .handle("oauthCallback", ({ urlParams }) => | |
| 277 | + executor.mcp.completeOAuth({ | |
| 278 | + state: urlParams.state, | |
| 279 | + code: urlParams.code, | |
| 280 | + error: urlParams.error, | |
| 281 | + errorDescription: urlParams.error_description, | |
| 282 | + }).pipe( | |
| 283 | + Effect.map((payload) => popupDocument(payload)), | |
| 284 | + Effect.mapError((cause) => | |
| 285 | + toStorageError("mcp.oauthCallback", cause) | |
| 286 | + ), | |
| 287 | + Effect.catchAll((error) => | |
| 288 | + Effect.succeed( | |
| 289 | + popupDocument({ | |
| 290 | + type: "executor:oauth-result", | |
| 291 | + ok: false, | |
| 292 | + sessionId: null, | |
| 293 | + error: error.message, | |
| 294 | + }), | |
| 295 | + ) | |
| 296 | + ), | |
| 297 | + Effect.flatMap((html) => HttpServerResponse.html(html)), | |
| 298 | + ) | |
| 299 | + ) | |
| 300 | + ), | |
| 301 | +}); | |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -0,0 +1,793 @@ | ||
| 1 | +import { startTransition, useMemo, useState, type ReactNode } from "react"; | |
| 2 | +import { useNavigate } from "@tanstack/react-router"; | |
| 3 | +import type { Source } from "@executor/react"; | |
| 4 | +import { | |
| 5 | + Result, | |
| 6 | + defineExecutorPluginHttpApiClient, | |
| 7 | + useAtomSet, | |
| 8 | + useAtomValue, | |
| 9 | + useExecutorMutation, | |
| 10 | + useLocalInstallation, | |
| 11 | +} from "@executor/react"; | |
| 12 | + | |
| 13 | +import { | |
| 14 | + mcpHttpApiExtension, | |
| 15 | +} from "@executor/plugin-mcp-http"; | |
| 16 | +import { | |
| 17 | + type McpConnectInput, | |
| 18 | + type McpConnectionAuth, | |
| 19 | + type McpOAuthPopupResult, | |
| 20 | + type McpStartOAuthInput, | |
| 21 | +} from "@executor/plugin-mcp-shared"; | |
| 22 | +import { IconPencil } from "../../../apps/web/src/components/icons"; | |
| 23 | +import { SourceToolExplorer } from "../../../apps/web/src/source-plugins/tool-explorer"; | |
| 24 | +import { | |
| 25 | + asMcpRemoteTransportValue, | |
| 26 | + defaultMcpRemoteTransportFields, | |
| 27 | + defaultMcpStdioTransportFields, | |
| 28 | + setMcpTransportFieldsTransport, | |
| 29 | + type McpTransportFields, | |
| 30 | + type McpTransportValue, | |
| 31 | +} from "../../../apps/web/src/views/mcp-transport-state"; | |
| 32 | +import { | |
| 33 | + parseJsonStringArray, | |
| 34 | + parseJsonStringMap, | |
| 35 | +} from "../../../apps/web/src/views/json-form"; | |
| 36 | + | |
| 37 | +type FrontendSourceTypeDefinition = { | |
| 38 | + kind: string; | |
| 39 | + displayName: string; | |
| 40 | + description?: string; | |
| 41 | + renderAddPage: () => ReactNode; | |
| 42 | + renderEditPage?: (input: { source: Source }) => ReactNode; | |
| 43 | + renderDetailPage?: (input: { | |
| 44 | + source: Source; | |
| 45 | + route: { | |
| 46 | + search?: unknown; | |
| 47 | + navigate?: unknown; | |
| 48 | + }; | |
| 49 | + }) => ReactNode; | |
| 50 | +}; | |
| 51 | + | |
| 52 | +type FrontendPluginRegisterApi = { | |
| 53 | + sources: { | |
| 54 | + registerType: (definition: FrontendSourceTypeDefinition) => void; | |
| 55 | + }; | |
| 56 | +}; | |
| 57 | + | |
| 58 | +type RouteToolSearch = { | |
| 59 | + tab?: "model" | "discover"; | |
| 60 | + tool?: string; | |
| 61 | + query?: string; | |
| 62 | +}; | |
| 63 | + | |
| 64 | +const OAUTH_STORAGE_PREFIX = "executor:mcp-oauth:"; | |
| 65 | +const OAUTH_TIMEOUT_MS = 2 * 60_000; | |
| 66 | + | |
| 67 | +const getMcpHttpClient = defineExecutorPluginHttpApiClient<"McpReactHttpClient">()( | |
| 68 | + "McpReactHttpClient", | |
| 69 | + [mcpHttpApiExtension] as const, | |
| 70 | +); | |
| 71 | + | |
| 72 | +const defaultMcpInput = (): McpConnectInput => ({ | |
| 73 | + name: "My MCP Source", | |
| 74 | + endpoint: "https://example.com/mcp", | |
| 75 | + transport: "auto", | |
| 76 | + queryParams: null, | |
| 77 | + headers: null, | |
| 78 | + command: null, | |
| 79 | + args: null, | |
| 80 | + env: null, | |
| 81 | + cwd: null, | |
| 82 | + auth: { | |
| 83 | + kind: "none", | |
| 84 | + }, | |
| 85 | +}); | |
| 86 | + | |
| 87 | +const stringifyStringMap = ( | |
| 88 | + value: Record<string, string> | null | undefined, | |
| 89 | +): string => | |
| 90 | + !value || Object.keys(value).length === 0 | |
| 91 | + ? "" | |
| 92 | + : JSON.stringify(value, null, 2); | |
| 93 | + | |
| 94 | +const stringifyStringArray = ( | |
| 95 | + value: ReadonlyArray<string> | null | undefined, | |
| 96 | +): string => | |
| 97 | + !value || value.length === 0 ? "" : JSON.stringify(value, null, 2); | |
| 98 | + | |
| 99 | +const transportFieldsFromInput = (input: McpConnectInput): McpTransportFields => | |
| 100 | + input.transport === "stdio" || input.command | |
| 101 | + ? defaultMcpStdioTransportFields({ | |
| 102 | + command: input.command ?? "", | |
| 103 | + argsText: stringifyStringArray(input.args), | |
| 104 | + envText: stringifyStringMap(input.env), | |
| 105 | + cwd: input.cwd ?? "", | |
| 106 | + }) | |
| 107 | + : { | |
| 108 | + ...defaultMcpRemoteTransportFields( | |
| 109 | + asMcpRemoteTransportValue(input.transport), | |
| 110 | + ), | |
| 111 | + queryParamsText: stringifyStringMap(input.queryParams), | |
| 112 | + headersText: stringifyStringMap(input.headers), | |
| 113 | + }; | |
| 114 | + | |
| 115 | +const waitForOauthPopupResult = async ( | |
| 116 | + sessionId: string, | |
| 117 | +): Promise<McpOAuthPopupResult> => | |
| 118 | + new Promise((resolve, reject) => { | |
| 119 | + const storageKey = `${OAUTH_STORAGE_PREFIX}${sessionId}`; | |
| 120 | + const startedAt = Date.now(); | |
| 121 | + | |
| 122 | + const cleanup = () => { | |
| 123 | + window.removeEventListener("message", handleMessage); | |
| 124 | + window.clearInterval(intervalId); | |
| 125 | + }; | |
| 126 | + | |
| 127 | + const finish = (result: McpOAuthPopupResult) => { | |
| 128 | + cleanup(); | |
| 129 | + try { | |
| 130 | + window.localStorage.removeItem(storageKey); | |
| 131 | + } catch {} | |
| 132 | + resolve(result); | |
| 133 | + }; | |
| 134 | + | |
| 135 | + const handleMessage = (event: MessageEvent) => { | |
| 136 | + if (event.origin !== window.location.origin) { | |
| 137 | + return; | |
| 138 | + } | |
| 139 | + | |
| 140 | + const data = event.data as McpOAuthPopupResult | undefined; | |
| 141 | + if (!data || data.type !== "executor:oauth-result") { | |
| 142 | + return; | |
| 143 | + } | |
| 144 | + | |
| 145 | + if (data.ok && data.sessionId !== sessionId) { | |
| 146 | + return; | |
| 147 | + } | |
| 148 | + | |
| 149 | + finish(data); | |
| 150 | + }; | |
| 151 | + | |
| 152 | + window.addEventListener("message", handleMessage); | |
| 153 | + const intervalId = window.setInterval(() => { | |
| 154 | + if (Date.now() - startedAt > OAUTH_TIMEOUT_MS) { | |
| 155 | + cleanup(); | |
| 156 | + reject(new Error("Timed out waiting for MCP OAuth to finish.")); | |
| 157 | + return; | |
| 158 | + } | |
| 159 | + | |
| 160 | + try { | |
| 161 | + const raw = window.localStorage.getItem(storageKey); | |
| 162 | + if (!raw) { | |
| 163 | + return; | |
| 164 | + } | |
| 165 | + | |
| 166 | + finish(JSON.parse(raw) as McpOAuthPopupResult); | |
| 167 | + } catch { | |
| 168 | + // Ignore malformed local storage and continue polling. | |
| 169 | + } | |
| 170 | + }, 400); | |
| 171 | + }); | |
| 172 | + | |
| 173 | +const navigateFromPluginRoute = ( | |
| 174 | + navigate: unknown, | |
| 175 | + next: { | |
| 176 | + tab: "model" | "discover"; | |
| 177 | + tool?: string; | |
| 178 | + query?: string; | |
| 179 | + }, | |
| 180 | +) => { | |
| 181 | + const routeNavigate = navigate as | |
| 182 | + | ((input: { | |
| 183 | + search: { | |
| 184 | + tab: "model" | "discover"; | |
| 185 | + tool?: string; | |
| 186 | + query?: string; | |
| 187 | + }; | |
| 188 | + }) => void | Promise<void>) | |
| 189 | + | undefined; | |
| 190 | + if (routeNavigate) { | |
| 191 | + void routeNavigate({ | |
| 192 | + search: next, | |
| 193 | + }); | |
| 194 | + } | |
| 195 | +}; | |
| 196 | + | |
| 197 | +function McpSourceForm(props: { | |
| 198 | + initialValue: McpConnectInput; | |
| 199 | + mode: "create" | "edit"; | |
| 200 | + onSubmit: (input: McpConnectInput) => Promise<void>; | |
| 201 | +}) { | |
| 202 | + const installation = useLocalInstallation(); | |
| 203 | + const client = getMcpHttpClient(); | |
| 204 | + const startOAuth = useAtomSet( | |
| 205 | + client.mutation("mcp", "startOAuth"), | |
| 206 | + { mode: "promise" }, | |
| 207 | + ); | |
| 208 | + const submitMutation = useExecutorMutation<McpConnectInput, void>(props.onSubmit); | |
| 209 | + const [name, setName] = useState(props.initialValue.name); | |
| 210 | + const [endpoint, setEndpoint] = useState(props.initialValue.endpoint ?? ""); | |
| 211 | + const [transportFields, setTransportFields] = useState<McpTransportFields>( | |
| 212 | + transportFieldsFromInput(props.initialValue), | |
| 213 | + ); | |
| 214 | + const [authKind, setAuthKind] = useState<McpConnectionAuth["kind"]>( | |
| 215 | + props.initialValue.auth.kind, | |
| 216 | + ); | |
| 217 | + const [oauthAuth, setOauthAuth] = useState< | |
| 218 | + Extract<McpConnectionAuth, { kind: "oauth2" }> | null | |
| 219 | + >( | |
| 220 | + props.initialValue.auth.kind === "oauth2" | |
| 221 | + ? props.initialValue.auth | |
| 222 | + : null, | |
| 223 | + ); | |
| 224 | + const [oauthStatus, setOauthStatus] = useState<"idle" | "pending" | "connected">( | |
| 225 | + props.initialValue.auth.kind === "oauth2" ? "connected" : "idle", | |
| 226 | + ); | |
| 227 | + const [error, setError] = useState<string | null>(null); | |
| 228 | + | |
| 229 | + const isStdio = transportFields.transport === "stdio"; | |
| 230 | + | |
| 231 | + const runOauth = async () => { | |
| 232 | + if (installation.status !== "ready") { | |
| 233 | + throw new Error("Workspace is still loading."); | |
| 234 | + } | |
| 235 | + if (isStdio) { | |
| 236 | + throw new Error("MCP OAuth is only available for remote MCP transports."); | |
| 237 | + } | |
| 238 | + | |
| 239 | + const payload: McpStartOAuthInput = { | |
| 240 | + endpoint: endpoint.trim(), | |
| 241 | + queryParams: parseJsonStringMap("Query params", transportFields.queryParamsText), | |
| 242 | + redirectUrl: new URL( | |
| 243 | + "/v1/plugins/mcp/oauth/callback", | |
| 244 | + window.location.origin, | |
| 245 | + ).toString(), | |
| 246 | + }; | |
| 247 | + | |
| 248 | + const started = await startOAuth({ | |
| 249 | + path: { | |
| 250 | + workspaceId: installation.data.scopeId, | |
| 251 | + }, | |
| 252 | + payload, | |
| 253 | + }); | |
| 254 | + | |
| 255 | + const popup = window.open( | |
| 256 | + started.authorizationUrl, | |
| 257 | + "executor-mcp-oauth", | |
| 258 | + "width=560,height=760,noopener,noreferrer", | |
| 259 | + ); | |
| 260 | + if (!popup) { | |
| 261 | + throw new Error("Failed opening MCP OAuth popup."); | |
| 262 | + } | |
| 263 | + | |
| 264 | + const result = await waitForOauthPopupResult(started.sessionId); | |
| 265 | + if (!result.ok) { | |
| 266 | + throw new Error(result.error); | |
| 267 | + } | |
| 268 | + | |
| 269 | + setOauthAuth(result.auth); | |
| 270 | + setOauthStatus("connected"); | |
| 271 | + }; | |
| 272 | + | |
| 273 | + const buildInput = (): McpConnectInput => ({ | |
| 274 | + name: name.trim(), | |
| 275 | + endpoint: isStdio ? null : (endpoint.trim() || null), | |
| 276 | + transport: | |
| 277 | + transportFields.transport === "" | |
| 278 | + ? null | |
| 279 | + : transportFields.transport, | |
| 280 | + queryParams: isStdio | |
| 281 | + ? null | |
| 282 | + : parseJsonStringMap("Query params", transportFields.queryParamsText), | |
| 283 | + headers: isStdio | |
| 284 | + ? null | |
| 285 | + : parseJsonStringMap("Headers", transportFields.headersText), | |
| 286 | + command: isStdio ? transportFields.command.trim() || null : null, | |
| 287 | + args: isStdio | |
| 288 | + ? parseJsonStringArray("Args", transportFields.argsText) | |
| 289 | + : null, | |
| 290 | + env: isStdio | |
| 291 | + ? parseJsonStringMap("Environment", transportFields.envText) | |
| 292 | + : null, | |
| 293 | + cwd: isStdio ? transportFields.cwd.trim() || null : null, | |
| 294 | + auth: | |
| 295 | + authKind === "oauth2" | |
| 296 | + ? oauthAuth ?? (() => { | |
| 297 | + throw new Error("Complete MCP OAuth before saving."); | |
| 298 | + })() | |
| 299 | + : { kind: "none" }, | |
| 300 | + }); | |
| 301 | + | |
| 302 | + return ( | |
| 303 | + <div className="space-y-4 rounded-2xl border border-border bg-card p-5"> | |
| 304 | + <div> | |
| 305 | + <h2 className="text-lg font-semibold text-foreground"> | |
| 306 | + {props.mode === "create" ? "Connect MCP Source" : "Edit MCP Source"} | |
| 307 | + </h2> | |
| 308 | + <p className="mt-1 text-sm text-muted-foreground"> | |
| 309 | + MCP now owns its transport config, OAuth flow, and tool surface inside the plugin. | |
| 310 | + </p> | |
| 311 | + </div> | |
| 312 | + | |
| 313 | + <label className="grid gap-1.5"> | |
| 314 | + <span className="text-xs font-medium text-foreground">Name</span> | |
| 315 | + <input | |
| 316 | + value={name} | |
| 317 | + onChange={(event) => setName(event.target.value)} | |
| 318 | + className="h-10 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | |
| 319 | + /> | |
| 320 | + </label> | |
| 321 | + | |
| 322 | + <label className="grid gap-1.5"> | |
| 323 | + <span className="text-xs font-medium text-foreground">Transport</span> | |
| 324 | + <select | |
| 325 | + value={transportFields.transport} | |
| 326 | + onChange={(event) => { | |
| 327 | + const nextTransport = event.target.value as McpTransportValue; | |
| 328 | + setTransportFields((current) => | |
| 329 | + setMcpTransportFieldsTransport(current, nextTransport) | |
| 330 | + ); | |
| 331 | + if (nextTransport === "stdio") { | |
| 332 | + setAuthKind("none"); | |
| 333 | + setOauthAuth(null); | |
| 334 | + setOauthStatus("idle"); | |
| 335 | + } | |
| 336 | + }} | |
| 337 | + className="h-10 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | |
| 338 | + > | |
| 339 | + <option value="">Auto (remote)</option> | |
| 340 | + <option value="auto">Auto</option> | |
| 341 | + <option value="streamable-http">Streamable HTTP</option> | |
| 342 | + <option value="sse">SSE</option> | |
| 343 | + <option value="stdio">stdio</option> | |
| 344 | + </select> | |
| 345 | + </label> | |
| 346 | + | |
| 347 | + {isStdio ? ( | |
| 348 | + <div className="grid gap-4 md:grid-cols-2"> | |
| 349 | + <label className="grid gap-1.5 md:col-span-2"> | |
| 350 | + <span className="text-xs font-medium text-foreground">Command</span> | |
| 351 | + <input | |
| 352 | + value={transportFields.command} | |
| 353 | + onChange={(event) => | |
| 354 | + setTransportFields({ | |
| 355 | + ...transportFields, | |
| 356 | + command: event.target.value, | |
| 357 | + })} | |
| 358 | + className="h-10 rounded-lg border border-input bg-background px-3 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | |
| 359 | + /> | |
| 360 | + </label> | |
| 361 | + | |
| 362 | + <label className="grid gap-1.5"> | |
| 363 | + <span className="text-xs font-medium text-foreground">Args</span> | |
| 364 | + <textarea | |
| 365 | + value={transportFields.argsText} | |
| 366 | + onChange={(event) => | |
| 367 | + setTransportFields({ | |
| 368 | + ...transportFields, | |
| 369 | + argsText: event.target.value, | |
| 370 | + })} | |
| 371 | + rows={4} | |
| 372 | + placeholder='["server.js","--port","8787"]' | |
| 373 | + className="rounded-lg border border-input bg-background px-3 py-2 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | |
| 374 | + /> | |
| 375 | + </label> | |
| 376 | + | |
| 377 | + <label className="grid gap-1.5"> | |
| 378 | + <span className="text-xs font-medium text-foreground">Environment</span> | |
| 379 | + <textarea | |
| 380 | + value={transportFields.envText} | |
| 381 | + onChange={(event) => | |
| 382 | + setTransportFields({ | |
| 383 | + ...transportFields, | |
| 384 | + envText: event.target.value, | |
| 385 | + })} | |
| 386 | + rows={4} | |
| 387 | + placeholder='{"NODE_ENV":"production"}' | |
| 388 | + className="rounded-lg border border-input bg-background px-3 py-2 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | |
| 389 | + /> | |
| 390 | + </label> | |
| 391 | + | |
| 392 | + <label className="grid gap-1.5 md:col-span-2"> | |
| 393 | + <span className="text-xs font-medium text-foreground">Working Directory</span> | |
| 394 | + <input | |
| 395 | + value={transportFields.cwd} | |
| 396 | + onChange={(event) => | |
| 397 | + setTransportFields({ | |
| 398 | + ...transportFields, | |
| 399 | + cwd: event.target.value, | |
| 400 | + })} | |
| 401 | + className="h-10 rounded-lg border border-input bg-background px-3 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | |
| 402 | + /> | |
| 403 | + </label> | |
| 404 | + </div> | |
| 405 | + ) : ( | |
| 406 | + <div className="grid gap-4 md:grid-cols-2"> | |
| 407 | + <label className="grid gap-1.5 md:col-span-2"> | |
| 408 | + <span className="text-xs font-medium text-foreground">Endpoint</span> | |
| 409 | + <input | |
| 410 | + value={endpoint} | |
| 411 | + onChange={(event) => setEndpoint(event.target.value)} | |
| 412 | + className="h-10 rounded-lg border border-input bg-background px-3 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | |
| 413 | + /> | |
| 414 | + </label> | |
| 415 | + | |
| 416 | + <label className="grid gap-1.5"> | |
| 417 | + <span className="text-xs font-medium text-foreground">Query Params</span> | |
| 418 | + <textarea | |
| 419 | + value={transportFields.queryParamsText} | |
| 420 | + onChange={(event) => | |
| 421 | + setTransportFields({ | |
| 422 | + ...transportFields, | |
| 423 | + queryParamsText: event.target.value, | |
| 424 | + })} | |
| 425 | + rows={4} | |
| 426 | + placeholder='{"transport":"streamable-http"}' | |
| 427 | + className="rounded-lg border border-input bg-background px-3 py-2 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | |
| 428 | + /> | |
| 429 | + </label> | |
| 430 | + | |
| 431 | + <label className="grid gap-1.5"> | |
| 432 | + <span className="text-xs font-medium text-foreground">Headers</span> | |
| 433 | + <textarea | |
| 434 | + value={transportFields.headersText} | |
| 435 | + onChange={(event) => | |
| 436 | + setTransportFields({ | |
| 437 | + ...transportFields, | |
| 438 | + headersText: event.target.value, | |
| 439 | + })} | |
| 440 | + rows={4} | |
| 441 | + placeholder='{"x-api-key":"..."}' | |
| 442 | + className="rounded-lg border border-input bg-background px-3 py-2 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | |
| 443 | + /> | |
| 444 | + </label> | |
| 445 | + </div> | |
| 446 | + )} | |
| 447 | + | |
| 448 | + {!isStdio && ( | |
| 449 | + <label className="grid gap-1.5"> | |
| 450 | + <span className="text-xs font-medium text-foreground">Auth</span> | |
| 451 | + <select | |
| 452 | + value={authKind} | |
| 453 | + onChange={(event) => { | |
| 454 | + const nextKind = event.target.value as McpConnectionAuth["kind"]; | |
| 455 | + setAuthKind(nextKind); | |
| 456 | + if (nextKind !== "oauth2") { | |
| 457 | + setOauthAuth(null); | |
| 458 | + setOauthStatus("idle"); | |
| 459 | + } | |
| 460 | + }} | |
| 461 | + className="h-10 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" | |
| 462 | + > | |
| 463 | + <option value="none">None</option> | |
| 464 | + <option value="oauth2">OAuth 2.0</option> | |
| 465 | + </select> | |
| 466 | + </label> | |
| 467 | + )} | |
| 468 | + | |
| 469 | + {!isStdio && authKind === "oauth2" && ( | |
| 470 | + <div className="rounded-xl border border-border/70 bg-muted/40 p-4"> | |
| 471 | + <div className="flex flex-wrap items-center justify-between gap-3"> | |
| 472 | + <div> | |
| 473 | + <div className="text-sm font-medium text-foreground"> | |
| 474 | + MCP OAuth | |
| 475 | + </div> | |
| 476 | + <div className="mt-1 text-xs text-muted-foreground"> | |
| 477 | + Authenticate directly against the MCP server and keep the token refs in plugin storage. | |
| 478 | + </div> | |
| 479 | + </div> | |
| 480 | + <button | |
| 481 | + type="button" | |
| 482 | + onClick={() => { | |
| 483 | + setError(null); | |
| 484 | + setOauthStatus("pending"); | |
| 485 | + void runOauth() | |
| 486 | + .catch((cause) => { | |
| 487 | + setOauthStatus("idle"); | |
| 488 | + setError( | |
| 489 | + cause instanceof Error ? cause.message : String(cause), | |
| 490 | + ); | |
| 491 | + }); | |
| 492 | + }} | |
| 493 | + className="inline-flex h-9 items-center rounded-lg border border-input bg-card px-3 text-sm font-medium text-foreground transition-colors hover:bg-accent/50" | |
| 494 | + > | |
| 495 | + {oauthStatus === "connected" ? "Reconnect OAuth" : "Connect OAuth"} | |
| 496 | + </button> | |
| 497 | + </div> | |
| 498 | + <div className="mt-3 text-xs text-muted-foreground"> | |
| 499 | + Status: <span className="text-foreground">{oauthStatus}</span> | |
| 500 | + </div> | |
| 501 | + </div> | |
| 502 | + )} | |
| 503 | + | |
| 504 | + {error && ( | |
| 505 | + <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-3 text-sm text-destructive"> | |
| 506 | + {error} | |
| 507 | + </div> | |
| 508 | + )} | |
| 509 | + | |
| 510 | + <div className="flex items-center justify-end gap-3"> | |
| 511 | + <button | |
| 512 | + type="button" | |
| 513 | + onClick={() => { | |
| 514 | + setError(null); | |
| 515 | + void submitMutation | |
| 516 | + .mutateAsync(buildInput()) | |
| 517 | + .catch((cause: unknown) => | |
| 518 | + setError(cause instanceof Error ? cause.message : String(cause)) | |
| 519 | + ); | |
| 520 | + }} | |
| 521 | + disabled={submitMutation.status === "pending"} | |
| 522 | + className="inline-flex h-10 items-center rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-60" | |
| 523 | + > | |
| 524 | + {submitMutation.status === "pending" | |
| 525 | + ? props.mode === "create" | |
| 526 | + ? "Creating..." | |
| 527 | + : "Saving..." | |
| 528 | + : props.mode === "create" | |
| 529 | + ? "Create Source" | |
| 530 | + : "Save Changes"} | |
| 531 | + </button> | |
| 532 | + </div> | |
| 533 | + </div> | |
| 534 | + ); | |
| 535 | +} | |
| 536 | + | |
| 537 | +function McpAddPage() { | |
| 538 | + const navigate = useNavigate(); | |
| 539 | + const installation = useLocalInstallation(); | |
| 540 | + const client = getMcpHttpClient(); | |
| 541 | + const createSource = useAtomSet( | |
| 542 | + client.mutation("mcp", "createSource"), | |
| 543 | + { mode: "promise" }, | |
| 544 | + ); | |
| 545 | + | |
| 546 | + if (installation.status !== "ready") { | |
| 547 | + return <div className="text-sm text-muted-foreground">Loading workspace...</div>; | |
| 548 | + } | |
| 549 | + | |
| 550 | + return ( | |
| 551 | + <McpSourceForm | |
| 552 | + initialValue={defaultMcpInput()} | |
| 553 | + mode="create" | |
| 554 | + onSubmit={async (input) => { | |
| 555 | + const source = await createSource({ | |
| 556 | + path: { | |
| 557 | + workspaceId: installation.data.scopeId, | |
| 558 | + }, | |
| 559 | + payload: input, | |
| 560 | + reactivityKeys: { | |
| 561 | + sources: [installation.data.scopeId], | |
| 562 | + }, | |
| 563 | + }); | |
| 564 | + | |
| 565 | + startTransition(() => { | |
| 566 | + void navigate({ | |
| 567 | + to: "/sources/$sourceId", | |
| 568 | + params: { | |
| 569 | + sourceId: source.id, | |
| 570 | + }, | |
| 571 | + search: { | |
| 572 | + tab: "model", | |
| 573 | + }, | |
| 574 | + }); | |
| 575 | + }); | |
| 576 | + }} | |
| 577 | + /> | |
| 578 | + ); | |
| 579 | +} | |
| 580 | + | |
| 581 | +function McpEditPage(props: { | |
| 582 | + source: Source; | |
| 583 | +}) { | |
| 584 | + const navigate = useNavigate(); | |
| 585 | + const installation = useLocalInstallation(); | |
| 586 | + const client = getMcpHttpClient(); | |
| 587 | + const configResult = useAtomValue( | |
| 588 | + installation.status === "ready" | |
| 589 | + ? client.query("mcp", "getSourceConfig", { | |
| 590 | + path: { | |
| 591 | + workspaceId: installation.data.scopeId, | |
| 592 | + sourceId: props.source.id, | |
| 593 | + }, | |
| 594 | + reactivityKeys: { | |
| 595 | + source: [installation.data.scopeId, props.source.id], | |
| 596 | + }, | |
| 597 | + timeToLive: "30 seconds", | |
| 598 | + }) | |
| 599 | + : client.query("local", "installation", { | |
| 600 | + timeToLive: "1 second", | |
| 601 | + }) as never, | |
| 602 | + ); | |
| 603 | + const updateSource = useAtomSet( | |
| 604 | + client.mutation("mcp", "updateSource"), | |
| 605 | + { mode: "promise" }, | |
| 606 | + ); | |
| 607 | + | |
| 608 | + if (installation.status !== "ready") { | |
| 609 | + return <div className="text-sm text-muted-foreground">Loading workspace...</div>; | |
| 610 | + } | |
| 611 | + | |
| 612 | + if (Result.isFailure(configResult)) { | |
| 613 | + return ( | |
| 614 | + <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-3 text-sm text-destructive"> | |
| 615 | + Failed loading MCP config. | |
| 616 | + </div> | |
| 617 | + ); | |
| 618 | + } | |
| 619 | + | |
| 620 | + if (!Result.isSuccess(configResult)) { | |
| 621 | + return <div className="text-sm text-muted-foreground">Loading MCP config...</div>; | |
| 622 | + } | |
| 623 | + | |
| 624 | + return ( | |
| 625 | + <McpSourceForm | |
| 626 | + initialValue={configResult.value} | |
| 627 | + mode="edit" | |
| 628 | + onSubmit={async (input) => { | |
| 629 | + const source = await updateSource({ | |
| 630 | + path: { | |
| 631 | + workspaceId: installation.data.scopeId, | |
| 632 | + sourceId: props.source.id, | |
| 633 | + }, | |
| 634 | + payload: input, | |
| 635 | + reactivityKeys: { | |
| 636 | + sources: [installation.data.scopeId], | |
| 637 | + source: [installation.data.scopeId, props.source.id], | |
| 638 | + sourceInspection: [installation.data.scopeId, props.source.id], | |
| 639 | + sourceInspectionTool: [installation.data.scopeId, props.source.id], | |
| 640 | + sourceDiscovery: [installation.data.scopeId, props.source.id], | |
| 641 | + }, | |
| 642 | + }); | |
| 643 | + | |
| 644 | + startTransition(() => { | |
| 645 | + void navigate({ | |
| 646 | + to: "/sources/$sourceId", | |
| 647 | + params: { | |
| 648 | + sourceId: source.id, | |
| 649 | + }, | |
| 650 | + search: { | |
| 651 | + tab: "model", | |
| 652 | + }, | |
| 653 | + }); | |
| 654 | + }); | |
| 655 | + }} | |
| 656 | + /> | |
| 657 | + ); | |
| 658 | +} | |
| 659 | + | |
| 660 | +function McpDetailPage(props: { | |
| 661 | + source: Source; | |
| 662 | + route: { | |
| 663 | + search?: unknown; | |
| 664 | + navigate?: unknown; | |
| 665 | + }; | |
| 666 | +}) { | |
| 667 | + const navigate = useNavigate(); | |
| 668 | + const installation = useLocalInstallation(); | |
| 669 | + const client = getMcpHttpClient(); | |
| 670 | + const removeSource = useAtomSet( | |
| 671 | + client.mutation("mcp", "removeSource"), | |
| 672 | + { mode: "promise" }, | |
| 673 | + ); | |
| 674 | + const configResult = useAtomValue( | |
| 675 | + installation.status === "ready" | |
| 676 | + ? client.query("mcp", "getSourceConfig", { | |
| 677 | + path: { | |
| 678 | + workspaceId: installation.data.scopeId, | |
| 679 | + sourceId: props.source.id, | |
| 680 | + }, | |
| 681 | + reactivityKeys: { | |
| 682 | + source: [installation.data.scopeId, props.source.id], | |
| 683 | + }, | |
| 684 | + timeToLive: "30 seconds", | |
| 685 | + }) | |
| 686 | + : client.query("local", "installation", { | |
| 687 | + timeToLive: "1 second", | |
| 688 | + }) as never, | |
| 689 | + ); | |
| 690 | + const summary = useMemo(() => { | |
| 691 | + if (!Result.isSuccess(configResult)) { | |
| 692 | + return null; | |
| 693 | + } | |
| 694 | + | |
| 695 | + const config = configResult.value; | |
| 696 | + const location = config.transport === "stdio" | |
| 697 | + ? config.command ?? "stdio" | |
| 698 | + : config.endpoint ?? "remote"; | |
| 699 | + | |
| 700 | + return ( | |
| 701 | + <div className="space-y-1"> | |
| 702 | + <div className="font-mono text-xs text-foreground">{location}</div> | |
| 703 | + <div> | |
| 704 | + Transport: <span className="text-foreground">{config.transport ?? "auto"}</span> | |
| 705 | + </div> | |
| 706 | + <div> | |
| 707 | + Auth: <span className="text-foreground">{config.auth.kind}</span> | |
| 708 | + </div> | |
| 709 | + </div> | |
| 710 | + ); | |
| 711 | + }, [configResult]); | |
| 712 | + | |
| 713 | + if (installation.status !== "ready") { | |
| 714 | + return <div className="text-sm text-muted-foreground">Loading workspace...</div>; | |
| 715 | + } | |
| 716 | + | |
| 717 | + return ( | |
| 718 | + <SourceToolExplorer | |
| 719 | + sourceId={props.source.id} | |
| 720 | + title={props.source.name} | |
| 721 | + kind={props.source.kind} | |
| 722 | + search={(props.route.search ?? {}) as RouteToolSearch} | |
| 723 | + navigate={(next) => navigateFromPluginRoute(props.route.navigate, next)} | |
| 724 | + summary={summary} | |
| 725 | + actions={( | |
| 726 | + <> | |
| 727 | + <button | |
| 728 | + type="button" | |
| 729 | + onClick={() => | |
| 730 | + void navigate({ | |
| 731 | + to: "/sources/$sourceId/edit", | |
| 732 | + params: { | |
| 733 | + sourceId: props.source.id, | |
| 734 | + }, | |
| 735 | + })} | |
| 736 | + className="inline-flex h-9 items-center gap-1.5 rounded-lg border border-input bg-card px-3 text-sm font-medium text-foreground transition-colors hover:bg-accent/50" | |
| 737 | + > | |
| 738 | + <IconPencil className="size-3.5" /> | |
| 739 | + Edit | |
| 740 | + </button> | |
| 741 | + <button | |
| 742 | + type="button" | |
| 743 | + onClick={() => { | |
| 744 | + const confirmed = window.confirm(`Delete MCP source "${props.source.name}"?`); | |
| 745 | + if (!confirmed) { | |
| 746 | + return; | |
| 747 | + } | |
| 748 | + | |
| 749 | + void removeSource({ | |
| 750 | + path: { | |
| 751 | + workspaceId: installation.data.scopeId, | |
| 752 | + sourceId: props.source.id, | |
| 753 | + }, | |
| 754 | + reactivityKeys: { | |
| 755 | + sources: [installation.data.scopeId], | |
| 756 | + source: [installation.data.scopeId, props.source.id], | |
| 757 | + sourceInspection: [installation.data.scopeId, props.source.id], | |
| 758 | + sourceInspectionTool: [installation.data.scopeId, props.source.id], | |
| 759 | + sourceDiscovery: [installation.data.scopeId, props.source.id], | |
| 760 | + }, | |
| 761 | + }).then(() => { | |
| 762 | + startTransition(() => { | |
| 763 | + void navigate({ | |
| 764 | + to: "/", | |
| 765 | + }); | |
| 766 | + }); | |
| 767 | + }); | |
| 768 | + }} | |
| 769 | + className="inline-flex h-9 items-center rounded-lg border border-destructive/25 bg-destructive/5 px-3 text-sm font-medium text-destructive transition-colors hover:bg-destructive/10" | |
| 770 | + > | |
| 771 | + Delete | |
| 772 | + </button> | |
| 773 | + </> | |
| 774 | + )} | |
| 775 | + /> | |
| 776 | + ); | |
| 777 | +} | |
| 778 | + | |
| 779 | +export const McpReactPlugin = { | |
| 780 | + key: "mcp", | |
| 781 | + register(api: FrontendPluginRegisterApi) { | |
| 782 | + api.sources.registerType({ | |
| 783 | + kind: "mcp", | |
| 784 | + displayName: "MCP", | |
| 785 | + description: "Connect remote or local MCP servers with plugin-owned OAuth.", | |
| 786 | + renderAddPage: () => <McpAddPage />, | |
| 787 | + renderEditPage: ({ source }) => <McpEditPage source={source} />, | |
| 788 | + renderDetailPage: ({ source, route }) => ( | |
| 789 | + <McpDetailPage source={source} route={route} /> | |
| 790 | + ), | |
| 791 | + }); | |
| 792 | + }, | |
| 793 | +}; | |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -0,0 +1,285 @@ | ||
| 1 | +import { createCatalogSnapshotV1FromFragments } from "@executor/ir/catalog"; | |
| 2 | +import { | |
| 3 | + CapabilityIdSchema, | |
| 4 | + DocumentIdSchema, | |
| 5 | + ExecutableIdSchema, | |
| 6 | + ResponseSymbolIdSchema, | |
| 7 | + ScopeIdSchema, | |
| 8 | +} from "@executor/ir/ids"; | |
| 9 | +import type { | |
| 10 | + Capability, | |
| 11 | + CatalogSnapshotV1, | |
| 12 | + ResponseSymbol, | |
| 13 | + Executable, | |
| 14 | +} from "@executor/ir/model"; | |
| 15 | +import { | |
| 16 | + type BaseCatalogOperationInput, | |
| 17 | + type CatalogFragmentBuilder, | |
| 18 | + type CatalogSourceDocumentInput, | |
| 19 | + type JsonSchemaImporter, | |
| 20 | + type Source, | |
| 21 | + EXECUTABLE_BINDING_VERSION, | |
| 22 | + buildCatalogFragment, | |
| 23 | + createCatalogImportMetadata, | |
| 24 | + docsFrom, | |
| 25 | + interactionForEffect, | |
| 26 | + isObjectLikeJsonSchema, | |
| 27 | + mutableRecord, | |
| 28 | + provenanceFor, | |
| 29 | + responseSetFromSingleResponse, | |
| 30 | + schemaWithMergedDefs, | |
| 31 | + stableHash, | |
| 32 | + toolPathSegments, | |
| 33 | +} from "@executor/source-core"; | |
| 34 | + | |
| 35 | +import type { | |
| 36 | + McpServerMetadata, | |
| 37 | + McpToolAnnotations, | |
| 38 | + McpToolExecution, | |
| 39 | +} from "./manifest"; | |
| 40 | + | |
| 41 | +export type McpCatalogOperationInput = BaseCatalogOperationInput & { | |
| 42 | + providerData: { | |
| 43 | + toolId: string; | |
| 44 | + toolName: string; | |
| 45 | + displayTitle: string; | |
| 46 | + title: string | null; | |
| 47 | + description: string | null; | |
| 48 | + annotations: McpToolAnnotations | null; | |
| 49 | + execution: McpToolExecution | null; | |
| 50 | + icons: unknown[] | null; | |
| 51 | + meta: unknown; | |
| 52 | + rawTool: unknown; | |
| 53 | + server: McpServerMetadata | null; | |
| 54 | + }; | |
| 55 | +}; | |
| 56 | + | |
| 57 | +const mcpResumeSupport = ( | |
| 58 | + execution: McpCatalogOperationInput["providerData"]["execution"], | |
| 59 | +): boolean => | |
| 60 | + execution?.taskSupport === "optional" || | |
| 61 | + execution?.taskSupport === "required"; | |
| 62 | + | |
| 63 | +const mcpSemanticsForOperation = (input: { | |
| 64 | + effect: McpCatalogOperationInput["effect"]; | |
| 65 | + annotations: McpCatalogOperationInput["providerData"]["annotations"]; | |
| 66 | +}): Capability["semantics"] => { | |
| 67 | + const safe = input.effect === "read"; | |
| 68 | + | |
| 69 | + return { | |
| 70 | + effect: input.effect, | |
| 71 | + safe, | |
| 72 | + idempotent: safe || input.annotations?.idempotentHint === true, | |
| 73 | + destructive: safe ? false : input.annotations?.destructiveHint !== false, | |
| 74 | + }; | |
| 75 | +}; | |
| 76 | + | |
| 77 | +const createMcpCapability = (input: { | |
| 78 | + catalog: CatalogFragmentBuilder; | |
| 79 | + source: Pick<Source, "id" | "name" | "namespace">; | |
| 80 | + documentId: ReturnType<typeof DocumentIdSchema.make>; | |
| 81 | + serviceScopeId: ReturnType<typeof ScopeIdSchema.make>; | |
| 82 | + operation: McpCatalogOperationInput; | |
| 83 | + importer: JsonSchemaImporter; | |
| 84 | +}) => { | |
| 85 | + const toolPath = toolPathSegments( | |
| 86 | + input.source, | |
| 87 | + input.operation.providerData.toolId, | |
| 88 | + ); | |
| 89 | + const capabilityId = CapabilityIdSchema.make( | |
| 90 | + `cap_${stableHash({ | |
| 91 | + sourceId: input.source.id, | |
| 92 | + toolId: input.operation.providerData.toolId, | |
| 93 | + })}`, | |
| 94 | + ); | |
| 95 | + const executableId = ExecutableIdSchema.make( | |
| 96 | + `exec_${stableHash({ | |
| 97 | + sourceId: input.source.id, | |
| 98 | + toolId: input.operation.providerData.toolId, | |
| 99 | + protocol: "mcp", | |
| 100 | + })}`, | |
| 101 | + ); | |
| 102 | + const outputShapeId = | |
| 103 | + input.operation.outputSchema !== undefined | |
| 104 | + ? input.importer.importSchema( | |
| 105 | + input.operation.outputSchema, | |
| 106 | + `#/mcp/${input.operation.providerData.toolId}/output`, | |
| 107 | + ) | |
| 108 | + : undefined; | |
| 109 | + const callShapeId = | |
| 110 | + input.operation.inputSchema === undefined | |
| 111 | + ? input.importer.importSchema( | |
| 112 | + { | |
| 113 | + type: "object", | |
| 114 | + properties: {}, | |
| 115 | + additionalProperties: false, | |
| 116 | + }, | |
| 117 | + `#/mcp/${input.operation.providerData.toolId}/call`, | |
| 118 | + ) | |
| 119 | + : isObjectLikeJsonSchema(input.operation.inputSchema) | |
| 120 | + ? input.importer.importSchema( | |
| 121 | + input.operation.inputSchema, | |
| 122 | + `#/mcp/${input.operation.providerData.toolId}/call`, | |
| 123 | + input.operation.inputSchema, | |
| 124 | + ) | |
| 125 | + : input.importer.importSchema( | |
| 126 | + schemaWithMergedDefs( | |
| 127 | + { | |
| 128 | + type: "object", | |
| 129 | + properties: { | |
| 130 | + input: input.operation.inputSchema, | |
| 131 | + }, | |
| 132 | + required: ["input"], | |
| 133 | + additionalProperties: false, | |
| 134 | + }, | |
| 135 | + input.operation.inputSchema, | |
| 136 | + ), | |
| 137 | + `#/mcp/${input.operation.providerData.toolId}/call`, | |
| 138 | + ); | |
| 139 | + const resultStatusShapeId = input.importer.importSchema( | |
| 140 | + { type: "null" }, | |
| 141 | + `#/mcp/${input.operation.providerData.toolId}/status`, | |
| 142 | + ); | |
| 143 | + | |
| 144 | + const responseId = ResponseSymbolIdSchema.make( | |
| 145 | + `response_${stableHash({ capabilityId })}`, | |
| 146 | + ); | |
| 147 | + mutableRecord(input.catalog.symbols)[responseId] = { | |
| 148 | + id: responseId, | |
| 149 | + kind: "response", | |
| 150 | + ...(docsFrom({ | |
| 151 | + description: | |
| 152 | + input.operation.providerData.description ?? input.operation.description, | |
| 153 | + }) | |
| 154 | + ? { | |
| 155 | + docs: docsFrom({ | |
| 156 | + description: | |
| 157 | + input.operation.providerData.description ?? | |
| 158 | + input.operation.description, | |
| 159 | + })!, | |
| 160 | + } | |
| 161 | + : {}), | |
| 162 | + ...(outputShapeId | |
| 163 | + ? { | |
| 164 | + contents: [ | |
| 165 | + { | |
| 166 | + mediaType: "application/json", | |
| 167 | + shapeId: outputShapeId, | |
| 168 | + }, | |
| 169 | + ], | |
| 170 | + } | |
| 171 | + : {}), | |
| 172 | + synthetic: false, | |
| 173 | + provenance: provenanceFor( | |
| 174 | + input.documentId, | |
| 175 | + `#/mcp/${input.operation.providerData.toolId}/response`, | |
| 176 | + ), | |
| 177 | + } satisfies ResponseSymbol; | |
| 178 | + const responseSetId = responseSetFromSingleResponse({ | |
| 179 | + catalog: input.catalog, | |
| 180 | + responseId, | |
| 181 | + provenance: provenanceFor( | |
| 182 | + input.documentId, | |
| 183 | + `#/mcp/${input.operation.providerData.toolId}/responseSet`, | |
| 184 | + ), | |
| 185 | + }); | |
| 186 | + | |
| 187 | + mutableRecord(input.catalog.executables)[executableId] = { | |
| 188 | + id: executableId, | |
| 189 | + capabilityId, | |
| 190 | + scopeId: input.serviceScopeId, | |
| 191 | + pluginKey: "mcp", | |
| 192 | + bindingVersion: EXECUTABLE_BINDING_VERSION, | |
| 193 | + binding: input.operation.providerData, | |
| 194 | + projection: { | |
| 195 | + responseSetId, | |
| 196 | + callShapeId, | |
| 197 | + ...(outputShapeId ? { resultDataShapeId: outputShapeId } : {}), | |
| 198 | + resultStatusShapeId, | |
| 199 | + }, | |
| 200 | + display: { | |
| 201 | + protocol: "mcp", | |
| 202 | + method: null, | |
| 203 | + pathTemplate: null, | |
| 204 | + operationId: input.operation.providerData.toolName, | |
| 205 | + group: null, | |
| 206 | + leaf: input.operation.providerData.toolName, | |
| 207 | + rawToolId: input.operation.providerData.toolId, | |
| 208 | + title: input.operation.providerData.displayTitle, | |
| 209 | + summary: | |
| 210 | + input.operation.providerData.description ?? | |
| 211 | + input.operation.description ?? | |
| 212 | + null, | |
| 213 | + }, | |
| 214 | + synthetic: false, | |
| 215 | + provenance: provenanceFor( | |
| 216 | + input.documentId, | |
| 217 | + `#/mcp/${input.operation.providerData.toolId}/executable`, | |
| 218 | + ), | |
| 219 | + } satisfies Executable; | |
| 220 | + | |
| 221 | + const interaction = interactionForEffect(input.operation.effect); | |
| 222 | + mutableRecord(input.catalog.capabilities)[capabilityId] = { | |
| 223 | + id: capabilityId, | |
| 224 | + serviceScopeId: input.serviceScopeId, | |
| 225 | + surface: { | |
| 226 | + toolPath, | |
| 227 | + title: input.operation.providerData.displayTitle, | |
| 228 | + ...(input.operation.providerData.description | |
| 229 | + ? { summary: input.operation.providerData.description } | |
| 230 | + : {}), | |
| 231 | + }, | |
| 232 | + semantics: mcpSemanticsForOperation({ | |
| 233 | + effect: input.operation.effect, | |
| 234 | + annotations: input.operation.providerData.annotations, | |
| 235 | + }), | |
| 236 | + auth: { kind: "none" }, | |
| 237 | + interaction: { | |
| 238 | + ...interaction, | |
| 239 | + resume: { | |
| 240 | + supported: mcpResumeSupport(input.operation.providerData.execution), | |
| 241 | + }, | |
| 242 | + }, | |
| 243 | + executableIds: [executableId], | |
| 244 | + synthetic: false, | |
| 245 | + provenance: provenanceFor( | |
| 246 | + input.documentId, | |
| 247 | + `#/mcp/${input.operation.providerData.toolId}/capability`, | |
| 248 | + ), | |
| 249 | + } satisfies Capability; | |
| 250 | +}; | |
| 251 | + | |
| 252 | +export const createMcpCatalogFragment = (input: { | |
| 253 | + source: Source; | |
| 254 | + documents: readonly CatalogSourceDocumentInput[]; | |
| 255 | + operations: readonly McpCatalogOperationInput[]; | |
| 256 | +}) => | |
| 257 | + buildCatalogFragment({ | |
| 258 | + source: input.source, | |
| 259 | + documents: input.documents, | |
| 260 | + registerOperations: ({ catalog, documentId, serviceScopeId, importer }) => { | |
| 261 | + for (const operation of input.operations) { | |
| 262 | + createMcpCapability({ | |
| 263 | + catalog, | |
| 264 | + source: input.source, | |
| 265 | + documentId, | |
| 266 | + serviceScopeId, | |
| 267 | + operation, | |
| 268 | + importer, | |
| 269 | + }); | |
| 270 | + } | |
| 271 | + }, | |
| 272 | + }); | |
| 273 | + | |
| 274 | +export const createMcpCatalogSnapshot = (input: { | |
| 275 | + source: Source; | |
| 276 | + documents: readonly CatalogSourceDocumentInput[]; | |
| 277 | + operations: readonly McpCatalogOperationInput[]; | |
| 278 | +}): CatalogSnapshotV1 => | |
| 279 | + createCatalogSnapshotV1FromFragments({ | |
| 280 | + import: createCatalogImportMetadata({ | |
| 281 | + source: input.source, | |
| 282 | + pluginKey: "mcp", | |
| 283 | + }), | |
| 284 | + fragments: [createMcpCatalogFragment(input)], | |
| 285 | + }); | |
| @@ -0,0 +1,151 @@ | ||
| 1 | +import * as Data from "effect/Data"; | |
| 2 | +import * as Effect from "effect/Effect"; | |
| 3 | +import * as Exit from "effect/Exit"; | |
| 4 | +import * as Scope from "effect/Scope"; | |
| 5 | + | |
| 6 | +import type { McpConnection, McpConnector } from "./tools"; | |
| 7 | + | |
| 8 | +export class McpConnectionPoolError extends Data.TaggedError("McpConnectionPoolError")<{ | |
| 9 | + readonly operation: "connect" | "close"; | |
| 10 | + readonly message: string; | |
| 11 | + readonly cause: unknown; | |
| 12 | +}> {} | |
| 13 | + | |
| 14 | +type PoolEntry = { | |
| 15 | + scope: Scope.CloseableScope; | |
| 16 | + connection: McpConnector; | |
| 17 | +}; | |
| 18 | + | |
| 19 | +const pooledRuns = new Map<string, Map<string, PoolEntry>>(); | |
| 20 | + | |
| 21 | +const mcpConnectionPoolError = (input: { | |
| 22 | + operation: "connect" | "close"; | |
| 23 | + message: string; | |
| 24 | + cause: unknown; | |
| 25 | +}): McpConnectionPoolError => new McpConnectionPoolError(input); | |
| 26 | + | |
| 27 | +const deletePoolEntry = (runId: string, sourceKey: string, entry: PoolEntry) => { | |
| 28 | + const runEntries = pooledRuns.get(runId); | |
| 29 | + if (!runEntries || runEntries.get(sourceKey) !== entry) { | |
| 30 | + return; | |
| 31 | + } | |
| 32 | + | |
| 33 | + runEntries.delete(sourceKey); | |
| 34 | + if (runEntries.size === 0) { | |
| 35 | + pooledRuns.delete(runId); | |
| 36 | + } | |
| 37 | +}; | |
| 38 | + | |
| 39 | +const closePooledConnection = ( | |
| 40 | + connection: McpConnection, | |
| 41 | +): Effect.Effect<void, never, never> => | |
| 42 | + Effect.tryPromise({ | |
| 43 | + try: () => Promise.resolve(connection.close?.()), | |
| 44 | + catch: (cause) => | |
| 45 | + mcpConnectionPoolError({ | |
| 46 | + operation: "close", | |
| 47 | + message: "Failed closing pooled MCP connection", | |
| 48 | + cause, | |
| 49 | + }), | |
| 50 | + }).pipe(Effect.ignore); | |
| 51 | + | |
| 52 | +const createPoolEntry = (connect: McpConnector): PoolEntry => { | |
| 53 | + const scope = Effect.runSync(Scope.make()); | |
| 54 | + const connection = Effect.runSync( | |
| 55 | + Effect.cached( | |
| 56 | + Effect.acquireRelease( | |
| 57 | + connect.pipe( | |
| 58 | + Effect.mapError((cause) => | |
| 59 | + mcpConnectionPoolError({ | |
| 60 | + operation: "connect", | |
| 61 | + message: "Failed creating pooled MCP connection", | |
| 62 | + cause, | |
| 63 | + })), | |
| 64 | + ), | |
| 65 | + closePooledConnection, | |
| 66 | + ).pipe(Scope.extend(scope)), | |
| 67 | + ), | |
| 68 | + ); | |
| 69 | + | |
| 70 | + return { | |
| 71 | + scope, | |
| 72 | + connection, | |
| 73 | + }; | |
| 74 | +}; | |
| 75 | + | |
| 76 | +const getOrCreatePoolEntry = (input: { | |
| 77 | + runId: string; | |
| 78 | + sourceKey: string; | |
| 79 | + connect: McpConnector; | |
| 80 | +}): PoolEntry => { | |
| 81 | + const existing = pooledRuns.get(input.runId)?.get(input.sourceKey); | |
| 82 | + if (existing) { | |
| 83 | + return existing; | |
| 84 | + } | |
| 85 | + | |
| 86 | + let runEntries = pooledRuns.get(input.runId); | |
| 87 | + if (!runEntries) { | |
| 88 | + runEntries = new Map<string, PoolEntry>(); | |
| 89 | + pooledRuns.set(input.runId, runEntries); | |
| 90 | + } | |
| 91 | + | |
| 92 | + const entry = createPoolEntry(input.connect); | |
| 93 | + entry.connection = entry.connection.pipe( | |
| 94 | + Effect.tapError(() => | |
| 95 | + Effect.sync(() => { | |
| 96 | + deletePoolEntry(input.runId, input.sourceKey, entry); | |
| 97 | + }).pipe( | |
| 98 | + Effect.zipRight(closePoolEntry(entry)), | |
| 99 | + )), | |
| 100 | + ); | |
| 101 | + runEntries.set(input.sourceKey, entry); | |
| 102 | + return entry; | |
| 103 | +}; | |
| 104 | + | |
| 105 | +const closePoolEntry = (entry: PoolEntry): Effect.Effect<void, never, never> => | |
| 106 | + Scope.close(entry.scope, Exit.void).pipe(Effect.ignore); | |
| 107 | + | |
| 108 | +export const createPooledMcpConnector = (input: { | |
| 109 | + connect: McpConnector; | |
| 110 | + runId?: string; | |
| 111 | + sourceKey?: string; | |
| 112 | +}): McpConnector => { | |
| 113 | + if (!input.runId || !input.sourceKey) { | |
| 114 | + return input.connect; | |
| 115 | + } | |
| 116 | + | |
| 117 | + return Effect.gen(function* () { | |
| 118 | + const entry = getOrCreatePoolEntry({ | |
| 119 | + runId: input.runId!, | |
| 120 | + sourceKey: input.sourceKey!, | |
| 121 | + connect: input.connect, | |
| 122 | + }); | |
| 123 | + const connection = yield* entry.connection; | |
| 124 | + return { | |
| 125 | + client: connection.client, | |
| 126 | + close: async () => undefined, | |
| 127 | + }; | |
| 128 | + }); | |
| 129 | +}; | |
| 130 | + | |
| 131 | +export const clearMcpConnectionPoolRun = ( | |
| 132 | + runId: string, | |
| 133 | +): Effect.Effect<void, never, never> => { | |
| 134 | + const runEntries = pooledRuns.get(runId); | |
| 135 | + if (!runEntries) { | |
| 136 | + return Effect.void; | |
| 137 | + } | |
| 138 | + | |
| 139 | + pooledRuns.delete(runId); | |
| 140 | + return Effect.forEach([...runEntries.values()], closePoolEntry, { | |
| 141 | + discard: true, | |
| 142 | + }); | |
| 143 | +}; | |
| 144 | + | |
| 145 | +export const clearAllMcpConnectionPools = (): Effect.Effect<void, never, never> => { | |
| 146 | + const runEntries = [...pooledRuns.values()].flatMap((entries) => [...entries.values()]); | |
| 147 | + pooledRuns.clear(); | |
| 148 | + return Effect.forEach(runEntries, closePoolEntry, { | |
| 149 | + discard: true, | |
| 150 | + }); | |
| 151 | +}; | |
| @@ -0,0 +1,222 @@ | ||
| 1 | +import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; | |
| 2 | +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; | |
| 3 | +import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; | |
| 4 | +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; | |
| 5 | +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; | |
| 6 | +import * as Data from "effect/Data"; | |
| 7 | +import * as Effect from "effect/Effect"; | |
| 8 | + | |
| 9 | +import type { McpConnection, McpConnector } from "./tools"; | |
| 10 | + | |
| 11 | +export type McpTransportPreference = "auto" | "streamable-http" | "sse" | "stdio"; | |
| 12 | + | |
| 13 | +export type CreateSdkMcpConnectorInput = { | |
| 14 | + endpoint?: string; | |
| 15 | + transport?: McpTransportPreference; | |
| 16 | + queryParams?: Record<string, string>; | |
| 17 | + headers?: Record<string, string>; | |
| 18 | + authProvider?: OAuthClientProvider; | |
| 19 | + clientName?: string; | |
| 20 | + clientVersion?: string; | |
| 21 | + command?: string; | |
| 22 | + args?: ReadonlyArray<string>; | |
| 23 | + env?: Record<string, string>; | |
| 24 | + cwd?: string; | |
| 25 | +}; | |
| 26 | + | |
| 27 | +export class McpConnectionError extends Data.TaggedError("McpConnectionError")<{ | |
| 28 | + readonly transport: McpTransportPreference; | |
| 29 | + readonly message: string; | |
| 30 | + readonly cause: unknown; | |
| 31 | +}> {} | |
| 32 | + | |
| 33 | +export const isMcpStdioTransport = ( | |
| 34 | + input: Pick<CreateSdkMcpConnectorInput, "transport" | "command">, | |
| 35 | +): boolean => | |
| 36 | + input.transport === "stdio" | |
| 37 | + || (typeof input.command === "string" && input.command.trim().length > 0); | |
| 38 | + | |
| 39 | +const mcpConnectionError = (input: { | |
| 40 | + transport: McpTransportPreference; | |
| 41 | + message: string; | |
| 42 | + cause: unknown; | |
| 43 | +}): McpConnectionError => new McpConnectionError(input); | |
| 44 | + | |
| 45 | +const createEndpoint = (input: { | |
| 46 | + endpoint: string | undefined; | |
| 47 | + queryParams: Record<string, string>; | |
| 48 | + transport: Exclude<McpTransportPreference, "stdio">; | |
| 49 | +}): Effect.Effect<URL, McpConnectionError, never> => | |
| 50 | + Effect.try({ | |
| 51 | + try: () => { | |
| 52 | + if (!input.endpoint) { | |
| 53 | + throw new Error("MCP endpoint is required for HTTP/SSE transports"); | |
| 54 | + } | |
| 55 | + | |
| 56 | + const url = new URL(input.endpoint); | |
| 57 | + | |
| 58 | + for (const [key, value] of Object.entries(input.queryParams)) { | |
| 59 | + url.searchParams.set(key, value); | |
| 60 | + } | |
| 61 | + | |
| 62 | + return url; | |
| 63 | + }, | |
| 64 | + catch: (cause) => | |
| 65 | + mcpConnectionError({ | |
| 66 | + transport: input.transport, | |
| 67 | + message: "Failed building MCP endpoint URL", | |
| 68 | + cause, | |
| 69 | + }), | |
| 70 | + }); | |
| 71 | + | |
| 72 | +type FetchInput = Parameters<typeof fetch>[0]; | |
| 73 | +type FetchInit = Parameters<typeof fetch>[1]; | |
| 74 | + | |
| 75 | +const mergeHeadersForFetch = ( | |
| 76 | + input: FetchInput, | |
| 77 | + init: FetchInit, | |
| 78 | + headers: Record<string, string>, | |
| 79 | +): Promise<Response> => { | |
| 80 | + const mergedHeaders = new Headers(init?.headers ?? {}); | |
| 81 | + | |
| 82 | + for (const [key, value] of Object.entries(headers)) { | |
| 83 | + mergedHeaders.set(key, value); | |
| 84 | + } | |
| 85 | + | |
| 86 | + return fetch(input, { | |
| 87 | + ...init, | |
| 88 | + headers: mergedHeaders, | |
| 89 | + }); | |
| 90 | +}; | |
| 91 | + | |
| 92 | +const connectionFromClient = (client: Client): McpConnection => ({ | |
| 93 | + client, | |
| 94 | + close: () => client.close(), | |
| 95 | +}); | |
| 96 | + | |
| 97 | +const closeClient = (client: Client): Effect.Effect<void, never, never> => | |
| 98 | + Effect.tryPromise({ | |
| 99 | + try: () => client.close(), | |
| 100 | + catch: (cause) => | |
| 101 | + mcpConnectionError({ | |
| 102 | + transport: "auto", | |
| 103 | + message: "Failed closing MCP client", | |
| 104 | + cause, | |
| 105 | + }), | |
| 106 | + }).pipe(Effect.ignore); | |
| 107 | + | |
| 108 | +const connectClient = (input: { | |
| 109 | + createClient: () => Client; | |
| 110 | + transport: McpTransportPreference; | |
| 111 | + createTransport: () => Parameters<Client["connect"]>[0]; | |
| 112 | +}): Effect.Effect<McpConnection, McpConnectionError, never> => | |
| 113 | + Effect.gen(function* () { | |
| 114 | + const client = input.createClient(); | |
| 115 | + const transportInstance = input.createTransport(); | |
| 116 | + | |
| 117 | + return yield* Effect.tryPromise({ | |
| 118 | + try: () => client.connect(transportInstance), | |
| 119 | + catch: (cause) => | |
| 120 | + mcpConnectionError({ | |
| 121 | + transport: input.transport, | |
| 122 | + message: | |
| 123 | + `Failed connecting to MCP server via ${input.transport}: ${ | |
| 124 | + cause instanceof Error ? cause.message : String(cause) | |
| 125 | + }`, | |
| 126 | + cause, | |
| 127 | + }), | |
| 128 | + }).pipe( | |
| 129 | + Effect.as(connectionFromClient(client)), | |
| 130 | + Effect.onError(() => closeClient(client)), | |
| 131 | + ); | |
| 132 | + }); | |
| 133 | + | |
| 134 | +export const createSdkMcpConnector = ( | |
| 135 | + input: CreateSdkMcpConnectorInput, | |
| 136 | +): McpConnector => { | |
| 137 | + const headers = input.headers ?? {}; | |
| 138 | + const transport = isMcpStdioTransport(input) | |
| 139 | + ? "stdio" | |
| 140 | + : (input.transport ?? "auto"); | |
| 141 | + const requestInit = Object.keys(headers).length > 0 | |
| 142 | + ? { headers } | |
| 143 | + : undefined; | |
| 144 | + | |
| 145 | + const createClient = () => | |
| 146 | + new Client( | |
| 147 | + { | |
| 148 | + name: input.clientName ?? "executor-codemode-mcp", | |
| 149 | + version: input.clientVersion ?? "0.1.0", | |
| 150 | + }, | |
| 151 | + { capabilities: { elicitation: { form: {}, url: {} } } }, | |
| 152 | + ); | |
| 153 | + | |
| 154 | + return Effect.gen(function* () { | |
| 155 | + if (transport === "stdio") { | |
| 156 | + const command = input.command?.trim(); | |
| 157 | + if (!command) { | |
| 158 | + return yield* mcpConnectionError({ | |
| 159 | + transport: "stdio", | |
| 160 | + message: "MCP stdio transport requires a command", | |
| 161 | + cause: new Error("Missing MCP stdio command"), | |
| 162 | + }); | |
| 163 | + } | |
| 164 | + | |
| 165 | + return yield* connectClient({ | |
| 166 | + createClient, | |
| 167 | + transport: "stdio", | |
| 168 | + createTransport: () => | |
| 169 | + new StdioClientTransport({ | |
| 170 | + command, | |
| 171 | + args: input.args ? [...input.args] : undefined, | |
| 172 | + env: input.env, | |
| 173 | + cwd: input.cwd?.trim().length ? input.cwd.trim() : undefined, | |
| 174 | + }), | |
| 175 | + }); | |
| 176 | + } | |
| 177 | + | |
| 178 | + const endpoint = yield* createEndpoint({ | |
| 179 | + endpoint: input.endpoint, | |
| 180 | + queryParams: input.queryParams ?? {}, | |
| 181 | + transport, | |
| 182 | + }); | |
| 183 | + | |
| 184 | + const connectStreamableHttp = connectClient({ | |
| 185 | + createClient, | |
| 186 | + transport: "streamable-http", | |
| 187 | + createTransport: () => | |
| 188 | + new StreamableHTTPClientTransport(endpoint, { | |
| 189 | + requestInit, | |
| 190 | + authProvider: input.authProvider, | |
| 191 | + }), | |
| 192 | + }); | |
| 193 | + | |
| 194 | + if (transport === "streamable-http") { | |
| 195 | + return yield* connectStreamableHttp; | |
| 196 | + } | |
| 197 | + | |
| 198 | + const connectSse = connectClient({ | |
| 199 | + createClient, | |
| 200 | + transport: "sse", | |
| 201 | + createTransport: () => | |
| 202 | + new SSEClientTransport(endpoint, { | |
| 203 | + authProvider: input.authProvider, | |
| 204 | + requestInit, | |
| 205 | + eventSourceInit: requestInit | |
| 206 | + ? { | |
| 207 | + fetch: (requestInput: FetchInput, requestOptions: FetchInit) => | |
| 208 | + mergeHeadersForFetch(requestInput, requestOptions, headers), | |
| 209 | + } | |
| 210 | + : undefined, | |
| 211 | + }), | |
| 212 | + }); | |
| 213 | + | |
| 214 | + if (transport === "sse") { | |
| 215 | + return yield* connectSse; | |
| 216 | + } | |
| 217 | + | |
| 218 | + return yield* connectStreamableHttp.pipe( | |
| 219 | + Effect.catchAll(() => connectSse), | |
| 220 | + ); | |
| 221 | + }); | |
| 222 | +}; | |
| @@ -0,0 +1,84 @@ | ||
| 1 | +import { | |
| 2 | + defaultNameFromEndpoint, | |
| 3 | + namespaceFromSourceName, | |
| 4 | + noneAuthInference, | |
| 5 | + supportedAuthInference, | |
| 6 | + type SourceDiscoveryProbeInput, | |
| 7 | + type SourceDiscoveryResult, | |
| 8 | +} from "@executor/source-core"; | |
| 9 | +import { startMcpOAuthAuthorization } from "@executor/auth-mcp-oauth"; | |
| 10 | +import * as Either from "effect/Either"; | |
| 11 | +import * as Effect from "effect/Effect"; | |
| 12 | + | |
| 13 | +import { createSdkMcpConnector } from "./connection"; | |
| 14 | +import { discoverMcpToolsFromConnector } from "./tools"; | |
| 15 | + | |
| 16 | +export const detectMcpSource = ( | |
| 17 | + input: SourceDiscoveryProbeInput, | |
| 18 | +): Effect.Effect<SourceDiscoveryResult | null, never, never> => | |
| 19 | + Effect.gen(function* () { | |
| 20 | + const connector = createSdkMcpConnector({ | |
| 21 | + endpoint: input.normalizedUrl, | |
| 22 | + headers: input.headers, | |
| 23 | + transport: "auto", | |
| 24 | + }); | |
| 25 | + | |
| 26 | + const discovered = yield* Effect.either(discoverMcpToolsFromConnector({ | |
| 27 | + connect: connector, | |
| 28 | + sourceKey: "discovery", | |
| 29 | + namespace: namespaceFromSourceName(defaultNameFromEndpoint(input.normalizedUrl)), | |
| 30 | + })); | |
| 31 | + | |
| 32 | + if (Either.isRight(discovered)) { | |
| 33 | + const name = defaultNameFromEndpoint(input.normalizedUrl); | |
| 34 | + return { | |
| 35 | + detectedKind: "plugin", | |
| 36 | + confidence: "high", | |
| 37 | + endpoint: input.normalizedUrl, | |
| 38 | + specUrl: null, | |
| 39 | + name, | |
| 40 | + namespace: namespaceFromSourceName(name), | |
| 41 | + transport: "auto", | |
| 42 | + authInference: noneAuthInference( | |
| 43 | + "MCP tool discovery succeeded without an advertised auth requirement", | |
| 44 | + "medium", | |
| 45 | + ), | |
| 46 | + toolCount: discovered.right.manifest.tools.length, | |
| 47 | + warnings: [], | |
| 48 | + } satisfies SourceDiscoveryResult; | |
| 49 | + } | |
| 50 | + | |
| 51 | + const oauthProbe = yield* Effect.either(startMcpOAuthAuthorization({ | |
| 52 | + endpoint: input.normalizedUrl, | |
| 53 | + redirectUrl: "http://127.0.0.1/executor/discovery/oauth/callback", | |
| 54 | + state: "source-discovery", | |
| 55 | + })); | |
| 56 | + | |
| 57 | + if (Either.isLeft(oauthProbe)) { | |
| 58 | + return null; | |
| 59 | + } | |
| 60 | + | |
| 61 | + const name = defaultNameFromEndpoint(input.normalizedUrl); | |
| 62 | + return { | |
| 63 | + detectedKind: "plugin", | |
| 64 | + confidence: "high", | |
| 65 | + endpoint: input.normalizedUrl, | |
| 66 | + specUrl: null, | |
| 67 | + name, | |
| 68 | + namespace: namespaceFromSourceName(name), | |
| 69 | + transport: "auto", | |
| 70 | + authInference: supportedAuthInference("oauth2", { | |
| 71 | + confidence: "high", | |
| 72 | + reason: "MCP endpoint advertised OAuth during discovery", | |
| 73 | + headerName: "Authorization", | |
| 74 | + prefix: "Bearer ", | |
| 75 | + parameterName: null, | |
| 76 | + parameterLocation: null, | |
| 77 | + oauthAuthorizationUrl: oauthProbe.right.authorizationUrl, | |
| 78 | + oauthTokenUrl: oauthProbe.right.authorizationServerUrl, | |
| 79 | + oauthScopes: [], | |
| 80 | + }), | |
| 81 | + toolCount: null, | |
| 82 | + warnings: ["OAuth is required before MCP tools can be listed."], | |
| 83 | + } satisfies SourceDiscoveryResult; | |
| 84 | + }).pipe(Effect.catchAll(() => Effect.succeed(null))); | |
| @@ -0,0 +1,119 @@ | ||
| 1 | +import { | |
| 2 | + ElicitRequestSchema, | |
| 3 | + UrlElicitationRequiredError, | |
| 4 | +} from "@modelcontextprotocol/sdk/types.js"; | |
| 5 | +import * as Either from "effect/Either"; | |
| 6 | +import * as Option from "effect/Option"; | |
| 7 | +import * as ParseResult from "effect/ParseResult"; | |
| 8 | +import * as Schema from "effect/Schema"; | |
| 9 | + | |
| 10 | +import type { | |
| 11 | + ElicitationRequest, | |
| 12 | + ElicitationResponse, | |
| 13 | + ToolExecutionContext, | |
| 14 | + ToolPath, | |
| 15 | +} from "@executor/codemode-core"; | |
| 16 | + | |
| 17 | +const UnknownRecordSchema = Schema.Record({ | |
| 18 | + key: Schema.String, | |
| 19 | + value: Schema.Unknown, | |
| 20 | +}); | |
| 21 | + | |
| 22 | +const decodeUnknownRecordOption = Schema.decodeUnknownOption(UnknownRecordSchema); | |
| 23 | + | |
| 24 | +export const readUnknownRecord = (input: unknown): Record<string, unknown> => { | |
| 25 | + const decoded = decodeUnknownRecordOption(input); | |
| 26 | + return Option.isSome(decoded) ? decoded.value : {}; | |
| 27 | +}; | |
| 28 | + | |
| 29 | +const RawMcpElicitationParamsSchema = Schema.Struct({ | |
| 30 | + mode: Schema.optional(Schema.Union(Schema.Literal("form"), Schema.Literal("url"))), | |
| 31 | + message: Schema.optional(Schema.String), | |
| 32 | + requestedSchema: Schema.optional(UnknownRecordSchema), | |
| 33 | + url: Schema.optional(Schema.String), | |
| 34 | + elicitationId: Schema.optional(Schema.String), | |
| 35 | + id: Schema.optional(Schema.String), | |
| 36 | +}); | |
| 37 | + | |
| 38 | +const decodeMcpElicitationParamsEither = Schema.decodeUnknownEither( | |
| 39 | + RawMcpElicitationParamsSchema, | |
| 40 | +); | |
| 41 | + | |
| 42 | +export const readMcpElicitationRequest = (input: unknown): ElicitationRequest => { | |
| 43 | + const decoded = decodeMcpElicitationParamsEither(input); | |
| 44 | + if (Either.isLeft(decoded)) { | |
| 45 | + throw new Error( | |
| 46 | + `Invalid MCP elicitation request params: ${ParseResult.TreeFormatter.formatErrorSync(decoded.left)}`, | |
| 47 | + ); | |
| 48 | + } | |
| 49 | + | |
| 50 | + const params = decoded.right; | |
| 51 | + const message = params.message ?? ""; | |
| 52 | + | |
| 53 | + if (params.mode === "url") { | |
| 54 | + return { | |
| 55 | + mode: "url", | |
| 56 | + message, | |
| 57 | + url: params.url ?? "", | |
| 58 | + elicitationId: params.elicitationId ?? params.id ?? "", | |
| 59 | + }; | |
| 60 | + } | |
| 61 | + | |
| 62 | + return { | |
| 63 | + mode: "form", | |
| 64 | + message, | |
| 65 | + requestedSchema: params.requestedSchema ?? {}, | |
| 66 | + }; | |
| 67 | +}; | |
| 68 | + | |
| 69 | +export const toMcpElicitationResponse = ( | |
| 70 | + response: ElicitationResponse, | |
| 71 | +): { action: "accept" | "decline" | "cancel"; content?: Record<string, unknown> } => | |
| 72 | + response.action === "accept" | |
| 73 | + ? { | |
| 74 | + action: "accept", | |
| 75 | + ...(response.content ? { content: response.content } : {}), | |
| 76 | + } | |
| 77 | + : { action: response.action }; | |
| 78 | + | |
| 79 | +type McpClientWithElicitationHandler = { | |
| 80 | + setRequestHandler: ( | |
| 81 | + schema: typeof ElicitRequestSchema, | |
| 82 | + handler: (request: { params: unknown }) => Promise<unknown>, | |
| 83 | + ) => void; | |
| 84 | +}; | |
| 85 | + | |
| 86 | +export const hasElicitationRequestHandler = ( | |
| 87 | + value: unknown, | |
| 88 | +): value is McpClientWithElicitationHandler => | |
| 89 | + typeof (value as { setRequestHandler?: unknown }).setRequestHandler === "function"; | |
| 90 | + | |
| 91 | +export const createInteractionId = (input: { | |
| 92 | + path: ToolPath; | |
| 93 | + invocation?: ToolExecutionContext["invocation"]; | |
| 94 | + elicitation: ElicitationRequest; | |
| 95 | + sequence?: number; | |
| 96 | +}): string => { | |
| 97 | + if (input.elicitation.mode === "url" && input.elicitation.elicitationId.length > 0) { | |
| 98 | + return input.elicitation.elicitationId; | |
| 99 | + } | |
| 100 | + | |
| 101 | + const parts = [ | |
| 102 | + input.invocation?.runId, | |
| 103 | + input.invocation?.callId, | |
| 104 | + input.path, | |
| 105 | + "mcp", | |
| 106 | + typeof input.sequence === "number" ? String(input.sequence) : undefined, | |
| 107 | + ].filter( | |
| 108 | + (part): part is string => typeof part === "string" && part.length > 0, | |
| 109 | + ); | |
| 110 | + | |
| 111 | + return parts.join(":"); | |
| 112 | +}; | |
| 113 | + | |
| 114 | +export const isUrlElicitationRequiredError = ( | |
| 115 | + cause: unknown, | |
| 116 | +): cause is UrlElicitationRequiredError => | |
| 117 | + cause instanceof UrlElicitationRequiredError | |
| 118 | + && Array.isArray(cause.elicitations) | |
| 119 | + && cause.elicitations.length > 0; | |
| @@ -0,0 +1,1021 @@ | ||
| 1 | +import type { | |
| 2 | + OAuthClientProvider, | |
| 3 | + OAuthDiscoveryState, | |
| 4 | +} from "@modelcontextprotocol/sdk/client/auth.js"; | |
| 5 | +import type { | |
| 6 | + OAuthClientInformationMixed, | |
| 7 | + OAuthTokens, | |
| 8 | +} from "@modelcontextprotocol/sdk/shared/auth.js"; | |
| 9 | +import type { | |
| 10 | + ToolExecutionContext, | |
| 11 | + ToolInput, | |
| 12 | + ToolPath, | |
| 13 | +} from "@executor/codemode-core"; | |
| 14 | +import { | |
| 15 | + exchangeMcpOAuthAuthorizationCode, | |
| 16 | + startMcpOAuthAuthorization, | |
| 17 | +} from "@executor/auth-mcp-oauth"; | |
| 18 | +import { | |
| 19 | + contentHash, | |
| 20 | + createCatalogImportMetadata, | |
| 21 | + createSourceCatalogSyncResult, | |
| 22 | +} from "@executor/source-core"; | |
| 23 | +import type { Source } from "@executor/platform-sdk/schema"; | |
| 24 | +import type { | |
| 25 | + ExecutorSdkPlugin, | |
| 26 | + ExecutorSdkPluginHost, | |
| 27 | + ExecutorSourceConnector, | |
| 28 | + SourcePluginRuntime, | |
| 29 | +} from "@executor/platform-sdk/plugins"; | |
| 30 | +import { | |
| 31 | + SecretMaterialDeleterService, | |
| 32 | + SecretMaterialResolverService, | |
| 33 | + SecretMaterialStorerService, | |
| 34 | + SecretMaterialUpdaterService, | |
| 35 | + provideExecutorRuntime, | |
| 36 | +} from "@executor/platform-sdk/runtime"; | |
| 37 | +import { | |
| 38 | + McpConnectionAuthSchema, | |
| 39 | + McpOAuthSessionSchema, | |
| 40 | + McpStoredSourceDataSchema, | |
| 41 | + deriveMcpNamespace, | |
| 42 | + resolveMcpEndpoint, | |
| 43 | + type McpConnectInput, | |
| 44 | + type McpConnectionAuth, | |
| 45 | + type McpOAuthPopupResult, | |
| 46 | + type McpOAuthSession, | |
| 47 | + type McpSourceConfigPayload, | |
| 48 | + type McpStartOAuthInput, | |
| 49 | + type McpStartOAuthResult, | |
| 50 | + type McpStoredSourceData, | |
| 51 | + type McpUpdateSourceInput, | |
| 52 | +} from "@executor/plugin-mcp-shared"; | |
| 53 | +import * as Effect from "effect/Effect"; | |
| 54 | +import * as Schema from "effect/Schema"; | |
| 55 | + | |
| 56 | +import { | |
| 57 | + createMcpCatalogFragment, | |
| 58 | + type McpCatalogOperationInput, | |
| 59 | +} from "./catalog"; | |
| 60 | +import { | |
| 61 | + createPooledMcpConnector, | |
| 62 | +} from "./connection-pool"; | |
| 63 | +import { | |
| 64 | + createSdkMcpConnector, | |
| 65 | + isMcpStdioTransport, | |
| 66 | +} from "./connection"; | |
| 67 | +import { | |
| 68 | + joinToolPath, | |
| 69 | + type McpServerMetadata, | |
| 70 | + type McpToolManifestEntry, | |
| 71 | +} from "./manifest"; | |
| 72 | +import { | |
| 73 | + createMcpToolsFromManifest, | |
| 74 | + discoverMcpToolsFromConnector, | |
| 75 | +} from "./tools"; | |
| 76 | + | |
| 77 | +const decodeStoredSourceData = Schema.decodeUnknownSync(McpStoredSourceDataSchema); | |
| 78 | +const decodeSession = Schema.decodeUnknownSync(McpOAuthSessionSchema); | |
| 79 | + | |
| 80 | +const McpExecutableBindingSchema = Schema.Struct({ | |
| 81 | + toolId: Schema.String, | |
| 82 | + toolName: Schema.String, | |
| 83 | + displayTitle: Schema.String, | |
| 84 | + title: Schema.NullOr(Schema.String), | |
| 85 | + description: Schema.NullOr(Schema.String), | |
| 86 | + annotations: Schema.NullOr(Schema.Unknown), | |
| 87 | + execution: Schema.NullOr(Schema.Unknown), | |
| 88 | + icons: Schema.NullOr(Schema.Unknown), | |
| 89 | + meta: Schema.NullOr(Schema.Unknown), | |
| 90 | + rawTool: Schema.NullOr(Schema.Unknown), | |
| 91 | + server: Schema.NullOr(Schema.Unknown), | |
| 92 | +}); | |
| 93 | + | |
| 94 | +type McpExecutableBinding = typeof McpExecutableBindingSchema.Type; | |
| 95 | + | |
| 96 | +const decodeProviderData = Schema.decodeUnknownSync(McpExecutableBindingSchema); | |
| 97 | + | |
| 98 | +export type McpSourceStorage = { | |
| 99 | + get: (input: { | |
| 100 | + scopeId: string; | |
| 101 | + sourceId: string; | |
| 102 | + }) => Effect.Effect<McpStoredSourceData | null, Error, never>; | |
| 103 | + put: (input: { | |
| 104 | + scopeId: string; | |
| 105 | + sourceId: string; | |
| 106 | + value: McpStoredSourceData; | |
| 107 | + }) => Effect.Effect<void, Error, never>; | |
| 108 | + remove?: (input: { | |
| 109 | + scopeId: string; | |
| 110 | + sourceId: string; | |
| 111 | + }) => Effect.Effect<void, Error, never>; | |
| 112 | +}; | |
| 113 | + | |
| 114 | +export type McpOAuthSessionStorage = { | |
| 115 | + get: (sessionId: string) => Effect.Effect<McpOAuthSession | null, Error, never>; | |
| 116 | + put: (input: { | |
| 117 | + sessionId: string; | |
| 118 | + value: McpOAuthSession; | |
| 119 | + }) => Effect.Effect<void, Error, never>; | |
| 120 | + remove?: (sessionId: string) => Effect.Effect<void, Error, never>; | |
| 121 | +}; | |
| 122 | + | |
| 123 | +export type McpSdk = { | |
| 124 | + getSourceConfig: ( | |
| 125 | + sourceId: Source["id"], | |
| 126 | + ) => Effect.Effect<McpSourceConfigPayload, Error>; | |
| 127 | + createSource: ( | |
| 128 | + input: McpConnectInput, | |
| 129 | + ) => Effect.Effect<Source, Error>; | |
| 130 | + updateSource: ( | |
| 131 | + input: McpUpdateSourceInput, | |
| 132 | + ) => Effect.Effect<Source, Error>; | |
| 133 | + removeSource: ( | |
| 134 | + sourceId: Source["id"], | |
| 135 | + ) => Effect.Effect<boolean, Error>; | |
| 136 | + startOAuth: ( | |
| 137 | + input: McpStartOAuthInput, | |
| 138 | + ) => Effect.Effect<McpStartOAuthResult, Error>; | |
| 139 | + completeOAuth: (input: { | |
| 140 | + state: string; | |
| 141 | + code?: string; | |
| 142 | + error?: string; | |
| 143 | + errorDescription?: string; | |
| 144 | + }) => Effect.Effect<Extract<McpOAuthPopupResult, { ok: true }>, Error>; | |
| 145 | +}; | |
| 146 | + | |
| 147 | +const McpExecutorAddInputSchema = Schema.Struct({ | |
| 148 | + kind: Schema.optional(Schema.Literal("mcp")), | |
| 149 | + name: Schema.String, | |
| 150 | + endpoint: Schema.NullOr(Schema.String), | |
| 151 | + transport: Schema.NullOr(Schema.Literal("streamable-http", "sse", "stdio", "auto")), | |
| 152 | + queryParams: Schema.NullOr(Schema.Record({ key: Schema.String, value: Schema.String })), | |
| 153 | + headers: Schema.NullOr(Schema.Record({ key: Schema.String, value: Schema.String })), | |
| 154 | + command: Schema.NullOr(Schema.String), | |
| 155 | + args: Schema.NullOr(Schema.Array(Schema.String)), | |
| 156 | + env: Schema.NullOr(Schema.Record({ key: Schema.String, value: Schema.String })), | |
| 157 | + cwd: Schema.NullOr(Schema.String), | |
| 158 | + auth: McpConnectionAuthSchema, | |
| 159 | +}); | |
| 160 | + | |
| 161 | +type McpExecutorAddInput = typeof McpExecutorAddInputSchema.Type; | |
| 162 | + | |
| 163 | +export type McpSdkPluginOptions = { | |
| 164 | + storage: McpSourceStorage; | |
| 165 | + oauthSessions: McpOAuthSessionStorage; | |
| 166 | +}; | |
| 167 | + | |
| 168 | +const asRecord = (value: unknown): Record<string, unknown> => | |
| 169 | + typeof value === "object" && value !== null && !Array.isArray(value) | |
| 170 | + ? (value as Record<string, unknown>) | |
| 171 | + : {}; | |
| 172 | + | |
| 173 | +const trimOrNull = (value: string | null | undefined): string | null => { | |
| 174 | + if (typeof value !== "string") { | |
| 175 | + return null; | |
| 176 | + } | |
| 177 | + | |
| 178 | + const trimmed = value.trim(); | |
| 179 | + return trimmed.length > 0 ? trimmed : null; | |
| 180 | +}; | |
| 181 | + | |
| 182 | +const normalizeStringRecord = ( | |
| 183 | + value: Readonly<Record<string, string>> | null | undefined, | |
| 184 | +): Record<string, string> | null => { | |
| 185 | + if (!value || Object.keys(value).length === 0) { | |
| 186 | + return null; | |
| 187 | + } | |
| 188 | + | |
| 189 | + const normalized = Object.fromEntries( | |
| 190 | + Object.entries(value).flatMap(([key, entry]) => { | |
| 191 | + const trimmedKey = key.trim(); | |
| 192 | + const trimmedValue = entry.trim(); | |
| 193 | + return trimmedKey.length > 0 && trimmedValue.length > 0 | |
| 194 | + ? [[trimmedKey, trimmedValue]] | |
| 195 | + : []; | |
| 196 | + }), | |
| 197 | + ); | |
| 198 | + | |
| 199 | + return Object.keys(normalized).length > 0 ? normalized : null; | |
| 200 | +}; | |
| 201 | + | |
| 202 | +const normalizeStringArray = ( | |
| 203 | + value: ReadonlyArray<string> | null | undefined, | |
| 204 | +): Array<string> | null => { | |
| 205 | + if (!value || value.length === 0) { | |
| 206 | + return null; | |
| 207 | + } | |
| 208 | + | |
| 209 | + const normalized = value | |
| 210 | + .map((entry) => entry.trim()) | |
| 211 | + .filter((entry) => entry.length > 0); | |
| 212 | + | |
| 213 | + return normalized.length > 0 ? normalized : null; | |
| 214 | +}; | |
| 215 | + | |
| 216 | +const normalizeStoredSourceData = ( | |
| 217 | + input: McpConnectInput | McpSourceConfigPayload, | |
| 218 | +): Effect.Effect<McpStoredSourceData, Error, never> => | |
| 219 | + Effect.gen(function* () { | |
| 220 | + const endpoint = trimOrNull(input.endpoint); | |
| 221 | + const command = trimOrNull(input.command); | |
| 222 | + const transport = input.transport ?? null; | |
| 223 | + const queryParams = normalizeStringRecord(input.queryParams); | |
| 224 | + const headers = normalizeStringRecord(input.headers); | |
| 225 | + const args = normalizeStringArray(input.args); | |
| 226 | + const env = normalizeStringRecord(input.env); | |
| 227 | + const cwd = trimOrNull(input.cwd); | |
| 228 | + const stdio = isMcpStdioTransport({ | |
| 229 | + transport: transport ?? undefined, | |
| 230 | + command: command ?? undefined, | |
| 231 | + }); | |
| 232 | + | |
| 233 | + if (stdio) { | |
| 234 | + if (command === null) { | |
| 235 | + return yield* Effect.fail( | |
| 236 | + new Error("MCP stdio transport requires a command."), | |
| 237 | + ); | |
| 238 | + } | |
| 239 | + | |
| 240 | + if (queryParams !== null) { | |
| 241 | + return yield* Effect.fail( | |
| 242 | + new Error("MCP stdio transport does not support query params."), | |
| 243 | + ); | |
| 244 | + } | |
| 245 | + | |
| 246 | + if (headers !== null) { | |
| 247 | + return yield* Effect.fail( | |
| 248 | + new Error("MCP stdio transport does not support request headers."), | |
| 249 | + ); | |
| 250 | + } | |
| 251 | + | |
| 252 | + return decodeStoredSourceData({ | |
| 253 | + endpoint: null, | |
| 254 | + transport: "stdio", | |
| 255 | + queryParams: null, | |
| 256 | + headers: null, | |
| 257 | + command, | |
| 258 | + args, | |
| 259 | + env, | |
| 260 | + cwd, | |
| 261 | + auth: input.auth, | |
| 262 | + }); | |
| 263 | + } | |
| 264 | + | |
| 265 | + if (endpoint === null) { | |
| 266 | + return yield* Effect.fail( | |
| 267 | + new Error("MCP remote transports require an endpoint."), | |
| 268 | + ); | |
| 269 | + } | |
| 270 | + | |
| 271 | + if (command !== null || args !== null || env !== null || cwd !== null) { | |
| 272 | + return yield* Effect.fail( | |
| 273 | + new Error('MCP process settings require transport "stdio".'), | |
| 274 | + ); | |
| 275 | + } | |
| 276 | + | |
| 277 | + return decodeStoredSourceData({ | |
| 278 | + endpoint, | |
| 279 | + transport, | |
| 280 | + queryParams, | |
| 281 | + headers, | |
| 282 | + command: null, | |
| 283 | + args: null, | |
| 284 | + env: null, | |
| 285 | + cwd: null, | |
| 286 | + auth: input.auth, | |
| 287 | + }); | |
| 288 | + }); | |
| 289 | + | |
| 290 | +const sourceConfigFromStored = ( | |
| 291 | + source: Source, | |
| 292 | + stored: McpStoredSourceData, | |
| 293 | +): McpSourceConfigPayload => ({ | |
| 294 | + name: source.name, | |
| 295 | + endpoint: stored.endpoint, | |
| 296 | + transport: stored.transport, | |
| 297 | + queryParams: stored.queryParams, | |
| 298 | + headers: stored.headers, | |
| 299 | + command: stored.command, | |
| 300 | + args: stored.args, | |
| 301 | + env: stored.env, | |
| 302 | + cwd: stored.cwd, | |
| 303 | + auth: stored.auth, | |
| 304 | +}); | |
| 305 | + | |
| 306 | +const createMcpSourceSdk = ( | |
| 307 | + options: McpSdkPluginOptions, | |
| 308 | + host: ExecutorSdkPluginHost, | |
| 309 | +) => ({ | |
| 310 | + getSourceConfig: (sourceId: Source["id"]) => | |
| 311 | + Effect.gen(function* () { | |
| 312 | + const source = yield* host.sources.get(sourceId); | |
| 313 | + if (source.kind !== "mcp") { | |
| 314 | + return yield* Effect.fail( | |
| 315 | + new Error(`Source ${sourceId} is not an MCP source.`), | |
| 316 | + ); | |
| 317 | + } | |
| 318 | + | |
| 319 | + const stored = yield* options.storage.get({ | |
| 320 | + scopeId: source.scopeId, | |
| 321 | + sourceId: source.id, | |
| 322 | + }); | |
| 323 | + if (stored === null) { | |
| 324 | + return yield* Effect.fail( | |
| 325 | + new Error(`MCP source storage missing for ${source.id}`), | |
| 326 | + ); | |
| 327 | + } | |
| 328 | + | |
| 329 | + return sourceConfigFromStored(source, stored); | |
| 330 | + }), | |
| 331 | + createSource: (input: McpConnectInput) => | |
| 332 | + Effect.gen(function* () { | |
| 333 | + const stored = yield* normalizeStoredSourceData(input); | |
| 334 | + const createdSource = yield* host.sources.create({ | |
| 335 | + source: { | |
| 336 | + name: input.name.trim(), | |
| 337 | + kind: "mcp", | |
| 338 | + status: "connected", | |
| 339 | + enabled: true, | |
| 340 | + namespace: deriveMcpNamespace({ | |
| 341 | + name: input.name, | |
| 342 | + endpoint: stored.endpoint, | |
| 343 | + command: stored.command, | |
| 344 | + }), | |
| 345 | + }, | |
| 346 | + }); | |
| 347 | + | |
| 348 | + yield* options.storage.put({ | |
| 349 | + scopeId: createdSource.scopeId, | |
| 350 | + sourceId: createdSource.id, | |
| 351 | + value: stored, | |
| 352 | + }); | |
| 353 | + | |
| 354 | + return yield* host.sources.refreshCatalog(createdSource.id); | |
| 355 | + }), | |
| 356 | + updateSource: (input: McpUpdateSourceInput) => | |
| 357 | + Effect.gen(function* () { | |
| 358 | + const source = yield* host.sources.get(input.sourceId as Source["id"]); | |
| 359 | + if (source.kind !== "mcp") { | |
| 360 | + return yield* Effect.fail( | |
| 361 | + new Error(`Source ${input.sourceId} is not an MCP source.`), | |
| 362 | + ); | |
| 363 | + } | |
| 364 | + | |
| 365 | + const stored = yield* normalizeStoredSourceData(input.config); | |
| 366 | + const savedSource = yield* host.sources.save({ | |
| 367 | + ...source, | |
| 368 | + name: input.config.name.trim(), | |
| 369 | + namespace: deriveMcpNamespace({ | |
| 370 | + name: input.config.name, | |
| 371 | + endpoint: stored.endpoint, | |
| 372 | + command: stored.command, | |
| 373 | + }), | |
| 374 | + }); | |
| 375 | + | |
| 376 | + yield* options.storage.put({ | |
| 377 | + scopeId: savedSource.scopeId, | |
| 378 | + sourceId: savedSource.id, | |
| 379 | + value: stored, | |
| 380 | + }); | |
| 381 | + | |
| 382 | + return yield* host.sources.refreshCatalog(savedSource.id); | |
| 383 | + }), | |
| 384 | + removeSource: (sourceId: Source["id"]) => | |
| 385 | + Effect.gen(function* () { | |
| 386 | + const source = yield* host.sources.get(sourceId); | |
| 387 | + if (source.kind !== "mcp") { | |
| 388 | + return yield* Effect.fail( | |
| 389 | + new Error(`Source ${sourceId} is not an MCP source.`), | |
| 390 | + ); | |
| 391 | + } | |
| 392 | + | |
| 393 | + const stored = yield* options.storage.get({ | |
| 394 | + scopeId: source.scopeId, | |
| 395 | + sourceId: source.id, | |
| 396 | + }); | |
| 397 | + if (stored?.auth.kind === "oauth2") { | |
| 398 | + const deleteSecretMaterial = yield* SecretMaterialDeleterService; | |
| 399 | + yield* Effect.either(deleteSecretMaterial(stored.auth.accessTokenRef)); | |
| 400 | + if (stored.auth.refreshTokenRef) { | |
| 401 | + yield* Effect.either(deleteSecretMaterial(stored.auth.refreshTokenRef)); | |
| 402 | + } | |
| 403 | + } | |
| 404 | + | |
| 405 | + if (options.storage.remove) { | |
| 406 | + yield* options.storage.remove({ | |
| 407 | + scopeId: source.scopeId, | |
| 408 | + sourceId: source.id, | |
| 409 | + }); | |
| 410 | + } | |
| 411 | + | |
| 412 | + return yield* host.sources.remove(source.id); | |
| 413 | + }), | |
| 414 | +}); | |
| 415 | + | |
| 416 | +const mcpSourceConnector = ( | |
| 417 | + options: McpSdkPluginOptions, | |
| 418 | +): ExecutorSourceConnector<McpExecutorAddInput> => ({ | |
| 419 | + kind: "mcp", | |
| 420 | + displayName: "MCP", | |
| 421 | + inputSchema: McpExecutorAddInputSchema, | |
| 422 | + inputSignatureWidth: 320, | |
| 423 | + helpText: [ | |
| 424 | + "Remote MCP sources use `endpoint`; local stdio sources use `command` plus `transport: \"stdio\"`.", | |
| 425 | + "OAuth browser setup stays on the plugin-owned HTTP/UI surfaces.", | |
| 426 | + ], | |
| 427 | + createSource: ({ args, host }) => | |
| 428 | + createMcpSourceSdk(options, host).createSource(args), | |
| 429 | +}); | |
| 430 | + | |
| 431 | +const mcpCatalogOperationFromManifestEntry = (input: { | |
| 432 | + entry: McpToolManifestEntry; | |
| 433 | + server: McpServerMetadata | null | undefined; | |
| 434 | +}): McpCatalogOperationInput => ({ | |
| 435 | + toolId: input.entry.toolId, | |
| 436 | + title: | |
| 437 | + input.entry.displayTitle | |
| 438 | + ?? input.entry.title | |
| 439 | + ?? input.entry.toolName, | |
| 440 | + description: input.entry.description ?? null, | |
| 441 | + effect: input.entry.annotations?.readOnlyHint === true ? "read" : "write", | |
| 442 | + inputSchema: input.entry.inputSchema, | |
| 443 | + outputSchema: input.entry.outputSchema, | |
| 444 | + providerData: { | |
| 445 | + toolId: input.entry.toolId, | |
| 446 | + toolName: input.entry.toolName, | |
| 447 | + displayTitle: | |
| 448 | + input.entry.displayTitle | |
| 449 | + ?? input.entry.title | |
| 450 | + ?? input.entry.toolName, | |
| 451 | + title: input.entry.title ?? null, | |
| 452 | + description: input.entry.description ?? null, | |
| 453 | + annotations: input.entry.annotations ?? null, | |
| 454 | + execution: input.entry.execution ?? null, | |
| 455 | + icons: input.entry.icons ?? null, | |
| 456 | + meta: input.entry.meta ?? null, | |
| 457 | + rawTool: input.entry.rawTool ?? null, | |
| 458 | + server: input.server ?? null, | |
| 459 | + }, | |
| 460 | +}); | |
| 461 | + | |
| 462 | +const expiresAtFromTokens = (tokens: OAuthTokens): number | null => | |
| 463 | + typeof tokens.expires_in === "number" && Number.isFinite(tokens.expires_in) | |
| 464 | + ? Date.now() + Math.max(0, tokens.expires_in) * 1000 | |
| 465 | + : null; | |
| 466 | + | |
| 467 | +const expiresInFromAuth = ( | |
| 468 | + auth: Extract<McpConnectionAuth, { kind: "oauth2" }>, | |
| 469 | +): number | undefined => { | |
| 470 | + if (auth.expiresAt === null) { | |
| 471 | + return undefined; | |
| 472 | + } | |
| 473 | + | |
| 474 | + return Math.max(0, Math.ceil((auth.expiresAt - Date.now()) / 1000)); | |
| 475 | +}; | |
| 476 | + | |
| 477 | +const createPersistedMcpAuthProvider = (input: { | |
| 478 | + scopeId: string; | |
| 479 | + sourceId: string; | |
| 480 | + stored: McpStoredSourceData; | |
| 481 | + storage: McpSourceStorage; | |
| 482 | +}): Effect.Effect<OAuthClientProvider, Error, any> => | |
| 483 | + Effect.gen(function* () { | |
| 484 | + if (input.stored.auth.kind !== "oauth2") { | |
| 485 | + return yield* Effect.fail( | |
| 486 | + new Error(`Source ${input.sourceId} is not configured for MCP OAuth.`), | |
| 487 | + ); | |
| 488 | + } | |
| 489 | + | |
| 490 | + const resolveSecretMaterial = yield* SecretMaterialResolverService; | |
| 491 | + const storeSecretMaterial = yield* SecretMaterialStorerService; | |
| 492 | + const updateSecretMaterial = yield* SecretMaterialUpdaterService; | |
| 493 | + const deleteSecretMaterial = yield* SecretMaterialDeleterService; | |
| 494 | + | |
| 495 | + let currentStored = input.stored; | |
| 496 | + let currentAuth = input.stored.auth; | |
| 497 | + | |
| 498 | + const persistAuth = ( | |
| 499 | + nextAuth: Extract<McpConnectionAuth, { kind: "oauth2" }>, | |
| 500 | + ) => | |
| 501 | + input.storage.put({ | |
| 502 | + scopeId: input.scopeId, | |
| 503 | + sourceId: input.sourceId, | |
| 504 | + value: { | |
| 505 | + ...currentStored, | |
| 506 | + auth: nextAuth, | |
| 507 | + }, | |
| 508 | + }).pipe( | |
| 509 | + Effect.tap(() => | |
| 510 | + Effect.sync(() => { | |
| 511 | + currentAuth = nextAuth; | |
| 512 | + currentStored = { | |
| 513 | + ...currentStored, | |
| 514 | + auth: nextAuth, | |
| 515 | + }; | |
| 516 | + }) | |
| 517 | + ), | |
| 518 | + ); | |
| 519 | + | |
| 520 | + return { | |
| 521 | + get redirectUrl() { | |
| 522 | + return currentAuth.redirectUri; | |
| 523 | + }, | |
| 524 | + | |
| 525 | + get clientMetadata() { | |
| 526 | + return { | |
| 527 | + redirect_uris: [currentAuth.redirectUri], | |
| 528 | + grant_types: ["authorization_code", "refresh_token"], | |
| 529 | + response_types: ["code"], | |
| 530 | + token_endpoint_auth_method: "none", | |
| 531 | + client_name: "Executor Local", | |
| 532 | + }; | |
| 533 | + }, | |
| 534 | + | |
| 535 | + clientInformation: () => | |
| 536 | + currentAuth.clientInformation as OAuthClientInformationMixed | undefined, | |
| 537 | + | |
| 538 | + saveClientInformation: (clientInformation) => | |
| 539 | + Effect.runPromise( | |
| 540 | + persistAuth({ | |
| 541 | + ...currentAuth, | |
| 542 | + clientInformation: | |
| 543 | + (clientInformation as Extract<McpConnectionAuth, { kind: "oauth2" }>["clientInformation"]) | |
| 544 | + ?? null, | |
| 545 | + }), | |
| 546 | + ).then(() => undefined), | |
| 547 | + | |
| 548 | + tokens: async () => { | |
| 549 | + const accessToken = await Effect.runPromise( | |
| 550 | + resolveSecretMaterial({ | |
| 551 | + ref: currentAuth.accessTokenRef, | |
| 552 | + }), | |
| 553 | + ); | |
| 554 | + const refreshToken = currentAuth.refreshTokenRef | |
| 555 | + ? await Effect.runPromise( | |
| 556 | + resolveSecretMaterial({ | |
| 557 | + ref: currentAuth.refreshTokenRef, | |
| 558 | + }), | |
| 559 | + ) | |
| 560 | + : undefined; | |
| 561 | + | |
| 562 | + return { | |
| 563 | + access_token: accessToken, | |
| 564 | + token_type: currentAuth.tokenType, | |
| 565 | + ...(refreshToken ? { refresh_token: refreshToken } : {}), | |
| 566 | + ...(currentAuth.scope ? { scope: currentAuth.scope } : {}), | |
| 567 | + ...(expiresInFromAuth(currentAuth) !== undefined | |
| 568 | + ? { expires_in: expiresInFromAuth(currentAuth) } | |
| 569 | + : {}), | |
| 570 | + } satisfies OAuthTokens; | |
| 571 | + }, | |
| 572 | + | |
| 573 | + saveTokens: (tokens) => | |
| 574 | + Effect.runPromise( | |
| 575 | + Effect.gen(function* () { | |
| 576 | + yield* updateSecretMaterial({ | |
| 577 | + ref: currentAuth.accessTokenRef, | |
| 578 | + value: tokens.access_token, | |
| 579 | + }); | |
| 580 | + | |
| 581 | + let refreshTokenRef = currentAuth.refreshTokenRef; | |
| 582 | + if (tokens.refresh_token) { | |
| 583 | + if (refreshTokenRef) { | |
| 584 | + yield* updateSecretMaterial({ | |
| 585 | + ref: refreshTokenRef, | |
| 586 | + value: tokens.refresh_token, | |
| 587 | + }); | |
| 588 | + } else { | |
| 589 | + refreshTokenRef = yield* storeSecretMaterial({ | |
| 590 | + purpose: "oauth_refresh_token", | |
| 591 | + value: tokens.refresh_token, | |
| 592 | + name: `${input.sourceId} MCP Refresh Token`, | |
| 593 | + }); | |
| 594 | + } | |
| 595 | + } | |
| 596 | + | |
| 597 | + yield* persistAuth({ | |
| 598 | + ...currentAuth, | |
| 599 | + refreshTokenRef, | |
| 600 | + tokenType: tokens.token_type ?? currentAuth.tokenType, | |
| 601 | + expiresAt: expiresAtFromTokens(tokens) ?? currentAuth.expiresAt, | |
| 602 | + scope: tokens.scope ?? currentAuth.scope, | |
| 603 | + }); | |
| 604 | + }).pipe( | |
| 605 | + Effect.catchAll((error) => | |
| 606 | + Effect.gen(function* () { | |
| 607 | + if (currentAuth.refreshTokenRef && tokens.refresh_token === undefined) { | |
| 608 | + yield* Effect.either(deleteSecretMaterial(currentAuth.refreshTokenRef)); | |
| 609 | + } | |
| 610 | + return yield* Effect.fail(error); | |
| 611 | + }) | |
| 612 | + ), | |
| 613 | + ), | |
| 614 | + ).then(() => undefined), | |
| 615 | + | |
| 616 | + redirectToAuthorization: async (authorizationUrl) => { | |
| 617 | + throw new Error( | |
| 618 | + `MCP OAuth re-authorization is required for ${input.sourceId}: ${authorizationUrl.toString()}`, | |
| 619 | + ); | |
| 620 | + }, | |
| 621 | + | |
| 622 | + saveCodeVerifier: () => undefined, | |
| 623 | + | |
| 624 | + codeVerifier: () => { | |
| 625 | + throw new Error("Persisted MCP OAuth sessions do not retain an active PKCE verifier."); | |
| 626 | + }, | |
| 627 | + | |
| 628 | + saveDiscoveryState: (state) => | |
| 629 | + Effect.runPromise( | |
| 630 | + persistAuth({ | |
| 631 | + ...currentAuth, | |
| 632 | + resourceMetadataUrl: state.resourceMetadataUrl ?? null, | |
| 633 | + authorizationServerUrl: state.authorizationServerUrl ?? null, | |
| 634 | + resourceMetadata: | |
| 635 | + (state.resourceMetadata as Extract<McpConnectionAuth, { kind: "oauth2" }>["resourceMetadata"]) | |
| 636 | + ?? null, | |
| 637 | + authorizationServerMetadata: | |
| 638 | + (state.authorizationServerMetadata as Extract<McpConnectionAuth, { kind: "oauth2" }>["authorizationServerMetadata"]) | |
| 639 | + ?? null, | |
| 640 | + }), | |
| 641 | + ).then(() => undefined), | |
| 642 | + | |
| 643 | + discoveryState: () => | |
| 644 | + currentAuth.authorizationServerUrl === null | |
| 645 | + ? undefined | |
| 646 | + : { | |
| 647 | + resourceMetadataUrl: currentAuth.resourceMetadataUrl ?? undefined, | |
| 648 | + authorizationServerUrl: currentAuth.authorizationServerUrl, | |
| 649 | + resourceMetadata: | |
| 650 | + currentAuth.resourceMetadata as OAuthDiscoveryState["resourceMetadata"], | |
| 651 | + authorizationServerMetadata: | |
| 652 | + currentAuth.authorizationServerMetadata as OAuthDiscoveryState["authorizationServerMetadata"], | |
| 653 | + }, | |
| 654 | + } satisfies OAuthClientProvider; | |
| 655 | + }); | |
| 656 | + | |
| 657 | +const createStoredMcpConnector = (input: { | |
| 658 | + scopeId: string; | |
| 659 | + sourceId: string; | |
| 660 | + stored: McpStoredSourceData; | |
| 661 | + storage: McpSourceStorage; | |
| 662 | +}): Effect.Effect<ReturnType<typeof createSdkMcpConnector>, Error, any> => | |
| 663 | + Effect.gen(function* () { | |
| 664 | + const authProvider = | |
| 665 | + input.stored.auth.kind === "oauth2" | |
| 666 | + ? yield* createPersistedMcpAuthProvider(input) | |
| 667 | + : undefined; | |
| 668 | + | |
| 669 | + return createSdkMcpConnector({ | |
| 670 | + endpoint: input.stored.endpoint ?? undefined, | |
| 671 | + transport: input.stored.transport ?? undefined, | |
| 672 | + queryParams: input.stored.queryParams ?? undefined, | |
| 673 | + headers: input.stored.headers ?? undefined, | |
| 674 | + authProvider, | |
| 675 | + command: input.stored.command ?? undefined, | |
| 676 | + args: input.stored.args ?? undefined, | |
| 677 | + env: input.stored.env ?? undefined, | |
| 678 | + cwd: input.stored.cwd ?? undefined, | |
| 679 | + }); | |
| 680 | + }); | |
| 681 | + | |
| 682 | +const mcpDocumentKey = (stored: McpStoredSourceData): string => | |
| 683 | + stored.endpoint | |
| 684 | + ?? (stored.command | |
| 685 | + ? `stdio://${stored.command}` | |
| 686 | + : "mcp"); | |
| 687 | + | |
| 688 | +const createMcpSourceRuntime = ( | |
| 689 | + options: McpSdkPluginOptions, | |
| 690 | +): SourcePluginRuntime => ({ | |
| 691 | + kind: "mcp", | |
| 692 | + displayName: "MCP", | |
| 693 | + catalogKind: "imported", | |
| 694 | + catalogIdentity: ({ source }) => ({ | |
| 695 | + kind: "mcp", | |
| 696 | + sourceId: source.id, | |
| 697 | + }), | |
| 698 | + getIrModel: ({ source }) => | |
| 699 | + Effect.gen(function* () { | |
| 700 | + const stored = yield* options.storage.get({ | |
| 701 | + scopeId: source.scopeId, | |
| 702 | + sourceId: source.id, | |
| 703 | + }); | |
| 704 | + if (stored === null) { | |
| 705 | + return createSourceCatalogSyncResult({ | |
| 706 | + fragment: { | |
| 707 | + version: "ir.v1.fragment", | |
| 708 | + }, | |
| 709 | + importMetadata: { | |
| 710 | + ...createCatalogImportMetadata({ | |
| 711 | + source, | |
| 712 | + pluginKey: "mcp", | |
| 713 | + }), | |
| 714 | + importerVersion: "ir.v1.mcp", | |
| 715 | + sourceConfigHash: "missing", | |
| 716 | + }, | |
| 717 | + sourceHash: null, | |
| 718 | + }); | |
| 719 | + } | |
| 720 | + | |
| 721 | + const connector = yield* createStoredMcpConnector({ | |
| 722 | + scopeId: source.scopeId, | |
| 723 | + sourceId: source.id, | |
| 724 | + stored, | |
| 725 | + storage: options.storage, | |
| 726 | + }); | |
| 727 | + const discovered = yield* discoverMcpToolsFromConnector({ | |
| 728 | + connect: connector, | |
| 729 | + namespace: | |
| 730 | + source.namespace | |
| 731 | + ?? deriveMcpNamespace({ | |
| 732 | + name: source.name, | |
| 733 | + endpoint: stored.endpoint, | |
| 734 | + command: stored.command, | |
| 735 | + }) | |
| 736 | + ?? undefined, | |
| 737 | + sourceKey: source.id, | |
| 738 | + }).pipe( | |
| 739 | + Effect.mapError((cause) => | |
| 740 | + cause instanceof Error | |
| 741 | + ? cause | |
| 742 | + : new Error(String(cause)), | |
| 743 | + ), | |
| 744 | + ); | |
| 745 | + const manifestJson = JSON.stringify(discovered.manifest); | |
| 746 | + const now = Date.now(); | |
| 747 | + | |
| 748 | + return createSourceCatalogSyncResult({ | |
| 749 | + fragment: createMcpCatalogFragment({ | |
| 750 | + source, | |
| 751 | + documents: [ | |
| 752 | + { | |
| 753 | + documentKind: "mcp_manifest", | |
| 754 | + documentKey: mcpDocumentKey(stored), | |
| 755 | + contentText: manifestJson, | |
| 756 | + fetchedAt: now, | |
| 757 | + }, | |
| 758 | + ], | |
| 759 | + operations: discovered.manifest.tools.map((entry) => | |
| 760 | + mcpCatalogOperationFromManifestEntry({ | |
| 761 | + entry, | |
| 762 | + server: discovered.manifest.server, | |
| 763 | + }) | |
| 764 | + ), | |
| 765 | + }), | |
| 766 | + importMetadata: { | |
| 767 | + ...createCatalogImportMetadata({ | |
| 768 | + source, | |
| 769 | + pluginKey: "mcp", | |
| 770 | + }), | |
| 771 | + importerVersion: "ir.v1.mcp", | |
| 772 | + }, | |
| 773 | + sourceHash: contentHash(manifestJson), | |
| 774 | + }); | |
| 775 | + }), | |
| 776 | + invoke: (input) => | |
| 777 | + Effect.gen(function* () { | |
| 778 | + const stored = yield* options.storage.get({ | |
| 779 | + scopeId: input.source.scopeId, | |
| 780 | + sourceId: input.source.id, | |
| 781 | + }); | |
| 782 | + if (stored === null) { | |
| 783 | + return yield* Effect.fail( | |
| 784 | + new Error(`MCP source storage missing for ${input.source.id}`), | |
| 785 | + ); | |
| 786 | + } | |
| 787 | + | |
| 788 | + const providerData = decodeProviderData( | |
| 789 | + input.executable.binding, | |
| 790 | + ) as McpExecutableBinding; | |
| 791 | + const connector = createPooledMcpConnector({ | |
| 792 | + connect: yield* createStoredMcpConnector({ | |
| 793 | + scopeId: input.source.scopeId, | |
| 794 | + sourceId: input.source.id, | |
| 795 | + stored, | |
| 796 | + storage: options.storage, | |
| 797 | + }), | |
| 798 | + runId: | |
| 799 | + typeof input.context?.runId === "string" && input.context.runId.length > 0 | |
| 800 | + ? input.context.runId | |
| 801 | + : undefined, | |
| 802 | + sourceKey: input.source.id, | |
| 803 | + }); | |
| 804 | + const manifest = { | |
| 805 | + version: 2 as const, | |
| 806 | + server: providerData.server as McpServerMetadata | null, | |
| 807 | + tools: [ | |
| 808 | + { | |
| 809 | + toolId: providerData.toolId, | |
| 810 | + toolName: providerData.toolName, | |
| 811 | + displayTitle: providerData.displayTitle, | |
| 812 | + title: providerData.title, | |
| 813 | + description: providerData.description, | |
| 814 | + annotations: providerData.annotations as McpToolManifestEntry["annotations"], | |
| 815 | + execution: providerData.execution as McpToolManifestEntry["execution"], | |
| 816 | + icons: providerData.icons as McpToolManifestEntry["icons"], | |
| 817 | + meta: providerData.meta, | |
| 818 | + rawTool: providerData.rawTool, | |
| 819 | + inputSchema: input.descriptor.contract?.inputSchema, | |
| 820 | + outputSchema: input.descriptor.contract?.outputSchema, | |
| 821 | + }, | |
| 822 | + ], | |
| 823 | + }; | |
| 824 | + const tools = createMcpToolsFromManifest({ | |
| 825 | + manifest, | |
| 826 | + connect: connector, | |
| 827 | + namespace: input.source.namespace ?? undefined, | |
| 828 | + sourceKey: input.source.id, | |
| 829 | + }); | |
| 830 | + const toolPath = joinToolPath(input.source.namespace ?? undefined, providerData.toolId); | |
| 831 | + const entry = ( | |
| 832 | + tools[toolPath] | |
| 833 | + ?? tools[providerData.toolId] | |
| 834 | + ?? tools[providerData.toolName] | |
| 835 | + ) as ToolInput | undefined; | |
| 836 | + const definition = | |
| 837 | + entry && typeof entry === "object" && entry !== null && "tool" in entry | |
| 838 | + ? entry.tool | |
| 839 | + : entry; | |
| 840 | + | |
| 841 | + if (!definition) { | |
| 842 | + return yield* Effect.fail( | |
| 843 | + new Error(`Missing MCP tool definition for ${providerData.toolName}`), | |
| 844 | + ); | |
| 845 | + } | |
| 846 | + | |
| 847 | + const inputShape = input.executable.projection.callShapeId | |
| 848 | + ? input.catalog.symbols[input.executable.projection.callShapeId] | |
| 849 | + : undefined; | |
| 850 | + const payload = | |
| 851 | + inputShape?.kind === "shape" && inputShape.node.type !== "object" | |
| 852 | + ? asRecord(input.args).input | |
| 853 | + : input.args; | |
| 854 | + const executionContext: ToolExecutionContext | undefined = | |
| 855 | + input.onElicitation | |
| 856 | + ? { | |
| 857 | + path: input.descriptor.path as ToolPath, | |
| 858 | + sourceKey: input.source.id, | |
| 859 | + metadata: { | |
| 860 | + sourceKey: input.source.id, | |
| 861 | + interaction: input.descriptor.interaction, | |
| 862 | + contract: { | |
| 863 | + ...(input.descriptor.contract?.inputSchema !== undefined | |
| 864 | + ? { inputSchema: input.descriptor.contract.inputSchema } | |
| 865 | + : {}), | |
| 866 | + ...(input.descriptor.contract?.outputSchema !== undefined | |
| 867 | + ? { outputSchema: input.descriptor.contract.outputSchema } | |
| 868 | + : {}), | |
| 869 | + }, | |
| 870 | + pluginKind: input.descriptor.pluginKind, | |
| 871 | + pluginData: input.descriptor.pluginData, | |
| 872 | + }, | |
| 873 | + invocation: input.context, | |
| 874 | + onElicitation: input.onElicitation, | |
| 875 | + } | |
| 876 | + : undefined; | |
| 877 | + const result = yield* Effect.tryPromise({ | |
| 878 | + try: async () => | |
| 879 | + await definition.execute(asRecord(payload), executionContext), | |
| 880 | + catch: (cause) => | |
| 881 | + cause instanceof Error ? cause : new Error(String(cause)), | |
| 882 | + }); | |
| 883 | + const resultRecord = asRecord(result); | |
| 884 | + const isError = resultRecord.isError === true; | |
| 885 | + | |
| 886 | + return { | |
| 887 | + data: isError ? null : (result ?? null), | |
| 888 | + error: isError ? result : null, | |
| 889 | + headers: {}, | |
| 890 | + status: null, | |
| 891 | + }; | |
| 892 | + }), | |
| 893 | +}); | |
| 894 | + | |
| 895 | +export const mcpSdkPlugin = ( | |
| 896 | + options: McpSdkPluginOptions, | |
| 897 | +): ExecutorSdkPlugin<"mcp", McpSdk> => ({ | |
| 898 | + key: "mcp", | |
| 899 | + sources: [createMcpSourceRuntime(options)], | |
| 900 | + sourceConnectors: [mcpSourceConnector(options)], | |
| 901 | + extendExecutor: ({ host, executor }) => { | |
| 902 | + const sourceSdk = createMcpSourceSdk(options, host); | |
| 903 | + const provideRuntime = <A>( | |
| 904 | + effect: Effect.Effect<A, Error, any>, | |
| 905 | + ): Effect.Effect<A, Error, never> => | |
| 906 | + provideExecutorRuntime(effect, executor.runtime); | |
| 907 | + | |
| 908 | + return { | |
| 909 | + getSourceConfig: (sourceId) => | |
| 910 | + provideRuntime(sourceSdk.getSourceConfig(sourceId)), | |
| 911 | + createSource: (input) => | |
| 912 | + provideRuntime(sourceSdk.createSource(input)), | |
| 913 | + updateSource: (input) => | |
| 914 | + provideRuntime(sourceSdk.updateSource(input)), | |
| 915 | + removeSource: (sourceId) => | |
| 916 | + provideRuntime(sourceSdk.removeSource(sourceId)), | |
| 917 | + startOAuth: (input) => | |
| 918 | + provideRuntime( | |
| 919 | + Effect.gen(function* () { | |
| 920 | + const endpoint = resolveMcpEndpoint({ | |
| 921 | + endpoint: input.endpoint.trim(), | |
| 922 | + queryParams: input.queryParams, | |
| 923 | + }); | |
| 924 | + const sessionId = `mcp_oauth_${crypto.randomUUID()}`; | |
| 925 | + const started = yield* startMcpOAuthAuthorization({ | |
| 926 | + endpoint, | |
| 927 | + redirectUrl: input.redirectUrl, | |
| 928 | + state: sessionId, | |
| 929 | + }); | |
| 930 | + | |
| 931 | + yield* options.oauthSessions.put({ | |
| 932 | + sessionId, | |
| 933 | + value: decodeSession({ | |
| 934 | + endpoint, | |
| 935 | + redirectUrl: input.redirectUrl, | |
| 936 | + codeVerifier: started.codeVerifier, | |
| 937 | + resourceMetadataUrl: started.resourceMetadataUrl, | |
| 938 | + authorizationServerUrl: started.authorizationServerUrl, | |
| 939 | + resourceMetadata: started.resourceMetadata, | |
| 940 | + authorizationServerMetadata: started.authorizationServerMetadata, | |
| 941 | + clientInformation: started.clientInformation, | |
| 942 | + }), | |
| 943 | + }); | |
| 944 | + | |
| 945 | + return { | |
| 946 | + sessionId, | |
| 947 | + authorizationUrl: started.authorizationUrl, | |
| 948 | + }; | |
| 949 | + }), | |
| 950 | + ), | |
| 951 | + completeOAuth: (input) => | |
| 952 | + provideRuntime( | |
| 953 | + Effect.gen(function* () { | |
| 954 | + if (input.error) { | |
| 955 | + return yield* Effect.fail( | |
| 956 | + new Error(input.errorDescription || input.error || "MCP OAuth failed"), | |
| 957 | + ); | |
| 958 | + } | |
| 959 | + if (!input.code) { | |
| 960 | + return yield* Effect.fail(new Error("Missing MCP OAuth code.")); | |
| 961 | + } | |
| 962 | + | |
| 963 | + const session = yield* options.oauthSessions.get(input.state); | |
| 964 | + if (session === null) { | |
| 965 | + return yield* Effect.fail( | |
| 966 | + new Error(`MCP OAuth session not found: ${input.state}`), | |
| 967 | + ); | |
| 968 | + } | |
| 969 | + | |
| 970 | + const exchanged = yield* exchangeMcpOAuthAuthorizationCode({ | |
| 971 | + session, | |
| 972 | + code: input.code, | |
| 973 | + }); | |
| 974 | + const storeSecretMaterial = yield* SecretMaterialStorerService; | |
| 975 | + const accessTokenRef = yield* storeSecretMaterial({ | |
| 976 | + purpose: "oauth_access_token", | |
| 977 | + value: exchanged.tokens.access_token, | |
| 978 | + name: "MCP Access Token", | |
| 979 | + }); | |
| 980 | + const refreshTokenRef = exchanged.tokens.refresh_token | |
| 981 | + ? yield* storeSecretMaterial({ | |
| 982 | + purpose: "oauth_refresh_token", | |
| 983 | + value: exchanged.tokens.refresh_token, | |
| 984 | + name: "MCP Refresh Token", | |
| 985 | + }) | |
| 986 | + : null; | |
| 987 | + | |
| 988 | + if (options.oauthSessions.remove) { | |
| 989 | + yield* options.oauthSessions.remove(input.state); | |
| 990 | + } | |
| 991 | + | |
| 992 | + return { | |
| 993 | + type: "executor:oauth-result", | |
| 994 | + ok: true, | |
| 995 | + sessionId: input.state, | |
| 996 | + auth: { | |
| 997 | + kind: "oauth2", | |
| 998 | + redirectUri: session.redirectUrl, | |
| 999 | + accessTokenRef, | |
| 1000 | + refreshTokenRef, | |
| 1001 | + tokenType: exchanged.tokens.token_type ?? "Bearer", | |
| 1002 | + expiresAt: expiresAtFromTokens(exchanged.tokens), | |
| 1003 | + scope: exchanged.tokens.scope ?? null, | |
| 1004 | + resourceMetadataUrl: | |
| 1005 | + exchanged.resourceMetadataUrl ?? session.resourceMetadataUrl, | |
| 1006 | + authorizationServerUrl: | |
| 1007 | + exchanged.authorizationServerUrl ?? session.authorizationServerUrl, | |
| 1008 | + resourceMetadata: | |
| 1009 | + exchanged.resourceMetadata ?? session.resourceMetadata, | |
| 1010 | + authorizationServerMetadata: | |
| 1011 | + exchanged.authorizationServerMetadata | |
| 1012 | + ?? session.authorizationServerMetadata, | |
| 1013 | + clientInformation: | |
| 1014 | + exchanged.clientInformation ?? session.clientInformation, | |
| 1015 | + }, | |
| 1016 | + }; | |
| 1017 | + }), | |
| 1018 | + ), | |
| 1019 | + }; | |
| 1020 | + }, | |
| 1021 | +}); | |
| @@ -0,0 +1,363 @@ | ||
| 1 | +import * as Option from "effect/Option"; | |
| 2 | +import * as Schema from "effect/Schema"; | |
| 3 | + | |
| 4 | +import type { ToolPath } from "@executor/codemode-core"; | |
| 5 | + | |
| 6 | +export type McpToolAnnotations = { | |
| 7 | + title: string | null; | |
| 8 | + readOnlyHint: boolean | null; | |
| 9 | + destructiveHint: boolean | null; | |
| 10 | + idempotentHint: boolean | null; | |
| 11 | + openWorldHint: boolean | null; | |
| 12 | +}; | |
| 13 | + | |
| 14 | +export type McpToolExecution = { | |
| 15 | + taskSupport: "forbidden" | "optional" | "required" | null; | |
| 16 | +}; | |
| 17 | + | |
| 18 | +export type McpServerInfo = { | |
| 19 | + name: string; | |
| 20 | + version: string; | |
| 21 | + title: string | null; | |
| 22 | + description: string | null; | |
| 23 | + websiteUrl: string | null; | |
| 24 | + icons: unknown[] | null; | |
| 25 | +}; | |
| 26 | + | |
| 27 | +export type McpServerCapabilities = { | |
| 28 | + experimental: Record<string, unknown> | null; | |
| 29 | + logging: boolean; | |
| 30 | + completions: boolean; | |
| 31 | + prompts: { | |
| 32 | + listChanged: boolean; | |
| 33 | + } | null; | |
| 34 | + resources: { | |
| 35 | + subscribe: boolean; | |
| 36 | + listChanged: boolean; | |
| 37 | + } | null; | |
| 38 | + tools: { | |
| 39 | + listChanged: boolean; | |
| 40 | + } | null; | |
| 41 | + tasks: { | |
| 42 | + list: boolean; | |
| 43 | + cancel: boolean; | |
| 44 | + toolCall: boolean; | |
| 45 | + } | null; | |
| 46 | +}; | |
| 47 | + | |
| 48 | +export type McpServerMetadata = { | |
| 49 | + info: McpServerInfo | null; | |
| 50 | + capabilities: McpServerCapabilities | null; | |
| 51 | + instructions: string | null; | |
| 52 | + rawInfo: unknown; | |
| 53 | + rawCapabilities: unknown; | |
| 54 | +}; | |
| 55 | + | |
| 56 | +export type McpListToolsMetadata = { | |
| 57 | + nextCursor: string | null; | |
| 58 | + meta: unknown; | |
| 59 | + rawResult: unknown; | |
| 60 | +}; | |
| 61 | + | |
| 62 | +export type McpToolManifestEntry = { | |
| 63 | + toolId: string; | |
| 64 | + toolName: string; | |
| 65 | + description: string | null; | |
| 66 | + title?: string | null; | |
| 67 | + displayTitle?: string | null; | |
| 68 | + annotations?: McpToolAnnotations | null; | |
| 69 | + execution?: McpToolExecution | null; | |
| 70 | + icons?: unknown[] | null; | |
| 71 | + meta?: unknown; | |
| 72 | + rawTool?: unknown; | |
| 73 | + inputSchema?: unknown; | |
| 74 | + outputSchema?: unknown; | |
| 75 | +}; | |
| 76 | + | |
| 77 | +export type McpToolManifest = { | |
| 78 | + version: 2; | |
| 79 | + server?: McpServerMetadata | null; | |
| 80 | + listTools?: McpListToolsMetadata | null; | |
| 81 | + tools: readonly McpToolManifestEntry[]; | |
| 82 | +}; | |
| 83 | + | |
| 84 | +const sanitizeToolId = (value: string): string => { | |
| 85 | + const normalized = value | |
| 86 | + .trim() | |
| 87 | + .toLowerCase() | |
| 88 | + .replace(/[^a-z0-9]+/g, "_") | |
| 89 | + .replace(/^_+|_+$/g, ""); | |
| 90 | + | |
| 91 | + return normalized.length > 0 ? normalized : "tool"; | |
| 92 | +}; | |
| 93 | + | |
| 94 | +const uniqueToolId = (value: string, byBase: Map<string, number>): string => { | |
| 95 | + const base = sanitizeToolId(value); | |
| 96 | + const count = (byBase.get(base) ?? 0) + 1; | |
| 97 | + byBase.set(base, count); | |
| 98 | + | |
| 99 | + return count === 1 ? base : `${base}_${count}`; | |
| 100 | +}; | |
| 101 | + | |
| 102 | +const asObject = (value: unknown): Record<string, unknown> => | |
| 103 | + value !== null && typeof value === "object" && !Array.isArray(value) | |
| 104 | + ? (value as Record<string, unknown>) | |
| 105 | + : {}; | |
| 106 | + | |
| 107 | +const asOptionalObject = (value: unknown): Record<string, unknown> | null => { | |
| 108 | + const objectValue = asObject(value); | |
| 109 | + return Object.keys(objectValue).length > 0 ? objectValue : null; | |
| 110 | +}; | |
| 111 | + | |
| 112 | +const hasOwn = (value: Record<string, unknown>, key: string): boolean => | |
| 113 | + Object.prototype.hasOwnProperty.call(value, key); | |
| 114 | + | |
| 115 | +const asString = (value: unknown): string | null => | |
| 116 | + typeof value === "string" && value.trim().length > 0 ? value.trim() : null; | |
| 117 | + | |
| 118 | +const asBoolean = (value: unknown): boolean | null => | |
| 119 | + typeof value === "boolean" ? value : null; | |
| 120 | + | |
| 121 | +const asUnknownArray = (value: unknown): unknown[] | null => | |
| 122 | + Array.isArray(value) ? value : null; | |
| 123 | + | |
| 124 | +const ToolAnnotationsSchema = Schema.Struct({ | |
| 125 | + title: Schema.optional(Schema.NullOr(Schema.String)), | |
| 126 | + readOnlyHint: Schema.optional(Schema.Boolean), | |
| 127 | + destructiveHint: Schema.optional(Schema.Boolean), | |
| 128 | + idempotentHint: Schema.optional(Schema.Boolean), | |
| 129 | + openWorldHint: Schema.optional(Schema.Boolean), | |
| 130 | +}); | |
| 131 | + | |
| 132 | +const ToolExecutionSchema = Schema.Struct({ | |
| 133 | + taskSupport: Schema.optional( | |
| 134 | + Schema.Literal("forbidden", "optional", "required"), | |
| 135 | + ), | |
| 136 | +}); | |
| 137 | + | |
| 138 | +const ListedMcpToolSchema = Schema.Struct({ | |
| 139 | + name: Schema.String, | |
| 140 | + title: Schema.optional(Schema.NullOr(Schema.String)), | |
| 141 | + description: Schema.optional(Schema.NullOr(Schema.String)), | |
| 142 | + inputSchema: Schema.optional(Schema.Unknown), | |
| 143 | + parameters: Schema.optional(Schema.Unknown), | |
| 144 | + outputSchema: Schema.optional(Schema.Unknown), | |
| 145 | + annotations: Schema.optional(ToolAnnotationsSchema), | |
| 146 | + execution: Schema.optional(ToolExecutionSchema), | |
| 147 | + icons: Schema.optional(Schema.Unknown), | |
| 148 | + _meta: Schema.optional(Schema.Unknown), | |
| 149 | +}); | |
| 150 | + | |
| 151 | +const ListToolsResultSchema = Schema.Struct({ | |
| 152 | + tools: Schema.Array(ListedMcpToolSchema), | |
| 153 | + nextCursor: Schema.optional(Schema.NullOr(Schema.String)), | |
| 154 | + _meta: Schema.optional(Schema.Unknown), | |
| 155 | +}); | |
| 156 | + | |
| 157 | +const decodeListToolsResultOption = Schema.decodeUnknownOption(ListToolsResultSchema); | |
| 158 | + | |
| 159 | +const readListedTools = (value: unknown): ReadonlyArray<typeof ListedMcpToolSchema.Type> => { | |
| 160 | + const decoded = decodeListToolsResultOption(value); | |
| 161 | + if (Option.isNone(decoded)) { | |
| 162 | + return []; | |
| 163 | + } | |
| 164 | + | |
| 165 | + return decoded.value.tools; | |
| 166 | +}; | |
| 167 | + | |
| 168 | +const normalizeToolAnnotations = (value: unknown): McpToolAnnotations | null => { | |
| 169 | + const objectValue = asOptionalObject(value); | |
| 170 | + if (objectValue === null) { | |
| 171 | + return null; | |
| 172 | + } | |
| 173 | + | |
| 174 | + const annotations = { | |
| 175 | + title: asString(objectValue.title), | |
| 176 | + readOnlyHint: asBoolean(objectValue.readOnlyHint), | |
| 177 | + destructiveHint: asBoolean(objectValue.destructiveHint), | |
| 178 | + idempotentHint: asBoolean(objectValue.idempotentHint), | |
| 179 | + openWorldHint: asBoolean(objectValue.openWorldHint), | |
| 180 | + } satisfies McpToolAnnotations; | |
| 181 | + | |
| 182 | + return Object.values(annotations).some((entry) => entry !== null) | |
| 183 | + ? annotations | |
| 184 | + : null; | |
| 185 | +}; | |
| 186 | + | |
| 187 | +const normalizeToolExecution = (value: unknown): McpToolExecution | null => { | |
| 188 | + const objectValue = asOptionalObject(value); | |
| 189 | + if (objectValue === null) { | |
| 190 | + return null; | |
| 191 | + } | |
| 192 | + | |
| 193 | + const taskSupport = objectValue.taskSupport; | |
| 194 | + if ( | |
| 195 | + taskSupport !== "forbidden" | |
| 196 | + && taskSupport !== "optional" | |
| 197 | + && taskSupport !== "required" | |
| 198 | + ) { | |
| 199 | + return null; | |
| 200 | + } | |
| 201 | + | |
| 202 | + return { | |
| 203 | + taskSupport, | |
| 204 | + }; | |
| 205 | +}; | |
| 206 | + | |
| 207 | +const normalizeServerInfo = (value: unknown): McpServerInfo | null => { | |
| 208 | + const objectValue = asOptionalObject(value); | |
| 209 | + const name = objectValue ? asString(objectValue.name) : null; | |
| 210 | + const version = objectValue ? asString(objectValue.version) : null; | |
| 211 | + if (name === null || version === null) { | |
| 212 | + return null; | |
| 213 | + } | |
| 214 | + | |
| 215 | + return { | |
| 216 | + name, | |
| 217 | + version, | |
| 218 | + title: asString(objectValue?.title), | |
| 219 | + description: asString(objectValue?.description), | |
| 220 | + websiteUrl: asString(objectValue?.websiteUrl), | |
| 221 | + icons: asUnknownArray(objectValue?.icons), | |
| 222 | + }; | |
| 223 | +}; | |
| 224 | + | |
| 225 | +const normalizeServerCapabilities = (value: unknown): McpServerCapabilities | null => { | |
| 226 | + const objectValue = asOptionalObject(value); | |
| 227 | + if (objectValue === null) { | |
| 228 | + return null; | |
| 229 | + } | |
| 230 | + | |
| 231 | + const prompts = asObject(objectValue.prompts); | |
| 232 | + const resources = asObject(objectValue.resources); | |
| 233 | + const tools = asObject(objectValue.tools); | |
| 234 | + const tasks = asObject(objectValue.tasks); | |
| 235 | + const taskRequests = asObject(tasks.requests); | |
| 236 | + const taskToolRequests = asObject(taskRequests.tools); | |
| 237 | + | |
| 238 | + return { | |
| 239 | + experimental: | |
| 240 | + hasOwn(objectValue, "experimental") | |
| 241 | + ? asObject(objectValue.experimental) | |
| 242 | + : null, | |
| 243 | + logging: hasOwn(objectValue, "logging"), | |
| 244 | + completions: hasOwn(objectValue, "completions"), | |
| 245 | + prompts: | |
| 246 | + !hasOwn(objectValue, "prompts") | |
| 247 | + ? null | |
| 248 | + : { | |
| 249 | + listChanged: asBoolean(prompts.listChanged) ?? false, | |
| 250 | + }, | |
| 251 | + resources: | |
| 252 | + !hasOwn(objectValue, "resources") | |
| 253 | + ? null | |
| 254 | + : { | |
| 255 | + subscribe: asBoolean(resources.subscribe) ?? false, | |
| 256 | + listChanged: asBoolean(resources.listChanged) ?? false, | |
| 257 | + }, | |
| 258 | + tools: | |
| 259 | + !hasOwn(objectValue, "tools") | |
| 260 | + ? null | |
| 261 | + : { | |
| 262 | + listChanged: asBoolean(tools.listChanged) ?? false, | |
| 263 | + }, | |
| 264 | + tasks: | |
| 265 | + !hasOwn(objectValue, "tasks") | |
| 266 | + ? null | |
| 267 | + : { | |
| 268 | + list: hasOwn(tasks, "list"), | |
| 269 | + cancel: hasOwn(tasks, "cancel"), | |
| 270 | + toolCall: hasOwn(taskToolRequests, "call"), | |
| 271 | + }, | |
| 272 | + }; | |
| 273 | +}; | |
| 274 | + | |
| 275 | +const normalizeServerMetadata = (input?: { | |
| 276 | + serverInfo?: unknown; | |
| 277 | + serverCapabilities?: unknown; | |
| 278 | + instructions?: string | null | undefined; | |
| 279 | +}): McpServerMetadata | null => { | |
| 280 | + const info = normalizeServerInfo(input?.serverInfo); | |
| 281 | + const capabilities = normalizeServerCapabilities(input?.serverCapabilities); | |
| 282 | + const instructions = asString(input?.instructions); | |
| 283 | + const rawInfo = input?.serverInfo ?? null; | |
| 284 | + const rawCapabilities = input?.serverCapabilities ?? null; | |
| 285 | + | |
| 286 | + if ( | |
| 287 | + info === null | |
| 288 | + && capabilities === null | |
| 289 | + && instructions === null | |
| 290 | + && rawInfo === null | |
| 291 | + && rawCapabilities === null | |
| 292 | + ) { | |
| 293 | + return null; | |
| 294 | + } | |
| 295 | + | |
| 296 | + return { | |
| 297 | + info, | |
| 298 | + capabilities, | |
| 299 | + instructions, | |
| 300 | + rawInfo, | |
| 301 | + rawCapabilities, | |
| 302 | + }; | |
| 303 | +}; | |
| 304 | + | |
| 305 | +export const extractMcpToolManifestFromListToolsResult = ( | |
| 306 | + listToolsResult: unknown, | |
| 307 | + metadata?: { | |
| 308 | + serverInfo?: unknown; | |
| 309 | + serverCapabilities?: unknown; | |
| 310 | + instructions?: string | null | undefined; | |
| 311 | + }, | |
| 312 | +): McpToolManifest => { | |
| 313 | + const byBase = new Map<string, number>(); | |
| 314 | + const rawListTools = asObject(listToolsResult); | |
| 315 | + const rawListedTools = Array.isArray(rawListTools.tools) ? rawListTools.tools : []; | |
| 316 | + | |
| 317 | + const tools = readListedTools(listToolsResult) | |
| 318 | + .map((tool, index): McpToolManifestEntry | null => { | |
| 319 | + const toolName = tool.name.trim(); | |
| 320 | + if (toolName.length === 0) { | |
| 321 | + return null; | |
| 322 | + } | |
| 323 | + | |
| 324 | + const title = asString(tool.title); | |
| 325 | + const annotations = normalizeToolAnnotations(tool.annotations); | |
| 326 | + const displayTitle = title ?? annotations?.title ?? toolName; | |
| 327 | + | |
| 328 | + return { | |
| 329 | + toolId: uniqueToolId(toolName, byBase), | |
| 330 | + toolName, | |
| 331 | + title, | |
| 332 | + displayTitle, | |
| 333 | + description: tool.description ?? null, | |
| 334 | + annotations, | |
| 335 | + execution: normalizeToolExecution(tool.execution), | |
| 336 | + icons: asUnknownArray(tool.icons), | |
| 337 | + meta: tool._meta ?? null, | |
| 338 | + rawTool: rawListedTools[index] ?? null, | |
| 339 | + inputSchema: tool.inputSchema ?? tool.parameters, | |
| 340 | + outputSchema: tool.outputSchema, | |
| 341 | + }; | |
| 342 | + }) | |
| 343 | + .filter((tool): tool is McpToolManifestEntry => tool !== null); | |
| 344 | + | |
| 345 | + return { | |
| 346 | + version: 2, | |
| 347 | + server: normalizeServerMetadata(metadata), | |
| 348 | + listTools: { | |
| 349 | + nextCursor: asString(rawListTools.nextCursor), | |
| 350 | + meta: rawListTools._meta ?? null, | |
| 351 | + rawResult: listToolsResult, | |
| 352 | + }, | |
| 353 | + tools, | |
| 354 | + }; | |
| 355 | +}; | |
| 356 | + | |
| 357 | +export const joinToolPath = (namespace: string | undefined, toolId: string): ToolPath => { | |
| 358 | + if (!namespace || namespace.trim().length === 0) { | |
| 359 | + return toolId as ToolPath; | |
| 360 | + } | |
| 361 | + | |
| 362 | + return `${namespace}.${toolId}` as ToolPath; | |
| 363 | +}; | |
| @@ -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 | +} | |
| @@ -0,0 +1,581 @@ | ||
| 1 | +import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"; | |
| 2 | +import * as Data from "effect/Data"; | |
| 3 | +import * as Either from "effect/Either"; | |
| 4 | +import * as Effect from "effect/Effect"; | |
| 5 | +import * as Cause from "effect/Cause"; | |
| 6 | +import * as Exit from "effect/Exit"; | |
| 7 | +import * as PartitionedSemaphore from "effect/PartitionedSemaphore"; | |
| 8 | + | |
| 9 | +import { | |
| 10 | + standardSchemaFromJsonSchema, | |
| 11 | + toTool, | |
| 12 | + type ElicitationRequest, | |
| 13 | + type ElicitationResponse, | |
| 14 | + type ToolInvocationContext, | |
| 15 | + type ToolExecutionContext, | |
| 16 | + type ToolMap, | |
| 17 | + type ToolMetadata, | |
| 18 | + type ToolPath, | |
| 19 | + unknownInputSchema, | |
| 20 | +} from "@executor/codemode-core"; | |
| 21 | + | |
| 22 | +import { | |
| 23 | + createInteractionId, | |
| 24 | + hasElicitationRequestHandler, | |
| 25 | + isUrlElicitationRequiredError, | |
| 26 | + readMcpElicitationRequest, | |
| 27 | + readUnknownRecord, | |
| 28 | + toMcpElicitationResponse, | |
| 29 | +} from "./elicitation-bridge"; | |
| 30 | +import { | |
| 31 | + extractMcpToolManifestFromListToolsResult, | |
| 32 | + joinToolPath, | |
| 33 | + type McpToolManifest, | |
| 34 | + type McpToolManifestEntry, | |
| 35 | +} from "./manifest"; | |
| 36 | + | |
| 37 | +export type { McpToolManifest, McpToolManifestEntry }; | |
| 38 | +export { extractMcpToolManifestFromListToolsResult }; | |
| 39 | + | |
| 40 | +export type McpClientLike = { | |
| 41 | + listTools: () => Promise<unknown>; | |
| 42 | + callTool: (input: { | |
| 43 | + name: string; | |
| 44 | + arguments?: Record<string, unknown>; | |
| 45 | + }) => Promise<unknown>; | |
| 46 | + getServerCapabilities?: () => unknown; | |
| 47 | + getServerVersion?: () => unknown; | |
| 48 | + getInstructions?: () => string | undefined; | |
| 49 | +}; | |
| 50 | + | |
| 51 | +export type McpConnection = { | |
| 52 | + client: McpClientLike; | |
| 53 | + close?: () => Promise<void>; | |
| 54 | +}; | |
| 55 | + | |
| 56 | +export type McpConnector = Effect.Effect<McpConnection, unknown, never>; | |
| 57 | + | |
| 58 | +export type McpDiscoveryElicitationContext = { | |
| 59 | + onElicitation: NonNullable<ToolExecutionContext["onElicitation"]>; | |
| 60 | + path: ToolPath; | |
| 61 | + sourceKey: string; | |
| 62 | + args: Record<string, unknown>; | |
| 63 | + metadata?: ToolMetadata; | |
| 64 | + invocation?: ToolInvocationContext; | |
| 65 | +}; | |
| 66 | + | |
| 67 | +type McpDiscoveryStage = "connect" | "list_tools" | "call_tool"; | |
| 68 | + | |
| 69 | +export class McpToolsError extends Data.TaggedError("McpToolsError")<{ | |
| 70 | + stage: McpDiscoveryStage; | |
| 71 | + message: string; | |
| 72 | + details: string | null; | |
| 73 | +}> {} | |
| 74 | + | |
| 75 | +const EXECUTION_SUSPENDED_SENTINEL = "__EXECUTION_SUSPENDED__"; | |
| 76 | + | |
| 77 | +const causeText = (cause: unknown): string => { | |
| 78 | + if (cause instanceof Error) { | |
| 79 | + return `${cause.message}\n${cause.stack ?? ""}`; | |
| 80 | + } | |
| 81 | + | |
| 82 | + if (typeof cause === "string") { | |
| 83 | + return cause; | |
| 84 | + } | |
| 85 | + | |
| 86 | + try { | |
| 87 | + return JSON.stringify(cause); | |
| 88 | + } catch { | |
| 89 | + return String(cause); | |
| 90 | + } | |
| 91 | +}; | |
| 92 | + | |
| 93 | +const isExecutionSuspendedCause = (cause: unknown): boolean => | |
| 94 | + causeText(cause).includes(EXECUTION_SUSPENDED_SENTINEL); | |
| 95 | + | |
| 96 | +const toDetails = (cause: unknown): string => | |
| 97 | + cause instanceof Error ? cause.message : String(cause); | |
| 98 | + | |
| 99 | +const inputSchemaFromManifest = (inputSchema: unknown) => { | |
| 100 | + if (inputSchema === undefined || inputSchema === null) { | |
| 101 | + return unknownInputSchema; | |
| 102 | + } | |
| 103 | + | |
| 104 | + try { | |
| 105 | + return standardSchemaFromJsonSchema(inputSchema, { | |
| 106 | + vendor: "mcp", | |
| 107 | + fallback: unknownInputSchema, | |
| 108 | + }); | |
| 109 | + } catch { | |
| 110 | + return unknownInputSchema; | |
| 111 | + } | |
| 112 | +}; | |
| 113 | + | |
| 114 | +const closeConnection = (connection: McpConnection): Effect.Effect<void> => | |
| 115 | + Effect.tryPromise({ | |
| 116 | + try: () => connection.close?.() ?? Promise.resolve(), | |
| 117 | + catch: (cause) => | |
| 118 | + cause instanceof Error ? cause : new Error(String(cause ?? "mcp connection close failed")), | |
| 119 | + }).pipe( | |
| 120 | + Effect.asVoid, | |
| 121 | + Effect.catchAll(() => Effect.void), | |
| 122 | + ); | |
| 123 | + | |
| 124 | +const withConnectionEffect = <A, E>(input: { | |
| 125 | + connect: McpConnector; | |
| 126 | + onConnectError: (cause: unknown) => McpToolsError; | |
| 127 | + run: (connection: McpConnection) => Effect.Effect<A, E>; | |
| 128 | +}): Effect.Effect<A, E | McpToolsError> => | |
| 129 | + Effect.acquireUseRelease( | |
| 130 | + input.connect.pipe(Effect.mapError(input.onConnectError)), | |
| 131 | + input.run, | |
| 132 | + closeConnection, | |
| 133 | + ); | |
| 134 | + | |
| 135 | +const elicitationClientSemaphore = PartitionedSemaphore.makeUnsafe<McpClientLike>({ | |
| 136 | + permits: 1, | |
| 137 | +}); | |
| 138 | + | |
| 139 | +const withElicitationClientLock = <A, E>( | |
| 140 | + client: McpClientLike, | |
| 141 | + effect: Effect.Effect<A, E>, | |
| 142 | +): Effect.Effect<A, E> => elicitationClientSemaphore.withPermits(client, 1)(effect); | |
| 143 | + | |
| 144 | +const resolveElicitationResponse = (input: { | |
| 145 | + toolName: string; | |
| 146 | + onElicitation: NonNullable<ToolExecutionContext["onElicitation"]>; | |
| 147 | + interactionId: string; | |
| 148 | + path: ToolPath; | |
| 149 | + sourceKey: string; | |
| 150 | + args: Record<string, unknown>; | |
| 151 | + executionContext?: ToolExecutionContext; | |
| 152 | + elicitation: ElicitationRequest; | |
| 153 | +}): Effect.Effect<ElicitationResponse, McpToolsError> => | |
| 154 | + input.onElicitation({ | |
| 155 | + interactionId: input.interactionId, | |
| 156 | + path: input.path, | |
| 157 | + sourceKey: input.sourceKey, | |
| 158 | + args: input.args, | |
| 159 | + metadata: input.executionContext?.metadata, | |
| 160 | + context: input.executionContext?.invocation, | |
| 161 | + elicitation: input.elicitation, | |
| 162 | + }).pipe( | |
| 163 | + Effect.mapError((cause) => | |
| 164 | + isExecutionSuspendedCause(cause) | |
| 165 | + ? (cause as McpToolsError) | |
| 166 | + : new McpToolsError({ | |
| 167 | + stage: "call_tool", | |
| 168 | + message: `Failed resolving elicitation for ${input.toolName}`, | |
| 169 | + details: toDetails(cause), | |
| 170 | + })), | |
| 171 | + ); | |
| 172 | + | |
| 173 | +const installMcpElicitationHandler = (input: { | |
| 174 | + client: McpClientLike; | |
| 175 | + toolName: string; | |
| 176 | + onElicitation: NonNullable<ToolExecutionContext["onElicitation"]>; | |
| 177 | + path: ToolPath; | |
| 178 | + sourceKey: string; | |
| 179 | + args: Record<string, unknown>; | |
| 180 | + executionContext?: ToolExecutionContext; | |
| 181 | +}): Effect.Effect<void, McpToolsError> => { | |
| 182 | + const client = input.client; | |
| 183 | + if (!hasElicitationRequestHandler(client)) { | |
| 184 | + return Effect.succeed(undefined); | |
| 185 | + } | |
| 186 | + | |
| 187 | + return Effect.try({ | |
| 188 | + try: () => { | |
| 189 | + let sequence = 0; | |
| 190 | + client.setRequestHandler(ElicitRequestSchema, (request: { params: unknown }) => { | |
| 191 | + sequence += 1; | |
| 192 | + | |
| 193 | + return Effect.runPromise( | |
| 194 | + Effect.try({ | |
| 195 | + try: () => readMcpElicitationRequest(request.params), | |
| 196 | + catch: (cause) => | |
| 197 | + new McpToolsError({ | |
| 198 | + stage: "call_tool", | |
| 199 | + message: `Failed parsing MCP elicitation for ${input.toolName}`, | |
| 200 | + details: toDetails(cause), | |
| 201 | + }), | |
| 202 | + }).pipe( | |
| 203 | + Effect.flatMap((elicitation) => | |
| 204 | + resolveElicitationResponse({ | |
| 205 | + toolName: input.toolName, | |
| 206 | + onElicitation: input.onElicitation, | |
| 207 | + interactionId: createInteractionId({ | |
| 208 | + path: input.path, | |
| 209 | + invocation: input.executionContext?.invocation, | |
| 210 | + elicitation, | |
| 211 | + sequence, | |
| 212 | + }), | |
| 213 | + path: input.path, | |
| 214 | + sourceKey: input.sourceKey, | |
| 215 | + args: input.args, | |
| 216 | + executionContext: input.executionContext, | |
| 217 | + elicitation, | |
| 218 | + }), | |
| 219 | + ), | |
| 220 | + Effect.map(toMcpElicitationResponse), | |
| 221 | + Effect.catchAll((error) => { | |
| 222 | + if (isExecutionSuspendedCause(error)) { | |
| 223 | + return Effect.fail(error); | |
| 224 | + } | |
| 225 | + | |
| 226 | + console.error( | |
| 227 | + `[mcp-tools] elicitation failed for ${input.toolName}, treating as cancel:`, | |
| 228 | + error instanceof Error ? error.message : String(error), | |
| 229 | + ); | |
| 230 | + return Effect.succeed({ action: "cancel" as const }); | |
| 231 | + }), | |
| 232 | + ), | |
| 233 | + ); | |
| 234 | + }); | |
| 235 | + }, | |
| 236 | + catch: (cause) => | |
| 237 | + cause instanceof McpToolsError | |
| 238 | + ? cause | |
| 239 | + : new McpToolsError({ | |
| 240 | + stage: "call_tool", | |
| 241 | + message: `Failed installing elicitation handler for ${input.toolName}`, | |
| 242 | + details: toDetails(cause), | |
| 243 | + }), | |
| 244 | + }); | |
| 245 | +}; | |
| 246 | + | |
| 247 | +const resolveUrlElicitations = (input: { | |
| 248 | + cause: { | |
| 249 | + elicitations: ReadonlyArray<ElicitationRequest>; | |
| 250 | + }; | |
| 251 | + toolName: string; | |
| 252 | + onElicitation: NonNullable<ToolExecutionContext["onElicitation"]>; | |
| 253 | + path: ToolPath; | |
| 254 | + sourceKey: string; | |
| 255 | + args: Record<string, unknown>; | |
| 256 | + executionContext?: ToolExecutionContext; | |
| 257 | +}): Effect.Effect<void, McpToolsError> => | |
| 258 | + Effect.forEach( | |
| 259 | + input.cause.elicitations, | |
| 260 | + (elicitation) => | |
| 261 | + resolveElicitationResponse({ | |
| 262 | + toolName: input.toolName, | |
| 263 | + onElicitation: input.onElicitation, | |
| 264 | + interactionId: createInteractionId({ | |
| 265 | + path: input.path, | |
| 266 | + invocation: input.executionContext?.invocation, | |
| 267 | + elicitation, | |
| 268 | + }), | |
| 269 | + path: input.path, | |
| 270 | + sourceKey: input.sourceKey, | |
| 271 | + args: input.args, | |
| 272 | + executionContext: input.executionContext, | |
| 273 | + elicitation, | |
| 274 | + }).pipe( | |
| 275 | + Effect.flatMap((response) => | |
| 276 | + response.action === "accept" | |
| 277 | + ? Effect.succeed(undefined) | |
| 278 | + : Effect.fail( | |
| 279 | + new McpToolsError({ | |
| 280 | + stage: "call_tool", | |
| 281 | + message: `URL elicitation was not accepted for ${input.toolName}`, | |
| 282 | + details: response.action, | |
| 283 | + }), | |
| 284 | + )), | |
| 285 | + ), | |
| 286 | + { discard: true }, | |
| 287 | + ); | |
| 288 | + | |
| 289 | +const callMcpTool = (input: { | |
| 290 | + client: McpClientLike; | |
| 291 | + toolName: string; | |
| 292 | + args: Record<string, unknown>; | |
| 293 | +}): Effect.Effect<unknown, unknown> => | |
| 294 | + Effect.tryPromise({ | |
| 295 | + try: () => | |
| 296 | + input.client.callTool({ | |
| 297 | + name: input.toolName, | |
| 298 | + arguments: input.args, | |
| 299 | + }), | |
| 300 | + catch: (cause) => cause, | |
| 301 | + }); | |
| 302 | + | |
| 303 | +const toToolExecutionContext = ( | |
| 304 | + input: McpDiscoveryElicitationContext | undefined, | |
| 305 | +): ToolExecutionContext | undefined => | |
| 306 | + input | |
| 307 | + ? { | |
| 308 | + path: input.path, | |
| 309 | + sourceKey: input.sourceKey, | |
| 310 | + metadata: input.metadata, | |
| 311 | + invocation: input.invocation, | |
| 312 | + onElicitation: input.onElicitation, | |
| 313 | + } | |
| 314 | + : undefined; | |
| 315 | + | |
| 316 | +const runMcpListToolsEffect = (input: { | |
| 317 | + connection: McpConnection; | |
| 318 | + mcpDiscoveryElicitation?: McpDiscoveryElicitationContext; | |
| 319 | +}): Effect.Effect<unknown, McpToolsError> => | |
| 320 | + Effect.gen(function* () { | |
| 321 | + const executionContext = toToolExecutionContext(input.mcpDiscoveryElicitation); | |
| 322 | + | |
| 323 | + if (input.mcpDiscoveryElicitation) { | |
| 324 | + yield* installMcpElicitationHandler({ | |
| 325 | + client: input.connection.client, | |
| 326 | + toolName: "tools/list", | |
| 327 | + onElicitation: input.mcpDiscoveryElicitation.onElicitation, | |
| 328 | + path: input.mcpDiscoveryElicitation.path, | |
| 329 | + sourceKey: input.mcpDiscoveryElicitation.sourceKey, | |
| 330 | + args: input.mcpDiscoveryElicitation.args, | |
| 331 | + executionContext, | |
| 332 | + }); | |
| 333 | + } | |
| 334 | + | |
| 335 | + let retries = 0; | |
| 336 | + while (true) { | |
| 337 | + const attempt = yield* Effect.either( | |
| 338 | + Effect.tryPromise({ | |
| 339 | + try: () => input.connection.client.listTools(), | |
| 340 | + catch: (cause) => cause, | |
| 341 | + }), | |
| 342 | + ); | |
| 343 | + | |
| 344 | + if (Either.isRight(attempt)) { | |
| 345 | + return attempt.right; | |
| 346 | + } | |
| 347 | + | |
| 348 | + if (isExecutionSuspendedCause(attempt.left)) { | |
| 349 | + return yield* (attempt.left as McpToolsError); | |
| 350 | + } | |
| 351 | + | |
| 352 | + if ( | |
| 353 | + input.mcpDiscoveryElicitation | |
| 354 | + && isUrlElicitationRequiredError(attempt.left) | |
| 355 | + && retries < 2 | |
| 356 | + ) { | |
| 357 | + yield* resolveUrlElicitations({ | |
| 358 | + cause: attempt.left, | |
| 359 | + toolName: "tools/list", | |
| 360 | + onElicitation: input.mcpDiscoveryElicitation.onElicitation, | |
| 361 | + path: input.mcpDiscoveryElicitation.path, | |
| 362 | + sourceKey: input.mcpDiscoveryElicitation.sourceKey, | |
| 363 | + args: input.mcpDiscoveryElicitation.args, | |
| 364 | + executionContext, | |
| 365 | + }); | |
| 366 | + retries += 1; | |
| 367 | + continue; | |
| 368 | + } | |
| 369 | + | |
| 370 | + return yield* new McpToolsError({ | |
| 371 | + stage: "list_tools", | |
| 372 | + message: "Failed listing MCP tools", | |
| 373 | + details: toDetails(attempt.left), | |
| 374 | + }); | |
| 375 | + } | |
| 376 | + }); | |
| 377 | + | |
| 378 | +const runMcpToolCallEffect = (input: { | |
| 379 | + connection: McpConnection; | |
| 380 | + toolName: string; | |
| 381 | + path: ToolPath; | |
| 382 | + sourceKey: string; | |
| 383 | + args: Record<string, unknown>; | |
| 384 | + executionContext?: ToolExecutionContext; | |
| 385 | +}): Effect.Effect<unknown, McpToolsError> => | |
| 386 | + Effect.gen(function* () { | |
| 387 | + const onElicitation = input.executionContext?.onElicitation; | |
| 388 | + if (onElicitation) { | |
| 389 | + yield* installMcpElicitationHandler({ | |
| 390 | + client: input.connection.client, | |
| 391 | + toolName: input.toolName, | |
| 392 | + onElicitation, | |
| 393 | + path: input.path, | |
| 394 | + sourceKey: input.sourceKey, | |
| 395 | + args: input.args, | |
| 396 | + executionContext: input.executionContext, | |
| 397 | + }); | |
| 398 | + } | |
| 399 | + | |
| 400 | + let retries = 0; | |
| 401 | + while (true) { | |
| 402 | + const attempt = yield* Effect.either( | |
| 403 | + callMcpTool({ | |
| 404 | + client: input.connection.client, | |
| 405 | + toolName: input.toolName, | |
| 406 | + args: input.args, | |
| 407 | + }), | |
| 408 | + ); | |
| 409 | + | |
| 410 | + if (Either.isRight(attempt)) { | |
| 411 | + return attempt.right; | |
| 412 | + } | |
| 413 | + | |
| 414 | + if (isExecutionSuspendedCause(attempt.left)) { | |
| 415 | + return yield* (attempt.left as McpToolsError); | |
| 416 | + } | |
| 417 | + | |
| 418 | + if ( | |
| 419 | + onElicitation | |
| 420 | + && isUrlElicitationRequiredError(attempt.left) | |
| 421 | + && retries < 2 | |
| 422 | + ) { | |
| 423 | + yield* resolveUrlElicitations({ | |
| 424 | + cause: attempt.left, | |
| 425 | + toolName: input.toolName, | |
| 426 | + onElicitation, | |
| 427 | + path: input.path, | |
| 428 | + sourceKey: input.sourceKey, | |
| 429 | + args: input.args, | |
| 430 | + executionContext: input.executionContext, | |
| 431 | + }); | |
| 432 | + retries += 1; | |
| 433 | + continue; | |
| 434 | + } | |
| 435 | + | |
| 436 | + return yield* new McpToolsError({ | |
| 437 | + stage: "call_tool", | |
| 438 | + message: `Failed invoking MCP tool: ${input.toolName}`, | |
| 439 | + details: toDetails(attempt.left), | |
| 440 | + }); | |
| 441 | + } | |
| 442 | + }); | |
| 443 | + | |
| 444 | +export const createMcpConnectorFromClient = ( | |
| 445 | + client: McpClientLike, | |
| 446 | +): McpConnector => | |
| 447 | + Effect.succeed({ | |
| 448 | + client, | |
| 449 | + close: async () => undefined, | |
| 450 | + }); | |
| 451 | + | |
| 452 | +export const createMcpToolsFromManifest = (input: { | |
| 453 | + manifest: McpToolManifest; | |
| 454 | + connect: McpConnector; | |
| 455 | + namespace?: string; | |
| 456 | + sourceKey?: string; | |
| 457 | +}): ToolMap => { | |
| 458 | + const sourceKey = input.sourceKey ?? "mcp.generated"; | |
| 459 | + | |
| 460 | + return Object.fromEntries( | |
| 461 | + input.manifest.tools.map((entry) => { | |
| 462 | + const path = joinToolPath(input.namespace, entry.toolId); | |
| 463 | + | |
| 464 | + return [ | |
| 465 | + path, | |
| 466 | + toTool({ | |
| 467 | + tool: { | |
| 468 | + description: entry.description ?? `MCP tool: ${entry.toolName}`, | |
| 469 | + inputSchema: inputSchemaFromManifest(entry.inputSchema), | |
| 470 | + execute: async (args: unknown, executionContext?: ToolExecutionContext) => { | |
| 471 | + const exit = await Effect.runPromiseExit( | |
| 472 | + withConnectionEffect({ | |
| 473 | + connect: input.connect, | |
| 474 | + onConnectError: (cause) => | |
| 475 | + new McpToolsError({ | |
| 476 | + stage: "connect", | |
| 477 | + message: `Failed connecting to MCP server for ${entry.toolName}`, | |
| 478 | + details: toDetails(cause), | |
| 479 | + }), | |
| 480 | + run: (connection) => { | |
| 481 | + const payloadArgs = readUnknownRecord(args); | |
| 482 | + const callEffect = runMcpToolCallEffect({ | |
| 483 | + connection, | |
| 484 | + toolName: entry.toolName, | |
| 485 | + path, | |
| 486 | + sourceKey, | |
| 487 | + args: payloadArgs, | |
| 488 | + executionContext, | |
| 489 | + }); | |
| 490 | + | |
| 491 | + return executionContext?.onElicitation | |
| 492 | + ? withElicitationClientLock(connection.client, callEffect) | |
| 493 | + : callEffect; | |
| 494 | + }, | |
| 495 | + }), | |
| 496 | + ); | |
| 497 | + if (Exit.isSuccess(exit)) return exit.value; | |
| 498 | + throw Cause.squash(exit.cause); | |
| 499 | + }, | |
| 500 | + }, | |
| 501 | + metadata: { | |
| 502 | + sourceKey, | |
| 503 | + contract: { | |
| 504 | + ...(entry.inputSchema !== undefined | |
| 505 | + ? { inputSchema: entry.inputSchema } | |
| 506 | + : {}), | |
| 507 | + ...(entry.outputSchema !== undefined | |
| 508 | + ? { outputSchema: entry.outputSchema } | |
| 509 | + : {}), | |
| 510 | + }, | |
| 511 | + }, | |
| 512 | + }), | |
| 513 | + ] as const; | |
| 514 | + }), | |
| 515 | + ); | |
| 516 | +}; | |
| 517 | + | |
| 518 | +export const discoverMcpToolsFromConnector = (input: { | |
| 519 | + connect: McpConnector; | |
| 520 | + namespace?: string; | |
| 521 | + sourceKey?: string; | |
| 522 | + mcpDiscoveryElicitation?: McpDiscoveryElicitationContext; | |
| 523 | +}): Effect.Effect<{ manifest: McpToolManifest; tools: ToolMap }, McpToolsError> => | |
| 524 | + Effect.gen(function* () { | |
| 525 | + const listed = yield* withConnectionEffect({ | |
| 526 | + connect: input.connect, | |
| 527 | + onConnectError: (cause) => | |
| 528 | + new McpToolsError({ | |
| 529 | + stage: "connect", | |
| 530 | + message: "Failed connecting to MCP server", | |
| 531 | + details: toDetails(cause), | |
| 532 | + }), | |
| 533 | + run: (connection) => { | |
| 534 | + const listEffect = runMcpListToolsEffect({ | |
| 535 | + connection, | |
| 536 | + mcpDiscoveryElicitation: input.mcpDiscoveryElicitation, | |
| 537 | + }); | |
| 538 | + | |
| 539 | + const settledListEffect = input.mcpDiscoveryElicitation | |
| 540 | + ? withElicitationClientLock(connection.client, listEffect) | |
| 541 | + : listEffect; | |
| 542 | + | |
| 543 | + return Effect.map(settledListEffect, (listed) => ({ | |
| 544 | + listed, | |
| 545 | + serverInfo: connection.client.getServerVersion?.(), | |
| 546 | + serverCapabilities: connection.client.getServerCapabilities?.(), | |
| 547 | + instructions: connection.client.getInstructions?.(), | |
| 548 | + })); | |
| 549 | + }, | |
| 550 | + }); | |
| 551 | + | |
| 552 | + const manifest = extractMcpToolManifestFromListToolsResult( | |
| 553 | + listed.listed, | |
| 554 | + { | |
| 555 | + serverInfo: listed.serverInfo, | |
| 556 | + serverCapabilities: listed.serverCapabilities, | |
| 557 | + instructions: listed.instructions, | |
| 558 | + }, | |
| 559 | + ); | |
| 560 | + | |
| 561 | + return { | |
| 562 | + manifest, | |
| 563 | + tools: createMcpToolsFromManifest({ | |
| 564 | + manifest, | |
| 565 | + connect: input.connect, | |
| 566 | + namespace: input.namespace, | |
| 567 | + sourceKey: input.sourceKey, | |
| 568 | + }), | |
| 569 | + }; | |
| 570 | + }); | |
| 571 | + | |
| 572 | +export const discoverMcpToolsFromClient = (input: { | |
| 573 | + client: McpClientLike; | |
| 574 | + namespace?: string; | |
| 575 | + sourceKey?: string; | |
| 576 | +}): Effect.Effect<{ manifest: McpToolManifest; tools: ToolMap }, McpToolsError> => | |
| 577 | + discoverMcpToolsFromConnector({ | |
| 578 | + connect: createMcpConnectorFromClient(input.client), | |
| 579 | + namespace: input.namespace, | |
| 580 | + sourceKey: input.sourceKey, | |
| 581 | + }); | |
| @@ -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 | +} | |
| @@ -0,0 +1,163 @@ | ||
| 1 | +import * as Schema from "effect/Schema"; | |
| 2 | + | |
| 3 | +import { | |
| 4 | + SecretRefSchema, | |
| 5 | +} from "@executor/platform-sdk/schema"; | |
| 6 | +import { | |
| 7 | + JsonObjectSchema, | |
| 8 | + SourceTransportSchema, | |
| 9 | + StringArraySchema, | |
| 10 | + StringMapSchema, | |
| 11 | + defaultNameFromEndpoint, | |
| 12 | + namespaceFromSourceName, | |
| 13 | +} from "@executor/source-core"; | |
| 14 | + | |
| 15 | +export const McpConnectionAuthSchema = Schema.Union( | |
| 16 | + Schema.Struct({ | |
| 17 | + kind: Schema.Literal("none"), | |
| 18 | + }), | |
| 19 | + Schema.Struct({ | |
| 20 | + kind: Schema.Literal("oauth2"), | |
| 21 | + redirectUri: Schema.String, | |
| 22 | + accessTokenRef: SecretRefSchema, | |
| 23 | + refreshTokenRef: Schema.NullOr(SecretRefSchema), | |
| 24 | + tokenType: Schema.String, | |
| 25 | + expiresAt: Schema.NullOr(Schema.Number), | |
| 26 | + scope: Schema.NullOr(Schema.String), | |
| 27 | + resourceMetadataUrl: Schema.NullOr(Schema.String), | |
| 28 | + authorizationServerUrl: Schema.NullOr(Schema.String), | |
| 29 | + resourceMetadata: Schema.NullOr(JsonObjectSchema), | |
| 30 | + authorizationServerMetadata: Schema.NullOr(JsonObjectSchema), | |
| 31 | + clientInformation: Schema.NullOr(JsonObjectSchema), | |
| 32 | + }), | |
| 33 | +); | |
| 34 | + | |
| 35 | +export const McpConnectInputSchema = Schema.Struct({ | |
| 36 | + name: Schema.String, | |
| 37 | + endpoint: Schema.NullOr(Schema.String), | |
| 38 | + transport: Schema.NullOr(SourceTransportSchema), | |
| 39 | + queryParams: Schema.NullOr(StringMapSchema), | |
| 40 | + headers: Schema.NullOr(StringMapSchema), | |
| 41 | + command: Schema.NullOr(Schema.String), | |
| 42 | + args: Schema.NullOr(StringArraySchema), | |
| 43 | + env: Schema.NullOr(StringMapSchema), | |
| 44 | + cwd: Schema.NullOr(Schema.String), | |
| 45 | + auth: McpConnectionAuthSchema, | |
| 46 | +}); | |
| 47 | + | |
| 48 | +export const McpSourceConfigPayloadSchema = McpConnectInputSchema; | |
| 49 | + | |
| 50 | +export const McpUpdateSourceInputSchema = Schema.Struct({ | |
| 51 | + sourceId: Schema.String, | |
| 52 | + config: McpSourceConfigPayloadSchema, | |
| 53 | +}); | |
| 54 | + | |
| 55 | +export const McpStoredSourceDataSchema = Schema.Struct({ | |
| 56 | + endpoint: Schema.NullOr(Schema.String), | |
| 57 | + transport: Schema.NullOr(SourceTransportSchema), | |
| 58 | + queryParams: Schema.NullOr(StringMapSchema), | |
| 59 | + headers: Schema.NullOr(StringMapSchema), | |
| 60 | + command: Schema.NullOr(Schema.String), | |
| 61 | + args: Schema.NullOr(StringArraySchema), | |
| 62 | + env: Schema.NullOr(StringMapSchema), | |
| 63 | + cwd: Schema.NullOr(Schema.String), | |
| 64 | + auth: McpConnectionAuthSchema, | |
| 65 | +}); | |
| 66 | + | |
| 67 | +export const McpStartOAuthInputSchema = Schema.Struct({ | |
| 68 | + endpoint: Schema.String, | |
| 69 | + queryParams: Schema.NullOr(StringMapSchema), | |
| 70 | + redirectUrl: Schema.String, | |
| 71 | +}); | |
| 72 | + | |
| 73 | +export const McpStartOAuthResultSchema = Schema.Struct({ | |
| 74 | + sessionId: Schema.String, | |
| 75 | + authorizationUrl: Schema.String, | |
| 76 | +}); | |
| 77 | + | |
| 78 | +export const McpOAuthSessionSchema = Schema.Struct({ | |
| 79 | + endpoint: Schema.String, | |
| 80 | + redirectUrl: Schema.String, | |
| 81 | + codeVerifier: Schema.String, | |
| 82 | + resourceMetadataUrl: Schema.NullOr(Schema.String), | |
| 83 | + authorizationServerUrl: Schema.NullOr(Schema.String), | |
| 84 | + resourceMetadata: Schema.NullOr(JsonObjectSchema), | |
| 85 | + authorizationServerMetadata: Schema.NullOr(JsonObjectSchema), | |
| 86 | + clientInformation: Schema.NullOr(JsonObjectSchema), | |
| 87 | +}); | |
| 88 | + | |
| 89 | +const McpOAuthPopupAuthSchema = Schema.Struct({ | |
| 90 | + kind: Schema.Literal("oauth2"), | |
| 91 | + redirectUri: Schema.String, | |
| 92 | + accessTokenRef: SecretRefSchema, | |
| 93 | + refreshTokenRef: Schema.NullOr(SecretRefSchema), | |
| 94 | + tokenType: Schema.String, | |
| 95 | + expiresAt: Schema.NullOr(Schema.Number), | |
| 96 | + scope: Schema.NullOr(Schema.String), | |
| 97 | + resourceMetadataUrl: Schema.NullOr(Schema.String), | |
| 98 | + authorizationServerUrl: Schema.NullOr(Schema.String), | |
| 99 | + resourceMetadata: Schema.NullOr(JsonObjectSchema), | |
| 100 | + authorizationServerMetadata: Schema.NullOr(JsonObjectSchema), | |
| 101 | + clientInformation: Schema.NullOr(JsonObjectSchema), | |
| 102 | +}); | |
| 103 | + | |
| 104 | +export const McpOAuthPopupResultSchema = Schema.Union( | |
| 105 | + Schema.Struct({ | |
| 106 | + type: Schema.Literal("executor:oauth-result"), | |
| 107 | + ok: Schema.Literal(true), | |
| 108 | + sessionId: Schema.String, | |
| 109 | + auth: McpOAuthPopupAuthSchema, | |
| 110 | + }), | |
| 111 | + Schema.Struct({ | |
| 112 | + type: Schema.Literal("executor:oauth-result"), | |
| 113 | + ok: Schema.Literal(false), | |
| 114 | + sessionId: Schema.Null, | |
| 115 | + error: Schema.String, | |
| 116 | + }), | |
| 117 | +); | |
| 118 | + | |
| 119 | +export type McpConnectionAuth = typeof McpConnectionAuthSchema.Type; | |
| 120 | +export type McpConnectInput = typeof McpConnectInputSchema.Type; | |
| 121 | +export type McpSourceConfigPayload = typeof McpSourceConfigPayloadSchema.Type; | |
| 122 | +export type McpUpdateSourceInput = typeof McpUpdateSourceInputSchema.Type; | |
| 123 | +export type McpStoredSourceData = typeof McpStoredSourceDataSchema.Type; | |
| 124 | +export type McpStartOAuthInput = typeof McpStartOAuthInputSchema.Type; | |
| 125 | +export type McpStartOAuthResult = typeof McpStartOAuthResultSchema.Type; | |
| 126 | +export type McpOAuthSession = typeof McpOAuthSessionSchema.Type; | |
| 127 | +export type McpOAuthPopupResult = typeof McpOAuthPopupResultSchema.Type; | |
| 128 | + | |
| 129 | +export const deriveMcpNamespace = (input: { | |
| 130 | + name?: string | null; | |
| 131 | + endpoint?: string | null; | |
| 132 | + command?: string | null; | |
| 133 | +}): string | null => { | |
| 134 | + if (input.name && input.name.trim().length > 0) { | |
| 135 | + return namespaceFromSourceName(input.name); | |
| 136 | + } | |
| 137 | + | |
| 138 | + if (input.endpoint && input.endpoint.trim().length > 0) { | |
| 139 | + try { | |
| 140 | + return namespaceFromSourceName(defaultNameFromEndpoint(input.endpoint)); | |
| 141 | + } catch { | |
| 142 | + // Fall through to command inference. | |
| 143 | + } | |
| 144 | + } | |
| 145 | + | |
| 146 | + if (input.command && input.command.trim().length > 0) { | |
| 147 | + const commandName = input.command.trim().split(/[\\/]/).pop() ?? input.command.trim(); | |
| 148 | + return namespaceFromSourceName(commandName); | |
| 149 | + } | |
| 150 | + | |
| 151 | + return null; | |
| 152 | +}; | |
| 153 | + | |
| 154 | +export const resolveMcpEndpoint = (input: { | |
| 155 | + endpoint: string; | |
| 156 | + queryParams?: Readonly<Record<string, string>> | null; | |
| 157 | +}): string => { | |
| 158 | + const url = new URL(input.endpoint); | |
| 159 | + for (const [key, value] of Object.entries(input.queryParams ?? {})) { | |
| 160 | + url.searchParams.set(key, value); | |
| 161 | + } | |
| 162 | + return url.toString(); | |
| 163 | +}; | |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -9,9 +9,12 @@ import { · 8 unmodified lines | ||
| 9 | 9 | import type { Source } from "@executor/platform-sdk/schema"; |
| 10 | 10 | import type { |
| 11 | 11 | ExecutorSdkPlugin, |
| 12 | + ExecutorSdkPluginHost, | |
| 13 | + ExecutorSourceConnector, | |
| 12 | 14 | SourcePluginRuntime, |
| 13 | 15 | } from "@executor/platform-sdk/plugins"; |
| 14 | 16 | import { |
| 17 | + OpenApiConnectionAuthSchema, | |
| 15 | 18 | deriveOpenApiNamespace, |
| 16 | 19 | previewOpenApiDocument, |
| 17 | 20 | type OpenApiConnectInput, |
| @@ -90,6 +93,16 @@ export type OpenApiSdkPluginOptions = { · 72 unmodified lines | ||
| 90 | 93 | secrets?: OpenApiSecrets; |
| 91 | 94 | }; |
| 92 | 95 | |
| 96 | +const OpenApiExecutorAddInputSchema = Schema.Struct({ | |
| 97 | + kind: Schema.Literal("openapi"), | |
| 98 | + name: Schema.String, | |
| 99 | + specUrl: Schema.String, | |
| 100 | + baseUrl: Schema.NullOr(Schema.String), | |
| 101 | + auth: OpenApiConnectionAuthSchema, | |
| 102 | +}); | |
| 103 | + | |
| 104 | +type OpenApiExecutorAddInput = typeof OpenApiExecutorAddInputSchema.Type; | |
| 105 | + | |
| 93 | 106 | const createStoredSourceData = ( |
| 94 | 107 | input: OpenApiConnectInput, |
| 95 | 108 | ): OpenApiStoredSourceData => ({ |
| @@ -249,6 +262,117 @@ const responseHeadersRecord = (response: Response): Record<string, string> => { · 153 unmodified lines | ||
| 249 | 262 | return headers; |
| 250 | 263 | }; |
| 251 | 264 | |
| 265 | +const createOpenApiSourceSdk = ( | |
| 266 | + options: OpenApiSdkPluginOptions, | |
| 267 | + host: ExecutorSdkPluginHost, | |
| 268 | +) => ({ | |
| 269 | + getSourceConfig: (sourceId: Source["id"]) => | |
| 270 | + Effect.gen(function* () { | |
| 271 | + const source = yield* host.sources.get(sourceId); | |
| 272 | + if (source.kind !== "openapi") { | |
| 273 | + return yield* Effect.fail( | |
| 274 | + new Error(`Source ${sourceId} is not an OpenAPI source.`), | |
| 275 | + ); | |
| 276 | + } | |
| 277 | + | |
| 278 | + const stored = yield* options.storage.get({ | |
| 279 | + scopeId: source.scopeId, | |
| 280 | + sourceId: source.id, | |
| 281 | + }); | |
| 282 | + if (stored === null) { | |
| 283 | + return yield* Effect.fail( | |
| 284 | + new Error(`OpenAPI source storage missing for ${source.id}`), | |
| 285 | + ); | |
| 286 | + } | |
| 287 | + | |
| 288 | + return configFromStoredSourceData(source, stored); | |
| 289 | + }), | |
| 290 | + createSource: (input: OpenApiConnectInput) => | |
| 291 | + Effect.gen(function* () { | |
| 292 | + const stored = createStoredSourceData(input); | |
| 293 | + const createdSource = yield* host.sources.create({ | |
| 294 | + source: { | |
| 295 | + name: input.name.trim(), | |
| 296 | + kind: "openapi", | |
| 297 | + status: "connected", | |
| 298 | + enabled: true, | |
| 299 | + namespace: deriveOpenApiNamespace({ | |
| 300 | + specUrl: input.specUrl, | |
| 301 | + title: input.name, | |
| 302 | + }), | |
| 303 | + }, | |
| 304 | + }); | |
| 305 | + | |
| 306 | + yield* options.storage.put({ | |
| 307 | + scopeId: createdSource.scopeId, | |
| 308 | + sourceId: createdSource.id, | |
| 309 | + value: stored, | |
| 310 | + }); | |
| 311 | + | |
| 312 | + return yield* host.sources.refreshCatalog(createdSource.id); | |
| 313 | + }), | |
| 314 | + updateSource: (input: OpenApiUpdateSourceInput) => | |
| 315 | + Effect.gen(function* () { | |
| 316 | + const source = yield* host.sources.get(input.sourceId as Source["id"]); | |
| 317 | + if (source.kind !== "openapi") { | |
| 318 | + return yield* Effect.fail( | |
| 319 | + new Error(`Source ${input.sourceId} is not an OpenAPI source.`), | |
| 320 | + ); | |
| 321 | + } | |
| 322 | + | |
| 323 | + const nextStored = createStoredSourceData(input.config); | |
| 324 | + const savedSource = yield* host.sources.save({ | |
| 325 | + ...source, | |
| 326 | + name: input.config.name.trim(), | |
| 327 | + namespace: deriveOpenApiNamespace({ | |
| 328 | + specUrl: input.config.specUrl, | |
| 329 | + title: input.config.name, | |
| 330 | + }), | |
| 331 | + }); | |
| 332 | + | |
| 333 | + yield* options.storage.put({ | |
| 334 | + scopeId: savedSource.scopeId, | |
| 335 | + sourceId: savedSource.id, | |
| 336 | + value: nextStored, | |
| 337 | + }); | |
| 338 | + | |
| 339 | + return yield* host.sources.refreshCatalog(savedSource.id); | |
| 340 | + }), | |
| 341 | + removeSource: (sourceId: Source["id"]) => | |
| 342 | + Effect.gen(function* () { | |
| 343 | + const source = yield* host.sources.get(sourceId); | |
| 344 | + if (source.kind !== "openapi") { | |
| 345 | + return yield* Effect.fail( | |
| 346 | + new Error(`Source ${sourceId} is not an OpenAPI source.`), | |
| 347 | + ); | |
| 348 | + } | |
| 349 | + | |
| 350 | + if (options.storage.remove) { | |
| 351 | + yield* options.storage.remove({ | |
| 352 | + scopeId: source.scopeId, | |
| 353 | + sourceId: source.id, | |
| 354 | + }); | |
| 355 | + } | |
| 356 | + | |
| 357 | + return yield* host.sources.remove(source.id); | |
| 358 | + }), | |
| 359 | +}); | |
| 360 | + | |
| 361 | +const openApiSourceConnector = ( | |
| 362 | + options: OpenApiSdkPluginOptions, | |
| 363 | +): ExecutorSourceConnector<OpenApiExecutorAddInput> => ({ | |
| 364 | + kind: "openapi", | |
| 365 | + displayName: "OpenAPI", | |
| 366 | + inputSchema: OpenApiExecutorAddInputSchema, | |
| 367 | + inputSignatureWidth: 280, | |
| 368 | + helpText: [ | |
| 369 | + "Provide the OpenAPI document URL and optional base URL override.", | |
| 370 | + "Use `auth.kind = \"bearer\"` with a stored secret ref when required.", | |
| 371 | + ], | |
| 372 | + createSource: ({ args, host }) => | |
| 373 | + createOpenApiSourceSdk(options, host).createSource(args), | |
| 374 | +}); | |
| 375 | + | |
| 252 | 376 | const decodeResponseBody = async (response: Response): Promise<unknown> => { |
| 253 | 377 | if (response.status === 204) { |
| 254 | 378 | return null; |
| @@ -541,102 +665,29 @@ export const openApiSdkPlugin = ( · 286 unmodified lines | ||
| 541 | 665 | ): ExecutorSdkPlugin<"openapi", OpenApiSdk> => ({ |
| 542 | 666 | key: "openapi", |
| 543 | 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 | 677 | previewDocument: (input) => |
| 546 | 678 | Effect.tryPromise({ |
| 547 | 679 | try: () => previewOpenApiDocument(input), |
| 548 | 680 | catch: (cause) => |
| 549 | 681 | cause instanceof Error ? cause : new Error(String(cause)), |
| 550 | 682 | }), |
| 551 | − getSourceConfig: (sourceId) => | |
| 552 | − Effect.gen(function* () { | |
| 553 | − const source = yield* host.sources.get(sourceId); | |
| 554 | − if (source.kind !== "openapi") { | |
| 555 | − return yield* Effect.fail( | |
| 556 | − new Error(`Source ${sourceId} is not an OpenAPI source.`), | |
| 557 | − ); | |
| 558 | − } | |
| 559 | − | |
| 560 | − const stored = yield* options.storage.get({ | |
| 561 | − scopeId: source.scopeId, | |
| 562 | − sourceId: source.id, | |
| 563 | − }); | |
| 564 | − if (stored === null) { | |
| 565 | − return yield* Effect.fail( | |
| 566 | − new Error(`OpenAPI source storage missing for ${source.id}`), | |
| 567 | − ); | |
| 568 | − } | |
| 569 | − | |
| 570 | − return configFromStoredSourceData(source, stored); | |
| 571 | − }), | |
| 572 | − createSource: (input) => | |
| 573 | − Effect.gen(function* () { | |
| 574 | − const stored = createStoredSourceData(input); | |
| 575 | − const createdSource = yield* host.sources.create({ | |
| 576 | − source: { | |
| 577 | − name: input.name.trim(), | |
| 578 | − kind: "openapi", | |
| 579 | − status: "connected", | |
| 580 | − enabled: true, | |
| 581 | − namespace: deriveOpenApiNamespace({ | |
| 582 | − specUrl: input.specUrl, | |
| 583 | − title: input.name, | |
| 584 | − }), | |
| 585 | − }, | |
| 586 | − }); | |
| 587 | − | |
| 588 | − yield* options.storage.put({ | |
| 589 | − scopeId: createdSource.scopeId, | |
| 590 | − sourceId: createdSource.id, | |
| 591 | − value: stored, | |
| 592 | − }); | |
| 593 | − | |
| 594 | − return yield* host.sources.refreshCatalog(createdSource.id); | |
| 595 | − }), | |
| 596 | − updateSource: (input) => | |
| 597 | − Effect.gen(function* () { | |
| 598 | − const source = yield* host.sources.get(input.sourceId as Source["id"]); | |
| 599 | − if (source.kind !== "openapi") { | |
| 600 | − return yield* Effect.fail( | |
| 601 | − new Error(`Source ${input.sourceId} is not an OpenAPI source.`), | |
| 602 | − ); | |
| 603 | − } | |
| 604 | − | |
| 605 | − const nextStored = createStoredSourceData(input.config); | |
| 606 | − const savedSource = yield* host.sources.save({ | |
| 607 | − ...source, | |
| 608 | − name: input.config.name.trim(), | |
| 609 | − namespace: deriveOpenApiNamespace({ | |
| 610 | − specUrl: input.config.specUrl, | |
| 611 | − title: input.config.name, | |
| 612 | − }), | |
| 613 | − }); | |
| 614 | − | |
| 615 | − yield* options.storage.put({ | |
| 616 | − scopeId: savedSource.scopeId, | |
| 617 | − sourceId: savedSource.id, | |
| 618 | − value: nextStored, | |
| 619 | − }); | |
| 620 | − | |
| 621 | − return yield* host.sources.refreshCatalog(savedSource.id); | |
| 622 | − }), | |
| 623 | − removeSource: (sourceId) => | |
| 624 | − Effect.gen(function* () { | |
| 625 | − const source = yield* host.sources.get(sourceId); | |
| 626 | − if (source.kind !== "openapi") { | |
| 627 | − return yield* Effect.fail( | |
| 628 | − new Error(`Source ${sourceId} is not an OpenAPI source.`), | |
| 629 | − ); | |
| 630 | − } | |
| 631 | − | |
| 632 | − if (options.storage.remove) { | |
| 633 | − yield* options.storage.remove({ | |
| 634 | − scopeId: source.scopeId, | |
| 635 | − sourceId: source.id, | |
| 636 | − }); | |
| 637 | − } | |
| 638 | − | |
| 639 | − return yield* host.sources.remove(source.id); | |
| 640 | − }), | |
| 641 | − }), | |
| 683 | + getSourceConfig: (sourceId) => | |
| 684 | + provideRuntime(sourceSdk.getSourceConfig(sourceId)), | |
| 685 | + createSource: (input) => | |
| 686 | + provideRuntime(sourceSdk.createSource(input)), | |
| 687 | + updateSource: (input) => | |
| 688 | + provideRuntime(sourceSdk.updateSource(input)), | |
| 689 | + removeSource: (sourceId) => | |
| 690 | + provideRuntime(sourceSdk.removeSource(sourceId)), | |
| 691 | + }; | |
| 692 | + }, | |
| 642 | 693 | }); |