mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-08 04:08:24 +00:00
3f646048a2
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
230 lines
9.2 KiB
TypeScript
230 lines
9.2 KiB
TypeScript
/**
|
|
* 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);
|
|
}
|