diff --git a/learn/developers/mcp-and-openapi-metadata.mdx b/learn/developers/mcp-and-openapi-metadata.mdx new file mode 100644 index 00000000..aea29a39 --- /dev/null +++ b/learn/developers/mcp-and-openapi-metadata.mdx @@ -0,0 +1,202 @@ +--- +title: Writing quality MCP and OpenAPI descriptions +--- + +When an MCP client connects to Harper, the LLM on the other side sees your application as a list of tools. The text it reads to pick the right tool — the tool description, the per-attribute property descriptions, the output schema shape — is the dominant signal for tool selection. The same metadata also drives Harper's OpenAPI document, which any HTTP API consumer (Swagger UI, Redoc, generated SDKs, machine clients) reads. + +This guide shows how to author that metadata once and have it flow to both surfaces — via GraphQL docstrings for `@table @export` Resources, and via class-level statics for programmatic Resource subclasses. + +## Why descriptions matter + +Harper auto-generates MCP tools for every exported Resource. Without descriptions, every tool gets a generic template: `"get on resource '/Product' (table Product). Runtime RBAC enforces per-record access at call time."` An LLM picking between `get_Product`, `get_Order`, `get_Customer` sees three near-identical descriptions. Tool selection becomes guesswork. + +Add a one-line docstring to your `@table @export` type and the picture changes: each tool's description includes a sentence about what the resource actually represents, and every searchable attribute has a per-attribute description the LLM can use to form queries. + +## Path A: `@table @export` Resources via GraphQL docstrings + +For table-backed Resources, the natural authoring locus is the GraphQL schema. Triple-quoted docstrings on types and fields are picked up by Harper's parser and flow through to both MCP and OpenAPI automatically — no JavaScript code changes required. + +### Before + +```graphql +type Product @table @export { + sku: String! @primaryKey + name: String! + priceCents: Int! + inStock: Int! +} +``` + +MCP `tools/list` returns: + +```json +{ + "name": "get_Product", + "description": "get on resource '/Product' (table Product). Runtime RBAC (allowGet) enforces per-record access at call time.", + "inputSchema": { + "type": "object", + "properties": { "id": { "type": "string", "description": "Primary key (sku)." } }, + "required": ["id"] + } +} +``` + +### After + +```graphql +""" +Product catalog row — what shows up in the storefront listing, +search, and inventory feeds. One row per SKU. +""" +type Product @table @export { + """ + Stock keeping unit — globally unique across catalogs. + """ + sku: String! @primaryKey + + """ + Display name shown in the storefront. 100 chars max. + """ + name: String! + + """ + Retail price in cents (USD). + """ + priceCents: Int! + + """ + Current inventory level. Decremented by orders; reconciled nightly. + """ + inStock: Int! +} +``` + +MCP `tools/list` now returns: + +```json +{ + "name": "get_Product", + "description": "Product catalog row — what shows up in the storefront listing, search, and inventory feeds. One row per SKU.\n\nFetches a single Product record by sku. Runtime RBAC (allowGet) enforces per-record access at call time.", + "inputSchema": { + "type": "object", + "properties": { + "id": { "type": "string", "description": "Primary key (sku)." } + }, + "required": ["id"] + }, + "outputSchema": { + "type": "object", + "properties": { + "sku": { "type": "string", "description": "Stock keeping unit — globally unique across catalogs." }, + "name": { "type": "string", "description": "Display name shown in the storefront. 100 chars max." }, + "priceCents": { "type": "integer", "description": "Retail price in cents (USD)." }, + "inStock": { + "type": "integer", + "description": "Current inventory level. Decremented by orders; reconciled nightly." + } + }, + "required": ["sku", "name", "priceCents", "inStock"], + "additionalProperties": false + } +} +``` + +And `/openapi.json` 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 + +For `search_Product`, the `conditions[].attribute` field becomes a closed `enum` of the readable attributes, and each per-property description threads through. The LLM goes from "an attribute name (string)" to "one of these specific attribute names, with this meaning each." + +### Authoring rubric + +- **Lead with a verb-led sentence on the type:** "Product catalog row…", "Customer profile and order history…". Skip the trivia ("This is the Product table"); the LLM already knows it's a table. +- **Field docstrings should explain meaning, not type.** Saying "Integer." adds nothing — the schema already says `Int!`. Saying "Retail price in cents (USD)" lets the LLM construct sensible queries. +- **Mention units, formats, and edge cases.** "ISO 8601 timestamp", "cents not dollars", "null for SKUs that have never been counted". +- **Keep docstrings short.** Long descriptions waste LLM context and clutter the OpenAPI UI. + +## 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 +import { Resource } from 'harperdb'; + +export class ProductInventory extends Resource { + static description = + 'Aggregate inventory analytics computed over the Product catalog. ' + + 'Read-only; the underlying Product table is the system of record.'; + + static properties = { + sku: { type: 'string', primaryKey: true, 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.', + }, + }; + + async get(id) { + /* returns { sku, onHand, reserved, stockStatus } */ + } + async search(query) { + /* ... */ + } +} +``` + +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 + +Resources extending a `@table @export` Resource inherit the auto-derived metadata. Override individual entries with spread: + +```typescript +const { Product } = tables; + +class CustomProduct extends Product { + static properties = { + ...Product.properties, + priceCents: { + ...Product.properties.priceCents, + description: 'Retail price in cents, including any per-customer adjustments.', + }, + }; +} +``` + +The author writes against the canonical `properties` API. Internal code paths that need ordered iteration continue to read `Class.attributes` (the Array form), preserved through inheritance. + +## 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: + +```graphql +type Customer @table @export { + id: Long @primaryKey + name: String + + """ + Internal — used by the pricing engine; not for external consumers. + """ + creditScore: Int @hidden +} +``` + +`creditScore` is still queryable via direct Harper interfaces under the caller's `attribute_permissions` — `@hidden` is a metadata-visibility directive, not access control. For programmatic Resources, the equivalent is `static hidden = true` on the class (or `hidden: true` on a per-property entry in `static properties`). + +> **Trust model.** Docstrings reach LLMs and public OpenAPI consumers verbatim. Treat them as code: don't put secrets, internal-only commentary, or speculative prose in them. Use `@hidden` to suppress fields that shouldn't surface publicly. + +## 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. + +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. +4. Add `@hidden` to an attribute — confirm it disappears from both surfaces while remaining queryable via direct REST/SQL. diff --git a/reference/database/schema.md b/reference/database/schema.md index cb1792d8..cb6ff58f 100644 --- a/reference/database/schema.md +++ b/reference/database/schema.md @@ -185,6 +185,53 @@ type StrictRecord @table @sealed { } ``` +### `@hidden` (Type Directive) + +Suppresses the type from introspectable surfaces — MCP tool descriptors and the OpenAPI document. The table still exists; data is still queryable through Harper's other interfaces subject to RBAC. `@hidden` is a **metadata-visibility** directive, not an access-control mechanism: use `attribute_permissions` on roles to control data access. + +```graphql +type InternalConfig @table @hidden { + id: Long @primaryKey + value: String +} +``` + +`@hidden` is also available as a [field directive](#hidden-field-directive) to suppress individual attributes. + +## Documenting Types and Fields + +Harper picks up GraphQL's standard triple-quoted docstrings on type and field definitions. Docstrings flow through to: + +- **MCP** — `Table.description` (consumed as a prefix on every verb-tool description) and `inputSchema.properties[*].description` on derived tool schemas +- **OpenAPI** — `components.schemas[*].description`, per-property `description`, and the path-level `description` for every verb on the resource + +```graphql +""" +Product catalog row — what shows up in the storefront listing, +search, and inventory feeds. One row per SKU. +""" +type Product @table @export { + """ + Stock keeping unit — globally unique across catalogs. + """ + sku: String! @primaryKey + + """ + Display name shown in the storefront. + """ + name: String! + + """ + Retail price in cents (USD). + """ + priceCents: Int! +} +``` + +Docstrings on `@hidden` fields are dropped from the descriptive surfaces alongside the field itself. + +> **Trust model.** Docstrings reach LLMs and public OpenAPI consumers verbatim. Treat them as code: don't put secrets, internal-only commentary, or speculative prose in them. Use `@hidden` to suppress fields that shouldn't surface publicly. + ## Field Directives Field directives apply to individual attributes in a type definition. @@ -249,6 +296,24 @@ type Event @table { } ``` +### `@hidden` (Field Directive) + +Suppresses the field from MCP tool descriptors and the OpenAPI document. The attribute still exists in the table; data is still queryable through other interfaces subject to RBAC. Use this for fields that should not appear in introspectable surfaces. + +```graphql +type Customer @table { + id: Long @primaryKey + name: String + + """ + Internal — do not surface to external consumers. + """ + creditScore: Int @hidden +} +``` + +`@hidden` is a metadata-visibility directive, not access control: `attribute_permissions` on roles remains the data-access enforcement mechanism. + ## Relationships diff --git a/reference/mcp/tool-metadata.md b/reference/mcp/tool-metadata.md new file mode 100644 index 00000000..0ee2dbb8 --- /dev/null +++ b/reference/mcp/tool-metadata.md @@ -0,0 +1,148 @@ +--- +title: MCP tool payload sourcing +--- + +# MCP tool payload sourcing + +Harper's MCP server publishes tools via the Model Context Protocol (rev 2025-06-18, Streamable HTTP transport). This page is a reference for what fields appear on each generated tool descriptor and where the data comes from. For authoring guidance, see the [Writing quality MCP and OpenAPI descriptions](/learn/developers/mcp-and-openapi-metadata) how-to. + +## Tool descriptor fields + +Per the MCP spec, every tool descriptor returned from `tools/list` carries: + +| Field | Required | Purpose | +| -------------- | -------- | ------------------------------------------------------------------------------------------------ | +| `name` | yes | Machine identifier used by `tools/call` | +| `description` | yes | LLM-facing prose explaining what the tool does and when to pick it over siblings | +| `inputSchema` | yes | JSON Schema for the arguments the tool accepts | +| `outputSchema` | no | JSON Schema for the data the tool returns (added in spec rev 2025-06-18) | +| `annotations` | no | Hints for clients: `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`, `title` | + +Harper populates each from a different source depending on the tool's profile and origin. + +## Profiles + +Harper exposes two MCP profiles, each with its own port and registration loop: + +- **Operations profile** — registers one MCP tool per Harper operation that survives the `mcp.operations.allow` / `deny` filter. Walks `OPERATION_FUNCTION_MAP`. The default allow list is read-only and intentionally narrow: `describe_*`, `list_*`, `search_*`, plus an explicit list of safe getters. +- **Application profile** — registers verb tools (`get_*`, `search_*`, `create_*`, `update_*`, `patch_*`, `delete_*`) for every exported Resource, plus any custom tools declared via `static mcpTools`. + +## Sourcing per profile + +### Operations profile + +For operations tools, the descriptor fields are sourced from: + +| Field | Source | +| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `name` | Operation name (from `OPERATION_FUNCTION_MAP` key) | +| `description` | Hand-authored entry in the operations descriptions catalog; falls back to a generic template when an operator opts in a non-cataloged operation | +| `inputSchema` | Hand-curated JSON Schema in the operations input-schemas catalog; falls back to `{ type: 'object', additionalProperties: true }` | +| `annotations.readOnlyHint` | `true` if the operation matches a read-only prefix (`describe_`, `list_`, `search_`, `get_`, `read_`) or is the literal `system_information` | +| `annotations.destructiveHint` | `true` for operations in the curated destructive set (`drop_*`, `delete_*`, `restart`, `set_configuration`, etc.) | +| `annotations.idempotentHint` | Default empty; opt-in per operation after end-to-end verification that the second call produces the same observable outcome | + +Operations registered outside core (for example, `cluster_status` from harper-pro) don't have catalog entries; they fall back to the generic description template until the per-operation metadata registry lands. + +### Application profile — verb tools + +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 | + +`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. + +### Application profile — custom `mcpTools` + +For tools declared via `static mcpTools`: + +| Field | Source | +| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| `name` | `def.name` from the `mcpTools` entry | +| `description` | `def.description` from the entry; falls back to a generic template if omitted (with a warn-once log at registration) | +| `inputSchema` | `def.inputSchema` from the entry; falls back to `{ type: 'object', additionalProperties: true }` if omitted (with a warn-once log at registration) | +| `outputSchema` | `def.outputSchema` from the entry (optional; no fallback) | +| `annotations` | `def.annotations` from the entry (optional pass-through) | + +Custom tools have no per-user listing filter beyond authentication — the Resource's instance method is responsible for whatever RBAC it needs to enforce. + +## Sample descriptors + +### `get_Product` (with docstrings) + +Given a `Product` table with type-level and field-level docstrings: + +```json +{ + "name": "get_Product", + "description": "Product catalog row — what shows up in the storefront listing, search, and inventory feeds. One row per SKU.\n\nFetches a single Product 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" } } + }, + "required": ["id"] + }, + "outputSchema": { + "type": "object", + "properties": { + "sku": { "type": "string", "description": "Stock keeping unit — globally unique across catalogs." }, + "name": { "type": "string", "description": "Display name shown in the storefront." }, + "priceCents": { "type": "integer", "description": "Retail price in cents (USD)." } + }, + "required": ["sku", "name", "priceCents"], + "additionalProperties": false + }, + "annotations": { "readOnlyHint": true } +} +``` + +### `add_user` (operations profile) + +```json +{ + "name": "add_user", + "description": "Creates a new Harper user with username, password, and role. Requires super_user. Username is immutable after creation — use drop_user + add_user to rename.", + "inputSchema": { + "type": "object", + "properties": { + "username": { "type": "string" }, + "password": { "type": "string" }, + "role": { "type": "string", "description": "Role name. Use list_roles to discover available roles." }, + "active": { "type": "boolean", "default": true } + }, + "required": ["username", "password", "role"] + } +} +``` + +Note that `add_user` does NOT carry `idempotentHint: true`. Under MCP semantics this would claim the second call produces the same observable outcome — but `add_user("bob")` on first call returns the created user and on second call returns an "already exists" error. Different observable outcomes → not idempotent. + +## `harper://*` resources + +Harper also publishes a small set of synthetic resources via the MCP `resources/list` endpoint: + +| URI | Profile | Description | +| ------------------------------ | ----------- | ----------------------------------------------------- | +| `harper://about` | both | Server version, profile, MCP protocol versions | +| `harper://operations` | operations | User-filtered operations catalog | +| `harper://openapi` | application | Full OpenAPI 3.0.3 document | +| `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. + +## See also + +- [Writing quality MCP and OpenAPI descriptions](/learn/developers/mcp-and-openapi-metadata) — authoring how-to +- [Schema reference: docstrings and `@hidden`](/reference/v5/database/schema) — GraphQL surface +- [Resource API reference: class-level metadata](/reference/v5/resources/resource-api#class-level-metadata-for-mcp-and-openapi) — programmatic surface diff --git a/reference/resources/resource-api.md b/reference/resources/resource-api.md index 71a0297b..6124efca 100644 --- a/reference/resources/resource-api.md +++ b/reference/resources/resource-api.md @@ -439,6 +439,173 @@ The name of the primary key attribute for the table. --- +## Class-level metadata for MCP and OpenAPI + +Resource classes — both `@table @export` Resources and programmatic Resource subclasses — can declare class-level static fields that drive the MCP tool descriptors and OpenAPI document. These statics are JSON-Schema-aligned and tool-agnostic; the same data feeds every introspectable surface. + +For `@table @export` Resources, `static description` and `static properties` are auto-derived from the GraphQL schema (docstrings and field types). For programmatic Resources, you declare them directly. + +### `static description?: string` + +Class-level docstring. Consumed by: + +- **MCP** — prefixed onto every verb-tool description (`get_X`, `search_X`, etc.) and onto the `harper://schema/{db}/{table}` resource description +- **OpenAPI** — set as the schema-level `description` on the resource's component schema; also prepended to the `description` of every path operation (POST/GET/PUT/PATCH/DELETE) + +```typescript +import { Resource } from 'harperdb'; + +export class ProductInventory extends Resource { + static description = + 'Aggregate inventory analytics computed over the Product catalog. ' + + 'Read-only; the underlying Product table is the system of record.'; + + async get(id) { + /* ... */ + } +} +``` + +### `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 +export class ProductInventory extends Resource { + static description = '...'; + static properties = { + sku: { type: 'string', primaryKey: true, 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.', + }, + }; + + async get(id) { + /* ... */ + } +} +``` + +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`. + +**Inheritance composes naturally.** Extend a `@table @export` Resource and override individual entries with spread: + +```typescript +const { Product } = tables; + +class CustomProduct extends Product { + static properties = { + ...Product.properties, + priceCents: { + ...Product.properties.priceCents, + description: 'Retail price in cents, including any per-customer adjustments.', + }, + }; +} +``` + +The author writes against `properties` (the public API). Internal code that needs ordered iteration / index metadata continues to read `Class.attributes` (the internal Array form, also inherited). + +### `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, }` envelope for `delete`. `search_*` deliberately has no output schema. + +```typescript +export class ProductInventory extends Resource { + static description = '...'; + static properties = { + /* full record shape */ + }; + + static outputSchemas = { + get: { + type: 'object', + properties: { + sku: { type: 'string' }, + onHand: { type: 'integer' }, + stockStatus: { type: 'string', enum: ['in_stock', 'out_of_stock', 'backorder'] }, + }, + required: ['sku', 'onHand', 'stockStatus'], + }, + }; + + async get(id) { + /* returns the projection above */ + } +} +``` + +### `static hidden?: boolean` + +When `true`, the Resource is dropped from MCP tool registration and OpenAPI path generation entirely. Data remains accessible via direct Harper interfaces subject to RBAC. Equivalent to applying `@hidden` to the `@table @export` declaration. + +```typescript +export class InternalDiagnostics extends Resource { + static hidden = true; + async get() { + /* ... */ + } +} +``` + +### `static mcp?: { annotations?: { [verb: string]: Annotations } }` + +Narrow, MCP-only override for annotation hints that don't fit JSON Schema (such as `idempotentHint` per verb). Documented as discouraged — most authors should only need `static description` + `static properties`. Use this when you need to claim, for example, that your custom `update` semantics are observably idempotent on repeat calls. + +```typescript +export class ProductInventory extends Resource { + static description = '...'; + static properties = { + /* ... */ + }; + + static mcp = { + annotations: { + update: { idempotentHint: true }, + }, + }; +} +``` + +> **Under-annotate before mis-annotate.** Under MCP semantics, `idempotentHint: true` is a strong claim: the second call must produce the same observable outcome as the first. `add_*`-style operations that return "already exists" on the second call are NOT idempotent in this sense, even though they don't crash. Verify repeat-call behavior end-to-end before annotating. + +### `static mcpTools?: McpToolDefinition[]` + +Component-author opt-in for exposing non-verb instance methods as MCP tools. Each entry maps an instance-method name to an MCP tool descriptor. RBAC is enforced by the Resource method itself; the MCP layer does not invent new ACLs. + +```typescript +export class ProductInventory extends Resource { + static mcpTools = [ + { + name: 'reconcile_inventory', + method: 'reconcileInventory', + description: + 'Triggers an immediate reconciliation against the warehouse system. ' + + 'Returns the diff applied. Heavy — do not call in a loop.', + inputSchema: { + type: 'object', + properties: { + sku: { type: 'string', description: 'SKU to reconcile, or omit for full sweep.' }, + }, + }, + annotations: { destructiveHint: false, idempotentHint: false }, + }, + ]; + + async reconcileInventory(args) { + /* ... */ + } +} +``` + +Custom `mcpTools` declarations without a `description` or `inputSchema` are still registered, but Harper emits a warn-once log at registration time — LLM tool selection degrades without these fields. + +--- + ### `setComputedAttribute(name: string, computeFunction: (record) => any)` Define the compute function for a `@computed` schema attribute.