From dfebff194a575f57f411e3c1f16d5f74b8e1e30f Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Sat, 17 Oct 2026 18:55:51 -0600 Subject: [PATCH 1/5] Document subscribe on custom MCP resources A static mcpResources entry can name a subscribe method, so MCP clients can watch custom URIs with resources/subscribe. Covers the method's calling convention, how Harper releases the stream, identity lifetime, the JSON-RPC errors a refused subscribe answers, and background restore on reconnect. Companion to HarperFast/harper#3257. Dispatch-Task: harper-3250-mcp-resource-subscribe Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019W6f2CJA6oE5ixf6S2b4Ah --- reference/mcp/overview.md | 2 +- reference/mcp/subscriptions.md | 19 ++++++++-- reference/mcp/tools-and-resources.md | 53 +++++++++++++++++++++++++++- 3 files changed, 69 insertions(+), 5 deletions(-) diff --git a/reference/mcp/overview.md b/reference/mcp/overview.md index 05847c61d..47d533098 100644 --- a/reference/mcp/overview.md +++ b/reference/mcp/overview.md @@ -13,7 +13,7 @@ Harper implements the [Model Context Protocol](https://modelcontextprotocol.io) - **Tool discovery and invocation.** LLM hosts get a typed list of operations they can call (`tools/list`) and a uniform JSON-RPC invocation envelope (`tools/call`). Per-tool input schemas come from Harper's operation catalog (operations profile) or from your `Table.attributes` and exported `Resource` classes (application profile). - **Resource exposure.** Synthetic `harper://` URIs surface metadata (server info, OpenAPI document, table schemas, operations catalog), and `harper+rest://` URIs mirror your application's REST surface so hosts can resolve real REST endpoints in-process. Components can also publish their own content under author-chosen URIs with a static `mcpResources` array. - **Server-push notifications.** `notifications/tools/list_changed` and `notifications/resources/list_changed` fire over an open Server-Sent Events channel when role mutations or schema changes alter what a session can see. -- **Resource subscriptions.** Subscribe to a row-backed application resource with `resources/subscribe` and receive a `notifications/resources/updated` push whenever that record (or table) changes, driven by Harper's audit-log change feed. See [Resource Subscriptions](./subscriptions.md). +- **Resource subscriptions.** Subscribe to a row-backed application resource with `resources/subscribe` and receive a `notifications/resources/updated` push whenever that record (or table) changes, driven by Harper's audit-log change feed. Custom resources can be subscribable too, by declaring `subscribe` . See [Resource Subscriptions](./subscriptions.md). - **Resumable notification streams.** A dropped GET-SSE connection can reconnect with a `Last-Event-ID` header and replay the notifications it missed from a bounded per-session buffer, so a brief network blip doesn't lose `list_changed` / `resources/updated` events. See [Resource Subscriptions](./subscriptions.md#resuming-a-dropped-stream). - **Per-session bookkeeping.** Sessions persist for the configured idle window; `Mcp-Session-Id` ties JSON-RPC requests, GET-SSE notifications, and the optional DELETE-session cleanup together. - **Built-in auth and RBAC.** Harper's existing Basic, JWT, and mTLS authentication paths run unchanged on the MCP endpoint. Tool and resource visibility is filtered through your role's `permission` block (`super_user`, `structure_user`, per-operation, per-table, per-attribute). diff --git a/reference/mcp/subscriptions.md b/reference/mcp/subscriptions.md index 9fb221999..f28737106 100644 --- a/reference/mcp/subscriptions.md +++ b/reference/mcp/subscriptions.md @@ -6,7 +6,7 @@ title: MCP Resource Subscriptions -Beyond the `notifications/*/list_changed` events that tell a client _what tools and resources exist_, Harper can push a notification whenever the _contents_ of a specific application resource change. A client subscribes to a resource URI with `resources/subscribe`; Harper watches that record (or table) through its audit-log change feed and emits `notifications/resources/updated` on every commit. +Beyond the `notifications/*/list_changed` events that tell a client _what tools and resources exist_, Harper can push a notification whenever the _contents_ of a specific application resource change. A client subscribes to a resource URI with `resources/subscribe`; Harper watches that record (or table) through its audit-log change feed, or, for a [custom resource](./tools-and-resources.md#subscribing-to-custom-resources), the stream its `subscribe` method returns, and emits `notifications/resources/updated` on every change. This page covers `resources/subscribe` / `resources/unsubscribe`, the `notifications/resources/updated` push, which URIs are subscribable, and `Last-Event-ID` stream resumability. The `list_changed` events themselves are documented in [MCP Tools and Resources](./tools-and-resources.md#notificationslist_changed). @@ -59,7 +59,12 @@ A successful subscribe returns an empty result: ### Which URIs are subscribable -Only **row-backed application resources** can be subscribed to — the `harper+rest://` URIs that mirror an exported `Resource` (the same ones `resources/list` advertises on the application profile). The URI's path is matched against the `Resources` registry, and the resource is subscribable only if its class implements `subscribe`. Anything else returns `-32602`: +Two kinds of URI can be subscribed to: + +- **Row-backed application resources** — the `harper+rest://` URIs that mirror an exported `Resource` (the same ones `resources/list` advertises on the application profile). The URI's path is matched against the `Resources` registry, and the resource is subscribable only if its class implements `subscribe`. +- **Custom resources that declare `subscribe`** — URIs published through a static `mcpResources` entry. The URI is matched exactly as `resources/read` matches it, and the entry's `subscribe` method supplies the changes. See [Subscribing to custom resources](./tools-and-resources.md#subscribing-to-custom-resources). + +Anything else returns `-32602`: ```json { "error": { "code": -32602, "message": "resource is not subscribable: harper://schema/data/product" } } @@ -67,6 +72,14 @@ Only **row-backed application resources** can be subscribed to — the `harper+r Synthetic `harper://*` metadata URIs (`harper://about`, `harper://openapi`, `harper://schema/...`, `harper://operations`) have no change source and are **not** subscribable. They participate in `notifications/resources/list_changed` only. +A subscribe the resource refuses answers a JSON-RPC error rather than an HTTP error : + +| Answer | When | +| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| `-32602 permission denied: ` | The session user may not read the resource. | +| `-32603 failed to subscribe: ` | The subscribe failed for another reason; the raw error is in the server log. | +| `-32602 resources/subscribe for was cancelled: ` | The GET stream closed, or an unsubscribe or another subscribe for the URI arrived, during setup. | + `resources/list` advertised these URIs under `http(s)://` before 5.1.18 . A legacy `http://` or `https://` URI is still accepted by `resources/subscribe` and `resources/unsubscribe`, so a client holding an older listing keeps working; new clients should use the `harper+rest://` form the current listing returns. ### Record vs. collection URIs @@ -128,7 +141,7 @@ Mcp-Session-Id: 0c8f... Last-Event-ID: 137 ``` -Harper replays every buffered frame with a higher id (exclusive) before live frames resume, so a brief network blip doesn't drop `notifications/resources/updated` or `list_changed` events. On reconnect, Harper also restores the session's persisted subscriptions from the `mcp_session` record (best-effort — a URI that is no longer subscribable is dropped). +Harper replays every buffered frame with a higher id (exclusive) before live frames resume, so a brief network blip doesn't drop `notifications/resources/updated` or `list_changed` events. On reconnect, Harper also restores the session's persisted subscriptions from the `mcp_session` record (best-effort — a URI that is no longer subscribable is dropped; one whose subscribe failed, or that nothing on this worker matches yet, stays on the record and is retried on the next reconnect). The GET response does not wait for the restore : subscriptions are restored after the stream opens, at most eight at a time, and their notifications follow on the stream. Caveats, all best-effort by design: diff --git a/reference/mcp/tools-and-resources.md b/reference/mcp/tools-and-resources.md index 53e5f0756..86e4fcebc 100644 --- a/reference/mcp/tools-and-resources.md +++ b/reference/mcp/tools-and-resources.md @@ -188,6 +188,57 @@ Notes: - Custom URIs must use an author-chosen scheme (`docs:///...` above), and that scheme must be a literal — a template whose scheme position holds a parameter (`{scheme}://...`) is rejected. The reserved schemes — `harper:`, `harper+rest:`, `http:`, `https:` — are rejected at registration so custom entries cannot shadow the built-in surfaces. - Invalid entries are skipped with a warning in the server log rather than failing the profile rebuild: a missing `name` or `method`, both or neither of `uri`/`uriTemplate`, a malformed or parameter-less template, a reserved scheme, or a `method` that is not a function on the prototype. - A read error from the method surfaces to the client as a sanitized JSON-RPC error; the raw error is written to the server log. +- An optional `subscribe` names a method that makes the URI subscribable with `resources/subscribe` — see [Subscribing to custom resources](#subscribing-to-custom-resources). + +#### Subscribing to custom resources + + + +Add `subscribe` to an entry to let MCP clients watch its URIs. It names an instance method that is called the same way as `method` — `(params, context)` on the live class, inside the same transaction carrying the session user — and returns an async iterable. Every value the iterable yields sends one `notifications/resources/updated` with the subscribed URI; the value itself is never sent, so the client re-reads the resource. A URI whose entry has no `subscribe` answers `-32602 resource is not subscribable`, as before. + +```ts +import { RequestTarget } from 'harper'; + +export class Order extends tables.Order { + static async get(target, context) { + // the authorization gate, as in the read example below + } +} + +export class OrderDocs extends Resource { + static mcpResources = [ + { + uriTemplate: 'orders:///{+orderId}', + name: 'order by id', + description: 'An order; subscribe to hear when it changes', + mimeType: 'application/json', + method: 'readOrder', + subscribe: 'watchOrder', + }, + ]; + + async readOrder(params) { + /* … */ + } + + async watchOrder(params) { + const order = await Order.get(params.orderId); // authorize while the session user is in context + if (!order) throw new Error(`no such order: ${params.orderId}`); + const target = new RequestTarget(); + target.id = params.orderId; + target.omitCurrent = true; // only changes after subscribe + return tables.Order.subscribe(target); + } +} +``` + +The contract: + +- **Authorize and acquire before returning.** The returned iterable is consumed after the transaction ends, so a check made while iterating no longer runs as the session user. A generator method (`async *watch()`) would run its whole body that way, so a `subscribe` that names one is ignored with a warning, and the entry stays readable. +- **Release.** Harper releases the stream on `resources/unsubscribe`, when the session's GET stream closes, on `DELETE /mcp`, and when the stream ends or throws: it calls the stream's `end()` if it has one, otherwise its iterator's `return()`. Return a stream with `end()` — a Harper subscription such as `Table.subscribe(...)`, or your own queue. Without `end()`, the iterator must have a `return()` that releases its source at once. A stream with neither, or an async generator with no `end()`, fails the subscribe with `-32603`: there is nothing to call, or `return()` cannot interrupt the generator while it waits for its next value. +- **Access after subscribe.** Harper ends the stream when the session's bearer token expires, or when its user is dropped or no longer has a role. Changes to resource-specific access after subscribe are yours to enforce, for example by checking each change before yielding it. +- **Workers.** A subscription runs on the worker holding the session's GET stream. If changes can originate on other workers, your stream must deliver them; Harper table subscriptions do. +- **Errors.** An `AccessViolation` thrown by the method (for example, from a guarded `get`) answers `-32602 permission denied: `. Any other error answers `-32603 failed to subscribe: `; the raw error goes to the server log. #### Template parameters are client-controlled @@ -279,7 +330,7 @@ This is independent of the `http` exportType — the only switch that operators ## Resources surface -Both profiles serve `resources/list`, `resources/read`, and `resources/templates/list`. Row-backed application resources can additionally be watched with `resources/subscribe` — see [MCP Resource Subscriptions](./subscriptions.md). +Both profiles serve `resources/list`, `resources/read`, and `resources/templates/list`. Row-backed application resources, and custom resources that declare `subscribe`, can additionally be watched with `resources/subscribe` — see [MCP Resource Subscriptions](./subscriptions.md). ### `harper://` URIs From cfabb2d1e289151fbcb2912c0e864cc842278314 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Sat, 17 Oct 2026 19:06:39 -0600 Subject: [PATCH 2/5] Make the custom-subscribe example complete, and scope row-backed-only statements The example now carries its own authorization gate and subscribes through the exported class; the scoped-token exception, the bad-subscribe case and the exact not-subscribable message are stated; changes-only delivery and audit-log delivery are scoped to row-backed resources; a 5.4 release note. Dispatch-Task: harper-3250-mcp-resource-subscribe Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019W6f2CJA6oE5ixf6S2b4Ah --- reference/mcp/subscriptions.md | 8 ++++---- reference/mcp/tools-and-resources.md | 26 ++++++++++++++++---------- release-notes/v5-lincoln/5.4.md | 4 ++++ 3 files changed, 24 insertions(+), 14 deletions(-) diff --git a/reference/mcp/subscriptions.md b/reference/mcp/subscriptions.md index f28737106..403d234a9 100644 --- a/reference/mcp/subscriptions.md +++ b/reference/mcp/subscriptions.md @@ -72,7 +72,7 @@ Anything else returns `-32602`: Synthetic `harper://*` metadata URIs (`harper://about`, `harper://openapi`, `harper://schema/...`, `harper://operations`) have no change source and are **not** subscribable. They participate in `notifications/resources/list_changed` only. -A subscribe the resource refuses answers a JSON-RPC error rather than an HTTP error : +A refused subscribe, row-backed or custom, answers a JSON-RPC error rather than an HTTP error : | Answer | When | | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | @@ -93,11 +93,11 @@ The granularity of a subscription is determined by the path: A collection URI (no record key — the form `resources/list` advertises) subscribes to the entire table; a record URI narrows the watch to one primary key. -Subscriptions use `omitCurrent` semantics: Harper does **not** replay the current value at subscribe time. You only receive notifications for changes that commit _after_ the subscription is established. The notification carries no snapshot — re-read the resource (or call the corresponding `get_*` tool) to fetch the new state. +Row-backed subscriptions use `omitCurrent` semantics: Harper does **not** replay the current value at subscribe time. You only receive notifications for changes that commit _after_ the subscription is established. A custom resource's stream decides this itself: every value it yields sends a notification, including one it yields at once. The notification carries no snapshot — re-read the resource (or call the corresponding `get_*` tool) to fetch the new state. ## The `notifications/resources/updated` push -On each committed change to a subscribed resource, Harper pushes a notification onto the session's GET-SSE channel: +On each committed change to a subscribed row-backed resource, and on each value a custom resource's stream yields, Harper pushes a notification onto the session's GET-SSE channel: ```json { @@ -129,7 +129,7 @@ Subscriptions are also torn down automatically when the session's GET-SSE stream ## Scope and durability -Subscriptions are **per-worker**: a subscription is bound to the worker that holds the session's SSE stream, and the audit-log change feed delivers commits locally on that worker. The live subscription objects can't be persisted, but the durable list of subscribed URIs lives on the `mcp_session` record, which is how they survive a reconnect. +Subscriptions are **per-worker**: a subscription is bound to the worker that holds the session's SSE stream, and the audit-log change feed delivers commits locally on that worker. A custom resource's stream runs on that worker too, so it must deliver changes that originate on other workers. The live subscription objects can't be persisted, but the durable list of subscribed URIs lives on the `mcp_session` record, which is how they survive a reconnect. ## Resuming a dropped stream diff --git a/reference/mcp/tools-and-resources.md b/reference/mcp/tools-and-resources.md index 86e4fcebc..07cfab73c 100644 --- a/reference/mcp/tools-and-resources.md +++ b/reference/mcp/tools-and-resources.md @@ -186,7 +186,7 @@ Notes: - Reads dispatch on the **live** registry class, so an exported `resources.js` subclass's method (and its access control) always wins — the same rule as custom `mcpTools`. - `completions` optionally declares candidate values per template parameter, served by `completion/complete`. - Custom URIs must use an author-chosen scheme (`docs:///...` above), and that scheme must be a literal — a template whose scheme position holds a parameter (`{scheme}://...`) is rejected. The reserved schemes — `harper:`, `harper+rest:`, `http:`, `https:` — are rejected at registration so custom entries cannot shadow the built-in surfaces. -- Invalid entries are skipped with a warning in the server log rather than failing the profile rebuild: a missing `name` or `method`, both or neither of `uri`/`uriTemplate`, a malformed or parameter-less template, a reserved scheme, or a `method` that is not a function on the prototype. +- Invalid entries are skipped with a warning in the server log rather than failing the profile rebuild: a missing `name` or `method`, both or neither of `uri`/`uriTemplate`, a malformed or parameter-less template, a reserved scheme, or a `method` that is not a function on the prototype. A `subscribe` that is not a function on the prototype, or that names a generator method, is ignored with a warning instead, and the entry stays readable. - A read error from the method surfaces to the client as a sanitized JSON-RPC error; the raw error is written to the server log. - An optional `subscribe` names a method that makes the URI subscribable with `resources/subscribe` — see [Subscribing to custom resources](#subscribing-to-custom-resources). @@ -194,14 +194,16 @@ Notes: -Add `subscribe` to an entry to let MCP clients watch its URIs. It names an instance method that is called the same way as `method` — `(params, context)` on the live class, inside the same transaction carrying the session user — and returns an async iterable. Every value the iterable yields sends one `notifications/resources/updated` with the subscribed URI; the value itself is never sent, so the client re-reads the resource. A URI whose entry has no `subscribe` answers `-32602 resource is not subscribable`, as before. +Add `subscribe` to an entry to let MCP clients watch its URIs. It names an instance method that is called the same way as `method` — `(params, context)` on the live class, inside the same transaction carrying the session user — and returns an async iterable. Every value the iterable yields sends one `notifications/resources/updated` with the subscribed URI; the value itself is never sent, so the client re-reads the resource. A URI whose entry has no `subscribe` answers `-32602 resource is not subscribable: `, as before. ```ts -import { RequestTarget } from 'harper'; +import { RequestTarget, getUser } from 'harper'; export class Order extends tables.Order { static async get(target, context) { - // the authorization gate, as in the read example below + const order = await super.get(target, context); + if (order && order.customerId !== getUser()?.username) throw new Error('not authorized'); + return order; } } @@ -218,27 +220,31 @@ export class OrderDocs extends Resource { ]; async readOrder(params) { - /* … */ + const order = await Order.get(params.orderId); + if (!order) throw new Error(`no such order: ${params.orderId}`); + return { text: JSON.stringify(order), mimeType: 'application/json' }; } async watchOrder(params) { - const order = await Order.get(params.orderId); // authorize while the session user is in context + const order = await Order.get(params.orderId); if (!order) throw new Error(`no such order: ${params.orderId}`); const target = new RequestTarget(); target.id = params.orderId; - target.omitCurrent = true; // only changes after subscribe - return tables.Order.subscribe(target); + target.omitCurrent = true; + return Order.subscribe(target); } } ``` +`watchOrder` reads through the exported `Order` class, as `readOrder` does, so its `get` override authorizes the subscribe as the session user ([Access control for custom resources](#access-control-for-custom-resources) explains why the base `tables.Order` class must not be used). It subscribes through `Order` as well, so a `subscribe` override on that class runs too. `omitCurrent` skips the current value, so the first notification is the first change. + The contract: - **Authorize and acquire before returning.** The returned iterable is consumed after the transaction ends, so a check made while iterating no longer runs as the session user. A generator method (`async *watch()`) would run its whole body that way, so a `subscribe` that names one is ignored with a warning, and the entry stays readable. - **Release.** Harper releases the stream on `resources/unsubscribe`, when the session's GET stream closes, on `DELETE /mcp`, and when the stream ends or throws: it calls the stream's `end()` if it has one, otherwise its iterator's `return()`. Return a stream with `end()` — a Harper subscription such as `Table.subscribe(...)`, or your own queue. Without `end()`, the iterator must have a `return()` that releases its source at once. A stream with neither, or an async generator with no `end()`, fails the subscribe with `-32603`: there is nothing to call, or `return()` cannot interrupt the generator while it waits for its next value. -- **Access after subscribe.** Harper ends the stream when the session's bearer token expires, or when its user is dropped or no longer has a role. Changes to resource-specific access after subscribe are yours to enforce, for example by checking each change before yielding it. +- **Access after subscribe.** Harper ends the stream when the session's bearer token expires, or when its user is dropped or no longer has a role. A [scoped token](/reference/v5/security/jwt-authentication#scoped-tokens-inline-role) belongs to no user, so only its expiry ends the stream. Changes to resource-specific access after subscribe are yours to enforce, for example by checking each change before yielding it. - **Workers.** A subscription runs on the worker holding the session's GET stream. If changes can originate on other workers, your stream must deliver them; Harper table subscriptions do. -- **Errors.** An `AccessViolation` thrown by the method (for example, from a guarded `get`) answers `-32602 permission denied: `. Any other error answers `-32603 failed to subscribe: `; the raw error goes to the server log. +- **Errors.** An `AccessViolation`, which Harper's own permission checks throw (for example, a table read with `target.checkPermission` that the user's role does not allow), answers `-32602 permission denied: `. Any other error, including one your own gate throws, answers `-32603 failed to subscribe: `; the raw error goes to the server log. #### Template parameters are client-controlled diff --git a/release-notes/v5-lincoln/5.4.md b/release-notes/v5-lincoln/5.4.md index e1f638196..0d099a91c 100644 --- a/release-notes/v5-lincoln/5.4.md +++ b/release-notes/v5-lincoln/5.4.md @@ -24,6 +24,10 @@ A `subscribe` request can now carry the `databaseGeneration` its position came f A durable MQTT session now resumes through the same checked replay. When a reconnect's saved position names history that audit retention has removed, Harper deletes the session and reports `sessionPresent: false` (or, if the problem appears after `CONNACK`, sends an MQTT v5 `DISCONNECT` with reason code `0x83` and closes the connection) instead of delivering a partial catch-up. A position saved before a restore or copy, or on another cluster node, resumes best-effort, as before. A quiet topic's position is kept current, so retention on other tables does not reset it, and positions no longer skip unacknowledged messages that share a transaction. Resubscribing to a topic the session already holds continues from its saved position, and QoS 0 subscriptions are kept with the session and resume live. Each connection's Last Will is kept separately, so a connection that is taken over publishes its own will, never the newer connection's. See [Durable Sessions](/reference/v5/mqtt/overview#durable-sessions). +### MCP Clients Can Subscribe to Custom Resources + +A static `mcpResources` entry can now name a `subscribe` method, so MCP clients can watch a custom URI such as `memory:///{id}` with `resources/subscribe`. The method is called like the entry's read method, as the session's user, and returns an async iterable; each value it yields sends `notifications/resources/updated` with the URI. Harper releases the stream on unsubscribe, when the GET stream closes, on `DELETE /mcp`, and when the session's token expires or its user is dropped. A subscribe that fails now answers a JSON-RPC error instead of an HTTP 500, for row-backed resources too, and a reconnecting GET stream opens without waiting for its subscriptions to be restored. See [Subscribing to custom resources](/reference/v5/mcp/tools-and-resources#subscribing-to-custom-resources). + ## Built-in Agent ### Turning Off or Host-Limiting `http_fetch` From 51a0e19961bd85c7ad3499cf397108f3600c58e4 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Sat, 17 Oct 2026 19:09:55 -0600 Subject: [PATCH 3/5] Say a refused MCP subscribe used to answer HTTP 500, as the release note does Dispatch-Task: harper-3250-mcp-resource-subscribe Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019W6f2CJA6oE5ixf6S2b4Ah --- reference/mcp/subscriptions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reference/mcp/subscriptions.md b/reference/mcp/subscriptions.md index 403d234a9..a7d7402c7 100644 --- a/reference/mcp/subscriptions.md +++ b/reference/mcp/subscriptions.md @@ -72,7 +72,7 @@ Anything else returns `-32602`: Synthetic `harper://*` metadata URIs (`harper://about`, `harper://openapi`, `harper://schema/...`, `harper://operations`) have no change source and are **not** subscribable. They participate in `notifications/resources/list_changed` only. -A refused subscribe, row-backed or custom, answers a JSON-RPC error rather than an HTTP error : +A refused subscribe, row-backed or custom, answers a JSON-RPC error rather than an HTTP 500 : | Answer | When | | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | From c67dee038143ad65fce82219759afa6825c21196 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Sat, 17 Oct 2026 19:15:56 -0600 Subject: [PATCH 4/5] Mark the subscribe sample as JavaScript, and say how anonymous custom streams end Dispatch-Task: harper-3250-mcp-resource-subscribe Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019W6f2CJA6oE5ixf6S2b4Ah --- reference/mcp/subscriptions.md | 2 +- reference/mcp/tools-and-resources.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/reference/mcp/subscriptions.md b/reference/mcp/subscriptions.md index a7d7402c7..8075a8411 100644 --- a/reference/mcp/subscriptions.md +++ b/reference/mcp/subscriptions.md @@ -129,7 +129,7 @@ Subscriptions are also torn down automatically when the session's GET-SSE stream ## Scope and durability -Subscriptions are **per-worker**: a subscription is bound to the worker that holds the session's SSE stream, and the audit-log change feed delivers commits locally on that worker. A custom resource's stream runs on that worker too, so it must deliver changes that originate on other workers. The live subscription objects can't be persisted, but the durable list of subscribed URIs lives on the `mcp_session` record, which is how they survive a reconnect. +Subscriptions are **per-worker**: a subscription is bound to the worker that holds the session's SSE stream, and the audit-log change feed delivers commits locally on that worker. A custom resource's stream also runs on that worker, and must itself deliver changes made on other workers, as Harper table subscriptions do. The live subscription objects can't be persisted, but the durable list of subscribed URIs lives on the `mcp_session` record, which is how they survive a reconnect. ## Resuming a dropped stream diff --git a/reference/mcp/tools-and-resources.md b/reference/mcp/tools-and-resources.md index 07cfab73c..85670b4ac 100644 --- a/reference/mcp/tools-and-resources.md +++ b/reference/mcp/tools-and-resources.md @@ -196,7 +196,7 @@ Notes: Add `subscribe` to an entry to let MCP clients watch its URIs. It names an instance method that is called the same way as `method` — `(params, context)` on the live class, inside the same transaction carrying the session user — and returns an async iterable. Every value the iterable yields sends one `notifications/resources/updated` with the subscribed URI; the value itself is never sent, so the client re-reads the resource. A URI whose entry has no `subscribe` answers `-32602 resource is not subscribable: `, as before. -```ts +```js import { RequestTarget, getUser } from 'harper'; export class Order extends tables.Order { @@ -242,7 +242,7 @@ The contract: - **Authorize and acquire before returning.** The returned iterable is consumed after the transaction ends, so a check made while iterating no longer runs as the session user. A generator method (`async *watch()`) would run its whole body that way, so a `subscribe` that names one is ignored with a warning, and the entry stays readable. - **Release.** Harper releases the stream on `resources/unsubscribe`, when the session's GET stream closes, on `DELETE /mcp`, and when the stream ends or throws: it calls the stream's `end()` if it has one, otherwise its iterator's `return()`. Return a stream with `end()` — a Harper subscription such as `Table.subscribe(...)`, or your own queue. Without `end()`, the iterator must have a `return()` that releases its source at once. A stream with neither, or an async generator with no `end()`, fails the subscribe with `-32603`: there is nothing to call, or `return()` cannot interrupt the generator while it waits for its next value. -- **Access after subscribe.** Harper ends the stream when the session's bearer token expires, or when its user is dropped or no longer has a role. A [scoped token](/reference/v5/security/jwt-authentication#scoped-tokens-inline-role) belongs to no user, so only its expiry ends the stream. Changes to resource-specific access after subscribe are yours to enforce, for example by checking each change before yielding it. +- **Access after subscribe.** Harper ends the stream when the session's bearer token expires, or when its user is dropped or no longer has a role. A [scoped token](/reference/v5/security/jwt-authentication#scoped-tokens-inline-role) belongs to no user, so only its expiry ends the stream, and an anonymous session has no identity to lapse, so only the release paths above end its stream. Changes to resource-specific access after subscribe are yours to enforce, for example by checking each change before yielding it. - **Workers.** A subscription runs on the worker holding the session's GET stream. If changes can originate on other workers, your stream must deliver them; Harper table subscriptions do. - **Errors.** An `AccessViolation`, which Harper's own permission checks throw (for example, a table read with `target.checkPermission` that the user's role does not allow), answers `-32602 permission denied: `. Any other error, including one your own gate throws, answers `-32603 failed to subscribe: `; the raw error goes to the server log. From c24c6a900568effda123f118ea342a35cbc5bf30 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Sat, 17 Oct 2026 19:25:38 -0600 Subject: [PATCH 5/5] Write the inline 5.4 version notes as plain text, per CONTRIBUTING Dispatch-Task: harper-3250-mcp-resource-subscribe Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019W6f2CJA6oE5ixf6S2b4Ah --- reference/mcp/overview.md | 2 +- reference/mcp/subscriptions.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/reference/mcp/overview.md b/reference/mcp/overview.md index 47d533098..dc8530cfa 100644 --- a/reference/mcp/overview.md +++ b/reference/mcp/overview.md @@ -13,7 +13,7 @@ Harper implements the [Model Context Protocol](https://modelcontextprotocol.io) - **Tool discovery and invocation.** LLM hosts get a typed list of operations they can call (`tools/list`) and a uniform JSON-RPC invocation envelope (`tools/call`). Per-tool input schemas come from Harper's operation catalog (operations profile) or from your `Table.attributes` and exported `Resource` classes (application profile). - **Resource exposure.** Synthetic `harper://` URIs surface metadata (server info, OpenAPI document, table schemas, operations catalog), and `harper+rest://` URIs mirror your application's REST surface so hosts can resolve real REST endpoints in-process. Components can also publish their own content under author-chosen URIs with a static `mcpResources` array. - **Server-push notifications.** `notifications/tools/list_changed` and `notifications/resources/list_changed` fire over an open Server-Sent Events channel when role mutations or schema changes alter what a session can see. -- **Resource subscriptions.** Subscribe to a row-backed application resource with `resources/subscribe` and receive a `notifications/resources/updated` push whenever that record (or table) changes, driven by Harper's audit-log change feed. Custom resources can be subscribable too, by declaring `subscribe` . See [Resource Subscriptions](./subscriptions.md). +- **Resource subscriptions.** Subscribe to a row-backed application resource with `resources/subscribe` and receive a `notifications/resources/updated` push whenever that record (or table) changes, driven by Harper's audit-log change feed. Custom resources can be subscribable too, by declaring `subscribe` (Added in: v5.4.0). See [Resource Subscriptions](./subscriptions.md). - **Resumable notification streams.** A dropped GET-SSE connection can reconnect with a `Last-Event-ID` header and replay the notifications it missed from a bounded per-session buffer, so a brief network blip doesn't lose `list_changed` / `resources/updated` events. See [Resource Subscriptions](./subscriptions.md#resuming-a-dropped-stream). - **Per-session bookkeeping.** Sessions persist for the configured idle window; `Mcp-Session-Id` ties JSON-RPC requests, GET-SSE notifications, and the optional DELETE-session cleanup together. - **Built-in auth and RBAC.** Harper's existing Basic, JWT, and mTLS authentication paths run unchanged on the MCP endpoint. Tool and resource visibility is filtered through your role's `permission` block (`super_user`, `structure_user`, per-operation, per-table, per-attribute). diff --git a/reference/mcp/subscriptions.md b/reference/mcp/subscriptions.md index 8075a8411..a710b4ddc 100644 --- a/reference/mcp/subscriptions.md +++ b/reference/mcp/subscriptions.md @@ -62,7 +62,7 @@ A successful subscribe returns an empty result: Two kinds of URI can be subscribed to: - **Row-backed application resources** — the `harper+rest://` URIs that mirror an exported `Resource` (the same ones `resources/list` advertises on the application profile). The URI's path is matched against the `Resources` registry, and the resource is subscribable only if its class implements `subscribe`. -- **Custom resources that declare `subscribe`** — URIs published through a static `mcpResources` entry. The URI is matched exactly as `resources/read` matches it, and the entry's `subscribe` method supplies the changes. See [Subscribing to custom resources](./tools-and-resources.md#subscribing-to-custom-resources). +- **Custom resources that declare `subscribe`** (Added in: v5.4.0) — URIs published through a static `mcpResources` entry. The URI is matched exactly as `resources/read` matches it, and the entry's `subscribe` method supplies the changes. See [Subscribing to custom resources](./tools-and-resources.md#subscribing-to-custom-resources). Anything else returns `-32602`: @@ -72,7 +72,7 @@ Anything else returns `-32602`: Synthetic `harper://*` metadata URIs (`harper://about`, `harper://openapi`, `harper://schema/...`, `harper://operations`) have no change source and are **not** subscribable. They participate in `notifications/resources/list_changed` only. -A refused subscribe, row-backed or custom, answers a JSON-RPC error rather than an HTTP 500 : +A refused subscribe, row-backed or custom, answers a JSON-RPC error rather than an HTTP 500 (Changed in: v5.4.0): | Answer | When | | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | @@ -141,7 +141,7 @@ Mcp-Session-Id: 0c8f... Last-Event-ID: 137 ``` -Harper replays every buffered frame with a higher id (exclusive) before live frames resume, so a brief network blip doesn't drop `notifications/resources/updated` or `list_changed` events. On reconnect, Harper also restores the session's persisted subscriptions from the `mcp_session` record (best-effort — a URI that is no longer subscribable is dropped; one whose subscribe failed, or that nothing on this worker matches yet, stays on the record and is retried on the next reconnect). The GET response does not wait for the restore : subscriptions are restored after the stream opens, at most eight at a time, and their notifications follow on the stream. +Harper replays every buffered frame with a higher id (exclusive) before live frames resume, so a brief network blip doesn't drop `notifications/resources/updated` or `list_changed` events. On reconnect, Harper also restores the session's persisted subscriptions from the `mcp_session` record (best-effort — a URI that is no longer subscribable is dropped; one whose subscribe failed, or that nothing on this worker matches yet, stays on the record and is retried on the next reconnect). The GET response does not wait for the restore (Changed in: v5.4.0): subscriptions are restored after the stream opens, at most eight at a time, and their notifications follow on the stream. Caveats, all best-effort by design: