feat: Phase 11 — CRUD (create/update/delete) + auth + web UI

- Core: createDocument/updateDocument/deleteDocument (file + DB + revision + chunks)
- Parser: stringifyFile (serialize markdown with frontmatter to disk)
- API: tRPC CRUD routers (auth-gated via requireWriteAuth middleware)
- API: createContext now passes expectedSecret from deps (request-scoped auth)
- MCP: 3 new write tools (create_document, update_document, delete_document)
- Web: /create page + ?edit=1 form, /api/auth/login (cookie-based), /api/docs REST CRUD
- Web: Edit buttons on homepage + doc pages (auth-gated)
- Tests: 8 new tests (5 tRPC CRUD + 3 MCP CRUD auth), 40 total all green
This commit is contained in:
asepharyana
2026-08-20 13:08:27 +07:00
parent 2d974b8952
commit 57f90018da
19 changed files with 1215 additions and 23 deletions
+56
View File
@@ -46,6 +46,9 @@ mock.module("@mcpedia/core", () => ({
return Promise.resolve({ slug: "docs/test", documentId: "d1" });
},
readContentFile: () => "",
createDocument: (_input: unknown) => Promise.resolve({ slug: "docs/test", title: "Test" }),
updateDocument: (_slug: string, _input: unknown) => Promise.resolve({ slug: "docs/test", title: "Test" }),
deleteDocument: (_slug: string) => Promise.resolve({ deleted: true }),
}));
// Import AFTER mocking so the modules resolve to our fakes.
@@ -150,6 +153,55 @@ test("queue_status is public (no auth needed)", async () => {
await server.close();
});
// --- Phase 11: CRUD write tools auth gates ---
test("create_document without auth secret -> tool returns isError", async () => {
const { client, server } = await connect(); // no secret
const res = await client.callTool({
name: "create_document",
arguments: { slug: "docs/test", title: "Test", section: "docs", body: "# Hi" },
});
expect(res.isError).toBe(true);
await client.close();
await server.close();
});
test("create_document with auth secret -> success", async () => {
const { client, server } = await connect("secret");
const res = await client.callTool({
name: "create_document",
arguments: { slug: "docs/test", title: "Test", section: "docs", body: "# Hi" },
});
expect(res.isError).toBeFalsy();
const text = (res.content as any[])[0].text;
const parsed = JSON.parse(text);
expect(parsed.ok).toBe(true);
await client.close();
await server.close();
});
test("update_document with auth secret -> success", async () => {
const { client, server } = await connect("secret");
const res = await client.callTool({
name: "update_document",
arguments: { slug: "docs/test", body: "# Updated" },
});
expect(res.isError).toBeFalsy();
await client.close();
await server.close();
});
test("delete_document without auth secret -> tool returns isError", async () => {
const { client, server } = await connect(); // no secret
const res = await client.callTool({
name: "delete_document",
arguments: { slug: "docs/test" },
});
expect(res.isError).toBe(true);
await client.close();
await server.close();
});
test("tool discovery works without auth (read tools present)", async () => {
const { client, server } = await connect();
const tools = await client.listTools();
@@ -166,6 +218,10 @@ test("tool discovery works without auth (read tools present)", async () => {
expect(names).toContain("reindex_all");
expect(names).toContain("restore_revision");
expect(names).toContain("queue_status");
// Phase 11: CRUD write tools also present in discovery.
expect(names).toContain("create_document");
expect(names).toContain("update_document");
expect(names).toContain("delete_document");
await client.close();
await server.close();
});
+87
View File
@@ -13,6 +13,9 @@ import {
getRevision,
restoreRevision,
readContentFile,
createDocument,
updateDocument,
deleteDocument,
} from "@mcpedia/core";
import { enqueueIndexDoc, enqueueFullIndex, getQueue, INDEX_QUEUE } from "@mcpedia/queue";
import { CONTENT_ROOT } from "@mcpedia/config";
@@ -195,6 +198,90 @@ export function createMcpServer(authSecret?: string): McpServer {
},
);
// --- Phase 11: CRUD write tools (require x-webhook-secret) ---
server.registerTool(
"create_document",
{
description:
"Create a new document (writes markdown file + DB row + revision + chunks). Requires the x-webhook-secret header.",
inputSchema: z.object({
slug: z.string().describe("URL-safe slug (e.g. 'docs/my-new-doc')"),
title: z.string().describe("Document title"),
section: z.enum(["docs", "writeups", "research", "notes"]).describe("Content section"),
body: z.string().describe("Markdown body"),
type: z.enum(["documentation", "writeup", "research", "note"]).optional(),
status: z.enum(["published", "draft"]).optional(),
author: z.string().optional(),
tags: z.array(z.string()).optional(),
}),
},
async ({ slug, title, section, body, type, status, author, tags }) => {
requireMcpAuth(authSecret);
const doc = await createDocument({
slug,
title,
section,
body,
type,
status,
author,
tags,
});
return {
content: [{ type: "text", text: JSON.stringify({ ok: true, slug: doc.slug }) }],
};
},
);
server.registerTool(
"update_document",
{
description:
"Update an existing document (title, body, tags, status, etc.). Requires the x-webhook-secret header.",
inputSchema: z.object({
slug: z.string().describe("Document slug to update"),
title: z.string().optional(),
body: z.string().optional(),
type: z.enum(["documentation", "writeup", "research", "note"]).optional(),
status: z.enum(["published", "draft"]).optional(),
tags: z.array(z.string()).optional(),
author: z.string().optional(),
}),
},
async ({ slug, title, body, type, status, tags, author }) => {
requireMcpAuth(authSecret);
const doc = await updateDocument(slug, {
title,
body,
type,
status,
tags,
author,
});
return {
content: [{ type: "text", text: JSON.stringify({ ok: true, slug: doc.slug }) }],
};
},
);
server.registerTool(
"delete_document",
{
description:
"Delete a document (removes file + DB rows + chunks + revisions). Requires the x-webhook-secret header.",
inputSchema: z.object({
slug: z.string().describe("Document slug to delete"),
}),
},
async ({ slug }) => {
requireMcpAuth(authSecret);
const result = await deleteDocument(slug);
return {
content: [{ type: "text", text: JSON.stringify({ ok: true, deleted: result.deleted }) }],
};
},
);
server.registerTool(
"queue_status",
{