portage-ucp 0.1.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 +7 -0
- data/CHANGELOG.md +15 -0
- data/LICENSE +21 -0
- data/README.md +97 -0
- data/exe/portage-ucp-check +25 -0
- data/lib/portage/ucp/adapter.rb +58 -0
- data/lib/portage/ucp/authenticator.rb +26 -0
- data/lib/portage/ucp/capabilities/cart.rb +16 -0
- data/lib/portage/ucp/capabilities/catalog.rb +11 -0
- data/lib/portage/ucp/capabilities/checkout.rb +17 -0
- data/lib/portage/ucp/capabilities/identity_linking.rb +13 -0
- data/lib/portage/ucp/capabilities/order.rb +11 -0
- data/lib/portage/ucp/capability.rb +27 -0
- data/lib/portage/ucp/capability_negotiator.rb +40 -0
- data/lib/portage/ucp/capability_registry.rb +21 -0
- data/lib/portage/ucp/check.rb +96 -0
- data/lib/portage/ucp/configuration.rb +35 -0
- data/lib/portage/ucp/dispatcher.rb +39 -0
- data/lib/portage/ucp/errors.rb +12 -0
- data/lib/portage/ucp/manifest.rb +58 -0
- data/lib/portage/ucp/mcp/server.rb +83 -0
- data/lib/portage/ucp/observability.rb +31 -0
- data/lib/portage/ucp/payment_token_guard.rb +41 -0
- data/lib/portage/ucp/rack/manifest_endpoint.rb +49 -0
- data/lib/portage/ucp/rack/webhook_endpoint.rb +54 -0
- data/lib/portage/ucp/rate_limiter.rb +27 -0
- data/lib/portage/ucp/resolver.rb +133 -0
- data/lib/portage/ucp/schema_validator.rb +53 -0
- data/lib/portage/ucp/support/amounts.rb +43 -0
- data/lib/portage/ucp/support/api_error.rb +39 -0
- data/lib/portage/ucp/support/checkout_state.rb +42 -0
- data/lib/portage/ucp/support/http_client.rb +54 -0
- data/lib/portage/ucp/support/idempotency.rb +26 -0
- data/lib/portage/ucp/support/line_item_status.rb +44 -0
- data/lib/portage/ucp/support/not_found.rb +25 -0
- data/lib/portage/ucp/support/token_exchange.rb +43 -0
- data/lib/portage/ucp/support/totals.rb +31 -0
- data/lib/portage/ucp/value_objects.rb +186 -0
- data/lib/portage/ucp/version.rb +5 -0
- data/lib/portage/ucp/wire_envelope.rb +25 -0
- data/lib/portage/ucp.rb +44 -0
- data/schemas/2026-04-08/schemas/capability.json +70 -0
- data/schemas/2026-04-08/schemas/payment_handler.json +67 -0
- data/schemas/2026-04-08/schemas/service.json +190 -0
- data/schemas/2026-04-08/schemas/shopping/cart.json +134 -0
- data/schemas/2026-04-08/schemas/shopping/catalog_lookup.json +207 -0
- data/schemas/2026-04-08/schemas/shopping/catalog_search.json +64 -0
- data/schemas/2026-04-08/schemas/shopping/checkout.json +131 -0
- data/schemas/2026-04-08/schemas/shopping/discount.json +148 -0
- data/schemas/2026-04-08/schemas/shopping/fulfillment.json +154 -0
- data/schemas/2026-04-08/schemas/shopping/order.json +113 -0
- data/schemas/2026-04-08/schemas/shopping/payment.json +16 -0
- data/schemas/2026-04-08/schemas/shopping/types/adjustment.json +69 -0
- data/schemas/2026-04-08/schemas/shopping/types/amount.json +8 -0
- data/schemas/2026-04-08/schemas/shopping/types/attribution.json +11 -0
- data/schemas/2026-04-08/schemas/shopping/types/available_payment_instrument.json +22 -0
- data/schemas/2026-04-08/schemas/shopping/types/business_fulfillment_config.json +38 -0
- data/schemas/2026-04-08/schemas/shopping/types/buyer.json +25 -0
- data/schemas/2026-04-08/schemas/shopping/types/category.json +20 -0
- data/schemas/2026-04-08/schemas/shopping/types/context.json +42 -0
- data/schemas/2026-04-08/schemas/shopping/types/description.json +22 -0
- data/schemas/2026-04-08/schemas/shopping/types/detail_option_value.json +22 -0
- data/schemas/2026-04-08/schemas/shopping/types/error_code.json +17 -0
- data/schemas/2026-04-08/schemas/shopping/types/error_response.json +31 -0
- data/schemas/2026-04-08/schemas/shopping/types/expectation.json +62 -0
- data/schemas/2026-04-08/schemas/shopping/types/fulfillment.json +25 -0
- data/schemas/2026-04-08/schemas/shopping/types/fulfillment_available_method.json +45 -0
- data/schemas/2026-04-08/schemas/shopping/types/fulfillment_destination.json +16 -0
- data/schemas/2026-04-08/schemas/shopping/types/fulfillment_event.json +67 -0
- data/schemas/2026-04-08/schemas/shopping/types/fulfillment_group.json +45 -0
- data/schemas/2026-04-08/schemas/shopping/types/fulfillment_method.json +67 -0
- data/schemas/2026-04-08/schemas/shopping/types/fulfillment_option.json +56 -0
- data/schemas/2026-04-08/schemas/shopping/types/info_code.json +13 -0
- data/schemas/2026-04-08/schemas/shopping/types/input_correlation.json +24 -0
- data/schemas/2026-04-08/schemas/shopping/types/item.json +33 -0
- data/schemas/2026-04-08/schemas/shopping/types/line_item.json +46 -0
- data/schemas/2026-04-08/schemas/shopping/types/link.json +25 -0
- data/schemas/2026-04-08/schemas/shopping/types/media.json +36 -0
- data/schemas/2026-04-08/schemas/shopping/types/message.json +18 -0
- data/schemas/2026-04-08/schemas/shopping/types/message_error.json +49 -0
- data/schemas/2026-04-08/schemas/shopping/types/message_info.json +37 -0
- data/schemas/2026-04-08/schemas/shopping/types/message_warning.json +53 -0
- data/schemas/2026-04-08/schemas/shopping/types/option_value.json +20 -0
- data/schemas/2026-04-08/schemas/shopping/types/order_confirmation.json +26 -0
- data/schemas/2026-04-08/schemas/shopping/types/order_line_item.json +69 -0
- data/schemas/2026-04-08/schemas/shopping/types/pagination.json +62 -0
- data/schemas/2026-04-08/schemas/shopping/types/payment_credential.json +17 -0
- data/schemas/2026-04-08/schemas/shopping/types/payment_instrument.json +58 -0
- data/schemas/2026-04-08/schemas/shopping/types/platform_fulfillment_config.json +14 -0
- data/schemas/2026-04-08/schemas/shopping/types/postal_address.json +44 -0
- data/schemas/2026-04-08/schemas/shopping/types/price.json +22 -0
- data/schemas/2026-04-08/schemas/shopping/types/price_filter.json +17 -0
- data/schemas/2026-04-08/schemas/shopping/types/price_range.json +21 -0
- data/schemas/2026-04-08/schemas/shopping/types/product.json +89 -0
- data/schemas/2026-04-08/schemas/shopping/types/product_option.json +25 -0
- data/schemas/2026-04-08/schemas/shopping/types/rating.json +34 -0
- data/schemas/2026-04-08/schemas/shopping/types/retail_location.json +28 -0
- data/schemas/2026-04-08/schemas/shopping/types/reverse_domain_name.json +8 -0
- data/schemas/2026-04-08/schemas/shopping/types/search_filters.json +20 -0
- data/schemas/2026-04-08/schemas/shopping/types/selected_option.json +25 -0
- data/schemas/2026-04-08/schemas/shopping/types/shipping_destination.json +26 -0
- data/schemas/2026-04-08/schemas/shopping/types/signals.json +22 -0
- data/schemas/2026-04-08/schemas/shopping/types/signed_amount.json +7 -0
- data/schemas/2026-04-08/schemas/shopping/types/total.json +75 -0
- data/schemas/2026-04-08/schemas/shopping/types/totals.json +98 -0
- data/schemas/2026-04-08/schemas/shopping/types/variant.json +193 -0
- data/schemas/2026-04-08/schemas/shopping/types/warning_code.json +13 -0
- data/schemas/2026-04-08/schemas/transports/embedded_config.json +27 -0
- data/schemas/2026-04-08/schemas/ucp.json +364 -0
- data/schemas/2026-04-08/services/shopping/embedded.openrpc.json +634 -0
- data/schemas/2026-04-08/services/shopping/mcp.openrpc.json +492 -0
- metadata +283 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: a9198c70ed7abc380268adc344d7496c049a3c78c0ed28b16944e3b27d9d01ba
|
|
4
|
+
data.tar.gz: c076c1b4e544825d0db160bf2453fed3e256b2024c7aafb8f431227ec22364ef
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 80aac639014575e4416c3e477f451bf9cb25b3cd69ab8a3cda512bd19243d51a95b54c5086b738fc9140be4e0318cb17b81a30767f2ba6b18546b6cd22e9a33b
|
|
7
|
+
data.tar.gz: dc0f578ee8d379f9f12c4605dcaf37757d5518f2764087186669a4809773b5bd869192328a0027ef61fd36f5a4dbc629697841e57f9065c96a9a5d5d7796c11a
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. Format loosely follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.0.0/); this project is
|
|
5
|
+
pre-1.0, so APIs may still shift between minor versions.
|
|
6
|
+
|
|
7
|
+
## [0.1.0] - Unreleased
|
|
8
|
+
|
|
9
|
+
- Initial pre-release. Protocol-only core: `Adapter` contract, capability
|
|
10
|
+
registry, manifest builder, MCP server wrapper, offline `SchemaValidator`,
|
|
11
|
+
`portage-ucp-check` CLI.
|
|
12
|
+
- `Portage::Ucp::Support`: shared building blocks the adapter gems mix in
|
|
13
|
+
(money conversion, totals shapes, idempotency dedup, checkout-state
|
|
14
|
+
tracking, `ApiError`, 404-to-nil reads, Net::HTTP JSON client, OAuth token
|
|
15
|
+
exchange). Not used by the core gem's own request path.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tom Whitbread
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# portage-ucp
|
|
2
|
+
|
|
3
|
+
Protocol-only core gem: expose a commerce backend to AI shopping agents over **MCP**
|
|
4
|
+
([Model Context Protocol](https://modelcontextprotocol.io)) and **UCP**
|
|
5
|
+
([Universal Commerce Protocol](https://ucp.dev)) at once. Zero commerce-backend
|
|
6
|
+
dependencies — works with any backend that implements `Adapter`, Shopify or
|
|
7
|
+
otherwise. Adapter gems (`portage-ucp-shopify`, `portage-ucp-wix`, ...) are
|
|
8
|
+
consumers of this gem, not dependencies of it.
|
|
9
|
+
|
|
10
|
+
See the [root README](https://github.com/tomtom87/Portage#readme) for the full
|
|
11
|
+
walkthrough (an agent discovering a manifest and buying a snowboard end to end),
|
|
12
|
+
security model, and adapter comparison table. This README covers just what lives
|
|
13
|
+
in this gem.
|
|
14
|
+
|
|
15
|
+
## What it ships
|
|
16
|
+
|
|
17
|
+
| Class | Role |
|
|
18
|
+
|---|---|
|
|
19
|
+
| `Portage::Ucp::Adapter` | The contract your backend implements — override only the catalog/cart/checkout/order/identity methods you support; the rest stay unadvertised. |
|
|
20
|
+
| `Portage::Ucp::CapabilityRegistry` | Figures out which capabilities an `Adapter` actually backs. |
|
|
21
|
+
| `Portage::Ucp::Dispatcher` | Routes a capability+action call to the right `Adapter` method. |
|
|
22
|
+
| `Portage::Ucp::Mcp::Server` | Wraps an `Adapter` as an MCP server — one `MCP::Tool` per advertised action, stdio or Streamable HTTP. |
|
|
23
|
+
| `Portage::Ucp::Manifest` | Builds the signed `/.well-known/ucp` discovery document. |
|
|
24
|
+
| `Portage::Ucp::Rack::ManifestEndpoint` | Serves that manifest over Rack. |
|
|
25
|
+
| `Portage::Ucp::Rack::WebhookEndpoint` | HMAC-verified inbound order-lifecycle webhooks. |
|
|
26
|
+
| `Portage::Ucp::SchemaValidator` | Validates data against UCP's own vendored JSON Schemas/OpenRPC docs, offline. |
|
|
27
|
+
| `Portage::Ucp::Resolver` / `exe/portage-ucp-check` | Probes any store's homepage/`.well-known/ucp` and recommends the matching adapter gem. |
|
|
28
|
+
|
|
29
|
+
Security defaults are all locked down, not permissive-by-omission —
|
|
30
|
+
`UnconfiguredAuthenticator` rejects every mutating call until you configure a real
|
|
31
|
+
one, `PaymentTokenGuard` rejects raw card numbers before they reach your `Adapter`,
|
|
32
|
+
and manifest signing is opt-in. Full detail in the root README's
|
|
33
|
+
[Security hooks](https://github.com/tomtom87/Portage#security-hooks--nothing-is-permissive-by-default)
|
|
34
|
+
section.
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
# Gemfile
|
|
40
|
+
gem "portage-ucp"
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
bundle install
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Usage
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
require "portage/ucp"
|
|
51
|
+
|
|
52
|
+
class MyAdapter < Portage::Ucp::Adapter
|
|
53
|
+
def search_catalog(query:, limit:) = ...
|
|
54
|
+
def get_product(product_id:) = ...
|
|
55
|
+
def create_cart(line_items:, idempotency_key:) = ...
|
|
56
|
+
# override only the capabilities you support
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
Portage::Ucp.configure do |config|
|
|
60
|
+
config.authenticator = MyAuthenticator.new
|
|
61
|
+
config.rate_limiter = MyRateLimiter.new
|
|
62
|
+
config.business = { name: "Your Store", url: "https://your-shop.example" }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
server = Portage::Ucp::Mcp::Server.build(adapter: MyAdapter.new)
|
|
66
|
+
server.start
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
See the root README's [Usage](https://github.com/tomtom87/Portage#usage)
|
|
70
|
+
and the [detailed walkthrough](https://github.com/tomtom87/Portage/blob/main/docs/walkthrough.md)
|
|
71
|
+
for the full agent-side conversation, manifest/webhook Rack mounting, and a real
|
|
72
|
+
adapter to model your own against.
|
|
73
|
+
|
|
74
|
+
## Checking any store
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
bundle exec portage-ucp-check your-shop.example
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Tries `/.well-known/ucp` first; falls back to platform detection and names the
|
|
81
|
+
matching `portage-ucp-<adapter>` gem, live-probing it if credentials are already in
|
|
82
|
+
env. See the root README's [Checking any store](https://github.com/tomtom87/Portage#checking-any-store)
|
|
83
|
+
section for sample output.
|
|
84
|
+
|
|
85
|
+
## Development
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
bundle exec rspec
|
|
89
|
+
bundle exec rubocop
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
See the [design log](https://github.com/tomtom87/Portage/blob/main/docs/design-log.md) for the
|
|
93
|
+
design rationale and decision history behind this project.
|
|
94
|
+
|
|
95
|
+
## License
|
|
96
|
+
|
|
97
|
+
[MIT](LICENSE) — Copyright (c) 2026 Tom Whitbread.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Point this at any storefront URL and find out what portage-ucp can do with
|
|
5
|
+
# it: whether it already serves a native /.well-known/ucp manifest, and if
|
|
6
|
+
# not, which portage-ucp-<adapter> gem matches the platform — probed live
|
|
7
|
+
# when that adapter's env vars (see each gem's README) are already set.
|
|
8
|
+
#
|
|
9
|
+
# portage-ucp-check https://your-shop.myshopify.com
|
|
10
|
+
# portage-ucp-check your-shop.myshopify.com # scheme defaults to https
|
|
11
|
+
|
|
12
|
+
require "json"
|
|
13
|
+
require "portage/ucp"
|
|
14
|
+
|
|
15
|
+
url = ARGV.first
|
|
16
|
+
if url.nil? || url.empty?
|
|
17
|
+
warn "usage: portage-ucp-check <store url>"
|
|
18
|
+
exit 1
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
report = Portage::Ucp::Check.call(url)
|
|
22
|
+
puts JSON.pretty_generate(report)
|
|
23
|
+
|
|
24
|
+
found_something = !report[:native_ucp].nil? || report.dig(:live_probe, :status) == "ok"
|
|
25
|
+
exit(found_something ? 0 : 1)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Ucp
|
|
3
|
+
# @abstract Subclass and override the methods for the capabilities you support.
|
|
4
|
+
# Unoverridden methods leave that capability out of the manifest.
|
|
5
|
+
class Adapter
|
|
6
|
+
# --- Catalog (dev.ucp.shopping.catalog) ---
|
|
7
|
+
# @return [Array<Portage::Ucp::Product>]
|
|
8
|
+
def search_catalog(query:, limit:) = not_implemented
|
|
9
|
+
# @return [Portage::Ucp::Product, nil]
|
|
10
|
+
def get_product(product_id:) = not_implemented
|
|
11
|
+
|
|
12
|
+
# --- Cart (dev.ucp.shopping.cart) ---
|
|
13
|
+
# Full-replacement semantics, matching UCP's real cart methods: create/
|
|
14
|
+
# update take the complete desired line_items list, not a single item.
|
|
15
|
+
# `line_items:` is an array of request-shaped hashes (e.g.
|
|
16
|
+
# `{product_id:, quantity:}`) — the adapter looks up product data and
|
|
17
|
+
# builds the response's Item/Total/LineItem itself.
|
|
18
|
+
# @return [Portage::Ucp::Cart]
|
|
19
|
+
def get_cart(cart_id:) = not_implemented
|
|
20
|
+
# @return [Portage::Ucp::Cart]
|
|
21
|
+
def create_cart(line_items:, idempotency_key:) = not_implemented
|
|
22
|
+
# @return [Portage::Ucp::Cart]
|
|
23
|
+
def update_cart(cart_id:, line_items:, idempotency_key:) = not_implemented
|
|
24
|
+
# @return [Portage::Ucp::Cart]
|
|
25
|
+
def cancel_cart(cart_id:, idempotency_key:) = not_implemented
|
|
26
|
+
|
|
27
|
+
# --- Checkout (dev.ucp.shopping.checkout) ---
|
|
28
|
+
# @return [Portage::Ucp::Checkout]
|
|
29
|
+
def create_checkout(line_items:, idempotency_key:) = not_implemented
|
|
30
|
+
# @return [Portage::Ucp::Checkout]
|
|
31
|
+
def get_checkout(checkout_id:) = not_implemented
|
|
32
|
+
# Full-replacement, same as update_cart — line_items is required on
|
|
33
|
+
# checkout update per the real spec.
|
|
34
|
+
# @return [Portage::Ucp::Checkout]
|
|
35
|
+
def update_checkout(checkout_id:, line_items:, idempotency_key:) = not_implemented
|
|
36
|
+
# @param payment_token [String] single-use token from a UCP payment handler / AP2
|
|
37
|
+
# exchange — NEVER a raw PAN.
|
|
38
|
+
# @return [Portage::Ucp::Checkout]
|
|
39
|
+
def complete_checkout(checkout_id:, payment_token:, idempotency_key:) = not_implemented
|
|
40
|
+
# @return [Portage::Ucp::Checkout]
|
|
41
|
+
def cancel_checkout(checkout_id:, idempotency_key:) = not_implemented
|
|
42
|
+
|
|
43
|
+
# --- Order (dev.ucp.shopping.order) ---
|
|
44
|
+
# @return [Portage::Ucp::Order, nil]
|
|
45
|
+
def get_order(order_id:) = not_implemented
|
|
46
|
+
|
|
47
|
+
# --- Identity Linking (dev.ucp.shopping.identity, OAuth 2.0) ---
|
|
48
|
+
# @return [Portage::Ucp::Identity] linked profile for an exchanged OAuth token
|
|
49
|
+
def link_identity(oauth_token:) = not_implemented
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def not_implemented
|
|
54
|
+
raise Portage::Ucp::NotImplementedError, "#{self.class} does not implement this capability"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Ucp
|
|
3
|
+
# @abstract Pluggable auth contract (§9). Given the MCP server_context for a
|
|
4
|
+
# tool call, return an auth context (any truthy value) or raise
|
|
5
|
+
# Portage::Ucp::AuthenticationError. There is deliberately no default that
|
|
6
|
+
# allows anonymous mutation — an unconfigured server rejects every
|
|
7
|
+
# mutating capability call (see UNCONFIGURED below). Read-only catalog
|
|
8
|
+
# calls MAY be left open at the consumer's explicit choice by not
|
|
9
|
+
# requiring authentication for those specific actions (see
|
|
10
|
+
# Mcp::Server.build's `mutating_only:`).
|
|
11
|
+
class Authenticator
|
|
12
|
+
def call(_server_context)
|
|
13
|
+
raise NotImplementedError, "#{self.class} must implement #call"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# The default when no authenticator is configured: rejects every call it
|
|
18
|
+
# is asked to authenticate. Never permissive by default (§9).
|
|
19
|
+
class UnconfiguredAuthenticator < Authenticator
|
|
20
|
+
def call(_server_context)
|
|
21
|
+
raise Portage::Ucp::AuthenticationError,
|
|
22
|
+
"no authenticator configured — mutating capabilities reject all calls until one is set"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Ucp
|
|
3
|
+
module Capabilities
|
|
4
|
+
CART = Portage::Ucp::Capability.new(
|
|
5
|
+
name: "dev.ucp.shopping.cart",
|
|
6
|
+
version: "1",
|
|
7
|
+
actions: {
|
|
8
|
+
"create_cart" => :create_cart,
|
|
9
|
+
"get_cart" => :get_cart,
|
|
10
|
+
"update_cart" => :update_cart,
|
|
11
|
+
"cancel_cart" => :cancel_cart
|
|
12
|
+
}
|
|
13
|
+
)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Ucp
|
|
3
|
+
module Capabilities
|
|
4
|
+
CHECKOUT = Portage::Ucp::Capability.new(
|
|
5
|
+
name: "dev.ucp.shopping.checkout",
|
|
6
|
+
version: "1",
|
|
7
|
+
actions: {
|
|
8
|
+
"create_checkout" => :create_checkout,
|
|
9
|
+
"get_checkout" => :get_checkout,
|
|
10
|
+
"update_checkout" => :update_checkout,
|
|
11
|
+
"complete_checkout" => :complete_checkout,
|
|
12
|
+
"cancel_checkout" => :cancel_checkout
|
|
13
|
+
}
|
|
14
|
+
)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Ucp
|
|
3
|
+
module Capabilities
|
|
4
|
+
IDENTITY_LINKING = Portage::Ucp::Capability.new(
|
|
5
|
+
name: "dev.ucp.shopping.identity",
|
|
6
|
+
version: "1",
|
|
7
|
+
actions: { "link_identity" => :link_identity }
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
ALL = [CATALOG, CART, CHECKOUT, ORDER, IDENTITY_LINKING].freeze
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Ucp
|
|
3
|
+
# A named, versioned UCP capability (e.g. "dev.ucp.shopping.catalog") mapping
|
|
4
|
+
# UCP action names to the Adapter methods that back them.
|
|
5
|
+
class Capability
|
|
6
|
+
attr_reader :name, :version, :actions
|
|
7
|
+
|
|
8
|
+
def initialize(name:, version:, actions:)
|
|
9
|
+
@name = name
|
|
10
|
+
@version = version
|
|
11
|
+
@actions = actions
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Advertised only if at least one backing Adapter method is overridden —
|
|
15
|
+
# see Portage::Ucp::Adapter for why (contract can grow without breaking adapters).
|
|
16
|
+
def advertised_for?(adapter)
|
|
17
|
+
actions.values.any? { |method_name| overridden?(adapter, method_name) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def overridden?(adapter, method_name)
|
|
23
|
+
adapter.class.instance_method(method_name).owner != Portage::Ucp::Adapter
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Ucp
|
|
3
|
+
# Reconciles UCP's capability-version negotiation with MCP's own
|
|
4
|
+
# transport-level capability negotiation (§10) — they answer different
|
|
5
|
+
# questions and both apply regardless of transport:
|
|
6
|
+
# - MCP `initialize` negotiates protocol features (tools/resources/prompts).
|
|
7
|
+
# - This negotiator picks, per capability, which *version* both sides speak:
|
|
8
|
+
# the platform's advertised versions intersected with the versions this
|
|
9
|
+
# server (its registry + adapter) actually advertises.
|
|
10
|
+
#
|
|
11
|
+
# Over HTTP the platform's advertised versions come from the `UCP-Agent`
|
|
12
|
+
# header; over stdio there's no header, so they arrive in `initialize`
|
|
13
|
+
# params instead. Either way this class only deals in the parsed version
|
|
14
|
+
# list — transports are responsible for extracting it from their own
|
|
15
|
+
# request shape.
|
|
16
|
+
class CapabilityNegotiator
|
|
17
|
+
def initialize(registry: CapabilityRegistry.default)
|
|
18
|
+
@registry = registry
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @param adapter [Portage::Ucp::Adapter]
|
|
22
|
+
# @param platform_versions [Hash<String, Array<String>>] capability name =>
|
|
23
|
+
# versions the platform/agent advertises support for. A capability
|
|
24
|
+
# absent from this hash falls back to "offer all business-advertised
|
|
25
|
+
# versions, newest-first" (§10's stdio fallback, applied uniformly).
|
|
26
|
+
# @return [Hash<String, String>] capability name => negotiated version.
|
|
27
|
+
# A capability with no version in common is omitted, not raised on —
|
|
28
|
+
# it simply isn't usable for this session.
|
|
29
|
+
def negotiate(adapter:, platform_versions: {})
|
|
30
|
+
@registry.advertised(adapter).each_with_object({}) do |capability, negotiated|
|
|
31
|
+
business_versions = [capability.version]
|
|
32
|
+
offered = platform_versions[capability.name]
|
|
33
|
+
|
|
34
|
+
version = offered ? (offered & business_versions).first : business_versions.max
|
|
35
|
+
negotiated[capability.name] = version if version
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Ucp
|
|
3
|
+
class CapabilityRegistry
|
|
4
|
+
def initialize(capabilities:)
|
|
5
|
+
@capabilities = capabilities
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def self.default
|
|
9
|
+
new(capabilities: Portage::Ucp::Capabilities::ALL)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def advertised(adapter)
|
|
13
|
+
@capabilities.select { |capability| capability.advertised_for?(adapter) }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def find(name)
|
|
17
|
+
@capabilities.find { |capability| capability.name == name }
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "uri"
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Portage
|
|
6
|
+
module Ucp
|
|
7
|
+
# `portage-ucp-check <url>` — point it at any storefront and find out what
|
|
8
|
+
# portage-ucp can do with it. Checks for a native `/.well-known/ucp`
|
|
9
|
+
# manifest first (the store may already speak UCP without this gem, see
|
|
10
|
+
# README's "Why /.well-known/ucp?"); if there isn't one, detects the
|
|
11
|
+
# commerce platform from the page itself (see Resolver) and names
|
|
12
|
+
# the matching portage-ucp-<adapter> gem — probing it live when that
|
|
13
|
+
# adapter's env vars are already set, so "best match" means a
|
|
14
|
+
# confirmed-working adapter, not just a guess.
|
|
15
|
+
class Check
|
|
16
|
+
MANIFEST_PATH = "/.well-known/ucp".freeze
|
|
17
|
+
REDIRECT_LIMIT = 5
|
|
18
|
+
|
|
19
|
+
def self.call(url) = new(url).call
|
|
20
|
+
|
|
21
|
+
def initialize(url)
|
|
22
|
+
raw = url.to_s.strip
|
|
23
|
+
raw = "https://#{raw}" unless raw =~ %r{\Ahttps?://}i
|
|
24
|
+
@uri = URI.parse(raw)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def call
|
|
28
|
+
manifest = fetch_manifest
|
|
29
|
+
return { url: @uri.to_s, native_ucp: manifest } if manifest
|
|
30
|
+
|
|
31
|
+
body, headers = fetch_homepage
|
|
32
|
+
platform = Resolver.detect_platform(body, headers)
|
|
33
|
+
|
|
34
|
+
report = { url: @uri.to_s, native_ucp: nil, platform: platform&.name, recommended_gem: platform&.gem }
|
|
35
|
+
report[:live_probe] = probe(platform) if platform
|
|
36
|
+
report
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def fetch_manifest
|
|
42
|
+
manifest_uri = @uri.dup
|
|
43
|
+
manifest_uri.path = MANIFEST_PATH
|
|
44
|
+
response = get(manifest_uri)
|
|
45
|
+
return nil unless response.is_a?(Net::HTTPSuccess)
|
|
46
|
+
|
|
47
|
+
JSON.parse(response.body)
|
|
48
|
+
rescue StandardError
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def fetch_homepage
|
|
53
|
+
response = get(@uri)
|
|
54
|
+
return [nil, {}] unless response.is_a?(Net::HTTPSuccess)
|
|
55
|
+
|
|
56
|
+
[response.body, response.to_hash]
|
|
57
|
+
rescue StandardError
|
|
58
|
+
[nil, {}]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def probe(platform)
|
|
62
|
+
env = Resolver.env_for(platform)
|
|
63
|
+
missing = Resolver.missing_env(platform, env)
|
|
64
|
+
return { status: "skipped", reason: "missing env vars: #{missing.join(', ')}" } if missing.any?
|
|
65
|
+
|
|
66
|
+
run_probe(platform, env)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def run_probe(platform, env)
|
|
70
|
+
adapter = Resolver.build_adapter(platform, env)
|
|
71
|
+
product = adapter.search_catalog(query: "", limit: 1)&.first
|
|
72
|
+
{ status: "ok", sample_product: product && { id: product.id, title: product.title } }
|
|
73
|
+
rescue LoadError
|
|
74
|
+
{ status: "skipped", reason: "gem not installed: #{platform.gem}" }
|
|
75
|
+
rescue StandardError => e
|
|
76
|
+
{ status: "error", reason: "#{e.class}: #{e.message}" }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def get(uri, limit = REDIRECT_LIMIT)
|
|
80
|
+
raise Portage::Ucp::Error, "too many redirects" if limit.zero?
|
|
81
|
+
|
|
82
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
|
83
|
+
open_timeout: 5, read_timeout: 5) do |http|
|
|
84
|
+
http.get(uri.request_uri, { "User-Agent" => "portage-ucp-check" })
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
case response
|
|
88
|
+
when Net::HTTPRedirection
|
|
89
|
+
get(URI.join(uri, response["location"]), limit - 1)
|
|
90
|
+
else
|
|
91
|
+
response
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
require "logger"
|
|
2
|
+
|
|
3
|
+
module Portage
|
|
4
|
+
module Ucp
|
|
5
|
+
# Global defaults, set once via `Portage::Ucp.configure { |c| ... }`. Nothing
|
|
6
|
+
# requires this — every collaborator can still be passed explicitly to
|
|
7
|
+
# Mcp::Server.build/Manifest.new — but it's the one place a consumer wires
|
|
8
|
+
# up their authenticator/rate limiter/logger without threading them
|
|
9
|
+
# through every call site.
|
|
10
|
+
class Configuration
|
|
11
|
+
attr_accessor :registry, :authenticator, :rate_limiter, :logger,
|
|
12
|
+
:business, :signer, :payment_handlers, :signing_keys, :services
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
@registry = CapabilityRegistry.default
|
|
16
|
+
@authenticator = UnconfiguredAuthenticator.new
|
|
17
|
+
@rate_limiter = NullRateLimiter.new
|
|
18
|
+
@logger = Logger.new($stdout)
|
|
19
|
+
@payment_handlers = []
|
|
20
|
+
@signing_keys = []
|
|
21
|
+
@services = []
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
class << self
|
|
26
|
+
def configuration
|
|
27
|
+
@configuration ||= Configuration.new
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def configure
|
|
31
|
+
yield configuration
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Ucp
|
|
3
|
+
# Accepts a UCP-shaped request (capability + action + arguments), routes it
|
|
4
|
+
# through the CapabilityRegistry to the backing Adapter method, and wraps
|
|
5
|
+
# the result as MCP's dual content/structuredContent output (see §5).
|
|
6
|
+
class Dispatcher
|
|
7
|
+
def initialize(adapter:, registry: CapabilityRegistry.default)
|
|
8
|
+
@adapter = adapter
|
|
9
|
+
@registry = registry
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call(capability:, action:, arguments: {})
|
|
13
|
+
capability_definition = @registry.find(capability)
|
|
14
|
+
raise UnknownCapabilityError, capability if capability_definition.nil?
|
|
15
|
+
|
|
16
|
+
raise CapabilityNotAdvertisedError, capability unless capability_definition.advertised_for?(@adapter)
|
|
17
|
+
|
|
18
|
+
method_name = capability_definition.actions[action]
|
|
19
|
+
raise UnknownActionError, action if method_name.nil?
|
|
20
|
+
|
|
21
|
+
Portage::Ucp::PaymentTokenGuard.validate!(arguments[:payment_token]) if arguments.key?(:payment_token)
|
|
22
|
+
|
|
23
|
+
result = @adapter.public_send(method_name, **arguments)
|
|
24
|
+
wrap(capability, result)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def wrap(capability_name, result)
|
|
30
|
+
unless result.respond_to?(:to_wire_h)
|
|
31
|
+
return { content: [{ type: "text", text: result.inspect }], structuredContent: result }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
payload = Portage::Ucp::WireEnvelope.wrap(capability_name, result.to_wire_h)
|
|
35
|
+
{ content: [{ type: "text", text: payload.inspect }], structuredContent: payload }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Ucp
|
|
3
|
+
class Error < StandardError; end
|
|
4
|
+
class NotImplementedError < Error; end
|
|
5
|
+
class UnknownCapabilityError < Error; end
|
|
6
|
+
class UnknownActionError < Error; end
|
|
7
|
+
class CapabilityNotAdvertisedError < Error; end
|
|
8
|
+
class AuthenticationError < Error; end
|
|
9
|
+
class RawPanRejectedError < Error; end
|
|
10
|
+
class RateLimitExceededError < Error; end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "base64"
|
|
3
|
+
|
|
4
|
+
module Portage
|
|
5
|
+
module Ucp
|
|
6
|
+
# Builds the /.well-known/ucp discovery document: protocol version, the
|
|
7
|
+
# business's advertised capabilities (only those the Adapter overrides —
|
|
8
|
+
# see Capability#advertised_for?), payment handlers, signing keys, and the
|
|
9
|
+
# services array (transport + endpoint, e.g. {transport: "mcp", endpoint:
|
|
10
|
+
# "https://..."}) a client needs to find where to actually connect.
|
|
11
|
+
# Signing keys are never generated here — see §9, consumer-provided only.
|
|
12
|
+
class Manifest
|
|
13
|
+
UCP_VERSION = "2026-04-08".freeze
|
|
14
|
+
|
|
15
|
+
# @param signer [#kid, #sign] optional. Consumer-provided — the gem never
|
|
16
|
+
# generates or stores keys itself (§9). `sign(canonical_json_string)`
|
|
17
|
+
# must return raw signature bytes; `kid` identifies which entry in
|
|
18
|
+
# `signing_keys` verifies it (supports a current+next key set for
|
|
19
|
+
# rotation — the caller picks which signer/kid pair is "current").
|
|
20
|
+
# Algorithm-agnostic by design: the gem doesn't dictate Ed25519 vs RSA.
|
|
21
|
+
def initialize(adapter:, business: Portage::Ucp.configuration.business, registry: Portage::Ucp.configuration.registry,
|
|
22
|
+
payment_handlers: Portage::Ucp.configuration.payment_handlers,
|
|
23
|
+
signing_keys: Portage::Ucp.configuration.signing_keys, signer: Portage::Ucp.configuration.signer,
|
|
24
|
+
services: Portage::Ucp.configuration.services)
|
|
25
|
+
@adapter = adapter
|
|
26
|
+
@business = business
|
|
27
|
+
@registry = registry
|
|
28
|
+
@payment_handlers = payment_handlers
|
|
29
|
+
@signing_keys = signing_keys
|
|
30
|
+
@signer = signer
|
|
31
|
+
@services = services
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def to_h
|
|
35
|
+
payload = {
|
|
36
|
+
ucp_version: UCP_VERSION,
|
|
37
|
+
business: @business,
|
|
38
|
+
services: @services,
|
|
39
|
+
capabilities: @registry.advertised(@adapter).map do |capability|
|
|
40
|
+
{ name: capability.name, version: capability.version }
|
|
41
|
+
end,
|
|
42
|
+
payment_handlers: @payment_handlers,
|
|
43
|
+
signing_keys: @signing_keys
|
|
44
|
+
}
|
|
45
|
+
return payload unless @signer
|
|
46
|
+
|
|
47
|
+
payload.merge(signature: sign(payload))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def sign(payload)
|
|
53
|
+
canonical = JSON.generate(payload)
|
|
54
|
+
{ kid: @signer.kid, value: Base64.strict_encode64(@signer.sign(canonical)) }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|