diff --git a/learn/developers/mcp-and-openapi-metadata.mdx b/learn/developers/mcp-and-openapi-metadata.mdx index aea29a397..4a4ef05a5 100644 --- a/learn/developers/mcp-and-openapi-metadata.mdx +++ b/learn/developers/mcp-and-openapi-metadata.mdx @@ -101,7 +101,7 @@ MCP `tools/list` now returns: } ``` -And `/openapi.json` picks up the same data: schema-level `description`, per-property `description`, and prepended path-level descriptions for every verb on `/Product`. +And `/openapi` picks up the same data: schema-level `description`, per-property `description`, and prepended path-level descriptions for every verb on `/Product`. ### `search_*` gets typed and described too @@ -116,7 +116,7 @@ For `search_Product`, the `conditions[].attribute` field becomes a closed `enum` ## Path B: Programmatic Resources via class-level statics -For Resources without `@table @export` backing — Resource subclasses that override `get`/`post`/`put`/`delete` directly, or that aggregate across multiple tables — there's no GraphQL schema to derive from. Declare the same metadata directly on the class as JSON-Schema-shaped statics. The MCP and OpenAPI layers read both surfaces uniformly. +For Resources without `@table @export` backing — Resource subclasses that override `get`/`post`/`put`/`delete` directly, or that aggregate across multiple tables — there's no GraphQL schema to derive from. Declare the same metadata directly on the class as JSON-Schema-shaped statics. ```typescript import { Resource } from 'harperdb'; @@ -137,15 +137,17 @@ export class ProductInventory extends Resource { }, }; - async get(id) { + static async get(target) { /* returns { sku, onHand, reserved, stockStatus } */ } - async search(query) { + static async search(query) { /* ... */ } } ``` +`static description`, `static hidden`, and `static outputSchemas` take effect on the MCP and OpenAPI surfaces as written. `static properties` is the canonical class-level metadata API and drives REST behavior — notably which `id.property` URL suffixes resolve — but the MCP tool builder and the OpenAPI generator still derive their schemas from the Resource's internal attributes list, which a purely programmatic Resource does not have. So the `properties` above will not by itself populate `inputSchema` / `outputSchema` or the OpenAPI component; that gap is tracked in [harper#1923](https://github.com/HarperFast/harper/issues/1923). Until it closes, describe such a Resource's result shape with `static outputSchemas`. + See the [Resource API reference](/reference/v5/resources/resource-api#class-level-metadata-for-mcp-and-openapi) for the full surface, including `static outputSchemas` for per-verb projection overrides, `static hidden` for full suppression, and `static mcp` for narrow MCP-only annotation overrides. ## Inheritance: extending a table @@ -170,7 +172,7 @@ The author writes against the canonical `properties` API. Internal code paths th ## Hiding sensitive fields with `@hidden` -OpenAPI is typically exposed to anyone reachable on the HTTP port — there's no per-user filtering on `/openapi.json`. A docstring on a sensitive field publishes that text to anyone who can hit the endpoint. The `@hidden` directive suppresses a field (or an entire type) from both MCP and OpenAPI without affecting data access: +The OpenAPI document is global: there is no per-user filtering, so every caller who can fetch it sees the same schemas and the same docstrings. `GET /openapi` itself is super-user-only — any other caller gets `403` — but the same document is also served over MCP as the `harper://openapi` resource, which applies no permission check at all. So treat any field you document as visible to every MCP client that can reach the application profile, not just to users with permission to read it. The `@hidden` directive suppresses a field (or an entire type) from both MCP and OpenAPI without affecting data access: ```graphql type Customer @table @export { @@ -190,7 +192,7 @@ type Customer @table @export { ## RBAC and per-user filtering -For MCP tool descriptors, `attribute_permissions` already filters the schema per-user — an attribute the caller cannot read is dropped from that user's view of the tool descriptor, along with its description. The new metadata flows through the existing pipeline. +MCP tool schemas are derived once at registration time and are the same for every caller — `attribute_permissions` is enforced when the tool is called, not reflected in the advertised `inputSchema` / `outputSchema`. What RBAC does filter is the tool _list_: `tools/list` omits tools the caller has no permission for on the backing table. Treat a descriptor's property descriptions as visible to any authenticated MCP client, and use `@hidden` for anything that shouldn't be. For OpenAPI, the document is global and not per-user filtered. Use `@hidden` (or `static hidden`) to control what surfaces there. @@ -198,5 +200,5 @@ For OpenAPI, the document is global and not per-user filtered. Use `@hidden` (or 1. Add `"""docstrings"""` to a `@table @export` type and save your component. 2. Hit MCP `tools/list` for the application profile — confirm `get_*`, `search_*`, etc. descriptions include the type docstring and per-attribute descriptions are present in the `inputSchema` and `outputSchema`. -3. Hit `/openapi.json` on the application HTTP port — confirm the path-level descriptions and per-property descriptions show up in Swagger UI / Redoc. +3. Hit `/openapi` on the application HTTP port — confirm the path-level descriptions and per-property descriptions show up in Swagger UI / Redoc. 4. Add `@hidden` to an attribute — confirm it disappears from both surfaces while remaining queryable via direct REST/SQL. diff --git a/reference/mcp/overview.md b/reference/mcp/overview.md index e3eb16e77..ce17674d2 100644 --- a/reference/mcp/overview.md +++ b/reference/mcp/overview.md @@ -46,7 +46,7 @@ Wraps Harper's operation catalog (the same set of operations the REST `/operatio Walks your application's exported `Resource` classes and generates one MCP tool per implemented REST verb. Mounts on the **application HTTP server** (the same listener that serves your REST endpoints). - For each exported Resource, Harper emits `get_`, `search_`, `create_`, `update_`, and `delete_` tools when the corresponding verb is implemented on the prototype. -- Input schemas are derived from `Table.attributes` and narrowed by your role's `attribute_permissions`. +- Input schemas are derived from `Table.attributes`. They are built once at registration time and are identical for every caller — `attribute_permissions` is enforced when the tool runs, not reflected in the advertised schema. What RBAC filters is the tool _list_: `tools/list` omits a Resource's verb tools unless the caller holds the matching table-level permission. - Components can opt non-verb instance methods into the MCP surface by declaring a static `mcpTools` array on the Resource class. - A Resource is excluded from the MCP surface when its registration sets `exportTypes.mcp = false`. diff --git a/reference/mcp/tool-metadata.md b/reference/mcp/tool-metadata.md index b4210521e..e0e36faea 100644 --- a/reference/mcp/tool-metadata.md +++ b/reference/mcp/tool-metadata.md @@ -48,15 +48,15 @@ Operations registered outside core (for example, `cluster_status` from harper-pr For verb tools generated from exported Resources: -| Field | Source | -| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `name` | `${verb}_${sanitized-path}` (e.g. `get_Product`, `search_Customer`) | -| `description` | Composed: `[ResourceClass.description \n\n] ${verb sentence} ${runtime RBAC note}` | -| `inputSchema` | Derived per verb from `ResourceClass.attributes` and the caller's `attribute_permissions`. Per-attribute `description` propagates to `inputSchema.properties[*].description` | -| `outputSchema` | Derived per verb from `ResourceClass.attributes` for `get_*` / `create_*` / `update_*` / `patch_*`. `delete_*` returns `{ deleted: true, }`. `search_*` deliberately omits `outputSchema` | -| `annotations.readOnlyHint` | `true` on `get_*` and `search_*` | -| `annotations.destructiveHint` | `true` on `delete_*` | -| `annotations.idempotentHint` | `true` on `update_*` (PUT semantics); other verbs default off | +| Field | Source | +| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `name` | `${verb}_${sanitized-path}` (e.g. `get_Product`, `search_Customer`) | +| `description` | Composed: `[ResourceClass.description \n\n] ${verb sentence} ${runtime RBAC note}` | +| `inputSchema` | Derived per verb from `ResourceClass.attributes`, once at registration time and identical for every caller. Per-attribute `description` propagates to `inputSchema.properties[*].description` | +| `outputSchema` | The full record shape on `get_*`; fixed envelopes on `create_*` (`{ id }`), `update_*` / `patch_*` (`{ ok }`), and `delete_*` (`{ deleted }`). `search_*` deliberately omits `outputSchema` | +| `annotations.readOnlyHint` | `true` on `get_*` and `search_*` | +| `annotations.destructiveHint` | `true` on `delete_*` | +| `annotations.idempotentHint` | `true` on `update_*` (PUT semantics); other verbs default off | `static description` and `static properties` on the Resource class override the auto-derived values. `static outputSchemas[verb]` overrides per-verb output schemas. `static mcp.annotations[verb]` overrides annotations per verb. `static hidden === true` suppresses the entire Resource from MCP listing. diff --git a/reference/mcp/tools-and-resources.md b/reference/mcp/tools-and-resources.md index bb656c86f..9fa8ccd78 100644 --- a/reference/mcp/tools-and-resources.md +++ b/reference/mcp/tools-and-resources.md @@ -78,9 +78,9 @@ Input schemas come from `Table.attributes`: - Nested `Object` and `Array` attributes recurse into their `properties` / `elements`. - `nullable: true` adds `"null"` to the type union. - Auto-managed columns (`assignCreatedTime`, `assignUpdatedTime`, `expiresAt`) and computed columns are stripped from write schemas (`create_*`, `update_*`) — the server fills them in. -- Per-attribute `attribute_permissions` narrow the schema **per requesting user**: attributes the user cannot read are stripped from `get_*` / `search_*` schemas; attributes the user cannot insert/update are stripped from `create_*` / `update_*` schemas. +- Every remaining attribute is included. Schemas are derived **once at registration time**, with no caller permissions in scope, so `attribute_permissions` does not narrow them — the descriptor a restricted user receives is identical to a super-user's. -The schema narrowing is a UX optimization, not a security boundary — runtime `Table.allowUpdate` / `Table.allowCreate` still enforces. The narrowing just avoids burning LLM tokens on fields the user couldn't write anyway. +`attribute_permissions` is enforced when the tool runs, not when it is described: runtime `Table.allowUpdate` / `Table.allowCreate` and the per-attribute checks reject a restricted read or write regardless of what the advertised schema listed. What RBAC does filter is the tool _list_ — `tools/list` omits a Resource's verb tools unless the caller holds the matching table-level permission (`read` or `describe` for `get_*` / `search_*`, and `insert` / `update` / `delete` for the write verbs). Because the schema is caller-agnostic, treat every attribute name and `description` in it as visible to any authenticated caller who can see the tool, and use `@hidden` for anything that shouldn't be. ### Custom `mcpTools` opt-in diff --git a/reference/resources/resource-api.md b/reference/resources/resource-api.md index df77ba77d..bb48af636 100644 --- a/reference/resources/resource-api.md +++ b/reference/resources/resource-api.md @@ -516,7 +516,7 @@ export class ProductInventory extends Resource { 'Aggregate inventory analytics computed over the Product catalog. ' + 'Read-only; the underlying Product table is the system of record.'; - async get(id) { + static async get(target) { /* ... */ } } @@ -540,7 +540,7 @@ export class ProductInventory extends Resource { }, }; - async get(id) { + static async get(target) { /* ... */ } } @@ -568,7 +568,19 @@ The author writes against `properties` (the public API). Internal code that need ### `static outputSchemas?: { [verb: string]: JsonSchemaFragment }` -Per-verb output schema overrides for programmatic Resources whose verb methods return a projection rather than the full record. When omitted, the MCP deriver falls back to `static properties` for the cheap verbs (`get`/`create`/`update`/`patch`) and a synthesized `{deleted: true, }` envelope for `delete`. `search_*` deliberately has no output schema. +Per-verb output schema overrides for programmatic Resources whose verb methods return a projection rather than the full record. Only `get_*` derives a record-shaped output schema; the write verbs advertise fixed envelopes that do not vary with the record shape: + +| Verb | Output schema when no override is supplied | +| --------------------- | --------------------------------------------------------------------------------- | +| `get_*` | The record shape, derived from the Resource's attributes | +| `create_*` | `{ id }` — the new record's primary key, typed by the primary-key attribute | +| `update_*`, `patch_*` | `{ ok }` — a boolean acknowledgement (`Table.put` / `Table.patch` return nothing) | +| `delete_*` | `{ deleted }` — a boolean; it does not carry the primary key | +| `search_*` | None. `search_*` deliberately registers no `outputSchema` | + +So `static outputSchemas` is the only way to describe what a write verb actually returns. `outputSchemas.search` is not read at all — a `search` entry has no effect on `tools/list`. + +A programmatic Resource that declares no attributes gets an empty record schema on `get_*`, because the derivation reads the internal `attributes` Array rather than `static properties` ([harper#1923](https://github.com/HarperFast/harper/issues/1923)). Until that is resolved, describe such a Resource's `get_*` result with `static outputSchemas.get`. ```typescript export class ProductInventory extends Resource { @@ -589,7 +601,7 @@ export class ProductInventory extends Resource { }, }; - async get(id) { + static async get(target) { /* returns the projection above */ } } @@ -602,7 +614,7 @@ When `true`, the Resource is dropped from MCP tool registration and OpenAPI path ```typescript export class InternalDiagnostics extends Resource { static hidden = true; - async get() { + static async get(target) { /* ... */ } } diff --git a/reference/rest/overview.md b/reference/rest/overview.md index 74b755f7d..6abc6cec0 100644 --- a/reference/rest/overview.md +++ b/reference/rest/overview.md @@ -47,7 +47,7 @@ The REST interface follows a consistent URL structure: | `/my-resource/record-id/` | Trailing slash — the collection of records with the given id prefix | | `/my-resource/record-id/with/multiple/parts` | Record id with multiple path segments | - — Resources can be defined with nested paths and accessed by exact path without a trailing slash. The `id.property` dot syntax for accessing properties via URL is only applied to properties declared in a schema. + — Resources can be defined with nested paths and accessed by exact path without a trailing slash. The `id.property` dot syntax for accessing properties via URL is only applied to declared properties (see [below](#get)). ## HTTP Methods @@ -73,7 +73,7 @@ Returns records matching `name=Harper`. See [Querying](./querying.md) for the fu GET /MyTable/123.propertyName ``` -Returns a single property of a record. Only works for properties declared in the schema. +Returns a single property of a record. Only works for declared properties — a table's schema attributes, or a programmatic Resource's [`static properties`](../resources/resource-api.md#static-properties-recordstring-jsonschemafragment) . An undeclared name is treated as part of the record id instead. The suffixes `.json`, `.cbor`, `.msgpack`, and `.csv` are reserved as content-type selectors and take precedence, so a property with one of those names is not reachable this way. #### Conditional Requests and Caching diff --git a/reference/rest/querying.md b/reference/rest/querying.md index b05a19c07..a26082c48 100644 --- a/reference/rest/querying.md +++ b/reference/rest/querying.md @@ -245,7 +245,7 @@ Access a specific property of a record by appending it with dot syntax to the re GET /MyTable/123.propertyName ``` -This only works for properties declared in the schema. As of v4.5.0, dots in URL paths are no longer interpreted as property access for undeclared properties, allowing URLs to generally include dots without being misinterpreted. +This only works for declared properties — a table's schema attributes, or a programmatic Resource's [`static properties`](/reference/v5/resources/resource-api#static-properties-recordstring-jsonschemafragment) . As of v4.5.0, dots in URL paths are no longer interpreted as property access for undeclared properties, allowing URLs to generally include dots without being misinterpreted. The suffixes `.json`, `.cbor`, `.msgpack`, and `.csv` are reserved as content-type selectors and take precedence over a property of the same name. ## `directURLMapping` Option diff --git a/release-notes/v5-lincoln/5.2.md b/release-notes/v5-lincoln/5.2.md index 40341b4d0..841ebf462 100644 --- a/release-notes/v5-lincoln/5.2.md +++ b/release-notes/v5-lincoln/5.2.md @@ -30,6 +30,12 @@ See [Transaction Logging](/reference/v5/database/transaction) for the updated op Vector searches combined with filters now evaluate the filter during HNSW graph traversal, so the query keeps exploring until it has enough matching nearest neighbors instead of post-filtering a fixed candidate set (which under-filled results under selective filters). Filters can come from query conditions or JavaScript `vectorFilter` and `rowFilter` functions. `rowFilter` is an explicit query/subscription predicate that applications attach after admitting the operation; it also filters normal searches and subscription delivery. Very selective conditions automatically use an exact scan instead of graph traversal, and a `filterExpansion` visit budget bounds traversal cost. See [Vector Indexing](/reference/v5/database/schema#vector-indexing). +### URL Property Access for Programmatic Resources + +`GET /MyResource/id.propertyName` now resolves against a programmatic Resource's `static properties` declaration, not just a table's schema attributes. Previously only table-backed Resources could be addressed this way; a Resource that declared its shape with `static properties` had the suffix treated as part of the record id ([harper#1933](https://github.com/HarperFast/harper/pull/1933)). + +Property matching uses an own-key check, so inherited `Object.prototype` names (`constructor`, `toString`) are not treated as properties. The content-type suffixes `.json`, `.cbor`, `.msgpack`, and `.csv` are still matched first, so a property sharing one of those names is not reachable this way. See [REST Querying](/reference/v5/rest/querying#property-access-via-url). + ## Authorization The legacy `allowRead`, `allowUpdate`, `allowCreate`, and `allowDelete` hooks remain one-time operation gates and are deprecated in favor of operation overrides, which receive the complete target and can access the request context through `getContext()`. This restores the v5.1 operation-scoped contract for standard instance-mode collection reads: admission occurs before query planning or scanning. It also closes legacy `loadAsInstance = false` gaps in built-in table handlers: search and subscribe use one `allowRead` verdict, array PUT uses one `allowUpdate`, query DELETE uses one `allowDelete`, and publish uses one `allowCreate`. Custom false-mode handlers remain responsible for authorization unless they delegate to the built-in handlers. See [Row-Level Access Control with Explicit Filters](/reference/v5/database/schema#row-level-access-control-with-explicit-filters) for intentional row and subscription-event narrowing.