diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000000..769a349da5 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,65 @@ +import { z } from "zod"; + +import { ConfigurationError } from "./domain/errors.js"; +import { err, ok, type Result } from "./domain/result.js"; + +const DEFAULT_HOPPER_LAUNCHER_PATH = + "/Applications/Hopper Disassembler.app/Contents/MacOS/hopper"; + +export interface AppConfig { + readonly hopperLauncherPath: string; + readonly hopperTargetPath: string; + readonly hopperTargetKind: "executable" | "database"; + readonly hopperLoaderArgs: readonly string[]; +} + +const environmentSchema = z.object({ + HOPPER_LAUNCHER_PATH: z.string().min(1).optional(), + HOPPER_TARGET_PATH: z.string().min(1), + HOPPER_TARGET_KIND: z.enum(["executable", "database"]).default("executable"), + HOPPER_LOADER_ARGS_JSON: z.string().optional(), +}); + +/** Parse Hopper launcher configuration once at the composition root. */ +export const parseConfig = ( + environment: Readonly>, +): Result => { + const parsedEnvironment = environmentSchema.safeParse(environment); + if (!parsedEnvironment.success) { + return err( + new ConfigurationError("HOPPER_TARGET_PATH is required", { + cause: parsedEnvironment.error, + }), + ); + } + let loaderArgs: unknown = []; + const encoded = parsedEnvironment.data.HOPPER_LOADER_ARGS_JSON; + if (encoded !== undefined) { + try { + loaderArgs = JSON.parse(encoded); + } catch (cause: unknown) { + return err( + new ConfigurationError("HOPPER_LOADER_ARGS_JSON must be valid JSON", { + cause, + }), + ); + } + } + const parsedArgs = z.array(z.string()).safeParse(loaderArgs); + if (!parsedArgs.success) { + return err( + new ConfigurationError( + "HOPPER_LOADER_ARGS_JSON must encode an array of strings", + { cause: parsedArgs.error }, + ), + ); + } + return ok({ + hopperLauncherPath: + parsedEnvironment.data.HOPPER_LAUNCHER_PATH ?? + DEFAULT_HOPPER_LAUNCHER_PATH, + hopperTargetPath: parsedEnvironment.data.HOPPER_TARGET_PATH, + hopperTargetKind: parsedEnvironment.data.HOPPER_TARGET_KIND, + hopperLoaderArgs: parsedArgs.data, + }); +}; diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000000..9a58a8450d --- /dev/null +++ b/src/main.ts @@ -0,0 +1,84 @@ +#!/usr/bin/env node + +import { pathToFileURL } from "node:url"; +import { fileURLToPath } from "node:url"; + +import { + serveStdio, + type StdioServerHandle, +} from "@modelcontextprotocol/server/stdio"; + +import { parseConfig } from "./config.js"; +import { HopperApplicationLauncher } from "./hopper/BridgeLauncher.js"; +import { HopperClient } from "./hopper/HopperClient.js"; +import { createServer } from "./server/createServer.js"; + +/** Start the production stdio server and install owned shutdown handlers. */ +export const run = async (): Promise => { + const config = parseConfig(process.env); + if (!config.ok) { + process.stderr.write(`${config.error._tag}: ${config.error.message}\n`); + return 1; + } + + const bridgeScriptPath = fileURLToPath( + new URL("../bridge/hopper_bridge.py", import.meta.url), + ); + const hopper = new HopperClient({ + launcher: new HopperApplicationLauncher({ + launcherPath: config.value.hopperLauncherPath, + targetPath: config.value.hopperTargetPath, + targetKind: config.value.hopperTargetKind, + loaderArgs: config.value.hopperLoaderArgs, + bridgeScriptPath, + }), + }); + let handle: StdioServerHandle; + try { + handle = serveStdio(() => createServer(hopper), { + onerror: () => { + process.stderr.write("MCP stdio transport error\n"); + }, + }); + } catch { + await hopper.close(); + process.stderr.write("Failed to start MCP stdio transport\n"); + return 1; + } + + let shutdownPromise: Promise | undefined; + const shutdown = (): Promise => { + shutdownPromise ??= (async () => { + await handle.close(); + await hopper.close(); + })(); + return shutdownPromise; + }; + const requestShutdown = (): void => { + shutdown().catch(() => { + process.exitCode = 1; + process.stderr.write("Failed to shut down cleanly\n"); + }); + }; + + process.once("SIGINT", requestShutdown); + process.once("SIGTERM", requestShutdown); + process.stdin.once("end", requestShutdown); + process.stdin.once("close", requestShutdown); + return 0; +}; + +const entryPath = process.argv[1]; +if ( + entryPath !== undefined && + pathToFileURL(entryPath).href === import.meta.url +) { + run() + .then((exitCode) => { + process.exitCode = exitCode; + }) + .catch(() => { + process.stderr.write("Unexpected server startup failure\n"); + process.exitCode = 1; + }); +}