fix(ida): report missing tools when a compatibility profile mismatches (#2307)

A profile mismatch reached the caller as a bare AnalysisProtocolError,
which projects to `unreadable_output` with a generic message. The error
already named every missing tool, but none of that detail survived, so
the only way to identify the cause was to log the JSON-RPC traffic and
notice the exchange stopping after `tools/list`.

Throw a ProviderAdapterError instead, carrying the mode, the expected
profile and the missing tool names through the diagnostics channel that
already surfaces in `details`. Document which upstream versions each
profile accepts, since upstream `main` now serves neither.

Refs #2301

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
vtxandmorluto authored and GitHub committed 2026-10-13 21:58:36 +08:00
1 parent 48a683ac8c
commit beca34f522
4 files changed
+112 -6

No files matched your search

+9
View File
@@ -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:
+70
View File
@@ -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");
+21 -4
View File
@@ -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,
+12 -2
View File
@@ -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);