@mcp-b/webmcp-local-relay 1.6.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,18 +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
20
  /** @type {Window | null} */
21
- var widgetWindow = null;
21
+ let widgetWindow = null;
22
22
 
23
- /**
24
- * @returns {HTMLScriptElement | null}
25
- */
23
+ /** @returns {HTMLScriptElement | null} */
26
24
  function getCurrentScriptElement() {
27
25
  return document.currentScript instanceof HTMLScriptElement ? document.currentScript : null;
28
26
  }
@@ -35,9 +33,7 @@
35
33
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
36
34
  }
37
35
 
38
- /**
39
- * @returns {string}
40
- */
36
+ /** @returns {string} */
41
37
  function createTabId() {
42
38
  if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
43
39
  return crypto.randomUUID();
@@ -45,9 +41,7 @@
45
41
  return `${String(Date.now())}_${String(Math.random()).slice(2, 10)}`;
46
42
  }
47
43
 
48
- /**
49
- * @returns {string}
50
- */
44
+ /** @returns {string} */
51
45
  function readOrCreateTabId() {
52
46
  try {
53
47
  const storedTabId = sessionStorage.getItem(TAB_ID_STORAGE_KEY);
@@ -81,10 +75,16 @@
81
75
  return new URL('widget.html', script.src).href;
82
76
  } catch (err) {
83
77
  console.warn(
84
- '[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.',
85
80
  err
86
81
  );
87
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
+ );
88
88
  }
89
89
  return FALLBACK_WIDGET_URL;
90
90
  }
@@ -117,7 +117,10 @@
117
117
  return isJsonObject(parsed) ? parsed : { type: 'object', properties: {} };
118
118
  } catch (err) {
119
119
  console.warn(
120
- '[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,
121
124
  err
122
125
  );
123
126
  return { type: 'object', properties: {} };
@@ -129,12 +132,18 @@
129
132
  * @returns {JsonObject}
130
133
  */
131
134
  function toInvokeArgs(value) {
132
- 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 {};
133
144
  }
134
145
 
135
- /**
136
- * @returns {ToolBridge | null}
137
- */
146
+ /** @returns {ToolBridge | null} */
138
147
  function getToolBridge() {
139
148
  const modelContext = navigator.modelContext;
140
149
  if (
@@ -180,7 +189,14 @@
180
189
  content: [{ type: 'text', text: 'Tool execution interrupted by navigation' }],
181
190
  };
182
191
  }
183
- 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
+ }
184
200
  if (!isJsonObject(parsed)) {
185
201
  throw new Error('Testing tool response was not an object');
186
202
  }
@@ -189,10 +205,14 @@
189
205
  };
190
206
  }
191
207
 
208
+ console.warn(
209
+ '[webmcp-relay-embed] No WebMCP runtime found (navigator.modelContext or',
210
+ 'navigator.modelContextTesting). Tools will not be relayed.'
211
+ );
192
212
  return null;
193
213
  }
194
214
 
195
- var pushScheduled = false;
215
+ let pushScheduled = false;
196
216
 
197
217
  /**
198
218
  * Coalesced handler for tool change events.
@@ -201,13 +221,13 @@
201
221
  function onToolsChanged() {
202
222
  if (pushScheduled || !widgetWindow) return;
203
223
  pushScheduled = true;
204
- setTimeout(function pushToolsChangedToWidget() {
224
+ setTimeout(() => {
205
225
  pushScheduled = false;
206
226
  if (!widgetWindow) return;
207
- var bridge = getToolBridge();
208
- var toolsPromise = bridge ? Promise.resolve(bridge.listTools()) : Promise.resolve([]);
227
+ const bridge = getToolBridge();
228
+ const toolsPromise = bridge ? Promise.resolve(bridge.listTools()) : Promise.resolve([]);
209
229
  toolsPromise
210
- .then(function (tools) {
230
+ .then((tools) => {
211
231
  if (!widgetWindow) return;
212
232
  widgetWindow.postMessage(
213
233
  {
@@ -217,7 +237,7 @@
217
237
  config.widgetOrigin
218
238
  );
219
239
  })
220
- .catch(function (err) {
240
+ .catch((err) => {
221
241
  console.warn('[webmcp-relay-embed] Failed to push tool changes:', err);
222
242
  });
223
243
  }, 0);
@@ -229,23 +249,35 @@
229
249
  * falls back to registerToolsChangedCallback on modelContextTesting.
230
250
  */
231
251
  function subscribeToToolChanges() {
232
- var mc = navigator.modelContext;
252
+ const mc = navigator.modelContext;
233
253
  if (mc && typeof mc.addEventListener === 'function') {
234
254
  try {
235
255
  mc.addEventListener('toolschanged', onToolsChanged);
236
256
  return;
237
- } catch (_e) {
238
- /* BrowserMcpServer doesn't extend EventTarget — fall through */
257
+ } catch (error) {
258
+ if (!(error instanceof TypeError)) {
259
+ console.warn(
260
+ '[webmcp-relay-embed] Unexpected error subscribing via addEventListener:',
261
+ error
262
+ );
263
+ }
239
264
  }
240
265
  }
241
- var testing = navigator.modelContextTesting;
266
+ const testing = navigator.modelContextTesting;
242
267
  if (testing && typeof testing.registerToolsChangedCallback === 'function') {
243
268
  try {
244
269
  testing.registerToolsChangedCallback(onToolsChanged);
245
- } catch (e) {
246
- console.warn('[webmcp-relay-embed] Failed to subscribe to tool changes:', e);
270
+ return;
271
+ } catch (error) {
272
+ console.warn(
273
+ '[webmcp-relay-embed] Failed to subscribe via registerToolsChangedCallback:',
274
+ error
275
+ );
247
276
  }
248
277
  }
278
+ console.warn(
279
+ '[webmcp-relay-embed] Could not subscribe to tool changes. Dynamic tool updates will not be relayed.'
280
+ );
249
281
  }
250
282
 
251
283
  /**
@@ -258,7 +290,7 @@
258
290
  return;
259
291
  }
260
292
 
261
- const postMessage = source.postMessage;
293
+ const { postMessage } = source;
262
294
  if (typeof postMessage !== 'function') {
263
295
  return;
264
296
  }
@@ -309,6 +341,7 @@
309
341
  type: 'webmcp.tools.list.response',
310
342
  requestId: request.requestId,
311
343
  tools: [],
344
+ error: `Failed to list tools: ${error instanceof Error ? error.message : String(error)}`,
312
345
  });
313
346
  });
314
347
  }
@@ -345,9 +378,7 @@
345
378
  });
346
379
  }
347
380
 
348
- /**
349
- * @param {RelayConfig} config
350
- */
381
+ /** @param {RelayConfig} config */
351
382
  function injectRelayWidget(config) {
352
383
  if (document.querySelector(RELAY_IFRAME_SELECTOR)) {
353
384
  return;
@@ -356,7 +387,10 @@
356
387
  const searchParams = new URLSearchParams();
357
388
  searchParams.set('tabId', config.tabId);
358
389
  searchParams.set('hostOrigin', window.location.origin);
359
- 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);
360
394
  searchParams.set('hostTitle', document.title || '');
361
395
  searchParams.set('relayHost', config.relayHost);
362
396
  searchParams.set('relayPort', config.relayPort);
@@ -367,21 +401,44 @@
367
401
  iframe.setAttribute('aria-hidden', 'true');
368
402
  iframe.setAttribute('data-webmcp-relay', '1');
369
403
  document.body.appendChild(iframe);
404
+ widgetWindow = iframe.contentWindow;
370
405
  iframe.addEventListener('load', () => {
371
406
  widgetWindow = iframe.contentWindow;
372
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
+ });
373
415
  }
374
416
 
375
417
  if (document.querySelector(RELAY_IFRAME_SELECTOR)) {
376
418
  return;
377
419
  }
378
420
 
379
- 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
+ }
380
428
 
381
429
  window.addEventListener('message', (event) => {
382
430
  if (event.origin !== config.widgetOrigin) {
383
431
  return;
384
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
+ }
385
442
 
386
443
  const request = parseWidgetRequest(event.data);
387
444
  if (!request) {