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.