From ff7b5c8b9332f47a1525e96ab386c6715b6a8bf3 Mon Sep 17 00:00:00 2001 From: Konstantin Khlopkov Date: Tue, 8 Sep 2026 10:19:51 +0300 Subject: [PATCH] fix(client): omit empty _meta on outbound requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strict JSON-RPC servers reject an empty _meta object as invalid params: Meta's hosted Ads MCP server returns -32602 and HTTP 400 on initialize, making it unreachable from any client built on this SDK. The dispatcher now attaches _meta only when it carries something — a progress token, caller-supplied keys, or injected W3C trace context — and drops a caller-supplied empty _meta instead of forwarding it. --- src/mcp/shared/jsonrpc_dispatcher.py | 11 +++++++++-- tests/shared/test_jsonrpc_dispatcher.py | 14 +++++++++----- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/mcp/shared/jsonrpc_dispatcher.py b/src/mcp/shared/jsonrpc_dispatcher.py index 87bdf31ceb..68f3d7817a 100644 --- a/src/mcp/shared/jsonrpc_dispatcher.py +++ b/src/mcp/shared/jsonrpc_dispatcher.py @@ -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. @@ -386,8 +385,16 @@ 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 into `_meta`; the field only + # goes on the wire when it carries something (progress token, + # caller keys, or trace context) — an empty `_meta:{}` is + # rejected as invalid params by strict servers (e.g. Meta's + # hosted Ads MCP). inject_trace_context(out_meta) + if out_meta: + out_params["_meta"] = out_meta + elif "_meta" in out_params: + del out_params["_meta"] msg = JSONRPCRequest(jsonrpc="2.0", id=request_id, method=method, params=out_params) # Surface a pre-existing cancellation while the request provably # never started; past this point a cancelled write counts as issued. diff --git a/tests/shared/test_jsonrpc_dispatcher.py b/tests/shared/test_jsonrpc_dispatcher.py index 9bee8b2c3b..2728edabd9 100644 --- a/tests/shared/test_jsonrpc_dispatcher.py +++ b/tests/shared/test_jsonrpc_dispatcher.py @@ -1501,9 +1501,10 @@ 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_carries_meta_only_when_non_empty(): + """Outbound requests omit `params._meta` when it would be empty (strict servers + reject `_meta:{}` as invalid params); caller-supplied 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]: @@ -1518,13 +1519,16 @@ async def noop_progress(progress: float, total: float | None, message: str | Non with anyio.fail_after(5): await client.send_raw_request("a", None) await client.send_raw_request("b", {"x": 1, "_meta": {"k": "v"}}, opts) + await client.send_raw_request("c", {"x": 1, "_meta": {}}) # `_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 + # No progress token, no caller keys, no-op tracer in tests: `_meta` is absent entirely. + assert seen[0] is not None and "_meta" not in seen[0] 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" + # A caller-supplied empty `_meta` must not go on the wire either. + assert seen[2] is not None and "_meta" not in seen[2] @pytest.mark.anyio