portage-ucp-client 0.4.0 → 0.6.2

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: 89837fec9676ad52dd925e44ea86b12df99ae2f01ea56eccd4b877bbde7dfb7a
4
+ data.tar.gz: 34e0f987ba86fb975eaed185950cc6c2690c3f177ae8eacbbdb718560be4fb5e
5
5
  SHA512:
6
- metadata.gz: bd5574b147b2552c3a304e49ec71bac10183cd20eab5a06b7df7c0aa261c36642af1d9885d5880b5d08fd2320b9211c3ecc921c849dfe20c9b39b3a7c39ce537
7
- data.tar.gz: 31fa3387860b6d094399c5dd596fece5cf921e5f6b4b013f7180d37b8970f3c9b2981efd1cf37b7f8fada533439221e6c2d25e45e1078a597d13f88842a3f320
6
+ metadata.gz: 99581702b0ff34ed5a8fd680ffac05a05576823a6381bf312b63436107fb55977be865f7649be32827b3503b28465e3a862df9730058fe7ba4033179acd28505
7
+ data.tar.gz: db7d14a2a8149ca8b526ab4fc0ee6f6f807cad84b83eb57229879659e75e5d13207f96faada575354da1981fb78bc2a1de72edc98c38405a81a5efc3ddcfa7e1
data/CHANGELOG.md CHANGED
@@ -4,6 +4,80 @@ 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.2] - 2026-09-23
8
+
9
+ - Fix: the loopback and stdio transports dropped `cart_id` from every call,
10
+ so `get_cart`, `update_cart` and `cancel_cart` always failed with
11
+ "Missing required arguments: cart_id". `cart_id` is a real-UCP wire
12
+ argument only on `create_checkout` (the cart-to-checkout conversion), and
13
+ now only that call drops it. The shared rule is
14
+ `Transports::LocalArguments`.
15
+ - The UCP wire reshaping in `Transports::Http` moved to
16
+ `Transports::UcpWireShape`, so other transports can build the same body.
17
+ `portage-ucp-webmcp` uses it for UCP-shaped page tools. There is no
18
+ behavior change for `Http`.
19
+
20
+ ## [0.6.1] - 2026-09-22
21
+
22
+ - `ServerError` carries the parsed error document on `#payload` when the
23
+ server's text content is JSON, with `#summary`, `#continue_url` and
24
+ `#server_messages` readers over it. A real UCP server answers an
25
+ out-of-stock `create_cart` with its whole `ucp` envelope — every
26
+ capability, every payment handler — wrapped around a two-word
27
+ `messages[].content` of "Sold out", so `#message` alone is several
28
+ kilobytes of JSON and unusable by anything that has to show it to a
29
+ person. `#message` is unchanged; the readers are additive, and return
30
+ nil/`[]` for a refusal whose text isn't JSON.
31
+
32
+ ## [0.6.0] - 2026-09-22
33
+
34
+ - `Transports::Http` now builds `checkout.payment.instruments[]` for
35
+ `complete_checkout` against the card handler (`dev.shopify.card`) instead
36
+ of unconditionally raising `UnsupportedWireShapeError`. Top level carries
37
+ `id`/`checkout`; the instrument carries `id`/`handler_id`/`type`/
38
+ `credential: { token:, type: }`, per the schema pulled live from
39
+ `tools/list` 2026-09-22. `credential.type` (`dev.shopify.card_token`)
40
+ follows the payment-handler-guide's reverse-DNS-plus-`_token` convention
41
+ but is **not confirmed against a live response** — `complete_checkout` has
42
+ never actually been called (no grant to test against; see
43
+ `docs/ucp-tool-gating-investigation.md`). A `handler_id:` other than the
44
+ card handler still raises `UnsupportedWireShapeError`, now naming the
45
+ handler.
46
+ - `Session#complete_checkout` takes optional `handler_id:`/`credential_type:`
47
+ to override the above; both are dropped by `Transports::{Loopback,Stdio}`
48
+ same as `context:`/`cart_id:`, since neither means anything to an Adapter
49
+ method signature.
50
+ - New `Client::PaymentPermissionError`, distinct from `ServerError` (a
51
+ malformed/declined request) and `UnsupportedWireShapeError` (a handler
52
+ this client can't build a request for). `Transports::Http` raises it when
53
+ a `complete_checkout` refusal looks permission-shaped — going by the
54
+ *pattern* of Shopify's other confirmed permission error
55
+ (`get_order`'s `"You are forbidden to make tools/call requests"`) and the
56
+ community-thread description of what's gated on `complete_checkout`
57
+ (checkout-completion permission on the token, the merchant's channel
58
+ enabled). This pattern match is also unconfirmed live.
59
+
60
+ ## [0.5.0] - 2026-09-22
61
+
62
+ - `Session#search_catalog`/`#get_product`/`#lookup_catalog`/`#create_cart`/
63
+ `#update_cart`/`#create_checkout`/`#update_checkout` take a `context:` — the
64
+ UCP `context` object (`address_country`, `address_region`, `postal_code`,
65
+ `currency`, `language`) — and `Transports::Http` nests it under the
66
+ capability key. This reads as optional and isn't: a store resolves which
67
+ market, and so which publication and inventory, a call is scoped to from it.
68
+ A cart built without one comes back with `line_items: []`, zeroed totals and
69
+ a `merchandise_out_of_stock` warning naming a product the same store's
70
+ `search_catalog` returned as `available` seconds earlier — confirmed live
71
+ 2026-09-22. It failed open, with a plausible wrong answer instead of an
72
+ error.
73
+ - `Session#create_checkout` takes a `cart_id:`, converting an existing cart
74
+ into a checkout. `line_items:` stays required: `checkout.cart_id`'s own
75
+ schema says a cart id alone is enough, and the live server rejects that with
76
+ `missing required properties: line_items`, so `Transports::Http` sends both.
77
+ - `Transports::Loopback` and `Transports::Stdio` drop `context`/`cart_id`
78
+ before dispatch. Both hand arguments to this gem's own server, which splats
79
+ them into an Adapter method signature that has no such keywords.
80
+
7
81
  ## [0.4.0] - 2026-09-17
8
82
 
9
83
  - 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 "ucp_wire_shape"
2
3
 
3
4
  module Portage
4
5
  module Ucp
@@ -22,16 +23,7 @@ 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
25
- # Actions whose one identifying argument becomes a top-level `id`.
26
- ID_ARG = {
27
- "get_cart" => :cart_id, "cancel_cart" => :cart_id,
28
- "get_checkout" => :checkout_id, "cancel_checkout" => :checkout_id,
29
- "get_order" => :order_id
30
- }.freeze
31
-
32
- CATALOG_ACTIONS = %w[get_product lookup_catalog search_catalog].freeze
33
- CART_ACTIONS = %w[create_cart update_cart].freeze
34
- CHECKOUT_ACTIONS = %w[create_checkout update_checkout].freeze
26
+ include UcpWireShape
35
27
 
36
28
  def initialize(url:, headers: {})
37
29
  @client = ::MCP::Client.new(transport: ::MCP::Client::HTTP.new(url: url, headers: headers))
@@ -51,6 +43,10 @@ module Portage
51
43
  wire["meta"] = wire_meta(meta, idempotency_key)
52
44
  response = @client.call_tool(name: name, arguments: wire)
53
45
  ToolResult.extract(response, symbol_keys: false)
46
+ rescue ServerError, MCP::Client::RequestHandlerError => e
47
+ raise permission_error(e) if name == "complete_checkout" && permission_refusal?(e.message)
48
+
49
+ raise
54
50
  end
55
51
 
56
52
  private
@@ -70,49 +66,6 @@ module Portage
70
66
  wire["idempotency-key"] = idempotency_key if idempotency_key
71
67
  wire
72
68
  end
73
-
74
- def wire_arguments(name, arguments)
75
- arguments = arguments.dup
76
- arguments.delete(:idempotency_key)
77
-
78
- return { "id" => arguments.fetch(ID_ARG[name]) } if ID_ARG.key?(name)
79
- return catalog_body(name, arguments) if CATALOG_ACTIONS.include?(name)
80
- return wrap_line_items("cart", arguments, id_key: :cart_id) if CART_ACTIONS.include?(name)
81
- return wrap_line_items("checkout", arguments, id_key: :checkout_id) if CHECKOUT_ACTIONS.include?(name)
82
-
83
- raise_unsupported_payment_shape if name == "complete_checkout"
84
-
85
- arguments
86
- end
87
-
88
- 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
94
- end
95
-
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"
101
- end
102
-
103
- def search_catalog_body(arguments)
104
- { "query" => arguments[:query], "pagination" => { "limit" => arguments[:limit] }.compact }.compact
105
- end
106
-
107
- def wrap_line_items(wrapper, arguments, id_key:)
108
- body = { "line_items" => Array(arguments[:line_items]).map { |li| wire_line_item(li) } }
109
- { wrapper => body }.tap { |h| h["id"] = arguments[id_key] if arguments[id_key] }
110
- end
111
-
112
- def wire_line_item(line_item)
113
- { "item" => { "id" => line_item[:product_id] || line_item["product_id"] },
114
- "quantity" => line_item[:quantity] || line_item["quantity"] }
115
- end
116
69
  end
117
70
  end
118
71
  end
@@ -0,0 +1,30 @@
1
+ module Portage
2
+ module Ucp
3
+ module Client
4
+ module Transports
5
+ # Session's arguments trimmed to what this gem's own flat-argument
6
+ # server (Portage::Ucp::Mcp::Server -> Dispatcher -> Adapter) takes.
7
+ # `context`/`cart_id`/`handler_id`/`credential_type` are real-UCP
8
+ # wire concerns Session offers for Transports::Http to nest into a
9
+ # request body; the Adapter method signatures have no such keywords,
10
+ # so passing them on would be an ArgumentError on every call.
11
+ #
12
+ # `cart_id` is the exception that needs care: it is only a wire
13
+ # concern on create_checkout (the cart->checkout conversion). On
14
+ # get_cart/update_cart/cancel_cart it *is* the Adapter's own keyword,
15
+ # and dropping it there made every cart read/update/cancel fail with
16
+ # "Missing required arguments: cart_id".
17
+ module LocalArguments
18
+ REMOTE_WIRE_ARGUMENTS = %i[context cart_id handler_id credential_type].freeze
19
+ CART_ID_IS_WIRE_ONLY = %w[create_checkout].freeze
20
+
21
+ def self.strip(name, arguments)
22
+ remote = REMOTE_WIRE_ARGUMENTS
23
+ remote -= [:cart_id] unless CART_ID_IS_WIRE_ONLY.include?(name.to_s)
24
+ arguments.except(*remote)
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -15,8 +15,15 @@ module Portage
15
15
  @next_id = 0
16
16
  end
17
17
 
18
+ # Real-UCP wire concerns this transport drops before they reach the
19
+ # Adapter — see LocalArguments. Dropped here rather than branched on
20
+ # in Session, so each transport keeps owning which arguments it
21
+ # understands.
22
+ REMOTE_WIRE_ARGUMENTS = LocalArguments::REMOTE_WIRE_ARGUMENTS
23
+
18
24
  def call_tool(name:, arguments:, meta: nil)
19
25
  @next_id += 1
26
+ arguments = LocalArguments.strip(name, arguments)
20
27
  response = @server.handle(
21
28
  { jsonrpc: "2.0", id: @next_id, method: "tools/call",
22
29
  params: { name: name, arguments: arguments, **(meta ? { _meta: wire_meta(meta) } : {}) } }
@@ -12,8 +12,14 @@ module Portage
12
12
  @client.connect
13
13
  end
14
14
 
15
+ # Real-UCP wire concerns this transport drops before they reach the
16
+ # Adapter — see LocalArguments. Dropped here rather than branched on
17
+ # in Session, so each transport keeps owning which arguments it
18
+ # understands.
19
+ REMOTE_WIRE_ARGUMENTS = LocalArguments::REMOTE_WIRE_ARGUMENTS
20
+
15
21
  def call_tool(name:, arguments:, meta: nil)
16
- response = @client.call_tool(name: name, arguments: arguments, meta: meta)
22
+ response = @client.call_tool(name: name, arguments: LocalArguments.strip(name, arguments), meta: meta)
17
23
  ToolResult.extract(response, symbol_keys: false)
18
24
  end
19
25
  end
@@ -0,0 +1,121 @@
1
+ require_relative "http/complete_checkout_wire_shape"
2
+
3
+ module Portage
4
+ module Ucp
5
+ module Client
6
+ module Transports
7
+ # Session's flat arguments reshaped into the real UCP wire format —
8
+ # every tool nests its arguments under a capability key
9
+ # (catalog:/cart:/checkout:), confirmed live against Shopify's
10
+ # 2026-08-25 rollout (see Transports::Http). Split out of Http so any
11
+ # transport that reaches a UCP-shaped tool surface (Streamable HTTP
12
+ # today, a page's WebMCP tools in portage-ucp-webmcp) builds the same
13
+ # body from the same code rather than a second copy drifting from it.
14
+ # Private instance methods of the including transport, as before.
15
+ module UcpWireShape
16
+ include Http::CompleteCheckoutWireShape
17
+
18
+ # Actions whose one identifying argument becomes a top-level `id`.
19
+ ID_ARG = {
20
+ "get_cart" => :cart_id, "cancel_cart" => :cart_id,
21
+ "get_checkout" => :checkout_id, "cancel_checkout" => :checkout_id,
22
+ "get_order" => :order_id
23
+ }.freeze
24
+
25
+ CATALOG_ACTIONS = %w[get_product lookup_catalog search_catalog].freeze
26
+ CART_ACTIONS = %w[create_cart update_cart].freeze
27
+ CHECKOUT_ACTIONS = %w[create_checkout update_checkout].freeze
28
+
29
+ private
30
+
31
+ def wire_arguments(name, arguments)
32
+ idempotency_key = arguments[:idempotency_key] || arguments["idempotency_key"]
33
+ arguments = arguments.dup
34
+ arguments.delete(:idempotency_key)
35
+
36
+ return complete_checkout_body(arguments, idempotency_key) if name == "complete_checkout"
37
+ return { "id" => arguments.fetch(ID_ARG[name]) } if ID_ARG.key?(name)
38
+ return catalog_body(name, arguments) if CATALOG_ACTIONS.include?(name)
39
+ return wrap_line_items("cart", arguments, id_key: :cart_id) if CART_ACTIONS.include?(name)
40
+ return wrap_line_items("checkout", arguments, id_key: :checkout_id) if CHECKOUT_ACTIONS.include?(name)
41
+
42
+ arguments
43
+ end
44
+
45
+ def catalog_body(name, arguments)
46
+ body = case name
47
+ when "get_product" then { "id" => arguments.fetch(:product_id) }
48
+ when "lookup_catalog" then { "ids" => arguments.fetch(:product_ids) }
49
+ when "search_catalog" then search_catalog_body(arguments)
50
+ end
51
+ { "catalog" => with_context(body, arguments) }
52
+ end
53
+
54
+ # `context` carries the buyer's locale/currency/region hints the UCP
55
+ # `context` object is specified for. It looks optional and isn't:
56
+ # Shopify resolves which market (and therefore which publication and
57
+ # inventory) a call is scoped to from it, and a cart built without it
58
+ # comes back with `line_items: []`, zeroed totals, and a
59
+ # `merchandise_out_of_stock` warning naming a product `search_catalog`
60
+ # had just reported as `availability.available == true` on the same
61
+ # store. Confirmed live 2026-09-22: identical `create_cart`, context
62
+ # added, returns the line item at its real price.
63
+ #
64
+ # A sweep of nine third-party Shopify stores the same day found the
65
+ # empty cart is only the loudest of three outcomes for an omitted
66
+ # context. Three stores built a correct cart without one; one
67
+ # emptied it; five priced it in the market of the *caller's IP*
68
+ # (a run from Bangkok got THB totals from stores whose shoppers had
69
+ # asked for nothing of the sort). So an omitted context doesn't
70
+ # merely degrade results — it produces a valid-shaped cart at the
71
+ # wrong currency, with nothing on the response saying so, which is
72
+ # the harder failure to notice of the two.
73
+ #
74
+ # Sending one is necessary, not sufficient: the cart is scoped to
75
+ # the market the context names, so a product the store doesn't
76
+ # publish into that market drops out with the same
77
+ # `merchandise_out_of_stock` message (mejuri.com, `US`/`USD` and
78
+ # `GB`/`GBP` empty, `CA`/`CAD` fine). That store's `search_catalog`
79
+ # ignored the context entirely and quoted CAD at
80
+ # `available: true` for every market asked, so the search result
81
+ # gives a caller no warning. Nothing to fix here — it's the store's
82
+ # market config — but callers shouldn't read this code as a
83
+ # guarantee that a context makes carts work.
84
+ def with_context(body, arguments)
85
+ context = arguments[:context] || arguments["context"]
86
+ return body if context.nil? || context.empty?
87
+
88
+ body.merge("context" => stringify(context))
89
+ end
90
+
91
+ def stringify(context)
92
+ context.to_h { |key, value| [key.to_s, value] }
93
+ end
94
+
95
+ def search_catalog_body(arguments)
96
+ { "query" => arguments[:query], "pagination" => { "limit" => arguments[:limit] }.compact }.compact
97
+ end
98
+
99
+ # `cart_id` on a checkout body is the cart→checkout conversion the
100
+ # spec's `checkout.cart_id` describes ("the business uses cart
101
+ # contents and ignores overlapping fields"). Its schema says
102
+ # `cart_id` alone is enough; the live server disagrees and rejects
103
+ # that with `Invalid arguments: object at '/checkout' is missing
104
+ # required properties: line_items` (confirmed live 2026-09-22), so
105
+ # the line items go out alongside it rather than instead of it.
106
+ def wrap_line_items(wrapper, arguments, id_key:)
107
+ body = { "line_items" => Array(arguments[:line_items]).map { |li| wire_line_item(li) } }
108
+ body["cart_id"] = arguments[:cart_id] if wrapper == "checkout" && arguments[:cart_id]
109
+ body = with_context(body, arguments)
110
+ { wrapper => body }.tap { |h| h["id"] = arguments[id_key] if arguments[id_key] }
111
+ end
112
+
113
+ def wire_line_item(line_item)
114
+ { "item" => { "id" => line_item[:product_id] || line_item["product_id"] },
115
+ "quantity" => line_item[:quantity] || line_item["quantity"] }
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
121
+ 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.2".freeze
5
5
  end
6
6
  end
7
7
  end
@@ -8,6 +8,7 @@ require_relative "client/version"
8
8
  require_relative "client/errors"
9
9
  require_relative "client/tool_result"
10
10
  require_relative "client/session"
11
+ require_relative "client/transports/local_arguments"
11
12
  require_relative "client/transports/loopback"
12
13
  require_relative "client/transports/stdio"
13
14
  require_relative "client/transports/http"
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.2
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-23 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,8 +128,11 @@ 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
132
+ - lib/portage/ucp/client/transports/local_arguments.rb
129
133
  - lib/portage/ucp/client/transports/loopback.rb
130
134
  - lib/portage/ucp/client/transports/stdio.rb
135
+ - lib/portage/ucp/client/transports/ucp_wire_shape.rb
131
136
  - lib/portage/ucp/client/version.rb
132
137
  homepage: https://github.com/tomtom87/Portage/tree/main/portage-ucp-client
133
138
  licenses:
@@ -136,6 +141,7 @@ metadata:
136
141
  source_code_uri: https://github.com/tomtom87/Portage/tree/main/portage-ucp-client
137
142
  changelog_uri: https://github.com/tomtom87/Portage/blob/main/portage-ucp-client/CHANGELOG.md
138
143
  rubygems_mfa_required: 'true'
144
+ post_install_message:
139
145
  rdoc_options: []
140
146
  require_paths:
141
147
  - lib
@@ -150,7 +156,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
150
156
  - !ruby/object:Gem::Version
151
157
  version: '0'
152
158
  requirements: []
153
- rubygems_version: 4.0.21
159
+ rubygems_version: 3.5.22
160
+ signing_key:
154
161
  specification_version: 4
155
162
  summary: Client-side SDK for portage-ucp — act as the shopper's agent against any
156
163
  UCP/MCP server