Skip to content

feat(mcp/openapi): drive schemas from a programmatic Resource's static properties - #1921

Open
kylebernhardy wants to merge 10 commits into
mainfrom
feat/mcp-static-properties-tests-1920
Open

feat(mcp/openapi): drive schemas from a programmatic Resource's static properties#1921
kylebernhardy wants to merge 10 commits into
mainfrom
feat/mcp-static-properties-tests-1920

Conversation

@kylebernhardy

@kylebernhardy kylebernhardy commented Jul 23, 2026

Copy link
Copy Markdown
Member

Summary

Closes #1920 (follow-up to #1095/#1167). A programmatic Resource can declare static properties (a Record<string, JsonSchemaFragment>) instead of a table-backed attributes Array. The MCP tool builder, the OpenAPI generator, and the harper://schema introspection resource all read .attributes, so a bare static properties declaration produced skeletal schemas.

This adds projectPropertiesToAttributes / resolveAttributes (the structural inverse of the existing projectAttributesToProperties) and applies it on all three derivation surfaces, so a programmatic Resource gets the same rich schema a table-backed one does — types, per-property descriptions, enum/format/const, arrays, nested objects (incl. required/additionalProperties), and JSON-Schema union/nullable. Both type mappers (derive.ts, openApi.ts) pass lowercase JSON-Schema types through a single shared set (JSON_SCHEMA_SCALAR_TYPES), and existing table-backed output is unchanged.

Also replaces the previously ineffective idempotent-guard test (it asserted against a string map, so it passed trivially) with one that inspects the emitted tool's annotations, and adds MCP / OpenAPI / cross-surface convergence coverage that #1095 called for.

Where to look

  • resources/jsonSchemaTypes.tsfragmentToAttribute / projectPropertiesToAttributes / resolveAttributes. Round-trip is covered by a unit test asserting properties → attributes → properties is lossless (incl. enum/format/const, nested required/additionalProperties, arrays); a separate test covers ['T','null'] → nullable.
  • resources/openApi.ts — the attribute loop handles nested objects and array-of-object (via attributeToFragment), JSON-scalar array elements, and attaches enum/format/const.
  • components/mcp/tools/application.ts, components/mcp/resources.ts — one-line resolveAttributes swap each (cold path — registration / introspection, not per-request).

Attention

  • Out of scope, tracked in [MCP] parsePath attribute-suffix routing doesn't resolve for programmatic static-properties Resources #1922: Resource.parsePath reads this.attributes for URL attribute-suffix routing (/Widget/id.label). A programmatic Resource with only static properties doesn't support that today — a pre-existing per-request-hot-path gap this PR doesn't touch (a fix wants a cached projection).
  • Docs: [docs] Document programmatic Resource static properties for MCP/OpenAPI schema authoring #1923 tracks documenting the programmatic static properties authoring path (companion documentation-repo PR).
  • Reviewed with /deep-review (api + data-integrity) and /code-review; the findings they surfaced — union/nullable collapse, OpenAPI array-of-object, dropped nested required/additionalProperties, mapper duplication — are fixed in the second commit (in history, not re-litigated here). The deep-review agents independently confirmed table-backed output is unchanged and permission filtering still applies.

PR generated by kAIle (Claude Opus 4.8).

…c properties (#1920)

A programmatic Resource may declare `static properties` (a Record<string,
JsonSchemaFragment>) without an `attributes` Array. The MCP tool builder,
the OpenAPI generator, and the harper://schema introspection resource all
read `.attributes`, so such a Resource produced skeletal schemas.

Add `projectPropertiesToAttributes` / `resolveAttributes` (the structural
inverse of `projectAttributesToProperties`) and use it on all three
derivation surfaces, so a bare `static properties` declaration yields the
same rich schema — types, per-property descriptions, enum/format/const,
arrays, and nested objects — a table-backed Resource gets. Both type
mappers (derive.ts, openApi.ts) pass through lowercase JSON-Schema types
(static properties speaks JSON Schema; Harper's GraphQL types are
capitalized, so there is no collision) and existing table-backed output is
unchanged.

Also fixes the previously ineffective idempotent-guard test (it checked a
string map, not an emitted tool's annotations) and adds MCP, OpenAPI, and
cross-surface convergence coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188G62J9fZQg4J9rVuqLzjy
@kylebernhardy
kylebernhardy requested a review from kriszyp July 23, 2026 22:05

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces support for programmatic resources that declare static properties (in Record form) without an attributes array, ensuring consistent schema derivation across both MCP and OpenAPI surfaces. It adds utility functions to project JSON Schema properties back into attributes and includes comprehensive unit and convergence tests. The review feedback highlights two important improvements: guarding against a potential TypeError crash in OpenAPI generation when an array property omits items, and properly handling array-based nullable types (e.g., ['string', 'null']) during the reverse projection to preserve nullability and type resolution.

Comment thread resources/openApi.ts
Comment thread resources/jsonSchemaTypes.ts
Comment thread resources/openApi.ts
@claude

This comment has been minimized.

…of-object (deep+code review)

Addresses findings from the deep-review + cross-model review of #1921:

- Union type array (e.g. `type: ['string','null']`) no longer truncates to its
  first member; a `'null'` member folds into `nullable` and the remaining type
  is kept.
- Nested-object `required` / `additionalProperties` now survive the
  properties<->attributes round-trip and are emitted on both MCP and OpenAPI.
- OpenAPI array-of-object elements are recursed into their full object schema
  instead of emitting an undefined-typed item (MCP already handled this).
- JSON-Schema scalar type tolerance is now a single shared set
  (`JSON_SCHEMA_SCALAR_TYPES`) consumed by both type mappers, so they can't drift.
- Projected array-element attribute no longer reuses the parent field name.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188G62J9fZQg4J9rVuqLzjy
Comment thread resources/openApi.ts Outdated
nizzlenitz and others added 3 commits July 23, 2026 16:37
…t on the MCP inputSchema

Symmetry with the OpenAPI-side assertions — these behaviors were enabled on both
surfaces but only asserted on OpenAPI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188G62J9fZQg4J9rVuqLzjy
…erties projection

CI caught a real invariant break: emitting enum/format/const from the shared
`attributeToFragment` made a code-first `types.enum` column's `Table.properties`
diverge from its GraphQL `String` equivalent, breaking the deliberate code-first
⇔ GraphQL parity (types.enum is advisory — defineTable.ts). Those hints still
surface on the client-facing MCP/OpenAPI schemas for programmatic Resources via
derive.ts / openApi.ts; the canonical `.properties` Record stays neutral.

Round-trip test updated: `.properties` projection preserves type/description/
nested shapes/required/additionalProperties; enum/format/const are surfaced by
the schema paths, not carried through the neutral projection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188G62J9fZQg4J9rVuqLzjy
A programmatic `static properties` field of `{ type: 'array' }` with no `items`
(valid JSON Schema) projected to an attribute with undefined `elements`; the
OpenAPI array branch then dereferenced `elements.properties`, throwing and
crashing generation of the whole document. Emit a bare `{ type: 'array' }`.

Flagged by gemini-code-assist + claude PR review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188G62J9fZQg4J9rVuqLzjy
@kylebernhardy

Copy link
Copy Markdown
Member Author

Docs companion opened: HarperFast/documentation#605 — docs(resources): document static properties as a first-class MCP/OpenAPI schema source. It documents the JSON Schema vocabulary, the full fragment-key list, and the union / item-less-array / required-on-create resolution rules this PR introduces.

Comment generated by kAIle (Claude Opus 4.8).

// genuine multi-type union isn't expressible on an attribute, so the first member is kept.
const members = fragment.type.filter((t) => t !== 'null');
if (members.length !== fragment.type.length) attr.nullable = true;
if (members.length > 0) attr.type = members[0];

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we avoid dropping every non-first union member here? static properties is typed as JSON Schema, and MCP accepts type arrays, so { type: ['string', 'number'] } currently becomes string-only; { type: ['null'] } becomes untyped. Please preserve the source union for MCP and explicitly translate it for OpenAPI 3.0 (nullable for T | null, oneOf for genuine unions), with a consumer-level regression test.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partly addressed, partly not — splitting the two cases:

T | null is handled on the stacked follow-up #1944: MCP now re-expands to the source union ({ type: ['string','null'] }) rather than emitting a nullable keyword it has no use for, and OpenAPI emits nullable: true at top level and nested.

A genuine multi-type union (['string','number']) still collapses to its first member, on both PRs — the comment on this line admits it. Your oneOf suggestion is the right translation for OpenAPI, and MCP should simply pass the array through untouched since it accepts type arrays. That is not a small change to the projection: AttributeLike has a single type field, so preserving a union needs the attribute form to carry it, which is the same seam #1944 reworks. I would rather do it there or in a follow-up than partially here.

{ type: ['null'] } becoming untyped is a real edge of the same bug and I have no defense for it.

Comment generated by kAIle (Claude Opus 4.8)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done properly now in 2039e4d — I stop deferring this to the follow-up.

AttributeLike carries the source union on a new types field, verbatim. type still holds the first non-null member so the single-type consumers (validation, query coercion) are untouched. Each surface then expresses it in its own dialect:

  • MCP passes the array through as declared — it accepts type unions, as you said.
  • OpenAPI 3.0 emits oneOf, your suggested translation. nullable rides alongside it for ["string","number","null"].
  • { type: ["null"] } no longer goes untyped. On the stacked #1944 it emits { nullable: true, enum: [null] } — 3.0 has no null type, but a null-only enum is a form it can actually express, which beats staying silent about a field the author did describe.

The projection round-trips too: properties -> attributes -> properties returns the declared union rather than a collapsed scalar.

Consumer-level regression tests as you asked: oneOf emission, nullable-alongside-union, union round-trip, and a convergence test asserting both surfaces describe the same declaration without either narrowing it. On #1944 the translation lives in the shared emitter, so it applies at every nesting level rather than only the top — with a nested-union test to hold that.

Comment generated by kAIle (Claude Opus 4.8)

Comment thread resources/openApi.ts
(props[name] as { description?: string }).description = description;
// Attach per-property JSON-Schema hints (description/enum/format/const) so they surface in
// Swagger UI / Redoc; enum in particular tells clients the allowed values.
if (props[name] && typeof props[name] === 'object' && !('$ref' in props[name])) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The projection turns type: ['string', 'null'] into type: 'string', nullable: true, but this attachment block never emits nullable. The final OpenAPI component therefore rejects null while MCP preserves it. Please emit nullable: true here and assert the generated component; nullable enums also need an emitted form that accepts null.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and this one is already fixed on the stacked follow-up: #1944 emits nullable: true in exactly this attachment block (it was previously computed only to derive required and never written onto the property), with an assertion on the generated component in unitTests/components/mcp/tools/convergence.test.js plus a new emitted-document integration test.

The nullable-enum case you raise is not covered there — an enum whose list excludes null still rejects null even with nullable: true under 3.0. Tracking that with the dialect issue on your other thread.

Comment generated by kAIle (Claude Opus 4.8)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You were right and my earlier reply here was premature — the dialect commits fixed the nested and array paths but left this attachment block still dropping nullable, so the scalar case stayed broken. Fixed properly in f34cede:

  • nullable: true is now emitted on the scalar schema when the attribute is nullable (covers both an author-declared nullable and a ["string","null"] union, which folds to the same attribute upstream).
  • A nullable enum is widened with null, since 3.0's nullable does not widen an enum on its own.
  • const now intersects with a co-declared enum rather than deferring to it, matching the nested path.

Tests assert the emitted component positively now (maybe, nullableEnum, nullableConst) instead of only checking that invalid keywords are absent — the old assertions would have passed just as happily with nullability dropped, which is why this survived.

Still open from your review and not addressed here: genuine multi-type unions (["string","number"]) still collapse to the first member rather than emitting oneOf. Leaving that for the convergence work in #1944 unless you want it in this PR.

Comment generated by kAIle (Claude Opus 4.8)

Comment thread resources/openApi.ts Outdated
} else if (attr.properties) {
// Nested object from `static properties` — the shared projector emits the full object
// schema (sub-properties recursed); OpenAPI's table path uses $refs instead.
props[name] = attributeToFragment(attr);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This recursion goes through attributeToFragment(), which intentionally omits enum/format/const; the scalar-array branch similarly rebuilds only type. As a result, nested fields and array items lose author-declared constraints (for example, items: { type: 'string', format: 'uuid', enum: [...] } becomes only { type: 'string' }). Could this use a recursive OpenAPI projector that retains supported hints at every depth, with nested/item assertions?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, and also already fixed on the stacked follow-up #1944. It replaces this attributeToFragment() recursion with a shared, dialect-aware emitter (attributeToSchema) used by both the MCP deriver and the OpenAPI generator, so enum/format/const/description survive at every depth — nested objects and array items alike — and @hidden is suppressed at every level rather than only the top. Assertions cover nested and array-item hints on both surfaces.

The root cause was that attributeToFragment was serving two masters: the canonical, front-end-neutral Table.properties projection (which must omit those hints) and OpenAPI's nested emit (which must keep them).

Comment generated by kAIle (Claude Opus 4.8)

Comment thread resources/openApi.ts Outdated
if (description) prop.description = description;
if (attr.enum && prop.enum === undefined) prop.enum = attr.enum;
if (attr.format && prop.format === undefined) prop.format = attr.format;
if (attr.const !== undefined && prop.const === undefined) prop.const = attr.const;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because this document declares OpenAPI 3.0.3, const is not a valid Schema Object keyword (and lowercase type: 'null' passed through above is likewise not a 3.0 type). Please translate const to a single-value enum and nullability to OpenAPI 3.0 forms, or upgrade the whole document to 3.1. A spec-validation test would catch this dialect mismatch.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right, and this is the finding I had no answer for — thank you. The document declares 3.0.3, whose Schema Object is the draft-04 subset: const arrived in draft-06 and type: 'null' is not a 3.0 type (3.0 expresses nullability only via nullable). So we are emitting keywords the declared dialect does not define.

Worse, the stacked follow-up #1944 widens it — it carries const down into nested objects and array items too, and I documented const as a supported hint in the docs companion (documentation#605). All three need to change together.

Neither this PR nor #1944 addresses it, and I don't think it should be bolted onto either as a side fix — picking between "translate to 3.0 forms" (const → single-value enum, drop type: 'null' in favor of nullable, nullable-enum needs null appended to the list) and "declare 3.1" (which changes the contract for every existing consumer of the document, not just static-properties resources) is a real decision with downstream impact. Raising it with Kyle to scope as its own change, with the spec-validation test you suggest — that test is the thing that would have caught this and is worth having regardless of which direction we take.

Comment generated by kAIle (Claude Opus 4.8)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed across all three artifacts, taking the conservative direction — translate to valid 3.0.3 forms rather than declaring 3.1, since upgrading the document changes the contract for every existing consumer, not just static-properties resources. If you'd rather go to 3.1, that's a separate decision and this doesn't foreclose it.

  • harper#1921 (d10658161): const is emitted as the equivalent single-value enum; a bare type: 'null' becomes an untyped { nullable: true }, since 3.0 expresses nullability only via the keyword.
  • harper#1944 (d00549032): the same translation inside the shared emitter, so it holds at depth — the dialect branch lives in attributeToSchema, with MCP keeping const (it speaks current JSON Schema) and OpenAPI getting enum.
  • documentation#605 (cfdbab61): the reference and guide told authors const surfaces as written, which was never true. They now say: write const, expect enum: [value] in the OpenAPI output.

Your spec-validation suggestion is the part that earned its keep. I wrote it to walk the entire generated document for post-draft-04 keywords and null types rather than assert one property — and it immediately failed on #1944, catching six nested and array-item paths (components.schemas.Widget.properties.nested.properties.inner.const, …list.items.const, and the parameter copies) that my top-level fix had missed. Without that test I'd have shipped a half-fix and called it done.

Still outstanding from your other thread and not addressed here: nullable enums need null in the value list to actually accept null, since nullable: true alone doesn't extend an enum. Happy to fold that in if you want it on this PR, or leave it with the genuine-union oneOf work.

Comment generated by kAIle (Claude Opus 4.8).

} else if (fragment.type != null) {
attr.type = fragment.type;
}
if (fragment.description) attr.description = fragment.description;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we not use Object.assign?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Happy to drop it. For context on where it lives now: the stacked follow-up #1944 has one Object.assign, merging the per-surface primitive mapping into the fragment being built (Object.assign(fragment, options.mapPrimitive(attr.type, attr))). It is there because each surface's mapper returns a variable shape — MCP's Date yields { type: ['string','number'], description } and Bytes yields { type, contentEncoding }, so there is no fixed field list to copy.

If the objection is the opacity, I can replace it with explicit field copies for the keys we actually support, which also stops an unexpected key from a mapper leaking into the emitted schema. Say which you prefer and I will make the change on #1944.

Comment generated by kAIle (Claude Opus 4.8)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropped it — 61d2773 on #1944 copies the mapper result field by field instead.

Worth noting what that turned up: the wholesale merge was letting contentEncoding into emitted MCP schemas even though JsonSchemaFragment never declared it — the as JsonSchemaFragment cast on the mapper hid it. It is declared explicitly now. That is exactly the leak the explicit copy prevents, so this was the right call rather than just a style preference.

Comment generated by kAIle (Claude Opus 4.8)

The document declares OpenAPI 3.0.3, whose Schema Object is the JSON Schema
draft-04 subset. `const` arrived in draft-06 and `'null'` is not a 3.0 type, so
both are keywords the declared dialect does not define — strict validators
reject the result.

- `const` is emitted as the equivalent single-value `enum`.
- A `type: 'null'` property becomes an untyped `{ nullable: true }`, 3.0's only
  expression of nullability.

Adds a dialect-compliance test that walks the entire generated document rather
than checking a single property, so any future emit path reaching for a newer
keyword fails here instead of in a consumer's tooling.

Raised by kriszyp in review of #1921.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188G62J9fZQg4J9rVuqLzjy
Comment thread resources/openApi.ts Outdated
… paths too

The previous commit patched only the top-level scalar branch, so this PR
standalone still emitted `type: "null"` inside nested objects and array items,
and leaked Harper's `hidden`/`primaryKey` directives as schema keywords through
the canonical projection. Its dialect test checked only for `type: "null"` and
so passed on all of it — `type: 'Text'` and `type: 'Any'` sailed through too.

`attributeToFragment` can't be bent to fix this: its output is also
`Table.properties`, which must stay front-end-neutral. Translate on the way out
instead — `toOpenApiDialect` walks an emitted fragment recursively, dropping
Harper directives, resolving `'null'`/type-unions to `nullable`, and converting
`const` to a single-value `enum`.

Also: an author-declared `format` now outranks the Harper type name, so
`{ type: 'Date', format: 'date-time' }` stops emitting `format: Date`.

The test now asserts the closed six-value type enum, forbids type arrays and
Harper directives, walks the serialized document (so it judges what a consumer
receives rather than undefined-valued keys that vanish on the wire), and asserts
the fixture's properties exist so the walks can't pass vacuously.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188G62J9fZQg4J9rVuqLzjy
Comment thread resources/openApi.ts
The scalar branch emitted the type alone, so a `static properties` field
declared `{ type: 'string', nullable: true }` (or `['string','null']`, which
folds to the same attribute) produced a document asserting the field rejects
null — while MCP kept it nullable. The nested/array paths already translated
this through toOpenApiDialect; only the top-level path did not.

Also intersects `const` with a co-declared `enum` rather than deferring to the
enum, matching the nested path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188G62J9fZQg4J9rVuqLzjy
Comment thread resources/openApi.ts
…first member

`static properties` speaks JSON Schema, which has type unions, but the attribute
form it projects into has a single `type` — so `['string','number']` silently
became string-only and `['null']` became untyped.

Attributes now carry the source union on `types` (`type` still holds the first
non-null member for single-type consumers). Each surface then expresses it in
its own dialect: MCP passes the array through, OpenAPI 3.0 emits `oneOf`, since
3.0 has neither type arrays nor a `null` type.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188G62J9fZQg4J9rVuqLzjy
kylebernhardy pushed a commit that referenced this pull request Jul 27, 2026
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
Comment thread resources/openApi.ts
…th too

The top-level scalar path translated a bare `type: 'null'`; the nested-object
and array-item paths deleted the type and said nothing, so the same declaration
described a different contract depending on where it appeared.

All three now emit a null-only `enum` alongside `nullable`. 3.0 has no `'null'`
type and a bare `nullable` on an untyped schema constrains nothing, so the enum
is what actually carries the author's meaning.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188G62J9fZQg4J9rVuqLzjy

@kriszyp kriszyp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good. A few mechanical issues to work through.
🤖 Reviewed with GPT 5.6

*/
function fragmentToAttribute(name: string, fragment: JsonSchemaFragment): AttributeLike {
const attr: AttributeLike = { name };
if (fragment.properties) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This shape-first branch runs before the union branch, so structural unions do not survive the projection. For example, { type: ['object', 'null'], properties: { x: { type: 'string' } } } takes this branch and loses both types and nullable; { type: ['array', 'null'], items: { type: 'string' } } takes the union branch later but never records elements, so it becomes an unconstrained array. Both MCP and OpenAPI then advertise a different contract from the declared JSON Schema. Please process the type union orthogonally to properties/items (and add round-trip cases for nullable object and array schemas) so both the structural shape and every union member survive.

Comment thread resources/openApi.ts
} else if (union) {
// A genuine multi-type union (`['string','number']`). 3.0 has no type arrays, so the
// equivalent is `oneOf`; `attr.type` alone would drop every member but the first.
props[name] = { oneOf: union.map(openApiUnionMember) };

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For type: ['string', 'integer', 'null'], this creates an outer { oneOf: [...] }, and line 301 later adds nullable: true to that outer object. In OpenAPI 3.0.3, nullable only affects a type explicitly declared in the same Schema Object; this outer object has no type, so strict consumers still reject null. Please encode null in a branch that can actually admit it (for example, make exactly one typed oneOf member nullable) and validate the generated schema against a null instance in the test.

type: 'object',
properties: Object.fromEntries(attr.properties.map((p) => [p.name, attributeToProperty(p)])),
};
if (attr.required) base.required = attr.required;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The recursive mapping bypasses attributeVisible, so hidden is enforced only at the top level. A declaration such as profile.properties.secret = { type: 'string', hidden: true, description: 'internal' } exposes secret and its description to MCP clients; if secret is in the nested required array, clients are also told they must send the hidden field. OpenAPI already filters this case. Please suppress hidden attributes at every recursive depth and prune suppressed names from the corresponding required list.

Comment thread resources/openApi.ts
} else if (attr.properties) {
// Nested object from `static properties` — the shared projector emits the full object
// schema (sub-properties recursed); OpenAPI's table path uses $refs instead.
props[name] = toOpenApiDialect(attributeToFragment(attr));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

attributeToFragment deliberately omits enum, format, and const because it also feeds the front-end-neutral Table.properties projection. Using it as the consumer emitter therefore drops those constraints from every nested object; the type-only array branches likewise drop item-level hints. For example, a nested { type: 'string', enum: ['open', 'closed'] } or array item with const/format is retained by MCP but disappears from OpenAPI. Please use a consumer-facing recursive emitter (or propagate these hints explicitly at every depth) and add convergence assertions for nested and item-level hints.

const attributes = (ResourceClass?.attributes ?? []) as HarperAttribute[];
// A programmatic Resource may declare `static properties` without an `attributes` Array; resolve
// the effective attributes so its verb tools get a rich inputSchema instead of a skeletal one.
const attributes = resolveAttributes(ResourceClass) as HarperAttribute[];

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Projected JSON-Schema properties normally have nullable === undefined, but deriveCreateSchema interprets !attr.nullable as required. Consequently { id: { primaryKey: true, type: 'string' }, label: { type: 'string' } } yields MCP required: ['label'], while this PR's OpenAPI path only adds a property when nullable === false and leaves label optional. MCP clients can reject otherwise valid create calls before dispatch. Please carry presence/requiredness explicitly through the projection, or otherwise align the undefined case across both surfaces without conflating optionality with nullability.

Comment thread resources/openApi.ts
if (elements.type === 'Any') {
if (!elements) {
// `{ type: 'array' }` with no items — valid JSON Schema (array of anything).
props[name] = { type: 'array' };

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Although { type: 'array' } is valid general JSON Schema, OpenAPI 3.0.3 specifically requires items whenever type is array. Emitting the bare schema makes the generated 3.0.3 document invalid for strict tooling. Please translate an unconstrained array to { type: 'array', items: {} } (and do the same for array union members), then assert the OpenAPI-specific requirement rather than the source-dialect rule.

Comment thread resources/openApi.ts
// scalar path emits the type alone and the document claims the field rejects null.
if (nullable) prop.nullable = true;
// 3.0's `nullable` does not widen an `enum` — without `null` in the list a validator rejects it.
if (prop.nullable && Array.isArray(prop.enum) && !prop.enum.includes(null)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This unconditionally changes the intersection semantics of a standard JSON-Schema union. { type: ['string', 'null'], enum: ['a'] } rejects null because every keyword must match, but projection derives nullable: true from the type union and this line rewrites the enum to ['a', null]; the same issue occurs with const. Please preserve whether null came from the authored type union versus an explicit Harper nullable declaration, and do not add null to an enum/const that excluded it.

kriszyp pushed a commit that referenced this pull request Jul 31, 2026
…ed user (#1940)

`makeVisibleTo` returned false for a Resource with no databaseName/tableName,
so every non-super user saw none of its verb tools in tools/list. That hid
exactly the Resources #1920/#1921 taught to produce rich schemas, and hid
nothing meaningful: tools/call already accepts these tools by name, so the
gate cost discoverability without buying access control.

Return true instead and let the Resource's own allow* predicates enforce at
call time — the contract custom `mcpTools` have always had (they have never
had a listing filter beyond authentication). The table-backed path is
unchanged and still gates on per-table permissions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0188G62J9fZQg4J9rVuqLzjy
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[MCP] #1095 follow-ups: programmatic static-properties schema wiring + MCP/convergence test coverage

3 participants