Unified · Split
| @@ -83,6 +83,8 @@ That is intentional, but the direction is: · 82 unmodified lines | |||
| 83 | 83 | ||
| 84 | The goal is not to erase differences between formats. The goal is to stop those differences from dominating the core architecture. | 84 | The goal is not to erase differences between formats. The goal is to stop those differences from dominating the core architecture. |
| 85 | 85 | ||
| 86 | For the current fidelity tradeoffs and known lossy areas across adapters, see [Import Fidelity](/developer/import-fidelity). | ||
| 87 | |||
| 86 | ## What a good new adapter should do | 88 | ## What a good new adapter should do |
| 87 | 89 | ||
| 88 | A good new adapter should: | 90 | A good new adapter should: |
| @@ -0,0 +1,132 @@ | |||
| 1 | --- | ||
| 2 | title: "Import Fidelity" | ||
| 3 | description: "What executor preserves, normalizes, and currently loses when importing external formats." | ||
| 4 | --- | ||
| 5 | |||
| 6 | Executor should normalize syntax, not meaning. | ||
| 7 | |||
| 8 | That means the import pipeline can simplify surface differences between OpenAPI, GraphQL, Google Discovery, and MCP, but it should not silently erase semantics that change execution, trust, or user understanding. | ||
| 9 | |||
| 10 | The practical shape is: | ||
| 11 | |||
| 12 | - keep the raw source document or manifest | ||
| 13 | - keep adapter-native metadata that does not fit the common model cleanly | ||
| 14 | - promote execution-relevant semantics into first-class IR | ||
| 15 | |||
| 16 | ## The fidelity buckets | ||
| 17 | |||
| 18 | This audit uses four buckets: | ||
| 19 | |||
| 20 | - `Raw source`: the original imported document or manifest | ||
| 21 | - `Adapter/native`: typed provider data or native blobs attached during import | ||
| 22 | - `IR`: first-class catalog symbols and capability metadata | ||
| 23 | - `UI today`: what current inspection surfaces directly | ||
| 24 | |||
| 25 | Current inspection is still narrow. It mostly shows projected schemas plus `tool.executable.native[0]`, not all preserved native blobs or capability-native metadata. | ||
| 26 | |||
| 27 | ## OpenAPI | ||
| 28 | |||
| 29 | | Source detail | Raw source | Adapter/native | IR | UI today | Notes | | ||
| 30 | | --- | --- | --- | --- | --- | --- | | ||
| 31 | | Operation auth requirements and security schemes | Yes | No | No | No | Operation `security` is not promoted; imported capabilities currently use `auth: none`. | | ||
| 32 | | Exact response variants, status codes, and headers | Yes | Partial | No | Partial | Import chooses one preferred response, then projects one synthetic `2XX` response set. | | ||
| 33 | | Parameter serialization (`style`, `explode`, `allowReserved`, content-based params) | Yes | Mostly no | No | No | The IR can model these, but the OpenAPI importer currently only takes parameter `schema`. | | ||
| 34 | | Multiple request and response media types | Yes | Partial | Partial | Partial | Import picks a preferred content entry for typing; response docs keep content types only for the chosen response. | | ||
| 35 | | `servers` defaults | Yes | No | No | No | The IR can represent scope server defaults, but OpenAPI import does not populate them. | | ||
| 36 | | Rich schema semantics beyond the importer subset | Yes | Partial | Partial | Partial | Unsupported JSON Schema shapes fall back to `unknown` plus native preservation. | | ||
| 37 | |||
| 38 | ## GraphQL | ||
| 39 | |||
| 40 | | Source detail | Raw source | Adapter/native | IR | UI today | Notes | | ||
| 41 | | --- | --- | --- | --- | --- | --- | | ||
| 42 | | Subscription root fields as tools | Yes | Partial | No | No | The manifest records `subscriptionTypeName`, but tool generation only materializes query and mutation fields. | | ||
| 43 | | Full field result shapes for generated field tools | Yes | Partial | Partial | Partial | Field-tool outputs are heuristic projections, not full result types. | | ||
| 44 | | Union and deep nested selections | Yes | Partial | Partial | Partial | Unions degrade to `__typename`; nested selection stops after depth 2 in generated field tools. | | ||
| 45 | | Arbitrary caller-selected field projections | Yes | Partial | No | No | Generated field tools are compiled with fixed selections; the raw `request` tool is the escape hatch. | | ||
| 46 | | Deprecation reasons | Yes | Partial | Partial | No | GraphQL schemas emit `deprecated: true` plus `x-deprecationReason`, but the generic schema importer only keeps `deprecated` as first-class shape metadata. | | ||
| 47 | |||
| 48 | GraphQL is a special case: the main loss is not the raw source being discarded, but the generated field-tool projection being intentionally narrower than the schema. That tradeoff is reasonable for discoverability, but only if the system continues to preserve and expose the raw request path. | ||
| 49 | |||
| 50 | ## Google Discovery | ||
| 51 | |||
| 52 | | Source detail | Raw source | Adapter/native | IR | UI today | Notes | | ||
| 53 | | --- | --- | --- | --- | --- | --- | | ||
| 54 | | OAuth scope descriptions | Yes | Partial | No | No | Document-level scope descriptions are extracted, but the IR security scheme rewrites scopes to `{ scope: scope }`. | | ||
| 55 | | Rich media upload metadata | Yes | Partial | Partial | Partial | Upload support is collapsed to booleans such as `supportsMediaUpload` and `supportsMediaDownload`. | | ||
| 56 | | Service-level metadata (`title`, `description`, `batchPath`, `documentationLink`) | Yes | Partial | No | No | The extracted manifest has these fields, but per-operation provider data does not carry them into the catalog. | | ||
| 57 | | Parameter descriptions, repeated-ness, enums, defaults | Yes | Yes | Mostly yes | Mostly yes | This area is comparatively good. | | ||
| 58 | | Request and response schemas | Yes | Yes | Yes | Yes | Core schema structure is preserved reasonably well. | | ||
| 59 | |||
| 60 | Google Discovery is less lossy than OpenAPI on request and response typing, but it still loses useful service metadata and the human meaning attached to OAuth scopes. | ||
| 61 | |||
| 62 | ## MCP | ||
| 63 | |||
| 64 | | Source detail | Raw source | Adapter/native | IR | UI today | Notes | | ||
| 65 | | --- | --- | --- | --- | --- | --- | | ||
| 66 | | Non-tool MCP resources such as prompts or resources | No | No | No | No | The adapter only discovers `listTools()`. Anything outside the tool list never enters import. | | ||
| 67 | | Extra tool metadata beyond `name`, `description`, `inputSchema` or `parameters`, and `outputSchema` | Partial | Partial | No | No | The manifest schema keeps only a narrow subset of listed tool fields. | | ||
| 68 | | Tool-level safety or effect hints, if present | Partial | No | No | No | Imported MCP operations are currently hardcoded to `effect: action`. | | ||
| 69 | | Tool input and output schemas | Yes | Yes | Yes | Yes | This is the strongest part of the current MCP import. | | ||
| 70 | | Server-level metadata outside the tool list | No | No | No | No | The stored MCP "document" is a synthesized tool manifest, not a richer upstream server description. | | ||
| 71 | |||
| 72 | MCP is the most structurally lossy adapter today because the discovery step itself only imports tools and already narrows their metadata aggressively. | ||
| 73 | |||
| 74 | ## Cross-cutting UI caveat | ||
| 75 | |||
| 76 | Even when data is preserved natively, the current inspection UI often does not show it directly. | ||
| 77 | |||
| 78 | Today inspection primarily exposes: | ||
| 79 | |||
| 80 | - projected call and result schemas | ||
| 81 | - capability summary text | ||
| 82 | - the first executable native blob | ||
| 83 | |||
| 84 | That means "preserved in native data" and "visible to a user" are still different states. | ||
| 85 | |||
| 86 | ## What should be first-class in IR | ||
| 87 | |||
| 88 | These semantics are worth promoting into the common model because they affect execution or trust: | ||
| 89 | |||
| 90 | - auth and security requirements described by the source format | ||
| 91 | - response and status variants | ||
| 92 | - HTTP parameter serialization | ||
| 93 | - upload, download, and streaming traits | ||
| 94 | - GraphQL selection and subscription semantics | ||
| 95 | - source-provided safety or idempotency hints | ||
| 96 | |||
| 97 | By contrast, many provider-specific labels, service metadata fields, and raw source extras are fine to keep in adapter-native metadata as long as they remain inspectable. | ||
| 98 | |||
| 99 | ## Recommended priority order | ||
| 100 | |||
| 101 | ### P0 | ||
| 102 | |||
| 103 | - OpenAPI auth and security requirements | ||
| 104 | - OpenAPI response variants and status distinctions | ||
| 105 | - Google Discovery scope descriptions | ||
| 106 | - MCP discovery breadth: decide whether executor intends to import only tools or broader MCP surface area | ||
| 107 | |||
| 108 | ### P1 | ||
| 109 | |||
| 110 | - OpenAPI parameter serialization | ||
| 111 | - GraphQL subscription modeling | ||
| 112 | - GraphQL field-tool projection metadata so the fixed-selection behavior is explicit | ||
| 113 | - Google Discovery service metadata surfacing | ||
| 114 | - MCP tool metadata preservation beyond the current narrow manifest | ||
| 115 | |||
| 116 | ### P2 | ||
| 117 | |||
| 118 | - Better UI fallback to native/provider metadata | ||
| 119 | - Richer diagnostics for intentionally lossy normalization | ||
| 120 | - A fixture-backed audit matrix test per adapter | ||
| 121 | |||
| 122 | ## The IR boundary | ||
| 123 | |||
| 124 | The right boundary is: | ||
| 125 | |||
| 126 | - `raw source` remains the source of truth | ||
| 127 | - `adapter-native metadata` keeps source-specific details close to their format | ||
| 128 | - `IR` stays unified, but it must be lossless for downstream semantics | ||
| 129 | |||
| 130 | If executor keeps that line clear, a standardized representation is a strength. | ||
| 131 | |||
| 132 | If executor lets semantically important details live only in hidden native blobs, the standardized representation becomes misleading. | ||
| @@ -37,6 +37,7 @@ export const googleDiscoveryProviderDataFromDefinition = ( · 36 unmodified lines | |||
| 37 | version: string; | 37 | version: string; |
| 38 | rootUrl: string; | 38 | rootUrl: string; |
| 39 | servicePath: string; | 39 | servicePath: string; |
| 40 | oauthScopes?: Readonly<Record<string, string>>; | ||
| 40 | definition: GoogleDiscoveryManifestMethod; | 41 | definition: GoogleDiscoveryManifestMethod; |
| 41 | }, | 42 | }, |
| 42 | ): typeof GoogleDiscoveryToolProviderDataSchema.Type => ({ | 43 | ): typeof GoogleDiscoveryToolProviderDataSchema.Type => ({ |
| @@ -58,6 +59,17 @@ export const googleDiscoveryProviderDataFromDefinition = ( · 15 unmodified lines | |||
| 58 | requestSchemaId: input.definition.requestSchemaId, | 59 | requestSchemaId: input.definition.requestSchemaId, |
| 59 | responseSchemaId: input.definition.responseSchemaId, | 60 | responseSchemaId: input.definition.responseSchemaId, |
| 60 | scopes: input.definition.scopes, | 61 | scopes: input.definition.scopes, |
| 62 | ...(input.oauthScopes | ||
| 63 | ? { | ||
| 64 | scopeDescriptions: Object.fromEntries( | ||
| 65 | input.definition.scopes.flatMap((scope) => | ||
| 66 | input.oauthScopes?.[scope] !== undefined | ||
| 67 | ? [[scope, input.oauthScopes[scope]!]] | ||
| 68 | : [], | ||
| 69 | ), | ||
| 70 | ), | ||
| 71 | } | ||
| 72 | : {}), | ||
| 61 | supportsMediaUpload: input.definition.supportsMediaUpload, | 73 | supportsMediaUpload: input.definition.supportsMediaUpload, |
| 62 | supportsMediaDownload: input.definition.supportsMediaDownload, | 74 | supportsMediaDownload: input.definition.supportsMediaDownload, |
| 63 | }, | 75 | }, |
| @@ -347,6 +359,7 @@ export type CreateGoogleDiscoveryToolFromDefinitionInput = { · 283 unmodified lines | |||
| 347 | version: string; | 359 | version: string; |
| 348 | rootUrl: string; | 360 | rootUrl: string; |
| 349 | servicePath: string; | 361 | servicePath: string; |
| 362 | oauthScopes?: Readonly<Record<string, string>>; | ||
| 350 | path: string; | 363 | path: string; |
| 351 | sourceKey: string; | 364 | sourceKey: string; |
| 352 | defaultHeaders?: Readonly<Record<string, string>>; | 365 | defaultHeaders?: Readonly<Record<string, string>>; |
| @@ -371,6 +384,7 @@ export const buildGoogleDiscoveryToolPresentation = (input: { · 18 unmodified lines | |||
| 371 | versionName: string; | 384 | versionName: string; |
| 372 | rootUrl: string; | 385 | rootUrl: string; |
| 373 | servicePath: string; | 386 | servicePath: string; |
| 387 | oauthScopes?: Readonly<Record<string, string>>; | ||
| 374 | schemaRefTable?: Readonly<GoogleDiscoverySchemaRefTable>; | 388 | schemaRefTable?: Readonly<GoogleDiscoverySchemaRefTable>; |
| 375 | }; | 389 | }; |
| 376 | definition: GoogleDiscoveryToolDefinition; | 390 | definition: GoogleDiscoveryToolDefinition; |
| @@ -409,6 +423,7 @@ export const buildGoogleDiscoveryToolPresentation = (input: { · 32 unmodified lines | |||
| 409 | version: input.manifest.versionName, | 423 | version: input.manifest.versionName, |
| 410 | rootUrl: input.manifest.rootUrl, | 424 | rootUrl: input.manifest.rootUrl, |
| 411 | servicePath: input.manifest.servicePath, | 425 | servicePath: input.manifest.servicePath, |
| 426 | oauthScopes: input.manifest.oauthScopes, | ||
| 412 | definition: input.definition, | 427 | definition: input.definition, |
| 413 | }), | 428 | }), |
| 414 | }; | 429 | }; |
| @@ -423,6 +438,7 @@ export const createGoogleDiscoveryToolFromDefinition = ( · 8 unmodified lines | |||
| 423 | versionName: input.version, | 438 | versionName: input.version, |
| 424 | rootUrl: input.rootUrl, | 439 | rootUrl: input.rootUrl, |
| 425 | servicePath: input.servicePath, | 440 | servicePath: input.servicePath, |
| 441 | oauthScopes: input.oauthScopes, | ||
| 426 | schemaRefTable: input.schemaRefTable, | 442 | schemaRefTable: input.schemaRefTable, |
| 427 | }, | 443 | }, |
| 428 | definition: input.definition, | 444 | definition: input.definition, |
| @@ -41,6 +41,12 @@ export const GoogleDiscoveryInvocationPayloadSchema = Schema.Struct({ · 40 unmodified lines | |||
| 41 | requestSchemaId: Schema.NullOr(Schema.String), | 41 | requestSchemaId: Schema.NullOr(Schema.String), |
| 42 | responseSchemaId: Schema.NullOr(Schema.String), | 42 | responseSchemaId: Schema.NullOr(Schema.String), |
| 43 | scopes: Schema.Array(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 | ), | ||
| 44 | supportsMediaUpload: Schema.Boolean, | 50 | supportsMediaUpload: Schema.Boolean, |
| 45 | supportsMediaDownload: Schema.Boolean, | 51 | supportsMediaDownload: Schema.Boolean, |
| 46 | }); | 52 | }); |
| @@ -17,3 +17,11 @@ export { · 16 unmodified lines | |||
| 17 | type McpToolManifest, | 17 | type McpToolManifest, |
| 18 | type McpToolManifestEntry, | 18 | type McpToolManifestEntry, |
| 19 | } from "./mcp-tools"; | 19 | } from "./mcp-tools"; |
| 20 | export type { | ||
| 21 | McpListToolsMetadata, | ||
| 22 | McpServerCapabilities, | ||
| 23 | McpServerInfo, | ||
| 24 | McpServerMetadata, | ||
| 25 | McpToolAnnotations, | ||
| 26 | McpToolExecution, | ||
| 27 | } from "./mcp-manifest"; | ||
| @@ -3,16 +3,81 @@ import * as Schema from "effect/Schema"; · 2 unmodified lines | |||
| 3 | 3 | ||
| 4 | import type { ToolPath } from "@executor/codemode-core"; | 4 | import type { ToolPath } from "@executor/codemode-core"; |
| 5 | 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 | |||
| 6 | export type McpToolManifestEntry = { | 62 | export type McpToolManifestEntry = { |
| 7 | toolId: string; | 63 | toolId: string; |
| 8 | toolName: string; | 64 | toolName: string; |
| 9 | description: string | null; | 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; | ||
| 10 | inputSchema?: unknown; | 73 | inputSchema?: unknown; |
| 11 | outputSchema?: unknown; | 74 | outputSchema?: unknown; |
| 12 | }; | 75 | }; |
| 13 | 76 | ||
| 14 | export type McpToolManifest = { | 77 | export type McpToolManifest = { |
| 15 | version: 1; | 78 | version: 2; |
| 79 | server?: McpServerMetadata | null; | ||
| 80 | listTools?: McpListToolsMetadata | null; | ||
| 16 | tools: readonly McpToolManifestEntry[]; | 81 | tools: readonly McpToolManifestEntry[]; |
| 17 | }; | 82 | }; |
| 18 | 83 | ||
| @@ -34,16 +99,59 @@ const uniqueToolId = (value: string, byBase: Map<string, number>): string => { · 15 unmodified lines | |||
| 34 | return count === 1 ? base : `${base}_${count}`; | 99 | return count === 1 ? base : `${base}_${count}`; |
| 35 | }; | 100 | }; |
| 36 | 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 | |||
| 37 | const ListedMcpToolSchema = Schema.Struct({ | 138 | const ListedMcpToolSchema = Schema.Struct({ |
| 38 | name: Schema.String, | 139 | name: Schema.String, |
| 140 | title: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 39 | description: Schema.optional(Schema.NullOr(Schema.String)), | 141 | description: Schema.optional(Schema.NullOr(Schema.String)), |
| 40 | inputSchema: Schema.optional(Schema.Unknown), | 142 | inputSchema: Schema.optional(Schema.Unknown), |
| 41 | parameters: Schema.optional(Schema.Unknown), | 143 | parameters: Schema.optional(Schema.Unknown), |
| 42 | outputSchema: 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), | ||
| 43 | }); | 149 | }); |
| 44 | 150 | ||
| 45 | const ListToolsResultSchema = Schema.Struct({ | 151 | const ListToolsResultSchema = Schema.Struct({ |
| 46 | tools: Schema.Array(ListedMcpToolSchema), | 152 | tools: Schema.Array(ListedMcpToolSchema), |
| 153 | nextCursor: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 154 | _meta: Schema.optional(Schema.Unknown), | ||
| 47 | }); | 155 | }); |
| 48 | 156 | ||
| 49 | const decodeListToolsResultOption = Schema.decodeUnknownOption(ListToolsResultSchema); | 157 | const decodeListToolsResultOption = Schema.decodeUnknownOption(ListToolsResultSchema); |
| @@ -57,22 +165,177 @@ const readListedTools = (value: unknown): ReadonlyArray<typeof ListedMcpToolSche · 7 unmodified lines | |||
| 57 | return decoded.value.tools; | 165 | return decoded.value.tools; |
| 58 | }; | 166 | }; |
| 59 | 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 | |||
| 60 | export const extractMcpToolManifestFromListToolsResult = ( | 305 | export const extractMcpToolManifestFromListToolsResult = ( |
| 61 | listToolsResult: unknown, | 306 | listToolsResult: unknown, |
| 307 | metadata?: { | ||
| 308 | serverInfo?: unknown; | ||
| 309 | serverCapabilities?: unknown; | ||
| 310 | instructions?: string | null | undefined; | ||
| 311 | }, | ||
| 62 | ): McpToolManifest => { | 312 | ): McpToolManifest => { |
| 63 | const byBase = new Map<string, number>(); | 313 | const byBase = new Map<string, number>(); |
| 314 | const rawListTools = asObject(listToolsResult); | ||
| 315 | const rawListedTools = Array.isArray(rawListTools.tools) ? rawListTools.tools : []; | ||
| 64 | 316 | ||
| 65 | const tools = readListedTools(listToolsResult) | 317 | const tools = readListedTools(listToolsResult) |
| 66 | .map((tool): McpToolManifestEntry | null => { | 318 | .map((tool, index): McpToolManifestEntry | null => { |
| 67 | const toolName = tool.name.trim(); | 319 | const toolName = tool.name.trim(); |
| 68 | if (toolName.length === 0) { | 320 | if (toolName.length === 0) { |
| 69 | return null; | 321 | return null; |
| 70 | } | 322 | } |
| 71 | 323 | ||
| 324 | const title = asString(tool.title); | ||
| 325 | const annotations = normalizeToolAnnotations(tool.annotations); | ||
| 326 | const displayTitle = title ?? annotations?.title ?? toolName; | ||
| 327 | |||
| 72 | return { | 328 | return { |
| 73 | toolId: uniqueToolId(toolName, byBase), | 329 | toolId: uniqueToolId(toolName, byBase), |
| 74 | toolName, | 330 | toolName, |
| 331 | title, | ||
| 332 | displayTitle, | ||
| 75 | description: tool.description ?? null, | 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, | ||
| 76 | inputSchema: tool.inputSchema ?? tool.parameters, | 339 | inputSchema: tool.inputSchema ?? tool.parameters, |
| 77 | outputSchema: tool.outputSchema, | 340 | outputSchema: tool.outputSchema, |
| 78 | }; | 341 | }; |
| @@ -80,7 +343,13 @@ export const extractMcpToolManifestFromListToolsResult = ( · 1 unmodified line | |||
| 80 | .filter((tool): tool is McpToolManifestEntry => tool !== null); | 343 | .filter((tool): tool is McpToolManifestEntry => tool !== null); |
| 81 | 344 | ||
| 82 | return { | 345 | return { |
| 83 | version: 1, | 346 | version: 2, |
| 347 | server: normalizeServerMetadata(metadata), | ||
| 348 | listTools: { | ||
| 349 | nextCursor: asString(rawListTools.nextCursor), | ||
| 350 | meta: rawListTools._meta ?? null, | ||
| 351 | rawResult: listToolsResult, | ||
| 352 | }, | ||
| 84 | tools, | 353 | tools, |
| 85 | }; | 354 | }; |
| 86 | }; | 355 | }; |
| @@ -164,7 +164,7 @@ describe("codemode-mcp", () => { · 163 unmodified lines | |||
| 164 | ], | 164 | ], |
| 165 | }); | 165 | }); |
| 166 | 166 | ||
| 167 | expect(manifest.version).toBe(1); | 167 | expect(manifest.version).toBe(2); |
| 168 | expect(manifest.tools.map((tool) => tool.toolId)).toEqual([ | 168 | expect(manifest.tools.map((tool) => tool.toolId)).toEqual([ |
| 169 | "read_file", | 169 | "read_file", |
| 170 | "read_file_2", | 170 | "read_file_2", |
| @@ -174,6 +174,132 @@ describe("codemode-mcp", () => { · 3 unmodified lines | |||
| 174 | }), | 174 | }), |
| 175 | ); | 175 | ); |
| 176 | 176 | ||
| 177 | it.effect("preserves MCP annotations and server introspection metadata", () => | ||
| 178 | Effect.gen(function* () { | ||
| 179 | const manifest = extractMcpToolManifestFromListToolsResult( | ||
| 180 | { | ||
| 181 | tools: [ | ||
| 182 | { | ||
| 183 | name: "Read File", | ||
| 184 | title: "Read File", | ||
| 185 | description: "Read a file from memory", | ||
| 186 | inputSchema: { | ||
| 187 | type: "object", | ||
| 188 | properties: { | ||
| 189 | path: { | ||
| 190 | type: "string", | ||
| 191 | }, | ||
| 192 | }, | ||
| 193 | required: ["path"], | ||
| 194 | }, | ||
| 195 | outputSchema: { | ||
| 196 | type: "object", | ||
| 197 | properties: { | ||
| 198 | content: { | ||
| 199 | type: "string", | ||
| 200 | }, | ||
| 201 | }, | ||
| 202 | required: ["content"], | ||
| 203 | }, | ||
| 204 | annotations: { | ||
| 205 | title: "Read File (Annotated)", | ||
| 206 | readOnlyHint: true, | ||
| 207 | destructiveHint: false, | ||
| 208 | idempotentHint: true, | ||
| 209 | openWorldHint: false, | ||
| 210 | }, | ||
| 211 | execution: { | ||
| 212 | taskSupport: "optional", | ||
| 213 | }, | ||
| 214 | icons: [{ | ||
| 215 | src: "https://example.test/icon.png", | ||
| 216 | mimeType: "image/png", | ||
| 217 | }], | ||
| 218 | _meta: { | ||
| 219 | category: "filesystem", | ||
| 220 | }, | ||
| 221 | }, | ||
| 222 | ], | ||
| 223 | nextCursor: "cursor_2", | ||
| 224 | _meta: { | ||
| 225 | page: 1, | ||
| 226 | }, | ||
| 227 | }, | ||
| 228 | { | ||
| 229 | serverInfo: { | ||
| 230 | name: "mcp-test-server", | ||
| 231 | version: "1.2.3", | ||
| 232 | title: "Executor MCP", | ||
| 233 | description: "Test server", | ||
| 234 | websiteUrl: "https://example.test/mcp", | ||
| 235 | }, | ||
| 236 | serverCapabilities: { | ||
| 237 | tools: { | ||
| 238 | listChanged: true, | ||
| 239 | }, | ||
| 240 | tasks: { | ||
| 241 | list: {}, | ||
| 242 | requests: { | ||
| 243 | tools: { | ||
| 244 | call: {}, | ||
| 245 | }, | ||
| 246 | }, | ||
| 247 | }, | ||
| 248 | }, | ||
| 249 | instructions: "Use the tools carefully.", | ||
| 250 | }, | ||
| 251 | ); | ||
| 252 | |||
| 253 | expect(manifest.version).toBe(2); | ||
| 254 | expect(manifest.tools[0]).toMatchObject({ | ||
| 255 | toolId: "read_file", | ||
| 256 | toolName: "Read File", | ||
| 257 | title: "Read File", | ||
| 258 | displayTitle: "Read File", | ||
| 259 | annotations: { | ||
| 260 | title: "Read File (Annotated)", | ||
| 261 | readOnlyHint: true, | ||
| 262 | destructiveHint: false, | ||
| 263 | idempotentHint: true, | ||
| 264 | openWorldHint: false, | ||
| 265 | }, | ||
| 266 | execution: { | ||
| 267 | taskSupport: "optional", | ||
| 268 | }, | ||
| 269 | meta: { | ||
| 270 | category: "filesystem", | ||
| 271 | }, | ||
| 272 | }); | ||
| 273 | expect(manifest.tools[0]?.rawTool).toMatchObject({ | ||
| 274 | name: "Read File", | ||
| 275 | }); | ||
| 276 | expect(manifest.server).toMatchObject({ | ||
| 277 | info: { | ||
| 278 | name: "mcp-test-server", | ||
| 279 | version: "1.2.3", | ||
| 280 | title: "Executor MCP", | ||
| 281 | }, | ||
| 282 | capabilities: { | ||
| 283 | tools: { | ||
| 284 | listChanged: true, | ||
| 285 | }, | ||
| 286 | tasks: { | ||
| 287 | list: true, | ||
| 288 | cancel: false, | ||
| 289 | toolCall: true, | ||
| 290 | }, | ||
| 291 | }, | ||
| 292 | instructions: "Use the tools carefully.", | ||
| 293 | }); | ||
| 294 | expect(manifest.listTools).toMatchObject({ | ||
| 295 | nextCursor: "cursor_2", | ||
| 296 | meta: { | ||
| 297 | page: 1, | ||
| 298 | }, | ||
| 299 | }); | ||
| 300 | }), | ||
| 301 | ); | ||
| 302 | |||
| 177 | it.effect("discovers MCP tools and invokes via connector", () => | 303 | it.effect("discovers MCP tools and invokes via connector", () => |
| 178 | Effect.gen(function* () { | 304 | Effect.gen(function* () { |
| 179 | let connectCalls = 0; | 305 | let connectCalls = 0; |
| @@ -189,6 +315,7 @@ describe("codemode-mcp", () => { · 9 unmodified lines | |||
| 189 | tools: [ | 315 | tools: [ |
| 190 | { | 316 | { |
| 191 | name: "Echo", | 317 | name: "Echo", |
| 318 | title: "Echo", | ||
| 192 | description: "Echo payload", | 319 | description: "Echo payload", |
| 193 | inputSchema: { | 320 | inputSchema: { |
| 194 | type: "object", | 321 | type: "object", |
| @@ -196,6 +323,14 @@ describe("codemode-mcp", () => { · 1 unmodified line | |||
| 196 | value: { type: "string" }, | 323 | value: { type: "string" }, |
| 197 | }, | 324 | }, |
| 198 | }, | 325 | }, |
| 326 | annotations: { | ||
| 327 | readOnlyHint: true, | ||
| 328 | destructiveHint: false, | ||
| 329 | idempotentHint: true, | ||
| 330 | }, | ||
| 331 | _meta: { | ||
| 332 | category: "demo", | ||
| 333 | }, | ||
| 199 | }, | 334 | }, |
| 200 | ], | 335 | ], |
| 201 | }), | 336 | }), |
| @@ -210,6 +345,16 @@ describe("codemode-mcp", () => { · 8 unmodified lines | |||
| 210 | isError: false, | 345 | isError: false, |
| 211 | }; | 346 | }; |
| 212 | }, | 347 | }, |
| 348 | getServerVersion: () => ({ | ||
| 349 | name: "mcp-demo-server", | ||
| 350 | version: "1.0.0", | ||
| 351 | title: "Demo Server", | ||
| 352 | }), | ||
| 353 | getServerCapabilities: () => ({ | ||
| 354 | tools: { | ||
| 355 | listChanged: true, | ||
| 356 | }, | ||
| 357 | }), | ||
| 213 | }, | 358 | }, |
| 214 | close: async () => { | 359 | close: async () => { |
| 215 | closeCalls += 1; | 360 | closeCalls += 1; |
| @@ -226,6 +371,8 @@ describe("codemode-mcp", () => { · 10 unmodified lines | |||
| 226 | expect(connectCalls).toBe(1); | 371 | expect(connectCalls).toBe(1); |
| 227 | expect(closeCalls).toBe(1); | 372 | expect(closeCalls).toBe(1); |
| 228 | expect(discovered.manifest.tools).toHaveLength(1); | 373 | expect(discovered.manifest.tools).toHaveLength(1); |
| 374 | expect(discovered.manifest.server?.info?.name).toBe("mcp-demo-server"); | ||
| 375 | expect(discovered.manifest.tools[0]?.annotations?.readOnlyHint).toBe(true); | ||
| 229 | expect(Object.keys(discovered.tools)).toEqual(["source.mcp.echo"]); | 376 | expect(Object.keys(discovered.tools)).toEqual(["source.mcp.echo"]); |
| 230 | 377 | ||
| 231 | const toolDefinition = resolveToolDefinition(discovered.tools["source.mcp.echo"]!); | 378 | const toolDefinition = resolveToolDefinition(discovered.tools["source.mcp.echo"]!); |
| @@ -43,6 +43,9 @@ export type McpClientLike = { · 42 unmodified lines | |||
| 43 | name: string; | 43 | name: string; |
| 44 | arguments?: Record<string, unknown>; | 44 | arguments?: Record<string, unknown>; |
| 45 | }) => Promise<unknown>; | 45 | }) => Promise<unknown>; |
| 46 | getServerCapabilities?: () => unknown; | ||
| 47 | getServerVersion?: () => unknown; | ||
| 48 | getInstructions?: () => string | undefined; | ||
| 46 | }; | 49 | }; |
| 47 | 50 | ||
| 48 | export type McpConnection = { | 51 | export type McpConnection = { |
| @@ -538,13 +541,27 @@ export const discoverMcpToolsFromConnector = (input: { · 489 unmodified lines | |||
| 538 | mcpDiscoveryElicitation: input.mcpDiscoveryElicitation, | 541 | mcpDiscoveryElicitation: input.mcpDiscoveryElicitation, |
| 539 | }); | 542 | }); |
| 540 | 543 | ||
| 541 | return input.mcpDiscoveryElicitation | 544 | const settledListEffect = input.mcpDiscoveryElicitation |
| 542 | ? withElicitationClientLock(connection.client, listEffect) | 545 | ? withElicitationClientLock(connection.client, listEffect) |
| 543 | : listEffect; | 546 | : listEffect; |
| 547 | |||
| 548 | return Effect.map(settledListEffect, (listed) => ({ | ||
| 549 | listed, | ||
| 550 | serverInfo: connection.client.getServerVersion?.(), | ||
| 551 | serverCapabilities: connection.client.getServerCapabilities?.(), | ||
| 552 | instructions: connection.client.getInstructions?.(), | ||
| 553 | })); | ||
| 544 | }, | 554 | }, |
| 545 | }); | 555 | }); |
| 546 | 556 | ||
| 547 | const manifest = extractMcpToolManifestFromListToolsResult(listed); | 557 | const manifest = extractMcpToolManifestFromListToolsResult( |
| 558 | listed.listed, | ||
| 559 | { | ||
| 560 | serverInfo: listed.serverInfo, | ||
| 561 | serverCapabilities: listed.serverCapabilities, | ||
| 562 | instructions: listed.instructions, | ||
| 563 | }, | ||
| 564 | ); | ||
| 548 | 565 | ||
| 549 | return { | 566 | return { |
| 550 | manifest, | 567 | manifest, |
| @@ -47,9 +47,9 @@ struct OpenApiInvocationPayload { · 46 unmodified lines | |||
| 47 | #[serde(rename_all = "camelCase")] | 47 | #[serde(rename_all = "camelCase")] |
| 48 | struct DiscoveryTypingPayload { | 48 | struct DiscoveryTypingPayload { |
| 49 | #[serde(skip_serializing_if = "Option::is_none")] | 49 | #[serde(skip_serializing_if = "Option::is_none")] |
| 50 | input_schema_json: Option<String>, | 50 | input_schema: Option<Value>, |
| 51 | #[serde(skip_serializing_if = "Option::is_none")] | 51 | #[serde(skip_serializing_if = "Option::is_none")] |
| 52 | output_schema_json: Option<String>, | 52 | output_schema: Option<Value>, |
| 53 | #[serde(skip_serializing_if = "Option::is_none")] | 53 | #[serde(skip_serializing_if = "Option::is_none")] |
| 54 | ref_hint_keys: Option<Vec<String>>, | 54 | ref_hint_keys: Option<Vec<String>>, |
| 55 | } | 55 | } |
| @@ -690,14 +690,12 @@ fn build_tool_typing( · 634 unmodified lines | |||
| 690 | }; | 690 | }; |
| 691 | 691 | ||
| 692 | Ok(Some(DiscoveryTypingPayload { | 692 | Ok(Some(DiscoveryTypingPayload { |
| 693 | input_schema_json: input_schema | 693 | input_schema: input_schema |
| 694 | .as_ref() | 694 | .as_ref() |
| 695 | .map(encode_stable_json) | 695 | .map(to_stable_value), |
| 696 | .transpose()?, | 696 | output_schema: output_schema |
| 697 | output_schema_json: output_schema | ||
| 698 | .as_ref() | 697 | .as_ref() |
| 699 | .map(encode_stable_json) | 698 | .map(to_stable_value), |
| 700 | .transpose()?, | ||
| 701 | ref_hint_keys, | 699 | ref_hint_keys, |
| 702 | })) | 700 | })) |
| 703 | } | 701 | } |
| @@ -17,6 +17,16 @@ export { · 16 unmodified lines | |||
| 17 | openApiOutputTypeSignatureFromSchema, | 17 | openApiOutputTypeSignatureFromSchema, |
| 18 | type OpenApiToolPresentation, | 18 | type OpenApiToolPresentation, |
| 19 | } from "./openapi-tool-presentation"; | 19 | } from "./openapi-tool-presentation"; |
| 20 | export { | ||
| 21 | serializeOpenApiParameterValue, | ||
| 22 | serializeOpenApiRequestBody, | ||
| 23 | withSerializedQueryEntries, | ||
| 24 | type OpenApiSerializableContent, | ||
| 25 | type OpenApiSerializableParameter, | ||
| 26 | type OpenApiSerializableRequestBody, | ||
| 27 | type SerializedOpenApiParameterValue, | ||
| 28 | type SerializedOpenApiQueryEntry, | ||
| 29 | } from "./openapi-http-serialization"; | ||
| 20 | export { | 30 | export { |
| 21 | OpenApiToolInvocationError, | 31 | OpenApiToolInvocationError, |
| 22 | createOpenApiToolFromDefinition, | 32 | createOpenApiToolFromDefinition, |
| @@ -35,11 +45,19 @@ export { · 12 unmodified lines | |||
| 35 | OpenApiToolManifestSchema, | 45 | OpenApiToolManifestSchema, |
| 36 | OpenApiToolParameterSchema, | 46 | OpenApiToolParameterSchema, |
| 37 | OpenApiToolRequestBodySchema, | 47 | OpenApiToolRequestBodySchema, |
| 48 | OpenApiMediaContentSchema, | ||
| 49 | OpenApiHeaderSchema, | ||
| 50 | OpenApiServerSchema, | ||
| 38 | OpenApiExampleSchema, | 51 | OpenApiExampleSchema, |
| 52 | OpenApiResponseVariantSchema, | ||
| 39 | OpenApiParameterDocumentationSchema, | 53 | OpenApiParameterDocumentationSchema, |
| 40 | OpenApiRequestBodyDocumentationSchema, | 54 | OpenApiRequestBodyDocumentationSchema, |
| 41 | OpenApiResponseDocumentationSchema, | 55 | OpenApiResponseDocumentationSchema, |
| 42 | OpenApiToolDocumentationSchema, | 56 | OpenApiToolDocumentationSchema, |
| 57 | OpenApiSecurityRequirementSchema, | ||
| 58 | OpenApiSecuritySchemeTypeSchema, | ||
| 59 | OpenApiOAuthFlowSchema, | ||
| 60 | OpenApiSecuritySchemeSchema, | ||
| 43 | OpenApiToolProviderDataSchema, | 61 | OpenApiToolProviderDataSchema, |
| 44 | OpenApiRefHintTableSchema, | 62 | OpenApiRefHintTableSchema, |
| 45 | OpenApiRefHintValueSchema, | 63 | OpenApiRefHintValueSchema, |
| @@ -58,6 +76,14 @@ export { · 12 unmodified lines | |||
| 58 | type OpenApiRefHintValue, | 76 | type OpenApiRefHintValue, |
| 59 | type OpenApiToolParameter, | 77 | type OpenApiToolParameter, |
| 60 | type OpenApiToolRequestBody, | 78 | type OpenApiToolRequestBody, |
| 79 | type OpenApiMediaContent, | ||
| 80 | type OpenApiHeader, | ||
| 81 | type OpenApiServer, | ||
| 82 | type OpenApiResponseVariant, | ||
| 83 | type OpenApiSecurityRequirement, | ||
| 84 | type OpenApiSecuritySchemeType, | ||
| 85 | type OpenApiOAuthFlow, | ||
| 86 | type OpenApiSecurityScheme, | ||
| 61 | } from "./openapi-types"; | 87 | } from "./openapi-types"; |
| 62 | export type { | 88 | export type { |
| 63 | OpenApiExample, | 89 | OpenApiExample, |
| @@ -4,6 +4,10 @@ import { · 3 unmodified lines | |||
| 4 | DiscoveryTypingPayloadSchema, | 4 | DiscoveryTypingPayloadSchema, |
| 5 | OpenApiHttpMethodSchema, | 5 | OpenApiHttpMethodSchema, |
| 6 | OpenApiInvocationPayloadSchema, | 6 | OpenApiInvocationPayloadSchema, |
| 7 | OpenApiResponseVariantSchema, | ||
| 8 | OpenApiSecurityRequirementSchema, | ||
| 9 | OpenApiSecuritySchemeSchema, | ||
| 10 | OpenApiServerSchema, | ||
| 7 | OpenApiToolDocumentationSchema, | 11 | OpenApiToolDocumentationSchema, |
| 8 | OpenApiToolProviderDataSchema, | 12 | OpenApiToolProviderDataSchema, |
| 9 | type OpenApiExtractedTool, | 13 | type OpenApiExtractedTool, |
| @@ -143,6 +147,11 @@ export const OpenApiToolDefinitionSchema = Schema.Struct({ · 133 unmodified lines | |||
| 143 | operationHash: Schema.String, | 147 | operationHash: Schema.String, |
| 144 | typing: Schema.optional(DiscoveryTypingPayloadSchema), | 148 | typing: Schema.optional(DiscoveryTypingPayloadSchema), |
| 145 | documentation: Schema.optional(OpenApiToolDocumentationSchema), | 149 | documentation: Schema.optional(OpenApiToolDocumentationSchema), |
| 150 | responses: Schema.optional(Schema.Array(OpenApiResponseVariantSchema)), | ||
| 151 | authRequirement: Schema.optional(OpenApiSecurityRequirementSchema), | ||
| 152 | securitySchemes: Schema.optional(Schema.Array(OpenApiSecuritySchemeSchema)), | ||
| 153 | documentServers: Schema.optional(Schema.Array(OpenApiServerSchema)), | ||
| 154 | servers: Schema.optional(Schema.Array(OpenApiServerSchema)), | ||
| 146 | }); | 155 | }); |
| 147 | 156 | ||
| 148 | export type OpenApiToolDefinition = typeof OpenApiToolDefinitionSchema.Type; | 157 | export type OpenApiToolDefinition = typeof OpenApiToolDefinitionSchema.Type; |
| @@ -226,6 +235,11 @@ export const compileOpenApiToolDefinitions = ( · 77 unmodified lines | |||
| 226 | operationHash: tool.operationHash, | 235 | operationHash: tool.operationHash, |
| 227 | typing: tool.typing, | 236 | typing: tool.typing, |
| 228 | documentation: tool.documentation, | 237 | documentation: tool.documentation, |
| 238 | responses: tool.responses, | ||
| 239 | authRequirement: tool.authRequirement, | ||
| 240 | securitySchemes: tool.securitySchemes, | ||
| 241 | documentServers: tool.documentServers, | ||
| 242 | servers: tool.servers, | ||
| 229 | } satisfies Omit<OpenApiToolDefinition, "toolId">; | 243 | } satisfies Omit<OpenApiToolDefinition, "toolId">; |
| 230 | }); | 244 | }); |
| 231 | 245 | ||
| @@ -252,4 +266,9 @@ export const openApiProviderDataFromDefinition = ( · 20 unmodified lines | |||
| 252 | operationHash: definition.operationHash, | 266 | operationHash: definition.operationHash, |
| 253 | invocation: definition.invocation, | 267 | invocation: definition.invocation, |
| 254 | documentation: definition.documentation, | 268 | documentation: definition.documentation, |
| 269 | responses: definition.responses, | ||
| 270 | authRequirement: definition.authRequirement, | ||
| 271 | securitySchemes: definition.securitySchemes, | ||
| 272 | documentServers: definition.documentServers, | ||
| 273 | servers: definition.servers, | ||
| 255 | } satisfies typeof OpenApiToolProviderDataSchema.Type); | 274 | } satisfies typeof OpenApiToolProviderDataSchema.Type); |
| @@ -9,6 +9,9 @@ import { extractOpenApiManifestJsonWithWasm } from "./openapi-extractor-wasm"; · 8 unmodified lines | |||
| 9 | import { | 9 | import { |
| 10 | OpenApiToolManifestSchema, | 10 | OpenApiToolManifestSchema, |
| 11 | type OpenApiExtractedTool, | 11 | type OpenApiExtractedTool, |
| 12 | type OpenApiHeader, | ||
| 13 | type OpenApiSecurityRequirement, | ||
| 14 | type OpenApiServer, | ||
| 12 | type OpenApiJsonObject, | 15 | type OpenApiJsonObject, |
| 13 | type OpenApiSpecInput, | 16 | type OpenApiSpecInput, |
| 14 | type OpenApiToolManifest, | 17 | type OpenApiToolManifest, |
| @@ -67,6 +70,58 @@ const asObject = (value: unknown): Record<string, unknown> => · 52 unmodified lines | |||
| 67 | ? (value as Record<string, unknown>) | 70 | ? (value as Record<string, unknown>) |
| 68 | : {}; | 71 | : {}; |
| 69 | 72 | ||
| 73 | const asArray = (value: unknown): ReadonlyArray<unknown> => | ||
| 74 | Array.isArray(value) ? value : []; | ||
| 75 | |||
| 76 | const asTrimmedString = (value: unknown): string | undefined => | ||
| 77 | typeof value === "string" && value.trim().length > 0 | ||
| 78 | ? value.trim() | ||
| 79 | : undefined; | ||
| 80 | |||
| 81 | const stableJsonValue = (value: unknown): unknown => { | ||
| 82 | if (Array.isArray(value)) { | ||
| 83 | return value.map((entry) => stableJsonValue(entry)); | ||
| 84 | } | ||
| 85 | |||
| 86 | if (value !== null && typeof value === "object") { | ||
| 87 | return Object.fromEntries( | ||
| 88 | Object.entries(value as Record<string, unknown>) | ||
| 89 | .sort(([left], [right]) => left.localeCompare(right)) | ||
| 90 | .map(([key, entry]) => [key, stableJsonValue(entry)]), | ||
| 91 | ); | ||
| 92 | } | ||
| 93 | |||
| 94 | return value; | ||
| 95 | }; | ||
| 96 | |||
| 97 | const stableJsonStringify = (value: unknown): string => | ||
| 98 | JSON.stringify(stableJsonValue(value)); | ||
| 99 | |||
| 100 | const collectRefKeys = ( | ||
| 101 | value: unknown, | ||
| 102 | refs: Set<string>, | ||
| 103 | ): void => { | ||
| 104 | if (Array.isArray(value)) { | ||
| 105 | for (const item of value) { | ||
| 106 | collectRefKeys(item, refs); | ||
| 107 | } | ||
| 108 | return; | ||
| 109 | } | ||
| 110 | |||
| 111 | if (value === null || typeof value !== "object") { | ||
| 112 | return; | ||
| 113 | } | ||
| 114 | |||
| 115 | const record = value as Record<string, unknown>; | ||
| 116 | if (typeof record.$ref === "string" && record.$ref.startsWith("#/")) { | ||
| 117 | refs.add(record.$ref); | ||
| 118 | } | ||
| 119 | |||
| 120 | for (const nested of Object.values(record)) { | ||
| 121 | collectRefKeys(nested, refs); | ||
| 122 | } | ||
| 123 | }; | ||
| 124 | |||
| 70 | const resolvePointerSegment = (segment: string): string => | 125 | const resolvePointerSegment = (segment: string): string => |
| 71 | segment.replaceAll("~1", "/").replaceAll("~0", "~"); | 126 | segment.replaceAll("~1", "/").replaceAll("~0", "~"); |
| 72 | 127 | ||
| @@ -107,24 +162,264 @@ const resolveLocalRef = ( · 34 unmodified lines | |||
| 107 | : resolvedObject; | 162 | : resolvedObject; |
| 108 | }; | 163 | }; |
| 109 | 164 | ||
| 165 | const responseStatusRank = (statusCode: string): number => { | ||
| 166 | if (/^2\d\d$/.test(statusCode)) { | ||
| 167 | return 0; | ||
| 168 | } | ||
| 169 | |||
| 170 | if (statusCode === "default") { | ||
| 171 | return 1; | ||
| 172 | } | ||
| 173 | |||
| 174 | return 2; | ||
| 175 | }; | ||
| 176 | |||
| 177 | const preferredContentEntry = ( | ||
| 178 | content: unknown, | ||
| 179 | ): readonly [string, Record<string, unknown>] | undefined => { | ||
| 180 | const entries = Object.entries(asObject(content)) | ||
| 181 | .sort(([left], [right]) => left.localeCompare(right)) | ||
| 182 | .map(([mediaType, value]) => [mediaType, asObject(value)] as const); | ||
| 183 | |||
| 184 | return entries.find(([mediaType]) => mediaType === "application/json") | ||
| 185 | ?? entries.find(([mediaType]) => mediaType.toLowerCase().includes("+json")) | ||
| 186 | ?? entries.find(([mediaType]) => mediaType.toLowerCase().includes("json")) | ||
| 187 | ?? entries[0]; | ||
| 188 | }; | ||
| 189 | |||
| 110 | const contentSchemaFromOperationContent = ( | 190 | const contentSchemaFromOperationContent = ( |
| 111 | content: unknown, | 191 | content: unknown, |
| 112 | ): unknown | undefined => { | 192 | ): unknown | undefined => { |
| 113 | const entries = Object.entries(asObject(content)); | 193 | return preferredContentEntry(content)?.[1].schema; |
| 114 | const preferredEntry = entries.find(([mediaType]) => mediaType === "application/json") | 194 | }; |
| 115 | ?? entries.find(([mediaType]) => mediaType.toLowerCase().includes("+json")) | 195 | |
| 116 | ?? entries.find(([mediaType]) => mediaType.toLowerCase().includes("json")); | 196 | const contentEntriesFromContent = ( |
| 197 | document: OpenApiJsonObject, | ||
| 198 | content: unknown, | ||
| 199 | ): ReadonlyArray<{ | ||
| 200 | mediaType: string; | ||
| 201 | schema?: unknown; | ||
| 202 | examples?: Array<{ | ||
| 203 | valueJson: string; | ||
| 204 | mediaType?: string; | ||
| 205 | label?: string; | ||
| 206 | }>; | ||
| 207 | }> => { | ||
| 208 | const entries = Object.entries(asObject(content)) | ||
| 209 | .sort(([left], [right]) => left.localeCompare(right)) | ||
| 210 | .map(([mediaType, mediaValue]) => { | ||
| 211 | const mediaRecord = asObject(resolveLocalRef(document, mediaValue)); | ||
| 212 | const examples = examplesFromMediaType(mediaType, mediaRecord); | ||
| 213 | |||
| 214 | return { | ||
| 215 | mediaType, | ||
| 216 | ...(mediaRecord.schema !== undefined ? { schema: mediaRecord.schema } : {}), | ||
| 217 | ...(examples.length > 0 ? { examples } : {}), | ||
| 218 | }; | ||
| 219 | }); | ||
| 117 | 220 | ||
| 118 | return preferredEntry ? asObject(preferredEntry[1]).schema : undefined; | 221 | return entries; |
| 119 | }; | 222 | }; |
| 120 | 223 | ||
| 121 | const requestBodySchemaForTool = ( | 224 | const examplesFromValue = ( |
| 225 | value: unknown, | ||
| 226 | input: { | ||
| 227 | label?: string; | ||
| 228 | mediaType?: string; | ||
| 229 | } = {}, | ||
| 230 | ): Array<{ | ||
| 231 | valueJson: string; | ||
| 232 | mediaType?: string; | ||
| 233 | label?: string; | ||
| 234 | }> => { | ||
| 235 | const record = asObject(value); | ||
| 236 | const examples: Array<{ | ||
| 237 | valueJson: string; | ||
| 238 | mediaType?: string; | ||
| 239 | label?: string; | ||
| 240 | }> = []; | ||
| 241 | |||
| 242 | if (record.example !== undefined) { | ||
| 243 | examples.push({ | ||
| 244 | valueJson: stableJsonStringify(record.example), | ||
| 245 | ...(input.mediaType ? { mediaType: input.mediaType } : {}), | ||
| 246 | ...(input.label ? { label: input.label } : {}), | ||
| 247 | }); | ||
| 248 | } | ||
| 249 | |||
| 250 | const exampleEntries = Object.entries(asObject(record.examples)) | ||
| 251 | .sort(([left], [right]) => left.localeCompare(right)); | ||
| 252 | for (const [key, exampleValue] of exampleEntries) { | ||
| 253 | const exampleRecord = asObject(exampleValue); | ||
| 254 | examples.push({ | ||
| 255 | valueJson: stableJsonStringify( | ||
| 256 | exampleRecord.value !== undefined ? exampleRecord.value : exampleValue, | ||
| 257 | ), | ||
| 258 | ...(input.mediaType ? { mediaType: input.mediaType } : {}), | ||
| 259 | label: key, | ||
| 260 | }); | ||
| 261 | } | ||
| 262 | |||
| 263 | return examples; | ||
| 264 | }; | ||
| 265 | |||
| 266 | const examplesFromSchema = ( | ||
| 267 | schema: unknown, | ||
| 268 | ): Array<{ | ||
| 269 | valueJson: string; | ||
| 270 | mediaType?: string; | ||
| 271 | label?: string; | ||
| 272 | }> => examplesFromValue(schema); | ||
| 273 | |||
| 274 | const examplesFromMediaType = ( | ||
| 275 | mediaType: string, | ||
| 276 | mediaTypeRecord: Record<string, unknown>, | ||
| 277 | ): Array<{ | ||
| 278 | valueJson: string; | ||
| 279 | mediaType?: string; | ||
| 280 | label?: string; | ||
| 281 | }> => { | ||
| 282 | const direct = examplesFromValue(mediaTypeRecord, { mediaType }); | ||
| 283 | if (direct.length > 0) { | ||
| 284 | return direct; | ||
| 285 | } | ||
| 286 | |||
| 287 | return examplesFromSchema(mediaTypeRecord.schema).map((example) => ({ | ||
| 288 | ...example, | ||
| 289 | mediaType, | ||
| 290 | })); | ||
| 291 | }; | ||
| 292 | |||
| 293 | const headerFromValue = ( | ||
| 294 | document: OpenApiJsonObject, | ||
| 295 | name: string, | ||
| 296 | value: unknown, | ||
| 297 | ): OpenApiHeader | undefined => { | ||
| 298 | const header = asObject(resolveLocalRef(document, value)); | ||
| 299 | if (Object.keys(header).length === 0) { | ||
| 300 | return undefined; | ||
| 301 | } | ||
| 302 | |||
| 303 | const content = contentEntriesFromContent(document, header.content); | ||
| 304 | const directExamples = | ||
| 305 | content.length > 0 | ||
| 306 | ? [] | ||
| 307 | : examplesFromValue(header); | ||
| 308 | |||
| 309 | return { | ||
| 310 | name, | ||
| 311 | ...(asTrimmedString(header.description) | ||
| 312 | ? { description: asTrimmedString(header.description) } | ||
| 313 | : {}), | ||
| 314 | ...(typeof header.required === "boolean" ? { required: header.required } : {}), | ||
| 315 | ...(typeof header.deprecated === "boolean" ? { deprecated: header.deprecated } : {}), | ||
| 316 | ...(header.schema !== undefined ? { schema: header.schema } : {}), | ||
| 317 | ...(content.length > 0 ? { content } : {}), | ||
| 318 | ...(asTrimmedString(header.style) | ||
| 319 | ? { style: asTrimmedString(header.style) } | ||
| 320 | : {}), | ||
| 321 | ...(typeof header.explode === "boolean" ? { explode: header.explode } : {}), | ||
| 322 | ...(directExamples.length > 0 ? { examples: directExamples } : {}), | ||
| 323 | }; | ||
| 324 | }; | ||
| 325 | |||
| 326 | const headersFromValue = ( | ||
| 327 | document: OpenApiJsonObject, | ||
| 328 | value: unknown, | ||
| 329 | ): ReadonlyArray<OpenApiHeader> => Object.entries(asObject(value)) | ||
| 330 | .sort(([left], [right]) => left.localeCompare(right)) | ||
| 331 | .flatMap(([name, headerValue]) => { | ||
| 332 | const header = headerFromValue(document, name, headerValue); | ||
| 333 | return header ? [header] : []; | ||
| 334 | }); | ||
| 335 | |||
| 336 | const serversFromValue = ( | ||
| 337 | value: unknown, | ||
| 338 | ): ReadonlyArray<OpenApiServer> => | ||
| 339 | asArray(value) | ||
| 340 | .map((entry) => asObject(entry)) | ||
| 341 | .flatMap((server) => { | ||
| 342 | const url = asTrimmedString(server.url); | ||
| 343 | if (!url) { | ||
| 344 | return []; | ||
| 345 | } | ||
| 346 | |||
| 347 | const variables = Object.fromEntries( | ||
| 348 | Object.entries(asObject(server.variables)) | ||
| 349 | .sort(([left], [right]) => left.localeCompare(right)) | ||
| 350 | .flatMap(([name, variableValue]) => { | ||
| 351 | const variableRecord = asObject(variableValue); | ||
| 352 | const defaultValue = asTrimmedString(variableRecord.default); | ||
| 353 | return defaultValue ? [[name, defaultValue] as const] : []; | ||
| 354 | }), | ||
| 355 | ); | ||
| 356 | |||
| 357 | return [{ | ||
| 358 | url, | ||
| 359 | ...(asTrimmedString(server.description) | ||
| 360 | ? { description: asTrimmedString(server.description) } | ||
| 361 | : {}), | ||
| 362 | ...(Object.keys(variables).length > 0 ? { variables } : {}), | ||
| 363 | }]; | ||
| 364 | }); | ||
| 365 | |||
| 366 | const pathItemForTool = ( | ||
| 122 | document: OpenApiJsonObject, | 367 | document: OpenApiJsonObject, |
| 123 | tool: OpenApiExtractedTool, | 368 | tool: OpenApiExtractedTool, |
| 124 | ): unknown | undefined => { | 369 | ): Record<string, unknown> => |
| 125 | const operation = asObject( | 370 | asObject(asObject(document.paths)[tool.path]); |
| 371 | |||
| 372 | const parameterKey = (input: { | ||
| 373 | location: string; | ||
| 374 | name: string; | ||
| 375 | }): string => `${input.location}:${input.name}`; | ||
| 376 | |||
| 377 | const parameterRecordMapForTool = ( | ||
| 378 | document: OpenApiJsonObject, | ||
| 379 | tool: OpenApiExtractedTool, | ||
| 380 | ): ReadonlyMap<string, Record<string, unknown>> => { | ||
| 381 | const merged = new Map<string, Record<string, unknown>>(); | ||
| 382 | const pathItem = pathItemForTool(document, tool); | ||
| 383 | const operation = operationForTool(document, tool); | ||
| 384 | |||
| 385 | for (const parameterValue of asArray(pathItem.parameters)) { | ||
| 386 | const parameter = asObject(resolveLocalRef(document, parameterValue)); | ||
| 387 | const name = asTrimmedString(parameter.name); | ||
| 388 | const location = asTrimmedString(parameter.in); | ||
| 389 | if (!name || !location) { | ||
| 390 | continue; | ||
| 391 | } | ||
| 392 | |||
| 393 | merged.set(parameterKey({ location, name }), parameter); | ||
| 394 | } | ||
| 395 | |||
| 396 | for (const parameterValue of asArray(operation.parameters)) { | ||
| 397 | const parameter = asObject(resolveLocalRef(document, parameterValue)); | ||
| 398 | const name = asTrimmedString(parameter.name); | ||
| 399 | const location = asTrimmedString(parameter.in); | ||
| 400 | if (!name || !location) { | ||
| 401 | continue; | ||
| 402 | } | ||
| 403 | |||
| 404 | merged.set(parameterKey({ location, name }), parameter); | ||
| 405 | } | ||
| 406 | |||
| 407 | return merged; | ||
| 408 | }; | ||
| 409 | |||
| 410 | const operationForTool = ( | ||
| 411 | document: OpenApiJsonObject, | ||
| 412 | tool: OpenApiExtractedTool, | ||
| 413 | ): Record<string, unknown> => | ||
| 414 | asObject( | ||
| 126 | asObject(asObject(document.paths)[tool.path])[tool.method], | 415 | asObject(asObject(document.paths)[tool.path])[tool.method], |
| 127 | ); | 416 | ); |
| 417 | |||
| 418 | const requestBodySchemaForTool = ( | ||
| 419 | document: OpenApiJsonObject, | ||
| 420 | tool: OpenApiExtractedTool, | ||
| 421 | ): unknown | undefined => { | ||
| 422 | const operation = operationForTool(document, tool); | ||
| 128 | if (Object.keys(operation).length === 0) { | 423 | if (Object.keys(operation).length === 0) { |
| 129 | return undefined; | 424 | return undefined; |
| 130 | } | 425 | } |
| @@ -133,13 +428,38 @@ const requestBodySchemaForTool = ( · 2 unmodified lines | |||
| 133 | return contentSchemaFromOperationContent(asObject(requestBody).content); | 428 | return contentSchemaFromOperationContent(asObject(requestBody).content); |
| 134 | }; | 429 | }; |
| 135 | 430 | ||
| 431 | const requestBodyPayloadForTool = ( | ||
| 432 | document: OpenApiJsonObject, | ||
| 433 | tool: OpenApiExtractedTool, | ||
| 434 | ): OpenApiExtractedTool["invocation"]["requestBody"] => { | ||
| 435 | const operation = operationForTool(document, tool); | ||
| 436 | if (Object.keys(operation).length === 0) { | ||
| 437 | return tool.invocation.requestBody; | ||
| 438 | } | ||
| 439 | |||
| 440 | const requestBody = asObject(resolveLocalRef(document, operation.requestBody)); | ||
| 441 | if (Object.keys(requestBody).length === 0) { | ||
| 442 | return tool.invocation.requestBody; | ||
| 443 | } | ||
| 444 | |||
| 445 | const contents = contentEntriesFromContent(document, requestBody.content); | ||
| 446 | const contentTypes = contents.map((content) => content.mediaType); | ||
| 447 | |||
| 448 | return { | ||
| 449 | required: | ||
| 450 | typeof requestBody.required === "boolean" | ||
| 451 | ? requestBody.required | ||
| 452 | : (tool.invocation.requestBody?.required ?? false), | ||
| 453 | contentTypes, | ||
| 454 | ...(contents.length > 0 ? { contents } : {}), | ||
| 455 | }; | ||
| 456 | }; | ||
| 457 | |||
| 136 | const responseSchemaForTool = ( | 458 | const responseSchemaForTool = ( |
| 137 | document: OpenApiJsonObject, | 459 | document: OpenApiJsonObject, |
| 138 | tool: OpenApiExtractedTool, | 460 | tool: OpenApiExtractedTool, |
| 139 | ): unknown | undefined => { | 461 | ): unknown | undefined => { |
| 140 | const operation = asObject( | 462 | const operation = operationForTool(document, tool); |
| 141 | asObject(asObject(document.paths)[tool.path])[tool.method], | ||
| 142 | ); | ||
| 143 | if (Object.keys(operation).length === 0) { | 463 | if (Object.keys(operation).length === 0) { |
| 144 | return undefined; | 464 | return undefined; |
| 145 | } | 465 | } |
| @@ -161,29 +481,388 @@ const responseSchemaForTool = ( · 15 unmodified lines | |||
| 161 | return undefined; | 481 | return undefined; |
| 162 | }; | 482 | }; |
| 163 | 483 | ||
| 484 | const responseVariantsForTool = ( | ||
| 485 | document: OpenApiJsonObject, | ||
| 486 | tool: OpenApiExtractedTool, | ||
| 487 | ): OpenApiExtractedTool["responses"] => { | ||
| 488 | const operation = operationForTool(document, tool); | ||
| 489 | if (Object.keys(operation).length === 0) { | ||
| 490 | return undefined; | ||
| 491 | } | ||
| 492 | |||
| 493 | const responseEntries = Object.entries(asObject(operation.responses)) | ||
| 494 | .sort(([left], [right]) => | ||
| 495 | responseStatusRank(left) - responseStatusRank(right) || left.localeCompare(right), | ||
| 496 | ); | ||
| 497 | |||
| 498 | const responses = responseEntries.map(([statusCode, responseValue]) => { | ||
| 499 | const response = asObject(resolveLocalRef(document, responseValue)); | ||
| 500 | const contents = contentEntriesFromContent(document, response.content); | ||
| 501 | const contentTypes = contents.map((content) => content.mediaType); | ||
| 502 | const preferredContent = preferredContentEntry(response.content); | ||
| 503 | const examples = preferredContent | ||
| 504 | ? examplesFromMediaType(preferredContent[0], preferredContent[1]) | ||
| 505 | : []; | ||
| 506 | const headers = headersFromValue(document, response.headers); | ||
| 507 | |||
| 508 | return { | ||
| 509 | statusCode, | ||
| 510 | ...(asTrimmedString(response.description) | ||
| 511 | ? { description: asTrimmedString(response.description) } | ||
| 512 | : {}), | ||
| 513 | contentTypes, | ||
| 514 | ...(contentSchemaFromOperationContent(response.content) !== undefined | ||
| 515 | ? { schema: contentSchemaFromOperationContent(response.content) } | ||
| 516 | : {}), | ||
| 517 | ...(examples.length > 0 ? { examples } : {}), | ||
| 518 | ...(contents.length > 0 ? { contents } : {}), | ||
| 519 | ...(headers.length > 0 ? { headers } : {}), | ||
| 520 | }; | ||
| 521 | }); | ||
| 522 | |||
| 523 | return responses.length > 0 ? responses : undefined; | ||
| 524 | }; | ||
| 525 | |||
| 526 | const documentServersFromDocument = ( | ||
| 527 | document: OpenApiJsonObject, | ||
| 528 | ): OpenApiExtractedTool["documentServers"] => { | ||
| 529 | const servers = serversFromValue(document.servers); | ||
| 530 | return servers.length > 0 ? servers : undefined; | ||
| 531 | }; | ||
| 532 | |||
| 533 | const operationServersForTool = ( | ||
| 534 | document: OpenApiJsonObject, | ||
| 535 | tool: OpenApiExtractedTool, | ||
| 536 | ): OpenApiExtractedTool["servers"] => { | ||
| 537 | const operation = operationForTool(document, tool); | ||
| 538 | if (Object.keys(operation).length === 0) { | ||
| 539 | return undefined; | ||
| 540 | } | ||
| 541 | |||
| 542 | const operationServers = serversFromValue(operation.servers); | ||
| 543 | if (operationServers.length > 0) { | ||
| 544 | return operationServers; | ||
| 545 | } | ||
| 546 | |||
| 547 | const pathItemServers = serversFromValue(pathItemForTool(document, tool).servers); | ||
| 548 | return pathItemServers.length > 0 ? pathItemServers : undefined; | ||
| 549 | }; | ||
| 550 | |||
| 551 | const securityRequirementFromValue = ( | ||
| 552 | value: unknown, | ||
| 553 | ): OpenApiSecurityRequirement | undefined => { | ||
| 554 | const requirementEntries = asArray(value); | ||
| 555 | if (requirementEntries.length === 0) { | ||
| 556 | return { | ||
| 557 | kind: "none", | ||
| 558 | }; | ||
| 559 | } | ||
| 560 | |||
| 561 | const anyOfItems = requirementEntries.flatMap((entry) => { | ||
| 562 | const schemes = Object.entries(asObject(entry)) | ||
| 563 | .sort(([left], [right]) => left.localeCompare(right)) | ||
| 564 | .map(([schemeName, rawScopes]) => { | ||
| 565 | const scopes = asArray(rawScopes) | ||
| 566 | .flatMap((scope) => | ||
| 567 | typeof scope === "string" && scope.trim().length > 0 | ||
| 568 | ? [scope.trim()] | ||
| 569 | : [], | ||
| 570 | ); | ||
| 571 | |||
| 572 | return { | ||
| 573 | kind: "scheme" as const, | ||
| 574 | schemeName, | ||
| 575 | ...(scopes.length > 0 ? { scopes } : {}), | ||
| 576 | }; | ||
| 577 | }); | ||
| 578 | |||
| 579 | if (schemes.length === 0) { | ||
| 580 | return []; | ||
| 581 | } | ||
| 582 | |||
| 583 | return [ | ||
| 584 | schemes.length === 1 | ||
| 585 | ? schemes[0]! | ||
| 586 | : { | ||
| 587 | kind: "allOf" as const, | ||
| 588 | items: schemes, | ||
| 589 | }, | ||
| 590 | ]; | ||
| 591 | }); | ||
| 592 | |||
| 593 | if (anyOfItems.length === 0) { | ||
| 594 | return undefined; | ||
| 595 | } | ||
| 596 | |||
| 597 | return anyOfItems.length === 1 | ||
| 598 | ? anyOfItems[0] | ||
| 599 | : { | ||
| 600 | kind: "anyOf", | ||
| 601 | items: anyOfItems, | ||
| 602 | }; | ||
| 603 | }; | ||
| 604 | |||
| 605 | const authRequirementForTool = ( | ||
| 606 | document: OpenApiJsonObject, | ||
| 607 | tool: OpenApiExtractedTool, | ||
| 608 | ): OpenApiSecurityRequirement | undefined => { | ||
| 609 | const operation = operationForTool(document, tool); | ||
| 610 | if (Object.keys(operation).length === 0) { | ||
| 611 | return undefined; | ||
| 612 | } | ||
| 613 | |||
| 614 | if (Object.prototype.hasOwnProperty.call(operation, "security")) { | ||
| 615 | return securityRequirementFromValue(operation.security); | ||
| 616 | } | ||
| 617 | |||
| 618 | return securityRequirementFromValue(document.security); | ||
| 619 | }; | ||
| 620 | |||
| 621 | const collectReferencedSecuritySchemeNames = ( | ||
| 622 | authRequirement: OpenApiSecurityRequirement | undefined, | ||
| 623 | names: Set<string>, | ||
| 624 | ): void => { | ||
| 625 | if (!authRequirement) { | ||
| 626 | return; | ||
| 627 | } | ||
| 628 | |||
| 629 | switch (authRequirement.kind) { | ||
| 630 | case "none": | ||
| 631 | return; | ||
| 632 | case "scheme": | ||
| 633 | names.add(authRequirement.schemeName); | ||
| 634 | return; | ||
| 635 | case "allOf": | ||
| 636 | case "anyOf": | ||
| 637 | for (const item of authRequirement.items) { | ||
| 638 | collectReferencedSecuritySchemeNames(item, names); | ||
| 639 | } | ||
| 640 | } | ||
| 641 | }; | ||
| 642 | |||
| 643 | const oauthFlowRecord = ( | ||
| 644 | value: unknown, | ||
| 645 | ): Record<string, { | ||
| 646 | authorizationUrl?: string; | ||
| 647 | tokenUrl?: string; | ||
| 648 | refreshUrl?: string; | ||
| 649 | scopes?: Record<string, string>; | ||
| 650 | }> | undefined => { | ||
| 651 | const result = Object.fromEntries( | ||
| 652 | Object.entries(asObject(value)) | ||
| 653 | .sort(([left], [right]) => left.localeCompare(right)) | ||
| 654 | .map(([flowName, flowValue]) => { | ||
| 655 | const flowRecord = asObject(flowValue); | ||
| 656 | const scopes = Object.fromEntries( | ||
| 657 | Object.entries(asObject(flowRecord.scopes)) | ||
| 658 | .sort(([left], [right]) => left.localeCompare(right)) | ||
| 659 | .map(([scope, description]) => [scope, asTrimmedString(description) ?? ""]), | ||
| 660 | ); | ||
| 661 | |||
| 662 | return [flowName, { | ||
| 663 | ...(asTrimmedString(flowRecord.authorizationUrl) | ||
| 664 | ? { authorizationUrl: asTrimmedString(flowRecord.authorizationUrl) } | ||
| 665 | : {}), | ||
| 666 | ...(asTrimmedString(flowRecord.tokenUrl) | ||
| 667 | ? { tokenUrl: asTrimmedString(flowRecord.tokenUrl) } | ||
| 668 | : {}), | ||
| 669 | ...(asTrimmedString(flowRecord.refreshUrl) | ||
| 670 | ? { refreshUrl: asTrimmedString(flowRecord.refreshUrl) } | ||
| 671 | : {}), | ||
| 672 | ...(Object.keys(scopes).length > 0 ? { scopes } : {}), | ||
| 673 | }]; | ||
| 674 | }), | ||
| 675 | ); | ||
| 676 | |||
| 677 | return Object.keys(result).length > 0 ? result : undefined; | ||
| 678 | }; | ||
| 679 | |||
| 680 | const securitySchemesForTool = ( | ||
| 681 | document: OpenApiJsonObject, | ||
| 682 | authRequirement: OpenApiSecurityRequirement | undefined, | ||
| 683 | ): OpenApiExtractedTool["securitySchemes"] => { | ||
| 684 | if (!authRequirement || authRequirement.kind === "none") { | ||
| 685 | return undefined; | ||
| 686 | } | ||
| 687 | |||
| 688 | const schemeNames = new Set<string>(); | ||
| 689 | collectReferencedSecuritySchemeNames(authRequirement, schemeNames); | ||
| 690 | |||
| 691 | const securitySchemes = asObject(asObject(document.components).securitySchemes); | ||
| 692 | const resolved = [...schemeNames] | ||
| 693 | .sort((left, right) => left.localeCompare(right)) | ||
| 694 | .flatMap((schemeName) => { | ||
| 695 | const rawScheme = securitySchemes[schemeName]; | ||
| 696 | if (rawScheme === undefined) { | ||
| 697 | return []; | ||
| 698 | } | ||
| 699 | |||
| 700 | const scheme = asObject(resolveLocalRef(document, rawScheme)); | ||
| 701 | const schemeType = asTrimmedString(scheme.type); | ||
| 702 | if (!schemeType) { | ||
| 703 | return []; | ||
| 704 | } | ||
| 705 | const normalizedSchemeType: "apiKey" | "http" | "oauth2" | "openIdConnect" = | ||
| 706 | schemeType === "apiKey" | ||
| 707 | || schemeType === "http" | ||
| 708 | || schemeType === "oauth2" | ||
| 709 | || schemeType === "openIdConnect" | ||
| 710 | ? schemeType | ||
| 711 | : "http"; | ||
| 712 | |||
| 713 | const placementIn = asTrimmedString(scheme.in); | ||
| 714 | const normalizedPlacementIn: "header" | "query" | "cookie" | undefined = | ||
| 715 | placementIn === "header" || placementIn === "query" || placementIn === "cookie" | ||
| 716 | ? placementIn | ||
| 717 | : undefined; | ||
| 718 | |||
| 719 | return [{ | ||
| 720 | schemeName, | ||
| 721 | schemeType: normalizedSchemeType, | ||
| 722 | ...(asTrimmedString(scheme.description) | ||
| 723 | ? { description: asTrimmedString(scheme.description) } | ||
| 724 | : {}), | ||
| 725 | ...(normalizedPlacementIn | ||
| 726 | ? { placementIn: normalizedPlacementIn } | ||
| 727 | : {}), | ||
| 728 | ...(asTrimmedString(scheme.name) | ||
| 729 | ? { placementName: asTrimmedString(scheme.name) } | ||
| 730 | : {}), | ||
| 731 | ...(asTrimmedString(scheme.scheme) | ||
| 732 | ? { scheme: asTrimmedString(scheme.scheme) } | ||
| 733 | : {}), | ||
| 734 | ...(asTrimmedString(scheme.bearerFormat) | ||
| 735 | ? { bearerFormat: asTrimmedString(scheme.bearerFormat) } | ||
| 736 | : {}), | ||
| 737 | ...(asTrimmedString(scheme.openIdConnectUrl) | ||
| 738 | ? { openIdConnectUrl: asTrimmedString(scheme.openIdConnectUrl) } | ||
| 739 | : {}), | ||
| 740 | ...(oauthFlowRecord(scheme.flows) | ||
| 741 | ? { flows: oauthFlowRecord(scheme.flows) } | ||
| 742 | : {}), | ||
| 743 | }]; | ||
| 744 | }); | ||
| 745 | |||
| 746 | return resolved.length > 0 ? resolved : undefined; | ||
| 747 | }; | ||
| 748 | |||
| 749 | const mergeRefHintTable = ( | ||
| 750 | document: OpenApiJsonObject, | ||
| 751 | manifest: OpenApiToolManifest, | ||
| 752 | extraRefKeys: ReadonlySet<string>, | ||
| 753 | ): OpenApiToolManifest["refHintTable"] => { | ||
| 754 | const merged: Record<string, string> = { | ||
| 755 | ...(manifest.refHintTable ?? {}), | ||
| 756 | }; | ||
| 757 | const queue = [...extraRefKeys].sort((left, right) => left.localeCompare(right)); | ||
| 758 | const seen = new Set<string>(Object.keys(merged)); | ||
| 759 | |||
| 760 | while (queue.length > 0) { | ||
| 761 | const refKey = queue.shift()!; | ||
| 762 | if (seen.has(refKey)) { | ||
| 763 | continue; | ||
| 764 | } | ||
| 765 | seen.add(refKey); | ||
| 766 | |||
| 767 | const resolved = resolveLocalRef(document, { $ref: refKey }); | ||
| 768 | merged[refKey] = stableJsonStringify(resolved); | ||
| 769 | |||
| 770 | const nestedRefs = new Set<string>(); | ||
| 771 | collectRefKeys(resolved, nestedRefs); | ||
| 772 | for (const nestedRef of [...nestedRefs].sort((left, right) => left.localeCompare(right))) { | ||
| 773 | if (!seen.has(nestedRef)) { | ||
| 774 | queue.push(nestedRef); | ||
| 775 | } | ||
| 776 | } | ||
| 777 | } | ||
| 778 | |||
| 779 | return Object.keys(merged).length > 0 ? merged : undefined; | ||
| 780 | }; | ||
| 781 | |||
| 164 | const enrichManifestFromDocument = ( | 782 | const enrichManifestFromDocument = ( |
| 165 | document: OpenApiJsonObject, | 783 | document: OpenApiJsonObject, |
| 166 | manifest: OpenApiToolManifest, | 784 | manifest: OpenApiToolManifest, |
| 167 | ): OpenApiToolManifest => ({ | 785 | ): OpenApiToolManifest => { |
| 168 | ...manifest, | 786 | const extraRefKeys = new Set<string>(); |
| 169 | tools: manifest.tools.map((tool) => { | 787 | const documentServers = documentServersFromDocument(document); |
| 788 | const tools = manifest.tools.map((tool) => { | ||
| 789 | const parameterRecords = parameterRecordMapForTool(document, tool); | ||
| 790 | const invocationParameters = tool.invocation.parameters.map((parameter) => { | ||
| 791 | const record = parameterRecords.get(parameterKey({ | ||
| 792 | location: parameter.location, | ||
| 793 | name: parameter.name, | ||
| 794 | })); | ||
| 795 | const content = contentEntriesFromContent(document, record?.content); | ||
| 796 | |||
| 797 | return { | ||
| 798 | ...parameter, | ||
| 799 | ...(asTrimmedString(record?.style) ? { style: asTrimmedString(record?.style) } : {}), | ||
| 800 | ...(typeof record?.explode === "boolean" ? { explode: record.explode } : {}), | ||
| 801 | ...(typeof record?.allowReserved === "boolean" ? { allowReserved: record.allowReserved } : {}), | ||
| 802 | ...(content.length > 0 ? { content } : {}), | ||
| 803 | }; | ||
| 804 | }); | ||
| 805 | const requestBody = requestBodyPayloadForTool(document, tool); | ||
| 170 | const inputSchema = tool.typing?.inputSchema ?? requestBodySchemaForTool(document, tool); | 806 | const inputSchema = tool.typing?.inputSchema ?? requestBodySchemaForTool(document, tool); |
| 171 | const outputSchema = tool.typing?.outputSchema ?? responseSchemaForTool(document, tool); | 807 | const outputSchema = tool.typing?.outputSchema ?? responseSchemaForTool(document, tool); |
| 808 | const responses = responseVariantsForTool(document, tool); | ||
| 809 | const authRequirement = authRequirementForTool(document, tool); | ||
| 810 | const securitySchemes = securitySchemesForTool(document, authRequirement); | ||
| 811 | const operationServers = operationServersForTool(document, tool); | ||
| 812 | |||
| 813 | for (const parameter of invocationParameters) { | ||
| 814 | for (const content of parameter.content ?? []) { | ||
| 815 | collectRefKeys(content.schema, extraRefKeys); | ||
| 816 | } | ||
| 817 | } | ||
| 818 | |||
| 819 | for (const content of requestBody?.contents ?? []) { | ||
| 820 | collectRefKeys(content.schema, extraRefKeys); | ||
| 821 | } | ||
| 172 | 822 | ||
| 173 | if (inputSchema === undefined && outputSchema === undefined) { | 823 | for (const response of responses ?? []) { |
| 174 | return tool; | 824 | collectRefKeys(response.schema, extraRefKeys); |
| 825 | for (const content of response.contents ?? []) { | ||
| 826 | collectRefKeys(content.schema, extraRefKeys); | ||
| 827 | } | ||
| 828 | for (const header of response.headers ?? []) { | ||
| 829 | collectRefKeys(header.schema, extraRefKeys); | ||
| 830 | for (const content of header.content ?? []) { | ||
| 831 | collectRefKeys(content.schema, extraRefKeys); | ||
| 832 | } | ||
| 833 | } | ||
| 175 | } | 834 | } |
| 176 | 835 | ||
| 177 | return { | 836 | return { |
| 178 | ...tool, | 837 | ...tool, |
| 179 | typing: { | 838 | invocation: { |
| 180 | ...(tool.typing ?? {}), | 839 | ...tool.invocation, |
| 181 | ...(inputSchema !== undefined ? { inputSchema } : {}), | 840 | parameters: invocationParameters, |
| 182 | ...(outputSchema !== undefined ? { outputSchema } : {}), | 841 | requestBody, |
| 183 | }, | 842 | }, |
| 843 | ...((inputSchema !== undefined || outputSchema !== undefined) | ||
| 844 | ? { | ||
| 845 | typing: { | ||
| 846 | ...(tool.typing ?? {}), | ||
| 847 | ...(inputSchema !== undefined ? { inputSchema } : {}), | ||
| 848 | ...(outputSchema !== undefined ? { outputSchema } : {}), | ||
| 849 | }, | ||
| 850 | } | ||
| 851 | : {}), | ||
| 852 | ...(responses ? { responses } : {}), | ||
| 853 | ...(authRequirement ? { authRequirement } : {}), | ||
| 854 | ...(securitySchemes ? { securitySchemes } : {}), | ||
| 855 | ...(documentServers ? { documentServers } : {}), | ||
| 856 | ...(operationServers ? { servers: operationServers } : {}), | ||
| 184 | }; | 857 | }; |
| 185 | }), | 858 | }); |
| 186 | }); | 859 | |
| 860 | return { | ||
| 861 | ...manifest, | ||
| 862 | tools, | ||
| 863 | refHintTable: mergeRefHintTable(document, manifest, extraRefKeys), | ||
| 864 | }; | ||
| 865 | }; | ||
| 187 | 866 | ||
| 188 | export const extractOpenApiManifest = ( | 867 | export const extractOpenApiManifest = ( |
| 189 | sourceName: string, | 868 | sourceName: string, |
Binary file not shown.
| @@ -0,0 +1,515 @@ | |||
| 1 | type OpenApiSerializableLocation = "path" | "query" | "header" | "cookie"; | ||
| 2 | |||
| 3 | export type OpenApiSerializableContent = { | ||
| 4 | mediaType: string; | ||
| 5 | }; | ||
| 6 | |||
| 7 | export type OpenApiSerializableParameter = { | ||
| 8 | name: string; | ||
| 9 | location: OpenApiSerializableLocation; | ||
| 10 | style?: string; | ||
| 11 | explode?: boolean; | ||
| 12 | allowReserved?: boolean; | ||
| 13 | content?: ReadonlyArray<OpenApiSerializableContent>; | ||
| 14 | }; | ||
| 15 | |||
| 16 | export type OpenApiSerializableRequestBody = { | ||
| 17 | contentTypes?: ReadonlyArray<string>; | ||
| 18 | contents?: ReadonlyArray<OpenApiSerializableContent>; | ||
| 19 | }; | ||
| 20 | |||
| 21 | export type SerializedOpenApiQueryEntry = { | ||
| 22 | name: string; | ||
| 23 | value: string; | ||
| 24 | allowReserved?: boolean; | ||
| 25 | }; | ||
| 26 | |||
| 27 | export type SerializedOpenApiParameterValue = | ||
| 28 | | { | ||
| 29 | kind: "path" | "header"; | ||
| 30 | value: string; | ||
| 31 | } | ||
| 32 | | { | ||
| 33 | kind: "query"; | ||
| 34 | entries: ReadonlyArray<SerializedOpenApiQueryEntry>; | ||
| 35 | } | ||
| 36 | | { | ||
| 37 | kind: "cookie"; | ||
| 38 | pairs: ReadonlyArray<{ | ||
| 39 | name: string; | ||
| 40 | value: string; | ||
| 41 | }>; | ||
| 42 | }; | ||
| 43 | |||
| 44 | const isRecord = (value: unknown): value is Record<string, unknown> => | ||
| 45 | value !== null && typeof value === "object" && !Array.isArray(value); | ||
| 46 | |||
| 47 | const primitiveString = (value: unknown): string => { | ||
| 48 | if (typeof value === "string") { | ||
| 49 | return value; | ||
| 50 | } | ||
| 51 | |||
| 52 | if ( | ||
| 53 | typeof value === "number" | ||
| 54 | || typeof value === "boolean" | ||
| 55 | || typeof value === "bigint" | ||
| 56 | ) { | ||
| 57 | return String(value); | ||
| 58 | } | ||
| 59 | |||
| 60 | if (value === null || value === undefined) { | ||
| 61 | return ""; | ||
| 62 | } | ||
| 63 | |||
| 64 | try { | ||
| 65 | return JSON.stringify(value); | ||
| 66 | } catch { | ||
| 67 | return String(value); | ||
| 68 | } | ||
| 69 | }; | ||
| 70 | |||
| 71 | const objectEntries = (value: unknown): Array<[string, unknown]> => | ||
| 72 | Object.entries(isRecord(value) ? value : {}) | ||
| 73 | .sort(([left], [right]) => left.localeCompare(right)); | ||
| 74 | |||
| 75 | const defaultStyle = (location: OpenApiSerializableLocation): string => { | ||
| 76 | switch (location) { | ||
| 77 | case "path": | ||
| 78 | case "header": | ||
| 79 | return "simple"; | ||
| 80 | case "cookie": | ||
| 81 | case "query": | ||
| 82 | return "form"; | ||
| 83 | } | ||
| 84 | }; | ||
| 85 | |||
| 86 | const defaultExplode = (location: OpenApiSerializableLocation, style: string): boolean => | ||
| 87 | location === "query" || location === "cookie" | ||
| 88 | ? style === "form" | ||
| 89 | : false; | ||
| 90 | |||
| 91 | const selectedStyle = (parameter: OpenApiSerializableParameter): string => | ||
| 92 | parameter.style ?? defaultStyle(parameter.location); | ||
| 93 | |||
| 94 | const selectedExplode = (parameter: OpenApiSerializableParameter): boolean => | ||
| 95 | parameter.explode ?? defaultExplode(parameter.location, selectedStyle(parameter)); | ||
| 96 | |||
| 97 | const encodePathPart = (value: unknown): string => | ||
| 98 | encodeURIComponent(primitiveString(value)); | ||
| 99 | |||
| 100 | const encodeQueryValue = (value: string, allowReserved: boolean): string => { | ||
| 101 | const encoded = encodeURIComponent(value); | ||
| 102 | if (!allowReserved) { | ||
| 103 | return encoded; | ||
| 104 | } | ||
| 105 | |||
| 106 | return encoded | ||
| 107 | .replace(/%3A/gi, ":") | ||
| 108 | .replace(/%2F/gi, "/") | ||
| 109 | .replace(/%3F/gi, "?") | ||
| 110 | .replace(/%23/gi, "#") | ||
| 111 | .replace(/%5B/gi, "[") | ||
| 112 | .replace(/%5D/gi, "]") | ||
| 113 | .replace(/%40/gi, "@") | ||
| 114 | .replace(/%21/gi, "!") | ||
| 115 | .replace(/%24/gi, "$") | ||
| 116 | .replace(/%26/gi, "&") | ||
| 117 | .replace(/%27/gi, "'") | ||
| 118 | .replace(/%28/gi, "(") | ||
| 119 | .replace(/%29/gi, ")") | ||
| 120 | .replace(/%2A/gi, "*") | ||
| 121 | .replace(/%2B/gi, "+") | ||
| 122 | .replace(/%2C/gi, ",") | ||
| 123 | .replace(/%3B/gi, ";") | ||
| 124 | .replace(/%3D/gi, "="); | ||
| 125 | }; | ||
| 126 | |||
| 127 | const preferredContentType = (input: { | ||
| 128 | contents?: ReadonlyArray<OpenApiSerializableContent>; | ||
| 129 | contentTypes?: ReadonlyArray<string>; | ||
| 130 | }): string | undefined => { | ||
| 131 | const candidates = [ | ||
| 132 | ...(input.contents ?? []).map((content) => content.mediaType), | ||
| 133 | ...(input.contentTypes ?? []), | ||
| 134 | ]; | ||
| 135 | if (candidates.length === 0) { | ||
| 136 | return undefined; | ||
| 137 | } | ||
| 138 | |||
| 139 | return candidates.find((mediaType) => mediaType === "application/json") | ||
| 140 | ?? candidates.find((mediaType) => mediaType.toLowerCase().includes("+json")) | ||
| 141 | ?? candidates.find((mediaType) => mediaType.toLowerCase().includes("json")) | ||
| 142 | ?? candidates[0]; | ||
| 143 | }; | ||
| 144 | |||
| 145 | const serializeContentValue = (value: unknown, mediaType: string): string => { | ||
| 146 | const normalized = mediaType.toLowerCase(); | ||
| 147 | if ( | ||
| 148 | normalized === "application/json" | ||
| 149 | || normalized.includes("+json") | ||
| 150 | || normalized.includes("json") | ||
| 151 | ) { | ||
| 152 | return JSON.stringify(value); | ||
| 153 | } | ||
| 154 | |||
| 155 | if (normalized === "application/x-www-form-urlencoded") { | ||
| 156 | const params = new URLSearchParams(); | ||
| 157 | for (const [key, entryValue] of objectEntries(value)) { | ||
| 158 | if (Array.isArray(entryValue)) { | ||
| 159 | for (const item of entryValue) { | ||
| 160 | params.append(key, primitiveString(item)); | ||
| 161 | } | ||
| 162 | continue; | ||
| 163 | } | ||
| 164 | |||
| 165 | params.append(key, primitiveString(entryValue)); | ||
| 166 | } | ||
| 167 | return params.toString(); | ||
| 168 | } | ||
| 169 | |||
| 170 | return primitiveString(value); | ||
| 171 | }; | ||
| 172 | |||
| 173 | const serializeQueryParameter = ( | ||
| 174 | parameter: OpenApiSerializableParameter, | ||
| 175 | value: unknown, | ||
| 176 | ): SerializedOpenApiParameterValue => { | ||
| 177 | const style = selectedStyle(parameter); | ||
| 178 | const explode = selectedExplode(parameter); | ||
| 179 | const allowReserved = parameter.allowReserved === true; | ||
| 180 | const contentType = preferredContentType({ contents: parameter.content }); | ||
| 181 | |||
| 182 | if (contentType) { | ||
| 183 | return { | ||
| 184 | kind: "query", | ||
| 185 | entries: [{ | ||
| 186 | name: parameter.name, | ||
| 187 | value: serializeContentValue(value, contentType), | ||
| 188 | allowReserved, | ||
| 189 | }], | ||
| 190 | }; | ||
| 191 | } | ||
| 192 | |||
| 193 | if (Array.isArray(value)) { | ||
| 194 | if (style === "spaceDelimited") { | ||
| 195 | return { | ||
| 196 | kind: "query", | ||
| 197 | entries: [{ | ||
| 198 | name: parameter.name, | ||
| 199 | value: value.map((entry) => primitiveString(entry)).join(" "), | ||
| 200 | allowReserved, | ||
| 201 | }], | ||
| 202 | }; | ||
| 203 | } | ||
| 204 | |||
| 205 | if (style === "pipeDelimited") { | ||
| 206 | return { | ||
| 207 | kind: "query", | ||
| 208 | entries: [{ | ||
| 209 | name: parameter.name, | ||
| 210 | value: value.map((entry) => primitiveString(entry)).join("|"), | ||
| 211 | allowReserved, | ||
| 212 | }], | ||
| 213 | }; | ||
| 214 | } | ||
| 215 | |||
| 216 | return { | ||
| 217 | kind: "query", | ||
| 218 | entries: | ||
| 219 | explode | ||
| 220 | ? value.map((entry) => ({ | ||
| 221 | name: parameter.name, | ||
| 222 | value: primitiveString(entry), | ||
| 223 | allowReserved, | ||
| 224 | })) | ||
| 225 | : [{ | ||
| 226 | name: parameter.name, | ||
| 227 | value: value.map((entry) => primitiveString(entry)).join(","), | ||
| 228 | allowReserved, | ||
| 229 | }], | ||
| 230 | }; | ||
| 231 | } | ||
| 232 | |||
| 233 | if (isRecord(value)) { | ||
| 234 | const entries = objectEntries(value); | ||
| 235 | |||
| 236 | if (style === "deepObject") { | ||
| 237 | return { | ||
| 238 | kind: "query", | ||
| 239 | entries: entries.map(([key, entryValue]) => ({ | ||
| 240 | name: `${parameter.name}[${key}]`, | ||
| 241 | value: primitiveString(entryValue), | ||
| 242 | allowReserved, | ||
| 243 | })), | ||
| 244 | }; | ||
| 245 | } | ||
| 246 | |||
| 247 | return { | ||
| 248 | kind: "query", | ||
| 249 | entries: | ||
| 250 | explode | ||
| 251 | ? entries.map(([key, entryValue]) => ({ | ||
| 252 | name: key, | ||
| 253 | value: primitiveString(entryValue), | ||
| 254 | allowReserved, | ||
| 255 | })) | ||
| 256 | : [{ | ||
| 257 | name: parameter.name, | ||
| 258 | value: entries.flatMap(([key, entryValue]) => [key, primitiveString(entryValue)]).join(","), | ||
| 259 | allowReserved, | ||
| 260 | }], | ||
| 261 | }; | ||
| 262 | } | ||
| 263 | |||
| 264 | return { | ||
| 265 | kind: "query", | ||
| 266 | entries: [{ | ||
| 267 | name: parameter.name, | ||
| 268 | value: primitiveString(value), | ||
| 269 | allowReserved, | ||
| 270 | }], | ||
| 271 | }; | ||
| 272 | }; | ||
| 273 | |||
| 274 | const serializeHeaderParameter = ( | ||
| 275 | parameter: OpenApiSerializableParameter, | ||
| 276 | value: unknown, | ||
| 277 | ): SerializedOpenApiParameterValue => { | ||
| 278 | const explode = selectedExplode(parameter); | ||
| 279 | const contentType = preferredContentType({ contents: parameter.content }); | ||
| 280 | if (contentType) { | ||
| 281 | return { | ||
| 282 | kind: "header", | ||
| 283 | value: serializeContentValue(value, contentType), | ||
| 284 | }; | ||
| 285 | } | ||
| 286 | |||
| 287 | if (Array.isArray(value)) { | ||
| 288 | return { | ||
| 289 | kind: "header", | ||
| 290 | value: value.map((entry) => primitiveString(entry)).join(","), | ||
| 291 | }; | ||
| 292 | } | ||
| 293 | |||
| 294 | if (isRecord(value)) { | ||
| 295 | const entries = objectEntries(value); | ||
| 296 | return { | ||
| 297 | kind: "header", | ||
| 298 | value: | ||
| 299 | explode | ||
| 300 | ? entries.map(([key, entryValue]) => `${key}=${primitiveString(entryValue)}`).join(",") | ||
| 301 | : entries.flatMap(([key, entryValue]) => [key, primitiveString(entryValue)]).join(","), | ||
| 302 | }; | ||
| 303 | } | ||
| 304 | |||
| 305 | return { | ||
| 306 | kind: "header", | ||
| 307 | value: primitiveString(value), | ||
| 308 | }; | ||
| 309 | }; | ||
| 310 | |||
| 311 | const serializeCookieParameter = ( | ||
| 312 | parameter: OpenApiSerializableParameter, | ||
| 313 | value: unknown, | ||
| 314 | ): SerializedOpenApiParameterValue => { | ||
| 315 | const explode = selectedExplode(parameter); | ||
| 316 | const contentType = preferredContentType({ contents: parameter.content }); | ||
| 317 | if (contentType) { | ||
| 318 | return { | ||
| 319 | kind: "cookie", | ||
| 320 | pairs: [{ | ||
| 321 | name: parameter.name, | ||
| 322 | value: serializeContentValue(value, contentType), | ||
| 323 | }], | ||
| 324 | }; | ||
| 325 | } | ||
| 326 | |||
| 327 | if (Array.isArray(value)) { | ||
| 328 | return { | ||
| 329 | kind: "cookie", | ||
| 330 | pairs: | ||
| 331 | explode | ||
| 332 | ? value.map((entry) => ({ | ||
| 333 | name: parameter.name, | ||
| 334 | value: primitiveString(entry), | ||
| 335 | })) | ||
| 336 | : [{ | ||
| 337 | name: parameter.name, | ||
| 338 | value: value.map((entry) => primitiveString(entry)).join(","), | ||
| 339 | }], | ||
| 340 | }; | ||
| 341 | } | ||
| 342 | |||
| 343 | if (isRecord(value)) { | ||
| 344 | const entries = objectEntries(value); | ||
| 345 | return { | ||
| 346 | kind: "cookie", | ||
| 347 | pairs: | ||
| 348 | explode | ||
| 349 | ? entries.map(([key, entryValue]) => ({ | ||
| 350 | name: key, | ||
| 351 | value: primitiveString(entryValue), | ||
| 352 | })) | ||
| 353 | : [{ | ||
| 354 | name: parameter.name, | ||
| 355 | value: entries.flatMap(([key, entryValue]) => [key, primitiveString(entryValue)]).join(","), | ||
| 356 | }], | ||
| 357 | }; | ||
| 358 | } | ||
| 359 | |||
| 360 | return { | ||
| 361 | kind: "cookie", | ||
| 362 | pairs: [{ | ||
| 363 | name: parameter.name, | ||
| 364 | value: primitiveString(value), | ||
| 365 | }], | ||
| 366 | }; | ||
| 367 | }; | ||
| 368 | |||
| 369 | const serializePathParameter = ( | ||
| 370 | parameter: OpenApiSerializableParameter, | ||
| 371 | value: unknown, | ||
| 372 | ): SerializedOpenApiParameterValue => { | ||
| 373 | const style = selectedStyle(parameter); | ||
| 374 | const explode = selectedExplode(parameter); | ||
| 375 | const contentType = preferredContentType({ contents: parameter.content }); | ||
| 376 | if (contentType) { | ||
| 377 | return { | ||
| 378 | kind: "path", | ||
| 379 | value: encodePathPart(serializeContentValue(value, contentType)), | ||
| 380 | }; | ||
| 381 | } | ||
| 382 | |||
| 383 | if (Array.isArray(value)) { | ||
| 384 | if (style === "label") { | ||
| 385 | return { | ||
| 386 | kind: "path", | ||
| 387 | value: `.${value.map((entry) => encodePathPart(entry)).join(explode ? "." : ",")}`, | ||
| 388 | }; | ||
| 389 | } | ||
| 390 | |||
| 391 | if (style === "matrix") { | ||
| 392 | return { | ||
| 393 | kind: "path", | ||
| 394 | value: | ||
| 395 | explode | ||
| 396 | ? value.map((entry) => `;${encodeURIComponent(parameter.name)}=${encodePathPart(entry)}`).join("") | ||
| 397 | : `;${encodeURIComponent(parameter.name)}=${value.map((entry) => encodePathPart(entry)).join(",")}`, | ||
| 398 | }; | ||
| 399 | } | ||
| 400 | |||
| 401 | return { | ||
| 402 | kind: "path", | ||
| 403 | value: value.map((entry) => encodePathPart(entry)).join(","), | ||
| 404 | }; | ||
| 405 | } | ||
| 406 | |||
| 407 | if (isRecord(value)) { | ||
| 408 | const entries = objectEntries(value); | ||
| 409 | |||
| 410 | if (style === "label") { | ||
| 411 | return { | ||
| 412 | kind: "path", | ||
| 413 | value: | ||
| 414 | explode | ||
| 415 | ? `.${entries.map(([key, entryValue]) => `${encodePathPart(key)}=${encodePathPart(entryValue)}`).join(".")}` | ||
| 416 | : `.${entries.flatMap(([key, entryValue]) => [encodePathPart(key), encodePathPart(entryValue)]).join(",")}`, | ||
| 417 | }; | ||
| 418 | } | ||
| 419 | |||
| 420 | if (style === "matrix") { | ||
| 421 | return { | ||
| 422 | kind: "path", | ||
| 423 | value: | ||
| 424 | explode | ||
| 425 | ? entries.map(([key, entryValue]) => `;${encodeURIComponent(key)}=${encodePathPart(entryValue)}`).join("") | ||
| 426 | : `;${encodeURIComponent(parameter.name)}=${entries.flatMap(([key, entryValue]) => [ | ||
| 427 | encodePathPart(key), | ||
| 428 | encodePathPart(entryValue), | ||
| 429 | ]).join(",")}`, | ||
| 430 | }; | ||
| 431 | } | ||
| 432 | |||
| 433 | return { | ||
| 434 | kind: "path", | ||
| 435 | value: | ||
| 436 | explode | ||
| 437 | ? entries.map(([key, entryValue]) => `${encodePathPart(key)}=${encodePathPart(entryValue)}`).join(",") | ||
| 438 | : entries.flatMap(([key, entryValue]) => [encodePathPart(key), encodePathPart(entryValue)]).join(","), | ||
| 439 | }; | ||
| 440 | } | ||
| 441 | |||
| 442 | if (style === "label") { | ||
| 443 | return { | ||
| 444 | kind: "path", | ||
| 445 | value: `.${encodePathPart(value)}`, | ||
| 446 | }; | ||
| 447 | } | ||
| 448 | |||
| 449 | if (style === "matrix") { | ||
| 450 | return { | ||
| 451 | kind: "path", | ||
| 452 | value: `;${encodeURIComponent(parameter.name)}=${encodePathPart(value)}`, | ||
| 453 | }; | ||
| 454 | } | ||
| 455 | |||
| 456 | return { | ||
| 457 | kind: "path", | ||
| 458 | value: encodePathPart(value), | ||
| 459 | }; | ||
| 460 | }; | ||
| 461 | |||
| 462 | export const serializeOpenApiParameterValue = ( | ||
| 463 | parameter: OpenApiSerializableParameter, | ||
| 464 | value: unknown, | ||
| 465 | ): SerializedOpenApiParameterValue => { | ||
| 466 | switch (parameter.location) { | ||
| 467 | case "path": | ||
| 468 | return serializePathParameter(parameter, value); | ||
| 469 | case "query": | ||
| 470 | return serializeQueryParameter(parameter, value); | ||
| 471 | case "header": | ||
| 472 | return serializeHeaderParameter(parameter, value); | ||
| 473 | case "cookie": | ||
| 474 | return serializeCookieParameter(parameter, value); | ||
| 475 | } | ||
| 476 | }; | ||
| 477 | |||
| 478 | export const withSerializedQueryEntries = ( | ||
| 479 | url: URL, | ||
| 480 | entries: ReadonlyArray<SerializedOpenApiQueryEntry>, | ||
| 481 | ): string => { | ||
| 482 | if (entries.length === 0) { | ||
| 483 | return url.toString(); | ||
| 484 | } | ||
| 485 | |||
| 486 | const suffix = entries | ||
| 487 | .map((entry) => | ||
| 488 | `${encodeURIComponent(entry.name)}=${encodeQueryValue(entry.value, entry.allowReserved === true)}` | ||
| 489 | ) | ||
| 490 | .join("&"); | ||
| 491 | |||
| 492 | const urlText = url.toString(); | ||
| 493 | const [withoutHash, hashFragment] = urlText.split("#", 2); | ||
| 494 | const separator = | ||
| 495 | withoutHash.includes("?") | ||
| 496 | ? (withoutHash.endsWith("?") || withoutHash.endsWith("&") ? "" : "&") | ||
| 497 | : "?"; | ||
| 498 | |||
| 499 | return `${withoutHash}${separator}${suffix}${hashFragment ? `#${hashFragment}` : ""}`; | ||
| 500 | }; | ||
| 501 | |||
| 502 | export const serializeOpenApiRequestBody = (input: { | ||
| 503 | requestBody: OpenApiSerializableRequestBody; | ||
| 504 | body: unknown; | ||
| 505 | }): { | ||
| 506 | contentType: string; | ||
| 507 | bodyText: string; | ||
| 508 | } => { | ||
| 509 | const contentType = preferredContentType(input.requestBody) ?? "application/json"; | ||
| 510 | |||
| 511 | return { | ||
| 512 | contentType, | ||
| 513 | bodyText: serializeContentValue(input.body, contentType), | ||
| 514 | }; | ||
| 515 | }; | ||
| @@ -3,6 +3,7 @@ import { readFileSync } from "node:fs"; · 2 unmodified lines | |||
| 3 | import { describe, expect, it } from "@effect/vitest"; | 3 | import { describe, expect, it } from "@effect/vitest"; |
| 4 | import * as Effect from "effect/Effect"; | 4 | import * as Effect from "effect/Effect"; |
| 5 | 5 | ||
| 6 | import type { OpenApiToolDefinition } from "./openapi-definitions"; | ||
| 6 | import { compileOpenApiToolDefinitions } from "./openapi-definitions"; | 7 | import { compileOpenApiToolDefinitions } from "./openapi-definitions"; |
| 7 | import { extractOpenApiManifest } from "./openapi-extraction"; | 8 | import { extractOpenApiManifest } from "./openapi-extraction"; |
| 8 | import { buildOpenApiToolPresentation } from "./openapi-tool-presentation"; | 9 | import { buildOpenApiToolPresentation } from "./openapi-tool-presentation"; |
| @@ -14,6 +15,188 @@ const readFixture = (name: string): string => · 5 unmodified lines | |||
| 14 | ); | 15 | ); |
| 15 | 16 | ||
| 16 | describe("buildOpenApiToolPresentation", () => { | 17 | describe("buildOpenApiToolPresentation", () => { |
| 18 | it("adds parameter descriptions to flattened call schemas", () => { | ||
| 19 | const definition: OpenApiToolDefinition = { | ||
| 20 | toolId: "accessGroups.deleteProject", | ||
| 21 | rawToolId: "deleteAccessGroupProject", | ||
| 22 | name: "Delete an access group project", | ||
| 23 | description: "Allows deletion of an access group project", | ||
| 24 | group: "accessGroups", | ||
| 25 | leaf: "deleteProject", | ||
| 26 | tags: [], | ||
| 27 | versionSegment: "v1", | ||
| 28 | method: "delete", | ||
| 29 | path: "/v1/access-groups/{accessGroupIdOrName}/projects/{projectId}", | ||
| 30 | invocation: { | ||
| 31 | method: "delete", | ||
| 32 | pathTemplate: "/v1/access-groups/{accessGroupIdOrName}/projects/{projectId}", | ||
| 33 | parameters: [ | ||
| 34 | { | ||
| 35 | name: "accessGroupIdOrName", | ||
| 36 | location: "path", | ||
| 37 | required: true, | ||
| 38 | }, | ||
| 39 | { | ||
| 40 | name: "projectId", | ||
| 41 | location: "path", | ||
| 42 | required: true, | ||
| 43 | }, | ||
| 44 | { | ||
| 45 | name: "teamId", | ||
| 46 | location: "query", | ||
| 47 | required: false, | ||
| 48 | }, | ||
| 49 | { | ||
| 50 | name: "slug", | ||
| 51 | location: "query", | ||
| 52 | required: false, | ||
| 53 | }, | ||
| 54 | ], | ||
| 55 | requestBody: null, | ||
| 56 | }, | ||
| 57 | operationHash: "deadbeefcafebabe", | ||
| 58 | typing: { | ||
| 59 | inputSchema: { | ||
| 60 | type: "object", | ||
| 61 | properties: { | ||
| 62 | accessGroupIdOrName: { type: "string" }, | ||
| 63 | projectId: { type: "string" }, | ||
| 64 | teamId: { type: "string" }, | ||
| 65 | slug: { type: "string" }, | ||
| 66 | }, | ||
| 67 | required: ["accessGroupIdOrName", "projectId"], | ||
| 68 | additionalProperties: false, | ||
| 69 | }, | ||
| 70 | }, | ||
| 71 | documentation: { | ||
| 72 | summary: "Delete an access group project", | ||
| 73 | parameters: [ | ||
| 74 | { | ||
| 75 | name: "accessGroupIdOrName", | ||
| 76 | location: "path", | ||
| 77 | required: true, | ||
| 78 | description: "The access group identifier or slug.", | ||
| 79 | }, | ||
| 80 | { | ||
| 81 | name: "projectId", | ||
| 82 | location: "path", | ||
| 83 | required: true, | ||
| 84 | description: "The project identifier.", | ||
| 85 | }, | ||
| 86 | { | ||
| 87 | name: "teamId", | ||
| 88 | location: "query", | ||
| 89 | required: false, | ||
| 90 | description: "The Team identifier to perform the request on behalf of.", | ||
| 91 | }, | ||
| 92 | { | ||
| 93 | name: "slug", | ||
| 94 | location: "query", | ||
| 95 | required: false, | ||
| 96 | description: "The Team slug to perform the request on behalf of.", | ||
| 97 | }, | ||
| 98 | ], | ||
| 99 | }, | ||
| 100 | }; | ||
| 101 | |||
| 102 | const presentation = buildOpenApiToolPresentation({ definition }); | ||
| 103 | const schema = presentation.inputSchema as { | ||
| 104 | properties?: Record<string, { description?: string }>; | ||
| 105 | }; | ||
| 106 | |||
| 107 | expect(schema.properties?.accessGroupIdOrName?.description).toBe( | ||
| 108 | "The access group identifier or slug.", | ||
| 109 | ); | ||
| 110 | expect(schema.properties?.projectId?.description).toBe( | ||
| 111 | "The project identifier.", | ||
| 112 | ); | ||
| 113 | expect(schema.properties?.teamId?.description).toBe( | ||
| 114 | "The Team identifier to perform the request on behalf of.", | ||
| 115 | ); | ||
| 116 | expect(schema.properties?.slug?.description).toBe( | ||
| 117 | "The Team slug to perform the request on behalf of.", | ||
| 118 | ); | ||
| 119 | }); | ||
| 120 | |||
| 121 | it("uses the request body property schema instead of nesting the full input schema", () => { | ||
| 122 | const definition: OpenApiToolDefinition = { | ||
| 123 | toolId: "projects.update", | ||
| 124 | rawToolId: "updateProject", | ||
| 125 | name: "Update project", | ||
| 126 | description: "Updates a project.", | ||
| 127 | group: "projects", | ||
| 128 | leaf: "update", | ||
| 129 | tags: [], | ||
| 130 | versionSegment: "v1", | ||
| 131 | method: "patch", | ||
| 132 | path: "/v1/projects/{projectId}", | ||
| 133 | invocation: { | ||
| 134 | method: "patch", | ||
| 135 | pathTemplate: "/v1/projects/{projectId}", | ||
| 136 | parameters: [ | ||
| 137 | { | ||
| 138 | name: "projectId", | ||
| 139 | location: "path", | ||
| 140 | required: true, | ||
| 141 | }, | ||
| 142 | ], | ||
| 143 | requestBody: { | ||
| 144 | required: true, | ||
| 145 | contentTypes: ["application/json"], | ||
| 146 | }, | ||
| 147 | }, | ||
| 148 | operationHash: "feedfacecafebeef", | ||
| 149 | typing: { | ||
| 150 | inputSchema: { | ||
| 151 | type: "object", | ||
| 152 | properties: { | ||
| 153 | projectId: { | ||
| 154 | type: "string", | ||
| 155 | }, | ||
| 156 | body: { | ||
| 157 | type: "object", | ||
| 158 | properties: { | ||
| 159 | name: { | ||
| 160 | type: "string", | ||
| 161 | }, | ||
| 162 | }, | ||
| 163 | required: ["name"], | ||
| 164 | additionalProperties: false, | ||
| 165 | }, | ||
| 166 | }, | ||
| 167 | required: ["projectId", "body"], | ||
| 168 | additionalProperties: false, | ||
| 169 | }, | ||
| 170 | }, | ||
| 171 | documentation: { | ||
| 172 | parameters: [ | ||
| 173 | { | ||
| 174 | name: "projectId", | ||
| 175 | location: "path", | ||
| 176 | required: true, | ||
| 177 | description: "The project identifier.", | ||
| 178 | }, | ||
| 179 | ], | ||
| 180 | requestBody: { | ||
| 181 | description: "Project update payload.", | ||
| 182 | }, | ||
| 183 | }, | ||
| 184 | }; | ||
| 185 | |||
| 186 | const presentation = buildOpenApiToolPresentation({ definition }); | ||
| 187 | const schema = presentation.inputSchema as { | ||
| 188 | properties?: Record<string, unknown>; | ||
| 189 | }; | ||
| 190 | const body = schema.properties?.body as { | ||
| 191 | description?: string; | ||
| 192 | properties?: Record<string, unknown>; | ||
| 193 | }; | ||
| 194 | |||
| 195 | expect(body.description).toBe("Project update payload."); | ||
| 196 | expect(body.properties?.name).toEqual({ type: "string" }); | ||
| 197 | expect(body.properties?.body).toBeUndefined(); | ||
| 198 | }); | ||
| 199 | |||
| 17 | it.effect( | 200 | it.effect( |
| 18 | "resolves request and response schemas from ref hints for the real Neon OpenAPI spec", | 201 | "resolves request and response schemas from ref hints for the real Neon OpenAPI spec", |
| 19 | () => | 202 | () => |
| @@ -104,4 +287,65 @@ describe("buildOpenApiToolPresentation", () => { · 84 unmodified lines | |||
| 104 | }), | 287 | }), |
| 105 | 120_000, | 288 | 120_000, |
| 106 | ); | 289 | ); |
| 290 | |||
| 291 | it.effect( | ||
| 292 | "extracts parameter schemas and descriptions for parameter-only Vercel operations", | ||
| 293 | () => | ||
| 294 | Effect.gen(function* () { | ||
| 295 | const specText = readFixture("vercel-openapi.json"); | ||
| 296 | const manifest = yield* extractOpenApiManifest("vercel", specText); | ||
| 297 | const definition = compileOpenApiToolDefinitions(manifest).find( | ||
| 298 | (candidate) => candidate.toolId === "accessGroups.listAccessGroupProjects", | ||
| 299 | ); | ||
| 300 | |||
| 301 | expect(definition).toBeDefined(); | ||
| 302 | |||
| 303 | const presentation = buildOpenApiToolPresentation({ | ||
| 304 | definition: definition!, | ||
| 305 | refHintTable: manifest.refHintTable, | ||
| 306 | }); | ||
| 307 | |||
| 308 | expect(definition?.documentation?.parameters).toEqual( | ||
| 309 | expect.arrayContaining([ | ||
| 310 | expect.objectContaining({ | ||
| 311 | name: "idOrName", | ||
| 312 | description: "The ID or name of the Access Group.", | ||
| 313 | }), | ||
| 314 | expect.objectContaining({ | ||
| 315 | name: "limit", | ||
| 316 | description: "Limit how many access group projects should be returned.", | ||
| 317 | }), | ||
| 318 | ]), | ||
| 319 | ); | ||
| 320 | expect(presentation.inputSchema).toMatchObject({ | ||
| 321 | type: "object", | ||
| 322 | properties: { | ||
| 323 | idOrName: { | ||
| 324 | type: "string", | ||
| 325 | description: "The ID or name of the Access Group.", | ||
| 326 | }, | ||
| 327 | limit: { | ||
| 328 | type: "integer", | ||
| 329 | description: "Limit how many access group projects should be returned.", | ||
| 330 | minimum: 1, | ||
| 331 | maximum: 100, | ||
| 332 | }, | ||
| 333 | next: { | ||
| 334 | type: "string", | ||
| 335 | description: "Continuation cursor to retrieve the next page of results.", | ||
| 336 | }, | ||
| 337 | teamId: { | ||
| 338 | type: "string", | ||
| 339 | description: "The Team identifier to perform the request on behalf of.", | ||
| 340 | }, | ||
| 341 | slug: { | ||
| 342 | type: "string", | ||
| 343 | description: "The Team slug to perform the request on behalf of.", | ||
| 344 | }, | ||
| 345 | }, | ||
| 346 | required: ["idOrName"], | ||
| 347 | }); | ||
| 348 | }), | ||
| 349 | 120_000, | ||
| 350 | ); | ||
| 107 | }); | 351 | }); |
| @@ -3,8 +3,10 @@ import { typeSignatureFromSchema } from "@executor/codemode-core"; · 2 unmodified lines | |||
| 3 | 3 | ||
| 4 | import type { | 4 | import type { |
| 5 | OpenApiExample, | 5 | OpenApiExample, |
| 6 | OpenApiMediaContent, | ||
| 6 | OpenApiInvocationPayload, | 7 | OpenApiInvocationPayload, |
| 7 | OpenApiRefHintTable, | 8 | OpenApiRefHintTable, |
| 9 | OpenApiResponseVariant, | ||
| 8 | OpenApiToolDocumentation, | 10 | OpenApiToolDocumentation, |
| 9 | OpenApiToolProviderData, | 11 | OpenApiToolProviderData, |
| 10 | } from "./openapi-types"; | 12 | } from "./openapi-types"; |
| @@ -51,8 +53,74 @@ const firstExample = ( · 40 unmodified lines | |||
| 51 | examples: ReadonlyArray<OpenApiExample> | undefined, | 53 | examples: ReadonlyArray<OpenApiExample> | undefined, |
| 52 | ): OpenApiExample | undefined => examples?.[0]; | 54 | ): OpenApiExample | undefined => examples?.[0]; |
| 53 | 55 | ||
| 56 | const schemaProperty = ( | ||
| 57 | schema: unknown, | ||
| 58 | propertyName: string, | ||
| 59 | ): unknown | undefined => { | ||
| 60 | const record = asRecord(schema); | ||
| 61 | const properties = asRecord(record.properties); | ||
| 62 | return properties[propertyName]; | ||
| 63 | }; | ||
| 64 | |||
| 65 | const groupedSchemaForParameter = ( | ||
| 66 | schema: unknown, | ||
| 67 | location: OpenApiInvocationPayload["parameters"][number]["location"], | ||
| 68 | name: string, | ||
| 69 | ): unknown | undefined => { | ||
| 70 | const direct = schemaProperty(schema, name); | ||
| 71 | if (direct !== undefined) { | ||
| 72 | return direct; | ||
| 73 | } | ||
| 74 | |||
| 75 | const groupKey = | ||
| 76 | location === "header" | ||
| 77 | ? "headers" | ||
| 78 | : location === "cookie" | ||
| 79 | ? "cookies" | ||
| 80 | : location; | ||
| 81 | const groupSchema = schemaProperty(schema, groupKey); | ||
| 82 | return groupSchema === undefined ? undefined : schemaProperty(groupSchema, name); | ||
| 83 | }; | ||
| 84 | |||
| 85 | const preferredContentSchema = ( | ||
| 86 | contents: ReadonlyArray<OpenApiMediaContent> | undefined, | ||
| 87 | ): unknown | undefined => { | ||
| 88 | if (!contents || contents.length === 0) { | ||
| 89 | return undefined; | ||
| 90 | } | ||
| 91 | |||
| 92 | const preferred = [...contents].sort((left, right) => left.mediaType.localeCompare(right.mediaType)) | ||
| 93 | .find((content) => content.mediaType === "application/json") | ||
| 94 | ?? [...contents].find((content) => content.mediaType.toLowerCase().includes("+json")) | ||
| 95 | ?? [...contents].find((content) => content.mediaType.toLowerCase().includes("json")) | ||
| 96 | ?? contents[0]; | ||
| 97 | |||
| 98 | return preferred?.schema; | ||
| 99 | }; | ||
| 100 | |||
| 101 | const withDescription = ( | ||
| 102 | schema: unknown, | ||
| 103 | description: string | undefined, | ||
| 104 | ): unknown => { | ||
| 105 | if (!description) { | ||
| 106 | return schema; | ||
| 107 | } | ||
| 108 | |||
| 109 | const record = asRecord(schema); | ||
| 110 | if (Object.keys(record).length === 0) { | ||
| 111 | return schema; | ||
| 112 | } | ||
| 113 | |||
| 114 | return { | ||
| 115 | ...record, | ||
| 116 | description, | ||
| 117 | }; | ||
| 118 | }; | ||
| 119 | |||
| 54 | const callInputSchemaFromInvocation = (input: { | 120 | const callInputSchemaFromInvocation = (input: { |
| 55 | invocation: OpenApiInvocationPayload; | 121 | invocation: OpenApiInvocationPayload; |
| 122 | documentation?: OpenApiToolDocumentation; | ||
| 123 | parameterSourceSchema?: unknown; | ||
| 56 | requestBodySchema?: unknown; | 124 | requestBodySchema?: unknown; |
| 57 | }): Record<string, unknown> | undefined => { | 125 | }): Record<string, unknown> | undefined => { |
| 58 | const invocation = input.invocation; | 126 | const invocation = input.invocation; |
| @@ -60,18 +128,33 @@ const callInputSchemaFromInvocation = (input: { · 1 unmodified line | |||
| 60 | const required: string[] = []; | 128 | const required: string[] = []; |
| 61 | 129 | ||
| 62 | for (const parameter of invocation.parameters) { | 130 | for (const parameter of invocation.parameters) { |
| 63 | properties[parameter.name] = { | 131 | const matchingDocs = input.documentation?.parameters.find((candidate) => |
| 132 | candidate.name === parameter.name && candidate.location === parameter.location | ||
| 133 | ); | ||
| 134 | const parameterSchema = groupedSchemaForParameter( | ||
| 135 | input.parameterSourceSchema, | ||
| 136 | parameter.location, | ||
| 137 | parameter.name, | ||
| 138 | ) ?? preferredContentSchema(parameter.content) ?? { | ||
| 64 | type: "string", | 139 | type: "string", |
| 65 | }; | 140 | }; |
| 141 | |||
| 142 | properties[parameter.name] = withDescription( | ||
| 143 | parameterSchema, | ||
| 144 | matchingDocs?.description, | ||
| 145 | ); | ||
| 66 | if (parameter.required) { | 146 | if (parameter.required) { |
| 67 | required.push(parameter.name); | 147 | required.push(parameter.name); |
| 68 | } | 148 | } |
| 69 | } | 149 | } |
| 70 | 150 | ||
| 71 | if (invocation.requestBody) { | 151 | if (invocation.requestBody) { |
| 72 | properties.body = input.requestBodySchema ?? { | 152 | properties.body = withDescription( |
| 73 | type: "object", | 153 | input.requestBodySchema ?? preferredContentSchema(invocation.requestBody.contents) ?? { |
| 74 | }; | 154 | type: "object", |
| 155 | }, | ||
| 156 | input.documentation?.requestBody?.description, | ||
| 157 | ); | ||
| 75 | if (invocation.requestBody.required) { | 158 | if (invocation.requestBody.required) { |
| 76 | required.push("body"); | 159 | required.push("body"); |
| 77 | } | 160 | } |
| @@ -130,23 +213,22 @@ const resolvePresentationSchemas = (input: { · 52 unmodified lines | |||
| 130 | input.refHintTable, | 213 | input.refHintTable, |
| 131 | ); | 214 | ); |
| 132 | const requestBodySchema = | 215 | const requestBodySchema = |
| 133 | resolvedTyping.inputSchema | 216 | schemaProperty(resolvedTyping.inputSchema, "body") |
| 217 | ?? schemaProperty(resolvedTyping.inputSchema, "input") | ||
| 134 | ?? (resolvedRequestBodySchema !== undefined && resolvedRequestBodySchema !== null | 218 | ?? (resolvedRequestBodySchema !== undefined && resolvedRequestBodySchema !== null |
| 135 | ? resolvedRequestBodySchema | 219 | ? resolvedRequestBodySchema |
| 136 | : undefined); | 220 | : undefined); |
| 137 | 221 | ||
| 138 | const inputSchema = | 222 | const inputSchema = callInputSchemaFromInvocation({ |
| 139 | input.definition.invocation.requestBody !== null | 223 | invocation: input.definition.invocation, |
| 140 | ? callInputSchemaFromInvocation({ | 224 | documentation: input.definition.documentation, |
| 141 | invocation: input.definition.invocation, | 225 | parameterSourceSchema: resolvedTyping.inputSchema, |
| 142 | ...(requestBodySchema !== undefined | 226 | ...(requestBodySchema !== undefined |
| 143 | ? { requestBodySchema } | 227 | ? { requestBodySchema } |
| 144 | : {}), | 228 | : {}), |
| 145 | }) | 229 | }) |
| 146 | : resolvedTyping.inputSchema | 230 | ?? resolvedTyping.inputSchema |
| 147 | ?? callInputSchemaFromInvocation({ | 231 | ?? undefined; |
| 148 | invocation: input.definition.invocation, | ||
| 149 | }); | ||
| 150 | const outputSchema = | 232 | const outputSchema = |
| 151 | resolvedTyping.outputSchema | 233 | resolvedTyping.outputSchema |
| 152 | ?? resolveSchemaWithRefHints( | 234 | ?? resolveSchemaWithRefHints( |
| @@ -160,6 +242,27 @@ const resolvePresentationSchemas = (input: { · 7 unmodified lines | |||
| 160 | }; | 242 | }; |
| 161 | }; | 243 | }; |
| 162 | 244 | ||
| 245 | const resolveResponseVariantsWithRefHints = (input: { | ||
| 246 | variants: ReadonlyArray<OpenApiResponseVariant> | undefined; | ||
| 247 | refHintTable?: Readonly<OpenApiRefHintTable>; | ||
| 248 | }): ReadonlyArray<OpenApiResponseVariant> | undefined => { | ||
| 249 | if (!input.variants || input.variants.length === 0) { | ||
| 250 | return undefined; | ||
| 251 | } | ||
| 252 | |||
| 253 | return input.variants.map((variant) => { | ||
| 254 | const resolvedSchema = resolveSchemaWithRefHints( | ||
| 255 | variant.schema, | ||
| 256 | input.refHintTable, | ||
| 257 | ); | ||
| 258 | |||
| 259 | return { | ||
| 260 | ...variant, | ||
| 261 | ...(resolvedSchema !== undefined ? { schema: resolvedSchema } : {}), | ||
| 262 | }; | ||
| 263 | }); | ||
| 264 | }; | ||
| 265 | |||
| 163 | const buildExampleInput = ( | 266 | const buildExampleInput = ( |
| 164 | documentation: OpenApiToolDocumentation | undefined, | 267 | documentation: OpenApiToolDocumentation | undefined, |
| 165 | ): Record<string, unknown> | undefined => { | 268 | ): Record<string, unknown> | undefined => { |
| @@ -208,6 +311,10 @@ export const buildOpenApiToolPresentation = (input: { · 42 unmodified lines | |||
| 208 | refHintTable?: Readonly<OpenApiRefHintTable>; | 311 | refHintTable?: Readonly<OpenApiRefHintTable>; |
| 209 | }): OpenApiToolPresentation => { | 312 | }): OpenApiToolPresentation => { |
| 210 | const { inputSchema, outputSchema } = resolvePresentationSchemas(input); | 313 | const { inputSchema, outputSchema } = resolvePresentationSchemas(input); |
| 314 | const responses = resolveResponseVariantsWithRefHints({ | ||
| 315 | variants: input.definition.responses, | ||
| 316 | refHintTable: input.refHintTable, | ||
| 317 | }); | ||
| 211 | const exampleInput = buildExampleInput(input.definition.documentation); | 318 | const exampleInput = buildExampleInput(input.definition.documentation); |
| 212 | const exampleOutput = buildExampleOutput(input.definition.documentation); | 319 | const exampleOutput = buildExampleOutput(input.definition.documentation); |
| 213 | 320 | ||
| @@ -218,6 +325,9 @@ export const buildOpenApiToolPresentation = (input: { · 4 unmodified lines | |||
| 218 | ...(outputSchema !== undefined ? { outputSchema } : {}), | 325 | ...(outputSchema !== undefined ? { outputSchema } : {}), |
| 219 | ...(exampleInput !== undefined ? { exampleInput } : {}), | 326 | ...(exampleInput !== undefined ? { exampleInput } : {}), |
| 220 | ...(exampleOutput !== undefined ? { exampleOutput } : {}), | 327 | ...(exampleOutput !== undefined ? { exampleOutput } : {}), |
| 221 | providerData: openApiProviderDataFromDefinition(input.definition), | 328 | providerData: { |
| 329 | ...openApiProviderDataFromDefinition(input.definition), | ||
| 330 | ...(responses ? { responses } : {}), | ||
| 331 | } satisfies OpenApiToolProviderData, | ||
| 222 | }; | 332 | }; |
| 223 | }; | 333 | }; |
| @@ -296,6 +296,315 @@ describe("openapi-tools", () => { · 295 unmodified lines | |||
| 296 | }), | 296 | }), |
| 297 | ); | 297 | ); |
| 298 | 298 | ||
| 299 | it.effect("enriches extracted manifests with OpenAPI auth and response variants", () => | ||
| 300 | Effect.gen(function* () { | ||
| 301 | const manifest = yield* extractOpenApiManifest("secure-demo", { | ||
| 302 | openapi: "3.1.0", | ||
| 303 | info: { | ||
| 304 | title: "Secure Demo", | ||
| 305 | version: "1.0.0", | ||
| 306 | }, | ||
| 307 | security: [{ | ||
| 308 | bearerToken: [], | ||
| 309 | }], | ||
| 310 | paths: { | ||
| 311 | "/projects/{id}": { | ||
| 312 | get: { | ||
| 313 | operationId: "projects.getProject", | ||
| 314 | security: [], | ||
| 315 | parameters: [{ | ||
| 316 | name: "id", | ||
| 317 | in: "path", | ||
| 318 | required: true, | ||
| 319 | schema: { | ||
| 320 | type: "string", | ||
| 321 | }, | ||
| 322 | }], | ||
| 323 | responses: { | ||
| 324 | "200": { | ||
| 325 | description: "Project", | ||
| 326 | content: { | ||
| 327 | "application/json": { | ||
| 328 | schema: { | ||
| 329 | $ref: "#/$defs/Project", | ||
| 330 | }, | ||
| 331 | }, | ||
| 332 | }, | ||
| 333 | }, | ||
| 334 | "404": { | ||
| 335 | description: "Missing", | ||
| 336 | content: { | ||
| 337 | "application/json": { | ||
| 338 | schema: { | ||
| 339 | $ref: "#/$defs/Missing", | ||
| 340 | }, | ||
| 341 | }, | ||
| 342 | }, | ||
| 343 | }, | ||
| 344 | default: { | ||
| 345 | description: "Unexpected", | ||
| 346 | content: { | ||
| 347 | "application/json": { | ||
| 348 | schema: { | ||
| 349 | type: "object", | ||
| 350 | properties: { | ||
| 351 | error: { | ||
| 352 | type: "string", | ||
| 353 | }, | ||
| 354 | }, | ||
| 355 | }, | ||
| 356 | }, | ||
| 357 | }, | ||
| 358 | }, | ||
| 359 | }, | ||
| 360 | }, | ||
| 361 | }, | ||
| 362 | "/teams": { | ||
| 363 | get: { | ||
| 364 | operationId: "teams.listTeams", | ||
| 365 | security: [ | ||
| 366 | { bearerToken: [] }, | ||
| 367 | { apiKeyHeader: [] }, | ||
| 368 | ], | ||
| 369 | responses: { | ||
| 370 | "200": { | ||
| 371 | description: "Teams", | ||
| 372 | content: { | ||
| 373 | "application/json": { | ||
| 374 | schema: { | ||
| 375 | type: "object", | ||
| 376 | properties: { | ||
| 377 | teams: { | ||
| 378 | type: "array", | ||
| 379 | items: { | ||
| 380 | type: "string", | ||
| 381 | }, | ||
| 382 | }, | ||
| 383 | }, | ||
| 384 | }, | ||
| 385 | }, | ||
| 386 | }, | ||
| 387 | }, | ||
| 388 | }, | ||
| 389 | }, | ||
| 390 | }, | ||
| 391 | }, | ||
| 392 | components: { | ||
| 393 | securitySchemes: { | ||
| 394 | bearerToken: { | ||
| 395 | type: "http", | ||
| 396 | scheme: "bearer", | ||
| 397 | bearerFormat: "JWT", | ||
| 398 | description: "Bearer access token.", | ||
| 399 | }, | ||
| 400 | apiKeyHeader: { | ||
| 401 | type: "apiKey", | ||
| 402 | in: "header", | ||
| 403 | name: "x-api-key", | ||
| 404 | }, | ||
| 405 | }, | ||
| 406 | }, | ||
| 407 | $defs: { | ||
| 408 | Project: { | ||
| 409 | type: "object", | ||
| 410 | properties: { | ||
| 411 | id: { | ||
| 412 | type: "string", | ||
| 413 | }, | ||
| 414 | }, | ||
| 415 | required: ["id"], | ||
| 416 | }, | ||
| 417 | Missing: { | ||
| 418 | type: "object", | ||
| 419 | properties: { | ||
| 420 | code: { | ||
| 421 | type: "string", | ||
| 422 | }, | ||
| 423 | }, | ||
| 424 | required: ["code"], | ||
| 425 | }, | ||
| 426 | }, | ||
| 427 | }); | ||
| 428 | |||
| 429 | const projectTool = manifest.tools.find((tool) => tool.toolId === "projects.getProject"); | ||
| 430 | const teamsTool = manifest.tools.find((tool) => tool.toolId === "teams.listTeams"); | ||
| 431 | |||
| 432 | expect(projectTool?.authRequirement).toEqual({ | ||
| 433 | kind: "none", | ||
| 434 | }); | ||
| 435 | expect(projectTool?.responses?.map((response) => response.statusCode)).toEqual([ | ||
| 436 | "200", | ||
| 437 | "default", | ||
| 438 | "404", | ||
| 439 | ]); | ||
| 440 | expect(projectTool?.responses?.find((response) => response.statusCode === "404")?.schema).toMatchObject({ | ||
| 441 | $ref: "#/$defs/Missing", | ||
| 442 | }); | ||
| 443 | expect(teamsTool?.authRequirement).toMatchObject({ | ||
| 444 | kind: "anyOf", | ||
| 445 | }); | ||
| 446 | expect(teamsTool?.securitySchemes).toMatchObject([ | ||
| 447 | { | ||
| 448 | schemeName: "apiKeyHeader", | ||
| 449 | schemeType: "apiKey", | ||
| 450 | placementIn: "header", | ||
| 451 | placementName: "x-api-key", | ||
| 452 | }, | ||
| 453 | { | ||
| 454 | schemeName: "bearerToken", | ||
| 455 | schemeType: "http", | ||
| 456 | scheme: "bearer", | ||
| 457 | bearerFormat: "JWT", | ||
| 458 | }, | ||
| 459 | ]); | ||
| 460 | expect(Object.keys(manifest.refHintTable ?? {})).toEqual( | ||
| 461 | expect.arrayContaining([ | ||
| 462 | "#/$defs/Project", | ||
| 463 | "#/$defs/Missing", | ||
| 464 | ]), | ||
| 465 | ); | ||
| 466 | }), | ||
| 467 | ); | ||
| 468 | |||
| 469 | it.effect("preserves OpenAPI servers, parameter serialization, and response headers", () => | ||
| 470 | Effect.gen(function* () { | ||
| 471 | const manifest = yield* extractOpenApiManifest("serialized-demo", { | ||
| 472 | openapi: "3.1.0", | ||
| 473 | info: { | ||
| 474 | title: "Serialized Demo", | ||
| 475 | version: "1.0.0", | ||
| 476 | }, | ||
| 477 | servers: [{ | ||
| 478 | url: "https://api.example.test/{version}", | ||
| 479 | variables: { | ||
| 480 | version: { | ||
| 481 | default: "v1", | ||
| 482 | }, | ||
| 483 | }, | ||
| 484 | }], | ||
| 485 | paths: { | ||
| 486 | "/items/{itemId}": { | ||
| 487 | servers: [{ | ||
| 488 | url: "https://regional.example.test/base", | ||
| 489 | }], | ||
| 490 | get: { | ||
| 491 | operationId: "items.getItem", | ||
| 492 | parameters: [ | ||
| 493 | { | ||
| 494 | name: "itemId", | ||
| 495 | in: "path", | ||
| 496 | required: true, | ||
| 497 | style: "label", | ||
| 498 | explode: true, | ||
| 499 | schema: { | ||
| 500 | type: "array", | ||
| 501 | items: { | ||
| 502 | type: "string", | ||
| 503 | }, | ||
| 504 | }, | ||
| 505 | }, | ||
| 506 | { | ||
| 507 | name: "filter", | ||
| 508 | in: "query", | ||
| 509 | style: "deepObject", | ||
| 510 | explode: true, | ||
| 511 | schema: { | ||
| 512 | type: "object", | ||
| 513 | additionalProperties: { | ||
| 514 | type: "string", | ||
| 515 | }, | ||
| 516 | }, | ||
| 517 | }, | ||
| 518 | { | ||
| 519 | name: "search", | ||
| 520 | in: "query", | ||
| 521 | allowReserved: true, | ||
| 522 | schema: { | ||
| 523 | type: "string", | ||
| 524 | }, | ||
| 525 | }, | ||
| 526 | ], | ||
| 527 | responses: { | ||
| 528 | "200": { | ||
| 529 | description: "Item", | ||
| 530 | headers: { | ||
| 531 | "x-next-cursor": { | ||
| 532 | description: "Continuation cursor.", | ||
| 533 | schema: { | ||
| 534 | type: "string", | ||
| 535 | }, | ||
| 536 | }, | ||
| 537 | }, | ||
| 538 | content: { | ||
| 539 | "application/json": { | ||
| 540 | schema: { | ||
| 541 | type: "object", | ||
| 542 | properties: { | ||
| 543 | id: { | ||
| 544 | type: "string", | ||
| 545 | }, | ||
| 546 | }, | ||
| 547 | required: ["id"], | ||
| 548 | }, | ||
| 549 | }, | ||
| 550 | "text/plain": { | ||
| 551 | schema: { | ||
| 552 | type: "string", | ||
| 553 | }, | ||
| 554 | }, | ||
| 555 | }, | ||
| 556 | }, | ||
| 557 | }, | ||
| 558 | }, | ||
| 559 | }, | ||
| 560 | }, | ||
| 561 | }); | ||
| 562 | |||
| 563 | const tool = manifest.tools.find((candidate) => candidate.toolId === "items.getItem"); | ||
| 564 | |||
| 565 | expect(tool?.documentServers).toMatchObject([ | ||
| 566 | { | ||
| 567 | url: "https://api.example.test/{version}", | ||
| 568 | variables: { | ||
| 569 | version: "v1", | ||
| 570 | }, | ||
| 571 | }, | ||
| 572 | ]); | ||
| 573 | expect(tool?.servers).toMatchObject([ | ||
| 574 | { | ||
| 575 | url: "https://regional.example.test/base", | ||
| 576 | }, | ||
| 577 | ]); | ||
| 578 | expect(tool?.invocation.parameters).toMatchObject([ | ||
| 579 | { | ||
| 580 | name: "itemId", | ||
| 581 | style: "label", | ||
| 582 | explode: true, | ||
| 583 | }, | ||
| 584 | { | ||
| 585 | name: "filter", | ||
| 586 | style: "deepObject", | ||
| 587 | explode: true, | ||
| 588 | }, | ||
| 589 | { | ||
| 590 | name: "search", | ||
| 591 | allowReserved: true, | ||
| 592 | }, | ||
| 593 | ]); | ||
| 594 | expect(tool?.responses?.[0]).toMatchObject({ | ||
| 595 | statusCode: "200", | ||
| 596 | contentTypes: ["application/json", "text/plain"], | ||
| 597 | headers: [ | ||
| 598 | { | ||
| 599 | name: "x-next-cursor", | ||
| 600 | description: "Continuation cursor.", | ||
| 601 | }, | ||
| 602 | ], | ||
| 603 | }); | ||
| 604 | expect(tool?.responses?.[0]?.contents).toHaveLength(2); | ||
| 605 | }), | ||
| 606 | ); | ||
| 607 | |||
| 299 | it.scoped("accepts OpenApi.fromApi generated specs", () => | 608 | it.scoped("accepts OpenApi.fromApi generated specs", () => |
| 300 | Effect.gen(function* () { | 609 | Effect.gen(function* () { |
| 301 | const server = yield* makeTestServer; | 610 | const server = yield* makeTestServer; |
| @@ -396,6 +705,229 @@ describe("openapi-tools", () => { · 94 unmodified lines | |||
| 396 | }), | 705 | }), |
| 397 | ); | 706 | ); |
| 398 | 707 | ||
| 708 | it.scoped("serializes OpenAPI styles, allowReserved, and form bodies when invoking tools", () => | ||
| 709 | Effect.gen(function* () { | ||
| 710 | const requests: TestServer["requests"] = []; | ||
| 711 | const server = yield* Effect.acquireRelease( | ||
| 712 | Effect.promise<TestServer>( | ||
| 713 | () => | ||
| 714 | new Promise<TestServer>((resolve, reject) => { | ||
| 715 | const httpServer = createServer((request, response) => { | ||
| 716 | const url = new URL( | ||
| 717 | request.url ?? "/", | ||
| 718 | `http://${request.headers.host ?? "127.0.0.1"}`, | ||
| 719 | ); | ||
| 720 | let body = ""; | ||
| 721 | |||
| 722 | request.setEncoding("utf8"); | ||
| 723 | request.on("data", (chunk) => { | ||
| 724 | body += chunk; | ||
| 725 | }); | ||
| 726 | request.on("end", () => { | ||
| 727 | requests.push({ | ||
| 728 | method: request.method ?? "GET", | ||
| 729 | path: url.pathname, | ||
| 730 | query: url.search, | ||
| 731 | body, | ||
| 732 | headers: requestHeadersFromNode(request), | ||
| 733 | }); | ||
| 734 | response.statusCode = 200; | ||
| 735 | response.setHeader("content-type", "application/json"); | ||
| 736 | response.end(JSON.stringify({ ok: true })); | ||
| 737 | }); | ||
| 738 | }); | ||
| 739 | |||
| 740 | httpServer.once("error", reject); | ||
| 741 | httpServer.listen(0, "127.0.0.1", () => { | ||
| 742 | const address = httpServer.address(); | ||
| 743 | if (!address || typeof address === "string") { | ||
| 744 | reject(new Error("failed to resolve serialization test server address")); | ||
| 745 | return; | ||
| 746 | } | ||
| 747 | |||
| 748 | resolve({ | ||
| 749 | baseUrl: `http://127.0.0.1:${address.port}`, | ||
| 750 | requests, | ||
| 751 | close: async () => { | ||
| 752 | await new Promise<void>((closeResolve, closeReject) => { | ||
| 753 | httpServer.close((error: Error | undefined) => { | ||
| 754 | if (error) { | ||
| 755 | closeReject(error); | ||
| 756 | return; | ||
| 757 | } | ||
| 758 | closeResolve(); | ||
| 759 | }); | ||
| 760 | }); | ||
| 761 | }, | ||
| 762 | }); | ||
| 763 | }); | ||
| 764 | }), | ||
| 765 | ), | ||
| 766 | (resource) => | ||
| 767 | Effect.tryPromise({ | ||
| 768 | try: () => resource.close(), | ||
| 769 | catch: (error: unknown) => | ||
| 770 | error instanceof Error ? error : new Error(String(error)), | ||
| 771 | }).pipe(Effect.orDie), | ||
| 772 | ); | ||
| 773 | |||
| 774 | const extracted = yield* createOpenApiToolsFromSpec({ | ||
| 775 | sourceName: "serialized-demo", | ||
| 776 | openApiSpec: { | ||
| 777 | openapi: "3.1.0", | ||
| 778 | info: { | ||
| 779 | title: "Serialized Demo", | ||
| 780 | version: "1.0.0", | ||
| 781 | }, | ||
| 782 | paths: { | ||
| 783 | "/items/{itemId}": { | ||
| 784 | get: { | ||
| 785 | operationId: "items.getItem", | ||
| 786 | parameters: [ | ||
| 787 | { | ||
| 788 | name: "itemId", | ||
| 789 | in: "path", | ||
| 790 | required: true, | ||
| 791 | style: "label", | ||
| 792 | explode: true, | ||
| 793 | schema: { | ||
| 794 | type: "array", | ||
| 795 | items: { | ||
| 796 | type: "string", | ||
| 797 | }, | ||
| 798 | }, | ||
| 799 | }, | ||
| 800 | { | ||
| 801 | name: "filter", | ||
| 802 | in: "query", | ||
| 803 | style: "deepObject", | ||
| 804 | explode: true, | ||
| 805 | schema: { | ||
| 806 | type: "object", | ||
| 807 | additionalProperties: { | ||
| 808 | type: "string", | ||
| 809 | }, | ||
| 810 | }, | ||
| 811 | }, | ||
| 812 | { | ||
| 813 | name: "search", | ||
| 814 | in: "query", | ||
| 815 | allowReserved: true, | ||
| 816 | schema: { | ||
| 817 | type: "string", | ||
| 818 | }, | ||
| 819 | }, | ||
| 820 | { | ||
| 821 | name: "X-Trace", | ||
| 822 | in: "header", | ||
| 823 | style: "simple", | ||
| 824 | explode: false, | ||
| 825 | schema: { | ||
| 826 | type: "array", | ||
| 827 | items: { | ||
| 828 | type: "string", | ||
| 829 | }, | ||
| 830 | }, | ||
| 831 | }, | ||
| 832 | ], | ||
| 833 | responses: { | ||
| 834 | "200": { | ||
| 835 | description: "ok", | ||
| 836 | content: { | ||
| 837 | "application/json": { | ||
| 838 | schema: { | ||
| 839 | type: "object", | ||
| 840 | properties: { | ||
| 841 | ok: { | ||
| 842 | type: "boolean", | ||
| 843 | }, | ||
| 844 | }, | ||
| 845 | }, | ||
| 846 | }, | ||
| 847 | }, | ||
| 848 | }, | ||
| 849 | }, | ||
| 850 | }, | ||
| 851 | }, | ||
| 852 | "/forms": { | ||
| 853 | post: { | ||
| 854 | operationId: "forms.submit", | ||
| 855 | requestBody: { | ||
| 856 | required: true, | ||
| 857 | content: { | ||
| 858 | "application/x-www-form-urlencoded": { | ||
| 859 | schema: { | ||
| 860 | type: "object", | ||
| 861 | properties: { | ||
| 862 | title: { | ||
| 863 | type: "string", | ||
| 864 | }, | ||
| 865 | state: { | ||
| 866 | type: "string", | ||
| 867 | }, | ||
| 868 | }, | ||
| 869 | required: ["title"], | ||
| 870 | }, | ||
| 871 | }, | ||
| 872 | }, | ||
| 873 | }, | ||
| 874 | responses: { | ||
| 875 | "200": { | ||
| 876 | description: "ok", | ||
| 877 | content: { | ||
| 878 | "application/json": { | ||
| 879 | schema: { | ||
| 880 | type: "object", | ||
| 881 | properties: { | ||
| 882 | ok: { | ||
| 883 | type: "boolean", | ||
| 884 | }, | ||
| 885 | }, | ||
| 886 | }, | ||
| 887 | }, | ||
| 888 | }, | ||
| 889 | }, | ||
| 890 | }, | ||
| 891 | }, | ||
| 892 | }, | ||
| 893 | }, | ||
| 894 | }, | ||
| 895 | baseUrl: server.baseUrl, | ||
| 896 | namespace: "source.serialized", | ||
| 897 | }); | ||
| 898 | |||
| 899 | const getItem = resolveToolExecutor(extracted.tools, "source.serialized.items.getItem"); | ||
| 900 | const submitForm = resolveToolExecutor(extracted.tools, "source.serialized.forms.submit"); | ||
| 901 | |||
| 902 | yield* Effect.promise(() => | ||
| 903 | getItem({ | ||
| 904 | itemId: ["alpha", "beta"], | ||
| 905 | filter: { | ||
| 906 | status: "open", | ||
| 907 | }, | ||
| 908 | search: "refs/heads/main?draft=true", | ||
| 909 | "X-Trace": ["a", "b"], | ||
| 910 | }), | ||
| 911 | ); | ||
| 912 | yield* Effect.promise(() => | ||
| 913 | submitForm({ | ||
| 914 | body: { | ||
| 915 | title: "Bug report", | ||
| 916 | state: "open", | ||
| 917 | }, | ||
| 918 | }), | ||
| 919 | ); | ||
| 920 | |||
| 921 | expect(requests[0]?.path).toBe("/items/.alpha.beta"); | ||
| 922 | expect(requests[0]?.query).toContain("filter%5Bstatus%5D=open"); | ||
| 923 | expect(requests[0]?.query).toContain("search=refs/heads/main?draft=true"); | ||
| 924 | expect(requests[0]?.headers["x-trace"]).toBe("a,b"); | ||
| 925 | expect(requests[1]?.headers["content-type"]).toContain("application/x-www-form-urlencoded"); | ||
| 926 | expect(requests[1]?.body).toContain("title=Bug+report"); | ||
| 927 | expect(requests[1]?.body).toContain("state=open"); | ||
| 928 | }), | ||
| 929 | ); | ||
| 930 | |||
| 399 | it.effect("throws on non-2xx OpenAPI responses", () => | 931 | it.effect("throws on non-2xx OpenAPI responses", () => |
| 400 | Effect.gen(function* () { | 932 | Effect.gen(function* () { |
| 401 | const manifest = yield* extractOpenApiManifest("generated", generatedOpenApiSpec); | 933 | const manifest = yield* extractOpenApiManifest("generated", generatedOpenApiSpec); |
| @@ -22,6 +22,12 @@ import { · 21 unmodified lines | |||
| 22 | compileOpenApiToolDefinitions, | 22 | compileOpenApiToolDefinitions, |
| 23 | type OpenApiToolDefinition, | 23 | type OpenApiToolDefinition, |
| 24 | } from "./openapi-definitions"; | 24 | } from "./openapi-definitions"; |
| 25 | import { | ||
| 26 | serializeOpenApiParameterValue, | ||
| 27 | serializeOpenApiRequestBody, | ||
| 28 | withSerializedQueryEntries, | ||
| 29 | type SerializedOpenApiQueryEntry, | ||
| 30 | } from "./openapi-http-serialization"; | ||
| 25 | import { buildOpenApiToolPresentation } from "./openapi-tool-presentation"; | 31 | import { buildOpenApiToolPresentation } from "./openapi-tool-presentation"; |
| 26 | import { resolveSchemaWithRefHints } from "./openapi-schema-refs"; | 32 | import { resolveSchemaWithRefHints } from "./openapi-schema-refs"; |
| 27 | import { | 33 | import { |
| @@ -67,22 +73,6 @@ const parameterContainerKeys: Record< · 39 unmodified lines | |||
| 67 | cookie: ["cookies", "cookie"], | 73 | cookie: ["cookies", "cookie"], |
| 68 | }; | 74 | }; |
| 69 | 75 | ||
| 70 | const argsValueToString = (value: unknown): string => { | ||
| 71 | if (typeof value === "string") { | ||
| 72 | return value; | ||
| 73 | } | ||
| 74 | |||
| 75 | if ( | ||
| 76 | typeof value === "number" | ||
| 77 | || typeof value === "boolean" | ||
| 78 | || typeof value === "bigint" | ||
| 79 | ) { | ||
| 80 | return String(value); | ||
| 81 | } | ||
| 82 | |||
| 83 | return String(value); | ||
| 84 | }; | ||
| 85 | |||
| 86 | const readParameterValue = ( | 76 | const readParameterValue = ( |
| 87 | args: OpenApiToolArgs, | 77 | args: OpenApiToolArgs, |
| 88 | parameter: OpenApiToolParameter, | 78 | parameter: OpenApiToolParameter, |
| @@ -136,9 +126,12 @@ const replacePathTemplate = ( · 47 unmodified lines | |||
| 136 | continue; | 126 | continue; |
| 137 | } | 127 | } |
| 138 | 128 | ||
| 129 | const serialized = serializeOpenApiParameterValue(parameter, parameterValue); | ||
| 139 | resolvedPath = resolvedPath.replaceAll( | 130 | resolvedPath = resolvedPath.replaceAll( |
| 140 | `{${parameter.name}}`, | 131 | `{${parameter.name}}`, |
| 141 | encodeURIComponent(String(parameterValue)), | 132 | serialized.kind === "path" |
| 133 | ? serialized.value | ||
| 134 | : encodeURIComponent(String(parameterValue)), | ||
| 142 | ); | 135 | ); |
| 143 | } | 136 | } |
| 144 | 137 | ||
| @@ -324,7 +317,7 @@ const buildFetchRequest = (input: { · 179 unmodified lines | |||
| 324 | defaultHeaders: Readonly<Record<string, string>>; | 317 | defaultHeaders: Readonly<Record<string, string>>; |
| 325 | credentialPlacements: HttpRequestPlacements; | 318 | credentialPlacements: HttpRequestPlacements; |
| 326 | }): { | 319 | }): { |
| 327 | url: URL; | 320 | url: string; |
| 328 | method: string; | 321 | method: string; |
| 329 | headers: Record<string, string>; | 322 | headers: Record<string, string>; |
| 330 | body?: string; | 323 | body?: string; |
| @@ -340,6 +333,7 @@ const buildFetchRequest = (input: { · 9 unmodified lines | |||
| 340 | ...input.defaultHeaders, | 333 | ...input.defaultHeaders, |
| 341 | }; | 334 | }; |
| 342 | const cookieParts: Array<string> = []; | 335 | const cookieParts: Array<string> = []; |
| 336 | const queryEntries: SerializedOpenApiQueryEntry[] = []; | ||
| 343 | 337 | ||
| 344 | for (const parameter of input.payload.parameters) { | 338 | for (const parameter of input.payload.parameters) { |
| 345 | if (parameter.location === "path") { | 339 | if (parameter.location === "path") { |
| @@ -358,14 +352,18 @@ const buildFetchRequest = (input: { · 12 unmodified lines | |||
| 358 | continue; | 352 | continue; |
| 359 | } | 353 | } |
| 360 | 354 | ||
| 361 | const encoded = argsValueToString(parameterValue); | 355 | const serialized = serializeOpenApiParameterValue(parameter, parameterValue); |
| 362 | 356 | ||
| 363 | if (parameter.location === "query") { | 357 | if (serialized.kind === "query") { |
| 364 | url.searchParams.set(parameter.name, encoded); | 358 | queryEntries.push(...serialized.entries); |
| 365 | } else if (parameter.location === "header") { | 359 | } else if (serialized.kind === "header") { |
| 366 | headers[parameter.name] = encoded; | 360 | headers[parameter.name] = serialized.value; |
| 367 | } else if (parameter.location === "cookie") { | 361 | } else if (serialized.kind === "cookie") { |
| 368 | cookieParts.push(`${parameter.name}=${encodeURIComponent(encoded)}`); | 362 | cookieParts.push( |
| 363 | ...serialized.pairs.map((pair) => | ||
| 364 | `${pair.name}=${encodeURIComponent(pair.value)}` | ||
| 365 | ), | ||
| 366 | ); | ||
| 369 | } | 367 | } |
| 370 | } | 368 | } |
| 371 | 369 | ||
| @@ -387,20 +385,17 @@ const buildFetchRequest = (input: { · 15 unmodified lines | |||
| 387 | }); | 385 | }); |
| 388 | } | 386 | } |
| 389 | } else { | 387 | } else { |
| 390 | body = JSON.stringify( | 388 | const serializedBody = serializeOpenApiRequestBody({ |
| 391 | applyJsonBodyPlacements({ | 389 | requestBody: input.payload.requestBody, |
| 390 | body: applyJsonBodyPlacements({ | ||
| 392 | body: hasRequestBody(input.args) ? input.args.body : {}, | 391 | body: hasRequestBody(input.args) ? input.args.body : {}, |
| 393 | bodyValues, | 392 | bodyValues, |
| 394 | label: `${input.payload.method.toUpperCase()} ${input.payload.pathTemplate}`, | 393 | label: `${input.payload.method.toUpperCase()} ${input.payload.pathTemplate}`, |
| 395 | }), | 394 | }), |
| 396 | ); | 395 | }); |
| 397 | 396 | ||
| 398 | const preferredContentType = input.payload.requestBody.contentTypes[0]; | 397 | body = serializedBody.bodyText; |
| 399 | if (preferredContentType) { | 398 | headers["content-type"] = serializedBody.contentType; |
| 400 | headers["content-type"] = preferredContentType; | ||
| 401 | } else if (!("content-type" in headers)) { | ||
| 402 | headers["content-type"] = "application/json"; | ||
| 403 | } | ||
| 404 | } | 399 | } |
| 405 | } | 400 | } |
| 406 | 401 | ||
| @@ -408,6 +403,7 @@ const buildFetchRequest = (input: { · 1 unmodified line | |||
| 408 | url, | 403 | url, |
| 409 | queryParams: input.credentialPlacements.queryParams, | 404 | queryParams: input.credentialPlacements.queryParams, |
| 410 | }); | 405 | }); |
| 406 | const urlWithQueryParams = withSerializedQueryEntries(urlWithAuth, queryEntries); | ||
| 411 | const headersWithAuthCookies = applyCookiePlacementsToHeaders({ | 407 | const headersWithAuthCookies = applyCookiePlacementsToHeaders({ |
| 412 | headers, | 408 | headers, |
| 413 | cookies: input.credentialPlacements.cookies, | 409 | cookies: input.credentialPlacements.cookies, |
| @@ -418,7 +414,7 @@ const buildFetchRequest = (input: { · 4 unmodified lines | |||
| 418 | } | 414 | } |
| 419 | 415 | ||
| 420 | return { | 416 | return { |
| 421 | url: urlWithAuth, | 417 | url: urlWithQueryParams, |
| 422 | method: input.payload.method.toUpperCase(), | 418 | method: input.payload.method.toUpperCase(), |
| 423 | headers: headersWithAuthCookies, | 419 | headers: headersWithAuthCookies, |
| 424 | body, | 420 | body, |
| @@ -43,11 +43,45 @@ export const OpenApiToolParameterSchema = Schema.Struct({ · 42 unmodified lines | |||
| 43 | name: Schema.String, | 43 | name: Schema.String, |
| 44 | location: OpenApiParameterLocationSchema, | 44 | location: OpenApiParameterLocationSchema, |
| 45 | required: Schema.Boolean, | 45 | required: Schema.Boolean, |
| 46 | style: Schema.optional(Schema.String), | ||
| 47 | explode: Schema.optional(Schema.Boolean), | ||
| 48 | allowReserved: Schema.optional(Schema.Boolean), | ||
| 49 | content: Schema.optional(Schema.Array(Schema.suspend(() => OpenApiMediaContentSchema))), | ||
| 50 | }); | ||
| 51 | |||
| 52 | export const OpenApiMediaContentSchema = Schema.Struct({ | ||
| 53 | mediaType: Schema.String, | ||
| 54 | schema: Schema.optional(Schema.Unknown), | ||
| 55 | examples: Schema.optional(Schema.Array(Schema.suspend(() => OpenApiExampleSchema))), | ||
| 56 | }); | ||
| 57 | |||
| 58 | export const OpenApiHeaderSchema = Schema.Struct({ | ||
| 59 | name: Schema.String, | ||
| 60 | description: Schema.optional(Schema.String), | ||
| 61 | required: Schema.optional(Schema.Boolean), | ||
| 62 | deprecated: Schema.optional(Schema.Boolean), | ||
| 63 | schema: Schema.optional(Schema.Unknown), | ||
| 64 | content: Schema.optional(Schema.Array(OpenApiMediaContentSchema)), | ||
| 65 | style: Schema.optional(Schema.String), | ||
| 66 | explode: Schema.optional(Schema.Boolean), | ||
| 67 | examples: Schema.optional(Schema.Array(Schema.suspend(() => OpenApiExampleSchema))), | ||
| 46 | }); | 68 | }); |
| 47 | 69 | ||
| 48 | export const OpenApiToolRequestBodySchema = Schema.Struct({ | 70 | export const OpenApiToolRequestBodySchema = Schema.Struct({ |
| 49 | required: Schema.Boolean, | 71 | required: Schema.Boolean, |
| 50 | contentTypes: Schema.Array(Schema.String), | 72 | contentTypes: Schema.Array(Schema.String), |
| 73 | contents: Schema.optional(Schema.Array(OpenApiMediaContentSchema)), | ||
| 74 | }); | ||
| 75 | |||
| 76 | export const OpenApiServerSchema = Schema.Struct({ | ||
| 77 | url: Schema.String, | ||
| 78 | description: Schema.optional(Schema.String), | ||
| 79 | variables: Schema.optional( | ||
| 80 | Schema.Record({ | ||
| 81 | key: Schema.String, | ||
| 82 | value: Schema.String, | ||
| 83 | }), | ||
| 84 | ), | ||
| 51 | }); | 85 | }); |
| 52 | 86 | ||
| 53 | export const OpenApiInvocationPayloadSchema = Schema.Struct({ | 87 | export const OpenApiInvocationPayloadSchema = Schema.Struct({ |
| @@ -102,6 +136,16 @@ export const OpenApiResponseDocumentationSchema = Schema.Struct({ · 48 unmodified lines | |||
| 102 | examples: Schema.optional(Schema.Array(OpenApiExampleSchema)), | 136 | examples: Schema.optional(Schema.Array(OpenApiExampleSchema)), |
| 103 | }); | 137 | }); |
| 104 | 138 | ||
| 139 | export const OpenApiResponseVariantSchema = Schema.Struct({ | ||
| 140 | statusCode: Schema.String, | ||
| 141 | description: Schema.optional(Schema.String), | ||
| 142 | contentTypes: Schema.Array(Schema.String), | ||
| 143 | schema: Schema.optional(Schema.Unknown), | ||
| 144 | examples: Schema.optional(Schema.Array(OpenApiExampleSchema)), | ||
| 145 | contents: Schema.optional(Schema.Array(OpenApiMediaContentSchema)), | ||
| 146 | headers: Schema.optional(Schema.Array(OpenApiHeaderSchema)), | ||
| 147 | }); | ||
| 148 | |||
| 105 | export const OpenApiToolDocumentationSchema = Schema.Struct({ | 149 | export const OpenApiToolDocumentationSchema = Schema.Struct({ |
| 106 | summary: Schema.optional(Schema.String), | 150 | summary: Schema.optional(Schema.String), |
| 107 | deprecated: Schema.optional(Schema.Boolean), | 151 | deprecated: Schema.optional(Schema.Boolean), |
| @@ -110,6 +154,81 @@ export const OpenApiToolDocumentationSchema = Schema.Struct({ · 2 unmodified lines | |||
| 110 | response: Schema.optional(OpenApiResponseDocumentationSchema), | 154 | response: Schema.optional(OpenApiResponseDocumentationSchema), |
| 111 | }); | 155 | }); |
| 112 | 156 | ||
| 157 | export type OpenApiSecurityRequirement = | ||
| 158 | | { | ||
| 159 | readonly kind: "none"; | ||
| 160 | } | ||
| 161 | | { | ||
| 162 | readonly kind: "scheme"; | ||
| 163 | readonly schemeName: string; | ||
| 164 | readonly scopes?: readonly string[]; | ||
| 165 | } | ||
| 166 | | { | ||
| 167 | readonly kind: "allOf"; | ||
| 168 | readonly items: readonly OpenApiSecurityRequirement[]; | ||
| 169 | } | ||
| 170 | | { | ||
| 171 | readonly kind: "anyOf"; | ||
| 172 | readonly items: readonly OpenApiSecurityRequirement[]; | ||
| 173 | }; | ||
| 174 | |||
| 175 | export const OpenApiSecurityRequirementSchema = Schema.suspend(() => | ||
| 176 | Schema.Union( | ||
| 177 | Schema.Struct({ | ||
| 178 | kind: Schema.Literal("none"), | ||
| 179 | }), | ||
| 180 | Schema.Struct({ | ||
| 181 | kind: Schema.Literal("scheme"), | ||
| 182 | schemeName: Schema.String, | ||
| 183 | scopes: Schema.optional(Schema.Array(Schema.String)), | ||
| 184 | }), | ||
| 185 | Schema.Struct({ | ||
| 186 | kind: Schema.Literal("allOf"), | ||
| 187 | items: Schema.Array(OpenApiSecurityRequirementSchema), | ||
| 188 | }), | ||
| 189 | Schema.Struct({ | ||
| 190 | kind: Schema.Literal("anyOf"), | ||
| 191 | items: Schema.Array(OpenApiSecurityRequirementSchema), | ||
| 192 | }), | ||
| 193 | ) | ||
| 194 | ) as Schema.Schema<OpenApiSecurityRequirement, OpenApiSecurityRequirement, never>; | ||
| 195 | |||
| 196 | export const OpenApiSecuritySchemeTypeSchema = Schema.Literal( | ||
| 197 | "apiKey", | ||
| 198 | "http", | ||
| 199 | "oauth2", | ||
| 200 | "openIdConnect", | ||
| 201 | ); | ||
| 202 | |||
| 203 | export const OpenApiOAuthFlowSchema = Schema.Struct({ | ||
| 204 | authorizationUrl: Schema.optional(Schema.String), | ||
| 205 | tokenUrl: Schema.optional(Schema.String), | ||
| 206 | refreshUrl: Schema.optional(Schema.String), | ||
| 207 | scopes: Schema.optional( | ||
| 208 | Schema.Record({ | ||
| 209 | key: Schema.String, | ||
| 210 | value: Schema.String, | ||
| 211 | }), | ||
| 212 | ), | ||
| 213 | }); | ||
| 214 | |||
| 215 | export const OpenApiSecuritySchemeSchema = Schema.Struct({ | ||
| 216 | schemeName: Schema.String, | ||
| 217 | schemeType: OpenApiSecuritySchemeTypeSchema, | ||
| 218 | description: Schema.optional(Schema.String), | ||
| 219 | placementIn: Schema.optional(Schema.Literal("header", "query", "cookie")), | ||
| 220 | placementName: Schema.optional(Schema.String), | ||
| 221 | scheme: Schema.optional(Schema.String), | ||
| 222 | bearerFormat: Schema.optional(Schema.String), | ||
| 223 | openIdConnectUrl: Schema.optional(Schema.String), | ||
| 224 | flows: Schema.optional( | ||
| 225 | Schema.Record({ | ||
| 226 | key: Schema.String, | ||
| 227 | value: OpenApiOAuthFlowSchema, | ||
| 228 | }), | ||
| 229 | ), | ||
| 230 | }); | ||
| 231 | |||
| 113 | export const OpenApiToolProviderDataSchema = Schema.Struct({ | 232 | export const OpenApiToolProviderDataSchema = Schema.Struct({ |
| 114 | kind: Schema.Literal("openapi"), | 233 | kind: Schema.Literal("openapi"), |
| 115 | toolId: Schema.String, | 234 | toolId: Schema.String, |
| @@ -124,6 +243,11 @@ export const OpenApiToolProviderDataSchema = Schema.Struct({ · 8 unmodified lines | |||
| 124 | operationHash: Schema.String, | 243 | operationHash: Schema.String, |
| 125 | invocation: OpenApiInvocationPayloadSchema, | 244 | invocation: OpenApiInvocationPayloadSchema, |
| 126 | documentation: Schema.optional(OpenApiToolDocumentationSchema), | 245 | documentation: Schema.optional(OpenApiToolDocumentationSchema), |
| 246 | responses: Schema.optional(Schema.Array(OpenApiResponseVariantSchema)), | ||
| 247 | authRequirement: Schema.optional(OpenApiSecurityRequirementSchema), | ||
| 248 | securitySchemes: Schema.optional(Schema.Array(OpenApiSecuritySchemeSchema)), | ||
| 249 | documentServers: Schema.optional(Schema.Array(OpenApiServerSchema)), | ||
| 250 | servers: Schema.optional(Schema.Array(OpenApiServerSchema)), | ||
| 127 | }); | 251 | }); |
| 128 | 252 | ||
| 129 | export const OpenApiExtractedToolSchema = Schema.Struct({ | 253 | export const OpenApiExtractedToolSchema = Schema.Struct({ |
| @@ -138,6 +262,11 @@ export const OpenApiExtractedToolSchema = Schema.Struct({ · 8 unmodified lines | |||
| 138 | operationHash: Schema.String, | 262 | operationHash: Schema.String, |
| 139 | typing: Schema.optional(DiscoveryTypingPayloadSchema), | 263 | typing: Schema.optional(DiscoveryTypingPayloadSchema), |
| 140 | documentation: Schema.optional(OpenApiToolDocumentationSchema), | 264 | documentation: Schema.optional(OpenApiToolDocumentationSchema), |
| 265 | responses: Schema.optional(Schema.Array(OpenApiResponseVariantSchema)), | ||
| 266 | authRequirement: Schema.optional(OpenApiSecurityRequirementSchema), | ||
| 267 | securitySchemes: Schema.optional(Schema.Array(OpenApiSecuritySchemeSchema)), | ||
| 268 | documentServers: Schema.optional(Schema.Array(OpenApiServerSchema)), | ||
| 269 | servers: Schema.optional(Schema.Array(OpenApiServerSchema)), | ||
| 141 | }); | 270 | }); |
| 142 | 271 | ||
| 143 | export const OpenApiToolManifestSchema = Schema.Struct({ | 272 | export const OpenApiToolManifestSchema = Schema.Struct({ |
| @@ -155,7 +284,10 @@ export const OpenApiToolManifestSchema = Schema.Struct({ · 11 unmodified lines | |||
| 155 | export type OpenApiHttpMethod = typeof OpenApiHttpMethodSchema.Type; | 284 | export type OpenApiHttpMethod = typeof OpenApiHttpMethodSchema.Type; |
| 156 | export type OpenApiParameterLocation = typeof OpenApiParameterLocationSchema.Type; | 285 | export type OpenApiParameterLocation = typeof OpenApiParameterLocationSchema.Type; |
| 157 | export type OpenApiToolParameter = typeof OpenApiToolParameterSchema.Type; | 286 | export type OpenApiToolParameter = typeof OpenApiToolParameterSchema.Type; |
| 287 | export type OpenApiMediaContent = typeof OpenApiMediaContentSchema.Type; | ||
| 288 | export type OpenApiHeader = typeof OpenApiHeaderSchema.Type; | ||
| 158 | export type OpenApiToolRequestBody = typeof OpenApiToolRequestBodySchema.Type; | 289 | export type OpenApiToolRequestBody = typeof OpenApiToolRequestBodySchema.Type; |
| 290 | export type OpenApiServer = typeof OpenApiServerSchema.Type; | ||
| 159 | export type OpenApiInvocationPayload = typeof OpenApiInvocationPayloadSchema.Type; | 291 | export type OpenApiInvocationPayload = typeof OpenApiInvocationPayloadSchema.Type; |
| 160 | export type DiscoveryTypingPayload = typeof DiscoveryTypingPayloadSchema.Type; | 292 | export type DiscoveryTypingPayload = typeof DiscoveryTypingPayloadSchema.Type; |
| 161 | export type OpenApiRefHintValue = typeof OpenApiRefHintValueSchema.Type; | 293 | export type OpenApiRefHintValue = typeof OpenApiRefHintValueSchema.Type; |
| @@ -164,7 +296,11 @@ export type OpenApiExample = typeof OpenApiExampleSchema.Type; · 2 unmodified lines | |||
| 164 | export type OpenApiParameterDocumentation = typeof OpenApiParameterDocumentationSchema.Type; | 296 | export type OpenApiParameterDocumentation = typeof OpenApiParameterDocumentationSchema.Type; |
| 165 | export type OpenApiRequestBodyDocumentation = typeof OpenApiRequestBodyDocumentationSchema.Type; | 297 | export type OpenApiRequestBodyDocumentation = typeof OpenApiRequestBodyDocumentationSchema.Type; |
| 166 | export type OpenApiResponseDocumentation = typeof OpenApiResponseDocumentationSchema.Type; | 298 | export type OpenApiResponseDocumentation = typeof OpenApiResponseDocumentationSchema.Type; |
| 299 | export type OpenApiResponseVariant = typeof OpenApiResponseVariantSchema.Type; | ||
| 167 | export type OpenApiToolDocumentation = typeof OpenApiToolDocumentationSchema.Type; | 300 | export type OpenApiToolDocumentation = typeof OpenApiToolDocumentationSchema.Type; |
| 301 | export type OpenApiOAuthFlow = typeof OpenApiOAuthFlowSchema.Type; | ||
| 302 | export type OpenApiSecuritySchemeType = typeof OpenApiSecuritySchemeTypeSchema.Type; | ||
| 303 | export type OpenApiSecurityScheme = typeof OpenApiSecuritySchemeSchema.Type; | ||
| 168 | export type OpenApiToolProviderData = typeof OpenApiToolProviderDataSchema.Type; | 304 | export type OpenApiToolProviderData = typeof OpenApiToolProviderDataSchema.Type; |
| 169 | export type OpenApiExtractedTool = typeof OpenApiExtractedToolSchema.Type; | 305 | export type OpenApiExtractedTool = typeof OpenApiExtractedToolSchema.Type; |
| 170 | export type OpenApiToolManifest = typeof OpenApiToolManifestSchema.Type; | 306 | export type OpenApiToolManifest = typeof OpenApiToolManifestSchema.Type; |
| @@ -641,7 +641,11 @@ const projectResultShapeFromResponses = ( · 640 unmodified lines | |||
| 641 | .filter((entry): entry is { variant: ResponseSet["variants"][number]; response: ResponseSymbol; score: number } => entry !== null) | 641 | .filter((entry): entry is { variant: ResponseSet["variants"][number]; response: ResponseSymbol; score: number } => entry !== null) |
| 642 | .sort((left, right) => right.score - left.score); | 642 | .sort((left, right) => right.score - left.score); |
| 643 | 643 | ||
| 644 | const jsonCandidates = ranked.flatMap(({ response }) => | 644 | const preferred = ranked.some(({ score }) => score >= 60) |
| 645 | ? ranked.filter(({ score }) => score >= 60) | ||
| 646 | : ranked.filter(({ score }) => score === (ranked[0]?.score ?? 0)); | ||
| 647 | |||
| 648 | const jsonCandidates = preferred.flatMap(({ response }) => | ||
| 645 | responseContentCandidates(response).filter((candidate) => isJsonMediaType(candidate.mediaType)), | 649 | responseContentCandidates(response).filter((candidate) => isJsonMediaType(candidate.mediaType)), |
| 646 | ); | 650 | ); |
| 647 | 651 | ||
| @@ -658,7 +662,7 @@ const projectResultShapeFromResponses = ( · 10 unmodified lines | |||
| 658 | } | 662 | } |
| 659 | 663 | ||
| 660 | const fallbackShapeIds = unique( | 664 | const fallbackShapeIds = unique( |
| 661 | ranked.flatMap(({ response }) => | 665 | preferred.flatMap(({ response }) => |
| 662 | responseContentCandidates(response).map((candidate) => candidate.shapeId), | 666 | responseContentCandidates(response).map((candidate) => candidate.shapeId), |
| 663 | ), | 667 | ), |
| 664 | ); | 668 | ); |
| @@ -3,6 +3,11 @@ import { · 2 unmodified lines | |||
| 3 | applyHttpQueryPlacementsToUrl, | 3 | applyHttpQueryPlacementsToUrl, |
| 4 | applyJsonBodyPlacements, | 4 | applyJsonBodyPlacements, |
| 5 | } from "@executor/codemode-core"; | 5 | } from "@executor/codemode-core"; |
| 6 | import { | ||
| 7 | serializeOpenApiParameterValue, | ||
| 8 | serializeOpenApiRequestBody, | ||
| 9 | withSerializedQueryEntries, | ||
| 10 | } from "@executor/codemode-openapi"; | ||
| 6 | import { | 11 | import { |
| 7 | createMcpToolsFromManifest, | 12 | createMcpToolsFromManifest, |
| 8 | createSdkMcpConnector, | 13 | createSdkMcpConnector, |
| @@ -16,7 +21,16 @@ import type { · 7 unmodified lines | |||
| 16 | ToolInput, | 21 | ToolInput, |
| 17 | ToolPath, | 22 | ToolPath, |
| 18 | } from "@executor/codemode-core"; | 23 | } from "@executor/codemode-core"; |
| 19 | import type { CatalogV1, Capability, Executable, GraphQLExecutable, HttpExecutable, McpExecutable, ParameterSymbol } from "../ir/model"; | 24 | import type { |
| 25 | CatalogV1, | ||
| 26 | Capability, | ||
| 27 | Executable, | ||
| 28 | GraphQLExecutable, | ||
| 29 | HttpExecutable, | ||
| 30 | McpExecutable, | ||
| 31 | ParameterSymbol, | ||
| 32 | Scope, | ||
| 33 | } from "../ir/model"; | ||
| 20 | import type { LoadedSourceCatalogToolIndexEntry } from "./source-catalog-runtime"; | 34 | import type { LoadedSourceCatalogToolIndexEntry } from "./source-catalog-runtime"; |
| 21 | import type { ResolvedSourceAuthMaterial } from "./source-auth-material"; | 35 | import type { ResolvedSourceAuthMaterial } from "./source-auth-material"; |
| 22 | 36 | ||
| @@ -73,19 +87,6 @@ const decodeFetchBody = async (response: Response): Promise<unknown> => { · 50 unmodified lines | |||
| 73 | return response.text(); | 87 | return response.text(); |
| 74 | }; | 88 | }; |
| 75 | 89 | ||
| 76 | const primitiveString = (value: unknown): string => { | ||
| 77 | if (value === null || value === undefined) { | ||
| 78 | return ""; | ||
| 79 | } | ||
| 80 | if (typeof value === "string") { | ||
| 81 | return value; | ||
| 82 | } | ||
| 83 | if (typeof value === "number" || typeof value === "boolean") { | ||
| 84 | return String(value); | ||
| 85 | } | ||
| 86 | return JSON.stringify(value); | ||
| 87 | }; | ||
| 88 | |||
| 89 | const parameterById = (catalog: CatalogV1, parameterId: string): ParameterSymbol | undefined => { | 90 | const parameterById = (catalog: CatalogV1, parameterId: string): ParameterSymbol | undefined => { |
| 90 | const symbol = catalog.symbols[parameterId]; | 91 | const symbol = catalog.symbols[parameterId]; |
| 91 | return symbol?.kind === "parameter" ? symbol : undefined; | 92 | return symbol?.kind === "parameter" ? symbol : undefined; |
| @@ -130,7 +131,49 @@ const readHttpParameterValue = (input: { · 38 unmodified lines | |||
| 130 | return asObject(input.args[groupKey])[input.parameter.name]; | 131 | return asObject(input.args[groupKey])[input.parameter.name]; |
| 131 | }; | 132 | }; |
| 132 | 133 | ||
| 133 | const resolveHttpBaseUrl = (source: Source, executable: HttpExecutable): URL => { | 134 | const scopeDefaultServers = ( |
| 135 | catalog: CatalogV1, | ||
| 136 | scopeId: HttpExecutable["scopeId"], | ||
| 137 | ): Array<{ url: string; variables?: Record<string, string> }> => { | ||
| 138 | let currentId: HttpExecutable["scopeId"] | undefined = scopeId; | ||
| 139 | |||
| 140 | while (currentId) { | ||
| 141 | const scope: Scope | undefined = catalog.scopes[currentId]; | ||
| 142 | if (!scope) { | ||
| 143 | break; | ||
| 144 | } | ||
| 145 | |||
| 146 | if (scope.defaults?.servers && scope.defaults.servers.length > 0) { | ||
| 147 | return [...scope.defaults.servers]; | ||
| 148 | } | ||
| 149 | |||
| 150 | currentId = scope.parentId; | ||
| 151 | } | ||
| 152 | |||
| 153 | return []; | ||
| 154 | }; | ||
| 155 | |||
| 156 | const resolveScopedServerUrl = ( | ||
| 157 | source: Source, | ||
| 158 | server: { | ||
| 159 | url: string; | ||
| 160 | variables?: Record<string, string>; | ||
| 161 | }, | ||
| 162 | ): URL => { | ||
| 163 | const expanded = Object.entries(server.variables ?? {}).reduce( | ||
| 164 | (url, [name, value]) => url.replaceAll(`{${name}}`, value), | ||
| 165 | server.url, | ||
| 166 | ); | ||
| 167 | |||
| 168 | return new URL(expanded, source.endpoint); | ||
| 169 | }; | ||
| 170 | |||
| 171 | const resolveHttpBaseUrl = (source: Source, catalog: CatalogV1, executable: HttpExecutable): URL => { | ||
| 172 | const scopedServers = scopeDefaultServers(catalog, executable.scopeId); | ||
| 173 | if (scopedServers.length > 0) { | ||
| 174 | return resolveScopedServerUrl(source, scopedServers[0]!); | ||
| 175 | } | ||
| 176 | |||
| 134 | const googleBlob = executable.native?.find( | 177 | const googleBlob = executable.native?.find( |
| 135 | (blob) => blob.kind === "google_discovery_provider_data", | 178 | (blob) => blob.kind === "google_discovery_provider_data", |
| 136 | ); | 179 | ); |
| @@ -147,6 +190,27 @@ const resolveHttpBaseUrl = (source: Source, executable: HttpExecutable): URL => · 10 unmodified lines | |||
| 147 | return new URL(source.endpoint); | 190 | return new URL(source.endpoint); |
| 148 | }; | 191 | }; |
| 149 | 192 | ||
| 193 | const resolveHttpRequestUrl = (baseUrl: URL, resolvedPath: string): URL => { | ||
| 194 | try { | ||
| 195 | return new URL(resolvedPath); | ||
| 196 | } catch { | ||
| 197 | const resolved = new URL(baseUrl.toString()); | ||
| 198 | const basePath = | ||
| 199 | resolved.pathname === "/" | ||
| 200 | ? "" | ||
| 201 | : resolved.pathname.endsWith("/") | ||
| 202 | ? resolved.pathname.slice(0, -1) | ||
| 203 | : resolved.pathname; | ||
| 204 | const pathPart = resolvedPath.startsWith("/") ? resolvedPath : `/${resolvedPath}`; | ||
| 205 | |||
| 206 | resolved.pathname = `${basePath}${pathPart}`.replace(/\/{2,}/g, "/"); | ||
| 207 | resolved.search = ""; | ||
| 208 | resolved.hash = ""; | ||
| 209 | |||
| 210 | return resolved; | ||
| 211 | } | ||
| 212 | }; | ||
| 213 | |||
| 150 | const executeHttp = (input: { | 214 | const executeHttp = (input: { |
| 151 | source: Source; | 215 | source: Source; |
| 152 | catalog: CatalogV1; | 216 | catalog: CatalogV1; |
| @@ -159,13 +223,14 @@ const executeHttp = (input: { · 6 unmodified lines | |||
| 159 | const argsRecord = asObject(input.args); | 223 | const argsRecord = asObject(input.args); |
| 160 | let resolvedPath = input.executable.pathTemplate; | 224 | let resolvedPath = input.executable.pathTemplate; |
| 161 | const url = applyHttpQueryPlacementsToUrl({ | 225 | const url = applyHttpQueryPlacementsToUrl({ |
| 162 | url: resolveHttpBaseUrl(input.source, input.executable), | 226 | url: resolveHttpBaseUrl(input.source, input.catalog, input.executable), |
| 163 | queryParams: readSourceQueryParams(input.source), | 227 | queryParams: readSourceQueryParams(input.source), |
| 164 | }); | 228 | }); |
| 165 | const headers: Record<string, string> = { | 229 | const headers: Record<string, string> = { |
| 166 | ...readSourceHeaders(input.source), | 230 | ...readSourceHeaders(input.source), |
| 167 | }; | 231 | }; |
| 168 | const cookies: Record<string, string> = {}; | 232 | const queryEntries: Array<{ name: string; value: string; allowReserved?: boolean }> = []; |
| 233 | const cookieParts: string[] = []; | ||
| 169 | 234 | ||
| 170 | const allParameterIds = [ | 235 | const allParameterIds = [ |
| 171 | ...(input.executable.pathParameterIds ?? []), | 236 | ...(input.executable.pathParameterIds ?? []), |
| @@ -193,32 +258,37 @@ const executeHttp = (input: { · 21 unmodified lines | |||
| 193 | continue; | 258 | continue; |
| 194 | } | 259 | } |
| 195 | 260 | ||
| 196 | if (parameter.location === "path") { | 261 | const serialized = serializeOpenApiParameterValue({ |
| 262 | name: parameter.name, | ||
| 263 | location: parameter.location, | ||
| 264 | style: parameter.style, | ||
| 265 | explode: parameter.explode, | ||
| 266 | allowReserved: parameter.allowReserved, | ||
| 267 | content: parameter.content?.map((content) => ({ mediaType: content.mediaType })), | ||
| 268 | }, value); | ||
| 269 | |||
| 270 | if (serialized.kind === "path") { | ||
| 197 | resolvedPath = resolvedPath.replace( | 271 | resolvedPath = resolvedPath.replace( |
| 198 | new RegExp(`{${parameter.name}}`, "g"), | 272 | new RegExp(`{${parameter.name}}`, "g"), |
| 199 | encodeURIComponent(primitiveString(value)), | 273 | serialized.value, |
| 200 | ); | 274 | ); |
| 201 | continue; | 275 | continue; |
| 202 | } | 276 | } |
| 203 | 277 | ||
| 204 | if (parameter.location === "query") { | 278 | if (serialized.kind === "query") { |
| 205 | if (Array.isArray(value)) { | 279 | queryEntries.push(...serialized.entries); |
| 206 | for (const entry of value) { | ||
| 207 | url.searchParams.append(parameter.name, primitiveString(entry)); | ||
| 208 | } | ||
| 209 | } else { | ||
| 210 | url.searchParams.set(parameter.name, primitiveString(value)); | ||
| 211 | } | ||
| 212 | continue; | 280 | continue; |
| 213 | } | 281 | } |
| 214 | 282 | ||
| 215 | if (parameter.location === "header") { | 283 | if (serialized.kind === "header") { |
| 216 | headers[parameter.name] = primitiveString(value); | 284 | headers[parameter.name] = serialized.value; |
| 217 | continue; | 285 | continue; |
| 218 | } | 286 | } |
| 219 | 287 | ||
| 220 | if (parameter.location === "cookie") { | 288 | if (serialized.kind === "cookie") { |
| 221 | cookies[parameter.name] = primitiveString(value); | 289 | cookieParts.push( |
| 290 | ...serialized.pairs.map((pair) => `${pair.name}=${encodeURIComponent(pair.value)}`), | ||
| 291 | ); | ||
| 222 | } | 292 | } |
| 223 | } | 293 | } |
| 224 | 294 | ||
| @@ -229,35 +299,42 @@ const executeHttp = (input: { · 4 unmodified lines | |||
| 229 | if (bodySymbol?.kind === "requestBody") { | 299 | if (bodySymbol?.kind === "requestBody") { |
| 230 | const bodyValue = argsRecord.body ?? argsRecord.input; | 300 | const bodyValue = argsRecord.body ?? argsRecord.input; |
| 231 | if (bodyValue !== undefined) { | 301 | if (bodyValue !== undefined) { |
| 232 | const contentType = bodySymbol.contents[0]?.mediaType ?? "application/json"; | 302 | const serializedBody = serializeOpenApiRequestBody({ |
| 233 | headers["content-type"] = contentType; | 303 | requestBody: { |
| 234 | body = JSON.stringify( | 304 | contentTypes: bodySymbol.contents.map((content) => content.mediaType), |
| 235 | applyJsonBodyPlacements({ | 305 | contents: bodySymbol.contents.map((content) => ({ mediaType: content.mediaType })), |
| 306 | }, | ||
| 307 | body: applyJsonBodyPlacements({ | ||
| 236 | body: bodyValue, | 308 | body: bodyValue, |
| 237 | bodyValues: input.auth.bodyValues, | 309 | bodyValues: input.auth.bodyValues, |
| 238 | label: `${input.executable.method} ${input.executable.pathTemplate}`, | 310 | label: `${input.executable.method} ${input.executable.pathTemplate}`, |
| 239 | }), | 311 | }), |
| 240 | ); | 312 | }); |
| 313 | headers["content-type"] = serializedBody.contentType; | ||
| 314 | body = serializedBody.bodyText; | ||
| 241 | } | 315 | } |
| 242 | } | 316 | } |
| 243 | 317 | ||
| 244 | const requestUrl = new URL(resolvedPath, url); | 318 | const requestUrl = resolveHttpRequestUrl(url, resolvedPath); |
| 245 | const urlWithAuth = applyHttpQueryPlacementsToUrl({ | 319 | const urlWithAuth = applyHttpQueryPlacementsToUrl({ |
| 246 | url: requestUrl, | 320 | url: requestUrl, |
| 247 | queryParams: input.auth.queryParams, | 321 | queryParams: input.auth.queryParams, |
| 248 | }); | 322 | }); |
| 323 | const urlWithOpenApiQuery = withSerializedQueryEntries(urlWithAuth, queryEntries); | ||
| 324 | if (cookieParts.length > 0) { | ||
| 325 | headers.cookie = cookieParts.join("; "); | ||
| 326 | } | ||
| 249 | const headersWithCookies = applyCookiePlacementsToHeaders({ | 327 | const headersWithCookies = applyCookiePlacementsToHeaders({ |
| 250 | headers: { | 328 | headers: { |
| 251 | ...headers, | 329 | ...headers, |
| 252 | ...input.auth.headers, | 330 | ...input.auth.headers, |
| 253 | }, | 331 | }, |
| 254 | cookies: { | 332 | cookies: { |
| 255 | ...cookies, | ||
| 256 | ...input.auth.cookies, | 333 | ...input.auth.cookies, |
| 257 | }, | 334 | }, |
| 258 | }); | 335 | }); |
| 259 | 336 | ||
| 260 | const response = await fetch(urlWithAuth, { | 337 | const response = await fetch(urlWithOpenApiQuery, { |
| 261 | method: input.executable.method, | 338 | method: input.executable.method, |
| 262 | headers: headersWithCookies, | 339 | headers: headersWithCookies, |
| 263 | ...(body !== undefined ? { body } : {}), | 340 | ...(body !== undefined ? { body } : {}), |
| @@ -443,14 +520,23 @@ const executeMcp = (input: { · 179 unmodified lines | |||
| 443 | }); | 520 | }); |
| 444 | const tools = createMcpToolsFromManifest({ | 521 | const tools = createMcpToolsFromManifest({ |
| 445 | manifest: { | 522 | manifest: { |
| 446 | version: 1, | 523 | version: 2, |
| 447 | tools: [{ | 524 | tools: [{ |
| 448 | toolId: input.executable.toolName, | 525 | toolId: input.executable.toolName, |
| 449 | toolName: input.executable.toolName, | 526 | toolName: input.executable.toolName, |
| 527 | displayTitle: | ||
| 528 | input.tool.capability.surface.title | ||
| 529 | ?? input.executable.toolName, | ||
| 530 | title: input.tool.capability.surface.title ?? null, | ||
| 450 | description: | 531 | description: |
| 451 | input.tool.capability.surface.summary | 532 | input.tool.capability.surface.summary |
| 452 | ?? input.tool.capability.surface.title | 533 | ?? input.tool.capability.surface.title |
| 453 | ?? `MCP tool: ${input.executable.toolName}`, | 534 | ?? `MCP tool: ${input.executable.toolName}`, |
| 535 | annotations: null, | ||
| 536 | execution: null, | ||
| 537 | icons: null, | ||
| 538 | meta: null, | ||
| 539 | rawTool: null, | ||
| 454 | inputSchema: input.tool.descriptor.inputSchema, | 540 | inputSchema: input.tool.descriptor.inputSchema, |
| 455 | outputSchema: input.tool.descriptor.outputSchema, | 541 | outputSchema: input.tool.descriptor.outputSchema, |
| 456 | }], | 542 | }], |
| @@ -0,0 +1,151 @@ | |||
| 1 | import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; | ||
| 2 | import { tmpdir } from "node:os"; | ||
| 3 | import { join } from "node:path"; | ||
| 4 | import { gzipSync } from "node:zlib"; | ||
| 5 | import { NodeFileSystem } from "@effect/platform-node"; | ||
| 6 | import { describe, expect, it } from "@effect/vitest"; | ||
| 7 | import * as Effect from "effect/Effect"; | ||
| 8 | |||
| 9 | import { | ||
| 10 | SourceIdSchema, | ||
| 11 | WorkspaceIdSchema, | ||
| 12 | type Source, | ||
| 13 | } from "#schema"; | ||
| 14 | |||
| 15 | import { | ||
| 16 | buildLocalSourceArtifact, | ||
| 17 | readLocalSourceArtifact, | ||
| 18 | removeLocalSourceArtifact, | ||
| 19 | writeLocalSourceArtifact, | ||
| 20 | } from "./local-source-artifacts"; | ||
| 21 | import type { ResolvedLocalWorkspaceContext } from "./local-config"; | ||
| 22 | import { createOpenApiCatalogSnapshot } from "./source-catalog-snapshot"; | ||
| 23 | |||
| 24 | const makeContext = (): ResolvedLocalWorkspaceContext => { | ||
| 25 | const workspaceRoot = mkdtempSync(join(tmpdir(), "executor-artifacts-")); | ||
| 26 | return { | ||
| 27 | cwd: workspaceRoot, | ||
| 28 | workspaceRoot, | ||
| 29 | workspaceName: "executor-artifacts", | ||
| 30 | configDirectory: join(workspaceRoot, ".executor"), | ||
| 31 | projectConfigPath: join(workspaceRoot, ".executor", "executor.jsonc"), | ||
| 32 | homeConfigPath: join(workspaceRoot, ".executor-home.jsonc"), | ||
| 33 | artifactsDirectory: join(workspaceRoot, ".executor", "artifacts"), | ||
| 34 | stateDirectory: join(workspaceRoot, ".executor", "state"), | ||
| 35 | }; | ||
| 36 | }; | ||
| 37 | |||
| 38 | const makeSource = (): Source => ({ | ||
| 39 | id: SourceIdSchema.make("src_test"), | ||
| 40 | workspaceId: WorkspaceIdSchema.make("ws_test"), | ||
| 41 | name: "Test Source", | ||
| 42 | kind: "openapi", | ||
| 43 | endpoint: "https://example.com/api", | ||
| 44 | status: "connected", | ||
| 45 | enabled: true, | ||
| 46 | namespace: "test", | ||
| 47 | bindingVersion: 1, | ||
| 48 | binding: { | ||
| 49 | specUrl: "https://example.com/openapi.json", | ||
| 50 | defaultHeaders: null, | ||
| 51 | }, | ||
| 52 | importAuthPolicy: "none", | ||
| 53 | importAuth: { kind: "none" }, | ||
| 54 | auth: { kind: "none" }, | ||
| 55 | sourceHash: "hash_test", | ||
| 56 | lastError: null, | ||
| 57 | createdAt: 0, | ||
| 58 | updatedAt: 0, | ||
| 59 | }); | ||
| 60 | |||
| 61 | const makeArtifact = () => { | ||
| 62 | const source = makeSource(); | ||
| 63 | const snapshot = createOpenApiCatalogSnapshot({ | ||
| 64 | source, | ||
| 65 | documents: [{ | ||
| 66 | documentKind: "openapi", | ||
| 67 | documentKey: source.binding.specUrl, | ||
| 68 | contentText: '{"openapi":"3.1.0"}', | ||
| 69 | fetchedAt: 1, | ||
| 70 | }], | ||
| 71 | operations: [], | ||
| 72 | }); | ||
| 73 | |||
| 74 | return buildLocalSourceArtifact({ | ||
| 75 | source, | ||
| 76 | syncResult: { | ||
| 77 | snapshot, | ||
| 78 | sourceHash: source.sourceHash, | ||
| 79 | }, | ||
| 80 | }); | ||
| 81 | }; | ||
| 82 | |||
| 83 | describe("local-source-artifacts", () => { | ||
| 84 | it.effect("writes uncompressed artifacts and reads them back", () => | ||
| 85 | Effect.gen(function* () { | ||
| 86 | const context = makeContext(); | ||
| 87 | const artifact = makeArtifact(); | ||
| 88 | const path = join(context.artifactsDirectory, "sources", "src_test.json"); | ||
| 89 | const legacyPath = join(context.artifactsDirectory, "sources", "src_test.json.gz"); | ||
| 90 | const rawDocumentPath = join( | ||
| 91 | context.artifactsDirectory, | ||
| 92 | "sources", | ||
| 93 | "src_test", | ||
| 94 | "documents", | ||
| 95 | `${Object.keys(artifact.snapshot.catalog.documents)[0]}.txt`, | ||
| 96 | ); | ||
| 97 | |||
| 98 | yield* writeLocalSourceArtifact({ | ||
| 99 | context, | ||
| 100 | sourceId: "src_test", | ||
| 101 | artifact, | ||
| 102 | }); | ||
| 103 | |||
| 104 | expect(existsSync(path)).toBe(true); | ||
| 105 | expect(existsSync(legacyPath)).toBe(false); | ||
| 106 | expect(readFileSync(path, "utf8").startsWith("{")).toBe(true); | ||
| 107 | expect(existsSync(rawDocumentPath)).toBe(true); | ||
| 108 | |||
| 109 | const persistedArtifact = JSON.parse(readFileSync(path, "utf8")); | ||
| 110 | const persistedDocument = Object.values(persistedArtifact.snapshot.catalog.documents)[0] as { | ||
| 111 | native?: ReadonlyArray<{ kind?: string }>; | ||
| 112 | }; | ||
| 113 | expect(persistedDocument.native?.some((blob) => blob.kind === "source_document") ?? false).toBe(false); | ||
| 114 | |||
| 115 | const decoded = yield* readLocalSourceArtifact({ | ||
| 116 | context, | ||
| 117 | sourceId: "src_test", | ||
| 118 | }); | ||
| 119 | |||
| 120 | expect(decoded?.snapshot.import.adapterKey).toBe("openapi"); | ||
| 121 | expect(decoded?.sourceId).toBe("src_test"); | ||
| 122 | expect((Object.values(decoded?.snapshot.catalog.documents ?? {})[0] as { native?: Array<{ value?: unknown }> })?.native?.[0]?.value).toBe('{"openapi":"3.1.0"}'); | ||
| 123 | }).pipe(Effect.provide(NodeFileSystem.layer)), | ||
| 124 | ); | ||
| 125 | |||
| 126 | it.effect("reads legacy gz artifacts and removes both formats", () => | ||
| 127 | Effect.gen(function* () { | ||
| 128 | const context = makeContext(); | ||
| 129 | const artifact = makeArtifact(); | ||
| 130 | const directory = join(context.artifactsDirectory, "sources"); | ||
| 131 | const legacyPath = join(directory, "src_test.json.gz"); | ||
| 132 | |||
| 133 | mkdirSync(directory, { recursive: true }); | ||
| 134 | writeFileSync(legacyPath, gzipSync(JSON.stringify(artifact))); | ||
| 135 | |||
| 136 | const decoded = yield* readLocalSourceArtifact({ | ||
| 137 | context, | ||
| 138 | sourceId: "src_test", | ||
| 139 | }); | ||
| 140 | expect(decoded?.catalogId).toBe(artifact.catalogId); | ||
| 141 | |||
| 142 | yield* removeLocalSourceArtifact({ | ||
| 143 | context, | ||
| 144 | sourceId: "src_test", | ||
| 145 | }); | ||
| 146 | |||
| 147 | expect(existsSync(join(directory, "src_test.json"))).toBe(false); | ||
| 148 | expect(existsSync(legacyPath)).toBe(false); | ||
| 149 | }).pipe(Effect.provide(NodeFileSystem.layer)), | ||
| 150 | ); | ||
| 151 | }); | ||
| @@ -1,4 +1,5 @@ | |||
| 1 | import { join } from "node:path"; | 1 | import { join } from "node:path"; |
| 2 | import { gunzipSync } from "node:zlib"; | ||
| 2 | import { FileSystem } from "@effect/platform"; | 3 | import { FileSystem } from "@effect/platform"; |
| 3 | 4 | ||
| 4 | import { | 5 | import { |
| @@ -12,7 +13,11 @@ import { · 7 unmodified lines | |||
| 12 | import * as Effect from "effect/Effect"; | 13 | import * as Effect from "effect/Effect"; |
| 13 | import * as Schema from "effect/Schema"; | 14 | import * as Schema from "effect/Schema"; |
| 14 | 15 | ||
| 15 | import { CatalogSnapshotV1Schema } from "../ir/model"; | 16 | import { |
| 17 | CatalogSnapshotV1Schema, | ||
| 18 | type NativeBlob, | ||
| 19 | type SourceDocument, | ||
| 20 | } from "../ir/model"; | ||
| 16 | import type { SourceCatalogSyncResult } from "./source-catalog-support"; | 21 | import type { SourceCatalogSyncResult } from "./source-catalog-support"; |
| 17 | import type { ResolvedLocalWorkspaceContext } from "./local-config"; | 22 | import type { ResolvedLocalWorkspaceContext } from "./local-config"; |
| 18 | import { | 23 | import { |
| @@ -49,6 +54,9 @@ const mapFileSystemError = (path: string, action: string) => (cause: unknown) => · 30 unmodified lines | |||
| 49 | details: unknownLocalErrorDetails(cause), | 54 | details: unknownLocalErrorDetails(cause), |
| 50 | }); | 55 | }); |
| 51 | 56 | ||
| 57 | const mutableRecord = <K extends string, V>(value: Readonly<Record<K, V>>): Record<K, V> => | ||
| 58 | value as Record<K, V>; | ||
| 59 | |||
| 52 | const localSourceArtifactPath = (input: { | 60 | const localSourceArtifactPath = (input: { |
| 53 | context: ResolvedLocalWorkspaceContext; | 61 | context: ResolvedLocalWorkspaceContext; |
| 54 | sourceId: string; | 62 | sourceId: string; |
| @@ -59,6 +67,93 @@ const localSourceArtifactPath = (input: { · 4 unmodified lines | |||
| 59 | `${input.sourceId}.json`, | 67 | `${input.sourceId}.json`, |
| 60 | ); | 68 | ); |
| 61 | 69 | ||
| 70 | const legacyLocalSourceArtifactPath = (input: { | ||
| 71 | context: ResolvedLocalWorkspaceContext; | ||
| 72 | sourceId: string; | ||
| 73 | }): string => | ||
| 74 | join( | ||
| 75 | input.context.artifactsDirectory, | ||
| 76 | "sources", | ||
| 77 | `${input.sourceId}.json.gz`, | ||
| 78 | ); | ||
| 79 | |||
| 80 | const localSourceDocumentDirectory = (input: { | ||
| 81 | context: ResolvedLocalWorkspaceContext; | ||
| 82 | sourceId: string; | ||
| 83 | }): string => | ||
| 84 | join(input.context.artifactsDirectory, "sources", input.sourceId, "documents"); | ||
| 85 | |||
| 86 | const localSourceDocumentPath = (input: { | ||
| 87 | context: ResolvedLocalWorkspaceContext; | ||
| 88 | sourceId: string; | ||
| 89 | documentId: string; | ||
| 90 | }): string => | ||
| 91 | join(localSourceDocumentDirectory(input), `${input.documentId}.txt`); | ||
| 92 | |||
| 93 | const splitArtifactSourceDocuments = (artifact: LocalSourceArtifact): { | ||
| 94 | artifact: LocalSourceArtifact; | ||
| 95 | rawDocuments: ReadonlyArray<{ | ||
| 96 | documentId: string; | ||
| 97 | blob: NativeBlob; | ||
| 98 | content: string; | ||
| 99 | }>; | ||
| 100 | } => { | ||
| 101 | const nextArtifact = structuredClone(artifact); | ||
| 102 | const rawDocuments: Array<{ | ||
| 103 | documentId: string; | ||
| 104 | blob: NativeBlob; | ||
| 105 | content: string; | ||
| 106 | }> = []; | ||
| 107 | |||
| 108 | for (const [documentId, document] of Object.entries(nextArtifact.snapshot.catalog.documents)) { | ||
| 109 | const mutableDocument = document as SourceDocument & { native?: NativeBlob[] }; | ||
| 110 | const sourceDocumentBlob = document.native?.find((blob) => | ||
| 111 | blob.kind === "source_document" && typeof blob.value === "string" | ||
| 112 | ); | ||
| 113 | if (!sourceDocumentBlob || typeof sourceDocumentBlob.value !== "string") { | ||
| 114 | continue; | ||
| 115 | } | ||
| 116 | |||
| 117 | rawDocuments.push({ | ||
| 118 | documentId, | ||
| 119 | blob: sourceDocumentBlob, | ||
| 120 | content: sourceDocumentBlob.value, | ||
| 121 | }); | ||
| 122 | |||
| 123 | const remainingBlobs = (document.native ?? []).filter((blob) => blob !== sourceDocumentBlob); | ||
| 124 | if (remainingBlobs.length > 0) { | ||
| 125 | mutableDocument.native = remainingBlobs; | ||
| 126 | } else { | ||
| 127 | delete mutableDocument.native; | ||
| 128 | } | ||
| 129 | } | ||
| 130 | |||
| 131 | return { | ||
| 132 | artifact: nextArtifact, | ||
| 133 | rawDocuments, | ||
| 134 | }; | ||
| 135 | }; | ||
| 136 | |||
| 137 | const hydrateArtifactSourceDocuments = (input: { | ||
| 138 | artifact: LocalSourceArtifact; | ||
| 139 | rawDocuments: Readonly<Record<string, NativeBlob>>; | ||
| 140 | }): LocalSourceArtifact => { | ||
| 141 | const nextArtifact = structuredClone(input.artifact); | ||
| 142 | const mutableDocuments = mutableRecord(nextArtifact.snapshot.catalog.documents); | ||
| 143 | |||
| 144 | for (const [documentId, rawDocument] of Object.entries(input.rawDocuments)) { | ||
| 145 | const document = mutableDocuments[documentId as keyof typeof mutableDocuments] as (SourceDocument & { native?: NativeBlob[] }) | undefined; | ||
| 146 | if (!document) { | ||
| 147 | continue; | ||
| 148 | } | ||
| 149 | |||
| 150 | const remainingBlobs = (document.native ?? []).filter((blob) => blob.kind !== "source_document"); | ||
| 151 | document.native = [rawDocument, ...remainingBlobs]; | ||
| 152 | } | ||
| 153 | |||
| 154 | return nextArtifact; | ||
| 155 | }; | ||
| 156 | |||
| 62 | const snapshotHash = (snapshot: SourceCatalogSyncResult["snapshot"]): string => | 157 | const snapshotHash = (snapshot: SourceCatalogSyncResult["snapshot"]): string => |
| 63 | contentHash(JSON.stringify(snapshot)); | 158 | contentHash(JSON.stringify(snapshot)); |
| 64 | 159 | ||
| @@ -99,25 +194,79 @@ export const readLocalSourceArtifact = (input: { · 34 unmodified lines | |||
| 99 | Effect.gen(function* () { | 194 | Effect.gen(function* () { |
| 100 | const fs = yield* FileSystem.FileSystem; | 195 | const fs = yield* FileSystem.FileSystem; |
| 101 | const path = localSourceArtifactPath(input); | 196 | const path = localSourceArtifactPath(input); |
| 197 | const legacyPath = legacyLocalSourceArtifactPath(input); | ||
| 102 | const exists = yield* fs.exists(path).pipe( | 198 | const exists = yield* fs.exists(path).pipe( |
| 103 | Effect.mapError(mapFileSystemError(path, "check source artifact path")), | 199 | Effect.mapError(mapFileSystemError(path, "check source artifact path")), |
| 104 | ); | 200 | ); |
| 105 | if (!exists) { | 201 | const legacyExists = exists |
| 202 | ? false | ||
| 203 | : yield* fs.exists(legacyPath).pipe( | ||
| 204 | Effect.mapError(mapFileSystemError(legacyPath, "check legacy source artifact path")), | ||
| 205 | ); | ||
| 206 | if (!exists && !legacyExists) { | ||
| 106 | return null; | 207 | return null; |
| 107 | } | 208 | } |
| 108 | 209 | ||
| 109 | const content = yield* fs.readFileString(path, "utf8").pipe( | 210 | const readPath = exists ? path : legacyPath; |
| 110 | Effect.mapError(mapFileSystemError(path, "read source artifact")), | 211 | const content = exists |
| 111 | ); | 212 | ? yield* fs.readFileString(readPath, "utf8").pipe( |
| 112 | return yield* Effect.try({ | 213 | Effect.mapError(mapFileSystemError(readPath, "read source artifact")), |
| 214 | ) | ||
| 215 | : yield* fs.readFile(readPath).pipe( | ||
| 216 | Effect.mapError(mapFileSystemError(readPath, "read source artifact")), | ||
| 217 | Effect.flatMap((bytes) => | ||
| 218 | Effect.try({ | ||
| 219 | try: () => new TextDecoder().decode(gunzipSync(bytes)), | ||
| 220 | catch: (cause) => | ||
| 221 | new LocalSourceArtifactDecodeError({ | ||
| 222 | message: `Invalid compressed local source artifact at ${readPath}: ${unknownLocalErrorDetails(cause)}`, | ||
| 223 | path: readPath, | ||
| 224 | details: unknownLocalErrorDetails(cause), | ||
| 225 | }), | ||
| 226 | }) | ||
| 227 | ), | ||
| 228 | ); | ||
| 229 | |||
| 230 | const artifact = yield* Effect.try({ | ||
| 113 | try: () => decodeLocalSourceArtifact(JSON.parse(content) as unknown), | 231 | try: () => decodeLocalSourceArtifact(JSON.parse(content) as unknown), |
| 114 | catch: (cause) => | 232 | catch: (cause) => |
| 115 | new LocalSourceArtifactDecodeError({ | 233 | new LocalSourceArtifactDecodeError({ |
| 116 | message: `Invalid local source artifact at ${path}: ${unknownLocalErrorDetails(cause)}`, | 234 | message: `Invalid local source artifact at ${readPath}: ${unknownLocalErrorDetails(cause)}`, |
| 117 | path, | 235 | path: readPath, |
| 118 | details: unknownLocalErrorDetails(cause), | 236 | details: unknownLocalErrorDetails(cause), |
| 119 | }), | 237 | }), |
| 120 | }); | 238 | }); |
| 239 | |||
| 240 | const rawDocuments: Record<string, NativeBlob> = {}; | ||
| 241 | for (const documentId of Object.keys(artifact.snapshot.catalog.documents)) { | ||
| 242 | const sourceDocumentPath = localSourceDocumentPath({ | ||
| 243 | context: input.context, | ||
| 244 | sourceId: input.sourceId, | ||
| 245 | documentId, | ||
| 246 | }); | ||
| 247 | const sourceDocumentExists = yield* fs.exists(sourceDocumentPath).pipe( | ||
| 248 | Effect.mapError(mapFileSystemError(sourceDocumentPath, "check source document path")), | ||
| 249 | ); | ||
| 250 | if (!sourceDocumentExists) { | ||
| 251 | continue; | ||
| 252 | } | ||
| 253 | |||
| 254 | const sourceContent = yield* fs.readFileString(sourceDocumentPath, "utf8").pipe( | ||
| 255 | Effect.mapError(mapFileSystemError(sourceDocumentPath, "read source document")), | ||
| 256 | ); | ||
| 257 | rawDocuments[documentId] = { | ||
| 258 | sourceKind: artifact.snapshot.import.sourceKind, | ||
| 259 | kind: "source_document", | ||
| 260 | value: sourceContent, | ||
| 261 | } satisfies NativeBlob; | ||
| 262 | } | ||
| 263 | |||
| 264 | return Object.keys(rawDocuments).length > 0 | ||
| 265 | ? hydrateArtifactSourceDocuments({ | ||
| 266 | artifact, | ||
| 267 | rawDocuments, | ||
| 268 | }) | ||
| 269 | : artifact; | ||
| 121 | }); | 270 | }); |
| 122 | 271 | ||
| 123 | export const writeLocalSourceArtifact = (input: { | 272 | export const writeLocalSourceArtifact = (input: { |
| @@ -129,12 +278,41 @@ export const writeLocalSourceArtifact = (input: { · 5 unmodified lines | |||
| 129 | const fs = yield* FileSystem.FileSystem; | 278 | const fs = yield* FileSystem.FileSystem; |
| 130 | const directory = join(input.context.artifactsDirectory, "sources"); | 279 | const directory = join(input.context.artifactsDirectory, "sources"); |
| 131 | const path = localSourceArtifactPath(input); | 280 | const path = localSourceArtifactPath(input); |
| 281 | const legacyPath = legacyLocalSourceArtifactPath(input); | ||
| 282 | const sourceDocumentDirectory = localSourceDocumentDirectory(input); | ||
| 283 | const split = splitArtifactSourceDocuments(input.artifact); | ||
| 132 | yield* fs.makeDirectory(directory, { recursive: true }).pipe( | 284 | yield* fs.makeDirectory(directory, { recursive: true }).pipe( |
| 133 | Effect.mapError(mapFileSystemError(directory, "create source artifact directory")), | 285 | Effect.mapError(mapFileSystemError(directory, "create source artifact directory")), |
| 134 | ); | 286 | ); |
| 135 | yield* fs.writeFileString(path, `${JSON.stringify(input.artifact, null, 2)}\n`).pipe( | 287 | yield* fs.remove(sourceDocumentDirectory, { recursive: true, force: true }).pipe( |
| 288 | Effect.mapError(mapFileSystemError(sourceDocumentDirectory, "remove source document directory")), | ||
| 289 | ); | ||
| 290 | if (split.rawDocuments.length > 0) { | ||
| 291 | yield* fs.makeDirectory(sourceDocumentDirectory, { recursive: true }).pipe( | ||
| 292 | Effect.mapError(mapFileSystemError(sourceDocumentDirectory, "create source document directory")), | ||
| 293 | ); | ||
| 294 | for (const rawDocument of split.rawDocuments) { | ||
| 295 | const sourceDocumentPath = localSourceDocumentPath({ | ||
| 296 | context: input.context, | ||
| 297 | sourceId: input.sourceId, | ||
| 298 | documentId: rawDocument.documentId, | ||
| 299 | }); | ||
| 300 | yield* fs.writeFileString(sourceDocumentPath, rawDocument.content).pipe( | ||
| 301 | Effect.mapError(mapFileSystemError(sourceDocumentPath, "write source document")), | ||
| 302 | ); | ||
| 303 | } | ||
| 304 | } | ||
| 305 | yield* fs.writeFileString(path, `${JSON.stringify(split.artifact)}\n`).pipe( | ||
| 136 | Effect.mapError(mapFileSystemError(path, "write source artifact")), | 306 | Effect.mapError(mapFileSystemError(path, "write source artifact")), |
| 137 | ); | 307 | ); |
| 308 | const legacyExists = yield* fs.exists(legacyPath).pipe( | ||
| 309 | Effect.mapError(mapFileSystemError(legacyPath, "check legacy source artifact path")), | ||
| 310 | ); | ||
| 311 | if (legacyExists) { | ||
| 312 | yield* fs.remove(legacyPath).pipe( | ||
| 313 | Effect.mapError(mapFileSystemError(legacyPath, "remove legacy source artifact")), | ||
| 314 | ); | ||
| 315 | } | ||
| 138 | }); | 316 | }); |
| 139 | 317 | ||
| 140 | export const removeLocalSourceArtifact = (input: { | 318 | export const removeLocalSourceArtifact = (input: { |
| @@ -143,14 +321,19 @@ export const removeLocalSourceArtifact = (input: { · 2 unmodified lines | |||
| 143 | }): Effect.Effect<void, LocalFileSystemError, FileSystem.FileSystem> => | 321 | }): Effect.Effect<void, LocalFileSystemError, FileSystem.FileSystem> => |
| 144 | Effect.gen(function* () { | 322 | Effect.gen(function* () { |
| 145 | const fs = yield* FileSystem.FileSystem; | 323 | const fs = yield* FileSystem.FileSystem; |
| 146 | const path = localSourceArtifactPath(input); | 324 | for (const path of [localSourceArtifactPath(input), legacyLocalSourceArtifactPath(input)]) { |
| 147 | const exists = yield* fs.exists(path).pipe( | 325 | const exists = yield* fs.exists(path).pipe( |
| 148 | Effect.mapError(mapFileSystemError(path, "check source artifact path")), | 326 | Effect.mapError(mapFileSystemError(path, "check source artifact path")), |
| 149 | ); | 327 | ); |
| 150 | if (!exists) { | 328 | if (!exists) { |
| 151 | return; | 329 | continue; |
| 330 | } | ||
| 331 | yield* fs.remove(path).pipe( | ||
| 332 | Effect.mapError(mapFileSystemError(path, "remove source artifact")), | ||
| 333 | ); | ||
| 152 | } | 334 | } |
| 153 | yield* fs.remove(path).pipe( | 335 | const sourceDocumentDirectory = localSourceDocumentDirectory(input); |
| 154 | Effect.mapError(mapFileSystemError(path, "remove source artifact")), | 336 | yield* fs.remove(sourceDocumentDirectory, { recursive: true, force: true }).pipe( |
| 337 | Effect.mapError(mapFileSystemError(sourceDocumentDirectory, "remove source document directory")), | ||
| 155 | ); | 338 | ); |
| 156 | }); | 339 | }); |
| @@ -1,4 +1,5 @@ | |||
| 1 | import { readFileSync } from "node:fs"; | 1 | import { readFileSync } from "node:fs"; |
| 2 | import { createServer } from "node:http"; | ||
| 2 | 3 | ||
| 3 | import { | 4 | import { |
| 4 | buildOpenApiToolPresentation, | 5 | buildOpenApiToolPresentation, |
| @@ -38,6 +39,7 @@ import { · 33 unmodified lines | |||
| 38 | expandCatalogToolByPath, | 39 | expandCatalogToolByPath, |
| 39 | type LoadedSourceCatalog, | 40 | type LoadedSourceCatalog, |
| 40 | } from "./source-catalog-runtime"; | 41 | } from "./source-catalog-runtime"; |
| 42 | import { invokeIrTool } from "./ir-execution"; | ||
| 41 | import { | 43 | import { |
| 42 | createGoogleDiscoveryCatalogSnapshot, | 44 | createGoogleDiscoveryCatalogSnapshot, |
| 43 | createGraphqlCatalogSnapshot, | 45 | createGraphqlCatalogSnapshot, |
| @@ -335,6 +337,18 @@ describe("source adapter fixture matrix", () => { · 291 unmodified lines | |||
| 335 | expect( | 337 | expect( |
| 336 | unresolvedDiagnosticsForPrefix(snapshot, "#/openapi/addProjectDomain"), | 338 | unresolvedDiagnosticsForPrefix(snapshot, "#/openapi/addProjectDomain"), |
| 337 | ).toEqual([]); | 339 | ).toEqual([]); |
| 340 | expect( | ||
| 341 | Object.values(snapshot.catalog.capabilities).some( | ||
| 342 | (capability) => capability.auth.kind !== "none", | ||
| 343 | ), | ||
| 344 | ).toBe(true); | ||
| 345 | expect( | ||
| 346 | Object.values(snapshot.catalog.responseSets).some((responseSet) => | ||
| 347 | responseSet.variants.some( | ||
| 348 | (variant) => variant.match.kind === "exact" && variant.match.status >= 400, | ||
| 349 | ), | ||
| 350 | ), | ||
| 351 | ).toBe(true); | ||
| 338 | }), | 352 | }), |
| 339 | 120_000, | 353 | 120_000, |
| 340 | ); | 354 | ); |
| @@ -401,6 +415,288 @@ describe("source adapter fixture matrix", () => { · 60 unmodified lines | |||
| 401 | expect( | 415 | expect( |
| 402 | unresolvedDiagnosticsForPrefix(snapshot, "#/openapi/apiKey.createApiKey"), | 416 | unresolvedDiagnosticsForPrefix(snapshot, "#/openapi/apiKey.createApiKey"), |
| 403 | ).toEqual([]); | 417 | ).toEqual([]); |
| 418 | }), | ||
| 419 | 120_000, | ||
| 420 | ); | ||
| 421 | |||
| 422 | it.effect( | ||
| 423 | "executes imported OpenAPI tools with scoped servers and serialized parameters", | ||
| 424 | () => | ||
| 425 | Effect.tryPromise({ | ||
| 426 | try: async () => { | ||
| 427 | const requests: Array<{ | ||
| 428 | method: string; | ||
| 429 | path: string; | ||
| 430 | query: string; | ||
| 431 | headers: Record<string, string | string[] | undefined>; | ||
| 432 | body: string; | ||
| 433 | }> = []; | ||
| 434 | |||
| 435 | const server = createServer((request, response) => { | ||
| 436 | let body = ""; | ||
| 437 | request.setEncoding("utf8"); | ||
| 438 | request.on("data", (chunk) => { | ||
| 439 | body += chunk; | ||
| 440 | }); | ||
| 441 | request.on("end", () => { | ||
| 442 | const url = new URL(request.url ?? "/", "http://127.0.0.1"); | ||
| 443 | requests.push({ | ||
| 444 | method: request.method ?? "GET", | ||
| 445 | path: url.pathname, | ||
| 446 | query: url.search, | ||
| 447 | headers: request.headers, | ||
| 448 | body, | ||
| 449 | }); | ||
| 450 | response.statusCode = 200; | ||
| 451 | response.setHeader("content-type", "application/json"); | ||
| 452 | response.end(JSON.stringify({ ok: true })); | ||
| 453 | }); | ||
| 454 | }); | ||
| 455 | |||
| 456 | await new Promise<void>((resolve, reject) => { | ||
| 457 | server.listen(0, "127.0.0.1", (error?: Error) => { | ||
| 458 | if (error) { | ||
| 459 | reject(error); | ||
| 460 | return; | ||
| 461 | } | ||
| 462 | resolve(); | ||
| 463 | }); | ||
| 464 | }); | ||
| 465 | |||
| 466 | try { | ||
| 467 | const address = server.address(); | ||
| 468 | if (!address || typeof address === "string") { | ||
| 469 | throw new Error("Failed to resolve execution fixture server"); | ||
| 470 | } | ||
| 471 | |||
| 472 | const baseUrl = `http://127.0.0.1:${address.port}`; | ||
| 473 | const source = makeSource({ | ||
| 474 | id: "src_openapi_execution_fixture", | ||
| 475 | name: "Serialized API", | ||
| 476 | kind: "openapi", | ||
| 477 | endpoint: baseUrl, | ||
| 478 | namespace: "serialized", | ||
| 479 | binding: { | ||
| 480 | specUrl: `${baseUrl}/openapi.json`, | ||
| 481 | defaultHeaders: null, | ||
| 482 | }, | ||
| 483 | }); | ||
| 484 | const specText = JSON.stringify({ | ||
| 485 | openapi: "3.1.0", | ||
| 486 | info: { | ||
| 487 | title: "Serialized API", | ||
| 488 | version: "1.0.0", | ||
| 489 | }, | ||
| 490 | servers: [{ | ||
| 491 | url: "/v1", | ||
| 492 | }], | ||
| 493 | paths: { | ||
| 494 | "/items/{itemId}": { | ||
| 495 | get: { | ||
| 496 | operationId: "items.getItem", | ||
| 497 | servers: [{ | ||
| 498 | url: "/v2", | ||
| 499 | }], | ||
| 500 | parameters: [ | ||
| 501 | { | ||
| 502 | name: "itemId", | ||
| 503 | in: "path", | ||
| 504 | required: true, | ||
| 505 | style: "label", | ||
| 506 | explode: true, | ||
| 507 | schema: { | ||
| 508 | type: "array", | ||
| 509 | items: { | ||
| 510 | type: "string", | ||
| 511 | }, | ||
| 512 | }, | ||
| 513 | }, | ||
| 514 | { | ||
| 515 | name: "filter", | ||
| 516 | in: "query", | ||
| 517 | style: "deepObject", | ||
| 518 | explode: true, | ||
| 519 | schema: { | ||
| 520 | type: "object", | ||
| 521 | additionalProperties: { | ||
| 522 | type: "string", | ||
| 523 | }, | ||
| 524 | }, | ||
| 525 | }, | ||
| 526 | { | ||
| 527 | name: "search", | ||
| 528 | in: "query", | ||
| 529 | allowReserved: true, | ||
| 530 | schema: { | ||
| 531 | type: "string", | ||
| 532 | }, | ||
| 533 | }, | ||
| 534 | { | ||
| 535 | name: "X-Trace", | ||
| 536 | in: "header", | ||
| 537 | schema: { | ||
| 538 | type: "array", | ||
| 539 | items: { | ||
| 540 | type: "string", | ||
| 541 | }, | ||
| 542 | }, | ||
| 543 | }, | ||
| 544 | ], | ||
| 545 | responses: { | ||
| 546 | "200": { | ||
| 547 | description: "ok", | ||
| 548 | content: { | ||
| 549 | "application/json": { | ||
| 550 | schema: { | ||
| 551 | type: "object", | ||
| 552 | properties: { | ||
| 553 | ok: { | ||
| 554 | type: "boolean", | ||
| 555 | }, | ||
| 556 | }, | ||
| 557 | }, | ||
| 558 | }, | ||
| 559 | }, | ||
| 560 | }, | ||
| 561 | }, | ||
| 562 | }, | ||
| 563 | }, | ||
| 564 | "/forms": { | ||
| 565 | post: { | ||
| 566 | operationId: "forms.submit", | ||
| 567 | requestBody: { | ||
| 568 | required: true, | ||
| 569 | content: { | ||
| 570 | "application/x-www-form-urlencoded": { | ||
| 571 | schema: { | ||
| 572 | type: "object", | ||
| 573 | properties: { | ||
| 574 | title: { | ||
| 575 | type: "string", | ||
| 576 | }, | ||
| 577 | state: { | ||
| 578 | type: "string", | ||
| 579 | }, | ||
| 580 | }, | ||
| 581 | required: ["title"], | ||
| 582 | }, | ||
| 583 | }, | ||
| 584 | }, | ||
| 585 | }, | ||
| 586 | responses: { | ||
| 587 | "200": { | ||
| 588 | description: "ok", | ||
| 589 | content: { | ||
| 590 | "application/json": { | ||
| 591 | schema: { | ||
| 592 | type: "object", | ||
| 593 | properties: { | ||
| 594 | ok: { | ||
| 595 | type: "boolean", | ||
| 596 | }, | ||
| 597 | }, | ||
| 598 | }, | ||
| 599 | }, | ||
| 600 | }, | ||
| 601 | }, | ||
| 602 | }, | ||
| 603 | }, | ||
| 604 | }, | ||
| 605 | }, | ||
| 606 | }); | ||
| 607 | |||
| 608 | const { snapshot } = await Effect.runPromise( | ||
| 609 | openApiSnapshotFromFixture({ | ||
| 610 | source, | ||
| 611 | specText, | ||
| 612 | documentKey: `${baseUrl}/openapi.json`, | ||
| 613 | }), | ||
| 614 | ); | ||
| 615 | const loadedCatalog = makeLoadedCatalog({ | ||
| 616 | source, | ||
| 617 | snapshot, | ||
| 618 | }); | ||
| 619 | const getItemTool = await Effect.runPromise( | ||
| 620 | expandCatalogToolByPath({ | ||
| 621 | catalogs: [loadedCatalog], | ||
| 622 | path: "serialized.items.getItem", | ||
| 623 | }), | ||
| 624 | ); | ||
| 625 | const submitFormTool = await Effect.runPromise( | ||
| 626 | expandCatalogToolByPath({ | ||
| 627 | catalogs: [loadedCatalog], | ||
| 628 | path: "serialized.forms.submit", | ||
| 629 | }), | ||
| 630 | ); | ||
| 631 | |||
| 632 | if (!getItemTool || !submitFormTool) { | ||
| 633 | throw new Error("Expected OpenAPI execution fixture tools to resolve"); | ||
| 634 | } | ||
| 635 | |||
| 636 | const auth = { | ||
| 637 | placements: [], | ||
| 638 | headers: {}, | ||
| 639 | queryParams: {}, | ||
| 640 | cookies: {}, | ||
| 641 | bodyValues: {}, | ||
| 642 | expiresAt: null, | ||
| 643 | refreshAfter: null, | ||
| 644 | } as const; | ||
| 645 | |||
| 646 | await Effect.runPromise( | ||
| 647 | invokeIrTool({ | ||
| 648 | workspaceId: source.workspaceId, | ||
| 649 | accountId: "acct_fixture" as any, | ||
| 650 | tool: getItemTool, | ||
| 651 | auth, | ||
| 652 | args: { | ||
| 653 | itemId: ["alpha", "beta"], | ||
| 654 | filter: { | ||
| 655 | status: "open", | ||
| 656 | }, | ||
| 657 | search: "refs/heads/main?draft=true", | ||
| 658 | "X-Trace": ["a", "b"], | ||
| 659 | }, | ||
| 660 | }), | ||
| 661 | ); | ||
| 662 | await Effect.runPromise( | ||
| 663 | invokeIrTool({ | ||
| 664 | workspaceId: source.workspaceId, | ||
| 665 | accountId: "acct_fixture" as any, | ||
| 666 | tool: submitFormTool, | ||
| 667 | auth, | ||
| 668 | args: { | ||
| 669 | body: { | ||
| 670 | title: "Bug report", | ||
| 671 | state: "open", | ||
| 672 | }, | ||
| 673 | }, | ||
| 674 | }), | ||
| 675 | ); | ||
| 676 | |||
| 677 | expect(requests).toHaveLength(2); | ||
| 678 | expect(requests[0]?.path).toBe("/v2/items/.alpha.beta"); | ||
| 679 | expect(requests[0]?.query).toContain("filter%5Bstatus%5D=open"); | ||
| 680 | expect(requests[0]?.query).toContain("search=refs/heads/main?draft=true"); | ||
| 681 | expect(requests[0]?.headers["x-trace"]).toBe("a,b"); | ||
| 682 | expect(requests[1]?.path).toBe("/v1/forms"); | ||
| 683 | expect(String(requests[1]?.headers["content-type"])).toContain("application/x-www-form-urlencoded"); | ||
| 684 | expect(requests[1]?.body).toContain("title=Bug+report"); | ||
| 685 | expect(requests[1]?.body).toContain("state=open"); | ||
| 686 | } finally { | ||
| 687 | await new Promise<void>((resolve, reject) => { | ||
| 688 | server.close((error) => { | ||
| 689 | if (error) { | ||
| 690 | reject(error); | ||
| 691 | return; | ||
| 692 | } | ||
| 693 | resolve(); | ||
| 694 | }); | ||
| 695 | }); | ||
| 696 | } | ||
| 697 | }, | ||
| 698 | catch: (cause) => | ||
| 699 | cause instanceof Error ? cause : new Error(String(cause)), | ||
| 404 | }), | 700 | }), |
| 405 | 120_000, | 701 | 120_000, |
| 406 | ); | 702 | ); |
| @@ -0,0 +1,227 @@ | |||
| 1 | import { randomUUID } from "node:crypto"; | ||
| 2 | |||
| 3 | import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; | ||
| 4 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; | ||
| 5 | import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; | ||
| 6 | import { describe, expect, it } from "@effect/vitest"; | ||
| 7 | import { SourceIdSchema } from "#schema"; | ||
| 8 | |||
| 9 | import * as Effect from "effect/Effect"; | ||
| 10 | import { z } from "zod/v4"; | ||
| 11 | |||
| 12 | import { createSourceFromPayload } from "../source-definitions"; | ||
| 13 | import { mcpSourceAdapter } from "./mcp"; | ||
| 14 | |||
| 15 | type RealMcpServer = { | ||
| 16 | endpoint: string; | ||
| 17 | close: () => Promise<void>; | ||
| 18 | }; | ||
| 19 | |||
| 20 | const makeRealMcpServer = Effect.acquireRelease( | ||
| 21 | Effect.promise<RealMcpServer>( | ||
| 22 | () => | ||
| 23 | new Promise<RealMcpServer>((resolve, reject) => { | ||
| 24 | const createServerForRequest = () => { | ||
| 25 | const mcp = new McpServer( | ||
| 26 | { | ||
| 27 | name: "mcp-adapter-test-server", | ||
| 28 | version: "1.0.0", | ||
| 29 | title: "Adapter Test Server", | ||
| 30 | description: "Server for MCP adapter tests", | ||
| 31 | websiteUrl: "https://example.test/mcp", | ||
| 32 | }, | ||
| 33 | { | ||
| 34 | capabilities: { | ||
| 35 | tools: { | ||
| 36 | listChanged: true, | ||
| 37 | }, | ||
| 38 | logging: {}, | ||
| 39 | }, | ||
| 40 | }, | ||
| 41 | ); | ||
| 42 | |||
| 43 | mcp.registerTool( | ||
| 44 | "read_file", | ||
| 45 | { | ||
| 46 | title: "Read File", | ||
| 47 | description: "Read a file from memory", | ||
| 48 | inputSchema: { | ||
| 49 | path: z.string(), | ||
| 50 | }, | ||
| 51 | annotations: { | ||
| 52 | title: "Read File (Annotated)", | ||
| 53 | readOnlyHint: true, | ||
| 54 | destructiveHint: false, | ||
| 55 | idempotentHint: true, | ||
| 56 | openWorldHint: false, | ||
| 57 | }, | ||
| 58 | _meta: { | ||
| 59 | category: "filesystem", | ||
| 60 | }, | ||
| 61 | }, | ||
| 62 | async ({ path }: { path: string }) => ({ | ||
| 63 | content: [{ | ||
| 64 | type: "text", | ||
| 65 | text: `read:${path}`, | ||
| 66 | }], | ||
| 67 | }), | ||
| 68 | ); | ||
| 69 | |||
| 70 | const transport = new StreamableHTTPServerTransport({ | ||
| 71 | sessionIdGenerator: undefined, | ||
| 72 | }); | ||
| 73 | |||
| 74 | return { | ||
| 75 | mcp, | ||
| 76 | transport, | ||
| 77 | }; | ||
| 78 | }; | ||
| 79 | |||
| 80 | const app = createMcpExpressApp({ host: "127.0.0.1" }); | ||
| 81 | |||
| 82 | const handle = async (req: any, res: any, parsedBody?: unknown) => { | ||
| 83 | const { mcp, transport } = createServerForRequest(); | ||
| 84 | |||
| 85 | try { | ||
| 86 | await mcp.connect(transport); | ||
| 87 | await transport.handleRequest(req, res, parsedBody); | ||
| 88 | } finally { | ||
| 89 | await transport.close().catch(() => undefined); | ||
| 90 | await mcp.close().catch(() => undefined); | ||
| 91 | } | ||
| 92 | }; | ||
| 93 | |||
| 94 | app.post("/mcp", async (req: any, res: any) => { | ||
| 95 | await handle(req, res, req.body); | ||
| 96 | }); | ||
| 97 | |||
| 98 | app.get("/mcp", async (req: any, res: any) => { | ||
| 99 | await handle(req, res); | ||
| 100 | }); | ||
| 101 | |||
| 102 | app.delete("/mcp", async (req: any, res: any) => { | ||
| 103 | await handle(req, res, req.body); | ||
| 104 | }); | ||
| 105 | |||
| 106 | const listener = app.listen(0, "127.0.0.1", () => { | ||
| 107 | const address = listener.address(); | ||
| 108 | if (!address || typeof address === "string") { | ||
| 109 | reject(new Error("failed to resolve MCP adapter test server address")); | ||
| 110 | return; | ||
| 111 | } | ||
| 112 | |||
| 113 | resolve({ | ||
| 114 | endpoint: `http://127.0.0.1:${address.port}/mcp`, | ||
| 115 | close: async () => { | ||
| 116 | await new Promise<void>((closeResolve, closeReject) => { | ||
| 117 | listener.close((error: Error | undefined) => { | ||
| 118 | if (error) { | ||
| 119 | closeReject(error); | ||
| 120 | return; | ||
| 121 | } | ||
| 122 | closeResolve(); | ||
| 123 | }); | ||
| 124 | }); | ||
| 125 | }, | ||
| 126 | }); | ||
| 127 | }); | ||
| 128 | |||
| 129 | listener.once("error", reject); | ||
| 130 | }), | ||
| 131 | ), | ||
| 132 | (server: RealMcpServer) => | ||
| 133 | Effect.tryPromise({ | ||
| 134 | try: () => server.close(), | ||
| 135 | catch: (error: unknown) => | ||
| 136 | error instanceof Error ? error : new Error(String(error)), | ||
| 137 | }).pipe(Effect.orDie), | ||
| 138 | ); | ||
| 139 | |||
| 140 | describe("mcp source adapter", () => { | ||
| 141 | it.scoped("syncs MCP annotations and introspection metadata into snapshot", () => | ||
| 142 | Effect.gen(function* () { | ||
| 143 | const realServer = yield* makeRealMcpServer; | ||
| 144 | const source = yield* createSourceFromPayload({ | ||
| 145 | workspaceId: "ws_test" as any, | ||
| 146 | sourceId: SourceIdSchema.make(`src_${randomUUID()}`), | ||
| 147 | payload: { | ||
| 148 | name: "MCP Demo", | ||
| 149 | kind: "mcp", | ||
| 150 | endpoint: realServer.endpoint, | ||
| 151 | namespace: "mcp.demo", | ||
| 152 | binding: { | ||
| 153 | transport: "streamable-http", | ||
| 154 | queryParams: null, | ||
| 155 | headers: null, | ||
| 156 | }, | ||
| 157 | importAuthPolicy: "reuse_runtime", | ||
| 158 | importAuth: { kind: "none" }, | ||
| 159 | auth: { kind: "none" }, | ||
| 160 | status: "connected", | ||
| 161 | enabled: true, | ||
| 162 | }, | ||
| 163 | now: Date.now(), | ||
| 164 | }); | ||
| 165 | |||
| 166 | const syncResult = yield* mcpSourceAdapter.syncCatalog({ | ||
| 167 | source, | ||
| 168 | resolveSecretMaterial: () => | ||
| 169 | Effect.fail(new Error("unexpected secret lookup")), | ||
| 170 | resolveAuthMaterialForSlot: () => | ||
| 171 | Effect.succeed({ | ||
| 172 | placements: [], | ||
| 173 | headers: {}, | ||
| 174 | queryParams: {}, | ||
| 175 | cookies: {}, | ||
| 176 | bodyValues: {}, | ||
| 177 | expiresAt: null, | ||
| 178 | refreshAfter: null, | ||
| 179 | }), | ||
| 180 | }); | ||
| 181 | |||
| 182 | const capability = Object.values(syncResult.snapshot.catalog.capabilities)[0]!; | ||
| 183 | const executable = Object.values(syncResult.snapshot.catalog.executables)[0]!; | ||
| 184 | const document = Object.values(syncResult.snapshot.catalog.documents)[0]!; | ||
| 185 | const rawManifest = document.native?.[0]?.value; | ||
| 186 | |||
| 187 | expect(capability.surface.title).toBe("Read File"); | ||
| 188 | expect(capability.semantics).toMatchObject({ | ||
| 189 | effect: "read", | ||
| 190 | safe: true, | ||
| 191 | idempotent: true, | ||
| 192 | destructive: false, | ||
| 193 | }); | ||
| 194 | expect(capability.native).toBeUndefined(); | ||
| 195 | expect(executable.native).toBeUndefined(); | ||
| 196 | |||
| 197 | expect(typeof rawManifest).toBe("string"); | ||
| 198 | const manifest = JSON.parse(rawManifest as string) as { | ||
| 199 | server?: { | ||
| 200 | info?: { | ||
| 201 | name?: string; | ||
| 202 | }; | ||
| 203 | }; | ||
| 204 | tools?: Array<Record<string, unknown>>; | ||
| 205 | listTools?: { | ||
| 206 | rawResult?: { | ||
| 207 | tools?: Array<Record<string, unknown>>; | ||
| 208 | }; | ||
| 209 | }; | ||
| 210 | }; | ||
| 211 | |||
| 212 | expect(manifest.server?.info?.name).toBe("mcp-adapter-test-server"); | ||
| 213 | expect(manifest.tools?.[0]).toMatchObject({ | ||
| 214 | displayTitle: "Read File", | ||
| 215 | annotations: { | ||
| 216 | readOnlyHint: true, | ||
| 217 | }, | ||
| 218 | }); | ||
| 219 | expect(manifest.listTools?.rawResult?.tools?.[0]).toMatchObject({ | ||
| 220 | title: "Read File", | ||
| 221 | _meta: { | ||
| 222 | category: "filesystem", | ||
| 223 | }, | ||
| 224 | }); | ||
| 225 | }), | ||
| 226 | ); | ||
| 227 | }); | ||
| @@ -4,6 +4,7 @@ import { · 3 unmodified lines | |||
| 4 | import { | 4 | import { |
| 5 | createSdkMcpConnector, | 5 | createSdkMcpConnector, |
| 6 | discoverMcpToolsFromConnector, | 6 | discoverMcpToolsFromConnector, |
| 7 | type McpServerMetadata, | ||
| 7 | type McpToolManifest, | 8 | type McpToolManifest, |
| 8 | type McpToolManifestEntry, | 9 | type McpToolManifestEntry, |
| 9 | } from "@executor/codemode-mcp"; | 10 | } from "@executor/codemode-mcp"; |
| @@ -122,31 +123,41 @@ const mcpBindingConfigFromSource = ( · 112 unmodified lines | |||
| 122 | } satisfies McpBindingConfig; | 123 | } satisfies McpBindingConfig; |
| 123 | }); | 124 | }); |
| 124 | 125 | ||
| 125 | const mcpCatalogOperationFromManifestEntry = (entry: McpToolManifestEntry): McpCatalogOperationInput => ({ | 126 | const effectFromMcpManifestEntry = (entry: McpToolManifestEntry): McpCatalogOperationInput["effect"] => |
| 126 | toolId: entry.toolId, | 127 | entry.annotations?.readOnlyHint === true ? "read" : "write"; |
| 127 | title: entry.toolName, | 128 | |
| 128 | description: entry.description ?? null, | 129 | const mcpCatalogOperationFromManifestEntry = (input: { |
| 129 | effect: "action", | 130 | entry: McpToolManifestEntry; |
| 130 | inputSchema: entry.inputSchema, | 131 | server: McpServerMetadata | null | undefined; |
| 131 | outputSchema: entry.outputSchema, | 132 | }): McpCatalogOperationInput => ({ |
| 133 | toolId: input.entry.toolId, | ||
| 134 | title: input.entry.displayTitle ?? input.entry.title ?? input.entry.toolName, | ||
| 135 | description: input.entry.description ?? null, | ||
| 136 | effect: effectFromMcpManifestEntry(input.entry), | ||
| 137 | inputSchema: input.entry.inputSchema, | ||
| 138 | outputSchema: input.entry.outputSchema, | ||
| 132 | providerData: { | 139 | providerData: { |
| 133 | toolId: entry.toolId, | 140 | toolId: input.entry.toolId, |
| 134 | toolName: entry.toolName, | 141 | toolName: input.entry.toolName, |
| 135 | description: entry.description ?? null, | 142 | displayTitle: input.entry.displayTitle ?? input.entry.title ?? input.entry.toolName, |
| 143 | title: input.entry.title ?? null, | ||
| 144 | description: input.entry.description ?? null, | ||
| 145 | annotations: input.entry.annotations ?? null, | ||
| 146 | execution: input.entry.execution ?? null, | ||
| 147 | icons: input.entry.icons ?? null, | ||
| 148 | meta: input.entry.meta ?? null, | ||
| 149 | rawTool: input.entry.rawTool ?? null, | ||
| 150 | server: input.server ?? null, | ||
| 136 | }, | 151 | }, |
| 137 | }); | 152 | }); |
| 138 | 153 | ||
| 139 | export const catalogSyncResultFromMcpManifestEntries = (input: { | 154 | export const catalogSyncResultFromMcpManifest = (input: { |
| 140 | source: Source; | 155 | source: Source; |
| 141 | endpoint: string; | 156 | endpoint: string; |
| 142 | manifestEntries: readonly McpToolManifestEntry[]; | 157 | manifest: McpToolManifest; |
| 143 | }): SourceCatalogSyncResult => { | 158 | }): SourceCatalogSyncResult => { |
| 144 | const now = Date.now(); | 159 | const now = Date.now(); |
| 145 | const manifest: McpToolManifest = { | 160 | const manifestJson = JSON.stringify(input.manifest); |
| 146 | version: 1, | ||
| 147 | tools: input.manifestEntries, | ||
| 148 | }; | ||
| 149 | const manifestJson = JSON.stringify(manifest); | ||
| 150 | const manifestHash = contentHash(manifestJson); | 161 | const manifestHash = contentHash(manifestJson); |
| 151 | 162 | ||
| 152 | return { | 163 | return { |
| @@ -158,7 +169,12 @@ export const catalogSyncResultFromMcpManifestEntries = (input: { · 5 unmodified lines | |||
| 158 | contentText: manifestJson, | 169 | contentText: manifestJson, |
| 159 | fetchedAt: now, | 170 | fetchedAt: now, |
| 160 | }], | 171 | }], |
| 161 | operations: input.manifestEntries.map(mcpCatalogOperationFromManifestEntry), | 172 | operations: input.manifest.tools.map((entry) => |
| 173 | mcpCatalogOperationFromManifestEntry({ | ||
| 174 | entry, | ||
| 175 | server: input.manifest.server, | ||
| 176 | }) | ||
| 177 | ), | ||
| 162 | }), | 178 | }), |
| 163 | sourceHash: manifestHash, | 179 | sourceHash: manifestHash, |
| 164 | }; | 180 | }; |
| @@ -268,10 +284,10 @@ export const mcpSourceAdapter: SourceAdapter = { · 103 unmodified lines | |||
| 268 | ), | 284 | ), |
| 269 | ); | 285 | ); |
| 270 | 286 | ||
| 271 | return catalogSyncResultFromMcpManifestEntries({ | 287 | return catalogSyncResultFromMcpManifest({ |
| 272 | source, | 288 | source, |
| 273 | endpoint: source.endpoint, | 289 | endpoint: source.endpoint, |
| 274 | manifestEntries: discovered.manifest.tools, | 290 | manifest: discovered.manifest, |
| 275 | }); | 291 | }); |
| 276 | }), | 292 | }), |
| 277 | }; | 293 | }; |
| @@ -1119,7 +1119,7 @@ const connectMcpSourceInternal = (input: { · 1118 unmodified lines | |||
| 1119 | const indexed = yield* Effect.either( | 1119 | const indexed = yield* Effect.either( |
| 1120 | input.sourceCatalogSync.persistMcpCatalogSnapshotFromManifest({ | 1120 | input.sourceCatalogSync.persistMcpCatalogSnapshotFromManifest({ |
| 1121 | source: connected, | 1121 | source: connected, |
| 1122 | manifestEntries: result.manifest.tools, | 1122 | manifest: result.manifest, |
| 1123 | }), | 1123 | }), |
| 1124 | ); | 1124 | ); |
| 1125 | 1125 | ||
| @@ -10,6 +10,7 @@ import { · 9 unmodified lines | |||
| 10 | import { | 10 | import { |
| 11 | createGoogleDiscoveryCatalogSnapshot, | 11 | createGoogleDiscoveryCatalogSnapshot, |
| 12 | createGraphqlCatalogSnapshot, | 12 | createGraphqlCatalogSnapshot, |
| 13 | createMcpCatalogSnapshot, | ||
| 13 | createOpenApiCatalogSnapshot, | 14 | createOpenApiCatalogSnapshot, |
| 14 | } from "./source-catalog-snapshot"; | 15 | } from "./source-catalog-snapshot"; |
| 15 | 16 | ||
| @@ -129,12 +130,370 @@ describe("source-catalog-snapshot", () => { · 113 unmodified lines | |||
| 129 | 130 | ||
| 130 | expect(capability.surface.toolPath).toEqual(["google", "calendar", "events", "update"]); | 131 | expect(capability.surface.toolPath).toEqual(["google", "calendar", "events", "update"]); |
| 131 | expect(capability.semantics.effect).toBe("write"); | 132 | expect(capability.semantics.effect).toBe("write"); |
| 133 | expect(capability.auth.kind).toBe("none"); | ||
| 132 | expect(executable.protocol).toBe("http"); | 134 | expect(executable.protocol).toBe("http"); |
| 133 | expect(executable.method).toBe("PATCH"); | 135 | expect(executable.method).toBe("PATCH"); |
| 134 | expect(executable.pathTemplate).toBe("/calendars/{calendarId}/events/{eventId}"); | 136 | expect(executable.pathTemplate).toBe("/calendars/{calendarId}/events/{eventId}"); |
| 137 | expect(executable.native).toBeUndefined(); | ||
| 138 | expect(capability.native).toBeUndefined(); | ||
| 135 | expect(responseSet.variants).toHaveLength(1); | 139 | expect(responseSet.variants).toHaveLength(1); |
| 136 | }); | 140 | }); |
| 137 | 141 | ||
| 142 | it("projects OpenAPI auth requirements and response variants into IR", () => { | ||
| 143 | const snapshot = createOpenApiCatalogSnapshot({ | ||
| 144 | source: baseSource, | ||
| 145 | documents: [{ | ||
| 146 | documentKind: "openapi", | ||
| 147 | documentKey: "https://api.example.test/openapi.json", | ||
| 148 | contentText: "{}", | ||
| 149 | fetchedAt: 1, | ||
| 150 | }], | ||
| 151 | operations: [{ | ||
| 152 | toolId: "projects.get", | ||
| 153 | title: "Get project", | ||
| 154 | description: "Get a project", | ||
| 155 | effect: "read", | ||
| 156 | inputSchema: { | ||
| 157 | type: "object", | ||
| 158 | properties: { | ||
| 159 | projectId: { | ||
| 160 | type: "string", | ||
| 161 | }, | ||
| 162 | }, | ||
| 163 | required: ["projectId"], | ||
| 164 | additionalProperties: false, | ||
| 165 | }, | ||
| 166 | outputSchema: { | ||
| 167 | type: "object", | ||
| 168 | properties: { | ||
| 169 | id: { | ||
| 170 | type: "string", | ||
| 171 | }, | ||
| 172 | }, | ||
| 173 | required: ["id"], | ||
| 174 | additionalProperties: false, | ||
| 175 | }, | ||
| 176 | providerData: { | ||
| 177 | kind: "openapi", | ||
| 178 | toolId: "projects.get", | ||
| 179 | rawToolId: "projects.get", | ||
| 180 | group: "projects", | ||
| 181 | leaf: "get", | ||
| 182 | tags: ["projects"], | ||
| 183 | method: "get", | ||
| 184 | path: "/projects/{projectId}", | ||
| 185 | operationHash: "op_hash_projects_get", | ||
| 186 | invocation: { | ||
| 187 | method: "get", | ||
| 188 | pathTemplate: "/projects/{projectId}", | ||
| 189 | parameters: [ | ||
| 190 | { name: "projectId", location: "path", required: true }, | ||
| 191 | ], | ||
| 192 | requestBody: null, | ||
| 193 | }, | ||
| 194 | documentation: { | ||
| 195 | summary: "Get project", | ||
| 196 | parameters: [], | ||
| 197 | response: { | ||
| 198 | statusCode: "200", | ||
| 199 | description: "Project", | ||
| 200 | contentTypes: ["application/json"], | ||
| 201 | }, | ||
| 202 | }, | ||
| 203 | authRequirement: { | ||
| 204 | kind: "anyOf", | ||
| 205 | items: [ | ||
| 206 | { | ||
| 207 | kind: "scheme", | ||
| 208 | schemeName: "bearerToken", | ||
| 209 | }, | ||
| 210 | { | ||
| 211 | kind: "scheme", | ||
| 212 | schemeName: "apiKeyHeader", | ||
| 213 | }, | ||
| 214 | ], | ||
| 215 | }, | ||
| 216 | securitySchemes: [ | ||
| 217 | { | ||
| 218 | schemeName: "bearerToken", | ||
| 219 | schemeType: "http", | ||
| 220 | description: "Bearer auth.", | ||
| 221 | scheme: "bearer", | ||
| 222 | bearerFormat: "JWT", | ||
| 223 | }, | ||
| 224 | { | ||
| 225 | schemeName: "apiKeyHeader", | ||
| 226 | schemeType: "apiKey", | ||
| 227 | placementIn: "header", | ||
| 228 | placementName: "x-api-key", | ||
| 229 | }, | ||
| 230 | ], | ||
| 231 | responses: [ | ||
| 232 | { | ||
| 233 | statusCode: "200", | ||
| 234 | description: "Project", | ||
| 235 | contentTypes: ["application/json"], | ||
| 236 | schema: { | ||
| 237 | type: "object", | ||
| 238 | properties: { | ||
| 239 | id: { | ||
| 240 | type: "string", | ||
| 241 | }, | ||
| 242 | }, | ||
| 243 | required: ["id"], | ||
| 244 | additionalProperties: false, | ||
| 245 | }, | ||
| 246 | }, | ||
| 247 | { | ||
| 248 | statusCode: "404", | ||
| 249 | description: "Missing", | ||
| 250 | contentTypes: ["application/json"], | ||
| 251 | schema: { | ||
| 252 | type: "object", | ||
| 253 | properties: { | ||
| 254 | error: { | ||
| 255 | type: "string", | ||
| 256 | }, | ||
| 257 | }, | ||
| 258 | required: ["error"], | ||
| 259 | additionalProperties: false, | ||
| 260 | }, | ||
| 261 | }, | ||
| 262 | ], | ||
| 263 | }, | ||
| 264 | }], | ||
| 265 | }); | ||
| 266 | |||
| 267 | const capability = Object.values(snapshot.catalog.capabilities)[0]!; | ||
| 268 | const executable = Object.values(snapshot.catalog.executables)[0]!; | ||
| 269 | const responseSet = Object.values(snapshot.catalog.responseSets)[0]!; | ||
| 270 | const securitySchemes = Object.values(snapshot.catalog.symbols).filter((symbol) => symbol.kind === "securityScheme"); | ||
| 271 | |||
| 272 | expect(capability.auth).toMatchObject({ | ||
| 273 | kind: "anyOf", | ||
| 274 | }); | ||
| 275 | expect(executable.native).toBeUndefined(); | ||
| 276 | expect(capability.native).toBeUndefined(); | ||
| 277 | expect(securitySchemes).toHaveLength(2); | ||
| 278 | expect( | ||
| 279 | responseSet.variants.map((variant) => variant.match), | ||
| 280 | ).toEqual([ | ||
| 281 | { kind: "exact", status: 200 }, | ||
| 282 | { kind: "exact", status: 404 }, | ||
| 283 | ]); | ||
| 284 | }); | ||
| 285 | |||
| 286 | it("projects OpenAPI servers, parameter serialization, and response headers into IR", () => { | ||
| 287 | const snapshot = createOpenApiCatalogSnapshot({ | ||
| 288 | source: baseSource, | ||
| 289 | documents: [{ | ||
| 290 | documentKind: "openapi", | ||
| 291 | documentKey: "https://api.example.test/openapi.json", | ||
| 292 | contentText: "{}", | ||
| 293 | fetchedAt: 1, | ||
| 294 | }], | ||
| 295 | operations: [{ | ||
| 296 | toolId: "items.get", | ||
| 297 | title: "Get item", | ||
| 298 | description: "Get an item", | ||
| 299 | effect: "read", | ||
| 300 | inputSchema: { | ||
| 301 | type: "object", | ||
| 302 | properties: { | ||
| 303 | itemId: { | ||
| 304 | type: "array", | ||
| 305 | items: { | ||
| 306 | type: "string", | ||
| 307 | }, | ||
| 308 | }, | ||
| 309 | filter: { | ||
| 310 | type: "object", | ||
| 311 | additionalProperties: { | ||
| 312 | type: "string", | ||
| 313 | }, | ||
| 314 | }, | ||
| 315 | body: { | ||
| 316 | type: "object", | ||
| 317 | properties: { | ||
| 318 | title: { | ||
| 319 | type: "string", | ||
| 320 | }, | ||
| 321 | }, | ||
| 322 | }, | ||
| 323 | }, | ||
| 324 | required: ["itemId"], | ||
| 325 | additionalProperties: false, | ||
| 326 | }, | ||
| 327 | outputSchema: { | ||
| 328 | type: "object", | ||
| 329 | properties: { | ||
| 330 | id: { | ||
| 331 | type: "string", | ||
| 332 | }, | ||
| 333 | }, | ||
| 334 | required: ["id"], | ||
| 335 | additionalProperties: false, | ||
| 336 | }, | ||
| 337 | providerData: { | ||
| 338 | kind: "openapi", | ||
| 339 | toolId: "items.get", | ||
| 340 | rawToolId: "items.get", | ||
| 341 | group: "items", | ||
| 342 | leaf: "get", | ||
| 343 | tags: ["items"], | ||
| 344 | method: "get", | ||
| 345 | path: "/items/{itemId}", | ||
| 346 | operationHash: "op_hash_items_get", | ||
| 347 | invocation: { | ||
| 348 | method: "get", | ||
| 349 | pathTemplate: "/items/{itemId}", | ||
| 350 | parameters: [ | ||
| 351 | { | ||
| 352 | name: "itemId", | ||
| 353 | location: "path", | ||
| 354 | required: true, | ||
| 355 | style: "label", | ||
| 356 | explode: true, | ||
| 357 | }, | ||
| 358 | { | ||
| 359 | name: "filter", | ||
| 360 | location: "query", | ||
| 361 | required: false, | ||
| 362 | style: "deepObject", | ||
| 363 | explode: true, | ||
| 364 | }, | ||
| 365 | ], | ||
| 366 | requestBody: { | ||
| 367 | required: false, | ||
| 368 | contentTypes: ["application/x-www-form-urlencoded", "application/json"], | ||
| 369 | contents: [ | ||
| 370 | { | ||
| 371 | mediaType: "application/x-www-form-urlencoded", | ||
| 372 | schema: { | ||
| 373 | type: "object", | ||
| 374 | properties: { | ||
| 375 | title: { | ||
| 376 | type: "string", | ||
| 377 | }, | ||
| 378 | }, | ||
| 379 | }, | ||
| 380 | }, | ||
| 381 | { | ||
| 382 | mediaType: "application/json", | ||
| 383 | schema: { | ||
| 384 | type: "object", | ||
| 385 | properties: { | ||
| 386 | title: { | ||
| 387 | type: "string", | ||
| 388 | }, | ||
| 389 | }, | ||
| 390 | }, | ||
| 391 | }, | ||
| 392 | ], | ||
| 393 | }, | ||
| 394 | }, | ||
| 395 | documentation: null, | ||
| 396 | documentServers: [{ | ||
| 397 | url: "https://api.example.test/{version}", | ||
| 398 | variables: { | ||
| 399 | version: "v1", | ||
| 400 | }, | ||
| 401 | }], | ||
| 402 | servers: [{ | ||
| 403 | url: "https://regional.example.test/base", | ||
| 404 | }], | ||
| 405 | responses: [ | ||
| 406 | { | ||
| 407 | statusCode: "200", | ||
| 408 | description: "Item", | ||
| 409 | contentTypes: ["application/json", "text/plain"], | ||
| 410 | contents: [ | ||
| 411 | { | ||
| 412 | mediaType: "application/json", | ||
| 413 | schema: { | ||
| 414 | type: "object", | ||
| 415 | properties: { | ||
| 416 | id: { | ||
| 417 | type: "string", | ||
| 418 | }, | ||
| 419 | }, | ||
| 420 | required: ["id"], | ||
| 421 | additionalProperties: false, | ||
| 422 | }, | ||
| 423 | }, | ||
| 424 | { | ||
| 425 | mediaType: "text/plain", | ||
| 426 | schema: { | ||
| 427 | type: "string", | ||
| 428 | }, | ||
| 429 | }, | ||
| 430 | ], | ||
| 431 | headers: [ | ||
| 432 | { | ||
| 433 | name: "x-next-cursor", | ||
| 434 | description: "Next cursor.", | ||
| 435 | schema: { | ||
| 436 | type: "string", | ||
| 437 | }, | ||
| 438 | }, | ||
| 439 | ], | ||
| 440 | }, | ||
| 441 | ], | ||
| 442 | }, | ||
| 443 | }], | ||
| 444 | }); | ||
| 445 | |||
| 446 | const capability = Object.values(snapshot.catalog.capabilities)[0]!; | ||
| 447 | const executable = Object.values(snapshot.catalog.executables)[0]!; | ||
| 448 | const serviceScope = snapshot.catalog.scopes[capability.serviceScopeId]!; | ||
| 449 | const operationScope = snapshot.catalog.scopes[executable.scopeId]!; | ||
| 450 | const pathParameter = Object.values(snapshot.catalog.symbols).find((symbol) => | ||
| 451 | symbol.kind === "parameter" && symbol.name === "itemId" | ||
| 452 | ); | ||
| 453 | const queryParameter = Object.values(snapshot.catalog.symbols).find((symbol) => | ||
| 454 | symbol.kind === "parameter" && symbol.name === "filter" | ||
| 455 | ); | ||
| 456 | const requestBody = executable.requestBodyId | ||
| 457 | ? snapshot.catalog.symbols[executable.requestBodyId] | ||
| 458 | : undefined; | ||
| 459 | const response = Object.values(snapshot.catalog.symbols).find((symbol) => symbol.kind === "response"); | ||
| 460 | |||
| 461 | expect(serviceScope.defaults?.servers).toEqual([ | ||
| 462 | { | ||
| 463 | url: "https://api.example.test/{version}", | ||
| 464 | variables: { | ||
| 465 | version: "v1", | ||
| 466 | }, | ||
| 467 | }, | ||
| 468 | ]); | ||
| 469 | expect(operationScope.defaults?.servers).toEqual([ | ||
| 470 | { | ||
| 471 | url: "https://regional.example.test/base", | ||
| 472 | }, | ||
| 473 | ]); | ||
| 474 | expect(pathParameter).toMatchObject({ | ||
| 475 | kind: "parameter", | ||
| 476 | style: "label", | ||
| 477 | explode: true, | ||
| 478 | }); | ||
| 479 | expect(queryParameter).toMatchObject({ | ||
| 480 | kind: "parameter", | ||
| 481 | style: "deepObject", | ||
| 482 | explode: true, | ||
| 483 | }); | ||
| 484 | expect(requestBody?.kind).toBe("requestBody"); | ||
| 485 | expect(requestBody?.kind === "requestBody" ? requestBody.contents.map((content) => content.mediaType) : []).toEqual([ | ||
| 486 | "application/x-www-form-urlencoded", | ||
| 487 | "application/json", | ||
| 488 | ]); | ||
| 489 | expect(response?.kind).toBe("response"); | ||
| 490 | expect(response?.kind === "response" ? response.headerIds?.length : 0).toBe(1); | ||
| 491 | expect(response?.kind === "response" ? response.contents?.map((content) => content.mediaType) : []).toEqual([ | ||
| 492 | "application/json", | ||
| 493 | "text/plain", | ||
| 494 | ]); | ||
| 495 | }); | ||
| 496 | |||
| 138 | it("imports Google Discovery scopes as auth requirements", () => { | 497 | it("imports Google Discovery scopes as auth requirements", () => { |
| 139 | const snapshot = createGoogleDiscoveryCatalogSnapshot({ | 498 | const snapshot = createGoogleDiscoveryCatalogSnapshot({ |
| 140 | source: { | 499 | source: { |
| @@ -169,6 +528,9 @@ describe("source-catalog-snapshot", () => { · 28 unmodified lines | |||
| 169 | requestSchemaId: null, | 528 | requestSchemaId: null, |
| 170 | responseSchemaId: "FileList", | 529 | responseSchemaId: "FileList", |
| 171 | scopes: ["https://www.googleapis.com/auth/drive.readonly"], | 530 | scopes: ["https://www.googleapis.com/auth/drive.readonly"], |
| 531 | scopeDescriptions: { | ||
| 532 | "https://www.googleapis.com/auth/drive.readonly": "View your Google Drive files.", | ||
| 533 | }, | ||
| 172 | supportsMediaUpload: false, | 534 | supportsMediaUpload: false, |
| 173 | supportsMediaDownload: false, | 535 | supportsMediaDownload: false, |
| 174 | }, | 536 | }, |
| @@ -177,10 +539,134 @@ describe("source-catalog-snapshot", () => { · 2 unmodified lines | |||
| 177 | }); | 539 | }); |
| 178 | 540 | ||
| 179 | const capability = Object.values(snapshot.catalog.capabilities)[0]!; | 541 | const capability = Object.values(snapshot.catalog.capabilities)[0]!; |
| 542 | const executable = Object.values(snapshot.catalog.executables)[0]!; | ||
| 180 | const securityScheme = Object.values(snapshot.catalog.symbols).find((symbol) => symbol.kind === "securityScheme"); | 543 | const securityScheme = Object.values(snapshot.catalog.symbols).find((symbol) => symbol.kind === "securityScheme"); |
| 181 | 544 | ||
| 182 | expect(capability.auth.kind).toBe("scheme"); | 545 | expect(capability.auth.kind).toBe("scheme"); |
| 546 | expect(capability.native).toBeUndefined(); | ||
| 547 | expect(executable.native?.[0]?.value).toEqual({ | ||
| 548 | invocation: { | ||
| 549 | rootUrl: "https://www.googleapis.com/", | ||
| 550 | servicePath: "drive/v3/", | ||
| 551 | }, | ||
| 552 | }); | ||
| 183 | expect(securityScheme?.kind).toBe("securityScheme"); | 553 | expect(securityScheme?.kind).toBe("securityScheme"); |
| 554 | expect(securityScheme?.kind === "securityScheme" ? securityScheme.oauth?.scopes : undefined).toEqual({ | ||
| 555 | "https://www.googleapis.com/auth/drive.readonly": "View your Google Drive files.", | ||
| 556 | }); | ||
| 557 | }); | ||
| 558 | |||
| 559 | it("deduplicates structurally identical JSON schema shapes across operations", () => { | ||
| 560 | const repeatedObjectSchema = { | ||
| 561 | type: "object", | ||
| 562 | properties: { | ||
| 563 | id: { type: "string" }, | ||
| 564 | name: { type: "string" }, | ||
| 565 | }, | ||
| 566 | required: ["id"], | ||
| 567 | additionalProperties: false, | ||
| 568 | }; | ||
| 569 | |||
| 570 | const snapshot = createOpenApiCatalogSnapshot({ | ||
| 571 | source: baseSource, | ||
| 572 | documents: [{ | ||
| 573 | documentKind: "openapi", | ||
| 574 | documentKey: "https://api.example.test/openapi.json", | ||
| 575 | contentText: "{}", | ||
| 576 | fetchedAt: 1, | ||
| 577 | }], | ||
| 578 | operations: [ | ||
| 579 | { | ||
| 580 | toolId: "events.get", | ||
| 581 | title: "Get event", | ||
| 582 | description: "Get an event", | ||
| 583 | effect: "read", | ||
| 584 | inputSchema: { | ||
| 585 | type: "object", | ||
| 586 | properties: { | ||
| 587 | eventId: { type: "string" }, | ||
| 588 | }, | ||
| 589 | }, | ||
| 590 | outputSchema: repeatedObjectSchema, | ||
| 591 | providerData: { | ||
| 592 | kind: "openapi", | ||
| 593 | toolId: "events.get", | ||
| 594 | rawToolId: "events.get", | ||
| 595 | group: "events", | ||
| 596 | leaf: "get", | ||
| 597 | tags: ["events"], | ||
| 598 | method: "get", | ||
| 599 | path: "/events/{eventId}", | ||
| 600 | operationHash: "op_hash_get", | ||
| 601 | invocation: { | ||
| 602 | method: "get", | ||
| 603 | pathTemplate: "/events/{eventId}", | ||
| 604 | parameters: [ | ||
| 605 | { name: "eventId", location: "path", required: true }, | ||
| 606 | ], | ||
| 607 | requestBody: null, | ||
| 608 | }, | ||
| 609 | documentation: null, | ||
| 610 | }, | ||
| 611 | }, | ||
| 612 | { | ||
| 613 | toolId: "events.list", | ||
| 614 | title: "List events", | ||
| 615 | description: "List events", | ||
| 616 | effect: "read", | ||
| 617 | inputSchema: { | ||
| 618 | type: "object", | ||
| 619 | properties: { | ||
| 620 | pageToken: { type: "string" }, | ||
| 621 | }, | ||
| 622 | }, | ||
| 623 | outputSchema: { | ||
| 624 | type: "object", | ||
| 625 | properties: { | ||
| 626 | items: { | ||
| 627 | type: "array", | ||
| 628 | items: repeatedObjectSchema, | ||
| 629 | }, | ||
| 630 | }, | ||
| 631 | additionalProperties: false, | ||
| 632 | }, | ||
| 633 | providerData: { | ||
| 634 | kind: "openapi", | ||
| 635 | toolId: "events.list", | ||
| 636 | rawToolId: "events.list", | ||
| 637 | group: "events", | ||
| 638 | leaf: "list", | ||
| 639 | tags: ["events"], | ||
| 640 | method: "get", | ||
| 641 | path: "/events", | ||
| 642 | operationHash: "op_hash_list", | ||
| 643 | invocation: { | ||
| 644 | method: "get", | ||
| 645 | pathTemplate: "/events", | ||
| 646 | parameters: [], | ||
| 647 | requestBody: null, | ||
| 648 | }, | ||
| 649 | documentation: null, | ||
| 650 | }, | ||
| 651 | }, | ||
| 652 | ], | ||
| 653 | }); | ||
| 654 | |||
| 655 | const shapes = Object.values(snapshot.catalog.symbols).filter((symbol) => symbol.kind === "shape"); | ||
| 656 | const stringScalars = shapes.filter((symbol) => | ||
| 657 | symbol.node.type === "scalar" && symbol.node.scalar === "string" && symbol.node.format === undefined | ||
| 658 | ); | ||
| 659 | const repeatedObjects = shapes.filter((symbol) => | ||
| 660 | symbol.node.type === "object" | ||
| 661 | && Object.keys(symbol.node.fields).length === 2 | ||
| 662 | && symbol.node.fields.id !== undefined | ||
| 663 | && symbol.node.fields.name !== undefined | ||
| 664 | && symbol.node.additionalProperties === false | ||
| 665 | && symbol.node.required?.length === 1 | ||
| 666 | && symbol.node.required[0] === "id" | ||
| 667 | ); | ||
| 668 | expect(stringScalars).toHaveLength(1); | ||
| 669 | expect(repeatedObjects.length).toBeGreaterThan(0); | ||
| 184 | }); | 670 | }); |
| 185 | 671 | ||
| 186 | it("converts GraphQL field operations into GraphQL executables", () => { | 672 | it("converts GraphQL field operations into GraphQL executables", () => { |
| @@ -222,6 +708,128 @@ describe("source-catalog-snapshot", () => { · 35 unmodified lines | |||
| 222 | expect(executable.operationType).toBe("query"); | 708 | expect(executable.operationType).toBe("query"); |
| 223 | expect(executable.rootField).toBe("viewer"); | 709 | expect(executable.rootField).toBe("viewer"); |
| 224 | expect(executable.selectionMode).toBe("fixed"); | 710 | expect(executable.selectionMode).toBe("fixed"); |
| 711 | expect(executable.native).toBeUndefined(); | ||
| 712 | }); | ||
| 713 | |||
| 714 | it("projects MCP metadata into capability semantics", () => { | ||
| 715 | const snapshot = createMcpCatalogSnapshot({ | ||
| 716 | source: { | ||
| 717 | ...baseSource, | ||
| 718 | kind: "mcp", | ||
| 719 | namespace: "workspace.mcp", | ||
| 720 | }, | ||
| 721 | documents: [{ | ||
| 722 | documentKind: "mcp_manifest", | ||
| 723 | documentKey: "https://mcp.example.test", | ||
| 724 | contentText: "{}", | ||
| 725 | fetchedAt: 1, | ||
| 726 | }], | ||
| 727 | operations: [{ | ||
| 728 | toolId: "memory.read_file", | ||
| 729 | title: "Read File", | ||
| 730 | description: "Read a file from memory", | ||
| 731 | effect: "read", | ||
| 732 | inputSchema: { | ||
| 733 | type: "object", | ||
| 734 | properties: { | ||
| 735 | path: { | ||
| 736 | type: "string", | ||
| 737 | }, | ||
| 738 | }, | ||
| 739 | required: ["path"], | ||
| 740 | additionalProperties: false, | ||
| 741 | }, | ||
| 742 | outputSchema: { | ||
| 743 | type: "object", | ||
| 744 | properties: { | ||
| 745 | content: { | ||
| 746 | type: "string", | ||
| 747 | }, | ||
| 748 | }, | ||
| 749 | required: ["content"], | ||
| 750 | additionalProperties: false, | ||
| 751 | }, | ||
| 752 | providerData: { | ||
| 753 | toolId: "memory.read_file", | ||
| 754 | toolName: "read_file", | ||
| 755 | displayTitle: "Read File", | ||
| 756 | title: "Read File", | ||
| 757 | description: "Read a file from memory", | ||
| 758 | annotations: { | ||
| 759 | title: "Read File (Annotated)", | ||
| 760 | readOnlyHint: true, | ||
| 761 | destructiveHint: false, | ||
| 762 | idempotentHint: true, | ||
| 763 | openWorldHint: false, | ||
| 764 | }, | ||
| 765 | execution: { | ||
| 766 | taskSupport: "required", | ||
| 767 | }, | ||
| 768 | icons: [{ | ||
| 769 | src: "https://example.test/icon.png", | ||
| 770 | }], | ||
| 771 | meta: { | ||
| 772 | category: "filesystem", | ||
| 773 | }, | ||
| 774 | rawTool: { | ||
| 775 | name: "read_file", | ||
| 776 | annotations: { | ||
| 777 | readOnlyHint: true, | ||
| 778 | }, | ||
| 779 | }, | ||
| 780 | server: { | ||
| 781 | info: { | ||
| 782 | name: "mcp-test-server", | ||
| 783 | version: "1.0.0", | ||
| 784 | title: "Test Server", | ||
| 785 | description: null, | ||
| 786 | websiteUrl: null, | ||
| 787 | icons: null, | ||
| 788 | }, | ||
| 789 | capabilities: { | ||
| 790 | experimental: null, | ||
| 791 | logging: false, | ||
| 792 | completions: false, | ||
| 793 | prompts: null, | ||
| 794 | resources: null, | ||
| 795 | tools: { | ||
| 796 | listChanged: true, | ||
| 797 | }, | ||
| 798 | tasks: { | ||
| 799 | list: false, | ||
| 800 | cancel: false, | ||
| 801 | toolCall: true, | ||
| 802 | }, | ||
| 803 | }, | ||
| 804 | instructions: "Use carefully.", | ||
| 805 | rawInfo: { | ||
| 806 | name: "mcp-test-server", | ||
| 807 | version: "1.0.0", | ||
| 808 | }, | ||
| 809 | rawCapabilities: { | ||
| 810 | tools: { | ||
| 811 | listChanged: true, | ||
| 812 | }, | ||
| 813 | }, | ||
| 814 | }, | ||
| 815 | }, | ||
| 816 | }], | ||
| 817 | }); | ||
| 818 | |||
| 819 | const capability = Object.values(snapshot.catalog.capabilities)[0]!; | ||
| 820 | const executable = Object.values(snapshot.catalog.executables)[0]!; | ||
| 821 | |||
| 822 | expect(capability.surface.title).toBe("Read File"); | ||
| 823 | expect(capability.semantics).toMatchObject({ | ||
| 824 | effect: "read", | ||
| 825 | safe: true, | ||
| 826 | idempotent: true, | ||
| 827 | destructive: false, | ||
| 828 | }); | ||
| 829 | expect(capability.interaction.resume.supported).toBe(true); | ||
| 830 | expect(capability.native).toBeUndefined(); | ||
| 831 | expect(executable.protocol).toBe("mcp"); | ||
| 832 | expect(executable.native).toBeUndefined(); | ||
| 225 | }); | 833 | }); |
| 226 | 834 | ||
| 227 | it("materializes GraphQL input refs before importing into IR snapshots", () => { | 835 | it("materializes GraphQL input refs before importing into IR snapshots", () => { |
| @@ -5,6 +5,11 @@ import { · 4 unmodified lines | |||
| 5 | import { | 5 | import { |
| 6 | type GoogleDiscoveryToolProviderData, | 6 | type GoogleDiscoveryToolProviderData, |
| 7 | } from "@executor/codemode-google-discovery"; | 7 | } from "@executor/codemode-google-discovery"; |
| 8 | import type { | ||
| 9 | McpServerMetadata, | ||
| 10 | McpToolAnnotations, | ||
| 11 | McpToolExecution, | ||
| 12 | } from "@executor/codemode-mcp"; | ||
| 8 | 13 | ||
| 9 | import type { Source } from "#schema"; | 14 | import type { Source } from "#schema"; |
| 10 | 15 | ||
| @@ -90,12 +95,21 @@ export type McpCatalogOperationInput = CatalogOperationInput & { · 79 unmodified lines | |||
| 90 | providerData: { | 95 | providerData: { |
| 91 | toolId: string; | 96 | toolId: string; |
| 92 | toolName: string; | 97 | toolName: string; |
| 98 | displayTitle: string; | ||
| 99 | title: string | null; | ||
| 93 | description: string | null; | 100 | description: string | null; |
| 101 | annotations: McpToolAnnotations | null; | ||
| 102 | execution: McpToolExecution | null; | ||
| 103 | icons: unknown[] | null; | ||
| 104 | meta: unknown; | ||
| 105 | rawTool: unknown; | ||
| 106 | server: McpServerMetadata | null; | ||
| 94 | }; | 107 | }; |
| 95 | }; | 108 | }; |
| 96 | 109 | ||
| 97 | type JsonSchemaImporter = { | 110 | type JsonSchemaImporter = { |
| 98 | importSchema: (schema: unknown, key: string) => ReturnType<typeof ShapeSymbolIdSchema.make>; | 111 | importSchema: (schema: unknown, key: string, rootSchema?: unknown) => ReturnType<typeof ShapeSymbolIdSchema.make>; |
| 112 | finalize: () => void; | ||
| 99 | }; | 113 | }; |
| 100 | 114 | ||
| 101 | const stableStringify = (value: unknown): string => { | 115 | const stableStringify = (value: unknown): string => { |
| @@ -209,6 +223,28 @@ const interactionForEffect = (effect: EffectKind): Capability["interaction"] => · 107 unmodified lines | |||
| 209 | }, | 223 | }, |
| 210 | }); | 224 | }); |
| 211 | 225 | ||
| 226 | const mcpResumeSupport = ( | ||
| 227 | execution: McpCatalogOperationInput["providerData"]["execution"], | ||
| 228 | ): boolean => | ||
| 229 | execution?.taskSupport === "optional" || execution?.taskSupport === "required"; | ||
| 230 | |||
| 231 | const mcpSemanticsForOperation = (input: { | ||
| 232 | effect: EffectKind; | ||
| 233 | annotations: McpCatalogOperationInput["providerData"]["annotations"]; | ||
| 234 | }): Capability["semantics"] => { | ||
| 235 | const safe = input.effect === "read"; | ||
| 236 | |||
| 237 | return { | ||
| 238 | effect: input.effect, | ||
| 239 | safe, | ||
| 240 | idempotent: safe || input.annotations?.idempotentHint === true, | ||
| 241 | destructive: | ||
| 242 | safe | ||
| 243 | ? false | ||
| 244 | : input.annotations?.destructiveHint !== false, | ||
| 245 | }; | ||
| 246 | }; | ||
| 247 | |||
| 212 | const importMetadataFor = (input: { | 248 | const importMetadataFor = (input: { |
| 213 | source: Source; | 249 | source: Source; |
| 214 | adapterKey: string; | 250 | adapterKey: string; |
| @@ -280,6 +316,10 @@ const createJsonSchemaImporter = (input: { · 65 unmodified lines | |||
| 280 | documentId: ReturnType<typeof DocumentIdSchema.make>; | 316 | documentId: ReturnType<typeof DocumentIdSchema.make>; |
| 281 | }) : JsonSchemaImporter => { | 317 | }) : JsonSchemaImporter => { |
| 282 | const cache = new Map<string, ReturnType<typeof ShapeSymbolIdSchema.make>>(); | 318 | const cache = new Map<string, ReturnType<typeof ShapeSymbolIdSchema.make>>(); |
| 319 | const structuralCache = new Map<string, ReturnType<typeof ShapeSymbolIdSchema.make>>(); | ||
| 320 | const dedupedShapeIds = new Map<ReturnType<typeof ShapeSymbolIdSchema.make>, ReturnType<typeof ShapeSymbolIdSchema.make>>(); | ||
| 321 | const activeShapeIds: ReturnType<typeof ShapeSymbolIdSchema.make>[] = []; | ||
| 322 | const recursiveShapeIds = new Set<ReturnType<typeof ShapeSymbolIdSchema.make>>(); | ||
| 283 | 323 | ||
| 284 | const resolvePointer = (root: unknown, pointer: string): unknown => { | 324 | const resolvePointer = (root: unknown, pointer: string): unknown => { |
| 285 | if (pointer === "#" || pointer.length === 0) { | 325 | if (pointer === "#" || pointer.length === 0) { |
| @@ -308,11 +348,20 @@ const createJsonSchemaImporter = (input: { · 22 unmodified lines | |||
| 308 | const stableKey = `${input.resourceId}:${key}`; | 348 | const stableKey = `${input.resourceId}:${key}`; |
| 309 | const cached = cache.get(stableKey); | 349 | const cached = cache.get(stableKey); |
| 310 | if (cached) { | 350 | if (cached) { |
| 351 | const cycleIndex = activeShapeIds.indexOf(cached); | ||
| 352 | if (cycleIndex !== -1) { | ||
| 353 | for (const activeShapeId of activeShapeIds.slice(cycleIndex)) { | ||
| 354 | recursiveShapeIds.add(activeShapeId); | ||
| 355 | } | ||
| 356 | } | ||
| 311 | return cached; | 357 | return cached; |
| 312 | } | 358 | } |
| 313 | 359 | ||
| 314 | const shapeId = ShapeSymbolIdSchema.make(`shape_${stableHash({ resourceId: input.resourceId, key })}`); | 360 | const shapeId = ShapeSymbolIdSchema.make(`shape_${stableHash({ resourceId: input.resourceId, key })}`); |
| 315 | cache.set(stableKey, shapeId); | 361 | cache.set(stableKey, shapeId); |
| 362 | activeShapeIds.push(shapeId); | ||
| 363 | |||
| 364 | try { | ||
| 316 | 365 | ||
| 317 | const objectSchema = asObject(schema); | 366 | const objectSchema = asObject(schema); |
| 318 | const title = asString(objectSchema.title) ?? undefined; | 367 | const title = asString(objectSchema.title) ?? undefined; |
| @@ -324,7 +373,38 @@ const createJsonSchemaImporter = (input: { · 5 unmodified lines | |||
| 324 | const register = (node: ShapeNode, extras: { | 373 | const register = (node: ShapeNode, extras: { |
| 325 | native?: NativeBlob[]; | 374 | native?: NativeBlob[]; |
| 326 | diagnosticIds?: ReturnType<typeof DiagnosticIdSchema.make>[]; | 375 | diagnosticIds?: ReturnType<typeof DiagnosticIdSchema.make>[]; |
| 327 | } = {}) => { | 376 | } = {}): ReturnType<typeof ShapeSymbolIdSchema.make> => { |
| 377 | const signature = stableStringify(node); | ||
| 378 | const recursive = recursiveShapeIds.has(shapeId); | ||
| 379 | const existingShapeId = recursive ? undefined : structuralCache.get(signature); | ||
| 380 | |||
| 381 | if (existingShapeId) { | ||
| 382 | const existingShape = input.catalog.symbols[existingShapeId]; | ||
| 383 | if (existingShape?.kind === "shape") { | ||
| 384 | if (existingShape.title === undefined && title) { | ||
| 385 | mutableRecord(input.catalog.symbols)[existingShapeId] = { | ||
| 386 | ...existingShape, | ||
| 387 | title, | ||
| 388 | } satisfies ShapeSymbol; | ||
| 389 | } | ||
| 390 | if (existingShape.docs === undefined && docs) { | ||
| 391 | mutableRecord(input.catalog.symbols)[existingShapeId] = { | ||
| 392 | ...(mutableRecord(input.catalog.symbols)[existingShapeId] as ShapeSymbol), | ||
| 393 | docs, | ||
| 394 | } satisfies ShapeSymbol; | ||
| 395 | } | ||
| 396 | if (existingShape.deprecated === undefined && deprecated !== undefined) { | ||
| 397 | mutableRecord(input.catalog.symbols)[existingShapeId] = { | ||
| 398 | ...(mutableRecord(input.catalog.symbols)[existingShapeId] as ShapeSymbol), | ||
| 399 | deprecated, | ||
| 400 | } satisfies ShapeSymbol; | ||
| 401 | } | ||
| 402 | } | ||
| 403 | dedupedShapeIds.set(shapeId, existingShapeId); | ||
| 404 | cache.set(stableKey, existingShapeId); | ||
| 405 | return existingShapeId; | ||
| 406 | } | ||
| 407 | |||
| 328 | mutableRecord(input.catalog.symbols)[shapeId] = { | 408 | mutableRecord(input.catalog.symbols)[shapeId] = { |
| 329 | id: shapeId, | 409 | id: shapeId, |
| 330 | kind: "shape", | 410 | kind: "shape", |
| @@ -340,14 +420,19 @@ const createJsonSchemaImporter = (input: { · 9 unmodified lines | |||
| 340 | : {}), | 420 | : {}), |
| 341 | ...(extras.native && extras.native.length > 0 ? { native: extras.native } : {}), | 421 | ...(extras.native && extras.native.length > 0 ? { native: extras.native } : {}), |
| 342 | } satisfies ShapeSymbol; | 422 | } satisfies ShapeSymbol; |
| 423 | |||
| 424 | if (!recursive) { | ||
| 425 | structuralCache.set(signature, shapeId); | ||
| 426 | } | ||
| 427 | |||
| 428 | return shapeId; | ||
| 343 | }; | 429 | }; |
| 344 | 430 | ||
| 345 | if (typeof schema === "boolean") { | 431 | if (typeof schema === "boolean") { |
| 346 | register({ | 432 | return register({ |
| 347 | type: "unknown", | 433 | type: "unknown", |
| 348 | reason: schema ? "schema_true" : "schema_false", | 434 | reason: schema ? "schema_true" : "schema_false", |
| 349 | }); | 435 | }); |
| 350 | return shapeId; | ||
| 351 | } | 436 | } |
| 352 | 437 | ||
| 353 | const ref = asString(objectSchema.$ref); | 438 | const ref = asString(objectSchema.$ref); |
| @@ -374,71 +459,64 @@ const createJsonSchemaImporter = (input: { · 20 unmodified lines | |||
| 374 | diagnosticIds: [diagnosticId], | 459 | diagnosticIds: [diagnosticId], |
| 375 | }, | 460 | }, |
| 376 | ); | 461 | ); |
| 377 | return shapeId; | 462 | return cache.get(stableKey)!; |
| 378 | } | 463 | } |
| 379 | 464 | ||
| 380 | const target = importSchema(resolved, ref, rootSchema ?? schema); | 465 | const target = importSchema(resolved, ref, rootSchema ?? schema); |
| 381 | register({ | 466 | return register({ |
| 382 | type: "ref", | 467 | type: "ref", |
| 383 | target, | 468 | target, |
| 384 | }); | 469 | }); |
| 385 | return shapeId; | ||
| 386 | } | 470 | } |
| 387 | 471 | ||
| 388 | const enumValues = asArray(objectSchema.enum); | 472 | const enumValues = asArray(objectSchema.enum); |
| 389 | if (enumValues.length === 1) { | 473 | if (enumValues.length === 1) { |
| 390 | register({ | 474 | return register({ |
| 391 | type: "const", | 475 | type: "const", |
| 392 | value: enumValues[0], | 476 | value: enumValues[0], |
| 393 | }); | 477 | }); |
| 394 | return shapeId; | ||
| 395 | } | 478 | } |
| 396 | 479 | ||
| 397 | if (enumValues.length > 1) { | 480 | if (enumValues.length > 1) { |
| 398 | register({ | 481 | return register({ |
| 399 | type: "enum", | 482 | type: "enum", |
| 400 | values: enumValues, | 483 | values: enumValues, |
| 401 | }); | 484 | }); |
| 402 | return shapeId; | ||
| 403 | } | 485 | } |
| 404 | 486 | ||
| 405 | if ("const" in objectSchema) { | 487 | if ("const" in objectSchema) { |
| 406 | register({ | 488 | return register({ |
| 407 | type: "const", | 489 | type: "const", |
| 408 | value: objectSchema.const, | 490 | value: objectSchema.const, |
| 409 | }); | 491 | }); |
| 410 | return shapeId; | ||
| 411 | } | 492 | } |
| 412 | 493 | ||
| 413 | const anyOf = asArray(objectSchema.anyOf); | 494 | const anyOf = asArray(objectSchema.anyOf); |
| 414 | if (anyOf.length > 0) { | 495 | if (anyOf.length > 0) { |
| 415 | register({ | 496 | return register({ |
| 416 | type: "anyOf", | 497 | type: "anyOf", |
| 417 | items: anyOf.map((entry, index) => importSchema(entry, `${key}/anyOf/${index}`, rootSchema ?? schema)), | 498 | items: anyOf.map((entry, index) => importSchema(entry, `${key}/anyOf/${index}`, rootSchema ?? schema)), |
| 418 | }); | 499 | }); |
| 419 | return shapeId; | ||
| 420 | } | 500 | } |
| 421 | 501 | ||
| 422 | const allOf = asArray(objectSchema.allOf); | 502 | const allOf = asArray(objectSchema.allOf); |
| 423 | if (allOf.length > 0) { | 503 | if (allOf.length > 0) { |
| 424 | register({ | 504 | return register({ |
| 425 | type: "allOf", | 505 | type: "allOf", |
| 426 | items: allOf.map((entry, index) => importSchema(entry, `${key}/allOf/${index}`, rootSchema ?? schema)), | 506 | items: allOf.map((entry, index) => importSchema(entry, `${key}/allOf/${index}`, rootSchema ?? schema)), |
| 427 | }); | 507 | }); |
| 428 | return shapeId; | ||
| 429 | } | 508 | } |
| 430 | 509 | ||
| 431 | const oneOf = asArray(objectSchema.oneOf); | 510 | const oneOf = asArray(objectSchema.oneOf); |
| 432 | if (oneOf.length > 0) { | 511 | if (oneOf.length > 0) { |
| 433 | register({ | 512 | return register({ |
| 434 | type: "oneOf", | 513 | type: "oneOf", |
| 435 | items: oneOf.map((entry, index) => importSchema(entry, `${key}/oneOf/${index}`, rootSchema ?? schema)), | 514 | items: oneOf.map((entry, index) => importSchema(entry, `${key}/oneOf/${index}`, rootSchema ?? schema)), |
| 436 | }); | 515 | }); |
| 437 | return shapeId; | ||
| 438 | } | 516 | } |
| 439 | 517 | ||
| 440 | if ("if" in objectSchema || "then" in objectSchema || "else" in objectSchema) { | 518 | if ("if" in objectSchema || "then" in objectSchema || "else" in objectSchema) { |
| 441 | register({ | 519 | return register({ |
| 442 | type: "conditional", | 520 | type: "conditional", |
| 443 | ifShapeId: importSchema(objectSchema.if ?? {}, `${key}/if`, rootSchema ?? schema), | 521 | ifShapeId: importSchema(objectSchema.if ?? {}, `${key}/if`, rootSchema ?? schema), |
| 444 | thenShapeId: importSchema(objectSchema.then ?? {}, `${key}/then`, rootSchema ?? schema), | 522 | thenShapeId: importSchema(objectSchema.then ?? {}, `${key}/then`, rootSchema ?? schema), |
| @@ -448,15 +526,13 @@ const createJsonSchemaImporter = (input: { · 3 unmodified lines | |||
| 448 | } | 526 | } |
| 449 | : {}), | 527 | : {}), |
| 450 | }); | 528 | }); |
| 451 | return shapeId; | ||
| 452 | } | 529 | } |
| 453 | 530 | ||
| 454 | if ("not" in objectSchema) { | 531 | if ("not" in objectSchema) { |
| 455 | register({ | 532 | return register({ |
| 456 | type: "not", | 533 | type: "not", |
| 457 | itemShapeId: importSchema(objectSchema.not, `${key}/not`, rootSchema ?? schema), | 534 | itemShapeId: importSchema(objectSchema.not, `${key}/not`, rootSchema ?? schema), |
| 458 | }); | 535 | }); |
| 459 | return shapeId; | ||
| 460 | } | 536 | } |
| 461 | 537 | ||
| 462 | const declaredType = objectSchema.type; | 538 | const declaredType = objectSchema.type; |
| @@ -564,8 +640,7 @@ const createJsonSchemaImporter = (input: { · 101 unmodified lines | |||
| 564 | return registerNullable(innerId); | 640 | return registerNullable(innerId); |
| 565 | } | 641 | } |
| 566 | 642 | ||
| 567 | register(objectNode); | 643 | return register(objectNode); |
| 568 | return shapeId; | ||
| 569 | } | 644 | } |
| 570 | 645 | ||
| 571 | if (effectiveType === "array" || "items" in objectSchema || "prefixItems" in objectSchema) { | 646 | if (effectiveType === "array" || "items" in objectSchema || "prefixItems" in objectSchema) { |
| @@ -598,8 +673,7 @@ const createJsonSchemaImporter = (input: { · 26 unmodified lines | |||
| 598 | return registerNullable(innerId); | 673 | return registerNullable(innerId); |
| 599 | } | 674 | } |
| 600 | 675 | ||
| 601 | register(tupleNode); | 676 | return register(tupleNode); |
| 602 | return shapeId; | ||
| 603 | } | 677 | } |
| 604 | 678 | ||
| 605 | const items = objectSchema.items ?? {}; | 679 | const items = objectSchema.items ?? {}; |
| @@ -623,8 +697,7 @@ const createJsonSchemaImporter = (input: { · 17 unmodified lines | |||
| 623 | return registerNullable(innerId); | 697 | return registerNullable(innerId); |
| 624 | } | 698 | } |
| 625 | 699 | ||
| 626 | register(arrayNode); | 700 | return register(arrayNode); |
| 627 | return shapeId; | ||
| 628 | } | 701 | } |
| 629 | 702 | ||
| 630 | if ( | 703 | if ( |
| @@ -667,11 +740,10 @@ const createJsonSchemaImporter = (input: { · 36 unmodified lines | |||
| 667 | return registerNullable(innerId); | 740 | return registerNullable(innerId); |
| 668 | } | 741 | } |
| 669 | 742 | ||
| 670 | register(scalarNode); | 743 | return register(scalarNode); |
| 671 | return shapeId; | ||
| 672 | } | 744 | } |
| 673 | 745 | ||
| 674 | register({ | 746 | return register({ |
| 675 | type: "unknown", | 747 | type: "unknown", |
| 676 | reason: `unsupported_schema:${key}`, | 748 | reason: `unsupported_schema:${key}`, |
| 677 | }, { | 749 | }, { |
| @@ -683,11 +755,47 @@ const createJsonSchemaImporter = (input: { · 5 unmodified lines | |||
| 683 | summary: "Unsupported JSON schema preserved natively", | 755 | summary: "Unsupported JSON schema preserved natively", |
| 684 | })], | 756 | })], |
| 685 | }); | 757 | }); |
| 686 | return shapeId; | 758 | } finally { |
| 759 | const poppedShapeId = activeShapeIds.pop(); | ||
| 760 | if (poppedShapeId !== shapeId) { | ||
| 761 | throw new Error(`JSON schema importer stack mismatch for ${shapeId}`); | ||
| 762 | } | ||
| 763 | } | ||
| 687 | }; | 764 | }; |
| 688 | 765 | ||
| 689 | return { | 766 | return { |
| 690 | importSchema: (schema, key) => importSchema(schema, key, schema), | 767 | importSchema: (schema, key, rootSchema) => importSchema(schema, key, rootSchema ?? schema), |
| 768 | finalize: () => { | ||
| 769 | const rewriteDedupedShapeIds = (value: unknown): void => { | ||
| 770 | if (typeof value === "string") { | ||
| 771 | return; | ||
| 772 | } | ||
| 773 | if (!value || typeof value !== "object") { | ||
| 774 | return; | ||
| 775 | } | ||
| 776 | if (Array.isArray(value)) { | ||
| 777 | for (let index = 0; index < value.length; index += 1) { | ||
| 778 | const entry = value[index]; | ||
| 779 | if (typeof entry === "string" && dedupedShapeIds.has(entry as ReturnType<typeof ShapeSymbolIdSchema.make>)) { | ||
| 780 | value[index] = dedupedShapeIds.get(entry as ReturnType<typeof ShapeSymbolIdSchema.make>)!; | ||
| 781 | } else { | ||
| 782 | rewriteDedupedShapeIds(entry); | ||
| 783 | } | ||
| 784 | } | ||
| 785 | return; | ||
| 786 | } | ||
| 787 | |||
| 788 | for (const [entryKey, entryValue] of Object.entries(value)) { | ||
| 789 | if (typeof entryValue === "string" && dedupedShapeIds.has(entryValue as ReturnType<typeof ShapeSymbolIdSchema.make>)) { | ||
| 790 | (value as Record<string, unknown>)[entryKey] = dedupedShapeIds.get(entryValue as ReturnType<typeof ShapeSymbolIdSchema.make>)!; | ||
| 791 | } else { | ||
| 792 | rewriteDedupedShapeIds(entryValue); | ||
| 793 | } | ||
| 794 | } | ||
| 795 | }; | ||
| 796 | |||
| 797 | rewriteDedupedShapeIds(input.catalog); | ||
| 798 | }, | ||
| 691 | }; | 799 | }; |
| 692 | }; | 800 | }; |
| 693 | 801 | ||
| @@ -758,13 +866,30 @@ const requestBodySchemaFromInput = (schema: unknown): unknown => · 64 unmodified lines | |||
| 758 | 866 | ||
| 759 | const preferredResponseContentTypes = ( | 867 | const preferredResponseContentTypes = ( |
| 760 | mediaTypes: readonly string[] | undefined, | 868 | mediaTypes: readonly string[] | undefined, |
| 761 | ): string[] => | 869 | ): string[] => { |
| 762 | mediaTypes && mediaTypes.length > 0 ? [...mediaTypes] : ["application/json"]; | 870 | const candidates = mediaTypes && mediaTypes.length > 0 |
| 871 | ? [...mediaTypes] | ||
| 872 | : ["application/json"]; | ||
| 873 | |||
| 874 | const preferred = [ | ||
| 875 | ...candidates.filter((mediaType) => mediaType === "application/json"), | ||
| 876 | ...candidates.filter((mediaType) => mediaType !== "application/json" && mediaType.toLowerCase().includes("+json")), | ||
| 877 | ...candidates.filter((mediaType) => | ||
| 878 | mediaType !== "application/json" | ||
| 879 | && !mediaType.toLowerCase().includes("+json") | ||
| 880 | && mediaType.toLowerCase().includes("json"), | ||
| 881 | ), | ||
| 882 | ...candidates, | ||
| 883 | ]; | ||
| 884 | |||
| 885 | return [...new Set(preferred)]; | ||
| 886 | }; | ||
| 763 | 887 | ||
| 764 | const createServiceScope = (input: { | 888 | const createServiceScope = (input: { |
| 765 | catalog: CatalogV1; | 889 | catalog: CatalogV1; |
| 766 | source: Source; | 890 | source: Source; |
| 767 | documentId: ReturnType<typeof DocumentIdSchema.make>; | 891 | documentId: ReturnType<typeof DocumentIdSchema.make>; |
| 892 | defaults?: Scope["defaults"]; | ||
| 768 | }) => { | 893 | }) => { |
| 769 | const scopeId = serviceScopeIdForSource(input.source); | 894 | const scopeId = serviceScopeIdForSource(input.source); |
| 770 | mutableRecord(input.catalog.scopes)[scopeId] = { | 895 | mutableRecord(input.catalog.scopes)[scopeId] = { |
| @@ -775,12 +900,58 @@ const createServiceScope = (input: { · 4 unmodified lines | |||
| 775 | docs: docsFrom({ | 900 | docs: docsFrom({ |
| 776 | summary: input.source.name, | 901 | summary: input.source.name, |
| 777 | }), | 902 | }), |
| 903 | ...(input.defaults ? { defaults: input.defaults } : {}), | ||
| 778 | synthetic: false, | 904 | synthetic: false, |
| 779 | provenance: provenanceFor(input.documentId, "#/service"), | 905 | provenance: provenanceFor(input.documentId, "#/service"), |
| 780 | } satisfies Scope; | 906 | } satisfies Scope; |
| 781 | return scopeId; | 907 | return scopeId; |
| 782 | }; | 908 | }; |
| 783 | 909 | ||
| 910 | const openApiServerSpecs = ( | ||
| 911 | servers: OpenApiToolProviderData["servers"] | OpenApiToolProviderData["documentServers"] | undefined, | ||
| 912 | ): NonNullable<NonNullable<Scope["defaults"]>["servers"]> | undefined => { | ||
| 913 | if (!servers || servers.length === 0) { | ||
| 914 | return undefined; | ||
| 915 | } | ||
| 916 | |||
| 917 | return servers.map((server) => ({ | ||
| 918 | url: server.url, | ||
| 919 | ...(server.description ? { description: server.description } : {}), | ||
| 920 | ...(server.variables ? { variables: server.variables } : {}), | ||
| 921 | })); | ||
| 922 | }; | ||
| 923 | |||
| 924 | const createOperationScope = (input: { | ||
| 925 | catalog: CatalogV1; | ||
| 926 | source: Source; | ||
| 927 | documentId: ReturnType<typeof DocumentIdSchema.make>; | ||
| 928 | parentScopeId: ReturnType<typeof ScopeIdSchema.make>; | ||
| 929 | operation: OpenApiCatalogOperationInput; | ||
| 930 | defaults: Scope["defaults"]; | ||
| 931 | }) => { | ||
| 932 | const scopeId = ScopeIdSchema.make(`scope_${stableHash({ | ||
| 933 | sourceId: input.source.id, | ||
| 934 | toolId: input.operation.providerData.toolId, | ||
| 935 | kind: "operation", | ||
| 936 | })}`); | ||
| 937 | |||
| 938 | mutableRecord(input.catalog.scopes)[scopeId] = { | ||
| 939 | id: scopeId, | ||
| 940 | kind: "operation", | ||
| 941 | parentId: input.parentScopeId, | ||
| 942 | name: input.operation.title ?? input.operation.providerData.toolId, | ||
| 943 | docs: docsFrom({ | ||
| 944 | summary: input.operation.title ?? input.operation.providerData.toolId, | ||
| 945 | description: input.operation.description ?? undefined, | ||
| 946 | }), | ||
| 947 | defaults: input.defaults, | ||
| 948 | synthetic: false, | ||
| 949 | provenance: provenanceFor(input.documentId, `#/openapi/${input.operation.providerData.toolId}/scope`), | ||
| 950 | } satisfies Scope; | ||
| 951 | |||
| 952 | return scopeId; | ||
| 953 | }; | ||
| 954 | |||
| 784 | const responseSetFromSingleResponse = (input: { | 955 | const responseSetFromSingleResponse = (input: { |
| 785 | catalog: CatalogV1; | 956 | catalog: CatalogV1; |
| 786 | responseId: ReturnType<typeof ResponseSymbolIdSchema.make>; | 957 | responseId: ReturnType<typeof ResponseSymbolIdSchema.make>; |
| @@ -807,6 +978,320 @@ const responseSetFromSingleResponse = (input: { · 20 unmodified lines | |||
| 807 | return responseSetId; | 978 | return responseSetId; |
| 808 | }; | 979 | }; |
| 809 | 980 | ||
| 981 | const responseSetFromVariants = (input: { | ||
| 982 | catalog: CatalogV1; | ||
| 983 | variants: ResponseSet["variants"]; | ||
| 984 | provenance: ProvenanceRef[]; | ||
| 985 | }) => { | ||
| 986 | const responseSetId = ResponseSetIdSchema.make(`response_set_${stableHash({ | ||
| 987 | variants: input.variants.map((variant) => ({ | ||
| 988 | match: variant.match, | ||
| 989 | responseId: variant.responseId, | ||
| 990 | traits: variant.traits, | ||
| 991 | })), | ||
| 992 | })}`); | ||
| 993 | mutableRecord(input.catalog.responseSets)[responseSetId] = { | ||
| 994 | id: responseSetId, | ||
| 995 | variants: input.variants, | ||
| 996 | synthetic: false, | ||
| 997 | provenance: input.provenance, | ||
| 998 | } satisfies ResponseSet; | ||
| 999 | return responseSetId; | ||
| 1000 | }; | ||
| 1001 | |||
| 1002 | const statusMatchFromHttpStatusCode = ( | ||
| 1003 | statusCode: string, | ||
| 1004 | ): ResponseSet["variants"][number]["match"] => { | ||
| 1005 | const normalized = statusCode.trim().toUpperCase(); | ||
| 1006 | |||
| 1007 | if (/^\d{3}$/.test(normalized)) { | ||
| 1008 | return { | ||
| 1009 | kind: "exact", | ||
| 1010 | status: Number(normalized), | ||
| 1011 | }; | ||
| 1012 | } | ||
| 1013 | |||
| 1014 | if (/^[1-5]XX$/.test(normalized)) { | ||
| 1015 | return { | ||
| 1016 | kind: "range", | ||
| 1017 | value: normalized as "1XX" | "2XX" | "3XX" | "4XX" | "5XX", | ||
| 1018 | }; | ||
| 1019 | } | ||
| 1020 | |||
| 1021 | return { | ||
| 1022 | kind: "default", | ||
| 1023 | }; | ||
| 1024 | }; | ||
| 1025 | |||
| 1026 | const ensureOpenApiSecuritySchemeSymbol = (input: { | ||
| 1027 | catalog: CatalogV1; | ||
| 1028 | source: Source; | ||
| 1029 | documentId: ReturnType<typeof DocumentIdSchema.make>; | ||
| 1030 | schemeName: string; | ||
| 1031 | scheme?: NonNullable<OpenApiToolProviderData["securitySchemes"]>[number]; | ||
| 1032 | }) => { | ||
| 1033 | const schemeId = SecuritySchemeSymbolIdSchema.make(`security_${stableHash({ | ||
| 1034 | sourceId: input.source.id, | ||
| 1035 | provider: "openapi", | ||
| 1036 | schemeName: input.schemeName, | ||
| 1037 | })}`); | ||
| 1038 | |||
| 1039 | if (input.catalog.symbols[schemeId]) { | ||
| 1040 | return schemeId; | ||
| 1041 | } | ||
| 1042 | |||
| 1043 | const scheme = input.scheme; | ||
| 1044 | const httpScheme = scheme?.scheme?.toLowerCase(); | ||
| 1045 | const schemeType = | ||
| 1046 | scheme?.schemeType === "apiKey" | ||
| 1047 | ? "apiKey" | ||
| 1048 | : scheme?.schemeType === "oauth2" | ||
| 1049 | ? "oauth2" | ||
| 1050 | : scheme?.schemeType === "http" && httpScheme === "basic" | ||
| 1051 | ? "basic" | ||
| 1052 | : scheme?.schemeType === "http" && httpScheme === "bearer" | ||
| 1053 | ? "bearer" | ||
| 1054 | : scheme?.schemeType === "openIdConnect" | ||
| 1055 | ? "custom" | ||
| 1056 | : scheme?.schemeType === "http" | ||
| 1057 | ? "http" | ||
| 1058 | : "custom"; | ||
| 1059 | |||
| 1060 | const oauthFlows = Object.fromEntries( | ||
| 1061 | Object.entries(scheme?.flows ?? {}).map(([flowName, flow]) => [flowName, flow]), | ||
| 1062 | ); | ||
| 1063 | const oauthScopes = Object.fromEntries( | ||
| 1064 | Object.entries(scheme?.flows ?? {}).flatMap(([, flow]) => | ||
| 1065 | Object.entries(flow.scopes ?? {}), | ||
| 1066 | ), | ||
| 1067 | ); | ||
| 1068 | const description = scheme?.description | ||
| 1069 | ?? (scheme?.openIdConnectUrl | ||
| 1070 | ? `OpenID Connect: ${scheme.openIdConnectUrl}` | ||
| 1071 | : null); | ||
| 1072 | |||
| 1073 | mutableRecord(input.catalog.symbols)[schemeId] = { | ||
| 1074 | id: schemeId, | ||
| 1075 | kind: "securityScheme", | ||
| 1076 | schemeType, | ||
| 1077 | ...(docsFrom({ | ||
| 1078 | summary: input.schemeName, | ||
| 1079 | description, | ||
| 1080 | }) | ||
| 1081 | ? { | ||
| 1082 | docs: docsFrom({ | ||
| 1083 | summary: input.schemeName, | ||
| 1084 | description, | ||
| 1085 | })!, | ||
| 1086 | } | ||
| 1087 | : {}), | ||
| 1088 | ...((scheme?.placementIn || scheme?.placementName) | ||
| 1089 | ? { | ||
| 1090 | placement: { | ||
| 1091 | ...(scheme?.placementIn ? { in: scheme.placementIn } : {}), | ||
| 1092 | ...(scheme?.placementName ? { name: scheme.placementName } : {}), | ||
| 1093 | }, | ||
| 1094 | } | ||
| 1095 | : {}), | ||
| 1096 | ...(schemeType === "apiKey" && scheme?.placementIn && scheme?.placementName | ||
| 1097 | ? { | ||
| 1098 | apiKey: { | ||
| 1099 | in: scheme.placementIn, | ||
| 1100 | name: scheme.placementName, | ||
| 1101 | }, | ||
| 1102 | } | ||
| 1103 | : {}), | ||
| 1104 | ...((schemeType === "basic" || schemeType === "bearer" || schemeType === "http") && scheme?.scheme | ||
| 1105 | ? { | ||
| 1106 | http: { | ||
| 1107 | scheme: scheme.scheme, | ||
| 1108 | ...(scheme.bearerFormat ? { bearerFormat: scheme.bearerFormat } : {}), | ||
| 1109 | }, | ||
| 1110 | } | ||
| 1111 | : {}), | ||
| 1112 | ...(schemeType === "oauth2" | ||
| 1113 | ? { | ||
| 1114 | oauth: { | ||
| 1115 | ...(Object.keys(oauthFlows).length > 0 ? { flows: oauthFlows } : {}), | ||
| 1116 | ...(Object.keys(oauthScopes).length > 0 ? { scopes: oauthScopes } : {}), | ||
| 1117 | }, | ||
| 1118 | } | ||
| 1119 | : {}), | ||
| 1120 | ...(schemeType === "custom" | ||
| 1121 | ? { | ||
| 1122 | custom: {}, | ||
| 1123 | } | ||
| 1124 | : {}), | ||
| 1125 | synthetic: false, | ||
| 1126 | provenance: provenanceFor( | ||
| 1127 | input.documentId, | ||
| 1128 | `#/openapi/securitySchemes/${input.schemeName}`, | ||
| 1129 | ), | ||
| 1130 | } satisfies SecuritySchemeSymbol; | ||
| 1131 | |||
| 1132 | return schemeId; | ||
| 1133 | }; | ||
| 1134 | |||
| 1135 | const openApiAuthRequirementToIr = (input: { | ||
| 1136 | catalog: CatalogV1; | ||
| 1137 | source: Source; | ||
| 1138 | documentId: ReturnType<typeof DocumentIdSchema.make>; | ||
| 1139 | authRequirement: OpenApiToolProviderData["authRequirement"] | undefined; | ||
| 1140 | schemesByName: ReadonlyMap<string, NonNullable<OpenApiToolProviderData["securitySchemes"]>[number]>; | ||
| 1141 | }): AuthRequirement => { | ||
| 1142 | const requirement = input.authRequirement; | ||
| 1143 | if (!requirement) { | ||
| 1144 | return { | ||
| 1145 | kind: "none", | ||
| 1146 | }; | ||
| 1147 | } | ||
| 1148 | |||
| 1149 | switch (requirement.kind) { | ||
| 1150 | case "none": | ||
| 1151 | return { | ||
| 1152 | kind: "none", | ||
| 1153 | }; | ||
| 1154 | case "scheme": { | ||
| 1155 | const schemeId = ensureOpenApiSecuritySchemeSymbol({ | ||
| 1156 | catalog: input.catalog, | ||
| 1157 | source: input.source, | ||
| 1158 | documentId: input.documentId, | ||
| 1159 | schemeName: requirement.schemeName, | ||
| 1160 | scheme: input.schemesByName.get(requirement.schemeName), | ||
| 1161 | }); | ||
| 1162 | |||
| 1163 | return { | ||
| 1164 | kind: "scheme", | ||
| 1165 | schemeId, | ||
| 1166 | ...(requirement.scopes && requirement.scopes.length > 0 | ||
| 1167 | ? { scopes: [...requirement.scopes] } | ||
| 1168 | : {}), | ||
| 1169 | }; | ||
| 1170 | } | ||
| 1171 | case "allOf": | ||
| 1172 | case "anyOf": | ||
| 1173 | return { | ||
| 1174 | kind: requirement.kind, | ||
| 1175 | items: requirement.items.map((item) => | ||
| 1176 | openApiAuthRequirementToIr({ | ||
| 1177 | catalog: input.catalog, | ||
| 1178 | source: input.source, | ||
| 1179 | documentId: input.documentId, | ||
| 1180 | authRequirement: item, | ||
| 1181 | schemesByName: input.schemesByName, | ||
| 1182 | })), | ||
| 1183 | }; | ||
| 1184 | } | ||
| 1185 | }; | ||
| 1186 | |||
| 1187 | const contentSpecsFromOpenApiContents = (input: { | ||
| 1188 | catalog: CatalogV1; | ||
| 1189 | source: Source; | ||
| 1190 | documentId: ReturnType<typeof DocumentIdSchema.make>; | ||
| 1191 | importer: JsonSchemaImporter; | ||
| 1192 | rootSchema?: unknown; | ||
| 1193 | contents: | ||
| 1194 | | ReadonlyArray<NonNullable<NonNullable<OpenApiToolProviderData["responses"]>[number]["contents"]>[number]> | ||
| 1195 | | ReadonlyArray<NonNullable<NonNullable<OpenApiToolProviderData["invocation"]["requestBody"]>["contents"]>[number]> | ||
| 1196 | | ReadonlyArray<NonNullable<NonNullable<OpenApiToolProviderData["invocation"]["parameters"][number]["content"]>[number]>>; | ||
| 1197 | pointerBase: string; | ||
| 1198 | }) => | ||
| 1199 | input.contents.map((content, contentIndex) => { | ||
| 1200 | const exampleIds = (content.examples ?? []).map((example, exampleIndex) => | ||
| 1201 | exampleSymbolFromValue({ | ||
| 1202 | catalog: input.catalog, | ||
| 1203 | source: input.source, | ||
| 1204 | documentId: input.documentId, | ||
| 1205 | pointer: `${input.pointerBase}/content/${contentIndex}/example/${exampleIndex}`, | ||
| 1206 | name: example.label, | ||
| 1207 | summary: example.label, | ||
| 1208 | value: JSON.parse(example.valueJson) as unknown, | ||
| 1209 | }) | ||
| 1210 | ); | ||
| 1211 | |||
| 1212 | return { | ||
| 1213 | mediaType: content.mediaType, | ||
| 1214 | ...(content.schema !== undefined | ||
| 1215 | ? { | ||
| 1216 | shapeId: input.importer.importSchema( | ||
| 1217 | content.schema, | ||
| 1218 | `${input.pointerBase}/content/${contentIndex}`, | ||
| 1219 | input.rootSchema, | ||
| 1220 | ), | ||
| 1221 | } | ||
| 1222 | : {}), | ||
| 1223 | ...(exampleIds.length > 0 ? { exampleIds } : {}), | ||
| 1224 | } satisfies ContentSpec; | ||
| 1225 | }); | ||
| 1226 | |||
| 1227 | const createOpenApiHeaderSymbol = (input: { | ||
| 1228 | catalog: CatalogV1; | ||
| 1229 | source: Source; | ||
| 1230 | documentId: ReturnType<typeof DocumentIdSchema.make>; | ||
| 1231 | importer: JsonSchemaImporter; | ||
| 1232 | rootSchema?: unknown; | ||
| 1233 | pointer: string; | ||
| 1234 | idSeed: Record<string, unknown>; | ||
| 1235 | header: NonNullable<NonNullable<OpenApiToolProviderData["responses"]>[number]["headers"]>[number]; | ||
| 1236 | }) => { | ||
| 1237 | const headerId = HeaderSymbolIdSchema.make(`header_${stableHash(input.idSeed)}`); | ||
| 1238 | const exampleIds = (input.header.examples ?? []).map((example, index) => | ||
| 1239 | exampleSymbolFromValue({ | ||
| 1240 | catalog: input.catalog, | ||
| 1241 | source: input.source, | ||
| 1242 | documentId: input.documentId, | ||
| 1243 | pointer: `${input.pointer}/example/${index}`, | ||
| 1244 | name: example.label, | ||
| 1245 | summary: example.label, | ||
| 1246 | value: JSON.parse(example.valueJson) as unknown, | ||
| 1247 | }) | ||
| 1248 | ); | ||
| 1249 | const contents = input.header.content | ||
| 1250 | ? contentSpecsFromOpenApiContents({ | ||
| 1251 | catalog: input.catalog, | ||
| 1252 | source: input.source, | ||
| 1253 | documentId: input.documentId, | ||
| 1254 | importer: input.importer, | ||
| 1255 | rootSchema: input.rootSchema, | ||
| 1256 | contents: input.header.content, | ||
| 1257 | pointerBase: input.pointer, | ||
| 1258 | }) | ||
| 1259 | : undefined; | ||
| 1260 | |||
| 1261 | mutableRecord(input.catalog.symbols)[headerId] = { | ||
| 1262 | id: headerId, | ||
| 1263 | kind: "header", | ||
| 1264 | name: input.header.name, | ||
| 1265 | ...(docsFrom({ | ||
| 1266 | description: input.header.description, | ||
| 1267 | }) | ||
| 1268 | ? { | ||
| 1269 | docs: docsFrom({ | ||
| 1270 | description: input.header.description, | ||
| 1271 | })!, | ||
| 1272 | } | ||
| 1273 | : {}), | ||
| 1274 | ...(typeof input.header.deprecated === "boolean" ? { deprecated: input.header.deprecated } : {}), | ||
| 1275 | ...(input.header.schema !== undefined | ||
| 1276 | ? { | ||
| 1277 | schemaShapeId: input.importer.importSchema( | ||
| 1278 | input.header.schema, | ||
| 1279 | input.pointer, | ||
| 1280 | input.rootSchema, | ||
| 1281 | ), | ||
| 1282 | } | ||
| 1283 | : {}), | ||
| 1284 | ...(contents && contents.length > 0 ? { content: contents } : {}), | ||
| 1285 | ...(exampleIds.length > 0 ? { exampleIds } : {}), | ||
| 1286 | ...(input.header.style ? { style: input.header.style } : {}), | ||
| 1287 | ...(typeof input.header.explode === "boolean" ? { explode: input.header.explode } : {}), | ||
| 1288 | synthetic: false, | ||
| 1289 | provenance: provenanceFor(input.documentId, input.pointer), | ||
| 1290 | }; | ||
| 1291 | |||
| 1292 | return headerId; | ||
| 1293 | }; | ||
| 1294 | |||
| 810 | const createHttpCapabilityFromOpenApi = (input: { | 1295 | const createHttpCapabilityFromOpenApi = (input: { |
| 811 | catalog: CatalogV1; | 1296 | catalog: CatalogV1; |
| 812 | source: Source; | 1297 | source: Source; |
| @@ -814,6 +1299,7 @@ const createHttpCapabilityFromOpenApi = (input: { · 1 unmodified line | |||
| 814 | serviceScopeId: ReturnType<typeof ScopeIdSchema.make>; | 1299 | serviceScopeId: ReturnType<typeof ScopeIdSchema.make>; |
| 815 | operation: OpenApiCatalogOperationInput; | 1300 | operation: OpenApiCatalogOperationInput; |
| 816 | importer: JsonSchemaImporter; | 1301 | importer: JsonSchemaImporter; |
| 1302 | rootSchema?: unknown; | ||
| 817 | }) => { | 1303 | }) => { |
| 818 | const toolPath = toolPathSegments(input.source, input.operation.providerData.toolId); | 1304 | const toolPath = toolPathSegments(input.source, input.operation.providerData.toolId); |
| 819 | const capabilityId = CapabilityIdSchema.make(`cap_${stableHash({ | 1305 | const capabilityId = CapabilityIdSchema.make(`cap_${stableHash({ |
| @@ -828,6 +1314,9 @@ const createHttpCapabilityFromOpenApi = (input: { · 8 unmodified lines | |||
| 828 | const inputSchema = input.operation.inputSchema ?? {}; | 1314 | const inputSchema = input.operation.inputSchema ?? {}; |
| 829 | const outputSchema = input.operation.outputSchema ?? {}; | 1315 | const outputSchema = input.operation.outputSchema ?? {}; |
| 830 | const exampleIds: Array<ReturnType<typeof ExampleSymbolIdSchema.make>> = []; | 1316 | const exampleIds: Array<ReturnType<typeof ExampleSymbolIdSchema.make>> = []; |
| 1317 | const schemesByName = new Map( | ||
| 1318 | (input.operation.providerData.securitySchemes ?? []).map((scheme) => [scheme.schemeName, scheme] as const), | ||
| 1319 | ); | ||
| 831 | 1320 | ||
| 832 | const parameterIds = input.operation.providerData.invocation.parameters.map((parameter) => { | 1321 | const parameterIds = input.operation.providerData.invocation.parameters.map((parameter) => { |
| 833 | const parameterId = ParameterSymbolIdSchema.make(`param_${stableHash({ | 1322 | const parameterId = ParameterSymbolIdSchema.make(`param_${stableHash({ |
| @@ -852,6 +1341,17 @@ const createHttpCapabilityFromOpenApi = (input: { · 18 unmodified lines | |||
| 852 | }); | 1341 | }); |
| 853 | }); | 1342 | }); |
| 854 | exampleIds.push(...parameterExampleIds); | 1343 | exampleIds.push(...parameterExampleIds); |
| 1344 | const parameterContent = parameter.content | ||
| 1345 | ? contentSpecsFromOpenApiContents({ | ||
| 1346 | catalog: input.catalog, | ||
| 1347 | source: input.source, | ||
| 1348 | documentId: input.documentId, | ||
| 1349 | importer: input.importer, | ||
| 1350 | rootSchema: input.rootSchema, | ||
| 1351 | contents: parameter.content, | ||
| 1352 | pointerBase: `#/openapi/${input.operation.providerData.toolId}/parameter/${parameter.location}/${parameter.name}`, | ||
| 1353 | }) | ||
| 1354 | : undefined; | ||
| 855 | mutableRecord(input.catalog.symbols)[parameterId] = { | 1355 | mutableRecord(input.catalog.symbols)[parameterId] = { |
| 856 | id: parameterId, | 1356 | id: parameterId, |
| 857 | kind: "parameter", | 1357 | kind: "parameter", |
| @@ -865,17 +1365,22 @@ const createHttpCapabilityFromOpenApi = (input: { · 7 unmodified lines | |||
| 865 | docs: docsFrom({ | 1365 | docs: docsFrom({ |
| 866 | description: matchingDocs?.description ?? null, | 1366 | description: matchingDocs?.description ?? null, |
| 867 | })!, | 1367 | })!, |
| 868 | } | 1368 | } |
| 869 | : {}), | 1369 | : {}), |
| 870 | ...(parameterSchema !== undefined | 1370 | ...(parameterSchema !== undefined && (!parameterContent || parameterContent.length === 0) |
| 871 | ? { | 1371 | ? { |
| 872 | schemaShapeId: input.importer.importSchema( | 1372 | schemaShapeId: input.importer.importSchema( |
| 873 | parameterSchema, | 1373 | parameterSchema, |
| 874 | `#/openapi/${input.operation.providerData.toolId}/parameter/${parameter.location}/${parameter.name}`, | 1374 | `#/openapi/${input.operation.providerData.toolId}/parameter/${parameter.location}/${parameter.name}`, |
| 1375 | input.rootSchema, | ||
| 875 | ), | 1376 | ), |
| 876 | } | 1377 | } |
| 877 | : {}), | 1378 | : {}), |
| 1379 | ...(parameterContent && parameterContent.length > 0 ? { content: parameterContent } : {}), | ||
| 878 | ...(parameterExampleIds.length > 0 ? { exampleIds: parameterExampleIds } : {}), | 1380 | ...(parameterExampleIds.length > 0 ? { exampleIds: parameterExampleIds } : {}), |
| 1381 | ...(parameter.style ? { style: parameter.style } : {}), | ||
| 1382 | ...(typeof parameter.explode === "boolean" ? { explode: parameter.explode } : {}), | ||
| 1383 | ...(typeof parameter.allowReserved === "boolean" ? { allowReserved: parameter.allowReserved } : {}), | ||
| 879 | synthetic: false, | 1384 | synthetic: false, |
| 880 | provenance: provenanceFor( | 1385 | provenance: provenanceFor( |
| 881 | input.documentId, | 1386 | input.documentId, |
| @@ -891,33 +1396,50 @@ const createHttpCapabilityFromOpenApi = (input: { · 9 unmodified lines | |||
| 891 | 1396 | ||
| 892 | if (requestBodyId) { | 1397 | if (requestBodyId) { |
| 893 | const requestBodySchema = requestBodySchemaFromInput(inputSchema); | 1398 | const requestBodySchema = requestBodySchemaFromInput(inputSchema); |
| 894 | const requestBodyExampleIds = (input.operation.providerData.documentation?.requestBody?.examples ?? []).map( | 1399 | const requestBodyContents = input.operation.providerData.invocation.requestBody?.contents |
| 895 | (example, index) => | 1400 | ? contentSpecsFromOpenApiContents({ |
| 896 | exampleSymbolFromValue({ | ||
| 897 | catalog: input.catalog, | 1401 | catalog: input.catalog, |
| 898 | source: input.source, | 1402 | source: input.source, |
| 899 | documentId: input.documentId, | 1403 | documentId: input.documentId, |
| 900 | pointer: `#/openapi/${input.operation.providerData.toolId}/requestBody/example/${index}`, | 1404 | importer: input.importer, |
| 901 | name: example.label, | 1405 | rootSchema: input.rootSchema, |
| 902 | summary: example.label, | 1406 | contents: input.operation.providerData.invocation.requestBody.contents, |
| 903 | value: JSON.parse(example.valueJson) as unknown, | 1407 | pointerBase: `#/openapi/${input.operation.providerData.toolId}/requestBody`, |
| 904 | }), | 1408 | }) |
| 905 | ); | 1409 | : undefined; |
| 1410 | const requestBodyExampleIds = | ||
| 1411 | requestBodyContents?.flatMap((content) => content.exampleIds ?? []) | ||
| 1412 | ?? (input.operation.providerData.documentation?.requestBody?.examples ?? []).map( | ||
| 1413 | (example, index) => | ||
| 1414 | exampleSymbolFromValue({ | ||
| 1415 | catalog: input.catalog, | ||
| 1416 | source: input.source, | ||
| 1417 | documentId: input.documentId, | ||
| 1418 | pointer: `#/openapi/${input.operation.providerData.toolId}/requestBody/example/${index}`, | ||
| 1419 | name: example.label, | ||
| 1420 | summary: example.label, | ||
| 1421 | value: JSON.parse(example.valueJson) as unknown, | ||
| 1422 | }), | ||
| 1423 | ); | ||
| 906 | exampleIds.push(...requestBodyExampleIds); | 1424 | exampleIds.push(...requestBodyExampleIds); |
| 907 | const contents: ContentSpec[] = preferredResponseContentTypes( | 1425 | const contents: ContentSpec[] = |
| 908 | input.operation.providerData.invocation.requestBody?.contentTypes, | 1426 | requestBodyContents && requestBodyContents.length > 0 |
| 909 | ).map((mediaType) => ({ | 1427 | ? requestBodyContents |
| 910 | mediaType, | 1428 | : preferredResponseContentTypes( |
| 911 | ...(requestBodySchema !== undefined | 1429 | input.operation.providerData.invocation.requestBody?.contentTypes, |
| 912 | ? { | 1430 | ).map((mediaType) => ({ |
| 913 | shapeId: input.importer.importSchema( | 1431 | mediaType, |
| 914 | requestBodySchema, | 1432 | ...(requestBodySchema !== undefined |
| 915 | `#/openapi/${input.operation.providerData.toolId}/requestBody`, | 1433 | ? { |
| 916 | ), | 1434 | shapeId: input.importer.importSchema( |
| 917 | } | 1435 | requestBodySchema, |
| 918 | : {}), | 1436 | `#/openapi/${input.operation.providerData.toolId}/requestBody`, |
| 919 | ...(requestBodyExampleIds.length > 0 ? { exampleIds: requestBodyExampleIds } : {}), | 1437 | input.rootSchema, |
| 920 | })); | 1438 | ), |
| 1439 | } | ||
| 1440 | : {}), | ||
| 1441 | ...(requestBodyExampleIds.length > 0 ? { exampleIds: requestBodyExampleIds } : {}), | ||
| 1442 | })); | ||
| 921 | 1443 | ||
| 922 | mutableRecord(input.catalog.symbols)[requestBodyId] = { | 1444 | mutableRecord(input.catalog.symbols)[requestBodyId] = { |
| 923 | id: requestBodyId, | 1445 | id: requestBodyId, |
| @@ -938,62 +1460,188 @@ const createHttpCapabilityFromOpenApi = (input: { · 14 unmodified lines | |||
| 938 | }; | 1460 | }; |
| 939 | } | 1461 | } |
| 940 | 1462 | ||
| 941 | const responseId = ResponseSymbolIdSchema.make(`response_${stableHash({ capabilityId })}`); | 1463 | const openApiResponseVariants = input.operation.providerData.responses ?? []; |
| 942 | const responseExampleIds = (input.operation.providerData.documentation?.response?.examples ?? []).map((example, index) => | 1464 | const responseSetId = |
| 943 | exampleSymbolFromValue({ | 1465 | openApiResponseVariants.length > 0 |
| 944 | catalog: input.catalog, | 1466 | ? responseSetFromVariants({ |
| 945 | source: input.source, | 1467 | catalog: input.catalog, |
| 946 | documentId: input.documentId, | 1468 | variants: openApiResponseVariants.map((response, responseIndex) => { |
| 947 | pointer: `#/openapi/${input.operation.providerData.toolId}/response/example/${index}`, | 1469 | const responseId = ResponseSymbolIdSchema.make(`response_${stableHash({ |
| 948 | name: example.label, | 1470 | capabilityId, |
| 949 | summary: example.label, | 1471 | statusCode: response.statusCode, |
| 950 | value: JSON.parse(example.valueJson) as unknown, | 1472 | responseIndex, |
| 951 | }) | 1473 | })}`); |
| 952 | ); | 1474 | const responseExampleIds = (response.examples ?? []).map((example, index) => |
| 953 | exampleIds.push(...responseExampleIds); | 1475 | exampleSymbolFromValue({ |
| 954 | mutableRecord(input.catalog.symbols)[responseId] = { | 1476 | catalog: input.catalog, |
| 955 | id: responseId, | 1477 | source: input.source, |
| 956 | kind: "response", | 1478 | documentId: input.documentId, |
| 957 | ...(docsFrom({ | 1479 | pointer: `#/openapi/${input.operation.providerData.toolId}/responses/${response.statusCode}/example/${index}`, |
| 958 | description: | 1480 | name: example.label, |
| 959 | input.operation.providerData.documentation?.response?.description | 1481 | summary: example.label, |
| 960 | ?? input.operation.description, | 1482 | value: JSON.parse(example.valueJson) as unknown, |
| 961 | }) | 1483 | }) |
| 962 | ? { | 1484 | ); |
| 963 | docs: docsFrom({ | 1485 | exampleIds.push(...responseExampleIds); |
| 964 | description: | 1486 | |
| 965 | input.operation.providerData.documentation?.response?.description | 1487 | const contents = |
| 966 | ?? input.operation.description, | 1488 | response.contents && response.contents.length > 0 |
| 967 | })!, | 1489 | ? contentSpecsFromOpenApiContents({ |
| 968 | } | 1490 | catalog: input.catalog, |
| 969 | : {}), | 1491 | source: input.source, |
| 970 | contents: [{ | 1492 | documentId: input.documentId, |
| 971 | mediaType: preferredResponseContentTypes(input.operation.providerData.documentation?.response?.contentTypes)[0] ?? "application/json", | 1493 | importer: input.importer, |
| 972 | ...(input.operation.outputSchema !== undefined | 1494 | rootSchema: input.rootSchema, |
| 973 | ? { | 1495 | contents: response.contents, |
| 974 | shapeId: input.importer.importSchema( | 1496 | pointerBase: `#/openapi/${input.operation.providerData.toolId}/responses/${response.statusCode}`, |
| 975 | outputSchema, | 1497 | }) |
| 976 | `#/openapi/${input.operation.providerData.toolId}/response`, | 1498 | : (() => { |
| 977 | ), | 1499 | const responseShapeId = |
| 978 | } | 1500 | response.schema !== undefined |
| 979 | : {}), | 1501 | ? input.importer.importSchema( |
| 980 | ...(responseExampleIds.length > 0 ? { exampleIds: responseExampleIds } : {}), | 1502 | response.schema, |
| 981 | }], | 1503 | `#/openapi/${input.operation.providerData.toolId}/responses/${response.statusCode}`, |
| 982 | synthetic: false, | 1504 | input.rootSchema, |
| 983 | provenance: provenanceFor(input.documentId, `#/openapi/${input.operation.providerData.toolId}/response`), | 1505 | ) |
| 984 | } satisfies ResponseSymbol; | 1506 | : undefined; |
| 985 | 1507 | const preferredContentTypes = preferredResponseContentTypes(response.contentTypes); | |
| 986 | const responseSetId = responseSetFromSingleResponse({ | 1508 | |
| 987 | catalog: input.catalog, | 1509 | return preferredContentTypes.length > 0 |
| 988 | responseId, | 1510 | ? preferredContentTypes.map((mediaType, contentIndex) => ({ |
| 989 | provenance: provenanceFor(input.documentId, `#/openapi/${input.operation.providerData.toolId}/responseSet`), | 1511 | mediaType, |
| 990 | }); | 1512 | ...(responseShapeId !== undefined && contentIndex === 0 |
| 1513 | ? { shapeId: responseShapeId } | ||
| 1514 | : {}), | ||
| 1515 | ...(responseExampleIds.length > 0 && contentIndex === 0 | ||
| 1516 | ? { exampleIds: responseExampleIds } | ||
| 1517 | : {}), | ||
| 1518 | })) | ||
| 1519 | : undefined; | ||
| 1520 | })(); | ||
| 1521 | const headerIds = (response.headers ?? []).map((header, headerIndex) => | ||
| 1522 | createOpenApiHeaderSymbol({ | ||
| 1523 | catalog: input.catalog, | ||
| 1524 | source: input.source, | ||
| 1525 | documentId: input.documentId, | ||
| 1526 | importer: input.importer, | ||
| 1527 | rootSchema: input.rootSchema, | ||
| 1528 | pointer: `#/openapi/${input.operation.providerData.toolId}/responses/${response.statusCode}/headers/${header.name}`, | ||
| 1529 | idSeed: { | ||
| 1530 | capabilityId, | ||
| 1531 | responseId, | ||
| 1532 | headerIndex, | ||
| 1533 | headerName: header.name, | ||
| 1534 | }, | ||
| 1535 | header, | ||
| 1536 | }) | ||
| 1537 | ); | ||
| 1538 | |||
| 1539 | mutableRecord(input.catalog.symbols)[responseId] = { | ||
| 1540 | id: responseId, | ||
| 1541 | kind: "response", | ||
| 1542 | ...(docsFrom({ | ||
| 1543 | description: | ||
| 1544 | response.description | ||
| 1545 | ?? (responseIndex === 0 ? input.operation.description : null), | ||
| 1546 | }) | ||
| 1547 | ? { | ||
| 1548 | docs: docsFrom({ | ||
| 1549 | description: | ||
| 1550 | response.description | ||
| 1551 | ?? (responseIndex === 0 ? input.operation.description : null), | ||
| 1552 | })!, | ||
| 1553 | } | ||
| 1554 | : {}), | ||
| 1555 | ...(headerIds.length > 0 ? { headerIds } : {}), | ||
| 1556 | ...(contents && contents.length > 0 ? { contents } : {}), | ||
| 1557 | synthetic: false, | ||
| 1558 | provenance: provenanceFor( | ||
| 1559 | input.documentId, | ||
| 1560 | `#/openapi/${input.operation.providerData.toolId}/responses/${response.statusCode}`, | ||
| 1561 | ), | ||
| 1562 | } satisfies ResponseSymbol; | ||
| 1563 | |||
| 1564 | return { | ||
| 1565 | match: statusMatchFromHttpStatusCode(response.statusCode), | ||
| 1566 | responseId, | ||
| 1567 | } satisfies ResponseSet["variants"][number]; | ||
| 1568 | }), | ||
| 1569 | provenance: provenanceFor(input.documentId, `#/openapi/${input.operation.providerData.toolId}/responseSet`), | ||
| 1570 | }) | ||
| 1571 | : (() => { | ||
| 1572 | const responseId = ResponseSymbolIdSchema.make(`response_${stableHash({ capabilityId })}`); | ||
| 1573 | const responseExampleIds = (input.operation.providerData.documentation?.response?.examples ?? []).map((example, index) => | ||
| 1574 | exampleSymbolFromValue({ | ||
| 1575 | catalog: input.catalog, | ||
| 1576 | source: input.source, | ||
| 1577 | documentId: input.documentId, | ||
| 1578 | pointer: `#/openapi/${input.operation.providerData.toolId}/response/example/${index}`, | ||
| 1579 | name: example.label, | ||
| 1580 | summary: example.label, | ||
| 1581 | value: JSON.parse(example.valueJson) as unknown, | ||
| 1582 | }) | ||
| 1583 | ); | ||
| 1584 | exampleIds.push(...responseExampleIds); | ||
| 1585 | mutableRecord(input.catalog.symbols)[responseId] = { | ||
| 1586 | id: responseId, | ||
| 1587 | kind: "response", | ||
| 1588 | ...(docsFrom({ | ||
| 1589 | description: | ||
| 1590 | input.operation.providerData.documentation?.response?.description | ||
| 1591 | ?? input.operation.description, | ||
| 1592 | }) | ||
| 1593 | ? { | ||
| 1594 | docs: docsFrom({ | ||
| 1595 | description: | ||
| 1596 | input.operation.providerData.documentation?.response?.description | ||
| 1597 | ?? input.operation.description, | ||
| 1598 | })!, | ||
| 1599 | } | ||
| 1600 | : {}), | ||
| 1601 | contents: [{ | ||
| 1602 | mediaType: preferredResponseContentTypes(input.operation.providerData.documentation?.response?.contentTypes)[0] ?? "application/json", | ||
| 1603 | ...(input.operation.outputSchema !== undefined | ||
| 1604 | ? { | ||
| 1605 | shapeId: input.importer.importSchema( | ||
| 1606 | outputSchema, | ||
| 1607 | `#/openapi/${input.operation.providerData.toolId}/response`, | ||
| 1608 | ), | ||
| 1609 | } | ||
| 1610 | : {}), | ||
| 1611 | ...(responseExampleIds.length > 0 ? { exampleIds: responseExampleIds } : {}), | ||
| 1612 | }], | ||
| 1613 | synthetic: false, | ||
| 1614 | provenance: provenanceFor(input.documentId, `#/openapi/${input.operation.providerData.toolId}/response`), | ||
| 1615 | } satisfies ResponseSymbol; | ||
| 1616 | |||
| 1617 | return responseSetFromSingleResponse({ | ||
| 1618 | catalog: input.catalog, | ||
| 1619 | responseId, | ||
| 1620 | provenance: provenanceFor(input.documentId, `#/openapi/${input.operation.providerData.toolId}/responseSet`), | ||
| 1621 | }); | ||
| 1622 | })(); | ||
| 991 | 1623 | ||
| 992 | const executable: HttpExecutable = { | 1624 | const executable: HttpExecutable = { |
| 993 | id: executableId, | 1625 | id: executableId, |
| 994 | protocol: "http", | 1626 | protocol: "http", |
| 995 | capabilityId, | 1627 | capabilityId, |
| 996 | scopeId: input.serviceScopeId, | 1628 | scopeId: (() => { |
| 1629 | const operationServers = openApiServerSpecs(input.operation.providerData.servers); | ||
| 1630 | if (!operationServers || operationServers.length === 0) { | ||
| 1631 | return input.serviceScopeId; | ||
| 1632 | } | ||
| 1633 | |||
| 1634 | return createOperationScope({ | ||
| 1635 | catalog: input.catalog, | ||
| 1636 | source: input.source, | ||
| 1637 | documentId: input.documentId, | ||
| 1638 | parentScopeId: input.serviceScopeId, | ||
| 1639 | operation: input.operation, | ||
| 1640 | defaults: { | ||
| 1641 | servers: operationServers, | ||
| 1642 | }, | ||
| 1643 | }); | ||
| 1644 | })(), | ||
| 997 | method: input.operation.providerData.invocation.method.toUpperCase(), | 1645 | method: input.operation.providerData.invocation.method.toUpperCase(), |
| 998 | pathTemplate: input.operation.providerData.invocation.pathTemplate, | 1646 | pathTemplate: input.operation.providerData.invocation.pathTemplate, |
| 999 | pathParameterIds: parameterIds.filter((parameterId) => { | 1647 | pathParameterIds: parameterIds.filter((parameterId) => { |
| @@ -1016,16 +1664,17 @@ const createHttpCapabilityFromOpenApi = (input: { · 16 unmodified lines | |||
| 1016 | responseSetId, | 1664 | responseSetId, |
| 1017 | synthetic: false, | 1665 | synthetic: false, |
| 1018 | provenance: provenanceFor(input.documentId, `#/openapi/${input.operation.providerData.toolId}/executable`), | 1666 | provenance: provenanceFor(input.documentId, `#/openapi/${input.operation.providerData.toolId}/executable`), |
| 1019 | native: [nativeBlob({ | ||
| 1020 | source: input.source, | ||
| 1021 | kind: "openapi_provider_data", | ||
| 1022 | pointer: `#/openapi/${input.operation.providerData.toolId}/providerData`, | ||
| 1023 | value: input.operation.providerData, | ||
| 1024 | })], | ||
| 1025 | }; | 1667 | }; |
| 1026 | mutableRecord(input.catalog.executables)[executableId] = executable; | 1668 | mutableRecord(input.catalog.executables)[executableId] = executable; |
| 1027 | 1669 | ||
| 1028 | const effect = input.operation.effect; | 1670 | const effect = input.operation.effect; |
| 1671 | const auth = openApiAuthRequirementToIr({ | ||
| 1672 | catalog: input.catalog, | ||
| 1673 | source: input.source, | ||
| 1674 | documentId: input.documentId, | ||
| 1675 | authRequirement: input.operation.providerData.authRequirement, | ||
| 1676 | schemesByName, | ||
| 1677 | }); | ||
| 1029 | mutableRecord(input.catalog.capabilities)[capabilityId] = { | 1678 | mutableRecord(input.catalog.capabilities)[capabilityId] = { |
| 1030 | id: capabilityId, | 1679 | id: capabilityId, |
| 1031 | serviceScopeId: input.serviceScopeId, | 1680 | serviceScopeId: input.serviceScopeId, |
| @@ -1041,18 +1690,12 @@ const createHttpCapabilityFromOpenApi = (input: { · 9 unmodified lines | |||
| 1041 | idempotent: effect === "read" || effect === "delete", | 1690 | idempotent: effect === "read" || effect === "delete", |
| 1042 | destructive: effect === "delete", | 1691 | destructive: effect === "delete", |
| 1043 | }, | 1692 | }, |
| 1044 | auth: { kind: "none" }, | 1693 | auth, |
| 1045 | interaction: interactionForEffect(effect), | 1694 | interaction: interactionForEffect(effect), |
| 1046 | executableIds: [executableId], | 1695 | executableIds: [executableId], |
| 1047 | ...(exampleIds.length > 0 ? { exampleIds } : {}), | 1696 | ...(exampleIds.length > 0 ? { exampleIds } : {}), |
| 1048 | synthetic: false, | 1697 | synthetic: false, |
| 1049 | provenance: provenanceFor(input.documentId, `#/openapi/${input.operation.providerData.toolId}/capability`), | 1698 | provenance: provenanceFor(input.documentId, `#/openapi/${input.operation.providerData.toolId}/capability`), |
| 1050 | native: [nativeBlob({ | ||
| 1051 | source: input.source, | ||
| 1052 | kind: "openapi_documentation", | ||
| 1053 | pointer: `#/openapi/${input.operation.providerData.toolId}/documentation`, | ||
| 1054 | value: input.operation.providerData.documentation ?? null, | ||
| 1055 | })], | ||
| 1056 | } satisfies Capability; | 1699 | } satisfies Capability; |
| 1057 | }; | 1700 | }; |
| 1058 | 1701 | ||
| @@ -1085,6 +1728,12 @@ const createHttpCapabilityFromGoogleDiscovery = (input: { · 26 unmodified lines | |||
| 1085 | : undefined; | 1728 | : undefined; |
| 1086 | 1729 | ||
| 1087 | if (authSchemeId && !input.catalog.symbols[authSchemeId]) { | 1730 | if (authSchemeId && !input.catalog.symbols[authSchemeId]) { |
| 1731 | const scopeDescriptions = Object.fromEntries( | ||
| 1732 | input.operation.providerData.invocation.scopes.map((scope) => [ | ||
| 1733 | scope, | ||
| 1734 | input.operation.providerData.invocation.scopeDescriptions?.[scope] ?? scope, | ||
| 1735 | ]), | ||
| 1736 | ); | ||
| 1088 | mutableRecord(input.catalog.symbols)[authSchemeId] = { | 1737 | mutableRecord(input.catalog.symbols)[authSchemeId] = { |
| 1089 | id: authSchemeId, | 1738 | id: authSchemeId, |
| 1090 | kind: "securityScheme", | 1739 | kind: "securityScheme", |
| @@ -1095,7 +1744,7 @@ const createHttpCapabilityFromGoogleDiscovery = (input: { · 4 unmodified lines | |||
| 1095 | }), | 1744 | }), |
| 1096 | oauth: { | 1745 | oauth: { |
| 1097 | flows: {}, | 1746 | flows: {}, |
| 1098 | scopes: Object.fromEntries(input.operation.providerData.invocation.scopes.map((scope) => [scope, scope])), | 1747 | scopes: scopeDescriptions, |
| 1099 | }, | 1748 | }, |
| 1100 | synthetic: false, | 1749 | synthetic: false, |
| 1101 | provenance: provenanceFor(input.documentId, "#/googleDiscovery/security"), | 1750 | provenance: provenanceFor(input.documentId, "#/googleDiscovery/security"), |
| @@ -1232,7 +1881,12 @@ const createHttpCapabilityFromGoogleDiscovery = (input: { · 130 unmodified lines | |||
| 1232 | source: input.source, | 1881 | source: input.source, |
| 1233 | kind: "google_discovery_provider_data", | 1882 | kind: "google_discovery_provider_data", |
| 1234 | pointer: `#/googleDiscovery/${input.operation.providerData.toolId}/providerData`, | 1883 | pointer: `#/googleDiscovery/${input.operation.providerData.toolId}/providerData`, |
| 1235 | value: input.operation.providerData, | 1884 | value: { |
| 1885 | invocation: { | ||
| 1886 | rootUrl: input.operation.providerData.invocation.rootUrl, | ||
| 1887 | servicePath: input.operation.providerData.invocation.servicePath, | ||
| 1888 | }, | ||
| 1889 | }, | ||
| 1236 | })], | 1890 | })], |
| 1237 | } satisfies HttpExecutable; | 1891 | } satisfies HttpExecutable; |
| 1238 | 1892 | ||
| @@ -1266,12 +1920,6 @@ const createHttpCapabilityFromGoogleDiscovery = (input: { · 27 unmodified lines | |||
| 1266 | executableIds: [executableId], | 1920 | executableIds: [executableId], |
| 1267 | synthetic: false, | 1921 | synthetic: false, |
| 1268 | provenance: provenanceFor(input.documentId, `#/googleDiscovery/${input.operation.providerData.toolId}/capability`), | 1922 | provenance: provenanceFor(input.documentId, `#/googleDiscovery/${input.operation.providerData.toolId}/capability`), |
| 1269 | native: [nativeBlob({ | ||
| 1270 | source: input.source, | ||
| 1271 | kind: "google_discovery_provider_data", | ||
| 1272 | pointer: `#/googleDiscovery/${input.operation.providerData.toolId}/providerData`, | ||
| 1273 | value: input.operation.providerData, | ||
| 1274 | })], | ||
| 1275 | } satisfies Capability; | 1923 | } satisfies Capability; |
| 1276 | }; | 1924 | }; |
| 1277 | 1925 | ||
| @@ -1361,12 +2009,6 @@ const createGraphqlCapability = (input: { · 83 unmodified lines | |||
| 1361 | responseSetId, | 2009 | responseSetId, |
| 1362 | synthetic: false, | 2010 | synthetic: false, |
| 1363 | provenance: provenanceFor(input.documentId, `#/graphql/${input.operation.providerData.toolId}/executable`), | 2011 | provenance: provenanceFor(input.documentId, `#/graphql/${input.operation.providerData.toolId}/executable`), |
| 1364 | native: [nativeBlob({ | ||
| 1365 | source: input.source, | ||
| 1366 | kind: "graphql_provider_data", | ||
| 1367 | pointer: `#/graphql/${input.operation.providerData.toolId}/providerData`, | ||
| 1368 | value: input.operation.providerData, | ||
| 1369 | })], | ||
| 1370 | } satisfies GraphQLExecutable; | 2012 | } satisfies GraphQLExecutable; |
| 1371 | 2013 | ||
| 1372 | const effect = input.operation.effect; | 2014 | const effect = input.operation.effect; |
| @@ -1391,12 +2033,6 @@ const createGraphqlCapability = (input: { · 18 unmodified lines | |||
| 1391 | executableIds: [executableId], | 2033 | executableIds: [executableId], |
| 1392 | synthetic: false, | 2034 | synthetic: false, |
| 1393 | provenance: provenanceFor(input.documentId, `#/graphql/${input.operation.providerData.toolId}/capability`), | 2035 | provenance: provenanceFor(input.documentId, `#/graphql/${input.operation.providerData.toolId}/capability`), |
| 1394 | native: [nativeBlob({ | ||
| 1395 | source: input.source, | ||
| 1396 | kind: "graphql_provider_data", | ||
| 1397 | pointer: `#/graphql/${input.operation.providerData.toolId}/providerData`, | ||
| 1398 | value: input.operation.providerData, | ||
| 1399 | })], | ||
| 1400 | } satisfies Capability; | 2036 | } satisfies Capability; |
| 1401 | }; | 2037 | }; |
| 1402 | 2038 | ||
| @@ -1473,30 +2109,28 @@ const createMcpCapability = (input: { · 70 unmodified lines | |||
| 1473 | responseSetId, | 2109 | responseSetId, |
| 1474 | synthetic: false, | 2110 | synthetic: false, |
| 1475 | provenance: provenanceFor(input.documentId, `#/mcp/${input.operation.providerData.toolId}/executable`), | 2111 | provenance: provenanceFor(input.documentId, `#/mcp/${input.operation.providerData.toolId}/executable`), |
| 1476 | native: [nativeBlob({ | ||
| 1477 | source: input.source, | ||
| 1478 | kind: "mcp_provider_data", | ||
| 1479 | pointer: `#/mcp/${input.operation.providerData.toolId}/providerData`, | ||
| 1480 | value: input.operation.providerData, | ||
| 1481 | })], | ||
| 1482 | } satisfies McpExecutable; | 2112 | } satisfies McpExecutable; |
| 1483 | 2113 | ||
| 2114 | const interaction = interactionForEffect(input.operation.effect); | ||
| 1484 | mutableRecord(input.catalog.capabilities)[capabilityId] = { | 2115 | mutableRecord(input.catalog.capabilities)[capabilityId] = { |
| 1485 | id: capabilityId, | 2116 | id: capabilityId, |
| 1486 | serviceScopeId: input.serviceScopeId, | 2117 | serviceScopeId: input.serviceScopeId, |
| 1487 | surface: { | 2118 | surface: { |
| 1488 | toolPath, | 2119 | toolPath, |
| 1489 | title: input.operation.providerData.toolName, | 2120 | title: input.operation.providerData.displayTitle, |
| 1490 | ...(input.operation.providerData.description ? { summary: input.operation.providerData.description } : {}), | 2121 | ...(input.operation.providerData.description ? { summary: input.operation.providerData.description } : {}), |
| 1491 | }, | 2122 | }, |
| 1492 | semantics: { | 2123 | semantics: mcpSemanticsForOperation({ |
| 1493 | effect: input.operation.effect, | 2124 | effect: input.operation.effect, |
| 1494 | safe: input.operation.effect === "read", | 2125 | annotations: input.operation.providerData.annotations, |
| 1495 | idempotent: input.operation.effect === "read", | 2126 | }), |
| 1496 | destructive: false, | ||
| 1497 | }, | ||
| 1498 | auth: { kind: "none" }, | 2127 | auth: { kind: "none" }, |
| 1499 | interaction: interactionForEffect(input.operation.effect), | 2128 | interaction: { |
| 2129 | ...interaction, | ||
| 2130 | resume: { | ||
| 2131 | supported: mcpResumeSupport(input.operation.providerData.execution), | ||
| 2132 | }, | ||
| 2133 | }, | ||
| 1500 | executableIds: [executableId], | 2134 | executableIds: [executableId], |
| 1501 | synthetic: false, | 2135 | synthetic: false, |
| 1502 | provenance: provenanceFor(input.documentId, `#/mcp/${input.operation.providerData.toolId}/capability`), | 2136 | provenance: provenanceFor(input.documentId, `#/mcp/${input.operation.providerData.toolId}/capability`), |
| @@ -1507,6 +2141,7 @@ const buildCatalogSnapshot = (input: { · 4 unmodified lines | |||
| 1507 | source: Source; | 2141 | source: Source; |
| 1508 | adapterKey: string; | 2142 | adapterKey: string; |
| 1509 | documents: readonly CatalogSourceDocumentInput[]; | 2143 | documents: readonly CatalogSourceDocumentInput[]; |
| 2144 | serviceScopeDefaults?: Scope["defaults"]; | ||
| 1510 | registerOperations: (context: { | 2145 | registerOperations: (context: { |
| 1511 | catalog: CatalogV1; | 2146 | catalog: CatalogV1; |
| 1512 | documentId: ReturnType<typeof DocumentIdSchema.make>; | 2147 | documentId: ReturnType<typeof DocumentIdSchema.make>; |
| @@ -1573,6 +2208,7 @@ const buildCatalogSnapshot = (input: { · 60 unmodified lines | |||
| 1573 | catalog, | 2208 | catalog, |
| 1574 | source: input.source, | 2209 | source: input.source, |
| 1575 | documentId: primaryDocumentId, | 2210 | documentId: primaryDocumentId, |
| 2211 | defaults: input.serviceScopeDefaults, | ||
| 1576 | }); | 2212 | }); |
| 1577 | const importer = createJsonSchemaImporter({ | 2213 | const importer = createJsonSchemaImporter({ |
| 1578 | catalog, | 2214 | catalog, |
| @@ -1586,6 +2222,7 @@ const buildCatalogSnapshot = (input: { · 7 unmodified lines | |||
| 1586 | serviceScopeId, | 2222 | serviceScopeId, |
| 1587 | importer, | 2223 | importer, |
| 1588 | }); | 2224 | }); |
| 2225 | importer.finalize(); | ||
| 1589 | 2226 | ||
| 1590 | const snapshot = createCatalogSnapshotV1({ | 2227 | const snapshot = createCatalogSnapshotV1({ |
| 1591 | import: importMetadataFor(input), | 2228 | import: importMetadataFor(input), |
| @@ -1599,11 +2236,33 @@ export const createOpenApiCatalogSnapshot = (input: { · 7 unmodified lines | |||
| 1599 | source: Source; | 2236 | source: Source; |
| 1600 | documents: readonly CatalogSourceDocumentInput[]; | 2237 | documents: readonly CatalogSourceDocumentInput[]; |
| 1601 | operations: readonly OpenApiCatalogOperationInput[]; | 2238 | operations: readonly OpenApiCatalogOperationInput[]; |
| 1602 | }): CatalogSnapshotV1 => | 2239 | }): CatalogSnapshotV1 => { |
| 1603 | buildCatalogSnapshot({ | 2240 | const rootSchema = (() => { |
| 2241 | const primaryDocumentText = input.documents[0]?.contentText; | ||
| 2242 | if (!primaryDocumentText) { | ||
| 2243 | return undefined; | ||
| 2244 | } | ||
| 2245 | |||
| 2246 | try { | ||
| 2247 | return JSON.parse(primaryDocumentText) as unknown; | ||
| 2248 | } catch { | ||
| 2249 | return undefined; | ||
| 2250 | } | ||
| 2251 | })(); | ||
| 2252 | |||
| 2253 | return buildCatalogSnapshot({ | ||
| 1604 | source: input.source, | 2254 | source: input.source, |
| 1605 | adapterKey: "openapi", | 2255 | adapterKey: "openapi", |
| 1606 | documents: input.documents, | 2256 | documents: input.documents, |
| 2257 | serviceScopeDefaults: (() => { | ||
| 2258 | const documentServers = openApiServerSpecs( | ||
| 2259 | input.operations.find((operation) => | ||
| 2260 | (operation.providerData.documentServers ?? []).length > 0 | ||
| 2261 | )?.providerData.documentServers, | ||
| 2262 | ); | ||
| 2263 | |||
| 2264 | return documentServers ? { servers: documentServers } : undefined; | ||
| 2265 | })(), | ||
| 1607 | registerOperations: ({ catalog, documentId, serviceScopeId, importer }) => { | 2266 | registerOperations: ({ catalog, documentId, serviceScopeId, importer }) => { |
| 1608 | for (const operation of input.operations) { | 2267 | for (const operation of input.operations) { |
| 1609 | createHttpCapabilityFromOpenApi({ | 2268 | createHttpCapabilityFromOpenApi({ |
| @@ -1613,10 +2272,12 @@ export const createOpenApiCatalogSnapshot = (input: { · 3 unmodified lines | |||
| 1613 | serviceScopeId, | 2272 | serviceScopeId, |
| 1614 | operation, | 2273 | operation, |
| 1615 | importer, | 2274 | importer, |
| 2275 | rootSchema, | ||
| 1616 | }); | 2276 | }); |
| 1617 | } | 2277 | } |
| 1618 | }, | 2278 | }, |
| 1619 | }); | 2279 | }); |
| 2280 | }; | ||
| 1620 | 2281 | ||
| 1621 | export const createGoogleDiscoveryCatalogSnapshot = (input: { | 2282 | export const createGoogleDiscoveryCatalogSnapshot = (input: { |
| 1622 | source: Source; | 2283 | source: Source; |
| @@ -3,6 +3,7 @@ import type { · 2 unmodified lines | |||
| 3 | Source, | 3 | Source, |
| 4 | SourceStatus, | 4 | SourceStatus, |
| 5 | } from "#schema"; | 5 | } from "#schema"; |
| 6 | import type { McpToolManifest } from "@executor/codemode-mcp"; | ||
| 6 | import * as Context from "effect/Context"; | 7 | import * as Context from "effect/Context"; |
| 7 | import * as Effect from "effect/Effect"; | 8 | import * as Effect from "effect/Effect"; |
| 8 | import * as Layer from "effect/Layer"; | 9 | import * as Layer from "effect/Layer"; |
| @@ -27,7 +28,7 @@ import { · 18 unmodified lines | |||
| 27 | getSourceAdapterForSource, | 28 | getSourceAdapterForSource, |
| 28 | } from "./source-adapters"; | 29 | } from "./source-adapters"; |
| 29 | import { | 30 | import { |
| 30 | catalogSyncResultFromMcpManifestEntries, | 31 | catalogSyncResultFromMcpManifest, |
| 31 | } from "./source-adapters/mcp"; | 32 | } from "./source-adapters/mcp"; |
| 32 | import { SecretMaterialResolverService } from "./secret-material-providers"; | 33 | import { SecretMaterialResolverService } from "./secret-material-providers"; |
| 33 | import { | 34 | import { |
| @@ -61,7 +62,7 @@ export type RuntimeSourceCatalogSyncShape = { · 27 unmodified lines | |||
| 61 | }) => Effect.Effect<void, Error, never>; | 62 | }) => Effect.Effect<void, Error, never>; |
| 62 | persistMcpCatalogSnapshotFromManifest: (input: { | 63 | persistMcpCatalogSnapshotFromManifest: (input: { |
| 63 | source: Source; | 64 | source: Source; |
| 64 | manifestEntries: Parameters<typeof catalogSyncResultFromMcpManifestEntries>[0]["manifestEntries"]; | 65 | manifest: McpToolManifest; |
| 65 | }) => Effect.Effect<void, Error, never>; | 66 | }) => Effect.Effect<void, Error, never>; |
| 66 | }; | 67 | }; |
| 67 | 68 | ||
| @@ -189,7 +190,7 @@ const persistMcpCatalogSnapshotFromManifestWithDeps = ( · 121 unmodified lines | |||
| 189 | deps: RuntimeSourceCatalogSyncDeps, | 190 | deps: RuntimeSourceCatalogSyncDeps, |
| 190 | input: { | 191 | input: { |
| 191 | source: Source; | 192 | source: Source; |
| 192 | manifestEntries: Parameters<typeof catalogSyncResultFromMcpManifestEntries>[0]["manifestEntries"]; | 193 | manifest: McpToolManifest; |
| 193 | }, | 194 | }, |
| 194 | ): Effect.Effect<void, Error, never> => | 195 | ): Effect.Effect<void, Error, never> => |
| 195 | Effect.gen(function* () { | 196 | Effect.gen(function* () { |
| @@ -197,10 +198,10 @@ const persistMcpCatalogSnapshotFromManifestWithDeps = ( · 1 unmodified line | |||
| 197 | deps, | 198 | deps, |
| 198 | input.source.workspaceId, | 199 | input.source.workspaceId, |
| 199 | ); | 200 | ); |
| 200 | const syncResult = catalogSyncResultFromMcpManifestEntries({ | 201 | const syncResult = catalogSyncResultFromMcpManifest({ |
| 201 | source: input.source, | 202 | source: input.source, |
| 202 | endpoint: input.source.endpoint, | 203 | endpoint: input.source.endpoint, |
| 203 | manifestEntries: input.manifestEntries, | 204 | manifest: input.manifest, |
| 204 | }); | 205 | }); |
| 205 | 206 | ||
| 206 | yield* deps.sourceArtifactStore.write({ | 207 | yield* deps.sourceArtifactStore.write({ |
| @@ -249,7 +250,7 @@ export const syncSourceCatalog = (input: { · 42 unmodified lines | |||
| 249 | 250 | ||
| 250 | export const persistMcpCatalogSnapshotFromManifest = (input: { | 251 | export const persistMcpCatalogSnapshotFromManifest = (input: { |
| 251 | source: Source; | 252 | source: Source; |
| 252 | manifestEntries: Parameters<typeof catalogSyncResultFromMcpManifestEntries>[0]["manifestEntries"]; | 253 | manifest: McpToolManifest; |
| 253 | }): Effect.Effect<void, Error, SourceCatalogSyncServices> => | 254 | }): Effect.Effect<void, Error, SourceCatalogSyncServices> => |
| 254 | Effect.gen(function* () { | 255 | Effect.gen(function* () { |
| 255 | const runtimeLocalWorkspace = yield* RuntimeLocalWorkspaceService; | 256 | const runtimeLocalWorkspace = yield* RuntimeLocalWorkspaceService; |