Skip to content

Commit e7d0696

Browse files
bokelleyclaude
andauthored
feat: Add publisher authorization discovery API (#54)
* feat: Add publisher authorization discovery API Implement two approaches for discovering which publishers have authorized an agent: 1. "Pull" approach: fetch_agent_authorizations() - Check publisher adagents.json files in parallel to see which ones authorize your agent, extracting property IDs and tags. 2. "Push" approach: ADCPClient.list_authorized_properties() - Ask the agent directly what publishers it represents (already existed, now documented). Add AuthorizationContext class to encapsulate authorization info (property IDs, tags, raw property data). Includes comprehensive tests covering success, failure, filtering, and connection pooling scenarios. Resolves #53 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com> * docs: Move authorization discovery to README Remove separate AUTHORIZATION_DISCOVERY.md file and integrate content into README as a subsection under Publisher Authorization Validation. Keeps documentation consolidated in a single location. 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6ce1535 commit e7d0696

File tree

5 files changed

+668
-0
lines changed

5 files changed

+668
-0
lines changed

README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,42 @@ is_authorized = await verify_agent_for_property(
505505

506506
See `examples/adagents_validation.py` for complete examples.
507507

508+
### Authorization Discovery
509+
510+
Discover which publishers have authorized your agent using two approaches:
511+
512+
**1. "Push" Approach** - Ask the agent (recommended, fastest):
513+
```python
514+
from adcp import ADCPClient
515+
516+
async with ADCPClient(agent_config) as client:
517+
# Single API call to agent
518+
response = await client.simple.list_authorized_properties()
519+
print(f"Authorized for: {response.publisher_domains}")
520+
```
521+
522+
**2. "Pull" Approach** - Check publisher adagents.json files (when you need property details):
523+
```python
524+
from adcp import fetch_agent_authorizations
525+
526+
# Check specific publishers (fetches in parallel)
527+
contexts = await fetch_agent_authorizations(
528+
"https://our-sales-agent.com",
529+
["nytimes.com", "wsj.com", "cnn.com"]
530+
)
531+
532+
for domain, ctx in contexts.items():
533+
print(f"{domain}:")
534+
print(f" Property IDs: {ctx.property_ids}")
535+
print(f" Tags: {ctx.property_tags}")
536+
```
537+
538+
**When to use which:**
539+
- **Push**: Quick discovery, portfolio overview, high-level authorization check
540+
- **Pull**: Property-level details, specific publisher list, works offline
541+
542+
See `examples/fetch_agent_authorizations.py` for complete examples.
543+
508544
## CLI Tool
509545

510546
The `adcp` command-line tool provides easy interaction with AdCP agents without writing code.
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
"""
2+
Example showing how to discover which publishers have authorized your agent.
3+
4+
This example demonstrates TWO approaches:
5+
6+
1. "Push" approach - Ask the agent what it's authorized for:
7+
- Use the agent's list_authorized_properties endpoint
8+
- Agent tells you which publisher domains it represents
9+
- Fast and efficient - single API call
10+
11+
2. "Pull" approach - Check publisher adagents.json files:
12+
- Use fetch_agent_authorizations to check multiple publishers
13+
- Fetch adagents.json from each publisher's .well-known directory
14+
- Useful when you have a specific list of publishers to check
15+
- Supports connection pooling for better performance
16+
"""
17+
18+
import asyncio
19+
20+
from adcp import ADCPClient, AgentConfig, Protocol, fetch_agent_authorizations
21+
22+
23+
async def approach_1_push():
24+
"""APPROACH 1: Ask the agent what it's authorized for (RECOMMENDED)."""
25+
print("=" * 70)
26+
print("APPROACH 1: Push - Ask agent what it's authorized for")
27+
print("=" * 70)
28+
print()
29+
30+
# Configure the agent client
31+
agent_config = AgentConfig(
32+
id="sales_agent",
33+
agent_uri="https://our-sales-agent.com",
34+
protocol=Protocol.A2A,
35+
)
36+
37+
async with ADCPClient(agent_config) as client:
38+
# Ask the agent directly what publishers it represents
39+
# This is fast - just one API call!
40+
response = await client.simple.list_authorized_properties()
41+
42+
print(f"✅ Agent represents {len(response.publisher_domains)} publishers:\n")
43+
44+
for domain in response.publisher_domains:
45+
print(f" • {domain}")
46+
47+
print()
48+
print("📊 Portfolio Summary:")
49+
if response.primary_channels:
50+
print(f" Primary Channels: {', '.join(response.primary_channels)}")
51+
if response.primary_countries:
52+
print(f" Primary Countries: {', '.join(response.primary_countries)}")
53+
if response.portfolio_description:
54+
print(f" Description: {response.portfolio_description[:100]}...")
55+
56+
print()
57+
print("💡 TIP: Now fetch each publisher's adagents.json to see property details")
58+
print()
59+
60+
61+
async def approach_2_pull():
62+
"""APPROACH 2: Check publisher adagents.json files (when you know which publishers to check)."""
63+
print("=" * 70)
64+
print("APPROACH 2: Pull - Check specific publisher adagents.json files")
65+
print("=" * 70)
66+
print()
67+
68+
# Your agent's URL
69+
agent_url = "https://our-sales-agent.com"
70+
71+
# Publisher domains to check
72+
publisher_domains = [
73+
"nytimes.com",
74+
"wsj.com",
75+
"cnn.com",
76+
"espn.com",
77+
"techcrunch.com",
78+
]
79+
80+
print(f"Checking authorization for {agent_url} across {len(publisher_domains)} publishers...\n")
81+
82+
# Fetch authorization contexts (fetches all in parallel)
83+
contexts = await fetch_agent_authorizations(agent_url, publisher_domains)
84+
85+
# Display results
86+
if not contexts:
87+
print("No authorizations found.")
88+
return
89+
90+
print(f"✅ Authorized for {len(contexts)}/{len(publisher_domains)} publishers:\n")
91+
92+
for domain, ctx in contexts.items():
93+
print(f"{domain}:")
94+
print(f" Property IDs: {ctx.property_ids}")
95+
print(f" Property Tags: {ctx.property_tags}")
96+
print(f" Total Properties: {len(ctx.raw_properties)}")
97+
print()
98+
99+
# Example: Check if specific tags are available
100+
all_tags = set()
101+
for ctx in contexts.values():
102+
all_tags.update(ctx.property_tags)
103+
104+
print(f"📊 Total unique tags across all publishers: {len(all_tags)}")
105+
print(f"Tags: {sorted(all_tags)}")
106+
print()
107+
108+
109+
async def main():
110+
"""Demonstrate both approaches."""
111+
# APPROACH 1: Fast - ask agent what it's authorized for
112+
await approach_1_push()
113+
114+
print("\n" + "=" * 70 + "\n")
115+
116+
# APPROACH 2: Check specific publishers
117+
await approach_2_pull()
118+
119+
120+
async def main_with_connection_pooling():
121+
"""More efficient version using connection pooling for multiple requests."""
122+
import httpx
123+
124+
agent_url = "https://our-sales-agent.com"
125+
publisher_domains = ["nytimes.com", "wsj.com", "cnn.com"]
126+
127+
# Use a shared HTTP client for connection pooling
128+
async with httpx.AsyncClient(
129+
limits=httpx.Limits(max_keepalive_connections=10, max_connections=20)
130+
) as client:
131+
print("Using connection pooling for better performance...\n")
132+
133+
contexts = await fetch_agent_authorizations(agent_url, publisher_domains, client=client)
134+
135+
for domain, ctx in contexts.items():
136+
print(f"{domain}: {len(ctx.property_ids)} properties")
137+
138+
139+
if __name__ == "__main__":
140+
# Run basic example
141+
asyncio.run(main())
142+
143+
# Uncomment to run connection pooling example
144+
# asyncio.run(main_with_connection_pooling())

src/adcp/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@
88
"""
99

1010
from adcp.adagents import (
11+
AuthorizationContext,
1112
domain_matches,
1213
fetch_adagents,
14+
fetch_agent_authorizations,
1315
get_all_properties,
1416
get_all_tags,
1517
get_properties_by_agent,
@@ -178,7 +180,9 @@
178180
"Product",
179181
"Property",
180182
# Adagents validation
183+
"AuthorizationContext",
181184
"fetch_adagents",
185+
"fetch_agent_authorizations",
182186
"verify_agent_authorization",
183187
"verify_agent_for_property",
184188
"domain_matches",

src/adcp/adagents.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,3 +518,125 @@ def get_properties_by_agent(adagents_data: dict[str, Any], agent_url: str) -> li
518518
return [p for p in properties if isinstance(p, dict)]
519519

520520
return []
521+
522+
523+
class AuthorizationContext:
524+
"""Authorization context for a publisher domain.
525+
526+
Attributes:
527+
property_ids: List of property IDs the agent is authorized for
528+
property_tags: List of property tags the agent is authorized for
529+
raw_properties: Raw property data from adagents.json
530+
"""
531+
532+
def __init__(self, properties: list[dict[str, Any]]):
533+
"""Initialize from list of properties.
534+
535+
Args:
536+
properties: List of property dictionaries from adagents.json
537+
"""
538+
self.property_ids: list[str] = []
539+
self.property_tags: list[str] = []
540+
self.raw_properties = properties
541+
542+
# Extract property IDs and tags
543+
for prop in properties:
544+
if not isinstance(prop, dict):
545+
continue
546+
547+
# Extract property ID
548+
prop_id = prop.get("id")
549+
if prop_id and isinstance(prop_id, str):
550+
self.property_ids.append(prop_id)
551+
552+
# Extract tags
553+
tags = prop.get("tags", [])
554+
if isinstance(tags, list):
555+
for tag in tags:
556+
if isinstance(tag, str) and tag not in self.property_tags:
557+
self.property_tags.append(tag)
558+
559+
def __repr__(self) -> str:
560+
return (
561+
f"AuthorizationContext("
562+
f"property_ids={self.property_ids}, "
563+
f"property_tags={self.property_tags})"
564+
)
565+
566+
567+
async def fetch_agent_authorizations(
568+
agent_url: str,
569+
publisher_domains: list[str],
570+
timeout: float = 10.0,
571+
client: httpx.AsyncClient | None = None,
572+
) -> dict[str, AuthorizationContext]:
573+
"""Fetch authorization contexts by checking publisher adagents.json files.
574+
575+
This function discovers what publishers have authorized your agent by fetching
576+
their adagents.json files from the .well-known directory and extracting the
577+
properties your agent can access.
578+
579+
This is the "pull" approach - you query publishers to see if they've authorized you.
580+
For the "push" approach where the agent tells you what it's authorized for,
581+
use the agent's list_authorized_properties endpoint via ADCPClient.
582+
583+
Args:
584+
agent_url: URL of your sales agent
585+
publisher_domains: List of publisher domains to check (e.g., ["nytimes.com", "wsj.com"])
586+
timeout: Request timeout in seconds for each fetch
587+
client: Optional httpx.AsyncClient for connection pooling
588+
589+
Returns:
590+
Dictionary mapping publisher domain to AuthorizationContext.
591+
Only includes domains where the agent is authorized.
592+
593+
Example:
594+
>>> # "Pull" approach - check what publishers have authorized you
595+
>>> contexts = await fetch_agent_authorizations(
596+
... "https://our-sales-agent.com",
597+
... ["nytimes.com", "wsj.com", "cnn.com"]
598+
... )
599+
>>> for domain, ctx in contexts.items():
600+
... print(f"{domain}:")
601+
... print(f" Property IDs: {ctx.property_ids}")
602+
... print(f" Tags: {ctx.property_tags}")
603+
604+
See Also:
605+
ADCPClient.list_authorized_properties: "Push" approach using the agent's API
606+
607+
Notes:
608+
- Silently skips domains where adagents.json is not found or invalid
609+
- Only returns domains where the agent is explicitly authorized
610+
- For production use with many domains, pass a shared httpx.AsyncClient
611+
to enable connection pooling
612+
"""
613+
import asyncio
614+
615+
# Create tasks to fetch all adagents.json files in parallel
616+
async def fetch_authorization_for_domain(
617+
domain: str,
618+
) -> tuple[str, AuthorizationContext | None]:
619+
"""Fetch authorization context for a single domain."""
620+
try:
621+
adagents_data = await fetch_adagents(domain, timeout=timeout, client=client)
622+
623+
# Check if agent is authorized
624+
if not verify_agent_authorization(adagents_data, agent_url):
625+
return (domain, None)
626+
627+
# Get properties for this agent
628+
properties = get_properties_by_agent(adagents_data, agent_url)
629+
630+
# Create authorization context
631+
return (domain, AuthorizationContext(properties))
632+
633+
except (AdagentsNotFoundError, AdagentsValidationError, AdagentsTimeoutError):
634+
# Silently skip domains with missing or invalid adagents.json
635+
return (domain, None)
636+
637+
# Fetch all domains in parallel
638+
tasks = [fetch_authorization_for_domain(domain) for domain in publisher_domains]
639+
results = await asyncio.gather(*tasks)
640+
641+
# Build result dictionary, filtering out None values
642+
return {domain: ctx for domain, ctx in results if ctx is not None}

0 commit comments

Comments
 (0)