argusqa-os 9.4.2 → 9.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -6
- package/src/adapters/browser.js +86 -86
- package/src/mcp-server.js +312 -312
- package/src/utils/github-reporter.js +1 -1
- package/src/utils/mcp-client.js +263 -263
- package/src/utils/memory-analyzer.js +270 -270
|
@@ -135,7 +135,7 @@ export function formatPrComment(report, diff) {
|
|
|
135
135
|
}
|
|
136
136
|
|
|
137
137
|
lines.push('', '---');
|
|
138
|
-
lines.push(`_Generated by [Argus](https://github.com/
|
|
138
|
+
lines.push(`_Generated by [Argus](https://github.com/ironclawdevs27/Argus) · ${new Date(report.generatedAt).toISOString()}_`);
|
|
139
139
|
|
|
140
140
|
return lines.join('\n');
|
|
141
141
|
}
|
package/src/utils/mcp-client.js
CHANGED
|
@@ -1,263 +1,263 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ARGUS MCP Client — Headless CI Mode
|
|
3
|
-
*
|
|
4
|
-
* In Claude Code (interactive), MCP tools are called natively by the agent.
|
|
5
|
-
* In CI (GitHub Actions, headless), this module spawns the chrome-devtools-mcp
|
|
6
|
-
* process and communicates via JSON-RPC over stdio, wrapping each tool as an
|
|
7
|
-
* async function with the same signature our orchestration scripts expect.
|
|
8
|
-
*
|
|
9
|
-
* Usage:
|
|
10
|
-
* const mcp = await createMcpClient();
|
|
11
|
-
* await mcp.navigate_page({ url: 'http://localhost:3000' });
|
|
12
|
-
* const msgs = await mcp.list_console_messages();
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { spawn } from 'child_process';
|
|
16
|
-
import { childLogger } from './logger.js';
|
|
17
|
-
|
|
18
|
-
const logger = childLogger('mcp-client');
|
|
19
|
-
|
|
20
|
-
// Validate MCP_BROWSER_URL before embedding it in a shell:true spawn argument.
|
|
21
|
-
// Two-step defense:
|
|
22
|
-
// 1. new URL() rejects malformed/non-http(s) values.
|
|
23
|
-
// 2. Shell-metacharacter check rejects valid URLs whose query strings contain
|
|
24
|
-
// &, |, ;, backtick, $() etc. — new URL().toString() preserves & in query
|
|
25
|
-
// strings (valid URL syntax), but & is a shell background-operator that
|
|
26
|
-
// would split the spawn command on both bash and cmd.exe.
|
|
27
|
-
// A legitimate Chrome remote-debug URL is always http(s)://host:port with
|
|
28
|
-
// no path or query string, so this check never fires in practice.
|
|
29
|
-
const _rawBrowserUrl = process.env.MCP_BROWSER_URL ?? 'http://127.0.0.1:9222';
|
|
30
|
-
let BROWSER_URL;
|
|
31
|
-
try {
|
|
32
|
-
const _parsed = new URL(_rawBrowserUrl);
|
|
33
|
-
if (_parsed.protocol !== 'http:' && _parsed.protocol !== 'https:') {
|
|
34
|
-
throw new Error('protocol must be http or https');
|
|
35
|
-
}
|
|
36
|
-
BROWSER_URL = _parsed.toString();
|
|
37
|
-
} catch (e) {
|
|
38
|
-
throw new Error(`[ARGUS] Invalid MCP_BROWSER_URL "${_rawBrowserUrl}": ${e.message}`);
|
|
39
|
-
}
|
|
40
|
-
// Shell-metacharacter guard — must run AFTER URL re-serialization.
|
|
41
|
-
const _SHELL_META = /[&|;<>`${}()\n\r!"]/;
|
|
42
|
-
if (_SHELL_META.test(BROWSER_URL)) {
|
|
43
|
-
throw new Error(
|
|
44
|
-
`[ARGUS] MCP_BROWSER_URL contains shell-unsafe characters — ` +
|
|
45
|
-
`use a plain http(s)://host:port URL (got: "${BROWSER_URL}")`
|
|
46
|
-
);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Unwrap an evaluate_script result to its plain value.
|
|
51
|
-
*
|
|
52
|
-
* MCP clients may return results in different shapes depending on whether they
|
|
53
|
-
* are running in interactive mode (Claude Code native) or headless CI mode:
|
|
54
|
-
* - { result: value } — some interactive-mode responses
|
|
55
|
-
* - value — headless client already extracts the value
|
|
56
|
-
* - null / undefined — script failed or Chrome not connected
|
|
57
|
-
*
|
|
58
|
-
* @param {any} raw - Raw return value from mcp.evaluate_script(...)
|
|
59
|
-
* @returns {any} The unwrapped value
|
|
60
|
-
*/
|
|
61
|
-
export function unwrapEval(raw) {
|
|
62
|
-
if (raw == null) return null;
|
|
63
|
-
if (typeof raw === 'object' && !Array.isArray(raw)) return raw.result ?? raw;
|
|
64
|
-
return raw;
|
|
65
|
-
}
|
|
66
|
-
const TOOL_TIMEOUT_MS = parseInt(process.env.MCP_TOOL_TIMEOUT_MS ?? '30000', 10);
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Create an MCP client that wraps chrome-devtools-mcp via JSON-RPC over stdio.
|
|
70
|
-
* @returns {Promise<object>} Object with all MCP tool methods
|
|
71
|
-
*/
|
|
72
|
-
export async function createMcpClient() {
|
|
73
|
-
// On Windows, npx is npx.cmd — shell:true resolves this cross-platform.
|
|
74
|
-
const proc = spawn('npx', [
|
|
75
|
-
'-y', 'chrome-devtools-mcp@latest',
|
|
76
|
-
`--browser-url=${BROWSER_URL}`,
|
|
77
|
-
'--headless=true',
|
|
78
|
-
'--viewport=1920x1080',
|
|
79
|
-
], {
|
|
80
|
-
stdio: ['pipe', 'pipe', 'inherit'],
|
|
81
|
-
shell: true,
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
let messageId = 1;
|
|
85
|
-
const pending = new Map(); // id → { resolve, reject }
|
|
86
|
-
let buffer = '';
|
|
87
|
-
|
|
88
|
-
// Parse newline-delimited JSON-RPC responses from stdout
|
|
89
|
-
// Propagate stdin write errors — if the MCP process closes unexpectedly or the
|
|
90
|
-
// write buffer fills, stdin emits 'error'. Without this listener the error is an
|
|
91
|
-
// unhandled EventEmitter exception that crashes the process. Reject all pending calls
|
|
92
|
-
// so callers get a meaningful error instead of waiting for the 30 s timeout.
|
|
93
|
-
proc.stdin.on('error', err => {
|
|
94
|
-
logger.error('[ARGUS] MCP stdin error:', err.message);
|
|
95
|
-
for (const { reject } of pending.values()) {
|
|
96
|
-
reject(new Error(`MCP stdin error: ${err.message}`));
|
|
97
|
-
}
|
|
98
|
-
pending.clear();
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
const MAX_BUFFER_BYTES = 50 * 1024 * 1024;
|
|
102
|
-
proc.stdout.on('data', (chunk) => {
|
|
103
|
-
if (buffer.length + chunk.length > MAX_BUFFER_BYTES) {
|
|
104
|
-
logger.error('[ARGUS] MCP stdout buffer overflow — discarding buffer');
|
|
105
|
-
buffer = '';
|
|
106
|
-
}
|
|
107
|
-
buffer += chunk.toString();
|
|
108
|
-
const lines = buffer.split(/\r?\n/);
|
|
109
|
-
buffer = lines.pop(); // keep incomplete line in buffer
|
|
110
|
-
for (const line of lines) {
|
|
111
|
-
if (!line.trim()) continue;
|
|
112
|
-
try {
|
|
113
|
-
const msg = JSON.parse(line);
|
|
114
|
-
if (msg.id !== undefined && pending.has(msg.id)) {
|
|
115
|
-
const { resolve, reject } = pending.get(msg.id);
|
|
116
|
-
pending.delete(msg.id);
|
|
117
|
-
if (msg.error) {
|
|
118
|
-
reject(new Error(`MCP error ${msg.error.code}: ${msg.error.message}`));
|
|
119
|
-
} else {
|
|
120
|
-
resolve(msg.result);
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
} catch {
|
|
124
|
-
// non-JSON line from process — ignore
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
proc.on('exit', (code, signal) => {
|
|
130
|
-
if (code !== 0 || signal) {
|
|
131
|
-
for (const { reject } of pending.values()) {
|
|
132
|
-
reject(new Error(`MCP process exited: code=${code}, signal=${signal}`));
|
|
133
|
-
}
|
|
134
|
-
pending.clear();
|
|
135
|
-
}
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
// Send JSON-RPC initialize handshake
|
|
139
|
-
await call('initialize', {
|
|
140
|
-
protocolVersion: '2024-11-05',
|
|
141
|
-
capabilities: {},
|
|
142
|
-
clientInfo: { name: 'argus', version: '1.0.0' },
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* Call an MCP tool by name with params.
|
|
147
|
-
* @param {string} method - JSON-RPC method name
|
|
148
|
-
* @param {object} params
|
|
149
|
-
* @returns {Promise<any>}
|
|
150
|
-
*/
|
|
151
|
-
function call(method, params = {}) {
|
|
152
|
-
return new Promise((resolve, reject) => {
|
|
153
|
-
const id = messageId++;
|
|
154
|
-
const request = JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n';
|
|
155
|
-
pending.set(id, { resolve, reject });
|
|
156
|
-
|
|
157
|
-
const timer = setTimeout(() => {
|
|
158
|
-
if (pending.has(id)) {
|
|
159
|
-
pending.delete(id);
|
|
160
|
-
reject(new Error(`MCP tool timeout: ${method} (${TOOL_TIMEOUT_MS}ms)`));
|
|
161
|
-
}
|
|
162
|
-
}, TOOL_TIMEOUT_MS);
|
|
163
|
-
|
|
164
|
-
// Clear timer on resolution
|
|
165
|
-
const { resolve: origResolve, reject: origReject } = pending.get(id);
|
|
166
|
-
pending.set(id, {
|
|
167
|
-
resolve: (v) => { clearTimeout(timer); origResolve(v); },
|
|
168
|
-
reject: (e) => { clearTimeout(timer); origReject(e); },
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
proc.stdin.write(request, (err) => {
|
|
172
|
-
if (err && pending.has(id)) {
|
|
173
|
-
const { reject: rej } = pending.get(id);
|
|
174
|
-
pending.delete(id);
|
|
175
|
-
rej(err);
|
|
176
|
-
}
|
|
177
|
-
});
|
|
178
|
-
});
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/**
|
|
182
|
-
* Call an MCP tool (tools/call JSON-RPC method).
|
|
183
|
-
*/
|
|
184
|
-
function tool(name, args = {}) {
|
|
185
|
-
return call('tools/call', { name, arguments: args })
|
|
186
|
-
.then(result => {
|
|
187
|
-
// MCP returns { content: [{ type, text|data }] } — extract the value
|
|
188
|
-
const content = result?.content;
|
|
189
|
-
if (Array.isArray(content) && content.length > 0) {
|
|
190
|
-
const item = content[0];
|
|
191
|
-
if (item.type === 'image') {
|
|
192
|
-
// take_screenshot returns base64 image data — return in a shape callers expect
|
|
193
|
-
return { data: item.data, mimeType: item.mimeType ?? 'image/png' };
|
|
194
|
-
}
|
|
195
|
-
if (item.type === 'text') {
|
|
196
|
-
const text = item.text;
|
|
197
|
-
// chrome-devtools-mcp wraps evaluate_script results in a markdown code block:
|
|
198
|
-
// "Script ran on page and returned:\n```json\n<value>\n```"
|
|
199
|
-
// \n? before closing fence — responses without a trailing newline
|
|
200
|
-
// before the ``` would not match and fall through to raw JSON.parse, which
|
|
201
|
-
// then fails because the fence characters are still present in the text.
|
|
202
|
-
const mdMatch = text.match(/```(?:json)?\n([\s\S]*?)\n?```/);
|
|
203
|
-
if (mdMatch) {
|
|
204
|
-
try { return JSON.parse(mdMatch[1]); } catch { return mdMatch[1]; }
|
|
205
|
-
}
|
|
206
|
-
try { return JSON.parse(text); } catch { return text; }
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
return result;
|
|
210
|
-
});
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
// Graceful shutdown — idempotent and rejects all in-flight calls immediately.
|
|
214
|
-
// Without this, pending promises wait until TOOL_TIMEOUT_MS (30 s) after
|
|
215
|
-
// shutdown, making CI teardown slow and leaving dangling rejections.
|
|
216
|
-
let closed = false;
|
|
217
|
-
function close() {
|
|
218
|
-
if (closed) return;
|
|
219
|
-
closed = true;
|
|
220
|
-
for (const { reject } of pending.values()) {
|
|
221
|
-
reject(new Error('MCP client closed'));
|
|
222
|
-
}
|
|
223
|
-
pending.clear();
|
|
224
|
-
proc.stdin.end();
|
|
225
|
-
proc.kill('SIGTERM');
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// Build the mcp interface object matching what orchestration scripts expect
|
|
229
|
-
return {
|
|
230
|
-
navigate_page: (args) => tool('navigate_page', args),
|
|
231
|
-
list_pages: (args) => tool('list_pages', args),
|
|
232
|
-
new_page: (args) => tool('new_page', args),
|
|
233
|
-
select_page: (args) => tool('select_page', args),
|
|
234
|
-
close_page: (args) => tool('close_page', args),
|
|
235
|
-
take_screenshot: (args) => tool('take_screenshot', args),
|
|
236
|
-
take_snapshot: (args) => tool('take_snapshot', args),
|
|
237
|
-
list_console_messages: (args) => tool('list_console_messages', args),
|
|
238
|
-
get_console_message: (args) => tool('get_console_message', args),
|
|
239
|
-
list_network_requests: (args) => tool('list_network_requests', args),
|
|
240
|
-
get_network_request: (args) => tool('get_network_request', args),
|
|
241
|
-
evaluate_script: (args) => tool('evaluate_script', args),
|
|
242
|
-
wait_for: (args) => tool('wait_for', args),
|
|
243
|
-
click: (args) => tool('click', args),
|
|
244
|
-
fill: (args) => tool('fill', args),
|
|
245
|
-
fill_form: (args) => tool('fill_form', args),
|
|
246
|
-
hover: (args) => tool('hover', args),
|
|
247
|
-
type_text: (args) => tool('type_text', args),
|
|
248
|
-
press_key: (args) => tool('press_key', args),
|
|
249
|
-
resize_page: (args) => tool('resize_page', args),
|
|
250
|
-
emulate: (args) => tool('emulate', args),
|
|
251
|
-
performance_start_trace: (args) => tool('performance_start_trace', args),
|
|
252
|
-
performance_stop_trace: (args) => tool('performance_stop_trace', args),
|
|
253
|
-
performance_analyze_insight: (args) => tool('performance_analyze_insight', args),
|
|
254
|
-
take_heapsnapshot: (args) => tool('take_heapsnapshot', args),
|
|
255
|
-
lighthouse_audit: (args) => tool('lighthouse_audit', args),
|
|
256
|
-
handle_dialog: (args) => tool('handle_dialog', args),
|
|
257
|
-
drag: (args) => tool('drag', args),
|
|
258
|
-
upload_file: (args) => tool('upload_file', args),
|
|
259
|
-
select_option: (args) => tool('select_option', args),
|
|
260
|
-
emulate_network: (args) => tool('emulate_network', args),
|
|
261
|
-
close,
|
|
262
|
-
};
|
|
263
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* ARGUS MCP Client — Headless CI Mode
|
|
3
|
+
*
|
|
4
|
+
* In Claude Code (interactive), MCP tools are called natively by the agent.
|
|
5
|
+
* In CI (GitHub Actions, headless), this module spawns the chrome-devtools-mcp
|
|
6
|
+
* process and communicates via JSON-RPC over stdio, wrapping each tool as an
|
|
7
|
+
* async function with the same signature our orchestration scripts expect.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* const mcp = await createMcpClient();
|
|
11
|
+
* await mcp.navigate_page({ url: 'http://localhost:3000' });
|
|
12
|
+
* const msgs = await mcp.list_console_messages();
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { spawn } from 'child_process';
|
|
16
|
+
import { childLogger } from './logger.js';
|
|
17
|
+
|
|
18
|
+
const logger = childLogger('mcp-client');
|
|
19
|
+
|
|
20
|
+
// Validate MCP_BROWSER_URL before embedding it in a shell:true spawn argument.
|
|
21
|
+
// Two-step defense:
|
|
22
|
+
// 1. new URL() rejects malformed/non-http(s) values.
|
|
23
|
+
// 2. Shell-metacharacter check rejects valid URLs whose query strings contain
|
|
24
|
+
// &, |, ;, backtick, $() etc. — new URL().toString() preserves & in query
|
|
25
|
+
// strings (valid URL syntax), but & is a shell background-operator that
|
|
26
|
+
// would split the spawn command on both bash and cmd.exe.
|
|
27
|
+
// A legitimate Chrome remote-debug URL is always http(s)://host:port with
|
|
28
|
+
// no path or query string, so this check never fires in practice.
|
|
29
|
+
const _rawBrowserUrl = process.env.MCP_BROWSER_URL ?? 'http://127.0.0.1:9222';
|
|
30
|
+
let BROWSER_URL;
|
|
31
|
+
try {
|
|
32
|
+
const _parsed = new URL(_rawBrowserUrl);
|
|
33
|
+
if (_parsed.protocol !== 'http:' && _parsed.protocol !== 'https:') {
|
|
34
|
+
throw new Error('protocol must be http or https');
|
|
35
|
+
}
|
|
36
|
+
BROWSER_URL = _parsed.toString();
|
|
37
|
+
} catch (e) {
|
|
38
|
+
throw new Error(`[ARGUS] Invalid MCP_BROWSER_URL "${_rawBrowserUrl}": ${e.message}`);
|
|
39
|
+
}
|
|
40
|
+
// Shell-metacharacter guard — must run AFTER URL re-serialization.
|
|
41
|
+
const _SHELL_META = /[&|;<>`${}()\n\r!"]/;
|
|
42
|
+
if (_SHELL_META.test(BROWSER_URL)) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`[ARGUS] MCP_BROWSER_URL contains shell-unsafe characters — ` +
|
|
45
|
+
`use a plain http(s)://host:port URL (got: "${BROWSER_URL}")`
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Unwrap an evaluate_script result to its plain value.
|
|
51
|
+
*
|
|
52
|
+
* MCP clients may return results in different shapes depending on whether they
|
|
53
|
+
* are running in interactive mode (Claude Code native) or headless CI mode:
|
|
54
|
+
* - { result: value } — some interactive-mode responses
|
|
55
|
+
* - value — headless client already extracts the value
|
|
56
|
+
* - null / undefined — script failed or Chrome not connected
|
|
57
|
+
*
|
|
58
|
+
* @param {any} raw - Raw return value from mcp.evaluate_script(...)
|
|
59
|
+
* @returns {any} The unwrapped value
|
|
60
|
+
*/
|
|
61
|
+
export function unwrapEval(raw) {
|
|
62
|
+
if (raw == null) return null;
|
|
63
|
+
if (typeof raw === 'object' && !Array.isArray(raw)) return raw.result ?? raw;
|
|
64
|
+
return raw;
|
|
65
|
+
}
|
|
66
|
+
const TOOL_TIMEOUT_MS = parseInt(process.env.MCP_TOOL_TIMEOUT_MS ?? '30000', 10);
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Create an MCP client that wraps chrome-devtools-mcp via JSON-RPC over stdio.
|
|
70
|
+
* @returns {Promise<object>} Object with all MCP tool methods
|
|
71
|
+
*/
|
|
72
|
+
export async function createMcpClient() {
|
|
73
|
+
// On Windows, npx is npx.cmd — shell:true resolves this cross-platform.
|
|
74
|
+
const proc = spawn('npx', [
|
|
75
|
+
'-y', 'chrome-devtools-mcp@latest',
|
|
76
|
+
`--browser-url=${BROWSER_URL}`,
|
|
77
|
+
'--headless=true',
|
|
78
|
+
'--viewport=1920x1080',
|
|
79
|
+
], {
|
|
80
|
+
stdio: ['pipe', 'pipe', 'inherit'],
|
|
81
|
+
shell: true,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
let messageId = 1;
|
|
85
|
+
const pending = new Map(); // id → { resolve, reject }
|
|
86
|
+
let buffer = '';
|
|
87
|
+
|
|
88
|
+
// Parse newline-delimited JSON-RPC responses from stdout
|
|
89
|
+
// Propagate stdin write errors — if the MCP process closes unexpectedly or the
|
|
90
|
+
// write buffer fills, stdin emits 'error'. Without this listener the error is an
|
|
91
|
+
// unhandled EventEmitter exception that crashes the process. Reject all pending calls
|
|
92
|
+
// so callers get a meaningful error instead of waiting for the 30 s timeout.
|
|
93
|
+
proc.stdin.on('error', err => {
|
|
94
|
+
logger.error('[ARGUS] MCP stdin error:', err.message);
|
|
95
|
+
for (const { reject } of pending.values()) {
|
|
96
|
+
reject(new Error(`MCP stdin error: ${err.message}`));
|
|
97
|
+
}
|
|
98
|
+
pending.clear();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const MAX_BUFFER_BYTES = 50 * 1024 * 1024;
|
|
102
|
+
proc.stdout.on('data', (chunk) => {
|
|
103
|
+
if (buffer.length + chunk.length > MAX_BUFFER_BYTES) {
|
|
104
|
+
logger.error('[ARGUS] MCP stdout buffer overflow — discarding buffer');
|
|
105
|
+
buffer = '';
|
|
106
|
+
}
|
|
107
|
+
buffer += chunk.toString();
|
|
108
|
+
const lines = buffer.split(/\r?\n/);
|
|
109
|
+
buffer = lines.pop(); // keep incomplete line in buffer
|
|
110
|
+
for (const line of lines) {
|
|
111
|
+
if (!line.trim()) continue;
|
|
112
|
+
try {
|
|
113
|
+
const msg = JSON.parse(line);
|
|
114
|
+
if (msg.id !== undefined && pending.has(msg.id)) {
|
|
115
|
+
const { resolve, reject } = pending.get(msg.id);
|
|
116
|
+
pending.delete(msg.id);
|
|
117
|
+
if (msg.error) {
|
|
118
|
+
reject(new Error(`MCP error ${msg.error.code}: ${msg.error.message}`));
|
|
119
|
+
} else {
|
|
120
|
+
resolve(msg.result);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
} catch {
|
|
124
|
+
// non-JSON line from process — ignore
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
proc.on('exit', (code, signal) => {
|
|
130
|
+
if (code !== 0 || signal) {
|
|
131
|
+
for (const { reject } of pending.values()) {
|
|
132
|
+
reject(new Error(`MCP process exited: code=${code}, signal=${signal}`));
|
|
133
|
+
}
|
|
134
|
+
pending.clear();
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// Send JSON-RPC initialize handshake
|
|
139
|
+
await call('initialize', {
|
|
140
|
+
protocolVersion: '2024-11-05',
|
|
141
|
+
capabilities: {},
|
|
142
|
+
clientInfo: { name: 'argus', version: '1.0.0' },
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Call an MCP tool by name with params.
|
|
147
|
+
* @param {string} method - JSON-RPC method name
|
|
148
|
+
* @param {object} params
|
|
149
|
+
* @returns {Promise<any>}
|
|
150
|
+
*/
|
|
151
|
+
function call(method, params = {}) {
|
|
152
|
+
return new Promise((resolve, reject) => {
|
|
153
|
+
const id = messageId++;
|
|
154
|
+
const request = JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n';
|
|
155
|
+
pending.set(id, { resolve, reject });
|
|
156
|
+
|
|
157
|
+
const timer = setTimeout(() => {
|
|
158
|
+
if (pending.has(id)) {
|
|
159
|
+
pending.delete(id);
|
|
160
|
+
reject(new Error(`MCP tool timeout: ${method} (${TOOL_TIMEOUT_MS}ms)`));
|
|
161
|
+
}
|
|
162
|
+
}, TOOL_TIMEOUT_MS);
|
|
163
|
+
|
|
164
|
+
// Clear timer on resolution
|
|
165
|
+
const { resolve: origResolve, reject: origReject } = pending.get(id);
|
|
166
|
+
pending.set(id, {
|
|
167
|
+
resolve: (v) => { clearTimeout(timer); origResolve(v); },
|
|
168
|
+
reject: (e) => { clearTimeout(timer); origReject(e); },
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
proc.stdin.write(request, (err) => {
|
|
172
|
+
if (err && pending.has(id)) {
|
|
173
|
+
const { reject: rej } = pending.get(id);
|
|
174
|
+
pending.delete(id);
|
|
175
|
+
rej(err);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Call an MCP tool (tools/call JSON-RPC method).
|
|
183
|
+
*/
|
|
184
|
+
function tool(name, args = {}) {
|
|
185
|
+
return call('tools/call', { name, arguments: args })
|
|
186
|
+
.then(result => {
|
|
187
|
+
// MCP returns { content: [{ type, text|data }] } — extract the value
|
|
188
|
+
const content = result?.content;
|
|
189
|
+
if (Array.isArray(content) && content.length > 0) {
|
|
190
|
+
const item = content[0];
|
|
191
|
+
if (item.type === 'image') {
|
|
192
|
+
// take_screenshot returns base64 image data — return in a shape callers expect
|
|
193
|
+
return { data: item.data, mimeType: item.mimeType ?? 'image/png' };
|
|
194
|
+
}
|
|
195
|
+
if (item.type === 'text') {
|
|
196
|
+
const text = item.text;
|
|
197
|
+
// chrome-devtools-mcp wraps evaluate_script results in a markdown code block:
|
|
198
|
+
// "Script ran on page and returned:\n```json\n<value>\n```"
|
|
199
|
+
// \n? before closing fence — responses without a trailing newline
|
|
200
|
+
// before the ``` would not match and fall through to raw JSON.parse, which
|
|
201
|
+
// then fails because the fence characters are still present in the text.
|
|
202
|
+
const mdMatch = text.match(/```(?:json)?\n([\s\S]*?)\n?```/);
|
|
203
|
+
if (mdMatch) {
|
|
204
|
+
try { return JSON.parse(mdMatch[1]); } catch { return mdMatch[1]; }
|
|
205
|
+
}
|
|
206
|
+
try { return JSON.parse(text); } catch { return text; }
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return result;
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Graceful shutdown — idempotent and rejects all in-flight calls immediately.
|
|
214
|
+
// Without this, pending promises wait until TOOL_TIMEOUT_MS (30 s) after
|
|
215
|
+
// shutdown, making CI teardown slow and leaving dangling rejections.
|
|
216
|
+
let closed = false;
|
|
217
|
+
function close() {
|
|
218
|
+
if (closed) return;
|
|
219
|
+
closed = true;
|
|
220
|
+
for (const { reject } of pending.values()) {
|
|
221
|
+
reject(new Error('MCP client closed'));
|
|
222
|
+
}
|
|
223
|
+
pending.clear();
|
|
224
|
+
proc.stdin.end();
|
|
225
|
+
proc.kill('SIGTERM');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Build the mcp interface object matching what orchestration scripts expect
|
|
229
|
+
return {
|
|
230
|
+
navigate_page: (args) => tool('navigate_page', args),
|
|
231
|
+
list_pages: (args) => tool('list_pages', args),
|
|
232
|
+
new_page: (args) => tool('new_page', args),
|
|
233
|
+
select_page: (args) => tool('select_page', args),
|
|
234
|
+
close_page: (args) => tool('close_page', args),
|
|
235
|
+
take_screenshot: (args) => tool('take_screenshot', args),
|
|
236
|
+
take_snapshot: (args) => tool('take_snapshot', args),
|
|
237
|
+
list_console_messages: (args) => tool('list_console_messages', args),
|
|
238
|
+
get_console_message: (args) => tool('get_console_message', args),
|
|
239
|
+
list_network_requests: (args) => tool('list_network_requests', args),
|
|
240
|
+
get_network_request: (args) => tool('get_network_request', args),
|
|
241
|
+
evaluate_script: (args) => tool('evaluate_script', args),
|
|
242
|
+
wait_for: (args) => tool('wait_for', args),
|
|
243
|
+
click: (args) => tool('click', args),
|
|
244
|
+
fill: (args) => tool('fill', args),
|
|
245
|
+
fill_form: (args) => tool('fill_form', args),
|
|
246
|
+
hover: (args) => tool('hover', args),
|
|
247
|
+
type_text: (args) => tool('type_text', args),
|
|
248
|
+
press_key: (args) => tool('press_key', args),
|
|
249
|
+
resize_page: (args) => tool('resize_page', args),
|
|
250
|
+
emulate: (args) => tool('emulate', args),
|
|
251
|
+
performance_start_trace: (args) => tool('performance_start_trace', args),
|
|
252
|
+
performance_stop_trace: (args) => tool('performance_stop_trace', args),
|
|
253
|
+
performance_analyze_insight: (args) => tool('performance_analyze_insight', args),
|
|
254
|
+
take_heapsnapshot: (args) => tool('take_heapsnapshot', args),
|
|
255
|
+
lighthouse_audit: (args) => tool('lighthouse_audit', args),
|
|
256
|
+
handle_dialog: (args) => tool('handle_dialog', args),
|
|
257
|
+
drag: (args) => tool('drag', args),
|
|
258
|
+
upload_file: (args) => tool('upload_file', args),
|
|
259
|
+
select_option: (args) => tool('select_option', args),
|
|
260
|
+
emulate_network: (args) => tool('emulate_network', args),
|
|
261
|
+
close,
|
|
262
|
+
};
|
|
263
|
+
}
|