Skip to content
Open
105 changes: 56 additions & 49 deletions components/mcp/tools/schemas/derive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,13 @@
* doesn't waste tokens on fields it can't write), NOT a security boundary.
*/

import { JSON_SCHEMA_SCALAR_TYPES } from '../../../../resources/jsonSchemaTypes.ts';
import {
JSON_SCHEMA_SCALAR_TYPES,
attributeToSchema,
resolveDeclaredType,
type AttributeLike,
type JsonSchemaFragment,
} from '../../../../resources/jsonSchemaTypes.ts';

export interface HarperAttribute {
name: string;
Expand Down Expand Up @@ -51,7 +57,10 @@ type Mode = 'read' | 'insert' | 'update';
* types when nullable). Falls back to "string" for unknown types — better
* than blocking the field entirely; the runtime will validate.
*/
function harperTypeToJsonSchema(type: string | undefined): { type: string | string[] } | object {
function harperTypeToJsonSchema(
type: string | undefined,
attributeName?: string
): { 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 };
Expand All @@ -76,50 +85,44 @@ function harperTypeToJsonSchema(type: string | undefined): { type: string | stri
case 'Any':
case undefined:
return {};
default:
return { type: 'string' };
default: {
// Neither a Harper type nor a JSON Schema type. This used to coerce to `string` while OpenAPI
// emitted `{}` — two different wrong answers from one typo. Warn (once per name) and emit
// untyped, matching OpenAPI (#1942).
const resolved = resolveDeclaredType(type, `MCP tool schema property "${attributeName ?? '<unnamed>'}"`);
return resolved ? { type: resolved } : {};
}
}
}

function attributeToProperty(attr: HarperAttribute): object {
let base: { type?: string | string[]; description?: string; [key: string]: unknown };
// The GraphQL parser emits nested objects via `.properties` (not a capitalized `'Object'` type) and
// list types as lowercase `type: 'array'` with `.elements` — the prior `'Object'`/`'Array'` literal
// checks never matched, so nested shapes fell through to a bare `{ type: 'string' }`. Detect them the
// way the parser actually emits, matching the shared `attributeToFragment` projector.
if (attr.properties) {
base = {
type: 'object',
properties: Object.fromEntries(attr.properties.map((p) => [p.name, attributeToProperty(p)])),
};
if (attr.required) base.required = attr.required;
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;
}
if (attr.nullable && 'type' in base) {
const t = base.type;
const types = Array.isArray(t) ? t : [t as string];
if (!types.includes('null')) {
base.type = [...types, 'null'];
}
}
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;
/**
* Emit an attribute as an MCP property schema. The traversal (nesting, arrays, `hidden` suppression,
* hint propagation, nullability) is shared with the OpenAPI generator so the two can't describe the
* same fragment differently; only the Harper-primitive mapping below is MCP-specific.
*
* Returns `undefined` for a `hidden` attribute — callers skip it.
*/
function attributeToProperty(attr: HarperAttribute): object | undefined {
return attributeToSchema(attr as AttributeLike, {
dialect: 'json-schema',
mapPrimitive: (type, a) => harperTypeToJsonSchema(type, a.name) as JsonSchemaFragment,
});
}

/**
* The `id` argument's schema. Verb tools surface the primary key as the record's address, not as one
* of its fields, so a `@hidden` primary key still has to be typed here — otherwise the tool advertises
* a required argument with no type at all. Falls back only when there is no primary key to describe.
*/
function primaryKeySchema(pk: HarperAttribute | undefined): object {
if (!pk) return { type: 'string' };
return (
attributeToSchema(pk as AttributeLike, {
dialect: 'json-schema',
mapPrimitive: (type, a) => harperTypeToJsonSchema(type, a.name) as JsonSchemaFragment,
ignoreHidden: true,
}) ?? { type: 'string' }
);
}

/**
Expand Down Expand Up @@ -173,7 +176,9 @@ function buildPropertiesObject(
// Skip auto-managed columns from write inputs — Harper assigns them.
if (mode !== 'read' && (attr.assignCreatedTime || attr.assignUpdatedTime || attr.expiresAt)) continue;
if (mode !== 'read' && (attr.computed !== undefined || attr.computedFromExpression !== undefined)) continue;
properties[attr.name] = attributeToProperty(attr);
const schema = attributeToProperty(attr);
if (!schema) continue;
properties[attr.name] = schema;
if (mode === 'insert' && !attr.nullable && !attr.isPrimaryKey) {
required.push(attr.name);
}
Expand All @@ -190,7 +195,7 @@ export function deriveGetSchema(
_permissions: AttributePermissionEntry[] | undefined
): object {
const pk = findPrimaryKey(attributes);
const pkSchema = pk ? attributeToProperty(pk) : { type: 'string' };
const pkSchema = primaryKeySchema(pk);
return {
type: 'object',
properties: {
Expand Down Expand Up @@ -278,7 +283,7 @@ export function deriveUpdateSchema(
type: 'object',
properties: {
id: pk
? { ...attributeToProperty(pk), description: `Primary key (${pk.name}). Required.` }
? { ...primaryKeySchema(pk), description: `Primary key (${pk.name}). Required.` }
: { type: 'string', description: 'Primary key. Required.' },
...properties,
},
Expand All @@ -295,7 +300,7 @@ export function deriveDeleteSchema(
type: 'object',
properties: {
id: pk
? { ...attributeToProperty(pk), description: `Primary key (${pk.name}).` }
? { ...primaryKeySchema(pk), description: `Primary key (${pk.name}).` }
: { type: 'string', description: 'Primary key.' },
},
required: ['id'],
Expand Down Expand Up @@ -323,7 +328,9 @@ function deriveRecordSchema(
const required: string[] = [];
for (const attr of attributes) {
if (!attributeVisible(attr, permissions, 'read')) continue;
properties[attr.name] = attributeToProperty(attr);
const schema = attributeToProperty(attr);
if (!schema) continue;
properties[attr.name] = schema;
const requiredOnOutput =
attr.nullable === false || attr.assignCreatedTime || attr.assignUpdatedTime || attr.isPrimaryKey;
if (requiredOnOutput) required.push(attr.name);
Expand Down Expand Up @@ -375,7 +382,7 @@ export function deriveCreateOutputSchema(
_permissions: AttributePermissionEntry[] | undefined
): object {
const pk = findPrimaryKey(attributes);
const idSchema = pk ? attributeToProperty(pk) : { type: 'string' };
const idSchema = primaryKeySchema(pk);
return {
type: 'object',
properties: {
Expand Down
106 changes: 106 additions & 0 deletions integrationTests/resources/openapi-static-properties.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* OpenAPI emission for a programmatic Resource's `static properties` (#1941/#1942).
*
* The existing `apiTests/open-api.test.mjs` asserts the document's *structure* — that `paths` and
* `components.schemas` exist. Nothing asserted the emitted property schemas, so a wrong `nullable`, a
* leaked `@hidden` field, or a dropped nested `enum` would ship green. These emitter behaviors are
* per-surface and easy to regress silently, so they are pinned against the real document here.
*
* Skipped on Windows for the same reason as the sibling suite: `restart_service http_workers` after a
* component install crashes Harper on the single-worker model (HarperFast/harper#549).
*/
import { suite, test, before, after } from 'node:test';
import assert from 'node:assert';
import { startHarper, teardownHarper } from '@harperfast/integration-testing';
import { createApiClient } from '../apiTests/utils/client.mjs';
import { installAppComponent } from '../apiTests/utils/components.mjs';

const RESOURCES_JS = `export class OrderSummary extends Resource {
\tstatic description = 'Rolled-up order totals.';
\tstatic primaryKey = 'orderId';
\tstatic properties = {
\t\torderId: { type: 'string', primaryKey: true, description: 'Order id.' },
\t\tstatus: { type: 'string', enum: ['open', 'closed'], description: 'Fulfillment state.' },
\t\tnote: { type: ['string', 'null'], description: 'Nullable note.' },
\t\tprofile: {
\t\t\ttype: 'object',
\t\t\tproperties: {
\t\t\t\tname: { type: 'string', description: 'Visible name.' },
\t\t\t\tcreditScore: { type: 'integer', hidden: true, description: 'INTERNAL-ONLY-MARKER' },
\t\t\t},
\t\t\trequired: ['name', 'creditScore'],
\t\t},
\t\tallHidden: { type: 'object', properties: { secret: { type: 'string', hidden: true } }, required: ['secret'] },
\t\ttags: { type: 'array', items: { type: 'string', enum: ['x', 'y'], description: 'Tag.' } },
\t};
\tget() {
\t\treturn { orderId: 'o1' };
\t}
}
`;

const CONFIG_YAML = `rest: true
jsResource:
files: resources.js
`;

const skipSuite = process.platform === 'win32';

suite('OpenAPI — static properties emission', { skip: skipSuite }, (ctx) => {
let client;
let schema;

before(async () => {
await startHarper(ctx, { config: {}, env: {} });
client = createApiClient(ctx.harper);
await installAppComponent(client, {
project: 'staticPropsApp',
files: { 'resources.js': RESOURCES_JS, 'config.yaml': CONFIG_YAML },
probePath: '/OrderSummary/',
restartTimeoutMs: 120000,
});
const r = await client.reqRest('/openapi').expect(200);
schema = r.body.components.schemas.OrderSummary;
assert.ok(schema, `OrderSummary schema missing from the document: ${r.text.slice(0, 400)}`);
});

after(async () => {
await teardownHarper(ctx);
});

test('emits per-property type, description, and enum from static properties', () => {
assert.equal(schema.properties.status.type, 'string');
assert.equal(schema.properties.status.description, 'Fulfillment state.');
assert.deepEqual(schema.properties.status.enum, ['open', 'closed']);
});

test('emits OpenAPI `nullable` for a top-level nullable property', () => {
// OpenAPI 3.0.3 has no union types. This was computed only to derive `required` and never
// emitted, so a nullable property was advertised as non-nullable (#1942).
assert.equal(schema.properties.note.type, 'string');
assert.equal(schema.properties.note.nullable, true);
});

test('suppresses a @hidden sub-property of a nested object, and its description', () => {
assert.ok(schema.properties.profile.properties.name, 'visible sub-property kept');
assert.equal(schema.properties.profile.properties.creditScore, undefined, 'hidden sub-property emitted');
});

test('never emits `hidden` as a schema key, and never leaks a hidden description', () => {
const doc = JSON.stringify(schema);
assert.equal(doc.includes('"hidden"'), false, `hidden directive leaked into the document: ${doc}`);
assert.equal(doc.includes('INTERNAL-ONLY-MARKER'), false, 'hidden sub-property description leaked');
});

test('drops a suppressed name from `required`, and omits `required` when nothing survives', () => {
// `required: []` is invalid under draft-04 (which OpenAPI 3.0.3 inherits).
assert.deepEqual(schema.properties.profile.required, ['name']);
assert.equal('required' in schema.properties.allHidden, false, 'empty required must be omitted');
assert.equal('required' in schema, false, 'top-level empty required must be omitted');
});

test('carries enum and description into array items', () => {
assert.deepEqual(schema.properties.tags.items.enum, ['x', 'y']);
assert.equal(schema.properties.tags.items.description, 'Tag.');
});
});
Loading
Loading