/** * 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, deleteNote, listNotes, 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; } export const tools: readonly Tool[] = [ { name: "list_notes", description: "The notes, newest first.", inputSchema: { type: "object", properties: { limit: { type: "integer", minimum: 1, maximum: 500, description: "At most this many (100).", }, }, }, run: (db, who, args) => listNotes(db, who, { limit: Number(args.limit ?? 100) }), }, { name: "add_note", description: "Add a note as the signed-in person. Anonymous callers cannot.", inputSchema: { type: "object", properties: { body: { type: "string", maxLength: 500 } }, required: ["body"], }, run: (db, who, args) => addNote(db, who, { body: String(args.body ?? "") }), }, { name: "delete_note", description: "Remove a note by id. Only its author or an admin can.", 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}`); } }