mirror of
https://github.com/morluto/rea.git
synced 2026-11-15 11:13:21 +00:00
* test(mcp): open stdio clients through the shared local-tools fixture Twelve files built their own StdioClientTransport and Client for `rea mcp`. Those clients never listed tools, so the SDK did not validate structured results against advertised output schemas, and each file repeated transport setup, stderr draining and close ordering. Use connectLocalToolsMcp, which lists tools, validates replies, silences logging, leaves deep providers unconfigured and owns cleanup. Per-test clients register test-scoped cleanup; module-level clients collect cleanup for afterAll. * test(mcp): keep failed file-scoped MCP cleanup owned The three module-level clients removed every cleanup callback before running them, so a failed cleanup lost its only handle. Add fileLocalToolsMcp, which connects before the file's tests and removes each cleanup only after it succeeds, and use it in those files.
122 lines
4.1 KiB
TypeScript
122 lines
4.1 KiB
TypeScript
import {
|
|
Client,
|
|
type CallToolRequestOptions,
|
|
type ClientOptions,
|
|
} from "@modelcontextprotocol/client";
|
|
import {
|
|
StdioClientTransport,
|
|
type StdioServerParameters,
|
|
} from "@modelcontextprotocol/client/stdio";
|
|
import { closeVerifierResources } from "../../scripts/lib/verifier-cleanup.mjs";
|
|
import { fileURLToPath } from "node:url";
|
|
import { afterAll, beforeAll, expect, onTestFinished } from "vitest";
|
|
import { createMcpSchemaValidation } from "./mcpSchemaValidation.js";
|
|
|
|
/** Default to native tools while preserving explicit caller configuration. */
|
|
export const localToolsMcpEnvironment = (
|
|
environment: Readonly<Record<string, string>> = {},
|
|
): Record<string, string> => ({
|
|
PATH: process.env.PATH ?? "/usr/bin:/bin",
|
|
REA_LOG_LEVEL: "silent",
|
|
HOPPER_LAUNCHER_PATH: "/rea-unconfigured-deep-provider/hopper",
|
|
GHIDRA_INSTALL_DIR: process.execPath,
|
|
...environment,
|
|
});
|
|
|
|
/** Start the built production server without configuring deep binary providers. */
|
|
export async function connectLocalToolsMcp(
|
|
environment: Readonly<Record<string, string>> = {},
|
|
registerCleanup: (cleanup: () => Promise<void>) => void = onTestFinished,
|
|
transportOptions: Readonly<Pick<StdioServerParameters, "maxBufferSize">> = {},
|
|
jsonSchemaValidator?: NonNullable<ClientOptions["jsonSchemaValidator"]>,
|
|
) {
|
|
const transport = new StdioClientTransport({
|
|
...transportOptions,
|
|
command: process.execPath,
|
|
args: [
|
|
fileURLToPath(new URL("../../scripts/rea.mjs", import.meta.url)),
|
|
"mcp",
|
|
],
|
|
env: localToolsMcpEnvironment(environment),
|
|
stderr: "pipe",
|
|
});
|
|
const validation = createMcpSchemaValidation();
|
|
const client = new Client(
|
|
{ name: "local-tools-e2e", version: "1" },
|
|
{ jsonSchemaValidator: jsonSchemaValidator ?? validation },
|
|
);
|
|
registerCleanup(async () => {
|
|
await closeVerifierResources([
|
|
() => client.close(),
|
|
() => transport.close(),
|
|
() => validation.clear(),
|
|
]);
|
|
});
|
|
await client.connect(transport);
|
|
const tools = (await client.listTools()).tools;
|
|
// Catalog dialect/reference validity belongs to toolSchemaValidity and the
|
|
// compact-profile catalog tests. SDK callTool validates each successful reply.
|
|
const definitions = new Map(tools.map((tool) => [tool.name, tool]));
|
|
const call = async (
|
|
name: string,
|
|
arguments_: Record<string, unknown>,
|
|
options?: Pick<CallToolRequestOptions, "signal" | "onprogress">,
|
|
) => {
|
|
const definition = definitions.get(name);
|
|
const response = await client.callTool(
|
|
{ name, arguments: arguments_ },
|
|
{
|
|
...options,
|
|
...(definition === undefined ? {} : { toolDefinition: definition }),
|
|
},
|
|
);
|
|
// The SDK skips error payloads. A caller-supplied validator also retains
|
|
// the fixture's independent advertised-schema assertion.
|
|
if (
|
|
(response.isError === true || jsonSchemaValidator !== undefined) &&
|
|
response.structuredContent !== undefined &&
|
|
definition?.outputSchema !== undefined
|
|
) {
|
|
expect(
|
|
validation.assertions.validate(
|
|
definition.outputSchema,
|
|
response.structuredContent,
|
|
),
|
|
JSON.stringify(validation.assertions.errors),
|
|
).toBe(true);
|
|
}
|
|
return response;
|
|
};
|
|
return { client, call };
|
|
}
|
|
|
|
/**
|
|
* One production stdio MCP client for the calling test file. It connects
|
|
* before the file's tests. Teardown removes each cleanup only after it
|
|
* succeeds, so a failed cleanup stays owned.
|
|
*/
|
|
export const fileLocalToolsMcp = (
|
|
environment: Readonly<Record<string, string>> = {},
|
|
) => {
|
|
let connected: Awaited<ReturnType<typeof connectLocalToolsMcp>> | undefined;
|
|
const cleanups: Array<() => Promise<void>> = [];
|
|
beforeAll(async () => {
|
|
connected = await connectLocalToolsMcp(environment, (cleanup) => {
|
|
cleanups.push(cleanup);
|
|
});
|
|
});
|
|
afterAll(async () => {
|
|
for (let cleanup = cleanups.at(-1); cleanup; cleanup = cleanups.at(-1)) {
|
|
await cleanup();
|
|
cleanups.pop();
|
|
}
|
|
});
|
|
return {
|
|
get client() {
|
|
if (connected === undefined)
|
|
throw new Error("The file's MCP client is not connected");
|
|
return connected.client;
|
|
},
|
|
};
|
|
};
|