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 mechanical sentence and nothing else: "Fetches a single Product record by sku. Runtime RBAC (allowRead) enforces operation access at call time." An LLM picking between get_Product, get_Order, get_Customer sees three descriptions that differ only by table name. 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
type Product @table @export {
sku: String! @primaryKey
name: String!
priceCents: Int!
inStock: Int!
}
MCP tools/list returns:
{
"name": "get_Product",
"description": "Fetches a single Product record by sku. Runtime RBAC (allowRead) enforces operation access at call time.",
"inputSchema": {
"type": "object",
"properties": { "id": { "type": "string", "description": "Primary key (sku)." } },
"required": ["id"]
}
}
After
"""
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:
{
"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 (allowRead) enforces operation 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 picks up the same data: schema-level description, per-property description, and prepended path-level descriptions for every verb on the generated paths — /Product/ for the collection and /Product/{sku} for a single record. The trailing slash is load-bearing: a bare /Product returns the resource description rather than a record list, and is not itself a path in the document.
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.
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.',
},
};
static async get(target) {
/* returns { sku, onHand, reserved, stockStatus } */
}
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. Until it closes, describe such a Resource's result shape with static outputSchemas.
See the Resource API reference 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:
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
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:
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
@hiddento suppress fields that shouldn't surface publicly.
RBAC and per-user filtering
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
- Add
"""docstrings"""to a@table @exporttype and save your component. - Hit MCP
tools/listfor the application profile — confirmget_*,search_*, etc. descriptions include the type docstring and per-attribute descriptions are present in theinputSchemaandoutputSchema. - Hit
/openapion the application HTTP port — confirm the path-level descriptions and per-property descriptions show up in Swagger UI / Redoc. - Add
@hiddento an attribute — confirm it disappears from both surfaces while remaining queryable via direct REST/SQL.