Skip to content

Client: expose an outbound request seam (the send_raw_request TODO) so observers can see a request before it is written #3715

Open

Description

@rafaelsilva29

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 _otel only would silently stop it working);
  • inject_trace_context being 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 _meta dict.

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 mutable meta (the _meta that 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:

  1. Is this the seam you had in mind in the TODO, or is something different planned for the client?
  2. Should it also cover notifications, or requests only?
  3. Position relative to the OTel span: inside it (needed for the use case above), or outside it?
  4. 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

  1. added
    enhancementRequest for a new feature that's not currently supported
    on Oct 15, 2026
  2. sattyamjjain commented on Oct 17, 2026

    @sattyamjjain

    On your question about notifications: at 9f66f37, notify() in jsonrpc_dispatcher.py (L464-490) builds the JSONRPCNotification with no span and no inject_trace_context. Only send_raw_request gets 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_context is just opentelemetry.propagate.inject(meta) (_otel.py), so a custom propagator registered with set_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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementRequest for a new feature that's not currently supported

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions