Skip to content

Commit 3f08642

Browse files
authored
Merge branch 'main' into bokelley/adcp-3-2-beta6-sdk
2 parents 731e249 + d4a8df9 commit 3f08642

21 files changed

Lines changed: 5098 additions & 206 deletions

README.md

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ This README serves both sides of an AdCP integration. Jump to what you're doing:
2020
- [Building an AdCP Agent](#building-an-adcp-agent)
2121
- [Multi-agent discovery manifest](#multi-agent-discovery-manifest)
2222
- [Connecting to AdCP Agents](#connecting-to-adcp-agents)
23+
- [Buyer OAuth authorization code with PKCE](#buyer-oauth-authorization-code-with-pkce)
2324
- [The Core Concept](#the-core-concept)
2425
- [Installation](#installation)
2526
- [Quick Start: Test Helpers](#quick-start-test-helpers)
@@ -141,6 +142,61 @@ serve(
141142

142143
## Connecting to AdCP Agents
143144

145+
### Buyer OAuth authorization code with PKCE
146+
147+
`adcp.oauth` provides a hardened, pre-registered **public-client** flow. Pass a
148+
trusted authorization-server issuer URL (not an MCP resource URL), bind `state`
149+
to the user's browser session, and keep pending flows server-side:
150+
151+
```python
152+
from adcp.oauth import (
153+
InMemoryPendingOAuthFlowStore,
154+
OAuthIssuerBinding,
155+
complete_oauth_authorization,
156+
discover_oauth_metadata,
157+
start_oauth_authorization,
158+
)
159+
160+
pending = InMemoryPendingOAuthFlowStore() # development / one process only
161+
metadata = await discover_oauth_metadata("https://login.example.com/tenant")
162+
163+
request = await start_oauth_authorization(
164+
metadata,
165+
client_id="registered-public-client-id",
166+
redirect_uri="https://buyer.example.com/oauth/callback",
167+
store=pending,
168+
issuer_binding=OAuthIssuerBinding.AUTHORIZATION_RESPONSE_ISS,
169+
scopes=["media.buy"],
170+
resource="https://seller.example.com/mcp",
171+
)
172+
# Store request.state in the authenticated browser session, then redirect the
173+
# browser to request.authorization_url.
174+
175+
tokens = await complete_oauth_authorization(
176+
code=callback_query.get("code"),
177+
callback_state=callback_query["state"],
178+
expected_state=browser_session["oauth_state"],
179+
callback_issuer=callback_query.get("iss"),
180+
store=pending,
181+
)
182+
bearer = tokens.access_token.get_secret_value()
183+
```
184+
185+
Discovery requires an exact RFC 8414 issuer match, S256 PKCE, authorization
186+
code support, and `token_endpoint_auth_methods_supported: ["none", ...]`.
187+
Metadata and token requests are size-bounded, ignore proxy environment
188+
variables, reject redirects/compression, and use DNS-pinned transports. For an
189+
authorization server without RFC 9207 `iss` support, use
190+
`DISTINCT_REDIRECT_URI` only when that callback URI is exclusive to one issuer.
191+
Multi-process deployments must implement `PendingOAuthFlowStore` using shared
192+
storage with atomic insert-if-absent and consume operations. Encrypt the real
193+
`SecretStr` verifier value at rest; JSON-serializing the model produces the
194+
masked display value, not a recoverable verifier. Once completion consumes a
195+
flow, any failure or cancellation requires starting a new one instead of
196+
retrying it. Plain HTTP is disabled by default; `allow_loopback_http=True` is a
197+
development/native app escape limited to literal loopback IPs and is persisted
198+
with the flow.
199+
144200
## The Core Concept
145201

146202
AdCP operations are **distributed and asynchronous by default**. An agent might:
@@ -799,6 +855,72 @@ finally:
799855

800856
In most cases, prefer the context manager pattern.
801857

858+
### Per-call task deadlines
859+
860+
Use `TaskOptions` when a complete SDK call must fit one wall-clock budget:
861+
862+
```python
863+
from adcp import ADCPTimeoutError, TaskOptions
864+
865+
try:
866+
result = await client.create_media_buy(
867+
request,
868+
options=TaskOptions(timeout=15.0),
869+
)
870+
except ADCPTimeoutError as error:
871+
if error.recovery is not None:
872+
# Dispatch began, so the seller may have committed the mutation.
873+
# Retry the exact request with this same key; never mint a new one.
874+
retry_key = error.recovery.idempotency_key
875+
```
876+
877+
The deadline includes discovery, capability/version and signing preflight,
878+
protocol dispatch, response validation, and postflight projection. It never
879+
resets when one phase finishes. `AgentConfig.timeout` remains a separate
880+
transport timeout for connection/read-idle behavior.
881+
882+
Every single-agent task method accepts the keyword-only `options` argument.
883+
Multi-agent fan-out intentionally does not yet accept it because one timeout
884+
exception cannot safely represent several sellers' independent mutation
885+
outcomes and idempotency keys.
886+
887+
### Optional OpenTelemetry tracing
888+
889+
AdCP client calls create one OpenTelemetry `CLIENT` span when an application
890+
has configured an OpenTelemetry SDK provider. The library configures no SDK,
891+
exporter, endpoint, or credentials itself, so its default behavior remains a
892+
non-recording no-op.
893+
894+
```bash
895+
pip install "adcp[observability]" opentelemetry-sdk
896+
```
897+
898+
Configure the provider/exporter once in application startup using the normal
899+
OpenTelemetry Python APIs. The SDK then emits `adcp.mcp.call_tool` or
900+
`adcp.a2a.call_tool` spans with bounded attributes for the agent ID, protocol,
901+
tool name (`adcp.tool` plus the `adcp.tool.name` compatibility alias), standard
902+
RPC system/method fields, task status, and success flag. Multi-call helpers use
903+
an `adcp.client.workflow` parent with one child CLIENT span per wire call.
904+
Request parameters, response bodies, credentials, idempotency keys, and remote
905+
error prose are never attached; invalid or oversized identifiers become
906+
`unknown` instead of being truncated into telemetry.
907+
908+
Active W3C `traceparent`/`tracestate` values propagate on actual tool requests;
909+
agent-card and connection discovery requests are excluded. AdCP deliberately
910+
does not propagate OpenTelemetry baggage across the agent boundary.
911+
912+
```python
913+
from adcp import ADCPClient, is_tracing_available
914+
915+
client = ADCPClient(config)
916+
assert is_tracing_available() # API installed; exporting still needs a provider
917+
result = await client.get_products(request)
918+
```
919+
920+
The `get_tracer()` and `inject_trace_headers()` exports are available for
921+
custom integrations. Libraries should depend only on `opentelemetry-api`;
922+
applications own the SDK and exporter configuration.
923+
802924
### Error Handling
803925

804926
The library provides a comprehensive exception hierarchy with helpful error messages:

pyproject.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,9 @@ dev = [
145145
# src/app.py and seed.py.
146146
"alembic>=1.13.0",
147147
"pre-commit>=4.4.0",
148+
# In-memory span exporter used by the optional observability tests. The
149+
# library itself imports only opentelemetry-api and never configures an SDK.
150+
"opentelemetry-sdk>=1.25,<2",
148151
]
149152
docs = [
150153
"pdoc3>=0.10.0",
@@ -157,6 +160,11 @@ pg = [
157160
"psycopg[binary]>=3.1.0",
158161
"psycopg-pool>=3.2.0",
159162
]
163+
observability = [
164+
# API-only library instrumentation: without an application-configured SDK
165+
# provider/exporter, OpenTelemetry remains a non-recording no-op.
166+
"opentelemetry-api>=1.25,<2",
167+
]
160168

161169
[project.urls]
162170
Homepage = "https://github.com/adcontextprotocol/adcp-client-python"

src/adcp/__init__.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,32 @@ def _resolve_version() -> str:
140140
"ADCPMultiAgentClient",
141141
"Checkpoint",
142142
),
143+
"adcp.task_options": (
144+
"TaskOptions",
145+
"TaskRecoveryMetadata",
146+
),
147+
"adcp.observability": (
148+
"get_tracer",
149+
"inject_trace_headers",
150+
"is_tracing_available",
151+
),
152+
"adcp.oauth": (
153+
"InMemoryPendingOAuthFlowStore",
154+
"OAuthAuthorizationError",
155+
"OAuthAuthorizationRequest",
156+
"OAuthAuthorizationServerMetadata",
157+
"OAuthClientError",
158+
"OAuthDiscoveryError",
159+
"OAuthFlowStoreError",
160+
"OAuthIssuerBinding",
161+
"OAuthTokenExchangeError",
162+
"OAuthTokenSet",
163+
"PendingOAuthAuthorization",
164+
"PendingOAuthFlowStore",
165+
"complete_oauth_authorization",
166+
"discover_oauth_metadata",
167+
"start_oauth_authorization",
168+
),
143169
"adcp.exceptions": (
144170
"AdagentsAccessBlockedError",
145171
"AdagentsNotFoundError",
@@ -162,6 +188,8 @@ def _resolve_version() -> str:
162188
"IdempotencyScopeError",
163189
"IdempotencyUnsupportedError",
164190
"RegistryError",
191+
"RegistryErrorDetails",
192+
"RegistryValidationIssue",
165193
),
166194
"adcp.feed_mirror": (
167195
"EventHandler",
@@ -841,6 +869,27 @@ def get_adcp_version() -> str:
841869
"ADCPClient",
842870
"ADCPMultiAgentClient",
843871
"Checkpoint",
872+
"TaskOptions",
873+
"TaskRecoveryMetadata",
874+
"get_tracer",
875+
"inject_trace_headers",
876+
"is_tracing_available",
877+
# Buyer OAuth authorization-code helpers
878+
"InMemoryPendingOAuthFlowStore",
879+
"OAuthAuthorizationError",
880+
"OAuthAuthorizationRequest",
881+
"OAuthAuthorizationServerMetadata",
882+
"OAuthClientError",
883+
"OAuthDiscoveryError",
884+
"OAuthFlowStoreError",
885+
"OAuthIssuerBinding",
886+
"OAuthTokenExchangeError",
887+
"OAuthTokenSet",
888+
"PendingOAuthAuthorization",
889+
"PendingOAuthFlowStore",
890+
"complete_oauth_authorization",
891+
"discover_oauth_metadata",
892+
"start_oauth_authorization",
844893
"RegistryClient",
845894
"PropertyRegistry",
846895
"RegistrySync",
@@ -1311,6 +1360,8 @@ def get_adcp_version() -> str:
13111360
"IdempotencyScopeError",
13121361
"IdempotencyUnsupportedError",
13131362
"RegistryError",
1363+
"RegistryErrorDetails",
1364+
"RegistryValidationIssue",
13141365
# Validation utilities
13151366
"SchemaValidationError",
13161367
"UnknownFieldPolicy",
@@ -1529,6 +1580,8 @@ def get_adcp_version() -> str:
15291580
IdempotencyScopeError,
15301581
IdempotencyUnsupportedError,
15311582
RegistryError,
1583+
RegistryErrorDetails,
1584+
RegistryValidationIssue,
15321585
)
15331586
from adcp.feed_mirror import (
15341587
EventHandler,
@@ -1539,6 +1592,24 @@ def get_adcp_version() -> str:
15391592
FeedStateStore,
15401593
RefreshResult,
15411594
)
1595+
from adcp.oauth import (
1596+
InMemoryPendingOAuthFlowStore,
1597+
OAuthAuthorizationError,
1598+
OAuthAuthorizationRequest,
1599+
OAuthAuthorizationServerMetadata,
1600+
OAuthClientError,
1601+
OAuthDiscoveryError,
1602+
OAuthFlowStoreError,
1603+
OAuthIssuerBinding,
1604+
OAuthTokenExchangeError,
1605+
OAuthTokenSet,
1606+
PendingOAuthAuthorization,
1607+
PendingOAuthFlowStore,
1608+
complete_oauth_authorization,
1609+
discover_oauth_metadata,
1610+
start_oauth_authorization,
1611+
)
1612+
from adcp.observability import get_tracer, inject_trace_headers, is_tracing_available
15421613
from adcp.property_registry import PropertyRegistry
15431614
from adcp.registry import RegistryClient
15441615
from adcp.registry_sync import (
@@ -1558,6 +1629,7 @@ def get_adcp_version() -> str:
15581629
encode_unreserved,
15591630
translate_universal_macros,
15601631
)
1632+
from adcp.task_options import TaskOptions, TaskRecoveryMetadata
15611633
from adcp.testing import (
15621634
CREATIVE_AGENT_CONFIG,
15631635
TEST_AGENT_A2A_CONFIG,

0 commit comments

Comments
 (0)