portage-ucp 0.15.0 → 0.15.1
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 +9 -0
- data/lib/portage/ucp/adapter.rb +10 -0
- data/lib/portage/ucp/capabilities/identity_linking.rb +2 -2
- data/lib/portage/ucp/capabilities/permalink.rb +19 -0
- data/lib/portage/ucp/capability.rb +8 -1
- data/lib/portage/ucp/dispatcher.rb +9 -0
- data/lib/portage/ucp/manifest.rb +2 -0
- data/lib/portage/ucp/mcp/server.rb +24 -3
- data/lib/portage/ucp/mcp/tool_metadata.rb +189 -0
- data/lib/portage/ucp/support/order_ledger.rb +45 -0
- data/lib/portage/ucp/value_objects.rb +45 -4
- data/lib/portage/ucp/version.rb +1 -1
- data/lib/portage/ucp.rb +2 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f99f8f55e4bbfeb276c93c8f1c7e63d36b3f30661702303d1295103e2ea4993c
|
|
4
|
+
data.tar.gz: 1c21857f2b52165c30994ba40bab0d8522520bfe8d7bb50b7459898630b71647
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c406f740bebf4a83c4eb9184d62e6abc693478ba97b0ab51cd4549bcc8996addd658b84f437f488ffffafa03782be6b2ea464b05057545091380b86b02066f7a
|
|
7
|
+
data.tar.gz: 6acd7187aad41ebae15d667ebc150e6896e1f0f72b2f86211d173bd77a25a26d1cc0119d7a9375329c688d13423a655244c43b04537a30aa9a3c851c12c4efca
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,15 @@ pre-1.0, so APIs may still shift between minor versions.
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
- **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
|
+
- **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
|
+
- **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).
|
|
12
|
+
- **Added: post-purchase adjustments on the order ledger** (order-ledger plan Phase 3). `Dispatcher` writes the `adjustments` of the `Order` returned by `cancel_order`, `refund_order` or `request_return` onto that order's existing ledger record; line items, totals and other purchase facts are never rewritten. `OrderLedger#refresh(order_id, adapter:)` re-fetches with `get_order` and replaces the stored adjustments the same way, for disputes and returns that settle later. `OrderLedger#unresolved` lists every `pending` adjustment with its order id. An order with no snapshot is skipped, not backfilled. A ledger write failure raises only after the adapter call has succeeded, as the Phase 1 snapshot does (design-log §64).
|
|
13
|
+
|
|
14
|
+
## [0.15.1] - 2026-10-18
|
|
15
|
+
|
|
16
|
+
- **Fixed: generated MCP tools carry a real description, per-parameter description, and `readOnlyHint`/`destructiveHint`/`idempotentHint` annotations**, instead of `Mcp::Server.build_tool` emitting the bare `capability#action` identifier as the description and an empty schema for every parameter. New `Mcp::ToolMetadata` supplies this per action, sourced from the `@param`/`@return` docs already on `Adapter`'s methods; an action without an entry still falls back to the old `capability#action` format. `Adapter`, the wire format and every adapter gem are unchanged — this only changes what a calling agent reads in `tools/list`. Patch-level.
|
|
17
|
+
|
|
9
18
|
## [0.15.0] - 2026-10-13
|
|
10
19
|
|
|
11
20
|
- **Added: optional `policies:` on `Cart`, `Checkout`, `Order`, `Product`, `CatalogSearchResult` and `ProductDetail`** (UCP 2026-08-25 `policies[]`). An array of `policy.json` hashes (`type` and `description` required; `applies_to` and `url` optional), default `[]`, omitted from the wire payload when empty. `portage-ucp-shopify` 0.7.0 supplies it (design-log §60).
|
data/lib/portage/ucp/adapter.rb
CHANGED
|
@@ -132,6 +132,16 @@ module Portage
|
|
|
132
132
|
# @return [Boolean]
|
|
133
133
|
def fulfillment_supported? = false
|
|
134
134
|
|
|
135
|
+
# --- Permalink (dev.ucp.shopping.permalink) ---
|
|
136
|
+
# The business's browser endpoint for shopping permalinks, e.g.
|
|
137
|
+
# "https://merchant.example/buy" (absolute https, no query, fragment or
|
|
138
|
+
# trailing slash). The endpoint itself is a redirect route the
|
|
139
|
+
# business's storefront serves, not an MCP action, so this is a
|
|
140
|
+
# config-carrying predicate: nil (the default) leaves the capability
|
|
141
|
+
# out, a String advertises it with `config.endpoint`.
|
|
142
|
+
# @return [String, nil]
|
|
143
|
+
def permalink_endpoint = nil
|
|
144
|
+
|
|
135
145
|
# --- Identity Linking (dev.ucp.shopping.identity, OAuth 2.0) ---
|
|
136
146
|
# @return [Portage::Ucp::Identity] linked profile for an exchanged OAuth token
|
|
137
147
|
def link_identity(oauth_token:) = not_implemented
|
|
@@ -7,8 +7,8 @@ module Portage
|
|
|
7
7
|
actions: { "link_identity" => :link_identity }
|
|
8
8
|
)
|
|
9
9
|
|
|
10
|
-
ALL = [CATALOG, CART, CHECKOUT, ORDER, IDENTITY_LINKING, DISCOUNT, FULFILLMENT,
|
|
11
|
-
PAYMENT_ENROLLMENT, PAYMENT_METHOD, SAVED_ADDRESS, SHOPPER_DATA].freeze
|
|
10
|
+
ALL = [CATALOG, CART, CHECKOUT, ORDER, IDENTITY_LINKING, DISCOUNT, FULFILLMENT, PERMALINK,
|
|
11
|
+
REORDER, PAYMENT_ENROLLMENT, PAYMENT_METHOD, SAVED_ADDRESS, SHOPPER_DATA].freeze
|
|
12
12
|
end
|
|
13
13
|
end
|
|
14
14
|
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Ucp
|
|
3
|
+
module Capabilities
|
|
4
|
+
# schemas/shopping/permalink.json — a browser redirect endpoint the
|
|
5
|
+
# business serves (services/shopping/permalink.openapi.json), not an
|
|
6
|
+
# MCP action, so `actions` is empty and advertisement runs off
|
|
7
|
+
# Adapter#permalink_endpoint, whose value is the required
|
|
8
|
+
# `config.endpoint`.
|
|
9
|
+
PERMALINK = Portage::Ucp::Capability.new(
|
|
10
|
+
name: "dev.ucp.shopping.permalink",
|
|
11
|
+
version: "1",
|
|
12
|
+
schema: "shopping/permalink.json",
|
|
13
|
+
actions: {},
|
|
14
|
+
predicate: :permalink_endpoint,
|
|
15
|
+
config: ->(adapter) { { endpoint: adapter.permalink_endpoint } }
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -15,7 +15,11 @@ module Portage
|
|
|
15
15
|
# `schema:` is the capability's schema path under a UCP revision's
|
|
16
16
|
# `schemas/` (e.g. "shopping/checkout.json"); the manifest turns it into
|
|
17
17
|
# the `schema` URL 2026-08-25 business profiles require.
|
|
18
|
-
|
|
18
|
+
#
|
|
19
|
+
# `config:` is a callable taking the adapter and returning the `config`
|
|
20
|
+
# object the manifest entry carries (permalink's `{endpoint:}`).
|
|
21
|
+
def initialize(name:, version:, actions:, predicate: nil, schema: nil, config: nil)
|
|
22
|
+
@config = config
|
|
19
23
|
@name = name
|
|
20
24
|
@schema = schema
|
|
21
25
|
@version = version
|
|
@@ -32,6 +36,9 @@ module Portage
|
|
|
32
36
|
actions.values.any? { |method_name| overridden?(adapter, method_name) }
|
|
33
37
|
end
|
|
34
38
|
|
|
39
|
+
# @return [Hash, nil] the manifest entry's `config`, if the capability has one
|
|
40
|
+
def config_for(adapter) = @config&.call(adapter)
|
|
41
|
+
|
|
35
42
|
private
|
|
36
43
|
|
|
37
44
|
def overridden?(adapter, method_name)
|
|
@@ -9,6 +9,10 @@ module Portage
|
|
|
9
9
|
# the only `dev.ucp.shopping.checkout` action that dispatches a charge.
|
|
10
10
|
PAYMENT_COMPLETING_ACTION = "complete_checkout".freeze
|
|
11
11
|
|
|
12
|
+
# Each returns the updated Order whose adjustments land on the order
|
|
13
|
+
# ledger (docs/plans/order-ledger.md Phase 3).
|
|
14
|
+
ORDER_ADJUSTING_ACTIONS = %w[cancel_order refund_order request_return].freeze
|
|
15
|
+
|
|
12
16
|
# @param shop [String, nil] identifies which store this Dispatcher
|
|
13
17
|
# instance is completing charges for, threaded straight onto the
|
|
14
18
|
# transaction log record — Dispatcher/Adapter have no shared notion
|
|
@@ -92,6 +96,11 @@ module Portage
|
|
|
92
96
|
else
|
|
93
97
|
call_adapter(method_name, arguments, correlation_id)
|
|
94
98
|
end
|
|
99
|
+
# Same after-the-adapter posture as the Phase 1 snapshot in
|
|
100
|
+
# #call_and_log_transaction: the cancel/refund/return has already
|
|
101
|
+
# happened upstream, so a ledger write failure surfaces only after
|
|
102
|
+
# it — never instead of it, never retried or rolled back here.
|
|
103
|
+
@order_ledger.record_adjustments(result) if result && ORDER_ADJUSTING_ACTIONS.include?(action)
|
|
95
104
|
validate_outbound_boundaries!(result)
|
|
96
105
|
wrap(capability, result)
|
|
97
106
|
end
|
data/lib/portage/ucp/manifest.rb
CHANGED
|
@@ -62,6 +62,8 @@ module Portage
|
|
|
62
62
|
@registry.advertised(@adapter).to_h do |capability|
|
|
63
63
|
entry = { version: UCP_VERSION }
|
|
64
64
|
entry[:schema] = "https://ucp.dev/#{UCP_VERSION}/schemas/#{capability.schema}" if capability.schema
|
|
65
|
+
config = capability.config_for(@adapter)
|
|
66
|
+
entry[:config] = config if config
|
|
65
67
|
[capability.name, [entry]]
|
|
66
68
|
end
|
|
67
69
|
end
|
|
@@ -42,19 +42,40 @@ module Portage
|
|
|
42
42
|
def self.build_tool(adapter:, capability:, action_name:, method_name:, context:)
|
|
43
43
|
keyword_params = adapter.method(method_name).parameters.select { |type, _| %i[key keyreq].include?(type) }
|
|
44
44
|
required = keyword_params.select { |type, _| type == :keyreq }.map { |_, name| name.to_s }
|
|
45
|
-
|
|
45
|
+
metadata = ToolMetadata::TOOLS[action_name]
|
|
46
|
+
properties = keyword_params.to_h { |_, name| [name.to_s, property_schema(name.to_s, metadata)] }
|
|
46
47
|
mutating = keyword_params.any? { |_, name| name == :idempotency_key }
|
|
47
48
|
|
|
48
49
|
::MCP::Tool.define(
|
|
49
50
|
name: action_name,
|
|
50
|
-
description:
|
|
51
|
-
input_schema: { type: "object", properties: properties, required: required }
|
|
51
|
+
description: description_for(capability, action_name, metadata),
|
|
52
|
+
input_schema: { type: "object", properties: properties, required: required },
|
|
53
|
+
annotations: metadata && metadata[:annotations]
|
|
52
54
|
) do |**kwargs|
|
|
53
55
|
Portage::Ucp::Mcp::Server.call_tool(context: context, capability: capability, action_name: action_name,
|
|
54
56
|
mutating: mutating, kwargs: kwargs)
|
|
55
57
|
end
|
|
56
58
|
end
|
|
57
59
|
|
|
60
|
+
# @return [String] the documented description from ToolMetadata, or
|
|
61
|
+
# (for an undocumented/extension action) the old namespaced
|
|
62
|
+
# fallback, e.g. "dev.ucp.shopping.catalog#get_product".
|
|
63
|
+
def self.description_for(capability, action_name, metadata)
|
|
64
|
+
metadata ? metadata[:description] : "#{capability.name}##{action_name}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @return [Hash] a property's JSON Schema entry: a description from
|
|
68
|
+
# ToolMetadata when the action has an entry (none for an
|
|
69
|
+
# undocumented/extension action — see `metadata&.fetch` above), plus
|
|
70
|
+
# a `type` where ToolMetadata::PARAM_TYPES pins one down for this
|
|
71
|
+
# param name.
|
|
72
|
+
def self.property_schema(name, metadata)
|
|
73
|
+
schema = {}
|
|
74
|
+
schema[:description] = metadata[:params][name] if metadata && metadata[:params][name]
|
|
75
|
+
schema[:type] = ToolMetadata::PARAM_TYPES[name] if ToolMetadata::PARAM_TYPES[name]
|
|
76
|
+
schema
|
|
77
|
+
end
|
|
78
|
+
|
|
58
79
|
def self.call_tool(context:, capability:, action_name:, mutating:, kwargs:)
|
|
59
80
|
server_context = kwargs.delete(:server_context)
|
|
60
81
|
correlation_id = correlation_id_for(server_context)
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Ucp
|
|
3
|
+
module Mcp
|
|
4
|
+
# Per-action tool metadata — description, parameter descriptions, and
|
|
5
|
+
# MCP annotations — for Server.build_tool to attach to each generated
|
|
6
|
+
# ::MCP::Tool. Kept separate from Capability (name/version/actions/
|
|
7
|
+
# schema — the UCP wire contract) since this is MCP-presentation only:
|
|
8
|
+
# what a calling agent reads before it decides whether and how to call
|
|
9
|
+
# a tool, not part of the UCP spec itself. Content mirrors the @param/
|
|
10
|
+
# @return docs already on Adapter's methods (adapter.rb) rather than
|
|
11
|
+
# inventing anything new.
|
|
12
|
+
module ToolMetadata
|
|
13
|
+
ID = "A stable identifier returned by an earlier call (e.g. get_cart, create_checkout) " \
|
|
14
|
+
"— not something the caller makes up.".freeze
|
|
15
|
+
IDEMPOTENCY_KEY = "Caller-generated key that dedupes retries: calling again with the same " \
|
|
16
|
+
"key returns the original result instead of repeating the effect.".freeze
|
|
17
|
+
REPLACE_NOTE = "Replaces the existing contents rather than adding to them — resend the " \
|
|
18
|
+
"full desired list with an item adjusted, added, or omitted.".freeze
|
|
19
|
+
|
|
20
|
+
# JSON Schema `type` for parameter names whose shape is unambiguous
|
|
21
|
+
# from the name alone and constant across every action it appears
|
|
22
|
+
# in; left out entirely (no `type`) where it genuinely varies.
|
|
23
|
+
PARAM_TYPES = {
|
|
24
|
+
"query" => "string", "limit" => "integer", "product_id" => "string",
|
|
25
|
+
"product_ids" => "array", "cart_id" => "string", "checkout_id" => "string",
|
|
26
|
+
"order_id" => "string", "idempotency_key" => "string", "payment_token" => "string",
|
|
27
|
+
"reason" => "string", "line_items" => "array", "discount_codes" => "array",
|
|
28
|
+
"fulfillment" => "object", "mandate" => "object"
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
TOOLS = {
|
|
32
|
+
"search_catalog" => {
|
|
33
|
+
description: "Search the store's product catalog by free-text query, returning up to " \
|
|
34
|
+
"`limit` matches with price and availability. Use this to discover products " \
|
|
35
|
+
"when you don't yet have an ID; once you do, use get_product for one or " \
|
|
36
|
+
"lookup_catalog for several — neither re-runs a search.",
|
|
37
|
+
params: { "query" => "Free-text search query.", "limit" => "Maximum number of results to return." },
|
|
38
|
+
annotations: { read_only_hint: true, destructive_hint: false, idempotent_hint: true }
|
|
39
|
+
},
|
|
40
|
+
"get_product" => {
|
|
41
|
+
description: "Fetch full details — price, availability, and whatever else the store's " \
|
|
42
|
+
"catalog carries — for one product by ID. Returns not-found rather than " \
|
|
43
|
+
"raising if the ID doesn't exist. Use search_catalog first if you don't " \
|
|
44
|
+
"already have the ID; use lookup_catalog instead for several known IDs, to " \
|
|
45
|
+
"avoid one call per product.",
|
|
46
|
+
params: { "product_id" => "Product ID, as returned by search_catalog or lookup_catalog." },
|
|
47
|
+
annotations: { read_only_hint: true, destructive_hint: false, idempotent_hint: true }
|
|
48
|
+
},
|
|
49
|
+
"lookup_catalog" => {
|
|
50
|
+
description: "Batch-fetch several products by ID in one call, same result shape as " \
|
|
51
|
+
"search_catalog. Use when you already have a set of product IDs — e.g. " \
|
|
52
|
+
"hydrating a cart or order — instead of calling get_product once per ID.",
|
|
53
|
+
params: { "product_ids" => "Product IDs to fetch, as returned by search_catalog, a cart, or an order." },
|
|
54
|
+
annotations: { read_only_hint: true, destructive_hint: false, idempotent_hint: true }
|
|
55
|
+
},
|
|
56
|
+
"create_cart" => {
|
|
57
|
+
description: "Start a new cart from a list of line items — the cart's complete starting " \
|
|
58
|
+
"contents, not an item appended to an existing one. Use update_cart to change " \
|
|
59
|
+
"a cart that already exists. Optional discount_codes applies coupons if the " \
|
|
60
|
+
"store supports them.",
|
|
61
|
+
params: {
|
|
62
|
+
"line_items" => "Items to add, each {product_id:, quantity:}.",
|
|
63
|
+
"idempotency_key" => IDEMPOTENCY_KEY,
|
|
64
|
+
"discount_codes" => "Discount/coupon codes to apply, if the store supports them."
|
|
65
|
+
},
|
|
66
|
+
annotations: { read_only_hint: false, destructive_hint: false, idempotent_hint: true }
|
|
67
|
+
},
|
|
68
|
+
"get_cart" => {
|
|
69
|
+
description: "Fetch a cart's current contents and totals by ID.",
|
|
70
|
+
params: { "cart_id" => ID },
|
|
71
|
+
annotations: { read_only_hint: true, destructive_hint: false, idempotent_hint: true }
|
|
72
|
+
},
|
|
73
|
+
"update_cart" => {
|
|
74
|
+
description: "Replace a cart's contents. #{REPLACE_NOTE} Use this for every change to an " \
|
|
75
|
+
"existing cart: adding an item, removing one, or changing a quantity.",
|
|
76
|
+
params: {
|
|
77
|
+
"cart_id" => ID,
|
|
78
|
+
"line_items" => "The cart's complete desired contents, each {product_id:, quantity:}. #{REPLACE_NOTE}",
|
|
79
|
+
"idempotency_key" => IDEMPOTENCY_KEY,
|
|
80
|
+
"discount_codes" => "Discount/coupon codes to apply, if the store supports them."
|
|
81
|
+
},
|
|
82
|
+
annotations: { read_only_hint: false, destructive_hint: false, idempotent_hint: true }
|
|
83
|
+
},
|
|
84
|
+
"cancel_cart" => {
|
|
85
|
+
description: "Cancel and discard a cart. Irreversible — use this to abandon a cart the " \
|
|
86
|
+
"shopper no longer wants.",
|
|
87
|
+
params: { "cart_id" => ID, "idempotency_key" => IDEMPOTENCY_KEY },
|
|
88
|
+
annotations: { read_only_hint: false, destructive_hint: true, idempotent_hint: true }
|
|
89
|
+
},
|
|
90
|
+
"create_checkout" => {
|
|
91
|
+
description: "Start a checkout directly from line items, skipping a separate cart. " \
|
|
92
|
+
"Optional discount_codes applies coupons; optional fulfillment carries the " \
|
|
93
|
+
"shopper's desired shipping or pickup selection, if the store supports it. " \
|
|
94
|
+
"Does not charge anything — call complete_checkout once the shopper approves " \
|
|
95
|
+
"the total from get_checkout.",
|
|
96
|
+
params: {
|
|
97
|
+
"line_items" => "Items to buy, each {product_id:, quantity:}.",
|
|
98
|
+
"idempotency_key" => IDEMPOTENCY_KEY,
|
|
99
|
+
"discount_codes" => "Discount/coupon codes to apply, if the store supports them.",
|
|
100
|
+
"fulfillment" => "Desired shipping/pickup method selection, if the store supports it."
|
|
101
|
+
},
|
|
102
|
+
annotations: { read_only_hint: false, destructive_hint: false, idempotent_hint: true }
|
|
103
|
+
},
|
|
104
|
+
"get_checkout" => {
|
|
105
|
+
description: "Fetch a checkout's current contents, totals, and status by ID.",
|
|
106
|
+
params: { "checkout_id" => ID },
|
|
107
|
+
annotations: { read_only_hint: true, destructive_hint: false, idempotent_hint: true }
|
|
108
|
+
},
|
|
109
|
+
"update_checkout" => {
|
|
110
|
+
description: "Replace a checkout's contents before payment. #{REPLACE_NOTE} Also accepts " \
|
|
111
|
+
"updated discount_codes or fulfillment selection. Does not charge anything.",
|
|
112
|
+
params: {
|
|
113
|
+
"checkout_id" => ID,
|
|
114
|
+
"line_items" => "The checkout's complete desired contents, each {product_id:, quantity:}. " \
|
|
115
|
+
"#{REPLACE_NOTE}",
|
|
116
|
+
"idempotency_key" => IDEMPOTENCY_KEY,
|
|
117
|
+
"discount_codes" => "Discount/coupon codes to apply, if the store supports them.",
|
|
118
|
+
"fulfillment" => "Desired shipping/pickup method selection, if the store supports it."
|
|
119
|
+
},
|
|
120
|
+
annotations: { read_only_hint: false, destructive_hint: false, idempotent_hint: true }
|
|
121
|
+
},
|
|
122
|
+
"complete_checkout" => {
|
|
123
|
+
description: "Charge the checkout and place the order — the one tool here that moves " \
|
|
124
|
+
"money. Takes a single-use payment_token from a payment handler, never a raw " \
|
|
125
|
+
"card number. Re-checks stock at this point, since catalog calls don't " \
|
|
126
|
+
"guarantee live inventory, and fails rather than charging if a line item is " \
|
|
127
|
+
"no longer available. Only call this after the shopper has explicitly " \
|
|
128
|
+
"approved the exact total from get_checkout.",
|
|
129
|
+
params: {
|
|
130
|
+
"checkout_id" => ID,
|
|
131
|
+
"payment_token" => "Single-use payment token from a payment handler — never a raw card number.",
|
|
132
|
+
"idempotency_key" => IDEMPOTENCY_KEY,
|
|
133
|
+
"mandate" => "Optional AP2 payment mandate authorizing this charge, alongside or instead " \
|
|
134
|
+
"of payment_token."
|
|
135
|
+
},
|
|
136
|
+
annotations: { read_only_hint: false, destructive_hint: true, idempotent_hint: true }
|
|
137
|
+
},
|
|
138
|
+
"cancel_checkout" => {
|
|
139
|
+
description: "Cancel a checkout before payment. Irreversible — use this to abandon a " \
|
|
140
|
+
"checkout the shopper no longer wants. Safe with respect to money, since " \
|
|
141
|
+
"complete_checkout hasn't run yet.",
|
|
142
|
+
params: { "checkout_id" => ID, "idempotency_key" => IDEMPOTENCY_KEY },
|
|
143
|
+
annotations: { read_only_hint: false, destructive_hint: true, idempotent_hint: true }
|
|
144
|
+
},
|
|
145
|
+
"get_order" => {
|
|
146
|
+
description: "Fetch a placed order's status, line items, and totals by ID. Returns " \
|
|
147
|
+
"not-found rather than raising if the ID doesn't exist.",
|
|
148
|
+
params: { "order_id" => ID },
|
|
149
|
+
annotations: { read_only_hint: true, destructive_hint: false, idempotent_hint: true }
|
|
150
|
+
},
|
|
151
|
+
"cancel_order" => {
|
|
152
|
+
description: "Cancel an already-placed order. Irreversible. Optional reason is a " \
|
|
153
|
+
"free-text note for the merchant, not a fixed enum.",
|
|
154
|
+
params: {
|
|
155
|
+
"order_id" => ID, "idempotency_key" => IDEMPOTENCY_KEY,
|
|
156
|
+
"reason" => "Optional free-text reason for the cancellation."
|
|
157
|
+
},
|
|
158
|
+
annotations: { read_only_hint: false, destructive_hint: true, idempotent_hint: true }
|
|
159
|
+
},
|
|
160
|
+
"request_return" => {
|
|
161
|
+
description: "Request a return for one or more order line items. This files the request; " \
|
|
162
|
+
"it doesn't refund money itself — the order shows the items as pending until " \
|
|
163
|
+
"the merchant processes it. Use refund_order only once a refund has actually " \
|
|
164
|
+
"been approved.",
|
|
165
|
+
params: {
|
|
166
|
+
"order_id" => ID,
|
|
167
|
+
"line_items" => "Items to return, each {id:, quantity:}.",
|
|
168
|
+
"idempotency_key" => IDEMPOTENCY_KEY,
|
|
169
|
+
"reason" => "Optional free-text reason for the return."
|
|
170
|
+
},
|
|
171
|
+
annotations: { read_only_hint: false, destructive_hint: false, idempotent_hint: true }
|
|
172
|
+
},
|
|
173
|
+
"refund_order" => {
|
|
174
|
+
description: "Refund one or more order line items, moving money back to the shopper. " \
|
|
175
|
+
"Irreversible. Use request_return first if the merchant requires a return " \
|
|
176
|
+
"before refunding.",
|
|
177
|
+
params: {
|
|
178
|
+
"order_id" => ID,
|
|
179
|
+
"line_items" => "Items to refund, each {id:, quantity:}.",
|
|
180
|
+
"idempotency_key" => IDEMPOTENCY_KEY,
|
|
181
|
+
"reason" => "Optional free-text reason for the refund."
|
|
182
|
+
},
|
|
183
|
+
annotations: { read_only_hint: false, destructive_hint: true, idempotent_hint: true }
|
|
184
|
+
}
|
|
185
|
+
}.freeze
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
@@ -38,6 +38,51 @@ module Portage
|
|
|
38
38
|
@store.record(order.id, record)
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
+
# Phase 3: replaces the stored snapshot's `adjustments` with
|
|
42
|
+
# `order.adjustments` — the upstream list is authoritative for
|
|
43
|
+
# adjustment state (a `pending` return settles on the merchant's
|
|
44
|
+
# clock), so a merge would only keep stale statuses. Every other
|
|
45
|
+
# purchase fact stays exactly as first recorded; `order`'s own
|
|
46
|
+
# line items/totals are never read.
|
|
47
|
+
#
|
|
48
|
+
# No-op (returns nil) when there's no purchase snapshot for the
|
|
49
|
+
# order: Phase 2's "report the gap, don't backfill" rule — a
|
|
50
|
+
# post-purchase payload isn't evidence of what was originally paid.
|
|
51
|
+
#
|
|
52
|
+
# ponytail: find-then-record isn't one lock; a write landing in
|
|
53
|
+
# between is lost. Add a Store#update if two writers ever share an
|
|
54
|
+
# order id concurrently.
|
|
55
|
+
def record_adjustments(order)
|
|
56
|
+
existing = find(order.id)
|
|
57
|
+
return nil unless existing
|
|
58
|
+
|
|
59
|
+
# Same omit-when-empty shape as Order#to_wire_h.
|
|
60
|
+
adjustments = order.adjustments.map(&:to_wire_h)
|
|
61
|
+
snapshot = existing["order"].except("adjustments")
|
|
62
|
+
snapshot = existing["order"].merge("adjustments" => adjustments) unless adjustments.empty?
|
|
63
|
+
@store.record(order.id, existing.merge("order" => snapshot))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Explicit reconcile for adjustments with no originating dispatch
|
|
67
|
+
# (disputes) or that settle later (returns) — re-fetches via the
|
|
68
|
+
# caller's `adapter` and merges adjustments only, per
|
|
69
|
+
# #record_adjustments. No polling (plan Phase 3): call it when you
|
|
70
|
+
# want current state.
|
|
71
|
+
def refresh(order_id, adapter:)
|
|
72
|
+
order = adapter.get_order(order_id: order_id)
|
|
73
|
+
record_adjustments(order) if order
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Adjustments still `pending` — money owed but not yet received —
|
|
77
|
+
# each paired with its order id.
|
|
78
|
+
def unresolved
|
|
79
|
+
all.flat_map do |record|
|
|
80
|
+
order = record["order"]
|
|
81
|
+
Array(order["adjustments"]).select { |adjustment| adjustment["status"] == "pending" }
|
|
82
|
+
.map { |adjustment| { "order_id" => order["id"], "adjustment" => adjustment } }
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
41
86
|
def find(order_id)
|
|
42
87
|
@store.find(order_id)
|
|
43
88
|
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require "cgi"
|
|
2
|
+
|
|
1
3
|
module Portage
|
|
2
4
|
module Ucp
|
|
3
5
|
# Internal arithmetic helper only — never appears in a wire shape directly.
|
|
@@ -387,20 +389,57 @@ module Portage
|
|
|
387
389
|
end
|
|
388
390
|
end
|
|
389
391
|
|
|
392
|
+
# A policy's buyer-facing disclosure obligation is a `messages[]` warning
|
|
393
|
+
# whose `code` equals the policy `type` (policy.json). Derived at
|
|
394
|
+
# serialization for Cart and Checkout, so it needs no storage and survives
|
|
395
|
+
# any rebuild without duplicating. Content is the plain (else markdown)
|
|
396
|
+
# description; an html-only policy has its tags stripped, since the
|
|
397
|
+
# warning schema has no html format.
|
|
398
|
+
def self.policy_messages(policies)
|
|
399
|
+
policies.map do |policy|
|
|
400
|
+
description = policy["description"]
|
|
401
|
+
markdown = !description["plain"] && description["markdown"]
|
|
402
|
+
content = description["plain"] || description["markdown"] || html_to_text(description["html"])
|
|
403
|
+
message = { "type" => "warning", "code" => policy["type"], "presentation" => "disclosure",
|
|
404
|
+
"content" => content }
|
|
405
|
+
message["content_type"] = "markdown" if markdown
|
|
406
|
+
message["url"] = policy["url"] if policy["url"]
|
|
407
|
+
message["path"] = policy["applies_to"].first if policy["applies_to"]&.one?
|
|
408
|
+
message
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
# Adapter messages first, then the derived policy warnings, minus any
|
|
413
|
+
# whose `code` the adapter already warned about (that warning meets the
|
|
414
|
+
# disclosure obligation). Only the adapter's `messages` are stored.
|
|
415
|
+
def self.wire_messages(messages, policies)
|
|
416
|
+
warned = messages.select { |m| m["type"] == "warning" }.to_set { |m| m["code"] }
|
|
417
|
+
messages + policy_messages(policies).reject { |m| warned.include?(m["code"]) }
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def self.html_to_text(html)
|
|
421
|
+
text = html.gsub(%r{</(p|div|li|h\d)>|<br\s*/?>}i, "\n").gsub(/<[^>]*>/, "")
|
|
422
|
+
CGI.unescapeHTML(text).gsub(/\n{3,}/, "\n\n").strip
|
|
423
|
+
end
|
|
424
|
+
|
|
390
425
|
# schemas/shopping/cart.json — requires ucp/id/line_items/currency/totals.
|
|
391
426
|
# The "ucp" envelope is added centrally by Portage::Ucp::WireEnvelope, not stored
|
|
392
427
|
# on the value object itself.
|
|
393
428
|
# `discounts` is the dev.ucp.shopping.discount extension (optional on both
|
|
394
429
|
# cart.json and its own schema) — omitted from the wire payload when empty,
|
|
395
430
|
# same as Order#adjustments.
|
|
396
|
-
|
|
397
|
-
|
|
431
|
+
# `messages` holds only the adapter's own wire-shaped message hashes;
|
|
432
|
+
# policy warnings are derived in to_wire_h (see Ucp.wire_messages).
|
|
433
|
+
Cart = Data.define(:id, :line_items, :currency, :totals, :discounts, :policies, :messages) do
|
|
434
|
+
def initialize(id:, line_items:, currency:, totals:, discounts: Discounts.new, policies: [], messages: []) = super
|
|
398
435
|
|
|
399
436
|
def to_wire_h
|
|
400
437
|
h = { "id" => id, "line_items" => line_items.map(&:to_wire_h), "currency" => currency,
|
|
401
438
|
"totals" => totals.map(&:to_wire_h) }
|
|
402
439
|
h["discounts"] = discounts.to_wire_h unless discounts.empty?
|
|
403
440
|
h["policies"] = policies unless policies.empty?
|
|
441
|
+
all = Ucp.wire_messages(messages, policies)
|
|
442
|
+
h["messages"] = all unless all.empty?
|
|
404
443
|
h
|
|
405
444
|
end
|
|
406
445
|
end
|
|
@@ -429,9 +468,9 @@ module Portage
|
|
|
429
468
|
# omitted from the wire payload when empty), same posture as
|
|
430
469
|
# Order#adjustments.
|
|
431
470
|
Checkout = Data.define(:id, :status, :line_items, :currency, :totals, :links, :order, :discounts,
|
|
432
|
-
:fulfillment, :policies) do
|
|
471
|
+
:fulfillment, :policies, :messages) do
|
|
433
472
|
def initialize(id:, status:, line_items:, currency:, totals:, links:, order: nil, discounts: Discounts.new,
|
|
434
|
-
fulfillment: CheckoutFulfillment.new, policies: [])
|
|
473
|
+
fulfillment: CheckoutFulfillment.new, policies: [], messages: [])
|
|
435
474
|
super
|
|
436
475
|
end
|
|
437
476
|
|
|
@@ -442,6 +481,8 @@ module Portage
|
|
|
442
481
|
h["discounts"] = discounts.to_wire_h unless discounts.empty?
|
|
443
482
|
h["fulfillment"] = fulfillment.to_wire_h unless fulfillment.empty?
|
|
444
483
|
h["policies"] = policies unless policies.empty?
|
|
484
|
+
all = Ucp.wire_messages(messages, policies)
|
|
485
|
+
h["messages"] = all unless all.empty?
|
|
445
486
|
h
|
|
446
487
|
end
|
|
447
488
|
end
|
data/lib/portage/ucp/version.rb
CHANGED
data/lib/portage/ucp.rb
CHANGED
|
@@ -57,12 +57,14 @@ require_relative "ucp/capabilities/order"
|
|
|
57
57
|
require_relative "ucp/capabilities/reorder"
|
|
58
58
|
require_relative "ucp/capabilities/discount"
|
|
59
59
|
require_relative "ucp/capabilities/fulfillment"
|
|
60
|
+
require_relative "ucp/capabilities/permalink"
|
|
60
61
|
require_relative "ucp/capabilities/payment_enrollment"
|
|
61
62
|
require_relative "ucp/capabilities/payment_method"
|
|
62
63
|
require_relative "ucp/capabilities/shopper_data"
|
|
63
64
|
require_relative "ucp/capabilities/identity_linking"
|
|
64
65
|
require_relative "ucp/capability_registry"
|
|
65
66
|
require_relative "ucp/dispatcher"
|
|
67
|
+
require_relative "ucp/mcp/tool_metadata"
|
|
66
68
|
require_relative "ucp/mcp/server"
|
|
67
69
|
require_relative "ucp/rack/forwarded_request"
|
|
68
70
|
require_relative "ucp/rack/manifest_endpoint"
|
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.15.
|
|
4
|
+
version: 0.15.1
|
|
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-18 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: base64
|
|
@@ -193,6 +193,7 @@ files:
|
|
|
193
193
|
- lib/portage/ucp/capabilities/order.rb
|
|
194
194
|
- lib/portage/ucp/capabilities/payment_enrollment.rb
|
|
195
195
|
- lib/portage/ucp/capabilities/payment_method.rb
|
|
196
|
+
- lib/portage/ucp/capabilities/permalink.rb
|
|
196
197
|
- lib/portage/ucp/capabilities/reorder.rb
|
|
197
198
|
- lib/portage/ucp/capabilities/shopper_data.rb
|
|
198
199
|
- lib/portage/ucp/capability.rb
|
|
@@ -204,6 +205,7 @@ files:
|
|
|
204
205
|
- lib/portage/ucp/errors.rb
|
|
205
206
|
- lib/portage/ucp/manifest.rb
|
|
206
207
|
- lib/portage/ucp/mcp/server.rb
|
|
208
|
+
- lib/portage/ucp/mcp/tool_metadata.rb
|
|
207
209
|
- lib/portage/ucp/observability.rb
|
|
208
210
|
- lib/portage/ucp/payment_enrollment_guard.rb
|
|
209
211
|
- lib/portage/ucp/payment_token_guard.rb
|