@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 +62 -31
- package/dist/browser/embed.js +144 -23
- package/dist/browser/widget.html +134 -57
- 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,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
|
-
(
|
|
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
|
-
|
|
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]
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
})();
|