devtools-fleet-mcp 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.
- package/.claude-plugin/marketplace.json +9 -0
- package/.claude-plugin/plugin.json +21 -0
- package/CHANGELOG.md +14 -0
- package/LICENSE +21 -0
- package/README.md +364 -0
- package/bin/cli.js +246 -0
- package/package.json +61 -0
- package/skills/devtools-fleet/SKILL.md +41 -0
- package/src/index.js +7 -0
- package/src/lib/cdp.js +105 -0
- package/src/lib/chrome.js +222 -0
- package/src/lib/config.js +103 -0
- package/src/lib/fs-utils.js +31 -0
- package/src/lib/guard.js +93 -0
- package/src/lib/lock.js +54 -0
- package/src/lib/lockdown.js +38 -0
- package/src/lib/login.js +177 -0
- package/src/lib/origins.js +104 -0
- package/src/lib/policy.js +82 -0
- package/src/lib/process-tree.js +109 -0
- package/src/lib/reaper.js +184 -0
- package/src/lib/registry.js +89 -0
- package/src/lib/session.js +466 -0
- package/src/lib/states.js +135 -0
- package/src/lib/storage.js +137 -0
- package/src/lib/upstream.js +98 -0
- package/src/reaper-main.js +10 -0
- package/src/server.js +85 -0
- package/src/tools/fleet-tools.js +116 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { cookieMatchesAllowlist } from './origins.js';
|
|
2
|
+
|
|
3
|
+
// Moving logins in and out of a browser: cookies through the Storage domain,
|
|
4
|
+
// localStorage through a throwaway background tab.
|
|
5
|
+
//
|
|
6
|
+
// localStorage can only be touched from a page on that origin. Rather than
|
|
7
|
+
// load the real site (side effects, analytics, a service worker answering
|
|
8
|
+
// instead of the network), the tab navigates to <origin>/__devtools_fleet__
|
|
9
|
+
// and fleet answers that request itself with an empty page. The site's server
|
|
10
|
+
// is never contacted and its service worker is bypassed.
|
|
11
|
+
|
|
12
|
+
export const PLACEHOLDER_PATH = '/__devtools_fleet__';
|
|
13
|
+
|
|
14
|
+
const BLANK_HTML = Buffer.from('<!doctype html><link rel=icon href="data:,"><title>devtools-fleet</title>').toString('base64');
|
|
15
|
+
|
|
16
|
+
// Cookie fields as Playwright's storageState writes them.
|
|
17
|
+
export function toPlaywrightCookie(c) {
|
|
18
|
+
return {
|
|
19
|
+
name: c.name,
|
|
20
|
+
value: c.value,
|
|
21
|
+
domain: c.domain,
|
|
22
|
+
path: c.path,
|
|
23
|
+
expires: c.session ? -1 : c.expires,
|
|
24
|
+
httpOnly: c.httpOnly,
|
|
25
|
+
secure: c.secure,
|
|
26
|
+
sameSite: c.sameSite ?? 'Lax',
|
|
27
|
+
...(c.partitionKey ? { partitionKey: c.partitionKey } : {}),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function toCdpCookieParam(c) {
|
|
32
|
+
const param = {
|
|
33
|
+
name: c.name,
|
|
34
|
+
value: c.value,
|
|
35
|
+
domain: c.domain,
|
|
36
|
+
path: c.path ?? '/',
|
|
37
|
+
httpOnly: Boolean(c.httpOnly),
|
|
38
|
+
secure: Boolean(c.secure),
|
|
39
|
+
};
|
|
40
|
+
if (c.sameSite) param.sameSite = c.sameSite;
|
|
41
|
+
if (typeof c.expires === 'number' && c.expires > 0) param.expires = c.expires;
|
|
42
|
+
if (c.partitionKey && typeof c.partitionKey === 'object') param.partitionKey = c.partitionKey;
|
|
43
|
+
return param;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Answer a paused request with the empty placeholder page. */
|
|
47
|
+
export function fulfillPlaceholder(cdp, requestId, sessionId) {
|
|
48
|
+
return cdp.send('Fetch.fulfillRequest', {
|
|
49
|
+
requestId,
|
|
50
|
+
responseCode: 200,
|
|
51
|
+
responseHeaders: [{ name: 'content-type', value: 'text/html; charset=utf-8' }],
|
|
52
|
+
body: BLANK_HTML,
|
|
53
|
+
}, sessionId).catch(() => {});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function withBlankOrigin(cdp, origin, fn) {
|
|
57
|
+
const { targetId } = await cdp.send('Target.createTarget', { url: 'about:blank', background: true, newWindow: false });
|
|
58
|
+
let sessionId;
|
|
59
|
+
try {
|
|
60
|
+
({ sessionId } = await cdp.send('Target.attachToTarget', { targetId, flatten: true }));
|
|
61
|
+
await cdp.send('Network.enable', {}, sessionId);
|
|
62
|
+
await cdp.send('Network.setBypassServiceWorker', { bypass: true }, sessionId);
|
|
63
|
+
await cdp.send('Page.enable', {}, sessionId);
|
|
64
|
+
await cdp.send('Fetch.enable', { patterns: [{ urlPattern: '*', resourceType: 'Document', requestStage: 'Request' }] }, sessionId);
|
|
65
|
+
const off = cdp.on('Fetch.requestPaused', (params, sid) => {
|
|
66
|
+
if (sid !== sessionId) return;
|
|
67
|
+
fulfillPlaceholder(cdp, params.requestId, sid);
|
|
68
|
+
});
|
|
69
|
+
try {
|
|
70
|
+
const loaded = cdp.waitFor('Page.loadEventFired', (_, sid) => sid === sessionId, 10_000);
|
|
71
|
+
await cdp.send('Page.navigate', { url: `${origin}${PLACEHOLDER_PATH}` }, sessionId);
|
|
72
|
+
await loaded;
|
|
73
|
+
} finally {
|
|
74
|
+
off();
|
|
75
|
+
}
|
|
76
|
+
return await fn(sessionId);
|
|
77
|
+
} finally {
|
|
78
|
+
await cdp.send('Target.closeTarget', { targetId }).catch(() => {});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function evaluate(cdp, sessionId, expression) {
|
|
83
|
+
const { result, exceptionDetails } = await cdp.send('Runtime.evaluate', { expression, returnByValue: true }, sessionId);
|
|
84
|
+
if (exceptionDetails) throw new Error(exceptionDetails.exception?.description ?? exceptionDetails.text);
|
|
85
|
+
return result.value;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Every http(s) origin currently open in a tab. */
|
|
89
|
+
export async function openOrigins(cdp) {
|
|
90
|
+
const { targetInfos } = await cdp.send('Target.getTargets');
|
|
91
|
+
const origins = new Set();
|
|
92
|
+
for (const t of targetInfos) {
|
|
93
|
+
if (t.type !== 'page') continue;
|
|
94
|
+
try {
|
|
95
|
+
const url = new URL(t.url);
|
|
96
|
+
if (url.protocol === 'http:' || url.protocol === 'https:') origins.add(url.origin);
|
|
97
|
+
} catch {
|
|
98
|
+
// about:blank, chrome-error:// and friends
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return [...origins];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Snapshot cookies and localStorage.
|
|
106
|
+
* @param {object} opts
|
|
107
|
+
* @param {string[]|null} opts.cookieAllowlist keep only cookies for these origins (null = all)
|
|
108
|
+
* @param {string[]} opts.storageOrigins concrete origins to read localStorage from
|
|
109
|
+
*/
|
|
110
|
+
export async function captureStorage(cdp, { cookieAllowlist = null, storageOrigins = [] } = {}) {
|
|
111
|
+
const { cookies } = await cdp.send('Storage.getCookies');
|
|
112
|
+
const kept = cookieAllowlist ? cookies.filter((c) => cookieMatchesAllowlist(c.domain, cookieAllowlist)) : cookies;
|
|
113
|
+
const origins = [];
|
|
114
|
+
for (const origin of storageOrigins) {
|
|
115
|
+
const entries = await withBlankOrigin(cdp, origin, (sid) => evaluate(cdp, sid, 'Object.entries(localStorage)'));
|
|
116
|
+
if (entries.length) origins.push({ origin, localStorage: entries.map(([name, value]) => ({ name, value })) });
|
|
117
|
+
}
|
|
118
|
+
return { cookies: kept.map(toPlaywrightCookie), origins };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Load cookies and localStorage into a browser. Call before the first real navigation. */
|
|
122
|
+
export async function applyStorage(cdp, { cookies = [], origins = [] }) {
|
|
123
|
+
if (cookies.length) await cdp.send('Storage.setCookies', { cookies: cookies.map(toCdpCookieParam) });
|
|
124
|
+
for (const { origin, localStorage } of origins) {
|
|
125
|
+
if (!localStorage?.length) continue;
|
|
126
|
+
const payload = JSON.stringify(localStorage.map(({ name, value }) => [name, value]));
|
|
127
|
+
await withBlankOrigin(cdp, origin, (sid) => evaluate(cdp, sid, `for (const [k, v] of ${payload}) localStorage.setItem(k, v); localStorage.length`));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Tabs worth reopening after a relaunch. */
|
|
132
|
+
export async function openTabUrls(cdp) {
|
|
133
|
+
const { targetInfos } = await cdp.send('Target.getTargets');
|
|
134
|
+
return targetInfos
|
|
135
|
+
.filter((t) => t.type === 'page' && /^https?:/.test(t.url) && !t.url.includes(PLACEHOLDER_PATH))
|
|
136
|
+
.map((t) => t.url);
|
|
137
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
4
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
5
|
+
import { ListRootsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
6
|
+
|
|
7
|
+
// chrome-devtools-mcp runs as a child process, pointed at a Chrome fleet
|
|
8
|
+
// launched itself (--browserUrl). Fleet is an MCP client to it and passes its
|
|
9
|
+
// tools through unchanged. It comes from our own node_modules at a pinned
|
|
10
|
+
// range, never `npx @latest`, so startup doesn't depend on the npm registry.
|
|
11
|
+
|
|
12
|
+
const require = createRequire(import.meta.url);
|
|
13
|
+
const PKG_JSON = require.resolve('chrome-devtools-mcp/package.json');
|
|
14
|
+
const UPSTREAM_BIN = join(dirname(PKG_JSON), 'build/src/bin/chrome-devtools-mcp.js');
|
|
15
|
+
export const UPSTREAM_VERSION = require(PKG_JSON).version;
|
|
16
|
+
|
|
17
|
+
// Performance traces and Lighthouse runs can take minutes.
|
|
18
|
+
const CALL_TIMEOUT_MS = 10 * 60_000;
|
|
19
|
+
|
|
20
|
+
export class Upstream {
|
|
21
|
+
#client = null;
|
|
22
|
+
#closed = false;
|
|
23
|
+
onClose = () => {};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {string} browserUrl e.g. http://127.0.0.1:54288 ; null = tool listing only
|
|
27
|
+
* @param {(() => Promise<Array>)|null} rootsProvider the MCP client's workspace roots.
|
|
28
|
+
* Upstream only reads and writes files inside roots (plus the temp dir), so
|
|
29
|
+
* they're passed through; without them, screenshots can't go to the project.
|
|
30
|
+
*/
|
|
31
|
+
static async start({ browserUrl, extraArgs = [], rootsProvider = null }) {
|
|
32
|
+
const upstream = new Upstream();
|
|
33
|
+
// A port nothing listens on is fine for listing: upstream connects lazily.
|
|
34
|
+
const url = browserUrl ?? 'http://127.0.0.1:9';
|
|
35
|
+
const transport = new StdioClientTransport({
|
|
36
|
+
command: process.execPath,
|
|
37
|
+
args: [UPSTREAM_BIN, `--browserUrl=${url}`, ...extraArgs],
|
|
38
|
+
stderr: 'pipe',
|
|
39
|
+
});
|
|
40
|
+
transport.stderr?.on('data', (chunk) => {
|
|
41
|
+
const text = chunk.toString();
|
|
42
|
+
// Upstream prints a privacy banner on every start; keep real errors only.
|
|
43
|
+
if (/error|exception|fatal/i.test(text)) process.stderr.write(`[chrome-devtools-mcp] ${text}`);
|
|
44
|
+
});
|
|
45
|
+
const client = new Client(
|
|
46
|
+
{ name: 'devtools-fleet-mcp', version: '1' },
|
|
47
|
+
{ capabilities: rootsProvider ? { roots: { listChanged: true } } : {} },
|
|
48
|
+
);
|
|
49
|
+
if (rootsProvider) {
|
|
50
|
+
client.setRequestHandler(ListRootsRequestSchema, async () => ({ roots: await rootsProvider().catch(() => []) }));
|
|
51
|
+
}
|
|
52
|
+
client.onclose = () => {
|
|
53
|
+
// close() sets #closed first, so only an unexpected exit reaches onClose.
|
|
54
|
+
const unexpected = !upstream.#closed;
|
|
55
|
+
upstream.#closed = true;
|
|
56
|
+
if (unexpected) upstream.onClose();
|
|
57
|
+
};
|
|
58
|
+
await client.connect(transport);
|
|
59
|
+
upstream.#client = client;
|
|
60
|
+
return upstream;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
get closed() {
|
|
64
|
+
return this.#closed;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async listTools() {
|
|
68
|
+
const tools = [];
|
|
69
|
+
let cursor;
|
|
70
|
+
do {
|
|
71
|
+
const page = await this.#client.listTools(cursor ? { cursor } : {});
|
|
72
|
+
tools.push(...page.tools);
|
|
73
|
+
cursor = page.nextCursor;
|
|
74
|
+
} while (cursor);
|
|
75
|
+
return tools;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
getInstructions() {
|
|
79
|
+
return this.#client.getInstructions();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
sendRootsListChanged() {
|
|
83
|
+
if (!this.#closed) this.#client.sendRootsListChanged().catch(() => {});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async callTool(name, args) {
|
|
87
|
+
return this.#client.callTool({ name, arguments: args }, undefined, {
|
|
88
|
+
timeout: CALL_TIMEOUT_MS,
|
|
89
|
+
resetTimeoutOnProgress: true,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async close() {
|
|
94
|
+
if (this.#closed) return;
|
|
95
|
+
this.#closed = true;
|
|
96
|
+
await this.#client?.close().catch(() => {});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Detached background process that closes abandoned fleet browsers. Started
|
|
3
|
+
// by devtools-fleet-mcp on demand; exits by itself when there is nothing left.
|
|
4
|
+
import { loadConfig } from './lib/config.js';
|
|
5
|
+
import { runReaper } from './lib/reaper.js';
|
|
6
|
+
|
|
7
|
+
runReaper({ loadConfig }).catch((err) => {
|
|
8
|
+
console.error(`${new Date().toISOString()} reaper crashed: ${err.stack ?? err.message}`);
|
|
9
|
+
process.exit(1);
|
|
10
|
+
});
|
package/src/server.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
2
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, RootsListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import { loadConfig } from './lib/config.js';
|
|
6
|
+
import { findSessionAnchor } from './lib/process-tree.js';
|
|
7
|
+
import { FleetSession } from './lib/session.js';
|
|
8
|
+
import { Upstream, UPSTREAM_VERSION } from './lib/upstream.js';
|
|
9
|
+
import { toolDefinitions, findFleetTool } from './tools/fleet-tools.js';
|
|
10
|
+
|
|
11
|
+
const { version } = createRequire(import.meta.url)('../package.json');
|
|
12
|
+
|
|
13
|
+
const INSTRUCTIONS = `devtools-fleet gives this session its own isolated Chrome with the full chrome-devtools-mcp toolset (${UPSTREAM_VERSION}).
|
|
14
|
+
- Any browser tool starts the browser on first use. Other agents have their own browsers; nothing is shared.
|
|
15
|
+
- To work logged in, call state_list, then browser_start({ state: "<name>" }). A person creates states with \`devtools-fleet login\`.
|
|
16
|
+
- A browser started from a state can only reach that state's allowed origins. Blocked navigations say so; don't try to work around them, ask the person.
|
|
17
|
+
- browser_restart({ headless: false }) shows a window, e.g. for the person to watch or solve a captcha.`;
|
|
18
|
+
|
|
19
|
+
export async function startServer() {
|
|
20
|
+
const config = loadConfig();
|
|
21
|
+
const anchor = findSessionAnchor();
|
|
22
|
+
const session = new FleetSession(config, anchor);
|
|
23
|
+
|
|
24
|
+
// Upstream's tool list, fetched once from a browserless child (~0.3s).
|
|
25
|
+
const lister = await Upstream.start({ browserUrl: null, extraArgs: config.upstreamArgs });
|
|
26
|
+
const upstreamTools = await lister.listTools();
|
|
27
|
+
await lister.close();
|
|
28
|
+
|
|
29
|
+
const fleetDefs = toolDefinitions();
|
|
30
|
+
const clash = upstreamTools.find((t) => fleetDefs.some((f) => f.name === t.name));
|
|
31
|
+
if (clash) throw new Error(`chrome-devtools-mcp now has a tool named "${clash.name}", which clashes with devtools-fleet. Please report this.`);
|
|
32
|
+
const tools = [...fleetDefs, ...upstreamTools];
|
|
33
|
+
|
|
34
|
+
const server = new Server(
|
|
35
|
+
{ name: 'devtools-fleet-mcp', version },
|
|
36
|
+
{ capabilities: { tools: {} }, instructions: INSTRUCTIONS },
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
|
|
40
|
+
|
|
41
|
+
// Pass the client's workspace roots through to chrome-devtools-mcp (see upstream.js).
|
|
42
|
+
server.oninitialized = () => {
|
|
43
|
+
if (server.getClientCapabilities()?.roots) {
|
|
44
|
+
session.setRootsProvider(async () => (await server.listRoots(undefined, { timeout: 5000 })).roots);
|
|
45
|
+
server.setNotificationHandler(RootsListChangedNotificationSchema, async () => session.rootsChanged());
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
50
|
+
const { name, arguments: args = {} } = request.params;
|
|
51
|
+
try {
|
|
52
|
+
const fleetTool = findFleetTool(name);
|
|
53
|
+
if (fleetTool) {
|
|
54
|
+
const parsed = fleetTool.schema.safeParse(args);
|
|
55
|
+
if (!parsed.success) return errorResult(`Invalid arguments for ${name}: ${parsed.error.message}`);
|
|
56
|
+
return await fleetTool.handler(session, parsed.data);
|
|
57
|
+
}
|
|
58
|
+
if (!upstreamTools.some((t) => t.name === name)) return errorResult(`Unknown tool: ${name}`);
|
|
59
|
+
return await session.callUpstream(name, args);
|
|
60
|
+
} catch (err) {
|
|
61
|
+
return errorResult(err.message);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
let shuttingDown = false;
|
|
66
|
+
const shutdown = async () => {
|
|
67
|
+
if (shuttingDown) return;
|
|
68
|
+
shuttingDown = true;
|
|
69
|
+
// Leave the browser running for re-adoption; the reaper handles the rest.
|
|
70
|
+
await session.detach().catch(() => {});
|
|
71
|
+
process.exit(0);
|
|
72
|
+
};
|
|
73
|
+
process.on('SIGINT', shutdown);
|
|
74
|
+
process.on('SIGTERM', shutdown);
|
|
75
|
+
process.on('SIGHUP', shutdown);
|
|
76
|
+
process.stdin.on('close', shutdown);
|
|
77
|
+
|
|
78
|
+
const transport = new StdioServerTransport();
|
|
79
|
+
transport.onclose = shutdown;
|
|
80
|
+
await server.connect(transport);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function errorResult(text) {
|
|
84
|
+
return { content: [{ type: 'text', text }], isError: true };
|
|
85
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { listStates } from '../lib/states.js';
|
|
3
|
+
|
|
4
|
+
// devtools-fleet's own tools. Everything else the agent sees is
|
|
5
|
+
// chrome-devtools-mcp's, passed through with its original names.
|
|
6
|
+
|
|
7
|
+
const viewport = z.string().regex(/^\d{2,5}x\d{2,5}$/).describe('Window size, e.g. "1280x720"');
|
|
8
|
+
|
|
9
|
+
export const FLEET_TOOLS = [
|
|
10
|
+
{
|
|
11
|
+
name: 'browser_start',
|
|
12
|
+
description:
|
|
13
|
+
'Start this session\'s own Chrome (isolated from every other agent). Optional: the first browser tool call starts one with defaults anyway. ' +
|
|
14
|
+
'Call it explicitly to load a saved login (`state`), to pick headless/viewport/channel, or to open a URL. ' +
|
|
15
|
+
'A browser started from a state can only visit that state\'s allowed origins. ' +
|
|
16
|
+
'If this session already had a browser before a reconnect, it is picked up again with its tabs.',
|
|
17
|
+
schema: z.object({
|
|
18
|
+
state: z.string().optional().describe('Name of a saved login state (see state_list)'),
|
|
19
|
+
headless: z.boolean().optional().describe('Run without a window (default from config, normally true)'),
|
|
20
|
+
viewport: viewport.optional(),
|
|
21
|
+
channel: z.enum(['stable', 'beta', 'dev', 'canary']).optional(),
|
|
22
|
+
url: z.string().url().optional().describe('Open this URL in the first tab'),
|
|
23
|
+
allowedOrigins: z.array(z.string()).optional().describe('Without a state: restrict this browser to these origins, e.g. ["http://localhost:3000"]'),
|
|
24
|
+
}).strict(),
|
|
25
|
+
async handler(session, args) {
|
|
26
|
+
const { adopted, alreadyRunning } = await session.start(args);
|
|
27
|
+
const status = await session.status();
|
|
28
|
+
const how = alreadyRunning ? 'Already running' : adopted ? 'Re-attached to this session\'s existing browser' : 'Started';
|
|
29
|
+
return text(`${how}: ${describe(status)}`);
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: 'browser_status',
|
|
34
|
+
description: 'Show this session\'s browser: id, saved state, allowlist, headless, open tabs, uptime.',
|
|
35
|
+
schema: z.object({}).strict(),
|
|
36
|
+
async handler(session) {
|
|
37
|
+
const status = await session.status();
|
|
38
|
+
return text(status.running ? describe(status) : 'No browser running in this session. Any browser tool call (or browser_start) starts one.');
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: 'browser_restart',
|
|
43
|
+
description:
|
|
44
|
+
'Relaunch this session\'s browser keeping cookies, localStorage and open tabs. Pass headless: false to get a visible window ' +
|
|
45
|
+
'(e.g. so the human can watch or solve a captcha), headless: true to hide it again. Pages reload.',
|
|
46
|
+
schema: z.object({ headless: z.boolean().optional() }).strict(),
|
|
47
|
+
async handler(session, args) {
|
|
48
|
+
const { headless, reopenedTabs } = await session.restart(args);
|
|
49
|
+
return text(`Restarted ${headless ? 'headless' : 'with a visible window'}; reopened ${reopenedTabs} tab(s). Page ids have changed: call list_pages.`);
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: 'browser_stop',
|
|
54
|
+
description: 'Close this session\'s browser and delete its temporary profile.',
|
|
55
|
+
schema: z.object({}).strict(),
|
|
56
|
+
async handler(session) {
|
|
57
|
+
const { stopped, id } = await session.stop();
|
|
58
|
+
return text(stopped ? `Closed browser ${id}.` : 'No browser was running.');
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: 'state_save',
|
|
63
|
+
description:
|
|
64
|
+
'Save the current browser\'s login (cookies + localStorage) as a named state, so other sessions can start already logged in. ' +
|
|
65
|
+
'Only cookies for the allowed origins are kept. allowedOrigins defaults to the http(s) origins open in tabs; ' +
|
|
66
|
+
'it can never be wider than this browser\'s own allowlist. States a person created with `devtools-fleet login` cannot be overwritten.',
|
|
67
|
+
schema: z.object({
|
|
68
|
+
name: z.string().describe('State name: letters, digits, . _ -'),
|
|
69
|
+
allowedOrigins: z.array(z.string()).optional().describe('Origins this state may be used on, e.g. ["https://staging.example.com"]'),
|
|
70
|
+
strict: z.boolean().optional().describe('Block every request (not just navigations) outside the allowlist when the state is used'),
|
|
71
|
+
overwrite: z.boolean().optional(),
|
|
72
|
+
}).strict(),
|
|
73
|
+
async handler(session, args) {
|
|
74
|
+
const s = await session.saveState(args);
|
|
75
|
+
return text(`Saved state "${s.name}": ${s.cookies} cookie(s), localStorage for ${s.localStorageOrigins.length} origin(s). Allowed: ${s.allowedOrigins.join(', ')}${s.strict ? ' (strict)' : ''}.`);
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
name: 'state_list',
|
|
80
|
+
description: 'List saved login states: name, allowed origins, cookie counts, when saved and when the first cookie expires. Never shows cookie values.',
|
|
81
|
+
schema: z.object({}).strict(),
|
|
82
|
+
async handler() {
|
|
83
|
+
const states = listStates();
|
|
84
|
+
if (!states.length) return text('No saved states. A person can create one with `devtools-fleet login <name> <url>`.');
|
|
85
|
+
return text(states.map((s) => s.error
|
|
86
|
+
? `${s.name}: unreadable (${s.error})`
|
|
87
|
+
: `${s.name}: ${s.allowedOrigins.join(', ')}${s.strict ? ' [strict]' : ''} | ${s.cookies} cookies (${s.expiredCookies} expired) | saved ${s.savedAt} by ${s.createdBy}`).join('\n'));
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
export function toolDefinitions() {
|
|
93
|
+
return FLEET_TOOLS.map((t) => ({
|
|
94
|
+
name: t.name,
|
|
95
|
+
description: t.description,
|
|
96
|
+
inputSchema: z.toJSONSchema(t.schema, { target: 'draft-7' }),
|
|
97
|
+
}));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function findFleetTool(name) {
|
|
101
|
+
return FLEET_TOOLS.find((t) => t.name === name);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function describe(s) {
|
|
105
|
+
const lines = [
|
|
106
|
+
`browser ${s.id}${s.state ? `, state "${s.state}"` : ''}, ${s.headless ? 'headless' : 'visible window'}${s.viewport ? `, ${s.viewport}` : ''}, ${s.channel}`,
|
|
107
|
+
s.allowedOrigins ? `allowed origins: ${s.allowedOrigins.join(', ')}${s.strict ? ' (strict)' : ''}` : 'allowed origins: any',
|
|
108
|
+
`tabs: ${s.tabs.length ? s.tabs.join(' | ') : 'none with a web page'}`,
|
|
109
|
+
`uptime ${s.uptimeSeconds}s${s.blockedRequests ? `, ${s.blockedRequests} request(s) blocked so far` : ''}`,
|
|
110
|
+
];
|
|
111
|
+
return lines.join('\n');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function text(value) {
|
|
115
|
+
return { content: [{ type: 'text', text: value }] };
|
|
116
|
+
}
|