Skip to content

Commit 0b2fd3e

Browse files
authored
Omit an empty _meta and empty params from outbound requests (#3628)
1 parent cdf7d33 commit 0b2fd3e

10 files changed

Lines changed: 63 additions & 46 deletions

File tree

‎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

‎src/mcp/shared/jsonrpc_dispatcher.py‎

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

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

‎tests/conftest.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ def _capfire_isolated(capfire: CaptureLogfire) -> Iterator[CaptureLogfire]:
8282
rest of the process. Without isolation, every subsequent test in the same
8383
worker would emit real spans, and `send_raw_request` would inject a real
8484
`traceparent` into outbound `_meta`, breaking the interaction-suite
85-
snapshots that pin `_meta={}` under a no-op tracer.
85+
snapshots that pin no `_meta` under a no-op tracer.
8686
8787
Setup points `_tracer` at the now-live provider so MCP spans record;
8888
teardown replaces it with a `NoOpTracer`.

‎tests/interaction/lowlevel/test_elicitation.py‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,6 @@ async def answer_form(context: ClientRequestContext, params: types.ElicitRequest
8282
assert received == snapshot(
8383
[
8484
ElicitRequestFormParams(
85-
_meta={},
8685
message="Choose a username.",
8786
requested_schema={
8887
"type": "object",
@@ -229,7 +228,6 @@ async def answer_url(context: ClientRequestContext, params: types.ElicitRequestP
229228
assert received == snapshot(
230229
[
231230
ElicitRequestURLParams(
232-
_meta={},
233231
message="Authorize access to your calendar.",
234232
url="https://example.com/oauth/authorize",
235233
elicitation_id="auth-001",

‎tests/interaction/lowlevel/test_progress.py‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,7 @@ async def list_tools(
9898

9999
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
100100
assert params.name == "inspect"
101-
assert ctx.meta is not None
102-
return CallToolResult(content=[TextContent(text=str(ctx.meta.get("progress_token")))])
101+
return CallToolResult(content=[TextContent(text=str((ctx.meta or {}).get("progress_token")))])
103102

104103
server = Server("introspector", on_list_tools=list_tools, on_call_tool=call_tool)
105104

‎tests/interaction/lowlevel/test_sampling.py‎

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,6 @@ async def sampling_callback(
7777
assert received == snapshot(
7878
[
7979
CreateMessageRequestParams(
80-
_meta={},
8180
messages=[SamplingMessage(role="user", content=TextContent(text="Say hello."))],
8281
max_tokens=100,
8382
)
@@ -137,7 +136,6 @@ async def sampling_callback(
137136
assert received == snapshot(
138137
[
139138
CreateMessageRequestParams(
140-
_meta={},
141139
messages=[SamplingMessage(role="user", content=TextContent(text="Pick a model."))],
142140
model_preferences=ModelPreferences(
143141
hints=[ModelHint(name="claude"), ModelHint(name="gpt")],
@@ -199,7 +197,6 @@ async def sampling_callback(
199197
assert received == snapshot(
200198
[
201199
CreateMessageRequestParams(
202-
_meta={},
203200
messages=[SamplingMessage(role="user", content=ImageContent(data="aW1n", mime_type="image/png"))],
204201
max_tokens=100,
205202
)
@@ -481,7 +478,6 @@ async def sampling_callback(
481478
assert received == snapshot(
482479
[
483480
CreateMessageRequestParams(
484-
_meta={},
485481
messages=[SamplingMessage(role="user", content=AudioContent(data="c25k", mime_type="audio/wav"))],
486482
max_tokens=100,
487483
)
@@ -574,7 +570,6 @@ async def sampling_callback(
574570
assert received == snapshot(
575571
[
576572
CreateMessageRequestParams(
577-
_meta={},
578573
messages=[
579574
SamplingMessage(
580575
role="user",

‎tests/interaction/mcpserver/test_context.py‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,6 @@ async def answer_form(context: ClientRequestContext, params: ElicitRequestParams
184184
assert received == snapshot(
185185
[
186186
ElicitRequestFormParams(
187-
_meta={},
188187
message="Where to?",
189188
requested_schema={
190189
"properties": {

‎tests/server/test_runner.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -537,8 +537,8 @@ async def test_runner_absent_wire_params_reaches_request_handler_as_defaults_mod
537537
"""A request with no `params` member on the wire reaches the handler as
538538
the params model with its defaults, never `None`.
539539
540-
The in-SDK client always attaches `_meta`, so a middleware rewrites
541-
`ctx.params` to `None` to model what an external client sends.
540+
The in-SDK client attaches `_meta` under a live tracer, so a middleware
541+
rewrites `ctx.params` to `None` to pin the absent case regardless.
542542
"""
543543
seen: list[PaginatedRequestParams | None] = []
544544

‎tests/shared/test_dispatcher.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ async def on_request(
4545
ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
4646
) -> dict[str, Any]:
4747
# Strip `_meta` so JSON-RPC and direct dispatch record identically:
48-
# the JSON-RPC outbound path always attaches `_meta` (otel injection).
48+
# the JSON-RPC outbound path attaches `_meta` under a live tracer (otel injection).
4949
recorded = {k: v for k, v in (params or {}).items() if k != "_meta"} if params is not None else None
5050
recorder.requests.append((method, recorded))
5151
recorder.contexts.append(ctx)

‎tests/shared/test_jsonrpc_dispatcher.py‎

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1482,9 +1482,46 @@ async def on_notify(ctx: DCtx, method: str, params: Mapping[str, Any] | None) ->
14821482

14831483

14841484
@pytest.mark.anyio
1485-
async def test_send_raw_request_always_carries_meta_on_the_wire():
1486-
"""Outbound requests always carry `params._meta` (otel injection per SEP-414); caller-supplied
1487-
keys are preserved and the progress token is merged in."""
1485+
async def test_send_raw_request_omits_empty_meta_and_empty_params_on_the_wire():
1486+
"""A request with nothing to put in `_meta` carries none, and one left with no params carries no
1487+
`params` member. A scripted peer serializes as the transports do (`exclude_unset=True`): a
1488+
handler sees `None` for both an absent and a null `params`."""
1489+
c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32)
1490+
s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32)
1491+
client: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(s2c_recv, c2s_send)
1492+
on_request, on_notify = echo_handlers(Recorder())
1493+
wire: list[dict[str, Any]] = []
1494+
1495+
async def peer() -> None:
1496+
for _ in range(3):
1497+
out = await c2s_recv.receive()
1498+
assert isinstance(out, SessionMessage)
1499+
assert isinstance(out.message, JSONRPCRequest)
1500+
wire.append(json.loads(out.message.model_dump_json(by_alias=True, exclude_unset=True)))
1501+
await s2c_send.send(SessionMessage(message=JSONRPCResponse(jsonrpc="2.0", id=out.message.id, result={})))
1502+
1503+
try:
1504+
async with anyio.create_task_group() as tg:
1505+
await tg.start(client.run, on_request, on_notify)
1506+
tg.start_soon(peer)
1507+
with anyio.fail_after(5):
1508+
await client.send_raw_request("ping", None)
1509+
await client.send_raw_request("tools/list", {"_meta": {}})
1510+
await client.send_raw_request("tools/call", {"name": "t"})
1511+
tg.cancel_scope.cancel()
1512+
finally:
1513+
for s in (c2s_send, c2s_recv, s2c_send, s2c_recv):
1514+
s.close()
1515+
assert wire == [
1516+
{"jsonrpc": "2.0", "id": 1, "method": "ping"},
1517+
{"jsonrpc": "2.0", "id": 2, "method": "tools/list"},
1518+
{"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "t"}},
1519+
]
1520+
1521+
1522+
@pytest.mark.anyio
1523+
async def test_send_raw_request_merges_progress_token_into_caller_meta():
1524+
"""Caller-supplied `_meta` keys are preserved and the progress token is merged in."""
14881525
seen: list[Mapping[str, Any] | None] = []
14891526

14901527
async def server_on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
@@ -1497,15 +1534,12 @@ async def noop_progress(progress: float, total: float | None, message: str | Non
14971534
opts: CallOptions = {"on_progress": noop_progress}
14981535
async with running_pair(jsonrpc_pair, server_on_request=server_on_request) as (client, *_):
14991536
with anyio.fail_after(5):
1500-
await client.send_raw_request("a", None)
15011537
await client.send_raw_request("b", {"x": 1, "_meta": {"k": "v"}}, opts)
15021538
# `_meta` contents depend on the active otel tracer, so pin only what sits beyond the W3C keys.
15031539
w3c = {"traceparent", "tracestate"}
1504-
assert seen[0] is not None and seen[0].keys() == {"_meta"}
1505-
assert set(seen[0]["_meta"].keys()) <= w3c
1506-
assert seen[1] is not None and seen[1]["x"] == 1
1507-
assert set(seen[1]["_meta"].keys()) - w3c == {"k", "progressToken"}
1508-
assert seen[1]["_meta"]["k"] == "v"
1540+
assert seen[0] is not None and seen[0]["x"] == 1
1541+
assert set(seen[0]["_meta"].keys()) - w3c == {"k", "progressToken"}
1542+
assert seen[0]["_meta"]["k"] == "v"
15091543

15101544

15111545
@pytest.mark.anyio

0 commit comments

Comments
 (0)