Omit an empty _meta and empty params from outbound requests - #3628
Conversation
The JSON-RPC dispatcher attached `"_meta": {}` to every request it sent.
On a 2025-11-25 or earlier connection `initialize` therefore carried an
empty `_meta`, and requests with no params, such as `ping` and
`tools/list`, went out as `"params": {"_meta": {}}`. v1 sent neither, and
some servers reject the empty object.
`_meta` is now attached only when it has content once trace context has
been injected: a progress token, caller-supplied keys, a `traceparent`,
or the per-request fields of a 2026-07-28 connection. A request left with
no params is sent without a `params` member. Requests on a 2026-07-28
connection are unchanged, since their `_meta` is never empty.
Fixes #3473
📚 Documentation preview
|
There was a problem hiding this comment.
2 issues found across 10 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. 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. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="docs/migration.md">
<violation number="1" location="docs/migration.md:2664">
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.</violation>
</file>
<file name="tests/shared/test_jsonrpc_dispatcher.py">
<violation number="1" location="tests/shared/test_jsonrpc_dispatcher.py:1486">
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.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| ``` | ||
|
|
||
| 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. |
There was a problem hiding this comment.
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>
| 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. |
| """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`.""" |
There was a problem hiding this comment.
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>
| """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`).""" |
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
Beyond the inline notes, I also checked: the out_params.pop("_meta", None) branch for a caller-supplied empty _meta is exercised by the new scripted-peer test's tools/list case; the two migration.md anchors that pointed at the removed heading are both rewritten and nothing else in docs/ links to the old anchor or describes the empty envelope; and 2026-07-28 connections are unaffected since dump_params/Connection always populate _meta before send_raw_request sees it.
Extended reasoning...
The change moves the _meta attach in src/mcp/shared/jsonrpc_dispatcher.py after trace injection so empty _meta and empty params are omitted from outbound JSON-RPC requests, with migration.md and snapshot tests updated to match. It touches no auth, crypto, or data-exposure surface. The two inline findings are nits about 2.x behaviour-change policy and a test wait without its own fail_after; the ruled-out items above were verified from the diff, the new test, and a docs grep.
| # 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: |
There was a problem hiding this comment.
🟡 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."
|
|
||
| async def peer() -> None: | ||
| for _ in range(3): | ||
| out = await c2s_recv.receive() |
There was a problem hiding this comment.
🟡 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).
Fixes #3473.
On a connection that uses the
initializehandshake (2025-11-25 and earlier), v2 put"_meta": {}in the params of every request it sent,initializeincluded. An empty_metais valid per the spec, but v1 never sent one and some servers reject it, which made them unreachable from a v2 client.What was wrong
JSONRPCDispatcher.send_raw_requestattached the_metadict before trace context was injected into it, so it went out even when nothing was ever put in it.pingandtools/list, went out as"params": {"_meta": {}}.What changes on the wire
_metais now sent only when it has content, and a request left with no params is sent without aparamsmember.Before:
After:
paramsis optional in the schema for every request that can end up with none (ping, the list requests,roots/list), and JSON-RPC 2.0 allows omitting it.What users will notice
_metaunless there is a progress token, caller-supplied meta, or trace context to carryparamsmember onpingand on list requests without a cursorping,roots/list, sampling, elicitation) change the same way.ctx.metaasNonerather than{}ctx.paramsasNonefor a request with no paramsparams.metaasNonerather than{}_metapassed by the caller (for examplesend_ping(meta={})) is not sent, and neither is an empty params object.docs/migration.mdis corrected:_metaenvelope, or that nothing restores the v1 wire shape#opentelemetry-is-on-by-defaultWhat is unchanged
_metaalways carries the protocol version, client info and capabilities, so it is never empty; captured output is byte-identical before and after.traceparentis still injected into_metaon every outbound request, including ones with no other params._metakeys are sent as before.How it was checked
tests/shared/test_jsonrpc_dispatcher.pyreads requests as the transports serialize them and covers three cases: no params, a caller-supplied empty_meta, and params without meta. It fails onmainand passes with the change.traceparentreaching the server when a tracer is configured._metaare updated:tests/interaction/lowlevel/test_sampling.py,tests/interaction/lowlevel/test_elicitation.pyandtests/interaction/mcpserver/test_context.pytest_no_progress_callback_means_no_tokenintests/interaction/lowlevel/test_progress.py, which assumedctx.metais neverNone./scripts/testpasses with 100% coverage; ruff and pyright are clean.AI Disclaimer