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
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ jobs:
- name: Install uv
uses: astral-sh/setup-uv@v10.2.0
with:
version: "0.12.22"
version: "0.12.23"

- name: Set up Python
run: uv python install 3.12
Expand Down Expand Up @@ -71,7 +71,7 @@ jobs:
- name: Install uv
uses: astral-sh/setup-uv@v10.2.0
with:
version: "0.12.22"
version: "0.12.23"

- name: Set up Python
run: uv python install ${{ matrix.python }}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:

- uses: astral-sh/setup-uv@v10.2.0
with:
version: "0.12.22"
version: "0.12.23"

- name: Set version from tag
run: sed -i "s/^version = .*/version = \"${GITHUB_REF_NAME#v}\"/" pyproject.toml
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ jobs:
- uses: useblacksmith/checkout@v1
- uses: astral-sh/setup-uv@v10.2.0
with:
version: "0.12.22"
version: "0.12.23"
- uses: actions/setup-python@v7
with:
python-version: "3.13"
Expand Down
11 changes: 5 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,6 @@ checkout = Checkout(
checkout.mount_ucp_routes_fastapi(
app,
name="Merchant",
well_known_ucp_url="https://merchant.example/.well-known/ucp",
services=default_a2a_services(agent_card_url="https://merchant.example/.well-known/agent-card.json"),
signing_kid="merchant-2026-05",
)
Expand Down Expand Up @@ -340,16 +339,16 @@ card = build_a2a_agent_card(

# Google Universal Commerce Protocol. Publish at /.well-known/ucp.
# Output shape: {"ucp": {"version", "services", "capabilities",
# "payment_handlers", "name?", "supported_versions?"}, "signing_keys": [...]}
# "payment_handlers", "name?", "supported_versions?"}, "keys": [...]}
# , services / capabilities / payment_handlers are MAPS keyed by reverse-DNS
# service / capability / handler name (UCP spec §3 + §6).
profile = build_ucp_profile(
name="My Service",
services={
"dev.ucp.shopping": [
UCPServiceBinding(
version="2026-04-08",
spec="https://ucp.dev/2026-04-08/specification/overview",
version="2026-08-25",
spec="https://ucp.dev/2026-08-25/specification/overview",
transport="mcp",
endpoint=f"{base_url}/api/ucp/mcp",
schema="https://ucp.dev/services/shopping/mcp.openrpc.json",
Expand All @@ -361,7 +360,7 @@ profile = build_ucp_profile(
**x402_payment_handler(networks=[X402BaseRailSpec(recipient=BASE_ADDR)]),
**stripe_spt_payment_handler(spec=StripeRailSpec(profile_id="profile_5xKvNqM9BaH")),
},
signing_keys=[UCPSigningKey(kid="me", kty="EC", alg="ES256")],
keys=[UCPSigningKey(kid="me", kty="EC", alg="ES256")],
# Optional: declare merchant gate policy as an `com.agentscore.identity` capability
# binding inside the public profile. Static policy declaration only, no per-operator
# claims. Per-operator identity attestation flows through the AP2 risk-signal endpoint.
Expand Down Expand Up @@ -395,7 +394,7 @@ profile = build_ucp_profile(
name="My Service",
services={...},
payment_handlers={...},
signing_keys=[UCPSigningKey.from_jwk(key.public_jwk)],
keys=[UCPSigningKey.from_jwk(key.public_jwk)],
)
signed = sign_ucp_profile(profile.to_dict(), signing_key=key.private_key, kid=key.public_jwk["kid"], alg="EdDSA")
jwks = build_jwks_response([key.public_jwk])
Expand Down
12 changes: 6 additions & 6 deletions agentscore_commerce/checkout.py
Original file line number Diff line number Diff line change
Expand Up @@ -1507,7 +1507,7 @@ def _build_ucp_resp(
request_headers: Mapping[str, str],
*,
name: str,
well_known_ucp_url: str,
well_known_ucp_url: str | None = None,
services: dict[str, Any],
signing_kid: str,
agentscore_gate: Any,
Expand Down Expand Up @@ -1539,7 +1539,7 @@ def mount_ucp_routes_fastapi(
app: Any,
*,
name: str,
well_known_ucp_url: str,
well_known_ucp_url: str | None = None,
services: dict[str, Any],
signing_kid: str = "merchant-default",
agentscore_gate: Any = None,
Expand Down Expand Up @@ -1587,7 +1587,7 @@ def mount_ucp_routes_flask(
app: Any,
*,
name: str,
well_known_ucp_url: str,
well_known_ucp_url: str | None = None,
services: dict[str, Any],
signing_kid: str = "merchant-default",
agentscore_gate: Any = None,
Expand Down Expand Up @@ -1640,7 +1640,7 @@ def mount_ucp_routes_django(
urlpatterns: list[Any],
*,
name: str,
well_known_ucp_url: str,
well_known_ucp_url: str | None = None,
services: dict[str, Any],
signing_kid: str = "merchant-default",
agentscore_gate: Any = None,
Expand Down Expand Up @@ -1686,7 +1686,7 @@ def mount_ucp_routes_aiohttp(
app: Any,
*,
name: str,
well_known_ucp_url: str,
well_known_ucp_url: str | None = None,
services: dict[str, Any],
signing_kid: str = "merchant-default",
agentscore_gate: Any = None,
Expand Down Expand Up @@ -1724,7 +1724,7 @@ def mount_ucp_routes_sanic(
app: Any,
*,
name: str,
well_known_ucp_url: str,
well_known_ucp_url: str | None = None,
services: dict[str, Any],
signing_kid: str = "merchant-default",
agentscore_gate: Any = None,
Expand Down
2 changes: 2 additions & 0 deletions agentscore_commerce/discovery/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
agentscore_openapi_snippets,
agentscore_payment_required_schema,
agentscore_security_schemes,
offers_from,
siwx_security_scheme,
x_guidance_extension,
x_payment_info_extension,
Expand Down Expand Up @@ -129,6 +130,7 @@
"is_discovery_probe_request",
"llms_txt_identity_section",
"llms_txt_payment_section",
"offers_from",
"purchase_mode_note",
"sample_x402_accept_for_network",
"signed_response_aiohttp",
Expand Down
36 changes: 35 additions & 1 deletion agentscore_commerce/discovery/openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
from dataclasses import dataclass
from typing import Any, Literal

from agentscore_commerce.payment.amounts import usd_to_atomic


def agentscore_security_schemes(*, aip: bool = False) -> dict[str, Any]:
"""Standard AgentScore identity security schemes for `components.securitySchemes`.
Expand Down Expand Up @@ -101,25 +103,57 @@ def x_payment_info_extension(
price: XPaymentInfoPrice,
protocols: list[dict[str, Any]],
description: str | None = None,
offers: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Wrap a price + protocols block under ``x-payment-info``.

For spreading into an OpenAPI operation object. ``protocols`` is a list of
single-key dicts: ``{"x402": {}}`` for x402, ``{"mpp": {"method": ...,
"intent": ..., "currency": ...}}`` for MPP. Order is preserved.

Emits ``authMode: "payment"`` by default per the x402scan convention.
The block carries both readers' shapes, because MPP and x402scan define the same
``x-payment-info`` extension differently and neither reads the other's keys:
x402scan reads ``price`` + ``protocols`` (and ``authMode: "payment"``), MPP reads
``offers``. ``offers`` defaults to one per MPP protocol entry (:func:`offers_from`).
"""
if isinstance(price, XPaymentInfoFixedPrice):
price_dict: dict[str, Any] = {"mode": "fixed", "currency": price.currency, "amount": price.amount}
else:
price_dict = {"mode": "dynamic", "currency": price.currency, "min": price.min, "max": price.max}
block: dict[str, Any] = {"authMode": "payment", "price": price_dict, "protocols": protocols}
derived = offers if offers is not None else offers_from(price, protocols)
if derived:
block["offers"] = derived
if description is not None:
block["description"] = description
return {"x-payment-info": block}


def offers_from(price: XPaymentInfoPrice, protocols: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""MPP payment offers (``draft-payment-discovery``) for the MPP entries in ``protocols``.

Priced in each method's smallest unit the way the 402 challenge prices it: Stripe in
cents, the token rails (Tempo USDC.e, Solana USDC) in 6-decimal base units. x402
entries have no MPP offer; a dynamic price is ``None`` (``null``), which MPP defines
as "depends on the request".
"""
offers: list[dict[str, Any]] = []
for p in protocols:
mpp = p.get("mpp")
if not isinstance(mpp, dict):
continue
method, _, slash_intent = str(mpp.get("method", "")).partition("/")
intent = "session" if (slash_intent or mpp.get("intent")) == "session" else "charge"
decimals = 2 if method == "stripe" else 6
fixed_usd = isinstance(price, XPaymentInfoFixedPrice) and price.currency.upper() == "USD"
amount = str(usd_to_atomic(price.amount, decimals=decimals)) if fixed_usd else None
offer: dict[str, Any] = {"intent": intent, "method": method, "amount": amount}
if isinstance(mpp.get("currency"), str):
offer["currency"] = mpp["currency"]
offers.append(offer)
return offers


def x_guidance_extension(text: str) -> dict[str, str]:
"""Wrap a prose blurb under ``x-guidance`` for spreading into an OpenAPI ``info`` block.

Expand Down
20 changes: 11 additions & 9 deletions agentscore_commerce/discovery/well_known.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ def build_signed_ucp_response(
*,
checkout: Checkout,
name: str,
well_known_ucp_url: str,
well_known_ucp_url: str | None = None,
services: dict[str, list[UCPServiceBinding]],
request_headers: Mapping[str, str] | None = None,
signing_kid: str = "merchant-default",
Expand All @@ -169,8 +169,10 @@ def build_signed_ucp_response(
Cache-Control) when no payment handlers can be derived from rails.

``services`` is the spec-compliant services map (keyed by reverse-DNS
service name). ``well_known_ucp_url`` is the canonical URL of this profile,
surfaced as the value in ``supported_versions``.
service name). The profile publishes only the current UCP version:
``supported_versions`` maps OLDER versions to complete profiles for them, and
this serves none. ``well_known_ucp_url`` is no longer published and is accepted
so existing callers keep working.
"""
handlers = _compose_handlers(checkout)
if not handlers:
Expand All @@ -181,11 +183,10 @@ def build_signed_ucp_response(

profile = build_ucp_profile(
name=name,
supported_versions={"2026-04-08": well_known_ucp_url},
agentscore_gate=agentscore_gate,
services=services,
payment_handlers=handlers,
signing_keys=[signing_key_entry],
keys=[signing_key_entry],
)
signed = sign_ucp_profile(
profile.to_dict(),
Expand Down Expand Up @@ -281,13 +282,14 @@ def well_known_preflight_response(
)


_UCP_SHOPPING_SPEC_2026_04_08 = "https://ucp.dev/2026-04-08/specification/overview"
_UCP_VERSION = "2026-08-25"
_UCP_SHOPPING_SPEC = f"https://ucp.dev/{_UCP_VERSION}/specification/overview"


def default_a2a_services(*, agent_card_url: str) -> dict[str, list[UCPServiceBinding]]:
"""Canonical UCP §services map for a merchant publishing an A2A agent card.

Returns ``{"dev.ucp.shopping": [UCPServiceBinding(version="2026-04-08",
Returns ``{"dev.ucp.shopping": [UCPServiceBinding(version="2026-08-25",
spec="<UCP shopping spec>", transport="a2a", endpoint=agent_card_url)]}`` ;
the binding every UCP-publishing merchant declares when their primary agent
surface is the A2A v1.0 ``/.well-known/agent-card.json`` (versus a UCP MCP
Expand All @@ -299,8 +301,8 @@ def default_a2a_services(*, agent_card_url: str) -> dict[str, list[UCPServiceBin
return {
"dev.ucp.shopping": [
UCPServiceBinding(
version="2026-04-08",
spec=_UCP_SHOPPING_SPEC_2026_04_08,
version=_UCP_VERSION,
spec=_UCP_SHOPPING_SPEC,
transport="a2a",
endpoint=agent_card_url,
),
Expand Down
Loading
Loading