@opentabs-dev/mcp-server 0.0.22 → 0.0.25

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.
Files changed (94) hide show
  1. package/dist/adapter-files.d.ts +53 -0
  2. package/dist/adapter-files.d.ts.map +1 -0
  3. package/dist/adapter-files.js +176 -0
  4. package/dist/adapter-files.js.map +1 -0
  5. package/dist/audit-disk.d.ts.map +1 -1
  6. package/dist/audit-disk.js +5 -6
  7. package/dist/audit-disk.js.map +1 -1
  8. package/dist/browser-tool-names.d.ts +14 -0
  9. package/dist/browser-tool-names.d.ts.map +1 -0
  10. package/dist/browser-tool-names.js +52 -0
  11. package/dist/browser-tool-names.js.map +1 -0
  12. package/dist/browser-tools/analyze-site/detect-auth.d.ts.map +1 -1
  13. package/dist/browser-tools/analyze-site/detect-auth.js +2 -1
  14. package/dist/browser-tools/analyze-site/detect-auth.js.map +1 -1
  15. package/dist/browser-tools/analyze-site/index.d.ts.map +1 -1
  16. package/dist/browser-tools/analyze-site/index.js +111 -15
  17. package/dist/browser-tools/analyze-site/index.js.map +1 -1
  18. package/dist/browser-tools/execute-script.js +1 -1
  19. package/dist/browser-tools/execute-script.js.map +1 -1
  20. package/dist/browser-tools/extension-get-logs.d.ts +2 -2
  21. package/dist/browser-tools/focus-tab.js +1 -1
  22. package/dist/browser-tools/focus-tab.js.map +1 -1
  23. package/dist/browser-tools/get-console-logs.d.ts +2 -2
  24. package/dist/browser-tools/index.d.ts +4 -0
  25. package/dist/browser-tools/index.d.ts.map +1 -1
  26. package/dist/browser-tools/index.js +19 -0
  27. package/dist/browser-tools/index.js.map +1 -1
  28. package/dist/browser-tools/select-option.d.ts.map +1 -1
  29. package/dist/browser-tools/select-option.js +11 -6
  30. package/dist/browser-tools/select-option.js.map +1 -1
  31. package/dist/config.d.ts +13 -16
  32. package/dist/config.d.ts.map +1 -1
  33. package/dist/config.js +43 -109
  34. package/dist/config.js.map +1 -1
  35. package/dist/discovery.d.ts.map +1 -1
  36. package/dist/discovery.js +9 -12
  37. package/dist/discovery.js.map +1 -1
  38. package/dist/extension-handlers.d.ts +102 -0
  39. package/dist/extension-handlers.d.ts.map +1 -0
  40. package/dist/extension-handlers.js +443 -0
  41. package/dist/extension-handlers.js.map +1 -0
  42. package/dist/extension-protocol.d.ts +6 -38
  43. package/dist/extension-protocol.d.ts.map +1 -1
  44. package/dist/extension-protocol.js +47 -535
  45. package/dist/extension-protocol.js.map +1 -1
  46. package/dist/file-watcher.d.ts.map +1 -1
  47. package/dist/file-watcher.js +106 -104
  48. package/dist/file-watcher.js.map +1 -1
  49. package/dist/http-routes.d.ts +9 -7
  50. package/dist/http-routes.d.ts.map +1 -1
  51. package/dist/http-routes.js +275 -265
  52. package/dist/http-routes.js.map +1 -1
  53. package/dist/index.js +17 -10
  54. package/dist/index.js.map +1 -1
  55. package/dist/loader.d.ts +14 -3
  56. package/dist/loader.d.ts.map +1 -1
  57. package/dist/loader.js +42 -22
  58. package/dist/loader.js.map +1 -1
  59. package/dist/log-buffer.d.ts.map +1 -1
  60. package/dist/log-buffer.js +17 -6
  61. package/dist/log-buffer.js.map +1 -1
  62. package/dist/logger.d.ts.map +1 -1
  63. package/dist/logger.js +5 -2
  64. package/dist/logger.js.map +1 -1
  65. package/dist/mcp-setup.d.ts +3 -18
  66. package/dist/mcp-setup.d.ts.map +1 -1
  67. package/dist/mcp-setup.js +13 -407
  68. package/dist/mcp-setup.js.map +1 -1
  69. package/dist/mcp-tool-dispatch.d.ts +51 -0
  70. package/dist/mcp-tool-dispatch.d.ts.map +1 -0
  71. package/dist/mcp-tool-dispatch.js +410 -0
  72. package/dist/mcp-tool-dispatch.js.map +1 -0
  73. package/dist/plugin-management.d.ts +123 -0
  74. package/dist/plugin-management.d.ts.map +1 -0
  75. package/dist/plugin-management.js +340 -0
  76. package/dist/plugin-management.js.map +1 -0
  77. package/dist/registry.d.ts +2 -13
  78. package/dist/registry.d.ts.map +1 -1
  79. package/dist/registry.js +2 -24
  80. package/dist/registry.js.map +1 -1
  81. package/dist/reload.d.ts.map +1 -1
  82. package/dist/reload.js +13 -2
  83. package/dist/reload.js.map +1 -1
  84. package/dist/resolver.d.ts.map +1 -1
  85. package/dist/resolver.js +33 -25
  86. package/dist/resolver.js.map +1 -1
  87. package/dist/state.d.ts +30 -21
  88. package/dist/state.d.ts.map +1 -1
  89. package/dist/state.js +14 -10
  90. package/dist/state.js.map +1 -1
  91. package/dist/version-check.d.ts.map +1 -1
  92. package/dist/version-check.js +36 -14
  93. package/dist/version-check.js.map +1 -1
  94. package/package.json +2 -2
@@ -1,131 +1,16 @@
1
1
  /**
2
2
  * Extension WebSocket protocol handler.
3
- * Handles JSON-RPC messages between the MCP server and Chrome extension.
3
+ * Handles JSON-RPC dispatch mechanics and message routing between the MCP
4
+ * server and Chrome extension. Individual message handlers live in
5
+ * extension-handlers.ts.
4
6
  */
5
- import { getAdaptersDir } from './config.js';
6
- import { appendLog } from './log-buffer.js';
7
+ import { ensureAdaptersDir, writeAdapterFile, cleanupStaleAdapterFiles, writeExecFile, deleteExecFile, cleanupStaleExecFiles, timeoutRace, ADAPTER_WRITE_TIMEOUT_MS, } from './adapter-files.js';
8
+ import { sendToExtension, sendJsonRpcError, serializePluginForExtension, handleTabSyncAll, handleTabStateChanged, handleConfigGetState, handleConfigSetToolEnabled, handleConfigSetAllToolsEnabled, handlePluginSearch, handlePluginInstall, handlePluginUpdateFromRegistry, handlePluginRemove, handlePluginCheckUpdates, handleToolProgress, handlePluginLog, handleConfirmationResponse, rejectAllPendingConfirmations, } from './extension-handlers.js';
7
9
  import { log } from './logger.js';
8
- import { prefixedToolName, isToolEnabled, getNextRequestId, DISPATCH_TIMEOUT_MS, MAX_DISPATCH_TIMEOUT_MS, CONFIRMATION_TIMEOUT_MS, } from './state.js';
9
- import { mkdir, readdir, rename } from 'node:fs/promises';
10
- import { join } from 'node:path';
11
- /**
12
- * Ensure the adapters directory exists, creating it if necessary.
13
- * Caches the result so mkdir is called at most once per module evaluation
14
- * (resets on bun --hot reload, which is correct since the config dir could change).
15
- */
16
- let adaptersDirReady = false;
17
- const ensureAdaptersDir = async () => {
18
- if (adaptersDirReady)
19
- return;
20
- await mkdir(getAdaptersDir(), { recursive: true });
21
- adaptersDirReady = true;
22
- };
10
+ import { getNextRequestId, DISPATCH_TIMEOUT_MS, CONFIRMATION_TIMEOUT_MS } from './state.js';
11
+ import { toErrorMessage } from '@opentabs-dev/shared';
23
12
  /** Maximum incoming WebSocket message size (10MB) */
24
13
  const MAX_MESSAGE_SIZE = 10 * 1024 * 1024;
25
- /** Prefix for dynamically generated exec script files */
26
- const EXEC_FILE_PREFIX = '__exec-';
27
- /**
28
- * Send a JSON-serialized message to the extension WebSocket if connected.
29
- * Centralizes the null check on state.extensionWs so callers don't repeat it.
30
- * Returns true if the message was sent, false if the extension is not connected.
31
- */
32
- const sendToExtension = (state, msg) => {
33
- if (!state.extensionWs)
34
- return false;
35
- try {
36
- state.extensionWs.send(JSON.stringify(msg));
37
- return true;
38
- }
39
- catch (err) {
40
- log.warn('Failed to send to extension:', err);
41
- return false;
42
- }
43
- };
44
- /**
45
- * Write a plugin's adapter IIFE to the extension's adapters/ directory.
46
- * The extension injects adapters via chrome.scripting.executeScript({ files: [...] })
47
- * using these files, bypassing page CSP restrictions.
48
- *
49
- * If a source map is provided, writes it alongside the adapter as {pluginName}.js.map
50
- * and rewrites the sourceMappingURL in the IIFE to reference the per-plugin filename
51
- * (prevents collisions when multiple plugins are loaded).
52
- */
53
- const writeAdapterFile = async (pluginName, iife, sourceMap) => {
54
- const adaptersDir = getAdaptersDir();
55
- const finalPath = join(adaptersDir, `${pluginName}.js`);
56
- const tmpPath = join(adaptersDir, `${pluginName}.js.tmp`);
57
- let content = iife;
58
- if (sourceMap) {
59
- // Rewrite sourceMappingURL to use the per-plugin filename
60
- content = iife.replace(/\/\/# sourceMappingURL=adapter\.iife\.js\.map/, `//# sourceMappingURL=${pluginName}.js.map`);
61
- // Write source map atomically
62
- const mapFinalPath = join(adaptersDir, `${pluginName}.js.map`);
63
- const mapTmpPath = join(adaptersDir, `${pluginName}.js.map.tmp`);
64
- await Bun.write(mapTmpPath, sourceMap);
65
- await rename(mapTmpPath, mapFinalPath);
66
- }
67
- await Bun.write(tmpPath, content);
68
- await rename(tmpPath, finalPath);
69
- };
70
- /**
71
- * Remove stale adapter .js files from the adapters directory that do not
72
- * correspond to any plugin in the current set. Called from sendSyncFull
73
- * before writing new adapter files.
74
- */
75
- const cleanupStaleAdapterFiles = async (currentPluginNames) => {
76
- const adaptersDir = getAdaptersDir();
77
- let entries;
78
- try {
79
- entries = await readdir(adaptersDir);
80
- }
81
- catch {
82
- // Directory may not exist yet on first run
83
- return;
84
- }
85
- const staleFiles = entries.filter(f => {
86
- if (f.endsWith('.js.tmp') || f.endsWith('.js.map.tmp'))
87
- return false;
88
- if (f.startsWith(EXEC_FILE_PREFIX))
89
- return false; // Managed by cleanupStaleExecFiles
90
- // Match adapter .js files
91
- if (f.endsWith('.js')) {
92
- const pluginName = f.slice(0, -3); // strip '.js'
93
- return !currentPluginNames.has(pluginName);
94
- }
95
- // Match adapter .js.map source map files
96
- if (f.endsWith('.js.map')) {
97
- const pluginName = f.slice(0, -7); // strip '.js.map'
98
- return !currentPluginNames.has(pluginName);
99
- }
100
- return false;
101
- });
102
- if (staleFiles.length === 0)
103
- return;
104
- const results = await Promise.allSettled(staleFiles.map(f => Bun.file(join(adaptersDir, f)).delete()));
105
- let deleted = 0;
106
- for (const [i, result] of results.entries()) {
107
- if (result.status === 'rejected') {
108
- const fileName = staleFiles[i] ?? 'unknown';
109
- log.warn(`Failed to delete stale adapter file ${fileName}:`, result.reason);
110
- }
111
- else {
112
- deleted++;
113
- }
114
- }
115
- log.info(`Cleaned up ${deleted} stale adapter file(s)`);
116
- };
117
- /** Timeout for batch adapter file writes in sendSyncFull (10 seconds) */
118
- const ADAPTER_WRITE_TIMEOUT_MS = 10_000;
119
- /** Create a cancellable timeout promise for use with Promise.race */
120
- const timeoutRace = (value, ms) => {
121
- let timerId;
122
- const promise = new Promise(resolve => {
123
- timerId = setTimeout(() => resolve(value), ms);
124
- });
125
- // timerId is assigned synchronously by the Promise executor
126
- const cancel = () => clearTimeout(timerId);
127
- return { promise, cancel };
128
- };
129
14
  /**
130
15
  * Send sync.full notification to extension on connect.
131
16
  * Writes all plugin adapter IIFEs to the extension's adapters/ directory,
@@ -136,7 +21,7 @@ const sendSyncFull = async (state) => {
136
21
  // Uses allSettled so a single plugin's write failure doesn't block the sync notification.
137
22
  // Races against a timeout so stalled writes don't hang hot reload indefinitely.
138
23
  const pluginList = Array.from(state.registry.plugins.values());
139
- await ensureAdaptersDir();
24
+ await ensureAdaptersDir(state);
140
25
  // Remove stale adapter files from plugins that are no longer in the current set
141
26
  const currentPluginNames = new Set(pluginList.map(p => p.name));
142
27
  await cleanupStaleAdapterFiles(currentPluginNames);
@@ -156,24 +41,9 @@ const sendSyncFull = async (state) => {
156
41
  }
157
42
  }
158
43
  const plugins = pluginList.map(p => ({
159
- name: p.name,
160
- version: p.version,
161
- displayName: p.displayName,
162
- urlPatterns: p.urlPatterns,
163
- trustTier: p.trustTier,
44
+ ...serializePluginForExtension(p, state),
164
45
  sourcePath: p.sourcePath,
165
46
  adapterHash: p.adapterHash,
166
- ...(p.iconSvg ? { iconSvg: p.iconSvg } : {}),
167
- ...(p.iconInactiveSvg ? { iconInactiveSvg: p.iconInactiveSvg } : {}),
168
- tools: p.tools.map(t => ({
169
- name: t.name,
170
- displayName: t.displayName,
171
- description: t.description,
172
- icon: t.icon,
173
- ...(t.iconSvg ? { iconSvg: t.iconSvg } : {}),
174
- ...(t.iconInactiveSvg ? { iconInactiveSvg: t.iconInactiveSvg } : {}),
175
- enabled: isToolEnabled(state, prefixedToolName(p.name, t.name)),
176
- })),
177
47
  }));
178
48
  const sent = sendToExtension(state, {
179
49
  jsonrpc: '2.0',
@@ -227,7 +97,7 @@ const dispatchToExtension = (state, method, params, options) => {
227
97
  catch (err) {
228
98
  clearTimeout(timerId);
229
99
  state.pendingDispatches.delete(id);
230
- reject(new Error(`WebSocket send failed: ${err instanceof Error ? err.message : String(err)}`));
100
+ reject(new Error(`WebSocket send failed: ${toErrorMessage(err)}`));
231
101
  }
232
102
  });
233
103
  };
@@ -296,51 +166,6 @@ const sendConfirmationRequest = (state, tool, domain, tabId, paramsPreview) => {
296
166
  }
297
167
  });
298
168
  };
299
- /**
300
- * Handle a confirmation.response from the extension.
301
- * Resolves the pending confirmation promise with the user's decision.
302
- * For 'allow_always', also adds a session permission rule.
303
- */
304
- const handleConfirmationResponse = (state, params) => {
305
- if (!params)
306
- return;
307
- const id = params.id;
308
- if (typeof id !== 'string')
309
- return;
310
- const decision = params.decision;
311
- if (decision !== 'allow_once' && decision !== 'allow_always' && decision !== 'deny')
312
- return;
313
- const pending = state.pendingConfirmations.get(id);
314
- if (!pending)
315
- return;
316
- clearTimeout(pending.timerId);
317
- state.pendingConfirmations.delete(id);
318
- // For allow_always, add a session permission rule based on the scope
319
- if (decision === 'allow_always') {
320
- const scope = typeof params.scope === 'string' ? params.scope : 'tool_domain';
321
- const rule = { tool: pending.tool, domain: pending.domain, scope };
322
- // Adjust rule fields based on scope
323
- if (scope === 'tool_all') {
324
- rule.domain = null;
325
- }
326
- else if (scope === 'domain_all') {
327
- rule.tool = null;
328
- }
329
- state.sessionPermissions.push(rule);
330
- }
331
- pending.resolve(decision);
332
- };
333
- /**
334
- * Reject all pending confirmations. Called when the extension disconnects
335
- * to clean up any confirmation promises that can no longer be fulfilled.
336
- */
337
- const rejectAllPendingConfirmations = (state) => {
338
- for (const [id, pending] of state.pendingConfirmations) {
339
- clearTimeout(pending.timerId);
340
- pending.reject(new Error('Extension disconnected — confirmation cancelled'));
341
- state.pendingConfirmations.delete(id);
342
- }
343
- };
344
169
  /**
345
170
  * Send plugin.update notification to extension with updated plugin metadata.
346
171
  * Writes the adapter IIFE to the extension's adapters/ directory first,
@@ -356,30 +181,15 @@ const sendPluginUpdate = async (state, pluginName, iife, sourceMap) => {
356
181
  const plugin = state.registry.plugins.get(pluginName);
357
182
  if (!plugin)
358
183
  return;
359
- await ensureAdaptersDir();
184
+ await ensureAdaptersDir(state);
360
185
  await writeAdapterFile(pluginName, iife, sourceMap);
361
186
  const sent = sendToExtension(state, {
362
187
  jsonrpc: '2.0',
363
188
  method: 'plugin.update',
364
189
  params: {
365
- name: plugin.name,
366
- version: plugin.version,
367
- displayName: plugin.displayName,
368
- urlPatterns: plugin.urlPatterns,
369
- trustTier: plugin.trustTier,
190
+ ...serializePluginForExtension(plugin, state),
370
191
  sourcePath: plugin.sourcePath,
371
192
  adapterHash: plugin.adapterHash,
372
- ...(plugin.iconSvg ? { iconSvg: plugin.iconSvg } : {}),
373
- ...(plugin.iconInactiveSvg ? { iconInactiveSvg: plugin.iconInactiveSvg } : {}),
374
- tools: plugin.tools.map(t => ({
375
- name: t.name,
376
- displayName: t.displayName,
377
- description: t.description,
378
- icon: t.icon,
379
- ...(t.iconSvg ? { iconSvg: t.iconSvg } : {}),
380
- ...(t.iconInactiveSvg ? { iconInactiveSvg: t.iconInactiveSvg } : {}),
381
- enabled: isToolEnabled(state, prefixedToolName(plugin.name, t.name)),
382
- })),
383
193
  },
384
194
  });
385
195
  if (!sent)
@@ -426,8 +236,19 @@ const handleExtensionMessage = (state, text, callbacks, senderWs) => {
426
236
  clearTimeout(pending.timerId);
427
237
  log.debug('dispatch ← extension:', pending.label, 'id:', id, 'in', `${Date.now() - pending.startTs}ms`);
428
238
  if ('error' in parsed) {
429
- const err = parsed.error;
430
- const error = new DispatchError(err.message, err.code, err.data);
239
+ const rawErr = parsed.error;
240
+ const errObj = rawErr !== null && typeof rawErr === 'object' && !Array.isArray(rawErr)
241
+ ? rawErr
242
+ : {};
243
+ const errCode = typeof errObj.code === 'number' ? errObj.code : -32603;
244
+ const errMsg = typeof errObj.message === 'string' ? errObj.message : 'Unknown extension error';
245
+ const errData = errObj.data !== null &&
246
+ errObj.data !== undefined &&
247
+ typeof errObj.data === 'object' &&
248
+ !Array.isArray(errObj.data)
249
+ ? errObj.data
250
+ : undefined;
251
+ const error = new DispatchError(errMsg, errCode, errData);
431
252
  pending.reject(error);
432
253
  }
433
254
  else {
@@ -443,7 +264,7 @@ const handleExtensionMessage = (state, text, callbacks, senderWs) => {
443
264
  return;
444
265
  }
445
266
  const params = rawParams;
446
- // Handle notifications/requests from extension
267
+ // Route to individual handlers in extension-handlers.ts
447
268
  if (method === 'tab.syncAll') {
448
269
  handleTabSyncAll(state, params);
449
270
  return;
@@ -452,7 +273,6 @@ const handleExtensionMessage = (state, text, callbacks, senderWs) => {
452
273
  handleTabStateChanged(state, params, id);
453
274
  return;
454
275
  }
455
- // Handle config operations (requests with id from side panel, relayed through extension)
456
276
  if (method === 'config.getState' && id !== undefined) {
457
277
  handleConfigGetState(state, id);
458
278
  return;
@@ -465,6 +285,26 @@ const handleExtensionMessage = (state, text, callbacks, senderWs) => {
465
285
  handleConfigSetAllToolsEnabled(state, params, id, callbacks);
466
286
  return;
467
287
  }
288
+ if (method === 'plugin.search' && id !== undefined) {
289
+ void handlePluginSearch(state, params, id);
290
+ return;
291
+ }
292
+ if (method === 'plugin.install' && id !== undefined) {
293
+ void handlePluginInstall(state, params, id, callbacks);
294
+ return;
295
+ }
296
+ if (method === 'plugin.updateFromRegistry' && id !== undefined) {
297
+ void handlePluginUpdateFromRegistry(state, params, id, callbacks);
298
+ return;
299
+ }
300
+ if (method === 'plugin.remove' && id !== undefined) {
301
+ void handlePluginRemove(state, params, id, callbacks);
302
+ return;
303
+ }
304
+ if (method === 'plugin.checkUpdates' && id !== undefined) {
305
+ void handlePluginCheckUpdates(state, id);
306
+ return;
307
+ }
468
308
  if (method === 'tool.progress') {
469
309
  handleToolProgress(state, params);
470
310
  return;
@@ -479,11 +319,7 @@ const handleExtensionMessage = (state, text, callbacks, senderWs) => {
479
319
  }
480
320
  // Unrecognized method with an id — send JSON-RPC -32601 'Method not found'
481
321
  if (id !== undefined && method) {
482
- sendToExtension(state, {
483
- jsonrpc: '2.0',
484
- error: { code: -32601, message: `Method not found: ${method}` },
485
- id,
486
- });
322
+ sendJsonRpcError(state, id, -32601, `Method not found: ${method}`);
487
323
  return;
488
324
  }
489
325
  // Unrecognized notification or malformed message
@@ -528,330 +364,6 @@ const isDispatchError = (err) => err !== null &&
528
364
  'code' in err &&
529
365
  'name' in err &&
530
366
  err.name === 'DispatchError';
531
- const parseTabMapping = (wire) => ({
532
- state: wire.state === 'closed' || wire.state === 'unavailable' || wire.state === 'ready' ? wire.state : 'closed',
533
- tabId: typeof wire.tabId === 'number' ? wire.tabId : null,
534
- url: typeof wire.url === 'string' ? wire.url : null,
535
- });
536
- const handleTabSyncAll = (state, params) => {
537
- if (!params)
538
- return;
539
- const tabSyncParams = params;
540
- const tabs = tabSyncParams.tabs;
541
- if (!tabs)
542
- return;
543
- state.tabMapping.clear();
544
- for (const [pluginName, mapping] of Object.entries(tabs)) {
545
- state.tabMapping.set(pluginName, parseTabMapping(mapping));
546
- }
547
- log.info(`tab.syncAll received — ${state.tabMapping.size} plugin(s) mapped`);
548
- };
549
- const handleTabStateChanged = (state, params, id) => {
550
- const sendError = (message) => {
551
- if (id !== undefined) {
552
- sendToExtension(state, { jsonrpc: '2.0', error: { code: -32602, message }, id });
553
- }
554
- else {
555
- log.warn(`tab.stateChanged: ${message}`);
556
- }
557
- };
558
- if (!params) {
559
- sendError('Missing params');
560
- return;
561
- }
562
- const plugin = params.plugin;
563
- if (typeof plugin !== 'string' || plugin.length === 0) {
564
- sendError('Missing or invalid "plugin" field (expected non-empty string)');
565
- return;
566
- }
567
- if (!state.registry.plugins.has(plugin)) {
568
- sendError(`Unknown plugin: ${plugin}`);
569
- return;
570
- }
571
- if (typeof params.state !== 'string') {
572
- sendError('Missing or invalid "state" field (expected string)');
573
- return;
574
- }
575
- if (params.state !== 'closed' && params.state !== 'unavailable' && params.state !== 'ready') {
576
- sendError(`Invalid tab state: ${params.state} (expected closed, unavailable, or ready)`);
577
- return;
578
- }
579
- const wire = {
580
- state: params.state,
581
- tabId: typeof params.tabId === 'number' ? params.tabId : null,
582
- url: typeof params.url === 'string' ? params.url : null,
583
- };
584
- state.tabMapping.set(plugin, parseTabMapping(wire));
585
- log.info(`tab.stateChanged: ${plugin} → ${wire.state ?? 'unknown'}`);
586
- };
587
- const handleConfigGetState = (state, id) => {
588
- const plugins = Array.from(state.registry.plugins.values())
589
- .sort((a, b) => a.name.localeCompare(b.name))
590
- .map(p => {
591
- const tabInfo = state.tabMapping.get(p.name);
592
- return {
593
- name: p.name,
594
- displayName: p.displayName,
595
- version: p.version,
596
- trustTier: p.trustTier,
597
- source: p.source,
598
- tabState: tabInfo?.state ?? 'closed',
599
- urlPatterns: p.urlPatterns,
600
- ...(p.sdkVersion ? { sdkVersion: p.sdkVersion } : {}),
601
- ...(p.iconSvg ? { iconSvg: p.iconSvg } : {}),
602
- ...(p.iconInactiveSvg ? { iconInactiveSvg: p.iconInactiveSvg } : {}),
603
- tools: p.tools.map(t => ({
604
- name: t.name,
605
- displayName: t.displayName,
606
- description: t.description,
607
- icon: t.icon,
608
- ...(t.iconSvg ? { iconSvg: t.iconSvg } : {}),
609
- ...(t.iconInactiveSvg ? { iconInactiveSvg: t.iconInactiveSvg } : {}),
610
- enabled: isToolEnabled(state, prefixedToolName(p.name, t.name)),
611
- })),
612
- };
613
- });
614
- sendToExtension(state, {
615
- jsonrpc: '2.0',
616
- result: {
617
- plugins,
618
- failedPlugins: state.discoveryErrors.map(e => ({ specifier: e.specifier, error: e.error })),
619
- outdatedPlugins: state.outdatedPlugins,
620
- },
621
- id,
622
- });
623
- };
624
- const handleConfigSetToolEnabled = (state, params, id, callbacks) => {
625
- if (!params) {
626
- sendToExtension(state, { jsonrpc: '2.0', error: { code: -32602, message: 'Missing params' }, id });
627
- return;
628
- }
629
- const toolEnabledParams = params;
630
- const pluginName = toolEnabledParams.plugin;
631
- const tool = toolEnabledParams.tool;
632
- const enabled = toolEnabledParams.enabled;
633
- if (typeof pluginName !== 'string' || typeof tool !== 'string' || typeof enabled !== 'boolean') {
634
- sendToExtension(state, {
635
- jsonrpc: '2.0',
636
- error: { code: -32602, message: 'Invalid params: expected plugin (string), tool (string), enabled (boolean)' },
637
- id,
638
- });
639
- return;
640
- }
641
- const plugin = state.registry.plugins.get(pluginName);
642
- if (!plugin) {
643
- sendToExtension(state, {
644
- jsonrpc: '2.0',
645
- error: { code: -32602, message: `Plugin not found: ${pluginName}` },
646
- id,
647
- });
648
- return;
649
- }
650
- if (!plugin.tools.some(t => t.name === tool)) {
651
- sendToExtension(state, {
652
- jsonrpc: '2.0',
653
- error: { code: -32602, message: `Tool not found: ${tool} in plugin ${pluginName}` },
654
- id,
655
- });
656
- return;
657
- }
658
- const prefixed = prefixedToolName(pluginName, tool);
659
- state.toolConfig[prefixed] = enabled;
660
- callbacks.onToolConfigChanged();
661
- callbacks.onToolConfigPersist();
662
- sendToExtension(state, {
663
- jsonrpc: '2.0',
664
- result: { ok: true },
665
- id,
666
- });
667
- };
668
- const handleConfigSetAllToolsEnabled = (state, params, id, callbacks) => {
669
- if (!params) {
670
- sendToExtension(state, { jsonrpc: '2.0', error: { code: -32602, message: 'Missing params' }, id });
671
- return;
672
- }
673
- const allToolsEnabledParams = params;
674
- const pluginName = allToolsEnabledParams.plugin;
675
- const enabled = allToolsEnabledParams.enabled;
676
- if (typeof pluginName !== 'string' || typeof enabled !== 'boolean') {
677
- sendToExtension(state, {
678
- jsonrpc: '2.0',
679
- error: { code: -32602, message: 'Invalid params: expected plugin (string), enabled (boolean)' },
680
- id,
681
- });
682
- return;
683
- }
684
- const plugin = state.registry.plugins.get(pluginName);
685
- if (!plugin) {
686
- sendToExtension(state, {
687
- jsonrpc: '2.0',
688
- error: { code: -32602, message: `Plugin not found: ${pluginName}` },
689
- id,
690
- });
691
- return;
692
- }
693
- for (const tool of plugin.tools) {
694
- const prefixed = prefixedToolName(pluginName, tool.name);
695
- state.toolConfig[prefixed] = enabled;
696
- }
697
- callbacks.onToolConfigChanged();
698
- callbacks.onToolConfigPersist();
699
- sendToExtension(state, {
700
- jsonrpc: '2.0',
701
- result: { ok: true },
702
- id,
703
- });
704
- };
705
- /**
706
- * Handle tool.progress notification from the extension.
707
- * Looks up the pending dispatch by dispatchId, invokes the onProgress callback
708
- * to emit an MCP ProgressNotification to the client, and resets the dispatch
709
- * timeout timer (the tool is alive). The timer reset is bounded by
710
- * MAX_DISPATCH_TIMEOUT_MS — if the dispatch has been running longer than the
711
- * absolute maximum, it is rejected immediately regardless of progress.
712
- */
713
- const handleToolProgress = (state, params) => {
714
- if (!params)
715
- return;
716
- const dispatchId = params.dispatchId;
717
- if (typeof dispatchId !== 'string')
718
- return;
719
- const progress = params.progress;
720
- const total = params.total;
721
- if (typeof progress !== 'number' || typeof total !== 'number')
722
- return;
723
- const message = typeof params.message === 'string' ? params.message : undefined;
724
- const pending = state.pendingDispatches.get(dispatchId);
725
- if (!pending)
726
- return;
727
- pending.lastProgressTs = Date.now();
728
- // Forward the progress notification to the MCP client
729
- if (pending.onProgress) {
730
- try {
731
- pending.onProgress(progress, total, message);
732
- }
733
- catch {
734
- // Fire-and-forget — errors in the progress chain must not affect tool execution
735
- }
736
- }
737
- // Reset the dispatch timeout — the tool is alive and making progress.
738
- // Bounded by MAX_DISPATCH_TIMEOUT_MS from the dispatch start time.
739
- clearTimeout(pending.timerId);
740
- const elapsed = Date.now() - pending.startTs;
741
- const remainingMax = MAX_DISPATCH_TIMEOUT_MS - elapsed;
742
- if (remainingMax <= 0) {
743
- // Absolute max exceeded — reject immediately
744
- state.pendingDispatches.delete(dispatchId);
745
- pending.reject(new Error(`Dispatch ${pending.label} exceeded absolute max timeout of ${MAX_DISPATCH_TIMEOUT_MS}ms`));
746
- return;
747
- }
748
- const nextTimeout = Math.min(DISPATCH_TIMEOUT_MS, remainingMax);
749
- pending.timerId = setTimeout(() => {
750
- if (state.pendingDispatches.has(dispatchId)) {
751
- state.pendingDispatches.delete(dispatchId);
752
- pending.reject(new Error(`Dispatch ${pending.label} timed out after ${DISPATCH_TIMEOUT_MS}ms`));
753
- }
754
- }, nextTimeout);
755
- };
756
- /** Valid plugin log levels (matches MCP LoggingLevel subset used by the SDK) */
757
- const VALID_LOG_LEVELS = new Set(['debug', 'info', 'warning', 'error']);
758
- const handlePluginLog = (params, callbacks) => {
759
- if (!params)
760
- return;
761
- const plugin = params.plugin;
762
- const level = params.level;
763
- const message = params.message;
764
- if (typeof plugin !== 'string' || plugin.length === 0)
765
- return;
766
- if (typeof level !== 'string' || !VALID_LOG_LEVELS.has(level))
767
- return;
768
- if (typeof message !== 'string')
769
- return;
770
- const ts = typeof params.ts === 'string' ? params.ts : new Date().toISOString();
771
- const entry = {
772
- level,
773
- plugin,
774
- message,
775
- data: params.data,
776
- ts,
777
- };
778
- appendLog(plugin, entry);
779
- callbacks.onPluginLog(entry);
780
- };
781
- // ---------------------------------------------------------------------------
782
- // Dynamic exec file helpers — write/delete/cleanup for browser_execute_script
783
- // ---------------------------------------------------------------------------
784
- /**
785
- * Write a dynamic exec script to the adapters/ directory.
786
- * Wraps the user's code in an IIFE that captures the result (sync or async)
787
- * into globalThis.__openTabs.__lastExecResult for the extension to read back.
788
- *
789
- * Returns the filename (relative to adapters/) for the extension to inject.
790
- */
791
- const writeExecFile = async (execId, code) => {
792
- await ensureAdaptersDir();
793
- const filename = `${EXEC_FILE_PREFIX}${execId}.js`;
794
- const adaptersDir = getAdaptersDir();
795
- const finalPath = join(adaptersDir, filename);
796
- const tmpPath = join(adaptersDir, `${filename}.tmp`);
797
- // Wrap user code to capture sync/async results and errors.
798
- // The wrapper stores results at globalThis.__openTabs.__lastExecResult.
799
- // The extension reads this value after injection and cleans it up.
800
- //
801
- // User code is passed as a JSON-escaped string literal to new Function(),
802
- // preventing IIFE wrapper breakout attacks. The Function constructor
803
- // parses the code in its own context — closing braces/parens in user
804
- // code cannot break the wrapper syntax.
805
- const wrapped = [
806
- '(function() {',
807
- ' var __ot = globalThis.__openTabs = globalThis.__openTabs || {};',
808
- ' try {',
809
- ` var __userFn = new Function(${JSON.stringify(code)});`,
810
- ' var __r = __userFn();',
811
- ' if (__r && typeof __r === "object" && typeof __r.then === "function") {',
812
- ' __ot.__lastExecAsync = true;',
813
- ' __r.then(function(v) { __ot.__lastExecResult = { value: v }; })',
814
- ' .catch(function(e) { __ot.__lastExecResult = { error: e instanceof Error ? e.message : String(e) }; });',
815
- ' } else {',
816
- ' __ot.__lastExecResult = { value: __r };',
817
- ' }',
818
- ' } catch (e) {',
819
- ' __ot.__lastExecResult = { error: e instanceof Error ? e.message : String(e) };',
820
- ' }',
821
- '})();',
822
- ].join('\n');
823
- await Bun.write(tmpPath, wrapped);
824
- await rename(tmpPath, finalPath);
825
- return filename;
826
- };
827
- /** Delete a dynamic exec script file. Fire-and-forget — logs on failure. */
828
- const deleteExecFile = async (filename) => {
829
- try {
830
- await Bun.file(join(getAdaptersDir(), filename)).delete();
831
- }
832
- catch {
833
- log.warn(`Failed to delete exec file: ${filename}`);
834
- }
835
- };
836
- /**
837
- * Remove stale __exec-*.js files from the adapters directory.
838
- * Called on server startup to clean up leftovers from crashed sessions.
839
- */
840
- const cleanupStaleExecFiles = async () => {
841
- const adaptersDir = getAdaptersDir();
842
- let entries;
843
- try {
844
- entries = await readdir(adaptersDir);
845
- }
846
- catch {
847
- return;
848
- }
849
- const staleExecFiles = entries.filter(f => f.startsWith(EXEC_FILE_PREFIX) && (f.endsWith('.js') || f.endsWith('.js.tmp')));
850
- if (staleExecFiles.length === 0)
851
- return;
852
- await Promise.allSettled(staleExecFiles.map(f => Bun.file(join(adaptersDir, f)).delete()));
853
- log.info(`Cleaned up ${staleExecFiles.length} stale exec file(s)`);
854
- };
855
367
  /**
856
368
  * Send extension.reload JSON-RPC request to trigger chrome.runtime.reload()
857
369
  * in the connected extension. Used when the server detects that the managed