portage-ucp 0.2.0 → 0.4.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 +111 -0
- data/lib/portage/ucp/adapter.rb +17 -2
- data/lib/portage/ucp/capabilities/identity_linking.rb +1 -1
- data/lib/portage/ucp/capabilities/reorder.rb +17 -0
- data/lib/portage/ucp/capability_negotiator.rb +30 -0
- data/lib/portage/ucp/dispatcher.rb +22 -3
- data/lib/portage/ucp/errors.rb +22 -0
- data/lib/portage/ucp/mcp/server.rb +39 -5
- data/lib/portage/ucp/observability.rb +12 -3
- data/lib/portage/ucp/rack/webhook_endpoint.rb +21 -3
- data/lib/portage/ucp/reference_adapter.rb +333 -0
- data/lib/portage/ucp/rspec.rb +175 -0
- data/lib/portage/ucp/schema_validator.rb +23 -2
- data/lib/portage/ucp/support/api_error.rb +9 -6
- data/lib/portage/ucp/support/checkout_state.rb +41 -0
- data/lib/portage/ucp/support/http_client.rb +9 -2
- data/lib/portage/ucp/support/idempotency.rb +22 -3
- data/lib/portage/ucp/support/retry.rb +66 -0
- data/lib/portage/ucp/support/session_lock.rb +40 -0
- data/lib/portage/ucp/value_objects.rb +183 -1
- data/lib/portage/ucp/version.rb +1 -1
- data/lib/portage/ucp/wire_envelope.rb +5 -1
- data/lib/portage/ucp.rb +4 -0
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 36c3a77e9f625761b827d1e925d54217b7bff302f4a7a60540d4a8c5d1794fb9
|
|
4
|
+
data.tar.gz: 05a86e2740a327c63bb0e06aa450e2aa5207aa60ce637575133483ed8df4698b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d0d4746f829e60009463ee2748ba5c46ff6f3932b5a345f3f26d94e437a5132b39002945bf19abe1ee3d02c46e1760b52afa6910a15531df494c4e39dcca7787
|
|
7
|
+
data.tar.gz: 60898e67d72f545c24970415775506be4930336a4f325be61714cef0f810547f77b79951f7b48e3fb32b72cdd9adb9669b4f3af4f872be0a4bcb7ea60b325bf5
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,117 @@ All notable changes to this project are documented here. Format loosely follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.0.0/); this project is
|
|
5
5
|
pre-1.0, so APIs may still shift between minor versions.
|
|
6
6
|
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.4.0] - 2026-08-28
|
|
10
|
+
|
|
11
|
+
- Added `Adapter#reorder(order_id:, idempotency_key:)`, advertised as a new
|
|
12
|
+
`app.portage-ucp.reorder` capability — a Portage extension, not part of the
|
|
13
|
+
UCP spec. Hydrates a `Cart` from a previous order's line items, re-checking
|
|
14
|
+
each item's current price/availability rather than replaying the order's
|
|
15
|
+
historical totals, and reports anything no longer purchasable via the new
|
|
16
|
+
`ReorderResult#unavailable_items` rather than failing the whole call.
|
|
17
|
+
Implemented in `ReferenceAdapter` as a worked example.
|
|
18
|
+
- `Mcp::Server.call_tool` now emits a minimal pre-auth `tool_call_received`
|
|
19
|
+
event (capability, action, correlation id — no arguments) before
|
|
20
|
+
`authorize`/`rate_limit` run, moving the full `tool_called` event
|
|
21
|
+
(arguments included) below them. Previously the full event, arguments and
|
|
22
|
+
all, was logged before authorization, so an unauthenticated caller could
|
|
23
|
+
write attacker-chosen content into the operator's logs at whatever volume
|
|
24
|
+
the rate limiter would otherwise have refused (design-log §23).
|
|
25
|
+
- `Mcp::Server.correlation_id_for` stamps both events with a correlation id
|
|
26
|
+
read from the inbound W3C `traceparent` in `server_context[:_meta]`
|
|
27
|
+
(SEP-414, `MCP::TraceContext`), falling back to `SecureRandom.uuid` when
|
|
28
|
+
absent or malformed. Deliberately per-request, not per-session —
|
|
29
|
+
`Server::Context` is built once per process in `.build`, and `mcp`
|
|
30
|
+
0.25.0's Streamable HTTP transport is stateful and multi-session, so
|
|
31
|
+
memoizing an id there would stamp every session in the process with the
|
|
32
|
+
same value (design-log §23). Because `traceparent` is unauthenticated
|
|
33
|
+
input read before `authorize`/`rate_limit` run, it's validated against
|
|
34
|
+
the W3C Trace Context format before use rather than accepted as-is — a
|
|
35
|
+
malformed or oversized value falls back to a generated id instead of
|
|
36
|
+
reaching the pre-auth log or `Dispatcher`/`CheckoutState` unchecked.
|
|
37
|
+
- `Dispatcher` now threads its logger and each call's correlation id to the
|
|
38
|
+
adapter for the duration of that one call
|
|
39
|
+
(`Support::CheckoutState.with_observability`) so a
|
|
40
|
+
`checkout_state_transition` event (§12) fires from `record_checkout_status`
|
|
41
|
+
carrying the same correlation id as the `tool_called` event that triggered
|
|
42
|
+
it — without adding a `correlation_id:` kwarg to any checkout method, which
|
|
43
|
+
would have been a breaking change to the `Adapter` contract. Storage is
|
|
44
|
+
`Thread.current`, keyed per adapter object and restored on exit, rather
|
|
45
|
+
than an instance variable on the adapter: `Mcp::Server.build` constructs
|
|
46
|
+
one adapter per process, shared across every concurrent session, so an
|
|
47
|
+
instance variable would let two in-flight requests clobber each other's
|
|
48
|
+
correlation id — the same per-process-state trap §23 diagnosed for the
|
|
49
|
+
correlation id generator itself, one layer down. No `capability_negotiated`
|
|
50
|
+
event yet: `CapabilityNegotiator#negotiate` has no call site anywhere in
|
|
51
|
+
the gem outside its own spec, so there's nowhere to emit it from without
|
|
52
|
+
building that call site first (design-log §23).
|
|
53
|
+
- `Observability::REDACTED_KEYS` grows past the three credential keys to
|
|
54
|
+
cover the PII that actually flows through logged events — `email`
|
|
55
|
+
(`Identity`, §3) and `first_name`/`last_name`/`phone_number`/
|
|
56
|
+
`street_address`/`extended_address`/`address_locality`/`address_region`/
|
|
57
|
+
`address_country`/`postal_code` (`PostalAddress`, fulfillment
|
|
58
|
+
destinations). §12's "Money-adjacent PII" named no real key — `Money`/
|
|
59
|
+
`Total` carry only amounts and currency codes (design-log §23 step 4).
|
|
60
|
+
- `Rack::WebhookEndpoint` takes a `logger:` kwarg (defaulting to
|
|
61
|
+
`Portage::Ucp.configuration.logger`) and emits `order_webhook_received`
|
|
62
|
+
(order id, checkout id) on a verified payload and `order_webhook_rejected`
|
|
63
|
+
(reason: `invalid_signature` or `bad_request`) on the two rejection paths
|
|
64
|
+
— never the request body. No new `config.event_sink`: this endpoint is a
|
|
65
|
+
plain Rack app never built through `Mcp::Server.build`, so it can't reach
|
|
66
|
+
`mcp`'s own request hooks, and threading the gem's existing `logger:`
|
|
67
|
+
convention through one more constructor was the whole fix (design-log
|
|
68
|
+
§23 step 5).
|
|
69
|
+
|
|
70
|
+
## [0.3.0] - 2026-08-27
|
|
71
|
+
|
|
72
|
+
- `Portage::Ucp::Support::Retry` (`lib/portage/ucp/support/retry.rb`) —
|
|
73
|
+
bounded retry with backoff for adapters, plus normalized conflict/throttle
|
|
74
|
+
errors on `Support::ApiError` so a caller can distinguish "retry this" from
|
|
75
|
+
"don't."
|
|
76
|
+
- `Portage::Ucp::Support::SessionLock` (`lib/portage/ucp/support/session_lock.rb`)
|
|
77
|
+
— serializes per-cart/checkout mutations against a single upstream session,
|
|
78
|
+
used by the Shopify and Wix adapters to stop concurrent cart writes from
|
|
79
|
+
racing the same checkout.
|
|
80
|
+
- `Support::Idempotency` is now thread-safe under concurrent duplicate calls
|
|
81
|
+
— the dedup table write was not atomic, so two requests with the same
|
|
82
|
+
idempotency key arriving together could both miss the cache and both hit
|
|
83
|
+
the adapter.
|
|
84
|
+
- Conformance kit: the repeated-idempotency-key example no longer passes on
|
|
85
|
+
output equality alone. An adapter wired to a fixed-response test double
|
|
86
|
+
returns identical output whether or not it deduped, so the example now also
|
|
87
|
+
asserts the key reached `Support::Idempotency`'s dedup table when the
|
|
88
|
+
adapter includes that module, and `warn`s (rather than silently passing)
|
|
89
|
+
when it doesn't.
|
|
90
|
+
|
|
91
|
+
- `Portage::Ucp::ReferenceAdapter` (`lib/portage/ucp/reference_adapter.rb`) —
|
|
92
|
+
the in-memory `Adapter` roadmap §8 step 1 called for and design-log §17
|
|
93
|
+
flagged as missing outside `spec/support/fake_adapter.rb`, ships with the
|
|
94
|
+
gem now. Implements every capability including
|
|
95
|
+
`discount_codes_supported?`/`fulfillment_supported?`/`link_identity` — the
|
|
96
|
+
first adapter in this repo to back identity linking at all.
|
|
97
|
+
- `Portage::Ucp::RSpec`/`portage/ucp/rspec.rb` — the adapter conformance kit
|
|
98
|
+
design-log §17 called "the missing piece that turns 'any backend that
|
|
99
|
+
implements Adapter' from a README claim into something checked": an
|
|
100
|
+
`it_behaves_like "a portage adapter"` shared-examples suite checking the
|
|
101
|
+
contract's behavioral guarantees (idempotency dedup, the PAN guard,
|
|
102
|
+
schema-conformant wire output, `OutOfStockError` on a stale-stock line) —
|
|
103
|
+
not loaded by `require "portage/ucp"`, opt-in via `require
|
|
104
|
+
"portage/ucp/rspec"` since it pulls in RSpec itself. Exercised against
|
|
105
|
+
`ReferenceAdapter` in this gem's own suite
|
|
106
|
+
(`spec/reference_adapter_conformance_spec.rb`); wired into each adapter
|
|
107
|
+
gem's own spec suite (`spec/portage/ucp/<platform>/conformance_spec.rb`)
|
|
108
|
+
as follow-up.
|
|
109
|
+
- Conformance kit: `existing_variant_id` alongside `existing_product_id`, for
|
|
110
|
+
adapters (Shopify) where a catalog lookup id and a cart line-item id are
|
|
111
|
+
different GIDs. Defaults to `existing_product_id`, so every other adapter
|
|
112
|
+
is unaffected.
|
|
113
|
+
- `search_catalog`/`get_product` output is schema-wrapped like every other
|
|
114
|
+
capability now — previously returned a bare array/`Product` with no
|
|
115
|
+
`to_wire_h`, so the dispatcher's schema-wrap never touched it and nothing
|
|
116
|
+
caught it drifting from `catalog_search.json`/`catalog_lookup.json`.
|
|
117
|
+
|
|
7
118
|
## [0.2.0] - 2026-08-21
|
|
8
119
|
|
|
9
120
|
- `Portage::Ucp::OutOfStockError` — the contract for `#complete_checkout`
|
data/lib/portage/ucp/adapter.rb
CHANGED
|
@@ -4,9 +4,11 @@ module Portage
|
|
|
4
4
|
# Unoverridden methods leave that capability out of the manifest.
|
|
5
5
|
class Adapter
|
|
6
6
|
# --- Catalog (dev.ucp.shopping.catalog) ---
|
|
7
|
-
# @return [
|
|
7
|
+
# @return [Portage::Ucp::CatalogSearchResult]
|
|
8
8
|
def search_catalog(query:, limit:) = not_implemented
|
|
9
|
-
#
|
|
9
|
+
# nil when the product isn't found, same not-found posture as
|
|
10
|
+
# get_cart/get_checkout/get_order.
|
|
11
|
+
# @return [Portage::Ucp::ProductDetail, nil]
|
|
10
12
|
def get_product(product_id:) = not_implemented
|
|
11
13
|
|
|
12
14
|
# --- Cart (dev.ucp.shopping.cart) ---
|
|
@@ -88,6 +90,19 @@ module Portage
|
|
|
88
90
|
# @return [Portage::Ucp::Order]
|
|
89
91
|
def refund_order(order_id:, line_items:, idempotency_key:, reason: nil) = not_implemented
|
|
90
92
|
|
|
93
|
+
# --- Reorder (app.portage-ucp.reorder — Portage extension, not part of
|
|
94
|
+
# the UCP spec: dev.ucp.dev has no reorder/cart-hydration capability as
|
|
95
|
+
# of the 2026-04-08 spec) ---
|
|
96
|
+
# Hydrates a cart from a previous order's line items so a caller doesn't
|
|
97
|
+
# have to re-walk get_order + create_cart itself. Re-checks each item's
|
|
98
|
+
# current price/availability rather than replaying the order's snapshot
|
|
99
|
+
# totals — order_line_item.json's totals are historical, not live, same
|
|
100
|
+
# posture as complete_checkout's stock re-check above — and drops
|
|
101
|
+
# anything no longer purchasable instead of failing the whole call,
|
|
102
|
+
# reporting what got dropped via ReorderResult#unavailable_items.
|
|
103
|
+
# @return [Portage::Ucp::ReorderResult, nil] nil if the order isn't found.
|
|
104
|
+
def reorder(order_id:, idempotency_key:) = not_implemented
|
|
105
|
+
|
|
91
106
|
# --- Discount (dev.ucp.shopping.discount) ---
|
|
92
107
|
# Extends Cart/Checkout with the `discount_codes:` param above rather
|
|
93
108
|
# than adding actions of its own — Capability::DISCOUNT advertises off
|
|
@@ -7,7 +7,7 @@ module Portage
|
|
|
7
7
|
actions: { "link_identity" => :link_identity }
|
|
8
8
|
)
|
|
9
9
|
|
|
10
|
-
ALL = [CATALOG, CART, CHECKOUT, ORDER, IDENTITY_LINKING, DISCOUNT, FULFILLMENT].freeze
|
|
10
|
+
ALL = [CATALOG, CART, CHECKOUT, ORDER, IDENTITY_LINKING, DISCOUNT, FULFILLMENT, REORDER].freeze
|
|
11
11
|
end
|
|
12
12
|
end
|
|
13
13
|
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Ucp
|
|
3
|
+
module Capabilities
|
|
4
|
+
# Portage-only extension, not part of the UCP spec (no ucp.dev
|
|
5
|
+
# reverse-domain meaning to borrow), so this is namespaced under the
|
|
6
|
+
# gem's own name instead of "dev.ucp.*". Advertised only if the
|
|
7
|
+
# adapter overrides Adapter#reorder, same as every other capability
|
|
8
|
+
# (Capability#advertised_for?) — an adapter that hasn't implemented it
|
|
9
|
+
# simply never shows it in the manifest.
|
|
10
|
+
REORDER = Portage::Ucp::Capability.new(
|
|
11
|
+
name: "app.portage-ucp.reorder",
|
|
12
|
+
version: "1",
|
|
13
|
+
actions: { "reorder" => :reorder }
|
|
14
|
+
)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -13,6 +13,36 @@ module Portage
|
|
|
13
13
|
# params instead. Either way this class only deals in the parsed version
|
|
14
14
|
# list — transports are responsible for extracting it from their own
|
|
15
15
|
# request shape.
|
|
16
|
+
#
|
|
17
|
+
# §23 step 3: §12 promises a `capability_negotiated` event here, but
|
|
18
|
+
# #negotiate has no call site anywhere in this gem outside its own spec
|
|
19
|
+
# — nothing in the request path (transport `initialize` handling, or
|
|
20
|
+
# otherwise) invokes it yet. Wiring the event would mean building that
|
|
21
|
+
# call site first, which is a bigger change than threading a logger
|
|
22
|
+
# through an existing collaborator; left undone, and cut from §12
|
|
23
|
+
# rather than promised.
|
|
24
|
+
#
|
|
25
|
+
# §25: the reason there's no call site is structural, not just
|
|
26
|
+
# unbuilt. `mcp` 0.25.0's `MCP::Server#init`
|
|
27
|
+
# (lib/mcp/server.rb:608) never reads `params[:_meta]` and never calls
|
|
28
|
+
# `add_instrumentation_data`, unlike `call_tool` — so nothing at
|
|
29
|
+
# initialize time reaches `around_request`/`instrumentation_callback`
|
|
30
|
+
# or the `_meta` mechanism `Mcp::Server.correlation_id_for` already
|
|
31
|
+
# relies on (§23/§24). And over HTTP,
|
|
32
|
+
# `StreamableHTTPTransport#handle_initialization`
|
|
33
|
+
# (lib/mcp/server/transports/streamable_http_transport.rb:812) builds
|
|
34
|
+
# a `Rack::Request` with full headers but only pulls `HTTP_ORIGIN` out
|
|
35
|
+
# of it before handing the raw JSON body string to
|
|
36
|
+
# `ServerSession#handle_json` — a `UCP-Agent` header never crosses into
|
|
37
|
+
# the server at all. Wiring #negotiate for real means monkeypatching
|
|
38
|
+
# both (subclass/prepend `MCP::Server#init` to capture
|
|
39
|
+
# `clientInfo`/`_meta`, and `StreamableHTTPTransport#handle_initialization`
|
|
40
|
+
# to capture the `UCP-Agent` header), coupling this gem to `mcp`
|
|
41
|
+
# internals that could silently break on a `mcp` gem upgrade.
|
|
42
|
+
# Revisiting this needs either an upstream `mcp` gem hook at
|
|
43
|
+
# initialize time, or a deliberate decision to accept the monkeypatch
|
|
44
|
+
# coupling — not something to build silently as a side effect of
|
|
45
|
+
# another task.
|
|
16
46
|
class CapabilityNegotiator
|
|
17
47
|
def initialize(registry: CapabilityRegistry.default)
|
|
18
48
|
@registry = registry
|
|
@@ -4,12 +4,21 @@ module Portage
|
|
|
4
4
|
# through the CapabilityRegistry to the backing Adapter method, and wraps
|
|
5
5
|
# the result as MCP's dual content/structuredContent output (see §5).
|
|
6
6
|
class Dispatcher
|
|
7
|
-
def initialize(adapter:, registry: CapabilityRegistry.default)
|
|
7
|
+
def initialize(adapter:, registry: CapabilityRegistry.default, logger: Portage::Ucp.configuration.logger)
|
|
8
8
|
@adapter = adapter
|
|
9
9
|
@registry = registry
|
|
10
|
+
@logger = logger
|
|
10
11
|
end
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
# @param correlation_id [String, nil] threaded through to the adapter
|
|
14
|
+
# (via Support::CheckoutState.with_observability, scoped to this call
|
|
15
|
+
# only) so a checkout_state_transition event (§12) it emits during
|
|
16
|
+
# this call carries the same id as the tool_called event that
|
|
17
|
+
# triggered it. Optional, not correlation_id: required, since
|
|
18
|
+
# Dispatcher.call is also the conformance kit's
|
|
19
|
+
# (lib/portage/ucp/rspec.rb) and specs' direct entry point, outside
|
|
20
|
+
# any MCP request (§23).
|
|
21
|
+
def call(capability:, action:, arguments: {}, correlation_id: nil)
|
|
13
22
|
capability_definition = @registry.find(capability)
|
|
14
23
|
raise UnknownCapabilityError, capability if capability_definition.nil?
|
|
15
24
|
|
|
@@ -20,12 +29,22 @@ module Portage
|
|
|
20
29
|
|
|
21
30
|
Portage::Ucp::PaymentTokenGuard.validate!(arguments[:payment_token]) if arguments.key?(:payment_token)
|
|
22
31
|
|
|
23
|
-
result =
|
|
32
|
+
result = call_adapter(method_name, arguments, correlation_id)
|
|
24
33
|
wrap(capability, result)
|
|
25
34
|
end
|
|
26
35
|
|
|
27
36
|
private
|
|
28
37
|
|
|
38
|
+
def call_adapter(method_name, arguments, correlation_id)
|
|
39
|
+
unless @adapter.is_a?(Portage::Ucp::Support::CheckoutState)
|
|
40
|
+
return @adapter.public_send(method_name, **arguments)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
Portage::Ucp::Support::CheckoutState.with_observability(@adapter, @logger, correlation_id) do
|
|
44
|
+
@adapter.public_send(method_name, **arguments)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
29
48
|
def wrap(capability_name, result)
|
|
30
49
|
unless result.respond_to?(:to_wire_h)
|
|
31
50
|
return { content: [{ type: "text", text: result.inspect }], structuredContent: result }
|
data/lib/portage/ucp/errors.rb
CHANGED
|
@@ -16,5 +16,27 @@ module Portage
|
|
|
16
16
|
# standardized "out_of_stock"/"item_unavailable" error codes
|
|
17
17
|
# (schemas/shopping/types/error_code.json).
|
|
18
18
|
class OutOfStockError < Error; end
|
|
19
|
+
# Raised when a mutation collides with a concurrent write upstream (HTTP
|
|
20
|
+
# 409, or a platform's own optimistic-concurrency rejection) — the
|
|
21
|
+
# resource changed between this call's read and its write. Support::Retry
|
|
22
|
+
# deliberately never retries this itself: the state it read is already
|
|
23
|
+
# stale, so retrying blindly would just collide again. The caller is
|
|
24
|
+
# expected to re-read (get_cart/get_checkout) and resubmit against
|
|
25
|
+
# current state. Maps to UCP's freeform "conflict" error code —
|
|
26
|
+
# error_code.json's examples list isn't exhaustive ("freeform codes are
|
|
27
|
+
# permitted"); "conflict" follows the same snake_case convention as its
|
|
28
|
+
# "out_of_stock"/"payment_failed" examples, same justification
|
|
29
|
+
# OutOfStockError above relies on.
|
|
30
|
+
class ConflictError < Error; end
|
|
31
|
+
# Raised when Support::Retry exhausts its bounded backoff against a
|
|
32
|
+
# genuinely-retryable upstream throttle (Shopify GraphQL THROTTLED, HTTP
|
|
33
|
+
# 429, or cartSubmitForCompletion's SubmitThrottled pollAfter) and the
|
|
34
|
+
# platform still hasn't done the work. Named apart from
|
|
35
|
+
# RateLimitExceededError — that one is this gem's own pluggable
|
|
36
|
+
# RateLimiter rejecting a call before it ever reaches the network; this
|
|
37
|
+
# is the upstream platform itself refusing after every retry. Maps to
|
|
38
|
+
# UCP's freeform "rate_limited" error code, same convention as
|
|
39
|
+
# ConflictError above.
|
|
40
|
+
class UpstreamThrottledError < Error; end
|
|
19
41
|
end
|
|
20
42
|
end
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require "mcp"
|
|
2
|
+
require "securerandom"
|
|
2
3
|
|
|
3
4
|
module Portage
|
|
4
5
|
module Ucp
|
|
@@ -16,8 +17,10 @@ module Portage
|
|
|
16
17
|
authenticator: Portage::Ucp.configuration.authenticator,
|
|
17
18
|
rate_limiter: Portage::Ucp.configuration.rate_limiter,
|
|
18
19
|
logger: Portage::Ucp.configuration.logger, **server_opts)
|
|
19
|
-
context = Context.new(
|
|
20
|
-
|
|
20
|
+
context = Context.new(
|
|
21
|
+
dispatcher: Portage::Ucp::Dispatcher.new(adapter: adapter, registry: registry, logger: logger),
|
|
22
|
+
authenticator: authenticator, rate_limiter: rate_limiter, logger: logger
|
|
23
|
+
)
|
|
21
24
|
tools = registry.advertised(adapter).flat_map do |capability|
|
|
22
25
|
capability.actions.map do |action_name, method_name|
|
|
23
26
|
build_tool(adapter: adapter, capability: capability, action_name: action_name,
|
|
@@ -46,17 +49,48 @@ module Portage
|
|
|
46
49
|
|
|
47
50
|
def self.call_tool(context:, capability:, action_name:, mutating:, kwargs:)
|
|
48
51
|
server_context = kwargs.delete(:server_context)
|
|
49
|
-
|
|
50
|
-
|
|
52
|
+
correlation_id = correlation_id_for(server_context)
|
|
53
|
+
Portage::Ucp::Observability.log(context.logger, "tool_call_received", capability: capability.name,
|
|
54
|
+
action: action_name,
|
|
55
|
+
correlation_id: correlation_id)
|
|
51
56
|
|
|
52
57
|
rejection = authorize(context.authenticator, server_context, mutating: mutating) ||
|
|
53
58
|
rate_limit(context.rate_limiter, server_context, capability.name, mutating: mutating)
|
|
54
59
|
return rejection if rejection
|
|
55
60
|
|
|
56
|
-
|
|
61
|
+
Portage::Ucp::Observability.log(context.logger, "tool_called", capability: capability.name,
|
|
62
|
+
action: action_name, arguments: kwargs,
|
|
63
|
+
correlation_id: correlation_id)
|
|
64
|
+
|
|
65
|
+
result = context.dispatcher.call(capability: capability.name, action: action_name, arguments: kwargs,
|
|
66
|
+
correlation_id: correlation_id)
|
|
57
67
|
::MCP::Tool::Response.new(result[:content], structured_content: result[:structuredContent])
|
|
58
68
|
end
|
|
59
69
|
|
|
70
|
+
# Per-request correlation only (§23): `Context` above is built once per
|
|
71
|
+
# process in `.build`, and mcp 0.25.0's Streamable HTTP transport is
|
|
72
|
+
# explicitly stateful/multi-session, so memoizing an id there would
|
|
73
|
+
# stamp every session in the process with the same value. Prefers the
|
|
74
|
+
# inbound W3C `traceparent` the MCP spec passes through `_meta`
|
|
75
|
+
# untouched (SEP-414, see `MCP::TraceContext`) so a caller that already
|
|
76
|
+
# traces its own calls gets one trace across both sides; generates a
|
|
77
|
+
# fallback only when absent.
|
|
78
|
+
#
|
|
79
|
+
# `traceparent` is unauthenticated input — reachable before
|
|
80
|
+
# `authorize`/`rate_limit` run, same as the pre-auth event this
|
|
81
|
+
# correlation id feeds. Validated against W3C Trace Context's own
|
|
82
|
+
# format before use, which is spec-correct behavior (a malformed
|
|
83
|
+
# traceparent MUST be treated as absent, restarting the trace), and
|
|
84
|
+
# incidentally closes off unbounded-length log writes and non-String
|
|
85
|
+
# values reaching Dispatcher/CheckoutState as a "correlation_id".
|
|
86
|
+
TRACEPARENT_FORMAT = /\A[0-9a-f]{2}-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}\z/
|
|
87
|
+
|
|
88
|
+
def self.correlation_id_for(server_context)
|
|
89
|
+
meta = server_context[:_meta] if server_context.respond_to?(:[])
|
|
90
|
+
traceparent = meta && (meta[:traceparent] || meta["traceparent"])
|
|
91
|
+
traceparent.is_a?(String) && TRACEPARENT_FORMAT.match?(traceparent) ? traceparent : SecureRandom.uuid
|
|
92
|
+
end
|
|
93
|
+
|
|
60
94
|
def self.authorize(authenticator, server_context, mutating:)
|
|
61
95
|
return unless mutating
|
|
62
96
|
|
|
@@ -6,10 +6,19 @@ module Portage
|
|
|
6
6
|
# Structured log events (§12), emitted through a consumer-injected logger
|
|
7
7
|
# (defaults to Logger.new($stdout)) so the gem instruments nothing to a
|
|
8
8
|
# specific APM — it just exposes the events. Redacts payment_token,
|
|
9
|
-
# oauth_token, and Authorization by default
|
|
10
|
-
#
|
|
9
|
+
# oauth_token, and Authorization by default, plus the PII fields that
|
|
10
|
+
# actually appear on Portage::Ucp::Identity (email) and
|
|
11
|
+
# Portage::Ucp::PostalAddress (name/address/contact) — §23 step 4
|
|
12
|
+
# resolving §12's "Money-adjacent PII" phrase, which named no real key:
|
|
13
|
+
# Money/Total carry amounts and currency only, no PII; the PII that
|
|
14
|
+
# flows through the gem is on identity-linking results and fulfillment
|
|
15
|
+
# destinations instead.
|
|
11
16
|
module Observability
|
|
12
|
-
REDACTED_KEYS = %w[
|
|
17
|
+
REDACTED_KEYS = %w[
|
|
18
|
+
payment_token oauth_token authorization
|
|
19
|
+
email first_name last_name phone_number
|
|
20
|
+
street_address extended_address address_locality address_region address_country postal_code
|
|
21
|
+
].freeze
|
|
13
22
|
REDACTED = "[REDACTED]".freeze
|
|
14
23
|
|
|
15
24
|
def self.log(logger, event, **fields)
|
|
@@ -11,11 +11,22 @@ module Portage
|
|
|
11
11
|
# authenticated it. Normalizes to a Portage::Ucp::Order and hands off to a
|
|
12
12
|
# consumer-supplied `on_order_event` callback — the gem doesn't assume
|
|
13
13
|
# anything about how the consumer stores or reacts to order events.
|
|
14
|
+
#
|
|
15
|
+
# Emits Observability events (§12) directly via `logger:` rather than a
|
|
16
|
+
# new config.event_sink seam (§23 step 5): this endpoint is a plain Rack
|
|
17
|
+
# app, not built through Mcp::Server.build, so it never runs inside an
|
|
18
|
+
# MCP request and `mcp`'s own around_request/instrumentation_callback
|
|
19
|
+
# hooks can't see it — the same reason CheckoutState (§23 step 3) needed
|
|
20
|
+
# a logger threaded to it, not a reason to invent a second config
|
|
21
|
+
# option: `logger:` already exists on Portage::Ucp.configuration and
|
|
22
|
+
# every other collaborator that logs takes it the same way.
|
|
14
23
|
class WebhookEndpoint
|
|
15
|
-
def initialize(secret:, on_order_event:, signature_header: "HTTP_X_UCP_SIGNATURE"
|
|
24
|
+
def initialize(secret:, on_order_event:, signature_header: "HTTP_X_UCP_SIGNATURE",
|
|
25
|
+
logger: Portage::Ucp.configuration.logger)
|
|
16
26
|
@secret = secret
|
|
17
27
|
@on_order_event = on_order_event
|
|
18
28
|
@signature_header = signature_header
|
|
29
|
+
@logger = logger
|
|
19
30
|
end
|
|
20
31
|
|
|
21
32
|
def call(env)
|
|
@@ -23,7 +34,10 @@ module Portage
|
|
|
23
34
|
return respond(404, error: "not_found") unless request.post?
|
|
24
35
|
|
|
25
36
|
body = request.body.read
|
|
26
|
-
|
|
37
|
+
unless valid_signature?(body, env[@signature_header])
|
|
38
|
+
Portage::Ucp::Observability.log(@logger, "order_webhook_rejected", reason: "invalid_signature")
|
|
39
|
+
return respond(401, error: "invalid_signature")
|
|
40
|
+
end
|
|
27
41
|
|
|
28
42
|
handle_order_event(body)
|
|
29
43
|
end
|
|
@@ -32,9 +46,13 @@ module Portage
|
|
|
32
46
|
|
|
33
47
|
def handle_order_event(body)
|
|
34
48
|
payload = JSON.parse(body, symbolize_names: true)
|
|
35
|
-
|
|
49
|
+
order = Portage::Ucp::Order.new(**payload)
|
|
50
|
+
Portage::Ucp::Observability.log(@logger, "order_webhook_received", order_id: order.id,
|
|
51
|
+
checkout_id: order.checkout_id)
|
|
52
|
+
@on_order_event.call(order)
|
|
36
53
|
respond(200, ok: true)
|
|
37
54
|
rescue JSON::ParserError, ArgumentError
|
|
55
|
+
Portage::Ucp::Observability.log(@logger, "order_webhook_rejected", reason: "bad_request")
|
|
38
56
|
respond(400, error: "bad_request")
|
|
39
57
|
end
|
|
40
58
|
|