import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import type { CallToolResult } from "@modelcontextprotocol/server"; import { afterEach, describe, expect, it } from "vitest"; import { z } from "zod"; import { BinarySession } from "../src/application/BinarySession.js"; import type { AnalysisClient } from "../src/application/AnalysisProvider.js"; import { ok } from "../src/domain/result.js"; import { createServer } from "../src/server/createServer.js"; import { evidenceBundleSchema } from "../src/domain/evidenceBundle.js"; const resources: Array<{ close(): Promise }> = []; 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; }); describe("target-free MCP lifecycle", () => { it("reports no-target, opens, analyzes, switches, reports status, and closes", async () => { directory = await mkdtemp(join(tmpdir(), "rea-mcp-session-")); const first = join(directory, "first.hop"); const second = join(directory, "second.hop"); await writeFile(first, "one"); await writeFile(second, "two"); const closed: string[] = []; const session = new BinarySession((target) => client(target.path, closed)); const server = createServer(session, session); const mcp = new Client({ name: "session-test", version: "1.0.0" }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); resources.push(mcp, server); await server.connect(serverTransport); await mcp.connect(clientTransport); const before = await mcp.callTool({ name: "current_document", arguments: {}, }); expect(before.isError).toBe(true); expect(text(before)).toContain("NoBinaryOpenError"); expect((await mcp.listTools()).tools).toHaveLength(50); const deniedCapture = await mcp.callTool({ name: "capture_process_scenario", arguments: { approved: true, executable: "/bin/sh", working_directory: "/tmp", }, }); expect(deniedCapture.isError).toBe(true); expect(text(deniedCapture)).toContain("process capture is disabled"); expect( (await mcp.callTool({ name: "open_binary", arguments: { path: first } })) .isError, ).not.toBe(true); expect( text(await mcp.callTool({ name: "current_document", arguments: {} })), ).toContain("first.hop"); const exported = await mcp.callTool({ name: "export_evidence_bundle", arguments: {}, }); const bundle = evidenceBundleSchema.parse(structured(exported).result); expect(bundle.records).toHaveLength(1); expect( structured( await mcp.callTool({ name: "import_evidence_bundle", arguments: { bundle }, }), ).result, ).toEqual({ imported: 0, total: 1 }); await mcp.callTool({ name: "open_binary", arguments: { path: second } }); expect(closed.some((path) => path.endsWith("first.hop"))).toBe(true); expect( text(await mcp.callTool({ name: "binary_session", arguments: {} })), ).toContain("second.hop"); await mcp.callTool({ name: "close_binary", arguments: {} }); expect( text(await mcp.callTool({ name: "binary_session", arguments: {} })), ).toContain('"open": false'); }); }); const client = (path: string, closed: string[]): AnalysisClient => ({ execute: (name) => Promise.resolve( ok(name === "health" ? null : name === "current_document" ? path : null), ), close: () => { closed.push(path); return Promise.resolve(); }, }); const text = (result: CallToolResult): string => { const content = result.content.find((item) => item.type === "text"); if (content?.type !== "text") throw new Error("missing text result"); return content.text; }; const structured = (result: CallToolResult): Record => { if ( typeof result.structuredContent !== "object" || result.structuredContent === null ) throw new Error("missing structured result"); return z.record(z.string(), z.unknown()).parse(result.structuredContent); };