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 +4 -4
- data/CHANGELOG.md +5 -0
- data/lib/portage/ucp/adapter.rb +11 -0
- data/lib/portage/ucp/authenticator.rb +5 -4
- data/lib/portage/ucp/capabilities/identity_linking.rb +1 -1
- data/lib/portage/ucp/capabilities/order_history.rb +17 -0
- data/lib/portage/ucp/mcp/server.rb +21 -9
- data/lib/portage/ucp/mcp/tool_metadata.rb +24 -5
- data/lib/portage/ucp/reference_adapter.rb +12 -0
- data/lib/portage/ucp/value_objects.rb +7 -0
- data/lib/portage/ucp/version.rb +1 -1
- data/lib/portage/ucp.rb +1 -0
- metadata +5 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6bfa07946dff1ff76a95ade848a4721727da69c8ecacd4d37c4a59bf449179e4
|
|
4
|
+
data.tar.gz: c23b673f2cedb83ffc2326804b96975b5d57c7737cd625bbf8465b89d02e4e70
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
data/lib/portage/ucp/adapter.rb
CHANGED
|
@@ -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
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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:,
|
|
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,
|
|
88
|
-
rate_limit(context.rate_limiter, server_context, capability.name,
|
|
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,
|
|
138
|
-
return unless
|
|
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,
|
|
150
|
-
return unless
|
|
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,
|
|
29
|
-
"
|
|
30
|
-
"
|
|
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
|
|
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",
|
data/lib/portage/ucp/version.rb
CHANGED
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.
|
|
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-
|
|
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: '
|
|
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: '
|
|
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
|