@agent360/browser-mcp 1.25.0 → 1.28.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/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * Agent360 Browser MCP Server
4
4
  *
5
5
  * Bridges Claude Code (stdio MCP) to Chrome Extension (WebSocket).
6
- * Auto-selects first available port in range 9876-9885 for multi-session support.
6
+ * Auto-selects first available port in range 9876-9895 for multi-session support.
7
7
  *
8
8
  * Architecture:
9
9
  * Claude Code ←(stdio)→ this process ←(WS :port)→ Offscreen Doc ←(sendMessage)→ Service Worker → Chrome APIs
@@ -11,12 +11,14 @@
11
11
 
12
12
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
13
13
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
14
+ import { ledErDoedt, forfaedreKaede } from './vagt.js';
14
15
  import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
15
16
  import { WebSocketServer } from 'ws';
16
- import { execSync } from 'child_process';
17
- import { dirname, join, resolve } from 'path';
17
+ import { execSync, execFile } from 'child_process';
18
+ import { dirname, join, resolve, sep } from 'path';
18
19
  import { fileURLToPath } from 'url';
19
- import { readFileSync, writeFileSync, mkdirSync } from 'fs';
20
+ import { homedir } from 'os';
21
+ import { readFileSync, writeFileSync, mkdirSync, appendFileSync } from 'fs';
20
22
  import { TOOLS, PROVIDER_PAGES } from './tools.js';
21
23
 
22
24
  // Read version from package.json — single source of truth, never drifts
@@ -29,33 +31,114 @@ const PKG_VERSION = JSON.parse(
29
31
  const __dirname = dirname(fileURLToPath(import.meta.url));
30
32
  const repoDir = dirname(__dirname); // parent of mcp-server/
31
33
 
34
+ // FJERNET 22/8: her koerte `git pull --ff-only` + `npm install` ved hver serveropstart,
35
+ // med cwd = pakkens foraeldremappe. I en npm-installation er det node_modules/@agent360/ —
36
+ // og git soeger OPAD, saa kaldet landede i BRUGERENS EGET repo. Maalt: fra
37
+ // node_modules/@agent360 opløser git toplevel til det omkringliggende projekt.
38
+ // En agent-session maatte altsaa ikke mutere brugerens git-trae uden samtykke.
39
+ // `npx @agent360/browser-mcp@latest` opdaterer allerede serveren; blokken var overfloedig.
32
40
  let extensionUpdated = false;
33
- try {
34
- const before = execSync('git rev-parse HEAD', { cwd: repoDir }).toString().trim();
35
- execSync('git pull --ff-only 2>/dev/null', { cwd: repoDir, timeout: 10000 });
36
- const after = execSync('git rev-parse HEAD', { cwd: repoDir }).toString().trim();
37
- if (before !== after) {
38
- extensionUpdated = true;
39
- process.stderr.write(`[MCP] Updated to ${after.slice(0, 8)} — extension reload recommended\n`);
40
- // Check if npm deps changed
41
- try {
42
- const diff = execSync(`git diff ${before} ${after} -- mcp-server/package.json`, { cwd: repoDir }).toString();
43
- if (diff) {
44
- execSync('npm install --silent', { cwd: `${repoDir}/mcp-server`, timeout: 30000 });
45
- process.stderr.write('[MCP] Dependencies updated\n');
46
- }
47
- } catch {}
48
- } else {
49
- process.stderr.write('[MCP] Already up to date\n');
50
- }
51
- } catch (e) {
52
- process.stderr.write(`[MCP] Auto-update skipped: ${e.message?.split('\n')[0]}\n`);
53
- }
54
41
 
55
42
  const BASE_PORT = 9876;
56
43
  const MAX_PORT = 9895; // 20 ports instead of 10 — zombies die within 5s via parent check
57
- let extensionSocket = null;
44
+
45
+ // ── Extension connections ───────────────────────────────────────────────────
46
+ // FEJL MAALT 21/8: her stod `let extensionSocket = null`, og hver ny forbindelse
47
+ // overskrev den. Er der to udgaver af udvidelsen indlaest i den samme Chrome —
48
+ // fx en "load unpacked"-kopi ved siden af en anden — scanner BEGGE de samme porte
49
+ // og forbinder til hver eneste server. Maalt med lsof: 2 ESTABLISHED forbindelser
50
+ // paa hver af de fire aktive porte. Kommandoerne gik til den der forbandt sidst,
51
+ // mens den anden holdt sit eget sessions-kort og sine egne fane-grupper — og et
52
+ // `terminate` fra den forkerte kopi lukkede serveren ned under den rigtige.
53
+ //
54
+ // Nu holdes alle forbindelser med deres identitet, kommandoer sendes kun til den
55
+ // nyeste udgave, og konflikten kan ses (browser_provide_feedback) i stedet for at
56
+ // vise sig som faner der "forsvinder".
57
+ const connections = new Set(); // { ws, seq, extensionId, version, name, since }
58
+ let connSeq = 0;
58
59
  let activePort = null;
60
+ let alleePorteOptaget = false; // hele spaendet i brug — se createWSS
61
+
62
+ function cmpVersion(a, b) {
63
+ const pa = String(a || '0.0.0').split('.').map(n => parseInt(n, 10) || 0);
64
+ const pb = String(b || '0.0.0').split('.').map(n => parseInt(n, 10) || 0);
65
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
66
+ const d = (pa[i] || 0) - (pb[i] || 0);
67
+ if (d) return d > 0 ? 1 : -1;
68
+ }
69
+ return 0;
70
+ }
71
+
72
+ let sidsteKonfliktNoegle = '';
73
+ function advarOmKonflikt(conn) {
74
+ const alle = distinctExtensions();
75
+ if (alle.length < 2) return;
76
+ // Samme konflikt maa ikke skrige ved hver eneste hello — kun naar billedet aendrer sig.
77
+ const noegle = alle.map(c => `${c.extensionId || 'ukendt'}@${c.version || '?'}`).sort().join('|');
78
+ if (noegle === sidsteKonfliktNoegle) return;
79
+ sidsteKonfliktNoegle = noegle;
80
+ const aktiv = activeConnection();
81
+ // Oplyser INGEN af dem en version (alle udgivne udgaver er fra foer haandtrykket),
82
+ // er der intet grundlag for at vaelge. Det skal staa der — ellers laeser man
83
+ // "kommandoer sendes kun til X" som om X var det rigtige valg.
84
+ const kanVaelge = alle.some(c => c.version);
85
+ process.stderr.write(
86
+ `[MCP] ADVARSEL: ${alle.length} Browser MCP-udvidelser er forbundet til denne server samtidig ` +
87
+ `(${alle.map(c => `${c.extensionId || 'ukendt id'}${c.version ? ' v' + c.version : ''}`).join(', ')}). ` +
88
+ 'De deler faner og sessions-tilstand, saa faner kan se ud til at forsvinde. ' +
89
+ (kanVaelge
90
+ ? `Kommandoer sendes kun til den nyeste (${aktiv?.extensionId}). `
91
+ : `Ingen af dem oplyser sin version, saa valget (${aktiv?.extensionId}) er vilkaarligt og kan skifte. `) +
92
+ 'Ret det ved at slaa alle paa naer én fra paa chrome://extensions.\n',
93
+ );
94
+ }
95
+
96
+ function liveConnections() {
97
+ return [...connections].filter(c => c.ws.readyState === 1);
98
+ }
99
+
100
+ // Valget LAASES for serverens levetid.
101
+ //
102
+ // MAALT 21/8 i flow-harnessen: uden laasen skiftede den aktive udvidelse MIDT i en
103
+ // koersel. Foerste kommando (navigate) gik til udvidelse A, som aabnede fanen. Et
104
+ // oejeblik senere forbandt udvidelse B og overtog, fordi den var nyere i raekken —
105
+ // men B kendte ikke A's fane og lavede en frisk about:blank. Alt derefter fejlede med
106
+ // "Cannot access contents of url about:blank". 21 af 43 vaerktoejer faldt paa det, og
107
+ // symptomet lignede praecis "faner forsvinder" og "kun én session virker".
108
+ //
109
+ // Faner hoerer til den udvidelse der aabnede dem. Skifter man udvidelse, strander de.
110
+ // Derfor: vaelg én gang, og bliv ved den saa laenge dens forbindelse lever. Doer den,
111
+ // vaelges der forfra — det er en aegte genopretning, ikke et vilkaarligt skift.
112
+ let laastForbindelse = null;
113
+ let harSendtKommando = false;
114
+ const PINNET_UDVIDELSE = (process.env.BROWSER_MCP_EXTENSION_ID || '').trim() || null;
115
+
116
+ function activeConnection() {
117
+ if (laastForbindelse && laastForbindelse.ws.readyState === 1) return laastForbindelse;
118
+
119
+ // Nyeste udvidelse vinder. Ved uafgjort: den der forbandt sidst. En udvidelse fra
120
+ // foer haandtrykket har ingen version og taber til en der har én — haandtrykket kom
121
+ // med den nyere udgave.
122
+ let best = null;
123
+ for (const c of liveConnections()) {
124
+ if (!best) { best = c; continue; }
125
+ const d = cmpVersion(c.version, best.version);
126
+ if (d > 0 || (d === 0 && c.since > best.since)) best = c;
127
+ }
128
+ laastForbindelse = best;
129
+ return best;
130
+ }
131
+
132
+ // Én post pr. udvidelse. En udvidelse uden identitet taelles for sig selv (dens
133
+ // socket er noeglen), saa to gamle kopier stadig ses som to.
134
+ function distinctExtensions() {
135
+ const byKey = new Map();
136
+ for (const c of liveConnections()) {
137
+ const key = c.extensionId || `legacy:${c.seq}`;
138
+ if (!byKey.has(key)) byKey.set(key, c);
139
+ }
140
+ return [...byKey.values()];
141
+ }
59
142
  let wss = null; // Track WSS for graceful shutdown
60
143
  let cmdId = 0;
61
144
  let lastActivity = Date.now();
@@ -68,7 +151,26 @@ let parentCheck = null;
68
151
  // ── WebSocket Server ───────────────────────────────────────────────────────
69
152
 
70
153
  function createWSS(port = BASE_PORT) {
71
- const server = new WebSocketServer({ host: '127.0.0.1', port });
154
+ const server = new WebSocketServer({
155
+ host: '127.0.0.1',
156
+ port,
157
+ // Afvis allerede i HAANDTRYKKET, ikke efter. MAALT 23/8: lukkede vi foerst
158
+ // forbindelsen inde i 'connection', naaede den fremmede at faa en aaben socket
159
+ // (og et 101-svar) foer den blev smidt ud. Med verifyClient faar den 401 og
160
+ // ingen socket overhovedet.
161
+ //
162
+ // Chrome saetter ALTID Origin: chrome-extension://<32 tegn> paa en WebSocket fra
163
+ // en udvidelse — verificeret mod den koerende. Alt andet er per definition ikke
164
+ // en udvidelse, saa gaten koster aegte brugere ingenting.
165
+ verifyClient: ({ origin }, godkend) => {
166
+ if (/^chrome-extension:\/\/[a-p]{32}$/.test(origin || '')) return godkend(true);
167
+ process.stderr.write(
168
+ `[MCP] Afviser opkobling uden gyldig chrome-extension-Origin` +
169
+ `${origin ? ` (fik "${String(origin).slice(0, 60)}")` : ' (ingen Origin-header)'}\n`,
170
+ );
171
+ godkend(false, 401, 'only chrome extensions may connect');
172
+ },
173
+ });
72
174
  wss = server;
73
175
 
74
176
  server.on('error', (err) => {
@@ -77,6 +179,13 @@ function createWSS(port = BASE_PORT) {
77
179
  process.stderr.write(`[MCP] Port ${port} in use, trying ${port + 1}...\n`);
78
180
  createWSS(port + 1);
79
181
  } else {
182
+ // MAALT 22/8: her stod KUN denne stderr-linje. Ingen laeser stderr fra en
183
+ // MCP-server, saa udtoemte porte var en helt tavs fejl. Hvert vaerktoejskald
184
+ // fejlede bagefter med "extension not connected" — en tekst der oven i koebet
185
+ // siger at serveren koerer og sender brugeren til Chrome Web Store. Begge dele
186
+ // er forkerte naar sandheden er at vi aldrig fik en port. Flaget laeses i
187
+ // sendToExtension, saa agenten kan give brugeren den rigtige forklaring.
188
+ alleePorteOptaget = true;
80
189
  process.stderr.write(`[MCP] All ports ${BASE_PORT}-${MAX_PORT} in use. Cannot start.\n`);
81
190
  }
82
191
  } else {
@@ -84,9 +193,57 @@ function createWSS(port = BASE_PORT) {
84
193
  }
85
194
  });
86
195
 
87
- server.on('connection', (ws) => {
88
- extensionSocket = ws;
89
- process.stderr.write(`[MCP] Chrome extension connected on port ${port}\n`);
196
+ server.on('connection', (ws, req) => {
197
+ // WebSocket-handshaket fra en udvidelse baerer Origin: chrome-extension://<id>.
198
+ // Den identificerer afsenderen UDEN at udvidelsen behoever at kende haandtrykket,
199
+ // saa en konflikt mellem to indlaeste udvidelser kan opdages ogsaa naar begge er
200
+ // gamle udgaver — hvilket er praecis den situation konflikten opstaar i.
201
+ // Maalt 21/8: to distinkte origins ringede op til hver eneste server.
202
+ const origin = req?.headers?.origin || '';
203
+ const fraOrigin = /^chrome-extension:\/\/([a-p]{32})$/.exec(origin)?.[1] || null;
204
+
205
+ // ── Kun Chrome-udvidelser lukkes ind (MAALT 23/8) ─────────────────────────
206
+ //
207
+ // Reproduceret med en raa WebSocket-klient mod en aegte server: en klient der
208
+ // simpelthen UDELOD Origin-headeren blev accepteret, vandt rollen som aktiv
209
+ // udvidelse med `hello version 99.0.0`, fik `browser_get_cookies` leveret, og
210
+ // kunne lukke serveren med `terminate`. Alle tre trin lykkedes.
211
+ //
212
+ // Serveren lytter kun paa 127.0.0.1, saa angriberen skal koere lokalt — men det
213
+ // goer enhver anden app og ethvert npm-postinstall-script. Og hvad den kan er
214
+ // ikke smaating: laese alt agenten sender til browseren (kodeord fra ask_user,
215
+ // cookies, sidetekst), fodre agenten med opdigtet sideindhold, og slukke
216
+ // browser-adgangen i alle aabne chats.
217
+ //
218
+ // Chrome saetter ALTID `Origin: chrome-extension://<id>` paa en WebSocket fra en
219
+ // udvidelse — verificeret mod den koerende udvidelse. En manglende header er
220
+ // derfor ikke en aeldre udgave; det er noget andet end en udvidelse.
221
+ if (!fraOrigin) {
222
+ process.stderr.write(
223
+ "[MCP] Afviser forbindelse uden gyldig chrome-extension-Origin" +
224
+ (origin ? ` (fik "${origin.slice(0, 60)}")` : " (ingen Origin-header)") + "\n",
225
+ );
226
+ try { ws.close(1008, "only chrome extensions may connect"); } catch {}
227
+ return;
228
+ }
229
+
230
+ // Noedudgang naar flere udvidelser er indlaest og brugeren ikke kan eller vil
231
+ // slaa dem fra: BROWSER_MCP_EXTENSION_ID=<id> binder serveren til én bestemt.
232
+ // Uden den er valget vilkaarligt naar ingen af dem oplyser en version.
233
+ if (PINNET_UDVIDELSE && fraOrigin && fraOrigin !== PINNET_UDVIDELSE) {
234
+ process.stderr.write(`[MCP] Afviser udvidelse ${fraOrigin} — bundet til ${PINNET_UDVIDELSE}\n`);
235
+ try { ws.close(1008, 'not the pinned extension'); } catch {}
236
+ return;
237
+ }
238
+
239
+ const conn = { ws, seq: ++connSeq, extensionId: fraOrigin, version: null, name: null,
240
+ harHilst: false, helloId: null, since: Date.now() };
241
+ connections.add(conn);
242
+ // Har vi endnu ikke sendt en eneste kommando, er ingen faner i spil, og en
243
+ // nytilkommen udvidelse maa gerne komme i betragtning igen.
244
+ if (!harSendtKommando) laastForbindelse = null;
245
+ process.stderr.write(`[MCP] Chrome extension connected on port ${port}${fraOrigin ? ` (${fraOrigin})` : ''}\n`);
246
+ advarOmKonflikt(conn);
90
247
 
91
248
  // If extension was auto-updated, trigger reload
92
249
  if (process.env.BROWSER_MCP_EXTENSION_UPDATED === '1') {
@@ -101,7 +258,53 @@ function createWSS(port = BASE_PORT) {
101
258
  let msg;
102
259
  try { msg = JSON.parse(data.toString()); } catch { return; }
103
260
 
261
+ // Identitets-haandtryk fra offscreen-dokumentet (v1.28+).
262
+ if (msg.type === 'hello') {
263
+ // MAALT 23/8: her stod `conn.extensionId = msg.extensionId` — altsaa lod
264
+ // haandtrykket afsenderen OVERSKRIVE sin egen identitet med hvad som helst.
265
+ // Origin-headeren er den eneste kilde Chrome selv saetter og som afsenderen
266
+ // ikke kan forfalske, saa den vinder. Beskedens id gemmes separat: stemmer de
267
+ // ikke overens, er noget galt, og saa maa forbindelsen ikke lukke serveren ned.
268
+ conn.helloId = typeof msg.extensionId === 'string' ? msg.extensionId : null;
269
+ conn.harHilst = true;
270
+ if (conn.helloId && conn.helloId !== conn.extensionId) {
271
+ process.stderr.write(
272
+ `[MCP] Haandtryk oplyser ${conn.helloId} men Origin siger ${conn.extensionId} — ` +
273
+ 'bruger Origin\n',
274
+ );
275
+ }
276
+ conn.version = typeof msg.version === 'string' ? msg.version : null;
277
+ conn.name = typeof msg.name === 'string' ? msg.name : null;
278
+ advarOmKonflikt(conn);
279
+ return;
280
+ }
281
+
104
282
  if (msg.type === 'terminate') {
283
+ // terminate lukker serveren for ALLE chats paa porten, saa den har to gates.
284
+ //
285
+ // Om id-sammenligningen: ORIGIN er autoriteten — Chrome saetter den, og
286
+ // afsenderen kan ikke forfalske den. Haandtrykkets id er kun et ekstra
287
+ // signal. MAALT 23/8 mod den AEGTE udvidelse: den sender hello med id null,
288
+ // fordi chrome.runtime.getManifest() kan fejle i offscreen-dokumentet.
289
+ // Kraevede vi lighed ubetinget, kunne en HELT legitim udvidelse aldrig lukke
290
+ // sin session ned — en fejl jeg selv indfoerte samme aften. Derfor: et id der
291
+ // MANGLER er fint (Origin har allerede bevist hvem det er), mens et id der er
292
+ // TIL STEDE og peger et ANDET sted afvises.
293
+ //
294
+ // 1) Afsenderen skal have sendt et hello der stemmer med sin egen Origin.
295
+ // MAALT 23/8: uden den kunne en forbindelse der lige havde vundet rollen
296
+ // som aktiv slukke browser-adgangen med én besked.
297
+ // 2) Afsenderen skal VAERE den aktive. Uden den kunne en gammel sidelaebende
298
+ // kopi, der lukkede sin sidste fane, rive serveren vaek under den
299
+ // udvidelse der reelt loeste opgaven.
300
+ if (!conn.harHilst || (conn.helloId && conn.helloId !== conn.extensionId)) {
301
+ process.stderr.write('[MCP] terminate ignoreret — intet gyldigt haandtryk\n');
302
+ return;
303
+ }
304
+ if (activeConnection() !== conn) {
305
+ process.stderr.write('[MCP] terminate ignoreret — kom fra en inaktiv udvidelses-forbindelse\n');
306
+ return;
307
+ }
105
308
  gracefulShutdown('Terminate signal from extension (last tab closed)');
106
309
  return;
107
310
  }
@@ -115,11 +318,41 @@ function createWSS(port = BASE_PORT) {
115
318
  else p.resolve(result);
116
319
  });
117
320
 
118
- ws.on('close', () => {
119
- if (extensionSocket === ws) {
120
- extensionSocket = null;
121
- process.stderr.write(`[MCP] Chrome extension disconnected\n`);
321
+ // ── En doed socket skal AFVISE de kald der var undervejs (MAALT 23/8) ──────
322
+ // Foer roerte close-handleren ikke `pending`. Maalt: en socket der doede 300 ms
323
+ // inde i et kald gav foerst svar efter 30.011 ms — og med den FORKERTE
324
+ // forklaring, "kommandoen tog for lang tid". For extract_list er timeouten 180
325
+ // sekunder, altsaa tre minutters tavshed hvor sandheden var kendt med det samme.
326
+ const afvisVentende = (grund) => {
327
+ // Kun naar ingen anden levende forbindelse kan svare — ellers ville et helt
328
+ // normalt skift mellem to udvidelser afbryde kald der er fuldt i orden.
329
+ if (!pending.size || liveConnections().length) return;
330
+ const antal = pending.size;
331
+ for (const [id, p] of pending) {
332
+ clearTimeout(p.timer);
333
+ pending.delete(id);
334
+ p.reject(new Error(
335
+ `Forbindelsen til Chrome-udvidelsen forsvandt mens kommandoen koerte (${grund}). ` +
336
+ 'Kommandoen naaede maaske at blive udfoert i browseren — tjek tilstanden foer du ' +
337
+ 'proever igen. Er udvidelsen slaaet fra eller Chrome lukket, saa start den og proev forfra.',
338
+ ));
122
339
  }
340
+ process.stderr.write(`[MCP] ${antal} ventende kald afvist — ${grund}\n`);
341
+ };
342
+
343
+ // MAALT 23/8: der fandtes INGEN error-handler. Et ugyldigt WebSocket-frame (fx
344
+ // RSV1 sat) faar 'ws' til at emitte 'error' paa socketen, og en uhaandteret
345
+ // 'error' paa en EventEmitter kaster og draeber hele processen — altsaa alle
346
+ // chats paa den port. Tre linjer lukker det.
347
+ ws.on('error', (e) => {
348
+ process.stderr.write(`[MCP] WebSocket-fejl paa forbindelsen: ${e?.message || e}\n`);
349
+ try { ws.close(); } catch {}
350
+ });
351
+
352
+ ws.on('close', () => {
353
+ connections.delete(conn);
354
+ afvisVentende('udvidelsen koblede fra');
355
+ process.stderr.write(`[MCP] Chrome extension disconnected (${liveConnections().length} tilbage)\n`);
123
356
  });
124
357
  });
125
358
 
@@ -130,9 +363,7 @@ function createWSS(port = BASE_PORT) {
130
363
 
131
364
  // Heartbeat + idle timeout (4 hours) — hoisted to module scope so gracefulShutdown can clear it
132
365
  heartbeat = setInterval(() => {
133
- if (extensionSocket && extensionSocket.readyState === 1) {
134
- extensionSocket.ping();
135
- }
366
+ for (const c of liveConnections()) c.ws.ping();
136
367
  if (Date.now() - lastActivity > 4 * 60 * 60 * 1000) {
137
368
  gracefulShutdown('Idle timeout (4h)');
138
369
  }
@@ -145,12 +376,35 @@ createWSS();
145
376
 
146
377
  async function sendToExtension(method, params = {}, timeoutMs = 30000, _retries = 5) {
147
378
  // Retry if extension is temporarily disconnected (reconnects every 2s)
148
- if (!extensionSocket || extensionSocket.readyState !== 1) {
379
+ const conn = activeConnection();
380
+ if (!conn) {
149
381
  if (_retries > 0) {
150
382
  await new Promise(r => setTimeout(r, 1500));
151
383
  return sendToExtension(method, params, timeoutMs, _retries - 1);
152
384
  }
153
- throw new Error('Chrome extension not connected after 5 retries. Open Chrome and ensure Agent360 Browser MCP extension is installed.');
385
+ // This is the other half of the two-part setup: the server is clearly running (it is
386
+ // throwing this), so what is missing is the extension, Chrome itself, or the connection
387
+ // between them. Say which, and where to get it — the agent relays this text to the user.
388
+ if (alleePorteOptaget) {
389
+ throw new Error(
390
+ `Alle porte ${BASE_PORT}-${MAX_PORT} er optaget, saa denne server fik aldrig en port. ` +
391
+ 'Det er IKKE et problem med Chrome eller udvidelsen — de virker fint.\n' +
392
+ `Du har ${MAX_PORT - BASE_PORT + 1} chats i gang der bruger browseren samtidig. ` +
393
+ 'Luk en af dem — pladsen frigives inden for faa sekunder — og genstart saa denne chat.\n' +
394
+ 'Sig praecis dét til brugeren. Sig IKKE at udvidelsen mangler.',
395
+ );
396
+ }
397
+ throw new Error(
398
+ 'Chrome extension not connected after 5 retries.\n' +
399
+ 'Browser MCP needs BOTH halves: this MCP server (running) and the Agent360 Browser MCP ' +
400
+ 'Chrome extension (apparently not reachable).\n' +
401
+ 'Check, in order:\n' +
402
+ ' 1. Chrome is actually open and running.\n' +
403
+ ' 2. The extension is installed and enabled at chrome://extensions — install it from\n' +
404
+ ' https://chromewebstore.google.com/detail/agent360-browser-mcp/jdehgalffmffhfhmmhaokfbfnafnmgcl\n' +
405
+ ' 3. Click the extension icon -> Reconnect, and wait 2-3 seconds.\n' +
406
+ 'Still stuck: https://browsermcp.dev/docs/troubleshooting/'
407
+ );
154
408
  }
155
409
  return new Promise((resolve, reject) => {
156
410
  const id = ++cmdId;
@@ -159,7 +413,10 @@ async function sendToExtension(method, params = {}, timeoutMs = 30000, _retries
159
413
  reject(new Error(`Command timed out after ${timeoutMs}ms: ${method}`));
160
414
  }, timeoutMs);
161
415
  pending.set(id, { resolve, reject, timer });
162
- extensionSocket.send(JSON.stringify({ id, method, params }));
416
+ // pid = Claude Code-processen der ejer denne server. Udvidelsen bruger den til at
417
+ // skelne 'samme chat, ny forbindelse' fra 'en anden chat' naar den adopterer sessioner.
418
+ harSendtKommando = true;
419
+ conn.ws.send(JSON.stringify({ id, method, params, pid: process.ppid }));
163
420
  });
164
421
  }
165
422
 
@@ -244,13 +501,27 @@ If the extension files were updated, ask the user to reload it:
244
501
  "The Browser MCP extension was updated. Please go to chrome://extensions, find 'Agent360 Browser MCP', and click the reload icon (🔄) to apply the update."
245
502
  You cannot navigate to chrome:// pages — the user must do this manually.
246
503
 
504
+ ## When Browser MCP itself is the obstacle — call browser_provide_feedback
505
+ Before you tell the user "browser-mcp can't do X", or give up on a tool that errored, timed out, or did something other than what it promised: call **browser_provide_feedback** with what_happened. Do it yourself, without asking. It checks the install first — this server against the latest on npm, the extension against this server, and whether more than one Browser MCP extension is connected at once — because those three explain most "it's broken" moments, and none of them are visible to you otherwise. Read the verdict:
506
+ - **outdated** or **disconnected** → relay fix_steps to the user, then retry the action. An outdated install explains most failures, so try the fix first — but if the behaviour still looks wrong after that, report it anyway. A release window is not a reason to stay silent about a real bug.
507
+ - **conflict** → more than one extension is loaded; tabs and sessions will keep behaving randomly until the user disables the extras at chrome://extensions. Say so plainly.
508
+ - **current** → the install is fine, so this is a genuine gap. Offer the returned submit_url as a clickable link.
509
+ - **unknown** → nothing local looks wrong, but the npm comparison was skipped (it is opt-in via BROWSER_MCP_CHECK_NPM=1). Treat it like **current**.
510
+ Call it once per distinct obstacle, not once per retry.
511
+
247
512
  ## Sharing wishes / use-cases / bugs
248
513
  Browser MCP is community-shaped. Whenever the user (a) says "I wish browser-mcp could…" or expresses a missing feature, (b) hits something broken or unexpected that looks like a bug in browser-mcp itself, or (c) describes something cool they built with it — call **browser_about** with the matching intent ("wish" / "use_case" / "bug") and a short title + body, then offer the returned submit_url to the user as a clickable link. Don't ask permission, just draft + offer the link. This is the user's path to contribute back, and they usually don't know it exists.`;
249
514
 
515
+ // FEJL MAALT 21/8: `instructions` laa i et TREDJE argument. Server-konstruktoeren
516
+ // tager kun to (serverInfo, options), saa JavaScript smed objektet vaek i tavshed —
517
+ // og hele blokken ovenfor naaede aldrig frem til nogen klient. Verificeret ved at
518
+ // laese initialize-svaret: det havde kun protocolVersion, capabilities og serverInfo.
519
+ // Det betyder at "luk altid faner naar du er faerdig", CAPTCHA-fremgangsmaaden,
520
+ // tekst-selektor-vejledningen og resten aldrig har styret nogen agent. Nu ligger
521
+ // instructions i SAMME options-objekt som capabilities, hvor SDK'en laeser den.
250
522
  const mcpServer = new Server(
251
523
  { name: 'agent360-browser', version: PKG_VERSION },
252
- { capabilities: { tools: {} } },
253
- { instructions: INSTRUCTIONS },
524
+ { capabilities: { tools: {} }, instructions: INSTRUCTIONS },
254
525
  );
255
526
 
256
527
  mcpServer.setRequestHandler(ListToolsRequestSchema, async () => ({
@@ -295,12 +566,21 @@ mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
295
566
  browser_dismiss_overlays: 'dismiss_overlays',
296
567
  browser_set_combobox: 'set_combobox',
297
568
  browser_drop_file: 'drop_file',
569
+ browser_double_click: 'double_click',
570
+ browser_right_click: 'right_click',
571
+ browser_click_xy: 'click_xy',
572
+ browser_reattach_debugger: 'reattach_debugger',
573
+ browser_extract_list: 'extract_list',
298
574
  };
299
575
 
300
576
  if (name === 'browser_about') {
301
577
  return handleAbout(args);
302
578
  }
303
579
 
580
+ if (name === 'browser_provide_feedback') {
581
+ return await handleProvideFeedback(args);
582
+ }
583
+
304
584
  if (name === 'browser_extract_token') {
305
585
  return await handleExtractToken(args);
306
586
  }
@@ -310,8 +590,11 @@ mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
310
590
  return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true };
311
591
  }
312
592
 
593
+ // extract_list scrolls a container in a loop (up to 300 rounds × wait_ms), so the 30 s
594
+ // default would kill a long mail list mid-walk and report a partial set as complete.
313
595
  const timeout = method === 'ask_user' ? (args?.timeout || 120000) + 5000 :
314
- method === 'solve_captcha' ? 60000 : 30000;
596
+ method === 'solve_captcha' ? 60000 :
597
+ method === 'extract_list' ? 180000 : 30000;
315
598
  const result = await sendToExtension(method, args || {}, timeout);
316
599
 
317
600
  if (name === 'browser_screenshot' && result?.image) {
@@ -321,7 +604,18 @@ mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
321
604
  const base64 = result.image.replace(prefix, '');
322
605
 
323
606
  if (args && args.path) {
324
- const targetPath = resolve(process.cwd(), args.path);
607
+ // MAALT 23/8: ingen indeslutning. En sti med ../../.. skrev til
608
+ // /private/tmp/a360-udenfor/x.png. Argumenterne kommer fra en model der laeser
609
+ // FREMMEDE websider, saa en prompt-injektion paa en vilkaarlig side kunne
610
+ // overskrive en fil i brugerens hjemmemappe med PNG-bytes.
611
+ const rod = resolve(process.cwd());
612
+ const targetPath = resolve(rod, args.path);
613
+ if (targetPath !== rod && !targetPath.startsWith(rod + sep)) {
614
+ throw new Error(
615
+ `path skal ligge inden for arbejdsmappen (${rod}). ` +
616
+ `"${args.path}" peger udenfor. Brug en relativ sti uden ../.`,
617
+ );
618
+ }
325
619
  mkdirSync(dirname(targetPath), { recursive: true });
326
620
  writeFileSync(targetPath, Buffer.from(base64, 'base64'));
327
621
  return {
@@ -351,12 +645,45 @@ mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
351
645
  return response;
352
646
  } catch (err) {
353
647
  return {
354
- content: [{ type: 'text', text: `Error: ${err.message}` }],
648
+ content: [{ type: 'text', text: forklarSkaevhed(err.message) }],
355
649
  isError: true,
356
650
  };
357
651
  }
358
652
  });
359
653
 
654
+ // Naar udvidelsen er aeldre end serveren, svarer den `Unknown method: X` — og det er
655
+ // alt brugeren ser. Det sker GARANTERET: serveren kommer fra npm og opdateres straks,
656
+ // mens udvidelsen skal gennem Chrome Web Stores review paa 1-3 dage. I det vindue
657
+ // findes otte vaerktoejer i serveren som en 1.25.0-udvidelse ikke kender
658
+ // (click_xy, double_click, right_click, extract_list, reattach_debugger og de tre
659
+ // udklipsholder-vaerktoejer, alle fra v1.26.0).
660
+ //
661
+ // Serveren VED at udvidelsen er gammel: den sendte intet haandtryk. Saa i stedet for
662
+ // en gaadefuld fejl faar brugeren at vide hvorfor — og hvad de kan goere imens.
663
+ const ERSTATNINGER = {
664
+ double_click: 'kald `browser_click` to gange',
665
+ right_click: 'brug `browser_execute_script` med et contextmenu-event',
666
+ click_xy: 'brug `browser_click` med en selector',
667
+ extract_list: 'brug `browser_get_page_content` og scroll med `browser_scroll`',
668
+ reattach_debugger: 'genindlaes udvidelsen paa chrome://extensions',
669
+ };
670
+
671
+ function forklarSkaevhed(besked) {
672
+ const m = /Unknown method: ([a-z_]+)/.exec(besked || '');
673
+ if (!m) return `Error: ${besked}`;
674
+ const aktiv = activeConnection();
675
+ // Kun hvis udvidelsen faktisk er for gammel. Er den ny og metoden alligevel ukendt,
676
+ // er det en aegte fejl og skal ikke bortforklares.
677
+ if (aktiv && aktiv.version) return `Error: ${besked}`;
678
+ const alt = ERSTATNINGER[m[1]];
679
+ return `Error: browser_${m[1]} findes i denne server, men ikke i din Chrome-udvidelse.\n\n` +
680
+ 'Udvidelsen opdateres via Chrome Web Store og kan vaere 1-3 dage bagud efter en ' +
681
+ 'udgivelse — serveren opdateres med det samme via npm. Alt andet virker imens.\n' +
682
+ (alt ? `\nIndtil da: ${alt}.\n` : '') +
683
+ '\nTjek om en opdatering venter: chrome://extensions → Agent360 Browser MCP. ' +
684
+ 'Er den indlaest som "unpacked", saa koer `npx @agent360/browser-mcp install`.';
685
+ }
686
+
360
687
  const REPO_URL = 'https://github.com/Agent360dk/browser-mcp';
361
688
  const ISSUE_TEMPLATES = { wish: 'wish.yml', use_case: 'use-case.yml', bug: 'bug.yml' };
362
689
 
@@ -396,6 +723,230 @@ function handleAbout(args) {
396
723
  };
397
724
  }
398
725
 
726
+ // ── Selv-diagnose: er installationen overhovedet frisk? ─────────────────────
727
+ // Baggrund (21/8): den udgave der koerte lokalt var npm 1.25.0, mens rettelserne
728
+ // laa uudgivet i repoet. Fejlen viste sig som "sessioner opfoerer sig underligt",
729
+ // ikke som "du koerer en gammel version" — og der fandtes ingen maade at spoerge
730
+ // paa. Derfor spoerger vaerktoejet selv, foer det konkluderer noget som helst.
731
+
732
+ // Friskheds-tjek mod npm. SLUKKET SOM STANDARD siden 22/8.
733
+ //
734
+ // Produktet lover paa forsiden "nothing leaves your machine ... no telemetry", og et
735
+ // opslag i npm-registret ER et kald ud af maskinen — ogsaa selv om det kun sender et
736
+ // pakkenavn og ingen brugerdata. Loeftet vejer tungere end bekvemmeligheden, saa
737
+ // tjekket er nu opt-in: saet BROWSER_MCP_CHECK_NPM=1.
738
+ //
739
+ // Alt det der betyder mest er ren LOKAL maaling og koerer altid: er udvidelsen aeldre
740
+ // end serveren, og er der flere udvidelser forbundet paa én gang.
741
+ const TJEK_NPM = process.env.BROWSER_MCP_CHECK_NPM === '1';
742
+ let npmLatestCache = null; // { version, at }
743
+ const NPM_LATEST_TTL_MS = 10 * 60 * 1000;
744
+
745
+ function npmLatestVersion() {
746
+ if (!TJEK_NPM) return Promise.resolve(null);
747
+ if (npmLatestCache && Date.now() - npmLatestCache.at < NPM_LATEST_TTL_MS) {
748
+ return Promise.resolve(npmLatestCache.version);
749
+ }
750
+ return new Promise((resolve) => {
751
+ // Offline, bag proxy, eller npm mangler paa PATH: svar null i stedet for at fejle.
752
+ // En friskheds-kontrol maa aldrig vaere det der braekker vaerktoejet.
753
+ execFile('npm', ['view', '@agent360/browser-mcp', 'version'], { timeout: 6000 }, (err, stdout) => {
754
+ if (err) return resolve(null);
755
+ const v = String(stdout).trim();
756
+ const ok = /^\d+\.\d+\.\d+/.test(v) ? v : null;
757
+ if (ok) npmLatestCache = { version: ok, at: Date.now() };
758
+ resolve(ok);
759
+ });
760
+ });
761
+ }
762
+
763
+ // ── Lokal logbog over hver graense agenten render ind i ─────────────────────
764
+ //
765
+ // Formaalet er loekken: hver gang et vaerktoej spaerrer vejen, skal det kunne taelles og
766
+ // rettes. Logbogen ligger LOKALT og forlader ikke maskinen.
767
+ //
768
+ // Bevidst ikke auto-indsendelse til et offentligt GitHub-issue: rapporten baerer URL og
769
+ // fejltekst fra den side agenten stod paa — og det er ofte en annoncekonto, en indbakke
770
+ // eller et kundesystem. Et offentligt issue kan ikke tages tilbage.
771
+ const FEEDBACK_LOG = join(homedir(), '.browser-mcp', 'feedback.jsonl');
772
+ const setteFingeraftryk = new Set(); // samme graense logges én gang pr. serverliv
773
+
774
+ function fingeraftryk(kind, tool, what) {
775
+ // Tal, id'er og lange hex-strenge varierer fra gang til gang og maa ikke goere to ens
776
+ // haendelser forskellige.
777
+ const kerne = String(what).toLowerCase()
778
+ .replace(/\b[0-9a-f]{8,}\b/g, '#')
779
+ .replace(/\d+/g, '#')
780
+ .slice(0, 160);
781
+ return `${kind}|${tool || '-'}|${kerne}`;
782
+ }
783
+
784
+ // URL'en reduceres til oprindelse + sti. Query og fragment baerer tokens, sessions-id'er
785
+ // og soegetermer — de har intet at goere i en logbog nogen senere kopierer ind i et issue.
786
+ function afkortUrl(u) {
787
+ if (!u) return null;
788
+ try { const x = new URL(u); return x.origin + x.pathname; } catch { return '(ulaeselig url)'; }
789
+ }
790
+
791
+ function skrivTilLogbog(post) {
792
+ const fp = fingeraftryk(post.kind, post.tool, post.what_happened);
793
+ const foerste = !setteFingeraftryk.has(fp);
794
+ setteFingeraftryk.add(fp);
795
+ if (!foerste) return { logged: false, reason: 'allerede logget i denne session', fingerprint: fp };
796
+ try {
797
+ mkdirSync(dirname(FEEDBACK_LOG), { recursive: true });
798
+ appendFileSync(FEEDBACK_LOG, JSON.stringify({ ...post, fingerprint: fp }) + '\n');
799
+ return { logged: true, path: FEEDBACK_LOG, fingerprint: fp };
800
+ } catch (e) {
801
+ // En logbog der ikke kan skrives maa aldrig vaere det der braekker vaerktoejet.
802
+ return { logged: false, reason: e.message, fingerprint: fp };
803
+ }
804
+ }
805
+
806
+ async function handleProvideFeedback(args) {
807
+ const what = String(args?.what_happened || '').trim();
808
+ const kind = args?.kind || 'blocked';
809
+ const tool = args?.tool || null;
810
+ const url = args?.url || null;
811
+ const attempted = args?.attempted || null;
812
+
813
+ const npmLatest = await npmLatestVersion();
814
+ const exts = distinctExtensions();
815
+ const active = activeConnection();
816
+
817
+ const serverOutdated = npmLatest ? cmpVersion(npmLatest, PKG_VERSION) > 0 : null;
818
+ // Udvidelse og server udgives sammen under samme versionsnummer, saa en
819
+ // udvidelse der er AELDRE end serveren mangler per definition rettelser.
820
+ const extVersion = active ? active.version : null;
821
+ const extOutdated = active
822
+ ? (extVersion === null ? true : cmpVersion(PKG_VERSION, extVersion) > 0)
823
+ : null;
824
+
825
+ const findings = [];
826
+ const fix_steps = [];
827
+
828
+ if (exts.length > 1) {
829
+ const kanVaelge = exts.some(c => c.version);
830
+ findings.push(
831
+ `${exts.length} Browser MCP-udvidelser er indlaest i Chrome og forbundet til denne server samtidig ` +
832
+ `(${exts.map(c => `${c.extensionId || 'ukendt id'}${c.version ? ' v' + c.version : ' (oplyser ikke version)'}`).join(' + ')}). ` +
833
+ 'Hver af dem foerer sit eget sessions-kort og sine egne fane-grupper i den samme browser, ' +
834
+ 'saa faner kan se ud til at forsvinde og sessioner til at smelte sammen. ' +
835
+ (kanVaelge
836
+ ? `Denne server sender kun til den nyeste (${active?.extensionId}).`
837
+ : `Ingen af dem oplyser sin version, saa hvilken der styres (${active?.extensionId}) er vilkaarligt og kan skifte mellem sessioner.`),
838
+ );
839
+ fix_steps.push(
840
+ 'Aabn chrome://extensions og slaa alle Browser MCP-udvidelser fra paa naer én. ' +
841
+ 'Det skal brugeren selv goere — chrome:// kan ikke styres herfra. Behold den nyeste.',
842
+ );
843
+ }
844
+ if (!active) {
845
+ findings.push('Ingen Chrome-udvidelse er forbundet til denne MCP-server lige nu.');
846
+ fix_steps.push('Tjek at Chrome koerer og at udvidelsen er slaaet til paa chrome://extensions, klik derefter paa ikonet → Reconnect.');
847
+ }
848
+ if (serverOutdated) {
849
+ findings.push(`MCP-serveren koerer v${PKG_VERSION}, men npm har v${npmLatest}. Fejlen kan allerede vaere rettet.`);
850
+ fix_steps.push(`Genstart klienten — den henter selv @agent360/browser-mcp@latest (v${npmLatest}).`);
851
+ }
852
+ if (extOutdated) {
853
+ findings.push(
854
+ extVersion === null
855
+ ? `Den forbundne udvidelse er saa gammel at den ikke oplyser sin version (foer v${PKG_VERSION}). Den mangler alt hvad der er rettet siden.`
856
+ : `Udvidelsen er v${extVersion}, serveren er v${PKG_VERSION}. Udvidelsen mangler rettelser fra de mellemliggende udgaver.`,
857
+ );
858
+ // MAALT 22/8: "↻ reload" er ubrugeligt for en Chrome Web Store-bruger. Butikken
859
+ // skubber paa Googles tidsplan efter et review paa 1-3 dage — der er ingen nyere
860
+ // version at hente endnu, saa raadet foerer i ring. Serveren kan ikke se hvilken
861
+ // slags installation det er (den gamle udvidelse oplyser intet), saa begge tilfaelde
862
+ // skal staa der — og det skal siges at ventetiden er forventet, ikke en fejl.
863
+ fix_steps.push(
864
+ extVersion === null
865
+ ? 'Kommer udvidelsen fra Chrome Web Store: der er sandsynligvis en nyere version i review ' +
866
+ '(1-3 dage efter en udgivelse). ↻ reload henter den IKKE foer Google har godkendt — ' +
867
+ 'det er forventet og gaar over af sig selv. Alt andet virker imens. ' +
868
+ 'Er den indlaest som "unpacked": koer `npx @agent360/browser-mcp install` og derefter ' +
869
+ 'chrome://extensions → Agent360 Browser MCP → ↻ reload.'
870
+ : 'Opdatér udvidelsen: chrome://extensions → Agent360 Browser MCP → ↻ reload. ' +
871
+ 'Er den indlaest som "unpacked", saa koer `npx @agent360/browser-mcp install` foerst.',
872
+ );
873
+ }
874
+
875
+ const verdict =
876
+ exts.length > 1 ? 'conflict'
877
+ : (serverOutdated || extOutdated) ? 'outdated'
878
+ : !active ? 'disconnected'
879
+ : (npmLatest === null ? 'unknown' : 'current');
880
+
881
+ const environment = {
882
+ mcp_server_version: PKG_VERSION,
883
+ npm_latest_version: npmLatest,
884
+ server_up_to_date: serverOutdated === null ? null : !serverOutdated,
885
+ extensions_connected: exts.map(c => ({
886
+ name: c.name,
887
+ version: c.version,
888
+ extension_id: c.extensionId,
889
+ active: c === active,
890
+ })),
891
+ extension_up_to_date: extOutdated === null ? null : !extOutdated,
892
+ ws_port: activePort,
893
+ node: process.version,
894
+ platform: `${process.platform} ${process.arch}`,
895
+ };
896
+
897
+ const issueBody = [
898
+ what && `**What happened**\n${what}`,
899
+ tool && `\n**Tool**: \`${tool}\``,
900
+ // MAALT 22/8 ved sikkerhedsreview: her stod den RAA url, mens den lokale logbog
901
+ // nedenfor bruger afkortUrl(). Query-strengen — hvor tokens bor — blev altsaa
902
+ // strippet fra filen paa disken, men sendt uredigeret ind i et link til et
903
+ // OFFENTLIGT GitHub-issue. Praecis den forkerte vej rundt.
904
+ url && `\n**URL**: ${afkortUrl(url)}`,
905
+ attempted && `\n**Already tried**\n${attempted}`,
906
+ `\n**Environment**\n\`\`\`json\n${JSON.stringify(environment, null, 2)}\n\`\`\``,
907
+ ].filter(Boolean).join('\n');
908
+
909
+ const template = kind === 'wish' ? ISSUE_TEMPLATES.wish
910
+ : kind === 'use_case' ? ISSUE_TEMPLATES.use_case
911
+ : ISSUE_TEMPLATES.bug;
912
+ const issueTitle = what.split('\n')[0].slice(0, 90) || 'Browser MCP feedback';
913
+ const submit_url = `${REPO_URL}/issues/new?template=${template}` +
914
+ `&title=${encodeURIComponent(issueTitle)}&body=${encodeURIComponent(issueBody)}`;
915
+
916
+ const instruction =
917
+ verdict === 'conflict' || verdict === 'outdated' || verdict === 'disconnected'
918
+ ? 'Fortael brugeren hvad der blev fundet, og giv fix_steps som konkrete skridt. Proev derefter handlingen igen. ' +
919
+ 'Del KUN submit_url hvis problemet stadig staar efter at fix_steps er fulgt — det er sandsynligvis installationen, ikke en fejl i Browser MCP.'
920
+ : 'Installationen er frisk, saa det her er sandsynligvis en aegte mangel eller fejl. Fortael brugeren kort hvad der ikke kunne lade sig goere, ' +
921
+ 'og tilbyd submit_url som et klikbart link ("forudfyldt — du kan rette i den foer du sender"). Spoerg ikke om lov foerst.';
922
+
923
+ const logbog = skrivTilLogbog({
924
+ at: new Date().toISOString(),
925
+ kind, tool, what_happened: what, attempted,
926
+ url: afkortUrl(url),
927
+ verdict,
928
+ server_version: PKG_VERSION,
929
+ extension_version: extVersion,
930
+ extensions_connected: exts.length,
931
+ });
932
+
933
+ return {
934
+ content: [{
935
+ type: 'text',
936
+ text: JSON.stringify({
937
+ reported: { kind, what_happened: what, tool, url, attempted },
938
+ verdict,
939
+ findings,
940
+ fix_steps,
941
+ environment,
942
+ logged_locally: logbog,
943
+ submit_url,
944
+ instruction,
945
+ }, null, 2),
946
+ }],
947
+ };
948
+ }
949
+
399
950
  async function handleExtractToken(args) {
400
951
  const { provider } = args;
401
952
  const info = PROVIDER_PAGES[provider];
@@ -433,8 +984,8 @@ function gracefulShutdown(reason, code = 0) {
433
984
  if (heartbeat) clearInterval(heartbeat);
434
985
 
435
986
  // Close WS with explicit close-frame so extension's onclose handler fires
436
- if (extensionSocket && extensionSocket.readyState === 1) {
437
- try { extensionSocket.close(1000, 'mcp-shutdown'); } catch {}
987
+ for (const c of liveConnections()) {
988
+ try { c.ws.close(1000, 'mcp-shutdown'); } catch {}
438
989
  }
439
990
  if (wss) try { wss.close(); } catch {}
440
991
 
@@ -447,19 +998,100 @@ process.on('SIGINT', () => gracefulShutdown('SIGINT'));
447
998
  process.on('exit', () => {
448
999
  // Safety net for direct process.exit calls that bypass gracefulShutdown
449
1000
  if (wss) try { wss.close(); } catch {}
450
- if (extensionSocket) try { extensionSocket.close(); } catch {}
1001
+ for (const c of connections) try { c.ws.close(); } catch {}
451
1002
  });
452
1003
 
453
- // Detect Claude Code exit check if parent process is still alive
454
- // stdin.on('end') doesn't work because MCP SDK's StdioServerTransport owns stdin
1004
+ // ── Naar lukker serveren sin port? (MAALT 22/8) ──────────────────────────────
1005
+ //
1006
+ // Symptomet: tyve porte optaget, og ingen chats i live bag dem. Aarsagen var at
1007
+ // tjekket kiggede paa den FORKERTE proces. Kaeden er nemlig ikke to led, men fire:
1008
+ //
1009
+ // Claude Code → wrapper → npm exec → denne server
1010
+ //
1011
+ // `process.ppid` er `npm exec`, ikke Claude Code. Doer chatten, kan `npm exec`
1012
+ // blive haengende som foraeldreloes — og saa ser tjekket en levende foraelder for
1013
+ // evigt. Porten blev holdt i op til fire timer (idle-graensen), og med flere
1014
+ // forladte chats loeb spaendet fuldt.
1015
+ //
1016
+ // Rettelsen er at tjekke HELE kaeden op til roden, ikke kun naermeste led. Doer et
1017
+ // vilkaarligt led, er forbindelsen til den chat der ejer os brudt, og saa er vi
1018
+ // foraeldreloese uanset om vores naermeste foraelder stadig aander.
1019
+ //
1020
+ // Kaeden hentes én gang ved opstart (én ps-kommando), og derefter koster tjekket
1021
+ // kun et signal 0 pr. led hvert 5. sekund. Kan kaeden ikke laeses (Windows, eller
1022
+ // ps mangler), falder vi tilbage til det gamle enkelt-tjek — daarligere, men aldrig
1023
+ // vaerre end foer.
1024
+
1025
+ // ps-opslaget bor her (det er en sideeffekt); selve kaede-logikken og doeds-dommen
1026
+ // ligger i vagt.js, saa de kan koeres i en test uden at starte en server.
1027
+ function laesPpid(pid) {
1028
+ try {
1029
+ const ud = execSync(`ps -o ppid= -p ${pid}`, {
1030
+ encoding: 'utf8',
1031
+ stdio: ['ignore', 'pipe', 'ignore'],
1032
+ timeout: 2000, // et haengende ps ville ellers blokere hele opstarten synkront
1033
+ });
1034
+ return Number(String(ud).trim());
1035
+ } catch {
1036
+ return null;
1037
+ }
1038
+ }
1039
+
455
1040
  const parentPid = process.ppid;
1041
+
1042
+ // MAALT 22/8: doer den naermeste foraelder FOER serveren er bootet (~2 sek node+SDK),
1043
+ // laeser process.ppid vaerdien 1 — altsaa launchd. Kaeden blev saa [1], og pid 1 er
1044
+ // baade udoedelig og ejet af root, saa vagten var enten inert eller draebte os selv
1045
+ // paa EPERM. En kaede der kun bestaar af pid 1 vogter ingenting og skal ikke bruges.
1046
+ let vagtKaede = parentPid > 1 ? [parentPid] : [];
1047
+ try {
1048
+ const k = forfaedreKaede(parentPid, laesPpid).filter((x) => x > 1);
1049
+ if (k.length) vagtKaede = k;
1050
+ } catch {}
1051
+ if (!vagtKaede.length) {
1052
+ process.stderr.write('[MCP] ingen brugbar foraelder-kaede (ppid=' + parentPid +
1053
+ ') — falder tilbage paa idle-graensen alene\n');
1054
+ }
1055
+ process.stderr.write(`[MCP] vagt-kaede: ${vagtKaede.join(' → ')}\n`);
1056
+
456
1057
  parentCheck = setInterval(() => {
1058
+ const doede = vagtKaede.filter((pid) => ledErDoedt(pid));
1059
+ if (!doede.length) return;
1060
+
1061
+ // ── Et doedt led betyder ikke automatisk at chatten er vaek (MAALT 23/8) ──────
1062
+ //
1063
+ // Kaeden blev frosset ved opstart. Men et MELLEMLED kan afslutte helt normalt
1064
+ // mens ejeren koerer videre — maalt to gange paa denne maskine, hvor kaeden gaar
1065
+ // npm exec → wrapper → claude → Code Helper (Plugin) → Code. Et forbigaaende led
1066
+ // der lukkede pænt udloeste "chatten bag denne server er vaek", mens chatten var
1067
+ // uroert. Og risikoen er ensrettet vaerre end 1.25.0, som vogtede ét pid: nu er
1068
+ // hvert af 5-6 led en ny doedsaarsag.
1069
+ //
1070
+ // Derfor genlaeses kaeden foerst naar noget SER doedt ud. Kan vi stadig gaa fra
1071
+ // vores egen foraelder op til en rod, er vi ikke foraeldreloese — vi er bare blevet
1072
+ // reparented, og den nye kaede overtager. Kun naar den vej ogsaa er vaek, lukker vi.
1073
+ //
1074
+ // Prisen er nul i normal drift: genlaesningen koerer kun i det tik hvor et led er
1075
+ // forsvundet, ikke hvert 5. sekund.
1076
+ let frisk = [];
457
1077
  try {
458
- process.kill(parentPid, 0); // signal 0 = check if process exists
459
- } catch {
460
- gracefulShutdown(`Parent process ${parentPid} died`);
1078
+ frisk = forfaedreKaede(process.ppid, laesPpid).filter((x) => x > 1 && !ledErDoedt(x));
1079
+ } catch {}
1080
+
1081
+ if (frisk.length) {
1082
+ process.stderr.write(
1083
+ `[MCP] led ${doede.join(', ')} er vaek, men kaeden gaar stadig op: ` +
1084
+ `${frisk.join(' → ')} — fortsaetter\n`,
1085
+ );
1086
+ vagtKaede = frisk;
1087
+ return;
461
1088
  }
462
- }, 5000); // check every 5 seconds
1089
+
1090
+ gracefulShutdown(
1091
+ `Proces ${doede[0]} i kaeden doede, og der er ingen levende vej op — ` +
1092
+ 'chatten bag denne server er vaek',
1093
+ );
1094
+ }, 5000); // hvert 5. sekund
463
1095
 
464
1096
  // Also listen for stdin close as backup
465
1097
  process.stdin.on('end', () => gracefulShutdown('stdin closed'));