Skip to main content
Glama

CajaGuard

A local spend gate for shopping agents. CajaGuard is an MCP proxy that sits in front of a commerce MCP server speaking the Universal Commerce Protocol (UCP) shopping tools, the same tool names Shopify documents for its Cart, Checkout and Global Catalog MCP servers. Every tool passes through, except complete_checkout. That one is checked against per-currency spend caps, a rolling window, a merchant allowlist and, above a threshold, a signed, single-use, expiring human approval. In test mode it also refuses anything that looks like a real card number or a live token before the shop sees it.

CI License

The problem

Shopping MCP servers can now create checkouts and place orders, so an agent that can browse can also pay. Teams wiring this up need proof, before any live payment credential is in play, that a human signs off above a limit, that splitting a basket does not get around it, and that a test setup never carries a real card. CajaGuard is a small, local, keyless way to get that proof against a fixture shop with the real tool shapes.

Related MCP server: acuris-agent-guard

What the real tools look like (verified 2026-10-21)

Source

What it says

URL

UCP spec, tag v2026-08-25 (commit cd78fb3, Apache-2.0)

MCP methods create_checkout(meta, checkout), get_checkout(meta, id), update_checkout(meta, id, checkout), complete_checkout(meta, id, checkout), cancel_checkout, create_cart/get_cart/update_cart/cancel_cart, search_catalog, lookup_catalog, get_product, get_order. complete_checkout requires meta["ucp-agent"] and meta["idempotency-key"].

mcp.openrpc.json (vendored in spec/ucp/)

UCP checkout.json

currency, totals, status and item prices are business-owned (ucp_request: "omit"); payment is required on complete. Status ready_for_complete before completion.

checkout.json (vendored)

UCP amount.json

Amounts are integers in ISO 4217 minor units (exponent 2 for USD, 0 for JPY, 3 for KWD).

amount.json

UCP credentials

payment.instruments[] items have id, handler_id, type, credential; credential types include token ({type, token}), pan and network_token. UCP's checkout docs show Shop Pay as handler com.shopify.shop_pay with instrument type shop_pay.

token_credential.json, checkout REST doc

Shopify Checkout MCP

POST https://{shop-domain}/api/ucp/mcp, Bearer token auth, tools create_checkout, get_checkout, update_checkout, complete_checkout, cancel_checkout. On complete_checkout: "Use this tool when the checkout is ready and the buyer has authorized payment."

shopify.dev Checkout MCP

Shopify Cart MCP

Same endpoint, tools create_cart, get_cart, update_cart, cancel_cart.

shopify.dev Cart MCP

Shopify Global Catalog MCP

https://catalog.shopify.com/api/ucp/mcp, tools search_catalog, lookup_catalog, get_product. Read-only; returns checkout links.

shopify.dev Catalog MCP

Shopify Storefront MCP

The older cart and catalog tools on https://{shop}/api/mcp were removed in favour of /api/ucp/mcp.

shopify.dev Storefront MCP

So complete_checkout is the one call that uses a payment credential and places an order. That is what CajaGuard gates.

How it works

flowchart LR
  agent[Agent] -->|UCP tools| cg[cajaguard mcp]
  cg -->|search / cart / create / update checkout| shop[commerce MCP server<br/>fixture in this repo]
  cg -->|complete_checkout| scan{test mode:<br/>real-looking credential?}
  scan -->|yes| deny[denied]
  scan -->|no| read[get_checkout from the shop<br/>business-owned total + currency]
  read --> rules{units, merchant, currency,<br/>per-transaction cap, rolling window}
  rules -->|fail| deny
  rules -->|over threshold or live mode| sig{valid signed approval<br/>for this exact payment?}
  rules -->|under threshold| shop
  sig -->|yes, once| shop
  sig -->|no| blocked[blocked + request id]
  human[Human] -->|cajaguard approve id| file[(Ed25519 approval)]
  file --> sig
  1. Read, don't trust. On complete_checkout, CajaGuard calls the shop's own get_checkout and takes the total, currency, status and merchant from it. If the agent sends totals, currency or line_items on complete, the call is refused, because UCP marks those fields omit.

  2. Units. Every amount must be a safe non-negative integer in minor units. A shop that reports 49.99 instead of 4999 is refused (fail closed). Caps are configured as strings in major units ("100.00") and converted with the ISO 4217 exponent; "50.005" USD is a config error. A currency without caps is refused and never converted.

  3. Caps. A per-transaction cap and a rolling-window cap per currency are hard limits that no approval can lift. Completed spend, including approved spend, counts toward the window, so four USD 45.00 checkouts cannot get under a USD 50.00 threshold and past a USD 150.00 window.

  4. Merchant allowlist. The merchant is the host of the shop-authored continue_url (else the first links[] URL).

  5. Approval. Above approval_above (or always, in mode: "live"), the gate writes a pending request whose id comes from a SHA-256 digest of merchant, checkout id, currency, total, line items and payment handlers. A human runs cajaguard approve <id>. The CLI re-reads the checkout from the shop, refuses if it changed, shows it, asks y/N and writes an Ed25519-signed approval with an expiry. The gate accepts it once: the approval is spent before the payment call, so a decline cannot be replayed.

  6. Test-mode tripwire. In mode: "test", every tool call's arguments are scanned. Instruments must use an allowlisted test handler, tokens must match the test pattern, raw pan/network_token/card credentials are refused, Luhn-valid card numbers anywhere (including free text) are refused, and secret-like fields with a live segment are refused. None of it reaches the shop.

Quickstart (keyless demo)

Needs Bun 1.4.3 (Node 24 in .nvmrc). Everything runs against the fixture shop in fixtures/, whose merchants use the reserved .example domain.

bun install --frozen-lockfile
bun run cajaguard keygen                                   # approver key in ~/.config/cajaguard (0600)
bun run demo buy "Trail socks" 2                           # USD 24.00: allowed, order placed
bun run demo buy "Day pack"                                # USD 89.00: blocked, prints cg-<id> and the checkout id
bun run cajaguard approve cg-<id> --config examples/cajaguard.demo.json   # re-reads the checkout, asks y/N, signs
bun run demo pay gid://fixture/Checkout/<n>                # allow_approved, order placed
bun run demo pay gid://fixture/Checkout/<n>                # denied: checkout already completed
bun run demo buy "Rain shell" --token "4111 1111 1111 1111"   # denied: test_mode_credential
bun run cajaguard status --config examples/cajaguard.demo.json

State (requests, approvals, spend ledger, audit log, fixture checkouts) lives in .cajaguard/, which is gitignored. Run the full local gate with bun run check.

MCP server

bun run cajaguard mcp --config examples/cajaguard.demo.json starts the stdio proxy (client config in examples/mcp-servers.json). It spawns the downstream server named in the config and lists its UCP tools plus:

Tool

Effect

UCP tools (search_catalog ... get_order)

Forwarded unchanged (after the test-mode scan)

complete_checkout

Gated as above. Refusals return isError: true with { "cajaguard": { verdict, reason, detail, request_id?, next? } }

cajaguard_status

Read-only: mode, merchants, caps, window spend, pending requests

cajaguard_approval_status

Read-only: pending, approved, used, denied or invalid

Any other downstream tool is hidden and refused (tool_not_allowed). No tool can approve.

Verdicts: allow (within_caps), allow_approved (signed_approval), blocked (pending_approval, approval_expired, approval_untrusted_key, approval_bad_signature, approval_already_used, ...), denied (over_per_transaction_cap, over_window_cap, merchant_not_allowed, currency_not_allowed, amount_not_minor_units, request_field_not_allowed, checkout_not_ready, test_mode_credential, approval_denied, bad_request, tool_not_allowed).

Config

{
  "mode": "test",
  "home": "../.cajaguard",
  "downstream": { "command": "bun", "args": ["fixtures/commerce-server.ts"], "cwd": "..", "env": {} },
  "merchants": ["demo-outfitters.example", "tokyo-tea.example"],
  "caps": {
    "USD": { "approval_above": "50.00", "per_transaction": "100.00", "window": "150.00" },
    "JPY": { "approval_above": "5000", "per_transaction": "12000", "window": "20000" }
  },
  "window_hours": 24,
  "approvals": { "trusted_keys": ["~/.config/cajaguard/approver.pub.pem"], "max_ttl_minutes": 15 },
  "test_mode": { "handler_ids": ["fixture_test_handler"], "token_pattern": "^cg_test_[A-Za-z0-9_]{8,}$" }
}

mode: "live" drops the test-token rules and requires a signed approval for every complete_checkout, whatever the amount. It is tested only against the fixture shop (see Limits).

CLI

Command

Exit codes

keygen [--out DIR] [--force]

0

mcp --config FILE

stdio server

status, pending, show <id>

0 / 3

verify <id>

0 approved, 3 pending/invalid/used, 4 denied

approve <id> [--ttl 15m] [--note T], deny <id>

needs a TTY unless --yes; 3 if the checkout changed

Measured

Produced by bun run report (scripts/report.ts) from a real keyless run: real Ed25519 keys and signatures, the real file store, the fixture shop in-process for the scenarios and over stdio for the MCP timing. Source: reports/cajaguard.md, reports/cajaguard.json. "Reached shop" means the complete_checkout call was forwarded to the shop.

Generated by bun run report on 2026-10-21 (Bun 1.4.3, @modelcontextprotocol/sdk 1.32.1, UCP spec v2026-08-25). Fixture shop only, real Ed25519 keys.

Caps: USD approval above 50.00, per transaction 100.00, window 150.00 / 24h; JPY approval above 5000, per transaction 12000, window 20000 / 24h.

Scenario

Total

Expected

Verdict

Reason

Reached shop

2 x Trail socks, test token

USD 24.00

allow

allow

within_caps

yes

Down parka (over per-transaction cap)

USD 219.00

denied

denied

over_per_transaction_cap

no

Day pack, no approval yet

USD 89.00

blocked

blocked

pending_approval

no

Day pack + trusted signed approval

USD 89.00

allow_approved

allow_approved

signed_approval

yes

next basket after that approval was used

USD 60.00

blocked

blocked

pending_approval

no

approved, shop declines the test token

USD 89.00

allow_approved

allow_approved

signed_approval

yes

retry with the same approval (replay)

USD 89.00

blocked

blocked

approval_already_used

no

approval signed by an untrusted key

USD 89.00

blocked

blocked

approval_untrusted_key

no

real approval with edited expires_at

USD 89.00

blocked

blocked

approval_bad_signature

no

approval 6 min after a 5 min TTL

USD 89.00

blocked

blocked

approval_expired

no

human signed a deny

USD 89.00

denied

denied

approval_denied

no

basket grew 5 -> 8 socks after approval

USD 96.00

blocked

blocked

pending_approval

no

merchant reports 49.99 (major units)

-

denied

denied

amount_not_minor_units

no

agent sends totals: 120 for a USD 219.00 basket

-

denied

denied

request_field_not_allowed

no

JPY 9800 checkout, only USD caps

JPY 9800

denied

denied

currency_not_allowed

no

JPY 1800 checkout, JPY caps

JPY 1800

allow

allow

within_caps

yes

split purchase 1/4: Rain shell

USD 45.00

allow

allow

within_caps

yes

split purchase 2/4: Rain shell

USD 45.00

allow

allow

within_caps

yes

split purchase 3/4: Rain shell

USD 45.00

allow

allow

within_caps

yes

split purchase 4/4: Rain shell

USD 45.00

denied

denied

over_window_cap

no

merchant not on allowlist

USD 79.00

denied

denied

merchant_not_allowed

no

Shop Pay handler in test mode

-

denied

denied

test_mode_credential

no

raw PAN credential in test mode

-

denied

denied

test_mode_credential

no

live-looking token in test mode

-

denied

denied

test_mode_credential

no

live mode, USD 12.00

USD 12.00

blocked

blocked

pending_approval

no

Scenarios as expected: 25/25.

Timing

n

p50 ms

p95 ms

guard complete_checkout, allow (get_checkout + complete + ledger)

200

0.454

0.735

guard complete_checkout, blocked pending approval

200

0.423

0.726

guard complete_checkout, approval rejected (Ed25519 verify)

200

0.46

0.711

guard complete_checkout, test-mode tripwire deny

200

0.125

0.194

MCP stdio complete_checkout via proxy -> fixture (over cap), denied 20/20

20

1.694

2.926

MCP proxy spawn + initialize + tools/list (also spawns the fixture shop): 321.4 ms.

Keys and environment

No API keys. Nothing here talks to a real store, a payment provider or the network; CI runs the same keyless gate. No .env is needed (.env.example lists the one optional variable).

Variable

Required

Use

CAJAGUARD_KEY_DIR

no

Approver key directory (default ~/.config/cajaguard)

Limits

  • Fixture shop, not Shopify. Tool names and argument shapes follow the pinned UCP spec and Shopify's docs, and tests check the fixture's required params against mcp.openrpc.json. The fixture's catalog responses are simplified, and its totals only have subtotal and total (no tax, shipping or discounts). Nothing was run against a real Shopify store, Checkout MCP's Bearer-token auth, or a real payment handler.

  • Shop Pay tokens are not recognized as such. The idea talks about "live Shop Pay tokens". We could not verify a Shop Pay credential or token format in the UCP spec or Shopify's docs. CajaGuard blocks them indirectly: in test mode only allowlisted test handlers and cg_test_... tokens pass, so a shop_pay instrument is refused by handler id. In live mode every payment needs a human signature.

  • The tripwire is pattern based. Luhn card numbers, live segments in secret-like fields, raw card credential types and non-matching tokens. A real token with no recognizable shape, sent with an allowlisted test handler id, would pass the scan if it matched your token pattern. Keep the pattern strict.

  • The approver key and .cajaguard/ are the trust root. An agent that can read the key, or write the config, spend ledger or consumed.jsonl, can get around the gate. See SECURITY.md.

  • Only calls through the proxy are gated. An agent with the shop endpoint, a browser or a stored card has other ways to pay.

  • The shop's numbers are trusted. Price and currency come from get_checkout. If a shop charges more than it reported, CajaGuard records the higher total and logs a mismatch after the fact; it cannot undo a charge.

  • Window is per currency, across merchants, local to one machine. There is no FX conversion and no shared ledger across agents or hosts.

  • Approval is a terminal prompt. No phone or Slack approval flow, and MCP elicitation is not used since a client could answer it without a human. --yes exists for scripted demos.

  • Default caps are examples, not a recommended policy.

Credits

Not affiliated with Shopify, the Universal Commerce Protocol project, Helio, Anthropic or the Model Context Protocol project.

License

Apache-2.0. See LICENSE and NOTICE. Contact: hello@skilleddragon.com.

Built by SkilledDragon LLC, skilleddragon.com

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    C
    maintenance
    A proxy server that sits between MCP clients and servers, providing authentication, tool discovery, caching, and guardrail enforcement to protect MCP servers from malicious inputs.
    30 PyPI
    58
    Apache 2.0
  • A
    license
    Not graded
    quality
    C
    maintenance
    MCP server that verifies storefront merchants before AI agents make payments, checking if the merchant is a real legal entity bound to the domain, and returning a PROCEED, ABORT, or REVIEW decision to prevent payment to clones or fraudulent stores.
    MIT
  • A
    license
    A
    quality
    A
    maintenance
    An MCP proxy that enforces policy on every tool call, blocking or flagging actions before they reach downstream MCP servers.
    1
    27 npm
    MIT
  • A
    license
    Not graded
    quality
    B
    maintenance
    Enables AI agents and MCP clients to safely access upstream MCP servers through centralized policy enforcement, including allow/deny/approval decisions, schema pinning, circuit breakers, and audit logging.
    MIT