@predotdev/mcp 2.1.4 → 2.1.5

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 CHANGED
@@ -508,7 +508,7 @@ npx -y @predotdev/mcp uninstall # remove it from every agent and delete its fil
508
508
  | --- | --- |
509
509
  | `Chrome remote debugging is off` | Open `chrome://inspect/#remote-debugging` in Chrome and turn it on. |
510
510
  | `Chrome is asking "Allow remote debugging?"` | Click **Allow** in Chrome, then ask your agent to try again. |
511
- | Chrome asks to allow again | Normal after Chrome restarts, or after this tool updates to a new version. |
511
+ | Chrome asks to allow again | Normal after Chrome restarts. Updates keep the approved connection, except a rare one that changes how the background process talks to Chrome. |
512
512
  | `Plain-words actions need a pre.dev account` | Run `npx -y @predotdev/mcp login`. No restart needed. |
513
513
  | `pre.dev rejected the saved key` | Run `npx -y @predotdev/mcp login` again. |
514
514
  | A message about credits or subscribing | Your workspace is out of trial credits. Subscribe or top up at [pre.dev/billing](https://pre.dev/billing). |
@@ -531,7 +531,7 @@ cd mcp
531
531
  node src/bridge.mjs check
532
532
  ```
533
533
 
534
- Point your agent at `node /path/to/mcp/src/bridge.mjs`. Edits to `src/tools.mjs` reload automatically without dropping Chrome's approved connection. Edits to `src/bridge.mjs` restart the background process, so Chrome asks you to allow again.
534
+ Point your agent at `node /path/to/mcp/src/bridge.mjs`. Edits to `src/tools.mjs` reload automatically without dropping Chrome's approved connection: every call runs the calling copy's `tools.mjs`. The background process restarts (and Chrome asks you to allow again) only when `PROTOCOL` in `src/bridge.mjs` changes; bump it when you change the daemon's HTTP API, what it hands `tools.mjs`, or its Chrome connection code, or run `node src/bridge.mjs stop` to pick up other daemon edits.
535
535
 
536
536
  ## About
537
537
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@predotdev/mcp",
3
- "version": "2.1.4",
3
+ "version": "2.1.5",
4
4
  "description": "pre.dev in any coding agent: Browser Agents in the Chrome you already use and in the cloud, plus specs and plans. One command to set up. Free and open source.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/bridge.mjs CHANGED
@@ -9,8 +9,9 @@
9
9
  // predev-mcp check checks your setup and lists your Chrome profiles
10
10
  // predev-mcp stop stops the background daemon
11
11
  // predev-mcp daemon (internal) holds the single Chrome connection and serves the MCP shims
12
- // over localhost HTTP with a per-run token. Tools live in tools.mjs and
13
- // hot-reload, so editing them never drops the approved connection.
12
+ // over localhost HTTP with a per-run token. Each call runs the calling
13
+ // copy's tools.mjs (reloaded when it changes), so tool updates never drop
14
+ // the approved connection; the daemon is replaced only when PROTOCOL changes.
14
15
  //
15
16
  // Chrome asks "Allow remote debugging?" once per connection, so the daemon keeps one
16
17
  // connection for every agent instead of each agent opening its own.
@@ -25,12 +26,15 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
25
26
  import { createRemote } from './remote.mjs';
26
27
 
27
28
  const SELF = fileURLToPath(import.meta.url);
28
- const TOOLS_FILE = path.join(path.dirname(SELF), 'tools.mjs');
29
+ const TOOLS_FILE = (file => { try { return fs.realpathSync(file); } catch { return file; } })(path.join(path.dirname(SELF), 'tools.mjs'));
29
30
  const PKG = (() => {
30
31
  try { return JSON.parse(fs.readFileSync(path.join(path.dirname(SELF), '../package.json'), 'utf8')); } catch { return {}; }
31
32
  })();
32
- // Content hash, not mtime: reinstalling the same version must not restart the daemon (and re-ask Allow).
33
- const VERSION = crypto.createHash('sha1').update(fs.readFileSync(SELF)).digest('hex').slice(0, 12);
33
+ // What a shim and a running daemon must agree on. Chrome asks "Allow remote debugging?" for every new
34
+ // connection, so a release keeps the running daemon unless this changes: bump it only when the
35
+ // daemon's HTTP API, the core it hands tools.mjs, or its Chrome connection code changes in a way the
36
+ // running daemon can't serve. Tool changes need no bump: each call names the caller's tools.mjs.
37
+ const PROTOCOL = 1;
34
38
 
35
39
  function defaultChromeDir() {
36
40
  const home = os.homedir();
@@ -202,20 +206,43 @@ const core = {
202
206
  get daemonPort() { return daemonPort; },
203
207
  };
204
208
 
205
- let loaded = { mtime: 0, api: null };
206
- async function loadTools() {
209
+ // Tools modules by real path, re-imported when the file's mtime changes.
210
+ const loadedTools = new Map();
211
+ async function loadTools(file = TOOLS_FILE, version = PKG.version) {
212
+ const cached = loadedTools.get(file);
207
213
  let mtime;
208
- try { mtime = fs.statSync(TOOLS_FILE).mtimeMs; } catch (error) { if (loaded.api) return loaded.api; throw error; }
209
- if (!loaded.api || loaded.mtime !== mtime) {
210
- const module = await import(`${pathToFileURL(TOOLS_FILE).href}?v=${mtime}`);
211
- loaded = { mtime, api: module.createTools(core) };
212
- log('tools loaded', Math.round(mtime));
213
- }
214
- return loaded.api;
214
+ try { mtime = fs.statSync(file).mtimeMs; } catch (error) { if (cached) return cached.api; throw error; }
215
+ if (cached?.mtime === mtime) return cached.api;
216
+ const module = await import(`${pathToFileURL(file).href}?v=${mtime}`);
217
+ // The daemon's one shared core; only the version (sent to pre.dev) is the calling copy's own.
218
+ const api = module.createTools(Object.create(core, { version: { value: version || '0.0.0' } }));
219
+ loadedTools.set(file, { mtime, api });
220
+ log('tools loaded', file, Math.round(mtime));
221
+ return api;
222
+ }
223
+
224
+ // The tools.mjs a call names, so an update that changes only tools reaches the running daemon
225
+ // without a new Chrome connection. Only a copy of this package's tools.mjs that belongs to this
226
+ // user is loaded; anything else gets the daemon's own copy.
227
+ function callerTools(header) {
228
+ const own = { file: TOOLS_FILE, version: PKG.version };
229
+ if (!header) return own;
230
+ let file = '';
231
+ try {
232
+ file = fs.realpathSync(decodeURIComponent(String(header)));
233
+ const stat = fs.statSync(file);
234
+ const mine = typeof process.getuid !== 'function' || stat.uid === process.getuid();
235
+ if (path.basename(file) === 'tools.mjs' && stat.isFile() && mine) {
236
+ const pkg = JSON.parse(fs.readFileSync(path.join(path.dirname(file), '..', 'package.json'), 'utf8'));
237
+ if (pkg?.name === '@predotdev/mcp') return { file, version: pkg.version };
238
+ }
239
+ } catch {}
240
+ log('ignored tools path', file || String(header).slice(0, 300));
241
+ return own;
215
242
  }
216
243
 
217
- async function callTool(name, args, ctx) {
218
- const { TOOLS } = await loadTools();
244
+ async function callTool(name, args, ctx, tools = {}) {
245
+ const { TOOLS } = await loadTools(tools.file, tools.version);
219
246
  const tool = TOOLS.find(t => t.name === name);
220
247
  if (!tool) throw new Error(`Unknown tool ${name}`);
221
248
  await chrome.connect();
@@ -264,7 +291,7 @@ function runDaemon() {
264
291
  // A browser page could reach localhost; it can't know the token, and it would send Origin.
265
292
  if (req.headers['x-bridge-token'] !== token || req.headers.origin) { res.writeHead(403); res.end(); return; }
266
293
  const reply = (body, status = 200) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); };
267
- if (req.method === 'GET' && req.url === '/health') return reply({ ok: true, version: VERSION, chrome: chrome.connected });
294
+ if (req.method === 'GET' && req.url === '/health') return reply({ ok: true, protocol: PROTOCOL, version: PKG.version || '0.0.0', chrome: chrome.connected });
268
295
  if (req.method === 'POST' && req.url === '/shutdown') { reply({ ok: true }); setTimeout(() => process.exit(0), 50); return; }
269
296
  if (req.method === 'POST' && req.url === '/call') {
270
297
  let body = '';
@@ -275,9 +302,10 @@ function runDaemon() {
275
302
  apiKey: String(req.headers['x-predev-api-key'] || '').trim(),
276
303
  apiUrl: String(req.headers['x-predev-api-url'] || DEFAULT_API_URL).trim(),
277
304
  };
305
+ const tools = callerTools(req.headers['x-predev-tools']);
278
306
  const started = Date.now();
279
307
  try {
280
- const result = await callTool(name, args, ctx);
308
+ const result = await callTool(name, args, ctx, tools);
281
309
  log('call', name, 'ok', `${Date.now() - started}ms`);
282
310
  reply(result);
283
311
  } catch (error) {
@@ -291,9 +319,9 @@ function runDaemon() {
291
319
  server.listen(0, '127.0.0.1', () => {
292
320
  daemonPort = server.address().port;
293
321
  const tmp = `${STATE_FILE}.${process.pid}`;
294
- fs.writeFileSync(tmp, JSON.stringify({ port: server.address().port, token, pid: process.pid, version: VERSION }), { mode: 0o600 });
322
+ fs.writeFileSync(tmp, JSON.stringify({ port: server.address().port, token, pid: process.pid, protocol: PROTOCOL, version: PKG.version || '0.0.0' }), { mode: 0o600 });
295
323
  fs.renameSync(tmp, STATE_FILE);
296
- log('daemon listening on', server.address().port, 'version', VERSION);
324
+ log('daemon listening on', server.address().port, 'protocol', PROTOCOL, 'version', PKG.version || '0.0.0');
297
325
  });
298
326
  }
299
327
 
@@ -331,6 +359,8 @@ function request(state, method, route, body, timeout = 180000) {
331
359
  host: '127.0.0.1', port: state.port, path: route, method, timeout,
332
360
  headers: {
333
361
  'x-bridge-token': state.token, 'content-type': 'application/json',
362
+ // The daemon runs this copy's tools (see callerTools).
363
+ ...(route === '/call' ? { 'x-predev-tools': encodeURIComponent(TOOLS_FILE) } : {}),
334
364
  ...(apiKey ? { 'x-predev-api-key': apiKey, 'x-predev-api-url': apiUrl } : {}),
335
365
  },
336
366
  }, res => {
@@ -358,7 +388,8 @@ async function ensureDaemon() {
358
388
  for (let attempt = 0; attempt < 3; attempt++) {
359
389
  const state = readState();
360
390
  const health = state && await request(state, 'GET', '/health', null, 3000).catch(() => null);
361
- if (health?.ok && health.version === VERSION) return state;
391
+ if (health?.ok && health.protocol === PROTOCOL) return state;
392
+ // A daemon from before PROTOCOL (2.1.4 and older) or with another one is replaced: a new connection.
362
393
  if (health?.ok) { await request(state, 'POST', '/shutdown', {}, 3000).catch(() => {}); await sleep(300); }
363
394
  privateStateDir();
364
395
  const out = fs.openSync(LOG_FILE, 'a', 0o600);
@@ -368,7 +399,7 @@ async function ensureDaemon() {
368
399
  for (let i = 0; i < 50; i++) {
369
400
  await sleep(100);
370
401
  const fresh = readState();
371
- if (fresh && fresh.version === VERSION && await request(fresh, 'GET', '/health', null, 1000).then(h => h.ok).catch(() => false)) return fresh;
402
+ if (fresh && fresh.protocol === PROTOCOL && await request(fresh, 'GET', '/health', null, 1000).then(h => h.ok).catch(() => false)) return fresh;
372
403
  }
373
404
  }
374
405
  throw new Error(`Could not start the Chrome MCP daemon; see ${LOG_FILE}.`);
package/src/tools.mjs CHANGED
@@ -593,6 +593,15 @@ async function predev(ctx, route, body) {
593
593
  throw new Error('pre.dev is busy right now. Retry in a few seconds, or use chrome_snapshot refs.');
594
594
  }
595
595
 
596
+ // A plain-words argument under the documented name or one agents often reach for instead.
597
+ function plainWords(args, names) {
598
+ for (const name of names) {
599
+ const value = args?.[name];
600
+ if (typeof value === 'string' && value.trim()) return value.trim();
601
+ }
602
+ return '';
603
+ }
604
+
596
605
  // Picks the element a plain-words description refers to from the page's interactive elements.
597
606
  async function matchElement(session, description, ctx) {
598
607
  if (!ctx.apiKey) throw new Error(KEY_HELP);
@@ -796,7 +805,10 @@ const TOOLS = [
796
805
  submit: { type: 'boolean' }, clear: { type: 'boolean' },
797
806
  },
798
807
  },
799
- async run({ tab, target: description, action = 'click', text, submit, clear }, ctx = {}) {
808
+ async run(args, ctx = {}) {
809
+ const { tab, action = 'click', text, submit, clear } = args;
810
+ const description = plainWords(args, ['target', 'description', 'instruction', 'element', 'query']);
811
+ if (!description) throw new Error('chrome_act needs "target": the element in plain words, e.g. {"tab":"AB12CD","target":"the Search button"}.');
800
812
  const info = findTab(tab);
801
813
  const session = await chrome.session(info.targetId);
802
814
  const match = await matchElement(session, description, ctx);
@@ -901,7 +913,12 @@ const TOOLS = [
901
913
  gone: { type: 'boolean' }, timeout: { type: 'number', description: 'Seconds.' },
902
914
  },
903
915
  },
904
- async run({ tab, text, selector, url, condition, gone = false, timeout = 15 }, ctx = {}) {
916
+ async run(args, ctx = {}) {
917
+ const { tab, text, selector, url, gone = false, timeout = 15 } = args;
918
+ const condition = plainWords(args, ['condition', 'description', 'instruction', 'query', 'until']);
919
+ if (!condition && !text && !selector && !url) {
920
+ throw new Error('chrome_wait needs "condition" (plain words, e.g. {"tab":"AB12CD","condition":"the search results have loaded"}), or "text", "selector" or "url".');
921
+ }
905
922
  const info = findTab(tab);
906
923
  const session = await chrome.session(info.targetId);
907
924
  if (condition) {
@@ -921,7 +938,6 @@ const TOOLS = [
921
938
  if (text) checks.push(`(document.body?.innerText || '').includes(${JSON.stringify(text)})`);
922
939
  if (selector) checks.push(`!!document.querySelector(${JSON.stringify(selector)})`);
923
940
  if (url) checks.push(`location.href.includes(${JSON.stringify(url)})`);
924
- if (!checks.length) throw new Error('Pass text, selector, url or condition.');
925
941
  const expression = `${gone ? '!' : ''}(${checks.join(' && ')})`;
926
942
  const end = Date.now() + Math.min(Number(timeout) || 15, 120) * 1000;
927
943
  while (Date.now() < end) {