adcp 1.5.0__py3-none-any.whl → 1.6.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
adcp/__init__.py CHANGED
@@ -7,8 +7,21 @@ Official Python client for the Ad Context Protocol (AdCP).
7
7
  Supports both A2A and MCP protocols with full type safety.
8
8
  """
9
9
 
10
+ from adcp.adagents import (
11
+ domain_matches,
12
+ fetch_adagents,
13
+ get_all_properties,
14
+ get_all_tags,
15
+ get_properties_by_agent,
16
+ identifiers_match,
17
+ verify_agent_authorization,
18
+ verify_agent_for_property,
19
+ )
10
20
  from adcp.client import ADCPClient, ADCPMultiAgentClient
11
21
  from adcp.exceptions import (
22
+ AdagentsNotFoundError,
23
+ AdagentsTimeoutError,
24
+ AdagentsValidationError,
12
25
  ADCPAuthenticationError,
13
26
  ADCPConnectionError,
14
27
  ADCPError,
@@ -150,7 +163,7 @@ from adcp.types.generated import (
150
163
  TaskStatus as GeneratedTaskStatus,
151
164
  )
152
165
 
153
- __version__ = "1.5.0"
166
+ __version__ = "1.6.1"
154
167
 
155
168
  __all__ = [
156
169
  # Client classes
@@ -162,6 +175,15 @@ __all__ = [
162
175
  "TaskResult",
163
176
  "TaskStatus",
164
177
  "WebhookMetadata",
178
+ # Adagents validation
179
+ "fetch_adagents",
180
+ "verify_agent_authorization",
181
+ "verify_agent_for_property",
182
+ "domain_matches",
183
+ "identifiers_match",
184
+ "get_all_properties",
185
+ "get_all_tags",
186
+ "get_properties_by_agent",
165
187
  # Test helpers
166
188
  "test_agent",
167
189
  "test_agent_a2a",
@@ -185,6 +207,9 @@ __all__ = [
185
207
  "ADCPToolNotFoundError",
186
208
  "ADCPWebhookError",
187
209
  "ADCPWebhookSignatureError",
210
+ "AdagentsValidationError",
211
+ "AdagentsNotFoundError",
212
+ "AdagentsTimeoutError",
188
213
  # Request/Response types
189
214
  "ActivateSignalRequest",
190
215
  "ActivateSignalResponse",
adcp/adagents.py ADDED
@@ -0,0 +1,521 @@
1
+ from __future__ import annotations
2
+
3
+ """
4
+ Utilities for fetching, parsing, and validating adagents.json files per the AdCP specification.
5
+
6
+ Publishers declare authorized sales agents via adagents.json files hosted at
7
+ https://{publisher_domain}/.well-known/adagents.json. This module provides utilities
8
+ for sales agents to verify they are authorized for specific properties.
9
+ """
10
+
11
+ from typing import Any
12
+ from urllib.parse import urlparse
13
+
14
+ import httpx
15
+
16
+ from adcp.exceptions import AdagentsNotFoundError, AdagentsTimeoutError, AdagentsValidationError
17
+
18
+
19
+ def _normalize_domain(domain: str) -> str:
20
+ """Normalize domain for comparison - strip, lowercase, remove trailing dots/slashes.
21
+
22
+ Args:
23
+ domain: Domain to normalize
24
+
25
+ Returns:
26
+ Normalized domain string
27
+
28
+ Raises:
29
+ AdagentsValidationError: If domain contains invalid patterns
30
+ """
31
+ domain = domain.strip().lower()
32
+ # Remove both trailing slashes and dots iteratively
33
+ while domain.endswith("/") or domain.endswith("."):
34
+ domain = domain.rstrip("/").rstrip(".")
35
+
36
+ # Check for invalid patterns
37
+ if not domain or ".." in domain:
38
+ raise AdagentsValidationError(f"Invalid domain format: {domain!r}")
39
+
40
+ return domain
41
+
42
+
43
+ def _validate_publisher_domain(domain: str) -> str:
44
+ """Validate and sanitize publisher domain for security.
45
+
46
+ Args:
47
+ domain: Publisher domain to validate
48
+
49
+ Returns:
50
+ Validated and normalized domain
51
+
52
+ Raises:
53
+ AdagentsValidationError: If domain is invalid or contains suspicious characters
54
+ """
55
+ # Check for suspicious characters BEFORE stripping (to catch injection attempts)
56
+ suspicious_chars = ["\\", "@", "\n", "\r", "\t"]
57
+ for char in suspicious_chars:
58
+ if char in domain:
59
+ raise AdagentsValidationError(
60
+ f"Invalid character in publisher domain: {char!r}"
61
+ )
62
+
63
+ domain = domain.strip()
64
+
65
+ # Check basic constraints
66
+ if not domain:
67
+ raise AdagentsValidationError("Publisher domain cannot be empty")
68
+ if len(domain) > 253: # DNS maximum length
69
+ raise AdagentsValidationError(f"Publisher domain too long: {len(domain)} chars (max 253)")
70
+
71
+ # Check for spaces after stripping leading/trailing whitespace
72
+ if " " in domain:
73
+ raise AdagentsValidationError(
74
+ "Invalid character in publisher domain: ' '"
75
+ )
76
+
77
+ # Remove protocol if present (common user error) - do this BEFORE checking for slashes
78
+ if "://" in domain:
79
+ domain = domain.split("://", 1)[1]
80
+
81
+ # Remove path if present (should only be domain) - do this BEFORE checking for slashes
82
+ if "/" in domain:
83
+ domain = domain.split("/", 1)[0]
84
+
85
+ # Normalize
86
+ domain = _normalize_domain(domain)
87
+
88
+ # Final validation - must look like a domain
89
+ if "." not in domain:
90
+ raise AdagentsValidationError(
91
+ f"Publisher domain must contain at least one dot: {domain!r}"
92
+ )
93
+
94
+ return domain
95
+
96
+
97
+ def normalize_url(url: str) -> str:
98
+ """Normalize URL by removing protocol and trailing slash.
99
+
100
+ Args:
101
+ url: URL to normalize
102
+
103
+ Returns:
104
+ Normalized URL (domain/path without protocol or trailing slash)
105
+ """
106
+ parsed = urlparse(url)
107
+ normalized = parsed.netloc + parsed.path
108
+ return normalized.rstrip("/")
109
+
110
+
111
+ def domain_matches(property_domain: str, agent_domain_pattern: str) -> bool:
112
+ """Check if domains match per AdCP rules.
113
+
114
+ Rules:
115
+ - Exact match always succeeds
116
+ - 'example.com' matches www.example.com, m.example.com (common subdomains)
117
+ - 'subdomain.example.com' matches that specific subdomain only
118
+ - '*.example.com' matches all subdomains
119
+
120
+ Args:
121
+ property_domain: Domain from property
122
+ agent_domain_pattern: Domain pattern from adagents.json
123
+
124
+ Returns:
125
+ True if domains match per AdCP rules
126
+ """
127
+ # Normalize both domains for comparison
128
+ try:
129
+ property_domain = _normalize_domain(property_domain)
130
+ agent_domain_pattern = _normalize_domain(agent_domain_pattern)
131
+ except AdagentsValidationError:
132
+ # Invalid domain format - no match
133
+ return False
134
+
135
+ # Exact match
136
+ if property_domain == agent_domain_pattern:
137
+ return True
138
+
139
+ # Wildcard pattern (*.example.com)
140
+ if agent_domain_pattern.startswith("*."):
141
+ base_domain = agent_domain_pattern[2:]
142
+ return property_domain.endswith(f".{base_domain}")
143
+
144
+ # Bare domain matches common subdomains (www, m)
145
+ # If agent pattern is a bare domain (no subdomain), match www/m subdomains
146
+ if "." in agent_domain_pattern and not agent_domain_pattern.startswith("www."):
147
+ # Check if this looks like a bare domain (e.g., example.com)
148
+ parts = agent_domain_pattern.split(".")
149
+ if len(parts) == 2: # Looks like bare domain
150
+ common_subdomains = ["www", "m"]
151
+ for subdomain in common_subdomains:
152
+ if property_domain == f"{subdomain}.{agent_domain_pattern}":
153
+ return True
154
+
155
+ return False
156
+
157
+
158
+ def identifiers_match(
159
+ property_identifiers: list[dict[str, str]],
160
+ agent_identifiers: list[dict[str, str]],
161
+ ) -> bool:
162
+ """Check if any property identifier matches agent's authorized identifiers.
163
+
164
+ Args:
165
+ property_identifiers: Identifiers from property
166
+ (e.g., [{"type": "domain", "value": "cnn.com"}])
167
+ agent_identifiers: Identifiers from adagents.json
168
+
169
+ Returns:
170
+ True if any identifier matches
171
+
172
+ Notes:
173
+ - Domain identifiers use AdCP domain matching rules
174
+ - Other identifiers (bundle_id, roku_store_id, etc.) require exact match
175
+ """
176
+ for prop_id in property_identifiers:
177
+ prop_type = prop_id.get("type", "")
178
+ prop_value = prop_id.get("value", "")
179
+
180
+ for agent_id in agent_identifiers:
181
+ agent_type = agent_id.get("type", "")
182
+ agent_value = agent_id.get("value", "")
183
+
184
+ # Type must match
185
+ if prop_type != agent_type:
186
+ continue
187
+
188
+ # Domain identifiers use special matching rules
189
+ if prop_type == "domain":
190
+ if domain_matches(prop_value, agent_value):
191
+ return True
192
+ else:
193
+ # Other identifier types require exact match
194
+ if prop_value == agent_value:
195
+ return True
196
+
197
+ return False
198
+
199
+
200
+ def verify_agent_authorization(
201
+ adagents_data: dict[str, Any],
202
+ agent_url: str,
203
+ property_type: str | None = None,
204
+ property_identifiers: list[dict[str, str]] | None = None,
205
+ ) -> bool:
206
+ """Check if agent is authorized for a property.
207
+
208
+ Args:
209
+ adagents_data: Parsed adagents.json data
210
+ agent_url: URL of the sales agent to verify
211
+ property_type: Type of property (website, app, etc.) - optional
212
+ property_identifiers: List of identifiers to match - optional
213
+
214
+ Returns:
215
+ True if agent is authorized, False otherwise
216
+
217
+ Raises:
218
+ AdagentsValidationError: If adagents_data is malformed
219
+
220
+ Notes:
221
+ - If property_type/identifiers are None, checks if agent is authorized
222
+ for ANY property on this domain
223
+ - Implements AdCP domain matching rules
224
+ - Agent URLs are matched ignoring protocol and trailing slash
225
+ """
226
+ # Validate structure
227
+ if not isinstance(adagents_data, dict):
228
+ raise AdagentsValidationError("adagents_data must be a dictionary")
229
+
230
+ authorized_agents = adagents_data.get("authorized_agents")
231
+ if not isinstance(authorized_agents, list):
232
+ raise AdagentsValidationError("adagents.json must have 'authorized_agents' array")
233
+
234
+ # Normalize the agent URL for comparison
235
+ normalized_agent_url = normalize_url(agent_url)
236
+
237
+ # Check each authorized agent
238
+ for agent in authorized_agents:
239
+ if not isinstance(agent, dict):
240
+ continue
241
+
242
+ agent_url_from_json = agent.get("url", "")
243
+ if not agent_url_from_json:
244
+ continue
245
+
246
+ # Match agent URL (protocol-agnostic)
247
+ if normalize_url(agent_url_from_json) != normalized_agent_url:
248
+ continue
249
+
250
+ # Found matching agent - now check properties
251
+ properties = agent.get("properties")
252
+
253
+ # If properties field is missing or empty, agent is authorized for all properties
254
+ if properties is None or (isinstance(properties, list) and len(properties) == 0):
255
+ return True
256
+
257
+ # If no property filters specified, we found the agent - authorized
258
+ if property_type is None and property_identifiers is None:
259
+ return True
260
+
261
+ # Check specific property authorization
262
+ if isinstance(properties, list):
263
+ for prop in properties:
264
+ if not isinstance(prop, dict):
265
+ continue
266
+
267
+ # Check property type if specified
268
+ if property_type is not None:
269
+ prop_type = prop.get("property_type", "")
270
+ if prop_type != property_type:
271
+ continue
272
+
273
+ # Check identifiers if specified
274
+ if property_identifiers is not None:
275
+ prop_identifiers = prop.get("identifiers", [])
276
+ if not isinstance(prop_identifiers, list):
277
+ continue
278
+
279
+ if identifiers_match(property_identifiers, prop_identifiers):
280
+ return True
281
+ else:
282
+ # Property type matched and no identifier check needed
283
+ return True
284
+
285
+ return False
286
+
287
+
288
+ async def fetch_adagents(
289
+ publisher_domain: str,
290
+ timeout: float = 10.0,
291
+ user_agent: str = "AdCP-Client/1.0",
292
+ client: httpx.AsyncClient | None = None,
293
+ ) -> dict[str, Any]:
294
+ """Fetch and parse adagents.json from publisher domain.
295
+
296
+ Args:
297
+ publisher_domain: Domain hosting the adagents.json file
298
+ timeout: Request timeout in seconds
299
+ user_agent: User-Agent header for HTTP request
300
+ client: Optional httpx.AsyncClient for connection pooling.
301
+ If provided, caller is responsible for client lifecycle.
302
+ If None, a new client is created for this request.
303
+
304
+ Returns:
305
+ Parsed adagents.json data
306
+
307
+ Raises:
308
+ AdagentsNotFoundError: If adagents.json not found (404)
309
+ AdagentsValidationError: If JSON is invalid or malformed
310
+ AdagentsTimeoutError: If request times out
311
+
312
+ Notes:
313
+ For production use with multiple requests, pass a shared httpx.AsyncClient
314
+ to enable connection pooling and improve performance.
315
+ """
316
+ # Validate and normalize domain for security
317
+ publisher_domain = _validate_publisher_domain(publisher_domain)
318
+
319
+ # Construct URL
320
+ url = f"https://{publisher_domain}/.well-known/adagents.json"
321
+
322
+ try:
323
+ # Use provided client or create a new one
324
+ if client is not None:
325
+ # Reuse provided client (connection pooling)
326
+ response = await client.get(
327
+ url,
328
+ headers={"User-Agent": user_agent},
329
+ timeout=timeout,
330
+ follow_redirects=True,
331
+ )
332
+ else:
333
+ # Create new client for single request
334
+ async with httpx.AsyncClient() as new_client:
335
+ response = await new_client.get(
336
+ url,
337
+ headers={"User-Agent": user_agent},
338
+ timeout=timeout,
339
+ follow_redirects=True,
340
+ )
341
+
342
+ # Process response (same for both paths)
343
+ if response.status_code == 404:
344
+ raise AdagentsNotFoundError(publisher_domain)
345
+
346
+ if response.status_code != 200:
347
+ raise AdagentsValidationError(
348
+ f"Failed to fetch adagents.json: HTTP {response.status_code}"
349
+ )
350
+
351
+ # Parse JSON
352
+ try:
353
+ data = response.json()
354
+ except Exception as e:
355
+ raise AdagentsValidationError(f"Invalid JSON in adagents.json: {e}") from e
356
+
357
+ # Validate basic structure
358
+ if not isinstance(data, dict):
359
+ raise AdagentsValidationError("adagents.json must be a JSON object")
360
+
361
+ if "authorized_agents" not in data:
362
+ raise AdagentsValidationError(
363
+ "adagents.json must have 'authorized_agents' field"
364
+ )
365
+
366
+ if not isinstance(data["authorized_agents"], list):
367
+ raise AdagentsValidationError("'authorized_agents' must be an array")
368
+
369
+ return data
370
+
371
+ except httpx.TimeoutException as e:
372
+ raise AdagentsTimeoutError(publisher_domain, timeout) from e
373
+ except httpx.RequestError as e:
374
+ raise AdagentsValidationError(f"Failed to fetch adagents.json: {e}") from e
375
+
376
+
377
+ async def verify_agent_for_property(
378
+ publisher_domain: str,
379
+ agent_url: str,
380
+ property_identifiers: list[dict[str, str]],
381
+ property_type: str | None = None,
382
+ timeout: float = 10.0,
383
+ client: httpx.AsyncClient | None = None,
384
+ ) -> bool:
385
+ """Convenience wrapper to fetch adagents.json and verify authorization in one call.
386
+
387
+ Args:
388
+ publisher_domain: Domain hosting the adagents.json file
389
+ agent_url: URL of the sales agent to verify
390
+ property_identifiers: List of identifiers to match
391
+ property_type: Type of property (website, app, etc.) - optional
392
+ timeout: Request timeout in seconds
393
+ client: Optional httpx.AsyncClient for connection pooling
394
+
395
+ Returns:
396
+ True if agent is authorized, False otherwise
397
+
398
+ Raises:
399
+ AdagentsNotFoundError: If adagents.json not found (404)
400
+ AdagentsValidationError: If JSON is invalid or malformed
401
+ AdagentsTimeoutError: If request times out
402
+ """
403
+ adagents_data = await fetch_adagents(publisher_domain, timeout=timeout, client=client)
404
+ return verify_agent_authorization(
405
+ adagents_data=adagents_data,
406
+ agent_url=agent_url,
407
+ property_type=property_type,
408
+ property_identifiers=property_identifiers,
409
+ )
410
+
411
+
412
+ def get_all_properties(adagents_data: dict[str, Any]) -> list[dict[str, Any]]:
413
+ """Extract all properties from adagents.json data.
414
+
415
+ Args:
416
+ adagents_data: Parsed adagents.json data
417
+
418
+ Returns:
419
+ List of all properties across all authorized agents, with agent_url added
420
+
421
+ Raises:
422
+ AdagentsValidationError: If adagents_data is malformed
423
+ """
424
+ if not isinstance(adagents_data, dict):
425
+ raise AdagentsValidationError("adagents_data must be a dictionary")
426
+
427
+ authorized_agents = adagents_data.get("authorized_agents")
428
+ if not isinstance(authorized_agents, list):
429
+ raise AdagentsValidationError("adagents.json must have 'authorized_agents' array")
430
+
431
+ properties = []
432
+ for agent in authorized_agents:
433
+ if not isinstance(agent, dict):
434
+ continue
435
+
436
+ agent_url = agent.get("url", "")
437
+ if not agent_url:
438
+ continue
439
+
440
+ agent_properties = agent.get("properties", [])
441
+ if not isinstance(agent_properties, list):
442
+ continue
443
+
444
+ # Add each property with the agent URL for reference
445
+ for prop in agent_properties:
446
+ if isinstance(prop, dict):
447
+ # Create a copy and add agent_url
448
+ prop_with_agent = {**prop, "agent_url": agent_url}
449
+ properties.append(prop_with_agent)
450
+
451
+ return properties
452
+
453
+
454
+ def get_all_tags(adagents_data: dict[str, Any]) -> set[str]:
455
+ """Extract all unique tags from properties in adagents.json data.
456
+
457
+ Args:
458
+ adagents_data: Parsed adagents.json data
459
+
460
+ Returns:
461
+ Set of all unique tags across all properties
462
+
463
+ Raises:
464
+ AdagentsValidationError: If adagents_data is malformed
465
+ """
466
+ properties = get_all_properties(adagents_data)
467
+ tags = set()
468
+
469
+ for prop in properties:
470
+ prop_tags = prop.get("tags", [])
471
+ if isinstance(prop_tags, list):
472
+ for tag in prop_tags:
473
+ if isinstance(tag, str):
474
+ tags.add(tag)
475
+
476
+ return tags
477
+
478
+
479
+ def get_properties_by_agent(adagents_data: dict[str, Any], agent_url: str) -> list[dict[str, Any]]:
480
+ """Get all properties authorized for a specific agent.
481
+
482
+ Args:
483
+ adagents_data: Parsed adagents.json data
484
+ agent_url: URL of the agent to filter by
485
+
486
+ Returns:
487
+ List of properties for the specified agent (empty if agent not found or no properties)
488
+
489
+ Raises:
490
+ AdagentsValidationError: If adagents_data is malformed
491
+ """
492
+ if not isinstance(adagents_data, dict):
493
+ raise AdagentsValidationError("adagents_data must be a dictionary")
494
+
495
+ authorized_agents = adagents_data.get("authorized_agents")
496
+ if not isinstance(authorized_agents, list):
497
+ raise AdagentsValidationError("adagents.json must have 'authorized_agents' array")
498
+
499
+ # Normalize the agent URL for comparison
500
+ normalized_agent_url = normalize_url(agent_url)
501
+
502
+ for agent in authorized_agents:
503
+ if not isinstance(agent, dict):
504
+ continue
505
+
506
+ agent_url_from_json = agent.get("url", "")
507
+ if not agent_url_from_json:
508
+ continue
509
+
510
+ # Match agent URL (protocol-agnostic)
511
+ if normalize_url(agent_url_from_json) != normalized_agent_url:
512
+ continue
513
+
514
+ # Found the agent - return their properties
515
+ properties = agent.get("properties", [])
516
+ if not isinstance(properties, list):
517
+ return []
518
+
519
+ return [p for p in properties if isinstance(p, dict)]
520
+
521
+ return []
adcp/exceptions.py CHANGED
@@ -153,3 +153,33 @@ class ADCPSimpleAPIError(ADCPError):
153
153
  f" # Handle error with full TaskResult context"
154
154
  )
155
155
  super().__init__(message, agent_id, None, suggestion)
156
+
157
+
158
+ class AdagentsValidationError(ADCPError):
159
+ """Base error for adagents.json validation issues."""
160
+
161
+
162
+ class AdagentsNotFoundError(AdagentsValidationError):
163
+ """adagents.json file not found (404)."""
164
+
165
+ def __init__(self, publisher_domain: str):
166
+ """Initialize not found error."""
167
+ message = f"adagents.json not found for domain: {publisher_domain}"
168
+ suggestion = (
169
+ "Verify that the publisher has deployed adagents.json to:\n"
170
+ f" https://{publisher_domain}/.well-known/adagents.json"
171
+ )
172
+ super().__init__(message, None, None, suggestion)
173
+
174
+
175
+ class AdagentsTimeoutError(AdagentsValidationError):
176
+ """Request for adagents.json timed out."""
177
+
178
+ def __init__(self, publisher_domain: str, timeout: float):
179
+ """Initialize timeout error."""
180
+ message = f"Request to fetch adagents.json timed out after {timeout}s"
181
+ suggestion = (
182
+ "The publisher's server may be slow or unresponsive.\n"
183
+ " Try increasing the timeout value or check the domain is correct."
184
+ )
185
+ super().__init__(message, None, None, suggestion)
adcp/types/__init__.py CHANGED
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  """Type definitions for AdCP client."""
4
4
 
5
+ from adcp.types.base import AdCPBaseModel
5
6
  from adcp.types.core import (
6
7
  Activity,
7
8
  ActivityType,
@@ -14,6 +15,7 @@ from adcp.types.core import (
14
15
  )
15
16
 
16
17
  __all__ = [
18
+ "AdCPBaseModel",
17
19
  "AgentConfig",
18
20
  "Protocol",
19
21
  "TaskResult",
adcp/types/base.py ADDED
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ """Base model for AdCP types with spec-compliant serialization."""
4
+
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel
8
+
9
+
10
+ class AdCPBaseModel(BaseModel):
11
+ """Base model for AdCP types with spec-compliant serialization.
12
+
13
+ AdCP JSON schemas use additionalProperties: false and do not allow null
14
+ for optional fields. Therefore, optional fields must be omitted entirely
15
+ when not present (not sent as null).
16
+ """
17
+
18
+ def model_dump(self, **kwargs: Any) -> dict[str, Any]:
19
+ if "exclude_none" not in kwargs:
20
+ kwargs["exclude_none"] = True
21
+ return super().model_dump(**kwargs)
22
+
23
+ def model_dump_json(self, **kwargs: Any) -> str:
24
+ if "exclude_none" not in kwargs:
25
+ kwargs["exclude_none"] = True
26
+ return super().model_dump_json(**kwargs)
adcp/types/generated.py CHANGED
@@ -14,7 +14,9 @@ from __future__ import annotations
14
14
  import re
15
15
  from typing import Any, Literal
16
16
 
17
- from pydantic import BaseModel, ConfigDict, Field, field_validator
17
+ from pydantic import ConfigDict, Field, field_validator
18
+
19
+ from adcp.types.base import AdCPBaseModel as BaseModel
18
20
 
19
21
 
20
22
 
@@ -945,6 +947,7 @@ class TasksListResponse(BaseModel):
945
947
  # The simple code generator produces type aliases (e.g., PreviewCreativeRequest = Any)
946
948
  # for complex schemas that use oneOf. We override them here with proper Pydantic classes
947
949
  # to maintain type safety and enable batch API support.
950
+ # Note: All classes inherit from BaseModel (which is aliased to AdCPBaseModel for exclude_none).
948
951
 
949
952
 
950
953
  class FormatId(BaseModel):
@@ -978,7 +981,7 @@ class PreviewCreativeRequest(BaseModel):
978
981
 
979
982
  # Output format (applies to both modes)
980
983
  output_format: Literal["url", "html"] | None = Field(default="url", description="Output format: 'url' for iframe URLs, 'html' for direct embedding")
981
-
984
+ context: dict[str, Any] | None = Field(None, description="Initiator-provided context echoed inside the task payload. Opaque metadata such as UI/session hints, correlation tokens, or tracking identifiers.")
982
985
 
983
986
  class PreviewCreativeResponse(BaseModel):
984
987
  """Response containing preview links for one or more creatives. Format matches the request: single preview response for single requests, batch results for batch requests."""
@@ -990,6 +993,7 @@ class PreviewCreativeResponse(BaseModel):
990
993
 
991
994
  # Batch mode field
992
995
  results: list[dict[str, Any]] | None = Field(default=None, description="Array of preview results for batch processing")
996
+ context: dict[str, Any] | None = Field(None, description="Initiator-provided context echoed inside the task payload. Opaque metadata such as UI/session hints, correlation tokens, or tracking identifiers.")
993
997
 
994
998
 
995
999
  # ============================================================================
@@ -1007,13 +1011,13 @@ class ActivateSignalSuccess(BaseModel):
1007
1011
  )
1008
1012
  estimated_activation_duration_minutes: float | None = None
1009
1013
  deployed_at: str | None = None
1010
-
1014
+ context: dict[str, Any] | None = Field(None, description="Initiator-provided context echoed inside the task payload. Opaque metadata such as UI/session hints, correlation tokens, or tracking identifiers.")
1011
1015
 
1012
1016
  class ActivateSignalError(BaseModel):
1013
1017
  """Failed signal activation response"""
1014
1018
 
1015
1019
  errors: list[Error] = Field(description="Task-specific errors and warnings")
1016
-
1020
+ context: dict[str, Any] | None = Field(None, description="Initiator-provided context echoed inside the task payload. Opaque metadata such as UI/session hints, correlation tokens, or tracking identifiers.")
1017
1021
 
1018
1022
  # Override the generated ActivateSignalResponse type alias
1019
1023
  ActivateSignalResponse = ActivateSignalSuccess | ActivateSignalError
@@ -1031,12 +1035,14 @@ class CreateMediaBuySuccess(BaseModel):
1031
1035
  None,
1032
1036
  description="ISO 8601 date when creatives must be provided for launch",
1033
1037
  )
1038
+ context: dict[str, Any] | None = Field(None, description="Initiator-provided context echoed inside the task payload. Opaque metadata such as UI/session hints, correlation tokens, or tracking identifiers.")
1034
1039
 
1035
1040
 
1036
1041
  class CreateMediaBuyError(BaseModel):
1037
1042
  """Failed media buy creation response"""
1038
1043
 
1039
1044
  errors: list[Error] = Field(description="Task-specific errors and warnings")
1045
+ context: dict[str, Any] | None = Field(None, description="Initiator-provided context echoed inside the task payload. Opaque metadata such as UI/session hints, correlation tokens, or tracking identifiers.")
1040
1046
 
1041
1047
 
1042
1048
  # Override the generated CreateMediaBuyResponse type alias
@@ -1051,13 +1057,14 @@ class UpdateMediaBuySuccess(BaseModel):
1051
1057
  packages: list[Package] = Field(
1052
1058
  description="Array of updated packages reflecting the changes"
1053
1059
  )
1060
+ context: dict[str, Any] | None = Field(None, description="Initiator-provided context echoed inside the task payload. Opaque metadata such as UI/session hints, correlation tokens, or tracking identifiers.")
1054
1061
 
1055
1062
 
1056
1063
  class UpdateMediaBuyError(BaseModel):
1057
1064
  """Failed media buy update response"""
1058
1065
 
1059
1066
  errors: list[Error] = Field(description="Task-specific errors and warnings")
1060
-
1067
+ context: dict[str, Any] | None = Field(None, description="Initiator-provided context echoed inside the task payload. Opaque metadata such as UI/session hints, correlation tokens, or tracking identifiers.")
1061
1068
 
1062
1069
  # Override the generated UpdateMediaBuyResponse type alias
1063
1070
  UpdateMediaBuyResponse = UpdateMediaBuySuccess | UpdateMediaBuyError
@@ -1069,13 +1076,14 @@ class SyncCreativesSuccess(BaseModel):
1069
1076
  assignments: list[CreativeAssignment] = Field(
1070
1077
  description="Array of creative assignments with updated status"
1071
1078
  )
1079
+ context: dict[str, Any] | None = Field(None, description="Initiator-provided context echoed inside the task payload. Opaque metadata such as UI/session hints, correlation tokens, or tracking identifiers.")
1072
1080
 
1073
1081
 
1074
1082
  class SyncCreativesError(BaseModel):
1075
1083
  """Failed creative sync response"""
1076
1084
 
1077
1085
  errors: list[Error] = Field(description="Task-specific errors and warnings")
1078
-
1086
+ context: dict[str, Any] | None = Field(None, description="Initiator-provided context echoed inside the task payload. Opaque metadata such as UI/session hints, correlation tokens, or tracking identifiers.")
1079
1087
 
1080
1088
  # Override the generated SyncCreativesResponse type alias
1081
1089
  SyncCreativesResponse = SyncCreativesSuccess | SyncCreativesError
@@ -74,7 +74,11 @@ class PreviewURLGenerator:
74
74
 
75
75
  try:
76
76
  request = PreviewCreativeRequest(
77
- format_id=format_id, creative_manifest=manifest, inputs=None, template_id=None
77
+ format_id=format_id,
78
+ creative_manifest=manifest,
79
+ inputs=None,
80
+ template_id=None,
81
+ context=None
78
82
  )
79
83
  result = await self.creative_agent_client.preview_creative(request)
80
84
 
@@ -164,6 +168,7 @@ class PreviewURLGenerator:
164
168
  batch_request = PreviewCreativeRequest(
165
169
  requests=chunk_requests,
166
170
  output_format=output_format, # type: ignore[arg-type]
171
+ context=None,
167
172
  )
168
173
  result = await self.creative_agent_client.preview_creative(batch_request)
169
174
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: adcp
3
- Version: 1.5.0
3
+ Version: 1.6.1
4
4
  Summary: Official Python client for the Ad Context Protocol (AdCP)
5
5
  Author-email: AdCP Community <maintainers@adcontextprotocol.org>
6
6
  License: Apache-2.0
@@ -461,6 +461,50 @@ auth = index.get_agent_authorizations("https://agent-x.com")
461
461
  premium = index.find_agents_by_property_tags(["premium", "ctv"])
462
462
  ```
463
463
 
464
+ ## Publisher Authorization Validation
465
+
466
+ Verify sales agents are authorized to sell publisher properties via adagents.json:
467
+
468
+ ```python
469
+ from adcp import (
470
+ fetch_adagents,
471
+ verify_agent_authorization,
472
+ verify_agent_for_property,
473
+ )
474
+
475
+ # Fetch and parse adagents.json from publisher
476
+ adagents_data = await fetch_adagents("publisher.com")
477
+
478
+ # Verify agent authorization for a property
479
+ is_authorized = verify_agent_authorization(
480
+ adagents_data=adagents_data,
481
+ agent_url="https://sales-agent.example.com",
482
+ property_type="website",
483
+ property_identifiers=[{"type": "domain", "value": "publisher.com"}]
484
+ )
485
+
486
+ # Or use convenience wrapper (fetch + verify in one call)
487
+ is_authorized = await verify_agent_for_property(
488
+ publisher_domain="publisher.com",
489
+ agent_url="https://sales-agent.example.com",
490
+ property_identifiers=[{"type": "domain", "value": "publisher.com"}],
491
+ property_type="website"
492
+ )
493
+ ```
494
+
495
+ **Domain Matching Rules:**
496
+ - Exact match: `example.com` matches `example.com`
497
+ - Common subdomains: `www.example.com` matches `example.com`
498
+ - Wildcards: `api.example.com` matches `*.example.com`
499
+ - Protocol-agnostic: `http://agent.com` matches `https://agent.com`
500
+
501
+ **Use Cases:**
502
+ - Sales agents verify authorization before accepting media buys
503
+ - Publishers test their adagents.json files
504
+ - Developer tools build authorization validators
505
+
506
+ See `examples/adagents_validation.py` for complete examples.
507
+
464
508
  ## CLI Tool
465
509
 
466
510
  The `adcp` command-line tool provides easy interaction with AdCP agents without writing code.
@@ -1,8 +1,9 @@
1
- adcp/__init__.py,sha256=Q3JzUv35ZwIU3Y97bd_wpHikojz-YFtlaekN961bakg,6976
1
+ adcp/__init__.py,sha256=g3x4xqEGJgRcHZB6Oso3XAxs8uwNtEhk8l9TGFVcN1c,7612
2
2
  adcp/__main__.py,sha256=Avy_C71rruh2lOuojvuXDj09tkFOaek74nJ-dbx25Sw,12838
3
+ adcp/adagents.py,sha256=thq6qZScRHLWrRR4DjGlZzHpC1ZssQqhc5l0pbKSa-Q,17483
3
4
  adcp/client.py,sha256=4qoFNDT5swzi4w5bnWJ5nVTG5JIL0xnLJOJMGeMyci4,28412
4
5
  adcp/config.py,sha256=Vsy7ZPOI8G3fB_i5Nk-CHbC7wdasCUWuKlos0fwA0kY,2017
5
- adcp/exceptions.py,sha256=9L7a3TKrMhmbEqDM0Qjdu3dQAFeKCSW4nc1IcLGSj4Y,5597
6
+ adcp/exceptions.py,sha256=1aZEWpaM92OxD2jl9yKsqJp5ReSWaj0S0DFhxChhLlA,6732
6
7
  adcp/simple.py,sha256=FgPYWT32BNXkQz07r2x2gXgOmOikWLi88SzN5UIVSiU,10440
7
8
  adcp/protocols/__init__.py,sha256=6UFwACQ0QadBUzy17wUROHqsJDp8ztPW2jzyl53Zh_g,262
8
9
  adcp/protocols/a2a.py,sha256=FHgc6G_eU2qD0vH7_RyS1eZvUFSb2j3-EsceoHPi384,12467
@@ -10,17 +11,18 @@ adcp/protocols/base.py,sha256=vBHD23Fzl_CCk_Gy9nvSbBYopcJlYkYyzoz-rhI8wHg,5214
10
11
  adcp/protocols/mcp.py,sha256=d9uSpGd0BKvQ0JxztkfDvHwoDrDYhuiw5oivpYOAbmM,16647
11
12
  adcp/testing/__init__.py,sha256=ZWp_floWjVZfy8RBG5v_FUXQ8YbN7xjXvVcX-_zl_HU,1416
12
13
  adcp/testing/test_helpers.py,sha256=4n8fZYy1cVpjZpFW2SxBzpC8fmY-MBFrzY4tIPqe4rQ,10028
13
- adcp/types/__init__.py,sha256=3E_TJUXqQQFcjmSZZSPLwqBP3s_ijsH2LDeuOU-MP30,402
14
+ adcp/types/__init__.py,sha256=FXm4210pkzOIQQEgpe-EeLLd7mxofzEgKLGl1r8fj4o,465
15
+ adcp/types/base.py,sha256=QoEuVfI4yzefup0dc2KN11AcJTbcGxRep7xOw5hXfs8,837
14
16
  adcp/types/core.py,sha256=RXkKCWCXS9BVJTNpe3Opm5O1I_LaQPMUuVwa-ipvS1Q,4839
15
- adcp/types/generated.py,sha256=li7OXwzwbSufdff6sF_tjA9X1Sce4pq_FB3aijvoW-E,81384
17
+ adcp/types/generated.py,sha256=os26c6yVrCzfSJXJkMsNUODRgp4UrozMkc19OPYcxls,83621
16
18
  adcp/types/tasks.py,sha256=Ae9TSwG2F7oWXTcl4TvLhAzinbQkHNGF1Pc0q8RMNNM,23424
17
19
  adcp/utils/__init__.py,sha256=uetvSJB19CjQbtwEYZiTnumJG11GsafQmXm5eR3hL7E,153
18
20
  adcp/utils/operation_id.py,sha256=wQX9Bb5epXzRq23xoeYPTqzu5yLuhshg7lKJZihcM2k,294
19
- adcp/utils/preview_cache.py,sha256=8_2qs5CgrHv1_WOnD4bs43VWueu-rcZRu5PZMQ_lyuE,17573
21
+ adcp/utils/preview_cache.py,sha256=8BqbGtilEWD-2ZIIcXCxVar6hs1uMLqTs9DND05RMh8,17685
20
22
  adcp/utils/response_parser.py,sha256=uPk2vIH-RYZmq7y3i8lC4HTMQ3FfKdlgXKTjgJ1955M,6253
21
- adcp-1.5.0.dist-info/licenses/LICENSE,sha256=PF39NR3Ae8PLgBhg3Uxw6ju7iGVIf8hfv9LRWQdii_U,629
22
- adcp-1.5.0.dist-info/METADATA,sha256=seaLyTrrjeh5W1sSvZajfvbomYy-VcX9jPWfCZQiXDU,19931
23
- adcp-1.5.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
24
- adcp-1.5.0.dist-info/entry_points.txt,sha256=DQKpcGsJX8DtVI_SGApQ7tNvqUB4zkTLaTAEpFgmi3U,44
25
- adcp-1.5.0.dist-info/top_level.txt,sha256=T1_NF0GefncFU9v_k56oDwKSJREyCqIM8lAwNZf0EOs,5
26
- adcp-1.5.0.dist-info/RECORD,,
23
+ adcp-1.6.1.dist-info/licenses/LICENSE,sha256=PF39NR3Ae8PLgBhg3Uxw6ju7iGVIf8hfv9LRWQdii_U,629
24
+ adcp-1.6.1.dist-info/METADATA,sha256=OgqXYiJzf7oW6Hta0L_sH3VJq2EYWMypqJzu9wBx_Qs,21345
25
+ adcp-1.6.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
26
+ adcp-1.6.1.dist-info/entry_points.txt,sha256=DQKpcGsJX8DtVI_SGApQ7tNvqUB4zkTLaTAEpFgmi3U,44
27
+ adcp-1.6.1.dist-info/top_level.txt,sha256=T1_NF0GefncFU9v_k56oDwKSJREyCqIM8lAwNZf0EOs,5
28
+ adcp-1.6.1.dist-info/RECORD,,
File without changes