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 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
+ })();