diff --git a/docs/ida-provider.md b/docs/ida-provider.md index fbf67c8a2..012f75bfb 100644 --- a/docs/ida-provider.md +++ b/docs/ida-provider.md @@ -4,6 +4,15 @@ REA adapts an existing IDA MCP server into its native analysis tools. Existing I Choose one lifecycle mode in an upstream MCP JSON registration. Set `REA_IDA_MCP_CONFIG` to that file, then select `ida` once through `REA_ANALYSIS_PROVIDER`, the CLI's `--provider ida`, or MCP's `open_binary.provider_id`. No provider startup occurs during discovery. +## Supported upstream versions + +Each mode accepts one upstream tool profile, checked against `tools/list` before any analysis runs. Upstream's `main` branch has since moved to 2.0.0, which renamed the GUI tools and dropped `idb_close`, so it serves neither profile. Installing from that branch fails at startup with the missing tool names in the error diagnostics. + +| Mode | Upstream profile | Known working | +| -------- | ------------------- | ------------------------------------------------------ | +| attached | legacy GUI tools | 1.4.0 | +| headless | database supervisor | builds serving `idb_open`, `idb_list`, and `idb_close` | + ## Analyze an already-open GUI target The attached profile supports upstream **1.4.0 legacy tools**, including `get_metadata`, `list_functions`, and `decompile_function`. Copy the upstream registration's `command`, `args`, and optional `env` into a JSON file. An existing `mcpServers` object with an `ida-pro-mcp` entry is also accepted. For example: diff --git a/src/ida/IdaSessionClient.test.ts b/src/ida/IdaSessionClient.test.ts index 7fa366bce..0ae4002d2 100644 --- a/src/ida/IdaSessionClient.test.ts +++ b/src/ida/IdaSessionClient.test.ts @@ -31,6 +31,7 @@ const LIVE_PEER = { const fixture = async ( mode: "attached" | "headless" = "attached", timeoutMs = 1000, + protocol?: "native", ) => { const { root, target } = await createIdaTarget(); roots.push(root); @@ -41,6 +42,7 @@ const fixture = async ( args: [], env: {}, mode, + ...(protocol === undefined ? {} : { protocol }), timeoutMs, workspaceRoot: root, }, @@ -709,6 +711,74 @@ describe("headless IDA non-busy health payloads", () => { }); }); +describe("IDA compatibility profile mismatch", () => { + it("reports the native profile for an incompatible catalog", async () => { + const { producer, client } = await fixture("attached", 1000, "native"); + producer.tools = []; + const result = await client.execute("health", {}); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected incompatible native catalog"); + expect(projectAnalysisError(result.error).details).toMatchObject({ + diagnostics: { + mode: "attached", + expected_profile: "native", + missing_tools: expect.arrayContaining(["decompile"]), + }, + }); + expect(producer.calls).toEqual([]); + expect((await client.close()).ok).toBe(true); + }); + + it("names every missing tool so the caller can identify the upstream profile", async () => { + const { producer, client } = await fixture("headless"); + producer.tools = [ + "idb_open", + "idb_list", + "server_health", + "list_funcs", + "lookup_funcs", + "find_regex", + "decompile", + "disasm", + "callees", + "xrefs_to", + ]; + const result = await client.execute("health", {}); + expect(result.ok, "an incomplete profile cannot serve analysis").toBe( + false, + ); + if (result.ok) return; + const projected = projectAnalysisError(result.error); + expect( + projected.details, + "the absent tool and the expected profile are both reported", + ).toMatchObject({ + provider_id: "ida", + diagnostics: { + mode: "headless", + expected_profile: "database-supervisor", + missing_tools: ["idb_close"], + }, + }); + }); + + it("reports the legacy profile when an attached producer serves modern tools", async () => { + const { producer, client } = await fixture("attached"); + producer.tools = ["server_health", "lookup_funcs", "decompile", "disasm"]; + const result = await client.execute("health", {}); + expect(result.ok, "an incomplete profile cannot serve analysis").toBe( + false, + ); + if (result.ok) return; + expect( + projectAnalysisError(result.error).details, + "attached mode names the legacy profile it expected", + ).toMatchObject({ + diagnostics: { mode: "attached", expected_profile: "legacy-1.4" }, + }); + }); +}); + describe("headless IDA lifecycle boundaries", () => { it("never closes an unowned worker even when open echoes the requested private identity", async () => { const { producer, client } = await fixture("headless"); diff --git a/src/ida/IdaSessionClient.ts b/src/ida/IdaSessionClient.ts index dc0ae5d6f..70d4fc365 100644 --- a/src/ida/IdaSessionClient.ts +++ b/src/ida/IdaSessionClient.ts @@ -483,10 +483,27 @@ export class IdaSessionClient implements AnalysisClient { ? legacyTools : modernTools; const missing = expected.filter((name) => !tools.has(name)); - if (missing.length > 0) - throw new AnalysisProtocolError( - `IDA MCP ${this.config.protocol === "native" ? "native attached" : this.config.mode} compatibility profile is missing tools: ${missing.join(", ")}. Native attached mode requires the native IDA MCP tool catalog; legacy attached mode requires the legacy 1.4 profile; headless mode requires the database supervisor profile. See docs/ida-provider.md.`, - ); + if (missing.length > 0) { + const summary = `IDA MCP ${this.config.protocol === "native" ? "native attached" : this.config.mode} compatibility profile is missing tools: ${missing.join(", ")}. Native attached mode requires the native IDA MCP tool catalog; legacy attached mode requires the legacy 1.4 profile; headless mode requires the database supervisor profile. See docs/ida-provider.md.`; + // The caller cannot act on a profile mismatch it never sees, so the + // missing names travel as adapter diagnostics rather than a bare message. + throw new ProviderAdapterError("ida", "initialize", { + userMessage: summary, + diagnostics: { + reason: summary, + mode: this.config.mode, + expected_profile: + this.config.protocol === "native" + ? "native" + : this.config.mode === "attached" + ? "legacy-1.4" + : "database-supervisor", + missing_tools: [...missing], + recovery: + "Install an IDA MCP build that serves the selected profile; see docs/ida-provider.md.", + }, + }); + } if (this.config.mode === "headless") { this.#workspace = await IdaWorkspace.allocate( this.target, diff --git a/tests/composition/idaProvider.test.ts b/tests/composition/idaProvider.test.ts index 9a702bb45..f6dd1dd0d 100644 --- a/tests/composition/idaProvider.test.ts +++ b/tests/composition/idaProvider.test.ts @@ -1,6 +1,7 @@ import { rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { projectAnalysisError } from "../../src/domain/analysisErrorProjection.js"; import { IdaProvider } from "../../src/ida/IdaProvider.js"; import { createIdaTarget, RecordingIdaMcp } from "../fixtures/idaMcp.js"; import { createTestBinarySession } from "../fixtures/binarySession.js"; @@ -157,12 +158,21 @@ describe("IDA provider composition", () => { expect(analyzed).toMatchObject({ ok: false, error: { - _tag: "AnalysisProtocolError", - message: expect.stringContaining( + _tag: "ProviderAdapterError", + userMessage: expect.stringContaining( "headless compatibility profile is missing tools: idb_open", ), }, }); + if (analyzed.ok) throw new Error("Expected incompatible IDA profile"); + expect(projectAnalysisError(analyzed.error).details).toMatchObject({ + provider_id: "ida", + diagnostics: { + mode: "headless", + expected_profile: "database-supervisor", + missing_tools: expect.arrayContaining(["idb_open"]), + }, + }); expect(producer.connects).toBe(1); expect((await session.close()).ok).toBe(true); expect(producer.closes).toBe(1);