Skip to content

Commit fd02e79

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

4 files changed

Lines changed: 379 additions & 0 deletions

File tree

src/daemon/daemon.ts

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

src/daemon/utils.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import os from 'node:os';
8+
import path from 'node:path';
9+
import process from 'node:process';
10+
11+
const _dirname = import.meta.dirname;
12+
13+
export const DAEMON_SCRIPT_PATH = path.join(_dirname, 'daemon.js');
14+
export const INDEX_SCRIPT_PATH = path.join(_dirname, '..', 'index.js');
15+
16+
function getDataHome(): string {
17+
if (process.env.XDG_DATA_HOME) {
18+
return process.env.XDG_DATA_HOME;
19+
}
20+
const platform = os.platform();
21+
const home = os.homedir();
22+
23+
switch (platform) {
24+
case 'win32':
25+
return process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
26+
case 'darwin':
27+
return path.join(home, 'Library', 'Application Support');
28+
default:
29+
// linux, etc.
30+
return path.join(home, '.local', 'share');
31+
}
32+
}
33+
34+
function getDaemonDir(): string {
35+
const dataHome = getDataHome();
36+
// Using a vendor-prefixed name for safety/standard practice
37+
return path.join(dataHome, 'google', 'chrome-devtools-mcp');
38+
}
39+
40+
export function getDaemonPaths() {
41+
const daemonDir = getDaemonDir();
42+
43+
const pidFile = path.join(daemonDir, 'server.pid');
44+
45+
const isWindows = os.platform() === 'win32';
46+
const socketPath = isWindows
47+
? '\\\\.\\pipe\\chrome-devtools-mcp-daemon'
48+
: path.join(daemonDir, 'server.sock');
49+
50+
return {
51+
pidFile,
52+
socketPath,
53+
daemonDir,
54+
};
55+
}

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: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
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 fs from 'node:fs/promises';
10+
import net from 'node:net';
11+
import os from 'node:os';
12+
import path from 'node:path';
13+
import {describe, it, before} from 'node:test';
14+
15+
import {getDaemonPaths} from '../../src/daemon/utils.js';
16+
17+
const DAEMON_SCRIPT = path.join(
18+
import.meta.dirname,
19+
'..',
20+
'..',
21+
'src',
22+
'daemon',
23+
'daemon.js',
24+
);
25+
26+
describe('Daemon', () => {
27+
let tmpDir: string;
28+
29+
before(async () => {
30+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-daemon-test-'));
31+
process.env['XDG_DATA_HOME'] = tmpDir;
32+
await fs.mkdir(getDaemonPaths().daemonDir, {
33+
recursive: true,
34+
});
35+
});
36+
37+
it('should terminate chrome instance when transport is closed', async () => {
38+
const daemonProcess = spawn(process.execPath, [DAEMON_SCRIPT], {
39+
env: {
40+
...process.env,
41+
XDG_DATA_HOME: tmpDir,
42+
},
43+
stdio: ['ignore', 'pipe', 'pipe'],
44+
});
45+
46+
const {socketPath} = getDaemonPaths();
47+
// Wait for daemon to be ready
48+
await new Promise<void>((resolve, reject) => {
49+
const onData = (data: Buffer) => {
50+
const output = data.toString();
51+
// Wait for MCP client to connect
52+
if (output.includes('MCP client connected')) {
53+
daemonProcess.stdout.off('data', onData);
54+
resolve();
55+
}
56+
};
57+
daemonProcess.stdout.on('data', onData);
58+
daemonProcess.stderr.on('data', data => {
59+
console.log('err', data.toString('utf8'));
60+
});
61+
daemonProcess.on('error', reject);
62+
daemonProcess.on('exit', (code: number) => {
63+
if (code !== 0 && code !== null) {
64+
reject(new Error(`Daemon exited with code ${code}`));
65+
}
66+
});
67+
});
68+
69+
const socket = net.createConnection(socketPath);
70+
await new Promise<void>(resolve => socket.on('connect', resolve));
71+
72+
daemonProcess.kill();
73+
assert.ok(daemonProcess.killed);
74+
});
75+
});

0 commit comments

Comments
 (0)