portage-ucp
Protocol-only core gem: expose a commerce backend to AI shopping agents over MCP
(Model Context Protocol) and UCP
(Universal Commerce Protocol) at once. Zero commerce-backend
dependencies — works with any backend that implements Adapter, Shopify or
otherwise. Adapter gems (portage-ucp-shopify, portage-ucp-wix, ...) are
consumers of this gem, not dependencies of it.
See the root README for the full walkthrough (an agent discovering a manifest and buying a snowboard end to end), security model, and adapter comparison table. This README covers just what lives in this gem.
What it ships
| Class | Role |
|---|---|
Portage::Ucp::Adapter |
The contract your backend implements — override only the catalog/cart/checkout/order/identity methods you support; the rest stay unadvertised. |
Portage::Ucp::CapabilityRegistry |
Figures out which capabilities an Adapter actually backs. |
Portage::Ucp::Dispatcher |
Routes a capability+action call to the right Adapter method. |
Portage::Ucp::Mcp::Server |
Wraps an Adapter as an MCP server — one MCP::Tool per advertised action; serve it over stdio or Streamable HTTP with the mcp gem's transports. |
Portage::Ucp::Manifest |
Builds the signed /.well-known/ucp discovery document. |
Portage::Ucp::Rack::ManifestEndpoint |
Serves that manifest over Rack. |
Portage::Ucp::Rack::WebhookEndpoint |
HMAC-verified inbound order-lifecycle webhooks. |
Portage::Ucp::Security::Signature / Portage::Ucp::Rack::SignatureVerification |
Verifies RFC 9421 HTTP Message Signatures on inbound requests per UCP's signature spec — cryptographic proof the call carries a signed AP2/UCP authorization, not just an authenticated caller. Wrap your mounted MCP/UCP endpoint with the Rack middleware; verification runs before the body is parsed. |
Portage::Ucp::SchemaValidator |
Validates data against UCP's own vendored JSON Schemas/OpenRPC docs, offline. |
Portage::Ucp::Resolver / exe/portage-ucp-check |
Probes any store's homepage/.well-known/ucp and recommends the matching adapter gem. |
Portage::Ucp::Support::TransactionLog |
Durable pre/post-dispatch record of every complete_checkout call — reserved before dispatch, marked settled/failed after, so a crash mid-charge is diagnosable rather than silently lost. |
Portage::Ucp::Support::OrderLedger |
Durable snapshot written after settlement, alongside (not instead of) the transaction record — a failed snapshot write surfaces without flipping an already-settled charge to failed. |
Portage::Ucp::Confirmer |
Gate run just before complete_checkout dispatch, after PolicyGuard. Confirmer::Terminal blocks on stdin and fails closed on anything but an explicit "y"; Confirmer::AutoApprove is for specs/conformance kits that need a real confirm! without blocking; Confirmer::Webhook is an out-of-band transport (POST + poll a status URL, or a caller-supplied wait: callback) for a Slack/WhatsApp/etc. approval flow — same fail-closed-on-timeout contract, its own longer default timeout. |
Portage::Ucp::PolicyGuard / Portage::Ucp::Policy |
Per-transaction/rolling/velocity caps and a merchant allowlist, checked before complete_checkout dispatch; configured via portage-cli's portage policy show/set. |
Portage::Ucp::Support::OfferRanking / Portage::Ucp::Support::Escalation |
The offer-ranking rule (buyable, then priced, then cheapest, ties stable) and the escalation rule (requires_escalation, then a mismatch). portage-ucp-decision wraps both as typed verdicts; portage-cli calls them directly. |
Portage::Ucp::PaymentEnrollmentGuard |
Validates every create_payment_enrollment/get_payment_enrollment result an Adapter returns — status must be "pending" (with a setup_url, no payment_token) or "complete" (with a payment_token, no setup_url). Runs automatically in Dispatcher#call. |
Portage::Ucp::Ap2::PaymentMandate / Portage::Ucp::Ap2::MandateGuard |
A typed shape for an AP2 payment mandate, and validation (required fields + expiry, plus signature verification when mandate_trusted_keys is configured) run automatically on any mandate: argument passed through Dispatcher#call. |
Security defaults are all locked down, not permissive-by-omission —
UnconfiguredAuthenticator rejects every mutating call until you configure a real
one, PaymentTokenGuard rejects raw card numbers before they reach your Adapter,
PaymentEnrollmentGuard/Ap2::MandateGuard reject malformed enrollments/mandates
before they cross the same boundary, and manifest signing is opt-in. Full detail in
the root README's
Security hooks
section.
Installation
# Gemfile
gem "portage-ucp"
bundle install
Usage
require "portage/ucp"
class MyAdapter < Portage::Ucp::Adapter
def search_catalog(query:, limit:) = ...
def get_product(product_id:) = ...
def create_cart(line_items:, idempotency_key:) = ...
# override only the capabilities you support
end
Portage::Ucp.configure do |config|
config.authenticator = MyAuthenticator.new
config.rate_limiter = MyRateLimiter.new
config.business = { name: "Your Store", url: "https://your-shop.example" }
end
server = Portage::Ucp::Mcp::Server.build(adapter: MyAdapter.new)
MCP::Server::Transports::StdioTransport.new(server).open
See the root README's Usage and the detailed walkthrough for the full agent-side conversation, manifest/webhook Rack mounting, and a real adapter to model your own against.
Swapping the store
Support::TransactionLog and Support::OrderLedger each accept a store:
(Dispatcher.new(transaction_log:, order_ledger:) is the injection point).
FileStore — whole-file flock + JSON, chmod 0600 — is the shipped
default for both; nothing else ships today. Write your own subclass of
Support::TransactionLog::Store / Support::OrderLedger::Store for a real
database, Redis, or an in-memory double for tests — same posture as
portage-ucp-journal's Store/FileStore seam, no bundled second backend
(see that gem's README). path:/clock: still work as a shorthand that
builds a FileStore under the hood, so existing callers are unaffected.
Checking any store
bundle exec portage-ucp-check your-shop.example
Tries /.well-known/ucp first; falls back to platform detection and names the
matching portage-ucp-<adapter> gem, live-probing it if credentials are already in
env. See the root README's Checking any store
section for sample output.
Development
bundle exec rspec
bundle exec rubocop
See the design log for the design rationale and decision history behind this project.
License
MIT — Copyright (c) 2026 Tom Whitbread.