@@ -4,7 +4,7 @@ import * as fs from 'fs';
44import * as os from 'os' ;
55import * as path from 'path' ;
66import * as readline from 'readline' ;
7- import { execFile } from 'child_process' ;
7+ import { execFile , spawn } from 'child_process' ;
88
99const canAccess = ( filePath : string ) : Promise < boolean > =>
1010 fs . promises . access ( filePath ) . then ( ( ) => true ) . catch ( ( ) => false ) ;
@@ -159,6 +159,7 @@ function getBridgeFailureInstruction(error: any): string {
159159const POLL_INTERVAL_MS = 5_000 ;
160160const LAUNCH_TIMEOUT_MS = 30_000 ;
161161const 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+
202250async 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