-
Notifications
You must be signed in to change notification settings - Fork 11
feat(mcp/openapi): drive schemas from a programmatic Resource's static properties #1921
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
7ba6f91
6ac0d14
6539146
b895a63
1383b6e
d106581
1eb8aa2
f34cede
2039e4d
eaa755b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,12 +11,16 @@ | |
| * doesn't waste tokens on fields it can't write), NOT a security boundary. | ||
| */ | ||
|
|
||
| import { JSON_SCHEMA_SCALAR_TYPES } from '../../../../resources/jsonSchemaTypes.ts'; | ||
|
|
||
| export interface HarperAttribute { | ||
| name: string; | ||
| type?: string; | ||
| description?: string; | ||
| hidden?: boolean; | ||
| nullable?: boolean; | ||
| /** Source JSON-Schema type union from `static properties`; MCP accepts type arrays, so it passes through. */ | ||
| types?: readonly string[]; | ||
| isPrimaryKey?: boolean; | ||
| properties?: HarperAttribute[]; | ||
| elements?: HarperAttribute; | ||
|
|
@@ -25,6 +29,12 @@ export interface HarperAttribute { | |
| assignCreatedTime?: boolean; | ||
| assignUpdatedTime?: boolean; | ||
| expiresAt?: boolean; | ||
| // JSON-Schema hints a programmatic Resource may carry via `static properties`. | ||
| enum?: readonly (string | number | boolean | null)[]; | ||
| format?: string; | ||
| const?: unknown; | ||
| required?: readonly string[]; | ||
| additionalProperties?: boolean; | ||
| } | ||
|
|
||
| export interface AttributePermissionEntry { | ||
|
|
@@ -42,6 +52,9 @@ type Mode = 'read' | 'insert' | 'update'; | |
| * than blocking the field entirely; the runtime will validate. | ||
| */ | ||
| function harperTypeToJsonSchema(type: string | undefined): { type: string | string[] } | object { | ||
| // A programmatic Resource's `static properties` already speaks JSON Schema (lowercase types, no | ||
| // collision with Harper's capitalized GraphQL types); pass those through unchanged. | ||
| if (type && JSON_SCHEMA_SCALAR_TYPES.has(type)) return { type }; | ||
| switch (type) { | ||
| case 'Int': | ||
| case 'Long': | ||
|
|
@@ -79,11 +92,17 @@ function attributeToProperty(attr: HarperAttribute): object { | |
| type: 'object', | ||
| properties: Object.fromEntries(attr.properties.map((p) => [p.name, attributeToProperty(p)])), | ||
| }; | ||
| if (attr.required) base.required = attr.required; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The recursive mapping bypasses |
||
| if (attr.additionalProperties !== undefined) base.additionalProperties = attr.additionalProperties; | ||
| } else if (attr.type === 'array' && attr.elements) { | ||
| base = { | ||
| type: 'array', | ||
| items: attributeToProperty(attr.elements), | ||
| }; | ||
| } else if (attr.types) { | ||
| // MCP speaks JSON Schema, which has type unions — emit the author's union as declared rather | ||
| // than the single `type` the attribute form collapses to. | ||
| base = { type: [...attr.types] }; | ||
| } else { | ||
| base = harperTypeToJsonSchema(attr.type) as typeof base; | ||
| } | ||
|
|
@@ -97,6 +116,9 @@ function attributeToProperty(attr: HarperAttribute): object { | |
| if (attr.description && !base.description) { | ||
| base.description = attr.description; | ||
| } | ||
| if (attr.enum && !('enum' in base)) base.enum = attr.enum; | ||
| if (attr.format && !('format' in base)) base.format = attr.format; | ||
| if (attr.const !== undefined && !('const' in base)) base.const = attr.const; | ||
| return base; | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -28,8 +28,25 @@ export interface JsonSchemaFragment { | |
| additionalProperties?: boolean; | ||
| format?: string; | ||
| const?: unknown; | ||
| /** Emitted by the OpenAPI 3.0 projection for a genuine multi-type union; not authored directly. */ | ||
| oneOf?: JsonSchemaFragment[]; | ||
| } | ||
|
|
||
| /** | ||
| * The JSON-Schema scalar/structural type names. A programmatic Resource's `static properties` speaks | ||
| * JSON Schema directly (lowercase), so the MCP and OpenAPI type mappers pass these through unchanged | ||
| * rather than treating them as unknown Harper types. Shared so the two mappers can't drift apart. | ||
| */ | ||
| export const JSON_SCHEMA_SCALAR_TYPES: ReadonlySet<string> = new Set([ | ||
| 'string', | ||
| 'integer', | ||
| 'number', | ||
| 'boolean', | ||
| 'object', | ||
| 'array', | ||
| 'null', | ||
| ]); | ||
|
|
||
| export const DATA_TYPES: Record<string, JsonSchemaType> = { | ||
| Int: 'integer', | ||
| Float: 'number', | ||
|
|
@@ -56,9 +73,23 @@ export interface AttributeLike { | |
| assignCreatedTime?: boolean; | ||
| assignUpdatedTime?: boolean; | ||
| nullable?: boolean; | ||
| /** | ||
| * The source JSON-Schema type union, verbatim, when `static properties` declared one. `type` holds | ||
| * the first non-null member so single-type consumers keep working; surfaces that can express a | ||
| * union (MCP passes it through, OpenAPI 3.0 translates it to `oneOf`) read this instead. | ||
| */ | ||
| types?: readonly string[]; | ||
| elements?: AttributeLike; | ||
| /** Sub-attributes of a nested object field (the same array form `Table.validate` iterates). */ | ||
| properties?: AttributeLike[]; | ||
| // JSON-Schema-only hints an author may declare on `static properties`; carried through the | ||
| // projection so they survive the properties <-> attributes round-trip. | ||
| enum?: readonly (string | number | boolean | null)[]; | ||
| format?: string; | ||
| const?: unknown; | ||
| /** Object-level constraints for a nested object field, carried through the round-trip. */ | ||
| required?: readonly string[]; | ||
| additionalProperties?: boolean; | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -79,9 +110,15 @@ export function attributeToFragment(attr: AttributeLike): JsonSchemaFragment { | |
| fragment.type = 'object'; | ||
| fragment.properties = {}; | ||
| for (const sub of attr.properties) fragment.properties[sub.name] = attributeToFragment(sub); | ||
| if (attr.required) fragment.required = attr.required; | ||
| if (attr.additionalProperties !== undefined) fragment.additionalProperties = attr.additionalProperties; | ||
| } else if (attr.type === 'array' && attr.elements) { | ||
| fragment.type = 'array'; | ||
| fragment.items = attributeToFragment(attr.elements); | ||
| } else if (attr.types) { | ||
| // A declared union round-trips verbatim; collapsing it to `attr.type` here would make the | ||
| // canonical `Table.properties` disagree with what the author wrote. | ||
| fragment.type = [...attr.types] as JsonSchemaType[]; | ||
| } else { | ||
| const jsonType = attr.type ? DATA_TYPES[attr.type] : undefined; | ||
| if (jsonType) fragment.type = jsonType; | ||
|
|
@@ -93,6 +130,10 @@ export function attributeToFragment(attr: AttributeLike): JsonSchemaFragment { | |
| if (attr.assignUpdatedTime) fragment.assignUpdatedTime = true; | ||
| if (attr.hidden) fragment.hidden = true; | ||
| if (attr.nullable) fragment.nullable = true; | ||
| // NOTE: enum/format/const are deliberately NOT emitted here. This projector feeds the canonical, | ||
| // front-end-neutral `Table.properties` Record, where a code-first `types.enum` column must stay | ||
| // identical to its GraphQL `String` equivalent (types.enum is advisory — see defineTable.ts). The | ||
| // MCP/OpenAPI schema paths (derive.ts / openApi.ts) surface those hints for programmatic Resources. | ||
| return fragment; | ||
| } | ||
|
|
||
|
|
@@ -108,3 +149,67 @@ export function projectAttributesToProperties(attributes: AttributeLike[]): Reco | |
| } | ||
| return result; | ||
| } | ||
|
|
||
| /** | ||
| * Structural inverse of `attributeToFragment`: rebuild an attribute from a JSON Schema fragment. | ||
| * A programmatic Resource may declare `static properties` (the Record form) without populating the | ||
| * `attributes` Array; the schema-derivation paths (MCP `derive.ts`, OpenAPI) read attributes, so a | ||
| * bare declaration would otherwise yield a skeletal schema. Projecting the fragments back into | ||
| * attributes lets those paths produce the same rich schema they build for table-backed resources. | ||
| */ | ||
| function fragmentToAttribute(name: string, fragment: JsonSchemaFragment): AttributeLike { | ||
| const attr: AttributeLike = { name }; | ||
| if (fragment.properties) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, |
||
| attr.properties = Object.entries(fragment.properties).map(([subName, sub]) => fragmentToAttribute(subName, sub)); | ||
| if (fragment.required) attr.required = fragment.required; | ||
| if (fragment.additionalProperties !== undefined) attr.additionalProperties = fragment.additionalProperties; | ||
| } else if (fragment.type === 'array' && fragment.items) { | ||
| attr.type = 'array'; | ||
| // The element attribute's name is unused (attributeToFragment ignores it); keep it empty rather | ||
| // than misleadingly reusing the array field's own name. | ||
| attr.elements = fragmentToAttribute('', fragment.items); | ||
| } else if (Array.isArray(fragment.type)) { | ||
| // JSON-Schema union type. Keep the source union on `types` so surfaces that can express one | ||
| // (MCP natively, OpenAPI 3.0 via `oneOf`) don't have to reconstruct it, and fold a `'null'` | ||
| // member into `nullable` as well since that is the form OpenAPI needs. `type` carries the first | ||
| // non-null member for the single-type consumers (validation, query coercion) that read it. | ||
| attr.types = fragment.type; | ||
| 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]; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could we avoid dropping every non-first union member here?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Partly addressed, partly not — splitting the two cases:
A genuine multi-type union (
Comment generated by kAIle (Claude Opus 4.8)
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
The projection round-trips too: Consumer-level regression tests as you asked: Comment generated by kAIle (Claude Opus 4.8) |
||
| } else if (fragment.type != null) { | ||
| attr.type = fragment.type; | ||
| } | ||
|
kylebernhardy marked this conversation as resolved.
|
||
| if (fragment.description) attr.description = fragment.description; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we not use Object.assign?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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)
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Comment generated by kAIle (Claude Opus 4.8) |
||
| if (fragment.primaryKey) attr.isPrimaryKey = true; | ||
| if (fragment.assignCreatedTime) attr.assignCreatedTime = true; | ||
| if (fragment.assignUpdatedTime) attr.assignUpdatedTime = true; | ||
| if (fragment.hidden) attr.hidden = true; | ||
| if (fragment.nullable) attr.nullable = true; | ||
| if (fragment.enum) attr.enum = fragment.enum; | ||
| if (fragment.format) attr.format = fragment.format; | ||
| if (fragment.const !== undefined) attr.const = fragment.const; | ||
| return attr; | ||
| } | ||
|
|
||
| /** | ||
| * Project a `Record<string, JsonSchemaFragment>` (the `static properties` form) back into the | ||
| * `Attribute[]` Array the schema-derivation paths consume. Inverse of `projectAttributesToProperties`. | ||
| */ | ||
| export function projectPropertiesToAttributes(properties: Record<string, JsonSchemaFragment>): AttributeLike[] { | ||
| return Object.entries(properties).map(([name, fragment]) => fragmentToAttribute(name, fragment)); | ||
| } | ||
|
|
||
| /** | ||
| * The effective attribute Array for a Resource/Table class: its declared `attributes` when present, | ||
| * otherwise the projection of a bare `static properties` declaration. Keeps MCP and OpenAPI schema | ||
| * derivation identical for table-backed and programmatic Resources. | ||
| */ | ||
| export function resolveAttributes(source?: { | ||
| attributes?: AttributeLike[]; | ||
| properties?: Record<string, JsonSchemaFragment>; | ||
| }): AttributeLike[] { | ||
| if (source?.attributes?.length) return source.attributes; | ||
| if (source?.properties) return projectPropertiesToAttributes(source.properties); | ||
| return []; | ||
| } | ||
There was a problem hiding this comment.
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, butderiveCreateSchemainterprets!attr.nullableas required. Consequently{ id: { primaryKey: true, type: 'string' }, label: { type: 'string' } }yields MCPrequired: ['label'], while this PR's OpenAPI path only adds a property whennullable === falseand leaveslabeloptional. 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.