/** * An MCP server over plain HTTP: `POST /mcp` takes one JSON-RPC 2.0 * message and answers with JSON (streamable HTTP without the stream — * nothing here needs to push). The tools are the actions, called with the * same identity the page gets, so an agent speaking MCP through the forge * is the person whose grant it carries, and a bare one is anonymous. */ import type { Database } from "bun:sqlite"; import { addNote, BODY_MAX, deleteNote, findNote, getNote, listNotes, openOrCreate, searchNotes, today, updateNote, type Result, } from "./actions.ts"; import { title } from "./env.ts"; import { fields } from "./http.ts"; import type { Who } from "./who.ts"; interface Tool { readonly name: string; readonly description: string; readonly inputSchema: Record; readonly run: ( db: Database, who: Who, args: Record, ) => Result; } const str = (v: unknown): string | undefined => (typeof v === "string" ? v : undefined); /** A note by `id`, or by `title` in the caller's vault. */ const which = (db: Database, who: Who, args: Record) => args.id !== undefined ? getNote(db, who, { id: Number(args.id) }) : findNote(db, who, { title: str(args.title) ?? "" }); const NOTE_REF = { id: { type: "integer", description: "The note's id." }, title: { type: "string", description: "Or its title (any case)." }, }; export const tools: readonly Tool[] = [ { name: "list_notes", description: "The titles in your vault (id, title, pinned, updated_at), pinned first then most recently changed. Read one with read_note.", inputSchema: { type: "object", properties: { limit: { type: "integer", minimum: 1, maximum: 5000, description: "At most this many." }, }, }, run: (db, who, args) => { const listed = listNotes(db, who, args.limit === undefined ? {} : { limit: Number(args.limit) }); return listed.ok ? { ok: true, value: listed.value.map(({ id, title, pinned, updated_at }) => ({ id, title, pinned: pinned === 1, updated_at })), } : listed; }, }, { name: "search_notes", description: 'Search your vault: every word must appear; "a phrase" matches whole; #tag (or tag:name) must be on the note. Title matches rank first.', inputSchema: { type: "object", properties: { q: { type: "string", maxLength: 500 }, limit: { type: "integer", minimum: 1, maximum: 500 } }, required: ["q"], }, run: (db, who, args) => searchNotes(db, who, { q: str(args.q) ?? "", ...(args.limit === undefined ? {} : { limit: Number(args.limit) }) }), }, { name: "read_note", description: "One note's markdown, by id or title.", inputSchema: { type: "object", properties: NOTE_REF }, run: (db, who, args) => which(db, who, args), }, { name: "create_note", description: "Write a new note in markdown. Link other notes with [[Their title]] and tag with #tags. Titles are unique in the vault.", inputSchema: { type: "object", properties: { title: { type: "string", maxLength: 200 }, body: { type: "string", maxLength: BODY_MAX }, }, required: ["title"], }, run: (db, who, args) => addNote(db, who, { title: str(args.title) ?? "", body: str(args.body) ?? "" }), }, { name: "update_note", description: "Replace a note's body, rename it (links to it follow), or pin it. Give only what changes.", inputSchema: { type: "object", properties: { id: { type: "integer" }, title: { type: "string", maxLength: 200 }, body: { type: "string", maxLength: BODY_MAX }, pinned: { type: "boolean" }, }, required: ["id"], }, run: (db, who, args) => updateNote(db, who, { id: Number(args.id), ...(str(args.title) === undefined ? {} : { title: str(args.title)! }), ...(str(args.body) === undefined ? {} : { body: str(args.body)! }), ...(typeof args.pinned === "boolean" ? { pinned: args.pinned } : {}), }), }, { name: "append_to_note", description: "Add text to the end of a note, made if it does not exist yet. With no title, today's daily note (YYYY-MM-DD, UTC).", inputSchema: { type: "object", properties: { title: { type: "string", maxLength: 200 }, text: { type: "string", maxLength: BODY_MAX }, }, required: ["text"], }, run: (db, who, args) => { const day = today(); const name = str(args.title) ?? day; const opened = openOrCreate(db, who, { title: name, body: name === day ? `# ${day}\n` : "" }); if (!opened.ok) return opened; const body = opened.value.body.replace(/\s*$/, ""); return updateNote(db, who, { id: opened.value.id, body: `${body}${body === "" ? "" : "\n\n"}${str(args.text) ?? ""}\n`, }); }, }, { name: "delete_note", description: "Remove a note by id. This cannot be undone.", inputSchema: { type: "object", properties: { id: { type: "integer" } }, required: ["id"], }, run: (db, who, args) => deleteNote(db, who, { id: Number(args.id) }), }, ]; /** One JSON-RPC message in, an HTTP status and a body out (a notification gets 202 and no body). */ export function rpc( db: Database, who: Who, message: unknown, ): { status: number; body?: unknown } { const m = fields(message); const id = m.id ?? null; const reply = (result: unknown) => ({ status: 200, body: { jsonrpc: "2.0", id, result }, }); const refuse = (code: number, text: string) => ({ status: 200, body: { jsonrpc: "2.0", id, error: { code, message: text } }, }); if (m.jsonrpc !== "2.0" || typeof m.method !== "string") return refuse(-32600, "not a JSON-RPC 2.0 request"); if (m.id === undefined) return { status: 202 }; const params = fields(m.params); switch (m.method) { case "initialize": return reply({ protocolVersion: "2025-06-18", capabilities: { tools: {} }, serverInfo: { name: title, version: "1" }, }); case "ping": return reply({}); case "tools/list": return reply({ tools: tools.map(({ name, description, inputSchema }) => ({ name, description, inputSchema, })), }); case "tools/call": { const tool = tools.find((t) => t.name === params.name); if (tool === undefined) return refuse(-32602, `no tool named ${String(params.name)}`); const result = tool.run(db, who, fields(params.arguments)); return reply( result.ok ? { content: [{ type: "text", text: JSON.stringify(result.value) }] } : { content: [ { type: "text", text: `${result.error}: ${result.message}` }, ], isError: true, }, ); } default: return refuse(-32601, `no method ${m.method}`); } }