@@ -48,7 +48,7 @@ <h2 class="section-title" id="header-classes">Classes</h2>
4848< dl >
4949< dt id ="adcp.client.ADCPClient "> < code class ="flex name class ">
5050< span > class < span class ="ident "> ADCPClient</ span > </ span >
51- < span > (</ span > < span > agent_config: AgentConfig,< br > webhook_url_template: str | None = None,< br > webhook_secret: str | None = None,< br > on_activity: Callable[[Activity], None] | None = None,< br > webhook_timestamp_tolerance: int = 300,< br > capabilities_ttl: float = 3600.0,< br > validate_features: bool = False,< br > strict_idempotency: bool = False)</ span >
51+ < span > (</ span > < span > agent_config: AgentConfig,< br > webhook_url_template: str | None = None,< br > webhook_secret: str | None = None,< br > on_activity: Callable[[Activity], None] | None = None,< br > webhook_timestamp_tolerance: int = 300,< br > capabilities_ttl: float = 3600.0,< br > validate_features: bool = False,< br > strict_idempotency: bool = False, < br > signing: SigningConfig | None = None )</ span >
5252</ code > </ dt >
5353< dd >
5454< details class ="source ">
@@ -68,6 +68,7 @@ <h2 class="section-title" id="header-classes">Classes</h2>
6868 capabilities_ttl: float = 3600.0,
6969 validate_features: bool = False,
7070 strict_idempotency: bool = False,
71+ signing: SigningConfig | None = None,
7172 ):
7273 """
7374 Initialize ADCP client for a single agent.
@@ -91,6 +92,17 @@ <h2 class="section-title" id="header-classes">Classes</h2>
9192 ``IdempotencyUnsupportedError`` if the declaration is missing —
9293 sellers that don't declare it provide no retry-safety guarantee
9394 per AdCP #2315. Defaults to False for backward compatibility.
95+ signing: Optional RFC 9421 request-signing config. When provided,
96+ the client automatically attaches ``Signature`` /
97+ ``Signature-Input`` / ``Content-Digest`` headers to operations
98+ the seller's ``request_signing`` capability lists in
99+ ``required_for``, ``warn_for``, or ``supported_for``. The
100+ seller's ``covers_content_digest`` policy determines whether
101+ the body is bound to the signature. Generate a key with
102+ ``adcp-keygen`` and publish the public JWK at your
103+ ``jwks_uri``. Supported on both A2A and MCP
104+ (``mcp_transport="streamable_http"``); SSE-transport MCP
105+ logs a warning and falls through unsigned.
94106 """
95107 self.agent_config = agent_config
96108 self.webhook_url_template = webhook_url_template
@@ -100,6 +112,7 @@ <h2 class="section-title" id="header-classes">Classes</h2>
100112 self.capabilities_ttl = capabilities_ttl
101113 self.validate_features = validate_features
102114 self.strict_idempotency = strict_idempotency
115+ self.signing = signing
103116
104117 # Capabilities cache
105118 self._capabilities: GetAdcpCapabilitiesResponse | None = None
@@ -124,6 +137,8 @@ <h2 class="section-title" id="header-classes">Classes</h2>
124137 self.adapter.idempotency_client_token = self._idempotency_client_token
125138 if strict_idempotency:
126139 self.adapter.idempotency_capability_check = self._ensure_idempotency_capability
140+ if signing is not None:
141+ self.adapter.signing_request_hook = self._sign_outgoing_request
127142
128143 # Initialize simple API accessor (lazy import to avoid circular dependency)
129144 from adcp.simple import SimpleAPI
@@ -167,6 +182,89 @@ <h2 class="section-title" id="header-classes">Classes</h2>
167182 self._idempotency_capability_verified = False
168183 raise
169184
185+ async def _sign_outgoing_request(self, request: httpx.Request) -> None:
186+ """httpx request event hook that attaches RFC 9421 signature headers.
187+
188+ Installed on the protocol adapter's httpx client when a
189+ ``SigningConfig`` was passed to ``ADCPClient``. Consults the
190+ seller's advertised ``request_signing`` capability and signs only
191+ the operations the seller listed in ``required_for``, ``warn_for``,
192+ or ``supported_for`` — other requests (including the agent-card
193+ fetch and ``get_adcp_capabilities`` itself) pass through unsigned.
194+ The ``covers_content_digest`` tri-state determines whether the
195+ body is bound to the signature.
196+ """
197+ if self.signing is None:
198+ return
199+ operation = _signing_current_operation.get()
200+ # Unset ContextVar → out-of-band call (agent-card fetch, session
201+ # initialize, etc). Skip without fetching capabilities.
202+ #
203+ # get_adcp_capabilities → bootstrap carve-out: signing it would
204+ # require capabilities we don't have yet, and if a pathological
205+ # seller listed this op in its own required_for we'd recurse.
206+ # Keep this check narrow — only operations strictly required to
207+ # *obtain* capabilities belong here. Today that's just
208+ # get_adcp_capabilities. A future adapter that adds another
209+ # capabilities-precondition op MUST extend this guard.
210+ if operation is None or operation == "get_adcp_capabilities":
211+ return
212+
213+ caps = await self.fetch_capabilities()
214+ req_signing = getattr(caps, "request_signing", None)
215+
216+ # Detect and surface a malformed seller config: supported=False is
217+ # "signatures are ignored", but populating required_for alongside
218+ # it is contradictory. The classifier correctly skips (matches
219+ # verifier behavior) but the silent downgrade hides a config bug
220+ # that will bite pilots.
221+ if (
222+ req_signing is not None
223+ and not req_signing.supported
224+ and (req_signing.required_for or req_signing.warn_for)
225+ ):
226+ logger.warning(
227+ "Seller %s advertises request_signing.supported=false but "
228+ "populates required_for/warn_for — treating as unsupported "
229+ "per spec. Verify the seller's capability advertisement.",
230+ self.agent_config.id,
231+ )
232+
233+ decision = operation_needs_signing(req_signing, operation)
234+ if decision == "skip":
235+ return
236+
237+ covers_policy: str | None = None
238+ if req_signing is not None and req_signing.covers_content_digest is not None:
239+ covers_policy = req_signing.covers_content_digest.value
240+ if covers_policy == "forbidden":
241+ cover_digest = False
242+ elif covers_policy == "required":
243+ cover_digest = True
244+ else:
245+ # "either" or absent — signer's choice; default stricter.
246+ cover_digest = True
247+
248+ body = request.content
249+ signed = sign_request(
250+ method=request.method,
251+ url=str(request.url),
252+ headers=dict(request.headers),
253+ body=body,
254+ private_key=self.signing.private_key,
255+ key_id=self.signing.key_id,
256+ alg=self.signing.alg,
257+ cover_content_digest=cover_digest,
258+ tag=self.signing.tag,
259+ )
260+ # pop-then-set ensures our signed values are authoritative even if
261+ # another hook or earlier layer added a same-named header. httpx
262+ # headers are a case-insensitive MultiDict, so a naive assignment
263+ # could leave a duplicate value in a different case.
264+ for header_name, header_value in signed.as_dict().items():
265+ request.headers.pop(header_name, None)
266+ request.headers[header_name] = header_value
267+
170268 def get_webhook_url(self, task_type: str, operation_id: str) -> str:
171269 """Generate webhook URL for a task."""
172270 if not self.webhook_url_template:
@@ -3475,6 +3573,18 @@ <h2 id="args">Args</h2>
34753573< code > IdempotencyUnsupportedError</ code > if the declaration is missing —
34763574sellers that don't declare it provide no retry-safety guarantee
34773575per AdCP #2315. Defaults to False for backward compatibility.</ dd >
3576+ < dt > < strong > < code > signing</ code > </ strong > </ dt >
3577+ < dd > Optional RFC 9421 request-signing config. When provided,
3578+ the client automatically attaches < code > Signature</ code > /
3579+ < code > Signature-Input</ code > / < code > Content-Digest</ code > headers to operations
3580+ the seller's < code > request_signing</ code > capability lists in
3581+ < code > required_for</ code > , < code > warn_for</ code > , or < code > supported_for</ code > . The
3582+ seller's < code > covers_content_digest</ code > policy determines whether
3583+ the body is bound to the signature. Generate a key with
3584+ < code > adcp-keygen</ code > and publish the public JWK at your
3585+ < code > jwks_uri</ code > . Supported on both A2A and MCP
3586+ (< code > mcp_transport="streamable_http"</ code > ); SSE-transport MCP
3587+ logs a warning and falls through unsigned.</ dd >
34783588</ dl > </ div >
34793589< h3 > Instance variables</ h3 >
34803590< dl >
@@ -7613,7 +7723,7 @@ <h2 id="returns">Returns</h2>
76137723</ dd >
76147724< dt id ="adcp.client.ADCPMultiAgentClient "> < code class ="flex name class ">
76157725< span > class < span class ="ident "> ADCPMultiAgentClient</ span > </ span >
7616- < span > (</ span > < span > agents: list[AgentConfig],< br > webhook_url_template: str | None = None,< br > webhook_secret: str | None = None,< br > on_activity: Callable[[Activity], None] | None = None,< br > handlers: dict[str, Callable[..., Any]] | None = None)</ span >
7726+ < span > (</ span > < span > agents: list[AgentConfig],< br > webhook_url_template: str | None = None,< br > webhook_secret: str | None = None,< br > on_activity: Callable[[Activity], None] | None = None,< br > handlers: dict[str, Callable[..., Any]] | None = None, < br > signing: SigningConfig | None = None )</ span >
76177727</ code > </ dt >
76187728< dd >
76197729< details class ="source ">
@@ -7630,6 +7740,7 @@ <h2 id="returns">Returns</h2>
76307740 webhook_secret: str | None = None,
76317741 on_activity: Callable[[Activity], None] | None = None,
76327742 handlers: dict[str, Callable[..., Any]] | None = None,
7743+ signing: SigningConfig | None = None,
76337744 ):
76347745 """
76357746 Initialize multi-agent client.
@@ -7640,13 +7751,17 @@ <h2 id="returns">Returns</h2>
76407751 webhook_secret: Secret for webhook verification
76417752 on_activity: Callback for activity events
76427753 handlers: Task completion handlers
7754+ signing: Optional RFC 9421 signing config forwarded to every
7755+ per-agent ADCPClient. The same identity signs traffic to
7756+ all agents. See ADCPClient.__init__ for details.
76437757 """
76447758 self.agents = {
76457759 agent.id: ADCPClient(
76467760 agent,
76477761 webhook_url_template=webhook_url_template,
76487762 webhook_secret=webhook_secret,
76497763 on_activity=on_activity,
7764+ signing=signing,
76507765 )
76517766 for agent in agents
76527767 }
@@ -7727,6 +7842,10 @@ <h2 id="args">Args</h2>
77277842< dd > Callback for activity events</ dd >
77287843< dt > < strong > < code > handlers</ code > </ strong > </ dt >
77297844< dd > Task completion handlers</ dd >
7845+ < dt > < strong > < code > signing</ code > </ strong > </ dt >
7846+ < dd > Optional RFC 9421 signing config forwarded to every
7847+ per-agent ADCPClient. The same identity signs traffic to
7848+ all agents. See ADCPClient.< strong > init</ strong > for details.</ dd >
77307849</ dl > </ div >
77317850< h3 > Static methods</ h3 >
77327851< dl >
0 commit comments