Skip to content

Commit b659f70

Browse files
committed
Set up an MCP e2e test
1 parent 8749ef9 commit b659f70

3 files changed

Lines changed: 285 additions & 6 deletions

File tree

playwright.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { defineConfig } from '@playwright/test';
22

33
export default defineConfig({
44
testDir: './test',
5-
testMatch: 'smoke.spec.ts',
5+
testIgnore: 'ui-bridge.spec.ts',
66
timeout: 30000,
77
workers: 1,
88
retries: 0,

src/index.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -714,16 +714,21 @@ if (!amMainInstance) {
714714
}
715715

716716
// Restrict calls to IPC handler to our trusted host. This shouldn't be required (we don't allow loading
717-
// 3rd party sites) but it's good practice for defense-in-depth etc.
717+
// 3rd party sites) but it's good practice for defense-in-depth etc. We allow calls with an empty URL
718+
// because the preload script fires IPC invocations before navigation completes, so the frame URL is
719+
// not yet set at that point.
718720
const ipcHandler = <A, R>(fn: (...args: A[]) => R) => (
719721
event: Electron.IpcMainInvokeEvent,
720722
...args: A[]
721723
): R => {
722-
if (!event.senderFrame || !hasTrustedOrigin(new URL(event.senderFrame.url))) {
723-
throw new Error(`Invalid IPC sender URL: ${event.senderFrame?.url}`);
724-
} else {
725-
return fn(...args);
724+
if (!event.senderFrame) {
725+
throw new Error('IPC call from destroyed frame');
726726
}
727+
const frameUrl = event.senderFrame.url;
728+
if (frameUrl && frameUrl !== 'about:blank' && !hasTrustedOrigin(new URL(frameUrl))) {
729+
throw new Error(`Invalid IPC sender URL: ${frameUrl}`);
730+
}
731+
return fn(...args);
727732
};
728733

729734
ipcMain.handle('select-application', ipcHandler(async () => {

test/mcp-e2e.spec.ts

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
import { test, expect, _electron as electron } from '@playwright/test';
2+
import { spawn, ChildProcess } from 'child_process';
3+
import * as http from 'http';
4+
import * as path from 'path';
5+
import * as os from 'os';
6+
import * as readline from 'readline';
7+
8+
// Socket path logic matching src/index.ts and src/cli/cli.ts
9+
function getSocketPath(): string {
10+
if (process.platform === 'win32') {
11+
return '\\\\.\\pipe\\httptoolkit-desktop';
12+
}
13+
14+
let socketDir: string;
15+
if (process.platform === 'linux' && process.env.XDG_RUNTIME_DIR) {
16+
socketDir = process.env.XDG_RUNTIME_DIR;
17+
} else {
18+
const tmpDir = os.tmpdir();
19+
if (tmpDir === '/tmp' || tmpDir === '/var/tmp') {
20+
socketDir = path.join(tmpDir, `httptoolkit-${process.getuid!()}`);
21+
} else {
22+
socketDir = tmpDir;
23+
}
24+
}
25+
26+
return path.join(socketDir, 'httptoolkit.sock');
27+
}
28+
29+
function apiRequest(socketPath: string, method: 'GET' | 'POST', urlPath: string, body?: any): Promise<any> {
30+
return new Promise((resolve, reject) => {
31+
const req = http.request({
32+
method,
33+
path: urlPath,
34+
socketPath,
35+
headers: { 'Content-Type': 'application/json' }
36+
}, (res) => {
37+
const chunks: Buffer[] = [];
38+
res.on('data', (chunk: Buffer) => chunks.push(chunk));
39+
res.on('end', () => {
40+
const raw = Buffer.concat(chunks).toString('utf-8');
41+
if (res.statusCode && res.statusCode >= 400) {
42+
reject(new Error(`HTTP ${res.statusCode}: ${raw}`));
43+
return;
44+
}
45+
try {
46+
resolve(JSON.parse(raw));
47+
} catch {
48+
resolve(raw);
49+
}
50+
});
51+
});
52+
req.on('error', reject);
53+
if (body) req.write(JSON.stringify(body));
54+
req.end();
55+
});
56+
}
57+
58+
async function launchApp(extraArgs: string[] = [], extraEnv: Record<string, string> = {}) {
59+
const app = await electron.launch({
60+
cwd: path.join(import.meta.dirname, '..'),
61+
args: [
62+
'.',
63+
...extraArgs,
64+
// On Linux ARM64 CI, sandboxing doesn't work, so we have to skip it:
65+
...(process.env.CI && process.platform === 'linux' && process.arch === 'arm64' ?
66+
[
67+
'--no-sandbox',
68+
'--disable-setuid-sandbox'
69+
] : []
70+
)
71+
],
72+
timeout: 20000,
73+
env: {
74+
...process.env,
75+
'HTTPTOOLKIT_SERVER_DISABLE_AUTOUPDATE': '1',
76+
...extraEnv
77+
}
78+
});
79+
80+
app.process().stdout?.on('data', (data) => {
81+
console.log('[stdout]', data.toString().trim());
82+
});
83+
app.process().stderr?.on('data', (data) => {
84+
console.error('[stderr]', data.toString().trim());
85+
});
86+
87+
return app;
88+
}
89+
90+
test('MCP server exposes UI operations as tools', async () => {
91+
// This test launches the full Electron app, waits for the production UI
92+
// to register MCP operations, then spawns the CLI in MCP mode and verifies
93+
// the full JSON-RPC protocol flow.
94+
test.setTimeout(120_000);
95+
96+
const electronApp = await launchApp();
97+
let mcpProcess: ChildProcess | undefined;
98+
99+
try {
100+
const window = await electronApp.firstWindow();
101+
102+
// Wait for UI to fully load (server started + UI connected)
103+
await expect(window.locator('h1:has-text("Intercept HTTP")')).toBeVisible({ timeout: 30000 });
104+
105+
const socketPath = getSocketPath();
106+
107+
// Poll until operations are available via the socket API.
108+
// The UI needs to call desktopApi.setApiOperations() after loading,
109+
// which may take a moment after the page is visible.
110+
let operations: any[] = [];
111+
for (let attempt = 0; attempt < 30; attempt++) {
112+
try {
113+
operations = await apiRequest(socketPath, 'GET', '/api/operations');
114+
if (Array.isArray(operations) && operations.length > 0) break;
115+
} catch {
116+
// Socket not ready yet
117+
}
118+
await new Promise(r => setTimeout(r, 1000));
119+
}
120+
121+
expect(operations.length).toBeGreaterThan(0);
122+
123+
// Verify operation shape
124+
for (const op of operations) {
125+
expect(typeof op.name).toBe('string');
126+
expect(typeof op.description).toBe('string');
127+
expect(op.name.length).toBeGreaterThan(0);
128+
}
129+
130+
// Verify status endpoint reports ready
131+
const status = await apiRequest(socketPath, 'GET', '/api/status');
132+
expect(status.ready).toBe(true);
133+
134+
// --- MCP protocol test via CLI ---
135+
136+
mcpProcess = spawn('node', [
137+
path.join(import.meta.dirname, '..', 'build', 'cli', 'cli.js'),
138+
'mcp'
139+
], {
140+
stdio: ['pipe', 'pipe', 'pipe']
141+
});
142+
143+
// Wait for the MCP server to finish its initial operations fetch
144+
// and start listening on stdin before we send any messages.
145+
await new Promise<void>((resolve, reject) => {
146+
const onData = (data: Buffer) => {
147+
if (data.toString().includes('MCP server started on stdio')) {
148+
mcpProcess!.stderr!.removeListener('data', onData);
149+
resolve();
150+
}
151+
};
152+
mcpProcess!.stderr!.on('data', onData);
153+
mcpProcess!.on('exit', (code) =>
154+
reject(new Error(`MCP process exited early with code ${code}`))
155+
);
156+
});
157+
158+
// Set up line-by-line JSON-RPC response reading
159+
const rl = readline.createInterface({ input: mcpProcess.stdout! });
160+
const pendingResolvers: ((value: any) => void)[] = [];
161+
const receivedMessages: any[] = [];
162+
163+
rl.on('line', (line) => {
164+
try {
165+
const parsed = JSON.parse(line.trim());
166+
if (pendingResolvers.length > 0) {
167+
pendingResolvers.shift()!(parsed);
168+
} else {
169+
receivedMessages.push(parsed);
170+
}
171+
} catch {
172+
// Ignore non-JSON lines
173+
}
174+
});
175+
176+
function nextMessage(): Promise<any> {
177+
if (receivedMessages.length > 0) {
178+
return Promise.resolve(receivedMessages.shift());
179+
}
180+
return new Promise(resolve => pendingResolvers.push(resolve));
181+
}
182+
183+
function sendMessage(msg: any): void {
184+
mcpProcess!.stdin!.write(JSON.stringify(msg) + '\n');
185+
}
186+
187+
// 1. Initialize
188+
sendMessage({
189+
jsonrpc: '2.0',
190+
id: 1,
191+
method: 'initialize',
192+
params: {
193+
protocolVersion: '2024-11-05',
194+
capabilities: {},
195+
clientInfo: { name: 'httptoolkit-e2e-test', version: '1.0.0' }
196+
}
197+
});
198+
199+
const initResponse = await nextMessage();
200+
expect(initResponse.jsonrpc).toBe('2.0');
201+
expect(initResponse.id).toBe(1);
202+
expect(initResponse.result.protocolVersion).toBe('2024-11-05');
203+
expect(initResponse.result.capabilities.tools.listChanged).toBe(true);
204+
expect(initResponse.result.serverInfo.name).toBe('httptoolkit');
205+
206+
// 2. Send initialized notification
207+
sendMessage({
208+
jsonrpc: '2.0',
209+
method: 'notifications/initialized'
210+
});
211+
212+
// 3. Request tools list
213+
sendMessage({
214+
jsonrpc: '2.0',
215+
id: 2,
216+
method: 'tools/list'
217+
});
218+
219+
const toolsResponse = await nextMessage();
220+
expect(toolsResponse.id).toBe(2);
221+
const tools = toolsResponse.result.tools;
222+
expect(tools.length).toBeGreaterThan(0);
223+
224+
// Verify tool names match operations (dots replaced with underscores)
225+
const expectedToolNames = new Set(
226+
operations.map((op: any) => op.name.replace(/\./g, '_'))
227+
);
228+
const actualToolNames = new Set(
229+
tools.map((t: any) => t.name)
230+
);
231+
232+
for (const expectedName of expectedToolNames) {
233+
expect(actualToolNames.has(expectedName)).toBe(true);
234+
}
235+
236+
// Verify each tool has the correct MCP shape
237+
for (const tool of tools) {
238+
expect(typeof tool.name).toBe('string');
239+
expect(typeof tool.description).toBe('string');
240+
expect(tool.inputSchema).toBeTruthy();
241+
expect(tool.inputSchema.type).toBe('object');
242+
}
243+
244+
// 4. Test unknown method returns error
245+
sendMessage({
246+
jsonrpc: '2.0',
247+
id: 3,
248+
method: 'nonexistent/method'
249+
});
250+
251+
const errorResponse = await nextMessage();
252+
expect(errorResponse.id).toBe(3);
253+
expect(errorResponse.error).toBeTruthy();
254+
expect(errorResponse.error.code).toBe(-32601);
255+
256+
// 5. Test malformed JSON returns parse error
257+
mcpProcess.stdin!.write('not valid json\n');
258+
259+
const parseErrorResponse = await nextMessage();
260+
expect(parseErrorResponse.error).toBeTruthy();
261+
expect(parseErrorResponse.error.code).toBe(-32700);
262+
263+
} finally {
264+
if (mcpProcess) {
265+
mcpProcess.stdin?.end();
266+
mcpProcess.kill();
267+
}
268+
await electronApp.close();
269+
270+
// Wait for the detached server child process to fully release its ports,
271+
// so subsequent tests that launch Electron won't hit port conflicts.
272+
await new Promise(r => setTimeout(r, 2000));
273+
}
274+
});

0 commit comments

Comments
 (0)