// Single responsibility: SchemaNode -> JSON Schema. Load-bearing: OpenAPI request/response // bodies and MCP tool `inputSchema` are both this function's output, so an agent's view of an // action and an HTTP client's view can never drift. Where the projection and the PARSER differ, // the published document states the narrower of the two — see `date` and `record` below. import { describeValue } from './describe-value'; import { SchemaUnsupportedError } from './errors'; import { JSON_MAX_DEPTH } from './json-value'; import { CURRENCY_CODE_PATTERN, MAX_MONEY_SCALE } from './money-value'; import { requiredKeys, type SchemaNode, type SchemaRefinement } from './node'; import { PROTOTYPE_KEYS } from './prototype-keys'; import { introspect } from './provider'; export type JsonSchemaType = | 'string' | 'number' | 'integer' | 'boolean' | 'object' | 'array' /** Only ever emitted as the second branch of a nullable field's `anyOf` — see `annotate`. */ | 'null'; /** OpenAPI 3.1's tagged-union hint. `propertyName` alone: the branches here are inline, not `$ref`. */ export interface JsonSchemaDiscriminator { readonly propertyName: string; } export interface JsonSchema { readonly $schema?: string; readonly type?: JsonSchemaType; readonly format?: string; readonly description?: string; readonly default?: unknown; readonly minLength?: number; readonly maxLength?: number; readonly pattern?: string; /** * The RegExp flags `pattern` is applied with, when there are any. JSON Schema's `pattern` has no * flag syntax and an inline `(?i)` is not ECMA-262, so they travel beside it as an extension a * consumer that compiles the pattern (`@ultimat3/mcp`'s arg validator) reads; the prose copy in * `description` is for a reader. */ readonly 'x-ultimate-pattern-flags'?: string; readonly minimum?: number; readonly maximum?: number; readonly enum?: readonly (string | number)[]; readonly const?: string | number | boolean | null; readonly properties?: Readonly>; readonly required?: readonly string[]; readonly additionalProperties?: boolean | JsonSchema; /** What a record's KEYS must satisfy. Draft-06 and later, so it is honest in both dialects. */ readonly propertyNames?: JsonSchema; /** Negation. Emitted only inside `propertyNames`, to publish a closed set of refused keys. */ readonly not?: JsonSchema; readonly items?: JsonSchema; readonly minItems?: number; readonly maxItems?: number; readonly anyOf?: readonly JsonSchema[]; readonly discriminator?: JsonSchemaDiscriminator; readonly title?: string; /** * The refinements a consumer can act on mechanically. The prose copy also lands in * `description`, because that is the only field an LLM reading an MCP tool schema is * guaranteed to see — the extension is for code generators, the description is for readers. */ readonly 'x-ultimate-refinements'?: readonly SchemaRefinement[]; } export type JsonSchemaDialect = '2020-12' | 'draft-07'; const DIALECTS = Object.freeze>({ '2020-12': 'https://json-schema.org/draft/2020-12/schema', 'draft-07': 'http://json-schema.org/draft-07/schema#', }); export interface ToJsonSchemaOptions { /** MCP clients are happiest with `draft-07`; OpenAPI 3.1 wants `2020-12` (the default). */ readonly dialect?: JsonSchemaDialect | undefined; /** Emit `$schema` at the root. Off for OpenAPI component schemas. */ readonly includeDialect?: boolean | undefined; readonly title?: string | undefined; } const FORMAT_MAP: Readonly> = Object.freeze({ uuid: 'uuid', email: 'email', uri: 'uri', 'date-time': 'date-time', slug: 'slug', timezone: 'iana-time-zone', locale: 'bcp47-locale', cursor: 'ultimate-cursor', }); /** * `Object.hasOwn`, never the read alone: `node.format` comes from a schema PROVIDER's IR, so * `FORMAT_MAP['constructor']` answered with the `Object` function and `?? node.format` could not * rescue it — `JSON.stringify` then dropped `format` from the published document in silence. * Same discriminator as `@ultimat3/action`'s `BY_FORMAT[node.format]`, one package up. */ const publishedFormat = (format: string): string => Object.hasOwn(FORMAT_MAP, format) ? (FORMAT_MAP[format] ?? format) : format; function stringNode(node: SchemaNode): JsonSchema { return { type: 'string', ...(node.format === undefined ? {} : { format: publishedFormat(node.format) }), ...(node.minLength === undefined ? {} : { minLength: node.minLength }), ...(node.maxLength === undefined ? {} : { maxLength: node.maxLength }), ...(node.pattern === undefined ? {} : { pattern: node.pattern }), ...(node.pattern === undefined || node.patternFlags === undefined || node.patternFlags === '' ? {} : { 'x-ultimate-pattern-flags': node.patternFlags }), }; } /** * The range an `integer` node's validator actually enforces — `Number.isSafeInteger`, the rule * `validators.ts` applies and `money-value.ts` already published. Spelled `-MAX_SAFE_INTEGER` * rather than `MIN_SAFE_INTEGER` (they are the same number) so the two projections of one rule * read identically. * * A caller's own bound is the published one when it is NARROWER, and is clamped when it is not: * `t.number.int().max(2 ** 60)` refuses `2 ** 60` at the boundary whatever the node says, so * publishing it would be a promise the parser breaks — which is the disagreement this whole * function exists to prevent, one layer out. */ const SAFE_INTEGER_MIN = -Number.MAX_SAFE_INTEGER; const SAFE_INTEGER_MAX = Number.MAX_SAFE_INTEGER; /** * A non-integer `t.number` publishes only what the caller declared: it accepts every finite * double, so a safe-integer range on it would tell a generated client to refuse `0.5`. */ function numberNode(node: SchemaNode): JsonSchema { const integer = node.integer === true; const minimum = integer ? Math.max(node.minimum ?? SAFE_INTEGER_MIN, SAFE_INTEGER_MIN) : node.minimum; const maximum = integer ? Math.min(node.maximum ?? SAFE_INTEGER_MAX, SAFE_INTEGER_MAX) : node.maximum; return { type: integer ? 'integer' : 'number', ...(minimum === undefined ? {} : { minimum }), ...(maximum === undefined ? {} : { maximum }), }; } /** * JSON Schema's `pattern` is an ECMA-262 source with no flag syntax, so a flagged pattern is * stated in prose instead of silently narrowed: a consumer applying `pattern` alone would refuse * values this schema accepts, and there is nowhere honest to hide that. */ function patternNote(node: SchemaNode): string | undefined { if (node.pattern === undefined) return undefined; const flags = node.patternFlags; return flags === undefined || flags === '' ? undefined : `pattern is applied with RegExp flags "${flags}"`; } /** * JSON Schema has no keyword for nesting depth, so `t.json()`'s bound is stated in prose — the * narrower-of-the-two rule this file's header states: `{}` alone promised a document 300 levels * deep that the parser refuses. */ function jsonNote(node: SchemaNode): string | undefined { return node.kind === 'json' ? `any JSON value, nested at most ${JSON_MAX_DEPTH} levels deep` : undefined; } function convert(node: SchemaNode): JsonSchema { const refinements = node.refinements ?? []; const notes = [ node.description, jsonNote(node), patternNote(node), ...refinements.map((refinement) => refinement.message), ].filter((part): part is string => part !== undefined); const described = notes.length === 0 ? undefined : notes.join(' — '); const annotations: JsonSchema = { ...(described === undefined ? {} : { description: described }), ...(node.hasDefault === true ? { default: node.default } : {}), ...(refinements.length === 0 ? {} : { 'x-ultimate-refinements': refinements }), }; /** * `null` is a VALUE the field holds, so it joins the type union rather than the annotations — * `{ anyOf: [, { type: 'null' }] }`, which is how OpenAPI 3.1 / JSON Schema 2020-12 * spell it (3.0's `nullable: true` keyword is gone). Dropping it published a contract every * surface at once disagreed with: OpenAPI bodies, MCP `inputSchema`, `respondToolFor` and the * typed client all forbade a `null` the action's own `output:` validator returns. * * The annotations stay OUTSIDE the `anyOf` — they describe the field, not one branch of it — * and `requiredKeys` is untouched, because nullable is not optional: the key is still sent. */ const annotate = (schema: JsonSchema): JsonSchema => node.nullable === true ? { anyOf: [schema, { type: 'null' }], ...annotations } : { ...schema, ...annotations }; switch (node.kind) { case 'string': return annotate(stringNode(node)); case 'number': return annotate(numberNode(node)); case 'boolean': return annotate({ type: 'boolean' }); // Narrower than the parser, deliberately. `dateSchema` also accepts a `number` — epoch // milliseconds — as a coercion convenience for a value that never crossed a wire as JSON, and // publishing `anyOf: [{ type: 'string', format: 'date-time' }, { type: 'number' }]` would tell // every generated client, every agent reading a tool schema and every OpenAPI consumer that an // epoch number is a supported spelling of an instant. It is not one: it carries no zone, it is // ambiguous about seconds versus milliseconds, and `t.date`'s own refusal says ISO-8601. So // the contract publishes the one spelling a caller should send, and the parser stays the more // permissive of the two — the safe direction, where the boundary accepts a value the document // did not promise rather than promising one it refuses. case 'date': return annotate({ type: 'string', format: 'date-time' }); case 'enum': return annotate({ type: 'string', enum: node.values ?? [] }); case 'literal': return annotate({ const: node.literal ?? null }); case 'array': return annotate({ type: 'array', items: node.items === undefined ? {} : convert(node.items), ...(node.minItems === undefined ? {} : { minItems: node.minItems }), ...(node.maxItems === undefined ? {} : { maxItems: node.maxItems }), }); case 'union': return annotate({ anyOf: (node.anyOf ?? []).map(convert), // Only when the union actually dispatches on a key: an untagged `t.union` carrying a // `discriminator` would tell a generator to read a property no member declares. ...(node.discriminant === undefined ? {} : { discriminator: { propertyName: node.discriminant } }), }); case 'record': return annotate({ type: 'object', additionalProperties: node.valueNode === undefined ? true : convert(node.valueNode), // The key rule, published rather than left implicit: `recordSchema` refuses // `__proto__` / `constructor` / `prototype` by name, and without this the document // advertised them — an OpenAPI body, a generated client and an MCP tool all sending a key // the boundary answers with a 422. `propertyNames` is draft-06 and later, so the one // emission is valid in both dialects this file publishes; the names come from // `prototype-keys.ts`, which the parser reads too, so the two views cannot drift. propertyNames: { not: { enum: PROTOTYPE_KEYS } }, }); case 'money': return annotate({ type: 'object', properties: { minor: { type: 'integer', description: 'amount in minor units, never a float', // The safe-integer range the validator enforces, so a generated client refuses the // same value the boundary does instead of learning about it from a 500. minimum: -Number.MAX_SAFE_INTEGER, maximum: Number.MAX_SAFE_INTEGER, }, // The pattern the validator applies, not a copy of it: this object IS the contract a // generated client checks against, so a widened predicate here would be a client // refusing a code the boundary accepts. currency: { type: 'string', pattern: CURRENCY_CODE_PATTERN }, // Optional, never required: `additionalProperties: false` alone would make a generated // client refuse a scaled amount the framework's own validator accepts. scale: { type: 'integer', description: 'decimal places `minor` counts; absent means the currency’s own', minimum: 0, maximum: MAX_MONEY_SCALE, }, }, required: ['minor', 'currency'], additionalProperties: false, }); case 'object': { // `Object.create(null)`, the same rule `validators.ts` applies to the IR this reads: a // `__proto__` property assigned into a `{}` literal reaches the setter and is published // nowhere, so the document would omit a field the server enforces. const properties: Record = Object.create(null) as Record< string, JsonSchema >; for (const [key, child] of Object.entries(node.properties ?? {})) { properties[key] = convert(child); } return annotate({ type: 'object', properties, required: requiredKeys(node), additionalProperties: false, }); } // No `type`: an empty schema is JSON Schema's own spelling of "any JSON value", and every // instance it validates is JSON already. The depth bound rides in `description` (`jsonNote`). case 'json': return annotate({}); default: return annotate({}); } } /** * The dialect a caller asked for, or a refusal. `Object.hasOwn`, never the read alone: `dialect` is * a field on this file's public entry point, so it is CALLER data, and `DIALECTS['constructor']` * answers the `Object` function — measured, `$schema` held that function, `JSON.stringify` dropped * the key in silence, and every consumer that does not serialise (the MCP tool schema, the OpenAPI * writer that spreads this document) carried it. `bun run proto-index` could not see the read: the * unrelated `Object.create(null)` in `convert` above exempted this whole FILE until that rule * became per-table. * * `X_SCHEMA_UNSUPPORTED` rather than a new code: this is the same question `toJsonSchema` already * answers with it — a document this package cannot publish — and a code is stable forever once * shipped, so a fourth one for one option would be a fourth thing to document. */ function dialectUri(dialect: string): string { if (Object.hasOwn(DIALECTS, dialect)) return DIALECTS[dialect as JsonSchemaDialect]; throw new SchemaUnsupportedError({ // `describeValue`, never the value: this cause is folded into a log line and an HTTP problem // document, and the two spellings that WORK are what a caller needs, not their own back. cause: `a json schema dialect must be '2020-12' or 'draft-07'; got ${describeValue(dialect)}`, fix: "toJsonSchema(schema, { dialect: '2020-12' }) # or 'draft-07' for an MCP tool schema", meta: { dialect }, }); } /** Convert any schema the active provider can introspect. */ export function toJsonSchema(schema: unknown, options?: ToJsonSchemaOptions): JsonSchema { const converted = convert(introspect(schema)); // Resolved BEFORE `includeDialect` is read: a dialect nothing publishes is a caller mistake // whether or not this document happens to emit `$schema`, and a refusal that fires for one flag // and not the other is a rule a caller learns the wrong half of. const uri = dialectUri(options?.dialect ?? '2020-12'); return { ...(options?.includeDialect === false ? {} : { $schema: uri }), ...(options?.title === undefined ? {} : { title: options.title }), ...converted, }; } /** Draft-07, no `$schema`: the document `wire-schema.ts` narrows to what an MCP tool publishes. */ export function toMcpInputSchema(schema: unknown): JsonSchema { return toJsonSchema(schema, { dialect: 'draft-07', includeDialect: false }); } /** Convert an already-introspected node — used by generators that walk the IR themselves. */ export function nodeToJsonSchema(node: SchemaNode): JsonSchema { return convert(node); }