@mcp-b/webmcp-local-relay 2.0.13 → 2.2.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.
@@ -1,480 +1 @@
1
- /**
2
- * Injects a hidden relay widget iframe and bridges widget messages to host tools.
3
- *
4
- * Usage:
5
- * `<script src=".../embed.js" data-relay-host="127.0.0.1" data-relay-port="9333"></script>`
6
- *
7
- * Add `data-debug` to enable diagnostic logging:
8
- * `<script src=".../embed.js" data-debug></script>`
9
- *
10
- * @typedef {{ [key: string]: unknown }} JsonObject
11
- * @typedef {{ name: string; description?: string; inputSchema?: JsonObject }} ToolDescriptor
12
- * @typedef {{ isError?: boolean; content?: Array<{ type: string; text: string }>; [key: string]: unknown }} ToolInvokeResult
13
- * @typedef {{ listTools: () => ToolDescriptor[] | Promise<ToolDescriptor[]>; invoke: (name: string, args: JsonObject) => ToolInvokeResult | Promise<ToolInvokeResult> }} ToolBridge
14
- * @typedef {{ requestId: string; type: string; toolName?: unknown; args?: unknown }} WidgetRequestMessage
15
- * @typedef {{ relayHost: string; relayPort: string; tabId: string; widgetUrl: string; widgetOrigin: string }} RelayConfig
16
- */
17
- (() => {
18
- const RELAY_IFRAME_SELECTOR = '[data-webmcp-relay]';
19
- const TAB_ID_STORAGE_KEY = '__webmcp_relay_tab_id';
20
- const FALLBACK_WIDGET_URL =
21
- 'https://cdn.jsdelivr.net/npm/@mcp-b/webmcp-local-relay/dist/browser/widget.html';
22
-
23
- /** @type {Window | null} */
24
- let widgetWindow = null;
25
-
26
- /** @returns {HTMLScriptElement | null} */
27
- function getCurrentScriptElement() {
28
- return document.currentScript instanceof HTMLScriptElement ? document.currentScript : null;
29
- }
30
-
31
- const scriptEl = getCurrentScriptElement();
32
- const DEBUG = scriptEl ? scriptEl.hasAttribute('data-debug') : false;
33
-
34
- /** @param {unknown[]} args */
35
- function debugWarn(...args) {
36
- if (DEBUG) console.warn('[webmcp-relay-embed]', ...args);
37
- }
38
-
39
- /**
40
- * @param {unknown} value
41
- * @returns {value is JsonObject}
42
- */
43
- function isJsonObject(value) {
44
- return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
45
- }
46
-
47
- /** @returns {string} */
48
- function createTabId() {
49
- if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
50
- return crypto.randomUUID();
51
- }
52
- return `${String(Date.now())}_${String(Math.random()).slice(2, 10)}`;
53
- }
54
-
55
- /** @returns {string} */
56
- function readOrCreateTabId() {
57
- try {
58
- const storedTabId = sessionStorage.getItem(TAB_ID_STORAGE_KEY);
59
- if (storedTabId) {
60
- return storedTabId;
61
- }
62
- } catch (err) {
63
- debugWarn('sessionStorage read failed, tab ID will not persist:', err);
64
- }
65
-
66
- const tabId = createTabId();
67
- try {
68
- sessionStorage.setItem(TAB_ID_STORAGE_KEY, tabId);
69
- } catch (err) {
70
- debugWarn('sessionStorage write failed:', err);
71
- }
72
-
73
- return tabId;
74
- }
75
-
76
- /**
77
- * @param {HTMLScriptElement | null} script
78
- * @returns {string}
79
- */
80
- function resolveWidgetUrl(script) {
81
- if (script?.src) {
82
- try {
83
- return new URL('widget.html', script.src).href;
84
- } catch (err) {
85
- debugWarn('Failed to resolve widget URL from script src, falling back to CDN:', err);
86
- }
87
- } else {
88
- debugWarn('Script element has no src attribute, falling back to CDN widget URL.');
89
- }
90
- return FALLBACK_WIDGET_URL;
91
- }
92
-
93
- /**
94
- * @param {HTMLScriptElement | null} script
95
- * @returns {RelayConfig}
96
- */
97
- function buildRelayConfig(script) {
98
- const widgetUrl = resolveWidgetUrl(script);
99
- return {
100
- relayHost: script?.getAttribute('data-relay-host') || '127.0.0.1',
101
- relayPort: script?.getAttribute('data-relay-port') || '9333',
102
- tabId: readOrCreateTabId(),
103
- widgetUrl,
104
- widgetOrigin: new URL(widgetUrl).origin,
105
- };
106
- }
107
-
108
- /**
109
- * @param {unknown} rawSchema
110
- * @returns {JsonObject}
111
- */
112
- function parseTestingSchema(rawSchema) {
113
- if (typeof rawSchema !== 'string' || rawSchema.length === 0) {
114
- return { type: 'object', properties: {} };
115
- }
116
- try {
117
- const parsed = JSON.parse(rawSchema);
118
- return isJsonObject(parsed) ? parsed : { type: 'object', properties: {} };
119
- } catch (err) {
120
- debugWarn(
121
- 'Tool inputSchema is not valid JSON:',
122
- typeof rawSchema === 'string' ? rawSchema.slice(0, 200) : rawSchema,
123
- err
124
- );
125
- return { type: 'object', properties: {} };
126
- }
127
- }
128
-
129
- /**
130
- * @param {unknown} value
131
- * @returns {JsonObject}
132
- */
133
- function toInvokeArgs(value) {
134
- if (isJsonObject(value)) return value;
135
- if (value !== undefined && value !== null) {
136
- debugWarn('Tool invocation args must be an object, got', typeof value);
137
- }
138
- return {};
139
- }
140
-
141
- /** @returns {ToolBridge | null} */
142
- function getToolBridge() {
143
- const modelContext = navigator.modelContext;
144
- if (
145
- modelContext &&
146
- typeof modelContext.listTools === 'function' &&
147
- typeof modelContext.callTool === 'function'
148
- ) {
149
- return {
150
- listTools() {
151
- return modelContext.listTools().map((tool) => ({
152
- name: tool.name,
153
- description: tool.description,
154
- inputSchema: isJsonObject(tool.inputSchema)
155
- ? tool.inputSchema
156
- : { type: 'object', properties: {} },
157
- }));
158
- },
159
- invoke(name, args) {
160
- return modelContext.callTool({ name, arguments: args });
161
- },
162
- };
163
- }
164
-
165
- const testing = navigator.modelContextTesting;
166
- if (
167
- testing &&
168
- typeof testing.listTools === 'function' &&
169
- typeof testing.executeTool === 'function'
170
- ) {
171
- return {
172
- listTools() {
173
- return testing.listTools().map((tool) => ({
174
- name: tool.name,
175
- description: tool.description,
176
- inputSchema: parseTestingSchema(tool.inputSchema),
177
- }));
178
- },
179
- async invoke(name, args) {
180
- const serialized = await testing.executeTool(name, JSON.stringify(args));
181
- if (serialized === null) {
182
- return {
183
- isError: true,
184
- content: [{ type: 'text', text: 'Tool execution interrupted by navigation' }],
185
- };
186
- }
187
- let parsed;
188
- try {
189
- parsed = JSON.parse(serialized);
190
- } catch {
191
- throw new Error(
192
- `Testing tool returned invalid JSON: ${String(serialized).slice(0, 200)}`
193
- );
194
- }
195
- if (!isJsonObject(parsed)) {
196
- throw new Error('Testing tool response was not an object');
197
- }
198
- return parsed;
199
- },
200
- };
201
- }
202
-
203
- debugWarn('No WebMCP runtime found (navigator.modelContext or navigator.modelContextTesting).');
204
- return null;
205
- }
206
-
207
- let pushScheduled = false;
208
-
209
- /**
210
- * Coalesced handler for tool change events.
211
- * Uses flag + setTimeout(0) to batch rapid registrations into a single push.
212
- */
213
- function onToolsChanged() {
214
- if (pushScheduled || !widgetWindow) return;
215
- pushScheduled = true;
216
- setTimeout(() => {
217
- pushScheduled = false;
218
- if (!widgetWindow) return;
219
- const bridge = getToolBridge();
220
- const toolsPromise = bridge ? Promise.resolve(bridge.listTools()) : Promise.resolve([]);
221
- toolsPromise
222
- .then((tools) => {
223
- if (!widgetWindow) return;
224
- widgetWindow.postMessage(
225
- {
226
- type: 'webmcp.tools.changed',
227
- tools: Array.isArray(tools) ? tools : [],
228
- },
229
- config.widgetOrigin
230
- );
231
- })
232
- .catch((err) => {
233
- debugWarn('Failed to push tool changes:', err);
234
- });
235
- }, 0);
236
- }
237
-
238
- /**
239
- * Attempts to subscribe to tool change events on the current APIs.
240
- * @returns {boolean} true if subscription succeeded
241
- */
242
- function trySubscribe() {
243
- const mc = navigator.modelContext;
244
- if (mc && typeof mc.addEventListener === 'function') {
245
- try {
246
- mc.addEventListener('toolschanged', onToolsChanged);
247
- return true;
248
- } catch (error) {
249
- debugWarn('addEventListener threw:', error);
250
- }
251
- }
252
- const testing = navigator.modelContextTesting;
253
- if (testing && typeof testing.registerToolsChangedCallback === 'function') {
254
- try {
255
- testing.registerToolsChangedCallback(onToolsChanged);
256
- return true;
257
- } catch (error) {
258
- debugWarn('Failed to subscribe via registerToolsChangedCallback:', error);
259
- }
260
- }
261
- return false;
262
- }
263
-
264
- /**
265
- * Subscribes to modelContext tool change events.
266
- * Tries addEventListener first (future native EventTarget support),
267
- * falls back to registerToolsChangedCallback on modelContextTesting.
268
- *
269
- * When embed.js loads as a classic script before the app's module entry
270
- * point, the WebMCP APIs may not exist yet. In that case we retry on a
271
- * short interval (up to ~5 s) to catch late initialization.
272
- */
273
- function subscribeToToolChanges() {
274
- if (trySubscribe()) {
275
- return;
276
- }
277
-
278
- let retries = 0;
279
- const MAX_RETRIES = 50;
280
- const RETRY_INTERVAL_MS = 100;
281
- const retryTimer = setInterval(() => {
282
- retries++;
283
- if (trySubscribe()) {
284
- clearInterval(retryTimer);
285
- return;
286
- }
287
- if (retries >= MAX_RETRIES) {
288
- clearInterval(retryTimer);
289
- debugWarn(
290
- 'Could not subscribe to tool changes after ' +
291
- MAX_RETRIES +
292
- ' retries. Dynamic tool updates will not be relayed.'
293
- );
294
- }
295
- }, RETRY_INTERVAL_MS);
296
- }
297
-
298
- /**
299
- * @param {MessageEventSource | null} source
300
- * @param {string} origin
301
- * @param {JsonObject} payload
302
- */
303
- function respondToSource(source, origin, payload) {
304
- if (!source || typeof source !== 'object' || !('postMessage' in source)) {
305
- return;
306
- }
307
-
308
- const { postMessage } = source;
309
- if (typeof postMessage !== 'function') {
310
- return;
311
- }
312
-
313
- postMessage.call(source, payload, origin);
314
- }
315
-
316
- /**
317
- * @param {unknown} value
318
- * @returns {WidgetRequestMessage | null}
319
- */
320
- function parseWidgetRequest(value) {
321
- if (
322
- !isJsonObject(value) ||
323
- typeof value.requestId !== 'string' ||
324
- typeof value.type !== 'string'
325
- ) {
326
- return null;
327
- }
328
-
329
- return {
330
- requestId: value.requestId,
331
- type: value.type,
332
- toolName: value.toolName,
333
- args: value.args,
334
- };
335
- }
336
-
337
- /**
338
- * @param {WidgetRequestMessage} request
339
- * @param {MessageEvent} event
340
- */
341
- function handleListRequest(request, event) {
342
- const bridge = getToolBridge();
343
- const toolsPromise = bridge ? Promise.resolve(bridge.listTools()) : Promise.resolve([]);
344
-
345
- toolsPromise
346
- .then((tools) => {
347
- respondToSource(event.source, event.origin, {
348
- type: 'webmcp.tools.list.response',
349
- requestId: request.requestId,
350
- tools: Array.isArray(tools) ? tools : [],
351
- });
352
- })
353
- .catch((error) => {
354
- debugWarn('Failed to list tools:', error);
355
- respondToSource(event.source, event.origin, {
356
- type: 'webmcp.tools.list.response',
357
- requestId: request.requestId,
358
- tools: [],
359
- error: `Failed to list tools: ${error instanceof Error ? error.message : String(error)}`,
360
- });
361
- });
362
- }
363
-
364
- /**
365
- * @param {WidgetRequestMessage} request
366
- * @param {MessageEvent} event
367
- */
368
- function handleInvokeRequest(request, event) {
369
- const bridge = getToolBridge();
370
- if (!bridge) {
371
- respondToSource(event.source, event.origin, {
372
- type: 'webmcp.tools.invoke.error',
373
- requestId: request.requestId,
374
- error: 'No WebMCP runtime found on this page',
375
- });
376
- return;
377
- }
378
-
379
- Promise.resolve(bridge.invoke(String(request.toolName ?? ''), toInvokeArgs(request.args)))
380
- .then((result) => {
381
- respondToSource(event.source, event.origin, {
382
- type: 'webmcp.tools.invoke.response',
383
- requestId: request.requestId,
384
- result: isJsonObject(result) ? result : {},
385
- });
386
- })
387
- .catch((error) => {
388
- respondToSource(event.source, event.origin, {
389
- type: 'webmcp.tools.invoke.error',
390
- requestId: request.requestId,
391
- error: String(error instanceof Error ? error.message : error),
392
- });
393
- });
394
- }
395
-
396
- /** @param {RelayConfig} config */
397
- function injectRelayWidget(config) {
398
- if (document.querySelector(RELAY_IFRAME_SELECTOR)) {
399
- return;
400
- }
401
-
402
- const searchParams = new URLSearchParams();
403
- searchParams.set('tabId', config.tabId);
404
- searchParams.set('hostOrigin', window.location.origin);
405
- const cleanUrl = new URL(window.location.href);
406
- cleanUrl.search = '';
407
- cleanUrl.hash = '';
408
- searchParams.set('hostUrl', cleanUrl.href);
409
- searchParams.set('hostTitle', document.title || '');
410
- searchParams.set('relayHost', config.relayHost);
411
- searchParams.set('relayPort', config.relayPort);
412
-
413
- const iframe = document.createElement('iframe');
414
- iframe.src = `${config.widgetUrl}?${searchParams.toString()}`;
415
- iframe.style.display = 'none';
416
- iframe.setAttribute('aria-hidden', 'true');
417
- iframe.setAttribute('data-webmcp-relay', '1');
418
- document.body.appendChild(iframe);
419
- widgetWindow = iframe.contentWindow;
420
- iframe.addEventListener('load', () => {
421
- widgetWindow = iframe.contentWindow;
422
- });
423
- iframe.addEventListener('error', () => {
424
- console.error(
425
- '[webmcp-relay-embed] Failed to load relay widget iframe from:',
426
- iframe.src,
427
- '-- WebMCP tools will NOT be relayed. Check network connectivity and widget URL.'
428
- );
429
- });
430
- }
431
-
432
- if (document.querySelector(RELAY_IFRAME_SELECTOR)) {
433
- return;
434
- }
435
-
436
- let config;
437
- try {
438
- config = buildRelayConfig(scriptEl);
439
- } catch (err) {
440
- console.error('[webmcp-relay-embed] Failed to initialize relay configuration:', err);
441
- return;
442
- }
443
-
444
- window.addEventListener('message', (event) => {
445
- if (event.origin !== config.widgetOrigin) {
446
- return;
447
- }
448
- if (!widgetWindow || event.source !== widgetWindow) {
449
- return;
450
- }
451
-
452
- const data = event.data;
453
- if (isJsonObject(data) && data.type === 'webmcp.reload') {
454
- window.location.reload();
455
- return;
456
- }
457
-
458
- const request = parseWidgetRequest(event.data);
459
- if (!request) {
460
- return;
461
- }
462
-
463
- if (request.type === 'webmcp.tools.list.request') {
464
- handleListRequest(request, event);
465
- return;
466
- }
467
-
468
- if (request.type === 'webmcp.tools.invoke.request') {
469
- handleInvokeRequest(request, event);
470
- }
471
- });
472
-
473
- if (document.body) {
474
- injectRelayWidget(config);
475
- } else {
476
- document.addEventListener('DOMContentLoaded', () => injectRelayWidget(config), { once: true });
477
- }
478
-
479
- subscribeToToolChanges();
480
- })();
1
+ (function(){function e(e){return!!e&&typeof e==`object`&&!Array.isArray(e)}let t=`[data-webmcp-relay]`,n=`__webmcp_relay_tab_id`,r=null,i;function a(){return document.currentScript instanceof HTMLScriptElement?document.currentScript:null}let o=a(),s=o?o.hasAttribute(`data-debug`):!1;function c(...e){s&&console.warn(`[webmcp-relay-embed]`,...e)}function l(){return typeof crypto<`u`&&typeof crypto.randomUUID==`function`?crypto.randomUUID():`${String(Date.now())}_${String(Math.random()).slice(2,10)}`}function u(){try{let e=sessionStorage.getItem(n);if(e)return e}catch(e){c(`sessionStorage read failed, tab ID will not persist:`,e)}let e=l();try{sessionStorage.setItem(n,e)}catch(e){c(`sessionStorage write failed:`,e)}return e}function d(e){if(e?.src)try{return new URL(`widget.html`,e.src).href}catch(e){c(`Failed to resolve widget URL from script src, falling back to CDN:`,e)}else c(`Script element has no src attribute, falling back to CDN widget URL.`);return`https://cdn.jsdelivr.net/npm/@mcp-b/webmcp-local-relay/dist/browser/widget.html`}function f(e){let t=d(e),n=e?.getAttribute(`data-relay-id`)||void 0,r=e?.getAttribute(`data-relay-workspace`)||void 0;return{autoConnect:e?.getAttribute(`data-auto-connect`)!==`false`,relayHost:e?.getAttribute(`data-relay-host`)||`127.0.0.1`,relayPort:e?.getAttribute(`data-relay-port`)||`9333`,...n?{relayId:n}:{},...r?{relayWorkspace:r}:{},tabId:u(),widgetUrl:t,widgetOrigin:new URL(t).origin}}function p(t){if(typeof t!=`string`||t.length===0)return{type:`object`,properties:{}};try{let n=JSON.parse(t);return e(n)?n:{type:`object`,properties:{}}}catch(e){return c(`Tool inputSchema is not valid JSON:`,typeof t==`string`?t.slice(0,200):t,e),{type:`object`,properties:{}}}}function m(t){return e(t)?t:(t!=null&&c(`Tool invocation args must be an object, got`,typeof t),{})}function h(e){return{name:e.name,description:e.description,inputSchema:e.inputSchema}}function g(e){return{name:e.name,description:e.description,inputSchema:p(e.inputSchema)}}function _(){let e=navigator.modelContext;if(e&&typeof e.listTools==`function`&&typeof e.callTool==`function`)return e}function v(){let t=_();if(t)return{listTools(){return t.listTools().map(h)},invoke(e,n){return t.callTool({name:e,arguments:n})}};let n=navigator.modelContextTesting;return n&&typeof n.listTools==`function`&&typeof n.executeTool==`function`?{listTools(){return n.listTools().map(g)},async invoke(t,r){let i=await n.executeTool(t,JSON.stringify(r));if(i===null)return{isError:!0,content:[{type:`text`,text:`Tool execution interrupted by navigation`}]};let a;try{a=JSON.parse(i)}catch{throw Error(`Testing tool returned invalid JSON: ${String(i).slice(0,200)}`)}if(!e(a))throw Error(`Testing tool response was not an object`);return a}}:(c(`No WebMCP runtime found (navigator.modelContext or navigator.modelContextTesting).`),null)}let y=!1;function b(){y||!r||(y=!0,setTimeout(()=>{if(y=!1,!r)return;let e=v();(e?Promise.resolve(e.listTools()):Promise.resolve([])).then(e=>{r&&r.postMessage({type:`webmcp.tools.changed`,tools:Array.isArray(e)?e:[]},i.widgetOrigin)}).catch(e=>{c(`Failed to push tool changes:`,e)})},0))}function x(){let e=_();if(e)try{return e.addEventListener(`toolschanged`,b),!0}catch(e){c(`addEventListener threw:`,e)}let t=navigator.modelContextTesting;if(t&&typeof t.registerToolsChangedCallback==`function`)try{return t.registerToolsChangedCallback(b),!0}catch(e){c(`Failed to subscribe via registerToolsChangedCallback:`,e)}return!1}function S(){if(x())return;let e=0,t=setInterval(()=>{if(e++,x()){clearInterval(t);return}e>=50&&(clearInterval(t),c(`Could not subscribe to tool changes after 50 retries. Dynamic tool updates will not be relayed.`))},100)}function C(e,t,n){!e||typeof e!=`object`||!(`postMessage`in e)||e.postMessage(n,t)}function w(t){return!e(t)||typeof t.requestId!=`string`||typeof t.type!=`string`?null:{requestId:t.requestId,type:t.type,toolName:t.toolName,args:t.args}}function T(e,t){let n=v();(n?Promise.resolve(n.listTools()):Promise.resolve([])).then(n=>{C(t.source,t.origin,{type:`webmcp.tools.list.response`,requestId:e.requestId,tools:Array.isArray(n)?n:[]})}).catch(n=>{c(`Failed to list tools:`,n),C(t.source,t.origin,{type:`webmcp.tools.list.response`,requestId:e.requestId,tools:[],error:`Failed to list tools: ${n instanceof Error?n.message:String(n)}`})})}function E(t,n){let r=v();if(!r){C(n.source,n.origin,{type:`webmcp.tools.invoke.error`,requestId:t.requestId,error:`No WebMCP runtime found on this page`});return}Promise.resolve(r.invoke(String(t.toolName??``),m(t.args))).then(r=>{C(n.source,n.origin,{type:`webmcp.tools.invoke.response`,requestId:t.requestId,result:e(r)?r:{}})}).catch(e=>{C(n.source,n.origin,{type:`webmcp.tools.invoke.error`,requestId:t.requestId,error:String(e instanceof Error?e.message:e)})})}function D(e){if(document.querySelector(t))return;let n=new URLSearchParams;n.set(`tabId`,e.tabId),n.set(`hostOrigin`,window.location.origin);let i=new URL(window.location.href);i.search=``,i.hash=``,n.set(`hostUrl`,i.href),n.set(`hostTitle`,document.title||``),n.set(`relayHost`,e.relayHost),n.set(`relayPort`,e.relayPort),n.set(`autoConnect`,e.autoConnect?`true`:`false`),e.relayId&&n.set(`relayId`,e.relayId),e.relayWorkspace&&n.set(`relayWorkspace`,e.relayWorkspace);let a=document.createElement(`iframe`);a.src=`${e.widgetUrl}?${n.toString()}`,a.style.display=`none`,a.setAttribute(`aria-hidden`,`true`),a.setAttribute(`data-webmcp-relay`,`1`),a.setAttribute(`allow`,`loopback-network; local-network; local-network-access`),document.body.appendChild(a),r=a.contentWindow,a.addEventListener(`load`,()=>{r=a.contentWindow}),a.addEventListener(`error`,()=>{console.error(`[webmcp-relay-embed] Failed to load relay widget iframe from:`,a.src,`-- WebMCP tools will NOT be relayed. Check network connectivity and widget URL.`)})}if(!document.querySelector(t)){try{i=f(o)}catch(e){throw console.error(`[webmcp-relay-embed] Failed to initialize relay configuration:`,e),e}window.addEventListener(`message`,t=>{if(t.origin!==i.widgetOrigin||!r||t.source!==r)return;let n=t.data;if(e(n)&&n.type===`webmcp.reload`){window.location.reload();return}let a=w(t.data);if(a){if(a.type===`webmcp.tools.list.request`){T(a,t);return}a.type===`webmcp.tools.invoke.request`&&E(a,t)}}),document.body?D(i):document.addEventListener(`DOMContentLoaded`,()=>D(i),{once:!0}),S()}})();