From 1e1005f22cc15c2d149fb38d3aa8b60df8f4cf69 Mon Sep 17 00:00:00 2001 From: Kyle Bernhardy Date: Tue, 3 Nov 2026 22:55:12 -0700 Subject: [PATCH 01/15] Document MCP subscription expiry and credential rotation --- reference/mcp/tools-and-resources.md | 10 ++++++++++ release-notes/v5-lincoln/5.4.md | 6 +++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/reference/mcp/tools-and-resources.md b/reference/mcp/tools-and-resources.md index e12a5aa63..91090ef19 100644 --- a/reference/mcp/tools-and-resources.md +++ b/reference/mcp/tools-and-resources.md @@ -371,6 +371,16 @@ Per-record reads go through the tools surface, where each Resource's `allow{Read Author-declared `mcpResources` entries (see [Custom `mcpResources` opt-in](#custom-mcpresources-opt-in)) appear alongside the built-in surfaces: fixed URIs in `resources/list`, templates in `resources/templates/list`. A registered custom URI always wins over the discovered surfaces on `resources/read`. +### Subscription lifetime and credential rotation + + + +Resource subscriptions run on the worker holding the session's GET-SSE stream, even when a `resources/subscribe` POST reaches another worker. Open the GET stream before subscribing. A successful subscribe response means that the current GET worker has installed the subscription. + +Resource updates stop when either the subscribing POST's bearer credential or the receiving GET's bearer credential expires, whichever comes first. Reconnecting with a refreshed token can restore an unexpired subscription, but does not extend the expiry of the credential that created it. Current GET permissions still apply. + +After rotating credentials, open a GET stream with the replacement credential and re-issue `resources/subscribe` for each URI you want to keep watching. A native table subscription can end at expiry while the GET stream stays open, so an open connection alone does not mean that updates are still being delivered. Re-subscribing with the replacement credential establishes a new subscription lifetime. + ## `notifications/*/list_changed` After the `initialize` handshake, an MCP client opens `GET /mcp` to keep an SSE channel open for server-push frames. Harper subscribes to its existing role-cache and schema-reload event channels and, whenever one fires: diff --git a/release-notes/v5-lincoln/5.4.md b/release-notes/v5-lincoln/5.4.md index 84dcb9450..095d6231d 100644 --- a/release-notes/v5-lincoln/5.4.md +++ b/release-notes/v5-lincoln/5.4.md @@ -73,7 +73,7 @@ A durable MQTT session now resumes through the same checked replay. When a recon ### 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). +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 reconnecting GET streams restore persisted subscriptions before accepting new resource updates. See [Subscribing to custom resources](/reference/v5/mcp/tools-and-resources#subscribing-to-custom-resources). ## Built-in Agent @@ -206,6 +206,10 @@ When two enabled OIDC trust policies match one identity token and give it a diff ## MCP +### Resource Subscriptions Preserve Credential Expiry Across Workers + +MCP resource subscriptions now work when the subscribe POST and GET-SSE stream reach different HTTP workers. Bearer expiry is preserved across workers and reconnects, and refreshing permissions retains the GET credential's expiry. Updates stop after either credential expires. Refreshing a token or reconnecting does not renew an existing subscription: open a GET stream with the replacement credential, then re-issue `resources/subscribe` for each URI. Native table subscriptions can end without closing the GET stream. See [Subscription lifetime and credential rotation](/reference/v5/mcp/tools-and-resources#subscription-lifetime-and-credential-rotation) ([Make MCP resource subscriptions reliable across HTTP workers](https://github.com/HarperFast/harper/pull/2436)). + ### Session Creation Is Rate-Limited by Default MCP `initialize` is now limited per client identity, on both profiles, by the new `mcp..rateLimit.initializePerSecond` (default `10`) and `initializeBurst` (default `50`). Each session is a stored row kept for the idle timeout, and the application profile accepts anonymous `initialize`, so this limit is on by default; set `initializePerSecond: 0` to turn it off. A denied `initialize` gets HTTP `429` with a `Retry-After` header and a JSON-RPC error, and no session is created. Clients that open sessions in bursts, such as a test suite or a pool of agents behind one IP address, may now see that `429` after upgrading. `initialize` also stores only the names of the client capabilities Harper checks (`sampling`, `elicitation`, `roots`), not the capabilities object the client sent. See [`initializePerSecond`](/reference/v5/mcp/configuration#mcpprofileratelimitinitializepersecond) ([harper#3537](https://github.com/HarperFast/harper/pull/3537)). From 69e51d5f60351dcf079a31b8b9c5e3d8b3b0e8e9 Mon Sep 17 00:00:00 2001 From: Kyle Bernhardy Date: Tue, 3 Nov 2026 22:59:24 -0700 Subject: [PATCH 02/15] Align MCP subscription lifecycle and reconnect guidance --- reference/mcp/subscriptions.md | 39 +++++++++++++++++++--------- reference/mcp/tools-and-resources.md | 12 ++------- release-notes/v5-lincoln/5.4.md | 4 +-- 3 files changed, 31 insertions(+), 24 deletions(-) diff --git a/reference/mcp/subscriptions.md b/reference/mcp/subscriptions.md index 477074191..5cbceb2fd 100644 --- a/reference/mcp/subscriptions.md +++ b/reference/mcp/subscriptions.md @@ -32,7 +32,7 @@ A subscription delivers its updates over the session's GET-SSE channel, so the c { "error": { "code": -32602, "message": "open the GET SSE stream before subscribing to resources" } } ``` -The open stream is also the subscription's lifecycle boundary: when it closes (by any of the paths in [Unsubscribing](#unsubscribing)), the subscription's underlying change iterator is released. This is why Harper rejects a stream-less subscribe rather than letting the iterator leak with nowhere to deliver. +The open stream is also a subscription lifecycle boundary: when it closes (by any of the paths in [Unsubscribing](#unsubscribing)), the subscription's underlying change iterator is released. Credential expiry also stops delivery; see [Subscription lifetime and credential rotation](#subscription-lifetime-and-credential-rotation). ## Subscribing @@ -47,7 +47,7 @@ Send `resources/subscribe` with the resource URI: } ``` -A successful subscribe returns an empty result: +A successful subscribe means the current GET worker has installed the subscription, and returns an empty result: ```json { "jsonrpc": "2.0", "id": 7, "result": {} } @@ -74,11 +74,13 @@ Synthetic `harper://*` metadata URIs (`harper://about`, `harper://openapi`, `har 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: ` | 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. | +| Answer | When | +| ----------------------------------------------------------- | ------------------------------------------------------------------------- | +| `-32602 permission denied: ` | The subscription principal may not read the resource. | +| `-32603 resource subscription timed out; retry the request` | Installation was not acknowledged before the timeout. | +| `-32603 resource subscription failed; retry the request` | Installation failed or an overlapping request superseded this generation. | + +A missing or expired session returns HTTP `404`. A successful response from an overlapping subscribe does not prove that another request's credential was authorized. `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. @@ -125,11 +127,24 @@ Send `resources/unsubscribe` with the same URI: - `params.uri` is required and must be a string — otherwise `-32602` (`resources/unsubscribe requires params.uri`). - Unsubscribing a URI that was never subscribed is a no-op and still returns `{}`. -Subscriptions are also torn down automatically when the session's GET-SSE stream closes — via explicit `DELETE /mcp`, idle-timeout eviction from `system.mcp_session`, a dropped TCP connection, or an idle-prune sweep. +Subscriptions are also torn down automatically when the session's GET-SSE stream closes — via explicit `DELETE /mcp`, idle-timeout eviction from `system.mcp_session`, a dropped TCP connection, or an idle-prune sweep. Credential expiry stops delivery too; a row-backed (table) subscription can end at expiry while its GET stream remains open. ## 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 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. +A live subscription runs on the worker holding the session's GET-SSE stream. The subscribe POST may reach another HTTP worker: it updates the durable desired URI set, and succeeds after the current GET worker acknowledges installation. The durable state survives worker changes and a server restart. A custom resource's stream still needs to deliver changes originating on other workers, as Harper table subscriptions do. + +## Subscription lifetime and credential rotation + + + +Resource updates stop when either the subscribing POST's bearer credential or the receiving GET's bearer credential expires, whichever comes first. Harper keeps the GET credential's expiry when it re-reads the user's permissions after a role change. Reconnecting can restore an unexpired subscription under the current GET permissions, but cannot extend the original POST credential's expiry. A reconnect after a network interruption does not require re-subscribing while that credential is still valid. + +To renew subscriptions after rotating credentials: + +1. Close the old GET stream and open `GET /mcp` for the same session with the replacement credential. +2. Send `resources/subscribe` for each URI you want to keep watching, using the replacement credential on each POST too. + +A row-backed (table) subscription can end at expiry while the GET stream stays open. An open connection alone does not mean that updates are still being delivered. Re-subscribing with the replacement credential establishes a new subscription lifetime. ## Resuming a dropped stream @@ -141,13 +156,13 @@ 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 (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. +Harper replays buffered frames with a higher id (exclusive) before live frames resume, subject to the per-worker limits below. On reconnect, Harper restores persisted subscriptions under the current GET permissions and their original credential expiry. The GET response waits for restoration before returning HTTP `200` (Changed in: v5.4.0). Expired, denied, or no-longer-subscribable entries are removed. A transient restoration failure retains the URI and returns HTTP `503` with `Retry-After: 1`; retry the GET. An absent session returns HTTP `404`. Caveats, all best-effort by design: - **Buffer bound.** Only the last 100 frames are retained; a client that was disconnected long enough to miss more than 100 frames will not get the older ones. A non-numeric `Last-Event-ID` replays the entire buffer. -- **Per-worker.** The replay buffer and event-id sequence live on the worker that served the original stream. If the reconnecting `GET` lands on a different worker (one that never held the prior stream), its buffer is empty and there is nothing to replay. This is the same per-worker binding that scopes sessions and subscriptions. +- **Per-worker.** The replay buffer and event-id sequence live on the worker that served the original stream. If the reconnecting `GET` lands on a different worker (one that never held the prior stream), its buffer is empty and there is nothing to replay. Persisted subscription state can restore on the new worker, but the old notification history cannot. ## Not yet supported -See [the v1 out-of-scope list](./overview.md#out-of-scope-for-v1). Notably, cross-worker session sharing is deferred, so subscription delivery and `Last-Event-ID` replay are both best-effort across a reconnect that changes workers. +See [the v1 out-of-scope list](./overview.md#out-of-scope-for-v1). Notification replay history is not shared between workers; restoring a subscription on another worker does not recover updates missed during the disconnection. diff --git a/reference/mcp/tools-and-resources.md b/reference/mcp/tools-and-resources.md index 91090ef19..34013fdfc 100644 --- a/reference/mcp/tools-and-resources.md +++ b/reference/mcp/tools-and-resources.md @@ -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, 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. +- **Access after subscribe.** Harper stops updates when either the subscribing POST's or the receiving GET's bearer credential expires, whichever comes first, or when the user is dropped or no longer has a role. See [Subscription lifetime and credential rotation](./subscriptions.md#subscription-lifetime-and-credential-rotation). 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. @@ -371,15 +371,7 @@ Per-record reads go through the tools surface, where each Resource's `allow{Read Author-declared `mcpResources` entries (see [Custom `mcpResources` opt-in](#custom-mcpresources-opt-in)) appear alongside the built-in surfaces: fixed URIs in `resources/list`, templates in `resources/templates/list`. A registered custom URI always wins over the discovered surfaces on `resources/read`. -### Subscription lifetime and credential rotation - - - -Resource subscriptions run on the worker holding the session's GET-SSE stream, even when a `resources/subscribe` POST reaches another worker. Open the GET stream before subscribing. A successful subscribe response means that the current GET worker has installed the subscription. - -Resource updates stop when either the subscribing POST's bearer credential or the receiving GET's bearer credential expires, whichever comes first. Reconnecting with a refreshed token can restore an unexpired subscription, but does not extend the expiry of the credential that created it. Current GET permissions still apply. - -After rotating credentials, open a GET stream with the replacement credential and re-issue `resources/subscribe` for each URI you want to keep watching. A native table subscription can end at expiry while the GET stream stays open, so an open connection alone does not mean that updates are still being delivered. Re-subscribing with the replacement credential establishes a new subscription lifetime. +For the GET-stream prerequisite, credential expiry, and renewal steps, see [Subscription lifetime and credential rotation](./subscriptions.md#subscription-lifetime-and-credential-rotation). ## `notifications/*/list_changed` diff --git a/release-notes/v5-lincoln/5.4.md b/release-notes/v5-lincoln/5.4.md index 095d6231d..5461e5dad 100644 --- a/release-notes/v5-lincoln/5.4.md +++ b/release-notes/v5-lincoln/5.4.md @@ -73,7 +73,7 @@ A durable MQTT session now resumes through the same checked replay. When a recon ### 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 reconnecting GET streams restore persisted subscriptions before accepting new resource updates. See [Subscribing to custom resources](/reference/v5/mcp/tools-and-resources#subscribing-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 its user is dropped. Updates also stop at the earlier POST/GET bearer expiry. A subscribe that fails now answers a JSON-RPC error instead of an HTTP 500, for row-backed resources too, and a reconnecting GET response waits for persisted subscriptions to be restored before returning HTTP `200`. A transient restore failure returns HTTP `503` with `Retry-After: 1`. See [Subscribing to custom resources](/reference/v5/mcp/tools-and-resources#subscribing-to-custom-resources). ## Built-in Agent @@ -208,7 +208,7 @@ When two enabled OIDC trust policies match one identity token and give it a diff ### Resource Subscriptions Preserve Credential Expiry Across Workers -MCP resource subscriptions now work when the subscribe POST and GET-SSE stream reach different HTTP workers. Bearer expiry is preserved across workers and reconnects, and refreshing permissions retains the GET credential's expiry. Updates stop after either credential expires. Refreshing a token or reconnecting does not renew an existing subscription: open a GET stream with the replacement credential, then re-issue `resources/subscribe` for each URI. Native table subscriptions can end without closing the GET stream. See [Subscription lifetime and credential rotation](/reference/v5/mcp/tools-and-resources#subscription-lifetime-and-credential-rotation) ([Make MCP resource subscriptions reliable across HTTP workers](https://github.com/HarperFast/harper/pull/2436)). +MCP resource subscriptions now work when the subscribe POST and GET-SSE stream reach different HTTP workers. The earlier POST/GET bearer expiry is preserved across workers and reconnects. Re-reading a user's permissions after a role change also retains the GET credential's expiry. An ordinary network reconnect restores unexpired subscriptions without new subscribe calls. To renew after rotating credentials, close the old GET, open a replacement GET with the new credential, and send each `resources/subscribe` POST with that credential too. Row-backed subscriptions can end at expiry without closing the GET stream. See [Subscription lifetime and credential rotation](/reference/v5/mcp/subscriptions#subscription-lifetime-and-credential-rotation) ([Make MCP resource subscriptions reliable across HTTP workers](https://github.com/HarperFast/harper/pull/2436)). ### Session Creation Is Rate-Limited by Default From 167213087f12ef208758cdc2009a640e60aef153 Mon Sep 17 00:00:00 2001 From: Kyle Bernhardy Date: Tue, 3 Nov 2026 23:03:25 -0700 Subject: [PATCH 03/15] Clarify MCP subscription retry and restore boundaries --- reference/mcp/overview.md | 2 +- reference/mcp/subscriptions.md | 16 ++++++++-------- reference/mcp/tools-and-resources.md | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/reference/mcp/overview.md b/reference/mcp/overview.md index a342716eb..3a6392426 100644 --- a/reference/mcp/overview.md +++ b/reference/mcp/overview.md @@ -64,6 +64,6 @@ See [MCP Tools and Resources](./tools-and-resources.md) for the full generation The following items are explicitly deferred to a follow-on release: - OAuth 2.1 PRM (Protected Resource Metadata) authorization. -- Cross-worker session sharing (each MCP session is bound to the worker that accepted the GET stream). +- Sharing notification replay history between workers. The live GET stream belongs to one worker, but [persisted resource subscriptions](./subscriptions.md#scope-and-durability) can restore on another worker and accept subscribe POSTs from other workers. - TypeScript type reflection into JSON Schema for custom `mcpTools` entries (schemas are hand-authored). - Global REST/operations rate limiting — the MCP rate limits (per-session, per-tool, per-client, and `initialize`) apply to the MCP surface only. diff --git a/reference/mcp/subscriptions.md b/reference/mcp/subscriptions.md index 5cbceb2fd..3111388c8 100644 --- a/reference/mcp/subscriptions.md +++ b/reference/mcp/subscriptions.md @@ -74,13 +74,13 @@ Synthetic `harper://*` metadata URIs (`harper://about`, `harper://openapi`, `har 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: ` | The subscription principal may not read the resource. | -| `-32603 resource subscription timed out; retry the request` | Installation was not acknowledged before the timeout. | -| `-32603 resource subscription failed; retry the request` | Installation failed or an overlapping request superseded this generation. | +| Answer | When | +| ----------------------------------------------------------- | ------------------------------------------------------------------------------ | +| `-32602 permission denied: ` | The credentials used for this subscription do not permit reading the resource. | +| `-32603 resource subscription timed out; retry the request` | Installation was not acknowledged before the timeout. | +| `-32603 resource subscription failed; retry the request` | Installation failed or an overlapping request superseded this generation. | -A missing or expired session returns HTTP `404`. A successful response from an overlapping subscribe does not prove that another request's credential was authorized. +A missing or expired session returns HTTP `404`. If requests for the same URI overlap, retry a failed request with the current credential. `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. @@ -137,7 +137,7 @@ A live subscription runs on the worker holding the session's GET-SSE stream. The -Resource updates stop when either the subscribing POST's bearer credential or the receiving GET's bearer credential expires, whichever comes first. Harper keeps the GET credential's expiry when it re-reads the user's permissions after a role change. Reconnecting can restore an unexpired subscription under the current GET permissions, but cannot extend the original POST credential's expiry. A reconnect after a network interruption does not require re-subscribing while that credential is still valid. +Resource updates stop when either the subscribing POST's bearer credential or the receiving GET's bearer credential expires, whichever comes first. Harper keeps the GET credential's expiry when it re-reads the user's permissions after a role change. Reconnecting can restore an unexpired subscription under the current GET permissions, but cannot extend the original POST credential's expiry. A reconnect after a network interruption does not require re-subscribing while the original subscribing POST credential is still valid. To renew subscriptions after rotating credentials: @@ -156,7 +156,7 @@ Mcp-Session-Id: 0c8f... Last-Event-ID: 137 ``` -Harper replays buffered frames with a higher id (exclusive) before live frames resume, subject to the per-worker limits below. On reconnect, Harper restores persisted subscriptions under the current GET permissions and their original credential expiry. The GET response waits for restoration before returning HTTP `200` (Changed in: v5.4.0). Expired, denied, or no-longer-subscribable entries are removed. A transient restoration failure retains the URI and returns HTTP `503` with `Retry-After: 1`; retry the GET. An absent session returns HTTP `404`. +Harper replays buffered frames with a higher id (exclusive) before live frames resume, subject to the per-worker limits below. On reconnect, Harper restores persisted subscriptions under the current GET permissions, bounded by the original subscribing POST expiry and the current GET expiry. The GET response waits for restoration before returning HTTP `200` (Changed in: v5.4.0). Expired, denied, or no-longer-subscribable entries are removed. A transient restoration failure retains the URI and returns HTTP `503` with `Retry-After: 1`; retry the GET. An absent session returns HTTP `404`. Caveats, all best-effort by design: diff --git a/reference/mcp/tools-and-resources.md b/reference/mcp/tools-and-resources.md index 34013fdfc..f666115d7 100644 --- a/reference/mcp/tools-and-resources.md +++ b/reference/mcp/tools-and-resources.md @@ -244,7 +244,7 @@ The contract: - **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 stops updates when either the subscribing POST's or the receiving GET's bearer credential expires, whichever comes first, or when the user is dropped or no longer has a role. See [Subscription lifetime and credential rotation](./subscriptions.md#subscription-lifetime-and-credential-rotation). 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. +- **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: `. Other installation failures, including an error your own gate throws, answer `-32603`; see [subscription error responses](./subscriptions.md#which-uris-are-subscribable). The raw error goes to the server log. #### Template parameters are client-controlled From ad73ca9cc15c9705a9905b0fd3b355bb61eb61c6 Mon Sep 17 00:00:00 2001 From: Kyle Bernhardy Date: Tue, 3 Nov 2026 23:06:10 -0700 Subject: [PATCH 04/15] Name the retained POST expiry in MCP release guidance --- release-notes/v5-lincoln/5.4.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/release-notes/v5-lincoln/5.4.md b/release-notes/v5-lincoln/5.4.md index 5461e5dad..2e988b679 100644 --- a/release-notes/v5-lincoln/5.4.md +++ b/release-notes/v5-lincoln/5.4.md @@ -208,7 +208,7 @@ When two enabled OIDC trust policies match one identity token and give it a diff ### Resource Subscriptions Preserve Credential Expiry Across Workers -MCP resource subscriptions now work when the subscribe POST and GET-SSE stream reach different HTTP workers. The earlier POST/GET bearer expiry is preserved across workers and reconnects. Re-reading a user's permissions after a role change also retains the GET credential's expiry. An ordinary network reconnect restores unexpired subscriptions without new subscribe calls. To renew after rotating credentials, close the old GET, open a replacement GET with the new credential, and send each `resources/subscribe` POST with that credential too. Row-backed subscriptions can end at expiry without closing the GET stream. See [Subscription lifetime and credential rotation](/reference/v5/mcp/subscriptions#subscription-lifetime-and-credential-rotation) ([Make MCP resource subscriptions reliable across HTTP workers](https://github.com/HarperFast/harper/pull/2436)). +MCP resource subscriptions now work when the subscribe POST and GET-SSE stream reach different HTTP workers. The original subscribing POST expiry is preserved across workers and reconnects; delivery is also bounded by the current GET credential's expiry. Re-reading a user's permissions after a role change also retains the GET credential's expiry. An ordinary network reconnect restores unexpired subscriptions without new subscribe calls. To renew after rotating credentials, close the old GET, open a replacement GET with the new credential, and send each `resources/subscribe` POST with that credential too. Row-backed subscriptions can end at expiry without closing the GET stream. See [Subscription lifetime and credential rotation](/reference/v5/mcp/subscriptions#subscription-lifetime-and-credential-rotation) ([Make MCP resource subscriptions reliable across HTTP workers](https://github.com/HarperFast/harper/pull/2436)). ### Session Creation Is Rate-Limited by Default From 388dd50935bdd65372c9c427aa0957b8c6c75f69 Mon Sep 17 00:00:00 2001 From: Kyle Bernhardy Date: Tue, 3 Nov 2026 23:09:16 -0700 Subject: [PATCH 05/15] Clarify retry limits and re-reading after subscription renewal --- reference/mcp/subscriptions.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/reference/mcp/subscriptions.md b/reference/mcp/subscriptions.md index 3111388c8..e5bad7452 100644 --- a/reference/mcp/subscriptions.md +++ b/reference/mcp/subscriptions.md @@ -74,13 +74,13 @@ Synthetic `harper://*` metadata URIs (`harper://about`, `harper://openapi`, `har 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: ` | The credentials used for this subscription do not permit reading the resource. | -| `-32603 resource subscription timed out; retry the request` | Installation was not acknowledged before the timeout. | -| `-32603 resource subscription failed; retry the request` | Installation failed or an overlapping request superseded this generation. | +| Answer | When | +| ----------------------------------------------------------- | ------------------------------------------------------------------------------- | +| `-32602 permission denied: ` | The credentials used for this subscription do not permit reading the resource. | +| `-32603 resource subscription timed out; retry the request` | Installation was not acknowledged before the timeout. | +| `-32603 resource subscription failed; retry the request` | Installation failed, or another request for the same URI replaced this request. | -A missing or expired session returns HTTP `404`. If requests for the same URI overlap, retry a failed request with the current credential. +A missing or expired session returns HTTP `404`. If requests for the same URI overlap, retry a failed request with the current credential. Retry transient `-32603` failures with backoff. If failures repeat, have the server operator inspect the logged error and fix its cause before retrying; an invalid custom stream or a consistently failing custom gate will not be repaired by retries. `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. @@ -137,12 +137,13 @@ A live subscription runs on the worker holding the session's GET-SSE stream. The -Resource updates stop when either the subscribing POST's bearer credential or the receiving GET's bearer credential expires, whichever comes first. Harper keeps the GET credential's expiry when it re-reads the user's permissions after a role change. Reconnecting can restore an unexpired subscription under the current GET permissions, but cannot extend the original POST credential's expiry. A reconnect after a network interruption does not require re-subscribing while the original subscribing POST credential is still valid. +Resource updates stop when either the subscribing POST's bearer credential or the receiving GET's bearer credential expires, whichever comes first. Harper keeps the GET credential's expiry when it re-reads the user's permissions after a role change. Reconnecting can restore an unexpired subscription under the current GET permissions, but cannot extend the original POST credential's expiry. A reconnect after a network interruption does not require re-subscribing while the original subscribing POST credential is still valid. Component bearer validators that omit `authExpiresAt` default to five minutes, capped by a JWT's `exp`; return the verified token expiry as described in [Bearer validators](../http/api.md#serveraddbearervalidatorvalidator-options). To renew subscriptions after rotating credentials: 1. Close the old GET stream and open `GET /mcp` for the same session with the replacement credential. 2. Send `resources/subscribe` for each URI you want to keep watching, using the replacement credential on each POST too. +3. Re-read each resource to catch changes made while its subscription was inactive; re-subscribing does not replay those changes. A row-backed (table) subscription can end at expiry while the GET stream stays open. An open connection alone does not mean that updates are still being delivered. Re-subscribing with the replacement credential establishes a new subscription lifetime. From 19d20eae2016846d72db2a87867c3f119debc1ec Mon Sep 17 00:00:00 2001 From: Kyle Bernhardy Date: Tue, 3 Nov 2026 23:10:24 -0700 Subject: [PATCH 06/15] Link the registered bearer-validator contract --- 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 e5bad7452..b6495a308 100644 --- a/reference/mcp/subscriptions.md +++ b/reference/mcp/subscriptions.md @@ -137,7 +137,7 @@ A live subscription runs on the worker holding the session's GET-SSE stream. The -Resource updates stop when either the subscribing POST's bearer credential or the receiving GET's bearer credential expires, whichever comes first. Harper keeps the GET credential's expiry when it re-reads the user's permissions after a role change. Reconnecting can restore an unexpired subscription under the current GET permissions, but cannot extend the original POST credential's expiry. A reconnect after a network interruption does not require re-subscribing while the original subscribing POST credential is still valid. Component bearer validators that omit `authExpiresAt` default to five minutes, capped by a JWT's `exp`; return the verified token expiry as described in [Bearer validators](../http/api.md#serveraddbearervalidatorvalidator-options). +Resource updates stop when either the subscribing POST's bearer credential or the receiving GET's bearer credential expires, whichever comes first. Harper keeps the GET credential's expiry when it re-reads the user's permissions after a role change. Reconnecting can restore an unexpired subscription under the current GET permissions, but cannot extend the original POST credential's expiry. A reconnect after a network interruption does not require re-subscribing while the original subscribing POST credential is still valid. Component bearer validators that omit `authExpiresAt` default to five minutes, capped by a JWT's `exp`; return the verified token expiry as described in [Bearer validators](../http/api.md#serverregisterbearervalidatorvalidator-options). To renew subscriptions after rotating credentials: From d7fc941066d414f174d2f64a7cedd7607ad1f889 Mon Sep 17 00:00:00 2001 From: Kyle Bernhardy Date: Tue, 3 Nov 2026 23:16:14 -0700 Subject: [PATCH 07/15] Separate MCP subscription conditions from client instructions --- reference/mcp/subscriptions.md | 8 ++++---- reference/mcp/tools-and-resources.md | 2 +- release-notes/v5-lincoln/5.4.md | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/reference/mcp/subscriptions.md b/reference/mcp/subscriptions.md index b6495a308..48e352aa0 100644 --- a/reference/mcp/subscriptions.md +++ b/reference/mcp/subscriptions.md @@ -80,7 +80,7 @@ A refused subscribe, row-backed or custom, answers a JSON-RPC error rather than | `-32603 resource subscription timed out; retry the request` | Installation was not acknowledged before the timeout. | | `-32603 resource subscription failed; retry the request` | Installation failed, or another request for the same URI replaced this request. | -A missing or expired session returns HTTP `404`. If requests for the same URI overlap, retry a failed request with the current credential. Retry transient `-32603` failures with backoff. If failures repeat, have the server operator inspect the logged error and fix its cause before retrying; an invalid custom stream or a consistently failing custom gate will not be repaired by retries. +A missing or expired session returns HTTP `404`. If requests for the same URI overlap, retry a failed request with the current credential. Retry transient `-32603` failures with backoff. If failures repeat, have the server operator inspect the logged error and fix its cause before retrying. An invalid custom stream or a consistently failing custom gate will not be repaired by retries. `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. @@ -127,7 +127,7 @@ Send `resources/unsubscribe` with the same URI: - `params.uri` is required and must be a string — otherwise `-32602` (`resources/unsubscribe requires params.uri`). - Unsubscribing a URI that was never subscribed is a no-op and still returns `{}`. -Subscriptions are also torn down automatically when the session's GET-SSE stream closes — via explicit `DELETE /mcp`, idle-timeout eviction from `system.mcp_session`, a dropped TCP connection, or an idle-prune sweep. Credential expiry stops delivery too; a row-backed (table) subscription can end at expiry while its GET stream remains open. +Subscriptions are also torn down automatically when the session's GET-SSE stream closes — via explicit `DELETE /mcp`, idle-timeout eviction from `system.mcp_session`, a dropped TCP connection, or an idle-prune sweep. Credential expiry stops delivery too. A row-backed (table) subscription can end at expiry while its GET stream remains open. ## Scope and durability @@ -137,7 +137,7 @@ A live subscription runs on the worker holding the session's GET-SSE stream. The -Resource updates stop when either the subscribing POST's bearer credential or the receiving GET's bearer credential expires, whichever comes first. Harper keeps the GET credential's expiry when it re-reads the user's permissions after a role change. Reconnecting can restore an unexpired subscription under the current GET permissions, but cannot extend the original POST credential's expiry. A reconnect after a network interruption does not require re-subscribing while the original subscribing POST credential is still valid. Component bearer validators that omit `authExpiresAt` default to five minutes, capped by a JWT's `exp`; return the verified token expiry as described in [Bearer validators](../http/api.md#serverregisterbearervalidatorvalidator-options). +Resource updates stop when either the subscribing POST's bearer credential or the receiving GET's bearer credential expires, whichever comes first. Harper keeps the GET credential's expiry when it re-reads the user's permissions after a role change. Reconnecting can restore an unexpired subscription under the current GET permissions, but cannot extend the original POST credential's expiry. A reconnect after a network interruption does not require re-subscribing while the original subscribing POST credential is still valid. Component bearer validators that omit `authExpiresAt` default to five minutes, capped by a JWT's `exp`. Return the verified token expiry as described in [Bearer validators](../http/api.md#serverregisterbearervalidatorvalidator-options). To renew subscriptions after rotating credentials: @@ -157,7 +157,7 @@ Mcp-Session-Id: 0c8f... Last-Event-ID: 137 ``` -Harper replays buffered frames with a higher id (exclusive) before live frames resume, subject to the per-worker limits below. On reconnect, Harper restores persisted subscriptions under the current GET permissions, bounded by the original subscribing POST expiry and the current GET expiry. The GET response waits for restoration before returning HTTP `200` (Changed in: v5.4.0). Expired, denied, or no-longer-subscribable entries are removed. A transient restoration failure retains the URI and returns HTTP `503` with `Retry-After: 1`; retry the GET. An absent session returns HTTP `404`. +Harper replays buffered frames with a higher id (exclusive) before live frames resume, subject to the per-worker limits below. On reconnect, Harper restores persisted subscriptions under the current GET permissions, bounded by the original subscribing POST expiry and the current GET expiry. The GET response waits for restoration before returning HTTP `200` (Changed in: v5.4.0). Expired, denied, or no-longer-subscribable entries are removed. A transient restoration failure retains the URI and returns HTTP `503` with `Retry-After: 1`. Retry the GET. An absent session returns HTTP `404`. Caveats, all best-effort by design: diff --git a/reference/mcp/tools-and-resources.md b/reference/mcp/tools-and-resources.md index f666115d7..b0ef3e694 100644 --- a/reference/mcp/tools-and-resources.md +++ b/reference/mcp/tools-and-resources.md @@ -244,7 +244,7 @@ The contract: - **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 stops updates when either the subscribing POST's or the receiving GET's bearer credential expires, whichever comes first, or when the user is dropped or no longer has a role. See [Subscription lifetime and credential rotation](./subscriptions.md#subscription-lifetime-and-credential-rotation). 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: `. Other installation failures, including an error your own gate throws, answer `-32603`; see [subscription error responses](./subscriptions.md#which-uris-are-subscribable). 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: `. Other installation failures, including an error your own gate throws, answer `-32603`. See [subscription error responses](./subscriptions.md#which-uris-are-subscribable). 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 2e988b679..786b38e62 100644 --- a/release-notes/v5-lincoln/5.4.md +++ b/release-notes/v5-lincoln/5.4.md @@ -73,7 +73,7 @@ A durable MQTT session now resumes through the same checked replay. When a recon ### 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 its user is dropped. Updates also stop at the earlier POST/GET bearer expiry. A subscribe that fails now answers a JSON-RPC error instead of an HTTP 500, for row-backed resources too, and a reconnecting GET response waits for persisted subscriptions to be restored before returning HTTP `200`. A transient restore failure returns HTTP `503` with `Retry-After: 1`. See [Subscribing to custom resources](/reference/v5/mcp/tools-and-resources#subscribing-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 its user is dropped. Updates also stop at the earlier POST/GET bearer expiry. A subscribe that fails now answers a JSON-RPC error instead of an HTTP 500, for row-backed resources too, and a reconnecting GET response waits for persisted subscriptions to be restored before returning HTTP `200`. A transient restore failure returns HTTP `503` with `Retry-After: 1`. See [Subscribing to custom resources](/reference/v5/mcp/tools-and-resources#subscribing-to-custom-resources). ## Built-in Agent @@ -208,7 +208,7 @@ When two enabled OIDC trust policies match one identity token and give it a diff ### Resource Subscriptions Preserve Credential Expiry Across Workers -MCP resource subscriptions now work when the subscribe POST and GET-SSE stream reach different HTTP workers. The original subscribing POST expiry is preserved across workers and reconnects; delivery is also bounded by the current GET credential's expiry. Re-reading a user's permissions after a role change also retains the GET credential's expiry. An ordinary network reconnect restores unexpired subscriptions without new subscribe calls. To renew after rotating credentials, close the old GET, open a replacement GET with the new credential, and send each `resources/subscribe` POST with that credential too. Row-backed subscriptions can end at expiry without closing the GET stream. See [Subscription lifetime and credential rotation](/reference/v5/mcp/subscriptions#subscription-lifetime-and-credential-rotation) ([Make MCP resource subscriptions reliable across HTTP workers](https://github.com/HarperFast/harper/pull/2436)). +MCP resource subscriptions now work when the subscribe POST and GET-SSE stream reach different HTTP workers. The original subscribing POST expiry is preserved across workers and reconnects. Delivery is also bounded by the current GET credential's expiry. Re-reading a user's permissions after a role change also retains the GET credential's expiry. An ordinary network reconnect restores unexpired subscriptions without new subscribe calls. To renew after rotating credentials, close the old GET, open a replacement GET with the new credential, and send each `resources/subscribe` POST with that credential too. Row-backed subscriptions can end at expiry without closing the GET stream. See [Subscription lifetime and credential rotation](/reference/v5/mcp/subscriptions#subscription-lifetime-and-credential-rotation) ([Make MCP resource subscriptions reliable across HTTP workers](https://github.com/HarperFast/harper/pull/2436)). ### Session Creation Is Rate-Limited by Default From b53ba9de6f26ea0c66307c58290bbbc38c3b4c6d Mon Sep 17 00:00:00 2001 From: Kyle Bernhardy Date: Wed, 4 Nov 2026 11:55:22 -0700 Subject: [PATCH 08/15] Clarify scoped subscription reconnect credentials --- reference/mcp/subscriptions.md | 2 ++ release-notes/v5-lincoln/5.4.md | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/reference/mcp/subscriptions.md b/reference/mcp/subscriptions.md index 48e352aa0..7a0320b7b 100644 --- a/reference/mcp/subscriptions.md +++ b/reference/mcp/subscriptions.md @@ -139,6 +139,8 @@ A live subscription runs on the worker holding the session's GET-SSE stream. The Resource updates stop when either the subscribing POST's bearer credential or the receiving GET's bearer credential expires, whichever comes first. Harper keeps the GET credential's expiry when it re-reads the user's permissions after a role change. Reconnecting can restore an unexpired subscription under the current GET permissions, but cannot extend the original POST credential's expiry. A reconnect after a network interruption does not require re-subscribing while the original subscribing POST credential is still valid. Component bearer validators that omit `authExpiresAt` default to five minutes, capped by a JWT's `exp`. Return the verified token expiry as described in [Bearer validators](../http/api.md#serverregisterbearervalidatorvalidator-options). +A subscription created with a scoped token restores only when the GET presents a scoped token with the same username and role permissions. A refreshed expiry is allowed, but does not extend the original subscription expiry. An ordinary credential with the same username cannot inherit that scoped subscription; reconnecting with it removes the stored scoped entry while retaining valid ordinary subscriptions. An explicit new `resources/subscribe` POST can use its own scoped credential. + To renew subscriptions after rotating credentials: 1. Close the old GET stream and open `GET /mcp` for the same session with the replacement credential. diff --git a/release-notes/v5-lincoln/5.4.md b/release-notes/v5-lincoln/5.4.md index 786b38e62..4010ea974 100644 --- a/release-notes/v5-lincoln/5.4.md +++ b/release-notes/v5-lincoln/5.4.md @@ -208,7 +208,7 @@ When two enabled OIDC trust policies match one identity token and give it a diff ### Resource Subscriptions Preserve Credential Expiry Across Workers -MCP resource subscriptions now work when the subscribe POST and GET-SSE stream reach different HTTP workers. The original subscribing POST expiry is preserved across workers and reconnects. Delivery is also bounded by the current GET credential's expiry. Re-reading a user's permissions after a role change also retains the GET credential's expiry. An ordinary network reconnect restores unexpired subscriptions without new subscribe calls. To renew after rotating credentials, close the old GET, open a replacement GET with the new credential, and send each `resources/subscribe` POST with that credential too. Row-backed subscriptions can end at expiry without closing the GET stream. See [Subscription lifetime and credential rotation](/reference/v5/mcp/subscriptions#subscription-lifetime-and-credential-rotation) ([Make MCP resource subscriptions reliable across HTTP workers](https://github.com/HarperFast/harper/pull/2436)). +MCP resource subscriptions now work when the subscribe POST and GET-SSE stream reach different HTTP workers. The original subscribing POST expiry is preserved across workers and reconnects. Delivery is also bounded by the current GET credential's expiry. Re-reading a user's permissions after a role change also retains the GET credential's expiry. A network reconnect with matching credentials restores unexpired subscriptions without new subscribe calls. Stored scoped subscriptions require a scoped GET with the same username and role permissions; an ordinary credential with that username cannot inherit them. To renew after rotating credentials, close the old GET, open a replacement GET with the new credential, and send each `resources/subscribe` POST with that credential too. Row-backed subscriptions can end at expiry without closing the GET stream. See [Subscription lifetime and credential rotation](/reference/v5/mcp/subscriptions#subscription-lifetime-and-credential-rotation) ([Make MCP resource subscriptions reliable across HTTP workers](https://github.com/HarperFast/harper/pull/2436)). ### Session Creation Is Rate-Limited by Default From be31e5664129ffbdc30c1141b380b22d3284a197 Mon Sep 17 00:00:00 2001 From: Kyle Bernhardy Date: Wed, 4 Nov 2026 11:57:43 -0700 Subject: [PATCH 09/15] Describe newly issued scoped tokens without implying refresh support --- 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 7a0320b7b..39d54eb7d 100644 --- a/reference/mcp/subscriptions.md +++ b/reference/mcp/subscriptions.md @@ -139,7 +139,7 @@ A live subscription runs on the worker holding the session's GET-SSE stream. The Resource updates stop when either the subscribing POST's bearer credential or the receiving GET's bearer credential expires, whichever comes first. Harper keeps the GET credential's expiry when it re-reads the user's permissions after a role change. Reconnecting can restore an unexpired subscription under the current GET permissions, but cannot extend the original POST credential's expiry. A reconnect after a network interruption does not require re-subscribing while the original subscribing POST credential is still valid. Component bearer validators that omit `authExpiresAt` default to five minutes, capped by a JWT's `exp`. Return the verified token expiry as described in [Bearer validators](../http/api.md#serverregisterbearervalidatorvalidator-options). -A subscription created with a scoped token restores only when the GET presents a scoped token with the same username and role permissions. A refreshed expiry is allowed, but does not extend the original subscription expiry. An ordinary credential with the same username cannot inherit that scoped subscription; reconnecting with it removes the stored scoped entry while retaining valid ordinary subscriptions. An explicit new `resources/subscribe` POST can use its own scoped credential. +A subscription created with a scoped token restores only when the GET presents a scoped token with the same username and role permissions. A newly issued scoped token may have a later expiry, but restoring does not extend the original subscription expiry. An ordinary credential with the same username cannot inherit that scoped subscription; reconnecting with it removes the stored scoped entry while retaining valid ordinary subscriptions. An explicit new `resources/subscribe` POST can use its own scoped credential. To renew subscriptions after rotating credentials: From 1c1b8f91670c99916c6d5fac1ffcbd50528359ab Mon Sep 17 00:00:00 2001 From: Kyle Bernhardy Date: Wed, 4 Nov 2026 12:01:56 -0700 Subject: [PATCH 10/15] Explain when a scoped attribution name can match an ordinary user --- 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 39d54eb7d..ddbcbae4d 100644 --- a/reference/mcp/subscriptions.md +++ b/reference/mcp/subscriptions.md @@ -139,7 +139,7 @@ A live subscription runs on the worker holding the session's GET-SSE stream. The Resource updates stop when either the subscribing POST's bearer credential or the receiving GET's bearer credential expires, whichever comes first. Harper keeps the GET credential's expiry when it re-reads the user's permissions after a role change. Reconnecting can restore an unexpired subscription under the current GET permissions, but cannot extend the original POST credential's expiry. A reconnect after a network interruption does not require re-subscribing while the original subscribing POST credential is still valid. Component bearer validators that omit `authExpiresAt` default to five minutes, capped by a JWT's `exp`. Return the verified token expiry as described in [Bearer validators](../http/api.md#serverregisterbearervalidatorvalidator-options). -A subscription created with a scoped token restores only when the GET presents a scoped token with the same username and role permissions. A newly issued scoped token may have a later expiry, but restoring does not extend the original subscription expiry. An ordinary credential with the same username cannot inherit that scoped subscription; reconnecting with it removes the stored scoped entry while retaining valid ordinary subscriptions. An explicit new `resources/subscribe` POST can use its own scoped credential. +A subscription created with a scoped token restores only when the GET presents a scoped token with the same username and role permissions. A newly issued scoped token may have a later expiry, but restoring does not extend the original subscription expiry. An ordinary credential with the same username, such as a user created after the scoped token was issued, cannot inherit that scoped subscription; reconnecting with it removes the stored scoped entry while retaining valid ordinary subscriptions. An explicit new `resources/subscribe` POST can use its own scoped credential. To renew subscriptions after rotating credentials: From eca65f3e8499fe73d41f47ffd144a3972c9ab564 Mon Sep 17 00:00:00 2001 From: Kyle Bernhardy Date: Wed, 4 Nov 2026 12:23:37 -0700 Subject: [PATCH 11/15] Cite the scoped reconnect isolation follow-up --- release-notes/v5-lincoln/5.4.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/release-notes/v5-lincoln/5.4.md b/release-notes/v5-lincoln/5.4.md index 4010ea974..327c2bab1 100644 --- a/release-notes/v5-lincoln/5.4.md +++ b/release-notes/v5-lincoln/5.4.md @@ -208,7 +208,7 @@ When two enabled OIDC trust policies match one identity token and give it a diff ### Resource Subscriptions Preserve Credential Expiry Across Workers -MCP resource subscriptions now work when the subscribe POST and GET-SSE stream reach different HTTP workers. The original subscribing POST expiry is preserved across workers and reconnects. Delivery is also bounded by the current GET credential's expiry. Re-reading a user's permissions after a role change also retains the GET credential's expiry. A network reconnect with matching credentials restores unexpired subscriptions without new subscribe calls. Stored scoped subscriptions require a scoped GET with the same username and role permissions; an ordinary credential with that username cannot inherit them. To renew after rotating credentials, close the old GET, open a replacement GET with the new credential, and send each `resources/subscribe` POST with that credential too. Row-backed subscriptions can end at expiry without closing the GET stream. See [Subscription lifetime and credential rotation](/reference/v5/mcp/subscriptions#subscription-lifetime-and-credential-rotation) ([Make MCP resource subscriptions reliable across HTTP workers](https://github.com/HarperFast/harper/pull/2436)). +MCP resource subscriptions now work when the subscribe POST and GET-SSE stream reach different HTTP workers. The original subscribing POST expiry is preserved across workers and reconnects. Delivery is also bounded by the current GET credential's expiry. Re-reading a user's permissions after a role change also retains the GET credential's expiry. A network reconnect with matching credentials restores unexpired subscriptions without new subscribe calls. Stored scoped subscriptions require a scoped GET with the same username and role permissions; an ordinary credential with that username cannot inherit them. To renew after rotating credentials, close the old GET, open a replacement GET with the new credential, and send each `resources/subscribe` POST with that credential too. Row-backed subscriptions can end at expiry without closing the GET stream. See [Subscription lifetime and credential rotation](/reference/v5/mcp/subscriptions#subscription-lifetime-and-credential-rotation) ([Make MCP resource subscriptions reliable across HTTP workers](https://github.com/HarperFast/harper/pull/2436); [Fix scoped MCP reconnects and concurrent subscribe failures](https://github.com/HarperFast/harper/pull/4029)). ### Session Creation Is Rate-Limited by Default From a313ee4fd2a7f4a1fe239b5fc0a019cd603724c8 Mon Sep 17 00:00:00 2001 From: Kyle Bernhardy Date: Wed, 4 Nov 2026 12:42:56 -0700 Subject: [PATCH 12/15] Clarify MCP retries, expiry discovery, and upgrade guidance --- reference/mcp/overview.md | 2 +- reference/mcp/subscriptions.md | 22 +++++++++++++--------- reference/mcp/tools-and-resources.md | 2 +- release-notes/v5-lincoln/5.4.md | 6 ++++-- 4 files changed, 19 insertions(+), 13 deletions(-) diff --git a/reference/mcp/overview.md b/reference/mcp/overview.md index 3a6392426..1fc82dca4 100644 --- a/reference/mcp/overview.md +++ b/reference/mcp/overview.md @@ -64,6 +64,6 @@ See [MCP Tools and Resources](./tools-and-resources.md) for the full generation The following items are explicitly deferred to a follow-on release: - OAuth 2.1 PRM (Protected Resource Metadata) authorization. -- Sharing notification replay history between workers. The live GET stream belongs to one worker, but [persisted resource subscriptions](./subscriptions.md#scope-and-durability) can restore on another worker and accept subscribe POSTs from other workers. +- Sharing notification replay history between workers. The live GET stream belongs to one worker, but the session accepts subscribe POSTs on other workers and [persisted resource subscriptions](./subscriptions.md#scope-and-durability) can restore on a new worker. - TypeScript type reflection into JSON Schema for custom `mcpTools` entries (schemas are hand-authored). - Global REST/operations rate limiting — the MCP rate limits (per-session, per-tool, per-client, and `initialize`) apply to the MCP surface only. diff --git a/reference/mcp/subscriptions.md b/reference/mcp/subscriptions.md index ddbcbae4d..4bba01cd3 100644 --- a/reference/mcp/subscriptions.md +++ b/reference/mcp/subscriptions.md @@ -74,13 +74,15 @@ Synthetic `harper://*` metadata URIs (`harper://about`, `harper://openapi`, `har 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: ` | The credentials used for this subscription do not permit reading the resource. | -| `-32603 resource subscription timed out; retry the request` | Installation was not acknowledged before the timeout. | -| `-32603 resource subscription failed; retry the request` | Installation failed, or another request for the same URI replaced this request. | +| Answer | When | +| ----------------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| `-32602 permission denied: ` | The credentials used for this subscription do not permit reading the resource. | +| `-32603 resource subscription timed out; retry the request` | The GET worker did not acknowledge installation within the 30-second acknowledgment wait. | +| `-32603 resource subscription failed; retry the request` | Installation failed, or another request for the same URI replaced this request. | -A missing or expired session returns HTTP `404`. If requests for the same URI overlap, retry a failed request with the current credential. Retry transient `-32603` failures with backoff. If failures repeat, have the server operator inspect the logged error and fix its cause before retrying. An invalid custom stream or a consistently failing custom gate will not be repaired by retries. +A missing or expired session returns HTTP `404`. If requests for the same URI overlap, let them settle and retry with the current credential only if that subscription is still wanted. For other `-32603` failures, retry only transient causes, with backoff. If failures repeat, have the server operator inspect the logged error and fix its cause before retrying. An invalid custom stream or a consistently failing custom gate will not be repaired by retries. + +A failed or timed-out subscribe attempts to remove the state written by that request without removing a later request for the same URI. If storage prevents cleanup, the outcome is ambiguous; send `resources/unsubscribe` to abandon that URI once storage is available. If the GET closes during setup, a waiting POST can complete after reconnect, or return the timeout error if no reconnect acknowledges it. Open a replacement GET before retrying. `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. @@ -127,7 +129,7 @@ Send `resources/unsubscribe` with the same URI: - `params.uri` is required and must be a string — otherwise `-32602` (`resources/unsubscribe requires params.uri`). - Unsubscribing a URI that was never subscribed is a no-op and still returns `{}`. -Subscriptions are also torn down automatically when the session's GET-SSE stream closes — via explicit `DELETE /mcp`, idle-timeout eviction from `system.mcp_session`, a dropped TCP connection, or an idle-prune sweep. Credential expiry stops delivery too. A row-backed (table) subscription can end at expiry while its GET stream remains open. +Subscriptions are also torn down automatically when the session's GET-SSE stream closes — via explicit `DELETE /mcp`, idle-timeout eviction from `system.mcp_session`, a dropped TCP connection, or an idle-prune sweep. Credential expiry stops delivery too; see [Subscription lifetime and credential rotation](#subscription-lifetime-and-credential-rotation). ## Scope and durability @@ -139,6 +141,8 @@ A live subscription runs on the worker holding the session's GET-SSE stream. The Resource updates stop when either the subscribing POST's bearer credential or the receiving GET's bearer credential expires, whichever comes first. Harper keeps the GET credential's expiry when it re-reads the user's permissions after a role change. Reconnecting can restore an unexpired subscription under the current GET permissions, but cannot extend the original POST credential's expiry. A reconnect after a network interruption does not require re-subscribing while the original subscribing POST credential is still valid. Component bearer validators that omit `authExpiresAt` default to five minutes, capped by a JWT's `exp`. Return the verified token expiry as described in [Bearer validators](../http/api.md#serverregisterbearervalidatorvalidator-options). +MCP does not return the effective subscription expiry. Obtain credential lifetime from the issuer, such as `expires_in` or a JWT's `exp`, and renew before the earlier POST/GET expiry. For component bearer validators, the service must also communicate any shorter `authExpiresAt` lifetime, including the five-minute default; clients cannot discover that server-side cap through MCP. + A subscription created with a scoped token restores only when the GET presents a scoped token with the same username and role permissions. A newly issued scoped token may have a later expiry, but restoring does not extend the original subscription expiry. An ordinary credential with the same username, such as a user created after the scoped token was issued, cannot inherit that scoped subscription; reconnecting with it removes the stored scoped entry while retaining valid ordinary subscriptions. An explicit new `resources/subscribe` POST can use its own scoped credential. To renew subscriptions after rotating credentials: @@ -147,7 +151,7 @@ To renew subscriptions after rotating credentials: 2. Send `resources/subscribe` for each URI you want to keep watching, using the replacement credential on each POST too. 3. Re-read each resource to catch changes made while its subscription was inactive; re-subscribing does not replay those changes. -A row-backed (table) subscription can end at expiry while the GET stream stays open. An open connection alone does not mean that updates are still being delivered. Re-subscribing with the replacement credential establishes a new subscription lifetime. +An open GET alone does not mean that updates are still being delivered. A table or custom iterator can end at expiry while the GET stays open. Harper can also close the GET when it detects an expired credential during delivery. Renew before expiry rather than waiting for the connection to close. Re-subscribing with the replacement credential establishes a new subscription lifetime. ## Resuming a dropped stream @@ -159,7 +163,7 @@ Mcp-Session-Id: 0c8f... Last-Event-ID: 137 ``` -Harper replays buffered frames with a higher id (exclusive) before live frames resume, subject to the per-worker limits below. On reconnect, Harper restores persisted subscriptions under the current GET permissions, bounded by the original subscribing POST expiry and the current GET expiry. The GET response waits for restoration before returning HTTP `200` (Changed in: v5.4.0). Expired, denied, or no-longer-subscribable entries are removed. A transient restoration failure retains the URI and returns HTTP `503` with `Retry-After: 1`. Retry the GET. An absent session returns HTTP `404`. +Harper replays buffered frames with a higher id (exclusive) before live frames resume, subject to the per-worker limits below. On reconnect, Harper restores persisted subscriptions under the current GET permissions, bounded by the original subscribing POST expiry and the current GET expiry. The GET response waits for restoration before returning HTTP `200`. Expired, denied, or no-longer-subscribable entries are removed. A transient restoration failure retains the URI and returns HTTP `503` with `Retry-After: 1` (Changed in: v5.4.0). Resend `GET /mcp` with the same `Mcp-Session-Id`, `Mcp-Protocol-Version` and, if used, `Last-Event-ID` headers, using valid authorization. A `503` alone does not require repeating `initialize` or the subscribe POSTs. An absent session returns HTTP `404`. Caveats, all best-effort by design: diff --git a/reference/mcp/tools-and-resources.md b/reference/mcp/tools-and-resources.md index b0ef3e694..0964c08e5 100644 --- a/reference/mcp/tools-and-resources.md +++ b/reference/mcp/tools-and-resources.md @@ -244,7 +244,7 @@ The contract: - **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 stops updates when either the subscribing POST's or the receiving GET's bearer credential expires, whichever comes first, or when the user is dropped or no longer has a role. See [Subscription lifetime and credential rotation](./subscriptions.md#subscription-lifetime-and-credential-rotation). 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: `. Other installation failures, including an error your own gate throws, answer `-32603`. See [subscription error responses](./subscriptions.md#which-uris-are-subscribable). 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: `. Other installation failures, including an error your own gate throws, now answer `-32603 resource subscription failed; retry the request` (previously `-32603 failed to subscribe: `). See [subscription error responses](./subscriptions.md#which-uris-are-subscribable). 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 327c2bab1..19a11b3ce 100644 --- a/release-notes/v5-lincoln/5.4.md +++ b/release-notes/v5-lincoln/5.4.md @@ -10,6 +10,8 @@ All patch release notes for 5.4.x are available on the [releases page](https://g ## Upgrade Notes +MCP clients using expiring credentials must renew their subscriptions before expiry. Harper preserves the original subscribing POST expiry across workers and reconnects; an open GET does not guarantee that updates are still being delivered. Open a replacement GET and re-subscribe each URI with the new credential. See [Subscription lifetime and credential rotation](/reference/v5/mcp/subscriptions#subscription-lifetime-and-credential-rotation). + Full-text indexes using fulltext 0.5.0 with current field-based declarations, or prerelease 0.6, rebuild locally after this upgrade. Plan for search unavailability and review the [Tantivy upgrade requirements](#tantivy-027-and-existing-index-rebuilds) before deploying; direct downgrade to fulltext 0.5.x is unsupported. An OIDC exchange now refuses a token that two enabled trust policies give a different user or different operations, where before the first policy by `id` answered (see [Conflicting Trust Policies Are Refused](#conflicting-trust-policies-are-refused)). Before upgrading, run `list_oidc_trust` and look for two enabled policies, with the same issuer and audience, whose claims can match the same runs but which name different users or `operations`, such as the policies of two `harper deploy setup=true provider=github-actions` runs that share a workflow, branch and environment. Drop, disable or narrow one of each pair, or the runs both match stop authenticating. A 5.4 node's `list_oidc_trust` names each pair in `invalid_reason`. Upgrade every node: a node still running 5.3 keeps answering with the first policy by `id`. @@ -73,7 +75,7 @@ A durable MQTT session now resumes through the same checked replay. When a recon ### 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 its user is dropped. Updates also stop at the earlier POST/GET bearer expiry. A subscribe that fails now answers a JSON-RPC error instead of an HTTP 500, for row-backed resources too, and a reconnecting GET response waits for persisted subscriptions to be restored before returning HTTP `200`. A transient restore failure returns HTTP `503` with `Retry-After: 1`. See [Subscribing to custom resources](/reference/v5/mcp/tools-and-resources#subscribing-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, and on `DELETE /mcp`. For subscriptions tied to a stored user, delivery also stops if that user is dropped or no longer has a role. Updates also stop at the earlier POST/GET bearer expiry. A subscribe that fails now answers a JSON-RPC error instead of an HTTP 500, for row-backed resources too. See [Subscribing to custom resources](/reference/v5/mcp/tools-and-resources#subscribing-to-custom-resources). ## Built-in Agent @@ -208,7 +210,7 @@ When two enabled OIDC trust policies match one identity token and give it a diff ### Resource Subscriptions Preserve Credential Expiry Across Workers -MCP resource subscriptions now work when the subscribe POST and GET-SSE stream reach different HTTP workers. The original subscribing POST expiry is preserved across workers and reconnects. Delivery is also bounded by the current GET credential's expiry. Re-reading a user's permissions after a role change also retains the GET credential's expiry. A network reconnect with matching credentials restores unexpired subscriptions without new subscribe calls. Stored scoped subscriptions require a scoped GET with the same username and role permissions; an ordinary credential with that username cannot inherit them. To renew after rotating credentials, close the old GET, open a replacement GET with the new credential, and send each `resources/subscribe` POST with that credential too. Row-backed subscriptions can end at expiry without closing the GET stream. See [Subscription lifetime and credential rotation](/reference/v5/mcp/subscriptions#subscription-lifetime-and-credential-rotation) ([Make MCP resource subscriptions reliable across HTTP workers](https://github.com/HarperFast/harper/pull/2436); [Fix scoped MCP reconnects and concurrent subscribe failures](https://github.com/HarperFast/harper/pull/4029)). +MCP resource subscriptions now work when the subscribe POST and GET-SSE stream reach different HTTP workers. Reconnecting GETs continue to wait for restoration before HTTP `200`; a transient restoration failure now returns HTTP `503` with `Retry-After: 1`. The original subscribing POST expiry is preserved across workers and reconnects. Delivery is also bounded by the current GET credential's expiry. Re-reading a user's permissions after a role change also retains the GET credential's expiry. A network reconnect with matching credentials restores unexpired subscriptions without new subscribe calls. Stored scoped subscriptions require a scoped GET with the same username and role permissions; an ordinary credential with that username cannot inherit them. To renew after rotating credentials, close the old GET, open a replacement GET with the new credential, and send each `resources/subscribe` POST with that credential too. Row-backed subscriptions can end at expiry without closing the GET stream. See [Subscription lifetime and credential rotation](/reference/v5/mcp/subscriptions#subscription-lifetime-and-credential-rotation) ([Make MCP resource subscriptions reliable across HTTP workers](https://github.com/HarperFast/harper/pull/2436); [Fix scoped MCP reconnects and concurrent subscribe failures](https://github.com/HarperFast/harper/pull/4029)). ### Session Creation Is Rate-Limited by Default From e6d7e6d4504bbb2596d15d9fda00e6b4b6abae23 Mon Sep 17 00:00:00 2001 From: Kyle Bernhardy Date: Wed, 4 Nov 2026 12:56:14 -0700 Subject: [PATCH 13/15] Make failed-subscribe recovery actionable and align expiry guidance --- reference/mcp/subscriptions.md | 2 +- reference/mcp/tools-and-resources.md | 6 +++--- release-notes/v5-lincoln/5.4.md | 4 +++- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/reference/mcp/subscriptions.md b/reference/mcp/subscriptions.md index 4bba01cd3..fdb5b68d4 100644 --- a/reference/mcp/subscriptions.md +++ b/reference/mcp/subscriptions.md @@ -82,7 +82,7 @@ A refused subscribe, row-backed or custom, answers a JSON-RPC error rather than A missing or expired session returns HTTP `404`. If requests for the same URI overlap, let them settle and retry with the current credential only if that subscription is still wanted. For other `-32603` failures, retry only transient causes, with backoff. If failures repeat, have the server operator inspect the logged error and fix its cause before retrying. An invalid custom stream or a consistently failing custom gate will not be repaired by retries. -A failed or timed-out subscribe attempts to remove the state written by that request without removing a later request for the same URI. If storage prevents cleanup, the outcome is ambiguous; send `resources/unsubscribe` to abandon that URI once storage is available. If the GET closes during setup, a waiting POST can complete after reconnect, or return the timeout error if no reconnect acknowledges it. Open a replacement GET before retrying. +A failed or timed-out subscribe attempts to remove the state written by that request without removing a later request for the same URI. The error response does not report whether cleanup succeeded. If you no longer want that URI subscribed after any failed or timed-out request, send `resources/unsubscribe` to confirm removal, following the transient-error retry guidance above. If the GET closes during setup, a waiting POST can complete after reconnect, or return the timeout error if no reconnect acknowledges it. Open a replacement GET before retrying. `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. diff --git a/reference/mcp/tools-and-resources.md b/reference/mcp/tools-and-resources.md index 0964c08e5..70647b73e 100644 --- a/reference/mcp/tools-and-resources.md +++ b/reference/mcp/tools-and-resources.md @@ -244,7 +244,7 @@ The contract: - **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 stops updates when either the subscribing POST's or the receiving GET's bearer credential expires, whichever comes first, or when the user is dropped or no longer has a role. See [Subscription lifetime and credential rotation](./subscriptions.md#subscription-lifetime-and-credential-rotation). 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: `. Other installation failures, including an error your own gate throws, now answer `-32603 resource subscription failed; retry the request` (previously `-32603 failed to subscribe: `). See [subscription error responses](./subscriptions.md#which-uris-are-subscribable). 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: `. Other installation failures, including an error your own gate throws, now answer `-32603 resource subscription failed; retry the request`. See [subscription error responses](./subscriptions.md#which-uris-are-subscribable). The raw error goes to the server log. #### Template parameters are client-controlled @@ -338,6 +338,8 @@ This is independent of the `http` exportType — the only switch that operators 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). +For the GET-stream prerequisite, credential expiry, and renewal steps, see [Subscription lifetime and credential rotation](./subscriptions.md#subscription-lifetime-and-credential-rotation). + ### `harper://` URIs | URI | Profile | Content | @@ -371,8 +373,6 @@ Per-record reads go through the tools surface, where each Resource's `allow{Read Author-declared `mcpResources` entries (see [Custom `mcpResources` opt-in](#custom-mcpresources-opt-in)) appear alongside the built-in surfaces: fixed URIs in `resources/list`, templates in `resources/templates/list`. A registered custom URI always wins over the discovered surfaces on `resources/read`. -For the GET-stream prerequisite, credential expiry, and renewal steps, see [Subscription lifetime and credential rotation](./subscriptions.md#subscription-lifetime-and-credential-rotation). - ## `notifications/*/list_changed` After the `initialize` handshake, an MCP client opens `GET /mcp` to keep an SSE channel open for server-push frames. Harper subscribes to its existing role-cache and schema-reload event channels and, whenever one fires: diff --git a/release-notes/v5-lincoln/5.4.md b/release-notes/v5-lincoln/5.4.md index 19a11b3ce..1c21caedb 100644 --- a/release-notes/v5-lincoln/5.4.md +++ b/release-notes/v5-lincoln/5.4.md @@ -210,7 +210,9 @@ When two enabled OIDC trust policies match one identity token and give it a diff ### Resource Subscriptions Preserve Credential Expiry Across Workers -MCP resource subscriptions now work when the subscribe POST and GET-SSE stream reach different HTTP workers. Reconnecting GETs continue to wait for restoration before HTTP `200`; a transient restoration failure now returns HTTP `503` with `Retry-After: 1`. The original subscribing POST expiry is preserved across workers and reconnects. Delivery is also bounded by the current GET credential's expiry. Re-reading a user's permissions after a role change also retains the GET credential's expiry. A network reconnect with matching credentials restores unexpired subscriptions without new subscribe calls. Stored scoped subscriptions require a scoped GET with the same username and role permissions; an ordinary credential with that username cannot inherit them. To renew after rotating credentials, close the old GET, open a replacement GET with the new credential, and send each `resources/subscribe` POST with that credential too. Row-backed subscriptions can end at expiry without closing the GET stream. See [Subscription lifetime and credential rotation](/reference/v5/mcp/subscriptions#subscription-lifetime-and-credential-rotation) ([Make MCP resource subscriptions reliable across HTTP workers](https://github.com/HarperFast/harper/pull/2436); [Fix scoped MCP reconnects and concurrent subscribe failures](https://github.com/HarperFast/harper/pull/4029)). +MCP resource subscriptions now work when the subscribe POST and GET-SSE stream reach different HTTP workers. Reconnecting GETs continue to wait for restoration before HTTP `200`; a transient restoration failure now returns HTTP `503` with `Retry-After: 1`. The original subscribing POST expiry is preserved across workers and reconnects. Delivery is also bounded by the current GET credential's expiry. Re-reading a user's permissions after a role change also retains the GET credential's expiry. A network reconnect with matching credentials restores unexpired subscriptions without new subscribe calls. Stored scoped subscriptions require a scoped GET with the same username and role permissions; an ordinary credential with that username cannot inherit them. To renew after rotating credentials, close the old GET, open a replacement GET with the new credential, and send each `resources/subscribe` POST with that credential too. Subscriptions can end at expiry without closing the GET stream. See [Subscription lifetime and credential rotation](/reference/v5/mcp/subscriptions#subscription-lifetime-and-credential-rotation) ([Make MCP resource subscriptions reliable across HTTP workers](https://github.com/HarperFast/harper/pull/2436); [Fix scoped MCP reconnects and concurrent subscribe failures](https://github.com/HarperFast/harper/pull/4029)). + +Clients matching the formerly documented `-32603 failed to subscribe: ` message should handle the current `-32603 resource subscription failed; retry the request` response. ### Session Creation Is Rate-Limited by Default From 3ef1c106764623a8fd193a97960201914e2699f0 Mon Sep 17 00:00:00 2001 From: Kyle Bernhardy Date: Wed, 4 Nov 2026 13:09:59 -0700 Subject: [PATCH 14/15] Include cancellation response in MCP migration guidance --- release-notes/v5-lincoln/5.4.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/release-notes/v5-lincoln/5.4.md b/release-notes/v5-lincoln/5.4.md index 1c21caedb..bd1dff5a5 100644 --- a/release-notes/v5-lincoln/5.4.md +++ b/release-notes/v5-lincoln/5.4.md @@ -212,7 +212,7 @@ When two enabled OIDC trust policies match one identity token and give it a diff MCP resource subscriptions now work when the subscribe POST and GET-SSE stream reach different HTTP workers. Reconnecting GETs continue to wait for restoration before HTTP `200`; a transient restoration failure now returns HTTP `503` with `Retry-After: 1`. The original subscribing POST expiry is preserved across workers and reconnects. Delivery is also bounded by the current GET credential's expiry. Re-reading a user's permissions after a role change also retains the GET credential's expiry. A network reconnect with matching credentials restores unexpired subscriptions without new subscribe calls. Stored scoped subscriptions require a scoped GET with the same username and role permissions; an ordinary credential with that username cannot inherit them. To renew after rotating credentials, close the old GET, open a replacement GET with the new credential, and send each `resources/subscribe` POST with that credential too. Subscriptions can end at expiry without closing the GET stream. See [Subscription lifetime and credential rotation](/reference/v5/mcp/subscriptions#subscription-lifetime-and-credential-rotation) ([Make MCP resource subscriptions reliable across HTTP workers](https://github.com/HarperFast/harper/pull/2436); [Fix scoped MCP reconnects and concurrent subscribe failures](https://github.com/HarperFast/harper/pull/4029)). -Clients matching the formerly documented `-32603 failed to subscribe: ` message should handle the current `-32603 resource subscription failed; retry the request` response. +Clients matching the formerly documented `-32603 failed to subscribe: ` or `-32602 resources/subscribe for was cancelled: ` responses should handle the current `-32603 resource subscription failed; retry the request` response. Let overlapping requests settle before retrying a transient failure; do not retry solely because the message says to. ### Session Creation Is Rate-Limited by Default From a73e21bc63f3852a829302a4efe51a9725e2d84e Mon Sep 17 00:00:00 2001 From: Kyle Bernhardy Date: Wed, 4 Nov 2026 13:13:41 -0700 Subject: [PATCH 15/15] Clarify timeout outcome in cancellation migration note --- release-notes/v5-lincoln/5.4.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/release-notes/v5-lincoln/5.4.md b/release-notes/v5-lincoln/5.4.md index bd1dff5a5..a6773cd11 100644 --- a/release-notes/v5-lincoln/5.4.md +++ b/release-notes/v5-lincoln/5.4.md @@ -212,7 +212,7 @@ When two enabled OIDC trust policies match one identity token and give it a diff MCP resource subscriptions now work when the subscribe POST and GET-SSE stream reach different HTTP workers. Reconnecting GETs continue to wait for restoration before HTTP `200`; a transient restoration failure now returns HTTP `503` with `Retry-After: 1`. The original subscribing POST expiry is preserved across workers and reconnects. Delivery is also bounded by the current GET credential's expiry. Re-reading a user's permissions after a role change also retains the GET credential's expiry. A network reconnect with matching credentials restores unexpired subscriptions without new subscribe calls. Stored scoped subscriptions require a scoped GET with the same username and role permissions; an ordinary credential with that username cannot inherit them. To renew after rotating credentials, close the old GET, open a replacement GET with the new credential, and send each `resources/subscribe` POST with that credential too. Subscriptions can end at expiry without closing the GET stream. See [Subscription lifetime and credential rotation](/reference/v5/mcp/subscriptions#subscription-lifetime-and-credential-rotation) ([Make MCP resource subscriptions reliable across HTTP workers](https://github.com/HarperFast/harper/pull/2436); [Fix scoped MCP reconnects and concurrent subscribe failures](https://github.com/HarperFast/harper/pull/4029)). -Clients matching the formerly documented `-32603 failed to subscribe: ` or `-32602 resources/subscribe for was cancelled: ` responses should handle the current `-32603 resource subscription failed; retry the request` response. Let overlapping requests settle before retrying a transient failure; do not retry solely because the message says to. +Clients matching the formerly documented `-32603 failed to subscribe: ` or `-32602 resources/subscribe for was cancelled: ` responses should handle the current `-32603 resource subscription failed; retry the request` response and, when the GET closes during setup without a successful reconnect, `-32603 resource subscription timed out; retry the request`. Let overlapping requests settle before retrying a transient failure; do not retry solely because the message says to. ### Session Creation Is Rate-Limited by Default