portage-ucp-client 0.3.1 → 0.4.0
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 +35 -1
- data/lib/portage/ucp/client/errors.rb +29 -3
- data/lib/portage/ucp/client/transports/http.rb +98 -1
- data/lib/portage/ucp/client/transports/loopback.rb +19 -1
- data/lib/portage/ucp/client/version.rb +1 -1
- data/lib/portage/ucp/client.rb +18 -4
- metadata +21 -10
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7d63c926220d7d3aa15f0d11d4e2bdddc27ac0ad3227ae1bec20ed1beddd471a
|
|
4
|
+
data.tar.gz: 8fc8999401c53b2cc6db65347aa1a377a20a2c9d316a94354328237c4727ec78
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bd5574b147b2552c3a304e49ec71bac10183cd20eab5a06b7df7c0aa261c36642af1d9885d5880b5d08fd2320b9211c3ecc921c849dfe20c9b39b3a7c39ce537
|
|
7
|
+
data.tar.gz: 31fa3387860b6d094399c5dd596fece5cf921e5f6b4b013f7180d37b8970f3c9b2981efd1cf37b7f8fada533439221e6c2d25e45e1078a597d13f88842a3f320
|
data/CHANGELOG.md
CHANGED
|
@@ -4,7 +4,41 @@ 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.4.0] - 2026-09-17
|
|
8
|
+
|
|
9
|
+
- Fixed `Transports::Http` sending every tool call in the flat, unwrapped
|
|
10
|
+
shape this gem's own Dispatcher/adapters speak — real UCP servers
|
|
11
|
+
(confirmed live against Shopify's 2026-08-25 rollout) reject it with a 422,
|
|
12
|
+
since they expect arguments nested under a capability key
|
|
13
|
+
(`catalog:`/`cart:`/`checkout:`) and a `meta.ucp-agent.profile` URL they
|
|
14
|
+
fetch themselves to verify the caller's identity. `Http` now builds that
|
|
15
|
+
real wire shape; `Loopback`/`Stdio` are unchanged, since the former talks
|
|
16
|
+
to this gem's own Dispatcher (still the flat shape by design) and the
|
|
17
|
+
latter has no confirmed real-world shape to fix. Callers must now pass
|
|
18
|
+
`meta: { agent_profile: <url> }`, or `MissingAgentProfileError` explains
|
|
19
|
+
what's missing instead of a bare 422.
|
|
20
|
+
- Added `MissingAgentProfileError` and `UnsupportedWireShapeError`.
|
|
21
|
+
`complete_checkout` over HTTP raises the latter rather than guessing at
|
|
22
|
+
the real payment-instrument shape (Apple Pay/Shop Pay/card-token variants
|
|
23
|
+
each have distinct required credential fields) with no way to verify it
|
|
24
|
+
against a real payment flow.
|
|
25
|
+
- Widens the `portage-ucp` dependency pin to `~> 0.8` so this gem installs
|
|
26
|
+
alongside `portage-ucp` 0.8.0 (the `~> 0.7` pin published with 0.3.3 is
|
|
27
|
+
pessimistic and excludes it).
|
|
28
|
+
|
|
29
|
+
## [0.3.3] - 2026-09-16
|
|
30
|
+
|
|
31
|
+
- No behavior change — 0.3.2 was built and pushed with `gem build` run from
|
|
32
|
+
the workspace root instead of this gem's own directory, so `spec.files =
|
|
33
|
+
Dir[...]` resolved against the wrong working directory and packaged an
|
|
34
|
+
empty gem. 0.3.2 has been yanked; 0.3.3 repackages the exact same 0.3.2
|
|
35
|
+
code correctly.
|
|
36
|
+
|
|
37
|
+
## [0.3.2] - 2026-09-16
|
|
38
|
+
|
|
39
|
+
- No behavior change — widens the `portage-ucp` dependency pin to `~> 0.7`
|
|
40
|
+
so this gem can install alongside `portage-ucp` 0.7.0 (the pessimistic
|
|
41
|
+
`~> 0.6` pin published with 0.3.1 excludes it).
|
|
8
42
|
|
|
9
43
|
## [0.3.1] - 2026-09-15
|
|
10
44
|
|
|
@@ -10,10 +10,36 @@ module Portage
|
|
|
10
10
|
# content the server returned, not a generic string.
|
|
11
11
|
class ServerError < Error; end
|
|
12
12
|
|
|
13
|
-
# Raised by .discover when the manifest can't be fetched
|
|
14
|
-
#
|
|
15
|
-
#
|
|
13
|
+
# Raised by .discover when the manifest can't be fetched at all: the URL
|
|
14
|
+
# 404s, the host refuses the connection, or the body isn't valid JSON.
|
|
15
|
+
# Indistinguishable from "this store doesn't run UCP" — callers that
|
|
16
|
+
# want to fall back silently for that case should rescue this.
|
|
16
17
|
class DiscoveryError < Error; end
|
|
18
|
+
|
|
19
|
+
# Raised by .discover when the manifest *was* fetched and parsed as
|
|
20
|
+
# JSON, but this client couldn't make sense of its shape (no `services`
|
|
21
|
+
# entry advertising an `mcp` transport). Unlike DiscoveryError, this
|
|
22
|
+
# means the store *is* running UCP — the failure is on this client's
|
|
23
|
+
# side, not the store's — so callers shouldn't treat it the same as
|
|
24
|
+
# "no native UCP support" the way a 404 does.
|
|
25
|
+
class ManifestShapeError < DiscoveryError; end
|
|
26
|
+
|
|
27
|
+
# Raised by Transports::Http when a call is made without
|
|
28
|
+
# `meta: { agent_profile: <url> }` — real UCP servers (confirmed
|
|
29
|
+
# against Shopify's 2026-08-25 rollout) fetch and verify this URL
|
|
30
|
+
# themselves to identify the calling agent, so there's no sane default
|
|
31
|
+
# to fall back to; better to fail here than pass `meta: nil` through
|
|
32
|
+
# to a 422 the server explains as `profile_unreachable`.
|
|
33
|
+
class MissingAgentProfileError < Error; end
|
|
34
|
+
|
|
35
|
+
# Raised by Transports::Http for a mutating call this gem can't yet
|
|
36
|
+
# build the real wire shape for — currently only `complete_checkout`,
|
|
37
|
+
# whose `checkout.payment.instruments` shape (Apple Pay/Shop
|
|
38
|
+
# Pay/card-token variants, each with its own required credential
|
|
39
|
+
# fields) can't be safely guessed without a real payment flow to test
|
|
40
|
+
# against. Raised instead of sending a best-effort shape that might
|
|
41
|
+
# silently misbehave with real money on the line.
|
|
42
|
+
class UnsupportedWireShapeError < Error; end
|
|
17
43
|
end
|
|
18
44
|
end
|
|
19
45
|
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require_relative "../errors"
|
|
2
|
+
|
|
1
3
|
module Portage
|
|
2
4
|
module Ucp
|
|
3
5
|
module Client
|
|
@@ -6,16 +8,111 @@ module Portage
|
|
|
6
8
|
# `mcp` gem's client half (MCP::Client + MCP::Client::HTTP). Performs
|
|
7
9
|
# the `initialize` handshake eagerly so a caller's first real call
|
|
8
10
|
# doesn't pay for it.
|
|
11
|
+
#
|
|
12
|
+
# Session's public API (query:/limit:, cart_id:/checkout_id:/
|
|
13
|
+
# order_id:, line_items: [{product_id:, quantity:}], ...) stays flat
|
|
14
|
+
# — it's shared with Loopback, which hands arguments straight to this
|
|
15
|
+
# gem's own Dispatcher/adapters, and those speak that flat shape by
|
|
16
|
+
# design (see portage-ucp/lib/portage/ucp/mcp/server.rb). Real UCP
|
|
17
|
+
# servers don't: confirmed live against Shopify's 2026-08-25 rollout,
|
|
18
|
+
# every tool nests its arguments under a capability key
|
|
19
|
+
# (catalog:/cart:/checkout:) and requires a `meta.ucp-agent.profile`
|
|
20
|
+
# URL the server itself fetches to verify the caller's identity. This
|
|
21
|
+
# transport is the one place that reshapes Session's flat arguments
|
|
22
|
+
# into that real wire format before the request goes out — Loopback
|
|
23
|
+
# and Stdio are untouched.
|
|
9
24
|
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
|
|
35
|
+
|
|
10
36
|
def initialize(url:, headers: {})
|
|
11
37
|
@client = ::MCP::Client.new(transport: ::MCP::Client::HTTP.new(url: url, headers: headers))
|
|
12
38
|
@client.connect
|
|
13
39
|
end
|
|
14
40
|
|
|
41
|
+
# `meta` here is a *property of the tool's own `arguments` object*
|
|
42
|
+
# (confirmed live against Shopify's schema — every tool's
|
|
43
|
+
# input_schema lists "meta" as a top-level sibling of
|
|
44
|
+
# "catalog"/"cart"/"checkout"), not the MCP protocol's `_meta`
|
|
45
|
+
# envelope field. `MCP::Client#call_tool`'s own `meta:` kwarg sends
|
|
46
|
+
# the latter, so it's unused here — the caller-supplied meta gets
|
|
47
|
+
# folded into `arguments["meta"]` instead.
|
|
15
48
|
def call_tool(name:, arguments:, meta: nil)
|
|
16
|
-
|
|
49
|
+
idempotency_key = arguments[:idempotency_key] || arguments["idempotency_key"]
|
|
50
|
+
wire = wire_arguments(name, arguments)
|
|
51
|
+
wire["meta"] = wire_meta(meta, idempotency_key)
|
|
52
|
+
response = @client.call_tool(name: name, arguments: wire)
|
|
17
53
|
ToolResult.extract(response, symbol_keys: false)
|
|
18
54
|
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
# `idempotency_key` arrives via `arguments` — Session#call folds it
|
|
59
|
+
# in there for every mutating action — not via `meta`, so it has to
|
|
60
|
+
# be threaded through here rather than read off `meta` directly.
|
|
61
|
+
def wire_meta(meta, idempotency_key)
|
|
62
|
+
profile = meta && (meta[:agent_profile] || meta["agent_profile"])
|
|
63
|
+
unless profile
|
|
64
|
+
raise MissingAgentProfileError,
|
|
65
|
+
"meta: { agent_profile: <url> } is required for HTTP calls — real UCP servers fetch " \
|
|
66
|
+
"and verify this URL to identify the calling agent"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
wire = { "ucp-agent" => { "profile" => profile } }
|
|
70
|
+
wire["idempotency-key"] = idempotency_key if idempotency_key
|
|
71
|
+
wire
|
|
72
|
+
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
|
|
19
116
|
end
|
|
20
117
|
end
|
|
21
118
|
end
|
|
@@ -19,10 +19,28 @@ module Portage
|
|
|
19
19
|
@next_id += 1
|
|
20
20
|
response = @server.handle(
|
|
21
21
|
{ jsonrpc: "2.0", id: @next_id, method: "tools/call",
|
|
22
|
-
params: { name: name, arguments: arguments, **(meta ? { _meta: meta } : {}) } }
|
|
22
|
+
params: { name: name, arguments: arguments, **(meta ? { _meta: wire_meta(meta) } : {}) } }
|
|
23
23
|
)
|
|
24
24
|
ToolResult.extract(response, symbol_keys: true)
|
|
25
25
|
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
# Session's transport-agnostic `meta: { agent_profile: <url> }`
|
|
30
|
+
# convention (the one `Buy#agent_meta` actually passes, regardless
|
|
31
|
+
# of which transport `session` turns out to be) has to reach
|
|
32
|
+
# `Mcp::Server.agent_profile_for`, which only ever looks at
|
|
33
|
+
# `_meta["ucp-agent.profile"]`/`_meta[:"ucp-agent.profile"]` — a
|
|
34
|
+
# different key. Without this, an own-store loopback buy silently
|
|
35
|
+
# dropped its agent_profile (no error, just a blank field in
|
|
36
|
+
# tool_call_received/tool_called events) while the real-store HTTP
|
|
37
|
+
# transport picked the same `agent_profile:` key up correctly.
|
|
38
|
+
# Merges rather than replaces so a caller already using the
|
|
39
|
+
# server's own key (as this gem's specs do) is untouched.
|
|
40
|
+
def wire_meta(meta)
|
|
41
|
+
profile = meta[:agent_profile] || meta["agent_profile"]
|
|
42
|
+
profile ? meta.merge("ucp-agent.profile" => profile) : meta
|
|
43
|
+
end
|
|
26
44
|
end
|
|
27
45
|
end
|
|
28
46
|
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,28 @@
|
|
|
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.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Tom Whitbread
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: bin
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: faraday
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '2.0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '2.0'
|
|
13
26
|
- !ruby/object:Gem::Dependency
|
|
14
27
|
name: mcp
|
|
15
28
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -30,14 +43,14 @@ dependencies:
|
|
|
30
43
|
requirements:
|
|
31
44
|
- - "~>"
|
|
32
45
|
- !ruby/object:Gem::Version
|
|
33
|
-
version: '0.
|
|
46
|
+
version: '0.8'
|
|
34
47
|
type: :runtime
|
|
35
48
|
prerelease: false
|
|
36
49
|
version_requirements: !ruby/object:Gem::Requirement
|
|
37
50
|
requirements:
|
|
38
51
|
- - "~>"
|
|
39
52
|
- !ruby/object:Gem::Version
|
|
40
|
-
version: '0.
|
|
53
|
+
version: '0.8'
|
|
41
54
|
- !ruby/object:Gem::Dependency
|
|
42
55
|
name: rspec
|
|
43
56
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -99,8 +112,8 @@ description: 'Every other portage-ucp gem lets a Ruby program expose a commerce
|
|
|
99
112
|
/.well-known/ucp manifest, or drive your own Adapter directly, and place an order
|
|
100
113
|
as the client. Three transports behind one interface (loopback over an in-process
|
|
101
114
|
Adapter, stdio, Streamable HTTP) — callers never know which they got. Depends only
|
|
102
|
-
on portage-ucp
|
|
103
|
-
|
|
115
|
+
on portage-ucp, the mcp gem''s client half, and faraday (the HTTP transport''s backend);
|
|
116
|
+
no adapter gem is a dependency.'
|
|
104
117
|
executables: []
|
|
105
118
|
extensions: []
|
|
106
119
|
extra_rdoc_files: []
|
|
@@ -123,7 +136,6 @@ metadata:
|
|
|
123
136
|
source_code_uri: https://github.com/tomtom87/Portage/tree/main/portage-ucp-client
|
|
124
137
|
changelog_uri: https://github.com/tomtom87/Portage/blob/main/portage-ucp-client/CHANGELOG.md
|
|
125
138
|
rubygems_mfa_required: 'true'
|
|
126
|
-
post_install_message:
|
|
127
139
|
rdoc_options: []
|
|
128
140
|
require_paths:
|
|
129
141
|
- lib
|
|
@@ -138,8 +150,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
138
150
|
- !ruby/object:Gem::Version
|
|
139
151
|
version: '0'
|
|
140
152
|
requirements: []
|
|
141
|
-
rubygems_version:
|
|
142
|
-
signing_key:
|
|
153
|
+
rubygems_version: 4.0.21
|
|
143
154
|
specification_version: 4
|
|
144
155
|
summary: Client-side SDK for portage-ucp — act as the shopper's agent against any
|
|
145
156
|
UCP/MCP server
|