Skip to main content

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

type Product @table @export {
sku: String! @primaryKey
name: String!
priceCents: Int!
inStock: Int!
}

MCP tools/list returns:

{
"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

"""
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 (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 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.

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.';

// The fragment flag below types MCP's `id` argument; this class static is what the
// OpenAPI path parameter and the tool-description sentence read. Declare both.
static primaryKey = 'sku';

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) {
/* ... */
}
}

Changed in: v5.2.0 — MCP tools/list returns the same shape a table-backed Resource produces — types, per-property descriptions, and the enum — instead of an empty property set:

{
"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": "Primary key (sku)." },
"get_attributes": {
"type": "array",
"items": { "type": "string" },
"description": "Attribute names to project; defaults to all readable attributes."
}
},
"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."
}
},
"required": ["sku"],
"additionalProperties": false
},
"annotations": { "readOnlyHint": true }
}

/openapi picks up the same properties. Two things this does not reach: harper://schema/{db}/{table} is keyed by database and table name, so a Resource with no backing table never appears there; and only get_* carries a record-shaped outputSchemasearch_* has none, and the write verbs advertise fixed { id } / { ok } / { deleted } envelopes.

The "record by sku" phrasing above and the OpenAPI path parameter both come from that static primaryKey, not from the fragment's primaryKey: true. Omit it and you get /ProductInventory/{id} and "…record by id" while MCP's id argument still describes itself as the sku.

Watch the vocabulary: lowercase JSON Schema, not GraphQL

static properties is JSON Schema. Types are lowercase — string, integer, number, boolean, object, array, null. Harper also recognizes the capitalized GraphQL names (String, Int, Long) you write in a .graphql schema, so those still map correctly. The hazard is a name in neither vocabulary — 'Text', 'Object', 'Array' — where MCP quietly coerces to { type: 'string' } and OpenAPI emits an untyped {}. Sticking to the lowercase names keeps the two surfaces in agreement.

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. Those hints reach both surfaces at the top level; inside a nested object or an array's items, MCP keeps them and OpenAPI drops them. See the Resource API reference 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 and static primaryKey. The fragment flag types and describes the id argument on get_* / update_* / delete_*; the class static drives the OpenAPI path parameter and the tool-description sentence.
  • Reach for static outputSchemas when a verb returns something other than the full record — get_* is the only verb whose output schema is derived from static properties, so this is how you describe what create_* or a custom search_* actually returns.

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 on /openapi, so every caller who can fetch it sees the same schemas and the same docstrings. Whether an unauthenticated caller can fetch it at all depends on your instance's authentication configuration — a default prod install returns 403 without credentials — but treat any field you document as visible to every user who can reach the endpoint, not just those 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 @hidden to 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

  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 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.

Two things to check for a programmatic Resource in step 2. An empty properties: {} on the get_* output schema means the fragments never resolved — most often because the class also carries a non-empty attributes Array, which wins. Properties named 0, 1, 2… mean static properties was declared as an array instead of a Record keyed by property name. And if the tools don't appear in tools/list at all, check whether you're authenticated as a super-user — see the listing-visibility note in the Resource API reference.