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
9 changes: 4 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,6 @@ const checkout = new Checkout({
// directly inside the route handler.)
checkout.mountUcpRoutesHono(app, {
name: "Merchant",
wellKnownUcpUrl: "https://merchant.example/.well-known/ucp",
services: defaultA2aServices({ agentCardUrl: "https://merchant.example/.well-known/agent-card.json" }),
signingKid: "merchant-2026-05",
});
Expand Down Expand Up @@ -297,14 +296,14 @@ const card = buildA2AAgentCard({

// Google Universal Commerce Protocol; publish at /.well-known/ucp
// Output shape: { ucp: { version, services, capabilities, payment_handlers,
// name?, supported_versions? }, signing_keys: [...], signature?: "..." }
// name?, supported_versions? }, keys: [...], signature?: "..." }
//, services / capabilities / payment_handlers are MAPS keyed by reverse-DNS
// service / capability / handler name (UCP spec §3 + §6).
const profile = buildUCPProfile({
name,
services: {
'dev.ucp.shopping': [
{ 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: 'https://merchant.example/api/ucp/mcp',
schema: 'https://ucp.dev/services/shopping/mcp.openrpc.json' },
],
Expand All @@ -314,7 +313,7 @@ const profile = buildUCPProfile({
...x402PaymentHandler({ networks: [{ network: 'base-8453', recipient: BASE_ADDR }] }),
...stripeSptPaymentHandler({ spec: { profileId: 'profile_5xKvNqM9BaH' } }),
},
signing_keys,
keys,
// Optional: declare the merchant's gate policy as an `com.agentscore.identity` capability
// binding inside the public profile. Static policy declaration only, no per-operator data.
// Per-operator identity attestation lives on the AP2 risk-signal endpoint, not here.
Expand All @@ -328,7 +327,7 @@ UCP §6 doesn't mandate profile-body JWS signing; production UCP merchants commo
import { buildJWKSResponse, generateUCPSigningKey, signUCPProfile, verifyUCPProfile, UCPVerificationError } from "@agent-score/commerce";

const { privateKey, publicJWK } = await generateUCPSigningKey({ kid: "merchant-2026-05" });
const profile = buildUCPProfile({ name, services, payment_handlers, signing_keys: [publicJWK] });
const profile = buildUCPProfile({ name, services, payment_handlers, keys: [publicJWK] });
const signed = await signUCPProfile(profile, { signingKey: privateKey, kid: publicJWK.kid, alg: "EdDSA" });
const jwks = buildJWKSResponse([publicJWK]);
```
Expand Down
16 changes: 8 additions & 8 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions examples/signed-ucp-merchant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,6 @@ app.use('*', rateLimitHono());
// CORS + X-Request-ID headers per UCP §6.
checkout.mountUcpRoutesHono(app, {
name: 'My Agent Service',
wellKnownUcpUrl: 'https://agents.example.com/.well-known/ucp',
services: defaultA2aServices({
agentCardUrl: 'https://agents.example.com/.well-known/agent-card.json',
}),
Expand All @@ -92,7 +91,7 @@ app.get('/_selftest/ucp', async (c: Context) => {
await verifyUCPProfile(profile as never, jwks);
return c.json({
ok: true,
kid: ((profile.signing_keys as Array<{ kid?: string }> | undefined)?.[0])?.kid,
kid: ((profile.keys as Array<{ kid?: string }> | undefined)?.[0])?.kid,
});
} catch (err) {
if (err instanceof UCPVerificationError) {
Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@agent-score/commerce",
"version": "2.15.0",
"version": "3.0.0",
"description": "Agentic commerce SDK: identity middleware (Hono, Express, Fastify, Next.js, Web Fetch) + payment helpers + 402 builders + discovery + Stripe multichain. The full merchant-side toolkit for AgentScore-powered agentic commerce.",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
Expand Down Expand Up @@ -197,7 +197,7 @@
"@solana/kit": "^8.4.0",
"@solana/mpp": "^0.7.0",
"@types/express": "^5.0.6",
"@types/node": "^26.6.1",
"@types/node": "^26.6.4",
"@vitest/coverage-v8": "^5.0.3",
"@x402/core": "2.28.0",
"@x402/evm": "2.28.0",
Expand All @@ -213,7 +213,7 @@
"jose": "^6.2.12",
"knip": "^6.39.0",
"lefthook": "^2.1.16",
"mppx": "0.12.0",
"mppx": "0.13.1",
"tsup": "^8.5.1",
"typescript": "^6.0.3",
"typescript-eslint": "^8.71.0",
Expand Down
5 changes: 3 additions & 2 deletions src/checkout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2647,7 +2647,9 @@ interface FastifyLikeReply {
* (GET ucp + GET jwks + OPTIONS preflights) every time. */
export interface MountUcpRoutesOptions {
name: string;
wellKnownUcpUrl: string;
/** @deprecated No longer published (it fed `supported_versions`, which UCP reserves for
* older versions' own profiles). Accepted so existing callers keep compiling. */
wellKnownUcpUrl?: string;
services: Record<string, unknown[]>;
signingKid?: string;
agentscoreGate?: unknown;
Expand All @@ -2664,7 +2666,6 @@ async function _ucpSignedResp(
return await buildSignedUcpResponse({
checkout,
name: opts.name,
wellKnownUcpUrl: opts.wellKnownUcpUrl,
services: opts.services as Parameters<typeof buildSignedUcpResponse>[0]['services'],
requestHeaders: reqHeaders,
...(opts.signingKid !== undefined && { signingKid: opts.signingKid }),
Expand Down
46 changes: 46 additions & 0 deletions src/discovery/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
* full spec.
*/

import { usdToAtomic } from '../payment/amounts';

/**
* Standard AgentScore identity security schemes. Plug into `components.securitySchemes`.
*
Expand Down Expand Up @@ -203,32 +205,76 @@ export interface XPaymentInfoMppProtocol {

export type XPaymentInfoProtocol = XPaymentInfoX402Protocol | XPaymentInfoMppProtocol;

/**
* One way to pay for an operation, in MPP's payment-discovery form
* (`draft-payment-discovery`): `amount` is an integer string in the currency's
* smallest unit, or `null` when the price depends on the request.
*/
export interface XPaymentInfoOffer {
intent: 'charge' | 'session';
method: string;
amount: string | null;
currency?: string;
description?: string;
}

/**
* Carries both readers' shapes in one object, because MPP and x402scan define the
* same `x-payment-info` extension differently and neither reads the other's keys:
* x402scan reads `price` + `protocols`, MPP reads `offers`.
*/
export interface XPaymentInfoBlock {
authMode: 'payment';
price: XPaymentInfoPrice;
protocols: XPaymentInfoProtocol[];
offers?: XPaymentInfoOffer[];
description?: string;
}

export function xPaymentInfoExtension({
price,
protocols,
offers,
description,
}: {
price: XPaymentInfoPrice;
protocols: XPaymentInfoProtocol[];
/** MPP payment offers. Defaults to one per MPP protocol entry, priced from `price`. */
offers?: XPaymentInfoOffer[];
description?: string;
}): { 'x-payment-info': XPaymentInfoBlock } {
const derived = offers ?? offersFrom(price, protocols);
return {
'x-payment-info': {
authMode: 'payment',
price,
protocols,
...(derived.length > 0 && { offers: derived }),
...(description !== undefined && { description }),
},
};
}

/**
* MPP offers 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 `null`, which MPP defines as "depends on the request".
*/
export function offersFrom(price: XPaymentInfoPrice, protocols: XPaymentInfoProtocol[]): XPaymentInfoOffer[] {
const offers: XPaymentInfoOffer[] = [];
for (const p of protocols) {
if (!('mpp' in p)) continue;
const [method, slashIntent] = p.mpp.method.split('/');
const intent = (slashIntent ?? p.mpp.intent) === 'session' ? 'session' : 'charge';
const currency = typeof p.mpp.currency === 'string' ? p.mpp.currency : undefined;
const decimals = method === 'stripe' ? 2 : 6;
const amount = price.mode === 'fixed' && price.currency.toUpperCase() === 'USD' ? usdToAtomic(price.amount, { decimals }).toString() : null;
offers.push({ intent, method: method!, amount, ...(currency !== undefined && { currency }) });
}
return offers;
}

/**
* `info.x-guidance` extension, per the x402scan discovery spec. Spread into your
* OpenAPI document's `info` block to give agents a high-level prose description
Expand Down
21 changes: 11 additions & 10 deletions src/discovery/well_known.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ import type {

const UCP_CACHE_SECONDS = 60;
const JWKS_CACHE_SECONDS = 300;
const UCP_SHOPPING_SPEC_2026_04_08 = 'https://ucp.dev/2026-04-08/specification/overview';
const UCP_VERSION = '2026-08-25';
const UCP_SHOPPING_SPEC = `https://ucp.dev/${UCP_VERSION}/specification/overview`;

/**
* Framework-neutral response shape for discovery endpoints.
Expand Down Expand Up @@ -175,13 +176,15 @@ function misconfiguredResponse(
* Cache-Control) when no payment handlers can be derived from rails.
*
* `services` is the spec-compliant services map (keyed by reverse-DNS service
* name). `wellKnownUcpUrl` is the canonical URL of this profile, surfaced as
* the value in `supported_versions`.
* name). The profile publishes only the current UCP version: `supported_versions`
* maps OLDER versions to complete profiles for them, and this serves none.
*/
export async function buildSignedUcpResponse(opts: {
checkout: Checkout;
name: string;
wellKnownUcpUrl: string;
/** @deprecated No longer published: it fed `supported_versions`, which UCP reserves for
* older versions' own profiles. Accepted so existing callers keep compiling. */
wellKnownUcpUrl?: string;
services: Record<string, UCPServiceBinding[]>;
requestHeaders?: Headers | Record<string, string>;
signingKid?: string;
Expand All @@ -190,7 +193,6 @@ export async function buildSignedUcpResponse(opts: {
const {
checkout,
name,
wellKnownUcpUrl,
services,
requestHeaders,
signingKid = 'merchant-default',
Expand All @@ -207,11 +209,10 @@ export async function buildSignedUcpResponse(opts: {

const profile = buildUCPProfile({
name,
supported_versions: { '2026-04-08': wellKnownUcpUrl },
agentscore_gate: agentscoreGate,
services,
payment_handlers: handlers,
signing_keys: [signingKeyEntry],
keys: [signingKeyEntry],
});
const signed = await signUCPProfile(profile, {
signingKey: key.privateKey,
Expand Down Expand Up @@ -301,7 +302,7 @@ export function wellKnownPreflightResponse(
/**
* 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: agentCardUrl)]}`;
* 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 or
Expand All @@ -316,8 +317,8 @@ export function defaultA2aServices(opts: {
return {
'dev.ucp.shopping': [
{
version: '2026-04-08',
spec: UCP_SHOPPING_SPEC_2026_04_08,
version: UCP_VERSION,
spec: UCP_SHOPPING_SPEC,
transport: 'a2a',
endpoint: opts.agentCardUrl,
},
Expand Down
Loading
Loading