From 4c89f1f4ba9438396d0789bcdc011fe977c7f903 Mon Sep 17 00:00:00 2001 From: Ryan Aubrey Date: Fri, 26 Jun 2026 08:07:56 -0400 Subject: [PATCH 01/15] Basic UCP support --- packages/cli/src/cli.tsx | 2 + packages/cli/src/commands/ucp/index.tsx | 205 +++++++++++ packages/cli/src/commands/ucp/schema.ts | 83 +++++ packages/sdk/src/index.ts | 2 + packages/sdk/src/resources/interfaces.ts | 51 +++ packages/sdk/src/resources/ucp-types.ts | 117 +++++++ packages/sdk/src/resources/ucp.ts | 421 +++++++++++++++++++++++ 7 files changed, 881 insertions(+) create mode 100644 packages/cli/src/commands/ucp/index.tsx create mode 100644 packages/cli/src/commands/ucp/schema.ts create mode 100644 packages/sdk/src/resources/ucp-types.ts create mode 100644 packages/sdk/src/resources/ucp.ts diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index ecddba9..27fbce9 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -10,6 +10,7 @@ import { createServeCli } from './commands/serve'; import { createShippingAddressCli } from './commands/shipping-address'; import { createSpendRequestCli } from './commands/spend-request'; import { createTransactionsCli } from './commands/transactions'; +import { createUcpCli } from './commands/ucp'; import { createUserInfoCli } from './commands/user-info'; import { createWebBotAuthCli } from './commands/web-bot-auth'; import { ResourceFactory } from './utils/resource-factory'; @@ -153,6 +154,7 @@ if (!transactionsCli) { authStorage, ), ); + cli.command(createUcpCli()); cli.command(createServeCli(cli)); } diff --git a/packages/cli/src/commands/ucp/index.tsx b/packages/cli/src/commands/ucp/index.tsx new file mode 100644 index 0000000..d543aca --- /dev/null +++ b/packages/cli/src/commands/ucp/index.tsx @@ -0,0 +1,205 @@ +import { UcpResource } from '@stripe/link-sdk'; +import { Cli, z } from 'incur'; +import { + businessOption, + cartCreateOptions, + cartGetOptions, + cartUpdateOptions, + catalogLookupOptions, + catalogSearchOptions, + checkoutCompleteOptions, + checkoutCreateOptions, + checkoutGetOptions, + checkoutUpdateOptions, +} from './schema'; + +function createUcpResource(profileUrl?: string): UcpResource { + return new UcpResource({ profileUrl: profileUrl ?? '' }); +} + +export function createUcpCli() { + const cli = Cli.create('ucp', { + description: 'Universal Commerce Protocol (UCP) commands', + }); + + // --- discover --- + + cli.command('discover', { + description: 'See what operations a business supports before calling them', + args: z.object({ + business: z + .string() + .describe( + 'Business URL to discover (e.g. https://ucp-demo.myshopify.com)', + ), + }), + options: z.object({ + profileUrl: z + .string() + .optional() + .describe('Agent profile URL (not required for discovery, but needed for tool negotiation)'), + }), + outputPolicy: 'agent-only' as const, + async run(c) { + const resource = createUcpResource(c.options.profileUrl); + return resource.discover(c.args.business); + }, + }); + + // --- catalog search --- + + const catalogCli = Cli.create('catalog', { + description: + 'Search for products, enumerate variants and options, check availability', + }); + + catalogCli.command('search', { + description: 'Search a business catalog over UCP', + options: catalogSearchOptions, + outputPolicy: 'agent-only' as const, + async run(c) { + const resource = createUcpResource(c.options.profileUrl); + return resource.catalogSearch(c.options.business, { + query: c.options.query, + limit: c.options.limit, + cursor: c.options.cursor, + }); + }, + }); + + catalogCli.command('lookup', { + description: 'Batch lookup products/variants by ID', + options: catalogLookupOptions, + outputPolicy: 'agent-only' as const, + async run(c) { + const resource = createUcpResource(c.options.profileUrl); + const ids = c.options.ids.split(',').map((id) => id.trim()); + return resource.catalogLookup(c.options.business, { ids }); + }, + }); + + cli.command(catalogCli); + + // --- cart --- + + const cartCli = Cli.create('cart', { + description: 'Build a shoppable cart with line items and cost estimates', + }); + + cartCli.command('create', { + description: 'Create a new cart with line items', + options: cartCreateOptions, + outputPolicy: 'agent-only' as const, + async run(c) { + const resource = createUcpResource(c.options.profileUrl); + if (c.options.input) { + const parsed = JSON.parse(c.options.input); + return resource.cartCreate(c.options.business, parsed); + } + const lineItems = JSON.parse(c.options.lineItems); + return resource.cartCreate(c.options.business, { + line_items: lineItems, + }); + }, + }); + + cartCli.command('get', { + description: 'Fetch a cart by ID', + options: cartGetOptions, + outputPolicy: 'agent-only' as const, + async run(c) { + const resource = createUcpResource(c.options.profileUrl); + return resource.cartGet(c.options.business, c.options.id); + }, + }); + + cartCli.command('update', { + description: 'Update an existing cart', + options: cartUpdateOptions, + outputPolicy: 'agent-only' as const, + async run(c) { + const resource = createUcpResource(c.options.profileUrl); + if (c.options.input) { + const parsed = JSON.parse(c.options.input); + return resource.cartUpdate(c.options.business, c.options.id, parsed); + } + const params: Record = {}; + if (c.options.lineItems) { + params.line_items = JSON.parse(c.options.lineItems); + } + return resource.cartUpdate(c.options.business, c.options.id, params); + }, + }); + + cli.command(cartCli); + + // --- checkout --- + + const checkoutCli = Cli.create('checkout', { + description: + 'Complete a purchase, pick fulfillment options, confirm payment', + }); + + checkoutCli.command('create', { + description: + 'Create a checkout from line_items, or convert a cart with --cart-id', + options: checkoutCreateOptions, + outputPolicy: 'agent-only' as const, + async run(c) { + const resource = createUcpResource(c.options.profileUrl); + if (c.options.input) { + const parsed = JSON.parse(c.options.input); + return resource.checkoutCreate(c.options.business, parsed); + } + const params: Record = {}; + if (c.options.cartId) { + params.cart_id = c.options.cartId; + } + if (c.options.lineItems) { + params.line_items = JSON.parse(c.options.lineItems); + } + return resource.checkoutCreate(c.options.business, params); + }, + }); + + checkoutCli.command('get', { + description: 'Fetch a checkout by ID', + options: checkoutGetOptions, + outputPolicy: 'agent-only' as const, + async run(c) { + const resource = createUcpResource(c.options.profileUrl); + return resource.checkoutGet(c.options.business, c.options.id); + }, + }); + + checkoutCli.command('update', { + description: 'Update an existing checkout (fulfillment, buyer info, etc.)', + options: checkoutUpdateOptions, + outputPolicy: 'agent-only' as const, + async run(c) { + const resource = createUcpResource(c.options.profileUrl); + const parsed = JSON.parse(c.options.input); + return resource.checkoutUpdate(c.options.business, c.options.id, parsed); + }, + }); + + checkoutCli.command('complete', { + description: + 'Complete a checkout and place the order. May return requires_escalation with a continue_url for browser-based payment.', + options: checkoutCompleteOptions, + outputPolicy: 'agent-only' as const, + async run(c) { + const resource = createUcpResource(c.options.profileUrl); + const params = c.options.input ? JSON.parse(c.options.input) : undefined; + return resource.checkoutComplete( + c.options.business, + c.options.id, + params, + ); + }, + }); + + cli.command(checkoutCli); + + return cli; +} diff --git a/packages/cli/src/commands/ucp/schema.ts b/packages/cli/src/commands/ucp/schema.ts new file mode 100644 index 0000000..aaa78d6 --- /dev/null +++ b/packages/cli/src/commands/ucp/schema.ts @@ -0,0 +1,83 @@ +import { z } from 'incur'; + +export const businessOption = z.object({ + business: z + .string() + .describe( + 'Business URL to interact with (e.g. https://ucp-demo.myshopify.com)', + ), + profileUrl: z + .string() + .describe( + 'Agent profile URL that identifies this agent to the merchant', + ), +}); + +export const catalogSearchOptions = businessOption.extend({ + query: z.string().describe('Free-text search query (e.g. "boots")'), + limit: z.number().optional().describe('Maximum number of results to return'), + cursor: z + .string() + .optional() + .describe('Pagination cursor from a prior response'), +}); + +export const catalogLookupOptions = businessOption.extend({ + ids: z + .string() + .describe('Comma-separated list of product/variant IDs to look up'), +}); + +export const cartCreateOptions = businessOption.extend({ + lineItems: z + .string() + .describe( + 'Line items as JSON array (e.g. \'[{"variant_id":"gid://123","quantity":1}]\')', + ), + input: z + .string() + .optional() + .describe('Full operation payload as JSON (overrides other fields)'), +}); + +export const cartGetOptions = businessOption.extend({ + id: z.string().describe('Cart ID'), +}); + +export const cartUpdateOptions = businessOption.extend({ + id: z.string().describe('Cart ID'), + lineItems: z.string().optional().describe('Updated line items as JSON array'), + input: z + .string() + .optional() + .describe('Full operation payload as JSON (overrides other fields)'), +}); + +export const checkoutCreateOptions = businessOption.extend({ + cartId: z.string().optional().describe('Cart ID to convert to checkout'), + lineItems: z + .string() + .optional() + .describe('Line items as JSON array (alternative to --cart-id)'), + input: z + .string() + .optional() + .describe('Full operation payload as JSON (overrides other fields)'), +}); + +export const checkoutGetOptions = businessOption.extend({ + id: z.string().describe('Checkout ID'), +}); + +export const checkoutUpdateOptions = businessOption.extend({ + id: z.string().describe('Checkout ID'), + input: z.string().describe('Update payload as JSON'), +}); + +export const checkoutCompleteOptions = businessOption.extend({ + id: z.string().describe('Checkout ID'), + input: z + .string() + .optional() + .describe('Complete payload as JSON (e.g. payment method details)'), +}); diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index ad84de8..ccf4f3d 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -12,6 +12,8 @@ export * from './resources/user-info'; export * from './resources/web-bot-auth'; export * from './resources/transactions'; export * from './resources/report'; +export * from './resources/ucp'; +export * from './resources/ucp-types'; export { MemoryStorage, Storage, storage } from './utils/storage'; export type { AuthStorage, diff --git a/packages/sdk/src/resources/interfaces.ts b/packages/sdk/src/resources/interfaces.ts index df6a3dc..d401068 100644 --- a/packages/sdk/src/resources/interfaces.ts +++ b/packages/sdk/src/resources/interfaces.ts @@ -12,6 +12,17 @@ import type { UserInfo, WebBotAuthBlock, } from '@/types/index'; +import type { + UcpCartCreateParams, + UcpCartUpdateParams, + UcpCatalogLookupParams, + UcpCatalogSearchParams, + UcpCheckoutCompleteParams, + UcpCheckoutCreateParams, + UcpCheckoutUpdateParams, + UcpDiscoveryResult, + UcpOperationResult, +} from './ucp-types'; export interface IAuthResource { initiateDeviceAuth(clientName?: string): Promise; @@ -154,3 +165,43 @@ export interface ReportRecord { export interface IReportResource { create(params: CreateReportParams): Promise; } + +export interface IUcpResource { + discover(businessUrl: string): Promise; + catalogSearch( + businessUrl: string, + params: UcpCatalogSearchParams, + ): Promise; + catalogLookup( + businessUrl: string, + params: UcpCatalogLookupParams, + ): Promise; + cartCreate( + businessUrl: string, + params: UcpCartCreateParams, + ): Promise; + cartGet(businessUrl: string, cartId: string): Promise; + cartUpdate( + businessUrl: string, + cartId: string, + params: UcpCartUpdateParams, + ): Promise; + checkoutCreate( + businessUrl: string, + params: UcpCheckoutCreateParams, + ): Promise; + checkoutGet( + businessUrl: string, + checkoutId: string, + ): Promise; + checkoutUpdate( + businessUrl: string, + checkoutId: string, + params: UcpCheckoutUpdateParams, + ): Promise; + checkoutComplete( + businessUrl: string, + checkoutId: string, + params?: UcpCheckoutCompleteParams, + ): Promise; +} diff --git a/packages/sdk/src/resources/ucp-types.ts b/packages/sdk/src/resources/ucp-types.ts new file mode 100644 index 0000000..529bab7 --- /dev/null +++ b/packages/sdk/src/resources/ucp-types.ts @@ -0,0 +1,117 @@ +// UCP (Universal Commerce Protocol) types +// Protocol spec: https://ucp.dev/2026-04-08/specification/overview/ + +// --- Discovery (.well-known/ucp) --- + +export interface UcpServiceBinding { + version: string; + spec: string; + transport: 'mcp' | 'embedded' | string; + endpoint?: string; + schema?: string; +} + +export interface UcpCapability { + version: string; + spec: string; + schema?: string; + config?: Record; + extends?: string[]; + requires?: { protocol?: { min?: string } }; +} + +export interface UcpPaymentHandler { + version: string; + spec: string; + schema?: string; + id?: string; + config?: Record; +} + +export interface UcpDiscoverySpec { + version: string; + supported_versions?: Record; + services: Record; + capabilities: Record; + payment_handlers?: Record; +} + +export interface UcpDiscoveryResult { + business: string; + ucp: UcpDiscoverySpec; + mcp_endpoint: string | null; + capabilities: string[]; + payment_handlers: string[]; +} + +// --- MCP JSON-RPC transport --- + +export interface McpToolCallParams { + name: string; + arguments: Record; +} + +export interface McpContentBlock { + type: string; + text?: string; + data?: unknown; +} + +export interface McpToolCallResult { + content: McpContentBlock[]; + isError?: boolean; +} + +// --- Catalog --- + +export interface UcpCatalogSearchParams { + query: string; + limit?: number; + cursor?: string; + [key: string]: unknown; +} + +export interface UcpCatalogLookupParams { + ids: string[]; + [key: string]: unknown; +} + +// --- Cart --- + +export interface UcpLineItem { + item: { id: string; [key: string]: unknown }; + quantity: number; + id?: string; + [key: string]: unknown; +} + +export interface UcpCartCreateParams { + line_items: UcpLineItem[]; + [key: string]: unknown; +} + +export interface UcpCartUpdateParams { + line_items?: UcpLineItem[]; + [key: string]: unknown; +} + +// --- Checkout --- + +export interface UcpCheckoutCreateParams { + cart_id?: string; + line_items?: UcpLineItem[]; + [key: string]: unknown; +} + +export interface UcpCheckoutUpdateParams { + [key: string]: unknown; +} + +export interface UcpCheckoutCompleteParams { + [key: string]: unknown; +} + +// --- Generic operation result (pass-through) --- +// UCP responses are passed through raw; these are the common envelope fields. + +export type UcpOperationResult = Record; diff --git a/packages/sdk/src/resources/ucp.ts b/packages/sdk/src/resources/ucp.ts new file mode 100644 index 0000000..74caa19 --- /dev/null +++ b/packages/sdk/src/resources/ucp.ts @@ -0,0 +1,421 @@ +import type { + McpToolCallParams, + McpToolCallResult, + UcpCartCreateParams, + UcpCartUpdateParams, + UcpCatalogLookupParams, + UcpCatalogSearchParams, + UcpCheckoutCompleteParams, + UcpCheckoutCreateParams, + UcpCheckoutUpdateParams, + UcpDiscoveryResult, + UcpDiscoverySpec, + UcpOperationResult, +} from './ucp-types'; + +export interface UcpResourceOptions { + fetch?: typeof globalThis.fetch; + profileUrl: string; + timeoutMs?: number; +} + +const DEFAULT_TIMEOUT_MS = 30_000; + +interface ToolDescriptor { + name: string; + description?: string; + inputSchema?: unknown; +} + +interface NegotiatedEndpoint { + endpoint: string; + tools: Record; +} + +let nextRequestId = 1; + +export class UcpResource { + private fetchImpl: typeof globalThis.fetch; + private profileUrl: string; + private timeoutMs: number; + private negotiatedCache: Map = new Map(); + + constructor(options: UcpResourceOptions) { + this.fetchImpl = options.fetch ?? globalThis.fetch; + this.profileUrl = options.profileUrl; + this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + } + + async discover(businessUrl: string): Promise { + const normalized = this.normalizeBusinessUrl(businessUrl); + const wellKnownUrl = `${normalized}/.well-known/ucp`; + + const response = await this.httpGet(wellKnownUrl); + if (!response.ok) { + throw new UcpError( + `Discovery failed for ${businessUrl}: HTTP ${response.status}`, + 'DISCOVERY_FAILED', + { status: response.status, business: businessUrl }, + ); + } + + let body: unknown; + try { + body = await response.json(); + } catch { + throw new UcpError( + `Discovery response is not valid JSON: ${businessUrl}`, + 'DISCOVERY_INVALID_RESPONSE', + { business: businessUrl }, + ); + } + + const spec = (body as Record).ucp as + | UcpDiscoverySpec + | undefined; + if (!spec) { + throw new UcpError( + `Discovery response missing "ucp" field: ${businessUrl}`, + 'DISCOVERY_INVALID_RESPONSE', + { business: businessUrl, body }, + ); + } + + const mcpEndpoint = this.extractMcpEndpoint(spec); + const capabilities = Object.keys(spec.capabilities ?? {}); + const paymentHandlers = Object.keys(spec.payment_handlers ?? {}); + + return { + business: normalized, + ucp: spec, + mcp_endpoint: mcpEndpoint, + capabilities, + payment_handlers: paymentHandlers, + }; + } + + async catalogSearch( + businessUrl: string, + params: UcpCatalogSearchParams, + ): Promise { + const { query, limit, cursor, ...rest } = params; + const args: Record = { + catalog: { + query, + ...(limit !== undefined && { limit }), + ...(cursor !== undefined && { cursor }), + }, + ...rest, + }; + return this.callTool(businessUrl, 'search_catalog', args); + } + + async catalogLookup( + businessUrl: string, + params: UcpCatalogLookupParams, + ): Promise { + const { ids, ...rest } = params; + return this.callTool(businessUrl, 'lookup_catalog', { + catalog: { ids }, + ...rest, + }); + } + + async cartCreate( + businessUrl: string, + params: UcpCartCreateParams, + ): Promise { + return this.callTool(businessUrl, 'create_cart', { cart: params }); + } + + async cartGet( + businessUrl: string, + cartId: string, + ): Promise { + return this.callTool(businessUrl, 'get_cart', { id: cartId }); + } + + async cartUpdate( + businessUrl: string, + cartId: string, + params: UcpCartUpdateParams, + ): Promise { + return this.callTool(businessUrl, 'update_cart', { + id: cartId, + cart: params, + }); + } + + async checkoutCreate( + businessUrl: string, + params: UcpCheckoutCreateParams, + ): Promise { + return this.callTool(businessUrl, 'create_checkout', { checkout: params }); + } + + async checkoutGet( + businessUrl: string, + checkoutId: string, + ): Promise { + return this.callTool(businessUrl, 'get_checkout', { id: checkoutId }); + } + + async checkoutUpdate( + businessUrl: string, + checkoutId: string, + params: UcpCheckoutUpdateParams, + ): Promise { + return this.callTool(businessUrl, 'update_checkout', { + id: checkoutId, + checkout: params, + }); + } + + async checkoutComplete( + businessUrl: string, + checkoutId: string, + params?: UcpCheckoutCompleteParams, + ): Promise { + return this.callTool(businessUrl, 'complete_checkout', { + id: checkoutId, + ...(params ?? {}), + }); + } + + // --- Internal --- + + private async negotiate(businessUrl: string): Promise { + const cached = this.negotiatedCache.get(businessUrl); + if (cached) return cached; + + if (!this.profileUrl) { + throw new UcpError( + 'A --profile-url is required for UCP operations (catalog, cart, checkout). Use `ucp discover` without one to inspect capabilities first.', + 'PROFILE_REQUIRED', + { business: businessUrl }, + ); + } + + const discovery = await this.discover(businessUrl); + if (!discovery.mcp_endpoint) { + throw new UcpError( + `No MCP endpoint found for ${businessUrl}. The merchant may not support MCP transport.`, + 'NO_MCP_ENDPOINT', + { business: businessUrl }, + ); + } + + const toolsListResult = await this.mcpRpc<{ tools: ToolDescriptor[] }>( + discovery.mcp_endpoint, + 'tools/list', + { + arguments: { + meta: { + 'ucp-agent': { profile: this.profileUrl }, + }, + }, + }, + ); + + const tools: Record = {}; + for (const tool of toolsListResult.tools ?? []) { + tools[tool.name] = tool; + } + + const negotiated: NegotiatedEndpoint = { + endpoint: discovery.mcp_endpoint, + tools, + }; + this.negotiatedCache.set(businessUrl, negotiated); + return negotiated; + } + + private async callTool( + businessUrl: string, + toolName: string, + args: Record, + ): Promise { + const normalized = this.normalizeBusinessUrl(businessUrl); + const negotiated = await this.negotiate(normalized); + + if (!negotiated.tools[toolName]) { + throw new UcpError( + `Tool "${toolName}" not available on ${businessUrl}. Available: ${Object.keys(negotiated.tools).join(', ')}`, + 'TOOL_NOT_FOUND', + { + business: businessUrl, + tool: toolName, + available: Object.keys(negotiated.tools), + }, + ); + } + + const toolArgs: Record = { + ...args, + meta: { + ...((args.meta as Record) ?? {}), + 'ucp-agent': { profile: this.profileUrl }, + 'idempotency-key': crypto.randomUUID(), + }, + }; + + const params: McpToolCallParams = { + name: toolName, + arguments: toolArgs, + }; + + const result = await this.mcpRpc( + negotiated.endpoint, + 'tools/call', + params, + ); + + return this.unwrapResult(result, toolName, businessUrl); + } + + private unwrapResult( + result: McpToolCallResult, + toolName: string, + businessUrl: string, + ): UcpOperationResult { + // Prefer structuredContent (newer MCP), fall back to text content + const r = result as unknown as Record; + if ( + typeof r.structuredContent === 'object' && + r.structuredContent !== null + ) { + return r.structuredContent as UcpOperationResult; + } + + const textContent = result.content?.find((c) => c.type === 'text'); + if (textContent?.text) { + try { + const parsed = JSON.parse(textContent.text) as UcpOperationResult; + // UCP uses isError for escalation responses too — return them as data + // so agents can inspect status/continue_url + return parsed; + } catch { + if (result.isError) { + throw new UcpError( + `UCP operation ${toolName} failed: ${textContent.text}`, + 'OPERATION_FAILED', + { business: businessUrl, tool: toolName, content: result.content }, + ); + } + return { raw_text: textContent.text } as UcpOperationResult; + } + } + + if (result.isError) { + throw new UcpError( + `UCP operation ${toolName} failed with no content`, + 'OPERATION_FAILED', + { business: businessUrl, tool: toolName, content: result.content }, + ); + } + + return { content: result.content } as UcpOperationResult; + } + + private async mcpRpc( + endpoint: string, + method: string, + params?: unknown, + ): Promise { + const id = nextRequestId++; + const body = JSON.stringify({ + jsonrpc: '2.0', + id, + method, + ...(params !== undefined ? { params } : {}), + }); + + const response = await this.fetchImpl(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body, + signal: AbortSignal.timeout(this.timeoutMs), + }); + + const responseText = await response.text(); + let parsed: unknown; + try { + parsed = JSON.parse(responseText); + } catch { + throw new UcpError( + `MCP response is not valid JSON from ${endpoint}`, + 'MCP_INVALID_RESPONSE', + { endpoint, method, status: response.status }, + ); + } + + const envelope = parsed as Record; + if (envelope.jsonrpc !== '2.0') { + throw new UcpError( + `Invalid JSON-RPC response from ${endpoint}`, + 'MCP_INVALID_RESPONSE', + { endpoint, method, body: envelope }, + ); + } + + if (envelope.error) { + const err = envelope.error as { + code: number; + message: string; + data?: unknown; + }; + throw new UcpError( + `MCP RPC error from ${endpoint}: (${err.code}) ${err.message}`, + 'MCP_RPC_ERROR', + { endpoint, method, rpcCode: err.code, rpcData: err.data }, + ); + } + + return envelope.result as T; + } + + private async httpGet(url: string): Promise { + return this.fetchImpl(url, { + method: 'GET', + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(this.timeoutMs), + }); + } + + private normalizeBusinessUrl(url: string): string { + let normalized = url.trim(); + if ( + !normalized.startsWith('http://') && + !normalized.startsWith('https://') + ) { + normalized = `https://${normalized}`; + } + return normalized.replace(/\/+$/, ''); + } + + private extractMcpEndpoint(spec: UcpDiscoverySpec): string | null { + const shoppingService = spec.services?.['dev.ucp.shopping']; + if (!shoppingService) return null; + const mcpBinding = shoppingService.find((s) => s.transport === 'mcp'); + return mcpBinding?.endpoint ?? null; + } +} + +export class UcpError extends Error { + code: string; + context: Record; + + constructor( + message: string, + code: string, + context: Record = {}, + ) { + super(message); + this.name = 'UcpError'; + this.code = code; + this.context = context; + } +} From e13032a9a84d1d7792c9e0446be68d13e1ec8454 Mon Sep 17 00:00:00 2001 From: Ryan Aubrey Date: Fri, 26 Jun 2026 11:52:30 -0400 Subject: [PATCH 02/15] Add ucp order get command for fulfillment tracking Adds the `ucp order get` command which retrieves the current state of an order including fulfillment expectations, tracking events, and line item delivery status via the UCP `get_order` MCP tool. Co-Authored-By: Claude Opus 4.6 (1M context) Committed-By-Agent: claude --- packages/cli/src/commands/ucp/index.tsx | 21 +++++++++++++++++++++ packages/cli/src/commands/ucp/schema.ts | 4 ++++ packages/sdk/src/resources/ucp-types.ts | 7 +++++++ packages/sdk/src/resources/ucp.ts | 8 ++++++++ 4 files changed, 40 insertions(+) diff --git a/packages/cli/src/commands/ucp/index.tsx b/packages/cli/src/commands/ucp/index.tsx index d543aca..87bf46a 100644 --- a/packages/cli/src/commands/ucp/index.tsx +++ b/packages/cli/src/commands/ucp/index.tsx @@ -11,6 +11,7 @@ import { checkoutCreateOptions, checkoutGetOptions, checkoutUpdateOptions, + orderGetOptions, } from './schema'; function createUcpResource(profileUrl?: string): UcpResource { @@ -201,5 +202,25 @@ export function createUcpCli() { cli.command(checkoutCli); + // --- order --- + + const orderCli = Cli.create('order', { + description: + 'Retrieve order state including fulfillment tracking and delivery status', + }); + + orderCli.command('get', { + description: + 'Get the current state of an order, including fulfillment expectations, tracking events, and line item status', + options: orderGetOptions, + outputPolicy: 'agent-only' as const, + async run(c) { + const resource = createUcpResource(c.options.profileUrl); + return resource.orderGet(c.options.business, c.options.id); + }, + }); + + cli.command(orderCli); + return cli; } diff --git a/packages/cli/src/commands/ucp/schema.ts b/packages/cli/src/commands/ucp/schema.ts index aaa78d6..ece5b06 100644 --- a/packages/cli/src/commands/ucp/schema.ts +++ b/packages/cli/src/commands/ucp/schema.ts @@ -81,3 +81,7 @@ export const checkoutCompleteOptions = businessOption.extend({ .optional() .describe('Complete payload as JSON (e.g. payment method details)'), }); + +export const orderGetOptions = businessOption.extend({ + id: z.string().describe('Order ID (returned from checkout complete)'), +}); diff --git a/packages/sdk/src/resources/ucp-types.ts b/packages/sdk/src/resources/ucp-types.ts index 529bab7..ea51cfb 100644 --- a/packages/sdk/src/resources/ucp-types.ts +++ b/packages/sdk/src/resources/ucp-types.ts @@ -111,6 +111,13 @@ export interface UcpCheckoutCompleteParams { [key: string]: unknown; } +// --- Order --- + +export interface UcpOrderGetParams { + id: string; + [key: string]: unknown; +} + // --- Generic operation result (pass-through) --- // UCP responses are passed through raw; these are the common envelope fields. diff --git a/packages/sdk/src/resources/ucp.ts b/packages/sdk/src/resources/ucp.ts index 74caa19..937e455 100644 --- a/packages/sdk/src/resources/ucp.ts +++ b/packages/sdk/src/resources/ucp.ts @@ -11,6 +11,7 @@ import type { UcpDiscoveryResult, UcpDiscoverySpec, UcpOperationResult, + UcpOrderGetParams, } from './ucp-types'; export interface UcpResourceOptions { @@ -182,6 +183,13 @@ export class UcpResource { }); } + async orderGet( + businessUrl: string, + orderId: string, + ): Promise { + return this.callTool(businessUrl, 'get_order', { id: orderId }); + } + // --- Internal --- private async negotiate(businessUrl: string): Promise { From 2b1dfe90928fa91e5c9ebd363c2deb0cecf67199 Mon Sep 17 00:00:00 2001 From: Ryan Aubrey Date: Fri, 26 Jun 2026 12:22:35 -0400 Subject: [PATCH 03/15] Add REST transport support to UCP with auto-fallback to MCP UcpResource now supports both REST and MCP transports. In the default "auto" mode, REST is preferred when the merchant advertises a REST endpoint. If the REST call returns 405/501 (route not implemented), it falls back to MCP transparently. A --transport flag lets callers force a specific transport for debugging. Tested against acs-exploration.vercel.app which exposes both transports. Co-Authored-By: Claude Opus 4.6 (1M context) Committed-By-Agent: claude --- packages/cli/src/commands/ucp/index.tsx | 29 ++- packages/cli/src/commands/ucp/schema.ts | 6 + packages/sdk/src/resources/ucp-types.ts | 1 + packages/sdk/src/resources/ucp.ts | 301 +++++++++++++++++++----- 4 files changed, 270 insertions(+), 67 deletions(-) diff --git a/packages/cli/src/commands/ucp/index.tsx b/packages/cli/src/commands/ucp/index.tsx index 87bf46a..f7b1cce 100644 --- a/packages/cli/src/commands/ucp/index.tsx +++ b/packages/cli/src/commands/ucp/index.tsx @@ -1,4 +1,4 @@ -import { UcpResource } from '@stripe/link-sdk'; +import { type UcpTransport, UcpResource } from '@stripe/link-sdk'; import { Cli, z } from 'incur'; import { businessOption, @@ -14,8 +14,11 @@ import { orderGetOptions, } from './schema'; -function createUcpResource(profileUrl?: string): UcpResource { - return new UcpResource({ profileUrl: profileUrl ?? '' }); +function createUcpResource( + profileUrl?: string, + transport?: UcpTransport, +): UcpResource { + return new UcpResource({ profileUrl: profileUrl ?? '', transport }); } export function createUcpCli() { @@ -59,7 +62,7 @@ export function createUcpCli() { options: catalogSearchOptions, outputPolicy: 'agent-only' as const, async run(c) { - const resource = createUcpResource(c.options.profileUrl); + const resource = createUcpResource(c.options.profileUrl, c.options.transport); return resource.catalogSearch(c.options.business, { query: c.options.query, limit: c.options.limit, @@ -73,7 +76,7 @@ export function createUcpCli() { options: catalogLookupOptions, outputPolicy: 'agent-only' as const, async run(c) { - const resource = createUcpResource(c.options.profileUrl); + const resource = createUcpResource(c.options.profileUrl, c.options.transport); const ids = c.options.ids.split(',').map((id) => id.trim()); return resource.catalogLookup(c.options.business, { ids }); }, @@ -92,7 +95,7 @@ export function createUcpCli() { options: cartCreateOptions, outputPolicy: 'agent-only' as const, async run(c) { - const resource = createUcpResource(c.options.profileUrl); + const resource = createUcpResource(c.options.profileUrl, c.options.transport); if (c.options.input) { const parsed = JSON.parse(c.options.input); return resource.cartCreate(c.options.business, parsed); @@ -109,7 +112,7 @@ export function createUcpCli() { options: cartGetOptions, outputPolicy: 'agent-only' as const, async run(c) { - const resource = createUcpResource(c.options.profileUrl); + const resource = createUcpResource(c.options.profileUrl, c.options.transport); return resource.cartGet(c.options.business, c.options.id); }, }); @@ -119,7 +122,7 @@ export function createUcpCli() { options: cartUpdateOptions, outputPolicy: 'agent-only' as const, async run(c) { - const resource = createUcpResource(c.options.profileUrl); + const resource = createUcpResource(c.options.profileUrl, c.options.transport); if (c.options.input) { const parsed = JSON.parse(c.options.input); return resource.cartUpdate(c.options.business, c.options.id, parsed); @@ -147,7 +150,7 @@ export function createUcpCli() { options: checkoutCreateOptions, outputPolicy: 'agent-only' as const, async run(c) { - const resource = createUcpResource(c.options.profileUrl); + const resource = createUcpResource(c.options.profileUrl, c.options.transport); if (c.options.input) { const parsed = JSON.parse(c.options.input); return resource.checkoutCreate(c.options.business, parsed); @@ -168,7 +171,7 @@ export function createUcpCli() { options: checkoutGetOptions, outputPolicy: 'agent-only' as const, async run(c) { - const resource = createUcpResource(c.options.profileUrl); + const resource = createUcpResource(c.options.profileUrl, c.options.transport); return resource.checkoutGet(c.options.business, c.options.id); }, }); @@ -178,7 +181,7 @@ export function createUcpCli() { options: checkoutUpdateOptions, outputPolicy: 'agent-only' as const, async run(c) { - const resource = createUcpResource(c.options.profileUrl); + const resource = createUcpResource(c.options.profileUrl, c.options.transport); const parsed = JSON.parse(c.options.input); return resource.checkoutUpdate(c.options.business, c.options.id, parsed); }, @@ -190,7 +193,7 @@ export function createUcpCli() { options: checkoutCompleteOptions, outputPolicy: 'agent-only' as const, async run(c) { - const resource = createUcpResource(c.options.profileUrl); + const resource = createUcpResource(c.options.profileUrl, c.options.transport); const params = c.options.input ? JSON.parse(c.options.input) : undefined; return resource.checkoutComplete( c.options.business, @@ -215,7 +218,7 @@ export function createUcpCli() { options: orderGetOptions, outputPolicy: 'agent-only' as const, async run(c) { - const resource = createUcpResource(c.options.profileUrl); + const resource = createUcpResource(c.options.profileUrl, c.options.transport); return resource.orderGet(c.options.business, c.options.id); }, }); diff --git a/packages/cli/src/commands/ucp/schema.ts b/packages/cli/src/commands/ucp/schema.ts index ece5b06..8304b7a 100644 --- a/packages/cli/src/commands/ucp/schema.ts +++ b/packages/cli/src/commands/ucp/schema.ts @@ -11,6 +11,12 @@ export const businessOption = z.object({ .describe( 'Agent profile URL that identifies this agent to the merchant', ), + transport: z + .enum(['auto', 'rest', 'mcp']) + .optional() + .describe( + 'Transport to use: auto (prefer REST, fall back to MCP), rest, or mcp', + ), }); export const catalogSearchOptions = businessOption.extend({ diff --git a/packages/sdk/src/resources/ucp-types.ts b/packages/sdk/src/resources/ucp-types.ts index ea51cfb..b820726 100644 --- a/packages/sdk/src/resources/ucp-types.ts +++ b/packages/sdk/src/resources/ucp-types.ts @@ -40,6 +40,7 @@ export interface UcpDiscoveryResult { business: string; ucp: UcpDiscoverySpec; mcp_endpoint: string | null; + rest_endpoint: string | null; capabilities: string[]; payment_handlers: string[]; } diff --git a/packages/sdk/src/resources/ucp.ts b/packages/sdk/src/resources/ucp.ts index 937e455..c44cfea 100644 --- a/packages/sdk/src/resources/ucp.ts +++ b/packages/sdk/src/resources/ucp.ts @@ -14,10 +14,13 @@ import type { UcpOrderGetParams, } from './ucp-types'; +export type UcpTransport = 'auto' | 'rest' | 'mcp'; + export interface UcpResourceOptions { fetch?: typeof globalThis.fetch; profileUrl: string; timeoutMs?: number; + transport?: UcpTransport; } const DEFAULT_TIMEOUT_MS = 30_000; @@ -33,22 +36,54 @@ interface NegotiatedEndpoint { tools: Record; } +interface RestRoute { + method: string; + path: string; + bodyKey?: string; +} + +const REST_ROUTES: Record = { + search_catalog: { method: 'POST', path: '/catalog/search', bodyKey: 'catalog' }, + lookup_catalog: { method: 'POST', path: '/catalog/lookup', bodyKey: 'catalog' }, + get_product: { method: 'POST', path: '/catalog/product' }, + create_cart: { method: 'POST', path: '/carts', bodyKey: 'cart' }, + get_cart: { method: 'GET', path: '/carts/{id}' }, + update_cart: { method: 'PUT', path: '/carts/{id}', bodyKey: 'cart' }, + cancel_cart: { method: 'POST', path: '/carts/{id}/cancel' }, + create_checkout: { method: 'POST', path: '/checkout-sessions', bodyKey: 'checkout' }, + get_checkout: { method: 'GET', path: '/checkout-sessions/{id}' }, + update_checkout: { method: 'PUT', path: '/checkout-sessions/{id}', bodyKey: 'checkout' }, + complete_checkout: { + method: 'POST', + path: '/checkout-sessions/{id}/complete', + }, + cancel_checkout: { method: 'POST', path: '/checkout-sessions/{id}/cancel' }, + get_order: { method: 'GET', path: '/orders/{id}' }, +}; + let nextRequestId = 1; export class UcpResource { private fetchImpl: typeof globalThis.fetch; private profileUrl: string; private timeoutMs: number; + private transport: UcpTransport; private negotiatedCache: Map = new Map(); + private discoveryCache: Map = new Map(); constructor(options: UcpResourceOptions) { this.fetchImpl = options.fetch ?? globalThis.fetch; this.profileUrl = options.profileUrl; this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + this.transport = options.transport ?? 'auto'; } async discover(businessUrl: string): Promise { const normalized = this.normalizeBusinessUrl(businessUrl); + + const cached = this.discoveryCache.get(normalized); + if (cached) return cached; + const wellKnownUrl = `${normalized}/.well-known/ucp`; const response = await this.httpGet(wellKnownUrl); @@ -82,17 +117,21 @@ export class UcpResource { ); } - const mcpEndpoint = this.extractMcpEndpoint(spec); + const { mcpEndpoint, restEndpoint } = this.extractEndpoints(spec); const capabilities = Object.keys(spec.capabilities ?? {}); const paymentHandlers = Object.keys(spec.payment_handlers ?? {}); - return { + const result: UcpDiscoveryResult = { business: normalized, ucp: spec, mcp_endpoint: mcpEndpoint, + rest_endpoint: restEndpoint, capabilities, payment_handlers: paymentHandlers, }; + + this.discoveryCache.set(normalized, result); + return result; } async catalogSearch( @@ -108,7 +147,7 @@ export class UcpResource { }, ...rest, }; - return this.callTool(businessUrl, 'search_catalog', args); + return this.callOperation(businessUrl, 'search_catalog', args); } async catalogLookup( @@ -116,7 +155,7 @@ export class UcpResource { params: UcpCatalogLookupParams, ): Promise { const { ids, ...rest } = params; - return this.callTool(businessUrl, 'lookup_catalog', { + return this.callOperation(businessUrl, 'lookup_catalog', { catalog: { ids }, ...rest, }); @@ -126,14 +165,14 @@ export class UcpResource { businessUrl: string, params: UcpCartCreateParams, ): Promise { - return this.callTool(businessUrl, 'create_cart', { cart: params }); + return this.callOperation(businessUrl, 'create_cart', { cart: params }); } async cartGet( businessUrl: string, cartId: string, ): Promise { - return this.callTool(businessUrl, 'get_cart', { id: cartId }); + return this.callOperation(businessUrl, 'get_cart', { id: cartId }); } async cartUpdate( @@ -141,7 +180,7 @@ export class UcpResource { cartId: string, params: UcpCartUpdateParams, ): Promise { - return this.callTool(businessUrl, 'update_cart', { + return this.callOperation(businessUrl, 'update_cart', { id: cartId, cart: params, }); @@ -151,14 +190,16 @@ export class UcpResource { businessUrl: string, params: UcpCheckoutCreateParams, ): Promise { - return this.callTool(businessUrl, 'create_checkout', { checkout: params }); + return this.callOperation(businessUrl, 'create_checkout', { + checkout: params, + }); } async checkoutGet( businessUrl: string, checkoutId: string, ): Promise { - return this.callTool(businessUrl, 'get_checkout', { id: checkoutId }); + return this.callOperation(businessUrl, 'get_checkout', { id: checkoutId }); } async checkoutUpdate( @@ -166,7 +207,7 @@ export class UcpResource { checkoutId: string, params: UcpCheckoutUpdateParams, ): Promise { - return this.callTool(businessUrl, 'update_checkout', { + return this.callOperation(businessUrl, 'update_checkout', { id: checkoutId, checkout: params, }); @@ -177,7 +218,7 @@ export class UcpResource { checkoutId: string, params?: UcpCheckoutCompleteParams, ): Promise { - return this.callTool(businessUrl, 'complete_checkout', { + return this.callOperation(businessUrl, 'complete_checkout', { id: checkoutId, ...(params ?? {}), }); @@ -187,64 +228,170 @@ export class UcpResource { businessUrl: string, orderId: string, ): Promise { - return this.callTool(businessUrl, 'get_order', { id: orderId }); + return this.callOperation(businessUrl, 'get_order', { id: orderId }); } - // --- Internal --- + // --- Internal: Transport Selection --- - private async negotiate(businessUrl: string): Promise { - const cached = this.negotiatedCache.get(businessUrl); - if (cached) return cached; + private async callOperation( + businessUrl: string, + operationId: string, + args: Record, + ): Promise { + const normalized = this.normalizeBusinessUrl(businessUrl); if (!this.profileUrl) { throw new UcpError( 'A --profile-url is required for UCP operations (catalog, cart, checkout). Use `ucp discover` without one to inspect capabilities first.', 'PROFILE_REQUIRED', - { business: businessUrl }, + { business: normalized }, ); } - const discovery = await this.discover(businessUrl); - if (!discovery.mcp_endpoint) { - throw new UcpError( - `No MCP endpoint found for ${businessUrl}. The merchant may not support MCP transport.`, - 'NO_MCP_ENDPOINT', - { business: businessUrl }, - ); + const discovery = await this.discover(normalized); + const useRest = this.shouldUseRest(discovery, operationId); + + if (useRest) { + // In auto mode, fall back to MCP on 405 (route not implemented) + if (this.transport === 'auto' && discovery.mcp_endpoint) { + try { + return await this.callRest(discovery.rest_endpoint!, operationId, args); + } catch (err) { + if ( + err instanceof UcpError && + err.code === 'REST_REQUEST_FAILED' && + (err.context.status === 405 || err.context.status === 501) + ) { + return this.callMcp(normalized, operationId, args); + } + throw err; + } + } + return this.callRest(discovery.rest_endpoint!, operationId, args); } + return this.callMcp(normalized, operationId, args); + } - const toolsListResult = await this.mcpRpc<{ tools: ToolDescriptor[] }>( - discovery.mcp_endpoint, - 'tools/list', - { - arguments: { - meta: { - 'ucp-agent': { profile: this.profileUrl }, - }, - }, - }, - ); + private shouldUseRest( + discovery: UcpDiscoveryResult, + operationId: string, + ): boolean { + if (this.transport === 'mcp') return false; + if (this.transport === 'rest') { + if (!discovery.rest_endpoint) { + throw new UcpError( + 'REST transport requested but merchant does not advertise a REST endpoint.', + 'NO_REST_ENDPOINT', + { business: discovery.business }, + ); + } + if (!REST_ROUTES[operationId]) { + throw new UcpError( + `No REST route mapping for operation "${operationId}".`, + 'NO_REST_ROUTE', + { business: discovery.business, operation: operationId }, + ); + } + return true; + } + // auto: prefer REST when available and route is known + return !!discovery.rest_endpoint && !!REST_ROUTES[operationId]; + } - const tools: Record = {}; - for (const tool of toolsListResult.tools ?? []) { - tools[tool.name] = tool; + // --- REST Transport --- + + private async callRest( + restEndpoint: string, + operationId: string, + args: Record, + ): Promise { + const route = REST_ROUTES[operationId]!; + const id = args.id as string | undefined; + + let path = route.path; + if (id && path.includes('{id}')) { + path = path.replace('{id}', encodeURIComponent(id)); } - const negotiated: NegotiatedEndpoint = { - endpoint: discovery.mcp_endpoint, - tools, + const url = `${restEndpoint.replace(/\/+$/, '')}${path}`; + + const headers: Record = { + Accept: 'application/json', + 'UCP-Agent': `profile="${this.profileUrl}"`, + 'Request-Id': crypto.randomUUID(), }; - this.negotiatedCache.set(businessUrl, negotiated); - return negotiated; + + const hasBody = route.method !== 'GET'; + if (hasBody) { + headers['Content-Type'] = 'application/json'; + headers['Idempotency-Key'] = crypto.randomUUID(); + } + + let body: string | undefined; + if (hasBody) { + const { id: _id, meta: _meta, ...payload } = args; + // If route has a bodyKey, unwrap that key as the REST body + const restBody = route.bodyKey + ? (payload[route.bodyKey] as Record) ?? payload + : payload; + body = JSON.stringify(restBody); + } + + const response = await this.fetchImpl(url, { + method: route.method, + headers, + ...(body !== undefined ? { body } : {}), + signal: AbortSignal.timeout(this.timeoutMs), + }); + + const responseText = await response.text(); + if (!responseText) { + if (!response.ok) { + throw new UcpError( + `REST ${route.method} ${path} failed: HTTP ${response.status}`, + 'REST_REQUEST_FAILED', + { url, status: response.status, operation: operationId }, + ); + } + return {} as UcpOperationResult; + } + + let parsed: unknown; + try { + parsed = JSON.parse(responseText); + } catch { + throw new UcpError( + `REST response is not valid JSON from ${url}`, + 'REST_INVALID_RESPONSE', + { url, status: response.status, operation: operationId }, + ); + } + + if (!response.ok) { + const errBody = parsed as Record; + const messages = errBody.messages as + | Array<{ code?: string; content?: string }> + | undefined; + const msg = + messages?.[0]?.content ?? `HTTP ${response.status}`; + throw new UcpError( + `REST ${route.method} ${path} failed: ${msg}`, + 'REST_REQUEST_FAILED', + { url, status: response.status, operation: operationId, body: errBody }, + ); + } + + return parsed as UcpOperationResult; } - private async callTool( + // --- MCP Transport --- + + private async callMcp( businessUrl: string, toolName: string, args: Record, ): Promise { - const normalized = this.normalizeBusinessUrl(businessUrl); - const negotiated = await this.negotiate(normalized); + const negotiated = await this.negotiate(businessUrl); if (!negotiated.tools[toolName]) { throw new UcpError( @@ -278,15 +425,52 @@ export class UcpResource { params, ); - return this.unwrapResult(result, toolName, businessUrl); + return this.unwrapMcpResult(result, toolName, businessUrl); } - private unwrapResult( + private async negotiate(businessUrl: string): Promise { + const cached = this.negotiatedCache.get(businessUrl); + if (cached) return cached; + + const discovery = await this.discover(businessUrl); + if (!discovery.mcp_endpoint) { + throw new UcpError( + `No MCP endpoint found for ${businessUrl}. The merchant may not support MCP transport.`, + 'NO_MCP_ENDPOINT', + { business: businessUrl }, + ); + } + + const toolsListResult = await this.mcpRpc<{ tools: ToolDescriptor[] }>( + discovery.mcp_endpoint, + 'tools/list', + { + arguments: { + meta: { + 'ucp-agent': { profile: this.profileUrl }, + }, + }, + }, + ); + + const tools: Record = {}; + for (const tool of toolsListResult.tools ?? []) { + tools[tool.name] = tool; + } + + const negotiated: NegotiatedEndpoint = { + endpoint: discovery.mcp_endpoint, + tools, + }; + this.negotiatedCache.set(businessUrl, negotiated); + return negotiated; + } + + private unwrapMcpResult( result: McpToolCallResult, toolName: string, businessUrl: string, ): UcpOperationResult { - // Prefer structuredContent (newer MCP), fall back to text content const r = result as unknown as Record; if ( typeof r.structuredContent === 'object' && @@ -299,8 +483,6 @@ export class UcpResource { if (textContent?.text) { try { const parsed = JSON.parse(textContent.text) as UcpOperationResult; - // UCP uses isError for escalation responses too — return them as data - // so agents can inspect status/continue_url return parsed; } catch { if (result.isError) { @@ -385,6 +567,8 @@ export class UcpResource { return envelope.result as T; } + // --- Helpers --- + private async httpGet(url: string): Promise { return this.fetchImpl(url, { method: 'GET', @@ -404,11 +588,20 @@ export class UcpResource { return normalized.replace(/\/+$/, ''); } - private extractMcpEndpoint(spec: UcpDiscoverySpec): string | null { + private extractEndpoints(spec: UcpDiscoverySpec): { + mcpEndpoint: string | null; + restEndpoint: string | null; + } { const shoppingService = spec.services?.['dev.ucp.shopping']; - if (!shoppingService) return null; + if (!shoppingService) return { mcpEndpoint: null, restEndpoint: null }; + const mcpBinding = shoppingService.find((s) => s.transport === 'mcp'); - return mcpBinding?.endpoint ?? null; + const restBinding = shoppingService.find((s) => s.transport === 'rest'); + + return { + mcpEndpoint: mcpBinding?.endpoint ?? null, + restEndpoint: restBinding?.endpoint ?? null, + }; } } From 252f88e1256bd7dfba0670dadb73ae3ff2dacb6f Mon Sep 17 00:00:00 2001 From: Ryan Aubrey Date: Fri, 26 Jun 2026 12:30:23 -0400 Subject: [PATCH 04/15] Remove silent REST-to-MCP fallback in auto mode Let REST errors surface directly instead of silently retrying over MCP. Users can explicitly pass --transport mcp if a merchant's REST endpoint doesn't support a given operation. Co-Authored-By: Claude Opus 4.6 (1M context) Committed-By-Agent: claude --- packages/sdk/src/resources/ucp.ts | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/packages/sdk/src/resources/ucp.ts b/packages/sdk/src/resources/ucp.ts index c44cfea..53de2b5 100644 --- a/packages/sdk/src/resources/ucp.ts +++ b/packages/sdk/src/resources/ucp.ts @@ -252,21 +252,6 @@ export class UcpResource { const useRest = this.shouldUseRest(discovery, operationId); if (useRest) { - // In auto mode, fall back to MCP on 405 (route not implemented) - if (this.transport === 'auto' && discovery.mcp_endpoint) { - try { - return await this.callRest(discovery.rest_endpoint!, operationId, args); - } catch (err) { - if ( - err instanceof UcpError && - err.code === 'REST_REQUEST_FAILED' && - (err.context.status === 405 || err.context.status === 501) - ) { - return this.callMcp(normalized, operationId, args); - } - throw err; - } - } return this.callRest(discovery.rest_endpoint!, operationId, args); } return this.callMcp(normalized, operationId, args); From 9f21db39284e9da7e73abe791a2419d89833141b Mon Sep 17 00:00:00 2001 From: Ryan Aubrey Date: Fri, 26 Jun 2026 12:31:00 -0400 Subject: [PATCH 05/15] Remove Shopify references from UCP command descriptions Co-Authored-By: Claude Opus 4.6 (1M context) Committed-By-Agent: claude --- packages/cli/src/commands/ucp/index.tsx | 2 +- packages/cli/src/commands/ucp/schema.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/ucp/index.tsx b/packages/cli/src/commands/ucp/index.tsx index f7b1cce..8572a18 100644 --- a/packages/cli/src/commands/ucp/index.tsx +++ b/packages/cli/src/commands/ucp/index.tsx @@ -34,7 +34,7 @@ export function createUcpCli() { business: z .string() .describe( - 'Business URL to discover (e.g. https://ucp-demo.myshopify.com)', + 'Business URL to discover (e.g. https://shop.example.com)', ), }), options: z.object({ diff --git a/packages/cli/src/commands/ucp/schema.ts b/packages/cli/src/commands/ucp/schema.ts index 8304b7a..16f4a65 100644 --- a/packages/cli/src/commands/ucp/schema.ts +++ b/packages/cli/src/commands/ucp/schema.ts @@ -4,7 +4,7 @@ export const businessOption = z.object({ business: z .string() .describe( - 'Business URL to interact with (e.g. https://ucp-demo.myshopify.com)', + 'Business URL to interact with (e.g. https://shop.example.com)', ), profileUrl: z .string() From 58a970747d2f619336481e0ec003efa5aa805b14 Mon Sep 17 00:00:00 2001 From: Ryan Aubrey Date: Fri, 26 Jun 2026 12:33:45 -0400 Subject: [PATCH 06/15] Use merchant.example.com in UCP example URLs Co-Authored-By: Claude Opus 4.6 (1M context) Committed-By-Agent: claude --- packages/cli/src/commands/ucp/index.tsx | 2 +- packages/cli/src/commands/ucp/schema.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/ucp/index.tsx b/packages/cli/src/commands/ucp/index.tsx index 8572a18..d02fa28 100644 --- a/packages/cli/src/commands/ucp/index.tsx +++ b/packages/cli/src/commands/ucp/index.tsx @@ -34,7 +34,7 @@ export function createUcpCli() { business: z .string() .describe( - 'Business URL to discover (e.g. https://shop.example.com)', + 'Business URL to discover (e.g. https://merchant.example.com)', ), }), options: z.object({ diff --git a/packages/cli/src/commands/ucp/schema.ts b/packages/cli/src/commands/ucp/schema.ts index 16f4a65..cde6d3b 100644 --- a/packages/cli/src/commands/ucp/schema.ts +++ b/packages/cli/src/commands/ucp/schema.ts @@ -4,7 +4,7 @@ export const businessOption = z.object({ business: z .string() .describe( - 'Business URL to interact with (e.g. https://shop.example.com)', + 'Business URL to interact with (e.g. https://merchant.example.com)', ), profileUrl: z .string() From 8d91b3190d0d73626019ea62cece0a209aa1a058 Mon Sep 17 00:00:00 2001 From: Ryan Aubrey Date: Fri, 26 Jun 2026 14:13:04 -0400 Subject: [PATCH 07/15] Replace private SPT+UCP details with basic UCP discovery guidance in skill Remove the UCP-specific SPT payment instructions (agent_provided tokenization, checkout payload format) from the create-payment-credential skill since those are private. Replace with a short UCP section that documents `ucp discover` for merchant introspection and directs agents to the standard card flow when browser-based checkout confirmation is needed. Co-Authored-By: Claude Opus 4.6 (1M context) Committed-By-Agent: claude --- plugins/link/.claude-plugin/plugin.json | 2 +- plugins/link/.codex-plugin/plugin.json | 2 +- plugins/link/.cursor-plugin/plugin.json | 2 +- skills/create-payment-credential/SKILL.md | 6 ++++-- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/plugins/link/.claude-plugin/plugin.json b/plugins/link/.claude-plugin/plugin.json index 15ddbf8..89e6d12 100644 --- a/plugins/link/.claude-plugin/plugin.json +++ b/plugins/link/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "link", - "version": "0.7.2", + "version": "0.8.1", "description": "Authenticate with Link, create spend requests, and retrieve one-time-use card or shared payment token credentials for user-approved purchases.", "author": { "name": "Stripe" diff --git a/plugins/link/.codex-plugin/plugin.json b/plugins/link/.codex-plugin/plugin.json index 3f4c52f..ffec54d 100644 --- a/plugins/link/.codex-plugin/plugin.json +++ b/plugins/link/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "link", - "version": "0.7.2", + "version": "0.8.1", "description": "Secure, one-time-use payment credentials from Link", "author": { "name": "Stripe", diff --git a/plugins/link/.cursor-plugin/plugin.json b/plugins/link/.cursor-plugin/plugin.json index b6e764e..29d0b03 100644 --- a/plugins/link/.cursor-plugin/plugin.json +++ b/plugins/link/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "link", "displayName": "Stripe Link", - "version": "0.7.2", + "version": "0.8.1", "description": "Get secure, one-time-use payment credentials from a Link wallet so agents can complete purchases on your behalf.", "author": { "name": "Stripe" diff --git a/skills/create-payment-credential/SKILL.md b/skills/create-payment-credential/SKILL.md index dd9a881..322c5a6 100644 --- a/skills/create-payment-credential/SKILL.md +++ b/skills/create-payment-credential/SKILL.md @@ -1,5 +1,5 @@ --- -version: 0.7.2 +version: 0.8.1 name: create-payment-credential description: | Gets secure, one-time-use payment credentials (cards, tokens) from a Link wallet so agents can complete purchases on behalf of users. Use when the user says "get me a card", "buy something", "pay for X", "make a purchase", "I need to pay", "complete checkout", or asks to transact on any merchant site. Use when the user asks to connect or log in to or sign up for their Link account. @@ -123,7 +123,6 @@ What you find determines which credential type to use: | Credit card form / Stripe Elements | `card` (default) | Card | | HTTP 402 with `method="stripe"` in `www-authenticate` | `shared_payment_token` | Shared payment token (SPT) | | HTTP 402 without `method="stripe"` in `www-authenticate` | not supported | Do not continue | - **For 402 responses:** The `www-authenticate` header may contain **multiple** payment challenges (e.g. `tempo`, `stripe`) in a single header value. Do not try to decode the payload manually. Pass the **full raw `WWW-Authenticate` header value** to Link CLI and let `mpp decode` select and validate the `method="stripe"` challenge. To derive `network_id`, use Link CLI's challenge decoder: @@ -194,6 +193,9 @@ link-cli mpp pay --spend-request-id [--method POST] [--data '{"amount `mpp pay` handles the full 402 flow automatically: probes the URL, parses the `www-authenticate` header, builds the `Authorization: Payment` credential using the SPT, and retries. +## UCP (Universal Commerce Protocol) + +Link CLI includes UCP commands for discovering merchants and browsing product catalogs programmatically. Use `link-cli ucp discover ` to introspect a merchant's site — this checks for UCP support and returns the merchant's capabilities, catalog endpoints, and payment options. If no UCP well-known is discovered, the merchant does not support UCP and you should fall back to other checkout methods (e.g. browsing the site and using a virtual card with the standard card flow). Use `link-cli ucp --help` to see all available subcommands. When a UCP merchant requires browser-based checkout confirmation, use the standard card flow above — provision a virtual card and fill it into the merchant's checkout page. ## Important From 2687f114c137b186b555e3fb9674e1bcd339ec24 Mon Sep 17 00:00:00 2001 From: Ryan Aubrey Date: Mon, 29 Jun 2026 09:35:07 -0400 Subject: [PATCH 08/15] Add UCP auth flags (client-id, client-secret, access-token) and tests Refactor UcpResourceOptions to group auth into a UcpAuth object with profileUrl, clientId, clientSecret, and accessToken. Auth headers are sent on both REST and MCP transports with Bearer > Basic > none precedence. Profile URL is now optional and omitted from headers when not provided. Co-Authored-By: Claude Opus 4.6 (1M context) Committed-By-Agent: claude --- packages/cli/src/__tests__/ucp.test.ts | 466 ++++++++++++++++++++++++ packages/cli/src/commands/ucp/index.tsx | 43 ++- packages/cli/src/commands/ucp/schema.ts | 12 + packages/sdk/src/__tests__/ucp.test.ts | 316 ++++++++++++++++ packages/sdk/src/resources/ucp.ts | 46 ++- 5 files changed, 858 insertions(+), 25 deletions(-) create mode 100644 packages/cli/src/__tests__/ucp.test.ts create mode 100644 packages/sdk/src/__tests__/ucp.test.ts diff --git a/packages/cli/src/__tests__/ucp.test.ts b/packages/cli/src/__tests__/ucp.test.ts new file mode 100644 index 0000000..aed7a8f --- /dev/null +++ b/packages/cli/src/__tests__/ucp.test.ts @@ -0,0 +1,466 @@ +import { execFile } from 'node:child_process'; +import http from 'node:http'; +import { promisify } from 'node:util'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +const execFileAsync = promisify(execFile); + +const CLI_PATH = new URL('../../dist/cli.js', import.meta.url).pathname; + +interface CliResult { + stdout: string; + stderr: string; + exitCode: number; +} + +interface RequestLog { + method: string; + url: string; + headers: http.IncomingHttpHeaders; + body: string; +} + +let server: http.Server; +let serverPort: number; +let requests: RequestLog[]; +let responsesByUrl: Record< + string, + { status: number; body: unknown; headers?: Record } +>; + +function discoveryResponse(port: number) { + return { + ucp: { + version: '2026-04-08', + services: { + 'dev.ucp.shopping': [ + { + version: '1.0', + spec: 'ucp', + transport: 'rest', + endpoint: `http://127.0.0.1:${port}/ucp/rest`, + }, + ], + }, + capabilities: { + 'dev.ucp.catalog': [{ version: '1.0', spec: 'ucp' }], + 'dev.ucp.cart': [{ version: '1.0', spec: 'ucp' }], + 'dev.ucp.checkout': [{ version: '1.0', spec: 'ucp' }], + }, + payment_handlers: {}, + }, + }; +} + +async function runCli(...args: string[]): Promise { + try { + const { stdout, stderr } = await execFileAsync( + 'node', + [CLI_PATH, ...args], + { + env: { + ...process.env, + XDG_DATA_HOME: '/tmp/link-cli-ucp-test', + }, + timeout: 10_000, + }, + ); + return { stdout, stderr, exitCode: 0 }; + } catch (err: unknown) { + const e = err as { stdout?: string; stderr?: string; code?: number }; + return { + stdout: e.stdout ?? '', + stderr: e.stderr ?? '', + exitCode: e.code ?? 1, + }; + } +} + +describe('ucp commands', () => { + beforeAll(async () => { + server = http.createServer((req, res) => { + let body = ''; + req.on('data', (chunk) => { + body += chunk; + }); + req.on('end', () => { + requests.push({ + method: req.method ?? '', + url: req.url ?? '', + headers: req.headers, + body, + }); + + const match = responsesByUrl[req.url ?? '']; + if (match) { + res.writeHead(match.status, { + 'Content-Type': 'application/json', + ...match.headers, + }); + res.end(JSON.stringify(match.body)); + } else { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'not found' })); + } + }); + }); + + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + const addr = server.address() as { port: number }; + serverPort = addr.port; + resolve(); + }); + }); + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + beforeEach(() => { + requests = []; + responsesByUrl = { + '/.well-known/ucp': { + status: 200, + body: discoveryResponse(serverPort), + }, + }; + }); + + describe('discover', () => { + it('returns discovery info as JSON', async () => { + const result = await runCli( + 'ucp', + 'discover', + `http://127.0.0.1:${serverPort}`, + '--json', + ); + + expect(result.exitCode).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.business).toBe(`http://127.0.0.1:${serverPort}`); + expect(output.rest_endpoint).toBe( + `http://127.0.0.1:${serverPort}/ucp/rest`, + ); + expect(output.capabilities).toContain('dev.ucp.catalog'); + }); + + it('fails gracefully when discovery endpoint is unavailable', async () => { + responsesByUrl['/.well-known/ucp'] = { status: 503, body: {} }; + + const result = await runCli( + 'ucp', + 'discover', + `http://127.0.0.1:${serverPort}`, + '--json', + ); + + expect(result.exitCode).toBe(1); + const output = JSON.parse(result.stdout); + expect(output.message).toMatch(/Discovery failed/); + }); + }); + + describe('catalog search', () => { + it('sends search request and returns results', async () => { + responsesByUrl['/ucp/rest/catalog/search'] = { + status: 200, + body: { products: [{ id: 'prod_1', name: 'Red Boots' }] }, + }; + + const result = await runCli( + 'ucp', + 'catalog', + 'search', + '--business', + `http://127.0.0.1:${serverPort}`, + '--profile-url', + 'https://agent.example/profile.json', + '--query', + 'boots', + '--json', + ); + + expect(result.exitCode).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.products).toHaveLength(1); + expect(output.products[0].name).toBe('Red Boots'); + + const searchReq = requests.find((r) => r.url === '/ucp/rest/catalog/search'); + expect(searchReq).toBeDefined(); + expect(searchReq!.method).toBe('POST'); + expect(JSON.parse(searchReq!.body)).toEqual({ query: 'boots' }); + }); + + it('sends auth headers when --client-id and --client-secret are provided', async () => { + responsesByUrl['/ucp/rest/catalog/search'] = { + status: 200, + body: { products: [] }, + }; + + await runCli( + 'ucp', + 'catalog', + 'search', + '--business', + `http://127.0.0.1:${serverPort}`, + '--profile-url', + 'https://agent.example/profile.json', + '--client-id', + 'test_client', + '--client-secret', + 'test_secret', + '--query', + 'shoes', + '--json', + ); + + const searchReq = requests.find((r) => r.url === '/ucp/rest/catalog/search'); + expect(searchReq).toBeDefined(); + const expected = `Basic ${btoa('test_client:test_secret')}`; + expect(searchReq!.headers.authorization).toBe(expected); + }); + + it('sends Bearer token when --access-token is provided', async () => { + responsesByUrl['/ucp/rest/catalog/search'] = { + status: 200, + body: { products: [] }, + }; + + await runCli( + 'ucp', + 'catalog', + 'search', + '--business', + `http://127.0.0.1:${serverPort}`, + '--profile-url', + 'https://agent.example/profile.json', + '--access-token', + 'tok_user_123', + '--query', + 'hats', + '--json', + ); + + const searchReq = requests.find((r) => r.url === '/ucp/rest/catalog/search'); + expect(searchReq!.headers.authorization).toBe('Bearer tok_user_123'); + }); + + it('fails when --profile-url is missing', async () => { + const result = await runCli( + 'ucp', + 'catalog', + 'search', + '--business', + `http://127.0.0.1:${serverPort}`, + '--query', + 'boots', + '--json', + ); + + expect(result.exitCode).toBe(1); + }); + }); + + describe('catalog lookup', () => { + it('sends lookup request with IDs', async () => { + responsesByUrl['/ucp/rest/catalog/lookup'] = { + status: 200, + body: { products: [{ id: 'prod_1' }, { id: 'prod_2' }] }, + }; + + const result = await runCli( + 'ucp', + 'catalog', + 'lookup', + '--business', + `http://127.0.0.1:${serverPort}`, + '--profile-url', + 'https://agent.example/profile.json', + '--ids', + 'prod_1,prod_2', + '--json', + ); + + expect(result.exitCode).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.products).toHaveLength(2); + + const lookupReq = requests.find((r) => r.url === '/ucp/rest/catalog/lookup'); + expect(JSON.parse(lookupReq!.body)).toEqual({ ids: ['prod_1', 'prod_2'] }); + }); + }); + + describe('cart', () => { + it('create sends POST to /carts', async () => { + responsesByUrl['/ucp/rest/carts'] = { + status: 200, + body: { + id: 'cart_1', + line_items: [{ item: { id: 'var_1' }, quantity: 1 }], + }, + }; + + const result = await runCli( + 'ucp', + 'cart', + 'create', + '--business', + `http://127.0.0.1:${serverPort}`, + '--profile-url', + 'https://agent.example/profile.json', + '--line-items', + JSON.stringify([{ item: { id: 'var_1' }, quantity: 1 }]), + '--json', + ); + + expect(result.exitCode).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.id).toBe('cart_1'); + + const cartReq = requests.find((r) => r.url === '/ucp/rest/carts'); + expect(cartReq!.method).toBe('POST'); + }); + + it('get fetches cart by ID', async () => { + responsesByUrl['/ucp/rest/carts/cart_1'] = { + status: 200, + body: { id: 'cart_1', line_items: [] }, + }; + + const result = await runCli( + 'ucp', + 'cart', + 'get', + '--business', + `http://127.0.0.1:${serverPort}`, + '--profile-url', + 'https://agent.example/profile.json', + '--id', + 'cart_1', + '--json', + ); + + expect(result.exitCode).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.id).toBe('cart_1'); + + const getReq = requests.find((r) => r.url === '/ucp/rest/carts/cart_1'); + expect(getReq!.method).toBe('GET'); + }); + + it('update sends PUT to /carts/{id}', async () => { + responsesByUrl['/ucp/rest/carts/cart_1'] = { + status: 200, + body: { id: 'cart_1', line_items: [{ item: { id: 'var_2' }, quantity: 3 }] }, + }; + + const result = await runCli( + 'ucp', + 'cart', + 'update', + '--business', + `http://127.0.0.1:${serverPort}`, + '--profile-url', + 'https://agent.example/profile.json', + '--id', + 'cart_1', + '--line-items', + JSON.stringify([{ item: { id: 'var_2' }, quantity: 3 }]), + '--json', + ); + + expect(result.exitCode).toBe(0); + const putReq = requests.find( + (r) => r.url === '/ucp/rest/carts/cart_1' && r.method === 'PUT', + ); + expect(putReq).toBeDefined(); + }); + }); + + describe('checkout', () => { + it('create sends POST to /checkout-sessions', async () => { + responsesByUrl['/ucp/rest/checkout-sessions'] = { + status: 200, + body: { id: 'cs_1', status: 'open' }, + }; + + const result = await runCli( + 'ucp', + 'checkout', + 'create', + '--business', + `http://127.0.0.1:${serverPort}`, + '--profile-url', + 'https://agent.example/profile.json', + '--cart-id', + 'cart_1', + '--json', + ); + + expect(result.exitCode).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.id).toBe('cs_1'); + + const createReq = requests.find((r) => r.url === '/ucp/rest/checkout-sessions'); + expect(createReq!.method).toBe('POST'); + expect(JSON.parse(createReq!.body)).toMatchObject({ cart_id: 'cart_1' }); + }); + + it('get fetches checkout by ID', async () => { + responsesByUrl['/ucp/rest/checkout-sessions/cs_1'] = { + status: 200, + body: { id: 'cs_1', status: 'open' }, + }; + + const result = await runCli( + 'ucp', + 'checkout', + 'get', + '--business', + `http://127.0.0.1:${serverPort}`, + '--profile-url', + 'https://agent.example/profile.json', + '--id', + 'cs_1', + '--json', + ); + + expect(result.exitCode).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.id).toBe('cs_1'); + }); + + it('complete sends POST to /checkout-sessions/{id}/complete', async () => { + responsesByUrl['/ucp/rest/checkout-sessions/cs_1/complete'] = { + status: 200, + body: { id: 'cs_1', status: 'completed', order_id: 'ord_1' }, + }; + + const result = await runCli( + 'ucp', + 'checkout', + 'complete', + '--business', + `http://127.0.0.1:${serverPort}`, + '--profile-url', + 'https://agent.example/profile.json', + '--id', + 'cs_1', + '--input', + JSON.stringify({ payment_method: 'pm_1' }), + '--json', + ); + + expect(result.exitCode).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.order_id).toBe('ord_1'); + + const completeReq = requests.find( + (r) => r.url === '/ucp/rest/checkout-sessions/cs_1/complete', + ); + expect(completeReq!.method).toBe('POST'); + }); + }); +}); diff --git a/packages/cli/src/commands/ucp/index.tsx b/packages/cli/src/commands/ucp/index.tsx index d02fa28..2ca33f2 100644 --- a/packages/cli/src/commands/ucp/index.tsx +++ b/packages/cli/src/commands/ucp/index.tsx @@ -14,11 +14,22 @@ import { orderGetOptions, } from './schema'; -function createUcpResource( - profileUrl?: string, - transport?: UcpTransport, -): UcpResource { - return new UcpResource({ profileUrl: profileUrl ?? '', transport }); +function createUcpResource(opts: { + profileUrl?: string; + clientId?: string; + clientSecret?: string; + accessToken?: string; + transport?: UcpTransport; +}): UcpResource { + return new UcpResource({ + auth: { + profileUrl: opts.profileUrl, + clientId: opts.clientId, + clientSecret: opts.clientSecret, + accessToken: opts.accessToken, + }, + transport: opts.transport, + }); } export function createUcpCli() { @@ -45,7 +56,7 @@ export function createUcpCli() { }), outputPolicy: 'agent-only' as const, async run(c) { - const resource = createUcpResource(c.options.profileUrl); + const resource = createUcpResource({ profileUrl: c.options.profileUrl }); return resource.discover(c.args.business); }, }); @@ -62,7 +73,7 @@ export function createUcpCli() { options: catalogSearchOptions, outputPolicy: 'agent-only' as const, async run(c) { - const resource = createUcpResource(c.options.profileUrl, c.options.transport); + const resource = createUcpResource(c.options); return resource.catalogSearch(c.options.business, { query: c.options.query, limit: c.options.limit, @@ -76,7 +87,7 @@ export function createUcpCli() { options: catalogLookupOptions, outputPolicy: 'agent-only' as const, async run(c) { - const resource = createUcpResource(c.options.profileUrl, c.options.transport); + const resource = createUcpResource(c.options); const ids = c.options.ids.split(',').map((id) => id.trim()); return resource.catalogLookup(c.options.business, { ids }); }, @@ -95,7 +106,7 @@ export function createUcpCli() { options: cartCreateOptions, outputPolicy: 'agent-only' as const, async run(c) { - const resource = createUcpResource(c.options.profileUrl, c.options.transport); + const resource = createUcpResource(c.options); if (c.options.input) { const parsed = JSON.parse(c.options.input); return resource.cartCreate(c.options.business, parsed); @@ -112,7 +123,7 @@ export function createUcpCli() { options: cartGetOptions, outputPolicy: 'agent-only' as const, async run(c) { - const resource = createUcpResource(c.options.profileUrl, c.options.transport); + const resource = createUcpResource(c.options); return resource.cartGet(c.options.business, c.options.id); }, }); @@ -122,7 +133,7 @@ export function createUcpCli() { options: cartUpdateOptions, outputPolicy: 'agent-only' as const, async run(c) { - const resource = createUcpResource(c.options.profileUrl, c.options.transport); + const resource = createUcpResource(c.options); if (c.options.input) { const parsed = JSON.parse(c.options.input); return resource.cartUpdate(c.options.business, c.options.id, parsed); @@ -150,7 +161,7 @@ export function createUcpCli() { options: checkoutCreateOptions, outputPolicy: 'agent-only' as const, async run(c) { - const resource = createUcpResource(c.options.profileUrl, c.options.transport); + const resource = createUcpResource(c.options); if (c.options.input) { const parsed = JSON.parse(c.options.input); return resource.checkoutCreate(c.options.business, parsed); @@ -171,7 +182,7 @@ export function createUcpCli() { options: checkoutGetOptions, outputPolicy: 'agent-only' as const, async run(c) { - const resource = createUcpResource(c.options.profileUrl, c.options.transport); + const resource = createUcpResource(c.options); return resource.checkoutGet(c.options.business, c.options.id); }, }); @@ -181,7 +192,7 @@ export function createUcpCli() { options: checkoutUpdateOptions, outputPolicy: 'agent-only' as const, async run(c) { - const resource = createUcpResource(c.options.profileUrl, c.options.transport); + const resource = createUcpResource(c.options); const parsed = JSON.parse(c.options.input); return resource.checkoutUpdate(c.options.business, c.options.id, parsed); }, @@ -193,7 +204,7 @@ export function createUcpCli() { options: checkoutCompleteOptions, outputPolicy: 'agent-only' as const, async run(c) { - const resource = createUcpResource(c.options.profileUrl, c.options.transport); + const resource = createUcpResource(c.options); const params = c.options.input ? JSON.parse(c.options.input) : undefined; return resource.checkoutComplete( c.options.business, @@ -218,7 +229,7 @@ export function createUcpCli() { options: orderGetOptions, outputPolicy: 'agent-only' as const, async run(c) { - const resource = createUcpResource(c.options.profileUrl, c.options.transport); + const resource = createUcpResource(c.options); return resource.orderGet(c.options.business, c.options.id); }, }); diff --git a/packages/cli/src/commands/ucp/schema.ts b/packages/cli/src/commands/ucp/schema.ts index cde6d3b..5e242e1 100644 --- a/packages/cli/src/commands/ucp/schema.ts +++ b/packages/cli/src/commands/ucp/schema.ts @@ -11,6 +11,18 @@ export const businessOption = z.object({ .describe( 'Agent profile URL that identifies this agent to the merchant', ), + clientId: z + .string() + .optional() + .describe('OAuth client_id for agent-authenticated requests'), + clientSecret: z + .string() + .optional() + .describe('OAuth client_secret for agent-authenticated requests'), + accessToken: z + .string() + .optional() + .describe('OAuth Bearer token for user-authenticated requests'), transport: z .enum(['auto', 'rest', 'mcp']) .optional() diff --git a/packages/sdk/src/__tests__/ucp.test.ts b/packages/sdk/src/__tests__/ucp.test.ts new file mode 100644 index 0000000..2e1a2b4 --- /dev/null +++ b/packages/sdk/src/__tests__/ucp.test.ts @@ -0,0 +1,316 @@ +import { describe, expect, it, vi } from 'vitest'; +import { UcpResource, UcpError } from '../resources/ucp'; + +const DISCOVERY_RESPONSE = { + ucp: { + version: '2026-04-08', + services: { + 'dev.ucp.shopping': [ + { version: '1.0', spec: 'ucp', transport: 'rest', endpoint: 'https://merchant.example.com/ucp/rest' }, + { version: '1.0', spec: 'ucp', transport: 'mcp', endpoint: 'https://merchant.example.com/ucp/mcp' }, + ], + }, + capabilities: { + 'dev.ucp.catalog': [{ version: '1.0', spec: 'ucp' }], + 'dev.ucp.cart': [{ version: '1.0', spec: 'ucp' }], + }, + payment_handlers: {}, + }, +}; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function createResource( + fetchMock: ReturnType, + opts: { profileUrl?: string; clientId?: string; clientSecret?: string; accessToken?: string; transport?: 'auto' | 'rest' | 'mcp' } = {}, +) { + return new UcpResource({ + fetch: fetchMock as unknown as typeof globalThis.fetch, + auth: { + profileUrl: opts.profileUrl, + clientId: opts.clientId, + clientSecret: opts.clientSecret, + accessToken: opts.accessToken, + }, + transport: opts.transport, + }); +} + +describe('UcpResource', () => { + describe('discover', () => { + it('fetches .well-known/ucp and returns parsed result', async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse(DISCOVERY_RESPONSE)); + const resource = createResource(fetchMock); + + const result = await resource.discover('https://merchant.example.com'); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchMock.mock.calls[0][0]).toBe('https://merchant.example.com/.well-known/ucp'); + expect(result.business).toBe('https://merchant.example.com'); + expect(result.rest_endpoint).toBe('https://merchant.example.com/ucp/rest'); + expect(result.mcp_endpoint).toBe('https://merchant.example.com/ucp/mcp'); + expect(result.capabilities).toContain('dev.ucp.catalog'); + }); + + it('throws DISCOVERY_FAILED on non-200', async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({}, 404)); + const resource = createResource(fetchMock); + + await expect(resource.discover('https://merchant.example.com')).rejects.toThrow(UcpError); + await expect(resource.discover('https://merchant.example.com')).rejects.toMatchObject({ code: 'DISCOVERY_FAILED' }); + }); + + it('caches discovery results', async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse(DISCOVERY_RESPONSE)); + const resource = createResource(fetchMock); + + await resource.discover('https://merchant.example.com'); + await resource.discover('https://merchant.example.com'); + + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it('normalizes URLs without protocol', async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse(DISCOVERY_RESPONSE)); + const resource = createResource(fetchMock); + + await resource.discover('merchant.example.com'); + + expect(fetchMock.mock.calls[0][0]).toBe('https://merchant.example.com/.well-known/ucp'); + }); + }); + + describe('catalogSearch (REST)', () => { + it('sends POST to /catalog/search with query', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(jsonResponse(DISCOVERY_RESPONSE)) + .mockResolvedValueOnce(jsonResponse({ products: [] })); + + const resource = createResource(fetchMock, { profileUrl: 'https://agent.example/profile.json', transport: 'rest' }); + + await resource.catalogSearch('https://merchant.example.com', { query: 'boots' }); + + const [url, opts] = fetchMock.mock.calls[1]; + expect(url).toBe('https://merchant.example.com/ucp/rest/catalog/search'); + expect(opts.method).toBe('POST'); + expect(JSON.parse(opts.body)).toEqual({ query: 'boots' }); + }); + + it('throws AUTH_PROFILE_REQUIRED when no profileUrl', async () => { + const fetchMock = vi.fn(); + const resource = createResource(fetchMock); + + await expect( + resource.catalogSearch('https://merchant.example.com', { query: 'boots' }), + ).rejects.toMatchObject({ code: 'AUTH_PROFILE_REQUIRED' }); + }); + }); + + describe('auth headers', () => { + it('sends Bearer token when accessToken is set', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(jsonResponse(DISCOVERY_RESPONSE)) + .mockResolvedValueOnce(jsonResponse({ products: [] })); + + const resource = createResource(fetchMock, { + profileUrl: 'https://agent.example/profile.json', + accessToken: 'tok_123', + transport: 'rest', + }); + + await resource.catalogSearch('https://merchant.example.com', { query: 'boots' }); + + const headers = fetchMock.mock.calls[1][1].headers; + expect(headers.Authorization).toBe('Bearer tok_123'); + }); + + it('sends Basic auth when clientId and clientSecret are set', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(jsonResponse(DISCOVERY_RESPONSE)) + .mockResolvedValueOnce(jsonResponse({ products: [] })); + + const resource = createResource(fetchMock, { + profileUrl: 'https://agent.example/profile.json', + clientId: 'my_client', + clientSecret: 'my_secret', + transport: 'rest', + }); + + await resource.catalogSearch('https://merchant.example.com', { query: 'boots' }); + + const headers = fetchMock.mock.calls[1][1].headers; + const expected = `Basic ${btoa('my_client:my_secret')}`; + expect(headers.Authorization).toBe(expected); + }); + + it('Bearer token takes precedence over client credentials', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(jsonResponse(DISCOVERY_RESPONSE)) + .mockResolvedValueOnce(jsonResponse({ products: [] })); + + const resource = createResource(fetchMock, { + profileUrl: 'https://agent.example/profile.json', + clientId: 'my_client', + clientSecret: 'my_secret', + accessToken: 'tok_123', + transport: 'rest', + }); + + await resource.catalogSearch('https://merchant.example.com', { query: 'boots' }); + + const headers = fetchMock.mock.calls[1][1].headers; + expect(headers.Authorization).toBe('Bearer tok_123'); + }); + + it('sends no Authorization header when no credentials are set', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(jsonResponse(DISCOVERY_RESPONSE)) + .mockResolvedValueOnce(jsonResponse({ products: [] })); + + const resource = createResource(fetchMock, { + profileUrl: 'https://agent.example/profile.json', + transport: 'rest', + }); + + await resource.catalogSearch('https://merchant.example.com', { query: 'boots' }); + + const headers = fetchMock.mock.calls[1][1].headers; + expect(headers.Authorization).toBeUndefined(); + }); + }); + + describe('UCP-Agent header', () => { + it('includes profile URL in UCP-Agent header', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(jsonResponse(DISCOVERY_RESPONSE)) + .mockResolvedValueOnce(jsonResponse({})); + + const resource = createResource(fetchMock, { + profileUrl: 'https://agent.example/profile.json', + transport: 'rest', + }); + + await resource.catalogSearch('https://merchant.example.com', { query: 'test' }); + + const headers = fetchMock.mock.calls[1][1].headers; + expect(headers['UCP-Agent']).toBe('profile="https://agent.example/profile.json"'); + }); + }); + + describe('cartCreate (REST)', () => { + it('sends POST to /carts with line items', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(jsonResponse(DISCOVERY_RESPONSE)) + .mockResolvedValueOnce(jsonResponse({ id: 'cart_1', line_items: [] })); + + const resource = createResource(fetchMock, { + profileUrl: 'https://agent.example/profile.json', + transport: 'rest', + }); + + const lineItems = [{ item: { id: 'variant_1' }, quantity: 2 }]; + await resource.cartCreate('https://merchant.example.com', { line_items: lineItems }); + + const [url, opts] = fetchMock.mock.calls[1]; + expect(url).toBe('https://merchant.example.com/ucp/rest/carts'); + expect(opts.method).toBe('POST'); + expect(JSON.parse(opts.body)).toEqual({ line_items: lineItems }); + }); + }); + + describe('checkoutComplete (REST)', () => { + it('sends POST to /checkout-sessions/{id}/complete', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(jsonResponse(DISCOVERY_RESPONSE)) + .mockResolvedValueOnce(jsonResponse({ id: 'cs_1', status: 'completed' })); + + const resource = createResource(fetchMock, { + profileUrl: 'https://agent.example/profile.json', + transport: 'rest', + }); + + await resource.checkoutComplete('https://merchant.example.com', 'cs_1', { payment_method: 'pm_1' }); + + const [url, opts] = fetchMock.mock.calls[1]; + expect(url).toBe('https://merchant.example.com/ucp/rest/checkout-sessions/cs_1/complete'); + expect(opts.method).toBe('POST'); + }); + }); + + describe('MCP transport', () => { + it('calls tools/list then tools/call via JSON-RPC', async () => { + const toolsListResponse = { + jsonrpc: '2.0', + id: 1, + result: { + tools: [{ name: 'search_catalog', description: 'Search catalog' }], + }, + }; + const toolCallResponse = { + jsonrpc: '2.0', + id: 2, + result: { + content: [{ type: 'text', text: '{"products":[]}' }], + }, + }; + + const fetchMock = vi.fn() + .mockResolvedValueOnce(jsonResponse(DISCOVERY_RESPONSE)) + .mockResolvedValueOnce(jsonResponse(toolsListResponse)) + .mockResolvedValueOnce(jsonResponse(toolCallResponse)); + + const resource = createResource(fetchMock, { + profileUrl: 'https://agent.example/profile.json', + transport: 'mcp', + }); + + const result = await resource.catalogSearch('https://merchant.example.com', { query: 'boots' }); + + expect(result).toEqual({ products: [] }); + + // tools/list call + const listBody = JSON.parse(fetchMock.mock.calls[1][1].body); + expect(listBody.method).toBe('tools/list'); + + // tools/call + const callBody = JSON.parse(fetchMock.mock.calls[2][1].body); + expect(callBody.method).toBe('tools/call'); + expect(callBody.params.name).toBe('search_catalog'); + }); + + it('sends auth headers on MCP requests', async () => { + const toolsListResponse = { + jsonrpc: '2.0', + id: 1, + result: { tools: [{ name: 'search_catalog' }] }, + }; + const toolCallResponse = { + jsonrpc: '2.0', + id: 2, + result: { content: [{ type: 'text', text: '{}' }] }, + }; + + const fetchMock = vi.fn() + .mockResolvedValueOnce(jsonResponse(DISCOVERY_RESPONSE)) + .mockResolvedValueOnce(jsonResponse(toolsListResponse)) + .mockResolvedValueOnce(jsonResponse(toolCallResponse)); + + const resource = createResource(fetchMock, { + profileUrl: 'https://agent.example/profile.json', + accessToken: 'tok_abc', + transport: 'mcp', + }); + + await resource.catalogSearch('https://merchant.example.com', { query: 'test' }); + + // Both MCP calls should have Bearer token + expect(fetchMock.mock.calls[1][1].headers.Authorization).toBe('Bearer tok_abc'); + expect(fetchMock.mock.calls[2][1].headers.Authorization).toBe('Bearer tok_abc'); + }); + }); +}); diff --git a/packages/sdk/src/resources/ucp.ts b/packages/sdk/src/resources/ucp.ts index 53de2b5..0d8cf13 100644 --- a/packages/sdk/src/resources/ucp.ts +++ b/packages/sdk/src/resources/ucp.ts @@ -16,9 +16,16 @@ import type { export type UcpTransport = 'auto' | 'rest' | 'mcp'; +export interface UcpAuth { + profileUrl?: string; + clientId?: string; + clientSecret?: string; + accessToken?: string; +} + export interface UcpResourceOptions { fetch?: typeof globalThis.fetch; - profileUrl: string; + auth?: UcpAuth; timeoutMs?: number; transport?: UcpTransport; } @@ -65,7 +72,7 @@ let nextRequestId = 1; export class UcpResource { private fetchImpl: typeof globalThis.fetch; - private profileUrl: string; + private auth: UcpAuth; private timeoutMs: number; private transport: UcpTransport; private negotiatedCache: Map = new Map(); @@ -73,7 +80,7 @@ export class UcpResource { constructor(options: UcpResourceOptions) { this.fetchImpl = options.fetch ?? globalThis.fetch; - this.profileUrl = options.profileUrl; + this.auth = options.auth ?? {}; this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; this.transport = options.transport ?? 'auto'; } @@ -240,10 +247,10 @@ export class UcpResource { ): Promise { const normalized = this.normalizeBusinessUrl(businessUrl); - if (!this.profileUrl) { + if (!this.auth.profileUrl) { throw new UcpError( - 'A --profile-url is required for UCP operations (catalog, cart, checkout). Use `ucp discover` without one to inspect capabilities first.', - 'PROFILE_REQUIRED', + 'A --profile-url is required for UCP operations (catalog, cart, checkout). Use `ucp discover` without one to inspect capabilities first. If you don\'t have your own profile, consider using a developer one like https://shopify.dev/ucp/agent-profiles/2026-04-08/valid-with-capabilities.json', + 'AUTH_PROFILE_REQUIRED', { business: normalized }, ); } @@ -302,8 +309,11 @@ export class UcpResource { const headers: Record = { Accept: 'application/json', - 'UCP-Agent': `profile="${this.profileUrl}"`, 'Request-Id': crypto.randomUUID(), + ...this.authHeaders(), + ...(this.auth.profileUrl + ? { 'UCP-Agent': `profile="${this.auth.profileUrl}"` } + : {}), }; const hasBody = route.method !== 'GET'; @@ -394,7 +404,9 @@ export class UcpResource { ...args, meta: { ...((args.meta as Record) ?? {}), - 'ucp-agent': { profile: this.profileUrl }, + ...(this.auth.profileUrl + ? { 'ucp-agent': { profile: this.auth.profileUrl } } + : {}), 'idempotency-key': crypto.randomUUID(), }, }; @@ -432,7 +444,9 @@ export class UcpResource { { arguments: { meta: { - 'ucp-agent': { profile: this.profileUrl }, + ...(this.auth.profileUrl + ? { 'ucp-agent': { profile: this.auth.profileUrl } } + : {}), }, }, }, @@ -510,6 +524,7 @@ export class UcpResource { headers: { 'Content-Type': 'application/json', Accept: 'application/json', + ...this.authHeaders(), }, body, signal: AbortSignal.timeout(this.timeoutMs), @@ -552,6 +567,19 @@ export class UcpResource { return envelope.result as T; } + // --- Auth --- + + private authHeaders(): Record { + if (this.auth.accessToken) { + return { Authorization: `Bearer ${this.auth.accessToken}` }; + } + if (this.auth.clientId && this.auth.clientSecret) { + const encoded = btoa(`${this.auth.clientId}:${this.auth.clientSecret}`); + return { Authorization: `Basic ${encoded}` }; + } + return {}; + } + // --- Helpers --- private async httpGet(url: string): Promise { From 2ec51bba40f27b4bf793c74340bcf866fedf36b4 Mon Sep 17 00:00:00 2001 From: Ryan Aubrey Date: Mon, 29 Jun 2026 12:09:23 -0400 Subject: [PATCH 09/15] Revert SKILL.md/plugin version bumps and remove Shopify profile URL from error Keep this branch focused on UCP functionality without version changes or external profile references. Co-Authored-By: Claude Opus 4.6 (1M context) Committed-By-Agent: claude --- packages/sdk/src/resources/ucp.ts | 2 +- plugins/link/.claude-plugin/plugin.json | 2 +- plugins/link/.codex-plugin/plugin.json | 2 +- plugins/link/.cursor-plugin/plugin.json | 2 +- skills/create-payment-credential/SKILL.md | 6 ++---- 5 files changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/sdk/src/resources/ucp.ts b/packages/sdk/src/resources/ucp.ts index 0d8cf13..1f7393d 100644 --- a/packages/sdk/src/resources/ucp.ts +++ b/packages/sdk/src/resources/ucp.ts @@ -249,7 +249,7 @@ export class UcpResource { if (!this.auth.profileUrl) { throw new UcpError( - 'A --profile-url is required for UCP operations (catalog, cart, checkout). Use `ucp discover` without one to inspect capabilities first. If you don\'t have your own profile, consider using a developer one like https://shopify.dev/ucp/agent-profiles/2026-04-08/valid-with-capabilities.json', + 'A --profile-url is required for UCP operations (catalog, cart, checkout). Use `ucp discover` without one to inspect capabilities first.', 'AUTH_PROFILE_REQUIRED', { business: normalized }, ); diff --git a/plugins/link/.claude-plugin/plugin.json b/plugins/link/.claude-plugin/plugin.json index 89e6d12..15ddbf8 100644 --- a/plugins/link/.claude-plugin/plugin.json +++ b/plugins/link/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "link", - "version": "0.8.1", + "version": "0.7.2", "description": "Authenticate with Link, create spend requests, and retrieve one-time-use card or shared payment token credentials for user-approved purchases.", "author": { "name": "Stripe" diff --git a/plugins/link/.codex-plugin/plugin.json b/plugins/link/.codex-plugin/plugin.json index ffec54d..3f4c52f 100644 --- a/plugins/link/.codex-plugin/plugin.json +++ b/plugins/link/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "link", - "version": "0.8.1", + "version": "0.7.2", "description": "Secure, one-time-use payment credentials from Link", "author": { "name": "Stripe", diff --git a/plugins/link/.cursor-plugin/plugin.json b/plugins/link/.cursor-plugin/plugin.json index 29d0b03..b6e764e 100644 --- a/plugins/link/.cursor-plugin/plugin.json +++ b/plugins/link/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "link", "displayName": "Stripe Link", - "version": "0.8.1", + "version": "0.7.2", "description": "Get secure, one-time-use payment credentials from a Link wallet so agents can complete purchases on your behalf.", "author": { "name": "Stripe" diff --git a/skills/create-payment-credential/SKILL.md b/skills/create-payment-credential/SKILL.md index 322c5a6..dd9a881 100644 --- a/skills/create-payment-credential/SKILL.md +++ b/skills/create-payment-credential/SKILL.md @@ -1,5 +1,5 @@ --- -version: 0.8.1 +version: 0.7.2 name: create-payment-credential description: | Gets secure, one-time-use payment credentials (cards, tokens) from a Link wallet so agents can complete purchases on behalf of users. Use when the user says "get me a card", "buy something", "pay for X", "make a purchase", "I need to pay", "complete checkout", or asks to transact on any merchant site. Use when the user asks to connect or log in to or sign up for their Link account. @@ -123,6 +123,7 @@ What you find determines which credential type to use: | Credit card form / Stripe Elements | `card` (default) | Card | | HTTP 402 with `method="stripe"` in `www-authenticate` | `shared_payment_token` | Shared payment token (SPT) | | HTTP 402 without `method="stripe"` in `www-authenticate` | not supported | Do not continue | + **For 402 responses:** The `www-authenticate` header may contain **multiple** payment challenges (e.g. `tempo`, `stripe`) in a single header value. Do not try to decode the payload manually. Pass the **full raw `WWW-Authenticate` header value** to Link CLI and let `mpp decode` select and validate the `method="stripe"` challenge. To derive `network_id`, use Link CLI's challenge decoder: @@ -193,9 +194,6 @@ link-cli mpp pay --spend-request-id [--method POST] [--data '{"amount `mpp pay` handles the full 402 flow automatically: probes the URL, parses the `www-authenticate` header, builds the `Authorization: Payment` credential using the SPT, and retries. -## UCP (Universal Commerce Protocol) - -Link CLI includes UCP commands for discovering merchants and browsing product catalogs programmatically. Use `link-cli ucp discover ` to introspect a merchant's site — this checks for UCP support and returns the merchant's capabilities, catalog endpoints, and payment options. If no UCP well-known is discovered, the merchant does not support UCP and you should fall back to other checkout methods (e.g. browsing the site and using a virtual card with the standard card flow). Use `link-cli ucp --help` to see all available subcommands. When a UCP merchant requires browser-based checkout confirmation, use the standard card flow above — provision a virtual card and fill it into the merchant's checkout page. ## Important From ab19e84a75bac94a041eb98c646ee650ecde4cc6 Mon Sep 17 00:00:00 2001 From: Ryan Aubrey Date: Mon, 29 Jun 2026 12:47:44 -0400 Subject: [PATCH 10/15] Fix biome lint errors (non-null assertions, formatting) Co-Authored-By: Claude Opus 4.6 (1M context) Committed-By-Agent: claude --- packages/cli/src/__tests__/ucp.test.ts | 47 ++++--- packages/cli/src/commands/ucp/index.tsx | 6 +- packages/cli/src/commands/ucp/schema.ts | 4 +- packages/sdk/src/__tests__/ucp.test.ts | 156 ++++++++++++++++++------ packages/sdk/src/resources/ucp.ts | 35 ++++-- 5 files changed, 178 insertions(+), 70 deletions(-) diff --git a/packages/cli/src/__tests__/ucp.test.ts b/packages/cli/src/__tests__/ucp.test.ts index aed7a8f..3127bfd 100644 --- a/packages/cli/src/__tests__/ucp.test.ts +++ b/packages/cli/src/__tests__/ucp.test.ts @@ -187,10 +187,12 @@ describe('ucp commands', () => { expect(output.products).toHaveLength(1); expect(output.products[0].name).toBe('Red Boots'); - const searchReq = requests.find((r) => r.url === '/ucp/rest/catalog/search'); + const searchReq = requests.find( + (r) => r.url === '/ucp/rest/catalog/search', + ); expect(searchReq).toBeDefined(); - expect(searchReq!.method).toBe('POST'); - expect(JSON.parse(searchReq!.body)).toEqual({ query: 'boots' }); + expect(searchReq?.method).toBe('POST'); + expect(JSON.parse(searchReq?.body ?? '')).toEqual({ query: 'boots' }); }); it('sends auth headers when --client-id and --client-secret are provided', async () => { @@ -216,10 +218,12 @@ describe('ucp commands', () => { '--json', ); - const searchReq = requests.find((r) => r.url === '/ucp/rest/catalog/search'); + const searchReq = requests.find( + (r) => r.url === '/ucp/rest/catalog/search', + ); expect(searchReq).toBeDefined(); const expected = `Basic ${btoa('test_client:test_secret')}`; - expect(searchReq!.headers.authorization).toBe(expected); + expect(searchReq?.headers.authorization).toBe(expected); }); it('sends Bearer token when --access-token is provided', async () => { @@ -243,8 +247,10 @@ describe('ucp commands', () => { '--json', ); - const searchReq = requests.find((r) => r.url === '/ucp/rest/catalog/search'); - expect(searchReq!.headers.authorization).toBe('Bearer tok_user_123'); + const searchReq = requests.find( + (r) => r.url === '/ucp/rest/catalog/search', + ); + expect(searchReq?.headers.authorization).toBe('Bearer tok_user_123'); }); it('fails when --profile-url is missing', async () => { @@ -287,8 +293,12 @@ describe('ucp commands', () => { const output = JSON.parse(result.stdout); expect(output.products).toHaveLength(2); - const lookupReq = requests.find((r) => r.url === '/ucp/rest/catalog/lookup'); - expect(JSON.parse(lookupReq!.body)).toEqual({ ids: ['prod_1', 'prod_2'] }); + const lookupReq = requests.find( + (r) => r.url === '/ucp/rest/catalog/lookup', + ); + expect(JSON.parse(lookupReq?.body ?? '')).toEqual({ + ids: ['prod_1', 'prod_2'], + }); }); }); @@ -320,7 +330,7 @@ describe('ucp commands', () => { expect(output.id).toBe('cart_1'); const cartReq = requests.find((r) => r.url === '/ucp/rest/carts'); - expect(cartReq!.method).toBe('POST'); + expect(cartReq?.method).toBe('POST'); }); it('get fetches cart by ID', async () => { @@ -347,13 +357,16 @@ describe('ucp commands', () => { expect(output.id).toBe('cart_1'); const getReq = requests.find((r) => r.url === '/ucp/rest/carts/cart_1'); - expect(getReq!.method).toBe('GET'); + expect(getReq?.method).toBe('GET'); }); it('update sends PUT to /carts/{id}', async () => { responsesByUrl['/ucp/rest/carts/cart_1'] = { status: 200, - body: { id: 'cart_1', line_items: [{ item: { id: 'var_2' }, quantity: 3 }] }, + body: { + id: 'cart_1', + line_items: [{ item: { id: 'var_2' }, quantity: 3 }], + }, }; const result = await runCli( @@ -403,9 +416,11 @@ describe('ucp commands', () => { const output = JSON.parse(result.stdout); expect(output.id).toBe('cs_1'); - const createReq = requests.find((r) => r.url === '/ucp/rest/checkout-sessions'); - expect(createReq!.method).toBe('POST'); - expect(JSON.parse(createReq!.body)).toMatchObject({ cart_id: 'cart_1' }); + const createReq = requests.find( + (r) => r.url === '/ucp/rest/checkout-sessions', + ); + expect(createReq?.method).toBe('POST'); + expect(JSON.parse(createReq?.body ?? '')).toMatchObject({ cart_id: 'cart_1' }); }); it('get fetches checkout by ID', async () => { @@ -460,7 +475,7 @@ describe('ucp commands', () => { const completeReq = requests.find( (r) => r.url === '/ucp/rest/checkout-sessions/cs_1/complete', ); - expect(completeReq!.method).toBe('POST'); + expect(completeReq?.method).toBe('POST'); }); }); }); diff --git a/packages/cli/src/commands/ucp/index.tsx b/packages/cli/src/commands/ucp/index.tsx index 2ca33f2..9058b52 100644 --- a/packages/cli/src/commands/ucp/index.tsx +++ b/packages/cli/src/commands/ucp/index.tsx @@ -1,4 +1,4 @@ -import { type UcpTransport, UcpResource } from '@stripe/link-sdk'; +import { UcpResource, type UcpTransport } from '@stripe/link-sdk'; import { Cli, z } from 'incur'; import { businessOption, @@ -52,7 +52,9 @@ export function createUcpCli() { profileUrl: z .string() .optional() - .describe('Agent profile URL (not required for discovery, but needed for tool negotiation)'), + .describe( + 'Agent profile URL (not required for discovery, but needed for tool negotiation)', + ), }), outputPolicy: 'agent-only' as const, async run(c) { diff --git a/packages/cli/src/commands/ucp/schema.ts b/packages/cli/src/commands/ucp/schema.ts index 5e242e1..be1b2e0 100644 --- a/packages/cli/src/commands/ucp/schema.ts +++ b/packages/cli/src/commands/ucp/schema.ts @@ -8,9 +8,7 @@ export const businessOption = z.object({ ), profileUrl: z .string() - .describe( - 'Agent profile URL that identifies this agent to the merchant', - ), + .describe('Agent profile URL that identifies this agent to the merchant'), clientId: z .string() .optional() diff --git a/packages/sdk/src/__tests__/ucp.test.ts b/packages/sdk/src/__tests__/ucp.test.ts index 2e1a2b4..fa37cd0 100644 --- a/packages/sdk/src/__tests__/ucp.test.ts +++ b/packages/sdk/src/__tests__/ucp.test.ts @@ -1,13 +1,23 @@ import { describe, expect, it, vi } from 'vitest'; -import { UcpResource, UcpError } from '../resources/ucp'; +import { UcpError, UcpResource } from '../resources/ucp'; const DISCOVERY_RESPONSE = { ucp: { version: '2026-04-08', services: { 'dev.ucp.shopping': [ - { version: '1.0', spec: 'ucp', transport: 'rest', endpoint: 'https://merchant.example.com/ucp/rest' }, - { version: '1.0', spec: 'ucp', transport: 'mcp', endpoint: 'https://merchant.example.com/ucp/mcp' }, + { + version: '1.0', + spec: 'ucp', + transport: 'rest', + endpoint: 'https://merchant.example.com/ucp/rest', + }, + { + version: '1.0', + spec: 'ucp', + transport: 'mcp', + endpoint: 'https://merchant.example.com/ucp/mcp', + }, ], }, capabilities: { @@ -27,7 +37,13 @@ function jsonResponse(body: unknown, status = 200): Response { function createResource( fetchMock: ReturnType, - opts: { profileUrl?: string; clientId?: string; clientSecret?: string; accessToken?: string; transport?: 'auto' | 'rest' | 'mcp' } = {}, + opts: { + profileUrl?: string; + clientId?: string; + clientSecret?: string; + accessToken?: string; + transport?: 'auto' | 'rest' | 'mcp'; + } = {}, ) { return new UcpResource({ fetch: fetchMock as unknown as typeof globalThis.fetch, @@ -44,15 +60,21 @@ function createResource( describe('UcpResource', () => { describe('discover', () => { it('fetches .well-known/ucp and returns parsed result', async () => { - const fetchMock = vi.fn().mockResolvedValue(jsonResponse(DISCOVERY_RESPONSE)); + const fetchMock = vi + .fn() + .mockResolvedValue(jsonResponse(DISCOVERY_RESPONSE)); const resource = createResource(fetchMock); const result = await resource.discover('https://merchant.example.com'); expect(fetchMock).toHaveBeenCalledOnce(); - expect(fetchMock.mock.calls[0][0]).toBe('https://merchant.example.com/.well-known/ucp'); + expect(fetchMock.mock.calls[0][0]).toBe( + 'https://merchant.example.com/.well-known/ucp', + ); expect(result.business).toBe('https://merchant.example.com'); - expect(result.rest_endpoint).toBe('https://merchant.example.com/ucp/rest'); + expect(result.rest_endpoint).toBe( + 'https://merchant.example.com/ucp/rest', + ); expect(result.mcp_endpoint).toBe('https://merchant.example.com/ucp/mcp'); expect(result.capabilities).toContain('dev.ucp.catalog'); }); @@ -61,12 +83,18 @@ describe('UcpResource', () => { const fetchMock = vi.fn().mockResolvedValue(jsonResponse({}, 404)); const resource = createResource(fetchMock); - await expect(resource.discover('https://merchant.example.com')).rejects.toThrow(UcpError); - await expect(resource.discover('https://merchant.example.com')).rejects.toMatchObject({ code: 'DISCOVERY_FAILED' }); + await expect( + resource.discover('https://merchant.example.com'), + ).rejects.toThrow(UcpError); + await expect( + resource.discover('https://merchant.example.com'), + ).rejects.toMatchObject({ code: 'DISCOVERY_FAILED' }); }); it('caches discovery results', async () => { - const fetchMock = vi.fn().mockResolvedValue(jsonResponse(DISCOVERY_RESPONSE)); + const fetchMock = vi + .fn() + .mockResolvedValue(jsonResponse(DISCOVERY_RESPONSE)); const resource = createResource(fetchMock); await resource.discover('https://merchant.example.com'); @@ -76,24 +104,34 @@ describe('UcpResource', () => { }); it('normalizes URLs without protocol', async () => { - const fetchMock = vi.fn().mockResolvedValue(jsonResponse(DISCOVERY_RESPONSE)); + const fetchMock = vi + .fn() + .mockResolvedValue(jsonResponse(DISCOVERY_RESPONSE)); const resource = createResource(fetchMock); await resource.discover('merchant.example.com'); - expect(fetchMock.mock.calls[0][0]).toBe('https://merchant.example.com/.well-known/ucp'); + expect(fetchMock.mock.calls[0][0]).toBe( + 'https://merchant.example.com/.well-known/ucp', + ); }); }); describe('catalogSearch (REST)', () => { it('sends POST to /catalog/search with query', async () => { - const fetchMock = vi.fn() + const fetchMock = vi + .fn() .mockResolvedValueOnce(jsonResponse(DISCOVERY_RESPONSE)) .mockResolvedValueOnce(jsonResponse({ products: [] })); - const resource = createResource(fetchMock, { profileUrl: 'https://agent.example/profile.json', transport: 'rest' }); + const resource = createResource(fetchMock, { + profileUrl: 'https://agent.example/profile.json', + transport: 'rest', + }); - await resource.catalogSearch('https://merchant.example.com', { query: 'boots' }); + await resource.catalogSearch('https://merchant.example.com', { + query: 'boots', + }); const [url, opts] = fetchMock.mock.calls[1]; expect(url).toBe('https://merchant.example.com/ucp/rest/catalog/search'); @@ -106,14 +144,17 @@ describe('UcpResource', () => { const resource = createResource(fetchMock); await expect( - resource.catalogSearch('https://merchant.example.com', { query: 'boots' }), + resource.catalogSearch('https://merchant.example.com', { + query: 'boots', + }), ).rejects.toMatchObject({ code: 'AUTH_PROFILE_REQUIRED' }); }); }); describe('auth headers', () => { it('sends Bearer token when accessToken is set', async () => { - const fetchMock = vi.fn() + const fetchMock = vi + .fn() .mockResolvedValueOnce(jsonResponse(DISCOVERY_RESPONSE)) .mockResolvedValueOnce(jsonResponse({ products: [] })); @@ -123,14 +164,17 @@ describe('UcpResource', () => { transport: 'rest', }); - await resource.catalogSearch('https://merchant.example.com', { query: 'boots' }); + await resource.catalogSearch('https://merchant.example.com', { + query: 'boots', + }); const headers = fetchMock.mock.calls[1][1].headers; expect(headers.Authorization).toBe('Bearer tok_123'); }); it('sends Basic auth when clientId and clientSecret are set', async () => { - const fetchMock = vi.fn() + const fetchMock = vi + .fn() .mockResolvedValueOnce(jsonResponse(DISCOVERY_RESPONSE)) .mockResolvedValueOnce(jsonResponse({ products: [] })); @@ -141,7 +185,9 @@ describe('UcpResource', () => { transport: 'rest', }); - await resource.catalogSearch('https://merchant.example.com', { query: 'boots' }); + await resource.catalogSearch('https://merchant.example.com', { + query: 'boots', + }); const headers = fetchMock.mock.calls[1][1].headers; const expected = `Basic ${btoa('my_client:my_secret')}`; @@ -149,7 +195,8 @@ describe('UcpResource', () => { }); it('Bearer token takes precedence over client credentials', async () => { - const fetchMock = vi.fn() + const fetchMock = vi + .fn() .mockResolvedValueOnce(jsonResponse(DISCOVERY_RESPONSE)) .mockResolvedValueOnce(jsonResponse({ products: [] })); @@ -161,14 +208,17 @@ describe('UcpResource', () => { transport: 'rest', }); - await resource.catalogSearch('https://merchant.example.com', { query: 'boots' }); + await resource.catalogSearch('https://merchant.example.com', { + query: 'boots', + }); const headers = fetchMock.mock.calls[1][1].headers; expect(headers.Authorization).toBe('Bearer tok_123'); }); it('sends no Authorization header when no credentials are set', async () => { - const fetchMock = vi.fn() + const fetchMock = vi + .fn() .mockResolvedValueOnce(jsonResponse(DISCOVERY_RESPONSE)) .mockResolvedValueOnce(jsonResponse({ products: [] })); @@ -177,7 +227,9 @@ describe('UcpResource', () => { transport: 'rest', }); - await resource.catalogSearch('https://merchant.example.com', { query: 'boots' }); + await resource.catalogSearch('https://merchant.example.com', { + query: 'boots', + }); const headers = fetchMock.mock.calls[1][1].headers; expect(headers.Authorization).toBeUndefined(); @@ -186,7 +238,8 @@ describe('UcpResource', () => { describe('UCP-Agent header', () => { it('includes profile URL in UCP-Agent header', async () => { - const fetchMock = vi.fn() + const fetchMock = vi + .fn() .mockResolvedValueOnce(jsonResponse(DISCOVERY_RESPONSE)) .mockResolvedValueOnce(jsonResponse({})); @@ -195,16 +248,21 @@ describe('UcpResource', () => { transport: 'rest', }); - await resource.catalogSearch('https://merchant.example.com', { query: 'test' }); + await resource.catalogSearch('https://merchant.example.com', { + query: 'test', + }); const headers = fetchMock.mock.calls[1][1].headers; - expect(headers['UCP-Agent']).toBe('profile="https://agent.example/profile.json"'); + expect(headers['UCP-Agent']).toBe( + 'profile="https://agent.example/profile.json"', + ); }); }); describe('cartCreate (REST)', () => { it('sends POST to /carts with line items', async () => { - const fetchMock = vi.fn() + const fetchMock = vi + .fn() .mockResolvedValueOnce(jsonResponse(DISCOVERY_RESPONSE)) .mockResolvedValueOnce(jsonResponse({ id: 'cart_1', line_items: [] })); @@ -214,7 +272,9 @@ describe('UcpResource', () => { }); const lineItems = [{ item: { id: 'variant_1' }, quantity: 2 }]; - await resource.cartCreate('https://merchant.example.com', { line_items: lineItems }); + await resource.cartCreate('https://merchant.example.com', { + line_items: lineItems, + }); const [url, opts] = fetchMock.mock.calls[1]; expect(url).toBe('https://merchant.example.com/ucp/rest/carts'); @@ -225,19 +285,26 @@ describe('UcpResource', () => { describe('checkoutComplete (REST)', () => { it('sends POST to /checkout-sessions/{id}/complete', async () => { - const fetchMock = vi.fn() + const fetchMock = vi + .fn() .mockResolvedValueOnce(jsonResponse(DISCOVERY_RESPONSE)) - .mockResolvedValueOnce(jsonResponse({ id: 'cs_1', status: 'completed' })); + .mockResolvedValueOnce( + jsonResponse({ id: 'cs_1', status: 'completed' }), + ); const resource = createResource(fetchMock, { profileUrl: 'https://agent.example/profile.json', transport: 'rest', }); - await resource.checkoutComplete('https://merchant.example.com', 'cs_1', { payment_method: 'pm_1' }); + await resource.checkoutComplete('https://merchant.example.com', 'cs_1', { + payment_method: 'pm_1', + }); const [url, opts] = fetchMock.mock.calls[1]; - expect(url).toBe('https://merchant.example.com/ucp/rest/checkout-sessions/cs_1/complete'); + expect(url).toBe( + 'https://merchant.example.com/ucp/rest/checkout-sessions/cs_1/complete', + ); expect(opts.method).toBe('POST'); }); }); @@ -259,7 +326,8 @@ describe('UcpResource', () => { }, }; - const fetchMock = vi.fn() + const fetchMock = vi + .fn() .mockResolvedValueOnce(jsonResponse(DISCOVERY_RESPONSE)) .mockResolvedValueOnce(jsonResponse(toolsListResponse)) .mockResolvedValueOnce(jsonResponse(toolCallResponse)); @@ -269,7 +337,10 @@ describe('UcpResource', () => { transport: 'mcp', }); - const result = await resource.catalogSearch('https://merchant.example.com', { query: 'boots' }); + const result = await resource.catalogSearch( + 'https://merchant.example.com', + { query: 'boots' }, + ); expect(result).toEqual({ products: [] }); @@ -295,7 +366,8 @@ describe('UcpResource', () => { result: { content: [{ type: 'text', text: '{}' }] }, }; - const fetchMock = vi.fn() + const fetchMock = vi + .fn() .mockResolvedValueOnce(jsonResponse(DISCOVERY_RESPONSE)) .mockResolvedValueOnce(jsonResponse(toolsListResponse)) .mockResolvedValueOnce(jsonResponse(toolCallResponse)); @@ -306,11 +378,17 @@ describe('UcpResource', () => { transport: 'mcp', }); - await resource.catalogSearch('https://merchant.example.com', { query: 'test' }); + await resource.catalogSearch('https://merchant.example.com', { + query: 'test', + }); // Both MCP calls should have Bearer token - expect(fetchMock.mock.calls[1][1].headers.Authorization).toBe('Bearer tok_abc'); - expect(fetchMock.mock.calls[2][1].headers.Authorization).toBe('Bearer tok_abc'); + expect(fetchMock.mock.calls[1][1].headers.Authorization).toBe( + 'Bearer tok_abc', + ); + expect(fetchMock.mock.calls[2][1].headers.Authorization).toBe( + 'Bearer tok_abc', + ); }); }); }); diff --git a/packages/sdk/src/resources/ucp.ts b/packages/sdk/src/resources/ucp.ts index 1f7393d..e80bf2e 100644 --- a/packages/sdk/src/resources/ucp.ts +++ b/packages/sdk/src/resources/ucp.ts @@ -50,16 +50,32 @@ interface RestRoute { } const REST_ROUTES: Record = { - search_catalog: { method: 'POST', path: '/catalog/search', bodyKey: 'catalog' }, - lookup_catalog: { method: 'POST', path: '/catalog/lookup', bodyKey: 'catalog' }, + search_catalog: { + method: 'POST', + path: '/catalog/search', + bodyKey: 'catalog', + }, + lookup_catalog: { + method: 'POST', + path: '/catalog/lookup', + bodyKey: 'catalog', + }, get_product: { method: 'POST', path: '/catalog/product' }, create_cart: { method: 'POST', path: '/carts', bodyKey: 'cart' }, get_cart: { method: 'GET', path: '/carts/{id}' }, update_cart: { method: 'PUT', path: '/carts/{id}', bodyKey: 'cart' }, cancel_cart: { method: 'POST', path: '/carts/{id}/cancel' }, - create_checkout: { method: 'POST', path: '/checkout-sessions', bodyKey: 'checkout' }, + create_checkout: { + method: 'POST', + path: '/checkout-sessions', + bodyKey: 'checkout', + }, get_checkout: { method: 'GET', path: '/checkout-sessions/{id}' }, - update_checkout: { method: 'PUT', path: '/checkout-sessions/{id}', bodyKey: 'checkout' }, + update_checkout: { + method: 'PUT', + path: '/checkout-sessions/{id}', + bodyKey: 'checkout', + }, complete_checkout: { method: 'POST', path: '/checkout-sessions/{id}/complete', @@ -258,8 +274,8 @@ export class UcpResource { const discovery = await this.discover(normalized); const useRest = this.shouldUseRest(discovery, operationId); - if (useRest) { - return this.callRest(discovery.rest_endpoint!, operationId, args); + if (useRest && discovery.rest_endpoint) { + return this.callRest(discovery.rest_endpoint, operationId, args); } return this.callMcp(normalized, operationId, args); } @@ -297,7 +313,7 @@ export class UcpResource { operationId: string, args: Record, ): Promise { - const route = REST_ROUTES[operationId]!; + const route = REST_ROUTES[operationId] as RestRoute; const id = args.id as string | undefined; let path = route.path; @@ -327,7 +343,7 @@ export class UcpResource { const { id: _id, meta: _meta, ...payload } = args; // If route has a bodyKey, unwrap that key as the REST body const restBody = route.bodyKey - ? (payload[route.bodyKey] as Record) ?? payload + ? ((payload[route.bodyKey] as Record) ?? payload) : payload; body = JSON.stringify(restBody); } @@ -367,8 +383,7 @@ export class UcpResource { const messages = errBody.messages as | Array<{ code?: string; content?: string }> | undefined; - const msg = - messages?.[0]?.content ?? `HTTP ${response.status}`; + const msg = messages?.[0]?.content ?? `HTTP ${response.status}`; throw new UcpError( `REST ${route.method} ${path} failed: ${msg}`, 'REST_REQUEST_FAILED', From ac9ea2676b72d988572103308f11c8421c338fc0 Mon Sep 17 00:00:00 2001 From: Ryan Aubrey Date: Mon, 29 Jun 2026 12:52:01 -0400 Subject: [PATCH 11/15] Fix biome formatting in test file Co-Authored-By: Claude Opus 4.6 (1M context) Committed-By-Agent: claude --- packages/cli/src/__tests__/ucp.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/__tests__/ucp.test.ts b/packages/cli/src/__tests__/ucp.test.ts index 3127bfd..fdefa2e 100644 --- a/packages/cli/src/__tests__/ucp.test.ts +++ b/packages/cli/src/__tests__/ucp.test.ts @@ -420,7 +420,9 @@ describe('ucp commands', () => { (r) => r.url === '/ucp/rest/checkout-sessions', ); expect(createReq?.method).toBe('POST'); - expect(JSON.parse(createReq?.body ?? '')).toMatchObject({ cart_id: 'cart_1' }); + expect(JSON.parse(createReq?.body ?? '')).toMatchObject({ + cart_id: 'cart_1', + }); }); it('get fetches checkout by ID', async () => { From cad2f6da89069fd47989dc855ed3bb410deff8db Mon Sep 17 00:00:00 2001 From: Ryan Aubrey Date: Mon, 29 Jun 2026 13:30:11 -0400 Subject: [PATCH 12/15] Replace regex with loop to fix CodeQL polynomial regex warning Co-Authored-By: Claude Opus 4.6 (1M context) Committed-By-Agent: claude --- packages/sdk/src/resources/ucp.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/sdk/src/resources/ucp.ts b/packages/sdk/src/resources/ucp.ts index e80bf2e..8b84ee9 100644 --- a/packages/sdk/src/resources/ucp.ts +++ b/packages/sdk/src/resources/ucp.ts @@ -321,7 +321,11 @@ export class UcpResource { path = path.replace('{id}', encodeURIComponent(id)); } - const url = `${restEndpoint.replace(/\/+$/, '')}${path}`; + let base = restEndpoint; + while (base.endsWith('/')) { + base = base.slice(0, -1); + } + const url = `${base}${path}`; const headers: Record = { Accept: 'application/json', @@ -613,7 +617,10 @@ export class UcpResource { ) { normalized = `https://${normalized}`; } - return normalized.replace(/\/+$/, ''); + while (normalized.endsWith('/')) { + normalized = normalized.slice(0, -1); + } + return normalized; } private extractEndpoints(spec: UcpDiscoverySpec): { From 049ce1f834882f3535c23cdd0fe24e306d9acda8 Mon Sep 17 00:00:00 2001 From: Ryan Aubrey Date: Tue, 30 Jun 2026 11:48:01 -0400 Subject: [PATCH 13/15] Show human-visible output for all UCP commands Change outputPolicy from 'agent-only' to 'all' so UCP commands render results in the terminal when run interactively. Co-Authored-By: Claude Opus 4.6 (1M context) Committed-By-Agent: claude --- packages/cli/src/commands/ucp/index.tsx | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/commands/ucp/index.tsx b/packages/cli/src/commands/ucp/index.tsx index 9058b52..06062c9 100644 --- a/packages/cli/src/commands/ucp/index.tsx +++ b/packages/cli/src/commands/ucp/index.tsx @@ -56,7 +56,7 @@ export function createUcpCli() { 'Agent profile URL (not required for discovery, but needed for tool negotiation)', ), }), - outputPolicy: 'agent-only' as const, + outputPolicy: 'all' as const, async run(c) { const resource = createUcpResource({ profileUrl: c.options.profileUrl }); return resource.discover(c.args.business); @@ -73,7 +73,7 @@ export function createUcpCli() { catalogCli.command('search', { description: 'Search a business catalog over UCP', options: catalogSearchOptions, - outputPolicy: 'agent-only' as const, + outputPolicy: 'all' as const, async run(c) { const resource = createUcpResource(c.options); return resource.catalogSearch(c.options.business, { @@ -87,7 +87,7 @@ export function createUcpCli() { catalogCli.command('lookup', { description: 'Batch lookup products/variants by ID', options: catalogLookupOptions, - outputPolicy: 'agent-only' as const, + outputPolicy: 'all' as const, async run(c) { const resource = createUcpResource(c.options); const ids = c.options.ids.split(',').map((id) => id.trim()); @@ -106,7 +106,7 @@ export function createUcpCli() { cartCli.command('create', { description: 'Create a new cart with line items', options: cartCreateOptions, - outputPolicy: 'agent-only' as const, + outputPolicy: 'all' as const, async run(c) { const resource = createUcpResource(c.options); if (c.options.input) { @@ -123,7 +123,7 @@ export function createUcpCli() { cartCli.command('get', { description: 'Fetch a cart by ID', options: cartGetOptions, - outputPolicy: 'agent-only' as const, + outputPolicy: 'all' as const, async run(c) { const resource = createUcpResource(c.options); return resource.cartGet(c.options.business, c.options.id); @@ -133,7 +133,7 @@ export function createUcpCli() { cartCli.command('update', { description: 'Update an existing cart', options: cartUpdateOptions, - outputPolicy: 'agent-only' as const, + outputPolicy: 'all' as const, async run(c) { const resource = createUcpResource(c.options); if (c.options.input) { @@ -161,7 +161,7 @@ export function createUcpCli() { description: 'Create a checkout from line_items, or convert a cart with --cart-id', options: checkoutCreateOptions, - outputPolicy: 'agent-only' as const, + outputPolicy: 'all' as const, async run(c) { const resource = createUcpResource(c.options); if (c.options.input) { @@ -182,7 +182,7 @@ export function createUcpCli() { checkoutCli.command('get', { description: 'Fetch a checkout by ID', options: checkoutGetOptions, - outputPolicy: 'agent-only' as const, + outputPolicy: 'all' as const, async run(c) { const resource = createUcpResource(c.options); return resource.checkoutGet(c.options.business, c.options.id); @@ -192,7 +192,7 @@ export function createUcpCli() { checkoutCli.command('update', { description: 'Update an existing checkout (fulfillment, buyer info, etc.)', options: checkoutUpdateOptions, - outputPolicy: 'agent-only' as const, + outputPolicy: 'all' as const, async run(c) { const resource = createUcpResource(c.options); const parsed = JSON.parse(c.options.input); @@ -204,7 +204,7 @@ export function createUcpCli() { description: 'Complete a checkout and place the order. May return requires_escalation with a continue_url for browser-based payment.', options: checkoutCompleteOptions, - outputPolicy: 'agent-only' as const, + outputPolicy: 'all' as const, async run(c) { const resource = createUcpResource(c.options); const params = c.options.input ? JSON.parse(c.options.input) : undefined; @@ -229,7 +229,7 @@ export function createUcpCli() { description: 'Get the current state of an order, including fulfillment expectations, tracking events, and line item status', options: orderGetOptions, - outputPolicy: 'agent-only' as const, + outputPolicy: 'all' as const, async run(c) { const resource = createUcpResource(c.options); return resource.orderGet(c.options.business, c.options.id); From 828f6a6981994f56c76055e8a022aa8dca58a12f Mon Sep 17 00:00:00 2001 From: Ryan Aubrey Date: Mon, 6 Jul 2026 10:57:11 -0400 Subject: [PATCH 14/15] Default UCP profile to Link's published agent profile --profile-url now defaults to https://link.com/ucp/agent-profiles/2026-04-08/link-wallet.json so callers don't need to specify it explicitly. The flag still works as an override. Co-Authored-By: Claude Opus 4.6 (1M context) Committed-By-Agent: claude --- packages/cli/src/commands/ucp/index.tsx | 4 +++- packages/cli/src/commands/ucp/schema.ts | 9 ++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/ucp/index.tsx b/packages/cli/src/commands/ucp/index.tsx index 06062c9..19879f1 100644 --- a/packages/cli/src/commands/ucp/index.tsx +++ b/packages/cli/src/commands/ucp/index.tsx @@ -1,6 +1,7 @@ import { UcpResource, type UcpTransport } from '@stripe/link-sdk'; import { Cli, z } from 'incur'; import { + DEFAULT_PROFILE_URL, businessOption, cartCreateOptions, cartGetOptions, @@ -52,8 +53,9 @@ export function createUcpCli() { profileUrl: z .string() .optional() + .default(DEFAULT_PROFILE_URL) .describe( - 'Agent profile URL (not required for discovery, but needed for tool negotiation)', + 'Agent profile URL (defaults to Link wallet profile; needed for tool negotiation)', ), }), outputPolicy: 'all' as const, diff --git a/packages/cli/src/commands/ucp/schema.ts b/packages/cli/src/commands/ucp/schema.ts index be1b2e0..defe787 100644 --- a/packages/cli/src/commands/ucp/schema.ts +++ b/packages/cli/src/commands/ucp/schema.ts @@ -1,5 +1,8 @@ import { z } from 'incur'; +export const DEFAULT_PROFILE_URL = + 'https://link.com/ucp/agent-profiles/2026-04-08/link-wallet.json'; + export const businessOption = z.object({ business: z .string() @@ -8,7 +11,11 @@ export const businessOption = z.object({ ), profileUrl: z .string() - .describe('Agent profile URL that identifies this agent to the merchant'), + .optional() + .default(DEFAULT_PROFILE_URL) + .describe( + 'Agent profile URL that identifies this agent to the merchant (defaults to the Link wallet profile)', + ), clientId: z .string() .optional() From 27647555f592513aa7deae9b64c1af6bf799d3b2 Mon Sep 17 00:00:00 2001 From: Ryan Aubrey Date: Mon, 6 Jul 2026 12:06:31 -0400 Subject: [PATCH 15/15] Revert "Default UCP profile to Link's published agent profile" This reverts commit 828f6a6981994f56c76055e8a022aa8dca58a12f. --- packages/cli/src/commands/ucp/index.tsx | 4 +--- packages/cli/src/commands/ucp/schema.ts | 9 +-------- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/commands/ucp/index.tsx b/packages/cli/src/commands/ucp/index.tsx index 19879f1..06062c9 100644 --- a/packages/cli/src/commands/ucp/index.tsx +++ b/packages/cli/src/commands/ucp/index.tsx @@ -1,7 +1,6 @@ import { UcpResource, type UcpTransport } from '@stripe/link-sdk'; import { Cli, z } from 'incur'; import { - DEFAULT_PROFILE_URL, businessOption, cartCreateOptions, cartGetOptions, @@ -53,9 +52,8 @@ export function createUcpCli() { profileUrl: z .string() .optional() - .default(DEFAULT_PROFILE_URL) .describe( - 'Agent profile URL (defaults to Link wallet profile; needed for tool negotiation)', + 'Agent profile URL (not required for discovery, but needed for tool negotiation)', ), }), outputPolicy: 'all' as const, diff --git a/packages/cli/src/commands/ucp/schema.ts b/packages/cli/src/commands/ucp/schema.ts index defe787..be1b2e0 100644 --- a/packages/cli/src/commands/ucp/schema.ts +++ b/packages/cli/src/commands/ucp/schema.ts @@ -1,8 +1,5 @@ import { z } from 'incur'; -export const DEFAULT_PROFILE_URL = - 'https://link.com/ucp/agent-profiles/2026-04-08/link-wallet.json'; - export const businessOption = z.object({ business: z .string() @@ -11,11 +8,7 @@ export const businessOption = z.object({ ), profileUrl: z .string() - .optional() - .default(DEFAULT_PROFILE_URL) - .describe( - 'Agent profile URL that identifies this agent to the merchant (defaults to the Link wallet profile)', - ), + .describe('Agent profile URL that identifies this agent to the merchant'), clientId: z .string() .optional()