Files
homarr/apps/nextjs/src/app/api/mcp/_protocol.spec.ts
T

166 lines
5.4 KiB
TypeScript

// @vitest-environment node
import { describe, expect, test, vi } from "vitest";
import { z } from "zod/v4";
import type { McpTool } from "@homarr/api/mcp";
import { createMcpProtocolHandler } from "./_protocol";
const modernMetadata = {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": { name: "homarr-test", version: "1.0.0" },
"io.modelcontextprotocol/clientCapabilities": {},
};
const createRequest = (method: string, params: Record<string, unknown> = {}, name = "homarr") =>
new Request("http://homarr.test/api/mcp", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
"Mcp-Method": method,
"Mcp-Name": name,
},
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method,
params: { ...params, _meta: modernMetadata },
}),
});
const parseResponse = async (response: Response) => {
expect(response.status).toBe(200);
const body = await response.text();
const json = response.headers.get("Content-Type")?.includes("text/event-stream")
? body
.split("\n")
.find((line) => line.startsWith("data: "))
?.slice(6)
: body;
expect(json).toBeTruthy();
return JSON.parse(json ?? "") as {
result: Record<string, unknown>;
};
};
const echoValidator = z.object({ message: z.string() });
const echoTool: McpTool = {
name: "echo",
description: "Echo a message",
type: "query",
pathInRouter: ["echo"],
inputValidator: echoValidator,
inputSchema: z.toJSONSchema(echoValidator),
};
const createTestHandler = () => {
const echo = vi.fn(async (input: unknown) => input);
const handler = createMcpProtocolHandler({
caller: { echo },
tools: [echoTool],
version: "1.0.0",
instructions: "Test server",
formatToolError: () => "Tool failed",
});
return { echo, handler };
};
describe("MCP v2 protocol", () => {
test("discovers the stateless 2026-07-28 server", async () => {
const { handler } = createTestHandler();
const response = await parseResponse(await handler.fetch(createRequest("server/discover")));
expect(response.result.supportedVersions).toEqual(["2026-07-28"]);
expect(response.result).toMatchObject({ resultType: "complete", cacheScope: "private" });
});
test("returns a deterministic, privately cacheable tool catalog", async () => {
const { handler } = createTestHandler();
const response = await parseResponse(await handler.fetch(createRequest("tools/list")));
expect(response.result).toMatchObject({
resultType: "complete",
ttlMs: 300_000,
cacheScope: "private",
tools: [{ name: "echo", description: "Echo a message" }],
});
});
test("validates and calls Homarr procedures without a protocol session", async () => {
const { echo, handler } = createTestHandler();
const response = await parseResponse(
await handler.fetch(createRequest("tools/call", { name: "echo", arguments: { message: "hello" } }, "echo")),
);
expect(echo).toHaveBeenCalledWith({ message: "hello" });
expect(response.result.content).toEqual([{ type: "text", text: '{"message":"hello"}' }]);
});
test("passes the tool name to safe error formatting", async () => {
const formatToolError = vi.fn((_error: unknown, toolName: string) => `${toolName} input was invalid`);
const handler = createMcpProtocolHandler({
caller: {
echo: vi.fn(async () => {
throw new Error("private details");
}),
},
tools: [echoTool],
version: "1.0.0",
instructions: "Test server",
formatToolError,
});
const response = await parseResponse(
await handler.fetch(createRequest("tools/call", { name: "echo", arguments: { message: "hello" } }, "echo")),
);
expect(formatToolError).toHaveBeenCalledWith(expect.any(Error), "echo");
expect(response.result).toMatchObject({
isError: true,
content: [{ type: "text", text: '{"error":"echo input was invalid"}' }],
});
});
test("keeps stateless compatibility with 2025 Streamable HTTP clients", async () => {
const { handler } = createTestHandler();
const request = new Request("http://homarr.test/api/mcp", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
},
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: "2025-03-26",
capabilities: {},
clientInfo: { name: "legacy-test", version: "1.0.0" },
},
}),
});
const initializeResponse = await handler.fetch(request);
expect(initializeResponse.headers.has("Mcp-Session-Id")).toBe(false);
const response = await parseResponse(initializeResponse);
expect(response.result.protocolVersion).toBe("2025-03-26");
const listResponse = await handler.fetch(
new Request("http://homarr.test/api/mcp", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
},
body: JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list" }),
}),
);
expect(listResponse.headers.has("Mcp-Session-Id")).toBe(false);
expect((await parseResponse(listResponse)).result.tools).toEqual([expect.objectContaining({ name: "echo" })]);
});
});