From 7f0b748126e101ce3c4d2585ff11a6848063db94 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Thu, 13 Aug 2026 01:36:21 +0300 Subject: [PATCH 01/10] feat: emit TypeScript call signatures via emitTypeSignatures (metadata.typescript) --- CLAUDE.md | 1 + README.md | 1 + docs/api-reference.md | 9 + docs/configuration.md | 1 + docs/type-signatures.md | 81 ++++++ src/__tests__/generator.spec.ts | 65 +++++ src/__tests__/type-signature.spec.ts | 315 +++++++++++++++++++++ src/generator.ts | 14 + src/index.ts | 4 + src/type-signature.ts | 396 +++++++++++++++++++++++++++ src/types.ts | 21 ++ 11 files changed, 908 insertions(+) create mode 100644 docs/type-signatures.md create mode 100644 src/__tests__/type-signature.spec.ts create mode 100644 src/type-signature.ts diff --git a/CLAUDE.md b/CLAUDE.md index 34385c4..03834dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,6 +41,7 @@ OpenAPIToolGenerator (src/generator.ts) | `src/overlay.ts` | OpenAPI Overlay 1.0 application with a JSONPath subset (filters, recursive descent); `OverlayError` | | `src/lint.ts` | `lintDocument` agent-readiness findings (severity + fix hints); `PAGINATION_PARAM` shared regex | | `src/sdk.ts` | `toSdkTool` — registerTool-shaped output for the official MCP SDK (no SDK dependency) | +| `src/type-signature.ts` | `emitToolTypeScript` — TypeScript signature/declaration rendering of a tool's call contract (`emitTypeSignatures` option → `metadata.typescript`) | | `src/parameter-resolver.ts` | Resolves OpenAPI parameters + requestBody into flat inputSchema with conflict resolution; flattens `allOf` bodies, flags `wholeBody`/`binary` | | `src/response-builder.ts` | Builds outputSchema from OpenAPI responses with content-type and status code preferences | | `src/format-resolver.ts` | Format-to-schema resolution. Built-in resolvers for uuid, date-time, email, int32, etc. | diff --git a/README.md b/README.md index 16ec970..c2ed122 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,7 @@ for (const tool of await generator.generateTools({ target: "claude" })) { | [Request Builder](https://github.com/agentfront/mcp-from-openapi/blob/main/docs/request-builder.md) | `buildHttpRequest` — full OpenAPI parameter serialization | | [Client Targets](https://github.com/agentfront/mcp-from-openapi/blob/main/docs/client-targets.md) | Per-client schema dialects (Claude, OpenAI, Gemini) | | [Curation](https://github.com/agentfront/mcp-from-openapi/blob/main/docs/curation.md) | Token budgets, overlays, lint, trimming, response hints | +| [Type Signatures](https://github.com/agentfront/mcp-from-openapi/blob/main/docs/type-signatures.md) | TypeScript call contracts for code-execution surfaces | | [Response Schemas](https://github.com/agentfront/mcp-from-openapi/blob/main/docs/response-schemas.md) | Output schemas, status codes, oneOf unions | | [Annotations & Extensions](https://github.com/agentfront/mcp-from-openapi/blob/main/docs/annotations.md) | Tool title, annotation inference, `x-mcp` extension family | | [Security](https://github.com/agentfront/mcp-from-openapi/blob/main/docs/security.md) | SecurityResolver, all auth types, custom resolvers | diff --git a/docs/api-reference.md b/docs/api-reference.md index 165dd44..b494612 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -141,6 +141,15 @@ Apply a client dialect's schema transforms (`'claude' | 'openai' | 'gemini' | 's applyClientTarget(schema: JsonSchema, target: ClientTarget): JsonSchema ``` +### emitToolTypeScript / toPascalIdentifier + +Render a tool's call contract as TypeScript text (one-line `signature` + self-contained `declaration`). Also emitted during generation via `GenerateOptions.emitTypeSignatures` as `metadata.typescript`. See [Type Signatures](./type-signatures.md). + +```typescript +emitToolTypeScript(toolName: string, description: string | undefined, inputSchema: JsonSchema, outputSchema?: JsonSchema, options?: TypeSignatureOptions): ToolTypeScriptInfo +toPascalIdentifier(toolName: string): string +``` + ### analyzeToolSet / estimateToolTokens Context-budget analysis: per-tool token estimates (heaviest first) and curation warnings. See [Curation](./curation.md). diff --git a/docs/configuration.md b/docs/configuration.md index cae1939..49d7c41 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -89,6 +89,7 @@ const tools = await generator.generateTools({ | `maxProperties` | `number` | - | Cap object nodes to their first N properties (drop noted); root input params never dropped | | `maxDescriptionLength` | `number` | - | Ellipsis-truncate every schema description at N chars | | `stripExamples` | `boolean` | `false` | Remove all `examples` arrays from generated schemas | +| `emitTypeSignatures` | `boolean` | `false` | Render `metadata.typescript = { signature, declaration }` — see [Type Signatures](./type-signatures.md) | ### Filtering Operations diff --git a/docs/type-signatures.md b/docs/type-signatures.md new file mode 100644 index 0000000..e7b083c --- /dev/null +++ b/docs/type-signatures.md @@ -0,0 +1,81 @@ +# TypeScript Call Signatures + +[Home](../README.md) | [Configuration](./configuration.md) | [API Reference](./api-reference.md) + +--- + +Code-execution surfaces (like FrontMCP CodeCall) present tools to the model as importable, typed functions instead of raw tool JSON — Anthropic measured a ~98.7% token reduction for this pattern. The library can render each tool's call contract as TypeScript text, computed from the **final** schemas (after format resolution, depth truncation, trimming, and client-target transforms), so the emitted types match exactly what the tool accepts and returns. + +## Emitting signatures + +```typescript +const tools = await generator.generateTools({ emitTypeSignatures: true }); + +tools[0].metadata.typescript?.signature; +// "(input: { id: string; limit?: number }) => Promise<{ name: string }>" + +tools[0].metadata.typescript?.declaration; +// /** Get a user */ +// interface GetUserInput { +// /** @format uuid */ +// id: string; +// limit?: number; +// } +// +// interface GetUserOutput { +// name: string; +// } +// +// declare function getUser(input: GetUserInput): Promise; +``` + +`signature` is a one-line arrow type with inline anonymous types — suitable for compact tool listings. `declaration` is a self-contained block: JSDoc from the tool description and schema `description`/`format`/`default`/`deprecated` fields, named `Input` / `Output` types (PascalCase from the tool name), and a `declare function` using the camelCase form of the name. Both are deterministic: the same tool always renders the same text. + +When collision dedup renames a tool during `generateTools()`, the declaration is recomputed with the final name, so the type names never drift from `tool.name`. + +## The unwrapped-return contract + +The emitted return type is always the **unwrapped OpenAPI response type**. Frameworks that wrap tool results (for example FrontMCP's `{ status, ok, data, error }` envelope) apply that wrapper *after* this library — they must wrap the emitted type themselves. This keeps the library's output framework-neutral. + +## Standalone usage + +The printer is exported for use outside generation: + +```typescript +import { emitToolTypeScript, toPascalIdentifier } from 'mcp-from-openapi'; + +const { signature, declaration } = emitToolTypeScript( + 'users.get', // tool name → UsersGetInput / UsersGetOutput / usersGet + 'Fetch a user.', // optional description → leading JSDoc + tool.inputSchema, + tool.outputSchema, + { maxDepth: 8 }, // optional; default 8 +); + +toPascalIdentifier('3d.scan'); // "T3dScan" +``` + +## Printing rules + +| Schema construct | TypeScript | +| ---------------- | ---------- | +| `string` / `boolean` / `null` | `string` / `boolean` / `null` | +| `number` / `integer` | `number` (`format` becomes a JSDoc `@format` hint) | +| `enum` | literal union (`"a" \| "b" \| 1`) | +| primitive `const` | literal type | +| `nullable` wrapper (`anyOf: [X, {type:'null'}]`) | `X \| null` | +| `oneOf` / `anyOf` | union; root output status variants gain `/** status 200 (application/json) */` comments from `x-status-code` / `x-content-type` | +| `allOf` | intersection (`A & B`) | +| `array` + `items` | `T[]`; `prefixItems` become tuples (`[string, ...number[]]`) | +| object with `properties` | object type; optionality from `required`; non-identifier keys quoted | +| bare object | `Record` (`Record` when `additionalProperties: false`) | +| typed `additionalProperties` / `patternProperties` | `Record` (intersected when properties also exist) | +| boolean schemas | `true` → `unknown`, `false` → `never` | +| `$ref` leftovers | `unknown` (declarations are always self-contained) | +| cycles / nesting beyond `maxDepth` (printer default 8; generated schemas are already depth-truncated at 10) | `unknown` | + +All `x-` annotation keywords (`x-parameter-location`, `x-status-code`, `x-mcp-header`, …) are ignored for typing. + +--- + +**Related:** [Configuration](./configuration.md) | [Curation](./curation.md) | [Client Targets](./client-targets.md) diff --git a/src/__tests__/generator.spec.ts b/src/__tests__/generator.spec.ts index 9c971ed..369f61f 100644 --- a/src/__tests__/generator.spec.ts +++ b/src/__tests__/generator.spec.ts @@ -1,4 +1,5 @@ import { OpenAPIToolGenerator } from '../generator'; +import { toPascalIdentifier } from '../type-signature'; import { ParameterResolver } from '../parameter-resolver'; import { ResponseBuilder } from '../response-builder'; import { ParseError, LoadError } from '../errors'; @@ -4489,3 +4490,67 @@ describe('OverlayError identity through factory methods', () => { } }); }); + +describe('TypeScript signature emission (emitTypeSignatures)', () => { + const spec: any = { + openapi: '3.0.0', + info: { title: 'Sig API', version: '1.0.0' }, + paths: { + '/users/{id}': { + get: { + operationId: 'getUser', + summary: 'Get a user', + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }, + }, + }, + }, + }, + }, + }, + }, + }; + + it('emits metadata.typescript from the final schemas when enabled', async () => { + const generator = await OpenAPIToolGenerator.fromJSON(spec, { validate: false }); + const tool = await generator.generateTool('/users/{id}', 'get', { emitTypeSignatures: true }); + + expect(tool.metadata.typescript).toBeDefined(); + expect(tool.metadata.typescript?.signature).toBe('(input: { id: string }) => Promise<{ name: string }>'); + expect(tool.metadata.typescript?.declaration).toContain('interface GetUserInput {'); + expect(tool.metadata.typescript?.declaration).toContain('declare function getUser(input: GetUserInput): Promise;'); + // the tool description leads the declaration as JSDoc + expect(tool.metadata.typescript?.declaration).toContain('/** Get a user */'); + }); + + it('does not emit metadata.typescript by default', async () => { + const generator = await OpenAPIToolGenerator.fromJSON(spec, { validate: false }); + const tool = await generator.generateTool('/users/{id}', 'get'); + expect(tool.metadata.typescript).toBeUndefined(); + }); + + it('recomputes the declaration when collision dedup renames a tool', async () => { + const dupSpec: any = { + openapi: '3.0.0', + info: { title: 'Dup API', version: '1.0.0' }, + paths: { + '/a': { get: { operationId: 'dupOp', responses: { '200': { description: 'OK' } } } }, + '/b': { post: { operationId: 'dupOp', responses: { '200': { description: 'OK' } } } }, + }, + }; + const generator = await OpenAPIToolGenerator.fromJSON(dupSpec, { validate: false }); + const tools = await generator.generateTools({ emitTypeSignatures: true }); + + expect(tools[1].name).toMatch(/^dupOp_[0-9a-f]{8}$/); + expect(tools[0].metadata.typescript?.declaration).toContain('DupOpInput'); + const dedupedPascal = toPascalIdentifier(tools[1].name); + expect(dedupedPascal).not.toBe('DupOp'); + expect(tools[1].metadata.typescript?.declaration).toContain(`${dedupedPascal}Input`); + expect(tools[1].metadata.typescript?.declaration).not.toContain('DupOpInput ='); + }); +}); diff --git a/src/__tests__/type-signature.spec.ts b/src/__tests__/type-signature.spec.ts new file mode 100644 index 0000000..08e4ab3 --- /dev/null +++ b/src/__tests__/type-signature.spec.ts @@ -0,0 +1,315 @@ +/** Tests for TypeScript call-signature emission */ +import { emitToolTypeScript, toPascalIdentifier } from '../type-signature'; +import type { JsonSchema } from '../types'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +const sig = (input: any, output?: any, options?: any): string => + emitToolTypeScript('t', undefined, input as JsonSchema, output as JsonSchema | undefined, options).signature; + +const inputType = (input: any, options?: any): string => { + const m = sig(input, undefined, options).match(/^\((?:input\??: )?(.*?)\) => /); + return m ? (m[1] ?? '') : ''; +}; + +const outputType = (output: any, options?: any): string => + sig({ type: 'object', properties: { a: { type: 'string' } } }, output, options).replace(/^.* => Promise<(.*)>$/s, '$1'); + +describe('toPascalIdentifier', () => { + it('pascal-cases dotted, dashed, and underscored tool names', () => { + expect(toPascalIdentifier('users.get_by-id')).toBe('UsersGetById'); + expect(toPascalIdentifier('getUser')).toBe('GetUser'); + }); + + it('falls back to Tool for names with no alphanumerics', () => { + expect(toPascalIdentifier('...')).toBe('Tool'); + }); + + it('prefixes T when the result starts with a digit', () => { + expect(toPascalIdentifier('3d.scan')).toBe('T3dScan'); + }); +}); + +describe('emitToolTypeScript type printing', () => { + it('prints scalar types and maps integer to number', () => { + expect(outputType({ type: 'string' })).toBe('string'); + expect(outputType({ type: 'integer' })).toBe('number'); + expect(outputType({ type: 'number' })).toBe('number'); + expect(outputType({ type: 'boolean' })).toBe('boolean'); + expect(outputType({ type: 'null' })).toBe('null'); + }); + + it('prints boolean schemas as unknown/never', () => { + expect(outputType(true)).toBe('unknown'); + expect(outputType(false)).toBe('never'); + expect(outputType({ type: 'object', properties: { a: true, b: false } })).toBe('{ a?: unknown; b?: never }'); + }); + + it('prints unknown for non-object schemas and unrecognized types', () => { + expect(outputType('nonsense')).toBe('unknown'); + expect(outputType({ type: 'mystery' })).toBe('unknown'); + }); + + it('prints $ref leftovers as unknown and never prints $defs', () => { + expect(outputType({ $ref: '#/$defs/User' })).toBe('unknown'); + expect(outputType({ type: 'object', properties: { u: { $ref: '#/x' } }, $defs: { x: { type: 'string' } } })).toBe( + '{ u?: unknown }', + ); + }); + + it('ignores x- annotation keywords for typing', () => { + expect( + outputType({ type: 'string', 'x-parameter-location': 'header', 'x-status-code': 200, 'x-content-type': 'a/b' }), + ).toBe('string'); + }); + + it('prints primitive consts as literals and falls through for object consts', () => { + expect(outputType({ const: 'fixed' })).toBe('"fixed"'); + expect(outputType({ const: 42 })).toBe('42'); + expect(outputType({ const: false })).toBe('false'); + expect(outputType({ const: null })).toBe('null'); + expect(outputType({ const: { a: 1 }, type: 'object', properties: { a: { type: 'number' } } })).toBe( + '{ a?: number }', + ); + }); + + it('prints enums as literal unions with dedupe and unknown for non-primitive members', () => { + expect(outputType({ enum: ['a', 'b', 'a', 1, true, null, { bad: 1 }] })).toBe('"a" | "b" | 1 | true | null | unknown'); + expect(outputType({ enum: [] })).toBe('unknown'); + expect(outputType({ type: 'integer', enum: [1, 2] })).toBe('1 | 2'); + }); + + it('recognizes the nullable anyOf wrapper in either order', () => { + expect(outputType({ anyOf: [{ type: 'string' }, { type: 'null' }] })).toBe('string | null'); + expect(outputType({ anyOf: [{ type: 'null' }, { type: 'integer' }] })).toBe('number | null'); + expect(outputType({ anyOf: [{ type: 'null' }, { type: 'null' }] })).toBe('null'); + }); + + it('parenthesizes union members inside the nullable wrapper', () => { + expect(outputType({ anyOf: [{ oneOf: [{ type: 'string' }, { type: 'number' }] }, { type: 'null' }] })).toBe( + '(string | number) | null', + ); + }); + + it('prints allOf as an intersection including local properties', () => { + expect(outputType({ allOf: [{ type: 'object', properties: { a: { type: 'string' } }, required: ['a'] }] })).toBe( + '{ a: string }', + ); + expect( + outputType({ + allOf: [{ type: 'object', properties: { a: { type: 'string' } } }], + properties: { b: { type: 'number' } }, + }), + ).toBe('{ a?: string } & { b?: number }'); + expect(outputType({ allOf: [] })).toBe('unknown'); + }); + + it('prints oneOf/anyOf as deduplicated unions', () => { + expect(outputType({ oneOf: [{ type: 'string' }, { type: 'number' }, { type: 'string' }] })).toBe('string | number'); + expect(outputType({ anyOf: [{ type: 'string' }, { type: 'number' }, { type: 'boolean' }] })).toBe( + 'string | number | boolean', + ); + expect(outputType({ oneOf: [] })).toBe('unknown'); + }); + + it('prints type arrays as unions', () => { + expect(outputType({ type: ['string', 'null'] })).toBe('string | null'); + expect(outputType({ type: [] })).toBe('unknown'); + }); + + it('prints arrays with item types, parenthesizing unions', () => { + expect(outputType({ type: 'array', items: { type: 'string' } })).toBe('string[]'); + expect(outputType({ type: 'array' })).toBe('unknown[]'); + expect(outputType({ type: 'array', items: { oneOf: [{ type: 'string' }, { type: 'number' }] } })).toBe( + '(string | number)[]', + ); + }); + + it('prints tuples from prefixItems and legacy array items', () => { + expect(outputType({ type: 'array', prefixItems: [{ type: 'string' }, { type: 'number' }] })).toBe( + '[string, number]', + ); + expect( + outputType({ type: 'array', prefixItems: [{ type: 'string' }], items: { type: 'number' } }), + ).toBe('[string, ...number[]]'); + expect(outputType({ type: 'array', items: [{ type: 'string' }, { type: 'boolean' }] })).toBe('[string, boolean]'); + }); + + it('prints bare objects as Records keyed by additionalProperties', () => { + expect(outputType({ type: 'object' })).toBe('Record'); + expect(outputType({ type: 'object', additionalProperties: false })).toBe('Record'); + expect(outputType({ type: 'object', additionalProperties: true })).toBe('Record'); + expect(outputType({ type: 'object', additionalProperties: { type: 'number' } })).toBe('Record'); + expect(outputType({ type: 'object', patternProperties: { '^x': { type: 'string' } } })).toBe( + 'Record', + ); + }); + + it('treats type-less schemas with object keywords as objects', () => { + expect(outputType({ properties: { a: { type: 'string' } } })).toBe('{ a?: string }'); + expect(outputType({ additionalProperties: { type: 'string' } })).toBe('Record'); + expect(outputType({ patternProperties: { '^x': { type: 'number' } } })).toBe('Record'); + expect(outputType({ format: 'opaque' })).toBe('unknown'); + }); + + it('appends a Record intersection for typed additionalProperties beside properties', () => { + expect( + outputType({ + type: 'object', + properties: { a: { type: 'string' } }, + required: ['a'], + additionalProperties: { type: 'number' }, + patternProperties: { '^x': { type: 'boolean' } }, + }), + ).toBe('{ a: string } & Record'); + }); + + it('quotes property names that are not valid identifiers', () => { + expect(outputType({ type: 'object', properties: { 'content-type': { type: 'string' }, ok$_1: { type: 'boolean' } } })).toBe( + '{ "content-type"?: string; ok$_1?: boolean }', + ); + }); + + it('collapses true cycles to unknown while diamond-shared nodes print fully', () => { + const cyclic: any = { type: 'object', properties: {} }; + cyclic.properties.self = cyclic; + expect(outputType(cyclic)).toBe('{ self?: unknown }'); + + const shared: any = { type: 'string' }; + expect(outputType({ type: 'object', properties: { a: shared, b: shared } })).toBe('{ a?: string; b?: string }'); + }); + + it('caps nesting at maxDepth and honors the option', () => { + const deep = { type: 'object', properties: { l1: { type: 'object', properties: { l2: { type: 'string' } } } } }; + expect(outputType(deep, { maxDepth: 2 })).toBe('{ l1?: { l2?: unknown } }'); + expect(outputType(deep, { maxDepth: 1 })).toBe('{ l1?: unknown }'); + expect(outputType(deep, { maxDepth: Number.NaN })).toBe('{ l1?: { l2?: string } }'); + }); +}); + +describe('emitToolTypeScript assembly', () => { + it('builds the signature parameter form from the input schema', () => { + expect(sig({ type: 'object', properties: {} })).toBe('() => Promise'); + expect(sig('not-a-schema')).toBe('() => Promise'); + expect(sig({ type: 'object', properties: { a: { type: 'string' } } })).toBe( + '(input?: { a?: string }) => Promise', + ); + expect(sig({ type: 'object', properties: { a: { type: 'string' } }, required: ['a'] })).toBe( + '(input: { a: string }) => Promise', + ); + }); + + it('emits a complete self-contained declaration with JSDoc', () => { + const { declaration } = emitToolTypeScript( + 'users.get', + 'Fetch a user.\nSecond line with */ inside.', + { + type: 'object', + properties: { + id: { type: 'string', description: 'The user id', format: 'uuid' }, + verbose: { type: 'boolean', default: false, deprecated: true }, + }, + required: ['id'], + } as JsonSchema, + { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] } as JsonSchema, + ); + expect(declaration).toBe( + [ + '/**', + ' * Fetch a user.', + ' * Second line with *\\/ inside.', + ' */', + '', + 'interface UsersGetInput {', + ' /**', + ' * The user id', + ' * @format uuid', + ' */', + ' id: string;', + ' /**', + ' * @default false', + ' * @deprecated', + ' */', + ' verbose?: boolean;', + '}', + '', + 'interface UsersGetOutput {', + ' name: string;', + '}', + '', + 'declare function usersGet(input: UsersGetInput): Promise;', + ].join('\n'), + ); + }); + + it('renders single-line JSDoc compactly and skips unserializable defaults', () => { + const { declaration } = emitToolTypeScript( + 't', + undefined, + { + type: 'object', + properties: { a: { type: 'string', description: 'One line', default: undefined } }, + } as JsonSchema, + undefined, + ); + expect(declaration).toContain(' /** One line */\n a?: string;'); + expect(declaration).not.toContain('@default'); + }); + + it('uses type aliases for non-object roots and unknown output when absent', () => { + const { declaration } = emitToolTypeScript('t', undefined, { type: 'object' } as JsonSchema, { + type: 'string', + } as JsonSchema); + expect(declaration).toContain('type TInput = Record;'); + expect(declaration).toContain('type TOutput = string;'); + + const none = emitToolTypeScript('t', undefined, { type: 'object', properties: {} } as JsonSchema, undefined); + expect(none.declaration).toContain('type TOutput = unknown;'); + expect(none.declaration).toContain('declare function t(): Promise;'); + }); + + it('uses a type alias when an intersection suffix prevents an interface body', () => { + const { declaration } = emitToolTypeScript( + 't', + undefined, + { + type: 'object', + properties: { a: { type: 'string' } }, + additionalProperties: { type: 'number' }, + } as JsonSchema, + undefined, + ); + expect(declaration).toContain('type TInput = {\n a?: string;\n} & Record;'); + }); + + it('annotates root output status variants from x-status-code', () => { + const { declaration } = emitToolTypeScript('t', undefined, { type: 'object', properties: {} } as JsonSchema, { + oneOf: [ + { type: 'object', properties: { ok: { type: 'boolean' } }, 'x-status-code': 200, 'x-content-type': 'application/json' }, + { type: 'object', properties: { error: { type: 'string' } }, 'x-status-code': '404' }, + { type: 'string' }, + true, + ], + } as JsonSchema); + expect(declaration).toContain('type TOutput ='); + expect(declaration).toContain(' | /** status 200 (application/json) */ {'); + expect(declaration).toContain(' | /** status 404 */ {'); + expect(declaration).toContain(' | string'); + expect(declaration).toContain(' | unknown'); + }); + + it('falls back to a plain union for root oneOf without status codes', () => { + const { declaration } = emitToolTypeScript('t', undefined, { type: 'object', properties: {} } as JsonSchema, { + oneOf: [{ type: 'string' }, { type: 'number' }], + } as JsonSchema); + expect(declaration).toContain('type TOutput = string | number;'); + }); + + it('is deterministic across calls', () => { + const input = { type: 'object', properties: { a: { type: 'string' } } } as JsonSchema; + const output = { oneOf: [{ type: 'string' }, { type: 'number' }] } as JsonSchema; + const first = emitToolTypeScript('users.get', 'd', input, output); + const second = emitToolTypeScript('users.get', 'd', input, output); + expect(second).toEqual(first); + }); +}); diff --git a/src/generator.ts b/src/generator.ts index 3e29b63..0d3b27b 100644 --- a/src/generator.ts +++ b/src/generator.ts @@ -34,6 +34,7 @@ import { lintDocument, PAGINATION_PARAM, type LintResult } from './lint'; import { Validator } from './validator'; import { GenerationError, LoadError, OverlayError, ParseError } from './errors'; import { BUILTIN_FORMAT_RESOLVERS, resolveSchemaFormats } from './format-resolver'; +import { emitToolTypeScript } from './type-signature'; import { isBlockedHostname, normalizeSsrfOptions, safeFetch } from './ssrf'; /** MCP hard limit for tool name length (spec revision 2025-11-25, SEP-986) */ @@ -722,6 +723,14 @@ export class OpenAPIToolGenerator { attempts++; } tool = { ...tool, name: deduped }; + // The TypeScript declaration derives its type names from the tool + // name — recompute it so a dedup rename can't leave them stale. + if (tool.metadata.typescript) { + tool.metadata = { + ...tool.metadata, + typescript: emitToolTypeScript(deduped, tool.description, tool.inputSchema, tool.outputSchema), + }; + } } usedNames.add(tool.name); tools.push(tool); @@ -890,6 +899,11 @@ export class OpenAPIToolGenerator { } } + // TypeScript call contract (computed on the FINAL schemas) + if (options.emitTypeSignatures) { + metadata.typescript = emitToolTypeScript(name, finalDescription, resolvedInputSchema, resolvedOutputSchema); + } + return { name, ...(title !== undefined && { title }), diff --git a/src/index.ts b/src/index.ts index ba3d772..42542c0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,10 @@ export { SecurityResolver, createSecurityContext } from './security-resolver'; export { BUILTIN_FORMAT_RESOLVERS, resolveSchemaFormats } from './format-resolver'; export { inferAnnotationsFromMethod, extractExtensionOverrides, resolveExtensionEnabled } from './annotations'; export type { ExtensionToolOverrides } from './annotations'; + +// TypeScript call-signature emission +export { emitToolTypeScript, toPascalIdentifier } from './type-signature'; +export type { ToolTypeScriptInfo, TypeSignatureOptions } from './type-signature'; export { applyClientTarget, inlineLocalRefs, diff --git a/src/type-signature.ts b/src/type-signature.ts new file mode 100644 index 0000000..0353f42 --- /dev/null +++ b/src/type-signature.ts @@ -0,0 +1,396 @@ +/** + * TypeScript call-signature emission. + * + * Renders a tool's final JSON Schemas (2020-12, post client-target transforms) + * as TypeScript type text for code-execution surfaces such as FrontMCP + * CodeCall, which present tools as importable typed functions instead of raw + * tool JSON. The emitted return type is always the UNWRAPPED OpenAPI response + * type — consumers that wrap results (e.g. `{status, ok, data, error}`) must + * wrap the type themselves. + */ +import type { JsonSchema } from './types'; + +/** TypeScript rendering of one tool's call contract. */ +export interface ToolTypeScriptInfo { + /** + * One-line arrow type with inline anonymous types, e.g. + * `(input: { id: string; limit?: number }) => Promise<{ name: string }>` + */ + signature: string; + /** + * Self-contained declaration text: JSDoc from schema descriptions, named + * `Input` / `Output` types, and a `declare function`. + */ + declaration: string; +} + +/** Options for the type-signature printer. */ +export interface TypeSignatureOptions { + /** + * Nesting depth beyond which types collapse to `unknown`. + * @default 8 + */ + maxDepth?: number; +} + +const DEFAULT_MAX_DEPTH = 8; +const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/; + +type SchemaRecord = Record; + +interface PrintContext { + mode: 'compact' | 'pretty'; + maxDepth: number; + /** Ancestor schemas on the current path — true cycles collapse to `unknown`, + * diamond-shared nodes still print fully. */ + stack: Set; +} + +/** + * Derive a PascalCase TypeScript identifier from an MCP tool name + * (`[A-Za-z0-9_.-]`). Empty results become `Tool`; a leading digit is + * prefixed with `T` (`3d.scan` → `T3dScan`). + */ +export function toPascalIdentifier(toolName: string): string { + const segments = toolName.split(/[^A-Za-z0-9]+/).filter((s) => s.length > 0); + const joined = segments.map((s) => s[0].toUpperCase() + s.slice(1)).join(''); + if (joined === '') { + return 'Tool'; + } + return /^[0-9]/.test(joined) ? `T${joined}` : joined; +} + +function lowerFirst(name: string): string { + return name[0].toLowerCase() + name.slice(1); +} + +function isSchemaRecord(value: unknown): value is SchemaRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isNullSchema(value: unknown): boolean { + return isSchemaRecord(value) && value['type'] === 'null'; +} + +/** Wrap union/intersection expressions in parentheses where composition + * requires it; over-parenthesizing is valid TS, so the check is conservative. */ +function paren(expr: string): string { + return expr.includes(' | ') || expr.includes(' & ') ? `(${expr})` : expr; +} + +function dedupe(parts: string[]): string[] { + return [...new Set(parts)]; +} + +function quoteKey(name: string): string { + return IDENTIFIER.test(name) ? name : JSON.stringify(name); +} + +function literalOf(value: unknown): string { + if (value === null) { + return 'null'; + } + const t = typeof value; + if (t === 'string' || t === 'number' || t === 'boolean') { + return JSON.stringify(value); + } + return 'unknown'; +} + +function escapeJsdoc(text: string): string { + return text.replace(/\*\//g, '*\\/'); +} + +/** JSDoc lines for a property in pretty mode (description, @format, @default, + * @deprecated) — empty array when there is nothing to say. */ +function jsdocLines(prop: unknown): string[] { + if (!isSchemaRecord(prop)) { + return []; + } + const lines: string[] = []; + const description = prop['description']; + if (typeof description === 'string' && description !== '') { + lines.push(...escapeJsdoc(description).split('\n')); + } + const format = prop['format']; + if (typeof format === 'string' && format !== '') { + lines.push(`@format ${escapeJsdoc(format)}`); + } + if ('default' in prop) { + const rendered = JSON.stringify(prop['default']); + if (rendered !== undefined) { + lines.push(`@default ${escapeJsdoc(rendered)}`); + } + } + if (prop['deprecated'] === true) { + lines.push('@deprecated'); + } + return lines; +} + +function renderJsdoc(lines: string[], indent: string): string { + if (lines.length === 1) { + return `${indent}/** ${lines[0]} */\n`; + } + return `${indent}/**\n${lines.map((l) => `${indent} * ${l}`).join('\n')}\n${indent} */\n`; +} + +function hasObjectShape(r: SchemaRecord): boolean { + return ( + r['type'] === 'object' || + (r['type'] === undefined && + (r['properties'] !== undefined || r['additionalProperties'] !== undefined || r['patternProperties'] !== undefined)) + ); +} + +function typeExpr(schema: unknown, ctx: PrintContext, depth: number, indent: string): string { + if (schema === true) { + return 'unknown'; + } + if (schema === false) { + return 'never'; + } + if (!isSchemaRecord(schema)) { + return 'unknown'; + } + if (ctx.stack.has(schema)) { + return 'unknown'; + } + if (depth >= ctx.maxDepth) { + return 'unknown'; + } + if (schema['$ref'] !== undefined) { + return 'unknown'; + } + ctx.stack.add(schema); + try { + return typeExprInner(schema, ctx, depth, indent); + } finally { + ctx.stack.delete(schema); + } +} + +function typeExprInner(r: SchemaRecord, ctx: PrintContext, depth: number, indent: string): string { + // Literals win over structural typing + if ('const' in r) { + const rendered = literalOf(r['const']); + if (rendered !== 'unknown') { + return rendered; + } + // non-primitive const: fall through to structural rules + } + const enumMembers = r['enum']; + if (Array.isArray(enumMembers)) { + if (enumMembers.length === 0) { + return 'unknown'; + } + return dedupe(enumMembers.map(literalOf)).join(' | '); + } + + // Nullable wrapper produced by toJsonSchema: anyOf [X, {type:'null'}] + const anyOf = r['anyOf']; + if (Array.isArray(anyOf) && anyOf.length === 2) { + const nullIdx = anyOf.findIndex(isNullSchema); + if (nullIdx >= 0 && !isNullSchema(anyOf[1 - nullIdx])) { + return `${paren(typeExpr(anyOf[1 - nullIdx], ctx, depth + 1, indent))} | null`; + } + } + + const allOf = r['allOf']; + if (Array.isArray(allOf)) { + const parts = allOf.map((m) => paren(typeExpr(m, ctx, depth + 1, indent))); + if (r['properties'] !== undefined) { + parts.push(paren(objectExpr(r, ctx, depth, indent))); + } + return parts.length === 0 ? 'unknown' : dedupe(parts).join(' & '); + } + + const union = Array.isArray(r['oneOf']) ? (r['oneOf'] as unknown[]) : Array.isArray(anyOf) ? anyOf : undefined; + if (union) { + if (union.length === 0) { + return 'unknown'; + } + return dedupe(union.map((m) => typeExpr(m, ctx, depth + 1, indent))).join(' | '); + } + + const type = r['type']; + if (Array.isArray(type)) { + const parts = type.map((t) => typeExpr({ ...r, type: t }, ctx, depth, indent)); + return parts.length === 0 ? 'unknown' : dedupe(parts).join(' | '); + } + switch (type) { + case 'string': + return 'string'; + case 'number': + case 'integer': + return 'number'; + case 'boolean': + return 'boolean'; + case 'null': + return 'null'; + case 'array': + return arrayExpr(r, ctx, depth, indent); + default: + if (hasObjectShape(r)) { + return objectExpr(r, ctx, depth, indent); + } + return 'unknown'; + } +} + +function arrayExpr(r: SchemaRecord, ctx: PrintContext, depth: number, indent: string): string { + const items = r['items']; + const prefix = Array.isArray(r['prefixItems']) ? (r['prefixItems'] as unknown[]) : Array.isArray(items) ? items : undefined; + if (prefix) { + const parts = prefix.map((m) => typeExpr(m, ctx, depth + 1, indent)); + let rest = ''; + // 2020-12: `items` beside `prefixItems` types the remaining elements + if (Array.isArray(r['prefixItems']) && items !== undefined && !Array.isArray(items)) { + rest = `, ...${paren(typeExpr(items, ctx, depth + 1, indent))}[]`; + } + return `[${parts.join(', ')}${rest}]`; + } + if (items === undefined) { + return 'unknown[]'; + } + return `${paren(typeExpr(items, ctx, depth + 1, indent))}[]`; +} + +function objectExpr(r: SchemaRecord, ctx: PrintContext, depth: number, indent: string): string { + const properties = isSchemaRecord(r['properties']) ? (r['properties'] as SchemaRecord) : {}; + const entries = Object.entries(properties); + const required = new Set(Array.isArray(r['required']) ? (r['required'] as unknown[]) : []); + + // Value type for unnamed keys: additionalProperties plus patternProperties + const extraTypes: string[] = []; + const ap = r['additionalProperties']; + if (ap === true) { + extraTypes.push('unknown'); + } else if (isSchemaRecord(ap)) { + extraTypes.push(typeExpr(ap, ctx, depth + 1, indent)); + } + const patternProps = r['patternProperties']; + if (isSchemaRecord(patternProps)) { + for (const value of Object.values(patternProps)) { + extraTypes.push(typeExpr(value, ctx, depth + 1, indent)); + } + } + const extra = extraTypes.length > 0 ? dedupe(extraTypes).join(' | ') : undefined; + + if (entries.length === 0) { + if (extra !== undefined) { + return `Record`; + } + return ap === false ? 'Record' : 'Record'; + } + + const suffix = extra !== undefined ? ` & Record` : ''; + if (ctx.mode === 'compact') { + const members = entries.map( + ([key, prop]) => `${quoteKey(key)}${required.has(key) ? '' : '?'}: ${typeExpr(prop, ctx, depth + 1, indent)}`, + ); + return `{ ${members.join('; ')} }${suffix}`; + } + + const inner = indent + ' '; + let body = '{\n'; + for (const [key, prop] of entries) { + const doc = jsdocLines(prop); + if (doc.length > 0) { + body += renderJsdoc(doc, inner); + } + body += `${inner}${quoteKey(key)}${required.has(key) ? '' : '?'}: ${typeExpr(prop, ctx, depth + 1, inner)};\n`; + } + body += `${indent}}`; + return `${body}${suffix}`; +} + +/** Render the parameter list for the signature / declare-function forms. */ +function paramList(inputSchema: JsonSchema, typeText: string): string { + const r = isSchemaRecord(inputSchema) ? inputSchema : undefined; + const properties = r && isSchemaRecord(r['properties']) ? (r['properties'] as SchemaRecord) : {}; + const keys = Object.keys(properties); + if (keys.length === 0) { + return '()'; + } + const required = new Set(Array.isArray(r?.['required']) ? (r?.['required'] as unknown[]) : []); + const allOptional = keys.every((k) => !required.has(k)); + return allOptional ? `(input?: ${typeText})` : `(input: ${typeText})`; +} + +/** Root output oneOf variants annotated from x-status-code / x-content-type. */ +function outputVariantsDeclaration(name: string, variants: unknown[], ctx: PrintContext): string { + const lines = variants.map((member) => { + let comment = ''; + if (isSchemaRecord(member)) { + const status = member['x-status-code']; + if (typeof status === 'number' || typeof status === 'string') { + const contentType = member['x-content-type']; + const ct = typeof contentType === 'string' ? ` (${contentType})` : ''; + comment = `/** status ${status}${ct} */ `; + } + } + return ` | ${comment}${typeExpr(member, ctx, 1, ' ')}`; + }); + return `type ${name} =\n${lines.join('\n')};`; +} + +/** + * Render a tool's TypeScript signature and self-contained declaration from + * its final (post-transform) schemas. Pure and deterministic. + */ +export function emitToolTypeScript( + toolName: string, + description: string | undefined, + inputSchema: JsonSchema, + outputSchema: JsonSchema | undefined, + options: TypeSignatureOptions = {}, +): ToolTypeScriptInfo { + const maxDepth = + typeof options.maxDepth === 'number' && Number.isFinite(options.maxDepth) + ? Math.max(1, Math.floor(options.maxDepth)) + : DEFAULT_MAX_DEPTH; + const compact: PrintContext = { mode: 'compact', maxDepth, stack: new Set() }; + const pretty: PrintContext = { mode: 'pretty', maxDepth, stack: new Set() }; + + const inputCompact = typeExpr(inputSchema, compact, 0, ''); + const outputCompact = outputSchema === undefined ? 'unknown' : typeExpr(outputSchema, compact, 0, ''); + const signature = `${paramList(inputSchema, inputCompact)} => Promise<${outputCompact}>`; + + const base = toPascalIdentifier(toolName); + const inputName = `${base}Input`; + const outputName = `${base}Output`; + const blocks: string[] = []; + + if (typeof description === 'string' && description !== '') { + blocks.push(renderJsdoc(escapeJsdoc(description).split('\n'), '').trimEnd()); + } + + const inputPretty = typeExpr(inputSchema, pretty, 0, ''); + blocks.push( + inputPretty.startsWith('{') && inputPretty.endsWith('}') + ? `interface ${inputName} ${inputPretty}` + : `type ${inputName} = ${inputPretty};`, + ); + + const outputUnion = + isSchemaRecord(outputSchema) && Array.isArray(outputSchema['oneOf']) + ? (outputSchema['oneOf'] as unknown[]) + : undefined; + if (outputSchema === undefined) { + blocks.push(`type ${outputName} = unknown;`); + } else if (outputUnion && outputUnion.some((m) => isSchemaRecord(m) && m['x-status-code'] !== undefined)) { + blocks.push(outputVariantsDeclaration(outputName, outputUnion, pretty)); + } else { + const outputPretty = typeExpr(outputSchema, pretty, 0, ''); + blocks.push( + outputPretty.startsWith('{') && outputPretty.endsWith('}') + ? `interface ${outputName} ${outputPretty}` + : `type ${outputName} = ${outputPretty};`, + ); + } + + blocks.push(`declare function ${lowerFirst(base)}${paramList(inputSchema, inputName)}: Promise<${outputName}>;`); + + return { signature, declaration: blocks.join('\n\n') }; +} diff --git a/src/types.ts b/src/types.ts index 18b0212..2d80298 100644 --- a/src/types.ts +++ b/src/types.ts @@ -523,6 +523,14 @@ export interface ToolMetadata { * present only when there is something to know. */ responseHints?: ResponseHints; + + /** + * TypeScript rendering of the tool's call contract — present when + * `GenerateOptions.emitTypeSignatures` is set. Computed on the FINAL + * schemas (after formats, depth truncation, trimming, and client-target + * transforms). The return type is the UNWRAPPED response type. + */ + typescript?: import('./type-signature').ToolTypeScriptInfo; } /** @@ -978,6 +986,19 @@ export interface GenerateOptions { * When used without `resolveFormats`, only custom resolvers are applied. */ formatResolvers?: Record; + + /** + * Emit a TypeScript rendering of each tool's call contract as + * `metadata.typescript = { signature, declaration }`. The signature is a + * one-line arrow type with inline anonymous types; the declaration is a + * self-contained block with named `Input` / `Output` + * types and JSDoc from schema descriptions. Computed on the FINAL schemas + * (after format resolution, depth truncation, trimming, and client-target + * transforms). The return type is the unwrapped OpenAPI response type — + * consumers that wrap results must wrap the type themselves. + * @default false + */ + emitTypeSignatures?: boolean; } /** From 292afae0f3531b5c6fa886abae05c97666bf464e Mon Sep 17 00:00:00 2001 From: David Antoon Date: Thu, 13 Aug 2026 01:43:13 +0300 Subject: [PATCH 02/10] feat: add dottedNaming preset for CodeCall-bindable ns.method tool names --- CLAUDE.md | 1 + docs/api-reference.md | 8 ++ docs/naming-strategies.md | 24 ++++- src/__tests__/naming-presets.spec.ts | 136 ++++++++++++++++++++++++ src/generator.ts | 11 +- src/index.ts | 4 + src/naming-presets.ts | 150 +++++++++++++++++++++++++++ src/parameter-resolver.ts | 7 +- src/types.ts | 11 +- 9 files changed, 340 insertions(+), 12 deletions(-) create mode 100644 src/__tests__/naming-presets.spec.ts create mode 100644 src/naming-presets.ts diff --git a/CLAUDE.md b/CLAUDE.md index 03834dc..8ec070c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,6 +42,7 @@ OpenAPIToolGenerator (src/generator.ts) | `src/lint.ts` | `lintDocument` agent-readiness findings (severity + fix hints); `PAGINATION_PARAM` shared regex | | `src/sdk.ts` | `toSdkTool` — registerTool-shaped output for the official MCP SDK (no SDK dependency) | | `src/type-signature.ts` | `emitToolTypeScript` — TypeScript signature/declaration rendering of a tool's call contract (`emitTypeSignatures` option → `metadata.typescript`) | +| `src/naming-presets.ts` | `dottedNaming` — two-segment `ns.method` naming preset for CodeCall namespace binding; `CODECALL_RESERVED_NAMESPACES` | | `src/parameter-resolver.ts` | Resolves OpenAPI parameters + requestBody into flat inputSchema with conflict resolution; flattens `allOf` bodies, flags `wholeBody`/`binary` | | `src/response-builder.ts` | Builds outputSchema from OpenAPI responses with content-type and status code preferences | | `src/format-resolver.ts` | Format-to-schema resolution. Built-in resolvers for uuid, date-time, email, int32, etc. | diff --git a/docs/api-reference.md b/docs/api-reference.md index b494612..c7d85ad 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -141,6 +141,14 @@ Apply a client dialect's schema transforms (`'claude' | 'openai' | 'gemini' | 's applyClientTarget(schema: JsonSchema, target: ClientTarget): JsonSchema ``` +### dottedNaming + +Naming preset producing two-segment `ns.method` tool names bindable by code-execution namespaces (FrontMCP CodeCall). See [Naming Strategies](./naming-strategies.md). + +```typescript +dottedNaming(options?: DottedNamingOptions): NamingStrategy +``` + ### emitToolTypeScript / toPascalIdentifier Render a tool's call contract as TypeScript text (one-line `signature` + self-contained `declaration`). Also emitted during generation via `GenerateOptions.emitTypeSignatures` as `metadata.typescript`. See [Type Signatures](./type-signatures.md). diff --git a/docs/naming-strategies.md b/docs/naming-strategies.md index 1477446..194100e 100644 --- a/docs/naming-strategies.md +++ b/docs/naming-strategies.md @@ -110,13 +110,12 @@ By default, tools are named using the operation's `operationId` (or an extension For example: `GET /users/{id}` becomes `get_users_By_id`. -Override with `toolNameGenerator` (output is still normalized as above): +Override with `toolNameGenerator` (output is still normalized as above). `conflictResolver` is optional — when omitted, the default location-prefix resolver applies. The generator also receives the full operation object as a fourth argument for tag-aware strategies: ```typescript const tools = await generator.generateTools({ namingStrategy: { - conflictResolver: (name, loc) => `${loc}${name.charAt(0).toUpperCase()}${name.slice(1)}`, - toolNameGenerator: (path, method, operationId) => { + toolNameGenerator: (path, method, operationId, operation) => { if (operationId) return operationId; // camelCase: getUsersById const parts = path.split('/').filter(Boolean); @@ -130,6 +129,25 @@ const tools = await generator.generateTools({ }); ``` +Note: an `x-mcp` family `name` override arrives through the `operationId` argument, in place of the operationId. + +## dottedNaming Preset + +Code-execution surfaces (FrontMCP CodeCall) bind tools named `ns.method` — exactly two identifier-safe segments — as ergonomic namespaces: `await billing.listInvoices({...})`. The `dottedNaming` preset produces that shape: + +```typescript +import { dottedNaming } from "mcp-from-openapi"; + +const tools = await generator.generateTools({ namingStrategy: dottedNaming() }); +// GET /invoices (tags: [billing], operationId: listInvoices) -> "billing.listInvoices" +// GET /users/{id} (no tags, no operationId) -> "users.get_by_id" +``` + +- **Namespace half**: the operation's first tag, falling back to the first path segment, then `api`. Configure with `namespaceFrom: 'tag' | 'firstPathSegment'` (default `'tag'`). +- **Method half**: the sanitized operationId, falling back to the HTTP method plus the remaining path segments (`{param}` becomes `by_`). +- **Reserved namespaces** (CodeCall sandbox globals such as `console`, `JSON`, `callTool` — the full list is exported as `CODECALL_RESERVED_NAMESPACES`) get an `_` suffix; add your own via `reservedNamespaces: [...]`. +- **Collisions**: `generateTools()` dedup appends `_` to the method half, which stays namespace-parseable. Under very small `maxToolNameLength` caps, hash truncation can remove the dot — the name stays MCP-valid but loses namespace binding. + --- **Related:** [Parameter Conflicts](./parameter-conflicts.md) | [Configuration](./configuration.md) | [API Reference](./api-reference.md) diff --git a/src/__tests__/naming-presets.spec.ts b/src/__tests__/naming-presets.spec.ts new file mode 100644 index 0000000..84f1ffa --- /dev/null +++ b/src/__tests__/naming-presets.spec.ts @@ -0,0 +1,136 @@ +/** Tests for the dottedNaming preset */ +import { dottedNaming, CODECALL_RESERVED_NAMESPACES } from '../naming-presets'; +import { OpenAPIToolGenerator } from '../generator'; +import type { HTTPMethod, OperationObject } from '../types'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +const HALF = /^[A-Za-z_$][A-Za-z0-9_$]*$/; + +const nameFor = ( + path: string, + method: HTTPMethod, + operationId?: string, + operation?: Partial, + options?: any, +): string => dottedNaming(options).toolNameGenerator!(path, method, operationId, operation as OperationObject); + +const expectBindable = (name: string): void => { + const dot = name.indexOf('.'); + expect(dot).toBeGreaterThan(0); + expect(HALF.test(name.slice(0, dot))).toBe(true); + expect(HALF.test(name.slice(dot + 1))).toBe(true); +}; + +describe('dottedNaming', () => { + it('namespaces by first tag with the operationId as the method half', () => { + const name = nameFor('/invoices', 'get', 'listInvoices', { tags: ['billing', 'other'] }); + expect(name).toBe('billing.listInvoices'); + expectBindable(name); + }); + + it('sanitizes tags and operationIds into identifiers', () => { + expect(nameFor('/x', 'get', 'get-user.by id', { tags: ['User Management'] })).toBe('User_Management.get_user_by_id'); + expect(nameFor('/x', 'get', undefined, { tags: ['3rd-party'] })).toBe('_3rd_party.get_x'); + expect(nameFor('/x', 'get', 'weird!!')).toBe('x.weird'); + }); + + it('falls back to the first path segment, then to api', () => { + expect(nameFor('/users/{id}', 'get', 'getUser')).toBe('users.getUser'); + expect(nameFor('/users/{id}', 'get', 'getUser', { tags: [] })).toBe('users.getUser'); + expect(nameFor('/{id}', 'get', 'lookup')).toBe('api.lookup'); + expect(nameFor('/', 'get', 'root')).toBe('api.root'); + }); + + it('uses the first path segment directly when namespaceFrom is firstPathSegment', () => { + expect(nameFor('/users/{id}', 'get', 'getUser', { tags: ['billing'] }, { namespaceFrom: 'firstPathSegment' })).toBe( + 'users.getUser', + ); + }); + + it('suffixes reserved namespaces with an underscore', () => { + expect(nameFor('/x', 'get', 'log', { tags: ['console'] })).toBe('console_.log'); + expect(nameFor('/x', 'get', 'op', { tags: ['mine'] }, { reservedNamespaces: ['mine'] })).toBe('mine_.op'); + expect(CODECALL_RESERVED_NAMESPACES).toContain('callTool'); + }); + + it('derives the method half from method and path when there is no operationId', () => { + expect(nameFor('/users/{id}/posts', 'get', undefined)).toBe('users.get_by_id_posts'); + expect(nameFor('/users', 'delete', undefined)).toBe('users.delete'); + expect(nameFor('/users', 'delete', undefined, { tags: ['admin'] })).toBe('admin.delete_users'); + }); + + it('produces bindable two-segment names across shapes', () => { + const cases: Array<[string, HTTPMethod, string | undefined, Partial | undefined]> = [ + ['/a-b/{c}', 'post', undefined, undefined], + ['/x', 'get', '...', { tags: ['...'] }], + ['/{v}/{w}', 'put', undefined, { tags: ['T-1'] }], + ]; + for (const [path, method, opId, op] of cases) { + expectBindable(nameFor(path, method, opId, op)); + } + }); +}); + +describe('dottedNaming end-to-end through generateTools', () => { + const spec: any = { + openapi: '3.0.0', + info: { title: 'Dotted API', version: '1.0.0' }, + paths: { + '/invoices': { + get: { operationId: 'listInvoices', tags: ['billing'], responses: { '200': { description: 'OK' } } }, + }, + '/invoices/{id}': { + get: { + operationId: 'getInvoice', + tags: ['billing'], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + responses: { '200': { description: 'OK' } }, + }, + }, + '/dup': { get: { operationId: 'same', tags: ['ns'], responses: { '200': { description: 'OK' } } } }, + '/dup2': { get: { operationId: 'same', tags: ['ns'], responses: { '200': { description: 'OK' } } } }, + }, + }; + + it('emits dotted names and keeps dedup suffixes namespace-parseable', async () => { + const generator = await OpenAPIToolGenerator.fromJSON(spec, { validate: false }); + const tools = await generator.generateTools({ namingStrategy: dottedNaming() }); + const names = tools.map((t) => t.name); + + expect(names).toContain('billing.listInvoices'); + expect(names).toContain('billing.getInvoice'); + expect(names).toContain('ns.same'); + const deduped = names.find((n) => /^ns\.same_[0-9a-f]{8}$/.test(n)); + expect(deduped).toBeDefined(); + const dot = deduped!.indexOf('.'); + expect(HALF.test(deduped!.slice(dot + 1))).toBe(true); + }); + + it('resolves parameter conflicts via the default resolver when the strategy has none', async () => { + const conflictSpec: any = { + openapi: '3.0.0', + info: { title: 'Conflict API', version: '1.0.0' }, + paths: { + '/things/{name}': { + get: { + operationId: 'getThing', + tags: ['things'], + parameters: [ + { name: 'name', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'name', in: 'query', schema: { type: 'string' } }, + ], + responses: { '200': { description: 'OK' } }, + }, + }, + }, + }; + const generator = await OpenAPIToolGenerator.fromJSON(conflictSpec, { validate: false }); + const tool = await generator.generateTool('/things/{name}', 'get', { namingStrategy: dottedNaming() }); + + expect(tool.name).toBe('things.getThing'); + const keys = Object.keys((tool.inputSchema as any).properties); + expect(keys).toContain('pathName'); + expect(keys).toContain('queryName'); + }); +}); diff --git a/src/generator.ts b/src/generator.ts index 0d3b27b..eec0d4a 100644 --- a/src/generator.ts +++ b/src/generator.ts @@ -800,7 +800,13 @@ export class OpenAPIToolGenerator { // Generate tool name (an extension name override takes the operationId's // place, including as the value passed to a custom toolNameGenerator) - const name = this.generateToolName(pathStr, method as HTTPMethod, overrides.name ?? operation.operationId, options); + const name = this.generateToolName( + pathStr, + method as HTTPMethod, + overrides.name ?? operation.operationId, + options, + operation, + ); // Generate description (extension override > strategy) const description = @@ -1010,11 +1016,12 @@ export class OpenAPIToolGenerator { method: HTTPMethod, operationId?: string, options: GenerateOptions = {}, + operation?: OperationObject, ): string { let rawName: string; if (options.namingStrategy?.toolNameGenerator) { - rawName = options.namingStrategy.toolNameGenerator(path, method, operationId); + rawName = options.namingStrategy.toolNameGenerator(path, method, operationId, operation); } else if (operationId) { rawName = operationId; } else { diff --git a/src/index.ts b/src/index.ts index 42542c0..caeb6cd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,10 @@ export type { ExtensionToolOverrides } from './annotations'; // TypeScript call-signature emission export { emitToolTypeScript, toPascalIdentifier } from './type-signature'; export type { ToolTypeScriptInfo, TypeSignatureOptions } from './type-signature'; + +// Naming presets +export { dottedNaming, CODECALL_RESERVED_NAMESPACES } from './naming-presets'; +export type { DottedNamingOptions } from './naming-presets'; export { applyClientTarget, inlineLocalRefs, diff --git a/src/naming-presets.ts b/src/naming-presets.ts new file mode 100644 index 0000000..926bea0 --- /dev/null +++ b/src/naming-presets.ts @@ -0,0 +1,150 @@ +/** + * Naming presets. + * + * `dottedNaming` produces two-segment `ns.method` tool names whose halves are + * valid JavaScript identifiers — the shape code-execution surfaces (FrontMCP + * CodeCall) bind as ergonomic namespaces: a tool named `billing.listInvoices` + * becomes `await billing.listInvoices({...})` in sandbox code. Names with any + * other shape (no dot, or a half that is not an identifier) still work via + * `callTool('name', input)` but get no namespace binding. + */ +import type { HTTPMethod, NamingStrategy, OperationObject } from './types'; + +/** + * Namespace identifiers reserved by FrontMCP CodeCall's sandbox globals — + * a namespace equal to one of these would shadow (or be shadowed by) a + * sandbox binding, so `dottedNaming` suffixes it with `_`. + */ +export const CODECALL_RESERVED_NAMESPACES: readonly string[] = [ + 'console', + 'Math', + 'JSON', + 'Object', + 'Promise', + 'Array', + 'String', + 'Number', + 'Boolean', + 'Date', + 'RegExp', + 'Error', + 'Symbol', + 'Map', + 'Set', + 'globalThis', + 'undefined', + 'NaN', + 'Infinity', + 'callTool', + 'getTool', + 'mcpLog', + 'mcpNotify', +]; + +/** Options for the {@link dottedNaming} preset. */ +export interface DottedNamingOptions { + /** + * Where the namespace half comes from: the operation's first tag, or the + * first path segment. `'tag'` falls back to the first path segment when the + * operation has no tags, then to `'api'`. + * @default 'tag' + */ + namespaceFrom?: 'tag' | 'firstPathSegment'; + + /** + * Additional reserved namespace names, merged with + * {@link CODECALL_RESERVED_NAMESPACES}. + */ + reservedNamespaces?: string[]; +} + +/** + * Sanitize a string into a JavaScript identifier that is also MCP-name-safe + * (`$` is a valid identifier character but not a valid MCP name character, so + * it is excluded): non-identifier characters become `_`, runs collapse, + * leading/trailing `_` are trimmed (loop-based — no backtracking-prone + * regex), and a leading digit gains a `_` prefix. Returns '' when nothing + * survives. + */ +function sanitizeIdentifier(value: string | undefined): string { + if (value === undefined) { + return ''; + } + let out = value.replace(/[^A-Za-z0-9_]+/g, '_').replace(/_+/g, '_'); + let start = 0; + let end = out.length; + while (start < end && out[start] === '_') start++; + while (end > start && out[end - 1] === '_') end--; + out = out.slice(start, end); + if (out === '') { + return ''; + } + return /^[0-9]/.test(out) ? `_${out}` : out; +} + +function firstPathSegment(path: string): string { + for (const segment of path.split('/')) { + if (segment !== '' && !segment.startsWith('{')) { + return sanitizeIdentifier(segment); + } + } + return ''; +} + +/** Path remainder → method half: `/users/{id}/posts` → `users_by_id_posts`. */ +function pathMethodHalf(method: HTTPMethod, path: string, ns: string): string { + const segments = path + .split('/') + .filter((s) => s !== '') + .map((s) => { + const templated = s.replace(/\{([^{}]+)\}/g, 'by_$1'); + return sanitizeIdentifier(templated); + }) + .filter((s) => s !== ''); + if (segments.length > 0 && segments[0] === ns) { + segments.shift(); + } + const joined = segments.join('_'); + return joined === '' ? method : `${method}_${joined}`; +} + +/** + * Naming preset producing two-segment `ns.method` tool names bindable by + * code-execution namespaces (e.g. FrontMCP CodeCall's `await ns.method({...})`). + * + * The namespace half comes from the operation's first tag (or first path + * segment); the method half from the operationId (an `x-mcp` family name + * override arrives through the operationId argument), falling back to the + * HTTP method plus the path. Both halves are sanitized to identifiers, so the + * emitted name contains exactly one dot. + * + * Collision dedup in `generateTools()` appends `_` to the method half, + * which keeps the name namespace-parseable. Under very small + * `maxToolNameLength` caps, hash truncation can remove the dot — such names + * remain valid MCP names but lose namespace binding. + */ +export function dottedNaming(options: DottedNamingOptions = {}): NamingStrategy { + const namespaceFrom = options.namespaceFrom ?? 'tag'; + const reserved = new Set([...CODECALL_RESERVED_NAMESPACES, ...(options.reservedNamespaces ?? [])]); + + return { + toolNameGenerator: (path: string, method: HTTPMethod, operationId?: string, operation?: OperationObject): string => { + let ns = ''; + if (namespaceFrom === 'tag') { + ns = sanitizeIdentifier(operation?.tags?.[0]); + } + if (ns === '') { + ns = firstPathSegment(path); + } + if (ns === '') { + ns = 'api'; + } + if (reserved.has(ns)) { + ns = `${ns}_`; + } + + const methodHalf = sanitizeIdentifier(operationId) || pathMethodHalf(method, path, ns); + return `${ns}.${methodHalf}`; + }, + }; +} diff --git a/src/parameter-resolver.ts b/src/parameter-resolver.ts index fef1e23..51cfdb0 100644 --- a/src/parameter-resolver.ts +++ b/src/parameter-resolver.ts @@ -31,12 +31,13 @@ export interface ParameterResolverOptions { * Resolves parameters and handles naming conflicts */ export class ParameterResolver { - private namingStrategy: NamingStrategy; + private namingStrategy: NamingStrategy & { conflictResolver: NonNullable }; private includeExamples: boolean; constructor(namingStrategy?: NamingStrategy, options?: ParameterResolverOptions) { - this.namingStrategy = namingStrategy ?? { - conflictResolver: this.defaultConflictResolver, + this.namingStrategy = { + ...namingStrategy, + conflictResolver: namingStrategy?.conflictResolver ?? this.defaultConflictResolver, }; this.includeExamples = options?.includeExamples ?? false; } diff --git a/src/types.ts b/src/types.ts index 2d80298..b3c0a7c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1012,22 +1012,25 @@ export type FormatResolver = (schema: JsonSchema) => JsonSchema; */ export interface NamingStrategy { /** - * Resolver function for parameter name conflicts + * Resolver function for parameter name conflicts. * @param paramName - Original parameter name * @param location - Parameter location * @param index - Index of conflicting parameter (0-based) * @returns New parameter name + * @default a location-prefix resolver (`headerX_Trace`-style) */ - conflictResolver: (paramName: string, location: ParameterLocation, index: number) => string; + conflictResolver?: (paramName: string, location: ParameterLocation, index: number) => string; /** * Function to generate tool names * @param path - OpenAPI path * @param method - HTTP method - * @param operationId - Operation ID if available + * @param operationId - Operation ID if available (an `x-mcp` family name + * override arrives through this argument in place of the operationId) + * @param operation - The full operation object (for tag-aware strategies) * @returns Tool name */ - toolNameGenerator?: (path: string, method: HTTPMethod, operationId?: string) => string; + toolNameGenerator?: (path: string, method: HTTPMethod, operationId?: string, operation?: OperationObject) => string; } /** From 3a2fecd7c182f13a600709576ae6032f48510cdb Mon Sep 17 00:00:00 2001 From: David Antoon Date: Thu, 13 Aug 2026 01:49:43 +0300 Subject: [PATCH 03/10] fix: apply review findings on interface-safe roots, comment escaping, reserved names, and depth wiring in type signatures --- docs/type-signatures.md | 4 +- src/__tests__/generator.spec.ts | 26 +++++++ src/__tests__/type-signature.spec.ts | 70 ++++++++++++++++++ src/generator.ts | 10 ++- src/type-signature.ts | 102 ++++++++++++++++++++------- 5 files changed, 184 insertions(+), 28 deletions(-) diff --git a/docs/type-signatures.md b/docs/type-signatures.md index e7b083c..9c55dab 100644 --- a/docs/type-signatures.md +++ b/docs/type-signatures.md @@ -49,7 +49,7 @@ const { signature, declaration } = emitToolTypeScript( 'Fetch a user.', // optional description → leading JSDoc tool.inputSchema, tool.outputSchema, - { maxDepth: 8 }, // optional; default 8 + { maxDepth: 8 }, // optional; default 8 (generation passes maxSchemaDepth here) ); toPascalIdentifier('3d.scan'); // "T3dScan" @@ -72,7 +72,7 @@ toPascalIdentifier('3d.scan'); // "T3dScan" | typed `additionalProperties` / `patternProperties` | `Record` (intersected when properties also exist) | | boolean schemas | `true` → `unknown`, `false` → `never` | | `$ref` leftovers | `unknown` (declarations are always self-contained) | -| cycles / nesting beyond `maxDepth` (printer default 8; generated schemas are already depth-truncated at 10) | `unknown` | +| cycles / nesting beyond `maxDepth` | `unknown` (during generation the printer depth follows `maxSchemaDepth`, so it never collapses levels the schema still carries; standalone default 8) | All `x-` annotation keywords (`x-parameter-location`, `x-status-code`, `x-mcp-header`, …) are ignored for typing. diff --git a/src/__tests__/generator.spec.ts b/src/__tests__/generator.spec.ts index 369f61f..fe49c7b 100644 --- a/src/__tests__/generator.spec.ts +++ b/src/__tests__/generator.spec.ts @@ -4554,3 +4554,29 @@ describe('TypeScript signature emission (emitTypeSignatures)', () => { expect(tools[1].metadata.typescript?.declaration).not.toContain('DupOpInput ='); }); }); + +describe('Type-signature depth follows maxSchemaDepth', () => { + it('prints levels beyond the old printer default when the schema carries them', async () => { + // Build a 9-level-deep response schema: l1.l2....l9: string + let leaf: any = { type: 'string' }; + for (let i = 9; i >= 1; i--) { + leaf = { type: 'object', properties: { [`l${i}`]: leaf } }; + } + const spec: any = { + openapi: '3.0.0', + info: { title: 'Deep API', version: '1.0.0' }, + paths: { + '/deep': { + get: { + operationId: 'getDeep', + responses: { '200': { description: 'OK', content: { 'application/json': { schema: leaf } } } }, + }, + }, + }, + }; + const generator = await OpenAPIToolGenerator.fromJSON(spec, { validate: false }); + const tool = await generator.generateTool('/deep', 'get', { emitTypeSignatures: true }); + // depth 10 default: all 9 object levels print; the leaf string survives + expect(tool.metadata.typescript?.signature).toContain('l9?: string'); + }); +}); diff --git a/src/__tests__/type-signature.spec.ts b/src/__tests__/type-signature.spec.ts index 08e4ab3..076f9ed 100644 --- a/src/__tests__/type-signature.spec.ts +++ b/src/__tests__/type-signature.spec.ts @@ -305,6 +305,76 @@ describe('emitToolTypeScript assembly', () => { expect(declaration).toContain('type TOutput = string | number;'); }); + it('declares union and intersection roots as type aliases, never interfaces', () => { + const union = emitToolTypeScript('t', undefined, { type: 'object', properties: {} } as JsonSchema, { + oneOf: [ + { type: 'object', properties: { bark: { type: 'boolean' } } }, + { type: 'object', properties: { meow: { type: 'boolean' } } }, + ], + 'x-status-code': 200, + } as JsonSchema); + expect(union.declaration).toContain('type TOutput = {\n bark?: boolean;\n} | {\n meow?: boolean;\n};'); + expect(union.declaration).not.toContain('interface TOutput'); + + const intersection = emitToolTypeScript('t', undefined, { type: 'object', properties: {} } as JsonSchema, { + allOf: [ + { type: 'object', properties: { a: { type: 'string' } } }, + { type: 'object', properties: { b: { type: 'number' } } }, + ], + } as JsonSchema); + expect(intersection.declaration).toContain('type TOutput = {\n a?: string;\n} & {\n b?: number;\n};'); + }); + + it('escapes comment-breaking content types and statuses in variant comments', () => { + const { declaration } = emitToolTypeScript('t', undefined, { type: 'object', properties: {} } as JsonSchema, { + oneOf: [ + { type: 'string', 'x-status-code': 200, 'x-content-type': '*/*' }, + { type: 'number', 'x-status-code': 500 }, + ], + } as JsonSchema); + expect(declaration).toContain('/** status 200 (*\\/*) */ string'); + expect(declaration).not.toContain('(*/*)'); + }); + + it('suffixes reserved words used as function names', () => { + const { declaration } = emitToolTypeScript( + 'delete', + undefined, + { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] } as JsonSchema, + undefined, + ); + expect(declaration).toContain('declare function delete_(input: DeleteInput): Promise;'); + }); + + it('degrades non-finite numeric literals to number and skips their defaults', () => { + expect(outputType({ enum: [Infinity, 1] })).toBe('number | 1'); + expect(outputType({ const: Number.NaN })).toBe('number'); + const { declaration } = emitToolTypeScript('t', undefined, { + type: 'object', + properties: { x: { type: 'number', default: Infinity } }, + } as JsonSchema, undefined); + expect(declaration).not.toContain('@default'); + }); + + it('survives crafted self-referential type arrays', () => { + const type: any[] = ['object']; + type.push(type); + expect(outputType({ type, properties: { a: { type: 'string' } } })).toBe('{ a?: string }'); + }); + + it('keeps input for property-less roots that still carry data', () => { + expect(sig({ type: 'object', additionalProperties: { type: 'string' } })).toBe( + '(input: Record) => Promise', + ); + expect(sig({ type: 'string' })).toBe('(input: string) => Promise'); + expect(sig(true)).toBe('(input?: unknown) => Promise'); + const { declaration } = emitToolTypeScript('t', undefined, { + type: 'object', + additionalProperties: { type: 'string' }, + } as JsonSchema, undefined); + expect(declaration).toContain('declare function t(input: TInput): Promise;'); + }); + it('is deterministic across calls', () => { const input = { type: 'object', properties: { a: { type: 'string' } } } as JsonSchema; const output = { oneOf: [{ type: 'string' }, { type: 'number' }] } as JsonSchema; diff --git a/src/generator.ts b/src/generator.ts index eec0d4a..e70bad2 100644 --- a/src/generator.ts +++ b/src/generator.ts @@ -728,7 +728,9 @@ export class OpenAPIToolGenerator { if (tool.metadata.typescript) { tool.metadata = { ...tool.metadata, - typescript: emitToolTypeScript(deduped, tool.description, tool.inputSchema, tool.outputSchema), + typescript: emitToolTypeScript(deduped, tool.description, tool.inputSchema, tool.outputSchema, { + maxDepth: Math.max(1, options.maxSchemaDepth ?? 10), + }), }; } } @@ -907,7 +909,11 @@ export class OpenAPIToolGenerator { // TypeScript call contract (computed on the FINAL schemas) if (options.emitTypeSignatures) { - metadata.typescript = emitToolTypeScript(name, finalDescription, resolvedInputSchema, resolvedOutputSchema); + metadata.typescript = emitToolTypeScript(name, finalDescription, resolvedInputSchema, resolvedOutputSchema, { + // Print at least as deep as the schemas were truncated, so the + // emitted types never collapse levels the schema still carries. + maxDepth: Math.max(1, options.maxSchemaDepth ?? 10), + }); } return { diff --git a/src/type-signature.ts b/src/type-signature.ts index 0353f42..502404a 100644 --- a/src/type-signature.ts +++ b/src/type-signature.ts @@ -91,12 +91,24 @@ function literalOf(value: unknown): string { return 'null'; } const t = typeof value; - if (t === 'string' || t === 'number' || t === 'boolean') { + if (t === 'number') { + // JSON.stringify(Infinity/NaN) is 'null' — degrade to `number` instead + return Number.isFinite(value) ? JSON.stringify(value) : 'number'; + } + if (t === 'string' || t === 'boolean') { return JSON.stringify(value); } return 'unknown'; } +/** Words that cannot name a `declare function` in a strict-mode module. */ +const RESERVED_WORDS = new Set([ + 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', 'delete', 'do', 'else', 'enum', + 'export', 'extends', 'false', 'finally', 'for', 'function', 'if', 'import', 'in', 'instanceof', 'new', 'null', + 'return', 'super', 'switch', 'this', 'throw', 'true', 'try', 'typeof', 'var', 'void', 'while', 'with', + 'implements', 'interface', 'let', 'package', 'private', 'protected', 'public', 'static', 'yield', 'await', +]); + function escapeJsdoc(text: string): string { return text.replace(/\*\//g, '*\\/'); } @@ -116,7 +128,7 @@ function jsdocLines(prop: unknown): string[] { if (typeof format === 'string' && format !== '') { lines.push(`@format ${escapeJsdoc(format)}`); } - if ('default' in prop) { + if ('default' in prop && !(typeof prop['default'] === 'number' && !Number.isFinite(prop['default']))) { const rendered = JSON.stringify(prop['default']); if (rendered !== undefined) { lines.push(`@default ${escapeJsdoc(rendered)}`); @@ -215,7 +227,9 @@ function typeExprInner(r: SchemaRecord, ctx: PrintContext, depth: number, indent const type = r['type']; if (Array.isArray(type)) { - const parts = type.map((t) => typeExpr({ ...r, type: t }, ctx, depth, indent)); + // String members only — the spread creates a fresh object per member, so a + // crafted self-referential array element would bypass the identity guard + const parts = type.filter((t): t is string => typeof t === 'string').map((t) => typeExpr({ ...r, type: t }, ctx, depth, indent)); return parts.length === 0 ? 'unknown' : dedupe(parts).join(' | '); } switch (type) { @@ -305,15 +319,60 @@ function objectExpr(r: SchemaRecord, ctx: PrintContext, depth: number, indent: s return `${body}${suffix}`; } +/** + * True when the schema renders as a lone `{ ... }` object body with no + * union/intersection suffix — the only shape valid as an `interface` body. + * Mirrors the printer's branch order. + */ +function isPlainObjectBody(schema: unknown): boolean { + if (!isSchemaRecord(schema) || schema['$ref'] !== undefined) { + return false; + } + if (('const' in schema && literalOf(schema['const']) !== 'unknown') || Array.isArray(schema['enum'])) { + return false; + } + if (Array.isArray(schema['allOf']) || Array.isArray(schema['oneOf']) || Array.isArray(schema['anyOf'])) { + return false; + } + if (Array.isArray(schema['type']) || !hasObjectShape(schema)) { + return false; + } + const properties = isSchemaRecord(schema['properties']) ? (schema['properties'] as SchemaRecord) : {}; + if (Object.keys(properties).length === 0) { + return false; // renders as Record<...> + } + const ap = schema['additionalProperties']; + if (ap === true || isSchemaRecord(ap) || isSchemaRecord(schema['patternProperties'])) { + return false; // renders with a `& Record<...>` suffix + } + return true; +} + +/** Emit a named root type: `interface` for plain object bodies, alias otherwise. */ +function namedRoot(name: string, schema: unknown, ctx: PrintContext): string { + const expr = typeExpr(schema, ctx, 0, ''); + return isPlainObjectBody(schema) ? `interface ${name} ${expr}` : `type ${name} = ${expr};`; +} + /** Render the parameter list for the signature / declare-function forms. */ -function paramList(inputSchema: JsonSchema, typeText: string): string { - const r = isSchemaRecord(inputSchema) ? inputSchema : undefined; - const properties = r && isSchemaRecord(r['properties']) ? (r['properties'] as SchemaRecord) : {}; - const keys = Object.keys(properties); - if (keys.length === 0) { +function paramList(inputSchema: unknown, typeText: string): string { + if (inputSchema === true) { + return `(input?: ${typeText})`; + } + if (!isSchemaRecord(inputSchema)) { return '()'; } - const required = new Set(Array.isArray(r?.['required']) ? (r?.['required'] as unknown[]) : []); + const properties = isSchemaRecord(inputSchema['properties']) ? (inputSchema['properties'] as SchemaRecord) : {}; + const keys = Object.keys(properties); + if (keys.length === 0) { + const ap = inputSchema['additionalProperties']; + const hasExtra = ap === true || isSchemaRecord(ap) || isSchemaRecord(inputSchema['patternProperties']); + const objectish = inputSchema['type'] === 'object' || inputSchema['type'] === undefined; + // A closed, empty object root truly takes no input; anything else + // (typed additionalProperties, non-object roots) still carries data. + return objectish && !hasExtra ? '()' : `(input: ${typeText})`; + } + const required = new Set(Array.isArray(inputSchema['required']) ? (inputSchema['required'] as unknown[]) : []); const allOptional = keys.every((k) => !required.has(k)); return allOptional ? `(input?: ${typeText})` : `(input: ${typeText})`; } @@ -326,8 +385,9 @@ function outputVariantsDeclaration(name: string, variants: unknown[], ctx: Print const status = member['x-status-code']; if (typeof status === 'number' || typeof status === 'string') { const contentType = member['x-content-type']; - const ct = typeof contentType === 'string' ? ` (${contentType})` : ''; - comment = `/** status ${status}${ct} */ `; + // Content types like `*/*` would terminate the comment unescaped + const ct = typeof contentType === 'string' ? ` (${escapeJsdoc(contentType)})` : ''; + comment = `/** status ${escapeJsdoc(String(status))}${ct} */ `; } } return ` | ${comment}${typeExpr(member, ctx, 1, ' ')}`; @@ -366,12 +426,7 @@ export function emitToolTypeScript( blocks.push(renderJsdoc(escapeJsdoc(description).split('\n'), '').trimEnd()); } - const inputPretty = typeExpr(inputSchema, pretty, 0, ''); - blocks.push( - inputPretty.startsWith('{') && inputPretty.endsWith('}') - ? `interface ${inputName} ${inputPretty}` - : `type ${inputName} = ${inputPretty};`, - ); + blocks.push(namedRoot(inputName, inputSchema, pretty)); const outputUnion = isSchemaRecord(outputSchema) && Array.isArray(outputSchema['oneOf']) @@ -382,15 +437,14 @@ export function emitToolTypeScript( } else if (outputUnion && outputUnion.some((m) => isSchemaRecord(m) && m['x-status-code'] !== undefined)) { blocks.push(outputVariantsDeclaration(outputName, outputUnion, pretty)); } else { - const outputPretty = typeExpr(outputSchema, pretty, 0, ''); - blocks.push( - outputPretty.startsWith('{') && outputPretty.endsWith('}') - ? `interface ${outputName} ${outputPretty}` - : `type ${outputName} = ${outputPretty};`, - ); + blocks.push(namedRoot(outputName, outputSchema, pretty)); } - blocks.push(`declare function ${lowerFirst(base)}${paramList(inputSchema, inputName)}: Promise<${outputName}>;`); + let fnName = lowerFirst(base); + if (RESERVED_WORDS.has(fnName)) { + fnName = `${fnName}_`; + } + blocks.push(`declare function ${fnName}${paramList(inputSchema, inputName)}: Promise<${outputName}>;`); return { signature, declaration: blocks.join('\n\n') }; } From c57c0c31bb54ba3ef4a7b5a83db330472c49d14f Mon Sep 17 00:00:00 2001 From: David Antoon Date: Thu, 13 Aug 2026 01:52:24 +0300 Subject: [PATCH 04/10] fix: apply review findings on digit-guard namespaces, CodeCall reserved list, and resolver this-binding --- docs/naming-strategies.md | 3 +- src/__tests__/naming-presets.spec.ts | 60 +++++++++++++++++++++++++++- src/naming-presets.ts | 21 ++++++++-- src/parameter-resolver.ts | 6 ++- 4 files changed, 83 insertions(+), 7 deletions(-) diff --git a/docs/naming-strategies.md b/docs/naming-strategies.md index 194100e..7dd4795 100644 --- a/docs/naming-strategies.md +++ b/docs/naming-strategies.md @@ -146,7 +146,8 @@ const tools = await generator.generateTools({ namingStrategy: dottedNaming() }); - **Namespace half**: the operation's first tag, falling back to the first path segment, then `api`. Configure with `namespaceFrom: 'tag' | 'firstPathSegment'` (default `'tag'`). - **Method half**: the sanitized operationId, falling back to the HTTP method plus the remaining path segments (`{param}` becomes `by_`). - **Reserved namespaces** (CodeCall sandbox globals such as `console`, `JSON`, `callTool` — the full list is exported as `CODECALL_RESERVED_NAMESPACES`) get an `_` suffix; add your own via `reservedNamespaces: [...]`. -- **Collisions**: `generateTools()` dedup appends `_` to the method half, which stays namespace-parseable. Under very small `maxToolNameLength` caps, hash truncation can remove the dot — the name stays MCP-valid but loses namespace binding. +- **Collisions**: `generateTools()` dedup appends `_` to the method half, which stays namespace-parseable. Hash truncation can remove the dot when the namespace half alone approaches the name cap (≥ 55 chars at the default 64) — the name stays MCP-valid but loses namespace binding. +- **Digit-leading namespaces** get an `n` prefix (`3rd-party` → `n3rd_party`) — a `_` guard would be trimmed off the front of the tool name during normalization. --- diff --git a/src/__tests__/naming-presets.spec.ts b/src/__tests__/naming-presets.spec.ts index 84f1ffa..a6b39af 100644 --- a/src/__tests__/naming-presets.spec.ts +++ b/src/__tests__/naming-presets.spec.ts @@ -31,10 +31,15 @@ describe('dottedNaming', () => { it('sanitizes tags and operationIds into identifiers', () => { expect(nameFor('/x', 'get', 'get-user.by id', { tags: ['User Management'] })).toBe('User_Management.get_user_by_id'); - expect(nameFor('/x', 'get', undefined, { tags: ['3rd-party'] })).toBe('_3rd_party.get_x'); expect(nameFor('/x', 'get', 'weird!!')).toBe('x.weird'); }); + it('letter-guards digit-leading namespaces so normalization cannot strip the guard', () => { + expect(nameFor('/x', 'get', undefined, { tags: ['3rd-party'] })).toBe('n3rd_party.get_x'); + expect(nameFor('/x', 'get', 'op', { tags: ['42'] })).toBe('n42.op'); + expect(nameFor('/2fa/enable', 'post', undefined)).toBe('n2fa.post__2fa_enable'); + }); + it('falls back to the first path segment, then to api', () => { expect(nameFor('/users/{id}', 'get', 'getUser')).toBe('users.getUser'); expect(nameFor('/users/{id}', 'get', 'getUser', { tags: [] })).toBe('users.getUser'); @@ -51,7 +56,11 @@ describe('dottedNaming', () => { it('suffixes reserved namespaces with an underscore', () => { expect(nameFor('/x', 'get', 'log', { tags: ['console'] })).toBe('console_.log'); expect(nameFor('/x', 'get', 'op', { tags: ['mine'] }, { reservedNamespaces: ['mine'] })).toBe('mine_.op'); - expect(CODECALL_RESERVED_NAMESPACES).toContain('callTool'); + expect(nameFor('/x', 'get', 'op', { tags: ['global'] })).toBe('global_.op'); + // mirror CodeCall's real reserved list + for (const ns of ['callTool', 'WeakMap', 'WeakSet', 'global', 'window', 'self', 'null', 'true', 'false']) { + expect(CODECALL_RESERVED_NAMESPACES).toContain(ns); + } }); it('derives the method half from method and path when there is no operationId', () => { @@ -107,6 +116,53 @@ describe('dottedNaming end-to-end through generateTools', () => { expect(HALF.test(deduped!.slice(dot + 1))).toBe(true); }); + it('keeps digit-leading namespaces bindable through generateTools normalization', async () => { + const digitSpec: any = { + openapi: '3.0.0', + info: { title: 'Digit API', version: '1.0.0' }, + paths: { + '/things': { get: { operationId: 'opA', tags: ['3rd-party'], responses: { '200': { description: 'OK' } } } }, + }, + }; + const generator = await OpenAPIToolGenerator.fromJSON(digitSpec, { validate: false }); + const tools = await generator.generateTools({ namingStrategy: dottedNaming() }); + expect(tools[0].name).toBe('n3rd_party.opA'); + expectBindable(tools[0].name); + }); + + it('preserves this binding for class-based conflict resolvers', async () => { + class MyStrategy { + prefix = 'X'; + helper(name: string, index: number): string { + return `${this.prefix}${index}_${name}`; + } + conflictResolver = function (this: MyStrategy, paramName: string, _location: unknown, index: number): string { + return this.helper(paramName, index); + }; + } + const conflictSpec: any = { + openapi: '3.0.0', + info: { title: 'Conflict API', version: '1.0.0' }, + paths: { + '/things/{name}': { + get: { + operationId: 'getThing', + parameters: [ + { name: 'name', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'name', in: 'query', schema: { type: 'string' } }, + ], + responses: { '200': { description: 'OK' } }, + }, + }, + }, + }; + const generator = await OpenAPIToolGenerator.fromJSON(conflictSpec, { validate: false }); + const tool = await generator.generateTool('/things/{name}', 'get', { namingStrategy: new MyStrategy() as any }); + const keys = Object.keys((tool.inputSchema as any).properties); + expect(keys).toContain('X0_name'); + expect(keys).toContain('X1_name'); + }); + it('resolves parameter conflicts via the default resolver when the strategy has none', async () => { const conflictSpec: any = { openapi: '3.0.0', diff --git a/src/naming-presets.ts b/src/naming-presets.ts index 926bea0..81b28fc 100644 --- a/src/naming-presets.ts +++ b/src/naming-presets.ts @@ -31,8 +31,16 @@ export const CODECALL_RESERVED_NAMESPACES: readonly string[] = [ 'Symbol', 'Map', 'Set', + 'WeakMap', + 'WeakSet', 'globalThis', + 'global', + 'window', + 'self', 'undefined', + 'null', + 'true', + 'false', 'NaN', 'Infinity', 'callTool', @@ -119,9 +127,10 @@ function pathMethodHalf(method: HTTPMethod, path: string, ns: string): string { * emitted name contains exactly one dot. * * Collision dedup in `generateTools()` appends `_` to the method half, - * which keeps the name namespace-parseable. Under very small - * `maxToolNameLength` caps, hash truncation can remove the dot — such names - * remain valid MCP names but lose namespace binding. + * which keeps the name namespace-parseable. Hash truncation can remove the + * dot when the namespace half alone approaches `maxToolNameLength` (≥ 55 + * chars at the default cap of 64) — such names remain valid MCP names but + * lose namespace binding. */ export function dottedNaming(options: DottedNamingOptions = {}): NamingStrategy { const namespaceFrom = options.namespaceFrom ?? 'tag'; @@ -139,6 +148,12 @@ export function dottedNaming(options: DottedNamingOptions = {}): NamingStrategy if (ns === '') { ns = 'api'; } + // A leading `_` here is always the digit guard, and it would sit at + // position 0 of the full tool name where normalizeToolName trims it — + // use a letter guard instead so the namespace stays identifier-valid. + if (ns.startsWith('_')) { + ns = `n${ns.slice(1)}`; + } if (reserved.has(ns)) { ns = `${ns}_`; } diff --git a/src/parameter-resolver.ts b/src/parameter-resolver.ts index 51cfdb0..5352516 100644 --- a/src/parameter-resolver.ts +++ b/src/parameter-resolver.ts @@ -37,7 +37,11 @@ export class ParameterResolver { constructor(namingStrategy?: NamingStrategy, options?: ParameterResolverOptions) { this.namingStrategy = { ...namingStrategy, - conflictResolver: namingStrategy?.conflictResolver ?? this.defaultConflictResolver, + // Bind a supplied resolver to its own strategy object so class-based + // strategies keep their `this` (we invoke it off a spread clone). + conflictResolver: namingStrategy?.conflictResolver + ? namingStrategy.conflictResolver.bind(namingStrategy) + : this.defaultConflictResolver, }; this.includeExamples = options?.includeExamples ?? false; } From 00b9ef0ea611c796c9181c1311f6859f29162693 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Thu, 13 Aug 2026 02:01:24 +0300 Subject: [PATCH 05/10] feat: emit _meta, tool icons, x-mcp-header markers, and security elicitation descriptors --- CLAUDE.md | 1 + README.md | 1 + docs/annotations.md | 2 +- docs/api-reference.md | 8 ++ docs/configuration.md | 2 + docs/modern-mcp-fields.md | 78 +++++++++++++++ docs/x-frontmcp.md | 22 ++++ src/__tests__/annotations.spec.ts | 71 +++++++++++++ src/__tests__/elicitation.spec.ts | 124 +++++++++++++++++++++++ src/__tests__/generator.spec.ts | 134 +++++++++++++++++++++++++ src/annotations.ts | 72 +++++++++++-- src/elicitation.ts | 161 ++++++++++++++++++++++++++++++ src/generator.ts | 48 +++++++++ src/index.ts | 5 + src/parameter-resolver.ts | 7 ++ src/types.ts | 65 ++++++++++++ 16 files changed, 790 insertions(+), 11 deletions(-) create mode 100644 docs/modern-mcp-fields.md create mode 100644 src/__tests__/elicitation.spec.ts create mode 100644 src/elicitation.ts diff --git a/CLAUDE.md b/CLAUDE.md index 8ec070c..beec57c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,6 +43,7 @@ OpenAPIToolGenerator (src/generator.ts) | `src/sdk.ts` | `toSdkTool` — registerTool-shaped output for the official MCP SDK (no SDK dependency) | | `src/type-signature.ts` | `emitToolTypeScript` — TypeScript signature/declaration rendering of a tool's call contract (`emitTypeSignatures` option → `metadata.typescript`) | | `src/naming-presets.ts` | `dottedNaming` — two-segment `ns.method` naming preset for CodeCall namespace binding; `CODECALL_RESERVED_NAMESPACES` | +| `src/elicitation.ts` | `deriveSecurityElicitations` — MCP elicitation descriptors (`{message, requestedSchema}`) from a tool's security data | | `src/parameter-resolver.ts` | Resolves OpenAPI parameters + requestBody into flat inputSchema with conflict resolution; flattens `allOf` bodies, flags `wholeBody`/`binary` | | `src/response-builder.ts` | Builds outputSchema from OpenAPI responses with content-type and status code preferences | | `src/format-resolver.ts` | Format-to-schema resolution. Built-in resolvers for uuid, date-time, email, int32, etc. | diff --git a/README.md b/README.md index c2ed122..ad5373a 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,7 @@ for (const tool of await generator.generateTools({ target: "claude" })) { | [Client Targets](https://github.com/agentfront/mcp-from-openapi/blob/main/docs/client-targets.md) | Per-client schema dialects (Claude, OpenAI, Gemini) | | [Curation](https://github.com/agentfront/mcp-from-openapi/blob/main/docs/curation.md) | Token budgets, overlays, lint, trimming, response hints | | [Type Signatures](https://github.com/agentfront/mcp-from-openapi/blob/main/docs/type-signatures.md) | TypeScript call contracts for code-execution surfaces | +| [Modern MCP Fields](https://github.com/agentfront/mcp-from-openapi/blob/main/docs/modern-mcp-fields.md) | Tool `_meta`, icons, `x-mcp-header`, elicitation descriptors | | [Response Schemas](https://github.com/agentfront/mcp-from-openapi/blob/main/docs/response-schemas.md) | Output schemas, status codes, oneOf unions | | [Annotations & Extensions](https://github.com/agentfront/mcp-from-openapi/blob/main/docs/annotations.md) | Tool title, annotation inference, `x-mcp` extension family | | [Security](https://github.com/agentfront/mcp-from-openapi/blob/main/docs/security.md) | SecurityResolver, all auth types, custom resolvers | diff --git a/docs/annotations.md b/docs/annotations.md index c15f078..47950dc 100644 --- a/docs/annotations.md +++ b/docs/annotations.md @@ -42,7 +42,7 @@ Disable inference with `{ inferAnnotations: false }`; extension overrides (below ## Extension Overrides -Spec authors can override the tool name, title, description, and annotations — and exclude operations entirely — through the `x-mcp` extension family at the **operation level**. Three dialects are read, in ascending precedence (later wins field-by-field): +Spec authors can override the tool name, title, description, annotations, `_meta` entries, and icons — and exclude operations entirely — through the `x-mcp` extension family at the **operation level**. Three dialects are read, in ascending precedence (later wins field-by-field; `meta` merges key-by-key, `icons` replaces wholesale, and `x-speakeasy-mcp` supports neither): ### 1. `x-speakeasy-mcp` (interop) diff --git a/docs/api-reference.md b/docs/api-reference.md index c7d85ad..0cc7dfc 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -141,6 +141,14 @@ Apply a client dialect's schema transforms (`'claude' | 'openai' | 'gemini' | 's applyClientTarget(schema: JsonSchema, target: ClientTarget): JsonSchema ``` +### deriveSecurityElicitations + +Derive MCP-elicitation-compatible `{ message, requestedSchema }` credential requests from a tool's security data. See [Modern MCP Fields](./modern-mcp-fields.md). + +```typescript +deriveSecurityElicitations(tool: McpOpenAPITool): SecurityElicitation[] +``` + ### dottedNaming Naming preset producing two-segment `ns.method` tool names bindable by code-execution namespaces (FrontMCP CodeCall). See [Naming Strategies](./naming-strategies.md). diff --git a/docs/configuration.md b/docs/configuration.md index 49d7c41..a0b8cb6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -90,6 +90,8 @@ const tools = await generator.generateTools({ | `maxDescriptionLength` | `number` | - | Ellipsis-truncate every schema description at N chars | | `stripExamples` | `boolean` | `false` | Remove all `examples` arrays from generated schemas | | `emitTypeSignatures` | `boolean` | `false` | Render `metadata.typescript = { signature, declaration }` — see [Type Signatures](./type-signatures.md) | +| `emitMeta` | `boolean` | `false` | Emit the `dev.agentfront.openapi/operation` entry on tool `_meta` — see [Modern MCP Fields](./modern-mcp-fields.md) | +| `inheritDocumentIcons` | `boolean` | `false` | Fall back to `info['x-logo']` as a tool icon when no extension icons exist | ### Filtering Operations diff --git a/docs/modern-mcp-fields.md b/docs/modern-mcp-fields.md new file mode 100644 index 0000000..02b4c71 --- /dev/null +++ b/docs/modern-mcp-fields.md @@ -0,0 +1,78 @@ +# Modern MCP Fields: \_meta, Icons, Headers & Elicitation + +[Home](../README.md) | [Configuration](./configuration.md) | [API Reference](./api-reference.md) + +--- + +The MCP spec arc from 2025-06-18 through 2026-07-28 added client-visible surfaces beyond schemas: namespaced `_meta`, tool icons, and elicitation. This page covers how generated tools carry them — all as pure data; wiring them to a live server is the consumer's job. + +## Tool `_meta` + +```typescript +const tool = await generator.generateTool('/items', 'get', { emitMeta: true }); + +tool._meta; +// { +// 'dev.agentfront.openapi/operation': { +// path: '/items', method: 'get', operationId: 'listItems', +// tags: ['items'], deprecated: true, +// specTitle: 'Items API', specVersion: '2.0.0', +// }, +// } +``` + +With `emitMeta: true`, every tool gets a `dev.agentfront.openapi/operation` entry (reverse-DNS key per MCP `_meta` conventions) carrying the source operation's coordinates — absent fields are elided. Spec authors can add their own entries via the extensions: + +```yaml +x-mcp: + meta: { "com.example/billing-tier": "pro" } +``` + +Extension-supplied `meta` is emitted **even when `emitMeta` is off**, merged key-by-key with `x-frontmcp.meta` winning over `x-mcp.meta`, and both winning over the generated entry. `x-speakeasy-mcp` does not participate (outside its published contract). + +## Tool icons + +```yaml +x-frontmcp: + icons: [{ src: "https://example.com/invoice.png", mimeType: "image/png", sizes: ["48x48"] }] +``` + +Icons (MCP spec 2025-11-25: `{ src, mimeType?, sizes? }`) come from `x-frontmcp.icons` or `x-mcp.icons` (later replaces wholesale; malformed entries are dropped). With `inheritDocumentIcons: true`, operations without extension icons fall back to the document's `info['x-logo']` (Redoc convention — a URL string or `{ url }` object) as a single icon on every tool. The fallback is off by default so one logo doesn't silently inflate every tool definition. + +Neither `_meta` nor `icons` count toward `estimateToolTokens` — they are client chrome, not model-facing text. + +## `x-mcp-header` + +Every header-located input property is annotated with its original wire header name: + +```typescript +tool.inputSchema.properties.headerTrace; +// { type: 'string', 'x-parameter-location': 'header', 'x-mcp-header': 'trace' } +``` + +This is always on. Conflict renames only change the input key, so `x-mcp-header` preserves the true header; security-derived header inputs (when `includeSecurityInInput` is set) carry it too (`Authorization`, or the API-key header name). All client targets preserve `x-` keywords, so the marker survives `target` transforms. Generic MCP-to-HTTP bridges can use it to route inputs into headers without consulting the mapper. + +## Security elicitation descriptors + +```typescript +import { deriveSecurityElicitations } from 'mcp-from-openapi'; + +const [request] = deriveSecurityElicitations(tool); +// { +// scheme: 'bearerAuth', +// message: 'Provide the bearer token for "bearerAuth".', +// requestedSchema: { +// type: 'object', +// properties: { token: { type: 'string', title: 'Token', description: 'HTTP bearer authentication token (JWT).' } }, +// required: ['token'], +// }, +// } +``` + +`deriveSecurityElicitations(tool)` derives one MCP-elicitation-compatible `{ message, requestedSchema }` descriptor per distinct security scheme, from the mapper's security entries (falling back to `metadata.security`). Shapes per scheme type: basic/digest → `username` + `password`; bearer and other HTTP schemes → `token`; API key → `apiKey`; OAuth2/OIDC → `accessToken` (scopes in the description). Mutual-TLS and custom signature schemes are skipped — they have no elicitable string credential. + +The requested schemas are flat string-property objects, as MCP elicitation requires. **Caveat:** the MCP spec advises servers not to elicit secrets over untrusted paths — this function gives you the shape; whether and where to ask is transport policy. + +--- + +**Related:** [Configuration](./configuration.md) | [Annotations & Extensions](./annotations.md) | [Security](./security.md) diff --git a/docs/x-frontmcp.md b/docs/x-frontmcp.md index 8e84f18..4490efa 100644 --- a/docs/x-frontmcp.md +++ b/docs/x-frontmcp.md @@ -109,6 +109,28 @@ x-frontmcp: input: {} ``` +### meta + +MCP `_meta` entries emitted on the tool (merged over `x-mcp.meta` and any generated `emitMeta` entry — see [Modern MCP Fields](./modern-mcp-fields.md)): + +```yaml +x-frontmcp: + meta: + com.example/billing-tier: pro +``` + +### icons + +Tool icons (MCP 2025-11-25 shape; replaces any `x-mcp.icons` wholesale): + +```yaml +x-frontmcp: + icons: + - src: https://example.com/invoice.png + mimeType: image/png + sizes: ["48x48"] +``` + --- ## Accessing in Code diff --git a/src/__tests__/annotations.spec.ts b/src/__tests__/annotations.spec.ts index b77082a..5c8c341 100644 --- a/src/__tests__/annotations.spec.ts +++ b/src/__tests__/annotations.spec.ts @@ -192,3 +192,74 @@ describe('extractExtensionOverrides', () => { }); }); }); + +describe('meta and icons extension extraction', () => { + it('reads meta and icons from the x-mcp object form', () => { + const overrides = extractExtensionOverrides({ + 'x-mcp': { + meta: { 'com.example/a': 1 }, + icons: [{ src: 'https://e.com/i.png', mimeType: 'image/png', sizes: ['48x48'] }], + }, + } as any); + expect(overrides.meta).toEqual({ 'com.example/a': 1 }); + expect(overrides.icons).toEqual([{ src: 'https://e.com/i.png', mimeType: 'image/png', sizes: ['48x48'] }]); + }); + + it('reads meta and icons from x-frontmcp even without annotations', () => { + const overrides = extractExtensionOverrides({ + 'x-frontmcp': { meta: { 'com.example/b': 2 }, icons: [{ src: 'https://e.com/f.png' }] }, + } as any); + expect(overrides.meta).toEqual({ 'com.example/b': 2 }); + expect(overrides.icons).toEqual([{ src: 'https://e.com/f.png' }]); + expect(overrides.annotations).toBeUndefined(); + expect(overrides.title).toBeUndefined(); + }); + + it('merges meta key-by-key and replaces icons wholesale across layers', () => { + const overrides = extractExtensionOverrides({ + 'x-mcp': { meta: { keep: 1, shared: 'mcp' }, icons: [{ src: 'https://e.com/mcp.png' }] }, + 'x-frontmcp': { meta: { shared: 'frontmcp' }, icons: [{ src: 'https://e.com/front.png' }] }, + } as any); + expect(overrides.meta).toEqual({ keep: 1, shared: 'frontmcp' }); + expect(overrides.icons).toEqual([{ src: 'https://e.com/front.png' }]); + }); + + it('ignores malformed meta and icon entries', () => { + const overrides = extractExtensionOverrides({ + 'x-mcp': { + meta: ['not', 'an', 'object'], + icons: [ + 'not-an-object', + { mimeType: 'image/png' }, + { src: '' }, + { src: 'https://e.com/ok.png', mimeType: 42, sizes: ['48x48', 7] }, + ['array'], + ], + }, + } as any); + expect(overrides.meta).toBeUndefined(); + expect(overrides.icons).toEqual([{ src: 'https://e.com/ok.png' }]); + }); + + it('returns undefined icons when nothing well-formed remains', () => { + const overrides = extractExtensionOverrides({ 'x-mcp': { icons: [{ bad: true }] } } as any); + expect(overrides.icons).toBeUndefined(); + }); + + it('does not read meta or icons from x-speakeasy-mcp', () => { + const overrides = extractExtensionOverrides({ + 'x-speakeasy-mcp': { meta: { 'com.example/x': 1 }, icons: [{ src: 'https://e.com/s.png' }] }, + } as any); + expect(overrides.meta).toBeUndefined(); + expect(overrides.icons).toBeUndefined(); + }); + + it('still promotes x-frontmcp annotations.title alongside meta', () => { + const overrides = extractExtensionOverrides({ + 'x-frontmcp': { annotations: { title: 'Nice', readOnlyHint: true }, meta: { m: 1 } }, + } as any); + expect(overrides.title).toBe('Nice'); + expect(overrides.annotations).toEqual({ title: 'Nice', readOnlyHint: true }); + expect(overrides.meta).toEqual({ m: 1 }); + }); +}); diff --git a/src/__tests__/elicitation.spec.ts b/src/__tests__/elicitation.spec.ts new file mode 100644 index 0000000..51ddc43 --- /dev/null +++ b/src/__tests__/elicitation.spec.ts @@ -0,0 +1,124 @@ +/** Tests for security elicitation descriptors */ +import { deriveSecurityElicitations } from '../elicitation'; +import { OpenAPIToolGenerator } from '../generator'; +import type { McpOpenAPITool } from '../types'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +const toolWith = (extras: Partial): McpOpenAPITool => ({ + name: 't', + description: 'd', + inputSchema: { type: 'object', properties: {} }, + mapper: [], + metadata: { path: '/t', method: 'get' }, + ...extras, +}); + +const mapperEntry = (security: any): any => ({ + inputKey: security.scheme, + type: 'header', + key: 'Authorization', + required: true, + security, +}); + +describe('deriveSecurityElicitations', () => { + it('returns an empty array for tools without security', () => { + expect(deriveSecurityElicitations(toolWith({}))).toEqual([]); + }); + + it('describes bearer tokens with format hints', () => { + const [e] = deriveSecurityElicitations( + toolWith({ mapper: [mapperEntry({ scheme: 'auth', type: 'http', httpScheme: 'bearer', bearerFormat: 'JWT' })] }), + ); + expect(e.scheme).toBe('auth'); + expect(e.message).toBe('Provide the bearer token for "auth".'); + expect(e.requestedSchema.properties['token'].description).toBe('HTTP bearer authentication token (JWT).'); + expect(e.requestedSchema.required).toEqual(['token']); + }); + + it('defaults http schemes to bearer and skips the format suffix when absent', () => { + const [e] = deriveSecurityElicitations(toolWith({ mapper: [mapperEntry({ scheme: 'auth', type: 'http' })] })); + expect(e.requestedSchema.properties['token'].description).toBe('HTTP bearer authentication token.'); + }); + + it('requests username and password for basic and digest', () => { + for (const httpScheme of ['basic', 'digest', 'Basic']) { + const [e] = deriveSecurityElicitations( + toolWith({ mapper: [mapperEntry({ scheme: 's', type: 'http', httpScheme })] }), + ); + expect(e.message).toBe(`Provide HTTP ${httpScheme.toLowerCase()} credentials for "s".`); + expect(e.requestedSchema.required).toEqual(['username', 'password']); + } + }); + + it('describes api keys with their wire name and location', () => { + const [e] = deriveSecurityElicitations( + toolWith({ mapper: [mapperEntry({ scheme: 'key', type: 'apiKey', apiKeyName: 'X-API-Key', apiKeyIn: 'query' })] }), + ); + expect(e.requestedSchema.properties['apiKey'].description).toBe('API key "X-API-Key" sent via query.'); + const [d] = deriveSecurityElicitations(toolWith({ mapper: [mapperEntry({ scheme: 'key', type: 'apiKey' })] })); + expect(d.requestedSchema.properties['apiKey'].description).toBe('API key "key" sent via header.'); + }); + + it('describes oauth2 and openIdConnect with scopes', () => { + const [e] = deriveSecurityElicitations( + toolWith({ mapper: [mapperEntry({ scheme: 'oauth', type: 'oauth2', scopes: ['read', 'write'] })] }), + ); + expect(e.message).toBe('Provide an OAuth2 access token for "oauth". Scopes: read, write.'); + expect(e.requestedSchema.required).toEqual(['accessToken']); + const [o] = deriveSecurityElicitations(toolWith({ mapper: [mapperEntry({ scheme: 'oidc', type: 'openIdConnect' })] })); + expect(o.message).toBe('Provide an OAuth2 access token for "oidc".'); + }); + + it('skips schemes without elicitable credentials and dedupes repeats', () => { + const tool = toolWith({ + mapper: [ + mapperEntry({ scheme: 'mtls', type: 'mutualTLS' }), + mapperEntry({ scheme: 'auth', type: 'http' }), + mapperEntry({ scheme: 'auth', type: 'http' }), + ], + }); + const result = deriveSecurityElicitations(tool); + expect(result).toHaveLength(1); + expect(result[0].scheme).toBe('auth'); + }); + + it('falls back to metadata.security when the mapper carries none', () => { + const tool = toolWith({ + metadata: { + path: '/t', + method: 'get', + security: [ + { scheme: 'key', type: 'apiKey', name: 'X-Key', in: 'cookie' }, + { scheme: 'key', type: 'apiKey', name: 'X-Key', in: 'cookie' }, + { scheme: 'oauth', type: 'oauth2', scopes: ['a'] }, + ], + }, + }); + const result = deriveSecurityElicitations(tool); + expect(result).toHaveLength(2); + expect(result[0].requestedSchema.properties['apiKey'].description).toBe('API key "X-Key" sent via cookie.'); + expect(result[1].scheme).toBe('oauth'); + }); + + it('derives from a generated tool end-to-end', async () => { + const spec: any = { + openapi: '3.0.0', + info: { title: 'Sec API', version: '1.0.0' }, + components: { + securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' } }, + }, + paths: { + '/me': { + get: { operationId: 'me', security: [{ bearerAuth: [] }], responses: { '200': { description: 'OK' } } }, + }, + }, + }; + const generator = await OpenAPIToolGenerator.fromJSON(spec, { validate: false }); + const tool = await generator.generateTool('/me', 'get'); + const [e] = deriveSecurityElicitations(tool); + expect(e.scheme).toBe('bearerAuth'); + expect(e.requestedSchema.properties['token'].description).toBe('HTTP bearer authentication token (JWT).'); + }); +}); diff --git a/src/__tests__/generator.spec.ts b/src/__tests__/generator.spec.ts index fe49c7b..386fb42 100644 --- a/src/__tests__/generator.spec.ts +++ b/src/__tests__/generator.spec.ts @@ -4580,3 +4580,137 @@ describe('Type-signature depth follows maxSchemaDepth', () => { expect(tool.metadata.typescript?.signature).toContain('l9?: string'); }); }); + +describe('Modern-spec surface: _meta, icons, x-mcp-header', () => { + const baseSpec = (): any => ({ + openapi: '3.0.0', + info: { title: 'Meta API', version: '2.0.0', 'x-logo': { url: 'https://example.com/logo.png' } }, + paths: { + '/items': { + get: { + operationId: 'listItems', + tags: ['items'], + deprecated: true, + responses: { '200': { description: 'OK' } }, + }, + }, + }, + }); + + it('emits the namespaced operation _meta entry when emitMeta is set', async () => { + const generator = await OpenAPIToolGenerator.fromJSON(baseSpec(), { validate: false }); + const tool = await generator.generateTool('/items', 'get', { emitMeta: true }); + expect(tool._meta).toEqual({ + 'dev.agentfront.openapi/operation': { + path: '/items', + method: 'get', + operationId: 'listItems', + tags: ['items'], + deprecated: true, + specTitle: 'Meta API', + specVersion: '2.0.0', + }, + }); + }); + + it('elides absent operation fields from the _meta entry', async () => { + const spec = baseSpec(); + spec.info = { title: 'Meta API', version: '2.0.0' }; + spec.paths['/items'].get = { responses: { '200': { description: 'OK' } } }; + const generator = await OpenAPIToolGenerator.fromJSON(spec, { validate: false }); + const tool = await generator.generateTool('/items', 'get', { emitMeta: true }); + const entry = (tool._meta as any)['dev.agentfront.openapi/operation']; + expect(entry).toEqual({ path: '/items', method: 'get', specTitle: 'Meta API', specVersion: '2.0.0' }); + }); + + it('omits _meta entirely by default and passes extension meta through even then', async () => { + const generator = await OpenAPIToolGenerator.fromJSON(baseSpec(), { validate: false }); + const plain = await generator.generateTool('/items', 'get'); + expect(plain._meta).toBeUndefined(); + + const spec = baseSpec(); + spec.paths['/items'].get['x-mcp'] = { meta: { 'com.example/flag': true } }; + const extGenerator = await OpenAPIToolGenerator.fromJSON(spec, { validate: false }); + const tool = await extGenerator.generateTool('/items', 'get'); + expect(tool._meta).toEqual({ 'com.example/flag': true }); + }); + + it('merges extension meta over the generated entry with x-frontmcp winning', async () => { + const spec = baseSpec(); + spec.paths['/items'].get['x-mcp'] = { meta: { 'com.example/flag': true, 'com.example/level': 1 } }; + spec.paths['/items'].get['x-frontmcp'] = { meta: { 'com.example/level': 2 } }; + const generator = await OpenAPIToolGenerator.fromJSON(spec, { validate: false }); + const tool = await generator.generateTool('/items', 'get', { emitMeta: true }); + expect((tool._meta as any)['com.example/flag']).toBe(true); + expect((tool._meta as any)['com.example/level']).toBe(2); + expect((tool._meta as any)['dev.agentfront.openapi/operation']).toBeDefined(); + }); + + it('emits extension icons always, and the document logo only on opt-in', async () => { + const spec = baseSpec(); + spec.paths['/items'].get['x-mcp'] = { icons: [{ src: 'https://example.com/op.png', mimeType: 'image/png' }] }; + const generator = await OpenAPIToolGenerator.fromJSON(spec, { validate: false }); + const tool = await generator.generateTool('/items', 'get'); + expect(tool.icons).toEqual([{ src: 'https://example.com/op.png', mimeType: 'image/png' }]); + + const plainGenerator = await OpenAPIToolGenerator.fromJSON(baseSpec(), { validate: false }); + expect((await plainGenerator.generateTool('/items', 'get')).icons).toBeUndefined(); + const inherited = await plainGenerator.generateTool('/items', 'get', { inheritDocumentIcons: true }); + expect(inherited.icons).toEqual([{ src: 'https://example.com/logo.png' }]); + }); + + it('supports string x-logo and ignores malformed logos', async () => { + const stringLogo = baseSpec(); + stringLogo.info['x-logo'] = 'https://example.com/s.svg'; + const g1 = await OpenAPIToolGenerator.fromJSON(stringLogo, { validate: false }); + expect((await g1.generateTool('/items', 'get', { inheritDocumentIcons: true })).icons).toEqual([ + { src: 'https://example.com/s.svg' }, + ]); + + const badLogo = baseSpec(); + badLogo.info['x-logo'] = { alt: 'no url' }; + const g2 = await OpenAPIToolGenerator.fromJSON(badLogo, { validate: false }); + expect((await g2.generateTool('/items', 'get', { inheritDocumentIcons: true })).icons).toBeUndefined(); + + const emptyLogo = baseSpec(); + emptyLogo.info['x-logo'] = ''; + const g3 = await OpenAPIToolGenerator.fromJSON(emptyLogo, { validate: false }); + expect((await g3.generateTool('/items', 'get', { inheritDocumentIcons: true })).icons).toBeUndefined(); + + const noInfo = baseSpec(); + delete noInfo.info; + const g4 = await OpenAPIToolGenerator.fromJSON(noInfo, { validate: false }); + expect((await g4.generateTool('/items', 'get', { inheritDocumentIcons: true })).icons).toBeUndefined(); + }); + + it('marks header parameters with x-mcp-header, surviving conflict renames and client targets', async () => { + const spec: any = { + openapi: '3.0.0', + info: { title: 'Header API', version: '1.0.0' }, + components: { securitySchemes: { apiAuth: { type: 'apiKey', name: 'X-Auth', in: 'header' } } }, + paths: { + '/data/{trace}': { + get: { + operationId: 'getData', + security: [{ apiAuth: [] }], + parameters: [ + { name: 'trace', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'trace', in: 'header', schema: { type: 'string' } }, + { name: 'limit', in: 'query', schema: { type: 'integer' } }, + ], + responses: { '200': { description: 'OK' } }, + }, + }, + }, + }; + const generator = await OpenAPIToolGenerator.fromJSON(spec, { validate: false }); + const tool = await generator.generateTool('/data/{trace}', 'get', { + includeSecurityInInput: true, + target: 'gemini', + }); + const props = (tool.inputSchema as any).properties; + expect(props.headerTrace['x-mcp-header']).toBe('trace'); + expect(props.limit['x-mcp-header']).toBeUndefined(); + expect(props.apiAuth['x-mcp-header']).toBe('X-Auth'); + }); +}); diff --git a/src/annotations.ts b/src/annotations.ts index c050fdb..3aab23d 100644 --- a/src/annotations.ts +++ b/src/annotations.ts @@ -1,4 +1,4 @@ -import type { FrontMcpExtensionData, HTTPMethod, OperationObject, ToolAnnotations } from './types'; +import type { FrontMcpExtensionData, HTTPMethod, OperationObject, ToolAnnotations, ToolIcon } from './types'; /** * Tool-level overrides read from the `x-mcp` extension family on an operation. @@ -30,6 +30,17 @@ export interface ExtensionToolOverrides { * Annotation overrides, merged field-by-field over inferred values. */ annotations?: ToolAnnotations; + + /** + * MCP `_meta` entries supplied by the extension (merged key-by-key across + * layers, emitted on the tool's `_meta` even when `emitMeta` is off). + */ + meta?: Record; + + /** + * Tool icons supplied by the extension (later layers replace wholesale). + */ + icons?: ToolIcon[]; } /** @@ -81,9 +92,42 @@ function mergeOverrides(base: ExtensionToolOverrides, layer: ExtensionToolOverri ...((base.annotations || layer.annotations) && { annotations: { ...base.annotations, ...layer.annotations }, }), + ...((base.meta || layer.meta) && { meta: { ...base.meta, ...layer.meta } }), + ...(layer.icons !== undefined && { icons: layer.icons }), }; } +/** Accept only a plain (non-array) object as a `_meta` contribution. */ +function sanitizeMeta(value: unknown): Record | undefined { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Record; + } + return undefined; +} + +/** Keep only well-formed icon entries: objects with a string `src`, copying + * just the MCP icon fields (`src`, `mimeType`, `sizes`). */ +function sanitizeIcons(value: unknown): ToolIcon[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const icons: ToolIcon[] = []; + for (const entry of value) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue; + const raw = entry as Record; + if (typeof raw['src'] !== 'string' || raw['src'] === '') continue; + const icon: ToolIcon = { src: raw['src'] }; + if (typeof raw['mimeType'] === 'string') { + icon.mimeType = raw['mimeType']; + } + if (Array.isArray(raw['sizes']) && raw['sizes'].every((s) => typeof s === 'string')) { + icon.sizes = raw['sizes'] as string[]; + } + icons.push(icon); + } + return icons.length > 0 ? icons : undefined; +} + /** Read the `x-mcp` extension off any spec node (document, path item, operation). */ function readXMcp(node: object): unknown { return (node as { 'x-mcp'?: unknown })['x-mcp']; @@ -165,19 +209,27 @@ export function extractExtensionOverrides(operation: OperationObject): Extension title: typeof ext['title'] === 'string' ? ext['title'] : undefined, description: typeof ext['description'] === 'string' ? ext['description'] : undefined, annotations: pickAnnotations(ext['annotations'] as Record | undefined), + meta: sanitizeMeta(ext['meta']), + icons: sanitizeIcons(ext['icons']), }); } - // 3. x-frontmcp (highest precedence): only `annotations` maps onto tool - // overrides; the rest of the extension (cache, codecall, tags, ...) flows - // through `metadata.frontmcp` untouched. + // 3. x-frontmcp (highest precedence): `annotations`, `meta`, and `icons` + // map onto tool overrides; the rest of the extension (cache, codecall, + // tags, ...) flows through `metadata.frontmcp` untouched. const frontmcp = op['x-frontmcp'] as FrontMcpExtensionData | undefined; - if (frontmcp && typeof frontmcp === 'object' && frontmcp.annotations) { - const annotations = pickAnnotations(frontmcp.annotations as Record); - result = mergeOverrides(result, { - annotations, - title: typeof frontmcp.annotations.title === 'string' ? frontmcp.annotations.title : undefined, - }); + if (frontmcp && typeof frontmcp === 'object') { + const layer: ExtensionToolOverrides = { + meta: sanitizeMeta(frontmcp.meta), + icons: sanitizeIcons(frontmcp.icons), + }; + if (frontmcp.annotations) { + layer.annotations = pickAnnotations(frontmcp.annotations as Record); + if (typeof frontmcp.annotations.title === 'string') { + layer.title = frontmcp.annotations.title; + } + } + result = mergeOverrides(result, layer); } return result; diff --git a/src/elicitation.ts b/src/elicitation.ts new file mode 100644 index 0000000..ee448c7 --- /dev/null +++ b/src/elicitation.ts @@ -0,0 +1,161 @@ +/** + * Security elicitation descriptors. + * + * Derives MCP-elicitation-compatible `{ message, requestedSchema }` request + * descriptors from a tool's resolved security data, so a server can ask the + * user for missing credentials in the shape `elicitInput` expects. Pure data + * derivation — transport policy is the consumer's. Note the MCP guidance: + * servers SHOULD NOT elicit secrets over untrusted paths; prefer dedicated + * credential flows where available. + */ +import type { McpOpenAPITool, SecurityParameterInfo } from './types'; + +/** A flat string property in an elicitation `requestedSchema`. */ +export interface ElicitationField { + type: 'string'; + title?: string; + description?: string; +} + +/** MCP elicitation request descriptor derived from a tool's security data. */ +export interface SecurityElicitation { + /** + * OpenAPI security scheme name (as declared in `components.securitySchemes`). + */ + scheme: string; + + /** + * Human-readable prompt (`ElicitRequest.message`). + */ + message: string; + + /** + * Flat requested schema — primitive string properties only, per MCP + * elicitation rules. + */ + requestedSchema: { + type: 'object'; + properties: Record; + required: string[]; + }; +} + +/** Normalized view over `mapper[].security` / `metadata.security` entries. */ +interface SecuritySource { + scheme: string; + type: string; + httpScheme?: string; + bearerFormat?: string; + scopes?: string[]; + apiKeyName?: string; + apiKeyIn?: string; +} + +function buildElicitation(source: SecuritySource): SecurityElicitation | undefined { + const { scheme, type } = source; + if (type === 'http') { + const httpScheme = (source.httpScheme ?? 'bearer').toLowerCase(); + if (httpScheme === 'basic' || httpScheme === 'digest') { + return { + scheme, + message: `Provide HTTP ${httpScheme} credentials for "${scheme}".`, + requestedSchema: { + type: 'object', + properties: { + username: { type: 'string', title: 'Username' }, + password: { type: 'string', title: 'Password', description: 'Handled as a secret — never logged.' }, + }, + required: ['username', 'password'], + }, + }; + } + const format = source.bearerFormat ? ` (${source.bearerFormat})` : ''; + return { + scheme, + message: `Provide the ${httpScheme} token for "${scheme}".`, + requestedSchema: { + type: 'object', + properties: { + token: { type: 'string', title: 'Token', description: `HTTP ${httpScheme} authentication token${format}.` }, + }, + required: ['token'], + }, + }; + } + if (type === 'apiKey') { + const keyName = source.apiKeyName ?? scheme; + const location = source.apiKeyIn ?? 'header'; + return { + scheme, + message: `Provide the API key for "${scheme}".`, + requestedSchema: { + type: 'object', + properties: { + apiKey: { type: 'string', title: 'API key', description: `API key "${keyName}" sent via ${location}.` }, + }, + required: ['apiKey'], + }, + }; + } + if (type === 'oauth2' || type === 'openIdConnect') { + const scopes = source.scopes && source.scopes.length > 0 ? ` Scopes: ${source.scopes.join(', ')}.` : ''; + return { + scheme, + message: `Provide an OAuth2 access token for "${scheme}".${scopes}`, + requestedSchema: { + type: 'object', + properties: { + accessToken: { type: 'string', title: 'Access token', description: `OAuth2 access token.${scopes}` }, + }, + required: ['accessToken'], + }, + }; + } + // mutualTLS and custom types have no elicitable string credentials + return undefined; +} + +/** + * Derive credential-elicitation descriptors from a generated tool — one per + * distinct security scheme, in mapper order. Falls back to + * `metadata.security` for hand-built tools without security mapper entries. + * Returns `[]` when the tool declares no security. + */ +export function deriveSecurityElicitations(tool: McpOpenAPITool): SecurityElicitation[] { + const sources: SecuritySource[] = []; + const seen = new Set(); + + for (const entry of tool.mapper) { + const security: SecurityParameterInfo | undefined = entry.security; + if (security && !seen.has(security.scheme)) { + seen.add(security.scheme); + sources.push(security); + } + } + + if (sources.length === 0 && tool.metadata.security) { + for (const requirement of tool.metadata.security) { + if (!seen.has(requirement.scheme)) { + seen.add(requirement.scheme); + sources.push({ + scheme: requirement.scheme, + type: requirement.type, + httpScheme: requirement.httpScheme, + bearerFormat: requirement.bearerFormat, + scopes: requirement.scopes, + apiKeyName: requirement.name, + apiKeyIn: requirement.in, + }); + } + } + } + + const result: SecurityElicitation[] = []; + for (const source of sources) { + const elicitation = buildElicitation(source); + if (elicitation) { + result.push(elicitation); + } + } + return result; +} diff --git a/src/generator.ts b/src/generator.ts index e70bad2..e9669bc 100644 --- a/src/generator.ts +++ b/src/generator.ts @@ -21,6 +21,7 @@ import type { ServerObject, PathItemObject, JsonSchema, + ToolIcon, } from './types'; import type { ParserOptions } from '@apidevtools/json-schema-ref-parser'; import { isReferenceObject } from './types'; @@ -232,6 +233,27 @@ function matchesAnyGlob(path: string, globs: string[]): boolean { * repetition is polynomial on adversarial inputs (CodeQL js/polynomial-redos), * and tool names derive from uncontrolled spec data. */ +/** + * Map the document's `info['x-logo']` (Redoc convention: a URL string or an + * object with `url`) to a single tool icon. + */ +function iconsFromInfoLogo(info: unknown): ToolIcon[] | undefined { + if (!info || typeof info !== 'object') { + return undefined; + } + const logo = (info as Record)['x-logo']; + if (typeof logo === 'string' && logo !== '') { + return [{ src: logo }]; + } + if (logo && typeof logo === 'object' && !Array.isArray(logo)) { + const url = (logo as Record)['url']; + if (typeof url === 'string' && url !== '') { + return [{ src: url }]; + } + } + return undefined; +} + function trimUnderscores(value: string): string { let start = 0; let end = value.length; @@ -916,11 +938,37 @@ export class OpenAPIToolGenerator { }); } + // MCP `_meta`: generated operation entry (opt-in) + extension pass-through (always) + let toolMeta: Record | undefined; + if (options.emitMeta) { + const info = document.info as Record | undefined; + toolMeta = { + 'dev.agentfront.openapi/operation': { + path: pathStr, + method, + ...(operation.operationId !== undefined && { operationId: operation.operationId }), + ...(operation.tags && { tags: operation.tags }), + ...(operation.deprecated !== undefined && { deprecated: operation.deprecated }), + ...(typeof info?.['title'] === 'string' && { specTitle: info['title'] }), + ...(typeof info?.['version'] === 'string' && { specVersion: info['version'] }), + }, + }; + } + if (overrides.meta) { + toolMeta = { ...toolMeta, ...overrides.meta }; + } + + // Icons: extension-supplied wins; document logo only on explicit opt-in + const icons = + overrides.icons ?? (options.inheritDocumentIcons ? iconsFromInfoLogo(document.info) : undefined); + return { name, ...(title !== undefined && { title }), description: finalDescription, ...(annotations && { annotations }), + ...(toolMeta && { _meta: toolMeta }), + ...(icons && { icons }), inputSchema: resolvedInputSchema, outputSchema: resolvedOutputSchema, mapper, diff --git a/src/index.ts b/src/index.ts index caeb6cd..d3a56f4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,10 @@ export type { ToolTypeScriptInfo, TypeSignatureOptions } from './type-signature' // Naming presets export { dottedNaming, CODECALL_RESERVED_NAMESPACES } from './naming-presets'; export type { DottedNamingOptions } from './naming-presets'; + +// Security elicitation descriptors +export { deriveSecurityElicitations } from './elicitation'; +export type { SecurityElicitation, ElicitationField } from './elicitation'; export { applyClientTarget, inlineLocalRefs, @@ -70,6 +74,7 @@ export type { // Main MCP types McpOpenAPITool, ToolAnnotations, + ToolIcon, ParameterMapper, ToolMetadata, ResponseHints, diff --git a/src/parameter-resolver.ts b/src/parameter-resolver.ts index 5352516..64c84e3 100644 --- a/src/parameter-resolver.ts +++ b/src/parameter-resolver.ts @@ -296,6 +296,10 @@ export class ParameterResolver { // Add parameter metadata (schema as any)['x-parameter-location'] = param.location; + if (param.location === 'header') { + // Original wire header name (conflict renames only change the inputKey) + (schema as any)['x-mcp-header'] = param.name; + } if (param.style) { (schema as any)['x-parameter-style'] = param.style; } @@ -424,6 +428,9 @@ export class ParameterResolver { // (all schemes stay in the mapper either way) const schemeInInput = includeInInput === true || (Array.isArray(includeInInput) && includeInInput.includes(scheme)); if (schemeInInput) { + if (paramLocation === 'header') { + (schema as any)['x-mcp-header'] = headerKey; + } properties[inputKey] = schema; required.push(inputKey); } diff --git a/src/types.ts b/src/types.ts index b3c0a7c..054d56e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -269,6 +269,26 @@ export interface ToolAnnotations { openWorldHint?: boolean; } +/** + * Tool icon (MCP spec 2025-11-25). + */ +export interface ToolIcon { + /** + * Icon URI (`https:` or `data:`). + */ + src: string; + + /** + * MIME type, e.g. `image/png`. + */ + mimeType?: string; + + /** + * Sizes the icon is available in, e.g. `['48x48', 'any']`. + */ + sizes?: string[]; +} + /** * Main MCP Tool definition generated from OpenAPI. * @@ -300,6 +320,22 @@ export interface McpOpenAPITool { */ annotations?: ToolAnnotations; + /** + * MCP `_meta` (spec 2025-06-18): namespaced, client-visible metadata. + * Contains the `dev.agentfront.openapi/operation` entry when + * `GenerateOptions.emitMeta` is set, plus any `meta` object supplied via + * the `x-mcp` / `x-frontmcp` extensions (emitted even when the flag is + * off). + */ + _meta?: Record; + + /** + * Tool icons (MCP spec 2025-11-25). From `x-frontmcp.icons` / + * `x-mcp.icons`, or the document's `info['x-logo']` when + * `GenerateOptions.inheritDocumentIcons` is set. + */ + icons?: ToolIcon[]; + /** * Combined input schema including all parameters * (path, query, header, cookie, body) @@ -594,6 +630,16 @@ export interface FrontMcpExtensionData { input: Record; output?: unknown; }>; + + /** + * MCP `_meta` entries to emit on the tool. + */ + meta?: Record; + + /** + * Tool icons to emit on the tool. + */ + icons?: ToolIcon[]; } /** @@ -999,6 +1045,25 @@ export interface GenerateOptions { * @default false */ emitTypeSignatures?: boolean; + + /** + * Emit `_meta['dev.agentfront.openapi/operation']` on every tool with the + * source operation's coordinates: `{ path, method, operationId?, tags?, + * deprecated?, specTitle?, specVersion? }` (reverse-DNS key per MCP `_meta` + * conventions). Extension-supplied `meta` (`x-mcp` / `x-frontmcp`) merges + * on top and is emitted even when this flag is off. + * @default false + */ + emitMeta?: boolean; + + /** + * When an operation has no extension-supplied icons, fall back to the + * document's `info['x-logo']` (Redoc convention) as a single icon applied + * to every tool. Off by default so one logo doesn't silently inflate all + * tool definitions. + * @default false + */ + inheritDocumentIcons?: boolean; } /** From a7f1811c9536715448e828899f195b1dbc3e16d6 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Thu, 13 Aug 2026 02:37:13 +0300 Subject: [PATCH 06/10] feat: add fromArazzo() converting Arazzo 1.0 workflows into consolidated MCP tools --- CLAUDE.md | 3 +- README.md | 1 + docs/api-reference.md | 9 + docs/arazzo.md | 81 +++ jest.config.js | 2 + src/__tests__/arazzo.spec.ts | 1125 +++++++++++++++++++++++++++++ src/__tests__/errors.spec.ts | 12 +- src/__tests__/integration.spec.ts | 71 ++ src/arazzo-expressions.ts | 189 +++++ src/arazzo-types.ts | 303 ++++++++ src/arazzo.ts | 1085 ++++++++++++++++++++++++++++ src/errors.ts | 13 + src/generator.ts | 4 +- src/index.ts | 37 +- src/types.ts | 9 + 15 files changed, 2939 insertions(+), 5 deletions(-) create mode 100644 docs/arazzo.md create mode 100644 src/__tests__/arazzo.spec.ts create mode 100644 src/arazzo-expressions.ts create mode 100644 src/arazzo-types.ts create mode 100644 src/arazzo.ts diff --git a/CLAUDE.md b/CLAUDE.md index beec57c..54b4983 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,13 +44,14 @@ OpenAPIToolGenerator (src/generator.ts) | `src/type-signature.ts` | `emitToolTypeScript` — TypeScript signature/declaration rendering of a tool's call contract (`emitTypeSignatures` option → `metadata.typescript`) | | `src/naming-presets.ts` | `dottedNaming` — two-segment `ns.method` naming preset for CodeCall namespace binding; `CODECALL_RESERVED_NAMESPACES` | | `src/elicitation.ts` | `deriveSecurityElicitations` — MCP elicitation descriptors (`{message, requestedSchema}`) from a tool's security data | +| `src/arazzo.ts` | `fromArazzo()` — Arazzo 1.0 parsing, source/operation resolution, workflow IR + consolidated tools (companions: `arazzo-types.ts`, `arazzo-expressions.ts`) | | `src/parameter-resolver.ts` | Resolves OpenAPI parameters + requestBody into flat inputSchema with conflict resolution; flattens `allOf` bodies, flags `wholeBody`/`binary` | | `src/response-builder.ts` | Builds outputSchema from OpenAPI responses with content-type and status code preferences | | `src/format-resolver.ts` | Format-to-schema resolution. Built-in resolvers for uuid, date-time, email, int32, etc. | | `src/schema-builder.ts` | Static utilities: merge, union, clone, flatten, simplify, withFormat, etc. | | `src/security-resolver.ts` | Resolves security schemes (Bearer, Basic, Digest, API Key, OAuth2, OpenID Connect) | | `src/validator.ts` | Validates OpenAPI document structure | -| `src/errors.ts` | Error class hierarchy: LoadError, ParseError, ValidationError, GenerationError, SchemaError | +| `src/errors.ts` | Error class hierarchy: LoadError, ParseError, ValidationError, GenerationError, SchemaError, OverlayError, RequestBuildError, ArazzoError | | `src/index.ts` | Barrel file for public exports | ## Development Commands diff --git a/README.md b/README.md index ad5373a..3e0ba82 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,7 @@ for (const tool of await generator.generateTools({ target: "claude" })) { | [Curation](https://github.com/agentfront/mcp-from-openapi/blob/main/docs/curation.md) | Token budgets, overlays, lint, trimming, response hints | | [Type Signatures](https://github.com/agentfront/mcp-from-openapi/blob/main/docs/type-signatures.md) | TypeScript call contracts for code-execution surfaces | | [Modern MCP Fields](https://github.com/agentfront/mcp-from-openapi/blob/main/docs/modern-mcp-fields.md) | Tool `_meta`, icons, `x-mcp-header`, elicitation descriptors | +| [Arazzo Workflows](https://github.com/agentfront/mcp-from-openapi/blob/main/docs/arazzo.md) | fromArazzo() — Arazzo 1.0 workflows as consolidated MCP tools | | [Response Schemas](https://github.com/agentfront/mcp-from-openapi/blob/main/docs/response-schemas.md) | Output schemas, status codes, oneOf unions | | [Annotations & Extensions](https://github.com/agentfront/mcp-from-openapi/blob/main/docs/annotations.md) | Tool title, annotation inference, `x-mcp` extension family | | [Security](https://github.com/agentfront/mcp-from-openapi/blob/main/docs/security.md) | SecurityResolver, all auth types, custom resolvers | diff --git a/docs/api-reference.md b/docs/api-reference.md index 0cc7dfc..edf8d0a 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -141,6 +141,15 @@ Apply a client dialect's schema transforms (`'claude' | 'openai' | 'gemini' | 's applyClientTarget(schema: JsonSchema, target: ClientTarget): JsonSchema ``` +### fromArazzo / parseRuntimeExpression + +Convert an Arazzo 1.0 workflow document into consolidated MCP tools (one per workflow, IR on `metadata.workflow`); parse Arazzo runtime expressions standalone. Throws `ArazzoError` with a JSON-Pointer `path`. See [Arazzo Workflows](./arazzo.md). + +```typescript +fromArazzo(document: ArazzoDocument | string, options: FromArazzoOptions): Promise +parseRuntimeExpression(raw: string, docPath?: string): RuntimeExpressionAST +``` + ### deriveSecurityElicitations Derive MCP-elicitation-compatible `{ message, requestedSchema }` credential requests from a tool's security data. See [Modern MCP Fields](./modern-mcp-fields.md). diff --git a/docs/arazzo.md b/docs/arazzo.md new file mode 100644 index 0000000..fdd1f08 --- /dev/null +++ b/docs/arazzo.md @@ -0,0 +1,81 @@ +# Arazzo Workflows + +[Home](../README.md) | [Configuration](./configuration.md) | [API Reference](./api-reference.md) + +--- + +Tool consolidation is the ecosystem's consensus answer to context bloat, and [Arazzo 1.0](https://spec.openapis.org/arazzo/v1.0.0.html) is its standards-track format: a document describing multi-step workflows over one or more OpenAPI APIs. `fromArazzo()` turns each workflow into **one** consolidated MCP tool — workflow inputs become the tool's input schema, workflow outputs derive its output schema, and a pure, JSON-serializable IR carries the step sequence. The library never fetches source URLs, performs HTTP, or evaluates expressions — an executor (e.g. a framework like FrontMCP) drives the IR. + +## Quick start + +```typescript +import { fromArazzo } from 'mcp-from-openapi'; + +const tools = await fromArazzo(arazzoYamlOrObject, { + sources: { pets: petstoreDocument, orders: ordersGenerator }, // name → document or generator + generateOptions: { target: 'claude', emitTypeSignatures: true }, +}); +// one McpOpenAPITool per workflow, in document order +``` + +## Sources + +`sources` maps every source description **name** to a resolved OpenAPI document or a pre-built `OpenAPIToolGenerator`. URLs in `sourceDescriptions` are **never fetched** — supplying documents keeps loading under the caller's control (and its SSRF posture). A source used by any step must be supplied; unknown keys are rejected; `type: 'arazzo'` sources cannot be used by steps. Step operations resolve by `operationId` (searched across all supplied sources; ambiguity is an error — pin with `$sourceDescriptions..`) or by `operationPath` (`{$sourceDescriptions.pets.url}#/paths/~1pets~1{petId}/get`). + +## The workflow IR + +The tool's `metadata.workflow` is the complete, self-contained execution plan: + +```typescript +const ir = tool.metadata.workflow!; +ir.steps[0]; +// { +// kind: 'operation', stepId: 'fetch', source: 'pets', +// path: '/pets/{petId}', method: 'get', operationId: 'getPet', +// parameters: [{ name: 'petId', in: 'path', value: { kind: 'expression', expression: {...} } }], +// operation: { inputSchema, outputSchema, mapper, security, servers }, // no second spec pass needed +// outputs: { pet: { type: 'response', source: 'body', raw: '$response.body', ... } }, +// } +``` + +Each operation step embeds the resolved operation's essentials — its `mapper` feeds [`buildHttpRequest`](./request-builder.md) directly. Nested workflow invocations appear as `{ kind: 'workflow', workflowId }` steps (recursion is rejected). `successCriteria` conditions are carried **raw** and never evaluated; `onSuccess`/`onFailure` actions (`end`/`goto`/`retry` with `retryAfter`/`retryLimit`) are captured faithfully. Request bodies keep the verbatim `payload` plus a pointer-keyed `payloadExpressions` substitution list (RFC 6901) and parsed `replacements`. + +**Placeholders:** a workflow tool's `metadata.path` is `arazzo:` and `method` is `'post'` — never feed the workflow tool itself to `buildHttpRequest`; its top-level `mapper` is `[]` by design. Executors drive each step's `operation.mapper`. + +## Runtime expressions + +Every Arazzo runtime expression is parsed into a serializable AST (`{ type, raw, path, source?, name?, pointer? }`) — `$inputs.x`, `$steps.id.outputs.y`, `$response.body#/json/pointer`, `$request.header.Name`, `$statusCode`, `$url`, `$method`, `$workflows.*`, `$sourceDescriptions.*`, `$components.*`. Strings with embedded `{$...}` become templates; strings whose `$` prefix matches no known root (like `"$50"`) stay literals. The parser is exported standalone: + +```typescript +import { parseRuntimeExpression } from 'mcp-from-openapi'; +parseRuntimeExpression('$steps.fetch.outputs.pet'); +// { type: 'steps', raw: '...', path: ['fetch', 'outputs', 'pet'] } +``` + +## Output schema derivation + +Workflow `outputs` derive the tool's output schema best-effort: `$statusCode` → `number`; `$url` / `$method` / header refs → `string`; `$inputs.` → that input's schema; `$steps..outputs.` is chased (depth-capped) into the step's `$response.body` schema, following `#/pointers` through `properties`/`items`. Anything unresolvable degrades to an unconstrained schema. Every derived property keeps the raw expression in its `description` (`Arazzo output: $steps.fetch.outputs.pet`), and outputs are never `required` — they exist only after successful execution. + +## Options + +`ArazzoGenerateOptions` is the schema-shaping subset of [`GenerateOptions`](./configuration.md): `target`, `maxSchemaDepth`, `maxProperties`, `maxDescriptionLength`, `stripExamples`, `includeExamples`, `resolveFormats`/`formatResolvers`, `preferredStatusCodes`, `includeAllResponses`, `maxToolNameLength`, `includeSecurityInInput`, and `emitTypeSignatures`. They apply to the per-step embedded schemas AND the consolidated workflow schemas, in the same order as `generateTool` (formats → depth truncation → trims → client target). Operation-filtering options have no meaning here and are not accepted. + +## Errors + +Every failure throws `ArazzoError` with a JSON-Pointer `path` into the Arazzo document: + +```typescript +try { + await fromArazzo(doc, { sources }); +} catch (error) { + if (error instanceof ArazzoError) { + console.error(error.message, error.path); // e.g. '/workflows/0/steps/2' + } +} +``` + +Structural violations (missing ids, duplicate names, malformed criteria/actions), unresolvable references (`$components.*`, unknown operationIds, missing sources), cyclic `dependsOn` chains, and recursive workflow invocations are all rejected at parse time. + +--- + +**Related:** [Request Builder](./request-builder.md) | [Type Signatures](./type-signatures.md) | [Configuration](./configuration.md) diff --git a/jest.config.js b/jest.config.js index bb9f202..4db4a74 100644 --- a/jest.config.js +++ b/jest.config.js @@ -38,6 +38,8 @@ module.exports = { collectCoverageFrom: [ 'src/**/*.ts', '!src/index.ts', + // Types-only module (import type everywhere) — never loaded at runtime + '!src/arazzo-types.ts', '!src/**/*.spec.ts', '!src/**/*.test.ts', '!src/**/__tests__/**', diff --git a/src/__tests__/arazzo.spec.ts b/src/__tests__/arazzo.spec.ts new file mode 100644 index 0000000..4a97ac1 --- /dev/null +++ b/src/__tests__/arazzo.spec.ts @@ -0,0 +1,1125 @@ +/** Tests for fromArazzo() and Arazzo runtime-expression parsing */ +import { fromArazzo } from '../arazzo'; +import { collectPayloadExpressions, parseExpressionValue, parseRuntimeExpression } from '../arazzo-expressions'; +import { ArazzoError } from '../errors'; +import { OpenAPIToolGenerator } from '../generator'; +import * as yaml from 'yaml'; +import type { OperationStepIR, NestedWorkflowStepIR } from '../arazzo-types'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +const petstoreDoc = (): any => ({ + openapi: '3.0.0', + info: { title: 'Pets', version: '1.0.0' }, + servers: [{ url: 'https://pets.example.com' }], + components: { securitySchemes: { petAuth: { type: 'http', scheme: 'bearer' } } }, + paths: { + '/pets': { + get: { + operationId: 'listPets', + parameters: [{ name: 'limit', in: 'query', schema: { type: 'integer' } }], + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { type: 'array', items: { type: 'object', properties: { id: { type: 'string' } } } }, + }, + }, + }, + }, + }, + post: { + operationId: 'createPet', + security: [{ petAuth: [] }], + requestBody: { + content: { 'application/json': { schema: { type: 'object', properties: { name: { type: 'string' } } } } }, + }, + responses: { '201': { description: 'Created' } }, + }, + }, + '/pets/{petId}': { + get: { + operationId: 'getPet', + parameters: [{ name: 'petId', in: 'path', required: true, schema: { type: 'string' } }], + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + id: { type: 'string' }, + owner: { type: 'object', properties: { email: { type: 'string' } } }, + photos: { type: 'array', items: { type: 'object', properties: { url: { type: 'string' } } } }, + }, + }, + }, + }, + }, + }, + }, + }, + }, +}); + +const ordersDoc = (): any => ({ + openapi: '3.0.0', + info: { title: 'Orders', version: '1.0.0' }, + paths: { + '/orders/{id}': { + get: { + operationId: 'getOrder', + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + responses: { '200': { description: 'OK' } }, + }, + }, + '/shared': { get: { operationId: 'sharedOp', responses: { '200': { description: 'OK' } } } }, + }, +}); + +// sharedOp exists in both sources for ambiguity tests +const petstoreWithShared = (): any => { + const doc = petstoreDoc(); + doc.paths['/shared'] = { get: { operationId: 'sharedOp', responses: { '200': { description: 'OK' } } } }; + return doc; +}; + +const arazzoWith = (workflows: any[], extra: any = {}): any => ({ + arazzo: '1.0.0', + info: { title: 'Flows', version: '1.0.0' }, + sourceDescriptions: [ + { name: 'pets', url: 'https://x/pets.json' }, + { name: 'orders', url: 'https://x/orders.json' }, + ], + workflows, + ...extra, +}); + +const simpleWorkflow = (overrides: any = {}): any => ({ + workflowId: 'getPetFlow', + summary: 'Get a pet', + description: 'Fetch one pet by id.', + inputs: { type: 'object', properties: { petId: { type: 'string' } }, required: ['petId'] }, + steps: [ + { + stepId: 'fetch', + operationId: 'getPet', + parameters: [{ name: 'petId', in: 'path', value: '$inputs.petId' }], + outputs: { pet: '$response.body' }, + }, + ], + outputs: { pet: '$steps.fetch.outputs.pet' }, + ...overrides, +}); + +const sources = () => ({ pets: petstoreDoc(), orders: ordersDoc() }); + +const expectArazzoError = async (promise: Promise, match: RegExp, path?: string): Promise => { + await expect(promise).rejects.toThrow(ArazzoError); + await promise.catch((error: ArazzoError) => { + expect(error.message).toMatch(match); + if (path !== undefined) { + expect(error.path).toBe(path); + } + }); +}; + +describe('parseRuntimeExpression', () => { + it('parses every expression root structurally', () => { + expect(parseRuntimeExpression('$url')).toEqual({ type: 'url', raw: '$url', path: [] }); + expect(parseRuntimeExpression('$method')).toEqual({ type: 'method', raw: '$method', path: [] }); + expect(parseRuntimeExpression('$statusCode')).toEqual({ type: 'statusCode', raw: '$statusCode', path: [] }); + expect(parseRuntimeExpression('$request.header.Accept')).toEqual({ + type: 'request', + raw: '$request.header.Accept', + path: [], + source: 'header', + name: 'Accept', + }); + expect(parseRuntimeExpression('$request.query.limit')).toEqual({ + type: 'request', + raw: '$request.query.limit', + path: [], + source: 'query', + name: 'limit', + }); + expect(parseRuntimeExpression('$request.path.petId')).toEqual({ + type: 'request', + raw: '$request.path.petId', + path: [], + source: 'path', + name: 'petId', + }); + expect(parseRuntimeExpression('$response.body')).toEqual({ type: 'response', raw: '$response.body', path: [], source: 'body' }); + expect(parseRuntimeExpression('$response.body#/a~1b/0')).toEqual({ + type: 'response', + raw: '$response.body#/a~1b/0', + path: [], + source: 'body', + pointer: '/a~1b/0', + }); + expect(parseRuntimeExpression('$response.body#')).toEqual({ + type: 'response', + raw: '$response.body#', + path: [], + source: 'body', + pointer: '', + }); + expect(parseRuntimeExpression('$inputs.petId')).toEqual({ type: 'inputs', raw: '$inputs.petId', path: ['petId'] }); + expect(parseRuntimeExpression('$outputs.result')).toEqual({ type: 'outputs', raw: '$outputs.result', path: ['result'] }); + expect(parseRuntimeExpression('$steps.s1.outputs.id')).toEqual({ + type: 'steps', + raw: '$steps.s1.outputs.id', + path: ['s1', 'outputs', 'id'], + }); + expect(parseRuntimeExpression('$workflows.w1.outputs.x')).toEqual({ + type: 'workflows', + raw: '$workflows.w1.outputs.x', + path: ['w1', 'outputs', 'x'], + }); + expect(parseRuntimeExpression('$sourceDescriptions.pets.url')).toEqual({ + type: 'sourceDescriptions', + raw: '$sourceDescriptions.pets.url', + path: ['pets', 'url'], + }); + expect(parseRuntimeExpression('$components.parameters.page')).toEqual({ + type: 'components', + raw: '$components.parameters.page', + path: ['parameters', 'page'], + }); + }); + + it('rejects malformed expressions with the document path attached', () => { + for (const bad of ['$urlx', '$url.extra', '$respons.body', '$request.body#x', '$request.cookie.x', '$steps.', '$steps..x', '$steps.a b', '$request.header.', '$request.header.bad name', '$request.query.', '', 'plain', '$']) { + expect(() => parseRuntimeExpression(bad, '/at')).toThrow(ArazzoError); + } + try { + parseRuntimeExpression('$steps.', '/workflows/0/outputs/x'); + } catch (error) { + expect((error as ArazzoError).path).toBe('/workflows/0/outputs/x'); + } + }); +}); + +describe('parseExpressionValue', () => { + it('classifies literals, expressions, and templates', () => { + expect(parseExpressionValue(42)).toEqual({ kind: 'literal', value: 42 }); + expect(parseExpressionValue(null)).toEqual({ kind: 'literal', value: null }); + expect(parseExpressionValue('plain')).toEqual({ kind: 'literal', value: 'plain' }); + expect(parseExpressionValue('$50')).toEqual({ kind: 'literal', value: '$50' }); + expect(parseExpressionValue('has { braces } but no dollar')).toEqual({ + kind: 'literal', + value: 'has { braces } but no dollar', + }); + expect(parseExpressionValue('$inputs.a')).toEqual({ + kind: 'expression', + expression: { type: 'inputs', raw: '$inputs.a', path: ['a'] }, + }); + const template = parseExpressionValue('Bearer {$inputs.token} end'); + expect(template).toEqual({ + kind: 'template', + raw: 'Bearer {$inputs.token} end', + parts: ['Bearer ', { type: 'inputs', raw: '$inputs.token', path: ['token'] }, ' end'], + }); + const backToBack = parseExpressionValue('{$inputs.a}{$inputs.b}'); + expect((backToBack as any).parts).toHaveLength(2); + }); + + it('rejects known-root strings that fail to parse and unterminated templates', () => { + expect(() => parseExpressionValue('$inputs.')).toThrow(ArazzoError); + expect(() => parseExpressionValue('x {$inputs.a')).toThrow(ArazzoError); + }); +}); + +describe('collectPayloadExpressions', () => { + it('locates expressions by RFC 6901 pointer with escaped keys', () => { + const payload = { + 'a/b': '$inputs.slash', + 'c~d': { deep: 'Bearer {$inputs.token}' }, + list: ['plain', '$statusCode'], + literal: '$50', + count: 3, + }; + const found = collectPayloadExpressions(payload); + expect(found.map((f) => f.pointer)).toEqual(['/a~1b', '/c~0d/deep', '/list/1']); + }); + + it('treats a whole-string payload as pointer ""', () => { + const found = collectPayloadExpressions('$inputs.body'); + expect(found).toEqual([ + { pointer: '', value: { kind: 'expression', expression: { type: 'inputs', raw: '$inputs.body', path: ['body'] } } }, + ]); + expect(collectPayloadExpressions(undefined)).toEqual([]); + expect(collectPayloadExpressions(7)).toEqual([]); + }); +}); + +describe('fromArazzo happy path', () => { + it('builds one consolidated tool per workflow with an executor-ready IR', async () => { + const tools = await fromArazzo(arazzoWith([simpleWorkflow()]), { sources: sources() }); + expect(tools).toHaveLength(1); + const tool = tools[0]; + + expect(tool.name).toBe('getPetFlow'); + expect(tool.title).toBe('Get a pet'); + expect(tool.description).toBe('Get a pet\n\nFetch one pet by id.'); + expect(tool.mapper).toEqual([]); + expect(tool.metadata.path).toBe('arazzo:getPetFlow'); + expect(tool.metadata.method).toBe('post'); + expect(tool.metadata.operationId).toBe('getPetFlow'); + expect((tool.inputSchema as any).properties.petId).toEqual({ type: 'string' }); + + const ir = tool.metadata.workflow!; + expect(ir.arazzoVersion).toBe('1.0.0'); + expect(ir.workflowId).toBe('getPetFlow'); + expect(ir.steps).toHaveLength(1); + const step = ir.steps[0] as OperationStepIR; + expect(step.kind).toBe('operation'); + expect(step.source).toBe('pets'); + expect(step.path).toBe('/pets/{petId}'); + expect(step.method).toBe('get'); + expect(step.operationId).toBe('getPet'); + expect(step.parameters).toEqual([ + { name: 'petId', in: 'path', value: { kind: 'expression', expression: { type: 'inputs', raw: '$inputs.petId', path: ['petId'] } } }, + ]); + expect(step.outputs?.pet).toEqual({ type: 'response', raw: '$response.body', path: [], source: 'body' }); + + // Embedded operation essentials match a direct generateTool call + const generator = await OpenAPIToolGenerator.fromJSON(petstoreDoc()); + const direct = await generator.generateTool('/pets/{petId}', 'get'); + expect(step.operation.inputSchema).toEqual(direct.inputSchema); + expect(step.operation.mapper).toEqual(direct.mapper); + expect(step.operation.outputSchema).toEqual(direct.outputSchema); + expect(step.operation.servers).toEqual(direct.metadata.servers); + + // Output schema derived from the chased step output ($response.body) + const outProps = (tool.outputSchema as any).properties; + expect(outProps.pet.description).toBe('Arazzo output: $steps.fetch.outputs.pet'); + expect(outProps.pet.properties.id).toEqual({ type: 'string' }); + + // Read-only workflow (single GET) gets safe annotations + expect(tool.annotations).toEqual({ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }); + }); + + it('accepts YAML input and produces output identical to the object form', async () => { + const doc = arazzoWith([simpleWorkflow()]); + const fromObject = await fromArazzo(doc, { sources: sources() }); + const fromYaml = await fromArazzo(yaml.stringify(doc), { sources: sources() }); + expect(fromYaml).toEqual(fromObject); + }); + + it('is deterministic, document-ordered, and JSON-serializable', async () => { + const second = simpleWorkflow({ workflowId: 'zeta', outputs: undefined, steps: [{ stepId: 's', operationId: 'listPets' }] }); + const doc = arazzoWith([simpleWorkflow({ workflowId: 'omega' }), second]); + const first = await fromArazzo(doc, { sources: sources() }); + const again = await fromArazzo(doc, { sources: sources() }); + expect(first.map((t) => t.name)).toEqual(['omega', 'zeta']); + expect(again).toEqual(first); + expect(JSON.parse(JSON.stringify(first))).toEqual(first); + }); + + it('accepts pre-built generators and normalizes workflow inputs (nullable)', async () => { + const generator = await OpenAPIToolGenerator.fromJSON(petstoreDoc()); + const workflow = simpleWorkflow({ + inputs: { type: 'object', properties: { petId: { type: 'string', nullable: true } }, required: ['petId'] }, + outputs: undefined, + }); + const [tool] = await fromArazzo(arazzoWith([workflow]), { sources: { pets: generator } }); + expect((tool.inputSchema as any).properties.petId).toEqual({ type: ['string', 'null'] }); + expect(tool.outputSchema).toBeUndefined(); + }); + + it('defaults the input schema when the workflow declares no inputs', async () => { + const workflow = simpleWorkflow({ inputs: undefined, outputs: undefined }); + workflow.steps[0].parameters = [{ name: 'petId', in: 'path', value: 'fixed' }]; + const [tool] = await fromArazzo(arazzoWith([workflow]), { sources: sources() }); + expect(tool.inputSchema).toEqual({ type: 'object', properties: {} }); + expect(tool.metadata.workflow!.inputSchema).toBeUndefined(); + }); + + it('omits annotations for mixed-method and nested-workflow flows and unions security', async () => { + const mixed = arazzoWith([ + simpleWorkflow({ + workflowId: 'mixed', + outputs: undefined, + steps: [ + { stepId: 'a', operationId: 'getPet', parameters: [{ name: 'petId', in: 'path', value: 'x' }] }, + { stepId: 'b', operationId: 'createPet' }, + { stepId: 'b2', operationId: 'createPet' }, + ], + }), + simpleWorkflow({ workflowId: 'nested', outputs: undefined, steps: [{ stepId: 'n', workflowId: 'mixed' }] }), + ]); + const tools = await fromArazzo(mixed, { sources: sources() }); + expect(tools[0].annotations).toBeUndefined(); + expect(tools[0].metadata.security).toHaveLength(1); + expect(tools[0].metadata.security![0]).toMatchObject({ scheme: 'petAuth', type: 'http', httpScheme: 'bearer' }); + expect(tools[1].annotations).toBeUndefined(); + expect(tools[1].metadata.security).toBeUndefined(); + const nestedStep = tools[1].metadata.workflow!.steps[0] as NestedWorkflowStepIR; + expect(nestedStep).toEqual({ kind: 'workflow', workflowId: 'mixed', stepId: 'n' }); + }); + + it('dedupes tool names that normalize identically', async () => { + const doc = arazzoWith([ + simpleWorkflow({ workflowId: 'flow', outputs: undefined }), + simpleWorkflow({ workflowId: '_flow', outputs: undefined }), + ]); + const tools = await fromArazzo(doc, { sources: sources() }); + expect(tools[0].name).toBe('flow'); + expect(tools[1].name).toMatch(/^flow_[0-9a-f]{8}$/); + }); + + it('applies generateOptions to consolidated and embedded schemas including type signatures', async () => { + const workflow = simpleWorkflow(); + const [tool] = await fromArazzo(arazzoWith([workflow]), { + sources: sources(), + generateOptions: { target: 'gemini', stripExamples: true, emitTypeSignatures: true, maxSchemaDepth: 4 }, + }); + expect(tool.metadata.typescript?.signature).toContain('petId: string'); + const step = tool.metadata.workflow!.steps[0] as OperationStepIR; + // gemini target inlines/normalizes — embedded schema equals a direct call with the same options + const generator = await OpenAPIToolGenerator.fromJSON(petstoreDoc()); + const direct = await generator.generateTool('/pets/{petId}', 'get', { + target: 'gemini', + stripExamples: true, + emitTypeSignatures: true, + maxSchemaDepth: 4, + }); + expect(step.operation.inputSchema).toEqual(direct.inputSchema); + }); +}); + +describe('fromArazzo input parsing errors', () => { + it('rejects invalid YAML, non-object documents, and null input', async () => { + await expectArazzoError(fromArazzo('{{{{:::', { sources: {} }), /Failed to parse Arazzo document/); + await expectArazzoError(fromArazzo('42', { sources: {} }), /must be an object/); + await expectArazzoError(fromArazzo(null as any, { sources: {} }), /must be an object/); + }); +}); + +describe('fromArazzo structural validation', () => { + const base = () => arazzoWith([simpleWorkflow()]); + + const cases: Array<{ label: string; mutate: (doc: any) => void; match: RegExp; path?: string }> = [ + { label: 'bad version', mutate: (d) => (d.arazzo = '2.0.0'), match: /Unsupported arazzo version/, path: '/arazzo' }, + { label: 'missing info title', mutate: (d) => delete d.info.title, match: /"info" requires/, path: '/info' }, + { label: 'empty sourceDescriptions', mutate: (d) => (d.sourceDescriptions = []), match: /non-empty array/, path: '/sourceDescriptions' }, + { label: 'bad source name', mutate: (d) => (d.sourceDescriptions[0].name = 'has space'), match: /matching \[A-Za-z0-9_-\]\+/ }, + { label: 'missing source url', mutate: (d) => delete d.sourceDescriptions[0].url, match: /requires a string "url"/ }, + { label: 'bad source type', mutate: (d) => (d.sourceDescriptions[0].type = 'graphql'), match: /invalid type/ }, + { label: 'duplicate source names', mutate: (d) => (d.sourceDescriptions[1].name = 'pets'), match: /Duplicate source description name/ }, + { label: 'empty workflows', mutate: (d) => (d.workflows = []), match: /"workflows" must be a non-empty array/ }, + { label: 'bad workflowId', mutate: (d) => (d.workflows[0].workflowId = 'no good'), match: /workflowId/ }, + { label: 'empty steps', mutate: (d) => (d.workflows[0].steps = []), match: /non-empty "steps"/ }, + { label: 'bad stepId', mutate: (d) => (d.workflows[0].steps[0].stepId = ''), match: /stepId/ }, + { label: 'two step kinds', mutate: (d) => (d.workflows[0].steps[0].workflowId = 'x'), match: /exactly one of/ }, + { label: 'zero step kinds', mutate: (d) => delete d.workflows[0].steps[0].operationId, match: /exactly one of/ }, + { label: 'parameters not array', mutate: (d) => (d.workflows[0].steps[0].parameters = 'nope'), match: /must be an array/ }, + { label: 'parameter not object', mutate: (d) => (d.workflows[0].steps[0].parameters = [7]), match: /Parameter must be an object/ }, + { label: 'parameter missing name', mutate: (d) => (d.workflows[0].steps[0].parameters = [{ value: 1 }]), match: /non-empty string "name"/ }, + { label: 'parameter missing value', mutate: (d) => (d.workflows[0].steps[0].parameters = [{ name: 'a', in: 'query' }]), match: /requires a "value"/ }, + { label: 'bad parameter location', mutate: (d) => (d.workflows[0].steps[0].parameters = [{ name: 'a', in: 'body', value: 1 }]), match: /Invalid parameter location/ }, + { label: 'operation param missing in', mutate: (d) => (d.workflows[0].steps[0].parameters = [{ name: 'a', value: 1 }]), match: /requires "in"/ }, + { label: 'duplicate parameters', mutate: (d) => (d.workflows[0].steps[0].parameters = [{ name: 'a', in: 'query', value: 1 }, { name: 'a', in: 'query', value: 2 }]), match: /Duplicate parameter/ }, + { label: 'criteria not array', mutate: (d) => (d.workflows[0].steps[0].successCriteria = 'x'), match: /must be an array/ }, + { label: 'criterion not object', mutate: (d) => (d.workflows[0].steps[0].successCriteria = [1]), match: /Criterion must be an object/ }, + { label: 'criterion missing condition', mutate: (d) => (d.workflows[0].steps[0].successCriteria = [{}]), match: /non-empty string "condition"/ }, + { label: 'unknown criterion type', mutate: (d) => (d.workflows[0].steps[0].successCriteria = [{ condition: 'x', type: 'fancy' }]), match: /Unknown criterion type/ }, + { label: 'bad criterion type object', mutate: (d) => (d.workflows[0].steps[0].successCriteria = [{ condition: 'x', type: { type: 'jsonpath' } }]), match: /requires "type" \(jsonpath\|xpath\) and "version"/ }, + { label: 'criterion type wrong shape', mutate: (d) => (d.workflows[0].steps[0].successCriteria = [{ condition: 'x', type: 42 }]), match: /must be a string or a Criterion Expression Type Object/ }, + { label: 'typed criterion missing context', mutate: (d) => (d.workflows[0].steps[0].successCriteria = [{ condition: 'x', type: 'regex' }]), match: /requires a "context" expression/ }, + { label: 'actions not array', mutate: (d) => (d.workflows[0].steps[0].onSuccess = 'x'), match: /Actions must be an array/ }, + { label: 'action not object', mutate: (d) => (d.workflows[0].steps[0].onSuccess = [null]), match: /Action must be an object/ }, + { label: 'action missing name', mutate: (d) => (d.workflows[0].steps[0].onSuccess = [{ type: 'end' }]), match: /non-empty string "name"/ }, + { label: 'success action retry', mutate: (d) => (d.workflows[0].steps[0].onSuccess = [{ name: 'r', type: 'retry' }]), match: /Invalid success-action type/ }, + { label: 'goto both targets', mutate: (d) => (d.workflows[0].steps[0].onFailure = [{ name: 'g', type: 'goto', workflowId: 'a', stepId: 'b' }]), match: /exactly one of "workflowId" or "stepId"/ }, + { label: 'goto no target', mutate: (d) => (d.workflows[0].steps[0].onFailure = [{ name: 'g', type: 'goto' }]), match: /exactly one of/ }, + { label: 'end with target', mutate: (d) => (d.workflows[0].steps[0].onSuccess = [{ name: 'e', type: 'end', stepId: 's' }]), match: /must not specify/ }, + { label: 'negative retryAfter', mutate: (d) => (d.workflows[0].steps[0].onFailure = [{ name: 'r', type: 'retry', retryAfter: -1 }]), match: /non-negative number/ }, + { label: 'fractional retryLimit', mutate: (d) => (d.workflows[0].steps[0].onFailure = [{ name: 'r', type: 'retry', retryLimit: 1.5 }]), match: /non-negative integer/ }, + { label: 'bad action criteria', mutate: (d) => (d.workflows[0].steps[0].onFailure = [{ name: 'r', type: 'end', criteria: [{ type: 'simple' }] }]), match: /non-empty string "condition"/ }, + { label: 'outputs not object', mutate: (d) => (d.workflows[0].outputs = ['x']), match: /must be an object/ }, + { label: 'bad output name', mutate: (d) => (d.workflows[0].outputs = { 'no space': '$url' }), match: /Invalid output name/ }, + { label: 'non-string output', mutate: (d) => (d.workflows[0].outputs = { x: 42 }), match: /must be a runtime expression string/ }, + { label: 'workflow param with in on workflow step', mutate: (d) => { + d.workflows[0].steps[0] = { stepId: 'n', workflowId: 'getPetFlow', parameters: [{ name: 'a', in: 'query', value: 1 }] }; + }, match: /must not specify "in"/ }, + ]; + + it.each(cases)('rejects $label', async ({ mutate, match, path }) => { + const doc = base(); + mutate(doc); + await expectArazzoError(fromArazzo(doc, { sources: sources() }), match, path); + }); + + it('rejects duplicate workflowIds and stepIds', async () => { + const dupWf = arazzoWith([simpleWorkflow(), simpleWorkflow()]); + await expectArazzoError(fromArazzo(dupWf, { sources: sources() }), /Duplicate workflowId/, '/workflows/1'); + + const dupStep = base(); + dupStep.workflows[0].steps.push({ ...dupStep.workflows[0].steps[0] }); + await expectArazzoError(fromArazzo(dupStep, { sources: sources() }), /Duplicate stepId/); + }); +}); + +describe('fromArazzo components resolution', () => { + it('inlines reusable parameters with value overrides and reusable actions', async () => { + const doc = arazzoWith( + [ + simpleWorkflow({ + outputs: undefined, + steps: [ + { + stepId: 'fetch', + operationId: 'getPet', + parameters: [{ reference: '$components.parameters.petParam', value: '$inputs.petId' }], + onFailure: [{ reference: '$components.failureActions.giveUp' }], + }, + ], + failureActions: [{ reference: '$components.failureActions.giveUp' }], + }), + ], + { + components: { + parameters: { petParam: { name: 'petId', in: 'path', value: 'default' } }, + failureActions: { giveUp: { name: 'giveUp', type: 'retry', retryAfter: 5, retryLimit: 2, criteria: [{ condition: '$statusCode == 503', context: '$statusCode', type: 'regex' }] } }, + }, + }, + ); + const [tool] = await fromArazzo(doc, { sources: sources() }); + const step = tool.metadata.workflow!.steps[0] as OperationStepIR; + expect(step.parameters).toEqual([ + { name: 'petId', in: 'path', value: { kind: 'expression', expression: { type: 'inputs', raw: '$inputs.petId', path: ['petId'] } } }, + ]); + expect(step.onFailure).toEqual([ + { + name: 'giveUp', + kind: 'failure', + type: 'retry', + retryAfter: 5, + retryLimit: 2, + criteria: [ + { condition: '$statusCode == 503', context: { type: 'statusCode', raw: '$statusCode', path: [] }, type: 'regex' }, + ], + }, + ]); + expect(tool.metadata.workflow!.failureActions).toHaveLength(1); + }); + + it('resolves components.inputs $refs so the IR is self-contained', async () => { + const doc = arazzoWith( + [ + simpleWorkflow({ + inputs: { + type: 'object', + properties: { petId: { $ref: '#/components/inputs/petIdInput' } }, + required: ['petId'], + }, + outputs: undefined, + }), + ], + { components: { inputs: { petIdInput: { type: 'string', description: 'A pet id' } } } }, + ); + const [tool] = await fromArazzo(doc, { sources: sources() }); + expect((tool.inputSchema as any).properties.petId).toEqual({ type: 'string', description: 'A pet id' }); + expect(JSON.stringify(tool)).not.toContain('$ref'); + }); + + it('rejects unknown, cyclic, wrong-group, and malformed references', async () => { + const withInputs = (inputs: any, components: any = {}) => + arazzoWith([simpleWorkflow({ inputs, outputs: undefined })], { components }); + + await expectArazzoError( + fromArazzo(withInputs({ $ref: '#/components/inputs/missing' }), { sources: sources() }), + /Unknown workflow inputs reference/, + ); + await expectArazzoError( + fromArazzo(withInputs({ $ref: '#/definitions/x' }), { sources: sources() }), + /Unsupported \$ref/, + ); + await expectArazzoError( + fromArazzo( + withInputs({ $ref: '#/components/inputs/a' }, { inputs: { a: { $ref: '#/components/inputs/a' } } }), + { sources: sources() }, + ), + /Cyclic workflow inputs reference/, + ); + + const badGroup = arazzoWith([ + simpleWorkflow({ + outputs: undefined, + steps: [{ stepId: 'f', operationId: 'getPet', parameters: [{ reference: '$components.successActions.x' }] }], + }), + ]); + await expectArazzoError(fromArazzo(badGroup, { sources: sources() }), /must point at \$components\.parameters/); + + const unknownRef = arazzoWith([ + simpleWorkflow({ + outputs: undefined, + steps: [{ stepId: 'f', operationId: 'getPet', parameters: [{ reference: '$components.parameters.nope' }] }], + }), + ]); + await expectArazzoError(fromArazzo(unknownRef, { sources: sources() }), /Unknown reference/); + + const nonString = arazzoWith([ + simpleWorkflow({ + outputs: undefined, + steps: [{ stepId: 'f', operationId: 'getPet', parameters: [{ reference: 42 }] }], + }), + ]); + await expectArazzoError(fromArazzo(nonString, { sources: sources() }), /"reference" must be a string/); + }); + + it('re-validates action types resolved from components', async () => { + const doc = arazzoWith( + [ + simpleWorkflow({ + outputs: undefined, + steps: [ + { stepId: 'f', operationId: 'getPet', onSuccess: [{ reference: '$components.successActions.retryish' }] }, + ], + }), + ], + { components: { successActions: { retryish: { name: 'r', type: 'retry' } } } }, + ); + await expectArazzoError(fromArazzo(doc, { sources: sources() }), /Invalid success-action type "retry"/); + }); +}); + +describe('fromArazzo source and operation resolution', () => { + it('rejects unknown options.sources keys and missing used sources', async () => { + await expectArazzoError( + fromArazzo(arazzoWith([simpleWorkflow()]), { sources: { ...sources(), extra: petstoreDoc() } }), + /not a declared source description/, + ); + await expectArazzoError( + fromArazzo(arazzoWith([simpleWorkflow()]), { sources: {} }), + /not found in any supplied source/, + ); + }); + + it('rejects steps resolving into arazzo-typed sources', async () => { + const doc = arazzoWith([ + simpleWorkflow({ + outputs: undefined, + steps: [{ stepId: 'f', operationPath: '{$sourceDescriptions.flows.url}#/paths/~1x/get' }], + }), + ]); + doc.sourceDescriptions.push({ name: 'flows', url: 'https://x/flows.yaml', type: 'arazzo' }); + await expectArazzoError(fromArazzo(doc, { sources: sources() }), /nested Arazzo sources are not supported/); + }); + + it('resolves ambiguous operationIds only with a $sourceDescriptions pin', async () => { + const both = { pets: petstoreWithShared(), orders: ordersDoc() }; + const ambiguous = arazzoWith([ + simpleWorkflow({ outputs: undefined, steps: [{ stepId: 's', operationId: 'sharedOp' }] }), + ]); + await expectArazzoError(fromArazzo(ambiguous, { sources: both }), /ambiguous across sources/); + + const pinned = arazzoWith([ + simpleWorkflow({ outputs: undefined, steps: [{ stepId: 's', operationId: '$sourceDescriptions.orders.sharedOp' }] }), + ]); + const [tool] = await fromArazzo(pinned, { sources: both }); + const step = tool.metadata.workflow!.steps[0] as OperationStepIR; + expect(step.source).toBe('orders'); + expect(step.operationId).toBe('sharedOp'); + }); + + it('rejects bad operationId pins', async () => { + const shortPin = arazzoWith([ + simpleWorkflow({ outputs: undefined, steps: [{ stepId: 's', operationId: '$sourceDescriptions.pets' }] }), + ]); + await expectArazzoError(fromArazzo(shortPin, { sources: sources() }), /must be \$sourceDescriptions\.\./); + + const missingPin = arazzoWith([ + simpleWorkflow({ outputs: undefined, steps: [{ stepId: 's', operationId: '$sourceDescriptions.pets.getOrder' }] }), + ]); + await expectArazzoError(fromArazzo(missingPin, { sources: sources() }), /not found in source "pets"/); + + const missingSourcePin = arazzoWith([ + simpleWorkflow({ outputs: undefined, steps: [{ stepId: 's', operationId: '$sourceDescriptions.orders.getOrder' }] }), + ]); + await expectArazzoError(fromArazzo(missingSourcePin, { sources: { pets: petstoreDoc() } }), /No document supplied for source "orders"/); + }); + + it('rejects duplicated operationIds inside one source when pinned', async () => { + const dupDoc = petstoreDoc(); + dupDoc.paths['/pets2'] = { get: { operationId: 'getPet', responses: { '200': { description: 'OK' } } } }; + const pinned = arazzoWith([ + simpleWorkflow({ outputs: undefined, steps: [{ stepId: 's', operationId: '$sourceDescriptions.pets.getPet' }] }), + ]); + await expectArazzoError(fromArazzo(pinned, { sources: { pets: dupDoc, orders: ordersDoc() } }), /duplicated inside source/); + }); + + it('resolves operationPath with pointer escapes', async () => { + const doc = arazzoWith([ + simpleWorkflow({ + outputs: undefined, + steps: [ + { + stepId: 's', + operationPath: '{$sourceDescriptions.pets.url}#/paths/~1pets~1{petId}/get', + parameters: [{ name: 'petId', in: 'path', value: 'x' }], + }, + ], + }), + ]); + const [tool] = await fromArazzo(doc, { sources: sources() }); + const step = tool.metadata.workflow!.steps[0] as OperationStepIR; + expect(step.path).toBe('/pets/{petId}'); + expect(step.method).toBe('get'); + expect(step.operationId).toBeUndefined(); + }); + + it('rejects malformed operationPath variants', async () => { + const mk = (operationPath: string) => + arazzoWith([simpleWorkflow({ outputs: undefined, steps: [{ stepId: 's', operationPath }] })]); + await expectArazzoError(fromArazzo(mk('nobrace#/paths/~1x/get'), { sources: sources() }), /must start with/); + await expectArazzoError(fromArazzo(mk('{$sourceDescriptions.pets.url#/paths/~1x/get'), { sources: sources() }), /missing "}"/); + await expectArazzoError(fromArazzo(mk('{$inputs.x}#/paths/~1x/get'), { sources: sources() }), /must reference \$sourceDescriptions/); + await expectArazzoError(fromArazzo(mk('{$sourceDescriptions.pets.name}#/paths/~1x/get'), { sources: sources() }), /must reference \$sourceDescriptions\.\.url/); + await expectArazzoError(fromArazzo(mk('{$sourceDescriptions.pets.url}/paths/~1x/get'), { sources: sources() }), /requires a "#\/paths/); + await expectArazzoError(fromArazzo(mk('{$sourceDescriptions.pets.url}#/definitions/~1x/get'), { sources: sources() }), /shape #\/paths/); + await expectArazzoError(fromArazzo(mk('{$sourceDescriptions.pets.url}#/paths/~1x'), { sources: sources() }), /shape #\/paths/); + await expectArazzoError(fromArazzo(mk('{$sourceDescriptions.pets.url}#/paths/~1x/fetch'), { sources: sources() }), /unknown HTTP method/); + }); + + it('wraps generateTool failures with the step path', async () => { + const doc = arazzoWith([ + simpleWorkflow({ + outputs: undefined, + steps: [{ stepId: 's', operationPath: '{$sourceDescriptions.pets.url}#/paths/~1missing/get' }], + }), + ]); + await expectArazzoError( + fromArazzo(doc, { sources: sources() }), + /Failed to resolve GET \/missing from source "pets"/, + '/workflows/0/steps/0', + ); + }); +}); + +describe('fromArazzo workflow graph checks', () => { + it('rejects unknown and cyclic dependsOn chains', async () => { + const unknown = arazzoWith([simpleWorkflow({ dependsOn: ['ghost'], outputs: undefined })]); + await expectArazzoError(fromArazzo(unknown, { sources: sources() }), /dependsOn unknown workflow/); + + const cyclic = arazzoWith([ + simpleWorkflow({ workflowId: 'a', dependsOn: ['b'], outputs: undefined }), + simpleWorkflow({ workflowId: 'b', dependsOn: ['a'], outputs: undefined }), + ]); + await expectArazzoError(fromArazzo(cyclic, { sources: sources() }), /Cyclic dependsOn chain: (a -> b -> a|b -> a -> b)/); + }); + + it('rejects recursive workflow invocation and unknown nested targets', async () => { + const selfCall = arazzoWith([ + simpleWorkflow({ workflowId: 'a', outputs: undefined, steps: [{ stepId: 's', workflowId: 'a' }] }), + ]); + await expectArazzoError(fromArazzo(selfCall, { sources: sources() }), /Cyclic workflow invocation: a -> a/); + + const unknownTarget = arazzoWith([ + simpleWorkflow({ workflowId: 'a', outputs: undefined, steps: [{ stepId: 's', workflowId: 'ghost' }] }), + ]); + // Unknown nested targets surface at the step level after graph construction + await expectArazzoError(fromArazzo(unknownTarget, { sources: sources() }), /references unknown workflow "ghost"/); + }); + + it('accepts valid dependsOn chains', async () => { + const chain = arazzoWith([ + simpleWorkflow({ workflowId: 'a', outputs: undefined }), + simpleWorkflow({ workflowId: 'b', dependsOn: ['a'], outputs: undefined }), + ]); + const tools = await fromArazzo(chain, { sources: sources() }); + expect(tools[1].metadata.workflow!.dependsOn).toEqual(['a']); + }); +}); + +describe('fromArazzo request bodies', () => { + it('captures payload expressions, replacements, and contentType', async () => { + const doc = arazzoWith([ + simpleWorkflow({ + outputs: undefined, + steps: [ + { + stepId: 'create', + operationId: 'createPet', + requestBody: { + contentType: 'application/json', + payload: { name: '{$inputs.petId} the pet', 'meta/kind': '$inputs.petId', fixed: 1 }, + replacements: [ + { target: '/fixed', value: '$statusCode' }, + { target: '/name', value: 'literal' }, + ], + }, + }, + ], + }), + ]); + const [tool] = await fromArazzo(doc, { sources: sources() }); + const step = tool.metadata.workflow!.steps[0] as OperationStepIR; + expect(step.requestBody?.contentType).toBe('application/json'); + expect(step.requestBody?.payload).toEqual({ name: '{$inputs.petId} the pet', 'meta/kind': '$inputs.petId', fixed: 1 }); + expect(step.requestBody?.payloadExpressions?.map((e) => e.pointer)).toEqual(['/name', '/meta~1kind']); + expect(step.requestBody?.replacements).toEqual([ + { target: '/fixed', value: { kind: 'expression', expression: { type: 'statusCode', raw: '$statusCode', path: [] } } }, + { target: '/name', value: { kind: 'literal', value: 'literal' } }, + ]); + }); + + it('rejects malformed request bodies and replacements', async () => { + const badBody = arazzoWith([ + simpleWorkflow({ outputs: undefined, steps: [{ stepId: 's', operationId: 'createPet', requestBody: 'x' }] }), + ]); + await expectArazzoError(fromArazzo(badBody, { sources: sources() }), /requestBody must be an object/); + + const badReplacements = arazzoWith([ + simpleWorkflow({ + outputs: undefined, + steps: [{ stepId: 's', operationId: 'createPet', requestBody: { payload: {}, replacements: 'x' } }], + }), + ]); + await expectArazzoError(fromArazzo(badReplacements, { sources: sources() }), /replacements must be an array/); + + const badReplacement = arazzoWith([ + simpleWorkflow({ + outputs: undefined, + steps: [{ stepId: 's', operationId: 'createPet', requestBody: { payload: {}, replacements: [{ value: 1 }] } }], + }), + ]); + await expectArazzoError(fromArazzo(badReplacement, { sources: sources() }), /Replacement requires a string "target"/); + }); +}); + +describe('fromArazzo output schema derivation', () => { + const flowWith = (outputs: Record, stepOutputs: Record = { pet: '$response.body' }) => + arazzoWith([ + simpleWorkflow({ + steps: [ + { + stepId: 'fetch', + operationId: 'getPet', + parameters: [{ name: 'petId', in: 'path', value: '$inputs.petId' }], + outputs: stepOutputs, + }, + ], + outputs, + }), + ]); + + const outputProp = async (outputs: Record, stepOutputs?: Record) => { + const [tool] = await fromArazzo(flowWith(outputs, stepOutputs), { sources: sources() }); + return (tool.outputSchema as any).properties; + }; + + it('derives scalar shapes for statusCode, url, method, and headers', async () => { + const props = await outputProp({ code: '$statusCode', where: '$url', how: '$method' }); + expect(props.code.type).toBe('number'); + expect(props.where.type).toBe('string'); + expect(props.how.type).toBe('string'); + }); + + it('chases step outputs into response body schemas with pointers', async () => { + const props = await outputProp( + { owner: '$steps.fetch.outputs.ownerEmail', photo: '$steps.fetch.outputs.firstPhoto' }, + { ownerEmail: '$response.body#/owner/email', firstPhoto: '$response.body#/photos/0/url' }, + ); + expect(props.owner.type).toBe('string'); + expect(props.photo.type).toBe('string'); + }); + + it('degrades unresolvable outputs to unknown with the raw expression preserved', async () => { + const props = await outputProp( + { + missing: '$steps.fetch.outputs.nope', + badStep: '$steps.ghost.outputs.x', + shallow: '$steps.fetch.foo', + input: '$inputs.petId', + inputMissing: '$inputs.ghost', + wf: '$workflows.other.outputs.x', + header: '$steps.fetch.outputs.hdr', + deadEnd: '$steps.fetch.outputs.badPtr', + }, + { hdr: '$response.header.X-Trace', badPtr: '$response.body#/owner/missing/deep' }, + ); + expect(props.missing).toEqual({ description: 'Arazzo output: $steps.fetch.outputs.nope' }); + expect(props.badStep.description).toContain('$steps.ghost'); + expect(props.shallow.type).toBeUndefined(); + expect(props.input.type).toBe('string'); + expect(props.inputMissing.type).toBeUndefined(); + expect(props.wf.type).toBeUndefined(); + expect(props.header.type).toBe('string'); + expect(props.deadEnd.type).toBeUndefined(); + }); + + it('caps self-referential step output chases', async () => { + const props = await outputProp({ loop: '$steps.fetch.outputs.self' }, { self: '$steps.fetch.outputs.self' }); + expect(props.loop).toEqual({ description: 'Arazzo output: $steps.fetch.outputs.self' }); + }); + + it('uses the first status variant for multi-response operations', async () => { + const multi = petstoreDoc(); + multi.paths['/pets/{petId}'].get.responses['404'] = { + description: 'Not found', + content: { 'application/json': { schema: { type: 'object', properties: { error: { type: 'string' } } } } }, + }; + const [tool] = await fromArazzo(flowWith({ pet: '$steps.fetch.outputs.pet' }), { + sources: { pets: multi, orders: ordersDoc() }, + }); + const props = (tool.outputSchema as any).properties; + expect(props.pet.properties.id).toEqual({ type: 'string' }); + }); + + it('ignores outputs referencing nested workflow steps', async () => { + const doc = arazzoWith([ + simpleWorkflow({ workflowId: 'inner', outputs: undefined }), + simpleWorkflow({ + workflowId: 'outer', + steps: [{ stepId: 'call', workflowId: 'inner' }], + outputs: { x: '$steps.call.outputs.pet' }, + }), + ]); + const tools = await fromArazzo(doc, { sources: sources() }); + const props = (tools[1].outputSchema as any).properties; + expect(props.x).toEqual({ description: 'Arazzo output: $steps.call.outputs.pet' }); + }); +}); + +describe('fromArazzo remaining coverage', () => { + it('rejects bare dotted roots like $inputs', () => { + expect(() => parseRuntimeExpression('$inputs')).toThrow(/missing a name/); + }); + + it('captures workflow-level parameters, success actions, and typed criteria', async () => { + const doc = arazzoWith([ + simpleWorkflow({ + outputs: undefined, + parameters: [{ name: 'tenant', in: 'header', value: '$inputs.petId' }], + successActions: [{ name: 'done', type: 'end' }], + steps: [ + { + stepId: 'fetch', + operationId: 'getPet', + parameters: [{ name: 'petId', in: 'path', value: 'x' }], + successCriteria: [ + { context: '$response.body', condition: '$[0].id', type: { type: 'jsonpath', version: 'draft-goessner-dispatch-jsonpath-00' } }, + ], + }, + ], + }), + ]); + const [tool] = await fromArazzo(doc, { sources: sources() }); + const ir = tool.metadata.workflow!; + expect(ir.parameters).toEqual([ + { name: 'tenant', in: 'header', value: { kind: 'expression', expression: { type: 'inputs', raw: '$inputs.petId', path: ['petId'] } } }, + ]); + expect(ir.successActions).toEqual([{ name: 'done', kind: 'success', type: 'end' }]); + const step = ir.steps[0] as OperationStepIR; + expect(step.successCriteria).toEqual([ + { + context: { type: 'response', raw: '$response.body', path: [], source: 'body' }, + condition: '$[0].id', + type: 'jsonpath', + version: 'draft-goessner-dispatch-jsonpath-00', + }, + ]); + }); + + it('rejects malformed reusable parameter components', async () => { + const doc = arazzoWith( + [ + simpleWorkflow({ + outputs: undefined, + steps: [{ stepId: 'f', operationId: 'getPet', parameters: [{ reference: '$components.parameters.broken' }] }], + }), + ], + { components: { parameters: { broken: { in: 'query', value: 1 } as any } } }, + ); + await expectArazzoError(fromArazzo(doc, { sources: sources() }), /Resolved parameter requires "name" and "value"/); + }); + + it('degrades direct workflow-level $response.body outputs to unknown', async () => { + const doc = arazzoWith([simpleWorkflow({ outputs: { direct: '$response.body' } })]); + const [tool] = await fromArazzo(doc, { sources: sources() }); + expect((tool.outputSchema as any).properties.direct).toEqual({ description: 'Arazzo output: $response.body' }); + }); + + it('applies description and property trims to consolidated schemas', async () => { + const workflow = simpleWorkflow({ + inputs: { + type: 'object', + properties: { + petId: { + type: 'object', + description: 'A very long description that should be truncated at some point for budget reasons', + properties: { a: { type: 'string' }, b: { type: 'string' }, c: { type: 'string' } }, + }, + }, + }, + }); + const [tool] = await fromArazzo(arazzoWith([workflow]), { + sources: sources(), + generateOptions: { maxDescriptionLength: 20, maxProperties: 2 }, + }); + const petId = (tool.inputSchema as any).properties.petId; + // description capped first, then the property-omission note appends + expect(petId.description).toMatch(/^A very long descrip… \[1 additional property omitted/); + expect(Object.keys(petId.properties)).toHaveLength(2); + const out = (tool.outputSchema as any).properties.pet; + expect(Object.keys(out.properties).length).toBeLessThanOrEqual(2); + }); +}); + +describe('fromArazzo branch completeness', () => { + it('rejects wrong criterion-object types and non-numeric retryLimit', async () => { + const bad1 = arazzoWith([ + simpleWorkflow({ + outputs: undefined, + steps: [{ stepId: 's', operationId: 'getPet', parameters: [{ name: 'petId', in: 'path', value: 'x' }], successCriteria: [{ condition: 'x', context: '$statusCode', type: { type: 'simple', version: 'v' } }] }], + }), + ]); + await expectArazzoError(fromArazzo(bad1, { sources: sources() }), /requires "type" \(jsonpath\|xpath\)/); + + const bad2 = arazzoWith([ + simpleWorkflow({ + outputs: undefined, + steps: [{ stepId: 's', operationId: 'getPet', parameters: [{ name: 'petId', in: 'path', value: 'x' }], onFailure: [{ name: 'r', type: 'retry', retryLimit: 'lots' }] }], + }), + ]); + await expectArazzoError(fromArazzo(bad2, { sources: sources() }), /non-negative integer/); + + const bad3 = arazzoWith([ + simpleWorkflow({ + outputs: undefined, + steps: [{ stepId: 's', operationId: 'getPet', parameters: [{ name: 'petId', in: 'path', value: 'x' }], onFailure: [{ name: 'r', type: 'retry', retryLimit: -2 }] }], + }), + ]); + await expectArazzoError(fromArazzo(bad3, { sources: sources() }), /non-negative integer/); + }); + + it('rejects duplicate location-less workflow-level parameters', async () => { + const doc = arazzoWith([ + simpleWorkflow({ + outputs: undefined, + parameters: [{ name: 'a', value: 1 }, { name: 'a', value: 2 }], + }), + ]); + await expectArazzoError(fromArazzo(doc, { sources: sources() }), /Duplicate parameter "a"/); + }); + + it('tolerates missing options.sources and skips pathless or malformed source paths', async () => { + const noSources = arazzoWith([simpleWorkflow({ outputs: undefined })]); + await expectArazzoError(fromArazzo(noSources, { } as any), /not found in any supplied source/); + + const oddDoc: any = { + openapi: '3.0.0', + info: { title: 'Odd', version: '1.0.0' }, + paths: { '/null': null, '/noid': { get: { responses: { '200': { description: 'OK' } } } } }, + }; + const doc = arazzoWith([simpleWorkflow({ outputs: undefined })]); + await expectArazzoError( + fromArazzo(doc, { sources: { pets: oddDoc, orders: ordersDoc() } }), + /not found in any supplied source/, + ); + + const pathless: any = { openapi: '3.0.0', info: { title: 'Empty', version: '1.0.0' } }; + await expectArazzoError( + fromArazzo(doc, { sources: { pets: pathless } }), + /not found in any supplied source/, + ); + }); + + it('marks later workflows done before revisiting them in the cycle check', async () => { + const doc = arazzoWith([ + simpleWorkflow({ workflowId: 'first', dependsOn: ['second'], outputs: undefined }), + simpleWorkflow({ workflowId: 'second', outputs: undefined }), + ]); + const tools = await fromArazzo(doc, { sources: sources() }); + expect(tools).toHaveLength(2); + }); + + it('carries goto targets, step descriptions, and criteria-less actions into the IR', async () => { + const doc = arazzoWith([ + simpleWorkflow({ + outputs: undefined, + steps: [ + { + stepId: 'fetch', + description: 'First step', + operationId: 'getPet', + parameters: [{ name: 'petId', in: 'path', value: 'x' }], + onSuccess: [{ name: 'jump', type: 'goto', stepId: 'fetch' }], + onFailure: [{ name: 'redo', type: 'goto', workflowId: 'getPetFlow' }], + }, + ], + }), + ]); + const [tool] = await fromArazzo(doc, { sources: sources() }); + const step = tool.metadata.workflow!.steps[0] as OperationStepIR; + expect(step.description).toBe('First step'); + expect(step.onSuccess).toEqual([{ name: 'jump', kind: 'success', type: 'goto', stepId: 'fetch' }]); + expect(step.onFailure).toEqual([{ name: 'redo', kind: 'failure', type: 'goto', workflowId: 'getPetFlow' }]); + }); + + it('walks pointers through boolean sub-schemas without crashing', async () => { + const boolDoc = petstoreDoc(); + boolDoc.paths['/pets/{petId}'].get.responses['200'].content['application/json'].schema = { + type: 'object', + properties: { anything: true }, + }; + const doc = arazzoWith([ + simpleWorkflow({ + outputs: { deep: '$steps.fetch.outputs.deep' }, + steps: [ + { + stepId: 'fetch', + operationId: 'getPet', + parameters: [{ name: 'petId', in: 'path', value: 'x' }], + outputs: { deep: '$response.body#/anything/nested' }, + }, + ], + }), + ]); + const [tool] = await fromArazzo(doc, { sources: { pets: boolDoc, orders: ordersDoc() } }); + expect((tool.outputSchema as any).properties.deep).toEqual({ description: 'Arazzo output: $steps.fetch.outputs.deep' }); + }); + + it('handles $inputs outputs when the workflow has no or shapeless inputs', async () => { + const noInputs = arazzoWith([ + simpleWorkflow({ inputs: undefined, outputs: { echo: '$inputs.petId' }, steps: [{ stepId: 's', operationId: 'listPets' }] }), + ]); + const [t1] = await fromArazzo(noInputs, { sources: sources() }); + expect((t1.outputSchema as any).properties.echo.type).toBeUndefined(); + + const shapeless = arazzoWith([ + simpleWorkflow({ inputs: { type: 'object' }, outputs: { echo: '$inputs.petId' }, steps: [{ stepId: 's', operationId: 'listPets' }] }), + ]); + const [t2] = await fromArazzo(shapeless, { sources: sources() }); + expect((t2.outputSchema as any).properties.echo.type).toBeUndefined(); + }); + + it('resolves formats and defaults the printer depth for type signatures', async () => { + const doc = arazzoWith([ + simpleWorkflow({ + inputs: { type: 'object', properties: { petId: { type: 'string', format: 'uuid' } } }, + outputs: undefined, + }), + ]); + const [tool] = await fromArazzo(doc, { sources: sources(), generateOptions: { resolveFormats: true, emitTypeSignatures: true } }); + expect((tool.inputSchema as any).properties.petId.pattern).toBeDefined(); + expect(tool.metadata.typescript?.signature).toContain('petId'); + }); + + it('falls back through the description forms', async () => { + const doc = arazzoWith([ + simpleWorkflow({ workflowId: 'bare', summary: undefined, description: undefined, outputs: undefined }), + simpleWorkflow({ workflowId: 'descOnly', summary: undefined, description: 'Only description.', outputs: undefined }), + ]); + const tools = await fromArazzo(doc, { sources: sources() }); + expect(tools[0].description).toBe('Arazzo workflow: bare'); + expect(tools[0].title).toBeUndefined(); + expect(tools[1].description).toBe('Only description.'); + }); +}); diff --git a/src/__tests__/errors.spec.ts b/src/__tests__/errors.spec.ts index 0509577..c7e9094 100644 --- a/src/__tests__/errors.spec.ts +++ b/src/__tests__/errors.spec.ts @@ -2,7 +2,7 @@ * Tests for error classes */ -import { OpenAPIToolError, LoadError, ParseError, ValidationError, GenerationError, SchemaError } from '../errors'; +import { OpenAPIToolError, LoadError, ParseError, ValidationError, GenerationError, SchemaError, ArazzoError } from '../errors'; describe('OpenAPIToolError', () => { it('should create error with message', () => { @@ -188,3 +188,13 @@ describe('Error Inheritance Chain', () => { } }); }); + +describe('ArazzoError', () => { + it('promotes the document path from context and keeps the hierarchy', () => { + const error = new ArazzoError('bad step', { path: '/workflows/0/steps/1', stepId: 's' }); + expect(error).toBeInstanceOf(OpenAPIToolError); + expect(error.path).toBe('/workflows/0/steps/1'); + expect(error.context?.stepId).toBe('s'); + expect(new ArazzoError('no context').path).toBeUndefined(); + }); +}); diff --git a/src/__tests__/integration.spec.ts b/src/__tests__/integration.spec.ts index e0efe77..8d16d5f 100644 --- a/src/__tests__/integration.spec.ts +++ b/src/__tests__/integration.spec.ts @@ -738,3 +738,74 @@ describe('Integration: Entrypoint Exports', () => { expect(typeof lib.SchemaError).toBe('function'); }); }); + +describe('Tier 4 surface through the entrypoint', () => { + /* eslint-disable @typescript-eslint/no-explicit-any */ + const lib = require('../index'); + + it('runs the full Arazzo + signatures + elicitation pipeline from the barrel', async () => { + const petSpec: any = { + openapi: '3.0.0', + info: { title: 'Pets', version: '1.0.0' }, + components: { securitySchemes: { auth: { type: 'http', scheme: 'bearer' } } }, + paths: { + '/pets/{petId}': { + get: { + operationId: 'getPet', + tags: ['pets'], + security: [{ auth: [] }], + parameters: [{ name: 'petId', in: 'path', required: true, schema: { type: 'string' } }], + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { schema: { type: 'object', properties: { id: { type: 'string' } } } }, + }, + }, + }, + }, + }, + }, + }; + + // Per-operation tools with the Tier 4 emissions on + const generator = await lib.OpenAPIToolGenerator.fromJSON(petSpec); + const tools = await generator.generateTools({ + emitTypeSignatures: true, + emitMeta: true, + namingStrategy: lib.dottedNaming(), + }); + expect(tools[0].name).toBe('pets.getPet'); + expect(tools[0].metadata.typescript?.declaration).toContain('declare function petsGetPet'); + expect(tools[0]._meta?.['dev.agentfront.openapi/operation']).toMatchObject({ path: '/pets/{petId}' }); + expect(lib.deriveSecurityElicitations(tools[0])[0].scheme).toBe('auth'); + + // Arazzo consolidation over the same source, YAML in + const arazzoYaml = [ + 'arazzo: 1.0.0', + 'info: { title: Flows, version: 1.0.0 }', + 'sourceDescriptions:', + ' - { name: pets, url: "https://x/pets.json" }', + 'workflows:', + ' - workflowId: fetchPet', + ' summary: Fetch a pet', + ' inputs: { type: object, properties: { petId: { type: string } }, required: [petId] }', + ' steps:', + ' - stepId: fetch', + ' operationId: getPet', + ' parameters:', + ' - { name: petId, in: path, value: $inputs.petId }', + ' outputs: { pet: $response.body }', + ' outputs: { pet: $steps.fetch.outputs.pet }', + ].join('\n'); + const [workflowTool] = await lib.fromArazzo(arazzoYaml, { + sources: { pets: petSpec }, + generateOptions: { emitTypeSignatures: true }, + }); + expect(workflowTool.name).toBe('fetchPet'); + expect(workflowTool.metadata.workflow.steps[0].operation.mapper).toHaveLength(2); + expect(workflowTool.metadata.typescript?.signature).toContain('petId'); + expect(lib.parseRuntimeExpression('$steps.fetch.outputs.pet').type).toBe('steps'); + expect(() => lib.parseRuntimeExpression('$nope')).toThrow(lib.ArazzoError); + }); +}); diff --git a/src/arazzo-expressions.ts b/src/arazzo-expressions.ts new file mode 100644 index 0000000..a751a1b --- /dev/null +++ b/src/arazzo-expressions.ts @@ -0,0 +1,189 @@ +/** + * Arazzo runtime-expression parsing. + * + * Hand-rolled tokenizer over the Arazzo 1.0 runtime-expression grammar + * (`$url`, `$method`, `$statusCode`, `$request.…`, `$response.…`, `$inputs.…`, + * `$outputs.…`, `$steps.…`, `$workflows.…`, `$sourceDescriptions.…`, + * `$components.…`), producing a small serializable AST. Expressions are + * parsed, never evaluated. + */ +import { ArazzoError } from './errors'; +import type { ExpressionValueIR, PayloadExpressionIR, RuntimeExpressionAST, RuntimeExpressionType } from './arazzo-types'; + +const EXACT_ROOTS: Record = { + $url: 'url', + $method: 'method', + $statusCode: 'statusCode', +}; + +const DOTTED_ROOTS: Record = { + $inputs: 'inputs', + $outputs: 'outputs', + $steps: 'steps', + $workflows: 'workflows', + $sourceDescriptions: 'sourceDescriptions', + $components: 'components', +}; + +/** All roots the grammar knows, used to decide expression-vs-literal. */ +const KNOWN_ROOT = /^\$(url|method|statusCode|request|response|inputs|outputs|steps|workflows|sourceDescriptions|components)\b/; + +function fail(message: string, docPath: string, expression: string): never { + throw new ArazzoError(message, { path: docPath, expression }); +} + +/** RFC 7230 token characters (header names). */ +const TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; + +function parseSourceRef(prefix: 'request' | 'response', rest: string, raw: string, docPath: string): RuntimeExpressionAST { + if (rest.startsWith('header.')) { + const name = rest.slice('header.'.length); + if (name === '' || !TOKEN.test(name)) { + fail(`Invalid header name in runtime expression "${raw}"`, docPath, raw); + } + return { type: prefix, raw, path: [], source: 'header', name }; + } + if (rest.startsWith('query.') || rest.startsWith('path.')) { + const source = rest.startsWith('query.') ? 'query' : 'path'; + const name = rest.slice(source.length + 1); + if (name === '') { + fail(`Empty ${source} parameter name in runtime expression "${raw}"`, docPath, raw); + } + return { type: prefix, raw, path: [], source, name }; + } + if (rest === 'body' || rest.startsWith('body#')) { + const node: RuntimeExpressionAST = { type: prefix, raw, path: [], source: 'body' }; + if (rest.startsWith('body#')) { + const pointer = rest.slice('body#'.length); + if (pointer !== '' && !pointer.startsWith('/')) { + fail(`JSON Pointer in "${raw}" must be empty or start with "/"`, docPath, raw); + } + node.pointer = pointer; + } + return node; + } + fail(`Invalid $${prefix} reference "${raw}" — expected header., query., path., or body[#]`, docPath, raw); +} + +/** + * Parse a runtime expression into its structured form. Throws `ArazzoError` + * (with the document path in `context.path`) on any grammar violation. + */ +export function parseRuntimeExpression(raw: string, docPath = ''): RuntimeExpressionAST { + const exact = EXACT_ROOTS[raw]; + if (exact) { + return { type: exact, raw, path: [] }; + } + for (const key of Object.keys(EXACT_ROOTS)) { + if (raw.startsWith(key) && raw !== key) { + fail(`Unexpected characters after "${key}" in runtime expression "${raw}"`, docPath, raw); + } + } + + if (raw.startsWith('$request.') || raw.startsWith('$response.')) { + const prefix = raw.startsWith('$request.') ? 'request' : 'response'; + return parseSourceRef(prefix, raw.slice(prefix.length + 2), raw, docPath); + } + + const dot = raw.indexOf('.'); + const rootToken = dot === -1 ? raw : raw.slice(0, dot); + const root = DOTTED_ROOTS[rootToken]; + if (root) { + const rest = dot === -1 ? '' : raw.slice(dot + 1); + if (rest === '') { + fail(`Runtime expression "${raw}" is missing a name after "${rootToken}."`, docPath, raw); + } + const path = rest.split('.'); + if (path.some((segment) => segment === '' || /\s/.test(segment))) { + fail(`Runtime expression "${raw}" contains an empty or whitespace path segment`, docPath, raw); + } + return { type: root, raw, path }; + } + + fail(`Invalid runtime expression "${raw}"`, docPath, raw); +} + +/** + * Interpret a step/parameter value: non-strings are literals; strings that + * start with a known expression root must parse as expressions; strings with + * embedded `{$...}` become templates; everything else is a literal (`"$50"` + * has no known root and stays literal). + */ +export function parseExpressionValue(value: unknown, docPath = ''): ExpressionValueIR { + if (typeof value !== 'string') { + return { kind: 'literal', value }; + } + if (value.startsWith('$')) { + if (KNOWN_ROOT.test(value)) { + return { kind: 'expression', expression: parseRuntimeExpression(value, docPath) }; + } + return { kind: 'literal', value }; + } + if (!value.includes('{$')) { + return { kind: 'literal', value }; + } + + // Template scan: `{$...}` embeds (no nesting per spec) + const parts: Array = []; + let cursor = 0; + while (cursor < value.length) { + const open = value.indexOf('{$', cursor); + if (open === -1) { + parts.push(value.slice(cursor)); + break; + } + if (open > cursor) { + parts.push(value.slice(cursor, open)); + } + const close = value.indexOf('}', open); + if (close === -1) { + fail(`Unterminated "{$" template expression in "${value}"`, docPath, value); + } + parts.push(parseRuntimeExpression(value.slice(open + 1, close), docPath)); + cursor = close + 1; + } + return { kind: 'template', raw: value, parts }; +} + +/** Escape an object key per RFC 6901. */ +function escapePointerSegment(segment: string): string { + return segment.replace(/~/g, '~0').replace(/\//g, '~1'); +} + +/** + * Walk a request-body payload and record every string that parses to an + * expression or template, keyed by its RFC 6901 pointer (`''` = the payload + * itself is the value). + */ +export function collectPayloadExpressions(payload: unknown, docPath = ''): PayloadExpressionIR[] { + const found: PayloadExpressionIR[] = []; + const seen = new Set(); + + const visit = (node: unknown, pointer: string): void => { + if (typeof node === 'string') { + const value = parseExpressionValue(node, docPath); + if (value.kind !== 'literal') { + found.push({ pointer, value }); + } + return; + } + if (!node || typeof node !== 'object') { + return; + } + /* c8 ignore next 3 -- payloads come from JSON/YAML and cannot be cyclic */ + if (seen.has(node)) { + return; + } + seen.add(node); + if (Array.isArray(node)) { + node.forEach((item, index) => visit(item, `${pointer}/${index}`)); + return; + } + for (const [key, value] of Object.entries(node)) { + visit(value, `${pointer}/${escapePointerSegment(key)}`); + } + }; + + visit(payload, ''); + return found; +} diff --git a/src/arazzo-types.ts b/src/arazzo-types.ts new file mode 100644 index 0000000..955d95c --- /dev/null +++ b/src/arazzo-types.ts @@ -0,0 +1,303 @@ +/** + * Arazzo 1.0 document and workflow-IR type definitions. + * + * The document types cover the subset of the Arazzo Specification 1.0 + * (https://spec.openapis.org/arazzo/v1.0.0.html) that `fromArazzo()` reads. + * The IR types describe the pure, JSON-serializable workflow representation + * embedded on `metadata.workflow` — executors drive HTTP from it and never + * need a second spec pass. This library never executes steps or evaluates + * criteria. + */ +import type { HTTPMethod, JsonSchema, ParameterMapper, SchemaObject, SecurityRequirement, ServerInfo } from './types'; + +// --------------------------------------------------------------------------- +// Arazzo document subset +// --------------------------------------------------------------------------- + +/** Arazzo `info` object. */ +export interface ArazzoInfo { + title: string; + summary?: string; + description?: string; + version: string; +} + +/** A source description entry (`sourceDescriptions[]`). */ +export interface ArazzoSourceDescription { + /** Unique name matching `[A-Za-z0-9_-]+`. */ + name: string; + /** URL/location of the source document — never fetched by this library. */ + url: string; + type?: 'openapi' | 'arazzo'; +} + +/** Root Arazzo 1.0 document. */ +export interface ArazzoDocument { + /** Version string matching `1.0.x`. */ + arazzo: string; + info: ArazzoInfo; + sourceDescriptions: ArazzoSourceDescription[]; + workflows: ArazzoWorkflow[]; + components?: ArazzoComponents; +} + +/** A workflow (`workflows[]`). */ +export interface ArazzoWorkflow { + /** Unique id matching `[A-Za-z0-9_-]+`. */ + workflowId: string; + summary?: string; + description?: string; + /** JSON Schema for workflow inputs; may `$ref` into `#/components/inputs`. */ + inputs?: SchemaObject; + dependsOn?: string[]; + steps: ArazzoStep[]; + successActions?: Array; + failureActions?: Array; + /** Output name → runtime expression. */ + outputs?: Record; + parameters?: Array; +} + +/** A step: exactly one of `operationId` / `operationPath` / `workflowId`. */ +export interface ArazzoStep { + /** Unique id within the workflow, matching `[A-Za-z0-9_-]+`. */ + stepId: string; + description?: string; + operationId?: string; + operationPath?: string; + workflowId?: string; + parameters?: Array; + requestBody?: ArazzoRequestBody; + successCriteria?: ArazzoCriterion[]; + onSuccess?: Array; + onFailure?: Array; + /** Output name → runtime expression. */ + outputs?: Record; +} + +/** A parameter applied to a step or workflow. */ +export interface ArazzoParameter { + name: string; + /** Required for operation steps; forbidden on workflowId steps. */ + in?: 'path' | 'query' | 'header' | 'cookie'; + /** Literal value or runtime expression (string form). */ + value: unknown; +} + +/** Step request body. */ +export interface ArazzoRequestBody { + contentType?: string; + /** Literal payload; strings may embed `{$...}` template expressions. */ + payload?: unknown; + replacements?: ArazzoPayloadReplacement[]; +} + +/** A targeted replacement inside `payload`. */ +export interface ArazzoPayloadReplacement { + /** JSON Pointer (or XPath for XML payloads) into the payload. */ + target: string; + value: unknown; +} + +/** Criterion `type`: shorthand or the Criterion Expression Type Object. */ +export type ArazzoCriterionType = 'simple' | 'regex' | 'jsonpath' | 'xpath' | { type: 'jsonpath' | 'xpath'; version: string }; + +/** A success criterion — this library never evaluates conditions. */ +export interface ArazzoCriterion { + /** Runtime expression providing evaluation context (required for non-simple types). */ + context?: string; + condition: string; + type?: ArazzoCriterionType; +} + +/** `onSuccess` / workflow `successActions` entry. */ +export interface ArazzoSuccessAction { + name: string; + type: 'end' | 'goto'; + workflowId?: string; + stepId?: string; + criteria?: ArazzoCriterion[]; +} + +/** `onFailure` / workflow `failureActions` entry. */ +export interface ArazzoFailureAction { + name: string; + type: 'end' | 'retry' | 'goto'; + workflowId?: string; + stepId?: string; + /** Seconds to wait before retrying. */ + retryAfter?: number; + retryLimit?: number; + criteria?: ArazzoCriterion[]; +} + +/** Reusable Object: a `$components.…` reference with an optional value override. */ +export interface ArazzoReusableObject { + /** Runtime expression, e.g. `$components.parameters.page`. */ + reference: string; + /** Overrides the referenced parameter's `value`. */ + value?: unknown; +} + +/** Arazzo `components`. */ +export interface ArazzoComponents { + inputs?: Record; + parameters?: Record; + successActions?: Record; + failureActions?: Record; +} + +// --------------------------------------------------------------------------- +// Runtime expressions +// --------------------------------------------------------------------------- + +/** Root of a parsed runtime expression. */ +export type RuntimeExpressionType = + | 'url' + | 'method' + | 'statusCode' + | 'request' + | 'response' + | 'inputs' + | 'outputs' + | 'steps' + | 'workflows' + | 'sourceDescriptions' + | 'components'; + +/** + * Structured form of an Arazzo runtime expression. `raw` always preserves + * the exact original text. + */ +export interface RuntimeExpressionAST { + type: RuntimeExpressionType; + raw: string; + /** Dot segments after the root: `$steps.s1.outputs.id` → `['s1','outputs','id']`. */ + path: string[]; + /** For `request` / `response` roots: which part is referenced. */ + source?: 'header' | 'query' | 'path' | 'body'; + /** Header/query/path parameter name for `request` / `response` source refs. */ + name?: string; + /** JSON Pointer after `#` on body refs: `$response.body#/items/0/id` → `/items/0/id`. */ + pointer?: string; +} + +/** A value that is a literal, a whole expression, or a `{$...}`-templated string. */ +export type ExpressionValueIR = + | { kind: 'literal'; value: unknown } + | { kind: 'expression'; expression: RuntimeExpressionAST } + | { kind: 'template'; raw: string; parts: Array }; + +// --------------------------------------------------------------------------- +// Workflow IR +// --------------------------------------------------------------------------- + +/** A parameter in the IR — components inlined, value parsed. */ +export interface StepParameterIR { + name: string; + in?: 'path' | 'query' | 'header' | 'cookie'; + value: ExpressionValueIR; +} + +/** A criterion in the IR — the condition is raw text, never evaluated. */ +export interface CriterionIR { + context?: RuntimeExpressionAST; + condition: string; + type: 'simple' | 'regex' | 'jsonpath' | 'xpath'; + /** Present when the document used a Criterion Expression Type Object. */ + version?: string; +} + +/** A flow action in the IR (success or failure family). */ +export interface ActionIR { + name: string; + kind: 'success' | 'failure'; + type: 'end' | 'goto' | 'retry'; + workflowId?: string; + stepId?: string; + retryAfter?: number; + retryLimit?: number; + criteria?: CriterionIR[]; +} + +/** An expression located inside a request payload (RFC 6901 pointer). */ +export interface PayloadExpressionIR { + /** `''` means the whole payload is the expression value. */ + pointer: string; + value: ExpressionValueIR; +} + +/** A `replacements[]` entry in the IR. */ +export interface PayloadReplacementIR { + target: string; + value: ExpressionValueIR; +} + +/** Step request body in the IR: verbatim payload + located substitutions. */ +export interface StepRequestBodyIR { + contentType?: string; + payload?: unknown; + payloadExpressions?: PayloadExpressionIR[]; + replacements?: PayloadReplacementIR[]; +} + +/** + * Embedded essentials of a step's resolved operation — the subset of the + * per-operation tool an executor needs (`mapper` feeds `buildHttpRequest`). + */ +export interface StepOperationIR { + inputSchema: JsonSchema; + outputSchema?: JsonSchema; + mapper: ParameterMapper[]; + security?: SecurityRequirement[]; + servers?: ServerInfo[]; +} + +interface StepIRBase { + stepId: string; + description?: string; + parameters?: StepParameterIR[]; + successCriteria?: CriterionIR[]; + onSuccess?: ActionIR[]; + onFailure?: ActionIR[]; + outputs?: Record; +} + +/** A step that invokes one HTTP operation from a source description. */ +export interface OperationStepIR extends StepIRBase { + kind: 'operation'; + /** Source description name the operation was resolved from. */ + source: string; + /** OpenAPI path in that source. */ + path: string; + method: HTTPMethod; + operationId?: string; + operation: StepOperationIR; + requestBody?: StepRequestBodyIR; +} + +/** A step that invokes another workflow in the same Arazzo document. */ +export interface NestedWorkflowStepIR extends StepIRBase { + kind: 'workflow'; + workflowId: string; +} + +/** Discriminated step union (`kind`). */ +export type WorkflowStepIR = OperationStepIR | NestedWorkflowStepIR; + +/** The complete serializable workflow IR carried on `metadata.workflow`. */ +export interface WorkflowIR { + /** The document's `arazzo` version, e.g. `'1.0.0'`. */ + arazzoVersion: string; + workflowId: string; + summary?: string; + description?: string; + /** Normalized workflow inputs (components `$ref`s resolved). */ + inputSchema?: JsonSchema; + dependsOn?: string[]; + parameters?: StepParameterIR[]; + steps: WorkflowStepIR[]; + successActions?: ActionIR[]; + failureActions?: ActionIR[]; + outputs?: Record; +} diff --git a/src/arazzo.ts b/src/arazzo.ts new file mode 100644 index 0000000..b2fb693 --- /dev/null +++ b/src/arazzo.ts @@ -0,0 +1,1085 @@ +/** + * Arazzo 1.0 → consolidated MCP tools. + * + * `fromArazzo()` parses an Arazzo workflow document against caller-supplied + * OpenAPI sources and emits ONE `McpOpenAPITool` per workflow: the workflow + * inputs become the tool's input schema, the workflow outputs derive the + * output schema, and a pure, JSON-serializable IR (`metadata.workflow`) + * carries the step sequence — each operation step embedding its resolved + * schemas and mapper so an executor needs no second spec pass. This library + * never fetches source URLs, performs HTTP, or evaluates expressions. + */ +import { OpenAPIToolGenerator, fnv1aHex, normalizeToolName } from './generator'; +import { ArazzoError } from './errors'; +import { collectPayloadExpressions, parseExpressionValue, parseRuntimeExpression } from './arazzo-expressions'; +import { inferAnnotationsFromMethod } from './annotations'; +import { SchemaBuilder } from './schema-builder'; +import { applyClientTarget } from './client-targets'; +import { BUILTIN_FORMAT_RESOLVERS, resolveSchemaFormats } from './format-resolver'; +import { emitToolTypeScript } from './type-signature'; +import { toJsonSchema } from './types'; +import * as yaml from 'yaml'; +import type { + GenerateOptions, + HTTPMethod, + JsonSchema, + LoadOptions, + McpOpenAPITool, + OpenAPIDocument, + SchemaObject, + SecurityRequirement, +} from './types'; +import type { + ActionIR, + ArazzoComponents, + ArazzoCriterion, + ArazzoDocument, + ArazzoFailureAction, + ArazzoParameter, + ArazzoReusableObject, + ArazzoStep, + ArazzoSuccessAction, + ArazzoWorkflow, + CriterionIR, + NestedWorkflowStepIR, + OperationStepIR, + RuntimeExpressionAST, + StepOperationIR, + StepParameterIR, + StepRequestBodyIR, + WorkflowIR, + WorkflowStepIR, +} from './arazzo-types'; + +/** + * The `GenerateOptions` subset that applies to Arazzo output — schema-shaping + * and naming options. Operation-filtering options have no meaning here. + */ +export type ArazzoGenerateOptions = Pick< + GenerateOptions, + | 'target' + | 'maxSchemaDepth' + | 'maxProperties' + | 'maxDescriptionLength' + | 'stripExamples' + | 'includeExamples' + | 'resolveFormats' + | 'formatResolvers' + | 'preferredStatusCodes' + | 'includeAllResponses' + | 'maxToolNameLength' + | 'includeSecurityInInput' + | 'emitTypeSignatures' +>; + +/** Options for {@link fromArazzo}. */ +export interface FromArazzoOptions { + /** + * Source description name → resolved OpenAPI document or pre-built + * generator. Source URLs are NEVER fetched — the caller supplies resolved + * documents for every source the workflows use. + */ + sources: Record; + + /** + * Schema-affecting options, applied to the per-step embedded schemas AND + * the consolidated workflow schemas. + */ + generateOptions?: ArazzoGenerateOptions; + + /** + * Load options for internally-constructed generators (raw documents only). + */ + loadOptions?: Pick; +} + +const ID_PATTERN = /^[A-Za-z0-9_-]+$/; +const OUTPUT_KEY_PATTERN = /^[a-zA-Z0-9.\-_]+$/; +const VERSION_PATTERN = /^1\.0\.\d+$/; +const HTTP_METHODS: readonly string[] = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace']; +const PARAMETER_LOCATIONS: readonly string[] = ['path', 'query', 'header', 'cookie']; +const OUTPUT_DERIVATION_MAX_DEPTH = 8; + +function err(message: string, path: string, extra?: Record): never { + throw new ArazzoError(message, { path, ...extra }); +} + +// --------------------------------------------------------------------------- +// Parsing & validation +// --------------------------------------------------------------------------- + +function parseArazzoInput(input: ArazzoDocument | string): ArazzoDocument { + if (typeof input === 'string') { + let parsed: unknown; + try { + parsed = yaml.parse(input); + } catch (error: unknown) { + /* c8 ignore next -- yaml only throws Error instances */ + const message = error instanceof Error ? error.message : String(error); + throw new ArazzoError(`Failed to parse Arazzo document: ${message}`, { path: '' }); + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + err('Arazzo document must be an object', ''); + } + return parsed as ArazzoDocument; + } + if (!input || typeof input !== 'object' || Array.isArray(input)) { + err('Arazzo document must be an object', ''); + } + // Never mutate caller input (components inlining edits the tree) + return JSON.parse(JSON.stringify(input)) as ArazzoDocument; +} + +function validateCriteria(criteria: unknown, path: string): void { + if (criteria === undefined) return; + if (!Array.isArray(criteria)) { + err('successCriteria/criteria must be an array', path); + } + criteria.forEach((criterion: ArazzoCriterion, index) => { + const cPath = `${path}/${index}`; + if (!criterion || typeof criterion !== 'object') { + err('Criterion must be an object', cPath); + } + if (typeof criterion.condition !== 'string' || criterion.condition === '') { + err('Criterion requires a non-empty string "condition"', cPath); + } + const type = criterion.type; + let effectiveType: string | undefined; + if (type !== undefined) { + if (typeof type === 'string') { + if (!['simple', 'regex', 'jsonpath', 'xpath'].includes(type)) { + err(`Unknown criterion type "${type}"`, cPath); + } + effectiveType = type; + } else if (type && typeof type === 'object') { + if ((type.type !== 'jsonpath' && type.type !== 'xpath') || typeof type.version !== 'string') { + err('Criterion Expression Type Object requires "type" (jsonpath|xpath) and "version"', cPath); + } + effectiveType = type.type; + } else { + err('Criterion "type" must be a string or a Criterion Expression Type Object', cPath); + } + } + if (effectiveType !== undefined && effectiveType !== 'simple' && criterion.context === undefined) { + err(`Criterion of type "${effectiveType}" requires a "context" expression`, cPath); + } + }); +} + +function validateActions( + actions: Array | undefined, + kind: 'success' | 'failure', + path: string, +): void { + if (actions === undefined) return; + if (!Array.isArray(actions)) { + err('Actions must be an array', path); + } + actions.forEach((action, index) => { + const aPath = `${path}/${index}`; + if (!action || typeof action !== 'object') { + err('Action must be an object', aPath); + } + if ('reference' in action) { + return; // Reusable Object — resolved and re-validated later + } + const act = action as ArazzoSuccessAction & ArazzoFailureAction; + if (typeof act.name !== 'string' || act.name === '') { + err('Action requires a non-empty string "name"', aPath); + } + const allowed = kind === 'success' ? ['end', 'goto'] : ['end', 'retry', 'goto']; + if (!allowed.includes(act.type)) { + err(`Invalid ${kind}-action type "${String(act.type)}" (allowed: ${allowed.join(', ')})`, aPath); + } + const targets = [act.workflowId, act.stepId].filter((t) => t !== undefined).length; + if (act.type === 'goto' && targets !== 1) { + err('A "goto" action requires exactly one of "workflowId" or "stepId"', aPath); + } + if (act.type === 'end' && targets !== 0) { + err('An "end" action must not specify "workflowId" or "stepId"', aPath); + } + if (act.retryAfter !== undefined && (typeof act.retryAfter !== 'number' || act.retryAfter < 0)) { + err('"retryAfter" must be a non-negative number', aPath); + } + if (act.retryLimit !== undefined && (typeof act.retryLimit !== 'number' || !Number.isInteger(act.retryLimit) || act.retryLimit < 0)) { + err('"retryLimit" must be a non-negative integer', aPath); + } + validateCriteria(act.criteria, `${aPath}/criteria`); + }); +} + +function validateParameters( + parameters: Array | undefined, + requireIn: boolean | undefined, + path: string, +): void { + if (parameters === undefined) return; + if (!Array.isArray(parameters)) { + err('Parameters must be an array', path); + } + const seen = new Set(); + parameters.forEach((parameter, index) => { + const pPath = `${path}/${index}`; + if (!parameter || typeof parameter !== 'object') { + err('Parameter must be an object', pPath); + } + if ('reference' in parameter) { + return; // Reusable Object — resolved and re-validated later + } + const param = parameter as ArazzoParameter; + if (typeof param.name !== 'string' || param.name === '') { + err('Parameter requires a non-empty string "name"', pPath); + } + const paramName = param.name; + if (!('value' in param)) { + err(`Parameter "${paramName}" requires a "value"`, pPath); + } + if (param.in !== undefined && !PARAMETER_LOCATIONS.includes(param.in)) { + err(`Invalid parameter location "${String(param.in)}"`, pPath); + } + if (requireIn === true && param.in === undefined) { + err(`Parameter "${param.name}" on an operation step requires "in"`, pPath); + } + if (requireIn === false && param.in !== undefined) { + err(`Parameter "${param.name}" on a workflowId step must not specify "in"`, pPath); + } + const key = `${param.name}${param.in ?? ''}`; + if (seen.has(key)) { + err(`Duplicate parameter "${param.name}"${param.in ? ` (in: ${param.in})` : ''}`, pPath); + } + seen.add(key); + }); +} + +function validateOutputs(outputs: unknown, path: string): void { + if (outputs === undefined) return; + if (!outputs || typeof outputs !== 'object' || Array.isArray(outputs)) { + err('"outputs" must be an object of name → runtime expression', path); + } + for (const [key, value] of Object.entries(outputs)) { + if (!OUTPUT_KEY_PATTERN.test(key)) { + err(`Invalid output name "${key}"`, `${path}/${key}`); + } + if (typeof value !== 'string') { + err(`Output "${key}" must be a runtime expression string`, `${path}/${key}`); + } + } +} + +function validateDocument(doc: ArazzoDocument): void { + if (typeof doc.arazzo !== 'string' || !VERSION_PATTERN.test(doc.arazzo)) { + err(`Unsupported arazzo version "${String(doc.arazzo)}" (expected 1.0.x)`, '/arazzo'); + } + if (!doc.info || typeof doc.info !== 'object' || typeof doc.info.title !== 'string' || typeof doc.info.version !== 'string') { + err('"info" requires string "title" and "version"', '/info'); + } + if (!Array.isArray(doc.sourceDescriptions) || doc.sourceDescriptions.length === 0) { + err('"sourceDescriptions" must be a non-empty array', '/sourceDescriptions'); + } + const sourceNames = new Set(); + doc.sourceDescriptions.forEach((source, index) => { + const sPath = `/sourceDescriptions/${index}`; + if (!source || typeof source !== 'object' || typeof source.name !== 'string' || !ID_PATTERN.test(source.name)) { + err('Source description requires a "name" matching [A-Za-z0-9_-]+', sPath); + } + if (typeof source.url !== 'string' || source.url === '') { + err(`Source "${source.name}" requires a string "url"`, sPath); + } + if (source.type !== undefined && source.type !== 'openapi' && source.type !== 'arazzo') { + err(`Source "${source.name}" has invalid type "${String(source.type)}"`, sPath); + } + if (sourceNames.has(source.name)) { + err(`Duplicate source description name "${source.name}"`, sPath); + } + sourceNames.add(source.name); + }); + + if (!Array.isArray(doc.workflows) || doc.workflows.length === 0) { + err('"workflows" must be a non-empty array', '/workflows'); + } + const workflowIds = new Set(); + doc.workflows.forEach((workflow, wIndex) => { + const wPath = `/workflows/${wIndex}`; + if (!workflow || typeof workflow !== 'object' || typeof workflow.workflowId !== 'string' || !ID_PATTERN.test(workflow.workflowId)) { + err('Workflow requires a "workflowId" matching [A-Za-z0-9_-]+', wPath); + } + if (workflowIds.has(workflow.workflowId)) { + err(`Duplicate workflowId "${workflow.workflowId}"`, wPath); + } + workflowIds.add(workflow.workflowId); + + if (!Array.isArray(workflow.steps) || workflow.steps.length === 0) { + err(`Workflow "${workflow.workflowId}" requires a non-empty "steps" array`, `${wPath}/steps`); + } + validateParameters(workflow.parameters, undefined, `${wPath}/parameters`); + validateActions(workflow.successActions, 'success', `${wPath}/successActions`); + validateActions(workflow.failureActions, 'failure', `${wPath}/failureActions`); + validateOutputs(workflow.outputs, `${wPath}/outputs`); + + const stepIds = new Set(); + workflow.steps.forEach((step, sIndex) => { + const sPath = `${wPath}/steps/${sIndex}`; + if (!step || typeof step !== 'object' || typeof step.stepId !== 'string' || !ID_PATTERN.test(step.stepId)) { + err('Step requires a "stepId" matching [A-Za-z0-9_-]+', sPath); + } + if (stepIds.has(step.stepId)) { + err(`Duplicate stepId "${step.stepId}" in workflow "${workflow.workflowId}"`, sPath); + } + stepIds.add(step.stepId); + + const kinds = [step.operationId, step.operationPath, step.workflowId].filter((k) => k !== undefined).length; + if (kinds !== 1) { + err(`Step "${step.stepId}" requires exactly one of "operationId", "operationPath", or "workflowId"`, sPath); + } + validateParameters(step.parameters, step.workflowId !== undefined ? false : true, `${sPath}/parameters`); + validateCriteria(step.successCriteria, `${sPath}/successCriteria`); + validateActions(step.onSuccess, 'success', `${sPath}/onSuccess`); + validateActions(step.onFailure, 'failure', `${sPath}/onFailure`); + validateOutputs(step.outputs, `${sPath}/outputs`); + }); + }); +} + +// --------------------------------------------------------------------------- +// Components resolution +// --------------------------------------------------------------------------- + +function resolveReusable( + entry: T | ArazzoReusableObject, + components: ArazzoComponents | undefined, + expectedGroup: 'parameters' | 'successActions' | 'failureActions', + path: string, +): T { + if (!entry || typeof entry !== 'object' || !('reference' in (entry as object))) { + return entry as T; + } + const reusable = entry as ArazzoReusableObject; + if (typeof reusable.reference !== 'string') { + err('Reusable Object "reference" must be a string', path); + } + const ast = parseRuntimeExpression(reusable.reference, path); + if (ast.type !== 'components' || ast.path.length !== 2 || ast.path[0] !== expectedGroup) { + err(`Reference "${reusable.reference}" must point at $components.${expectedGroup}.`, path); + } + const name = ast.path[1]; + const target = components?.[expectedGroup]?.[name]; + if (!target) { + err(`Unknown reference "$components.${expectedGroup}.${name}"`, path); + } + const resolved = JSON.parse(JSON.stringify(target)) as T; + if (expectedGroup === 'parameters' && 'value' in reusable) { + (resolved as ArazzoParameter).value = reusable.value; + } + return resolved; +} + +/** Resolve `#/components/inputs/...` $refs inside a workflow inputs schema. */ +function resolveInputRefs(node: unknown, components: ArazzoComponents | undefined, path: string, seen: Set): unknown { + if (Array.isArray(node)) { + return node.map((item) => resolveInputRefs(item, components, path, seen)); + } + if (!node || typeof node !== 'object') { + return node; + } + const record = node as Record; + const ref = record['$ref']; + if (typeof ref === 'string') { + const prefix = '#/components/inputs/'; + if (!ref.startsWith(prefix)) { + err(`Unsupported $ref "${ref}" in workflow inputs (only ${prefix} is resolvable)`, path); + } + const name = ref.slice(prefix.length); + const target = components?.inputs?.[name]; + if (!target) { + err(`Unknown workflow inputs reference "${ref}"`, path); + } + if (seen.has(name)) { + err(`Cyclic workflow inputs reference "${ref}"`, path); + } + seen.add(name); + const resolved = resolveInputRefs(target, components, path, seen); + seen.delete(name); + return resolved; + } + const out: Record = {}; + for (const [key, value] of Object.entries(record)) { + out[key] = resolveInputRefs(value, components, path, seen); + } + return out; +} + +// --------------------------------------------------------------------------- +// Sources & operation resolution +// --------------------------------------------------------------------------- + +interface SourceContext { + generators: Map; + /** operationId → every (source, path, method) that declares it. */ + operationIndex: Map>; + sourceTypes: Map; +} + +async function prepareSources(doc: ArazzoDocument, options: FromArazzoOptions): Promise { + const declared = new Map(doc.sourceDescriptions.map((s) => [s.name, s])); + const generators = new Map(); + const sourceTypes = new Map(); + + for (const [name, source] of Object.entries(options.sources ?? {})) { + if (!declared.has(name)) { + err(`options.sources contains "${name}", which is not a declared source description`, '/sourceDescriptions', { + declared: [...declared.keys()], + }); + } + if (source instanceof OpenAPIToolGenerator) { + generators.set(name, source); + } else { + generators.set(name, await OpenAPIToolGenerator.fromJSON(source as object, options.loadOptions)); + } + } + for (const [name, source] of declared) { + sourceTypes.set(name, source.type ?? 'openapi'); + } + + const operationIndex: SourceContext['operationIndex'] = new Map(); + for (const [name, generator] of generators) { + const document = generator.getDocument(); + for (const [pathStr, pathItem] of Object.entries(document.paths ?? {})) { + if (!pathItem || typeof pathItem !== 'object') continue; + for (const method of HTTP_METHODS) { + const operation = (pathItem as Record)[method]; + if (!operation || typeof operation !== 'object') continue; + const operationId = (operation as Record)['operationId']; + if (typeof operationId !== 'string') continue; + const hits = operationIndex.get(operationId) ?? []; + hits.push({ source: name, path: pathStr, method: method as HTTPMethod }); + operationIndex.set(operationId, hits); + } + } + } + return { generators, operationIndex, sourceTypes }; +} + +function requireGenerator(ctx: SourceContext, source: string, path: string): OpenAPIToolGenerator { + if (ctx.sourceTypes.get(source) === 'arazzo') { + err(`Source "${source}" has type "arazzo" — nested Arazzo sources are not supported`, path); + } + const generator = ctx.generators.get(source); + if (!generator) { + err(`No document supplied for source "${source}" (add it to options.sources)`, path, { + supplied: [...ctx.generators.keys()], + }); + } + return generator; +} + +/** Parse `{$sourceDescriptions..url}#` into (source, path, method). */ +function parseOperationPath(value: string, path: string): { source: string; path: string; method: HTTPMethod } { + if (!value.startsWith('{')) { + err(`operationPath "${value}" must start with a "{$sourceDescriptions...}" expression`, path); + } + const close = value.indexOf('}'); + if (close === -1) { + err(`operationPath "${value}" is missing "}"`, path); + } + const ast = parseRuntimeExpression(value.slice(1, close), path); + if (ast.type !== 'sourceDescriptions' || ast.path.length !== 2 || ast.path[1] !== 'url') { + err(`operationPath "${value}" must reference $sourceDescriptions..url`, path); + } + const source = ast.path[0]; + const rest = value.slice(close + 1); + if (!rest.startsWith('#/')) { + err(`operationPath "${value}" requires a "#/paths/..." JSON Pointer after the source expression`, path); + } + const segments = rest + .slice(2) + .split('/') + .map((segment) => segment.replace(/~1/g, '/').replace(/~0/g, '~')); + if (segments.length !== 3 || segments[0] !== 'paths') { + err(`operationPath pointer in "${value}" must have the shape #/paths//`, path); + } + const method = segments[2].toLowerCase(); + if (!HTTP_METHODS.includes(method)) { + err(`operationPath "${value}" ends in unknown HTTP method "${segments[2]}"`, path); + } + return { source, path: segments[1], method: method as HTTPMethod }; +} + +function resolveOperationRef( + step: ArazzoStep, + ctx: SourceContext, + path: string, +): { source: string; path: string; method: HTTPMethod; operationId?: string } { + if (step.operationPath !== undefined) { + return parseOperationPath(step.operationPath, path); + } + const ref = step.operationId as string; + if (ref.startsWith('$')) { + // $sourceDescriptions.. pins the source; the + // remainder is re-joined so dotted operationIds survive. + const ast = parseRuntimeExpression(ref, path); + if (ast.type !== 'sourceDescriptions' || ast.path.length < 2) { + err(`operationId expression "${ref}" must be $sourceDescriptions..`, path); + } + const source = ast.path[0]; + const operationId = ast.path.slice(1).join('.'); + const hits = (ctx.operationIndex.get(operationId) ?? []).filter((h) => h.source === source); + if (hits.length === 0) { + requireGenerator(ctx, source, path); // surface missing-source/arazzo-type errors first + err(`operationId "${operationId}" not found in source "${source}"`, path); + } + if (hits.length > 1) { + err(`operationId "${operationId}" is duplicated inside source "${source}"`, path, { hits }); + } + return { ...hits[0], operationId }; + } + const hits = ctx.operationIndex.get(ref) ?? []; + if (hits.length === 0) { + err(`operationId "${ref}" not found in any supplied source (${[...ctx.generators.keys()].join(', ') || 'none'})`, path); + } + if (hits.length > 1) { + err( + `operationId "${ref}" is ambiguous across sources (${hits.map((h) => h.source).join(', ')}) — pin it with $sourceDescriptions..${ref}`, + path, + { hits }, + ); + } + return { ...hits[0], operationId: ref }; +} + +// --------------------------------------------------------------------------- +// Cycle checks +// --------------------------------------------------------------------------- + +function checkCycles(edges: Map, kind: string): void { + const state = new Map(); + for (const start of edges.keys()) { + if (state.get(start) === 'done') continue; + const stack: Array<{ node: string; next: number }> = [{ node: start, next: 0 }]; + state.set(start, 'visiting'); + while (stack.length > 0) { + const frame = stack[stack.length - 1]; + const targets = edges.get(frame.node) ?? []; + if (frame.next >= targets.length) { + state.set(frame.node, 'done'); + stack.pop(); + continue; + } + const target = targets[frame.next++]; + const targetState = state.get(target); + if (targetState === 'visiting') { + const cycle = [...stack.map((f) => f.node), target]; + err(`Cyclic ${kind}: ${cycle.slice(cycle.indexOf(target)).join(' -> ')}`, '/workflows'); + } + if (targetState !== 'done') { + state.set(target, 'visiting'); + stack.push({ node: target, next: 0 }); + } + } + } +} + +// --------------------------------------------------------------------------- +// IR construction +// --------------------------------------------------------------------------- + +interface BuildContext { + doc: ArazzoDocument; + sources: SourceContext; + generateOptions: ArazzoGenerateOptions; + workflowIds: Set; + /** Cache of resolved per-operation tools, keyed by source+method+path. */ + operationCache: Map>; +} + +function toCriterionIR(criterion: ArazzoCriterion, path: string): CriterionIR { + const ir: CriterionIR = { + condition: criterion.condition, + type: 'simple', + }; + if (criterion.context !== undefined) { + ir.context = parseRuntimeExpression(criterion.context, path); + } + if (typeof criterion.type === 'string') { + ir.type = criterion.type; + } else if (criterion.type) { + ir.type = criterion.type.type; + ir.version = criterion.type.version; + } + return ir; +} + +function toActionIR( + action: ArazzoSuccessAction | ArazzoFailureAction, + kind: 'success' | 'failure', + path: string, +): ActionIR { + const failure = action as ArazzoFailureAction; + return { + name: action.name, + kind, + type: action.type, + ...(action.workflowId !== undefined && { workflowId: action.workflowId }), + ...(action.stepId !== undefined && { stepId: action.stepId }), + ...(failure.retryAfter !== undefined && { retryAfter: failure.retryAfter }), + ...(failure.retryLimit !== undefined && { retryLimit: failure.retryLimit }), + ...(action.criteria && { criteria: action.criteria.map((c) => toCriterionIR(c, path)) }), + }; +} + +function resolveActions( + actions: Array, + kind: 'success' | 'failure', + components: ArazzoComponents | undefined, + path: string, +): ActionIR[] { + const group = kind === 'success' ? 'successActions' : 'failureActions'; + const resolved = actions.map((action, index) => { + const aPath = `${path}/${index}`; + const concrete = resolveReusable(action, components, group, aPath); + return toActionIR(concrete, kind, aPath); + }); + // Reusable-sourced actions bypass the first validation pass + resolved.forEach((action, index) => { + const allowed = kind === 'success' ? ['end', 'goto'] : ['end', 'retry', 'goto']; + if (!allowed.includes(action.type)) { + err(`Invalid ${kind}-action type "${action.type}" (allowed: ${allowed.join(', ')})`, `${path}/${index}`); + } + }); + return resolved; +} + +function resolveParameters( + parameters: Array, + components: ArazzoComponents | undefined, + path: string, +): StepParameterIR[] { + return parameters.map((parameter, index) => { + const pPath = `${path}/${index}`; + const concrete = resolveReusable(parameter, components, 'parameters', pPath); + if (typeof concrete.name !== 'string' || concrete.name === '' || !('value' in concrete)) { + err('Resolved parameter requires "name" and "value"', pPath); + } + return { + name: concrete.name, + ...(concrete.in !== undefined && { in: concrete.in }), + value: parseExpressionValue(concrete.value, pPath), + }; + }); +} + +function parseOutputs(outputs: Record | undefined, path: string): Record | undefined { + if (!outputs) return undefined; + const parsed: Record = {}; + for (const [name, expression] of Object.entries(outputs)) { + parsed[name] = parseRuntimeExpression(expression, `${path}/${name}`); + } + return parsed; +} + +async function resolveStepOperation( + ref: { source: string; path: string; method: HTTPMethod }, + ctx: BuildContext, + docPath: string, +): Promise { + const key = `${ref.source}${ref.method}${ref.path}`; + let cached = ctx.operationCache.get(key); + if (!cached) { + const generator = requireGenerator(ctx.sources, ref.source, docPath); + cached = generator.generateTool(ref.path, ref.method, ctx.generateOptions as GenerateOptions).catch((error: unknown) => { + /* c8 ignore next -- the generator only throws Error instances */ + const message = error instanceof Error ? error.message : String(error); + throw new ArazzoError( + `Failed to resolve ${ref.method.toUpperCase()} ${ref.path} from source "${ref.source}": ${message}`, + { path: docPath, source: ref.source }, + ); + }); + ctx.operationCache.set(key, cached); + } + return cached; +} + +async function buildStepIR(step: ArazzoStep, ctx: BuildContext, path: string): Promise { + const components = ctx.doc.components; + const base = { + stepId: step.stepId, + ...(step.description !== undefined && { description: step.description }), + ...(step.parameters && { parameters: resolveParameters(step.parameters, components, `${path}/parameters`) }), + ...(step.successCriteria && { + successCriteria: step.successCriteria.map((c, i) => toCriterionIR(c, `${path}/successCriteria/${i}`)), + }), + ...(step.onSuccess && { onSuccess: resolveActions(step.onSuccess, 'success', components, `${path}/onSuccess`) }), + ...(step.onFailure && { onFailure: resolveActions(step.onFailure, 'failure', components, `${path}/onFailure`) }), + ...(step.outputs && { outputs: parseOutputs(step.outputs, `${path}/outputs`) }), + }; + + if (step.workflowId !== undefined) { + if (!ctx.workflowIds.has(step.workflowId)) { + err(`Step "${step.stepId}" references unknown workflow "${step.workflowId}"`, path); + } + const ir: NestedWorkflowStepIR = { kind: 'workflow', workflowId: step.workflowId, ...base }; + return ir; + } + + const ref = resolveOperationRef(step, ctx.sources, path); + const tool = await resolveStepOperation(ref, ctx, path); + const operation: StepOperationIR = { + inputSchema: tool.inputSchema, + outputSchema: tool.outputSchema, + mapper: tool.mapper, + ...(tool.metadata.security && { security: tool.metadata.security }), + ...(tool.metadata.servers && { servers: tool.metadata.servers }), + }; + + let requestBody: StepRequestBodyIR | undefined; + if (step.requestBody !== undefined) { + if (!step.requestBody || typeof step.requestBody !== 'object') { + err(`Step "${step.stepId}" requestBody must be an object`, `${path}/requestBody`); + } + requestBody = { + ...(step.requestBody.contentType !== undefined && { contentType: step.requestBody.contentType }), + ...(step.requestBody.payload !== undefined && { payload: step.requestBody.payload }), + }; + const expressions = collectPayloadExpressions(step.requestBody.payload, `${path}/requestBody/payload`); + if (expressions.length > 0) { + requestBody.payloadExpressions = expressions; + } + if (step.requestBody.replacements !== undefined) { + if (!Array.isArray(step.requestBody.replacements)) { + err(`Step "${step.stepId}" requestBody.replacements must be an array`, `${path}/requestBody/replacements`); + } + requestBody.replacements = step.requestBody.replacements.map((replacement, index) => { + const rPath = `${path}/requestBody/replacements/${index}`; + if (!replacement || typeof replacement !== 'object' || typeof replacement.target !== 'string') { + err('Replacement requires a string "target"', rPath); + } + return { target: replacement.target, value: parseExpressionValue(replacement.value, rPath) }; + }); + } + } + + const ir: OperationStepIR = { + kind: 'operation', + source: ref.source, + path: ref.path, + method: ref.method, + ...(ref.operationId !== undefined && { operationId: ref.operationId }), + operation, + ...(requestBody && { requestBody }), + ...base, + }; + return ir; +} + +// --------------------------------------------------------------------------- +// Output schema derivation +// --------------------------------------------------------------------------- + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** Follow a JSON Pointer through `properties` / `items`, best-effort. */ +function walkPointer(schema: unknown, pointer: string | undefined): unknown { + if (pointer === undefined || pointer === '') { + return schema; + } + let node: unknown = schema; + for (const rawSegment of pointer.slice(1).split('/')) { + const segment = rawSegment.replace(/~1/g, '/').replace(/~0/g, '~'); + /* c8 ignore next -- schema nodes are records after toJsonSchema; guards hand-built IRs */ + if (!isRecord(node)) return undefined; + const properties = node['properties']; + if (isRecord(properties) && properties[segment] !== undefined) { + node = properties[segment]; + continue; + } + if (/^\d+$/.test(segment) && node['items'] !== undefined && !Array.isArray(node['items'])) { + node = node['items']; + continue; + } + return undefined; + } + return node; +} + +/** Prefer the first status variant of a ResponseBuilder `oneOf` union. */ +function primaryResponseSchema(outputSchema: unknown): unknown { + if (isRecord(outputSchema) && Array.isArray(outputSchema['oneOf'])) { + const variants = outputSchema['oneOf'] as unknown[]; + if (variants.length > 0 && variants.every((v) => isRecord(v) && v['x-status-code'] !== undefined)) { + return variants[0]; + } + } + return outputSchema; +} + +function deriveOutputSchema( + ast: RuntimeExpressionAST, + steps: Map, + inputSchema: JsonSchema | undefined, + depth: number, + stepContext?: OperationStepIR, +): JsonSchema { + if (depth >= OUTPUT_DERIVATION_MAX_DEPTH) { + return {}; + } + if (ast.type === 'statusCode') { + return { type: 'number' }; + } + if (ast.type === 'url' || ast.type === 'method') { + return { type: 'string' }; + } + if (ast.type === 'response') { + if (ast.source !== 'body') { + return { type: 'string' }; + } + if (!stepContext) { + return {}; + } + const body = primaryResponseSchema(stepContext.operation.outputSchema); + const target = walkPointer(body, ast.pointer); + return isRecord(target) ? (target as JsonSchema) : {}; + } + if (ast.type === 'inputs') { + const properties = isRecord(inputSchema) ? inputSchema['properties'] : undefined; + const target = isRecord(properties) ? properties[ast.path[0]] : undefined; + return isRecord(target) ? (target as JsonSchema) : {}; + } + if (ast.type === 'steps' && ast.path.length >= 3 && ast.path[1] === 'outputs') { + const step = steps.get(ast.path[0]); + if (step?.kind === 'operation') { + const stepOutput = step.outputs?.[ast.path.slice(2).join('.')]; + if (stepOutput) { + return deriveOutputSchema(stepOutput, steps, inputSchema, depth + 1, step); + } + } + return {}; + } + return {}; +} + +function deriveOutputsSchema( + outputs: Record | undefined, + steps: WorkflowStepIR[], + inputSchema: JsonSchema | undefined, +): JsonSchema | undefined { + if (!outputs) { + return undefined; + } + const stepMap = new Map(steps.map((s) => [s.stepId, s])); + const properties: Record = {}; + for (const [name, ast] of Object.entries(outputs)) { + const derived = deriveOutputSchema(ast, stepMap, inputSchema, 0); + properties[name] = { ...derived, description: `Arazzo output: ${ast.raw}` }; + } + // No `required`: outputs exist only after successful execution + return { type: 'object', properties }; +} + +// --------------------------------------------------------------------------- +// Tool assembly +// --------------------------------------------------------------------------- + +/** The exact generateTool post-pipeline: formats → depth → trims → target. */ +function applySchemaPipeline(schema: JsonSchema, options: ArazzoGenerateOptions, isInputRoot: boolean): JsonSchema { + const formatResolvers = { + ...(options.resolveFormats ? BUILTIN_FORMAT_RESOLVERS : {}), + ...options.formatResolvers, + }; + let resolved = Object.keys(formatResolvers).length > 0 ? resolveSchemaFormats(schema, formatResolvers) : schema; + resolved = SchemaBuilder.truncateDepth(resolved, Math.max(1, options.maxSchemaDepth ?? 10)); + if (options.stripExamples) resolved = SchemaBuilder.stripExamples(resolved); + if (options.maxDescriptionLength !== undefined) { + resolved = SchemaBuilder.capDescriptions(resolved, options.maxDescriptionLength); + } + if (options.maxProperties !== undefined) { + if (isInputRoot) { + const properties = resolved.properties; + if (properties && typeof properties === 'object') { + const limited: Record = {}; + for (const [key, value] of Object.entries(properties)) { + limited[key] = SchemaBuilder.limitProperties(value as JsonSchema, options.maxProperties); + } + resolved = { ...resolved, properties: limited }; + } + } else { + resolved = SchemaBuilder.limitProperties(resolved, options.maxProperties); + } + } + if (options.target) { + resolved = applyClientTarget(resolved, options.target); + } + return resolved; +} + +function buildWorkflowTool( + workflow: ArazzoWorkflow, + stepIRs: WorkflowStepIR[], + ctx: BuildContext, + wPath: string, +): McpOpenAPITool { + const options = ctx.generateOptions; + + // Input schema: workflow inputs → components refs resolved → normalized + let inputSchema: JsonSchema; + let rawInputSchema: JsonSchema | undefined; + if (workflow.inputs !== undefined) { + const resolved = resolveInputRefs(workflow.inputs, ctx.doc.components, `${wPath}/inputs`, new Set()); + rawInputSchema = toJsonSchema(resolved as SchemaObject); + inputSchema = applySchemaPipeline(rawInputSchema, options, true); + } else { + inputSchema = { type: 'object', properties: {} }; + } + + const derivedOutput = deriveOutputsSchema(parseOutputs(workflow.outputs, `${wPath}/outputs`), stepIRs, rawInputSchema); + const outputSchema = derivedOutput ? applySchemaPipeline(derivedOutput, options, false) : undefined; + + const name = normalizeToolName(workflow.workflowId, options.maxToolNameLength ?? 64, workflow.workflowId); + const description = + workflow.summary && workflow.description + ? `${workflow.summary}\n\n${workflow.description}` + : (workflow.summary ?? workflow.description ?? `Arazzo workflow: ${workflow.workflowId}`); + + // Read-only iff every step is an operation step on a safe method + const operationSteps = stepIRs.filter((s): s is OperationStepIR => s.kind === 'operation'); + const allReadOnly = + operationSteps.length === stepIRs.length && + operationSteps.every((s) => inferAnnotationsFromMethod(s.method).readOnlyHint === true); + + // Deduped union of every step's security requirements + const security: SecurityRequirement[] = []; + const seenSecurity = new Set(); + for (const step of operationSteps) { + for (const requirement of step.operation.security ?? []) { + const key = JSON.stringify(requirement); + if (!seenSecurity.has(key)) { + seenSecurity.add(key); + security.push(requirement); + } + } + } + + const ir: WorkflowIR = { + arazzoVersion: ctx.doc.arazzo, + workflowId: workflow.workflowId, + ...(workflow.summary !== undefined && { summary: workflow.summary }), + ...(workflow.description !== undefined && { description: workflow.description }), + ...(rawInputSchema !== undefined && { inputSchema: rawInputSchema }), + ...(workflow.dependsOn && { dependsOn: workflow.dependsOn }), + ...(workflow.parameters && { + parameters: resolveParameters(workflow.parameters, ctx.doc.components, `${wPath}/parameters`), + }), + steps: stepIRs, + ...(workflow.successActions && { + successActions: resolveActions(workflow.successActions, 'success', ctx.doc.components, `${wPath}/successActions`), + }), + ...(workflow.failureActions && { + failureActions: resolveActions(workflow.failureActions, 'failure', ctx.doc.components, `${wPath}/failureActions`), + }), + ...(workflow.outputs && { outputs: parseOutputs(workflow.outputs, `${wPath}/outputs`) }), + }; + + const tool: McpOpenAPITool = { + name, + ...(workflow.summary !== undefined && { title: workflow.summary }), + description, + ...(allReadOnly && { + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + }), + inputSchema, + outputSchema, + // A workflow tool has no single HTTP shape — each step's mapper lives at + // metadata.workflow.steps[*].operation.mapper + mapper: [], + metadata: { + path: `arazzo:${workflow.workflowId}`, + method: 'post', + operationId: workflow.workflowId, + ...(workflow.summary !== undefined && { operationSummary: workflow.summary }), + ...(workflow.description !== undefined && { operationDescription: workflow.description }), + ...(security.length > 0 && { security }), + workflow: ir, + }, + }; + + if (options.emitTypeSignatures) { + tool.metadata.typescript = emitToolTypeScript(name, description, inputSchema, outputSchema, { + maxDepth: Math.max(1, options.maxSchemaDepth ?? 10), + }); + } + return tool; +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +/** + * Convert an Arazzo 1.0 workflow document (object or YAML/JSON string) into + * consolidated MCP tools — one per workflow, in document order. Source URLs + * are never fetched; supply every used source via `options.sources`. Throws + * `ArazzoError` (with a JSON-Pointer `path`) on malformed documents, + * unresolvable references, or cyclic workflows. + */ +export async function fromArazzo(document: ArazzoDocument | string, options: FromArazzoOptions): Promise { + const doc = parseArazzoInput(document); + validateDocument(doc); + + const sources = await prepareSources(doc, options); + const workflowIds = new Set(doc.workflows.map((w) => w.workflowId)); + + // dependsOn edges and nested workflowId-step edges must both be acyclic + const dependsEdges = new Map(); + const nestedEdges = new Map(); + doc.workflows.forEach((workflow, index) => { + const targets = workflow.dependsOn ?? []; + for (const target of targets) { + if (!workflowIds.has(target)) { + err(`Workflow "${workflow.workflowId}" dependsOn unknown workflow "${target}"`, `/workflows/${index}/dependsOn`); + } + } + dependsEdges.set(workflow.workflowId, targets); + nestedEdges.set( + workflow.workflowId, + workflow.steps.filter((s) => s.workflowId !== undefined).map((s) => s.workflowId as string), + ); + }); + checkCycles(dependsEdges, 'dependsOn chain'); + checkCycles(nestedEdges, 'workflow invocation'); + + const ctx: BuildContext = { + doc, + sources, + generateOptions: options.generateOptions ?? {}, + workflowIds, + operationCache: new Map(), + }; + + const tools: McpOpenAPITool[] = []; + const usedNames = new Set(); + for (let wIndex = 0; wIndex < doc.workflows.length; wIndex++) { + const workflow = doc.workflows[wIndex]; + const wPath = `/workflows/${wIndex}`; + const stepIRs: WorkflowStepIR[] = []; + for (let sIndex = 0; sIndex < workflow.steps.length; sIndex++) { + stepIRs.push(await buildStepIR(workflow.steps[sIndex], ctx, `${wPath}/steps/${sIndex}`)); + } + let tool = buildWorkflowTool(workflow, stepIRs, ctx, wPath); + // Distinct workflowIds can still normalize to the same name (`_flow` and + // `flow` both become `flow`) — dedupe with the generator's hash pattern. + if (usedNames.has(tool.name)) { + const maxLength = ctx.generateOptions.maxToolNameLength ?? 64; + let seed = workflow.workflowId; + let deduped = normalizeToolName(`${tool.name}_${fnv1aHex(seed)}`, maxLength, seed); + /* c8 ignore next 4 -- reachable only via an fnv1a hash collision between distinct ids */ + while (usedNames.has(deduped)) { + seed += '#'; + deduped = normalizeToolName(`${tool.name}_${fnv1aHex(seed)}`, maxLength, seed); + } + tool = { ...tool, name: deduped }; + } + usedNames.add(tool.name); + tools.push(tool); + } + return tools; +} diff --git a/src/errors.ts b/src/errors.ts index 89a65d1..0aeeef0 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -91,6 +91,19 @@ export class RequestBuildError extends OpenAPIToolError { } } +/** + * Error thrown when an Arazzo document is malformed or cannot be resolved + * against its sources. `path` is a JSON Pointer into the Arazzo document. + */ +export class ArazzoError extends OpenAPIToolError { + public readonly path?: string; + + constructor(message: string, context?: Record) { + super(message, context); + this.path = context?.['path']; + } +} + /** * Error thrown when a schema is invalid */ diff --git a/src/generator.ts b/src/generator.ts index e9669bc..5fecaaf 100644 --- a/src/generator.ts +++ b/src/generator.ts @@ -266,7 +266,7 @@ function trimUnderscores(value: string): string { * 32-bit FNV-1a hash rendered as 8 hex chars. Used for stable, content-derived * name suffixes (no Node `crypto` dependency, so V8-isolate runtimes work). */ -function fnv1aHex(input: string): string { +export function fnv1aHex(input: string): string { let hash = 0x811c9dc5; for (let i = 0; i < input.length; i++) { hash ^= input.charCodeAt(i); @@ -283,7 +283,7 @@ function fnv1aHex(input: string): string { * `fallbackSeed` names the operation (method + path) when sanitization leaves * nothing usable. */ -function normalizeToolName(raw: string, maxLength: number, fallbackSeed: string): string { +export function normalizeToolName(raw: string, maxLength: number, fallbackSeed: string): string { // Hash the RAW name, not the sanitized one: two raws differing only in // invalid characters must not collapse to the same truncation suffix. let hashSeed = raw; diff --git a/src/index.ts b/src/index.ts index d3a56f4..2bbec90 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,6 +20,41 @@ export type { DottedNamingOptions } from './naming-presets'; // Security elicitation descriptors export { deriveSecurityElicitations } from './elicitation'; export type { SecurityElicitation, ElicitationField } from './elicitation'; + +// Arazzo 1.0 workflows +export { fromArazzo } from './arazzo'; +export type { FromArazzoOptions, ArazzoGenerateOptions } from './arazzo'; +export { parseRuntimeExpression } from './arazzo-expressions'; +export type { + ArazzoDocument, + ArazzoInfo, + ArazzoSourceDescription, + ArazzoWorkflow, + ArazzoStep, + ArazzoParameter, + ArazzoRequestBody, + ArazzoPayloadReplacement, + ArazzoCriterion, + ArazzoCriterionType, + ArazzoSuccessAction, + ArazzoFailureAction, + ArazzoReusableObject, + ArazzoComponents, + WorkflowIR, + WorkflowStepIR, + OperationStepIR, + NestedWorkflowStepIR, + StepOperationIR, + StepParameterIR, + StepRequestBodyIR, + PayloadExpressionIR, + PayloadReplacementIR, + CriterionIR, + ActionIR, + RuntimeExpressionAST, + RuntimeExpressionType, + ExpressionValueIR, +} from './arazzo-types'; export { applyClientTarget, inlineLocalRefs, @@ -53,7 +88,7 @@ export { lintDocument } from './lint'; export type { LintResult, LintFinding, LintSeverity } from './lint'; // Error exports -export { OpenAPIToolError, LoadError, SsrfError, ParseError, ValidationError, GenerationError, SchemaError, RequestBuildError, OverlayError } from './errors'; +export { OpenAPIToolError, LoadError, SsrfError, ParseError, ValidationError, GenerationError, SchemaError, RequestBuildError, OverlayError, ArazzoError } from './errors'; // SSRF protection (shared by spec-URL loading and $ref resolution; usable by // consumers that fetch spec URLs themselves, e.g. pollers) diff --git a/src/types.ts b/src/types.ts index 054d56e..627a1b4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -567,6 +567,15 @@ export interface ToolMetadata { * transforms). The return type is the UNWRAPPED response type. */ typescript?: import('./type-signature').ToolTypeScriptInfo; + + /** + * Arazzo workflow IR — present only on tools produced by `fromArazzo()`. + * When set, `path`/`method` are non-HTTP placeholders + * (`arazzo:` / `'post'`); executors must drive requests from + * `workflow.steps[*].operation.mapper`, never from this tool's (empty) + * top-level mapper. + */ + workflow?: import('./arazzo-types').WorkflowIR; } /** From 16db2a0f0c6cfe343a65c0f9481edbc389fb2fc5 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Thu, 13 Aug 2026 02:40:39 +0300 Subject: [PATCH 07/10] fix: apply review findings on meta pollution stripping, icon scheme allowlist, and reserved namespace protection --- docs/modern-mcp-fields.md | 4 +-- src/__tests__/annotations.spec.ts | 40 ++++++++++++++++++++++++++ src/__tests__/generator.spec.ts | 28 ++++++++++++++++++ src/annotations.ts | 48 +++++++++++++++++++++++++++---- src/generator.ts | 34 ++++++++++++++++------ src/types.ts | 3 +- 6 files changed, 139 insertions(+), 18 deletions(-) diff --git a/docs/modern-mcp-fields.md b/docs/modern-mcp-fields.md index 02b4c71..fe1ac48 100644 --- a/docs/modern-mcp-fields.md +++ b/docs/modern-mcp-fields.md @@ -28,7 +28,7 @@ x-mcp: meta: { "com.example/billing-tier": "pro" } ``` -Extension-supplied `meta` is emitted **even when `emitMeta` is off**, merged key-by-key with `x-frontmcp.meta` winning over `x-mcp.meta`, and both winning over the generated entry. `x-speakeasy-mcp` does not participate (outside its published contract). +Extension-supplied `meta` is emitted **even when `emitMeta` is off**, merged key-by-key with `x-frontmcp.meta` winning over `x-mcp.meta`. Keys under the reserved `dev.agentfront.openapi/` namespace are ignored from extensions — consumers can trust the generated entry's operation coordinates. Pollution-gadget keys (`__proto__`, `constructor`, `prototype`) are stripped recursively. `x-speakeasy-mcp` does not participate (outside its published contract). ## Tool icons @@ -37,7 +37,7 @@ x-frontmcp: icons: [{ src: "https://example.com/invoice.png", mimeType: "image/png", sizes: ["48x48"] }] ``` -Icons (MCP spec 2025-11-25: `{ src, mimeType?, sizes? }`) come from `x-frontmcp.icons` or `x-mcp.icons` (later replaces wholesale; malformed entries are dropped). With `inheritDocumentIcons: true`, operations without extension icons fall back to the document's `info['x-logo']` (Redoc convention — a URL string or `{ url }` object) as a single icon on every tool. The fallback is off by default so one logo doesn't silently inflate every tool definition. +Icons (MCP spec 2025-11-25: `{ src, mimeType?, sizes? }`) come from `x-frontmcp.icons` or `x-mcp.icons` (later replaces wholesale; malformed entries are dropped, and `src` must be an `https:` or `data:` URI — anything else, `javascript:` included, is rejected). With `inheritDocumentIcons: true`, operations without extension icons fall back to the document's `info['x-logo']` (Redoc convention — a URL string or `{ url }` object, same scheme rule) as a single icon on every tool. The fallback is off by default so one logo doesn't silently inflate every tool definition. Neither `_meta` nor `icons` count toward `estimateToolTokens` — they are client chrome, not model-facing text. diff --git a/src/__tests__/annotations.spec.ts b/src/__tests__/annotations.spec.ts index 5c8c341..476e15d 100644 --- a/src/__tests__/annotations.spec.ts +++ b/src/__tests__/annotations.spec.ts @@ -241,6 +241,46 @@ describe('meta and icons extension extraction', () => { expect(overrides.icons).toEqual([{ src: 'https://e.com/ok.png' }]); }); + it('rejects icon sources outside the https/data scheme contract', () => { + const overrides = extractExtensionOverrides({ + 'x-mcp': { + icons: [ + { src: 'javascript:alert(1)' }, + { src: 'http://e.com/insecure.png' }, + { src: 'file:///etc/icon.png' }, + { src: 'DATA:image/png;base64,AAAA' }, + { src: 'https://e.com/ok.png' }, + ], + }, + } as any); + expect(overrides.icons).toEqual([{ src: 'DATA:image/png;base64,AAAA' }, { src: 'https://e.com/ok.png' }]); + }); + + it('copies icon sizes instead of aliasing the extension array', () => { + const sizes = ['48x48']; + const overrides = extractExtensionOverrides({ 'x-mcp': { icons: [{ src: 'https://e.com/i.png', sizes }] } } as any); + expect(overrides.icons![0].sizes).toEqual(['48x48']); + expect(overrides.icons![0].sizes).not.toBe(sizes); + }); + + it('strips pollution-gadget keys from meta recursively', () => { + const raw = JSON.parse('{"real": 1, "__proto__": {"polluted": true}, "constructor": {"x": 1}, "nested": {"prototype": 2, "keep": {"__proto__": 3, "ok": 4}}}'); + const overrides = extractExtensionOverrides({ 'x-mcp': { meta: raw } } as any); + const meta = overrides.meta!; + expect(Object.keys(meta)).toEqual(['real', 'nested']); + expect(Object.getOwnPropertyNames(meta)).not.toContain('__proto__'); + expect(meta['nested']).toEqual({ keep: { ok: 4 } }); + expect(Object.getOwnPropertyNames((meta['nested'] as any).keep)).not.toContain('__proto__'); + expect(({} as any).polluted).toBeUndefined(); + }); + + it('cleanses meta arrays and scalars in place', () => { + const overrides = extractExtensionOverrides({ + 'x-mcp': { meta: { list: [1, { '__proto__': 1, a: 2 }, 'x'] } }, + } as any); + expect(overrides.meta).toEqual({ list: [1, { a: 2 }, 'x'] }); + }); + it('returns undefined icons when nothing well-formed remains', () => { const overrides = extractExtensionOverrides({ 'x-mcp': { icons: [{ bad: true }] } } as any); expect(overrides.icons).toBeUndefined(); diff --git a/src/__tests__/generator.spec.ts b/src/__tests__/generator.spec.ts index 386fb42..2896629 100644 --- a/src/__tests__/generator.spec.ts +++ b/src/__tests__/generator.spec.ts @@ -4635,6 +4635,34 @@ describe('Modern-spec surface: _meta, icons, x-mcp-header', () => { expect(tool._meta).toEqual({ 'com.example/flag': true }); }); + it('protects the generated reserved namespace from extension spoofing', async () => { + const spec = baseSpec(); + spec.paths['/items'].get['x-frontmcp'] = { + meta: { 'dev.agentfront.openapi/operation': { path: '/FAKE', method: 'delete' }, 'com.example/ok': 1 }, + }; + const generator = await OpenAPIToolGenerator.fromJSON(spec, { validate: false }); + const withFlag = await generator.generateTool('/items', 'get', { emitMeta: true }); + expect((withFlag._meta as any)['dev.agentfront.openapi/operation'].path).toBe('/items'); + expect((withFlag._meta as any)['com.example/ok']).toBe(1); + + const withoutFlag = await generator.generateTool('/items', 'get'); + expect((withoutFlag._meta as any)['dev.agentfront.openapi/operation']).toBeUndefined(); + expect((withoutFlag._meta as any)['com.example/ok']).toBe(1); + + // Only reserved keys supplied and flag off -> no _meta at all + const onlyReserved = baseSpec(); + onlyReserved.paths['/items'].get['x-mcp'] = { meta: { 'dev.agentfront.openapi/operation': { path: '/FAKE' } } }; + const g2 = await OpenAPIToolGenerator.fromJSON(onlyReserved, { validate: false }); + expect((await g2.generateTool('/items', 'get'))._meta).toBeUndefined(); + }); + + it('rejects non-https document logos', async () => { + const spec = baseSpec(); + spec.info['x-logo'] = 'javascript:alert(1)'; + const generator = await OpenAPIToolGenerator.fromJSON(spec, { validate: false }); + expect((await generator.generateTool('/items', 'get', { inheritDocumentIcons: true })).icons).toBeUndefined(); + }); + it('merges extension meta over the generated entry with x-frontmcp winning', async () => { const spec = baseSpec(); spec.paths['/items'].get['x-mcp'] = { meta: { 'com.example/flag': true, 'com.example/level': 1 } }; diff --git a/src/annotations.ts b/src/annotations.ts index 3aab23d..772669e 100644 --- a/src/annotations.ts +++ b/src/annotations.ts @@ -97,16 +97,52 @@ function mergeOverrides(base: ExtensionToolOverrides, layer: ExtensionToolOverri }; } -/** Accept only a plain (non-array) object as a `_meta` contribution. */ +/** Rebuild a `_meta` contribution with pollution-gadget keys removed at every + * level — untrusted specs cross a trust boundary here, and JSON/YAML parsing + * creates `__proto__` as an own key that downstream deep-merges would follow. */ +function cleanseMeta(node: unknown, seen: Set): unknown { + if (!node || typeof node !== 'object') { + return node; + } + /* c8 ignore next 3 -- extension objects come from JSON/YAML and cannot be cyclic */ + if (seen.has(node)) { + return undefined; + } + seen.add(node); + try { + if (Array.isArray(node)) { + return node.map((item) => cleanseMeta(item, seen)); + } + const out: Record = {}; + for (const [key, value] of Object.entries(node)) { + // Literal comparisons (not a shared Set) so static analysis recognizes + // the prototype-pollution sanitizer + if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue; + out[key] = cleanseMeta(value, seen); + } + return out; + } finally { + seen.delete(node); + } +} + +/** Accept only a plain (non-array) object as a `_meta` contribution, + * rebuilding it with pollution keys stripped recursively. */ function sanitizeMeta(value: unknown): Record | undefined { if (value && typeof value === 'object' && !Array.isArray(value)) { - return value as Record; + return cleanseMeta(value, new Set()) as Record; } return undefined; } -/** Keep only well-formed icon entries: objects with a string `src`, copying - * just the MCP icon fields (`src`, `mimeType`, `sizes`). */ +/** Icon URI schemes matching the documented `ToolIcon.src` contract. */ +function isAllowedIconSrc(src: string): boolean { + const lower = src.toLowerCase(); + return lower.startsWith('https:') || lower.startsWith('data:'); +} + +/** Keep only well-formed icon entries: objects with an `https:`/`data:` + * string `src`, copying just the MCP icon fields (`src`, `mimeType`, `sizes`). */ function sanitizeIcons(value: unknown): ToolIcon[] | undefined { if (!Array.isArray(value)) { return undefined; @@ -115,13 +151,13 @@ function sanitizeIcons(value: unknown): ToolIcon[] | undefined { for (const entry of value) { if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue; const raw = entry as Record; - if (typeof raw['src'] !== 'string' || raw['src'] === '') continue; + if (typeof raw['src'] !== 'string' || !isAllowedIconSrc(raw['src'])) continue; const icon: ToolIcon = { src: raw['src'] }; if (typeof raw['mimeType'] === 'string') { icon.mimeType = raw['mimeType']; } if (Array.isArray(raw['sizes']) && raw['sizes'].every((s) => typeof s === 'string')) { - icon.sizes = raw['sizes'] as string[]; + icon.sizes = [...(raw['sizes'] as string[])]; } icons.push(icon); } diff --git a/src/generator.ts b/src/generator.ts index 5fecaaf..6782220 100644 --- a/src/generator.ts +++ b/src/generator.ts @@ -242,15 +242,20 @@ function iconsFromInfoLogo(info: unknown): ToolIcon[] | undefined { return undefined; } const logo = (info as Record)['x-logo']; - if (typeof logo === 'string' && logo !== '') { - return [{ src: logo }]; - } - if (logo && typeof logo === 'object' && !Array.isArray(logo)) { + let src: string | undefined; + if (typeof logo === 'string') { + src = logo; + } else if (logo && typeof logo === 'object' && !Array.isArray(logo)) { const url = (logo as Record)['url']; - if (typeof url === 'string' && url !== '') { - return [{ src: url }]; + if (typeof url === 'string') { + src = url; } } + // Same scheme contract as extension icons (https:/data: only) + const lower = src?.toLowerCase(); + if (lower !== undefined && (lower.startsWith('https:') || lower.startsWith('data:'))) { + return [{ src: src as string }]; + } return undefined; } @@ -940,22 +945,33 @@ export class OpenAPIToolGenerator { // MCP `_meta`: generated operation entry (opt-in) + extension pass-through (always) let toolMeta: Record | undefined; + if (overrides.meta) { + // Extension meta may not claim the generated reserved namespace — + // consumers must be able to trust `dev.agentfront.openapi/*` entries + toolMeta = {}; + for (const [key, value] of Object.entries(overrides.meta)) { + if (!key.startsWith('dev.agentfront.openapi/')) { + toolMeta[key] = value; + } + } + } if (options.emitMeta) { const info = document.info as Record | undefined; toolMeta = { + ...toolMeta, 'dev.agentfront.openapi/operation': { path: pathStr, method, ...(operation.operationId !== undefined && { operationId: operation.operationId }), - ...(operation.tags && { tags: operation.tags }), + ...(operation.tags && { tags: [...operation.tags] }), ...(operation.deprecated !== undefined && { deprecated: operation.deprecated }), ...(typeof info?.['title'] === 'string' && { specTitle: info['title'] }), ...(typeof info?.['version'] === 'string' && { specVersion: info['version'] }), }, }; } - if (overrides.meta) { - toolMeta = { ...toolMeta, ...overrides.meta }; + if (toolMeta && Object.keys(toolMeta).length === 0) { + toolMeta = undefined; } // Icons: extension-supplied wins; document logo only on explicit opt-in diff --git a/src/types.ts b/src/types.ts index 627a1b4..4b3e5e6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -325,7 +325,8 @@ export interface McpOpenAPITool { * Contains the `dev.agentfront.openapi/operation` entry when * `GenerateOptions.emitMeta` is set, plus any `meta` object supplied via * the `x-mcp` / `x-frontmcp` extensions (emitted even when the flag is - * off). + * off). Extension keys under `dev.agentfront.openapi/` are ignored, and + * pollution-gadget keys are stripped recursively. */ _meta?: Record; From f94a527e80be2140453efd2eac0df8dd7b3df57c Mon Sep 17 00:00:00 2001 From: David Antoon Date: Thu, 13 Aug 2026 03:01:49 +0300 Subject: [PATCH 08/10] fix: apply review findings on YAML normalization, message expressions, dotted names, cross-document dependsOn, and reusable re-validation --- docs/arazzo.md | 4 +- src/__tests__/arazzo.spec.ts | 252 ++++++++++++++++++++++++++++++++++- src/arazzo-expressions.ts | 24 +++- src/arazzo-types.ts | 1 + src/arazzo.ts | 208 ++++++++++++++++++++--------- 5 files changed, 414 insertions(+), 75 deletions(-) diff --git a/docs/arazzo.md b/docs/arazzo.md index fdd1f08..46b8424 100644 --- a/docs/arazzo.md +++ b/docs/arazzo.md @@ -42,9 +42,11 @@ Each operation step embeds the resolved operation's essentials — its `mapper` **Placeholders:** a workflow tool's `metadata.path` is `arazzo:` and `method` is `'post'` — never feed the workflow tool itself to `buildHttpRequest`; its top-level `mapper` is `[]` by design. Executors drive each step's `operation.mapper`. +**Treat the IR as immutable:** steps referencing the same operation share embedded schema/mapper structure in memory (JSON serialization is unaffected). Documents are normalized through a JSON round-trip on input — YAML anchors expand into distinct nodes, YAML-only scalars become their JSON forms, and cyclic or absurdly deep documents are rejected with `ArazzoError`. Cross-document `dependsOn` entries (`$sourceDescriptions..`) are accepted and carried verbatim; cross-document *step* invocations are not supported. One documented strictness deviation: `workflowId`/`stepId` must match `[A-Za-z0-9_-]+` (a SHOULD in the spec, enforced here so `$steps.` references stay parseable). + ## Runtime expressions -Every Arazzo runtime expression is parsed into a serializable AST (`{ type, raw, path, source?, name?, pointer? }`) — `$inputs.x`, `$steps.id.outputs.y`, `$response.body#/json/pointer`, `$request.header.Name`, `$statusCode`, `$url`, `$method`, `$workflows.*`, `$sourceDescriptions.*`, `$components.*`. Strings with embedded `{$...}` become templates; strings whose `$` prefix matches no known root (like `"$50"`) stay literals. The parser is exported standalone: +Every Arazzo runtime expression is parsed into a serializable AST (`{ type, raw, path, source?, name?, pointer? }`) — `$inputs.x`, `$steps.id.outputs.y`, `$response.body#/json/pointer`, `$request.header.Name`, `$message.body`, `$statusCode`, `$url`, `$method`, `$workflows.*`, `$sourceDescriptions.*`, `$components.*`. Strings with embedded `{$...}` become templates; strings whose `$` prefix matches no known root (like `"$50"` or `"$request-id"`) stay literals. The parser is exported standalone: ```typescript import { parseRuntimeExpression } from 'mcp-from-openapi'; diff --git a/src/__tests__/arazzo.spec.ts b/src/__tests__/arazzo.spec.ts index 4a97ac1..a1044c3 100644 --- a/src/__tests__/arazzo.spec.ts +++ b/src/__tests__/arazzo.spec.ts @@ -937,7 +937,7 @@ describe('fromArazzo remaining coverage', () => { ], { components: { parameters: { broken: { in: 'query', value: 1 } as any } } }, ); - await expectArazzoError(fromArazzo(doc, { sources: sources() }), /Resolved parameter requires "name" and "value"/); + await expectArazzoError(fromArazzo(doc, { sources: sources() }), /Parameter requires a non-empty string "name"/); }); it('degrades direct workflow-level $response.body outputs to unknown', async () => { @@ -1123,3 +1123,253 @@ describe('fromArazzo branch completeness', () => { expect(tools[1].description).toBe('Only description.'); }); }); + +describe('fromArazzo review-fix regressions', () => { + it('expands YAML anchors so payload expressions cover every occurrence', async () => { + const yamlDoc = [ + 'arazzo: 1.0.0', + 'info: { title: F, version: "1" }', + 'sourceDescriptions: [{ name: pets, url: "https://x" }]', + 'workflows:', + ' - workflowId: w', + ' steps:', + ' - stepId: s', + ' operationId: createPet', + ' requestBody:', + ' payload:', + ' a: &shared { v: $inputs.foo }', + ' b: *shared', + ].join('\n'); + const [tool] = await fromArazzo(yamlDoc, { sources: { pets: petstoreDoc() } }); + const step = tool.metadata.workflow!.steps[0] as OperationStepIR; + expect(step.requestBody?.payloadExpressions?.map((e) => e.pointer).sort()).toEqual(['/a/v', '/b/v']); + expect(JSON.stringify(tool)).toBeDefined(); + }); + + it('rejects cyclic YAML documents with an ArazzoError', async () => { + const cyclic = ['arazzo: 1.0.0', 'x: &c', ' self: *c'].join('\n'); + await expectArazzoError(fromArazzo(cyclic, { sources: {} }), /JSON-serializable/); + const loop: any = { arazzo: '1.0.0' }; + loop.self = loop; + await expectArazzoError(fromArazzo(loop, { sources: {} }), /JSON-serializable/); + }); + + it('supports the $message expression root', async () => { + expect(parseRuntimeExpression('$message.body#/x')).toEqual({ + type: 'message', + raw: '$message.body#/x', + path: [], + source: 'body', + pointer: '/x', + }); + expect(parseRuntimeExpression('$message.header.X-Id').name).toBe('X-Id'); + const doc = arazzoWith([ + simpleWorkflow({ + outputs: undefined, + steps: [ + { + stepId: 's', + operationId: 'getPet', + parameters: [{ name: 'petId', in: 'path', value: 'x' }], + successCriteria: [{ condition: 'ok', context: '$message.body', type: 'regex' }], + }, + ], + }), + ]); + const [tool] = await fromArazzo(doc, { sources: sources() }); + const step = tool.metadata.workflow!.steps[0] as OperationStepIR; + expect(step.successCriteria![0].context?.type).toBe('message'); + }); + + it('keeps unknown-root dollar strings literal at exact boundaries', () => { + expect(parseExpressionValue('$request-id')).toEqual({ kind: 'literal', value: '$request-id' }); + expect(parseExpressionValue('$urlx')).toEqual({ kind: 'literal', value: '$urlx' }); + expect(parseExpressionValue('$inputsfoo')).toEqual({ kind: 'literal', value: '$inputsfoo' }); + }); + + it('resolves dotted component names', async () => { + const doc = arazzoWith( + [ + simpleWorkflow({ + outputs: undefined, + steps: [ + { stepId: 's', operationId: 'getPet', parameters: [{ reference: '$components.parameters.my.org.petId' }] }, + ], + }), + ], + { components: { parameters: { 'my.org.petId': { name: 'petId', in: 'path', value: 'x' } } } }, + ); + const [tool] = await fromArazzo(doc, { sources: sources() }); + const step = tool.metadata.workflow!.steps[0] as OperationStepIR; + expect(step.parameters![0].name).toBe('petId'); + }); + + it('accepts cross-document dependsOn expressions and rejects malformed ones', async () => { + const doc = arazzoWith([ + simpleWorkflow({ dependsOn: ['$sourceDescriptions.flows.otherWf'], outputs: undefined }), + ]); + doc.sourceDescriptions.push({ name: 'flows', url: 'https://x/flows.yaml', type: 'arazzo' }); + const [tool] = await fromArazzo(doc, { sources: sources() }); + expect(tool.metadata.workflow!.dependsOn).toEqual(['$sourceDescriptions.flows.otherWf']); + + const badSource = arazzoWith([ + simpleWorkflow({ dependsOn: ['$sourceDescriptions.ghost.wf'], outputs: undefined }), + ]); + await expectArazzoError(fromArazzo(badSource, { sources: sources() }), /must reference a declared source/); + + const notArray = arazzoWith([simpleWorkflow({ dependsOn: 'other', outputs: undefined })]); + await expectArazzoError(fromArazzo(notArray, { sources: sources() }), /must be an array/); + + const badEntry = arazzoWith([simpleWorkflow({ dependsOn: [42], outputs: undefined })]); + await expectArazzoError(fromArazzo(badEntry, { sources: sources() }), /entries must be strings/); + }); + + it('rejects non-string criterion contexts as ArazzoError, not a crash', async () => { + const doc = arazzoWith([ + simpleWorkflow({ + outputs: undefined, + steps: [ + { + stepId: 's', + operationId: 'getPet', + parameters: [{ name: 'petId', in: 'path', value: 'x' }], + successCriteria: [{ condition: 'x', context: 123, type: 'regex' }], + }, + ], + }), + ]); + await expectArazzoError(fromArazzo(doc, { sources: sources() }), /"context" must be a runtime expression string/); + }); + + it('gives cross-document workflow steps the nested-unsupported error and rejects their request bodies', async () => { + const crossDoc = arazzoWith([ + simpleWorkflow({ outputs: undefined, steps: [{ stepId: 's', workflowId: '$sourceDescriptions.flows.wf' }] }), + ]); + crossDoc.sourceDescriptions.push({ name: 'flows', url: 'https://x/f.yaml', type: 'arazzo' }); + await expectArazzoError(fromArazzo(crossDoc, { sources: sources() }), /nested Arazzo sources are not supported/); + + const bodyOnWorkflow = arazzoWith([ + simpleWorkflow({ workflowId: 'a', outputs: undefined }), + simpleWorkflow({ + workflowId: 'b', + outputs: undefined, + steps: [{ stepId: 's', workflowId: 'a', requestBody: { payload: {} } }], + }), + ]); + await expectArazzoError(fromArazzo(bodyOnWorkflow, { sources: sources() }), /must not declare a requestBody/); + }); + + it('re-validates reusable-sourced actions and parameters fully', async () => { + const smuggledGoto = arazzoWith( + [ + simpleWorkflow({ + outputs: undefined, + steps: [{ stepId: 's', operationId: 'getPet', parameters: [{ name: 'petId', in: 'path', value: 'x' }], onSuccess: [{ reference: '$components.successActions.bad' }] }], + }), + ], + { components: { successActions: { bad: { name: 'g', type: 'goto' } } } }, + ); + await expectArazzoError(fromArazzo(smuggledGoto, { sources: sources() }), /exactly one of "workflowId" or "stepId"/); + + const dupViaRefs = arazzoWith( + [ + simpleWorkflow({ + outputs: undefined, + steps: [ + { + stepId: 's', + operationId: 'getPet', + parameters: [ + { reference: '$components.parameters.petParam' }, + { reference: '$components.parameters.petParam' }, + ], + }, + ], + }), + ], + { components: { parameters: { petParam: { name: 'petId', in: 'path', value: 'x' } } } }, + ); + await expectArazzoError(fromArazzo(dupViaRefs, { sources: sources() }), /Duplicate parameter "petId"/); + + const noIn = arazzoWith( + [ + simpleWorkflow({ + outputs: undefined, + steps: [{ stepId: 's', operationId: 'getPet', parameters: [{ reference: '$components.parameters.inless' }] }], + }), + ], + { components: { parameters: { inless: { name: 'petId', value: 'x' } as any } } }, + ); + await expectArazzoError(fromArazzo(noIn, { sources: sources() }), /requires "in"/); + }); + + it('never aliases the tool output schema with the embedded step schemas', async () => { + const [tool] = await fromArazzo(arazzoWith([simpleWorkflow()]), { sources: sources() }); + const outputPet = (tool.outputSchema as any).properties.pet; + const step = tool.metadata.workflow!.steps[0] as OperationStepIR; + const embedded: any = step.operation.outputSchema; + expect(outputPet.properties).not.toBe(embedded.properties); + outputPet.properties.id.type = 'MUTATED'; + expect(embedded.properties.id.type).toBe('string'); + }); + + it('reports per-index paths for action criteria failures', async () => { + const doc = arazzoWith([ + simpleWorkflow({ + outputs: undefined, + steps: [ + { + stepId: 's', + operationId: 'getPet', + parameters: [{ name: 'petId', in: 'path', value: 'x' }], + onFailure: [{ name: 'r', type: 'end', criteria: [{ condition: 'ok' }, { condition: '' }] }], + }, + ], + }), + ]); + await expectArazzoError( + fromArazzo(doc, { sources: sources() }), + /non-empty string "condition"/, + '/workflows/0/steps/0/onFailure/0/criteria/1', + ); + }); +}); + +describe('fromArazzo location-less parameters through resolution', () => { + it('resolves in-less workflow and nested-step parameters and dedupes them', async () => { + const doc = arazzoWith( + [ + simpleWorkflow({ workflowId: 'inner', outputs: undefined }), + simpleWorkflow({ + workflowId: 'outer', + outputs: undefined, + parameters: [{ name: 'shared', value: 1 }], + steps: [{ stepId: 'call', workflowId: 'inner', parameters: [{ name: 'input', value: '$inputs.petId' }] }], + }), + ], + ); + const tools = await fromArazzo(doc, { sources: sources() }); + expect(tools[1].metadata.workflow!.parameters).toEqual([{ name: 'shared', value: { kind: 'literal', value: 1 } }]); + const step = tools[1].metadata.workflow!.steps[0] as NestedWorkflowStepIR; + expect(step.parameters![0].in).toBeUndefined(); + + const dupInless = arazzoWith( + [ + simpleWorkflow({ workflowId: 'inner', outputs: undefined }), + simpleWorkflow({ + workflowId: 'outer', + outputs: undefined, + steps: [ + { + stepId: 'call', + workflowId: 'inner', + parameters: [{ reference: '$components.parameters.p' }, { reference: '$components.parameters.p' }], + }, + ], + }), + ], + { components: { parameters: { p: { name: 'dup', value: 1 } as any } } }, + ); + await expectArazzoError(fromArazzo(dupInless, { sources: sources() }), /Duplicate parameter "dup"/); + }); +}); diff --git a/src/arazzo-expressions.ts b/src/arazzo-expressions.ts index a751a1b..a8385e4 100644 --- a/src/arazzo-expressions.ts +++ b/src/arazzo-expressions.ts @@ -2,7 +2,7 @@ * Arazzo runtime-expression parsing. * * Hand-rolled tokenizer over the Arazzo 1.0 runtime-expression grammar - * (`$url`, `$method`, `$statusCode`, `$request.…`, `$response.…`, `$inputs.…`, + * (`$url`, `$method`, `$statusCode`, `$request.…`, `$response.…`, `$message.…`, `$inputs.…`, * `$outputs.…`, `$steps.…`, `$workflows.…`, `$sourceDescriptions.…`, * `$components.…`), producing a small serializable AST. Expressions are * parsed, never evaluated. @@ -25,8 +25,12 @@ const DOTTED_ROOTS: Record = { $components: 'components', }; -/** All roots the grammar knows, used to decide expression-vs-literal. */ -const KNOWN_ROOT = /^\$(url|method|statusCode|request|response|inputs|outputs|steps|workflows|sourceDescriptions|components)\b/; +/** + * All roots the grammar knows, with the exact boundary each requires — + * used to decide expression-vs-literal. `$request-id` matches no root + * (the source roots require a literal dot) and stays a literal. + */ +const KNOWN_ROOT = /^\$(?:(?:url|method|statusCode)$|(?:request|response|message)\.|(?:inputs|outputs|steps|workflows|sourceDescriptions|components)\.)/; function fail(message: string, docPath: string, expression: string): never { throw new ArazzoError(message, { path: docPath, expression }); @@ -35,7 +39,12 @@ function fail(message: string, docPath: string, expression: string): never { /** RFC 7230 token characters (header names). */ const TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; -function parseSourceRef(prefix: 'request' | 'response', rest: string, raw: string, docPath: string): RuntimeExpressionAST { +function parseSourceRef( + prefix: 'request' | 'response' | 'message', + rest: string, + raw: string, + docPath: string, +): RuntimeExpressionAST { if (rest.startsWith('header.')) { const name = rest.slice('header.'.length); if (name === '' || !TOKEN.test(name)) { @@ -80,9 +89,10 @@ export function parseRuntimeExpression(raw: string, docPath = ''): RuntimeExpres } } - if (raw.startsWith('$request.') || raw.startsWith('$response.')) { - const prefix = raw.startsWith('$request.') ? 'request' : 'response'; - return parseSourceRef(prefix, raw.slice(prefix.length + 2), raw, docPath); + for (const prefix of ['request', 'response', 'message'] as const) { + if (raw.startsWith(`$${prefix}.`)) { + return parseSourceRef(prefix, raw.slice(prefix.length + 2), raw, docPath); + } } const dot = raw.indexOf('.'); diff --git a/src/arazzo-types.ts b/src/arazzo-types.ts index 955d95c..47b3ae9 100644 --- a/src/arazzo-types.ts +++ b/src/arazzo-types.ts @@ -158,6 +158,7 @@ export type RuntimeExpressionType = | 'statusCode' | 'request' | 'response' + | 'message' | 'inputs' | 'outputs' | 'steps' diff --git a/src/arazzo.ts b/src/arazzo.ts index b2fb693..ed05ca8 100644 --- a/src/arazzo.ts +++ b/src/arazzo.ts @@ -108,6 +108,25 @@ function err(message: string, path: string, extra?: Record): ne // Parsing & validation // --------------------------------------------------------------------------- +/** + * Normalize to plain, alias-free JSON data. The round-trip expands YAML + * anchors into distinct nodes (so payload expression pointers see every + * occurrence), converts YAML-only scalars (dates, binary) to their JSON + * forms, and rejects cyclic or absurdly deep structures with an ArazzoError + * instead of letting later passes crash. + */ +function toPlainJson(value: unknown): unknown { + try { + return JSON.parse(JSON.stringify(value)); + } catch (error: unknown) { + /* c8 ignore next -- JSON.stringify only throws Error instances */ + const message = error instanceof Error ? error.message : String(error); + throw new ArazzoError(`Arazzo document must be JSON-serializable (acyclic, bounded depth): ${message}`, { + path: '', + }); + } +} + function parseArazzoInput(input: ArazzoDocument | string): ArazzoDocument { if (typeof input === 'string') { let parsed: unknown; @@ -121,13 +140,13 @@ function parseArazzoInput(input: ArazzoDocument | string): ArazzoDocument { if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { err('Arazzo document must be an object', ''); } - return parsed as ArazzoDocument; + return toPlainJson(parsed) as ArazzoDocument; } if (!input || typeof input !== 'object' || Array.isArray(input)) { err('Arazzo document must be an object', ''); } // Never mutate caller input (components inlining edits the tree) - return JSON.parse(JSON.stringify(input)) as ArazzoDocument; + return toPlainJson(input) as ArazzoDocument; } function validateCriteria(criteria: unknown, path: string): void { @@ -160,6 +179,9 @@ function validateCriteria(criteria: unknown, path: string): void { err('Criterion "type" must be a string or a Criterion Expression Type Object', cPath); } } + if (criterion.context !== undefined && typeof criterion.context !== 'string') { + err('Criterion "context" must be a runtime expression string', cPath); + } if (effectiveType !== undefined && effectiveType !== 'simple' && criterion.context === undefined) { err(`Criterion of type "${effectiveType}" requires a "context" expression`, cPath); } @@ -181,33 +203,38 @@ function validateActions( err('Action must be an object', aPath); } if ('reference' in action) { - return; // Reusable Object — resolved and re-validated later - } - const act = action as ArazzoSuccessAction & ArazzoFailureAction; - if (typeof act.name !== 'string' || act.name === '') { - err('Action requires a non-empty string "name"', aPath); - } - const allowed = kind === 'success' ? ['end', 'goto'] : ['end', 'retry', 'goto']; - if (!allowed.includes(act.type)) { - err(`Invalid ${kind}-action type "${String(act.type)}" (allowed: ${allowed.join(', ')})`, aPath); - } - const targets = [act.workflowId, act.stepId].filter((t) => t !== undefined).length; - if (act.type === 'goto' && targets !== 1) { - err('A "goto" action requires exactly one of "workflowId" or "stepId"', aPath); - } - if (act.type === 'end' && targets !== 0) { - err('An "end" action must not specify "workflowId" or "stepId"', aPath); - } - if (act.retryAfter !== undefined && (typeof act.retryAfter !== 'number' || act.retryAfter < 0)) { - err('"retryAfter" must be a non-negative number', aPath); + return; // Reusable Object — resolved and re-validated in resolveActions } - if (act.retryLimit !== undefined && (typeof act.retryLimit !== 'number' || !Number.isInteger(act.retryLimit) || act.retryLimit < 0)) { - err('"retryLimit" must be a non-negative integer', aPath); - } - validateCriteria(act.criteria, `${aPath}/criteria`); + validateActionObject(action, kind, aPath); }); } +/** Validation for one concrete action — also run on resolved reusables. */ +function validateActionObject(action: ArazzoSuccessAction | ArazzoFailureAction, kind: 'success' | 'failure', aPath: string): void { + const act = action as ArazzoSuccessAction & ArazzoFailureAction; + if (typeof act.name !== 'string' || act.name === '') { + err('Action requires a non-empty string "name"', aPath); + } + const allowed = kind === 'success' ? ['end', 'goto'] : ['end', 'retry', 'goto']; + if (!allowed.includes(act.type)) { + err(`Invalid ${kind}-action type "${String(act.type)}" (allowed: ${allowed.join(', ')})`, aPath); + } + const targets = [act.workflowId, act.stepId].filter((t) => t !== undefined).length; + if (act.type === 'goto' && targets !== 1) { + err('A "goto" action requires exactly one of "workflowId" or "stepId"', aPath); + } + if (act.type === 'end' && targets !== 0) { + err('An "end" action must not specify "workflowId" or "stepId"', aPath); + } + if (act.retryAfter !== undefined && (typeof act.retryAfter !== 'number' || act.retryAfter < 0)) { + err('"retryAfter" must be a non-negative number', aPath); + } + if (act.retryLimit !== undefined && (typeof act.retryLimit !== 'number' || !Number.isInteger(act.retryLimit) || act.retryLimit < 0)) { + err('"retryLimit" must be a non-negative integer', aPath); + } + validateCriteria(act.criteria, `${aPath}/criteria`); +} + function validateParameters( parameters: Array | undefined, requireIn: boolean | undefined, @@ -224,26 +251,11 @@ function validateParameters( err('Parameter must be an object', pPath); } if ('reference' in parameter) { - return; // Reusable Object — resolved and re-validated later + return; // Reusable Object — resolved and re-validated in resolveParameters } + validateParameterObject(parameter, requireIn, pPath); const param = parameter as ArazzoParameter; - if (typeof param.name !== 'string' || param.name === '') { - err('Parameter requires a non-empty string "name"', pPath); - } - const paramName = param.name; - if (!('value' in param)) { - err(`Parameter "${paramName}" requires a "value"`, pPath); - } - if (param.in !== undefined && !PARAMETER_LOCATIONS.includes(param.in)) { - err(`Invalid parameter location "${String(param.in)}"`, pPath); - } - if (requireIn === true && param.in === undefined) { - err(`Parameter "${param.name}" on an operation step requires "in"`, pPath); - } - if (requireIn === false && param.in !== undefined) { - err(`Parameter "${param.name}" on a workflowId step must not specify "in"`, pPath); - } - const key = `${param.name}${param.in ?? ''}`; + const key = `${param.name} ${param.in ?? ''}`; if (seen.has(key)) { err(`Duplicate parameter "${param.name}"${param.in ? ` (in: ${param.in})` : ''}`, pPath); } @@ -251,6 +263,26 @@ function validateParameters( }); } +/** Validation for one concrete parameter — also run on resolved reusables. */ +function validateParameterObject(param: ArazzoParameter, requireIn: boolean | undefined, pPath: string): void { + if (typeof param.name !== 'string' || param.name === '') { + err('Parameter requires a non-empty string "name"', pPath); + } + const paramName = param.name; + if (!('value' in param)) { + err(`Parameter "${paramName}" requires a "value"`, pPath); + } + if (param.in !== undefined && !PARAMETER_LOCATIONS.includes(param.in)) { + err(`Invalid parameter location "${String(param.in)}"`, pPath); + } + if (requireIn === true && param.in === undefined) { + err(`Parameter "${param.name}" on an operation step requires "in"`, pPath); + } + if (requireIn === false && param.in !== undefined) { + err(`Parameter "${param.name}" on a workflowId step must not specify "in"`, pPath); + } +} + function validateOutputs(outputs: unknown, path: string): void { if (outputs === undefined) return; if (!outputs || typeof outputs !== 'object' || Array.isArray(outputs)) { @@ -358,10 +390,11 @@ function resolveReusable( err('Reusable Object "reference" must be a string', path); } const ast = parseRuntimeExpression(reusable.reference, path); - if (ast.type !== 'components' || ast.path.length !== 2 || ast.path[0] !== expectedGroup) { + if (ast.type !== 'components' || ast.path.length < 2 || ast.path[0] !== expectedGroup) { err(`Reference "${reusable.reference}" must point at $components.${expectedGroup}.`, path); } - const name = ast.path[1]; + // Component names may legally contain dots (`my.org.petId`) — re-join + const name = ast.path.slice(1).join('.'); const target = components?.[expectedGroup]?.[name]; if (!target) { err(`Unknown reference "$components.${expectedGroup}.${name}"`, path); @@ -622,7 +655,7 @@ function toActionIR( ...(action.stepId !== undefined && { stepId: action.stepId }), ...(failure.retryAfter !== undefined && { retryAfter: failure.retryAfter }), ...(failure.retryLimit !== undefined && { retryLimit: failure.retryLimit }), - ...(action.criteria && { criteria: action.criteria.map((c) => toCriterionIR(c, path)) }), + ...(action.criteria && { criteria: action.criteria.map((c, i) => toCriterionIR(c, `${path}/criteria/${i}`)) }), }; } @@ -633,32 +666,34 @@ function resolveActions( path: string, ): ActionIR[] { const group = kind === 'success' ? 'successActions' : 'failureActions'; - const resolved = actions.map((action, index) => { + return actions.map((action, index) => { const aPath = `${path}/${index}`; const concrete = resolveReusable(action, components, group, aPath); + // Reusable-sourced actions bypass the first validation pass — re-run the + // full object validation so components can't smuggle in malformed actions + validateActionObject(concrete, kind, aPath); return toActionIR(concrete, kind, aPath); }); - // Reusable-sourced actions bypass the first validation pass - resolved.forEach((action, index) => { - const allowed = kind === 'success' ? ['end', 'goto'] : ['end', 'retry', 'goto']; - if (!allowed.includes(action.type)) { - err(`Invalid ${kind}-action type "${action.type}" (allowed: ${allowed.join(', ')})`, `${path}/${index}`); - } - }); - return resolved; } function resolveParameters( parameters: Array, components: ArazzoComponents | undefined, + requireIn: boolean | undefined, path: string, ): StepParameterIR[] { + const seen = new Set(); return parameters.map((parameter, index) => { const pPath = `${path}/${index}`; const concrete = resolveReusable(parameter, components, 'parameters', pPath); - if (typeof concrete.name !== 'string' || concrete.name === '' || !('value' in concrete)) { - err('Resolved parameter requires "name" and "value"', pPath); + // Reusable-sourced parameters bypass the first validation pass — re-run + // the object validation and the duplicate check on the RESOLVED list + validateParameterObject(concrete, requireIn, pPath); + const key = `${concrete.name} ${concrete.in ?? ''}`; + if (seen.has(key)) { + err(`Duplicate parameter "${concrete.name}"${concrete.in ? ` (in: ${concrete.in})` : ''}`, pPath); } + seen.add(key); return { name: concrete.name, ...(concrete.in !== undefined && { in: concrete.in }), @@ -681,7 +716,7 @@ async function resolveStepOperation( ctx: BuildContext, docPath: string, ): Promise { - const key = `${ref.source}${ref.method}${ref.path}`; + const key = `${ref.source} ${ref.method} ${ref.path}`; let cached = ctx.operationCache.get(key); if (!cached) { const generator = requireGenerator(ctx.sources, ref.source, docPath); @@ -703,7 +738,14 @@ async function buildStepIR(step: ArazzoStep, ctx: BuildContext, path: string): P const base = { stepId: step.stepId, ...(step.description !== undefined && { description: step.description }), - ...(step.parameters && { parameters: resolveParameters(step.parameters, components, `${path}/parameters`) }), + ...(step.parameters && { + parameters: resolveParameters( + step.parameters, + components, + step.workflowId !== undefined ? false : true, + `${path}/parameters`, + ), + }), ...(step.successCriteria && { successCriteria: step.successCriteria.map((c, i) => toCriterionIR(c, `${path}/successCriteria/${i}`)), }), @@ -713,6 +755,13 @@ async function buildStepIR(step: ArazzoStep, ctx: BuildContext, path: string): P }; if (step.workflowId !== undefined) { + if (step.requestBody !== undefined) { + err(`Step "${step.stepId}" invokes a workflow and must not declare a requestBody`, `${path}/requestBody`); + } + if (step.workflowId.startsWith('$')) { + // $sourceDescriptions.. targets another Arazzo doc + err(`Step "${step.stepId}" invokes a workflow in another Arazzo document — nested Arazzo sources are not supported`, path); + } if (!ctx.workflowIds.has(step.workflowId)) { err(`Step "${step.stepId}" references unknown workflow "${step.workflowId}"`, path); } @@ -842,7 +891,8 @@ function deriveOutputSchema( } if (ast.type === 'inputs') { const properties = isRecord(inputSchema) ? inputSchema['properties'] : undefined; - const target = isRecord(properties) ? properties[ast.path[0]] : undefined; + // Input names may legally contain dots — the whole remainder is the name + const target = isRecord(properties) ? properties[ast.path.join('.')] : undefined; return isRecord(target) ? (target as JsonSchema) : {}; } if (ast.type === 'steps' && ast.path.length >= 3 && ast.path[1] === 'outputs') { @@ -870,7 +920,10 @@ function deriveOutputsSchema( const properties: Record = {}; for (const [name, ast] of Object.entries(outputs)) { const derived = deriveOutputSchema(ast, stepMap, inputSchema, 0); - properties[name] = { ...derived, description: `Arazzo output: ${ast.raw}` }; + // Deep-copy so the tool's output schema never aliases the embedded step + // schemas inside the IR (mutating one view must not corrupt the other) + const copied = JSON.parse(JSON.stringify(derived)) as JsonSchema; + properties[name] = { ...copied, description: `Arazzo output: ${ast.raw}` }; } // No `required`: outputs exist only after successful execution return { type: 'object', properties }; @@ -967,7 +1020,7 @@ function buildWorkflowTool( ...(rawInputSchema !== undefined && { inputSchema: rawInputSchema }), ...(workflow.dependsOn && { dependsOn: workflow.dependsOn }), ...(workflow.parameters && { - parameters: resolveParameters(workflow.parameters, ctx.doc.components, `${wPath}/parameters`), + parameters: resolveParameters(workflow.parameters, ctx.doc.components, undefined, `${wPath}/parameters`), }), steps: stepIRs, ...(workflow.successActions && { @@ -1031,17 +1084,40 @@ export async function fromArazzo(document: ArazzoDocument | string, options: Fro // dependsOn edges and nested workflowId-step edges must both be acyclic const dependsEdges = new Map(); const nestedEdges = new Map(); + const declaredSources = new Set(doc.sourceDescriptions.map((s) => s.name)); doc.workflows.forEach((workflow, index) => { - const targets = workflow.dependsOn ?? []; - for (const target of targets) { + if (workflow.dependsOn !== undefined && !Array.isArray(workflow.dependsOn)) { + err(`Workflow "${workflow.workflowId}" dependsOn must be an array of workflowIds`, `/workflows/${index}/dependsOn`); + } + const localTargets: string[] = []; + for (const target of workflow.dependsOn ?? []) { + if (typeof target !== 'string') { + err(`Workflow "${workflow.workflowId}" dependsOn entries must be strings`, `/workflows/${index}/dependsOn`); + } + if (target.startsWith('$')) { + // Cross-document form (spec-mandated for external workflows): + // $sourceDescriptions.. — carried verbatim in the + // IR, outside the local cycle graph + const ast = parseRuntimeExpression(target, `/workflows/${index}/dependsOn`); + if (ast.type !== 'sourceDescriptions' || ast.path.length < 2 || !declaredSources.has(ast.path[0])) { + err( + `Workflow "${workflow.workflowId}" dependsOn "${target}" must reference a declared source ($sourceDescriptions..)`, + `/workflows/${index}/dependsOn`, + ); + } + continue; + } if (!workflowIds.has(target)) { err(`Workflow "${workflow.workflowId}" dependsOn unknown workflow "${target}"`, `/workflows/${index}/dependsOn`); } + localTargets.push(target); } - dependsEdges.set(workflow.workflowId, targets); + dependsEdges.set(workflow.workflowId, localTargets); nestedEdges.set( workflow.workflowId, - workflow.steps.filter((s) => s.workflowId !== undefined).map((s) => s.workflowId as string), + workflow.steps + .filter((s) => s.workflowId !== undefined && !s.workflowId.startsWith('$')) + .map((s) => s.workflowId as string), ); }); checkCycles(dependsEdges, 'dependsOn chain'); From 2470c652e5d0f8489ddfaeca251aafa7b5e623a1 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Thu, 13 Aug 2026 03:27:16 +0300 Subject: [PATCH 09/10] fix: address review findings on pollution test fidelity, component lookup guards, composed input roots, and doc sync --- docs/annotations.md | 4 +-- docs/naming-strategies.md | 4 +-- docs/x-frontmcp.md | 2 ++ src/__tests__/annotations.spec.ts | 8 +++--- src/__tests__/arazzo.spec.ts | 40 ++++++++++++++++++++++++++++ src/__tests__/type-signature.spec.ts | 17 ++++++++---- src/annotations.ts | 8 ++++-- src/arazzo.ts | 15 +++++++++-- src/generator.ts | 7 +++-- src/type-signature.ts | 12 +++++++-- 10 files changed, 95 insertions(+), 22 deletions(-) diff --git a/docs/annotations.md b/docs/annotations.md index 47950dc..d4cf06c 100644 --- a/docs/annotations.md +++ b/docs/annotations.md @@ -78,7 +78,7 @@ x-mcp: ### 3. `x-frontmcp` (canonical, highest precedence) -Only its `annotations` block participates here (including `annotations.title`); the rest of the extension (cache, codecall, tags, examples, ...) flows through `tool.metadata.frontmcp` untouched — see [x-frontmcp Extension](./x-frontmcp.md). +Its `annotations` block (including `annotations.title`, which also becomes the tool title), `meta`, and `icons` map onto tool overrides; the rest of the extension (cache, codecall, tags, examples, ...) flows through `tool.metadata.frontmcp` untouched — see [x-frontmcp Extension](./x-frontmcp.md). ```yaml x-frontmcp: @@ -117,7 +117,7 @@ inferAnnotationsFromMethod('delete'); // { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false } extractExtensionOverrides(operation); -// { disabled?, name?, title?, description?, annotations? } +// { disabled?, name?, title?, description?, annotations?, meta?, icons? } ``` --- diff --git a/docs/naming-strategies.md b/docs/naming-strategies.md index 7dd4795..7226516 100644 --- a/docs/naming-strategies.md +++ b/docs/naming-strategies.md @@ -37,8 +37,8 @@ Only conflicted names are renamed. Unique parameter names are kept as-is. ```typescript interface NamingStrategy { - conflictResolver: (paramName: string, location: ParameterLocation, index: number) => string; - toolNameGenerator?: (path: string, method: HTTPMethod, operationId?: string) => string; + conflictResolver?: (paramName: string, location: ParameterLocation, index: number) => string; + toolNameGenerator?: (path: string, method: HTTPMethod, operationId?: string, operation?: OperationObject) => string; } ``` diff --git a/docs/x-frontmcp.md b/docs/x-frontmcp.md index 4490efa..61d011f 100644 --- a/docs/x-frontmcp.md +++ b/docs/x-frontmcp.md @@ -185,6 +185,8 @@ interface FrontMcpExtensionData { input: Record; output?: unknown; }>; + meta?: Record; + icons?: ToolIcon[]; } ``` diff --git a/src/__tests__/annotations.spec.ts b/src/__tests__/annotations.spec.ts index 476e15d..c4262ff 100644 --- a/src/__tests__/annotations.spec.ts +++ b/src/__tests__/annotations.spec.ts @@ -275,10 +275,12 @@ describe('meta and icons extension extraction', () => { }); it('cleanses meta arrays and scalars in place', () => { - const overrides = extractExtensionOverrides({ - 'x-mcp': { meta: { list: [1, { '__proto__': 1, a: 2 }, 'x'] } }, - } as any); + // JSON.parse creates __proto__ as a real own key (an object literal would + // invoke the prototype setter instead and never produce an own property) + const meta = JSON.parse('{"list": [1, {"__proto__": {"polluted": true}, "a": 2}, "x"]}'); + const overrides = extractExtensionOverrides({ 'x-mcp': { meta } } as any); expect(overrides.meta).toEqual({ list: [1, { a: 2 }, 'x'] }); + expect(Object.getOwnPropertyNames((overrides.meta!['list'] as any[])[1])).toEqual(['a']); }); it('returns undefined icons when nothing well-formed remains', () => { diff --git a/src/__tests__/arazzo.spec.ts b/src/__tests__/arazzo.spec.ts index a1044c3..5254a2a 100644 --- a/src/__tests__/arazzo.spec.ts +++ b/src/__tests__/arazzo.spec.ts @@ -573,6 +573,46 @@ describe('fromArazzo components resolution', () => { await expectArazzoError(fromArazzo(nonString, { sources: sources() }), /"reference" must be a string/); }); + it('never resolves inherited or non-object component members', async () => { + // Without the own-key guard, `$components.parameters.toString` resolves + // Object.prototype.toString and crashes in the JSON round-trip + const inherited = arazzoWith( + [ + simpleWorkflow({ + outputs: undefined, + steps: [{ stepId: 'f', operationId: 'getPet', parameters: [{ reference: '$components.parameters.toString' }] }], + }), + ], + { components: { parameters: {} } }, + ); + await expectArazzoError(fromArazzo(inherited, { sources: sources() }), /Unknown reference/); + + const nullish = arazzoWith( + [ + simpleWorkflow({ + outputs: undefined, + steps: [{ stepId: 'f', operationId: 'getPet', parameters: [{ reference: '$components.parameters.gone' }] }], + }), + ], + { components: { parameters: { gone: null } } }, + ); + await expectArazzoError(fromArazzo(nullish, { sources: sources() }), /Unknown reference/); + + // `#/components/inputs/constructor` would resolve the inherited Function + const inputsRef = (name: string, components: any) => + arazzoWith([simpleWorkflow({ inputs: { $ref: `#/components/inputs/${name}` }, outputs: undefined })], { + components, + }); + await expectArazzoError( + fromArazzo(inputsRef('constructor', { inputs: {} }), { sources: sources() }), + /Unknown workflow inputs reference/, + ); + await expectArazzoError( + fromArazzo(inputsRef('prim', { inputs: { prim: 'not-a-schema' } }), { sources: sources() }), + /Unknown workflow inputs reference/, + ); + }); + it('re-validates action types resolved from components', async () => { const doc = arazzoWith( [ diff --git a/src/__tests__/type-signature.spec.ts b/src/__tests__/type-signature.spec.ts index 076f9ed..0416a79 100644 --- a/src/__tests__/type-signature.spec.ts +++ b/src/__tests__/type-signature.spec.ts @@ -7,11 +7,6 @@ import type { JsonSchema } from '../types'; const sig = (input: any, output?: any, options?: any): string => emitToolTypeScript('t', undefined, input as JsonSchema, output as JsonSchema | undefined, options).signature; -const inputType = (input: any, options?: any): string => { - const m = sig(input, undefined, options).match(/^\((?:input\??: )?(.*?)\) => /); - return m ? (m[1] ?? '') : ''; -}; - const outputType = (output: any, options?: any): string => sig({ type: 'object', properties: { a: { type: 'string' } } }, output, options).replace(/^.* => Promise<(.*)>$/s, '$1'); @@ -375,6 +370,18 @@ describe('emitToolTypeScript assembly', () => { expect(declaration).toContain('declare function t(input: TInput): Promise;'); }); + it('keeps input for composed roots without properties', () => { + const a = { type: 'object', properties: { a: { type: 'string' } }, required: ['a'] }; + const b = { type: 'object', properties: { b: { type: 'number' } }, required: ['b'] }; + expect(sig({ oneOf: [a, b] })).toBe('(input: { a: string } | { b: number }) => Promise'); + expect(sig({ allOf: [a] })).toBe('(input: { a: string }) => Promise'); + expect(sig({ anyOf: [{ type: 'string' }, { type: 'number' }] })).toBe('(input: string | number) => Promise'); + expect(sig({ enum: ['a', 'b'] })).toBe('(input: "a" | "b") => Promise'); + expect(sig({ const: 'fixed' })).toBe('(input: "fixed") => Promise'); + const { declaration } = emitToolTypeScript('t', undefined, { oneOf: [a, b] } as JsonSchema, undefined); + expect(declaration).toContain('declare function t(input: TInput): Promise;'); + }); + it('is deterministic across calls', () => { const input = { type: 'object', properties: { a: { type: 'string' } } } as JsonSchema; const output = { oneOf: [{ type: 'string' }, { type: 'number' }] } as JsonSchema; diff --git a/src/annotations.ts b/src/annotations.ts index 772669e..8b0430b 100644 --- a/src/annotations.ts +++ b/src/annotations.ts @@ -135,8 +135,12 @@ function sanitizeMeta(value: unknown): Record | undefined { return undefined; } -/** Icon URI schemes matching the documented `ToolIcon.src` contract. */ -function isAllowedIconSrc(src: string): boolean { +/** + * Whether an icon source URI matches the documented `ToolIcon.src` scheme + * contract (`https:` or `data:` only, case-insensitive). Shared by extension + * icon sanitization and the generator's `info['x-logo']` inheritance. + */ +export function isAllowedIconSrc(src: string): boolean { const lower = src.toLowerCase(); return lower.startsWith('https:') || lower.startsWith('data:'); } diff --git a/src/arazzo.ts b/src/arazzo.ts index ed05ca8..30ac060 100644 --- a/src/arazzo.ts +++ b/src/arazzo.ts @@ -376,6 +376,17 @@ function validateDocument(doc: ArazzoDocument): void { // Components resolution // --------------------------------------------------------------------------- +/** Own-key component lookup: inherited members (`toString`, `constructor`, + * ...) and non-object values never resolve — document-supplied names must not + * reach prototype members or leak primitives where component objects belong. */ +function ownComponent(group: Record | undefined, name: string): object | undefined { + if (!group || !Object.prototype.hasOwnProperty.call(group, name)) { + return undefined; + } + const value = group[name]; + return value !== null && typeof value === 'object' ? (value as object) : undefined; +} + function resolveReusable( entry: T | ArazzoReusableObject, components: ArazzoComponents | undefined, @@ -395,7 +406,7 @@ function resolveReusable( } // Component names may legally contain dots (`my.org.petId`) — re-join const name = ast.path.slice(1).join('.'); - const target = components?.[expectedGroup]?.[name]; + const target = ownComponent(components?.[expectedGroup], name); if (!target) { err(`Unknown reference "$components.${expectedGroup}.${name}"`, path); } @@ -422,7 +433,7 @@ function resolveInputRefs(node: unknown, components: ArazzoComponents | undefine err(`Unsupported $ref "${ref}" in workflow inputs (only ${prefix} is resolvable)`, path); } const name = ref.slice(prefix.length); - const target = components?.inputs?.[name]; + const target = ownComponent(components?.inputs, name); if (!target) { err(`Unknown workflow inputs reference "${ref}"`, path); } diff --git a/src/generator.ts b/src/generator.ts index 6782220..9e690f4 100644 --- a/src/generator.ts +++ b/src/generator.ts @@ -28,7 +28,7 @@ import { isReferenceObject } from './types'; import { ParameterResolver } from './parameter-resolver'; import { ResponseBuilder } from './response-builder'; import { SchemaBuilder } from './schema-builder'; -import { extractExtensionOverrides, inferAnnotationsFromMethod, resolveExtensionEnabled } from './annotations'; +import { extractExtensionOverrides, inferAnnotationsFromMethod, isAllowedIconSrc, resolveExtensionEnabled } from './annotations'; import { applyClientTarget } from './client-targets'; import { applyOverlay } from './overlay'; import { lintDocument, PAGINATION_PARAM, type LintResult } from './lint'; @@ -252,9 +252,8 @@ function iconsFromInfoLogo(info: unknown): ToolIcon[] | undefined { } } // Same scheme contract as extension icons (https:/data: only) - const lower = src?.toLowerCase(); - if (lower !== undefined && (lower.startsWith('https:') || lower.startsWith('data:'))) { - return [{ src: src as string }]; + if (src !== undefined && isAllowedIconSrc(src)) { + return [{ src }]; } return undefined; } diff --git a/src/type-signature.ts b/src/type-signature.ts index 502404a..8b13ead 100644 --- a/src/type-signature.ts +++ b/src/type-signature.ts @@ -368,9 +368,17 @@ function paramList(inputSchema: unknown, typeText: string): string { const ap = inputSchema['additionalProperties']; const hasExtra = ap === true || isSchemaRecord(ap) || isSchemaRecord(inputSchema['patternProperties']); const objectish = inputSchema['type'] === 'object' || inputSchema['type'] === undefined; + // Composed roots (oneOf/anyOf/allOf/enum/const) type real data even + // though they declare no properties of their own. + const composed = + Array.isArray(inputSchema['allOf']) || + Array.isArray(inputSchema['oneOf']) || + Array.isArray(inputSchema['anyOf']) || + Array.isArray(inputSchema['enum']) || + 'const' in inputSchema; // A closed, empty object root truly takes no input; anything else - // (typed additionalProperties, non-object roots) still carries data. - return objectish && !hasExtra ? '()' : `(input: ${typeText})`; + // (typed additionalProperties, composed or non-object roots) carries data. + return objectish && !hasExtra && !composed ? '()' : `(input: ${typeText})`; } const required = new Set(Array.isArray(inputSchema['required']) ? (inputSchema['required'] as unknown[]) : []); const allOptional = keys.every((k) => !required.has(k)); From ae80ad3e3b10f8b9bba15b428735c1583c056035 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Thu, 13 Aug 2026 03:34:50 +0300 Subject: [PATCH 10/10] docs: sync the x-frontmcp access example with the meta and icons fields --- docs/x-frontmcp.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/x-frontmcp.md b/docs/x-frontmcp.md index 61d011f..8792680 100644 --- a/docs/x-frontmcp.md +++ b/docs/x-frontmcp.md @@ -140,7 +140,7 @@ const tools = await generator.generateTools(); for (const tool of tools) { if (tool.metadata.frontmcp) { - const { annotations, cache, codecall, tags, hideFromDiscovery, examples } = tool.metadata.frontmcp; + const { annotations, cache, codecall, tags, hideFromDiscovery, examples, meta, icons } = tool.metadata.frontmcp; if (annotations?.readOnlyHint) { // Safe to cache or retry @@ -153,6 +153,9 @@ for (const tool of tools) { if (hideFromDiscovery) { // Skip in tool listings } + + // meta and icons also surface on the tool itself (tool._meta / tool.icons) + // after sanitization — see Modern MCP Fields. } } ```