portage-ucp-client 0.6.1 → 0.6.3

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: 8eeeb1c006e06155791f086b9dbfce78562ecb4281412c18dbdf5794a4189c08
4
- data.tar.gz: 6be190c6cb00f34b0d0978b522da1e912f3a0b33973b6eb813db72859869b491
3
+ metadata.gz: d79c922ef93a60d8ae293284eb680bb4db51aba09be905ccdd61c0f756723ef5
4
+ data.tar.gz: f133375b2803b101f0c1bf5e9a70fa88cb6bc7c0126277e303eb909b4fff59aa
5
5
  SHA512:
6
- metadata.gz: c3ce4e8717a67bce1e3614677ec6b850ac58f6a598402debc78371b3a11fd3ebfde1d876375d6ed588d7ad9a7cde8a5a2806e982f2c5ab9ea8c14c72136b6651
7
- data.tar.gz: a559406993fc8f3d9bb95f59df96e8fd70a6a45672675a9255e84857b1e36778993a1be498ca710ad8c60cb27cc27da3d48a4d2d8d3065550ee1254b141d2569
6
+ metadata.gz: a849432b3f19bfb560d54a7bf5d122dafefc55476a98d93708fed59b6f274362bcbff2c7f1799f833f2237e25991d06064b7bf0481a7c29faa7bd7e7a0e84b79
7
+ data.tar.gz: ee79d83b07f4da1678839b0006de2156112f22327c24e9750bf6c63bf6ef48971e46b9d1e73cc9f136ba11ffa3f4f3fb741b092c6dfb9001b6c69ff6228a492e
data/CHANGELOG.md CHANGED
@@ -4,6 +4,33 @@ 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.3] - 2026-09-24
8
+
9
+ - `Client.connect(url:, proxy:)` and `Client.discover(url, proxy:)` take a
10
+ `proxy:` option (a URL string, Hash or `URI`), passed straight to
11
+ Faraday's own `proxy=` on the Streamable HTTP transport. Faraday already
12
+ reads the env proxy vars itself; this adds an explicit override.
13
+ - Every store-facing request now identifies itself: `Client::USER_AGENT`
14
+ (`"portage-ucp-client/<ver> (+https://github.com/tomtom87/Portage)"`) is
15
+ sent on the manifest GET and every UCP transport call, via
16
+ `Client.with_user_agent(headers)`. A caller's own `User-Agent` header
17
+ still wins (case-insensitive). `discover(url, headers: {})` now forwards
18
+ `headers` to both the manifest GET and the transport it builds.
19
+ `Transports::Http` merges the UA into its `MCP::Client::HTTP` headers.
20
+
21
+ ## [0.6.2] - 2026-09-23
22
+
23
+ - Fix: the loopback and stdio transports dropped `cart_id` from every call,
24
+ so `get_cart`, `update_cart` and `cancel_cart` always failed with
25
+ "Missing required arguments: cart_id". `cart_id` is a real-UCP wire
26
+ argument only on `create_checkout` (the cart-to-checkout conversion), and
27
+ now only that call drops it. The shared rule is
28
+ `Transports::LocalArguments`.
29
+ - The UCP wire reshaping in `Transports::Http` moved to
30
+ `Transports::UcpWireShape`, so other transports can build the same body.
31
+ `portage-ucp-webmcp` uses it for UCP-shaped page tools. There is no
32
+ behavior change for `Http`.
33
+
7
34
  ## [0.6.1] - 2026-09-22
8
35
 
9
36
  - `ServerError` carries the parsed error document on `#payload` when the
@@ -1,5 +1,5 @@
1
1
  require_relative "../errors"
2
- require_relative "http/complete_checkout_wire_shape"
2
+ require_relative "ucp_wire_shape"
3
3
 
4
4
  module Portage
5
5
  module Ucp
@@ -23,21 +23,22 @@ module Portage
23
23
  # into that real wire format before the request goes out — Loopback
24
24
  # and Stdio are untouched.
25
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
-
39
- def initialize(url:, headers: {})
40
- @client = ::MCP::Client.new(transport: ::MCP::Client::HTTP.new(url: url, headers: headers))
26
+ include UcpWireShape
27
+
28
+ # @param proxy [String, Hash, URI, nil] docs/plans/proxy-support.md
29
+ # Phase 1's "lighter touch" for this gem: Faraday already reads
30
+ # the env proxy vars itself (`Faraday.ignore_env_proxy` is
31
+ # false) and accepts a `proxy:` connection option, so this just
32
+ # wires that option through rather than reimplementing Faraday's
33
+ # own proxy handling. Accepts anything
34
+ # `Faraday::Connection#proxy=`/`Faraday::ProxyOptions.from`
35
+ # accepts — a bare URL string is the common case.
36
+ def initialize(url:, headers: {}, proxy: nil)
37
+ @client = ::MCP::Client.new(
38
+ transport: ::MCP::Client::HTTP.new(url: url, headers: Client.with_user_agent(headers)) do |faraday|
39
+ faraday.proxy = proxy if proxy
40
+ end
41
+ )
41
42
  @client.connect
42
43
  end
43
44
 
@@ -77,93 +78,6 @@ module Portage
77
78
  wire["idempotency-key"] = idempotency_key if idempotency_key
78
79
  wire
79
80
  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"] }
166
- end
167
81
  end
168
82
  end
169
83
  end
@@ -0,0 +1,30 @@
1
+ module Portage
2
+ module Ucp
3
+ module Client
4
+ module Transports
5
+ # Session's arguments trimmed to what this gem's own flat-argument
6
+ # server (Portage::Ucp::Mcp::Server -> Dispatcher -> Adapter) takes.
7
+ # `context`/`cart_id`/`handler_id`/`credential_type` are real-UCP
8
+ # wire concerns Session offers for Transports::Http to nest into a
9
+ # request body; the Adapter method signatures have no such keywords,
10
+ # so passing them on would be an ArgumentError on every call.
11
+ #
12
+ # `cart_id` is the exception that needs care: it is only a wire
13
+ # concern on create_checkout (the cart->checkout conversion). On
14
+ # get_cart/update_cart/cancel_cart it *is* the Adapter's own keyword,
15
+ # and dropping it there made every cart read/update/cancel fail with
16
+ # "Missing required arguments: cart_id".
17
+ module LocalArguments
18
+ REMOTE_WIRE_ARGUMENTS = %i[context cart_id handler_id credential_type].freeze
19
+ CART_ID_IS_WIRE_ONLY = %w[create_checkout].freeze
20
+
21
+ def self.strip(name, arguments)
22
+ remote = REMOTE_WIRE_ARGUMENTS
23
+ remote -= [:cart_id] unless CART_ID_IS_WIRE_ONLY.include?(name.to_s)
24
+ arguments.except(*remote)
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -15,19 +15,15 @@ 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
18
+ # Real-UCP wire concerns this transport drops before they reach the
19
+ # Adapter — see LocalArguments. Dropped here rather than branched on
20
+ # in Session, so each transport keeps owning which arguments it
25
21
  # understands.
26
- REMOTE_WIRE_ARGUMENTS = %i[context cart_id handler_id credential_type].freeze
22
+ REMOTE_WIRE_ARGUMENTS = LocalArguments::REMOTE_WIRE_ARGUMENTS
27
23
 
28
24
  def call_tool(name:, arguments:, meta: nil)
29
25
  @next_id += 1
30
- arguments = arguments.except(*REMOTE_WIRE_ARGUMENTS)
26
+ arguments = LocalArguments.strip(name, arguments)
31
27
  response = @server.handle(
32
28
  { jsonrpc: "2.0", id: @next_id, method: "tools/call",
33
29
  params: { name: name, arguments: arguments, **(meta ? { _meta: wire_meta(meta) } : {}) } }
@@ -12,18 +12,14 @@ 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
15
+ # Real-UCP wire concerns this transport drops before they reach the
16
+ # Adapter — see LocalArguments. Dropped here rather than branched on
17
+ # in Session, so each transport keeps owning which arguments it
22
18
  # understands.
23
- REMOTE_WIRE_ARGUMENTS = %i[context cart_id handler_id credential_type].freeze
19
+ REMOTE_WIRE_ARGUMENTS = LocalArguments::REMOTE_WIRE_ARGUMENTS
24
20
 
25
21
  def call_tool(name:, arguments:, meta: nil)
26
- response = @client.call_tool(name: name, arguments: arguments.except(*REMOTE_WIRE_ARGUMENTS), meta: meta)
22
+ response = @client.call_tool(name: name, arguments: LocalArguments.strip(name, arguments), meta: meta)
27
23
  ToolResult.extract(response, symbol_keys: false)
28
24
  end
29
25
  end
@@ -0,0 +1,121 @@
1
+ require_relative "http/complete_checkout_wire_shape"
2
+
3
+ module Portage
4
+ module Ucp
5
+ module Client
6
+ module Transports
7
+ # Session's flat arguments reshaped into the real UCP wire format —
8
+ # every tool nests its arguments under a capability key
9
+ # (catalog:/cart:/checkout:), confirmed live against Shopify's
10
+ # 2026-08-25 rollout (see Transports::Http). Split out of Http so any
11
+ # transport that reaches a UCP-shaped tool surface (Streamable HTTP
12
+ # today, a page's WebMCP tools in portage-ucp-webmcp) builds the same
13
+ # body from the same code rather than a second copy drifting from it.
14
+ # Private instance methods of the including transport, as before.
15
+ module UcpWireShape
16
+ include Http::CompleteCheckoutWireShape
17
+
18
+ # Actions whose one identifying argument becomes a top-level `id`.
19
+ ID_ARG = {
20
+ "get_cart" => :cart_id, "cancel_cart" => :cart_id,
21
+ "get_checkout" => :checkout_id, "cancel_checkout" => :checkout_id,
22
+ "get_order" => :order_id
23
+ }.freeze
24
+
25
+ CATALOG_ACTIONS = %w[get_product lookup_catalog search_catalog].freeze
26
+ CART_ACTIONS = %w[create_cart update_cart].freeze
27
+ CHECKOUT_ACTIONS = %w[create_checkout update_checkout].freeze
28
+
29
+ private
30
+
31
+ def wire_arguments(name, arguments)
32
+ idempotency_key = arguments[:idempotency_key] || arguments["idempotency_key"]
33
+ arguments = arguments.dup
34
+ arguments.delete(:idempotency_key)
35
+
36
+ return complete_checkout_body(arguments, idempotency_key) if name == "complete_checkout"
37
+ return { "id" => arguments.fetch(ID_ARG[name]) } if ID_ARG.key?(name)
38
+ return catalog_body(name, arguments) if CATALOG_ACTIONS.include?(name)
39
+ return wrap_line_items("cart", arguments, id_key: :cart_id) if CART_ACTIONS.include?(name)
40
+ return wrap_line_items("checkout", arguments, id_key: :checkout_id) if CHECKOUT_ACTIONS.include?(name)
41
+
42
+ arguments
43
+ end
44
+
45
+ def catalog_body(name, arguments)
46
+ body = case name
47
+ when "get_product" then { "id" => arguments.fetch(:product_id) }
48
+ when "lookup_catalog" then { "ids" => arguments.fetch(:product_ids) }
49
+ when "search_catalog" then search_catalog_body(arguments)
50
+ end
51
+ { "catalog" => with_context(body, arguments) }
52
+ end
53
+
54
+ # `context` carries the buyer's locale/currency/region hints the UCP
55
+ # `context` object is specified for. It looks optional and isn't:
56
+ # Shopify resolves which market (and therefore which publication and
57
+ # inventory) a call is scoped to from it, and a cart built without it
58
+ # comes back with `line_items: []`, zeroed totals, and a
59
+ # `merchandise_out_of_stock` warning naming a product `search_catalog`
60
+ # had just reported as `availability.available == true` on the same
61
+ # store. Confirmed live 2026-09-22: identical `create_cart`, context
62
+ # added, returns the line item at its real price.
63
+ #
64
+ # A sweep of nine third-party Shopify stores the same day found the
65
+ # empty cart is only the loudest of three outcomes for an omitted
66
+ # context. Three stores built a correct cart without one; one
67
+ # emptied it; five priced it in the market of the *caller's IP*
68
+ # (a run from Bangkok got THB totals from stores whose shoppers had
69
+ # asked for nothing of the sort). So an omitted context doesn't
70
+ # merely degrade results — it produces a valid-shaped cart at the
71
+ # wrong currency, with nothing on the response saying so, which is
72
+ # the harder failure to notice of the two.
73
+ #
74
+ # Sending one is necessary, not sufficient: the cart is scoped to
75
+ # the market the context names, so a product the store doesn't
76
+ # publish into that market drops out with the same
77
+ # `merchandise_out_of_stock` message (mejuri.com, `US`/`USD` and
78
+ # `GB`/`GBP` empty, `CA`/`CAD` fine). That store's `search_catalog`
79
+ # ignored the context entirely and quoted CAD at
80
+ # `available: true` for every market asked, so the search result
81
+ # gives a caller no warning. Nothing to fix here — it's the store's
82
+ # market config — but callers shouldn't read this code as a
83
+ # guarantee that a context makes carts work.
84
+ def with_context(body, arguments)
85
+ context = arguments[:context] || arguments["context"]
86
+ return body if context.nil? || context.empty?
87
+
88
+ body.merge("context" => stringify(context))
89
+ end
90
+
91
+ def stringify(context)
92
+ context.to_h { |key, value| [key.to_s, value] }
93
+ end
94
+
95
+ def search_catalog_body(arguments)
96
+ { "query" => arguments[:query], "pagination" => { "limit" => arguments[:limit] }.compact }.compact
97
+ end
98
+
99
+ # `cart_id` on a checkout body is the cart→checkout conversion the
100
+ # spec's `checkout.cart_id` describes ("the business uses cart
101
+ # contents and ignores overlapping fields"). Its schema says
102
+ # `cart_id` alone is enough; the live server disagrees and rejects
103
+ # that with `Invalid arguments: object at '/checkout' is missing
104
+ # required properties: line_items` (confirmed live 2026-09-22), so
105
+ # the line items go out alongside it rather than instead of it.
106
+ def wrap_line_items(wrapper, arguments, id_key:)
107
+ body = { "line_items" => Array(arguments[:line_items]).map { |li| wire_line_item(li) } }
108
+ body["cart_id"] = arguments[:cart_id] if wrapper == "checkout" && arguments[:cart_id]
109
+ body = with_context(body, arguments)
110
+ { wrapper => body }.tap { |h| h["id"] = arguments[id_key] if arguments[id_key] }
111
+ end
112
+
113
+ def wire_line_item(line_item)
114
+ { "item" => { "id" => line_item[:product_id] || line_item["product_id"] },
115
+ "quantity" => line_item[:quantity] || line_item["quantity"] }
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -1,7 +1,7 @@
1
1
  module Portage
2
2
  module Ucp
3
3
  module Client
4
- VERSION = "0.6.1".freeze
4
+ VERSION = "0.6.3".freeze
5
5
  end
6
6
  end
7
7
  end
@@ -8,6 +8,7 @@ require_relative "client/version"
8
8
  require_relative "client/errors"
9
9
  require_relative "client/tool_result"
10
10
  require_relative "client/session"
11
+ require_relative "client/transports/local_arguments"
11
12
  require_relative "client/transports/loopback"
12
13
  require_relative "client/transports/stdio"
13
14
  require_relative "client/transports/http"
@@ -21,6 +22,11 @@ module Portage
21
22
  # Adapter directly, and place an order.
22
23
  module Client
23
24
  MANIFEST_PATH = "/.well-known/ucp".freeze
25
+ # Sent on every request this gem makes to a store (the manifest GET and
26
+ # each Streamable HTTP call) unless the caller's own headers name one,
27
+ # so a merchant reading its logs can tell a Portage agent from Ruby's
28
+ # or Faraday's default and find out what it is.
29
+ USER_AGENT = "portage-ucp-client/#{VERSION} (+https://github.com/tomtom87/Portage)".freeze
24
30
 
25
31
  # Wraps an already-built Adapter directly — no subprocess/socket. Still
26
32
  # runs the real merchant-side contract (authenticator, rate limiter,
@@ -34,11 +40,13 @@ module Portage
34
40
 
35
41
  # Connects over stdio (a subprocess) or Streamable HTTP (a URL) — pass
36
42
  # exactly one of `command:` or `url:`.
37
- def self.connect(command: nil, args: [], env: nil, url: nil, headers: {}, capabilities: nil)
43
+ # @param proxy [String, Hash, nil] forwarded to Transports::Http (a
44
+ # `url:` connection only) — see its own doc comment.
45
+ def self.connect(command: nil, args: [], env: nil, url: nil, headers: {}, capabilities: nil, proxy: nil)
38
46
  transport = if command
39
47
  Transports::Stdio.new(command: command, args: args, env: env)
40
48
  elsif url
41
- Transports::Http.new(url: url, headers: headers)
49
+ Transports::Http.new(url: url, headers: headers, proxy: proxy)
42
50
  else
43
51
  raise ArgumentError, "connect requires either command: or url:"
44
52
  end
@@ -50,14 +58,19 @@ module Portage
50
58
  # Portage::Ucp::Manifest#services — the core-gem fix this client
51
59
  # depends on to know where to connect).
52
60
  # @return [Session] scoped to the manifest's advertised capabilities.
53
- def self.discover(url)
54
- manifest = fetch_manifest(url)
55
- connect(url: mcp_endpoint(manifest), capabilities: capability_names(manifest))
61
+ # @param headers [Hash{String => String}] sent with the manifest GET
62
+ # and every call after it — e.g. a "User-Agent" naming the app
63
+ # built on this gem.
64
+ # @param proxy [String, Hash, nil] forwarded to the Streamable HTTP
65
+ # connection this discovers into — see Transports::Http.
66
+ def self.discover(url, headers: {}, proxy: nil)
67
+ manifest = fetch_manifest(url, headers)
68
+ connect(url: mcp_endpoint(manifest), headers: headers, capabilities: capability_names(manifest), proxy: proxy)
56
69
  end
57
70
 
58
- def self.fetch_manifest(url)
71
+ def self.fetch_manifest(url, headers = {})
59
72
  uri = URI.parse("#{url.to_s.sub(%r{/\z}, '')}#{MANIFEST_PATH}")
60
- response = Net::HTTP.get_response(uri)
73
+ response = Net::HTTP.get_response(uri, with_user_agent(headers))
61
74
  raise DiscoveryError, "GET #{uri} returned #{response.code}" unless response.is_a?(Net::HTTPSuccess)
62
75
 
63
76
  JSON.parse(response.body)
@@ -70,6 +83,13 @@ module Portage
70
83
  end
71
84
  private_class_method :fetch_manifest
72
85
 
86
+ # USER_AGENT unless `headers` already names one, in any case.
87
+ def self.with_user_agent(headers)
88
+ return headers if headers.keys.any? { |name| name.to_s.casecmp?("user-agent") }
89
+
90
+ { "User-Agent" => USER_AGENT }.merge(headers)
91
+ end
92
+
73
93
  # Real UCP manifests (confirmed live on Casper, Allbirds, Glossier, and
74
94
  # 34+ other Shopify UCP rollouts as of "2026-08-25") nest everything one
75
95
  # level deeper under a "ucp" key. This gem's own server side
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: portage-ucp-client
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.1
4
+ version: 0.6.3
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-22 00:00:00.000000000 Z
11
+ date: 2026-09-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -129,8 +129,10 @@ files:
129
129
  - lib/portage/ucp/client/tool_result.rb
130
130
  - lib/portage/ucp/client/transports/http.rb
131
131
  - lib/portage/ucp/client/transports/http/complete_checkout_wire_shape.rb
132
+ - lib/portage/ucp/client/transports/local_arguments.rb
132
133
  - lib/portage/ucp/client/transports/loopback.rb
133
134
  - lib/portage/ucp/client/transports/stdio.rb
135
+ - lib/portage/ucp/client/transports/ucp_wire_shape.rb
134
136
  - lib/portage/ucp/client/version.rb
135
137
  homepage: https://github.com/tomtom87/Portage/tree/main/portage-ucp-client
136
138
  licenses: