Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
b39c4bf
Add the client-side subscriptions/listen driver
maxisbey Jul 1, 2026
f69098c
Keep the docs watch-loop snippet ASCII
maxisbey Jul 1, 2026
6c36cbd
Harden the listen driver against receive-order races and hostile peers
maxisbey Jul 1, 2026
ec13b6b
Trim comments and docstrings in the listen driver changes
maxisbey Jul 1, 2026
7550131
Point moved docs pages at the client driver section
maxisbey Jul 2, 2026
c10f25f
Move the docs watch-loop example into docs_src and prove it
maxisbey Jul 2, 2026
6770ffa
Deep-link the migration guide and spell out the watcher test choreogr…
maxisbey Jul 2, 2026
cf2a7d3
Prove the caller-supplied listen id end to end and undefer its requir…
maxisbey Jul 6, 2026
9ef0ed7
Prove concurrent listen streams demux their own acks
maxisbey Jul 6, 2026
46ba071
Rewrite the subscriptions docs around a worked example
maxisbey Jul 7, 2026
2ce1e47
Repoint subscription doc links at the renamed headings
maxisbey Jul 7, 2026
5e941c6
Connect the subscription examples over HTTP
maxisbey Jul 7, 2026
b9284a5
State precisely what a subscription filter can and cannot leak
maxisbey Jul 7, 2026
34215a9
Correct the stop-watching wire claim and the copied-filename import note
maxisbey Jul 7, 2026
b642ac4
Fix two waiter-resolution gaps in the streamable HTTP client
maxisbey Jul 7, 2026
f6b5295
Shadow the per-module runner lease in the docs subscriptions tests
maxisbey Jul 7, 2026
07b5c9a
Sever a superseded POST instead of guarding one resolution path
maxisbey Jul 7, 2026
56e2a26
Split the client side of subscriptions onto its own page
maxisbey Jul 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/client/caching.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ One rule sits above `"use"`: **calls carrying `meta` always reach the server.**

To turn caching off entirely, construct with `Client(server, cache=False)`: every call is a round trip again, and `cache_mode`, while still accepted, does nothing.

Scope is honored automatically too: `"private"` entries are keyed to the cache's *partition* (below), while `"public"` ones may opt into wider sharing. And **notifications beat TTL** for the exact entries they name: a `list_changed` notification evicts the matching cached listing, and `resources/updated` evicts the cached read stored under exactly its URI, however fresh they were.
Scope is honored automatically too: `"private"` entries are keyed to the cache's *partition* (below), while `"public"` ones may opt into wider sharing. And **notifications beat TTL** for the exact entries they name: a `list_changed` notification evicts the matching cached listing, and `resources/updated` evicts the cached read stored under exactly its URI, however fresh they were. On a 2026-07-28 connection those notifications arrive on a `subscriptions/listen` stream you open with `client.listen(...)`, and eviction completes before your watcher sees the event; **[Subscriptions](subscriptions.md)** is that page.

One caveat on `resources/updated`: eviction is exact-URI only. The store contract has no enumerate or scan operation (same as the reference TypeScript implementation), so a notification carrying a *sub*-resource URI does not evict a cached read of its parent. If your server signals sub-resources this way, refetch the parent with `cache_mode="refresh"`.

Expand Down
2 changes: 1 addition & 1 deletion docs/client/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ The resource verbs come in pairs: two ways to list, one way to read.

`read_resource` returns `contents`, a list of `TextResourceContents` or `BlobResourceContents`. Same idea as tool content: narrow with `isinstance`, then read `.text` (or `.blob`).

A client can also be told when a resource changes. On 2025-era connections that is `subscribe_resource(uri)` / `unsubscribe_resource(uri)` - a method pair `MCPServer` doesn't implement, so on the 2026-07-28 wire (where those verbs no longer exist) the request answers `-32601`, *Method not found*. The 2026 replacement is a `subscriptions/listen` stream, which `MCPServer` *does* serve - `server_capabilities.resources.subscribe` is `True` there, and the server side of the story is **[Subscriptions](../handlers/subscriptions.md)**.
A client can also be told when a resource changes. On 2025-era connections that is `subscribe_resource(uri)` / `unsubscribe_resource(uri)` - a method pair `MCPServer` doesn't implement, so on the 2026-07-28 wire (where those verbs no longer exist) the request answers `-32601`, *Method not found*. The 2026 replacement is a `subscriptions/listen` stream, which `MCPServer` *does* serve - `server_capabilities.resources.subscribe` is `True` there - and consuming it with `client.listen(...)` is this section's **[Subscriptions](subscriptions.md)** page.

## Prompts

Expand Down
86 changes: 86 additions & 0 deletions docs/client/subscriptions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# Subscriptions

A server's catalog is not fixed. Tools appear at runtime, and the content behind a resource URI changes. A client hears about it through `client.listen(...)`: one `subscriptions/listen` request whose response *is* the stream. It stays open and carries the change notifications the client asked for.

This page is the client end: opening the stream, watching it beside your main flow, and handling its endings. Publishing changes, filtering, and serving the method are the server's side of the story, told in **[Subscriptions](../handlers/subscriptions.md)** under *Inside your handler*. The examples here talk to the sprint-board server built there.

## Watching the stream

A subscription is one context manager. Entering it sends the request, with your keyword arguments as the subscription filter, and waits for the server's acknowledgment, so the stream is live by the time the block starts.

```python title="client.py" hl_lines="16 19 29"
--8<-- "docs_src/subscriptions/tutorial003.py"
```

Iteration yields four typed events: `ToolsListChanged`, `PromptsListChanged`, `ResourcesListChanged`, and `ResourceUpdated(uri=...)`.

An event says *what* changed, never *how*. That is why `follow_board` calls `read_resource` and `list_tools`: the event is a cue to refetch. Read `event.uri` rather than assuming which resource moved: a filter can name several URIs, and a server may report a change on a sub-resource of one of them.

Duplicate events waiting to be consumed collapse into one, and refetching still gets you the current state. Only identical events collapse: two `ResourceUpdated` for different URIs are two events.

Two more properties of the handle:

* `sub.honored` is the filter the server acknowledged: a `SubscriptionFilter` with the fields you passed, read as attributes (`sub.honored.prompts_list_changed`). `MCPServer` honors every kind you ask for, so it echoes your request back. A server that narrows the filter (see the [filter warning](../handlers/subscriptions.md#only-what-was-asked-for) on the server page) acknowledges less, and an honored kind may still never fire.
* `sub.subscription_id` is the listen request's id, the one stamped on every frame of this stream. Several subscriptions can be open at once, each demultiplexed by its own id.

## Watching without blocking

`follow_board` runs until the server closes the stream, which may be never, so on its own it owns your program. Real clients want the watcher *beside* the main flow: an agent calls tools while a watcher keeps a cache or a UI current.

Open the subscription first, then start the watcher and get on with your work.

=== "asyncio"

```python title="app.py" hl_lines="18 20"
--8<-- "docs_src/subscriptions/tutorial004_asyncio.py"
```

=== "trio"

```python title="app.py" hl_lines="18 21"
--8<-- "docs_src/subscriptions/tutorial004_trio.py"
```

=== "anyio"

```python title="app.py" hl_lines="18 21"
--8<-- "docs_src/subscriptions/tutorial004_anyio.py"
```

!!! note
`app.py` imports `BOARD` and `read_board` from the first example, which this repo stores as
`tutorial003.py`. If you save the rendered files side by side as `client.py` and `app.py`,
write `from client import BOARD, read_board` instead. The `watch.py` example further down
imports `read_board` the same way.

The order is the point. Nothing is replayed, so an event published before your stream existed is missed. Entering `client.listen(...)` waits for the acknowledgment, so every change from that moment on reaches your watcher, and the snapshot you take inside the block cannot miss one.

Requests run freely beside an open stream, from the watcher task or any other, on the same client. Because *duplicate* unconsumed events coalesce, a busy main flow may produce one refetch rather than three. Events that differ do not coalesce: a filter naming many URIs queues one pending event per URI.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The backlog description understates how many ResourceUpdated events can be pending for one subscription. Since the client keys pending resource updates by the reported event.uri and accepts sub-resource URIs for an honored resource subscription, this should avoid promising only one pending event per subscribed URI.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs/client/subscriptions.md, line 58:

<comment>The backlog description understates how many `ResourceUpdated` events can be pending for one subscription. Since the client keys pending resource updates by the reported `event.uri` and accepts sub-resource URIs for an honored resource subscription, this should avoid promising only one pending event per subscribed URI.</comment>

<file context>
@@ -0,0 +1,86 @@
+
+The order is the point. Nothing is replayed, so an event published before your stream existed is missed. Entering `client.listen(...)` waits for the acknowledgment, so every change from that moment on reaches your watcher, and the snapshot you take inside the block cannot miss one.
+
+Requests run freely beside an open stream, from the watcher task or any other, on the same client. Because *duplicate* unconsumed events coalesce, a busy main flow may produce one refetch rather than three. Events that differ do not coalesce: a filter naming many URIs queues one pending event per URI.
+
+To stop watching, leave the block: there is no `unsubscribe` call. Cancelling the task that owns the block does that for you, and the SDK cancels the listen request the way the transport expects: over streamable HTTP, by closing that request's stream. A watcher that runs for the life of your app never returns on its own, so cancel it, or its task group's scope, at shutdown.
</file context>
Suggested change
Requests run freely beside an open stream, from the watcher task or any other, on the same client. Because *duplicate* unconsumed events coalesce, a busy main flow may produce one refetch rather than three. Events that differ do not coalesce: a filter naming many URIs queues one pending event per URI.
Requests run freely beside an open stream, from the watcher task or any other, on the same client. Because *duplicate* unconsumed events coalesce, a busy main flow may produce one refetch rather than three. Events that differ do not coalesce: `ResourceUpdated` events are keyed by the reported URI, so sub-resource reports can leave multiple pending URI updates even for one subscribed URI.


To stop watching, leave the block: there is no `unsubscribe` call. Cancelling the task that owns the block does that for you, and the SDK cancels the listen request the way the transport expects: over streamable HTTP, by closing that request's stream. A watcher that runs for the life of your app never returns on its own, so cancel it, or its task group's scope, at shutdown.

## Streams end

A stream ends in one of two ways, both ordinary control flow. A graceful server close ends the `async for`; an abrupt drop raises `SubscriptionLost`.

The difference is diagnostic, not a difference in what to do next: the stream is gone, nothing was replayed, and a watcher that still cares re-listens and refetches.

```python title="watch.py" hl_lines="16 20"
--8<-- "docs_src/subscriptions/tutorial005.py"
```

Servers close streams gracefully for their own reasons, including shedding a subscriber whose backlog grew too large, so a clean end is not a signal to stop watching. Back off before re-listening.

`SubscriptionLost` has one local cause too. The client holds at most 1024 unconsumed events, and a consumer that falls that far behind loses the subscription rather than grow without bound. Keep the body of the `async for` short and do slow work elsewhere.

`keep_following` catches only `SubscriptionLost`. Entering `listen()` can also raise `MCPError` (the connection failed, or the server does not serve the method), `TimeoutError` (no acknowledgment arrived), and `ListenNotSupportedError` (a pre-2026 connection). Decide which of those your watcher should retry: the last never heals.

## Recap

* Enter `async with client.listen(...)`; entering waits for the acknowledgment, so nothing published after it is missed.
* Iterate with `async for event in sub`. Events are cues to refetch, never payloads.
* Open the subscription, then run the watcher as a task, and tool calls keep flowing beside it.
* A clean end stops the loop; a drop raises `SubscriptionLost`. Either way: re-listen, refetch, back off first.
* Leaving the block is the unsubscribe.

Publishing these events, narrowing the filter, and scaling past one process are the server's story: **[Subscriptions](../handlers/subscriptions.md)**. These same events also keep a client-side cache honest, and **[Caching](caching.md)** is the next page.
Loading
Loading