portage-ucp 0.15.0 → 0.16.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 +12 -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 +27 -5
- data/lib/portage/ucp/mcp/tool_metadata.rb +242 -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: 0a7cd8175dc8b1acd3a884205a5df5f030e897bf1090cdf6675fd582f7c4f0ee
|
|
4
|
+
data.tar.gz: 8acedc01a0e9dff428670248e9257647b26fb7ee54ea1e0625f67bf169c81285
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6422591fbb901e8e901b4ca6b6bba5b3cd8c6e776d1dd3980536c8245598787fbe2b25d4dd193bf5a05ed87cf6c4e2ff0d7f0b93222ae2716631f23fa028b7f6
|
|
7
|
+
data.tar.gz: 3d5928f821375dc3a71b7f9cae13864430d90b0167aebb8ca35f5ed008fe3e5617e0df0a95ada2f2db2fc962d8309eea7d852453374a81ce046f1609b9a366b7
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,18 @@ pre-1.0, so APIs may still shift between minor versions.
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.16.0] - 2026-10-21
|
|
10
|
+
|
|
11
|
+
- **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.
|
|
12
|
+
- **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).
|
|
13
|
+
- **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).
|
|
14
|
+
- **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).
|
|
15
|
+
- **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).
|
|
16
|
+
|
|
17
|
+
## [0.15.1] - 2026-10-18
|
|
18
|
+
|
|
19
|
+
- **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.
|
|
20
|
+
|
|
9
21
|
## [0.15.0] - 2026-10-13
|
|
10
22
|
|
|
11
23
|
- **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
|
|
@@ -23,7 +23,8 @@ module Portage
|
|
|
23
23
|
def self.build(adapter:, registry: Portage::Ucp.configuration.registry,
|
|
24
24
|
authenticator: Portage::Ucp.configuration.authenticator,
|
|
25
25
|
rate_limiter: Portage::Ucp.configuration.rate_limiter,
|
|
26
|
-
logger: Portage::Ucp.configuration.logger, journal: nil,
|
|
26
|
+
logger: Portage::Ucp.configuration.logger, journal: nil,
|
|
27
|
+
instructions: ToolMetadata::INSTRUCTIONS, **server_opts)
|
|
27
28
|
context = Context.new(
|
|
28
29
|
dispatcher: Portage::Ucp::Dispatcher.new(adapter: adapter, registry: registry, logger: logger,
|
|
29
30
|
journal: journal),
|
|
@@ -36,25 +37,46 @@ module Portage
|
|
|
36
37
|
end
|
|
37
38
|
end
|
|
38
39
|
|
|
39
|
-
::MCP::Server.new(name: "portage-ucp", tools: tools, **server_opts)
|
|
40
|
+
::MCP::Server.new(name: "portage-ucp", tools: tools, instructions: instructions, **server_opts)
|
|
40
41
|
end
|
|
41
42
|
|
|
42
43
|
def self.build_tool(adapter:, capability:, action_name:, method_name:, context:)
|
|
43
44
|
keyword_params = adapter.method(method_name).parameters.select { |type, _| %i[key keyreq].include?(type) }
|
|
44
45
|
required = keyword_params.select { |type, _| type == :keyreq }.map { |_, name| name.to_s }
|
|
45
|
-
|
|
46
|
+
metadata = ToolMetadata::TOOLS[action_name]
|
|
47
|
+
properties = keyword_params.to_h { |_, name| [name.to_s, property_schema(name.to_s, metadata)] }
|
|
46
48
|
mutating = keyword_params.any? { |_, name| name == :idempotency_key }
|
|
47
49
|
|
|
48
50
|
::MCP::Tool.define(
|
|
49
51
|
name: action_name,
|
|
50
|
-
description:
|
|
51
|
-
input_schema: { type: "object", properties: properties, required: required }
|
|
52
|
+
description: description_for(capability, action_name, metadata),
|
|
53
|
+
input_schema: { type: "object", properties: properties, required: required },
|
|
54
|
+
annotations: metadata && metadata[:annotations]
|
|
52
55
|
) do |**kwargs|
|
|
53
56
|
Portage::Ucp::Mcp::Server.call_tool(context: context, capability: capability, action_name: action_name,
|
|
54
57
|
mutating: mutating, kwargs: kwargs)
|
|
55
58
|
end
|
|
56
59
|
end
|
|
57
60
|
|
|
61
|
+
# @return [String] the documented description from ToolMetadata, or
|
|
62
|
+
# (for an undocumented/extension action) the old namespaced
|
|
63
|
+
# fallback, e.g. "dev.ucp.shopping.catalog#get_product".
|
|
64
|
+
def self.description_for(capability, action_name, metadata)
|
|
65
|
+
metadata ? metadata[:description] : "#{capability.name}##{action_name}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @return [Hash] a property's JSON Schema entry: a description from
|
|
69
|
+
# ToolMetadata when the action has an entry (none for an
|
|
70
|
+
# undocumented/extension action — see `metadata&.fetch` above), plus
|
|
71
|
+
# a `type` where ToolMetadata::PARAM_TYPES pins one down for this
|
|
72
|
+
# param name.
|
|
73
|
+
def self.property_schema(name, metadata)
|
|
74
|
+
schema = {}
|
|
75
|
+
schema[:description] = metadata[:params][name] if metadata && metadata[:params][name]
|
|
76
|
+
schema[:type] = ToolMetadata::PARAM_TYPES[name] if ToolMetadata::PARAM_TYPES[name]
|
|
77
|
+
schema
|
|
78
|
+
end
|
|
79
|
+
|
|
58
80
|
def self.call_tool(context:, capability:, action_name:, mutating:, kwargs:)
|
|
59
81
|
server_context = kwargs.delete(:server_context)
|
|
60
82
|
correlation_id = correlation_id_for(server_context)
|
|
@@ -0,0 +1,242 @@
|
|
|
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. 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
|
+
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
|
+
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, 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 " \
|
|
31
|
+
"without one is read-only. Payment is a single-use token, never a card number. " \
|
|
32
|
+
"Prices and stock come live from the store and can change between calls.".freeze
|
|
33
|
+
|
|
34
|
+
# JSON Schema `type` for parameter names whose shape is unambiguous
|
|
35
|
+
# from the name alone and constant across every action it appears
|
|
36
|
+
# in; left out entirely (no `type`) where it genuinely varies.
|
|
37
|
+
PARAM_TYPES = {
|
|
38
|
+
"query" => "string", "limit" => "integer", "product_id" => "string",
|
|
39
|
+
"product_ids" => "array", "cart_id" => "string", "checkout_id" => "string",
|
|
40
|
+
"order_id" => "string", "idempotency_key" => "string", "payment_token" => "string",
|
|
41
|
+
"reason" => "string", "line_items" => "array", "discount_codes" => "array",
|
|
42
|
+
"fulfillment" => "object", "mandate" => "object"
|
|
43
|
+
}.freeze
|
|
44
|
+
|
|
45
|
+
TOOLS = {
|
|
46
|
+
"search_catalog" => {
|
|
47
|
+
description: "Search the store's product catalog by free-text query and return up to " \
|
|
48
|
+
"`limit` matching products, each with ID, title, price range, variants and " \
|
|
49
|
+
"availability. Use it to discover products when you have no product ID yet; " \
|
|
50
|
+
"once you have IDs, use get_product (one) or lookup_catalog (several) " \
|
|
51
|
+
"instead of searching again. #{OPEN_NOTE} An empty product list means no " \
|
|
52
|
+
"match, not an error; results reflect the live store and can change between calls.",
|
|
53
|
+
params: {
|
|
54
|
+
"query" => "Free-text search, e.g. a product name, brand or category (\"burton snowboard\").",
|
|
55
|
+
"limit" => "Maximum number of products to return; 5–20 suits most searches."
|
|
56
|
+
},
|
|
57
|
+
annotations: { read_only_hint: true, destructive_hint: false, idempotent_hint: true }
|
|
58
|
+
},
|
|
59
|
+
"get_product" => {
|
|
60
|
+
description: "Fetch the full record for one product by ID: title, description, price " \
|
|
61
|
+
"range, every variant with its own price and availability, URL and store " \
|
|
62
|
+
"policies. Use it when you need variant-level detail for a single product " \
|
|
63
|
+
"before adding it to a cart; use lookup_catalog for several IDs at once, and " \
|
|
64
|
+
"search_catalog if you don't have an ID. #{OPEN_NOTE} An unknown ID returns " \
|
|
65
|
+
"an empty result rather than an error.",
|
|
66
|
+
params: { "product_id" => "Product ID, as returned by search_catalog or lookup_catalog." },
|
|
67
|
+
annotations: { read_only_hint: true, destructive_hint: false, idempotent_hint: true }
|
|
68
|
+
},
|
|
69
|
+
"lookup_catalog" => {
|
|
70
|
+
description: "Fetch several products by ID in one call, returning the same product list " \
|
|
71
|
+
"shape as search_catalog. Use it when you already hold a set of IDs — e.g. to " \
|
|
72
|
+
"refresh prices for a cart or order — instead of calling get_product once per " \
|
|
73
|
+
"ID; use search_catalog when you have a query rather than IDs. #{OPEN_NOTE} " \
|
|
74
|
+
"IDs the store doesn't recognise are omitted from the result, not reported as errors.",
|
|
75
|
+
params: { "product_ids" => "Product IDs to fetch, as returned by search_catalog, a cart, or an order." },
|
|
76
|
+
annotations: { read_only_hint: true, destructive_hint: false, idempotent_hint: true }
|
|
77
|
+
},
|
|
78
|
+
"create_cart" => {
|
|
79
|
+
description: "Create a new cart holding the given line items and return it with its ID, " \
|
|
80
|
+
"line items, currency and totals. Use it to collect items before checkout; " \
|
|
81
|
+
"to change a cart that already exists use update_cart, and to buy straight " \
|
|
82
|
+
"away without a cart use create_checkout. Nothing is reserved or charged. " \
|
|
83
|
+
"#{AUTH_NOTE}",
|
|
84
|
+
params: {
|
|
85
|
+
"line_items" => "The cart's starting contents, each {product_id:, quantity:} with a " \
|
|
86
|
+
"product or variant ID from the catalog tools.",
|
|
87
|
+
"idempotency_key" => IDEMPOTENCY_KEY,
|
|
88
|
+
"discount_codes" => "Optional coupon codes to apply, if the store supports them."
|
|
89
|
+
},
|
|
90
|
+
annotations: { read_only_hint: false, destructive_hint: false, idempotent_hint: true }
|
|
91
|
+
},
|
|
92
|
+
"get_cart" => {
|
|
93
|
+
description: "Fetch an existing cart by ID and return its line items, currency, applied " \
|
|
94
|
+
"discounts and current totals, recalculated against live store prices. Use " \
|
|
95
|
+
"it to show the shopper what is in the cart; it is for carts only — use " \
|
|
96
|
+
"get_checkout once a checkout exists and get_order after purchase. #{OPEN_NOTE}",
|
|
97
|
+
params: { "cart_id" => "Cart ID returned by create_cart. #{ID}" },
|
|
98
|
+
annotations: { read_only_hint: true, destructive_hint: false, idempotent_hint: true }
|
|
99
|
+
},
|
|
100
|
+
"update_cart" => {
|
|
101
|
+
description: "Replace an existing cart's line items and discount codes, then return the " \
|
|
102
|
+
"updated cart with new totals. #{REPLACE_NOTE} Use it for every change to a " \
|
|
103
|
+
"cart — add, remove or change a quantity; use create_cart only for a new " \
|
|
104
|
+
"cart and update_checkout once the shopper has moved to checkout. " \
|
|
105
|
+
"Nothing is charged. #{AUTH_NOTE}",
|
|
106
|
+
params: {
|
|
107
|
+
"cart_id" => "Cart ID returned by create_cart. #{ID}",
|
|
108
|
+
"line_items" => "The cart's complete desired contents, each {product_id:, quantity:}.",
|
|
109
|
+
"idempotency_key" => IDEMPOTENCY_KEY,
|
|
110
|
+
"discount_codes" => "Optional coupon codes, replacing those applied; leave out to keep the current ones."
|
|
111
|
+
},
|
|
112
|
+
annotations: { read_only_hint: false, destructive_hint: false, idempotent_hint: true }
|
|
113
|
+
},
|
|
114
|
+
"cancel_cart" => {
|
|
115
|
+
description: "Abandon a cart and return it in its final state; don't reuse the cart ID " \
|
|
116
|
+
"afterwards (some stores delete it, others such as Shopify let it expire). " \
|
|
117
|
+
"Use it when the shopper gives up on the cart; to just " \
|
|
118
|
+
"remove items use update_cart instead, and to abandon a checkout use " \
|
|
119
|
+
"cancel_checkout. No money is involved. #{AUTH_NOTE}",
|
|
120
|
+
params: { "cart_id" => "Cart ID returned by create_cart. #{ID}", "idempotency_key" => IDEMPOTENCY_KEY },
|
|
121
|
+
annotations: { read_only_hint: false, destructive_hint: true, idempotent_hint: true }
|
|
122
|
+
},
|
|
123
|
+
"create_checkout" => {
|
|
124
|
+
description: "Open a checkout for the given line items and return it with its ID, status, " \
|
|
125
|
+
"currency, itemised totals (including tax and shipping once known) and links. " \
|
|
126
|
+
"Use it when the shopper is ready to buy; use create_cart instead if they " \
|
|
127
|
+
"are still collecting items. Does not charge — complete_checkout does that, " \
|
|
128
|
+
"only after the shopper approves the total. #{AUTH_NOTE}",
|
|
129
|
+
params: {
|
|
130
|
+
"line_items" => "Items to buy, each {product_id:, quantity:} with a product or variant ID " \
|
|
131
|
+
"from the catalog tools or a cart.",
|
|
132
|
+
"idempotency_key" => IDEMPOTENCY_KEY,
|
|
133
|
+
"discount_codes" => "Optional coupon codes to apply, if the store supports them.",
|
|
134
|
+
"fulfillment" => "Optional shipping or pickup selection; leave it out to get the " \
|
|
135
|
+
"store's available options back on the checkout."
|
|
136
|
+
},
|
|
137
|
+
annotations: { read_only_hint: false, destructive_hint: false, idempotent_hint: true }
|
|
138
|
+
},
|
|
139
|
+
"get_checkout" => {
|
|
140
|
+
description: "Fetch an existing checkout by ID and return its status, line items, " \
|
|
141
|
+
"currency and itemised totals. Use it to show the shopper the exact final " \
|
|
142
|
+
"total to approve before complete_checkout, or to check whether a completed " \
|
|
143
|
+
"checkout produced an order; use get_cart for carts and get_order for placed " \
|
|
144
|
+
"orders. #{OPEN_NOTE}",
|
|
145
|
+
params: { "checkout_id" => "Checkout ID returned by create_checkout. #{ID}" },
|
|
146
|
+
annotations: { read_only_hint: true, destructive_hint: false, idempotent_hint: true }
|
|
147
|
+
},
|
|
148
|
+
"update_checkout" => {
|
|
149
|
+
description: "Replace an open checkout's line items, discount codes or fulfillment " \
|
|
150
|
+
"selection and return it with recalculated totals. #{REPLACE_NOTE} Use it " \
|
|
151
|
+
"before payment only; a completed checkout can't change — adjust the order " \
|
|
152
|
+
"with cancel_order or request_return instead. Does not charge, and any total " \
|
|
153
|
+
"the shopper approved earlier must be re-approved. #{AUTH_NOTE}",
|
|
154
|
+
params: {
|
|
155
|
+
"checkout_id" => "Checkout ID returned by create_checkout. #{ID}",
|
|
156
|
+
"line_items" => "The checkout's complete desired contents, each {product_id:, quantity:}.",
|
|
157
|
+
"idempotency_key" => IDEMPOTENCY_KEY,
|
|
158
|
+
"discount_codes" => "Optional coupon codes, replacing those applied; leave out to keep the current ones.",
|
|
159
|
+
"fulfillment" => "Optional shipping or pickup selection, from the options listed on the checkout."
|
|
160
|
+
},
|
|
161
|
+
annotations: { read_only_hint: false, destructive_hint: false, idempotent_hint: true }
|
|
162
|
+
},
|
|
163
|
+
"complete_checkout" => {
|
|
164
|
+
description: "Charge the shopper and place the order for an open checkout — the only tool " \
|
|
165
|
+
"here that spends money. Call it once, only after the shopper has explicitly " \
|
|
166
|
+
"approved the exact total from get_checkout; never to preview a price. Before " \
|
|
167
|
+
"charging it rejects anything resembling a raw card number, enforces the " \
|
|
168
|
+
"operator's spending caps and allowlist, may require a human confirmation, " \
|
|
169
|
+
"and re-checks stock, failing without a charge if anything is off. Returns " \
|
|
170
|
+
"the checkout with status completed and the new order. #{AUTH_NOTE}",
|
|
171
|
+
params: {
|
|
172
|
+
"checkout_id" => "Checkout ID returned by create_checkout. #{ID}",
|
|
173
|
+
"payment_token" => "Single-use payment token from a payment handler — never a raw card number.",
|
|
174
|
+
"idempotency_key" => "#{IDEMPOTENCY_KEY} Reuse it on retry so a timeout never charges twice.",
|
|
175
|
+
"mandate" => "Optional AP2 payment mandate authorizing this charge, alongside or instead " \
|
|
176
|
+
"of payment_token; its signature is verified before use."
|
|
177
|
+
},
|
|
178
|
+
annotations: { read_only_hint: false, destructive_hint: true, idempotent_hint: true }
|
|
179
|
+
},
|
|
180
|
+
"cancel_checkout" => {
|
|
181
|
+
description: "Abandon an open checkout before payment and return it with status canceled; " \
|
|
182
|
+
"it cannot be completed afterwards. Use it when the shopper declines the " \
|
|
183
|
+
"total; nothing has been charged yet, so no refund is needed. For a checkout " \
|
|
184
|
+
"that already completed, use cancel_order instead. #{AUTH_NOTE}",
|
|
185
|
+
params: { "checkout_id" => "Checkout ID returned by create_checkout. #{ID}",
|
|
186
|
+
"idempotency_key" => IDEMPOTENCY_KEY },
|
|
187
|
+
annotations: { read_only_hint: false, destructive_hint: true, idempotent_hint: true }
|
|
188
|
+
},
|
|
189
|
+
"get_order" => {
|
|
190
|
+
description: "Fetch a placed order by ID and return its line items with per-item " \
|
|
191
|
+
"fulfillment status, shipping details, totals, permalink and any " \
|
|
192
|
+
"cancellations, returns or refunds applied so far. Use it to answer " \
|
|
193
|
+
"\"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
|
+
"An unknown ID returns an empty result rather than an error.",
|
|
196
|
+
params: { "order_id" => "Order ID from a completed checkout. #{ID}" },
|
|
197
|
+
annotations: { read_only_hint: true, destructive_hint: false, idempotent_hint: true }
|
|
198
|
+
},
|
|
199
|
+
"cancel_order" => {
|
|
200
|
+
description: "Cancel an entire placed order and return it with the cancellation recorded. " \
|
|
201
|
+
"Use it before the order ships; for part of an order, or once it has shipped, " \
|
|
202
|
+
"use request_return. Whether a refund follows automatically depends on the " \
|
|
203
|
+
"store — check the returned order's adjustments. Cannot be undone. #{AUTH_NOTE}",
|
|
204
|
+
params: {
|
|
205
|
+
"order_id" => "Order ID from a completed checkout. #{ID}", "idempotency_key" => IDEMPOTENCY_KEY,
|
|
206
|
+
"reason" => "Optional free-text note for the merchant, e.g. \"ordered by mistake\"."
|
|
207
|
+
},
|
|
208
|
+
annotations: { read_only_hint: false, destructive_hint: true, idempotent_hint: true }
|
|
209
|
+
},
|
|
210
|
+
"request_return" => {
|
|
211
|
+
description: "File a return request for some or all line items of a placed order and " \
|
|
212
|
+
"return the order with the request recorded as pending. It only asks — no " \
|
|
213
|
+
"money moves until the merchant approves and a refund is issued with " \
|
|
214
|
+
"refund_order. Use it for delivered items; use cancel_order for an order " \
|
|
215
|
+
"that hasn't shipped. #{AUTH_NOTE}",
|
|
216
|
+
params: {
|
|
217
|
+
"order_id" => "Order ID from a completed checkout. #{ID}",
|
|
218
|
+
"line_items" => "Items to return, each {id:, quantity:} using the order line item IDs from get_order.",
|
|
219
|
+
"idempotency_key" => IDEMPOTENCY_KEY,
|
|
220
|
+
"reason" => "Optional free-text reason for the merchant, e.g. \"wrong size\"."
|
|
221
|
+
},
|
|
222
|
+
annotations: { read_only_hint: false, destructive_hint: false, idempotent_hint: true }
|
|
223
|
+
},
|
|
224
|
+
"refund_order" => {
|
|
225
|
+
description: "Refund some or all line items of a placed order back to the original " \
|
|
226
|
+
"payment method and return the order with the refund recorded. Money moves " \
|
|
227
|
+
"and it cannot be reversed. Use it once a refund is agreed; if the store " \
|
|
228
|
+
"needs the goods back first, call request_return before this. To call off " \
|
|
229
|
+
"a whole unshipped order, use cancel_order. #{AUTH_NOTE}",
|
|
230
|
+
params: {
|
|
231
|
+
"order_id" => "Order ID from a completed checkout. #{ID}",
|
|
232
|
+
"line_items" => "Items to refund, each {id:, quantity:} using the order line item IDs from get_order.",
|
|
233
|
+
"idempotency_key" => IDEMPOTENCY_KEY,
|
|
234
|
+
"reason" => "Optional free-text reason for the merchant."
|
|
235
|
+
},
|
|
236
|
+
annotations: { read_only_hint: false, destructive_hint: true, idempotent_hint: true }
|
|
237
|
+
}
|
|
238
|
+
}.freeze
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
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.
|
|
4
|
+
version: 0.16.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-21 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
|