/** * The MCP server agents use. It is one more client of the write API: every * tool that changes something calls `ContentService.apply`, so agents meet * the same rules as everyone else, plus the agent rules. * * Agents can find, read and add. They cannot delete, rename, move or write a * raw file, and a draft post always starts unpublished. */ import type { IncomingMessage, ServerResponse } from "node:http"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { z } from "zod"; import { libraryStates, media, noteTypes, topics, typeDef } from "@gk/schema"; import type { Change, ContentService, Outcome } from "./service.ts"; import type { Author } from "./store.ts"; import { heldView, noteView, outcomeView, summaryView } from "./views.ts"; const json = (value: unknown) => ({ content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] }); const fail = (message: string) => ({ content: [{ type: "text" as const, text: message }], isError: true }); /** Turn an outcome into a tool result an agent can act on. */ function result(o: Outcome) { if (o.status === "rejected") return fail(`Not saved:\n${o.reasons.map((r) => `- ${r.code}: ${r.message}`).join("\n")}`); if (o.status === "held") { return json({ ...outcomeView(o), message: "Saved for Greg to review. It is not live yet. Do not retry; the change is waiting.", }); } return json(outcomeView(o)); } const today = () => new Date().toISOString().slice(0, 10); export function createMcpServer(service: ContentService, author: Author) { const { store } = service; const server = new McpServer( { name: "gregkilmer-content", version: "0.1.0" }, { instructions: "Content for GregKilmer.com. Search and read freely. Writes go through rules: some are saved straight away, " + "others wait for Greg's review, and a held change should not be retried. Call get_schema before writing a type " + "for the first time. Never invent facts, numbers or dates you were not given.", }, ); const apply = (change: Change) => result(service.apply(change, author)); server.registerTool( "search", { title: "Search notes", description: "Full-text search over every note's title and body, drafts included. Optionally limit to one type.", inputSchema: { query: z.string().min(1), type: z.enum(Object.keys(noteTypes) as [string, ...string[]]).optional(), limit: z.number().int().min(1).max(50).optional(), }, annotations: { readOnlyHint: true }, }, async ({ query, type, limit }) => json(store.search(query, { limit: (limit ?? 10) * (type ? 5 : 1) }).filter((n) => !type || n.type === type).slice(0, limit ?? 10).map(summaryView)), ); server.registerTool( "get_note", { title: "Read a note", description: "One note by its path (like Library/Books/Mistborn.md) or its name (the file name without .md), with what links to it.", inputSchema: { path: z.string().optional(), name: z.string().optional() }, annotations: { readOnlyHint: true }, }, async ({ path, name }) => { const n = path ? store.get(path) : name ? store.byName(name) : null; if (!n || n.removed) return fail("No such note. Use search to find it."); const pending = store.pendingFor(n.path); return json({ ...noteView(n), backlinks: store.backlinks(n.name).map(summaryView), pending: pending ? heldView(pending) : null, }); }, ); server.registerTool( "get_schema", { title: "Note types", description: "Without a type: every note type and what it is for. With a type: its fields as JSON Schema.", inputSchema: { type: z.enum(Object.keys(noteTypes) as [string, ...string[]]).optional() }, annotations: { readOnlyHint: true }, }, async ({ type }) => { if (!type) return json(Object.values(noteTypes).map((d) => ({ type: d.type, label: d.label, description: d.description }))); const def = typeDef(type)!; return json({ type: def.type, description: def.description, body: def.body, schema: z.toJSONSchema(def.schema, { io: "input", unrepresentable: "any" }), }); }, ); server.registerTool( "list_held", { title: "Changes waiting for review", description: "Changes held for Greg's review, with the reasons. Useful before retrying anything.", inputSchema: {}, annotations: { readOnlyHint: true }, }, async () => json(store.heldList("pending").map((h) => heldView(h))), ); server.registerTool( "set_status", { title: "Post a status", description: "Add a line to the Now status log, like reading a book or starting a build. kind must be one of the kinds in Now/_kinds. " + "subject links the note it is about, by name.", inputSchema: { kind: z.string().min(1), title: z.string().min(1), note: z.string().optional(), subject: z.string().optional().describe("Name of the note this is about, without brackets"), date: z.string().optional().describe("YYYY-MM-DD, default today"), publish: z.boolean().optional().describe("Default true"), }, }, async ({ kind, title, note, subject, date, publish }) => { const kinds = store.list({ type: "status-kinds" })[0]?.data.kinds as { label: string }[] | undefined; if (kinds && !kinds.some((k) => k.label.toLowerCase() === kind.toLowerCase())) { return fail(`Unknown kind "${kind}". Kinds: ${kinds.map((k) => k.label).join(", ")}`); } const label = kinds?.find((k) => k.label.toLowerCase() === kind.toLowerCase())?.label ?? kind; return apply({ op: "create", data: { type: "status", title, kind: label, date: date ?? today(), ...(note ? { note } : {}), ...(subject ? { subject: `[[${subject.replace(/^\[\[|\]\]$/g, "")}]]` } : {}), publish: publish ?? true, }, }); }, ); server.registerTool( "add_library_item", { title: "Add to the Library", description: "Add a book, series, show, film or game. To change one that exists, use update_fields.", inputSchema: { title: z.string().min(1), medium: z.enum(media), state: z.enum(libraryStates), creator: z.string().optional(), group: z.string().optional(), year: z.number().int().optional(), platform: z.string().optional(), verdict: z.string().optional(), note: z.string().optional(), progress: z.string().optional(), anilistId: z.number().int().optional(), publish: z.boolean().optional().describe("Default true"), }, }, async ({ publish, ...fields }) => { const existing = store.search(fields.title, { limit: 5 }).find((n) => n.type === "library-item" && n.title.toLowerCase() === fields.title.toLowerCase()); if (existing) return fail(`Already in the Library at ${existing.path}. Use update_fields to change it.`); const data = Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined)); return apply({ op: "create", data: { type: "library-item", ...data, publish: publish ?? true } }); }, ); server.registerTool( "update_fields", { title: "Change a note's fields", description: "Change some fields of an existing note; fields not named stay as they are, and null removes one. " + "Give body only to replace the whole body. Greg's own notes need agentEdits: true before an agent may change them.", inputSchema: { path: z.string(), fields: z.record(z.string(), z.unknown()), body: z.string().optional(), }, }, async ({ path, fields, body }) => apply({ op: "update", path, fields, ...(body !== undefined ? { body } : {}) }), ); server.registerTool( "draft_post", { title: "Draft a blog post", description: "Start a blog post as an unpublished draft for Greg to finish. It never goes live from here.", inputSchema: { title: z.string().min(1), topic: z.enum(topics), summary: z.string().min(1), body: z.string().min(1), postType: z.enum(["article", "build log", "case study"]).optional(), tags: z.array(z.string()).optional(), }, }, async ({ body, ...fields }) => { const data = Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined)); return apply({ op: "create", data: { type: "post", date: today(), ...data, publish: false }, body }); }, ); return server; } /** One stateless MCP exchange per request: nothing is kept between calls. */ export async function handleMcp(req: IncomingMessage, res: ServerResponse, body: unknown, service: ContentService, author: Author) { const server = createMcpServer(service, author); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true }); res.on("close", () => { void transport.close(); void server.close(); }); await server.connect(transport); await transport.handleRequest(req, res, body); }