@opentabs-dev/mcp-server 0.0.22 → 0.0.24

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
@@ -24,9 +24,10 @@ import { getNextRequestId, prefixedToolName, STATE_SCHEMA_VERSION } from './stat
24
24
  import { version } from './version.js';
25
25
  import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
26
26
  import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
27
+ import { toErrorMessage } from '@opentabs-dev/shared';
27
28
  import { timingSafeEqual } from 'node:crypto';
28
29
  /** Callbacks for extension protocol → MCP server integration */
29
- const createMcpCallbacks = (state, sessionServers) => ({
30
+ const createMcpCallbacks = (state, sessionServers, transports) => ({
30
31
  onToolConfigChanged: () => {
31
32
  for (const srv of sessionServers) {
32
33
  notifyToolListChanged(srv);
@@ -51,6 +52,7 @@ const createMcpCallbacks = (state, sessionServers) => ({
51
52
  });
52
53
  }
53
54
  },
55
+ onReload: () => performConfigReload(state, sessionServers, transports),
54
56
  });
55
57
  /**
56
58
  * Constant-time string comparison using crypto.timingSafeEqual.
@@ -78,7 +80,7 @@ const checkBearerAuth = (req, wsSecret) => {
78
80
  return null;
79
81
  };
80
82
  /** Allowed hostnames for the Host header (DNS rebinding protection) */
81
- const ALLOWED_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
83
+ const ALLOWED_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '::ffff:127.0.0.1']);
82
84
  /**
83
85
  * Check whether a Host header value refers to a localhost address.
84
86
  * Strips the optional port suffix before comparing against the allowed set.
@@ -101,16 +103,15 @@ const isLocalhostHost = (hostHeader) => {
101
103
  return ALLOWED_HOSTS.has(hostname);
102
104
  };
103
105
  // --- Rate limiting for administrative endpoints ---
104
- const endpointCallTimestamps = new Map();
105
- const checkEndpointRateLimit = (endpoint, maxPerMinute) => {
106
+ const checkEndpointRateLimit = (state, endpoint, maxPerMinute) => {
106
107
  const now = Date.now();
107
- const timestamps = (endpointCallTimestamps.get(endpoint) ?? []).filter(t => now - t < 60_000);
108
+ const timestamps = (state.endpointCallTimestamps.get(endpoint) ?? []).filter(t => now - t < 60_000);
108
109
  if (timestamps.length >= maxPerMinute) {
109
- endpointCallTimestamps.set(endpoint, timestamps);
110
+ state.endpointCallTimestamps.set(endpoint, timestamps);
110
111
  return false;
111
112
  }
112
113
  timestamps.push(now);
113
- endpointCallTimestamps.set(endpoint, timestamps);
114
+ state.endpointCallTimestamps.set(endpoint, timestamps);
114
115
  return true;
115
116
  };
116
117
  /** Compute aggregate audit statistics from the audit log buffer */
@@ -154,291 +155,300 @@ const computeAuditSummary = (auditLog) => {
154
155
  avgDurationMs,
155
156
  };
156
157
  };
157
- const createHandleFetch = ({ state, transports, sessionServers, getHotState }) => async (req, bunServer) => {
158
- const url = new URL(req.url);
159
- // --- Host header validation (DNS rebinding protection) ---
160
- // Reject requests with a non-localhost Host header. A DNS rebinding
161
- // attack re-maps a malicious domain to 127.0.0.1, so the browser sends
162
- // requests to our loopback server with Host: evil.com. Checking the Host
163
- // header is the standard mitigation (CVE-2025-66414 class).
164
- const hostHeader = req.headers.get('Host');
165
- if (!hostHeader || !isLocalhostHost(hostHeader)) {
166
- return new Response('Forbidden: invalid Host header', { status: 403 });
167
- }
168
- // --- CORS protection ---
169
- // MCP clients (Claude Code, etc.) don't run in browsers, so legitimate
170
- // requests never carry an Origin header. Reject requests with an Origin
171
- // header to prevent DNS rebinding attacks from malicious web pages.
172
- //
173
- // Chrome extension requests carry an Origin of `chrome-extension://...`
174
- // and must be allowed through — the extension's background script
175
- // fetches /ws-info to obtain the authenticated WebSocket URL.
176
- const origin = req.headers.get('Origin');
177
- if (origin && !origin.startsWith('chrome-extension://')) {
178
- return new Response('Forbidden: browser requests are not allowed', { status: 403 });
179
- }
180
- // --- WebSocket upgrade for extension ---
181
- if (url.pathname === '/ws') {
182
- // Authenticate WebSocket connections using a shared secret sent via
183
- // the Sec-WebSocket-Protocol header (not URL query params, which leak
184
- // into server logs, browser history, and proxy logs).
185
- // The client sends protocols: ['opentabs', '<secret>'] and the server
186
- // echoes 'opentabs' as the accepted subprotocol.
187
- // Uses constant-time comparison to prevent timing side-channel attacks.
188
- if (state.wsSecret) {
189
- const protocols = req.headers.get('sec-websocket-protocol');
190
- const parts = protocols?.split(',').map(p => p.trim()) ?? [];
191
- let secretMatched = false;
192
- for (const part of parts) {
193
- if (constantTimeEqual(part, state.wsSecret)) {
194
- secretMatched = true;
195
- }
158
+ // --- Extracted route handlers ---
159
+ // Each handles a single route (or route group) and receives only the
160
+ // dependencies it needs. The router in createHandleFetch delegates to these.
161
+ /** WebSocket upgrade for extension connections (/ws) */
162
+ const handleWsUpgrade = (req, bunServer, state) => {
163
+ // Authenticate WebSocket connections using a shared secret sent via
164
+ // the Sec-WebSocket-Protocol header (not URL query params, which leak
165
+ // into server logs, browser history, and proxy logs).
166
+ // The client sends protocols: ['opentabs', '<secret>'] and the server
167
+ // echoes 'opentabs' as the accepted subprotocol.
168
+ // Uses constant-time comparison to prevent timing side-channel attacks.
169
+ if (state.wsSecret) {
170
+ const protocols = req.headers.get('sec-websocket-protocol');
171
+ const parts = protocols?.split(',').map(p => p.trim()) ?? [];
172
+ let secretMatched = false;
173
+ for (const part of parts) {
174
+ if (constantTimeEqual(part, state.wsSecret)) {
175
+ secretMatched = true;
196
176
  }
197
- if (!secretMatched) {
198
- return new Response('Unauthorized', { status: 401 });
199
- }
200
- const upgraded = bunServer.upgrade(req, {
201
- data: undefined,
202
- headers: { 'sec-websocket-protocol': 'opentabs' },
203
- });
204
- if (!upgraded) {
205
- return new Response('WebSocket upgrade failed', { status: 400 });
206
- }
207
- return undefined;
208
177
  }
209
- const upgraded = bunServer.upgrade(req, { data: undefined });
178
+ if (!secretMatched) {
179
+ return new Response('Unauthorized', { status: 401 });
180
+ }
181
+ const upgraded = bunServer.upgrade(req, {
182
+ data: undefined,
183
+ headers: { 'sec-websocket-protocol': 'opentabs' },
184
+ });
210
185
  if (!upgraded) {
211
186
  return new Response('WebSocket upgrade failed', { status: 400 });
212
187
  }
213
188
  return undefined;
214
189
  }
215
- // --- WebSocket info endpoint (for extension authentication) ---
216
- // Returns the WebSocket URL and secret as separate fields. The secret
217
- // is sent via the Sec-WebSocket-Protocol header during the upgrade,
218
- // keeping it out of URLs, logs, and browser history.
219
- //
220
- // Requires Bearer auth. The extension bootstraps the secret from
221
- // auth.json (bundled in the extension directory at install time)
222
- // and uses it for all /ws-info requests.
223
- if (url.pathname === '/ws-info' && req.method === 'GET') {
224
- const authError = checkBearerAuth(req, state.wsSecret);
225
- if (authError)
226
- return authError;
227
- const wsUrl = `ws://${url.host}/ws`;
228
- return Response.json({ wsUrl, wsSecret: state.wsSecret });
190
+ const upgraded = bunServer.upgrade(req, { data: undefined });
191
+ if (!upgraded) {
192
+ return new Response('WebSocket upgrade failed', { status: 400 });
229
193
  }
230
- // --- Health endpoint ---
231
- // Unauthenticated requests get a minimal status response (alive check only).
232
- // Authenticated requests get the full response with plugin details, paths, etc.
233
- if (url.pathname === '/health' && req.method === 'GET') {
234
- const authenticated = checkBearerAuth(req, state.wsSecret) === null;
235
- if (!authenticated) {
236
- return Response.json({
237
- status: 'ok',
238
- version,
239
- extensionConnected: state.extensionWs !== null,
240
- });
241
- }
242
- const hs = getHotState();
243
- const pluginDetails = [...state.registry.plugins.values()].map(p => ({
244
- name: p.name,
245
- displayName: p.displayName,
246
- toolCount: p.tools.length,
247
- tools: p.tools.map(t => prefixedToolName(p.name, t.name)),
248
- tabState: state.tabMapping.get(p.name)?.state ?? 'closed',
249
- source: p.source,
250
- sdkVersion: p.sdkVersion ?? null,
251
- logBufferSize: getLogCount(p.name),
252
- ...(p.iconSvg ? { iconSvg: p.iconSvg } : {}),
253
- }));
254
- const toolCount = state.registry.toolLookup.size + state.cachedBrowserTools.length;
255
- const uptimeSeconds = Math.floor((Date.now() - state.startedAt) / 1000);
256
- const pendingPlugins = state.fileWatcherEntries.filter(e => e.pluginName.startsWith('(pending:')).length;
257
- const watchedPlugins = state.fileWatcherEntries.length - pendingPlugins;
258
- const auditSummary = computeAuditSummary(state.auditLog);
259
- const disabledBrowserTools = state.cachedBrowserTools
260
- .filter(c => state.browserToolPolicy[c.name] === false)
261
- .map(c => c.name);
194
+ return undefined;
195
+ };
196
+ /** WebSocket info endpoint for extension authentication (GET /ws-info) */
197
+ const handleWsInfo = (req, url, state) => {
198
+ const authError = checkBearerAuth(req, state.wsSecret);
199
+ if (authError)
200
+ return authError;
201
+ const wsUrl = `ws://${url.host}/ws`;
202
+ return Response.json({ wsUrl, ...(state.wsSecret ? { wsSecret: state.wsSecret } : {}) });
203
+ };
204
+ /** Health endpoint (GET /health) */
205
+ const handleHealth = (req, state, transports, getHotState) => {
206
+ const authenticated = checkBearerAuth(req, state.wsSecret) === null;
207
+ if (!authenticated) {
262
208
  return Response.json({
263
209
  status: 'ok',
264
210
  version,
265
- sdkVersion,
266
- mode: isDev() ? 'dev' : 'production',
267
211
  extensionConnected: state.extensionWs !== null,
268
- mcpClients: transports.size,
269
- plugins: state.registry.plugins.size,
270
- pluginDetails,
271
- failedPlugins: [...state.registry.failures],
272
- discoveryErrors: [...state.discoveryErrors],
273
- toolCount,
274
- disabledBrowserTools,
275
- confirmationBypassed: state.skipConfirmation,
276
- uptime: uptimeSeconds,
277
- reloadCount: hs?.reloadCount ?? 0,
278
- lastReloadTimestamp: hs?.lastReloadTimestamp ?? 0,
279
- lastReloadDurationMs: hs?.lastReloadDurationMs ?? 0,
280
- stateSchemaVersion: STATE_SCHEMA_VERSION,
281
- fileWatcher: {
282
- watchedPlugins,
283
- pendingPlugins,
284
- lastPollAt: state.mtimeLastPollAt,
285
- pollDetections: state.mtimePollDetections,
286
- },
287
- auditSummary,
288
212
  });
289
213
  }
290
- // --- Audit log endpoint ---
291
- if (url.pathname === '/audit' && req.method === 'GET') {
292
- const authError = checkBearerAuth(req, state.wsSecret);
293
- if (authError)
294
- return authError;
295
- const limitParam = parseInt(url.searchParams.get('limit') ?? '50', 10);
296
- const limit = Math.max(1, Math.min(500, Number.isNaN(limitParam) ? 50 : limitParam));
297
- const pluginFilter = url.searchParams.get('plugin');
298
- const toolFilter = url.searchParams.get('tool');
299
- const successParam = url.searchParams.get('success');
300
- const successFilter = successParam === 'true' ? true : successParam === 'false' ? false : undefined;
301
- let entries = [...state.auditLog].reverse();
302
- if (pluginFilter)
303
- entries = entries.filter(e => e.plugin === pluginFilter);
304
- if (toolFilter)
305
- entries = entries.filter(e => e.tool === toolFilter);
306
- if (successFilter !== undefined)
307
- entries = entries.filter(e => e.success === successFilter);
308
- entries = entries.slice(0, limit);
309
- return Response.json(entries);
214
+ const hs = getHotState();
215
+ const pluginDetails = [...state.registry.plugins.values()].map(p => ({
216
+ name: p.name,
217
+ displayName: p.displayName,
218
+ toolCount: p.tools.length,
219
+ tools: p.tools.map(t => prefixedToolName(p.name, t.name)),
220
+ tabState: state.tabMapping.get(p.name)?.state ?? 'closed',
221
+ source: p.source,
222
+ sdkVersion: p.sdkVersion ?? null,
223
+ logBufferSize: getLogCount(p.name),
224
+ ...(p.iconSvg ? { iconSvg: p.iconSvg } : {}),
225
+ }));
226
+ const toolCount = state.registry.toolLookup.size + state.cachedBrowserTools.length;
227
+ const uptimeSeconds = Math.floor((Date.now() - state.startedAt) / 1000);
228
+ const pendingPlugins = state.fileWatching.entries.filter(e => e.pluginName.startsWith('(pending:')).length;
229
+ const watchedPlugins = state.fileWatching.entries.length - pendingPlugins;
230
+ const auditSummary = computeAuditSummary(state.auditLog);
231
+ const disabledBrowserTools = state.cachedBrowserTools
232
+ .filter(c => state.browserToolPolicy[c.name] === false)
233
+ .map(c => c.name);
234
+ return Response.json({
235
+ status: 'ok',
236
+ version,
237
+ sdkVersion,
238
+ mode: isDev() ? 'dev' : 'production',
239
+ extensionConnected: state.extensionWs !== null,
240
+ mcpClients: transports.size,
241
+ plugins: state.registry.plugins.size,
242
+ pluginDetails,
243
+ failedPlugins: [...state.registry.failures],
244
+ discoveryErrors: [...state.discoveryErrors],
245
+ toolCount,
246
+ disabledBrowserTools,
247
+ confirmationBypassed: state.skipConfirmation,
248
+ uptime: uptimeSeconds,
249
+ reloadCount: hs?.reloadCount ?? 0,
250
+ lastReloadTimestamp: hs?.lastReloadTimestamp ?? 0,
251
+ lastReloadDurationMs: hs?.lastReloadDurationMs ?? 0,
252
+ stateSchemaVersion: STATE_SCHEMA_VERSION,
253
+ fileWatcher: {
254
+ watchedPlugins,
255
+ pendingPlugins,
256
+ lastPollAt: state.fileWatching.mtimeLastPollAt,
257
+ pollDetections: state.fileWatching.mtimePollDetections,
258
+ },
259
+ auditSummary,
260
+ });
261
+ };
262
+ /** Audit log endpoint (GET /audit) */
263
+ const handleAudit = (url, state, req) => {
264
+ const authError = checkBearerAuth(req, state.wsSecret);
265
+ if (authError)
266
+ return authError;
267
+ const limitParam = parseInt(url.searchParams.get('limit') ?? '50', 10);
268
+ const limit = Math.max(1, Math.min(500, Number.isNaN(limitParam) ? 50 : limitParam));
269
+ const pluginFilter = url.searchParams.get('plugin');
270
+ const toolFilter = url.searchParams.get('tool');
271
+ const successParam = url.searchParams.get('success');
272
+ const successFilter = successParam === 'true' ? true : successParam === 'false' ? false : undefined;
273
+ let entries = [...state.auditLog].reverse();
274
+ if (pluginFilter)
275
+ entries = entries.filter(e => e.plugin === pluginFilter);
276
+ if (toolFilter)
277
+ entries = entries.filter(e => e.tool === toolFilter);
278
+ if (successFilter !== undefined)
279
+ entries = entries.filter(e => e.success === successFilter);
280
+ entries = entries.slice(0, limit);
281
+ return Response.json(entries);
282
+ };
283
+ /** Config/plugin rediscovery endpoint (POST /reload) */
284
+ const handleReload = async (req, state, sessionServers, transports) => {
285
+ const authError = checkBearerAuth(req, state.wsSecret);
286
+ if (authError)
287
+ return authError;
288
+ if (!checkEndpointRateLimit(state, '/reload', 10)) {
289
+ return new Response('Too Many Requests', { status: 429, headers: { 'Retry-After': '60' } });
310
290
  }
311
- // --- Config/plugin rediscovery endpoint ---
312
- if (url.pathname === '/reload' && req.method === 'POST') {
313
- const authError = checkBearerAuth(req, state.wsSecret);
314
- if (authError)
315
- return authError;
316
- if (!checkEndpointRateLimit('/reload', 10)) {
317
- return new Response('Too Many Requests', { status: 429, headers: { 'Retry-After': '60' } });
318
- }
319
- try {
320
- const result = await performConfigReload(state, sessionServers, transports);
321
- return Response.json({
322
- ok: true,
323
- plugins: result.plugins,
324
- durationMs: result.durationMs,
325
- });
326
- }
327
- catch (err) {
328
- log.error('Config reload failed:', err);
329
- const rawMsg = err instanceof Error ? err.message : String(err);
330
- return Response.json({ ok: false, error: sanitizeErrorMessage(rawMsg) }, { status: 500 });
331
- }
291
+ try {
292
+ const result = await performConfigReload(state, sessionServers, transports);
293
+ return Response.json({
294
+ ok: true,
295
+ plugins: result.plugins,
296
+ durationMs: result.durationMs,
297
+ });
332
298
  }
333
- // --- Extension reload endpoint ---
334
- if (url.pathname === '/extension/reload' && req.method === 'POST') {
335
- const authError = checkBearerAuth(req, state.wsSecret);
336
- if (authError)
337
- return authError;
338
- if (!checkEndpointRateLimit('/extension/reload', 10)) {
339
- return new Response('Too Many Requests', { status: 429, headers: { 'Retry-After': '60' } });
299
+ catch (err) {
300
+ log.error('Config reload failed:', err);
301
+ const rawMsg = toErrorMessage(err);
302
+ return Response.json({ ok: false, error: sanitizeErrorMessage(rawMsg) }, { status: 500 });
303
+ }
304
+ };
305
+ /** Extension reload endpoint (POST /extension/reload) */
306
+ const handleExtensionReload = (req, state) => {
307
+ const authError = checkBearerAuth(req, state.wsSecret);
308
+ if (authError)
309
+ return authError;
310
+ if (!checkEndpointRateLimit(state, '/extension/reload', 10)) {
311
+ return new Response('Too Many Requests', { status: 429, headers: { 'Retry-After': '60' } });
312
+ }
313
+ if (!state.extensionWs) {
314
+ return Response.json({ ok: false, error: 'Extension not connected' }, { status: 503 });
315
+ }
316
+ const id = getNextRequestId();
317
+ state.extensionWs.send(JSON.stringify({ jsonrpc: '2.0', method: 'extension.reload', id }));
318
+ return Response.json({ ok: true, message: 'Reload signal sent to extension' });
319
+ };
320
+ /** MCP Streamable HTTP transport (/mcp — POST/GET/DELETE) */
321
+ const handleMcp = async (req, bunServer, state, transports, sessionServers) => {
322
+ const authError = checkBearerAuth(req, state.wsSecret);
323
+ if (authError)
324
+ return authError;
325
+ // Disable Bun's per-connection idle timeout for MCP requests.
326
+ // Tool dispatches can take up to DISPATCH_TIMEOUT_MS (30s) and the
327
+ // Streamable HTTP transport holds the response open until the tool
328
+ // result arrives. The default idle timeout (10s) would close the
329
+ // connection before long-running dispatches complete.
330
+ bunServer.timeout(req, 0);
331
+ const sessionId = req.headers.get('mcp-session-id');
332
+ if (req.method === 'POST') {
333
+ // Existing session
334
+ if (sessionId) {
335
+ const existingTransport = transports.get(sessionId);
336
+ if (existingTransport) {
337
+ return existingTransport.handleRequest(req);
338
+ }
340
339
  }
341
- if (!state.extensionWs) {
342
- return Response.json({ ok: false, error: 'Extension not connected' }, { status: 503 });
340
+ // New session — rate-limit session creation to prevent resource exhaustion
341
+ if (!checkEndpointRateLimit(state, '/mcp-session-create', 5)) {
342
+ return new Response('Too Many Requests', { status: 429, headers: { 'Retry-After': '60' } });
343
343
  }
344
- const id = getNextRequestId();
345
- state.extensionWs.send(JSON.stringify({ jsonrpc: '2.0', method: 'extension.reload', id }));
346
- return Response.json({ ok: true, message: 'Reload signal sent to extension' });
347
- }
348
- // --- MCP Streamable HTTP transport ---
349
- if (url.pathname === '/mcp') {
350
- const authError = checkBearerAuth(req, state.wsSecret);
351
- if (authError)
352
- return authError;
353
- // Disable Bun's per-connection idle timeout for MCP requests.
354
- // Tool dispatches can take up to DISPATCH_TIMEOUT_MS (30s) and the
355
- // Streamable HTTP transport holds the response open until the tool
356
- // result arrives. The default idle timeout (10s) would close the
357
- // connection before long-running dispatches complete.
358
- bunServer.timeout(req, 0);
359
- const sessionId = req.headers.get('mcp-session-id');
360
- if (req.method === 'POST') {
361
- // Existing session
362
- if (sessionId) {
363
- const existingTransport = transports.get(sessionId);
364
- if (existingTransport) {
365
- return existingTransport.handleRequest(req);
344
+ // Check if it's an initialize request
345
+ const body = await req.json().catch(() => null);
346
+ if (body && isInitializeRequest(body)) {
347
+ let sessionServer = null;
348
+ const removeSession = () => {
349
+ if (sessionServer) {
350
+ const idx = sessionServers.indexOf(sessionServer);
351
+ if (idx !== -1)
352
+ sessionServers.splice(idx, 1);
353
+ sessionServer = null;
366
354
  }
367
- }
368
- // New session rate-limit session creation to prevent resource exhaustion
369
- if (!checkEndpointRateLimit('/mcp-session-create', 5)) {
370
- return new Response('Too Many Requests', { status: 429, headers: { 'Retry-After': '60' } });
371
- }
372
- // Check if it's an initialize request
373
- const body = await req.json().catch(() => null);
374
- if (body && isInitializeRequest(body)) {
375
- let sessionServer = null;
376
- const removeSession = () => {
355
+ };
356
+ const transport = new WebStandardStreamableHTTPServerTransport({
357
+ sessionIdGenerator: () => crypto.randomUUID(),
358
+ onsessioninitialized: (sid) => {
359
+ transports.set(sid, transport);
360
+ // Track which transport this session is connected to for stale sweep
377
361
  if (sessionServer) {
378
- const idx = sessionServers.indexOf(sessionServer);
379
- if (idx !== -1)
380
- sessionServers.splice(idx, 1);
381
- sessionServer = null;
382
- }
383
- };
384
- const transport = new WebStandardStreamableHTTPServerTransport({
385
- sessionIdGenerator: () => crypto.randomUUID(),
386
- onsessioninitialized: (sid) => {
387
- transports.set(sid, transport);
388
- // Track which transport this session is connected to for stale sweep
389
- if (sessionServer) {
390
- state.sessionTransportIds.set(sessionServer, sid);
391
- }
392
- log.info(`MCP client connected (session: ${sid})`);
393
- },
394
- onsessionclosed: (sid) => {
395
- transports.delete(sid);
396
- removeSession();
397
- log.info(`MCP client disconnected (session: ${sid})`);
398
- },
399
- });
400
- transport.onclose = () => {
401
- if (transport.sessionId) {
402
- transports.delete(transport.sessionId);
362
+ state.sessionTransportIds.set(sessionServer, sid);
403
363
  }
364
+ log.info(`MCP client connected (session: ${sid})`);
365
+ },
366
+ onsessionclosed: (sid) => {
367
+ transports.delete(sid);
404
368
  removeSession();
405
- };
406
- try {
407
- sessionServer = await createMcpServer(state);
408
- sessionServers.push(sessionServer);
409
- await sessionServer.connect(transport);
410
- return await transport.handleRequest(req, { parsedBody: body });
411
- }
412
- catch (err) {
413
- removeSession();
414
- throw err;
369
+ log.info(`MCP client disconnected (session: ${sid})`);
370
+ },
371
+ });
372
+ transport.onclose = () => {
373
+ if (transport.sessionId) {
374
+ transports.delete(transport.sessionId);
415
375
  }
376
+ removeSession();
377
+ };
378
+ try {
379
+ sessionServer = await createMcpServer(state);
380
+ sessionServers.push(sessionServer);
381
+ await sessionServer.connect(transport);
382
+ return await transport.handleRequest(req, { parsedBody: body });
416
383
  }
417
- return Response.json({
418
- jsonrpc: '2.0',
419
- error: {
420
- code: -32600,
421
- message: 'Bad Request: missing session or not an initialize request',
422
- },
423
- id: null,
424
- }, { status: 400 });
425
- }
426
- if (req.method === 'GET') {
427
- const getTransport = sessionId ? transports.get(sessionId) : undefined;
428
- if (getTransport) {
429
- return getTransport.handleRequest(req);
384
+ catch (err) {
385
+ removeSession();
386
+ throw err;
430
387
  }
431
- return new Response('Missing or invalid session', { status: 400 });
432
388
  }
433
- if (req.method === 'DELETE') {
434
- const delTransport = sessionId ? transports.get(sessionId) : undefined;
435
- if (delTransport) {
436
- return delTransport.handleRequest(req);
437
- }
438
- return new Response('Missing or invalid session', { status: 400 });
389
+ return Response.json({
390
+ jsonrpc: '2.0',
391
+ error: {
392
+ code: -32600,
393
+ message: 'Bad Request: missing session or not an initialize request',
394
+ },
395
+ id: null,
396
+ }, { status: 400 });
397
+ }
398
+ if (req.method === 'GET') {
399
+ const getTransport = sessionId ? transports.get(sessionId) : undefined;
400
+ if (getTransport) {
401
+ return getTransport.handleRequest(req);
402
+ }
403
+ return new Response('Missing or invalid session', { status: 400 });
404
+ }
405
+ if (req.method === 'DELETE') {
406
+ const delTransport = sessionId ? transports.get(sessionId) : undefined;
407
+ if (delTransport) {
408
+ return delTransport.handleRequest(req);
439
409
  }
440
- return new Response('Method not allowed', { status: 405 });
410
+ return new Response('Missing or invalid session', { status: 400 });
411
+ }
412
+ return new Response('Method not allowed', { status: 405 });
413
+ };
414
+ // --- Main router ---
415
+ const createHandleFetch = ({ state, transports, sessionServers, getHotState }) => async (req, bunServer) => {
416
+ const url = new URL(req.url);
417
+ // --- Host header validation (DNS rebinding protection) ---
418
+ // Reject requests with a non-localhost Host header. A DNS rebinding
419
+ // attack re-maps a malicious domain to 127.0.0.1, so the browser sends
420
+ // requests to our loopback server with Host: evil.com. Checking the Host
421
+ // header is the standard mitigation (CVE-2025-66414 class).
422
+ const hostHeader = req.headers.get('Host');
423
+ if (!hostHeader || !isLocalhostHost(hostHeader)) {
424
+ return new Response('Forbidden: invalid Host header', { status: 403 });
425
+ }
426
+ // --- CORS protection ---
427
+ // MCP clients (Claude Code, etc.) don't run in browsers, so legitimate
428
+ // requests never carry an Origin header. Reject requests with an Origin
429
+ // header to prevent DNS rebinding attacks from malicious web pages.
430
+ //
431
+ // Chrome extension requests carry an Origin of `chrome-extension://...`
432
+ // and must be allowed through — the extension's background script
433
+ // fetches /ws-info to obtain the authenticated WebSocket URL.
434
+ const origin = req.headers.get('Origin');
435
+ if (origin && !origin.startsWith('chrome-extension://')) {
436
+ return new Response('Forbidden: browser requests are not allowed', { status: 403 });
441
437
  }
438
+ if (url.pathname === '/ws')
439
+ return handleWsUpgrade(req, bunServer, state);
440
+ if (url.pathname === '/ws-info' && req.method === 'GET')
441
+ return handleWsInfo(req, url, state);
442
+ if (url.pathname === '/health' && req.method === 'GET')
443
+ return handleHealth(req, state, transports, getHotState);
444
+ if (url.pathname === '/audit' && req.method === 'GET')
445
+ return handleAudit(url, state, req);
446
+ if (url.pathname === '/reload' && req.method === 'POST')
447
+ return handleReload(req, state, sessionServers, transports);
448
+ if (url.pathname === '/extension/reload' && req.method === 'POST')
449
+ return handleExtensionReload(req, state);
450
+ if (url.pathname === '/mcp')
451
+ return handleMcp(req, bunServer, state, transports, sessionServers);
442
452
  return new Response('Not Found', { status: 404 });
443
453
  };
444
454
  const createHandleWsOpen = (state) => (ws) => {
@@ -500,7 +510,7 @@ const createHandleWsClose = (state) => (ws) => {
500
510
  * fresh closures over the latest module imports.
501
511
  */
502
512
  const createHandlers = (deps) => {
503
- const mcpCallbacks = createMcpCallbacks(deps.state, deps.sessionServers);
513
+ const mcpCallbacks = createMcpCallbacks(deps.state, deps.sessionServers, deps.transports);
504
514
  return {
505
515
  fetch: createHandleFetch(deps),
506
516
  wsOpen: createHandleWsOpen(deps.state),