Skip to content

Commit ea8bd94

Browse files
committed
feat(directory): support source-aware directory discovery
1 parent d663e54 commit ea8bd94

11 files changed

Lines changed: 522 additions & 18 deletions

File tree

‎README.md‎

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Services and navigating their Offerings.
1313
ODP separates two levels of discovery:
1414

1515
1. An Agent searches the canonical Directory for Services.
16-
2. The Agent inspects a Service's live ODP document and navigates that Service's Collections and
16+
2. For a native ODP source, the Agent inspects the Service's live ODP document and navigates its Collections and
1717
Offerings.
1818

1919
The Directory does not copy every Service catalog. Catalog searches go directly to each Service.
@@ -77,17 +77,36 @@ asyncio.run(main())
7777
an explicit list must be nonempty and distinct. Filters apply to the owning Service's metadata.
7878
Omit the query to browse. The default and maximum result limit are 100.
7979

80-
A Collection is identified by its owning Service origin and case-sensitive Collection ID.
81-
Inspect that Service's live ODP document, then call `ServiceClient.get_collection()` for current
82-
details. The result's `indexed_at` describes Collection freshness; `service.indexed_at` describes
83-
parent freshness. `service.service_id` is the Directory's Service identifier. A Service result can
80+
A Collection is identified by its owning `service.service_id` and case-sensitive Collection ID.
81+
Different OpenAPI documents can share an API origin without being the same Directory Service.
82+
When `service.source.type == "odp"`, inspect that Service's live ODP document, then call
83+
`ServiceClient.get_collection()` for current details. OpenAPI Collections are Directory presentation
84+
groups, not ODP operation targets. The result's `indexed_at` describes Collection freshness;
85+
`service.indexed_at` describes parent freshness. `service.service_id` is the Directory's Service identifier. A Service result can
8486
have `available_through` platform attribution; a Collection's attribution is its owning `service`.
8587

8688
Malformed known results are omitted and reported in `issues` with their original response index.
8789
Unknown future types retain their full JSON in `UnknownResult.raw`; do not treat them as Services
8890
or execute their metadata. Additional fields are available through `additional`. Directory
8991
metadata does not replace inspection of the Service's own document.
9092

93+
Mixed results use `DirectoryIndexedService`, separate from the native `DirectoryService` returned
94+
by `search_services()`. Each mixed Service requires `service_id`, `service_origin`, `name`,
95+
`indexed_at` and `source`. Imported descriptions and languages are optional; missing lists become
96+
empty lists. Imported results do not expose native ODP operations. Native results retain ODP
97+
validation. Unverified execution fields such as `http` and `payment_origins` are not returned.
98+
99+
`DirectorySource` identifies the document used for discovery:
100+
101+
- `type` is `"odp"`, `"openapi"`, or an unknown future string. Unknown formats remain readable
102+
but must not be passed to ODP operations.
103+
- `url` is the exact document URL, including path and query. It can differ from the API origin;
104+
do not reconstruct it from `service_origin`.
105+
- `x402_discovery` records supporting fixed-path x402 discovery, not proof that an endpoint
106+
accepts payments. Advertised protocol evidence remains in `protocols`.
107+
108+
The client does not fetch or execute OpenAPI documents.
109+
91110
Mixed search does not currently offer continuation. Missing `next` does **not** mean every match
92111
was returned. Refine the query or filters when needed. `continue_search(next)` follows one opaque
93112
same-origin reference if the server supplies one; the SDK does not invent continuations. Facets
@@ -105,7 +124,32 @@ are candidate search queries, not resource identifiers.
105124

106125
See the [runnable canonical Directory example](examples/README.md#canonical-directory-discovery).
107126

108-
## Search only Services
127+
### Filter by source
128+
129+
```python
130+
from offering_protocol.directory import (
131+
DirectoryClient,
132+
ResourceSearchRequest,
133+
ServiceFilters,
134+
SuggestionRequest,
135+
)
136+
137+
138+
async def discover_openapi() -> None:
139+
filters = ServiceFilters(sources=["openapi"])
140+
async with DirectoryClient() as directory:
141+
results = await directory.search(ResourceSearchRequest(query="weather", filters=filters))
142+
names = await directory.suggest(SuggestionRequest(prefix="we", filters=filters))
143+
print(results.items, names)
144+
```
145+
146+
Omitting `sources` includes all formats. An explicit list must contain one or both distinct
147+
`"odp"` and `"openapi"` values. Sources are alternatives, combined with other filter categories
148+
using AND. Collections inherit their owning Service's source. Unsupported source filter values
149+
are rejected. Native `search_services()` accepts the filter but remains ODP-only: an OpenAPI-only
150+
filter returns no native matches.
151+
152+
## Search only native ODP Services
109153

110154
`DirectoryClient` uses the one canonical production Directory. Pass `Environment.SANDBOX` when
111155
working against InFlow's sandbox; the endpoint itself is not configurable.
@@ -143,6 +187,9 @@ and trust protocols. Use `suggest_services()` to discover Service-only keyword c
143187

144188
### API migration
145189

190+
- Mixed results use `DirectoryIndexedService` with required source metadata. Missing sources are
191+
reported as item issues, not assumed to be ODP. Native Service-only models are unchanged.
192+
- Imported `description` and `language` can be `None`; check the source before ODP navigation.
146193
- Service-only `search()` calls become `search_services()`, and `continue_search()` calls become
147194
`continue_search_services()`.
148195
- Aggregating `search_services(request, options)` calls become `collect_services(request, options)`.
@@ -385,8 +432,9 @@ Protocol models preserve additive members in `model.additional` and round-trip t
385432
`model.to_dict()`. Parsing remains strict for normative constraints and fields that prohibit unknown
386433
members.
387434

388-
Directory records retain additional metadata such as branding and MCP endpoints. These are discovery
389-
hints, not authorization or authoritative routing data. The default Agent factory uses the record's
435+
Native Service-only records retain additional metadata such as branding and MCP endpoints. Mixed
436+
results omit unverified execution fields. Directory metadata is not authorization or authoritative
437+
routing data. The default Agent factory uses the native record's
390438
`service_origin` and retrieves that Service's own document before making catalog requests.
391439

392440
Handle the narrowest error that the application can act upon and use the role's base error for the

‎examples/README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ uv run python examples/directory.py sandbox weather
3939
Use `production` for the production Directory. Omit `weather` to browse. This example requires a
4040
deployment with `/v1/directory/search`. It requests at most five mixed results, displays Service
4141
and Collection names, reports unusable or unknown results, and retrieves Collection details only
42-
after inspecting the owning Service's advertised anonymous support. It does not enroll, pay or
42+
for ODP sources after inspecting the owning Service's advertised anonymous support. Imported
43+
Collections print their exact document URL without ODP calls. It does not enroll, pay or
4344
invoke Actions. Unlike the local Service example above, this uses the real Directory.
4445
The server's bounded result list does not promise every matching result is included.

‎examples/directory.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,15 @@ async def discover(environment: Environment, query: str) -> None:
2525
for item in response.items:
2626
if isinstance(item, ServiceResult):
2727
print(f"Service: {item.service.name} ({item.service.service_origin})")
28+
print(f"Discovery document: {item.service.source.url}")
2829
elif isinstance(item, CollectionResult):
2930
print(
3031
f"Collection: {item.collection.name} "
3132
f"({item.collection.id}, through {item.service.service_origin})"
3233
)
34+
if item.service.source.type != "odp":
35+
print(f"Discovery document: {item.service.source.url}")
36+
continue
3337
async with ServiceClient(item.service.service_origin) as service:
3438
inspection = await service.inspect()
3539
if any(

‎scripts/verify-consumer.sh‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,14 @@ assert directory.__name__ == "offering_protocol.directory"
8383
assert service.__name__ == "offering_protocol.service"
8484
request = directory.ResourceSearchRequest(types=["collection"])
8585
assert request.to_dict() == {"types": ["collection"]}
86+
filters = directory.ServiceFilters(sources=["openapi"])
87+
assert filters.to_dict() == {"sources": ["openapi"]}
88+
source = directory.DirectorySource(type="openapi", url="https://example.com/api.json", x402_discovery=False)
89+
record = directory.DirectoryIndexedService(
90+
indexed_at="2026-09-23T12:00:00Z", name="Example", service_id="example",
91+
service_origin="https://example.com", source=source,
92+
)
93+
assert record.source.url == source.url and record.operations == []
8694
document = core.parse_service_document(
8795
b'{"description":"Consumer smoke test","http":{"endpoint_base":"/odp"},'
8896
b'"language":"en","localizations":["en"],"name":"Consumer",'

‎src/offering_protocol/directory/__init__.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@
88
from offering_protocol.directory.models import (
99
CollectionResult,
1010
CollectionSummary,
11+
DirectoryIndexedService,
1112
DirectoryIssue,
1213
DirectoryResult,
1314
DirectoryService,
15+
DirectorySource,
1416
Environment,
1517
Facet,
1618
Facets,
@@ -42,10 +44,12 @@
4244
"CollectionSummary",
4345
"DirectoryClient",
4446
"DirectoryError",
47+
"DirectoryIndexedService",
4548
"DirectoryIssue",
4649
"DirectoryRequestError",
4750
"DirectoryResult",
4851
"DirectoryService",
52+
"DirectorySource",
4953
"Environment",
5054
"Facet",
5155
"Facets",

‎src/offering_protocol/directory/client.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,10 @@ def _validate_search_request(request: SearchRequest) -> None:
332332
raise DirectoryError(
333333
"query must contain at most 512 characters without surrounding whitespace"
334334
)
335+
if request.filters is not None and request.filters.sources is not None:
336+
sources = request.filters.sources
337+
if not 1 <= len(sources) <= 2 or len(set(sources)) != len(sources):
338+
raise DirectoryError("sources must contain one or two distinct odp or openapi values")
335339
if request.filters is not None and (
336340
len(request.filters.keywords) > 32
337341
or any(not keyword or len(keyword) > 64 for keyword in request.filters.keywords)

‎src/offering_protocol/directory/models.py‎

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ class ServiceFilters(OdpModel):
5050
keywords: list[str] = Field(default_factory=list)
5151
operations: list[OperationFilter] = Field(default_factory=list)
5252
payments: list[PaymentFilter] = Field(default_factory=list)
53+
sources: list[Literal["odp", "openapi"]] | None = None
5354
trust: list[TrustProtocol] = Field(default_factory=list)
5455

5556

@@ -84,6 +85,30 @@ def service_id(self) -> str | None:
8485
return value if isinstance(value, str) else None
8586

8687

88+
class DirectorySource(OdpModel):
89+
type: str
90+
url: str
91+
x402_discovery: bool
92+
93+
94+
class DirectoryIndexedService(OdpModel):
95+
description: str | None = None
96+
documentation_url: str | None = None
97+
indexed_at: str
98+
keywords: list[str] = Field(default_factory=list)
99+
language: str | None = None
100+
localizations: list[str] = Field(default_factory=list)
101+
name: str
102+
operations: list[OperationDescriptor] = Field(default_factory=list)
103+
protocols: ServiceProtocols | None = None
104+
service_id: str
105+
service_origin: str
106+
source: DirectorySource
107+
status_url: str | None = None
108+
support_url: str | None = None
109+
website_url: str | None = None
110+
111+
87112
class ServiceReference(OdpModel):
88113
service_id: str
89114
service_origin: str
@@ -98,14 +123,14 @@ class CollectionSummary(OdpModel):
98123

99124
class ServiceResult(OdpModel):
100125
type: Literal["service"]
101-
service: DirectoryService
126+
service: DirectoryIndexedService
102127
indexed_at: str
103128
available_through: ServiceReference | None = None
104129

105130

106131
class CollectionResult(OdpModel):
107132
type: Literal["collection"]
108-
service: DirectoryService
133+
service: DirectoryIndexedService
109134
indexed_at: str
110135
collection: CollectionSummary
111136

‎src/offering_protocol/directory/results.py‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
ServiceResult,
2121
UnknownResult,
2222
)
23+
from offering_protocol.directory.sources import read_source, validate_imported_service
2324

2425
_OBJECT = TypeAdapter(dict[str, JsonValue])
2526

@@ -56,7 +57,7 @@ def _result(value: JsonValue) -> DirectoryResult:
5657
_text(service, "service_id", 128)
5758
_origin(service)
5859
_timestamp(service)
59-
candidate = dict(service)
60+
source = read_source(service.get("source"))
6061
for name in (
6162
"branding",
6263
"http",
@@ -65,7 +66,18 @@ def _result(value: JsonValue) -> DirectoryResult:
6566
"payment_origins",
6667
"search_capabilities",
6768
):
68-
candidate.pop(name, None)
69+
service.pop(name, None)
70+
if source.type == "odp":
71+
_native_service(service)
72+
else:
73+
validate_imported_service(service)
74+
raw["service"] = service
75+
return _finish_result(raw, kind)
76+
77+
78+
def _native_service(service: dict[str, JsonValue]) -> None:
79+
candidate = dict(service)
80+
candidate.pop("source")
6981
document = parse_agent_service_document(
7082
json.dumps({**candidate, "odp_version": "1.0", "http": {"endpoint_base": "/"}})
7183
)
@@ -74,7 +86,9 @@ def _result(value: JsonValue) -> DirectoryResult:
7486
service.pop("protocols", None)
7587
else:
7688
service["protocols"] = document.protocols.to_dict()
77-
raw["service"] = service
89+
90+
91+
def _finish_result(raw: dict[str, JsonValue], kind: str) -> DirectoryResult:
7892
if kind == "service":
7993
if "available_through" in raw:
8094
reference = _OBJECT.validate_python(raw["available_through"])
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
"""Source-aware metadata validation for mixed Directory results."""
2+
3+
from __future__ import annotations
4+
5+
from ipaddress import ip_address
6+
from urllib.parse import urlsplit
7+
8+
from pydantic import JsonValue, TypeAdapter
9+
10+
from offering_protocol.core import derive_service_origin, validate_value
11+
from offering_protocol.directory.addresses import is_public
12+
from offering_protocol.directory.models import DirectorySource
13+
14+
_OBJECT = TypeAdapter(dict[str, JsonValue])
15+
16+
17+
def read_source(value: JsonValue) -> DirectorySource:
18+
source = DirectorySource.model_validate(value, strict=True)
19+
if not source.type.strip() or len(source.type) > 128:
20+
raise ValueError("source.type must be a nonempty string of at most 128 characters")
21+
url = source.url
22+
if (
23+
len(url) > 2048
24+
or not url.lower().startswith("https://")
25+
or any(character.isspace() for character in url)
26+
or "#" in url
27+
):
28+
raise ValueError("source.url must be an HTTPS document URL without a fragment")
29+
origin = derive_service_origin(url)
30+
host = urlsplit(origin).hostname or ""
31+
if host.rstrip(".") == "localhost" or host.rstrip(".").endswith(".localhost"):
32+
raise ValueError("source.url must have a public host")
33+
try:
34+
address = ip_address(host)
35+
except ValueError:
36+
return source
37+
if not is_public(address):
38+
raise ValueError("source.url must have a public host")
39+
return source
40+
41+
42+
def validate_imported_service(service: dict[str, JsonValue]) -> None:
43+
name = service.get("name")
44+
if not isinstance(name, str) or not name.strip() or len(name) > 128:
45+
raise ValueError(
46+
"Imported Service name must be a nonempty string of at most 128 characters"
47+
)
48+
for field in (
49+
"description",
50+
"documentation_url",
51+
"language",
52+
"status_url",
53+
"support_url",
54+
"website_url",
55+
):
56+
if field in service and not isinstance(service[field], str):
57+
raise ValueError(f"{field} must be a string")
58+
service.pop("operations", None)
59+
if "protocols" not in service:
60+
return
61+
protocols = _OBJECT.validate_python(service["protocols"])
62+
retained: dict[str, JsonValue] = {}
63+
for category, known, schema in (
64+
("enrollment", {"aep"}, "enrollment-protocol.schema.json"),
65+
("payments", {"mpp", "x402"}, "payment-protocol.schema.json"),
66+
("trust", {"tap"}, "trust-protocol.schema.json"),
67+
):
68+
if category not in protocols:
69+
continue
70+
values = protocols[category]
71+
if not isinstance(values, list) or not values:
72+
raise ValueError(f"protocols.{category} must be a nonempty array")
73+
selected: list[JsonValue] = []
74+
names: set[str] = set()
75+
for value in values:
76+
descriptor = _OBJECT.validate_python(value)
77+
name = descriptor.get("name")
78+
if not isinstance(name, str) or not name.strip() or len(name) > 128:
79+
raise ValueError(
80+
"Protocol name must be a nonempty string of at most 128 characters"
81+
)
82+
if name not in known:
83+
continue
84+
if name in names:
85+
raise ValueError(f"Duplicate {category} descriptor")
86+
names.add(name)
87+
validate_value(descriptor, schema, category)
88+
selected.append(descriptor)
89+
if selected:
90+
retained[category] = selected
91+
service["protocols"] = retained

0 commit comments

Comments
 (0)