Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
24 changes: 4 additions & 20 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ dependencies = [
]
```

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`.
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`.

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

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

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

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.
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.

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)).

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

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.

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

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.

**Before (v1):** same client code, 2025-11-25 peer:

```text
{"method":"ping","jsonrpc":"2.0","id":1}
{"method":"tools/list","jsonrpc":"2.0","id":2}
```

**After (v2):**

```text
{"jsonrpc":"2.0","id":2,"method":"ping","params":{"_meta":{}}}
{"jsonrpc":"2.0","id":3,"method":"tools/list","params":{"_meta":{}}}
```

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.
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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: "Nothing is added to outbound requests" is too broad: progress tokens and caller-supplied non-empty metadata remain outbound without an OpenTelemetry SDK. Say that no tracing fields are added so readers do not infer that progress or metadata disappear.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At docs/migration.md, line 2664:

<comment>"Nothing is added to outbound requests" is too broad: progress tokens and caller-supplied non-empty metadata remain outbound without an OpenTelemetry SDK. Say that no tracing fields are added so readers do not infer that progress or metadata disappear.</comment>

<file context>
@@ -2659,25 +2659,9 @@ Validation runs when the result is serialized onto the wire, not when the model
-```
-
-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.
+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.
 
 The SDK's new `opentelemetry-api` runtime dependency is covered under [Packaging, dependencies, and CLI](#packaging-dependencies-and-cli).
</file context>
Suggested change
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.
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 no tracing fields are added to outbound requests; progress tokens and caller-supplied metadata are unchanged. 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.


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

Expand Down
14 changes: 11 additions & 3 deletions src/mcp/shared/jsonrpc_dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,6 @@ async def send_raw_request(
if on_progress is not None:
# The request id doubles as the progress token, so `_pending[token]` finds `on_progress` directly.
out_meta["progressToken"] = request_id
out_params["_meta"] = out_meta

# buffer=1: a close signal can arrive before the waiter parks in receive();
# a WouldBlock later just means the waiter already has its one outcome.
Expand All @@ -386,9 +385,18 @@ async def send_raw_request(
kind=SpanKind.CLIENT,
attributes={"mcp.method.name": method, "jsonrpc.request.id": str(request_id)},
):
# SEP-414: inject W3C trace context; `_meta` stays on the wire even with a no-op tracer.
# SEP-414: inject W3C trace context.
inject_trace_context(out_meta)
msg = JSONRPCRequest(jsonrpc="2.0", id=request_id, method=method, params=out_params)
if out_meta:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 nit (optional): AGENTS.md says any change to an existing API's observable behaviour in 2.x is an explicit maintainer design decision and should generally be avoided. src/mcp/shared/jsonrpc_dispatcher.py now omits _meta when empty and omits params when empty, so on SDK-to-SDK legacy connections ctx.meta, middleware ctx.params and sampling/elicitation params.meta flip from {} to None, and raw wire bytes change on every param-less request. Fix: record this as the explicit 2.x compatibility decision it is (link #3473 and the migration.md rewrite in the PR) so the behaviour change is a documented maintainer call rather than an incidental side effect of the bug fix.

Why this was flagged

Nothing fails at runtime inside the SDK. The guard is against silent 2.x behaviour drift: handler code written against v2 so far, like ctx.meta.get("progress_token"), now raises AttributeError on a None meta when the peer is a v2 client on a 2025-11-25 connection — the PR's own tests/interaction/lowlevel/test_progress.py had to drop assert ctx.meta is not None and switch to (ctx.meta or {}).get(...), which is exactly the user-side edit this implies. Mitigating facts for the maintainer: the change restores the v1 wire shape, fixes servers that reject an empty _meta (#3473), 2026-07-28 connections are byte-identical, and the author appears to be a maintainer (a TODO(maxisbey) sits in this same function), so this may already be the explicit decision the instruction asks for.

Verification: AGENTS.md (base commit) "Branching Model": "v2 is released; its public API is a compatibility contract for the 2.x line. Removals, renames, or any change to an existing API's signature or observable behaviour ... is a design decision a maintainer makes explicitly, and should generally be avoided."

out_params["_meta"] = out_meta
else:
out_params.pop("_meta", None)
# Leave `params` unset when empty: with `exclude_unset=True` an explicit
# None would serialize as `"params": null`, which JSON-RPC 2.0 forbids.
if out_params:
msg = JSONRPCRequest(jsonrpc="2.0", id=request_id, method=method, params=out_params)
else:
msg = JSONRPCRequest(jsonrpc="2.0", id=request_id, method=method)
# Surface a pre-existing cancellation while the request provably
# never started; past this point a cancelled write counts as issued.
await anyio.lowlevel.checkpoint_if_cancelled()
Expand Down
2 changes: 1 addition & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ def _capfire_isolated(capfire: CaptureLogfire) -> Iterator[CaptureLogfire]:
rest of the process. Without isolation, every subsequent test in the same
worker would emit real spans, and `send_raw_request` would inject a real
`traceparent` into outbound `_meta`, breaking the interaction-suite
snapshots that pin `_meta={}` under a no-op tracer.
snapshots that pin no `_meta` under a no-op tracer.

Setup points `_tracer` at the now-live provider so MCP spans record;
teardown replaces it with a `NoOpTracer`.
Expand Down
2 changes: 0 additions & 2 deletions tests/interaction/lowlevel/test_elicitation.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,6 @@ async def answer_form(context: ClientRequestContext, params: types.ElicitRequest
assert received == snapshot(
[
ElicitRequestFormParams(
_meta={},
message="Choose a username.",
requested_schema={
"type": "object",
Expand Down Expand Up @@ -229,7 +228,6 @@ async def answer_url(context: ClientRequestContext, params: types.ElicitRequestP
assert received == snapshot(
[
ElicitRequestURLParams(
_meta={},
message="Authorize access to your calendar.",
url="https://example.com/oauth/authorize",
elicitation_id="auth-001",
Expand Down
3 changes: 1 addition & 2 deletions tests/interaction/lowlevel/test_progress.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,7 @@ async def list_tools(

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

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

Expand Down
5 changes: 0 additions & 5 deletions tests/interaction/lowlevel/test_sampling.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,6 @@ async def sampling_callback(
assert received == snapshot(
[
CreateMessageRequestParams(
_meta={},
messages=[SamplingMessage(role="user", content=TextContent(text="Say hello."))],
max_tokens=100,
)
Expand Down Expand Up @@ -137,7 +136,6 @@ async def sampling_callback(
assert received == snapshot(
[
CreateMessageRequestParams(
_meta={},
messages=[SamplingMessage(role="user", content=TextContent(text="Pick a model."))],
model_preferences=ModelPreferences(
hints=[ModelHint(name="claude"), ModelHint(name="gpt")],
Expand Down Expand Up @@ -199,7 +197,6 @@ async def sampling_callback(
assert received == snapshot(
[
CreateMessageRequestParams(
_meta={},
messages=[SamplingMessage(role="user", content=ImageContent(data="aW1n", mime_type="image/png"))],
max_tokens=100,
)
Expand Down Expand Up @@ -481,7 +478,6 @@ async def sampling_callback(
assert received == snapshot(
[
CreateMessageRequestParams(
_meta={},
messages=[SamplingMessage(role="user", content=AudioContent(data="c25k", mime_type="audio/wav"))],
max_tokens=100,
)
Expand Down Expand Up @@ -574,7 +570,6 @@ async def sampling_callback(
assert received == snapshot(
[
CreateMessageRequestParams(
_meta={},
messages=[
SamplingMessage(
role="user",
Expand Down
1 change: 0 additions & 1 deletion tests/interaction/mcpserver/test_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,6 @@ async def answer_form(context: ClientRequestContext, params: ElicitRequestParams
assert received == snapshot(
[
ElicitRequestFormParams(
_meta={},
message="Where to?",
requested_schema={
"properties": {
Expand Down
4 changes: 2 additions & 2 deletions tests/server/test_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -537,8 +537,8 @@ async def test_runner_absent_wire_params_reaches_request_handler_as_defaults_mod
"""A request with no `params` member on the wire reaches the handler as
the params model with its defaults, never `None`.
The in-SDK client always attaches `_meta`, so a middleware rewrites
`ctx.params` to `None` to model what an external client sends.
The in-SDK client attaches `_meta` under a live tracer, so a middleware
rewrites `ctx.params` to `None` to pin the absent case regardless.
"""
seen: list[PaginatedRequestParams | None] = []

Expand Down
2 changes: 1 addition & 1 deletion tests/shared/test_dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ async def on_request(
ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
) -> dict[str, Any]:
# Strip `_meta` so JSON-RPC and direct dispatch record identically:
# the JSON-RPC outbound path always attaches `_meta` (otel injection).
# the JSON-RPC outbound path attaches `_meta` under a live tracer (otel injection).
recorded = {k: v for k, v in (params or {}).items() if k != "_meta"} if params is not None else None
recorder.requests.append((method, recorded))
recorder.contexts.append(ctx)
Expand Down
52 changes: 43 additions & 9 deletions tests/shared/test_jsonrpc_dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -1482,9 +1482,46 @@ async def on_notify(ctx: DCtx, method: str, params: Mapping[str, Any] | None) ->


@pytest.mark.anyio
async def test_send_raw_request_always_carries_meta_on_the_wire():
"""Outbound requests always carry `params._meta` (otel injection per SEP-414); caller-supplied
keys are preserved and the progress token is merged in."""
async def test_send_raw_request_omits_empty_meta_and_empty_params_on_the_wire():
"""A request with nothing to put in `_meta` carries none, and one left with no params carries no
`params` member. A scripted peer serializes as the transports do (`exclude_unset=True`): a
handler sees `None` for both an absent and a null `params`."""
Comment on lines +1486 to +1488

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 docstring claims the test verifies that "a handler sees None for both an absent and a null params," but the scripted peer never parses or asserts anything about params and no receiving handler runs in this test — it only checks the outbound wire shape of three requests. Drop the handler claim or add a handler-side assertion for the params-omitted request.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At tests/shared/test_jsonrpc_dispatcher.py, line 1486:

<comment>The docstring claims the test verifies that "a handler sees `None` for both an absent and a null `params`," but the scripted peer never parses or asserts anything about params and no receiving handler runs in this test — it only checks the outbound wire shape of three requests. Drop the handler claim or add a handler-side assertion for the params-omitted request.</comment>

<file context>
@@ -1482,9 +1482,46 @@ async def on_notify(ctx: DCtx, method: str, params: Mapping[str, Any] | None) ->
-    """Outbound requests always carry `params._meta` (otel injection per SEP-414); caller-supplied
-    keys are preserved and the progress token is merged in."""
+async def test_send_raw_request_omits_empty_meta_and_empty_params_on_the_wire():
+    """A request with nothing to put in `_meta` carries none, and one left with no params carries no
+    `params` member. A scripted peer serializes as the transports do (`exclude_unset=True`): a
+    handler sees `None` for both an absent and a null `params`."""
</file context>
Suggested change
"""A request with nothing to put in `_meta` carries none, and one left with no params carries no
`params` member. A scripted peer serializes as the transports do (`exclude_unset=True`): a
handler sees `None` for both an absent and a null `params`."""
"""A request with nothing to put in `_meta` carries none, and one left with no params carries no
`params` member. The scripted peer serializes as the transports do (`exclude_unset=True`)."""

c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32)
s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32)
client: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(s2c_recv, c2s_send)
on_request, on_notify = echo_handlers(Recorder())
wire: list[dict[str, Any]] = []

async def peer() -> None:
for _ in range(3):
out = await c2s_recv.receive()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 nit (optional): AGENTS.md asks that indefinite waits such as stream.receive() be wrapped in anyio.fail_after(5). In tests/shared/test_jsonrpc_dispatcher.py the new scripted peer() task loops on await c2s_recv.receive() with no fail_after of its own; only the three send_raw_request calls in the test body are wrapped. Fix: put the peer loop's receive under with anyio.fail_after(5): (or wrap the whole for body) so every indefinite wait in the test is bounded directly, matching the instruction's pattern.

Why this was flagged

Nothing hangs today: the peer task lives in the same task group as the wrapped send_raw_request calls, so if the client never gets a response the body's fail_after(5) raises and the task group cancels the peer. The instruction's bar is per-wait, though, and a future edit that moves the peer out of this task group or extends it past tg.cancel_scope.cancel() would turn its unbounded receive into a test hang. Small consequence; a one-line wrap satisfies the rule.

Verification: AGENTS.md (base 19e4f2a) Testing section: "Wrap indefinite waits (event.wait(), stream.receive()) in anyio.fail_after(5) to prevent hangs". The diff adds, at /home/claude/python-sdk/tests/shared/test_jsonrpc_dispatcher.py:1497 inside the new peer() task of test_send_raw_request_omits_empty_meta_and_empty_params_on_the_wire, out = await c2s_recv.receive() with no fail_after of its own; the only with anyio.fail_after(5): in the test wraps the three send_raw_request calls in the body (lines 1508-1511).

assert isinstance(out, SessionMessage)
assert isinstance(out.message, JSONRPCRequest)
wire.append(json.loads(out.message.model_dump_json(by_alias=True, exclude_unset=True)))
await s2c_send.send(SessionMessage(message=JSONRPCResponse(jsonrpc="2.0", id=out.message.id, result={})))

try:
async with anyio.create_task_group() as tg:
await tg.start(client.run, on_request, on_notify)
tg.start_soon(peer)
with anyio.fail_after(5):
await client.send_raw_request("ping", None)
await client.send_raw_request("tools/list", {"_meta": {}})
await client.send_raw_request("tools/call", {"name": "t"})
tg.cancel_scope.cancel()
finally:
for s in (c2s_send, c2s_recv, s2c_send, s2c_recv):
s.close()
assert wire == [
{"jsonrpc": "2.0", "id": 1, "method": "ping"},
{"jsonrpc": "2.0", "id": 2, "method": "tools/list"},
{"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "t"}},
]


@pytest.mark.anyio
async def test_send_raw_request_merges_progress_token_into_caller_meta():
"""Caller-supplied `_meta` keys are preserved and the progress token is merged in."""
seen: list[Mapping[str, Any] | None] = []

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


@pytest.mark.anyio
Expand Down
Loading