cdpfleet-mcp 0.0.0-stage → 0.1.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +24 -2
  3. package/package.json +38 -4
  4. package/src/server.js +168 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Cdpfleet LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,25 @@
1
- # Temporary Holding Version
1
+ # cdpfleet-mcp — MCP server for real remote browsers
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Let Claude, Cursor, Codex or any MCP client drive a real browser on [cdpfleet](https://cdpfleet.com):
4
+ pick Chrome, Edge, Brave, Firefox, Camoufox and more; browse through a residential IP by country
5
+ or your own proxy; read pages as accessibility snapshots; hand the browser to a person through
6
+ live view (captchas, one-time codes); every session is recorded.
7
+
8
+ **Claude Code**
9
+
10
+ ```bash
11
+ claude mcp add cdpfleet -e CDPFLEET_API_KEY=your-key -- npx -y cdpfleet-mcp
12
+ ```
13
+
14
+ **Claude Desktop, Cursor and others**
15
+
16
+ ```json
17
+ { "mcpServers": { "cdpfleet": { "command": "npx", "args": ["-y", "cdpfleet-mcp"], "env": { "CDPFLEET_API_KEY": "your key" } } } }
18
+ ```
19
+
20
+ Optional `CDPFLEET_PROXY`: the default proxy (else the cdpfleet residential proxy, billed per GB).
21
+
22
+ Tools: `launch_browser`, `navigate`, `snapshot`, `click`, `type`, `press`, `wait_for`,
23
+ `screenshot`, `evaluate`, `live_view`, `close_browser`, `list_browsers`, `account`.
24
+
25
+ Docs: https://cdpfleet.com/docs/mcp
package/package.json CHANGED
@@ -1,6 +1,40 @@
1
1
  {
2
2
  "name": "cdpfleet-mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
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
+ "type": "module",
6
+ "bin": {
7
+ "cdpfleet-mcp": "src/server.js"
8
+ },
9
+ "files": [
10
+ "src",
11
+ "README.md",
12
+ "LICENSE"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "dependencies": {
18
+ "@modelcontextprotocol/sdk": "^1.32.1",
19
+ "cdpfleet": "^0.1.0",
20
+ "playwright-core": "1.60.0",
21
+ "zod": "^3.25.76"
22
+ },
23
+ "keywords": [
24
+ "mcp",
25
+ "model-context-protocol",
26
+ "browser",
27
+ "playwright",
28
+ "claude",
29
+ "cursor",
30
+ "agents",
31
+ "cdpfleet"
32
+ ],
33
+ "homepage": "https://cdpfleet.com/docs/mcp",
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/cdpfleet/cdpfleet.git",
37
+ "directory": "sdk/mcp"
38
+ },
39
+ "license": "MIT"
40
+ }
package/src/server.js ADDED
@@ -0,0 +1,168 @@
1
+ #!/usr/bin/env node
2
+ // cdpfleet MCP server: gives an MCP client (Claude, Cursor, Codex…) a real remote browser on
3
+ // cdpfleet — the browser and version of its choice, a residential or your own proxy, a live
4
+ // view a person can take over, and a recording of what happened. One browser at a time.
5
+ // env: CDPFLEET_API_KEY (required), CDPFLEET_PROXY (default proxy; else cdpfleet-resi)
6
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
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';
11
+
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)); });
168
+ await server.connect(new StdioServerTransport());