From 7fea797e22a04c5307af55dc51d3e81db44d5f96 Mon Sep 17 00:00:00 2001 From: Kyle Bernhardy Date: Fri, 24 Jul 2026 15:20:22 -0600 Subject: [PATCH 1/5] docs(resources): document static properties as a first-class MCP/OpenAPI schema source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Harper 5.2.0 makes a programmatic Resource's `static properties` drive the MCP tool schemas, the OpenAPI document, and `harper://schema` — previously those surfaces read only the internal `attributes` Array, so a bare `static properties` declaration produced a skeletal `{ type: 'object' }`. 5.2.0 also resolves `GET /Resource/id.property` against `static properties`. Documents the authoring path: the JSON Schema vocabulary (lowercase types, distinct from the capitalized GraphQL names), the full fragment key list, how unions/item-less arrays/optional properties resolve, and a nested + array-of-object example. Companion to HarperFast/harper#1921 and #1933; closes HarperFast/harper#1923. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_0188G62J9fZQg4J9rVuqLzjy --- learn/developers/mcp-and-openapi-metadata.mdx | 53 +++++++++++++- reference/mcp/tool-metadata.md | 22 +++--- reference/resources/resource-api.md | 70 ++++++++++++++++++- reference/rest/overview.md | 2 +- 4 files changed, 134 insertions(+), 13 deletions(-) diff --git a/learn/developers/mcp-and-openapi-metadata.mdx b/learn/developers/mcp-and-openapi-metadata.mdx index aea29a39..1f911d6e 100644 --- a/learn/developers/mcp-and-openapi-metadata.mdx +++ b/learn/developers/mcp-and-openapi-metadata.mdx @@ -116,6 +116,8 @@ For `search_Product`, the `conditions[].attribute` field becomes a closed `enum` ## Path B: Programmatic Resources via class-level statics + + For Resources without `@table @export` backing — Resource subclasses that override `get`/`post`/`put`/`delete` directly, or that aggregate across multiple tables — there's no GraphQL schema to derive from. Declare the same metadata directly on the class as JSON-Schema-shaped statics. The MCP and OpenAPI layers read both surfaces uniformly. ```typescript @@ -146,6 +148,53 @@ export class ProductInventory extends Resource { } ``` +MCP `tools/list` returns the same shape a table-backed Resource produces — types, per-property descriptions, and the `enum` — rather than an untyped `{ "type": "object" }`: + +```json +{ + "name": "get_ProductInventory", + "description": "Aggregate inventory analytics computed over the Product catalog. Read-only; the underlying Product table is the system of record.\n\nFetches a single ProductInventory record by sku. Runtime RBAC (allowGet) enforces per-record access at call time.", + "inputSchema": { + "type": "object", + "properties": { + "id": { "type": "string", "description": "Stock keeping unit; matches Product.sku." }, + "get_attributes": { "type": "array", "items": { "type": "string" } } + }, + "required": ["id"] + }, + "outputSchema": { + "type": "object", + "properties": { + "sku": { "type": "string", "description": "Stock keeping unit; matches Product.sku." }, + "onHand": { "type": "integer", "description": "Current warehouse count." }, + "reserved": { "type": "integer", "description": "Units allocated to open orders but not yet shipped." }, + "stockStatus": { + "type": "string", + "enum": ["in_stock", "out_of_stock", "backorder"], + "description": "Derived from onHand vs reserved." + } + } + }, + "annotations": { "readOnlyHint": true } +} +``` + +`/openapi.json` picks up the same properties, and the `harper://schema` MCP resource reports them for introspection. + +### Watch the vocabulary: lowercase JSON Schema, not GraphQL + +`static properties` is JSON Schema. Types are lowercase — `string`, `integer`, `number`, `boolean`, `object`, `array`, `null` — not the capitalized GraphQL names (`String`, `Int`, `Long`) you write in a `.graphql` schema. Writing `type: 'String'` in a `static properties` fragment is the most common mistake here; Harper can't map it and the property degrades to an untyped entry. + +Beyond `type` and `description`, the fragments carry `enum` / `format` / `const` for value constraints, `items` for arrays (including arrays of objects), and `properties` / `required` / `additionalProperties` for nested objects. See the [Resource API reference](/reference/v5/resources/resource-api#static-properties-recordstring-jsonschemafragment) for the full key list and how unions and item-less arrays resolve. + +### Authoring rubric, Path B edition + +- **Describe every property.** The rubric from Path A applies verbatim — meaning over type, units and formats spelled out, short. +- **Use `enum` wherever the value set is closed.** It's the single highest-leverage hint for an LLM: it turns "pass a status string" into "pass one of these four." +- **Add `format`** (`date-time`, `uuid`, `email`, ...) where it applies. It reaches Swagger UI and gives the LLM a concrete shape to emit. +- **Declare a `primaryKey` property.** It's what the `get_*` / `delete_*` tools bind their `id` argument to and what the OpenAPI path parameter is derived from. +- **Reach for `static outputSchemas`** when a verb returns a projection rather than the full record — otherwise the output schema mirrors `static properties`. + See the [Resource API reference](/reference/v5/resources/resource-api#class-level-metadata-for-mcp-and-openapi) for the full surface, including `static outputSchemas` for per-verb projection overrides, `static hidden` for full suppression, and `static mcp` for narrow MCP-only annotation overrides. ## Inheritance: extending a table @@ -196,7 +245,9 @@ For OpenAPI, the document is global and not per-user filtered. Use `@hidden` (or ## Verifying the end-to-end flow -1. Add `"""docstrings"""` to a `@table @export` type and save your component. +1. Add `"""docstrings"""` to a `@table @export` type — or `static description` + `static properties` to a programmatic Resource — and save your component. 2. Hit MCP `tools/list` for the application profile — confirm `get_*`, `search_*`, etc. descriptions include the type docstring and per-attribute descriptions are present in the `inputSchema` and `outputSchema`. 3. Hit `/openapi.json` on the application HTTP port — confirm the path-level descriptions and per-property descriptions show up in Swagger UI / Redoc. 4. Add `@hidden` to an attribute — confirm it disappears from both surfaces while remaining queryable via direct REST/SQL. + +For a programmatic Resource, an untyped `{ "type": "object" }` in step 2 usually means the fragments never resolved — check that the types are lowercase JSON Schema names and that `static properties` is a `Record` keyed by property name, not an array. diff --git a/reference/mcp/tool-metadata.md b/reference/mcp/tool-metadata.md index 0ee2dbb8..effff560 100644 --- a/reference/mcp/tool-metadata.md +++ b/reference/mcp/tool-metadata.md @@ -48,15 +48,17 @@ Operations registered outside core (for example, `cluster_status` from harper-pr For verb tools generated from exported Resources: -| Field | Source | -| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `name` | `${verb}_${sanitized-path}` (e.g. `get_Product`, `search_Customer`) | -| `description` | Composed: `[ResourceClass.description \n\n] ${verb sentence} ${runtime RBAC note}` | -| `inputSchema` | Derived per verb from `ResourceClass.attributes` and the caller's `attribute_permissions`. Per-attribute `description` propagates to `inputSchema.properties[*].description` | -| `outputSchema` | Derived per verb from `ResourceClass.attributes` for `get_*` / `create_*` / `update_*` / `patch_*`. `delete_*` returns `{ deleted: true, }`. `search_*` deliberately omits `outputSchema` | -| `annotations.readOnlyHint` | `true` on `get_*` and `search_*` | -| `annotations.destructiveHint` | `true` on `delete_*` | -| `annotations.idempotentHint` | `true` on `update_*` (PUT semantics); other verbs default off | +| Field | Source | +| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `name` | `${verb}_${sanitized-path}` (e.g. `get_Product`, `search_Customer`) | +| `description` | Composed: `[ResourceClass.description \n\n] ${verb sentence} ${runtime RBAC note}` | +| `inputSchema` | Derived per verb from the Resource's schema (below) and the caller's `attribute_permissions`. Per-property `description` propagates to `inputSchema.properties[*].description` | +| `outputSchema` | Derived per verb from the same source for `get_*` / `create_*` / `update_*` / `patch_*`. `delete_*` returns `{ deleted: true, }`. `search_*` deliberately omits `outputSchema` | +| `annotations.readOnlyHint` | `true` on `get_*` and `search_*` | +| `annotations.destructiveHint` | `true` on `delete_*` | +| `annotations.idempotentHint` | `true` on `update_*` (PUT semantics); other verbs default off | + +The schema source is the Resource's table-derived attributes when it has them, and its `static properties` declaration when it doesn't — a programmatic Resource declaring only `static properties` yields the same rich `inputSchema` / `outputSchema` as a table-backed one . `static properties` uses JSON Schema types (lowercase), including `enum`, `format`, `const`, arrays, and nested objects; see the [Resource API reference](/reference/v5/resources/resource-api#static-properties-recordstring-jsonschemafragment). `static description` and `static properties` on the Resource class override the auto-derived values. `static outputSchemas[verb]` overrides per-verb output schemas. `static mcp.annotations[verb]` overrides annotations per verb. `static hidden === true` suppresses the entire Resource from MCP listing. @@ -139,7 +141,7 @@ Harper also publishes a small set of synthetic resources via the MCP `resources/ | `harper://schema/{db}/{table}` | application | Per-table schema, filtered by `attribute_permissions` | | `https://{host}/{path}` | application | Application HTTP Resources, in-process | -For `harper://schema/{db}/{table}` and `https://{host}/{path}` entries, the descriptor description prepends `Table.description` / `ResourceClass.description` when present. +For `harper://schema/{db}/{table}` and `https://{host}/{path}` entries, the descriptor description prepends `Table.description` / `ResourceClass.description` when present. The schema body reports the Resource's table-derived attributes, falling back to its `static properties` declaration when it has none . ## See also diff --git a/reference/resources/resource-api.md b/reference/resources/resource-api.md index 03841dde..53d5f7ec 100644 --- a/reference/resources/resource-api.md +++ b/reference/resources/resource-api.md @@ -514,6 +514,8 @@ export class ProductInventory extends Resource { ### `static properties?: Record` + + JSON-Schema-shaped attribute map keyed by name. This is the canonical public API for class-level metadata. For `@table @export` Resources it's auto-derived from the GraphQL schema. For programmatic Resources, declare it directly: ```typescript @@ -536,7 +538,73 @@ export class ProductInventory extends Resource { } ``` -For complex types and nested structures, JSON Schema vocabulary applies (`type`, `enum`, `required`, `additionalProperties`, etc.). Per-property `description` flows into both MCP `inputSchema.properties[*].description` and OpenAPI `components.schemas[*].properties[*].description`. +As of v5.2.0, a Resource that declares `static properties` with no table backing gets the same rich schemas a table-backed Resource gets, on all three introspection surfaces: + +- **MCP verb tools** — `inputSchema` and `outputSchema` on `get_*` / `search_*` / `create_*` / `update_*` / `patch_*` / `delete_*` +- **OpenAPI** — the resource's `components.schemas` entry and its per-path query parameters +- **`harper://schema/{db}/{table}`** — the MCP schema-introspection resource + +Before v5.2.0 these surfaces read only the internal `attributes` Array, so a bare `static properties` declaration produced a skeletal `{ type: 'object' }` schema. + +#### JSON Schema vocabulary + +`static properties` speaks JSON Schema, not GraphQL. Types are **lowercase** (`string`, `integer`, `number`, `boolean`, `object`, `array`, `null`) — distinct from the capitalized Harper/GraphQL type names (`String`, `Int`, `Long`, `Float`, `Boolean`, `Date`, `Bytes`, `BigInt`) that appear on the internal `Class.attributes` Array. Harper maps the GraphQL names to their JSON Schema equivalents when projecting a table-backed schema, and passes lowercase names through unchanged. + +Fragment keys Harper reads: + +| Key | Purpose | +| ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- | +| `type` | JSON Schema type, or an array of types for a union | +| `description` | Per-property prose; flows to MCP `properties[*].description` and OpenAPI | +| `primaryKey` | Marks the identifying property (drives the `id` argument on verb tools and the OpenAPI path parameter) | +| `enum`, `format`, `const` | Value constraints; emitted onto both the MCP and OpenAPI property schema | +| `items` | Element schema for `type: 'array'` — including arrays of objects | +| `properties`, `required`, `additionalProperties` | Nested object shape and its object-level constraints | +| `nullable` | Emitted as-is; also inferred when `type` is a union containing `'null'` | +| `hidden` | Suppresses this property from MCP and OpenAPI (see [`static hidden`](#static-hidden-boolean) for the whole-class equivalent) | +| `assignCreatedTime`, `assignUpdatedTime` | Marks a Harper-assigned timestamp: dropped from write-verb input schemas, and always present on output | + +Notes on how a few of these resolve: + +- A **union type** folds a `'null'` member into `nullable` and keeps the remaining type: `type: ['string', 'null']` emits `{ type: 'string', nullable: true }`. A union of two non-null types isn't expressible on the attribute form, so the first member wins — prefer a single type where you can. +- `{ type: 'array' }` with no `items` is valid and means "array of anything"; it emits a bare array schema rather than guessing an element type. +- `enum` / `format` / `const` are surfaced for properties you declare here. They are deliberately **not** emitted from a table's derived properties, where the GraphQL/code-first schema is the source of truth. +- **Optional properties need `nullable: true`.** On the `create_*` input schema every visible property is `required` unless it is the primary key or is marked nullable — the same rule GraphQL's `String` vs `String!` expresses. Mark genuinely optional properties `nullable: true` (or give `type` a `'null'` member) or an MCP client will believe it must supply them. + +```typescript +export class OrderSummary extends Resource { + static description = 'Rolled-up order totals with line items, computed per customer order.'; + + static properties = { + orderId: { type: 'string', primaryKey: true, description: 'Order identifier (ULID).' }, + placedAt: { type: 'string', format: 'date-time', description: 'ISO 8601 timestamp the order was placed.' }, + status: { + type: 'string', + enum: ['pending', 'shipped', 'delivered', 'cancelled'], + description: 'Fulfillment state.', + }, + note: { type: ['string', 'null'], description: 'Free-text note; null when the customer left none.' }, + lineItems: { + type: 'array', + description: 'One entry per SKU on the order.', + items: { + type: 'object', + properties: { + sku: { type: 'string', description: 'Stock keeping unit.' }, + quantity: { type: 'integer', description: 'Units ordered.' }, + unitPriceCents: { type: 'integer', description: 'Price per unit in cents (USD).' }, + }, + required: ['sku', 'quantity'], + additionalProperties: false, + }, + }, + }; + + async get(id) { + /* ... */ + } +} +``` **Inheritance composes naturally.** Extend a `@table @export` Resource and override individual entries with spread: diff --git a/reference/rest/overview.md b/reference/rest/overview.md index a2121f98..f03fe456 100644 --- a/reference/rest/overview.md +++ b/reference/rest/overview.md @@ -73,7 +73,7 @@ Returns records matching `name=Harper`. See [Querying](./querying.md) for the fu GET /MyTable/123.propertyName ``` -Returns a single property of a record. Only works for properties declared in the schema. +Returns a single property of a record. Only works for declared properties — a table's schema attributes, or a programmatic Resource's [`static properties`](../resources/resource-api.md#static-properties-recordstring-jsonschemafragment) . An undeclared name is treated as part of the record id instead. #### Conditional Requests and Caching From 6cfe041d8847140d8c9e2fe2b57a8669026d4389 Mon Sep 17 00:00:00 2001 From: Kyle Bernhardy Date: Fri, 24 Jul 2026 15:43:15 -0600 Subject: [PATCH 2/5] docs: correct static-properties claims against the implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial fact-check of every technical claim in the previous commit against harper core turned up 15 defects. Corrections: - harper://schema is keyed by database/table, so a table-less programmatic Resource never appears there; dropped it from the surface list. - Only get_* has a record-shaped outputSchema. search_* has none; the write verbs advertise fixed {id}/{ok}/{deleted} envelopes. update_*/patch_* are mutually exclusive. - Nullability: MCP re-expands to type:['string','null'] and never emits a `nullable` keyword; OpenAPI drops it at the top level and emits it only inside nested objects. - enum/format/const and per-property hidden are honored at the top level; nested/items keep them on MCP but not OpenAPI. Timestamp keys are MCP-only. - The OpenAPI path parameter and the verb-description sentence read the class-level `static primaryKey`, not the fragment flag — document both. - Capitalized GraphQL type names DO map correctly; the real hazard is a name in neither vocabulary, where the two surfaces disagree. - Corrected the get_ProductInventory sample to the actual emitted shape (pk description override, get_attributes description, required + additionalProperties on the output schema). - Added the super-user-only listing caveat for table-less Resources. - Fixed a pre-existing claim that attribute_permissions filters tool schemas per-user; schemas are built once at registration and are user-agnostic. - REST: .json/.cbor/.msgpack/.csv take precedence over a same-named property. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_0188G62J9fZQg4J9rVuqLzjy --- learn/developers/mcp-and-openapi-metadata.mdx | 39 +++++++++++----- reference/mcp/tool-metadata.md | 20 ++++----- reference/resources/resource-api.md | 45 +++++++++++-------- reference/rest/overview.md | 2 +- 4 files changed, 65 insertions(+), 41 deletions(-) diff --git a/learn/developers/mcp-and-openapi-metadata.mdx b/learn/developers/mcp-and-openapi-metadata.mdx index 1f911d6e..33816be1 100644 --- a/learn/developers/mcp-and-openapi-metadata.mdx +++ b/learn/developers/mcp-and-openapi-metadata.mdx @@ -148,7 +148,7 @@ export class ProductInventory extends Resource { } ``` -MCP `tools/list` returns the same shape a table-backed Resource produces — types, per-property descriptions, and the `enum` — rather than an untyped `{ "type": "object" }`: +MCP `tools/list` returns the same shape a table-backed Resource produces — types, per-property descriptions, and the `enum` — instead of an empty property set: ```json { @@ -157,8 +157,12 @@ MCP `tools/list` returns the same shape a table-backed Resource produces — typ "inputSchema": { "type": "object", "properties": { - "id": { "type": "string", "description": "Stock keeping unit; matches Product.sku." }, - "get_attributes": { "type": "array", "items": { "type": "string" } } + "id": { "type": "string", "description": "Primary key (sku)." }, + "get_attributes": { + "type": "array", + "items": { "type": "string" }, + "description": "Attribute names to project; defaults to all readable attributes." + } }, "required": ["id"] }, @@ -173,27 +177,40 @@ MCP `tools/list` returns the same shape a table-backed Resource produces — typ "enum": ["in_stock", "out_of_stock", "backorder"], "description": "Derived from onHand vs reserved." } - } + }, + "required": ["sku"], + "additionalProperties": false }, "annotations": { "readOnlyHint": true } } ``` -`/openapi.json` picks up the same properties, and the `harper://schema` MCP resource reports them for introspection. +`/openapi.json` picks up the same properties. Two things this does _not_ reach: `harper://schema/{db}/{table}` is keyed by database and table name, so a Resource with no backing table never appears there; and only `get_*` carries a record-shaped `outputSchema` — `search_*` has none, and the write verbs advertise fixed `{ id }` / `{ ok }` / `{ deleted }` envelopes. + +The `"record by sku"` phrasing above and the OpenAPI path parameter both come from the class-level `static primaryKey`, not from the fragment's `primaryKey: true`. Declare both: + +```typescript +export class ProductInventory extends Resource { + static primaryKey = 'sku'; + static properties = { sku: { type: 'string', primaryKey: true /* ... */ } }; +} +``` + +Without `static primaryKey` you get `/ProductInventory/{id}` and "…record by id" while MCP's `id` argument still describes itself as the `sku`. ### Watch the vocabulary: lowercase JSON Schema, not GraphQL -`static properties` is JSON Schema. Types are lowercase — `string`, `integer`, `number`, `boolean`, `object`, `array`, `null` — not the capitalized GraphQL names (`String`, `Int`, `Long`) you write in a `.graphql` schema. Writing `type: 'String'` in a `static properties` fragment is the most common mistake here; Harper can't map it and the property degrades to an untyped entry. +`static properties` is JSON Schema. Types are lowercase — `string`, `integer`, `number`, `boolean`, `object`, `array`, `null`. Harper also recognizes the capitalized GraphQL names (`String`, `Int`, `Long`) you write in a `.graphql` schema, so those still map correctly. The hazard is a name in neither vocabulary — `'Text'`, `'Object'`, `'Array'` — where MCP quietly coerces to `{ type: 'string' }` and OpenAPI emits an untyped `{}`. Sticking to the lowercase names keeps the two surfaces in agreement. -Beyond `type` and `description`, the fragments carry `enum` / `format` / `const` for value constraints, `items` for arrays (including arrays of objects), and `properties` / `required` / `additionalProperties` for nested objects. See the [Resource API reference](/reference/v5/resources/resource-api#static-properties-recordstring-jsonschemafragment) for the full key list and how unions and item-less arrays resolve. +Beyond `type` and `description`, the fragments carry `enum` / `format` / `const` for value constraints, `items` for arrays (including arrays of objects), and `properties` / `required` / `additionalProperties` for nested objects. Those hints reach both surfaces at the top level; inside a nested object or an array's `items`, MCP keeps them and OpenAPI drops them. See the [Resource API reference](/reference/v5/resources/resource-api#static-properties-recordstring-jsonschemafragment) for the full key list and how unions and item-less arrays resolve. ### Authoring rubric, Path B edition - **Describe every property.** The rubric from Path A applies verbatim — meaning over type, units and formats spelled out, short. - **Use `enum` wherever the value set is closed.** It's the single highest-leverage hint for an LLM: it turns "pass a status string" into "pass one of these four." - **Add `format`** (`date-time`, `uuid`, `email`, ...) where it applies. It reaches Swagger UI and gives the LLM a concrete shape to emit. -- **Declare a `primaryKey` property.** It's what the `get_*` / `delete_*` tools bind their `id` argument to and what the OpenAPI path parameter is derived from. -- **Reach for `static outputSchemas`** when a verb returns a projection rather than the full record — otherwise the output schema mirrors `static properties`. +- **Declare a `primaryKey` property _and_ `static primaryKey`.** The fragment flag types and describes the `id` argument on `get_*` / `update_*` / `delete_*`; the class static drives the OpenAPI path parameter and the tool-description sentence. +- **Reach for `static outputSchemas`** when a verb returns something other than the full record — `get_*` is the only verb whose output schema is derived from `static properties`, so this is how you describe what `create_*` or a custom `search_*` actually returns. See the [Resource API reference](/reference/v5/resources/resource-api#class-level-metadata-for-mcp-and-openapi) for the full surface, including `static outputSchemas` for per-verb projection overrides, `static hidden` for full suppression, and `static mcp` for narrow MCP-only annotation overrides. @@ -239,7 +256,7 @@ type Customer @table @export { ## RBAC and per-user filtering -For MCP tool descriptors, `attribute_permissions` already filters the schema per-user — an attribute the caller cannot read is dropped from that user's view of the tool descriptor, along with its description. The new metadata flows through the existing pipeline. +MCP tool schemas are derived once at registration time and are the same for every caller — `attribute_permissions` is enforced when the tool is called, not reflected in the advertised `inputSchema` / `outputSchema`. What RBAC does filter is the tool _list_: `tools/list` omits tools the caller has no permission for on the backing table. Treat a descriptor's property descriptions as visible to any authenticated MCP client, and use `@hidden` for anything that shouldn't be. For OpenAPI, the document is global and not per-user filtered. Use `@hidden` (or `static hidden`) to control what surfaces there. @@ -250,4 +267,4 @@ For OpenAPI, the document is global and not per-user filtered. Use `@hidden` (or 3. Hit `/openapi.json` on the application HTTP port — confirm the path-level descriptions and per-property descriptions show up in Swagger UI / Redoc. 4. Add `@hidden` to an attribute — confirm it disappears from both surfaces while remaining queryable via direct REST/SQL. -For a programmatic Resource, an untyped `{ "type": "object" }` in step 2 usually means the fragments never resolved — check that the types are lowercase JSON Schema names and that `static properties` is a `Record` keyed by property name, not an array. +Two things to check for a programmatic Resource in step 2. An empty `properties: {}` on the `get_*` output schema means the fragments never resolved — most often because the class also carries a non-empty `attributes` Array, which wins. Properties named `0`, `1`, `2`… mean `static properties` was declared as an array instead of a `Record` keyed by property name. And if the tools don't appear in `tools/list` at all, check whether you're authenticated as a super-user — see the listing-visibility note in the [Resource API reference](/reference/v5/resources/resource-api#static-properties-recordstring-jsonschemafragment). diff --git a/reference/mcp/tool-metadata.md b/reference/mcp/tool-metadata.md index effff560..bd73b4b7 100644 --- a/reference/mcp/tool-metadata.md +++ b/reference/mcp/tool-metadata.md @@ -48,15 +48,15 @@ Operations registered outside core (for example, `cluster_status` from harper-pr For verb tools generated from exported Resources: -| Field | Source | -| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `name` | `${verb}_${sanitized-path}` (e.g. `get_Product`, `search_Customer`) | -| `description` | Composed: `[ResourceClass.description \n\n] ${verb sentence} ${runtime RBAC note}` | -| `inputSchema` | Derived per verb from the Resource's schema (below) and the caller's `attribute_permissions`. Per-property `description` propagates to `inputSchema.properties[*].description` | -| `outputSchema` | Derived per verb from the same source for `get_*` / `create_*` / `update_*` / `patch_*`. `delete_*` returns `{ deleted: true, }`. `search_*` deliberately omits `outputSchema` | -| `annotations.readOnlyHint` | `true` on `get_*` and `search_*` | -| `annotations.destructiveHint` | `true` on `delete_*` | -| `annotations.idempotentHint` | `true` on `update_*` (PUT semantics); other verbs default off | +| Field | Source | +| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `name` | `${verb}_${sanitized-path}` (e.g. `get_Product`, `search_Customer`) | +| `description` | Composed: `[ResourceClass.description \n\n] ${verb sentence} ${runtime RBAC note}` | +| `inputSchema` | Derived per verb from the Resource's schema (below), once at registration time and identical for every caller. Per-property `description` propagates to `inputSchema.properties[*].description` | +| `outputSchema` | The full record shape on `get_*`; fixed envelopes on `create_*` (`{ id }`), `update_*` / `patch_*` (`{ ok }`), and `delete_*` (`{ deleted }`). `search_*` deliberately omits `outputSchema` | +| `annotations.readOnlyHint` | `true` on `get_*` and `search_*` | +| `annotations.destructiveHint` | `true` on `delete_*` | +| `annotations.idempotentHint` | `true` on `update_*` (PUT semantics); other verbs default off | The schema source is the Resource's table-derived attributes when it has them, and its `static properties` declaration when it doesn't — a programmatic Resource declaring only `static properties` yields the same rich `inputSchema` / `outputSchema` as a table-backed one . `static properties` uses JSON Schema types (lowercase), including `enum`, `format`, `const`, arrays, and nested objects; see the [Resource API reference](/reference/v5/resources/resource-api#static-properties-recordstring-jsonschemafragment). @@ -141,7 +141,7 @@ Harper also publishes a small set of synthetic resources via the MCP `resources/ | `harper://schema/{db}/{table}` | application | Per-table schema, filtered by `attribute_permissions` | | `https://{host}/{path}` | application | Application HTTP Resources, in-process | -For `harper://schema/{db}/{table}` and `https://{host}/{path}` entries, the descriptor description prepends `Table.description` / `ResourceClass.description` when present. The schema body reports the Resource's table-derived attributes, falling back to its `static properties` declaration when it has none . +For `harper://schema/{db}/{table}` and `https://{host}/{path}` entries, the descriptor description prepends `Table.description` / `ResourceClass.description` when present. `harper://schema/{db}/{table}` is enumerated by database and table name, so a Resource that identifies no table is not listed there; for one that does, the schema body falls back to its `static properties` declaration when its `attributes` Array is empty . ## See also diff --git a/reference/resources/resource-api.md b/reference/resources/resource-api.md index 53d5f7ec..9a610feb 100644 --- a/reference/resources/resource-api.md +++ b/reference/resources/resource-api.md @@ -538,42 +538,49 @@ export class ProductInventory extends Resource { } ``` -As of v5.2.0, a Resource that declares `static properties` with no table backing gets the same rich schemas a table-backed Resource gets, on all three introspection surfaces: +As of v5.2.0, a Resource that declares `static properties` with no table backing drives the same schema derivation a table-backed Resource gets: -- **MCP verb tools** — `inputSchema` and `outputSchema` on `get_*` / `search_*` / `create_*` / `update_*` / `patch_*` / `delete_*` -- **OpenAPI** — the resource's `components.schemas` entry and its per-path query parameters -- **`harper://schema/{db}/{table}`** — the MCP schema-introspection resource +- **MCP verb tools** — the `inputSchema` on every registered verb tool, and the `outputSchema` on `get_*` (the full record shape). `search_*` has no `outputSchema`; `create_*` / `update_*` / `patch_*` / `delete_*` advertise fixed result envelopes (`{ id }`, `{ ok }`, `{ deleted }`) that don't vary with your properties. A Resource implementing both `put` and `patch` registers `update_*` only — `patch_*` appears when it implements `patch` without `put`. +- **OpenAPI** — the resource's `components.schemas` entry and its per-path query parameters. -Before v5.2.0 these surfaces read only the internal `attributes` Array, so a bare `static properties` declaration produced a skeletal `{ type: 'object' }` schema. +Before v5.2.0 these surfaces read only the internal `attributes` Array, so a bare `static properties` declaration yielded an empty property set. + +The `harper://schema/{db}/{table}` MCP resource is **not** included: it is keyed by database and table name, so only Resources that identify a table are enumerated there. A purely programmatic Resource never gets such a URI. + +> **Listing visibility.** MCP `tools/list` has no static permission gate to apply to a Resource with no backing table, so Harper conservatively lists its verb tools for super-users only. Other users can still call the tools by name — the Resource's own `allowRead` / `allowUpdate` / etc. predicates enforce access at call time. #### JSON Schema vocabulary -`static properties` speaks JSON Schema, not GraphQL. Types are **lowercase** (`string`, `integer`, `number`, `boolean`, `object`, `array`, `null`) — distinct from the capitalized Harper/GraphQL type names (`String`, `Int`, `Long`, `Float`, `Boolean`, `Date`, `Bytes`, `BigInt`) that appear on the internal `Class.attributes` Array. Harper maps the GraphQL names to their JSON Schema equivalents when projecting a table-backed schema, and passes lowercase names through unchanged. +`static properties` speaks JSON Schema, not GraphQL. Types are **lowercase** (`string`, `integer`, `number`, `boolean`, `object`, `array`, `null`) — distinct from the capitalized Harper/GraphQL type names (`String`, `Int`, `Long`, `Float`, `Boolean`, `Date`, `Bytes`, `BigInt`) that appear on the internal `Class.attributes` Array. Harper recognizes both vocabularies: it maps the GraphQL names to their JSON Schema equivalents and passes lowercase names through unchanged. + +A name in neither vocabulary (`'Text'`, `'Object'`, `'Array'`) is where the surfaces diverge — MCP coerces it to `{ type: 'string' }` while OpenAPI emits an untyped `{}`. Use the lowercase JSON Schema names so the two agree. Fragment keys Harper reads: -| Key | Purpose | -| ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- | -| `type` | JSON Schema type, or an array of types for a union | -| `description` | Per-property prose; flows to MCP `properties[*].description` and OpenAPI | -| `primaryKey` | Marks the identifying property (drives the `id` argument on verb tools and the OpenAPI path parameter) | -| `enum`, `format`, `const` | Value constraints; emitted onto both the MCP and OpenAPI property schema | -| `items` | Element schema for `type: 'array'` — including arrays of objects | -| `properties`, `required`, `additionalProperties` | Nested object shape and its object-level constraints | -| `nullable` | Emitted as-is; also inferred when `type` is a union containing `'null'` | -| `hidden` | Suppresses this property from MCP and OpenAPI (see [`static hidden`](#static-hidden-boolean) for the whole-class equivalent) | -| `assignCreatedTime`, `assignUpdatedTime` | Marks a Harper-assigned timestamp: dropped from write-verb input schemas, and always present on output | +| Key | Purpose | +| ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `type` | JSON Schema type, or an array of types for a union | +| `description` | Per-property prose; flows to MCP `properties[*].description` and OpenAPI | +| `primaryKey` | Marks the identifying property. Types and describes the `id` argument on `get_*` / `update_*` / `delete_*` — see the caveat below | +| `enum`, `format`, `const` | Value constraints. Emitted on both surfaces for a top-level property; inside a nested object or an array's `items`, MCP keeps them and OpenAPI drops them | +| `items` | Element schema for `type: 'array'` — including arrays of objects | +| `properties`, `required`, `additionalProperties` | Nested object shape and its object-level constraints | +| `nullable` | Marks the property nullable; also inferred when `type` is a union containing `'null'`. See the note below on how each surface emits it | +| `hidden` | Suppresses a **top-level** property from MCP and OpenAPI (see [`static hidden`](#static-hidden-boolean) for the whole-class equivalent) | +| `assignCreatedTime`, `assignUpdatedTime` | Marks a Harper-assigned timestamp: dropped from the MCP write-verb input schemas and marked `required` on the `get_*` output schema | Notes on how a few of these resolve: -- A **union type** folds a `'null'` member into `nullable` and keeps the remaining type: `type: ['string', 'null']` emits `{ type: 'string', nullable: true }`. A union of two non-null types isn't expressible on the attribute form, so the first member wins — prefer a single type where you can. +- **Nullability is emitted differently per surface.** A `'null'` member in a union folds into the same internal flag `nullable: true` sets. MCP then re-expands it — `type: ['string', 'null']` emits `{ "type": ["string", "null"] }`, never a `nullable` keyword. OpenAPI 3.0.3 emits `{ "type": "string" }` for a top-level property (the flag isn't represented there) and `{ "type": "string", "nullable": true }` for a property inside a nested object. A union of two non-null types isn't expressible on the attribute form, so the first member wins — prefer a single type where you can. - `{ type: 'array' }` with no `items` is valid and means "array of anything"; it emits a bare array schema rather than guessing an element type. -- `enum` / `format` / `const` are surfaced for properties you declare here. They are deliberately **not** emitted from a table's derived properties, where the GraphQL/code-first schema is the source of truth. +- `hidden` and `assignCreatedTime` / `assignUpdatedTime` are honored only at the top level, and the timestamp keys only by MCP — OpenAPI request bodies reference one shared component schema per resource, so a Harper-assigned property still appears there. - **Optional properties need `nullable: true`.** On the `create_*` input schema every visible property is `required` unless it is the primary key or is marked nullable — the same rule GraphQL's `String` vs `String!` expresses. Mark genuinely optional properties `nullable: true` (or give `type` a `'null'` member) or an MCP client will believe it must supply them. +- **Declare `static primaryKey` as well.** `primaryKey: true` on a fragment is what MCP's `id` argument is typed and described from, but the OpenAPI path parameter and the verb-tool description sentence read the class-level `static primaryKey`, which defaults to `'id'`. Without it you get `/OrderSummary/{id}` and "…record by id" even though the record is keyed by `orderId`. ```typescript export class OrderSummary extends Resource { static description = 'Rolled-up order totals with line items, computed per customer order.'; + static primaryKey = 'orderId'; static properties = { orderId: { type: 'string', primaryKey: true, description: 'Order identifier (ULID).' }, diff --git a/reference/rest/overview.md b/reference/rest/overview.md index f03fe456..ee3be1fd 100644 --- a/reference/rest/overview.md +++ b/reference/rest/overview.md @@ -73,7 +73,7 @@ Returns records matching `name=Harper`. See [Querying](./querying.md) for the fu GET /MyTable/123.propertyName ``` -Returns a single property of a record. Only works for declared properties — a table's schema attributes, or a programmatic Resource's [`static properties`](../resources/resource-api.md#static-properties-recordstring-jsonschemafragment) . An undeclared name is treated as part of the record id instead. +Returns a single property of a record. Only works for declared properties — a table's schema attributes, or a programmatic Resource's [`static properties`](../resources/resource-api.md#static-properties-recordstring-jsonschemafragment) . An undeclared name is treated as part of the record id instead. The suffixes `.json`, `.cbor`, `.msgpack`, and `.csv` are reserved as content-type selectors and take precedence, so a property with one of those names is not reachable this way. #### Conditional Requests and Caching From 556106bb06e235a6d258c6e5de643bfb1dcc6a4d Mon Sep 17 00:00:00 2001 From: Kyle Bernhardy Date: Fri, 24 Jul 2026 15:46:23 -0600 Subject: [PATCH 3/5] docs: propagate the static-properties change to the pages that still contradicted it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Conventions/consistency pass found three pages stating the old rule, none of which the first pass touched: - reference/mcp/overview.md said input schemas are "narrowed by your role's attribute_permissions" — they are not; schemas are built once at registration and are caller-agnostic. Corrected, plus the static properties source. - reference/mcp/tools-and-resources.md said input schemas come from Table.attributes only. - reference/rest/querying.md is the canonical page for the `id.property` URL form and still said "declared in the schema"; rest/overview.md restated the same rule 26 lines above the one this PR had updated. Also: moved the Path B version badge off the (unchanged) section heading onto the paragraph it describes, and gave the ProductInventory example the `static primaryKey` its sample output already assumed. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_0188G62J9fZQg4J9rVuqLzjy --- learn/developers/mcp-and-openapi-metadata.mdx | 20 +++++++------------ reference/mcp/overview.md | 2 +- reference/mcp/tools-and-resources.md | 2 +- reference/rest/overview.md | 2 +- reference/rest/querying.md | 2 +- 5 files changed, 11 insertions(+), 17 deletions(-) diff --git a/learn/developers/mcp-and-openapi-metadata.mdx b/learn/developers/mcp-and-openapi-metadata.mdx index 33816be1..9c3f8e55 100644 --- a/learn/developers/mcp-and-openapi-metadata.mdx +++ b/learn/developers/mcp-and-openapi-metadata.mdx @@ -116,8 +116,6 @@ For `search_Product`, the `conditions[].attribute` field becomes a closed `enum` ## Path B: Programmatic Resources via class-level statics - - For Resources without `@table @export` backing — Resource subclasses that override `get`/`post`/`put`/`delete` directly, or that aggregate across multiple tables — there's no GraphQL schema to derive from. Declare the same metadata directly on the class as JSON-Schema-shaped statics. The MCP and OpenAPI layers read both surfaces uniformly. ```typescript @@ -128,6 +126,10 @@ export class ProductInventory extends Resource { 'Aggregate inventory analytics computed over the Product catalog. ' + 'Read-only; the underlying Product table is the system of record.'; + // The fragment flag below types MCP's `id` argument; this class static is what the + // OpenAPI path parameter and the tool-description sentence read. Declare both. + static primaryKey = 'sku'; + static properties = { sku: { type: 'string', primaryKey: true, description: 'Stock keeping unit; matches Product.sku.' }, onHand: { type: 'integer', description: 'Current warehouse count.' }, @@ -148,7 +150,8 @@ export class ProductInventory extends Resource { } ``` -MCP `tools/list` returns the same shape a table-backed Resource produces — types, per-property descriptions, and the `enum` — instead of an empty property set: + — MCP `tools/list` returns the same shape a table-backed Resource +produces — types, per-property descriptions, and the `enum` — instead of an empty property set: ```json { @@ -187,16 +190,7 @@ MCP `tools/list` returns the same shape a table-backed Resource produces — typ `/openapi.json` picks up the same properties. Two things this does _not_ reach: `harper://schema/{db}/{table}` is keyed by database and table name, so a Resource with no backing table never appears there; and only `get_*` carries a record-shaped `outputSchema` — `search_*` has none, and the write verbs advertise fixed `{ id }` / `{ ok }` / `{ deleted }` envelopes. -The `"record by sku"` phrasing above and the OpenAPI path parameter both come from the class-level `static primaryKey`, not from the fragment's `primaryKey: true`. Declare both: - -```typescript -export class ProductInventory extends Resource { - static primaryKey = 'sku'; - static properties = { sku: { type: 'string', primaryKey: true /* ... */ } }; -} -``` - -Without `static primaryKey` you get `/ProductInventory/{id}` and "…record by id" while MCP's `id` argument still describes itself as the `sku`. +The `"record by sku"` phrasing above and the OpenAPI path parameter both come from that `static primaryKey`, not from the fragment's `primaryKey: true`. Omit it and you get `/ProductInventory/{id}` and "…record by id" while MCP's `id` argument still describes itself as the `sku`. ### Watch the vocabulary: lowercase JSON Schema, not GraphQL diff --git a/reference/mcp/overview.md b/reference/mcp/overview.md index e3eb16e7..e2166b47 100644 --- a/reference/mcp/overview.md +++ b/reference/mcp/overview.md @@ -46,7 +46,7 @@ Wraps Harper's operation catalog (the same set of operations the REST `/operatio Walks your application's exported `Resource` classes and generates one MCP tool per implemented REST verb. Mounts on the **application HTTP server** (the same listener that serves your REST endpoints). - For each exported Resource, Harper emits `get_`, `search_`, `create_`, `update_`, and `delete_` tools when the corresponding verb is implemented on the prototype. -- Input schemas are derived from `Table.attributes` and narrowed by your role's `attribute_permissions`. +- Input schemas are derived from `Table.attributes`, or from a programmatic Resource's `static properties` when it declares no attributes. They are built once at registration and are identical for every caller — `attribute_permissions` is enforced at call time and filters which tools `tools/list` returns, not the schemas themselves. - Components can opt non-verb instance methods into the MCP surface by declaring a static `mcpTools` array on the Resource class. - A Resource is excluded from the MCP surface when its registration sets `exportTypes.mcp = false`. diff --git a/reference/mcp/tools-and-resources.md b/reference/mcp/tools-and-resources.md index 749f3032..2a5d7872 100644 --- a/reference/mcp/tools-and-resources.md +++ b/reference/mcp/tools-and-resources.md @@ -72,7 +72,7 @@ The Resource's path is sanitized into a valid tool name: `/` and `.` become `_`. ### Input schema derivation -Input schemas come from `Table.attributes`: +Input schemas come from `Table.attributes` — or, for a programmatic Resource that declares no attributes, from its [`static properties`](/reference/v5/resources/resource-api#static-properties-recordstring-jsonschemafragment) , whose JSON Schema types pass through unchanged: - Harper types map to JSON Schema primitive types (`Int`/`Long`/`BigInt` → `integer`, `Float` → `number`, `String`/`ID` → `string`, `Boolean` → `boolean`, `Date` → `[string, number]`, `Bytes`/`Blob` → `string` with `contentEncoding: base64`). - Nested `Object` and `Array` attributes recurse into their `properties` / `elements`. diff --git a/reference/rest/overview.md b/reference/rest/overview.md index ee3be1fd..fbc7dda8 100644 --- a/reference/rest/overview.md +++ b/reference/rest/overview.md @@ -47,7 +47,7 @@ The REST interface follows a consistent URL structure: | `/my-resource/record-id/` | Trailing slash — the collection of records with the given id prefix | | `/my-resource/record-id/with/multiple/parts` | Record id with multiple path segments | - — Resources can be defined with nested paths and accessed by exact path without a trailing slash. The `id.property` dot syntax for accessing properties via URL is only applied to properties declared in a schema. + — Resources can be defined with nested paths and accessed by exact path without a trailing slash. The `id.property` dot syntax for accessing properties via URL is only applied to declared properties (see [below](#get)). ## HTTP Methods diff --git a/reference/rest/querying.md b/reference/rest/querying.md index b05a19c0..a26082c4 100644 --- a/reference/rest/querying.md +++ b/reference/rest/querying.md @@ -245,7 +245,7 @@ Access a specific property of a record by appending it with dot syntax to the re GET /MyTable/123.propertyName ``` -This only works for properties declared in the schema. As of v4.5.0, dots in URL paths are no longer interpreted as property access for undeclared properties, allowing URLs to generally include dots without being misinterpreted. +This only works for declared properties — a table's schema attributes, or a programmatic Resource's [`static properties`](/reference/v5/resources/resource-api#static-properties-recordstring-jsonschemafragment) . As of v4.5.0, dots in URL paths are no longer interpreted as property access for undeclared properties, allowing URLs to generally include dots without being misinterpreted. The suffixes `.json`, `.cbor`, `.msgpack`, and `.csv` are reserved as content-type selectors and take precedence over a property of the same name. ## `directURLMapping` Option From 10bc616288d47d750f2a3df1ada3a09e6c19afe9 Mon Sep 17 00:00:00 2001 From: Kyle Bernhardy Date: Fri, 24 Jul 2026 15:54:23 -0600 Subject: [PATCH 4/5] docs(resources): use static verb handlers in the class-level metadata examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The examples in this section defined `get` as an instance method while the page's own primary examples (and the Resource Static Methods section above) use `static get(target)` — the documented override point. Converts all five, not just the one added by this PR. Flagged by gemini-code-assist on documentation#605. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_0188G62J9fZQg4J9rVuqLzjy --- learn/developers/mcp-and-openapi-metadata.mdx | 4 ++-- reference/resources/resource-api.md | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/learn/developers/mcp-and-openapi-metadata.mdx b/learn/developers/mcp-and-openapi-metadata.mdx index 9c3f8e55..89eebb33 100644 --- a/learn/developers/mcp-and-openapi-metadata.mdx +++ b/learn/developers/mcp-and-openapi-metadata.mdx @@ -141,10 +141,10 @@ export class ProductInventory extends Resource { }, }; - async get(id) { + static async get(target) { /* returns { sku, onHand, reserved, stockStatus } */ } - async search(query) { + static async search(query) { /* ... */ } } diff --git a/reference/resources/resource-api.md b/reference/resources/resource-api.md index 9a610feb..8b0f517a 100644 --- a/reference/resources/resource-api.md +++ b/reference/resources/resource-api.md @@ -506,7 +506,7 @@ export class ProductInventory extends Resource { 'Aggregate inventory analytics computed over the Product catalog. ' + 'Read-only; the underlying Product table is the system of record.'; - async get(id) { + static async get(target) { /* ... */ } } @@ -532,7 +532,7 @@ export class ProductInventory extends Resource { }, }; - async get(id) { + static async get(target) { /* ... */ } } @@ -607,7 +607,7 @@ export class OrderSummary extends Resource { }, }; - async get(id) { + static async get(target) { /* ... */ } } @@ -654,7 +654,7 @@ export class ProductInventory extends Resource { }, }; - async get(id) { + static async get(target) { /* returns the projection above */ } } @@ -667,7 +667,7 @@ When `true`, the Resource is dropped from MCP tool registration and OpenAPI path ```typescript export class InternalDiagnostics extends Resource { static hidden = true; - async get() { + static async get(target) { /* ... */ } } From 5f06e2bcfc1010295c3315954a255ec70e35ab10 Mon Sep 17 00:00:00 2001 From: Kyle Bernhardy Date: Fri, 24 Jul 2026 18:29:20 -0600 Subject: [PATCH 5/5] docs: correct the OpenAPI route and the exposure claim against a running instance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified by booting Harper with a programmatic static-properties component and hitting the endpoints: - The route is `/openapi`, not `/openapi.json` — the latter 404s. Four references corrected, two of which this PR introduced. The integration suite uses `/openapi`, which is the authority. - "OpenAPI is typically exposed to anyone reachable on the HTTP port" overstates it: a default `prod` install returns 403 for an unauthenticated GET. What is actually true, and what matters for the @hidden guidance, is that the document is global — no per-user filtering — so any authorized caller sees every docstring regardless of their own read permissions. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_0188G62J9fZQg4J9rVuqLzjy --- learn/developers/mcp-and-openapi-metadata.mdx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/learn/developers/mcp-and-openapi-metadata.mdx b/learn/developers/mcp-and-openapi-metadata.mdx index 89eebb33..7c20f4ef 100644 --- a/learn/developers/mcp-and-openapi-metadata.mdx +++ b/learn/developers/mcp-and-openapi-metadata.mdx @@ -101,7 +101,7 @@ MCP `tools/list` now returns: } ``` -And `/openapi.json` picks up the same data: schema-level `description`, per-property `description`, and prepended path-level descriptions for every verb on `/Product`. +And `/openapi` picks up the same data: schema-level `description`, per-property `description`, and prepended path-level descriptions for every verb on `/Product`. ### `search_*` gets typed and described too @@ -188,7 +188,7 @@ produces — types, per-property descriptions, and the `enum` — instead of an } ``` -`/openapi.json` picks up the same properties. Two things this does _not_ reach: `harper://schema/{db}/{table}` is keyed by database and table name, so a Resource with no backing table never appears there; and only `get_*` carries a record-shaped `outputSchema` — `search_*` has none, and the write verbs advertise fixed `{ id }` / `{ ok }` / `{ deleted }` envelopes. +`/openapi` picks up the same properties. Two things this does _not_ reach: `harper://schema/{db}/{table}` is keyed by database and table name, so a Resource with no backing table never appears there; and only `get_*` carries a record-shaped `outputSchema` — `search_*` has none, and the write verbs advertise fixed `{ id }` / `{ ok }` / `{ deleted }` envelopes. The `"record by sku"` phrasing above and the OpenAPI path parameter both come from that `static primaryKey`, not from the fragment's `primaryKey: true`. Omit it and you get `/ProductInventory/{id}` and "…record by id" while MCP's `id` argument still describes itself as the `sku`. @@ -230,7 +230,7 @@ The author writes against the canonical `properties` API. Internal code paths th ## Hiding sensitive fields with `@hidden` -OpenAPI is typically exposed to anyone reachable on the HTTP port — there's no per-user filtering on `/openapi.json`. A docstring on a sensitive field publishes that text to anyone who can hit the endpoint. The `@hidden` directive suppresses a field (or an entire type) from both MCP and OpenAPI without affecting data access: +The OpenAPI document is global: there is no per-user filtering on `/openapi`, so every caller who can fetch it sees the same schemas and the same docstrings. Whether an unauthenticated caller can fetch it at all depends on your instance's authentication configuration — a default `prod` install returns `403` without credentials — but treat any field you document as visible to every user who can reach the endpoint, not just those with permission to read it. The `@hidden` directive suppresses a field (or an entire type) from both MCP and OpenAPI without affecting data access: ```graphql type Customer @table @export { @@ -258,7 +258,7 @@ For OpenAPI, the document is global and not per-user filtered. Use `@hidden` (or 1. Add `"""docstrings"""` to a `@table @export` type — or `static description` + `static properties` to a programmatic Resource — and save your component. 2. Hit MCP `tools/list` for the application profile — confirm `get_*`, `search_*`, etc. descriptions include the type docstring and per-attribute descriptions are present in the `inputSchema` and `outputSchema`. -3. Hit `/openapi.json` on the application HTTP port — confirm the path-level descriptions and per-property descriptions show up in Swagger UI / Redoc. +3. Hit `/openapi` on the application HTTP port — confirm the path-level descriptions and per-property descriptions show up in Swagger UI / Redoc. 4. Add `@hidden` to an attribute — confirm it disappears from both surfaces while remaining queryable via direct REST/SQL. Two things to check for a programmatic Resource in step 2. An empty `properties: {}` on the `get_*` output schema means the fragments never resolved — most often because the class also carries a non-empty `attributes` Array, which wins. Properties named `0`, `1`, `2`… mean `static properties` was declared as an array instead of a `Record` keyed by property name. And if the tools don't appear in `tools/list` at all, check whether you're authenticated as a super-user — see the listing-visibility note in the [Resource API reference](/reference/v5/resources/resource-api#static-properties-recordstring-jsonschemafragment).