portage-ucp-client 0.3.3 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f0cbf2202d4919b19b1a74189fdb31be0ead7ebe888d4bed829cef643493e694
4
- data.tar.gz: 87f4581b5e880bda64986f54dad191ab3560ef838f2229e9a97f6c084f5b3ab2
3
+ metadata.gz: 8eeeb1c006e06155791f086b9dbfce78562ecb4281412c18dbdf5794a4189c08
4
+ data.tar.gz: 6be190c6cb00f34b0d0978b522da1e912f3a0b33973b6eb813db72859869b491
5
5
  SHA512:
6
- metadata.gz: eff0ee7ebac57a7c3bb903a5a59793e39c47edf383573757ecdaa8940f2a3dc6f2fdce2a31c2a1fc0d264eafc1455ff9a0ced8e0d16bd5ddf5fbab139258a36b
7
- data.tar.gz: 2293b310ae2d4185f92028b26b28b9449fc561fb5563788d9c598cb5b4802f80e9f9f81a38a6d05a7ad5ea89ce98ee25cbe9e458cc5564c0976eccce70a66aef
6
+ metadata.gz: c3ce4e8717a67bce1e3614677ec6b850ac58f6a598402debc78371b3a11fd3ebfde1d876375d6ed588d7ad9a7cde8a5a2806e982f2c5ab9ea8c14c72136b6651
7
+ data.tar.gz: a559406993fc8f3d9bb95f59df96e8fd70a6a45672675a9255e84857b1e36778993a1be498ca710ad8c60cb27cc27da3d48a4d2d8d3065550ee1254b141d2569
data/CHANGELOG.md CHANGED
@@ -4,7 +4,88 @@ All notable changes to this project are documented here. Format loosely follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.0.0/); this project is
5
5
  pre-1.0, so APIs may still shift between minor versions.
6
6
 
7
- ## [Unreleased]
7
+ ## [0.6.1] - 2026-09-22
8
+
9
+ - `ServerError` carries the parsed error document on `#payload` when the
10
+ server's text content is JSON, with `#summary`, `#continue_url` and
11
+ `#server_messages` readers over it. A real UCP server answers an
12
+ out-of-stock `create_cart` with its whole `ucp` envelope — every
13
+ capability, every payment handler — wrapped around a two-word
14
+ `messages[].content` of "Sold out", so `#message` alone is several
15
+ kilobytes of JSON and unusable by anything that has to show it to a
16
+ person. `#message` is unchanged; the readers are additive, and return
17
+ nil/`[]` for a refusal whose text isn't JSON.
18
+
19
+ ## [0.6.0] - 2026-09-22
20
+
21
+ - `Transports::Http` now builds `checkout.payment.instruments[]` for
22
+ `complete_checkout` against the card handler (`dev.shopify.card`) instead
23
+ of unconditionally raising `UnsupportedWireShapeError`. Top level carries
24
+ `id`/`checkout`; the instrument carries `id`/`handler_id`/`type`/
25
+ `credential: { token:, type: }`, per the schema pulled live from
26
+ `tools/list` 2026-09-22. `credential.type` (`dev.shopify.card_token`)
27
+ follows the payment-handler-guide's reverse-DNS-plus-`_token` convention
28
+ but is **not confirmed against a live response** — `complete_checkout` has
29
+ never actually been called (no grant to test against; see
30
+ `docs/ucp-tool-gating-investigation.md`). A `handler_id:` other than the
31
+ card handler still raises `UnsupportedWireShapeError`, now naming the
32
+ handler.
33
+ - `Session#complete_checkout` takes optional `handler_id:`/`credential_type:`
34
+ to override the above; both are dropped by `Transports::{Loopback,Stdio}`
35
+ same as `context:`/`cart_id:`, since neither means anything to an Adapter
36
+ method signature.
37
+ - New `Client::PaymentPermissionError`, distinct from `ServerError` (a
38
+ malformed/declined request) and `UnsupportedWireShapeError` (a handler
39
+ this client can't build a request for). `Transports::Http` raises it when
40
+ a `complete_checkout` refusal looks permission-shaped — going by the
41
+ *pattern* of Shopify's other confirmed permission error
42
+ (`get_order`'s `"You are forbidden to make tools/call requests"`) and the
43
+ community-thread description of what's gated on `complete_checkout`
44
+ (checkout-completion permission on the token, the merchant's channel
45
+ enabled). This pattern match is also unconfirmed live.
46
+
47
+ ## [0.5.0] - 2026-09-22
48
+
49
+ - `Session#search_catalog`/`#get_product`/`#lookup_catalog`/`#create_cart`/
50
+ `#update_cart`/`#create_checkout`/`#update_checkout` take a `context:` — the
51
+ UCP `context` object (`address_country`, `address_region`, `postal_code`,
52
+ `currency`, `language`) — and `Transports::Http` nests it under the
53
+ capability key. This reads as optional and isn't: a store resolves which
54
+ market, and so which publication and inventory, a call is scoped to from it.
55
+ A cart built without one comes back with `line_items: []`, zeroed totals and
56
+ a `merchandise_out_of_stock` warning naming a product the same store's
57
+ `search_catalog` returned as `available` seconds earlier — confirmed live
58
+ 2026-09-22. It failed open, with a plausible wrong answer instead of an
59
+ error.
60
+ - `Session#create_checkout` takes a `cart_id:`, converting an existing cart
61
+ into a checkout. `line_items:` stays required: `checkout.cart_id`'s own
62
+ schema says a cart id alone is enough, and the live server rejects that with
63
+ `missing required properties: line_items`, so `Transports::Http` sends both.
64
+ - `Transports::Loopback` and `Transports::Stdio` drop `context`/`cart_id`
65
+ before dispatch. Both hand arguments to this gem's own server, which splats
66
+ them into an Adapter method signature that has no such keywords.
67
+
68
+ ## [0.4.0] - 2026-09-17
69
+
70
+ - Fixed `Transports::Http` sending every tool call in the flat, unwrapped
71
+ shape this gem's own Dispatcher/adapters speak — real UCP servers
72
+ (confirmed live against Shopify's 2026-08-25 rollout) reject it with a 422,
73
+ since they expect arguments nested under a capability key
74
+ (`catalog:`/`cart:`/`checkout:`) and a `meta.ucp-agent.profile` URL they
75
+ fetch themselves to verify the caller's identity. `Http` now builds that
76
+ real wire shape; `Loopback`/`Stdio` are unchanged, since the former talks
77
+ to this gem's own Dispatcher (still the flat shape by design) and the
78
+ latter has no confirmed real-world shape to fix. Callers must now pass
79
+ `meta: { agent_profile: <url> }`, or `MissingAgentProfileError` explains
80
+ what's missing instead of a bare 422.
81
+ - Added `MissingAgentProfileError` and `UnsupportedWireShapeError`.
82
+ `complete_checkout` over HTTP raises the latter rather than guessing at
83
+ the real payment-instrument shape (Apple Pay/Shop Pay/card-token variants
84
+ each have distinct required credential fields) with no way to verify it
85
+ against a real payment flow.
86
+ - Widens the `portage-ucp` dependency pin to `~> 0.8` so this gem installs
87
+ alongside `portage-ucp` 0.8.0 (the `~> 0.7` pin published with 0.3.3 is
88
+ pessimistic and excludes it).
8
89
 
9
90
  ## [0.3.3] - 2026-09-16
10
91
 
@@ -8,12 +8,94 @@ module Portage
8
8
  # `isError: true` — e.g. an AuthenticationError/RateLimitExceededError/
9
9
  # RawPanRejectedError the server side surfaced. `#message` is the text
10
10
  # content the server returned, not a generic string.
11
- class ServerError < Error; end
11
+ #
12
+ # On a real UCP server that text is frequently the whole error document
13
+ # rather than a sentence — Shopify answers an out-of-stock `create_cart`
14
+ # with its entire `ucp` envelope (every capability, every payment
15
+ # handler) plus a two-word `messages[].content` of "Sold out" (confirmed
16
+ # live 2026-09-22). Raising that as a several-kilobyte `#message` is
17
+ # accurate and useless to anything that has to show it to a person, so
18
+ # `#payload` carries the parsed document when the text is JSON, and the
19
+ # readers below pull out the parts a caller actually wants. They return
20
+ # nil/[] rather than raising for a server whose text isn't JSON — this
21
+ # is an error path, and failing to parse an error is not worth a second
22
+ # error.
23
+ class ServerError < Error
24
+ attr_reader :payload
12
25
 
13
- # Raised by .discover when the manifest can't be fetched/parsed, or has
14
- # no `services` entry for the mcp transport to connect to (see
15
- # Portage::Ucp::Manifest#services — the core-gem fix this depends on).
26
+ def initialize(message = nil, payload: nil)
27
+ super(message)
28
+ @payload = payload
29
+ end
30
+
31
+ # The server's own human-readable explanations, most specific first —
32
+ # `messages[]` entries carry `content`, `code` and `severity`.
33
+ def server_messages
34
+ Array(payload && payload["messages"]).filter_map do |m|
35
+ next unless m.is_a?(Hash)
36
+
37
+ { code: m["code"], content: m["content"], severity: m["severity"] }.compact
38
+ end
39
+ end
40
+
41
+ # One line fit to print: the server's message content joined, falling
42
+ # back to the raw text when there's no structured document to read.
43
+ def summary
44
+ contents = server_messages.filter_map { |m| m[:content] }
45
+ return message if contents.empty?
46
+
47
+ contents.join("; ")
48
+ end
49
+
50
+ # Where a shopper can finish by hand — present on Shopify's cart and
51
+ # checkout errors, which is exactly when a CLI wants to offer it.
52
+ def continue_url
53
+ payload && payload["continue_url"]
54
+ end
55
+ end
56
+
57
+ # Raised by .discover when the manifest can't be fetched at all: the URL
58
+ # 404s, the host refuses the connection, or the body isn't valid JSON.
59
+ # Indistinguishable from "this store doesn't run UCP" — callers that
60
+ # want to fall back silently for that case should rescue this.
16
61
  class DiscoveryError < Error; end
62
+
63
+ # Raised by .discover when the manifest *was* fetched and parsed as
64
+ # JSON, but this client couldn't make sense of its shape (no `services`
65
+ # entry advertising an `mcp` transport). Unlike DiscoveryError, this
66
+ # means the store *is* running UCP — the failure is on this client's
67
+ # side, not the store's — so callers shouldn't treat it the same as
68
+ # "no native UCP support" the way a 404 does.
69
+ class ManifestShapeError < DiscoveryError; end
70
+
71
+ # Raised by Transports::Http when a call is made without
72
+ # `meta: { agent_profile: <url> }` — real UCP servers (confirmed
73
+ # against Shopify's 2026-08-25 rollout) fetch and verify this URL
74
+ # themselves to identify the calling agent, so there's no sane default
75
+ # to fall back to; better to fail here than pass `meta: nil` through
76
+ # to a 422 the server explains as `profile_unreachable`.
77
+ class MissingAgentProfileError < Error; end
78
+
79
+ # Raised by Transports::Http for a mutating call this gem can't yet
80
+ # build the real wire shape for — currently only `complete_checkout`,
81
+ # whose `checkout.payment.instruments` shape (Apple Pay/Shop
82
+ # Pay/card-token variants, each with its own required credential
83
+ # fields) can't be safely guessed without a real payment flow to test
84
+ # against. Raised instead of sending a best-effort shape that might
85
+ # silently misbehave with real money on the line.
86
+ class UnsupportedWireShapeError < Error; end
87
+
88
+ # Raised by Transports::Http when a `complete_checkout` call is refused
89
+ # because this client's token lacks checkout-completion permission on
90
+ # the store, or the merchant hasn't enabled this agent's channel (see
91
+ # docs/ucp-tool-gating-investigation.md — that's the one thing genuinely
92
+ # gated in native UCP, granted case by case, no scope picker). Distinct
93
+ # from ServerError (a malformed/declined request the server understood
94
+ # and rejected on its own terms) and from UnsupportedWireShapeError (a
95
+ # handler this client can't build a request for at all) — callers that
96
+ # want to fall back to the checkout's own continue_url should rescue
97
+ # this specifically rather than pattern-matching ServerError#message.
98
+ class PaymentPermissionError < Error; end
17
99
  end
18
100
  end
19
101
  end
@@ -38,22 +38,35 @@ module Portage
38
38
  capabilities&.include?(capability_name)
39
39
  end
40
40
 
41
- def search_catalog(query:, limit: 20,
41
+ # `context:` is the UCP `context` object — buyer locale hints
42
+ # (`address_country`, `address_region`, `postal_code`, `currency`,
43
+ # `language`). Optional on paper, effectively required against a real
44
+ # store: see Transports::Http#with_context for what a store does with
45
+ # a cart built without one. Ignored by the loopback/stdio transports,
46
+ # which talk to this gem's own flat-argument server.
47
+ def search_catalog(query:, limit: 20, context: nil,
42
48
  meta: nil)
43
- call("search_catalog", meta: meta, query: query, limit: limit)
49
+ call("search_catalog", meta: meta, query: query, limit: limit, context: context)
44
50
  end
45
51
 
46
- def get_product(product_id:, meta: nil) = call("get_product", meta: meta, product_id: product_id)
47
- def lookup_catalog(product_ids:, meta: nil) = call("lookup_catalog", meta: meta, product_ids: product_ids)
52
+ def get_product(product_id:, context: nil, meta: nil)
53
+ call("get_product", meta: meta, product_id: product_id, context: context)
54
+ end
55
+
56
+ def lookup_catalog(product_ids:, context: nil, meta: nil)
57
+ call("lookup_catalog", meta: meta, product_ids: product_ids, context: context)
58
+ end
48
59
 
49
60
  def get_cart(cart_id:, meta: nil) = call("get_cart", meta: meta, cart_id: cart_id)
50
61
 
51
- def create_cart(line_items:, idempotency_key: nil, meta: nil)
52
- call("create_cart", meta: meta, line_items: line_items, idempotency_key: idempotency_key)
62
+ def create_cart(line_items:, idempotency_key: nil, context: nil, meta: nil)
63
+ call("create_cart", meta: meta, line_items: line_items, idempotency_key: idempotency_key,
64
+ context: context)
53
65
  end
54
66
 
55
- def update_cart(cart_id:, line_items:, idempotency_key: nil, meta: nil)
56
- call("update_cart", meta: meta, cart_id: cart_id, line_items: line_items, idempotency_key: idempotency_key)
67
+ def update_cart(cart_id:, line_items:, idempotency_key: nil, context: nil, meta: nil)
68
+ call("update_cart", meta: meta, cart_id: cart_id, line_items: line_items,
69
+ idempotency_key: idempotency_key, context: context)
57
70
  end
58
71
 
59
72
  def cancel_cart(cart_id:, idempotency_key: nil, meta: nil)
@@ -66,23 +79,39 @@ module Portage
66
79
  # (a Portage::Ucp::CheckoutFulfillment for loopback). Over stdio/HTTP
67
80
  # it would need a JSON wire shape this gem doesn't build yet, so
68
81
  # callers on those transports should leave it nil.
69
- def create_checkout(line_items:, idempotency_key: nil, fulfillment: nil, meta: nil)
82
+ # `cart_id:` converts an existing cart into a checkout rather than
83
+ # re-listing its contents from scratch — HTTP only (see
84
+ # Transports::Http#wrap_line_items); `line_items:` stays required
85
+ # because the live server rejects a `cart_id`-only body.
86
+ def create_checkout(line_items:, idempotency_key: nil, fulfillment: nil, cart_id: nil, context: nil,
87
+ meta: nil)
70
88
  call("create_checkout", meta: meta, line_items: line_items, idempotency_key: idempotency_key,
89
+ context: context, **(cart_id ? { cart_id: cart_id } : {}),
71
90
  **(fulfillment ? { fulfillment: fulfillment } : {}))
72
91
  end
73
92
 
74
93
  def get_checkout(checkout_id:, meta: nil) = call("get_checkout", meta: meta, checkout_id: checkout_id)
75
94
 
76
- def update_checkout(checkout_id:, line_items:, idempotency_key: nil, fulfillment: nil, meta: nil)
95
+ def update_checkout(checkout_id:, line_items:, idempotency_key: nil, fulfillment: nil, context: nil,
96
+ meta: nil)
77
97
  call("update_checkout", meta: meta, checkout_id: checkout_id, line_items: line_items,
78
- idempotency_key: idempotency_key,
98
+ idempotency_key: idempotency_key, context: context,
79
99
  **(fulfillment ? { fulfillment: fulfillment } : {}))
80
100
  end
81
101
 
82
- def complete_checkout(checkout_id:, payment_token:, idempotency_key: nil, meta: nil)
102
+ # `handler_id:`/`credential_type:` only matter to Transports::Http
103
+ # (see #complete_checkout_body there) — the loopback/stdio transports
104
+ # splat straight into an Adapter signature with no such keywords, so
105
+ # both are dropped there like `context:`/`cart_id:` (their own
106
+ # REMOTE_WIRE_ARGUMENTS). Left nil, Http assumes the one handler it
107
+ # knows how to build a request for (the card handler).
108
+ def complete_checkout(checkout_id:, payment_token:, idempotency_key: nil, handler_id: nil,
109
+ credential_type: nil, meta: nil)
83
110
  Portage::Ucp::PaymentTokenGuard.validate!(payment_token)
84
111
  call("complete_checkout", meta: meta, checkout_id: checkout_id, payment_token: payment_token,
85
- idempotency_key: idempotency_key)
112
+ idempotency_key: idempotency_key,
113
+ **(handler_id ? { handler_id: handler_id } : {}),
114
+ **(credential_type ? { credential_type: credential_type } : {}))
86
115
  end
87
116
 
88
117
  def cancel_checkout(checkout_id:, idempotency_key: nil, meta: nil)
@@ -106,6 +135,7 @@ module Portage
106
135
  private
107
136
 
108
137
  def call(action, meta: nil, **arguments)
138
+ arguments.delete(:context) if arguments[:context].nil?
109
139
  arguments[:idempotency_key] ||= SecureRandom.uuid if MUTATING_ACTIONS.include?(action)
110
140
  @transport.call_tool(name: action, arguments: arguments, meta: meta)
111
141
  end
@@ -1,3 +1,5 @@
1
+ require "json"
2
+
1
3
  module Portage
2
4
  module Ucp
3
5
  module Client
@@ -10,7 +12,10 @@ module Portage
10
12
  def self.extract(response, symbol_keys:)
11
13
  result = fetch(response, "result", symbol_keys) || {}
12
14
  content = fetch(result, "content", symbol_keys)
13
- raise ServerError, text(content, symbol_keys: symbol_keys) if fetch(result, "isError", symbol_keys)
15
+ if fetch(result, "isError", symbol_keys)
16
+ body = text(content, symbol_keys: symbol_keys)
17
+ raise ServerError.new(body, payload: parse(body))
18
+ end
14
19
 
15
20
  fetch(result, "structuredContent", symbol_keys)
16
21
  end
@@ -19,6 +24,18 @@ module Portage
19
24
  Array(content).filter_map { |block| fetch(block, "text", symbol_keys) }.join(" ")
20
25
  end
21
26
 
27
+ # A UCP server's error text is usually its whole JSON error document
28
+ # (see ServerError) — parsed here so callers get the `messages[]` and
29
+ # `continue_url` inside it without re-parsing a string. Anything that
30
+ # isn't a JSON object stays nil and callers fall back to the raw text.
31
+ def self.parse(body)
32
+ parsed = JSON.parse(body.to_s)
33
+ parsed.is_a?(Hash) ? parsed : nil
34
+ rescue JSON::ParserError
35
+ nil
36
+ end
37
+ private_class_method :parse
38
+
22
39
  def self.fetch(hash, key, symbol_keys)
23
40
  hash[symbol_keys ? key.to_sym : key]
24
41
  end
@@ -0,0 +1,86 @@
1
+ module Portage
2
+ module Ucp
3
+ module Client
4
+ module Transports
5
+ class Http
6
+ # `complete_checkout`'s real-UCP wire shape — split out of Http
7
+ # itself only to keep that class under Metrics/ClassLength; these
8
+ # methods are private instance methods of Http, not a separate
9
+ # collaborator object, so they still read/raise the same
10
+ # Http-scoped constants and errors.
11
+ module CompleteCheckoutWireShape
12
+ # The one payment handler this transport can build a request for.
13
+ # Reverse-DNS handler id, confirmed live 2026-09-22 in the dev
14
+ # store's checkout `payment_handlers` (see docs handoff) — the key
15
+ # the store itself uses, not the `id`/`spec` fields nested under
16
+ # it (those name the handler's own config, e.g. "shopify.card").
17
+ CARD_HANDLER_ID = "dev.shopify.card".freeze
18
+
19
+ # `credential.type` per the payment-handler-guide's discriminated
20
+ # union (ucp.dev/specification/payment/guide, e.g.
21
+ # `tokenizer_card_token` for a `com.example.tokenizer` handler) —
22
+ # the reverse-DNS handler id plus a `_token` suffix. NOT confirmed
23
+ # against a live response (complete_checkout has never been
24
+ # called — see handoff doc); a caller that knows better can
25
+ # override via `credential_type:`.
26
+ CARD_CREDENTIAL_TYPE = "dev.shopify.card_token".freeze
27
+
28
+ # Substrings a permission refusal is expected to carry, going by
29
+ # the *shape* of Shopify's other permission error ("You are
30
+ # forbidden to make tools/call requests" on `get_order`,
31
+ # confirmed live — see docs/ucp-tool-gating-investigation.md) and
32
+ # the community-thread description of what's gated
33
+ # (checkout-completion permission / channel not enabled). NOT
34
+ # confirmed for `complete_checkout` itself — no live call has
35
+ # been made. A refusal that doesn't match falls through as a
36
+ # plain ServerError/RequestHandlerError instead of being
37
+ # misreported.
38
+ PERMISSION_REFUSAL_PATTERN = /forbidden|not enabled|not granted|checkout.?completion/i
39
+
40
+ # `checkout.payment.instruments[]` — schema pulled live from
41
+ # tools/list 2026-09-22 (see handoff doc): top level needs
42
+ # `id`/`checkout`, `checkout.payment.instruments[]` items need
43
+ # `id`/`handler_id`/`type`, and every handler but apple-pay needs
44
+ # `credential: { token:, type: }`. Only the card handler is
45
+ # wired; any other `handler_id:` the caller passes raises,
46
+ # naming it, rather than guessing a shape that's never been
47
+ # tested live.
48
+ def complete_checkout_body(arguments, idempotency_key)
49
+ handler_id = arguments[:handler_id] || CARD_HANDLER_ID
50
+ raise_unsupported_payment_shape(handler_id) unless handler_id == CARD_HANDLER_ID
51
+
52
+ instrument = {
53
+ "id" => "instrument-#{idempotency_key}",
54
+ "handler_id" => handler_id,
55
+ "type" => "card",
56
+ "credential" => { "token" => arguments.fetch(:payment_token),
57
+ "type" => arguments[:credential_type] || CARD_CREDENTIAL_TYPE }
58
+ }
59
+ { "id" => arguments.fetch(:checkout_id),
60
+ "checkout" => { "payment" => { "instruments" => [instrument] } } }
61
+ end
62
+
63
+ def permission_refusal?(message)
64
+ message.to_s.match?(PERMISSION_REFUSAL_PATTERN)
65
+ end
66
+
67
+ def permission_error(cause)
68
+ PaymentPermissionError.new(
69
+ "complete_checkout refused for lack of permission (#{cause.class}: #{cause.message}) — needs " \
70
+ "checkout-completion permission on this client's token and the merchant enabling this agent's " \
71
+ "channel on their shop"
72
+ )
73
+ end
74
+
75
+ def raise_unsupported_payment_shape(handler_id)
76
+ raise UnsupportedWireShapeError,
77
+ "complete_checkout doesn't know how to build a payment instrument for handler " \
78
+ "#{handler_id.inspect} — only #{CARD_HANDLER_ID.inspect} is wired; apple-pay/shop-pay each " \
79
+ "need their own credential shape this client hasn't verified against a real payment flow"
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -1,3 +1,6 @@
1
+ require_relative "../errors"
2
+ require_relative "http/complete_checkout_wire_shape"
3
+
1
4
  module Portage
2
5
  module Ucp
3
6
  module Client
@@ -6,15 +9,160 @@ module Portage
6
9
  # `mcp` gem's client half (MCP::Client + MCP::Client::HTTP). Performs
7
10
  # the `initialize` handshake eagerly so a caller's first real call
8
11
  # doesn't pay for it.
12
+ #
13
+ # Session's public API (query:/limit:, cart_id:/checkout_id:/
14
+ # order_id:, line_items: [{product_id:, quantity:}], ...) stays flat
15
+ # — it's shared with Loopback, which hands arguments straight to this
16
+ # gem's own Dispatcher/adapters, and those speak that flat shape by
17
+ # design (see portage-ucp/lib/portage/ucp/mcp/server.rb). Real UCP
18
+ # servers don't: confirmed live against Shopify's 2026-08-25 rollout,
19
+ # every tool nests its arguments under a capability key
20
+ # (catalog:/cart:/checkout:) and requires a `meta.ucp-agent.profile`
21
+ # URL the server itself fetches to verify the caller's identity. This
22
+ # transport is the one place that reshapes Session's flat arguments
23
+ # into that real wire format before the request goes out — Loopback
24
+ # and Stdio are untouched.
9
25
  class Http
26
+ include CompleteCheckoutWireShape
27
+
28
+ # Actions whose one identifying argument becomes a top-level `id`.
29
+ ID_ARG = {
30
+ "get_cart" => :cart_id, "cancel_cart" => :cart_id,
31
+ "get_checkout" => :checkout_id, "cancel_checkout" => :checkout_id,
32
+ "get_order" => :order_id
33
+ }.freeze
34
+
35
+ CATALOG_ACTIONS = %w[get_product lookup_catalog search_catalog].freeze
36
+ CART_ACTIONS = %w[create_cart update_cart].freeze
37
+ CHECKOUT_ACTIONS = %w[create_checkout update_checkout].freeze
38
+
10
39
  def initialize(url:, headers: {})
11
40
  @client = ::MCP::Client.new(transport: ::MCP::Client::HTTP.new(url: url, headers: headers))
12
41
  @client.connect
13
42
  end
14
43
 
44
+ # `meta` here is a *property of the tool's own `arguments` object*
45
+ # (confirmed live against Shopify's schema — every tool's
46
+ # input_schema lists "meta" as a top-level sibling of
47
+ # "catalog"/"cart"/"checkout"), not the MCP protocol's `_meta`
48
+ # envelope field. `MCP::Client#call_tool`'s own `meta:` kwarg sends
49
+ # the latter, so it's unused here — the caller-supplied meta gets
50
+ # folded into `arguments["meta"]` instead.
15
51
  def call_tool(name:, arguments:, meta: nil)
16
- response = @client.call_tool(name: name, arguments: arguments, meta: meta)
52
+ idempotency_key = arguments[:idempotency_key] || arguments["idempotency_key"]
53
+ wire = wire_arguments(name, arguments)
54
+ wire["meta"] = wire_meta(meta, idempotency_key)
55
+ response = @client.call_tool(name: name, arguments: wire)
17
56
  ToolResult.extract(response, symbol_keys: false)
57
+ rescue ServerError, MCP::Client::RequestHandlerError => e
58
+ raise permission_error(e) if name == "complete_checkout" && permission_refusal?(e.message)
59
+
60
+ raise
61
+ end
62
+
63
+ private
64
+
65
+ # `idempotency_key` arrives via `arguments` — Session#call folds it
66
+ # in there for every mutating action — not via `meta`, so it has to
67
+ # be threaded through here rather than read off `meta` directly.
68
+ def wire_meta(meta, idempotency_key)
69
+ profile = meta && (meta[:agent_profile] || meta["agent_profile"])
70
+ unless profile
71
+ raise MissingAgentProfileError,
72
+ "meta: { agent_profile: <url> } is required for HTTP calls — real UCP servers fetch " \
73
+ "and verify this URL to identify the calling agent"
74
+ end
75
+
76
+ wire = { "ucp-agent" => { "profile" => profile } }
77
+ wire["idempotency-key"] = idempotency_key if idempotency_key
78
+ wire
79
+ end
80
+
81
+ def wire_arguments(name, arguments)
82
+ idempotency_key = arguments[:idempotency_key] || arguments["idempotency_key"]
83
+ arguments = arguments.dup
84
+ arguments.delete(:idempotency_key)
85
+
86
+ return complete_checkout_body(arguments, idempotency_key) if name == "complete_checkout"
87
+ return { "id" => arguments.fetch(ID_ARG[name]) } if ID_ARG.key?(name)
88
+ return catalog_body(name, arguments) if CATALOG_ACTIONS.include?(name)
89
+ return wrap_line_items("cart", arguments, id_key: :cart_id) if CART_ACTIONS.include?(name)
90
+ return wrap_line_items("checkout", arguments, id_key: :checkout_id) if CHECKOUT_ACTIONS.include?(name)
91
+
92
+ arguments
93
+ end
94
+
95
+ def catalog_body(name, arguments)
96
+ body = case name
97
+ when "get_product" then { "id" => arguments.fetch(:product_id) }
98
+ when "lookup_catalog" then { "ids" => arguments.fetch(:product_ids) }
99
+ when "search_catalog" then search_catalog_body(arguments)
100
+ end
101
+ { "catalog" => with_context(body, arguments) }
102
+ end
103
+
104
+ # `context` carries the buyer's locale/currency/region hints the UCP
105
+ # `context` object is specified for. It looks optional and isn't:
106
+ # Shopify resolves which market (and therefore which publication and
107
+ # inventory) a call is scoped to from it, and a cart built without it
108
+ # comes back with `line_items: []`, zeroed totals, and a
109
+ # `merchandise_out_of_stock` warning naming a product `search_catalog`
110
+ # had just reported as `availability.available == true` on the same
111
+ # store. Confirmed live 2026-09-22: identical `create_cart`, context
112
+ # added, returns the line item at its real price.
113
+ #
114
+ # A sweep of nine third-party Shopify stores the same day found the
115
+ # empty cart is only the loudest of three outcomes for an omitted
116
+ # context. Three stores built a correct cart without one; one
117
+ # emptied it; five priced it in the market of the *caller's IP*
118
+ # (a run from Bangkok got THB totals from stores whose shoppers had
119
+ # asked for nothing of the sort). So an omitted context doesn't
120
+ # merely degrade results — it produces a valid-shaped cart at the
121
+ # wrong currency, with nothing on the response saying so, which is
122
+ # the harder failure to notice of the two.
123
+ #
124
+ # Sending one is necessary, not sufficient: the cart is scoped to
125
+ # the market the context names, so a product the store doesn't
126
+ # publish into that market drops out with the same
127
+ # `merchandise_out_of_stock` message (mejuri.com, `US`/`USD` and
128
+ # `GB`/`GBP` empty, `CA`/`CAD` fine). That store's `search_catalog`
129
+ # ignored the context entirely and quoted CAD at
130
+ # `available: true` for every market asked, so the search result
131
+ # gives a caller no warning. Nothing to fix here — it's the store's
132
+ # market config — but callers shouldn't read this code as a
133
+ # guarantee that a context makes carts work.
134
+ def with_context(body, arguments)
135
+ context = arguments[:context] || arguments["context"]
136
+ return body if context.nil? || context.empty?
137
+
138
+ body.merge("context" => stringify(context))
139
+ end
140
+
141
+ def stringify(context)
142
+ context.to_h { |key, value| [key.to_s, value] }
143
+ end
144
+
145
+ def search_catalog_body(arguments)
146
+ { "query" => arguments[:query], "pagination" => { "limit" => arguments[:limit] }.compact }.compact
147
+ end
148
+
149
+ # `cart_id` on a checkout body is the cart→checkout conversion the
150
+ # spec's `checkout.cart_id` describes ("the business uses cart
151
+ # contents and ignores overlapping fields"). Its schema says
152
+ # `cart_id` alone is enough; the live server disagrees and rejects
153
+ # that with `Invalid arguments: object at '/checkout' is missing
154
+ # required properties: line_items` (confirmed live 2026-09-22), so
155
+ # the line items go out alongside it rather than instead of it.
156
+ def wrap_line_items(wrapper, arguments, id_key:)
157
+ body = { "line_items" => Array(arguments[:line_items]).map { |li| wire_line_item(li) } }
158
+ body["cart_id"] = arguments[:cart_id] if wrapper == "checkout" && arguments[:cart_id]
159
+ body = with_context(body, arguments)
160
+ { wrapper => body }.tap { |h| h["id"] = arguments[id_key] if arguments[id_key] }
161
+ end
162
+
163
+ def wire_line_item(line_item)
164
+ { "item" => { "id" => line_item[:product_id] || line_item["product_id"] },
165
+ "quantity" => line_item[:quantity] || line_item["quantity"] }
18
166
  end
19
167
  end
20
168
  end
@@ -15,14 +15,43 @@ module Portage
15
15
  @next_id = 0
16
16
  end
17
17
 
18
+ # `context`/`cart_id`/`handler_id`/`credential_type` are real-UCP
19
+ # wire concerns Session offers for Transports::Http to nest into a
20
+ # request body. This transport hands arguments straight to this
21
+ # gem's own Dispatcher, which splats them into an Adapter method
22
+ # signature that has no such keywords, so passing them on would be
23
+ # an ArgumentError on every call. Dropped here rather than branched
24
+ # on in Session, so each transport keeps owning which arguments it
25
+ # understands.
26
+ REMOTE_WIRE_ARGUMENTS = %i[context cart_id handler_id credential_type].freeze
27
+
18
28
  def call_tool(name:, arguments:, meta: nil)
19
29
  @next_id += 1
30
+ arguments = arguments.except(*REMOTE_WIRE_ARGUMENTS)
20
31
  response = @server.handle(
21
32
  { jsonrpc: "2.0", id: @next_id, method: "tools/call",
22
- params: { name: name, arguments: arguments, **(meta ? { _meta: meta } : {}) } }
33
+ params: { name: name, arguments: arguments, **(meta ? { _meta: wire_meta(meta) } : {}) } }
23
34
  )
24
35
  ToolResult.extract(response, symbol_keys: true)
25
36
  end
37
+
38
+ private
39
+
40
+ # Session's transport-agnostic `meta: { agent_profile: <url> }`
41
+ # convention (the one `Buy#agent_meta` actually passes, regardless
42
+ # of which transport `session` turns out to be) has to reach
43
+ # `Mcp::Server.agent_profile_for`, which only ever looks at
44
+ # `_meta["ucp-agent.profile"]`/`_meta[:"ucp-agent.profile"]` — a
45
+ # different key. Without this, an own-store loopback buy silently
46
+ # dropped its agent_profile (no error, just a blank field in
47
+ # tool_call_received/tool_called events) while the real-store HTTP
48
+ # transport picked the same `agent_profile:` key up correctly.
49
+ # Merges rather than replaces so a caller already using the
50
+ # server's own key (as this gem's specs do) is untouched.
51
+ def wire_meta(meta)
52
+ profile = meta[:agent_profile] || meta["agent_profile"]
53
+ profile ? meta.merge("ucp-agent.profile" => profile) : meta
54
+ end
26
55
  end
27
56
  end
28
57
  end
@@ -12,8 +12,18 @@ module Portage
12
12
  @client.connect
13
13
  end
14
14
 
15
+ # `context`/`cart_id`/`handler_id`/`credential_type` are real-UCP
16
+ # wire concerns Session offers for Transports::Http to nest into a
17
+ # request body. This transport hands arguments straight to this
18
+ # gem's own Dispatcher, which splats them into an Adapter method
19
+ # signature that has no such keywords, so passing them on would be
20
+ # an ArgumentError on every call. Dropped here rather than branched
21
+ # on in Session, so each transport keeps owning which arguments it
22
+ # understands.
23
+ REMOTE_WIRE_ARGUMENTS = %i[context cart_id handler_id credential_type].freeze
24
+
15
25
  def call_tool(name:, arguments:, meta: nil)
16
- response = @client.call_tool(name: name, arguments: arguments, meta: meta)
26
+ response = @client.call_tool(name: name, arguments: arguments.except(*REMOTE_WIRE_ARGUMENTS), meta: meta)
17
27
  ToolResult.extract(response, symbol_keys: false)
18
28
  end
19
29
  end
@@ -1,7 +1,7 @@
1
1
  module Portage
2
2
  module Ucp
3
3
  module Client
4
- VERSION = "0.3.3".freeze
4
+ VERSION = "0.6.1".freeze
5
5
  end
6
6
  end
7
7
  end
@@ -70,17 +70,31 @@ module Portage
70
70
  end
71
71
  private_class_method :fetch_manifest
72
72
 
73
+ # Real UCP manifests (confirmed live on Casper, Allbirds, Glossier, and
74
+ # 34+ other Shopify UCP rollouts as of "2026-08-25") nest everything one
75
+ # level deeper under a "ucp" key. This gem's own server side
76
+ # (Portage::Ucp::Manifest) still emits the old flat shape, so both are
77
+ # supported rather than picking one — see docs/well-known-ucp.md.
78
+ def self.ucp_section(manifest)
79
+ manifest["ucp"] || manifest
80
+ end
81
+ private_class_method :ucp_section
82
+
73
83
  def self.mcp_endpoint(manifest)
74
- service = Array(manifest["services"]).find { |s| s["transport"] == "mcp" }
75
- endpoint = service && service["endpoint"]
76
- raise DiscoveryError, "manifest has no mcp service entry to connect to" unless endpoint
84
+ services = ucp_section(manifest)["services"]
85
+ entries = services.is_a?(Hash) ? services.values.flatten : Array(services)
86
+ endpoint = entries.select { |s| s["transport"] == "mcp" }
87
+ .max_by { |s| s["version"].to_s }
88
+ &.fetch("endpoint", nil)
89
+ raise ManifestShapeError, "manifest has no mcp service entry to connect to" unless endpoint
77
90
 
78
91
  endpoint
79
92
  end
80
93
  private_class_method :mcp_endpoint
81
94
 
82
95
  def self.capability_names(manifest)
83
- Array(manifest["capabilities"]).map { |c| c["name"] }
96
+ capabilities = ucp_section(manifest)["capabilities"]
97
+ capabilities.is_a?(Hash) ? capabilities.keys : Array(capabilities).map { |c| c["name"] }
84
98
  end
85
99
  private_class_method :capability_names
86
100
  end
metadata CHANGED
@@ -1,15 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: portage-ucp-client
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.3
4
+ version: 0.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tom Whitbread
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-16 00:00:00.000000000 Z
11
+ date: 2026-09-22 00:00:00.000000000 Z
12
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: faraday
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '2.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '2.0'
13
27
  - !ruby/object:Gem::Dependency
14
28
  name: mcp
15
29
  requirement: !ruby/object:Gem::Requirement
@@ -30,14 +44,14 @@ dependencies:
30
44
  requirements:
31
45
  - - "~>"
32
46
  - !ruby/object:Gem::Version
33
- version: '0.7'
47
+ version: '0.8'
34
48
  type: :runtime
35
49
  prerelease: false
36
50
  version_requirements: !ruby/object:Gem::Requirement
37
51
  requirements:
38
52
  - - "~>"
39
53
  - !ruby/object:Gem::Version
40
- version: '0.7'
54
+ version: '0.8'
41
55
  - !ruby/object:Gem::Dependency
42
56
  name: rspec
43
57
  requirement: !ruby/object:Gem::Requirement
@@ -99,7 +113,8 @@ description: 'Every other portage-ucp gem lets a Ruby program expose a commerce
99
113
  /.well-known/ucp manifest, or drive your own Adapter directly, and place an order
100
114
  as the client. Three transports behind one interface (loopback over an in-process
101
115
  Adapter, stdio, Streamable HTTP) — callers never know which they got. Depends only
102
- on portage-ucp + the mcp gem''s client half; no adapter gem is a dependency.'
116
+ on portage-ucp, the mcp gem''s client half, and faraday (the HTTP transport''s backend);
117
+ no adapter gem is a dependency.'
103
118
  email:
104
119
  executables: []
105
120
  extensions: []
@@ -113,6 +128,7 @@ files:
113
128
  - lib/portage/ucp/client/session.rb
114
129
  - lib/portage/ucp/client/tool_result.rb
115
130
  - lib/portage/ucp/client/transports/http.rb
131
+ - lib/portage/ucp/client/transports/http/complete_checkout_wire_shape.rb
116
132
  - lib/portage/ucp/client/transports/loopback.rb
117
133
  - lib/portage/ucp/client/transports/stdio.rb
118
134
  - lib/portage/ucp/client/version.rb