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.
Files changed (112) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +15 -0
  3. data/LICENSE +21 -0
  4. data/README.md +97 -0
  5. data/exe/portage-ucp-check +25 -0
  6. data/lib/portage/ucp/adapter.rb +58 -0
  7. data/lib/portage/ucp/authenticator.rb +26 -0
  8. data/lib/portage/ucp/capabilities/cart.rb +16 -0
  9. data/lib/portage/ucp/capabilities/catalog.rb +11 -0
  10. data/lib/portage/ucp/capabilities/checkout.rb +17 -0
  11. data/lib/portage/ucp/capabilities/identity_linking.rb +13 -0
  12. data/lib/portage/ucp/capabilities/order.rb +11 -0
  13. data/lib/portage/ucp/capability.rb +27 -0
  14. data/lib/portage/ucp/capability_negotiator.rb +40 -0
  15. data/lib/portage/ucp/capability_registry.rb +21 -0
  16. data/lib/portage/ucp/check.rb +96 -0
  17. data/lib/portage/ucp/configuration.rb +35 -0
  18. data/lib/portage/ucp/dispatcher.rb +39 -0
  19. data/lib/portage/ucp/errors.rb +12 -0
  20. data/lib/portage/ucp/manifest.rb +58 -0
  21. data/lib/portage/ucp/mcp/server.rb +83 -0
  22. data/lib/portage/ucp/observability.rb +31 -0
  23. data/lib/portage/ucp/payment_token_guard.rb +41 -0
  24. data/lib/portage/ucp/rack/manifest_endpoint.rb +49 -0
  25. data/lib/portage/ucp/rack/webhook_endpoint.rb +54 -0
  26. data/lib/portage/ucp/rate_limiter.rb +27 -0
  27. data/lib/portage/ucp/resolver.rb +133 -0
  28. data/lib/portage/ucp/schema_validator.rb +53 -0
  29. data/lib/portage/ucp/support/amounts.rb +43 -0
  30. data/lib/portage/ucp/support/api_error.rb +39 -0
  31. data/lib/portage/ucp/support/checkout_state.rb +42 -0
  32. data/lib/portage/ucp/support/http_client.rb +54 -0
  33. data/lib/portage/ucp/support/idempotency.rb +26 -0
  34. data/lib/portage/ucp/support/line_item_status.rb +44 -0
  35. data/lib/portage/ucp/support/not_found.rb +25 -0
  36. data/lib/portage/ucp/support/token_exchange.rb +43 -0
  37. data/lib/portage/ucp/support/totals.rb +31 -0
  38. data/lib/portage/ucp/value_objects.rb +186 -0
  39. data/lib/portage/ucp/version.rb +5 -0
  40. data/lib/portage/ucp/wire_envelope.rb +25 -0
  41. data/lib/portage/ucp.rb +44 -0
  42. data/schemas/2026-04-08/schemas/capability.json +70 -0
  43. data/schemas/2026-04-08/schemas/payment_handler.json +67 -0
  44. data/schemas/2026-04-08/schemas/service.json +190 -0
  45. data/schemas/2026-04-08/schemas/shopping/cart.json +134 -0
  46. data/schemas/2026-04-08/schemas/shopping/catalog_lookup.json +207 -0
  47. data/schemas/2026-04-08/schemas/shopping/catalog_search.json +64 -0
  48. data/schemas/2026-04-08/schemas/shopping/checkout.json +131 -0
  49. data/schemas/2026-04-08/schemas/shopping/discount.json +148 -0
  50. data/schemas/2026-04-08/schemas/shopping/fulfillment.json +154 -0
  51. data/schemas/2026-04-08/schemas/shopping/order.json +113 -0
  52. data/schemas/2026-04-08/schemas/shopping/payment.json +16 -0
  53. data/schemas/2026-04-08/schemas/shopping/types/adjustment.json +69 -0
  54. data/schemas/2026-04-08/schemas/shopping/types/amount.json +8 -0
  55. data/schemas/2026-04-08/schemas/shopping/types/attribution.json +11 -0
  56. data/schemas/2026-04-08/schemas/shopping/types/available_payment_instrument.json +22 -0
  57. data/schemas/2026-04-08/schemas/shopping/types/business_fulfillment_config.json +38 -0
  58. data/schemas/2026-04-08/schemas/shopping/types/buyer.json +25 -0
  59. data/schemas/2026-04-08/schemas/shopping/types/category.json +20 -0
  60. data/schemas/2026-04-08/schemas/shopping/types/context.json +42 -0
  61. data/schemas/2026-04-08/schemas/shopping/types/description.json +22 -0
  62. data/schemas/2026-04-08/schemas/shopping/types/detail_option_value.json +22 -0
  63. data/schemas/2026-04-08/schemas/shopping/types/error_code.json +17 -0
  64. data/schemas/2026-04-08/schemas/shopping/types/error_response.json +31 -0
  65. data/schemas/2026-04-08/schemas/shopping/types/expectation.json +62 -0
  66. data/schemas/2026-04-08/schemas/shopping/types/fulfillment.json +25 -0
  67. data/schemas/2026-04-08/schemas/shopping/types/fulfillment_available_method.json +45 -0
  68. data/schemas/2026-04-08/schemas/shopping/types/fulfillment_destination.json +16 -0
  69. data/schemas/2026-04-08/schemas/shopping/types/fulfillment_event.json +67 -0
  70. data/schemas/2026-04-08/schemas/shopping/types/fulfillment_group.json +45 -0
  71. data/schemas/2026-04-08/schemas/shopping/types/fulfillment_method.json +67 -0
  72. data/schemas/2026-04-08/schemas/shopping/types/fulfillment_option.json +56 -0
  73. data/schemas/2026-04-08/schemas/shopping/types/info_code.json +13 -0
  74. data/schemas/2026-04-08/schemas/shopping/types/input_correlation.json +24 -0
  75. data/schemas/2026-04-08/schemas/shopping/types/item.json +33 -0
  76. data/schemas/2026-04-08/schemas/shopping/types/line_item.json +46 -0
  77. data/schemas/2026-04-08/schemas/shopping/types/link.json +25 -0
  78. data/schemas/2026-04-08/schemas/shopping/types/media.json +36 -0
  79. data/schemas/2026-04-08/schemas/shopping/types/message.json +18 -0
  80. data/schemas/2026-04-08/schemas/shopping/types/message_error.json +49 -0
  81. data/schemas/2026-04-08/schemas/shopping/types/message_info.json +37 -0
  82. data/schemas/2026-04-08/schemas/shopping/types/message_warning.json +53 -0
  83. data/schemas/2026-04-08/schemas/shopping/types/option_value.json +20 -0
  84. data/schemas/2026-04-08/schemas/shopping/types/order_confirmation.json +26 -0
  85. data/schemas/2026-04-08/schemas/shopping/types/order_line_item.json +69 -0
  86. data/schemas/2026-04-08/schemas/shopping/types/pagination.json +62 -0
  87. data/schemas/2026-04-08/schemas/shopping/types/payment_credential.json +17 -0
  88. data/schemas/2026-04-08/schemas/shopping/types/payment_instrument.json +58 -0
  89. data/schemas/2026-04-08/schemas/shopping/types/platform_fulfillment_config.json +14 -0
  90. data/schemas/2026-04-08/schemas/shopping/types/postal_address.json +44 -0
  91. data/schemas/2026-04-08/schemas/shopping/types/price.json +22 -0
  92. data/schemas/2026-04-08/schemas/shopping/types/price_filter.json +17 -0
  93. data/schemas/2026-04-08/schemas/shopping/types/price_range.json +21 -0
  94. data/schemas/2026-04-08/schemas/shopping/types/product.json +89 -0
  95. data/schemas/2026-04-08/schemas/shopping/types/product_option.json +25 -0
  96. data/schemas/2026-04-08/schemas/shopping/types/rating.json +34 -0
  97. data/schemas/2026-04-08/schemas/shopping/types/retail_location.json +28 -0
  98. data/schemas/2026-04-08/schemas/shopping/types/reverse_domain_name.json +8 -0
  99. data/schemas/2026-04-08/schemas/shopping/types/search_filters.json +20 -0
  100. data/schemas/2026-04-08/schemas/shopping/types/selected_option.json +25 -0
  101. data/schemas/2026-04-08/schemas/shopping/types/shipping_destination.json +26 -0
  102. data/schemas/2026-04-08/schemas/shopping/types/signals.json +22 -0
  103. data/schemas/2026-04-08/schemas/shopping/types/signed_amount.json +7 -0
  104. data/schemas/2026-04-08/schemas/shopping/types/total.json +75 -0
  105. data/schemas/2026-04-08/schemas/shopping/types/totals.json +98 -0
  106. data/schemas/2026-04-08/schemas/shopping/types/variant.json +193 -0
  107. data/schemas/2026-04-08/schemas/shopping/types/warning_code.json +13 -0
  108. data/schemas/2026-04-08/schemas/transports/embedded_config.json +27 -0
  109. data/schemas/2026-04-08/schemas/ucp.json +364 -0
  110. data/schemas/2026-04-08/services/shopping/embedded.openrpc.json +634 -0
  111. data/schemas/2026-04-08/services/shopping/mcp.openrpc.json +492 -0
  112. 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,11 @@
1
+ module Portage
2
+ module Ucp
3
+ module Capabilities
4
+ CATALOG = Portage::Ucp::Capability.new(
5
+ name: "dev.ucp.shopping.catalog",
6
+ version: "1",
7
+ actions: { "search_catalog" => :search_catalog, "get_product" => :get_product }
8
+ )
9
+ end
10
+ end
11
+ 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,11 @@
1
+ module Portage
2
+ module Ucp
3
+ module Capabilities
4
+ ORDER = Portage::Ucp::Capability.new(
5
+ name: "dev.ucp.shopping.order",
6
+ version: "1",
7
+ actions: { "get_order" => :get_order }
8
+ )
9
+ end
10
+ end
11
+ 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