fix(mcp/openapi): share one schema emitter so the two surfaces stop diverging - #1944
Conversation
…iverging (#1941, #1942) `attributeToFragment` served two masters: the canonical, front-end-neutral `Table.properties` Record and (since #1921) OpenAPI's nested-object emit. Those have opposite requirements — the canonical projection must carry `hidden` and must not carry enum/format/const; an emitted document is the reverse — so the nested paths drifted from MCP's. Add `attributeToSchema(attr, { dialect, mapPrimitive })`: one traversal, used by both surfaces, with each keeping its own primitive mapping (MCP widens Date and tags Bytes; OpenAPI carries the Harper type as `format`). `attributeToFragment` is untouched and still feeds `Table.properties`. #1941 — `hidden` is now honored at every nesting level, not just the top: - A hidden sub-property of a nested object was emitted on both surfaces, with its description; OpenAPI additionally leaked `hidden: true` into the public document as a schema key. The emitter returns undefined for a hidden attribute and no longer emits Harper directives at all. - A hidden name is also dropped from the nested `required` list, which would otherwise make the object unsatisfiable for a validating client. #1942 — the three divergences: - An unrecognized type name coerced to `string` on MCP and to untyped `{}` on OpenAPI. Both now emit untyped, and `resolveDeclaredType` warns once per name so the typo is visible instead of silently producing two wrong schemas. - enum/format/const/description reached only top-level properties on OpenAPI; nested objects and array items now carry them, matching MCP. - OpenAPI read `nullable` only to compute `required` and never emitted it, so a nullable top-level property was advertised as non-nullable while a nested one was not. Both levels now emit `nullable: true` (OpenAPI 3.0.3's expression); MCP continues to emit the `['T','null']` union and never the keyword. Closes #1941, closes #1942. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0188G62J9fZQg4J9rVuqLzjy
There was a problem hiding this comment.
Code Review
This pull request unifies and refactors the schema generation logic for MCP tools and OpenAPI 3.0.3 endpoints by introducing a shared attributeToSchema function in resources/jsonSchemaTypes.ts. This ensures consistent traversal, nesting, array handling, hidden property suppression, and nullability across both surfaces, addressing previous divergence issues. Feedback focuses on allowing user-defined hints to overwrite generic defaults from primitive mappings, passing the current attribute to the mapPrimitive callback to improve warning context accuracy, and omitting the required array if it becomes empty after filtering out hidden properties.
| if (attr.description && fragment.description === undefined) fragment.description = attr.description; | ||
| if (attr.enum && fragment.enum === undefined) fragment.enum = attr.enum; | ||
| if (attr.format && fragment.format === undefined) fragment.format = attr.format; | ||
| if (attr.const !== undefined && fragment.const === undefined) fragment.const = attr.const; |
There was a problem hiding this comment.
In attributeToSchema, user-defined hints (like description, enum, format, and const) are only applied if they are currently undefined on the fragment. However, primitive mappings (such as Date in harperTypeToJsonSchema) can provide generic default descriptions (e.g., 'ISO 8601 timestamp or epoch milliseconds.'). If a user defines a custom description on a Date attribute, it will be silently ignored because fragment.description is already populated by the primitive mapping.
Additionally, this creates a divergence with top-level OpenAPI properties where the user's description always overwrites the default.
We should let the user-defined attributes directly overwrite any generic defaults from the primitive mapping.
| if (attr.description && fragment.description === undefined) fragment.description = attr.description; | |
| if (attr.enum && fragment.enum === undefined) fragment.enum = attr.enum; | |
| if (attr.format && fragment.format === undefined) fragment.format = attr.format; | |
| if (attr.const !== undefined && fragment.const === undefined) fragment.const = attr.const; | |
| if (attr.description) fragment.description = attr.description; | |
| if (attr.enum) fragment.enum = attr.enum; | |
| if (attr.format) fragment.format = attr.format; | |
| if (attr.const !== undefined) fragment.const = attr.const; |
| * OpenAPI emits a `format`. Those differences are deliberate, so they stay with the caller; only | ||
| * the traversal around them is shared. | ||
| */ | ||
| mapPrimitive: (type: string | undefined) => JsonSchemaFragment; |
There was a problem hiding this comment.
The mapPrimitive callback currently only receives the type string. When resolving types for nested properties, this makes it impossible to know which specific property is being mapped, leading to warning messages that incorrectly attribute the unrecognized type to the top-level property name.
Let's update the signature of mapPrimitive to also accept the current AttributeLike object so that callers can use the correct property name for warning contexts.
| mapPrimitive: (type: string | undefined) => JsonSchemaFragment; | |
| mapPrimitive: (type: string | undefined, attr: AttributeLike) => JsonSchemaFragment; |
| if (items) fragment.items = items; | ||
| } | ||
| } else { | ||
| Object.assign(fragment, options.mapPrimitive(attr.type)); |
There was a problem hiding this comment.
| function attributeToProperty(attr: HarperAttribute): object | undefined { | ||
| return attributeToSchema(attr as AttributeLike, { | ||
| dialect: 'json-schema', | ||
| mapPrimitive: (type) => harperTypeToJsonSchema(type) as JsonSchemaFragment, |
There was a problem hiding this comment.
Update the callback signature to accept leafAttr to support the signature change described in the main finding on SchemaEmitOptions.
| mapPrimitive: (type) => harperTypeToJsonSchema(type) as JsonSchemaFragment, | |
| mapPrimitive: (type, leafAttr) => harperTypeToJsonSchema(type) as JsonSchemaFragment, |
| mapPrimitive: (type) => { | ||
| if (type === 'Any' || type === undefined) return {}; | ||
| const resolved = resolveDeclaredType(type, `OpenAPI property "${attr.name}"`); | ||
| if (!resolved) return {}; | ||
| // Preserve the Harper type name as `format` for the types where it adds information, matching | ||
| // the top-level `Type()` emitter. | ||
| return DATA_TYPES[type] ? (new Type(resolved, type) as JsonSchemaFragment) : { type: resolved }; | ||
| }, |
There was a problem hiding this comment.
Use the leafAttr parameter to resolve the correct nested property name in the warning context, as described in the main finding on SchemaEmitOptions.
| mapPrimitive: (type) => { | |
| if (type === 'Any' || type === undefined) return {}; | |
| const resolved = resolveDeclaredType(type, `OpenAPI property "${attr.name}"`); | |
| if (!resolved) return {}; | |
| // Preserve the Harper type name as `format` for the types where it adds information, matching | |
| // the top-level `Type()` emitter. | |
| return DATA_TYPES[type] ? (new Type(resolved, type) as JsonSchemaFragment) : { type: resolved }; | |
| }, | |
| mapPrimitive: (type, leafAttr) => { | |
| if (type === 'Any' || type === undefined) return {}; | |
| const resolved = resolveDeclaredType(type, `OpenAPI property "${leafAttr.name}"`); | |
| if (!resolved) return {}; | |
| // Preserve the Harper type name as `format` for the types where it adds information, matching | |
| // the top-level `Type()` emitter. | |
| return DATA_TYPES[type] ? (new Type(resolved, type) as JsonSchemaFragment) : { type: resolved }; | |
| }, |
| } | ||
| // Drop suppressed sub-properties from `required` too — advertising a required property the | ||
| // schema does not define makes the object unsatisfiable for any client that validates. | ||
| if (attr.required) fragment.required = attr.required.filter((name) => visibleNames.has(name)); |
There was a problem hiding this comment.
If all required properties of a nested object are hidden, fragment.required will be filtered down to an empty array. While technically valid, emitting an empty required array is redundant and can be omitted entirely for cleaner schema output.
| if (attr.required) fragment.required = attr.required.filter((name) => visibleNames.has(name)); | |
| if (attr.required) { | |
| const filtered = attr.required.filter((name) => visibleNames.has(name)); | |
| if (filtered.length > 0) fragment.required = filtered; | |
| } |
|
1 blocker: fragmentToOpenApiSchema (resources/openApi.ts:672-708) narrows a genuine multi-type union to its first member instead of oneOf, for the request-contract body/response/query-param path — the same bug class this PR fixes everywhere else. See inline comment. |
Follow-ups to the shared-emitter refactor, all confirmed by executing against the built code rather than reading it: - A @hidden primary key produced a REQUIRED `id` argument with NO type on update_/delete_, and silently retyped an Int PK as string on get_ — the four derive sites gave three different answers. The PK is the record's address, not one of its fields, so `hidden` must not strip it: add an `ignoreHidden` emit option and route all four sites through one `primaryKeySchema`. The option does not cascade — a hidden sub-property of a surfaced PK stays suppressed. - `Blob` warned as an unrecognized type on every process start. It and `Any` are Harper primitives (graphql.ts PRIMITIVE_TYPES) that simply carry no DATA_TYPES entry; the warning also enumerated a type list that omitted both. - `required: []` was synthesized when every required sub-property was hidden. JSON Schema draft-04, which OpenAPI 3.0.3 inherits, requires at least one element; omit the key instead. - `DATA_TYPES[type]` on an object literal resolved prototype members, so `type: 'constructor'` put a *function* into an emitted schema and skipped the warning for a bogus name. Use Object.hasOwn. - `nullable: true` was emitted on fragments whose type never resolved, which is meaningless in OpenAPI 3.0.3 (it modifies `type`). - The unknown-type warning named the attribute the recursion started from, not the one carrying the bad type, and the MCP side used a constant string. Thread the mapped attribute through so a typo is locatable. - A typo'd type at the OpenAPI top level never warned — only the MCP deriver did, so with MCP disabled the silence #1942 set out to fix remained. Route that branch through the shared primitive mapper too. Adds the regression tests these lacked: hidden PK typing across verbs, hidden non-key still suppressed, Blob/Any silence, empty-required omission, prototype keys, typeless nullability, warn-once + message content, ignoreHidden scoping. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0188G62J9fZQg4J9rVuqLzjy
| if (attr.nullable) applyNullability(fragment, options.dialect); | ||
| if (attr.description && fragment.description === undefined) fragment.description = attr.description; | ||
| if (attr.enum && fragment.enum === undefined) fragment.enum = attr.enum; | ||
| if (attr.format && fragment.format === undefined) fragment.format = attr.format; | ||
| if (attr.const !== undefined && fragment.const === undefined) fragment.const = attr.const; |
There was a problem hiding this comment.
1. Primitive-mapping defaults silently block user-defined hints (MCP Date description)
File: resources/jsonSchemaTypes.ts:309-312
What: attr.description/enum/format/const only overwrite the fragment when the corresponding field is still undefined. But mapPrimitive runs first (line 305) and can populate fragment.description itself — MCP's harperTypeToJsonSchema does exactly this for Date, hardcoding 'ISO 8601 timestamp or epoch milliseconds.'. If an attribute of type Date also declares its own description, that user-authored text is silently dropped in favor of the generic default, because fragment.description is already non-undefined by the time line 309 runs.
Why it matters: This is the exact class of MCP/OpenAPI divergence this PR sets out to close (#1941/#1942) — OpenAPI's mapPrimitive never sets description, so a Date attribute's custom description survives on OpenAPI but is silently discarded on MCP. It's also a real regression for integrators: a documented field description is dropped without any warning. This was flagged by a peer reviewer (gemini) on the first commit and was not addressed by the 7281aaa follow-up, which fixed several sibling gemini findings (mapPrimitive signature, empty required) but not this one. None of the added convergence tests exercise a Date-typed attribute with a custom description, so the gap isn't caught.
Suggested fix: Let the attribute-level hint always win over a primitive-mapping default, as gemini suggested:
| if (attr.nullable) applyNullability(fragment, options.dialect); | |
| if (attr.description && fragment.description === undefined) fragment.description = attr.description; | |
| if (attr.enum && fragment.enum === undefined) fragment.enum = attr.enum; | |
| if (attr.format && fragment.format === undefined) fragment.format = attr.format; | |
| if (attr.const !== undefined && fragment.const === undefined) fragment.const = attr.const; | |
| if (attr.description) fragment.description = attr.description; | |
| if (attr.enum) fragment.enum = attr.enum; | |
| if (attr.format) fragment.format = attr.format; | |
| if (attr.const !== undefined) fragment.const = attr.const; |
…integration test Runtime verification against a booted instance surfaced one more instance of the empty-`required` problem: the earlier fix covered nested objects, but the top-level `ResourceSchema` still shipped `required: []` for a resource whose attributes are all nullable. Invalid under JSON Schema draft-04, which OpenAPI 3.0.3 inherits. Adds `integrationTests/resources/openapi-static-properties.test.mjs`, which asserts the *emitted* document rather than its structure. The existing apiTests/open-api.test.mjs only checks that `paths` and `components.schemas` exist, so a wrong `nullable`, a leaked @hidden field, or a dropped nested enum would ship green — which is how these defects reached review in the first place. It pins: per-property type/description/enum, top-level `nullable`, nested @hidden suppression, no `hidden` key or hidden description anywhere in the document, `required` filtering plus omission when empty, and enum/description on array items. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0188G62J9fZQg4J9rVuqLzjy
| if (attr.description && fragment.description === undefined) fragment.description = attr.description; | ||
| if (attr.enum && fragment.enum === undefined) fragment.enum = attr.enum; | ||
| if (attr.format && fragment.format === undefined) fragment.format = attr.format; | ||
| if (attr.const !== undefined && fragment.const === undefined) fragment.const = attr.const; |
There was a problem hiding this comment.
Still open from the prior review round (flagged here by both gemini and this bot, and not addressed by the 7281aaa5d/f54031bda follow-ups): mapPrimitive runs first and can pre-populate fragment.description (MCP's harperTypeToJsonSchema hardcodes 'ISO 8601 timestamp or epoch milliseconds.' for Date). Because these lines only assign when the field is still undefined, a user-authored description on a Date attribute is silently dropped on MCP — while it survives on OpenAPI, since OpenAPI's mapPrimitive never sets description. That's the same divergence class #1941/#1942 set out to close, and no test in this PR's convergence suite exercises a Date attribute with a custom description, so it isn't caught.
| if (attr.description && fragment.description === undefined) fragment.description = attr.description; | |
| if (attr.enum && fragment.enum === undefined) fragment.enum = attr.enum; | |
| if (attr.format && fragment.format === undefined) fragment.format = attr.format; | |
| if (attr.const !== undefined && fragment.const === undefined) fragment.const = attr.const; | |
| if (attr.description) fragment.description = attr.description; | |
| if (attr.enum) fragment.enum = attr.enum; | |
| if (attr.format) fragment.format = attr.format; | |
| if (attr.const !== undefined) fragment.const = attr.const; |
…lid type
Merges the base's 3.0.3 dialect fix and extends it to the shared emitter, which
carried `const` into nested objects and array items — so the top-level fix alone
left the document non-conformant at depth.
`attributeToSchema` now branches on dialect: OpenAPI 3.0.3 gets the equivalent
single-value `enum` (draft-04 has no `const`), MCP keeps `const` since it speaks
current JSON Schema. `openApiPrimitive` maps a resolved `'null'` type to an
untyped `{ nullable: true }` rather than a type 3.0 cannot express.
The convergence test now asserts the difference explicitly instead of expecting
`const` on both surfaces: the constraint reaches both, only its spelling differs.
The base's whole-document dialect scan is what caught the nested paths.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188G62J9fZQg4J9rVuqLzjy
| if (attr.description && fragment.description === undefined) fragment.description = attr.description; | ||
| if (attr.enum && fragment.enum === undefined) fragment.enum = attr.enum; | ||
| if (attr.format && fragment.format === undefined) fragment.format = attr.format; | ||
| if (attr.const !== undefined) { |
There was a problem hiding this comment.
Still open (re-raising from the prior thread — flagged by both gemini and this bot, addressed for const/dialect handling in the two latest commits but not for this): mapPrimitive runs first and MCP's Date mapping pre-populates fragment.description, so these === undefined guards mean a user-authored description on a Date attribute is silently dropped on MCP while it survives on OpenAPI. Suggested fix unchanged:
| if (attr.description && fragment.description === undefined) fragment.description = attr.description; | |
| if (attr.enum && fragment.enum === undefined) fragment.enum = attr.enum; | |
| if (attr.format && fragment.format === undefined) fragment.format = attr.format; | |
| if (attr.const !== undefined) { | |
| if (attr.description) fragment.description = attr.description; | |
| if (attr.enum) fragment.enum = attr.enum; | |
| if (attr.format) fragment.format = attr.format; |
Review of the previous commit found it made nullable properties strictly worse.
`const` was an unrecognized keyword in 3.0, so validators ignored it and `null`
passed; emitting the equivalent `enum` made the constraint real, and 3.0's
`nullable` does not widen an `enum` — so `{ const: 'x', nullable: true }` became
a schema that rejects null despite the flag. Converting a benign no-op into an
active contradiction is worse than the original defect.
- A nullable property carrying an `enum` (author-declared or const-derived) now
includes `null` in the value list, which also repairs the pre-existing plain
nullable-enum case.
- `enum` + `const` on one property intersects instead of dropping the `const`,
so OpenAPI stops advertising a wider value set than MCP from one declaration.
- An author-declared `format`/`description` outranks the primitive mapper's
default; `{ type: 'Date', format: 'date-time' }` no longer emits `format: Date`
on OpenAPI while MCP keeps `date-time`.
- A `'null'` type emits `{}` rather than a bare `{ nullable: true }`, matching
the invariant `applyNullability` already enforced and the suite already tested.
The dialect test checked only for `type: 'null'`, so `type: 'Text'` and
`['string','number']` — equally invalid in 3.0 — passed it. It now asserts the
closed six-value type enum, forbids type arrays, forbids Harper directives
leaking as schema keywords, and asserts the fixture's properties exist so the
whole-document walks can't pass vacuously on an empty document.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188G62J9fZQg4J9rVuqLzjy
`fragmentToOpenApiSchema` copied `type` and `const` verbatim from author-written contract fragments, so every request body, response, and contract query parameter could carry `const`, `type: 'null'`, or a type union into a document that declares 3.0.3 — the one emit path none of the earlier commits touched. The dialect test could not have caught it: its fixture built a single table-shaped resource, so `fragmentToOpenApiSchema` was never entered. The "whole document" framing was only as broad as the fixture. It now also builds a request-contract resource carrying a `const`, a `['string','null']` union, and a nullable enum, with an explicit assertion on the emitted body so the addition can't silently go inert. Also merges the base's serialized-document walk, so the checks judge what a consumer receives rather than undefined-valued keys that vanish on the wire. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0188G62J9fZQg4J9rVuqLzjy
Object.assign merged whatever a surface's mapper returned straight into the emitted fragment. The set of keys a leaf schema may gain is fixed, so copy them explicitly — which also surfaced that `contentEncoding` was reaching MCP schemas through the cast without being declared on JsonSchemaFragment. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0188G62J9fZQg4J9rVuqLzjy
The base branch added `types` (the source union) and translated it per surface
at each call site. This branch replaced those call sites with one shared
emitter, so the translation moves into it: `attributeToSchema` emits the union
verbatim for MCP and `oneOf` for OpenAPI 3.0, which also makes it work at every
nesting level rather than only the top one.
Also expresses a `null`-only declaration as `{ nullable: true, enum: [null] }`
rather than dropping it — 3.0 has no `null` type, but it can say "only null".
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188G62J9fZQg4J9rVuqLzjy
| if (Array.isArray(fragment.type)) { | ||
| const members = fragment.type.filter((t: unknown) => t !== 'null'); | ||
| if (members.length !== fragment.type.length) schema.nullable = true; | ||
| if (members.length > 0) schema.type = members[0]; |
There was a problem hiding this comment.
What: fragmentToOpenApiSchema — the function that emits request-contract bodies/responses/query params (resources/openApi.ts:672-708) — narrows a genuine multi-type union to only its first member. When fragment.type is e.g. ['string', 'number'] (no 'null'), members is ['string','number'], and line 682 sets schema.type = members[0], silently dropping 'number'.
Why it matters: This is the exact bug class this PR's own later commits (2039e4dec "preserve a declared type union instead of keeping its first member", 61d2773fa) fix everywhere else — the shared attributeToSchema/toOpenApiDialect path now converts a genuine union to oneOf (see resources/jsonSchemaTypes.ts:334-338 and the top-level loop's union/oneOf branch in this same file). fragmentToOpenApiSchema feeds every request-contract body, response, and query parameter (defineResource/Resource.withSchema), and defineResource.ts itself validates against a type array at runtime (Array.isArray(fragment.type) ? fragment.type : [fragment.type], line ~301) — so a contract that legitimately accepts string | number will have its OpenAPI doc claim only string is accepted. That's integrator-misleading documentation drift, and it diverges from what the server actually enforces on the same input.
The added test (translates request-contract schemas too) only exercises note: { type: ['string', 'null'] }, which is nullability, not a genuine union — it wouldn't catch this.
Suggested fix: mirror the oneOf translation used by the table path:
| if (Array.isArray(fragment.type)) { | |
| const members = fragment.type.filter((t: unknown) => t !== 'null'); | |
| if (members.length !== fragment.type.length) schema.nullable = true; | |
| if (members.length > 0) schema.type = members[0]; | |
| if (Array.isArray(fragment.type)) { | |
| const members = fragment.type.filter((t: unknown) => t !== 'null'); | |
| if (members.length !== fragment.type.length) schema.nullable = true; | |
| if (members.length > 1) schema.oneOf = members.map((member: unknown) => ({ type: member })); | |
| else if (members.length === 1) schema.type = members[0]; | |
| } else if (fragment.type != null && fragment.type !== 'null') { | |
| schema.type = fragment.type; | |
| } |
kriszyp
left a comment
There was a problem hiding this comment.
A few more things to cover.
🤖 Reviewed with GPT 5.6
| // on the table path: no `'null'` type, no type unions, no `const`. This function feeds every | ||
| // request body, response, and contract query parameter, so a fragment reaching it unfiltered is a | ||
| // non-conformant document even though the table-derived paths are clean. | ||
| if (Array.isArray(fragment.type)) { |
There was a problem hiding this comment.
This remains a second schema emitter, and it narrows every genuine union to members[0]. For example, a request-contract property declared as schemaOf({ type: ['string', 'number'] }) is accepted as either member by runtime validation and exposed as either member to MCP, but OpenAPI advertises only string; generated clients can therefore reject valid numeric requests/responses. Please route request-contract fragments through the shared dialect-aware emitter (or extract one fragment-level emitter used by both paths), and add body/response/query coverage for a non-null multi-type union.
| if ((!('type' in fragment) || fragment.type === undefined) && fragment.oneOf === undefined) return; | ||
| if (fragment.type === undefined) { | ||
| // A `oneOf` union. 3.0 takes `nullable` alongside it; JSON Schema takes a `null` branch. | ||
| if (dialect === 'openapi-3.0.3') fragment.nullable = true; |
There was a problem hiding this comment.
OpenAPI 3.0.3 says nullable only takes effect when type is explicitly defined in the same Schema Object (https://spec.openapis.org/oas/v3.0.3.html#fixed-fields-20). A declaration such as type: ['string', 'integer', 'null'] therefore becomes two non-null oneOf branches plus an inert nullable: true, so OpenAPI clients still reject null while MCP accepts it. Please encode null as an explicit union branch (for example { enum: [null] }) and apply the same fix to the duplicated top-level OpenAPI path, with a validation test that actually checks a null instance.
| props[name] = { oneOf: union.map(openApiUnionMember) }; | ||
| props[name] = { oneOf: union.map((member) => openApiPrimitive(member, name)) }; | ||
| } else if (type === 'array') { | ||
| if (!elements) { |
There was a problem hiding this comment.
Although { type: 'array' } is valid general JSON Schema, OpenAPI 3.0.3 requires items whenever type is array (https://spec.openapis.org/oas/v3.0.3.html#schema-object). This branch emits a bare array, and the shared nested emitter/request-contract translator do the same, so a supported static declaration can make the entire OpenAPI document fail strict validation. For the OpenAPI dialect, please emit items: {} for an unconstrained array (MCP can keep the bare form) and cover top-level, nested, and request-contract arrays.
| /** Binary encoding of a string-typed value. Emitted on the MCP surface only — not a 3.0.3 keyword. */ | ||
| contentEncoding?: string; | ||
| /** Emitted by the OpenAPI 3.0 projection for a genuine multi-type union; not authored directly. */ | ||
| oneOf?: JsonSchemaFragment[]; |
There was a problem hiding this comment.
JsonSchemaFragment is the author-facing IR behind SchemaSource and Resource.properties/inputSchemas/outputSchemas, so adding oneOf here makes schemaOf({ oneOf: [...] }) a supported TypeScript declaration. Runtime validateValue, however, treats that fragment as untyped because it has no type, and the request-contract OpenAPI translator drops oneOf; an apparently constrained body is therefore accepted without the advertised validation. Please either keep dialect-only output fields in a separate emitted-schema type, or implement authored oneOf end-to-end before exposing it in this public shape.
Closes #1941, closes #1942.
Root cause
attributeToFragmentended up serving two masters: the canonical, front-end-neutralTable.propertiesRecord, and — since #1921 — OpenAPI's nested-object emit. Those have opposite requirements. The canonical projection must carryhidden(it round-trips throughfragmentToAttribute) and must not carryenum/format/const(a code-firsttypes.enumcolumn has to stay identical to its GraphQL equivalent). An emitted document is the exact reverse. So the nested paths quietly drifted away from MCP's, and every divergence in #1942 lives at that seam.Rather than bolt more conditionals onto the shared projector, this adds a second, purpose-built one:
One traversal — nesting, arrays,
hiddensuppression, hint propagation, nullability — used by both surfaces. Each surface keeps its own primitive mapping, because those differences are legitimate: MCP widensDateto['string','number']and tagsByteswithcontentEncoding; OpenAPI carries the Harper type name asformat. Unifying those too would churn table-backed output, which #1921 was careful to leave alone.attributeToFragmentis untouched.What changed behaviorally
#1941 —
hiddenis honored at every nesting level, not just the top. A hidden sub-property of a nested object was previously emitted on both surfaces with its description, and OpenAPI additionally wrotehidden: trueinto the public document as a schema key — advertising that the field was meant to be suppressed. Harper directives (hidden,primaryKey,assignCreatedTime,assignUpdatedTime) are no longer emitted into schemas at all. A suppressed name is also dropped from the nestedrequiredlist, which would otherwise make the object unsatisfiable for a validating client.#1942 — all three:
'Text') coerced tostringon MCP and to untyped{}on OpenAPI. Both now emit untyped, andresolveDeclaredTypewarns once per name so the typo is visible rather than silently producing two different wrong schemas.enum/format/const/descriptionreached only top-level properties on OpenAPI. Nested objects and array items now carry them, matching MCP.nullableonly to computerequiredand never emitted it — so a nullable top-level property was advertised as non-nullable, while a nested one (routed through the shared projector) was not. Both levels now emitnullable: true; MCP continues to emit the['T','null']union and never the keyword.Where to look
applyNullabilityis the one place the dialects legitimately differ, and it's the piece most worth checking. OpenAPI 3.0.3 has no union types, sonullable: trueis the only expression available; JSON Schema has nonullablekeyword. If you'd rather MCP also emitnullablefor client compatibility, that's a one-line change — but it would be non-standard.enum/description), and I'd addedresolveDeclaredTypewithout wiring it into MCP'sdefault:case. Both fixed; the tests are the reason I know./openapi.jsonseesnullable: trueappear on top-level properties,hidden: truedisappear from nested ones, and untyped{}where an unrecognized type name previously produced{"type":"string"}on the MCP side. That last one is the only case where a client could see less type information than before — deliberate, since the previous value was a guess.Seven convergence tests assert MCP and OpenAPI output side by side for the same fragment, which is the regression shape these issues kept slipping through. 534 tests pass across the MCP, OpenAPI, GraphQL-metadata, and code-first-parity suites.
Docs follow-up: documentation#605 documents the current (pre-fix) per-surface caveats and will need them collapsed once this lands.
PR description generated by kAIle (Claude Opus 4.8).