diff --git a/apps/server/spec/etapi/mcp.spec.ts b/apps/server/spec/etapi/mcp.spec.ts new file mode 100644 index 0000000000..e8b95954e5 --- /dev/null +++ b/apps/server/spec/etapi/mcp.spec.ts @@ -0,0 +1,143 @@ +import { Application } from "express"; +import { beforeAll, describe, expect, it } from "vitest"; +import supertest from "supertest"; +import { createNote, login } from "./utils.js"; +import config from "../../src/services/config.js"; +import optionService from "../../src/services/options.js"; +import cls from "../../src/services/cls.js"; + +let app: Application; +let token: string; + +const USER = "etapi"; +const MCP_ACCEPT = "application/json, text/event-stream"; + +/** Builds a JSON-RPC 2.0 request body for MCP. */ +function jsonRpc(method: string, params?: Record, id: number = 1) { + return { jsonrpc: "2.0", id, method, params }; +} + +/** Parses the JSON-RPC response from an SSE response text. */ +function parseSseResponse(text: string) { + const dataLine = text.split("\n").find(line => line.startsWith("data: ")); + if (!dataLine) { + throw new Error(`No SSE data line found in response: ${text}`); + } + return JSON.parse(dataLine.slice("data: ".length)); +} + +function mcpPost(app: Application) { + return supertest(app) + .post("/mcp") + .set("Accept", MCP_ACCEPT) + .set("Content-Type", "application/json"); +} + +function setOption(name: Parameters[0], value: string) { + cls.init(() => optionService.setOption(name, value)); +} + +describe("mcp", () => { + beforeAll(async () => { + config.General.noAuthentication = false; + const buildApp = (await (import("../../src/app.js"))).default; + app = await buildApp(); + token = await login(app); + }); + + describe("option gate", () => { + it("rejects requests when mcpEnabled is false", async () => { + setOption("mcpEnabled", "false"); + + const response = await mcpPost(app) + .send(jsonRpc("initialize")) + .expect(403); + + expect(response.body.error).toContain("disabled"); + }); + + it("accepts requests when mcpEnabled is true", async () => { + setOption("mcpEnabled", "true"); + + const response = await mcpPost(app) + .send(jsonRpc("initialize", { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "test", version: "1.0.0" } + })); + + expect(response.status).not.toBe(403); + }); + }); + + describe("protocol", () => { + beforeAll(() => { + setOption("mcpEnabled", "true"); + }); + + it("initializes and returns server capabilities", async () => { + const response = await mcpPost(app) + .send(jsonRpc("initialize", { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "test", version: "1.0.0" } + })) + .expect(200); + + const body = parseSseResponse(response.text); + expect(body.result.serverInfo.name).toBe("trilium-notes"); + expect(body.result.capabilities.tools).toBeDefined(); + }); + + it("lists available tools", async () => { + const response = await mcpPost(app) + .send(jsonRpc("tools/list")) + .expect(200); + + const body = parseSseResponse(response.text); + const toolNames: string[] = body.result.tools.map((t: { name: string }) => t.name); + expect(toolNames).toContain("search_notes"); + expect(toolNames).toContain("read_note"); + expect(toolNames).toContain("create_note"); + }); + }); + + describe("tools", () => { + let noteId: string; + + beforeAll(async () => { + setOption("mcpEnabled", "true"); + noteId = await createNote(app, token, "MCP test note content"); + }); + + it("searches for notes", async () => { + const response = await mcpPost(app) + .send(jsonRpc("tools/call", { + name: "search_notes", + arguments: { query: "MCP test note content" } + })) + .expect(200); + + const body = parseSseResponse(response.text); + expect(body.result).toBeDefined(); + const content = body.result.content; + expect(content.length).toBeGreaterThan(0); + expect(content[0].text).toContain(noteId); + }); + + it("reads a note by ID", async () => { + const response = await mcpPost(app) + .send(jsonRpc("tools/call", { + name: "read_note", + arguments: { noteId } + })) + .expect(200); + + const body = parseSseResponse(response.text); + expect(body.result).toBeDefined(); + const parsed = JSON.parse(body.result.content[0].text); + expect(parsed.noteId).toBe(noteId); + expect(parsed.content).toContain("MCP test note content"); + }); + }); +});