@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 +2 -2
- package/package.json +1 -1
- package/src/bridge.mjs +53 -22
- package/src/tools.mjs +19 -3
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
|
|
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.
|
|
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.
|
|
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.
|
|
13
|
-
//
|
|
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
|
-
//
|
|
33
|
-
|
|
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
|
-
|
|
206
|
-
|
|
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(
|
|
209
|
-
if (
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
}
|
|
214
|
-
|
|
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:
|
|
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:
|
|
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',
|
|
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.
|
|
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.
|
|
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(
|
|
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(
|
|
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) {
|