Add integration tests for MCP server lifecycle and config parsing
This commit is contained in:
parent
bb8862c605
commit
d341f26459
|
|
@ -0,0 +1,77 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseConfig } from "../../src/config.js";
|
||||
|
||||
describe("configuration parsing integration", () => {
|
||||
it("returns defaults when no environment variables are set", () => {
|
||||
const result = parseConfig({});
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) throw new Error("unreachable");
|
||||
expect(result.value.hopperLauncherPath).toBe(
|
||||
"/Applications/Hopper Disassembler.app/Contents/MacOS/hopper",
|
||||
);
|
||||
expect(result.value.hopperTargetPath).toBeUndefined();
|
||||
expect(result.value.hopperTargetKind).toBe("executable");
|
||||
expect(result.value.hopperLoaderArgs).toEqual([]);
|
||||
});
|
||||
|
||||
it("parses a complete target-bound configuration", () => {
|
||||
const result = parseConfig({
|
||||
HOPPER_TARGET_PATH: "/usr/bin/true",
|
||||
HOPPER_TARGET_KIND: "executable",
|
||||
HOPPER_LOADER_ARGS_JSON: '["-l","Mach-O","--aarch64"]',
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) throw new Error("unreachable");
|
||||
expect(result.value.hopperTargetPath).toBe("/usr/bin/true");
|
||||
expect(result.value.hopperTargetKind).toBe("executable");
|
||||
expect(result.value.hopperLoaderArgs).toEqual(["-l", "Mach-O", "--aarch64"]);
|
||||
});
|
||||
|
||||
it("rejects target kind values outside the enum", () => {
|
||||
const result = parseConfig({
|
||||
HOPPER_TARGET_KIND: "archive",
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("unreachable");
|
||||
expect(result.error.message).toContain("Invalid Hopper environment");
|
||||
});
|
||||
|
||||
it("rejects invalid JSON in loader arguments", () => {
|
||||
const result = parseConfig({
|
||||
HOPPER_LOADER_ARGS_JSON: "not json",
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("unreachable");
|
||||
expect(result.error.message).toContain("valid JSON");
|
||||
});
|
||||
|
||||
it("rejects non-array types in loader arguments JSON", () => {
|
||||
const result = parseConfig({
|
||||
HOPPER_LOADER_ARGS_JSON: '"just a string"',
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("unreachable");
|
||||
expect(result.error.message).toContain("array of strings");
|
||||
});
|
||||
|
||||
it("allows overriding the launcher path while keeping other defaults", () => {
|
||||
const result = parseConfig({
|
||||
HOPPER_LAUNCHER_PATH: "/custom/hopper",
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) throw new Error("unreachable");
|
||||
expect(result.value.hopperLauncherPath).toBe("/custom/hopper");
|
||||
expect(result.value.hopperTargetPath).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parses database target kind correctly", () => {
|
||||
const result = parseConfig({
|
||||
HOPPER_TARGET_PATH: "/path/to/file.hop",
|
||||
HOPPER_TARGET_KIND: "database",
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) throw new Error("unreachable");
|
||||
expect(result.value.hopperTargetKind).toBe("database");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
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 type { HopperToolPort } from "../../src/application/HopperToolPort.js";
|
||||
import { HopperRemoteError } from "../../src/domain/errors.js";
|
||||
import { err, ok } from "../../src/domain/result.js";
|
||||
import { createServer } from "../../src/server/createServer.js";
|
||||
|
||||
const resources: Array<{ close(): Promise<void> }> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
resources.splice(0).map(async (resource) => resource.close()),
|
||||
);
|
||||
});
|
||||
|
||||
const connect = async (hopper: HopperToolPort) => {
|
||||
const server = createServer(hopper);
|
||||
const client = new Client({
|
||||
name: "integration-test",
|
||||
version: "1.0.0",
|
||||
});
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
resources.push(client, server);
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
return client;
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
describe("full MCP integration with multi-tool sequences", () => {
|
||||
it("executes a realistic workflow: list methods, decompile selected, get xrefs", async () => {
|
||||
const client = await connect({
|
||||
callTool: (name, args) => {
|
||||
switch (name) {
|
||||
case "list_procedures":
|
||||
return Promise.resolve(
|
||||
ok({ "0x1000": "main", "0x2000": "helper" }),
|
||||
);
|
||||
case "procedure_pseudo_code":
|
||||
return Promise.resolve(
|
||||
ok(`pseudo for ${(args as { procedure: string }).procedure}`),
|
||||
);
|
||||
case "xrefs":
|
||||
return Promise.resolve(ok(["0x1000"]));
|
||||
default:
|
||||
return Promise.resolve(ok(null));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const listResult = await client.callTool({
|
||||
name: "list_procedures",
|
||||
arguments: {},
|
||||
});
|
||||
expect(listResult.isError).not.toBe(true);
|
||||
const procedures = JSON.parse(text(listResult));
|
||||
expect(procedures).toEqual({ "0x1000": "main", "0x2000": "helper" });
|
||||
|
||||
const decompileResult = await client.callTool({
|
||||
name: "procedure_pseudo_code",
|
||||
arguments: { procedure: "0x1000" },
|
||||
});
|
||||
expect(text(decompileResult)).toBe("pseudo for 0x1000");
|
||||
|
||||
const xrefResult = await client.callTool({
|
||||
name: "xrefs",
|
||||
arguments: {},
|
||||
});
|
||||
expect(JSON.parse(text(xrefResult))).toEqual(["0x1000"]);
|
||||
});
|
||||
|
||||
it("preserves the complete 42-tool inventory with a session", async () => {
|
||||
const session = new BinarySession((_path) => ({
|
||||
callTool: () => Promise.resolve(ok(null)),
|
||||
close: () => Promise.resolve(),
|
||||
} satisfies BinaryClient));
|
||||
const server = createServer(
|
||||
{ callTool: () => Promise.resolve(ok(null)) },
|
||||
session,
|
||||
);
|
||||
const client = new Client({
|
||||
name: "integration-test",
|
||||
version: "1.0.0",
|
||||
});
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
resources.push(client, server);
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
|
||||
const listed = await client.listTools();
|
||||
expect(listed.tools).toHaveLength(42);
|
||||
const names = listed.tools.map((t) => t.name);
|
||||
expect(names).toContain("open_binary");
|
||||
expect(names).toContain("close_binary");
|
||||
expect(names).toContain("binary_session");
|
||||
expect(names).toContain("binary_overview");
|
||||
expect(names).toContain("batch_decompile");
|
||||
});
|
||||
|
||||
it("preserves the 39-tool inventory without a session", async () => {
|
||||
const client = await connect({
|
||||
callTool: () => Promise.resolve(ok(null)),
|
||||
});
|
||||
const listed = await client.listTools();
|
||||
expect(listed.tools).toHaveLength(39);
|
||||
const names = listed.tools.map((t) => t.name);
|
||||
expect(names).toContain("binary_overview");
|
||||
expect(names).toContain("batch_decompile");
|
||||
expect(names).not.toContain("open_binary");
|
||||
});
|
||||
|
||||
it("propagates remote Hopper errors with structured content", async () => {
|
||||
const client = await connect({
|
||||
callTool: () =>
|
||||
Promise.resolve(err(new HopperRemoteError(-32000, "bridge timeout"))),
|
||||
});
|
||||
|
||||
const result = await client.callTool({
|
||||
name: "list_documents",
|
||||
arguments: {},
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(text(result)).toContain("HopperRemoteError");
|
||||
expect(text(result)).toContain("bridge timeout");
|
||||
});
|
||||
|
||||
it("handles concurrent tool calls without corruption", async () => {
|
||||
const invocations: string[] = [];
|
||||
const client = await connect({
|
||||
callTool: (name) => {
|
||||
invocations.push(name);
|
||||
return Promise.resolve(ok({ handled: name }));
|
||||
},
|
||||
});
|
||||
|
||||
const results = await Promise.all([
|
||||
client.callTool({ name: "list_procedures", arguments: {} }),
|
||||
client.callTool({ name: "list_segments", arguments: {} }),
|
||||
client.callTool({ name: "list_strings", arguments: {} }),
|
||||
]);
|
||||
|
||||
expect(results.every((r) => !r.isError)).toBe(true);
|
||||
expect(invocations).toHaveLength(3);
|
||||
expect(new Set(invocations).size).toBe(3);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue