diff --git a/.changeset/ucp-version-drift.md b/.changeset/ucp-version-drift.md new file mode 100644 index 0000000..1f9a00c --- /dev/null +++ b/.changeset/ucp-version-drift.md @@ -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 ]` 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. 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/README.md b/README.md index 31dac0f..41114db 100644 --- a/README.md +++ b/README.md @@ -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 --help` for the flags on any command, and see the [Shop skill](https://shop.app/SKILL.md) for the full reference and shopping workflow. diff --git a/skill/SKILL.md b/skill/SKILL.md index 0b6e7c0..939443f 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -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 ` 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 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/cli.ts b/src/cli.ts index 6928ff3..892c061 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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 @@ -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 ', '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 ', 'UCP agent profile URL for global catalog calls') .option('--memory-store', 'Use in-memory token storage for tests and dry runs') @@ -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 ', '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 = { 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 } @@ -410,6 +428,7 @@ function explicitCountry(program: Command): string | undefined { function resolveClient(deps: CliDependencies, program: Command): ShopCatalogClient { const globals = program.optsWithGlobals() 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. @@ -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`), }) } 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/src/shop-client.ts b/src/shop-client.ts index 8d9ae26..c147203 100644 --- a/src/shop-client.ts +++ b/src/shop-client.ts @@ -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): @@ -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 { @@ -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() constructor(private readonly options: ShopCatalogClientOptions) { const baseFetch = options.fetch ?? fetch @@ -433,10 +447,38 @@ export class ShopCatalogClient { }), }) const json = await parseJsonResponse(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 { + 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. @@ -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) } diff --git a/src/ucp-version.ts b/src/ucp-version.ts new file mode 100644 index 0000000..8ba8264 --- /dev/null +++ b/src/ucp-version.ts @@ -0,0 +1,212 @@ +import { UCP_VERSION } from './constants.js' +import type { FetchLike, JsonObject } from './types.js' + +// UCP servers negotiate DOWN to the release named in the agent profile, so a +// successful response never reveals that this CLI is behind. The only +// server-published signal is the discovery manifest at /.well-known/ucp, which +// lists the server's default `version` and the older `supported_versions` it +// still accepts. Everything here compares the pinned UCP_VERSION against that. + +export type UcpVersionStatus = 'current' | 'outdated' | 'unsupported' | 'ahead' | 'unknown' + +export interface UcpVersionCheck { + host: string + manifest_url: string + cli_ucp_version: string + server_version: string | null + supported_versions: string[] + status: UcpVersionStatus + message: string +} + +export interface UcpManifestVersions { + version: string + supported: string[] +} + +export const UPDATE_HINT = + 'Update shop-cli: `npm install --global @shopify/shop-cli@latest` (or `pnpm add --global @shopify/shop-cli@latest`).' + +export function manifestUrlFor(host: string): string { + const trimmed = host.trim().replace(/^https?:\/\//i, '').replace(/\/.*$/, '') + return `https://${trimmed}/.well-known/ucp` +} + +// Read `ucp.version` and `ucp.supported_versions` from a host's discovery +// manifest. Never throws: any network, HTTP, or shape problem yields null so +// callers can degrade to "unknown" instead of failing the command. +export async function fetchUcpManifestVersions( + fetchImpl: FetchLike, + host: string, +): Promise { + try { + const response = await fetchImpl(manifestUrlFor(host), { + method: 'GET', + headers: { Accept: 'application/json' }, + }) + if (!response.ok) return null + const body = JSON.parse(await response.text()) as unknown + return parseManifestVersions(body) + } catch { + return null + } +} + +export function parseManifestVersions(body: unknown): UcpManifestVersions | null { + const root = isRecord(body) ? body : null + const ucp = root && isRecord(root.ucp) ? root.ucp : root + if (!ucp || typeof ucp.version !== 'string') return null + const supported = new Set([ucp.version]) + const declared = ucp.supported_versions + if (Array.isArray(declared)) { + for (const entry of declared) if (typeof entry === 'string') supported.add(entry) + } else if (isRecord(declared)) { + for (const key of Object.keys(declared)) supported.add(key) + } + return { version: ucp.version, supported: sortVersionsDesc([...supported]) } +} + +export function classifyUcpVersion( + manifest: UcpManifestVersions | null, + host: string, + cliVersion = UCP_VERSION, +): UcpVersionCheck { + const base = { + host, + manifest_url: manifestUrlFor(host), + cli_ucp_version: cliVersion, + } + + if (!manifest) { + return { + ...base, + server_version: null, + supported_versions: [], + status: 'unknown', + message: `Could not read the UCP manifest at ${base.manifest_url}, so the version could not be checked. shop-cli speaks UCP ${cliVersion}.`, + } + } + + const supported = manifest.supported + const list = supported.join(', ') + const cliSupported = supported.includes(cliVersion) + const withVersions = { ...base, server_version: manifest.version, supported_versions: supported } + + if (manifest.version === cliVersion) { + return { + ...withVersions, + status: 'current', + message: `shop-cli speaks UCP ${cliVersion}, the current release at ${host}.`, + } + } + + if (manifest.version > cliVersion) { + if (cliSupported) { + return { + ...withVersions, + status: 'outdated', + message: `${host} is on UCP ${manifest.version}; shop-cli speaks ${cliVersion}, which ${host} still supports (${list}) but will drop in a future release. ${UPDATE_HINT}`, + } + } + return { + ...withVersions, + status: 'unsupported', + message: `${host} no longer supports UCP ${cliVersion}, the release this shop-cli speaks (it accepts ${list}). Calls will fail until you update. ${UPDATE_HINT}`, + } + } + + // Server default is older than the CLI. + if (cliSupported) { + return { + ...withVersions, + status: 'current', + message: `${host} supports UCP ${cliVersion}, the release shop-cli speaks (its default is ${manifest.version}).`, + } + } + return { + ...withVersions, + status: 'ahead', + message: `${host} has not adopted UCP ${cliVersion} yet (it supports ${list}). shop-cli is newer than this server, so requests may be negotiated down or fail; updating shop-cli will not help.`, + } +} + +export async function checkUcpVersion(fetchImpl: FetchLike, host: string): Promise { + return classifyUcpVersion(await fetchUcpManifestVersions(fetchImpl, host), host) +} + +// A successful MCP result carries the negotiated release in +// `result.structuredContent.ucp.version`. Returns a notice when it differs from +// the release the CLI asked for, plus a dedupe key so callers can report each +// host/version pair once per process. +export function negotiatedVersionNotice( + payload: unknown, + toolName: string, + host: string, + cliVersion = UCP_VERSION, +): { key: string; message: string } | undefined { + const version = negotiatedVersionOf(payload) + if (!version || version === cliVersion) return undefined + const key = `${host}|${version}` + if (version < cliVersion) { + return { + key, + message: `${host} answered ${toolName} with UCP ${version}, older than the ${cliVersion} shop-cli requested. This server has not adopted ${cliVersion} yet, so its responses follow the ${version} schema.`, + } + } + return { + key, + message: `${host} answered ${toolName} with UCP ${version}, newer than the ${cliVersion} shop-cli speaks. ${UPDATE_HINT}`, + } +} + +export function negotiatedVersionOf(payload: unknown): string | undefined { + if (!isRecord(payload)) return undefined + const result = isRecord(payload.result) ? payload.result : payload + const content = isRecord(result.structuredContent) ? result.structuredContent : result + const ucp = isRecord(content.ucp) ? content.ucp : undefined + return typeof ucp?.version === 'string' ? ucp.version : undefined +} + +// JSON-RPC error envelopes carry the useful part in `message` and often a +// human-readable `data` string ("Tool not found: search_catalog"). Join them so +// the CLI error shows what the server actually said. +export function describeMcpError(error: unknown): string { + if (typeof error === 'string') return error + if (!isRecord(error)) return '' + const parts: string[] = [] + if (typeof error.message === 'string' && error.message.length > 0) parts.push(error.message) + const data = error.data + if (typeof data === 'string' && data.length > 0) parts.push(data) + else if (isRecord(data) && typeof data.message === 'string') parts.push(data.message) + return parts.join(': ') +} + +// The failure a stale CLI hits: the server dropped the release named in the +// agent profile, so the tool the CLI is built against is "not found". +export function isUnknownToolError(error: unknown): boolean { + if (!isRecord(error)) return false + if (error.code === -32601) return true + return /tool not found|unknown tool|no such tool/i.test(describeMcpError(error)) +} + +export function unknownToolGuidance(check: UcpVersionCheck): string { + switch (check.status) { + case 'unsupported': + case 'outdated': + case 'ahead': + return check.message + case 'current': + return `${check.host} still supports UCP ${check.cli_ucp_version}, so this is not a version mismatch.` + case 'unknown': + return `This usually means ${check.host} no longer accepts UCP ${check.cli_ucp_version}, the release this shop-cli speaks. ${UPDATE_HINT}` + } +} + +function sortVersionsDesc(versions: string[]): string[] { + // UCP releases are YYYY-MM-DD strings, so lexical order is chronological. + return [...versions].sort((a, b) => (a < b ? 1 : a > b ? -1 : 0)) +} + +function isRecord(value: unknown): value is JsonObject { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/tests/ucp-version-check.test.ts b/tests/ucp-version-check.test.ts new file mode 100644 index 0000000..e89aa57 --- /dev/null +++ b/tests/ucp-version-check.test.ts @@ -0,0 +1,295 @@ +import { describe, it } from 'node:test' +import { expect, fn } from './harness.js' + +import { createProgram } from '../src/cli.js' +import { CLI_VERSION, GLOBAL_CATALOG_MCP_URL, UCP_VERSION } from '../src/constants.js' +import { ShopCatalogClient } from '../src/shop-client.js' +import { + classifyUcpVersion, + describeMcpError, + isUnknownToolError, + negotiatedVersionNotice, + parseManifestVersions, + UPDATE_HINT, +} from '../src/ucp-version.js' +import { createFetchMock, createStore, jsonResponse } from './test-utils.js' + +const CATALOG_MANIFEST_URL = 'https://catalog.shopify.com/.well-known/ucp' +const MERCHANT_MANIFEST_URL = 'https://example.myshopify.com/.well-known/ucp' +const OLDER = '2026-04-08' +const NEWER = '2027-01-15' +const UPDATE_COMMAND = 'npm install --global @shopify/shop-cli@latest' + +type Handler = (url: string, init: RequestInit) => Promise | Response + +function manifest(version: string, supported: string[]): Record { + return { + ucp: { + version, + supported_versions: Object.fromEntries(supported.map((v) => [v, `https://catalog.shopify.com/.well-known/ucp/${v}`])), + }, + } +} + +function mcpResult(structuredContent: Record): Response { + return jsonResponse({ jsonrpc: '2.0', id: 1, result: { structuredContent } }) +} + +function mcpError(error: Record): Response { + return jsonResponse({ jsonrpc: '2.0', id: 1, error }) +} + +function joinWrites(writer: { write: ReturnType }): string { + return (writer.write.mock.calls as { arguments: unknown[] }[]).map((call) => String(call.arguments[0])).join('') +} + +async function runCli(args: string[], handler: Handler) { + const stdout = { write: fn() } + const stderr = { write: fn() } + const urls: string[] = [] + const inits: RequestInit[] = [] + let exitCode: number | undefined + const fetchMock = createFetchMock(async (url, init) => { + urls.push(url) + inits.push(init) + return handler(url, init) + }) + + try { + await createProgram({ + fetch: fetchMock, + store: createStore({}), + stdout, + stderr, + exit: ((code: number) => { + exitCode = code + throw new Error(`exit ${code}`) + }) as never, + }).parseAsync(['node', 'shop', ...args]) + } catch (error) { + if (!String(error).includes('exit ')) throw error + } + + return { out: joinWrites(stdout), err: joinWrites(stderr), urls, inits, exitCode } +} + +function parseJson(out: string): Record { + return JSON.parse(out.slice(out.indexOf('{'))) as Record +} + +describe('shop version', () => { + it('prints the CLI and UCP versions without touching the network', async () => { + const { out, err, urls, exitCode } = await runCli(['version'], () => { + throw new Error('no network expected') + }) + expect(exitCode).toBeUndefined() + expect(err).toBe('') + expect(urls).toEqual([]) + expect(parseJson(out)).toEqual({ cli: CLI_VERSION, ucp: UCP_VERSION }) + }) + + it('--check reads the global catalog manifest and reports current when it matches', async () => { + const { out, urls, inits, exitCode } = await runCli(['version', '--check'], (url) => { + if (url === CATALOG_MANIFEST_URL) return jsonResponse(manifest(UCP_VERSION, [OLDER])) + throw new Error(`Unexpected URL ${url}`) + }) + expect(exitCode).toBeUndefined() + expect(urls).toEqual([CATALOG_MANIFEST_URL]) + expect((inits[0].headers as Record)['User-Agent']).toBe(`shop-cli/${CLI_VERSION}`) + expect(parseJson(out)).toMatchObject({ + cli: CLI_VERSION, + ucp: UCP_VERSION, + check: { + host: 'catalog.shopify.com', + manifest_url: CATALOG_MANIFEST_URL, + cli_ucp_version: UCP_VERSION, + server_version: UCP_VERSION, + supported_versions: [UCP_VERSION, OLDER], + status: 'current', + }, + }) + }) + + it('--check reports outdated with an update hint when the server default is newer but still supports us', async () => { + const { out } = await runCli(['version', '--check'], () => jsonResponse(manifest(NEWER, [UCP_VERSION]))) + const check = parseJson(out).check as Record + expect(check.status).toBe('outdated') + expect(check.server_version).toBe(NEWER) + expect(check.message).toContain(UPDATE_COMMAND) + }) + + it('--check reports unsupported when the server dropped our release', async () => { + const { out, exitCode } = await runCli(['version', '--check'], () => jsonResponse(manifest(NEWER, ['2026-12-01']))) + const check = parseJson(out).check as Record + expect(exitCode).toBeUndefined() + expect(check.status).toBe('unsupported') + expect(check.message).toContain('no longer supports') + expect(check.message).toContain(UPDATE_COMMAND) + }) + + it('--check reports unknown instead of failing when the manifest is unavailable', async () => { + const { out, err, exitCode } = await runCli(['version', '--check'], () => jsonResponse({ error: 'nope' }, { status: 404 })) + expect(exitCode).toBeUndefined() + expect(err).toBe('') + const check = parseJson(out).check as Record + expect(check.status).toBe('unknown') + expect(check.server_version).toBe(null) + expect(check.message).toContain(CATALOG_MANIFEST_URL) + }) + + it('--shop-domain checks the merchant manifest instead of the catalog', async () => { + const { out, urls } = await runCli(['version', '--shop-domain', 'example.myshopify.com'], (url) => { + if (url === MERCHANT_MANIFEST_URL) return jsonResponse(manifest(UCP_VERSION, [OLDER])) + throw new Error(`Unexpected URL ${url}`) + }) + expect(urls).toEqual([MERCHANT_MANIFEST_URL]) + const check = parseJson(out).check as Record + expect(check.host).toBe('example.myshopify.com') + expect(check.status).toBe('current') + }) +}) + +describe('UCP manifest classification', () => { + it('reports ahead when the server has not adopted our release and updating would not help', () => { + const check = classifyUcpVersion({ version: OLDER, supported: [OLDER, '2026-01-23'] }, 'shop.example') + expect(check.status).toBe('ahead') + expect(check.message).toContain('updating shop-cli will not help') + }) + + it('treats a server whose default is older but which lists our release as current', () => { + const check = classifyUcpVersion({ version: OLDER, supported: [UCP_VERSION, OLDER] }, 'shop.example') + expect(check.status).toBe('current') + }) + + it('parses supported_versions as a map or a list and sorts newest first', () => { + expect(parseManifestVersions(manifest(UCP_VERSION, ['2026-01-23', OLDER]))).toEqual({ + version: UCP_VERSION, + supported: [UCP_VERSION, OLDER, '2026-01-23'], + }) + expect(parseManifestVersions({ ucp: { version: UCP_VERSION, supported_versions: [OLDER] } })).toEqual({ + version: UCP_VERSION, + supported: [UCP_VERSION, OLDER], + }) + expect(parseManifestVersions({ version: UCP_VERSION })).toEqual({ version: UCP_VERSION, supported: [UCP_VERSION] }) + expect(parseManifestVersions({ ucp: {} })).toBe(null) + expect(parseManifestVersions('not json')).toBe(null) + }) +}) + +describe('UCP version drift during MCP calls', () => { + const search = ['--format', 'json', 'search', 'shoes'] + + it('writes a one-time stderr notice when a server negotiates an older UCP release', async () => { + const { out, err, exitCode } = await runCli(search, (url) => { + if (url === GLOBAL_CATALOG_MCP_URL) return mcpResult({ ucp: { version: OLDER }, products: [] }) + throw new Error(`Unexpected URL ${url}`) + }) + expect(exitCode).toBeUndefined() + expect(err).toContain('# Notice') + expect(err).toContain(`with UCP ${OLDER}`) + expect(err).toContain(UCP_VERSION) + expect(out).toContain('"products"') + }) + + it('stays silent when the negotiated release matches the CLI', async () => { + const { err } = await runCli(search, () => mcpResult({ ucp: { version: UCP_VERSION }, products: [] })) + expect(err).toBe('') + }) + + it('reports each host/version mismatch once per client, not once per call', async () => { + const onNotice = fn() + const client = new ShopCatalogClient({ + fetch: createFetchMock(async () => mcpResult({ ucp: { version: OLDER }, products: [] })), + store: createStore({}), + onNotice, + }) + await client.searchCatalog({ query: 'shoes' }) + await client.getProduct({ id: 'gid://shopify/Product/1' }) + expect(onNotice).toHaveBeenCalledTimes(1) + }) + + it('explains a Tool not found error as a dropped UCP release, confirmed via the manifest', async () => { + const { err, urls, exitCode } = await runCli(search, (url) => { + if (url === GLOBAL_CATALOG_MCP_URL) { + return mcpError({ code: -32602, message: 'Invalid params', data: 'Tool not found: search_catalog' }) + } + if (url === CATALOG_MANIFEST_URL) return jsonResponse(manifest(NEWER, ['2026-12-01'])) + throw new Error(`Unexpected URL ${url}`) + }) + expect(exitCode).toBe(1) + expect(urls).toEqual([GLOBAL_CATALOG_MCP_URL, CATALOG_MANIFEST_URL]) + expect(err).toContain('# Error') + expect(err).toContain('MCP search_catalog returned an error: Invalid params: Tool not found: search_catalog') + expect(err).toContain(`catalog.shopify.com no longer supports UCP ${UCP_VERSION}`) + expect(err).toContain(UPDATE_COMMAND) + }) + + it('falls back to a generic update hint when the manifest cannot be read', async () => { + const { err, exitCode } = await runCli(search, (url) => { + if (url === GLOBAL_CATALOG_MCP_URL) { + return mcpError({ code: -32602, message: 'Invalid params', data: 'Tool not found: search_catalog' }) + } + throw new Error(`Unexpected URL ${url}`) + }) + expect(exitCode).toBe(1) + expect(err).toContain('This usually means catalog.shopify.com no longer accepts') + expect(err).toContain(UPDATE_COMMAND) + }) + + it('says it is not a version mismatch when the manifest still lists our release', async () => { + const { err } = await runCli(search, (url) => { + if (url === GLOBAL_CATALOG_MCP_URL) { + return mcpError({ code: -32602, message: 'Invalid params', data: 'Tool not found: search_catalog' }) + } + return jsonResponse(manifest(UCP_VERSION, [OLDER])) + }) + expect(err).toContain('not a version mismatch') + expect(err).not.toContain(UPDATE_COMMAND) + }) + + it('points at a custom --profile-url when one is in use and the tool is not found', async () => { + const { err } = await runCli(['--profile-url', 'https://example.com/agent.json', ...search], (url) => { + if (url === GLOBAL_CATALOG_MCP_URL) { + return mcpError({ code: -32602, message: 'Invalid params', data: 'Tool not found: search_catalog' }) + } + return jsonResponse(manifest(UCP_VERSION, [OLDER])) + }) + expect(err).toContain('not a version mismatch') + expect(err).toContain('A custom --profile-url is in use (https://example.com/agent.json)') + }) + + it('surfaces the server message for unrelated MCP errors without fetching the manifest', async () => { + const { err, urls, exitCode } = await runCli(search, () => mcpError({ code: -32602, message: 'bad input' })) + expect(exitCode).toBe(1) + expect(urls).toEqual([GLOBAL_CATALOG_MCP_URL]) + expect(err).toContain('MCP search_catalog returned an error: bad input') + expect(err).not.toContain(UPDATE_COMMAND) + }) +}) + +describe('MCP error helpers', () => { + it('recognizes tool-not-found envelopes by text or JSON-RPC method-not-found code', () => { + expect(isUnknownToolError({ code: -32602, message: 'Invalid params', data: 'Tool not found: search_catalog' })).toBe(true) + expect(isUnknownToolError({ code: -32601, message: 'Method not found' })).toBe(true) + expect(isUnknownToolError({ code: -32602, message: 'Unknown tool: create_checkout' })).toBe(true) + expect(isUnknownToolError({ code: -32602, message: 'bad input' })).toBe(false) + expect(isUnknownToolError('Tool not found')).toBe(false) + }) + + it('joins message and data when describing an error', () => { + expect(describeMcpError({ message: 'Invalid params', data: 'Tool not found: x' })).toBe('Invalid params: Tool not found: x') + expect(describeMcpError({ message: 'Invalid params', data: { message: 'nested' } })).toBe('Invalid params: nested') + expect(describeMcpError({ code: 1 })).toBe('') + expect(describeMcpError('plain')).toBe('plain') + }) + + it('builds a negotiated-version notice only when the release differs', () => { + const older = negotiatedVersionNotice({ result: { structuredContent: { ucp: { version: OLDER } } } }, 'search_catalog', 'h') + expect(older?.key).toBe(`h|${OLDER}`) + expect(older?.message).toContain(`older than the ${UCP_VERSION}`) + const newer = negotiatedVersionNotice({ result: { structuredContent: { ucp: { version: NEWER } } } }, 'search_catalog', 'h') + expect(newer?.message).toContain(UPDATE_HINT) + expect(negotiatedVersionNotice({ result: { structuredContent: { ucp: { version: UCP_VERSION } } } }, 'x', 'h')).toBeUndefined() + expect(negotiatedVersionNotice({ result: { structuredContent: { products: [] } } }, 'x', 'h')).toBeUndefined() + }) +}) 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]) + }) +}) diff --git a/tests/version.test.ts b/tests/version.test.ts index c1bf2e3..eca5f53 100644 --- a/tests/version.test.ts +++ b/tests/version.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs' import { describe, it } from 'node:test' import { createProgram } from '../src/cli.js' -import { USER_AGENT } from '../src/constants.js' +import { UCP_VERSION, USER_AGENT } from '../src/constants.js' import { expect } from './harness.js' const packageJson = JSON.parse( @@ -15,8 +15,8 @@ describe('version metadata', () => { expect(packageJson.version).toMatch(/^\d+\.\d+\.\d+/) }) - it('uses the package version for the CLI', () => { - expect(createProgram().version()).toBe(packageJson.version) + it('reports the package version and the pinned UCP release for --version', () => { + expect(createProgram().version()).toBe(`${packageJson.version} (UCP ${UCP_VERSION})`) }) it('uses the package version for the User-Agent', () => {