Skip to content
Draft
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
10 changes: 10 additions & 0 deletions .changeset/ucp-version-drift.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@shopify/shop-cli": minor
---

Detect UCP version drift so agents on a stale install learn to update instead of hitting opaque errors:

- `shop --version` now prints the UCP release the CLI speaks alongside the package version, e.g. `0.1.2 (UCP 2026-08-25)`.
- New `shop version [--check] [--shop-domain <domain>]` command prints `{ cli, ucp }` and, with `--check`, fetches the global catalog's (or a merchant's) `/.well-known/ucp` manifest and reports whether the pinned release is `current`, `outdated`, `unsupported`, `ahead`, or `unknown`, with an update hint.
- MCP JSON-RPC errors now include the server's `message`/`data` (e.g. `Tool not found: search_catalog`), and a "Tool not found" error is checked against the host's manifest and explained as a dropped UCP release with the upgrade command.
- When a server negotiates a different UCP release than the CLI requested, a one-time `# Notice` is written to stderr; stdout stays clean JSON/markdown.
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.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ shop orders search --type recent
- `shop checkout` — `create`, `update`, and `complete` a UCP checkout on the merchant domain (`complete` requires `--confirm`).
- `shop orders search` — search recent orders, tracking, order info, returns, and reorder candidates.
- `shop config` — persist CLI preferences such as a default country.
- `shop version` — print the CLI version and the UCP release it speaks; `--check` compares that release against the catalog's (or a merchant's) `/.well-known/ucp` manifest and says whether to update.

Run `shop <command> --help` for the flags on any command, and see the [Shop skill](https://shop.app/SKILL.md) for the full reference and shopping workflow.

Expand Down
8 changes: 8 additions & 0 deletions skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ shop --help

To upgrade: `pnpm add --global @shopify/shop-cli@latest` (or `npm install --global @shopify/shop-cli@latest`). Uninstall: `pnpm rm -g @shopify/shop-cli` (or `npm rm -g @shopify/shop-cli`).

**Stay on a supported UCP release.** `shop --version` prints the CLI version and the UCP release it speaks, e.g. `0.1.2 (UCP 2026-08-25)`. The catalog and merchants drop older releases over time, and a stale CLI fails with errors like `Tool not found: search_catalog`. Run `shop version --check` once at the start of a shopping session, and again whenever a call fails with "Tool not found", then act on `check.status`:
- `current` — nothing to do.
- `outdated` — still works; tell the user an update is available and continue.
- `unsupported` — stop and ask the user to upgrade (command above) before shopping.
- `unknown` — the manifest could not be read; continue, but upgrade if calls fail with "Tool not found".

Add `--shop-domain <merchant>` to check a specific merchant before checkout. A `# Notice` on stderr during a call means that server negotiated a different UCP release than the CLI asked for; it is informational, not an error.

**Reference files:**
- [catalog-mcp.md](references/catalog-mcp.md) — direct catalog MCP calls + manual token exchange
- [direct-api.md](references/direct-api.md) — auth, checkout, and orders API details
Expand Down
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
24 changes: 22 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ import { extname } from 'node:path'
import { Command } from 'commander'

import { AuthClient } from './auth.js'
import { CLI_VERSION, COUNTRY_ACCOUNT, DEFAULT_COUNTRY } from './constants.js'
import { CLI_VERSION, COUNTRY_ACCOUNT, DEFAULT_COUNTRY, GLOBAL_CATALOG_MCP_URL, UCP_VERSION } from './constants.js'
import { toErrorMessage } from './errors.js'
import { renderCatalogResult, renderCheckoutMessages } from './render.js'
import { ShopCatalogClient } from './shop-client.js'
import { withUserAgent } from './http.js'
import { clearStoredAuth, KeytarSecretStore, MemorySecretStore, setCountry } from './storage.js'
import type { FetchLike, SecretStore } from './types.js'
import { checkUcpVersion } from './ucp-version.js'

export interface CliDependencies {
fetch?: FetchLike
Expand Down Expand Up @@ -38,7 +40,7 @@ export function createProgram(deps: CliDependencies = {}): Command {
program
.name('shop')
.description('Shop personal shopping CLI for catalog search, auth, checkout, and order search')
.version(CLI_VERSION)
.version(`${CLI_VERSION} (UCP ${UCP_VERSION})`)
.option('--country <code>', 'Buyer country for this call (catalog context signal, not a ships-to filter). Transient; use `shop config set-country` to persist a default.', DEFAULT_COUNTRY)
.option('--profile-url <url>', 'UCP agent profile URL for global catalog calls')
.option('--memory-store', 'Use in-memory token storage for tests and dry runs')
Expand Down Expand Up @@ -384,6 +386,22 @@ export function createProgram(deps: CliDependencies = {}): Command {
})
})

program
.command('version')
.description('Print the CLI version and the UCP release it speaks; --check compares that release against a server manifest')
.option('--check', 'Fetch /.well-known/ucp from the global catalog (or --shop-domain) and report whether this CLI is current, outdated, or unsupported')
.option('--shop-domain <domain>', 'Merchant domain to check instead of the global catalog, e.g. example.myshopify.com')
.action(async (opts: { check?: boolean; shopDomain?: string }) => {
await runAction({ stdout, stderr, exit }, async () => {
const result: Record<string, unknown> = { cli: CLI_VERSION, ucp: UCP_VERSION }
if (opts.check || opts.shopDomain) {
const host = opts.shopDomain ?? new URL(GLOBAL_CATALOG_MCP_URL).host
result.check = await checkUcpVersion(withUserAgent(deps.fetch ?? fetch), host)
}
return result
})
})

return program
}

Expand All @@ -410,6 +428,7 @@ function explicitCountry(program: Command): string | undefined {
function resolveClient(deps: CliDependencies, program: Command): ShopCatalogClient {
const globals = program.optsWithGlobals<GlobalOptions>()
const store = resolveStore(deps, globals)
const stderr = deps.stderr ?? process.stderr
// Only treat --country as an explicit override when it was actually passed on the CLI;
// otherwise leave it undefined so the client falls back to the stored preference, then
// DEFAULT_COUNTRY. The default value of the option must never override a stored country.
Expand All @@ -418,6 +437,7 @@ function resolveClient(deps: CliDependencies, program: Command): ShopCatalogClie
store,
profileUrl: globals.profileUrl,
country: explicitCountry(program),
onNotice: (message) => stderr.write(`# Notice\n\n${message}\n\n`),
})
}

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`
52 changes: 51 additions & 1 deletion src/shop-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ import {
import { AuthClient } from './auth.js'
import { getCountry, getOrCreateDeviceId } from './storage.js'
import type { FetchLike, JsonObject, SecretStore } from './types.js'
import {
checkUcpVersion,
describeMcpError,
isUnknownToolError,
negotiatedVersionNotice,
unknownToolGuidance,
} from './ucp-version.js'

// Maps a CatalogSearchInput field to its exact `filters.attributes[].name`
// display name, as recognized by the catalog API (shop/world#792867):
Expand All @@ -41,6 +48,10 @@ export interface ShopCatalogClientOptions {
profileUrl?: string
country?: string
auth?: AuthClient
// Receives non-fatal notices (e.g. a server negotiated a different UCP release
// than the one this CLI speaks). The CLI routes these to stderr so stdout
// stays clean JSON/markdown.
onNotice?: (message: string) => void
}

export interface CatalogSearchInput {
Expand Down Expand Up @@ -139,6 +150,9 @@ export class ShopCatalogClient {
// Cached global-catalog exchange JWT (session/in-memory only). Present only
// when the buyer is signed in; absent means we search the catalog unauthenticated.
private catalogToken?: string
// host|version pairs already reported through onNotice, so a multi-call flow
// (create then complete checkout) mentions each mismatch once.
private readonly reportedVersions = new Set<string>()

constructor(private readonly options: ShopCatalogClientOptions) {
const baseFetch = options.fetch ?? fetch
Expand Down Expand Up @@ -433,10 +447,38 @@ export class ShopCatalogClient {
}),
})
const json = await parseJsonResponse<JsonObject>(response, `Call ${toolName}`)
if (json.error) throw new ShopCliError(`MCP ${toolName} returned an error`, { details: json.error })
if (json.error) throw await this.mcpError(endpoint, toolName, json.error)
this.noteNegotiatedVersion(endpoint, toolName, json)
return json
}

// Turn a JSON-RPC error into an actionable message. "Tool not found" for a
// tool this CLI is built against almost always means the server dropped the
// UCP release named in our agent profile, so confirm against the host's
// /.well-known/ucp manifest (non-fatal) and say how to fix it.
private async mcpError(endpoint: string, toolName: string, error: unknown): Promise<ShopCliError> {
const detail = describeMcpError(error)
let message = `MCP ${toolName} returned an error${detail ? `: ${detail}` : ''}`
if (isUnknownToolError(error)) {
const check = await checkUcpVersion(this.fetchImpl, hostOf(endpoint))
message = `${message}. ${unknownToolGuidance(check)}`
// The manifest check only vouches for the release this build pins; an
// overridden profile may declare a release the server has since dropped.
if (isCatalogTool(toolName) && this.options.profileUrl) {
message = `${message} A custom --profile-url is in use (${this.options.profileUrl}); the server may have dropped the UCP release that profile declares.`
}
}
return new ShopCliError(message, { details: error })
}

private noteNegotiatedVersion(endpoint: string, toolName: string, json: JsonObject): void {
if (!this.options.onNotice) return
const notice = negotiatedVersionNotice(json, toolName, hostOf(endpoint))
if (!notice || this.reportedVersions.has(notice.key)) return
this.reportedVersions.add(notice.key)
this.options.onNotice(notice.message)
}

// Global catalog read calls. When the buyer is signed in we attach an
// authenticated catalog token; otherwise we fall back to an unauthenticated
// request. Auth is handled here so callers never have to.
Expand Down Expand Up @@ -763,6 +805,14 @@ function isCatalogTool(toolName: string): boolean {
return toolName === 'search_catalog' || toolName === 'lookup_catalog' || toolName === 'get_product'
}

function hostOf(endpoint: string): string {
try {
return new URL(endpoint).host
} catch {
return endpoint
}
}

function isPlainObject(value: unknown): value is JsonObject {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
Expand Down
Loading
Loading