rea/tests/ghidraMcp.test.ts

455 lines
14 KiB
TypeScript

import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { describe, expect, it } from "vitest";
import { BinarySession } from "../src/application/BinarySession.js";
import { EnhancedTools } from "../src/application/EnhancedTools.js";
import { AnalysisProviderRegistry } from "../src/application/AnalysisProviderRegistry.js";
import { SessionProviderRouter } from "../src/application/SessionProviderRouter.js";
import { parseConfig } from "../src/config.js";
import type { JsonValue } from "../src/domain/jsonValue.js";
import { ok } from "../src/domain/result.js";
import type { GhidraInstallationHost } from "../src/ghidra/GhidraInstallation.js";
import type { GhidraOperation } from "../src/ghidra/GhidraClient.js";
import {
GhidraProvider,
type GhidraProviderClientFactory,
} from "../src/ghidra/GhidraProvider.js";
import { GHIDRA_SESSION_CAPABILITIES } from "../src/ghidra/GhidraSessionValues.js";
import { silentLogger } from "../src/logger.js";
import { createServer } from "../src/server/createServer.js";
import {
ghidraBounded,
ghidraFunctionClassification,
ghidraFunctionDossier,
ghidraFunctionIdentity,
ghidraReferenceEdge,
} from "./fixtures/ghidraFunction.js";
const INSTALL = "/opt/ghidra_12.1.2_PUBLIC";
describe("Ghidra MCP and shared CLI composition", () => {
it("preserves provider evidence, composed parity, and capability routing", async () => {
const calls: GhidraOperation[] = [];
const factory: GhidraProviderClientFactory = (options) => ({
start: () => Promise.resolve(ok(sessionInfo(options.profileDigest))),
callTool: (operation, input) => {
calls.push(operation);
return Promise.resolve(ok(resultFor(operation, input)));
},
close: () => Promise.resolve(),
});
const config = parseConfig({ GHIDRA_INSTALL_DIR: INSTALL });
if (!config.ok) throw config.error;
const provider = new GhidraProvider(
config.value,
silentLogger,
installationHost(),
factory,
);
const session = new BinarySession(
SessionProviderRouter.selectable(
new AnalysisProviderRegistry([provider]),
[],
),
);
const server = createServer(session, session, { logger: silentLogger });
const mcp = new Client({ name: "ghidra-parity", version: "1.0.0" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
try {
await server.connect(serverTransport);
await mcp.connect(clientTransport);
const opened = await mcp.callTool({
name: "open_binary",
arguments: { path: process.execPath },
});
expect(opened.isError).not.toBe(true);
const listed = sessionEvidence(
session,
(
await mcp.callTool({
name: "list_procedures",
arguments: {},
})
).structuredContent,
);
expect(listed).toMatchObject({
operation: "list_procedures",
provider: { id: "ghidra", name: "Ghidra", version: "12.1.2" },
analysis_profile: {
provider: { id: "ghidra", version: "12.1.2" },
parameters: {
import_mode: "ephemeral-read-only",
analyzer_preset: "ghidra-default",
},
},
normalized_result: {
items: [
{
address: "0x401000",
value: "fixture_main",
procedure: {
external: false,
thunk: false,
thunk_target: null,
},
},
],
total: 1,
},
raw_result: {
items: [{ value_truncated: false }],
},
});
const mcpOverview = sessionEvidence(
session,
(
await mcp.callTool({
name: "binary_overview",
arguments: { detail: "detailed", limit: 5 },
})
).structuredContent,
);
const directOverview = await new EnhancedTools(session).execute(
"binary_overview",
{ detail: "detailed", limit: 5 },
);
expect(directOverview.ok).toBe(true);
if (!directOverview.ok) return;
expect(mcpOverview.normalized_result).toEqual(directOverview.value);
expect(mcpOverview).toMatchObject({
provider: { id: "rea-workflow" },
confidence: "derived",
normalized_result: {
document: "fixture",
segment_count: 1,
procedure_count: 1,
string_count: 2,
segments: [{ name: ".text", length: 256 }],
},
});
const pseudocode = sessionEvidence(
session,
(
await mcp.callTool({
name: "procedure_pseudo_code",
arguments: { procedure: "fixture_main" },
})
).structuredContent,
);
expect(pseudocode).toMatchObject({
operation: "procedure_pseudo_code",
provider: { id: "ghidra", version: "12.1.2" },
normalized_result: expect.stringContaining("return 42"),
limitations: expect.arrayContaining([
expect.stringContaining("not text-equivalent to Hopper"),
expect.stringContaining("30-second native deadline"),
]),
});
const analyzed = sessionEvidence(
session,
(
await mcp.callTool({
name: "analyze_function",
arguments: { procedure: "fixture_main", include_assembly: true },
})
).structuredContent,
);
expect(analyzed).toMatchObject({
operation: "analyze_function",
provider: { id: "rea-workflow", version: "1" },
normalized_result: {
procedure: {
address: "0x401000",
name: "fixture_main",
classification: {
external: false,
thunk: false,
provenance: "ghidra-function-manager",
},
},
pseudocode: { truncated: false },
outgoing_references: {
items: [
{
kind: {
available: true,
provenance: "ghidra-reference-manager",
},
},
],
},
limitations: expect.arrayContaining([
expect.stringContaining("indirect flows without target addresses"),
expect.stringContaining(
"not original source or Hopper-equivalent text",
),
]),
},
limitations: [
"Derived by an REA workflow from one or more provider observations.",
],
});
const directAnalyzed = await new EnhancedTools(session).execute(
"analyze_function",
{ procedure: "fixture_main", include_assembly: true },
);
expect(directAnalyzed.ok).toBe(true);
if (!directAnalyzed.ok) return;
expect(analyzed.normalized_result).toEqual(directAnalyzed.value);
const batch = await mcp.callTool({
name: "batch_decompile",
arguments: { addresses: ["fixture_main"] },
});
expect(batch.isError).not.toBe(true);
expect(batch.structuredContent).toMatchObject({
result: {
succeeded: 1,
failed: 0,
items: [{ status: "ok" }],
},
});
const unsupported = await mcp.callTool({
name: "set_comment",
arguments: { address: "0x401000", comment: "not allowed" },
});
expect(unsupported.isError).toBe(true);
expect(unsupported.structuredContent).toMatchObject({
error: { code: "capability_unavailable" },
});
expect(calls).not.toContain("set_comment");
const status = await mcp.callTool({
name: "binary_session",
arguments: {},
});
expect(status.structuredContent).toMatchObject({
result: {
open: true,
analysis_provider_binding: {
provider: { id: "ghidra", version: "12.1.2" },
},
tool_availability: expect.arrayContaining([
expect.objectContaining({
name: "binary_overview",
available: true,
}),
expect.objectContaining({ name: "swift_classes", available: true }),
expect.objectContaining({
name: "get_objc_classes",
available: true,
}),
expect.objectContaining({
name: "batch_decompile",
available: true,
}),
expect.objectContaining({
name: "trace_feature",
available: true,
}),
expect.objectContaining({
name: "get_call_graph",
available: true,
}),
expect.objectContaining({
name: "find_xrefs_to_name",
available: true,
}),
]),
},
});
} finally {
await Promise.allSettled([mcp.close(), server.close()]);
await session.close();
}
});
});
const sessionEvidence = (session: BinarySession, value: unknown) => {
if (
typeof value !== "object" ||
value === null ||
!("evidence_id" in value) ||
typeof value.evidence_id !== "string"
)
throw new TypeError(
`Missing compact Evidence ID: ${JSON.stringify(value)}`,
);
const evidence = session.evidenceById(value.evidence_id);
if (evidence === undefined) throw new TypeError("Missing session Evidence");
return evidence;
};
const installationHost = (): GhidraInstallationHost => ({
platform: "linux",
architecture: "x64",
readText: () => "application.version=12.1.2\n",
executable: () => true,
probeJava: () => ({
version: "21.0.11",
major: 21,
home: "/usr/lib/jvm/jdk-21",
bits: 64,
jdk: true,
}),
});
const sessionInfo = (profileDigest: string) => ({
name: "REA Ghidra bridge" as const,
bridge_version: 3 as const,
run_id: "11111111-1111-4111-8111-111111111111",
profile_digest: profileDigest,
provider: { id: "ghidra" as const, version: "12.1.2" },
read_only: true as const,
analysis_complete: true,
analysis_timed_out: false,
capabilities: [...GHIDRA_SESSION_CAPABILITIES],
target: {
name: "fixture",
language_id: "x86:LE:64:default",
compiler_spec_id: "gcc",
image_base: "0x400000",
default_address_space: "ram",
},
});
const resultFor = (
operation: GhidraOperation,
input: Readonly<Record<string, JsonValue>>,
): JsonValue => {
const limit = typeof input.limit === "number" ? input.limit : 100;
switch (operation) {
case "list_documents":
return ["fixture"];
case "list_procedures":
return page(
[
{
address: "0x401000",
value: "fixture_main",
value_truncated: false,
procedure: {
external: false,
thunk: false,
thunk_target: null,
},
},
],
limit,
);
case "list_strings":
return page(
[
stringItem("0x402000", "inventory fixture"),
stringItem("0x402020", "external fixture"),
],
limit,
);
case "list_segments":
return [
{
name: ".text",
start: "0x401000",
end: "0x401100",
readable: true,
writable: false,
executable: true,
permissions: { available: true, source: "ghidra-memory-block" },
provenance: "ghidra-memory-block",
address_space: "ram",
image_base: "0x400000",
initialized: true,
overlay: false,
sections: [],
},
];
case "list_names":
return page(
[
{
address: "0x401000",
value: "fixture_main",
value_truncated: false,
symbol: {
primary: true,
dynamic: false,
external: false,
type: "function",
source: "user_defined",
},
},
],
limit,
);
case "search_procedures":
case "search_strings":
return page([], limit);
case "address_name":
return "fixture_main";
case "procedure_address":
return "0x401000";
case "resolve_containing_procedure":
return {
query_address: "0x401001",
found: true,
procedure: ghidraFunctionIdentity(),
};
case "procedure_assembly":
return "0x401000: CALL 0x401020\n0x401005: RET";
case "procedure_callees":
case "procedure_callers":
return [];
case "procedure_info":
return {
name: "fixture_main",
entrypoint: "0x401000",
basicblock_count: 1,
length: 6,
signature: "int fixture_main(void)",
locals: [],
classification: ghidraFunctionClassification(),
};
case "procedure_pseudo_code":
return "int fixture_main(void) { return 42; }";
case "procedure_references":
return {
procedure: ghidraFunctionIdentity(),
direction: input.direction ?? "outgoing",
references: ghidraBounded([ghidraReferenceEdge()]),
instructions_scanned: 2,
instruction_scan_truncated: false,
};
case "xrefs":
return ["0x401001"];
case "analyze_function":
return ghidraFunctionDossier(input.include_assembly === true);
}
};
const page = (items: readonly JsonValue[], limit: number): JsonValue => ({
items: [...items],
offset: 0,
limit,
total: items.length,
next_offset: null,
has_more: false,
});
const stringItem = (address: string, value: string): JsonValue => ({
address,
value,
value_truncated: false,
string: {
encoding: "UTF-8",
termination: "present_or_not_required",
byte_length: value.length + 1,
},
});