Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion components/mcp/resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import harperLogger from '../../utility/logging/harper_logger.ts';
import { AccessViolation } from '../../utility/errors/hdbError.ts';
import { SERVER_CAPABILITIES, SERVER_INFO, SUPPORTED_PROTOCOL_VERSIONS } from './lifecycle.ts';
import { encodeCursor } from './pagination.ts';
import { resolveAttributes } from '../../resources/jsonSchemaTypes.ts';
import {
customResourceCompletionValues,
listCustomResources,
Expand Down Expand Up @@ -820,7 +821,9 @@ function readTableSchema(db: string, table: string, user: AuthedUser, href: stri
}
}
if (!resource) return { ok: false, reason: `table not found: ${db}.${table}` };
const attributes = resource.attributes ?? [];
// Fall back to a programmatic Resource's `static properties` when it declares no attributes Array,
// so schema introspection matches the tool/OpenAPI surfaces.
const attributes = resolveAttributes(resource);
const filteredAttributes = filterAttributesByPermissions(attributes, perm?.attribute_permissions);
const body = {
database: db,
Expand Down
5 changes: 4 additions & 1 deletion components/mcp/tools/application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
} from '../customResourceRegistry.ts';
import { notifyPromptsListChanged, notifyResourcesListChanged, notifyToolsListChanged } from '../listChanged.ts';
import { decodeCursor, encodeCursor } from '../pagination.ts';
import { resolveAttributes } from '../../../resources/jsonSchemaTypes.ts';
import {
type AttributePermissionEntry,
type HarperAttribute,
Expand Down Expand Up @@ -1356,7 +1357,9 @@ function buildApplicationTools(resources: ResourcesRegistry): void {
const tableName = ResourceClass?.tableName;
const suffix = uniqueSuffix(path, databaseName, claimedSuffixes);
claimedSuffixes.add(suffix);
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.

if (hasVerbs) {
toolsRegistered += registerVerbTools({
path,
Expand Down
22 changes: 22 additions & 0 deletions components/mcp/tools/schemas/derive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand All @@ -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':
Expand Down Expand Up @@ -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;

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.

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;
}
Expand All @@ -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;
}

Expand Down
105 changes: 105 additions & 0 deletions resources/jsonSchemaTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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;
}

/**
Expand All @@ -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;
Expand All @@ -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;
}

Expand All @@ -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) {

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.

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];

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)

} else if (fragment.type != null) {
attr.type = fragment.type;
}
Comment thread
kylebernhardy marked this conversation as resolved.
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)

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 [];
}
Loading
Loading