-
Notifications
You must be signed in to change notification settings - Fork 2.3k
chore: implement a daemon process #1020
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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', | ||
| }, | ||
| { | ||
| 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); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| }); | ||
| }); |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.