Repository navigation
Client: expose an outbound request seam (the send_raw_request TODO) so observers can see a request before it is written #3715
Description
Summary
There is no supported way for code outside the SDK to observe or wrap an outgoing client request at the point where it is built and written. The dispatcher already says as much in a TODO:
# mcp/shared/jsonrpc_dispatcher.py, JSONRPCDispatcher.send_raw_request
# TODO(maxisbey): move the otel span + inject into an outbound
# middleware once that seam exists; the dispatcher should not own otel.
with otel_span(span_name, kind=SpanKind.CLIENT, ...):
inject_trace_context(out_meta)
msg = JSONRPCRequest(jsonrpc="2.0", id=request_id, method=method, params=out_params)
...
await self._write(msg, plan.metadata)This issue asks for that seam on the client side, as the counterpart of the existing lowlevel Server.middleware. It is related to #1233 (middleware support), which so far discusses the server side only.
Version checked: mcp 2.2.0.
Use case
Instrumentation and policy tooling that needs to act on a tools/call before it leaves the process, for example:
- emitting a "tool call started" event with the tool name and arguments to an observability pipeline or a policy engine, so a decision can be made while the request is still local;
- attaching or rewriting
_meta(correlation ids, session ids) in addition to the trace context; - refusing a request locally.
The event has to be tied to the same trace context that goes on the wire, so it must be emitted from inside the span the dispatcher opens (MCP send tools/call <tool>), where inject_trace_context runs. That is the only place where the method, the tool name, the arguments and the outgoing traceparent are all known together.
What we do today, and why it is fragile
Because there is no seam, the only option is to wrap the module-level function mcp.shared.jsonrpc_dispatcher.inject_trace_context, which is called inside that span, and use it as the "request is about to be written" signal. That depends on:
- a private import name in
jsonrpc_dispatcher(a rename or a move into_otelonly would silently stop it working); inject_trace_contextbeing called exactly once per request, inside the span, before the write;- the arguments being passed through a side channel (a contextvar set by a wrapper around
ClientSession.send_request), because the hook only receives the_metadict.
Wrapping ClientSession.send_request instead does not work for this purpose: it runs outside the dispatcher's span, so anything emitted there carries a different span id than the traceparent that is sent.
Proposal
Something minimal and symmetrical to the server middleware, for example a keyword-only middleware on the client session/dispatcher, called once per outgoing request with an object exposing at least:
method,params(read-only view of what will be sent) and a mutablemeta(the_metathat will be sent);- a way to call the next stage.
and run inside the OpenTelemetry span, so that observers see the same span context that inject_trace_context uses. The built-in OTel injection could then become the first middleware, which resolves the TODO.
Open questions:
- Is this the seam you had in mind in the TODO, or is something different planned for the client?
- Should it also cover notifications, or requests only?
- Position relative to the OTel span: inside it (needed for the use case above), or outside it?
- Would you prefer this as part of Middleware Support in MCP #1233, or as a separate piece of work?
Alternatives considered
- Wrapping
inject_trace_context(what we do now): works, but depends on private structure as above. - Wrapping
ClientSession.send_request: wrong span, see above. - Using the HTTP client hooks (httpx event hooks): they see the already-encoded HTTP request, not the method and arguments, and only exist on HTTP transports, not stdio.
Happy to help with a PR if the shape is agreed.
I would like to fix this myself once the shape is agreed (and a maintainer is fine with that). AI disclosure: this issue was drafted with AI assistance (Claude Code); I reviewed the text and checked the quoted code against mcp 2.2.0.
Activity
sattyamjjain commentedon Oct 17, 2026
On your question about notifications: at 9f66f37,
notify()in jsonrpc_dispatcher.py (L464-490) builds the JSONRPCNotification with no span and noinject_trace_context. Onlysend_raw_requestgets the "MCP send ..." span and the inject (L387-393), next to the TODO at L384-385. So notifications carry no trace context today.https://github.com/modelcontextprotocol/python-sdk/blob/9f66f376d486e84e9810b368cf8be9d5908661ab/src/mcp/shared/jsonrpc_dispatcher.py
Untested workaround until the seam exists, without importing the private helper:
inject_trace_contextis justopentelemetry.propagate.inject(meta)(_otel.py), so a custom propagator registered withset_global_textmap(composite with TraceContext) runs inside that span for every outgoing request. You get the method and tool name from the span, not the arguments. Caveat: a global propagator also fires for every other propagation in the process, for example, httpx instrumentation.rafaelsilva29 commentedon Oct 20, 2026
Thanks @sattyamjjain, that answers the notification question.
Here is a concrete use case for the seam, since we ended up depending on the private hook.
We instrument the MCP client in the Observe SDK (agntcy/observe#143). For a
tools/callwe need to publish a realtime event with the tool arguments before the request is written, and then put that event's id into the outgoing_meta, so a downstream consumer can fetch exactly that event. Two constraints decide where this can run:MCP sendspan, so that its span is the one thetraceparenton the wire carries._metaafterwards, before the request is built.Today the only point that satisfies both is wrapping
mcp.shared.jsonrpc_dispatcher.inject_trace_context. It runs inside the span and receivesout_meta, whichsend_raw_requestuses to build theJSONRPCRequestright after it returns.That works with mcp 2.2.0, but it relies on two undocumented properties: that
inject_trace_contextis looked up as a module global (so it can be wrapped), and thatout_metais still the dict the request is built from when it returns. If either changes, the key silently stops reaching the wire, and nothing in the SDK or the tests of the library would tell us.The propagator workaround would not cover this. It sees the span (method and tool name) but not the arguments, and it cannot add a key derived from an event that is emitted after the span started. It is also process-global, as noted.
What would cover it is an outbound hook, called inside the span with the method, the params and a mutable
_meta, before the message is built. That is the middleware the TODO describes. It would be most useful if it also covered notifications, which today get no span and no injection.