Files
rea/tests/boundary/mcp/callRelationshipAvailability.test.ts
T
morluto 1d5bf5ff08 fix(session): align call relationship discovery with traversal modes
Declare the missing trace composition and correct call graph prerequisites to require address resolution plus the selected relationship direction. Report forward/backward modes and the actual default independently, preserving missing operations, owner failure reasons and direct partial outcomes.

Share the direction-to-provider mapping across runtime and discovery, reuse the mode evaluator, select defaults by name, and check composition coverage against every canonical enhanced contract. Keep same-endpoint traces callable without relationship reads.

Adapt the missing trace-composition correction proposed in PR #4729 and complete the directly related ownership/refactoring and sibling graph correction.

Validation: 75 source tests and 76 public MCP/schema tests passed; full static/architecture checks, docs and test discovery passed. Controlled SDK/provider workflows verified unchanged catalogs and normalized traces. Small whole-session costs were recorded, with no speedup claim. No native-engine, CLI, Linux/Windows or model tool-selection qualification.

Fixes #4687
Fixes #5068
Co-authored-by: AHMED <aptech356@gmail.com>
2026-10-19 03:08:39 +08:00

424 lines
14 KiB
TypeScript

import { rm } from "node:fs/promises";
import type { Client } from "@modelcontextprotocol/client";
import type { CallToolResult } from "@modelcontextprotocol/server";
import { afterEach, describe, expect, it } from "vitest";
import type {
AnalysisClient,
AnalysisProvider,
CapabilityDescriptor,
} from "../../../src/application/AnalysisProvider.js";
import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js";
import { ok } from "../../../src/domain/result.js";
import type { ProviderRejectionCode } from "../../../src/contracts/providerSelection.js";
import { toolContract } from "../../../src/contracts/toolContracts.js";
import { expandToolAvailability } from "../../../src/contracts/toolOutputSchemaPrimitives.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { createSessionMcpHarness } from "./sessionMcpHarness.js";
const resources: Array<{ close(): Promise<unknown> }> = [];
let directory: string | undefined;
afterEach(async () => {
await Promise.all(resources.splice(0).map((resource) => resource.close()));
if (directory !== undefined)
await rm(directory, { recursive: true, force: true });
directory = undefined;
});
const providerIdentity = {
id: "fixture",
name: "Fixture analysis provider",
version: "1",
};
const effects = {
mutatesArtifact: false,
launchesProcess: false,
mayShowUi: false,
mayAccessNetwork: false,
mayWriteFilesystem: false,
changesPermissions: false,
requiresRoot: false,
};
const addressBySymbol = new Map([
["start", "0x1000"],
["goal", "0x2000"],
]);
type RelationshipOperation =
| "procedure_address"
| "procedure_callees"
| "procedure_callers";
type Scenario = {
readonly name: string;
readonly operations: readonly RelationshipOperation[];
readonly unavailable?: RelationshipOperation;
readonly availabilityCode?: ProviderRejectionCode;
readonly unavailableReason?: string;
};
const FULL_SCENARIO: Scenario = {
name: "both directions",
operations: ["procedure_address", "procedure_callees", "procedure_callers"],
};
const scenarios: readonly Scenario[] = [
FULL_SCENARIO,
{
name: "forward only",
operations: ["procedure_address", "procedure_callees"],
},
{
name: "backward only",
operations: ["procedure_address", "procedure_callers"],
},
{
name: "address missing",
operations: ["procedure_callees", "procedure_callers"],
},
{
name: "address unavailable",
operations: ["procedure_address", "procedure_callees", "procedure_callers"],
unavailable: "procedure_address",
},
{
name: "forward relationship unavailable",
operations: ["procedure_address", "procedure_callees", "procedure_callers"],
unavailable: "procedure_callees",
},
{
name: "address unsupported on host",
operations: ["procedure_address", "procedure_callees", "procedure_callers"],
unavailable: "procedure_address",
availabilityCode: "unsupported_host",
unavailableReason: "Procedure address lookup is unsupported on this host.",
},
{ name: "no relationships", operations: ["procedure_address"] },
];
describe("call relationship capability inventory at the public MCP boundary", () => {
it.each(scenarios)(
"keeps $name inventory aligned with callable modes",
async (scenario) => {
directory = await createTestTempDirectory("rea-call-availability-");
const calls: string[] = [];
const { mcp, first } = await createSessionMcpHarness(
directory,
() => relationshipProvider(scenario, calls),
resources,
);
const toolsBefore = await mcp.listTools();
const before = await sessionInventory(mcp);
const opened = await mcp.callTool({
name: "open_binary",
arguments: { path: first },
});
expect(opened.isError).not.toBe(true);
try {
const inventory = await sessionInventory(mcp);
await assertDirectExecution(mcp, scenario, calls);
assertInventory(inventory, scenario);
const after = await sessionInventory(mcp);
expect((await mcp.listTools()).tools.map(({ name }) => name)).toEqual(
toolsBefore.tools.map(({ name }) => name),
);
expect(after.result.server_identity.catalog).toEqual(
before.result.server_identity.catalog,
);
} finally {
const closed = await mcp.callTool({
name: "close_binary",
arguments: {},
});
expect(closed.isError).not.toBe(true);
}
},
);
it("keeps call relationship tools unavailable before a target is open", async () => {
directory = await createTestTempDirectory(
"rea-call-availability-no-target-",
);
const { mcp } = await createSessionMcpHarness(
directory,
() => relationshipProvider(FULL_SCENARIO, []),
resources,
);
const inventory = await sessionInventory(mcp);
expect(inventory.result.open).toBe(false);
expect(
Object.fromEntries(
callToolAvailability(inventory).map((entry) => [
entry.name,
entry.reason,
]),
),
).toEqual({
trace_call_path: "target_required",
get_call_graph: "target_required",
});
});
});
const sessionInventory = async (mcp: Client) => {
const result = await mcp.callTool({ name: "binary_session", arguments: {} });
return toolContract("binary_session").outputSchema.parse(
result.structuredContent,
);
};
const callToolAvailability = (
inventory: Awaited<ReturnType<typeof sessionInventory>>,
) =>
expandToolAvailability(inventory.result.tool_availability).filter((entry) =>
["trace_call_path", "get_call_graph"].includes(entry.name),
);
const assertDirectExecution = async (
mcp: Client,
scenario: Scenario,
calls: string[],
): Promise<void> => {
for (const direction of ["forward", "backward"] as const) {
const relation =
direction === "forward" ? "procedure_callees" : "procedure_callers";
const start = direction === "forward" ? "start" : "goal";
const goal = direction === "forward" ? "goal" : "start";
const startAddress = direction === "forward" ? "0x1000" : "0x2000";
const nextAddress = direction === "forward" ? "0x2000" : "0x1000";
const graph = await mcp.callTool({
name: "get_call_graph",
arguments: { address: start, direction },
});
const trace = await mcp.callTool({
name: "trace_call_path",
arguments: { start, goal, direction },
});
if (!scenario.operations.includes("procedure_address")) {
expect(errorCode(graph)).toBe("capability_unavailable");
expect(errorCode(trace)).toBe("capability_unavailable");
} else if (scenario.unavailable === "procedure_address") {
expect(errorCode(graph)).toBe("capability_unavailable");
expect(errorCode(trace)).toBe("capability_unavailable");
expect(calls).toEqual([]);
} else if (
!scenario.operations.includes(relation) ||
scenario.unavailable === relation
) {
expect(calls).not.toContain(relation);
expect(graph.structuredContent).toMatchObject({
normalized_result: {
graph: {
"0": [
{
address: startAddress,
status: "error",
error: { code: "capability_unavailable" },
},
],
},
},
});
expect(trace.structuredContent).toMatchObject({
normalized_result: {
goal_status: "unknown",
truncated: true,
failures: expect.arrayContaining([
expect.objectContaining({
address: startAddress,
error: expect.objectContaining({
code: "capability_unavailable",
}),
}),
]),
},
});
} else {
expect(graph.structuredContent).toMatchObject({
normalized_result: {
graph: {
"0": [
{
address: startAddress,
status: "ok",
calls: [nextAddress],
relationship_coverage: { status: "complete" },
},
],
},
},
});
expect(trace.structuredContent).toMatchObject({
normalized_result: {
goal_status: "reached",
traversal_path: [startAddress, nextAddress],
relationship_coverage: { [startAddress]: { status: "complete" } },
},
});
}
}
if (
scenario.operations.includes("procedure_address") &&
!scenario.operations.includes("procedure_callees") &&
!scenario.operations.includes("procedure_callers")
) {
const callsBefore = calls.length;
const sameEndpoint = await mcp.callTool({
name: "trace_call_path",
arguments: { start: "start", goal: "start" },
});
expect(sameEndpoint.structuredContent).toMatchObject({
normalized_result: {
goal_status: "reached",
traversal_path: ["0x1000"],
},
});
expect(
calls
.slice(callsBefore)
.every((operation) => operation === "procedure_address"),
).toBe(true);
}
};
const assertInventory = (
inventory: Awaited<ReturnType<typeof sessionInventory>>,
scenario: Scenario,
): void => {
const available = callToolAvailability(inventory);
const trace = available.find((entry) => entry.name === "trace_call_path");
const graph = available.find((entry) => entry.name === "get_call_graph");
expect(trace).toBeDefined();
expect(graph).toBeDefined();
if (trace === undefined || graph === undefined)
throw new Error("call relationship tools missing from inventory");
const forwardAvailable = modeAvailable(scenario, "procedure_callees");
const backwardAvailable = modeAvailable(scenario, "procedure_callers");
const expected = {
available: forwardAvailable || backwardAvailable,
reason: availabilityReason(scenario),
default_mode_available: forwardAvailable,
modes: [
mode("forward", scenario, "procedure_callees"),
mode("backward", scenario, "procedure_callers"),
],
};
expect(trace).toMatchObject(expected);
expect(graph).toMatchObject(expected);
if (scenario.unavailableReason !== undefined) {
expect(trace.remediation).toContain(scenario.unavailableReason);
expect(graph.remediation).toContain(scenario.unavailableReason);
}
};
const modeAvailable = (
scenario: Scenario,
relation: "procedure_callees" | "procedure_callers",
): boolean =>
scenario.operations.includes("procedure_address") &&
scenario.unavailable !== "procedure_address" &&
scenario.unavailable !== relation &&
scenario.operations.includes(relation);
const mode = (
name: "forward" | "backward",
scenario: Scenario,
relation: "procedure_callees" | "procedure_callers",
) => {
const available = modeAvailable(scenario, relation);
const required: readonly RelationshipOperation[] = [
"procedure_address",
relation,
];
const missing = required.filter(
(operation) =>
!scenario.operations.includes(operation) ||
scenario.unavailable === operation,
);
return {
name,
available,
missing_operations: available ? [] : missing,
remediation: available ? null : expect.any(String),
};
};
const availabilityReason = (scenario: Scenario) => {
if (scenario.unavailable === "procedure_address")
return scenario.availabilityCode === "unsupported_host"
? "unsupported_host"
: "provider_unavailable";
if (!scenario.operations.includes("procedure_address"))
return "provider_missing";
return scenario.operations.some((operation) =>
["procedure_callees", "procedure_callers"].includes(operation),
)
? "available"
: "provider_missing";
};
const errorCode = (result: CallToolResult): unknown =>
result.isError === true ? parseMcpToolError(result).error.code : undefined;
const relationshipProvider = (
scenario: Scenario,
calls: string[],
): AnalysisProvider => {
const capabilities: CapabilityDescriptor[] = scenario.operations.map(
(operation) => {
const shared = {
provider: providerIdentity,
operation,
effects,
limitations: [],
};
return scenario.unavailable === operation
? {
...shared,
available: false as const,
reason:
scenario.unavailableReason ?? "fixture operation unavailable",
...(scenario.availabilityCode === undefined
? {}
: { availabilityCode: scenario.availabilityCode }),
}
: { ...shared, available: true as const, reason: null };
},
);
return {
identity: () => providerIdentity,
capabilities: () => capabilities,
createClient: (): AnalysisClient => ({
execute: async (operation, arguments_) => {
if (operation === "health")
return ok(createAnalysisExecution(null, providerIdentity));
calls.push(operation);
if (operation === "procedure_address") {
const address = addressBySymbol.get(String(arguments_.procedure));
return ok(createAnalysisExecution(address ?? null, providerIdentity));
}
if (
operation === "procedure_callees" ||
operation === "procedure_callers"
) {
const forward = operation === "procedure_callees";
const edge = forward ? "0x2000" : "0x1000";
const from = forward ? "0x1000" : "0x2000";
const related = String(arguments_.procedure) === from ? [edge] : [];
return ok(
createAnalysisExecution(related, providerIdentity, {
relationshipCoverage: { status: "complete" },
}),
);
}
throw new Error(`Unexpected operation ${operation}`);
},
close: async () => ok(null),
}),
};
};