Unified · Split
| @@ -1,8 +1,8 @@ | |||
| 1 | { | 1 | { |
| 2 | "sources": { | 2 | "sources": { |
| 3 | "vercel-api": { | 3 | "vercel-api": { |
| 4 | "kind": "openapi", | 4 | "name": "Vercel API", |
| 5 | "name": "Vercel API" | 5 | "kind": "openapi" |
| 6 | } | 6 | } |
| 7 | } | 7 | } |
| 8 | } | 8 | } |
| @@ -229,24 +229,33 @@ Examples: · 228 unmodified lines | |||
| 229 | 229 | ||
| 230 | ```ts | 230 | ```ts |
| 231 | return await tools.executor.sources.add({ | 231 | return await tools.executor.sources.add({ |
| 232 | endpoint: "https://example.com/mcp", | 232 | kind: "mcp", |
| 233 | name: "Example", | 233 | name: "Example", |
| 234 | namespace: "example", | 234 | endpoint: "https://example.com/mcp", |
| 235 | transport: "auto", | ||
| 236 | queryParams: null, | ||
| 237 | headers: null, | ||
| 238 | command: null, | ||
| 239 | args: null, | ||
| 240 | env: null, | ||
| 241 | cwd: null, | ||
| 242 | auth: { kind: "none" }, | ||
| 235 | }); | 243 | }); |
| 236 | ``` | 244 | ``` |
| 237 | 245 | ||
| 238 | ```ts | 246 | ```ts |
| 239 | return await tools.executor.sources.add({ | 247 | return await tools.executor.sources.add({ |
| 240 | kind: "openapi", | 248 | kind: "openapi", |
| 241 | endpoint: "https://api.github.com", | 249 | name: "GitHub", |
| 242 | specUrl: | 250 | specUrl: |
| 243 | "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json", | 251 | "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json", |
| 244 | name: "GitHub", | 252 | baseUrl: null, |
| 245 | namespace: "github", | 253 | auth: { kind: "none" }, |
| 246 | }); | 254 | }); |
| 247 | ``` | 255 | ``` |
| 248 | 256 | ||
| 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 | ## How execution works | 260 | ## How execution works |
| 252 | 261 | ||
| @@ -38,6 +38,9 @@ · 37 unmodified lines | |||
| 38 | "@effect/platform-node": "catalog:", | 38 | "@effect/platform-node": "catalog:", |
| 39 | "@executor/codemode-core": "workspace:*", | 39 | "@executor/codemode-core": "workspace:*", |
| 40 | "@executor/executor-mcp": "workspace:*", | 40 | "@executor/executor-mcp": "workspace:*", |
| 41 | "@executor/plugin-google-discovery-sdk": "workspace:*", | ||
| 42 | "@executor/plugin-graphql-sdk": "workspace:*", | ||
| 43 | "@executor/plugin-mcp-sdk": "workspace:*", | ||
| 41 | "@executor/plugin-openapi-sdk": "workspace:*", | 44 | "@executor/plugin-openapi-sdk": "workspace:*", |
| 42 | "@executor/platform-api": "workspace:*", | 45 | "@executor/platform-api": "workspace:*", |
| 43 | "@executor/platform-internal": "workspace:*", | 46 | "@executor/platform-internal": "workspace:*", |
| @@ -14,12 +14,21 @@ import { · 13 unmodified lines | |||
| 14 | type ExecutorApiEffectClient as ExecutorApiClient, | 14 | type ExecutorApiEffectClient as ExecutorApiClient, |
| 15 | } from "@executor/platform-api/effect"; | 15 | } from "@executor/platform-api/effect"; |
| 16 | import { runExecutorMcpStdioServer } from "@executor/executor-mcp"; | 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 | import { | 26 | import { |
| 18 | openApiSdkPlugin, | 27 | openApiSdkPlugin, |
| 19 | } from "@executor/plugin-openapi-sdk"; | 28 | } from "@executor/plugin-openapi-sdk"; |
| 20 | import { createWorkspaceExecutorAdminToolMap } from "@executor/platform-internal"; | 29 | import { createWorkspaceExecutorAdminToolMap } from "@executor/platform-internal"; |
| 21 | import { | 30 | import { |
| 22 | EXECUTOR_SOURCES_ADD_HELP_LINES, | 31 | getExecutorSourcesAddHelpLines, |
| 23 | RuntimeExecutionResolverService, | 32 | RuntimeExecutionResolverService, |
| 24 | } from "@executor/platform-sdk/runtime"; | 33 | } from "@executor/platform-sdk/runtime"; |
| 25 | import { | 34 | import { |
| @@ -40,6 +49,11 @@ import * as Option from "effect/Option"; · 14 unmodified lines | |||
| 40 | import * as Cause from "effect/Cause"; | 49 | import * as Cause from "effect/Cause"; |
| 41 | 50 | ||
| 42 | import { | 51 | import { |
| 52 | createFileGoogleDiscoveryOAuthSessionStorage, | ||
| 53 | createFileGoogleDiscoverySourceStorage, | ||
| 54 | createFileGraphqlSourceStorage, | ||
| 55 | createFileMcpOAuthSessionStorage, | ||
| 56 | createFileMcpSourceStorage, | ||
| 43 | createFileOpenApiSourceStorage, | 57 | createFileOpenApiSourceStorage, |
| 44 | createLocalExecutorServer, | 58 | createLocalExecutorServer, |
| 45 | DEFAULT_SERVER_BASE_URL, | 59 | DEFAULT_SERVER_BASE_URL, |
| @@ -216,13 +230,13 @@ const buildWorkflowText = (namespaces: readonly string[] = []): string => · 170 unmodified lines | |||
| 216 | '1) const matches = await tools.discover({ query: "<intent>", limit: 12 });', | 230 | '1) const matches = await tools.discover({ query: "<intent>", limit: 12 });', |
| 217 | "2) const details = await tools.describe.tool({ path, includeSchemas: true });", | 231 | "2) const details = await tools.describe.tool({ path, includeSchemas: true });", |
| 218 | "3) Call selected tools.<path>(input).", | 232 | "3) Call selected tools.<path>(input).", |
| 219 | "4) Source plugins are not registered in this build.", | 233 | "4) Use source plugins to inspect or add API sources.", |
| 220 | ...EXECUTOR_SOURCES_ADD_HELP_LINES, | 234 | ...getExecutorSourcesAddHelpLines(), |
| 221 | "5) If execution pauses for interaction, resume it with `executor resume --execution-id ...`.", | 235 | "5) If execution pauses for interaction, resume it with `executor resume --execution-id ...`.", |
| 222 | "Do not use fetch; use tools.* only.", | 236 | "Do not use fetch; use tools.* only.", |
| 223 | ].join("\n"); | 237 | ].join("\n"); |
| 224 | 238 | ||
| 225 | const DEFAULT_RUN_WORKFLOW = buildWorkflowText(); | 239 | const getDefaultRunWorkflow = () => buildWorkflowText(); |
| 226 | 240 | ||
| 227 | const indentBlock = (value: string, prefix: string = " "): string => | 241 | const indentBlock = (value: string, prefix: string = " "): string => |
| 228 | value | 242 | value |
| @@ -267,7 +281,7 @@ const buildRunWorkflowText = ( · 38 unmodified lines | |||
| 267 | catalog?: ToolCatalog, | 281 | catalog?: ToolCatalog, |
| 268 | ): Effect.Effect<string, Error, never> => { | 282 | ): Effect.Effect<string, Error, never> => { |
| 269 | if (!catalog) { | 283 | if (!catalog) { |
| 270 | return Effect.succeed(DEFAULT_RUN_WORKFLOW); | 284 | return Effect.succeed(getDefaultRunWorkflow()); |
| 271 | } | 285 | } |
| 272 | 286 | ||
| 273 | return catalog.listNamespaces({ limit: 200 }).pipe( | 287 | return catalog.listNamespaces({ limit: 200 }).pipe( |
| @@ -289,6 +303,27 @@ const loadRunWorkflowText = (): Effect.Effect<string, Error, never> => · 15 unmodified lines | |||
| 289 | localDataDir: DEFAULT_LOCAL_DATA_DIR, | 303 | localDataDir: DEFAULT_LOCAL_DATA_DIR, |
| 290 | }), | 304 | }), |
| 291 | plugins: [ | 305 | plugins: [ |
| 306 | graphqlSdkPlugin({ | ||
| 307 | storage: createFileGraphqlSourceStorage({ | ||
| 308 | rootDir: `${DEFAULT_LOCAL_DATA_DIR}/plugins/graphql/sources`, | ||
| 309 | }), | ||
| 310 | }), | ||
| 311 | googleDiscoverySdkPlugin({ | ||
| 312 | storage: createFileGoogleDiscoverySourceStorage({ | ||
| 313 | rootDir: `${DEFAULT_LOCAL_DATA_DIR}/plugins/google-discovery/sources`, | ||
| 314 | }), | ||
| 315 | oauthSessions: createFileGoogleDiscoveryOAuthSessionStorage({ | ||
| 316 | rootDir: `${DEFAULT_LOCAL_DATA_DIR}/plugins/google-discovery/oauth-sessions`, | ||
| 317 | }), | ||
| 318 | }), | ||
| 319 | mcpSdkPlugin({ | ||
| 320 | storage: createFileMcpSourceStorage({ | ||
| 321 | rootDir: `${DEFAULT_LOCAL_DATA_DIR}/plugins/mcp/sources`, | ||
| 322 | }), | ||
| 323 | oauthSessions: createFileMcpOAuthSessionStorage({ | ||
| 324 | rootDir: `${DEFAULT_LOCAL_DATA_DIR}/plugins/mcp/oauth-sessions`, | ||
| 325 | }), | ||
| 326 | }), | ||
| 292 | openApiSdkPlugin({ | 327 | openApiSdkPlugin({ |
| 293 | storage: createFileOpenApiSourceStorage({ | 328 | storage: createFileOpenApiSourceStorage({ |
| 294 | rootDir: `${DEFAULT_LOCAL_DATA_DIR}/plugins/openapi/sources`, | 329 | rootDir: `${DEFAULT_LOCAL_DATA_DIR}/plugins/openapi/sources`, |
| @@ -318,7 +353,7 @@ const loadRunWorkflowText = (): Effect.Effect<string, Error, never> => · 23 unmodified lines | |||
| 318 | Effect.catchAllCause((cause) => | 353 | Effect.catchAllCause((cause) => |
| 319 | Effect.succeed( | 354 | Effect.succeed( |
| 320 | [ | 355 | [ |
| 321 | DEFAULT_RUN_WORKFLOW, | 356 | getDefaultRunWorkflow(), |
| 322 | "", | 357 | "", |
| 323 | formatCatalogUnavailableMessage(cause), | 358 | formatCatalogUnavailableMessage(cause), |
| 324 | ].join("\n"), | 359 | ].join("\n"), |
| @@ -9,6 +9,9 @@ · 8 unmodified lines | |||
| 9 | "start": "NODE_ENV=production bun src/server.ts" | 9 | "start": "NODE_ENV=production bun src/server.ts" |
| 10 | }, | 10 | }, |
| 11 | "dependencies": { | 11 | "dependencies": { |
| 12 | "@executor/plugin-google-discovery-react": "workspace:*", | ||
| 13 | "@executor/plugin-graphql-react": "workspace:*", | ||
| 14 | "@executor/plugin-mcp-react": "workspace:*", | ||
| 12 | "@executor/plugin-openapi-react": "workspace:*", | 15 | "@executor/plugin-openapi-react": "workspace:*", |
| 13 | "@executor/react": "workspace:*", | 16 | "@executor/react": "workspace:*", |
| 14 | "@executor/server": "workspace:*", | 17 | "@executor/server": "workspace:*", |
| @@ -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 | import { | 10 | import { |
| 2 | OpenApiReactPlugin, | 11 | OpenApiReactPlugin, |
| 3 | } from "@executor/plugin-openapi-react"; | 12 | } from "@executor/plugin-openapi-react"; |
| @@ -7,6 +16,9 @@ import type { · 3 unmodified lines | |||
| 7 | } from "./types"; | 16 | } from "./types"; |
| 8 | 17 | ||
| 9 | const frontendPlugins = [ | 18 | const frontendPlugins = [ |
| 19 | McpReactPlugin, | ||
| 20 | GraphqlReactPlugin, | ||
| 21 | GoogleDiscoveryReactPlugin, | ||
| 10 | OpenApiReactPlugin, | 22 | OpenApiReactPlugin, |
| 11 | ] as const satisfies readonly ExecutorFrontendPlugin[]; | 23 | ] as const satisfies readonly ExecutorFrontendPlugin[]; |
| 12 | 24 | ||
| @@ -1,9 +1,11 @@ | |||
| 1 | import { useEffect, useState } from "react"; | ||
| 1 | import { useSource } from "@executor/react"; | 2 | import { useSource } from "@executor/react"; |
| 2 | import { LoadableBlock } from "../components/loadable"; | 3 | import { LoadableBlock } from "../components/loadable"; |
| 3 | import { SourcePluginsResetState } from "../components/source-plugins-reset-state"; | 4 | import { SourcePluginsResetState } from "../components/source-plugins-reset-state"; |
| 4 | import { | 5 | import { |
| 5 | getDefaultSourceFrontendType, | 6 | getDefaultSourceFrontendType, |
| 6 | getSourceFrontendType, | 7 | getSourceFrontendType, |
| 8 | registeredSourceFrontendTypes, | ||
| 7 | } from "./index"; | 9 | } from "./index"; |
| 8 | 10 | ||
| 9 | export type SourcePluginRouteSearch = { | 11 | export type SourcePluginRouteSearch = { |
| @@ -12,38 +14,73 @@ export type SourcePluginRouteSearch = { · 2 unmodified lines | |||
| 12 | query?: string; | 14 | query?: string; |
| 13 | }; | 15 | }; |
| 14 | 16 | ||
| 15 | export function SourcePluginAddPage() { | 17 | const SourcePluginPicker = (props: { |
| 16 | const definition = getDefaultSourceFrontendType(); | 18 | selectedKind: string | null; |
| 17 | if (definition === null) { | 19 | onSelect: (kind: string) => void; |
| 18 | return ( | 20 | }) => { |
| 19 | <SourcePluginsResetState | 21 | if (registeredSourceFrontendTypes.length <= 1) { |
| 20 | title="Add Source is intentionally blank" | 22 | return null; |
| 21 | message="No source plugins are registered in this build." | ||
| 22 | /> | ||
| 23 | ); | ||
| 24 | } | 23 | } |
| 25 | 24 | ||
| 26 | return ( | 25 | return ( |
| 27 | <div className="mx-auto max-w-4xl px-6 py-10 lg:px-10 lg:py-14"> | 26 | <div className="mb-8 flex flex-wrap gap-2"> |
| 28 | <div className="mb-8"> | 27 | {registeredSourceFrontendTypes.map((definition) => ( |
| 29 | <div className="inline-flex rounded-full border border-border bg-muted px-3 py-1 text-[11px] font-medium uppercase tracking-[0.2em] text-muted-foreground"> | 28 | <button |
| 30 | Source Plugin | 29 | key={definition.kind} |
| 31 | </div> | 30 | type="button" |
| 32 | <h1 className="mt-5 font-display text-3xl tracking-tight text-foreground lg:text-4xl"> | 31 | onClick={() => props.onSelect(definition.kind)} |
| 32 | className={ | ||
| 33 | props.selectedKind === definition.kind | ||
| 34 | ? "rounded-full border border-primary/30 bg-primary/10 px-3 py-1.5 text-xs font-medium text-foreground" | ||
| 35 | : "rounded-full border border-border bg-muted px-3 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground" | ||
| 36 | } | ||
| 37 | > | ||
| 33 | {definition.displayName} | 38 | {definition.displayName} |
| 34 | </h1> | 39 | </button> |
| 35 | </div> | 40 | ))} |
| 36 | {definition.renderAddPage()} | ||
| 37 | </div> | 41 | </div> |
| 38 | ); | 42 | ); |
| 39 | } | 43 | }; |
| 44 | |||
| 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 | if (definition === null) { | 80 | if (definition === null) { |
| 44 | return ( | 81 | return ( |
| 45 | <SourcePluginsResetState | 82 | <SourcePluginsResetState |
| 46 | title="New source creation is disabled" | 83 | title="Source plugins are unavailable" |
| 47 | message="No source plugins are registered in this build." | 84 | message="No source plugins are registered in this build." |
| 48 | /> | 85 | /> |
| 49 | ); | 86 | ); |
| @@ -51,9 +88,31 @@ export function SourcePluginCreatePage() { · 1 unmodified line | |||
| 51 | 88 | ||
| 52 | return ( | 89 | return ( |
| 53 | <div className="mx-auto max-w-4xl px-6 py-10 lg:px-10 lg:py-14"> | 90 | <div className="mx-auto max-w-4xl px-6 py-10 lg:px-10 lg:py-14"> |
| 54 | {definition.renderAddPage()} | 91 | {!props.compact && ( |
| 92 | <div className="mb-8"> | ||
| 93 | <div className="inline-flex rounded-full border border-border bg-muted px-3 py-1 text-[11px] font-medium uppercase tracking-[0.2em] text-muted-foreground"> | ||
| 94 | Source Plugin | ||
| 95 | </div> | ||
| 96 | <h1 className="mt-5 font-display text-3xl tracking-tight text-foreground lg:text-4xl"> | ||
| 97 | {definition.displayName} | ||
| 98 | </h1> | ||
| 99 | </div> | ||
| 100 | )} | ||
| 101 | <SourcePluginPicker | ||
| 102 | selectedKind={selectedKind} | ||
| 103 | onSelect={setSelectedKind} | ||
| 104 | /> | ||
| 105 | {props.compact ? definition.renderAddPage() : definition.renderAddPage()} | ||
| 55 | </div> | 106 | </div> |
| 56 | ); | 107 | ); |
| 108 | }; | ||
| 109 | |||
| 110 | export function SourcePluginAddPage() { | ||
| 111 | return <SourcePluginAddLayout />; | ||
| 112 | } | ||
| 113 | |||
| 114 | export function SourcePluginCreatePage() { | ||
| 115 | return <SourcePluginAddLayout compact />; | ||
| 57 | } | 116 | } |
| 58 | 117 | ||
| 59 | export function SourcePluginEditPage(input: { | 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 | import { Badge } from "../components/ui/badge"; | 4 | import { Badge } from "../components/ui/badge"; |
| 5 | import { Button } from "../components/ui/button"; | 5 | import { Button } from "../components/ui/button"; |
| 6 | import { SourcePluginsResetState } from "../components/source-plugins-reset-state"; | 6 | import { SourcePluginsResetState } from "../components/source-plugins-reset-state"; |
| 7 | import { registeredSourceFrontendTypes } from "../source-plugins"; | ||
| 7 | 8 | ||
| 8 | export function HomePage() { | 9 | export function HomePage() { |
| 9 | const sources = useSources(); | 10 | const sources = useSources(); |
| @@ -16,13 +17,22 @@ export function HomePage() { · 6 unmodified lines | |||
| 16 | Sources | 17 | Sources |
| 17 | </div> | 18 | </div> |
| 18 | <h1 className="mt-5 font-display text-3xl tracking-tight text-foreground lg:text-4xl"> | 19 | <h1 className="mt-5 font-display text-3xl tracking-tight text-foreground lg:text-4xl"> |
| 19 | OpenAPI is now mounted as a source plugin | 20 | Source plugins now own source-specific product behavior |
| 20 | </h1> | 21 | </h1> |
| 21 | <p className="mt-3 max-w-3xl text-sm leading-6 text-muted-foreground"> | 22 | <p className="mt-3 max-w-3xl text-sm leading-6 text-muted-foreground"> |
| 22 | The product shell is still in transition, but the first plugin-backed | 23 | Add, edit, inspect, and authorize sources through plugin-owned UI and |
| 23 | surface is wired in for OpenAPI so the frontend and backend boundaries | 24 | API surfaces. The core app stays generic while each plugin carries its |
| 24 | are visible in the app again. | 25 | own transport, auth, and storage behavior. |
| 25 | </p> | 26 | </p> |
| 27 | {registeredSourceFrontendTypes.length > 0 && ( | ||
| 28 | <div className="mt-5 flex flex-wrap gap-2"> | ||
| 29 | {registeredSourceFrontendTypes.map((definition) => ( | ||
| 30 | <Badge key={definition.kind} variant="outline"> | ||
| 31 | {definition.displayName} | ||
| 32 | </Badge> | ||
| 33 | ))} | ||
| 34 | </div> | ||
| 35 | )} | ||
| 26 | </div> | 36 | </div> |
| 27 | 37 | ||
| 28 | <div className="mt-8"> | 38 | <div className="mt-8"> |
| @@ -36,11 +46,11 @@ export function HomePage() { · 7 unmodified lines | |||
| 36 | ) : items.length === 0 ? ( | 46 | ) : items.length === 0 ? ( |
| 37 | <SourcePluginsResetState | 47 | <SourcePluginsResetState |
| 38 | title="No sources connected yet" | 48 | title="No sources connected yet" |
| 39 | message="The OpenAPI source plugin is registered. Use the add flow to inspect the plugin-owned UI surface." | 49 | message="Use the add flow to connect an OpenAPI, GraphQL, MCP, or Google Discovery source through its plugin-owned surface." |
| 40 | action={( | 50 | action={( |
| 41 | <Link to="/sources/add"> | 51 | <Link to="/sources/add"> |
| 42 | <Button size="sm" variant="outline"> | 52 | <Button size="sm" variant="outline"> |
| 43 | Open OpenAPI Plugin | 53 | Add Source |
| 44 | </Button> | 54 | </Button> |
| 45 | </Link> | 55 | </Link> |
| 46 | )} | 56 | )} |
| @@ -32,6 +32,9 @@ · 31 unmodified lines | |||
| 32 | "@executor/platform-internal": "workspace:*", | 32 | "@executor/platform-internal": "workspace:*", |
| 33 | "@executor/platform-sdk": "workspace:*", | 33 | "@executor/platform-sdk": "workspace:*", |
| 34 | "@executor/platform-sdk-file": "workspace:*", | 34 | "@executor/platform-sdk-file": "workspace:*", |
| 35 | "@executor/plugin-google-discovery-sdk": "workspace:*", | ||
| 36 | "@executor/plugin-graphql-sdk": "workspace:*", | ||
| 37 | "@executor/plugin-mcp-sdk": "workspace:*", | ||
| 35 | "@executor/plugin-openapi-sdk": "workspace:*", | 38 | "@executor/plugin-openapi-sdk": "workspace:*", |
| 36 | "@executor/server": "workspace:*", | 39 | "@executor/server": "workspace:*", |
| 37 | "effect": "catalog:", | 40 | "effect": "catalog:", |
| @@ -52,6 +55,9 @@ · 14 unmodified lines | |||
| 52 | "name": "@executor/web", | 55 | "name": "@executor/web", |
| 53 | "version": "0.0.0", | 56 | "version": "0.0.0", |
| 54 | "dependencies": { | 57 | "dependencies": { |
| 58 | "@executor/plugin-google-discovery-react": "workspace:*", | ||
| 59 | "@executor/plugin-graphql-react": "workspace:*", | ||
| 60 | "@executor/plugin-mcp-react": "workspace:*", | ||
| 55 | "@executor/plugin-openapi-react": "workspace:*", | 61 | "@executor/plugin-openapi-react": "workspace:*", |
| 56 | "@executor/react": "workspace:*", | 62 | "@executor/react": "workspace:*", |
| 57 | "@executor/server": "workspace:*", | 63 | "@executor/server": "workspace:*", |
| @@ -394,6 +400,15 @@ · 336 unmodified lines | |||
| 394 | "@executor/platform-internal": "workspace:*", | 400 | "@executor/platform-internal": "workspace:*", |
| 395 | "@executor/platform-sdk": "workspace:*", | 401 | "@executor/platform-sdk": "workspace:*", |
| 396 | "@executor/platform-sdk-file": "workspace:*", | 402 | "@executor/platform-sdk-file": "workspace:*", |
| 403 | "@executor/plugin-google-discovery-http": "workspace:*", | ||
| 404 | "@executor/plugin-google-discovery-sdk": "workspace:*", | ||
| 405 | "@executor/plugin-google-discovery-shared": "workspace:*", | ||
| 406 | "@executor/plugin-graphql-http": "workspace:*", | ||
| 407 | "@executor/plugin-graphql-sdk": "workspace:*", | ||
| 408 | "@executor/plugin-graphql-shared": "workspace:*", | ||
| 409 | "@executor/plugin-mcp-http": "workspace:*", | ||
| 410 | "@executor/plugin-mcp-sdk": "workspace:*", | ||
| 411 | "@executor/plugin-mcp-shared": "workspace:*", | ||
| 397 | "@executor/plugin-openapi-http": "workspace:*", | 412 | "@executor/plugin-openapi-http": "workspace:*", |
| 398 | "@executor/plugin-openapi-sdk": "workspace:*", | 413 | "@executor/plugin-openapi-sdk": "workspace:*", |
| 399 | "@executor/plugin-openapi-shared": "workspace:*", | 414 | "@executor/plugin-openapi-shared": "workspace:*", |
| @@ -425,6 +440,191 @@ · 25 unmodified lines | |||
| 425 | "bun-types": "catalog:", | 440 | "bun-types": "catalog:", |
| 426 | }, | 441 | }, |
| 427 | }, | 442 | }, |
| 443 | "plugins/google-discovery/http": { | ||
| 444 | "name": "@executor/plugin-google-discovery-http", | ||
| 445 | "version": "0.0.0", | ||
| 446 | "dependencies": { | ||
| 447 | "@effect/platform": "catalog:", | ||
| 448 | "@executor/platform-api": "workspace:*", | ||
| 449 | "@executor/platform-sdk": "workspace:*", | ||
| 450 | "@executor/plugin-google-discovery-shared": "workspace:*", | ||
| 451 | "effect": "catalog:", | ||
| 452 | }, | ||
| 453 | "devDependencies": { | ||
| 454 | "@types/node": "catalog:", | ||
| 455 | "bun-types": "catalog:", | ||
| 456 | }, | ||
| 457 | }, | ||
| 458 | "plugins/google-discovery/react": { | ||
| 459 | "name": "@executor/plugin-google-discovery-react", | ||
| 460 | "version": "0.0.0", | ||
| 461 | "dependencies": { | ||
| 462 | "@executor/plugin-google-discovery-http": "workspace:*", | ||
| 463 | "@executor/plugin-google-discovery-shared": "workspace:*", | ||
| 464 | "@executor/react": "workspace:*", | ||
| 465 | "@tanstack/react-router": "^1.132.7", | ||
| 466 | "react": "^19.2.0", | ||
| 467 | }, | ||
| 468 | "devDependencies": { | ||
| 469 | "@types/node": "catalog:", | ||
| 470 | "@types/react": "^19.2.10", | ||
| 471 | "bun-types": "catalog:", | ||
| 472 | }, | ||
| 473 | }, | ||
| 474 | "plugins/google-discovery/sdk": { | ||
| 475 | "name": "@executor/plugin-google-discovery-sdk", | ||
| 476 | "version": "0.0.0", | ||
| 477 | "dependencies": { | ||
| 478 | "@effect/platform": "catalog:", | ||
| 479 | "@executor/auth-oauth2": "workspace:*", | ||
| 480 | "@executor/codemode-core": "workspace:*", | ||
| 481 | "@executor/ir": "workspace:*", | ||
| 482 | "@executor/platform-sdk": "workspace:*", | ||
| 483 | "@executor/plugin-google-discovery-shared": "workspace:*", | ||
| 484 | "@executor/source-core": "workspace:*", | ||
| 485 | "effect": "catalog:", | ||
| 486 | }, | ||
| 487 | "devDependencies": { | ||
| 488 | "@types/node": "catalog:", | ||
| 489 | "bun-types": "catalog:", | ||
| 490 | }, | ||
| 491 | }, | ||
| 492 | "plugins/google-discovery/shared": { | ||
| 493 | "name": "@executor/plugin-google-discovery-shared", | ||
| 494 | "version": "0.0.0", | ||
| 495 | "dependencies": { | ||
| 496 | "@executor/platform-sdk": "workspace:*", | ||
| 497 | "@executor/source-core": "workspace:*", | ||
| 498 | "effect": "catalog:", | ||
| 499 | }, | ||
| 500 | "devDependencies": { | ||
| 501 | "@types/node": "catalog:", | ||
| 502 | "bun-types": "catalog:", | ||
| 503 | }, | ||
| 504 | }, | ||
| 505 | "plugins/graphql/http": { | ||
| 506 | "name": "@executor/plugin-graphql-http", | ||
| 507 | "version": "0.0.0", | ||
| 508 | "dependencies": { | ||
| 509 | "@effect/platform": "catalog:", | ||
| 510 | "@executor/platform-api": "workspace:*", | ||
| 511 | "@executor/platform-sdk": "workspace:*", | ||
| 512 | "@executor/plugin-graphql-shared": "workspace:*", | ||
| 513 | "effect": "catalog:", | ||
| 514 | }, | ||
| 515 | "devDependencies": { | ||
| 516 | "@types/node": "catalog:", | ||
| 517 | "bun-types": "catalog:", | ||
| 518 | }, | ||
| 519 | }, | ||
| 520 | "plugins/graphql/react": { | ||
| 521 | "name": "@executor/plugin-graphql-react", | ||
| 522 | "version": "0.0.0", | ||
| 523 | "dependencies": { | ||
| 524 | "@executor/plugin-graphql-http": "workspace:*", | ||
| 525 | "@executor/plugin-graphql-shared": "workspace:*", | ||
| 526 | "@executor/react": "workspace:*", | ||
| 527 | "@tanstack/react-router": "^1.132.7", | ||
| 528 | "react": "^19.2.0", | ||
| 529 | }, | ||
| 530 | "devDependencies": { | ||
| 531 | "@types/node": "catalog:", | ||
| 532 | "@types/react": "^19.2.10", | ||
| 533 | "bun-types": "catalog:", | ||
| 534 | }, | ||
| 535 | }, | ||
| 536 | "plugins/graphql/sdk": { | ||
| 537 | "name": "@executor/plugin-graphql-sdk", | ||
| 538 | "version": "0.0.0", | ||
| 539 | "dependencies": { | ||
| 540 | "@executor/codemode-core": "workspace:*", | ||
| 541 | "@executor/ir": "workspace:*", | ||
| 542 | "@executor/platform-sdk": "workspace:*", | ||
| 543 | "@executor/plugin-graphql-shared": "workspace:*", | ||
| 544 | "@executor/source-core": "workspace:*", | ||
| 545 | "effect": "catalog:", | ||
| 546 | "graphql": "^16.12.0", | ||
| 547 | }, | ||
| 548 | "devDependencies": { | ||
| 549 | "@types/node": "catalog:", | ||
| 550 | "bun-types": "catalog:", | ||
| 551 | }, | ||
| 552 | }, | ||
| 553 | "plugins/graphql/shared": { | ||
| 554 | "name": "@executor/plugin-graphql-shared", | ||
| 555 | "version": "0.0.0", | ||
| 556 | "dependencies": { | ||
| 557 | "@executor/platform-sdk": "workspace:*", | ||
| 558 | "@executor/source-core": "workspace:*", | ||
| 559 | "effect": "catalog:", | ||
| 560 | }, | ||
| 561 | "devDependencies": { | ||
| 562 | "@types/node": "catalog:", | ||
| 563 | "bun-types": "catalog:", | ||
| 564 | }, | ||
| 565 | }, | ||
| 566 | "plugins/mcp/http": { | ||
| 567 | "name": "@executor/plugin-mcp-http", | ||
| 568 | "version": "0.0.0", | ||
| 569 | "dependencies": { | ||
| 570 | "@effect/platform": "catalog:", | ||
| 571 | "@executor/platform-api": "workspace:*", | ||
| 572 | "@executor/platform-sdk": "workspace:*", | ||
| 573 | "@executor/plugin-mcp-shared": "workspace:*", | ||
| 574 | "effect": "catalog:", | ||
| 575 | }, | ||
| 576 | "devDependencies": { | ||
| 577 | "@types/node": "catalog:", | ||
| 578 | "bun-types": "catalog:", | ||
| 579 | }, | ||
| 580 | }, | ||
| 581 | "plugins/mcp/react": { | ||
| 582 | "name": "@executor/plugin-mcp-react", | ||
| 583 | "version": "0.0.0", | ||
| 584 | "dependencies": { | ||
| 585 | "@executor/plugin-mcp-http": "workspace:*", | ||
| 586 | "@executor/plugin-mcp-shared": "workspace:*", | ||
| 587 | "@executor/react": "workspace:*", | ||
| 588 | "@tanstack/react-router": "^1.132.7", | ||
| 589 | "react": "^19.2.0", | ||
| 590 | }, | ||
| 591 | "devDependencies": { | ||
| 592 | "@types/node": "catalog:", | ||
| 593 | "@types/react": "^19.2.10", | ||
| 594 | "bun-types": "catalog:", | ||
| 595 | }, | ||
| 596 | }, | ||
| 597 | "plugins/mcp/sdk": { | ||
| 598 | "name": "@executor/plugin-mcp-sdk", | ||
| 599 | "version": "0.0.0", | ||
| 600 | "dependencies": { | ||
| 601 | "@executor/auth-mcp-oauth": "workspace:*", | ||
| 602 | "@executor/codemode-core": "workspace:*", | ||
| 603 | "@executor/ir": "workspace:*", | ||
| 604 | "@executor/platform-sdk": "workspace:*", | ||
| 605 | "@executor/plugin-mcp-shared": "workspace:*", | ||
| 606 | "@executor/source-core": "workspace:*", | ||
| 607 | "@modelcontextprotocol/sdk": "^1.26.0", | ||
| 608 | "effect": "catalog:", | ||
| 609 | }, | ||
| 610 | "devDependencies": { | ||
| 611 | "@types/node": "catalog:", | ||
| 612 | "bun-types": "catalog:", | ||
| 613 | }, | ||
| 614 | }, | ||
| 615 | "plugins/mcp/shared": { | ||
| 616 | "name": "@executor/plugin-mcp-shared", | ||
| 617 | "version": "0.0.0", | ||
| 618 | "dependencies": { | ||
| 619 | "@executor/platform-sdk": "workspace:*", | ||
| 620 | "@executor/source-core": "workspace:*", | ||
| 621 | "effect": "catalog:", | ||
| 622 | }, | ||
| 623 | "devDependencies": { | ||
| 624 | "@types/node": "catalog:", | ||
| 625 | "bun-types": "catalog:", | ||
| 626 | }, | ||
| 627 | }, | ||
| 428 | "plugins/openapi/http": { | 628 | "plugins/openapi/http": { |
| 429 | "name": "@executor/plugin-openapi-http", | 629 | "name": "@executor/plugin-openapi-http", |
| 430 | "version": "0.0.0", | 630 | "version": "0.0.0", |
| @@ -717,6 +917,30 @@ · 286 unmodified lines | |||
| 717 | 917 | ||
| 718 | "@executor/platform-sdk-file": ["@executor/platform-sdk-file@workspace:packages/platform/sdk-file"], | 918 | "@executor/platform-sdk-file": ["@executor/platform-sdk-file@workspace:packages/platform/sdk-file"], |
| 719 | 919 | ||
| 920 | "@executor/plugin-google-discovery-http": ["@executor/plugin-google-discovery-http@workspace:plugins/google-discovery/http"], | ||
| 921 | |||
| 922 | "@executor/plugin-google-discovery-react": ["@executor/plugin-google-discovery-react@workspace:plugins/google-discovery/react"], | ||
| 923 | |||
| 924 | "@executor/plugin-google-discovery-sdk": ["@executor/plugin-google-discovery-sdk@workspace:plugins/google-discovery/sdk"], | ||
| 925 | |||
| 926 | "@executor/plugin-google-discovery-shared": ["@executor/plugin-google-discovery-shared@workspace:plugins/google-discovery/shared"], | ||
| 927 | |||
| 928 | "@executor/plugin-graphql-http": ["@executor/plugin-graphql-http@workspace:plugins/graphql/http"], | ||
| 929 | |||
| 930 | "@executor/plugin-graphql-react": ["@executor/plugin-graphql-react@workspace:plugins/graphql/react"], | ||
| 931 | |||
| 932 | "@executor/plugin-graphql-sdk": ["@executor/plugin-graphql-sdk@workspace:plugins/graphql/sdk"], | ||
| 933 | |||
| 934 | "@executor/plugin-graphql-shared": ["@executor/plugin-graphql-shared@workspace:plugins/graphql/shared"], | ||
| 935 | |||
| 936 | "@executor/plugin-mcp-http": ["@executor/plugin-mcp-http@workspace:plugins/mcp/http"], | ||
| 937 | |||
| 938 | "@executor/plugin-mcp-react": ["@executor/plugin-mcp-react@workspace:plugins/mcp/react"], | ||
| 939 | |||
| 940 | "@executor/plugin-mcp-sdk": ["@executor/plugin-mcp-sdk@workspace:plugins/mcp/sdk"], | ||
| 941 | |||
| 942 | "@executor/plugin-mcp-shared": ["@executor/plugin-mcp-shared@workspace:plugins/mcp/shared"], | ||
| 943 | |||
| 720 | "@executor/plugin-openapi-http": ["@executor/plugin-openapi-http@workspace:plugins/openapi/http"], | 944 | "@executor/plugin-openapi-http": ["@executor/plugin-openapi-http@workspace:plugins/openapi/http"], |
| 721 | 945 | ||
| 722 | "@executor/plugin-openapi-react": ["@executor/plugin-openapi-react@workspace:plugins/openapi/react"], | 946 | "@executor/plugin-openapi-react": ["@executor/plugin-openapi-react@workspace:plugins/openapi/react"], |
| @@ -1373,6 +1597,8 @@ · 650 unmodified lines | |||
| 1373 | 1597 | ||
| 1374 | "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], | 1598 | "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], |
| 1375 | 1599 | ||
| 1600 | "graphql": ["graphql@16.13.1", "", {}, "sha512-gGgrVCoDKlIZ8fIqXBBb0pPKqDgki0Z/FSKNiQzSGj2uEYHr1tq5wmBegGwJx6QB5S5cM0khSBpi/JFHMCvsmQ=="], | ||
| 1601 | |||
| 1376 | "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], | 1602 | "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], |
| 1377 | 1603 | ||
| 1378 | "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], | 1604 | "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], |
| @@ -2,7 +2,7 @@ import type { · 1 unmodified line | |||
| 2 | ExecutionEnvelope, | 2 | ExecutionEnvelope, |
| 3 | } from "@executor/platform-sdk/schema"; | 3 | } from "@executor/platform-sdk/schema"; |
| 4 | import { | 4 | import { |
| 5 | EXECUTOR_SOURCES_ADD_HELP_LINES, | 5 | getExecutorSourcesAddHelpLines, |
| 6 | RuntimeSourceCatalogStoreService, | 6 | RuntimeSourceCatalogStoreService, |
| 7 | createExecution, | 7 | createExecution, |
| 8 | getExecution, | 8 | getExecution, |
| @@ -172,14 +172,12 @@ const buildExecuteWorkflowText = ( · 163 unmodified lines | |||
| 172 | '1) const matches = await tools.discover({ query: "<intent>", limit: 12 });', | 172 | '1) const matches = await tools.discover({ query: "<intent>", limit: 12 });', |
| 173 | "2) const details = await tools.describe.tool({ path, includeSchemas: true });", | 173 | "2) const details = await tools.describe.tool({ path, includeSchemas: true });", |
| 174 | "3) Call selected tools.<path>(input).", | 174 | "3) Call selected tools.<path>(input).", |
| 175 | "4) Source plugins are not registered in this build.", | 175 | "4) Use source plugins to inspect or add API sources.", |
| 176 | ...EXECUTOR_SOURCES_ADD_HELP_LINES, | 176 | ...getExecutorSourcesAddHelpLines(), |
| 177 | "5) If execution pauses for interaction, resume it with the returned resumePayload or the available resume flow.", | 177 | "5) If execution pauses for interaction, resume it with the returned resumePayload or the available resume flow.", |
| 178 | "Do not use fetch; use tools.* only.", | 178 | "Do not use fetch; use tools.* only.", |
| 179 | ].join("\n"); | 179 | ].join("\n"); |
| 180 | 180 | ||
| 181 | const defaultExecuteDescription = buildExecuteWorkflowText(); | ||
| 182 | |||
| 183 | const loadExecuteDescription = (runtime: ExecutorRuntime): Promise<string> => | 181 | const loadExecuteDescription = (runtime: ExecutorRuntime): Promise<string> => |
| 184 | runControlPlane( | 182 | runControlPlane( |
| 185 | runtime, | 183 | runtime, |
| @@ -203,11 +201,11 @@ const loadExecuteDescription = (runtime: ExecutorRuntime): Promise<string> => · 17 unmodified lines | |||
| 203 | .filter((catalog) => catalog.toolPaths.length > 0); | 201 | .filter((catalog) => catalog.toolPaths.length > 0); |
| 204 | 202 | ||
| 205 | if (sourceToolExamples.length === 0) { | 203 | if (sourceToolExamples.length === 0) { |
| 206 | return defaultExecuteDescription; | 204 | return buildExecuteWorkflowText(); |
| 207 | } | 205 | } |
| 208 | 206 | ||
| 209 | return buildExecuteWorkflowText(sourceToolExamples); | 207 | return buildExecuteWorkflowText(sourceToolExamples); |
| 210 | }).pipe(Effect.catchAll(() => Effect.succeed(defaultExecuteDescription))), | 208 | }).pipe(Effect.catchAll(() => Effect.succeed(buildExecuteWorkflowText()))), |
| 211 | ); | 209 | ); |
| 212 | 210 | ||
| 213 | const summarizeExecution = (execution: ExecutionEnvelope["execution"]): string => { | 211 | const summarizeExecution = (execution: ExecutionEnvelope["execution"]): string => { |
| @@ -285,6 +285,31 @@ export const createExecutorEffect = < · 284 unmodified lines | |||
| 285 | cause instanceof Error ? cause : new Error(String(cause)), | 285 | cause instanceof Error ? cause : new Error(String(cause)), |
| 286 | ), | 286 | ), |
| 287 | ); | 287 | ); |
| 288 | const providePluginExtensionValue = (value: unknown): unknown => { | ||
| 289 | if (Effect.isEffect(value)) { | ||
| 290 | return providePluginHostEffect(value); | ||
| 291 | } | ||
| 292 | |||
| 293 | if (typeof value === "function") { | ||
| 294 | return (...args: Array<unknown>) => | ||
| 295 | providePluginExtensionValue(value(...args)); | ||
| 296 | } | ||
| 297 | |||
| 298 | if (Array.isArray(value)) { | ||
| 299 | return value.map(providePluginExtensionValue); | ||
| 300 | } | ||
| 301 | |||
| 302 | if (value !== null && typeof value === "object") { | ||
| 303 | return Object.fromEntries( | ||
| 304 | Object.entries(value).map(([key, nestedValue]) => [ | ||
| 305 | key, | ||
| 306 | providePluginExtensionValue(nestedValue), | ||
| 307 | ]), | ||
| 308 | ); | ||
| 309 | } | ||
| 310 | |||
| 311 | return value; | ||
| 312 | }; | ||
| 288 | const host: ExecutorSdkPluginHost = { | 313 | const host: ExecutorSdkPluginHost = { |
| 289 | sources: { | 314 | sources: { |
| 290 | create: ({ source }) => | 315 | create: ({ source }) => |
| @@ -330,10 +355,12 @@ export const createExecutorEffect = < · 39 unmodified lines | |||
| 330 | const extensions = Object.fromEntries( | 355 | const extensions = Object.fromEntries( |
| 331 | (options.plugins ?? []).map((plugin) => [ | 356 | (options.plugins ?? []).map((plugin) => [ |
| 332 | plugin.key, | 357 | plugin.key, |
| 333 | plugin.extendExecutor?.({ | 358 | providePluginExtensionValue( |
| 334 | executor, | 359 | plugin.extendExecutor?.({ |
| 335 | host, | 360 | executor, |
| 336 | }) ?? {}, | 361 | host, |
| 362 | }) ?? {}, | ||
| 363 | ), | ||
| 337 | ]), | 364 | ]), |
| 338 | ); | 365 | ); |
| 339 | 366 | ||
| @@ -11,6 +11,7 @@ import type { · 10 unmodified lines | |||
| 11 | SourceSyncInput, | 11 | SourceSyncInput, |
| 12 | } from "@executor/source-core"; | 12 | } from "@executor/source-core"; |
| 13 | import * as Effect from "effect/Effect"; | 13 | import * as Effect from "effect/Effect"; |
| 14 | import type * as Schema from "effect/Schema"; | ||
| 14 | 15 | ||
| 15 | export type ExecutorSdkPlugin< | 16 | export type ExecutorSdkPlugin< |
| 16 | TKey extends string = string, | 17 | TKey extends string = string, |
| @@ -18,6 +19,7 @@ export type ExecutorSdkPlugin< · 1 unmodified line | |||
| 18 | > = { | 19 | > = { |
| 19 | key: TKey; | 20 | key: TKey; |
| 20 | sources?: readonly SourcePluginRuntime[]; | 21 | sources?: readonly SourcePluginRuntime[]; |
| 22 | sourceConnectors?: readonly ExecutorSourceConnector<any>[]; | ||
| 21 | extendExecutor?: (input: { | 23 | extendExecutor?: (input: { |
| 22 | executor: ExecutorEffect & Record<string, unknown>; | 24 | executor: ExecutorEffect & Record<string, unknown>; |
| 23 | host: ExecutorSdkPluginHost; | 25 | host: ExecutorSdkPluginHost; |
| @@ -31,16 +33,28 @@ export type ExecutorSdkPluginHost = { · 7 unmodified lines | |||
| 31 | ExecutorSource, | 33 | ExecutorSource, |
| 32 | "id" | "scopeId" | "createdAt" | "updatedAt" | 34 | "id" | "scopeId" | "createdAt" | "updatedAt" |
| 33 | >; | 35 | >; |
| 34 | }) => Effect.Effect<ExecutorSource, Error, never>; | 36 | }) => Effect.Effect<ExecutorSource, Error, any>; |
| 35 | get: (sourceId: ExecutorSource["id"]) => Effect.Effect<ExecutorSource, Error, never>; | 37 | get: (sourceId: ExecutorSource["id"]) => Effect.Effect<ExecutorSource, Error, any>; |
| 36 | save: (source: ExecutorSource) => Effect.Effect<ExecutorSource, Error, never>; | 38 | save: (source: ExecutorSource) => Effect.Effect<ExecutorSource, Error, any>; |
| 37 | refreshCatalog: ( | 39 | refreshCatalog: ( |
| 38 | sourceId: ExecutorSource["id"], | 40 | sourceId: ExecutorSource["id"], |
| 39 | ) => Effect.Effect<ExecutorSource, Error, never>; | 41 | ) => Effect.Effect<ExecutorSource, Error, any>; |
| 40 | remove: (sourceId: ExecutorSource["id"]) => Effect.Effect<boolean, Error, never>; | 42 | remove: (sourceId: ExecutorSource["id"]) => Effect.Effect<boolean, Error, any>; |
| 41 | }; | 43 | }; |
| 42 | }; | 44 | }; |
| 43 | 45 | ||
| 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 | export type ExecutorSdkPluginExtensions< | 58 | export type ExecutorSdkPluginExtensions< |
| 45 | TPlugins extends readonly ExecutorSdkPlugin<any, any>[], | 59 | TPlugins extends readonly ExecutorSdkPlugin<any, any>[], |
| 46 | > = { | 60 | > = { |
| @@ -59,21 +73,25 @@ export type SourcePluginRuntime = { · 12 unmodified lines | |||
| 59 | }) => Record<string, unknown>; | 73 | }) => Record<string, unknown>; |
| 60 | getIrModel: ( | 74 | getIrModel: ( |
| 61 | input: SourceSyncInput, | 75 | input: SourceSyncInput, |
| 62 | ) => Effect.Effect<SourceCatalogSyncResult, Error, never>; | 76 | ) => Effect.Effect<SourceCatalogSyncResult, Error, any>; |
| 63 | invoke: ( | 77 | invoke: ( |
| 64 | input: SourceInvokeInput, | 78 | input: SourceInvokeInput, |
| 65 | ) => Effect.Effect<SourceInvokeResult, Error, never>; | 79 | ) => Effect.Effect<SourceInvokeResult, Error, any>; |
| 66 | }; | 80 | }; |
| 67 | 81 | ||
| 68 | export const registerExecutorSdkPlugins = ( | 82 | export const registerExecutorSdkPlugins = ( |
| 69 | plugins: readonly ExecutorSdkPlugin[], | 83 | plugins: readonly ExecutorSdkPlugin[], |
| 70 | ) => { | 84 | ) => { |
| 71 | const sourcePlugins = new Map<string, SourcePluginRuntime>(); | 85 | const sourcePlugins = new Map<string, SourcePluginRuntime>(); |
| 86 | const sourceConnectors = new Map<string, ExecutorSourceConnector<any>>(); | ||
| 72 | 87 | ||
| 73 | for (const plugin of plugins) { | 88 | for (const plugin of plugins) { |
| 74 | for (const source of plugin.sources ?? []) { | 89 | for (const source of plugin.sources ?? []) { |
| 75 | sourcePlugins.set(source.kind, source); | 90 | sourcePlugins.set(source.kind, source); |
| 76 | } | 91 | } |
| 92 | for (const connector of plugin.sourceConnectors ?? []) { | ||
| 93 | sourceConnectors.set(connector.kind, connector); | ||
| 94 | } | ||
| 77 | } | 95 | } |
| 78 | 96 | ||
| 79 | const getSourcePlugin = (kind: string) => { | 97 | const getSourcePlugin = (kind: string) => { |
| @@ -91,6 +109,7 @@ export const registerExecutorSdkPlugins = ( · 11 unmodified lines | |||
| 91 | return { | 109 | return { |
| 92 | plugins, | 110 | plugins, |
| 93 | sourcePlugins: [...sourcePlugins.values()], | 111 | sourcePlugins: [...sourcePlugins.values()], |
| 112 | sourceConnectors: [...sourceConnectors.values()], | ||
| 94 | getSourcePlugin, | 113 | getSourcePlugin, |
| 95 | getSourcePluginForSource, | 114 | getSourcePluginForSource, |
| 96 | sourcePluginCatalogKind: (kind: string): SourceCatalogKind => | 115 | sourcePluginCatalogKind: (kind: string): SourceCatalogKind => |
| @@ -56,7 +56,7 @@ export type RuntimeSourceCatalogSyncShape = { · 55 unmodified lines | |||
| 56 | sync: (input: { | 56 | sync: (input: { |
| 57 | source: Source; | 57 | source: Source; |
| 58 | actorScopeId?: ScopeId | null; | 58 | actorScopeId?: ScopeId | null; |
| 59 | }) => Effect.Effect<void, Error, never>; | 59 | }) => Effect.Effect<void, Error, any>; |
| 60 | }; | 60 | }; |
| 61 | 61 | ||
| 62 | export class RuntimeSourceCatalogSyncService extends Context.Tag( | 62 | export class RuntimeSourceCatalogSyncService extends Context.Tag( |
| @@ -83,7 +83,7 @@ const syncSourceCatalogWithDeps = ( · 20 unmodified lines | |||
| 83 | source: Source; | 83 | source: Source; |
| 84 | actorScopeId?: ScopeId | null; | 84 | actorScopeId?: ScopeId | null; |
| 85 | }, | 85 | }, |
| 86 | ): Effect.Effect<void, Error, never> => | 86 | ): Effect.Effect<void, Error, any> => |
| 87 | Effect.gen(function* () { | 87 | Effect.gen(function* () { |
| 88 | yield* ensureRuntimeCatalogSyncWorkspace(deps, input.source.scopeId); | 88 | yield* ensureRuntimeCatalogSyncWorkspace(deps, input.source.scopeId); |
| 89 | 89 | ||
| @@ -129,6 +129,9 @@ export const createScopeToolInvoker = (input: { · 128 unmodified lines | |||
| 129 | const executorTools = createExecutorToolMap({ | 129 | const executorTools = createExecutorToolMap({ |
| 130 | scopeId: input.scopeId, | 130 | scopeId: input.scopeId, |
| 131 | actorScopeId: input.actorScopeId, | 131 | actorScopeId: input.actorScopeId, |
| 132 | executorStateStore: input.executorStateStore, | ||
| 133 | sourceStore: input.sourceStore, | ||
| 134 | sourceCatalogSyncService: input.sourceCatalogSyncService, | ||
| 132 | installationStore: input.installationStore, | 135 | installationStore: input.installationStore, |
| 133 | scopeConfigStore: input.scopeConfigStore, | 136 | scopeConfigStore: input.scopeConfigStore, |
| 134 | scopeStateStore: input.scopeStateStore, | 137 | scopeStateStore: input.scopeStateStore, |
| @@ -236,9 +239,13 @@ export const createScopeToolInvoker = (input: { · 101 unmodified lines | |||
| 236 | catalog, | 239 | catalog, |
| 237 | toolInvoker: { | 240 | toolInvoker: { |
| 238 | invoke: ({ path, args, context }) => { | 241 | invoke: ({ path, args, context }) => { |
| 239 | const effect: Effect.Effect<unknown, unknown, never> = authoredToolPaths.has(path) | 242 | const effect = authoredToolPaths.has(path) |
| 240 | ? authoredInvoker.invoke({ path, args, context }) as Effect.Effect<unknown, unknown, never> | 243 | ? authoredInvoker.invoke({ path, args, context }) |
| 241 | : invokePersistedTool({ path, args, context }); | 244 | : invokePersistedTool({ path, args, context }) as Effect.Effect< |
| 245 | unknown, | ||
| 246 | unknown, | ||
| 247 | never | ||
| 248 | >; | ||
| 242 | 249 | ||
| 243 | return provideRuntimeLocalScope(effect, input.runtimeLocalScope); | 250 | return provideRuntimeLocalScope(effect, input.runtimeLocalScope); |
| 244 | }, | 251 | }, |
| @@ -1,47 +1,267 @@ | |||
| 1 | import type { ToolMap } from "@executor/codemode-core"; | 1 | import { toTool, type ToolMap } from "@executor/codemode-core"; |
| 2 | import { SourceSchema } from "#schema"; | 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 | import { | 19 | import { |
| 5 | deriveSchemaJson, | 20 | deriveSchemaJson, |
| 6 | deriveSchemaTypeSignature, | 21 | deriveSchemaTypeSignature, |
| 7 | } from "../catalog/schema-type-signature"; | 22 | } from "../catalog/schema-type-signature"; |
| 8 | import type { | 23 | import { |
| 9 | InstallationStoreShape, | 24 | RuntimeSourceCatalogSyncService, |
| 10 | SourceArtifactStoreShape, | 25 | } from "../catalog/source/sync"; |
| 11 | ScopeConfigStoreShape, | 26 | import { |
| 12 | ScopeStateStoreShape, | 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 | } from "../scope/storage"; | 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 = | 59 | if (connectors.length === 1) { |
| 16 | "Source plugins are not registered in this build."; | 60 | return connectors[0]!.inputSchema; |
| 61 | } | ||
| 62 | |||
| 63 | return Schema.Union( | ||
| 64 | ...(connectors.map((connector) => connector.inputSchema) as [ | ||
| 65 | Schema.Schema<any, any, never>, | ||
| 66 | Schema.Schema<any, any, never>, | ||
| 67 | ...Array<Schema.Schema<any, any, never>>, | ||
| 68 | ]) | ||
| 69 | ); | ||
| 70 | }; | ||
| 71 | |||
| 72 | export const getExecutorSourcesAddInputHint = (): string => | ||
| 73 | deriveSchemaTypeSignature(createExecutorSourcesAddSchema(), 340); | ||
| 17 | 74 | ||
| 18 | export const EXECUTOR_SOURCES_ADD_OUTPUT_SIGNATURE = deriveSchemaTypeSignature( | 75 | export const EXECUTOR_SOURCES_ADD_OUTPUT_SIGNATURE = deriveSchemaTypeSignature( |
| 19 | SourceSchema, | 76 | SourceSchema, |
| 20 | 260, | 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 | export const EXECUTOR_SOURCES_ADD_OUTPUT_SCHEMA = deriveSchemaJson( | 83 | export const EXECUTOR_SOURCES_ADD_OUTPUT_SCHEMA = deriveSchemaJson( |
| 26 | SourceSchema, | 84 | SourceSchema, |
| 27 | ) ?? {}; | 85 | ) ?? {}; |
| 28 | 86 | ||
| 29 | export const EXECUTOR_SOURCES_ADD_HELP_LINES = [ | 87 | export const getExecutorSourcesAddHelpLines = (): readonly string[] => { |
| 30 | "Source plugins are not registered in this build.", | 88 | const connectors = registeredSourceConnectors(); |
| 31 | ] as const; | 89 | if (connectors.length === 0) { |
| 90 | return ["No source plugins are registered in this build."] as const; | ||
| 91 | } | ||
| 32 | 92 | ||
| 33 | export const buildExecutorSourcesAddDescription = (): string => | 93 | return [ |
| 34 | "Source plugins are not registered in this build."; | 94 | "Source add input shapes:", |
| 95 | ...connectors.flatMap((connector) => [ | ||
| 96 | `- ${connector.displayName}: ${deriveSchemaTypeSignature( | ||
| 97 | connector.inputSchema, | ||
| 98 | connector.inputSignatureWidth ?? 260, | ||
| 99 | )}`, | ||
| 100 | ...(connector.helpText ?? []).map((line) => ` ${line}`), | ||
| 101 | ]), | ||
| 102 | ]; | ||
| 103 | }; | ||
| 104 | |||
| 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 | export const createExecutorToolMap = (input: { | 216 | export const createExecutorToolMap = (input: { |
| 37 | scopeId?: string; | 217 | scopeId: ScopeId; |
| 38 | actorScopeId?: string; | 218 | actorScopeId: ScopeId; |
| 219 | executorStateStore: ExecutorStateStoreShape; | ||
| 220 | sourceStore: RuntimeSourceStore; | ||
| 221 | sourceCatalogSyncService: Effect.Effect.Success< | ||
| 222 | typeof RuntimeSourceCatalogSyncService | ||
| 223 | >; | ||
| 39 | installationStore: InstallationStoreShape; | 224 | installationStore: InstallationStoreShape; |
| 40 | scopeConfigStore: ScopeConfigStoreShape; | 225 | scopeConfigStore: ScopeConfigStoreShape; |
| 41 | scopeStateStore: ScopeStateStoreShape; | 226 | scopeStateStore: ScopeStateStoreShape; |
| 42 | sourceArtifactStore: SourceArtifactStoreShape; | 227 | sourceArtifactStore: SourceArtifactStoreShape; |
| 43 | runtimeLocalScope?: unknown; | 228 | runtimeLocalScope: RuntimeLocalScopeState | null; |
| 44 | }): ToolMap => { | 229 | }): ToolMap => { |
| 45 | void input; | 230 | const connectors = registeredSourceConnectors(); |
| 46 | return {}; | 231 | if (connectors.length === 0) { |
| 232 | return {}; | ||
| 233 | } | ||
| 234 | |||
| 235 | const host = createSourceConnectorHost({ | ||
| 236 | scopeId: input.scopeId, | ||
| 237 | actorScopeId: input.actorScopeId, | ||
| 238 | }); | ||
| 239 | |||
| 240 | return { | ||
| 241 | "executor.sources.add": toTool({ | ||
| 242 | tool: { | ||
| 243 | description: buildExecutorSourcesAddDescription(), | ||
| 244 | inputSchema: Schema.standardSchemaV1(createExecutorSourcesAddSchema()), | ||
| 245 | outputSchema: Schema.standardSchemaV1(SourceSchema), | ||
| 246 | execute: async (args: unknown): Promise<Source> => { | ||
| 247 | const matched = resolveSourceConnector(connectors, args); | ||
| 248 | if (matched === null) { | ||
| 249 | throw new Error( | ||
| 250 | "executor.sources.add input did not match a registered source plugin.", | ||
| 251 | ); | ||
| 252 | } | ||
| 253 | |||
| 254 | const createdSource = await runExecutorSourceEffect( | ||
| 255 | matched.connector.createSource({ | ||
| 256 | args: matched.parsedArgs, | ||
| 257 | host, | ||
| 258 | }), | ||
| 259 | input, | ||
| 260 | ); | ||
| 261 | |||
| 262 | return toSerializableValue(createdSource); | ||
| 263 | }, | ||
| 264 | }, | ||
| 265 | }), | ||
| 266 | }; | ||
| 47 | }; | 267 | }; |
| @@ -27,6 +27,7 @@ export const configureExecutorSourcePlugins = ( · 26 unmodified lines | |||
| 27 | }; | 27 | }; |
| 28 | 28 | ||
| 29 | export const registeredSourcePlugins = () => registry.sourcePlugins; | 29 | export const registeredSourcePlugins = () => registry.sourcePlugins; |
| 30 | export const registeredSourceConnectors = () => registry.sourceConnectors; | ||
| 30 | 31 | ||
| 31 | export const getSourcePlugin = (kind: string) => registry.getSourcePlugin(kind); | 32 | export const getSourcePlugin = (kind: string) => registry.getSourcePlugin(kind); |
| 32 | export const getSourcePluginForSource = ( | 33 | export const getSourcePluginForSource = ( |
| @@ -15,6 +15,15 @@ · 14 unmodified lines | |||
| 15 | "@effect/platform": "catalog:", | 15 | "@effect/platform": "catalog:", |
| 16 | "@effect/platform-node": "catalog:", | 16 | "@effect/platform-node": "catalog:", |
| 17 | "@executor/executor-mcp": "workspace:*", | 17 | "@executor/executor-mcp": "workspace:*", |
| 18 | "@executor/plugin-google-discovery-http": "workspace:*", | ||
| 19 | "@executor/plugin-google-discovery-shared": "workspace:*", | ||
| 20 | "@executor/plugin-google-discovery-sdk": "workspace:*", | ||
| 21 | "@executor/plugin-graphql-http": "workspace:*", | ||
| 22 | "@executor/plugin-graphql-shared": "workspace:*", | ||
| 23 | "@executor/plugin-graphql-sdk": "workspace:*", | ||
| 24 | "@executor/plugin-mcp-http": "workspace:*", | ||
| 25 | "@executor/plugin-mcp-shared": "workspace:*", | ||
| 26 | "@executor/plugin-mcp-sdk": "workspace:*", | ||
| 18 | "@executor/plugin-openapi-http": "workspace:*", | 27 | "@executor/plugin-openapi-http": "workspace:*", |
| 19 | "@executor/plugin-openapi-shared": "workspace:*", | 28 | "@executor/plugin-openapi-shared": "workspace:*", |
| 20 | "@executor/plugin-openapi-sdk": "workspace:*", | 29 | "@executor/plugin-openapi-sdk": "workspace:*", |
| @@ -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 | import { Readable } from "node:stream"; | 8 | import { Readable } from "node:stream"; |
| 9 | import { FileSystem, HttpApiBuilder, HttpServer } from "@effect/platform"; | 9 | import { FileSystem, HttpApiBuilder, HttpServer } from "@effect/platform"; |
| 10 | import { NodeFileSystem } from "@effect/platform-node"; | 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 | import { openApiHttpPlugin } from "@executor/plugin-openapi-http"; | 17 | import { openApiHttpPlugin } from "@executor/plugin-openapi-http"; |
| 12 | import { | 18 | import { |
| 13 | openApiSdkPlugin, | 19 | openApiSdkPlugin, |
| @@ -48,8 +54,18 @@ import { · 34 unmodified lines | |||
| 48 | tracingSearchUrl, | 54 | tracingSearchUrl, |
| 49 | } from "./tracing"; | 55 | } from "./tracing"; |
| 50 | import { platformServerEffectError } from "./effect-errors"; | 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 | import { createFileOpenApiSourceStorage } from "./openapi-source-storage"; | 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 | export { createFileOpenApiSourceStorage } from "./openapi-source-storage"; | 69 | export { createFileOpenApiSourceStorage } from "./openapi-source-storage"; |
| 54 | 70 | ||
| 55 | export { | 71 | export { |
| @@ -114,7 +130,12 @@ type ExecutorMcpHandler = ReturnType<typeof createExecutorMcpRequestHandler>; · 58 unmodified lines | |||
| 114 | const EXECUTOR_NPM_DIST_TAGS_PATHNAME = "/v1/app/npm/dist-tags"; | 130 | const EXECUTOR_NPM_DIST_TAGS_PATHNAME = "/v1/app/npm/dist-tags"; |
| 115 | const EXECUTOR_NPM_DIST_TAGS_URL = | 131 | const EXECUTOR_NPM_DIST_TAGS_URL = |
| 116 | "https://registry.npmjs.org/-/package/executor/dist-tags"; | 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 | const disposeExecutor = (executor: Executor) => | 140 | const disposeExecutor = (executor: Executor) => |
| 120 | Effect.tryPromise({ | 141 | Effect.tryPromise({ |
| @@ -134,6 +155,27 @@ const createExecutorRuntime = ( · 13 unmodified lines | |||
| 134 | localDataDir, | 155 | localDataDir, |
| 135 | }), | 156 | }), |
| 136 | plugins: [ | 157 | plugins: [ |
| 158 | graphqlSdkPlugin({ | ||
| 159 | storage: createFileGraphqlSourceStorage({ | ||
| 160 | rootDir: resolve(localDataDir, "plugins", "graphql", "sources"), | ||
| 161 | }), | ||
| 162 | }), | ||
| 163 | googleDiscoverySdkPlugin({ | ||
| 164 | storage: createFileGoogleDiscoverySourceStorage({ | ||
| 165 | rootDir: resolve(localDataDir, "plugins", "google-discovery", "sources"), | ||
| 166 | }), | ||
| 167 | oauthSessions: createFileGoogleDiscoveryOAuthSessionStorage({ | ||
| 168 | rootDir: resolve(localDataDir, "plugins", "google-discovery", "oauth-sessions"), | ||
| 169 | }), | ||
| 170 | }), | ||
| 171 | mcpSdkPlugin({ | ||
| 172 | storage: createFileMcpSourceStorage({ | ||
| 173 | rootDir: resolve(localDataDir, "plugins", "mcp", "sources"), | ||
| 174 | }), | ||
| 175 | oauthSessions: createFileMcpOAuthSessionStorage({ | ||
| 176 | rootDir: resolve(localDataDir, "plugins", "mcp", "oauth-sessions"), | ||
| 177 | }), | ||
| 178 | }), | ||
| 137 | openApiSdkPlugin({ | 179 | openApiSdkPlugin({ |
| 138 | storage: createFileOpenApiSourceStorage({ | 180 | storage: createFileOpenApiSourceStorage({ |
| 139 | rootDir: resolve(localDataDir, "plugins", "openapi", "sources"), | 181 | rootDir: resolve(localDataDir, "plugins", "openapi", "sources"), |
| @@ -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 | import type { Source } from "@executor/platform-sdk/schema"; | 9 | import type { Source } from "@executor/platform-sdk/schema"; |
| 10 | import type { | 10 | import type { |
| 11 | ExecutorSdkPlugin, | 11 | ExecutorSdkPlugin, |
| 12 | ExecutorSdkPluginHost, | ||
| 13 | ExecutorSourceConnector, | ||
| 12 | SourcePluginRuntime, | 14 | SourcePluginRuntime, |
| 13 | } from "@executor/platform-sdk/plugins"; | 15 | } from "@executor/platform-sdk/plugins"; |
| 14 | import { | 16 | import { |
| 17 | OpenApiConnectionAuthSchema, | ||
| 15 | deriveOpenApiNamespace, | 18 | deriveOpenApiNamespace, |
| 16 | previewOpenApiDocument, | 19 | previewOpenApiDocument, |
| 17 | type OpenApiConnectInput, | 20 | type OpenApiConnectInput, |
| @@ -90,6 +93,16 @@ export type OpenApiSdkPluginOptions = { · 72 unmodified lines | |||
| 90 | secrets?: OpenApiSecrets; | 93 | secrets?: OpenApiSecrets; |
| 91 | }; | 94 | }; |
| 92 | 95 | ||
| 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 | const createStoredSourceData = ( | 106 | const createStoredSourceData = ( |
| 94 | input: OpenApiConnectInput, | 107 | input: OpenApiConnectInput, |
| 95 | ): OpenApiStoredSourceData => ({ | 108 | ): OpenApiStoredSourceData => ({ |
| @@ -249,6 +262,117 @@ const responseHeadersRecord = (response: Response): Record<string, string> => { · 153 unmodified lines | |||
| 249 | return headers; | 262 | return headers; |
| 250 | }; | 263 | }; |
| 251 | 264 | ||
| 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 | const decodeResponseBody = async (response: Response): Promise<unknown> => { | 376 | const decodeResponseBody = async (response: Response): Promise<unknown> => { |
| 253 | if (response.status === 204) { | 377 | if (response.status === 204) { |
| 254 | return null; | 378 | return null; |
| @@ -541,102 +665,29 @@ export const openApiSdkPlugin = ( · 286 unmodified lines | |||
| 541 | ): ExecutorSdkPlugin<"openapi", OpenApiSdk> => ({ | 665 | ): ExecutorSdkPlugin<"openapi", OpenApiSdk> => ({ |
| 542 | key: "openapi", | 666 | key: "openapi", |
| 543 | sources: [createOpenApiSourceRuntime(options)], | 667 | sources: [createOpenApiSourceRuntime(options)], |
| 544 | extendExecutor: ({ host }) => ({ | 668 | sourceConnectors: [openApiSourceConnector(options)], |
| 669 | extendExecutor: ({ host, executor }) => { | ||
| 670 | const sourceSdk = createOpenApiSourceSdk(options, host); | ||
| 671 | const provideRuntime = <A>( | ||
| 672 | effect: Effect.Effect<A, Error, any>, | ||
| 673 | ): Effect.Effect<A, Error, never> => | ||
| 674 | effect.pipe(Effect.provide(executor.runtime.managedRuntime)); | ||
| 675 | |||
| 676 | return { | ||
| 545 | previewDocument: (input) => | 677 | previewDocument: (input) => |
| 546 | Effect.tryPromise({ | 678 | Effect.tryPromise({ |
| 547 | try: () => previewOpenApiDocument(input), | 679 | try: () => previewOpenApiDocument(input), |
| 548 | catch: (cause) => | 680 | catch: (cause) => |
| 549 | cause instanceof Error ? cause : new Error(String(cause)), | 681 | cause instanceof Error ? cause : new Error(String(cause)), |
| 550 | }), | 682 | }), |
| 551 | getSourceConfig: (sourceId) => | 683 | getSourceConfig: (sourceId) => |
| 552 | Effect.gen(function* () { | 684 | provideRuntime(sourceSdk.getSourceConfig(sourceId)), |
| 553 | const source = yield* host.sources.get(sourceId); | 685 | createSource: (input) => |
| 554 | if (source.kind !== "openapi") { | 686 | provideRuntime(sourceSdk.createSource(input)), |
| 555 | return yield* Effect.fail( | 687 | updateSource: (input) => |
| 556 | new Error(`Source ${sourceId} is not an OpenAPI source.`), | 688 | provideRuntime(sourceSdk.updateSource(input)), |
| 557 | ); | 689 | removeSource: (sourceId) => |
| 558 | } | 690 | provideRuntime(sourceSdk.removeSource(sourceId)), |
| 559 | 691 | }; | |
| 560 | const stored = yield* options.storage.get({ | 692 | }, |
| 561 | scopeId: source.scopeId, | ||
| 562 | sourceId: source.id, | ||
| 563 | }); | ||
| 564 | if (stored === null) { | ||
| 565 | return yield* Effect.fail( | ||
| 566 | new Error(`OpenAPI source storage missing for ${source.id}`), | ||
| 567 | ); | ||
| 568 | } | ||
| 569 | |||
| 570 | return configFromStoredSourceData(source, stored); | ||
| 571 | }), | ||
| 572 | createSource: (input) => | ||
| 573 | Effect.gen(function* () { | ||
| 574 | const stored = createStoredSourceData(input); | ||
| 575 | const createdSource = yield* host.sources.create({ | ||
| 576 | source: { | ||
| 577 | name: input.name.trim(), | ||
| 578 | kind: "openapi", | ||
| 579 | status: "connected", | ||
| 580 | enabled: true, | ||
| 581 | namespace: deriveOpenApiNamespace({ | ||
| 582 | specUrl: input.specUrl, | ||
| 583 | title: input.name, | ||
| 584 | }), | ||
| 585 | }, | ||
| 586 | }); | ||
| 587 | |||
| 588 | yield* options.storage.put({ | ||
| 589 | scopeId: createdSource.scopeId, | ||
| 590 | sourceId: createdSource.id, | ||
| 591 | value: stored, | ||
| 592 | }); | ||
| 593 | |||
| 594 | return yield* host.sources.refreshCatalog(createdSource.id); | ||
| 595 | }), | ||
| 596 | updateSource: (input) => | ||
| 597 | Effect.gen(function* () { | ||
| 598 | const source = yield* host.sources.get(input.sourceId as Source["id"]); | ||
| 599 | if (source.kind !== "openapi") { | ||
| 600 | return yield* Effect.fail( | ||
| 601 | new Error(`Source ${input.sourceId} is not an OpenAPI source.`), | ||
| 602 | ); | ||
| 603 | } | ||
| 604 | |||
| 605 | const nextStored = createStoredSourceData(input.config); | ||
| 606 | const savedSource = yield* host.sources.save({ | ||
| 607 | ...source, | ||
| 608 | name: input.config.name.trim(), | ||
| 609 | namespace: deriveOpenApiNamespace({ | ||
| 610 | specUrl: input.config.specUrl, | ||
| 611 | title: input.config.name, | ||
| 612 | }), | ||
| 613 | }); | ||
| 614 | |||
| 615 | yield* options.storage.put({ | ||
| 616 | scopeId: savedSource.scopeId, | ||
| 617 | sourceId: savedSource.id, | ||
| 618 | value: nextStored, | ||
| 619 | }); | ||
| 620 | |||
| 621 | return yield* host.sources.refreshCatalog(savedSource.id); | ||
| 622 | }), | ||
| 623 | removeSource: (sourceId) => | ||
| 624 | Effect.gen(function* () { | ||
| 625 | const source = yield* host.sources.get(sourceId); | ||
| 626 | if (source.kind !== "openapi") { | ||
| 627 | return yield* Effect.fail( | ||
| 628 | new Error(`Source ${sourceId} is not an OpenAPI source.`), | ||
| 629 | ); | ||
| 630 | } | ||
| 631 | |||
| 632 | if (options.storage.remove) { | ||
| 633 | yield* options.storage.remove({ | ||
| 634 | scopeId: source.scopeId, | ||
| 635 | sourceId: source.id, | ||
| 636 | }); | ||
| 637 | } | ||
| 638 | |||
| 639 | return yield* host.sources.remove(source.id); | ||
| 640 | }), | ||
| 641 | }), | ||
| 642 | }); | 693 | }); |