diff --git a/packages/cli/src/__tests__/ucp.test.ts b/packages/cli/src/__tests__/ucp.test.ts new file mode 100644 index 0000000..fdefa2e --- /dev/null +++ b/packages/cli/src/__tests__/ucp.test.ts @@ -0,0 +1,483 @@ +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/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..06062c9 --- /dev/null +++ b/packages/cli/src/commands/ucp/index.tsx @@ -0,0 +1,242 @@ +import { UcpResource, type UcpTransport } from '@stripe/link-sdk'; +import { Cli, z } from 'incur'; +import { + businessOption, + cartCreateOptions, + cartGetOptions, + cartUpdateOptions, + catalogLookupOptions, + catalogSearchOptions, + checkoutCompleteOptions, + checkoutCreateOptions, + checkoutGetOptions, + checkoutUpdateOptions, + orderGetOptions, +} from './schema'; + +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() { + 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://merchant.example.com)', + ), + }), + options: z.object({ + profileUrl: z + .string() + .optional() + .describe( + 'Agent profile URL (not required for discovery, but needed for tool negotiation)', + ), + }), + outputPolicy: 'all' as const, + async run(c) { + const resource = createUcpResource({ profileUrl: 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: 'all' as const, + async run(c) { + const resource = createUcpResource(c.options); + 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: 'all' as const, + async run(c) { + const resource = createUcpResource(c.options); + 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: 'all' as const, + async run(c) { + const resource = createUcpResource(c.options); + 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: 'all' as const, + async run(c) { + const resource = createUcpResource(c.options); + return resource.cartGet(c.options.business, c.options.id); + }, + }); + + cartCli.command('update', { + description: 'Update an existing cart', + options: cartUpdateOptions, + outputPolicy: 'all' as const, + async run(c) { + 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); + } + 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: 'all' as const, + async run(c) { + const resource = createUcpResource(c.options); + 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: 'all' as const, + async run(c) { + const resource = createUcpResource(c.options); + return resource.checkoutGet(c.options.business, c.options.id); + }, + }); + + checkoutCli.command('update', { + description: 'Update an existing checkout (fulfillment, buyer info, etc.)', + options: checkoutUpdateOptions, + outputPolicy: 'all' as const, + async run(c) { + const resource = createUcpResource(c.options); + 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: 'all' as const, + async run(c) { + const resource = createUcpResource(c.options); + const params = c.options.input ? JSON.parse(c.options.input) : undefined; + return resource.checkoutComplete( + c.options.business, + c.options.id, + params, + ); + }, + }); + + 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: 'all' as const, + async run(c) { + const resource = createUcpResource(c.options); + 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 new file mode 100644 index 0000000..be1b2e0 --- /dev/null +++ b/packages/cli/src/commands/ucp/schema.ts @@ -0,0 +1,103 @@ +import { z } from 'incur'; + +export const businessOption = z.object({ + business: z + .string() + .describe( + 'Business URL to interact with (e.g. https://merchant.example.com)', + ), + profileUrl: z + .string() + .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() + .describe( + 'Transport to use: auto (prefer REST, fall back to MCP), rest, or mcp', + ), +}); + +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)'), +}); + +export const orderGetOptions = businessOption.extend({ + id: z.string().describe('Order ID (returned from checkout complete)'), +}); diff --git a/packages/sdk/src/__tests__/ucp.test.ts b/packages/sdk/src/__tests__/ucp.test.ts new file mode 100644 index 0000000..fa37cd0 --- /dev/null +++ b/packages/sdk/src/__tests__/ucp.test.ts @@ -0,0 +1,394 @@ +import { describe, expect, it, vi } from 'vitest'; +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', + }, + ], + }, + 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/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..b820726 --- /dev/null +++ b/packages/sdk/src/resources/ucp-types.ts @@ -0,0 +1,125 @@ +// 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; + rest_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; +} + +// --- 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. + +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..8b84ee9 --- /dev/null +++ b/packages/sdk/src/resources/ucp.ts @@ -0,0 +1,657 @@ +import type { + McpToolCallParams, + McpToolCallResult, + UcpCartCreateParams, + UcpCartUpdateParams, + UcpCatalogLookupParams, + UcpCatalogSearchParams, + UcpCheckoutCompleteParams, + UcpCheckoutCreateParams, + UcpCheckoutUpdateParams, + UcpDiscoveryResult, + UcpDiscoverySpec, + UcpOperationResult, + UcpOrderGetParams, +} from './ucp-types'; + +export type UcpTransport = 'auto' | 'rest' | 'mcp'; + +export interface UcpAuth { + profileUrl?: string; + clientId?: string; + clientSecret?: string; + accessToken?: string; +} + +export interface UcpResourceOptions { + fetch?: typeof globalThis.fetch; + auth?: UcpAuth; + timeoutMs?: number; + transport?: UcpTransport; +} + +const DEFAULT_TIMEOUT_MS = 30_000; + +interface ToolDescriptor { + name: string; + description?: string; + inputSchema?: unknown; +} + +interface NegotiatedEndpoint { + endpoint: string; + 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 auth: UcpAuth; + 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.auth = options.auth ?? {}; + 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); + 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, restEndpoint } = this.extractEndpoints(spec); + const capabilities = Object.keys(spec.capabilities ?? {}); + const paymentHandlers = Object.keys(spec.payment_handlers ?? {}); + + 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( + 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.callOperation(businessUrl, 'search_catalog', args); + } + + async catalogLookup( + businessUrl: string, + params: UcpCatalogLookupParams, + ): Promise { + const { ids, ...rest } = params; + return this.callOperation(businessUrl, 'lookup_catalog', { + catalog: { ids }, + ...rest, + }); + } + + async cartCreate( + businessUrl: string, + params: UcpCartCreateParams, + ): Promise { + return this.callOperation(businessUrl, 'create_cart', { cart: params }); + } + + async cartGet( + businessUrl: string, + cartId: string, + ): Promise { + return this.callOperation(businessUrl, 'get_cart', { id: cartId }); + } + + async cartUpdate( + businessUrl: string, + cartId: string, + params: UcpCartUpdateParams, + ): Promise { + return this.callOperation(businessUrl, 'update_cart', { + id: cartId, + cart: params, + }); + } + + async checkoutCreate( + businessUrl: string, + params: UcpCheckoutCreateParams, + ): Promise { + return this.callOperation(businessUrl, 'create_checkout', { + checkout: params, + }); + } + + async checkoutGet( + businessUrl: string, + checkoutId: string, + ): Promise { + return this.callOperation(businessUrl, 'get_checkout', { id: checkoutId }); + } + + async checkoutUpdate( + businessUrl: string, + checkoutId: string, + params: UcpCheckoutUpdateParams, + ): Promise { + return this.callOperation(businessUrl, 'update_checkout', { + id: checkoutId, + checkout: params, + }); + } + + async checkoutComplete( + businessUrl: string, + checkoutId: string, + params?: UcpCheckoutCompleteParams, + ): Promise { + return this.callOperation(businessUrl, 'complete_checkout', { + id: checkoutId, + ...(params ?? {}), + }); + } + + async orderGet( + businessUrl: string, + orderId: string, + ): Promise { + return this.callOperation(businessUrl, 'get_order', { id: orderId }); + } + + // --- Internal: Transport Selection --- + + private async callOperation( + businessUrl: string, + operationId: string, + args: Record, + ): Promise { + const normalized = this.normalizeBusinessUrl(businessUrl); + + 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.', + 'AUTH_PROFILE_REQUIRED', + { business: normalized }, + ); + } + + const discovery = await this.discover(normalized); + const useRest = this.shouldUseRest(discovery, operationId); + + if (useRest && discovery.rest_endpoint) { + return this.callRest(discovery.rest_endpoint, operationId, args); + } + return this.callMcp(normalized, operationId, args); + } + + 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]; + } + + // --- REST Transport --- + + private async callRest( + restEndpoint: string, + operationId: string, + args: Record, + ): Promise { + const route = REST_ROUTES[operationId] as RestRoute; + const id = args.id as string | undefined; + + let path = route.path; + if (id && path.includes('{id}')) { + path = path.replace('{id}', encodeURIComponent(id)); + } + + let base = restEndpoint; + while (base.endsWith('/')) { + base = base.slice(0, -1); + } + const url = `${base}${path}`; + + const headers: Record = { + Accept: 'application/json', + 'Request-Id': crypto.randomUUID(), + ...this.authHeaders(), + ...(this.auth.profileUrl + ? { 'UCP-Agent': `profile="${this.auth.profileUrl}"` } + : {}), + }; + + 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; + } + + // --- MCP Transport --- + + private async callMcp( + businessUrl: string, + toolName: string, + args: Record, + ): Promise { + const negotiated = await this.negotiate(businessUrl); + + 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) ?? {}), + ...(this.auth.profileUrl + ? { 'ucp-agent': { profile: this.auth.profileUrl } } + : {}), + 'idempotency-key': crypto.randomUUID(), + }, + }; + + const params: McpToolCallParams = { + name: toolName, + arguments: toolArgs, + }; + + const result = await this.mcpRpc( + negotiated.endpoint, + 'tools/call', + params, + ); + + return this.unwrapMcpResult(result, toolName, businessUrl); + } + + 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: { + ...(this.auth.profileUrl + ? { 'ucp-agent': { profile: this.auth.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 { + 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; + 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', + ...this.authHeaders(), + }, + 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; + } + + // --- 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 { + 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}`; + } + while (normalized.endsWith('/')) { + normalized = normalized.slice(0, -1); + } + return normalized; + } + + private extractEndpoints(spec: UcpDiscoverySpec): { + mcpEndpoint: string | null; + restEndpoint: string | null; + } { + const shoppingService = spec.services?.['dev.ucp.shopping']; + if (!shoppingService) return { mcpEndpoint: null, restEndpoint: null }; + + const mcpBinding = shoppingService.find((s) => s.transport === 'mcp'); + const restBinding = shoppingService.find((s) => s.transport === 'rest'); + + return { + mcpEndpoint: mcpBinding?.endpoint ?? null, + restEndpoint: restBinding?.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; + } +}