Files
rea/tests/boundary/mcp/absoluteLocalPathSchema.test.ts
morluto 0e83d92c58 fix(mcp): advertise existing absolute path constraints
Reuse the schema-visible host path owner across session, firmware, managed, browser, Electron and Evidence-file inputs. Keep existing runtime and CLI path semantics.

Refs #5493.
2026-10-20 19:54:14 +08:00

225 lines
6.4 KiB
TypeScript

import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { Ajv2020 } from "ajv/dist/2020.js";
import { resolve } from "node:path";
import { expect, it } from "vitest";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
import { createServer } from "../../../src/server/createServer.js";
import { silentLogger } from "../../../src/logger.js";
interface PathInputCase {
readonly name: string;
readonly field: string;
readonly valid: Readonly<Record<string, unknown>>;
readonly invalid: Readonly<Record<string, unknown>>;
}
const createMcp = async (firmwareCalls: string[]) => {
const session = createTestBinarySession(() => {
throw new Error("Invalid path input must not acquire a binary provider");
});
const server = createServer(
{ kind: "session", session },
{
logger: silentLogger,
firmwareAnalysis: {
execute: async (request) => {
firmwareCalls.push(request.operation);
throw new Error("Invalid path input reached firmware analysis");
},
close: async () => {},
},
},
);
const client = new Client({ name: "absolute-path-schema", version: "1" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
const close = async (): Promise<void> => {
const failures: unknown[] = [];
for (const release of [
() => client.close(),
() => server.close(),
() => session.close(),
]) {
try {
await release();
} catch (cause) {
failures.push(cause);
}
}
if (failures.length > 0)
throw new AggregateError(failures, "MCP path fixture cleanup failed");
};
try {
await server.connect(serverTransport);
await client.connect(clientTransport);
return { client, close };
} catch (cause: unknown) {
await close();
throw cause;
}
};
const inputCases = (
absolute: string,
relative: string,
): readonly PathInputCase[] => {
const pathCase = (
name: string,
field: string,
valid: Readonly<Record<string, unknown>>,
invalid: Readonly<Record<string, unknown>>,
): PathInputCase => ({ name, field, valid, invalid });
return [
pathCase("open_binary", "path", { path: absolute }, { path: relative }),
pathCase(
"open_binary",
"snapshot_path",
{ path: absolute, snapshot_path: absolute },
{ path: absolute, snapshot_path: relative },
),
pathCase(
"close_binary",
"snapshot_path",
{ snapshot_path: absolute },
{ snapshot_path: relative },
),
pathCase(
"export_evidence_bundle",
"path",
{ path: absolute },
{ path: relative },
),
pathCase(
"binary_session",
"expected_server_path",
{ expected_server_path: absolute },
{ expected_server_path: relative },
),
...[
"inspect_managed_artifact",
"inspect_managed_members",
"inspect_managed_native_boundaries",
].map((name) =>
pathCase(name, "path", { path: absolute }, { path: relative }),
),
pathCase(
"inspect_firmware_regions",
"path",
{ path: absolute },
{ path: relative },
),
pathCase(
"extract_firmware",
"path",
{ path: absolute, output_directory: absolute },
{ path: relative, output_directory: absolute },
),
pathCase(
"extract_firmware",
"output_directory",
{ path: absolute, output_directory: absolute },
{ path: absolute, output_directory: relative },
),
pathCase(
"inspect_analysis_view",
"source.path",
{
source: { kind: "evidence-file", path: absolute },
view: { kind: "summary" },
},
{
source: { kind: "evidence-file", path: relative },
view: { kind: "summary" },
},
),
pathCase(
"capture_browser_scenario",
"browser.executable_path",
{
browser: { mode: "launch", executable_path: absolute },
start_url: { url: "https://example.test/" },
actions: [
{ step_id: "wait", action: "wait_for_timeout", duration_ms: 1 },
],
},
{
browser: { mode: "launch", executable_path: relative },
start_url: { url: "https://example.test/" },
actions: [
{ step_id: "wait", action: "wait_for_timeout", duration_ms: 1 },
],
},
),
...["executable_path", "application_path", "application_root"].map(
(field) =>
pathCase(
"capture_electron_scenario",
field,
{
executable_path: absolute,
application_path: absolute,
[field]: absolute,
},
{
executable_path: absolute,
application_path: absolute,
[field]: relative,
},
),
),
];
};
it("advertises host-absolute path constraints after SDK schema conversion", async () => {
const firmwareCalls: string[] = [];
const { client, close } = await createMcp(firmwareCalls);
try {
const listed = await client.listTools();
const ajv = new Ajv2020({ strict: false, validateFormats: false });
const absolute = resolve("rea-absolute-input");
const relative = "relative-input";
for (const { name, field, valid, invalid } of inputCases(
absolute,
relative,
)) {
const schema = listed.tools.find(
(tool) => tool.name === name,
)?.inputSchema;
if (schema === undefined)
throw new Error(`Missing advertised tool ${name}`);
const validate = ajv.compile(schema);
expect(validate(invalid), `${name}.${field}`).toBe(false);
expect(
validate(valid),
`${name}.${field}: ${JSON.stringify(validate.errors)}`,
).toBe(true);
}
expect(firmwareCalls).toEqual([]);
} finally {
await close();
}
});
it("rejects relative paths through in-memory MCP handlers", async () => {
const firmwareCalls: string[] = [];
const { client, close } = await createMcp(firmwareCalls);
try {
for (const { name, field, invalid } of inputCases(
resolve("rea-absolute-input"),
"relative-input",
)) {
const result = await client.callTool({ name, arguments: invalid });
expect(result.isError, `${name}.${field}`).toBe(true);
expect(JSON.stringify(result), `${name}.${field}`).toContain("absolute");
}
expect(firmwareCalls).toEqual([]);
} finally {
await close();
}
});