History

trim sizes

Rhys Sullivan committed 8 months ago

Commit 71e33a91 parent a43695c30 files · +5421 −336Browse files

Unified · Split

30 files
  1. apps/docs/developer/adapters-and-formats.mdx +2 −0
  2. apps/docs/developer/import-fidelity.mdx +132 −0
  3. packages/codemode-google-discovery/src/google-discovery-tools.ts +16 −0
  4. packages/codemode-google-discovery/src/google-discovery-types.ts +6 −0
  5. packages/codemode-mcp/src/index.ts +8 −0
  6. packages/codemode-mcp/src/mcp-manifest.ts +272 −3
  7. packages/codemode-mcp/src/mcp-tools.test.ts +148 −1
  8. packages/codemode-mcp/src/mcp-tools.ts +19 −2
  9. packages/codemode-openapi/openapi-extractor-rs/src/lib.rs +6 −8
  10. packages/codemode-openapi/src/index.ts +26 −0
  11. packages/codemode-openapi/src/openapi-definitions.ts +19 −0
  12. packages/codemode-openapi/src/openapi-extraction.ts +701 −22
  13. packages/codemode-openapi/src/openapi-extractor-wasm/openapi_extractor_bg.wasm +0 −0
  14. packages/codemode-openapi/src/openapi-http-serialization.ts +515 −0
  15. packages/codemode-openapi/src/openapi-tool-presentation.test.ts +244 −0
  16. packages/codemode-openapi/src/openapi-tool-presentation.ts +128 −18
  17. packages/codemode-openapi/src/openapi-tools.test.ts +532 −0
  18. packages/codemode-openapi/src/openapi-tools.ts +32 −36
  19. packages/codemode-openapi/src/openapi-types.ts +136 −0
  20. packages/control-plane/src/ir/catalog.ts +6 −2
  21. packages/control-plane/src/runtime/ir-execution.ts +126 −40
  22. packages/control-plane/src/runtime/local-source-artifacts.test.ts +151 −0
  23. packages/control-plane/src/runtime/local-source-artifacts.ts +200 −17
  24. packages/control-plane/src/runtime/source-adapter-fixture-matrix.test.ts +296 −0
  25. packages/control-plane/src/runtime/source-adapters/mcp.test.ts +227 −0
  26. packages/control-plane/src/runtime/source-adapters/mcp.ts +36 −20
  27. packages/control-plane/src/runtime/source-auth-service.ts +1 −1
  28. packages/control-plane/src/runtime/source-catalog-snapshot.test.ts +608 −0
  29. packages/control-plane/src/runtime/source-catalog-snapshot.ts +822 −161
  30. packages/control-plane/src/runtime/source-catalog-sync.ts +7 −6
apps/docs/developer/adapters-and-formats.mdx+2−0
@@ -83,6 +83,8 @@ That is intentional, but the direction is: · 82 unmodified lines
83 83
84The goal is not to erase differences between formats. The goal is to stop those differences from dominating the core architecture.84The goal is not to erase differences between formats. The goal is to stop those differences from dominating the core architecture.
85 85
86For the current fidelity tradeoffs and known lossy areas across adapters, see [Import Fidelity](/developer/import-fidelity).
87
86## What a good new adapter should do88## What a good new adapter should do
87 89
88A good new adapter should:90A good new adapter should:
apps/docs/developer/import-fidelity.mdx+132−0
@@ -0,0 +1,132 @@
1---
2title: "Import Fidelity"
3description: "What executor preserves, normalizes, and currently loses when importing external formats."
4---
5
6Executor should normalize syntax, not meaning.
7
8That 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
10The 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
18This 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
25Current 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
48GraphQL 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
60Google 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
72MCP 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
76Even when data is preserved natively, the current inspection UI often does not show it directly.
77
78Today inspection primarily exposes:
79
80- projected call and result schemas
81- capability summary text
82- the first executable native blob
83
84That means "preserved in native data" and "visible to a user" are still different states.
85
86## What should be first-class in IR
87
88These 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
97By 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
124The 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
130If executor keeps that line clear, a standardized representation is a strength.
131
132If executor lets semantically important details live only in hidden native blobs, the standardized representation becomes misleading.
packages/codemode-google-discovery/src/google-discovery-tools.ts+16−0
@@ -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,
packages/codemode-google-discovery/src/google-discovery-types.ts+6−0
@@ -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});
packages/codemode-mcp/src/index.ts+8−0
@@ -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";
20export type {
21 McpListToolsMetadata,
22 McpServerCapabilities,
23 McpServerInfo,
24 McpServerMetadata,
25 McpToolAnnotations,
26 McpToolExecution,
27} from "./mcp-manifest";
packages/codemode-mcp/src/mcp-manifest.ts+272−3
@@ -3,16 +3,81 @@ import * as Schema from "effect/Schema"; · 2 unmodified lines
3 3
4import type { ToolPath } from "@executor/codemode-core";4import type { ToolPath } from "@executor/codemode-core";
5 5
6export type McpToolAnnotations = {
7 title: string | null;
8 readOnlyHint: boolean | null;
9 destructiveHint: boolean | null;
10 idempotentHint: boolean | null;
11 openWorldHint: boolean | null;
12};
13
14export type McpToolExecution = {
15 taskSupport: "forbidden" | "optional" | "required" | null;
16};
17
18export type McpServerInfo = {
19 name: string;
20 version: string;
21 title: string | null;
22 description: string | null;
23 websiteUrl: string | null;
24 icons: unknown[] | null;
25};
26
27export type McpServerCapabilities = {
28 experimental: Record<string, unknown> | null;
29 logging: boolean;
30 completions: boolean;
31 prompts: {
32 listChanged: boolean;
33 } | null;
34 resources: {
35 subscribe: boolean;
36 listChanged: boolean;
37 } | null;
38 tools: {
39 listChanged: boolean;
40 } | null;
41 tasks: {
42 list: boolean;
43 cancel: boolean;
44 toolCall: boolean;
45 } | null;
46};
47
48export type McpServerMetadata = {
49 info: McpServerInfo | null;
50 capabilities: McpServerCapabilities | null;
51 instructions: string | null;
52 rawInfo: unknown;
53 rawCapabilities: unknown;
54};
55
56export type McpListToolsMetadata = {
57 nextCursor: string | null;
58 meta: unknown;
59 rawResult: unknown;
60};
61
6export type McpToolManifestEntry = {62export 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
14export type McpToolManifest = {77export 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
102const asObject = (value: unknown): Record<string, unknown> =>
103 value !== null && typeof value === "object" && !Array.isArray(value)
104 ? (value as Record<string, unknown>)
105 : {};
106
107const asOptionalObject = (value: unknown): Record<string, unknown> | null => {
108 const objectValue = asObject(value);
109 return Object.keys(objectValue).length > 0 ? objectValue : null;
110};
111
112const hasOwn = (value: Record<string, unknown>, key: string): boolean =>
113 Object.prototype.hasOwnProperty.call(value, key);
114
115const asString = (value: unknown): string | null =>
116 typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
117
118const asBoolean = (value: unknown): boolean | null =>
119 typeof value === "boolean" ? value : null;
120
121const asUnknownArray = (value: unknown): unknown[] | null =>
122 Array.isArray(value) ? value : null;
123
124const ToolAnnotationsSchema = Schema.Struct({
125 title: Schema.optional(Schema.NullOr(Schema.String)),
126 readOnlyHint: Schema.optional(Schema.Boolean),
127 destructiveHint: Schema.optional(Schema.Boolean),
128 idempotentHint: Schema.optional(Schema.Boolean),
129 openWorldHint: Schema.optional(Schema.Boolean),
130});
131
132const ToolExecutionSchema = Schema.Struct({
133 taskSupport: Schema.optional(
134 Schema.Literal("forbidden", "optional", "required"),
135 ),
136});
137
37const ListedMcpToolSchema = Schema.Struct({138const 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
45const ListToolsResultSchema = Schema.Struct({151const 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
49const decodeListToolsResultOption = Schema.decodeUnknownOption(ListToolsResultSchema);157const 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
168const normalizeToolAnnotations = (value: unknown): McpToolAnnotations | null => {
169 const objectValue = asOptionalObject(value);
170 if (objectValue === null) {
171 return null;
172 }
173
174 const annotations = {
175 title: asString(objectValue.title),
176 readOnlyHint: asBoolean(objectValue.readOnlyHint),
177 destructiveHint: asBoolean(objectValue.destructiveHint),
178 idempotentHint: asBoolean(objectValue.idempotentHint),
179 openWorldHint: asBoolean(objectValue.openWorldHint),
180 } satisfies McpToolAnnotations;
181
182 return Object.values(annotations).some((entry) => entry !== null)
183 ? annotations
184 : null;
185};
186
187const normalizeToolExecution = (value: unknown): McpToolExecution | null => {
188 const objectValue = asOptionalObject(value);
189 if (objectValue === null) {
190 return null;
191 }
192
193 const taskSupport = objectValue.taskSupport;
194 if (
195 taskSupport !== "forbidden"
196 && taskSupport !== "optional"
197 && taskSupport !== "required"
198 ) {
199 return null;
200 }
201
202 return {
203 taskSupport,
204 };
205};
206
207const normalizeServerInfo = (value: unknown): McpServerInfo | null => {
208 const objectValue = asOptionalObject(value);
209 const name = objectValue ? asString(objectValue.name) : null;
210 const version = objectValue ? asString(objectValue.version) : null;
211 if (name === null || version === null) {
212 return null;
213 }
214
215 return {
216 name,
217 version,
218 title: asString(objectValue?.title),
219 description: asString(objectValue?.description),
220 websiteUrl: asString(objectValue?.websiteUrl),
221 icons: asUnknownArray(objectValue?.icons),
222 };
223};
224
225const normalizeServerCapabilities = (value: unknown): McpServerCapabilities | null => {
226 const objectValue = asOptionalObject(value);
227 if (objectValue === null) {
228 return null;
229 }
230
231 const prompts = asObject(objectValue.prompts);
232 const resources = asObject(objectValue.resources);
233 const tools = asObject(objectValue.tools);
234 const tasks = asObject(objectValue.tasks);
235 const taskRequests = asObject(tasks.requests);
236 const taskToolRequests = asObject(taskRequests.tools);
237
238 return {
239 experimental:
240 hasOwn(objectValue, "experimental")
241 ? asObject(objectValue.experimental)
242 : null,
243 logging: hasOwn(objectValue, "logging"),
244 completions: hasOwn(objectValue, "completions"),
245 prompts:
246 !hasOwn(objectValue, "prompts")
247 ? null
248 : {
249 listChanged: asBoolean(prompts.listChanged) ?? false,
250 },
251 resources:
252 !hasOwn(objectValue, "resources")
253 ? null
254 : {
255 subscribe: asBoolean(resources.subscribe) ?? false,
256 listChanged: asBoolean(resources.listChanged) ?? false,
257 },
258 tools:
259 !hasOwn(objectValue, "tools")
260 ? null
261 : {
262 listChanged: asBoolean(tools.listChanged) ?? false,
263 },
264 tasks:
265 !hasOwn(objectValue, "tasks")
266 ? null
267 : {
268 list: hasOwn(tasks, "list"),
269 cancel: hasOwn(tasks, "cancel"),
270 toolCall: hasOwn(taskToolRequests, "call"),
271 },
272 };
273};
274
275const normalizeServerMetadata = (input?: {
276 serverInfo?: unknown;
277 serverCapabilities?: unknown;
278 instructions?: string | null | undefined;
279}): McpServerMetadata | null => {
280 const info = normalizeServerInfo(input?.serverInfo);
281 const capabilities = normalizeServerCapabilities(input?.serverCapabilities);
282 const instructions = asString(input?.instructions);
283 const rawInfo = input?.serverInfo ?? null;
284 const rawCapabilities = input?.serverCapabilities ?? null;
285
286 if (
287 info === null
288 && capabilities === null
289 && instructions === null
290 && rawInfo === null
291 && rawCapabilities === null
292 ) {
293 return null;
294 }
295
296 return {
297 info,
298 capabilities,
299 instructions,
300 rawInfo,
301 rawCapabilities,
302 };
303};
304
60export const extractMcpToolManifestFromListToolsResult = (305export 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};
packages/codemode-mcp/src/mcp-tools.test.ts+148−1
@@ -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"]!);
packages/codemode-mcp/src/mcp-tools.ts+19−2
@@ -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
48export type McpConnection = {51export 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.mcpDiscoveryElicitation544 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,
packages/codemode-openapi/openapi-extractor-rs/src/lib.rs+6−8
@@ -47,9 +47,9 @@ struct OpenApiInvocationPayload { · 46 unmodified lines
47#[serde(rename_all = "camelCase")]47#[serde(rename_all = "camelCase")]
48struct DiscoveryTypingPayload {48struct 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_schema693 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}
packages/codemode-openapi/src/index.ts+26−0
@@ -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";
20export {
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";
20export {30export {
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";
62export type {88export type {
63 OpenApiExample,89 OpenApiExample,
packages/codemode-openapi/src/openapi-definitions.ts+19−0
@@ -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
148export type OpenApiToolDefinition = typeof OpenApiToolDefinitionSchema.Type;157export 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);
packages/codemode-openapi/src/openapi-extraction.ts+701−22
@@ -9,6 +9,9 @@ import { extractOpenApiManifestJsonWithWasm } from "./openapi-extractor-wasm"; · 8 unmodified lines
9import {9import {
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
73const asArray = (value: unknown): ReadonlyArray<unknown> =>
74 Array.isArray(value) ? value : [];
75
76const asTrimmedString = (value: unknown): string | undefined =>
77 typeof value === "string" && value.trim().length > 0
78 ? value.trim()
79 : undefined;
80
81const 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
97const stableJsonStringify = (value: unknown): string =>
98 JSON.stringify(stableJsonValue(value));
99
100const 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
70const resolvePointerSegment = (segment: string): string =>125const 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
165const 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
177const 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
110const contentSchemaFromOperationContent = (190const 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"));196const 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
121const requestBodySchemaForTool = (224const 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
266const examplesFromSchema = (
267 schema: unknown,
268): Array<{
269 valueJson: string;
270 mediaType?: string;
271 label?: string;
272}> => examplesFromValue(schema);
273
274const 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
293const 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
326const 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
336const 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
366const 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
372const parameterKey = (input: {
373 location: string;
374 name: string;
375}): string => `${input.location}:${input.name}`;
376
377const 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
410const 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
418const 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
431const 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
136const responseSchemaForTool = (458const 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
484const 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
526const documentServersFromDocument = (
527 document: OpenApiJsonObject,
528): OpenApiExtractedTool["documentServers"] => {
529 const servers = serversFromValue(document.servers);
530 return servers.length > 0 ? servers : undefined;
531};
532
533const 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
551const 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
605const 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
621const 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
643const 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
680const 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
749const 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
164const enrichManifestFromDocument = (782const 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
188export const extractOpenApiManifest = (867export const extractOpenApiManifest = (
189 sourceName: string,868 sourceName: string,
packages/codemode-openapi/src/openapi-extractor-wasm/openapi_extractor_bg.wasm+0−0

Binary file not shown.

packages/codemode-openapi/src/openapi-http-serialization.ts+515−0
@@ -0,0 +1,515 @@
1type OpenApiSerializableLocation = "path" | "query" | "header" | "cookie";
2
3export type OpenApiSerializableContent = {
4 mediaType: string;
5};
6
7export type OpenApiSerializableParameter = {
8 name: string;
9 location: OpenApiSerializableLocation;
10 style?: string;
11 explode?: boolean;
12 allowReserved?: boolean;
13 content?: ReadonlyArray<OpenApiSerializableContent>;
14};
15
16export type OpenApiSerializableRequestBody = {
17 contentTypes?: ReadonlyArray<string>;
18 contents?: ReadonlyArray<OpenApiSerializableContent>;
19};
20
21export type SerializedOpenApiQueryEntry = {
22 name: string;
23 value: string;
24 allowReserved?: boolean;
25};
26
27export 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
44const isRecord = (value: unknown): value is Record<string, unknown> =>
45 value !== null && typeof value === "object" && !Array.isArray(value);
46
47const 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
71const objectEntries = (value: unknown): Array<[string, unknown]> =>
72 Object.entries(isRecord(value) ? value : {})
73 .sort(([left], [right]) => left.localeCompare(right));
74
75const 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
86const defaultExplode = (location: OpenApiSerializableLocation, style: string): boolean =>
87 location === "query" || location === "cookie"
88 ? style === "form"
89 : false;
90
91const selectedStyle = (parameter: OpenApiSerializableParameter): string =>
92 parameter.style ?? defaultStyle(parameter.location);
93
94const selectedExplode = (parameter: OpenApiSerializableParameter): boolean =>
95 parameter.explode ?? defaultExplode(parameter.location, selectedStyle(parameter));
96
97const encodePathPart = (value: unknown): string =>
98 encodeURIComponent(primitiveString(value));
99
100const 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
127const 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
145const 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
173const 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
274const 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
311const 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
369const 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
462export 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
478export 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
502export 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};
packages/codemode-openapi/src/openapi-tool-presentation.test.ts+244−0
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs"; · 2 unmodified lines
3import { describe, expect, it } from "@effect/vitest";3import { describe, expect, it } from "@effect/vitest";
4import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
5 5
6import type { OpenApiToolDefinition } from "./openapi-definitions";
6import { compileOpenApiToolDefinitions } from "./openapi-definitions";7import { compileOpenApiToolDefinitions } from "./openapi-definitions";
7import { extractOpenApiManifest } from "./openapi-extraction";8import { extractOpenApiManifest } from "./openapi-extraction";
8import { buildOpenApiToolPresentation } from "./openapi-tool-presentation";9import { buildOpenApiToolPresentation } from "./openapi-tool-presentation";
@@ -14,6 +15,188 @@ const readFixture = (name: string): string => · 5 unmodified lines
14 );15 );
15 16
16describe("buildOpenApiToolPresentation", () => {17describe("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});
packages/codemode-openapi/src/openapi-tool-presentation.ts+128−18
@@ -3,8 +3,10 @@ import { typeSignatureFromSchema } from "@executor/codemode-core"; · 2 unmodified lines
3 3
4import type {4import 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
56const 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
65const 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
85const 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
101const 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
54const callInputSchemaFromInvocation = (input: {120const 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.inputSchema216 schemaProperty(resolvedTyping.inputSchema, "body")
217 ?? schemaProperty(resolvedTyping.inputSchema, "input")
134 ?? (resolvedRequestBodySchema !== undefined && resolvedRequestBodySchema !== null218 ?? (resolvedRequestBodySchema !== undefined && resolvedRequestBodySchema !== null
135 ? resolvedRequestBodySchema219 ? resolvedRequestBodySchema
136 : undefined);220 : undefined);
137 221
138 const inputSchema =222 const inputSchema = callInputSchemaFromInvocation({
139 input.definition.invocation.requestBody !== null223 invocation: input.definition.invocation,
140 ? callInputSchemaFromInvocation({224 documentation: input.definition.documentation,
141 invocation: input.definition.invocation,225 parameterSourceSchema: resolvedTyping.inputSchema,
142 ...(requestBodySchema !== undefined226 ...(requestBodySchema !== undefined
143 ? { requestBodySchema }227 ? { requestBodySchema }
144 : {}),228 : {}),
145 })229 })
146 : resolvedTyping.inputSchema230 ?? resolvedTyping.inputSchema
147 ?? callInputSchemaFromInvocation({231 ?? undefined;
148 invocation: input.definition.invocation,
149 });
150 const outputSchema =232 const outputSchema =
151 resolvedTyping.outputSchema233 resolvedTyping.outputSchema
152 ?? resolveSchemaWithRefHints(234 ?? resolveSchemaWithRefHints(
@@ -160,6 +242,27 @@ const resolvePresentationSchemas = (input: { · 7 unmodified lines
160 };242 };
161};243};
162 244
245const 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
163const buildExampleInput = (266const 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};
packages/codemode-openapi/src/openapi-tools.test.ts+532−0
@@ -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);
packages/codemode-openapi/src/openapi-tools.ts+32−36
@@ -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";
25import {
26 serializeOpenApiParameterValue,
27 serializeOpenApiRequestBody,
28 withSerializedQueryEntries,
29 type SerializedOpenApiQueryEntry,
30} from "./openapi-http-serialization";
25import { buildOpenApiToolPresentation } from "./openapi-tool-presentation";31import { buildOpenApiToolPresentation } from "./openapi-tool-presentation";
26import { resolveSchemaWithRefHints } from "./openapi-schema-refs";32import { resolveSchemaWithRefHints } from "./openapi-schema-refs";
27import {33import {
@@ -67,22 +73,6 @@ const parameterContainerKeys: Record< · 39 unmodified lines
67 cookie: ["cookies", "cookie"],73 cookie: ["cookies", "cookie"],
68};74};
69 75
70const 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
86const readParameterValue = (76const 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,
packages/codemode-openapi/src/openapi-types.ts+136−0
@@ -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
52export 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
58export 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
48export const OpenApiToolRequestBodySchema = Schema.Struct({70export 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
76export 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
53export const OpenApiInvocationPayloadSchema = Schema.Struct({87export 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
139export 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
105export const OpenApiToolDocumentationSchema = Schema.Struct({149export 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
157export 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
175export 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
196export const OpenApiSecuritySchemeTypeSchema = Schema.Literal(
197 "apiKey",
198 "http",
199 "oauth2",
200 "openIdConnect",
201);
202
203export 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
215export 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
113export const OpenApiToolProviderDataSchema = Schema.Struct({232export 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
129export const OpenApiExtractedToolSchema = Schema.Struct({253export 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
143export const OpenApiToolManifestSchema = Schema.Struct({272export const OpenApiToolManifestSchema = Schema.Struct({
@@ -155,7 +284,10 @@ export const OpenApiToolManifestSchema = Schema.Struct({ · 11 unmodified lines
155export type OpenApiHttpMethod = typeof OpenApiHttpMethodSchema.Type;284export type OpenApiHttpMethod = typeof OpenApiHttpMethodSchema.Type;
156export type OpenApiParameterLocation = typeof OpenApiParameterLocationSchema.Type;285export type OpenApiParameterLocation = typeof OpenApiParameterLocationSchema.Type;
157export type OpenApiToolParameter = typeof OpenApiToolParameterSchema.Type;286export type OpenApiToolParameter = typeof OpenApiToolParameterSchema.Type;
287export type OpenApiMediaContent = typeof OpenApiMediaContentSchema.Type;
288export type OpenApiHeader = typeof OpenApiHeaderSchema.Type;
158export type OpenApiToolRequestBody = typeof OpenApiToolRequestBodySchema.Type;289export type OpenApiToolRequestBody = typeof OpenApiToolRequestBodySchema.Type;
290export type OpenApiServer = typeof OpenApiServerSchema.Type;
159export type OpenApiInvocationPayload = typeof OpenApiInvocationPayloadSchema.Type;291export type OpenApiInvocationPayload = typeof OpenApiInvocationPayloadSchema.Type;
160export type DiscoveryTypingPayload = typeof DiscoveryTypingPayloadSchema.Type;292export type DiscoveryTypingPayload = typeof DiscoveryTypingPayloadSchema.Type;
161export type OpenApiRefHintValue = typeof OpenApiRefHintValueSchema.Type;293export type OpenApiRefHintValue = typeof OpenApiRefHintValueSchema.Type;
@@ -164,7 +296,11 @@ export type OpenApiExample = typeof OpenApiExampleSchema.Type; · 2 unmodified lines
164export type OpenApiParameterDocumentation = typeof OpenApiParameterDocumentationSchema.Type;296export type OpenApiParameterDocumentation = typeof OpenApiParameterDocumentationSchema.Type;
165export type OpenApiRequestBodyDocumentation = typeof OpenApiRequestBodyDocumentationSchema.Type;297export type OpenApiRequestBodyDocumentation = typeof OpenApiRequestBodyDocumentationSchema.Type;
166export type OpenApiResponseDocumentation = typeof OpenApiResponseDocumentationSchema.Type;298export type OpenApiResponseDocumentation = typeof OpenApiResponseDocumentationSchema.Type;
299export type OpenApiResponseVariant = typeof OpenApiResponseVariantSchema.Type;
167export type OpenApiToolDocumentation = typeof OpenApiToolDocumentationSchema.Type;300export type OpenApiToolDocumentation = typeof OpenApiToolDocumentationSchema.Type;
301export type OpenApiOAuthFlow = typeof OpenApiOAuthFlowSchema.Type;
302export type OpenApiSecuritySchemeType = typeof OpenApiSecuritySchemeTypeSchema.Type;
303export type OpenApiSecurityScheme = typeof OpenApiSecuritySchemeSchema.Type;
168export type OpenApiToolProviderData = typeof OpenApiToolProviderDataSchema.Type;304export type OpenApiToolProviderData = typeof OpenApiToolProviderDataSchema.Type;
169export type OpenApiExtractedTool = typeof OpenApiExtractedToolSchema.Type;305export type OpenApiExtractedTool = typeof OpenApiExtractedToolSchema.Type;
170export type OpenApiToolManifest = typeof OpenApiToolManifestSchema.Type;306export type OpenApiToolManifest = typeof OpenApiToolManifestSchema.Type;
packages/control-plane/src/ir/catalog.ts+6−2
@@ -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 );
packages/control-plane/src/runtime/ir-execution.ts+126−40
@@ -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";
6import {
7 serializeOpenApiParameterValue,
8 serializeOpenApiRequestBody,
9 withSerializedQueryEntries,
10} from "@executor/codemode-openapi";
6import {11import {
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";
19import type { CatalogV1, Capability, Executable, GraphQLExecutable, HttpExecutable, McpExecutable, ParameterSymbol } from "../ir/model";24import type {
25 CatalogV1,
26 Capability,
27 Executable,
28 GraphQLExecutable,
29 HttpExecutable,
30 McpExecutable,
31 ParameterSymbol,
32 Scope,
33} from "../ir/model";
20import type { LoadedSourceCatalogToolIndexEntry } from "./source-catalog-runtime";34import type { LoadedSourceCatalogToolIndexEntry } from "./source-catalog-runtime";
21import type { ResolvedSourceAuthMaterial } from "./source-auth-material";35import 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
76const 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
89const parameterById = (catalog: CatalogV1, parameterId: string): ParameterSymbol | undefined => {90const 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
133const resolveHttpBaseUrl = (source: Source, executable: HttpExecutable): URL => {134const 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
156const 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
171const 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
193const 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
150const executeHttp = (input: {214const 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.summary532 input.tool.capability.surface.summary
452 ?? input.tool.capability.surface.title533 ?? 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 }],
packages/control-plane/src/runtime/local-source-artifacts.test.ts+151−0
@@ -0,0 +1,151 @@
1import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
2import { tmpdir } from "node:os";
3import { join } from "node:path";
4import { gzipSync } from "node:zlib";
5import { NodeFileSystem } from "@effect/platform-node";
6import { describe, expect, it } from "@effect/vitest";
7import * as Effect from "effect/Effect";
8
9import {
10 SourceIdSchema,
11 WorkspaceIdSchema,
12 type Source,
13} from "#schema";
14
15import {
16 buildLocalSourceArtifact,
17 readLocalSourceArtifact,
18 removeLocalSourceArtifact,
19 writeLocalSourceArtifact,
20} from "./local-source-artifacts";
21import type { ResolvedLocalWorkspaceContext } from "./local-config";
22import { createOpenApiCatalogSnapshot } from "./source-catalog-snapshot";
23
24const 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
38const 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
61const 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
83describe("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});
packages/control-plane/src/runtime/local-source-artifacts.ts+200−17
@@ -1,4 +1,5 @@
1import { join } from "node:path";1import { join } from "node:path";
2import { gunzipSync } from "node:zlib";
2import { FileSystem } from "@effect/platform";3import { FileSystem } from "@effect/platform";
3 4
4import {5import {
@@ -12,7 +13,11 @@ import { · 7 unmodified lines
12import * as Effect from "effect/Effect";13import * as Effect from "effect/Effect";
13import * as Schema from "effect/Schema";14import * as Schema from "effect/Schema";
14 15
15import { CatalogSnapshotV1Schema } from "../ir/model";16import {
17 CatalogSnapshotV1Schema,
18 type NativeBlob,
19 type SourceDocument,
20} from "../ir/model";
16import type { SourceCatalogSyncResult } from "./source-catalog-support";21import type { SourceCatalogSyncResult } from "./source-catalog-support";
17import type { ResolvedLocalWorkspaceContext } from "./local-config";22import type { ResolvedLocalWorkspaceContext } from "./local-config";
18import {23import {
@@ -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
57const mutableRecord = <K extends string, V>(value: Readonly<Record<K, V>>): Record<K, V> =>
58 value as Record<K, V>;
59
52const localSourceArtifactPath = (input: {60const 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
70const 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
80const localSourceDocumentDirectory = (input: {
81 context: ResolvedLocalWorkspaceContext;
82 sourceId: string;
83}): string =>
84 join(input.context.artifactsDirectory, "sources", input.sourceId, "documents");
85
86const localSourceDocumentPath = (input: {
87 context: ResolvedLocalWorkspaceContext;
88 sourceId: string;
89 documentId: string;
90}): string =>
91 join(localSourceDocumentDirectory(input), `${input.documentId}.txt`);
92
93const 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
137const 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
62const snapshotHash = (snapshot: SourceCatalogSyncResult["snapshot"]): string =>157const 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
123export const writeLocalSourceArtifact = (input: {272export 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
140export const removeLocalSourceArtifact = (input: {318export 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 });
packages/control-plane/src/runtime/source-adapter-fixture-matrix.test.ts+296−0
@@ -1,4 +1,5 @@
1import { readFileSync } from "node:fs";1import { readFileSync } from "node:fs";
2import { createServer } from "node:http";
2 3
3import {4import {
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";
42import { invokeIrTool } from "./ir-execution";
41import {43import {
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 );
packages/control-plane/src/runtime/source-adapters/mcp.test.ts+227−0
@@ -0,0 +1,227 @@
1import { randomUUID } from "node:crypto";
2
3import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
4import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
6import { describe, expect, it } from "@effect/vitest";
7import { SourceIdSchema } from "#schema";
8
9import * as Effect from "effect/Effect";
10import { z } from "zod/v4";
11
12import { createSourceFromPayload } from "../source-definitions";
13import { mcpSourceAdapter } from "./mcp";
14
15type RealMcpServer = {
16 endpoint: string;
17 close: () => Promise<void>;
18};
19
20const 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
140describe("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});
packages/control-plane/src/runtime/source-adapters/mcp.ts+36−20
@@ -4,6 +4,7 @@ import { · 3 unmodified lines
4import {4import {
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
125const mcpCatalogOperationFromManifestEntry = (entry: McpToolManifestEntry): McpCatalogOperationInput => ({126const 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,129const 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
139export const catalogSyncResultFromMcpManifestEntries = (input: {154export 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};
packages/control-plane/src/runtime/source-auth-service.ts+1−1
@@ -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
packages/control-plane/src/runtime/source-catalog-snapshot.test.ts+608−0
@@ -10,6 +10,7 @@ import { · 9 unmodified lines
10import {10import {
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", () => {
packages/control-plane/src/runtime/source-catalog-snapshot.ts+822−161
@@ -5,6 +5,11 @@ import { · 4 unmodified lines
5import {5import {
6 type GoogleDiscoveryToolProviderData,6 type GoogleDiscoveryToolProviderData,
7} from "@executor/codemode-google-discovery";7} from "@executor/codemode-google-discovery";
8import type {
9 McpServerMetadata,
10 McpToolAnnotations,
11 McpToolExecution,
12} from "@executor/codemode-mcp";
8 13
9import type { Source } from "#schema";14import 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
97type JsonSchemaImporter = {110type 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
101const stableStringify = (value: unknown): string => {115const stableStringify = (value: unknown): string => {
@@ -209,6 +223,28 @@ const interactionForEffect = (effect: EffectKind): Capability["interaction"] => · 107 unmodified lines
209 },223 },
210});224});
211 225
226const mcpResumeSupport = (
227 execution: McpCatalogOperationInput["providerData"]["execution"],
228): boolean =>
229 execution?.taskSupport === "optional" || execution?.taskSupport === "required";
230
231const 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
212const importMetadataFor = (input: {248const 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
759const preferredResponseContentTypes = (867const 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
764const createServiceScope = (input: {888const 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
910const 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
924const 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
784const responseSetFromSingleResponse = (input: {955const 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
981const 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
1002const 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
1026const 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
1135const 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
1187const 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
1227const 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
810const createHttpCapabilityFromOpenApi = (input: {1295const 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 !== undefined1370 ...(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 !== undefined1429 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?.description1481 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?.description1487 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 !== undefined1494 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
1621export const createGoogleDiscoveryCatalogSnapshot = (input: {2282export const createGoogleDiscoveryCatalogSnapshot = (input: {
1622 source: Source;2283 source: Source;
packages/control-plane/src/runtime/source-catalog-sync.ts+7−6
@@ -3,6 +3,7 @@ import type { · 2 unmodified lines
3 Source,3 Source,
4 SourceStatus,4 SourceStatus,
5} from "#schema";5} from "#schema";
6import type { McpToolManifest } from "@executor/codemode-mcp";
6import * as Context from "effect/Context";7import * as Context from "effect/Context";
7import * as Effect from "effect/Effect";8import * as Effect from "effect/Effect";
8import * as Layer from "effect/Layer";9import * 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";
29import {30import {
30 catalogSyncResultFromMcpManifestEntries,31 catalogSyncResultFromMcpManifest,
31} from "./source-adapters/mcp";32} from "./source-adapters/mcp";
32import { SecretMaterialResolverService } from "./secret-material-providers";33import { SecretMaterialResolverService } from "./secret-material-providers";
33import {34import {
@@ -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
250export const persistMcpCatalogSnapshotFromManifest = (input: {251export 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;