Skip to content

MCP transport: outbound request-signing hook is a no-op (current_operation ContextVar not propagated to the task that sends the request) #1017

Description

@KonstantinMirin

Summary

On adcp==6.6.0, ADCPClient/ADCPMultiAgentClient constructed with signing=SigningConfig(...) never actually sign outbound requests over the MCP transport, even though the signing hook fires and no error is raised. Reproduced end-to-end against a local FastMCP server with a header spy: the signing hook fired on every call, current_operation read None every time, no get_adcp_capabilities fetch occurred, and no Signature header was ever sent on the wire.

Root cause

adcp/protocols/mcp.py:560 sets the current_operation ContextVar in the caller's asyncio task, wrapped around the session.call_tool(...) invocation.

mcp/client/streamable_http.py:566 (the mcp package's own transport) dispatches the actual signed POST via tg.start_soon(handle_request_async) from post_writer's task. Since contextvars.Context is captured at task-spawn time (i.e., when post_writer's task group started, at connect time) and NOT inherited live from the calling task at request time, the spawned task's copy of current_operation is whatever it was when the connection was established — effectively always None for the first (and typically only) request.

ADCPClient._sign_outgoing_request (adcp/client.py:972) reads current_operation from the ContextVar, sees None, and returns early without signing — silently, no exception, no log.

The SSE transport branch (adcp/protocols/mcp.py:383-388) is aware of this class of problem and explicitly warns-and-skips rather than attempting to sign. The streamable-HTTP transport (the default) has no such guard and just silently no-ops.

Note: the A2A transport is unaffected — its signing hook runs in the caller's own task (adcp/protocols/a2a.py:262), so current_operation propagates correctly there. This appears to be specific to how the mcp package's streamable-HTTP client schedules its send.

Suggested fix direction

Capture the operation at message-send time (inside the task that actually performs session.call_tool/the HTTP POST) rather than via a ContextVar set in a different task, OR thread the operation through ClientMessageMetadata (or an equivalent explicit parameter) rather than relying on ambient context propagated across an anyio task-group boundary.

Impact

Any caller relying on ADCPClient(signing=...) over MCP transport gets zero signing with no error surfaced — a silent security gap for anyone who believes they've turned on RFC 9421 request signing for MCP-transport outbound calls.

Repro sketch

  • Stand up a minimal FastMCP server that echoes the received headers.
  • Construct ADCPClient(agent_url=..., protocol="mcp", signing=SigningConfig(...)).
  • Call any tool via the client 5 times.
  • Observe: signing hook invoked 5/5 times, current_operation is None on all 5, zero Signature header on the wire, zero get_adcp_capabilities fetch (which _sign_outgoing_request would trigger if it proceeded past the operation is None check).

Happy to share the exact repro script if useful — surfaced this while implementing outbound request signing for a downstream seller agent (prebid/salesagent, RFC 9421 adoption epic).

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions