Skip to content

Commit f66fbad

Browse files
committed
chore: implement a daemon process
1 parent 59f6477 commit f66fbad

4 files changed

Lines changed: 403 additions & 0 deletions

File tree

src/daemon/daemon.ts

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

src/daemon/utils.ts

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

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,

0 commit comments

Comments
 (0)