@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 +62 -31
- package/dist/browser/embed.js +93 -36
- package/dist/browser/widget.html +119 -62
- package/dist/cli.js +6 -5
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +509 -197
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/mcpRelayServer-B8d7NAUK.js +1639 -0
- package/dist/mcpRelayServer-B8d7NAUK.js.map +1 -0
- package/package.json +1 -1
- package/dist/mcpRelayServer-B1kmqJkx.js +0 -972
- package/dist/mcpRelayServer-B1kmqJkx.js.map +0 -1
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
|
|
9
|
-
|
|
10
|
-
│
|
|
11
|
-
│
|
|
12
|
-
│
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
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
|
|
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
|
|
138
|
-
npx @mcp-b/webmcp-local-relay --widget-origin https://
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
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
|
|
package/dist/browser/embed.js
CHANGED
|
@@ -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
|
-
(
|
|
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
|
-
|
|
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]
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
224
|
+
setTimeout(() => {
|
|
205
225
|
pushScheduled = false;
|
|
206
226
|
if (!widgetWindow) return;
|
|
207
|
-
|
|
208
|
-
|
|
227
|
+
const bridge = getToolBridge();
|
|
228
|
+
const toolsPromise = bridge ? Promise.resolve(bridge.listTools()) : Promise.resolve([]);
|
|
209
229
|
toolsPromise
|
|
210
|
-
.then(
|
|
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(
|
|
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
|
-
|
|
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 (
|
|
238
|
-
|
|
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
|
-
|
|
266
|
+
const testing = navigator.modelContextTesting;
|
|
242
267
|
if (testing && typeof testing.registerToolsChangedCallback === 'function') {
|
|
243
268
|
try {
|
|
244
269
|
testing.registerToolsChangedCallback(onToolsChanged);
|
|
245
|
-
|
|
246
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|