豆豆友情提示:这是一个非官方 GitHub 代理镜像,主要用于网络测试或访问加速。请勿在此进行登录、注册或处理任何敏感信息。进行这些操作请务必访问官方网站 github.com。 Raw 内容也通过此代理提供。
Skip to content

Commit dd26bbf

Browse files
authored
chore: implement a daemon process (#1020)
Implements a daemon process needed for the CLI. The daemon has commands to start/stop itself as well as an ability to forward data to the managed MCP server. Local sockets or named pipes are used depending on the platform. The .sock and .pid files are placed under XDG_RUNTIME_DIR/chrome-devtools-mcp or /tmp/chrome-devtools-mcp for short socket paths. It uses Puppeteer's PipeTransport to avoid re-implementing \0 terminated messages. Note the code is not used anywhere at the moment.
1 parent ca6635d commit dd26bbf

File tree

4 files changed

+362
-0
lines changed

4 files changed

+362
-0
lines changed

src/daemon/daemon.ts

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* @license
5+
* Copyright 2026 Google LLC
6+
* SPDX-License-Identifier: Apache-2.0
7+
*/
8+
9+
import fs from 'node:fs/promises';
10+
import {createServer, type Server} from 'node:net';
11+
import process from 'node:process';
12+
13+
import {Client} from '@modelcontextprotocol/sdk/client/index.js';
14+
import {StdioClientTransport} from '@modelcontextprotocol/sdk/client/stdio.js';
15+
16+
import {logger} from '../logger.js';
17+
import {PipeTransport} from '../third_party/index.js';
18+
19+
import {
20+
getSocketPath,
21+
handlePidFile,
22+
INDEX_SCRIPT_PATH,
23+
IS_WINDOWS,
24+
} from './utils.js';
25+
26+
const pidFile = handlePidFile();
27+
const socketPath = getSocketPath();
28+
29+
let mcpClient: Client | null = null;
30+
let mcpTransport: StdioClientTransport | null = null;
31+
let server: Server | null = null;
32+
33+
async function setupMCPClient() {
34+
console.log('Setting up MCP client connection...');
35+
36+
const args = process.argv.slice(2);
37+
// Create stdio transport for chrome-devtools-mcp
38+
mcpTransport = new StdioClientTransport({
39+
command: process.execPath,
40+
args: [INDEX_SCRIPT_PATH, ...args],
41+
env: process.env as Record<string, string>,
42+
});
43+
mcpClient = new Client(
44+
{
45+
name: 'chrome-devtools-cli-daemon',
46+
// TODO: handle client version (optional).
47+
version: '0.1.0',
48+
},
49+
{
50+
capabilities: {},
51+
},
52+
);
53+
await mcpClient.connect(mcpTransport);
54+
55+
console.log('MCP client connected');
56+
}
57+
58+
interface McpContent {
59+
type: string;
60+
text?: string;
61+
}
62+
63+
interface McpResult {
64+
content?: McpContent[] | string;
65+
text?: string;
66+
}
67+
68+
type DaemonMessage =
69+
| {
70+
method: 'stop';
71+
}
72+
| {
73+
method: 'invoke_tool';
74+
tool: string;
75+
args?: Record<string, unknown>;
76+
};
77+
78+
async function handleRequest(msg: DaemonMessage) {
79+
try {
80+
if (msg.method === 'invoke_tool') {
81+
if (!mcpClient) {
82+
throw new Error('MCP client not initialized');
83+
}
84+
const {tool, args} = msg;
85+
86+
const result = (await mcpClient.callTool({
87+
name: tool,
88+
arguments: args || {},
89+
})) as McpResult | McpContent[];
90+
91+
return {
92+
success: true,
93+
result: JSON.stringify(result),
94+
};
95+
} else if (msg.method === 'stop') {
96+
// Trigger cleanup asynchronously
97+
setImmediate(() => {
98+
void cleanup();
99+
});
100+
return {
101+
success: true,
102+
message: 'stopping',
103+
};
104+
} else {
105+
return {
106+
success: false,
107+
error: `Unknown method: ${JSON.stringify(msg, null, 2)}`,
108+
};
109+
}
110+
} catch (error: unknown) {
111+
const errorMessage = error instanceof Error ? error.message : String(error);
112+
return {
113+
success: false,
114+
error: errorMessage,
115+
};
116+
}
117+
}
118+
119+
async function startSocketServer() {
120+
// Remove existing socket file if it exists (only on non-Windows)
121+
if (!IS_WINDOWS) {
122+
try {
123+
await fs.unlink(socketPath);
124+
} catch {
125+
// ignore errors.
126+
}
127+
}
128+
129+
return await new Promise<void>((resolve, reject) => {
130+
server = createServer(socket => {
131+
const transport = new PipeTransport(socket, socket);
132+
transport.onmessage = async (message: string) => {
133+
logger('onmessage', message);
134+
const response = await handleRequest(JSON.parse(message));
135+
transport.send(JSON.stringify(response));
136+
socket.end();
137+
};
138+
socket.on('error', error => {
139+
logger('Socket error:', error);
140+
});
141+
});
142+
143+
server.listen(
144+
{
145+
path: socketPath,
146+
readableAll: false,
147+
writableAll: false,
148+
},
149+
async () => {
150+
console.log(`Daemon server listening on ${socketPath}`);
151+
152+
try {
153+
// Setup MCP client
154+
await setupMCPClient();
155+
resolve();
156+
} catch (err) {
157+
reject(err);
158+
}
159+
},
160+
);
161+
162+
server.on('error', error => {
163+
logger('Server error:', error);
164+
reject(error);
165+
});
166+
});
167+
}
168+
169+
async function cleanup() {
170+
console.log('Cleaning up daemon...');
171+
172+
try {
173+
await mcpClient?.close();
174+
} catch (error) {
175+
logger('Error closing MCP client:', error);
176+
}
177+
try {
178+
await mcpTransport?.close();
179+
} catch (error) {
180+
logger('Error closing MCP transport:', error);
181+
}
182+
server?.close(() => {
183+
if (!IS_WINDOWS) {
184+
void fs.unlink(socketPath).catch(() => undefined);
185+
}
186+
});
187+
await fs.unlink(pidFile).catch(() => undefined);
188+
process.exit(0);
189+
}
190+
191+
// Handle shutdown signals
192+
process.on('SIGTERM', () => {
193+
void cleanup();
194+
});
195+
process.on('SIGINT', () => {
196+
void cleanup();
197+
});
198+
process.on('SIGHUP', () => {
199+
void cleanup();
200+
});
201+
202+
// Handle uncaught errors
203+
process.on('uncaughtException', error => {
204+
logger('Uncaught exception:', error);
205+
});
206+
process.on('unhandledRejection', error => {
207+
logger('Unhandled rejection:', error);
208+
});
209+
210+
// Start the server
211+
startSocketServer().catch(error => {
212+
logger('Failed to start daemon server:', error);
213+
process.exit(1);
214+
});

src/daemon/utils.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import fs from 'node:fs';
8+
import os from 'node:os';
9+
import path from 'node:path';
10+
import process from 'node:process';
11+
12+
export const DAEMON_SCRIPT_PATH = path.join(import.meta.dirname, 'daemon.js');
13+
export const INDEX_SCRIPT_PATH = path.join(
14+
import.meta.dirname,
15+
'..',
16+
'index.js',
17+
);
18+
19+
const APP_NAME = 'chrome-devtools-mcp';
20+
21+
// Using these paths due to strict limits on the POSIX socket path length.
22+
export function getSocketPath(): string {
23+
const uid = os.userInfo().uid;
24+
25+
if (IS_WINDOWS) {
26+
// Windows uses Named Pipes, not file paths.
27+
// This format is required for server.listen()
28+
return path.join('\\\\.\\pipe', APP_NAME, 'server.sock');
29+
}
30+
31+
// 1. Try XDG_RUNTIME_DIR (Linux standard, sometimes macOS)
32+
if (process.env.XDG_RUNTIME_DIR) {
33+
return path.join(process.env.XDG_RUNTIME_DIR, APP_NAME, 'server.sock');
34+
}
35+
36+
// 2. macOS/Unix Fallback: Use /tmp/
37+
// We use /tmp/ because it is much shorter than ~/Library/Application Support/
38+
// and keeps us well under the 104-character limit.
39+
return path.join('/tmp', `${APP_NAME}-${uid}.sock`);
40+
}
41+
42+
export function getRuntimeHome(): string {
43+
const platform = os.platform();
44+
const uid = os.userInfo().uid;
45+
46+
// 1. Check for the modern Unix standard
47+
if (process.env.XDG_RUNTIME_DIR) {
48+
return path.join(process.env.XDG_RUNTIME_DIR, APP_NAME);
49+
}
50+
51+
// 2. Fallback for macOS and older Linux
52+
if (platform === 'darwin' || platform === 'linux') {
53+
// /tmp is cleared on boot, making it perfect for PIDs
54+
return path.join('/tmp', `${APP_NAME}-${uid}`);
55+
}
56+
57+
// 3. Windows Fallback
58+
return path.join(os.tmpdir(), APP_NAME);
59+
}
60+
61+
export const IS_WINDOWS = os.platform() === 'win32';
62+
63+
export function handlePidFile() {
64+
const runtimeDir = getRuntimeHome();
65+
const pidPath = path.join(runtimeDir, 'daemon.pid');
66+
67+
if (fs.existsSync(pidPath)) {
68+
const oldPid = parseInt(fs.readFileSync(pidPath, 'utf8'), 10);
69+
try {
70+
// Sending signal 0 checks if the process is still alive without killing it
71+
process.kill(oldPid, 0);
72+
console.error('Daemon is already running!');
73+
process.exit(1);
74+
} catch {
75+
// Process is dead, we can safely overwrite the PID file
76+
fs.unlinkSync(pidPath);
77+
}
78+
}
79+
80+
fs.mkdirSync(path.dirname(pidPath), {
81+
recursive: true,
82+
});
83+
fs.writeFileSync(pidPath, process.pid.toString());
84+
return pidPath;
85+
}

src/third_party/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export {
3030
} from 'puppeteer-core';
3131
export {default as puppeteer} from 'puppeteer-core';
3232
export type * from 'puppeteer-core';
33+
export {PipeTransport} from 'puppeteer-core/internal/node/PipeTransport.js';
3334
export type {CdpPage} from 'puppeteer-core/internal/cdp/Page.js';
3435
export {
3536
resolveDefaultUserDataDir,

tests/daemon/daemon.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import assert from 'node:assert';
8+
import {spawn} from 'node:child_process';
9+
import net from 'node:net';
10+
import path from 'node:path';
11+
import {describe, it} from 'node:test';
12+
13+
import {getSocketPath} from '../../src/daemon/utils.js';
14+
15+
const DAEMON_SCRIPT = path.join(
16+
import.meta.dirname,
17+
'..',
18+
'..',
19+
'src',
20+
'daemon',
21+
'daemon.js',
22+
);
23+
24+
describe('Daemon', () => {
25+
it('should terminate chrome instance when transport is closed', async () => {
26+
const daemonProcess = spawn(process.execPath, [DAEMON_SCRIPT], {
27+
env: {
28+
...process.env,
29+
},
30+
stdio: ['ignore', 'pipe', 'pipe'],
31+
});
32+
33+
const socketPath = getSocketPath();
34+
// Wait for daemon to be ready
35+
await new Promise<void>((resolve, reject) => {
36+
const onData = (data: Buffer) => {
37+
const output = data.toString();
38+
// Wait for MCP client to connect
39+
if (output.includes('MCP client connected')) {
40+
daemonProcess.stdout.off('data', onData);
41+
resolve();
42+
}
43+
};
44+
daemonProcess.stdout.on('data', onData);
45+
daemonProcess.stderr.on('data', data => {
46+
console.log('err', data.toString('utf8'));
47+
});
48+
daemonProcess.on('error', reject);
49+
daemonProcess.on('exit', (code: number) => {
50+
if (code !== 0 && code !== null) {
51+
reject(new Error(`Daemon exited with code ${code}`));
52+
}
53+
});
54+
});
55+
56+
const socket = net.createConnection(socketPath);
57+
await new Promise<void>(resolve => socket.on('connect', resolve));
58+
59+
daemonProcess.kill();
60+
assert.ok(daemonProcess.killed);
61+
});
62+
});

0 commit comments

Comments
 (0)