You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
Every Harper-shipped operation tool carries a hand-authored, LLM-grade description (MCP).
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.
Component-author custom MCP tools are nudged (but not blocked) to ship a description.
Where the spec gives us other tool-payload signals (annotations.title, annotations.idempotentHint, outputSchema), evaluate and fill in the ones with clear wins.
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.
❌ 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;classCustomProductextendsProduct{staticproperties={
...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:
The new properties getter / static on Table and Resource.
GraphQL parser co-populates both shapes.
MCP and OpenAPI consumers read properties (the canonical surface).
Existing attributes consumers stay on attributes; deprecation/migration is opportunistic later.
Surface 1 — Operations profile
New filecomponents/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. */exportconstOPERATION_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:
functionbuildDescription(operationName: string,hasCuratedSchema: boolean): string{constcurated=OPERATION_DESCRIPTIONS[operationName];if(curated)returncurated;constbase=`Harper operation '${operationName}'.`;constschemaNote=hasCuratedSchema
? ' Arguments validated against the curated schema below.'
: ' Arguments forwarded as-is; the server validates and returns a structured error on rejection.';returnbase+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.
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."""typeProduct@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.tsmakeTable(...) (~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:
constVERB_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'}.`,};functionverbDescription(verb: string,ctx: {tableDoc?: string;tableName: string;primaryKey?: string}): string{constprefix=ctx.tableDoc ? `${ctx.tableDoc}\n\n` : '';constsentence=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.tsattributeToProperty (~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:380ResourceSchema 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. */functionderiveRecordSchema(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,};}exportfunctionderiveGetOutputSchema(attrs: Attribute[]){returnderiveRecordSchema(attrs);}exportfunctionderiveCreateOutputSchema(attrs: Attribute[]){returnderiveRecordSchema(attrs);}exportfunctionderiveUpdateOutputSchema(attrs: Attribute[]){returnderiveRecordSchema(attrs);}exportfunctionderivePatchOutputSchema(attrs: Attribute[]){returnderiveRecordSchema(attrs);}exportfunctionderiveDeleteOutputSchema(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:
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=newSet<string>();letdescription=def.description;if(!description){constkey=`${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';exportclassProductInventoryextendsResource{// 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`staticdescription='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).staticproperties={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.'},};asyncget(id){/* … */}asyncsearch(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:
exportclassProductInventoryextendsResource{staticdescription='…';staticproperties={/* 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.staticoutputSchemas={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.};asyncget(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;classCustomProductextendsProduct{// Inherit Product.properties (auto-derived from Product's GraphQL schema by Surface 2);// override one entry with a custom description.staticproperties={
...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:
exportclassProductInventoryextendsResource{staticdescription='…';staticproperties={/* … */};// 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.staticmcp={annotations: {get: {idempotentHint: true},},};// Custom (non-verb) methods continue to use the existing opt-in. Unchanged.staticmcpTools=[{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},},];asyncreconcileInventory(args){/* … */}}
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.
components/mcp/tools/application.ts:408-485 — verbDescription(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,380 — ResourceSchema 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 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)
✅ 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 mcpToolswithoutinputSchema
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:letinputSchema=def.inputSchema;if(!inputSchema){constkey=`${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:
constIDEMPOTENT_OPERATIONS: ReadonlySet<string>=newSet([// 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_user → Add 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.
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):
(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
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.tsaddTool 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)
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)
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.
properties ↔ attributes 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.
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:"Harper operation 'add_user'. Arguments forwarded as-is; the server validates and returns a structured error on rejection.""get on resource '/Product' (table Product). Runtime RBAC (allowGet) enforces per-record access at call time."Table.attributesto draw from). No metadata hook to override either.mcpToolsharper://*The problem: an LLM picking between
get_Product,get_Order,get_Customersees 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[*].descriptionis empty for user-defined attributes. The LLM is told thatsearch_Productaccepts aconditions[].attributefield 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-248hardcodes path-level descriptions and:102-139emits no per-property descriptions at all. Once the schema layer carriesdescriptionfor 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
annotations.title,annotations.idempotentHint,outputSchema), evaluate and fill in the ones with clear wins.static descriptionand a JSON-Schema-shaped attribute map) is consumed by both MCP and OpenAPI from a single source. No MCP-specific descriptive namespace.Non-goals
typescriptpackage into the runtime; cost > benefit when authors can adddescriptiontwo lines away).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| OAIQuantified state:
MCP surfaces:
namedescriptioninputSchemainputSchema.properties[*].descriptionannotations.readOnlyHintannotations.destructiveHintannotations.idempotentHintannotations.openWorldHintannotations.titleoutputSchemaOpenAPI surface (
resources/openApi.ts):description(per verb)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[*].descriptionopenApi.ts:144—'primary key of record'). No others.components.schemas[*].description(path/type level)ResourceSchemaconstructor (openApi.ts:380) doesn't accept a description.components.schemas[*].properties[*].descriptionopenApi.ts:102-139destructures{ type, name, elements, relationship, definition, nullable }and ignoresdescription. 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 endFoundation —
propertiesas the canonical Resource/Table public APIPer 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:
Table.attributes/ResourceClass.attributesArray<Attribute>— the primary surface for everythingTable.properties/ResourceClass.propertiesRecord<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 columnArrayfor the same internal reason (nested traversal with metadata). The public counterpart for nested objects would also beAttribute.<some name for child properties>as a Record. Initial pass keeps the existing array; only the class-level alignment is in scope here.Why
propertiesand notschema: 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 inheritsPropertiesvia JS class inheritance. Override with spread:CustomProduct.attributesand anyCustomProduct.properties.nestedObject.attributesstill exist for internal enumerated access. The author writes againstproperties(the public API); internal code walksattributes(the iterable form).Bidirectional consistency.
propertiesandattributesdescribe the same data in two shapes; they must agree. Three options for how to maintain that: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.attributesderives fromproperties. Define a getter that converts the Record to an Array on access. Single source of truth. Marginal cost on iteration paths.propertiesderives fromattributes. 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
propertieson the Resource/Table class that lazily projects fromattributeswhen the user hasn't supplied astatic propertiesoverride. This means:Table.propertiesreturns the projection ofTable.attributesautomatically. Zero author work for backward-compatible behavior.static properties = {...}to override. JS class field shadowing handles precedence.static properties = {...}directly — same surface, same shape.extendscarries the static down; spread-override is the natural extension pattern.Back-compat. Existing code paths read
Table.attributesand continue to work — that's the internal array form, preserved unchanged. New code paths (MCP deriver, OpenAPI consumer post-this-PR) readTable.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
propertiesas the canonical API, withattributesreserved for cases that genuinely need ordered iteration) is bigger than this PR. This PR delivers:propertiesgetter / static onTableandResource.properties(the canonical surface).attributesconsumers stay onattributes; deprecation/migration is opportunistic later.Surface 1 — Operations profile
New file
components/mcp/tools/schemas/operationDescriptions.ts:Integration at
components/mcp/tools/operations.ts:195-201:Note on scope and #878. This catalog is a transitional artifact. Operations registered outside core (e.g.,
cluster_statusfrom 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.tsalready walks everyObjectTypeDefinitionNodeand field — the graphql AST exposes.descriptionasStringValueNode | undefinedand it's currently dropped. We capture docstrings and, in the same pass, build theTable.propertiesRecord (the canonical public API per the Foundation section above) alongside the existingTable.attributesArray (the internal enumerated form).Edit
resources/graphql.ts:60-173:The
graphqlTypeToJsonSchemaTypehelper 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 @exportResource exposes astatic propertiesRecord automatically post-merge. Authors who want richer metadata override with their ownstatic 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: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
descriptionthroughresources/databases.tsmakeTable(...)(~3 lines) and widenAttribute(resources/Table.ts:75-96) with an optionaldescription?: string.Step B — consume in tool registration via a structured composer. Edit
components/mcp/tools/application.ts:408-485. Replace the inlinebaseDescriptiontemplate (which produced mechanical-sounding output like"get on resource '/Product' (table Product)") with a per-verb sentence composer:Renders for
get_Product(with the docstring above):vs. today's mechanical output:
Step C — per-attribute schema descriptions. Edit
components/mcp/tools/schemas/derive.tsattributeToProperty(~line 69): ifattr.descriptionis set, spread it onto the property'sdescriptionfield. Two lines.Step D — OpenAPI consumption. The same captured docstrings flow into OpenAPI:
resources/openApi.ts:179-248): the hardcodeddescriptionarguments toPost/Get/Put/Patch/Delete/Optionsconstructors becomeTable.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.resources/openApi.ts:102-139): extend thedef.propertiesiterator to copyprop.descriptionontodefProps[prop.name](today the destructure ignores it). One line.resources/openApi.ts:380ResourceSchemaconstructor): widen to accept an optionaldescriptionargument; passTable.descriptionthrough at the call site (openApi.ts:80).deriveRecordSchemaoutput intoresponses[200].content['application/json'].schemafor 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.graphqlwith"""docstrings"""gives Swagger UI / Redoc readers the same context the LLM gets. Total OpenAPI integration: ~30 LOC, all inresources/openApi.ts; no new files.Step E — GraphQL directives map to input vs. output schema behavior. The same
type Foo @table @exportdefinition drives BOTHinputSchema(existing) andoutputSchema(new in Step F). The deriver projects differently per direction; Harper's existing GraphQL directives already encode everything needed:field: Type!(non-null)field: Type(nullable)field: ID @primaryKeyfield: Float @createdTimefield: Float @updatedTime"""docstring"""on typedescriptionin output"""docstring"""on fieldSo the rule for "required on output" becomes:
nullable === falseORassignCreatedTimeORassignUpdatedTimeORisPrimaryKey. All three Harper directive flags are already onAttribute(perresources/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:Wire into
application.ts:414+. EachaddToolcall adds one line:Five lines total across the five cheap verbs.
search_*deliberately omitsoutputSchema; 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 }outputSchemais JSON Schema by MCP spec (rev 2025-06-18, same vocabulary asinputSchema). 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 existingcreate_*inputSchema — emitting it as outputSchema adds zero new commitment. Thedelete_*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-535to gate the fallback through a deduped warning (module-levelSet<string>keyed by${path}:${methodName}):Surface 4 — Application
https://...resourcesEdit
components/mcp/resources.ts:297-313— for each enumeratedhttps://...entry, prependTable.descriptionwhen available:Same data source as Surface 2 — no new plumbing.
Surface 5 — Authoring
static propertieson Resource classesThe 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 propertiesis 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 @exportbacking — overridingget/post/put/deletedirectly, or aggregating across multiple tablesProductInventory-style), there's no GraphQL schema to derive from. Authors declarestatic propertiesdirectly. 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 likeidempotentHint) but is documented as discouraged — most authors should only need the sharedstatic description+static properties.A) Shared (tool-agnostic) metadata — consumed by MCP AND OpenAPI:
B) Optional
static outputSchemasfor per-verb return overrides. When a Resource's verb method returns a projection or non-record shape (e.g.,ProductInventory.getreturns{sku, onHand, reserved, stockStatus}rather than the underlying Product record), declare the override:For Resources that return the full record shape (the common case),
static outputSchemasis unnecessary — the deriver falls back tostatic propertiesautomatically.B.5) Extending a table — inheritance via spread. Per kriszyp's example, a Resource extending a
@table @exportResource inherits itspropertiesvia JS class inheritance. Override individual entries with spread:The author writes against
properties(the canonical surface). Internal code that needs ordered iteration / index metadata continues to walkCustomProduct.attributes(the internal Array form, inherited fromProduct). MCP and OpenAPI both pick up the override transparently.C) Narrow MCP override — for genuinely MCP-only knobs that don't fit JSON Schema:
D) Fallback chain (programmatic Resources):
descriptionstatic description+ verb-composer sentence → verb-composer sentence alone (no per-verb override hook)descriptionstatic description→ existing hardcoded default inopenApi.ts:179-248inputSchemaper-propertydescriptionstatic properties[name].description→ derived primary-key default → omitteddescriptionstatic properties[name].description→ omittedoutputSchema(per verb)static outputSchemas[verb]→ derived fromstatic properties(get/create/update/patch) or synthesized (delete) → omitted (search)ResourceSchemaannotationsstatic mcp.annotations[verb](override) → existing per-verb heuristicmcpTools[])E)
@tableResources may also use these statics. A@table @exportResource can declarestatic description,static properties, andstatic outputSchemasto augment or override GraphQL docstrings / derived shapes. Precedence: explicit static > GraphQL docstring/derivation > existing default. Most@tableResources won't need this — the docstring path is the natural authorship locus.F) Backwards compatibility. Resources without
static description/static properties/static outputSchemas/static mcpproduce 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-485—verbDescription(verb, ctx)composer (from Surface 2 Step B) readsResourceClass.descriptionfor the prefix. No per-verb override hook on programmatic Resources —static descriptionis the only knob.components/mcp/tools/application.ts:414+(six verbaddToolcalls) — mergeResourceClass.mcp?.annotations?.[verb]over the per-verb default annotations; consultResourceClass.outputSchemas?.[verb]before the deriver fallback.components/mcp/tools/schemas/derive.ts— shifts to consumingTable.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 readattributes) — same fallback as the deriver.resources/openApi.ts:80,102-139,179-248,380—ResourceSchemawidened to acceptdescription; the path-description constructors consultResourceClass.descriptionfirst; the per-property iterator copiesprop.description; thestatic propertiesobject is read alongsidedef.properties; thederiveRecordSchemaoutput threads intoresponses[200].contentfor each verb path.resources/Resource.ts— declare optionalstatic 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.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;
inputSchematells 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
inputSchemasurfaceOPERATION_INPUT_SCHEMASPERMISSIVE_SCHEMA){ type: 'object', additionalProperties: true }— LLM gets no argument shapeTable.attributes— typed and completestatic propertiesmcpToolswith author-suppliedinputSchemamcpToolswithoutinputSchema{ type: 'object', additionalProperties: true }fallback (application.ts:526) — same gapThe two
additionalProperties: truefallbacks are real LLM-usability gaps. For DEFAULT_ALLOW operations and for any opt-inmcpTools, the tool will be listed but hard to invoke correctly.Operations: tighten via CI lint
A test in
unitTests/components/mcp/tools/operations.test.jsexpands the v1DEFAULT_ALLOWglob againstOPERATION_FUNCTION_MAPand asserts every matched operation has an entry inOPERATION_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_SCHEMAis 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:
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
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.typescriptat runtime.Other-payload extensions (in scope)
annotations.idempotentHintSemantics matter — be conservative. Per MCP spec,
idempotentHint: truesignals "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:Excluded explicitly (NOT idempotent under MCP semantics):
add_user,add_role, allcreate_*, alladd_*— 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 statepatch→ depends on the partial-update semantics; skip unless we can verifydelete→ 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 annotatecreate,get,search→ NOT annotated (createnot idempotent;get/searchare covered byreadOnlyHintwhich is the stronger signal anyway)For operations and verbs whose idempotency is undetermined, omit the hint. The MCP spec defaults
idempotentHinttofalsewhen omitted, which is the safe default.annotations.titleOptional 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:
titlecan be a Title Case form of the name (add_user→Add 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.outputSchemaSpec'd in MCP rev 2025-06-18 as an optional
outputSchema: objecton tool descriptors — already JSON Schema, same vocabulary asinputSchema. 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 thecreate_*inputSchema's record shape, just projected with output-directionrequired(server-assigned + non-null). Zero new compat surface — the shape was already locked wheninputSchemashipped.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.outputSchemais omitted entirely until the sibling issue [MCP/OpenAPI] Return envelope for search and list verbs #1107 picks one.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)
inputSchemaquality is a separate axis fromdescriptionquality. Audit + CI lint + warn-once added; covered in its own section.annotations.openWorldHintSignals 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_nodefor replication peers,deploy_componentif 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):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.add_useris 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."""ontype Product @table @exportand"""Stock keeping unit, unique per catalog."""on theskufield):(The
enumonattributeis 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.tsexists with ~45 hand-authored entries covering DEFAULT_ALLOW expansion + common opt-in destructive opsEach entry follows the authoring rubric (verb-led, disambiguating, cost/hazard, ≤ 400 chars)
buildDescription(operations.ts) prefersOPERATION_DESCRIPTIONSover the templateresources/graphql.tscapturesdescriptionfrom bothObjectTypeDefinitionNodeandFieldDefinitionNode; covered by a unit test inunitTests/resources/Table.descriptionandAttribute.descriptionflow through to MCP registrationbaseDescriptioninapplication.tsprefixes the table docstring when presentattributeToPropertyinderive.tspropagates per-attribute descriptions to derived schemasCustom
mcpToolswithoutdescriptionemit a deduped warn at registrationhttps://...application resources useTable.descriptionwhen availableIDEMPOTENT_OPERATIONSset populated; idempotentHint emitted for matching opsupdate,patch,deleteverb tools annotatedidempotentHint: trueApplication verb tools emit
outputSchemaderived from the same attribute metadataIntegration test:
tools/listagainst a fixture schema with docstrings → asserts description includes the docstring;inputSchema.properties[*]carry attribute descriptionsIntegration test:
tools/listagainst the operations profile → asserts curated descriptions land on the right toolsFoundation —
propertiesas canonical Resource/Table API:ResourceandTableclass types widened to declare optionalstatic description?: stringandstatic properties?: Record<string, JsonSchemaFragment>Resource.propertiesgetter projects fromResource.attributeslazily when nostatic propertiesis supplied (backward-compatible default for existing classes)@table @exportResources exposestatic propertiesautomatically post-merge, derived from the GraphQL parser (Surface 2 Step A);Table.attributesArray still exists, populated in the same parser passBidirectional consistency: every fixture Resource asserts that
properties[name]andattributes.find(a => a.name === name)describe the same dataInheritance via
extendscarriesstatic propertiesto 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.attributescontinue to read the same Array data unchangedSurface 5 (programmatic Resources):
Resourceclass type allows optionalstatic mcp: { annotations? }for MCP-only overrides; documented as discouraged for general usestatic descriptionemits that as the prefix on every MCP verb-tool description AND as the path-level description in OpenAPIstatic propertiesproduces non-skeletal MCP input schemas — each declared property appears with its description and typestatic propertiesenriches OpenAPI request/response schemas with per-property descriptionsstatic mcp.annotations.get.idempotentHint = trueemits the hint on theget_*MCP tool@tableResource with GraphQL docstrings and no override produces identical MCP + OpenAPI output to one with an explicitstatic description+static propertiesdeclaration (both paths converge)static properties(Record) from per-attributeAttribute.properties(Array, nested complex types) AND fromTable.attributes(Array, internal enumerated form)OpenAPI consumption (Surface 2 + Surface 5):
resources/openApi.tsreadsTable.descriptionfor path-level descriptions; hardcoded defaults remain as fallbackresources/openApi.tsreads per-attributedescriptionfrom the attribute iterator (openApi.ts:102-139); previously droppedResourceSchemaconstructor (openApi.ts:380) accepts and emits an optionaldescriptionresources/openApi.tsemitsresponses[200].contentschemas fromderiveRecordSchemafor get/create/update/patch verbstools/listAND OpenAPI/openapi.jsonoutputVerb description composer (Surface 2 Step B):
verbDescription(verb, ctx)composer replaces inlinebaseDescription; verb sentences are verb-specific ("Fetches…", "Searches…", "Creates…", etc.) not the mechanical "get on resource '/X'" template${tableDoc}\n\n${verbSentence} Runtime RBAC…when docstring is present;${verbSentence} Runtime RBAC…alone otherwiseoutputSchema (cheap cases):
deriveRecordSchema,deriveGetOutputSchema,deriveCreateOutputSchema,deriveUpdateOutputSchema,derivePatchOutputSchema,deriveDeleteOutputSchemaexist inderive.tsapplication.tsaddToolcalls for get/create/update/patch/delete pass anoutputSchema;search_*does NOToutputSchemafor get includes server-assigned fields (@createdTime,@updatedTime,@primaryKey) as required;inputSchemafor create excludes them from requiredstatic outputSchemas.get = {…}emits that override; without it, falls back toderiveRecordSchema(static properties)delete_*outputSchema verified against Harper's actual delete return value before merging; if non-standard, emit{type: 'object'}insteadidempotentHint (tightened):
IDEMPOTENT_OPERATIONSset excludesadd_user,add_role, allcreate_*(NOT idempotent under MCP semantics)idempotentHint: trueis NOT emitted foradd_useror anycreate_*toolupdate_*carriesidempotentHint: true(PUT semantics — repeatable with same payload)patch_*anddelete_*carryidempotentHintonly if Harper's actual behavior verifies repeat-safetyTool argument coverage:
OPERATION_INPUT_SCHEMASmcpToolsregistered withoutinputSchemaemit a deduped warn at registration (level: warn, since it affects usability)PERMISSIVE_SCHEMA({type:'object', additionalProperties: true}) still works; no behavior break for opt-in operationsOut of scope (deferred)
annotations.title(skip until MCP-client UIs ask for it)annotations.openWorldHint(low signal in Harper context)outputSchemaon operations (per-op research cost ≈ description cost; defer to follow-up)outputSchemaonsearch_*verbs — envelope shape is the open design question; tracked in sibling issue [MCP/OpenAPI] Return envelope for search and list verbs #1107inputSchemaentries — opportunistic content work; CI lint enforces coverage going forward but doesn't block this PR on hitting 100%static mcp.verbsper-verb description override — deliberately omitted (edge case; if author needs per-verb specificity, editstatic descriptionor declare a customstatic mcpToolsentry that supersedes the verb tool)attributefield becoming an enum (stretch — covered separately if scope allows)LOC envelope
operationDescriptions.tscontent (~45 ops × ~3 LOC)operations.tsfactory change + (narrow) idempotent setresources/graphql.tsdocstring capture + co-populatetypeDef.propertiesRecord +graphqlTypeToJsonSchemaTypehelperresources/databases.ts+Table.tswidening (threaddescription+propertiesthroughmakeTable)resources/Resource.tspropertiesgetter (lazy projection fromattributeswhen not statically overridden) + class type wideningapplication.tsverbDescriptioncomposer + custom-tool description+inputSchema warn + idempotent hintsderive.tsshift to consumingResourceClass.properties(canonical) + per-attribute description + per-verb input/output deriver wrappersresources.tshttps-resource description prefixapplication.tslookups forstatic description/static outputSchemas/static mcpon Resources (table-backed and programmatic both)resources/openApi.tsshifts to readingResource.properties(canonical); emitsTable.description+ per-attribute descriptions + class-level statics + per-verb response schemasOPERATION_INPUT_SCHEMAS+ warn-once formcpToolsmissing inputSchemaTable.attributesconsumers (replication, audit, REST middleware, etc.) still read the same array data after the alignmentpropertiesandattributesdescribe the same data for the same Resourcestatic propertiesinheritance/override, end-to-end across MCP + OpenAPI, idempotentHint scope, argument-coverage lint)One focused PR; reviewer load similar to PR-1 (#856).
Sequencing
Single PR off
main. Touchescomponents/mcp/,resources/graphql.ts,resources/databases.ts,resources/Table.ts, and a new unit test file. No companion docs PR needed — the existingharper/documentationPR #507 should be updated post-merge to mention the"""docstring"""convention for@table @exporttypes in the MCP section.Risks
Attributetype widening. Addingdescription?: stringmay surface inJSON.stringify(attributes)paths used by replication/audit. Spot-checkdataLayer/schemaDescribe.tsand anyattributesserializers before merging.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), dropoutputSchemafordelete_*and emit nothing rather than emit a lie. The other four cheap verbs add no new commitment.idempotentHintover-claim risk. Mis-annotating an op as idempotent leads LLMs to retry unsafe operations. Mitigation: ship an EMPTYIDEMPOTENT_OPERATIONSset 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.descriptionnaming collision. Some Harper-internal Resource subclasses may already declare adescriptionproperty at the instance level. Class-levelstatic descriptiondoesn't collide at runtime with instance-level fields, but a pre-merge grep confirms before merging. If a collision surfaces, fall back tostatic mcpDescriptionfor the class-level static.search_*outputSchema means MCP clients fall back to "structure unknown" for search results. This is the spec-allowed default (outputSchemais optional) but a user-visible gap. Sibling issue tracks this; merge here doesn't block ever shipping it.properties↔attributesbidirectional 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); thepropertiesgetter for non-overridden classes projects fromattributeslazily. Bidirectional-consistency tests in the test plan assert agreement for every fixture Resource.attributesconsumers. The internal Array form is preserved unchanged; replication, audit, REST middleware paths continue to work. Risk: an existing consumer that doesObject.keys(table.properties)(the oldResourceSchema.propertiesJSON-Schema object emitted at the OpenAPI boundary) gets a different shape now thatpropertiesis canonical at the class level. Pre-merge grep + back-compat test suite to lock this in.extendsinheritance edge cases. Spread-override (static properties = {...Parent.properties, foo: {...}}) requiresParent.propertiesto be resolved at static-init time. For programmatic Resources extending@table @export-backed ones, the parent'spropertiesis auto-derived from the GraphQL parse — must complete before the extending class is initialized. Mitigation: deferstatic propertiesresolution 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.