From d341f2645977717bb112369780a6f93cc233e150 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 12 Jul 2026 10:20:04 +0800 Subject: [PATCH] Add integration tests for MCP server lifecycle and config parsing --- tests/integration/configIntegration.test.ts | 77 ++++++++++ tests/integration/serverIntegration.test.ts | 159 ++++++++++++++++++++ 2 files changed, 236 insertions(+) create mode 100644 tests/integration/configIntegration.test.ts create mode 100644 tests/integration/serverIntegration.test.ts diff --git a/tests/integration/configIntegration.test.ts b/tests/integration/configIntegration.test.ts new file mode 100644 index 000000000..893f6e3f5 --- /dev/null +++ b/tests/integration/configIntegration.test.ts @@ -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"); + }); +}); diff --git a/tests/integration/serverIntegration.test.ts b/tests/integration/serverIntegration.test.ts new file mode 100644 index 000000000..4fa247382 --- /dev/null +++ b/tests/integration/serverIntegration.test.ts @@ -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 }> = []; + +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); + }); +});