195 lines
6.2 KiB
JavaScript
195 lines
6.2 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 MCPCompatibilityLayer = require("../../../utils/MCP");
|
|
const MCPHypervisor = require("../../../utils/MCP/hypervisor");
|
|
|
|
const GOOD_TOOL = {
|
|
name: "echo",
|
|
description: "Echoes input back",
|
|
inputSchema: { type: "object", properties: { text: { type: "string" } } },
|
|
};
|
|
|
|
// A tool whose outputSchema contains a $ref Ajv cannot resolve. The MCP
|
|
// SDK eagerly compiles outputSchema validators inside client.listTools(),
|
|
// so listing this tool throws - the exact failure from issue #5917 that
|
|
// used to hide the entire server list.
|
|
const BAD_REF_TOOL = {
|
|
name: "bad-ref",
|
|
description: "Advertises an unresolvable outputSchema $ref",
|
|
inputSchema: { type: "object", properties: {} },
|
|
outputSchema: {
|
|
type: "object",
|
|
$ref: "#/definitions/does-not-exist",
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Minimal in-process SSE MCP server advertising the given tools. GET opens
|
|
* the event stream, POST /messages carries the JSON-RPC messages.
|
|
*/
|
|
function startSSETestServer(tools) {
|
|
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))
|
|
);
|
|
}
|
|
|
|
function stopSSETestServer(httpServer) {
|
|
if (typeof httpServer.closeAllConnections === "function")
|
|
httpServer.closeAllConnections();
|
|
httpServer.close();
|
|
}
|
|
|
|
describe("MCPCompatibilityLayer.servers", () => {
|
|
let storageDir;
|
|
let mcpLayer;
|
|
|
|
beforeEach(() => {
|
|
storageDir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-compat-"));
|
|
process.env.STORAGE_DIR = storageDir;
|
|
MCPCompatibilityLayer._instance = undefined;
|
|
MCPHypervisor._instance = undefined;
|
|
jest.spyOn(console, "log").mockImplementation(() => {});
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (mcpLayer) mcpLayer.pruneMCPServers();
|
|
MCPCompatibilityLayer._instance = undefined;
|
|
MCPHypervisor._instance = undefined;
|
|
mcpLayer = undefined;
|
|
delete process.env.STORAGE_DIR;
|
|
fs.rmSync(storageDir, { recursive: true, force: true });
|
|
jest.restoreAllMocks();
|
|
});
|
|
|
|
function writeMCPConfig(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)
|
|
);
|
|
}
|
|
|
|
it("keeps a server visible with its error when listTools throws on an unresolvable outputSchema $ref (issue #5917)", async () => {
|
|
const goodServer = await startSSETestServer([GOOD_TOOL]);
|
|
const badServer = await startSSETestServer([BAD_REF_TOOL]);
|
|
|
|
try {
|
|
writeMCPConfig({
|
|
"good-server": {
|
|
url: `http://localhost:${goodServer.address().port}`,
|
|
},
|
|
"bad-ref-server": {
|
|
url: `http://localhost:${badServer.address().port}`,
|
|
},
|
|
});
|
|
mcpLayer = new MCPCompatibilityLayer();
|
|
const servers = await mcpLayer.servers();
|
|
|
|
expect(servers).toHaveLength(2);
|
|
|
|
const good = servers.find((s) => s.name === "good-server");
|
|
expect(good.running).toBe(true);
|
|
expect(good.tools.map((t) => t.name)).toEqual(["echo"]);
|
|
expect(good.error).toBeNull();
|
|
|
|
const bad = servers.find((s) => s.name === "bad-ref-server");
|
|
expect(bad.running).toBe(false);
|
|
expect(bad.tools).toEqual([]);
|
|
expect(bad.error).toMatch(/resolve reference/);
|
|
} finally {
|
|
stopSSETestServer(goodServer);
|
|
stopSSETestServer(badServer);
|
|
}
|
|
});
|
|
|
|
it("keeps a server visible with its error when ping fails after boot", async () => {
|
|
writeMCPConfig({});
|
|
mcpLayer = new MCPCompatibilityLayer();
|
|
|
|
// Simulate a server that connected at boot but has since died - its
|
|
// ping() rejects. bootMCPServers() skips booting since mcps is populated.
|
|
mcpLayer.mcps = {
|
|
"dead-server": {
|
|
ping: () => Promise.reject(new Error("fetch failed")),
|
|
transport: { close: () => {} },
|
|
close: () => {},
|
|
},
|
|
"alive-server": {
|
|
ping: () => Promise.resolve(true),
|
|
listTools: () => Promise.resolve({ tools: [GOOD_TOOL] }),
|
|
transport: { close: () => {} },
|
|
close: () => {},
|
|
},
|
|
};
|
|
mcpLayer.mcpLoadingResults = {
|
|
"dead-server": { status: "success", message: "connected" },
|
|
"alive-server": { status: "success", message: "connected" },
|
|
};
|
|
|
|
const servers = await mcpLayer.servers();
|
|
|
|
expect(servers).toHaveLength(2);
|
|
|
|
const dead = servers.find((s) => s.name === "dead-server");
|
|
expect(dead.running).toBe(false);
|
|
expect(dead.error).toBe("fetch failed");
|
|
|
|
const alive = servers.find((s) => s.name === "alive-server");
|
|
expect(alive.running).toBe(true);
|
|
expect(alive.tools.map((t) => t.name)).toEqual(["echo"]);
|
|
});
|
|
});
|