rea/tests/enhancedTools.test.ts

199 lines
6.6 KiB
TypeScript

import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import type { CallToolResult } from "@modelcontextprotocol/server";
import { afterEach, describe, expect, it } from "vitest";
import type { HopperToolPort } from "../src/application/HopperToolPort.js";
import { ENHANCED_TOOL_CONTRACTS } from "../src/contracts/toolContracts.js";
import { ok } from "../src/domain/result.js";
import { jsonValueSchema, type JsonValue } from "../src/hopper/protocol.js";
import { createServer } from "../src/server/createServer.js";
const PROCEDURES = {
"0x1": "_TtC7Fixture5Class",
"0x2": "_TtV7Fixture6Struct",
"0x3": "_TtP7Fixture8Protocol",
"0x4": "_TtO7Fixture4Enum",
"0x5": "_TtE7Fixture9Extension",
"0x6": "prefix_TtOther",
};
const fixturePort = (): HopperToolPort => ({
callTool: (name, arguments_) => {
switch (name) {
case "list_procedures":
return Promise.resolve(ok(PROCEDURES));
case "list_names":
return Promise.resolve(
ok([
{ address: "0x10", name: "_OBJC_CLASS_$_Fixture" },
{ address: "0x11", name: "_OBJC_CLASS_$_Fixture" },
{ address: "0x12", name: "_OBJC_PROTOCOL_$_FixtureDelegate" },
]),
);
case "procedure_pseudo_code": {
const procedure = arguments_.procedure;
return Promise.resolve(
ok(typeof procedure === "string" ? `pseudo:${procedure}` : "invalid"),
);
}
case "procedure_callees": {
const procedure = arguments_.procedure;
return Promise.resolve(
ok(
procedure === "0x1"
? ["0x2", "0x3"]
: procedure === "0x2"
? ["0x1"]
: [],
),
);
}
case "procedure_callers":
return Promise.resolve(ok(["0x9"]));
case "address_name":
return Promise.resolve(
ok({ address: "0x1", name: arguments_.address ?? "" }),
);
case "xrefs":
return Promise.resolve(ok(["0x20", "0x21"]));
case "list_segments":
return Promise.resolve(
ok([{ name: "__TEXT", start: "0x1000", end: "0x2000" }]),
);
case "list_documents":
return Promise.resolve(ok(["fixture"]));
case "list_strings":
return Promise.resolve(ok([{ address: "0x30", value: "hello" }]));
default:
return Promise.resolve(ok(null));
}
},
});
const resources: Array<{ close(): Promise<void> }> = [];
afterEach(async () => {
await Promise.all(
resources.splice(0).map(async (resource) => resource.close()),
);
});
const connect = async (hopper: HopperToolPort = fixturePort()) => {
const server = createServer(hopper);
const client = new Client({ name: "enhanced-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 jsonResult = (result: CallToolResult): JsonValue => {
const text = result.content.find((item) => item.type === "text");
if (text?.type !== "text")
throw new Error("Tool result omitted text content");
const decoded: unknown = JSON.parse(text.text);
const parsed = jsonValueSchema.safeParse(decoded);
if (!parsed.success) throw new Error("Tool result was not JSON");
return parsed.data;
};
describe("enhanced MCP tools", () => {
it("lists the complete 31 plus 8 surface", async () => {
const client = await connect();
const listed = await client.listTools();
expect(listed.tools).toHaveLength(39);
expect(
listed.tools
.map(({ name }) => name)
.filter((name) =>
ENHANCED_TOOL_CONTRACTS.some((tool) => tool.name === name),
)
.sort(),
).toEqual(ENHANCED_TOOL_CONTRACTS.map(({ name }) => name).sort());
});
it("executes all eight tools through production registration", async () => {
const client = await connect();
const calls = [
["swift_classes", { pattern: "Fixture" }],
["get_objc_classes", { pattern: "Fixture" }],
["get_objc_protocols", {}],
["batch_decompile", { addresses: ["0x1", "0x2"] }],
["get_call_graph", { address: "0x1", direction: "forward", depth: 2 }],
["analyze_swift_types", {}],
["find_xrefs_to_name", { name: "entry" }],
["binary_overview", {}],
] as const;
const results = [];
for (const [name, arguments_] of calls) {
results.push(
jsonResult(await client.callTool({ name, arguments: arguments_ })),
);
}
expect(results[0]).toMatchObject({ count: 1 });
// Legacy behavior treats any `_OBJC_` label, including protocol labels, as a class.
expect(results[1]).toMatchObject({ count: 2 });
expect(results[2]).toMatchObject({ count: 1 });
expect(results[3]).toEqual({ "0x1": "pseudo:0x1", "0x2": "pseudo:0x2" });
expect(results[4]).toEqual({
"0": [{ address: "0x1", calls: ["0x2", "0x3"] }],
"1": [
{ address: "0x2", calls: ["0x1"] },
{ address: "0x3", calls: [] },
],
});
expect(results[5]).toMatchObject({ total: 6 });
expect(results[6]).toEqual({ xrefs: ["0x20", "0x21"] });
expect(results[7]).toMatchObject({
document: "fixture",
segment_count: 1,
procedure_count: 6,
string_count: 1,
});
});
it("bounds batch concurrency at the parsed maximum of 20", async () => {
let active = 0;
let maximum = 0;
const hopper: HopperToolPort = {
callTool: async (name) => {
if (name !== "procedure_pseudo_code") return ok(null);
active += 1;
maximum = Math.max(maximum, active);
await new Promise((resolve) => setTimeout(resolve, 2));
active -= 1;
return ok("pseudo");
},
};
const client = await connect(hopper);
const result = await client.callTool({
name: "batch_decompile",
arguments: {
addresses: Array.from(
{ length: 20 },
(_, index) => `0x${String(index)}`,
),
},
});
expect(result.isError).not.toBe(true);
expect(maximum).toBe(20);
});
it("returns a typed tool error for malformed Hopper boundary values", async () => {
const client = await connect({
callTool: () => Promise.resolve(ok(["not", "a", "procedure", "map"])),
});
const result = await client.callTool({
name: "swift_classes",
arguments: {},
});
expect(result.isError).toBe(true);
const text = result.content.find((item) => item.type === "text");
expect(text?.type === "text" ? text.text : "").toContain(
"HopperProtocolError",
);
});
});