@predotdev/mcp 0.0.0-stage → 2.0.0

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/src/bridge.mjs ADDED
@@ -0,0 +1,511 @@
1
+ #!/usr/bin/env node
2
+ // pre.dev MCP: pre.dev in any local coding agent. Browser Agents Local drives the Chrome you
3
+ // already run, across every profile, through one shared debugging connection; pre.dev's hosted
4
+ // tools (cloud Browser Agents, specs, plans) come through on the same key (remote.mjs).
5
+ //
6
+ // predev-mcp stdio MCP server (what agents launch); starts the daemon on demand
7
+ // predev-mcp setup signs in to pre.dev, adds it to every coding agent, connects to Chrome
8
+ // (also login, logout, uninstall; see setup.mjs)
9
+ // predev-mcp check checks your setup and lists your Chrome profiles
10
+ // predev-mcp stop stops the background daemon
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.
14
+ //
15
+ // Chrome asks "Allow remote debugging?" once per connection, so the daemon keeps one
16
+ // connection for every agent instead of each agent opening its own.
17
+
18
+ import { spawn } from 'node:child_process';
19
+ import crypto from 'node:crypto';
20
+ import fs from 'node:fs';
21
+ import http from 'node:http';
22
+ import os from 'node:os';
23
+ import path from 'node:path';
24
+ import { fileURLToPath, pathToFileURL } from 'node:url';
25
+ import { createRemote } from './remote.mjs';
26
+
27
+ const SELF = fileURLToPath(import.meta.url);
28
+ const TOOLS_FILE = path.join(path.dirname(SELF), 'tools.mjs');
29
+ const PKG = (() => {
30
+ try { return JSON.parse(fs.readFileSync(path.join(path.dirname(SELF), '../package.json'), 'utf8')); } catch { return {}; }
31
+ })();
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);
34
+
35
+ function defaultChromeDir() {
36
+ const home = os.homedir();
37
+ if (process.platform === 'darwin') return path.join(home, 'Library/Application Support/Google/Chrome');
38
+ if (process.platform === 'win32') return path.join(process.env.LOCALAPPDATA || path.join(home, 'AppData/Local'), 'Google/Chrome/User Data');
39
+ return path.join(process.env.XDG_CONFIG_HOME || path.join(home, '.config'), 'google-chrome');
40
+ }
41
+
42
+ const CUSTOM_DIR = (process.env.CHROME_MCP_USER_DATA_DIR || '').trim();
43
+ const CHROME_DIR = CUSTOM_DIR ? path.resolve(CUSTOM_DIR.replace(/^~(?=[/\\]|$)/, os.homedir())) : defaultChromeDir();
44
+ // One daemon per Chrome: a custom user data dir gets its own daemon and state.
45
+ const BASE_STATE_DIR = path.join(os.homedir(), '.predev', 'mcp');
46
+ const STATE_DIR = CUSTOM_DIR
47
+ ? path.join(BASE_STATE_DIR, crypto.createHash('sha1').update(CHROME_DIR).digest('hex').slice(0, 10))
48
+ : BASE_STATE_DIR;
49
+ const STATE_FILE = path.join(STATE_DIR, 'daemon.json');
50
+ const LOCK_FILE = path.join(STATE_DIR, 'daemon.lock');
51
+ const MAP_FILE = path.join(STATE_DIR, 'profiles.json');
52
+ const LOG_FILE = path.join(STATE_DIR, 'predev-mcp.log');
53
+ /** Saved by `predev-mcp login` / `setup`, shared by every agent and every Chrome. */
54
+ const CREDENTIALS_FILE = path.join(BASE_STATE_DIR, 'credentials.json');
55
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
56
+
57
+ function log(...parts) {
58
+ try { fs.appendFileSync(LOG_FILE, `${new Date().toISOString()} ${parts.join(' ')}\n`); } catch {}
59
+ }
60
+
61
+ // ---------------------------------------------------------------- CDP connection
62
+
63
+ class Chrome {
64
+ constructor() {
65
+ this.ws = null;
66
+ this.wsUrl = '';
67
+ this.nextId = 0;
68
+ this.pending = new Map();
69
+ this.targets = new Map(); // targetId -> TargetInfo
70
+ this.sessions = new Map(); // targetId -> sessionId
71
+ this.connecting = null;
72
+ }
73
+
74
+ get connected() { return this.ws?.readyState === 1; }
75
+
76
+ // One pending connection at a time: while Chrome's "Allow remote debugging?" prompt is up,
77
+ // callers get a clear retry message instead of each opening another prompt.
78
+ async connect() {
79
+ if (this.connected) return;
80
+ this.connecting ??= this.#open().finally(() => { this.connecting = null; });
81
+ let timer;
82
+ const waiting = new Promise((_, reject) => {
83
+ timer = setTimeout(() => reject(new Error('Chrome is asking "Allow remote debugging?". Click Allow in Chrome, then retry this call.')), 40000);
84
+ });
85
+ try { await Promise.race([this.connecting, waiting]); } finally { clearTimeout(timer); }
86
+ }
87
+
88
+ async #open() {
89
+ let port, wsPath;
90
+ try {
91
+ [port, wsPath] = fs.readFileSync(path.join(CHROME_DIR, 'DevToolsActivePort'), 'utf8').trim().split('\n');
92
+ } catch {
93
+ throw new Error(`Chrome remote debugging is off. Open chrome://inspect/#remote-debugging in Chrome and turn it on.${CUSTOM_DIR ? ` (Looked in ${CHROME_DIR}.)` : ''}`);
94
+ }
95
+ const url = `ws://127.0.0.1:${port}${wsPath}`;
96
+ const ws = new WebSocket(url);
97
+ await new Promise((resolve, reject) => {
98
+ const timer = setTimeout(() => {
99
+ try { ws.close(); } catch {}
100
+ reject(new Error('Chrome\'s "Allow remote debugging?" prompt was not answered in 10 minutes; retry to ask again.'));
101
+ }, 600000);
102
+ ws.onopen = () => { clearTimeout(timer); resolve(); };
103
+ ws.onerror = () => { clearTimeout(timer); reject(new Error('Chrome refused the debugging connection (prompt cancelled, or Chrome is not running).')); };
104
+ });
105
+ ws.onmessage = event => this.#onMessage(JSON.parse(event.data));
106
+ ws.onclose = () => {
107
+ log('chrome connection closed');
108
+ this.ws = null;
109
+ this.targets.clear();
110
+ this.sessions.clear();
111
+ for (const call of this.pending.values()) { clearTimeout(call.timer); call.reject(new Error('Chrome connection closed; retry.')); }
112
+ this.pending.clear();
113
+ };
114
+ this.ws = ws;
115
+ if (this.wsUrl !== url) { contextDirs.clear(); this.wsUrl = url; loadProfileMap(); }
116
+ failedProbes.clear();
117
+ triedDirs.clear();
118
+ await this.send('Target.setDiscoverTargets', { discover: true });
119
+ for (const info of (await this.send('Target.getTargets')).targetInfos) this.targets.set(info.targetId, info);
120
+ log('connected', url);
121
+ }
122
+
123
+ #onMessage(message) {
124
+ if (message.id !== undefined) {
125
+ const call = this.pending.get(message.id);
126
+ if (!call) return;
127
+ this.pending.delete(message.id);
128
+ clearTimeout(call.timer);
129
+ if (message.error) call.reject(new Error(message.error.message));
130
+ else call.resolve(message.result);
131
+ return;
132
+ }
133
+ const { method, params } = message;
134
+ if (method === 'Target.targetCreated' || method === 'Target.targetInfoChanged') {
135
+ this.targets.set(params.targetInfo.targetId, params.targetInfo);
136
+ } else if (method === 'Target.targetDestroyed') {
137
+ this.targets.delete(params.targetId);
138
+ this.sessions.delete(params.targetId);
139
+ } else if (method === 'Target.detachedFromTarget') {
140
+ for (const [targetId, sessionId] of this.sessions) if (sessionId === params.sessionId) this.sessions.delete(targetId);
141
+ }
142
+ }
143
+
144
+ send(method, params = {}, sessionId, timeout = 15000) {
145
+ if (!this.connected) return Promise.reject(new Error('Not connected to Chrome; retry.'));
146
+ const id = ++this.nextId;
147
+ return new Promise((resolve, reject) => {
148
+ const timer = setTimeout(() => {
149
+ this.pending.delete(id);
150
+ reject(new Error(`Chrome did not answer ${method} within ${timeout / 1000}s.`));
151
+ }, timeout);
152
+ this.pending.set(id, { resolve, reject, timer });
153
+ this.ws.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
154
+ });
155
+ }
156
+
157
+ async session(targetId) {
158
+ const existing = this.sessions.get(targetId);
159
+ if (existing) return existing;
160
+ const { sessionId } = await this.send('Target.attachToTarget', { targetId, flatten: true });
161
+ this.sessions.set(targetId, sessionId);
162
+ // Background tabs are throttled or frozen; keep them responsive while an agent drives them.
163
+ await this.send('Page.setWebLifecycleState', { state: 'active' }, sessionId).catch(() => {});
164
+ await this.send('Emulation.setFocusEmulationEnabled', { enabled: true }, sessionId).catch(() => {});
165
+ return sessionId;
166
+ }
167
+ }
168
+
169
+ const chrome = new Chrome();
170
+ const contextDirs = new Map(); // browserContextId -> Chrome profile directory
171
+
172
+ function loadProfileMap() {
173
+ try {
174
+ const saved = JSON.parse(fs.readFileSync(MAP_FILE, 'utf8'));
175
+ if (saved.wsUrl === chrome.wsUrl) for (const [ctx, dir] of Object.entries(saved.map)) contextDirs.set(ctx, dir);
176
+ } catch {}
177
+ }
178
+
179
+ function saveProfileMap() {
180
+ try { fs.writeFileSync(MAP_FILE, JSON.stringify({ wsUrl: chrome.wsUrl, map: Object.fromEntries(contextDirs) })); } catch {}
181
+ }
182
+
183
+ // Mapping attempts that failed are not retried on the same connection, so tabs never flash twice.
184
+ const failedProbes = new Set();
185
+ const triedDirs = new Set();
186
+ let daemonPort = 0;
187
+
188
+ const core = {
189
+ chrome, contextDirs, failedProbes, triedDirs, saveProfileMap, log, sleep,
190
+ chromeDir: CHROME_DIR, customChromeDir: Boolean(CUSTOM_DIR), version: PKG.version || '0.0.0',
191
+ get daemonPort() { return daemonPort; },
192
+ };
193
+
194
+ let loaded = { mtime: 0, api: null };
195
+ async function loadTools() {
196
+ let mtime;
197
+ try { mtime = fs.statSync(TOOLS_FILE).mtimeMs; } catch (error) { if (loaded.api) return loaded.api; throw error; }
198
+ if (!loaded.api || loaded.mtime !== mtime) {
199
+ const module = await import(`${pathToFileURL(TOOLS_FILE).href}?v=${mtime}`);
200
+ loaded = { mtime, api: module.createTools(core) };
201
+ log('tools loaded', Math.round(mtime));
202
+ }
203
+ return loaded.api;
204
+ }
205
+
206
+ async function callTool(name, args, ctx) {
207
+ const { TOOLS } = await loadTools();
208
+ const tool = TOOLS.find(t => t.name === name);
209
+ if (!tool) throw new Error(`Unknown tool ${name}`);
210
+ await chrome.connect();
211
+ const result = await tool.run(args || {}, ctx);
212
+ return typeof result === 'string' ? { content: [{ type: 'text', text: result }] } : result;
213
+ }
214
+
215
+ // ---------------------------------------------------------------- daemon
216
+
217
+ function pidAlive(pid) { try { process.kill(pid, 0); return true; } catch { return false; } }
218
+
219
+ function takeLock() {
220
+ for (let attempt = 0; attempt < 2; attempt++) {
221
+ try {
222
+ const fd = fs.openSync(LOCK_FILE, 'wx');
223
+ fs.writeSync(fd, String(process.pid));
224
+ fs.closeSync(fd);
225
+ return true;
226
+ } catch {
227
+ const holder = Number(fs.readFileSync(LOCK_FILE, 'utf8')) || 0;
228
+ if (holder && pidAlive(holder)) return false;
229
+ fs.rmSync(LOCK_FILE, { force: true });
230
+ }
231
+ }
232
+ return false;
233
+ }
234
+
235
+ function runDaemon() {
236
+ fs.mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 });
237
+ if (!takeLock()) { log('another daemon holds the lock; exiting'); process.exit(0); }
238
+ const release = () => {
239
+ try { if (fs.readFileSync(LOCK_FILE, 'utf8') === String(process.pid)) fs.rmSync(LOCK_FILE); } catch {}
240
+ };
241
+ process.on('exit', release);
242
+ for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => process.exit(0));
243
+
244
+ const token = crypto.randomBytes(24).toString('hex');
245
+ const server = http.createServer(async (req, res) => {
246
+ if (req.method === 'GET' && req.url === '/blank') {
247
+ res.writeHead(200, { 'content-type': 'text/html' });
248
+ res.end('<!doctype html><title>Opening…</title>');
249
+ return;
250
+ }
251
+ // A browser page could reach localhost; it can't know the token, and it would send Origin.
252
+ if (req.headers['x-bridge-token'] !== token || req.headers.origin) { res.writeHead(403); res.end(); return; }
253
+ const reply = (body, status = 200) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); };
254
+ if (req.method === 'GET' && req.url === '/health') return reply({ ok: true, version: VERSION, chrome: chrome.connected });
255
+ if (req.method === 'POST' && req.url === '/shutdown') { reply({ ok: true }); setTimeout(() => process.exit(0), 50); return; }
256
+ if (req.method === 'POST' && req.url === '/call') {
257
+ let body = '';
258
+ for await (const chunk of req) body += chunk;
259
+ const { name, args } = JSON.parse(body);
260
+ // Each agent passes its own pre.dev API key; the daemon is shared, so it never uses one of its own.
261
+ const ctx = {
262
+ apiKey: String(req.headers['x-predev-api-key'] || '').trim(),
263
+ apiUrl: String(req.headers['x-predev-api-url'] || DEFAULT_API_URL).trim(),
264
+ };
265
+ const started = Date.now();
266
+ try {
267
+ const result = await callTool(name, args, ctx);
268
+ log('call', name, 'ok', `${Date.now() - started}ms`);
269
+ reply(result);
270
+ } catch (error) {
271
+ log('call', name, 'error', error.message);
272
+ reply({ content: [{ type: 'text', text: `Error: ${error.message}` }], isError: true });
273
+ }
274
+ return;
275
+ }
276
+ reply({ error: 'not found' }, 404);
277
+ });
278
+ server.listen(0, '127.0.0.1', () => {
279
+ daemonPort = server.address().port;
280
+ const tmp = `${STATE_FILE}.${process.pid}`;
281
+ fs.writeFileSync(tmp, JSON.stringify({ port: server.address().port, token, pid: process.pid, version: VERSION }), { mode: 0o600 });
282
+ fs.renameSync(tmp, STATE_FILE);
283
+ log('daemon listening on', server.address().port, 'version', VERSION);
284
+ });
285
+ }
286
+
287
+ // ---------------------------------------------------------------- MCP stdio shim
288
+
289
+ const DEFAULT_API_URL = 'https://api.pre.dev';
290
+ function readCredentials() {
291
+ try { return JSON.parse(fs.readFileSync(CREDENTIALS_FILE, 'utf8')) || {}; } catch { return {}; }
292
+ }
293
+ // Read on every call, so signing in takes effect without restarting the agent. An agent's
294
+ // PREDEV_API_KEY wins over the saved key.
295
+ function shimCtx() {
296
+ const saved = process.env.PREDEV_API_KEY ? {} : readCredentials();
297
+ return {
298
+ apiKey: String(process.env.PREDEV_API_KEY || saved.apiKey || '').trim(),
299
+ apiUrl: String(process.env.PREDEV_API_URL || saved.apiUrl || DEFAULT_API_URL).trim(),
300
+ };
301
+ }
302
+
303
+ function request(state, method, route, body, timeout = 180000) {
304
+ const { apiKey, apiUrl } = shimCtx();
305
+ return new Promise((resolve, reject) => {
306
+ const req = http.request({
307
+ host: '127.0.0.1', port: state.port, path: route, method, timeout,
308
+ headers: {
309
+ 'x-bridge-token': state.token, 'content-type': 'application/json',
310
+ ...(apiKey ? { 'x-predev-api-key': apiKey, 'x-predev-api-url': apiUrl } : {}),
311
+ },
312
+ }, res => {
313
+ let data = '';
314
+ res.on('data', chunk => { data += chunk; });
315
+ res.on('end', () => { try { resolve(JSON.parse(data)); } catch (error) { reject(error); } });
316
+ });
317
+ req.on('timeout', () => req.destroy(new Error('The Chrome MCP daemon did not answer in time.')));
318
+ req.on('error', reject);
319
+ if (body) req.write(JSON.stringify(body));
320
+ req.end();
321
+ });
322
+ }
323
+
324
+ function readState() {
325
+ try { return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')); } catch { return null; }
326
+ }
327
+
328
+ async function ensureDaemon() {
329
+ for (let attempt = 0; attempt < 3; attempt++) {
330
+ const state = readState();
331
+ const health = state && await request(state, 'GET', '/health', null, 3000).catch(() => null);
332
+ if (health?.ok && health.version === VERSION) return state;
333
+ if (health?.ok) { await request(state, 'POST', '/shutdown', {}, 3000).catch(() => {}); await sleep(300); }
334
+ fs.mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 });
335
+ const out = fs.openSync(LOG_FILE, 'a');
336
+ spawn(process.execPath, [SELF, 'daemon'], { detached: true, stdio: ['ignore', out, out], windowsHide: true }).unref();
337
+ for (let i = 0; i < 50; i++) {
338
+ await sleep(100);
339
+ const fresh = readState();
340
+ if (fresh && fresh.version === VERSION && await request(fresh, 'GET', '/health', null, 1000).then(h => h.ok).catch(() => false)) return fresh;
341
+ }
342
+ }
343
+ throw new Error(`Could not start the Chrome MCP daemon; see ${LOG_FILE}.`);
344
+ }
345
+
346
+ const remote = createRemote({ version: PKG.version || '0.0.0' });
347
+
348
+ function runMcp() {
349
+ const write = message => process.stdout.write(`${JSON.stringify(message)}\n`);
350
+ const handle = async message => {
351
+ const reply = result => write({ jsonrpc: '2.0', id: message.id, result });
352
+ try {
353
+ switch (message.method) {
354
+ case 'initialize':
355
+ return reply({
356
+ protocolVersion: message.params?.protocolVersion || '2025-06-18',
357
+ capabilities: { tools: {} },
358
+ serverInfo: { name: 'predev', version: PKG.version || '0.0.0' },
359
+ instructions: (await loadTools()).instructions(shimCtx()),
360
+ });
361
+ case 'tools/list': {
362
+ const ctx = shimCtx();
363
+ return reply({ tools: [...(await loadTools()).toolList(ctx), ...(await remote.listTools(ctx))] });
364
+ }
365
+ case 'tools/call': {
366
+ const ctx = shimCtx();
367
+ const { name, arguments: args } = message.params;
368
+ // pre.dev's hosted tools go straight to pre.dev; they don't need Chrome.
369
+ if (await remote.isRemote(ctx, name)) {
370
+ if (!ctx.apiKey) return reply({ content: [{ type: 'text', text: 'pre.dev tools need a free pre.dev account. Sign in by running `npx -y @predotdev/mcp login` in a terminal (no restart needed).' }], isError: true });
371
+ return reply(await remote.callTool(ctx, name, args));
372
+ }
373
+ const state = await ensureDaemon();
374
+ return reply(await request(state, 'POST', '/call', { name, args }));
375
+ }
376
+ case 'ping':
377
+ return reply({});
378
+ default:
379
+ if (message.id !== undefined) write({ jsonrpc: '2.0', id: message.id, error: { code: -32601, message: `Method not found: ${message.method}` } });
380
+ }
381
+ } catch (error) {
382
+ if (message.id !== undefined) reply({ content: [{ type: 'text', text: `Error: ${error.message}` }], isError: true });
383
+ }
384
+ };
385
+ let buffer = '';
386
+ process.stdin.setEncoding('utf8');
387
+ process.stdin.on('data', chunk => {
388
+ buffer += chunk;
389
+ let newline;
390
+ while ((newline = buffer.indexOf('\n')) >= 0) {
391
+ const line = buffer.slice(0, newline).trim();
392
+ buffer = buffer.slice(newline + 1);
393
+ if (!line) continue;
394
+ let message;
395
+ try { message = JSON.parse(line); } catch { continue; }
396
+ handle(message);
397
+ }
398
+ });
399
+ process.stdin.on('end', () => process.exit(0));
400
+ }
401
+
402
+ // ---------------------------------------------------------------- terminal commands
403
+
404
+ const HELP = `pre.dev MCP ${PKG.version || ''}
405
+ pre.dev in any coding agent: Browser Agents in the Chrome you already use (every profile, your
406
+ logins, your tabs) and in pre.dev's cloud, plus specs and plans.
407
+
408
+ Set it up in one go (signs you in to pre.dev, adds it to every coding agent on this computer,
409
+ and connects to Chrome). Run it again any time to update:
410
+
411
+ npx -y @predotdev/mcp setup
412
+
413
+ Commands:
414
+ predev-mcp setup sign in, add to your agents, connect to Chrome (safe to repeat; also updates)
415
+ predev-mcp check check your setup and list your Chrome profiles
416
+ predev-mcp login sign in to pre.dev again
417
+ predev-mcp logout forget the saved pre.dev key
418
+ predev-mcp stop stop the background daemon (it restarts on the next tool call)
419
+ predev-mcp uninstall remove it from every agent and delete its files
420
+ predev-mcp run the MCP server (what your agent launches)
421
+ predev-mcp --version
422
+
423
+ Docs: https://docs.pre.dev/browser-agents/local
424
+ `;
425
+
426
+ async function runCheck() {
427
+ let failed = false;
428
+ const ok = text => console.log(` ✓ ${text}`);
429
+ const bad = text => { failed = true; console.log(` ✗ ${text}`); };
430
+ console.log(`pre.dev MCP ${PKG.version || ''} setup check\n`);
431
+ ok(`Node ${process.versions.node}`);
432
+ if (fs.existsSync(path.join(CHROME_DIR, 'Local State'))) ok(`Chrome data found: ${CHROME_DIR}`);
433
+ else bad(`No Chrome data at ${CHROME_DIR}. Install Google Chrome, or set CHROME_MCP_USER_DATA_DIR.`);
434
+ if (fs.existsSync(path.join(CHROME_DIR, 'DevToolsActivePort'))) ok('Remote debugging is on');
435
+ else bad('Remote debugging is off: open chrome://inspect/#remote-debugging in Chrome and turn it on, then run this again.');
436
+ if (!failed) {
437
+ console.log('\n Connecting. If Chrome asks "Allow remote debugging?", click Allow.\n');
438
+ const end = Date.now() + 120000;
439
+ let result = null;
440
+ while (Date.now() < end) {
441
+ try {
442
+ const state = await ensureDaemon();
443
+ result = await request(state, 'POST', '/call', { name: 'chrome_profiles', args: {} });
444
+ } catch (error) { result = { isError: true, content: [{ text: `Error: ${error.message}` }] }; }
445
+ if (!result.isError || !/Allow remote debugging/.test(result.content?.[0]?.text || '')) break;
446
+ await sleep(1000);
447
+ }
448
+ const text = result?.content?.[0]?.text || '';
449
+ if (result?.isError) bad(text.replace(/^Error: /, ''));
450
+ else {
451
+ ok('Connected to Chrome. Your profiles:');
452
+ console.log(text.split('\n').map(line => ` ${line}`).join('\n'));
453
+ }
454
+ }
455
+ const { apiKey, apiUrl } = shimCtx();
456
+ if (!apiKey) {
457
+ console.log(' · Plain-words actions (chrome_act) are off: sign in to pre.dev (free) with');
458
+ console.log(' npx -y @predotdev/mcp login');
459
+ } else {
460
+ let usage = null;
461
+ const status = await fetch(`${apiUrl.replace(/\/+$/, '')}/v1/usage?days=30`, {
462
+ headers: { Authorization: `Bearer ${apiKey}` }, signal: AbortSignal.timeout(15000),
463
+ }).then(async r => { usage = await r.json().catch(() => null); return r.status; }).catch(() => 0);
464
+ if (status === 200) {
465
+ const actions = (usage?.by_model || []).filter(row => row.model === 'browser-agents-local').reduce((n, row) => n + row.requests, 0);
466
+ const plan = /^trial$/i.test(usage?.tier || '') ? 'Free' : usage?.tier;
467
+ ok(`Signed in to pre.dev${plan ? ` (plan: ${plan})` : ''}: plain-words actions are on, ${actions} used in the last 30 days`);
468
+ if (/^(trial|free)$/i.test(usage?.tier || '')) console.log(' The free plan includes a limited number of them. More: https://pre.dev/billing?from=browser-agents-local');
469
+ }
470
+ else if (status === 401 || status === 403) bad('pre.dev rejected your key. Sign in again with: npx -y @predotdev/mcp login');
471
+ else bad(`Could not check PREDEV_API_KEY with ${apiUrl} (${status ? `HTTP ${status}` : 'no answer'}).`);
472
+ }
473
+ console.log(failed ? '\nFix the ✗ items above, then run this again.' : '\nAll set. Add the server to your agent and ask it to use Chrome.');
474
+ process.exit(failed ? 1 : 0);
475
+ }
476
+
477
+ async function runStop() {
478
+ const state = readState();
479
+ const stopped = state && await request(state, 'POST', '/shutdown', {}, 3000).then(r => r.ok).catch(() => false);
480
+ console.log(stopped ? 'Stopped the Chrome MCP daemon.' : 'The Chrome MCP daemon is not running.');
481
+ }
482
+
483
+ if (typeof WebSocket === 'undefined') {
484
+ process.stderr.write(`pre.dev Chrome MCP needs Node 22 or newer (this is Node ${process.versions.node}).\n`);
485
+ process.exit(1);
486
+ }
487
+
488
+ async function runSetupCommand(name) {
489
+ const { createSetup } = await import(pathToFileURL(path.join(path.dirname(SELF), 'setup.mjs')).href);
490
+ const setup = createSetup({
491
+ baseStateDir: BASE_STATE_DIR, stateDir: STATE_DIR, chromeDir: CHROME_DIR, customChromeDir: Boolean(CUSTOM_DIR),
492
+ pkg: PKG, sleep, ensureDaemon, request, readCredentials, credentialsFile: CREDENTIALS_FILE,
493
+ packageRoot: path.join(path.dirname(SELF), '..'),
494
+ });
495
+ await setup[name]();
496
+ }
497
+
498
+ const command = process.argv[2] || '';
499
+ if (command === 'daemon') runDaemon();
500
+ else if (['setup', 'install', 'login', 'logout', 'uninstall'].includes(command)) runSetupCommand(command === 'install' ? 'setup' : command);
501
+ else if (command === 'check') runCheck();
502
+ else if (command === 'stop') runStop();
503
+ else if (command === '--version' || command === '-v') console.log(PKG.version || '0.0.0');
504
+ else if (command === '--help' || command === '-h' || command === 'help') process.stdout.write(HELP);
505
+ else if (command === '' || command === 'mcp') {
506
+ if (process.stdin.isTTY && !command) process.stderr.write(HELP);
507
+ else runMcp();
508
+ } else {
509
+ process.stderr.write(`Unknown command "${command}".\n\n${HELP}`);
510
+ process.exit(1);
511
+ }
package/src/remote.mjs ADDED
@@ -0,0 +1,112 @@
1
+ // pre.dev's hosted MCP tools (cloud Browser Agents, specs, plans), served through this local
2
+ // server on the same saved key, so one install gives an agent everything pre.dev offers.
3
+ //
4
+ // The hosted server (https://api.pre.dev/mcp) speaks streamable HTTP without sessions: every
5
+ // request is one POST, and the reply is JSON or a short event stream ending in the result.
6
+ // Signed out, or on a plan without the hosted tools, they are still listed (from the public
7
+ // /mcp/info) and a call says how to unlock them, so the agent can tell the user.
8
+
9
+ const LIST_TTL_MS = 10 * 60_000;
10
+ /** Kept callable for old prompts, but not listed: the hosted server marks them deprecated. */
11
+ const HIDDEN = new Set(['browser_task', 'browser_task_list', 'browser_task_get']);
12
+ const SIGN_IN = 'Sign in to pre.dev (free) by running `npx -y @predotdev/mcp login` in a terminal; no restart needed.';
13
+
14
+ class PlanRequired extends Error {}
15
+
16
+ export function createRemote({ version }) {
17
+ let cache = { key: null, at: 0, tools: [], locked: null };
18
+ let nextId = 0;
19
+ const base = ctx => (ctx.apiUrl || 'https://api.pre.dev').replace(/\/+$/, '');
20
+
21
+ async function rpc(ctx, method, params, timeout) {
22
+ const id = ++nextId;
23
+ const response = await fetch(`${base(ctx)}/mcp`, {
24
+ method: 'POST',
25
+ headers: {
26
+ Authorization: `Bearer ${ctx.apiKey}`,
27
+ 'Content-Type': 'application/json',
28
+ Accept: 'application/json, text/event-stream',
29
+ 'MCP-Protocol-Version': '2025-06-18',
30
+ 'User-Agent': `predev-mcp/${version}`,
31
+ },
32
+ body: JSON.stringify({ jsonrpc: '2.0', id, method, params }),
33
+ signal: AbortSignal.timeout(timeout),
34
+ });
35
+ const text = await response.text();
36
+ if (response.status === 401) {
37
+ let message = '';
38
+ try { message = JSON.parse(text).message || ''; } catch {}
39
+ // A valid key on a plan without the hosted tools: the message names the subscribe page.
40
+ if (/subscription/i.test(message)) throw new PlanRequired(message);
41
+ throw new Error('pre.dev rejected the saved key. Sign in again by running `npx -y @predotdev/mcp login` in a terminal.');
42
+ }
43
+ const messages = (response.headers.get('content-type') || '').includes('text/event-stream')
44
+ ? text.split('\n').filter(line => line.startsWith('data:')).map(line => { try { return JSON.parse(line.slice(5)); } catch { return null; } })
45
+ : [(() => { try { return JSON.parse(text); } catch { return null; } })()];
46
+ const reply = messages.find(m => m && m.id === id);
47
+ if (!reply) throw new Error(`pre.dev answered ${response.status} without a result.`);
48
+ if (reply.error) throw new Error(reply.error.message || 'pre.dev returned an error.');
49
+ return reply.result;
50
+ }
51
+
52
+ // The public catalog: names and descriptions only, listed with a note on what unlocks them.
53
+ async function catalog(ctx, note) {
54
+ const info = await fetch(`${base(ctx)}/mcp/info`, { signal: AbortSignal.timeout(10000) }).then(r => r.json());
55
+ return (info.tools || []).filter(tool => !tool.deprecated && !HIDDEN.has(tool.name)).map(tool => ({
56
+ name: tool.name,
57
+ ...(tool.title ? { title: tool.title } : {}),
58
+ description: `${note} ${tool.description || ''}`.trim(),
59
+ inputSchema: { type: 'object', additionalProperties: true },
60
+ }));
61
+ }
62
+
63
+ /** The hosted tools for this key, cached for a while; listed even when locked, so agents can offer them. */
64
+ async function listTools(ctx) {
65
+ const key = ctx.apiKey || '';
66
+ if (cache.key === key && Date.now() - cache.at < LIST_TTL_MS) return cache.tools;
67
+ let tools = [];
68
+ let locked = null;
69
+ try {
70
+ if (!key) {
71
+ locked = SIGN_IN;
72
+ tools = await catalog(ctx, 'Needs a free pre.dev account, not signed in yet.');
73
+ } else {
74
+ const result = await rpc(ctx, 'tools/list', {}, 15000);
75
+ tools = (result?.tools || []).filter(tool => !HIDDEN.has(tool.name) && !tool.name.startsWith('chrome_'));
76
+ }
77
+ } catch (error) {
78
+ if (error instanceof PlanRequired) {
79
+ locked = error.message;
80
+ tools = await catalog(ctx, 'Needs a pre.dev plan; this account does not have one yet.').catch(() => []);
81
+ } else if (cache.key === key) {
82
+ return cache.tools;
83
+ }
84
+ }
85
+ cache = { key, at: Date.now(), tools, locked };
86
+ return tools;
87
+ }
88
+
89
+ async function isRemote(ctx, name) {
90
+ if (name.startsWith('chrome_')) return false;
91
+ return HIDDEN.has(name) || (await listTools(ctx)).some(tool => tool.name === name);
92
+ }
93
+
94
+ const offer = message => ({
95
+ content: [{ type: 'text', text: `${message}\nTell the user. If they want it, offer to open the link in their Chrome with chrome_open (they finish sign-up or checkout themselves; never enter payment details).` }],
96
+ isError: true,
97
+ });
98
+
99
+ // Specs and cloud browser runs can take minutes; the hosted tools return when they are done.
100
+ async function callTool(ctx, name, args) {
101
+ await listTools(ctx);
102
+ if (cache.locked) return offer(cache.locked);
103
+ try {
104
+ return await rpc(ctx, 'tools/call', { name, arguments: args || {} }, 15 * 60_000);
105
+ } catch (error) {
106
+ if (error instanceof PlanRequired) { cache.locked = error.message; return offer(error.message); }
107
+ throw error;
108
+ }
109
+ }
110
+
111
+ return { listTools, isRemote, callTool };
112
+ }