@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.
- package/dist/adapter-files.d.ts +53 -0
- package/dist/adapter-files.d.ts.map +1 -0
- package/dist/adapter-files.js +176 -0
- package/dist/adapter-files.js.map +1 -0
- package/dist/audit-disk.d.ts.map +1 -1
- package/dist/audit-disk.js +5 -6
- package/dist/audit-disk.js.map +1 -1
- package/dist/browser-tool-names.d.ts +14 -0
- package/dist/browser-tool-names.d.ts.map +1 -0
- package/dist/browser-tool-names.js +52 -0
- package/dist/browser-tool-names.js.map +1 -0
- package/dist/browser-tools/analyze-site/detect-auth.d.ts.map +1 -1
- package/dist/browser-tools/analyze-site/detect-auth.js +2 -1
- package/dist/browser-tools/analyze-site/detect-auth.js.map +1 -1
- package/dist/browser-tools/analyze-site/index.d.ts.map +1 -1
- package/dist/browser-tools/analyze-site/index.js +111 -15
- package/dist/browser-tools/analyze-site/index.js.map +1 -1
- package/dist/browser-tools/execute-script.js +1 -1
- package/dist/browser-tools/execute-script.js.map +1 -1
- package/dist/browser-tools/extension-get-logs.d.ts +2 -2
- package/dist/browser-tools/focus-tab.js +1 -1
- package/dist/browser-tools/focus-tab.js.map +1 -1
- package/dist/browser-tools/get-console-logs.d.ts +2 -2
- package/dist/browser-tools/index.d.ts +4 -0
- package/dist/browser-tools/index.d.ts.map +1 -1
- package/dist/browser-tools/index.js +19 -0
- package/dist/browser-tools/index.js.map +1 -1
- package/dist/browser-tools/select-option.d.ts.map +1 -1
- package/dist/browser-tools/select-option.js +11 -6
- package/dist/browser-tools/select-option.js.map +1 -1
- package/dist/config.d.ts +13 -16
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +43 -109
- package/dist/config.js.map +1 -1
- package/dist/discovery.d.ts.map +1 -1
- package/dist/discovery.js +9 -12
- package/dist/discovery.js.map +1 -1
- package/dist/extension-handlers.d.ts +102 -0
- package/dist/extension-handlers.d.ts.map +1 -0
- package/dist/extension-handlers.js +443 -0
- package/dist/extension-handlers.js.map +1 -0
- package/dist/extension-protocol.d.ts +6 -38
- package/dist/extension-protocol.d.ts.map +1 -1
- package/dist/extension-protocol.js +47 -535
- package/dist/extension-protocol.js.map +1 -1
- package/dist/file-watcher.d.ts.map +1 -1
- package/dist/file-watcher.js +106 -104
- package/dist/file-watcher.js.map +1 -1
- package/dist/http-routes.d.ts +9 -7
- package/dist/http-routes.d.ts.map +1 -1
- package/dist/http-routes.js +275 -265
- package/dist/http-routes.js.map +1 -1
- package/dist/index.js +17 -10
- package/dist/index.js.map +1 -1
- package/dist/loader.d.ts +14 -3
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +42 -22
- package/dist/loader.js.map +1 -1
- package/dist/log-buffer.d.ts.map +1 -1
- package/dist/log-buffer.js +17 -6
- package/dist/log-buffer.js.map +1 -1
- package/dist/logger.d.ts.map +1 -1
- package/dist/logger.js +5 -2
- package/dist/logger.js.map +1 -1
- package/dist/mcp-setup.d.ts +3 -18
- package/dist/mcp-setup.d.ts.map +1 -1
- package/dist/mcp-setup.js +13 -407
- package/dist/mcp-setup.js.map +1 -1
- package/dist/mcp-tool-dispatch.d.ts +51 -0
- package/dist/mcp-tool-dispatch.d.ts.map +1 -0
- package/dist/mcp-tool-dispatch.js +410 -0
- package/dist/mcp-tool-dispatch.js.map +1 -0
- package/dist/plugin-management.d.ts +123 -0
- package/dist/plugin-management.d.ts.map +1 -0
- package/dist/plugin-management.js +340 -0
- package/dist/plugin-management.js.map +1 -0
- package/dist/registry.d.ts +2 -13
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +2 -24
- package/dist/registry.js.map +1 -1
- package/dist/reload.d.ts.map +1 -1
- package/dist/reload.js +13 -2
- package/dist/reload.js.map +1 -1
- package/dist/resolver.d.ts.map +1 -1
- package/dist/resolver.js +33 -25
- package/dist/resolver.js.map +1 -1
- package/dist/state.d.ts +30 -21
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +14 -10
- package/dist/state.js.map +1 -1
- package/dist/version-check.d.ts.map +1 -1
- package/dist/version-check.js +36 -14
- package/dist/version-check.js.map +1 -1
- package/package.json +2 -2
package/dist/http-routes.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
//
|
|
163
|
-
// header
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
const
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
|
|
342
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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('
|
|
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),
|