rea/scripts/verify-package-mcp.mjs

210 lines
6.5 KiB
JavaScript

import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { TOOL_CONTRACTS } from "../dist/contracts/toolContracts.js";
import * as prompts from "./verify-package-prompts.mjs";
import { json } from "./lib/verify-package-core.mjs";
const verifyMcpToolsAndPrompts = async (client, mcpOptions) => {
if (
(await client.listTools(undefined, mcpOptions)).tools.length !==
TOOL_CONTRACTS.length
)
throw new Error("packaged MCP tool inventory diverged from contracts");
await prompts.verifyPromptCatalog(client, mcpOptions, prompts.names);
await prompts.verifyPromptCompletion(client, mcpOptions, false);
};
const verifyMcpReplay = async (client, mcpOptions, investigationReplay) => {
const replay = await client.callTool(
{
name: "find_changed_behavior",
arguments: { investigation_run: investigationReplay.arguments },
},
mcpOptions,
);
const replayEvidence = json(prompts.mcpText(replay));
const replayWorkspace = json(
await readFile(investigationReplay.workspacePath, "utf8"),
);
if (
replay.isError === true ||
replayEvidence.evidence_id !== investigationReplay.evidenceId ||
replayWorkspace.revision !== investigationReplay.revision
)
throw new Error(
"packaged MCP did not replay with workspace-only authority",
);
};
const verifyMcpTargetFree = async (client, mcpOptions) => {
const result = await client.callTool(
{ name: "current_document", arguments: {} },
mcpOptions,
);
if (result.isError !== true)
throw new Error("packaged target-free MCP omitted no-target error");
};
const verifyMcpUnknownProvider = async (client, mcpOptions) => {
const unknownProvider = await client.callTool(
{
name: "open_binary",
arguments: {
path: process.execPath,
provider_id: "missing-provider",
},
},
mcpOptions,
);
if (
unknownProvider.isError !== true ||
unknownProvider.structuredContent?.error?.details?.selection_reason !==
"unknown_provider"
)
throw new Error("packaged MCP accepted an unknown analysis provider");
};
const verifyMcpOpenAndBind = async (client, mcpOptions) => {
const opened = await client.callTool(
{
name: "open_binary",
arguments: { path: process.execPath, provider_id: "hopper" },
},
mcpOptions,
);
if (opened.isError === true)
throw new Error("packaged MCP could not open a binary");
const providerStatusEnvelope = json(
prompts.mcpText(
await client.callTool(
{ name: "binary_session", arguments: {} },
mcpOptions,
),
),
);
const providerStatus = providerStatusEnvelope.result;
if (
providerStatus.analysis_provider_binding?.provider?.id !== "hopper" ||
providerStatus.analysis_provider_binding?.selection_source !== "request"
)
throw new Error("packaged MCP omitted its explicit Hopper binding");
};
const verifyMcpLinuxCurrentDocument = async (client, mcpOptions, current) => {
if (
current.isError !== true ||
!prompts
.mcpText(current)
.includes("not supported for unattended Linux analysis")
)
throw new Error("packaged Linux MCP did not reject an unpinned Hopper");
};
const verifyMcpNonLinuxCurrentDocument = async (
client,
mcpOptions,
current,
) => {
if (json(prompts.mcpText(current)).result !== "fixture")
throw new Error("packaged MCP bridge call failed");
const batch = await client.callTool(
{
name: "batch_decompile",
arguments: { addresses: ["0x1000"] },
},
mcpOptions,
);
const batchResult = json(prompts.mcpText(batch)).result;
if (
batch.isError === true ||
batchResult?.total !== 1 ||
batchResult?.succeeded !== 1 ||
batchResult?.failed !== 0 ||
batchResult?.items?.[0]?.status !== "ok" ||
batchResult?.items?.[0]?.pseudocode !== "return 0;"
)
throw new Error("packaged MCP structured batch result failed");
};
const verifyMcpBinaryLifecycle = async (client, mcpOptions) => {
await verifyMcpOpenAndBind(client, mcpOptions);
await prompts.verifyPromptCompletion(
client,
mcpOptions,
process.platform !== "linux",
);
const current = await client.callTool(
{ name: "current_document", arguments: {} },
mcpOptions,
);
if (process.platform === "linux") {
await verifyMcpLinuxCurrentDocument(client, mcpOptions, current);
} else {
await verifyMcpNonLinuxCurrentDocument(client, mcpOptions, current);
}
const closed = await client.callTool(
{ name: "close_binary", arguments: {} },
mcpOptions,
);
if (closed.isError === true)
throw new Error("packaged MCP could not close its binary");
await prompts.verifyPromptCompletion(client, mcpOptions, false);
};
const verifyMcpEvidenceBundle = async (client, mcpOptions, evidenceRoot) => {
const mcpBundlePath = join(evidenceRoot, "mcp.json");
const mcpExport = await client.callTool(
{ name: "export_evidence_bundle", arguments: { path: mcpBundlePath } },
mcpOptions,
);
if (mcpExport.isError === true)
throw new Error("packaged MCP evidence export failed");
const mcpImport = await client.callTool(
{ name: "import_evidence_bundle", arguments: { path: mcpBundlePath } },
mcpOptions,
);
if (mcpImport.isError === true)
throw new Error("packaged MCP evidence import failed");
};
/** Connect to the packaged MCP server and exercise the target-free catalog. */
export async function verifyPackageMcp({
cli,
environment,
evidenceRoot,
investigationReplay,
}) {
const transport = new StdioClientTransport({
command: cli,
args: ["mcp"],
env: {
...environment,
REA_INVESTIGATION_INPUT_ROOTS_JSON: JSON.stringify([]),
},
stderr: "pipe",
});
let mcpStderr = "";
transport.stderr?.on("data", (chunk) => {
mcpStderr += chunk.toString();
});
const client = new Client({ name: "package-smoke", version: "1.0.0" });
try {
await client.connect(transport);
const mcpOptions = { timeout: 15_000 };
await verifyMcpToolsAndPrompts(client, mcpOptions);
await verifyMcpReplay(client, mcpOptions, investigationReplay);
await verifyMcpTargetFree(client, mcpOptions);
await verifyMcpUnknownProvider(client, mcpOptions);
await verifyMcpBinaryLifecycle(client, mcpOptions);
await verifyMcpEvidenceBundle(client, mcpOptions, evidenceRoot);
} catch (cause) {
throw new Error(`packaged MCP smoke failed: ${mcpStderr}`, { cause });
} finally {
await client.close();
await transport.close();
}
}