portage-ucp 0.16.0 → 0.17.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: 0a7cd8175dc8b1acd3a884205a5df5f030e897bf1090cdf6675fd582f7c4f0ee
4
- data.tar.gz: 8acedc01a0e9dff428670248e9257647b26fb7ee54ea1e0625f67bf169c81285
3
+ metadata.gz: 6bfa07946dff1ff76a95ade848a4721727da69c8ecacd4d37c4a59bf449179e4
4
+ data.tar.gz: c23b673f2cedb83ffc2326804b96975b5d57c7737cd625bbf8465b89d02e4e70
5
5
  SHA512:
6
- metadata.gz: 6422591fbb901e8e901b4ca6b6bba5b3cd8c6e776d1dd3980536c8245598787fbe2b25d4dd193bf5a05ed87cf6c4e2ff0d7f0b93222ae2716631f23fa028b7f6
7
- data.tar.gz: 3d5928f821375dc3a71b7f9cae13864430d90b0167aebb8ca35f5ed008fe3e5617e0df0a95ada2f2db2fc962d8309eea7d852453374a81ce046f1609b9a366b7
6
+ metadata.gz: 3e53a572a3cddd068c4c653314c2348cbd3e75d014d563dc1bf6312eae84af8606364c747c1d58bd0f7accd82bb1a40199098fba4f40c03633ad8d42f4a1f443
7
+ data.tar.gz: aeda7078b20110826e45b900bcb634f8321f5b0df4edb7bb34374cabdd8da05158605757f790b917febb8f60c1b70f6fdb9e1e1d524de51b559bd63e45d6452e
data/CHANGELOG.md CHANGED
@@ -6,6 +6,11 @@ pre-1.0, so APIs may still shift between minor versions.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.17.0] - 2026-10-22
10
+
11
+ - **Changed: requires `mcp` `~> 1.8`** (was `~> 0.24`), which serves MCP 2026-07-28 through `server/discover` and keeps `initialize` for 2025-11-25 and earlier. Minor bump because the `mcp` requirement moved from 0.x to 1.x; no API change and no changes under `lib/`.
12
+ - **Added: `app.portage-ucp.order_history` extension capability with a `list_orders` MCP tool.** `Adapter#list_orders(limit:, query: nil)` returns a new `OrderList` value object (`{"orders" => [...]}` on the wire, since MCP `structuredContent` must be an object): placed orders newest first, optionally filtered by free text. Not part of the UCP spec, which reads orders only by id. `ReferenceAdapter` implements it (matching order id and line item titles). `list_orders` is read-only but `Mcp::Server` runs the authenticator and rate limiter for it (`Mcp::Server::AUTH_REQUIRED_READS`), because an open list would expose every customer's orders; an unconfigured server rejects it like a mutating tool. `ToolMetadata` documents it, points `get_order` at it, and the server instructions mention it. WebMCP's default capability filter (`dev.ucp.shopping.*` only) leaves it out of page-registered tools.
13
+
9
14
  ## [0.16.0] - 2026-10-21
10
15
 
11
16
  - **Changed: MCP tool descriptions rewritten against the TDQS rubric** (tdqs.dev, the score Glama shows). Each tool's description now names its siblings for when and when not to use it, states the auth and rate-limit behavior of state-changing tools, and says what it returns, since tools have no output schema. Parameter descriptions say where IDs come from. `Mcp::Server.build` now sends server `instructions` (`ToolMetadata::INSTRUCTIONS`, overridable with `instructions:`) that walk the catalog → cart → checkout → order flow. `tools/list` text only; tool names, schemas and annotations are unchanged.
@@ -116,6 +116,17 @@ module Portage
116
116
  # @return [Portage::Ucp::ReorderResult, nil] nil if the order isn't found.
117
117
  def reorder(order_id:, idempotency_key:) = not_implemented
118
118
 
119
+ # --- Order History (app.portage-ucp.order_history — Portage extension,
120
+ # not part of the UCP spec: dev.ucp.shopping.order has no list/search
121
+ # action as of the 2026-08-25 spec) ---
122
+ # Newest first, at most `limit`. `query` is free text an adapter matches
123
+ # however its platform searches (order number, product title, ...);
124
+ # nil lists everything. An adapter may return lighter Orders than
125
+ # #get_order (e.g. no fulfillment detail) — callers re-read one with
126
+ # #get_order for the full record.
127
+ # @return [Portage::Ucp::OrderList]
128
+ def list_orders(limit:, query: nil) = not_implemented
129
+
119
130
  # --- Discount (dev.ucp.shopping.discount) ---
120
131
  # Extends Cart/Checkout with the `discount_codes:` param above rather
121
132
  # than adding actions of its own — Capability::DISCOUNT advertises off
@@ -5,10 +5,11 @@ module Portage
5
5
  # Portage::Ucp::AuthenticationError. There is deliberately no default that
6
6
  # allows anonymous mutation — an unconfigured server rejects every
7
7
  # mutating capability call (see UnconfiguredAuthenticator below).
8
- # Mcp::Server.build calls it only for mutating tools: those whose
9
- # adapter method takes `idempotency_key:`. Every other tool (catalog
10
- # search, product, cart, checkout and order lookups) never reaches it,
11
- # and Server.build has no option to change that.
8
+ # Mcp::Server.build calls it for mutating tools (those whose adapter
9
+ # method takes `idempotency_key:`) and for the reads listed in
10
+ # Mcp::Server::AUTH_REQUIRED_READS (list_orders). Every other tool
11
+ # (catalog search, product, cart, checkout and order lookups) never
12
+ # reaches it, and Server.build has no option to change that.
12
13
  class Authenticator
13
14
  def call(_server_context)
14
15
  raise NotImplementedError, "#{self.class} must implement #call"
@@ -8,7 +8,7 @@ module Portage
8
8
  )
9
9
 
10
10
  ALL = [CATALOG, CART, CHECKOUT, ORDER, IDENTITY_LINKING, DISCOUNT, FULFILLMENT, PERMALINK,
11
- REORDER, PAYMENT_ENROLLMENT, PAYMENT_METHOD, SAVED_ADDRESS, SHOPPER_DATA].freeze
11
+ REORDER, ORDER_HISTORY, PAYMENT_ENROLLMENT, PAYMENT_METHOD, SAVED_ADDRESS, SHOPPER_DATA].freeze
12
12
  end
13
13
  end
14
14
  end
@@ -0,0 +1,17 @@
1
+ module Portage
2
+ module Ucp
3
+ module Capabilities
4
+ # Portage-only extension, not part of the UCP spec: dev.ucp.shopping.order
5
+ # only reads an order by id, with no way to find one without it. Same
6
+ # Portage-owned namespacing as Capabilities::REORDER. Mcp::Server gates
7
+ # list_orders behind the authenticator even though it reads (see
8
+ # Mcp::Server::AUTH_REQUIRED_READS) — an open list would hand any
9
+ # caller every customer's orders.
10
+ ORDER_HISTORY = Portage::Ucp::Capability.new(
11
+ name: "app.portage-ucp.order_history",
12
+ version: "1",
13
+ actions: { "list_orders" => :list_orders }
14
+ )
15
+ end
16
+ end
17
+ end
@@ -13,6 +13,12 @@ module Portage
13
13
  # doesn't need one keyword argument per collaborator.
14
14
  Context = Struct.new(:dispatcher, :authenticator, :rate_limiter, :logger, keyword_init: true)
15
15
 
16
+ # Read-only actions that still go through authorize/rate_limit, which
17
+ # otherwise run only for actions taking an idempotency_key. Listing
18
+ # orders reads, but an open list would hand any caller every
19
+ # customer's orders.
20
+ AUTH_REQUIRED_READS = %w[list_orders].freeze
21
+
16
22
  # @param journal [#record_checkout, nil] forwarded straight to
17
23
  # `Dispatcher.new` (see its own doc comment) — `nil` by default and
18
24
  # never `require`d from core (§2), same as `Dispatcher` itself.
@@ -45,7 +51,7 @@ module Portage
45
51
  required = keyword_params.select { |type, _| type == :keyreq }.map { |_, name| name.to_s }
46
52
  metadata = ToolMetadata::TOOLS[action_name]
47
53
  properties = keyword_params.to_h { |_, name| [name.to_s, property_schema(name.to_s, metadata)] }
48
- mutating = keyword_params.any? { |_, name| name == :idempotency_key }
54
+ guarded = guarded?(keyword_params, action_name)
49
55
 
50
56
  ::MCP::Tool.define(
51
57
  name: action_name,
@@ -54,10 +60,16 @@ module Portage
54
60
  annotations: metadata && metadata[:annotations]
55
61
  ) do |**kwargs|
56
62
  Portage::Ucp::Mcp::Server.call_tool(context: context, capability: capability, action_name: action_name,
57
- mutating: mutating, kwargs: kwargs)
63
+ guarded: guarded, kwargs: kwargs)
58
64
  end
59
65
  end
60
66
 
67
+ # Whether authorize/rate_limit run for this tool: every mutating action
68
+ # (it takes an idempotency_key) plus AUTH_REQUIRED_READS.
69
+ def self.guarded?(keyword_params, action_name)
70
+ keyword_params.any? { |_, name| name == :idempotency_key } || AUTH_REQUIRED_READS.include?(action_name)
71
+ end
72
+
61
73
  # @return [String] the documented description from ToolMetadata, or
62
74
  # (for an undocumented/extension action) the old namespaced
63
75
  # fallback, e.g. "dev.ucp.shopping.catalog#get_product".
@@ -77,15 +89,15 @@ module Portage
77
89
  schema
78
90
  end
79
91
 
80
- def self.call_tool(context:, capability:, action_name:, mutating:, kwargs:)
92
+ def self.call_tool(context:, capability:, action_name:, guarded:, kwargs:)
81
93
  server_context = kwargs.delete(:server_context)
82
94
  correlation_id = correlation_id_for(server_context)
83
95
  agent_profile = agent_profile_for(server_context)
84
96
  log_tool_event(context.logger, "tool_call_received", correlation_id, agent_profile,
85
97
  capability: capability.name, action: action_name)
86
98
 
87
- rejection = authorize(context.authenticator, server_context, mutating: mutating) ||
88
- rate_limit(context.rate_limiter, server_context, capability.name, mutating: mutating)
99
+ rejection = authorize(context.authenticator, server_context, guarded: guarded) ||
100
+ rate_limit(context.rate_limiter, server_context, capability.name, guarded: guarded)
89
101
  return rejection if rejection
90
102
 
91
103
  log_tool_event(context.logger, "tool_called", correlation_id, agent_profile,
@@ -134,8 +146,8 @@ module Portage
134
146
  meta && (meta["ucp-agent.profile"] || meta[:"ucp-agent.profile"])
135
147
  end
136
148
 
137
- def self.authorize(authenticator, server_context, mutating:)
138
- return unless mutating
149
+ def self.authorize(authenticator, server_context, guarded:)
150
+ return unless guarded
139
151
 
140
152
  authenticator.call(server_context)
141
153
  nil
@@ -146,8 +158,8 @@ module Portage
146
158
  # @param key [Object] whatever the consumer's RateLimiter derives an
147
159
  # identity from — the gem hands over the raw MCP server_context
148
160
  # rather than inventing its own per-session/per-key extraction (§9).
149
- def self.rate_limit(rate_limiter, key, capability_name, mutating:)
150
- return unless mutating
161
+ def self.rate_limit(rate_limiter, key, capability_name, guarded:)
162
+ return unless guarded
151
163
 
152
164
  rate_limiter.check!(key, capability_name)
153
165
  nil
@@ -25,9 +25,10 @@ module Portage
25
25
  "get_product or lookup_catalog. Either build a cart (create_cart/update_cart) " \
26
26
  "or go straight to create_checkout; a checkout never charges until " \
27
27
  "complete_checkout, which must only follow the shopper's explicit approval of " \
28
- "the exact total shown by get_checkout. After purchase, use get_order, " \
29
- "cancel_order, request_return and refund_order. Catalog reads are open; every " \
30
- "tool that changes state needs an authenticated session, and a server run " \
28
+ "the exact total shown by get_checkout. After purchase, find orders with " \
29
+ "list_orders when you have no order ID, then use get_order, cancel_order, " \
30
+ "request_return and refund_order. Catalog reads are open; list_orders and every " \
31
+ "tool that changes state need an authenticated session, and a server run " \
31
32
  "without one is read-only. Payment is a single-use token, never a card number. " \
32
33
  "Prices and stock come live from the store and can change between calls.".freeze
33
34
 
@@ -191,9 +192,27 @@ module Portage
191
192
  "fulfillment status, shipping details, totals, permalink and any " \
192
193
  "cancellations, returns or refunds applied so far. Use it to answer " \
193
194
  "\"where is my order\" or to pick line item IDs for request_return or " \
194
- "refund_order; use get_checkout for anything not yet paid. #{OPEN_NOTE} " \
195
+ "refund_order; use list_orders to find an order without its ID, and " \
196
+ "get_checkout for anything not yet paid. #{OPEN_NOTE} " \
195
197
  "An unknown ID returns an empty result rather than an error.",
196
- params: { "order_id" => "Order ID from a completed checkout. #{ID}" },
198
+ params: { "order_id" => "Order ID from a completed checkout or list_orders. #{ID}" },
199
+ annotations: { read_only_hint: true, destructive_hint: false, idempotent_hint: true }
200
+ },
201
+ "list_orders" => {
202
+ description: "List the store's placed orders, newest first, optionally filtered by a " \
203
+ "free-text query, and return up to `limit` orders, each with ID, line items, " \
204
+ "totals, currency, permalink and any cancellation, refund or return " \
205
+ "recorded. Use it to find an order when you don't hold its ID, e.g. " \
206
+ "\"what did I order last week\"; once you have the ID, use get_order for " \
207
+ "the full record with fulfillment and tracking, which list results may " \
208
+ "leave out. Read-only, but it exposes customers' orders, so it requires an " \
209
+ "authenticated session and may be rate-limited; a rejected call returns an " \
210
+ "error result. An empty list means no match, not an error.",
211
+ params: {
212
+ "query" => "Optional free-text filter, e.g. an order number, product title or customer " \
213
+ "email; how it matches depends on the store. Leave out to list all orders.",
214
+ "limit" => "Maximum number of orders to return; 5–10 suits most lookups."
215
+ },
197
216
  annotations: { read_only_hint: true, destructive_hint: false, idempotent_hint: true }
198
217
  },
199
218
  "cancel_order" => {
@@ -133,6 +133,18 @@ module Portage
133
133
 
134
134
  def get_order(order_id:) = @orders[order_id]
135
135
 
136
+ # Matches the query against the order id and line item titles.
137
+ def list_orders(limit:, query: nil)
138
+ orders = @orders.values.reverse
139
+ if query
140
+ needle = query.downcase
141
+ orders = orders.select do |order|
142
+ order.id.downcase.include?(needle) || order.line_items.any? { |li| li.item.title.downcase.include?(needle) }
143
+ end
144
+ end
145
+ Portage::Ucp::OrderList.new(orders: orders.first(limit))
146
+ end
147
+
136
148
  def cancel_order(order_id:, idempotency_key:, reason: nil)
137
149
  dedup(idempotency_key) do
138
150
  add_adjustment(order_id, type: "cancellation", status: "completed", description: reason)
@@ -580,6 +580,13 @@ module Portage
580
580
  end
581
581
  end
582
582
 
583
+ # Portage extension (see Adapter#list_orders / Capabilities::ORDER_HISTORY)
584
+ # — wraps the list in an object because MCP's structuredContent must be a
585
+ # JSON object, not a bare array.
586
+ OrderList = Data.define(:orders) do
587
+ def to_wire_h = { "orders" => orders.map(&:to_wire_h) }
588
+ end
589
+
583
590
  # Portage extension (see Adapter#reorder / Capabilities::REORDER) — no
584
591
  # schemas/ counterpart to cite, since it isn't part of the UCP spec.
585
592
  # `reason` is a short adapter-chosen string (e.g. "out_of_stock",
@@ -1,5 +1,5 @@
1
1
  module Portage
2
2
  module Ucp
3
- VERSION = "0.16.0".freeze
3
+ VERSION = "0.17.0".freeze
4
4
  end
5
5
  end
data/lib/portage/ucp.rb CHANGED
@@ -55,6 +55,7 @@ require_relative "ucp/capabilities/cart"
55
55
  require_relative "ucp/capabilities/checkout"
56
56
  require_relative "ucp/capabilities/order"
57
57
  require_relative "ucp/capabilities/reorder"
58
+ require_relative "ucp/capabilities/order_history"
58
59
  require_relative "ucp/capabilities/discount"
59
60
  require_relative "ucp/capabilities/fulfillment"
60
61
  require_relative "ucp/capabilities/permalink"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: portage-ucp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.16.0
4
+ version: 0.17.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tom Whitbread
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-10-21 00:00:00.000000000 Z
11
+ date: 2026-10-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64
@@ -72,14 +72,14 @@ dependencies:
72
72
  requirements:
73
73
  - - "~>"
74
74
  - !ruby/object:Gem::Version
75
- version: '0.24'
75
+ version: '1.8'
76
76
  type: :runtime
77
77
  prerelease: false
78
78
  version_requirements: !ruby/object:Gem::Requirement
79
79
  requirements:
80
80
  - - "~>"
81
81
  - !ruby/object:Gem::Version
82
- version: '0.24'
82
+ version: '1.8'
83
83
  - !ruby/object:Gem::Dependency
84
84
  name: rack
85
85
  requirement: !ruby/object:Gem::Requirement
@@ -191,6 +191,7 @@ files:
191
191
  - lib/portage/ucp/capabilities/fulfillment.rb
192
192
  - lib/portage/ucp/capabilities/identity_linking.rb
193
193
  - lib/portage/ucp/capabilities/order.rb
194
+ - lib/portage/ucp/capabilities/order_history.rb
194
195
  - lib/portage/ucp/capabilities/payment_enrollment.rb
195
196
  - lib/portage/ucp/capabilities/payment_method.rb
196
197
  - lib/portage/ucp/capabilities/permalink.rb