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("attachments", () => { const png = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.alloc(32)]); const upload = (name: string, bytes: Buffer, token = T.admin) => fetch(`${base}/api/attachments?folder=Posts&name=${encodeURIComponent(name)}`, { method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/octet-stream" }, body: new Uint8Array(bytes), }); it("adds an image under a safe, unused name", async () => { const a = (await (await upload("My Photo!.PNG", png)).json()) as { name: string }; const b = (await (await upload("my photo.png", png)).json()) as { name: string }; expect(a.name).toBe("my-photo.png"); expect(b.name).toBe("my-photo-2.png"); expect((await get("/attachments/my-photo-2.png", T.reader)).status).toBe(200); }); it("turns away other files and other roles", async () => { expect((await upload("x.svg", Buffer.from(""))).status).toBe(400); expect((await upload("x.png", Buffer.from("not a png"))).status).toBe(400); expect((await upload("x.png", png, T.agent)).status).toBe(403); expect((await get("/api/attachments", T.reader)).status).toBe(403); }); it("lists images with the notes that use them, and deletes only unused ones", async () => { await post_("/api/changes", T.admin, { op: "create", data: post("With a photo"), body: "![[my-photo.png]]" }); const list = (await (await get("/api/attachments", T.admin)).json()) as { name: string; folder: string; usedBy: { title: string }[] }[]; expect(list.find((a) => a.name === "my-photo.png")).toMatchObject({ folder: "Posts", usedBy: [{ title: "With a photo" }] }); expect((await post_("/api/attachments/delete", T.admin, { name: "my-photo.png" })).status).toBe(409); expect((await post_("/api/attachments/delete", T.admin, { name: "my-photo-2.png" })).status).toBe(200); expect((await get("/attachments/my-photo-2.png", T.reader)).status).toBe(404); }); }); 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(); }); });