import type { AddressInfo } from "node:net"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { parseTokens } from "../src/config.ts"; import { createHttpServer } from "../src/http.ts"; import { book, post, rig, type Rig } from "./helpers.ts"; const T = { reader: "r".repeat(24), agent: "a".repeat(24), editor: "e".repeat(24), admin: "d".repeat(24) }; let r: Rig; let base: string; let server: ReturnType; beforeAll(async () => { r = rig(); r.put({ type: "status-kinds", title: "Status kinds", publish: true, kinds: [{ label: "Reading", icon: "book" }] }); r.put(book("Mistborn"), "About [[Elantris]]."); r.put(book("Elantris")); r.put(post("Secret draft", { publish: false })); r.service.scan(); server = createHttpServer(r.service, { tokens: parseTokens(Object.entries(T).map(([k, v]) => `${k}:${v}`).join(",")) }); await new Promise((ok) => server.listen(0, "127.0.0.1", ok)); base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; }); afterAll(() => { server.close(); r.done(); }); const get = (path: string, token?: string) => fetch(base + path, { headers: token ? { authorization: `Bearer ${token}` } : {} }); const post_ = (path: string, token: string, body: unknown) => fetch(base + path, { method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: JSON.stringify(body) }); describe("HTTP API", () => { it("needs a token for everything but health", async () => { expect((await get("/health")).status).toBe(200); expect((await get("/api/notes")).status).toBe(401); expect((await get("/api/notes", "wrong".repeat(6))).status).toBe(401); }); it("shows readers published notes only", async () => { const reader = (await (await get("/api/notes?type=post", T.reader)).json()) as unknown[]; const editor = (await (await get("/api/notes?type=post", T.editor)).json()) as unknown[]; expect(reader).toHaveLength(0); expect(editor).toHaveLength(1); expect((await get("/api/note?path=Posts/Secret%20draft.md", T.reader)).status).toBe(404); }); it("finds a note by slug with its backlinks", async () => { const n = (await (await get("/api/note?type=library-item&slug=elantris", T.reader)).json()) as { title: string; backlinks: { title: string }[] }; expect(n.title).toBe("Elantris"); expect(n.backlinks.map((b) => b.title)).toEqual(["Mistborn"]); }); it("keeps readers from writing and agents from reviewing", async () => { expect((await post_("/api/changes", T.reader, { op: "create", data: book("X") })).status).toBe(403); expect((await post_("/api/held/1/accept", T.agent, {})).status).toBe(403); }); it("applies a change and reports rejections plainly", async () => { const ok = await post_("/api/changes", T.editor, { op: "create", data: book("Warbreaker") }); expect(ok.status).toBe(200); const bad = await post_("/api/changes", T.editor, { op: "create", data: { type: "library-item", title: "Y" } }); expect(bad.status).toBe(422); }); }); describe("sync reports", () => { it("records a run and shows it in the status", async () => { expect((await post_("/api/syncs", T.agent, { job: "personalreader", ok: true, counts: { updated: 3 } })).status).toBe(200); expect((await post_("/api/syncs", T.agent, { job: "Bad Name", ok: true })).status).toBe(400); const status = (await (await get("/api/status", T.admin)).json()) as { syncs: { job: string; lastOkAt: string }[] }; expect(status.syncs[0]).toMatchObject({ job: "personalreader" }); expect(status.syncs[0]!.lastOkAt).toBeTruthy(); expect((await get("/api/status", T.reader)).status).toBe(403); }); }); describe("MCP server", () => { it("lets an agent search, post a status and draft a post, but not publish it", async () => { const client = new Client({ name: "test", version: "1.0.0" }); await client.connect( new StreamableHTTPClientTransport(new URL(`${base}/mcp`), { requestInit: { headers: { authorization: `Bearer ${T.agent}` } } }), ); const tools = (await client.listTools()).tools.map((t) => t.name).sort(); expect(tools).toEqual(["add_library_item", "draft_post", "get_note", "get_schema", "list_held", "search", "set_status", "update_fields"]); const found = await client.callTool({ name: "search", arguments: { query: "mistborn" } }); expect(JSON.stringify(found.content)).toContain("Library/Books/Mistborn.md"); const status = await client.callTool({ name: "set_status", arguments: { kind: "reading", title: "Mistborn", subject: "Mistborn" } }); expect(status.isError).toBeFalsy(); const badKind = await client.callTool({ name: "set_status", arguments: { kind: "Juggling", title: "Balls" } }); expect(badKind.isError).toBe(true); const draft = await client.callTool({ name: "draft_post", arguments: { title: "Agent draft", topic: "Making", summary: "S.", body: "Text." }, }); expect(draft.isError).toBeFalsy(); const publish = await client.callTool({ name: "update_fields", arguments: { path: "Posts/Agent draft.md", fields: { publish: true } } }); expect(JSON.stringify(publish.content)).toContain("agent-publish"); expect(r.store.get("Posts/Agent draft.md")!.publish).toBe(false); await client.close(); }); });