diff --git a/src/mcp/shared/jsonrpc_dispatcher.py b/src/mcp/shared/jsonrpc_dispatcher.py index 87bdf31ceb..71da3104cc 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,13 @@ 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. Omit `_meta` when it is still + # empty after injection: some servers reject `_meta: {}`. inject_trace_context(out_meta) + if out_meta: + out_params["_meta"] = out_meta + else: + out_params.pop("_meta", None) 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..ecd7a361cb 100644 --- a/tests/shared/test_jsonrpc_dispatcher.py +++ b/tests/shared/test_jsonrpc_dispatcher.py @@ -109,6 +109,42 @@ async def call(method: str) -> None: assert results == {"first": {"m": "first"}, "second": {"m": "second"}} +@pytest.mark.anyio +async def test_send_raw_request_omits_empty_meta(): + """Strict servers reject `_meta: {}`. Leave the field off when it is empty.""" + seen: dict[str, Mapping[str, Any] | None] = {} + + async def server_on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]: + seen["params"] = params + return {"ok": True} + + async with running_pair(jsonrpc_pair, server_on_request=server_on_request) as (client, *_): + await client.send_raw_request("ping", None) + + params = seen["params"] + if params is not None: + assert "_meta" not in params + + +@pytest.mark.anyio +async def test_send_raw_request_keeps_progress_token_meta(): + seen: dict[str, Mapping[str, Any] | None] = {} + + async def server_on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]: + seen["params"] = params + return {"ok": True} + + async def on_progress(*_args: Any, **_kwargs: Any) -> None: + return None + + async with running_pair(jsonrpc_pair, server_on_request=server_on_request) as (client, *_): + await client.send_raw_request("ping", None, {"on_progress": on_progress}) + + params = seen["params"] + assert params is not None + assert "progressToken" in params["_meta"] + + @pytest.mark.anyio async def test_handler_raising_exception_sends_code_zero_with_str_message(): """Matches the existing server's `_handle_request`: code=0, message=str(e)."""