Files
rea/tests/boundary/mcp/idaTransport.test.ts
T

179 lines
6.3 KiB
TypeScript

import { createServer } from "node:http";
import { afterEach, expect, it } from "vitest";
import { z } from "zod";
import { createIdaMcpConnection } from "../../../src/ida/IdaMcpConnection.js";
import type { IdaMcpConnection } from "../../../src/ida/IdaMcpConnection.js";
const cleanups: (() => Promise<void>)[] = [];
afterEach(async () => {
for (const cleanup of cleanups.splice(0).reverse()) await cleanup();
});
const requestSchema = z.object({
id: z.union([z.string(), z.number()]).optional(),
method: z.string(),
params: z
.object({
protocolVersion: z.string().optional(),
cursor: z.string().optional(),
})
.passthrough()
.optional(),
});
const fixture = async (redirectUrl?: string, cycle = false, native = false) => {
const methods: string[] = [];
const headers: (string | undefined)[] = [];
const server = createServer(async (request, response) => {
if (redirectUrl !== undefined) {
response.writeHead(307, { Location: redirectUrl });
response.end();
return;
}
if (request.method !== "POST") {
response.writeHead(405);
response.end();
return;
}
let body = "";
for await (const chunk of request) body += String(chunk);
const input = requestSchema.parse(JSON.parse(body));
methods.push(input.method);
headers.push(request.headers.authorization);
if (input.id === undefined) {
response.writeHead(202);
response.end();
return;
}
const result =
input.method === "initialize"
? {
protocolVersion: input.params?.protocolVersion,
capabilities: { tools: {}, ...(native ? { resources: {} } : {}) },
serverInfo: {
name: "ida-transport-fixture",
version: "observed-server-version",
},
}
: input.method === "tools/list"
? {
tools: [
{
name: input.params?.cursor === undefined ? "first" : "second",
inputSchema: { type: "object", properties: {} },
},
],
...(input.params?.cursor === undefined
? { nextCursor: "next-page" }
: cycle
? {
nextCursor:
input.params.cursor === "next-page"
? "other-page"
: "next-page",
}
: {}),
}
: input.method === "resources/read"
? {
contents: [
{
uri: "ida://idb/metadata",
mimeType: "application/json",
text: JSON.stringify({
path: "/fixture/input.i64",
sha256: "a".repeat(64),
base: "0x1000",
module: "input",
}),
},
],
}
: native
? {
content: [
{
type: "text",
text: JSON.stringify([{ observed: true }]),
},
],
structuredContent: { result: [], _output_truncated: true },
}
: { content: [], structuredContent: { observed: true } };
response.writeHead(200, { "Content-Type": "application/json" });
response.end(JSON.stringify({ jsonrpc: "2.0", id: input.id, result }));
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
cleanups.push(
() =>
new Promise<void>((resolve, reject) => {
server.close((error) =>
error === undefined ? resolve() : reject(error),
);
server.closeAllConnections();
}),
);
const address = server.address();
if (address === null || typeof address === "string")
throw new Error("Missing HTTP fixture address");
const connection: IdaMcpConnection = createIdaMcpConnection(
{
url: `http://127.0.0.1:${address.port}/mcp`,
headers: { Authorization: "Bearer transport-fixture-secret" },
mode: native ? "attached" : "headless",
...(native ? { protocol: "native" as const } : {}),
timeoutMs: 2000,
},
{},
);
cleanups.push(() => connection.close());
return {
connection,
methods,
headers,
url: `http://127.0.0.1:${address.port}/mcp`,
};
};
it("uses the pinned SDK for a local Streamable HTTP handshake, paged discovery, structured results, and configured authentication", async () => {
const { connection, methods, headers } = await fixture();
expect(await connection.connect()).toEqual(["first", "second"]);
expect(connection.serverInfo()).toEqual({
name: "ida-transport-fixture",
version: "observed-server-version",
});
expect(await connection.call("first", {})).toEqual({ observed: true });
expect(methods).toContain("notifications/initialized");
expect(methods.filter((name) => name === "tools/list")).toHaveLength(2);
expect(
headers.every((value) => value === "Bearer transport-fixture-secret"),
).toBe(true);
});
it("rejects redirects instead of forwarding transport credentials to another endpoint", async () => {
const destination = await fixture();
const { connection } = await fixture(destination.url);
await expect(connection.connect()).rejects.toThrow();
expect(destination.headers).toEqual([]);
});
it("rejects nonterminating SDK pagination instead of returning a partial tool catalog", async () => {
const { connection, methods } = await fixture(undefined, true);
await expect(connection.connect()).rejects.toThrow(
"pagination did not terminate",
);
expect(
methods.filter((method) => method === "tools/list").length,
).toBeGreaterThan(2);
});
it("reads native metadata resources and complete JSON text through the real SDK transport", async () => {
const { connection, methods } = await fixture(undefined, false, true);
expect(await connection.connect()).toEqual(["first", "second"]);
expect(await connection.call("first", {})).toEqual([{ observed: true }]);
expect(await connection.readResource?.("ida://idb/metadata")).toMatchObject({
path: "/fixture/input.i64",
sha256: "a".repeat(64),
});
await expect(connection.readResource?.("ida://other")).rejects.toThrow();
expect(methods).toContain("resources/read");
});