Add config and composition root

This commit is contained in:
morluto committed 2026-07-12 03:25:26 +08:00
1 parent 09bec111d5
commit c047fcbd5c
2 files changed
+149

No files matched your search

+65
View File
@@ -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<Record<string, string | undefined>>,
): Result<AppConfig, ConfigurationError> => {
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,
});
};
+84
View File
@@ -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<number> => {
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<void> | undefined;
const shutdown = (): Promise<void> => {
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;
});
}