@@ -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
146202AdCP operations are ** distributed and asynchronous by default** . An agent might:
@@ -799,6 +855,72 @@ finally:
799855
800856In 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
804926The library provides a comprehensive exception hierarchy with helpful error messages:
0 commit comments