portage-ucp-client 0.4.0 → 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: 7d63c926220d7d3aa15f0d11d4e2bdddc27ac0ad3227ae1bec20ed1beddd471a
4
- data.tar.gz: 8fc8999401c53b2cc6db65347aa1a377a20a2c9d316a94354328237c4727ec78
3
+ metadata.gz: 8eeeb1c006e06155791f086b9dbfce78562ecb4281412c18dbdf5794a4189c08
4
+ data.tar.gz: 6be190c6cb00f34b0d0978b522da1e912f3a0b33973b6eb813db72859869b491
5
5
  SHA512:
6
- metadata.gz: bd5574b147b2552c3a304e49ec71bac10183cd20eab5a06b7df7c0aa261c36642af1d9885d5880b5d08fd2320b9211c3ecc921c849dfe20c9b39b3a7c39ce537
7
- data.tar.gz: 31fa3387860b6d094399c5dd596fece5cf921e5f6b4b013f7180d37b8970f3c9b2981efd1cf37b7f8fada533439221e6c2d25e45e1078a597d13f88842a3f320
6
+ metadata.gz: c3ce4e8717a67bce1e3614677ec6b850ac58f6a598402debc78371b3a11fd3ebfde1d876375d6ed588d7ad9a7cde8a5a2806e982f2c5ab9ea8c14c72136b6651
7
+ data.tar.gz: a559406993fc8f3d9bb95f59df96e8fd70a6a45672675a9255e84857b1e36778993a1be498ca710ad8c60cb27cc27da3d48a4d2d8d3065550ee1254b141d2569
data/CHANGELOG.md CHANGED
@@ -4,6 +4,67 @@ 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
+ ## [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
+
7
68
  ## [0.4.0] - 2026-09-17
8
69
 
9
70
  - Fixed `Transports::Http` sending every tool call in the flat, unwrapped
@@ -8,7 +8,51 @@ 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
25
+
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
12
56
 
13
57
  # Raised by .discover when the manifest can't be fetched at all: the URL
14
58
  # 404s, the host refuses the connection, or the body isn't valid JSON.
@@ -40,6 +84,18 @@ module Portage
40
84
  # against. Raised instead of sending a best-effort shape that might
41
85
  # silently misbehave with real money on the line.
42
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
43
99
  end
44
100
  end
45
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,4 +1,5 @@
1
1
  require_relative "../errors"
2
+ require_relative "http/complete_checkout_wire_shape"
2
3
 
3
4
  module Portage
4
5
  module Ucp
@@ -22,6 +23,8 @@ module Portage
22
23
  # into that real wire format before the request goes out — Loopback
23
24
  # and Stdio are untouched.
24
25
  class Http
26
+ include CompleteCheckoutWireShape
27
+
25
28
  # Actions whose one identifying argument becomes a top-level `id`.
26
29
  ID_ARG = {
27
30
  "get_cart" => :cart_id, "cancel_cart" => :cart_id,
@@ -51,6 +54,10 @@ module Portage
51
54
  wire["meta"] = wire_meta(meta, idempotency_key)
52
55
  response = @client.call_tool(name: name, arguments: wire)
53
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
54
61
  end
55
62
 
56
63
  private
@@ -72,40 +79,84 @@ module Portage
72
79
  end
73
80
 
74
81
  def wire_arguments(name, arguments)
82
+ idempotency_key = arguments[:idempotency_key] || arguments["idempotency_key"]
75
83
  arguments = arguments.dup
76
84
  arguments.delete(:idempotency_key)
77
85
 
86
+ return complete_checkout_body(arguments, idempotency_key) if name == "complete_checkout"
78
87
  return { "id" => arguments.fetch(ID_ARG[name]) } if ID_ARG.key?(name)
79
88
  return catalog_body(name, arguments) if CATALOG_ACTIONS.include?(name)
80
89
  return wrap_line_items("cart", arguments, id_key: :cart_id) if CART_ACTIONS.include?(name)
81
90
  return wrap_line_items("checkout", arguments, id_key: :checkout_id) if CHECKOUT_ACTIONS.include?(name)
82
91
 
83
- raise_unsupported_payment_shape if name == "complete_checkout"
84
-
85
92
  arguments
86
93
  end
87
94
 
88
95
  def catalog_body(name, arguments)
89
- case name
90
- when "get_product" then { "catalog" => { "id" => arguments.fetch(:product_id) } }
91
- when "lookup_catalog" then { "catalog" => { "ids" => arguments.fetch(:product_ids) } }
92
- when "search_catalog" then { "catalog" => search_catalog_body(arguments) }
93
- end
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))
94
139
  end
95
140
 
96
- def raise_unsupported_payment_shape
97
- raise UnsupportedWireShapeError,
98
- "complete_checkout isn't wired to the real payment-instrument shape yet — " \
99
- "checkout.payment.instruments varies by handler (card/apple-pay/shop-pay) and hasn't " \
100
- "been verified against a real payment flow"
141
+ def stringify(context)
142
+ context.to_h { |key, value| [key.to_s, value] }
101
143
  end
102
144
 
103
145
  def search_catalog_body(arguments)
104
146
  { "query" => arguments[:query], "pagination" => { "limit" => arguments[:limit] }.compact }.compact
105
147
  end
106
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.
107
156
  def wrap_line_items(wrapper, arguments, id_key:)
108
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)
109
160
  { wrapper => body }.tap { |h| h["id"] = arguments[id_key] if arguments[id_key] }
110
161
  end
111
162
 
@@ -15,8 +15,19 @@ 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
33
  params: { name: name, arguments: arguments, **(meta ? { _meta: wire_meta(meta) } : {}) } }
@@ -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.4.0".freeze
4
+ VERSION = "0.6.1".freeze
5
5
  end
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: portage-ucp-client
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tom Whitbread
8
+ autorequire:
8
9
  bindir: bin
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-09-22 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: faraday
@@ -114,6 +115,7 @@ description: 'Every other portage-ucp gem lets a Ruby program expose a commerce
114
115
  Adapter, stdio, Streamable HTTP) — callers never know which they got. Depends only
115
116
  on portage-ucp, the mcp gem''s client half, and faraday (the HTTP transport''s backend);
116
117
  no adapter gem is a dependency.'
118
+ email:
117
119
  executables: []
118
120
  extensions: []
119
121
  extra_rdoc_files: []
@@ -126,6 +128,7 @@ files:
126
128
  - lib/portage/ucp/client/session.rb
127
129
  - lib/portage/ucp/client/tool_result.rb
128
130
  - lib/portage/ucp/client/transports/http.rb
131
+ - lib/portage/ucp/client/transports/http/complete_checkout_wire_shape.rb
129
132
  - lib/portage/ucp/client/transports/loopback.rb
130
133
  - lib/portage/ucp/client/transports/stdio.rb
131
134
  - lib/portage/ucp/client/version.rb
@@ -136,6 +139,7 @@ metadata:
136
139
  source_code_uri: https://github.com/tomtom87/Portage/tree/main/portage-ucp-client
137
140
  changelog_uri: https://github.com/tomtom87/Portage/blob/main/portage-ucp-client/CHANGELOG.md
138
141
  rubygems_mfa_required: 'true'
142
+ post_install_message:
139
143
  rdoc_options: []
140
144
  require_paths:
141
145
  - lib
@@ -150,7 +154,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
150
154
  - !ruby/object:Gem::Version
151
155
  version: '0'
152
156
  requirements: []
153
- rubygems_version: 4.0.21
157
+ rubygems_version: 3.5.22
158
+ signing_key:
154
159
  specification_version: 4
155
160
  summary: Client-side SDK for portage-ucp — act as the shopper's agent against any
156
161
  UCP/MCP server