Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fixed x402 discovery filtering to read the description of v2 resources from the discovery API's top-level `description` field, falling back to `metadata.description` and then `accepts[].description`
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ class DiscoveryResource(TypedDict, total=False):
url: str
resource: str
type: str
# v2: the discovery API returns description as a top-level field on the resource.
description: str
metadata: dict
accepts: list[PaymentOption]
x402_version: int
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,10 @@ def _get_resource_description(resource: DiscoveryResource) -> str:
"""Extract description from a resource based on its x402 version.

- v1: description is in accepts[].description
- v2: description is in metadata.description
- v2: the discovery API returns description as a top-level field on the
resource; metadata.description and accepts[].description are checked
as fallbacks for resources published under an older or non-standard
v2 shape.

Args:
resource: The discovery resource
Expand All @@ -207,9 +210,21 @@ def _get_resource_description(resource: DiscoveryResource) -> str:
"""
x402_version = resource.get("x402_version", resource.get("x402Version"))
if x402_version == 2:
top_level_desc = resource.get("description")
if isinstance(top_level_desc, str) and top_level_desc.strip():
return top_level_desc

metadata = resource.get("metadata", {})
metadata_desc = metadata.get("description") if metadata else None
return metadata_desc if isinstance(metadata_desc, str) else ""
if isinstance(metadata_desc, str) and metadata_desc.strip():
return metadata_desc

accepts = resource.get("accepts", [])
for option in accepts:
desc = option.get("description", "")
if desc and desc.strip():
return desc
return ""

# v1: look in accepts[].description
accepts = resource.get("accepts", [])
Expand All @@ -224,7 +239,8 @@ def filter_by_description(resources: list[DiscoveryResource]) -> list[DiscoveryR
"""Filter resources by having a valid description.

Removes resources with empty or default descriptions.
Supports both v1 (accepts[].description) and v2 (metadata.description) formats.
Supports both v1 (accepts[].description) and v2 (description, with
metadata.description and accepts[].description as fallbacks) formats.

Args:
resources: Array of discovery resources
Expand Down Expand Up @@ -278,7 +294,8 @@ def filter_by_keyword(
"""Filter resources by keyword appearing in description or URL.

Case-insensitive search.
Supports both v1 (accepts[].description) and v2 (metadata.description) formats.
Supports both v1 (accepts[].description) and v2 (description, with
metadata.description and accepts[].description as fallbacks) formats.

Args:
resources: Array of discovery resources
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""Tests for x402 action provider utility functions."""

from coinbase_agentkit.action_providers.x402.utils import filter_by_description


def test_filter_by_description_keeps_v2_top_level_description():
"""A v2 resource with a top-level description is kept."""
resources = [
{
"resource": "https://example.com/feed",
"x402_version": 2,
"description": "Live agent-index data",
}
]

assert filter_by_description(resources) == resources


def test_filter_by_description_keeps_v2_metadata_only_description():
"""A v2 resource that only has metadata.description is kept."""
resources = [
{
"resource": "https://example.com/feed",
"x402_version": 2,
"metadata": {"description": "Legacy metadata-only description"},
}
]

assert filter_by_description(resources) == resources


def test_filter_by_description_prefers_top_level_over_metadata():
"""The top-level description wins when both are present."""
resources = [
{
"resource": "https://example.com/feed",
"x402_version": 2,
"description": "Top-level wins",
"metadata": {"description": "Should be ignored"},
}
]

assert len(filter_by_description(resources)) == 1


def test_filter_by_description_drops_v2_with_no_description():
"""A v2 resource with no description anywhere is dropped."""
resources = [
{
"resource": "https://example.com/feed",
"x402_version": 2,
}
]

assert filter_by_description(resources) == []


def test_filter_by_description_leaves_v1_accepts_path_unchanged():
"""The v1 accepts[].description path is unaffected by the v2 fix."""
resources = [
{
"resource": "https://example.com/v1-feed",
"x402_version": 1,
"accepts": [
{
"scheme": "exact",
"network": "base",
"asset": "0xusdc",
"max_amount_required": "1000",
"description": "v1 accepts description",
}
],
}
]

assert filter_by_description(resources) == resources


def test_filter_by_description_drops_default_placeholder():
"""Resources whose description is the discovery API's default placeholder are dropped."""
resources = [
{
"resource": "https://example.com/placeholder",
"x402_version": 2,
"description": "Access to protected content",
}
]

assert filter_by_description(resources) == []
5 changes: 5 additions & 0 deletions typescript/.changeset/fix-x402-v2-discovery-description.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@coinbase/agentkit": patch
---

Fixed x402 discovery filtering to read the description of v2 resources from the discovery API's top-level `description` field, falling back to `metadata.description` and then `accepts[].description`. Previously only `metadata.description` was checked, so `filterByDescription` and `filterByKeyword` silently dropped every v2 resource that used the top-level field, which is the shape the live Bazaar discovery API returns.
2 changes: 2 additions & 0 deletions typescript/agentkit/src/action-providers/x402/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ export interface DiscoveryResource {
url?: string;
resource?: string;
type?: string;
// v2: the discovery API returns description as a top-level field on the resource.
description?: string;
metadata?: {
[key: string]: unknown;
description?: string;
Expand Down
87 changes: 87 additions & 0 deletions typescript/agentkit/src/action-providers/x402/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { filterByDescription } from "./utils";
import { DiscoveryResource } from "./constants";

describe("x402 Utilities", () => {
describe("filterByDescription", () => {
it("keeps a v2 resource with a top-level description", () => {
const resources: DiscoveryResource[] = [
{
resource: "https://example.com/feed",
x402Version: 2,
description: "Live agent-index data",
},
];

expect(filterByDescription(resources)).toEqual(resources);
});

it("keeps a v2 resource that only has metadata.description", () => {
const resources: DiscoveryResource[] = [
{
resource: "https://example.com/feed",
x402Version: 2,
metadata: { description: "Legacy metadata-only description" },
},
];

expect(filterByDescription(resources)).toEqual(resources);
});

it("prefers the top-level description over metadata.description when both are present", () => {
const resources: DiscoveryResource[] = [
{
resource: "https://example.com/feed",
x402Version: 2,
description: "Top-level wins",
metadata: { description: "Should be ignored" },
},
];

const result = filterByDescription(resources);
expect(result).toHaveLength(1);
});

it("drops a v2 resource with no description anywhere", () => {
const resources: DiscoveryResource[] = [
{
resource: "https://example.com/feed",
x402Version: 2,
},
];

expect(filterByDescription(resources)).toEqual([]);
});

it("leaves the v1 accepts[].description path unchanged", () => {
const resources: DiscoveryResource[] = [
{
resource: "https://example.com/v1-feed",
x402Version: 1,
accepts: [
{
scheme: "exact",
network: "base",
asset: "0xusdc",
maxAmountRequired: "1000",
description: "v1 accepts description",
},
],
},
];

expect(filterByDescription(resources)).toEqual(resources);
});

it("drops resources whose description is the discovery API's default placeholder", () => {
const resources: DiscoveryResource[] = [
{
resource: "https://example.com/placeholder",
x402Version: 2,
description: "Access to protected content",
},
];

expect(filterByDescription(resources)).toEqual([]);
});
});
});
26 changes: 22 additions & 4 deletions typescript/agentkit/src/action-providers/x402/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,15 +180,31 @@ export function filterByNetwork(
/**
* Extracts description from a resource based on its x402 version.
* - v1: description is in accepts[].description
* - v2: description is in metadata.description
* - v2: the discovery API returns description as a top-level field on the resource;
* metadata.description and accepts[].description are checked as fallbacks for
* resources published under an older or non-standard v2 shape.
*
* @param resource - The discovery resource
* @returns The description string or empty string if not found
*/
function getResourceDescription(resource: DiscoveryResource): string {
if (resource.x402Version === 2) {
if (typeof resource.description === "string" && resource.description.trim()) {
return resource.description;
}

const metadataDesc = resource.metadata?.description;
return typeof metadataDesc === "string" ? metadataDesc : "";
if (typeof metadataDesc === "string" && metadataDesc.trim()) {
return metadataDesc;
}

const accepts = resource.accepts ?? [];
for (const option of accepts) {
if (option.description?.trim()) {
return option.description;
}
}
return "";
}

// v1: look in accepts[].description
Expand All @@ -204,7 +220,8 @@ function getResourceDescription(resource: DiscoveryResource): string {
/**
* Filters resources by having a valid description.
* Removes resources with empty or default descriptions.
* Supports both v1 (accepts[].description) and v2 (metadata.description) formats.
* Supports both v1 (accepts[].description) and v2 (description, with metadata.description
* and accepts[].description as fallbacks) formats.
*
* @param resources - Array of discovery resources
* @returns Filtered array of resources with valid descriptions
Expand Down Expand Up @@ -240,7 +257,8 @@ export function filterByX402Version(
/**
* Filters resources by keyword appearing in description or URL.
* Case-insensitive search.
* Supports both v1 (accepts[].description) and v2 (metadata.description) formats.
* Supports both v1 (accepts[].description) and v2 (description, with metadata.description
* and accepts[].description as fallbacks) formats.
*
* @param resources - Array of discovery resources
* @param keyword - The keyword to search for in descriptions and URLs
Expand Down
Loading