Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/update-ucp-2026-08-25.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 4 additions & 4 deletions skill/references/catalog-mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": {}
Expand All @@ -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": {
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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": {
Expand Down
7 changes: 4 additions & 3 deletions skill/references/direct-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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",
Expand Down Expand Up @@ -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": "<unique_key_for_purchase_intent>"
},
Expand Down Expand Up @@ -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": "<checkout_id>",
Expand Down
12 changes: 8 additions & 4 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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`
142 changes: 142 additions & 0 deletions tests/ucp-version.test.ts
Original file line number Diff line number Diff line change
@@ -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<string | undefined>) {
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<string | undefined> = []
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<string | undefined> = []
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<string | undefined> = []
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<string | undefined> = []
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<string | undefined> = []
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])
})
})
Loading