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:
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user