Skip to content

Commit b6ed5bc

Browse files
committed
Merge remote-tracking branch 'origin/main' into 3174-client-span-error-status
2 parents 6af12e0 + 0b2fd3e commit b6ed5bc

19 files changed

Lines changed: 411 additions & 48 deletions

File tree

‎docs/handlers/cancellation.md‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Cancellation
2+
3+
A client can give up on a call: the user pressed stop, or a timeout ran out.
4+
5+
When it does, the SDK **cancels your handler**. The `await` it is waiting on raises, the function unwinds, and nothing it returns is sent. Most handlers need to do nothing about that.
6+
7+
Two kinds do: a handler with something to clean up, and a handler that is a plain `def`.
8+
9+
## Clean up in an `async def` tool
10+
11+
Put the cleanup in a `finally`:
12+
13+
```python title="server.py" hl_lines="23 26-28"
14+
--8<-- "docs_src/cancellation/tutorial001.py"
15+
```
16+
17+
* The `finally` runs however the tool ends: it returned, it raised, or it was cancelled.
18+
* Cleanup that has to `await` needs `shield=True`. In a cancelled handler every further `await` raises too, so without the shield `release_hold` would stop at its first line.
19+
* Nothing can cancel a shielded block, so give it a time limit. Here that is `5` seconds.
20+
21+
!!! tip
22+
Reach for `finally`, not `except`. The cancellation has to keep travelling up once your cleanup
23+
is done, and a `finally` lets it.
24+
25+
## Stop early in a plain `def` tool
26+
27+
A plain `def` tool runs in a thread, and nothing can interrupt a thread from outside. The tool has to ask:
28+
29+
```python title="server.py" hl_lines="22 25-26"
30+
--8<-- "docs_src/cancellation/tutorial002.py"
31+
```
32+
33+
* `anyio.from_thread.check_cancelled()` does nothing while the call is live, and raises once it has been cancelled. Call it between units of work.
34+
* Cleanup goes in a `finally` here too. Nothing in a thread awaits, so it needs no shield.
35+
* A `def` tool that never asks runs to the end, and its result is thrown away.
36+
37+
## Where it applies
38+
39+
Prompt and resource functions are cancelled exactly like tools.
40+
41+
It works the same over stdio and Streamable HTTP. With this SDK's `Client`, giving up means cancelling the task that awaits `call_tool`, or letting its `read_timeout_seconds` run out.
42+
43+
!!! warning
44+
Two Streamable HTTP options keep the news from your handler: `json_response=True` on a
45+
`2026-07-28` connection, and `stateless_http=True` on a legacy one. There the handler runs to
46+
the end whatever the client did.
47+
48+
## Recap
49+
50+
* When the client gives up on a call, the SDK cancels the handler: tool, prompt or resource.
51+
* `async def`: clean up in a `finally`, and put cleanup that awaits inside `anyio.move_on_after(seconds, shield=True)`.
52+
* Plain `def`: call `anyio.from_thread.check_cancelled()` between units of work, or the tool runs to the end. A plain `finally` cleans up.
53+
* `json_response=True` (modern connections) and `stateless_http=True` (legacy ones) switch cancellation off.
54+
55+
Progress and cancellation are between a running tool and its *caller*. The lines it logs for *you*, the person operating the server, are a different channel: **[Logging](logging.md)**.

‎docs/handlers/index.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ What it can do while it runs:
2222
**[Sampling and roots](sampling-and-roots.md)**, deprecated but still
2323
served.
2424
* Report **[Progress](progress.md)** on something slow.
25+
* Clean up, or stop early, when the client gives up on the call, with
26+
**[Cancellation](cancellation.md)**.
2527
* Write logs (to standard error, for whoever operates the server) with
2628
**[Logging](logging.md)**.
2729
* Tell subscribed clients that something changed with

‎docs/handlers/progress.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,4 +115,4 @@ The callback receives `total=None`. A client can still show *activity* ("3 impor
115115
* No callback on the call means `report_progress` does nothing. Report unconditionally.
116116
* Omit `total` when you don't know it; the callback gets `None`.
117117

118-
Progress is what a running tool shows the *user*. The lines it logs for *you*, the person operating the server, are a different channel: **[Logging](logging.md)**.
118+
Progress is for a client that is still waiting. What your tool sees when the client stops waiting is **[Cancellation](cancellation.md)**.

‎docs/migration.md‎

Lines changed: 4 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ dependencies = [
9797
]
9898
```
9999

100-
Relax or bump any conflicting pins when upgrading. sse-starlette jumps two majors, so a project that imports `sse_starlette` itself must also work through that library's own breaking changes to co-install with mcp v2. `opentelemetry-api` is a new hard dependency because every outbound request now carries a `_meta` envelope used for OpenTelemetry trace propagation; see [Every outbound request now carries a `_meta` envelope](#every-outbound-request-now-carries-a-_meta-envelope-opentelemetry-is-on-by-default). `mcp-types` is exact-pinned to the SDK version; nothing in a v1 tree can conflict with it, but do not pin `mcp-types` independently of `mcp`.
100+
Relax or bump any conflicting pins when upgrading. sse-starlette jumps two majors, so a project that imports `sse_starlette` itself must also work through that library's own breaking changes to co-install with mcp v2. `opentelemetry-api` is a new hard dependency because OpenTelemetry trace propagation now ships enabled; see [OpenTelemetry is on by default](#opentelemetry-is-on-by-default). `mcp-types` is exact-pinned to the SDK version; nothing in a v1 tree can conflict with it, but do not pin `mcp-types` independently of `mcp`.
101101

102102
### `httpx` and `httpx-sse` replaced by `httpx2`
103103

@@ -1789,7 +1789,7 @@ Positional callers (`session.elicit_form(message, schema)`) are unaffected, and
17891789

17901790
### `Client` defaults to `mode='auto'`
17911791

1792-
In v1, connecting to a server always performed the `initialize` handshake. In v2, `Client` defaults to `mode='auto'`: on enter it probes `server/discover` and, if the server doesn't support it, falls back to the `initialize` handshake. Pass `mode='legacy'` to force the initialize handshake and reproduce v1's pre-2026 connection sequence (the per-request wire shape still differs from v1; see [Every outbound request now carries a `_meta` envelope](#every-outbound-request-now-carries-a-_meta-envelope-opentelemetry-is-on-by-default)), or pass a modern protocol-version string (e.g. `mode='2026-07-28'`) to pin a version without probing.
1792+
In v1, connecting to a server always performed the `initialize` handshake. In v2, `Client` defaults to `mode='auto'`: on enter it probes `server/discover` and, if the server doesn't support it, falls back to the `initialize` handshake. Pass `mode='legacy'` to force the initialize handshake and reproduce v1's pre-2026 connection sequence, or pass a modern protocol-version string (e.g. `mode='2026-07-28'`) to pin a version without probing.
17931793

17941794
The probe is transport-independent: v2 servers answer it over stdio (and any other stream-pair transport) as well as streamable HTTP, so `mode='auto'` lands on `2026-07-28` against a v2 server on every transport. If your stdio workflow relies on server-initiated requests (sampling, push elicitation, roots), pass `mode='legacy'` — a 2026-07-28 connection refuses them on every transport with `NoBackChannelError` (see [Server-initiated sampling, elicitation, and roots raise `NoBackChannelError`](#server-initiated-sampling-elicitation-and-roots-raise-nobackchannelerror)).
17951795

@@ -2659,25 +2659,9 @@ Validation runs when the result is serialized onto the wire, not when the model
26592659

26602660
In v1, a request for a method the SDK didn't recognize failed request-union validation and was answered with `-32602` (`"Invalid request parameters"`, empty `data`). Any method the receiver doesn't serve — unrecognized on either side, or a spec method the server has no registered handler for — is now answered with the JSON-RPC-specified `-32601` (`"Method not found"`), with the method name in `data`, in every initialization state. Clients still decline sampling, elicitation, and roots requests with `-32600` when no callback is registered, as in v1. Update anything that matched on the old code for this case.
26612661

2662-
### Every outbound request now carries a `_meta` envelope; OpenTelemetry is on by default
2662+
### OpenTelemetry is on by default
26632663

2664-
v2 sends `"_meta": {}` in the params of every request it emits, at every negotiated protocol version. Requests that had no params in v1, such as `ping` and `tools/list`, now carry `"params": {"_meta": {}}`; server-initiated requests get the same envelope. This is spec-valid and accepted by all peers, but wire traffic differs from v1 on every call, and no configuration restores the v1 wire shape. Update any test or tooling that asserts on raw outbound request bytes.
2665-
2666-
**Before (v1):** same client code, 2025-11-25 peer:
2667-
2668-
```text
2669-
{"method":"ping","jsonrpc":"2.0","id":1}
2670-
{"method":"tools/list","jsonrpc":"2.0","id":2}
2671-
```
2672-
2673-
**After (v2):**
2674-
2675-
```text
2676-
{"jsonrpc":"2.0","id":2,"method":"ping","params":{"_meta":{}}}
2677-
{"jsonrpc":"2.0","id":3,"method":"tools/list","params":{"_meta":{}}}
2678-
```
2679-
2680-
The envelope exists for OpenTelemetry trace propagation ([SEP-414](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/414)), which now ships enabled: every server installs a tracing middleware and the client opens a span per outbound request. With no OpenTelemetry SDK configured these are no-ops and only the empty envelope is visible. If your application already configures a global tracer provider, it starts recording MCP client and server spans with no code change, and a W3C `traceparent` field is injected into outbound `_meta`, propagating your trace ids to the servers you call. To suppress the spans, filter the `mcp-python-sdk` tracer in your pipeline; [OpenTelemetry](run/opentelemetry.md) has the recipe for removing the server middleware. There is no public switch for the client-side span and `traceparent` injection.
2664+
OpenTelemetry trace propagation ([SEP-414](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/414)) now ships enabled: every server installs a tracing middleware and the client opens a span per outbound request. With no OpenTelemetry SDK configured these are no-ops and nothing is added to outbound requests. If your application already configures a global tracer provider, it starts recording MCP client and server spans with no code change, and a W3C `traceparent` field is injected into outbound `_meta`, propagating your trace ids to the servers you call. To suppress the spans, filter the `mcp-python-sdk` tracer in your pipeline; [OpenTelemetry](run/opentelemetry.md) has the recipe for removing the server middleware. There is no public switch for the client-side span and `traceparent` injection.
26812665

26822666
The SDK's new `opentelemetry-api` runtime dependency is covered under [Packaging, dependencies, and CLI](#packaging-dependencies-and-cli).
26832667

‎docs/servers/tools.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ You can mix and match: plain parameters next to model parameters, nested models,
136136

137137
If a tool does I/O (calls an API, reads a file, queries a database), declare it `async def` and `await` inside it. The SDK awaits it.
138138

139-
A plain `def` tool works too: the SDK runs it in a thread so it never blocks the server.
139+
A plain `def` tool works too: the SDK runs it in a thread so it never blocks the server. A long one can check whether the client is still waiting; see **[Cancellation](../handlers/cancellation.md)**.
140140

141141
There is nothing else to configure.
142142

‎docs_src/cancellation/__init__.py‎

Whitespace-only changes.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import anyio
2+
3+
from mcp.server import MCPServer
4+
5+
mcp = MCPServer("Bookshop")
6+
7+
holds: set[str] = set()
8+
9+
10+
async def take_payment(title: str) -> None:
11+
await anyio.sleep(30) # the customer is typing a card number
12+
13+
14+
async def release_hold(title: str) -> None:
15+
await anyio.sleep(0.1) # a round trip to the stock system
16+
holds.discard(title)
17+
18+
19+
@mcp.tool()
20+
async def order_book(title: str) -> str:
21+
"""Hold a copy of a book while the customer pays for it."""
22+
holds.add(title)
23+
try:
24+
await take_payment(title)
25+
return f"Ordered {title!r}."
26+
finally:
27+
with anyio.move_on_after(5, shield=True):
28+
await release_hold(title)
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import time
2+
3+
import anyio.from_thread
4+
5+
from mcp.server import MCPServer
6+
7+
mcp = MCPServer("Bookshop")
8+
9+
offline: set[str] = set()
10+
11+
12+
def index_book(title: str) -> None:
13+
time.sleep(1) # slow work with nothing to await
14+
15+
16+
@mcp.tool()
17+
def rebuild_index(titles: list[str]) -> str:
18+
"""Take search offline and rebuild its index, one book at a time."""
19+
offline.add("search")
20+
try:
21+
for title in titles:
22+
anyio.from_thread.check_cancelled()
23+
index_book(title)
24+
return f"Indexed {len(titles)} books."
25+
finally:
26+
offline.discard("search")

‎mkdocs.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ nav:
4040
- Multi-round-trip requests: handlers/multi-round-trip.md
4141
- Sampling and roots: handlers/sampling-and-roots.md
4242
- Progress: handlers/progress.md
43+
- Cancellation: handlers/cancellation.md
4344
- Logging: handlers/logging.md
4445
- Subscriptions: handlers/subscriptions.md
4546
- Running your server:

‎src/mcp/shared/jsonrpc_dispatcher.py‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -362,7 +362,6 @@ async def send_raw_request(
362362
if on_progress is not None:
363363
# The request id doubles as the progress token, so `_pending[token]` finds `on_progress` directly.
364364
out_meta["progressToken"] = request_id
365-
out_params["_meta"] = out_meta
366365

367366
# buffer=1: a close signal can arrive before the waiter parks in receive();
368367
# a WouldBlock later just means the waiter already has its one outcome.
@@ -389,9 +388,18 @@ async def send_raw_request(
389388
kind=SpanKind.CLIENT,
390389
attributes={"mcp.method.name": method, "jsonrpc.request.id": str(request_id)},
391390
) as span:
392-
# SEP-414: inject W3C trace context; `_meta` stays on the wire even with a no-op tracer.
391+
# SEP-414: inject W3C trace context.
393392
inject_trace_context(out_meta)
394-
msg = JSONRPCRequest(jsonrpc="2.0", id=request_id, method=method, params=out_params)
393+
if out_meta:
394+
out_params["_meta"] = out_meta
395+
else:
396+
out_params.pop("_meta", None)
397+
# Leave `params` unset when empty: with `exclude_unset=True` an explicit
398+
# None would serialize as `"params": null`, which JSON-RPC 2.0 forbids.
399+
if out_params:
400+
msg = JSONRPCRequest(jsonrpc="2.0", id=request_id, method=method, params=out_params)
401+
else:
402+
msg = JSONRPCRequest(jsonrpc="2.0", id=request_id, method=method)
395403
# Surface a pre-existing cancellation while the request provably
396404
# never started; past this point a cancelled write counts as issued.
397405
await anyio.lowlevel.checkpoint_if_cancelled()

0 commit comments

Comments
 (0)