diff --git a/src/daemon/daemon.ts b/src/daemon/daemon.ts new file mode 100644 index 000000000..0c2235dc2 --- /dev/null +++ b/src/daemon/daemon.ts @@ -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, + }); + mcpClient = new Client( + { + name: 'chrome-devtools-cli-daemon', + // TODO: handle client version (optional). + version: '0.1.0', + }, + { + 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; + }; + +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((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); +}); diff --git a/src/daemon/utils.ts b/src/daemon/utils.ts new file mode 100644 index 000000000..3ff2472e8 --- /dev/null +++ b/src/daemon/utils.ts @@ -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; +} diff --git a/src/third_party/index.ts b/src/third_party/index.ts index 55c094954..862227871 100644 --- a/src/third_party/index.ts +++ b/src/third_party/index.ts @@ -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, diff --git a/tests/daemon/daemon.test.ts b/tests/daemon/daemon.test.ts new file mode 100644 index 000000000..521562139 --- /dev/null +++ b/tests/daemon/daemon.test.ts @@ -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((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(resolve => socket.on('connect', resolve)); + + daemonProcess.kill(); + assert.ok(daemonProcess.killed); + }); +});