feat(session): add dynamic binary lifecycle

This commit is contained in:
morluto committed 2026-07-12 06:34:18 +08:00
1 parent c2c3d989eb
commit f32e718cc7
16 files changed
+942 -28

No files matched your search

+156
View File
@@ -0,0 +1,156 @@
import type { BinaryTarget } from "../domain/binaryTarget.js";
import { parseBinaryTarget } from "../domain/binaryTarget.js";
import {
HopperCancelledError,
NoBinaryOpenError,
type HopperError,
} from "../domain/errors.js";
import { err, ok, type Result } from "../domain/result.js";
import type { JsonValue } from "../hopper/protocol.js";
import type { HopperToolPort } from "./HopperToolPort.js";
/** A closeable Hopper port created for exactly one parsed target. */
export interface BinaryClient extends HopperToolPort {
close(): Promise<void>;
}
/** Production seam for creating a target-scoped Hopper client. */
export type BinaryClientFactory = (target: BinaryTarget) => BinaryClient;
/** Serializes target transitions while allowing calls within the active session. */
export class BinarySession implements HopperToolPort {
#active:
| { readonly target: BinaryTarget; readonly client: BinaryClient }
| undefined;
#transition: Promise<void> = Promise.resolve();
readonly #calls = new Set<Promise<unknown>>();
constructor(readonly createClient: BinaryClientFactory) {}
/** Open or switch to a target, retaining the old session when the new one fails. */
open(
path: string,
options: { readonly signal?: AbortSignal } = {},
): Promise<Result<BinaryTarget, HopperError>> {
return this.#serialize(async () => {
if (isAborted(options.signal)) return err(new HopperCancelledError());
const parsed = await parseBinaryTarget(path);
if (!parsed.ok) return parsed;
if (isAborted(options.signal)) return err(new HopperCancelledError());
if (this.#active?.target.path === parsed.value.path)
return ok(parsed.value);
await this.#drainCalls();
const previous = this.#active;
this.#active = undefined;
await previous?.client.close();
const client = this.createClient(parsed.value);
const started = await client.callTool("health", {}, options);
if (!started.ok) {
await client.close();
if (previous !== undefined) await this.#restore(previous.target);
return started;
}
if (isAborted(options.signal)) {
await client.close();
if (previous !== undefined) await this.#restore(previous.target);
return err(new HopperCancelledError());
}
this.#active = { target: parsed.value, client };
return ok(parsed.value);
});
}
/** Close the active target, if any. */
close(): Promise<Result<null, HopperError>> {
return this.#serialize(async () => {
const previous = this.#active;
this.#active = undefined;
await this.#drainCalls();
await previous?.client.close();
return ok(null);
});
}
/** Describe the active binary session. */
status(): JsonValue {
const target = this.#active?.target;
return target === undefined
? { open: false }
: {
open: true,
path: target.path,
format: target.format,
kind: target.kind,
};
}
/** Invoke a Hopper tool against the active target. */
async callTool(
name: string,
arguments_: Readonly<Record<string, JsonValue>>,
options?: { readonly signal?: AbortSignal },
): Promise<Result<JsonValue, HopperError>> {
const transitioned = await this.#waitForTransition(options?.signal);
if (!transitioned.ok) return transitioned;
const active = this.#active;
if (active === undefined) return err(new NoBinaryOpenError());
const call = active.client.callTool(name, arguments_, options);
this.#calls.add(call);
try {
return await call;
} finally {
this.#calls.delete(call);
}
}
#serialize<T>(operation: () => Promise<T>): Promise<T> {
const result = this.#transition.then(operation, operation);
this.#transition = result.then(
() => undefined,
() => undefined,
);
return result;
}
async #drainCalls(): Promise<void> {
await Promise.allSettled([...this.#calls]);
}
async #restore(target: BinaryTarget): Promise<void> {
const client = this.createClient(target);
const started = await client.callTool("health", {});
if (started.ok) this.#active = { target, client };
else await client.close();
}
async #waitForTransition(
signal?: AbortSignal,
): Promise<Result<undefined, HopperCancelledError>> {
if (signal?.aborted === true) return err(new HopperCancelledError());
if (signal === undefined) {
await this.#transition;
return ok(undefined);
}
return new Promise((resolve) => {
const onAbort = (): void => {
resolve(err(new HopperCancelledError()));
};
signal.addEventListener("abort", onAbort, { once: true });
this.#transition.then(
() => {
signal.removeEventListener("abort", onAbort);
resolve(
signal.aborted ? err(new HopperCancelledError()) : ok(undefined),
);
},
() => {
signal.removeEventListener("abort", onAbort);
resolve(
signal.aborted ? err(new HopperCancelledError()) : ok(undefined),
);
},
);
});
}
}
const isAborted = (signal: AbortSignal | undefined): boolean =>
signal?.aborted === true;
+3 -3
View File
@@ -8,14 +8,14 @@ const DEFAULT_HOPPER_LAUNCHER_PATH =
export interface AppConfig {
readonly hopperLauncherPath: string;
readonly hopperTargetPath: string;
readonly hopperTargetPath: string | undefined;
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_PATH: z.string().min(1).optional(),
HOPPER_TARGET_KIND: z.enum(["executable", "database"]).default("executable"),
HOPPER_LOADER_ARGS_JSON: z.string().optional(),
});
@@ -27,7 +27,7 @@ export const parseConfig = (
const parsedEnvironment = environmentSchema.safeParse(environment);
if (!parsedEnvironment.success) {
return err(
new ConfigurationError("HOPPER_TARGET_PATH is required", {
new ConfigurationError("Invalid Hopper environment configuration", {
cause: parsedEnvironment.error,
}),
);
+19 -1
View File
@@ -13,7 +13,7 @@ const caseSensitive = z
.describe("Whether to match case");
/** The architectural owner of a tool implementation. */
export type ToolKind = "official-proxy" | "enhanced";
export type ToolKind = "official-proxy" | "enhanced" | "session";
/** Stable caller-visible contract retained across the Python-to-TypeScript migration. */
export interface ToolContract {
@@ -35,6 +35,12 @@ const enhanced = (
inputSchema: z.ZodObject,
): ToolContract => ({ name, description, kind: "enhanced", inputSchema });
const session = (
name: string,
description: string,
inputSchema: z.ZodObject,
): ToolContract => ({ name, description, kind: "session", inputSchema });
/** All 31 tools forwarded unchanged to Hopper's official MCP server. */
export const OFFICIAL_TOOL_CONTRACTS = [
official(
@@ -218,8 +224,20 @@ export const ENHANCED_TOOL_CONTRACTS = [
),
] as const satisfies readonly ToolContract[];
/** Binary lifecycle tools available before Hopper is launched. */
export const SESSION_TOOL_CONTRACTS = [
session(
"open_binary",
"Open or switch the active binary",
z.object({ path: z.string().min(1) }),
),
session("close_binary", "Close the active binary", z.object({})),
session("binary_session", "Describe the active binary session", z.object({})),
] as const satisfies readonly ToolContract[];
/** Complete caller-visible tool inventory. */
export const TOOL_CONTRACTS = [
...OFFICIAL_TOOL_CONTRACTS,
...ENHANCED_TOOL_CONTRACTS,
...SESSION_TOOL_CONTRACTS,
] as const;
+217
View File
@@ -0,0 +1,217 @@
import { constants } from "node:fs";
import { access, open, realpath } from "node:fs/promises";
import { isAbsolute, resolve } from "node:path";
import { BinaryTargetError } from "./errors.js";
import { err, ok, type Result } from "./result.js";
/** CPU families understood by the supported executable loaders. */
export type BinaryArchitecture = "x86" | "x86_64" | "arm" | "arm64";
/** A parsed binary target and the Hopper launcher arguments it requires. */
export interface BinaryTarget {
readonly path: string;
readonly kind: "executable" | "database";
readonly format: "hopper" | "mach-o" | "elf" | "pe";
readonly architecture?: BinaryArchitecture;
readonly availableArchitectures?: readonly BinaryArchitecture[];
readonly loaderArgs: readonly string[];
}
/** Resolve, read, and classify a local binary path, including its architecture metadata. */
export const parseBinaryTarget = async (
input: string,
cwd = process.cwd(),
hostArchitecture: NodeJS.Architecture = process.arch,
): Promise<Result<BinaryTarget, BinaryTargetError>> => {
const candidate = isAbsolute(input) ? input : resolve(cwd, input);
try {
await access(candidate, constants.R_OK);
const path = await realpath(candidate);
if (path.toLowerCase().endsWith(".hop"))
return ok({ path, kind: "database", format: "hopper", loaderArgs: [] });
const handle = await open(path, "r");
let bytes: Buffer;
try {
bytes = Buffer.alloc(4096);
const read = await handle.read(bytes, 0, bytes.length, 0);
bytes = bytes.subarray(0, read.bytesRead);
} finally {
await handle.close();
}
const detected = parseExecutableHeader(bytes, hostArchitecture);
if (!detected.ok) return err(new BinaryTargetError(path, detected.error));
return ok({ path, kind: "executable", ...detected.value });
} catch (cause: unknown) {
return err(
new BinaryTargetError(candidate, "path is not readable", { cause }),
);
}
};
type ExecutableMetadata = Pick<
BinaryTarget,
"format" | "architecture" | "availableArchitectures" | "loaderArgs"
>;
/** Parse supported executable headers without performing I/O. */
export const parseExecutableHeader = (
bytes: Buffer,
hostArchitecture: NodeJS.Architecture,
): Result<ExecutableMetadata, string> => {
if (
bytes.length >= 4 &&
bytes.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))
)
return parseElf(bytes);
if (bytes.length >= 2 && bytes[0] === 0x4d && bytes[1] === 0x5a)
return parsePe(bytes);
if (bytes.length < 8) return err("truncated or unsupported binary header");
const magic = bytes.readUInt32BE(0);
if ([0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(magic))
return parseThinMachO(bytes, magic);
if ([0xcafebabe, 0xbebafeca, 0xcafebabf, 0xbfbafeca].includes(magic))
return parseFatMachO(bytes, magic, hostArchitecture);
return err("unsupported binary format");
};
const parseThinMachO = (
bytes: Buffer,
magic: number,
): Result<ExecutableMetadata, string> => {
if (bytes.length < 8) return err("truncated Mach-O header");
const little = magic === 0xcefaedfe || magic === 0xcffaedfe;
const architecture = machArchitecture(
little ? bytes.readUInt32LE(4) : bytes.readUInt32BE(4),
);
if (architecture === undefined) return err("unsupported Mach-O architecture");
return ok({
format: "mach-o",
architecture,
availableArchitectures: [architecture],
loaderArgs: ["-l", "Mach-O"],
});
};
const parseFatMachO = (
bytes: Buffer,
magic: number,
host: NodeJS.Architecture,
): Result<ExecutableMetadata, string> => {
const little = magic === 0xbebafeca || magic === 0xbfbafeca;
const is64 = magic === 0xcafebabf || magic === 0xbfbafeca;
if (bytes.length < 8) return err("truncated FAT header");
const read = (offset: number): number =>
little ? bytes.readUInt32LE(offset) : bytes.readUInt32BE(offset);
const count = read(4);
const entrySize = is64 ? 32 : 20;
if (count === 0 || count > 128 || bytes.length < 8 + count * entrySize)
return err("truncated or invalid FAT architecture table");
const architectures: BinaryArchitecture[] = [];
for (let index = 0; index < count; index += 1) {
const architecture = machArchitecture(read(8 + index * entrySize));
if (architecture !== undefined && !architectures.includes(architecture))
architectures.push(architecture);
}
const preferred =
host === "arm64"
? "arm64"
: host === "x64"
? "x86_64"
: host === "ia32"
? "x86"
: host === "arm"
? "arm"
: undefined;
if (preferred === undefined || !architectures.includes(preferred))
return err(`FAT binary has no host-compatible ${host} architecture`);
const flag =
preferred === "arm64"
? "--aarch64"
: preferred === "x86_64"
? "--x86_64"
: preferred === "arm"
? "--arm"
: "--x86";
return ok({
format: "mach-o",
architecture: preferred,
availableArchitectures: architectures,
loaderArgs: ["-l", "FAT", flag, "-l", "Mach-O"],
});
};
const parseElf = (bytes: Buffer): Result<ExecutableMetadata, string> => {
if (bytes.length < 20) return err("truncated ELF header");
if (bytes[4] !== 1 && bytes[4] !== 2) return err("unsupported ELF class");
const little = bytes[5] === 1;
if (!little && bytes[5] !== 2) return err("unsupported ELF endianness");
const machine = little ? bytes.readUInt16LE(18) : bytes.readUInt16BE(18);
const architecture = elfArchitecture(machine);
if (architecture === undefined) return err("unsupported ELF architecture");
return ok({
format: "elf",
architecture,
availableArchitectures: [architecture],
loaderArgs: ["-l", "ELF"],
});
};
const parsePe = (bytes: Buffer): Result<ExecutableMetadata, string> => {
if (bytes.length < 64) return err("truncated PE DOS header");
const offset = bytes.readUInt32LE(0x3c);
if (
offset > bytes.length - 6 ||
bytes.toString("binary", offset, offset + 4) !== "PE\u0000\u0000"
)
return err("invalid or truncated PE header");
const architecture = peArchitecture(bytes.readUInt16LE(offset + 4));
if (architecture === undefined) return err("unsupported PE architecture");
return ok({
format: "pe",
architecture,
availableArchitectures: [architecture],
loaderArgs: ["-l", "PE"],
});
};
const machArchitecture = (cpu: number): BinaryArchitecture | undefined => {
switch (cpu) {
case 7:
return "x86";
case 0x01000007:
return "x86_64";
case 12:
return "arm";
case 0x0100000c:
return "arm64";
}
return undefined;
};
const elfArchitecture = (machine: number): BinaryArchitecture | undefined => {
switch (machine) {
case 3:
return "x86";
case 62:
return "x86_64";
case 40:
return "arm";
case 183:
return "arm64";
}
return undefined;
};
const peArchitecture = (machine: number): BinaryArchitecture | undefined => {
switch (machine) {
case 0x14c:
return "x86";
case 0x8664:
return "x86_64";
case 0x1c0:
return "arm";
case 0xaa64:
return "arm64";
}
return undefined;
};
+22
View File
@@ -71,3 +71,25 @@ export class HopperStartError extends HopperError {
export class ConfigurationError extends HopperError {
readonly _tag = "ConfigurationError";
}
/** No binary session exists for an analysis request. */
export class NoBinaryOpenError extends HopperError {
readonly _tag = "NoBinaryOpenError";
constructor() {
super(
"No binary is open. Call open_binary with a readable local path first.",
);
}
}
/** A supplied target path could not be safely opened as a supported binary. */
export class BinaryTargetError extends HopperError {
readonly _tag = "BinaryTargetError";
constructor(
readonly path: string,
reason: string,
options?: ErrorOptions,
) {
super(`Cannot open binary: ${reason}: ${path}`, options);
}
}
+10
View File
@@ -0,0 +1,10 @@
/** Provisional product identifiers kept together for release-time renaming. */
export const PRODUCT_IDENTITY = {
displayName: "Better Binary",
packageName: "better-binary-mcp",
cliBinary: "better-binary",
mcpBinary: "better-binary-mcp",
mcpServerKey: "better-binary",
skillName: "better-binary-analysis",
configFileName: "better-binary.json",
} as const;
+28 -13
View File
@@ -2,6 +2,7 @@
import { pathToFileURL } from "node:url";
import { fileURLToPath } from "node:url";
import { realpathSync } from "node:fs";
import {
serveStdio,
@@ -9,6 +10,7 @@ import {
} from "@modelcontextprotocol/server/stdio";
import { parseConfig } from "./config.js";
import { BinarySession } from "./application/BinarySession.js";
import { HopperApplicationLauncher } from "./hopper/BridgeLauncher.js";
import { HopperClient } from "./hopper/HopperClient.js";
import { createServer } from "./server/createServer.js";
@@ -24,24 +26,37 @@ export const run = async (): Promise<number> => {
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,
}),
});
const session = new BinarySession(
(target) =>
new HopperClient({
launcher: new HopperApplicationLauncher({
launcherPath: config.value.hopperLauncherPath,
targetPath: target.path,
targetKind: target.kind,
loaderArgs:
config.value.hopperLoaderArgs.length > 0
? config.value.hopperLoaderArgs
: target.loaderArgs,
bridgeScriptPath,
}),
}),
);
if (config.value.hopperTargetPath !== undefined) {
const opened = await session.open(config.value.hopperTargetPath);
if (!opened.ok) {
process.stderr.write(`${opened.error._tag}: ${opened.error.message}\n`);
return 1;
}
}
let handle: StdioServerHandle;
try {
handle = serveStdio(() => createServer(hopper), {
handle = serveStdio(() => createServer(session, session), {
onerror: () => {
process.stderr.write("MCP stdio transport error\n");
},
});
} catch {
await hopper.close();
await session.close();
process.stderr.write("Failed to start MCP stdio transport\n");
return 1;
}
@@ -50,7 +65,7 @@ export const run = async (): Promise<number> => {
const shutdown = (): Promise<void> => {
shutdownPromise ??= (async () => {
await handle.close();
await hopper.close();
await session.close();
})();
return shutdownPromise;
};
@@ -71,7 +86,7 @@ export const run = async (): Promise<number> => {
const entryPath = process.argv[1];
if (
entryPath !== undefined &&
pathToFileURL(entryPath).href === import.meta.url
pathToFileURL(realpathSync(entryPath)).href === import.meta.url
) {
run()
.then((exitCode) => {
+10 -3
View File
@@ -1,20 +1,27 @@
import { McpServer } from "@modelcontextprotocol/server";
import type { HopperToolPort } from "../application/HopperToolPort.js";
import type { BinarySession } from "../application/BinarySession.js";
import { PRODUCT_IDENTITY } from "../identity.js";
import { registerEnhancedTools } from "./registerEnhancedTools.js";
import { registerOfficialTools } from "./registerOfficialTools.js";
import { registerSessionTools } from "./registerSessionTools.js";
/** Construct one MCP server instance without acquiring subprocess resources. */
export const createServer = (hopper: HopperToolPort): McpServer => {
export const createServer = (
hopper: HopperToolPort,
session?: BinarySession,
): McpServer => {
const server = new McpServer(
{ name: "betterBinaryMCP", version: "0.1.0" },
{ name: PRODUCT_IDENTITY.mcpServerKey, version: "0.1.0" },
{
capabilities: { tools: {} },
instructions:
"Reverse-engineering tools for Hopper Disassembler. Start with binary_overview.",
"Reverse-engineering tools for Hopper Disassembler. Open a target with open_binary, then start with binary_overview.",
},
);
registerOfficialTools(server, hopper);
registerEnhancedTools(server, hopper);
if (session !== undefined) registerSessionTools(server, session);
return server;
};
+54
View File
@@ -0,0 +1,54 @@
import type { McpServer } from "@modelcontextprotocol/server";
import { z } from "zod";
import type { BinarySession } from "../application/BinarySession.js";
import { SESSION_TOOL_CONTRACTS } from "../contracts/toolContracts.js";
import { toCallToolResult } from "./toolResult.js";
/** Register binary lifecycle management tools. */
export const registerSessionTools = (
server: McpServer,
session: BinarySession,
): void => {
const [openContract, closeContract, statusContract] = SESSION_TOOL_CONTRACTS;
server.registerTool(
openContract.name,
{
description: openContract.description,
inputSchema: openContract.inputSchema,
},
async (input, context) => {
const parsed = z.object({ path: z.string().min(1) }).parse(input);
const opened = await session.open(parsed.path, {
signal: context.mcpReq.signal,
});
return opened.ok
? toCallToolResult({
ok: true,
value: {
path: opened.value.path,
format: opened.value.format,
kind: opened.value.kind,
loaderArgs: [...opened.value.loaderArgs],
},
})
: toCallToolResult(opened);
},
);
server.registerTool(
closeContract.name,
{
description: closeContract.description,
inputSchema: closeContract.inputSchema,
},
async () => toCallToolResult(await session.close()),
);
server.registerTool(
statusContract.name,
{
description: statusContract.description,
inputSchema: statusContract.inputSchema,
},
() => toCallToolResult({ ok: true, value: session.status() }),
);
};
+190
View File
@@ -0,0 +1,190 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
BinarySession,
type BinaryClient,
} from "../src/application/BinarySession.js";
import { HopperStartError } from "../src/domain/errors.js";
import { err, ok } from "../src/domain/result.js";
let directory: string | undefined;
afterEach(async () => {
if (directory !== undefined)
await rm(directory, { recursive: true, force: true });
directory = undefined;
});
describe("binary session", () => {
it("requires an open binary and closes idempotently", async () => {
const session = new BinarySession(() => client());
expect((await session.callTool("binary_overview", {})).ok).toBe(false);
expect(await session.close()).toEqual({ ok: true, value: null });
});
it("rolls back a failed switch", async () => {
directory = await mkdtemp(join(tmpdir(), "bb-session-"));
const first = join(directory, "first.hop");
const second = join(directory, "second.hop");
await writeFile(first, "one");
await writeFile(second, "two");
let created = 0;
const session = new BinarySession(() => client(created++ === 1));
expect((await session.open(first)).ok).toBe(true);
expect((await session.open(second)).ok).toBe(false);
expect(session.status()).toMatchObject({ open: true });
expect(JSON.stringify(session.status())).toContain("first.hop");
expect(created).toBe(3);
});
it("serializes concurrent opens and leaves the last target active", async () => {
const [first, second] = await targets();
const clients: TestClient[] = [];
const session = new BinarySession(() => {
const value = new TestClient();
clients.push(value);
return value;
});
const one = session.open(first);
const two = session.open(second);
expect((await one).ok).toBe(true);
expect((await two).ok).toBe(true);
expect(session.status()).toMatchObject({ open: true });
expect(JSON.stringify(session.status())).toContain("second.hop");
expect(clients[0]?.closed).toBe(1);
});
it("waits for an active call before closing its client during a switch", async () => {
const [first, second] = await targets();
const active = deferred<ReturnType<typeof ok<null>>>();
const clients: TestClient[] = [];
const session = new BinarySession(() => {
const value = new TestClient(
undefined,
false,
clients.length === 0 ? active.promise : undefined,
);
clients.push(value);
return value;
});
await session.open(first);
const call = session.callTool("long", {});
const switching = session.open(second);
await Promise.resolve();
expect(clients[0]?.closed).toBe(0);
active.resolve(ok(null));
await call;
await switching;
expect(clients[0]?.closed).toBe(1);
});
it("cancels an open queued behind another transition without creating a client", async () => {
const [first, second] = await targets();
const health = deferred<ReturnType<typeof ok<null>>>();
let created = 0;
const session = new BinarySession(() => {
created += 1;
return new TestClient(created === 1 ? health.promise : undefined);
});
const opening = session.open(first);
const controller = new AbortController();
const queued = session.open(second, { signal: controller.signal });
controller.abort();
health.resolve(ok(null));
await opening;
const result = await queued;
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error._tag).toBe("HopperCancelledError");
expect(created).toBe(1);
});
it("cancels a call while it waits for a transition", async () => {
const [first] = await targets();
const health = deferred<ReturnType<typeof ok<null>>>();
const session = new BinarySession(() => new TestClient(health.promise));
const opening = session.open(first);
const controller = new AbortController();
const call = session.callTool(
"overview",
{},
{ signal: controller.signal },
);
controller.abort();
const result = await call;
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error._tag).toBe("HopperCancelledError");
health.resolve(ok(null));
await opening;
});
it("closes a failed candidate and preserves the previous target", async () => {
const [first, second] = await targets();
const clients: TestClient[] = [];
const session = new BinarySession(() => {
const value = new TestClient(undefined, clients.length === 1);
clients.push(value);
return value;
});
await session.open(first);
await session.open(second);
expect(clients[1]?.closed).toBe(1);
expect(clients[0]?.closed).toBe(1);
expect(JSON.stringify(session.status())).toContain("first.hop");
await session.close();
await session.close();
expect(clients[2]?.closed).toBe(1);
});
});
const client = (fail = false): BinaryClient => ({
callTool: () =>
Promise.resolve(fail ? err(new HopperStartError()) : ok(null)),
close: () => Promise.resolve(),
});
class TestClient implements BinaryClient {
closed = 0;
constructor(
readonly pendingHealth?: Promise<ReturnType<typeof ok<null>>>,
readonly failHealth = false,
readonly pendingCall?: Promise<ReturnType<typeof ok<null>>>,
) {}
callTool(name: string) {
if (name === "health")
return this.failHealth
? Promise.resolve(err(new HopperStartError()))
: (this.pendingHealth ?? Promise.resolve(ok(null)));
return this.pendingCall ?? Promise.resolve(ok(null));
}
close(): Promise<void> {
this.closed += 1;
return Promise.resolve();
}
}
const targets = async (): Promise<readonly [string, string]> => {
directory ??= await mkdtemp(join(tmpdir(), "bb-session-"));
const first = join(directory, "first.hop");
const second = join(directory, "second.hop");
await writeFile(first, "one");
await writeFile(second, "two");
return [first, second];
};
const deferred = <T>(): {
readonly promise: Promise<T>;
resolve(value: T): void;
} => {
let resolvePromise: ((value: T) => void) | undefined;
const promise = new Promise<T>((resolve) => {
resolvePromise = resolve;
});
return {
promise,
resolve(value) {
resolvePromise?.(value);
},
};
};
+94
View File
@@ -0,0 +1,94 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
parseBinaryTarget,
parseExecutableHeader,
} from "../src/domain/binaryTarget.js";
let directory: string | undefined;
afterEach(async () => {
if (directory !== undefined)
await rm(directory, { recursive: true, force: true });
directory = undefined;
});
describe("executable header parsing", () => {
it.each([
[elf(1, 1, 62), "elf", "x86_64"],
[elf(2, 2, 183), "elf", "arm64"],
[thinMach(0xfeedfacf, 0x0100000c), "mach-o", "arm64"],
[thinMach(0xcefaedfe, 0x01000007), "mach-o", "x86_64"],
[pe(0x8664), "pe", "x86_64"],
[pe(0xaa64), "pe", "arm64"],
] as const)("parses metadata for %s", (bytes, format, architecture) => {
const result = parseExecutableHeader(bytes, "arm64");
expect(result.ok && result.value).toMatchObject({ format, architecture });
});
it("selects the host architecture from a FAT table", () => {
const bytes = fat([0x01000007, 0x0100000c]);
const arm = parseExecutableHeader(bytes, "arm64");
const intel = parseExecutableHeader(bytes, "x64");
expect(arm.ok && arm.value.loaderArgs).toContain("--aarch64");
expect(intel.ok && intel.value.loaderArgs).toContain("--x86_64");
});
it("rejects FAT files without a host-compatible slice", () => {
expect(parseExecutableHeader(fat([0x01000007]), "arm64")).toMatchObject({
ok: false,
});
});
it.each([
Buffer.alloc(0),
Buffer.from([0x7f, 0x45, 0x4c, 0x46]),
fat([], 2),
pe(0xffff),
])("rejects malformed, truncated, or unsupported metadata", (bytes) => {
expect(parseExecutableHeader(bytes, "arm64").ok).toBe(false);
});
});
describe("binary target I/O", () => {
it("resolves relative Hopper databases and rejects unknown or unreadable paths", async () => {
directory = await mkdtemp(join(tmpdir(), "bb-target-"));
await writeFile(join(directory, "sample.hop"), "database");
await writeFile(join(directory, "text"), "hello");
expect((await parseBinaryTarget("sample.hop", directory)).ok).toBe(true);
expect((await parseBinaryTarget("text", directory)).ok).toBe(false);
expect((await parseBinaryTarget("missing", directory)).ok).toBe(false);
});
});
const elf = (class_: number, endian: number, machine: number): Buffer => {
const bytes = Buffer.alloc(20);
bytes.set([0x7f, 0x45, 0x4c, 0x46, class_, endian]);
if (endian === 1) bytes.writeUInt16LE(machine, 18);
else bytes.writeUInt16BE(machine, 18);
return bytes;
};
const thinMach = (magic: number, cpu: number): Buffer => {
const bytes = Buffer.alloc(8);
bytes.writeUInt32BE(magic, 0);
if (magic === 0xcefaedfe || magic === 0xcffaedfe) bytes.writeUInt32LE(cpu, 4);
else bytes.writeUInt32BE(cpu, 4);
return bytes;
};
const fat = (cpus: readonly number[], declared = cpus.length): Buffer => {
const bytes = Buffer.alloc(8 + cpus.length * 20);
bytes.writeUInt32BE(0xcafebabe, 0);
bytes.writeUInt32BE(declared, 4);
cpus.forEach((cpu, index) => bytes.writeUInt32BE(cpu, 8 + index * 20));
return bytes;
};
const pe = (machine: number): Buffer => {
const bytes = Buffer.alloc(128);
bytes.write("MZ", 0, "ascii");
bytes.writeUInt32LE(64, 0x3c);
bytes.write("PE\0\0", 64, "binary");
bytes.writeUInt16LE(machine, 68);
return bytes;
};
+4 -2
View File
@@ -3,8 +3,10 @@ import { describe, expect, it } from "vitest";
import { parseConfig } from "../src/config.js";
describe("runtime configuration", () => {
it("requires a target and defaults to Hopper's documented launcher", () => {
expect(parseConfig({}).ok).toBe(false);
it("allows target-free startup and defaults to Hopper's documented launcher", () => {
const empty = parseConfig({});
expect(empty.ok).toBe(true);
if (empty.ok) expect(empty.value.hopperTargetPath).toBeUndefined();
const result = parseConfig({ HOPPER_TARGET_PATH: "/usr/bin/true" });
expect(result.ok).toBe(true);
if (result.ok) {
+43
View File
@@ -0,0 +1,43 @@
import { createHash } from "node:crypto";
import { describe, expect, it } from "vitest";
import { z } from "zod";
import {
ENHANCED_TOOL_CONTRACTS,
OFFICIAL_TOOL_CONTRACTS,
SESSION_TOOL_CONTRACTS,
} from "../src/contracts/toolContracts.js";
const ORIGINAL_39_SHA256 =
"43ffddd91b775a3e00fb736c6b1a644210e5917a1e1f02745d0283ac6ab99db4";
describe("original tool contract snapshot", () => {
it("preserves every original name, description, kind, and complete JSON schema", () => {
const projection = [
...OFFICIAL_TOOL_CONTRACTS,
...ENHANCED_TOOL_CONTRACTS,
].map((contract) => ({
name: contract.name,
description: contract.description,
kind: contract.kind,
inputSchema: z.toJSONSchema(contract.inputSchema, {
target: "draft-07",
unrepresentable: "any",
}),
}));
expect(projection).toHaveLength(39);
expect(
createHash("sha256").update(JSON.stringify(projection)).digest("hex"),
).toBe(ORIGINAL_39_SHA256);
});
it("keeps exactly three additive session contracts", () => {
expect(
SESSION_TOOL_CONTRACTS.map(({ name, kind }) => ({ name, kind })),
).toEqual([
{ name: "open_binary", kind: "session" },
{ name: "close_binary", kind: "session" },
{ name: "binary_session", kind: "session" },
]);
});
});
+3 -3
View File
@@ -10,7 +10,7 @@ const fixturePath = fileURLToPath(
);
describe("production stdio runtime", () => {
it("starts the built entrypoint, lists 39 tools, calls one, and shuts down", async () => {
it("starts the built entrypoint, lists 42 tools, calls one, and shuts down", async () => {
const transport = new StdioClientTransport({
command: process.execPath,
args: [mainPath],
@@ -18,7 +18,7 @@ describe("production stdio runtime", () => {
env: {
PATH: process.env.PATH ?? "",
HOPPER_LAUNCHER_PATH: process.execPath,
HOPPER_TARGET_PATH: "/fixture/sample",
HOPPER_TARGET_PATH: process.execPath,
HOPPER_LOADER_ARGS_JSON: JSON.stringify([fixturePath]),
},
stderr: "pipe",
@@ -32,7 +32,7 @@ describe("production stdio runtime", () => {
try {
await client.connect(transport);
const tools = await client.listTools();
expect(tools.tools).toHaveLength(39);
expect(tools.tools).toHaveLength(42);
const result = await client.callTool({
name: "current_document",
arguments: {},
+80
View File
@@ -0,0 +1,80 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import type { CallToolResult } from "@modelcontextprotocol/server";
import { afterEach, describe, expect, it } from "vitest";
import {
BinarySession,
type BinaryClient,
} from "../src/application/BinarySession.js";
import { ok } from "../src/domain/result.js";
import { createServer } from "../src/server/createServer.js";
const resources: Array<{ close(): Promise<unknown> }> = [];
let directory: string | undefined;
afterEach(async () => {
await Promise.all(resources.splice(0).map((resource) => resource.close()));
if (directory !== undefined)
await rm(directory, { recursive: true, force: true });
directory = undefined;
});
describe("target-free MCP lifecycle", () => {
it("reports no-target, opens, analyzes, switches, reports status, and closes", async () => {
directory = await mkdtemp(join(tmpdir(), "bb-mcp-session-"));
const first = join(directory, "first.hop");
const second = join(directory, "second.hop");
await writeFile(first, "one");
await writeFile(second, "two");
const closed: string[] = [];
const session = new BinarySession((target) => client(target.path, closed));
const server = createServer(session, session);
const mcp = new Client({ name: "session-test", version: "1.0.0" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
resources.push(mcp, server);
await server.connect(serverTransport);
await mcp.connect(clientTransport);
const before = await mcp.callTool({
name: "current_document",
arguments: {},
});
expect(before.isError).toBe(true);
expect(text(before)).toContain("NoBinaryOpenError");
expect((await mcp.listTools()).tools).toHaveLength(42);
expect(
(await mcp.callTool({ name: "open_binary", arguments: { path: first } }))
.isError,
).not.toBe(true);
expect(
text(await mcp.callTool({ name: "current_document", arguments: {} })),
).toContain("first.hop");
await mcp.callTool({ name: "open_binary", arguments: { path: second } });
expect(closed.some((path) => path.endsWith("first.hop"))).toBe(true);
expect(
text(await mcp.callTool({ name: "binary_session", arguments: {} })),
).toContain("second.hop");
await mcp.callTool({ name: "close_binary", arguments: {} });
expect(
text(await mcp.callTool({ name: "binary_session", arguments: {} })),
).toContain('"open": false');
});
});
const client = (path: string, closed: string[]): BinaryClient => ({
callTool: (name) =>
Promise.resolve(ok(name === "health" ? null : { path, name })),
close: () => {
closed.push(path);
return Promise.resolve();
},
});
const text = (result: CallToolResult): string => {
const content = result.content.find((item) => item.type === "text");
if (content?.type !== "text") throw new Error("missing text result");
return content.text;
};
+9 -3
View File
@@ -3,15 +3,21 @@ import { describe, expect, it } from "vitest";
import {
ENHANCED_TOOL_CONTRACTS,
OFFICIAL_TOOL_CONTRACTS,
SESSION_TOOL_CONTRACTS,
TOOL_CONTRACTS,
} from "../src/contracts/toolContracts.js";
describe("tool contract inventory", () => {
it("preserves the complete 31 plus 8 tool surface", () => {
it("preserves the original 39 contracts and adds three session tools", () => {
expect(OFFICIAL_TOOL_CONTRACTS).toHaveLength(31);
expect(ENHANCED_TOOL_CONTRACTS).toHaveLength(8);
expect(TOOL_CONTRACTS).toHaveLength(39);
expect(new Set(TOOL_CONTRACTS.map(({ name }) => name)).size).toBe(39);
expect(SESSION_TOOL_CONTRACTS.map(({ name }) => name)).toEqual([
"open_binary",
"close_binary",
"binary_session",
]);
expect(TOOL_CONTRACTS).toHaveLength(42);
expect(new Set(TOOL_CONTRACTS.map(({ name }) => name)).size).toBe(42);
});
it("retains documented enhanced-tool limits at the input boundary", () => {