179 lines
6.3 KiB
TypeScript
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");
|
|
});
|