157 lines
5.0 KiB
JavaScript
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/);
|
|
});
|
|
});
|