portage-ucp-client 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 +10 -0
- data/LICENSE +21 -0
- data/README.md +69 -0
- data/lib/portage/ucp/client/errors.rb +19 -0
- data/lib/portage/ucp/client/session.rb +90 -0
- data/lib/portage/ucp/client/tool_result.rb +29 -0
- data/lib/portage/ucp/client/transports/http.rb +23 -0
- data/lib/portage/ucp/client/transports/loopback.rb +29 -0
- data/lib/portage/ucp/client/transports/stdio.rb +23 -0
- data/lib/portage/ucp/client/version.rb +7 -0
- data/lib/portage/ucp/client.rb +88 -0
- metadata +146 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 96ae3a5da5ef868663d2b296539ef3812249479031f6237f0c4dbb5679dda268
|
|
4
|
+
data.tar.gz: 7b132dc0d5c2152a2bba93db9629e5c897331a5360d95894cc84028fb9052ee9
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 88439c696a4d6c6b4ead359c4b463b00fc07f02e811774096e745d7e95880b45b67ec3d948c5beffea0f0bd56456834305c774cc4d2b0a7281846840fd526bc3
|
|
7
|
+
data.tar.gz: d0965725d9e6914ae0bd19a0f2e8acabd182c943d01d0cb810458e3edc9d9d3001cf67bea1931eca3258b0315f32328e47b156a752c5f935bc07772d1ac9d0b3
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
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. Client-side SDK — loopback, stdio, and Streamable HTTP
|
|
10
|
+
transports behind one `Session` interface.
|
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,69 @@
|
|
|
1
|
+
# portage-ucp-client
|
|
2
|
+
|
|
3
|
+
Client-side SDK for [`portage-ucp`](https://github.com/tomtom87/Portage/tree/main/portage-ucp)
|
|
4
|
+
— the other direction from every adapter gem. Every adapter gem lets a Ruby
|
|
5
|
+
program *expose* a commerce backend to agents (server side). This gem lets a
|
|
6
|
+
Ruby program *act as* the shopper's agent: connect to somebody else's
|
|
7
|
+
`/.well-known/ucp` manifest, or drive your own `Adapter` directly, and place an
|
|
8
|
+
order as the client.
|
|
9
|
+
|
|
10
|
+
Three transports behind one `Session` interface — callers never know which they
|
|
11
|
+
got:
|
|
12
|
+
|
|
13
|
+
| Transport | Use case |
|
|
14
|
+
|---|---|
|
|
15
|
+
| `Portage::Ucp::Client.for_adapter(adapter)` | Loopback over an in-process `Adapter` — no subprocess, no socket. Still runs the real authenticator/rate-limiter/`Dispatcher` stack, just without the wire hop. For driving your own store's `Adapter` directly. |
|
|
16
|
+
| `Portage::Ucp::Client.connect(command: ...)` | stdio, spawns a subprocess (an MCP server exe). |
|
|
17
|
+
| `Portage::Ucp::Client.connect(url: ...)` | Streamable HTTP, connects to a remote MCP endpoint. |
|
|
18
|
+
| `Portage::Ucp::Client.discover(url)` | Fetches `<url>/.well-known/ucp`, parses the manifest, and connects to whatever transport it advertises — the entry point for buying from a store you've never talked to before. |
|
|
19
|
+
|
|
20
|
+
Depends only on `portage-ucp` and the `mcp` gem's client half — no adapter gem is
|
|
21
|
+
a dependency.
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
# Gemfile
|
|
27
|
+
gem "portage-ucp-client"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
bundle install
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
require "portage/ucp"
|
|
38
|
+
require "portage/ucp/client"
|
|
39
|
+
|
|
40
|
+
# Discover and connect to a live store you've never talked to before:
|
|
41
|
+
session = Portage::Ucp::Client.discover("https://your-shop.example")
|
|
42
|
+
|
|
43
|
+
products = session.search_catalog(query: "snowboard", limit: 5)
|
|
44
|
+
checkout = session.create_checkout(line_items: [{ product_id: products.first.id, quantity: 1 }])
|
|
45
|
+
completed = session.complete_checkout(checkout_id: checkout["id"], payment_token: "spt_1a2b3c...")
|
|
46
|
+
order = session.get_order(order_id: "gid://shopify/Order/9001")
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`Session` generates an `idempotency_key` for you on every mutating call unless you
|
|
50
|
+
pass your own — retrying with the same key replays the cached result instead of
|
|
51
|
+
double-charging.
|
|
52
|
+
|
|
53
|
+
See the
|
|
54
|
+
[detailed walkthrough](https://github.com/tomtom87/Portage/blob/main/docs/walkthrough.md)
|
|
55
|
+
for the full runnable example (via the loopback transport) including
|
|
56
|
+
`requires_escalation` handling and `RawPanRejectedError`, and
|
|
57
|
+
[`portage-cli`](https://github.com/tomtom87/Portage/tree/main/portage-cli) for a
|
|
58
|
+
ready-made `portage buy <url>` command built on top of this gem.
|
|
59
|
+
|
|
60
|
+
## Development
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
bundle exec rspec
|
|
64
|
+
bundle exec rubocop
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## License
|
|
68
|
+
|
|
69
|
+
[MIT](LICENSE) — Copyright (c) 2026 Tom Whitbread.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Ucp
|
|
3
|
+
module Client
|
|
4
|
+
# Base class for every error this gem raises.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when a tool call's JSON-RPC response comes back with
|
|
8
|
+
# `isError: true` — e.g. an AuthenticationError/RateLimitExceededError/
|
|
9
|
+
# RawPanRejectedError the server side surfaced. `#message` is the text
|
|
10
|
+
# content the server returned, not a generic string.
|
|
11
|
+
class ServerError < Error; end
|
|
12
|
+
|
|
13
|
+
# Raised by .discover when the manifest can't be fetched/parsed, or has
|
|
14
|
+
# no `services` entry for the mcp transport to connect to (see
|
|
15
|
+
# Portage::Ucp::Manifest#services — the core-gem fix this depends on).
|
|
16
|
+
class DiscoveryError < Error; end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
require "securerandom"
|
|
2
|
+
|
|
3
|
+
module Portage
|
|
4
|
+
module Ucp
|
|
5
|
+
module Client
|
|
6
|
+
# Caller-facing object returned by Client.for_adapter/.connect/.discover
|
|
7
|
+
# — same convenience method names as the merchant-side
|
|
8
|
+
# Portage::Ucp::Adapter, regardless of which transport is underneath
|
|
9
|
+
# (loopback/stdio/HTTP; callers never know which they got).
|
|
10
|
+
#
|
|
11
|
+
# Generates an `idempotency_key` per mutating call unless the caller
|
|
12
|
+
# supplies one, and runs PaymentTokenGuard client-side before a
|
|
13
|
+
# payment_token goes out over complete_checkout — belt-and-suspenders
|
|
14
|
+
# with the merchant's own guard (§9), not a replacement for it.
|
|
15
|
+
#
|
|
16
|
+
# A Checkout/Order response with `status == "requires_escalation"` is
|
|
17
|
+
# returned normally, with its `links`, not raised as an error — callers
|
|
18
|
+
# must branch on it themselves.
|
|
19
|
+
class Session
|
|
20
|
+
MUTATING_ACTIONS = %w[create_cart update_cart cancel_cart create_checkout update_checkout
|
|
21
|
+
complete_checkout cancel_checkout].freeze
|
|
22
|
+
|
|
23
|
+
# @param capabilities [Array<String>, nil] reverse-domain capability
|
|
24
|
+
# names advertised by the server, when known upfront (Client.discover
|
|
25
|
+
# populates this from the manifest; Client.for_adapter/.connect leave
|
|
26
|
+
# it nil since nothing was fetched to populate it from).
|
|
27
|
+
def initialize(transport:, capabilities: nil)
|
|
28
|
+
@transport = transport
|
|
29
|
+
@capabilities = capabilities
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
attr_reader :capabilities
|
|
33
|
+
|
|
34
|
+
# @return [Boolean, nil] nil when capabilities weren't known upfront
|
|
35
|
+
# (see #capabilities) — callers with a nil result can't tell either
|
|
36
|
+
# way and should just attempt the call.
|
|
37
|
+
def advertises?(capability_name)
|
|
38
|
+
capabilities&.include?(capability_name)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def search_catalog(query:, limit: 20) = call("search_catalog", query: query, limit: limit)
|
|
42
|
+
def get_product(product_id:) = call("get_product", product_id: product_id)
|
|
43
|
+
|
|
44
|
+
def get_cart(cart_id:) = call("get_cart", cart_id: cart_id)
|
|
45
|
+
|
|
46
|
+
def create_cart(line_items:, idempotency_key: nil)
|
|
47
|
+
call("create_cart", line_items: line_items, idempotency_key: idempotency_key)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def update_cart(cart_id:, line_items:, idempotency_key: nil)
|
|
51
|
+
call("update_cart", cart_id: cart_id, line_items: line_items, idempotency_key: idempotency_key)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def cancel_cart(cart_id:, idempotency_key: nil)
|
|
55
|
+
call("cancel_cart", cart_id: cart_id, idempotency_key: idempotency_key)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def create_checkout(line_items:, idempotency_key: nil)
|
|
59
|
+
call("create_checkout", line_items: line_items, idempotency_key: idempotency_key)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def get_checkout(checkout_id:) = call("get_checkout", checkout_id: checkout_id)
|
|
63
|
+
|
|
64
|
+
def update_checkout(checkout_id:, line_items:, idempotency_key: nil)
|
|
65
|
+
call("update_checkout", checkout_id: checkout_id, line_items: line_items, idempotency_key: idempotency_key)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def complete_checkout(checkout_id:, payment_token:, idempotency_key: nil)
|
|
69
|
+
Portage::Ucp::PaymentTokenGuard.validate!(payment_token)
|
|
70
|
+
call("complete_checkout", checkout_id: checkout_id, payment_token: payment_token,
|
|
71
|
+
idempotency_key: idempotency_key)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def cancel_checkout(checkout_id:, idempotency_key: nil)
|
|
75
|
+
call("cancel_checkout", checkout_id: checkout_id, idempotency_key: idempotency_key)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def get_order(order_id:) = call("get_order", order_id: order_id)
|
|
79
|
+
def link_identity(oauth_token:) = call("link_identity", oauth_token: oauth_token)
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def call(action, **arguments)
|
|
84
|
+
arguments[:idempotency_key] ||= SecureRandom.uuid if MUTATING_ACTIONS.include?(action)
|
|
85
|
+
@transport.call_tool(name: action, arguments: arguments)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Ucp
|
|
3
|
+
module Client
|
|
4
|
+
# Normalizes a tools/call JSON-RPC response into either the tool's
|
|
5
|
+
# structuredContent, or a raised ServerError — shared across transports
|
|
6
|
+
# since the loopback transport gets a symbol-keyed response (no JSON
|
|
7
|
+
# round-trip, see Transports::Loopback) while stdio/HTTP get a
|
|
8
|
+
# string-keyed one (real wire JSON, parsed by the `mcp` gem's client).
|
|
9
|
+
module ToolResult
|
|
10
|
+
def self.extract(response, symbol_keys:)
|
|
11
|
+
result = fetch(response, "result", symbol_keys) || {}
|
|
12
|
+
content = fetch(result, "content", symbol_keys)
|
|
13
|
+
raise ServerError, text(content, symbol_keys: symbol_keys) if fetch(result, "isError", symbol_keys)
|
|
14
|
+
|
|
15
|
+
fetch(result, "structuredContent", symbol_keys)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.text(content, symbol_keys:)
|
|
19
|
+
Array(content).filter_map { |block| fetch(block, "text", symbol_keys) }.join(" ")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.fetch(hash, key, symbol_keys)
|
|
23
|
+
hash[symbol_keys ? key.to_sym : key]
|
|
24
|
+
end
|
|
25
|
+
private_class_method :fetch
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Ucp
|
|
3
|
+
module Client
|
|
4
|
+
module Transports
|
|
5
|
+
# Connects to a UCP/MCP server over Streamable HTTP via the official
|
|
6
|
+
# `mcp` gem's client half (MCP::Client + MCP::Client::HTTP). Performs
|
|
7
|
+
# the `initialize` handshake eagerly so a caller's first real call
|
|
8
|
+
# doesn't pay for it.
|
|
9
|
+
class Http
|
|
10
|
+
def initialize(url:, headers: {})
|
|
11
|
+
@client = ::MCP::Client.new(transport: ::MCP::Client::HTTP.new(url: url, headers: headers))
|
|
12
|
+
@client.connect
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call_tool(name:, arguments:)
|
|
16
|
+
response = @client.call_tool(name: name, arguments: arguments)
|
|
17
|
+
ToolResult.extract(response, symbol_keys: false)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Ucp
|
|
3
|
+
module Client
|
|
4
|
+
module Transports
|
|
5
|
+
# Wraps a Portage::Ucp::Mcp::Server built directly from an Adapter —
|
|
6
|
+
# no subprocess, no socket. This still runs the exact request a real
|
|
7
|
+
# server would handle over stdio/HTTP (authenticator, rate limiter,
|
|
8
|
+
# Dispatcher, WireEnvelope — see Portage::Ucp::Mcp::Server.build), it
|
|
9
|
+
# just skips the wire hop. This is what makes the
|
|
10
|
+
# merchant-buys-from-their-own-store case, and specs/examples that
|
|
11
|
+
# want a full buy cycle, possible without two processes.
|
|
12
|
+
class Loopback
|
|
13
|
+
def initialize(adapter:, **server_opts)
|
|
14
|
+
@server = Portage::Ucp::Mcp::Server.build(adapter: adapter, **server_opts)
|
|
15
|
+
@next_id = 0
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call_tool(name:, arguments:)
|
|
19
|
+
@next_id += 1
|
|
20
|
+
response = @server.handle(
|
|
21
|
+
{ jsonrpc: "2.0", id: @next_id, method: "tools/call", params: { name: name, arguments: arguments } }
|
|
22
|
+
)
|
|
23
|
+
ToolResult.extract(response, symbol_keys: true)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Ucp
|
|
3
|
+
module Client
|
|
4
|
+
module Transports
|
|
5
|
+
# Connects to a UCP/MCP server over stdio — a subprocess speaking
|
|
6
|
+
# JSON-RPC on stdin/stdout — via the official `mcp` gem's client half
|
|
7
|
+
# (MCP::Client + MCP::Client::Stdio). Performs the `initialize`
|
|
8
|
+
# handshake eagerly so a caller's first real call doesn't pay for it.
|
|
9
|
+
class Stdio
|
|
10
|
+
def initialize(command:, args: [], env: nil)
|
|
11
|
+
@client = ::MCP::Client.new(transport: ::MCP::Client::Stdio.new(command: command, args: args, env: env))
|
|
12
|
+
@client.connect
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call_tool(name:, arguments:)
|
|
16
|
+
response = @client.call_tool(name: name, arguments: arguments)
|
|
17
|
+
ToolResult.extract(response, symbol_keys: false)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "uri"
|
|
3
|
+
require "json"
|
|
4
|
+
require "mcp"
|
|
5
|
+
require "portage/ucp"
|
|
6
|
+
|
|
7
|
+
require_relative "client/version"
|
|
8
|
+
require_relative "client/errors"
|
|
9
|
+
require_relative "client/tool_result"
|
|
10
|
+
require_relative "client/session"
|
|
11
|
+
require_relative "client/transports/loopback"
|
|
12
|
+
require_relative "client/transports/stdio"
|
|
13
|
+
require_relative "client/transports/http"
|
|
14
|
+
|
|
15
|
+
module Portage
|
|
16
|
+
module Ucp
|
|
17
|
+
# Client-side counterpart to the rest of portage-ucp: everything else in
|
|
18
|
+
# this repo lets a Ruby program *expose* a commerce backend to agents
|
|
19
|
+
# (server side). This gem lets a Ruby program *act as* the shopper's
|
|
20
|
+
# agent — discover somebody else's manifest, or drive an owner's own
|
|
21
|
+
# Adapter directly, and place an order.
|
|
22
|
+
module Client
|
|
23
|
+
MANIFEST_PATH = "/.well-known/ucp".freeze
|
|
24
|
+
|
|
25
|
+
# Wraps an already-built Adapter directly — no subprocess/socket. Still
|
|
26
|
+
# runs the real merchant-side contract (authenticator, rate limiter,
|
|
27
|
+
# Dispatcher, WireEnvelope, via Portage::Ucp::Mcp::Server), just
|
|
28
|
+
# in-process. This is the transport for the "your own store" case: you
|
|
29
|
+
# already have credentials for this Adapter, so there's no manifest to
|
|
30
|
+
# discover and no wire hop to make.
|
|
31
|
+
def self.for_adapter(adapter, **server_opts)
|
|
32
|
+
Session.new(transport: Transports::Loopback.new(adapter: adapter, **server_opts))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Connects over stdio (a subprocess) or Streamable HTTP (a URL) — pass
|
|
36
|
+
# exactly one of `command:` or `url:`.
|
|
37
|
+
def self.connect(command: nil, args: [], env: nil, url: nil, headers: {}, capabilities: nil)
|
|
38
|
+
transport = if command
|
|
39
|
+
Transports::Stdio.new(command: command, args: args, env: env)
|
|
40
|
+
elsif url
|
|
41
|
+
Transports::Http.new(url: url, headers: headers)
|
|
42
|
+
else
|
|
43
|
+
raise ArgumentError, "connect requires either command: or url:"
|
|
44
|
+
end
|
|
45
|
+
Session.new(transport: transport, capabilities: capabilities)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# GETs `<url>/.well-known/ucp`, parses the manifest, and connects to the
|
|
49
|
+
# `mcp`-transport endpoint it advertises in `services` (see
|
|
50
|
+
# Portage::Ucp::Manifest#services — the core-gem fix this client
|
|
51
|
+
# depends on to know where to connect).
|
|
52
|
+
# @return [Session] scoped to the manifest's advertised capabilities.
|
|
53
|
+
def self.discover(url)
|
|
54
|
+
manifest = fetch_manifest(url)
|
|
55
|
+
connect(url: mcp_endpoint(manifest), capabilities: capability_names(manifest))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.fetch_manifest(url)
|
|
59
|
+
uri = URI.parse("#{url.to_s.sub(%r{/\z}, '')}#{MANIFEST_PATH}")
|
|
60
|
+
response = Net::HTTP.get_response(uri)
|
|
61
|
+
raise DiscoveryError, "GET #{uri} returned #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
62
|
+
|
|
63
|
+
JSON.parse(response.body)
|
|
64
|
+
rescue JSON::ParserError => e
|
|
65
|
+
raise DiscoveryError, "manifest at #{url} is not valid JSON: #{e.message}"
|
|
66
|
+
rescue DiscoveryError
|
|
67
|
+
raise
|
|
68
|
+
rescue StandardError => e
|
|
69
|
+
raise DiscoveryError, "couldn't reach #{url}: #{e.class}: #{e.message}"
|
|
70
|
+
end
|
|
71
|
+
private_class_method :fetch_manifest
|
|
72
|
+
|
|
73
|
+
def self.mcp_endpoint(manifest)
|
|
74
|
+
service = Array(manifest["services"]).find { |s| s["transport"] == "mcp" }
|
|
75
|
+
endpoint = service && service["endpoint"]
|
|
76
|
+
raise DiscoveryError, "manifest has no mcp service entry to connect to" unless endpoint
|
|
77
|
+
|
|
78
|
+
endpoint
|
|
79
|
+
end
|
|
80
|
+
private_class_method :mcp_endpoint
|
|
81
|
+
|
|
82
|
+
def self.capability_names(manifest)
|
|
83
|
+
Array(manifest["capabilities"]).map { |c| c["name"] }
|
|
84
|
+
end
|
|
85
|
+
private_class_method :capability_names
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: portage-ucp-client
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Tom Whitbread
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-08-14 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: mcp
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '0.24'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '0.24'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: portage-ucp
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '0.1'
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '0.1'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: rspec
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '3.13'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '3.13'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: rubocop
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - "~>"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '1.88'
|
|
62
|
+
type: :development
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - "~>"
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '1.88'
|
|
69
|
+
- !ruby/object:Gem::Dependency
|
|
70
|
+
name: webmock
|
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - "~>"
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '3.24'
|
|
76
|
+
type: :development
|
|
77
|
+
prerelease: false
|
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - "~>"
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: '3.24'
|
|
83
|
+
- !ruby/object:Gem::Dependency
|
|
84
|
+
name: yard
|
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
|
86
|
+
requirements:
|
|
87
|
+
- - "~>"
|
|
88
|
+
- !ruby/object:Gem::Version
|
|
89
|
+
version: '0.9'
|
|
90
|
+
type: :development
|
|
91
|
+
prerelease: false
|
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
93
|
+
requirements:
|
|
94
|
+
- - "~>"
|
|
95
|
+
- !ruby/object:Gem::Version
|
|
96
|
+
version: '0.9'
|
|
97
|
+
description: 'Every other portage-ucp gem lets a Ruby program expose a commerce backend
|
|
98
|
+
to agents (server side). This gem is the other direction: connect to somebody else''s
|
|
99
|
+
/.well-known/ucp manifest, or drive your own Adapter directly, and place an order
|
|
100
|
+
as the client. Three transports behind one interface (loopback over an in-process
|
|
101
|
+
Adapter, stdio, Streamable HTTP) — callers never know which they got. Depends only
|
|
102
|
+
on portage-ucp + the mcp gem''s client half; no adapter gem is a dependency.'
|
|
103
|
+
email:
|
|
104
|
+
executables: []
|
|
105
|
+
extensions: []
|
|
106
|
+
extra_rdoc_files: []
|
|
107
|
+
files:
|
|
108
|
+
- CHANGELOG.md
|
|
109
|
+
- LICENSE
|
|
110
|
+
- README.md
|
|
111
|
+
- lib/portage/ucp/client.rb
|
|
112
|
+
- lib/portage/ucp/client/errors.rb
|
|
113
|
+
- lib/portage/ucp/client/session.rb
|
|
114
|
+
- lib/portage/ucp/client/tool_result.rb
|
|
115
|
+
- lib/portage/ucp/client/transports/http.rb
|
|
116
|
+
- lib/portage/ucp/client/transports/loopback.rb
|
|
117
|
+
- lib/portage/ucp/client/transports/stdio.rb
|
|
118
|
+
- lib/portage/ucp/client/version.rb
|
|
119
|
+
homepage: https://github.com/tomtom87/Portage/tree/main/portage-ucp-client
|
|
120
|
+
licenses:
|
|
121
|
+
- MIT
|
|
122
|
+
metadata:
|
|
123
|
+
source_code_uri: https://github.com/tomtom87/Portage/tree/main/portage-ucp-client
|
|
124
|
+
changelog_uri: https://github.com/tomtom87/Portage/blob/main/portage-ucp-client/CHANGELOG.md
|
|
125
|
+
rubygems_mfa_required: 'true'
|
|
126
|
+
post_install_message:
|
|
127
|
+
rdoc_options: []
|
|
128
|
+
require_paths:
|
|
129
|
+
- lib
|
|
130
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
131
|
+
requirements:
|
|
132
|
+
- - ">="
|
|
133
|
+
- !ruby/object:Gem::Version
|
|
134
|
+
version: '3.2'
|
|
135
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
136
|
+
requirements:
|
|
137
|
+
- - ">="
|
|
138
|
+
- !ruby/object:Gem::Version
|
|
139
|
+
version: '0'
|
|
140
|
+
requirements: []
|
|
141
|
+
rubygems_version: 3.5.22
|
|
142
|
+
signing_key:
|
|
143
|
+
specification_version: 4
|
|
144
|
+
summary: Client-side SDK for portage-ucp — act as the shopper's agent against any
|
|
145
|
+
UCP/MCP server
|
|
146
|
+
test_files: []
|