doxa-maps-cdn/server/mcp/tools/pages.ts
2026-04-29 13:06:54 +01:00

218 lines
8.3 KiB
TypeScript

// Page CRUD tools — list, get, create, update, delete.
//
// All tool names are snake_case per MCP convention; the server name
// `doxa-cms` disambiguates in the client's tool list. Scopes are
// RBAC permission strings (no `cms.*` namespace) per the project's
// permission registry.
import { defineMcpTool, mcpLog } from '#mcp-layer'
import {
listPagesInput,
getPageInput,
createPageInput,
updatePageInput,
deletePageInput
} from '../schemas'
import {
listCmsPages,
getCmsPage,
createCmsPage,
updateCmsPage,
deleteCmsPage,
applyPageInvalidations
} from '../../services/cmsPages'
import { upsertCmsPageTranslation, applyTranslationInvalidations } from '../../services/cmsTranslations'
import { tiptapValidate } from '../../utils/tiptapValidate'
import { markdownToTiptap } from '../../utils/tiptapFromMarkdown'
import { tiptapToMarkdown } from '../../utils/tiptapToMarkdown'
import { detectLossy } from '../../utils/tiptapLossyDetector'
export const listPagesTool = defineMcpTool({
name: 'list_pages',
description: 'List CMS pages with their translations. Returns a paginated list ordered by menu_order then id; use the returned `nextCursor` to page through. Filters: category_id (or null for uncategorized), status (requires locale; use "any" to skip), locale, and a substring query against slug/title.',
scope: 'pages.view',
input: listPagesInput,
async handler(input) {
const result = await listCmsPages({
category_id: input.category_id ?? undefined,
status: input.status,
locale: input.locale,
query: input.query,
limit: input.limit,
cursor: input.cursor
})
return {
content: [{ type: 'text', text: `Found ${result.pages.length} page(s).` }],
structuredContent: result
}
}
})
export const getPageTool = defineMcpTool({
name: 'get_page',
description: 'Fetch a single CMS page by id or slug, including all translations (any status). Each translation includes body_json (Tiptap JSON), a Markdown rendering, and a body_is_lossy_in_markdown flag.',
scope: 'pages.view',
input: getPageInput,
async handler(input) {
const found = await getCmsPage({ id: input.id, slug: input.slug })
if (!found) {
return {
content: [{ type: 'text', text: 'Page not found' }],
isError: true
}
}
const translations = found.translations.map((t) => {
const lossy = detectLossy(t.body_json)
return {
id: t.id,
page_id: t.page_id,
locale: t.locale,
title: t.title,
body_json: t.body_json,
body_markdown: tiptapToMarkdown(t.body_json),
body_is_lossy_in_markdown: lossy.body_is_lossy_in_markdown,
body_lossy_reasons: lossy.body_lossy_reasons,
excerpt: t.excerpt,
featured_image: t.featured_image,
meta_title: t.meta_title,
meta_description: t.meta_description,
og_image: t.og_image,
status: t.status,
updated_at: new Date(t.updated as unknown as string).toISOString()
}
})
return {
content: [{ type: 'text', text: `Page "${found.page.slug}" (${translations.length} translation(s))` }],
structuredContent: {
page: {
id: found.page.id,
slug: found.page.slug,
category_id: found.page.category_id,
category_slug: found.category_slug,
menu_order: found.page.menu_order,
theme: found.page.theme,
custom_css: found.page.custom_css
},
translations
}
}
}
})
export const createPageTool = defineMcpTool({
name: 'create_page',
description: 'Create a new CMS page shell, optionally with an inline initial translation. The page shell + translation are committed atomically. Slug rules: under a category the slug must start with "<category_slug>/"; uncategorized slugs cannot collide with category slugs. If translation.body_markdown is supplied it is converted to Tiptap JSON server-side; if translation.body_json is supplied it is validated against the schema/attribute allowlist.',
scope: 'pages.write',
input: createPageInput,
async handler(input, ctx) {
let inlineTranslation: Parameters<typeof createCmsPage>[0]['translation']
if (input.translation) {
// Zod's inlineTranslationInput.refine() already enforces exactly
// one of body_json / body_markdown, so we never reach this code
// path with neither set — pick whichever the schema admitted.
const body_json: Record<string, unknown> = input.translation.body_json
? (input.translation.body_json as Record<string, unknown>)
: (markdownToTiptap(input.translation.body_markdown!) as unknown as Record<string, unknown>)
tiptapValidate(body_json)
inlineTranslation = {
locale: input.translation.locale,
title: input.translation.title,
body_json,
excerpt: input.translation.excerpt ?? null,
featured_image: input.translation.featured_image ?? null,
meta_title: input.translation.meta_title ?? null,
meta_description: input.translation.meta_description ?? null,
og_image: input.translation.og_image ?? null,
status: input.translation.status
}
}
const created = await createCmsPage(
{
slug: input.slug,
category_id: input.category_id ?? null,
menu_order: input.menu_order,
theme: input.theme,
custom_css: input.custom_css ?? null,
translation: inlineTranslation
},
async ({ pageId, translationId }, executor) => {
await mcpLog('CREATE', 'pages', pageId, ctx, { slug: input.slug }, executor as never)
if (translationId && inlineTranslation) {
// Audit the translation row by its actual id so greps on
// record_id find it. Both inserts commit in the same tx,
// so the audit row's executor is honored.
await mcpLog('CREATE', 'page_translations', translationId, ctx, {
page_id: pageId,
locale: inlineTranslation.locale,
via: 'create_page'
}, executor as never)
}
}
)
await applyPageInvalidations(created.slugsToPurge, created.categoriesToPurge)
return {
content: [{ type: 'text', text: `Created page "${created.page.slug}"` }],
structuredContent: {
page: created.page,
translation: created.translation
}
}
}
})
export const updatePageTool = defineMcpTool({
name: 'update_page',
description: 'Update a page\'s metadata: slug, category_id, menu_order, theme, custom_css. Translation edits go through upsert_page_translation. Changing the category rewrites the slug prefix; cache for the old + new slug + categories is purged.',
scope: 'pages.write',
input: updatePageInput,
async handler(input, ctx) {
const result = await updateCmsPage({
id: input.id,
slug: input.slug,
category_id: input.category_id ?? undefined,
menu_order: input.menu_order,
theme: input.theme,
custom_css: input.custom_css ?? undefined
})
if (Object.keys(result.changes).length > 0) {
await mcpLog('UPDATE', 'pages', input.id, ctx, { changes: result.changes })
}
await applyPageInvalidations(result.slugsToPurge, result.categoriesToPurge)
return {
content: [{ type: 'text', text: `Updated page "${result.page.slug}"` }],
structuredContent: { page: result.page, changes: result.changes }
}
}
})
export const deletePageTool = defineMcpTool({
name: 'delete_page',
description: 'Delete a CMS page and (via cascade) all its translations. This is destructive — the operation is irreversible without a database restore.',
scope: 'pages.write',
destructive: true,
input: deletePageInput,
async handler(input, ctx) {
const { slug, categoryId } = await deleteCmsPage(input.id)
await mcpLog('DELETE', 'pages', input.id, ctx, { slug })
await applyPageInvalidations([slug], categoryId ? [categoryId] : [])
return {
content: [{ type: 'text', text: `Deleted page "${slug}"` }],
structuredContent: { ok: true }
}
}
})
export const PAGE_TOOLS = [
listPagesTool,
getPageTool,
createPageTool,
updatePageTool,
deletePageTool
] as const
// Re-export so the translations.ts importer can pull the same tools
// helpers without repeating the import paths.
export { upsertCmsPageTranslation, applyTranslationInvalidations }