portage-ucp 0.15.1 → 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 +8 -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 +24 -11
- data/lib/portage/ucp/mcp/tool_metadata.rb +154 -82
- 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,14 @@ 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
|
+
|
|
14
|
+
## [0.16.0] - 2026-10-21
|
|
15
|
+
|
|
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.
|
|
9
17
|
- **Added: `dev.ucp.shopping.permalink` capability** (UCP 2026-08-25). An adapter that overrides `Adapter#permalink_endpoint` (default `nil`) to return its https browser endpoint is advertised with `config.endpoint`. `Capability` takes an optional `config:` callable for the manifest entry's `config`. No MCP tool: the permalink is a browser redirect the host serves (design-log §61).
|
|
10
18
|
- **Added: policy disclosure warnings on `Cart` and `Checkout`** (UCP 2026-08-25). Each entry of `policies:` now also yields a `messages[]` warning with `code` equal to the policy `type`, `presentation: "disclosure"`, `content` from the description and `url`/`path` when the policy has them. Derived in `to_wire_h`, so every adapter gets it and nothing is stored. Orders are unchanged (design-log §62).
|
|
11
19
|
- **Added: optional `messages:` on `Cart` and `Checkout`.** An array of wire-shaped message hashes (default `[]`) an adapter can set itself. `to_wire_h` emits them before the derived policy warnings and drops a derived warning when the adapter already warned with the same `code`. `messages` is omitted from the wire when the combined list is empty (design-log §63).
|
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.
|
|
@@ -23,7 +29,8 @@ module Portage
|
|
|
23
29
|
def self.build(adapter:, registry: Portage::Ucp.configuration.registry,
|
|
24
30
|
authenticator: Portage::Ucp.configuration.authenticator,
|
|
25
31
|
rate_limiter: Portage::Ucp.configuration.rate_limiter,
|
|
26
|
-
logger: Portage::Ucp.configuration.logger, journal: nil,
|
|
32
|
+
logger: Portage::Ucp.configuration.logger, journal: nil,
|
|
33
|
+
instructions: ToolMetadata::INSTRUCTIONS, **server_opts)
|
|
27
34
|
context = Context.new(
|
|
28
35
|
dispatcher: Portage::Ucp::Dispatcher.new(adapter: adapter, registry: registry, logger: logger,
|
|
29
36
|
journal: journal),
|
|
@@ -36,7 +43,7 @@ module Portage
|
|
|
36
43
|
end
|
|
37
44
|
end
|
|
38
45
|
|
|
39
|
-
::MCP::Server.new(name: "portage-ucp", tools: tools, **server_opts)
|
|
46
|
+
::MCP::Server.new(name: "portage-ucp", tools: tools, instructions: instructions, **server_opts)
|
|
40
47
|
end
|
|
41
48
|
|
|
42
49
|
def self.build_tool(adapter:, capability:, action_name:, method_name:, context:)
|
|
@@ -44,7 +51,7 @@ module Portage
|
|
|
44
51
|
required = keyword_params.select { |type, _| type == :keyreq }.map { |_, name| name.to_s }
|
|
45
52
|
metadata = ToolMetadata::TOOLS[action_name]
|
|
46
53
|
properties = keyword_params.to_h { |_, name| [name.to_s, property_schema(name.to_s, metadata)] }
|
|
47
|
-
|
|
54
|
+
guarded = guarded?(keyword_params, action_name)
|
|
48
55
|
|
|
49
56
|
::MCP::Tool.define(
|
|
50
57
|
name: action_name,
|
|
@@ -53,10 +60,16 @@ module Portage
|
|
|
53
60
|
annotations: metadata && metadata[:annotations]
|
|
54
61
|
) do |**kwargs|
|
|
55
62
|
Portage::Ucp::Mcp::Server.call_tool(context: context, capability: capability, action_name: action_name,
|
|
56
|
-
|
|
63
|
+
guarded: guarded, kwargs: kwargs)
|
|
57
64
|
end
|
|
58
65
|
end
|
|
59
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
|
+
|
|
60
73
|
# @return [String] the documented description from ToolMetadata, or
|
|
61
74
|
# (for an undocumented/extension action) the old namespaced
|
|
62
75
|
# fallback, e.g. "dev.ucp.shopping.catalog#get_product".
|
|
@@ -76,15 +89,15 @@ module Portage
|
|
|
76
89
|
schema
|
|
77
90
|
end
|
|
78
91
|
|
|
79
|
-
def self.call_tool(context:, capability:, action_name:,
|
|
92
|
+
def self.call_tool(context:, capability:, action_name:, guarded:, kwargs:)
|
|
80
93
|
server_context = kwargs.delete(:server_context)
|
|
81
94
|
correlation_id = correlation_id_for(server_context)
|
|
82
95
|
agent_profile = agent_profile_for(server_context)
|
|
83
96
|
log_tool_event(context.logger, "tool_call_received", correlation_id, agent_profile,
|
|
84
97
|
capability: capability.name, action: action_name)
|
|
85
98
|
|
|
86
|
-
rejection = authorize(context.authenticator, server_context,
|
|
87
|
-
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)
|
|
88
101
|
return rejection if rejection
|
|
89
102
|
|
|
90
103
|
log_tool_event(context.logger, "tool_called", correlation_id, agent_profile,
|
|
@@ -133,8 +146,8 @@ module Portage
|
|
|
133
146
|
meta && (meta["ucp-agent.profile"] || meta[:"ucp-agent.profile"])
|
|
134
147
|
end
|
|
135
148
|
|
|
136
|
-
def self.authorize(authenticator, server_context,
|
|
137
|
-
return unless
|
|
149
|
+
def self.authorize(authenticator, server_context, guarded:)
|
|
150
|
+
return unless guarded
|
|
138
151
|
|
|
139
152
|
authenticator.call(server_context)
|
|
140
153
|
nil
|
|
@@ -145,8 +158,8 @@ module Portage
|
|
|
145
158
|
# @param key [Object] whatever the consumer's RateLimiter derives an
|
|
146
159
|
# identity from — the gem hands over the raw MCP server_context
|
|
147
160
|
# rather than inventing its own per-session/per-key extraction (§9).
|
|
148
|
-
def self.rate_limit(rate_limiter, key, capability_name,
|
|
149
|
-
return unless
|
|
161
|
+
def self.rate_limit(rate_limiter, key, capability_name, guarded:)
|
|
162
|
+
return unless guarded
|
|
150
163
|
|
|
151
164
|
rate_limiter.check!(key, capability_name)
|
|
152
165
|
nil
|
|
@@ -6,9 +6,9 @@ module Portage
|
|
|
6
6
|
# ::MCP::Tool. Kept separate from Capability (name/version/actions/
|
|
7
7
|
# schema — the UCP wire contract) since this is MCP-presentation only:
|
|
8
8
|
# what a calling agent reads before it decides whether and how to call
|
|
9
|
-
# a tool, not part of the UCP spec itself.
|
|
10
|
-
#
|
|
11
|
-
#
|
|
9
|
+
# a tool, not part of the UCP spec itself. Written against the TDQS
|
|
10
|
+
# rubric (tdqs.dev): purpose, when/when-not with named siblings,
|
|
11
|
+
# behavior annotations can't carry, and return shape (no output schema).
|
|
12
12
|
module ToolMetadata
|
|
13
13
|
ID = "A stable identifier returned by an earlier call (e.g. get_cart, create_checkout) " \
|
|
14
14
|
"— not something the caller makes up.".freeze
|
|
@@ -16,6 +16,21 @@ module Portage
|
|
|
16
16
|
"key returns the original result instead of repeating the effect.".freeze
|
|
17
17
|
REPLACE_NOTE = "Replaces the existing contents rather than adding to them — resend the " \
|
|
18
18
|
"full desired list with an item adjusted, added, or omitted.".freeze
|
|
19
|
+
AUTH_NOTE = "Requires an authenticated session and may be rate-limited; a rejected call " \
|
|
20
|
+
"returns an error result and changes nothing.".freeze
|
|
21
|
+
OPEN_NOTE = "Needs no authentication and is never rate-limited.".freeze
|
|
22
|
+
|
|
23
|
+
INSTRUCTIONS = "Shopping tools for one store, following the UCP lifecycle: catalog → cart → " \
|
|
24
|
+
"checkout → order. Find products with search_catalog, read them with " \
|
|
25
|
+
"get_product or lookup_catalog. Either build a cart (create_cart/update_cart) " \
|
|
26
|
+
"or go straight to create_checkout; a checkout never charges until " \
|
|
27
|
+
"complete_checkout, which must only follow the shopper's explicit approval of " \
|
|
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 " \
|
|
32
|
+
"without one is read-only. Payment is a single-use token, never a card number. " \
|
|
33
|
+
"Prices and stock come live from the store and can change between calls.".freeze
|
|
19
34
|
|
|
20
35
|
# JSON Schema `type` for parameter names whose shape is unambiguous
|
|
21
36
|
# from the name alone and constant across every action it appears
|
|
@@ -30,155 +45,212 @@ module Portage
|
|
|
30
45
|
|
|
31
46
|
TOOLS = {
|
|
32
47
|
"search_catalog" => {
|
|
33
|
-
description: "Search the store's product catalog by free-text query
|
|
34
|
-
"`limit`
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
|
|
48
|
+
description: "Search the store's product catalog by free-text query and return up to " \
|
|
49
|
+
"`limit` matching products, each with ID, title, price range, variants and " \
|
|
50
|
+
"availability. Use it to discover products when you have no product ID yet; " \
|
|
51
|
+
"once you have IDs, use get_product (one) or lookup_catalog (several) " \
|
|
52
|
+
"instead of searching again. #{OPEN_NOTE} An empty product list means no " \
|
|
53
|
+
"match, not an error; results reflect the live store and can change between calls.",
|
|
54
|
+
params: {
|
|
55
|
+
"query" => "Free-text search, e.g. a product name, brand or category (\"burton snowboard\").",
|
|
56
|
+
"limit" => "Maximum number of products to return; 5–20 suits most searches."
|
|
57
|
+
},
|
|
38
58
|
annotations: { read_only_hint: true, destructive_hint: false, idempotent_hint: true }
|
|
39
59
|
},
|
|
40
60
|
"get_product" => {
|
|
41
|
-
description: "Fetch full
|
|
42
|
-
"
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
"
|
|
61
|
+
description: "Fetch the full record for one product by ID: title, description, price " \
|
|
62
|
+
"range, every variant with its own price and availability, URL and store " \
|
|
63
|
+
"policies. Use it when you need variant-level detail for a single product " \
|
|
64
|
+
"before adding it to a cart; use lookup_catalog for several IDs at once, and " \
|
|
65
|
+
"search_catalog if you don't have an ID. #{OPEN_NOTE} An unknown ID returns " \
|
|
66
|
+
"an empty result rather than an error.",
|
|
46
67
|
params: { "product_id" => "Product ID, as returned by search_catalog or lookup_catalog." },
|
|
47
68
|
annotations: { read_only_hint: true, destructive_hint: false, idempotent_hint: true }
|
|
48
69
|
},
|
|
49
70
|
"lookup_catalog" => {
|
|
50
|
-
description: "
|
|
51
|
-
"search_catalog. Use when you already
|
|
52
|
-
"
|
|
71
|
+
description: "Fetch several products by ID in one call, returning the same product list " \
|
|
72
|
+
"shape as search_catalog. Use it when you already hold a set of IDs — e.g. to " \
|
|
73
|
+
"refresh prices for a cart or order — instead of calling get_product once per " \
|
|
74
|
+
"ID; use search_catalog when you have a query rather than IDs. #{OPEN_NOTE} " \
|
|
75
|
+
"IDs the store doesn't recognise are omitted from the result, not reported as errors.",
|
|
53
76
|
params: { "product_ids" => "Product IDs to fetch, as returned by search_catalog, a cart, or an order." },
|
|
54
77
|
annotations: { read_only_hint: true, destructive_hint: false, idempotent_hint: true }
|
|
55
78
|
},
|
|
56
79
|
"create_cart" => {
|
|
57
|
-
description: "
|
|
58
|
-
"
|
|
59
|
-
"a cart that already exists
|
|
60
|
-
"
|
|
80
|
+
description: "Create a new cart holding the given line items and return it with its ID, " \
|
|
81
|
+
"line items, currency and totals. Use it to collect items before checkout; " \
|
|
82
|
+
"to change a cart that already exists use update_cart, and to buy straight " \
|
|
83
|
+
"away without a cart use create_checkout. Nothing is reserved or charged. " \
|
|
84
|
+
"#{AUTH_NOTE}",
|
|
61
85
|
params: {
|
|
62
|
-
"line_items" => "
|
|
86
|
+
"line_items" => "The cart's starting contents, each {product_id:, quantity:} with a " \
|
|
87
|
+
"product or variant ID from the catalog tools.",
|
|
63
88
|
"idempotency_key" => IDEMPOTENCY_KEY,
|
|
64
|
-
"discount_codes" => "
|
|
89
|
+
"discount_codes" => "Optional coupon codes to apply, if the store supports them."
|
|
65
90
|
},
|
|
66
91
|
annotations: { read_only_hint: false, destructive_hint: false, idempotent_hint: true }
|
|
67
92
|
},
|
|
68
93
|
"get_cart" => {
|
|
69
|
-
description: "Fetch
|
|
70
|
-
|
|
94
|
+
description: "Fetch an existing cart by ID and return its line items, currency, applied " \
|
|
95
|
+
"discounts and current totals, recalculated against live store prices. Use " \
|
|
96
|
+
"it to show the shopper what is in the cart; it is for carts only — use " \
|
|
97
|
+
"get_checkout once a checkout exists and get_order after purchase. #{OPEN_NOTE}",
|
|
98
|
+
params: { "cart_id" => "Cart ID returned by create_cart. #{ID}" },
|
|
71
99
|
annotations: { read_only_hint: true, destructive_hint: false, idempotent_hint: true }
|
|
72
100
|
},
|
|
73
101
|
"update_cart" => {
|
|
74
|
-
description: "Replace
|
|
75
|
-
"
|
|
102
|
+
description: "Replace an existing cart's line items and discount codes, then return the " \
|
|
103
|
+
"updated cart with new totals. #{REPLACE_NOTE} Use it for every change to a " \
|
|
104
|
+
"cart — add, remove or change a quantity; use create_cart only for a new " \
|
|
105
|
+
"cart and update_checkout once the shopper has moved to checkout. " \
|
|
106
|
+
"Nothing is charged. #{AUTH_NOTE}",
|
|
76
107
|
params: {
|
|
77
|
-
"cart_id" => ID,
|
|
78
|
-
"line_items" => "The cart's complete desired contents, each {product_id:, quantity:}.
|
|
108
|
+
"cart_id" => "Cart ID returned by create_cart. #{ID}",
|
|
109
|
+
"line_items" => "The cart's complete desired contents, each {product_id:, quantity:}.",
|
|
79
110
|
"idempotency_key" => IDEMPOTENCY_KEY,
|
|
80
|
-
"discount_codes" => "
|
|
111
|
+
"discount_codes" => "Optional coupon codes, replacing those applied; leave out to keep the current ones."
|
|
81
112
|
},
|
|
82
113
|
annotations: { read_only_hint: false, destructive_hint: false, idempotent_hint: true }
|
|
83
114
|
},
|
|
84
115
|
"cancel_cart" => {
|
|
85
|
-
description: "
|
|
86
|
-
"
|
|
87
|
-
|
|
116
|
+
description: "Abandon a cart and return it in its final state; don't reuse the cart ID " \
|
|
117
|
+
"afterwards (some stores delete it, others such as Shopify let it expire). " \
|
|
118
|
+
"Use it when the shopper gives up on the cart; to just " \
|
|
119
|
+
"remove items use update_cart instead, and to abandon a checkout use " \
|
|
120
|
+
"cancel_checkout. No money is involved. #{AUTH_NOTE}",
|
|
121
|
+
params: { "cart_id" => "Cart ID returned by create_cart. #{ID}", "idempotency_key" => IDEMPOTENCY_KEY },
|
|
88
122
|
annotations: { read_only_hint: false, destructive_hint: true, idempotent_hint: true }
|
|
89
123
|
},
|
|
90
124
|
"create_checkout" => {
|
|
91
|
-
description: "
|
|
92
|
-
"
|
|
93
|
-
"shopper
|
|
94
|
-
"Does not charge
|
|
95
|
-
"the
|
|
125
|
+
description: "Open a checkout for the given line items and return it with its ID, status, " \
|
|
126
|
+
"currency, itemised totals (including tax and shipping once known) and links. " \
|
|
127
|
+
"Use it when the shopper is ready to buy; use create_cart instead if they " \
|
|
128
|
+
"are still collecting items. Does not charge — complete_checkout does that, " \
|
|
129
|
+
"only after the shopper approves the total. #{AUTH_NOTE}",
|
|
96
130
|
params: {
|
|
97
|
-
"line_items" => "Items to buy, each {product_id:, quantity:}
|
|
131
|
+
"line_items" => "Items to buy, each {product_id:, quantity:} with a product or variant ID " \
|
|
132
|
+
"from the catalog tools or a cart.",
|
|
98
133
|
"idempotency_key" => IDEMPOTENCY_KEY,
|
|
99
|
-
"discount_codes" => "
|
|
100
|
-
"fulfillment" => "
|
|
134
|
+
"discount_codes" => "Optional coupon codes to apply, if the store supports them.",
|
|
135
|
+
"fulfillment" => "Optional shipping or pickup selection; leave it out to get the " \
|
|
136
|
+
"store's available options back on the checkout."
|
|
101
137
|
},
|
|
102
138
|
annotations: { read_only_hint: false, destructive_hint: false, idempotent_hint: true }
|
|
103
139
|
},
|
|
104
140
|
"get_checkout" => {
|
|
105
|
-
description: "Fetch
|
|
106
|
-
|
|
141
|
+
description: "Fetch an existing checkout by ID and return its status, line items, " \
|
|
142
|
+
"currency and itemised totals. Use it to show the shopper the exact final " \
|
|
143
|
+
"total to approve before complete_checkout, or to check whether a completed " \
|
|
144
|
+
"checkout produced an order; use get_cart for carts and get_order for placed " \
|
|
145
|
+
"orders. #{OPEN_NOTE}",
|
|
146
|
+
params: { "checkout_id" => "Checkout ID returned by create_checkout. #{ID}" },
|
|
107
147
|
annotations: { read_only_hint: true, destructive_hint: false, idempotent_hint: true }
|
|
108
148
|
},
|
|
109
149
|
"update_checkout" => {
|
|
110
|
-
description: "Replace
|
|
111
|
-
"
|
|
150
|
+
description: "Replace an open checkout's line items, discount codes or fulfillment " \
|
|
151
|
+
"selection and return it with recalculated totals. #{REPLACE_NOTE} Use it " \
|
|
152
|
+
"before payment only; a completed checkout can't change — adjust the order " \
|
|
153
|
+
"with cancel_order or request_return instead. Does not charge, and any total " \
|
|
154
|
+
"the shopper approved earlier must be re-approved. #{AUTH_NOTE}",
|
|
112
155
|
params: {
|
|
113
|
-
"checkout_id" => ID,
|
|
114
|
-
"line_items" => "The checkout's complete desired contents, each {product_id:, quantity:}.
|
|
115
|
-
"#{REPLACE_NOTE}",
|
|
156
|
+
"checkout_id" => "Checkout ID returned by create_checkout. #{ID}",
|
|
157
|
+
"line_items" => "The checkout's complete desired contents, each {product_id:, quantity:}.",
|
|
116
158
|
"idempotency_key" => IDEMPOTENCY_KEY,
|
|
117
|
-
"discount_codes" => "
|
|
118
|
-
"fulfillment" => "
|
|
159
|
+
"discount_codes" => "Optional coupon codes, replacing those applied; leave out to keep the current ones.",
|
|
160
|
+
"fulfillment" => "Optional shipping or pickup selection, from the options listed on the checkout."
|
|
119
161
|
},
|
|
120
162
|
annotations: { read_only_hint: false, destructive_hint: false, idempotent_hint: true }
|
|
121
163
|
},
|
|
122
164
|
"complete_checkout" => {
|
|
123
|
-
description: "Charge the
|
|
124
|
-
"money.
|
|
125
|
-
"
|
|
126
|
-
"
|
|
127
|
-
"
|
|
128
|
-
"
|
|
165
|
+
description: "Charge the shopper and place the order for an open checkout — the only tool " \
|
|
166
|
+
"here that spends money. Call it once, only after the shopper has explicitly " \
|
|
167
|
+
"approved the exact total from get_checkout; never to preview a price. Before " \
|
|
168
|
+
"charging it rejects anything resembling a raw card number, enforces the " \
|
|
169
|
+
"operator's spending caps and allowlist, may require a human confirmation, " \
|
|
170
|
+
"and re-checks stock, failing without a charge if anything is off. Returns " \
|
|
171
|
+
"the checkout with status completed and the new order. #{AUTH_NOTE}",
|
|
129
172
|
params: {
|
|
130
|
-
"checkout_id" => ID,
|
|
173
|
+
"checkout_id" => "Checkout ID returned by create_checkout. #{ID}",
|
|
131
174
|
"payment_token" => "Single-use payment token from a payment handler — never a raw card number.",
|
|
132
|
-
"idempotency_key" => IDEMPOTENCY_KEY,
|
|
175
|
+
"idempotency_key" => "#{IDEMPOTENCY_KEY} Reuse it on retry so a timeout never charges twice.",
|
|
133
176
|
"mandate" => "Optional AP2 payment mandate authorizing this charge, alongside or instead " \
|
|
134
|
-
"of payment_token."
|
|
177
|
+
"of payment_token; its signature is verified before use."
|
|
135
178
|
},
|
|
136
179
|
annotations: { read_only_hint: false, destructive_hint: true, idempotent_hint: true }
|
|
137
180
|
},
|
|
138
181
|
"cancel_checkout" => {
|
|
139
|
-
description: "
|
|
140
|
-
"
|
|
141
|
-
"
|
|
142
|
-
|
|
182
|
+
description: "Abandon an open checkout before payment and return it with status canceled; " \
|
|
183
|
+
"it cannot be completed afterwards. Use it when the shopper declines the " \
|
|
184
|
+
"total; nothing has been charged yet, so no refund is needed. For a checkout " \
|
|
185
|
+
"that already completed, use cancel_order instead. #{AUTH_NOTE}",
|
|
186
|
+
params: { "checkout_id" => "Checkout ID returned by create_checkout. #{ID}",
|
|
187
|
+
"idempotency_key" => IDEMPOTENCY_KEY },
|
|
143
188
|
annotations: { read_only_hint: false, destructive_hint: true, idempotent_hint: true }
|
|
144
189
|
},
|
|
145
190
|
"get_order" => {
|
|
146
|
-
description: "Fetch a placed order
|
|
147
|
-
"
|
|
148
|
-
|
|
191
|
+
description: "Fetch a placed order by ID and return its line items with per-item " \
|
|
192
|
+
"fulfillment status, shipping details, totals, permalink and any " \
|
|
193
|
+
"cancellations, returns or refunds applied so far. Use it to answer " \
|
|
194
|
+
"\"where is my order\" or to pick line item IDs for request_return or " \
|
|
195
|
+
"refund_order; use list_orders to find an order without its ID, and " \
|
|
196
|
+
"get_checkout for anything not yet paid. #{OPEN_NOTE} " \
|
|
197
|
+
"An unknown ID returns an empty result rather than an error.",
|
|
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
|
+
},
|
|
149
216
|
annotations: { read_only_hint: true, destructive_hint: false, idempotent_hint: true }
|
|
150
217
|
},
|
|
151
218
|
"cancel_order" => {
|
|
152
|
-
description: "Cancel an
|
|
153
|
-
"
|
|
219
|
+
description: "Cancel an entire placed order and return it with the cancellation recorded. " \
|
|
220
|
+
"Use it before the order ships; for part of an order, or once it has shipped, " \
|
|
221
|
+
"use request_return. Whether a refund follows automatically depends on the " \
|
|
222
|
+
"store — check the returned order's adjustments. Cannot be undone. #{AUTH_NOTE}",
|
|
154
223
|
params: {
|
|
155
|
-
"order_id" => ID, "idempotency_key" => IDEMPOTENCY_KEY,
|
|
156
|
-
"reason" => "Optional free-text
|
|
224
|
+
"order_id" => "Order ID from a completed checkout. #{ID}", "idempotency_key" => IDEMPOTENCY_KEY,
|
|
225
|
+
"reason" => "Optional free-text note for the merchant, e.g. \"ordered by mistake\"."
|
|
157
226
|
},
|
|
158
227
|
annotations: { read_only_hint: false, destructive_hint: true, idempotent_hint: true }
|
|
159
228
|
},
|
|
160
229
|
"request_return" => {
|
|
161
|
-
description: "
|
|
162
|
-
"
|
|
163
|
-
"
|
|
164
|
-
"
|
|
230
|
+
description: "File a return request for some or all line items of a placed order and " \
|
|
231
|
+
"return the order with the request recorded as pending. It only asks — no " \
|
|
232
|
+
"money moves until the merchant approves and a refund is issued with " \
|
|
233
|
+
"refund_order. Use it for delivered items; use cancel_order for an order " \
|
|
234
|
+
"that hasn't shipped. #{AUTH_NOTE}",
|
|
165
235
|
params: {
|
|
166
|
-
"order_id" => ID,
|
|
167
|
-
"line_items" => "Items to return, each {id:, quantity:}.",
|
|
236
|
+
"order_id" => "Order ID from a completed checkout. #{ID}",
|
|
237
|
+
"line_items" => "Items to return, each {id:, quantity:} using the order line item IDs from get_order.",
|
|
168
238
|
"idempotency_key" => IDEMPOTENCY_KEY,
|
|
169
|
-
"reason" => "Optional free-text reason for the
|
|
239
|
+
"reason" => "Optional free-text reason for the merchant, e.g. \"wrong size\"."
|
|
170
240
|
},
|
|
171
241
|
annotations: { read_only_hint: false, destructive_hint: false, idempotent_hint: true }
|
|
172
242
|
},
|
|
173
243
|
"refund_order" => {
|
|
174
|
-
description: "Refund
|
|
175
|
-
"
|
|
176
|
-
"
|
|
244
|
+
description: "Refund some or all line items of a placed order back to the original " \
|
|
245
|
+
"payment method and return the order with the refund recorded. Money moves " \
|
|
246
|
+
"and it cannot be reversed. Use it once a refund is agreed; if the store " \
|
|
247
|
+
"needs the goods back first, call request_return before this. To call off " \
|
|
248
|
+
"a whole unshipped order, use cancel_order. #{AUTH_NOTE}",
|
|
177
249
|
params: {
|
|
178
|
-
"order_id" => ID,
|
|
179
|
-
"line_items" => "Items to refund, each {id:, quantity:}.",
|
|
250
|
+
"order_id" => "Order ID from a completed checkout. #{ID}",
|
|
251
|
+
"line_items" => "Items to refund, each {id:, quantity:} using the order line item IDs from get_order.",
|
|
180
252
|
"idempotency_key" => IDEMPOTENCY_KEY,
|
|
181
|
-
"reason" => "Optional free-text reason for the
|
|
253
|
+
"reason" => "Optional free-text reason for the merchant."
|
|
182
254
|
},
|
|
183
255
|
annotations: { read_only_hint: false, destructive_hint: true, idempotent_hint: true }
|
|
184
256
|
}
|
|
@@ -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
|