portage-ucp-shopify

Shopify adapter for portage-ucp. Implements Portage::Ucp::Adapter against Shopify's Admin (catalog, order) and Storefront (cart, checkout) GraphQL APIs. Generic only — no merchant-specific business logic. Plain Net::HTTP, no shopify_api runtime dependency.

What it covers

UCP capability Backing Shopify API Notes
dev.ucp.shopping.catalog Admin search_catalog, get_product
dev.ucp.shopping.cart Storefront (Cart) get_cart, create_cart, update_cart, cancel_cart
dev.ucp.shopping.checkout Storefront (same Cart object) create_checkout, get_checkout, update_checkout, complete_checkout, cancel_checkout
dev.ucp.shopping.order Admin get_order
dev.ucp.shopping.identity not implemented; Shopify's OAuth identity story lives in the separate Customer Account API, out of scope here

Shopify has no separate "Checkout" object — Storefront's Cart is the checkout. The adapter tracks checkout status (incomplete / completed / canceled / complete_in_progress) itself, keyed by cart id, and resolves Order#checkout_id after completion via a cart_token: order search.

Update/replace operations (update_cart, update_checkout) are full-replacement: Storefront has no atomic "replace all lines" mutation, so the adapter removes every current line then re-adds the desired ones. Mutating methods dedup by idempotency_key in-process so a dropped-connection retry can't double-charge.

Installation

# Gemfile
gem "portage-ucp-shopify"
bundle install

Setup

You need a shop domain plus an Admin API access token (for catalog/order) and a Storefront API access token (for cart/checkout). Either capability can be used alone if you only pass the token it needs.

require "portage/ucp/shopify"

client = Portage::Ucp::Shopify::Client.new(
  shop_domain: "your-shop.myshopify.com",
  admin_access_token: ENV.fetch("SHOPIFY_ADMIN_ACCESS_TOKEN"),
  storefront_access_token: ENV.fetch("SHOPIFY_STOREFRONT_ACCESS_TOKEN")
)

adapter = Portage::Ucp::Shopify::Adapter.new(client: client)

Fetching an Admin token from a custom app's client credentials

Shopify custom apps no longer expose a static, copy-once admin token — only a client_id/client_secret. Exchange those for a real access token via OAuth's client_credentials grant:

fetcher = Portage::Ucp::Shopify::AccessTokenFetcher.new(
  shop_domain: "your-shop.myshopify.com",
  client_id: ENV.fetch("SHOPIFY_CLIENT_ID"),
  client_secret: ENV.fetch("SHOPIFY_CLIENT_SECRET")
)

result = fetcher.fetch
result.access_token # => admin_access_token to pass into Client.new
result.expires_in    # => seconds until it needs refetching

Usage

# Catalog
products = adapter.search_catalog(query: "hoodie", limit: 10)
product  = adapter.get_product(product_id: products.first.id)

# Cart
cart = adapter.create_cart(
  line_items: [{ product_id: product.variants.first[:id], quantity: 2 }],
  idempotency_key: SecureRandom.uuid
)
cart = adapter.update_cart(cart_id: cart.id, line_items: [], idempotency_key: SecureRandom.uuid) # empties cart

# Checkout
checkout = adapter.create_checkout(
  line_items: [{ product_id: product.variants.first[:id], quantity: 1 }],
  idempotency_key: SecureRandom.uuid
)
checkout = adapter.complete_checkout(
  checkout_id: checkout.id,
  payment_token: single_use_token_from_payment_handler,
  idempotency_key: SecureRandom.uuid
)

# Order
order = adapter.get_order(order_id: checkout.id) # only once linked post-completion

payment_token must already be a single-use tokenized credential from a UCP payment handler (validated as non-PAN by Portage::Ucp::PaymentTokenGuard upstream) — it's passed straight into Storefront's cartPaymentUpdate.

Wiring into portage-ucp

Drop the adapter into a Dispatcher (or the MCP server) the same as any other backend:

dispatcher = Portage::Ucp::Dispatcher.new(adapter: adapter)

dispatcher.call(
  capability: "dev.ucp.shopping.cart",
  action: "create",
  arguments: { line_items: [{ product_id: variant_id, quantity: 1 }], idempotency_key: SecureRandom.uuid }
)

Because link_identity is left unoverridden, Capability#advertised_for? simply won't advertise dev.ucp.shopping.identity for this adapter — callers get an absent capability, not a 500.

Errors

Portage::Ucp::Shopify::Error        # base class
Portage::Ucp::Shopify::GraphqlError # top-level GraphQL `errors` (bad query, throttled, auth rejected)
Portage::Ucp::Shopify::UserError    # a mutation's non-empty `userErrors` (e.g. "line item not found")

Development

bundle exec rspec   # tests (WebMock-stubbed, no live store needed)
bundle exec rubocop  # lint

# fetch a real admin_access_token for a dev store, via client_credentials
SHOPIFY_SHOP_DOMAIN=your-shop.myshopify.com \
SHOPIFY_CLIENT_ID=... SHOPIFY_CLIENT_SECRET=... \
  bundle exec rake shopify_access_token

License

MIT — Copyright (c) 2026 Tom Whitbread.