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
8383
8484 The goal is not to erase differences between formats. The goal is to stop those differences from dominating the core architecture.
8585
86+For the current fidelity tradeoffs and known lossy areas across adapters, see [Import Fidelity](/developer/import-fidelity).
87+
8688 ## What a good new adapter should do
8789
8890 A good new adapter should:
apps/docs/developer/import-fidelity.mdx+132−0
@@ -0,0 +1,132 @@
1+---
2+title: "Import Fidelity"
3+description: "What executor preserves, normalizes, and currently loses when importing external formats."
4+---
5+
6+Executor should normalize syntax, not meaning.
7+
8+That means the import pipeline can simplify surface differences between OpenAPI, GraphQL, Google Discovery, and MCP, but it should not silently erase semantics that change execution, trust, or user understanding.
9+
10+The practical shape is:
11+
12+- keep the raw source document or manifest
13+- keep adapter-native metadata that does not fit the common model cleanly
14+- promote execution-relevant semantics into first-class IR
15+
16+## The fidelity buckets
17+
18+This audit uses four buckets:
19+
20+- `Raw source`: the original imported document or manifest
21+- `Adapter/native`: typed provider data or native blobs attached during import
22+- `IR`: first-class catalog symbols and capability metadata
23+- `UI today`: what current inspection surfaces directly
24+
25+Current inspection is still narrow. It mostly shows projected schemas plus `tool.executable.native[0]`, not all preserved native blobs or capability-native metadata.
26+
27+## OpenAPI
28+
29+| Source detail | Raw source | Adapter/native | IR | UI today | Notes |
30+| --- | --- | --- | --- | --- | --- |
31+| Operation auth requirements and security schemes | Yes | No | No | No | Operation `security` is not promoted; imported capabilities currently use `auth: none`. |
32+| Exact response variants, status codes, and headers | Yes | Partial | No | Partial | Import chooses one preferred response, then projects one synthetic `2XX` response set. |
33+| Parameter serialization (`style`, `explode`, `allowReserved`, content-based params) | Yes | Mostly no | No | No | The IR can model these, but the OpenAPI importer currently only takes parameter `schema`. |
34+| Multiple request and response media types | Yes | Partial | Partial | Partial | Import picks a preferred content entry for typing; response docs keep content types only for the chosen response. |
35+| `servers` defaults | Yes | No | No | No | The IR can represent scope server defaults, but OpenAPI import does not populate them. |
36+| Rich schema semantics beyond the importer subset | Yes | Partial | Partial | Partial | Unsupported JSON Schema shapes fall back to `unknown` plus native preservation. |
37+
38+## GraphQL
39+
40+| Source detail | Raw source | Adapter/native | IR | UI today | Notes |
41+| --- | --- | --- | --- | --- | --- |
42+| Subscription root fields as tools | Yes | Partial | No | No | The manifest records `subscriptionTypeName`, but tool generation only materializes query and mutation fields. |
43+| Full field result shapes for generated field tools | Yes | Partial | Partial | Partial | Field-tool outputs are heuristic projections, not full result types. |
44+| Union and deep nested selections | Yes | Partial | Partial | Partial | Unions degrade to `__typename`; nested selection stops after depth 2 in generated field tools. |
45+| Arbitrary caller-selected field projections | Yes | Partial | No | No | Generated field tools are compiled with fixed selections; the raw `request` tool is the escape hatch. |
46+| Deprecation reasons | Yes | Partial | Partial | No | GraphQL schemas emit `deprecated: true` plus `x-deprecationReason`, but the generic schema importer only keeps `deprecated` as first-class shape metadata. |
47+
48+GraphQL is a special case: the main loss is not the raw source being discarded, but the generated field-tool projection being intentionally narrower than the schema. That tradeoff is reasonable for discoverability, but only if the system continues to preserve and expose the raw request path.
49+
50+## Google Discovery
51+
52+| Source detail | Raw source | Adapter/native | IR | UI today | Notes |
53+| --- | --- | --- | --- | --- | --- |
54+| OAuth scope descriptions | Yes | Partial | No | No | Document-level scope descriptions are extracted, but the IR security scheme rewrites scopes to `{ scope: scope }`. |
55+| Rich media upload metadata | Yes | Partial | Partial | Partial | Upload support is collapsed to booleans such as `supportsMediaUpload` and `supportsMediaDownload`. |
56+| Service-level metadata (`title`, `description`, `batchPath`, `documentationLink`) | Yes | Partial | No | No | The extracted manifest has these fields, but per-operation provider data does not carry them into the catalog. |
57+| Parameter descriptions, repeated-ness, enums, defaults | Yes | Yes | Mostly yes | Mostly yes | This area is comparatively good. |
58+| Request and response schemas | Yes | Yes | Yes | Yes | Core schema structure is preserved reasonably well. |
59+
60+Google Discovery is less lossy than OpenAPI on request and response typing, but it still loses useful service metadata and the human meaning attached to OAuth scopes.
61+
62+## MCP
63+
64+| Source detail | Raw source | Adapter/native | IR | UI today | Notes |
65+| --- | --- | --- | --- | --- | --- |
66+| Non-tool MCP resources such as prompts or resources | No | No | No | No | The adapter only discovers `listTools()`. Anything outside the tool list never enters import. |
67+| Extra tool metadata beyond `name`, `description`, `inputSchema` or `parameters`, and `outputSchema` | Partial | Partial | No | No | The manifest schema keeps only a narrow subset of listed tool fields. |
68+| Tool-level safety or effect hints, if present | Partial | No | No | No | Imported MCP operations are currently hardcoded to `effect: action`. |
69+| Tool input and output schemas | Yes | Yes | Yes | Yes | This is the strongest part of the current MCP import. |
70+| Server-level metadata outside the tool list | No | No | No | No | The stored MCP "document" is a synthesized tool manifest, not a richer upstream server description. |
71+
72+MCP is the most structurally lossy adapter today because the discovery step itself only imports tools and already narrows their metadata aggressively.
73+
74+## Cross-cutting UI caveat
75+
76+Even when data is preserved natively, the current inspection UI often does not show it directly.
77+
78+Today inspection primarily exposes:
79+
80+- projected call and result schemas
81+- capability summary text
82+- the first executable native blob
83+
84+That means "preserved in native data" and "visible to a user" are still different states.
85+
86+## What should be first-class in IR
87+
88+These semantics are worth promoting into the common model because they affect execution or trust:
89+
90+- auth and security requirements described by the source format
91+- response and status variants
92+- HTTP parameter serialization
93+- upload, download, and streaming traits
94+- GraphQL selection and subscription semantics
95+- source-provided safety or idempotency hints
96+
97+By contrast, many provider-specific labels, service metadata fields, and raw source extras are fine to keep in adapter-native metadata as long as they remain inspectable.
98+
99+## Recommended priority order
100+
101+### P0
102+
103+- OpenAPI auth and security requirements
104+- OpenAPI response variants and status distinctions
105+- Google Discovery scope descriptions
106+- MCP discovery breadth: decide whether executor intends to import only tools or broader MCP surface area
107+
108+### P1
109+
110+- OpenAPI parameter serialization
111+- GraphQL subscription modeling
112+- GraphQL field-tool projection metadata so the fixed-selection behavior is explicit
113+- Google Discovery service metadata surfacing
114+- MCP tool metadata preservation beyond the current narrow manifest
115+
116+### P2
117+
118+- Better UI fallback to native/provider metadata
119+- Richer diagnostics for intentionally lossy normalization
120+- A fixture-backed audit matrix test per adapter
121+
122+## The IR boundary
123+
124+The right boundary is:
125+
126+- `raw source` remains the source of truth
127+- `adapter-native metadata` keeps source-specific details close to their format
128+- `IR` stays unified, but it must be lossless for downstream semantics
129+
130+If executor keeps that line clear, a standardized representation is a strength.
131+
132+If executor lets semantically important details live only in hidden native blobs, the standardized representation becomes misleading.
packages/codemode-google-discovery/src/google-discovery-tools.ts+16−0
@@ -37,6 +37,7 @@ export const googleDiscoveryProviderDataFromDefinition = ( · 36 unmodified lines
3737 version: string;
3838 rootUrl: string;
3939 servicePath: string;
40+ oauthScopes?: Readonly<Record<string, string>>;
4041 definition: GoogleDiscoveryManifestMethod;
4142 },
4243 ): typeof GoogleDiscoveryToolProviderDataSchema.Type => ({
@@ -58,6 +59,17 @@ export const googleDiscoveryProviderDataFromDefinition = ( · 15 unmodified lines
5859 requestSchemaId: input.definition.requestSchemaId,
5960 responseSchemaId: input.definition.responseSchemaId,
6061 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+ : {}),
6173 supportsMediaUpload: input.definition.supportsMediaUpload,
6274 supportsMediaDownload: input.definition.supportsMediaDownload,
6375 },
@@ -347,6 +359,7 @@ export type CreateGoogleDiscoveryToolFromDefinitionInput = { · 283 unmodified lines
347359 version: string;
348360 rootUrl: string;
349361 servicePath: string;
362+ oauthScopes?: Readonly<Record<string, string>>;
350363 path: string;
351364 sourceKey: string;
352365 defaultHeaders?: Readonly<Record<string, string>>;
@@ -371,6 +384,7 @@ export const buildGoogleDiscoveryToolPresentation = (input: { · 18 unmodified lines
371384 versionName: string;
372385 rootUrl: string;
373386 servicePath: string;
387+ oauthScopes?: Readonly<Record<string, string>>;
374388 schemaRefTable?: Readonly<GoogleDiscoverySchemaRefTable>;
375389 };
376390 definition: GoogleDiscoveryToolDefinition;
@@ -409,6 +423,7 @@ export const buildGoogleDiscoveryToolPresentation = (input: { · 32 unmodified lines
409423 version: input.manifest.versionName,
410424 rootUrl: input.manifest.rootUrl,
411425 servicePath: input.manifest.servicePath,
426+ oauthScopes: input.manifest.oauthScopes,
412427 definition: input.definition,
413428 }),
414429 };
@@ -423,6 +438,7 @@ export const createGoogleDiscoveryToolFromDefinition = ( · 8 unmodified lines
423438 versionName: input.version,
424439 rootUrl: input.rootUrl,
425440 servicePath: input.servicePath,
441+ oauthScopes: input.oauthScopes,
426442 schemaRefTable: input.schemaRefTable,
427443 },
428444 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
4141 requestSchemaId: Schema.NullOr(Schema.String),
4242 responseSchemaId: Schema.NullOr(Schema.String),
4343 scopes: Schema.Array(Schema.String),
44+ scopeDescriptions: Schema.optional(
45+ Schema.Record({
46+ key: Schema.String,
47+ value: Schema.String,
48+ }),
49+ ),
4450 supportsMediaUpload: Schema.Boolean,
4551 supportsMediaDownload: Schema.Boolean,
4652 });
packages/codemode-mcp/src/index.ts+8−0
@@ -17,3 +17,11 @@ export { · 16 unmodified lines
1717 type McpToolManifest,
1818 type McpToolManifestEntry,
1919 } from "./mcp-tools";
20+export 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
33
44 import type { ToolPath } from "@executor/codemode-core";
55
6+export type McpToolAnnotations = {
7+ title: string | null;
8+ readOnlyHint: boolean | null;
9+ destructiveHint: boolean | null;
10+ idempotentHint: boolean | null;
11+ openWorldHint: boolean | null;
12+};
13+
14+export type McpToolExecution = {
15+ taskSupport: "forbidden" | "optional" | "required" | null;
16+};
17+
18+export type McpServerInfo = {
19+ name: string;
20+ version: string;
21+ title: string | null;
22+ description: string | null;
23+ websiteUrl: string | null;
24+ icons: unknown[] | null;
25+};
26+
27+export type McpServerCapabilities = {
28+ experimental: Record<string, unknown> | null;
29+ logging: boolean;
30+ completions: boolean;
31+ prompts: {
32+ listChanged: boolean;
33+ } | null;
34+ resources: {
35+ subscribe: boolean;
36+ listChanged: boolean;
37+ } | null;
38+ tools: {
39+ listChanged: boolean;
40+ } | null;
41+ tasks: {
42+ list: boolean;
43+ cancel: boolean;
44+ toolCall: boolean;
45+ } | null;
46+};
47+
48+export type McpServerMetadata = {
49+ info: McpServerInfo | null;
50+ capabilities: McpServerCapabilities | null;
51+ instructions: string | null;
52+ rawInfo: unknown;
53+ rawCapabilities: unknown;
54+};
55+
56+export type McpListToolsMetadata = {
57+ nextCursor: string | null;
58+ meta: unknown;
59+ rawResult: unknown;
60+};
61+
662 export type McpToolManifestEntry = {
763 toolId: string;
864 toolName: string;
965 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;
1073 inputSchema?: unknown;
1174 outputSchema?: unknown;
1275 };
1376
1477 export type McpToolManifest = {
15− version: 1;
78+ version: 2;
79+ server?: McpServerMetadata | null;
80+ listTools?: McpListToolsMetadata | null;
1681 tools: readonly McpToolManifestEntry[];
1782 };
1883
@@ -34,16 +99,59 @@ const uniqueToolId = (value: string, byBase: Map<string, number>): string => { · 15 unmodified lines
3499 return count === 1 ? base : `${base}_${count}`;
35100 };
36101
102+const asObject = (value: unknown): Record<string, unknown> =>
103+ value !== null && typeof value === "object" && !Array.isArray(value)
104+ ? (value as Record<string, unknown>)
105+ : {};
106+
107+const asOptionalObject = (value: unknown): Record<string, unknown> | null => {
108+ const objectValue = asObject(value);
109+ return Object.keys(objectValue).length > 0 ? objectValue : null;
110+};
111+
112+const hasOwn = (value: Record<string, unknown>, key: string): boolean =>
113+ Object.prototype.hasOwnProperty.call(value, key);
114+
115+const asString = (value: unknown): string | null =>
116+ typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
117+
118+const asBoolean = (value: unknown): boolean | null =>
119+ typeof value === "boolean" ? value : null;
120+
121+const asUnknownArray = (value: unknown): unknown[] | null =>
122+ Array.isArray(value) ? value : null;
123+
124+const ToolAnnotationsSchema = Schema.Struct({
125+ title: Schema.optional(Schema.NullOr(Schema.String)),
126+ readOnlyHint: Schema.optional(Schema.Boolean),
127+ destructiveHint: Schema.optional(Schema.Boolean),
128+ idempotentHint: Schema.optional(Schema.Boolean),
129+ openWorldHint: Schema.optional(Schema.Boolean),
130+});
131+
132+const ToolExecutionSchema = Schema.Struct({
133+ taskSupport: Schema.optional(
134+ Schema.Literal("forbidden", "optional", "required"),
135+ ),
136+});
137+
37138 const ListedMcpToolSchema = Schema.Struct({
38139 name: Schema.String,
140+ title: Schema.optional(Schema.NullOr(Schema.String)),
39141 description: Schema.optional(Schema.NullOr(Schema.String)),
40142 inputSchema: Schema.optional(Schema.Unknown),
41143 parameters: Schema.optional(Schema.Unknown),
42144 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),
43149 });
44150
45151 const ListToolsResultSchema = Schema.Struct({
46152 tools: Schema.Array(ListedMcpToolSchema),
153+ nextCursor: Schema.optional(Schema.NullOr(Schema.String)),
154+ _meta: Schema.optional(Schema.Unknown),
47155 });
48156
49157 const decodeListToolsResultOption = Schema.decodeUnknownOption(ListToolsResultSchema);
@@ -57,22 +165,177 @@ const readListedTools = (value: unknown): ReadonlyArray<typeof ListedMcpToolSche · 7 unmodified lines
57165 return decoded.value.tools;
58166 };
59167
168+const normalizeToolAnnotations = (value: unknown): McpToolAnnotations | null => {
169+ const objectValue = asOptionalObject(value);
170+ if (objectValue === null) {
171+ return null;
172+ }
173+
174+ const annotations = {
175+ title: asString(objectValue.title),
176+ readOnlyHint: asBoolean(objectValue.readOnlyHint),
177+ destructiveHint: asBoolean(objectValue.destructiveHint),
178+ idempotentHint: asBoolean(objectValue.idempotentHint),
179+ openWorldHint: asBoolean(objectValue.openWorldHint),
180+ } satisfies McpToolAnnotations;
181+
182+ return Object.values(annotations).some((entry) => entry !== null)
183+ ? annotations
184+ : null;
185+};
186+
187+const normalizeToolExecution = (value: unknown): McpToolExecution | null => {
188+ const objectValue = asOptionalObject(value);
189+ if (objectValue === null) {
190+ return null;
191+ }
192+
193+ const taskSupport = objectValue.taskSupport;
194+ if (
195+ taskSupport !== "forbidden"
196+ && taskSupport !== "optional"
197+ && taskSupport !== "required"
198+ ) {
199+ return null;
200+ }
201+
202+ return {
203+ taskSupport,
204+ };
205+};
206+
207+const normalizeServerInfo = (value: unknown): McpServerInfo | null => {
208+ const objectValue = asOptionalObject(value);
209+ const name = objectValue ? asString(objectValue.name) : null;
210+ const version = objectValue ? asString(objectValue.version) : null;
211+ if (name === null || version === null) {
212+ return null;
213+ }
214+
215+ return {
216+ name,
217+ version,
218+ title: asString(objectValue?.title),
219+ description: asString(objectValue?.description),
220+ websiteUrl: asString(objectValue?.websiteUrl),
221+ icons: asUnknownArray(objectValue?.icons),
222+ };
223+};
224+
225+const normalizeServerCapabilities = (value: unknown): McpServerCapabilities | null => {
226+ const objectValue = asOptionalObject(value);
227+ if (objectValue === null) {
228+ return null;
229+ }
230+
231+ const prompts = asObject(objectValue.prompts);
232+ const resources = asObject(objectValue.resources);
233+ const tools = asObject(objectValue.tools);
234+ const tasks = asObject(objectValue.tasks);
235+ const taskRequests = asObject(tasks.requests);
236+ const taskToolRequests = asObject(taskRequests.tools);
237+
238+ return {
239+ experimental:
240+ hasOwn(objectValue, "experimental")
241+ ? asObject(objectValue.experimental)
242+ : null,
243+ logging: hasOwn(objectValue, "logging"),
244+ completions: hasOwn(objectValue, "completions"),
245+ prompts:
246+ !hasOwn(objectValue, "prompts")
247+ ? null
248+ : {
249+ listChanged: asBoolean(prompts.listChanged) ?? false,
250+ },
251+ resources:
252+ !hasOwn(objectValue, "resources")
253+ ? null
254+ : {
255+ subscribe: asBoolean(resources.subscribe) ?? false,
256+ listChanged: asBoolean(resources.listChanged) ?? false,
257+ },
258+ tools:
259+ !hasOwn(objectValue, "tools")
260+ ? null
261+ : {
262+ listChanged: asBoolean(tools.listChanged) ?? false,
263+ },
264+ tasks:
265+ !hasOwn(objectValue, "tasks")
266+ ? null
267+ : {
268+ list: hasOwn(tasks, "list"),
269+ cancel: hasOwn(tasks, "cancel"),
270+ toolCall: hasOwn(taskToolRequests, "call"),
271+ },
272+ };
273+};
274+
275+const normalizeServerMetadata = (input?: {
276+ serverInfo?: unknown;
277+ serverCapabilities?: unknown;
278+ instructions?: string | null | undefined;
279+}): McpServerMetadata | null => {
280+ const info = normalizeServerInfo(input?.serverInfo);
281+ const capabilities = normalizeServerCapabilities(input?.serverCapabilities);
282+ const instructions = asString(input?.instructions);
283+ const rawInfo = input?.serverInfo ?? null;
284+ const rawCapabilities = input?.serverCapabilities ?? null;
285+
286+ if (
287+ info === null
288+ && capabilities === null
289+ && instructions === null
290+ && rawInfo === null
291+ && rawCapabilities === null
292+ ) {
293+ return null;
294+ }
295+
296+ return {
297+ info,
298+ capabilities,
299+ instructions,
300+ rawInfo,
301+ rawCapabilities,
302+ };
303+};
304+
60305 export const extractMcpToolManifestFromListToolsResult = (
61306 listToolsResult: unknown,
307+ metadata?: {
308+ serverInfo?: unknown;
309+ serverCapabilities?: unknown;
310+ instructions?: string | null | undefined;
311+ },
62312 ): McpToolManifest => {
63313 const byBase = new Map<string, number>();
314+ const rawListTools = asObject(listToolsResult);
315+ const rawListedTools = Array.isArray(rawListTools.tools) ? rawListTools.tools : [];
64316
65317 const tools = readListedTools(listToolsResult)
66− .map((tool): McpToolManifestEntry | null => {
318+ .map((tool, index): McpToolManifestEntry | null => {
67319 const toolName = tool.name.trim();
68320 if (toolName.length === 0) {
69321 return null;
70322 }
71323
324+ const title = asString(tool.title);
325+ const annotations = normalizeToolAnnotations(tool.annotations);
326+ const displayTitle = title ?? annotations?.title ?? toolName;
327+
72328 return {
73329 toolId: uniqueToolId(toolName, byBase),
74330 toolName,
331+ title,
332+ displayTitle,
75333 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,
76339 inputSchema: tool.inputSchema ?? tool.parameters,
77340 outputSchema: tool.outputSchema,
78341 };
@@ -80,7 +343,13 @@ export const extractMcpToolManifestFromListToolsResult = ( · 1 unmodified line
80343 .filter((tool): tool is McpToolManifestEntry => tool !== null);
81344
82345 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+ },
84353 tools,
85354 };
86355 };
packages/codemode-mcp/src/mcp-tools.test.ts+148−1
@@ -164,7 +164,7 @@ describe("codemode-mcp", () => { · 163 unmodified lines
164164 ],
165165 });
166166
167− expect(manifest.version).toBe(1);
167+ expect(manifest.version).toBe(2);
168168 expect(manifest.tools.map((tool) => tool.toolId)).toEqual([
169169 "read_file",
170170 "read_file_2",
@@ -174,6 +174,132 @@ describe("codemode-mcp", () => { · 3 unmodified lines
174174 }),
175175 );
176176
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+
177303 it.effect("discovers MCP tools and invokes via connector", () =>
178304 Effect.gen(function* () {
179305 let connectCalls = 0;
@@ -189,6 +315,7 @@ describe("codemode-mcp", () => { · 9 unmodified lines
189315 tools: [
190316 {
191317 name: "Echo",
318+ title: "Echo",
192319 description: "Echo payload",
193320 inputSchema: {
194321 type: "object",
@@ -196,6 +323,14 @@ describe("codemode-mcp", () => { · 1 unmodified line
196323 value: { type: "string" },
197324 },
198325 },
326+ annotations: {
327+ readOnlyHint: true,
328+ destructiveHint: false,
329+ idempotentHint: true,
330+ },
331+ _meta: {
332+ category: "demo",
333+ },
199334 },
200335 ],
201336 }),
@@ -210,6 +345,16 @@ describe("codemode-mcp", () => { · 8 unmodified lines
210345 isError: false,
211346 };
212347 },
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+ }),
213358 },
214359 close: async () => {
215360 closeCalls += 1;
@@ -226,6 +371,8 @@ describe("codemode-mcp", () => { · 10 unmodified lines
226371 expect(connectCalls).toBe(1);
227372 expect(closeCalls).toBe(1);
228373 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);
229376 expect(Object.keys(discovered.tools)).toEqual(["source.mcp.echo"]);
230377
231378 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
4343 name: string;
4444 arguments?: Record<string, unknown>;
4545 }) => Promise<unknown>;
46+ getServerCapabilities?: () => unknown;
47+ getServerVersion?: () => unknown;
48+ getInstructions?: () => string | undefined;
4649 };
4750
4851 export type McpConnection = {
@@ -538,13 +541,27 @@ export const discoverMcpToolsFromConnector = (input: { · 489 unmodified lines
538541 mcpDiscoveryElicitation: input.mcpDiscoveryElicitation,
539542 });
540543
541− return input.mcpDiscoveryElicitation
544+ const settledListEffect = input.mcpDiscoveryElicitation
542545 ? withElicitationClientLock(connection.client, listEffect)
543546 : 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+ }));
544554 },
545555 });
546556
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+ );
548565
549566 return {
550567 manifest,
packages/codemode-openapi/openapi-extractor-rs/src/lib.rs+6−8
@@ -47,9 +47,9 @@ struct OpenApiInvocationPayload { · 46 unmodified lines
4747 #[serde(rename_all = "camelCase")]
4848 struct DiscoveryTypingPayload {
4949 #[serde(skip_serializing_if = "Option::is_none")]
50− input_schema_json: Option<String>,
50+ input_schema: Option<Value>,
5151 #[serde(skip_serializing_if = "Option::is_none")]
52− output_schema_json: Option<String>,
52+ output_schema: Option<Value>,
5353 #[serde(skip_serializing_if = "Option::is_none")]
5454 ref_hint_keys: Option<Vec<String>>,
5555 }
@@ -690,14 +690,12 @@ fn build_tool_typing( · 634 unmodified lines
690690 };
691691
692692 Ok(Some(DiscoveryTypingPayload {
693− input_schema_json: input_schema
693+ input_schema: input_schema
694694 .as_ref()
695− .map(encode_stable_json)
696− .transpose()?,
697− output_schema_json: output_schema
695+ .map(to_stable_value),
696+ output_schema: output_schema
698697 .as_ref()
699− .map(encode_stable_json)
700− .transpose()?,
698+ .map(to_stable_value),
701699 ref_hint_keys,
702700 }))
703701 }
packages/codemode-openapi/src/index.ts+26−0
@@ -17,6 +17,16 @@ export { · 16 unmodified lines
1717 openApiOutputTypeSignatureFromSchema,
1818 type OpenApiToolPresentation,
1919 } from "./openapi-tool-presentation";
20+export {
21+ serializeOpenApiParameterValue,
22+ serializeOpenApiRequestBody,
23+ withSerializedQueryEntries,
24+ type OpenApiSerializableContent,
25+ type OpenApiSerializableParameter,
26+ type OpenApiSerializableRequestBody,
27+ type SerializedOpenApiParameterValue,
28+ type SerializedOpenApiQueryEntry,
29+} from "./openapi-http-serialization";
2030 export {
2131 OpenApiToolInvocationError,
2232 createOpenApiToolFromDefinition,
@@ -35,11 +45,19 @@ export { · 12 unmodified lines
3545 OpenApiToolManifestSchema,
3646 OpenApiToolParameterSchema,
3747 OpenApiToolRequestBodySchema,
48+ OpenApiMediaContentSchema,
49+ OpenApiHeaderSchema,
50+ OpenApiServerSchema,
3851 OpenApiExampleSchema,
52+ OpenApiResponseVariantSchema,
3953 OpenApiParameterDocumentationSchema,
4054 OpenApiRequestBodyDocumentationSchema,
4155 OpenApiResponseDocumentationSchema,
4256 OpenApiToolDocumentationSchema,
57+ OpenApiSecurityRequirementSchema,
58+ OpenApiSecuritySchemeTypeSchema,
59+ OpenApiOAuthFlowSchema,
60+ OpenApiSecuritySchemeSchema,
4361 OpenApiToolProviderDataSchema,
4462 OpenApiRefHintTableSchema,
4563 OpenApiRefHintValueSchema,
@@ -58,6 +76,14 @@ export { · 12 unmodified lines
5876 type OpenApiRefHintValue,
5977 type OpenApiToolParameter,
6078 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,
6187 } from "./openapi-types";
6288 export type {
6389 OpenApiExample,
packages/codemode-openapi/src/openapi-definitions.ts+19−0
@@ -4,6 +4,10 @@ import { · 3 unmodified lines
44 DiscoveryTypingPayloadSchema,
55 OpenApiHttpMethodSchema,
66 OpenApiInvocationPayloadSchema,
7+ OpenApiResponseVariantSchema,
8+ OpenApiSecurityRequirementSchema,
9+ OpenApiSecuritySchemeSchema,
10+ OpenApiServerSchema,
711 OpenApiToolDocumentationSchema,
812 OpenApiToolProviderDataSchema,
913 type OpenApiExtractedTool,
@@ -143,6 +147,11 @@ export const OpenApiToolDefinitionSchema = Schema.Struct({ · 133 unmodified lines
143147 operationHash: Schema.String,
144148 typing: Schema.optional(DiscoveryTypingPayloadSchema),
145149 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)),
146155 });
147156
148157 export type OpenApiToolDefinition = typeof OpenApiToolDefinitionSchema.Type;
@@ -226,6 +235,11 @@ export const compileOpenApiToolDefinitions = ( · 77 unmodified lines
226235 operationHash: tool.operationHash,
227236 typing: tool.typing,
228237 documentation: tool.documentation,
238+ responses: tool.responses,
239+ authRequirement: tool.authRequirement,
240+ securitySchemes: tool.securitySchemes,
241+ documentServers: tool.documentServers,
242+ servers: tool.servers,
229243 } satisfies Omit<OpenApiToolDefinition, "toolId">;
230244 });
231245
@@ -252,4 +266,9 @@ export const openApiProviderDataFromDefinition = ( · 20 unmodified lines
252266 operationHash: definition.operationHash,
253267 invocation: definition.invocation,
254268 documentation: definition.documentation,
269+ responses: definition.responses,
270+ authRequirement: definition.authRequirement,
271+ securitySchemes: definition.securitySchemes,
272+ documentServers: definition.documentServers,
273+ servers: definition.servers,
255274 } 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
99 import {
1010 OpenApiToolManifestSchema,
1111 type OpenApiExtractedTool,
12+ type OpenApiHeader,
13+ type OpenApiSecurityRequirement,
14+ type OpenApiServer,
1215 type OpenApiJsonObject,
1316 type OpenApiSpecInput,
1417 type OpenApiToolManifest,
@@ -67,6 +70,58 @@ const asObject = (value: unknown): Record<string, unknown> => · 52 unmodified lines
6770 ? (value as Record<string, unknown>)
6871 : {};
6972
73+const asArray = (value: unknown): ReadonlyArray<unknown> =>
74+ Array.isArray(value) ? value : [];
75+
76+const asTrimmedString = (value: unknown): string | undefined =>
77+ typeof value === "string" && value.trim().length > 0
78+ ? value.trim()
79+ : undefined;
80+
81+const stableJsonValue = (value: unknown): unknown => {
82+ if (Array.isArray(value)) {
83+ return value.map((entry) => stableJsonValue(entry));
84+ }
85+
86+ if (value !== null && typeof value === "object") {
87+ return Object.fromEntries(
88+ Object.entries(value as Record<string, unknown>)
89+ .sort(([left], [right]) => left.localeCompare(right))
90+ .map(([key, entry]) => [key, stableJsonValue(entry)]),
91+ );
92+ }
93+
94+ return value;
95+};
96+
97+const stableJsonStringify = (value: unknown): string =>
98+ JSON.stringify(stableJsonValue(value));
99+
100+const collectRefKeys = (
101+ value: unknown,
102+ refs: Set<string>,
103+): void => {
104+ if (Array.isArray(value)) {
105+ for (const item of value) {
106+ collectRefKeys(item, refs);
107+ }
108+ return;
109+ }
110+
111+ if (value === null || typeof value !== "object") {
112+ return;
113+ }
114+
115+ const record = value as Record<string, unknown>;
116+ if (typeof record.$ref === "string" && record.$ref.startsWith("#/")) {
117+ refs.add(record.$ref);
118+ }
119+
120+ for (const nested of Object.values(record)) {
121+ collectRefKeys(nested, refs);
122+ }
123+};
124+
70125 const resolvePointerSegment = (segment: string): string =>
71126 segment.replaceAll("~1", "/").replaceAll("~0", "~");
72127
@@ -107,24 +162,264 @@ const resolveLocalRef = ( · 34 unmodified lines
107162 : resolvedObject;
108163 };
109164
165+const responseStatusRank = (statusCode: string): number => {
166+ if (/^2\d\d$/.test(statusCode)) {
167+ return 0;
168+ }
169+
170+ if (statusCode === "default") {
171+ return 1;
172+ }
173+
174+ return 2;
175+};
176+
177+const preferredContentEntry = (
178+ content: unknown,
179+): readonly [string, Record<string, unknown>] | undefined => {
180+ const entries = Object.entries(asObject(content))
181+ .sort(([left], [right]) => left.localeCompare(right))
182+ .map(([mediaType, value]) => [mediaType, asObject(value)] as const);
183+
184+ return entries.find(([mediaType]) => mediaType === "application/json")
185+ ?? entries.find(([mediaType]) => mediaType.toLowerCase().includes("+json"))
186+ ?? entries.find(([mediaType]) => mediaType.toLowerCase().includes("json"))
187+ ?? entries[0];
188+};
189+
110190 const contentSchemaFromOperationContent = (
111191 content: unknown,
112192 ): unknown | undefined => {
113− const entries = Object.entries(asObject(content));
114− const preferredEntry = entries.find(([mediaType]) => mediaType === "application/json")
115− ?? entries.find(([mediaType]) => mediaType.toLowerCase().includes("+json"))
116− ?? entries.find(([mediaType]) => mediaType.toLowerCase().includes("json"));
193+ return preferredContentEntry(content)?.[1].schema;
194+};
195+
196+const contentEntriesFromContent = (
197+ document: OpenApiJsonObject,
198+ content: unknown,
199+): ReadonlyArray<{
200+ mediaType: string;
201+ schema?: unknown;
202+ examples?: Array<{
203+ valueJson: string;
204+ mediaType?: string;
205+ label?: string;
206+ }>;
207+}> => {
208+ const entries = Object.entries(asObject(content))
209+ .sort(([left], [right]) => left.localeCompare(right))
210+ .map(([mediaType, mediaValue]) => {
211+ const mediaRecord = asObject(resolveLocalRef(document, mediaValue));
212+ const examples = examplesFromMediaType(mediaType, mediaRecord);
213+
214+ return {
215+ mediaType,
216+ ...(mediaRecord.schema !== undefined ? { schema: mediaRecord.schema } : {}),
217+ ...(examples.length > 0 ? { examples } : {}),
218+ };
219+ });
117220
118− return preferredEntry ? asObject(preferredEntry[1]).schema : undefined;
221+ return entries;
119222 };
120223
121−const requestBodySchemaForTool = (
224+const examplesFromValue = (
225+ value: unknown,
226+ input: {
227+ label?: string;
228+ mediaType?: string;
229+ } = {},
230+): Array<{
231+ valueJson: string;
232+ mediaType?: string;
233+ label?: string;
234+}> => {
235+ const record = asObject(value);
236+ const examples: Array<{
237+ valueJson: string;
238+ mediaType?: string;
239+ label?: string;
240+ }> = [];
241+
242+ if (record.example !== undefined) {
243+ examples.push({
244+ valueJson: stableJsonStringify(record.example),
245+ ...(input.mediaType ? { mediaType: input.mediaType } : {}),
246+ ...(input.label ? { label: input.label } : {}),
247+ });
248+ }
249+
250+ const exampleEntries = Object.entries(asObject(record.examples))
251+ .sort(([left], [right]) => left.localeCompare(right));
252+ for (const [key, exampleValue] of exampleEntries) {
253+ const exampleRecord = asObject(exampleValue);
254+ examples.push({
255+ valueJson: stableJsonStringify(
256+ exampleRecord.value !== undefined ? exampleRecord.value : exampleValue,
257+ ),
258+ ...(input.mediaType ? { mediaType: input.mediaType } : {}),
259+ label: key,
260+ });
261+ }
262+
263+ return examples;
264+};
265+
266+const examplesFromSchema = (
267+ schema: unknown,
268+): Array<{
269+ valueJson: string;
270+ mediaType?: string;
271+ label?: string;
272+}> => examplesFromValue(schema);
273+
274+const examplesFromMediaType = (
275+ mediaType: string,
276+ mediaTypeRecord: Record<string, unknown>,
277+): Array<{
278+ valueJson: string;
279+ mediaType?: string;
280+ label?: string;
281+}> => {
282+ const direct = examplesFromValue(mediaTypeRecord, { mediaType });
283+ if (direct.length > 0) {
284+ return direct;
285+ }
286+
287+ return examplesFromSchema(mediaTypeRecord.schema).map((example) => ({
288+ ...example,
289+ mediaType,
290+ }));
291+};
292+
293+const headerFromValue = (
294+ document: OpenApiJsonObject,
295+ name: string,
296+ value: unknown,
297+): OpenApiHeader | undefined => {
298+ const header = asObject(resolveLocalRef(document, value));
299+ if (Object.keys(header).length === 0) {
300+ return undefined;
301+ }
302+
303+ const content = contentEntriesFromContent(document, header.content);
304+ const directExamples =
305+ content.length > 0
306+ ? []
307+ : examplesFromValue(header);
308+
309+ return {
310+ name,
311+ ...(asTrimmedString(header.description)
312+ ? { description: asTrimmedString(header.description) }
313+ : {}),
314+ ...(typeof header.required === "boolean" ? { required: header.required } : {}),
315+ ...(typeof header.deprecated === "boolean" ? { deprecated: header.deprecated } : {}),
316+ ...(header.schema !== undefined ? { schema: header.schema } : {}),
317+ ...(content.length > 0 ? { content } : {}),
318+ ...(asTrimmedString(header.style)
319+ ? { style: asTrimmedString(header.style) }
320+ : {}),
321+ ...(typeof header.explode === "boolean" ? { explode: header.explode } : {}),
322+ ...(directExamples.length > 0 ? { examples: directExamples } : {}),
323+ };
324+};
325+
326+const headersFromValue = (
327+ document: OpenApiJsonObject,
328+ value: unknown,
329+): ReadonlyArray<OpenApiHeader> => Object.entries(asObject(value))
330+ .sort(([left], [right]) => left.localeCompare(right))
331+ .flatMap(([name, headerValue]) => {
332+ const header = headerFromValue(document, name, headerValue);
333+ return header ? [header] : [];
334+ });
335+
336+const serversFromValue = (
337+ value: unknown,
338+): ReadonlyArray<OpenApiServer> =>
339+ asArray(value)
340+ .map((entry) => asObject(entry))
341+ .flatMap((server) => {
342+ const url = asTrimmedString(server.url);
343+ if (!url) {
344+ return [];
345+ }
346+
347+ const variables = Object.fromEntries(
348+ Object.entries(asObject(server.variables))
349+ .sort(([left], [right]) => left.localeCompare(right))
350+ .flatMap(([name, variableValue]) => {
351+ const variableRecord = asObject(variableValue);
352+ const defaultValue = asTrimmedString(variableRecord.default);
353+ return defaultValue ? [[name, defaultValue] as const] : [];
354+ }),
355+ );
356+
357+ return [{
358+ url,
359+ ...(asTrimmedString(server.description)
360+ ? { description: asTrimmedString(server.description) }
361+ : {}),
362+ ...(Object.keys(variables).length > 0 ? { variables } : {}),
363+ }];
364+ });
365+
366+const pathItemForTool = (
122367 document: OpenApiJsonObject,
123368 tool: OpenApiExtractedTool,
124−): unknown | undefined => {
125− const operation = asObject(
369+): Record<string, unknown> =>
370+ asObject(asObject(document.paths)[tool.path]);
371+
372+const parameterKey = (input: {
373+ location: string;
374+ name: string;
375+}): string => `${input.location}:${input.name}`;
376+
377+const parameterRecordMapForTool = (
378+ document: OpenApiJsonObject,
379+ tool: OpenApiExtractedTool,
380+): ReadonlyMap<string, Record<string, unknown>> => {
381+ const merged = new Map<string, Record<string, unknown>>();
382+ const pathItem = pathItemForTool(document, tool);
383+ const operation = operationForTool(document, tool);
384+
385+ for (const parameterValue of asArray(pathItem.parameters)) {
386+ const parameter = asObject(resolveLocalRef(document, parameterValue));
387+ const name = asTrimmedString(parameter.name);
388+ const location = asTrimmedString(parameter.in);
389+ if (!name || !location) {
390+ continue;
391+ }
392+
393+ merged.set(parameterKey({ location, name }), parameter);
394+ }
395+
396+ for (const parameterValue of asArray(operation.parameters)) {
397+ const parameter = asObject(resolveLocalRef(document, parameterValue));
398+ const name = asTrimmedString(parameter.name);
399+ const location = asTrimmedString(parameter.in);
400+ if (!name || !location) {
401+ continue;
402+ }
403+
404+ merged.set(parameterKey({ location, name }), parameter);
405+ }
406+
407+ return merged;
408+};
409+
410+const operationForTool = (
411+ document: OpenApiJsonObject,
412+ tool: OpenApiExtractedTool,
413+): Record<string, unknown> =>
414+ asObject(
126415 asObject(asObject(document.paths)[tool.path])[tool.method],
127416 );
417+
418+const requestBodySchemaForTool = (
419+ document: OpenApiJsonObject,
420+ tool: OpenApiExtractedTool,
421+): unknown | undefined => {
422+ const operation = operationForTool(document, tool);
128423 if (Object.keys(operation).length === 0) {
129424 return undefined;
130425 }
@@ -133,13 +428,38 @@ const requestBodySchemaForTool = ( · 2 unmodified lines
133428 return contentSchemaFromOperationContent(asObject(requestBody).content);
134429 };
135430
431+const requestBodyPayloadForTool = (
432+ document: OpenApiJsonObject,
433+ tool: OpenApiExtractedTool,
434+): OpenApiExtractedTool["invocation"]["requestBody"] => {
435+ const operation = operationForTool(document, tool);
436+ if (Object.keys(operation).length === 0) {
437+ return tool.invocation.requestBody;
438+ }
439+
440+ const requestBody = asObject(resolveLocalRef(document, operation.requestBody));
441+ if (Object.keys(requestBody).length === 0) {
442+ return tool.invocation.requestBody;
443+ }
444+
445+ const contents = contentEntriesFromContent(document, requestBody.content);
446+ const contentTypes = contents.map((content) => content.mediaType);
447+
448+ return {
449+ required:
450+ typeof requestBody.required === "boolean"
451+ ? requestBody.required
452+ : (tool.invocation.requestBody?.required ?? false),
453+ contentTypes,
454+ ...(contents.length > 0 ? { contents } : {}),
455+ };
456+};
457+
136458 const responseSchemaForTool = (
137459 document: OpenApiJsonObject,
138460 tool: OpenApiExtractedTool,
139461 ): unknown | undefined => {
140− const operation = asObject(
141− asObject(asObject(document.paths)[tool.path])[tool.method],
142− );
462+ const operation = operationForTool(document, tool);
143463 if (Object.keys(operation).length === 0) {
144464 return undefined;
145465 }
@@ -161,29 +481,388 @@ const responseSchemaForTool = ( · 15 unmodified lines
161481 return undefined;
162482 };
163483
484+const responseVariantsForTool = (
485+ document: OpenApiJsonObject,
486+ tool: OpenApiExtractedTool,
487+): OpenApiExtractedTool["responses"] => {
488+ const operation = operationForTool(document, tool);
489+ if (Object.keys(operation).length === 0) {
490+ return undefined;
491+ }
492+
493+ const responseEntries = Object.entries(asObject(operation.responses))
494+ .sort(([left], [right]) =>
495+ responseStatusRank(left) - responseStatusRank(right) || left.localeCompare(right),
496+ );
497+
498+ const responses = responseEntries.map(([statusCode, responseValue]) => {
499+ const response = asObject(resolveLocalRef(document, responseValue));
500+ const contents = contentEntriesFromContent(document, response.content);
501+ const contentTypes = contents.map((content) => content.mediaType);
502+ const preferredContent = preferredContentEntry(response.content);
503+ const examples = preferredContent
504+ ? examplesFromMediaType(preferredContent[0], preferredContent[1])
505+ : [];
506+ const headers = headersFromValue(document, response.headers);
507+
508+ return {
509+ statusCode,
510+ ...(asTrimmedString(response.description)
511+ ? { description: asTrimmedString(response.description) }
512+ : {}),
513+ contentTypes,
514+ ...(contentSchemaFromOperationContent(response.content) !== undefined
515+ ? { schema: contentSchemaFromOperationContent(response.content) }
516+ : {}),
517+ ...(examples.length > 0 ? { examples } : {}),
518+ ...(contents.length > 0 ? { contents } : {}),
519+ ...(headers.length > 0 ? { headers } : {}),
520+ };
521+ });
522+
523+ return responses.length > 0 ? responses : undefined;
524+};
525+
526+const documentServersFromDocument = (
527+ document: OpenApiJsonObject,
528+): OpenApiExtractedTool["documentServers"] => {
529+ const servers = serversFromValue(document.servers);
530+ return servers.length > 0 ? servers : undefined;
531+};
532+
533+const operationServersForTool = (
534+ document: OpenApiJsonObject,
535+ tool: OpenApiExtractedTool,
536+): OpenApiExtractedTool["servers"] => {
537+ const operation = operationForTool(document, tool);
538+ if (Object.keys(operation).length === 0) {
539+ return undefined;
540+ }
541+
542+ const operationServers = serversFromValue(operation.servers);
543+ if (operationServers.length > 0) {
544+ return operationServers;
545+ }
546+
547+ const pathItemServers = serversFromValue(pathItemForTool(document, tool).servers);
548+ return pathItemServers.length > 0 ? pathItemServers : undefined;
549+};
550+
551+const securityRequirementFromValue = (
552+ value: unknown,
553+): OpenApiSecurityRequirement | undefined => {
554+ const requirementEntries = asArray(value);
555+ if (requirementEntries.length === 0) {
556+ return {
557+ kind: "none",
558+ };
559+ }
560+
561+ const anyOfItems = requirementEntries.flatMap((entry) => {
562+ const schemes = Object.entries(asObject(entry))
563+ .sort(([left], [right]) => left.localeCompare(right))
564+ .map(([schemeName, rawScopes]) => {
565+ const scopes = asArray(rawScopes)
566+ .flatMap((scope) =>
567+ typeof scope === "string" && scope.trim().length > 0
568+ ? [scope.trim()]
569+ : [],
570+ );
571+
572+ return {
573+ kind: "scheme" as const,
574+ schemeName,
575+ ...(scopes.length > 0 ? { scopes } : {}),
576+ };
577+ });
578+
579+ if (schemes.length === 0) {
580+ return [];
581+ }
582+
583+ return [
584+ schemes.length === 1
585+ ? schemes[0]!
586+ : {
587+ kind: "allOf" as const,
588+ items: schemes,
589+ },
590+ ];
591+ });
592+
593+ if (anyOfItems.length === 0) {
594+ return undefined;
595+ }
596+
597+ return anyOfItems.length === 1
598+ ? anyOfItems[0]
599+ : {
600+ kind: "anyOf",
601+ items: anyOfItems,
602+ };
603+};
604+
605+const authRequirementForTool = (
606+ document: OpenApiJsonObject,
607+ tool: OpenApiExtractedTool,
608+): OpenApiSecurityRequirement | undefined => {
609+ const operation = operationForTool(document, tool);
610+ if (Object.keys(operation).length === 0) {
611+ return undefined;
612+ }
613+
614+ if (Object.prototype.hasOwnProperty.call(operation, "security")) {
615+ return securityRequirementFromValue(operation.security);
616+ }
617+
618+ return securityRequirementFromValue(document.security);
619+};
620+
621+const collectReferencedSecuritySchemeNames = (
622+ authRequirement: OpenApiSecurityRequirement | undefined,
623+ names: Set<string>,
624+): void => {
625+ if (!authRequirement) {
626+ return;
627+ }
628+
629+ switch (authRequirement.kind) {
630+ case "none":
631+ return;
632+ case "scheme":
633+ names.add(authRequirement.schemeName);
634+ return;
635+ case "allOf":
636+ case "anyOf":
637+ for (const item of authRequirement.items) {
638+ collectReferencedSecuritySchemeNames(item, names);
639+ }
640+ }
641+};
642+
643+const oauthFlowRecord = (
644+ value: unknown,
645+): Record<string, {
646+ authorizationUrl?: string;
647+ tokenUrl?: string;
648+ refreshUrl?: string;
649+ scopes?: Record<string, string>;
650+}> | undefined => {
651+ const result = Object.fromEntries(
652+ Object.entries(asObject(value))
653+ .sort(([left], [right]) => left.localeCompare(right))
654+ .map(([flowName, flowValue]) => {
655+ const flowRecord = asObject(flowValue);
656+ const scopes = Object.fromEntries(
657+ Object.entries(asObject(flowRecord.scopes))
658+ .sort(([left], [right]) => left.localeCompare(right))
659+ .map(([scope, description]) => [scope, asTrimmedString(description) ?? ""]),
660+ );
661+
662+ return [flowName, {
663+ ...(asTrimmedString(flowRecord.authorizationUrl)
664+ ? { authorizationUrl: asTrimmedString(flowRecord.authorizationUrl) }
665+ : {}),
666+ ...(asTrimmedString(flowRecord.tokenUrl)
667+ ? { tokenUrl: asTrimmedString(flowRecord.tokenUrl) }
668+ : {}),
669+ ...(asTrimmedString(flowRecord.refreshUrl)
670+ ? { refreshUrl: asTrimmedString(flowRecord.refreshUrl) }
671+ : {}),
672+ ...(Object.keys(scopes).length > 0 ? { scopes } : {}),
673+ }];
674+ }),
675+ );
676+
677+ return Object.keys(result).length > 0 ? result : undefined;
678+};
679+
680+const securitySchemesForTool = (
681+ document: OpenApiJsonObject,
682+ authRequirement: OpenApiSecurityRequirement | undefined,
683+): OpenApiExtractedTool["securitySchemes"] => {
684+ if (!authRequirement || authRequirement.kind === "none") {
685+ return undefined;
686+ }
687+
688+ const schemeNames = new Set<string>();
689+ collectReferencedSecuritySchemeNames(authRequirement, schemeNames);
690+
691+ const securitySchemes = asObject(asObject(document.components).securitySchemes);
692+ const resolved = [...schemeNames]
693+ .sort((left, right) => left.localeCompare(right))
694+ .flatMap((schemeName) => {
695+ const rawScheme = securitySchemes[schemeName];
696+ if (rawScheme === undefined) {
697+ return [];
698+ }
699+
700+ const scheme = asObject(resolveLocalRef(document, rawScheme));
701+ const schemeType = asTrimmedString(scheme.type);
702+ if (!schemeType) {
703+ return [];
704+ }
705+ const normalizedSchemeType: "apiKey" | "http" | "oauth2" | "openIdConnect" =
706+ schemeType === "apiKey"
707+ || schemeType === "http"
708+ || schemeType === "oauth2"
709+ || schemeType === "openIdConnect"
710+ ? schemeType
711+ : "http";
712+
713+ const placementIn = asTrimmedString(scheme.in);
714+ const normalizedPlacementIn: "header" | "query" | "cookie" | undefined =
715+ placementIn === "header" || placementIn === "query" || placementIn === "cookie"
716+ ? placementIn
717+ : undefined;
718+
719+ return [{
720+ schemeName,
721+ schemeType: normalizedSchemeType,
722+ ...(asTrimmedString(scheme.description)
723+ ? { description: asTrimmedString(scheme.description) }
724+ : {}),
725+ ...(normalizedPlacementIn
726+ ? { placementIn: normalizedPlacementIn }
727+ : {}),
728+ ...(asTrimmedString(scheme.name)
729+ ? { placementName: asTrimmedString(scheme.name) }
730+ : {}),
731+ ...(asTrimmedString(scheme.scheme)
732+ ? { scheme: asTrimmedString(scheme.scheme) }
733+ : {}),
734+ ...(asTrimmedString(scheme.bearerFormat)
735+ ? { bearerFormat: asTrimmedString(scheme.bearerFormat) }
736+ : {}),
737+ ...(asTrimmedString(scheme.openIdConnectUrl)
738+ ? { openIdConnectUrl: asTrimmedString(scheme.openIdConnectUrl) }
739+ : {}),
740+ ...(oauthFlowRecord(scheme.flows)
741+ ? { flows: oauthFlowRecord(scheme.flows) }
742+ : {}),
743+ }];
744+ });
745+
746+ return resolved.length > 0 ? resolved : undefined;
747+};
748+
749+const mergeRefHintTable = (
750+ document: OpenApiJsonObject,
751+ manifest: OpenApiToolManifest,
752+ extraRefKeys: ReadonlySet<string>,
753+): OpenApiToolManifest["refHintTable"] => {
754+ const merged: Record<string, string> = {
755+ ...(manifest.refHintTable ?? {}),
756+ };
757+ const queue = [...extraRefKeys].sort((left, right) => left.localeCompare(right));
758+ const seen = new Set<string>(Object.keys(merged));
759+
760+ while (queue.length > 0) {
761+ const refKey = queue.shift()!;
762+ if (seen.has(refKey)) {
763+ continue;
764+ }
765+ seen.add(refKey);
766+
767+ const resolved = resolveLocalRef(document, { $ref: refKey });
768+ merged[refKey] = stableJsonStringify(resolved);
769+
770+ const nestedRefs = new Set<string>();
771+ collectRefKeys(resolved, nestedRefs);
772+ for (const nestedRef of [...nestedRefs].sort((left, right) => left.localeCompare(right))) {
773+ if (!seen.has(nestedRef)) {
774+ queue.push(nestedRef);
775+ }
776+ }
777+ }
778+
779+ return Object.keys(merged).length > 0 ? merged : undefined;
780+};
781+
164782 const enrichManifestFromDocument = (
165783 document: OpenApiJsonObject,
166784 manifest: OpenApiToolManifest,
167−): OpenApiToolManifest => ({
168− ...manifest,
169− tools: manifest.tools.map((tool) => {
785+): OpenApiToolManifest => {
786+ const extraRefKeys = new Set<string>();
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);
170806 const inputSchema = tool.typing?.inputSchema ?? requestBodySchemaForTool(document, tool);
171807 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+ }
172822
173− if (inputSchema === undefined && outputSchema === undefined) {
174− return tool;
823+ for (const response of responses ?? []) {
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+ }
175834 }
176835
177836 return {
178837 ...tool,
179− typing: {
180− ...(tool.typing ?? {}),
181− ...(inputSchema !== undefined ? { inputSchema } : {}),
182− ...(outputSchema !== undefined ? { outputSchema } : {}),
838+ invocation: {
839+ ...tool.invocation,
840+ parameters: invocationParameters,
841+ requestBody,
183842 },
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 } : {}),
184857 };
185− }),
186−});
858+ });
859+
860+ return {
861+ ...manifest,
862+ tools,
863+ refHintTable: mergeRefHintTable(document, manifest, extraRefKeys),
864+ };
865+};
187866
188867 export const extractOpenApiManifest = (
189868 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 @@
1+type OpenApiSerializableLocation = "path" | "query" | "header" | "cookie";
2+
3+export type OpenApiSerializableContent = {
4+ mediaType: string;
5+};
6+
7+export type OpenApiSerializableParameter = {
8+ name: string;
9+ location: OpenApiSerializableLocation;
10+ style?: string;
11+ explode?: boolean;
12+ allowReserved?: boolean;
13+ content?: ReadonlyArray<OpenApiSerializableContent>;
14+};
15+
16+export type OpenApiSerializableRequestBody = {
17+ contentTypes?: ReadonlyArray<string>;
18+ contents?: ReadonlyArray<OpenApiSerializableContent>;
19+};
20+
21+export type SerializedOpenApiQueryEntry = {
22+ name: string;
23+ value: string;
24+ allowReserved?: boolean;
25+};
26+
27+export type SerializedOpenApiParameterValue =
28+ | {
29+ kind: "path" | "header";
30+ value: string;
31+ }
32+ | {
33+ kind: "query";
34+ entries: ReadonlyArray<SerializedOpenApiQueryEntry>;
35+ }
36+ | {
37+ kind: "cookie";
38+ pairs: ReadonlyArray<{
39+ name: string;
40+ value: string;
41+ }>;
42+ };
43+
44+const isRecord = (value: unknown): value is Record<string, unknown> =>
45+ value !== null && typeof value === "object" && !Array.isArray(value);
46+
47+const primitiveString = (value: unknown): string => {
48+ if (typeof value === "string") {
49+ return value;
50+ }
51+
52+ if (
53+ typeof value === "number"
54+ || typeof value === "boolean"
55+ || typeof value === "bigint"
56+ ) {
57+ return String(value);
58+ }
59+
60+ if (value === null || value === undefined) {
61+ return "";
62+ }
63+
64+ try {
65+ return JSON.stringify(value);
66+ } catch {
67+ return String(value);
68+ }
69+};
70+
71+const objectEntries = (value: unknown): Array<[string, unknown]> =>
72+ Object.entries(isRecord(value) ? value : {})
73+ .sort(([left], [right]) => left.localeCompare(right));
74+
75+const defaultStyle = (location: OpenApiSerializableLocation): string => {
76+ switch (location) {
77+ case "path":
78+ case "header":
79+ return "simple";
80+ case "cookie":
81+ case "query":
82+ return "form";
83+ }
84+};
85+
86+const defaultExplode = (location: OpenApiSerializableLocation, style: string): boolean =>
87+ location === "query" || location === "cookie"
88+ ? style === "form"
89+ : false;
90+
91+const selectedStyle = (parameter: OpenApiSerializableParameter): string =>
92+ parameter.style ?? defaultStyle(parameter.location);
93+
94+const selectedExplode = (parameter: OpenApiSerializableParameter): boolean =>
95+ parameter.explode ?? defaultExplode(parameter.location, selectedStyle(parameter));
96+
97+const encodePathPart = (value: unknown): string =>
98+ encodeURIComponent(primitiveString(value));
99+
100+const encodeQueryValue = (value: string, allowReserved: boolean): string => {
101+ const encoded = encodeURIComponent(value);
102+ if (!allowReserved) {
103+ return encoded;
104+ }
105+
106+ return encoded
107+ .replace(/%3A/gi, ":")
108+ .replace(/%2F/gi, "/")
109+ .replace(/%3F/gi, "?")
110+ .replace(/%23/gi, "#")
111+ .replace(/%5B/gi, "[")
112+ .replace(/%5D/gi, "]")
113+ .replace(/%40/gi, "@")
114+ .replace(/%21/gi, "!")
115+ .replace(/%24/gi, "$")
116+ .replace(/%26/gi, "&")
117+ .replace(/%27/gi, "'")
118+ .replace(/%28/gi, "(")
119+ .replace(/%29/gi, ")")
120+ .replace(/%2A/gi, "*")
121+ .replace(/%2B/gi, "+")
122+ .replace(/%2C/gi, ",")
123+ .replace(/%3B/gi, ";")
124+ .replace(/%3D/gi, "=");
125+};
126+
127+const preferredContentType = (input: {
128+ contents?: ReadonlyArray<OpenApiSerializableContent>;
129+ contentTypes?: ReadonlyArray<string>;
130+}): string | undefined => {
131+ const candidates = [
132+ ...(input.contents ?? []).map((content) => content.mediaType),
133+ ...(input.contentTypes ?? []),
134+ ];
135+ if (candidates.length === 0) {
136+ return undefined;
137+ }
138+
139+ return candidates.find((mediaType) => mediaType === "application/json")
140+ ?? candidates.find((mediaType) => mediaType.toLowerCase().includes("+json"))
141+ ?? candidates.find((mediaType) => mediaType.toLowerCase().includes("json"))
142+ ?? candidates[0];
143+};
144+
145+const serializeContentValue = (value: unknown, mediaType: string): string => {
146+ const normalized = mediaType.toLowerCase();
147+ if (
148+ normalized === "application/json"
149+ || normalized.includes("+json")
150+ || normalized.includes("json")
151+ ) {
152+ return JSON.stringify(value);
153+ }
154+
155+ if (normalized === "application/x-www-form-urlencoded") {
156+ const params = new URLSearchParams();
157+ for (const [key, entryValue] of objectEntries(value)) {
158+ if (Array.isArray(entryValue)) {
159+ for (const item of entryValue) {
160+ params.append(key, primitiveString(item));
161+ }
162+ continue;
163+ }
164+
165+ params.append(key, primitiveString(entryValue));
166+ }
167+ return params.toString();
168+ }
169+
170+ return primitiveString(value);
171+};
172+
173+const serializeQueryParameter = (
174+ parameter: OpenApiSerializableParameter,
175+ value: unknown,
176+): SerializedOpenApiParameterValue => {
177+ const style = selectedStyle(parameter);
178+ const explode = selectedExplode(parameter);
179+ const allowReserved = parameter.allowReserved === true;
180+ const contentType = preferredContentType({ contents: parameter.content });
181+
182+ if (contentType) {
183+ return {
184+ kind: "query",
185+ entries: [{
186+ name: parameter.name,
187+ value: serializeContentValue(value, contentType),
188+ allowReserved,
189+ }],
190+ };
191+ }
192+
193+ if (Array.isArray(value)) {
194+ if (style === "spaceDelimited") {
195+ return {
196+ kind: "query",
197+ entries: [{
198+ name: parameter.name,
199+ value: value.map((entry) => primitiveString(entry)).join(" "),
200+ allowReserved,
201+ }],
202+ };
203+ }
204+
205+ if (style === "pipeDelimited") {
206+ return {
207+ kind: "query",
208+ entries: [{
209+ name: parameter.name,
210+ value: value.map((entry) => primitiveString(entry)).join("|"),
211+ allowReserved,
212+ }],
213+ };
214+ }
215+
216+ return {
217+ kind: "query",
218+ entries:
219+ explode
220+ ? value.map((entry) => ({
221+ name: parameter.name,
222+ value: primitiveString(entry),
223+ allowReserved,
224+ }))
225+ : [{
226+ name: parameter.name,
227+ value: value.map((entry) => primitiveString(entry)).join(","),
228+ allowReserved,
229+ }],
230+ };
231+ }
232+
233+ if (isRecord(value)) {
234+ const entries = objectEntries(value);
235+
236+ if (style === "deepObject") {
237+ return {
238+ kind: "query",
239+ entries: entries.map(([key, entryValue]) => ({
240+ name: `${parameter.name}[${key}]`,
241+ value: primitiveString(entryValue),
242+ allowReserved,
243+ })),
244+ };
245+ }
246+
247+ return {
248+ kind: "query",
249+ entries:
250+ explode
251+ ? entries.map(([key, entryValue]) => ({
252+ name: key,
253+ value: primitiveString(entryValue),
254+ allowReserved,
255+ }))
256+ : [{
257+ name: parameter.name,
258+ value: entries.flatMap(([key, entryValue]) => [key, primitiveString(entryValue)]).join(","),
259+ allowReserved,
260+ }],
261+ };
262+ }
263+
264+ return {
265+ kind: "query",
266+ entries: [{
267+ name: parameter.name,
268+ value: primitiveString(value),
269+ allowReserved,
270+ }],
271+ };
272+};
273+
274+const serializeHeaderParameter = (
275+ parameter: OpenApiSerializableParameter,
276+ value: unknown,
277+): SerializedOpenApiParameterValue => {
278+ const explode = selectedExplode(parameter);
279+ const contentType = preferredContentType({ contents: parameter.content });
280+ if (contentType) {
281+ return {
282+ kind: "header",
283+ value: serializeContentValue(value, contentType),
284+ };
285+ }
286+
287+ if (Array.isArray(value)) {
288+ return {
289+ kind: "header",
290+ value: value.map((entry) => primitiveString(entry)).join(","),
291+ };
292+ }
293+
294+ if (isRecord(value)) {
295+ const entries = objectEntries(value);
296+ return {
297+ kind: "header",
298+ value:
299+ explode
300+ ? entries.map(([key, entryValue]) => `${key}=${primitiveString(entryValue)}`).join(",")
301+ : entries.flatMap(([key, entryValue]) => [key, primitiveString(entryValue)]).join(","),
302+ };
303+ }
304+
305+ return {
306+ kind: "header",
307+ value: primitiveString(value),
308+ };
309+};
310+
311+const serializeCookieParameter = (
312+ parameter: OpenApiSerializableParameter,
313+ value: unknown,
314+): SerializedOpenApiParameterValue => {
315+ const explode = selectedExplode(parameter);
316+ const contentType = preferredContentType({ contents: parameter.content });
317+ if (contentType) {
318+ return {
319+ kind: "cookie",
320+ pairs: [{
321+ name: parameter.name,
322+ value: serializeContentValue(value, contentType),
323+ }],
324+ };
325+ }
326+
327+ if (Array.isArray(value)) {
328+ return {
329+ kind: "cookie",
330+ pairs:
331+ explode
332+ ? value.map((entry) => ({
333+ name: parameter.name,
334+ value: primitiveString(entry),
335+ }))
336+ : [{
337+ name: parameter.name,
338+ value: value.map((entry) => primitiveString(entry)).join(","),
339+ }],
340+ };
341+ }
342+
343+ if (isRecord(value)) {
344+ const entries = objectEntries(value);
345+ return {
346+ kind: "cookie",
347+ pairs:
348+ explode
349+ ? entries.map(([key, entryValue]) => ({
350+ name: key,
351+ value: primitiveString(entryValue),
352+ }))
353+ : [{
354+ name: parameter.name,
355+ value: entries.flatMap(([key, entryValue]) => [key, primitiveString(entryValue)]).join(","),
356+ }],
357+ };
358+ }
359+
360+ return {
361+ kind: "cookie",
362+ pairs: [{
363+ name: parameter.name,
364+ value: primitiveString(value),
365+ }],
366+ };
367+};
368+
369+const serializePathParameter = (
370+ parameter: OpenApiSerializableParameter,
371+ value: unknown,
372+): SerializedOpenApiParameterValue => {
373+ const style = selectedStyle(parameter);
374+ const explode = selectedExplode(parameter);
375+ const contentType = preferredContentType({ contents: parameter.content });
376+ if (contentType) {
377+ return {
378+ kind: "path",
379+ value: encodePathPart(serializeContentValue(value, contentType)),
380+ };
381+ }
382+
383+ if (Array.isArray(value)) {
384+ if (style === "label") {
385+ return {
386+ kind: "path",
387+ value: `.${value.map((entry) => encodePathPart(entry)).join(explode ? "." : ",")}`,
388+ };
389+ }
390+
391+ if (style === "matrix") {
392+ return {
393+ kind: "path",
394+ value:
395+ explode
396+ ? value.map((entry) => `;${encodeURIComponent(parameter.name)}=${encodePathPart(entry)}`).join("")
397+ : `;${encodeURIComponent(parameter.name)}=${value.map((entry) => encodePathPart(entry)).join(",")}`,
398+ };
399+ }
400+
401+ return {
402+ kind: "path",
403+ value: value.map((entry) => encodePathPart(entry)).join(","),
404+ };
405+ }
406+
407+ if (isRecord(value)) {
408+ const entries = objectEntries(value);
409+
410+ if (style === "label") {
411+ return {
412+ kind: "path",
413+ value:
414+ explode
415+ ? `.${entries.map(([key, entryValue]) => `${encodePathPart(key)}=${encodePathPart(entryValue)}`).join(".")}`
416+ : `.${entries.flatMap(([key, entryValue]) => [encodePathPart(key), encodePathPart(entryValue)]).join(",")}`,
417+ };
418+ }
419+
420+ if (style === "matrix") {
421+ return {
422+ kind: "path",
423+ value:
424+ explode
425+ ? entries.map(([key, entryValue]) => `;${encodeURIComponent(key)}=${encodePathPart(entryValue)}`).join("")
426+ : `;${encodeURIComponent(parameter.name)}=${entries.flatMap(([key, entryValue]) => [
427+ encodePathPart(key),
428+ encodePathPart(entryValue),
429+ ]).join(",")}`,
430+ };
431+ }
432+
433+ return {
434+ kind: "path",
435+ value:
436+ explode
437+ ? entries.map(([key, entryValue]) => `${encodePathPart(key)}=${encodePathPart(entryValue)}`).join(",")
438+ : entries.flatMap(([key, entryValue]) => [encodePathPart(key), encodePathPart(entryValue)]).join(","),
439+ };
440+ }
441+
442+ if (style === "label") {
443+ return {
444+ kind: "path",
445+ value: `.${encodePathPart(value)}`,
446+ };
447+ }
448+
449+ if (style === "matrix") {
450+ return {
451+ kind: "path",
452+ value: `;${encodeURIComponent(parameter.name)}=${encodePathPart(value)}`,
453+ };
454+ }
455+
456+ return {
457+ kind: "path",
458+ value: encodePathPart(value),
459+ };
460+};
461+
462+export const serializeOpenApiParameterValue = (
463+ parameter: OpenApiSerializableParameter,
464+ value: unknown,
465+): SerializedOpenApiParameterValue => {
466+ switch (parameter.location) {
467+ case "path":
468+ return serializePathParameter(parameter, value);
469+ case "query":
470+ return serializeQueryParameter(parameter, value);
471+ case "header":
472+ return serializeHeaderParameter(parameter, value);
473+ case "cookie":
474+ return serializeCookieParameter(parameter, value);
475+ }
476+};
477+
478+export const withSerializedQueryEntries = (
479+ url: URL,
480+ entries: ReadonlyArray<SerializedOpenApiQueryEntry>,
481+): string => {
482+ if (entries.length === 0) {
483+ return url.toString();
484+ }
485+
486+ const suffix = entries
487+ .map((entry) =>
488+ `${encodeURIComponent(entry.name)}=${encodeQueryValue(entry.value, entry.allowReserved === true)}`
489+ )
490+ .join("&");
491+
492+ const urlText = url.toString();
493+ const [withoutHash, hashFragment] = urlText.split("#", 2);
494+ const separator =
495+ withoutHash.includes("?")
496+ ? (withoutHash.endsWith("?") || withoutHash.endsWith("&") ? "" : "&")
497+ : "?";
498+
499+ return `${withoutHash}${separator}${suffix}${hashFragment ? `#${hashFragment}` : ""}`;
500+};
501+
502+export const serializeOpenApiRequestBody = (input: {
503+ requestBody: OpenApiSerializableRequestBody;
504+ body: unknown;
505+}): {
506+ contentType: string;
507+ bodyText: string;
508+} => {
509+ const contentType = preferredContentType(input.requestBody) ?? "application/json";
510+
511+ return {
512+ contentType,
513+ bodyText: serializeContentValue(input.body, contentType),
514+ };
515+};
packages/codemode-openapi/src/openapi-tool-presentation.test.ts+244−0
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs"; · 2 unmodified lines
33 import { describe, expect, it } from "@effect/vitest";
44 import * as Effect from "effect/Effect";
55
6+import type { OpenApiToolDefinition } from "./openapi-definitions";
67 import { compileOpenApiToolDefinitions } from "./openapi-definitions";
78 import { extractOpenApiManifest } from "./openapi-extraction";
89 import { buildOpenApiToolPresentation } from "./openapi-tool-presentation";
@@ -14,6 +15,188 @@ const readFixture = (name: string): string => · 5 unmodified lines
1415 );
1516
1617 describe("buildOpenApiToolPresentation", () => {
18+ it("adds parameter descriptions to flattened call schemas", () => {
19+ const definition: OpenApiToolDefinition = {
20+ toolId: "accessGroups.deleteProject",
21+ rawToolId: "deleteAccessGroupProject",
22+ name: "Delete an access group project",
23+ description: "Allows deletion of an access group project",
24+ group: "accessGroups",
25+ leaf: "deleteProject",
26+ tags: [],
27+ versionSegment: "v1",
28+ method: "delete",
29+ path: "/v1/access-groups/{accessGroupIdOrName}/projects/{projectId}",
30+ invocation: {
31+ method: "delete",
32+ pathTemplate: "/v1/access-groups/{accessGroupIdOrName}/projects/{projectId}",
33+ parameters: [
34+ {
35+ name: "accessGroupIdOrName",
36+ location: "path",
37+ required: true,
38+ },
39+ {
40+ name: "projectId",
41+ location: "path",
42+ required: true,
43+ },
44+ {
45+ name: "teamId",
46+ location: "query",
47+ required: false,
48+ },
49+ {
50+ name: "slug",
51+ location: "query",
52+ required: false,
53+ },
54+ ],
55+ requestBody: null,
56+ },
57+ operationHash: "deadbeefcafebabe",
58+ typing: {
59+ inputSchema: {
60+ type: "object",
61+ properties: {
62+ accessGroupIdOrName: { type: "string" },
63+ projectId: { type: "string" },
64+ teamId: { type: "string" },
65+ slug: { type: "string" },
66+ },
67+ required: ["accessGroupIdOrName", "projectId"],
68+ additionalProperties: false,
69+ },
70+ },
71+ documentation: {
72+ summary: "Delete an access group project",
73+ parameters: [
74+ {
75+ name: "accessGroupIdOrName",
76+ location: "path",
77+ required: true,
78+ description: "The access group identifier or slug.",
79+ },
80+ {
81+ name: "projectId",
82+ location: "path",
83+ required: true,
84+ description: "The project identifier.",
85+ },
86+ {
87+ name: "teamId",
88+ location: "query",
89+ required: false,
90+ description: "The Team identifier to perform the request on behalf of.",
91+ },
92+ {
93+ name: "slug",
94+ location: "query",
95+ required: false,
96+ description: "The Team slug to perform the request on behalf of.",
97+ },
98+ ],
99+ },
100+ };
101+
102+ const presentation = buildOpenApiToolPresentation({ definition });
103+ const schema = presentation.inputSchema as {
104+ properties?: Record<string, { description?: string }>;
105+ };
106+
107+ expect(schema.properties?.accessGroupIdOrName?.description).toBe(
108+ "The access group identifier or slug.",
109+ );
110+ expect(schema.properties?.projectId?.description).toBe(
111+ "The project identifier.",
112+ );
113+ expect(schema.properties?.teamId?.description).toBe(
114+ "The Team identifier to perform the request on behalf of.",
115+ );
116+ expect(schema.properties?.slug?.description).toBe(
117+ "The Team slug to perform the request on behalf of.",
118+ );
119+ });
120+
121+ it("uses the request body property schema instead of nesting the full input schema", () => {
122+ const definition: OpenApiToolDefinition = {
123+ toolId: "projects.update",
124+ rawToolId: "updateProject",
125+ name: "Update project",
126+ description: "Updates a project.",
127+ group: "projects",
128+ leaf: "update",
129+ tags: [],
130+ versionSegment: "v1",
131+ method: "patch",
132+ path: "/v1/projects/{projectId}",
133+ invocation: {
134+ method: "patch",
135+ pathTemplate: "/v1/projects/{projectId}",
136+ parameters: [
137+ {
138+ name: "projectId",
139+ location: "path",
140+ required: true,
141+ },
142+ ],
143+ requestBody: {
144+ required: true,
145+ contentTypes: ["application/json"],
146+ },
147+ },
148+ operationHash: "feedfacecafebeef",
149+ typing: {
150+ inputSchema: {
151+ type: "object",
152+ properties: {
153+ projectId: {
154+ type: "string",
155+ },
156+ body: {
157+ type: "object",
158+ properties: {
159+ name: {
160+ type: "string",
161+ },
162+ },
163+ required: ["name"],
164+ additionalProperties: false,
165+ },
166+ },
167+ required: ["projectId", "body"],
168+ additionalProperties: false,
169+ },
170+ },
171+ documentation: {
172+ parameters: [
173+ {
174+ name: "projectId",
175+ location: "path",
176+ required: true,
177+ description: "The project identifier.",
178+ },
179+ ],
180+ requestBody: {
181+ description: "Project update payload.",
182+ },
183+ },
184+ };
185+
186+ const presentation = buildOpenApiToolPresentation({ definition });
187+ const schema = presentation.inputSchema as {
188+ properties?: Record<string, unknown>;
189+ };
190+ const body = schema.properties?.body as {
191+ description?: string;
192+ properties?: Record<string, unknown>;
193+ };
194+
195+ expect(body.description).toBe("Project update payload.");
196+ expect(body.properties?.name).toEqual({ type: "string" });
197+ expect(body.properties?.body).toBeUndefined();
198+ });
199+
17200 it.effect(
18201 "resolves request and response schemas from ref hints for the real Neon OpenAPI spec",
19202 () =>
@@ -104,4 +287,65 @@ describe("buildOpenApiToolPresentation", () => { · 84 unmodified lines
104287 }),
105288 120_000,
106289 );
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+ );
107351 });
packages/codemode-openapi/src/openapi-tool-presentation.ts+128−18
@@ -3,8 +3,10 @@ import { typeSignatureFromSchema } from "@executor/codemode-core"; · 2 unmodified lines
33
44 import type {
55 OpenApiExample,
6+ OpenApiMediaContent,
67 OpenApiInvocationPayload,
78 OpenApiRefHintTable,
9+ OpenApiResponseVariant,
810 OpenApiToolDocumentation,
911 OpenApiToolProviderData,
1012 } from "./openapi-types";
@@ -51,8 +53,74 @@ const firstExample = ( · 40 unmodified lines
5153 examples: ReadonlyArray<OpenApiExample> | undefined,
5254 ): OpenApiExample | undefined => examples?.[0];
5355
56+const schemaProperty = (
57+ schema: unknown,
58+ propertyName: string,
59+): unknown | undefined => {
60+ const record = asRecord(schema);
61+ const properties = asRecord(record.properties);
62+ return properties[propertyName];
63+};
64+
65+const groupedSchemaForParameter = (
66+ schema: unknown,
67+ location: OpenApiInvocationPayload["parameters"][number]["location"],
68+ name: string,
69+): unknown | undefined => {
70+ const direct = schemaProperty(schema, name);
71+ if (direct !== undefined) {
72+ return direct;
73+ }
74+
75+ const groupKey =
76+ location === "header"
77+ ? "headers"
78+ : location === "cookie"
79+ ? "cookies"
80+ : location;
81+ const groupSchema = schemaProperty(schema, groupKey);
82+ return groupSchema === undefined ? undefined : schemaProperty(groupSchema, name);
83+};
84+
85+const preferredContentSchema = (
86+ contents: ReadonlyArray<OpenApiMediaContent> | undefined,
87+): unknown | undefined => {
88+ if (!contents || contents.length === 0) {
89+ return undefined;
90+ }
91+
92+ const preferred = [...contents].sort((left, right) => left.mediaType.localeCompare(right.mediaType))
93+ .find((content) => content.mediaType === "application/json")
94+ ?? [...contents].find((content) => content.mediaType.toLowerCase().includes("+json"))
95+ ?? [...contents].find((content) => content.mediaType.toLowerCase().includes("json"))
96+ ?? contents[0];
97+
98+ return preferred?.schema;
99+};
100+
101+const withDescription = (
102+ schema: unknown,
103+ description: string | undefined,
104+): unknown => {
105+ if (!description) {
106+ return schema;
107+ }
108+
109+ const record = asRecord(schema);
110+ if (Object.keys(record).length === 0) {
111+ return schema;
112+ }
113+
114+ return {
115+ ...record,
116+ description,
117+ };
118+};
119+
54120 const callInputSchemaFromInvocation = (input: {
55121 invocation: OpenApiInvocationPayload;
122+ documentation?: OpenApiToolDocumentation;
123+ parameterSourceSchema?: unknown;
56124 requestBodySchema?: unknown;
57125 }): Record<string, unknown> | undefined => {
58126 const invocation = input.invocation;
@@ -60,18 +128,33 @@ const callInputSchemaFromInvocation = (input: { · 1 unmodified line
60128 const required: string[] = [];
61129
62130 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) ?? {
64139 type: "string",
65140 };
141+
142+ properties[parameter.name] = withDescription(
143+ parameterSchema,
144+ matchingDocs?.description,
145+ );
66146 if (parameter.required) {
67147 required.push(parameter.name);
68148 }
69149 }
70150
71151 if (invocation.requestBody) {
72− properties.body = input.requestBodySchema ?? {
73− type: "object",
74− };
152+ properties.body = withDescription(
153+ input.requestBodySchema ?? preferredContentSchema(invocation.requestBody.contents) ?? {
154+ type: "object",
155+ },
156+ input.documentation?.requestBody?.description,
157+ );
75158 if (invocation.requestBody.required) {
76159 required.push("body");
77160 }
@@ -130,23 +213,22 @@ const resolvePresentationSchemas = (input: { · 52 unmodified lines
130213 input.refHintTable,
131214 );
132215 const requestBodySchema =
133− resolvedTyping.inputSchema
216+ schemaProperty(resolvedTyping.inputSchema, "body")
217+ ?? schemaProperty(resolvedTyping.inputSchema, "input")
134218 ?? (resolvedRequestBodySchema !== undefined && resolvedRequestBodySchema !== null
135219 ? resolvedRequestBodySchema
136220 : undefined);
137221
138− const inputSchema =
139− input.definition.invocation.requestBody !== null
140− ? callInputSchemaFromInvocation({
141− invocation: input.definition.invocation,
142− ...(requestBodySchema !== undefined
143− ? { requestBodySchema }
144− : {}),
145− })
146− : resolvedTyping.inputSchema
147− ?? callInputSchemaFromInvocation({
148− invocation: input.definition.invocation,
149− });
222+ const inputSchema = callInputSchemaFromInvocation({
223+ invocation: input.definition.invocation,
224+ documentation: input.definition.documentation,
225+ parameterSourceSchema: resolvedTyping.inputSchema,
226+ ...(requestBodySchema !== undefined
227+ ? { requestBodySchema }
228+ : {}),
229+ })
230+ ?? resolvedTyping.inputSchema
231+ ?? undefined;
150232 const outputSchema =
151233 resolvedTyping.outputSchema
152234 ?? resolveSchemaWithRefHints(
@@ -160,6 +242,27 @@ const resolvePresentationSchemas = (input: { · 7 unmodified lines
160242 };
161243 };
162244
245+const resolveResponseVariantsWithRefHints = (input: {
246+ variants: ReadonlyArray<OpenApiResponseVariant> | undefined;
247+ refHintTable?: Readonly<OpenApiRefHintTable>;
248+}): ReadonlyArray<OpenApiResponseVariant> | undefined => {
249+ if (!input.variants || input.variants.length === 0) {
250+ return undefined;
251+ }
252+
253+ return input.variants.map((variant) => {
254+ const resolvedSchema = resolveSchemaWithRefHints(
255+ variant.schema,
256+ input.refHintTable,
257+ );
258+
259+ return {
260+ ...variant,
261+ ...(resolvedSchema !== undefined ? { schema: resolvedSchema } : {}),
262+ };
263+ });
264+};
265+
163266 const buildExampleInput = (
164267 documentation: OpenApiToolDocumentation | undefined,
165268 ): Record<string, unknown> | undefined => {
@@ -208,6 +311,10 @@ export const buildOpenApiToolPresentation = (input: { · 42 unmodified lines
208311 refHintTable?: Readonly<OpenApiRefHintTable>;
209312 }): OpenApiToolPresentation => {
210313 const { inputSchema, outputSchema } = resolvePresentationSchemas(input);
314+ const responses = resolveResponseVariantsWithRefHints({
315+ variants: input.definition.responses,
316+ refHintTable: input.refHintTable,
317+ });
211318 const exampleInput = buildExampleInput(input.definition.documentation);
212319 const exampleOutput = buildExampleOutput(input.definition.documentation);
213320
@@ -218,6 +325,9 @@ export const buildOpenApiToolPresentation = (input: { · 4 unmodified lines
218325 ...(outputSchema !== undefined ? { outputSchema } : {}),
219326 ...(exampleInput !== undefined ? { exampleInput } : {}),
220327 ...(exampleOutput !== undefined ? { exampleOutput } : {}),
221− providerData: openApiProviderDataFromDefinition(input.definition),
328+ providerData: {
329+ ...openApiProviderDataFromDefinition(input.definition),
330+ ...(responses ? { responses } : {}),
331+ } satisfies OpenApiToolProviderData,
222332 };
223333 };
packages/codemode-openapi/src/openapi-tools.test.ts+532−0
@@ -296,6 +296,315 @@ describe("openapi-tools", () => { · 295 unmodified lines
296296 }),
297297 );
298298
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+
299608 it.scoped("accepts OpenApi.fromApi generated specs", () =>
300609 Effect.gen(function* () {
301610 const server = yield* makeTestServer;
@@ -396,6 +705,229 @@ describe("openapi-tools", () => { · 94 unmodified lines
396705 }),
397706 );
398707
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+
399931 it.effect("throws on non-2xx OpenAPI responses", () =>
400932 Effect.gen(function* () {
401933 const manifest = yield* extractOpenApiManifest("generated", generatedOpenApiSpec);
packages/codemode-openapi/src/openapi-tools.ts+32−36
@@ -22,6 +22,12 @@ import { · 21 unmodified lines
2222 compileOpenApiToolDefinitions,
2323 type OpenApiToolDefinition,
2424 } from "./openapi-definitions";
25+import {
26+ serializeOpenApiParameterValue,
27+ serializeOpenApiRequestBody,
28+ withSerializedQueryEntries,
29+ type SerializedOpenApiQueryEntry,
30+} from "./openapi-http-serialization";
2531 import { buildOpenApiToolPresentation } from "./openapi-tool-presentation";
2632 import { resolveSchemaWithRefHints } from "./openapi-schema-refs";
2733 import {
@@ -67,22 +73,6 @@ const parameterContainerKeys: Record< · 39 unmodified lines
6773 cookie: ["cookies", "cookie"],
6874 };
6975
70−const argsValueToString = (value: unknown): string => {
71− if (typeof value === "string") {
72− return value;
73− }
74−
75− if (
76− typeof value === "number"
77− || typeof value === "boolean"
78− || typeof value === "bigint"
79− ) {
80− return String(value);
81− }
82−
83− return String(value);
84−};
85−
8676 const readParameterValue = (
8777 args: OpenApiToolArgs,
8878 parameter: OpenApiToolParameter,
@@ -136,9 +126,12 @@ const replacePathTemplate = ( · 47 unmodified lines
136126 continue;
137127 }
138128
129+ const serialized = serializeOpenApiParameterValue(parameter, parameterValue);
139130 resolvedPath = resolvedPath.replaceAll(
140131 `{${parameter.name}}`,
141− encodeURIComponent(String(parameterValue)),
132+ serialized.kind === "path"
133+ ? serialized.value
134+ : encodeURIComponent(String(parameterValue)),
142135 );
143136 }
144137
@@ -324,7 +317,7 @@ const buildFetchRequest = (input: { · 179 unmodified lines
324317 defaultHeaders: Readonly<Record<string, string>>;
325318 credentialPlacements: HttpRequestPlacements;
326319 }): {
327− url: URL;
320+ url: string;
328321 method: string;
329322 headers: Record<string, string>;
330323 body?: string;
@@ -340,6 +333,7 @@ const buildFetchRequest = (input: { · 9 unmodified lines
340333 ...input.defaultHeaders,
341334 };
342335 const cookieParts: Array<string> = [];
336+ const queryEntries: SerializedOpenApiQueryEntry[] = [];
343337
344338 for (const parameter of input.payload.parameters) {
345339 if (parameter.location === "path") {
@@ -358,14 +352,18 @@ const buildFetchRequest = (input: { · 12 unmodified lines
358352 continue;
359353 }
360354
361− const encoded = argsValueToString(parameterValue);
362−
363− if (parameter.location === "query") {
364− url.searchParams.set(parameter.name, encoded);
365− } else if (parameter.location === "header") {
366− headers[parameter.name] = encoded;
367− } else if (parameter.location === "cookie") {
368− cookieParts.push(`${parameter.name}=${encodeURIComponent(encoded)}`);
355+ const serialized = serializeOpenApiParameterValue(parameter, parameterValue);
356+
357+ if (serialized.kind === "query") {
358+ queryEntries.push(...serialized.entries);
359+ } else if (serialized.kind === "header") {
360+ headers[parameter.name] = serialized.value;
361+ } else if (serialized.kind === "cookie") {
362+ cookieParts.push(
363+ ...serialized.pairs.map((pair) =>
364+ `${pair.name}=${encodeURIComponent(pair.value)}`
365+ ),
366+ );
369367 }
370368 }
371369
@@ -387,20 +385,17 @@ const buildFetchRequest = (input: { · 15 unmodified lines
387385 });
388386 }
389387 } else {
390− body = JSON.stringify(
391− applyJsonBodyPlacements({
388+ const serializedBody = serializeOpenApiRequestBody({
389+ requestBody: input.payload.requestBody,
390+ body: applyJsonBodyPlacements({
392391 body: hasRequestBody(input.args) ? input.args.body : {},
393392 bodyValues,
394393 label: `${input.payload.method.toUpperCase()} ${input.payload.pathTemplate}`,
395394 }),
396− );
395+ });
397396
398− const preferredContentType = input.payload.requestBody.contentTypes[0];
399− if (preferredContentType) {
400− headers["content-type"] = preferredContentType;
401− } else if (!("content-type" in headers)) {
402− headers["content-type"] = "application/json";
403− }
397+ body = serializedBody.bodyText;
398+ headers["content-type"] = serializedBody.contentType;
404399 }
405400 }
406401
@@ -408,6 +403,7 @@ const buildFetchRequest = (input: { · 1 unmodified line
408403 url,
409404 queryParams: input.credentialPlacements.queryParams,
410405 });
406+ const urlWithQueryParams = withSerializedQueryEntries(urlWithAuth, queryEntries);
411407 const headersWithAuthCookies = applyCookiePlacementsToHeaders({
412408 headers,
413409 cookies: input.credentialPlacements.cookies,
@@ -418,7 +414,7 @@ const buildFetchRequest = (input: { · 4 unmodified lines
418414 }
419415
420416 return {
421− url: urlWithAuth,
417+ url: urlWithQueryParams,
422418 method: input.payload.method.toUpperCase(),
423419 headers: headersWithAuthCookies,
424420 body,
packages/codemode-openapi/src/openapi-types.ts+136−0
@@ -43,11 +43,45 @@ export const OpenApiToolParameterSchema = Schema.Struct({ · 42 unmodified lines
4343 name: Schema.String,
4444 location: OpenApiParameterLocationSchema,
4545 required: Schema.Boolean,
46+ style: Schema.optional(Schema.String),
47+ explode: Schema.optional(Schema.Boolean),
48+ allowReserved: Schema.optional(Schema.Boolean),
49+ content: Schema.optional(Schema.Array(Schema.suspend(() => OpenApiMediaContentSchema))),
50+});
51+
52+export const OpenApiMediaContentSchema = Schema.Struct({
53+ mediaType: Schema.String,
54+ schema: Schema.optional(Schema.Unknown),
55+ examples: Schema.optional(Schema.Array(Schema.suspend(() => OpenApiExampleSchema))),
56+});
57+
58+export const OpenApiHeaderSchema = Schema.Struct({
59+ name: Schema.String,
60+ description: Schema.optional(Schema.String),
61+ required: Schema.optional(Schema.Boolean),
62+ deprecated: Schema.optional(Schema.Boolean),
63+ schema: Schema.optional(Schema.Unknown),
64+ content: Schema.optional(Schema.Array(OpenApiMediaContentSchema)),
65+ style: Schema.optional(Schema.String),
66+ explode: Schema.optional(Schema.Boolean),
67+ examples: Schema.optional(Schema.Array(Schema.suspend(() => OpenApiExampleSchema))),
4668 });
4769
4870 export const OpenApiToolRequestBodySchema = Schema.Struct({
4971 required: Schema.Boolean,
5072 contentTypes: Schema.Array(Schema.String),
73+ contents: Schema.optional(Schema.Array(OpenApiMediaContentSchema)),
74+});
75+
76+export const OpenApiServerSchema = Schema.Struct({
77+ url: Schema.String,
78+ description: Schema.optional(Schema.String),
79+ variables: Schema.optional(
80+ Schema.Record({
81+ key: Schema.String,
82+ value: Schema.String,
83+ }),
84+ ),
5185 });
5286
5387 export const OpenApiInvocationPayloadSchema = Schema.Struct({
@@ -102,6 +136,16 @@ export const OpenApiResponseDocumentationSchema = Schema.Struct({ · 48 unmodified lines
102136 examples: Schema.optional(Schema.Array(OpenApiExampleSchema)),
103137 });
104138
139+export const OpenApiResponseVariantSchema = Schema.Struct({
140+ statusCode: Schema.String,
141+ description: Schema.optional(Schema.String),
142+ contentTypes: Schema.Array(Schema.String),
143+ schema: Schema.optional(Schema.Unknown),
144+ examples: Schema.optional(Schema.Array(OpenApiExampleSchema)),
145+ contents: Schema.optional(Schema.Array(OpenApiMediaContentSchema)),
146+ headers: Schema.optional(Schema.Array(OpenApiHeaderSchema)),
147+});
148+
105149 export const OpenApiToolDocumentationSchema = Schema.Struct({
106150 summary: Schema.optional(Schema.String),
107151 deprecated: Schema.optional(Schema.Boolean),
@@ -110,6 +154,81 @@ export const OpenApiToolDocumentationSchema = Schema.Struct({ · 2 unmodified lines
110154 response: Schema.optional(OpenApiResponseDocumentationSchema),
111155 });
112156
157+export type OpenApiSecurityRequirement =
158+ | {
159+ readonly kind: "none";
160+ }
161+ | {
162+ readonly kind: "scheme";
163+ readonly schemeName: string;
164+ readonly scopes?: readonly string[];
165+ }
166+ | {
167+ readonly kind: "allOf";
168+ readonly items: readonly OpenApiSecurityRequirement[];
169+ }
170+ | {
171+ readonly kind: "anyOf";
172+ readonly items: readonly OpenApiSecurityRequirement[];
173+ };
174+
175+export const OpenApiSecurityRequirementSchema = Schema.suspend(() =>
176+ Schema.Union(
177+ Schema.Struct({
178+ kind: Schema.Literal("none"),
179+ }),
180+ Schema.Struct({
181+ kind: Schema.Literal("scheme"),
182+ schemeName: Schema.String,
183+ scopes: Schema.optional(Schema.Array(Schema.String)),
184+ }),
185+ Schema.Struct({
186+ kind: Schema.Literal("allOf"),
187+ items: Schema.Array(OpenApiSecurityRequirementSchema),
188+ }),
189+ Schema.Struct({
190+ kind: Schema.Literal("anyOf"),
191+ items: Schema.Array(OpenApiSecurityRequirementSchema),
192+ }),
193+ )
194+) as Schema.Schema<OpenApiSecurityRequirement, OpenApiSecurityRequirement, never>;
195+
196+export const OpenApiSecuritySchemeTypeSchema = Schema.Literal(
197+ "apiKey",
198+ "http",
199+ "oauth2",
200+ "openIdConnect",
201+);
202+
203+export const OpenApiOAuthFlowSchema = Schema.Struct({
204+ authorizationUrl: Schema.optional(Schema.String),
205+ tokenUrl: Schema.optional(Schema.String),
206+ refreshUrl: Schema.optional(Schema.String),
207+ scopes: Schema.optional(
208+ Schema.Record({
209+ key: Schema.String,
210+ value: Schema.String,
211+ }),
212+ ),
213+});
214+
215+export const OpenApiSecuritySchemeSchema = Schema.Struct({
216+ schemeName: Schema.String,
217+ schemeType: OpenApiSecuritySchemeTypeSchema,
218+ description: Schema.optional(Schema.String),
219+ placementIn: Schema.optional(Schema.Literal("header", "query", "cookie")),
220+ placementName: Schema.optional(Schema.String),
221+ scheme: Schema.optional(Schema.String),
222+ bearerFormat: Schema.optional(Schema.String),
223+ openIdConnectUrl: Schema.optional(Schema.String),
224+ flows: Schema.optional(
225+ Schema.Record({
226+ key: Schema.String,
227+ value: OpenApiOAuthFlowSchema,
228+ }),
229+ ),
230+});
231+
113232 export const OpenApiToolProviderDataSchema = Schema.Struct({
114233 kind: Schema.Literal("openapi"),
115234 toolId: Schema.String,
@@ -124,6 +243,11 @@ export const OpenApiToolProviderDataSchema = Schema.Struct({ · 8 unmodified lines
124243 operationHash: Schema.String,
125244 invocation: OpenApiInvocationPayloadSchema,
126245 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)),
127251 });
128252
129253 export const OpenApiExtractedToolSchema = Schema.Struct({
@@ -138,6 +262,11 @@ export const OpenApiExtractedToolSchema = Schema.Struct({ · 8 unmodified lines
138262 operationHash: Schema.String,
139263 typing: Schema.optional(DiscoveryTypingPayloadSchema),
140264 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)),
141270 });
142271
143272 export const OpenApiToolManifestSchema = Schema.Struct({
@@ -155,7 +284,10 @@ export const OpenApiToolManifestSchema = Schema.Struct({ · 11 unmodified lines
155284 export type OpenApiHttpMethod = typeof OpenApiHttpMethodSchema.Type;
156285 export type OpenApiParameterLocation = typeof OpenApiParameterLocationSchema.Type;
157286 export type OpenApiToolParameter = typeof OpenApiToolParameterSchema.Type;
287+export type OpenApiMediaContent = typeof OpenApiMediaContentSchema.Type;
288+export type OpenApiHeader = typeof OpenApiHeaderSchema.Type;
158289 export type OpenApiToolRequestBody = typeof OpenApiToolRequestBodySchema.Type;
290+export type OpenApiServer = typeof OpenApiServerSchema.Type;
159291 export type OpenApiInvocationPayload = typeof OpenApiInvocationPayloadSchema.Type;
160292 export type DiscoveryTypingPayload = typeof DiscoveryTypingPayloadSchema.Type;
161293 export type OpenApiRefHintValue = typeof OpenApiRefHintValueSchema.Type;
@@ -164,7 +296,11 @@ export type OpenApiExample = typeof OpenApiExampleSchema.Type; · 2 unmodified lines
164296 export type OpenApiParameterDocumentation = typeof OpenApiParameterDocumentationSchema.Type;
165297 export type OpenApiRequestBodyDocumentation = typeof OpenApiRequestBodyDocumentationSchema.Type;
166298 export type OpenApiResponseDocumentation = typeof OpenApiResponseDocumentationSchema.Type;
299+export type OpenApiResponseVariant = typeof OpenApiResponseVariantSchema.Type;
167300 export type OpenApiToolDocumentation = typeof OpenApiToolDocumentationSchema.Type;
301+export type OpenApiOAuthFlow = typeof OpenApiOAuthFlowSchema.Type;
302+export type OpenApiSecuritySchemeType = typeof OpenApiSecuritySchemeTypeSchema.Type;
303+export type OpenApiSecurityScheme = typeof OpenApiSecuritySchemeSchema.Type;
168304 export type OpenApiToolProviderData = typeof OpenApiToolProviderDataSchema.Type;
169305 export type OpenApiExtractedTool = typeof OpenApiExtractedToolSchema.Type;
170306 export type OpenApiToolManifest = typeof OpenApiToolManifestSchema.Type;
packages/control-plane/src/ir/catalog.ts+6−2
@@ -641,7 +641,11 @@ const projectResultShapeFromResponses = ( · 640 unmodified lines
641641 .filter((entry): entry is { variant: ResponseSet["variants"][number]; response: ResponseSymbol; score: number } => entry !== null)
642642 .sort((left, right) => right.score - left.score);
643643
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 }) =>
645649 responseContentCandidates(response).filter((candidate) => isJsonMediaType(candidate.mediaType)),
646650 );
647651
@@ -658,7 +662,7 @@ const projectResultShapeFromResponses = ( · 10 unmodified lines
658662 }
659663
660664 const fallbackShapeIds = unique(
661− ranked.flatMap(({ response }) =>
665+ preferred.flatMap(({ response }) =>
662666 responseContentCandidates(response).map((candidate) => candidate.shapeId),
663667 ),
664668 );
packages/control-plane/src/runtime/ir-execution.ts+126−40
@@ -3,6 +3,11 @@ import { · 2 unmodified lines
33 applyHttpQueryPlacementsToUrl,
44 applyJsonBodyPlacements,
55 } from "@executor/codemode-core";
6+import {
7+ serializeOpenApiParameterValue,
8+ serializeOpenApiRequestBody,
9+ withSerializedQueryEntries,
10+} from "@executor/codemode-openapi";
611 import {
712 createMcpToolsFromManifest,
813 createSdkMcpConnector,
@@ -16,7 +21,16 @@ import type { · 7 unmodified lines
1621 ToolInput,
1722 ToolPath,
1823 } from "@executor/codemode-core";
19−import type { CatalogV1, Capability, Executable, GraphQLExecutable, HttpExecutable, McpExecutable, ParameterSymbol } from "../ir/model";
24+import type {
25+ CatalogV1,
26+ Capability,
27+ Executable,
28+ GraphQLExecutable,
29+ HttpExecutable,
30+ McpExecutable,
31+ ParameterSymbol,
32+ Scope,
33+} from "../ir/model";
2034 import type { LoadedSourceCatalogToolIndexEntry } from "./source-catalog-runtime";
2135 import type { ResolvedSourceAuthMaterial } from "./source-auth-material";
2236
@@ -73,19 +87,6 @@ const decodeFetchBody = async (response: Response): Promise<unknown> => { · 50 unmodified lines
7387 return response.text();
7488 };
7589
76−const primitiveString = (value: unknown): string => {
77− if (value === null || value === undefined) {
78− return "";
79− }
80− if (typeof value === "string") {
81− return value;
82− }
83− if (typeof value === "number" || typeof value === "boolean") {
84− return String(value);
85− }
86− return JSON.stringify(value);
87−};
88−
8990 const parameterById = (catalog: CatalogV1, parameterId: string): ParameterSymbol | undefined => {
9091 const symbol = catalog.symbols[parameterId];
9192 return symbol?.kind === "parameter" ? symbol : undefined;
@@ -130,7 +131,49 @@ const readHttpParameterValue = (input: { · 38 unmodified lines
130131 return asObject(input.args[groupKey])[input.parameter.name];
131132 };
132133
133−const resolveHttpBaseUrl = (source: Source, executable: HttpExecutable): URL => {
134+const scopeDefaultServers = (
135+ catalog: CatalogV1,
136+ scopeId: HttpExecutable["scopeId"],
137+): Array<{ url: string; variables?: Record<string, string> }> => {
138+ let currentId: HttpExecutable["scopeId"] | undefined = scopeId;
139+
140+ while (currentId) {
141+ const scope: Scope | undefined = catalog.scopes[currentId];
142+ if (!scope) {
143+ break;
144+ }
145+
146+ if (scope.defaults?.servers && scope.defaults.servers.length > 0) {
147+ return [...scope.defaults.servers];
148+ }
149+
150+ currentId = scope.parentId;
151+ }
152+
153+ return [];
154+};
155+
156+const resolveScopedServerUrl = (
157+ source: Source,
158+ server: {
159+ url: string;
160+ variables?: Record<string, string>;
161+ },
162+): URL => {
163+ const expanded = Object.entries(server.variables ?? {}).reduce(
164+ (url, [name, value]) => url.replaceAll(`{${name}}`, value),
165+ server.url,
166+ );
167+
168+ return new URL(expanded, source.endpoint);
169+};
170+
171+const resolveHttpBaseUrl = (source: Source, catalog: CatalogV1, executable: HttpExecutable): URL => {
172+ const scopedServers = scopeDefaultServers(catalog, executable.scopeId);
173+ if (scopedServers.length > 0) {
174+ return resolveScopedServerUrl(source, scopedServers[0]!);
175+ }
176+
134177 const googleBlob = executable.native?.find(
135178 (blob) => blob.kind === "google_discovery_provider_data",
136179 );
@@ -147,6 +190,27 @@ const resolveHttpBaseUrl = (source: Source, executable: HttpExecutable): URL => · 10 unmodified lines
147190 return new URL(source.endpoint);
148191 };
149192
193+const resolveHttpRequestUrl = (baseUrl: URL, resolvedPath: string): URL => {
194+ try {
195+ return new URL(resolvedPath);
196+ } catch {
197+ const resolved = new URL(baseUrl.toString());
198+ const basePath =
199+ resolved.pathname === "/"
200+ ? ""
201+ : resolved.pathname.endsWith("/")
202+ ? resolved.pathname.slice(0, -1)
203+ : resolved.pathname;
204+ const pathPart = resolvedPath.startsWith("/") ? resolvedPath : `/${resolvedPath}`;
205+
206+ resolved.pathname = `${basePath}${pathPart}`.replace(/\/{2,}/g, "/");
207+ resolved.search = "";
208+ resolved.hash = "";
209+
210+ return resolved;
211+ }
212+};
213+
150214 const executeHttp = (input: {
151215 source: Source;
152216 catalog: CatalogV1;
@@ -159,13 +223,14 @@ const executeHttp = (input: { · 6 unmodified lines
159223 const argsRecord = asObject(input.args);
160224 let resolvedPath = input.executable.pathTemplate;
161225 const url = applyHttpQueryPlacementsToUrl({
162− url: resolveHttpBaseUrl(input.source, input.executable),
226+ url: resolveHttpBaseUrl(input.source, input.catalog, input.executable),
163227 queryParams: readSourceQueryParams(input.source),
164228 });
165229 const headers: Record<string, string> = {
166230 ...readSourceHeaders(input.source),
167231 };
168− const cookies: Record<string, string> = {};
232+ const queryEntries: Array<{ name: string; value: string; allowReserved?: boolean }> = [];
233+ const cookieParts: string[] = [];
169234
170235 const allParameterIds = [
171236 ...(input.executable.pathParameterIds ?? []),
@@ -193,32 +258,37 @@ const executeHttp = (input: { · 21 unmodified lines
193258 continue;
194259 }
195260
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") {
197271 resolvedPath = resolvedPath.replace(
198272 new RegExp(`{${parameter.name}}`, "g"),
199− encodeURIComponent(primitiveString(value)),
273+ serialized.value,
200274 );
201275 continue;
202276 }
203277
204− if (parameter.location === "query") {
205− if (Array.isArray(value)) {
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− }
278+ if (serialized.kind === "query") {
279+ queryEntries.push(...serialized.entries);
212280 continue;
213281 }
214282
215− if (parameter.location === "header") {
216− headers[parameter.name] = primitiveString(value);
283+ if (serialized.kind === "header") {
284+ headers[parameter.name] = serialized.value;
217285 continue;
218286 }
219287
220− if (parameter.location === "cookie") {
221− cookies[parameter.name] = primitiveString(value);
288+ if (serialized.kind === "cookie") {
289+ cookieParts.push(
290+ ...serialized.pairs.map((pair) => `${pair.name}=${encodeURIComponent(pair.value)}`),
291+ );
222292 }
223293 }
224294
@@ -229,35 +299,42 @@ const executeHttp = (input: { · 4 unmodified lines
229299 if (bodySymbol?.kind === "requestBody") {
230300 const bodyValue = argsRecord.body ?? argsRecord.input;
231301 if (bodyValue !== undefined) {
232− const contentType = bodySymbol.contents[0]?.mediaType ?? "application/json";
233− headers["content-type"] = contentType;
234− body = JSON.stringify(
235− applyJsonBodyPlacements({
302+ const serializedBody = serializeOpenApiRequestBody({
303+ requestBody: {
304+ contentTypes: bodySymbol.contents.map((content) => content.mediaType),
305+ contents: bodySymbol.contents.map((content) => ({ mediaType: content.mediaType })),
306+ },
307+ body: applyJsonBodyPlacements({
236308 body: bodyValue,
237309 bodyValues: input.auth.bodyValues,
238310 label: `${input.executable.method} ${input.executable.pathTemplate}`,
239311 }),
240− );
312+ });
313+ headers["content-type"] = serializedBody.contentType;
314+ body = serializedBody.bodyText;
241315 }
242316 }
243317
244− const requestUrl = new URL(resolvedPath, url);
318+ const requestUrl = resolveHttpRequestUrl(url, resolvedPath);
245319 const urlWithAuth = applyHttpQueryPlacementsToUrl({
246320 url: requestUrl,
247321 queryParams: input.auth.queryParams,
248322 });
323+ const urlWithOpenApiQuery = withSerializedQueryEntries(urlWithAuth, queryEntries);
324+ if (cookieParts.length > 0) {
325+ headers.cookie = cookieParts.join("; ");
326+ }
249327 const headersWithCookies = applyCookiePlacementsToHeaders({
250328 headers: {
251329 ...headers,
252330 ...input.auth.headers,
253331 },
254332 cookies: {
255− ...cookies,
256333 ...input.auth.cookies,
257334 },
258335 });
259336
260− const response = await fetch(urlWithAuth, {
337+ const response = await fetch(urlWithOpenApiQuery, {
261338 method: input.executable.method,
262339 headers: headersWithCookies,
263340 ...(body !== undefined ? { body } : {}),
@@ -443,14 +520,23 @@ const executeMcp = (input: { · 179 unmodified lines
443520 });
444521 const tools = createMcpToolsFromManifest({
445522 manifest: {
446− version: 1,
523+ version: 2,
447524 tools: [{
448525 toolId: input.executable.toolName,
449526 toolName: input.executable.toolName,
527+ displayTitle:
528+ input.tool.capability.surface.title
529+ ?? input.executable.toolName,
530+ title: input.tool.capability.surface.title ?? null,
450531 description:
451532 input.tool.capability.surface.summary
452533 ?? input.tool.capability.surface.title
453534 ?? `MCP tool: ${input.executable.toolName}`,
535+ annotations: null,
536+ execution: null,
537+ icons: null,
538+ meta: null,
539+ rawTool: null,
454540 inputSchema: input.tool.descriptor.inputSchema,
455541 outputSchema: input.tool.descriptor.outputSchema,
456542 }],
packages/control-plane/src/runtime/local-source-artifacts.test.ts+151−0
@@ -0,0 +1,151 @@
1+import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
2+import { tmpdir } from "node:os";
3+import { join } from "node:path";
4+import { gzipSync } from "node:zlib";
5+import { NodeFileSystem } from "@effect/platform-node";
6+import { describe, expect, it } from "@effect/vitest";
7+import * as Effect from "effect/Effect";
8+
9+import {
10+ SourceIdSchema,
11+ WorkspaceIdSchema,
12+ type Source,
13+} from "#schema";
14+
15+import {
16+ buildLocalSourceArtifact,
17+ readLocalSourceArtifact,
18+ removeLocalSourceArtifact,
19+ writeLocalSourceArtifact,
20+} from "./local-source-artifacts";
21+import type { ResolvedLocalWorkspaceContext } from "./local-config";
22+import { createOpenApiCatalogSnapshot } from "./source-catalog-snapshot";
23+
24+const makeContext = (): ResolvedLocalWorkspaceContext => {
25+ const workspaceRoot = mkdtempSync(join(tmpdir(), "executor-artifacts-"));
26+ return {
27+ cwd: workspaceRoot,
28+ workspaceRoot,
29+ workspaceName: "executor-artifacts",
30+ configDirectory: join(workspaceRoot, ".executor"),
31+ projectConfigPath: join(workspaceRoot, ".executor", "executor.jsonc"),
32+ homeConfigPath: join(workspaceRoot, ".executor-home.jsonc"),
33+ artifactsDirectory: join(workspaceRoot, ".executor", "artifacts"),
34+ stateDirectory: join(workspaceRoot, ".executor", "state"),
35+ };
36+};
37+
38+const makeSource = (): Source => ({
39+ id: SourceIdSchema.make("src_test"),
40+ workspaceId: WorkspaceIdSchema.make("ws_test"),
41+ name: "Test Source",
42+ kind: "openapi",
43+ endpoint: "https://example.com/api",
44+ status: "connected",
45+ enabled: true,
46+ namespace: "test",
47+ bindingVersion: 1,
48+ binding: {
49+ specUrl: "https://example.com/openapi.json",
50+ defaultHeaders: null,
51+ },
52+ importAuthPolicy: "none",
53+ importAuth: { kind: "none" },
54+ auth: { kind: "none" },
55+ sourceHash: "hash_test",
56+ lastError: null,
57+ createdAt: 0,
58+ updatedAt: 0,
59+});
60+
61+const makeArtifact = () => {
62+ const source = makeSource();
63+ const snapshot = createOpenApiCatalogSnapshot({
64+ source,
65+ documents: [{
66+ documentKind: "openapi",
67+ documentKey: source.binding.specUrl,
68+ contentText: '{"openapi":"3.1.0"}',
69+ fetchedAt: 1,
70+ }],
71+ operations: [],
72+ });
73+
74+ return buildLocalSourceArtifact({
75+ source,
76+ syncResult: {
77+ snapshot,
78+ sourceHash: source.sourceHash,
79+ },
80+ });
81+};
82+
83+describe("local-source-artifacts", () => {
84+ it.effect("writes uncompressed artifacts and reads them back", () =>
85+ Effect.gen(function* () {
86+ const context = makeContext();
87+ const artifact = makeArtifact();
88+ const path = join(context.artifactsDirectory, "sources", "src_test.json");
89+ const legacyPath = join(context.artifactsDirectory, "sources", "src_test.json.gz");
90+ const rawDocumentPath = join(
91+ context.artifactsDirectory,
92+ "sources",
93+ "src_test",
94+ "documents",
95+ `${Object.keys(artifact.snapshot.catalog.documents)[0]}.txt`,
96+ );
97+
98+ yield* writeLocalSourceArtifact({
99+ context,
100+ sourceId: "src_test",
101+ artifact,
102+ });
103+
104+ expect(existsSync(path)).toBe(true);
105+ expect(existsSync(legacyPath)).toBe(false);
106+ expect(readFileSync(path, "utf8").startsWith("{")).toBe(true);
107+ expect(existsSync(rawDocumentPath)).toBe(true);
108+
109+ const persistedArtifact = JSON.parse(readFileSync(path, "utf8"));
110+ const persistedDocument = Object.values(persistedArtifact.snapshot.catalog.documents)[0] as {
111+ native?: ReadonlyArray<{ kind?: string }>;
112+ };
113+ expect(persistedDocument.native?.some((blob) => blob.kind === "source_document") ?? false).toBe(false);
114+
115+ const decoded = yield* readLocalSourceArtifact({
116+ context,
117+ sourceId: "src_test",
118+ });
119+
120+ expect(decoded?.snapshot.import.adapterKey).toBe("openapi");
121+ expect(decoded?.sourceId).toBe("src_test");
122+ expect((Object.values(decoded?.snapshot.catalog.documents ?? {})[0] as { native?: Array<{ value?: unknown }> })?.native?.[0]?.value).toBe('{"openapi":"3.1.0"}');
123+ }).pipe(Effect.provide(NodeFileSystem.layer)),
124+ );
125+
126+ it.effect("reads legacy gz artifacts and removes both formats", () =>
127+ Effect.gen(function* () {
128+ const context = makeContext();
129+ const artifact = makeArtifact();
130+ const directory = join(context.artifactsDirectory, "sources");
131+ const legacyPath = join(directory, "src_test.json.gz");
132+
133+ mkdirSync(directory, { recursive: true });
134+ writeFileSync(legacyPath, gzipSync(JSON.stringify(artifact)));
135+
136+ const decoded = yield* readLocalSourceArtifact({
137+ context,
138+ sourceId: "src_test",
139+ });
140+ expect(decoded?.catalogId).toBe(artifact.catalogId);
141+
142+ yield* removeLocalSourceArtifact({
143+ context,
144+ sourceId: "src_test",
145+ });
146+
147+ expect(existsSync(join(directory, "src_test.json"))).toBe(false);
148+ expect(existsSync(legacyPath)).toBe(false);
149+ }).pipe(Effect.provide(NodeFileSystem.layer)),
150+ );
151+});
packages/control-plane/src/runtime/local-source-artifacts.ts+200−17
@@ -1,4 +1,5 @@
11 import { join } from "node:path";
2+import { gunzipSync } from "node:zlib";
23 import { FileSystem } from "@effect/platform";
34
45 import {
@@ -12,7 +13,11 @@ import { · 7 unmodified lines
1213 import * as Effect from "effect/Effect";
1314 import * as Schema from "effect/Schema";
1415
15−import { CatalogSnapshotV1Schema } from "../ir/model";
16+import {
17+ CatalogSnapshotV1Schema,
18+ type NativeBlob,
19+ type SourceDocument,
20+} from "../ir/model";
1621 import type { SourceCatalogSyncResult } from "./source-catalog-support";
1722 import type { ResolvedLocalWorkspaceContext } from "./local-config";
1823 import {
@@ -49,6 +54,9 @@ const mapFileSystemError = (path: string, action: string) => (cause: unknown) => · 30 unmodified lines
4954 details: unknownLocalErrorDetails(cause),
5055 });
5156
57+const mutableRecord = <K extends string, V>(value: Readonly<Record<K, V>>): Record<K, V> =>
58+ value as Record<K, V>;
59+
5260 const localSourceArtifactPath = (input: {
5361 context: ResolvedLocalWorkspaceContext;
5462 sourceId: string;
@@ -59,6 +67,93 @@ const localSourceArtifactPath = (input: { · 4 unmodified lines
5967 `${input.sourceId}.json`,
6068 );
6169
70+const legacyLocalSourceArtifactPath = (input: {
71+ context: ResolvedLocalWorkspaceContext;
72+ sourceId: string;
73+}): string =>
74+ join(
75+ input.context.artifactsDirectory,
76+ "sources",
77+ `${input.sourceId}.json.gz`,
78+ );
79+
80+const localSourceDocumentDirectory = (input: {
81+ context: ResolvedLocalWorkspaceContext;
82+ sourceId: string;
83+}): string =>
84+ join(input.context.artifactsDirectory, "sources", input.sourceId, "documents");
85+
86+const localSourceDocumentPath = (input: {
87+ context: ResolvedLocalWorkspaceContext;
88+ sourceId: string;
89+ documentId: string;
90+}): string =>
91+ join(localSourceDocumentDirectory(input), `${input.documentId}.txt`);
92+
93+const splitArtifactSourceDocuments = (artifact: LocalSourceArtifact): {
94+ artifact: LocalSourceArtifact;
95+ rawDocuments: ReadonlyArray<{
96+ documentId: string;
97+ blob: NativeBlob;
98+ content: string;
99+ }>;
100+} => {
101+ const nextArtifact = structuredClone(artifact);
102+ const rawDocuments: Array<{
103+ documentId: string;
104+ blob: NativeBlob;
105+ content: string;
106+ }> = [];
107+
108+ for (const [documentId, document] of Object.entries(nextArtifact.snapshot.catalog.documents)) {
109+ const mutableDocument = document as SourceDocument & { native?: NativeBlob[] };
110+ const sourceDocumentBlob = document.native?.find((blob) =>
111+ blob.kind === "source_document" && typeof blob.value === "string"
112+ );
113+ if (!sourceDocumentBlob || typeof sourceDocumentBlob.value !== "string") {
114+ continue;
115+ }
116+
117+ rawDocuments.push({
118+ documentId,
119+ blob: sourceDocumentBlob,
120+ content: sourceDocumentBlob.value,
121+ });
122+
123+ const remainingBlobs = (document.native ?? []).filter((blob) => blob !== sourceDocumentBlob);
124+ if (remainingBlobs.length > 0) {
125+ mutableDocument.native = remainingBlobs;
126+ } else {
127+ delete mutableDocument.native;
128+ }
129+ }
130+
131+ return {
132+ artifact: nextArtifact,
133+ rawDocuments,
134+ };
135+};
136+
137+const hydrateArtifactSourceDocuments = (input: {
138+ artifact: LocalSourceArtifact;
139+ rawDocuments: Readonly<Record<string, NativeBlob>>;
140+}): LocalSourceArtifact => {
141+ const nextArtifact = structuredClone(input.artifact);
142+ const mutableDocuments = mutableRecord(nextArtifact.snapshot.catalog.documents);
143+
144+ for (const [documentId, rawDocument] of Object.entries(input.rawDocuments)) {
145+ const document = mutableDocuments[documentId as keyof typeof mutableDocuments] as (SourceDocument & { native?: NativeBlob[] }) | undefined;
146+ if (!document) {
147+ continue;
148+ }
149+
150+ const remainingBlobs = (document.native ?? []).filter((blob) => blob.kind !== "source_document");
151+ document.native = [rawDocument, ...remainingBlobs];
152+ }
153+
154+ return nextArtifact;
155+};
156+
62157 const snapshotHash = (snapshot: SourceCatalogSyncResult["snapshot"]): string =>
63158 contentHash(JSON.stringify(snapshot));
64159
@@ -99,25 +194,79 @@ export const readLocalSourceArtifact = (input: { · 34 unmodified lines
99194 Effect.gen(function* () {
100195 const fs = yield* FileSystem.FileSystem;
101196 const path = localSourceArtifactPath(input);
197+ const legacyPath = legacyLocalSourceArtifactPath(input);
102198 const exists = yield* fs.exists(path).pipe(
103199 Effect.mapError(mapFileSystemError(path, "check source artifact path")),
104200 );
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) {
106207 return null;
107208 }
108209
109− const content = yield* fs.readFileString(path, "utf8").pipe(
110− Effect.mapError(mapFileSystemError(path, "read source artifact")),
111− );
112− return yield* Effect.try({
210+ const readPath = exists ? path : legacyPath;
211+ const content = exists
212+ ? yield* fs.readFileString(readPath, "utf8").pipe(
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({
113231 try: () => decodeLocalSourceArtifact(JSON.parse(content) as unknown),
114232 catch: (cause) =>
115233 new LocalSourceArtifactDecodeError({
116− message: `Invalid local source artifact at ${path}: ${unknownLocalErrorDetails(cause)}`,
117− path,
234+ message: `Invalid local source artifact at ${readPath}: ${unknownLocalErrorDetails(cause)}`,
235+ path: readPath,
118236 details: unknownLocalErrorDetails(cause),
119237 }),
120238 });
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;
121270 });
122271
123272 export const writeLocalSourceArtifact = (input: {
@@ -129,12 +278,41 @@ export const writeLocalSourceArtifact = (input: { · 5 unmodified lines
129278 const fs = yield* FileSystem.FileSystem;
130279 const directory = join(input.context.artifactsDirectory, "sources");
131280 const path = localSourceArtifactPath(input);
281+ const legacyPath = legacyLocalSourceArtifactPath(input);
282+ const sourceDocumentDirectory = localSourceDocumentDirectory(input);
283+ const split = splitArtifactSourceDocuments(input.artifact);
132284 yield* fs.makeDirectory(directory, { recursive: true }).pipe(
133285 Effect.mapError(mapFileSystemError(directory, "create source artifact directory")),
134286 );
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(
136306 Effect.mapError(mapFileSystemError(path, "write source artifact")),
137307 );
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+ }
138316 });
139317
140318 export const removeLocalSourceArtifact = (input: {
@@ -143,14 +321,19 @@ export const removeLocalSourceArtifact = (input: { · 2 unmodified lines
143321 }): Effect.Effect<void, LocalFileSystemError, FileSystem.FileSystem> =>
144322 Effect.gen(function* () {
145323 const fs = yield* FileSystem.FileSystem;
146− const path = localSourceArtifactPath(input);
147− const exists = yield* fs.exists(path).pipe(
148− Effect.mapError(mapFileSystemError(path, "check source artifact path")),
149− );
150− if (!exists) {
151− return;
324+ for (const path of [localSourceArtifactPath(input), legacyLocalSourceArtifactPath(input)]) {
325+ const exists = yield* fs.exists(path).pipe(
326+ Effect.mapError(mapFileSystemError(path, "check source artifact path")),
327+ );
328+ if (!exists) {
329+ continue;
330+ }
331+ yield* fs.remove(path).pipe(
332+ Effect.mapError(mapFileSystemError(path, "remove source artifact")),
333+ );
152334 }
153− yield* fs.remove(path).pipe(
154− Effect.mapError(mapFileSystemError(path, "remove source artifact")),
335+ const sourceDocumentDirectory = localSourceDocumentDirectory(input);
336+ yield* fs.remove(sourceDocumentDirectory, { recursive: true, force: true }).pipe(
337+ Effect.mapError(mapFileSystemError(sourceDocumentDirectory, "remove source document directory")),
155338 );
156339 });
packages/control-plane/src/runtime/source-adapter-fixture-matrix.test.ts+296−0
@@ -1,4 +1,5 @@
11 import { readFileSync } from "node:fs";
2+import { createServer } from "node:http";
23
34 import {
45 buildOpenApiToolPresentation,
@@ -38,6 +39,7 @@ import { · 33 unmodified lines
3839 expandCatalogToolByPath,
3940 type LoadedSourceCatalog,
4041 } from "./source-catalog-runtime";
42+import { invokeIrTool } from "./ir-execution";
4143 import {
4244 createGoogleDiscoveryCatalogSnapshot,
4345 createGraphqlCatalogSnapshot,
@@ -335,6 +337,18 @@ describe("source adapter fixture matrix", () => { · 291 unmodified lines
335337 expect(
336338 unresolvedDiagnosticsForPrefix(snapshot, "#/openapi/addProjectDomain"),
337339 ).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);
338352 }),
339353 120_000,
340354 );
@@ -401,6 +415,288 @@ describe("source adapter fixture matrix", () => { · 60 unmodified lines
401415 expect(
402416 unresolvedDiagnosticsForPrefix(snapshot, "#/openapi/apiKey.createApiKey"),
403417 ).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)),
404700 }),
405701 120_000,
406702 );
packages/control-plane/src/runtime/source-adapters/mcp.test.ts+227−0
@@ -0,0 +1,227 @@
1+import { randomUUID } from "node:crypto";
2+
3+import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
4+import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5+import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
6+import { describe, expect, it } from "@effect/vitest";
7+import { SourceIdSchema } from "#schema";
8+
9+import * as Effect from "effect/Effect";
10+import { z } from "zod/v4";
11+
12+import { createSourceFromPayload } from "../source-definitions";
13+import { mcpSourceAdapter } from "./mcp";
14+
15+type RealMcpServer = {
16+ endpoint: string;
17+ close: () => Promise<void>;
18+};
19+
20+const makeRealMcpServer = Effect.acquireRelease(
21+ Effect.promise<RealMcpServer>(
22+ () =>
23+ new Promise<RealMcpServer>((resolve, reject) => {
24+ const createServerForRequest = () => {
25+ const mcp = new McpServer(
26+ {
27+ name: "mcp-adapter-test-server",
28+ version: "1.0.0",
29+ title: "Adapter Test Server",
30+ description: "Server for MCP adapter tests",
31+ websiteUrl: "https://example.test/mcp",
32+ },
33+ {
34+ capabilities: {
35+ tools: {
36+ listChanged: true,
37+ },
38+ logging: {},
39+ },
40+ },
41+ );
42+
43+ mcp.registerTool(
44+ "read_file",
45+ {
46+ title: "Read File",
47+ description: "Read a file from memory",
48+ inputSchema: {
49+ path: z.string(),
50+ },
51+ annotations: {
52+ title: "Read File (Annotated)",
53+ readOnlyHint: true,
54+ destructiveHint: false,
55+ idempotentHint: true,
56+ openWorldHint: false,
57+ },
58+ _meta: {
59+ category: "filesystem",
60+ },
61+ },
62+ async ({ path }: { path: string }) => ({
63+ content: [{
64+ type: "text",
65+ text: `read:${path}`,
66+ }],
67+ }),
68+ );
69+
70+ const transport = new StreamableHTTPServerTransport({
71+ sessionIdGenerator: undefined,
72+ });
73+
74+ return {
75+ mcp,
76+ transport,
77+ };
78+ };
79+
80+ const app = createMcpExpressApp({ host: "127.0.0.1" });
81+
82+ const handle = async (req: any, res: any, parsedBody?: unknown) => {
83+ const { mcp, transport } = createServerForRequest();
84+
85+ try {
86+ await mcp.connect(transport);
87+ await transport.handleRequest(req, res, parsedBody);
88+ } finally {
89+ await transport.close().catch(() => undefined);
90+ await mcp.close().catch(() => undefined);
91+ }
92+ };
93+
94+ app.post("/mcp", async (req: any, res: any) => {
95+ await handle(req, res, req.body);
96+ });
97+
98+ app.get("/mcp", async (req: any, res: any) => {
99+ await handle(req, res);
100+ });
101+
102+ app.delete("/mcp", async (req: any, res: any) => {
103+ await handle(req, res, req.body);
104+ });
105+
106+ const listener = app.listen(0, "127.0.0.1", () => {
107+ const address = listener.address();
108+ if (!address || typeof address === "string") {
109+ reject(new Error("failed to resolve MCP adapter test server address"));
110+ return;
111+ }
112+
113+ resolve({
114+ endpoint: `http://127.0.0.1:${address.port}/mcp`,
115+ close: async () => {
116+ await new Promise<void>((closeResolve, closeReject) => {
117+ listener.close((error: Error | undefined) => {
118+ if (error) {
119+ closeReject(error);
120+ return;
121+ }
122+ closeResolve();
123+ });
124+ });
125+ },
126+ });
127+ });
128+
129+ listener.once("error", reject);
130+ }),
131+ ),
132+ (server: RealMcpServer) =>
133+ Effect.tryPromise({
134+ try: () => server.close(),
135+ catch: (error: unknown) =>
136+ error instanceof Error ? error : new Error(String(error)),
137+ }).pipe(Effect.orDie),
138+);
139+
140+describe("mcp source adapter", () => {
141+ it.scoped("syncs MCP annotations and introspection metadata into snapshot", () =>
142+ Effect.gen(function* () {
143+ const realServer = yield* makeRealMcpServer;
144+ const source = yield* createSourceFromPayload({
145+ workspaceId: "ws_test" as any,
146+ sourceId: SourceIdSchema.make(`src_${randomUUID()}`),
147+ payload: {
148+ name: "MCP Demo",
149+ kind: "mcp",
150+ endpoint: realServer.endpoint,
151+ namespace: "mcp.demo",
152+ binding: {
153+ transport: "streamable-http",
154+ queryParams: null,
155+ headers: null,
156+ },
157+ importAuthPolicy: "reuse_runtime",
158+ importAuth: { kind: "none" },
159+ auth: { kind: "none" },
160+ status: "connected",
161+ enabled: true,
162+ },
163+ now: Date.now(),
164+ });
165+
166+ const syncResult = yield* mcpSourceAdapter.syncCatalog({
167+ source,
168+ resolveSecretMaterial: () =>
169+ Effect.fail(new Error("unexpected secret lookup")),
170+ resolveAuthMaterialForSlot: () =>
171+ Effect.succeed({
172+ placements: [],
173+ headers: {},
174+ queryParams: {},
175+ cookies: {},
176+ bodyValues: {},
177+ expiresAt: null,
178+ refreshAfter: null,
179+ }),
180+ });
181+
182+ const capability = Object.values(syncResult.snapshot.catalog.capabilities)[0]!;
183+ const executable = Object.values(syncResult.snapshot.catalog.executables)[0]!;
184+ const document = Object.values(syncResult.snapshot.catalog.documents)[0]!;
185+ const rawManifest = document.native?.[0]?.value;
186+
187+ expect(capability.surface.title).toBe("Read File");
188+ expect(capability.semantics).toMatchObject({
189+ effect: "read",
190+ safe: true,
191+ idempotent: true,
192+ destructive: false,
193+ });
194+ expect(capability.native).toBeUndefined();
195+ expect(executable.native).toBeUndefined();
196+
197+ expect(typeof rawManifest).toBe("string");
198+ const manifest = JSON.parse(rawManifest as string) as {
199+ server?: {
200+ info?: {
201+ name?: string;
202+ };
203+ };
204+ tools?: Array<Record<string, unknown>>;
205+ listTools?: {
206+ rawResult?: {
207+ tools?: Array<Record<string, unknown>>;
208+ };
209+ };
210+ };
211+
212+ expect(manifest.server?.info?.name).toBe("mcp-adapter-test-server");
213+ expect(manifest.tools?.[0]).toMatchObject({
214+ displayTitle: "Read File",
215+ annotations: {
216+ readOnlyHint: true,
217+ },
218+ });
219+ expect(manifest.listTools?.rawResult?.tools?.[0]).toMatchObject({
220+ title: "Read File",
221+ _meta: {
222+ category: "filesystem",
223+ },
224+ });
225+ }),
226+ );
227+});
packages/control-plane/src/runtime/source-adapters/mcp.ts+36−20
@@ -4,6 +4,7 @@ import { · 3 unmodified lines
44 import {
55 createSdkMcpConnector,
66 discoverMcpToolsFromConnector,
7+ type McpServerMetadata,
78 type McpToolManifest,
89 type McpToolManifestEntry,
910 } from "@executor/codemode-mcp";
@@ -122,31 +123,41 @@ const mcpBindingConfigFromSource = ( · 112 unmodified lines
122123 } satisfies McpBindingConfig;
123124 });
124125
125−const mcpCatalogOperationFromManifestEntry = (entry: McpToolManifestEntry): McpCatalogOperationInput => ({
126− toolId: entry.toolId,
127− title: entry.toolName,
128− description: entry.description ?? null,
129− effect: "action",
130− inputSchema: entry.inputSchema,
131− outputSchema: entry.outputSchema,
126+const effectFromMcpManifestEntry = (entry: McpToolManifestEntry): McpCatalogOperationInput["effect"] =>
127+ entry.annotations?.readOnlyHint === true ? "read" : "write";
128+
129+const mcpCatalogOperationFromManifestEntry = (input: {
130+ entry: McpToolManifestEntry;
131+ server: McpServerMetadata | null | undefined;
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,
132139 providerData: {
133− toolId: entry.toolId,
134− toolName: entry.toolName,
135− description: entry.description ?? null,
140+ toolId: input.entry.toolId,
141+ toolName: input.entry.toolName,
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,
136151 },
137152 });
138153
139−export const catalogSyncResultFromMcpManifestEntries = (input: {
154+export const catalogSyncResultFromMcpManifest = (input: {
140155 source: Source;
141156 endpoint: string;
142− manifestEntries: readonly McpToolManifestEntry[];
157+ manifest: McpToolManifest;
143158 }): SourceCatalogSyncResult => {
144159 const now = Date.now();
145− const manifest: McpToolManifest = {
146− version: 1,
147− tools: input.manifestEntries,
148− };
149− const manifestJson = JSON.stringify(manifest);
160+ const manifestJson = JSON.stringify(input.manifest);
150161 const manifestHash = contentHash(manifestJson);
151162
152163 return {
@@ -158,7 +169,12 @@ export const catalogSyncResultFromMcpManifestEntries = (input: { · 5 unmodified lines
158169 contentText: manifestJson,
159170 fetchedAt: now,
160171 }],
161− operations: input.manifestEntries.map(mcpCatalogOperationFromManifestEntry),
172+ operations: input.manifest.tools.map((entry) =>
173+ mcpCatalogOperationFromManifestEntry({
174+ entry,
175+ server: input.manifest.server,
176+ })
177+ ),
162178 }),
163179 sourceHash: manifestHash,
164180 };
@@ -268,10 +284,10 @@ export const mcpSourceAdapter: SourceAdapter = { · 103 unmodified lines
268284 ),
269285 );
270286
271− return catalogSyncResultFromMcpManifestEntries({
287+ return catalogSyncResultFromMcpManifest({
272288 source,
273289 endpoint: source.endpoint,
274− manifestEntries: discovered.manifest.tools,
290+ manifest: discovered.manifest,
275291 });
276292 }),
277293 };
packages/control-plane/src/runtime/source-auth-service.ts+1−1
@@ -1119,7 +1119,7 @@ const connectMcpSourceInternal = (input: { · 1118 unmodified lines
11191119 const indexed = yield* Effect.either(
11201120 input.sourceCatalogSync.persistMcpCatalogSnapshotFromManifest({
11211121 source: connected,
1122− manifestEntries: result.manifest.tools,
1122+ manifest: result.manifest,
11231123 }),
11241124 );
11251125
packages/control-plane/src/runtime/source-catalog-snapshot.test.ts+608−0
@@ -10,6 +10,7 @@ import { · 9 unmodified lines
1010 import {
1111 createGoogleDiscoveryCatalogSnapshot,
1212 createGraphqlCatalogSnapshot,
13+ createMcpCatalogSnapshot,
1314 createOpenApiCatalogSnapshot,
1415 } from "./source-catalog-snapshot";
1516
@@ -129,12 +130,370 @@ describe("source-catalog-snapshot", () => { · 113 unmodified lines
129130
130131 expect(capability.surface.toolPath).toEqual(["google", "calendar", "events", "update"]);
131132 expect(capability.semantics.effect).toBe("write");
133+ expect(capability.auth.kind).toBe("none");
132134 expect(executable.protocol).toBe("http");
133135 expect(executable.method).toBe("PATCH");
134136 expect(executable.pathTemplate).toBe("/calendars/{calendarId}/events/{eventId}");
137+ expect(executable.native).toBeUndefined();
138+ expect(capability.native).toBeUndefined();
135139 expect(responseSet.variants).toHaveLength(1);
136140 });
137141
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+
138497 it("imports Google Discovery scopes as auth requirements", () => {
139498 const snapshot = createGoogleDiscoveryCatalogSnapshot({
140499 source: {
@@ -169,6 +528,9 @@ describe("source-catalog-snapshot", () => { · 28 unmodified lines
169528 requestSchemaId: null,
170529 responseSchemaId: "FileList",
171530 scopes: ["https://www.googleapis.com/auth/drive.readonly"],
531+ scopeDescriptions: {
532+ "https://www.googleapis.com/auth/drive.readonly": "View your Google Drive files.",
533+ },
172534 supportsMediaUpload: false,
173535 supportsMediaDownload: false,
174536 },
@@ -177,10 +539,134 @@ describe("source-catalog-snapshot", () => { · 2 unmodified lines
177539 });
178540
179541 const capability = Object.values(snapshot.catalog.capabilities)[0]!;
542+ const executable = Object.values(snapshot.catalog.executables)[0]!;
180543 const securityScheme = Object.values(snapshot.catalog.symbols).find((symbol) => symbol.kind === "securityScheme");
181544
182545 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+ });
183553 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);
184670 });
185671
186672 it("converts GraphQL field operations into GraphQL executables", () => {
@@ -222,6 +708,128 @@ describe("source-catalog-snapshot", () => { · 35 unmodified lines
222708 expect(executable.operationType).toBe("query");
223709 expect(executable.rootField).toBe("viewer");
224710 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();
225833 });
226834
227835 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
55 import {
66 type GoogleDiscoveryToolProviderData,
77 } from "@executor/codemode-google-discovery";
8+import type {
9+ McpServerMetadata,
10+ McpToolAnnotations,
11+ McpToolExecution,
12+} from "@executor/codemode-mcp";
813
914 import type { Source } from "#schema";
1015
@@ -90,12 +95,21 @@ export type McpCatalogOperationInput = CatalogOperationInput & { · 79 unmodified lines
9095 providerData: {
9196 toolId: string;
9297 toolName: string;
98+ displayTitle: string;
99+ title: string | null;
93100 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;
94107 };
95108 };
96109
97110 type JsonSchemaImporter = {
98− importSchema: (schema: unknown, key: string) => ReturnType<typeof ShapeSymbolIdSchema.make>;
111+ importSchema: (schema: unknown, key: string, rootSchema?: unknown) => ReturnType<typeof ShapeSymbolIdSchema.make>;
112+ finalize: () => void;
99113 };
100114
101115 const stableStringify = (value: unknown): string => {
@@ -209,6 +223,28 @@ const interactionForEffect = (effect: EffectKind): Capability["interaction"] => · 107 unmodified lines
209223 },
210224 });
211225
226+const mcpResumeSupport = (
227+ execution: McpCatalogOperationInput["providerData"]["execution"],
228+): boolean =>
229+ execution?.taskSupport === "optional" || execution?.taskSupport === "required";
230+
231+const mcpSemanticsForOperation = (input: {
232+ effect: EffectKind;
233+ annotations: McpCatalogOperationInput["providerData"]["annotations"];
234+}): Capability["semantics"] => {
235+ const safe = input.effect === "read";
236+
237+ return {
238+ effect: input.effect,
239+ safe,
240+ idempotent: safe || input.annotations?.idempotentHint === true,
241+ destructive:
242+ safe
243+ ? false
244+ : input.annotations?.destructiveHint !== false,
245+ };
246+};
247+
212248 const importMetadataFor = (input: {
213249 source: Source;
214250 adapterKey: string;
@@ -280,6 +316,10 @@ const createJsonSchemaImporter = (input: { · 65 unmodified lines
280316 documentId: ReturnType<typeof DocumentIdSchema.make>;
281317 }) : JsonSchemaImporter => {
282318 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>>();
283323
284324 const resolvePointer = (root: unknown, pointer: string): unknown => {
285325 if (pointer === "#" || pointer.length === 0) {
@@ -308,11 +348,20 @@ const createJsonSchemaImporter = (input: { · 22 unmodified lines
308348 const stableKey = `${input.resourceId}:${key}`;
309349 const cached = cache.get(stableKey);
310350 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+ }
311357 return cached;
312358 }
313359
314360 const shapeId = ShapeSymbolIdSchema.make(`shape_${stableHash({ resourceId: input.resourceId, key })}`);
315361 cache.set(stableKey, shapeId);
362+ activeShapeIds.push(shapeId);
363+
364+ try {
316365
317366 const objectSchema = asObject(schema);
318367 const title = asString(objectSchema.title) ?? undefined;
@@ -324,7 +373,38 @@ const createJsonSchemaImporter = (input: { · 5 unmodified lines
324373 const register = (node: ShapeNode, extras: {
325374 native?: NativeBlob[];
326375 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+
328408 mutableRecord(input.catalog.symbols)[shapeId] = {
329409 id: shapeId,
330410 kind: "shape",
@@ -340,14 +420,19 @@ const createJsonSchemaImporter = (input: { · 9 unmodified lines
340420 : {}),
341421 ...(extras.native && extras.native.length > 0 ? { native: extras.native } : {}),
342422 } satisfies ShapeSymbol;
423+
424+ if (!recursive) {
425+ structuralCache.set(signature, shapeId);
426+ }
427+
428+ return shapeId;
343429 };
344430
345431 if (typeof schema === "boolean") {
346− register({
432+ return register({
347433 type: "unknown",
348434 reason: schema ? "schema_true" : "schema_false",
349435 });
350− return shapeId;
351436 }
352437
353438 const ref = asString(objectSchema.$ref);
@@ -374,71 +459,64 @@ const createJsonSchemaImporter = (input: { · 20 unmodified lines
374459 diagnosticIds: [diagnosticId],
375460 },
376461 );
377− return shapeId;
462+ return cache.get(stableKey)!;
378463 }
379464
380465 const target = importSchema(resolved, ref, rootSchema ?? schema);
381− register({
466+ return register({
382467 type: "ref",
383468 target,
384469 });
385− return shapeId;
386470 }
387471
388472 const enumValues = asArray(objectSchema.enum);
389473 if (enumValues.length === 1) {
390− register({
474+ return register({
391475 type: "const",
392476 value: enumValues[0],
393477 });
394− return shapeId;
395478 }
396479
397480 if (enumValues.length > 1) {
398− register({
481+ return register({
399482 type: "enum",
400483 values: enumValues,
401484 });
402− return shapeId;
403485 }
404486
405487 if ("const" in objectSchema) {
406− register({
488+ return register({
407489 type: "const",
408490 value: objectSchema.const,
409491 });
410− return shapeId;
411492 }
412493
413494 const anyOf = asArray(objectSchema.anyOf);
414495 if (anyOf.length > 0) {
415− register({
496+ return register({
416497 type: "anyOf",
417498 items: anyOf.map((entry, index) => importSchema(entry, `${key}/anyOf/${index}`, rootSchema ?? schema)),
418499 });
419− return shapeId;
420500 }
421501
422502 const allOf = asArray(objectSchema.allOf);
423503 if (allOf.length > 0) {
424− register({
504+ return register({
425505 type: "allOf",
426506 items: allOf.map((entry, index) => importSchema(entry, `${key}/allOf/${index}`, rootSchema ?? schema)),
427507 });
428− return shapeId;
429508 }
430509
431510 const oneOf = asArray(objectSchema.oneOf);
432511 if (oneOf.length > 0) {
433− register({
512+ return register({
434513 type: "oneOf",
435514 items: oneOf.map((entry, index) => importSchema(entry, `${key}/oneOf/${index}`, rootSchema ?? schema)),
436515 });
437− return shapeId;
438516 }
439517
440518 if ("if" in objectSchema || "then" in objectSchema || "else" in objectSchema) {
441− register({
519+ return register({
442520 type: "conditional",
443521 ifShapeId: importSchema(objectSchema.if ?? {}, `${key}/if`, rootSchema ?? schema),
444522 thenShapeId: importSchema(objectSchema.then ?? {}, `${key}/then`, rootSchema ?? schema),
@@ -448,15 +526,13 @@ const createJsonSchemaImporter = (input: { · 3 unmodified lines
448526 }
449527 : {}),
450528 });
451− return shapeId;
452529 }
453530
454531 if ("not" in objectSchema) {
455− register({
532+ return register({
456533 type: "not",
457534 itemShapeId: importSchema(objectSchema.not, `${key}/not`, rootSchema ?? schema),
458535 });
459− return shapeId;
460536 }
461537
462538 const declaredType = objectSchema.type;
@@ -564,8 +640,7 @@ const createJsonSchemaImporter = (input: { · 101 unmodified lines
564640 return registerNullable(innerId);
565641 }
566642
567− register(objectNode);
568− return shapeId;
643+ return register(objectNode);
569644 }
570645
571646 if (effectiveType === "array" || "items" in objectSchema || "prefixItems" in objectSchema) {
@@ -598,8 +673,7 @@ const createJsonSchemaImporter = (input: { · 26 unmodified lines
598673 return registerNullable(innerId);
599674 }
600675
601− register(tupleNode);
602− return shapeId;
676+ return register(tupleNode);
603677 }
604678
605679 const items = objectSchema.items ?? {};
@@ -623,8 +697,7 @@ const createJsonSchemaImporter = (input: { · 17 unmodified lines
623697 return registerNullable(innerId);
624698 }
625699
626− register(arrayNode);
627− return shapeId;
700+ return register(arrayNode);
628701 }
629702
630703 if (
@@ -667,11 +740,10 @@ const createJsonSchemaImporter = (input: { · 36 unmodified lines
667740 return registerNullable(innerId);
668741 }
669742
670− register(scalarNode);
671− return shapeId;
743+ return register(scalarNode);
672744 }
673745
674− register({
746+ return register({
675747 type: "unknown",
676748 reason: `unsupported_schema:${key}`,
677749 }, {
@@ -683,11 +755,47 @@ const createJsonSchemaImporter = (input: { · 5 unmodified lines
683755 summary: "Unsupported JSON schema preserved natively",
684756 })],
685757 });
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+ }
687764 };
688765
689766 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+ },
691799 };
692800 };
693801
@@ -758,13 +866,30 @@ const requestBodySchemaFromInput = (schema: unknown): unknown => · 64 unmodified lines
758866
759867 const preferredResponseContentTypes = (
760868 mediaTypes: readonly string[] | undefined,
761−): string[] =>
762− mediaTypes && mediaTypes.length > 0 ? [...mediaTypes] : ["application/json"];
869+): string[] => {
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+};
763887
764888 const createServiceScope = (input: {
765889 catalog: CatalogV1;
766890 source: Source;
767891 documentId: ReturnType<typeof DocumentIdSchema.make>;
892+ defaults?: Scope["defaults"];
768893 }) => {
769894 const scopeId = serviceScopeIdForSource(input.source);
770895 mutableRecord(input.catalog.scopes)[scopeId] = {
@@ -775,12 +900,58 @@ const createServiceScope = (input: { · 4 unmodified lines
775900 docs: docsFrom({
776901 summary: input.source.name,
777902 }),
903+ ...(input.defaults ? { defaults: input.defaults } : {}),
778904 synthetic: false,
779905 provenance: provenanceFor(input.documentId, "#/service"),
780906 } satisfies Scope;
781907 return scopeId;
782908 };
783909
910+const openApiServerSpecs = (
911+ servers: OpenApiToolProviderData["servers"] | OpenApiToolProviderData["documentServers"] | undefined,
912+): NonNullable<NonNullable<Scope["defaults"]>["servers"]> | undefined => {
913+ if (!servers || servers.length === 0) {
914+ return undefined;
915+ }
916+
917+ return servers.map((server) => ({
918+ url: server.url,
919+ ...(server.description ? { description: server.description } : {}),
920+ ...(server.variables ? { variables: server.variables } : {}),
921+ }));
922+};
923+
924+const createOperationScope = (input: {
925+ catalog: CatalogV1;
926+ source: Source;
927+ documentId: ReturnType<typeof DocumentIdSchema.make>;
928+ parentScopeId: ReturnType<typeof ScopeIdSchema.make>;
929+ operation: OpenApiCatalogOperationInput;
930+ defaults: Scope["defaults"];
931+}) => {
932+ const scopeId = ScopeIdSchema.make(`scope_${stableHash({
933+ sourceId: input.source.id,
934+ toolId: input.operation.providerData.toolId,
935+ kind: "operation",
936+ })}`);
937+
938+ mutableRecord(input.catalog.scopes)[scopeId] = {
939+ id: scopeId,
940+ kind: "operation",
941+ parentId: input.parentScopeId,
942+ name: input.operation.title ?? input.operation.providerData.toolId,
943+ docs: docsFrom({
944+ summary: input.operation.title ?? input.operation.providerData.toolId,
945+ description: input.operation.description ?? undefined,
946+ }),
947+ defaults: input.defaults,
948+ synthetic: false,
949+ provenance: provenanceFor(input.documentId, `#/openapi/${input.operation.providerData.toolId}/scope`),
950+ } satisfies Scope;
951+
952+ return scopeId;
953+};
954+
784955 const responseSetFromSingleResponse = (input: {
785956 catalog: CatalogV1;
786957 responseId: ReturnType<typeof ResponseSymbolIdSchema.make>;
@@ -807,6 +978,320 @@ const responseSetFromSingleResponse = (input: { · 20 unmodified lines
807978 return responseSetId;
808979 };
809980
981+const responseSetFromVariants = (input: {
982+ catalog: CatalogV1;
983+ variants: ResponseSet["variants"];
984+ provenance: ProvenanceRef[];
985+}) => {
986+ const responseSetId = ResponseSetIdSchema.make(`response_set_${stableHash({
987+ variants: input.variants.map((variant) => ({
988+ match: variant.match,
989+ responseId: variant.responseId,
990+ traits: variant.traits,
991+ })),
992+ })}`);
993+ mutableRecord(input.catalog.responseSets)[responseSetId] = {
994+ id: responseSetId,
995+ variants: input.variants,
996+ synthetic: false,
997+ provenance: input.provenance,
998+ } satisfies ResponseSet;
999+ return responseSetId;
1000+};
1001+
1002+const statusMatchFromHttpStatusCode = (
1003+ statusCode: string,
1004+): ResponseSet["variants"][number]["match"] => {
1005+ const normalized = statusCode.trim().toUpperCase();
1006+
1007+ if (/^\d{3}$/.test(normalized)) {
1008+ return {
1009+ kind: "exact",
1010+ status: Number(normalized),
1011+ };
1012+ }
1013+
1014+ if (/^[1-5]XX$/.test(normalized)) {
1015+ return {
1016+ kind: "range",
1017+ value: normalized as "1XX" | "2XX" | "3XX" | "4XX" | "5XX",
1018+ };
1019+ }
1020+
1021+ return {
1022+ kind: "default",
1023+ };
1024+};
1025+
1026+const ensureOpenApiSecuritySchemeSymbol = (input: {
1027+ catalog: CatalogV1;
1028+ source: Source;
1029+ documentId: ReturnType<typeof DocumentIdSchema.make>;
1030+ schemeName: string;
1031+ scheme?: NonNullable<OpenApiToolProviderData["securitySchemes"]>[number];
1032+}) => {
1033+ const schemeId = SecuritySchemeSymbolIdSchema.make(`security_${stableHash({
1034+ sourceId: input.source.id,
1035+ provider: "openapi",
1036+ schemeName: input.schemeName,
1037+ })}`);
1038+
1039+ if (input.catalog.symbols[schemeId]) {
1040+ return schemeId;
1041+ }
1042+
1043+ const scheme = input.scheme;
1044+ const httpScheme = scheme?.scheme?.toLowerCase();
1045+ const schemeType =
1046+ scheme?.schemeType === "apiKey"
1047+ ? "apiKey"
1048+ : scheme?.schemeType === "oauth2"
1049+ ? "oauth2"
1050+ : scheme?.schemeType === "http" && httpScheme === "basic"
1051+ ? "basic"
1052+ : scheme?.schemeType === "http" && httpScheme === "bearer"
1053+ ? "bearer"
1054+ : scheme?.schemeType === "openIdConnect"
1055+ ? "custom"
1056+ : scheme?.schemeType === "http"
1057+ ? "http"
1058+ : "custom";
1059+
1060+ const oauthFlows = Object.fromEntries(
1061+ Object.entries(scheme?.flows ?? {}).map(([flowName, flow]) => [flowName, flow]),
1062+ );
1063+ const oauthScopes = Object.fromEntries(
1064+ Object.entries(scheme?.flows ?? {}).flatMap(([, flow]) =>
1065+ Object.entries(flow.scopes ?? {}),
1066+ ),
1067+ );
1068+ const description = scheme?.description
1069+ ?? (scheme?.openIdConnectUrl
1070+ ? `OpenID Connect: ${scheme.openIdConnectUrl}`
1071+ : null);
1072+
1073+ mutableRecord(input.catalog.symbols)[schemeId] = {
1074+ id: schemeId,
1075+ kind: "securityScheme",
1076+ schemeType,
1077+ ...(docsFrom({
1078+ summary: input.schemeName,
1079+ description,
1080+ })
1081+ ? {
1082+ docs: docsFrom({
1083+ summary: input.schemeName,
1084+ description,
1085+ })!,
1086+ }
1087+ : {}),
1088+ ...((scheme?.placementIn || scheme?.placementName)
1089+ ? {
1090+ placement: {
1091+ ...(scheme?.placementIn ? { in: scheme.placementIn } : {}),
1092+ ...(scheme?.placementName ? { name: scheme.placementName } : {}),
1093+ },
1094+ }
1095+ : {}),
1096+ ...(schemeType === "apiKey" && scheme?.placementIn && scheme?.placementName
1097+ ? {
1098+ apiKey: {
1099+ in: scheme.placementIn,
1100+ name: scheme.placementName,
1101+ },
1102+ }
1103+ : {}),
1104+ ...((schemeType === "basic" || schemeType === "bearer" || schemeType === "http") && scheme?.scheme
1105+ ? {
1106+ http: {
1107+ scheme: scheme.scheme,
1108+ ...(scheme.bearerFormat ? { bearerFormat: scheme.bearerFormat } : {}),
1109+ },
1110+ }
1111+ : {}),
1112+ ...(schemeType === "oauth2"
1113+ ? {
1114+ oauth: {
1115+ ...(Object.keys(oauthFlows).length > 0 ? { flows: oauthFlows } : {}),
1116+ ...(Object.keys(oauthScopes).length > 0 ? { scopes: oauthScopes } : {}),
1117+ },
1118+ }
1119+ : {}),
1120+ ...(schemeType === "custom"
1121+ ? {
1122+ custom: {},
1123+ }
1124+ : {}),
1125+ synthetic: false,
1126+ provenance: provenanceFor(
1127+ input.documentId,
1128+ `#/openapi/securitySchemes/${input.schemeName}`,
1129+ ),
1130+ } satisfies SecuritySchemeSymbol;
1131+
1132+ return schemeId;
1133+};
1134+
1135+const openApiAuthRequirementToIr = (input: {
1136+ catalog: CatalogV1;
1137+ source: Source;
1138+ documentId: ReturnType<typeof DocumentIdSchema.make>;
1139+ authRequirement: OpenApiToolProviderData["authRequirement"] | undefined;
1140+ schemesByName: ReadonlyMap<string, NonNullable<OpenApiToolProviderData["securitySchemes"]>[number]>;
1141+}): AuthRequirement => {
1142+ const requirement = input.authRequirement;
1143+ if (!requirement) {
1144+ return {
1145+ kind: "none",
1146+ };
1147+ }
1148+
1149+ switch (requirement.kind) {
1150+ case "none":
1151+ return {
1152+ kind: "none",
1153+ };
1154+ case "scheme": {
1155+ const schemeId = ensureOpenApiSecuritySchemeSymbol({
1156+ catalog: input.catalog,
1157+ source: input.source,
1158+ documentId: input.documentId,
1159+ schemeName: requirement.schemeName,
1160+ scheme: input.schemesByName.get(requirement.schemeName),
1161+ });
1162+
1163+ return {
1164+ kind: "scheme",
1165+ schemeId,
1166+ ...(requirement.scopes && requirement.scopes.length > 0
1167+ ? { scopes: [...requirement.scopes] }
1168+ : {}),
1169+ };
1170+ }
1171+ case "allOf":
1172+ case "anyOf":
1173+ return {
1174+ kind: requirement.kind,
1175+ items: requirement.items.map((item) =>
1176+ openApiAuthRequirementToIr({
1177+ catalog: input.catalog,
1178+ source: input.source,
1179+ documentId: input.documentId,
1180+ authRequirement: item,
1181+ schemesByName: input.schemesByName,
1182+ })),
1183+ };
1184+ }
1185+};
1186+
1187+const contentSpecsFromOpenApiContents = (input: {
1188+ catalog: CatalogV1;
1189+ source: Source;
1190+ documentId: ReturnType<typeof DocumentIdSchema.make>;
1191+ importer: JsonSchemaImporter;
1192+ rootSchema?: unknown;
1193+ contents:
1194+ | ReadonlyArray<NonNullable<NonNullable<OpenApiToolProviderData["responses"]>[number]["contents"]>[number]>
1195+ | ReadonlyArray<NonNullable<NonNullable<OpenApiToolProviderData["invocation"]["requestBody"]>["contents"]>[number]>
1196+ | ReadonlyArray<NonNullable<NonNullable<OpenApiToolProviderData["invocation"]["parameters"][number]["content"]>[number]>>;
1197+ pointerBase: string;
1198+}) =>
1199+ input.contents.map((content, contentIndex) => {
1200+ const exampleIds = (content.examples ?? []).map((example, exampleIndex) =>
1201+ exampleSymbolFromValue({
1202+ catalog: input.catalog,
1203+ source: input.source,
1204+ documentId: input.documentId,
1205+ pointer: `${input.pointerBase}/content/${contentIndex}/example/${exampleIndex}`,
1206+ name: example.label,
1207+ summary: example.label,
1208+ value: JSON.parse(example.valueJson) as unknown,
1209+ })
1210+ );
1211+
1212+ return {
1213+ mediaType: content.mediaType,
1214+ ...(content.schema !== undefined
1215+ ? {
1216+ shapeId: input.importer.importSchema(
1217+ content.schema,
1218+ `${input.pointerBase}/content/${contentIndex}`,
1219+ input.rootSchema,
1220+ ),
1221+ }
1222+ : {}),
1223+ ...(exampleIds.length > 0 ? { exampleIds } : {}),
1224+ } satisfies ContentSpec;
1225+ });
1226+
1227+const createOpenApiHeaderSymbol = (input: {
1228+ catalog: CatalogV1;
1229+ source: Source;
1230+ documentId: ReturnType<typeof DocumentIdSchema.make>;
1231+ importer: JsonSchemaImporter;
1232+ rootSchema?: unknown;
1233+ pointer: string;
1234+ idSeed: Record<string, unknown>;
1235+ header: NonNullable<NonNullable<OpenApiToolProviderData["responses"]>[number]["headers"]>[number];
1236+}) => {
1237+ const headerId = HeaderSymbolIdSchema.make(`header_${stableHash(input.idSeed)}`);
1238+ const exampleIds = (input.header.examples ?? []).map((example, index) =>
1239+ exampleSymbolFromValue({
1240+ catalog: input.catalog,
1241+ source: input.source,
1242+ documentId: input.documentId,
1243+ pointer: `${input.pointer}/example/${index}`,
1244+ name: example.label,
1245+ summary: example.label,
1246+ value: JSON.parse(example.valueJson) as unknown,
1247+ })
1248+ );
1249+ const contents = input.header.content
1250+ ? contentSpecsFromOpenApiContents({
1251+ catalog: input.catalog,
1252+ source: input.source,
1253+ documentId: input.documentId,
1254+ importer: input.importer,
1255+ rootSchema: input.rootSchema,
1256+ contents: input.header.content,
1257+ pointerBase: input.pointer,
1258+ })
1259+ : undefined;
1260+
1261+ mutableRecord(input.catalog.symbols)[headerId] = {
1262+ id: headerId,
1263+ kind: "header",
1264+ name: input.header.name,
1265+ ...(docsFrom({
1266+ description: input.header.description,
1267+ })
1268+ ? {
1269+ docs: docsFrom({
1270+ description: input.header.description,
1271+ })!,
1272+ }
1273+ : {}),
1274+ ...(typeof input.header.deprecated === "boolean" ? { deprecated: input.header.deprecated } : {}),
1275+ ...(input.header.schema !== undefined
1276+ ? {
1277+ schemaShapeId: input.importer.importSchema(
1278+ input.header.schema,
1279+ input.pointer,
1280+ input.rootSchema,
1281+ ),
1282+ }
1283+ : {}),
1284+ ...(contents && contents.length > 0 ? { content: contents } : {}),
1285+ ...(exampleIds.length > 0 ? { exampleIds } : {}),
1286+ ...(input.header.style ? { style: input.header.style } : {}),
1287+ ...(typeof input.header.explode === "boolean" ? { explode: input.header.explode } : {}),
1288+ synthetic: false,
1289+ provenance: provenanceFor(input.documentId, input.pointer),
1290+ };
1291+
1292+ return headerId;
1293+};
1294+
8101295 const createHttpCapabilityFromOpenApi = (input: {
8111296 catalog: CatalogV1;
8121297 source: Source;
@@ -814,6 +1299,7 @@ const createHttpCapabilityFromOpenApi = (input: { · 1 unmodified line
8141299 serviceScopeId: ReturnType<typeof ScopeIdSchema.make>;
8151300 operation: OpenApiCatalogOperationInput;
8161301 importer: JsonSchemaImporter;
1302+ rootSchema?: unknown;
8171303 }) => {
8181304 const toolPath = toolPathSegments(input.source, input.operation.providerData.toolId);
8191305 const capabilityId = CapabilityIdSchema.make(`cap_${stableHash({
@@ -828,6 +1314,9 @@ const createHttpCapabilityFromOpenApi = (input: { · 8 unmodified lines
8281314 const inputSchema = input.operation.inputSchema ?? {};
8291315 const outputSchema = input.operation.outputSchema ?? {};
8301316 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+ );
8311320
8321321 const parameterIds = input.operation.providerData.invocation.parameters.map((parameter) => {
8331322 const parameterId = ParameterSymbolIdSchema.make(`param_${stableHash({
@@ -852,6 +1341,17 @@ const createHttpCapabilityFromOpenApi = (input: { · 18 unmodified lines
8521341 });
8531342 });
8541343 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;
8551355 mutableRecord(input.catalog.symbols)[parameterId] = {
8561356 id: parameterId,
8571357 kind: "parameter",
@@ -865,17 +1365,22 @@ const createHttpCapabilityFromOpenApi = (input: { · 7 unmodified lines
8651365 docs: docsFrom({
8661366 description: matchingDocs?.description ?? null,
8671367 })!,
868− }
869− : {}),
870− ...(parameterSchema !== undefined
1368+ }
1369+ : {}),
1370+ ...(parameterSchema !== undefined && (!parameterContent || parameterContent.length === 0)
8711371 ? {
8721372 schemaShapeId: input.importer.importSchema(
8731373 parameterSchema,
8741374 `#/openapi/${input.operation.providerData.toolId}/parameter/${parameter.location}/${parameter.name}`,
1375+ input.rootSchema,
8751376 ),
8761377 }
8771378 : {}),
1379+ ...(parameterContent && parameterContent.length > 0 ? { content: parameterContent } : {}),
8781380 ...(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 } : {}),
8791384 synthetic: false,
8801385 provenance: provenanceFor(
8811386 input.documentId,
@@ -891,33 +1396,50 @@ const createHttpCapabilityFromOpenApi = (input: { · 9 unmodified lines
8911396
8921397 if (requestBodyId) {
8931398 const requestBodySchema = requestBodySchemaFromInput(inputSchema);
894− const requestBodyExampleIds = (input.operation.providerData.documentation?.requestBody?.examples ?? []).map(
895− (example, index) =>
896− exampleSymbolFromValue({
1399+ const requestBodyContents = input.operation.providerData.invocation.requestBody?.contents
1400+ ? contentSpecsFromOpenApiContents({
8971401 catalog: input.catalog,
8981402 source: input.source,
8991403 documentId: input.documentId,
900− pointer: `#/openapi/${input.operation.providerData.toolId}/requestBody/example/${index}`,
901− name: example.label,
902− summary: example.label,
903− value: JSON.parse(example.valueJson) as unknown,
904− }),
905− );
1404+ importer: input.importer,
1405+ rootSchema: input.rootSchema,
1406+ contents: input.operation.providerData.invocation.requestBody.contents,
1407+ pointerBase: `#/openapi/${input.operation.providerData.toolId}/requestBody`,
1408+ })
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+ );
9061424 exampleIds.push(...requestBodyExampleIds);
907− const contents: ContentSpec[] = preferredResponseContentTypes(
908− input.operation.providerData.invocation.requestBody?.contentTypes,
909− ).map((mediaType) => ({
910− mediaType,
911− ...(requestBodySchema !== undefined
912− ? {
913− shapeId: input.importer.importSchema(
914− requestBodySchema,
915− `#/openapi/${input.operation.providerData.toolId}/requestBody`,
916− ),
917− }
918− : {}),
919− ...(requestBodyExampleIds.length > 0 ? { exampleIds: requestBodyExampleIds } : {}),
920− }));
1425+ const contents: ContentSpec[] =
1426+ requestBodyContents && requestBodyContents.length > 0
1427+ ? requestBodyContents
1428+ : preferredResponseContentTypes(
1429+ input.operation.providerData.invocation.requestBody?.contentTypes,
1430+ ).map((mediaType) => ({
1431+ mediaType,
1432+ ...(requestBodySchema !== undefined
1433+ ? {
1434+ shapeId: input.importer.importSchema(
1435+ requestBodySchema,
1436+ `#/openapi/${input.operation.providerData.toolId}/requestBody`,
1437+ input.rootSchema,
1438+ ),
1439+ }
1440+ : {}),
1441+ ...(requestBodyExampleIds.length > 0 ? { exampleIds: requestBodyExampleIds } : {}),
1442+ }));
9211443
9221444 mutableRecord(input.catalog.symbols)[requestBodyId] = {
9231445 id: requestBodyId,
@@ -938,62 +1460,188 @@ const createHttpCapabilityFromOpenApi = (input: { · 14 unmodified lines
9381460 };
9391461 }
9401462
941− const responseId = ResponseSymbolIdSchema.make(`response_${stableHash({ capabilityId })}`);
942− const responseExampleIds = (input.operation.providerData.documentation?.response?.examples ?? []).map((example, index) =>
943− exampleSymbolFromValue({
944− catalog: input.catalog,
945− source: input.source,
946− documentId: input.documentId,
947− pointer: `#/openapi/${input.operation.providerData.toolId}/response/example/${index}`,
948− name: example.label,
949− summary: example.label,
950− value: JSON.parse(example.valueJson) as unknown,
951− })
952− );
953− exampleIds.push(...responseExampleIds);
954− mutableRecord(input.catalog.symbols)[responseId] = {
955− id: responseId,
956− kind: "response",
957− ...(docsFrom({
958− description:
959− input.operation.providerData.documentation?.response?.description
960− ?? input.operation.description,
961− })
962− ? {
963− docs: docsFrom({
964− description:
965− input.operation.providerData.documentation?.response?.description
966− ?? input.operation.description,
967− })!,
968− }
969− : {}),
970− contents: [{
971− mediaType: preferredResponseContentTypes(input.operation.providerData.documentation?.response?.contentTypes)[0] ?? "application/json",
972− ...(input.operation.outputSchema !== undefined
973− ? {
974− shapeId: input.importer.importSchema(
975− outputSchema,
976− `#/openapi/${input.operation.providerData.toolId}/response`,
977− ),
978− }
979− : {}),
980− ...(responseExampleIds.length > 0 ? { exampleIds: responseExampleIds } : {}),
981− }],
982− synthetic: false,
983− provenance: provenanceFor(input.documentId, `#/openapi/${input.operation.providerData.toolId}/response`),
984− } satisfies ResponseSymbol;
985−
986− const responseSetId = responseSetFromSingleResponse({
987− catalog: input.catalog,
988− responseId,
989− provenance: provenanceFor(input.documentId, `#/openapi/${input.operation.providerData.toolId}/responseSet`),
990− });
1463+ const openApiResponseVariants = input.operation.providerData.responses ?? [];
1464+ const responseSetId =
1465+ openApiResponseVariants.length > 0
1466+ ? responseSetFromVariants({
1467+ catalog: input.catalog,
1468+ variants: openApiResponseVariants.map((response, responseIndex) => {
1469+ const responseId = ResponseSymbolIdSchema.make(`response_${stableHash({
1470+ capabilityId,
1471+ statusCode: response.statusCode,
1472+ responseIndex,
1473+ })}`);
1474+ const responseExampleIds = (response.examples ?? []).map((example, index) =>
1475+ exampleSymbolFromValue({
1476+ catalog: input.catalog,
1477+ source: input.source,
1478+ documentId: input.documentId,
1479+ pointer: `#/openapi/${input.operation.providerData.toolId}/responses/${response.statusCode}/example/${index}`,
1480+ name: example.label,
1481+ summary: example.label,
1482+ value: JSON.parse(example.valueJson) as unknown,
1483+ })
1484+ );
1485+ exampleIds.push(...responseExampleIds);
1486+
1487+ const contents =
1488+ response.contents && response.contents.length > 0
1489+ ? contentSpecsFromOpenApiContents({
1490+ catalog: input.catalog,
1491+ source: input.source,
1492+ documentId: input.documentId,
1493+ importer: input.importer,
1494+ rootSchema: input.rootSchema,
1495+ contents: response.contents,
1496+ pointerBase: `#/openapi/${input.operation.providerData.toolId}/responses/${response.statusCode}`,
1497+ })
1498+ : (() => {
1499+ const responseShapeId =
1500+ response.schema !== undefined
1501+ ? input.importer.importSchema(
1502+ response.schema,
1503+ `#/openapi/${input.operation.providerData.toolId}/responses/${response.statusCode}`,
1504+ input.rootSchema,
1505+ )
1506+ : undefined;
1507+ const preferredContentTypes = preferredResponseContentTypes(response.contentTypes);
1508+
1509+ return preferredContentTypes.length > 0
1510+ ? preferredContentTypes.map((mediaType, contentIndex) => ({
1511+ mediaType,
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+ })();
9911623
9921624 const executable: HttpExecutable = {
9931625 id: executableId,
9941626 protocol: "http",
9951627 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+ })(),
9971645 method: input.operation.providerData.invocation.method.toUpperCase(),
9981646 pathTemplate: input.operation.providerData.invocation.pathTemplate,
9991647 pathParameterIds: parameterIds.filter((parameterId) => {
@@ -1016,16 +1664,17 @@ const createHttpCapabilityFromOpenApi = (input: { · 16 unmodified lines
10161664 responseSetId,
10171665 synthetic: false,
10181666 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− })],
10251667 };
10261668 mutableRecord(input.catalog.executables)[executableId] = executable;
10271669
10281670 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+ });
10291678 mutableRecord(input.catalog.capabilities)[capabilityId] = {
10301679 id: capabilityId,
10311680 serviceScopeId: input.serviceScopeId,
@@ -1041,18 +1690,12 @@ const createHttpCapabilityFromOpenApi = (input: { · 9 unmodified lines
10411690 idempotent: effect === "read" || effect === "delete",
10421691 destructive: effect === "delete",
10431692 },
1044− auth: { kind: "none" },
1693+ auth,
10451694 interaction: interactionForEffect(effect),
10461695 executableIds: [executableId],
10471696 ...(exampleIds.length > 0 ? { exampleIds } : {}),
10481697 synthetic: false,
10491698 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− })],
10561699 } satisfies Capability;
10571700 };
10581701
@@ -1085,6 +1728,12 @@ const createHttpCapabilityFromGoogleDiscovery = (input: { · 26 unmodified lines
10851728 : undefined;
10861729
10871730 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+ );
10881737 mutableRecord(input.catalog.symbols)[authSchemeId] = {
10891738 id: authSchemeId,
10901739 kind: "securityScheme",
@@ -1095,7 +1744,7 @@ const createHttpCapabilityFromGoogleDiscovery = (input: { · 4 unmodified lines
10951744 }),
10961745 oauth: {
10971746 flows: {},
1098− scopes: Object.fromEntries(input.operation.providerData.invocation.scopes.map((scope) => [scope, scope])),
1747+ scopes: scopeDescriptions,
10991748 },
11001749 synthetic: false,
11011750 provenance: provenanceFor(input.documentId, "#/googleDiscovery/security"),
@@ -1232,7 +1881,12 @@ const createHttpCapabilityFromGoogleDiscovery = (input: { · 130 unmodified lines
12321881 source: input.source,
12331882 kind: "google_discovery_provider_data",
12341883 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+ },
12361890 })],
12371891 } satisfies HttpExecutable;
12381892
@@ -1266,12 +1920,6 @@ const createHttpCapabilityFromGoogleDiscovery = (input: { · 27 unmodified lines
12661920 executableIds: [executableId],
12671921 synthetic: false,
12681922 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− })],
12751923 } satisfies Capability;
12761924 };
12771925
@@ -1361,12 +2009,6 @@ const createGraphqlCapability = (input: { · 83 unmodified lines
13612009 responseSetId,
13622010 synthetic: false,
13632011 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− })],
13702012 } satisfies GraphQLExecutable;
13712013
13722014 const effect = input.operation.effect;
@@ -1391,12 +2033,6 @@ const createGraphqlCapability = (input: { · 18 unmodified lines
13912033 executableIds: [executableId],
13922034 synthetic: false,
13932035 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− })],
14002036 } satisfies Capability;
14012037 };
14022038
@@ -1473,30 +2109,28 @@ const createMcpCapability = (input: { · 70 unmodified lines
14732109 responseSetId,
14742110 synthetic: false,
14752111 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− })],
14822112 } satisfies McpExecutable;
14832113
2114+ const interaction = interactionForEffect(input.operation.effect);
14842115 mutableRecord(input.catalog.capabilities)[capabilityId] = {
14852116 id: capabilityId,
14862117 serviceScopeId: input.serviceScopeId,
14872118 surface: {
14882119 toolPath,
1489− title: input.operation.providerData.toolName,
2120+ title: input.operation.providerData.displayTitle,
14902121 ...(input.operation.providerData.description ? { summary: input.operation.providerData.description } : {}),
14912122 },
1492− semantics: {
2123+ semantics: mcpSemanticsForOperation({
14932124 effect: input.operation.effect,
1494− safe: input.operation.effect === "read",
1495− idempotent: input.operation.effect === "read",
1496− destructive: false,
1497− },
2125+ annotations: input.operation.providerData.annotations,
2126+ }),
14982127 auth: { kind: "none" },
1499− interaction: interactionForEffect(input.operation.effect),
2128+ interaction: {
2129+ ...interaction,
2130+ resume: {
2131+ supported: mcpResumeSupport(input.operation.providerData.execution),
2132+ },
2133+ },
15002134 executableIds: [executableId],
15012135 synthetic: false,
15022136 provenance: provenanceFor(input.documentId, `#/mcp/${input.operation.providerData.toolId}/capability`),
@@ -1507,6 +2141,7 @@ const buildCatalogSnapshot = (input: { · 4 unmodified lines
15072141 source: Source;
15082142 adapterKey: string;
15092143 documents: readonly CatalogSourceDocumentInput[];
2144+ serviceScopeDefaults?: Scope["defaults"];
15102145 registerOperations: (context: {
15112146 catalog: CatalogV1;
15122147 documentId: ReturnType<typeof DocumentIdSchema.make>;
@@ -1573,6 +2208,7 @@ const buildCatalogSnapshot = (input: { · 60 unmodified lines
15732208 catalog,
15742209 source: input.source,
15752210 documentId: primaryDocumentId,
2211+ defaults: input.serviceScopeDefaults,
15762212 });
15772213 const importer = createJsonSchemaImporter({
15782214 catalog,
@@ -1586,6 +2222,7 @@ const buildCatalogSnapshot = (input: { · 7 unmodified lines
15862222 serviceScopeId,
15872223 importer,
15882224 });
2225+ importer.finalize();
15892226
15902227 const snapshot = createCatalogSnapshotV1({
15912228 import: importMetadataFor(input),
@@ -1599,11 +2236,33 @@ export const createOpenApiCatalogSnapshot = (input: { · 7 unmodified lines
15992236 source: Source;
16002237 documents: readonly CatalogSourceDocumentInput[];
16012238 operations: readonly OpenApiCatalogOperationInput[];
1602−}): CatalogSnapshotV1 =>
1603− buildCatalogSnapshot({
2239+}): CatalogSnapshotV1 => {
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({
16042254 source: input.source,
16052255 adapterKey: "openapi",
16062256 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+ })(),
16072266 registerOperations: ({ catalog, documentId, serviceScopeId, importer }) => {
16082267 for (const operation of input.operations) {
16092268 createHttpCapabilityFromOpenApi({
@@ -1613,10 +2272,12 @@ export const createOpenApiCatalogSnapshot = (input: { · 3 unmodified lines
16132272 serviceScopeId,
16142273 operation,
16152274 importer,
2275+ rootSchema,
16162276 });
16172277 }
16182278 },
16192279 });
2280+};
16202281
16212282 export const createGoogleDiscoveryCatalogSnapshot = (input: {
16222283 source: Source;
packages/control-plane/src/runtime/source-catalog-sync.ts+7−6
@@ -3,6 +3,7 @@ import type { · 2 unmodified lines
33 Source,
44 SourceStatus,
55 } from "#schema";
6+import type { McpToolManifest } from "@executor/codemode-mcp";
67 import * as Context from "effect/Context";
78 import * as Effect from "effect/Effect";
89 import * as Layer from "effect/Layer";
@@ -27,7 +28,7 @@ import { · 18 unmodified lines
2728 getSourceAdapterForSource,
2829 } from "./source-adapters";
2930 import {
30− catalogSyncResultFromMcpManifestEntries,
31+ catalogSyncResultFromMcpManifest,
3132 } from "./source-adapters/mcp";
3233 import { SecretMaterialResolverService } from "./secret-material-providers";
3334 import {
@@ -61,7 +62,7 @@ export type RuntimeSourceCatalogSyncShape = { · 27 unmodified lines
6162 }) => Effect.Effect<void, Error, never>;
6263 persistMcpCatalogSnapshotFromManifest: (input: {
6364 source: Source;
64− manifestEntries: Parameters<typeof catalogSyncResultFromMcpManifestEntries>[0]["manifestEntries"];
65+ manifest: McpToolManifest;
6566 }) => Effect.Effect<void, Error, never>;
6667 };
6768
@@ -189,7 +190,7 @@ const persistMcpCatalogSnapshotFromManifestWithDeps = ( · 121 unmodified lines
189190 deps: RuntimeSourceCatalogSyncDeps,
190191 input: {
191192 source: Source;
192− manifestEntries: Parameters<typeof catalogSyncResultFromMcpManifestEntries>[0]["manifestEntries"];
193+ manifest: McpToolManifest;
193194 },
194195 ): Effect.Effect<void, Error, never> =>
195196 Effect.gen(function* () {
@@ -197,10 +198,10 @@ const persistMcpCatalogSnapshotFromManifestWithDeps = ( · 1 unmodified line
197198 deps,
198199 input.source.workspaceId,
199200 );
200− const syncResult = catalogSyncResultFromMcpManifestEntries({
201+ const syncResult = catalogSyncResultFromMcpManifest({
201202 source: input.source,
202203 endpoint: input.source.endpoint,
203− manifestEntries: input.manifestEntries,
204+ manifest: input.manifest,
204205 });
205206
206207 yield* deps.sourceArtifactStore.write({
@@ -249,7 +250,7 @@ export const syncSourceCatalog = (input: { · 42 unmodified lines
249250
250251 export const persistMcpCatalogSnapshotFromManifest = (input: {
251252 source: Source;
252− manifestEntries: Parameters<typeof catalogSyncResultFromMcpManifestEntries>[0]["manifestEntries"];
253+ manifest: McpToolManifest;
253254 }): Effect.Effect<void, Error, SourceCatalogSyncServices> =>
254255 Effect.gen(function* () {
255256 const runtimeLocalWorkspace = yield* RuntimeLocalWorkspaceService;