diff --git a/learn/developers/mcp-and-openapi-metadata.mdx b/learn/developers/mcp-and-openapi-metadata.mdx index aea29a39..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 @@ -126,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.' }, @@ -137,15 +141,71 @@ export class ProductInventory extends Resource { }, }; - async get(id) { + static async get(target) { /* returns { sku, onHand, reserved, stockStatus } */ } - async search(query) { + static async search(query) { /* ... */ } } ``` + — 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 +{ + "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": "Primary key (sku)." }, + "get_attributes": { + "type": "array", + "items": { "type": "string" }, + "description": "Attribute names to project; defaults to all readable attributes." + } + }, + "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." + } + }, + "required": ["sku"], + "additionalProperties": false + }, + "annotations": { "readOnlyHint": true } +} +``` + +`/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`. + +### Watch the vocabulary: lowercase JSON Schema, not GraphQL + +`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. 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 _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. ## Inheritance: extending a table @@ -170,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 { @@ -190,13 +250,15 @@ 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. ## 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. +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). 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/tool-metadata.md b/reference/mcp/tool-metadata.md index 0ee2dbb8..bd73b4b7 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), 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). `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. `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/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/resources/resource-api.md b/reference/resources/resource-api.md index 03841dde..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) { /* ... */ } } @@ -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 @@ -530,13 +532,86 @@ export class ProductInventory extends Resource { }, }; - async get(id) { + static async get(target) { /* ... */ } } ``` -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 drives the same schema derivation a table-backed Resource gets: + +- **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 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 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. 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: + +- **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. +- `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).' }, + 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, + }, + }, + }; + + static async get(target) { + /* ... */ + } +} +``` **Inheritance composes naturally.** Extend a `@table @export` Resource and override individual entries with spread: @@ -579,7 +654,7 @@ export class ProductInventory extends Resource { }, }; - async get(id) { + static async get(target) { /* returns the projection above */ } } @@ -592,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) { /* ... */ } } diff --git a/reference/rest/overview.md b/reference/rest/overview.md index a2121f98..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 @@ -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. 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 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