@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/setup.mjs ADDED
@@ -0,0 +1,413 @@
1
+ // One-command setup for the pre.dev MCP, plus login, logout and uninstall.
2
+ //
3
+ // predev-mcp setup sign in to pre.dev, install a stable copy, add it to every coding agent
4
+ // found on this computer, and connect to Chrome
5
+ // predev-mcp login sign in again (saves the key for every agent)
6
+ // predev-mcp logout forget the saved key
7
+ // predev-mcp uninstall remove it from every agent, stop it, and delete its files
8
+ //
9
+ // Agents are registered with absolute paths (this Node and the stable copy), so desktop apps that
10
+ // don't load your shell's PATH start it too, and no agent config holds the key: the server reads
11
+ // the key saved by login (an agent's PREDEV_API_KEY still wins).
12
+
13
+ import { execFile, spawnSync } from 'node:child_process';
14
+ import crypto from 'node:crypto';
15
+ import fs from 'node:fs';
16
+ import os from 'node:os';
17
+ import path from 'node:path';
18
+
19
+ const CLIENT_ID = 'predev-chrome-mcp';
20
+ // pre.dev's own servers: the hosted one, this package under either name, or its installed copy.
21
+ const OWN = /api\.pre\.dev\/mcp|predotdev\/(chrome-)?mcp|\.predev\/(chrome-)?mcp\b/;
22
+ const NAMES = ['predev', 'pre-dev'];
23
+ /** Names pre.dev servers were registered under before (hosted install, or this one as Browser Agents Local). */
24
+ const LEGACY_NAMES = ['predotdev', 'pre.dev', 'chrome', 'predev-chrome'];
25
+
26
+ export function createSetup(ctx) {
27
+ const { baseStateDir, stateDir, chromeDir, customChromeDir, pkg, sleep, ensureDaemon, request, readCredentials, credentialsFile, packageRoot } = ctx;
28
+ const APP_DIR = path.join(baseStateDir, 'app');
29
+ const APP_ENTRY = path.join(APP_DIR, 'src', 'bridge.mjs');
30
+ const NODE = process.execPath;
31
+ const home = os.homedir();
32
+ const tty = process.stdout.isTTY;
33
+ let failed = false;
34
+
35
+ const ok = text => console.log(` ✓ ${text}`);
36
+ const skip = text => console.log(` · ${text}`);
37
+ const bad = text => { failed = true; console.log(` ✗ ${text}`); };
38
+ const step = (n, text) => console.log(`\n${n}. ${text}`);
39
+
40
+ // A ticking "waiting" line on a terminal; a line every 15 s when output goes to a log or an agent.
41
+ function waiter(text) {
42
+ const started = Date.now();
43
+ let last = 0;
44
+ const elapsed = () => { const s = Math.round((Date.now() - started) / 1000); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; };
45
+ return {
46
+ tick() {
47
+ if (tty) process.stdout.write(`\r … ${text} ${elapsed()} `);
48
+ else if (Date.now() - last > 15000) { last = Date.now(); console.log(` … ${text} ${elapsed()}`); }
49
+ },
50
+ done() { if (tty) process.stdout.write('\r\x1b[2K'); },
51
+ };
52
+ }
53
+
54
+ function openUrl(url) {
55
+ if (process.env.PREDEV_NO_BROWSER) return Promise.resolve(false);
56
+ const [cmd, args] = process.platform === 'darwin' ? ['open', [url]]
57
+ : process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]] : ['xdg-open', [url]];
58
+ return new Promise(resolve => execFile(cmd, args, error => resolve(!error)));
59
+ }
60
+
61
+ const has = bin => spawnSync(process.platform === 'win32' ? 'where' : 'which', [bin], { stdio: 'ignore' }).status === 0;
62
+ const run = (bin, args) => spawnSync(bin, args, { encoding: 'utf8', timeout: 30000 });
63
+
64
+ // ------------------------------------------------------------------ sign in
65
+
66
+ const apiUrl = () => (process.env.PREDEV_API_URL || readCredentials().apiUrl || 'https://api.pre.dev').replace(/\/+$/, '');
67
+
68
+ async function keyWorks(key, url) {
69
+ if (!key) return false;
70
+ const status = await fetch(`${url}/v1/usage?days=1`, { headers: { Authorization: `Bearer ${key}` }, signal: AbortSignal.timeout(15000) })
71
+ .then(r => r.status).catch(() => 0);
72
+ return status === 200;
73
+ }
74
+
75
+ function saveKey(apiKey, url) {
76
+ fs.mkdirSync(baseStateDir, { recursive: true, mode: 0o700 });
77
+ const tmp = `${credentialsFile}.${process.pid}`;
78
+ fs.writeFileSync(tmp, JSON.stringify({ apiKey, apiUrl: url, savedAt: new Date().toISOString() }, null, 2), { mode: 0o600 });
79
+ fs.renameSync(tmp, credentialsFile);
80
+ }
81
+
82
+ // The pre.dev CLI's short-code sign-in: approve in the browser, the key comes back here.
83
+ async function signIn() {
84
+ const url = apiUrl();
85
+ const verifier = crypto.randomBytes(32).toString('base64url');
86
+ const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
87
+ const state = crypto.randomBytes(16).toString('base64url');
88
+ const start = await fetch(`${url}/oauth/device/authorize`, {
89
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: AbortSignal.timeout(20000),
90
+ body: JSON.stringify({
91
+ client_id: CLIENT_ID, code_challenge: challenge, code_challenge_method: 'S256', state, scope: 'mcp',
92
+ device_os: { darwin: 'macOS', win32: 'Windows', linux: 'Linux' }[process.platform] || process.platform,
93
+ device_host: os.hostname(), cli_version: `predev-mcp ${pkg.version || ''}`.trim(),
94
+ }),
95
+ }).then(async r => (r.ok ? r.json() : Promise.reject(new Error(`pre.dev answered ${r.status}`))));
96
+ const link = start.verification_uri_complete;
97
+ const opened = await openUrl(link);
98
+ console.log(` ${opened ? 'Opened pre.dev in your browser.' : 'Open this link in your browser:'} Sign in (or create a free account) and approve.`);
99
+ if (opened) console.log(` If nothing opened: ${link}`);
100
+ else console.log(` ${link}`);
101
+ console.log(` Your code: ${start.user_code}`);
102
+ const end = Date.now() + (start.expires_in || 600) * 1000;
103
+ const wait = waiter('Waiting for you to approve in the browser');
104
+ let code = null;
105
+ while (Date.now() < end && !code) {
106
+ wait.tick();
107
+ await sleep((start.interval || 2) * 1000);
108
+ const poll = await fetch(`${url}/oauth/cli/poll?state=${encodeURIComponent(state)}`, { signal: AbortSignal.timeout(15000) })
109
+ .then(r => r.json()).catch(() => ({ pending: true }));
110
+ if (poll.error) { wait.done(); throw new Error(poll.error === 'access_denied' ? 'Sign-in was denied in the browser.' : `Sign-in failed: ${poll.error}`); }
111
+ if (poll.code) code = poll.code;
112
+ }
113
+ wait.done();
114
+ if (!code) throw new Error('The sign-in code expired. Run this again for a new one.');
115
+ const token = await fetch(`${url}/oauth/token`, {
116
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: AbortSignal.timeout(20000),
117
+ body: JSON.stringify({ grant_type: 'authorization_code', code, code_verifier: verifier, redirect_uri: start.redirect_uri, client_id: CLIENT_ID }),
118
+ }).then(async r => (r.ok ? r.json() : Promise.reject(new Error(`pre.dev answered ${r.status} to the sign-in`))));
119
+ if (!token.access_token) throw new Error('pre.dev did not return a key.');
120
+ saveKey(token.access_token, url);
121
+ return token.access_token;
122
+ }
123
+
124
+ async function ensureSignedIn(force = false) {
125
+ const url = apiUrl();
126
+ if (!force) {
127
+ if (process.env.PREDEV_API_KEY) {
128
+ if (await keyWorks(process.env.PREDEV_API_KEY, url)) {
129
+ saveKey(process.env.PREDEV_API_KEY, url);
130
+ return ok(`Saved PREDEV_API_KEY from your environment in ${credentialsFile.replace(home, '~')} for every agent`);
131
+ }
132
+ return bad('pre.dev rejected PREDEV_API_KEY. Unset it to sign in here instead.');
133
+ }
134
+ const saved = readCredentials();
135
+ if (saved.apiKey && await keyWorks(saved.apiKey, saved.apiUrl || url)) return ok('Already signed in to pre.dev');
136
+ }
137
+ try {
138
+ await signIn();
139
+ ok(`Signed in. Your key is saved in ${credentialsFile.replace(home, '~')} for every agent.`);
140
+ } catch (error) {
141
+ bad(`${error.message} Plain-words actions stay off until you run: npx -y @predotdev/mcp login`);
142
+ }
143
+ }
144
+
145
+ // ------------------------------------------------------------------ stable copy
146
+
147
+ function installApp() {
148
+ if (path.resolve(packageRoot) === path.resolve(APP_DIR)) return ok(`Installed in ${APP_DIR.replace(home, '~')}`);
149
+ fs.mkdirSync(path.join(APP_DIR, 'src'), { recursive: true });
150
+ for (const file of ['src/bridge.mjs', 'src/tools.mjs', 'src/setup.mjs', 'package.json', 'LICENSE', 'README.md']) {
151
+ const from = path.join(packageRoot, file);
152
+ if (!fs.existsSync(from)) continue;
153
+ const tmp = path.join(APP_DIR, `${file}.${process.pid}`);
154
+ fs.copyFileSync(from, tmp);
155
+ fs.renameSync(tmp, path.join(APP_DIR, file));
156
+ }
157
+ fs.chmodSync(APP_ENTRY, 0o755);
158
+ ok(`Installed version ${pkg.version || '?'} in ${APP_DIR.replace(home, '~')}`);
159
+ }
160
+
161
+ // ------------------------------------------------------------------ agents
162
+
163
+ // Registers as "predev" ("pre-dev" when another tool already uses "predev"). Every other entry
164
+ // that is pre.dev's (the hosted server at api.pre.dev/mcp, or an older install of this one) is
165
+ // removed, since this server carries all of their tools; nothing else is ever touched.
166
+ function pickName(entries) {
167
+ return NAMES.find(name => !entries[name] || OWN.test(JSON.stringify(entries[name]))) || null;
168
+ }
169
+
170
+ function jsonAgent(label, file, key, entry, detect) {
171
+ const read = () => {
172
+ if (!fs.existsSync(file)) return { config: {}, existed: false };
173
+ return { config: JSON.parse(fs.readFileSync(file, 'utf8') || '{}'), existed: true };
174
+ };
175
+ const write = (config, existed) => {
176
+ fs.mkdirSync(path.dirname(file), { recursive: true });
177
+ if (existed && !fs.existsSync(`${file}.bak-predev`)) fs.copyFileSync(file, `${file}.bak-predev`);
178
+ const tmp = `${file}.${process.pid}`;
179
+ fs.writeFileSync(tmp, `${JSON.stringify(config, null, 2)}\n`);
180
+ fs.renameSync(tmp, file);
181
+ };
182
+ return {
183
+ label, detect,
184
+ register() {
185
+ let state;
186
+ try { state = read(); } catch {
187
+ return { manual: `couldn't read ${file.replace(home, '~')} (comments or invalid JSON); add the server by hand` };
188
+ }
189
+ const { config, existed } = state;
190
+ config[key] ??= {};
191
+ const name = pickName(config[key]);
192
+ if (!name) return { manual: `"predev" and "pre-dev" are taken in ${file.replace(home, '~')}` };
193
+ const replaced = Object.keys(config[key]).filter(other => other !== name && OWN.test(JSON.stringify(config[key][other])));
194
+ for (const other of replaced) delete config[key][other];
195
+ config[key][name] = entry();
196
+ write(config, existed);
197
+ return { name, replaced };
198
+ },
199
+ unregister() {
200
+ let state;
201
+ try { state = read(); } catch { return 0; }
202
+ const mine = Object.keys(state.config[key] || {}).filter(name => OWN.test(JSON.stringify(state.config[key][name])));
203
+ for (const name of mine) delete state.config[key][name];
204
+ if (mine.length) write(state.config, true);
205
+ return mine.length;
206
+ },
207
+ };
208
+ }
209
+
210
+ const stdio = () => ({ command: NODE, args: [APP_ENTRY] });
211
+ const appSupport = process.platform === 'darwin' ? path.join(home, 'Library/Application Support') : null;
212
+ const exists = p => fs.existsSync(p);
213
+
214
+ // CLI-managed agents: what they hold under names pre.dev has used (its own description of each).
215
+ const claudeEntries = () => {
216
+ const entries = {};
217
+ for (const name of [...NAMES, ...LEGACY_NAMES]) {
218
+ const got = run('claude', ['mcp', 'get', name]);
219
+ if (got.status === 0) entries[name] = got.stdout;
220
+ }
221
+ return entries;
222
+ };
223
+ const codexEntries = () => {
224
+ const out = run('codex', ['mcp', 'list', '--json']);
225
+ try { return Object.fromEntries(JSON.parse(out.stdout).map(server => [server.name, server])); } catch { return {}; }
226
+ };
227
+
228
+ function cliAgent(label, bin, entries, remove, add) {
229
+ return {
230
+ label, detect: () => has(bin),
231
+ register() {
232
+ const current = entries();
233
+ const name = pickName(current);
234
+ if (!name) return { manual: '"predev" and "pre-dev" are taken' };
235
+ const replaced = Object.keys(current).filter(other => other !== name && OWN.test(JSON.stringify(current[other])));
236
+ for (const other of [...replaced, ...(current[name] ? [name] : [])]) remove(other);
237
+ const added = add(name);
238
+ return added.status === 0 ? { name, replaced } : { manual: (added.stderr || added.stdout || `${bin} mcp add failed`).trim().split('\n')[0] };
239
+ },
240
+ unregister() {
241
+ const current = entries();
242
+ const mine = Object.keys(current).filter(name => OWN.test(JSON.stringify(current[name])));
243
+ for (const name of mine) remove(name);
244
+ return mine.length;
245
+ },
246
+ };
247
+ }
248
+
249
+ const AGENTS = [
250
+ cliAgent('Claude Code', 'claude', claudeEntries,
251
+ name => run('claude', ['mcp', 'remove', name, '-s', 'user']),
252
+ name => run('claude', ['mcp', 'add', '--scope', 'user', name, '--', NODE, APP_ENTRY])),
253
+ cliAgent('Codex', 'codex', codexEntries,
254
+ name => run('codex', ['mcp', 'remove', name]),
255
+ name => {
256
+ const added = run('codex', ['mcp', 'add', name, '--', NODE, APP_ENTRY]);
257
+ // Specs and cloud browser runs need more than Codex's default tool timeout.
258
+ const file = path.join(process.env.CODEX_HOME || path.join(home, '.codex'), 'config.toml');
259
+ try {
260
+ const lines = fs.readFileSync(file, 'utf8').split('\n');
261
+ const at = lines.findIndex(line => line.trim() === `[mcp_servers.${name}]`);
262
+ let end = lines.findIndex((line, i) => i > at && /^\s*\[/.test(line));
263
+ if (end < 0) end = lines.length;
264
+ if (at >= 0 && !lines.slice(at, end).some(line => /^\s*tool_timeout_sec\s*=/.test(line))) {
265
+ lines.splice(at + 1, 0, 'tool_timeout_sec = 900');
266
+ fs.writeFileSync(file, lines.join('\n'));
267
+ }
268
+ } catch {}
269
+ return added;
270
+ }),
271
+ jsonAgent('Cursor', path.join(home, '.cursor/mcp.json'), 'mcpServers', stdio, () => exists(path.join(home, '.cursor'))),
272
+ jsonAgent('Windsurf', path.join(home, '.codeium/windsurf/mcp_config.json'), 'mcpServers', stdio, () => exists(path.join(home, '.codeium/windsurf'))),
273
+ jsonAgent('Gemini CLI', path.join(home, '.gemini/settings.json'), 'mcpServers', stdio, () => exists(path.join(home, '.gemini')) || has('gemini')),
274
+ jsonAgent('OpenCode', path.join(home, '.config/opencode/opencode.json'), 'mcp',
275
+ () => ({ type: 'local', command: [NODE, APP_ENTRY], enabled: true }),
276
+ () => (exists(path.join(home, '.config/opencode')) || has('opencode')) && !exists(path.join(home, '.config/opencode/opencode.jsonc'))),
277
+ ...(appSupport ? [
278
+ jsonAgent('VS Code', path.join(appSupport, 'Code/User/mcp.json'), 'servers',
279
+ () => ({ type: 'stdio', ...stdio() }), () => exists(path.join(appSupport, 'Code/User'))),
280
+ jsonAgent('Claude Desktop', path.join(appSupport, 'Claude/claude_desktop_config.json'), 'mcpServers', stdio,
281
+ () => exists(path.join(appSupport, 'Claude'))),
282
+ ] : []),
283
+ ];
284
+
285
+ function registerAgents() {
286
+ const added = [];
287
+ for (const agent of AGENTS) {
288
+ let found = false;
289
+ try { found = agent.detect(); } catch {}
290
+ if (!found) continue;
291
+ try {
292
+ const result = agent.register();
293
+ if (!result.name) { skip(`${agent.label}: ${result.manual}. See https://github.com/predotdev/mcp#setup-for-every-agent`); continue; }
294
+ added.push(agent.label);
295
+ const notes = [
296
+ result.name === 'predev' ? '' : `as "${result.name}", since "predev" is taken`,
297
+ result.replaced?.length ? `replaced ${result.replaced.map(n => `"${n}"`).join(', ')}` : '',
298
+ ].filter(Boolean);
299
+ ok(`${agent.label}${notes.length ? ` (${notes.join('; ')})` : ''}`);
300
+ } catch (error) {
301
+ skip(`${agent.label}: ${error.message}`);
302
+ }
303
+ }
304
+ if (!added.length) skip('No coding agents found. Add it by hand: https://github.com/predotdev/mcp#setup-for-every-agent');
305
+ return added;
306
+ }
307
+
308
+ // ------------------------------------------------------------------ Chrome
309
+
310
+ async function connectChrome() {
311
+ if (!fs.existsSync(path.join(chromeDir, 'Local State'))) return bad(`No Chrome data at ${chromeDir}. Install Google Chrome, then run this again.`);
312
+ const portFile = path.join(chromeDir, 'DevToolsActivePort');
313
+ if (!fs.existsSync(portFile)) {
314
+ const address = 'chrome://inspect/#remote-debugging';
315
+ // Chrome won't open chrome:// pages handed to it by another app, so put the address on the clipboard.
316
+ let copied = false;
317
+ if (!customChromeDir && process.platform === 'darwin') {
318
+ copied = spawnSync('pbcopy', { input: address }).status === 0;
319
+ spawnSync('open', ['-a', 'Google Chrome'], { stdio: 'ignore' });
320
+ }
321
+ console.log(` Turn on remote debugging once: in Chrome, open ${address}${copied ? ' (copied: press ⌘L, ⌘V, Enter)' : ''} and switch it on.`);
322
+ const wait = waiter('Waiting for remote debugging to be on');
323
+ const end = Date.now() + 10 * 60000;
324
+ while (!fs.existsSync(portFile) && Date.now() < end) { wait.tick(); await sleep(1000); }
325
+ wait.done();
326
+ if (!fs.existsSync(portFile)) return bad('Remote debugging is still off. Turn it on, then run this again.');
327
+ ok('Remote debugging is on');
328
+ }
329
+ console.log(' Connecting. If Chrome asks "Allow remote debugging?", click Allow.');
330
+ const end = Date.now() + 10 * 60000;
331
+ const wait = waiter('Waiting for you to click Allow in Chrome');
332
+ let result = null;
333
+ while (Date.now() < end) {
334
+ try {
335
+ result = await request(await ensureDaemon(), 'POST', '/call', { name: 'chrome_profiles', args: {} });
336
+ } catch (error) { result = { isError: true, content: [{ text: `Error: ${error.message}` }] }; }
337
+ if (!result.isError || !/Allow remote debugging/.test(result.content?.[0]?.text || '')) break;
338
+ wait.tick();
339
+ await sleep(1000);
340
+ }
341
+ wait.done();
342
+ const text = result?.content?.[0]?.text || '';
343
+ if (result?.isError) return bad(text.replace(/^Error: /, ''));
344
+ ok('Connected to Chrome. Your profiles:');
345
+ console.log(text.split('\n').map(line => ` ${line}`).join('\n'));
346
+ }
347
+
348
+ // ------------------------------------------------------------------ commands
349
+
350
+ // npx reuses a cached copy without checking for a newer one.
351
+ async function newerVersion() {
352
+ const latest = await fetch('https://registry.npmjs.org/@predotdev/mcp/latest', { signal: AbortSignal.timeout(5000) })
353
+ .then(r => (r.ok ? r.json() : null)).then(d => d?.version).catch(() => null);
354
+ const parts = v => String(v || '0').split('.').map(Number);
355
+ const [a, b] = [parts(latest), parts(pkg.version)];
356
+ for (let i = 0; i < 3; i++) if ((a[i] || 0) !== (b[i] || 0)) return (a[i] || 0) > (b[i] || 0) ? latest : null;
357
+ return null;
358
+ }
359
+
360
+ async function setup() {
361
+ console.log(`pre.dev MCP ${pkg.version || ''} setup`);
362
+ // Re-running setup is how people update, so run the newest published version when this one is older.
363
+ const latest = process.env.CHROME_MCP_NO_SELF_UPDATE ? null : await newerVersion();
364
+ if (latest) {
365
+ console.log(` Version ${latest} is out; switching to it.`);
366
+ const next = spawnSync(process.platform === 'win32' ? 'npx.cmd' : 'npx', ['-y', '@predotdev/mcp@latest', 'setup'], {
367
+ stdio: 'inherit', env: { ...process.env, CHROME_MCP_NO_SELF_UPDATE: '1' },
368
+ });
369
+ if (next.status !== null) process.exit(next.status);
370
+ console.log(' Could not switch; continuing with this version.');
371
+ }
372
+ step(1, 'Sign in to pre.dev');
373
+ await ensureSignedIn();
374
+ step(2, 'Install');
375
+ installApp();
376
+ step(3, 'Add to your coding agents');
377
+ const added = registerAgents();
378
+ step(4, 'Connect to Chrome');
379
+ await connectChrome();
380
+ console.log(failed ? '\nFix the ✗ items above, then run this again (it is safe to repeat).'
381
+ : `\nAll set.${added.length ? ` Restart ${added.join(', ')} so ${added.length === 1 ? 'it loads' : 'they load'} the new server,` : ''} then ask your agent:\n "List my Chrome profiles and the tabs I have open."`);
382
+ process.exit(failed ? 1 : 0);
383
+ }
384
+
385
+ async function login() {
386
+ console.log('Sign in to pre.dev');
387
+ await ensureSignedIn(true);
388
+ process.exit(failed ? 1 : 0);
389
+ }
390
+
391
+ function logout() {
392
+ fs.rmSync(credentialsFile, { force: true });
393
+ console.log('Signed out: the saved pre.dev key is deleted. Plain-words actions are off until you run login again.');
394
+ }
395
+
396
+ async function uninstall() {
397
+ console.log('Removing the pre.dev MCP');
398
+ for (const agent of AGENTS) {
399
+ let found = false;
400
+ try { found = agent.detect(); } catch {}
401
+ if (!found) continue;
402
+ try { if (agent.unregister()) ok(`Removed from ${agent.label}`); } catch (error) { skip(`${agent.label}: ${error.message}`); }
403
+ }
404
+ const state = (() => { try { return JSON.parse(fs.readFileSync(path.join(stateDir, 'daemon.json'), 'utf8')); } catch { return null; } })();
405
+ if (state) await request(state, 'POST', '/shutdown', {}, 3000).catch(() => {});
406
+ fs.rmSync(APP_DIR, { recursive: true, force: true });
407
+ fs.rmSync(credentialsFile, { force: true });
408
+ ok('Stopped it and deleted its files and saved key');
409
+ console.log('\nDone. Restart your agents. To also turn off remote debugging, open chrome://inspect/#remote-debugging.');
410
+ }
411
+
412
+ return { setup, login, logout, uninstall };
413
+ }