Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions scripts/test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ const nodeArgs = [
'--test-reporter',
(process.env['NODE_TEST_REPORTER'] ?? process.env['CI']) ? 'spec' : 'dot',
'--test-force-exit',
'--test-concurrency=1',
'--test',
'--test-timeout=60000',
...flags,
Expand Down
32 changes: 27 additions & 5 deletions src/bin/chrome-devtools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ import process from 'node:process';
import yargs, {type Options, type PositionalOptions} from 'yargs';
import {hideBin} from 'yargs/helpers';

import {parseArguments} from '../cli.js';
import {
startDaemon,
stopDaemon,
sendCommand,
handleResponse,
} from '../daemon/client.js';
import {isDaemonRunning} from '../daemon/utils.js';
import {logDisclaimers} from '../server.js';
import type {CallToolResult} from '../third_party/index.js';
import {VERSION} from '../version.js';

Expand All @@ -31,6 +33,12 @@ if (argv.length === 0 || argv[0] === '--custom-help') {
process.exit(0);
}

async function start(args: string[]) {
const combinedArgs = [...args, ...defaultArgs];
await startDaemon([...args, ...defaultArgs]);
logDisclaimers(parseArguments(VERSION, combinedArgs));
}

const defaultArgs = ['--viaCli', '--experimentalStructuredContent'];

const y = yargs(argv)
Expand All @@ -44,28 +52,42 @@ const y = yargs(argv)
y.command(
'start',
'Start or restart chrome-devtools-mcp',
y => y.help(false), // Disable help for start command to avoid parsing issues with passed args
y =>
y
.help(false) // Disable help for start command to avoid parsing issues with passed args.
.example(
'$0 start --port 8080 --url http://localhost:8080',
'Start the server on port 8080 with a specific URL',
)
.strict(false), // Don't validate arguments for start, as they are passed through to the daemon.
async () => {
if (isDaemonRunning()) {
await stopDaemon();
}
// Extract args after 'start'
const startIndex = process.argv.indexOf('start');
const args = startIndex !== -1 ? process.argv.slice(startIndex + 1) : [];
await startDaemon([...args, ...defaultArgs]);
await start(args);
process.exit(0);
},
);
).strict(); // Re-enable strict validation for other commands; this is applied to the yargs instance itself

y.command('status', 'Checks if chrome-devtools-mcp is running', async () => {
if (isDaemonRunning()) {
console.log('chrome-devtools-mcp daemon is running.');
} else {
console.log('chrome-devtools-mcp daemon is not running');
console.log('chrome-devtools-mcp daemon is not running.');
}
process.exit(0);
});

y.command('stop', 'Stop chrome-devtools-mcp if any', async () => {
if (!isDaemonRunning()) {
process.exit(0);
return;
}
await stopDaemon();
process.exit(0);
});

for (const [commandName, commandDef] of Object.entries(commands)) {
Expand Down Expand Up @@ -127,7 +149,7 @@ for (const [commandName, commandDef] of Object.entries(commands)) {
async argv => {
try {
if (!isDaemonRunning()) {
await startDaemon(defaultArgs);
await start([]);
}

const commandArgs: Record<string, unknown> = {};
Expand Down
76 changes: 41 additions & 35 deletions src/daemon/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import fs from 'node:fs';
import net from 'node:net';

import {logger} from '../logger.js';
import {START_INDICATOR} from '../server.js';
import type {CallToolResult} from '../third_party/index.js';
import {PipeTransport} from '../third_party/index.js';

Expand All @@ -21,27 +20,46 @@ import {
isDaemonRunning,
} from './utils.js';

const FILE_TIMEOUT = 10_000;

/**
* Waits for a file to be created and populated.
* Waits for a file to be created and populated (removed = false) or removed (removed = true).
*/
function waitForFile(filePath: string, timeout = 5000) {
function waitForFile(filePath: string, removed = false) {
return new Promise<void>((resolve, reject) => {
if (fs.existsSync(filePath) && fs.statSync(filePath).size > 0) {
const check = () => {
const exists = fs.existsSync(filePath);
if (removed) {
return !exists;
}
if (!exists) {
return false;
}
try {
return fs.statSync(filePath).size > 0;
} catch {
return false;
}
};

if (check()) {
resolve();
return;
}

const timer = setTimeout(() => {
fs.unwatchFile(filePath);
reject(
new Error(`Timeout: file ${filePath} not found within ${timeout}ms`),
new Error(
`Timeout: file ${filePath} ${removed ? 'not removed' : 'not found'} within ${FILE_TIMEOUT}ms`,
),
);
}, timeout);
}, FILE_TIMEOUT);

fs.watchFile(filePath, {interval: 500}, curr => {
if (curr.size > 0) {
fs.watchFile(filePath, {interval: 500}, () => {
if (check()) {
clearTimeout(timer);
fs.unwatchFile(filePath); // Always clean up your listeners!
fs.unwatchFile(filePath);
resolve();
}
});
Expand All @@ -54,38 +72,22 @@ export async function startDaemon(mcpArgs: string[] = []) {
return;
}

const pidFilePath = getPidFilePath();

if (fs.existsSync(pidFilePath)) {
fs.unlinkSync(pidFilePath);
}

logger('Starting daemon...');
const child = spawn(process.execPath, [DAEMON_SCRIPT_PATH, ...mcpArgs], {
detached: true,
stdio: ['ignore', 'ignore', 'pipe'],
stdio: 'ignore',
env: process.env,
cwd: process.cwd(),
});
child.unref();

await new Promise<void>((resolve, reject) => {
child.on('error', err => {
reject(err);
});
child.on('exit', code => {
logger(`Child exited with code ${code}`);
reject(new Error(`Daemon process exited prematurely with code ${code}`));
});

waitForFile(getPidFilePath()).then(resolve).catch(reject);
});

logger(`Pid file found ${getPidFilePath()}`);

child.stderr.pipe(process.stderr);
await new Promise<void>(resolve => {
child.stderr.on('data', data => {
if (data.toString().includes(START_INDICATOR)) {
child.stderr.unpipe(process.stderr);
child.stderr.destroy();
child.unref();
resolve();
}
});
});
await waitForFile(pidFilePath);
}

const SEND_COMMAND_TIMEOUT = 60_000; // ms
Expand Down Expand Up @@ -135,7 +137,11 @@ export async function stopDaemon() {
return;
}

const pidFilePath = getPidFilePath();

await sendCommand({method: 'stop'});

await waitForFile(pidFilePath, /*removed=*/ true);
}

export function handleResponse(
Expand Down
29 changes: 2 additions & 27 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import process from 'node:process';

import {cliOptions, parseArguments} from './cli.js';
import {logger, saveLogsToFile} from './logger.js';
import {createMcpServer, START_INDICATOR} from './server.js';
import {createMcpServer, logDisclaimers} from './server.js';
import {computeFlagUsage} from './telemetry/flagUtils.js';
import {StdioServerTransport} from './third_party/index.js';
import {VERSION} from './version.js';
Expand All @@ -35,37 +35,12 @@ if (process.env['CHROME_DEVTOOLS_MCP_CRASH_ON_UNCAUGHT'] !== 'true') {
}

logger(`Starting Chrome DevTools MCP Server v${VERSION}`);

const logDisclaimers = () => {
console.error(
`chrome-devtools-mcp exposes content of the browser instance to the MCP clients allowing them to inspect,
debug, and modify any data in the browser or DevTools.
Avoid sharing sensitive or personal information that you do not want to share with MCP clients.`,
);

if (!args.slim && args.performanceCrux) {
console.error(
`Performance tools may send trace URLs to the Google CrUX API to fetch real-user experience data. To disable, run with --no-performance-crux.`,
);
}

if (!args.slim && args.usageStatistics) {
console.error(
`
Google collects usage statistics to improve Chrome DevTools MCP. To opt-out, run with --no-usage-statistics.
For more details, visit: https://github.com/ChromeDevTools/chrome-devtools-mcp#usage-statistics`,
);
}

console.error(START_INDICATOR);
};

const {server, clearcutLogger} = await createMcpServer(args, {
logFile,
});
const transport = new StdioServerTransport();
await server.connect(transport);
logger('Chrome DevTools MCP Server connected');
logDisclaimers();
logDisclaimers(args);
void clearcutLogger?.logDailyActiveIfNeeded();
void clearcutLogger?.logServerStart(computeFlagUsage(args, cliOptions));
22 changes: 21 additions & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,4 +257,24 @@ export async function createMcpServer(
return {server, clearcutLogger};
}

export const START_INDICATOR = 'Server started.';
export const logDisclaimers = (args: ReturnType<typeof parseArguments>) => {
console.error(
`chrome-devtools-mcp exposes content of the browser instance to the MCP clients allowing them to inspect,
debug, and modify any data in the browser or DevTools.
Avoid sharing sensitive or personal information that you do not want to share with MCP clients.`,
);

if (!args.slim && args.performanceCrux) {
console.error(
`Performance tools may send trace URLs to the Google CrUX API to fetch real-user experience data. To disable, run with --no-performance-crux.`,
);
}

if (!args.slim && args.usageStatistics) {
console.error(
`
Google collects usage statistics to improve Chrome DevTools MCP. To opt-out, run with --no-usage-statistics.
For more details, visit: https://github.com/ChromeDevTools/chrome-devtools-mcp#usage-statistics`,
);
}
};
23 changes: 11 additions & 12 deletions tests/daemon/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*/

import assert from 'node:assert';
import {describe, it, afterEach} from 'node:test';
import {describe, it, afterEach, beforeEach} from 'node:test';

import {
handleResponse,
Expand All @@ -16,12 +16,12 @@ import {isDaemonRunning} from '../../src/daemon/utils.js';

describe('daemon client', () => {
describe('start/stop', () => {
beforeEach(async () => {
await stopDaemon();
});

afterEach(async () => {
if (isDaemonRunning()) {
await stopDaemon();
// Wait a bit for the daemon to fully terminate and clean up its files.
await new Promise(resolve => setTimeout(resolve, 1000));
}
await stopDaemon();
});

it('should start and stop daemon', async () => {
Expand All @@ -31,7 +31,6 @@ describe('daemon client', () => {
assert.ok(isDaemonRunning(), 'Daemon should be running after start');

await stopDaemon();
await new Promise(resolve => setTimeout(resolve, 1000));
assert.ok(!isDaemonRunning(), 'Daemon should not be running after stop');
});

Expand All @@ -54,12 +53,12 @@ describe('daemon client', () => {
});

describe('parsing', () => {
it('handles MCP response with text format', () => {
it('handles MCP response with text format', async () => {
const textResponse = {content: [{type: 'text' as const, text: 'test'}]};
assert.strictEqual(handleResponse(textResponse, 'text'), 'test');
});

it('handles JSON response', () => {
it('handles JSON response', async () => {
const jsonResponse = {
content: [],
structuredContent: {
Expand All @@ -73,7 +72,7 @@ describe('daemon client', () => {
);
});

it('handles error response when isError is true', () => {
it('handles error response when isError is true', async () => {
const errorResponse = {
isError: true,
content: [{type: 'text' as const, text: 'Something went wrong'}],
Expand All @@ -84,7 +83,7 @@ describe('daemon client', () => {
);
});

it('handles text response when json format is requested but no structured content', () => {
it('handles text response when json format is requested but no structured content', async () => {
const textResponse = {
content: [{type: 'text' as const, text: 'Fall through text'}],
};
Expand All @@ -94,7 +93,7 @@ describe('daemon client', () => {
);
});

it('throws error for unsupported content type', () => {
it('throws error for unsupported content type', async () => {
const unsupportedContentResponse = {
content: [
{
Expand Down
Loading
Loading