From cde2157bec34755e9f58944092308603fc56f454 Mon Sep 17 00:00:00 2001 From: Larry Maccherone Date: Wed, 4 Nov 2026 04:25:13 -0500 Subject: [PATCH 01/15] Propose Resource Webhooks on Events Split out of lumenize/resource-webhooks-on-events' master plan: its proposal half becomes the Specification, its Decisions the Rationale, without the rows about the prototype alone, and its open questions Q6 and Q7. New are the Abstract, Backward Compatibility, Security Implications, the Reference Implementation and the build's two findings. SEP-3415 is pinned at b08e4f9 and the strawman at 8d0fa88. The first flavor's text stays in 2b5276a. Co-Authored-By: Claude Opus 5.5 --- proposals/XXXX-resource-webhooks.md | 447 ++++++++++++++++++++++++++++ 1 file changed, 447 insertions(+) create mode 100644 proposals/XXXX-resource-webhooks.md diff --git a/proposals/XXXX-resource-webhooks.md b/proposals/XXXX-resource-webhooks.md new file mode 100644 index 0000000..5ebc93e --- /dev/null +++ b/proposals/XXXX-resource-webhooks.md @@ -0,0 +1,447 @@ +# SEP-XXXX: Resource Webhooks on Events + +- **Status**: Draft +- **Type**: Extensions Track +- **Extension Identifier**: `io.modelcontextprotocol/resource-webhooks` +- **Created**: 2026-10-31 +- **Author(s)**: Larry Maccherone (@lmaccherone) +- **Sponsor**: None (seeking sponsor) +- **PR**: TBD + +## Abstract + +This extension lets an MCP client keep copies of resources current by webhook. A server records each change to its resources at a moment on a timeline, a string that sorts after every moment before it, such as `"108"`. For each change, it sends `mcp.resource.collection.updated` through the webhook delivery of SEP-3415, Events, to each subscription covering a resource the change touched. The event names the collection the subscription watches and each resource in it that changed, with its new revision, and carries none of their contents. + +A client that already holds each named revision, such as the client that made the change, skips the event. Any other catches up with `resources/collection/catchup`, which brings every change to the collection after the moment the client is caught up to, each as contents or a patch. A new client first reads the collection with `resources/collection/read`. Writes use the Filesystems working group's strawman methods, and a resource's revision is the moment it last changed. + +An event carries no state, so a lost, repeated or late one does no harm: the next catch-up brings whatever it missed. A working prototype builds all of it, as § *Reference Implementation* describes. + +This proposal comes to the Tasks and Events working group to be discussed, improved, and hopefully sponsored for approval. + +## References + +This proposal builds on two drafts, a working group's problem statements and one revision of MCP: + +- **SEP-3415, Events,** [modelcontextprotocol#3415](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3415), a draft, by reference at commit `b08e4f9`. This extension takes its `events/subscribe` and its webhook delivery, and sends an event of its own rather than the `mcp.resource.updated` its Open Question 2 sketches. +- **The Filesystems strawman,** [experimental-ext-filesystems#2](https://github.com/modelcontextprotocol/experimental-ext-filesystems/pull/2), the Filesystems working group's draft of resource operations, at commit `8d0fa88`, which the rest of this proposal calls "the strawman". It adds `resources/stat`, `resources/create`, `resources/update` and `resources/delete`. Each resource has a `revision`, a write names the one it expects as `ifMatch`, and a write's result holds a change-set entry for each resource it changed, such as `{uri, effect: "updated", previousRevision, revision}`. This extension takes its `revision` and its change-set entry, and the prototype builds its four methods. +- **The Filesystems problem statements,** [experimental-ext-filesystems](https://github.com/modelcontextprotocol/experimental-ext-filesystems/blob/main/docs/problem-statements.md), that working group's framing of its work. Problem Statement 6 asks a proposal to "Decide whether a client can subscribe to a directory", and asks "Does a client need ordering between its write response and the resulting notification?" A collection answers the first (R24), and revisions the second, since a client orders messages by revision rather than arrival (R15). This extension offers both for that group to weigh. +- **MCP 2026-07-28,** [the specification](https://modelcontextprotocol.io/specification/2026-07-28), the revision this extension builds on, for `resources/read`, pagination and `CacheableResult`. + +## Motivation + +**MCP clients want an *efficient* way to keep local copies of resources up to date in near *real time*.** A webhook delivers each change without the client holding a connection open. + +Keeping a copy current needs only the newest value, though, not every event on the way to it, so it calls for a different contract than a stream of events, as § *SEP-3415's open question 2* argues: here, an event only says that something changed, and a catch-up brings the newest value. It also differs from the state sync needs of tasks in other ways, which § *Why is this different from task state sync?* covers. + +A client watching resources today has four problems: + +1. **It must hold a stream open or poll.** Each has its own shortcomings: + - **Streaming.** `notifications/resources/updated` arrives only on a `subscriptions/listen` stream, since [SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575). A long-lived stream fits load-balanced and edge infrastructure poorly: proxies close idle connections, edge and serverless platforms cap how long a request may run, and a deploy or scale-in drops every stream an instance holds. + - **Polling** trades ***near real time*** against ***efficiency***. A long polling period means the client's state lags far behind. A short polling period spends a request per resource per interval, mostly to learn that nothing changed. +2. **It cannot tell whether it already holds the latest value.** The notification names the URI and nothing else, so each one costs a read, even when the client already holds the change. A ticket edited three times in a second costs three reads, though a read made after the third edit already brought the newest value, and the client that made an edit reads it back. +3. **Catching up after a disconnect is inefficient.** Since 2026-07-28 a broken stream cannot resume, so a change made while it is down is lost without a trace. A client back from a disconnect cannot tell which resources changed while it was away, so it must read every resource it watches again. +4. **It can watch only the resources it already knows.** `subscriptions/listen` takes resource URIs, in `resourceSubscriptions`, and no templates. So a client watching a collection subscribes to each member it knows, and hears of a new one, if at all, only through `notifications/resources/list_changed`, which covers the server's whole list and names no resource. + +### SEP-3415's open question 2 + +SEP-3415's Open Question 2 sketches a minimal implementation of resource subscription over Events, using "a reserved `mcp.resource.updated` event type whose arguments are `{uri}`", to re-express core's `notifications/resources/updated`, which names one changed resource. This extension leaves that event to Open Question 2, and sends one of its own, `mcp.resource.collection.updated`, which names a collection and every resource in it that one change touched (D19). + +**However, a resource is state, and a watcher cares only about the latest version, not every change.** A receiver that was down while a ticket was edited, reopened and closed needs only the closed ticket. On SEP-3415's thread on 2026-10-16, Peter Alexander (@pja-ant) [wrote](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3415#issuecomment-6215654779) that watching state and consuming messages "will need different handling and different APIs". The two "should likely share a bunch of the same machinery", he went on, but whether they should share an API "requires some thought and discussion". Here the event, delivered by webhook, is the shared machinery, and carries no state, only which resources changed and at which moment. The different API is the catch-up, which brings the newest values. SEP-3415 itself points this way: servers "SHOULD keep event payloads minimal", agents that need stronger guarantees should "use tools to read authoritative state", and servers "MAY implement resource watching through events". + +### Why is this different from task state sync? + +**Task state is closer to a resource than to a stream of messages, but it is still different.** Peter put the two on the same side: "this is very much the same thing (observing task state vs stream of messages)". A task's state, though, is a small record the Tasks extension defines, so its newest value can go whole in the event itself, as SEP-3415's Open Question 4 sketches with an `mcp.task.updated` event carrying `{status, progress?, result?}`. Resources come in collections, such as a template's members, which a client watches as one and catches up on with one call. So this proposal starts with resources, and tasks could reuse what it builds. Timing played a part too: this work began before the Tasks extension joined this working group on 2026-10-16. + +### Why now? + +**MCP is getting webhooks.** SEP-3415's webhook delivery is the catalyst for this work. It gives an MCP server a way to reach a client that holds no connection open, and this extension takes it as it stands. Clients already accept these webhooks while SEP-3415 is still a draft. [ChatGPT](https://developers.openai.com/plugins/build/mcp-events) receives Events by webhook on protocol `2026-07-28`, and so do the MCP packages of Vercel's AI SDK, Mastra and TanStack AI, in versions released in October 2026, and OpenClaw bundles an opt-in `mcp-events` plugin. + +**MCP now serves skills as resources.** [Skills over MCP](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/seps/2640-skills-extension.md), SEP-2640, is Final, so every client that adopts it reads resources, and may use a skill only while its files match the manifest it approved. Its group chose resources in part for "a subscription model for updates", as its [decision log](https://github.com/modelcontextprotocol/ext-skills/blob/main/docs/decisions.md) records, which since 2026-07-28 reaches a client only over an open `subscriptions/listen` stream. + +### What the working group's charter asks + +The Tasks and Events working group's [charter](https://modelcontextprotocol.io/community/triggers-events/charter) asks for a callback mechanism "with defined ordering guarantees that hold across all transports". SEP-3415 guarantees no order across subscriptions, and says "Servers that need stronger guarantees can implement them". A timeline is such a guarantee. It orders a server's changes, which commit in the order of their moments (R5), not its events, which still arrive in any order (R15), and it holds whichever transport prompts a catch-up. + +### Use cases + +- **A client behind a gateway.** A client on a laptop or in a browser has no URL a server can reach. In SEP-3415's forward-proxy deployment, it names its gateway's URL instead, and the gateway verifies each delivery and passes it on. +- **A collection of documents.** A client subscribes to `https://notes.example/notes/{id}`, hears of each note created, edited or deleted, and catches up after a night away. +- **A client that can't take webhooks.** A command-line client polls `resources/collection/catchup` for `https://notes.example/notes/{id}` as often as each result's `nextPollMs` asks: one request for the whole collection, with no changes when nothing changed. +- **A skill served over MCP.** A client that loaded `skill://code-review/SKILL.md` subscribes to `skill://code-review/{+path}`, hears of each changed file, and can ask for approval again before the model next uses the skill. + +§ *Specification* answers the four problems in turn: a webhook needs no open stream, an event names the revision of each resource that changed, so a client skips it when it already holds them, its own writes included, one catch-up brings only what changed in a whole collection, and a subscription names a template, so a resource created under it brings an event too. § *Rationale* says why, against the alternatives. + +## Specification + +**The key idea is a timeline of moments.** A server records each change to its resources at a moment on a timeline: a string that sorts after every moment before it, such as a counter zero-padded to a fixed width, so that `"042"` sorts before `"107"`, where `"42"` would not. The `tickets` timeline in the examples below begins like this: + +| moment | change | +|---|---| +| `"101"` | ticket-3 opened | +| `"107"` | ticket-7 closed as a duplicate of ticket-9, both changed in one atomic step | +| `"108"` | ticket-3 closed | + +The server keeps the latest moment and mints each new one later (R5). An event names each resource that changed and its new revision, so a client that already holds that revision skips it, and a catch-up brings every change after the moment the client is caught up to. A database's read replica follows its primary's oplog the same way, and wherever it stops, it holds the data as of that point, which may be behind the primary. A catch-up differs in sending each resource once, as its contents or a patch from an earlier revision, rather than every operation, but the idea is the same. + +Rules are numbered R1 and up across the sections below. Each keeps its ID when others are added or removed, so the IDs follow the order the rules were raised, not the order shown, and a gap is from a rule that was dropped, or that binds only how a server is built. + +### Definitions + +| Term | Meaning | Example | +|---|---|---| +| server | An MCP server as its clients see it, at one endpoint, however many processes, replicas or database nodes serve it. | replicas sharing one database | +| client | An MCP client, together with the application it runs in, which reads, subscribes, catches up and keeps copies. MCP's architecture calls that application the host, and this proposal says client throughout. | X and Y below | +| uri-specification | A uri, a uriTemplate, or later a uriGlob (§ *uriGlob, later*). A server lists a uri in `resources/list` and a uriTemplate in `resources/templates/list`. | `tickets/{id}` | +| collection | The resources a uri-specification covers. A uri on its own is a collection of one. | every ticket `tickets/{id}` covers, or `tickets/ticket-3` alone | +| timeline | The server's ordered record of the changes to a set of resources. | `tickets` below | +| moment | The point on a timeline at which a change occurred, a string that sorts after every moment before it. | `"108"` | +| latest moment | A timeline's most recent moment. A server stores it, and mints each new moment later than it (R5). | `"107"`, for `tickets` below | +| revision | A moment at which a resource changed, which the Filesystems strawman's fields `revision` and `previousRevision` hold. A resource's revision, with nothing more said, is its latest, as `resources/stat` and `resources/read` report it (R7). | ticket-3's `"101"`, and `"108"` from step 4 | +| tombstone | What a deleted resource leaves: its uri and the moment it was deleted. | ticket-3, deleted at `"111"` | +| copy | The contents a client last received or wrote for a resource, with the resource's revision then. | ticket-3's JSON at `"101"` | +| catch-up | A `resources/collection/catchup` call and its result: every change to a uri-specification after the moment the client is caught up to, up to the result's `nextFrom`. A client is caught up to the last `nextFrom` it applied in full, and holds every change at or before it. | steps 3 and 6 below | + +### A simple happy path + +Server A lists `tickets/{id}`, whose timeline's latest moment is `"107"`: ticket-3 last changed at `"101"`, and ticket-7 and ticket-9 at `"107"`. Two clients, X and Y, hold no copies yet, and each does steps 1 to 3. Each table shows what the server and each client hold after its step, in bold where the step changed it. The examples leave out JSON-RPC envelopes and core's result fields. + +#### Step 1, at `"107"`: X and Y read the collection + +Each client reads the collection, which brings every ticket that exists. + +```jsonc +{"method": "resources/collection/read", "params": {"uriTemplate": "tickets/{id}"}} +{"contents": [ + {"uri": "tickets/ticket-3", "mimeType": "application/json", + "text": "{\"title\":\"Printer jams\",\"status\":\"open\"}", + "_meta": {"io.modelcontextprotocol/resource-revision": "101"}} + // ...and ticket-7 and ticket-9, each at "107" + ], "nextFrom": "107"} +``` + +Each client now holds every ticket, and is caught up to `"107"`, the read's `nextFrom`. + +| | Server A | Client X | Client Y | +|---|---|---|---| +| latest moment | `"107"` | | | +| caught up to | | **`"107"`** | **`"107"`** | +| ticket-3 | `"101"`, open | **`"101"`, open** | **`"101"`, open** | +| ticket-7 and ticket-9 | `"107"` | **`"107"`** | **`"107"`** | + +- **R22.** `resources/collection/read` names a uri-specification as a catch-up does, gets the same errors for it, and is authorized the same way (R13), but takes no `from`. It returns the contents of each resource in the collection, each item as `resources/read` returns it, with its revision in `_meta` (R7), and `nextFrom`, the timeline's latest moment, with each resource as it stood at `nextFrom` or later. A long result comes in pages, as core's `resources/list` does: the server returns an opaque `nextCursor`, and the client sends it back as `cursor`. Every page repeats the first page's `nextFrom`. A resource that doesn't change while the client pages MUST come exactly once. The client catches up from `nextFrom` afterwards, which brings whatever changed while it paged, deletions included. Its result is a core `CacheableResult`. + - A uri on its own is a collection of one, and a client watching one reads it with `resources/collection/read` too, rather than `resources/read`, since only a collection read brings a `nextFrom` to catch up from. A copy's revision can be older than the history the server keeps, so a catch-up from it could get `momentExpired` (R21), while `nextFrom` is the timeline's latest moment. +- **R8.** A client keeps, for each uri-specification it catches up on, the moment it is caught up to, and each copy's revision. + +#### Step 2, at `"107"`: X and Y subscribe + +Each client subscribes through SEP-3415, naming the uri-specification as the event's `arguments`. + +```jsonc +{"method": "events/subscribe", "params": { + "name": "mcp.resource.collection.updated", + "arguments": {"uriTemplate": "tickets/{id}"}, + "delivery": {"mode": "webhook", "url": "https://gateway.example/hooks/x", + "secret": "whsec_..."}}} +``` + +A now holds a subscription for each client, and nothing else changes. + +- **R18.** A client subscribes with `events/subscribe`, naming `mcp.resource.collection.updated` and a uri-specification as `arguments`: `{uri}`, `{uriTemplate}`, or later `{uriGlob}`, and refreshes the subscription as SEP-3415 says. A client new to the uri-specification first reads its collection (R22). Once subscribed, it catches up, so no change falls between the read and the subscription. + +#### Step 3, at `"107"`: X and Y catch up + +Each client catches up from `"107"`, since a change made after its read and before its subscription took hold sends it no event. Here there is none, so the result is empty and nothing changes. Had a ticket changed in that gap, it would come here, as ticket-3's change does in step 6. + +```jsonc +{"method": "resources/collection/catchup", "params": {"uriTemplate": "tickets/{id}", + "from": "107", "patchMimeTypes": ["application/merge-patch+json"]}} +{"changes": [], "hasMore": false, "nextFrom": "107", "nextPollMs": 30000} +``` + +#### Step 4, at `"108"`: Y closes ticket-3 + +Y closes ticket-3 with the strawman's `resources/update`, sending the whole closed ticket, and its copy's revision as `ifMatch` (R7). + +```jsonc +{"method": "resources/update", "params": {"uri": "tickets/ticket-3", + "ifMatch": "101", "contents": {...}}} +{"changes": [{"uri": "tickets/ticket-3", "effect": "updated", + "previousRevision": "101", "revision": "108"}]} +``` + +Y's copy of ticket-3 is at `"108"`, but Y stays caught up to `"107"`, since others' changes could have landed before `"108"` (R9). + +| | Server A | Client X | Client Y | +|---|---|---|---| +| latest moment | **`"108"`** | | | +| caught up to | | `"107"` | `"107"` | +| ticket-3 | **`"108"`, closed** | `"101"`, open | **`"108"`, closed** | +| ticket-7 and ticket-9 | `"107"` | `"107"` | `"107"` | + +- **R17.** A server MUST store a create's or an update's contents as sent, so the writer's copy is the resource at the reply's `revision`. + +#### Step 5, at `"108"`: A sends the event + +A POSTs the event to each subscription's webhook. It names the subscription's uri-specification and the change, and carries none of the ticket's contents, so no copy changes. + +```jsonc +{"eventId": "108", "name": "mcp.resource.collection.updated", + "timestamp": "2026-11-03T16:00:00.000Z", + "data": {"uriTemplate": "tickets/{id}", "changes": [ + {"uri": "tickets/ticket-3", "effect": "updated", + "previousRevision": "101", "revision": "108"}]}} +``` + +- **R10.** Once a change is visible to a catch-up, the server MUST send one `mcp.resource.collection.updated` to each subscription covering any resource it touched. Its `data` repeats the subscription's `arguments`, naming its uri-specification, and holds `changes`, the strawman's change-set entry for each covered resource the change touched, `revision` always present. A change whose entries would take the body past the 256 KiB SEP-3415 asks for leaves `changes` out. Its `eventId` is the moment of its change, and SEP-3415's `cursor` stays null. A server MAY merge undelivered events for one subscription into the newest, whose `changes` then hold each uri once, as a catch-up's do (R19). + +#### Step 6, at `"108"`: X catches up + +Y already holds ticket-3, the event's one change, at `"108"`, so it skips the catch-up. X holds it at `"101"`, so X catches up from `"107"`. + +```jsonc +{"method": "resources/collection/catchup", "params": {"uriTemplate": "tickets/{id}", + "from": "107", "patchMimeTypes": ["application/merge-patch+json"]}} +{"changes": [ + {"uri": "tickets/ticket-3", "effect": "updated", + "previousRevision": "101", "revision": "108", + "patchMimeType": "application/merge-patch+json", + "patch": {"status": "closed"}}], + "hasMore": false, "nextFrom": "108", "nextPollMs": 30000} +``` + +X's copy is at `"101"`, the patch's `previousRevision`, so X applies it (R19), and is caught up to `"108"`. Y stays caught up to `"107"` until its next catch-up. + +| | Server A | Client X | Client Y | +|---|---|---|---| +| latest moment | `"108"` | | | +| caught up to | | **`"108"`** | `"107"` | +| ticket-3 | `"108"`, closed | **`"108"`, closed** | `"108"`, closed | +| ticket-7 and ticket-9 | `"107"` | `"107"` | `"107"` | + +- **R11.** When a client receives `mcp.resource.collection.updated`, it MUST catch up on the uri-specification the event names, unless it already holds each uri in `changes`, or its tombstone, at that entry's `revision` or later, which lets the writer skip its own change. An event with no `changes` always brings a catch-up. +- **R13.** A catch-up names a uri-specification, as `uri` or `uriTemplate`, the moment the client is caught up to as `from`, and any patch formats it applies as `patchMimeTypes`. Over Streamable HTTP, its request carries `Mcp-Name` holding that `uri` or `uriTemplate`, as `resources/read`'s carries its `uri`. It stores nothing, needs no subscription (D3), and is authorized as a `resources/read` of each covered resource. A catch-up's `changes` MUST hold every covered resource changed after `from` and at or before its `nextFrom`, as it stood at `nextFrom`, a deleted one as a tombstone, so a resource missing from them did not change in that span. With `hasMore` false, its `nextFrom` is no earlier than any moment on its timeline that the server had sent when it was read. A client that polls waits the result's `nextPollMs` before its next catch-up, with a floor, as in SEP-3415's poll mode (D12). + - A uri-specification on a timeline gets the timeline's latest moment as its `nextFrom` before any resource it covers exists. One the server neither lists nor covers (R24) gets `InvalidParams` (`-32602`) with `data.reason` `"notFound"`. A catch-up with no `from` gets `InvalidParams` too. + +### Timelines and moments + +A timeline's scope is the server's choice, and no client sees it: a client subscribes, catches up and keeps the moment it is caught up to for each uri-specification (R8). Each of the two examples below serves tickets and notes. The names `tickets`, `notes` and `work` are the server's own. + +In the first example below, `tickets/{id}` and `notes/{id}` each have a timeline of their own, so writes to one never wait on the other (R5), and a client watching both subscribes and catches up on each. § *A simple happy path* starts from this one, at `"107"`: + +```yaml +listed: # uri-specifications MAY point to separate timelines + tickets/{id}: tickets + notes/{id}: notes +timelines: + tickets: + latest: "107" # the timeline's latest moment + resources: # each one's revision, the moment it last changed + tickets/ticket-3: "101" + tickets/ticket-7: "107" # closing ticket-7 as a duplicate of ticket-9 + tickets/ticket-9: "107" # changed both in one atomic step + notes: + latest: "042" + resources: + notes/plan: "042" +``` + +In the second example below, closing a ticket also closes its note, in one atomic step, so tickets and notes share a timeline, and the server also lists `{type}/{id}`, which covers both, beside the templates that describe each type (R1, D9): + +```yaml +listed: + tickets/{id}: work + notes/{id}: work + "{type}/{id}": work # covers both, so it shares their timeline +timelines: + work: + latest: "108" + resources: + tickets/ticket-3: "101" + tickets/ticket-9: "108" # ticket-9 and its note closed in one atomic step + notes/ticket-9: "108" +``` + +A catch-up on `{type}/{id}` brings ticket-9 and its note as they stood at one moment. Separate catch-ups on `tickets/{id}` and `notes/{id}` can leave a client holding ticket-9 closed and its note still open until the second one. + +- **R1.** A server keeps one or more timelines, from one for everything it serves to one for each uri-specification it lists. Every resource a uri-specification covers is on that uri-specification's timeline, so uri-specifications that cover a resource in common MUST share one, and each resource is on exactly one timeline. Example: `tickets/{id}` and `{kind}/ticket-3` both cover `tickets/ticket-3`, so they share a timeline though neither contains the other. A server assigns timelines over every uri-specification it may ever list to anyone, since listings change and differ by who asks. +- **R2.** Every change to a resource, its creation and deletion included, happens at a new moment on its timeline, which becomes the timeline's latest moment and the resource's revision. +- **R3.** Moments on a timeline MUST strictly increase in byte order: ULIDs, or a counter zero-padded to a fixed width, but ISO timestamps only at a precision finer than the time between any two changes. + - Kubernetes works the same way: an object's `resourceVersion` names the version it last changed at, a client watches a collection for changes after one, and an update fails if it has moved on. [KEP-5504](https://www.kubernetes.dev/resources/keps/5504/), stable in v1.35, made them orderable within one resource type, as moments are within one timeline, where clients had compared them only for equality, as ETags compare. +- **R4.** A change that touches several resources in one atomic step gives them one moment, so resources that can change together share a timeline. +- **R5.** Changes on a timeline MUST commit in the order of their moments, so once a client has seen a moment, no change at or before it can still appear: SEP-3415's "safe-to-persist watermark", held by a timeline instead of a subscription. A server checks that each new moment is later than the timeline's latest one, and commits, in one atomic step: for example, in Postgres at read committed, with `UPDATE timelines SET latest = $new WHERE id = $timeline AND latest < $new` in byte order (`COLLATE "C"`), minting again when no row changes. A timeline per server serializes all its writes, and one per uri-specification only that one's. +- **R7.** Each of a resource's revisions, in the strawman's sense, MUST be the moment of that change, so `ifMatch` checks the latest, and this extension's clients may also order revisions. Every change-set entry in a write's result carries `revision`, a deletion's included. A `resources/read` result MUST carry each item's revision in `_meta`, under the strawman's provisional key, `io.modelcontextprotocol/resource-revision`. + +### What a catch-up brings + +- **R19.** Each entry in `changes` is one resource's change from `from` to the catch-up's `nextFrom` (R13): the strawman's change-set entry, with contents added. `previousRevision` is the resource's revision at `from`, absent if it did not exist then. `effect` is `deleted` if the change ends with the resource gone, else `created` if it did not exist at `from`, else `updated`. A non-deletion carries `contents`, one `TextResourceContents` or `BlobResourceContents`, or a `patch` from the contents at `previousRevision`, with its `patchMimeType`, in a listed format that applies. A merge patch ([RFC 7396](https://www.rfc-editor.org/rfc/rfc7396)) applies to one `application/json` text entry, and patches its parsed `text`. JSON-based patches travel as JSON, others as strings. The server sends `contents` when no listed format applies, when there is no `previousRevision`, or when it no longer keeps those contents, and MAY when `contents` is smaller. + - A client applies a patch only to a copy at its `previousRevision`, and otherwise reads the resource (D2). +- **R14.** A server may stop a catch-up short of the timeline's latest moment, to keep its result small. Its `nextFrom` is then where it stopped, never partway through one moment's changes, and `hasMore` is true, as in SEP-3415's poll mode. It may stop short with no changes, as after scanning many changes to another collection on a shared timeline. The client applies the changes, and is caught up to `nextFrom`. While `hasMore` is true, it catches up again at once, ignoring `nextPollMs`, as SEP-3415's poll mode does. A catch-up may run past the server's usual size to finish a moment. A server that can't send one moment's changes in a single result answers `InvalidParams` (`-32602`) with `data.reason` `"momentTooLarge"`, and the client starts over (R23), since a collection read pages freely and the catch-up after it starts past that moment. + - Each catch-up leaves the client's copies as they stood at its `nextFrom`, so the next one's patches start where the copies are, as a read replica's do (§ *Specification*). Example: X is caught up to `"100"` and holds ticket-5 at `"090"`, and ticket-5 changes at `"102"` and again at `"104"`. A catch-up that stops at `"102"` brings ticket-5 as it stood then, with a patch from `"090"` to `"102"`, and the next brings a patch from `"102"` to `"104"`. Sent as `contents` instead, ticket-5 would travel twice. A catch-up that reaches the timeline's latest moment brings each resource once, at its revision. + - Kubernetes reads every page of a list from one snapshot, so its `continue` token expires, after five minutes by default. A catch-up that stops short needs no snapshot, since the next is an ordinary catch-up, which expires only as any `from` does (R21). + +### Less common cases, most likely first + +**A writer's copy runs ahead of its catch-ups.** From `"107"`, as in § *A simple happy path*, X updates ticket-3 at `"109"`, and then Y updates it at `"110"`. X's copy is at `"109"`, but X stays caught up to `"107"` (R9): + +| | Server A | Client X | +|---|---|---| +| latest moment | `"110"` | | +| caught up to | | `"107"` | +| ticket-3 | `"110"`, Y's update | `"109"`, X's update | + +X then catches up from `"107"`: + +```jsonc +{"uri": "tickets/ticket-3", "effect": "updated", + "previousRevision": "101", "revision": "110", // ticket-3 as of "107" + "patchMimeType": "application/merge-patch+json", "patch": {...}} +// X's copy is at "109", not "101", so X reads ticket-3 with resources/read. +``` + +- **R9.** A client that writes a resource makes what it sent its copy, at the revision the reply gives that resource (R17). It takes nothing else from the reply. The reply lists every resource the write changed, such as a note closed along with its ticket, but holds no contents for the others, so the client gets them from a catch-up. Nor does the reply move what the client is caught up to. Example: X is caught up to `"107"`. Y updates ticket-9 at `"108"`, then X updates ticket-3 at `"109"`. Had X counted itself caught up to `"109"`, it would never fetch ticket-9's change. + +**Messages that cross.** + +```text +From "107" as in the simple happy path: +X -> A resources/update ticket-3, ifMatch "101" +A -> X event: ticket-3 at "109" arrives before the reply + X doesn't hold "109", so it catches up from "107" +Y -> A resources/delete ticket-3, at "111" +A -> X catch-up: ticket-3 deleted at "111" X keeps the tombstone +A -> X reply: ticket-3 at "109" earlier than "111", so ignored +``` + +- **R15.** A write's reply, a catch-up's entry or a tombstone changes a copy only when its revision is later than the copy's, and what a client is caught up to only moves forward, so messages may arrive in any order. +- **R16.** A client runs one catch-up or collection read per uri-specification at a time, counting the catch-ups that follow while `hasMore` is true as one. An event that arrives meanwhile waits until they end, and the client then applies R11 to it. + +**Missed events.** + +- **R12.** A client SHOULD catch up on each uri-specification it subscribed to whenever it may have missed events: after each SEP-3415 refresh, on reconnecting to its gateway or a `subscriptions/listen` stream, on starting, and on a `gap` envelope. A subscription with no expiry still gets SEP-3415's occasional refresh. + - A client can miss events without knowing, as a backend can when its webhook endpoint is down while the process holding its copies stays up. SEP-3415 retries each event, so a short outage loses none, and a missed event costs nothing once another event brings a catch-up, which brings every change after the moment the client is caught up to. A quiet collection stays stale at most until the next refresh, whose `deliveryStatus` says whether deliveries had been failing, so such a client sets its refresh interval to the staleness it can tolerate, even when granted no expiry. + +**A template the server doesn't list.** A client that loaded `skill://code-review/SKILL.md` also wants the skill's other files, such as `skill://code-review/scripts/lint.py`, from a server that lists one template, `skill://{skill}/{+path}`, for all its skills. The client subscribes to `skill://code-review/{+path}`, and the server accepts it, since the listed template matches every uri the client's could. To check, the server turns the client's template into a sample uri and matches it against the listed template, as this pseudo-code does: + +```text +covers(listed, clientTemplate): + 1. Write clientTemplate as a sample uri, replacing each {name} with the + stand-in SEG and each {+name} with the stand-in ANY. + 2. Match the sample against listed as you would any uri, except that: + - a literal in listed never matches a stand-in, + - a {name} in listed matches SEG but not ANY, since ANY may hold a slash, + - a {+name} in listed matches both. + 3. If they match, listed covers clientTemplate. If not, the server + answers notFound. +``` + +| Listed | Named by the client | Sample | Matches? | +|---|---|---|---| +| `skill://{skill}/{+path}` | `skill://code-review/{+path}` | `skill://code-review/ANY` | Yes: `{skill}` matches `code-review`, and `{+path}` matches `ANY` | +| `skill://{skill}/{+path}` | `skill://code-review/scripts/{+file}` | `skill://code-review/scripts/ANY` | Yes: `{skill}` matches `code-review`, and `{+path}` matches `scripts/ANY` | +| `{type}/{id}` | `tickets/{id}` | `tickets/SEG` | Yes: `{type}` matches `tickets`, and `{id}` matches `SEG` | +| `tickets/{id}` | `tickets/{+rest}` | `tickets/ANY` | No: `{id}` holds one path segment, but `ANY` may stand for several, such as `a/b`, so the server answers `notFound` | + +- **R24.** A client may name a uriTemplate the server doesn't list, in a subscription, a catch-up or a collection read, as long as a template the server lists matches every uri the client's could. The client's template then shares the listed one's timeline (R1). A server may check as the pseudo-code and table above show, and answers `notFound` when it can't tell. + +**Starting over.** A client caught up to a moment too old (R21), or whose catch-up meets a moment too large for one result (R14), reads the collection again, as a new client does (R18). + +- **R23.** A client that held copies before reading a collection replaces each one the read's pages name, even with an earlier revision, and drops each one in that collection that neither the read's pages nor the catch-ups after them named, if its revision is at or before the last catch-up's `nextFrom`. + +**A moment too old to answer from.** + +- **R21.** A server keeps the moment of every change, deletions included, back to a moment of its choosing, and may drop old contents at any time. A catch-up whose `from` is earlier than that gets `InvalidParams` (`-32602`) with `data.reason` `"momentExpired"`, and the client starts over (R23, D4). A server restored from a backup first mints a moment past every one it may have sent, and makes it the oldest it answers from, as etcd's `snapshot restore` does with `--bump-revision` and `--mark-compacted`. A server SHOULD keep that history longer than the time it grants between refreshes, and longer than a read of its largest collection takes, so that a client can catch up at each refresh (R12) and after reading any collection. + +**Resources a client may not read.** Q6 asks what a client sees of them, and proposes an answer. + +### Declaring the extension + +- **R20.** A server declares this extension in `capabilities.extensions` under a provisional identifier, `io.modelcontextprotocol/resource-webhooks`, as SEP-2133 asks, which commits it to `resources/collection/read`, to `resources/collection/catchup`, and to listing `mcp.resource.collection.updated` in `events/list` with webhook delivery. The extension reserves the prefix `resources/collection/` for its methods (D18), beside core's and the strawman's under `resources/`. The event's name waits on Open Question 2's decision about an `mcp.*` prefix. + +### uriGlob, later + +A uriGlob is a third kind of uri-specification. A glob is the familiar way to write a filesystem-like collection, and reads more clearly than a template that could describe much the same one: `file:///tickets/**/*.md`, the example the Filesystems working group's Problem Statement 2 gives, against `file:///tickets/{+path}.md`. Its match is also defined by its own rules, where a template's is left open: RFC 6570 defines how a template expands, and says matching one "only works well if the template expressions are delimited by the beginning or end of the URI or by characters that cannot be part of the expansion". Problem Statement 2 asks for glob matching under a URI prefix to find resources, and the strawman defers globs, so a uriGlob would let a client watch what it found with the same pattern. This extension offers that group three rules, and adopts whatever it settles: + +- `*` matches any characters within one segment, and `**` zero or more whole segments. +- A uriGlob has at most one `**`, so a matcher checks the segments before it from the URI's start and those after it from the end, and two globs' overlap (R1) stays easy to test. +- A glob has a field of its own, `uriGlob`, since `*` is legal in a URI's path. + +## Rationale + +IDs follow the order the decisions were recorded, not the order shown, and a gap is a decision withdrawn, or one about the prototype alone. + +| ID | Decision | Rejected alternative — why | +|---|---|---| +| D1 | Subscribe with `events/subscribe` (R18), and catch up with `resources/collection/catchup`, which stores nothing (R13), so SEP-3415's is the one subscription lifecycle. | A subscribe method of this extension's own, such as `resources/webhooks/subscribe`, a second subscription lifecycle beside SEP-3415's, which stores the subscription again on every resubscribe. | +| D2 | A patch starts from `previousRevision`, and a client whose copy is elsewhere reads the resource (R19). | Contents only, which gives up much of the saving, or patching from each copy's revision, which the server never learns. | +| D3 | A catch-up needs no subscription (R13), so clients that poll or listen on a stream can use it. | Naming a subscription so a lost one gets `NotFound`, which shuts those clients out. | +| D4 | A `from` too old gets `momentExpired`, and the client starts over (R21), as Kubernetes answers with `410 Gone` and core an expired cursor with `-32602`. | A flag with the whole current state, or SEP-3415's `truncated: true`, either of which a client could miss and keep deleted copies. | +| D5 | Events carry no contents (R10), so a lost, repeated or late one does no harm. A change then costs a subscriber an event and a catch-up, one round trip more than a delivery carrying the change, with about the same bytes. The writer pays nothing extra, and N changes before a catch-up cost N events and one catch-up. | Deliveries that carry each change's contents or a patch, which need a `fromVersion` to order them and a resubscribe that drops the deliveries still queued. | +| D6 | Moments are ordered (R3, R5), so one string serves as version, revision and the moment a client is caught up to, at the cost of serializing each timeline's writes. | Versions compared only for equality, as ETags are, which need a `fromVersion` to order deliveries. | +| D7 | Writes are the strawman's four methods (R7, R17), so a writer's reply carries the revision. | Writes outside MCP, where each server decides who made a version. | +| D9 | A server may list uri-specifications that overlap, such as a uri that a template it lists covers, or `{type}/{id}` beside `tickets/{id}` and `notes/{id}`, all on the timeline they share (R1). | Listings that never overlap, which would stop a common practice for nothing, and leave a client that wants changes across types whole no uri-specification to watch. | +| D11 | Patch formats are listed in `patchMimeTypes`, with `contents` for any entry no format fits (R19), as HTTP falls back for content codings ([RFC 9110 § 12.5.3](https://www.rfc-editor.org/rfc/rfc9110#section-12.5.3)) and deltas ([RFC 3229 § 5.2](https://www.rfc-editor.org/rfc/rfc3229#section-5.2)). | A `form` parameter, which could only repeat or contradict the list, or an error, which costs a second request. | +| D12 | A catch-up's result is a plain result, and a client that polls waits its `nextPollMs`, as in SEP-3415's poll mode (R13, R14). Core's `ttlMs` is a freshness hint, which clients "SHOULD NOT treat … as a polling interval", and a cache keyed on the request could answer step 6's catch-up with step 3's empty result, since both send `from` `"107"`. A collection read stays a `CacheableResult` (R22), since a cached read is an older snapshot whose `nextFrom` matches it. | Core's `CacheableResult` with `ttlMs` as the wait, for both reasons. No pacing field, which leaves a server no way to slow clients that poll. A rule that a client never answers a catch-up from a cache, which binds no gateway that follows core. | +| D13 | A server may stop a catch-up short, bringing each resource as it stood where it stopped (R13, R14), so a long catch-up is a run of ordinary ones, as polls are in SEP-3415, and every patch applies in order. | Taking the last entry's `revision` as the moment the client is caught up to, beside a field holding the timeline's latest moment, which gave the client two rules and could not stop short with no changes. Pages bringing each resource at its revision, which cost a read for each resource changed on both sides of a page's end. Sending the first page's `from` again on every page, with a `cursor` beside it, which needs both fields and a rule for which one a patch starts from. A fixed end sent back on every page, `through`, which cost a field and a catch-up after the last page. | +| D14 | Starting over has its own method, `resources/collection/read`, which takes no `from` and pages with core's opaque `cursor` and `nextCursor`, as `resources/list` does (R22). A client reads the collection, subscribes, then catches up from the read's `nextFrom` (R18), so every catch-up names `from`. | A catch-up with no `from`, which gave one method two kinds of result, and needed a `cursor` beside `from` with an error for naming both. `resources/template/read`, which would need a sibling for uriGlobs and reads like fetching the template itself. SEP-2640's `resources/directory/read`, which lists one directory's children without their contents. Subscribing before reading, which also works, but leaves a new client's events waiting while it pages. | +| D15 | A catch-up never stops partway through a moment, and a server that can't finish one answers `momentTooLarge` (R14), since a result run long to finish a moment is never larger than a start-over, which sends every resource in the collection. | Stopping partway through a moment, which would leave the client's copies matching no moment until the next catch-up. Reusing `momentExpired`, which would tell a client its `from` had expired when it hadn't. SEP-3415's `ResourceExhausted`, whose code its Open Question 5 may fold into `-32602`, and which would give a client a second code for the one response, starting over. | +| D16 | Each result's `nextFrom` is the `from` of the client's next catch-up, as core's `nextCursor` is the next `cursor` (R13, R14, R22). A collection read repeats one on every page. | `moment`, which left a reader asking which moment, since every `revision` is one. `latestMoment` or `currentMoment`, which is the timeline's latest only when `hasMore` is false. `caughtUpTo`, which is wrong for a collection read. | +| D17 | The vocabulary of § *Definitions*: moment, latest moment, revision and caught up to. "Moment" and "revision" name different things, so both stay. | One word for a moment and a resource's revision, which would ask the strawman to rename its fields, and left "a resource's moment", "the timeline's moment" and "a client's moment" each meaning a different latest. "Current moment", since a timeline moves only when a change occurs. | +| D18 | The extension's two methods share the prefix `resources/collection/`, which it reserves (R20), and a uri on its own is a collection of one (R22). | `resources/uri-specification/read` and `resources/uri-specification/catchup`, which read like fetching the specification itself, as D14 says of `resources/template/read`, and would put a hyphenated term of our own on the wire, where no core segment is hyphenated. The two names alone under core's `resources/`, which reserved nothing for a later method. | +| D19 | The extension sends an event of its own, `mcp.resource.collection.updated`, one for each change on each subscription covering a resource it touched. Its `data` names the subscription's uri-specification, and lists the change's entries that the subscription covers (R10, R11). It leaves `mcp.resource.updated` to SEP-3415's Open Question 2. A server may merge undelivered events, with `truncated` left false, as Peter agreed for replacing an event with a newer one. | Peter's `mcp.resource.updated` with data of our own, which takes a name Open Question 2 may define otherwise, for core's `notifications/resources/updated`, whose `uri` names the changed resource. One event for each resource changed, which sends a change touching 500 tickets as 500 deliveries, 499 of them skipped after the first catch-up. An event naming only the uri-specification and the moment, which a writer could skip only by taking the whole moment as its own, wrong when its write also changed a resource whose contents it lacks, as closing a ticket closes its note. Leaving the uri-specification to the subscription `id` in `X-MCP-Subscription-Id`, which each client would map back to its subscription. | +| D20 | A server accepts a uriTemplate it doesn't list when one it lists covers it, and MAY answer `notFound` for one it can't show is covered (R24). R24's pseudo-code checks it: write the client's template as a sample uri, with a stand-in for each expression, and match it as any uri. It never wrongly accepts, and it handles a listed template with part of it filled in, as a skill or a type is. | Listed templates only, which leaves a client that wants one skill, on a server listing `skill://{skill}/{+path}`, watching every skill, unless the server lists a template for each. An exact check, which must first settle what RFC 6570's expressions match, empty values and levels 3 and 4 included, then compare automata that can grow exponentially. | +| D21 | A server restored from a backup mints a moment past every one it may have sent, and answers nothing earlier, as etcd's restore does, and a start-over's pages replace the copies they name (R21, R23). | Leaving restores out, since R3 already forbids reusing a moment, which still leaves a well-restored server's clients keeping copies the backup lost. A client that drops its newer copies when a read's `nextFrom` is earlier than the moment it is caught up to, which misses a writer whose copy ran ahead. A guard on a `from` later than the latest moment alone, which catches a restore only until new writes pass the client's moment. | +| D22 | An event's `eventId` is the moment of its change (R10), the stable identifier SEP-3415 asks a server to use, since one change sends a subscription one event (D19). | The moment and the uri-specification, left from when one change sent a subscription an event for each resource, and repeating `data`. No rule, leaving the value to SEP-3415's SHOULD, so that another server could send any unique value. | +| D23 | Both methods carry `Mcp-Name` over Streamable HTTP, holding the `uri` or `uriTemplate` (R13, R22), as `resources/read` carries its `uri` and SEP-3415's methods their event's name, so a gateway can route a request by collection without parsing its body. | No header, as the strawman's four methods and SEP-2640's `resources/directory/read` carry none, which leaves a gateway to parse each body to learn the collection. | +| D24 | A server SHOULD keep history longer than the time it grants between refreshes (R21), so a quiet collection on a busy shared timeline costs no full read at each refresh, as SEP-3415's refresh keeps its cursor inside the upstream's retention window. | Answering from before the oldest moment when nothing in the collection changed, which works only for collections whose last change the server tracks. Bounding history by a read's length alone, which leaves such a collection re-read at every refresh. | +| D25 | R7 keeps its MUST that a `resources/read` result carry each item's revision in `_meta`, so a copy a client got from a core read, as a skills client gets `SKILL.md`, can be compared without reading it again. It binds only servers that declare this extension, and gives the strawman's open question about `resources/read` a use case. | R19's fallback reading through `resources/collection/read` alone, which needs no hook into core's read, but leaves copies from core reads without a revision. A SHOULD, as the strawman's "when the server can provide it", which gives R19 two routes. | + +## Backward Compatibility + +None. The extension adds two methods and one event, and binds only a server that declares it (R20). Its one rule on a core method, R7's revision for each `resources/read` item, adds a key under `_meta`, which a client that doesn't know the extension ignores (D25). + +## Security Implications + +This extension sends its events by SEP-3415's webhook delivery as it stands, so that SEP's rules on callback URLs, signing and endpoint verification cover every POST. It adds four things to weigh: + +- **Events carry no contents** (R10), so a delivery that reaches the wrong receiver reveals uris and revisions, never what a resource holds. +- **A catch-up or a collection read is authorized as a `resources/read`** of each resource it covers (R13, R22). Q6 proposes that events, catch-ups and reads cover only what the principal may read, and asks what happens when access changes while a resource doesn't. +- **`nextFrom` moves with every change on a timeline** (R13), so it can tell a principal when resources it may not read change. Q6 says how a server avoids that. +- **Both methods carry their `uri` or `uriTemplate` in `Mcp-Name`** (R13, D23), where a gateway or proxy sees it, as core's `resources/read` carries its `uri`. + +## Reference Implementation + +[lumenize/resource-webhooks-on-events](https://github.com/lumenize/resource-webhooks-on-events) builds this extension on Cloudflare Workers, without an MCP SDK, and with the spec's `schema.ts` as its only schema. It has two parts, each a Durable Object: + +- **A, the server.** It serves tickets and notes, with the strawman's four methods for writes and zero-padded counters for moments. Clients register by Client ID Metadata Document. +- **B, a gateway** in SEP-3415's forward-proxy deployment, which verifies each delivery and passes it to its client over a WebSocket. + +A client library, `src/client/copies.ts`, keeps copies by the client's rules: R8, R9, R11, R12, R15, R16, R18, R19, R21 and R23. A serves two layouts in its tests. One puts tickets and notes on one timeline and also lists `{type}/{id}` (D9). The other gives each its own timeline, so closing a ticket closes its note at a moment on each, which breaks R4 on purpose, to test a write that commits once per timeline. The deployed Worker serves the first. + +### Tested, and not yet tested + +- **456 tests pass,** and a check fails the run unless a passing test names every rule above. Each new test was mutation-checked: broken code turned it red. +- **Each MUST of SEP-3415's that the prototype uses has a test.** +- **MCP's conformance suite,** `@modelcontextprotocol/conformance` 0.2.0-alpha.12 at revision 2026-07-28, passes against A and against the client, with a baseline that lists the features neither offers, such as prompts. +- **A deployed tier runs on Cloudflare.** Two clients read the tickets, subscribe and catch up. One closes a ticket and skips its own event, and the other catches up with a merge patch. +- **Designed, not built:** several writers minting moments at once, subscriptions granted no expiry, R12's catch-up on a `subscriptions/listen` stream and the `deliveryStatus` it cites, uriGlob, access that differs by resource (Q6), and a restore from a real backup, whose steps an operator reset takes in the tests (R21). +- **Only the prototype's own client and server have run the extension so far.** The strawman's methods are built for the happy path and a conflicting write. + +## Findings + +- **A catch-up from before the contents a server keeps can't stop short.** R14 lets a server stop a catch-up short, and R13 has it bring each resource as it stood where it stopped. R21 lets a server drop old contents. So a catch-up from a moment older than the contents the server keeps runs on until it reaches a moment where each covered resource changed since its `from` still has its contents, and one result can hold every resource changed since then. In the prototype, which checks every row it keeps at each moment as it runs on, the cost grows as the square of their number. +- **Cloudflare's egress already does SEP-3415's address check.** SEP-3415 asks a server to reject callback URLs whose address isn't globally routable, at delivery time. With the `global_fetch_strictly_public` flag, a Worker's fetch refuses a name that resolves to a private, loopback, link-local or other special-purpose address with an error of Cloudflare's own, 1016 or 1002, and names built to rebind to a private address reached none. Two ranges, `192.0.0.0/24` and `192.0.2.0/24`, go out and time out. On 2026-11-03, A's webhook POSTs and its fetch of client metadata documents met the same outcome for each of 30 names, so a server on Workers needs no address check of its own for either. Cloudflare's documentation promises none of this, so the prototype's deployed tier measures it again on every run. + +## Open Questions + +IDs follow the order the questions were raised, not the order shown, and a gap is a question answered or withdrawn. + +- **Q6. What a client sees of resources it may not read.** Proposed: events, catch-ups and collection reads cover only what the principal may read. Still open is access that changes while the resource does not: a ticket shared with X after its last change never shows in X's catch-ups, and a revoked one stays in X's copies. + - (a) The server answers that principal's catch-ups from before the change with `momentExpired`, and ends its subscriptions, as SEP-3415's § *Authorization* does on a revoke. + - (b) A change in access is a change on the timeline, which makes a resource's revision differ by who asks, against R7 and R11. + - Recommendation: (a), designed, not built, since in the prototype every principal reads everything. + - Under either, `nextFrom` moves with every change on the timeline (R13), so a principal watching a timeline it shares with resources it may not read learns when they change, as the strawman warns that a revision does. A server can give each part a timeline of its own where R1 allows, or R13's MUST could count only the moments sent to that principal. +- **Q7. Starting to watch a collection in one call.** A client makes three: it reads the collection, subscribes with `events/subscribe`, and catches up (§ *A simple happy path*, steps 1 to 3). One call would be simpler to use. + - (a) Keep the three calls, so SEP-3415's `events/subscribe` stays the one subscription lifecycle (D1). + - (b) Add `resources/collection/subscribe` to this extension, which subscribes and returns the collection's contents. It would be a second subscription lifecycle beside `events/subscribe`, and its result would page as a collection read's does. + - (c) Add a helper to the SDKs that makes the three calls, leaving the wire as in (a). + - This draft takes (a), and leaves the choice to the working group. From 9af891b49b916952f8ba75592380ea4c36f929d5 Mon Sep 17 00:00:00 2001 From: Larry Maccherone Date: Wed, 4 Nov 2026 05:13:49 -0500 Subject: [PATCH 02/15] Recast the Rationale in SEP-3415's form, and drop nine decisions Larry's edits split the Abstract, name the extension's event in the SEP-3415 reference, and ask for the skills work in References and for D1's reason. The Rationale is now a bold-lead paragraph per decision under five topics. D3, D9, D11, D16, D18, D21, D22, D23 and D24 go, since the rules already carry their answers: R22 now says its read carries Mcp-Name, and R23 that a start-over replaces copies with earlier revisions after a restore. D1 says why a second lifecycle loses, D6 gives this design's reason for ordered moments, and D7 covers a client's own writes. SEP-2640 joins the References. Co-Authored-By: Claude Opus 5.5 --- proposals/XXXX-resource-webhooks.md | 87 +++++++++++++++++------------ 1 file changed, 51 insertions(+), 36 deletions(-) diff --git a/proposals/XXXX-resource-webhooks.md b/proposals/XXXX-resource-webhooks.md index 5ebc93e..0eb2e48 100644 --- a/proposals/XXXX-resource-webhooks.md +++ b/proposals/XXXX-resource-webhooks.md @@ -12,7 +12,9 @@ This extension lets an MCP client keep copies of resources current by webhook. A server records each change to its resources at a moment on a timeline, a string that sorts after every moment before it, such as `"108"`. For each change, it sends `mcp.resource.collection.updated` through the webhook delivery of SEP-3415, Events, to each subscription covering a resource the change touched. The event names the collection the subscription watches and each resource in it that changed, with its new revision, and carries none of their contents. -A client that already holds each named revision, such as the client that made the change, skips the event. Any other catches up with `resources/collection/catchup`, which brings every change to the collection after the moment the client is caught up to, each as contents or a patch. A new client first reads the collection with `resources/collection/read`. Writes use the Filesystems working group's strawman methods, and a resource's revision is the moment it last changed. +A client that already holds each named revision, such as the client that made the change, skips the event. Any other catches up with `resources/collection/catchup`, which brings every change to the collection after the moment the client is caught up to, each as contents or a patch. + +A new client first reads the collection with `resources/collection/read`. Writes use the Filesystems working group's strawman methods, and a resource's revision is the moment it last changed. An event carries no state, so a lost, repeated or late one does no harm: the next catch-up brings whatever it missed. A working prototype builds all of it, as § *Reference Implementation* describes. @@ -20,12 +22,13 @@ This proposal comes to the Tasks and Events working group to be discussed, impro ## References -This proposal builds on two drafts, a working group's problem statements and one revision of MCP: +This proposal builds on two drafts, a working group's problem statements and one revision of MCP, and takes its sharpest use case from a Final extension: -- **SEP-3415, Events,** [modelcontextprotocol#3415](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3415), a draft, by reference at commit `b08e4f9`. This extension takes its `events/subscribe` and its webhook delivery, and sends an event of its own rather than the `mcp.resource.updated` its Open Question 2 sketches. +- **SEP-3415, Events,** [modelcontextprotocol#3415](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3415), a draft, by reference at commit `b08e4f9`. This extension takes its `events/subscribe` and its webhook delivery, and sends an event of its own, `mcp.resource.collection.updated` rather than the `mcp.resource.updated` its Open Question 2 sketches. - **The Filesystems strawman,** [experimental-ext-filesystems#2](https://github.com/modelcontextprotocol/experimental-ext-filesystems/pull/2), the Filesystems working group's draft of resource operations, at commit `8d0fa88`, which the rest of this proposal calls "the strawman". It adds `resources/stat`, `resources/create`, `resources/update` and `resources/delete`. Each resource has a `revision`, a write names the one it expects as `ifMatch`, and a write's result holds a change-set entry for each resource it changed, such as `{uri, effect: "updated", previousRevision, revision}`. This extension takes its `revision` and its change-set entry, and the prototype builds its four methods. - **The Filesystems problem statements,** [experimental-ext-filesystems](https://github.com/modelcontextprotocol/experimental-ext-filesystems/blob/main/docs/problem-statements.md), that working group's framing of its work. Problem Statement 6 asks a proposal to "Decide whether a client can subscribe to a directory", and asks "Does a client need ordering between its write response and the resulting notification?" A collection answers the first (R24), and revisions the second, since a client orders messages by revision rather than arrival (R15). This extension offers both for that group to weigh. - **MCP 2026-07-28,** [the specification](https://modelcontextprotocol.io/specification/2026-07-28), the revision this extension builds on, for `resources/read`, pagination and `CacheableResult`. +- **SEP-2640, Skills over MCP,** [the extension](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/seps/2640-skills-extension.md), Final, which serves a skill's files as resources, such as `skill://pdf-processing/scripts/extract.py`. A client that loaded a skill can watch its files with this extension, as § *Use cases* shows. ## Motivation @@ -128,7 +131,7 @@ Each client now holds every ticket, and is caught up to `"107"`, the read's `nex | ticket-3 | `"101"`, open | **`"101"`, open** | **`"101"`, open** | | ticket-7 and ticket-9 | `"107"` | **`"107"`** | **`"107"`** | -- **R22.** `resources/collection/read` names a uri-specification as a catch-up does, gets the same errors for it, and is authorized the same way (R13), but takes no `from`. It returns the contents of each resource in the collection, each item as `resources/read` returns it, with its revision in `_meta` (R7), and `nextFrom`, the timeline's latest moment, with each resource as it stood at `nextFrom` or later. A long result comes in pages, as core's `resources/list` does: the server returns an opaque `nextCursor`, and the client sends it back as `cursor`. Every page repeats the first page's `nextFrom`. A resource that doesn't change while the client pages MUST come exactly once. The client catches up from `nextFrom` afterwards, which brings whatever changed while it paged, deletions included. Its result is a core `CacheableResult`. +- **R22.** `resources/collection/read` names a uri-specification as a catch-up does, in its params and in `Mcp-Name`, gets the same errors for it, and is authorized the same way (R13), but takes no `from`. It returns the contents of each resource in the collection, each item as `resources/read` returns it, with its revision in `_meta` (R7), and `nextFrom`, the timeline's latest moment, with each resource as it stood at `nextFrom` or later. A long result comes in pages, as core's `resources/list` does: the server returns an opaque `nextCursor`, and the client sends it back as `cursor`. Every page repeats the first page's `nextFrom`. A resource that doesn't change while the client pages MUST come exactly once. The client catches up from `nextFrom` afterwards, which brings whatever changed while it paged, deletions included. Its result is a core `CacheableResult`. - A uri on its own is a collection of one, and a client watching one reads it with `resources/collection/read` too, rather than `resources/read`, since only a collection read brings a `nextFrom` to catch up from. A copy's revision can be older than the history the server keeps, so a catch-up from it could get `momentExpired` (R21), while `nextFrom` is the timeline's latest moment. - **R8.** A client keeps, for each uri-specification it catches up on, the moment it is caught up to, and each copy's revision. @@ -219,7 +222,7 @@ X's copy is at `"101"`, the patch's `previousRevision`, so X applies it (R19), a | ticket-7 and ticket-9 | `"107"` | `"107"` | `"107"` | - **R11.** When a client receives `mcp.resource.collection.updated`, it MUST catch up on the uri-specification the event names, unless it already holds each uri in `changes`, or its tombstone, at that entry's `revision` or later, which lets the writer skip its own change. An event with no `changes` always brings a catch-up. -- **R13.** A catch-up names a uri-specification, as `uri` or `uriTemplate`, the moment the client is caught up to as `from`, and any patch formats it applies as `patchMimeTypes`. Over Streamable HTTP, its request carries `Mcp-Name` holding that `uri` or `uriTemplate`, as `resources/read`'s carries its `uri`. It stores nothing, needs no subscription (D3), and is authorized as a `resources/read` of each covered resource. A catch-up's `changes` MUST hold every covered resource changed after `from` and at or before its `nextFrom`, as it stood at `nextFrom`, a deleted one as a tombstone, so a resource missing from them did not change in that span. With `hasMore` false, its `nextFrom` is no earlier than any moment on its timeline that the server had sent when it was read. A client that polls waits the result's `nextPollMs` before its next catch-up, with a floor, as in SEP-3415's poll mode (D12). +- **R13.** A catch-up names a uri-specification, as `uri` or `uriTemplate`, the moment the client is caught up to as `from`, and any patch formats it applies as `patchMimeTypes`. Over Streamable HTTP, its request carries `Mcp-Name` holding that `uri` or `uriTemplate`, as `resources/read`'s carries its `uri`. It stores nothing, needs no subscription, and is authorized as a `resources/read` of each covered resource. A catch-up's `changes` MUST hold every covered resource changed after `from` and at or before its `nextFrom`, as it stood at `nextFrom`, a deleted one as a tombstone, so a resource missing from them did not change in that span. With `hasMore` false, its `nextFrom` is no earlier than any moment on its timeline that the server had sent when it was read. A client that polls waits the result's `nextPollMs` before its next catch-up, with a floor, as in SEP-3415's poll mode (D12). - A uri-specification on a timeline gets the timeline's latest moment as its `nextFrom` before any resource it covers exists. One the server neither lists nor covers (R24) gets `InvalidParams` (`-32602`) with `data.reason` `"notFound"`. A catch-up with no `from` gets `InvalidParams` too. ### Timelines and moments @@ -245,7 +248,7 @@ timelines: notes/plan: "042" ``` -In the second example below, closing a ticket also closes its note, in one atomic step, so tickets and notes share a timeline, and the server also lists `{type}/{id}`, which covers both, beside the templates that describe each type (R1, D9): +In the second example below, closing a ticket also closes its note, in one atomic step, so tickets and notes share a timeline, and the server also lists `{type}/{id}`, which covers both, beside the templates that describe each type (R1): ```yaml listed: @@ -345,7 +348,7 @@ covers(listed, clientTemplate): **Starting over.** A client caught up to a moment too old (R21), or whose catch-up meets a moment too large for one result (R14), reads the collection again, as a new client does (R18). -- **R23.** A client that held copies before reading a collection replaces each one the read's pages name, even with an earlier revision, and drops each one in that collection that neither the read's pages nor the catch-ups after them named, if its revision is at or before the last catch-up's `nextFrom`. +- **R23.** A client that held copies before reading a collection replaces each one the read's pages name, even with an earlier revision, as after a restore (R21), and drops each one in that collection that neither the read's pages nor the catch-ups after them named, if its revision is at or before the last catch-up's `nextFrom`. **A moment too old to answer from.** @@ -355,7 +358,7 @@ covers(listed, clientTemplate): ### Declaring the extension -- **R20.** A server declares this extension in `capabilities.extensions` under a provisional identifier, `io.modelcontextprotocol/resource-webhooks`, as SEP-2133 asks, which commits it to `resources/collection/read`, to `resources/collection/catchup`, and to listing `mcp.resource.collection.updated` in `events/list` with webhook delivery. The extension reserves the prefix `resources/collection/` for its methods (D18), beside core's and the strawman's under `resources/`. The event's name waits on Open Question 2's decision about an `mcp.*` prefix. +- **R20.** A server declares this extension in `capabilities.extensions` under a provisional identifier, `io.modelcontextprotocol/resource-webhooks`, as SEP-2133 asks, which commits it to `resources/collection/read`, to `resources/collection/catchup`, and to listing `mcp.resource.collection.updated` in `events/list` with webhook delivery. The extension reserves the prefix `resources/collection/` for its methods, beside core's and the strawman's under `resources/`. The event's name waits on Open Question 2's decision about an `mcp.*` prefix. ### uriGlob, later @@ -367,33 +370,45 @@ A uriGlob is a third kind of uri-specification. A glob is the familiar way to wr ## Rationale -IDs follow the order the decisions were recorded, not the order shown, and a gap is a decision withdrawn, or one about the prototype alone. +Each decision keeps the number the rules cite it by. -| ID | Decision | Rejected alternative — why | -|---|---|---| -| D1 | Subscribe with `events/subscribe` (R18), and catch up with `resources/collection/catchup`, which stores nothing (R13), so SEP-3415's is the one subscription lifecycle. | A subscribe method of this extension's own, such as `resources/webhooks/subscribe`, a second subscription lifecycle beside SEP-3415's, which stores the subscription again on every resubscribe. | -| D2 | A patch starts from `previousRevision`, and a client whose copy is elsewhere reads the resource (R19). | Contents only, which gives up much of the saving, or patching from each copy's revision, which the server never learns. | -| D3 | A catch-up needs no subscription (R13), so clients that poll or listen on a stream can use it. | Naming a subscription so a lost one gets `NotFound`, which shuts those clients out. | -| D4 | A `from` too old gets `momentExpired`, and the client starts over (R21), as Kubernetes answers with `410 Gone` and core an expired cursor with `-32602`. | A flag with the whole current state, or SEP-3415's `truncated: true`, either of which a client could miss and keep deleted copies. | -| D5 | Events carry no contents (R10), so a lost, repeated or late one does no harm. A change then costs a subscriber an event and a catch-up, one round trip more than a delivery carrying the change, with about the same bytes. The writer pays nothing extra, and N changes before a catch-up cost N events and one catch-up. | Deliveries that carry each change's contents or a patch, which need a `fromVersion` to order them and a resubscribe that drops the deliveries still queued. | -| D6 | Moments are ordered (R3, R5), so one string serves as version, revision and the moment a client is caught up to, at the cost of serializing each timeline's writes. | Versions compared only for equality, as ETags are, which need a `fromVersion` to order deliveries. | -| D7 | Writes are the strawman's four methods (R7, R17), so a writer's reply carries the revision. | Writes outside MCP, where each server decides who made a version. | -| D9 | A server may list uri-specifications that overlap, such as a uri that a template it lists covers, or `{type}/{id}` beside `tickets/{id}` and `notes/{id}`, all on the timeline they share (R1). | Listings that never overlap, which would stop a common practice for nothing, and leave a client that wants changes across types whole no uri-specification to watch. | -| D11 | Patch formats are listed in `patchMimeTypes`, with `contents` for any entry no format fits (R19), as HTTP falls back for content codings ([RFC 9110 § 12.5.3](https://www.rfc-editor.org/rfc/rfc9110#section-12.5.3)) and deltas ([RFC 3229 § 5.2](https://www.rfc-editor.org/rfc/rfc3229#section-5.2)). | A `form` parameter, which could only repeat or contradict the list, or an error, which costs a second request. | -| D12 | A catch-up's result is a plain result, and a client that polls waits its `nextPollMs`, as in SEP-3415's poll mode (R13, R14). Core's `ttlMs` is a freshness hint, which clients "SHOULD NOT treat … as a polling interval", and a cache keyed on the request could answer step 6's catch-up with step 3's empty result, since both send `from` `"107"`. A collection read stays a `CacheableResult` (R22), since a cached read is an older snapshot whose `nextFrom` matches it. | Core's `CacheableResult` with `ttlMs` as the wait, for both reasons. No pacing field, which leaves a server no way to slow clients that poll. A rule that a client never answers a catch-up from a cache, which binds no gateway that follows core. | -| D13 | A server may stop a catch-up short, bringing each resource as it stood where it stopped (R13, R14), so a long catch-up is a run of ordinary ones, as polls are in SEP-3415, and every patch applies in order. | Taking the last entry's `revision` as the moment the client is caught up to, beside a field holding the timeline's latest moment, which gave the client two rules and could not stop short with no changes. Pages bringing each resource at its revision, which cost a read for each resource changed on both sides of a page's end. Sending the first page's `from` again on every page, with a `cursor` beside it, which needs both fields and a rule for which one a patch starts from. A fixed end sent back on every page, `through`, which cost a field and a catch-up after the last page. | -| D14 | Starting over has its own method, `resources/collection/read`, which takes no `from` and pages with core's opaque `cursor` and `nextCursor`, as `resources/list` does (R22). A client reads the collection, subscribes, then catches up from the read's `nextFrom` (R18), so every catch-up names `from`. | A catch-up with no `from`, which gave one method two kinds of result, and needed a `cursor` beside `from` with an error for naming both. `resources/template/read`, which would need a sibling for uriGlobs and reads like fetching the template itself. SEP-2640's `resources/directory/read`, which lists one directory's children without their contents. Subscribing before reading, which also works, but leaves a new client's events waiting while it pages. | -| D15 | A catch-up never stops partway through a moment, and a server that can't finish one answers `momentTooLarge` (R14), since a result run long to finish a moment is never larger than a start-over, which sends every resource in the collection. | Stopping partway through a moment, which would leave the client's copies matching no moment until the next catch-up. Reusing `momentExpired`, which would tell a client its `from` had expired when it hadn't. SEP-3415's `ResourceExhausted`, whose code its Open Question 5 may fold into `-32602`, and which would give a client a second code for the one response, starting over. | -| D16 | Each result's `nextFrom` is the `from` of the client's next catch-up, as core's `nextCursor` is the next `cursor` (R13, R14, R22). A collection read repeats one on every page. | `moment`, which left a reader asking which moment, since every `revision` is one. `latestMoment` or `currentMoment`, which is the timeline's latest only when `hasMore` is false. `caughtUpTo`, which is wrong for a collection read. | -| D17 | The vocabulary of § *Definitions*: moment, latest moment, revision and caught up to. "Moment" and "revision" name different things, so both stay. | One word for a moment and a resource's revision, which would ask the strawman to rename its fields, and left "a resource's moment", "the timeline's moment" and "a client's moment" each meaning a different latest. "Current moment", since a timeline moves only when a change occurs. | -| D18 | The extension's two methods share the prefix `resources/collection/`, which it reserves (R20), and a uri on its own is a collection of one (R22). | `resources/uri-specification/read` and `resources/uri-specification/catchup`, which read like fetching the specification itself, as D14 says of `resources/template/read`, and would put a hyphenated term of our own on the wire, where no core segment is hyphenated. The two names alone under core's `resources/`, which reserved nothing for a later method. | -| D19 | The extension sends an event of its own, `mcp.resource.collection.updated`, one for each change on each subscription covering a resource it touched. Its `data` names the subscription's uri-specification, and lists the change's entries that the subscription covers (R10, R11). It leaves `mcp.resource.updated` to SEP-3415's Open Question 2. A server may merge undelivered events, with `truncated` left false, as Peter agreed for replacing an event with a newer one. | Peter's `mcp.resource.updated` with data of our own, which takes a name Open Question 2 may define otherwise, for core's `notifications/resources/updated`, whose `uri` names the changed resource. One event for each resource changed, which sends a change touching 500 tickets as 500 deliveries, 499 of them skipped after the first catch-up. An event naming only the uri-specification and the moment, which a writer could skip only by taking the whole moment as its own, wrong when its write also changed a resource whose contents it lacks, as closing a ticket closes its note. Leaving the uri-specification to the subscription `id` in `X-MCP-Subscription-Id`, which each client would map back to its subscription. | -| D20 | A server accepts a uriTemplate it doesn't list when one it lists covers it, and MAY answer `notFound` for one it can't show is covered (R24). R24's pseudo-code checks it: write the client's template as a sample uri, with a stand-in for each expression, and match it as any uri. It never wrongly accepts, and it handles a listed template with part of it filled in, as a skill or a type is. | Listed templates only, which leaves a client that wants one skill, on a server listing `skill://{skill}/{+path}`, watching every skill, unless the server lists a template for each. An exact check, which must first settle what RFC 6570's expressions match, empty values and levels 3 and 4 included, then compare automata that can grow exponentially. | -| D21 | A server restored from a backup mints a moment past every one it may have sent, and answers nothing earlier, as etcd's restore does, and a start-over's pages replace the copies they name (R21, R23). | Leaving restores out, since R3 already forbids reusing a moment, which still leaves a well-restored server's clients keeping copies the backup lost. A client that drops its newer copies when a read's `nextFrom` is earlier than the moment it is caught up to, which misses a writer whose copy ran ahead. A guard on a `from` later than the latest moment alone, which catches a restore only until new writes pass the client's moment. | -| D22 | An event's `eventId` is the moment of its change (R10), the stable identifier SEP-3415 asks a server to use, since one change sends a subscription one event (D19). | The moment and the uri-specification, left from when one change sent a subscription an event for each resource, and repeating `data`. No rule, leaving the value to SEP-3415's SHOULD, so that another server could send any unique value. | -| D23 | Both methods carry `Mcp-Name` over Streamable HTTP, holding the `uri` or `uriTemplate` (R13, R22), as `resources/read` carries its `uri` and SEP-3415's methods their event's name, so a gateway can route a request by collection without parsing its body. | No header, as the strawman's four methods and SEP-2640's `resources/directory/read` carry none, which leaves a gateway to parse each body to learn the collection. | -| D24 | A server SHOULD keep history longer than the time it grants between refreshes (R21), so a quiet collection on a busy shared timeline costs no full read at each refresh, as SEP-3415's refresh keeps its cursor inside the upstream's retention window. | Answering from before the oldest moment when nothing in the collection changed, which works only for collections whose last change the server tracks. Bounding history by a read's length alone, which leaves such a collection re-read at every refresh. | -| D25 | R7 keeps its MUST that a `resources/read` result carry each item's revision in `_meta`, so a copy a client got from a core read, as a skills client gets `SKILL.md`, can be compared without reading it again. It binds only servers that declare this extension, and gives the strawman's open question about `resources/read` a use case. | R19's fallback reading through `resources/collection/read` alone, which needs no hook into core's read, but leaves copies from core reads without a revision. A SHOULD, as the strawman's "when the server can provide it", which gives R19 two routes. | +### Subscribing and events + +**D1. A client subscribes with SEP-3415's `events/subscribe`.** It subscribes with `events/subscribe` (R18), and catches up with `resources/collection/catchup`, which stores nothing (R13), so SEP-3415's is the one subscription lifecycle. A subscribe method of this extension's own, such as `resources/webhooks/subscribe`, would be a second lifecycle beside it, repeating what SEP-3415 already specifies for webhooks: verifying the callback, signing each delivery, refreshing and expiring subscriptions, and reporting `deliveryStatus`. Each client and gateway that already takes Events, such as those § *Why now?* names, would then need a second subscription API to watch resources. + +**D5. Events carry no contents.** An event names what changed and carries none of it (R10), so a lost, repeated or late one does no harm. A change then costs a subscriber an event and a catch-up, one round trip more than a delivery carrying the change, with about the same bytes. The writer pays nothing extra, and N changes before a catch-up cost N events and one catch-up. Deliveries that carry each change's contents or a patch would need a `fromVersion` to order them, and a resubscribe that drops the deliveries still queued. + +**D19. The extension sends an event of its own, one for each change on each subscription.** `mcp.resource.collection.updated` goes to each subscription covering a resource the change touched. Its `data` names the subscription's uri-specification, and lists the change's entries that the subscription covers (R10, R11). It leaves `mcp.resource.updated` to SEP-3415's Open Question 2. A server may merge undelivered events, with `truncated` left false, as Peter agreed for replacing an event with a newer one. Peter's `mcp.resource.updated`, with data of our own, would take a name Open Question 2 may define otherwise, for core's `notifications/resources/updated`, whose `uri` names the changed resource. One event for each resource changed would send a change touching 500 tickets as 500 deliveries, 499 of them skipped after the first catch-up. An event naming only the uri-specification and the moment would let a writer skip it only by taking the whole moment as its own, which is wrong when its write also changed a resource whose contents it lacks, as closing a ticket closes its note. Leaving the uri-specification to the subscription `id` in `X-MCP-Subscription-Id` would have each client map it back to its subscription. + +### Moments and revisions + +**D6. Moments are ordered.** Moments sort (R3, R5), so one string serves as a resource's version, its revision, and the moment a client is caught up to, at the cost of serializing each timeline's writes. Versions compared only for equality, as ETags are, would leave a client unable to tell which of two revisions is the newer, which R11's skip and R15 both need. + +**D17. "Moment" and "revision" stay two words.** § *Definitions* uses moment, latest moment, revision and caught up to. A moment is a point on a timeline, and a revision is a moment at which one resource changed, so both stay. One word for both would ask the strawman to rename its fields, and would leave "a resource's moment", "the timeline's moment" and "a client's moment" each meaning a different latest. "Current moment" gave way to "latest moment", since a timeline moves only when a change occurs. + +### Catching up + +**D2. A patch starts from `previousRevision`.** A catch-up's patch applies to the resource as it stood at the client's `from`, and a client whose copy is at another revision reads the resource instead (R19). Patching from each copy's own revision would need the server to learn every copy's revision, which a catch-up never sends. Sending contents only would give up much of the saving. + +**D4. A `from` too old gets `momentExpired`.** The client then starts over (R21), as Kubernetes answers a watch from a compacted `resourceVersion` with `410 Gone`, and core an expired cursor with `-32602`. A flag on an ordinary result carrying the whole current state, or SEP-3415's `truncated: true`, is a signal a client could miss, and so keep copies of resources since deleted. + +**D12. A catch-up's result is a plain result, and paces polling with `nextPollMs`.** A client that polls waits the result's `nextPollMs`, as in SEP-3415's poll mode (R13, R14). Core's `CacheableResult` would serve badly: its `ttlMs` is a freshness hint, which clients "SHOULD NOT treat … as a polling interval", and a cache keyed on the request could answer step 6's catch-up with step 3's empty result, since both send `from` `"107"`. A rule that a client never answers a catch-up from a cache would bind no gateway that follows core, and with no pacing field, a server would have no way to slow clients that poll. A collection read stays a `CacheableResult` (R22), since a cached read is an older snapshot whose `nextFrom` matches it. + +**D13. A long catch-up is a run of short ones.** A server may stop a catch-up short, bringing each resource as it stood where it stopped (R13, R14), so a long catch-up is a run of ordinary ones, as polls are in SEP-3415, and every patch applies in order. Four kinds of paging lost to it. Taking the last entry's `revision` as the moment the client is caught up to, beside a field holding the timeline's latest moment, gave the client two rules, and could not stop short with no changes. Pages bringing each resource at its revision cost a read for each resource changed on both sides of a page's end. Sending the first page's `from` again on every page, with a `cursor` beside it, needs both fields and a rule for which one a patch starts from. A fixed end sent back on every page, as `through`, cost a field and a catch-up after the last page. + +**D15. A catch-up never stops partway through a moment.** Stopping there would leave the client's copies matching no moment until its next catch-up. So a server runs a result long to finish a moment. Such a result is never larger than a start-over, which sends every resource in the collection, and a server that still can't finish one answers `momentTooLarge` (R14). Reusing `momentExpired` would tell a client its `from` had expired when it hadn't. SEP-3415's `ResourceExhausted`, whose code its Open Question 5 may fold into `-32602`, would give a client a second code for the one response, starting over. + +### Reading collections + +**D14. Reading a collection has a method of its own.** `resources/collection/read` takes no `from`, and pages with core's opaque `cursor` and `nextCursor`, as `resources/list` does (R22). A client reads the collection, subscribes, then catches up from the read's `nextFrom` (R18), so every catch-up names a `from`. A catch-up with no `from` would give one method two kinds of result, and need a `cursor` beside `from`, with an error for naming both. `resources/template/read` would need a sibling for uriGlobs, and reads like fetching the template itself. SEP-2640's `resources/directory/read` lists one directory's children without their contents. Subscribing before reading also works, but leaves a new client's events waiting while it pages. + +**D20. A client may name a template the server doesn't list, when a listed one covers it.** A server may answer `notFound` for one it can't show is covered (R24). R24's pseudo-code checks it by writing the client's template as a sample uri, with a stand-in for each expression, and matching that as any uri. The check never wrongly accepts, and it handles a listed template with part of it filled in, as a skill or a type is. Listed templates alone would leave a client that wants one skill, on a server listing `skill://{skill}/{+path}`, watching every skill, unless the server lists a template for each. An exact check would first have to settle what RFC 6570's expressions match, empty values and levels 3 and 4 included, then compare automata that can grow exponentially. + +**D25. A `resources/read` result MUST carry each item's revision.** R7 puts it in `_meta`, so a copy a client got from a core read, as a skills client gets `SKILL.md`, can be compared without reading it again. The MUST binds only servers that declare this extension, and gives the strawman's open question about `resources/read` a use case. R19's fallback could read through `resources/collection/read` alone, which needs no hook into core's read, but that would leave copies from core reads without a revision. A SHOULD, as the strawman's "when the server can provide it", would give R19 two routes. + +### Writes + +**D7. A client's own writes use the strawman's four methods.** Their replies carry the revision of each resource a write changed (R7, R17), which lets the writer skip the event its own write sends (R9, R11). A write made another way, such as through a server's tools, returns no revision a client can rely on, so its writer would catch up on its own change. Changes from anywhere else still land at moments and send events (R2, R10). ## Backward Compatibility @@ -406,7 +421,7 @@ This extension sends its events by SEP-3415's webhook delivery as it stands, so - **Events carry no contents** (R10), so a delivery that reaches the wrong receiver reveals uris and revisions, never what a resource holds. - **A catch-up or a collection read is authorized as a `resources/read`** of each resource it covers (R13, R22). Q6 proposes that events, catch-ups and reads cover only what the principal may read, and asks what happens when access changes while a resource doesn't. - **`nextFrom` moves with every change on a timeline** (R13), so it can tell a principal when resources it may not read change. Q6 says how a server avoids that. -- **Both methods carry their `uri` or `uriTemplate` in `Mcp-Name`** (R13, D23), where a gateway or proxy sees it, as core's `resources/read` carries its `uri`. +- **Both methods carry their `uri` or `uriTemplate` in `Mcp-Name`** (R13, R22), where a gateway or proxy sees it, as core's `resources/read` carries its `uri`. ## Reference Implementation @@ -415,7 +430,7 @@ This extension sends its events by SEP-3415's webhook delivery as it stands, so - **A, the server.** It serves tickets and notes, with the strawman's four methods for writes and zero-padded counters for moments. Clients register by Client ID Metadata Document. - **B, a gateway** in SEP-3415's forward-proxy deployment, which verifies each delivery and passes it to its client over a WebSocket. -A client library, `src/client/copies.ts`, keeps copies by the client's rules: R8, R9, R11, R12, R15, R16, R18, R19, R21 and R23. A serves two layouts in its tests. One puts tickets and notes on one timeline and also lists `{type}/{id}` (D9). The other gives each its own timeline, so closing a ticket closes its note at a moment on each, which breaks R4 on purpose, to test a write that commits once per timeline. The deployed Worker serves the first. +A client library, `src/client/copies.ts`, keeps copies by the client's rules: R8, R9, R11, R12, R15, R16, R18, R19, R21 and R23. A serves two layouts in its tests. One puts tickets and notes on one timeline and also lists `{type}/{id}`. The other gives each its own timeline, so closing a ticket closes its note at a moment on each, which breaks R4 on purpose, to test a write that commits once per timeline. The deployed Worker serves the first. ### Tested, and not yet tested From a4c27d256874d7be1602e025d037519e85ba1b84 Mon Sep 17 00:00:00 2001 From: Larry Maccherone Date: Wed, 4 Nov 2026 05:55:12 -0500 Subject: [PATCH 03/15] Put the rules' obligations in BCP 14 keywords Every rule now says what binds with MUST, SHOULD or MAY, and the Specification says it reads them as BCP 14 does. Where a keyword landed on something that doesn't act, such as a change or a reply, the sentence names the server or the client instead. R10's eventId is a SHOULD, as in SEP-3415. R8 folds into the catch-up definition, which now says a client is caught up on each uri-specification. D19 no longer says a merge leaves truncated false, since SEP-3415 settles that for a subscription with no replay. Co-Authored-By: Claude Opus 5.5 --- proposals/XXXX-resource-webhooks.md | 55 ++++++++++++++--------------- 1 file changed, 27 insertions(+), 28 deletions(-) diff --git a/proposals/XXXX-resource-webhooks.md b/proposals/XXXX-resource-webhooks.md index 0eb2e48..2d1ff4b 100644 --- a/proposals/XXXX-resource-webhooks.md +++ b/proposals/XXXX-resource-webhooks.md @@ -86,7 +86,7 @@ The Tasks and Events working group's [charter](https://modelcontextprotocol.io/c The server keeps the latest moment and mints each new one later (R5). An event names each resource that changed and its new revision, so a client that already holds that revision skips it, and a catch-up brings every change after the moment the client is caught up to. A database's read replica follows its primary's oplog the same way, and wherever it stops, it holds the data as of that point, which may be behind the primary. A catch-up differs in sending each resource once, as its contents or a patch from an earlier revision, rather than every operation, but the idea is the same. -Rules are numbered R1 and up across the sections below. Each keeps its ID when others are added or removed, so the IDs follow the order the rules were raised, not the order shown, and a gap is from a rule that was dropped, or that binds only how a server is built. +Rules are numbered R1 and up across the sections below. Each keeps its ID when others are added or removed, so the IDs follow the order the rules were raised, not the order shown, and a gap is from a rule that was dropped, or that binds only how a server is built. The key words MUST, MUST NOT, SHOULD and MAY are to be read as BCP 14 ([RFC 2119](https://www.rfc-editor.org/rfc/rfc2119), [RFC 8174](https://www.rfc-editor.org/rfc/rfc8174)) defines them when, and only when, they appear in capitals. ### Definitions @@ -102,7 +102,7 @@ Rules are numbered R1 and up across the sections below. Each keeps its ID when o | revision | A moment at which a resource changed, which the Filesystems strawman's fields `revision` and `previousRevision` hold. A resource's revision, with nothing more said, is its latest, as `resources/stat` and `resources/read` report it (R7). | ticket-3's `"101"`, and `"108"` from step 4 | | tombstone | What a deleted resource leaves: its uri and the moment it was deleted. | ticket-3, deleted at `"111"` | | copy | The contents a client last received or wrote for a resource, with the resource's revision then. | ticket-3's JSON at `"101"` | -| catch-up | A `resources/collection/catchup` call and its result: every change to a uri-specification after the moment the client is caught up to, up to the result's `nextFrom`. A client is caught up to the last `nextFrom` it applied in full, and holds every change at or before it. | steps 3 and 6 below | +| catch-up | A `resources/collection/catchup` call and its result: every change to a uri-specification after the moment the client is caught up to, up to the result's `nextFrom`. A client is caught up, on each uri-specification, to the last `nextFrom` it applied in full for it, and holds every change to it at or before that. | steps 3 and 6 below | ### A simple happy path @@ -131,9 +131,8 @@ Each client now holds every ticket, and is caught up to `"107"`, the read's `nex | ticket-3 | `"101"`, open | **`"101"`, open** | **`"101"`, open** | | ticket-7 and ticket-9 | `"107"` | **`"107"`** | **`"107"`** | -- **R22.** `resources/collection/read` names a uri-specification as a catch-up does, in its params and in `Mcp-Name`, gets the same errors for it, and is authorized the same way (R13), but takes no `from`. It returns the contents of each resource in the collection, each item as `resources/read` returns it, with its revision in `_meta` (R7), and `nextFrom`, the timeline's latest moment, with each resource as it stood at `nextFrom` or later. A long result comes in pages, as core's `resources/list` does: the server returns an opaque `nextCursor`, and the client sends it back as `cursor`. Every page repeats the first page's `nextFrom`. A resource that doesn't change while the client pages MUST come exactly once. The client catches up from `nextFrom` afterwards, which brings whatever changed while it paged, deletions included. Its result is a core `CacheableResult`. - - A uri on its own is a collection of one, and a client watching one reads it with `resources/collection/read` too, rather than `resources/read`, since only a collection read brings a `nextFrom` to catch up from. A copy's revision can be older than the history the server keeps, so a catch-up from it could get `momentExpired` (R21), while `nextFrom` is the timeline's latest moment. -- **R8.** A client keeps, for each uri-specification it catches up on, the moment it is caught up to, and each copy's revision. +- **R22.** `resources/collection/read` names a uri-specification as a catch-up does, in its params and in `Mcp-Name`, gets the same errors for it, and is authorized the same way (R13), but takes no `from`. It returns the contents of each resource in the collection, each item as `resources/read` returns it, with its revision in `_meta` (R7), and `nextFrom`, the timeline's latest moment. Each resource MUST be as it stood at `nextFrom` or later. A long result comes in pages, as core's `resources/list` does: the server returns an opaque `nextCursor`, and the client sends it back as `cursor`. Every page MUST repeat the first page's `nextFrom`. A resource that doesn't change while the client pages MUST come exactly once. The client catches up from `nextFrom` afterwards, which brings whatever changed while it paged, deletions included. Its result is a core `CacheableResult`. + - A uri on its own is a collection of one, and a client watching one SHOULD read it with `resources/collection/read` too, rather than `resources/read`, since only a collection read brings a `nextFrom` to catch up from. A copy's revision can be older than the history the server keeps, so a catch-up from it could get `momentExpired` (R21), while `nextFrom` is the timeline's latest moment. #### Step 2, at `"107"`: X and Y subscribe @@ -149,7 +148,7 @@ Each client subscribes through SEP-3415, naming the uri-specification as the eve A now holds a subscription for each client, and nothing else changes. -- **R18.** A client subscribes with `events/subscribe`, naming `mcp.resource.collection.updated` and a uri-specification as `arguments`: `{uri}`, `{uriTemplate}`, or later `{uriGlob}`, and refreshes the subscription as SEP-3415 says. A client new to the uri-specification first reads its collection (R22). Once subscribed, it catches up, so no change falls between the read and the subscription. +- **R18.** A client subscribes with `events/subscribe`, naming `mcp.resource.collection.updated` and a uri-specification as `arguments`: `{uri}`, `{uriTemplate}`, or later `{uriGlob}`, and refreshes the subscription as SEP-3415 says. A client new to the uri-specification SHOULD first read its collection (R22). Once subscribed, it MUST catch up, so no change falls between the read and the subscription. #### Step 3, at `"107"`: X and Y catch up @@ -195,7 +194,7 @@ A POSTs the event to each subscription's webhook. It names the subscription's ur "previousRevision": "101", "revision": "108"}]}} ``` -- **R10.** Once a change is visible to a catch-up, the server MUST send one `mcp.resource.collection.updated` to each subscription covering any resource it touched. Its `data` repeats the subscription's `arguments`, naming its uri-specification, and holds `changes`, the strawman's change-set entry for each covered resource the change touched, `revision` always present. A change whose entries would take the body past the 256 KiB SEP-3415 asks for leaves `changes` out. Its `eventId` is the moment of its change, and SEP-3415's `cursor` stays null. A server MAY merge undelivered events for one subscription into the newest, whose `changes` then hold each uri once, as a catch-up's do (R19). +- **R10.** Once a change is visible to a catch-up, the server MUST send one `mcp.resource.collection.updated` to each subscription covering any resource it touched. Its `data` repeats the subscription's `arguments`, naming its uri-specification, and holds `changes`, the strawman's change-set entry for each covered resource the change touched, `revision` always present. When a change's entries would take the body past the 256 KiB SEP-3415 asks for, the server SHOULD leave `changes` out. Its `eventId` SHOULD be the moment of its change, and SEP-3415's `cursor` MUST be null. A server MAY merge undelivered events for one subscription into the newest, whose `changes` then hold each uri once, as a catch-up's do (R19). #### Step 6, at `"108"`: X catches up @@ -222,12 +221,12 @@ X's copy is at `"101"`, the patch's `previousRevision`, so X applies it (R19), a | ticket-7 and ticket-9 | `"107"` | `"107"` | `"107"` | - **R11.** When a client receives `mcp.resource.collection.updated`, it MUST catch up on the uri-specification the event names, unless it already holds each uri in `changes`, or its tombstone, at that entry's `revision` or later, which lets the writer skip its own change. An event with no `changes` always brings a catch-up. -- **R13.** A catch-up names a uri-specification, as `uri` or `uriTemplate`, the moment the client is caught up to as `from`, and any patch formats it applies as `patchMimeTypes`. Over Streamable HTTP, its request carries `Mcp-Name` holding that `uri` or `uriTemplate`, as `resources/read`'s carries its `uri`. It stores nothing, needs no subscription, and is authorized as a `resources/read` of each covered resource. A catch-up's `changes` MUST hold every covered resource changed after `from` and at or before its `nextFrom`, as it stood at `nextFrom`, a deleted one as a tombstone, so a resource missing from them did not change in that span. With `hasMore` false, its `nextFrom` is no earlier than any moment on its timeline that the server had sent when it was read. A client that polls waits the result's `nextPollMs` before its next catch-up, with a floor, as in SEP-3415's poll mode (D12). - - A uri-specification on a timeline gets the timeline's latest moment as its `nextFrom` before any resource it covers exists. One the server neither lists nor covers (R24) gets `InvalidParams` (`-32602`) with `data.reason` `"notFound"`. A catch-up with no `from` gets `InvalidParams` too. +- **R13.** A catch-up names a uri-specification, as `uri` or `uriTemplate`, the moment the client is caught up to as `from`, and any patch formats it applies as `patchMimeTypes`. Over Streamable HTTP, its request MUST carry `Mcp-Name` holding that `uri` or `uriTemplate`, as `resources/read`'s carries its `uri`. It stores nothing and needs no subscription, and the server MUST authorize it as a `resources/read` of each covered resource. A catch-up's `changes` MUST hold every covered resource changed after `from` and at or before its `nextFrom`, as it stood at `nextFrom`, a deleted one as a tombstone, so a resource missing from them did not change in that span. With `hasMore` false, its `nextFrom` MUST be no earlier than any moment on its timeline that the server had sent when it was read. A client that polls SHOULD wait the result's `nextPollMs` before its next catch-up, with a floor, as in SEP-3415's poll mode (D12). + - A uri-specification on a timeline gets the timeline's latest moment as its `nextFrom` before any resource it covers exists. The server MUST answer `InvalidParams` (`-32602`) to a catch-up with no `from`, and to one naming a uri-specification it neither lists nor covers (R24), with `data.reason` `"notFound"`. ### Timelines and moments -A timeline's scope is the server's choice, and no client sees it: a client subscribes, catches up and keeps the moment it is caught up to for each uri-specification (R8). Each of the two examples below serves tickets and notes. The names `tickets`, `notes` and `work` are the server's own. +A timeline's scope is the server's choice, and no client sees it: a client subscribes, catches up and keeps the moment it is caught up to for each uri-specification. Each of the two examples below serves tickets and notes. The names `tickets`, `notes` and `work` are the server's own. In the first example below, `tickets/{id}` and `notes/{id}` each have a timeline of their own, so writes to one never wait on the other (R5), and a client watching both subscribes and catches up on each. § *A simple happy path* starts from this one, at `"107"`: @@ -266,19 +265,19 @@ timelines: A catch-up on `{type}/{id}` brings ticket-9 and its note as they stood at one moment. Separate catch-ups on `tickets/{id}` and `notes/{id}` can leave a client holding ticket-9 closed and its note still open until the second one. -- **R1.** A server keeps one or more timelines, from one for everything it serves to one for each uri-specification it lists. Every resource a uri-specification covers is on that uri-specification's timeline, so uri-specifications that cover a resource in common MUST share one, and each resource is on exactly one timeline. Example: `tickets/{id}` and `{kind}/ticket-3` both cover `tickets/ticket-3`, so they share a timeline though neither contains the other. A server assigns timelines over every uri-specification it may ever list to anyone, since listings change and differ by who asks. -- **R2.** Every change to a resource, its creation and deletion included, happens at a new moment on its timeline, which becomes the timeline's latest moment and the resource's revision. +- **R1.** A server keeps one or more timelines, from one for everything it serves to one for each uri-specification it lists. Every resource a uri-specification covers is on that uri-specification's timeline, so uri-specifications that cover a resource in common MUST share one, and each resource is on exactly one timeline. Example: `tickets/{id}` and `{kind}/ticket-3` both cover `tickets/ticket-3`, so they share a timeline though neither contains the other. A server assigns timelines over every uri-specification it could ever list to anyone, since listings change and differ by who asks. +- **R2.** Every change to a resource, its creation and deletion included, MUST happen at a new moment on its timeline, which becomes the timeline's latest moment and the resource's revision. - **R3.** Moments on a timeline MUST strictly increase in byte order: ULIDs, or a counter zero-padded to a fixed width, but ISO timestamps only at a precision finer than the time between any two changes. - Kubernetes works the same way: an object's `resourceVersion` names the version it last changed at, a client watches a collection for changes after one, and an update fails if it has moved on. [KEP-5504](https://www.kubernetes.dev/resources/keps/5504/), stable in v1.35, made them orderable within one resource type, as moments are within one timeline, where clients had compared them only for equality, as ETags compare. -- **R4.** A change that touches several resources in one atomic step gives them one moment, so resources that can change together share a timeline. +- **R4.** A server MUST give one moment to a change that touches several resources in one atomic step, so resources that can change together share a timeline. - **R5.** Changes on a timeline MUST commit in the order of their moments, so once a client has seen a moment, no change at or before it can still appear: SEP-3415's "safe-to-persist watermark", held by a timeline instead of a subscription. A server checks that each new moment is later than the timeline's latest one, and commits, in one atomic step: for example, in Postgres at read committed, with `UPDATE timelines SET latest = $new WHERE id = $timeline AND latest < $new` in byte order (`COLLATE "C"`), minting again when no row changes. A timeline per server serializes all its writes, and one per uri-specification only that one's. -- **R7.** Each of a resource's revisions, in the strawman's sense, MUST be the moment of that change, so `ifMatch` checks the latest, and this extension's clients may also order revisions. Every change-set entry in a write's result carries `revision`, a deletion's included. A `resources/read` result MUST carry each item's revision in `_meta`, under the strawman's provisional key, `io.modelcontextprotocol/resource-revision`. +- **R7.** Each of a resource's revisions, in the strawman's sense, MUST be the moment of that change, so `ifMatch` checks the latest, and this extension's clients can also order revisions. Every change-set entry in a write's result MUST carry `revision`, a deletion's included. A `resources/read` result MUST carry each item's revision in `_meta`, under the strawman's provisional key, `io.modelcontextprotocol/resource-revision`. ### What a catch-up brings -- **R19.** Each entry in `changes` is one resource's change from `from` to the catch-up's `nextFrom` (R13): the strawman's change-set entry, with contents added. `previousRevision` is the resource's revision at `from`, absent if it did not exist then. `effect` is `deleted` if the change ends with the resource gone, else `created` if it did not exist at `from`, else `updated`. A non-deletion carries `contents`, one `TextResourceContents` or `BlobResourceContents`, or a `patch` from the contents at `previousRevision`, with its `patchMimeType`, in a listed format that applies. A merge patch ([RFC 7396](https://www.rfc-editor.org/rfc/rfc7396)) applies to one `application/json` text entry, and patches its parsed `text`. JSON-based patches travel as JSON, others as strings. The server sends `contents` when no listed format applies, when there is no `previousRevision`, or when it no longer keeps those contents, and MAY when `contents` is smaller. - - A client applies a patch only to a copy at its `previousRevision`, and otherwise reads the resource (D2). -- **R14.** A server may stop a catch-up short of the timeline's latest moment, to keep its result small. Its `nextFrom` is then where it stopped, never partway through one moment's changes, and `hasMore` is true, as in SEP-3415's poll mode. It may stop short with no changes, as after scanning many changes to another collection on a shared timeline. The client applies the changes, and is caught up to `nextFrom`. While `hasMore` is true, it catches up again at once, ignoring `nextPollMs`, as SEP-3415's poll mode does. A catch-up may run past the server's usual size to finish a moment. A server that can't send one moment's changes in a single result answers `InvalidParams` (`-32602`) with `data.reason` `"momentTooLarge"`, and the client starts over (R23), since a collection read pages freely and the catch-up after it starts past that moment. +- **R19.** Each entry in `changes` is one resource's change from `from` to the catch-up's `nextFrom` (R13): the strawman's change-set entry, with contents added. `previousRevision` is the resource's revision at `from`, absent if it did not exist then. `effect` is `deleted` if the change ends with the resource gone, else `created` if it did not exist at `from`, else `updated`. A non-deletion MUST carry `contents`, one `TextResourceContents` or `BlobResourceContents`, or a `patch` from the contents at `previousRevision`, with its `patchMimeType`, in a listed format that applies. A merge patch ([RFC 7396](https://www.rfc-editor.org/rfc/rfc7396)) applies to one `application/json` text entry, and patches its parsed `text`. JSON-based patches travel as JSON, others as strings. The server MUST send `contents` when no listed format applies, when there is no `previousRevision`, or when it no longer keeps those contents, and MAY send them whenever they're smaller than the patch. + - A client MUST apply a patch only to a copy at its `previousRevision`. Otherwise it reads the resource (D2). +- **R14.** A server MAY stop a catch-up short of the timeline's latest moment, to keep its result small. It MUST NOT stop partway through one moment's changes, and when it stops short it MUST set `hasMore` true, with `nextFrom` where it stopped, as in SEP-3415's poll mode. It MAY stop short with no changes, as after scanning many changes to another collection on a shared timeline. The client applies the changes, and is caught up to `nextFrom`. While `hasMore` is true, it SHOULD catch up again at once, ignoring `nextPollMs`, as SEP-3415's poll mode does. A catch-up MAY run past the server's usual size to finish a moment. A server that can't send one moment's changes in a single result MUST answer `InvalidParams` (`-32602`) with `data.reason` `"momentTooLarge"`, and the client starts over (R23), since a collection read pages freely and the catch-up after it starts past that moment. - Each catch-up leaves the client's copies as they stood at its `nextFrom`, so the next one's patches start where the copies are, as a read replica's do (§ *Specification*). Example: X is caught up to `"100"` and holds ticket-5 at `"090"`, and ticket-5 changes at `"102"` and again at `"104"`. A catch-up that stops at `"102"` brings ticket-5 as it stood then, with a patch from `"090"` to `"102"`, and the next brings a patch from `"102"` to `"104"`. Sent as `contents` instead, ticket-5 would travel twice. A catch-up that reaches the timeline's latest moment brings each resource once, at its revision. - Kubernetes reads every page of a list from one snapshot, so its `continue` token expires, after five minutes by default. A catch-up that stops short needs no snapshot, since the next is an ordinary catch-up, which expires only as any `from` does (R21). @@ -301,7 +300,7 @@ X then catches up from `"107"`: // X's copy is at "109", not "101", so X reads ticket-3 with resources/read. ``` -- **R9.** A client that writes a resource makes what it sent its copy, at the revision the reply gives that resource (R17). It takes nothing else from the reply. The reply lists every resource the write changed, such as a note closed along with its ticket, but holds no contents for the others, so the client gets them from a catch-up. Nor does the reply move what the client is caught up to. Example: X is caught up to `"107"`. Y updates ticket-9 at `"108"`, then X updates ticket-3 at `"109"`. Had X counted itself caught up to `"109"`, it would never fetch ticket-9's change. +- **R9.** A client that writes a resource makes what it sent its copy, at the revision the reply gives that resource (R17). It takes nothing else from the reply. The reply lists every resource the write changed, such as a note closed along with its ticket, but holds no contents for the others, so the client gets them from a catch-up. The client MUST NOT move what it is caught up to on a reply. Example: X is caught up to `"107"`. Y updates ticket-9 at `"108"`, then X updates ticket-3 at `"109"`. Had X counted itself caught up to `"109"`, it would never fetch ticket-9's change. **Messages that cross.** @@ -315,12 +314,12 @@ A -> X catch-up: ticket-3 deleted at "111" X keeps the tombstone A -> X reply: ticket-3 at "109" earlier than "111", so ignored ``` -- **R15.** A write's reply, a catch-up's entry or a tombstone changes a copy only when its revision is later than the copy's, and what a client is caught up to only moves forward, so messages may arrive in any order. -- **R16.** A client runs one catch-up or collection read per uri-specification at a time, counting the catch-ups that follow while `hasMore` is true as one. An event that arrives meanwhile waits until they end, and the client then applies R11 to it. +- **R15.** A client MUST ignore a write's reply, a catch-up's entry or a tombstone whose revision is no later than its copy's, and MUST NOT move what it is caught up to backward, so messages can arrive in any order. +- **R16.** A client SHOULD run one catch-up or collection read per uri-specification at a time, counting the catch-ups that follow while `hasMore` is true as one. An event that arrives meanwhile waits until they end, and the client then applies R11 to it. **Missed events.** -- **R12.** A client SHOULD catch up on each uri-specification it subscribed to whenever it may have missed events: after each SEP-3415 refresh, on reconnecting to its gateway or a `subscriptions/listen` stream, on starting, and on a `gap` envelope. A subscription with no expiry still gets SEP-3415's occasional refresh. +- **R12.** A client SHOULD catch up on each uri-specification it subscribed to whenever it might have missed events: after each SEP-3415 refresh, on reconnecting to its gateway or a `subscriptions/listen` stream, on starting, and on a `gap` envelope. A subscription with no expiry still gets SEP-3415's occasional refresh. - A client can miss events without knowing, as a backend can when its webhook endpoint is down while the process holding its copies stays up. SEP-3415 retries each event, so a short outage loses none, and a missed event costs nothing once another event brings a catch-up, which brings every change after the moment the client is caught up to. A quiet collection stays stale at most until the next refresh, whose `deliveryStatus` says whether deliveries had been failing, so such a client sets its refresh interval to the staleness it can tolerate, even when granted no expiry. **A template the server doesn't list.** A client that loaded `skill://code-review/SKILL.md` also wants the skill's other files, such as `skill://code-review/scripts/lint.py`, from a server that lists one template, `skill://{skill}/{+path}`, for all its skills. The client subscribes to `skill://code-review/{+path}`, and the server accepts it, since the listed template matches every uri the client's could. To check, the server turns the client's template into a sample uri and matches it against the listed template, as this pseudo-code does: @@ -344,21 +343,21 @@ covers(listed, clientTemplate): | `{type}/{id}` | `tickets/{id}` | `tickets/SEG` | Yes: `{type}` matches `tickets`, and `{id}` matches `SEG` | | `tickets/{id}` | `tickets/{+rest}` | `tickets/ANY` | No: `{id}` holds one path segment, but `ANY` may stand for several, such as `a/b`, so the server answers `notFound` | -- **R24.** A client may name a uriTemplate the server doesn't list, in a subscription, a catch-up or a collection read, as long as a template the server lists matches every uri the client's could. The client's template then shares the listed one's timeline (R1). A server may check as the pseudo-code and table above show, and answers `notFound` when it can't tell. +- **R24.** A client MAY name a uriTemplate the server doesn't list, in a subscription, a catch-up or a collection read, as long as a template the server lists matches every uri the client's could. The client's template then shares the listed one's timeline (R1). A server MAY check as the pseudo-code and table above show, and MUST answer `notFound` when it can't tell. **Starting over.** A client caught up to a moment too old (R21), or whose catch-up meets a moment too large for one result (R14), reads the collection again, as a new client does (R18). -- **R23.** A client that held copies before reading a collection replaces each one the read's pages name, even with an earlier revision, as after a restore (R21), and drops each one in that collection that neither the read's pages nor the catch-ups after them named, if its revision is at or before the last catch-up's `nextFrom`. +- **R23.** A client that held copies before reading a collection MUST replace each one the read's pages name, even with an earlier revision, as after a restore (R21). It MUST also drop each one in that collection that neither the read's pages nor the catch-ups after them named, if its revision is at or before the last catch-up's `nextFrom`. **A moment too old to answer from.** -- **R21.** A server keeps the moment of every change, deletions included, back to a moment of its choosing, and may drop old contents at any time. A catch-up whose `from` is earlier than that gets `InvalidParams` (`-32602`) with `data.reason` `"momentExpired"`, and the client starts over (R23, D4). A server restored from a backup first mints a moment past every one it may have sent, and makes it the oldest it answers from, as etcd's `snapshot restore` does with `--bump-revision` and `--mark-compacted`. A server SHOULD keep that history longer than the time it grants between refreshes, and longer than a read of its largest collection takes, so that a client can catch up at each refresh (R12) and after reading any collection. +- **R21.** A server MUST keep the moment of every change, deletions included, back to a moment of its choosing, and MAY drop old contents at any time. It MUST answer a catch-up whose `from` is earlier than that with `InvalidParams` (`-32602`) and `data.reason` `"momentExpired"`, and the client starts over (R23, D4). A server restored from a backup MUST first mint a moment past every one it might have sent, and make it the oldest it answers from, as etcd's `snapshot restore` does with `--bump-revision` and `--mark-compacted`. A server SHOULD keep that history longer than the time it grants between refreshes, and longer than a read of its largest collection takes, so that a client can catch up at each refresh (R12) and after reading any collection. **Resources a client may not read.** Q6 asks what a client sees of them, and proposes an answer. ### Declaring the extension -- **R20.** A server declares this extension in `capabilities.extensions` under a provisional identifier, `io.modelcontextprotocol/resource-webhooks`, as SEP-2133 asks, which commits it to `resources/collection/read`, to `resources/collection/catchup`, and to listing `mcp.resource.collection.updated` in `events/list` with webhook delivery. The extension reserves the prefix `resources/collection/` for its methods, beside core's and the strawman's under `resources/`. The event's name waits on Open Question 2's decision about an `mcp.*` prefix. +- **R20.** A server declares this extension in `capabilities.extensions` under a provisional identifier, `io.modelcontextprotocol/resource-webhooks`, as SEP-2133 asks. A server that declares it MUST serve `resources/collection/read` and `resources/collection/catchup`, and list `mcp.resource.collection.updated` in `events/list` with webhook delivery. The extension reserves the prefix `resources/collection/` for its methods, beside core's and the strawman's under `resources/`. The event's name waits on Open Question 2's decision about an `mcp.*` prefix. ### uriGlob, later @@ -378,7 +377,7 @@ Each decision keeps the number the rules cite it by. **D5. Events carry no contents.** An event names what changed and carries none of it (R10), so a lost, repeated or late one does no harm. A change then costs a subscriber an event and a catch-up, one round trip more than a delivery carrying the change, with about the same bytes. The writer pays nothing extra, and N changes before a catch-up cost N events and one catch-up. Deliveries that carry each change's contents or a patch would need a `fromVersion` to order them, and a resubscribe that drops the deliveries still queued. -**D19. The extension sends an event of its own, one for each change on each subscription.** `mcp.resource.collection.updated` goes to each subscription covering a resource the change touched. Its `data` names the subscription's uri-specification, and lists the change's entries that the subscription covers (R10, R11). It leaves `mcp.resource.updated` to SEP-3415's Open Question 2. A server may merge undelivered events, with `truncated` left false, as Peter agreed for replacing an event with a newer one. Peter's `mcp.resource.updated`, with data of our own, would take a name Open Question 2 may define otherwise, for core's `notifications/resources/updated`, whose `uri` names the changed resource. One event for each resource changed would send a change touching 500 tickets as 500 deliveries, 499 of them skipped after the first catch-up. An event naming only the uri-specification and the moment would let a writer skip it only by taking the whole moment as its own, which is wrong when its write also changed a resource whose contents it lacks, as closing a ticket closes its note. Leaving the uri-specification to the subscription `id` in `X-MCP-Subscription-Id` would have each client map it back to its subscription. +**D19. The extension sends an event of its own, one for each change on each subscription.** `mcp.resource.collection.updated` goes to each subscription covering a resource the change touched. Its `data` names the subscription's uri-specification, and lists the change's entries that the subscription covers (R10, R11). It leaves `mcp.resource.updated` to SEP-3415's Open Question 2. A server may merge undelivered events, as Peter agreed for replacing an event with a newer one. Peter's `mcp.resource.updated`, with data of our own, would take a name Open Question 2 may define otherwise, for core's `notifications/resources/updated`, whose `uri` names the changed resource. One event for each resource changed would send a change touching 500 tickets as 500 deliveries, 499 of them skipped after the first catch-up. An event naming only the uri-specification and the moment would let a writer skip it only by taking the whole moment as its own, which is wrong when its write also changed a resource whose contents it lacks, as closing a ticket closes its note. Leaving the uri-specification to the subscription `id` in `X-MCP-Subscription-Id` would have each client map it back to its subscription. ### Moments and revisions @@ -402,7 +401,7 @@ Each decision keeps the number the rules cite it by. **D14. Reading a collection has a method of its own.** `resources/collection/read` takes no `from`, and pages with core's opaque `cursor` and `nextCursor`, as `resources/list` does (R22). A client reads the collection, subscribes, then catches up from the read's `nextFrom` (R18), so every catch-up names a `from`. A catch-up with no `from` would give one method two kinds of result, and need a `cursor` beside `from`, with an error for naming both. `resources/template/read` would need a sibling for uriGlobs, and reads like fetching the template itself. SEP-2640's `resources/directory/read` lists one directory's children without their contents. Subscribing before reading also works, but leaves a new client's events waiting while it pages. -**D20. A client may name a template the server doesn't list, when a listed one covers it.** A server may answer `notFound` for one it can't show is covered (R24). R24's pseudo-code checks it by writing the client's template as a sample uri, with a stand-in for each expression, and matching that as any uri. The check never wrongly accepts, and it handles a listed template with part of it filled in, as a skill or a type is. Listed templates alone would leave a client that wants one skill, on a server listing `skill://{skill}/{+path}`, watching every skill, unless the server lists a template for each. An exact check would first have to settle what RFC 6570's expressions match, empty values and levels 3 and 4 included, then compare automata that can grow exponentially. +**D20. A client may name a template the server doesn't list, when a listed one covers it.** A server answers `notFound` for one it can't show is covered (R24). R24's pseudo-code checks it by writing the client's template as a sample uri, with a stand-in for each expression, and matching that as any uri. The check never wrongly accepts, and it handles a listed template with part of it filled in, as a skill or a type is. Listed templates alone would leave a client that wants one skill, on a server listing `skill://{skill}/{+path}`, watching every skill, unless the server lists a template for each. An exact check would first have to settle what RFC 6570's expressions match, empty values and levels 3 and 4 included, then compare automata that can grow exponentially. **D25. A `resources/read` result MUST carry each item's revision.** R7 puts it in `_meta`, so a copy a client got from a core read, as a skills client gets `SKILL.md`, can be compared without reading it again. The MUST binds only servers that declare this extension, and gives the strawman's open question about `resources/read` a use case. R19's fallback could read through `resources/collection/read` alone, which needs no hook into core's read, but that would leave copies from core reads without a revision. A SHOULD, as the strawman's "when the server can provide it", would give R19 two routes. @@ -430,7 +429,7 @@ This extension sends its events by SEP-3415's webhook delivery as it stands, so - **A, the server.** It serves tickets and notes, with the strawman's four methods for writes and zero-padded counters for moments. Clients register by Client ID Metadata Document. - **B, a gateway** in SEP-3415's forward-proxy deployment, which verifies each delivery and passes it to its client over a WebSocket. -A client library, `src/client/copies.ts`, keeps copies by the client's rules: R8, R9, R11, R12, R15, R16, R18, R19, R21 and R23. A serves two layouts in its tests. One puts tickets and notes on one timeline and also lists `{type}/{id}`. The other gives each its own timeline, so closing a ticket closes its note at a moment on each, which breaks R4 on purpose, to test a write that commits once per timeline. The deployed Worker serves the first. +A client library, `src/client/copies.ts`, keeps copies by the client's rules: R9, R11, R12, R15, R16, R18, R19, R21 and R23. A serves two layouts in its tests. One puts tickets and notes on one timeline and also lists `{type}/{id}`. The other gives each its own timeline, so closing a ticket closes its note at a moment on each, which breaks R4 on purpose, to test a write that commits once per timeline. The deployed Worker serves the first. ### Tested, and not yet tested From eb49d8ab523972418e686c15f48f21e6e9889ef0 Mon Sep 17 00:00:00 2001 From: Larry Maccherone Date: Wed, 4 Nov 2026 06:11:44 -0500 Subject: [PATCH 04/15] Trim the Motivation to what the working group doesn't already know The outline, the four problems and the use cases stay. Explanations the group shares, such as why long streams and polling hurt and what webhook delivery is for, become shorthand. Problem 4 now leads with the missing collection subscription, as Larry asked, and the closing map's answer to it matches. Co-Authored-By: Claude Opus 5.5 --- proposals/XXXX-resource-webhooks.md | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/proposals/XXXX-resource-webhooks.md b/proposals/XXXX-resource-webhooks.md index 2d1ff4b..48c2930 100644 --- a/proposals/XXXX-resource-webhooks.md +++ b/proposals/XXXX-resource-webhooks.md @@ -34,45 +34,45 @@ This proposal builds on two drafts, a working group's problem statements and one **MCP clients want an *efficient* way to keep local copies of resources up to date in near *real time*.** A webhook delivers each change without the client holding a connection open. -Keeping a copy current needs only the newest value, though, not every event on the way to it, so it calls for a different contract than a stream of events, as § *SEP-3415's open question 2* argues: here, an event only says that something changed, and a catch-up brings the newest value. It also differs from the state sync needs of tasks in other ways, which § *Why is this different from task state sync?* covers. +Keeping a copy current needs only the newest value, though, not every event on the way to it, so it calls for a different contract than a stream of events, as § *SEP-3415's open question 2* argues. It differs from keeping task state in sync too, as § *Why is this different from task state sync?* says. A client watching resources today has four problems: -1. **It must hold a stream open or poll.** Each has its own shortcomings: - - **Streaming.** `notifications/resources/updated` arrives only on a `subscriptions/listen` stream, since [SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575). A long-lived stream fits load-balanced and edge infrastructure poorly: proxies close idle connections, edge and serverless platforms cap how long a request may run, and a deploy or scale-in drops every stream an instance holds. - - **Polling** trades ***near real time*** against ***efficiency***. A long polling period means the client's state lags far behind. A short polling period spends a request per resource per interval, mostly to learn that nothing changed. -2. **It cannot tell whether it already holds the latest value.** The notification names the URI and nothing else, so each one costs a read, even when the client already holds the change. A ticket edited three times in a second costs three reads, though a read made after the third edit already brought the newest value, and the client that made an edit reads it back. -3. **Catching up after a disconnect is inefficient.** Since 2026-07-28 a broken stream cannot resume, so a change made while it is down is lost without a trace. A client back from a disconnect cannot tell which resources changed while it was away, so it must read every resource it watches again. -4. **It can watch only the resources it already knows.** `subscriptions/listen` takes resource URIs, in `resourceSubscriptions`, and no templates. So a client watching a collection subscribes to each member it knows, and hears of a new one, if at all, only through `notifications/resources/list_changed`, which covers the server's whole list and names no resource. +1. **It must hold a stream open or poll.** + - **Streaming.** Since [SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575), `notifications/resources/updated` arrives only on a `subscriptions/listen` stream, which load-balanced, edge and serverless infrastructure handles poorly. + - **Polling.** The polling interval is an uncomfortable trade between ***efficiency*** and ***near real time***. +2. **It can't tell whether it already holds the latest value.** The notification names only the URI, so each one costs a read, even for the client that made the change. +3. **Catching up after a disconnect is inefficient.** Since 2026-07-28 a broken stream can't resume, so a client back from a disconnect must read every resource it watches again. +4. **It can't subscribe to a collection.** `subscriptions/listen` takes resource URIs and no templates, so a client subscribes to every uri individually, and hears of a new one, if at all, only through `notifications/resources/list_changed`, which itself names no resource. ### SEP-3415's open question 2 -SEP-3415's Open Question 2 sketches a minimal implementation of resource subscription over Events, using "a reserved `mcp.resource.updated` event type whose arguments are `{uri}`", to re-express core's `notifications/resources/updated`, which names one changed resource. This extension leaves that event to Open Question 2, and sends one of its own, `mcp.resource.collection.updated`, which names a collection and every resource in it that one change touched (D19). +SEP-3415's Open Question 2 sketches a minimal resource subscription over Events, with "a reserved `mcp.resource.updated` event type whose arguments are `{uri}`", re-expressing core's `notifications/resources/updated`, which names one changed resource. This extension leaves that event to Open Question 2, and sends its own, `mcp.resource.collection.updated`, naming a collection and every resource in it that one change touched (D19). -**However, a resource is state, and a watcher cares only about the latest version, not every change.** A receiver that was down while a ticket was edited, reopened and closed needs only the closed ticket. On SEP-3415's thread on 2026-10-16, Peter Alexander (@pja-ant) [wrote](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3415#issuecomment-6215654779) that watching state and consuming messages "will need different handling and different APIs". The two "should likely share a bunch of the same machinery", he went on, but whether they should share an API "requires some thought and discussion". Here the event, delivered by webhook, is the shared machinery, and carries no state, only which resources changed and at which moment. The different API is the catch-up, which brings the newest values. SEP-3415 itself points this way: servers "SHOULD keep event payloads minimal", agents that need stronger guarantees should "use tools to read authoritative state", and servers "MAY implement resource watching through events". +**However, a resource is state, and a watcher cares only about the latest version, not every change.** A receiver that was down while a ticket was edited, reopened and closed needs only the closed ticket. On SEP-3415's thread on 2026-10-16, Peter Alexander (@pja-ant) [wrote](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3415#issuecomment-6215654779) that watching state and consuming messages "will need different handling and different APIs". The two "should likely share a bunch of the same machinery", he went on, but whether they should share an API "requires some thought and discussion". Here the shared machinery is the event, delivered by webhook, which carries only which resources changed and at which moment, and the different API is the catch-up, which brings the newest values. SEP-3415 itself points this way: servers "SHOULD keep event payloads minimal", agents that need stronger guarantees should "use tools to read authoritative state", and servers "MAY implement resource watching through events". ### Why is this different from task state sync? -**Task state is closer to a resource than to a stream of messages, but it is still different.** Peter put the two on the same side: "this is very much the same thing (observing task state vs stream of messages)". A task's state, though, is a small record the Tasks extension defines, so its newest value can go whole in the event itself, as SEP-3415's Open Question 4 sketches with an `mcp.task.updated` event carrying `{status, progress?, result?}`. Resources come in collections, such as a template's members, which a client watches as one and catches up on with one call. So this proposal starts with resources, and tasks could reuse what it builds. Timing played a part too: this work began before the Tasks extension joined this working group on 2026-10-16. +**Task state is closer to a resource than to a stream of messages, but it is still different.** Peter put the two on the same side: "this is very much the same thing (observing task state vs stream of messages)". A task's state, though, is a small record the Tasks extension defines, so its newest value can go whole in the event itself, as SEP-3415's Open Question 4 sketches with an `mcp.task.updated` event carrying `{status, progress?, result?}`. Resources come in collections, which a client watches and catches up on as one. So this proposal starts with resources, and tasks could reuse what it builds. This work also began before the Tasks extension joined this working group, on 2026-10-16. ### Why now? -**MCP is getting webhooks.** SEP-3415's webhook delivery is the catalyst for this work. It gives an MCP server a way to reach a client that holds no connection open, and this extension takes it as it stands. Clients already accept these webhooks while SEP-3415 is still a draft. [ChatGPT](https://developers.openai.com/plugins/build/mcp-events) receives Events by webhook on protocol `2026-07-28`, and so do the MCP packages of Vercel's AI SDK, Mastra and TanStack AI, in versions released in October 2026, and OpenClaw bundles an opt-in `mcp-events` plugin. +**MCP is getting webhooks.** SEP-3415's webhook delivery is the catalyst for this work, and this extension takes it as it stands. Clients accept these webhooks already, while SEP-3415 is still a draft: [ChatGPT](https://developers.openai.com/plugins/build/mcp-events) receives Events by webhook on protocol `2026-07-28`, as do the MCP packages of Vercel's AI SDK, Mastra and TanStack AI, in versions released in October 2026, and OpenClaw bundles an opt-in `mcp-events` plugin. **MCP now serves skills as resources.** [Skills over MCP](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/seps/2640-skills-extension.md), SEP-2640, is Final, so every client that adopts it reads resources, and may use a skill only while its files match the manifest it approved. Its group chose resources in part for "a subscription model for updates", as its [decision log](https://github.com/modelcontextprotocol/ext-skills/blob/main/docs/decisions.md) records, which since 2026-07-28 reaches a client only over an open `subscriptions/listen` stream. ### What the working group's charter asks -The Tasks and Events working group's [charter](https://modelcontextprotocol.io/community/triggers-events/charter) asks for a callback mechanism "with defined ordering guarantees that hold across all transports". SEP-3415 guarantees no order across subscriptions, and says "Servers that need stronger guarantees can implement them". A timeline is such a guarantee. It orders a server's changes, which commit in the order of their moments (R5), not its events, which still arrive in any order (R15), and it holds whichever transport prompts a catch-up. +The Tasks and Events working group's [charter](https://modelcontextprotocol.io/community/triggers-events/charter) asks for a callback mechanism "with defined ordering guarantees that hold across all transports". SEP-3415 guarantees no order across subscriptions, and says "Servers that need stronger guarantees can implement them". A timeline is such a guarantee: it orders a server's changes, which commit in the order of their moments (R5), rather than its events, which still arrive in any order (R15), and it holds whichever transport prompts a catch-up. ### Use cases -- **A client behind a gateway.** A client on a laptop or in a browser has no URL a server can reach. In SEP-3415's forward-proxy deployment, it names its gateway's URL instead, and the gateway verifies each delivery and passes it on. +- **A client behind a gateway.** A client on a laptop or in a browser, with no URL a server can reach, names its gateway's URL, as in SEP-3415's forward-proxy deployment. - **A collection of documents.** A client subscribes to `https://notes.example/notes/{id}`, hears of each note created, edited or deleted, and catches up after a night away. - **A client that can't take webhooks.** A command-line client polls `resources/collection/catchup` for `https://notes.example/notes/{id}` as often as each result's `nextPollMs` asks: one request for the whole collection, with no changes when nothing changed. - **A skill served over MCP.** A client that loaded `skill://code-review/SKILL.md` subscribes to `skill://code-review/{+path}`, hears of each changed file, and can ask for approval again before the model next uses the skill. -§ *Specification* answers the four problems in turn: a webhook needs no open stream, an event names the revision of each resource that changed, so a client skips it when it already holds them, its own writes included, one catch-up brings only what changed in a whole collection, and a subscription names a template, so a resource created under it brings an event too. § *Rationale* says why, against the alternatives. +§ *Specification* answers the four problems in turn. A webhook needs no open stream. An event names the revision of each resource that changed, so a client that already holds them skips it, its own writes included. One catch-up brings only what changed in a whole collection. And a subscription names a template, so one covers a whole collection, new members included. § *Rationale* says why, against the alternatives. ## Specification From 3f3ade52eb56883b747475b414d5a1183ed6c1f6 Mon Sep 17 00:00:00 2001 From: Larry Maccherone Date: Wed, 4 Nov 2026 06:19:40 -0500 Subject: [PATCH 05/15] Say why each Rationale paragraph restates its rule Co-Authored-By: Claude Opus 5.5 --- proposals/XXXX-resource-webhooks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/proposals/XXXX-resource-webhooks.md b/proposals/XXXX-resource-webhooks.md index 48c2930..0a556df 100644 --- a/proposals/XXXX-resource-webhooks.md +++ b/proposals/XXXX-resource-webhooks.md @@ -369,7 +369,7 @@ A uriGlob is a third kind of uri-specification. A glob is the familiar way to wr ## Rationale -Each decision keeps the number the rules cite it by. +Each decision keeps the number the rules cite it by, and opens by restating its rule, so it reads without scrolling back to the Specification. ### Subscribing and events From 9299905dd9c7b06122dfc92a03efb2be0b0425a4 Mon Sep 17 00:00:00 2001 From: Larry Maccherone Date: Wed, 4 Nov 2026 09:32:05 -0500 Subject: [PATCH 06/15] Show paging as two worked cases, and settle what a principal may read - A read that comes in pages and a catch-up that stops short open the less common cases. A read's paging moves out of R22 into R26, R14 moves beside it, and D14 says why the two page differently. - R25: catch-ups and collection reads MUST cover only what the principal may read, events SHOULD, and a server MAY start a principal over when its access changes. Q6 asks whether partial access gets an error or a partial answer. - Findings goes. Its Cloudflare egress finding becomes a note in Security, and the catch-up cost it reported was the prototype's own. - Q7's SDK helper folds into (a), events are coalesced rather than merged, R13 defers to hasMore, and the happy path says where A lists its template. - Wording that read as machine-drafted is plainer: the Abstract's last line, the Specification's opener, and three long sentences split. Co-Authored-By: Claude Opus 5.5 --- proposals/XXXX-resource-webhooks.md | 101 +++++++++++++++++----------- 1 file changed, 62 insertions(+), 39 deletions(-) diff --git a/proposals/XXXX-resource-webhooks.md b/proposals/XXXX-resource-webhooks.md index 0a556df..c5cea0f 100644 --- a/proposals/XXXX-resource-webhooks.md +++ b/proposals/XXXX-resource-webhooks.md @@ -18,12 +18,10 @@ A new client first reads the collection with `resources/collection/read`. Writes An event carries no state, so a lost, repeated or late one does no harm: the next catch-up brings whatever it missed. A working prototype builds all of it, as § *Reference Implementation* describes. -This proposal comes to the Tasks and Events working group to be discussed, improved, and hopefully sponsored for approval. +We bring this proposal to the Tasks and Events working group for discussion, and are looking for a sponsor. ## References -This proposal builds on two drafts, a working group's problem statements and one revision of MCP, and takes its sharpest use case from a Final extension: - - **SEP-3415, Events,** [modelcontextprotocol#3415](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3415), a draft, by reference at commit `b08e4f9`. This extension takes its `events/subscribe` and its webhook delivery, and sends an event of its own, `mcp.resource.collection.updated` rather than the `mcp.resource.updated` its Open Question 2 sketches. - **The Filesystems strawman,** [experimental-ext-filesystems#2](https://github.com/modelcontextprotocol/experimental-ext-filesystems/pull/2), the Filesystems working group's draft of resource operations, at commit `8d0fa88`, which the rest of this proposal calls "the strawman". It adds `resources/stat`, `resources/create`, `resources/update` and `resources/delete`. Each resource has a `revision`, a write names the one it expects as `ifMatch`, and a write's result holds a change-set entry for each resource it changed, such as `{uri, effect: "updated", previousRevision, revision}`. This extension takes its `revision` and its change-set entry, and the prototype builds its four methods. - **The Filesystems problem statements,** [experimental-ext-filesystems](https://github.com/modelcontextprotocol/experimental-ext-filesystems/blob/main/docs/problem-statements.md), that working group's framing of its work. Problem Statement 6 asks a proposal to "Decide whether a client can subscribe to a directory", and asks "Does a client need ordering between its write response and the resulting notification?" A collection answers the first (R24), and revisions the second, since a client orders messages by revision rather than arrival (R15). This extension offers both for that group to weigh. @@ -69,14 +67,14 @@ The Tasks and Events working group's [charter](https://modelcontextprotocol.io/c - **A client behind a gateway.** A client on a laptop or in a browser, with no URL a server can reach, names its gateway's URL, as in SEP-3415's forward-proxy deployment. - **A collection of documents.** A client subscribes to `https://notes.example/notes/{id}`, hears of each note created, edited or deleted, and catches up after a night away. -- **A client that can't take webhooks.** A command-line client polls `resources/collection/catchup` for `https://notes.example/notes/{id}` as often as each result's `nextPollMs` asks: one request for the whole collection, with no changes when nothing changed. +- **A client that can't take webhooks.** A command-line client polls `resources/collection/catchup` for `https://notes.example/notes/{id}` as often as each result's `nextPollMs` asks: one request for the whole collection, and an empty result when nothing changed. - **A skill served over MCP.** A client that loaded `skill://code-review/SKILL.md` subscribes to `skill://code-review/{+path}`, hears of each changed file, and can ask for approval again before the model next uses the skill. -§ *Specification* answers the four problems in turn. A webhook needs no open stream. An event names the revision of each resource that changed, so a client that already holds them skips it, its own writes included. One catch-up brings only what changed in a whole collection. And a subscription names a template, so one covers a whole collection, new members included. § *Rationale* says why, against the alternatives. +§ *Specification* answers the four problems. § *Rationale* says why, against the alternatives. ## Specification -**The key idea is a timeline of moments.** A server records each change to its resources at a moment on a timeline: a string that sorts after every moment before it, such as a counter zero-padded to a fixed width, so that `"042"` sorts before `"107"`, where `"42"` would not. The `tickets` timeline in the examples below begins like this: +**A server records each change to its resources at a moment on a timeline.** A moment is a string that sorts after every moment before it, such as a counter zero-padded to a fixed width, so that `"042"` sorts before `"107"`, where `"42"` would not. The `tickets` timeline in the examples below begins like this: | moment | change | |---|---| @@ -106,7 +104,7 @@ Rules are numbered R1 and up across the sections below. Each keeps its ID when o ### A simple happy path -Server A lists `tickets/{id}`, whose timeline's latest moment is `"107"`: ticket-3 last changed at `"101"`, and ticket-7 and ticket-9 at `"107"`. Two clients, X and Y, hold no copies yet, and each does steps 1 to 3. Each table shows what the server and each client hold after its step, in bold where the step changed it. The examples leave out JSON-RPC envelopes and core's result fields. +Server A lists the uriTemplate `tickets/{id}` in `resources/templates/list`, whose timeline's latest moment is `"107"`: ticket-3 last changed at `"101"`, and ticket-7 and ticket-9 at `"107"`. Two clients, X and Y, hold no copies yet, and each does steps 1 to 3. Each table shows what the server and each client hold after its step, in bold where the step changed it. The examples leave out JSON-RPC envelopes and core's result fields. #### Step 1, at `"107"`: X and Y read the collection @@ -131,7 +129,7 @@ Each client now holds every ticket, and is caught up to `"107"`, the read's `nex | ticket-3 | `"101"`, open | **`"101"`, open** | **`"101"`, open** | | ticket-7 and ticket-9 | `"107"` | **`"107"`** | **`"107"`** | -- **R22.** `resources/collection/read` names a uri-specification as a catch-up does, in its params and in `Mcp-Name`, gets the same errors for it, and is authorized the same way (R13), but takes no `from`. It returns the contents of each resource in the collection, each item as `resources/read` returns it, with its revision in `_meta` (R7), and `nextFrom`, the timeline's latest moment. Each resource MUST be as it stood at `nextFrom` or later. A long result comes in pages, as core's `resources/list` does: the server returns an opaque `nextCursor`, and the client sends it back as `cursor`. Every page MUST repeat the first page's `nextFrom`. A resource that doesn't change while the client pages MUST come exactly once. The client catches up from `nextFrom` afterwards, which brings whatever changed while it paged, deletions included. Its result is a core `CacheableResult`. +- **R22.** `resources/collection/read` names a uri-specification as a catch-up does, in its params and in `Mcp-Name`, gets the same errors for it, and is authorized the same way (R13), but takes no `from`. It returns the contents of each resource in the collection, each item as `resources/read` returns it, with its revision in `_meta` (R7), and `nextFrom`, the timeline's latest moment. Each resource MUST be as it stood at `nextFrom` or later. The client catches up from `nextFrom` afterwards, and a long read comes in pages (R26). Its result is a core `CacheableResult`. - A uri on its own is a collection of one, and a client watching one SHOULD read it with `resources/collection/read` too, rather than `resources/read`, since only a collection read brings a `nextFrom` to catch up from. A copy's revision can be older than the history the server keeps, so a catch-up from it could get `momentExpired` (R21), while `nextFrom` is the timeline's latest moment. #### Step 2, at `"107"`: X and Y subscribe @@ -194,7 +192,7 @@ A POSTs the event to each subscription's webhook. It names the subscription's ur "previousRevision": "101", "revision": "108"}]}} ``` -- **R10.** Once a change is visible to a catch-up, the server MUST send one `mcp.resource.collection.updated` to each subscription covering any resource it touched. Its `data` repeats the subscription's `arguments`, naming its uri-specification, and holds `changes`, the strawman's change-set entry for each covered resource the change touched, `revision` always present. When a change's entries would take the body past the 256 KiB SEP-3415 asks for, the server SHOULD leave `changes` out. Its `eventId` SHOULD be the moment of its change, and SEP-3415's `cursor` MUST be null. A server MAY merge undelivered events for one subscription into the newest, whose `changes` then hold each uri once, as a catch-up's do (R19). +- **R10.** Once a change is visible to a catch-up, the server MUST send one `mcp.resource.collection.updated` to each subscription covering any resource it touched. Its `data` repeats the subscription's `arguments`, naming its uri-specification, and holds `changes`, the strawman's change-set entry for each covered resource the change touched, `revision` always present. When a change's entries would take the body past the 256 KiB SEP-3415 asks for, the server SHOULD leave `changes` out. Its `eventId` SHOULD be the moment of its change, and SEP-3415's `cursor` MUST be null. A server MAY coalesce undelivered events for one subscription into the newest, whose `changes` then hold each uri once, as a catch-up's do (R19). #### Step 6, at `"108"`: X catches up @@ -221,7 +219,7 @@ X's copy is at `"101"`, the patch's `previousRevision`, so X applies it (R19), a | ticket-7 and ticket-9 | `"107"` | `"107"` | `"107"` | - **R11.** When a client receives `mcp.resource.collection.updated`, it MUST catch up on the uri-specification the event names, unless it already holds each uri in `changes`, or its tombstone, at that entry's `revision` or later, which lets the writer skip its own change. An event with no `changes` always brings a catch-up. -- **R13.** A catch-up names a uri-specification, as `uri` or `uriTemplate`, the moment the client is caught up to as `from`, and any patch formats it applies as `patchMimeTypes`. Over Streamable HTTP, its request MUST carry `Mcp-Name` holding that `uri` or `uriTemplate`, as `resources/read`'s carries its `uri`. It stores nothing and needs no subscription, and the server MUST authorize it as a `resources/read` of each covered resource. A catch-up's `changes` MUST hold every covered resource changed after `from` and at or before its `nextFrom`, as it stood at `nextFrom`, a deleted one as a tombstone, so a resource missing from them did not change in that span. With `hasMore` false, its `nextFrom` MUST be no earlier than any moment on its timeline that the server had sent when it was read. A client that polls SHOULD wait the result's `nextPollMs` before its next catch-up, with a floor, as in SEP-3415's poll mode (D12). +- **R13.** A catch-up names a uri-specification, as `uri` or `uriTemplate`, the moment the client is caught up to as `from`, and any patch formats it applies as `patchMimeTypes`. Over Streamable HTTP, its request MUST carry `Mcp-Name` holding that `uri` or `uriTemplate`, as `resources/read`'s carries its `uri`. It stores nothing and needs no subscription, and the server MUST authorize it as a `resources/read` of each covered resource. A catch-up's `changes` MUST hold every covered resource changed after `from` and at or before its `nextFrom`, as it stood at `nextFrom`, a deleted one as a tombstone, so a resource missing from them did not change in that span. With `hasMore` false, its `nextFrom` MUST be no earlier than any moment on its timeline that the server had sent when it was read. A client that polls SHOULD wait the result's `nextPollMs` before its next catch-up, with a floor, as in SEP-3415's poll mode (D12), except while `hasMore` is true (R14). - A uri-specification on a timeline gets the timeline's latest moment as its `nextFrom` before any resource it covers exists. The server MUST answer `InvalidParams` (`-32602`) to a catch-up with no `from`, and to one naming a uri-specification it neither lists nor covers (R24), with `data.reason` `"notFound"`. ### Timelines and moments @@ -253,7 +251,7 @@ In the second example below, closing a ticket also closes its note, in one atomi listed: tickets/{id}: work notes/{id}: work - "{type}/{id}": work # covers both, so it shares their timeline + "{type}/{id}": work # covers both so only one shared timeline is used timelines: work: latest: "108" @@ -268,21 +266,49 @@ A catch-up on `{type}/{id}` brings ticket-9 and its note as they stood at one mo - **R1.** A server keeps one or more timelines, from one for everything it serves to one for each uri-specification it lists. Every resource a uri-specification covers is on that uri-specification's timeline, so uri-specifications that cover a resource in common MUST share one, and each resource is on exactly one timeline. Example: `tickets/{id}` and `{kind}/ticket-3` both cover `tickets/ticket-3`, so they share a timeline though neither contains the other. A server assigns timelines over every uri-specification it could ever list to anyone, since listings change and differ by who asks. - **R2.** Every change to a resource, its creation and deletion included, MUST happen at a new moment on its timeline, which becomes the timeline's latest moment and the resource's revision. - **R3.** Moments on a timeline MUST strictly increase in byte order: ULIDs, or a counter zero-padded to a fixed width, but ISO timestamps only at a precision finer than the time between any two changes. - - Kubernetes works the same way: an object's `resourceVersion` names the version it last changed at, a client watches a collection for changes after one, and an update fails if it has moved on. [KEP-5504](https://www.kubernetes.dev/resources/keps/5504/), stable in v1.35, made them orderable within one resource type, as moments are within one timeline, where clients had compared them only for equality, as ETags compare. + - Kubernetes works the same way. An object's `resourceVersion` names the version it last changed at, a client watches a collection for changes after one, and an update fails if it has moved on. [KEP-5504](https://www.kubernetes.dev/resources/keps/5504/), stable in v1.35, made them orderable within one resource type, as moments are within one timeline. Clients had compared them only for equality, as ETags compare. - **R4.** A server MUST give one moment to a change that touches several resources in one atomic step, so resources that can change together share a timeline. -- **R5.** Changes on a timeline MUST commit in the order of their moments, so once a client has seen a moment, no change at or before it can still appear: SEP-3415's "safe-to-persist watermark", held by a timeline instead of a subscription. A server checks that each new moment is later than the timeline's latest one, and commits, in one atomic step: for example, in Postgres at read committed, with `UPDATE timelines SET latest = $new WHERE id = $timeline AND latest < $new` in byte order (`COLLATE "C"`), minting again when no row changes. A timeline per server serializes all its writes, and one per uri-specification only that one's. +- **R5.** Changes on a timeline MUST commit in the order of their moments, so once a client has seen a moment, no change at or before it can still appear: SEP-3415's "safe-to-persist watermark", held by a timeline instead of a subscription. A server checks that each new moment is later than the timeline's latest one, and commits, in one atomic step. In Postgres at read committed, for example, that's `UPDATE timelines SET latest = $new WHERE id = $timeline AND latest < $new` in byte order (`COLLATE "C"`), minting again when no row changes. A timeline per server serializes all its writes, and one per uri-specification only that one's. - **R7.** Each of a resource's revisions, in the strawman's sense, MUST be the moment of that change, so `ifMatch` checks the latest, and this extension's clients can also order revisions. Every change-set entry in a write's result MUST carry `revision`, a deletion's included. A `resources/read` result MUST carry each item's revision in `_meta`, under the strawman's provisional key, `io.modelcontextprotocol/resource-revision`. ### What a catch-up brings - **R19.** Each entry in `changes` is one resource's change from `from` to the catch-up's `nextFrom` (R13): the strawman's change-set entry, with contents added. `previousRevision` is the resource's revision at `from`, absent if it did not exist then. `effect` is `deleted` if the change ends with the resource gone, else `created` if it did not exist at `from`, else `updated`. A non-deletion MUST carry `contents`, one `TextResourceContents` or `BlobResourceContents`, or a `patch` from the contents at `previousRevision`, with its `patchMimeType`, in a listed format that applies. A merge patch ([RFC 7396](https://www.rfc-editor.org/rfc/rfc7396)) applies to one `application/json` text entry, and patches its parsed `text`. JSON-based patches travel as JSON, others as strings. The server MUST send `contents` when no listed format applies, when there is no `previousRevision`, or when it no longer keeps those contents, and MAY send them whenever they're smaller than the patch. - A client MUST apply a patch only to a copy at its `previousRevision`. Otherwise it reads the resource (D2). -- **R14.** A server MAY stop a catch-up short of the timeline's latest moment, to keep its result small. It MUST NOT stop partway through one moment's changes, and when it stops short it MUST set `hasMore` true, with `nextFrom` where it stopped, as in SEP-3415's poll mode. It MAY stop short with no changes, as after scanning many changes to another collection on a shared timeline. The client applies the changes, and is caught up to `nextFrom`. While `hasMore` is true, it SHOULD catch up again at once, ignoring `nextPollMs`, as SEP-3415's poll mode does. A catch-up MAY run past the server's usual size to finish a moment. A server that can't send one moment's changes in a single result MUST answer `InvalidParams` (`-32602`) with `data.reason` `"momentTooLarge"`, and the client starts over (R23), since a collection read pages freely and the catch-up after it starts past that moment. - - Each catch-up leaves the client's copies as they stood at its `nextFrom`, so the next one's patches start where the copies are, as a read replica's do (§ *Specification*). Example: X is caught up to `"100"` and holds ticket-5 at `"090"`, and ticket-5 changes at `"102"` and again at `"104"`. A catch-up that stops at `"102"` brings ticket-5 as it stood then, with a patch from `"090"` to `"102"`, and the next brings a patch from `"102"` to `"104"`. Sent as `contents` instead, ticket-5 would travel twice. A catch-up that reaches the timeline's latest moment brings each resource once, at its revision. - - Kubernetes reads every page of a list from one snapshot, so its `continue` token expires, after five minutes by default. A catch-up that stops short needs no snapshot, since the next is an ordinary catch-up, which expires only as any `from` does (R21). ### Less common cases, most likely first +**A read that comes in pages.** From `"107"`, as in § *A simple happy path*, X reads `tickets/{id}` two tickets to a page, while Y updates ticket-3 and then ticket-9: + +```text +X -> A resources/collection/read tickets/{id} +A -> X page 1: ticket-3 at "101", ticket-7 at "107", nextFrom "107", nextCursor +Y -> A resources/update ticket-3, at "108" +Y -> A resources/update ticket-9, at "109" +X -> A resources/collection/read, with that cursor +A -> X page 2: ticket-9 at "109", nextFrom "107" page 1's again +X -> A resources/collection/catchup from "107" +A -> X ticket-3 at "108", ticket-9 at "109", nextFrom "109" +``` + +After page 2, X's copies match no one moment: ticket-3 is as it stood at `"107"`, and ticket-9 as at `"109"`. Catching up from `"109"`, the newest revision X holds, would miss ticket-3's change at `"108"`. So every page carries the first page's `nextFrom`, and X catches up from it, ignoring ticket-9, which it already holds (R15). `nextFrom` also gives a client that reads an empty collection a moment to catch up from. + +- **R26.** A long collection read comes in pages, as core's `resources/list` does: the server returns an opaque `nextCursor`, and the client sends it back as `cursor`. Every page MUST repeat the first page's `nextFrom`. A resource that doesn't change while the client pages MUST come exactly once. The client's copies match no one moment until it has every page and has caught up from `nextFrom`, which brings whatever changed while it paged, deletions included. + +**A catch-up that stops short.** X is caught up to `"100"` and holds ticket-5 at `"090"`. Then ticket-5 changes at `"102"` and again at `"104"`, and A stops the first result at `"102"` to keep it small. Other tickets are left out: + +```text +X -> A resources/collection/catchup from "100" +A -> X ticket-5 at "102", a patch from "090", hasMore, nextFrom "102" +X -> A resources/collection/catchup from "102" at once, since hasMore is true +A -> X ticket-5 at "104", a patch from "102", nextFrom "104", nextPollMs 30000 +``` + +Each result leaves X's copies as they stood at its `nextFrom`, so the next one's patches start where the copies are, as a read replica's do (§ *Specification*). X could stop after either result and still hold the collection as it stood at one moment, which a read's pages don't give (R26). Sent as `contents` instead, ticket-5 would travel twice. A catch-up that reaches the timeline's latest moment brings each resource once, at its revision. + +- **R14.** A server MAY stop a catch-up short of the timeline's latest moment, to keep its result small. It MUST NOT stop partway through one moment's changes, and when it stops short it MUST set `hasMore` true, with `nextFrom` where it stopped, as in SEP-3415's poll mode. It MAY stop short with no changes, as after scanning many changes to another collection on a shared timeline. The client applies the changes, and is caught up to `nextFrom`. While `hasMore` is true, it SHOULD catch up again at once, ignoring `nextPollMs`, as SEP-3415's poll mode does. A catch-up MAY run past the server's usual size to finish a moment. A server that can't send one moment's changes in a single result MUST answer `InvalidParams` (`-32602`) with `data.reason` `"momentTooLarge"`, and the client starts over (R23), since a collection read pages freely and the catch-up after it starts past that moment. + - Kubernetes reads every page of a list from one snapshot, so its `continue` token expires, after five minutes by default. A catch-up that stops short needs no snapshot, since the next is an ordinary catch-up, which expires only as any `from` does (R21). + **A writer's copy runs ahead of its catch-ups.** From `"107"`, as in § *A simple happy path*, X updates ticket-3 at `"109"`, and then Y updates it at `"110"`. X's copy is at `"109"`, but X stays caught up to `"107"` (R9): | | Server A | Client X | @@ -353,7 +379,9 @@ covers(listed, clientTemplate): - **R21.** A server MUST keep the moment of every change, deletions included, back to a moment of its choosing, and MAY drop old contents at any time. It MUST answer a catch-up whose `from` is earlier than that with `InvalidParams` (`-32602`) and `data.reason` `"momentExpired"`, and the client starts over (R23, D4). A server restored from a backup MUST first mint a moment past every one it might have sent, and make it the oldest it answers from, as etcd's `snapshot restore` does with `--bump-revision` and `--mark-compacted`. A server SHOULD keep that history longer than the time it grants between refreshes, and longer than a read of its largest collection takes, so that a client can catch up at each refresh (R12) and after reading any collection. -**Resources a client may not read.** Q6 asks what a client sees of them, and proposes an answer. +**Resources a client may not read.** + +- **R25.** Catch-ups and collection reads MUST cover only the resources the principal may read, since they carry contents (R13, R22). Events SHOULD cover only those too, as SEP-3415's check at delivery time is a SHOULD, since an event carries only uris and revisions (R10). When what a principal may read changes while the resources don't, the server MAY mint a moment that changes no resource, as after a restore (R21), and answer that principal's catch-ups from before it with `momentExpired`. Its clients then start over (R23), which brings a shared resource or drops a revoked one. ### Declaring the extension @@ -361,7 +389,7 @@ covers(listed, clientTemplate): ### uriGlob, later -A uriGlob is a third kind of uri-specification. A glob is the familiar way to write a filesystem-like collection, and reads more clearly than a template that could describe much the same one: `file:///tickets/**/*.md`, the example the Filesystems working group's Problem Statement 2 gives, against `file:///tickets/{+path}.md`. Its match is also defined by its own rules, where a template's is left open: RFC 6570 defines how a template expands, and says matching one "only works well if the template expressions are delimited by the beginning or end of the URI or by characters that cannot be part of the expansion". Problem Statement 2 asks for glob matching under a URI prefix to find resources, and the strawman defers globs, so a uriGlob would let a client watch what it found with the same pattern. This extension offers that group three rules, and adopts whatever it settles: +A uriGlob is a third kind of uri-specification. A glob is the familiar way to write a filesystem-like collection, and reads more clearly than a template that could describe much the same one: `file:///tickets/**/*.md`, the example the Filesystems working group's Problem Statement 2 gives, against `file:///tickets/{+path}.md`. Its match is also defined by its own rules, where a template's is left open. RFC 6570 defines how a template expands, and says matching one "only works well if the template expressions are delimited by the beginning or end of the URI or by characters that cannot be part of the expansion". Problem Statement 2 asks for glob matching under a URI prefix to find resources, and the strawman defers globs, so a uriGlob would let a client watch what it found with the same pattern. This extension offers that group three rules, and adopts whatever it settles: - `*` matches any characters within one segment, and `**` zero or more whole segments. - A uriGlob has at most one `**`, so a matcher checks the segments before it from the URI's start and those after it from the end, and two globs' overlap (R1) stays easy to test. @@ -377,7 +405,7 @@ Each decision keeps the number the rules cite it by, and opens by restating its **D5. Events carry no contents.** An event names what changed and carries none of it (R10), so a lost, repeated or late one does no harm. A change then costs a subscriber an event and a catch-up, one round trip more than a delivery carrying the change, with about the same bytes. The writer pays nothing extra, and N changes before a catch-up cost N events and one catch-up. Deliveries that carry each change's contents or a patch would need a `fromVersion` to order them, and a resubscribe that drops the deliveries still queued. -**D19. The extension sends an event of its own, one for each change on each subscription.** `mcp.resource.collection.updated` goes to each subscription covering a resource the change touched. Its `data` names the subscription's uri-specification, and lists the change's entries that the subscription covers (R10, R11). It leaves `mcp.resource.updated` to SEP-3415's Open Question 2. A server may merge undelivered events, as Peter agreed for replacing an event with a newer one. Peter's `mcp.resource.updated`, with data of our own, would take a name Open Question 2 may define otherwise, for core's `notifications/resources/updated`, whose `uri` names the changed resource. One event for each resource changed would send a change touching 500 tickets as 500 deliveries, 499 of them skipped after the first catch-up. An event naming only the uri-specification and the moment would let a writer skip it only by taking the whole moment as its own, which is wrong when its write also changed a resource whose contents it lacks, as closing a ticket closes its note. Leaving the uri-specification to the subscription `id` in `X-MCP-Subscription-Id` would have each client map it back to its subscription. +**D19. The extension sends an event of its own, one for each change on each subscription.** `mcp.resource.collection.updated` goes to each subscription covering a resource the change touched. Its `data` names the subscription's uri-specification, and lists the change's entries that the subscription covers (R10, R11). It leaves `mcp.resource.updated` to SEP-3415's Open Question 2. A server may coalesce undelivered events, as Peter agreed for replacing an event with a newer one. Peter's `mcp.resource.updated`, with data of our own, would take a name Open Question 2 may define otherwise, for core's `notifications/resources/updated`, whose `uri` names the changed resource. One event for each resource changed would send a change touching 500 tickets as 500 deliveries, 499 of them skipped after the first catch-up. An event naming only the uri-specification and the moment would let a writer skip it only by taking the whole moment as its own, which is wrong when its write also changed a resource whose contents it lacks, as closing a ticket closes its note. Leaving the uri-specification to the subscription `id` in `X-MCP-Subscription-Id` would have each client map it back to its subscription. ### Moments and revisions @@ -393,13 +421,13 @@ Each decision keeps the number the rules cite it by, and opens by restating its **D12. A catch-up's result is a plain result, and paces polling with `nextPollMs`.** A client that polls waits the result's `nextPollMs`, as in SEP-3415's poll mode (R13, R14). Core's `CacheableResult` would serve badly: its `ttlMs` is a freshness hint, which clients "SHOULD NOT treat … as a polling interval", and a cache keyed on the request could answer step 6's catch-up with step 3's empty result, since both send `from` `"107"`. A rule that a client never answers a catch-up from a cache would bind no gateway that follows core, and with no pacing field, a server would have no way to slow clients that poll. A collection read stays a `CacheableResult` (R22), since a cached read is an older snapshot whose `nextFrom` matches it. -**D13. A long catch-up is a run of short ones.** A server may stop a catch-up short, bringing each resource as it stood where it stopped (R13, R14), so a long catch-up is a run of ordinary ones, as polls are in SEP-3415, and every patch applies in order. Four kinds of paging lost to it. Taking the last entry's `revision` as the moment the client is caught up to, beside a field holding the timeline's latest moment, gave the client two rules, and could not stop short with no changes. Pages bringing each resource at its revision cost a read for each resource changed on both sides of a page's end. Sending the first page's `from` again on every page, with a `cursor` beside it, needs both fields and a rule for which one a patch starts from. A fixed end sent back on every page, as `through`, cost a field and a catch-up after the last page. +**D13. A long catch-up is a run of short ones.** A server may stop a catch-up short, bringing each resource as it stood where it stopped (R13, R14), so a long catch-up is a run of ordinary ones, as polls are in SEP-3415, and every patch applies in order. Four other kinds of paging were weighed against it. Taking the last entry's `revision` as the moment the client is caught up to, beside a field holding the timeline's latest moment, gave the client two rules, and could not stop short with no changes. Pages bringing each resource at its revision cost a read for each resource changed on both sides of a page's end. Sending the first page's `from` again on every page, with a `cursor` beside it, needs both fields and a rule for which one a patch starts from. A fixed end sent back on every page, as `through`, cost a field and a catch-up after the last page. **D15. A catch-up never stops partway through a moment.** Stopping there would leave the client's copies matching no moment until its next catch-up. So a server runs a result long to finish a moment. Such a result is never larger than a start-over, which sends every resource in the collection, and a server that still can't finish one answers `momentTooLarge` (R14). Reusing `momentExpired` would tell a client its `from` had expired when it hadn't. SEP-3415's `ResourceExhausted`, whose code its Open Question 5 may fold into `-32602`, would give a client a second code for the one response, starting over. ### Reading collections -**D14. Reading a collection has a method of its own.** `resources/collection/read` takes no `from`, and pages with core's opaque `cursor` and `nextCursor`, as `resources/list` does (R22). A client reads the collection, subscribes, then catches up from the read's `nextFrom` (R18), so every catch-up names a `from`. A catch-up with no `from` would give one method two kinds of result, and need a `cursor` beside `from`, with an error for naming both. `resources/template/read` would need a sibling for uriGlobs, and reads like fetching the template itself. SEP-2640's `resources/directory/read` lists one directory's children without their contents. Subscribing before reading also works, but leaves a new client's events waiting while it pages. +**D14. Reading a collection has a method of its own.** `resources/collection/read` takes no `from`, and pages with core's opaque `cursor` and `nextCursor`, as `resources/list` does (R22, R26). Their pages differ: a client can stop after any catch-up and hold the collection as it stood at one moment (R14), but must read every page of a collection read, then catch up, to do so (R26). A client reads the collection, subscribes, then catches up from the read's `nextFrom` (R18), so every catch-up names a `from`. A catch-up with no `from` would give one method two kinds of result, and need a `cursor` beside `from`, with an error for naming both. `resources/template/read` would need a sibling for uriGlobs, and reads like fetching the template itself. SEP-2640's `resources/directory/read` lists one directory's children without their contents. Subscribing before reading also works, but leaves a new client's events waiting while it pages. **D20. A client may name a template the server doesn't list, when a listed one covers it.** A server answers `notFound` for one it can't show is covered (R24). R24's pseudo-code checks it by writing the client's template as a sample uri, with a stand-in for each expression, and matching that as any uri. The check never wrongly accepts, and it handles a listed template with part of it filled in, as a skill or a type is. Listed templates alone would leave a client that wants one skill, on a server listing `skill://{skill}/{+path}`, watching every skill, unless the server lists a template for each. An exact check would first have to settle what RFC 6570's expressions match, empty values and levels 3 and 4 included, then compare automata that can grow exponentially. @@ -415,13 +443,15 @@ None. The extension adds two methods and one event, and binds only a server that ## Security Implications -This extension sends its events by SEP-3415's webhook delivery as it stands, so that SEP's rules on callback URLs, signing and endpoint verification cover every POST. It adds four things to weigh: +This extension sends its events by SEP-3415's webhook delivery as it stands, so that SEP's rules on callback URLs, signing and endpoint verification cover every POST. It adds four points of its own: - **Events carry no contents** (R10), so a delivery that reaches the wrong receiver reveals uris and revisions, never what a resource holds. -- **A catch-up or a collection read is authorized as a `resources/read`** of each resource it covers (R13, R22). Q6 proposes that events, catch-ups and reads cover only what the principal may read, and asks what happens when access changes while a resource doesn't. -- **`nextFrom` moves with every change on a timeline** (R13), so it can tell a principal when resources it may not read change. Q6 says how a server avoids that. +- **A catch-up or a collection read is authorized as a `resources/read`** of each resource it covers (R13, R22), so catch-ups and reads MUST cover only what the principal may read, and events SHOULD (R25). +- **`nextFrom` moves with every change on a timeline** (R13), so it can tell a principal when resources it may not read change. A server that minds can avoid it, as by giving those resources a timeline of their own where R1 allows. - **Both methods carry their `uri` or `uriTemplate` in `Mcp-Name`** (R13, R22), where a gateway or proxy sees it, as core's `resources/read` carries its `uri`. +**On Cloudflare Workers, the platform's egress already does SEP-3415's address check.** SEP-3415 asks a server to reject callback URLs whose address isn't globally routable, at delivery time. With the `global_fetch_strictly_public` flag, a Worker's fetch refuses a name that resolves to a private, loopback, link-local or other special-purpose address with an error of Cloudflare's own, 1016 or 1002, and names built to rebind to a private address reached none. Two ranges, `192.0.0.0/24` and `192.0.2.0/24`, go out and time out. On 2026-11-03, the prototype's webhook POSTs and its fetches of client metadata documents met the same outcome for each of 30 names, so a server on Workers needs no address check of its own for either. Cloudflare's documentation promises none of this, so the prototype's deployed tier measures it again on every run. + ## Reference Implementation [lumenize/resource-webhooks-on-events](https://github.com/lumenize/resource-webhooks-on-events) builds this extension on Cloudflare Workers, without an MCP SDK, and with the spec's `schema.ts` as its only schema. It has two parts, each a Durable Object: @@ -433,29 +463,22 @@ A client library, `src/client/copies.ts`, keeps copies by the client's rules: R9 ### Tested, and not yet tested -- **456 tests pass,** and a check fails the run unless a passing test names every rule above. Each new test was mutation-checked: broken code turned it red. +- **456 tests pass,** and a check fails the run unless a passing test names every rule above but R25. Each new test was mutation-checked: broken code turned it red. - **Each MUST of SEP-3415's that the prototype uses has a test.** - **MCP's conformance suite,** `@modelcontextprotocol/conformance` 0.2.0-alpha.12 at revision 2026-07-28, passes against A and against the client, with a baseline that lists the features neither offers, such as prompts. - **A deployed tier runs on Cloudflare.** Two clients read the tickets, subscribe and catch up. One closes a ticket and skips its own event, and the other catches up with a merge patch. -- **Designed, not built:** several writers minting moments at once, subscriptions granted no expiry, R12's catch-up on a `subscriptions/listen` stream and the `deliveryStatus` it cites, uriGlob, access that differs by resource (Q6), and a restore from a real backup, whose steps an operator reset takes in the tests (R21). +- **Designed, not built:** several writers minting moments at once, subscriptions granted no expiry, R12's catch-up on a `subscriptions/listen` stream and the `deliveryStatus` it cites, uriGlob, access that differs by principal (R25), and a restore from a real backup, whose steps an operator reset takes in the tests (R21). - **Only the prototype's own client and server have run the extension so far.** The strawman's methods are built for the happy path and a conflicting write. -## Findings - -- **A catch-up from before the contents a server keeps can't stop short.** R14 lets a server stop a catch-up short, and R13 has it bring each resource as it stood where it stopped. R21 lets a server drop old contents. So a catch-up from a moment older than the contents the server keeps runs on until it reaches a moment where each covered resource changed since its `from` still has its contents, and one result can hold every resource changed since then. In the prototype, which checks every row it keeps at each moment as it runs on, the cost grows as the square of their number. -- **Cloudflare's egress already does SEP-3415's address check.** SEP-3415 asks a server to reject callback URLs whose address isn't globally routable, at delivery time. With the `global_fetch_strictly_public` flag, a Worker's fetch refuses a name that resolves to a private, loopback, link-local or other special-purpose address with an error of Cloudflare's own, 1016 or 1002, and names built to rebind to a private address reached none. Two ranges, `192.0.0.0/24` and `192.0.2.0/24`, go out and time out. On 2026-11-03, A's webhook POSTs and its fetch of client metadata documents met the same outcome for each of 30 names, so a server on Workers needs no address check of its own for either. Cloudflare's documentation promises none of this, so the prototype's deployed tier measures it again on every run. - ## Open Questions IDs follow the order the questions were raised, not the order shown, and a gap is a question answered or withdrawn. -- **Q6. What a client sees of resources it may not read.** Proposed: events, catch-ups and collection reads cover only what the principal may read. Still open is access that changes while the resource does not: a ticket shared with X after its last change never shows in X's catch-ups, and a revoked one stays in X's copies. - - (a) The server answers that principal's catch-ups from before the change with `momentExpired`, and ends its subscriptions, as SEP-3415's § *Authorization* does on a revoke. - - (b) A change in access is a change on the timeline, which makes a resource's revision differ by who asks, against R7 and R11. - - Recommendation: (a), designed, not built, since in the prototype every principal reads everything. - - Under either, `nextFrom` moves with every change on the timeline (R13), so a principal watching a timeline it shares with resources it may not read learns when they change, as the strawman warns that a revision does. A server can give each part a timeline of its own where R1 allows, or R13's MUST could count only the moments sent to that principal. -- **Q7. Starting to watch a collection in one call.** A client makes three: it reads the collection, subscribes with `events/subscribe`, and catches up (§ *A simple happy path*, steps 1 to 3). One call would be simpler to use. - - (a) Keep the three calls, so SEP-3415's `events/subscribe` stays the one subscription lifecycle (D1). +- **Q6. Authorization.** A principal may be able to read only some of the resources a uri-specification covers, such as some of the tickets under `tickets/{id}`. + - (a) The server answers a subscription, catch-up or collection read with an error unless the principal may read every covered resource, so a client can watch only a collection it may read whole. + - (b) The server answers with only the resources the principal may read, so a client can watch `tickets/{id}` and see just the tickets it may read. + - This draft takes (b), in R25, and leaves the choice to the working group. +- **Q7. Starting to watch a collection in one call.** The simple happy path shows a client making three calls in a row when it first connects: (1) it reads the collection, (2) subscribes with `events/subscribe`, and (3) catches up (§ *A simple happy path*, steps 1 to 3). One call would be simpler to use. + - (a) Keep the three calls, so SEP-3415's `events/subscribe` stays the one subscription lifecycle (D1), and add a helper to the SDKs that makes them. - (b) Add `resources/collection/subscribe` to this extension, which subscribes and returns the collection's contents. It would be a second subscription lifecycle beside `events/subscribe`, and its result would page as a collection read's does. - - (c) Add a helper to the SDKs that makes the three calls, leaving the wire as in (a). - This draft takes (a), and leaves the choice to the working group. From 5eb38d990a4bd27b826535847dd8b0f11b8ba393 Mon Sep 17 00:00:00 2001 From: Larry Maccherone Date: Wed, 4 Nov 2026 12:27:13 -0500 Subject: [PATCH 07/15] Rename the extension to Resource Events, and pin the strawman at db67f78 The identifier becomes io.modelcontextprotocol/resource-events, beside the strawman's resource-operations and SEP-3415's events, since a webhook is one of two ways a client hears of a change. The file follows, as proposals/XXXX-resource-events.md. The strawman's head, db67f78, requires ifMatch on resources/delete and drops uri from a write's contents. Nothing the proposal says of the strawman changes with it. The prototype now has 457 tests. Co-Authored-By: Claude Opus 5.5 --- ...XX-resource-webhooks.md => XXXX-resource-events.md} | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) rename proposals/{XXXX-resource-webhooks.md => XXXX-resource-events.md} (98%) diff --git a/proposals/XXXX-resource-webhooks.md b/proposals/XXXX-resource-events.md similarity index 98% rename from proposals/XXXX-resource-webhooks.md rename to proposals/XXXX-resource-events.md index c5cea0f..03ee56a 100644 --- a/proposals/XXXX-resource-webhooks.md +++ b/proposals/XXXX-resource-events.md @@ -1,8 +1,8 @@ -# SEP-XXXX: Resource Webhooks on Events +# SEP-XXXX: Resource Events - **Status**: Draft - **Type**: Extensions Track -- **Extension Identifier**: `io.modelcontextprotocol/resource-webhooks` +- **Extension Identifier**: `io.modelcontextprotocol/resource-events` - **Created**: 2026-10-31 - **Author(s)**: Larry Maccherone (@lmaccherone) - **Sponsor**: None (seeking sponsor) @@ -23,7 +23,7 @@ We bring this proposal to the Tasks and Events working group for discussion, and ## References - **SEP-3415, Events,** [modelcontextprotocol#3415](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3415), a draft, by reference at commit `b08e4f9`. This extension takes its `events/subscribe` and its webhook delivery, and sends an event of its own, `mcp.resource.collection.updated` rather than the `mcp.resource.updated` its Open Question 2 sketches. -- **The Filesystems strawman,** [experimental-ext-filesystems#2](https://github.com/modelcontextprotocol/experimental-ext-filesystems/pull/2), the Filesystems working group's draft of resource operations, at commit `8d0fa88`, which the rest of this proposal calls "the strawman". It adds `resources/stat`, `resources/create`, `resources/update` and `resources/delete`. Each resource has a `revision`, a write names the one it expects as `ifMatch`, and a write's result holds a change-set entry for each resource it changed, such as `{uri, effect: "updated", previousRevision, revision}`. This extension takes its `revision` and its change-set entry, and the prototype builds its four methods. +- **The Filesystems strawman,** [experimental-ext-filesystems#2](https://github.com/modelcontextprotocol/experimental-ext-filesystems/pull/2), the Filesystems working group's draft of resource operations, at commit `db67f78`, which the rest of this proposal calls "the strawman". It adds `resources/stat`, `resources/create`, `resources/update` and `resources/delete`. Each resource has a `revision`, a write names the one it expects as `ifMatch`, and a write's result holds a change-set entry for each resource it changed, such as `{uri, effect: "updated", previousRevision, revision}`. This extension takes its `revision` and its change-set entry, and the prototype builds its four methods. - **The Filesystems problem statements,** [experimental-ext-filesystems](https://github.com/modelcontextprotocol/experimental-ext-filesystems/blob/main/docs/problem-statements.md), that working group's framing of its work. Problem Statement 6 asks a proposal to "Decide whether a client can subscribe to a directory", and asks "Does a client need ordering between its write response and the resulting notification?" A collection answers the first (R24), and revisions the second, since a client orders messages by revision rather than arrival (R15). This extension offers both for that group to weigh. - **MCP 2026-07-28,** [the specification](https://modelcontextprotocol.io/specification/2026-07-28), the revision this extension builds on, for `resources/read`, pagination and `CacheableResult`. - **SEP-2640, Skills over MCP,** [the extension](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/seps/2640-skills-extension.md), Final, which serves a skill's files as resources, such as `skill://pdf-processing/scripts/extract.py`. A client that loaded a skill can watch its files with this extension, as § *Use cases* shows. @@ -385,7 +385,7 @@ covers(listed, clientTemplate): ### Declaring the extension -- **R20.** A server declares this extension in `capabilities.extensions` under a provisional identifier, `io.modelcontextprotocol/resource-webhooks`, as SEP-2133 asks. A server that declares it MUST serve `resources/collection/read` and `resources/collection/catchup`, and list `mcp.resource.collection.updated` in `events/list` with webhook delivery. The extension reserves the prefix `resources/collection/` for its methods, beside core's and the strawman's under `resources/`. The event's name waits on Open Question 2's decision about an `mcp.*` prefix. +- **R20.** A server declares this extension in `capabilities.extensions` under a provisional identifier, `io.modelcontextprotocol/resource-events`, as SEP-2133 asks. A server that declares it MUST serve `resources/collection/read` and `resources/collection/catchup`, and list `mcp.resource.collection.updated` in `events/list` with webhook delivery. The extension reserves the prefix `resources/collection/` for its methods, beside core's and the strawman's under `resources/`. The event's name waits on Open Question 2's decision about an `mcp.*` prefix. ### uriGlob, later @@ -463,7 +463,7 @@ A client library, `src/client/copies.ts`, keeps copies by the client's rules: R9 ### Tested, and not yet tested -- **456 tests pass,** and a check fails the run unless a passing test names every rule above but R25. Each new test was mutation-checked: broken code turned it red. +- **457 tests pass,** and a check fails the run unless a passing test names every rule above but R25. Each new test was mutation-checked: broken code turned it red. - **Each MUST of SEP-3415's that the prototype uses has a test.** - **MCP's conformance suite,** `@modelcontextprotocol/conformance` 0.2.0-alpha.12 at revision 2026-07-28, passes against A and against the client, with a baseline that lists the features neither offers, such as prompts. - **A deployed tier runs on Cloudflare.** Two clients read the tickets, subscribe and catch up. One closes a ticket and skips its own event, and the other catches up with a merge patch. From c1e4870f9943ad4eb3352e7cb3b13df5bee812be Mon Sep 17 00:00:00 2001 From: Larry Maccherone Date: Wed, 4 Nov 2026 13:13:11 -0500 Subject: [PATCH 08/15] Say what the extension does in the Abstract's first sentence, and how in the next A webhook is how a server must offer the event, but a client can also poll the catch-up, so the first sentence no longer names it. Co-Authored-By: Claude Opus 5.5 --- proposals/XXXX-resource-events.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/proposals/XXXX-resource-events.md b/proposals/XXXX-resource-events.md index 03ee56a..3b427fb 100644 --- a/proposals/XXXX-resource-events.md +++ b/proposals/XXXX-resource-events.md @@ -10,7 +10,7 @@ ## Abstract -This extension lets an MCP client keep copies of resources current by webhook. A server records each change to its resources at a moment on a timeline, a string that sorts after every moment before it, such as `"108"`. For each change, it sends `mcp.resource.collection.updated` through the webhook delivery of SEP-3415, Events, to each subscription covering a resource the change touched. The event names the collection the subscription watches and each resource in it that changed, with its new revision, and carries none of their contents. +This extension lets an MCP client keep copies of resources current. A server records each change to its resources at a moment on a timeline, a string that sorts after every moment before it, such as `"108"`. For each change, it sends `mcp.resource.collection.updated` through the webhook delivery of SEP-3415, Events, to each subscription covering a resource the change touched. The event names the collection the subscription watches and each resource in it that changed, with its new revision, and carries none of their contents. A client that already holds each named revision, such as the client that made the change, skips the event. Any other catches up with `resources/collection/catchup`, which brings every change to the collection after the moment the client is caught up to, each as contents or a patch. From 424164848b53d303cb6429a19e6eff6dad817cd5 Mon Sep 17 00:00:00 2001 From: Larry Maccherone Date: Wed, 4 Nov 2026 13:26:25 -0500 Subject: [PATCH 09/15] Leave the delivery mode out of the Abstract, and say a stream or polling also works The prototype and the Motivation speak of webhooks, but nothing here stops the event from going over SEP-3415's push delivery, and a client can poll with no stream at all. Co-Authored-By: Claude Opus 5.5 --- proposals/XXXX-resource-events.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/proposals/XXXX-resource-events.md b/proposals/XXXX-resource-events.md index 3b427fb..0169777 100644 --- a/proposals/XXXX-resource-events.md +++ b/proposals/XXXX-resource-events.md @@ -10,7 +10,7 @@ ## Abstract -This extension lets an MCP client keep copies of resources current. A server records each change to its resources at a moment on a timeline, a string that sorts after every moment before it, such as `"108"`. For each change, it sends `mcp.resource.collection.updated` through the webhook delivery of SEP-3415, Events, to each subscription covering a resource the change touched. The event names the collection the subscription watches and each resource in it that changed, with its new revision, and carries none of their contents. +This extension lets an MCP client keep copies of resources current. A server records each change to its resources at a moment on a timeline, a string that sorts after every moment before it, such as `"108"`. For each change, it sends `mcp.resource.collection.updated`, an event of SEP-3415's Events extension, to each subscription covering a resource the change touched. The event names the collection the subscription watches and each resource in it that changed, with its new revision, and carries none of their contents. A client that already holds each named revision, such as the client that made the change, skips the event. Any other catches up with `resources/collection/catchup`, which brings every change to the collection after the moment the client is caught up to, each as contents or a patch. @@ -18,6 +18,8 @@ A new client first reads the collection with `resources/collection/read`. Writes An event carries no state, so a lost, repeated or late one does no harm: the next catch-up brings whatever it missed. A working prototype builds all of it, as § *Reference Implementation* describes. +While the prototype and § *Motivation* mention webhooks explicitly, nothing in this proposal prevents its event from being delivered over an SSE stream, by SEP-3415's push delivery. Further, the proposal and the prototype both support a client that polls, with no stream at all (§ *Use cases*). + We bring this proposal to the Tasks and Events working group for discussion, and are looking for a sponsor. ## References @@ -47,7 +49,7 @@ A client watching resources today has four problems: SEP-3415's Open Question 2 sketches a minimal resource subscription over Events, with "a reserved `mcp.resource.updated` event type whose arguments are `{uri}`", re-expressing core's `notifications/resources/updated`, which names one changed resource. This extension leaves that event to Open Question 2, and sends its own, `mcp.resource.collection.updated`, naming a collection and every resource in it that one change touched (D19). -**However, a resource is state, and a watcher cares only about the latest version, not every change.** A receiver that was down while a ticket was edited, reopened and closed needs only the closed ticket. On SEP-3415's thread on 2026-10-16, Peter Alexander (@pja-ant) [wrote](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3415#issuecomment-6215654779) that watching state and consuming messages "will need different handling and different APIs". The two "should likely share a bunch of the same machinery", he went on, but whether they should share an API "requires some thought and discussion". Here the shared machinery is the event, delivered by webhook, which carries only which resources changed and at which moment, and the different API is the catch-up, which brings the newest values. SEP-3415 itself points this way: servers "SHOULD keep event payloads minimal", agents that need stronger guarantees should "use tools to read authoritative state", and servers "MAY implement resource watching through events". +**However, a resource is state, and a watcher cares only about the latest version, not every change.** A receiver that was down while a ticket was edited, reopened and closed needs only the closed ticket. On SEP-3415's thread on 2026-10-16, Peter Alexander (@pja-ant) [wrote](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3415#issuecomment-6215654779) that watching state and consuming messages "will need different handling and different APIs". The two "should likely share a bunch of the same machinery", he went on, but whether they should share an API "requires some thought and discussion". Here the shared machinery is the event, which carries only which resources changed and at which moment, and the different API is the catch-up, which brings the newest values. SEP-3415 itself points this way: servers "SHOULD keep event payloads minimal", agents that need stronger guarantees should "use tools to read authoritative state", and servers "MAY implement resource watching through events". ### Why is this different from task state sync? From 9561f6ae19ad07ec0ac683f89a07155b4e4efad8 Mon Sep 17 00:00:00 2001 From: Larry Maccherone Date: Wed, 4 Nov 2026 13:29:22 -0500 Subject: [PATCH 10/15] Let a server offer the event by any of SEP-3415's delivery modes (R20) SEP-3415 makes no delivery mode mandatory, and nothing in the Rationale argued for requiring webhooks, so a server that offers only push, such as a stdio server, can now declare the extension. The References entry and the Security section say the same. Co-Authored-By: Claude Opus 5.5 --- proposals/XXXX-resource-events.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/proposals/XXXX-resource-events.md b/proposals/XXXX-resource-events.md index 0169777..3c1bf24 100644 --- a/proposals/XXXX-resource-events.md +++ b/proposals/XXXX-resource-events.md @@ -24,7 +24,7 @@ We bring this proposal to the Tasks and Events working group for discussion, and ## References -- **SEP-3415, Events,** [modelcontextprotocol#3415](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3415), a draft, by reference at commit `b08e4f9`. This extension takes its `events/subscribe` and its webhook delivery, and sends an event of its own, `mcp.resource.collection.updated` rather than the `mcp.resource.updated` its Open Question 2 sketches. +- **SEP-3415, Events,** [modelcontextprotocol#3415](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3415), a draft, by reference at commit `b08e4f9`. This extension takes its subscriptions and delivery as they stand, and sends an event of its own, `mcp.resource.collection.updated` rather than the `mcp.resource.updated` its Open Question 2 sketches. - **The Filesystems strawman,** [experimental-ext-filesystems#2](https://github.com/modelcontextprotocol/experimental-ext-filesystems/pull/2), the Filesystems working group's draft of resource operations, at commit `db67f78`, which the rest of this proposal calls "the strawman". It adds `resources/stat`, `resources/create`, `resources/update` and `resources/delete`. Each resource has a `revision`, a write names the one it expects as `ifMatch`, and a write's result holds a change-set entry for each resource it changed, such as `{uri, effect: "updated", previousRevision, revision}`. This extension takes its `revision` and its change-set entry, and the prototype builds its four methods. - **The Filesystems problem statements,** [experimental-ext-filesystems](https://github.com/modelcontextprotocol/experimental-ext-filesystems/blob/main/docs/problem-statements.md), that working group's framing of its work. Problem Statement 6 asks a proposal to "Decide whether a client can subscribe to a directory", and asks "Does a client need ordering between its write response and the resulting notification?" A collection answers the first (R24), and revisions the second, since a client orders messages by revision rather than arrival (R15). This extension offers both for that group to weigh. - **MCP 2026-07-28,** [the specification](https://modelcontextprotocol.io/specification/2026-07-28), the revision this extension builds on, for `resources/read`, pagination and `CacheableResult`. @@ -387,7 +387,7 @@ covers(listed, clientTemplate): ### Declaring the extension -- **R20.** A server declares this extension in `capabilities.extensions` under a provisional identifier, `io.modelcontextprotocol/resource-events`, as SEP-2133 asks. A server that declares it MUST serve `resources/collection/read` and `resources/collection/catchup`, and list `mcp.resource.collection.updated` in `events/list` with webhook delivery. The extension reserves the prefix `resources/collection/` for its methods, beside core's and the strawman's under `resources/`. The event's name waits on Open Question 2's decision about an `mcp.*` prefix. +- **R20.** A server declares this extension in `capabilities.extensions` under a provisional identifier, `io.modelcontextprotocol/resource-events`, as SEP-2133 asks. A server that declares it MUST serve `resources/collection/read` and `resources/collection/catchup`, and list `mcp.resource.collection.updated` in `events/list`, with whichever of SEP-3415's delivery modes it offers. The extension reserves the prefix `resources/collection/` for its methods, beside core's and the strawman's under `resources/`. The event's name waits on Open Question 2's decision about an `mcp.*` prefix. ### uriGlob, later @@ -445,7 +445,7 @@ None. The extension adds two methods and one event, and binds only a server that ## Security Implications -This extension sends its events by SEP-3415's webhook delivery as it stands, so that SEP's rules on callback URLs, signing and endpoint verification cover every POST. It adds four points of its own: +This extension sends its events by SEP-3415's delivery modes as they stand, so that SEP's rules cover every delivery, as its rules on callback URLs, signing and endpoint verification cover every webhook POST. It adds four points of its own: - **Events carry no contents** (R10), so a delivery that reaches the wrong receiver reveals uris and revisions, never what a resource holds. - **A catch-up or a collection read is authorized as a `resources/read`** of each resource it covers (R13, R22), so catch-ups and reads MUST cover only what the principal may read, and events SHOULD (R25). From acbeb305204bef51bc024e3c6bfba533b1293288 Mon Sep 17 00:00:00 2001 From: Larry Maccherone Date: Thu, 5 Nov 2026 07:59:56 -0500 Subject: [PATCH 11/15] Shape a catch-up's patch as a write's contents (R19, D26) A patch is {mimeType, text} or {mimeType, blob}, as a strawman write's contents is, in place of a patchMimeType beside a patch carried as JSON for JSON-based formats. Co-Authored-By: Claude Opus 5.5 --- proposals/XXXX-resource-events.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/proposals/XXXX-resource-events.md b/proposals/XXXX-resource-events.md index 3c1bf24..c777f95 100644 --- a/proposals/XXXX-resource-events.md +++ b/proposals/XXXX-resource-events.md @@ -206,8 +206,8 @@ Y already holds ticket-3, the event's one change, at `"108"`, so it skips the ca {"changes": [ {"uri": "tickets/ticket-3", "effect": "updated", "previousRevision": "101", "revision": "108", - "patchMimeType": "application/merge-patch+json", - "patch": {"status": "closed"}}], + "patch": {"mimeType": "application/merge-patch+json", + "text": "{\"status\":\"closed\"}"}}], "hasMore": false, "nextFrom": "108", "nextPollMs": 30000} ``` @@ -275,7 +275,7 @@ A catch-up on `{type}/{id}` brings ticket-9 and its note as they stood at one mo ### What a catch-up brings -- **R19.** Each entry in `changes` is one resource's change from `from` to the catch-up's `nextFrom` (R13): the strawman's change-set entry, with contents added. `previousRevision` is the resource's revision at `from`, absent if it did not exist then. `effect` is `deleted` if the change ends with the resource gone, else `created` if it did not exist at `from`, else `updated`. A non-deletion MUST carry `contents`, one `TextResourceContents` or `BlobResourceContents`, or a `patch` from the contents at `previousRevision`, with its `patchMimeType`, in a listed format that applies. A merge patch ([RFC 7396](https://www.rfc-editor.org/rfc/rfc7396)) applies to one `application/json` text entry, and patches its parsed `text`. JSON-based patches travel as JSON, others as strings. The server MUST send `contents` when no listed format applies, when there is no `previousRevision`, or when it no longer keeps those contents, and MAY send them whenever they're smaller than the patch. +- **R19.** Each entry in `changes` is one resource's change from `from` to the catch-up's `nextFrom` (R13): the strawman's change-set entry, with contents added. `previousRevision` is the resource's revision at `from`, absent if it did not exist then. `effect` is `deleted` if the change ends with the resource gone, else `created` if it did not exist at `from`, else `updated`. A non-deletion MUST carry `contents`, one `TextResourceContents` or `BlobResourceContents`, or a `patch` from the contents at `previousRevision`, in a listed format that applies. A `patch` takes the shape of a strawman write's `contents`, a `text` or a `blob` with no `uri`, whose `mimeType` names the patch's format (D26). A merge patch ([RFC 7396](https://www.rfc-editor.org/rfc/rfc7396)) applies to one `application/json` text entry, and patches its parsed `text`. The server MUST send `contents` when no listed format applies, when there is no `previousRevision`, or when it no longer keeps those contents, and MAY send them whenever they're smaller than the patch. - A client MUST apply a patch only to a copy at its `previousRevision`. Otherwise it reads the resource (D2). ### Less common cases, most likely first @@ -324,7 +324,7 @@ X then catches up from `"107"`: ```jsonc {"uri": "tickets/ticket-3", "effect": "updated", "previousRevision": "101", "revision": "110", // ticket-3 as of "107" - "patchMimeType": "application/merge-patch+json", "patch": {...}} + "patch": {"mimeType": "application/merge-patch+json", "text": "..."}} // X's copy is at "109", not "101", so X reads ticket-3 with resources/read. ``` @@ -419,6 +419,8 @@ Each decision keeps the number the rules cite it by, and opens by restating its **D2. A patch starts from `previousRevision`.** A catch-up's patch applies to the resource as it stood at the client's `from`, and a client whose copy is at another revision reads the resource instead (R19). Patching from each copy's own revision would need the server to learn every copy's revision, which a catch-up never sends. Sending contents only would give up much of the saving. +**D26. A patch is shaped as a write's contents.** A `patch` is `{mimeType, text}` or `{mimeType, blob}`, as a strawman write's `contents` is, with its `mimeType` naming the patch's format (R19). A media type names a patch's format as it names a resource's, so a change carries its `contents` or its `patch` in one shape, much as HTTP's PATCH sends its patch as a body under its `Content-Type`. A binary patch format, such as VCDIFF for a blob, goes in `blob`. The cost is escaping a JSON patch's quotes. Carrying a JSON-based patch as JSON, beside a `patchMimeType`, reads better, but decodes the field two ways, by whether the format is JSON-based, and leaves it with no type a schema can check. The strawman defers patches, so its write `contents` is the nearest shape to match. + **D4. A `from` too old gets `momentExpired`.** The client then starts over (R21), as Kubernetes answers a watch from a compacted `resourceVersion` with `410 Gone`, and core an expired cursor with `-32602`. A flag on an ordinary result carrying the whole current state, or SEP-3415's `truncated: true`, is a signal a client could miss, and so keep copies of resources since deleted. **D12. A catch-up's result is a plain result, and paces polling with `nextPollMs`.** A client that polls waits the result's `nextPollMs`, as in SEP-3415's poll mode (R13, R14). Core's `CacheableResult` would serve badly: its `ttlMs` is a freshness hint, which clients "SHOULD NOT treat … as a polling interval", and a cache keyed on the request could answer step 6's catch-up with step 3's empty result, since both send `from` `"107"`. A rule that a client never answers a catch-up from a cache would bind no gateway that follows core, and with no pacing field, a server would have no way to slow clients that poll. A collection read stays a `CacheableResult` (R22), since a cached read is an older snapshot whose `nextFrom` matches it. @@ -465,7 +467,7 @@ A client library, `src/client/copies.ts`, keeps copies by the client's rules: R9 ### Tested, and not yet tested -- **457 tests pass,** and a check fails the run unless a passing test names every rule above but R25. Each new test was mutation-checked: broken code turned it red. +- **460 tests pass,** and a check fails the run unless a passing test names every rule above but R25. Each new test was mutation-checked: broken code turned it red. - **Each MUST of SEP-3415's that the prototype uses has a test.** - **MCP's conformance suite,** `@modelcontextprotocol/conformance` 0.2.0-alpha.12 at revision 2026-07-28, passes against A and against the client, with a baseline that lists the features neither offers, such as prompts. - **A deployed tier runs on Cloudflare.** Two clients read the tickets, subscribe and catch up. One closes a ticket and skips its own event, and the other catches up with a merge patch. From 292f70602d4fea5e3e9001f8505ccf73f84a03d4 Mon Sep 17 00:00:00 2001 From: Larry Maccherone Date: Thu, 5 Nov 2026 09:28:36 -0500 Subject: [PATCH 12/15] Count 461 tests Co-Authored-By: Claude Opus 5.5 --- proposals/XXXX-resource-events.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/proposals/XXXX-resource-events.md b/proposals/XXXX-resource-events.md index c777f95..f4be937 100644 --- a/proposals/XXXX-resource-events.md +++ b/proposals/XXXX-resource-events.md @@ -467,7 +467,7 @@ A client library, `src/client/copies.ts`, keeps copies by the client's rules: R9 ### Tested, and not yet tested -- **460 tests pass,** and a check fails the run unless a passing test names every rule above but R25. Each new test was mutation-checked: broken code turned it red. +- **461 tests pass,** and a check fails the run unless a passing test names every rule above but R25. Each new test was mutation-checked: broken code turned it red. - **Each MUST of SEP-3415's that the prototype uses has a test.** - **MCP's conformance suite,** `@modelcontextprotocol/conformance` 0.2.0-alpha.12 at revision 2026-07-28, passes against A and against the client, with a baseline that lists the features neither offers, such as prompts. - **A deployed tier runs on Cloudflare.** Two clients read the tickets, subscribe and catch up. One closes a ticket and skips its own event, and the other catches up with a merge patch. From 0432da1435d5f14dee2bd7cfa06dcf3645d2e6b6 Mon Sep 17 00:00:00 2001 From: Larry Maccherone Date: Thu, 5 Nov 2026 10:06:03 -0500 Subject: [PATCH 13/15] Count 463 tests Co-Authored-By: Claude Opus 5.5 --- proposals/XXXX-resource-events.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/proposals/XXXX-resource-events.md b/proposals/XXXX-resource-events.md index f4be937..f5a1364 100644 --- a/proposals/XXXX-resource-events.md +++ b/proposals/XXXX-resource-events.md @@ -467,7 +467,7 @@ A client library, `src/client/copies.ts`, keeps copies by the client's rules: R9 ### Tested, and not yet tested -- **461 tests pass,** and a check fails the run unless a passing test names every rule above but R25. Each new test was mutation-checked: broken code turned it red. +- **463 tests pass,** and a check fails the run unless a passing test names every rule above but R25. Each new test was mutation-checked: broken code turned it red. - **Each MUST of SEP-3415's that the prototype uses has a test.** - **MCP's conformance suite,** `@modelcontextprotocol/conformance` 0.2.0-alpha.12 at revision 2026-07-28, passes against A and against the client, with a baseline that lists the features neither offers, such as prompts. - **A deployed tier runs on Cloudflare.** Two clients read the tickets, subscribe and catch up. One closes a ticket and skips its own event, and the other catches up with a merge patch. From e0dd5d390651550acf1980f7f7a750e65800e54f Mon Sep 17 00:00:00 2001 From: Larry Maccherone Date: Thu, 5 Nov 2026 11:27:53 -0500 Subject: [PATCH 14/15] Name python-sdk#3822 as a base for the official SDK implementation Co-Authored-By: Claude Opus 5.5 --- proposals/XXXX-resource-events.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/proposals/XXXX-resource-events.md b/proposals/XXXX-resource-events.md index f5a1364..564f8d2 100644 --- a/proposals/XXXX-resource-events.md +++ b/proposals/XXXX-resource-events.md @@ -465,6 +465,8 @@ This extension sends its events by SEP-3415's delivery modes as they stand, so t A client library, `src/client/copies.ts`, keeps copies by the client's rules: R9, R11, R12, R15, R16, R18, R19, R21 and R23. A serves two layouts in its tests. One puts tickets and notes on one timeline and also lists `{type}/{id}`. The other gives each its own timeline, so closing a ticket closes its note at a moment on each, which breaks R4 on purpose, to test a write that commits once per timeline. The deployed Worker serves the first. +An implementation in an official SDK comes next, since an Extensions Track SEP needs one before review. It can go in the TypeScript SDK, or build on [python-sdk#3822](https://github.com/modelcontextprotocol/python-sdk/pull/3822), which adds SEP-3415's poll and webhook delivery to the Python SDK, the delivery this extension's events go out by. + ### Tested, and not yet tested - **463 tests pass,** and a check fails the run unless a passing test names every rule above but R25. Each new test was mutation-checked: broken code turned it red. From e9483ceb015c0af45bc8bbffa7dc12480a443e80 Mon Sep 17 00:00:00 2001 From: Larry Maccherone Date: Wed, 11 Nov 2026 16:26:18 -0500 Subject: [PATCH 15/15] Replace read, subscribe and catch-up with one resources/collection/sync, and add previousMoment One method reads a collection, subscribes and catches up, so the gap between reading and subscribing is gone (D28). Each event names the moment of the one before it on the same subscription, so a lost event is found at the next one and a writer moves forward with no call (D29). Q8 asks whether a method other than events/subscribe may create an Events subscription. SEP-3415 is cited as Accepted on 2026-11-11. The examples' code blocks are set one field to a line. Co-Authored-By: Claude Fable 5.2 --- proposals/XXXX-resource-events.md | 353 ++++++++++++++++++------------ 1 file changed, 217 insertions(+), 136 deletions(-) diff --git a/proposals/XXXX-resource-events.md b/proposals/XXXX-resource-events.md index 564f8d2..d695c72 100644 --- a/proposals/XXXX-resource-events.md +++ b/proposals/XXXX-resource-events.md @@ -10,21 +10,21 @@ ## Abstract -This extension lets an MCP client keep copies of resources current. A server records each change to its resources at a moment on a timeline, a string that sorts after every moment before it, such as `"108"`. For each change, it sends `mcp.resource.collection.updated`, an event of SEP-3415's Events extension, to each subscription covering a resource the change touched. The event names the collection the subscription watches and each resource in it that changed, with its new revision, and carries none of their contents. +This extension lets an MCP client keep copies of resources current. A server records each change to its resources at a moment on a timeline, a string that sorts after every moment before it, such as `"108"`. For each change, it sends `mcp.resource.collection.updated`, an event of SEP-3415's Events extension, to each subscription covering a resource the change touched. The event names the collection the subscription watches, each resource in it that changed, with its new revision, and the moment of the event before it on that subscription. It carries none of their contents. -A client that already holds each named revision, such as the client that made the change, skips the event. Any other catches up with `resources/collection/catchup`, which brings every change to the collection after the moment the client is caught up to, each as contents or a patch. +A client keeps a collection with one method, `resources/collection/sync`. Its first sync reads every resource in the collection, and, given a `delivery`, subscribes in the same step. A client that has accounted for the event before, and already holds each revision this one names, such as the client that made the change, makes no call, and is caught up to the event's moment. Any other syncs again, naming the moment it is caught up to, and gets every change to the collection after it, each as contents or a patch. -A new client first reads the collection with `resources/collection/read`. Writes use the Filesystems working group's strawman methods, and a resource's revision is the moment it last changed. +Writes use the Filesystems working group's strawman methods, and a resource's revision is the moment it last changed. -An event carries no state, so a lost, repeated or late one does no harm: the next catch-up brings whatever it missed. A working prototype builds all of it, as § *Reference Implementation* describes. +An event carries no state, so a lost, repeated or late one does no harm: the next event shows that one went unaccounted for, and the next sync brings whatever it missed. A working prototype builds all of it, as § *Reference Implementation* describes. -While the prototype and § *Motivation* mention webhooks explicitly, nothing in this proposal prevents its event from being delivered over an SSE stream, by SEP-3415's push delivery. Further, the proposal and the prototype both support a client that polls, with no stream at all (§ *Use cases*). +While the prototype and § *Motivation* mention webhooks explicitly, nothing in this proposal prevents its event from being delivered over an SSE stream, by SEP-3415's push delivery, to a client that subscribed with `events/subscribe` (R18). Further, the proposal and the prototype both support a client that polls, with no stream at all (§ *Use cases*). We bring this proposal to the Tasks and Events working group for discussion, and are looking for a sponsor. ## References -- **SEP-3415, Events,** [modelcontextprotocol#3415](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3415), a draft, by reference at commit `b08e4f9`. This extension takes its subscriptions and delivery as they stand, and sends an event of its own, `mcp.resource.collection.updated` rather than the `mcp.resource.updated` its Open Question 2 sketches. +- **SEP-3415, Events,** [modelcontextprotocol#3415](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3415), Accepted on 2026-11-11, by reference at commit `b08e4f9`. This extension takes its subscriptions and delivery as they stand, makes a subscription through a method of its own as well as through `events/subscribe` (D28), and sends an event of its own, `mcp.resource.collection.updated` rather than the `mcp.resource.updated` its Open Question 2 sketches. - **The Filesystems strawman,** [experimental-ext-filesystems#2](https://github.com/modelcontextprotocol/experimental-ext-filesystems/pull/2), the Filesystems working group's draft of resource operations, at commit `db67f78`, which the rest of this proposal calls "the strawman". It adds `resources/stat`, `resources/create`, `resources/update` and `resources/delete`. Each resource has a `revision`, a write names the one it expects as `ifMatch`, and a write's result holds a change-set entry for each resource it changed, such as `{uri, effect: "updated", previousRevision, revision}`. This extension takes its `revision` and its change-set entry, and the prototype builds its four methods. - **The Filesystems problem statements,** [experimental-ext-filesystems](https://github.com/modelcontextprotocol/experimental-ext-filesystems/blob/main/docs/problem-statements.md), that working group's framing of its work. Problem Statement 6 asks a proposal to "Decide whether a client can subscribe to a directory", and asks "Does a client need ordering between its write response and the resulting notification?" A collection answers the first (R24), and revisions the second, since a client orders messages by revision rather than arrival (R15). This extension offers both for that group to weigh. - **MCP 2026-07-28,** [the specification](https://modelcontextprotocol.io/specification/2026-07-28), the revision this extension builds on, for `resources/read`, pagination and `CacheableResult`. @@ -49,7 +49,7 @@ A client watching resources today has four problems: SEP-3415's Open Question 2 sketches a minimal resource subscription over Events, with "a reserved `mcp.resource.updated` event type whose arguments are `{uri}`", re-expressing core's `notifications/resources/updated`, which names one changed resource. This extension leaves that event to Open Question 2, and sends its own, `mcp.resource.collection.updated`, naming a collection and every resource in it that one change touched (D19). -**However, a resource is state, and a watcher cares only about the latest version, not every change.** A receiver that was down while a ticket was edited, reopened and closed needs only the closed ticket. On SEP-3415's thread on 2026-10-16, Peter Alexander (@pja-ant) [wrote](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3415#issuecomment-6215654779) that watching state and consuming messages "will need different handling and different APIs". The two "should likely share a bunch of the same machinery", he went on, but whether they should share an API "requires some thought and discussion". Here the shared machinery is the event, which carries only which resources changed and at which moment, and the different API is the catch-up, which brings the newest values. SEP-3415 itself points this way: servers "SHOULD keep event payloads minimal", agents that need stronger guarantees should "use tools to read authoritative state", and servers "MAY implement resource watching through events". +**However, a resource is state, and a watcher cares only about the latest version, not every change.** A receiver that was down while a ticket was edited, reopened and closed needs only the closed ticket. On SEP-3415's thread on 2026-10-16, Peter Alexander (@pja-ant) [wrote](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3415#issuecomment-6215654779) that watching state and consuming messages "will need different handling and different APIs". The two "should likely share a bunch of the same machinery", he went on, but whether they should share an API "requires some thought and discussion". Here the shared machinery is the event and its delivery, which carry only which resources changed and at which moment, and the different API is the sync, which brings the newest values. SEP-3415 itself points this way: servers "SHOULD keep event payloads minimal", agents that need stronger guarantees should "use tools to read authoritative state", and servers "MAY implement resource watching through events". ### Why is this different from task state sync? @@ -57,20 +57,20 @@ SEP-3415's Open Question 2 sketches a minimal resource subscription over Events, ### Why now? -**MCP is getting webhooks.** SEP-3415's webhook delivery is the catalyst for this work, and this extension takes it as it stands. Clients accept these webhooks already, while SEP-3415 is still a draft: [ChatGPT](https://developers.openai.com/plugins/build/mcp-events) receives Events by webhook on protocol `2026-07-28`, as do the MCP packages of Vercel's AI SDK, Mastra and TanStack AI, in versions released in October 2026, and OpenClaw bundles an opt-in `mcp-events` plugin. +**MCP is getting webhooks.** SEP-3415's webhook delivery is the catalyst for this work, and this extension takes it as it stands. SEP-3415 was accepted on 2026-11-11, and clients were taking these webhooks before then: [ChatGPT](https://developers.openai.com/plugins/build/mcp-events) receives Events by webhook on protocol `2026-07-28`, as do the MCP packages of Vercel's AI SDK, Mastra and TanStack AI, in versions released in October 2026, and OpenClaw bundles an opt-in `mcp-events` plugin. **MCP now serves skills as resources.** [Skills over MCP](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/seps/2640-skills-extension.md), SEP-2640, is Final, so every client that adopts it reads resources, and may use a skill only while its files match the manifest it approved. Its group chose resources in part for "a subscription model for updates", as its [decision log](https://github.com/modelcontextprotocol/ext-skills/blob/main/docs/decisions.md) records, which since 2026-07-28 reaches a client only over an open `subscriptions/listen` stream. ### What the working group's charter asks -The Tasks and Events working group's [charter](https://modelcontextprotocol.io/community/triggers-events/charter) asks for a callback mechanism "with defined ordering guarantees that hold across all transports". SEP-3415 guarantees no order across subscriptions, and says "Servers that need stronger guarantees can implement them". A timeline is such a guarantee: it orders a server's changes, which commit in the order of their moments (R5), rather than its events, which still arrive in any order (R15), and it holds whichever transport prompts a catch-up. +The Tasks and Events working group's [charter](https://modelcontextprotocol.io/community/triggers-events/charter) asks for a callback mechanism "with defined ordering guarantees that hold across all transports". SEP-3415 guarantees no order across subscriptions, and says "Servers that need stronger guarantees can implement them". A timeline is such a guarantee: it orders a server's changes, which commit in the order of their moments (R5), rather than its events, which still arrive in any order (R15), and it holds whichever transport prompts a sync. ### Use cases - **A client behind a gateway.** A client on a laptop or in a browser, with no URL a server can reach, names its gateway's URL, as in SEP-3415's forward-proxy deployment. -- **A collection of documents.** A client subscribes to `https://notes.example/notes/{id}`, hears of each note created, edited or deleted, and catches up after a night away. -- **A client that can't take webhooks.** A command-line client polls `resources/collection/catchup` for `https://notes.example/notes/{id}` as often as each result's `nextPollMs` asks: one request for the whole collection, and an empty result when nothing changed. -- **A skill served over MCP.** A client that loaded `skill://code-review/SKILL.md` subscribes to `skill://code-review/{+path}`, hears of each changed file, and can ask for approval again before the model next uses the skill. +- **A collection of documents.** A client syncs `https://notes.example/notes/{id}` with a `delivery`, hears of each note created, edited or deleted, and syncs once after a night away. +- **A client that can't take webhooks.** A command-line client polls `resources/collection/sync` for `https://notes.example/notes/{id}`, with no `delivery`, as often as each result's `nextPollMs` asks: one request for the whole collection, and an empty result when nothing changed. +- **A skill served over MCP.** A client that loaded `skill://code-review/SKILL.md` syncs `skill://code-review/{+path}` with a `delivery`, hears of each changed file, and can ask for approval again before the model next uses the skill. § *Specification* answers the four problems. § *Rationale* says why, against the alternatives. @@ -84,7 +84,7 @@ The Tasks and Events working group's [charter](https://modelcontextprotocol.io/c | `"107"` | ticket-7 closed as a duplicate of ticket-9, both changed in one atomic step | | `"108"` | ticket-3 closed | -The server keeps the latest moment and mints each new one later (R5). An event names each resource that changed and its new revision, so a client that already holds that revision skips it, and a catch-up brings every change after the moment the client is caught up to. A database's read replica follows its primary's oplog the same way, and wherever it stops, it holds the data as of that point, which may be behind the primary. A catch-up differs in sending each resource once, as its contents or a patch from an earlier revision, rather than every operation, but the idea is the same. +The server keeps the latest moment and mints each new one later (R5). An event names each resource that changed and its new revision, so a client that already holds that revision makes no call, and a sync brings every change after the moment the client is caught up to. A database's read replica follows its primary's oplog the same way, and wherever it stops, it holds the data as of that point, which may be behind the primary. A sync differs in sending each resource once, as its contents or a patch from an earlier revision, rather than every operation, but the idea is the same. Rules are numbered R1 and up across the sections below. Each keeps its ID when others are added or removed, so the IDs follow the order the rules were raised, not the order shown, and a gap is from a rule that was dropped, or that binds only how a server is built. The key words MUST, MUST NOT, SHOULD and MAY are to be read as BCP 14 ([RFC 2119](https://www.rfc-editor.org/rfc/rfc2119), [RFC 8174](https://www.rfc-editor.org/rfc/rfc8174)) defines them when, and only when, they appear in capitals. @@ -93,85 +93,106 @@ Rules are numbered R1 and up across the sections below. Each keeps its ID when o | Term | Meaning | Example | |---|---|---| | server | An MCP server as its clients see it, at one endpoint, however many processes, replicas or database nodes serve it. | replicas sharing one database | -| client | An MCP client, together with the application it runs in, which reads, subscribes, catches up and keeps copies. MCP's architecture calls that application the host, and this proposal says client throughout. | X and Y below | +| client | An MCP client, together with the application it runs in, which syncs, subscribes and keeps copies. MCP's architecture calls that application the host, and this proposal says client throughout. | X and Y below | | uri-specification | A uri, a uriTemplate, or later a uriGlob (§ *uriGlob, later*). A server lists a uri in `resources/list` and a uriTemplate in `resources/templates/list`. | `tickets/{id}` | | collection | The resources a uri-specification covers. A uri on its own is a collection of one. | every ticket `tickets/{id}` covers, or `tickets/ticket-3` alone | | timeline | The server's ordered record of the changes to a set of resources. | `tickets` below | | moment | The point on a timeline at which a change occurred, a string that sorts after every moment before it. | `"108"` | | latest moment | A timeline's most recent moment. A server stores it, and mints each new moment later than it (R5). | `"107"`, for `tickets` below | -| revision | A moment at which a resource changed, which the Filesystems strawman's fields `revision` and `previousRevision` hold. A resource's revision, with nothing more said, is its latest, as `resources/stat` and `resources/read` report it (R7). | ticket-3's `"101"`, and `"108"` from step 4 | +| revision | A moment at which a resource changed, which the Filesystems strawman's fields `revision` and `previousRevision` hold. A resource's revision, with nothing more said, is its latest, as `resources/stat` and `resources/read` report it (R7). | ticket-3's `"101"`, and `"108"` from step 2 | | tombstone | What a deleted resource leaves: its uri and the moment it was deleted. | ticket-3, deleted at `"111"` | | copy | The contents a client last received or wrote for a resource, with the resource's revision then. | ticket-3's JSON at `"101"` | -| catch-up | A `resources/collection/catchup` call and its result: every change to a uri-specification after the moment the client is caught up to, up to the result's `nextFrom`. A client is caught up, on each uri-specification, to the last `nextFrom` it applied in full for it, and holds every change to it at or before that. | steps 3 and 6 below | +| sync | A `resources/collection/sync` call and its result. | steps 1 and 4 below | +| first read | A sync that names no `from`: every resource in the collection, each as a `created` entry with its contents. | step 1 below | +| catch-up | A sync that names a `from`: every change to a uri-specification after that moment, up to the result's `nextFrom`. A client is caught up, on each uri-specification, to the last `nextFrom` it applied in full for it, or to the moment of an event that moved it past that (R11), and holds every change to it at or before that. | step 4 below | ### A simple happy path -Server A lists the uriTemplate `tickets/{id}` in `resources/templates/list`, whose timeline's latest moment is `"107"`: ticket-3 last changed at `"101"`, and ticket-7 and ticket-9 at `"107"`. Two clients, X and Y, hold no copies yet, and each does steps 1 to 3. Each table shows what the server and each client hold after its step, in bold where the step changed it. The examples leave out JSON-RPC envelopes and core's result fields. +Server A lists the uriTemplate `tickets/{id}` in `resources/templates/list`, whose timeline's latest moment is `"107"`: ticket-3 last changed at `"101"`, and ticket-7 and ticket-9 at `"107"`. Two clients, X and Y, hold no copies yet, and each does step 1. Each table shows what the server and each client hold after its step, in bold where the step changed it. The examples leave out JSON-RPC envelopes and core's result fields. -#### Step 1, at `"107"`: X and Y read the collection +#### Step 1, at `"107"`: X and Y sync -Each client reads the collection, which brings every ticket that exists. +Each client syncs the collection, and names where its events go. The one call brings every ticket that exists, and subscribes. ```jsonc -{"method": "resources/collection/read", "params": {"uriTemplate": "tickets/{id}"}} -{"contents": [ - {"uri": "tickets/ticket-3", "mimeType": "application/json", - "text": "{\"title\":\"Printer jams\",\"status\":\"open\"}", - "_meta": {"io.modelcontextprotocol/resource-revision": "101"}} - // ...and ticket-7 and ticket-9, each at "107" - ], "nextFrom": "107"} +{ + "method": "resources/collection/sync", + "params": { + "uriTemplate": "tickets/{id}", + "delivery": { + "mode": "webhook", + "url": "https://gateway.example/hooks/x", + "secret": "whsec_..." + } + } +} + +{ + "changes": [ + { + "uri": "tickets/ticket-3", + "effect": "created", + "revision": "101", + "contents": { + "uri": "tickets/ticket-3", + "mimeType": "application/json", + "text": "{\"title\":\"Printer jams\",\"status\":\"open\"}" + } + } + // ...and ticket-7 and ticket-9, each at "107" + ], + "hasMore": false, + "nextFrom": "107", + "nextPollMs": 30000, + "subscription": { + "id": "sub_a3f1c8e2b0d49f7e", + "refreshBefore": "2026-11-03T17:00:00.000Z" + } +} ``` -Each client now holds every ticket, and is caught up to `"107"`, the read's `nextFrom`. +Each client now holds every ticket, is subscribed, and is caught up to `"107"`, the sync's `nextFrom`. A read the tickets and wrote the subscription in one step, so no change can fall between the two. | | Server A | Client X | Client Y | |---|---|---|---| | latest moment | `"107"` | | | +| subscriptions | **X's, Y's** | | | | caught up to | | **`"107"`** | **`"107"`** | | ticket-3 | `"101"`, open | **`"101"`, open** | **`"101"`, open** | | ticket-7 and ticket-9 | `"107"` | **`"107"`** | **`"107"`** | -- **R22.** `resources/collection/read` names a uri-specification as a catch-up does, in its params and in `Mcp-Name`, gets the same errors for it, and is authorized the same way (R13), but takes no `from`. It returns the contents of each resource in the collection, each item as `resources/read` returns it, with its revision in `_meta` (R7), and `nextFrom`, the timeline's latest moment. Each resource MUST be as it stood at `nextFrom` or later. The client catches up from `nextFrom` afterwards, and a long read comes in pages (R26). Its result is a core `CacheableResult`. - - A uri on its own is a collection of one, and a client watching one SHOULD read it with `resources/collection/read` too, rather than `resources/read`, since only a collection read brings a `nextFrom` to catch up from. A copy's revision can be older than the history the server keeps, so a catch-up from it could get `momentExpired` (R21), while `nextFrom` is the timeline's latest moment. +- **R22.** A client keeps a collection with `resources/collection/sync`. A sync names a uri-specification, as `uri` or `uriTemplate`, and over Streamable HTTP its request MUST carry `Mcp-Name` holding that `uri` or `uriTemplate`, as `resources/read`'s carries its `uri`. The server MUST authorize it as a `resources/read` of each covered resource (R25), and MUST answer `InvalidParams` (`-32602`), with `data.reason` `"notFound"`, to one naming a uri-specification it neither lists nor covers (R24). A sync with no `from` is a first read. Its `changes` hold an entry for each resource in the collection, with `effect` `created`, its `revision` and its `contents`, and its `nextFrom` is the timeline's latest moment. Each resource MUST be as it stood at `nextFrom` or later. A long first read comes in pages (R26). A sync's result is a plain result, not a core `CacheableResult` (D12). + - A uri on its own is a collection of one, and a client watching one SHOULD first read it with a sync too, rather than `resources/read`, since only a sync brings a `nextFrom` to sync from next. A copy's revision can be older than the history the server keeps, so a sync from it could get `momentExpired` (R21), while `nextFrom` is the timeline's latest moment. +- **R18.** A client subscribes by sending a `delivery` in a sync: SEP-3415's webhook delivery, with that SEP's `ttlMs` beside it if the client suggests a lifetime. The server MUST process it under every rule SEP-3415 sets for an `events/subscribe` of `mcp.resource.collection.updated` with the sync's uri-specification as its `arguments`: the principal in the subscription's key, the checks of the callback URL and the secret, the verification handshake and its limits, and the grant. It MUST write the subscription, or refresh it, in one atomic step with the read of the result's `nextFrom`, so no change falls between the two. The result's `subscription` holds SEP-3415's `id` and `refreshBefore`, and its `deliveryStatus` when the server reports one. A sync answered with an error MUST NOT write or refresh a subscription. A client new to a uri-specification sends its `delivery` in its first read, and refreshes with one in a sync from the moment it is caught up to (R12). It unsubscribes with SEP-3415's `events/unsubscribe`. + - The server lists the event in `events/list` (R20), so a client MAY subscribe with `events/subscribe` instead, naming the uri-specification as `arguments`: `{uri}`, `{uriTemplate}`, or later `{uriGlob}`. It is the same subscription, with the same `id`, and the way to SEP-3415's other delivery modes. A client that subscribes that way MUST then sync, with no `delivery`, so no change falls between its last sync and the subscription. -#### Step 2, at `"107"`: X and Y subscribe - -Each client subscribes through SEP-3415, naming the uri-specification as the event's `arguments`. - -```jsonc -{"method": "events/subscribe", "params": { - "name": "mcp.resource.collection.updated", - "arguments": {"uriTemplate": "tickets/{id}"}, - "delivery": {"mode": "webhook", "url": "https://gateway.example/hooks/x", - "secret": "whsec_..."}}} -``` - -A now holds a subscription for each client, and nothing else changes. - -- **R18.** A client subscribes with `events/subscribe`, naming `mcp.resource.collection.updated` and a uri-specification as `arguments`: `{uri}`, `{uriTemplate}`, or later `{uriGlob}`, and refreshes the subscription as SEP-3415 says. A client new to the uri-specification SHOULD first read its collection (R22). Once subscribed, it MUST catch up, so no change falls between the read and the subscription. - -#### Step 3, at `"107"`: X and Y catch up - -Each client catches up from `"107"`, since a change made after its read and before its subscription took hold sends it no event. Here there is none, so the result is empty and nothing changes. Had a ticket changed in that gap, it would come here, as ticket-3's change does in step 6. - -```jsonc -{"method": "resources/collection/catchup", "params": {"uriTemplate": "tickets/{id}", - "from": "107", "patchMimeTypes": ["application/merge-patch+json"]}} -{"changes": [], "hasMore": false, "nextFrom": "107", "nextPollMs": 30000} -``` - -#### Step 4, at `"108"`: Y closes ticket-3 +#### Step 2, at `"108"`: Y closes ticket-3 Y closes ticket-3 with the strawman's `resources/update`, sending the whole closed ticket, and its copy's revision as `ifMatch` (R7). ```jsonc -{"method": "resources/update", "params": {"uri": "tickets/ticket-3", - "ifMatch": "101", "contents": {...}}} -{"changes": [{"uri": "tickets/ticket-3", "effect": "updated", - "previousRevision": "101", "revision": "108"}]} +{ + "method": "resources/update", + "params": { + "uri": "tickets/ticket-3", + "ifMatch": "101", + "contents": {...} + } +} + +{ + "changes": [ + { + "uri": "tickets/ticket-3", + "effect": "updated", + "previousRevision": "101", + "revision": "108" + } + ] +} ``` -Y's copy of ticket-3 is at `"108"`, but Y stays caught up to `"107"`, since others' changes could have landed before `"108"` (R9). +Y's copy of ticket-3 is at `"108"`, but Y stays caught up to `"107"` for now, since others' changes could have landed before `"108"`, and the reply can't say (R9). | | Server A | Client X | Client Y | |---|---|---|---| @@ -182,53 +203,90 @@ Y's copy of ticket-3 is at `"108"`, but Y stays caught up to `"107"`, since othe - **R17.** A server MUST store a create's or an update's contents as sent, so the writer's copy is the resource at the reply's `revision`. -#### Step 5, at `"108"`: A sends the event +#### Step 3, at `"108"`: A sends the event -A POSTs the event to each subscription's webhook. It names the subscription's uri-specification and the change, and carries none of the ticket's contents, so no copy changes. +A POSTs the event to each subscription's webhook. It names the subscription's uri-specification and the change. It also names `"107"` as `previousMoment`: the moment of the event before this one on the subscription, or, as here, the moment the subscription began. It carries none of the ticket's contents, so no copy changes. ```jsonc -{"eventId": "108", "name": "mcp.resource.collection.updated", - "timestamp": "2026-11-03T16:00:00.000Z", - "data": {"uriTemplate": "tickets/{id}", "changes": [ - {"uri": "tickets/ticket-3", "effect": "updated", - "previousRevision": "101", "revision": "108"}]}} +{ + "eventId": "108", + "name": "mcp.resource.collection.updated", + "timestamp": "2026-11-03T16:00:00.000Z", + "data": { + "uriTemplate": "tickets/{id}", + "previousMoment": "107", + "changes": [ + { + "uri": "tickets/ticket-3", + "effect": "updated", + "previousRevision": "101", + "revision": "108" + } + ] + } +} ``` -- **R10.** Once a change is visible to a catch-up, the server MUST send one `mcp.resource.collection.updated` to each subscription covering any resource it touched. Its `data` repeats the subscription's `arguments`, naming its uri-specification, and holds `changes`, the strawman's change-set entry for each covered resource the change touched, `revision` always present. When a change's entries would take the body past the 256 KiB SEP-3415 asks for, the server SHOULD leave `changes` out. Its `eventId` SHOULD be the moment of its change, and SEP-3415's `cursor` MUST be null. A server MAY coalesce undelivered events for one subscription into the newest, whose `changes` then hold each uri once, as a catch-up's do (R19). +- **R10.** Once a change is visible to a catch-up, the server MUST send one `mcp.resource.collection.updated` to each subscription covering any resource it touched. Its `data` repeats the subscription's `arguments`, naming its uri-specification, and holds `previousMoment` and `changes`. Its `eventId` SHOULD be the moment of its change, and SEP-3415's `cursor` SHOULD be null, marking an event type that doesn't replay, since a client that misses an event recovers by syncing. + - `changes` holds the strawman's change-set entry for each covered resource the change touched, `revision` always present. When a change's entries would take the body past the 256 KiB SEP-3415 asks for, the server SHOULD leave `changes` out. + - `previousMoment` MUST be the later of two moments: the moment of the last event the server sent that subscription, and the timeline's latest moment when the subscription was last written. So every change to the subscription's collection after `previousMoment`, that its subscriber may read, is one this event names. A server that withholds an event from a subscription (R25) MUST NOT count it as sent. + - A server MAY coalesce undelivered events for one subscription into the newest. Its `changes` then hold each uri once, as a catch-up's do (R19), and its `previousMoment` is the earliest among them, so the event still names every change after it. -#### Step 6, at `"108"`: X catches up +#### Step 4, at `"108"`: Y moves forward, and X syncs -Y already holds ticket-3, the event's one change, at `"108"`, so it skips the catch-up. X holds it at `"101"`, so X catches up from `"107"`. +Y is caught up to `"107"`, the event's `previousMoment`, so it has accounted for every event before this one. It already holds ticket-3, the event's one change, at `"108"`. So Y makes no call, and is caught up to `"108"`. X holds ticket-3 at `"101"`, so X syncs from `"107"`. ```jsonc -{"method": "resources/collection/catchup", "params": {"uriTemplate": "tickets/{id}", - "from": "107", "patchMimeTypes": ["application/merge-patch+json"]}} -{"changes": [ - {"uri": "tickets/ticket-3", "effect": "updated", - "previousRevision": "101", "revision": "108", - "patch": {"mimeType": "application/merge-patch+json", - "text": "{\"status\":\"closed\"}"}}], - "hasMore": false, "nextFrom": "108", "nextPollMs": 30000} +{ + "method": "resources/collection/sync", + "params": { + "uriTemplate": "tickets/{id}", + "from": "107", + "patchMimeTypes": ["application/merge-patch+json"] + } +} + +{ + "changes": [ + { + "uri": "tickets/ticket-3", + "effect": "updated", + "previousRevision": "101", + "revision": "108", + "patch": { + "mimeType": "application/merge-patch+json", + "text": "{\"status\":\"closed\"}" + } + } + ], + "hasMore": false, + "nextFrom": "108", + "nextPollMs": 30000 +} ``` -X's copy is at `"101"`, the patch's `previousRevision`, so X applies it (R19), and is caught up to `"108"`. Y stays caught up to `"107"` until its next catch-up. +X's copy is at `"101"`, the patch's `previousRevision`, so X applies it (R19), and is caught up to `"108"`. | | Server A | Client X | Client Y | |---|---|---|---| | latest moment | `"108"` | | | -| caught up to | | **`"108"`** | `"107"` | +| caught up to | | **`"108"`** | **`"108"`** | | ticket-3 | `"108"`, closed | **`"108"`, closed** | `"108"`, closed | | ticket-7 and ticket-9 | `"107"` | `"107"` | `"107"` | -- **R11.** When a client receives `mcp.resource.collection.updated`, it MUST catch up on the uri-specification the event names, unless it already holds each uri in `changes`, or its tombstone, at that entry's `revision` or later, which lets the writer skip its own change. An event with no `changes` always brings a catch-up. -- **R13.** A catch-up names a uri-specification, as `uri` or `uriTemplate`, the moment the client is caught up to as `from`, and any patch formats it applies as `patchMimeTypes`. Over Streamable HTTP, its request MUST carry `Mcp-Name` holding that `uri` or `uriTemplate`, as `resources/read`'s carries its `uri`. It stores nothing and needs no subscription, and the server MUST authorize it as a `resources/read` of each covered resource. A catch-up's `changes` MUST hold every covered resource changed after `from` and at or before its `nextFrom`, as it stood at `nextFrom`, a deleted one as a tombstone, so a resource missing from them did not change in that span. With `hasMore` false, its `nextFrom` MUST be no earlier than any moment on its timeline that the server had sent when it was read. A client that polls SHOULD wait the result's `nextPollMs` before its next catch-up, with a floor, as in SEP-3415's poll mode (D12), except while `hasMore` is true (R14). - - A uri-specification on a timeline gets the timeline's latest moment as its `nextFrom` before any resource it covers exists. The server MUST answer `InvalidParams` (`-32602`) to a catch-up with no `from`, and to one naming a uri-specification it neither lists nor covers (R24), with `data.reason` `"notFound"`. +- **R11.** When a client receives `mcp.resource.collection.updated`, it compares the event's `previousMoment` with the moment it is caught up to on the uri-specification the event names. + - If `previousMoment` is later, the client has not accounted for the event before this one: it was lost, it is late, or the sync it prompted did not apply in full. The client MUST sync, even when it holds every change this event names. + - Otherwise, if the client holds each uri in `changes`, or its tombstone, at that entry's `revision` or later, it need not sync, and is caught up to the greatest `revision` in `changes`. This is how a writer accounts for its own change with no call. + - Otherwise the client MUST sync. An event with no `changes`, or with none listed, always brings a sync. + - A client MUST NOT move what it is caught up to on an event with no `previousMoment`. It syncs if it lacks a change the event names. +- **R13.** A sync that names a `from`, the moment the client is caught up to, is a catch-up. It also names any patch formats the client applies, as `patchMimeTypes`. Without a `delivery` it stores nothing, and needs no subscription. A catch-up's `changes` MUST hold every covered resource changed after `from` and at or before its `nextFrom`, as it stood at `nextFrom`, a deleted one as a tombstone, so a resource missing from them did not change in that span. With `hasMore` false, its `nextFrom` MUST be no earlier than any moment on its timeline that the server had sent when it was read. A client that polls SHOULD wait the result's `nextPollMs` before its next sync, with a floor, as in SEP-3415's poll mode (D12), except while `hasMore` is true (R14). + - A uri-specification on a timeline gets the timeline's latest moment as its `nextFrom` before any resource it covers exists. The server MUST answer `InvalidParams` (`-32602`) to a sync naming both a `from` and a `cursor`. ### Timelines and moments -A timeline's scope is the server's choice, and no client sees it: a client subscribes, catches up and keeps the moment it is caught up to for each uri-specification. Each of the two examples below serves tickets and notes. The names `tickets`, `notes` and `work` are the server's own. +A timeline's scope is the server's choice, and no client sees it: a client syncs, and keeps the moment it is caught up to, for each uri-specification. Each of the two examples below serves tickets and notes. The names `tickets`, `notes` and `work` are the server's own. -In the first example below, `tickets/{id}` and `notes/{id}` each have a timeline of their own, so writes to one never wait on the other (R5), and a client watching both subscribes and catches up on each. § *A simple happy path* starts from this one, at `"107"`: +In the first example below, `tickets/{id}` and `notes/{id}` each have a timeline of their own, so writes to one never wait on the other (R5), and a client watching both syncs each. § *A simple happy path* starts from this one, at `"107"`: ```yaml listed: # uri-specifications MAY point to separate timelines @@ -275,43 +333,45 @@ A catch-up on `{type}/{id}` brings ticket-9 and its note as they stood at one mo ### What a catch-up brings -- **R19.** Each entry in `changes` is one resource's change from `from` to the catch-up's `nextFrom` (R13): the strawman's change-set entry, with contents added. `previousRevision` is the resource's revision at `from`, absent if it did not exist then. `effect` is `deleted` if the change ends with the resource gone, else `created` if it did not exist at `from`, else `updated`. A non-deletion MUST carry `contents`, one `TextResourceContents` or `BlobResourceContents`, or a `patch` from the contents at `previousRevision`, in a listed format that applies. A `patch` takes the shape of a strawman write's `contents`, a `text` or a `blob` with no `uri`, whose `mimeType` names the patch's format (D26). A merge patch ([RFC 7396](https://www.rfc-editor.org/rfc/rfc7396)) applies to one `application/json` text entry, and patches its parsed `text`. The server MUST send `contents` when no listed format applies, when there is no `previousRevision`, or when it no longer keeps those contents, and MAY send them whenever they're smaller than the patch. +- **R19.** Each entry in a catch-up's `changes` is one resource's change from `from` to its `nextFrom` (R13): the strawman's change-set entry, with contents added. A first read's entries have the same shape, each `created`, with its `contents` (R22). `previousRevision` is the resource's revision at `from`, absent if it did not exist then. `effect` is `deleted` if the change ends with the resource gone, else `created` if it did not exist at `from`, else `updated`. A non-deletion MUST carry `contents`, one `TextResourceContents` or `BlobResourceContents`, or a `patch` from the contents at `previousRevision`, in a listed format that applies. A `patch` takes the shape of a strawman write's `contents`, a `text` or a `blob` with no `uri`, whose `mimeType` names the patch's format (D26). A merge patch ([RFC 7396](https://www.rfc-editor.org/rfc/rfc7396)) applies to one `application/json` text entry, and patches its parsed `text`. The server MUST send `contents` when no listed format applies, when there is no `previousRevision`, or when it no longer keeps those contents, and MAY send them whenever they're smaller than the patch. - A client MUST apply a patch only to a copy at its `previousRevision`. Otherwise it reads the resource (D2). ### Less common cases, most likely first -**A read that comes in pages.** From `"107"`, as in § *A simple happy path*, X reads `tickets/{id}` two tickets to a page, while Y updates ticket-3 and then ticket-9: +**A first read that comes in pages.** From `"107"`, as in § *A simple happy path*, X syncs `tickets/{id}` two tickets to a page, while Y updates ticket-3 and then ticket-9: ```text -X -> A resources/collection/read tickets/{id} -A -> X page 1: ticket-3 at "101", ticket-7 at "107", nextFrom "107", nextCursor +X -> A resources/collection/sync tickets/{id}, with its delivery +A -> X page 1: ticket-3 at "101", ticket-7 at "107", + hasMore, nextFrom "107", nextCursor Y -> A resources/update ticket-3, at "108" Y -> A resources/update ticket-9, at "109" -X -> A resources/collection/read, with that cursor -A -> X page 2: ticket-9 at "109", nextFrom "107" page 1's again -X -> A resources/collection/catchup from "107" +X -> A resources/collection/sync, with that cursor +A -> X page 2: ticket-9 at "109", + hasMore, nextFrom "107" page 1's again, and no nextCursor +X -> A resources/collection/sync from "107" A -> X ticket-3 at "108", ticket-9 at "109", nextFrom "109" ``` -After page 2, X's copies match no one moment: ticket-3 is as it stood at `"107"`, and ticket-9 as at `"109"`. Catching up from `"109"`, the newest revision X holds, would miss ticket-3's change at `"108"`. So every page carries the first page's `nextFrom`, and X catches up from it, ignoring ticket-9, which it already holds (R15). `nextFrom` also gives a client that reads an empty collection a moment to catch up from. +After page 2, X's copies match no one moment: ticket-3 is as it stood at `"107"`, and ticket-9 as at `"109"`. Syncing from `"109"`, the newest revision X holds, would miss ticket-3's change at `"108"`. So every page carries the first page's `nextFrom`, and X syncs from it, ignoring ticket-9, which it already holds (R15). Every page has `hasMore` true, the last included, so X runs the same loop it runs for a catch-up that stops short (R14). X was subscribed from page 1, so the events for both changes reach it while it pages, and wait (R16). `nextFrom` also gives a client that reads an empty collection a moment to sync from. -- **R26.** A long collection read comes in pages, as core's `resources/list` does: the server returns an opaque `nextCursor`, and the client sends it back as `cursor`. Every page MUST repeat the first page's `nextFrom`. A resource that doesn't change while the client pages MUST come exactly once. The client's copies match no one moment until it has every page and has caught up from `nextFrom`, which brings whatever changed while it paged, deletions included. +- **R26.** A long first read comes in pages, as core's `resources/list` does: the server returns an opaque `nextCursor`, and the client sends it back as `cursor`. Only the first page carries a `delivery`. Every page MUST repeat the first page's `nextFrom`, and MUST have `hasMore` true, the last included, while a first read that fits one page has `hasMore` false. While `hasMore` is true a client syncs again at once: with `nextCursor` if the result has one, else from `nextFrom`. A resource that doesn't change while the client pages MUST come exactly once. The client's copies match no one moment until it has every page and has synced from `nextFrom`, which brings whatever changed while it paged, deletions included. **A catch-up that stops short.** X is caught up to `"100"` and holds ticket-5 at `"090"`. Then ticket-5 changes at `"102"` and again at `"104"`, and A stops the first result at `"102"` to keep it small. Other tickets are left out: ```text -X -> A resources/collection/catchup from "100" +X -> A resources/collection/sync from "100" A -> X ticket-5 at "102", a patch from "090", hasMore, nextFrom "102" -X -> A resources/collection/catchup from "102" at once, since hasMore is true +X -> A resources/collection/sync from "102" at once, since hasMore is true A -> X ticket-5 at "104", a patch from "102", nextFrom "104", nextPollMs 30000 ``` -Each result leaves X's copies as they stood at its `nextFrom`, so the next one's patches start where the copies are, as a read replica's do (§ *Specification*). X could stop after either result and still hold the collection as it stood at one moment, which a read's pages don't give (R26). Sent as `contents` instead, ticket-5 would travel twice. A catch-up that reaches the timeline's latest moment brings each resource once, at its revision. +Each result leaves X's copies as they stood at its `nextFrom`, so the next one's patches start where the copies are, as a read replica's do (§ *Specification*). X could stop after either result and still hold the collection as it stood at one moment, which a first read's pages don't give (R26). Sent as `contents` instead, ticket-5 would travel twice. A catch-up that reaches the timeline's latest moment brings each resource once, at its revision. -- **R14.** A server MAY stop a catch-up short of the timeline's latest moment, to keep its result small. It MUST NOT stop partway through one moment's changes, and when it stops short it MUST set `hasMore` true, with `nextFrom` where it stopped, as in SEP-3415's poll mode. It MAY stop short with no changes, as after scanning many changes to another collection on a shared timeline. The client applies the changes, and is caught up to `nextFrom`. While `hasMore` is true, it SHOULD catch up again at once, ignoring `nextPollMs`, as SEP-3415's poll mode does. A catch-up MAY run past the server's usual size to finish a moment. A server that can't send one moment's changes in a single result MUST answer `InvalidParams` (`-32602`) with `data.reason` `"momentTooLarge"`, and the client starts over (R23), since a collection read pages freely and the catch-up after it starts past that moment. +- **R14.** A server MAY stop a catch-up short of the timeline's latest moment, to keep its result small. It MUST NOT stop partway through one moment's changes, and when it stops short it MUST set `hasMore` true, with `nextFrom` where it stopped, as in SEP-3415's poll mode. It MAY stop short with no changes, as after scanning many changes to another collection on a shared timeline. The client applies the changes, and is caught up to `nextFrom`. While `hasMore` is true, it SHOULD catch up again at once, ignoring `nextPollMs`, as SEP-3415's poll mode does. A catch-up MAY run past the server's usual size to finish a moment. A server that can't send one moment's changes in a single result MUST answer `InvalidParams` (`-32602`) with `data.reason` `"momentTooLarge"`, and the client starts over (R23), since a first read pages freely and the sync after it starts past that moment. - Kubernetes reads every page of a list from one snapshot, so its `continue` token expires, after five minutes by default. A catch-up that stops short needs no snapshot, since the next is an ordinary catch-up, which expires only as any `from` does (R21). -**A writer's copy runs ahead of its catch-ups.** From `"107"`, as in § *A simple happy path*, X updates ticket-3 at `"109"`, and then Y updates it at `"110"`. X's copy is at `"109"`, but X stays caught up to `"107"` (R9): +**A writer's own event arrives late.** From `"107"`, as in § *A simple happy path*, X updates ticket-3 at `"109"`, and then Y updates it at `"110"`. X's copy is at `"109"`, and X stays caught up to `"107"` (R9), since its own event has yet to arrive: | | Server A | Client X | |---|---|---| @@ -319,16 +379,25 @@ Each result leaves X's copies as they stood at its `nextFrom`, so the next one's | caught up to | | `"107"` | | ticket-3 | `"110"`, Y's update | `"109"`, X's update | -X then catches up from `"107"`: +Y's event reaches X first, with `previousMoment` `"109"`. That is later than `"107"`, so X syncs from `"107"` (R11): ```jsonc -{"uri": "tickets/ticket-3", "effect": "updated", - "previousRevision": "101", "revision": "110", // ticket-3 as of "107" - "patch": {"mimeType": "application/merge-patch+json", "text": "..."}} +{ + "uri": "tickets/ticket-3", + "effect": "updated", + "previousRevision": "101", // ticket-3 as of "107" + "revision": "110", + "patch": { + "mimeType": "application/merge-patch+json", + "text": "..." + } +} // X's copy is at "109", not "101", so X reads ticket-3 with resources/read. ``` -- **R9.** A client that writes a resource makes what it sent its copy, at the revision the reply gives that resource (R17). It takes nothing else from the reply. The reply lists every resource the write changed, such as a note closed along with its ticket, but holds no contents for the others, so the client gets them from a catch-up. The client MUST NOT move what it is caught up to on a reply. Example: X is caught up to `"107"`. Y updates ticket-9 at `"108"`, then X updates ticket-3 at `"109"`. Had X counted itself caught up to `"109"`, it would never fetch ticket-9's change. +X's own event, when it arrives, names a revision earlier than X's copy, and changes nothing (R15). Had it arrived in order, it would have moved X to `"109"`, and the sync Y's event brings would have carried a patch from `"109"`. + +- **R9.** A client that writes a resource makes what it sent its copy, at the revision the reply gives that resource (R17). It takes nothing else from the reply. The reply lists every resource the write changed, such as a note closed along with its ticket, but holds no contents for the others, so the client gets them from a sync. The client MUST NOT move what it is caught up to on a reply. Example: X is caught up to `"107"`. Y updates ticket-9 at `"108"`, then X updates ticket-3 at `"109"`. Had X counted itself caught up to `"109"` on its reply, it would never fetch ticket-9's change. X's own event says what the reply can't: its `previousMoment`, `"108"`, shows X an event it has not accounted for, and X syncs (R11). **Messages that cross.** @@ -336,19 +405,19 @@ X then catches up from `"107"`: From "107" as in the simple happy path: X -> A resources/update ticket-3, ifMatch "101" A -> X event: ticket-3 at "109" arrives before the reply - X doesn't hold "109", so it catches up from "107" + X doesn't hold "109", so it syncs from "107" Y -> A resources/delete ticket-3, at "111" -A -> X catch-up: ticket-3 deleted at "111" X keeps the tombstone +A -> X sync: ticket-3 deleted at "111" X keeps the tombstone A -> X reply: ticket-3 at "109" earlier than "111", so ignored ``` - **R15.** A client MUST ignore a write's reply, a catch-up's entry or a tombstone whose revision is no later than its copy's, and MUST NOT move what it is caught up to backward, so messages can arrive in any order. -- **R16.** A client SHOULD run one catch-up or collection read per uri-specification at a time, counting the catch-ups that follow while `hasMore` is true as one. An event that arrives meanwhile waits until they end, and the client then applies R11 to it. +- **R16.** A client SHOULD run one sync per uri-specification at a time, counting the syncs that follow while `hasMore` is true as one. An event that arrives meanwhile waits until they end, and the client then applies R11 to it. **Missed events.** -- **R12.** A client SHOULD catch up on each uri-specification it subscribed to whenever it might have missed events: after each SEP-3415 refresh, on reconnecting to its gateway or a `subscriptions/listen` stream, on starting, and on a `gap` envelope. A subscription with no expiry still gets SEP-3415's occasional refresh. - - A client can miss events without knowing, as a backend can when its webhook endpoint is down while the process holding its copies stays up. SEP-3415 retries each event, so a short outage loses none, and a missed event costs nothing once another event brings a catch-up, which brings every change after the moment the client is caught up to. A quiet collection stays stale at most until the next refresh, whose `deliveryStatus` says whether deliveries had been failing, so such a client sets its refresh interval to the staleness it can tolerate, even when granted no expiry. +- **R12.** A client SHOULD sync each uri-specification it subscribed to whenever it might have missed events: at each SEP-3415 refresh, which is one sync carrying its `delivery` and its `from` (R18), on reconnecting to its gateway or a `subscriptions/listen` stream, on starting, and on a `gap` envelope. A subscription with no expiry still gets SEP-3415's occasional refresh. + - A client can miss events without knowing, as a backend can when its webhook endpoint is down while the process holding its copies stays up. SEP-3415 retries each event, so a short outage loses none, and a missed event costs nothing once another event arrives, since its `previousMoment` shows the gap and brings a sync (R11). A quiet collection stays stale at most until the next refresh, whose `deliveryStatus` says whether deliveries had been failing, so such a client sets its refresh interval to the staleness it can tolerate, even when granted no expiry. **A template the server doesn't list.** A client that loaded `skill://code-review/SKILL.md` also wants the skill's other files, such as `skill://code-review/scripts/lint.py`, from a server that lists one template, `skill://{skill}/{+path}`, for all its skills. The client subscribes to `skill://code-review/{+path}`, and the server accepts it, since the listed template matches every uri the client's could. To check, the server turns the client's template into a sample uri and matches it against the listed template, as this pseudo-code does: @@ -371,23 +440,27 @@ covers(listed, clientTemplate): | `{type}/{id}` | `tickets/{id}` | `tickets/SEG` | Yes: `{type}` matches `tickets`, and `{id}` matches `SEG` | | `tickets/{id}` | `tickets/{+rest}` | `tickets/ANY` | No: `{id}` holds one path segment, but `ANY` may stand for several, such as `a/b`, so the server answers `notFound` | -- **R24.** A client MAY name a uriTemplate the server doesn't list, in a subscription, a catch-up or a collection read, as long as a template the server lists matches every uri the client's could. The client's template then shares the listed one's timeline (R1). A server MAY check as the pseudo-code and table above show, and MUST answer `notFound` when it can't tell. +- **R24.** A client MAY name a uriTemplate the server doesn't list, in a sync or a subscription, as long as a template the server lists matches every uri the client's could. The client's template then shares the listed one's timeline (R1). A server MAY check as the pseudo-code and table above show, and MUST answer `notFound` when it can't tell. -**Starting over.** A client caught up to a moment too old (R21), or whose catch-up meets a moment too large for one result (R14), reads the collection again, as a new client does (R18). +**Starting over.** A client caught up to a moment too old (R21), or whose catch-up meets a moment too large for one result (R14), makes a first read again, with its `delivery`, as a new client does (R18), since the refused sync refreshed no subscription. -- **R23.** A client that held copies before reading a collection MUST replace each one the read's pages name, even with an earlier revision, as after a restore (R21). It MUST also drop each one in that collection that neither the read's pages nor the catch-ups after them named, if its revision is at or before the last catch-up's `nextFrom`. +- **R23.** A client that held copies before a first read MUST replace each one its pages name, even with an earlier revision, as after a restore (R21). The one exception is a copy from after the read began: one whose revision is later than the read's `nextFrom`, and no earlier than the entry's, as the client's own write makes while a page is on its way (R15). It MUST also drop each one in that collection that neither the first read's pages nor the syncs after them named, if its revision is at or before the last `nextFrom` it applied. **A moment too old to answer from.** -- **R21.** A server MUST keep the moment of every change, deletions included, back to a moment of its choosing, and MAY drop old contents at any time. It MUST answer a catch-up whose `from` is earlier than that with `InvalidParams` (`-32602`) and `data.reason` `"momentExpired"`, and the client starts over (R23, D4). A server restored from a backup MUST first mint a moment past every one it might have sent, and make it the oldest it answers from, as etcd's `snapshot restore` does with `--bump-revision` and `--mark-compacted`. A server SHOULD keep that history longer than the time it grants between refreshes, and longer than a read of its largest collection takes, so that a client can catch up at each refresh (R12) and after reading any collection. +- **R21.** A server MUST keep the moment of every change, deletions included, back to a moment of its choosing, and MAY drop old contents at any time. It MUST answer a catch-up whose `from` is earlier than that with `InvalidParams` (`-32602`) and `data.reason` `"momentExpired"`, and the client starts over (R23, D4). A server restored from a backup MUST first mint a moment past every one it might have sent, and make it the oldest it answers from, as etcd's `snapshot restore` does with `--bump-revision` and `--mark-compacted`. A server SHOULD keep that history longer than the time it grants between refreshes, and longer than a first read of its largest collection takes, so that a client can catch up at each refresh (R12) and after reading any collection. **Resources a client may not read.** -- **R25.** Catch-ups and collection reads MUST cover only the resources the principal may read, since they carry contents (R13, R22). Events SHOULD cover only those too, as SEP-3415's check at delivery time is a SHOULD, since an event carries only uris and revisions (R10). When what a principal may read changes while the resources don't, the server MAY mint a moment that changes no resource, as after a restore (R21), and answer that principal's catch-ups from before it with `momentExpired`. Its clients then start over (R23), which brings a shared resource or drops a revoked one. +- **R25.** Access is checked on reads, which carry contents, and MAY be checked on events, which carry only uris and revisions (R10). + - A sync MUST NOT return a resource the principal may not read (R13, R22). Where a uri-specification covers some, the server either answers as though it covered only those the principal may read, or refuses the whole sync with an error, which SHOULD be SEP-3415's `Forbidden` (`-32024`). It MAY choose for each uri-specification (D27). Core's `resources/read` keeps whatever error the server gives it today. + - SEP-3415 has the server check a subscriber's permission at subscribe time. A sync that carries a `delivery` is checked as any sync is, and one the server refuses writes no subscription (R18). A server SHOULD refuse an `events/subscribe` with `Forbidden` when it would refuse a sync of the same uri-specification, and MAY grant one it couldn't judge without checking every covered resource, leaving the refusal to the sync. + - An event MAY leave out the entries for resources the subscriber may not read. A server that withholds an event whole stores no new `previousMoment` for it (R10). + - When what a principal may read changes while the resources don't, the server MAY mint a moment that changes no resource, as after a restore (R21), and answer that principal's catch-ups from before it with `momentExpired`. Its clients then start over (R23), which brings a shared resource or drops a revoked one. ### Declaring the extension -- **R20.** A server declares this extension in `capabilities.extensions` under a provisional identifier, `io.modelcontextprotocol/resource-events`, as SEP-2133 asks. A server that declares it MUST serve `resources/collection/read` and `resources/collection/catchup`, and list `mcp.resource.collection.updated` in `events/list`, with whichever of SEP-3415's delivery modes it offers. The extension reserves the prefix `resources/collection/` for its methods, beside core's and the strawman's under `resources/`. The event's name waits on Open Question 2's decision about an `mcp.*` prefix. +- **R20.** A server declares this extension in `capabilities.extensions` under a provisional identifier, `io.modelcontextprotocol/resource-events`, as SEP-2133 asks. A server that declares it MUST serve `resources/collection/sync`, and list `mcp.resource.collection.updated` in `events/list`, with whichever of SEP-3415's delivery modes it offers. The extension reserves the prefix `resources/collection/` for its method, beside core's and the strawman's under `resources/`. The event's name waits on Open Question 2's decision about an `mcp.*` prefix. ### uriGlob, later @@ -403,9 +476,9 @@ Each decision keeps the number the rules cite it by, and opens by restating its ### Subscribing and events -**D1. A client subscribes with SEP-3415's `events/subscribe`.** It subscribes with `events/subscribe` (R18), and catches up with `resources/collection/catchup`, which stores nothing (R13), so SEP-3415's is the one subscription lifecycle. A subscribe method of this extension's own, such as `resources/webhooks/subscribe`, would be a second lifecycle beside it, repeating what SEP-3415 already specifies for webhooks: verifying the callback, signing each delivery, refreshing and expiring subscriptions, and reporting `deliveryStatus`. Each client and gateway that already takes Events, such as those § *Why now?* names, would then need a second subscription API to watch resources. +**D1. A subscription is SEP-3415's, whichever call makes it.** A sync that carries a `delivery` makes the subscription an `events/subscribe` would have made, under the same rules (R18), so SEP-3415's stays the one subscription lifecycle: verifying the callback, signing each delivery, refreshing and expiring subscriptions, and reporting `deliveryStatus`. A gateway that takes Events webhooks takes this extension's unchanged, such as those § *Why now?* names. A lifecycle of this extension's own would repeat all of that. D28 says why a sync makes the subscription, and Q8 asks whether it may. -**D5. Events carry no contents.** An event names what changed and carries none of it (R10), so a lost, repeated or late one does no harm. A change then costs a subscriber an event and a catch-up, one round trip more than a delivery carrying the change, with about the same bytes. The writer pays nothing extra, and N changes before a catch-up cost N events and one catch-up. Deliveries that carry each change's contents or a patch would need a `fromVersion` to order them, and a resubscribe that drops the deliveries still queued. +**D5. Events carry no contents.** An event names what changed and carries none of it (R10), so a lost, repeated or late one does no harm. A change then costs a subscriber an event and a catch-up, one round trip more than a delivery carrying the change, with about the same bytes. The writer pays nothing extra, since its own event moves it forward with no call (D29), and N changes before a catch-up cost N events and one catch-up. Deliveries that carry each change's contents or a patch would need a `fromVersion` to order them, and a resubscribe that drops the deliveries still queued. **D19. The extension sends an event of its own, one for each change on each subscription.** `mcp.resource.collection.updated` goes to each subscription covering a resource the change touched. Its `data` names the subscription's uri-specification, and lists the change's entries that the subscription covers (R10, R11). It leaves `mcp.resource.updated` to SEP-3415's Open Question 2. A server may coalesce undelivered events, as Peter agreed for replacing an event with a newer one. Peter's `mcp.resource.updated`, with data of our own, would take a name Open Question 2 may define otherwise, for core's `notifications/resources/updated`, whose `uri` names the changed resource. One event for each resource changed would send a change touching 500 tickets as 500 deliveries, 499 of them skipped after the first catch-up. An event naming only the uri-specification and the moment would let a writer skip it only by taking the whole moment as its own, which is wrong when its write also changed a resource whose contents it lacks, as closing a ticket closes its note. Leaving the uri-specification to the subscription `id` in `X-MCP-Subscription-Id` would have each client map it back to its subscription. @@ -423,7 +496,7 @@ Each decision keeps the number the rules cite it by, and opens by restating its **D4. A `from` too old gets `momentExpired`.** The client then starts over (R21), as Kubernetes answers a watch from a compacted `resourceVersion` with `410 Gone`, and core an expired cursor with `-32602`. A flag on an ordinary result carrying the whole current state, or SEP-3415's `truncated: true`, is a signal a client could miss, and so keep copies of resources since deleted. -**D12. A catch-up's result is a plain result, and paces polling with `nextPollMs`.** A client that polls waits the result's `nextPollMs`, as in SEP-3415's poll mode (R13, R14). Core's `CacheableResult` would serve badly: its `ttlMs` is a freshness hint, which clients "SHOULD NOT treat … as a polling interval", and a cache keyed on the request could answer step 6's catch-up with step 3's empty result, since both send `from` `"107"`. A rule that a client never answers a catch-up from a cache would bind no gateway that follows core, and with no pacing field, a server would have no way to slow clients that poll. A collection read stays a `CacheableResult` (R22), since a cached read is an older snapshot whose `nextFrom` matches it. +**D12. A sync's result is a plain result, and paces polling with `nextPollMs`.** A client that polls waits the result's `nextPollMs`, as in SEP-3415's poll mode (R13, R14). Core's `CacheableResult` would serve badly: its `ttlMs` is a freshness hint, which clients "SHOULD NOT treat … as a polling interval", and a cache keyed on the request could answer a catch-up with an earlier one's empty result, since a client that polls sends the same `from` until something changes. A rule that a client never answers a catch-up from a cache would bind no gateway that follows core, and with no pacing field, a server would have no way to slow clients that poll. A first read is a plain result too (R22). It may write a subscription, which no cache may answer for, and the request's `ttlMs` is already the subscription's lifetime, as in SEP-3415. **D13. A long catch-up is a run of short ones.** A server may stop a catch-up short, bringing each resource as it stood where it stopped (R13, R14), so a long catch-up is a run of ordinary ones, as polls are in SEP-3415, and every patch applies in order. Four other kinds of paging were weighed against it. Taking the last entry's `revision` as the moment the client is caught up to, beside a field holding the timeline's latest moment, gave the client two rules, and could not stop short with no changes. Pages bringing each resource at its revision cost a read for each resource changed on both sides of a page's end. Sending the first page's `from` again on every page, with a `cursor` beside it, needs both fields and a rule for which one a patch starts from. A fixed end sent back on every page, as `through`, cost a field and a catch-up after the last page. @@ -431,28 +504,39 @@ Each decision keeps the number the rules cite it by, and opens by restating its ### Reading collections -**D14. Reading a collection has a method of its own.** `resources/collection/read` takes no `from`, and pages with core's opaque `cursor` and `nextCursor`, as `resources/list` does (R22, R26). Their pages differ: a client can stop after any catch-up and hold the collection as it stood at one moment (R14), but must read every page of a collection read, then catch up, to do so (R26). A client reads the collection, subscribes, then catches up from the read's `nextFrom` (R18), so every catch-up names a `from`. A catch-up with no `from` would give one method two kinds of result, and need a `cursor` beside `from`, with an error for naming both. `resources/template/read` would need a sibling for uriGlobs, and reads like fetching the template itself. SEP-2640's `resources/directory/read` lists one directory's children without their contents. Subscribing before reading also works, but leaves a new client's events waiting while it pages. +**D14. A first read is a sync with no `from`.** It returns each resource as a `created` entry with its contents, the shape a catch-up's entries have, so the one method has one kind of result (R19, R22). It pages with core's opaque `cursor` and `nextCursor`, as `resources/list` does (R26), since its pages differ from a catch-up's: a client can stop after any catch-up and hold the collection as it stood at one moment (R14), but must take every page of a first read, then sync from `nextFrom`, to do so. `hasMore` on every page tells the client to, in the loop it already runs for a catch-up that stops short. The cost is a `cursor` beside `from`, and an error for naming both (R13). An earlier draft gave the first read a method of its own, to avoid that, at the cost D28 describes. `resources/template/read` would need a sibling for uriGlobs, and reads like fetching the template itself. SEP-2640's `resources/directory/read` lists one directory's children without their contents. **D20. A client may name a template the server doesn't list, when a listed one covers it.** A server answers `notFound` for one it can't show is covered (R24). R24's pseudo-code checks it by writing the client's template as a sample uri, with a stand-in for each expression, and matching that as any uri. The check never wrongly accepts, and it handles a listed template with part of it filled in, as a skill or a type is. Listed templates alone would leave a client that wants one skill, on a server listing `skill://{skill}/{+path}`, watching every skill, unless the server lists a template for each. An exact check would first have to settle what RFC 6570's expressions match, empty values and levels 3 and 4 included, then compare automata that can grow exponentially. -**D25. A `resources/read` result MUST carry each item's revision.** R7 puts it in `_meta`, so a copy a client got from a core read, as a skills client gets `SKILL.md`, can be compared without reading it again. The MUST binds only servers that declare this extension, and gives the strawman's open question about `resources/read` a use case. R19's fallback could read through `resources/collection/read` alone, which needs no hook into core's read, but that would leave copies from core reads without a revision. A SHOULD, as the strawman's "when the server can provide it", would give R19 two routes. +**D25. A `resources/read` result MUST carry each item's revision.** R7 puts it in `_meta`, so a copy a client got from a core read, as a skills client gets `SKILL.md`, can be compared without reading it again. The MUST binds only servers that declare this extension, and gives the strawman's open question about `resources/read` a use case. R19's fallback could be a first read of the one uri alone (R22), which needs no hook into core's read, but that would leave copies from core reads without a revision. A SHOULD, as the strawman's "when the server can provide it", would give R19 two routes. ### Writes **D7. A client's own writes use the strawman's four methods.** Their replies carry the revision of each resource a write changed (R7, R17), which lets the writer skip the event its own write sends (R9, R11). A write made another way, such as through a server's tools, returns no revision a client can rely on, so its writer would catch up on its own change. Changes from anywhere else still land at moments and send events (R2, R10). +### Access + +**D27. A server chooses whether a partly readable collection is filtered or refused.** Filtering lets a client watch `tickets/{id}` and see just the tickets it may read, which suits a list a person works through. It misleads in analytics: two people charting one collection would see different totals, so one could say sales are rising while another, at the same uri, says they are falling. Refusing the whole read gives the same answer to everyone who gets one. Each suits some collections, so R25 lets the server choose for each uri-specification, and a client lives with a filtered answer or narrows a refused one. A filtered answer can be empty, which tells a client nothing about resources it may not read. A refusal SHOULD use `Forbidden`, which SEP-3415 names for reuse across MCP. Core defines no such error for `resources/read`, so servers refuse a read in their own ways today, but this extension's method is new in every server, so none has an existing refusal to keep. A sync carries the MUST because it carries contents. The check of an `events/subscribe` is a SHOULD. A server whose templates line up with its access control, as `orgs/{org}/tickets/{id}` does when access is granted per org, checks a subscription once. Any other server would have to check every resource a template matches, and a resource created later would escape that check anyway, so the sync's refusal tells the client instead. An event's check is a MAY, since an event carries no contents and the sync it prompts is checked. + +### One method, and each event's `previousMoment` + +**D28. One method, `resources/collection/sync`, reads, subscribes and catches up.** An earlier draft had a client new to a collection make three calls in a row: `resources/collection/read`, then SEP-3415's `events/subscribe`, then `resources/collection/catchup`, to bring any change that fell between the first two. At the working group's meeting on 2026-11-06, the question was raised why a new client needs three calls, and the thought that webhook delivery might, in an ideal world, have been specified apart from Events. This draft takes that as room to make the three calls one (R13, R18, R22). A server reads the collection and writes the subscription in one atomic step, so there is no gap to cover and no third call, and a refresh brings what the client missed in the same call (R12). Adding a `resources/collection/subscribe` beside the read and the catch-up would have made three methods, and a refresh would still take two calls. Requiring a `delivery` on every sync would write a subscription, and send a secret, for each event, where a catch-up stores nothing. So a `delivery` is optional, and a client sends one when it starts, refreshes or starts over. The costs are that a `resources/` request can now make a webhook subscription (§ *Security Implications*), and that no cache answers a first read (D12). `events/subscribe` still makes the same subscription (R18), which keeps SEP-3415's other delivery modes in reach. + +**D29. Each event names the moment of the event before it.** With `previousMoment` (R10), a client tells by comparing two strings whether it has accounted for every event before this one (R11). A client that has, and holds the event's changes, is caught up to the event's moment with no call, so a writer's moment follows the server's as its own events arrive. A client that hasn't syncs, and so learns of a lost event at the very next one, not at its next refresh. The server keeps one value for each subscription, and needs no arithmetic on moments, so any format that sorts will do (R3). Three other designs were weighed. The timeline's previous moment, with no sync on a mismatch, stops a writer's moment for good after one lost event, or one change to another collection on a shared timeline. The timeline's previous moment, with a sync on a mismatch, has a writer beside a busy collection sync after most of its own writes, for changes it already holds. A catch-up for writers alone, in which the writer lists what it wrote and the server confirms nothing else changed, costs a round trip for each write, against D5. A field on a write's reply would move a writer that polls too, and waits on the strawman, whose reply it is. + ## Backward Compatibility -None. The extension adds two methods and one event, and binds only a server that declares it (R20). Its one rule on a core method, R7's revision for each `resources/read` item, adds a key under `_meta`, which a client that doesn't know the extension ignores (D25). +None. The extension adds one method and one event, and binds only a server that declares it (R20). Its one rule on a core method, R7's revision for each `resources/read` item, adds a key under `_meta`, which a client that doesn't know the extension ignores (D25). ## Security Implications -This extension sends its events by SEP-3415's delivery modes as they stand, so that SEP's rules cover every delivery, as its rules on callback URLs, signing and endpoint verification cover every webhook POST. It adds four points of its own: +This extension sends its events by SEP-3415's delivery modes as they stand, so that SEP's rules cover every delivery, as its rules on callback URLs, signing and endpoint verification cover every webhook POST. It adds five points of its own: -- **Events carry no contents** (R10), so a delivery that reaches the wrong receiver reveals uris and revisions, never what a resource holds. -- **A catch-up or a collection read is authorized as a `resources/read`** of each resource it covers (R13, R22), so catch-ups and reads MUST cover only what the principal may read, and events SHOULD (R25). -- **`nextFrom` moves with every change on a timeline** (R13), so it can tell a principal when resources it may not read change. A server that minds can avoid it, as by giving those resources a timeline of their own where R1 allows. -- **Both methods carry their `uri` or `uriTemplate` in `Mcp-Name`** (R13, R22), where a gateway or proxy sees it, as core's `resources/read` carries its `uri`. +- **A sync that carries a `delivery` makes a webhook subscription** (R18). The server processes it under every rule SEP-3415 sets for `events/subscribe`, so that SEP's protections against callback abuse hold for it, and a sync the server refuses writes none. The callback URL and the secret ride a `resources/` request, though. A gateway or policy that allows or denies subscribing by method name has to treat a `resources/collection/sync` that carries a `delivery` as it treats an `events/subscribe`. +- **Events carry no contents** (R10), so a delivery that reaches the wrong receiver reveals uris, revisions and moments, never what a resource holds. +- **A sync is authorized as a `resources/read`** of each resource it covers (R13, R22), so it returns no resource the principal may not read (R25). An `events/subscribe` SHOULD be checked and an event MAY be, so an event can name a resource its subscriber may not read, by its uri and revisions. +- **`nextFrom` moves with every change on a timeline** (R13), so it can tell a principal when resources it may not read change. A server that minds can avoid it, as by giving those resources a timeline of their own where R1 allows. `previousMoment` tells a subscriber nothing more: it is the moment of an event that subscriber was sent, or a `nextFrom` it could have read (R10). A server that counted a withheld event as sent would tell the subscriber that something it may not read had changed inside its own collection, which R10 forbids. +- **The method carries its `uri` or `uriTemplate` in `Mcp-Name`** (R22), where a gateway or proxy sees it, as core's `resources/read` carries its `uri`. **On Cloudflare Workers, the platform's egress already does SEP-3415's address check.** SEP-3415 asks a server to reject callback URLs whose address isn't globally routable, at delivery time. With the `global_fetch_strictly_public` flag, a Worker's fetch refuses a name that resolves to a private, loopback, link-local or other special-purpose address with an error of Cloudflare's own, 1016 or 1002, and names built to rebind to a private address reached none. Two ranges, `192.0.0.0/24` and `192.0.2.0/24`, go out and time out. On 2026-11-03, the prototype's webhook POSTs and its fetches of client metadata documents met the same outcome for each of 30 names, so a server on Workers needs no address check of its own for either. Cloudflare's documentation promises none of this, so the prototype's deployed tier measures it again on every run. @@ -469,10 +553,10 @@ An implementation in an official SDK comes next, since an Extensions Track SEP n ### Tested, and not yet tested -- **463 tests pass,** and a check fails the run unless a passing test names every rule above but R25. Each new test was mutation-checked: broken code turned it red. +- **505 tests pass,** and a check fails the run unless a passing test names every rule above but R25. Each new test was mutation-checked: broken code turned it red. - **Each MUST of SEP-3415's that the prototype uses has a test.** - **MCP's conformance suite,** `@modelcontextprotocol/conformance` 0.2.0-alpha.12 at revision 2026-07-28, passes against A and against the client, with a baseline that lists the features neither offers, such as prompts. -- **A deployed tier runs on Cloudflare.** Two clients read the tickets, subscribe and catch up. One closes a ticket and skips its own event, and the other catches up with a merge patch. +- **A deployed tier runs on Cloudflare.** Two clients each sync with a delivery. One closes a ticket, and its own event moves it forward with no call. The other syncs, and gets a merge patch. - **Designed, not built:** several writers minting moments at once, subscriptions granted no expiry, R12's catch-up on a `subscriptions/listen` stream and the `deliveryStatus` it cites, uriGlob, access that differs by principal (R25), and a restore from a real backup, whose steps an operator reset takes in the tests (R21). - **Only the prototype's own client and server have run the extension so far.** The strawman's methods are built for the happy path and a conflicting write. @@ -480,11 +564,8 @@ An implementation in an official SDK comes next, since an Extensions Track SEP n IDs follow the order the questions were raised, not the order shown, and a gap is a question answered or withdrawn. -- **Q6. Authorization.** A principal may be able to read only some of the resources a uri-specification covers, such as some of the tickets under `tickets/{id}`. - - (a) The server answers a subscription, catch-up or collection read with an error unless the principal may read every covered resource, so a client can watch only a collection it may read whole. - - (b) The server answers with only the resources the principal may read, so a client can watch `tickets/{id}` and see just the tickets it may read. - - This draft takes (b), in R25, and leaves the choice to the working group. -- **Q7. Starting to watch a collection in one call.** The simple happy path shows a client making three calls in a row when it first connects: (1) it reads the collection, (2) subscribes with `events/subscribe`, and (3) catches up (§ *A simple happy path*, steps 1 to 3). One call would be simpler to use. - - (a) Keep the three calls, so SEP-3415's `events/subscribe` stays the one subscription lifecycle (D1), and add a helper to the SDKs that makes them. - - (b) Add `resources/collection/subscribe` to this extension, which subscribes and returns the collection's contents. It would be a second subscription lifecycle beside `events/subscribe`, and its result would page as a collection read's does. +- **Q8. Whether a method other than `events/subscribe` may create an Events subscription.** A sync that carries a `delivery` does (R18, D28). The event stays one that SEP-3415 registers and lists, and its delivery, its handshake and its subscription's lifetime stay that SEP's (D1). What this extension steps around is the separate call to subscribe, which removes the gap between a read and a subscription. + - (a) Allow it, as this draft does. A gateway that filters by method name then has to look for a `delivery` in a sync (§ *Security Implications*). + - (b) Keep `events/subscribe` the only way to subscribe. A new client then reads, subscribes and catches up in separate calls, as the earlier draft had it, or an SDK helper makes them. + - (c) Factor webhook delivery out of SEP-3415, into something both extensions build on, as was raised at the working group's meeting on 2026-11-06. - This draft takes (a), and leaves the choice to the working group.