Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
53 changes: 52 additions & 1 deletion learn/developers/mcp-and-openapi-metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ For `search_Product`, the `conditions[].attribute` field becomes a closed `enum`

## Path B: Programmatic Resources via class-level statics

<VersionBadge type="changed" version="v5.2.0" />

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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
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) 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, <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 |

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. The schema body reports the Resource's table-derived attributes, falling back to its `static properties` declaration when it has none <VersionBadge type="changed" version="v5.2.0" />.

## See also

Expand Down
70 changes: 69 additions & 1 deletion reference/resources/resource-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,8 @@ export class ProductInventory extends Resource {

### `static properties?: Record<string, JsonSchemaFragment>`

<VersionBadge type="changed" version="v5.2.0" />

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
Expand All @@ -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) {
/* ... */
}
Comment thread
kylebernhardy marked this conversation as resolved.
Outdated
}
```

**Inheritance composes naturally.** Extend a `@table @export` Resource and override individual entries with spread:

Expand Down
2 changes: 1 addition & 1 deletion reference/rest/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

#### Conditional Requests and Caching

Expand Down