Skip to content
Merged
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
18 changes: 18 additions & 0 deletions .changeset/custom-request-headers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
"@shopify/ucp-cli": minor
---

Custom HTTP headers on UCP requests, with a built-in User-Agent default.

Adds a four-source resolver merged into a single header bag per dispatch:

1. CLI built-in: `User-Agent: @shopify/ucp-cli/<version>` (lowest priority — identifies CLI traffic in merchant logs / WAFs).
2. `~/.ucp/profiles/<name>/headers.json` `default` block — apply to every request.
3. `~/.ucp/profiles/<name>/headers.json` `businesses[<origin>]` block — per-origin add/override.
4. `--header 'Name: Value'` (repeatable) — per-call (highest priority).

Higher source wins on header-name conflict (case-insensitive); non-conflicting headers from every source ship. Empty values unset for that scope. `${ENV_VAR}` interpolation in config values keeps secrets out of the file. Reserved framing headers (`Content-Type`, `Accept`, `Host`, `Connection`, hop-by-hop, `MCP-Protocol-Version`) are silently dropped from user sources. Sensitive header values (`Authorization`, `Cookie`, and any name ending in `-Token`, `-Key`, `-Secret`, `-Password`) are redacted in verbose traces.

One generic mechanism, no per-feature aliases. Bearer auth is just `--header 'Authorization: Bearer <token>'` — the same shape works for any merchant's chosen scheme without growing the CLI flag surface per auth pattern.

Outbound requests now includes `User-Agent` on every fetch: `tools/call`, `tools/list`, ..., discovery.
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,46 @@ ucp cart update <id> --business https://<seller-domain> \

Builds and validates the request, prints the exact payload that would hit the wire (including auto-injected `meta.idempotency-key` and `meta.ucp-agent`), skips the network call. Cart and checkout updates are full-replace: carry forward request-shaped line items, using `line_items[].id` only for existing lines and `line_items[].item.id` for the underlying item/variant. Useful for debugging payloads or confirming a mutation before issuing it.

### Custom request headers (auth, tenancy, tracing)

UCP requests attach a built-in `User-Agent: @shopify/ucp-cli/<version>`. Override or extend with two user sources, merged with the built-in by priority (lowest to highest):

0. CLI built-in `User-Agent`
1. `~/.ucp/profiles/<name>/headers.json` `default` block — apply to every request
2. `~/.ucp/profiles/<name>/headers.json` `businesses[<origin>]` block — per-origin add/override
3. `--header 'Name: Value'` (repeatable) — per-call

Higher source wins per header name (case-insensitive); non-conflicting headers from every source ship. Empty values unset for that scope. Framing headers the dispatcher owns (`Content-Type`, `Accept`, `Host`, `Connection`, hop-by-hop, `MCP-Protocol-Version`) are silently dropped from all user sources. Sensitive header values (`Authorization`, `Cookie`, and any name ending in `-Token`, `-Key`, `-Secret`, `-Password`) are redacted in verbose traces (`UCP_VERBOSE=1`).

Persistent setup, modeled on git's `[http]` / `[http "<URL>"]`:

```json
{
"default": {
"Trace-Id": "my-agent-${HOSTNAME}"
},
"businesses": {
"https://shop.example.com": {
"Authorization": "Bearer ${EXAMPLE_TOKEN}"
}
}
}
```

Values support `${ENV_VAR}` interpolation so the file stays free of secrets.

Per-call usage:

```sh
# Bearer auth (covers the most common case in one keystroke)
ucp catalog search --header "Authorization: Bearer $TOKEN" --set /query='surf boards'

# Multiple headers (repeat the flag)
ucp catalog search --header "Authorization: Bearer $TOKEN" --header 'Trace-Id: req-abc'
```

There is no `--auth-bearer` flag and no `UCP_AUTH_BEARER` env var. `--header` is the only knob; bearer auth is just one shape it can carry. This keeps the CLI surface from growing every time a merchant picks a different auth header.

### Environment variables

| Variable | Effect |
Expand Down
4 changes: 4 additions & 0 deletions skills/ucp/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,10 @@ export UCP_ON_ESCALATION='jq -r .url | tee >(xargs open) | xargs -I{} osascript

The agent's job, beyond configuration, is to surface the escalation to the buyer with context: what was being done, why it stopped here, and what the buyer needs to do next. The hook handles delivery; the agent handles framing.

### Custom request headers

Pass `--header 'Name: Value'` (repeatable) on any op when a merchant requires a custom HTTP header — e.g. `--header "Authorization: Bearer $TOKEN"` or `--header "Api-Key: $KEY"`.

### Agent-initiated escalation ("this is as far as I got you")

When the CLI returns a blocking error — auth the CLI cannot perform, an unrecoverable operation error, or a merchant without the needed operation — stop retrying that blocked operation and hand the buyer off. Use the most specific buyer URL you already have; never invent one. A checkout auth/permission gate does **not** invalidate earlier unauthenticated work: preserve cart ids, selected variants, cart-stage shipping estimates, discounts, and totals you already obtained.
Expand Down
39 changes: 38 additions & 1 deletion src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ describe('createUcpCli', () => {
expect(JSON.parse(output)).toMatchObject({
result: { business: 'https://shop.example.com', negotiated: {} },
})
expect(calls).toEqual([['https://shop.example.com', { force: false, profileUrl: PROFILE_URL }]])
expect(calls).toMatchObject([
['https://shop.example.com', { force: false, profileUrl: PROFILE_URL }],
])
})

it('wires catalog search args and options to the search helper', async () => {
Expand Down Expand Up @@ -350,6 +352,41 @@ describe('createUcpCli — business resolution', () => {
])
})

it('discover: forwards --header onto the discover call so auth-gated /.well-known/ucp works', async () => {
// Regression: top-level `ucp discover` is the natural first command an
// agent runs to probe a merchant. If discovery itself is auth-gated
// (some merchants require auth even on /.well-known/ucp or tools/list),
// --header must reach discoverImpl. Mirrors the operation-command path.
const calls: unknown[] = []
const cli = createUcpCli({
resolveSession: stubSession('https://session.example.com'),
discover: async (...args) => {
calls.push(args)
return { business: args[0], profile: {}, negotiated: {} } as never
},
})
const { exitCode } = await serveCli(cli, [
'discover',
'--business',
'https://merchant.example.com',
'--header',
'Authorization: Bearer probe-token',
'--header',
'Tenant-Id: acme',
])
expect(exitCode).toBe(0)
const firstCall = calls[0] as [string, { headers?: Record<string, string> }]
expect(firstCall[0]).toBe('https://merchant.example.com')
expect(firstCall[1]?.headers).toMatchObject({
Authorization: 'Bearer probe-token',
'Tenant-Id': 'acme',
})
// Built-in User-Agent ships at lowest priority on every dispatch, including
// discover. Asserting it here guards against future refactors that
// accidentally drop the built-in identification layer.
expect(firstCall[1]?.headers?.['User-Agent']).toMatch(/^@shopify\/ucp-cli\//)
})

it('discover: emits BUSINESS_NOT_RESOLVED with CTA when nothing resolves', async () => {
const cli = createUcpCli({
resolveSession: stubSession(undefined),
Expand Down
46 changes: 46 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
resolveEscalationHook,
runEscalationHook,
} from './core/escalation.js'
import { canonicalizeOrigin, type HeaderMap, resolveHeaders } from './core/headers.js'
import { isDryRunPreview } from './core/operation.js'
import { DEFAULT_AGENT_CAPABILITY_IDS } from './core/profile.js'
import { acceptsHttpsUrl, parseHttpsUrl } from './core/url.js'
Expand Down Expand Up @@ -78,6 +79,8 @@ export type ShoppingHelperDep = (
force: boolean
profileUrl: string
dryRun?: boolean
/** Resolved outbound HTTP headers; see {@link resolveHeaders}. */
headers?: Record<string, string>
/** Internal-only side-channel; see CallOperationCallerOptions._onDiscover. */
_onDiscover?: (discovered: DiscoveredBusiness) => void
},
Expand Down Expand Up @@ -232,6 +235,12 @@ export function createUcpCli(deps: UcpCliDependencies = {}) {
.boolean()
.default(false)
.describe('Bypass the local profile cache and re-fetch from the business.'),
header: z
.array(z.string())
.default([])
.describe(
'Add an outbound HTTP header on the discovery + tools/list requests this command makes. Repeatable. Format: --header "Name: Value". Same semantics as on operation commands; required when a merchant gates `/.well-known/ucp` or tools/list behind auth. For bearer auth: --header "Authorization: Bearer $TOKEN".',
),
view: z
.string()
.optional()
Expand Down Expand Up @@ -275,9 +284,11 @@ export function createUcpCli(deps: UcpCliDependencies = {}) {
// fired had `meta.defaults.catalog` been set, so when it didn't, the init
// CTA is the recovery path.
if (businessUrl === undefined) return c.error(businessNotResolvedError())
const headers = await resolveCallHeaders(c.options, session, businessUrl)
const discoverResult = await discoverImpl(businessUrl, {
force: c.options.refresh,
profileUrl: requireProfileUrl(session.profile.profileUrl),
headers,
})
return c.ok(applyView({ result: discoverResult }, viewState))
},
Expand Down Expand Up @@ -343,6 +354,12 @@ export function createUcpCli(deps: UcpCliDependencies = {}) {
.describe(
'Shell command to invoke when a checkout response returns result.status === "requires_escalation". Receives a compact escalation payload as JSON on stdin. Auth errors use CTA handoff guidance and do not fire this hook. Overrides UCP_ON_ESCALATION / config.yaml / hooks file. No-op in --mcp mode.',
),
header: z
.array(z.string())
.default([])
.describe(
'Add an outbound HTTP header on UCP requests. Repeatable. Format: --header "Name: Value". Overrides headers.json (default + per-business). Reserved framing headers (Content-Type, Accept, Host, etc.) are dropped silently. Values may not contain CR/LF. For bearer auth: --header "Authorization: Bearer $TOKEN".',
),
view: z
.string()
.optional()
Expand Down Expand Up @@ -400,6 +417,11 @@ export function createUcpCli(deps: UcpCliDependencies = {}) {
if (!prep.ok) return c.error(prep.error)
const wrapped = wrapOperationInput(prep.input, bodyKey)
const merged = mergeId(wrapped, c.args.id, idPlacement)
const headers = await resolveCallHeaders(
c.options,
{ profile: { name: prep.profileName } },
prep.business,
)
// Capture the trusted negotiated view via the internal side-channel.
// Filled by `callOperation` after `discover()` resolves (BEFORE any
// OPERATION_NOT_OFFERED throw), so CTAs on transport-layer failures
Expand All @@ -410,6 +432,7 @@ export function createUcpCli(deps: UcpCliDependencies = {}) {
const result = await helper(prep.business, merged, {
force: prep.force,
profileUrl: prep.profileUrl,
headers,
...(c.options.dryRun ? { dryRun: true } : {}),
_onDiscover: (d) => {
discovered = d
Expand Down Expand Up @@ -665,6 +688,7 @@ interface OperationOptions {
inputSchema: boolean
dryRun: boolean
onEscalation?: string | undefined
header: string[]
view?: string | undefined
}

Expand Down Expand Up @@ -702,6 +726,8 @@ type ShoppingHelper = {
force: boolean
profileUrl: string
dryRun?: boolean
/** Resolved outbound HTTP headers; see {@link resolveHeaders}. */
headers?: Record<string, string>
/** Internal-only side-channel; see CallOperationCallerOptions._onDiscover. */
_onDiscover?: (discovered: DiscoveredBusiness) => void
},
Expand Down Expand Up @@ -766,6 +792,7 @@ type PreparedOperation =
ok: true
input: Record<string, unknown>
business: string
profileName: string
profileUrl: string
force: boolean
}
Expand Down Expand Up @@ -820,11 +847,28 @@ async function prepareOperation(
ok: true,
input,
business,
profileName: session.profile.name,
profileUrl: requireProfileUrl(session.profile.profileUrl),
force: c.options.refresh,
}
}

// `?? businessUrl` is defensive only: every caller has already routed the URL
// through session resolution / parseHttpsUrl, so canonicalizeOrigin should not
// fail here. Failing closed (throwing) would break dispatch on a non-bug; the
// raw string is a safe last-resort origin key against headers.json.
async function resolveCallHeaders(
options: { header: string[] },
session: { profile: { name: string } },
businessUrl: string,
): Promise<HeaderMap> {
return resolveHeaders({
argFlags: options.header,
origin: canonicalizeOrigin(businessUrl) ?? businessUrl,
profile: session.profile.name,
})
}

// Implements `--input-schema`: short-circuit before dispatch and return the
// upstream tool's `inputSchema` so agents can compose payloads without a
// trial-and-error round through schema validation. Discovery is lazy — if
Expand Down Expand Up @@ -873,10 +917,12 @@ async function inputSchemaOperation(
}

const profileUrl = requireProfileUrl(session.profile.profileUrl)
const headers = await resolveCallHeaders(c.options, session, businessUrl)
const resolved = await discoverImpl(businessUrl, {
capabilities: [helper.capability],
profileUrl,
force: c.options.refresh,
headers,
})
const negotiated = resolved.negotiated[helper.capability]
const tool = negotiated?.tools[helper.toolName]
Expand Down
12 changes: 11 additions & 1 deletion src/cli/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import { access, constants, mkdir } from 'node:fs/promises'
import { join } from 'node:path'

import { ucpFetch } from '../core/http-client.js'
import {
activeYamlPath,
type ProfileStoreOptions,
Expand Down Expand Up @@ -172,7 +173,16 @@ async function checkProfileUrl(
const ac = new AbortController()
const timer = setTimeout(() => ac.abort(), HEAD_TIMEOUT_MS)
try {
const res = await fetchImpl(url, { method: 'HEAD', signal: ac.signal })
// Route through ucpFetch so the agent-profile probe carries the same
// built-in User-Agent that every other outbound request sends. No
// merchant-scoped --header overrides apply here — this URL belongs to
// the agent's own identity, not a merchant.
const res = await ucpFetch(url, {
method: 'HEAD',
signal: ac.signal,
fetch: fetchImpl,
traceLabel: 'doctor',
})
if (res.ok) {
return { id: 'profile-url', status: 'ok', detail: `${url} → ${res.status}` }
}
Expand Down
17 changes: 14 additions & 3 deletions src/core/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { z } from 'incur'
import { type ErrorCode, UcpError } from '../lib/errors.js'
import type { ErrorLayer } from '../lib/types.js'
import { formatZodIssues } from '../lib/zod-format.js'
import { ucpFetch } from './http-client.js'
import { vlog } from './verbose.js'

/**
Expand Down Expand Up @@ -131,6 +132,14 @@ export interface FetchCachedOptions<T = unknown> {
signal?: AbortSignal
/** Per-request timeout in milliseconds. Default 30 s. */
timeoutMs?: number
/**
* Additional outbound headers (auth, tenancy, etc). Spread between the
* built-in User-Agent default and the framing `Accept` header, so a
* caller-supplied User-Agent overrides the built-in but no source can
* clobber the dispatcher's `Accept`. Reserved-header filtering is the
* caller's responsibility (see {@link resolveHeaders}).
*/
headers?: Record<string, string>
}

/**
Expand All @@ -145,7 +154,6 @@ export async function fetchCached<T = unknown>(
url: string,
options: FetchCachedOptions<T>,
): Promise<T> {
const fetchImpl = options.fetch ?? fetch
const layer: ErrorLayer = options.errorLayer ?? 'transport'
const cachePath = join(options.cacheDir, `${originToFilename(url)}.json`)

Expand All @@ -167,9 +175,12 @@ export async function fetchCached<T = unknown>(

let response: Response
try {
response = await fetchImpl(url, {
headers: { Accept: 'application/json' },
response = await ucpFetch(url, {
...(options.headers !== undefined && { headers: options.headers }),
framing: { Accept: 'application/json' },
signal,
...(options.fetch !== undefined && { fetch: options.fetch }),
traceLabel: 'cache',
})
} catch (err) {
throw new UcpError({
Expand Down
17 changes: 16 additions & 1 deletion src/core/discover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,13 @@ export interface DiscoverOptions {
signal?: AbortSignal
/** Platform profile URL advertised to the business during MCP discovery. */
profileUrl?: string
/**
* Outbound headers (auth, tenancy, etc) attached to every HTTP call made
* during discovery: the `/.well-known/ucp` GET and any `tools/list` POSTs.
* Some merchants require auth even on discovery, so the same resolved bag
* that flows to `tools/call` flows here too.
*/
headers?: Record<string, string>
/** Injectable for tests (forwarded to `fetchBusinessProfile` and `mcpRpc`). */
fetch?: typeof fetch
}
Expand Down Expand Up @@ -115,7 +122,12 @@ export async function discover(

const profile = await fetchBusinessProfile(normalizedBusiness.origin, {
cacheDir: profileCacheDir,
...omitUndefined({ fetch: options.fetch, signal: options.signal, force: options.force }),
...omitUndefined({
fetch: options.fetch,
signal: options.signal,
force: options.force,
headers: options.headers,
}),
})

const services = profile.ucp.services as Record<string, unknown> | undefined
Expand Down Expand Up @@ -157,6 +169,7 @@ export async function discover(
force: options.force,
fetch: options.fetch,
signal: options.signal,
headers: options.headers,
}),
})
const tools = Object.keys(negotiated[capability].tools).sort()
Expand All @@ -177,6 +190,7 @@ interface HydrateOptions {
fetch?: typeof fetch
signal?: AbortSignal
profileUrl?: string
headers?: Record<string, string>
}

async function hydrateCapability(opts: HydrateOptions): Promise<NegotiatedCapability> {
Expand All @@ -194,6 +208,7 @@ async function hydrateCapability(opts: HydrateOptions): Promise<NegotiatedCapabi
params: opts.profileUrl !== undefined ? profileParams(opts.profileUrl) : undefined,
fetch: opts.fetch,
signal: opts.signal,
headers: opts.headers,
}),
}),
})
Expand Down
Loading
Loading