chrome-ai-bridge 2.2.0 → 2.3.1
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 +1 -0
- package/build/src/config.js +11 -0
- package/build/src/main.js +171 -1
- package/build/src/process-lock.js +262 -0
- package/build/src/stdio-http-proxy.js +157 -0
- package/package.json +8 -3
package/README.md
CHANGED
|
@@ -263,6 +263,7 @@ npm run cdp:gemini
|
|
|
263
263
|
| [Technical Spec](docs/SPEC.md) | Detailed architecture and implementation |
|
|
264
264
|
| [Setup Guide](docs/user/setup.md) | Detailed MCP configuration |
|
|
265
265
|
| [Troubleshooting](docs/user/troubleshooting.md) | Problem solving |
|
|
266
|
+
| [CI Policy](docs/ci-policy.md) | Required checks and browser E2E lane policy |
|
|
266
267
|
| [Technical Spec - Architecture](docs/SPEC.md#1-architecture-overview) | Extension architecture |
|
|
267
268
|
|
|
268
269
|
---
|
package/build/src/config.js
CHANGED
|
@@ -36,6 +36,17 @@ export const GEMINI_CONFIG = {
|
|
|
36
36
|
*/
|
|
37
37
|
BASE_URL: 'https://gemini.google.com/',
|
|
38
38
|
};
|
|
39
|
+
/**
|
|
40
|
+
* IPC configuration for multi-client MCP support.
|
|
41
|
+
* The Primary instance exposes an HTTP endpoint on this port;
|
|
42
|
+
* Secondary instances connect as stdio-to-HTTP proxies.
|
|
43
|
+
*/
|
|
44
|
+
export const IPC_CONFIG = {
|
|
45
|
+
port: Number(process.env.CAI_IPC_PORT) || 9321,
|
|
46
|
+
host: '127.0.0.1',
|
|
47
|
+
healthPath: '/health',
|
|
48
|
+
mcpPath: '/mcp',
|
|
49
|
+
};
|
|
39
50
|
/**
|
|
40
51
|
* Get session configuration from environment variables or defaults.
|
|
41
52
|
*/
|
package/build/src/main.js
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
*
|
|
9
9
|
* This MCP server provides ChatGPT/Gemini integration via Chrome extension.
|
|
10
10
|
* Puppeteer has been removed - all browser interaction is via WebSocket relay.
|
|
11
|
+
*
|
|
12
|
+
* Multi-client: The first instance becomes Primary (stdio + IPC HTTP).
|
|
13
|
+
* Subsequent instances become Proxies that forward stdio to the Primary via HTTP.
|
|
11
14
|
*/
|
|
12
15
|
import assert from 'node:assert';
|
|
13
16
|
import fs from 'node:fs';
|
|
@@ -28,7 +31,9 @@ import { getFastContext } from './fast-cdp/fast-context.js';
|
|
|
28
31
|
import { cleanupAllConnections } from './fast-cdp/fast-chat.js';
|
|
29
32
|
import { generateAgentId, setAgentId } from './fast-cdp/agent-context.js';
|
|
30
33
|
import { cleanupStaleSessions } from './fast-cdp/session-manager.js';
|
|
31
|
-
import { getSessionConfig } from './config.js';
|
|
34
|
+
import { getSessionConfig, IPC_CONFIG } from './config.js';
|
|
35
|
+
import { acquireLock, releaseLock, killSiblings, checkExistingPrimary, updateLockPort } from './process-lock.js';
|
|
36
|
+
import { checkPrimaryHealth, startProxyMode } from './stdio-http-proxy.js';
|
|
32
37
|
function readPackageJson() {
|
|
33
38
|
const currentDir = import.meta.dirname;
|
|
34
39
|
const packageJsonPath = path.join(currentDir, '..', '..', 'package.json');
|
|
@@ -51,6 +56,28 @@ logger(`Starting Chrome AI Bridge v${version} (Extension-only mode)`);
|
|
|
51
56
|
// Initialize agent ID for Agent Teams support
|
|
52
57
|
const agentId = generateAgentId();
|
|
53
58
|
setAgentId(agentId);
|
|
59
|
+
// ─── Multi-client routing ───
|
|
60
|
+
// Check if a Primary instance is already running.
|
|
61
|
+
// If yes and healthy, enter proxy mode (never returns).
|
|
62
|
+
const existingPrimary = checkExistingPrimary();
|
|
63
|
+
if (existingPrimary && existingPrimary.port > 0) {
|
|
64
|
+
const healthy = await checkPrimaryHealth(existingPrimary.port);
|
|
65
|
+
if (healthy) {
|
|
66
|
+
logger(`[main] Primary is healthy (port=${existingPrimary.port}). Entering proxy mode.`);
|
|
67
|
+
await startProxyMode(existingPrimary.port); // never returns
|
|
68
|
+
}
|
|
69
|
+
logger(`[main] Primary (port=${existingPrimary.port}) not healthy. Starting as Primary.`);
|
|
70
|
+
}
|
|
71
|
+
// ─── Primary mode ───
|
|
72
|
+
// Kill all stale sibling processes first
|
|
73
|
+
const killed = await killSiblings();
|
|
74
|
+
if (killed > 0) {
|
|
75
|
+
logger(`[process-lock] Killed ${killed} stale sibling process(es)`);
|
|
76
|
+
}
|
|
77
|
+
// Generate a unique instance ID (survives PID reuse)
|
|
78
|
+
const instanceId = randomUUID();
|
|
79
|
+
// Acquire exclusive process lock (writes port + instanceId to lock file)
|
|
80
|
+
await acquireLock(IPC_CONFIG.port, instanceId);
|
|
54
81
|
// Start session cleanup timer
|
|
55
82
|
const sessionConfig = getSessionConfig();
|
|
56
83
|
const cleanupTimer = setInterval(async () => {
|
|
@@ -161,6 +188,145 @@ const transport = new StdioServerTransport();
|
|
|
161
188
|
await server.connect(transport);
|
|
162
189
|
logger('Chrome AI Bridge MCP Server connected');
|
|
163
190
|
logDisclaimers();
|
|
191
|
+
// ─── IPC HTTP server (for proxy clients) ───
|
|
192
|
+
{
|
|
193
|
+
const ipcTransports = {};
|
|
194
|
+
const ipcServer = http.createServer(async (req, res) => {
|
|
195
|
+
if (!req.url || !req.method) {
|
|
196
|
+
res.writeHead(400).end();
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const url = new URL(req.url, `http://${IPC_CONFIG.host}:${IPC_CONFIG.port}`);
|
|
200
|
+
// Health endpoint
|
|
201
|
+
if (url.pathname === IPC_CONFIG.healthPath) {
|
|
202
|
+
res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ status: 'ok', pid: process.pid, version, instanceId }));
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
// MCP endpoint
|
|
206
|
+
if (url.pathname !== IPC_CONFIG.mcpPath) {
|
|
207
|
+
res.writeHead(404).end();
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
// CORS for local usage
|
|
211
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
212
|
+
res.setHeader('Access-Control-Allow-Headers', 'content-type,mcp-session-id');
|
|
213
|
+
res.setHeader('Access-Control-Expose-Headers', 'mcp-session-id');
|
|
214
|
+
if (req.method === 'OPTIONS') {
|
|
215
|
+
res.writeHead(204).end();
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const sessionId = req.headers['mcp-session-id'];
|
|
219
|
+
if (req.method === 'POST') {
|
|
220
|
+
let body = '';
|
|
221
|
+
req.on('data', chunk => {
|
|
222
|
+
body += chunk;
|
|
223
|
+
});
|
|
224
|
+
req.on('end', async () => {
|
|
225
|
+
let json;
|
|
226
|
+
try {
|
|
227
|
+
json = body ? JSON.parse(body) : null;
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
res.writeHead(400).end(JSON.stringify({
|
|
231
|
+
jsonrpc: '2.0',
|
|
232
|
+
error: { code: -32700, message: 'Parse error' },
|
|
233
|
+
id: null,
|
|
234
|
+
}));
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
let ipcTransport;
|
|
238
|
+
if (sessionId && ipcTransports[sessionId]) {
|
|
239
|
+
ipcTransport = ipcTransports[sessionId];
|
|
240
|
+
}
|
|
241
|
+
else if (!sessionId && isInitializeRequest(json)) {
|
|
242
|
+
ipcTransport = new StreamableHTTPServerTransport({
|
|
243
|
+
sessionIdGenerator: () => randomUUID(),
|
|
244
|
+
onsessioninitialized: newSessionId => {
|
|
245
|
+
ipcTransports[newSessionId] = ipcTransport;
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
ipcTransport.onclose = () => {
|
|
249
|
+
if (ipcTransport?.sessionId) {
|
|
250
|
+
delete ipcTransports[ipcTransport.sessionId];
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
await server.connect(ipcTransport);
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
res.writeHead(400).end(JSON.stringify({
|
|
257
|
+
jsonrpc: '2.0',
|
|
258
|
+
error: {
|
|
259
|
+
code: -32000,
|
|
260
|
+
message: 'Bad Request: No valid session ID provided',
|
|
261
|
+
},
|
|
262
|
+
id: null,
|
|
263
|
+
}));
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
try {
|
|
267
|
+
await ipcTransport.handleRequest(req, res, json);
|
|
268
|
+
}
|
|
269
|
+
catch (error) {
|
|
270
|
+
if (!res.headersSent) {
|
|
271
|
+
res.writeHead(500).end(JSON.stringify({
|
|
272
|
+
jsonrpc: '2.0',
|
|
273
|
+
error: {
|
|
274
|
+
code: -32603,
|
|
275
|
+
message: error instanceof Error
|
|
276
|
+
? error.message
|
|
277
|
+
: String(error),
|
|
278
|
+
},
|
|
279
|
+
id: null,
|
|
280
|
+
}));
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
if (req.method === 'GET' || req.method === 'DELETE') {
|
|
287
|
+
if (!sessionId || !ipcTransports[sessionId]) {
|
|
288
|
+
res.writeHead(400).end('Invalid or missing session ID');
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
try {
|
|
292
|
+
await ipcTransports[sessionId].handleRequest(req, res);
|
|
293
|
+
}
|
|
294
|
+
catch (error) {
|
|
295
|
+
if (!res.headersSent) {
|
|
296
|
+
res.writeHead(500).end(JSON.stringify({
|
|
297
|
+
jsonrpc: '2.0',
|
|
298
|
+
error: {
|
|
299
|
+
code: -32603,
|
|
300
|
+
message: error instanceof Error ? error.message : String(error),
|
|
301
|
+
},
|
|
302
|
+
id: null,
|
|
303
|
+
}));
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
res.writeHead(405).end();
|
|
309
|
+
});
|
|
310
|
+
function onListening() {
|
|
311
|
+
const addr = ipcServer.address();
|
|
312
|
+
const actualPort = typeof addr === 'object' && addr ? addr.port : IPC_CONFIG.port;
|
|
313
|
+
if (actualPort !== IPC_CONFIG.port) {
|
|
314
|
+
logger(`[ipc] Configured port ${IPC_CONFIG.port} was unavailable. Using dynamic port ${actualPort}.`);
|
|
315
|
+
updateLockPort(actualPort);
|
|
316
|
+
}
|
|
317
|
+
logger(`[ipc] IPC HTTP listening on http://${IPC_CONFIG.host}:${actualPort} (health: ${IPC_CONFIG.healthPath}, mcp: ${IPC_CONFIG.mcpPath})`);
|
|
318
|
+
}
|
|
319
|
+
ipcServer.on('error', (err) => {
|
|
320
|
+
if (err.code === 'EADDRINUSE') {
|
|
321
|
+
logger(`[ipc] Port ${IPC_CONFIG.port} in use. Retrying with dynamic port...`);
|
|
322
|
+
ipcServer.listen(0, IPC_CONFIG.host, onListening);
|
|
323
|
+
}
|
|
324
|
+
else {
|
|
325
|
+
logger(`[ipc] IPC server error: ${err.message}`);
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
ipcServer.listen(IPC_CONFIG.port, IPC_CONFIG.host, onListening);
|
|
329
|
+
}
|
|
164
330
|
// Graceful shutdown handler with timeout
|
|
165
331
|
// Based on review: タイムアウト必須、強制終了タイマー必要
|
|
166
332
|
let isShuttingDown = false;
|
|
@@ -179,6 +345,8 @@ async function shutdown(reason) {
|
|
|
179
345
|
return;
|
|
180
346
|
isShuttingDown = true;
|
|
181
347
|
logger(`Shutting down: ${reason}`);
|
|
348
|
+
// Release lock early so a new instance can start immediately
|
|
349
|
+
releaseLock();
|
|
182
350
|
// Force exit timer (5 seconds) - prevents zombie if cleanup hangs
|
|
183
351
|
const forceExitTimer = setTimeout(() => {
|
|
184
352
|
logger('Graceful shutdown timed out. Forcing exit.');
|
|
@@ -207,10 +375,12 @@ process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
|
207
375
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
208
376
|
// Keep beforeExit for edge cases where stdin doesn't close
|
|
209
377
|
process.on('beforeExit', () => {
|
|
378
|
+
releaseLock();
|
|
210
379
|
if (logFile) {
|
|
211
380
|
logFile.close();
|
|
212
381
|
}
|
|
213
382
|
});
|
|
383
|
+
// ─── Optional: User-configured external HTTP server (MCP_HTTP_PORT) ───
|
|
214
384
|
const httpPortRaw = process.env.MCP_HTTP_PORT;
|
|
215
385
|
if (httpPortRaw) {
|
|
216
386
|
const httpPort = Number(httpPortRaw);
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process lock management using exclusive file lock.
|
|
3
|
+
*
|
|
4
|
+
* Lock file stores JSON: {pid, port, startedAt}
|
|
5
|
+
* Multi-client mode: alive processes are NOT killed — Secondary
|
|
6
|
+
* instances connect to the Primary via HTTP proxy instead.
|
|
7
|
+
*/
|
|
8
|
+
import { execFileSync } from 'node:child_process';
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { logger } from './logger.js';
|
|
13
|
+
const LOCK_DIR = path.join(os.homedir(), '.cache', 'chrome-ai-bridge');
|
|
14
|
+
const LOCK_FILE = path.join(LOCK_DIR, 'mcp.lock');
|
|
15
|
+
let lockFd = null;
|
|
16
|
+
function isProcessAlive(pid) {
|
|
17
|
+
try {
|
|
18
|
+
process.kill(pid, 0);
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function sleep(ms) {
|
|
26
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Read lock file content. Supports both JSON (new) and plain PID (legacy).
|
|
30
|
+
*/
|
|
31
|
+
export function readLockInfo() {
|
|
32
|
+
try {
|
|
33
|
+
const content = fs.readFileSync(LOCK_FILE, 'utf-8').trim();
|
|
34
|
+
if (!content)
|
|
35
|
+
return null;
|
|
36
|
+
// Try JSON format first
|
|
37
|
+
if (content.startsWith('{')) {
|
|
38
|
+
const parsed = JSON.parse(content);
|
|
39
|
+
if (parsed.pid && parsed.pid > 0) {
|
|
40
|
+
return {
|
|
41
|
+
pid: parsed.pid,
|
|
42
|
+
port: parsed.port ?? 0,
|
|
43
|
+
startedAt: parsed.startedAt ?? '',
|
|
44
|
+
instanceId: parsed.instanceId ?? '',
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
// Legacy: plain PID number
|
|
50
|
+
const pid = Number(content);
|
|
51
|
+
if (Number.isFinite(pid) && pid > 0) {
|
|
52
|
+
return { pid, port: 0, startedAt: '', instanceId: '' };
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Check if an existing Primary is alive and reachable.
|
|
62
|
+
* Reads the lock file and checks process liveness.
|
|
63
|
+
*/
|
|
64
|
+
export function checkExistingPrimary() {
|
|
65
|
+
const info = readLockInfo();
|
|
66
|
+
if (!info)
|
|
67
|
+
return null;
|
|
68
|
+
if (!isProcessAlive(info.pid)) {
|
|
69
|
+
logger(`[process-lock] Stale lock (pid=${info.pid}, not running).`);
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
return { alive: true, port: info.port, instanceId: info.instanceId };
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Try to create lock file exclusively (wx flag).
|
|
76
|
+
* Writes JSON {pid, port, startedAt}.
|
|
77
|
+
* Returns the file descriptor on success, null on EEXIST.
|
|
78
|
+
*/
|
|
79
|
+
function tryCreateLock(port, instanceId) {
|
|
80
|
+
try {
|
|
81
|
+
fs.mkdirSync(LOCK_DIR, { recursive: true });
|
|
82
|
+
const fd = fs.openSync(LOCK_FILE, 'wx');
|
|
83
|
+
const lockInfo = {
|
|
84
|
+
pid: process.pid,
|
|
85
|
+
port,
|
|
86
|
+
startedAt: new Date().toISOString(),
|
|
87
|
+
instanceId,
|
|
88
|
+
};
|
|
89
|
+
fs.writeSync(fd, JSON.stringify(lockInfo));
|
|
90
|
+
return fd;
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
if (error instanceof Error && 'code' in error && error.code === 'EEXIST') {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Handle an existing lock file.
|
|
101
|
+
* In multi-client mode, alive processes are NOT killed.
|
|
102
|
+
* Only stale (dead process) locks are removed.
|
|
103
|
+
* Returns true if the stale lock was removed and retry is possible.
|
|
104
|
+
* Returns false if the lock holder is alive (should enter proxy mode).
|
|
105
|
+
*/
|
|
106
|
+
async function handleExistingLock() {
|
|
107
|
+
const info = readLockInfo();
|
|
108
|
+
if (info === null) {
|
|
109
|
+
logger('[process-lock] Corrupted lock file found. Removing.');
|
|
110
|
+
try {
|
|
111
|
+
fs.unlinkSync(LOCK_FILE);
|
|
112
|
+
}
|
|
113
|
+
catch { /* ignore */ }
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
// Don't conflict with ourselves
|
|
117
|
+
if (info.pid === process.pid) {
|
|
118
|
+
try {
|
|
119
|
+
fs.unlinkSync(LOCK_FILE);
|
|
120
|
+
}
|
|
121
|
+
catch { /* ignore */ }
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
if (!isProcessAlive(info.pid)) {
|
|
125
|
+
logger(`[process-lock] Stale lock (pid=${info.pid}, not running). Removing.`);
|
|
126
|
+
try {
|
|
127
|
+
fs.unlinkSync(LOCK_FILE);
|
|
128
|
+
}
|
|
129
|
+
catch { /* ignore */ }
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
// Process is alive — do NOT kill. Caller should enter proxy mode.
|
|
133
|
+
logger(`[process-lock] Primary is alive (pid=${info.pid}, port=${info.port}). Cannot acquire lock.`);
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Acquire an exclusive process lock. Call once at startup for Primary mode.
|
|
138
|
+
*
|
|
139
|
+
* Flow:
|
|
140
|
+
* 1. Try fs.openSync(LOCK_FILE, 'wx') for atomic exclusive creation
|
|
141
|
+
* 2. Success -> write JSON {pid, port, startedAt}, hold FD
|
|
142
|
+
* 3. EEXIST -> check holder; remove only if stale, retry once
|
|
143
|
+
* 4. If holder is alive -> throw (caller should enter proxy mode)
|
|
144
|
+
*/
|
|
145
|
+
export async function acquireLock(port, instanceId) {
|
|
146
|
+
const fd = tryCreateLock(port, instanceId);
|
|
147
|
+
if (fd !== null) {
|
|
148
|
+
lockFd = fd;
|
|
149
|
+
logger(`[process-lock] Lock acquired (pid=${process.pid}, port=${port}, instanceId=${instanceId.slice(0, 8)})`);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
// Lock file exists - handle the existing holder
|
|
153
|
+
const canRetry = await handleExistingLock();
|
|
154
|
+
if (!canRetry) {
|
|
155
|
+
throw new Error('[process-lock] Primary is alive. Use proxy mode.');
|
|
156
|
+
}
|
|
157
|
+
// Retry once after stale removal
|
|
158
|
+
const fd2 = tryCreateLock(port, instanceId);
|
|
159
|
+
if (fd2 !== null) {
|
|
160
|
+
lockFd = fd2;
|
|
161
|
+
logger(`[process-lock] Lock acquired after cleanup (pid=${process.pid}, port=${port})`);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
throw new Error('[process-lock] Failed to acquire lock after retry');
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Update the port in an existing lock file (e.g. after dynamic port fallback).
|
|
168
|
+
* Rewrites the lock file content while keeping the FD open.
|
|
169
|
+
*/
|
|
170
|
+
export function updateLockPort(newPort) {
|
|
171
|
+
if (lockFd === null) {
|
|
172
|
+
logger('[process-lock] Cannot update port: no lock held.');
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const info = readLockInfo();
|
|
176
|
+
if (!info) {
|
|
177
|
+
logger('[process-lock] Cannot update port: lock file unreadable.');
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
info.port = newPort;
|
|
181
|
+
// Truncate and rewrite
|
|
182
|
+
fs.ftruncateSync(lockFd);
|
|
183
|
+
const buf = Buffer.from(JSON.stringify(info));
|
|
184
|
+
fs.writeSync(lockFd, buf, 0, buf.length, 0);
|
|
185
|
+
logger(`[process-lock] Lock port updated to ${newPort}`);
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Release the process lock. Call during shutdown.
|
|
189
|
+
*/
|
|
190
|
+
export function releaseLock() {
|
|
191
|
+
if (lockFd !== null) {
|
|
192
|
+
try {
|
|
193
|
+
fs.closeSync(lockFd);
|
|
194
|
+
}
|
|
195
|
+
catch { /* ignore */ }
|
|
196
|
+
lockFd = null;
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
fs.unlinkSync(LOCK_FILE);
|
|
200
|
+
}
|
|
201
|
+
catch { /* ignore */ }
|
|
202
|
+
logger('[process-lock] Lock released.');
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Kill all sibling chrome-ai-bridge processes (bulk cleanup).
|
|
206
|
+
*
|
|
207
|
+
* Uses pgrep to find processes matching 'chrome-ai-bridge/build/src/main.js',
|
|
208
|
+
* excludes self and parent, then SIGTERM -> wait -> SIGKILL survivors.
|
|
209
|
+
*
|
|
210
|
+
* Returns the number of processes killed.
|
|
211
|
+
* On pgrep failure (e.g. not installed), returns 0 silently.
|
|
212
|
+
*/
|
|
213
|
+
export async function killSiblings() {
|
|
214
|
+
let pids;
|
|
215
|
+
try {
|
|
216
|
+
const output = execFileSync('pgrep', ['-f', 'chrome-ai-bridge/build/src/main.js'], {
|
|
217
|
+
encoding: 'utf-8',
|
|
218
|
+
timeout: 5000,
|
|
219
|
+
});
|
|
220
|
+
pids = output.trim().split('\n')
|
|
221
|
+
.map(s => Number(s.trim()))
|
|
222
|
+
.filter(n => Number.isFinite(n) && n > 0);
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
// pgrep returns exit code 1 when no matches, or not available
|
|
226
|
+
return 0;
|
|
227
|
+
}
|
|
228
|
+
// Exclude self and parent (cli.mjs wrapper)
|
|
229
|
+
const selfPid = process.pid;
|
|
230
|
+
const parentPid = process.ppid;
|
|
231
|
+
const targets = pids.filter(pid => pid !== selfPid && pid !== parentPid);
|
|
232
|
+
if (targets.length === 0) {
|
|
233
|
+
return 0;
|
|
234
|
+
}
|
|
235
|
+
logger(`[process-lock] Found ${targets.length} stale sibling(s): ${targets.join(', ')}`);
|
|
236
|
+
// Send SIGTERM to all
|
|
237
|
+
for (const pid of targets) {
|
|
238
|
+
try {
|
|
239
|
+
process.kill(pid, 'SIGTERM');
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
// Process already gone
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
// Wait for graceful shutdown
|
|
246
|
+
await sleep(2000);
|
|
247
|
+
// SIGKILL survivors
|
|
248
|
+
let killed = 0;
|
|
249
|
+
for (const pid of targets) {
|
|
250
|
+
if (isProcessAlive(pid)) {
|
|
251
|
+
logger(`[process-lock] Process ${pid} still alive after SIGTERM. Sending SIGKILL...`);
|
|
252
|
+
try {
|
|
253
|
+
process.kill(pid, 'SIGKILL');
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
// ignore
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
killed++;
|
|
260
|
+
}
|
|
261
|
+
return killed;
|
|
262
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stdio-to-HTTP proxy for multi-client MCP support.
|
|
3
|
+
*
|
|
4
|
+
* When a Primary MCP server is already running, Secondary instances
|
|
5
|
+
* start in proxy mode: they bridge stdio (for Claude Code) to the
|
|
6
|
+
* Primary's Streamable HTTP endpoint.
|
|
7
|
+
*
|
|
8
|
+
* Uses MCP SDK transports to avoid custom JSON-RPC parsing.
|
|
9
|
+
*
|
|
10
|
+
* Resilience: On Primary disconnect, retries with exponential backoff
|
|
11
|
+
* (1s, 2s, 4s — max 3 attempts, ~7s total) before giving up.
|
|
12
|
+
*/
|
|
13
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
14
|
+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
15
|
+
import { logger } from './logger.js';
|
|
16
|
+
import { IPC_CONFIG } from './config.js';
|
|
17
|
+
import { readLockInfo } from './process-lock.js';
|
|
18
|
+
/**
|
|
19
|
+
* Check if the Primary's /health endpoint is reachable.
|
|
20
|
+
* Optionally verifies instanceId matches the lock file.
|
|
21
|
+
*/
|
|
22
|
+
export async function checkPrimaryHealth(port, expectedInstanceId) {
|
|
23
|
+
try {
|
|
24
|
+
const resp = await fetch(`http://${IPC_CONFIG.host}:${port}${IPC_CONFIG.healthPath}`, { signal: AbortSignal.timeout(2000) });
|
|
25
|
+
if (!resp.ok)
|
|
26
|
+
return false;
|
|
27
|
+
if (expectedInstanceId) {
|
|
28
|
+
const body = (await resp.json());
|
|
29
|
+
if (body.instanceId && body.instanceId !== expectedInstanceId) {
|
|
30
|
+
logger(`[proxy] instanceId mismatch: expected=${expectedInstanceId.slice(0, 8)}, got=${body.instanceId.slice(0, 8)}`);
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const RETRY_DELAYS = [1000, 2000, 4000]; // exponential backoff
|
|
41
|
+
/**
|
|
42
|
+
* Attempt to reconnect to the Primary with exponential backoff.
|
|
43
|
+
* Re-reads lock file on every attempt (follow-the-leader strategy):
|
|
44
|
+
* - Port may have changed (dynamic fallback)
|
|
45
|
+
* - instanceId may have changed (Primary restart)
|
|
46
|
+
* Returns the current port for reconnection.
|
|
47
|
+
*/
|
|
48
|
+
async function waitForPrimaryRecovery(initialPort) {
|
|
49
|
+
for (let i = 0; i < RETRY_DELAYS.length; i++) {
|
|
50
|
+
const delay = RETRY_DELAYS[i];
|
|
51
|
+
logger(`[proxy] Retry ${i + 1}/${RETRY_DELAYS.length} in ${delay}ms...`);
|
|
52
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
53
|
+
// Re-read lock file each attempt (port/instanceId may have changed)
|
|
54
|
+
const lockInfo = readLockInfo();
|
|
55
|
+
if (!lockInfo) {
|
|
56
|
+
logger('[proxy] Lock file missing or unreadable, retrying...');
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const currentPort = lockInfo.port || initialPort;
|
|
60
|
+
// Follow-the-leader: use lock file's instanceId as truth
|
|
61
|
+
const expectedId = lockInfo.instanceId || undefined;
|
|
62
|
+
if (await checkPrimaryHealth(currentPort, expectedId)) {
|
|
63
|
+
logger(`[proxy] Primary recovered (port=${currentPort})`);
|
|
64
|
+
return { recovered: true, port: currentPort };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return { recovered: false, port: initialPort };
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Start in proxy mode: bridge stdio <-> Primary HTTP.
|
|
71
|
+
*
|
|
72
|
+
* This function never returns normally — the process exits
|
|
73
|
+
* when stdin closes or the Primary becomes unreachable after retries.
|
|
74
|
+
*/
|
|
75
|
+
export async function startProxyMode(port) {
|
|
76
|
+
let currentPort = port;
|
|
77
|
+
const mcpUrl = new URL(`http://${IPC_CONFIG.host}:${currentPort}${IPC_CONFIG.mcpPath}`);
|
|
78
|
+
logger(`[proxy] Entering proxy mode -> ${mcpUrl}`);
|
|
79
|
+
const stdio = new StdioServerTransport();
|
|
80
|
+
let http = new StreamableHTTPClientTransport(mcpUrl);
|
|
81
|
+
let isReconnecting = false;
|
|
82
|
+
/**
|
|
83
|
+
* Try to reconnect to the Primary after disconnect.
|
|
84
|
+
* Follows the leader: adopts new port/instanceId from lock file.
|
|
85
|
+
* On success, creates a new HTTP transport and re-wires the bridge.
|
|
86
|
+
* On failure, exits with code 1.
|
|
87
|
+
*/
|
|
88
|
+
async function handlePrimaryDisconnect() {
|
|
89
|
+
if (isReconnecting)
|
|
90
|
+
return;
|
|
91
|
+
isReconnecting = true;
|
|
92
|
+
logger('[proxy] Primary disconnected. Attempting recovery...');
|
|
93
|
+
const result = await waitForPrimaryRecovery(currentPort);
|
|
94
|
+
if (!result.recovered) {
|
|
95
|
+
logger('[proxy] Primary not recovered after retries. Exiting.');
|
|
96
|
+
process.exit(1);
|
|
97
|
+
}
|
|
98
|
+
// Port may have changed after Primary restart
|
|
99
|
+
currentPort = result.port;
|
|
100
|
+
const newMcpUrl = new URL(`http://${IPC_CONFIG.host}:${currentPort}${IPC_CONFIG.mcpPath}`);
|
|
101
|
+
// Create new transport and re-wire
|
|
102
|
+
try {
|
|
103
|
+
const newHttp = new StreamableHTTPClientTransport(newMcpUrl);
|
|
104
|
+
wireHttpTransport(newHttp);
|
|
105
|
+
await newHttp.start();
|
|
106
|
+
http = newHttp;
|
|
107
|
+
isReconnecting = false;
|
|
108
|
+
logger(`[proxy] Reconnected to Primary (port=${currentPort})`);
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
logger(`[proxy] Reconnection failed: ${err}`);
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Wire event handlers for an HTTP transport instance.
|
|
117
|
+
*/
|
|
118
|
+
function wireHttpTransport(transport) {
|
|
119
|
+
transport.onmessage = (message) => {
|
|
120
|
+
stdio.send(message).catch((err) => {
|
|
121
|
+
logger(`[proxy] Failed to write to stdout: ${err}`);
|
|
122
|
+
});
|
|
123
|
+
};
|
|
124
|
+
transport.onclose = () => {
|
|
125
|
+
logger('[proxy] HTTP connection to Primary closed');
|
|
126
|
+
handlePrimaryDisconnect();
|
|
127
|
+
};
|
|
128
|
+
transport.onerror = (err) => {
|
|
129
|
+
logger(`[proxy] HTTP error: ${err.message}`);
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
// Bridge: stdin (Claude Code) -> HTTP POST (Primary)
|
|
133
|
+
stdio.onmessage = (message) => {
|
|
134
|
+
http.send(message).catch((err) => {
|
|
135
|
+
logger(`[proxy] Failed to forward to Primary: ${err}`);
|
|
136
|
+
handlePrimaryDisconnect();
|
|
137
|
+
});
|
|
138
|
+
};
|
|
139
|
+
// Handle stdio close (Claude Code disconnected)
|
|
140
|
+
stdio.onclose = () => {
|
|
141
|
+
logger('[proxy] stdio closed');
|
|
142
|
+
http
|
|
143
|
+
.terminateSession()
|
|
144
|
+
.catch(() => { })
|
|
145
|
+
.finally(() => http.close().catch(() => { }))
|
|
146
|
+
.finally(() => process.exit(0));
|
|
147
|
+
};
|
|
148
|
+
// Wire initial HTTP transport
|
|
149
|
+
wireHttpTransport(http);
|
|
150
|
+
// Start HTTP transport first (sets up AbortController),
|
|
151
|
+
// then stdio (starts reading from stdin).
|
|
152
|
+
await http.start();
|
|
153
|
+
await stdio.start();
|
|
154
|
+
logger('[proxy] Proxy mode active');
|
|
155
|
+
// Keep process alive; exit is handled by event handlers above.
|
|
156
|
+
return new Promise(() => { });
|
|
157
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chrome-ai-bridge",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.1",
|
|
4
4
|
"description": "MCP server bridging Chrome extension and AI assistants (ChatGPT, Gemini). Extension-only mode - no Puppeteer.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": "./scripts/cli.mjs",
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
"check-format": "eslint --cache . && prettier --check --cache .;",
|
|
20
20
|
"start": "npm run build && node build/src/index.js",
|
|
21
21
|
"start-debug": "DEBUG=mcp:* DEBUG_COLORS=false npm run build && node build/src/index.js",
|
|
22
|
-
"
|
|
22
|
+
"cleanup": "node scripts/cleanup.mjs",
|
|
23
|
+
"restart-mcp": "node scripts/cleanup.mjs",
|
|
23
24
|
"test:chatgpt": "npm run build && node scripts/test-fast-chat.mjs chatgpt",
|
|
24
25
|
"test:gemini": "npm run build && node scripts/test-fast-chat.mjs gemini",
|
|
25
26
|
"test:both": "npm run build && node scripts/test-fast-chat.mjs both",
|
|
@@ -34,7 +35,11 @@
|
|
|
34
35
|
"test:mcp:chatgpt": "npm run build && node scripts/test-mcp.mjs --chatgpt",
|
|
35
36
|
"test:mcp:gemini": "npm run build && node scripts/test-mcp.mjs --gemini",
|
|
36
37
|
"test:mcp:parallel": "npm run build && node scripts/test-mcp.mjs --parallel",
|
|
37
|
-
"test:network": "npm run build && node scripts/test-network-intercept.mjs"
|
|
38
|
+
"test:network": "npm run build && node scripts/test-network-intercept.mjs",
|
|
39
|
+
"test": "npm run build:noext && node scripts/test-mcp.mjs --tools-only",
|
|
40
|
+
"docs": "npm run build:noext && node --experimental-strip-types scripts/generate-docs.ts",
|
|
41
|
+
"generate-docs": "npm run docs",
|
|
42
|
+
"sync-server-json-version": "node --experimental-strip-types scripts/sync-server-json-version.ts"
|
|
38
43
|
},
|
|
39
44
|
"files": [
|
|
40
45
|
"build/src",
|