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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9f50b4b88bf8e387e11a7540f15ebef6a3a9d3b46fcb124497cdacca936ae555
4
- data.tar.gz: 031ed26c26c706695ffdae1faff8f7d3a33d02cc0eafb1f8091a851af2a4a7af
3
+ metadata.gz: 7d63c926220d7d3aa15f0d11d4e2bdddc27ac0ad3227ae1bec20ed1beddd471a
4
+ data.tar.gz: 8fc8999401c53b2cc6db65347aa1a377a20a2c9d316a94354328237c4727ec78
5
5
  SHA512:
6
- metadata.gz: 9824791a9f0fddf499773fdc0560a85dfd84cfb0071dc180b3b86d2a33d1ed2e1e05cfd9f59cb4737fe416ee26e5b72e2a65b1bfaf9898c8322e3caf149540f4
7
- data.tar.gz: 5e805a953352027e67e5c803cf3f1f6bd8129c5bbf9f26e807b18bb06e926420d8aae59ac6e425532c511e444772a6a909bdd4a48c92ea86a6a9474d3f387d55
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
- ## [Unreleased]
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/parsed, or has
14
- # no `services` entry for the mcp transport to connect to (see
15
- # Portage::Ucp::Manifest#services the core-gem fix this depends on).
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
- response = @client.call_tool(name: name, arguments: arguments, meta: meta)
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
@@ -1,7 +1,7 @@
1
1
  module Portage
2
2
  module Ucp
3
3
  module Client
4
- VERSION = "0.3.1".freeze
4
+ VERSION = "0.4.0".freeze
5
5
  end
6
6
  end
7
7
  end
@@ -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
- service = Array(manifest["services"]).find { |s| s["transport"] == "mcp" }
75
- endpoint = service && service["endpoint"]
76
- raise DiscoveryError, "manifest has no mcp service entry to connect to" unless endpoint
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
- Array(manifest["capabilities"]).map { |c| c["name"] }
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.3.1
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: 2026-09-15 00:00:00.000000000 Z
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.6'
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.6'
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 + the mcp gem''s client half; no adapter gem is a dependency.'
103
- email:
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: 3.5.22
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