@mcp-b/webmcp-local-relay 1.1.0 → 1.7.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.
package/README.md CHANGED
@@ -5,15 +5,22 @@
5
5
  Use WebMCP tools from any website, right inside your AI client.
6
6
 
7
7
  ```text
8
- Browser Your machine
9
- ┌─────────────────┐ ┌─────────────────┐
10
- Website with webmcp-local-
11
- WebMCP tools │───────────│ relay
12
- │ localhost │
13
- └─────────────────┘ └────────┬────────┘
14
- │ stdio
15
-
16
- Claude / Cursor / etc.
8
+ Browser Tab Local Machine
9
+ ┌──────────────────────┐ ┌──────────────────────┐
10
+ WebSocket
11
+ Website with ├────────────▶ webmcp-local-relay
12
+ WebMCP tools │ localhost │ (MCP server)
13
+ │ │ │ │
14
+ └──────────────────────┘ └──────────┬───────────┘
15
+
16
+ stdio JSON-RPC
17
+
18
+ ┌──────────▼───────────┐
19
+ │ │
20
+ │ Claude / Cursor / │
21
+ │ any MCP client │
22
+ │ │
23
+ └──────────────────────┘
17
24
  ```
18
25
 
19
26
  Open a website that has WebMCP tools. Run the relay. The tools show up in your MCP client.
@@ -100,13 +107,14 @@ npx @mcp-b/webmcp-local-relay
100
107
 
101
108
  ### Exposed Tools
102
109
 
103
- The relay exposes three static management tools that are always available:
110
+ The relay exposes four static management tools that are always available:
104
111
 
105
112
  | Tool | Description |
106
113
  |------|-------------|
107
114
  | `webmcp_list_sources` | Lists connected browser tabs with metadata (tab ID, origin, URL, title, icon, tool count) |
108
115
  | `webmcp_list_tools` | Lists all relayed tools with source info |
109
116
  | `webmcp_call_tool` | Invokes a relayed tool by name with JSON arguments — useful for clients that don't support dynamic tool registration |
117
+ | `webmcp_open_page` | Opens a URL in the user's default browser, or refreshes a connected source page by matching origin |
110
118
 
111
119
  **Dynamic tools** are registered directly on the MCP server using the original tool name, sanitized to `[a-zA-Z0-9_]`. When tools from different tabs share a name, a short tab-ID suffix is appended for disambiguation:
112
120
 
@@ -120,8 +128,9 @@ webmcp-local-relay [options]
120
128
 
121
129
  --host, -H Bind host (default: 127.0.0.1)
122
130
  --port, -p WebSocket port (default: 9333)
123
- --widget-origin Allowed browser origins, comma-separated (default: *)
131
+ --widget-origin Allowed host page origin(s), comma-separated (default: *)
124
132
  --allowed-origin Alias for --widget-origin
133
+ --ws-origin Alias for --widget-origin
125
134
  --help, -h Show help
126
135
  ```
127
136
 
@@ -134,39 +143,61 @@ npx @mcp-b/webmcp-local-relay
134
143
  # Custom port
135
144
  npx @mcp-b/webmcp-local-relay --port 9444
136
145
 
137
- # Restrict to trusted origins only
138
- npx @mcp-b/webmcp-local-relay --widget-origin https://your-app.example.com,https://another-app.example.com
146
+ # Restrict to tools from trusted host pages
147
+ npx @mcp-b/webmcp-local-relay --widget-origin https://myapp.com
139
148
  ```
140
149
 
141
150
  ### Security
142
151
 
143
152
  - Binds to `127.0.0.1` by default (loopback only, not accessible from your network).
144
153
  - The default `allowedOrigins` is `*`, which permits any browser page to connect and register tools. This is convenient for development but means any website open in your browser can expose tools to the relay.
145
- - **Recommended:** Use `--widget-origin` to restrict connections to only the origins you trust:
154
+ - `--widget-origin` validates the **host page origin** reported in the browser `hello` message. This is the origin of the page that loaded `embed.js` (e.g., `https://myapp.com`), regardless of whether the widget iframe is served from CDN or self-hosted.
155
+ - **Recommended:** Use `--widget-origin` to restrict which websites can register tools:
146
156
  ```bash
147
- webmcp-local-relay --widget-origin https://your-app.example.com,https://another-app.example.com
157
+ # Only allow tools from myapp.com
158
+ webmcp-local-relay --widget-origin https://myapp.com
159
+
160
+ # Allow multiple origins
161
+ webmcp-local-relay --widget-origin https://app1.com,https://app2.com
148
162
  ```
149
- - Only the WebSocket `Origin` header is checked — any local process can connect regardless of origin restrictions.
163
+ - Only the host page origin is checked — any local process can connect regardless of origin restrictions.
150
164
 
151
165
  ### Architecture
152
166
 
153
167
  ```text
154
- MCP Client (Claude, Cursor, etc.)
155
- | stdio (JSON-RPC)
156
- v
157
- LocalRelayMcpServer
158
- | static + dynamic MCP tools
159
- v
160
- RelayBridgeServer (ws://127.0.0.1:9333)
161
- | ws messages
162
- v
163
- Widget iframe (embed.js -> widget.html)
164
- | postMessage bridge
165
- v
166
- Host page WebMCP runtime (navigator.modelContext)
168
+ ┌──────────────────────────────────────┐
169
+ │ MCP Client │
170
+ │ (Claude, Cursor, Windsurf, etc.) │
171
+ └──────────────────┬───────────────────┘
172
+ stdio / JSON-RPC
173
+ ┌──────────────────▼───────────────────┐
174
+ │ LocalRelayMcpServer │
175
+ │ webmcp_list_sources │
176
+ │ webmcp_list_tools │
177
+ │ webmcp_call_tool │
178
+ │ + dynamic tools from browser │
179
+ └──────────────────┬───────────────────┘
180
+ WebSocket (ws://127.0.0.1:9333)
181
+ ┌──────────────────▼───────────────────┐
182
+ │ RelayBridgeServer │
183
+ │ Manages connections, routes calls │
184
+ └──────────────────┬───────────────────┘
185
+ │ postMessage
186
+ ┌──────────────────▼───────────────────┐
187
+ │ Widget iframe │
188
+ │ embed.js injects widget.html │
189
+ └──────────────────┬───────────────────┘
190
+ │ navigator.modelContext
191
+ ┌──────────────────▼───────────────────┐
192
+ │ Host page │
193
+ │ WebMCP runtime + registered tools │
194
+ └──────────────────────────────────────┘
167
195
  ```
168
196
 
169
197
  **How it connects:** The embed script injects a hidden iframe into the host page. The iframe opens a WebSocket to the relay on `localhost`. Tools are discovered via `navigator.modelContext` (or `navigator.modelContextTesting` as fallback) and forwarded to the relay, which registers them as standard MCP tools over stdio.
198
+ If the relay is temporarily unavailable, the widget reconnects automatically using exponential backoff (1.5x multiplier) from `500ms` up to `3000ms`, stopping after 100 attempts.
199
+
200
+ **Client mode:** When a second relay instance starts and the port is already in use (`EADDRINUSE`), it automatically falls back to **client mode**. In client mode the relay connects as a WebSocket client to the existing server relay and proxies tool operations through it. If the server relay later stops, the client attempts to promote itself back to server mode. This enables multiple MCP clients to share the same browser connections without manual configuration.
170
201
 
171
202
  ### Runtime Compatibility
172
203
 
@@ -199,9 +230,9 @@ For Chromium/Chrome Canary native preview testing:
199
230
  | Problem | Fix |
200
231
  |---------|-----|
201
232
  | `No sources connected` | Ensure the page loaded `embed.js` and the relay process is running |
202
- | `No tools listed` | Ensure page tools are registered on the WebMCP runtime before `embed.js` loads |
233
+ | `No tools listed` | Ensure tools are registered on the page's WebMCP runtime. If tools register after load, confirm your runtime emits tool-change notifications (`toolschanged` or `registerToolsChangedCallback`) |
203
234
  | `Tool not found` | Tab reloaded or disconnected — call `webmcp_list_tools` again to refresh |
204
- | Connection blocked | Verify `--widget-origin` matches your page's origin, and relay port matches `data-relay-port` |
235
+ | Connection blocked | Verify `--widget-origin` matches your host page's origin (e.g., `https://myapp.com`), and relay port matches `data-relay-port` |
205
236
 
206
237
  ---
207
238
 
@@ -11,15 +11,16 @@
11
11
  * @typedef {{ requestId: string; type: string; toolName?: unknown; args?: unknown }} WidgetRequestMessage
12
12
  * @typedef {{ relayHost: string; relayPort: string; tabId: string; widgetUrl: string; widgetOrigin: string }} RelayConfig
13
13
  */
14
- (function initializeWebMcpRelayEmbed() {
14
+ (() => {
15
15
  const RELAY_IFRAME_SELECTOR = '[data-webmcp-relay]';
16
16
  const TAB_ID_STORAGE_KEY = '__webmcp_relay_tab_id';
17
17
  const FALLBACK_WIDGET_URL =
18
18
  'https://cdn.jsdelivr.net/npm/@mcp-b/webmcp-local-relay/dist/browser/widget.html';
19
19
 
20
- /**
21
- * @returns {HTMLScriptElement | null}
22
- */
20
+ /** @type {Window | null} */
21
+ let widgetWindow = null;
22
+
23
+ /** @returns {HTMLScriptElement | null} */
23
24
  function getCurrentScriptElement() {
24
25
  return document.currentScript instanceof HTMLScriptElement ? document.currentScript : null;
25
26
  }
@@ -32,9 +33,7 @@
32
33
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
33
34
  }
34
35
 
35
- /**
36
- * @returns {string}
37
- */
36
+ /** @returns {string} */
38
37
  function createTabId() {
39
38
  if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
40
39
  return crypto.randomUUID();
@@ -42,9 +41,7 @@
42
41
  return `${String(Date.now())}_${String(Math.random()).slice(2, 10)}`;
43
42
  }
44
43
 
45
- /**
46
- * @returns {string}
47
- */
44
+ /** @returns {string} */
48
45
  function readOrCreateTabId() {
49
46
  try {
50
47
  const storedTabId = sessionStorage.getItem(TAB_ID_STORAGE_KEY);
@@ -78,10 +75,16 @@
78
75
  return new URL('widget.html', script.src).href;
79
76
  } catch (err) {
80
77
  console.warn(
81
- '[webmcp-relay-embed] Failed to resolve widget URL from script src, falling back to CDN:',
78
+ '[webmcp-relay-embed] Failed to resolve widget URL from script src, falling back to CDN.',
79
+ 'This may cause version mismatches with your local relay server.',
82
80
  err
83
81
  );
84
82
  }
83
+ } else {
84
+ console.warn(
85
+ '[webmcp-relay-embed] Script element has no src attribute, falling back to CDN widget URL.',
86
+ 'This may cause version mismatches with your local relay server.'
87
+ );
85
88
  }
86
89
  return FALLBACK_WIDGET_URL;
87
90
  }
@@ -114,7 +117,10 @@
114
117
  return isJsonObject(parsed) ? parsed : { type: 'object', properties: {} };
115
118
  } catch (err) {
116
119
  console.warn(
117
- '[webmcp-relay-embed] Failed to parse tool inputSchema, using permissive default:',
120
+ '[webmcp-relay-embed] Tool inputSchema is not valid JSON.',
121
+ 'The tool will accept any arguments, which may cause invocation errors.',
122
+ 'Raw schema:',
123
+ typeof rawSchema === 'string' ? rawSchema.slice(0, 200) : rawSchema,
118
124
  err
119
125
  );
120
126
  return { type: 'object', properties: {} };
@@ -126,12 +132,18 @@
126
132
  * @returns {JsonObject}
127
133
  */
128
134
  function toInvokeArgs(value) {
129
- return isJsonObject(value) ? value : {};
135
+ if (isJsonObject(value)) return value;
136
+ if (value !== undefined && value !== null) {
137
+ console.warn(
138
+ '[webmcp-relay-embed] Tool invocation args must be an object, got',
139
+ typeof value,
140
+ '-- invocation will proceed with empty arguments'
141
+ );
142
+ }
143
+ return {};
130
144
  }
131
145
 
132
- /**
133
- * @returns {ToolBridge | null}
134
- */
146
+ /** @returns {ToolBridge | null} */
135
147
  function getToolBridge() {
136
148
  const modelContext = navigator.modelContext;
137
149
  if (
@@ -177,7 +189,14 @@
177
189
  content: [{ type: 'text', text: 'Tool execution interrupted by navigation' }],
178
190
  };
179
191
  }
180
- const parsed = JSON.parse(serialized);
192
+ let parsed;
193
+ try {
194
+ parsed = JSON.parse(serialized);
195
+ } catch {
196
+ throw new Error(
197
+ `Testing tool returned invalid JSON: ${String(serialized).slice(0, 200)}`
198
+ );
199
+ }
181
200
  if (!isJsonObject(parsed)) {
182
201
  throw new Error('Testing tool response was not an object');
183
202
  }
@@ -186,9 +205,81 @@
186
205
  };
187
206
  }
188
207
 
208
+ console.warn(
209
+ '[webmcp-relay-embed] No WebMCP runtime found (navigator.modelContext or',
210
+ 'navigator.modelContextTesting). Tools will not be relayed.'
211
+ );
189
212
  return null;
190
213
  }
191
214
 
215
+ let pushScheduled = false;
216
+
217
+ /**
218
+ * Coalesced handler for tool change events.
219
+ * Uses flag + setTimeout(0) to batch rapid registrations into a single push.
220
+ */
221
+ function onToolsChanged() {
222
+ if (pushScheduled || !widgetWindow) return;
223
+ pushScheduled = true;
224
+ setTimeout(() => {
225
+ pushScheduled = false;
226
+ if (!widgetWindow) return;
227
+ const bridge = getToolBridge();
228
+ const toolsPromise = bridge ? Promise.resolve(bridge.listTools()) : Promise.resolve([]);
229
+ toolsPromise
230
+ .then((tools) => {
231
+ if (!widgetWindow) return;
232
+ widgetWindow.postMessage(
233
+ {
234
+ type: 'webmcp.tools.changed',
235
+ tools: Array.isArray(tools) ? tools : [],
236
+ },
237
+ config.widgetOrigin
238
+ );
239
+ })
240
+ .catch((err) => {
241
+ console.warn('[webmcp-relay-embed] Failed to push tool changes:', err);
242
+ });
243
+ }, 0);
244
+ }
245
+
246
+ /**
247
+ * Subscribes to modelContext tool change events.
248
+ * Tries addEventListener first (future native EventTarget support),
249
+ * falls back to registerToolsChangedCallback on modelContextTesting.
250
+ */
251
+ function subscribeToToolChanges() {
252
+ const mc = navigator.modelContext;
253
+ if (mc && typeof mc.addEventListener === 'function') {
254
+ try {
255
+ mc.addEventListener('toolschanged', onToolsChanged);
256
+ return;
257
+ } catch (error) {
258
+ if (!(error instanceof TypeError)) {
259
+ console.warn(
260
+ '[webmcp-relay-embed] Unexpected error subscribing via addEventListener:',
261
+ error
262
+ );
263
+ }
264
+ }
265
+ }
266
+ const testing = navigator.modelContextTesting;
267
+ if (testing && typeof testing.registerToolsChangedCallback === 'function') {
268
+ try {
269
+ testing.registerToolsChangedCallback(onToolsChanged);
270
+ return;
271
+ } catch (error) {
272
+ console.warn(
273
+ '[webmcp-relay-embed] Failed to subscribe via registerToolsChangedCallback:',
274
+ error
275
+ );
276
+ }
277
+ }
278
+ console.warn(
279
+ '[webmcp-relay-embed] Could not subscribe to tool changes. Dynamic tool updates will not be relayed.'
280
+ );
281
+ }
282
+
192
283
  /**
193
284
  * @param {MessageEventSource | null} source
194
285
  * @param {string} origin
@@ -199,7 +290,7 @@
199
290
  return;
200
291
  }
201
292
 
202
- const postMessage = source.postMessage;
293
+ const { postMessage } = source;
203
294
  if (typeof postMessage !== 'function') {
204
295
  return;
205
296
  }
@@ -250,6 +341,7 @@
250
341
  type: 'webmcp.tools.list.response',
251
342
  requestId: request.requestId,
252
343
  tools: [],
344
+ error: `Failed to list tools: ${error instanceof Error ? error.message : String(error)}`,
253
345
  });
254
346
  });
255
347
  }
@@ -286,9 +378,7 @@
286
378
  });
287
379
  }
288
380
 
289
- /**
290
- * @param {RelayConfig} config
291
- */
381
+ /** @param {RelayConfig} config */
292
382
  function injectRelayWidget(config) {
293
383
  if (document.querySelector(RELAY_IFRAME_SELECTOR)) {
294
384
  return;
@@ -297,7 +387,10 @@
297
387
  const searchParams = new URLSearchParams();
298
388
  searchParams.set('tabId', config.tabId);
299
389
  searchParams.set('hostOrigin', window.location.origin);
300
- searchParams.set('hostUrl', window.location.href);
390
+ const cleanUrl = new URL(window.location.href);
391
+ cleanUrl.search = '';
392
+ cleanUrl.hash = '';
393
+ searchParams.set('hostUrl', cleanUrl.href);
301
394
  searchParams.set('hostTitle', document.title || '');
302
395
  searchParams.set('relayHost', config.relayHost);
303
396
  searchParams.set('relayPort', config.relayPort);
@@ -308,18 +401,44 @@
308
401
  iframe.setAttribute('aria-hidden', 'true');
309
402
  iframe.setAttribute('data-webmcp-relay', '1');
310
403
  document.body.appendChild(iframe);
404
+ widgetWindow = iframe.contentWindow;
405
+ iframe.addEventListener('load', () => {
406
+ widgetWindow = iframe.contentWindow;
407
+ });
408
+ iframe.addEventListener('error', () => {
409
+ console.error(
410
+ '[webmcp-relay-embed] Failed to load relay widget iframe from:',
411
+ iframe.src,
412
+ '-- WebMCP tools will NOT be relayed. Check network connectivity and widget URL.'
413
+ );
414
+ });
311
415
  }
312
416
 
313
417
  if (document.querySelector(RELAY_IFRAME_SELECTOR)) {
314
418
  return;
315
419
  }
316
420
 
317
- const config = buildRelayConfig(getCurrentScriptElement());
421
+ let config;
422
+ try {
423
+ config = buildRelayConfig(getCurrentScriptElement());
424
+ } catch (err) {
425
+ console.error('[webmcp-relay-embed] Failed to initialize relay configuration:', err);
426
+ return;
427
+ }
318
428
 
319
429
  window.addEventListener('message', (event) => {
320
430
  if (event.origin !== config.widgetOrigin) {
321
431
  return;
322
432
  }
433
+ if (!widgetWindow || event.source !== widgetWindow) {
434
+ return;
435
+ }
436
+
437
+ const data = event.data;
438
+ if (isJsonObject(data) && data.type === 'webmcp.reload') {
439
+ window.location.reload();
440
+ return;
441
+ }
323
442
 
324
443
  const request = parseWidgetRequest(event.data);
325
444
  if (!request) {
@@ -341,4 +460,6 @@
341
460
  } else {
342
461
  document.addEventListener('DOMContentLoaded', () => injectRelayWidget(config), { once: true });
343
462
  }
463
+
464
+ subscribeToToolChanges();
344
465
  })();