@vibebrowser/mcp 0.2.5 → 0.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +70 -2
- package/dist/browser-cli.d.ts.map +1 -1
- package/dist/browser-cli.js +767 -358
- package/dist/browser-cli.js.map +1 -1
- package/dist/browser-main.js +1 -0
- package/dist/browser-main.js.map +1 -1
- package/dist/cli.js +52 -4
- package/dist/cli.js.map +1 -1
- package/dist/connection.d.ts +14 -7
- package/dist/connection.d.ts.map +1 -1
- package/dist/connection.js +106 -21
- package/dist/connection.js.map +1 -1
- package/dist/devtools-fallback.d.ts +22 -0
- package/dist/devtools-fallback.d.ts.map +1 -0
- package/dist/devtools-fallback.js +172 -0
- package/dist/devtools-fallback.js.map +1 -0
- package/dist/relay.d.ts +23 -3
- package/dist/relay.d.ts.map +1 -1
- package/dist/relay.js +448 -103
- package/dist/relay.js.map +1 -1
- package/dist/server.d.ts +4 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +248 -12
- package/dist/server.js.map +1 -1
- package/dist/types.d.ts +27 -10
- package/dist/types.d.ts.map +1 -1
- package/docs/chrome-devtools-relay.md +7 -0
- package/docs/eval.md +101 -16
- package/docs/openclaw-local-browser.md +119 -18
- package/openclaw/vibebrowser/SKILL.md +219 -0
- package/package.json +9 -2
- package/openclaw/vibe-local-browser/SKILL.md +0 -132
package/dist/relay.js
CHANGED
|
@@ -13,6 +13,7 @@ import { writeFileSync, readFileSync, existsSync, unlinkSync, mkdirSync } from '
|
|
|
13
13
|
import { join } from 'path';
|
|
14
14
|
import { homedir } from 'os';
|
|
15
15
|
import { EventEmitter } from 'events';
|
|
16
|
+
import { DevtoolsFallbackConnection } from './devtools-fallback.js';
|
|
16
17
|
function parseEnvPort(name, fallback) {
|
|
17
18
|
const raw = process.env[name];
|
|
18
19
|
if (!raw) {
|
|
@@ -24,6 +25,9 @@ function parseEnvPort(name, fallback) {
|
|
|
24
25
|
}
|
|
25
26
|
return port;
|
|
26
27
|
}
|
|
28
|
+
function normalizeToolName(value) {
|
|
29
|
+
return value.replace(/[-\s]/g, '_').toLowerCase();
|
|
30
|
+
}
|
|
27
31
|
// Ports (19888/19889 to avoid conflict with Playwriter MCP which uses 19988/19989)
|
|
28
32
|
export const EXTENSION_PORT = parseEnvPort('VIBE_MCP_EXTENSION_PORT', 19889);
|
|
29
33
|
export const AGENT_PORT = parseEnvPort('VIBE_MCP_AGENT_PORT', 19888);
|
|
@@ -33,20 +37,27 @@ const PID_FILE = join(VIBE_DIR, 'relay.pid');
|
|
|
33
37
|
const LOG_FILE = join(VIBE_DIR, 'relay.log');
|
|
34
38
|
/**
|
|
35
39
|
* Vibe MCP Relay Server
|
|
40
|
+
*
|
|
41
|
+
* Owns exactly one shared chrome-devtools fallback backend process per relay
|
|
42
|
+
* daemon instance. All connected MCP clients (vibebrowser-mcp and
|
|
43
|
+
* vibebrowser-cli) route through this relay, so fallback lifecycle and tool
|
|
44
|
+
* routing stay deterministic across concurrent agents.
|
|
36
45
|
*/
|
|
37
46
|
export class RelayServer extends EventEmitter {
|
|
38
47
|
extensionWss = null;
|
|
39
48
|
agentWss = null;
|
|
40
|
-
|
|
49
|
+
extensionSessions = new Map();
|
|
50
|
+
socketToSessionId = new Map();
|
|
41
51
|
agents = new Map();
|
|
42
52
|
pendingRequests = new Map();
|
|
43
|
-
tools = [];
|
|
44
53
|
requestIdCounter = 0;
|
|
54
|
+
anonymousSessionCounter = 0;
|
|
45
55
|
debug;
|
|
46
|
-
|
|
56
|
+
devtoolsFallback;
|
|
47
57
|
constructor(debug = false) {
|
|
48
58
|
super();
|
|
49
59
|
this.debug = debug;
|
|
60
|
+
this.devtoolsFallback = new DevtoolsFallbackConnection(debug);
|
|
50
61
|
}
|
|
51
62
|
/**
|
|
52
63
|
* Start the relay server
|
|
@@ -60,6 +71,19 @@ export class RelayServer extends EventEmitter {
|
|
|
60
71
|
await this.startExtensionServer();
|
|
61
72
|
// Start agent WebSocket server
|
|
62
73
|
await this.startAgentServer();
|
|
74
|
+
await this.devtoolsFallback.start();
|
|
75
|
+
this.devtoolsFallback.on('tools_updated', () => {
|
|
76
|
+
this.broadcastDefaultToolsToAgents();
|
|
77
|
+
this.broadcastSessionState();
|
|
78
|
+
});
|
|
79
|
+
this.devtoolsFallback.on('connected', () => {
|
|
80
|
+
this.broadcastDefaultToolsToAgents();
|
|
81
|
+
this.broadcastSessionState();
|
|
82
|
+
});
|
|
83
|
+
this.devtoolsFallback.on('unavailable', () => {
|
|
84
|
+
this.broadcastDefaultToolsToAgents();
|
|
85
|
+
this.broadcastSessionState();
|
|
86
|
+
});
|
|
63
87
|
// Write PID file
|
|
64
88
|
writeFileSync(PID_FILE, String(process.pid));
|
|
65
89
|
this.log(`Relay started (PID: ${process.pid})`);
|
|
@@ -109,17 +133,8 @@ export class RelayServer extends EventEmitter {
|
|
|
109
133
|
* Handle extension connection
|
|
110
134
|
*/
|
|
111
135
|
handleExtensionConnection(ws) {
|
|
112
|
-
|
|
113
|
-
const replacedConnection = previousWs !== null && previousWs !== ws;
|
|
114
|
-
// Replace prior extension connection (background/service worker can reconnect).
|
|
115
|
-
if (previousWs && previousWs !== ws) {
|
|
116
|
-
previousWs.close();
|
|
117
|
-
}
|
|
118
|
-
this.log('Extension connected');
|
|
119
|
-
this.extensionWs = ws;
|
|
136
|
+
this.log('Extension socket connected; waiting for session handshake');
|
|
120
137
|
ws.on('message', (data) => {
|
|
121
|
-
if (this.extensionWs !== ws)
|
|
122
|
-
return;
|
|
123
138
|
try {
|
|
124
139
|
const message = JSON.parse(data.toString());
|
|
125
140
|
this.handleExtensionMessage(ws, message);
|
|
@@ -130,25 +145,29 @@ export class RelayServer extends EventEmitter {
|
|
|
130
145
|
});
|
|
131
146
|
ws.on('close', (code, reasonBuffer) => {
|
|
132
147
|
const reason = reasonBuffer?.toString() || '';
|
|
133
|
-
this.
|
|
134
|
-
|
|
135
|
-
if (
|
|
148
|
+
const sessionId = this.socketToSessionId.get(ws);
|
|
149
|
+
this.log(`Extension disconnected${sessionId ? ` (${sessionId})` : ''} (code=${code}${reason ? `, reason=${reason}` : ''})`);
|
|
150
|
+
if (!sessionId) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const session = this.extensionSessions.get(sessionId);
|
|
154
|
+
if (!session || session.ws !== ws) {
|
|
155
|
+
this.socketToSessionId.delete(ws);
|
|
136
156
|
return;
|
|
137
157
|
}
|
|
138
|
-
this.
|
|
139
|
-
this.
|
|
140
|
-
this.stopToolsSyncLoop();
|
|
141
|
-
|
|
142
|
-
this.
|
|
158
|
+
this.socketToSessionId.delete(ws);
|
|
159
|
+
this.extensionSessions.delete(sessionId);
|
|
160
|
+
this.stopToolsSyncLoop(session);
|
|
161
|
+
this.rejectPendingRequestsForSession(sessionId, `Extension session disconnected: ${sessionId}`);
|
|
162
|
+
this.broadcastSessionState();
|
|
163
|
+
if (this.extensionSessions.size === 0) {
|
|
164
|
+
this.broadcastToAgents({ type: 'extension_disconnected' });
|
|
165
|
+
this.broadcastDefaultToolsToAgents();
|
|
166
|
+
}
|
|
143
167
|
});
|
|
144
168
|
ws.on('error', (error) => {
|
|
145
169
|
this.log(`Extension WebSocket error: ${error.message}`);
|
|
146
170
|
});
|
|
147
|
-
// Request tools list, with retries in case the extension wasn't ready yet.
|
|
148
|
-
this.startToolsSyncLoop();
|
|
149
|
-
if (replacedConnection) {
|
|
150
|
-
this.replayPendingRequests(ws);
|
|
151
|
-
}
|
|
152
171
|
}
|
|
153
172
|
/**
|
|
154
173
|
* Handle agent connection
|
|
@@ -162,19 +181,19 @@ export class RelayServer extends EventEmitter {
|
|
|
162
181
|
};
|
|
163
182
|
this.agents.set(agentId, agent);
|
|
164
183
|
this.log(`Agent connected: ${agentId} (total: ${this.agents.size})`);
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
184
|
+
this.sendSessionStateToAgent(ws);
|
|
185
|
+
const defaultSession = this.getDefaultSession();
|
|
186
|
+
const tools = this.getToolsForSession(defaultSession);
|
|
187
|
+
if (tools.length > 0) {
|
|
188
|
+
ws.send(JSON.stringify({ type: 'tools_list', data: tools, sessionId: defaultSession?.sessionId }));
|
|
168
189
|
}
|
|
169
|
-
// Send extension status
|
|
170
|
-
ws.send(JSON.stringify({
|
|
171
|
-
type: 'extension_status',
|
|
172
|
-
connected: this.extensionWs !== null
|
|
173
|
-
}));
|
|
174
190
|
ws.on('message', (data) => {
|
|
175
191
|
try {
|
|
176
192
|
const message = JSON.parse(data.toString());
|
|
177
|
-
this.handleAgentMessage(agentId, message)
|
|
193
|
+
this.handleAgentMessage(agentId, message).catch((error) => {
|
|
194
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
195
|
+
this.log(`Unhandled agent message failure (${agentId}): ${reason}`);
|
|
196
|
+
});
|
|
178
197
|
}
|
|
179
198
|
catch (error) {
|
|
180
199
|
this.log(`Failed to parse agent message: ${error}`);
|
|
@@ -198,26 +217,18 @@ export class RelayServer extends EventEmitter {
|
|
|
198
217
|
* Handle message from extension
|
|
199
218
|
*/
|
|
200
219
|
handleExtensionMessage(sourceWs, message) {
|
|
201
|
-
|
|
202
|
-
|
|
220
|
+
const session = this.ensureSessionForSocket(sourceWs, message);
|
|
221
|
+
if (!session) {
|
|
222
|
+
this.log(`Ignoring extension message before handshake: ${message.type}`);
|
|
203
223
|
return;
|
|
204
224
|
}
|
|
205
|
-
this.log(`Extension message: ${message.type}`);
|
|
225
|
+
this.log(`Extension message (${session.sessionId}): ${message.type}`);
|
|
206
226
|
if (message.type === 'connected') {
|
|
207
|
-
// Local extension clients announce their websocket handshake with a
|
|
208
|
-
// standalone `connected` message. That is not itself a failure signal,
|
|
209
|
-
// and rejecting pending requests here breaks otherwise healthy reconnects.
|
|
210
|
-
return;
|
|
211
|
-
}
|
|
212
|
-
// Handle tools list
|
|
213
|
-
if (message.type === 'tools_list') {
|
|
214
|
-
this.tools = message.data;
|
|
215
|
-
this.stopToolsSyncLoop();
|
|
216
|
-
// Broadcast to all agents
|
|
217
|
-
this.broadcastToAgents({ type: 'tools_list', data: this.tools });
|
|
218
227
|
return;
|
|
219
228
|
}
|
|
220
|
-
// Handle response to a request
|
|
229
|
+
// Handle response to a pending request first — this must run before the
|
|
230
|
+
// tools_list broadcast so that `refreshTools()` resolves promptly instead
|
|
231
|
+
// of waiting for its 30 s timeout.
|
|
221
232
|
if (message.requestId) {
|
|
222
233
|
const pending = this.pendingRequests.get(message.requestId);
|
|
223
234
|
if (pending) {
|
|
@@ -225,59 +236,212 @@ export class RelayServer extends EventEmitter {
|
|
|
225
236
|
// Forward response to the requesting agent
|
|
226
237
|
const agent = this.agents.get(pending.agentId);
|
|
227
238
|
if (agent) {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
239
|
+
if (message.type === 'tools_list') {
|
|
240
|
+
agent.ws.send(JSON.stringify({
|
|
241
|
+
type: 'tools_list',
|
|
242
|
+
requestId: pending.originalRequestId,
|
|
243
|
+
data: this.getToolsForSession(this.extensionSessions.get(pending.sessionId) || null),
|
|
244
|
+
sessionId: pending.sessionId,
|
|
245
|
+
}));
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
agent.ws.send(JSON.stringify({
|
|
249
|
+
...message,
|
|
250
|
+
sessionId: session.sessionId,
|
|
251
|
+
requestId: pending.originalRequestId,
|
|
252
|
+
}));
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
// For tools_list we still want to cache + broadcast to *other* agents
|
|
256
|
+
// so they stay in sync, but the requesting agent already got its copy.
|
|
257
|
+
if (message.type === 'tools_list') {
|
|
258
|
+
session.tools = this.parseToolDefinitions(message.data);
|
|
259
|
+
this.stopToolsSyncLoop(session);
|
|
260
|
+
this.broadcastDefaultToolsToAgents(pending.agentId);
|
|
261
|
+
this.broadcastSessionState();
|
|
232
262
|
}
|
|
233
263
|
return;
|
|
234
264
|
}
|
|
235
265
|
}
|
|
266
|
+
// Handle unsolicited tools list (e.g. extension announces on connect)
|
|
267
|
+
if (message.type === 'tools_list') {
|
|
268
|
+
session.tools = this.parseToolDefinitions(message.data);
|
|
269
|
+
this.stopToolsSyncLoop(session);
|
|
270
|
+
this.broadcastDefaultToolsToAgents();
|
|
271
|
+
this.broadcastSessionState();
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
236
274
|
// Broadcast other messages to all agents
|
|
237
|
-
this.broadcastToAgents(message);
|
|
275
|
+
this.broadcastToAgents({ ...message, sessionId: session.sessionId });
|
|
238
276
|
}
|
|
239
277
|
/**
|
|
240
278
|
* Handle message from an agent
|
|
241
279
|
*/
|
|
242
|
-
handleAgentMessage(agentId, message) {
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
280
|
+
async handleAgentMessage(agentId, message) {
|
|
281
|
+
try {
|
|
282
|
+
this.log(`Agent ${agentId} message: ${message.type}`);
|
|
283
|
+
if (message.type === 'list_sessions') {
|
|
284
|
+
const agent = this.agents.get(agentId);
|
|
285
|
+
if (agent && message.requestId) {
|
|
286
|
+
agent.ws.send(JSON.stringify({
|
|
287
|
+
type: 'sessions_list',
|
|
288
|
+
requestId: message.requestId,
|
|
289
|
+
sessions: this.buildSessionsList(),
|
|
290
|
+
}));
|
|
291
|
+
}
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const requestedSessionId = typeof message.data?.sessionId === 'string' ? message.data.sessionId : undefined;
|
|
295
|
+
const targetSession = this.resolveTargetSession(requestedSessionId);
|
|
246
296
|
const agent = this.agents.get(agentId);
|
|
247
|
-
if (agent
|
|
297
|
+
if (!agent || !message.requestId) {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (message.type === 'list_tools') {
|
|
301
|
+
if (requestedSessionId && !targetSession) {
|
|
302
|
+
agent.ws.send(JSON.stringify({
|
|
303
|
+
type: 'error',
|
|
304
|
+
requestId: message.requestId,
|
|
305
|
+
error: `No browser session connected for sessionId=${requestedSessionId}`,
|
|
306
|
+
}));
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
agent.ws.send(JSON.stringify({
|
|
310
|
+
type: 'tools_list',
|
|
311
|
+
requestId: message.requestId,
|
|
312
|
+
data: this.getToolsForSession(targetSession),
|
|
313
|
+
sessionId: targetSession?.sessionId,
|
|
314
|
+
}));
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
if (message.type === 'call_tool') {
|
|
318
|
+
const requestedToolName = message.data?.name;
|
|
319
|
+
if (typeof requestedToolName !== 'string' || requestedToolName.trim().length === 0) {
|
|
320
|
+
agent.ws.send(JSON.stringify({
|
|
321
|
+
type: 'error',
|
|
322
|
+
requestId: message.requestId,
|
|
323
|
+
error: 'Tool name is required',
|
|
324
|
+
}));
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
if (requestedSessionId && !targetSession) {
|
|
328
|
+
agent.ws.send(JSON.stringify({
|
|
329
|
+
type: 'error',
|
|
330
|
+
requestId: message.requestId,
|
|
331
|
+
error: `No browser session connected for sessionId=${requestedSessionId}`,
|
|
332
|
+
}));
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
const extensionTool = this.findExtensionTool(targetSession, requestedToolName);
|
|
336
|
+
if (extensionTool && targetSession) {
|
|
337
|
+
const relayRequestId = `relay_${++this.requestIdCounter}`;
|
|
338
|
+
const cleanData = message.data ? { ...message.data } : undefined;
|
|
339
|
+
if (cleanData && 'sessionId' in cleanData) {
|
|
340
|
+
delete cleanData.sessionId;
|
|
341
|
+
}
|
|
342
|
+
const forwardMessage = {
|
|
343
|
+
...message,
|
|
344
|
+
requestId: relayRequestId,
|
|
345
|
+
...(cleanData ? { data: cleanData } : {}),
|
|
346
|
+
};
|
|
347
|
+
this.pendingRequests.set(relayRequestId, {
|
|
348
|
+
agentId,
|
|
349
|
+
originalRequestId: message.requestId,
|
|
350
|
+
lastSentAt: Date.now(),
|
|
351
|
+
forwardMessage,
|
|
352
|
+
sessionId: targetSession.sessionId,
|
|
353
|
+
});
|
|
354
|
+
targetSession.ws.send(JSON.stringify(forwardMessage));
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
if (!this.hasConnectedExtensionSession() && this.findFallbackTool(requestedToolName)) {
|
|
358
|
+
try {
|
|
359
|
+
const args = message.data?.arguments && typeof message.data.arguments === 'object'
|
|
360
|
+
? message.data.arguments
|
|
361
|
+
: {};
|
|
362
|
+
const result = await this.devtoolsFallback.callTool(requestedToolName, args);
|
|
363
|
+
agent.ws.send(JSON.stringify({
|
|
364
|
+
type: 'tool_result',
|
|
365
|
+
requestId: message.requestId,
|
|
366
|
+
data: result,
|
|
367
|
+
sessionId: targetSession?.sessionId,
|
|
368
|
+
}));
|
|
369
|
+
}
|
|
370
|
+
catch (error) {
|
|
371
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
372
|
+
agent.ws.send(JSON.stringify({
|
|
373
|
+
type: 'error',
|
|
374
|
+
requestId: message.requestId,
|
|
375
|
+
error: reason,
|
|
376
|
+
sessionId: targetSession?.sessionId,
|
|
377
|
+
}));
|
|
378
|
+
}
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
248
381
|
agent.ws.send(JSON.stringify({
|
|
249
382
|
type: 'error',
|
|
250
383
|
requestId: message.requestId,
|
|
251
|
-
error:
|
|
384
|
+
error: targetSession
|
|
385
|
+
? `Tool not found: ${requestedToolName}`
|
|
386
|
+
: 'No extension connected',
|
|
387
|
+
}));
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
if (!targetSession) {
|
|
391
|
+
// No extension connected, send error back
|
|
392
|
+
agent.ws.send(JSON.stringify({
|
|
393
|
+
type: 'error',
|
|
394
|
+
requestId: message.requestId,
|
|
395
|
+
error: requestedSessionId
|
|
396
|
+
? `No browser session connected for sessionId=${requestedSessionId}`
|
|
397
|
+
: 'No extension connected',
|
|
398
|
+
}));
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
// Generate relay request ID
|
|
402
|
+
const relayRequestId = `relay_${++this.requestIdCounter}`;
|
|
403
|
+
const cleanData = message.data ? { ...message.data } : undefined;
|
|
404
|
+
if (cleanData && 'sessionId' in cleanData) {
|
|
405
|
+
delete cleanData.sessionId;
|
|
406
|
+
}
|
|
407
|
+
const forwardMessage = {
|
|
408
|
+
...message,
|
|
409
|
+
requestId: relayRequestId,
|
|
410
|
+
...(cleanData ? { data: cleanData } : {}),
|
|
411
|
+
};
|
|
412
|
+
// Store pending request mapping so it can be replayed if the extension
|
|
413
|
+
// swaps sockets mid-flight.
|
|
414
|
+
this.pendingRequests.set(relayRequestId, {
|
|
415
|
+
agentId,
|
|
416
|
+
originalRequestId: message.requestId,
|
|
417
|
+
lastSentAt: Date.now(),
|
|
418
|
+
forwardMessage,
|
|
419
|
+
sessionId: targetSession.sessionId,
|
|
420
|
+
});
|
|
421
|
+
// Forward to extension with relay request ID
|
|
422
|
+
targetSession.ws.send(JSON.stringify(forwardMessage));
|
|
423
|
+
}
|
|
424
|
+
catch (error) {
|
|
425
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
426
|
+
this.log(`Failed to handle agent message (${agentId}, ${message.type}): ${reason}`);
|
|
427
|
+
const agent = this.agents.get(agentId);
|
|
428
|
+
if (agent && message.requestId && agent.ws.readyState === WebSocket.OPEN) {
|
|
429
|
+
agent.ws.send(JSON.stringify({
|
|
430
|
+
type: 'error',
|
|
431
|
+
requestId: message.requestId,
|
|
432
|
+
error: `Relay error: ${reason}`,
|
|
252
433
|
}));
|
|
253
434
|
}
|
|
254
|
-
return;
|
|
255
435
|
}
|
|
256
|
-
// Generate relay request ID
|
|
257
|
-
const relayRequestId = `relay_${++this.requestIdCounter}`;
|
|
258
|
-
const forwardMessage = {
|
|
259
|
-
...message,
|
|
260
|
-
requestId: relayRequestId,
|
|
261
|
-
};
|
|
262
|
-
// Store pending request mapping so it can be replayed if the extension
|
|
263
|
-
// swaps sockets mid-flight.
|
|
264
|
-
this.pendingRequests.set(relayRequestId, {
|
|
265
|
-
agentId,
|
|
266
|
-
originalRequestId: message.requestId,
|
|
267
|
-
lastSentAt: Date.now(),
|
|
268
|
-
forwardMessage,
|
|
269
|
-
});
|
|
270
|
-
// Forward to extension with relay request ID
|
|
271
|
-
this.extensionWs.send(JSON.stringify(forwardMessage));
|
|
272
436
|
}
|
|
273
437
|
/**
|
|
274
438
|
* Request tools list from extension
|
|
275
439
|
*/
|
|
276
|
-
requestToolsFromExtension() {
|
|
277
|
-
if (
|
|
440
|
+
requestToolsFromExtension(session) {
|
|
441
|
+
if (session.ws.readyState !== WebSocket.OPEN)
|
|
278
442
|
return;
|
|
279
443
|
const requestId = `relay_${++this.requestIdCounter}`;
|
|
280
|
-
|
|
444
|
+
session.ws.send(JSON.stringify({
|
|
281
445
|
type: 'list_tools',
|
|
282
446
|
requestId,
|
|
283
447
|
}));
|
|
@@ -289,16 +453,19 @@ export class RelayServer extends EventEmitter {
|
|
|
289
453
|
* same relay request ID lets us survive a transient socket swap without
|
|
290
454
|
* dropping the original MCP call or double-running it under normal reconnects.
|
|
291
455
|
*/
|
|
292
|
-
replayPendingRequests(
|
|
456
|
+
replayPendingRequests(session) {
|
|
293
457
|
if (this.pendingRequests.size === 0)
|
|
294
458
|
return;
|
|
295
|
-
if (
|
|
459
|
+
if (session.ws.readyState !== WebSocket.OPEN)
|
|
296
460
|
return;
|
|
297
|
-
|
|
298
|
-
|
|
461
|
+
const pendingForSession = [...this.pendingRequests.entries()].filter(([, pending]) => pending.sessionId === session.sessionId);
|
|
462
|
+
if (pendingForSession.length === 0)
|
|
463
|
+
return;
|
|
464
|
+
this.log(`Replaying ${pendingForSession.length} pending request(s) on replacement connection for ${session.sessionId}`);
|
|
465
|
+
for (const [relayRequestId, pending] of pendingForSession) {
|
|
299
466
|
pending.lastSentAt = Date.now();
|
|
300
467
|
try {
|
|
301
|
-
|
|
468
|
+
session.ws.send(JSON.stringify(pending.forwardMessage));
|
|
302
469
|
}
|
|
303
470
|
catch (error) {
|
|
304
471
|
this.log(`Failed to replay ${relayRequestId}: ${error}`);
|
|
@@ -308,29 +475,31 @@ export class RelayServer extends EventEmitter {
|
|
|
308
475
|
/**
|
|
309
476
|
* Keep requesting tools until extension responds with tools_list.
|
|
310
477
|
*/
|
|
311
|
-
startToolsSyncLoop() {
|
|
312
|
-
this.stopToolsSyncLoop();
|
|
313
|
-
this.requestToolsFromExtension();
|
|
314
|
-
|
|
315
|
-
if (
|
|
316
|
-
this.stopToolsSyncLoop();
|
|
478
|
+
startToolsSyncLoop(session) {
|
|
479
|
+
this.stopToolsSyncLoop(session);
|
|
480
|
+
this.requestToolsFromExtension(session);
|
|
481
|
+
session.toolsSyncTimer = setInterval(() => {
|
|
482
|
+
if (session.ws.readyState !== WebSocket.OPEN) {
|
|
483
|
+
this.stopToolsSyncLoop(session);
|
|
317
484
|
return;
|
|
318
485
|
}
|
|
319
|
-
this.requestToolsFromExtension();
|
|
486
|
+
this.requestToolsFromExtension(session);
|
|
320
487
|
}, 1_000);
|
|
321
488
|
}
|
|
322
|
-
stopToolsSyncLoop() {
|
|
323
|
-
if (
|
|
324
|
-
clearInterval(
|
|
325
|
-
|
|
489
|
+
stopToolsSyncLoop(session) {
|
|
490
|
+
if (session.toolsSyncTimer) {
|
|
491
|
+
clearInterval(session.toolsSyncTimer);
|
|
492
|
+
session.toolsSyncTimer = null;
|
|
326
493
|
}
|
|
327
494
|
}
|
|
328
495
|
/**
|
|
329
496
|
* Broadcast message to all connected agents
|
|
330
497
|
*/
|
|
331
|
-
broadcastToAgents(message) {
|
|
498
|
+
broadcastToAgents(message, excludeAgentId) {
|
|
332
499
|
const payload = JSON.stringify(message);
|
|
333
500
|
for (const agent of this.agents.values()) {
|
|
501
|
+
if (agent.id === excludeAgentId)
|
|
502
|
+
continue;
|
|
334
503
|
try {
|
|
335
504
|
agent.ws.send(payload);
|
|
336
505
|
}
|
|
@@ -363,12 +532,19 @@ export class RelayServer extends EventEmitter {
|
|
|
363
532
|
}
|
|
364
533
|
}
|
|
365
534
|
this.agents.clear();
|
|
366
|
-
// Close extension
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
535
|
+
// Close extension connections
|
|
536
|
+
for (const session of this.extensionSessions.values()) {
|
|
537
|
+
try {
|
|
538
|
+
session.ws.close();
|
|
539
|
+
}
|
|
540
|
+
catch (error) {
|
|
541
|
+
// Ignore
|
|
542
|
+
}
|
|
543
|
+
this.stopToolsSyncLoop(session);
|
|
370
544
|
}
|
|
371
|
-
this.
|
|
545
|
+
this.extensionSessions.clear();
|
|
546
|
+
this.socketToSessionId.clear();
|
|
547
|
+
await this.devtoolsFallback.stop();
|
|
372
548
|
// Close servers
|
|
373
549
|
if (this.agentWss) {
|
|
374
550
|
this.agentWss.close();
|
|
@@ -398,6 +574,175 @@ export class RelayServer extends EventEmitter {
|
|
|
398
574
|
// Ignore log errors
|
|
399
575
|
}
|
|
400
576
|
}
|
|
577
|
+
ensureSessionForSocket(ws, message) {
|
|
578
|
+
const existingSessionId = this.socketToSessionId.get(ws);
|
|
579
|
+
if (existingSessionId) {
|
|
580
|
+
return this.extensionSessions.get(existingSessionId) || null;
|
|
581
|
+
}
|
|
582
|
+
const announcedSessionId = this.extractAnnouncedSessionId(message) || `session_${++this.anonymousSessionCounter}`;
|
|
583
|
+
const existing = this.extensionSessions.get(announcedSessionId);
|
|
584
|
+
if (existing) {
|
|
585
|
+
const previousWs = existing.ws;
|
|
586
|
+
if (previousWs !== ws) {
|
|
587
|
+
this.log(`Extension session ${announcedSessionId} reconnecting, replacing previous socket`);
|
|
588
|
+
this.socketToSessionId.delete(previousWs);
|
|
589
|
+
existing.ws = ws;
|
|
590
|
+
existing.connectedAt = Date.now();
|
|
591
|
+
this.socketToSessionId.set(ws, announcedSessionId);
|
|
592
|
+
this.broadcastSessionState();
|
|
593
|
+
this.startToolsSyncLoop(existing);
|
|
594
|
+
this.replayPendingRequests(existing);
|
|
595
|
+
try {
|
|
596
|
+
previousWs.close();
|
|
597
|
+
}
|
|
598
|
+
catch (error) {
|
|
599
|
+
// ignore close errors on replaced sockets
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
return existing;
|
|
603
|
+
}
|
|
604
|
+
const session = {
|
|
605
|
+
ws,
|
|
606
|
+
sessionId: announcedSessionId,
|
|
607
|
+
connectedAt: Date.now(),
|
|
608
|
+
tools: [],
|
|
609
|
+
toolsSyncTimer: null,
|
|
610
|
+
};
|
|
611
|
+
this.extensionSessions.set(announcedSessionId, session);
|
|
612
|
+
this.socketToSessionId.set(ws, announcedSessionId);
|
|
613
|
+
this.broadcastSessionState();
|
|
614
|
+
this.startToolsSyncLoop(session);
|
|
615
|
+
return session;
|
|
616
|
+
}
|
|
617
|
+
extractAnnouncedSessionId(message) {
|
|
618
|
+
if (typeof message.sessionId === 'string' && message.sessionId.trim().length > 0) {
|
|
619
|
+
return message.sessionId.trim();
|
|
620
|
+
}
|
|
621
|
+
if (message.data && typeof message.data === 'object') {
|
|
622
|
+
const candidate = message.data.sessionId;
|
|
623
|
+
if (typeof candidate === 'string' && candidate.trim().length > 0) {
|
|
624
|
+
return candidate.trim();
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
return undefined;
|
|
628
|
+
}
|
|
629
|
+
buildSessionsList() {
|
|
630
|
+
return [...this.extensionSessions.values()].map((session) => ({
|
|
631
|
+
sessionId: session.sessionId,
|
|
632
|
+
connected: session.ws.readyState === WebSocket.OPEN,
|
|
633
|
+
connectedAt: session.connectedAt,
|
|
634
|
+
toolCount: session.tools.length,
|
|
635
|
+
}));
|
|
636
|
+
}
|
|
637
|
+
getDefaultSession() {
|
|
638
|
+
for (const session of this.extensionSessions.values()) {
|
|
639
|
+
if (session.ws.readyState === WebSocket.OPEN) {
|
|
640
|
+
return session;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
return null;
|
|
644
|
+
}
|
|
645
|
+
resolveTargetSession(requestedSessionId) {
|
|
646
|
+
if (requestedSessionId) {
|
|
647
|
+
const session = this.extensionSessions.get(requestedSessionId);
|
|
648
|
+
if (session && session.ws.readyState === WebSocket.OPEN) {
|
|
649
|
+
return session;
|
|
650
|
+
}
|
|
651
|
+
return null;
|
|
652
|
+
}
|
|
653
|
+
return this.getDefaultSession();
|
|
654
|
+
}
|
|
655
|
+
parseToolDefinitions(data) {
|
|
656
|
+
if (!Array.isArray(data)) {
|
|
657
|
+
return [];
|
|
658
|
+
}
|
|
659
|
+
return data
|
|
660
|
+
.filter((entry) => Boolean(entry) && typeof entry === 'object' && typeof entry.name === 'string')
|
|
661
|
+
.map((entry) => entry);
|
|
662
|
+
}
|
|
663
|
+
getToolsForSession(session) {
|
|
664
|
+
if (session) {
|
|
665
|
+
return [...session.tools];
|
|
666
|
+
}
|
|
667
|
+
if (this.hasConnectedExtensionSession()) {
|
|
668
|
+
return [];
|
|
669
|
+
}
|
|
670
|
+
return [...this.devtoolsFallback.getTools()];
|
|
671
|
+
}
|
|
672
|
+
findExtensionTool(session, toolName) {
|
|
673
|
+
if (!session) {
|
|
674
|
+
return undefined;
|
|
675
|
+
}
|
|
676
|
+
const key = normalizeToolName(toolName);
|
|
677
|
+
return session.tools.find((tool) => normalizeToolName(tool.name) === key);
|
|
678
|
+
}
|
|
679
|
+
findFallbackTool(toolName) {
|
|
680
|
+
const key = normalizeToolName(toolName);
|
|
681
|
+
return this.devtoolsFallback.getTools().find((tool) => normalizeToolName(tool.name) === key);
|
|
682
|
+
}
|
|
683
|
+
broadcastDefaultToolsToAgents(excludeAgentId) {
|
|
684
|
+
const defaultSession = this.getDefaultSession();
|
|
685
|
+
const tools = this.getToolsForSession(defaultSession);
|
|
686
|
+
this.broadcastToAgents({
|
|
687
|
+
type: 'tools_list',
|
|
688
|
+
data: tools,
|
|
689
|
+
sessionId: defaultSession?.sessionId,
|
|
690
|
+
}, excludeAgentId);
|
|
691
|
+
}
|
|
692
|
+
hasConnectedExtensionSession() {
|
|
693
|
+
return this.getDefaultSession() !== null;
|
|
694
|
+
}
|
|
695
|
+
sendSessionStateToAgent(ws) {
|
|
696
|
+
const sessions = this.buildSessionsList();
|
|
697
|
+
const defaultSession = this.getDefaultSession();
|
|
698
|
+
ws.send(JSON.stringify({
|
|
699
|
+
type: 'sessions_list',
|
|
700
|
+
sessions,
|
|
701
|
+
sessionIds: sessions.map((session) => session.sessionId),
|
|
702
|
+
connected: sessions.some((session) => session.connected),
|
|
703
|
+
sessionId: defaultSession?.sessionId,
|
|
704
|
+
}));
|
|
705
|
+
ws.send(JSON.stringify({
|
|
706
|
+
type: 'extension_status',
|
|
707
|
+
connected: sessions.some((session) => session.connected),
|
|
708
|
+
sessionIds: sessions.map((session) => session.sessionId),
|
|
709
|
+
sessionId: defaultSession?.sessionId,
|
|
710
|
+
}));
|
|
711
|
+
}
|
|
712
|
+
broadcastSessionState() {
|
|
713
|
+
const sessions = this.buildSessionsList();
|
|
714
|
+
const defaultSession = this.getDefaultSession();
|
|
715
|
+
this.broadcastToAgents({
|
|
716
|
+
type: 'sessions_list',
|
|
717
|
+
sessions,
|
|
718
|
+
sessionIds: sessions.map((session) => session.sessionId),
|
|
719
|
+
connected: sessions.some((session) => session.connected),
|
|
720
|
+
sessionId: defaultSession?.sessionId,
|
|
721
|
+
});
|
|
722
|
+
this.broadcastToAgents({
|
|
723
|
+
type: 'extension_status',
|
|
724
|
+
connected: sessions.some((session) => session.connected),
|
|
725
|
+
sessionIds: sessions.map((session) => session.sessionId),
|
|
726
|
+
sessionId: defaultSession?.sessionId,
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
rejectPendingRequestsForSession(sessionId, errorMessage) {
|
|
730
|
+
for (const [relayId, pending] of this.pendingRequests) {
|
|
731
|
+
if (pending.sessionId !== sessionId) {
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
this.pendingRequests.delete(relayId);
|
|
735
|
+
const agent = this.agents.get(pending.agentId);
|
|
736
|
+
if (agent && agent.ws.readyState === WebSocket.OPEN) {
|
|
737
|
+
agent.ws.send(JSON.stringify({
|
|
738
|
+
type: 'error',
|
|
739
|
+
requestId: pending.originalRequestId,
|
|
740
|
+
error: errorMessage,
|
|
741
|
+
sessionId,
|
|
742
|
+
}));
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
}
|
|
401
746
|
}
|
|
402
747
|
/**
|
|
403
748
|
* Check if relay is already running
|