Skip to content

Commit f1f929b

Browse files
committed
Make sure MCP server launched HTTP Toolkit in a fully detached state
1 parent d860d8c commit f1f929b

1 file changed

Lines changed: 58 additions & 2 deletions

File tree

src/commands/mcp.ts

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import * as fs from 'fs';
44
import * as os from 'os';
55
import * as path from 'path';
66
import * as readline from 'readline';
7-
import { execFile } from 'child_process';
7+
import { execFile, spawn } from 'child_process';
88

99
const canAccess = (filePath: string): Promise<boolean> =>
1010
fs.promises.access(filePath).then(() => true).catch(() => false);
@@ -159,6 +159,7 @@ function getBridgeFailureInstruction(error: any): string {
159159
const POLL_INTERVAL_MS = 5_000;
160160
const LAUNCH_TIMEOUT_MS = 30_000;
161161
const LAUNCH_POLL_MS = 500;
162+
const OPEN_TIMEOUT_MS = 10_000;
162163

163164
// Default install paths per platform. First match wins. The wrapper scripts
164165
// (httptoolkit-mcp / .cmd) set HTK_DESKTOP_EXE explicitly, so the env var
@@ -199,6 +200,53 @@ async function getLaunchableHtkExePath(): Promise<string | null> {
199200
return null;
200201
}
201202

203+
async function getOutermostAppBundlePath(exePath: string): Promise<string | undefined> {
204+
const realExePath = await fs.promises.realpath(exePath).catch(() => exePath);
205+
206+
const pathParts = realExePath.split(path.sep);
207+
const outermostBundleIndex = pathParts.findIndex((part) => part.endsWith('.app'));
208+
if (outermostBundleIndex === -1) return undefined;
209+
210+
return pathParts.slice(0, outermostBundleIndex + 1).join(path.sep);
211+
}
212+
213+
// We launch on Mac via `open`. Launching the exe directly would mean the MCP server
214+
// becomes the responsible process, which has permissions & OS UI effects.
215+
function openAppBundleViaLaunchServices(bundlePath: string): Promise<void> {
216+
return new Promise((resolve, reject) => {
217+
execFile('/usr/bin/open', ['-a', bundlePath], { timeout: OPEN_TIMEOUT_MS }, (error, _stdout, stderr) => {
218+
if (!error) resolve();
219+
else reject(new Error(`${bundlePath} could not be opened: ${stderr.trim() || error.message}`));
220+
});
221+
});
222+
}
223+
224+
function spawnAppDetached(exePath: string): Promise<void> {
225+
return new Promise((resolve, reject) => {
226+
const app = spawn(exePath, [], {
227+
detached: true,
228+
stdio: 'ignore'
229+
});
230+
231+
app.on('error', (error) => reject(
232+
new Error(`${exePath} could not be started: ${error.message}`)
233+
));
234+
app.on('spawn', () => {
235+
app.unref();
236+
resolve();
237+
});
238+
});
239+
}
240+
241+
async function launchDesktopApp(exePath: string): Promise<void> {
242+
if (process.platform === 'darwin') {
243+
const bundlePath = await getOutermostAppBundlePath(exePath);
244+
if (bundlePath) return openAppBundleViaLaunchServices(bundlePath);
245+
}
246+
247+
return spawnAppDetached(exePath);
248+
}
249+
202250
async function startHttpToolkit(
203251
log: (msg: string) => void,
204252
refreshOperations: () => Promise<void>
@@ -221,7 +269,15 @@ async function startHttpToolkit(
221269
}
222270

223271
log('Launching HTTP Toolkit desktop app...');
224-
execFile(exePath, [], () => {});
272+
try {
273+
await launchDesktopApp(exePath);
274+
} catch (err) {
275+
const message = err instanceof Error ? err.message : String(err);
276+
return {
277+
content: [{ type: 'text', text: `Could not launch HTTP Toolkit: ${message}` }],
278+
isError: true
279+
};
280+
}
225281

226282
// Wait for the UI to connect and send operations
227283
const deadline = Date.now() + LAUNCH_TIMEOUT_MS;

0 commit comments

Comments
 (0)