Add the content service and MCP server (phase 2)

services/content watches the vault's Site folder, runs every change through
fixed rules, keeps the last accepted version of each note with its history,
and serves one write API. The HTTP API, the MCP server for agents, and the
admin page and editing tools to come all write through it.

- SQLite store with history, review queue, redirects, links and full-text search
- Rules: invalid, misplaced, duplicate name or slug, text loss, secrets,
  agent contact details, agent edits, agent publishing, agent batches
- Watcher with debounce and a periodic full scan
- HTTP API with reader, agent, editor and admin tokens
- MCP tools: search, get_note, get_schema, list_held, set_status,
  add_library_item, update_fields, draft_post
- Tests, including taking in all 545 migrated notes with nothing held

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
This commit is contained in:
Claude
2026-10-11 19:10:38 +00:00
parent a2f723ca2d
commit 3f646048a2
23 changed files with 3482 additions and 38 deletions
+96
View File
@@ -0,0 +1,96 @@
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<typeof createHttpServer>;
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<void>((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("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();
});
});