cdpfleet-mcp 0.1.0 → 0.1.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdpfleet-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "MCP server for cdpfleet: let Claude, Cursor or any MCP client drive real remote browsers — Chrome, Edge, Firefox, Camoufox and more — with residential proxies and live-view handoff.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/http.js ADDED
@@ -0,0 +1,189 @@
1
+ #!/usr/bin/env node
2
+ // Hosted cdpfleet MCP server (MCP Streamable HTTP), served at https://cdpfleet.com/mcp: the same
3
+ // tools as `npx cdpfleet-mcp`, with nothing to install. Auth, per MCP session:
4
+ // - OAuth 2.1 (Claude, ChatGPT…): no credentials → 401 with WWW-Authenticate pointing to the
5
+ // protected-resource metadata; the client runs cdpfleet.com's OAuth flow and sends
6
+ // `Authorization: Bearer cdpfo_…`, checked with main (POST /internal/v1/mcp/token-info,
7
+ // MCP_SERVICE_TOKEN), which also returns the user's hidden MCP key to launch with;
8
+ // - `Authorization: Bearer <cdpfleet API key>` (or x-api-key), for scripts and headless agents.
9
+ // The browser belongs to the key, not to the MCP session: some clients (ChatGPT) open a new MCP
10
+ // session for every tool call, or send calls with no session at all, and must still find the
11
+ // browser they launched. Every session of a key shares that key's browser; a key unused for
12
+ // MCP_IDLE_MS has its browser closed (stops billing). Sessions stay bound to the key that opened them.
13
+ // env: PORT (8410), HOST (127.0.0.1), CDPFLEET_API_URL, MCP_SERVICE_TOKEN, MCP_IDLE_MS, MCP_MAX_PER_KEY
14
+ import http from 'node:http';
15
+ import crypto from 'node:crypto';
16
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
17
+ import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
18
+ import { createServer, createState } from './tools.js';
19
+
20
+ const PORT = Number(process.env.PORT || 8410);
21
+ const HOST = process.env.HOST || '127.0.0.1';
22
+ const API = (process.env.CDPFLEET_API_URL || 'https://cdpfleet.com').replace(/\/$/, '');
23
+ const IDLE_MS = Number(process.env.MCP_IDLE_MS || 30 * 60000);
24
+ const MAX_PER_KEY = Number(process.env.MCP_MAX_PER_KEY || 5);
25
+ const MAX_BODY = 1 << 20;
26
+ const PATH = /^\/mcp\/?$/;
27
+
28
+ const SERVICE_TOKEN = process.env.MCP_SERVICE_TOKEN || '';
29
+ const sessions = new Map(); // MCP session id → { transport, keyHash (owner), lastUsed }
30
+ // Owner (API key hash, or "grant:<id>" for an OAuth-connected app) → its browser, shared by its sessions.
31
+ const browsers = new Map();
32
+ const sha = (s) => crypto.createHash('sha256').update(s).digest('hex');
33
+ const log = (msg, extra = {}) => console.log(JSON.stringify({ time: new Date().toISOString(), msg, ...extra }));
34
+
35
+ const rpcError = (res, status, message, code = -32000) => {
36
+ res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
37
+ res.end(JSON.stringify({ jsonrpc: '2.0', error: { code, message }, id: null }));
38
+ };
39
+
40
+ // OAuth access token → { active, grant_id, key } from main, cached a minute.
41
+ const tokenCache = new Map();
42
+ async function tokenInfo(token) {
43
+ const h = sha(token);
44
+ const c = tokenCache.get(h);
45
+ if (c && Date.now() - c.at < 60000) return c.info;
46
+ const r = await fetch(`${API}/internal/v1/mcp/token-info`, {
47
+ method: 'POST', headers: { authorization: `Bearer ${SERVICE_TOKEN}`, 'content-type': 'application/json' },
48
+ body: JSON.stringify({ token }), signal: AbortSignal.timeout(10000),
49
+ }).catch(() => null);
50
+ const info = r?.ok ? await r.json() : { active: false };
51
+ tokenCache.set(h, { info, at: Date.now() });
52
+ if (tokenCache.size > 10000) tokenCache.clear();
53
+ return info;
54
+ }
55
+
56
+ function keyOf(req) {
57
+ const bearer = /^Bearer\s+(\S+)$/i.exec(req.headers.authorization || '')?.[1];
58
+ return bearer || req.headers['x-api-key'] || null;
59
+ }
60
+
61
+ // Is this a live cdpfleet key? Asked of main once a minute per key at most.
62
+ const checked = new Map();
63
+ async function keyIsValid(key) {
64
+ const h = sha(key);
65
+ const c = checked.get(h);
66
+ if (c && Date.now() - c.at < 60000) return c.ok;
67
+ const r = await fetch(`${API}/v1/me`, { headers: { 'x-api-key': key }, signal: AbortSignal.timeout(10000) }).catch(() => null);
68
+ const ok = r?.status === 200;
69
+ checked.set(h, { ok, at: Date.now() });
70
+ return ok;
71
+ }
72
+
73
+ function readJson(req) {
74
+ return new Promise((resolve, reject) => {
75
+ let size = 0;
76
+ const chunks = [];
77
+ req.on('data', (c) => { size += c.length; if (size > MAX_BODY) { reject(new Error('body too large')); req.destroy(); } else chunks.push(c); });
78
+ req.on('end', () => { try { resolve(chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : undefined); } catch { reject(new Error('invalid JSON')); } });
79
+ req.on('error', reject);
80
+ });
81
+ }
82
+
83
+ function browserOf(key, keyHash) {
84
+ let b = browsers.get(keyHash);
85
+ if (!b) browsers.set(keyHash, b = { state: createState({ apiKey: key }), lastUsed: Date.now() });
86
+ b.lastUsed = Date.now();
87
+ return b.state;
88
+ }
89
+
90
+ async function end(id, why) {
91
+ const s = sessions.get(id);
92
+ if (!s) return;
93
+ sessions.delete(id);
94
+ await s.transport.close?.().catch(() => {});
95
+ log('mcp session closed', { session: id, why, open: sessions.size });
96
+ }
97
+
98
+ async function closeBrowser(keyHash, why) {
99
+ const b = browsers.get(keyHash);
100
+ if (!b) return;
101
+ browsers.delete(keyHash);
102
+ const had = Boolean(b.state.current);
103
+ await b.state.close().catch(() => {});
104
+ if (had) log('mcp browser closed', { key: keyHash.slice(0, 12), why });
105
+ }
106
+
107
+ const server = http.createServer(async (req, res) => {
108
+ const path = (req.url || '').split('?')[0];
109
+ try {
110
+ if (path === '/mcp/healthz') {
111
+ res.writeHead(200, { 'content-type': 'application/json' });
112
+ return res.end(JSON.stringify({ ok: true, sessions: sessions.size, browsers: [...browsers.values()].filter((b) => b.state.current).length }));
113
+ }
114
+ const m = PATH.exec(path);
115
+ if (!m) return rpcError(res, 404, 'not found');
116
+ // 401s point MCP clients to the OAuth flow (MCP authorization spec / RFC 9728).
117
+ const unauthorized = (message, invalidToken) => {
118
+ res.setHeader('www-authenticate', `Bearer resource_metadata="${API}/.well-known/oauth-protected-resource/mcp"${invalidToken ? ', error="invalid_token"' : ''}`);
119
+ return rpcError(res, 401, message, -32001);
120
+ };
121
+ const key = keyOf(req);
122
+ if (!key) return unauthorized('Connect with OAuth (Claude, ChatGPT, Gemini: just add https://cdpfleet.com/mcp), or send "Authorization: Bearer <API key>".');
123
+ let apiKey = key;
124
+ let keyHash = sha(key);
125
+ const oauth = key.startsWith('cdpfo_');
126
+ if (oauth) {
127
+ const info = await tokenInfo(key);
128
+ if (!info.active) return unauthorized('The access token is invalid, expired or revoked.', true);
129
+ apiKey = info.key;
130
+ keyHash = `grant:${info.grant_id}`; // refreshed tokens keep the same sessions and browser
131
+ }
132
+ const body = req.method === 'POST' ? await readJson(req) : undefined;
133
+ const sid = req.headers['mcp-session-id'];
134
+
135
+ if (sid) {
136
+ const s = sessions.get(sid);
137
+ if (!s) return rpcError(res, 404, 'Unknown or expired MCP session: reconnect.');
138
+ if (s.keyHash !== keyHash) return rpcError(res, 403, 'This MCP session belongs to another key.');
139
+ s.lastUsed = Date.now();
140
+ browserOf(apiKey, keyHash);
141
+ return await s.transport.handleRequest(req, res, body);
142
+ }
143
+ if (req.method !== 'POST') return rpcError(res, 405, 'POST JSON-RPC requests to this endpoint.');
144
+ if (!oauth && !(await keyIsValid(key))) return unauthorized('Invalid or revoked cdpfleet API key.', true);
145
+
146
+ // No session and not an initialize: serve it statelessly, with the key's browser.
147
+ if (!isInitializeRequest(body)) {
148
+ const tools = createServer({ state: browserOf(apiKey, keyHash) });
149
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
150
+ await tools.server.connect(transport);
151
+ res.on('close', () => { transport.close?.().catch(() => {}); });
152
+ return await transport.handleRequest(req, res, body);
153
+ }
154
+
155
+ // A client that reconnects often shouldn't pile up browsers: keep the newest MAX_PER_KEY.
156
+ const mine = [...sessions.entries()].filter(([, s]) => s.keyHash === keyHash).sort((a, b) => a[1].lastUsed - b[1].lastUsed);
157
+ while (mine.length >= MAX_PER_KEY) await end(mine.shift()[0], 'too many sessions for this key');
158
+
159
+ const tools = createServer({ state: browserOf(apiKey, keyHash) });
160
+ const transport = new StreamableHTTPServerTransport({
161
+ sessionIdGenerator: () => crypto.randomUUID(),
162
+ onsessioninitialized: (id) => {
163
+ sessions.set(id, { transport, keyHash, lastUsed: Date.now() });
164
+ log('mcp session opened', { session: id, key: keyHash.slice(0, 12), open: sessions.size });
165
+ },
166
+ });
167
+ transport.onclose = () => { if (transport.sessionId) end(transport.sessionId, 'client closed'); };
168
+ await tools.server.connect(transport);
169
+ await transport.handleRequest(req, res, body);
170
+ } catch (err) {
171
+ log('mcp request failed', { path, err: err.message });
172
+ if (!res.headersSent) rpcError(res, err.message === 'invalid JSON' || err.message === 'body too large' ? 400 : 500, err.message === 'invalid JSON' || err.message === 'body too large' ? err.message : 'internal error');
173
+ }
174
+ });
175
+
176
+ setInterval(() => {
177
+ for (const [id, s] of sessions) if (Date.now() - s.lastUsed > IDLE_MS) end(id, 'idle');
178
+ for (const [h, b] of browsers) if (Date.now() - b.lastUsed > IDLE_MS) closeBrowser(h, 'idle');
179
+ }, 60000).unref();
180
+
181
+ for (const sig of ['SIGINT', 'SIGTERM']) {
182
+ process.on(sig, async () => {
183
+ await Promise.all([...sessions.keys()].map((id) => end(id, 'server stopping')));
184
+ await Promise.all([...browsers.keys()].map((h) => closeBrowser(h, 'server stopping')));
185
+ process.exit(0);
186
+ });
187
+ }
188
+
189
+ server.listen(PORT, HOST, () => log('cdpfleet MCP (HTTP) listening', { host: HOST, port: PORT }));
package/src/server.js CHANGED
@@ -3,166 +3,10 @@
3
3
  // cdpfleet — the browser and version of its choice, a residential or your own proxy, a live
4
4
  // view a person can take over, and a recording of what happened. One browser at a time.
5
5
  // env: CDPFLEET_API_KEY (required), CDPFLEET_PROXY (default proxy; else cdpfleet-resi)
6
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6
+ // No install needed at all: the same tools are hosted at https://cdpfleet.com/mcp (src/http.js).
7
7
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
8
- import { chromium, firefox, webkit } from 'playwright-core';
9
- import { z } from 'zod';
10
- import { Cdpfleet } from 'cdpfleet';
8
+ import { createServer } from './tools.js';
11
9
 
12
- const BROWSERS = ['chrome', 'edge', 'brave', 'opera', 'whale', 'yandex', 'chromium', 'patchright', 'cloakbrowser', 'camoufox', 'firefox', 'webkit'];
13
- const TYPE = { camoufox: firefox, firefox, webkit };
14
- const MAX_TEXT = 25000;
15
-
16
- const fleet = new Cdpfleet();
17
- let current = null; // { session, browser, page, name }
18
-
19
- const text = (t) => ({ content: [{ type: 'text', text: t }] });
20
- const fail = (t) => ({ content: [{ type: 'text', text: t }], isError: true });
21
- const clip = (s) => (s.length > MAX_TEXT ? `${s.slice(0, MAX_TEXT)}\n… (${s.length - MAX_TEXT} more characters; snapshot a narrower selector)` : s);
22
-
23
- function need() {
24
- if (!current) throw new Error('No browser is open. Call launch_browser first.');
25
- return current;
26
- }
27
-
28
- // A tool body that turns exceptions into an MCP error result.
29
- const tool = (fn) => async (args) => {
30
- try { return await fn(args ?? {}); } catch (err) { return fail(String(err.message || err).split('\n')[0]); }
31
- };
32
-
33
- // Click/type target: a CSS selector, or an ARIA role + accessible name (as in snapshot output).
34
- const target = { selector: z.string().optional().describe('CSS selector, or text=… / role=… Playwright selector'), role: z.string().optional().describe('ARIA role from the snapshot, e.g. button, link, textbox'), name: z.string().optional().describe('Accessible name from the snapshot') };
35
- function locate(page, { selector, role, name }) {
36
- if (role) return page.getByRole(role, name ? { name, exact: false } : {}).first();
37
- if (selector) return page.locator(selector).first();
38
- throw new Error('Give a selector, or a role (and name).');
39
- }
40
-
41
- const server = new McpServer({ name: 'cdpfleet', version: '0.1.0' });
42
-
43
- server.registerTool('launch_browser', {
44
- description: 'Start a real remote browser on cdpfleet and open a page. Pick the browser the site should see. Headful by default (2 threads; less detectable). Proxy: your own proxy URL, or cdpfleet residential (default; billed per GB from your balance), optionally by country.',
45
- inputSchema: {
46
- browser: z.enum(BROWSERS).default('chrome'),
47
- headless: z.boolean().default(false),
48
- proxy: z.string().optional().describe('Proxy URL (http/https/socks5) or a cdpfleet-resi token. Default: CDPFLEET_PROXY or cdpfleet-resi'),
49
- country: z.string().length(2).optional().describe('Residential exit country (ISO code) when using the cdpfleet residential proxy'),
50
- record: z.boolean().default(true).describe('Keep a video recording of the session'),
51
- options: z.record(z.any()).optional().describe('Extra launch options passed unchanged (e.g. channel, version, screen_size, Camoufox os/locale)'),
52
- },
53
- }, tool(async ({ browser, headless, proxy, country, record, options }) => {
54
- if (current) await closeCurrent();
55
- let p = proxy || process.env.CDPFLEET_PROXY || 'cdpfleet-resi';
56
- if (country && p.startsWith('cdpfleet-resi')) p = `${p}-country-${country.toLowerCase()}`;
57
- const live = headless && !['camoufox', 'firefox', 'webkit'].includes(browser) ? { live_view: true } : {};
58
- const session = await fleet.launch(browser, { proxy: p, headless, record, ...live, ...(options || {}) });
59
- const b = await session.connect(TYPE[browser] || chromium);
60
- const context = await b.newContext({ viewport: headless ? { width: 1280, height: 800 } : null });
61
- const page = await context.newPage();
62
- current = { session, browser: b, page, name: browser };
63
- return text(`Launched ${browser} ${b.version()} (${session.resourceClass}, ${session.weight} thread(s)), session ${session.id}. Proxy: ${p.startsWith('cdpfleet-resi') ? p : 'your proxy'}. Use navigate next.`);
64
- }));
65
-
66
- server.registerTool('navigate', {
67
- description: 'Open a URL in the current page and wait for its HTML. Returns the title and final URL.',
68
- inputSchema: { url: z.string().url() },
69
- }, tool(async ({ url }) => {
70
- const { page } = need();
71
- const res = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 });
72
- return text(`${res?.status() ?? ''} ${page.url()}\nTitle: ${await page.title()}`);
73
- }));
74
-
75
- server.registerTool('snapshot', {
76
- description: 'The page (or one element) as an accessibility snapshot: roles, names, text and links in YAML — compact and good for deciding what to click.',
77
- inputSchema: { selector: z.string().optional().describe('Limit to this CSS selector (default: body)') },
78
- }, tool(async ({ selector }) => {
79
- const { page } = need();
80
- return text(clip(await page.locator(selector || 'body').first().ariaSnapshot({ timeout: 15000 })));
81
- }));
82
-
83
- server.registerTool('click', { description: 'Click an element, by CSS selector or by role + name from the snapshot.', inputSchema: target },
84
- tool(async (args) => {
85
- const { page } = need();
86
- await locate(page, args).click({ timeout: 15000 });
87
- await page.waitForLoadState('domcontentloaded').catch(() => {});
88
- return text(`Clicked. Now at ${page.url()}`);
89
- }));
90
-
91
- server.registerTool('type', {
92
- description: 'Fill a text field, by CSS selector or role + name; optionally press Enter after.',
93
- inputSchema: { ...target, text: z.string(), submit: z.boolean().default(false) },
94
- }, tool(async ({ text: value, submit, ...args }) => {
95
- const { page } = need();
96
- const el = locate(page, args);
97
- await el.fill(value, { timeout: 15000 });
98
- if (submit) await el.press('Enter');
99
- return text(submit ? `Typed and submitted. Now at ${page.url()}` : 'Typed.');
100
- }));
101
-
102
- server.registerTool('press', { description: 'Press a key on the page (e.g. Enter, Escape, PageDown, ArrowDown).', inputSchema: { key: z.string() } },
103
- tool(async ({ key }) => { await need().page.keyboard.press(key); return text(`Pressed ${key}.`); }));
104
-
105
- server.registerTool('wait_for', {
106
- description: 'Wait until an element (CSS selector) or a text appears, up to 30 s.',
107
- inputSchema: { selector: z.string().optional(), text: z.string().optional(), timeout_s: z.number().max(60).default(30) },
108
- }, tool(async ({ selector, text: t, timeout_s: s }) => {
109
- const { page } = need();
110
- const loc = selector ? page.locator(selector) : t ? page.getByText(t) : null;
111
- if (!loc) throw new Error('Give a selector or a text.');
112
- await loc.first().waitFor({ timeout: s * 1000 });
113
- return text('It appeared.');
114
- }));
115
-
116
- server.registerTool('screenshot', {
117
- description: 'A JPEG screenshot of the current page (viewport, or the whole page).',
118
- inputSchema: { full_page: z.boolean().default(false) },
119
- }, tool(async ({ full_page: fullPage }) => {
120
- const img = await need().page.screenshot({ type: 'jpeg', quality: 60, fullPage });
121
- return { content: [{ type: 'image', data: img.toString('base64'), mimeType: 'image/jpeg' }] };
122
- }));
123
-
124
- server.registerTool('evaluate', {
125
- description: 'Run a JavaScript expression in the page and return its JSON result (e.g. extract data).',
126
- inputSchema: { expression: z.string().describe('An expression or an arrow function, e.g. () => [...document.links].map(a => a.href)') },
127
- }, tool(async ({ expression }) => {
128
- // A function given as text must be called, or evaluate returns the function itself.
129
- const isFn = /^\s*(async\s+)?(\([^)]*\)|[\w$]+)\s*=>|^\s*(async\s+)?function\b/.test(expression);
130
- const v = await need().page.evaluate(isFn ? `(${expression})()` : expression);
131
- return text(clip(typeof v === 'string' ? v : JSON.stringify(v, null, 2) ?? 'undefined'));
132
- }));
133
-
134
- server.registerTool('live_view', {
135
- description: 'Hand the browser to a person: e.g. to solve a captcha or type a one-time code. Returns where to watch or take control; the agent should wait until the person says they are done.',
136
- inputSchema: { mode: z.enum(['view', 'control']).default('control') },
137
- }, tool(async ({ mode }) => {
138
- const { session, name } = need();
139
- const link = await session.liveLink(mode);
140
- return text(`A person can ${mode === 'control' ? 'take control of' : 'watch'} this ${name} session in the dashboard: https://cdpfleet.com/app#sessions → Live sessions → ${mode === 'control' ? 'Take control' : 'Watch'} (session ${session.id}).\nFor a custom viewer: ${link.protocol} link ${link.live_url} (single use, ${link.expires_in_s} s).`);
141
- }));
142
-
143
- server.registerTool('close_browser', { description: 'End the browser session (stops billing). The video recording appears in the dashboard (Sessions) shortly after.', inputSchema: {} },
144
- tool(async () => { const id = need().session.id; await closeCurrent(); return text(`Session ${id} ended.`); }));
145
-
146
- server.registerTool('list_browsers', { description: 'The browsers cdpfleet offers, with their versions and channels.', inputSchema: {} },
147
- tool(async () => {
148
- const cat = await (await fetch('https://cdpfleet.com/api/public/browsers')).json();
149
- return text(cat.engines.map((e) => `${e.key}: ${e.name} — ${e.versions.map((v) => `${v.label} ${v.version}`).join(', ')}`).join('\n'));
150
- }));
151
-
152
- server.registerTool('account', { description: 'Your cdpfleet plan usage today, live sessions and residential-proxy balance.', inputSchema: {} },
153
- tool(async () => {
154
- const [usage, bal, threads] = await Promise.all([fleet.usage(), fleet.proxyBalance(), fleet.threads()]);
155
- // The residential balance is USD, spent per GB at each tier's price (cdpfleet-resi-tier-N).
156
- const usd = bal.balance_usd ?? (bal.balance_bytes / 1e9) * 5;
157
- const gbAt = Object.fromEntries((bal.tiers || []).map((t) => [`tier_${t.tier}`, Math.round((usd / t.price_per_gb_usd) * 100) / 100]));
158
- return text(JSON.stringify({ usage, live_sessions: threads.threads?.length ?? 0, residential_balance_usd: Math.round(usd * 100) / 100, residential_gb_at: gbAt }, null, 2));
159
- }));
160
-
161
- async function closeCurrent() {
162
- const c = current;
163
- current = null;
164
- if (c) await c.session.close().catch(() => {});
165
- }
166
-
167
- for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { closeCurrent().finally(() => process.exit(0)); });
10
+ const { server, close } = createServer({ apiKey: process.env.CDPFLEET_API_KEY, defaultProxy: process.env.CDPFLEET_PROXY });
11
+ for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { close().finally(() => process.exit(0)); });
168
12
  await server.connect(new StdioServerTransport());
package/src/tools.js ADDED
@@ -0,0 +1,222 @@
1
+ // cdpfleet MCP tools, as a factory. The browser lives in a `state` object, so several MCP
2
+ // servers can share one: the hosted server (src/http.js) keeps one state per API key, because
3
+ // some clients (ChatGPT) open a new MCP session for every tool call. The stdio server
4
+ // (src/server.js, `npx cdpfleet-mcp`) has one state of its own. One browser at a time per state.
5
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6
+ import { chromium, firefox, webkit } from 'playwright-core';
7
+ import { z } from 'zod';
8
+ import { Cdpfleet } from 'cdpfleet';
9
+
10
+ const BROWSERS = ['chrome', 'edge', 'brave', 'opera', 'whale', 'yandex', 'chromium', 'patchright', 'camoufox', 'firefox', 'webkit'];
11
+ const TYPE = { camoufox: firefox, firefox, webkit };
12
+ const MAX_TEXT = 25000;
13
+
14
+ // The free Community plan: a short note at launch and after a session, and with the errors its
15
+ // limits cause, saying what a paid plan adds (same wording as main's public/shared/upgrade.js).
16
+ const UPGRADE_URL = 'https://cdpfleet.com/app#subscriptions';
17
+ const UPGRADE_LINE = 'You\'re on the free Community plan. Ready for more? Paid plans unlock more threads, longer sessions, priority capacity, recordings and persistent profiles — from $5 a month';
18
+ const NUDGE_EVERY_MS = 30 * 60000;
19
+ const HINTS = {
20
+ community_capacity_full: 'The free Community capacity is busy right now — paid plans get priority capacity and aren\'t affected.',
21
+ daily_quota_exhausted: 'That\'s today\'s free minutes used — paid plans give you 60 to 480 minutes per thread a day, or unlimited on dedicated threads.',
22
+ hourly_quota_exhausted: 'That\'s this hour\'s share of your free minutes — paid plans come with many more.',
23
+ threads_exceeded: 'The free plan runs 2 threads at a time — paid plans start at 5, and scale to thousands.',
24
+ profiles_disabled: 'Persistent profiles (stay logged in across sessions) come with every paid plan.',
25
+ keep_alive_requires_dedicated: 'keep_alive (reconnect to a running browser) comes with dedicated threads.',
26
+ };
27
+
28
+ // The browser a client works with: { fleet, current: { session, browser, page, name } | null }.
29
+ export function createState({ apiKey, defaultProxy } = {}) {
30
+ const state = { fleet: new Cdpfleet(apiKey ? { apiKey } : {}), defaultProxy, current: null, plan: undefined, nudgedAt: 0 };
31
+ state.close = async () => {
32
+ const c = state.current;
33
+ state.current = null;
34
+ if (c) await c.session.close().catch(() => {});
35
+ };
36
+ return state;
37
+ }
38
+
39
+ // → { server, close, state }: close() ends the open browser session (stops billing).
40
+ export function createServer({ apiKey, defaultProxy, state = createState({ apiKey, defaultProxy }) } = {}) {
41
+ const { fleet } = state;
42
+
43
+ const text = (t) => ({ content: [{ type: 'text', text: t }] });
44
+ const fail = (t) => ({ content: [{ type: 'text', text: t }], isError: true });
45
+ const clip = (s) => (s.length > MAX_TEXT ? `${s.slice(0, MAX_TEXT)}\n… (${s.length - MAX_TEXT} more characters; snapshot a narrower selector)` : s);
46
+
47
+ // The key's plan, asked once per browser state: Community or not, and the longest idle time it allows.
48
+ async function plan() {
49
+ if (state.plan === undefined) {
50
+ state.plan = await fleet.me().then((m) => ({ community: Boolean(m.subscription?.features?.community), maxIdle: m.subscription?.max_inactivity_seconds || null }), () => null);
51
+ }
52
+ return state.plan;
53
+ }
54
+ const community = async () => Boolean((await plan())?.community);
55
+ // The upgrade note, at most every 30 minutes per key, for Community only.
56
+ async function nudge() {
57
+ if (!(await community()) || Date.now() - state.nudgedAt < NUDGE_EVERY_MS) return '';
58
+ state.nudgedAt = Date.now();
59
+ return `\n\n${UPGRADE_LINE}: ${UPGRADE_URL}`;
60
+ }
61
+
62
+ function need() {
63
+ if (!state.current) throw new Error('No browser is open. Call launch_browser first.');
64
+ return state.current;
65
+ }
66
+
67
+ // A tool body that turns exceptions into an MCP error result.
68
+ const tool = (fn) => async (args) => {
69
+ try { return await fn(args ?? {}); } catch (err) { return fail(String(err.message || err).split('\n')[0]); }
70
+ };
71
+
72
+ // Click/type target: a CSS selector, or an ARIA role + accessible name (as in snapshot output).
73
+ const target = { selector: z.string().optional().describe('CSS selector, or text=… / role=… Playwright selector'), role: z.string().optional().describe('ARIA role from the snapshot, e.g. button, link, textbox'), name: z.string().optional().describe('Accessible name from the snapshot') };
74
+ function locate(page, { selector, role, name }) {
75
+ if (role) return page.getByRole(role, name ? { name, exact: false } : {}).first();
76
+ if (selector) return page.locator(selector).first();
77
+ throw new Error('Give a selector, or a role (and name).');
78
+ }
79
+
80
+ const server = new McpServer({ name: 'cdpfleet', version: '0.1.2' });
81
+
82
+ server.registerTool('launch_browser', {
83
+ description: 'Start a real remote browser on cdpfleet and open a page. Pick the browser the site should see. Headful by default (2 threads; less detectable). Proxy: your own proxy URL, or cdpfleet residential (default; billed per GB from your balance), optionally by country.',
84
+ inputSchema: {
85
+ browser: z.enum(BROWSERS).default('chrome'),
86
+ headless: z.boolean().default(false),
87
+ proxy: z.string().optional().describe('Proxy URL (http/https/socks5) or a cdpfleet-resi token. Default: CDPFLEET_PROXY or cdpfleet-resi'),
88
+ country: z.string().length(2).optional().describe('Residential exit country (ISO code) when using the cdpfleet residential proxy'),
89
+ record: z.boolean().default(true).describe('Keep a video recording of the session'),
90
+ options: z.record(z.any()).optional().describe('Extra launch options passed unchanged (e.g. channel, version, screen_size, Camoufox os/locale)'),
91
+ },
92
+ }, tool(async (args) => {
93
+ try { return await launch(args); } catch (err) {
94
+ const hint = HINTS[err.code] && (await community()) ? ` ${HINTS[err.code]} See plans: ${UPGRADE_URL}` : '';
95
+ return fail(`${String(err.message || err).split('\n')[0]}${hint}`);
96
+ }
97
+ }));
98
+
99
+ async function launch({ browser, headless, proxy, country, record, options }) {
100
+ if (state.current) await closeCurrent();
101
+ let p = proxy || state.defaultProxy || 'cdpfleet-resi';
102
+ if (country && p.startsWith('cdpfleet-resi')) p = `${p}-country-${country.toLowerCase()}`;
103
+ const live = headless && !['camoufox', 'firefox', 'webkit'].includes(browser) ? { live_view: true } : {};
104
+ // A conversation pauses between tool calls: keep the browser for the plan's longest idle time
105
+ // (shared 10 min, Community 5 min; dedicated capped at 30 min here, like the server's own idle).
106
+ const maxIdle = (await plan())?.maxIdle;
107
+ const idle = maxIdle ? { inactivity_timeout: Math.min(maxIdle, 1800) } : {};
108
+ // A key created moments ago reaches the starters with the next accounts sync (about a
109
+ // second): retry a refused key briefly instead of failing the first launch.
110
+ let session;
111
+ for (let i = 0; ; i++) {
112
+ try { session = await fleet.launch(browser, { proxy: p, headless, record, ...live, ...idle, ...(options || {}) }); break; } catch (err) {
113
+ if (i >= 15 || !/invalid_api_key/.test(String(err.message || err))) throw err;
114
+ await new Promise((r) => setTimeout(r, 1000));
115
+ }
116
+ }
117
+ const b = await session.connect(TYPE[browser] || chromium);
118
+ const context = await b.newContext({ viewport: headless ? { width: 1280, height: 800 } : null });
119
+ const page = await context.newPage();
120
+ state.current = { session, browser: b, page, name: browser };
121
+ return text(`Launched ${browser} ${b.version()} (${session.resourceClass}, ${session.weight} thread(s)), session ${session.id}. Proxy: ${p.startsWith('cdpfleet-resi') ? p : p.startsWith('cdpfleet-plan') ? 'the Community proxy' : 'your proxy'}. Use navigate next.${await nudge()}`);
122
+ }
123
+
124
+ server.registerTool('navigate', {
125
+ description: 'Open a URL in the current page and wait for its HTML. Returns the title and final URL.',
126
+ inputSchema: { url: z.string().url() },
127
+ }, tool(async ({ url }) => {
128
+ const { page } = need();
129
+ const res = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 });
130
+ return text(`${res?.status() ?? ''} ${page.url()}\nTitle: ${await page.title()}`);
131
+ }));
132
+
133
+ server.registerTool('snapshot', {
134
+ description: 'The page (or one element) as an accessibility snapshot: roles, names, text and links in YAML — compact and good for deciding what to click.',
135
+ inputSchema: { selector: z.string().optional().describe('Limit to this CSS selector (default: body)') },
136
+ }, tool(async ({ selector }) => {
137
+ const { page } = need();
138
+ return text(clip(await page.locator(selector || 'body').first().ariaSnapshot({ timeout: 15000 })));
139
+ }));
140
+
141
+ server.registerTool('click', { description: 'Click an element, by CSS selector or by role + name from the snapshot.', inputSchema: target },
142
+ tool(async (args) => {
143
+ const { page } = need();
144
+ await locate(page, args).click({ timeout: 15000 });
145
+ await page.waitForLoadState('domcontentloaded').catch(() => {});
146
+ return text(`Clicked. Now at ${page.url()}`);
147
+ }));
148
+
149
+ server.registerTool('type', {
150
+ description: 'Fill a text field, by CSS selector or role + name; optionally press Enter after.',
151
+ inputSchema: { ...target, text: z.string(), submit: z.boolean().default(false) },
152
+ }, tool(async ({ text: value, submit, ...args }) => {
153
+ const { page } = need();
154
+ const el = locate(page, args);
155
+ await el.fill(value, { timeout: 15000 });
156
+ if (submit) await el.press('Enter');
157
+ return text(submit ? `Typed and submitted. Now at ${page.url()}` : 'Typed.');
158
+ }));
159
+
160
+ server.registerTool('press', { description: 'Press a key on the page (e.g. Enter, Escape, PageDown, ArrowDown).', inputSchema: { key: z.string() } },
161
+ tool(async ({ key }) => { await need().page.keyboard.press(key); return text(`Pressed ${key}.`); }));
162
+
163
+ server.registerTool('wait_for', {
164
+ description: 'Wait until an element (CSS selector) or a text appears, up to 30 s.',
165
+ inputSchema: { selector: z.string().optional(), text: z.string().optional(), timeout_s: z.number().max(60).default(30) },
166
+ }, tool(async ({ selector, text: t, timeout_s: s }) => {
167
+ const { page } = need();
168
+ const loc = selector ? page.locator(selector) : t ? page.getByText(t) : null;
169
+ if (!loc) throw new Error('Give a selector or a text.');
170
+ await loc.first().waitFor({ timeout: s * 1000 });
171
+ return text('It appeared.');
172
+ }));
173
+
174
+ server.registerTool('screenshot', {
175
+ description: 'A JPEG screenshot of the current page (viewport, or the whole page).',
176
+ inputSchema: { full_page: z.boolean().default(false) },
177
+ }, tool(async ({ full_page: fullPage }) => {
178
+ const img = await need().page.screenshot({ type: 'jpeg', quality: 60, fullPage });
179
+ return { content: [{ type: 'image', data: img.toString('base64'), mimeType: 'image/jpeg' }] };
180
+ }));
181
+
182
+ server.registerTool('evaluate', {
183
+ description: 'Run a JavaScript expression in the page and return its JSON result (e.g. extract data).',
184
+ inputSchema: { expression: z.string().describe('An expression or an arrow function, e.g. () => [...document.links].map(a => a.href)') },
185
+ }, tool(async ({ expression }) => {
186
+ // A function given as text must be called, or evaluate returns the function itself.
187
+ const isFn = /^\s*(async\s+)?(\([^)]*\)|[\w$]+)\s*=>|^\s*(async\s+)?function\b/.test(expression);
188
+ const v = await need().page.evaluate(isFn ? `(${expression})()` : expression);
189
+ return text(clip(typeof v === 'string' ? v : JSON.stringify(v, null, 2) ?? 'undefined'));
190
+ }));
191
+
192
+ server.registerTool('live_view', {
193
+ description: 'Hand the browser to a person: e.g. to solve a captcha or type a one-time code. Returns where to watch or take control; the agent should wait until the person says they are done.',
194
+ inputSchema: { mode: z.enum(['view', 'control']).default('control') },
195
+ }, tool(async ({ mode }) => {
196
+ const { session, name } = need();
197
+ const link = await session.liveLink(mode);
198
+ return text(`A person can ${mode === 'control' ? 'take control of' : 'watch'} this ${name} session in the dashboard: https://cdpfleet.com/app#sessions → Live sessions → ${mode === 'control' ? 'Take control' : 'Watch'} (session ${session.id}).\nFor a custom viewer: ${link.protocol} link ${link.live_url} (single use, ${link.expires_in_s} s).`);
199
+ }));
200
+
201
+ server.registerTool('close_browser', { description: 'End the browser session (stops billing). The video recording appears in the dashboard (Sessions) shortly after.', inputSchema: {} },
202
+ tool(async () => { const id = need().session.id; await closeCurrent(); return text(`Session ${id} ended.${await nudge()}`); }));
203
+
204
+ server.registerTool('list_browsers', { description: 'The browsers cdpfleet offers, with their versions and channels.', inputSchema: {} },
205
+ tool(async () => {
206
+ const cat = await (await fetch('https://cdpfleet.com/api/public/browsers')).json();
207
+ return text(cat.engines.map((e) => `${e.key}: ${e.name} — ${e.versions.map((v) => `${v.label} ${v.version}`).join(', ')}`).join('\n'));
208
+ }));
209
+
210
+ server.registerTool('account', { description: 'Your cdpfleet plan usage today, live sessions and residential-proxy balance.', inputSchema: {} },
211
+ tool(async () => {
212
+ const [usage, bal, threads] = await Promise.all([fleet.usage(), fleet.proxyBalance(), fleet.threads()]);
213
+ // The residential balance is USD, spent per GB at each tier's price (cdpfleet-resi-tier-N).
214
+ const usd = bal.balance_usd ?? (bal.balance_bytes / 1e9) * 5;
215
+ const gbAt = Object.fromEntries((bal.tiers || []).map((t) => [`tier_${t.tier}`, Math.round((usd / t.price_per_gb_usd) * 100) / 100]));
216
+ return text(JSON.stringify({ usage, live_sessions: threads.threads?.length ?? 0, residential_balance_usd: Math.round(usd * 100) / 100, residential_gb_at: gbAt }, null, 2));
217
+ }));
218
+
219
+ const closeCurrent = state.close;
220
+
221
+ return { server, close: closeCurrent, state };
222
+ }