Skip to content

[MCP] Quality descriptions and richer tool-payload metadata for LLM tool selection #1095

Description

@kylebernhardy

Context

MCP v1 shipped via the umbrella #465 (PRs landed through #888). Every generated tool and resource carries a description, but the descriptions are generic templates:

Surface Today's description
Operations "Harper operation 'add_user'. Arguments forwarded as-is; the server validates and returns a structured error on rejection."
Application verb tools (table-backed) "get on resource '/Product' (table Product). Runtime RBAC (allowGet) enforces per-record access at call time."
Application verb tools (programmatic Resource) Same generic template, and derived input schema is skeletal (no Table.attributes to draw from). No metadata hook to override either.
Custom mcpTools Author-supplied, with a generic fallback if omitted
harper://* Hand-curated (already good)

The problem: an LLM picking between get_Product, get_Order, get_Customer sees three near-identical descriptions that vary only by entity name. That's the dominant signal MCP clients feed to the model during tool selection — and it's currently mostly boilerplate.

A separate but related gap: inputSchema.properties[*].description is empty for user-defined attributes. The LLM is told that search_Product accepts a conditions[].attribute field but is not told which attributes exist or what they represent. Both gaps share the same fix path (GraphQL docstrings), so this issue addresses them together.

Per @kriszyp's review comment, the same descriptive data should also reach Harper's OpenAPI surface. Today resources/openApi.ts:179-248 hardcodes path-level descriptions and :102-139 emits no per-property descriptions at all. Once the schema layer carries description for tables and attributes (Surface 2 below), feeding both consumers is two 1-line additions — the alternative (parallel namespaces, one per consumer) wastes the abstraction. This issue therefore extends to OpenAPI consumption end-to-end, not just MCP.

Goals

  1. Every Harper-shipped operation tool carries a hand-authored, LLM-grade description (MCP).
  2. Application verb tools carry descriptions and per-attribute property descriptions derived from GraphQL schema docstrings — without component authors having to write them twice. Same docstrings drive OpenAPI's path-level and per-property descriptions.
  3. Component-author custom MCP tools are nudged (but not blocked) to ship a description.
  4. Where the spec gives us other tool-payload signals (annotations.title, annotations.idempotentHint, outputSchema), evaluate and fill in the ones with clear wins.
  5. Tool-agnostic class-level metadata for programmatic Resources (static description and a JSON-Schema-shaped attribute map) is consumed by both MCP and OpenAPI from a single source. No MCP-specific descriptive namespace.

Non-goals

  • TypeScript JSDoc extraction from Resource subclass methods at runtime (would require pulling the typescript package into the runtime; cost > benefit when authors can add description two lines away).
  • Per-locale / translated descriptions.
  • Runtime LLM-generated descriptions.
  • Touching the already-curated harper://* resource descriptions.

Audit — what's set today

flowchart LR
    A[OPERATION_INPUT_SCHEMAS<br/>23 curated entries] -->|inputSchema| O[Operations tool registration<br/>tools/operations.ts:267]
    B[PERMISSIVE_SCHEMA<br/>fallback] -->|inputSchema| O
    C[isReadOnly + isDestructive<br/>predicates] -->|annotations| O
    D[buildDescription<br/>template] -->|description<br/>generic| O

    E[GraphQL schema parser<br/>resources/graphql.ts] -.->|docstrings DROPPED<br/>not read from AST| F[Table + Attributes registry]
    F -->|attributes| G[deriveGetSchema/<br/>deriveSearchSchema/etc.<br/>schemas/derive.ts]
    G -->|inputSchema<br/>no per-attr description| V[Application verb tool registration<br/>tools/application.ts:414+]
    H[baseDescription<br/>template] -->|description<br/>generic| V

    I[def.description<br/>author-supplied] -->|description<br/>or generic fallback| K[Custom mcpTools registration<br/>tools/application.ts:521]

    F -->|attributes iterated<br/>but description IGNORED| OAI[OpenAPI generator<br/>resources/openApi.ts:102-139]
    OAH[Hardcoded strings<br/>'create a new record auto-assigning…'] -->|description<br/>HARDCODED per verb| OAI
Loading

Quantified state:

MCP surfaces:

Field Operations App verb tools (table) App verb tools (programmatic) Custom mcpTools
name
description ✅ generic ✅ generic ✅ generic, no override hook ✅ author OR generic
inputSchema ✅ (23 curated, rest permissive) ✅ derived from attributes ⚠️ skeletal (Table.attributes empty) ✅ author-supplied
inputSchema.properties[*].description ✅ (in curated schemas) ⚠️ structural only — no per-attribute ❌ no attributes to describe ✅ author-controlled
annotations.readOnlyHint ✅ (get, search) ✅ (get, search) optional via def.annotations
annotations.destructiveHint ✅ (delete) ✅ (delete) optional via def.annotations
annotations.idempotentHint
annotations.openWorldHint
annotations.title
outputSchema

OpenAPI surface (resources/openApi.ts):

Field State today
Path-level description (per verb) ⚠️ Hardcoded strings in openApi.ts:179-248'create a new record auto-assigning a primary key', 'retrieve a record by its primary key', etc. Same string for every Resource at the same verb.
parameters[*].description ⚠️ Only the primary-key parameter has one (openApi.ts:144'primary key of record'). No others.
components.schemas[*].description (path/type level) ❌ Never emitted; ResourceSchema constructor (openApi.ts:380) doesn't accept a description.
components.schemas[*].properties[*].description ❌ The attribute iterator at openApi.ts:102-139 destructures { type, name, elements, relationship, definition, nullable } and ignores description. Per-property descriptions never reach the OpenAPI output.

Proposed solution — hybrid sourcing per surface

flowchart LR
    subgraph OPS[Operations profile]
        A1[OPERATION_DESCRIPTIONS<br/>NEW sidecar<br/>~45 entries hand-authored] --> O1[buildDescription]
        A2[Existing predicates<br/>readOnly/destructive] --> OA[annotations]
        A3[Idempotent op list<br/>NEW set] --> OA
    end

    subgraph APP[Application profile]
        G1[GraphQL parser<br/>+4 lines: capture docstrings] --> G2[Table.description<br/>Attribute.description]
        G2 --> V1[baseDescription accepts<br/>tableDoc prefix]
        G2 --> V2[derive.ts uses<br/>attr.description]
    end

    subgraph CUSTOM[Custom mcpTools]
        C1[def.description<br/>preferred] --> CR[Custom registration]
        C2[Generic fallback +<br/>warn-once per path:method] --> CR
    end
Loading

Foundation — properties as the canonical Resource/Table public API

Per kriszyp's direction, this issue is the right place to align Harper's Resource/Table metadata model. The descriptive surface that MCP and OpenAPI both consume should be the primary, encouraged public API on Resources — not a parallel namespace and not just a programmatic-only escape hatch.

Alignment:

Surface Today Direction
Table.attributes / ResourceClass.attributes Array<Attribute> — the primary surface for everything Becomes the internal enumerated form. Still exists; still walked by code that needs ordered iteration, index-store associations, and per-attribute internal metadata. Kept for back-compat.
Table.properties / ResourceClass.properties Doesn't exist as a class-level public surface NEW canonical public API. Record<string, JsonSchemaFragment> keyed by attribute name. What authors write; what MCP, OpenAPI, and future schema consumers read.
Attribute.properties (nested complex types) Array<Attribute> — child attributes of a complex column Stays as Array for the same internal reason (nested traversal with metadata). The public counterpart for nested objects would also be Attribute.<some name for child properties> as a Record. Initial pass keeps the existing array; only the class-level alignment is in scope here.

Why properties and not schema: kriszyp's call. "Having parallel JSON schema graph alongside a JSON schema-ish graph doesn't help the situation, it makes it worse." The class-level vs. instance-level distinction disambiguates the name overload; the convergence on JSON Schema vocabulary outweighs the parallel-name cost.

Inheritance composes naturally. A Resource extending a @table @export-backed Resource inherits Properties via JS class inheritance. Override with spread:

const { Product } = tables;
class CustomProduct extends Product {
  static properties = {
    ...Product.properties,
    priceCents: { ...Product.properties.priceCents, description: 'a little different description of cents' },
  };
}

CustomProduct.attributes and any CustomProduct.properties.nestedObject.attributes still exist for internal enumerated access. The author writes against properties (the public API); internal code walks attributes (the iterable form).

Bidirectional consistency. properties and attributes describe the same data in two shapes; they must agree. Three options for how to maintain that:

  • (i) Co-populated at construction. The schema parser (resources/graphql.ts) builds both from the GraphQL AST in one pass. Cheap; no derived getters; small risk of drift if one is mutated externally.
  • (ii) attributes derives from properties. Define a getter that converts the Record to an Array on access. Single source of truth. Marginal cost on iteration paths.
  • (iii) properties derives from attributes. Reverse. Single source of truth but the LEAST aligned with Kris's "primary public API" framing — internal code defines what the public API exposes.

The recommended path is (i) for the GraphQL parser (where both shapes are needed downstream anyway and the parser already builds the array form) and define a getter for properties on the Resource/Table class that lazily projects from attributes when the user hasn't supplied a static properties override. This means:

  • Existing tables: Table.properties returns the projection of Table.attributes automatically. Zero author work for backward-compatible behavior.
  • Authors who want richer metadata than the GraphQL schema captures: declare static properties = {...} to override. JS class field shadowing handles precedence.
  • Programmatic Resources: declare static properties = {...} directly — same surface, same shape.
  • Inheritance: extends carries the static down; spread-override is the natural extension pattern.

Back-compat. Existing code paths read Table.attributes and continue to work — that's the internal array form, preserved unchanged. New code paths (MCP deriver, OpenAPI consumer post-this-PR) read Table.properties (or both). The transition is purely additive. Per kriszyp: "I don't think there is a lot of actual usage of these existing attributes, so I think alignment here is worth potential back-compat changes, although I think we can largely maintain compatibility shims/behavior."

Scope note: The full alignment (every internal consumer eventually shifts to read properties as the canonical API, with attributes reserved for cases that genuinely need ordered iteration) is bigger than this PR. This PR delivers:

  1. The new properties getter / static on Table and Resource.
  2. GraphQL parser co-populates both shapes.
  3. MCP and OpenAPI consumers read properties (the canonical surface).
  4. Existing attributes consumers stay on attributes; deprecation/migration is opportunistic later.

Surface 1 — Operations profile

New file components/mcp/tools/schemas/operationDescriptions.ts:

/**
 * Hand-authored MCP tool descriptions for Harper operations.
 *
 * Authoring rubric:
 *   1. Verb-led sentence: "Creates …", "Returns …", "Restarts …".
 *   2. Disambiguating context when a sibling op could be picked instead.
 *      (search_jobs_by_start_date vs search_jobs_by_username, etc.)
 *   3. One cost/hazard sentence for heavy or destructive calls.
 *   4. Length budget: <= 400 chars. Long descriptions waste context and
 *      get truncated in some MCP clients.
 *
 * Coverage target: every op matching the v1 DEFAULT_ALLOW glob expansion,
 * plus every destructive op an operator might opt into.
 */
export const OPERATION_DESCRIPTIONS: Record<string, string> = {
  add_user: 'Creates a new Harper user with username, password, and role. Requires super_user. Username is immutable after creation.',
  alter_user: 'Updates an existing user\'s password, role, or active flag. Username cannot be changed; use add_user + drop_user for rename.',
  search_jobs_by_start_date: 'Lists background jobs started in a UTC time window. Useful for auditing recent imports, backups, replication tasks. Pair with get_job for full status.',
  get_job: 'Returns state and result payload for one background job by id (export, backup, deploy, restart).',
  get_status: 'Returns one entry from the in-memory status KV components publish health and progress to. Safe to poll. Use system_information for server-level health.',
  system_information: 'Snapshot of host metrics (CPU, memory, disk, network, replication lag). Heavy — scope with `attributes` (e.g. ["memory","replication"]). Do not poll faster than 10s.',
  describe_table: 'Returns one table\'s schema: attributes, types, indexes, and primary key. Use to discover what attributes a search_* call can filter on.',
  read_audit_log: 'Returns mutation history (insert/update/delete) for a table, filterable by timestamp, username, or primary-key value. Requires audit logging enabled.',
  restart: 'Restarts the Harper process. Disconnects all clients; replication catches up on reconnect. Destructive — confirm before invoking.',
  set_configuration: 'Mutates the running server configuration and persists to harperdb-config.yaml. Affects all workers. Destructive.',
  // … ~35 more
};

Integration at components/mcp/tools/operations.ts:195-201:

function buildDescription(operationName: string, hasCuratedSchema: boolean): string {
  const curated = OPERATION_DESCRIPTIONS[operationName];
  if (curated) return curated;
  const base = `Harper operation '${operationName}'.`;
  const schemaNote = hasCuratedSchema
    ? ' Arguments validated against the curated schema below.'
    : ' Arguments forwarded as-is; the server validates and returns a structured error on rejection.';
  return base + schemaNote;
}

Note on scope and #878. This catalog is a transitional artifact. Operations registered outside core (e.g., cluster_status from harper-pro) cannot have their descriptions live in this Harper-repo file; their authoritative description belongs alongside their implementation. #878 (introspectable operations API registration) is the structural fix — once it lands, the description for each operation lives with its schema and its handler in one place, and this catalog gets retired. Until then, the catalog covers the in-core surface and out-of-core ops fall back to the generic template. Shipping the catalog now does not constrain #878's design; the eventual home for each description is the same JSON-Schema-aligned per-op metadata #878 will introduce.

Surface 2 — Application verb tools (GraphQL docstring threading)

Step A — capture docstrings in the parser AND co-populate Table.properties. resources/graphql.ts already walks every ObjectTypeDefinitionNode and field — the graphql AST exposes .description as StringValueNode | undefined and it's currently dropped. We capture docstrings and, in the same pass, build the Table.properties Record (the canonical public API per the Foundation section above) alongside the existing Table.attributes Array (the internal enumerated form).

Edit resources/graphql.ts:60-173:

// In OBJECT_TYPE_DEFINITION branch (~line 63), after `types.set(typeName, typeDef)`:
if (definition.description?.value) typeDef.description = definition.description.value;
typeDef.properties = {};  // canonical Record; co-populated in the field loop below

// In the field loop (~line 111), after building `property` (Attribute) and pushing it to `typeDef.attributes`:
if (field.description?.value) property.description = field.description.value;
// Project to the JSON-Schema-shaped Record entry alongside the Array push.
typeDef.properties[property.name] = {
  type: graphqlTypeToJsonSchemaType(property.type),
  ...(property.description ? { description: property.description } : {}),
  ...(property.isPrimaryKey ? { primaryKey: true } : {}),
  ...(property.assignCreatedTime ? { assignCreatedTime: true } : {}),
  ...(property.assignUpdatedTime ? { assignUpdatedTime: true } : {}),
};

The graphqlTypeToJsonSchemaType helper maps Harper's existing type strings ('ID' | 'Int' | 'Float' | 'String' | 'Boolean' | …) to JSON Schema type strings ('string' | 'integer' | 'number' | 'boolean' | …). Roughly ~12 LOC for the mapping.

The result: every @table @export Resource exposes a static properties Record automatically post-merge. Authors who want richer metadata override with their own static properties = {...Product.properties, …}.

What docstring-annotated source looks like. GraphQL's """triple-quote docstring""" syntax is the idiomatic, parser-friendly way to describe types and fields — no new directive required. Tooling (IDE highlighting, GraphQL Voyager, gql-cli) already renders them:

"""
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.
  Format: 3-letter prefix + 3-digit number (e.g. "WID-001").
  """
  sku: String! @primaryKey

  """
  Display name shown in the storefront. 100 chars max.
  """
  name: String!

  """
  Current inventory level. Decremented by orders; nightly
  reconciliation pulls from the warehouse system.
  """
  inStock: Int!

  """
  Retail price in cents (USD).
  """
  priceCents: Int!

  """
  ISO 8601 timestamp of the last warehouse reconciliation.
  Null for SKUs that have never been counted.
  """
  lastCountedAt: String
}

The MCP layer consumes the type-level docstring as the prefix on every verb-tool description for Product, and each field docstring as the per-property description in derived input and output schemas.

Then thread description through resources/databases.ts makeTable(...) (~3 lines) and widen Attribute (resources/Table.ts:75-96) with an optional description?: string.

Step B — consume in tool registration via a structured composer. Edit components/mcp/tools/application.ts:408-485. Replace the inline baseDescription template (which produced mechanical-sounding output like "get on resource '/Product' (table Product)") with a per-verb sentence composer:

const VERB_SENTENCES: Record<string, (ctx: { tableName: string; primaryKey?: string }) => string> = {
  get:    ({ tableName, primaryKey }) => `Fetches a single ${tableName} record by ${primaryKey ?? 'id'}.`,
  search: ({ tableName })              => `Searches ${tableName} records by attribute conditions.`,
  create: ({ tableName })              => `Creates a new ${tableName} record.`,
  update: ({ tableName, primaryKey }) => `Replaces a ${tableName} record by ${primaryKey ?? 'id'} (PUT semantics).`,
  patch:  ({ tableName, primaryKey }) => `Partially updates a ${tableName} record by ${primaryKey ?? 'id'}.`,
  delete: ({ tableName, primaryKey }) => `Deletes a ${tableName} record by ${primaryKey ?? 'id'}.`,
};

function verbDescription(verb: string, ctx: { tableDoc?: string; tableName: string; primaryKey?: string }): string {
  const prefix = ctx.tableDoc ? `${ctx.tableDoc}\n\n` : '';
  const sentence = VERB_SENTENCES[verb](ctx);
  return `${prefix}${sentence} Runtime RBAC (allow${verb[0].toUpperCase() + verb.slice(1)}) enforces per-record access at call time.`;
}

Renders for get_Product (with the docstring above):

Product catalog row — what shows up in the storefront listing, search, and inventory feeds. One row per SKU.

Fetches a single Product record by sku. Runtime RBAC (allowGet) enforces per-record access at call time.

vs. today's mechanical output:

get on resource '/Product' (table Product). Runtime RBAC (allowGet) enforces per-record access at call time.

Step C — per-attribute schema descriptions. Edit components/mcp/tools/schemas/derive.ts attributeToProperty (~line 69): if attr.description is set, spread it onto the property's description field. Two lines.

Step D — OpenAPI consumption. The same captured docstrings flow into OpenAPI:

  • Path-level descriptions (resources/openApi.ts:179-248): the hardcoded description arguments to Post/Get/Put/Patch/Delete/Options constructors become Table.description || '<existing hardcoded default>'. When the docstring is set, the OpenAPI consumer reads it for each verb's path description; otherwise the existing defaults stay.
  • Per-property descriptions (resources/openApi.ts:102-139): extend the def.properties iterator to copy prop.description onto defProps[prop.name] (today the destructure ignores it). One line.
  • Schema-level description (resources/openApi.ts:380 ResourceSchema constructor): widen to accept an optional description argument; pass Table.description through at the call site (openApi.ts:80).
  • Response schemas (NEW — via Step F deriver below): wire the same deriveRecordSchema output into responses[200].content['application/json'].schema for each verb path. Same data, output direction.

OpenAPI today emits zero per-property descriptions, one hardcoded description per verb, and no response-body schemas. Post-change, a schema.graphql with """docstrings""" gives Swagger UI / Redoc readers the same context the LLM gets. Total OpenAPI integration: ~30 LOC, all in resources/openApi.ts; no new files.

Step E — GraphQL directives map to input vs. output schema behavior. The same type Foo @table @export definition drives BOTH inputSchema (existing) and outputSchema (new in Step F). The deriver projects differently per direction; Harper's existing GraphQL directives already encode everything needed:

GraphQL signal Input schema behavior Output schema behavior
field: Type! (non-null) required in create input required in output (server returns it)
field: Type (nullable) optional in create input optional in output (may be null)
field: ID @primaryKey required in update/patch (routing); optional in create (server may assign) always required in output (server returns the key)
field: Float @createdTime omitted from input (server-assigned, author can't set) required in output (server populates)
field: Float @updatedTime omitted from input required in output
"""docstring""" on type description prefix on tool description schema-level description in output
"""docstring""" on field per-property description in input per-property description in output

So the rule for "required on output" becomes: nullable === false OR assignCreatedTime OR assignUpdatedTime OR isPrimaryKey. All three Harper directive flags are already on Attribute (per resources/Table.ts:75-96); the deriver just consults them.

Step F — outputSchema deriver for the cheap verbs. Add to components/mcp/tools/schemas/derive.ts:

/** Full record shape — every attribute as it appears in returned records. */
function deriveRecordSchema(attributes: Attribute[]): object {
  return {
    type: 'object',
    properties: Object.fromEntries(
      attributes.map((a) => [a.name, attributeToProperty(a)])
    ),
    required: attributes
      .filter((a) =>
        a.nullable === false ||
        a.assignCreatedTime ||
        a.assignUpdatedTime ||
        a.isPrimaryKey
      )
      .map((a) => a.name),
    additionalProperties: false,
  };
}

export function deriveGetOutputSchema(attrs: Attribute[]) { return deriveRecordSchema(attrs); }
export function deriveCreateOutputSchema(attrs: Attribute[]) { return deriveRecordSchema(attrs); }
export function deriveUpdateOutputSchema(attrs: Attribute[]) { return deriveRecordSchema(attrs); }
export function derivePatchOutputSchema(attrs: Attribute[]) { return deriveRecordSchema(attrs); }

export function deriveDeleteOutputSchema(pk?: Attribute) {
  return {
    type: 'object',
    properties: {
      deleted: { type: 'boolean', const: true, description: 'True when the record was deleted.' },
      ...(pk ? { [pk.name]: { ...attributeToProperty(pk), description: `Primary key of the deleted record.` } } : {}),
    },
    required: pk ? ['deleted', pk.name] : ['deleted'],
    additionalProperties: false,
  };
}

// deriveSearchOutputSchema — INTENTIONALLY OMITTED for v1.
// Envelope shape (records vs data, cursor vs nextCursor, error semantics)
// is the open design question deferred to the sibling issue.

Wire into application.ts:414+. Each addTool call adds one line:

addTool({
  name: `get_${path}`,
  description: verbDescription('get', ctx),
  inputSchema: deriveGetSchema(attributes, pk),
  outputSchema: deriveGetOutputSchema(attributes),  // NEW
  annotations: { readOnlyHint: true },
});

Five lines total across the five cheap verbs. search_* deliberately omits outputSchema; MCP spec marks the field optional, clients handle absence.

Concrete output for get_Product (with the Product GraphQL above):

{
  "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." },
    "inStock": { "type": "integer", "description": "Current inventory level …" },
    "priceCents": { "type": "integer", "description": "Retail price in cents (USD)." },
    "lastCountedAt": { "type": ["string", "null"], "description": "ISO 8601 timestamp …" },
    "created": { "type": "number", "description": "" },
    "updated": { "type": "number", "description": "" }
  },
  "required": ["sku", "name", "inStock", "priceCents", "created", "updated"],
  "additionalProperties": false
}

outputSchema is JSON Schema by MCP spec (rev 2025-06-18, same vocabulary as inputSchema). No translation layer needed; the deriver emits JSON Schema directly.

Compat surface. For get/create/update/patch, the record shape is already implicit in the existing create_* inputSchema — emitting it as outputSchema adds zero new commitment. The delete_* envelope ({deleted: true, <pk>}) is a new commitment; verify against Harper's actual delete return value before merging; if non-standard, emit {type: 'object'} instead (loose-typed "an object").

Surface 3 — Custom mcpTools

Edit components/mcp/tools/application.ts:521-535 to gate the fallback through a deduped warning (module-level Set<string> keyed by ${path}:${methodName}):

const _warnedMissingDesc = new Set<string>();
let description = def.description;
if (!description) {
  const key = `${path}:${methodName}`;
  if (!_warnedMissingDesc.has(key)) {
    _warnedMissingDesc.add(key);
    harperLogger.warn(
      `MCP application: Resource '${path}' exposes mcpTool '${def.name}' without a description. ` +
      `LLM tool selection degrades without one; add { description: '...' } to the mcpTools entry.`
    );
  }
  description = `Custom MCP tool exposed by Resource '${path}' (method '${methodName}'). RBAC is enforced by the Resource itself.`;
}

Surface 4 — Application https://... resources

Edit components/mcp/resources.ts:297-313 — for each enumerated https://... entry, prepend Table.description when available:

description: tableDoc ? `${tableDoc} Application resource at /${path}.` : `Application resource at /${path}.`,

Same data source as Surface 2 — no new plumbing.

Surface 5 — Authoring static properties on Resource classes

The Foundation section above establishes static properties (Record) as the canonical public API on every Resource — table-backed and programmatic alike. This surface covers the authoring details: how programmatic Resources declare it directly, how table-backed Resources override or augment the auto-derived version, and the inheritance pattern.

For table-backed Resources (@table @export), static properties is auto-derived from GraphQL by Surface 2's parser changes. Authors don't need to write it — but they can override or extend it for richer metadata than the schema captures.

For programmatic Resources (Resource subclasses without @table @export backing — overriding get/post/put/delete directly, or aggregating across multiple tables ProductInventory-style), there's no GraphQL schema to derive from. Authors declare static properties directly. Same Record shape, same consumers (MCP and OpenAPI both read it).

An MCP-only override static mcp = { annotations? } covers genuinely MCP-specific knobs (annotation hints like idempotentHint) but is documented as discouraged — most authors should only need the shared static description + static properties.

Naming decision (resolved). static properties is the canonical name, per kriszyp's comment. Rationale: "Having parallel JSON schema graph alongside a JSON schema-ish graph doesn't help the situation, it makes it worse." .properties overload (5 usages today) is disambiguated by scope (class-level vs. instance-level). The new class-level static properties becomes the primary encouraged public API; Attribute.properties (Array, nested complex types) and Table.attributes (Array, internal enumeration) stay as the internal forms. See the Foundation section above for the full alignment story.

A) Shared (tool-agnostic) metadata — consumed by MCP AND OpenAPI:

import { Resource } from 'harperdb';

export class ProductInventory extends Resource {
  // Class-level description. Consumed by:
  //   - MCP: prefix on every verb-tool description
  //   - OpenAPI: schema-level `description` on the path's request/response schema,
  //     and (when no Resource-specific override exists) the path-level `description`
  static description =
    'Aggregate inventory analytics computed over the Product catalog. ' +
    'Read-only; the underlying Product table is the system of record.';

  // Canonical public API: JSON-Schema-shaped attribute map keyed by name.
  // Same surface as table-backed Resources (where it auto-derives from GraphQL).
  // Consumed by MCP, OpenAPI, and any future schema consumer.
  //
  // NOTE: distinct from Attribute.properties (resources/Table.ts:88) — that's an
  // ARRAY of nested attributes inside a single complex-type column. Different
  // scope (class-level vs. per-attribute), different shape (Record vs. Array).
  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) { /* … */ }
  async search(query) { /* … */ }
}

B) Optional static outputSchemas for per-verb return overrides. When a Resource's verb method returns a projection or non-record shape (e.g., ProductInventory.get returns {sku, onHand, reserved, stockStatus} rather than the underlying Product record), declare the override:

export class ProductInventory extends Resource {
  static description = '…';
  static properties = { /* input shape */ };

  // Optional per-verb output overrides. When omitted, the deriver falls
  // back to `static properties` as the record shape for the cheap
  // verbs (get/create/update/patch) and the synthesized {deleted, <pk>}
  // shape for delete. Search outputSchema is omitted entirely.
  static outputSchemas = {
    get: {
      type: 'object',
      properties: {
        sku: { type: 'string' },
        onHand: { type: 'integer' },
        reserved: { type: 'integer' },
        stockStatus: { type: 'string', enum: ['in_stock', 'out_of_stock', 'backorder'] },
      },
      required: ['sku', 'onHand', 'reserved', 'stockStatus'],
    },
    // Omit a verb entry → deriver uses default for that verb.
  };

  async get(id) { /* returns the projection above */ }
}

For Resources that return the full record shape (the common case), static outputSchemas is unnecessary — the deriver falls back to static properties automatically.

B.5) Extending a table — inheritance via spread. Per kriszyp's example, a Resource extending a @table @export Resource inherits its properties via JS class inheritance. Override individual entries with spread:

const { Product } = tables;

class CustomProduct extends Product {
  // Inherit Product.properties (auto-derived from Product's GraphQL schema by Surface 2);
  // override one entry with a custom description.
  static properties = {
    ...Product.properties,
    priceCents: {
      ...Product.properties.priceCents,
      description: 'Retail price in cents, including any per-customer adjustments.',
    },
  };
}

The author writes against properties (the canonical surface). Internal code that needs ordered iteration / index metadata continues to walk CustomProduct.attributes (the internal Array form, inherited from Product). MCP and OpenAPI both pick up the override transparently.

C) Narrow MCP override — for genuinely MCP-only knobs that don't fit JSON Schema:

export class ProductInventory extends Resource {
  static description = '…';
  static properties = { /* … */ };

  // Optional. The only field is per-verb MCP annotation overrides
  // (idempotentHint, etc. — annotations beyond OpenAPI's vocabulary).
  // Per-verb description overrides are deliberately NOT supported here;
  // edit `static description` or the verb-method's underlying semantics
  // if the generated description doesn't capture intent.
  static mcp = {
    annotations: {
      get: { idempotentHint: true },
    },
  };

  // Custom (non-verb) methods continue to use the existing opt-in. Unchanged.
  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: { idempotentHint: true },
    },
  ];

  async reconcileInventory(args) { /* … */ }
}

D) Fallback chain (programmatic Resources):

Field Source (in order)
MCP verb tool description static description + verb-composer sentence → verb-composer sentence alone (no per-verb override hook)
OpenAPI path-level description static description → existing hardcoded default in openApi.ts:179-248
MCP inputSchema per-property description static properties[name].description → derived primary-key default → omitted
OpenAPI per-property description static properties[name].description → omitted
MCP outputSchema (per verb) static outputSchemas[verb] → derived from static properties (get/create/update/patch) or synthesized (delete) → omitted (search)
OpenAPI response schema same data as MCP outputSchema, threaded through ResourceSchema
MCP verb tool annotations static mcp.annotations[verb] (override) → existing per-verb heuristic
Custom mcpTools (mcpTools[]) unchanged from Surface 3

E) @table Resources may also use these statics. A @table @export Resource can declare static description, static properties, and static outputSchemas to augment or override GraphQL docstrings / derived shapes. Precedence: explicit static > GraphQL docstring/derivation > existing default. Most @table Resources won't need this — the docstring path is the natural authorship locus.

F) Backwards compatibility. Resources without static description / static properties / static outputSchemas / static mcp produce today's output unchanged (generic descriptions, skeletal schemas, hardcoded OpenAPI strings, no outputSchema). Purely additive opt-in.

Integration changes for Surface 5

  • components/mcp/tools/application.ts:408-485verbDescription(verb, ctx) composer (from Surface 2 Step B) reads ResourceClass.description for the prefix. No per-verb override hook on programmatic Resources — static description is the only knob.
  • components/mcp/tools/application.ts:414+ (six verb addTool calls) — merge ResourceClass.mcp?.annotations?.[verb] over the per-verb default annotations; consult ResourceClass.outputSchemas?.[verb] before the deriver fallback.
  • components/mcp/tools/schemas/derive.ts — shifts to consuming Table.properties / ResourceClass.properties (Record) as the canonical input. Table.attributes (Array) stays as the internal form and is co-populated for code paths that need ordered iteration / index metadata. Apply to both input and output derivers.
  • components/mcp/tools/application.ts:586 (where verb tools currently read attributes) — same fallback as the deriver.
  • resources/openApi.ts:80,102-139,179-248,380ResourceSchema widened to accept description; the path-description constructors consult ResourceClass.description first; the per-property iterator copies prop.description; the static properties object is read alongside def.properties; the deriveRecordSchema output threads into responses[200].content for each verb path.
  • resources/Resource.ts — declare optional static description?: string, static properties?: Record<string, JsonSchemaFragment>, static outputSchemas?: Record<Verb, JsonSchemaFragment>, static mcp?: { annotations?: Record<Verb, Annotations> } on the class type so TypeScript authors get autocomplete.
  • No new file needed. Surfaces consume static * inline.

Tool argument coverage audit (inputSchema quality)

Tool descriptions are one quality axis; tool argument shapes are another. Descriptions tell the LLM which tool to pick; inputSchema tells it how to fill in the arguments. A well-described tool with { type: 'object', additionalProperties: true } as its input schema is essentially "this exists and you can call it somehow" — the LLM has to guess argument names and shapes.

Audit of the current inputSchema surface

Surface Count Quality
Operations with curated OPERATION_INPUT_SCHEMAS 23 of ~84 ✅ Rich JSON Schema with required, typed properties
Operations without curated schema (fall back to PERMISSIVE_SCHEMA) ~61 of ~84 ⚠️ { type: 'object', additionalProperties: true } — LLM gets no argument shape
Application verb tools (table-backed) n/a ✅ Derived from Table.attributes — typed and complete
Application verb tools (programmatic Resource) n/a today Post-Surface-5: derived from static properties
Custom mcpTools with author-supplied inputSchema author's choice ✅ Author-controlled
Custom mcpTools without inputSchema falls back ⚠️ { type: 'object', additionalProperties: true } fallback (application.ts:526) — same gap

The two additionalProperties: true fallbacks are real LLM-usability gaps. For DEFAULT_ALLOW operations and for any opt-in mcpTools, the tool will be listed but hard to invoke correctly.

Operations: tighten via CI lint

A test in unitTests/components/mcp/tools/operations.test.js expands the v1 DEFAULT_ALLOW glob against OPERATION_FUNCTION_MAP and asserts every matched operation has an entry in OPERATION_INPUT_SCHEMAS. Catches "added a new safe getter to the allow list, forgot the schema."

For operations outside DEFAULT_ALLOW (opt-in by operators): no requirement, PERMISSIVE_SCHEMA is acceptable since the operator explicitly chose to expose them.

For ops registered outside core (harper-pro etc., per #878): the structural fix is the same one that solves description sourcing — schemas live next to handlers in the centralized registry. Until #878, those operations fall back to PERMISSIVE_SCHEMA with a one-time info log naming the operation.

Custom mcpTools: warn-once on missing inputSchema

Mirror the description warn-once pattern from Surface 3:

// In components/mcp/tools/application.ts custom-tool registration:
let inputSchema = def.inputSchema;
if (!inputSchema) {
  const key = `${path}:${def.name}:inputSchema`;
  if (!_warnedMissingInput.has(key)) {
    _warnedMissingInput.add(key);
    harperLogger.warn(
      `MCP application: Resource '${path}' exposes mcpTool '${def.name}' without an inputSchema. ` +
      `LLM cannot construct typed arguments; add { inputSchema: { type: 'object', properties: {...}, required: [...] } } to the mcpTools entry.`
    );
  }
  inputSchema = { type: 'object', additionalProperties: true };
}

Same dedup key shape as the description warn-once. Logged at warn level since this materially affects tool usability (vs. description, which is info).

Out of scope for argument quality

  • Authoring 61 operation schemas. The catalog growth here pairs with OPERATION_DESCRIPTIONS (Surface 1) — both naturally grow together as the v1 surface fills out. Don't block this PR on hitting 100% coverage; ship the lint as the structural fix, fill in schemas opportunistically.
  • Argument schemas for custom Resource methods inferred from TypeScript signatures. Same rejection as JSDoc extraction — needs typescript at runtime.

Other-payload extensions (in scope)

annotations.idempotentHint

Semantics matter — be conservative. Per MCP spec, idempotentHint: true signals "safe to retry; same observable outcome on repeat call." That's a stronger claim than "doesn't crash on retry." add_user("bob") on first call returns the created user; second call returns an "already exists" error. The observable outcome differs → NOT idempotent for this purpose. Setting the hint there nudges the LLM toward retry behavior that produces confusing errors. Under-annotate before mis-annotate.

Add a narrow set to operations.ts:

const IDEMPOTENT_OPERATIONS: ReadonlySet<string> = new Set([
  // PUT-semantics writes — replacing with the same payload is idempotent.
  // Add specific operations here only after verifying the handler's actual
  // behavior on repeat calls.
  // 'set_configuration' — included only if confirmed state-set, not state-merge
]);

Excluded explicitly (NOT idempotent under MCP semantics): add_user, add_role, all create_*, all add_* — second call returns an "already exists" error, different observable outcome.

Application verb tools:

  • update (PUT semantics) → idempotentHint: true — replacing with the same payload yields the same state
  • patch → depends on the partial-update semantics; skip unless we can verify
  • delete → depends on Harper's delete-of-deleted behavior; verify before annotating. If it returns the same {deleted: true} shape on repeat, annotate; if it returns a 404/error, do NOT annotate
  • create, get, search → NOT annotated (create not idempotent; get/search are covered by readOnlyHint which is the stronger signal anyway)

For operations and verbs whose idempotency is undetermined, omit the hint. The MCP spec defaults idempotentHint to false when omitted, which is the safe default.

annotations.title

Optional human-readable display name. Spec'd as the field MCP clients should prefer when rendering tools in a UI list (vs. the machine name).

For operations: title can be a Title Case form of the name (add_userAdd user) — but this is mechanical and offers little signal beyond the name itself. Recommendation: skip for v1.1, revisit if MCP-client UIs surface a real ask.

For application verb tools: similar — Get Product, Search Product, Delete Product. Recommendation: skip. Names are already readable.

outputSchema

Spec'd in MCP rev 2025-06-18 as an optional outputSchema: object on tool descriptors — already JSON Schema, same vocabulary as inputSchema. No translation layer needed.

Split into cheap and expensive cases:

Cheap cases (in scope this PR — see Surface 2 Step F):

  • get_*, create_*, update_*, patch_* — return the record shape. Identical to the create_* inputSchema's record shape, just projected with output-direction required (server-assigned + non-null). Zero new compat surface — the shape was already locked when inputSchema shipped.
  • delete_* — synthesized {deleted: true, <pk>}. One small new compat commitment; verify against Harper's actual delete return before merging.

Expensive cases (deferred to sibling issue):

  • search_* — envelope shape ({records, cursor} vs {data, nextCursor} vs alternatives) is a wire-contract decision. outputSchema is omitted entirely until the sibling issue [MCP/OpenAPI] Return envelope for search and list verbs #1107 picks one.
  • Operations outputSchema — per-op research cost matches the description cost; defer to follow-up.

Same data feeds OpenAPI's response schemas (Surface 2 Step D) — single source, two consumers.

Tool argument quality (see "Tool argument coverage audit" section above)

inputSchema quality is a separate axis from description quality. Audit + CI lint + warn-once added; covered in its own section.

annotations.openWorldHint

Signals that the tool may interact with services outside the immediate environment. Most Harper operations are local (database operations, file IO on the server). A few touch external state (add_node for replication peers, deploy_component if it fetches from a registry).

Recommendation: skip for v1.1. Low signal value; few Harper ops touch external services.

Before / after examples

add_user (operations):

Field Today After
description Harper operation 'add_user'. Arguments forwarded as-is; the server validates and returns a structured error on rejection. Creates a new Harper user with username, password, and role. Requires super_user. Username is immutable after creation.
annotations.readOnlyHint (omitted — correct) (omitted)
annotations.destructiveHint (omitted — correct) (omitted)
annotations.idempotentHint (omitted) (still omitted — add_user is NOT idempotent under MCP semantics; second call returns "already exists" error, different observable outcome)

search_Product (application, with """Product catalog row — title, SKU, inventory, pricing.""" on type Product @table @export and """Stock keeping unit, unique per catalog.""" on the sku field):

// Today:
{
  "name": "search_Product",
  "description": "search on resource '/Product' (table Product). Runtime RBAC (allowSearch) enforces per-record access at call time.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "conditions": {
        "type": "array",
        "items": {
          "properties": {
            "attribute": { "type": "string", "description": "Attribute name to filter on." },
            ...
          }
        }
      },
      ...
    }
  }
}

// After:
{
  "name": "search_Product",
  "description": "Product catalog row — title, SKU, inventory, pricing. search on resource '/Product' (table Product). Runtime RBAC (allowSearch) enforces per-record access at call time.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "conditions": {
        "type": "array",
        "items": {
          "properties": {
            "attribute": {
              "type": "string",
              "enum": ["id", "title", "sku", "price", "in_stock"],
              "description": "Attribute name to filter on. id: primary key. sku: Stock keeping unit, unique per catalog. ..."
            },
            ...
          }
        }
      },
      ...
    }
  },
  "annotations": { "readOnlyHint": true }
}

(The enum on attribute is a stretch goal — the schema deriver already knows the attribute list; emitting it as an enum gives the LLM a closed set instead of a free string.)

Acceptance criteria

  • components/mcp/tools/schemas/operationDescriptions.ts exists with ~45 hand-authored entries covering DEFAULT_ALLOW expansion + common opt-in destructive ops

  • Each entry follows the authoring rubric (verb-led, disambiguating, cost/hazard, ≤ 400 chars)

  • buildDescription (operations.ts) prefers OPERATION_DESCRIPTIONS over the template

  • resources/graphql.ts captures description from both ObjectTypeDefinitionNode and FieldDefinitionNode; covered by a unit test in unitTests/resources/

  • Table.description and Attribute.description flow through to MCP registration

  • baseDescription in application.ts prefixes the table docstring when present

  • attributeToProperty in derive.ts propagates per-attribute descriptions to derived schemas

  • Custom mcpTools without description emit a deduped warn at registration

  • https://... application resources use Table.description when available

  • IDEMPOTENT_OPERATIONS set populated; idempotentHint emitted for matching ops

  • update, patch, delete verb tools annotated idempotentHint: true

  • Application verb tools emit outputSchema derived from the same attribute metadata

  • Integration test: tools/list against a fixture schema with docstrings → asserts description includes the docstring; inputSchema.properties[*] carry attribute descriptions

  • Integration test: tools/list against the operations profile → asserts curated descriptions land on the right tools
    Foundation — properties as canonical Resource/Table API:

  • Resource and Table class types widened to declare optional static description?: string and static properties?: Record<string, JsonSchemaFragment>

  • Resource.properties getter projects from Resource.attributes lazily when no static properties is supplied (backward-compatible default for existing classes)

  • @table @export Resources expose static properties automatically post-merge, derived from the GraphQL parser (Surface 2 Step A); Table.attributes Array still exists, populated in the same parser pass

  • Bidirectional consistency: every fixture Resource asserts that properties[name] and attributes.find(a => a.name === name) describe the same data

  • Inheritance via extends carries static properties to the child; spread-override (static properties = {...Parent.properties, foo: {...}}) works as documented (test fixture matches kriszyp's CustomProduct example)

  • Back-compat: existing code paths reading Table.attributes continue to read the same Array data unchanged

Surface 5 (programmatic Resources):

  • Resource class type allows optional static mcp: { annotations? } for MCP-only overrides; documented as discouraged for general use
  • Programmatic Resource with static description emits that as the prefix on every MCP verb-tool description AND as the path-level description in OpenAPI
  • Programmatic Resource with static properties produces non-skeletal MCP input schemas — each declared property appears with its description and type
  • Programmatic Resource with static properties enriches OpenAPI request/response schemas with per-property descriptions
  • static mcp.annotations.get.idempotentHint = true emits the hint on the get_* MCP tool
  • @table Resource with GraphQL docstrings and no override produces identical MCP + OpenAPI output to one with an explicit static description + static properties declaration (both paths converge)
  • Backwards compat: existing Resource subclasses without any new statics produce today's output unchanged across MCP + OpenAPI
  • Naming-overload documentation: spec explicitly distinguishes class-level static properties (Record) from per-attribute Attribute.properties (Array, nested complex types) AND from Table.attributes (Array, internal enumerated form)

OpenAPI consumption (Surface 2 + Surface 5):

  • resources/openApi.ts reads Table.description for path-level descriptions; hardcoded defaults remain as fallback
  • resources/openApi.ts reads per-attribute description from the attribute iterator (openApi.ts:102-139); previously dropped
  • ResourceSchema constructor (openApi.ts:380) accepts and emits an optional description
  • resources/openApi.ts emits responses[200].content schemas from deriveRecordSchema for get/create/update/patch verbs
  • Integration test: a Resource (table-backed OR programmatic) with descriptions present surfaces them in both MCP tools/list AND OpenAPI /openapi.json output

Verb description composer (Surface 2 Step B):

  • verbDescription(verb, ctx) composer replaces inline baseDescription; verb sentences are verb-specific ("Fetches…", "Searches…", "Creates…", etc.) not the mechanical "get on resource '/X'" template
  • Verb tool descriptions render with ${tableDoc}\n\n${verbSentence} Runtime RBAC… when docstring is present; ${verbSentence} Runtime RBAC… alone otherwise

outputSchema (cheap cases):

  • deriveRecordSchema, deriveGetOutputSchema, deriveCreateOutputSchema, deriveUpdateOutputSchema, derivePatchOutputSchema, deriveDeleteOutputSchema exist in derive.ts
  • application.ts addTool calls for get/create/update/patch/delete pass an outputSchema; search_* does NOT
  • outputSchema for get includes server-assigned fields (@createdTime, @updatedTime, @primaryKey) as required; inputSchema for create excludes them from required
  • Programmatic Resource with static outputSchemas.get = {…} emits that override; without it, falls back to deriveRecordSchema(static properties)
  • delete_* outputSchema verified against Harper's actual delete return value before merging; if non-standard, emit {type: 'object'} instead

idempotentHint (tightened):

  • IDEMPOTENT_OPERATIONS set excludes add_user, add_role, all create_* (NOT idempotent under MCP semantics)
  • Test asserts idempotentHint: true is NOT emitted for add_user or any create_* tool
  • Application verb tool update_* carries idempotentHint: true (PUT semantics — repeatable with same payload)
  • patch_* and delete_* carry idempotentHint only if Harper's actual behavior verifies repeat-safety

Tool argument coverage:

  • CI lint asserts every operation in DEFAULT_ALLOW glob expansion has an entry in OPERATION_INPUT_SCHEMAS
  • Custom mcpTools registered without inputSchema emit a deduped warn at registration (level: warn, since it affects usability)
  • Fallback PERMISSIVE_SCHEMA ({type:'object', additionalProperties: true}) still works; no behavior break for opt-in operations

Out of scope (deferred)

  • annotations.title (skip until MCP-client UIs ask for it)
  • annotations.openWorldHint (low signal in Harper context)
  • outputSchema on operations (per-op research cost ≈ description cost; defer to follow-up)
  • outputSchema on search_* verbs — envelope shape is the open design question; tracked in sibling issue [MCP/OpenAPI] Return envelope for search and list verbs #1107
  • Authoring all ~61 missing operation inputSchema entries — opportunistic content work; CI lint enforces coverage going forward but doesn't block this PR on hitting 100%
  • static mcp.verbs per-verb description override — deliberately omitted (edge case; if author needs per-verb specificity, edit static description or declare a custom static mcpTools entry that supersedes the verb tool)
  • TypeScript JSDoc extraction from Resource source files
  • Per-locale / translated descriptions
  • attribute field becoming an enum (stretch — covered separately if scope allows)

LOC envelope

Item LOC Kind
operationDescriptions.ts content (~45 ops × ~3 LOC) ~140 content
operations.ts factory change + (narrow) idempotent set ~10 infra
resources/graphql.ts docstring capture + co-populate typeDef.properties Record + graphqlTypeToJsonSchemaType helper ~20 infra
resources/databases.ts + Table.ts widening (thread description + properties through makeTable) ~15 infra
resources/Resource.ts properties getter (lazy projection from attributes when not statically overridden) + class type widening ~25 infra
application.ts verbDescription composer + custom-tool description+inputSchema warn + idempotent hints ~35 infra
derive.ts shift to consuming ResourceClass.properties (canonical) + per-attribute description + per-verb input/output deriver wrappers ~50 infra
resources.ts https-resource description prefix ~6 infra
application.ts lookups for static description / static outputSchemas / static mcp on Resources (table-backed and programmatic both) ~20 infra
resources/openApi.ts shifts to reading Resource.properties (canonical); emits Table.description + per-attribute descriptions + class-level statics + per-verb response schemas ~40 infra
Tool-argument coverage: CI lint for DEFAULT_ALLOW vs OPERATION_INPUT_SCHEMAS + warn-once for mcpTools missing inputSchema ~30 infra
Back-compat tests: existing Table.attributes consumers (replication, audit, REST middleware, etc.) still read the same array data after the alignment ~50 tests
Bidirectional consistency tests: every fixture asserts properties and attributes describe the same data for the same Resource ~30 tests
Unit tests (graphql parser produces both shapes, application docstrings, operations catalog, custom warn dedup, outputSchema per-verb, static properties inheritance/override, end-to-end across MCP + OpenAPI, idempotentHint scope, argument-coverage lint) ~360 tests
Total ~831 ~140 content / ~251 infra / ~440 tests

One focused PR; reviewer load similar to PR-1 (#856).

Sequencing

Single PR off main. Touches components/mcp/, resources/graphql.ts, resources/databases.ts, resources/Table.ts, and a new unit test file. No companion docs PR needed — the existing harper/documentation PR #507 should be updated post-merge to mention the """docstring""" convention for @table @export types in the MCP section.

Risks

  • GraphQL parser change is in a hot boot path. Mitigation: change is a pure data-pass-through; covered by a fresh unit test; backward compatible (no docstring → identical behavior to today).
  • Attribute type widening. Adding description?: string may surface in JSON.stringify(attributes) paths used by replication/audit. Spot-check dataLayer/schemaDescribe.ts and any attributes serializers before merging.
  • Operations catalog content quality. A bad description is worse than the generic template (misleads the LLM). Authoring rubric + length cap mitigate; PR review must scrutinize every entry.
  • delete_* outputSchema commits us to {deleted: true, <pk>} envelope. Mitigation: pre-merge, verify what Harper's actual delete handler returns end-to-end. If it doesn't match (e.g., empty body, different envelope), drop outputSchema for delete_* and emit nothing rather than emit a lie. The other four cheap verbs add no new commitment.
  • idempotentHint over-claim risk. Mis-annotating an op as idempotent leads LLMs to retry unsafe operations. Mitigation: ship an EMPTY IDEMPOTENT_OPERATIONS set if we can't verify a single op end-to-end. Under-annotate before mis-annotate. PR review checks each entry's repeat-call behavior.
  • Resource.description naming collision. Some Harper-internal Resource subclasses may already declare a description property at the instance level. Class-level static description doesn't collide at runtime with instance-level fields, but a pre-merge grep confirms before merging. If a collision surfaces, fall back to static mcpDescription for the class-level static.
  • Search envelope deferred risk. Shipping verb tools without search_* outputSchema means MCP clients fall back to "structure unknown" for search results. This is the spec-allowed default (outputSchema is optional) but a user-visible gap. Sibling issue tracks this; merge here doesn't block ever shipping it.
  • propertiesattributes bidirectional consistency. The two shapes describe the same data; if they drift, MCP/OpenAPI consumers see a different view than internal consumers do. Mitigation: the GraphQL parser co-populates both in one pass (single source of truth at construction); the properties getter for non-overridden classes projects from attributes lazily. Bidirectional-consistency tests in the test plan assert agreement for every fixture Resource.
  • Existing attributes consumers. The internal Array form is preserved unchanged; replication, audit, REST middleware paths continue to work. Risk: an existing consumer that does Object.keys(table.properties) (the old ResourceSchema.properties JSON-Schema object emitted at the OpenAPI boundary) gets a different shape now that properties is canonical at the class level. Pre-merge grep + back-compat test suite to lock this in.
  • extends inheritance edge cases. Spread-override (static properties = {...Parent.properties, foo: {...}}) requires Parent.properties to be resolved at static-init time. For programmatic Resources extending @table @export-backed ones, the parent's properties is auto-derived from the GraphQL parse — must complete before the extending class is initialized. Mitigation: defer static properties resolution to a getter, OR ensure parser runs in the component-load lifecycle before user class statics evaluate. Verify with a fixture that exercises the pattern Kris described.

Metadata

Metadata

Assignees

Labels

area:mcpModel Context Protocol (MCP) server: protocol, profiles, stdio CLI

Type

No type

Fields

Priority

None yet

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions