Skip to content

Omit an empty _meta and empty params from outbound requests - #3628

Merged
maxisbey merged 1 commit into
mainfrom
3473-omit-empty-meta
Oct 2, 2026
Merged

maxisbey merged 1 commit into
mainfrom
3473-omit-empty-meta

Conversation

@maxisbey

@maxisbey maxisbey commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

Fixes #3473.

On a connection that uses the initialize handshake (2025-11-25 and earlier), v2 put "_meta": {} in the params of every request it sent, initialize included. An empty _meta is 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_request attached the _meta dict before trace context was injected into it, so it went out even when nothing was ever put in it.
  • That is the default case: no OpenTelemetry SDK configured, no progress callback, no caller-supplied meta.
  • Requests with no params of their own, such as ping and tools/list, went out as "params": {"_meta": {}}.

What changes on the wire

_meta is now sent only when it has content, and a request left with no params is sent without a params member.

Before:

{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"mcp","version":"0.1.0"},"_meta":{}}}
{"jsonrpc":"2.0","id":2,"method":"ping","params":{"_meta":{}}}

After:

{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"mcp","version":"0.1.0"}}}
{"jsonrpc":"2.0","id":2,"method":"ping"}
  • This is the shape v1 sent.
  • params is 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

  • Requests from a client on a 2025-11-25 or earlier connection:
    • no _meta unless there is a progress token, caller-supplied meta, or trace context to carry
    • no params member on ping and on list requests without a cursor
  • Server-initiated requests (ping, roots/list, sampling, elicitation) change the same way.
  • When both ends run this SDK and the sender supplied no meta, the receiving side sees what it already sees from any other client:
    • a server handler gets ctx.meta as None rather than {}
    • server middleware gets ctx.params as None for a request with no params
    • sampling and elicitation callbacks get params.meta as None rather than {}
  • An empty _meta passed by the caller (for example send_ping(meta={})) is not sent, and neither is an empty params object.
  • Tests or tooling that assert on raw outbound request bytes need updating.
  • docs/migration.md is corrected:
    • it no longer says every outbound request carries a _meta envelope, or that nothing restores the v1 wire shape
    • the section is now titled "OpenTelemetry is on by default", so its anchor becomes #opentelemetry-is-on-by-default

What is unchanged

  • Requests on a 2026-07-28 connection. Their _meta always carries the protocol version, client info and capabilities, so it is never empty; captured output is byte-identical before and after.
  • Tracing. With an OpenTelemetry tracer provider configured, traceparent is still injected into _meta on every outbound request, including ones with no other params.
  • Progress tokens and caller-supplied _meta keys are sent as before.
  • No new option or parameter.

How it was checked

  • A new test in tests/shared/test_jsonrpc_dispatcher.py reads requests as the transports serialize them and covers three cases: no params, a caller-supplied empty _meta, and params without meta. It fails on main and passes with the change.
  • The existing OpenTelemetry tests still show a traceparent reaching the server when a tracer is configured.
  • Tests that pinned the empty _meta are updated:
    • eight snapshot lines in tests/interaction/lowlevel/test_sampling.py, tests/interaction/lowlevel/test_elicitation.py and tests/interaction/mcpserver/test_context.py
    • test_no_progress_callback_means_no_token in tests/interaction/lowlevel/test_progress.py, which assumed ctx.meta is never None
  • ./scripts/test passes with 100% coverage; ruff and pyright are clean.

AI Disclaimer

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
@github-actions

github-actions Bot commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

📚 Documentation preview

Preview https://pr-3628.mcp-python-docs.pages.dev
Deployment https://1b0e3462.mcp-python-docs.pages.dev
Commit 97c0602
Triggered by @maxisbey
Updated 2026-10-02 13:16:07 UTC

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Comment thread docs/migration.md
```

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.

Comment on lines +1486 to +1488
"""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`."""

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

@claude claude Bot left a comment

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.

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:

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


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

@maxisbey
maxisbey merged commit 0b2fd3e into main Oct 2, 2026
44 checks passed
@maxisbey
maxisbey deleted the 3473-omit-empty-meta branch October 2, 2026 13:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Client sends empty _meta:{} on every request; strict servers (Meta Ads MCP) reject with HTTP 400

1 participant