Skip to content

Commit c7c0be9

Browse files
committed
docs: worked examples for ping and roots change notifications
Both are removed from the 2026-07-28 wire and only reachable through decorated APIs, so they land on the deprecated-features page (the one page whose tests allow the calls) as inline snippets whose claims are restated and proved in test_deprecated.py. "`ping` is removed" runs client.send_ping() and ctx.session.send_ping() on a legacy session and shows the two different failures on a modern one. "Roots change notifications" shows a legacy client sending notifications/roots/list_changed and a low-level Server re-listing in on_roots_list_changed, plus the silent drop without mode="legacy". Short pointers from callbacks, sampling-and-roots, the low-level server page and troubleshooting route readers there. No-Verification-Needed: docs and tests only
1 parent 5908286 commit c7c0be9

6 files changed

Lines changed: 438 additions & 24 deletions

File tree

docs/advanced/low-level-server.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ ValueError: 'initialize' is handled by the server runner and cannot be overridde
175175
use Server.middleware to observe or wrap initialization
176176
```
177177

178-
The handshake belongs to the runner. `server/discover`, `ping`, and every other built-in are yours to replace.
178+
The handshake belongs to the runner. `server/discover`, `ping` (which only handshake-era peers still send), and every other built-in are yours to replace.
179179

180180
!!! tip
181181
`Server.middleware`, mentioned in that error, wraps **every** inbound message, including `initialize`. If what you want is to observe or rewrite traffic rather than answer a new method, start at **[Middleware](middleware.md)**.
@@ -187,6 +187,7 @@ Each of these is one idea you now have the vocabulary for; each has its own page
187187
* `on_call_tool`, `on_get_prompt`, and `on_read_resource` may return an `InputRequiredResult` instead of their normal result to pause the call and ask the client for input; see **[Multi-round-trip requests](../handlers/multi-round-trip.md)**. True to this tier, nothing is installed for you: where `MCPServer` seals `requestState` by default, here the `request_state` you set crosses the wire exactly as written until you opt in with `server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[...]), default_audience=server.name))`: one line (both names import from `mcp.server.request_state`) for the identical sealing and verification `MCPServer` performs (**[Protecting `requestState`](../handlers/multi-round-trip.md#protecting-requeststate)**).
188188
* `on_list_resources`, `on_read_resource`, `on_list_prompts`, `on_get_prompt`, `on_completion` are the same `(ctx, params) -> result` shape for the other primitives.
189189
* `on_subscriptions_listen` serves the 2026-07-28 `subscriptions/listen` stream. Pass a `ListenHandler` built over a `SubscriptionBus` and publish events to the bus from your other handlers; see **[Subscriptions](../handlers/subscriptions.md)** for the full composition.
190+
* `on_roots_list_changed=` receives `notifications/roots/list_changed` from a 2025-era client. It is deprecated with the rest of roots and passing it warns at construction; **[Deprecated features](../deprecated.md#roots-change-notifications)** runs it end to end.
190191
* `server.streamable_http_app()` returns the same Starlette app `MCPServer`'s does; deploy it the way **[Running your server](../run/index.md)** deploys any other ASGI app. There is no `server.run(transport=...)` down here: `server.run(read_stream, write_stream, server.create_initialization_options())` drives one connection over a pair of streams, and that one line is the whole story.
191192

192193
## Recap

docs/client/callbacks.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,8 @@ You still need the callbacks to talk to servers that haven't moved. The signatur
129129

130130
Pass them to `Client(...)` exactly like `elicitation_callback`.
131131

132+
The `"listChanged": true` a roots callback declares is a promise to tell the server when your list changes. You keep it with `client.send_roots_list_changed()`, which only does anything on a `mode="legacy"` session and warns like the rest of the roots API; **[Deprecated features](../deprecated.md#roots-change-notifications)** shows both ends.
133+
132134
## The notification callbacks
133135

134136
Two more. Neither declares anything.

docs/deprecated.md

Lines changed: 153 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,17 @@ The table below names each deprecated feature, why it is going away, and the rep
88

99
| Deprecated | Why | What you do instead |
1010
|---|---|---|
11-
| **Roots**: `ctx.session.list_roots()`, `client.send_roots_list_changed()`, the `list_roots_callback=` you pass to `Client(...)` | [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) retires the capability. | Take the paths as ordinary tool arguments or resource URIs, or embed a `ListRootsRequest` in an `InputRequiredResult` (see **[Multi-round-trip requests](handlers/multi-round-trip.md)**). |
11+
| **Roots**: `ctx.session.list_roots()`, `client.send_roots_list_changed()`, the `list_roots_callback=` you pass to `Client(...)`, the `on_roots_list_changed=` you pass to `Server(...)` | [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) retires the capability. | Take the paths as ordinary tool arguments or resource URIs, or embed a `ListRootsRequest` in an `InputRequiredResult` (see **[Multi-round-trip requests](handlers/multi-round-trip.md)**). |
1212
| **Server-initiated sampling**: `ctx.session.create_message()`, the `sampling_callback=` you pass to `Client(...)` | SEP-2577 retires the capability. | Return `InputRequiredResult` and let the client retry the call (see **[Multi-round-trip requests](handlers/multi-round-trip.md)**). |
1313
| **Protocol logging**: `ctx.log()`, `ctx.debug()`, `ctx.info()`, `ctx.warning()`, `ctx.error()`, `ctx.session.send_log_message()`, `client.set_logging_level()` | SEP-2577 retires the capability. Nothing in-protocol replaces it. | Ordinary `import logging` to stderr (see **[Logging](handlers/logging.md)**). |
14-
| **`ping`**: `client.send_ping()` | **Removed** from the protocol, not merely deprecated. There is no `ping` method in 2026-07-28. | Nothing. It only works against a `mode="legacy"` connection. |
14+
| **`ping`**: `client.send_ping()`, `ctx.session.send_ping()` | **Removed** from the protocol, not merely deprecated. There is no `ping` method in 2026-07-28. | Nothing. Both work only against a `mode="legacy"` connection, and only the client's warns. |
1515
| **Client->server progress**: `client.send_progress_notification()` | 2026-07-28 makes progress server->client only. | Nothing to send. Your *server* reports progress with `ctx.report_progress()` (see **[Progress](handlers/progress.md)**). |
1616

1717
Three things fall out of that table:
1818

1919
* Roots, sampling, and logging go together. One proposal, **SEP-2577**, deprecates all three capabilities at once.
2020
* Sampling and roots share a deeper problem: they are places a **server** sends a **request** to the **client**. That whole direction is what 2026-07-28 replaces with **[Multi-round-trip requests](handlers/multi-round-trip.md)**. It is the standalone RPC methods (`sampling/createMessage`, `roots/list`, and push-style `elicitation/create`) that are gone; the `CreateMessageRequest` / `ListRootsRequest` / `ElicitRequest` payload types survive, embedded in `InputRequiredResult.input_requests`, and on the client they hit the same callbacks.
21-
* `ping` is the odd one out. The protocol does not deprecate it, it removes it. The SDK method still warns (its message says *removed*, not *deprecated*) and calling it on a modern connection answers with *"Method not found"*.
21+
* `ping` is the odd one out. The protocol does not deprecate it, it removes it ([SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575)), together with two single methods inside the deprecated capabilities: `logging/setLevel` and `notifications/roots/list_changed`. The client's `send_ping()` still warns, and a modern connection answers it with *"Method not found"*. **[`ping` is removed](#ping-is-removed)** and **[Roots change notifications](#roots-change-notifications)** below show `ping` and the roots notification on the one kind of session that still carries them.
2222

2323
## Deprecated is advisory
2424

@@ -50,6 +50,154 @@ MCPDeprecationWarning: The logging capability is deprecated as of 2026-07-28 (SE
5050
send. These two only work end-to-end on a `mode="legacy"` connection whose client
5151
registered the matching callback.
5252

53+
## `ping` is removed
54+
55+
A **ping** is an empty request either side sends to check that the other is still answering. It takes no arguments and its result is empty; the only information is that a result came back.
56+
57+
The 2026-07-28 spec removes it, in both directions ([SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575)). A modern connection has no channel for the server to send a request on, so a server's ping cannot be sent at all; in the other direction, any request that gets an answer has already proved the server is there. Beneath the protocol, whether the connection itself is healthy is the transport's business (HTTP keep-alive, SSE comments, whether the stdio process is still running), not a method's.
58+
59+
Both SDK methods are still there for handshake-era sessions:
60+
61+
```python title="client.py" hl_lines="11 16-17"
62+
from mcp import Client
63+
from mcp.server import MCPServer
64+
from mcp.server.mcpserver import Context
65+
66+
mcp = MCPServer("Bookshop")
67+
68+
69+
@mcp.tool()
70+
async def check_client(ctx: Context) -> str:
71+
"""A tool that still pings the client mid-call."""
72+
await ctx.session.send_ping()
73+
return "client answered"
74+
75+
76+
async def main() -> None:
77+
async with Client(mcp, mode="legacy") as client:
78+
await client.send_ping()
79+
result = await client.call_tool("check_client", {})
80+
print(client.protocol_version, result.structured_content)
81+
```
82+
83+
* `ctx.session.send_ping()` is the server asking the client, from inside a handler. It carries no warning.
84+
* `mode="legacy"` runs the classic `initialize` handshake, so this is a `2025-11-25` session: the only kind either ping can cross.
85+
* `client.send_ping()` is the client asking the server, and it is the one that warns: `MCPDeprecationWarning` fires the moment you call it, on any connection.
86+
87+
```text
88+
2025-11-25 {'result': 'client answered'}
89+
```
90+
91+
Neither end registered a handler for the ping. `MCPServer` answers the client's and `Client` answers the server's, both with an `EmptyResult`: `{}` on the wire. (The low-level `Server` takes `on_ping=` if you want your own code to run when one arrives; only handshake-era peers ever reach it.)
92+
93+
!!! check
94+
Drop `mode="legacy"` and run it again. The connection lands on `2026-07-28`, and
95+
`client.send_ping()` gives you two signals, in this order: the `MCPDeprecationWarning` the
96+
moment you call it, then the server's answer to the request itself, raised as an `MCPError`:
97+
98+
```text
99+
MCPDeprecationWarning: ping is removed as of 2026-07-28; the method only works under mode='legacy'.
100+
MCPError: Method not found
101+
```
102+
103+
The error's `data` field names the missing method: `'ping'`. The server answered; it is
104+
telling you the method does not exist at the version you are speaking.
105+
106+
Delete the `client.send_ping()` line as well, so the script reaches `call_tool`, and the
107+
failure moves to the server side. Nothing warns, because `ctx.session.send_ping()` carries no
108+
deprecation, and nothing is sent, because a modern connection has no channel to carry a
109+
request from the server. `call_tool` does not hand back an `is_error` result; it raises:
110+
111+
```text
112+
Cannot send 'ping': this transport context has no back-channel for server-initiated requests.
113+
```
114+
115+
In new code the fix for either failure is to delete the ping rather than pin
116+
`mode="legacy"`: the `tools/call` and its result already showed that both ends were there.
117+
118+
The server-side method keeps one use. If you hold a legacy `ServerSession` so you can notify that client later, sending a notification tells you nothing about whether it is still connected: one sent to a closed connection is dropped without an error. `await session.send_ping()` is a request, so it raises `MCPError` once the client has gone. There is no equivalent for a `2026-07-28` client: `send_ping()` on its session raises the no-back-channel error whether or not the client is still there.
119+
120+
## Roots change notifications
121+
122+
**Roots** are the workspace folders a client invites the server to work in. The server learns them by asking, with a `roots/list` request, and a 2025-era client can also announce that its list changed: it sends `notifications/roots/list_changed`, which carries no payload, and a server that cares responds by asking again.
123+
124+
That exchange exists only on a handshake-era session. The 2026-07-28 spec deprecates the roots capability as a whole and removes this one notification outright, as it removes `ping`, because a modern server never holds a list that could go stale: it asks for roots at the moment it needs them, by returning the request inside its tool result (the `ListRoots()` dependency on **[Sampling and roots](handlers/sampling-and-roots.md)** does that for you). `MCPServer` has no hook for the notification for that reason, so the receiving end below is a low-level `Server`. What follows is for clients and servers that still talk to pre-2026 peers.
125+
126+
Here are both ends in one module: a client that exposes a workspace and announces a new folder, and a server that keeps its own copy of the roots and refreshes it on every announcement:
127+
128+
```python title="client.py" hl_lines="12 19 32 36"
129+
import anyio
130+
from pydantic import FileUrl
131+
132+
from mcp import Client
133+
from mcp.client import ClientRequestContext
134+
from mcp.server import Server, ServerRequestContext
135+
from mcp.types import ListRootsResult, NotificationParams, Root
136+
137+
workspace = [Root(uri=FileUrl("file:///home/ada/catalog"), name="catalog")]
138+
139+
140+
async def list_roots(context: ClientRequestContext) -> ListRootsResult:
141+
return ListRootsResult(roots=workspace)
142+
143+
144+
async def open_folder(client: Client, uri: str, name: str) -> None:
145+
"""The user opened another folder: expose it, then tell the server."""
146+
workspace.append(Root(uri=FileUrl(uri), name=name))
147+
await client.send_roots_list_changed()
148+
149+
150+
workspace_folders: list[str] = []
151+
152+
153+
async def roots_changed(ctx: ServerRequestContext, params: NotificationParams | None) -> None:
154+
"""The client's roots changed: ask for the new list."""
155+
result = await ctx.session.list_roots()
156+
workspace_folders[:] = [str(root.uri) for root in result.roots]
157+
print(workspace_folders)
158+
159+
160+
server = Server("Bookshop", on_roots_list_changed=roots_changed)
161+
162+
163+
async def main() -> None:
164+
async with Client(server, mode="legacy", list_roots_callback=list_roots) as client:
165+
await open_folder(client, "file:///home/ada/archive", "archive")
166+
await anyio.sleep_forever() # a host keeps its session open, and the server's roots/list arrives on it
167+
```
168+
169+
* `list_roots` is the ordinary roots callback (**[Client callbacks](client/callbacks.md)** covers it). Registering it is what declares `"roots": {"listChanged": true}` in the handshake, and `listChanged` is precisely the promise to send this notification.
170+
* `open_folder` is what a host calls whenever the user adds a folder: update the list, then notify. A notification has no response, so the call returning means the frame left, not that the server has caught up.
171+
* `on_roots_list_changed=` sits with the other `on_*` handlers on **[the low-level Server](advanced/low-level-server.md)** and has the notification shape, `async (ctx, params) -> None`. `params` is empty: the client says *that* the list changed, never what it changed to.
172+
* The handler's job is to ask again. `ctx.session.list_roots()` sends `roots/list` back over the same session, the client's `list_roots` answers it, and `workspace_folders` is current. Like every server-to-client request it needs a back-channel, which a `stateless_http=True` deployment does not have (**[Serving legacy clients](run/legacy-clients.md)**).
173+
* `mode="legacy"` gives the notification somewhere to go: it forces the `initialize` handshake (**[Protocol versions](protocol-versions.md)**), and only a handshake-era session defines the method. `main()` then keeps the session open, as a real host does for as long as it runs; hang up straight after `open_folder` and the server's follow-up `roots/list` has nowhere to go.
174+
175+
On the wire the notification is a method name and nothing else:
176+
177+
```json
178+
{"jsonrpc": "2.0", "method": "notifications/roots/list_changed"}
179+
```
180+
181+
Run `main()` and the server's handler prints the refreshed list a moment after `open_folder` returns:
182+
183+
```text
184+
['file:///home/ada/catalog', 'file:///home/ada/archive']
185+
```
186+
187+
Everything deprecated on the way warned with the same message: constructing `Server(...)` with `on_roots_list_changed=`, calling `send_roots_list_changed()`, and calling `ctx.session.list_roots()`.
188+
189+
```text
190+
MCPDeprecationWarning: The roots capability is deprecated as of 2026-07-28 (SEP-2577).
191+
```
192+
193+
!!! check
194+
Drop `mode="legacy"` and run it again. The client now negotiates `2026-07-28` with the same
195+
server. The client's warning fires exactly as before, `send_roots_list_changed()` returns
196+
without error, and then nothing happens: `roots_changed` never runs and nothing is printed,
197+
and nothing reaches the client to say so. The method is not part of that protocol version,
198+
and a notification has no reply that could carry a refusal. If the announcement matters, the
199+
connection has to be one you pinned with `mode="legacy"`.
200+
53201
## Silencing the warning
54202

55203
Don't, in new code.
@@ -85,6 +233,8 @@ That is the whole API. There is no per-method switch, and you don't want one: th
85233
* The replacement column points you onward: **[Multi-round-trip requests](handlers/multi-round-trip.md)** for sampling and roots, **[Logging](handlers/logging.md)** for logging, **[Progress](handlers/progress.md)** for progress. `ping` needs nothing at all.
86234
* Deprecated is advisory: no wire changes, everything keeps working against pre-2026 sessions, and you get a visible `MCPDeprecationWarning` (a `UserWarning`, so it is on by default).
87235
* Sampling and roots additionally need a back-channel that a 2026-07-28 session does not have. On a modern connection they warn and then they raise.
236+
* `ping` is removed in both directions. Both SDK methods still round-trip on a `mode="legacy"` session (only the client's warns); on a modern connection the client's is answered *"Method not found"* and the server's raises the no-back-channel error.
237+
* A legacy client announces changed roots with `client.send_roots_list_changed()`; a low-level `Server(on_roots_list_changed=...)` handler receives it and requests `roots/list` again. At 2026-07-28 the notification does not exist: the call warns and goes nowhere.
88238
* `warnings.filterwarnings("ignore", category=MCPDeprecationWarning)` silences the whole category; `"error::mcp.MCPDeprecationWarning"` in pytest turns it into a test failure.
89239
* New code should not be built on any of these.
90240

docs/handlers/sampling-and-roots.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ On the other side of the wire, the client answers both requests with the callbac
3737

3838
`ctx.session.create_message(...)` and `ctx.session.list_roots()` still exist for code that drives the session directly. They only work where a back-channel exists (2025-era, non-stateless connections), and calling them raises a deprecation warning. The resolver markers above are the supported form: they pick the delivery from the negotiated version and don't warn.
3939

40+
A 2025-era client can also announce that its roots changed. `MCPServer` has no hook for that notification, and a `ListRoots()` dependency asks afresh on every call, so there is nothing to refresh; the low-level `Server` handler that receives it is on **[Deprecated features](../deprecated.md#roots-change-notifications)**.
41+
4042
## Recap
4143

4244
* Return `Sample(...)` or `ListRoots()` from a resolver; the tool receives the `CreateMessageResult` or `ListRootsResult` like any other dependency.

0 commit comments

Comments
 (0)