Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/source-aware-directory.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@offering-protocol/directory": minor
---

Support source-aware Directory discovery and source filters for search and suggestions. Mixed results include the exact discovery document URL and allow imported metadata to be absent. Native ODP Service search retains its existing validation contract.
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,14 @@ Use `npm install` or `yarn add` if those are the package managers in your applic

For general Directory discovery, use [`directory.search()`](./packages/directory/README.md#search-the-directory)
to receive typed Service and Collection results, and `directory.suggest()` to obtain matching names.
Mixed results include native ODP and imported OpenAPI sources. Check `result.service.source.type`
before using an ODP client; imported results provide their exact document URL in `source.url`.
Search and suggestions accept `filters.sources` to select `odp`, `openapi`, or both.
The Directory indexes submitted Collections, not every Offering in a Service's catalog. Its mixed
search returns at most 100 results and currently has no continuation; refine queries to narrow results.

`createOdpAgent` searches the canonical directory and then searches the live catalogs of matching
Services. This orchestration uses `searchServices()`, the Service-only API, and does not interpret
Services. This orchestration uses `searchServices()`, the native ODP Service-only API, and does not interpret
mixed results as Services. Directory results never pretend to contain complete Service catalogs.

```ts
Expand Down
2 changes: 1 addition & 1 deletion examples/odp-agent-discovery/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,5 @@ Unreachable URLs are skipped. The example calls `directory.search()` and branche
`type`. For a Service result, it prints the mock entry, validated ODP Service document, first terse
Offering page, and full details for the first Offering. For a Collection result, it prints the entry,
inspects the owning Service, and fetches the live Collection using that Service and Collection ID.
Unknown result types are reported without contacting their contents. The marketplace example
Unknown result types and non-ODP sources are reported without contacting their contents. The marketplace example
provides Collections, so run it to exercise both known result types.
4 changes: 4 additions & 0 deletions examples/odp-agent-discovery/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ for await (const result of directory.search().items) {
continue;
}
const service = result.service;
if (service.source.type !== "odp") {
print("Non-ODP discovery document", service.source);
continue;
}
discovered += 1;
const serviceUrl = mock.serviceUrlFor(service.service_origin);
const client = createOdpServiceClient({
Expand Down
1 change: 1 addition & 0 deletions examples/odp-agent-discovery/src/mock-directory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export async function createMockDirectory(serviceUrls: string[]): Promise<MockDi
const document = inspection.document;
const serviceOrigin = `https://service-${index + 1}.mock-directory.example`;
const service: DirectoryIndexedService = {
source: { type: "odp", url: `${serviceOrigin}/.well-known/odp`, x402_discovery: false },
service_id: randomUUID(),
service_origin: serviceOrigin,
name: document.name,
Expand Down
53 changes: 44 additions & 9 deletions packages/directory/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ A fetch-compatible `transport` can be injected for testing without changing the

## Search the Directory

`search()` returns Services and explicitly indexed Collections. It searches cached names,
`search()` returns native ODP Services, imported OpenAPI Services, and indexed Collections. It searches cached names,
descriptions, and Service keywords; it does not crawl catalogs or search individual Offerings.
Omit `types` to include both result types, or pass `["service"]` or `["collection"]`.

Expand All @@ -27,6 +27,10 @@ import { createDirectoryClient } from "@offering-protocol/directory";

const directory = createDirectoryClient();
for await (const result of directory.search({ query: "weather", limit: 25 }).items) {
if (result.type !== "unknown" && result.service.source.type !== "odp") {
showDiscoveryDocument(result.service.source);
continue;
}
switch (result.type) {
case "service":
useService(result.service.service_origin);
Expand All @@ -41,12 +45,36 @@ for await (const result of directory.search({ query: "weather", limit: 25 }).ite
}
```

The example's `useService`, `useCollection`, and `reportUnsupportedType` functions represent your
application's handling of a result. A Collection ID is scoped to its owning Service: two Services
can both have a Collection named `weather`. Preserve the origin and ID together. Inspect the
Service's live ODP document before fetching authoritative Collection details through
The example's `showDiscoveryDocument`, `useService`, `useCollection`, and `reportUnsupportedType`
functions represent your application's handling of a result. Preserve `service_id` and the Collection
ID together: multiple imported Services can share an API origin but use different source documents.
For an ODP result, inspect the Service's live ODP document before fetching Collection details through
`createOdpServiceClient` from `@offering-protocol/agent`.

Every known result includes `service.source`:

```json
{
"type": "openapi",
"url": "https://docs.example.com/v1/openapi.json?revision=2",
"x402_discovery": true
}
```

`type` identifies the discovery format. `url` preserves the exact primary document URL, including
its path and query; it can be hosted on a different origin than `service_origin`. `x402_discovery`
indicates detected supporting `/.well-known/x402.json` metadata, not proof that an endpoint accepts
x402. These fields are required; the client does not infer ODP when `source` is missing.

For OpenAPI and unfamiliar source types, description, language, localizations, and keywords can be
absent. The client does not manufacture these values or expose ODP `operations` on imported results.
Unknown source types remain displayable; do not send them to an ODP Agent client. This package does
not download or execute OpenAPI documents. An imported Collection is a Directory presentation group:
use its parent's `source.url` for discovery, not ODP `getCollection(collection.id)`.

Use `filters: { sources: ["odp"] }` for native ODP results or `["openapi"]` for imports. Omit `sources`
for all sources, or specify both. An empty list, duplicates, and unsupported filter values are rejected.

Each known result carries `service` metadata and `indexed_at`. For a Collection, the outer timestamp
describes its indexed metadata; `service.indexed_at` describes its parent. A Service result can have
`available_through`, a platform reference with `service_id`, `service_origin`, and optional `name`.
Expand All @@ -69,11 +97,17 @@ Known result types are validated; malformed entries appear in the page's `issues
original indexes and are omitted from `items`. An unfamiliar type is instead preserved as
`{ type: "unknown", resource_type, raw }`. Do not treat its unvalidated `raw` content as a Service
or automatically follow URLs inside it. Additional fields on known results are tolerated. Nested
Service metadata uses the validation policy documented below.
Service metadata uses strict ODP validation when `source.type` is `odp`. Imported metadata is checked
for its declared JSON types without requiring an ODP Service Document. Recognized protocol descriptors
are validated and unknown protocol names are ignored. Unvalidated ODP document fields such as `http`
and `mcp` are not exposed. Reading results never contacts their source documents or endpoints.

## Search for Services only

`searchServices()` covers cached Service metadata, not Collections or Service catalogs. Filter values within one
`searchServices()` covers native ODP Service metadata, not imported Services, Collections, or catalogs.
To list Services across source formats, use `search({ types: ["service"] })`. A source filter does
not broaden `searchServices()` beyond ODP; filtering it to OpenAPI returns no matches.
Filter values within one
category use OR semantics; different categories combine with AND semantics. The initial page can
include facets for keywords, enrollment protocols, payment protocols, individual protocol payment
options, trust protocols, and ODP operation descriptors.
Expand Down Expand Up @@ -142,13 +176,14 @@ substring search, despite the input parameter being named `prefix`. For example,
const names = await directory.suggest({
prefix: "we",
limit: 10,
filters: { payments: [{ name: "mpp", options: ["inflow"] }] }
filters: { sources: ["openapi"], payments: [{ name: "mpp", options: ["inflow"] }] }
});
// Use a selected name as the query for directory.search().
```

The endpoint is `POST /v1/directory/suggestions`. Optional `filters` use the same structure as search,
including keywords, AEP, ODP operations, payments, and trust. Collection filters apply to their owning
including sources, keywords, AEP, ODP operations, payments, and trust. An ODP operation filter does
not match an imported Service simply because it has OpenAPI endpoints. Collection filters apply to their owning
Service. Suggestions are deduplicated search strings, not
resource identifiers. They do not tell you which target type supplied each name. The server ranks
names by the total number of matching index rows, then alphabetically, and returns at most 25.
Expand Down
130 changes: 127 additions & 3 deletions packages/directory/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export interface DirectoryClientOptions {
}

export interface DirectoryServiceFilters {
sources?: DirectorySourceType[];
enrollment?: EnrollmentProtocol[];
keywords?: string[];
operations?: Array<{
Expand Down Expand Up @@ -56,8 +57,30 @@ export interface DirectoryServiceReference extends Record<string, unknown> {
name?: string;
}

export interface DirectoryIndexedService extends DirectoryService {
export type DirectorySourceType = "odp" | "openapi";

export interface DirectorySource extends Record<string, unknown> {
type: string;
url: string;
x402_discovery: boolean;
}

export interface DirectoryIndexedService extends Record<string, unknown> {
service_id: string;
service_origin: string;
source: DirectorySource;
name: string;
indexed_at: string;
description?: string;
documentation_url?: string;
language?: string;
localizations?: string[];
keywords?: string[];
operations?: OperationDescriptor[];
protocols?: ServiceProtocols;
status_url?: string;
support_url?: string;
website_url?: string;
}

export interface DirectoryServiceResult extends Record<string, unknown> {
Expand Down Expand Up @@ -528,9 +551,11 @@ function parseResult(value: unknown): DirectoryResult {
if (type !== "service" && type !== "collection")
return { type: "unknown", resource_type: type, raw: { ...object } };
const serviceObject = requireObject(object["service"], "service");
const source = parseSource(serviceObject["source"]);
const service: DirectoryIndexedService = {
...parseService(serviceObject),
service_id: requireText(serviceObject["service_id"], "service_id", 1, 128)
...(source.type === "odp" ? parseService(serviceObject) : parseImportedService(serviceObject)),
service_id: requireText(serviceObject["service_id"], "service_id", 1, 128),
source
};
const indexedAt = parseIndexedAt(object["indexed_at"]);
if (type === "service") {
Expand Down Expand Up @@ -563,6 +588,102 @@ function parseResult(value: unknown): DirectoryResult {
};
}

function parseSource(value: unknown): DirectorySource {
const object = requireObject(value, "source");
const type = requireText(object["type"], "source.type", 1, 128);
const address = requireText(object["url"], "source.url", 1, 2048);
const url = new URL(address);
if (
url.protocol !== "https:" ||
url.username !== "" ||
url.password !== "" ||
address.includes("#") ||
isPrivateHost(url.hostname)
)
throw new TypeError(
"source.url must be a public HTTPS document URL without credentials or a fragment"
);
const discovery = object["x402_discovery"];
if (typeof discovery !== "boolean")
throw new TypeError("source.x402_discovery must be a boolean");
return { ...object, type, url: address, x402_discovery: discovery };
}

function parseImportedService(
object: Record<string, unknown>
): Pick<DirectoryIndexedService, "service_origin" | "name" | "indexed_at"> &
Record<string, unknown> {
const reference = parseServiceReference(object);
const normalized = { ...object };
for (const member of [...UNVERIFIED_MEMBERS, "operations", "protocols"])
delete normalized[member];
for (const field of [
"description",
"documentation_url",
"language",
"status_url",
"support_url",
"website_url"
]) {
if (object[field] !== undefined && typeof object[field] !== "string")
throw new TypeError(`${field} must be a string`);
}
for (const field of ["keywords", "localizations"]) {
const value = object[field];
if (value !== undefined) {
if (!Array.isArray(value)) throw new TypeError(`${field} must be an array of strings`);
const entries: unknown[] = value;
normalized[field] = entries.map((item) => {
if (typeof item !== "string") throw new TypeError(`${field} must be an array of strings`);
return item;
});
}
}
const protocols =
object["protocols"] === undefined ? undefined : parseImportedProtocols(object["protocols"]);
return {
...normalized,
service_origin: reference.service_origin,
name: requireText(object["name"], "name", 1, 128),
indexed_at: parseIndexedAt(object["indexed_at"]),
...(protocols === undefined ? {} : { protocols })
};
}

function parseImportedProtocols(value: unknown): ServiceProtocols {
const object = requireObject(value, "protocols");
const enrollment = recognizedDescriptors(object["enrollment"], ["aep"], parseEnrollment);
const payments = recognizedDescriptors(object["payments"], ["mpp", "x402"], parsePayment);
const trust = recognizedDescriptors(object["trust"], ["tap"], parseTrust);
const result: ServiceProtocols = {};
if (enrollment[0] !== undefined) result.enrollment = [enrollment[0]];
if (trust[0] !== undefined) result.trust = [trust[0]];
if (payments[0] !== undefined)
result.payments = payments[1] === undefined ? [payments[0]] : [payments[0], payments[1]];
return result;
}

function recognizedDescriptors<Value>(
value: unknown,
names: string[],
parse: (value: unknown) => Value
): Value[] {
if (value === undefined) return [];
if (!Array.isArray(value)) throw new TypeError("protocol descriptors must be an array");
const result: Value[] = [];
const seen = new Set<string>();
for (const entry of value) {
const descriptor = requireObject(entry, "protocol descriptor");
const name = requireText(descriptor["name"], "protocol name", 1, 128);
if (names.includes(name)) {
if (seen.has(name)) throw new TypeError("protocol names must be unique");
seen.add(name);
result.push(parse(entry));
}
}
return result;
}

function parseServiceReference(value: unknown): DirectoryServiceReference {
const object = requireObject(value, "available_through");
const serviceOrigin = requireText(object["service_origin"], "service_origin", 1, 2048);
Expand All @@ -587,6 +708,9 @@ function parseIndexedAt(value: unknown): string {

function validateFilters(filters: DirectoryServiceFilters): DirectoryServiceFilters {
return {
...(filters.sources === undefined
? {}
: { sources: uniqueEnums(filters.sources, "sources", ["odp", "openapi"] as const) }),
...(filters.keywords === undefined
? {}
: { keywords: uniqueText(filters.keywords, "keywords", 32, 64) }),
Expand Down
1 change: 1 addition & 0 deletions packages/directory/test/unit/mixed-search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createDirectoryClient, type DirectoryResourceSearchRequest } from "../.

const indexedAt = "2026-09-18T12:00:00Z";
const service = {
source: { type: "odp", url: "https://api.example.com/.well-known/odp", x402_discovery: false },
service_id: "ca0304cc-ab28-43e5-af94-7bdf11b40c6e",
service_origin: "https://api.example.com",
name: "Example Service",
Expand Down
Loading
Loading