portage-ucp-webmcp 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 +45 -0
- data/LICENSE +21 -0
- data/README.md +275 -0
- data/lib/portage/ucp/webmcp/assets/consumer.js +104 -0
- data/lib/portage/ucp/webmcp/assets/polyfill.js +109 -0
- data/lib/portage/ucp/webmcp/assets/registrar.js +133 -0
- data/lib/portage/ucp/webmcp/assets.rb +31 -0
- data/lib/portage/ucp/webmcp/bridges/script_evaluator.rb +105 -0
- data/lib/portage/ucp/webmcp/errors.rb +33 -0
- data/lib/portage/ucp/webmcp/jsonable.rb +26 -0
- data/lib/portage/ucp/webmcp/rack/app.rb +61 -0
- data/lib/portage/ucp/webmcp/rack/call_endpoint.rb +153 -0
- data/lib/portage/ucp/webmcp/rack/script_endpoint.rb +33 -0
- data/lib/portage/ucp/webmcp/registrar.rb +53 -0
- data/lib/portage/ucp/webmcp/tool_catalog.rb +128 -0
- data/lib/portage/ucp/webmcp/tool_schemas.rb +52 -0
- data/lib/portage/ucp/webmcp/transport.rb +185 -0
- data/lib/portage/ucp/webmcp/version.rb +7 -0
- data/lib/portage/ucp/webmcp.rb +55 -0
- metadata +189 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 3b780435e8c5d1af1fe7c6943d789685242346aba2182ed99a1b53f42fce98d7
|
|
4
|
+
data.tar.gz: 90bc20baab307b0659242608563ff785db1e311413beb04d65600bd0d78e0d9b
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 254d23426d3384ad535c75f618c2d53174742337e2d9d4989905a861b241e3233636ea700067033177a423eedd3cc1e75b655099126315aa02b72e47f53d7e36
|
|
7
|
+
data.tar.gz: 17b34c2da562e7394402ada1bf6f9989600c5cc4a55ef803bc35a42cfbf260cd2e6537973e4e1d07c7698c57727477d0ecb22202c36bbcce7fc0e3aebeb7d75f
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
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 release. WebMCP as a transport to the existing `Adapter` contract.
|
|
10
|
+
- The registrar reports a non-JSON reply from its endpoint (a proxy's HTML
|
|
11
|
+
error page) as `tools/call to <endpoint> returned a non-JSON response
|
|
12
|
+
(<status>)`, rather than a bare `Unexpected token '<'`.
|
|
13
|
+
- Inbound (merchant side): `ToolCatalog` derives the tools a page registers
|
|
14
|
+
from `Portage::Ucp::Mcp::Server`'s own tools/list. It adds readable
|
|
15
|
+
descriptions, typed parameter schemas and WebMCP annotations.
|
|
16
|
+
`Rack::App` serves the page registrar (`GET /webmcp.js`) and its call
|
|
17
|
+
endpoint (`POST /webmcp`). The endpoint accepts JSON only, requires a
|
|
18
|
+
matching `Origin`, and allows only `tools/call`/`tools/list` for exposed
|
|
19
|
+
actions. It hands the Rack request to the Authenticator. OAuth-token and
|
|
20
|
+
stored-credential tools are left out by default. `complete_checkout` is
|
|
21
|
+
also left out by default, because the Dispatcher's default Terminal
|
|
22
|
+
Confirmer would block the web request.
|
|
23
|
+
- Outbound (agent side): `Transport` is a `portage-ucp-client` transport over
|
|
24
|
+
any page's WebMCP tools. It supports the spec surface, the
|
|
25
|
+
`modelContextTesting` surface and the userland-polyfill surface. It
|
|
26
|
+
resolves tool names with `tool_names:`/`prefix:`, and it picks flat or
|
|
27
|
+
real-UCP argument shapes from each tool's schema.
|
|
28
|
+
`Bridges::ScriptEvaluator` works with Ferrum, Playwright, Selenium or any
|
|
29
|
+
JavaScript-evaluating callable. `WebMcp.connect` returns a
|
|
30
|
+
`Client::Session`.
|
|
31
|
+
- `WebMcp.polyfill_js`: a minimal, spec-shaped `document.modelContext` for
|
|
32
|
+
browsers without native WebMCP.
|
|
33
|
+
- `Transport` waits out a page that drops and re-registers its tools
|
|
34
|
+
mid-call (`reregister_wait:`, default 2s) instead of raising
|
|
35
|
+
`ToolNotFoundError` for a tool it had just found. Confirmed live on
|
|
36
|
+
Shopify storefronts, whose own `add_to_cart` tool leaves the page with no
|
|
37
|
+
tools for about 500ms.
|
|
38
|
+
- The wait is a blocking `sleep`, now documented under the README's
|
|
39
|
+
"Timeouts". Its one deadline covers the whole call, and the last poll
|
|
40
|
+
no longer sleeps past it: a 0.25s poll started 0.1s before the deadline
|
|
41
|
+
now sleeps 0.1s.
|
|
42
|
+
- `ScriptEvaluator`'s `BridgeError` quotes at most 300 characters of the
|
|
43
|
+
driver's own error (and of an unreadable reply), the same cap
|
|
44
|
+
`portage-ucp-decision` uses for Jev. A Selenium failure can carry a DOM
|
|
45
|
+
dump or a stack trace, which used to land whole in an agent's context.
|
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,275 @@
|
|
|
1
|
+
# portage-ucp-webmcp
|
|
2
|
+
|
|
3
|
+
[WebMCP](https://webmachinelearning.github.io/webmcp/) for
|
|
4
|
+
[`portage-ucp`](https://github.com/tomtom87/Portage/tree/main/portage-ucp), as a
|
|
5
|
+
**transport**, not a commerce backend. WebMCP lets a web page register tools on
|
|
6
|
+
`document.modelContext` for an agent running in the browser to call. This gem
|
|
7
|
+
makes that page one more way to reach the existing `Adapter` contract, next to
|
|
8
|
+
classic MCP (stdio, Streamable HTTP) and native UCP.
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
┌── stdio / Streamable HTTP ──┐
|
|
12
|
+
Agent ── Session ────────┤── native UCP (HTTP) ────────┤── Mcp::Server ── Dispatcher ── Adapter
|
|
13
|
+
└── WebMCP (a browser page) ──┘
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Two halves, usable separately:
|
|
17
|
+
|
|
18
|
+
| Half | Side | What it does |
|
|
19
|
+
|---|---|---|
|
|
20
|
+
| Inbound | Merchant | Registers a Portage-powered store's catalog/cart/checkout tools on its pages via `document.modelContext`. Each tool call POSTs back to the store, into the same `Portage::Ucp::Mcp::Server` every other transport uses. |
|
|
21
|
+
| Outbound | Agent | A `portage-ucp-client` transport that finds and calls the WebMCP tools **any** page registers, Portage-powered or not, through the browser driver you already run. |
|
|
22
|
+
|
|
23
|
+
The `Adapter` stays the single source of truth. The tool list comes from the
|
|
24
|
+
`CapabilityRegistry`, as it does for MCP. Nothing commerce-related runs in the
|
|
25
|
+
browser.
|
|
26
|
+
|
|
27
|
+
## Inbound: expose your store to browser agents
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
require "portage/ucp/webmcp"
|
|
31
|
+
|
|
32
|
+
catalog = Portage::Ucp::WebMcp::ToolCatalog.new(
|
|
33
|
+
adapter: adapter,
|
|
34
|
+
authenticator: MyCookieAuthenticator.new # same Authenticator contract as MCP
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# config.ru
|
|
38
|
+
map("/ucp") { run Portage::Ucp::WebMcp::Rack::App.new(catalog: catalog) }
|
|
39
|
+
|
|
40
|
+
# Rails
|
|
41
|
+
mount Portage::Ucp::WebMcp::Rack::App.new(catalog: catalog), at: "/ucp"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
```html
|
|
45
|
+
<script src="/ucp/webmcp.js" defer></script>
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`Rack::App` serves two routes under its mount point:
|
|
49
|
+
|
|
50
|
+
| Route | Serves |
|
|
51
|
+
|---|---|
|
|
52
|
+
| `GET /webmcp.js` | The page script (`Registrar`). It registers each tool on `document.modelContext`, or on `navigator.modelContext` in earlier-draft browsers. |
|
|
53
|
+
| `POST /webmcp` | Its tool calls (`Rack::CallEndpoint`). One stateless JSON-RPC `tools/call` in, one result out, answered by the catalog's `Mcp::Server`. |
|
|
54
|
+
|
|
55
|
+
### What the page registers
|
|
56
|
+
|
|
57
|
+
`ToolCatalog` decorates the MCP server's generated tools for browser agents:
|
|
58
|
+
|
|
59
|
+
- Readable descriptions and typed JSON Schemas. `Mcp::Server` can only name
|
|
60
|
+
parameters, so every property there is `{}`.
|
|
61
|
+
- WebMCP annotations: `readOnlyHint` for reads, and `consequentialHint` for
|
|
62
|
+
`cancel_order`, `refund_order`, `request_return` and `complete_checkout`.
|
|
63
|
+
- `idempotency_key` is optional. The page generates one per mutating call when
|
|
64
|
+
the agent leaves it out.
|
|
65
|
+
|
|
66
|
+
What the catalog exposes:
|
|
67
|
+
|
|
68
|
+
- **Capabilities:** standard `dev.ucp.shopping.*` capabilities only, without
|
|
69
|
+
identity linking. Tools that take an OAuth token or manage stored credentials
|
|
70
|
+
(saved payment methods, saved addresses, shopper-data deletion, payment
|
|
71
|
+
enrollment) are left out. Widen with `capabilities: ->(name) { ... }`.
|
|
72
|
+
- **`complete_checkout`:** left out (`DEFAULT_EXCEPT`). Over any server
|
|
73
|
+
transport it runs the Dispatcher's payment `Confirmer`. That defaults to
|
|
74
|
+
`Confirmer::Terminal`, which prompts on the server's stdin, and
|
|
75
|
+
`Mcp::Server.build` has no seam to replace it. On a web request that prompt
|
|
76
|
+
holds the request until it times out and denies. In the browser the shopper
|
|
77
|
+
is present anyway: the agent builds the checkout, and the shopper pays in the
|
|
78
|
+
store's own checkout (the checkout's `links` or `continue_url`). Opt in with
|
|
79
|
+
`except: []` only after you have solved confirmation.
|
|
80
|
+
- **Filters:** `only:`, `except:` and `prefix:` (for example `prefix: "acme."`
|
|
81
|
+
when the page registers WebMCP tools of its own).
|
|
82
|
+
|
|
83
|
+
`Rack::CallEndpoint` enforces the same filter on the server side. An action the
|
|
84
|
+
page doesn't register can't be reached by POSTing its name.
|
|
85
|
+
|
|
86
|
+
### Security
|
|
87
|
+
|
|
88
|
+
The endpoint is browser-facing and the page's cookies go with every call. For
|
|
89
|
+
that reason it is stricter than a plain MCP endpoint:
|
|
90
|
+
|
|
91
|
+
- **JSON bodies only.** A cross-site `<form>` can't reach the endpoint without a
|
|
92
|
+
CORS preflight.
|
|
93
|
+
- **`Origin` required.** The header must match `allowed_origins`. The default is
|
|
94
|
+
the endpoint's own origin. A request with no `Origin` is refused unless you
|
|
95
|
+
pass `require_origin: false`.
|
|
96
|
+
- **Two methods only.** `tools/call` and `tools/list`, and only for exposed
|
|
97
|
+
actions.
|
|
98
|
+
- **Same guards as MCP.** Mutating calls go through your `Authenticator` and
|
|
99
|
+
`RateLimiter`, exactly as over MCP. The `server_context` they receive carries
|
|
100
|
+
`transport: "webmcp"`, `request:` (the `Rack::Request`, for your session
|
|
101
|
+
cookie or a CSRF header) and `origin:`.
|
|
102
|
+
|
|
103
|
+
Put a CSRF token on the page's requests with
|
|
104
|
+
`registrar_options: { headers: { "x-csrf-token" => token } }` and check it in
|
|
105
|
+
your `Authenticator`.
|
|
106
|
+
|
|
107
|
+
For a storefront on another origin than the endpoint, use this pattern:
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
Portage::Ucp::WebMcp::Rack::App.new(
|
|
111
|
+
catalog: catalog,
|
|
112
|
+
call_options: { allowed_origins: ["https://shop.example"] },
|
|
113
|
+
registrar_options: { endpoint: "https://api.shop.example/ucp/webmcp", credentials: "include" }
|
|
114
|
+
)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The endpoint then answers the CORS preflight and sends credentialed CORS
|
|
118
|
+
headers for that origin only.
|
|
119
|
+
|
|
120
|
+
### Browsers without WebMCP
|
|
121
|
+
|
|
122
|
+
When the page has no `modelContext`, the registrar does nothing. It records
|
|
123
|
+
`window.portageWebMcp.reason = "no_model_context"`. Pass
|
|
124
|
+
`registrar_options: { include_polyfill: true }` to install a minimal,
|
|
125
|
+
spec-shaped `document.modelContext` first. That helps agents that drive a
|
|
126
|
+
browser build without native WebMCP. A browser that has a native
|
|
127
|
+
implementation keeps it.
|
|
128
|
+
|
|
129
|
+
## Outbound: call any page's WebMCP tools from an agent
|
|
130
|
+
|
|
131
|
+
```ruby
|
|
132
|
+
require "portage/ucp/webmcp"
|
|
133
|
+
|
|
134
|
+
page = Ferrum::Browser.new.create_page
|
|
135
|
+
page.command("Page.addScriptToEvaluateOnNewDocument", source: Portage::Ucp::WebMcp.polyfill_js) # optional
|
|
136
|
+
page.go_to("https://shop.example/products/mug")
|
|
137
|
+
|
|
138
|
+
session = Portage::Ucp::WebMcp.connect(
|
|
139
|
+
bridge: Portage::Ucp::WebMcp::Bridges::ScriptEvaluator.ferrum(page)
|
|
140
|
+
)
|
|
141
|
+
session.search_catalog(query: "mug")
|
|
142
|
+
session.create_checkout(line_items: [{ product_id: "mug", quantity: 1 }])
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
`session` is the same `Portage::Ucp::Client::Session` that
|
|
146
|
+
`Client.for_adapter`, `.connect` and `.discover` return. It has the same
|
|
147
|
+
methods, the same client-side `PaymentTokenGuard` and the same `ServerError`
|
|
148
|
+
on a tool error. The caller doesn't need to know which transport it got.
|
|
149
|
+
|
|
150
|
+
### Browser drivers
|
|
151
|
+
|
|
152
|
+
No driver gem is a dependency. `ScriptEvaluator` takes any callable that
|
|
153
|
+
evaluates a JavaScript expression in the page and returns what its promise
|
|
154
|
+
resolves to. Helpers exist for three drivers:
|
|
155
|
+
|
|
156
|
+
| Driver | Bridge |
|
|
157
|
+
|---|---|
|
|
158
|
+
| Ferrum | `ScriptEvaluator.ferrum(page)` |
|
|
159
|
+
| Playwright (`playwright-ruby-client`) | `ScriptEvaluator.playwright(page)` |
|
|
160
|
+
| Selenium WebDriver | `ScriptEvaluator.selenium(driver)` |
|
|
161
|
+
| Anything else | `ScriptEvaluator.new(evaluate: ->(js) { ... })`, or `WebMcp.connect(evaluate: ...)` |
|
|
162
|
+
|
|
163
|
+
A browser extension, a raw CDP session or a remote grid can write its own
|
|
164
|
+
bridge instead. Any object with `#list_tools` and `#execute_tool(name, input)`
|
|
165
|
+
works.
|
|
166
|
+
|
|
167
|
+
### WebMCP surfaces
|
|
168
|
+
|
|
169
|
+
The consumer script tries these in order:
|
|
170
|
+
|
|
171
|
+
1. `document.modelContext`, or `navigator.modelContext`, with `getTools()` and
|
|
172
|
+
`executeTool()`. This is the current spec.
|
|
173
|
+
2. `navigator.modelContextTesting`, with `listTools()` and
|
|
174
|
+
`executeTool(name, json)`. This is the testing API from early browser builds.
|
|
175
|
+
3. `listTools()` and `callTool({ name, arguments })`, the shape that some
|
|
176
|
+
userland polyfills use.
|
|
177
|
+
|
|
178
|
+
A page that registers tools but offers none of these to a consumer can still be
|
|
179
|
+
read. Inject `WebMcp.polyfill_js` before navigation so the page registers into
|
|
180
|
+
something the consumer can list.
|
|
181
|
+
|
|
182
|
+
### Stores that don't run Portage
|
|
183
|
+
|
|
184
|
+
`Transport` does not assume that the page was built with this gem:
|
|
185
|
+
|
|
186
|
+
- **Tool names.** An action resolves to `tool_names[action]` first, then to
|
|
187
|
+
`"#{prefix}#{action}"`, then to the action itself. Map a store's own names:
|
|
188
|
+
`WebMcp.connect(bridge:, tool_names: { search_catalog: "findProducts" })`. A
|
|
189
|
+
miss reads the page again once (for tools registered late), then raises
|
|
190
|
+
`ToolNotFoundError`, which lists what the page does register.
|
|
191
|
+
- **Argument shape.** The shape comes from each tool's own `inputSchema`. If
|
|
192
|
+
its properties nest under `catalog`, `cart` or `checkout`, or it takes `id`
|
|
193
|
+
and `meta`, it is a real-UCP-shaped tool. It gets the same body that
|
|
194
|
+
`Transports::Http` builds for native UCP (the code is shared through
|
|
195
|
+
`Transports::UcpWireShape`). Other tools get Session's flat arguments, as
|
|
196
|
+
over stdio. Force one shape with `wire: :ucp` or `wire: :flat`.
|
|
197
|
+
- **Results.** An MCP-style `CallToolResult` is unwrapped the same way as over
|
|
198
|
+
stdio or HTTP, and `isError` raises `ServerError`. A JSON string (the spec's
|
|
199
|
+
`executeTool` resolves to one) is parsed. Anything else is returned as it is.
|
|
200
|
+
- **Re-registration.** Some pages drop all their tools and register them again
|
|
201
|
+
while they re-render. A call to a tool that vanished that way waits up to
|
|
202
|
+
`reregister_wait:` (default 2 seconds) for it to come back, then retries.
|
|
203
|
+
The call never ran the first time, so the retry is safe. The wait blocks
|
|
204
|
+
the calling thread; see "Timeouts" below.
|
|
205
|
+
|
|
206
|
+
### Timeouts
|
|
207
|
+
|
|
208
|
+
Two limits bound an outbound call, and both block the thread that made it:
|
|
209
|
+
|
|
210
|
+
- **The browser driver's own timeout** bounds each round trip to the page.
|
|
211
|
+
`ScriptEvaluator.ferrum(page, timeout: 30)` passes its `timeout:` (default
|
|
212
|
+
30 seconds) to `evaluate_async`. Playwright and Selenium use the page's or
|
|
213
|
+
driver's own script timeout, so set it there. A driver that times out
|
|
214
|
+
raises, and the error surfaces as `BridgeError`.
|
|
215
|
+
- **`reregister_wait:`** (default 2 seconds, `WebMcp.connect(...,
|
|
216
|
+
reregister_wait:)`) bounds the wait for a tool the page dropped mid-call.
|
|
217
|
+
It's a plain `sleep`, polling every 0.25 seconds. One monotonic deadline
|
|
218
|
+
covers the whole call: it's set before the first attempt, a retry never
|
|
219
|
+
resets it, the retried calls count against it, and no sleep runs past it.
|
|
220
|
+
A miss costs at most `reregister_wait:` plus the one driver round trip in
|
|
221
|
+
flight when it expires. Pass `reregister_wait: 0` to fail on the first
|
|
222
|
+
miss, or make the call off a thread that can't afford to block.
|
|
223
|
+
|
|
224
|
+
### Shopify storefronts
|
|
225
|
+
|
|
226
|
+
Checked live on 2026-09-23: 6 of 8 Shopify storefronts tried (ColourPop,
|
|
227
|
+
tentree, Kylie Cosmetics, Brooklinen, Allbirds, Billabong) register the same 11
|
|
228
|
+
WebMCP tools of their own. Gymshark and Fashion Nova registered none. The
|
|
229
|
+
tools take UCP-shaped arguments, so `wire: :auto` picks the UCP shape. Two
|
|
230
|
+
names differ from Session's:
|
|
231
|
+
|
|
232
|
+
```ruby
|
|
233
|
+
session = Portage::Ucp::WebMcp.connect(bridge: bridge, tool_names: { create_cart: "add_to_cart" })
|
|
234
|
+
session.search_catalog(query: "hoodie") # page tool: search_catalog
|
|
235
|
+
session.get_product(product_id: product["id"]) # page tool: get_product, variants included
|
|
236
|
+
session.create_cart(line_items: [{ product_id: variant["id"], quantity: 1 }]) # adds to the browser's cart
|
|
237
|
+
session.get_cart(cart_id: "current") # the browser's cart; cart_id is ignored
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Things to know about these tools:
|
|
241
|
+
|
|
242
|
+
- The cart is the browser session's own cart, so `add_to_cart` returns no cart
|
|
243
|
+
id and adds to what is already there.
|
|
244
|
+
- Search results carry no variants. Call `get_product` for variant ids.
|
|
245
|
+
- Right after `add_to_cart`, the page's cart tools can fail for a second or two
|
|
246
|
+
with `Standard Actions are not available ... Try again`. Wait and call again.
|
|
247
|
+
- `update_cart_lines` addresses existing cart lines by line id, not by variant.
|
|
248
|
+
`proceed_to_checkout` navigates the browser to Shopify checkout, where the
|
|
249
|
+
shopper pays. Neither maps onto a Session method.
|
|
250
|
+
|
|
251
|
+
### Errors
|
|
252
|
+
|
|
253
|
+
All errors are under `Portage::Ucp::Client::Error`, so existing `rescue` blocks
|
|
254
|
+
still catch them.
|
|
255
|
+
|
|
256
|
+
| Error | When |
|
|
257
|
+
|---|---|
|
|
258
|
+
| `WebMcp::BridgeError` | The page has no WebMCP surface, the driver failed, or the result couldn't be read. It quotes at most 300 characters of the driver's own error. |
|
|
259
|
+
| `WebMcp::ToolNotFoundError` | No page tool answers the action. `#available` lists what the page registers. |
|
|
260
|
+
| `Client::ServerError` | The tool ran and failed: `isError`, or the page's tool threw. |
|
|
261
|
+
|
|
262
|
+
## Development
|
|
263
|
+
|
|
264
|
+
```bash
|
|
265
|
+
bundle exec rspec && bundle exec rubocop
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
The browser-side scripts are plain `.js` files under
|
|
269
|
+
`lib/portage/ucp/webmcp/assets/`. Specs tagged `:node` run them unmodified in
|
|
270
|
+
a node process that stands in for a browser tab
|
|
271
|
+
(`spec/support/node_browser.{js,rb}`). The fetches of that tab are answered by
|
|
272
|
+
the real `Rack::App`. This makes the round-trip specs go page, then endpoint,
|
|
273
|
+
then `Mcp::Server`, then `ReferenceAdapter`, with nothing mocked. They also
|
|
274
|
+
check that WebMCP returns the same documents as the in-process Loopback
|
|
275
|
+
transport. These specs are skipped when `node` isn't installed.
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* portage-ucp-webmcp consumer (outbound).
|
|
3
|
+
*
|
|
4
|
+
* A single function expression Bridges::ScriptEvaluator evaluates in the
|
|
5
|
+
* page (via whatever driver the caller has) as `(<this>)(op, name, input)`.
|
|
6
|
+
* Always resolves — never rejects — to a JSON string envelope
|
|
7
|
+
* `{ ok: true, value }` or `{ ok: false, code, error }`, so every driver sees
|
|
8
|
+
* one shape regardless of how it surfaces a rejected promise. `code` is
|
|
9
|
+
* "no_surface" (no WebMCP on the page), "not_registered" (no such tool) or
|
|
10
|
+
* "execute_failed" (the tool itself threw or rejected).
|
|
11
|
+
*
|
|
12
|
+
* Surfaces probed, in order:
|
|
13
|
+
* 1. `document.modelContext` / `navigator.modelContext` with `getTools()` +
|
|
14
|
+
* `executeTool(tool, input)` — the current spec.
|
|
15
|
+
* 2. `navigator.modelContextTesting` with `listTools()` +
|
|
16
|
+
* `executeTool(name, jsonString)` — the testing API early browser builds
|
|
17
|
+
* shipped behind a flag.
|
|
18
|
+
* 3. A `modelContext` with `listTools()` + `callTool({ name, arguments })`
|
|
19
|
+
* — the shape some userland polyfills expose.
|
|
20
|
+
*/
|
|
21
|
+
(function (op, name, input) {
|
|
22
|
+
"use strict";
|
|
23
|
+
|
|
24
|
+
var root = typeof window !== "undefined" ? window : globalThis;
|
|
25
|
+
var doc = root.document;
|
|
26
|
+
var nav = root.navigator || {};
|
|
27
|
+
var context = (doc && doc.modelContext) || nav.modelContext;
|
|
28
|
+
var testing = nav.modelContextTesting;
|
|
29
|
+
|
|
30
|
+
function failure(code, message) {
|
|
31
|
+
var error = new Error(message);
|
|
32
|
+
error.code = code;
|
|
33
|
+
return error;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function surface() {
|
|
37
|
+
if (context && typeof context.getTools === "function" && typeof context.executeTool === "function") {
|
|
38
|
+
return Promise.resolve(context.getTools()).then(function (tools) {
|
|
39
|
+
return { kind: "spec", tools: tools || [] };
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
if (testing && typeof testing.listTools === "function") {
|
|
43
|
+
return Promise.resolve(testing.listTools()).then(function (tools) {
|
|
44
|
+
return { kind: "testing", tools: tools || [] };
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
if (context && typeof context.listTools === "function" && typeof context.callTool === "function") {
|
|
48
|
+
return Promise.resolve(context.listTools()).then(function (listed) {
|
|
49
|
+
return { kind: "polyfill", tools: (listed && listed.tools) || listed || [] };
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return Promise.reject(failure("no_surface",
|
|
53
|
+
"no WebMCP tool surface on this page (document.modelContext, navigator.modelContext, " +
|
|
54
|
+
"navigator.modelContextTesting)"));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function schema(value) {
|
|
58
|
+
if (typeof value !== "string") return value || {};
|
|
59
|
+
try { return JSON.parse(value); } catch (_e) { return {}; }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function plain(tool) {
|
|
63
|
+
return {
|
|
64
|
+
name: tool.name,
|
|
65
|
+
title: tool.title || null,
|
|
66
|
+
description: tool.description || "",
|
|
67
|
+
inputSchema: schema(tool.inputSchema),
|
|
68
|
+
annotations: tool.annotations || {},
|
|
69
|
+
origin: tool.origin || null
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function execute(found) {
|
|
74
|
+
var tool = found.tools.filter(function (t) { return t.name === name; })[0];
|
|
75
|
+
if (!tool) throw failure("not_registered", "WebMCP tool not registered on this page: " + name);
|
|
76
|
+
|
|
77
|
+
var run;
|
|
78
|
+
try {
|
|
79
|
+
if (found.kind === "spec") run = context.executeTool(tool, input || {});
|
|
80
|
+
else if (found.kind === "testing") run = testing.executeTool(name, JSON.stringify(input || {}));
|
|
81
|
+
else run = context.callTool({ name: name, arguments: input || {} });
|
|
82
|
+
} catch (error) {
|
|
83
|
+
run = Promise.reject(error);
|
|
84
|
+
}
|
|
85
|
+
return Promise.resolve(run).then(null, function (error) {
|
|
86
|
+
throw failure("execute_failed", String((error && error.message) || error));
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
var work = surface().then(function (found) {
|
|
91
|
+
return op === "list" ? found.tools.map(plain) : execute(found);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
return work.then(
|
|
95
|
+
function (value) { return JSON.stringify({ ok: true, value: value === undefined ? null : value }); },
|
|
96
|
+
function (error) {
|
|
97
|
+
return JSON.stringify({
|
|
98
|
+
ok: false,
|
|
99
|
+
code: (error && error.code) || "execute_failed",
|
|
100
|
+
error: String((error && error.message) || error)
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
);
|
|
104
|
+
})
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* portage-ucp-webmcp polyfill.
|
|
3
|
+
*
|
|
4
|
+
* Installs a minimal, spec-shaped ModelContext (`registerTool` with an
|
|
5
|
+
* AbortSignal, `getTools`, `executeTool`, plus the older `unregisterTool`)
|
|
6
|
+
* on `document.modelContext` and `navigator.modelContext` — only when the
|
|
7
|
+
* browser has neither. Two uses:
|
|
8
|
+
*
|
|
9
|
+
* - Agent side: inject it as an init script (Playwright `add_init_script`,
|
|
10
|
+
* Ferrum `evaluate_on_new_document`, CDP
|
|
11
|
+
* `Page.addScriptToEvaluateOnNewDocument`) so a page that registers
|
|
12
|
+
* WebMCP tools still has them discoverable in a browser build without
|
|
13
|
+
* native WebMCP.
|
|
14
|
+
* - Merchant side: prepend it to the registrar (`include_polyfill: true`)
|
|
15
|
+
* for agents that drive such a browser.
|
|
16
|
+
*
|
|
17
|
+
* It is not a security boundary and does nothing a page's own script
|
|
18
|
+
* couldn't; when a native implementation exists it is left untouched.
|
|
19
|
+
*/
|
|
20
|
+
(function () {
|
|
21
|
+
"use strict";
|
|
22
|
+
|
|
23
|
+
var root = typeof window !== "undefined" ? window : globalThis;
|
|
24
|
+
var doc = root.document;
|
|
25
|
+
var nav = root.navigator;
|
|
26
|
+
if ((doc && doc.modelContext) || (nav && nav.modelContext)) return;
|
|
27
|
+
|
|
28
|
+
var NAME = /^[A-Za-z0-9_.-]{1,128}$/;
|
|
29
|
+
var tools = new Map();
|
|
30
|
+
var listeners = {};
|
|
31
|
+
|
|
32
|
+
function emit(type, detail) {
|
|
33
|
+
(listeners[type] || []).slice().forEach(function (listener) {
|
|
34
|
+
try { listener({ type: type, toolName: detail }); } catch (_e) { /* listener's own problem */ }
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function origin() {
|
|
39
|
+
return (root.location && root.location.origin) || null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function remove(name) {
|
|
43
|
+
if (tools.delete(name)) emit("toolchange", name);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
var modelContext = {
|
|
47
|
+
registerTool: function (tool, options) {
|
|
48
|
+
if (!tool || !NAME.test(String(tool.name))) return Promise.reject(new TypeError("invalid tool name"));
|
|
49
|
+
if (!tool.description) return Promise.reject(new TypeError("tool description is required"));
|
|
50
|
+
if (typeof tool.execute !== "function") return Promise.reject(new TypeError("tool execute must be a function"));
|
|
51
|
+
if (tools.has(tool.name)) return Promise.reject(new Error("tool already registered: " + tool.name));
|
|
52
|
+
|
|
53
|
+
var signal = options && options.signal;
|
|
54
|
+
if (signal && signal.aborted) return Promise.resolve();
|
|
55
|
+
tools.set(tool.name, tool);
|
|
56
|
+
if (signal) signal.addEventListener("abort", function () { remove(tool.name); });
|
|
57
|
+
emit("toolchange", tool.name);
|
|
58
|
+
return Promise.resolve();
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
unregisterTool: function (name) { remove(name); },
|
|
62
|
+
|
|
63
|
+
getTools: function () {
|
|
64
|
+
return Promise.resolve(Array.from(tools.values()).map(function (tool) {
|
|
65
|
+
return {
|
|
66
|
+
name: tool.name,
|
|
67
|
+
title: tool.title,
|
|
68
|
+
description: tool.description,
|
|
69
|
+
inputSchema: tool.inputSchema || {},
|
|
70
|
+
annotations: tool.annotations || {},
|
|
71
|
+
origin: origin()
|
|
72
|
+
};
|
|
73
|
+
}));
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
// Resolves to a string, like the spec's `Promise<DOMString>`.
|
|
77
|
+
executeTool: function (registered, input, options) {
|
|
78
|
+
var name = typeof registered === "string" ? registered : registered && registered.name;
|
|
79
|
+
var tool = tools.get(name);
|
|
80
|
+
if (!tool) return Promise.reject(new Error("tool not registered: " + name));
|
|
81
|
+
|
|
82
|
+
var signal = (options && options.signal) ||
|
|
83
|
+
(typeof AbortController === "function" ? new AbortController().signal : undefined);
|
|
84
|
+
emit("toolactivated", name);
|
|
85
|
+
return Promise.resolve(tool.execute(input || {}, { signal: signal })).then(function (value) {
|
|
86
|
+
return typeof value === "string" ? value : JSON.stringify(value === undefined ? null : value);
|
|
87
|
+
});
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
addEventListener: function (type, listener) {
|
|
91
|
+
(listeners[type] = listeners[type] || []).push(listener);
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
removeEventListener: function (type, listener) {
|
|
95
|
+
listeners[type] = (listeners[type] || []).filter(function (l) { return l !== listener; });
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
function install(target) {
|
|
100
|
+
if (!target) return;
|
|
101
|
+
try {
|
|
102
|
+
Object.defineProperty(target, "modelContext", { value: modelContext, configurable: true });
|
|
103
|
+
} catch (_e) { /* frozen host object; the other target still gets it */ }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
install(doc);
|
|
107
|
+
install(nav);
|
|
108
|
+
root.__portageWebMcpPolyfill = true;
|
|
109
|
+
})();
|