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
76 changes: 69 additions & 7 deletions learn/developers/mcp-and-openapi-metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.' },
Expand All @@ -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) {
/* ... */
}
}
```

<VersionBadge type="changed" version="v5.2.0" /> — 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
Expand All @@ -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 {
Expand All @@ -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).
2 changes: 1 addition & 1 deletion reference/mcp/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<name>`, `search_<name>`, `create_<name>`, `update_<name>`, and `delete_<name>` 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` <VersionBadge type="changed" version="v5.2.0" /> 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`.

Expand Down
22 changes: 12 additions & 10 deletions reference/mcp/tool-metadata.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, <pk> }`. `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 <VersionBadge type="changed" version="v5.2.0" />. `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.

Expand Down Expand Up @@ -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 <VersionBadge type="changed" version="v5.2.0" />.

## See also

Expand Down
2 changes: 1 addition & 1 deletion reference/mcp/tools-and-resources.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) <VersionBadge type="changed" version="v5.2.0" />, 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`.
Expand Down
Loading