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