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
214 changes: 214 additions & 0 deletions src/daemon/daemon.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
#!/usr/bin/env node

/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import fs from 'node:fs/promises';
import {createServer, type Server} from 'node:net';
import process from 'node:process';

import {Client} from '@modelcontextprotocol/sdk/client/index.js';
import {StdioClientTransport} from '@modelcontextprotocol/sdk/client/stdio.js';

import {logger} from '../logger.js';
import {PipeTransport} from '../third_party/index.js';

import {
getSocketPath,
handlePidFile,
INDEX_SCRIPT_PATH,
IS_WINDOWS,
} from './utils.js';

const pidFile = handlePidFile();
const socketPath = getSocketPath();

let mcpClient: Client | null = null;
let mcpTransport: StdioClientTransport | null = null;
let server: Server | null = null;

async function setupMCPClient() {
console.log('Setting up MCP client connection...');

const args = process.argv.slice(2);
// Create stdio transport for chrome-devtools-mcp
mcpTransport = new StdioClientTransport({
command: process.execPath,
args: [INDEX_SCRIPT_PATH, ...args],
env: process.env as Record<string, string>,
});
mcpClient = new Client(
{
name: 'chrome-devtools-cli-daemon',
// TODO: handle client version (optional).
version: '0.1.0',
Comment thread
OrKoN marked this conversation as resolved.
},
{
capabilities: {},
},
);
await mcpClient.connect(mcpTransport);

console.log('MCP client connected');
}

interface McpContent {
type: string;
text?: string;
}

interface McpResult {
content?: McpContent[] | string;
text?: string;
}

type DaemonMessage =
| {
method: 'stop';
}
| {
method: 'invoke_tool';
tool: string;
args?: Record<string, unknown>;
};

async function handleRequest(msg: DaemonMessage) {
try {
if (msg.method === 'invoke_tool') {
if (!mcpClient) {
throw new Error('MCP client not initialized');
}
const {tool, args} = msg;

const result = (await mcpClient.callTool({
name: tool,
arguments: args || {},
})) as McpResult | McpContent[];

return {
success: true,
result: JSON.stringify(result),
};
} else if (msg.method === 'stop') {
// Trigger cleanup asynchronously
setImmediate(() => {
void cleanup();
});
return {
success: true,
message: 'stopping',
};
} else {
return {
success: false,
error: `Unknown method: ${JSON.stringify(msg, null, 2)}`,
};
}
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
success: false,
error: errorMessage,
};
}
}

async function startSocketServer() {
// Remove existing socket file if it exists (only on non-Windows)
if (!IS_WINDOWS) {
try {
await fs.unlink(socketPath);
} catch {
// ignore errors.
}
}

return await new Promise<void>((resolve, reject) => {
server = createServer(socket => {
const transport = new PipeTransport(socket, socket);
transport.onmessage = async (message: string) => {
logger('onmessage', message);
const response = await handleRequest(JSON.parse(message));
transport.send(JSON.stringify(response));
socket.end();
};
socket.on('error', error => {
logger('Socket error:', error);
});
});

server.listen(
{
path: socketPath,
readableAll: false,
writableAll: false,
},
async () => {
console.log(`Daemon server listening on ${socketPath}`);

try {
// Setup MCP client
await setupMCPClient();
resolve();
} catch (err) {
reject(err);
}
},
);

server.on('error', error => {
logger('Server error:', error);
reject(error);
});
});
}

async function cleanup() {
console.log('Cleaning up daemon...');

try {
await mcpClient?.close();
} catch (error) {
logger('Error closing MCP client:', error);
}
try {
await mcpTransport?.close();
} catch (error) {
logger('Error closing MCP transport:', error);
}
server?.close(() => {
if (!IS_WINDOWS) {
void fs.unlink(socketPath).catch(() => undefined);
}
});
await fs.unlink(pidFile).catch(() => undefined);
process.exit(0);
}

// Handle shutdown signals
process.on('SIGTERM', () => {
void cleanup();
});
process.on('SIGINT', () => {
void cleanup();
});
process.on('SIGHUP', () => {
void cleanup();
});

// Handle uncaught errors
process.on('uncaughtException', error => {
logger('Uncaught exception:', error);
});
process.on('unhandledRejection', error => {
logger('Unhandled rejection:', error);
});

// Start the server
startSocketServer().catch(error => {
logger('Failed to start daemon server:', error);
process.exit(1);
});
85 changes: 85 additions & 0 deletions src/daemon/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';

export const DAEMON_SCRIPT_PATH = path.join(import.meta.dirname, 'daemon.js');
export const INDEX_SCRIPT_PATH = path.join(
import.meta.dirname,
'..',
'index.js',
);

const APP_NAME = 'chrome-devtools-mcp';

// Using these paths due to strict limits on the POSIX socket path length.
export function getSocketPath(): string {
const uid = os.userInfo().uid;

if (IS_WINDOWS) {
// Windows uses Named Pipes, not file paths.
// This format is required for server.listen()
return path.join('\\\\.\\pipe', APP_NAME, 'server.sock');
}

// 1. Try XDG_RUNTIME_DIR (Linux standard, sometimes macOS)
if (process.env.XDG_RUNTIME_DIR) {
return path.join(process.env.XDG_RUNTIME_DIR, APP_NAME, 'server.sock');
}

// 2. macOS/Unix Fallback: Use /tmp/
// We use /tmp/ because it is much shorter than ~/Library/Application Support/
// and keeps us well under the 104-character limit.
return path.join('/tmp', `${APP_NAME}-${uid}.sock`);
}

export function getRuntimeHome(): string {
const platform = os.platform();
const uid = os.userInfo().uid;

// 1. Check for the modern Unix standard
if (process.env.XDG_RUNTIME_DIR) {
return path.join(process.env.XDG_RUNTIME_DIR, APP_NAME);
}

// 2. Fallback for macOS and older Linux
if (platform === 'darwin' || platform === 'linux') {
// /tmp is cleared on boot, making it perfect for PIDs
return path.join('/tmp', `${APP_NAME}-${uid}`);
}

// 3. Windows Fallback
return path.join(os.tmpdir(), APP_NAME);
}

export const IS_WINDOWS = os.platform() === 'win32';

export function handlePidFile() {
const runtimeDir = getRuntimeHome();
const pidPath = path.join(runtimeDir, 'daemon.pid');

if (fs.existsSync(pidPath)) {
const oldPid = parseInt(fs.readFileSync(pidPath, 'utf8'), 10);
try {
// Sending signal 0 checks if the process is still alive without killing it
process.kill(oldPid, 0);
console.error('Daemon is already running!');
process.exit(1);
} catch {
// Process is dead, we can safely overwrite the PID file
fs.unlinkSync(pidPath);
}
}

fs.mkdirSync(path.dirname(pidPath), {
recursive: true,
});
fs.writeFileSync(pidPath, process.pid.toString());
return pidPath;
}
1 change: 1 addition & 0 deletions src/third_party/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export {
} from 'puppeteer-core';
export {default as puppeteer} from 'puppeteer-core';
export type * from 'puppeteer-core';
export {PipeTransport} from 'puppeteer-core/internal/node/PipeTransport.js';
export type {CdpPage} from 'puppeteer-core/internal/cdp/Page.js';
export {
resolveDefaultUserDataDir,
Expand Down
62 changes: 62 additions & 0 deletions tests/daemon/daemon.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import assert from 'node:assert';
import {spawn} from 'node:child_process';
import net from 'node:net';
import path from 'node:path';
import {describe, it} from 'node:test';

import {getSocketPath} from '../../src/daemon/utils.js';

const DAEMON_SCRIPT = path.join(
import.meta.dirname,
'..',
'..',
'src',
'daemon',
'daemon.js',
);

describe('Daemon', () => {
it('should terminate chrome instance when transport is closed', async () => {
const daemonProcess = spawn(process.execPath, [DAEMON_SCRIPT], {
env: {
...process.env,
},
stdio: ['ignore', 'pipe', 'pipe'],
});

const socketPath = getSocketPath();
// Wait for daemon to be ready
await new Promise<void>((resolve, reject) => {
const onData = (data: Buffer) => {
const output = data.toString();
// Wait for MCP client to connect
if (output.includes('MCP client connected')) {
daemonProcess.stdout.off('data', onData);
resolve();
}
};
daemonProcess.stdout.on('data', onData);
daemonProcess.stderr.on('data', data => {
console.log('err', data.toString('utf8'));
});
daemonProcess.on('error', reject);
daemonProcess.on('exit', (code: number) => {
if (code !== 0 && code !== null) {
reject(new Error(`Daemon exited with code ${code}`));
}
});
});

const socket = net.createConnection(socketPath);
await new Promise<void>(resolve => socket.on('connect', resolve));

daemonProcess.kill();
assert.ok(daemonProcess.killed);
});
});