Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion reference/mcp/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.** <VersionBadge version="v5.1.10" /> 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.** <VersionBadge version="v5.1.10" /> 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.** <VersionBadge version="v5.1.10" /> 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).
Expand Down
25 changes: 19 additions & 6 deletions reference/mcp/subscriptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ title: MCP Resource Subscriptions

<VersionBadge version="v5.1.10" />

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

Expand Down Expand Up @@ -59,14 +59,27 @@ 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`** (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`:

```json
{ "error": { "code": -32602, "message": "resource is not subscribable: harper://schema/data/product" } }
```

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 (Changed in: v5.4.0):

| Answer | When |
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| `-32602 permission denied: <uri>` | The session user may not read the resource. |
| `-32603 failed to subscribe: <uri>` | The subscribe failed for another reason; the raw error is in the server log. |
| `-32602 resources/subscribe for <uri> was cancelled: <reason>` | 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 <VersionBadge type="changed" version="v5.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
Expand All @@ -80,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
{
Expand Down Expand Up @@ -116,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 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

Expand All @@ -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 (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:

Expand Down
61 changes: 59 additions & 2 deletions reference/mcp/tools-and-resources.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,8 +186,65 @@ 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).

#### Subscribing to custom resources

<VersionBadge version="v5.4.0" />

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: <uri>`, as before.

```js
import { RequestTarget, getUser } from 'harper';

export class Order extends tables.Order {
static async get(target, context) {
const order = await super.get(target, context);
if (order && order.customerId !== getUser()?.username) throw new Error('not authorized');
return order;
}
}

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) {
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);
if (!order) throw new Error(`no such order: ${params.orderId}`);
const target = new RequestTarget();
target.id = params.orderId;
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. 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: <uri>`. Any other error, including one your own gate throws, answers `-32603 failed to subscribe: <uri>`; the raw error goes to the server log.

#### Template parameters are client-controlled

Expand Down Expand Up @@ -279,7 +336,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

Expand Down
4 changes: 4 additions & 0 deletions release-notes/v5-lincoln/5.4.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
Loading