Files
2026-07-13 12:20:32 +08:00

157 lines
5.0 KiB
JavaScript

const path = require("path");
const fs = require("fs");
const os = require("os");
const http = require("http");
const {
Server: MCPTestServer,
} = require("@modelcontextprotocol/sdk/server/index.js");
const {
SSEServerTransport,
} = require("@modelcontextprotocol/sdk/server/sse.js");
const {
ListToolsRequestSchema,
} = require("@modelcontextprotocol/sdk/types.js");
// pkce-challenge (used by the MCP SDK's OAuth support, which these tests
// never exercise) is ESM-only under the hood and cannot be loaded by jest
// without --experimental-vm-modules.
jest.mock("pkce-challenge", () => ({
default: async () => ({ code_verifier: "", code_challenge: "" }),
}));
const MCPHypervisor = require("../../../utils/MCP/hypervisor");
/**
* Minimal in-process SSE MCP server so the hypervisor has something real
* to connect to. GET opens the event stream, POST /messages carries the
* JSON-RPC messages.
*/
function startSSETestServer() {
const transports = {};
const httpServer = http.createServer(async (req, res) => {
const url = new URL(req.url, "http://localhost");
if (req.method === "GET") {
const transport = new SSEServerTransport("/messages", res);
transports[transport.sessionId] = transport;
const mcpServer = new MCPTestServer(
{ name: "test-sse-server", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
mcpServer.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [],
}));
await mcpServer.connect(transport);
return;
}
if (req.method === "POST" && url.pathname === "/messages") {
const transport = transports[url.searchParams.get("sessionId")];
if (!transport) {
res.writeHead(400).end();
return;
}
let body = "";
req.on("data", (chunk) => (body += chunk));
req.on("end", () =>
transport.handlePostMessage(req, res, JSON.parse(body))
);
return;
}
res.writeHead(404).end();
});
return new Promise((resolve) =>
httpServer.listen(0, () => resolve(httpServer))
);
}
describe("MCPHypervisor server definition parsing & validation", () => {
let storageDir;
let hypervisor;
beforeEach(() => {
storageDir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-hypervisor-"));
process.env.STORAGE_DIR = storageDir;
MCPHypervisor._instance = undefined;
jest.spyOn(console, "log").mockImplementation(() => {});
});
afterEach(() => {
if (hypervisor) hypervisor.pruneMCPServers();
MCPHypervisor._instance = undefined;
hypervisor = undefined;
delete process.env.STORAGE_DIR;
fs.rmSync(storageDir, { recursive: true, force: true });
jest.restoreAllMocks();
});
function bootWithConfig(servers) {
fs.mkdirSync(path.join(storageDir, "plugins"), { recursive: true });
fs.writeFileSync(
path.join(storageDir, "plugins", "anythingllm_mcp_servers.json"),
JSON.stringify({ mcpServers: servers }, null, 2)
);
hypervisor = new MCPHypervisor();
return hypervisor.bootMCPServers();
}
it("assumes SSE for a url-only server definition (issue #5917)", async () => {
const sseServer = await startSSETestServer();
const port = sseServer.address().port;
try {
const results = await bootWithConfig({
"url-only": { url: `http://localhost:${port}` },
});
expect(results["url-only"].status).toBe("success");
} finally {
if (typeof sseServer.closeAllConnections === "function")
sseServer.closeAllConnections();
sseServer.close();
}
});
it("rejects an explicit unknown type value", async () => {
const results = await bootWithConfig({
"bogus-type": { type: "websocket", url: "http://localhost:1/mcp" },
});
expect(results["bogus-type"].status).toBe("failed");
expect(results["bogus-type"].message).toMatch(
/type must have sse or streamable value/
);
});
it("rejects an http type definition without a url", async () => {
const results = await bootWithConfig({
"missing-url": { type: "sse" },
});
expect(results["missing-url"].status).toBe("failed");
expect(results["missing-url"].message).toMatch(/missing required "url"/);
});
it("rejects an http type definition with an invalid url", async () => {
const results = await bootWithConfig({
"bad-url": { type: "streamable", url: "not a url" },
});
expect(results["bad-url"].status).toBe("failed");
expect(results["bad-url"].message).toMatch(/invalid URL/);
});
it("rejects a definition with neither command nor url", async () => {
const results = await bootWithConfig({
"empty-def": {},
});
expect(results["empty-def"].status).toBe("failed");
expect(results["empty-def"].message).toMatch(/command or url is required/);
});
it("rejects a stdio definition with non-array args", async () => {
const results = await bootWithConfig({
"bad-args": { command: "node", args: "not-an-array" },
});
expect(results["bad-args"].status).toBe("failed");
expect(results["bad-args"].message).toMatch(/args must be an array/);
});
});