Skip to content

Commit 16e1003

Browse files
committed
deploy: 551aaf8
1 parent 5e2b908 commit 16e1003

10 files changed

Lines changed: 964 additions & 24 deletions

File tree

client.html

Lines changed: 121 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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
&#34;&#34;&#34;
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&#39;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&#39;s ``request_signing`` capability lists in
99+
``required_for``, ``warn_for``, or ``supported_for``. The
100+
seller&#39;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=&#34;streamable_http&#34;``); SSE-transport MCP
105+
logs a warning and falls through unsigned.
94106
&#34;&#34;&#34;
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) -&gt; None:
186+
&#34;&#34;&#34;httpx request event hook that attaches RFC 9421 signature headers.
187+
188+
Installed on the protocol adapter&#39;s httpx client when a
189+
``SigningConfig`` was passed to ``ADCPClient``. Consults the
190+
seller&#39;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+
&#34;&#34;&#34;
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&#39;t have yet, and if a pathological
205+
# seller listed this op in its own required_for we&#39;d recurse.
206+
# Keep this check narrow — only operations strictly required to
207+
# *obtain* capabilities belong here. Today that&#39;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 == &#34;get_adcp_capabilities&#34;:
211+
return
212+
213+
caps = await self.fetch_capabilities()
214+
req_signing = getattr(caps, &#34;request_signing&#34;, None)
215+
216+
# Detect and surface a malformed seller config: supported=False is
217+
# &#34;signatures are ignored&#34;, 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+
&#34;Seller %s advertises request_signing.supported=false but &#34;
228+
&#34;populates required_for/warn_for — treating as unsupported &#34;
229+
&#34;per spec. Verify the seller&#39;s capability advertisement.&#34;,
230+
self.agent_config.id,
231+
)
232+
233+
decision = operation_needs_signing(req_signing, operation)
234+
if decision == &#34;skip&#34;:
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 == &#34;forbidden&#34;:
241+
cover_digest = False
242+
elif covers_policy == &#34;required&#34;:
243+
cover_digest = True
244+
else:
245+
# &#34;either&#34; or absent — signer&#39;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) -&gt; str:
171269
&#34;&#34;&#34;Generate webhook URL for a task.&#34;&#34;&#34;
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 —
34763574
sellers that don't declare it provide no retry-safety guarantee
34773575
per 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
&#34;&#34;&#34;
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
&#34;&#34;&#34;
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>

exceptions.html

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ <h3>Subclasses</h3>
205205
<li><a title="adcp.exceptions.ADCPConnectionError" href="#adcp.exceptions.ADCPConnectionError">ADCPConnectionError</a></li>
206206
<li><a title="adcp.exceptions.ADCPFeatureUnsupportedError" href="#adcp.exceptions.ADCPFeatureUnsupportedError">ADCPFeatureUnsupportedError</a></li>
207207
<li><a title="adcp.exceptions.ADCPProtocolError" href="#adcp.exceptions.ADCPProtocolError">ADCPProtocolError</a></li>
208+
<li><a title="adcp.exceptions.ADCPSigningRequiredError" href="#adcp.exceptions.ADCPSigningRequiredError">ADCPSigningRequiredError</a></li>
208209
<li><a title="adcp.exceptions.ADCPSimpleAPIError" href="#adcp.exceptions.ADCPSimpleAPIError">ADCPSimpleAPIError</a></li>
209210
<li><a title="adcp.exceptions.ADCPTaskError" href="#adcp.exceptions.ADCPTaskError">ADCPTaskError</a></li>
210211
<li><a title="adcp.exceptions.ADCPTimeoutError" href="#adcp.exceptions.ADCPTimeoutError">ADCPTimeoutError</a></li>
@@ -338,6 +339,64 @@ <h3>Inherited members</h3>
338339
</li>
339340
</ul>
340341
</dd>
342+
<dt id="adcp.exceptions.ADCPSigningRequiredError"><code class="flex name class">
343+
<span>class <span class="ident">ADCPSigningRequiredError</span></span>
344+
<span>(</span><span>operation: str, agent_id: str | None = None, agent_uri: str | None = None)</span>
345+
</code></dt>
346+
<dd>
347+
<details class="source">
348+
<summary>
349+
<span>Expand source code</span>
350+
</summary>
351+
<pre><code class="python">class ADCPSigningRequiredError(ADCPError):
352+
&#34;&#34;&#34;Raised when an operation in the seller&#39;s ``request_signing.required_for``
353+
is called without a ``SigningConfig`` on the client.
354+
355+
Signing a ``required_for`` operation is mandatory — sending it unsigned
356+
would produce a ``request_signature_required`` rejection from the seller.
357+
Raising locally before the wire call saves a round-trip and gives the
358+
caller a clear, actionable error.
359+
&#34;&#34;&#34;
360+
361+
def __init__(
362+
self,
363+
operation: str,
364+
agent_id: str | None = None,
365+
agent_uri: str | None = None,
366+
):
367+
self.operation = operation
368+
message = (
369+
f&#34;Operation {operation!r} is in the seller&#39;s request_signing.required_for &#34;
370+
f&#34;list; signing is mandatory but no SigningConfig was provided&#34;
371+
)
372+
suggestion = (
373+
&#34;Pass signing=SigningConfig(private_key=..., key_id=...) when &#34;
374+
&#34;constructing ADCPClient. See adcp-keygen for key generation.&#34;
375+
)
376+
super().__init__(message, agent_id, agent_uri, suggestion)</code></pre>
377+
</details>
378+
<div class="desc"><p>Raised when an operation in the seller's <code>request_signing.required_for</code>
379+
is called without a <code>SigningConfig</code> on the client.</p>
380+
<p>Signing a <code>required_for</code> operation is mandatory — sending it unsigned
381+
would produce a <code>request_signature_required</code> rejection from the seller.
382+
Raising locally before the wire call saves a round-trip and gives the
383+
caller a clear, actionable error.</p>
384+
<p>Initialize exception with context.</p></div>
385+
<h3>Ancestors</h3>
386+
<ul class="hlist">
387+
<li><a title="adcp.exceptions.ADCPError" href="#adcp.exceptions.ADCPError">ADCPError</a></li>
388+
<li>builtins.Exception</li>
389+
<li>builtins.BaseException</li>
390+
</ul>
391+
<h3>Inherited members</h3>
392+
<ul class="hlist">
393+
<li><code><b><a title="adcp.exceptions.ADCPError" href="#adcp.exceptions.ADCPError">ADCPError</a></b></code>:
394+
<ul class="hlist">
395+
<li><code><a title="adcp.exceptions.ADCPError.is_retryable" href="#adcp.exceptions.ADCPError.is_retryable">is_retryable</a></code></li>
396+
</ul>
397+
</li>
398+
</ul>
399+
</dd>
341400
<dt id="adcp.exceptions.ADCPSimpleAPIError"><code class="flex name class">
342401
<span>class <span class="ident">ADCPSimpleAPIError</span></span>
343402
<span>(</span><span>operation: str,<br>error_message: str | None = None,<br>agent_id: str | None = None,<br>errors: list[Any] | None = None)</span>
@@ -1061,6 +1120,9 @@ <h4><code><a title="adcp.exceptions.ADCPFeatureUnsupportedError" href="#adcp.exc
10611120
<h4><code><a title="adcp.exceptions.ADCPProtocolError" href="#adcp.exceptions.ADCPProtocolError">ADCPProtocolError</a></code></h4>
10621121
</li>
10631122
<li>
1123+
<h4><code><a title="adcp.exceptions.ADCPSigningRequiredError" href="#adcp.exceptions.ADCPSigningRequiredError">ADCPSigningRequiredError</a></code></h4>
1124+
</li>
1125+
<li>
10641126
<h4><code><a title="adcp.exceptions.ADCPSimpleAPIError" href="#adcp.exceptions.ADCPSimpleAPIError">ADCPSimpleAPIError</a></code></h4>
10651127
</li>
10661128
<li>

0 commit comments

Comments
 (0)