adcp 1.5.0__py3-none-any.whl → 1.6.0__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 +26 -1
- adcp/adagents.py +521 -0
- adcp/exceptions.py +30 -0
- adcp/types/__init__.py +2 -0
- adcp/types/base.py +26 -0
- adcp/types/generated.py +4 -1
- {adcp-1.5.0.dist-info → adcp-1.6.0.dist-info}/METADATA +45 -1
- {adcp-1.5.0.dist-info → adcp-1.6.0.dist-info}/RECORD +12 -10
- {adcp-1.5.0.dist-info → adcp-1.6.0.dist-info}/WHEEL +0 -0
- {adcp-1.5.0.dist-info → adcp-1.6.0.dist-info}/entry_points.txt +0 -0
- {adcp-1.5.0.dist-info → adcp-1.6.0.dist-info}/licenses/LICENSE +0 -0
- {adcp-1.5.0.dist-info → adcp-1.6.0.dist-info}/top_level.txt +0 -0
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.
|
|
166
|
+
__version__ = "1.6.0"
|
|
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
|
|
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):
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: adcp
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.6.0
|
|
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=
|
|
1
|
+
adcp/__init__.py,sha256=AssQu9RgVTit-LqDxtQ-pgKuxjoDQr67HhiIxKMmSFA,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=
|
|
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=
|
|
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=
|
|
17
|
+
adcp/types/generated.py,sha256=NY6A6eBw8wscSqJlLhW4OFvhTaZegcNR1hQ5VU51IFM,81526
|
|
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
21
|
adcp/utils/preview_cache.py,sha256=8_2qs5CgrHv1_WOnD4bs43VWueu-rcZRu5PZMQ_lyuE,17573
|
|
20
22
|
adcp/utils/response_parser.py,sha256=uPk2vIH-RYZmq7y3i8lC4HTMQ3FfKdlgXKTjgJ1955M,6253
|
|
21
|
-
adcp-1.
|
|
22
|
-
adcp-1.
|
|
23
|
-
adcp-1.
|
|
24
|
-
adcp-1.
|
|
25
|
-
adcp-1.
|
|
26
|
-
adcp-1.
|
|
23
|
+
adcp-1.6.0.dist-info/licenses/LICENSE,sha256=PF39NR3Ae8PLgBhg3Uxw6ju7iGVIf8hfv9LRWQdii_U,629
|
|
24
|
+
adcp-1.6.0.dist-info/METADATA,sha256=9p6HjEycLX_f45n5H1l_gsDlLU67EpDJM3jBWZhLcqk,21345
|
|
25
|
+
adcp-1.6.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
26
|
+
adcp-1.6.0.dist-info/entry_points.txt,sha256=DQKpcGsJX8DtVI_SGApQ7tNvqUB4zkTLaTAEpFgmi3U,44
|
|
27
|
+
adcp-1.6.0.dist-info/top_level.txt,sha256=T1_NF0GefncFU9v_k56oDwKSJREyCqIM8lAwNZf0EOs,5
|
|
28
|
+
adcp-1.6.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|