| @@ -7,10 +7,7 @@ import { · 6 unmodified lines |
| 7 | IntegrationDetectionResult, | 7 | IntegrationDetectionResult, |
| 8 | IntegrationNotFoundError, | 8 | IntegrationNotFoundError, |
| 9 | IntegrationSlug, | 9 | IntegrationSlug, |
| 10 | ToolName, | | |
| 11 | ToolFileJsonSchema, | | |
| 12 | ToolResult, | 10 | ToolResult, |
| 13 | authToolFailure, | | |
| 14 | definePlugin, | 11 | definePlugin, |
| 15 | mergeAuthTemplates, | 12 | mergeAuthTemplates, |
| 16 | sha256Hex, | 13 | sha256Hex, |
| @@ -20,18 +17,10 @@ import { · 3 unmodified lines |
| 20 | type IntegrationConfig, | 17 | type IntegrationConfig, |
| 21 | type IntegrationRecord, | 18 | type IntegrationRecord, |
| 22 | type PluginCtx, | 19 | type PluginCtx, |
| 23 | type ResolveToolsResult, | | |
| 24 | type StorageFailure, | 20 | type StorageFailure, |
| 25 | type ToolDef, | | |
| 26 | type ToolInvocationCredential, | | |
| 27 | } from "@executor-js/sdk/core"; | 21 | } from "@executor-js/sdk/core"; |
| 28 | | 22 | |
| 29 | import { | 23 | import { decodeOpenApiIntegrationConfig, type OpenApiIntegrationConfig } from "./config"; |
| 30 | decodeOpenApiIntegrationConfig, | | |
| 31 | renderAuthTemplate, | | |
| 32 | requiredTemplateVariables, | | |
| 33 | type OpenApiIntegrationConfig, | | |
| 34 | } from "./config"; | | |
| 35 | import { OpenApiExtractionError, OpenApiOAuthError, OpenApiParseError } from "./errors"; | 24 | import { OpenApiExtractionError, OpenApiOAuthError, OpenApiParseError } from "./errors"; |
| 36 | import { parse, resolveSpecText } from "./parse"; | 25 | import { parse, resolveSpecText } from "./parse"; |
| 37 | import { | 26 | import { |
| @@ -41,98 +30,20 @@ import { · 3 unmodified lines |
| 41 | isGoogleDiscoveryUrl, | 30 | isGoogleDiscoveryUrl, |
| 42 | } from "./google-discovery"; | 31 | } from "./google-discovery"; |
| 43 | import { extract } from "./extract"; | 32 | import { extract } from "./extract"; |
| 44 | import { compileToolDefinitions, type ToolDefinition } from "./definitions"; | | |
| 45 | import { annotationsForOperation, invokeWithLayer } from "./invoke"; | | |
| 46 | import { previewSpec, previewSpecText, type SpecPreview } from "./preview"; | 33 | import { previewSpec, previewSpecText, type SpecPreview } from "./preview"; |
| 47 | import { deriveAuthenticationTemplateFromPreview, firstBaseUrlForPreview } from "./derive-auth"; | 34 | import { deriveAuthenticationTemplateFromPreview, firstBaseUrlForPreview } from "./derive-auth"; |
| 48 | import { openApiPresets } from "./presets"; | 35 | import { openApiPresets } from "./presets"; |
| 49 | import { makeDefaultOpenapiStore, type OpenapiStore, type StoredOperation } from "./store"; | 36 | import { makeDefaultOpenapiStore, type OpenapiStore } from "./store"; |
| 50 | import type { Authentication } from "./types"; | 37 | import type { Authentication } from "./types"; |
| 51 | import { OperationBinding, normalizeOpenApiAuthInputs, type AuthenticationInput } from "./types"; | 38 | import { normalizeOpenApiAuthInputs, type AuthenticationInput } from "./types"; |
| 52 | import { ApiKeyAuthTemplate, describeApiKeyAuthMethod } from "@executor-js/sdk/http-auth"; | 39 | import { ApiKeyAuthTemplate, describeApiKeyAuthMethod } from "@executor-js/sdk/http-auth"; |
| 53 | | 40 | import { |
| 54 | // --------------------------------------------------------------------------- | 41 | compileOpenApiSpec, |
| 55 | // Plugin config | 42 | invokeOpenApiBackedTool, |
| 56 | // --------------------------------------------------------------------------- | 43 | openApiStoredOperationsFromCompiled, |
| 57 | | 44 | resolveOpenApiBackedAnnotations, |
| 58 | const STRINGIFIED_BODY_CAP = 1024; | 45 | resolveOpenApiBackedTools, |
| 59 | const UpstreamMessageBody = Schema.Struct({ message: Schema.String }); | 46 | } from "./backing"; |
| 60 | const UpstreamErrorMessageBody = Schema.Struct({ errorMessage: Schema.String }); | | |
| 61 | const UpstreamNestedErrorBody = Schema.Struct({ error: UpstreamMessageBody }); | | |
| 62 | const UpstreamErrorsArrayBody = Schema.Struct({ | | |
| 63 | errors: Schema.Array( | | |
| 64 | Schema.Struct({ | | |
| 65 | detail: Schema.optional(Schema.String), | | |
| 66 | message: Schema.optional(Schema.String), | | |
| 67 | title: Schema.optional(Schema.String), | | |
| 68 | }), | | |
| 69 | ), | | |
| 70 | }); | | |
| 71 | const UpstreamDescriptionBody = Schema.Struct({ | | |
| 72 | detail: Schema.optional(Schema.String), | | |
| 73 | title: Schema.optional(Schema.String), | | |
| 74 | description: Schema.optional(Schema.String), | | |
| 75 | }); | | |
| 76 | | | |
| 77 | const decodeUpstreamMessageBody = Schema.decodeUnknownOption(UpstreamMessageBody); | | |
| 78 | const decodeUpstreamErrorMessageBody = Schema.decodeUnknownOption(UpstreamErrorMessageBody); | | |
| 79 | const decodeUpstreamNestedErrorBody = Schema.decodeUnknownOption(UpstreamNestedErrorBody); | | |
| 80 | const decodeUpstreamErrorsArrayBody = Schema.decodeUnknownOption(UpstreamErrorsArrayBody); | | |
| 81 | const decodeUpstreamDescriptionBody = Schema.decodeUnknownOption(UpstreamDescriptionBody); | | |
| 82 | | | |
| 83 | const clampedStringify = (value: unknown): string => { | | |
| 84 | let s: string; | | |
| 85 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: JSON.stringify may throw on cycles; fall back to String() so the upstream body can still be surfaced as ToolError.details fallback text | | |
| 86 | try { | | |
| 87 | s = JSON.stringify(value); | | |
| 88 | } catch { | | |
| 89 | s = String(value); | | |
| 90 | } | | |
| 91 | return s.length > STRINGIFIED_BODY_CAP ? `${s.slice(0, STRINGIFIED_BODY_CAP)}…` : s; | | |
| 92 | }; | | |
| 93 | | | |
| 94 | const firstNonEmpty = (...values: readonly (string | undefined)[]): string | undefined => | | |
| 95 | values.find((value) => value !== undefined && value.length > 0); | | |
| 96 | | | |
| 97 | // Walk known upstream error-body shapes so ToolError.message stays concise | | |
| 98 | // while ToolError.details preserves the original body. | | |
| 99 | const extractUpstreamMessage = (body: unknown, status: number): string => { | | |
| 100 | if (typeof body === "string") { | | |
| 101 | return body.length > 0 ? body : `Upstream returned HTTP ${status}`; | | |
| 102 | } | | |
| 103 | const nested = Option.getOrUndefined(decodeUpstreamNestedErrorBody(body)); | | |
| 104 | const messageBody = Option.getOrUndefined(decodeUpstreamMessageBody(body)); | | |
| 105 | const errorMessageBody = Option.getOrUndefined(decodeUpstreamErrorMessageBody(body)); | | |
| 106 | const errorsBody = Option.getOrUndefined(decodeUpstreamErrorsArrayBody(body)); | | |
| 107 | const descriptionBody = Option.getOrUndefined(decodeUpstreamDescriptionBody(body)); | | |
| 108 | const arrayMessage = errorsBody?.errors | | |
| 109 | .map( | | |
| 110 | ({ | | |
| 111 | detail, | | |
| 112 | message: upstreamMessage, | | |
| 113 | title, | | |
| 114 | }: { | | |
| 115 | detail?: string; | | |
| 116 | message?: string; | | |
| 117 | title?: string; | | |
| 118 | }) => firstNonEmpty(detail, upstreamMessage, title), | | |
| 119 | ) | | |
| 120 | .find((message: string | undefined) => message !== undefined); | | |
| 121 | const message = firstNonEmpty( | | |
| 122 | nested?.error.message, | | |
| 123 | messageBody?.message, | | |
| 124 | errorMessageBody?.errorMessage, | | |
| 125 | arrayMessage, | | |
| 126 | descriptionBody?.detail, | | |
| 127 | descriptionBody?.title, | | |
| 128 | descriptionBody?.description, | | |
| 129 | ); | | |
| 130 | if (message !== undefined) return message; | | |
| 131 | if (body !== null && typeof body === "object") { | | |
| 132 | return clampedStringify(body); | | |
| 133 | } | | |
| 134 | return `Upstream returned HTTP ${status}`; | | |
| 135 | }; | | |
| 136 | | 47 | |
| 137 | // --------------------------------------------------------------------------- | 48 | // --------------------------------------------------------------------------- |
| 138 | // Extension input shapes | 49 | // Extension input shapes |
| @@ -381,38 +292,6 @@ const openApiToolFailure = (code: string, message: string, details?: unknown) => · 242 unmodified lines |
| 381 | ...(details === undefined ? {} : { details }), | 292 | ...(details === undefined ? {} : { details }), |
| 382 | }); | 293 | }); |
| 383 | | 294 | |
| 384 | const openApiAuthToolFailure = (failure: { | | |
| 385 | readonly code: string; | | |
| 386 | readonly message: string; | | |
| 387 | readonly owner: "org" | "user"; | | |
| 388 | readonly integration: string; | | |
| 389 | readonly connection: string; | | |
| 390 | readonly credentialKind: "secret" | "oauth" | "upstream"; | | |
| 391 | readonly credentialLabel?: string; | | |
| 392 | readonly status?: number; | | |
| 393 | readonly details?: unknown; | | |
| 394 | }) => | | |
| 395 | authToolFailure({ | | |
| 396 | // The auth-tool-failure helper's code set is shared with v1; keep the | | |
| 397 | // string but reference the connection rather than v1's source/slot. | | |
| 398 | code: failure.code as Parameters<typeof authToolFailure>[0]["code"], | | |
| 399 | message: failure.message, | | |
| 400 | source: { id: failure.integration, scope: failure.owner }, | | |
| 401 | credential: { | | |
| 402 | kind: failure.credentialKind, | | |
| 403 | ...(failure.credentialLabel ? { label: failure.credentialLabel } : {}), | | |
| 404 | }, | | |
| 405 | ...(failure.status !== undefined ? { status: failure.status } : {}), | | |
| 406 | ...(failure.details !== undefined | | |
| 407 | ? { | | |
| 408 | upstream: { | | |
| 409 | ...(failure.status !== undefined ? { status: failure.status } : {}), | | |
| 410 | details: failure.details, | | |
| 411 | }, | | |
| 412 | } | | |
| 413 | : {}), | | |
| 414 | }); | | |
| 415 | | | |
| 416 | const staticPreviewOutput = (preview: SpecPreview): StaticPreviewSpecOutput => ({ | 295 | const staticPreviewOutput = (preview: SpecPreview): StaticPreviewSpecOutput => ({ |
| 417 | title: Option.getOrNull(preview.title), | 296 | title: Option.getOrNull(preview.title), |
| 418 | version: Option.getOrNull(preview.version), | 297 | version: Option.getOrNull(preview.version), |
| @@ -477,58 +356,6 @@ const staticPreviewOutput = (preview: SpecPreview): StaticPreviewSpecOutput => ( · 58 unmodified lines |
| 477 | })), | 356 | })), |
| 478 | }); | 357 | }); |
| 479 | | 358 | |
| 480 | // --------------------------------------------------------------------------- | | |
| 481 | // Helpers | | |
| 482 | // --------------------------------------------------------------------------- | | |
| 483 | | | |
| 484 | /** Rewrite OpenAPI `#/components/schemas/X` refs to standard `#/$defs/X`. */ | | |
| 485 | const normalizeOpenApiRefs = (node: unknown): unknown => { | | |
| 486 | if (node == null || typeof node !== "object") return node; | | |
| 487 | if (Array.isArray(node)) { | | |
| 488 | let changed = false; | | |
| 489 | const out = node.map((item) => { | | |
| 490 | const n = normalizeOpenApiRefs(item); | | |
| 491 | if (n !== item) changed = true; | | |
| 492 | return n; | | |
| 493 | }); | | |
| 494 | return changed ? out : node; | | |
| 495 | } | | |
| 496 | | | |
| 497 | const obj = node as Record<string, unknown>; | | |
| 498 | | | |
| 499 | if (typeof obj.$ref === "string") { | | |
| 500 | const match = obj.$ref.match(/^#\/components\/schemas\/(.+)$/); | | |
| 501 | if (match) return { ...obj, $ref: `#/$defs/${match[1]}` }; | | |
| 502 | return obj; | | |
| 503 | } | | |
| 504 | | | |
| 505 | let changed = false; | | |
| 506 | const result: Record<string, unknown> = {}; | | |
| 507 | for (const [k, v] of Object.entries(obj)) { | | |
| 508 | const n = normalizeOpenApiRefs(v); | | |
| 509 | if (n !== v) changed = true; | | |
| 510 | result[k] = n; | | |
| 511 | } | | |
| 512 | return changed ? result : obj; | | |
| 513 | }; | | |
| 514 | | | |
| 515 | const toBinding = (def: ToolDefinition): OperationBinding => | | |
| 516 | OperationBinding.make({ | | |
| 517 | method: def.operation.method, | | |
| 518 | servers: def.operation.servers, | | |
| 519 | pathTemplate: def.operation.pathTemplate, | | |
| 520 | parameters: [...def.operation.parameters], | | |
| 521 | requestBody: def.operation.requestBody, | | |
| 522 | responseBody: def.operation.responseBody, | | |
| 523 | }); | | |
| 524 | | | |
| 525 | const descriptionFor = (def: ToolDefinition): string => { | | |
| 526 | const op = def.operation; | | |
| 527 | return Option.getOrElse(op.description, () => | | |
| 528 | Option.getOrElse(op.summary, () => `${op.method.toUpperCase()} ${op.pathTemplate}`), | | |
| 529 | ); | | |
| 530 | }; | | |
| 531 | | | |
| 532 | const specInputToSourceUrl = (spec: OpenApiSpecInput): string | undefined => | 359 | const specInputToSourceUrl = (spec: OpenApiSpecInput): string | undefined => |
| 533 | spec.kind === "url" || spec.kind === "googleDiscovery" ? spec.url : undefined; | 360 | spec.kind === "url" || spec.kind === "googleDiscovery" ? spec.url : undefined; |
| 534 | | 361 | |
| @@ -575,88 +402,6 @@ export const describeOpenApiIntegrationDisplay = ( · 40 unmodified lines |
| 575 | return { url: config?.baseUrl ?? config?.sourceUrl }; | 402 | return { url: config?.baseUrl ?? config?.sourceUrl }; |
| 576 | }; | 403 | }; |
| 577 | | 404 | |
| 578 | // --------------------------------------------------------------------------- | | |
| 579 | // Spec text resolution — the stored config carries the spec's content hash | | |
| 580 | // (`specHash` → blob `spec/<hash>`). Pre-blob rows that inlined the text are | | |
| 581 | // rewritten by the spec-to-blob migrations before this code reads them. | | |
| 582 | // --------------------------------------------------------------------------- | | |
| 583 | | | |
| 584 | const loadSpecText = ( | | |
| 585 | storage: OpenapiStore, | | |
| 586 | config: OpenApiIntegrationConfig, | | |
| 587 | ): Effect.Effect<string | null, StorageFailure> => | | |
| 588 | config.specHash != null ? storage.getSpec(config.specHash) : Effect.succeed(null); | | |
| 589 | | | |
| 590 | // --------------------------------------------------------------------------- | | |
| 591 | // Spec → tool definitions (shared by addSpec, resolveTools, and detect) | | |
| 592 | // --------------------------------------------------------------------------- | | |
| 593 | | | |
| 594 | interface CompiledSpec { | | |
| 595 | readonly definitions: readonly ToolDefinition[]; | | |
| 596 | readonly hoistedDefs: Record<string, unknown>; | | |
| 597 | readonly title: string | undefined; | | |
| 598 | /** The spec's `info.description`. */ | | |
| 599 | readonly description: string | undefined; | | |
| 600 | } | | |
| 601 | | | |
| 602 | const compileSpec = ( | | |
| 603 | specText: string, | | |
| 604 | ): Effect.Effect<CompiledSpec, OpenApiParseError | OpenApiExtractionError> => | | |
| 605 | Effect.gen(function* () { | | |
| 606 | const doc = yield* parse(specText); | | |
| 607 | const result = yield* extract(doc); | | |
| 608 | const hoistedDefs: Record<string, unknown> = {}; | | |
| 609 | if (doc.components?.schemas) { | | |
| 610 | for (const [k, v] of Object.entries(doc.components.schemas)) { | | |
| 611 | hoistedDefs[k] = normalizeOpenApiRefs(v); | | |
| 612 | } | | |
| 613 | } | | |
| 614 | return { | | |
| 615 | definitions: compileToolDefinitions(result.operations), | | |
| 616 | hoistedDefs, | | |
| 617 | title: Option.getOrUndefined(result.title), | | |
| 618 | description: Option.getOrUndefined(result.description), | | |
| 619 | }; | | |
| 620 | }); | | |
| 621 | | | |
| 622 | // A tool's name carries its structured `group.leaf` path verbatim (e.g. | | |
| 623 | // `aliases.deleteAlias`). The address grammar | | |
| 624 | // `tools.<integration>.<owner>.<connection>.<tool>` treats `<tool>` as the | | |
| 625 | // trailing remainder (see parseToolAddress), so the dotted path needs no | | |
| 626 | // flattening — it nests naturally as | | |
| 627 | // `tools.<integration>.<owner>.<connection>.aliases.deleteAlias`, matching how | | |
| 628 | // the sandbox `tools` proxy joins property access. | | |
| 629 | | | |
| 630 | const toolDefsFromCompiled = (compiled: CompiledSpec): readonly ToolDef[] => | | |
| 631 | compiled.definitions.map( | | |
| 632 | (def): ToolDef => ({ | | |
| 633 | name: ToolName.make(def.toolPath), | | |
| 634 | description: descriptionFor(def), | | |
| 635 | inputSchema: normalizeOpenApiRefs(Option.getOrUndefined(def.operation.inputSchema)), | | |
| 636 | // The output schema is the upstream response body only — transport | | |
| 637 | // status/headers live in the ToolResult `http` side channel, not the | | |
| 638 | // payload (see the invoke handler). | | |
| 639 | outputSchema: Option.match(def.operation.responseBody, { | | |
| 640 | onNone: () => normalizeOpenApiRefs(Option.getOrUndefined(def.operation.outputSchema)), | | |
| 641 | onSome: (responseBody) => | | |
| 642 | Option.isSome(responseBody.fileHint) | | |
| 643 | ? ToolFileJsonSchema | | |
| 644 | : normalizeOpenApiRefs(Option.getOrUndefined(def.operation.outputSchema)), | | |
| 645 | }), | | |
| 646 | annotations: annotationsForOperation(def.operation.method, def.operation.pathTemplate), | | |
| 647 | }), | | |
| 648 | ); | | |
| 649 | | | |
| 650 | const storedOperationsFromCompiled = ( | | |
| 651 | integration: string, | | |
| 652 | compiled: CompiledSpec, | | |
| 653 | ): readonly StoredOperation[] => | | |
| 654 | compiled.definitions.map((def) => ({ | | |
| 655 | integration, | | |
| 656 | toolName: def.toolPath, | | |
| 657 | binding: toBinding(def), | | |
| 658 | })); | | |
| 659 | | | |
| 660 | // --------------------------------------------------------------------------- | 405 | // --------------------------------------------------------------------------- |
| 661 | // Plugin factory | 406 | // Plugin factory |
| 662 | // --------------------------------------------------------------------------- | 407 | // --------------------------------------------------------------------------- |
| @@ -752,7 +497,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 89 unmodified lines |
| 752 | // Resolve URL → text and parse BEFORE opening a transaction. Holding | 497 | // Resolve URL → text and parse BEFORE opening a transaction. Holding |
| 753 | // `BEGIN` across a network fetch is the Hyperdrive deadlock path. | 498 | // `BEGIN` across a network fetch is the Hyperdrive deadlock path. |
| 754 | const resolved = yield* resolveSpecForInput(config.spec, httpClientLayer); | 499 | const resolved = yield* resolveSpecForInput(config.spec, httpClientLayer); |
| 755 | const compiled = yield* compileSpec(resolved.specText); | 500 | const compiled = yield* compileOpenApiSpec(resolved.specText); |
| 756 | | 501 | |
| 757 | // Defaults the add page derives from its preview, applied here so | 502 | // Defaults the add page derives from its preview, applied here so |
| 758 | // headless callers (MCP, API) get the same integration the UI's | 503 | // headless callers (MCP, API) get the same integration the UI's |
| @@ -846,7 +591,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 87 unmodified lines |
| 846 | }); | 591 | }); |
| 847 | yield* ctx.storage.putOperations( | 592 | yield* ctx.storage.putOperations( |
| 848 | config.slug, | 593 | config.slug, |
| 849 | storedOperationsFromCompiled(config.slug, compiled), | 594 | openApiStoredOperationsFromCompiled(config.slug, compiled), |
| 850 | ); | 595 | ); |
| 851 | }), | 596 | }), |
| 852 | ); | 597 | ); |
| @@ -890,7 +635,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 37 unmodified lines |
| 890 | // Resolve + compile BEFORE the transaction (same Hyperdrive-deadlock | 635 | // Resolve + compile BEFORE the transaction (same Hyperdrive-deadlock |
| 891 | // rule as addSpec: never hold BEGIN across a network fetch). | 636 | // rule as addSpec: never hold BEGIN across a network fetch). |
| 892 | const resolved = yield* resolveSpecForInput(specInput, httpClientLayer); | 637 | const resolved = yield* resolveSpecForInput(specInput, httpClientLayer); |
| 893 | const compiled = yield* compileSpec(resolved.specText); | 638 | const compiled = yield* compileOpenApiSpec(resolved.specText); |
| 894 | | 639 | |
| 895 | const previousOperations = yield* ctx.storage.listOperations(rawSlug); | 640 | const previousOperations = yield* ctx.storage.listOperations(rawSlug); |
| 896 | const previousNames = new Set(previousOperations.map((op) => op.toolName)); | 641 | const previousNames = new Set(previousOperations.map((op) => op.toolName)); |
| @@ -921,7 +666,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 24 unmodified lines |
| 921 | }); | 666 | }); |
| 922 | yield* ctx.storage.putOperations( | 667 | yield* ctx.storage.putOperations( |
| 923 | rawSlug, | 668 | rawSlug, |
| 924 | storedOperationsFromCompiled(rawSlug, compiled), | 669 | openApiStoredOperationsFromCompiled(rawSlug, compiled), |
| 925 | ); | 670 | ); |
| 926 | }), | 671 | }), |
| 927 | ); | 672 | ); |
| @@ -1133,173 +878,24 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { · 205 unmodified lines |
| 1133 | // operation bindings invokeTool needs are persisted at addSpec time; this | 878 | // operation bindings invokeTool needs are persisted at addSpec time; this |
| 1134 | // hook only shapes the per-connection ToolDefs from the spec blob the | 879 | // hook only shapes the per-connection ToolDefs from the spec blob the |
| 1135 | // catalog config points at. | 880 | // catalog config points at. |
| 1136 | resolveTools: ({ | 881 | resolveTools: ({ config, storage }) => resolveOpenApiBackedTools({ config, storage }), |
| 1137 | config, | 882 | |
| 1138 | storage, | 883 | invokeTool: ({ ctx: invokeCtx, toolRow, credential, args }) => { |
| 1139 | }: { | 884 | const httpClientLayer = options?.httpClientLayer ?? invokeCtx.httpClientLayer; |
| 1140 | readonly integration: Integration; | 885 | return invokeOpenApiBackedTool({ |
| 1141 | readonly config: IntegrationConfig; | 886 | ctx: invokeCtx, |
| 1142 | readonly storage: OpenapiStore; | 887 | toolRow, |
| 1143 | }): Effect.Effect<ResolveToolsResult, StorageFailure> => | 888 | credential, |
| 1144 | Effect.gen(function* () { | 889 | args, |
| 1145 | const openApiConfig = decodeOpenApiIntegrationConfig(config); | 890 | httpClientLayer, |
| 1146 | if (!openApiConfig) return { tools: [], definitions: {} }; | 891 | }); |
| 1147 | const specText = yield* loadSpecText(storage, openApiConfig); | 892 | }, |
| 1148 | if (specText == null) return { tools: [], definitions: {} }; | | |
| 1149 | const compiled = yield* compileSpec(specText).pipe( | | |
| 1150 | Effect.catch(() => Effect.succeed(null)), | | |
| 1151 | ); | | |
| 1152 | if (!compiled) return { tools: [], definitions: {} }; | | |
| 1153 | return { | | |
| 1154 | tools: toolDefsFromCompiled(compiled), | | |
| 1155 | definitions: compiled.hoistedDefs, | | |
| 1156 | }; | | |
| 1157 | }), | | |
| 1158 | | | |
| 1159 | invokeTool: ({ | | |
| 1160 | ctx: invokeCtx, | | |
| 1161 | toolRow, | | |
| 1162 | credential, | | |
| 1163 | args, | | |
| 1164 | }: { | | |
| 1165 | readonly ctx: PluginCtx<OpenapiStore>; | | |
| 1166 | readonly toolRow: { readonly integration: string; readonly name: string }; | | |
| 1167 | readonly credential: ToolInvocationCredential; | | |
| 1168 | readonly args: unknown; | | |
| 1169 | }) => | | |
| 1170 | Effect.gen(function* () { | | |
| 1171 | const httpClientLayer = options?.httpClientLayer ?? invokeCtx.httpClientLayer; | | |
| 1172 | const integration = toolRow.integration; | | |
| 1173 | const config = decodeOpenApiIntegrationConfig(credential.config); | | |
| 1174 | | | |
| 1175 | // Resolve the operation binding from the plugin store; fall back to | | |
| 1176 | // re-deriving it from the spec blob when the store has no row (e.g. an | | |
| 1177 | // integration registered without going through addSpec). The fallback | | |
| 1178 | // is the ONLY invoke-path spec read — the hot path never loads it. | | |
| 1179 | const bindingFromSpec = config | | |
| 1180 | ? Effect.gen(function* () { | | |
| 1181 | const specText = yield* loadSpecText(invokeCtx.storage, config).pipe( | | |
| 1182 | Effect.catch(() => Effect.succeed(null)), | | |
| 1183 | ); | | |
| 1184 | const compiled = | | |
| 1185 | specText == null | | |
| 1186 | ? null | | |
| 1187 | : yield* compileSpec(specText).pipe(Effect.catch(() => Effect.succeed(null))); | | |
| 1188 | return compiled | | |
| 1189 | ? storedOperationsFromCompiled(integration, compiled).find( | | |
| 1190 | (op) => op.toolName === toolRow.name, | | |
| 1191 | )?.binding | | |
| 1192 | : undefined; | | |
| 1193 | }) | | |
| 1194 | : Effect.succeed(undefined); | | |
| 1195 | | | |
| 1196 | let binding = (yield* invokeCtx.storage.getOperation(integration, toolRow.name))?.binding; | | |
| 1197 | if ((!binding || Option.isNone(binding.responseBody)) && config) { | | |
| 1198 | binding = (yield* bindingFromSpec) ?? binding; | | |
| 1199 | } | | |
| 1200 | if (!binding) { | | |
| 1201 | return yield* new OpenApiExtractionError({ | | |
| 1202 | message: `No OpenAPI operation found for tool "${toolRow.name}" on "${integration}"`, | | |
| 1203 | }); | | |
| 1204 | } | | |
| 1205 | | | |
| 1206 | const headers: Record<string, string> = { ...(config?.headers ?? {}) }; | | |
| 1207 | const queryParams: Record<string, string> = { | | |
| 1208 | ...(config?.queryParams ?? {}), | | |
| 1209 | }; | | |
| 1210 | | | |
| 1211 | // Apply the auth template (D11): render the connection's resolved inputs | | |
| 1212 | // into the matching template's header/query slots (apiKey) or as a bearer | | |
| 1213 | // Authorization header (oauth). A method with two distinct inputs (e.g. | | |
| 1214 | // Datadog) renders each from its own value. | | |
| 1215 | const template = (config?.authenticationTemplate ?? []).find( | | |
| 1216 | (entry) => String(entry.slug) === String(credential.template), | | |
| 1217 | ); | | |
| 1218 | if (template) { | | |
| 1219 | // Fail if ANY input the template requires is unresolved — not just the | | |
| 1220 | // primary `token` (a Datadog connection has no `token`, only its keys). | | |
| 1221 | const missing = requiredTemplateVariables(template).filter((name) => { | | |
| 1222 | const value = credential.values[name]; | | |
| 1223 | return value == null || value === ""; | | |
| 1224 | }); | | |
| 1225 | if (missing.length > 0) { | | |
| 1226 | return openApiAuthToolFailure({ | | |
| 1227 | code: | | |
| 1228 | template.kind === "oauth2" | | |
| 1229 | ? "oauth_connection_missing" | | |
| 1230 | : "connection_value_missing", | | |
| 1231 | message: `Connection "${credential.connection}" for "${integration}" has no resolvable credential value. Re-authenticate or update the connection.`, | | |
| 1232 | owner: credential.owner, | | |
| 1233 | integration, | | |
| 1234 | connection: String(credential.connection), | | |
| 1235 | credentialKind: template.kind === "oauth2" ? "oauth" : "secret", | | |
| 1236 | }); | | |
| 1237 | } | | |
| 1238 | const rendered = renderAuthTemplate(template, credential.values); | | |
| 1239 | Object.assign(headers, rendered.headers); | | |
| 1240 | Object.assign(queryParams, rendered.queryParams); | | |
| 1241 | } | | |
| 1242 | | | |
| 1243 | const result = yield* invokeWithLayer( | | |
| 1244 | binding, | | |
| 1245 | (args ?? {}) as Record<string, unknown>, | | |
| 1246 | config?.baseUrl ?? "", | | |
| 1247 | headers, | | |
| 1248 | queryParams, | | |
| 1249 | httpClientLayer, | | |
| 1250 | ); | | |
| 1251 | | | |
| 1252 | const ok = result.status >= 200 && result.status < 300; | | |
| 1253 | if (!ok) { | | |
| 1254 | if (result.status === 401 || result.status === 403) { | | |
| 1255 | return openApiAuthToolFailure({ | | |
| 1256 | code: "connection_rejected", | | |
| 1257 | status: result.status, | | |
| 1258 | message: `Upstream rejected credentials for "${integration}" with HTTP ${result.status}. Re-authenticate or update the connection "${credential.connection}" before retrying this tool.`, | | |
| 1259 | owner: credential.owner, | | |
| 1260 | integration, | | |
| 1261 | connection: String(credential.connection), | | |
| 1262 | credentialKind: "upstream", | | |
| 1263 | credentialLabel: "Upstream authorization", | | |
| 1264 | details: result.error, | | |
| 1265 | }); | | |
| 1266 | } | | |
| 1267 | return ToolResult.fail({ | | |
| 1268 | code: "upstream_http_error", | | |
| 1269 | status: result.status, | | |
| 1270 | message: extractUpstreamMessage(result.error, result.status), | | |
| 1271 | details: result.error, | | |
| 1272 | }); | | |
| 1273 | } | | |
| 1274 | // Payload-first: `data` is the upstream response body (matching the | | |
| 1275 | // graphql/mcp plugins); transport facts ride in the `http` side | | |
| 1276 | // channel so pagination/rate-limit headers stay reachable. | | |
| 1277 | return ToolResult.ok(result.data, { | | |
| 1278 | http: { status: result.status, headers: result.headers }, | | |
| 1279 | }); | | |
| 1280 | }), | | |
| 1281 | | 893 | |
| 1282 | resolveAnnotations: ({ | 894 | resolveAnnotations: ({ ctx: annotationsCtx, integration, toolRows }) => |
| 1283 | ctx: annotationsCtx, | 895 | resolveOpenApiBackedAnnotations({ |
| 1284 | integration, | 896 | ctx: annotationsCtx, |
| 1285 | toolRows, | 897 | integration: String(integration), |
| 1286 | }: { | 898 | toolRows, |
| 1287 | readonly ctx: PluginCtx<OpenapiStore>; | | |
| 1288 | readonly integration: string; | | |
| 1289 | readonly toolRows: readonly { readonly name: string }[]; | | |
| 1290 | }) => | | |
| 1291 | Effect.gen(function* () { | | |
| 1292 | const ops = yield* annotationsCtx.storage.listOperations(String(integration)); | | |
| 1293 | const byName = new Map<string, OperationBinding>(); | | |
| 1294 | for (const op of ops) byName.set(op.toolName, op.binding); | | |
| 1295 | const out: Record<string, ReturnType<typeof annotationsForOperation>> = {}; | | |
| 1296 | for (const row of toolRows) { | | |
| 1297 | const binding = byName.get(row.name); | | |
| 1298 | if (binding) { | | |
| 1299 | out[row.name] = annotationsForOperation(binding.method, binding.pathTemplate); | | |
| 1300 | } | | |
| 1301 | } | | |
| 1302 | return out; | | |
| 1303 | }), | 899 | }), |
| 1304 | | 900 | |
| 1305 | removeConnection: () => Effect.void, | 901 | removeConnection: () => Effect.void, |