adcp 1.5.0__tar.gz → 1.6.1__tar.gz

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.
Files changed (44) hide show
  1. {adcp-1.5.0/src/adcp.egg-info → adcp-1.6.1}/PKG-INFO +45 -1
  2. {adcp-1.5.0 → adcp-1.6.1}/README.md +44 -0
  3. {adcp-1.5.0 → adcp-1.6.1}/pyproject.toml +1 -1
  4. {adcp-1.5.0 → adcp-1.6.1}/src/adcp/__init__.py +26 -1
  5. adcp-1.6.1/src/adcp/adagents.py +521 -0
  6. {adcp-1.5.0 → adcp-1.6.1}/src/adcp/exceptions.py +30 -0
  7. {adcp-1.5.0 → adcp-1.6.1}/src/adcp/types/__init__.py +2 -0
  8. adcp-1.6.1/src/adcp/types/base.py +26 -0
  9. {adcp-1.5.0 → adcp-1.6.1}/src/adcp/types/generated.py +14 -6
  10. {adcp-1.5.0 → adcp-1.6.1}/src/adcp/utils/preview_cache.py +6 -1
  11. {adcp-1.5.0 → adcp-1.6.1/src/adcp.egg-info}/PKG-INFO +45 -1
  12. {adcp-1.5.0 → adcp-1.6.1}/src/adcp.egg-info/SOURCES.txt +3 -0
  13. adcp-1.6.1/tests/test_adagents.py +613 -0
  14. {adcp-1.5.0 → adcp-1.6.1}/LICENSE +0 -0
  15. {adcp-1.5.0 → adcp-1.6.1}/setup.cfg +0 -0
  16. {adcp-1.5.0 → adcp-1.6.1}/src/adcp/__main__.py +0 -0
  17. {adcp-1.5.0 → adcp-1.6.1}/src/adcp/client.py +0 -0
  18. {adcp-1.5.0 → adcp-1.6.1}/src/adcp/config.py +0 -0
  19. {adcp-1.5.0 → adcp-1.6.1}/src/adcp/protocols/__init__.py +0 -0
  20. {adcp-1.5.0 → adcp-1.6.1}/src/adcp/protocols/a2a.py +0 -0
  21. {adcp-1.5.0 → adcp-1.6.1}/src/adcp/protocols/base.py +0 -0
  22. {adcp-1.5.0 → adcp-1.6.1}/src/adcp/protocols/mcp.py +0 -0
  23. {adcp-1.5.0 → adcp-1.6.1}/src/adcp/simple.py +0 -0
  24. {adcp-1.5.0 → adcp-1.6.1}/src/adcp/testing/__init__.py +0 -0
  25. {adcp-1.5.0 → adcp-1.6.1}/src/adcp/testing/test_helpers.py +0 -0
  26. {adcp-1.5.0 → adcp-1.6.1}/src/adcp/types/core.py +0 -0
  27. {adcp-1.5.0 → adcp-1.6.1}/src/adcp/types/tasks.py +0 -0
  28. {adcp-1.5.0 → adcp-1.6.1}/src/adcp/utils/__init__.py +0 -0
  29. {adcp-1.5.0 → adcp-1.6.1}/src/adcp/utils/operation_id.py +0 -0
  30. {adcp-1.5.0 → adcp-1.6.1}/src/adcp/utils/response_parser.py +0 -0
  31. {adcp-1.5.0 → adcp-1.6.1}/src/adcp.egg-info/dependency_links.txt +0 -0
  32. {adcp-1.5.0 → adcp-1.6.1}/src/adcp.egg-info/entry_points.txt +0 -0
  33. {adcp-1.5.0 → adcp-1.6.1}/src/adcp.egg-info/requires.txt +0 -0
  34. {adcp-1.5.0 → adcp-1.6.1}/src/adcp.egg-info/top_level.txt +0 -0
  35. {adcp-1.5.0 → adcp-1.6.1}/tests/test_cli.py +0 -0
  36. {adcp-1.5.0 → adcp-1.6.1}/tests/test_client.py +0 -0
  37. {adcp-1.5.0 → adcp-1.6.1}/tests/test_code_generation.py +0 -0
  38. {adcp-1.5.0 → adcp-1.6.1}/tests/test_discriminated_unions.py +0 -0
  39. {adcp-1.5.0 → adcp-1.6.1}/tests/test_format_id_validation.py +0 -0
  40. {adcp-1.5.0 → adcp-1.6.1}/tests/test_helpers.py +0 -0
  41. {adcp-1.5.0 → adcp-1.6.1}/tests/test_preview_html.py +0 -0
  42. {adcp-1.5.0 → adcp-1.6.1}/tests/test_protocols.py +0 -0
  43. {adcp-1.5.0 → adcp-1.6.1}/tests/test_response_parser.py +0 -0
  44. {adcp-1.5.0 → adcp-1.6.1}/tests/test_simple_api.py +0 -0
@@ -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.
@@ -424,6 +424,50 @@ auth = index.get_agent_authorizations("https://agent-x.com")
424
424
  premium = index.find_agents_by_property_tags(["premium", "ctv"])
425
425
  ```
426
426
 
427
+ ## Publisher Authorization Validation
428
+
429
+ Verify sales agents are authorized to sell publisher properties via adagents.json:
430
+
431
+ ```python
432
+ from adcp import (
433
+ fetch_adagents,
434
+ verify_agent_authorization,
435
+ verify_agent_for_property,
436
+ )
437
+
438
+ # Fetch and parse adagents.json from publisher
439
+ adagents_data = await fetch_adagents("publisher.com")
440
+
441
+ # Verify agent authorization for a property
442
+ is_authorized = verify_agent_authorization(
443
+ adagents_data=adagents_data,
444
+ agent_url="https://sales-agent.example.com",
445
+ property_type="website",
446
+ property_identifiers=[{"type": "domain", "value": "publisher.com"}]
447
+ )
448
+
449
+ # Or use convenience wrapper (fetch + verify in one call)
450
+ is_authorized = await verify_agent_for_property(
451
+ publisher_domain="publisher.com",
452
+ agent_url="https://sales-agent.example.com",
453
+ property_identifiers=[{"type": "domain", "value": "publisher.com"}],
454
+ property_type="website"
455
+ )
456
+ ```
457
+
458
+ **Domain Matching Rules:**
459
+ - Exact match: `example.com` matches `example.com`
460
+ - Common subdomains: `www.example.com` matches `example.com`
461
+ - Wildcards: `api.example.com` matches `*.example.com`
462
+ - Protocol-agnostic: `http://agent.com` matches `https://agent.com`
463
+
464
+ **Use Cases:**
465
+ - Sales agents verify authorization before accepting media buys
466
+ - Publishers test their adagents.json files
467
+ - Developer tools build authorization validators
468
+
469
+ See `examples/adagents_validation.py` for complete examples.
470
+
427
471
  ## CLI Tool
428
472
 
429
473
  The `adcp` command-line tool provides easy interaction with AdCP agents without writing code.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "adcp"
7
- version = "1.5.0"
7
+ version = "1.6.1"
8
8
  description = "Official Python client for the Ad Context Protocol (AdCP)"
9
9
  authors = [
10
10
  {name = "AdCP Community", email = "maintainers@adcontextprotocol.org"}
@@ -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",
@@ -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 []