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 +4 -4
- data/CHANGELOG.md +61 -0
- data/lib/portage/ucp/client/errors.rb +57 -1
- data/lib/portage/ucp/client/session.rb +43 -13
- data/lib/portage/ucp/client/tool_result.rb +18 -1
- data/lib/portage/ucp/client/transports/http/complete_checkout_wire_shape.rb +86 -0
- data/lib/portage/ucp/client/transports/http.rb +63 -12
- data/lib/portage/ucp/client/transports/loopback.rb +11 -0
- data/lib/portage/ucp/client/transports/stdio.rb +11 -1
- data/lib/portage/ucp/client/version.rb +1 -1
- metadata +8 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8eeeb1c006e06155791f086b9dbfce78562ecb4281412c18dbdf5794a4189c08
|
|
4
|
+
data.tar.gz: 6be190c6cb00f34b0d0978b522da1e912f3a0b33973b6eb813db72859869b491
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
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:,
|
|
47
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
97
|
-
|
|
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
|
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
|
+
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:
|
|
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:
|
|
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
|