Skip to content
Open
16 changes: 9 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 @@ -116,7 +116,7 @@ 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.
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.

```typescript
import { Resource } from 'harperdb';
Expand All @@ -137,15 +137,17 @@ export class ProductInventory extends Resource {
},
};

async get(id) {
static async get(target) {
/* returns { sku, onHand, reserved, stockStatus } */
}
async search(query) {
static async search(query) {
/* ... */
}
}
```

`static description`, `static hidden`, and `static outputSchemas` take effect on the MCP and OpenAPI surfaces as written. `static properties` is the canonical class-level metadata API and drives REST behavior — notably which `id.property` URL suffixes resolve — but the MCP tool builder and the OpenAPI generator still derive their schemas from the Resource's internal attributes list, which a purely programmatic Resource does not have. So the `properties` above will not by itself populate `inputSchema` / `outputSchema` or the OpenAPI component; that gap is tracked in [harper#1923](https://github.com/HarperFast/harper/issues/1923). Until it closes, describe such a Resource's result shape with `static outputSchemas`.

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 +172,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, so every caller who can fetch it sees the same schemas and the same docstrings. `GET /openapi` itself is super-user-only — any other caller gets `403` — but the same document is also served over MCP as the `harper://openapi` resource, which applies no permission check at all. So treat any field you document as visible to every MCP client that can reach the application profile, not just to users 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 +192,13 @@ 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.
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.
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`. They are built once at registration time and are identical for every caller — `attribute_permissions` is enforced when the tool runs, not reflected in the advertised schema. What RBAC filters is the tool _list_: `tools/list` omits a Resource's verb tools unless the caller holds the matching table-level permission.
- 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
18 changes: 9 additions & 9 deletions reference/mcp/tool-metadata.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `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 `ResourceClass.attributes`, once at registration time and identical for every caller. Per-attribute `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 |

`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
4 changes: 2 additions & 2 deletions reference/mcp/tools-and-resources.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,9 @@ Input schemas come from `Table.attributes`:
- Nested `Object` and `Array` attributes recurse into their `properties` / `elements`.
- `nullable: true` adds `"null"` to the type union.
- Auto-managed columns (`assignCreatedTime`, `assignUpdatedTime`, `expiresAt`) and computed columns are stripped from write schemas (`create_*`, `update_*`) — the server fills them in.
- Per-attribute `attribute_permissions` narrow the schema **per requesting user**: attributes the user cannot read are stripped from `get_*` / `search_*` schemas; attributes the user cannot insert/update are stripped from `create_*` / `update_*` schemas.
- Every remaining attribute is included. Schemas are derived **once at registration time**, with no caller permissions in scope, so `attribute_permissions` does not narrow them — the descriptor a restricted user receives is identical to a super-user's.

The schema narrowing is a UX optimization, not a security boundary — runtime `Table.allowUpdate` / `Table.allowCreate` still enforces. The narrowing just avoids burning LLM tokens on fields the user couldn't write anyway.
`attribute_permissions` is enforced when the tool runs, not when it is described: runtime `Table.allowUpdate` / `Table.allowCreate` and the per-attribute checks reject a restricted read or write regardless of what the advertised schema listed. What RBAC does filter is the tool _list_ — `tools/list` omits a Resource's verb tools unless the caller holds the matching table-level permission (`read` or `describe` for `get_*` / `search_*`, and `insert` / `update` / `delete` for the write verbs). Because the schema is caller-agnostic, treat every attribute name and `description` in it as visible to any authenticated caller who can see the tool, and use `@hidden` for anything that shouldn't be.

### Custom `mcpTools` opt-in

Expand Down
22 changes: 17 additions & 5 deletions reference/resources/resource-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -516,7 +516,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) {
/* ... */
}
}
Expand All @@ -540,7 +540,7 @@ export class ProductInventory extends Resource {
},
};

async get(id) {
static async get(target) {
/* ... */
}
}
Expand Down Expand Up @@ -568,7 +568,19 @@ The author writes against `properties` (the public API). Internal code that need

### `static outputSchemas?: { [verb: string]: JsonSchemaFragment }`

Per-verb output schema overrides for programmatic Resources whose verb methods return a projection rather than the full record. When omitted, the MCP deriver falls back to `static properties` for the cheap verbs (`get`/`create`/`update`/`patch`) and a synthesized `{deleted: true, <pk>}` envelope for `delete`. `search_*` deliberately has no output schema.
Per-verb output schema overrides for programmatic Resources whose verb methods return a projection rather than the full record. Only `get_*` derives a record-shaped output schema; the write verbs advertise fixed envelopes that do not vary with the record shape:

| Verb | Output schema when no override is supplied |
| --------------------- | --------------------------------------------------------------------------------- |
| `get_*` | The record shape, derived from the Resource's attributes |
| `create_*` | `{ id }` — the new record's primary key, typed by the primary-key attribute |
| `update_*`, `patch_*` | `{ ok }` — a boolean acknowledgement (`Table.put` / `Table.patch` return nothing) |
| `delete_*` | `{ deleted }` — a boolean; it does not carry the primary key |
| `search_*` | None. `search_*` deliberately registers no `outputSchema` |

So `static outputSchemas` is the only way to describe what a write verb actually returns. `outputSchemas.search` is not read at all — a `search` entry has no effect on `tools/list`.

A programmatic Resource that declares no attributes gets an empty record schema on `get_*`, because the derivation reads the internal `attributes` Array rather than `static properties` ([harper#1923](https://github.com/HarperFast/harper/issues/1923)). Until that is resolved, describe such a Resource's `get_*` result with `static outputSchemas.get`.

```typescript
export class ProductInventory extends Resource {
Expand All @@ -589,7 +601,7 @@ export class ProductInventory extends Resource {
},
};

async get(id) {
static async get(target) {
/* returns the projection above */
}
}
Expand All @@ -602,7 +614,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) {
/* ... */
}
}
Expand Down
4 changes: 2 additions & 2 deletions reference/rest/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

<VersionBadge type="changed" version="v4.5.0" /> — 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.
<VersionBadge type="changed" version="v4.5.0" /> — 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

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

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

Expand Down
Loading