Skip to content

Commit decbcdf

Browse files
authored
fix(adagents): normalize agent matching and expose listing lookup (#1329)
1 parent 3fd42a1 commit decbcdf

6 files changed

Lines changed: 189 additions & 53 deletions

File tree

‎docs/adagents-discovery.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Publisher agent discovery
2+
3+
Agent lookup treats host case and explicit default ports as equivalent, and
4+
continues to match HTTP and HTTPS URLs. Paths remain case-sensitive; trailing
5+
slashes, query strings and fragments do not affect matching. Non-default ports
6+
remain significant.
7+
8+
Use the listing helper alongside property resolution to distinguish an agent
9+
that is listed but whose selectors match no properties from an unlisted agent:
10+
11+
```python
12+
from adcp import find_authorized_agent_entries, get_properties_by_agent
13+
14+
entries = find_authorized_agent_entries(document, "https://sales.example.com/mcp")
15+
properties = get_properties_by_agent(document, "https://sales.example.com/mcp")
16+
listed_but_unbound = bool(entries) and not properties
17+
```
18+
19+
Malformed sibling entries and non-string or empty URL values are skipped during
20+
lookup. `validate_adagents_structure(document)` reports invalid URL types through
21+
its existing `missing_url` diagnostic. The listing helper returns matching entry
22+
objects in source order and does not modify the document.

‎src/adcp/__init__.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ def _resolve_version() -> str:
158158
"fetch_agent_authorizations",
159159
"fetch_agent_authorizations_from_directory",
160160
"filter_revoked_selectors",
161+
"find_authorized_agent_entries",
161162
"get_all_properties",
162163
"get_all_tags",
163164
"get_properties_by_agent",
@@ -1555,6 +1556,7 @@ def get_adcp_version() -> str:
15551556
"fetch_agent_authorizations",
15561557
"fetch_agent_authorizations_from_directory",
15571558
"filter_revoked_selectors",
1559+
"find_authorized_agent_entries",
15581560
"validate_adagents_domain",
15591561
"validate_adagents_structure",
15601562
"verify_agent_authorization",
@@ -1803,6 +1805,7 @@ def get_adcp_version() -> str:
18031805
fetch_agent_authorizations,
18041806
fetch_agent_authorizations_from_directory,
18051807
filter_revoked_selectors,
1808+
find_authorized_agent_entries,
18061809
get_all_properties,
18071810
get_all_tags,
18081811
get_properties_by_agent,

‎src/adcp/adagents.py‎

Lines changed: 53 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -456,17 +456,60 @@ def _resolve_well_known_redirect_url(
456456

457457

458458
def normalize_url(url: str) -> str:
459-
"""Normalize URL by removing protocol and trailing slash.
459+
"""Return a protocol-agnostic URL key with host case/default ports normalized.
460460
461-
Args:
462-
url: URL to normalize
463-
464-
Returns:
465-
Normalized URL (domain/path without protocol or trailing slash)
461+
Paths remain case-sensitive. Query strings and fragments are ignored, and
462+
trailing path slashes are removed, preserving the existing matching rules.
463+
Only the default port for the supplied scheme is removed (80 for HTTP,
464+
443 for HTTPS); non-default ports remain significant.
466465
"""
467466
parsed = urlparse(url)
468-
normalized = parsed.netloc + parsed.path
469-
return normalized.rstrip("/")
467+
authority = parsed.netloc
468+
if parsed.hostname is not None:
469+
host = parsed.hostname.lower()
470+
if ":" in host:
471+
host = f"[{host}]"
472+
port = parsed.port
473+
if port is not None and (parsed.scheme.lower(), port) not in {("http", 80), ("https", 443)}:
474+
host += f":{port}"
475+
userinfo = authority.rsplit("@", 1)[0] + "@" if "@" in authority else ""
476+
authority = userinfo + host
477+
return (authority + parsed.path).rstrip("/")
478+
479+
480+
def find_authorized_agent_entries(
481+
adagents_data: dict[str, Any], agent_url: str
482+
) -> list[dict[str, Any]]:
483+
"""Return all entries listing an agent, independently of property binding.
484+
485+
A non-empty result distinguishes a listed agent whose selectors resolve to
486+
no properties from an unlisted agent. Entries are returned in document order
487+
without copying or modifying them. Malformed sibling entries and URL values
488+
are skipped. Matching preserves HTTP/HTTPS equivalence and path semantics.
489+
490+
Raises AdagentsValidationError if the document is not an object with an
491+
``authorized_agents`` array.
492+
"""
493+
if not isinstance(adagents_data, dict):
494+
raise AdagentsValidationError("adagents_data must be a dictionary")
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+
normalized_agent_url = normalize_url(agent_url)
499+
matches: list[dict[str, Any]] = []
500+
for agent in authorized_agents:
501+
if not isinstance(agent, dict):
502+
continue
503+
entry_url = agent.get("url")
504+
if not isinstance(entry_url, str) or not entry_url.strip():
505+
continue
506+
try:
507+
normalized_entry_url = normalize_url(entry_url)
508+
except ValueError:
509+
continue
510+
if normalized_entry_url == normalized_agent_url:
511+
matches.append(agent)
512+
return matches
470513

471514

472515
def domain_matches(property_domain: str, agent_domain_pattern: str) -> bool:
@@ -584,30 +627,7 @@ def verify_agent_authorization(
584627
- Implements AdCP domain matching rules
585628
- Agent URLs are matched ignoring protocol and trailing slash
586629
"""
587-
# Validate structure
588-
if not isinstance(adagents_data, dict):
589-
raise AdagentsValidationError("adagents_data must be a dictionary")
590-
591-
authorized_agents = adagents_data.get("authorized_agents")
592-
if not isinstance(authorized_agents, list):
593-
raise AdagentsValidationError("adagents.json must have 'authorized_agents' array")
594-
595-
# Normalize the agent URL for comparison
596-
normalized_agent_url = normalize_url(agent_url)
597-
598-
# Check each authorized agent
599-
for agent in authorized_agents:
600-
if not isinstance(agent, dict):
601-
continue
602-
603-
agent_url_from_json = agent.get("url", "")
604-
if not agent_url_from_json:
605-
continue
606-
607-
# Match agent URL (protocol-agnostic)
608-
if normalize_url(agent_url_from_json) != normalized_agent_url:
609-
continue
610-
630+
for agent in find_authorized_agent_entries(adagents_data, agent_url):
611631
# Found matching agent - now check properties
612632
properties = agent.get("properties")
613633

@@ -1915,12 +1935,7 @@ def _resolve_properties_for_agent(
19151935
permissive_bare_top_level: bool,
19161936
) -> list[dict[str, Any]]:
19171937
"""Implementation shared by strict and opt-in permissive property resolution."""
1918-
if not isinstance(adagents_data, dict):
1919-
raise AdagentsValidationError("adagents_data must be a dictionary")
1920-
1921-
authorized_agents = adagents_data.get("authorized_agents")
1922-
if not isinstance(authorized_agents, list):
1923-
raise AdagentsValidationError("adagents.json must have 'authorized_agents' array")
1938+
matches = find_authorized_agent_entries(adagents_data, agent_url)
19241939

19251940
top_level_properties = adagents_data.get("properties", [])
19261941
if not isinstance(top_level_properties, list):
@@ -1937,23 +1952,8 @@ def _resolve_properties_for_agent(
19371952
)
19381953
]
19391954

1940-
normalized_agent_url = normalize_url(agent_url)
1941-
19421955
domain_index = _build_domain_index(revoked_top_level)
19431956

1944-
matches: list[dict[str, Any]] = []
1945-
for agent in authorized_agents:
1946-
if not isinstance(agent, dict):
1947-
continue
1948-
1949-
agent_url_from_json = agent.get("url", "")
1950-
if not agent_url_from_json:
1951-
continue
1952-
1953-
if normalize_url(agent_url_from_json) != normalized_agent_url:
1954-
continue
1955-
matches.append(agent)
1956-
19571957
resolved: list[dict[str, Any]] = []
19581958
for agent in matches:
19591959
# revoked_top_level pre-filters revoked domains from the per-domain

‎tests/fixtures/public_api_snapshot.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -678,6 +678,7 @@
678678
"fetch_agent_authorizations",
679679
"fetch_agent_authorizations_from_directory",
680680
"filter_revoked_selectors",
681+
"find_authorized_agent_entries",
681682
"format_is_supported",
682683
"formats_are_equivalent",
683684
"generate_webhook_challenge_value",

‎tests/test_adagents_matching.py‎

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"""Regression contracts for protocol-agnostic agent listing and resolution."""
2+
3+
from copy import deepcopy
4+
from typing import Any
5+
6+
import pytest
7+
8+
from adcp import find_authorized_agent_entries
9+
from adcp.adagents import (
10+
get_properties_by_agent,
11+
normalize_url,
12+
resolve_properties_for_agent,
13+
validate_adagents_structure,
14+
verify_agent_authorization,
15+
)
16+
17+
AGENT = "https://sales.example.com/mcp"
18+
19+
20+
def document(url: Any = AGENT, tags: list[str] | None = None) -> dict[str, Any]:
21+
return {
22+
"authorized_agents": [
23+
{
24+
"url": url,
25+
"authorized_for": "Display",
26+
"authorization_type": "property_tags",
27+
"property_tags": ["all"] if tags is None else tags,
28+
}
29+
],
30+
"properties": [
31+
{
32+
"property_id": "site",
33+
"property_type": "website",
34+
"name": "Site",
35+
"identifiers": [{"type": "domain", "value": "example.com"}],
36+
"tags": ["all"],
37+
}
38+
],
39+
}
40+
41+
42+
@pytest.mark.parametrize(
43+
"url",
44+
[
45+
"https://SALES.Example.com:443/mcp/",
46+
"http://sales.example.com:80/mcp",
47+
"https://sales.example.com/mcp/?q=x#fragment",
48+
],
49+
)
50+
def test_normalization_retains_protocol_and_path_contract(url: str) -> None:
51+
assert normalize_url(url) == "sales.example.com/mcp"
52+
doc = document(url)
53+
assert find_authorized_agent_entries(doc, AGENT) == doc["authorized_agents"]
54+
assert get_properties_by_agent(doc, AGENT) == doc["properties"]
55+
assert verify_agent_authorization(doc, AGENT)
56+
57+
58+
@pytest.mark.parametrize(
59+
"url",
60+
[
61+
"https://sales.example.com:80/mcp",
62+
"http://sales.example.com:443/mcp",
63+
"https://sales.example.com:8443/mcp",
64+
"https://sales.example.com/MCP",
65+
"https://sales.example.com/other",
66+
],
67+
)
68+
def test_paths_and_nondefault_ports_remain_significant(url: str) -> None:
69+
assert find_authorized_agent_entries(document(url), AGENT) == []
70+
71+
72+
@pytest.mark.parametrize(
73+
"url",
74+
[None, [], [AGENT], 42, {}, "", " ", "https://sales.example.com:bad/mcp", "https://[bad/mcp"],
75+
)
76+
def test_invalid_sibling_cannot_break_lookup(url: Any) -> None:
77+
doc = document(url)
78+
valid = document()["authorized_agents"][0]
79+
doc["authorized_agents"].extend([None, 1, valid])
80+
before = deepcopy(doc)
81+
assert find_authorized_agent_entries(doc, AGENT) == [valid]
82+
assert verify_agent_authorization(doc, AGENT)
83+
assert get_properties_by_agent(doc, AGENT) == doc["properties"]
84+
assert resolve_properties_for_agent(doc, AGENT, mode="permissive") == doc["properties"]
85+
assert doc == before
86+
if not isinstance(url, str) or not url:
87+
assert any(error.kind == "missing_url" for error in validate_adagents_structure(doc).errors)
88+
89+
90+
def test_listing_distinguishes_unbound_and_unlisted_and_retains_duplicates() -> None:
91+
unbound = document(tags=["missing"])
92+
assert get_properties_by_agent(unbound, AGENT) == []
93+
assert find_authorized_agent_entries(unbound, AGENT)
94+
unlisted = document("https://other.example.com/mcp")
95+
assert get_properties_by_agent(unlisted, AGENT) == []
96+
assert find_authorized_agent_entries(unlisted, AGENT) == []
97+
unbound["authorized_agents"].append(document()["authorized_agents"][0])
98+
matches = find_authorized_agent_entries(unbound, AGENT)
99+
assert matches == unbound["authorized_agents"]
100+
assert matches[0] is unbound["authorized_agents"][0]
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
"""The public listing helper retains typed JSON entries for adopter code."""
2+
3+
from typing import Any
4+
5+
from adcp import find_authorized_agent_entries
6+
7+
8+
def is_listed(document: dict[str, Any], agent_url: str) -> bool:
9+
entries: list[dict[str, Any]] = find_authorized_agent_entries(document, agent_url)
10+
return bool(entries)

0 commit comments

Comments
 (0)