/** * 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 { deleteDrawing, getDrawing, listDrawings, startDrawing, svgsOf, type Drawing, type Result, } from "./actions.ts"; import { GLOW, INK, KINDS, PROMPT_MAX } from "./glyph.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; } /** A drawing as a tool answers it: its SVGs as an array. */ const shaped = (r: Result): Result => r.ok ? { ok: true, value: Array.isArray(r.value) ? r.value.map((d) => ({ ...d, svgs: svgsOf(d) })) : { ...(r.value as Drawing), svgs: svgsOf(r.value as Drawing) }, } : r; export const tools: readonly Tool[] = [ { name: "draw", description: `Ask Claude for SVG concepts from a prompt. Answers at once with status \`drawing\`; call get_drawing until it is \`done\` (about a minute). The SVGs use ${INK} as ink and ${GLOW} as glow: swap those two for your own colours.`, inputSchema: { type: "object", properties: { prompt: { type: "string", maxLength: PROMPT_MAX }, kind: { enum: Object.keys(KINDS), description: "What to draw (mark)." }, }, required: ["prompt"], }, run: (db, who, args) => shaped( startDrawing(db, who, { prompt: String(args.prompt ?? ""), ...(args.kind === undefined ? {} : { kind: String(args.kind) }), }), ), }, { name: "get_drawing", description: "One drawing by id, with its SVGs once it is done.", inputSchema: { type: "object", properties: { id: { type: "integer" } }, required: ["id"], }, run: (db, who, args) => shaped(getDrawing(db, who, { id: Number(args.id) })), }, { name: "list_drawings", description: "Your drawings, newest first.", inputSchema: { type: "object", properties: { limit: { type: "integer", minimum: 1, maximum: 200, description: "At most this many (50)." }, }, }, run: (db, who, args) => shaped(listDrawings(db, who, { limit: Number(args.limit ?? 50) })), }, { name: "delete_drawing", description: "Remove a drawing by id. Only whoever asked for it, or an admin, can.", inputSchema: { type: "object", properties: { id: { type: "integer" } }, required: ["id"], }, run: (db, who, args) => deleteDrawing(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}`); } }