diff --git a/.changeset/update-ucp-2026-08-25.md b/.changeset/update-ucp-2026-08-25.md new file mode 100644 index 0000000..775eb93 --- /dev/null +++ b/.changeset/update-ucp-2026-08-25.md @@ -0,0 +1,5 @@ +--- +"@shopify/shop-cli": minor +--- + +Speak the UCP 2026-08-25 release instead of 2026-04-08. The agent profiles sent with every MCP call (`valid-with-capabilities` for the global catalog, `personal_agent` for merchant checkout) now point at the 2026-08-25 fixtures, matching the version Shopify storefronts advertise as their default. The release is pinned in one place (`UCP_VERSION`), and the skill reference docs use the new profile URLs. diff --git a/skill/references/catalog-mcp.md b/skill/references/catalog-mcp.md index 8db9443..7e867e0 100644 --- a/skill/references/catalog-mcp.md +++ b/skill/references/catalog-mcp.md @@ -42,7 +42,7 @@ Every tool call includes: "arguments": { "meta": { "ucp-agent": { - "profile": "https://shopify.dev/ucp/agent-profiles/2026-04-08/valid-with-capabilities.json" + "profile": "https://shopify.dev/ucp/agent-profiles/2026-08-25/valid-with-capabilities.json" } }, "catalog": {} @@ -65,7 +65,7 @@ Every tool call includes: "arguments": { "meta": { "ucp-agent": { - "profile": "https://shopify.dev/ucp/agent-profiles/2026-04-08/valid-with-capabilities.json" + "profile": "https://shopify.dev/ucp/agent-profiles/2026-08-25/valid-with-capabilities.json" } }, "catalog": { @@ -179,7 +179,7 @@ Use `lookup_catalog` for known product or variant IDs. "arguments": { "meta": { "ucp-agent": { - "profile": "https://shopify.dev/ucp/agent-profiles/2026-04-08/valid-with-capabilities.json" + "profile": "https://shopify.dev/ucp/agent-profiles/2026-08-25/valid-with-capabilities.json" } }, "catalog": { @@ -208,7 +208,7 @@ Use `get_product` to inspect options, availability, selected variants, seller do "arguments": { "meta": { "ucp-agent": { - "profile": "https://shopify.dev/ucp/agent-profiles/2026-04-08/valid-with-capabilities.json" + "profile": "https://shopify.dev/ucp/agent-profiles/2026-08-25/valid-with-capabilities.json" } }, "catalog": { diff --git a/skill/references/direct-api.md b/skill/references/direct-api.md index 0f70d99..7f75035 100644 --- a/skill/references/direct-api.md +++ b/skill/references/direct-api.md @@ -105,7 +105,7 @@ Create with line items, or pass a checkout body that already contains a `cart_id "arguments": { "meta": { "ucp-agent": { - "profile": "https://shopify.dev/ucp/agent-profiles/2026-04-08/personal_agent.json" + "profile": "https://shopify.dev/ucp/agent-profiles/2026-08-25/personal_agent.json" } }, "checkout": { @@ -125,6 +125,7 @@ Create with line items, or pass a checkout body that already contains a `cart_id "destinations": [ { "id": "dest-1", + "type": "shipping_address", "first_name": "Jane", "last_name": "Doe", "street_address": "131 Greene St", @@ -176,7 +177,7 @@ it did not complete — do not retry without re-verifying. "arguments": { "meta": { "ucp-agent": { - "profile": "https://shopify.dev/ucp/agent-profiles/2026-04-08/personal_agent.json" + "profile": "https://shopify.dev/ucp/agent-profiles/2026-08-25/personal_agent.json" }, "idempotency-key": "" }, @@ -216,7 +217,7 @@ Use `update_checkout` with the checkout ID from create and only the fields that "arguments": { "meta": { "ucp-agent": { - "profile": "https://shopify.dev/ucp/agent-profiles/2026-04-08/personal_agent.json" + "profile": "https://shopify.dev/ucp/agent-profiles/2026-08-25/personal_agent.json" } }, "id": "", diff --git a/src/constants.ts b/src/constants.ts index 12a6492..69495db 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -11,8 +11,12 @@ function readPackageVersion(): string { export const CLIENT_ID = '5c733ab2-1903-400a-891e-7ba20c09e2a3' export const DEFAULT_AGENT_NAME = 'Shop CLI' export const DEFAULT_COUNTRY = 'US' -export const DEFAULT_PROFILE_URL = - 'https://shopify.dev/ucp/agent-profiles/2026-04-08/valid-with-capabilities.json' +// UCP release the CLI speaks. Both agent profiles below are pinned to it, so +// bump this one constant when moving to a newer release. +export const UCP_VERSION = '2026-08-25' +export const AGENT_PROFILES_BASE_URL = `https://shopify.dev/ucp/agent-profiles/${UCP_VERSION}` +// Profile sent with global catalog calls (search/lookup/get_product). +export const DEFAULT_PROFILE_URL = `${AGENT_PROFILES_BASE_URL}/valid-with-capabilities.json` export const GLOBAL_CATALOG_MCP_URL = 'https://catalog.shopify.com/api/ucp/mcp' // Changesets updates package.json, so use it as the single source of truth. export const CLI_VERSION = readPackageVersion() @@ -34,5 +38,5 @@ export const COUNTRY_ACCOUNT = 'country' // (emits the sign-in URL) and `auth poll` (exchanges + stores tokens). export const PENDING_DEVICE_AUTH_ACCOUNT = 'pending_device_auth' export const AUTH_SCOPES = 'openid email personal_agent' -export const UCP_PROFILE = - 'https://shopify.dev/ucp/agent-profiles/2026-04-08/personal_agent.json' +// Profile sent with merchant checkout calls (create/update/complete_checkout). +export const UCP_PROFILE = `${AGENT_PROFILES_BASE_URL}/personal_agent.json` diff --git a/tests/ucp-version.test.ts b/tests/ucp-version.test.ts new file mode 100644 index 0000000..746a955 --- /dev/null +++ b/tests/ucp-version.test.ts @@ -0,0 +1,142 @@ +import { describe, it } from 'node:test' +import { expect, fn } from './harness.js' + +import { + ACCESS_TOKEN_ACCOUNT, + DEFAULT_PROFILE_URL, + GLOBAL_CATALOG_MCP_URL, + PAYMENT_TOKENS_URL, + UCP_PROFILE, + UCP_VERSION, +} from '../src/constants.js' +import { ShopCatalogClient } from '../src/shop-client.js' +import { createFetchMock, createStore, jsonResponse, readJsonBody } from './test-utils.js' + +const MERCHANT_MCP_URL = 'https://example.myshopify.com/api/ucp/mcp' +const CUSTOM_PROFILE_URL = 'https://agent.example.com/ucp/profile.json' + +type McpBody = { params: { arguments: { meta?: { 'ucp-agent'?: { profile?: string } } } } } + +function profileOf(body: unknown): string | undefined { + return (body as McpBody).params.arguments.meta?.['ucp-agent']?.profile +} + +// Merchant checkout calls also hit auth + buyer-ip endpoints before the MCP +// call; answer those and record only the profile sent to the merchant. +function merchantFetchMock(profiles: Array) { + return createFetchMock(async (url, init) => { + if (url.endsWith('/userinfo')) return jsonResponse({ sub: 'user-1' }) + if (url === 'https://shop.app/oauth/token') return jsonResponse({ access_token: 'ucp-jwt' }) + if (url === 'https://api.ipify.org?format=json') return jsonResponse({ ip: '203.0.113.10' }) + if (url === PAYMENT_TOKENS_URL) return jsonResponse({ payment_tokens: [] }) + if (url === MERCHANT_MCP_URL) { + profiles.push(profileOf(await readJsonBody(init))) + return jsonResponse({ jsonrpc: '2.0', id: 1, result: { structuredContent: { status: 'ready_for_complete' } } }) + } + throw new Error(`Unexpected URL ${url}`) + }) +} + +describe('UCP release pinning', () => { + it('pins both agent profiles to the 2026-08-25 release', () => { + expect(UCP_VERSION).toBe('2026-08-25') + expect(DEFAULT_PROFILE_URL).toBe( + 'https://shopify.dev/ucp/agent-profiles/2026-08-25/valid-with-capabilities.json', + ) + expect(UCP_PROFILE).toBe('https://shopify.dev/ucp/agent-profiles/2026-08-25/personal_agent.json') + }) + + it('sends the valid-with-capabilities profile on every global catalog call', async () => { + const profiles: Array = [] + const fetchMock = createFetchMock(async (url, init) => { + expect(url).toBe(GLOBAL_CATALOG_MCP_URL) + profiles.push(profileOf(await readJsonBody(init))) + return jsonResponse({ jsonrpc: '2.0', id: 1, result: { structuredContent: { products: [] } } }) + }) + const client = new ShopCatalogClient({ fetch: fetchMock, store: createStore() }) + + await client.searchCatalog({ query: 'boots', country: 'US' }) + await client.lookupCatalog({ ids: ['gid://shopify/ProductVariant/1'] }) + await client.getProduct({ id: 'gid://shopify/p/abc' }) + + expect(profiles).toEqual([DEFAULT_PROFILE_URL, DEFAULT_PROFILE_URL, DEFAULT_PROFILE_URL]) + for (const profile of profiles) expect(profile).toContain('/2026-08-25/') + }) + + it('sends the personal_agent profile on merchant checkout calls', async () => { + const profiles: Array = [] + const client = new ShopCatalogClient({ + fetch: merchantFetchMock(profiles), + store: createStore({ [ACCESS_TOKEN_ACCOUNT]: 'access' }), + }) + + await client.createCheckout({ + shopDomain: 'example.myshopify.com', + variantId: '123', + quantity: 1, + checkout: { email: 'buyer@example.com' }, + }) + await client.updateCheckout({ + shopDomain: 'example.myshopify.com', + checkoutId: 'checkout-1', + checkout: { email: 'buyer@example.com' }, + }) + + expect(profiles).toEqual([UCP_PROFILE, UCP_PROFILE]) + for (const profile of profiles) expect(profile).toContain('/2026-08-25/') + }) + + it('a custom profileUrl overrides the catalog profile but never the checkout profile', async () => { + const catalogProfiles: Array = [] + const catalogFetch = createFetchMock(async (_url, init) => { + catalogProfiles.push(profileOf(await readJsonBody(init))) + return jsonResponse({ jsonrpc: '2.0', id: 1, result: { structuredContent: { products: [] } } }) + }) + await new ShopCatalogClient({ + fetch: catalogFetch, + store: createStore(), + profileUrl: CUSTOM_PROFILE_URL, + }).searchCatalog({ query: 'boots', country: 'US' }) + expect(catalogProfiles).toEqual([CUSTOM_PROFILE_URL]) + + const checkoutProfiles: Array = [] + await new ShopCatalogClient({ + fetch: merchantFetchMock(checkoutProfiles), + store: createStore({ [ACCESS_TOKEN_ACCOUNT]: 'access' }), + profileUrl: CUSTOM_PROFILE_URL, + }).createCheckout({ + shopDomain: 'example.myshopify.com', + variantId: '123', + quantity: 1, + checkout: { email: 'buyer@example.com' }, + }) + expect(checkoutProfiles).toEqual([UCP_PROFILE]) + }) + + it('CLI: --profile-url is forwarded to global catalog searches', async () => { + const { createProgram } = await import('../src/cli.js') + const stdout = { write: fn() } + const stderr = { write: fn() } + const profiles: Array = [] + const fetchMock = createFetchMock(async (url, init) => { + expect(url).toBe(GLOBAL_CATALOG_MCP_URL) + profiles.push(profileOf(await readJsonBody(init))) + return jsonResponse({ jsonrpc: '2.0', id: 1, result: { structuredContent: { products: [] } } }) + }) + const base = { + fetch: fetchMock, + store: createStore(), + stdout, + stderr, + exit: ((code: number) => { + throw new Error(`exit ${code}`) + }) as never, + } + + await createProgram(base).parseAsync(['node', 'shop', 'search', 'boots']) + await createProgram(base).parseAsync(['node', 'shop', '--profile-url', CUSTOM_PROFILE_URL, 'search', 'boots']) + + expect(stderr.write).not.toHaveBeenCalled() + expect(profiles).toEqual([DEFAULT_PROFILE_URL, CUSTOM_PROFILE_URL]) + }) +})