/** * EmDash MCP Server * * Exposes content, schema, media, search, taxonomy, and menu operations * as MCP tools over the Streamable HTTP transport. * * Tools use the EmDashHandlers interface (same as locals.emdash) so * they work with the pre-bound handlers that the middleware provides. * The handlers instance is passed per-request via authInfo on the transport. */ import type { Permission, RoleLevel } from "@emdash-cms/auth"; import { canActOnOwn, hasPermission, Role } from "@emdash-cms/auth"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { bylineCreateBody, bylineUpdateBody, contentBylineInputSchema, contentSeoInput, } from "#api/schemas.js"; import type { EmDashHandlers } from "../astro/types.js"; import { hasScope } from "../auth/api-tokens.js"; import { convertDataForRead, convertDataForWrite } from "../client/portable-text.js"; import type { FieldSchema } from "../client/portable-text.js"; const COLLECTION_SLUG_PATTERN = /^[a-z][a-z0-9_]*$/; /** http(s) scheme matcher used by `settings_update` URL validation. */ const HTTP_SCHEME_PATTERN = /^https?:\/\//i; // --------------------------------------------------------------------------- // Shared schemas — kept in sync with `api/schemas/settings.ts` (which the // REST handler validates against). Defined inline to match the rest of the // MCP tool registrations rather than reaching across into the REST layer. // --------------------------------------------------------------------------- const settingsMediaReferenceSchema = z.object({ mediaId: z.string().describe("Media item ID (use media_create or media_list)"), alt: z.string().optional().describe("Alt text for the media reference"), }); const settingsSocialSchema = z.object({ twitter: z.string().optional(), github: z.string().optional(), facebook: z.string().optional(), instagram: z.string().optional(), linkedin: z.string().optional(), youtube: z.string().optional(), }); const settingsSeoSchema = z.object({ titleSeparator: z .string() .max(10) .optional() .describe("Separator between page title and site title (e.g. ' | ')"), defaultOgImage: settingsMediaReferenceSchema .optional() .describe("Default Open Graph image when content has none"), robotsTxt: z .string() .max(5000) .optional() .describe("Custom robots.txt body. Leave unset for the EmDash default."), googleVerification: z .string() .max(100) .optional() .describe("Google Search Console verification token"), bingVerification: z .string() .max(100) .optional() .describe("Bing Webmaster Tools verification token"), }); // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- type HandlerResult = { success: boolean; data?: unknown; error?: unknown; }; type SuccessEnvelope = { content: Array<{ type: "text"; text: string }>; _meta?: Record; }; type ErrorEnvelope = { content: Array<{ type: "text"; text: string }>; isError: true; _meta: { code: string; details?: Record }; }; /** * Return a successful tool response with the data as pretty-printed JSON. */ function respondData(data: unknown): SuccessEnvelope { return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }], }; } /** * Return a structured error tool response. * * The error code is emitted both in the human-readable message (as a stable * `[CODE]` prefix that callers can match on) and in `_meta.code` so MCP-aware * clients can read it programmatically once the SDK supports forwarding meta. */ function respondError( code: string, message: string, details?: Record, ): ErrorEnvelope { const text = `[${code}] ${message}`; const meta: { code: string; details?: Record } = { code }; if (details !== undefined) meta.details = details; return { content: [{ type: "text", text }], isError: true, _meta: meta, }; } /** * Auth/permission errors thrown from `requireScope` / `requireRole` / * `requireOwnership` / `requireDraftAccess`. Carries a stable string `code` * field so `respondHandlerError` can surface it through `_meta.code` and * the message prefix. * * Distinct from `McpError` (which the SDK catches at JSON-RPC level — the * code there is numeric, not a stable EmDash error code). */ class EmDashAuthError extends Error { override readonly name = "EmDashAuthError"; constructor( message: string, readonly code: string, ) { super(message); } } /** * Map an unknown thrown error to a structured error envelope. * * Recognises (in priority order): * - `EmDashAuthError` — `code` is a stable EmDash auth code * (`UNAUTHORIZED`, `INSUFFICIENT_SCOPE`, `INSUFFICIENT_PERMISSIONS`). * - `Error` objects with an `apiError: { code, details? }` annotation * (handlers throw these for NOT_FOUND / CONFLICT inside transactions; * see `api/handlers/content.ts:538`). * - `SchemaError` (and any error with a string `code` field) — the code * is forwarded verbatim. `details` is forwarded too if present. * - Plain `Error` instances — message preserved, code falls back to * `fallbackCode` (or `INTERNAL_ERROR`). * - Strings — used directly as the message. * - Anything else — coerced via `String()`. * * The original message is always preserved so tests and humans can see the * specific failure cause. Numeric `code` values (e.g. on `McpError`) are * ignored — the field is reserved for stable string codes. */ function respondHandlerError(error: unknown, fallbackCode = "INTERNAL_ERROR"): ErrorEnvelope { let code = fallbackCode; let message: string; let details: Record | undefined; if (error instanceof EmDashAuthError) { message = error.message || fallbackCode; code = error.code; } else if (error instanceof Error) { message = error.message || fallbackCode; const apiError = (error as { apiError?: { code?: string; details?: unknown } }).apiError; if (apiError && typeof apiError.code === "string" && apiError.code) { code = apiError.code; if (apiError.details && typeof apiError.details === "object") { details = apiError.details as Record; } } else { // Errors that carry their own `code` (SchemaError, custom errors). // Skip numeric codes (McpError, Node fs errors) — `_meta.code` is // reserved for stable string codes. const rawCode = (error as { code?: unknown }).code; if (typeof rawCode === "string" && rawCode) { code = rawCode; } const rawDetails = (error as { details?: unknown }).details; if (rawDetails && typeof rawDetails === "object") { details = rawDetails as Record; } } } else if (typeof error === "string") { message = error; } else { message = String(error); } return respondError(code, message, details); } /** * Unwrap an ApiResult into MCP tool result format. * * On success returns the data as JSON. On failure propagates the structured * `{ code, message, details }` from the handler so the caller sees both a * machine-readable code (in `_meta.code` and as a `[CODE]` message prefix) * and the original human-readable message. */ function unwrap(result: HandlerResult): SuccessEnvelope | ErrorEnvelope { if (result.success && result.data !== undefined) { return respondData(result.data); } const err = result.error && typeof result.error === "object" ? (result.error as { code?: unknown; message?: unknown; details?: unknown }) : undefined; if (!err) return respondError("INTERNAL_ERROR", "Unknown error"); const code = typeof err.code === "string" && err.code ? err.code : "INTERNAL_ERROR"; const message = typeof err.message === "string" && err.message ? err.message : "Unknown error"; const details = err.details && typeof err.details === "object" ? (err.details as Record) : undefined; return respondError(code, message, details); } /** * Return a JSON text block (success path for tools that don't go through * the ApiResult-returning handler layer, e.g. schema/menu/taxonomy). */ function jsonResult(data: unknown): SuccessEnvelope { return respondData(data); } // --------------------------------------------------------------------------- // Context extraction // // The route handler passes emdash + userId in authInfo.extra. // --------------------------------------------------------------------------- interface EmDashExtra { emdash: EmDashHandlers; userId: string; /** The authenticated user's RBAC role level. */ userRole: RoleLevel; /** Token scopes — undefined for session auth (all access allowed). */ tokenScopes?: string[]; } function isPublished(t: unknown): boolean { return ( typeof t === "object" && t !== null && "status" in t && (t as Record).status === "published" ); } function getExtra(extra: { authInfo?: { extra?: Record } }): EmDashExtra { const payload = extra.authInfo?.extra as EmDashExtra | undefined; if (!payload?.emdash) { throw new Error("EmDash not available — server misconfigured"); } return payload; } function getEmDash(extra: { authInfo?: { extra?: Record } }): EmDashHandlers { return getExtra(extra).emdash; } async function getCollectionFields( ec: EmDashHandlers, collection: string, ): Promise { try { const { SchemaRegistry } = await import("../schema/index.js"); const col = await new SchemaRegistry(ec.db).getCollectionWithFields(collection); return col ? col.fields : null; } catch { return null; } } /** * Convert markdown strings to Portable Text for `portableText` fields on write. * Non-string values pass through, so callers may still send Portable Text. If * the schema can't be loaded, data is returned unchanged for the handler to * validate. */ async function convertWriteData( ec: EmDashHandlers, collection: string, data: Record, ): Promise> { const fields = await getCollectionFields(ec, collection); if (!fields) return data; return convertDataForWrite(data, fields); } /** * Convert `portableText` field values on each item's `data` to markdown, * mutating in place. Inverse of {@link convertWriteData}, gated on the read * tools' `markdown` argument. */ async function applyReadMarkdown( ec: EmDashHandlers, collection: string, items: Array>, ): Promise { const fields = await getCollectionFields(ec, collection); if (!fields) return; for (const item of items) { if (item.data && typeof item.data === "object") { item.data = convertDataForRead( // eslint-disable-next-line typescript/no-unsafe-type-assertion -- narrowed by typeof check above item.data as Record, fields, false, ); } } } async function invalidateBylines(): Promise { const { invalidateBylineCache } = await import("../bylines/index.js"); invalidateBylineCache(); } /** * Enforce a scope requirement on the current request. * * When tokenScopes is undefined (session auth), all operations are allowed * since session users have full access based on their role. When scopes are * present (token auth), the required scope must be included. */ function requireScope( extra: { authInfo?: { extra?: Record } }, scope: string, ): void { const payload = getExtra(extra); if (payload.tokenScopes && !hasScope(payload.tokenScopes, scope)) { throw new EmDashAuthError(`Insufficient scope: requires ${scope}`, "INSUFFICIENT_SCOPE"); } } /** * Defense-in-depth: enforce a minimum RBAC role on the current request. * * This is checked in addition to scope requirements. Even if a token has * the right scopes (e.g. due to a bug in scope clamping), the user's * actual role must still meet the minimum. */ function requireRole( extra: { authInfo?: { extra?: Record } }, minRole: RoleLevel, ): void { const payload = getExtra(extra); if (payload.userRole < minRole) { throw new EmDashAuthError( "Insufficient permissions for this operation", "INSUFFICIENT_PERMISSIONS", ); } } /** * Whether the current user may read non-published content (drafts, scheduled, * trashed, revisions, compare). SUBSCRIBER may hold content:read for * member-only published content but must not see drafts. */ function canReadDrafts(extra: { authInfo?: { extra?: Record } }): boolean { const payload = getExtra(extra); return hasPermission({ role: payload.userRole }, "content:read_drafts"); } /** * Throw if the current user cannot read non-published content. Used by * editor-only views (revisions, compare, trash, preview-url). */ function requireDraftAccess(extra: { authInfo?: { extra?: Record } }): void { if (!canReadDrafts(extra)) { throw new EmDashAuthError( "Insufficient permissions for this operation", "INSUFFICIENT_PERMISSIONS", ); } } /** * Enforce ownership-based permission checks, mirroring the REST API's * requireOwnerPerm() pattern. * * If the user is the owner, checks ownPermission. Otherwise checks * anyPermission (which requires EDITOR+ role). */ function requireOwnership( extra: { authInfo?: { extra?: Record } }, ownerId: string, ownPermission: Permission, anyPermission: Permission, ): void { const payload = getExtra(extra); const user = { id: payload.userId, role: payload.userRole }; if (!canActOnOwn(user, ownerId, ownPermission, anyPermission)) { throw new EmDashAuthError( "Insufficient permissions for this operation", "INSUFFICIENT_PERMISSIONS", ); } } /** * Extract the author ID from a content handler response. * * Content handlers return `{ item: { id, authorId, ... }, _rev? }`. * This helper navigates that shape safely. Returns "" when authorId is * missing or non-string (e.g. seed-imported content with no author); * `canActOnOwn` then decides based on the caller's permissions — * an actor with `*:edit_any` succeeds, an actor with only `*:edit_own` * is denied with a clean permission error. */ function extractContentAuthorId(data: unknown): string { if (!data || typeof data !== "object") return ""; const obj = data as Record; const item = obj.item && typeof obj.item === "object" ? (obj.item as Record) : obj; return typeof item?.authorId === "string" ? item.authorId : ""; } /** * Extract the resolved ID from a content handler response. * Handles slug -> ID resolution performed by the handler. */ function extractContentId(data: unknown): string | undefined { if (!data || typeof data !== "object") return undefined; const obj = data as Record; const item = obj.item && typeof obj.item === "object" ? (obj.item as Record) : obj; return typeof item?.id === "string" ? item.id : undefined; } // --------------------------------------------------------------------------- // Server factory // --------------------------------------------------------------------------- export function createMcpServer(): McpServer { const server = new McpServer( { name: "emdash", version: "0.1.0" }, { capabilities: { logging: {} } }, ); // Wrap every tool registration's callback so EmDashAuthError throws // (from requireScope / requireRole / requireOwnership / requireDraftAccess) // surface as structured `_meta.code`-bearing tool error envelopes // instead of the SDK's text-only fallback in createToolError(). // // Type-erased on purpose — the SDK's overloads are too narrow for a // generic wrapper, but the runtime contract (callback returns the tool // result envelope) holds for every registered tool. const originalRegisterTool = server.registerTool.bind(server); (server as { registerTool: typeof server.registerTool }).registerTool = (( name: string, config: unknown, callback: (...callbackArgs: unknown[]) => Promise, ) => { const wrapped = async ( ...callbackArgs: unknown[] ): Promise => { try { return await callback(...callbackArgs); } catch (error) { return respondHandlerError(error, "INTERNAL_ERROR"); } }; return ( originalRegisterTool as unknown as (n: string, c: unknown, cb: typeof wrapped) => unknown )(name, config, wrapped); }) as typeof server.registerTool; // ===================================================================== // Content tools // ===================================================================== server.registerTool( "content_list", { title: "List Content", description: "List content items in a collection with optional filtering and pagination. " + "Returns items sorted by the specified field. Use the nextCursor value from " + "the response to fetch the next page. Status can be 'draft', 'published', " + "or 'scheduled'. If no status is given, all non-trashed items are returned.", inputSchema: z.object({ collection: z.string().describe("Collection slug (e.g. 'posts', 'pages')"), status: z .enum(["draft", "published", "scheduled"]) .optional() .describe("Filter by content status"), limit: z .number() .int() .min(1) .max(100) .optional() .describe("Max items to return (default 50, max 100)"), cursor: z .string() .min(1) .max(2048) .optional() .describe("Pagination cursor from a previous response"), orderBy: z .string() .optional() .describe("Field to sort by (e.g. 'created_at', 'updated_at')"), order: z.enum(["asc", "desc"]).optional().describe("Sort direction (default 'desc')"), locale: z .string() .optional() .describe("Filter by locale (e.g. 'en', 'fr'). Only relevant when i18n is enabled."), markdown: z .boolean() .optional() .describe( "Return rich text (portableText) fields as Markdown strings instead of Portable Text arrays (default false).", ), }), annotations: { readOnlyHint: true }, }, async (args, extra) => { requireScope(extra, "content:read"); const ec = getEmDash(extra); // Subscribers must only see published content; force the status // filter regardless of caller-supplied value. const status = canReadDrafts(extra) ? args.status : "published"; const result = await ec.handleContentList(args.collection, { status, limit: args.limit, cursor: args.cursor, orderBy: args.orderBy, order: args.order, locale: args.locale, }); if (result.success && args.markdown) { const payload = result.data; if ( payload && typeof payload === "object" && "items" in payload && Array.isArray(payload.items) ) { await applyReadMarkdown(ec, args.collection, payload.items); } } return unwrap(result); }, ); server.registerTool( "content_get", { title: "Get Content", description: "Get a single content item by its ID or slug. Returns the full content data " + "including all field values, metadata, and a _rev token for optimistic " + "concurrency (pass _rev back when updating to detect conflicts).", inputSchema: z.object({ collection: z.string().describe("Collection slug (e.g. 'posts', 'pages')"), id: z.string().describe("Content item ID (ULID) or slug"), locale: z .string() .optional() .describe( "Locale to scope slug lookup (e.g. 'fr'). Only affects slug resolution; IDs are globally unique.", ), markdown: z .boolean() .optional() .describe( "Return rich text (portableText) fields as Markdown strings instead of Portable Text arrays (default false).", ), }), annotations: { readOnlyHint: true }, }, async (args, extra) => { requireScope(extra, "content:read"); const ec = getEmDash(extra); const result = await ec.handleContentGet(args.collection, args.id, args.locale); // Hide non-published items from users without draft access. Return a // not-found error so subscribers can't enumerate draft IDs by status. if (result.success && !canReadDrafts(extra)) { const data = result.data && typeof result.data === "object" ? // eslint-disable-next-line typescript/no-unsafe-type-assertion -- handler returns unknown data; narrowed by typeof check (result.data as Record) : undefined; const item = data?.item && typeof data.item === "object" ? // eslint-disable-next-line typescript/no-unsafe-type-assertion -- narrowed by typeof check (data.item as Record) : undefined; const status = typeof item?.status === "string" ? item.status : null; if (status !== "published") { return unwrap({ success: false, error: { code: "NOT_FOUND", message: `Content item not found: ${args.id}` }, }); } } if (result.success && args.markdown && result.data?.item) { await applyReadMarkdown(ec, args.collection, [result.data.item]); } return unwrap(result); }, ); server.registerTool( "content_create", { title: "Create Content", description: "Create a new content item in a collection. The 'data' object should " + "contain field values matching the collection's schema (use " + "schema_get_collection to check). For rich text (portableText) fields, " + "pass a Markdown string — converted to Portable Text automatically; prefer " + "this. Pass a Portable Text JSON array only for complex content Markdown " + "can't express (custom blocks, embeds). A slug is auto-generated if not " + "provided. Items are created as 'draft' by default — use content_publish " + "to make them live.", inputSchema: z.object({ collection: z.string().describe("Collection slug (e.g. 'posts', 'pages')"), data: z .record(z.string(), z.unknown()) .describe("Field values as key-value pairs matching the collection schema"), slug: z.string().optional().describe("URL slug (auto-generated from title if omitted)"), status: z .enum(["draft", "published"]) .optional() .describe("Initial status (default 'draft'). Requires publish permission."), locale: z .string() .optional() .describe("Locale for this content (e.g. 'fr'). Defaults to default locale."), translationOf: z .string() .optional() .describe( "ID of the content item this is a translation of. Links items in the same translation group.", ), bylines: z .array(contentBylineInputSchema) .optional() .describe( "Bylines to credit. Each entry references an existing byline by id (see byline_list / byline_create) with an optional roleLabel. The first entry becomes the primary byline.", ), }), annotations: { destructiveHint: false }, }, async (args, extra) => { requireScope(extra, "content:write"); requireRole(extra, Role.CONTRIBUTOR); const { emdash, userId } = getExtra(extra); // Creating a translation requires edit permission on the source item if (args.translationOf) { const source = await emdash.handleContentGet(args.collection, args.translationOf); if (!source.success) return unwrap(source); requireOwnership( extra, extractContentAuthorId(source.data), "content:edit_own", "content:edit_any", ); } const data = await convertWriteData(emdash, args.collection, args.data); // Publishing requires publish permission — create as draft then publish if (args.status === "published") { const user = { id: userId, role: getExtra(extra).userRole }; if (!hasPermission(user, "content:publish_own" as Permission)) { throw new EmDashAuthError( "Insufficient permissions: publishing requires content:publish_own", "INSUFFICIENT_PERMISSIONS", ); } const result = await emdash.handleContentCreate(args.collection, { data, slug: args.slug, authorId: userId, locale: args.locale, translationOf: args.translationOf, bylines: args.bylines, }); if (!result.success) return unwrap(result); const itemId = extractContentId(result.data); if (itemId) { return unwrap(await emdash.handleContentPublish(args.collection, itemId)); } return unwrap(result); } return unwrap( await emdash.handleContentCreate(args.collection, { data, slug: args.slug, authorId: userId, locale: args.locale, translationOf: args.translationOf, bylines: args.bylines, }), ); }, ); server.registerTool( "content_update", { title: "Update Content", description: "Update an existing content item. Only include fields you want to change " + "in the 'data' object — unspecified fields are left unchanged. Rich text " + "(portableText) fields accept a Markdown string (recommended, converted " + "automatically); use a Portable Text JSON array only for complex content " + "Markdown can't express (custom blocks, embeds). Pass the " + "_rev token from content_get to enable optimistic concurrency checking " + "(the update fails if the item was modified since you read it). " + "`seo` and `bylines` are persisted alongside the field updates in a " + "single transaction. `publishedAt` requires the content:publish_any " + "permission and is useful for migrations or correcting historical dates.", inputSchema: z.object({ collection: z.string().describe("Collection slug"), id: z.string().describe("Content item ID or slug"), locale: z .string() .optional() .describe( "Locale to scope slug lookup (e.g. 'fr'). Only affects slug resolution; IDs are globally unique.", ), data: z .record(z.string(), z.unknown()) .optional() .describe("Field values to update (only include changed fields)"), slug: z.string().optional().describe("New URL slug"), status: z .enum(["draft", "published"]) .optional() .describe( "New status. Setting to 'published' requires publish permission. Setting to 'draft' unpublishes the item and also requires publish permission.", ), // Reuse the REST schema rather than redefining inline. The REST schema's // `canonical` field is gated through `httpUrl` (validates the URL parses // AND has an http(s) scheme) which rejects javascript:/data: URIs that // would otherwise become stored XSS in the rendered . // Inlining a looser shape here would let MCP callers bypass that. seo: contentSeoInput .optional() .describe( "Per-content SEO metadata. Only valid for collections with SEO enabled (see schema_get_collection.hasSeo). Fields not included are left unchanged; pass null to clear.", ), bylines: z .array(contentBylineInputSchema) .optional() .describe( "Replace the byline list for this item. The first entry becomes the primary byline. Pass an empty array to clear all bylines.", ), publishedAt: z.iso .datetime({ offset: true, message: "must be an ISO 8601 datetime" }) .nullish() .describe( "Override the publication timestamp (ISO 8601). Requires content:publish_any permission. Pass null to clear. Useful for content migrations.", ), _rev: z .string() .optional() .describe("Revision token from content_get for conflict detection"), }), }, async (args, extra) => { requireScope(extra, "content:write"); requireRole(extra, Role.AUTHOR); const { emdash, userId, userRole } = getExtra(extra); // Fetch item to check ownership const existing = await emdash.handleContentGet(args.collection, args.id, args.locale); if (!existing.success) { return unwrap(existing); } const ownerId = extractContentAuthorId(existing.data); requireOwnership(extra, ownerId, "content:edit_own", "content:edit_any"); const data = args.data ? await convertWriteData(emdash, args.collection, args.data) : args.data; // Writing publishedAt directly (incl. clearing to null) overwrites // historical record — gate behind publish_any, mirroring the REST PUT // route. Status-driven publishes are gated separately below. if (args.publishedAt !== undefined) { const user = { id: userId, role: userRole }; if (!hasPermission(user, "content:publish_any" as Permission)) { throw new EmDashAuthError( "Setting publishedAt requires content:publish_any permission", "INSUFFICIENT_PERMISSIONS", ); } } const resolvedId = extractContentId(existing.data) ?? args.id; // Status transitions route through dedicated handlers for proper revision management if (args.status === "published") { requireOwnership(extra, ownerId, "content:publish_own", "content:publish_any"); if ( args.data || args.slug || args.seo !== undefined || args.bylines !== undefined || args.publishedAt !== undefined ) { const updateResult = await emdash.handleContentUpdate(args.collection, resolvedId, { data, slug: args.slug, authorId: userId, locale: args.locale, seo: args.seo, bylines: args.bylines, publishedAt: args.publishedAt, _rev: args._rev, }); if (!updateResult.success) return unwrap(updateResult); } return unwrap(await emdash.handleContentPublish(args.collection, resolvedId)); } if (args.status === "draft") { requireOwnership(extra, ownerId, "content:publish_own", "content:publish_any"); if ( args.data || args.slug || args.seo !== undefined || args.bylines !== undefined || args.publishedAt !== undefined ) { const updateResult = await emdash.handleContentUpdate(args.collection, resolvedId, { data, slug: args.slug, authorId: userId, locale: args.locale, seo: args.seo, bylines: args.bylines, publishedAt: args.publishedAt, _rev: args._rev, }); if (!updateResult.success) return unwrap(updateResult); } return unwrap(await emdash.handleContentUnpublish(args.collection, resolvedId)); } return unwrap( await emdash.handleContentUpdate(args.collection, resolvedId, { data, slug: args.slug, authorId: userId, locale: args.locale, seo: args.seo, bylines: args.bylines, publishedAt: args.publishedAt, _rev: args._rev, }), ); }, ); server.registerTool( "content_delete", { title: "Delete Content (Trash)", description: "Soft-delete a content item by moving it to the trash. The item can be " + "restored later with content_restore, or permanently deleted with " + "content_permanent_delete.", inputSchema: z.object({ collection: z.string().describe("Collection slug"), id: z.string().describe("Content item ID or slug"), }), annotations: { destructiveHint: true }, }, async (args, extra) => { requireScope(extra, "content:write"); requireRole(extra, Role.AUTHOR); const ec = getEmDash(extra); // Fetch item to check ownership const existing = await ec.handleContentGet(args.collection, args.id); if (!existing.success) { return unwrap(existing); } requireOwnership( extra, extractContentAuthorId(existing.data), "content:delete_own", "content:delete_any", ); const resolvedId = extractContentId(existing.data) ?? args.id; return unwrap(await ec.handleContentDelete(args.collection, resolvedId)); }, ); server.registerTool( "content_restore", { title: "Restore Content", description: "Restore a soft-deleted content item from the trash back to its previous state.", inputSchema: z.object({ collection: z.string().describe("Collection slug"), id: z.string().describe("Content item ID or slug"), }), }, async (args, extra) => { requireScope(extra, "content:write"); requireRole(extra, Role.AUTHOR); const ec = getEmDash(extra); // Fetch trashed item to check ownership const existing = await ec.handleContentGetIncludingTrashed(args.collection, args.id); if (!existing.success) { return unwrap(existing); } requireOwnership( extra, extractContentAuthorId(existing.data), "content:edit_own", "content:edit_any", ); const resolvedId = extractContentId(existing.data) ?? args.id; return unwrap(await ec.handleContentRestore(args.collection, resolvedId)); }, ); server.registerTool( "content_permanent_delete", { title: "Permanently Delete Content", description: "Permanently and irreversibly delete a trashed content item. The item " + "must be in the trash first (use content_delete). This cannot be undone.", inputSchema: z.object({ collection: z.string().describe("Collection slug"), id: z.string().describe("Content item ID or slug"), }), annotations: { destructiveHint: true }, }, async (args, extra) => { requireScope(extra, "content:write"); requireRole(extra, Role.ADMIN); const ec = getEmDash(extra); return unwrap(await ec.handleContentPermanentDelete(args.collection, args.id)); }, ); server.registerTool( "content_publish", { title: "Publish Content", description: "Publish a content item, making it live on the site. Creates a published " + "revision from the current draft. Further edits create a new draft without " + "affecting the live version until re-published. Pass `publishedAt` to " + "backdate (e.g. when migrating content from another CMS) — this requires " + "the content:publish_any permission. Without `publishedAt`, the existing " + "`published_at` is preserved on re-publish (idempotent) and falls back to " + "the current time on first publish.", inputSchema: z.object({ collection: z.string().describe("Collection slug"), id: z.string().describe("Content item ID or slug"), publishedAt: z.iso .datetime({ offset: true, message: "must be an ISO 8601 datetime" }) .optional() .describe( "Override publication timestamp (ISO 8601). Requires content:publish_any permission. Useful when importing content with original publish dates.", ), }), }, async (args, extra) => { requireScope(extra, "content:write"); requireRole(extra, Role.AUTHOR); const { emdash, userId, userRole } = getExtra(extra); // Fetch item to check ownership const existing = await emdash.handleContentGet(args.collection, args.id); if (!existing.success) { return unwrap(existing); } const ownerId = extractContentAuthorId(existing.data); requireOwnership(extra, ownerId, "content:publish_own", "content:publish_any"); // Backdating overwrites historical record — gate behind publish_any // regardless of ownership (mirrors the REST PUT route's publishedAt gate). if (args.publishedAt !== undefined) { const user = { id: userId, role: userRole }; if (!hasPermission(user, "content:publish_any" as Permission)) { throw new EmDashAuthError( "Setting publishedAt requires content:publish_any permission", "INSUFFICIENT_PERMISSIONS", ); } } const resolvedId = extractContentId(existing.data) ?? args.id; return unwrap( await emdash.handleContentPublish(args.collection, resolvedId, { publishedAt: args.publishedAt, }), ); }, ); server.registerTool( "content_unpublish", { title: "Unpublish Content", description: "Unpublish a content item, reverting it to draft status. It will no " + "longer be visible on the live site but its content is preserved.", inputSchema: z.object({ collection: z.string().describe("Collection slug"), id: z.string().describe("Content item ID or slug"), }), }, async (args, extra) => { requireScope(extra, "content:write"); requireRole(extra, Role.AUTHOR); const ec = getEmDash(extra); // Fetch item to check ownership const existing = await ec.handleContentGet(args.collection, args.id); if (!existing.success) { return unwrap(existing); } requireOwnership( extra, extractContentAuthorId(existing.data), "content:publish_own", "content:publish_any", ); const resolvedId = extractContentId(existing.data) ?? args.id; return unwrap(await ec.handleContentUnpublish(args.collection, resolvedId)); }, ); server.registerTool( "content_schedule", { title: "Schedule Content", description: "Schedule a content item for future publication. It will be automatically " + "published at the specified date/time. The scheduledAt value must be an " + "ISO 8601 datetime string in the future (e.g. '2025-06-01T09:00:00Z').", inputSchema: z.object({ collection: z.string().describe("Collection slug"), id: z.string().describe("Content item ID or slug"), scheduledAt: z .string() .describe("ISO 8601 datetime for publication (e.g. '2025-06-01T09:00:00Z')"), }), }, async (args, extra) => { requireScope(extra, "content:write"); requireRole(extra, Role.AUTHOR); const ec = getEmDash(extra); // Fetch item to check ownership const existing = await ec.handleContentGet(args.collection, args.id); if (!existing.success) { return unwrap(existing); } requireOwnership( extra, extractContentAuthorId(existing.data), "content:publish_own", "content:publish_any", ); const resolvedId = extractContentId(existing.data) ?? args.id; return unwrap(await ec.handleContentSchedule(args.collection, resolvedId, args.scheduledAt)); }, ); server.registerTool( "content_unschedule", { title: "Cancel Scheduled Publication", description: "Cancel a previously scheduled publication. The item remains in its current " + "status (typically 'draft' or 'scheduled'); only the scheduledAt timestamp is " + "cleared. Idempotent — calling on an item that isn't scheduled is a no-op.", inputSchema: z.object({ collection: z.string().describe("Collection slug"), id: z.string().describe("Content item ID or slug"), }), }, async (args, extra) => { requireScope(extra, "content:write"); requireRole(extra, Role.AUTHOR); const ec = getEmDash(extra); const existing = await ec.handleContentGet(args.collection, args.id); if (!existing.success) { return unwrap(existing); } requireOwnership( extra, extractContentAuthorId(existing.data), "content:publish_own", "content:publish_any", ); const resolvedId = extractContentId(existing.data) ?? args.id; return unwrap(await ec.handleContentUnschedule(args.collection, resolvedId)); }, ); server.registerTool( "content_compare", { title: "Compare Live vs Draft", description: "Compare the published (live) version of a content item with its current " + "draft. Returns both versions and a flag indicating whether there are " + "changes. Useful for reviewing unpublished edits before publishing.", inputSchema: z.object({ collection: z.string().describe("Collection slug"), id: z.string().describe("Content item ID or slug"), }), annotations: { readOnlyHint: true }, }, async (args, extra) => { requireScope(extra, "content:read"); requireDraftAccess(extra); const ec = getEmDash(extra); return unwrap(await ec.handleContentCompare(args.collection, args.id)); }, ); server.registerTool( "content_discard_draft", { title: "Discard Draft", description: "Discard the current draft changes and revert to the last published " + "version. Only works on items that have been published at least once.", inputSchema: z.object({ collection: z.string().describe("Collection slug"), id: z.string().describe("Content item ID or slug"), }), annotations: { destructiveHint: true }, }, async (args, extra) => { requireScope(extra, "content:write"); requireRole(extra, Role.AUTHOR); const ec = getEmDash(extra); // Fetch item to check ownership const existing = await ec.handleContentGet(args.collection, args.id); if (!existing.success) { return unwrap(existing); } requireOwnership( extra, extractContentAuthorId(existing.data), "content:edit_own", "content:edit_any", ); const resolvedId = extractContentId(existing.data) ?? args.id; return unwrap(await ec.handleContentDiscardDraft(args.collection, resolvedId)); }, ); server.registerTool( "content_list_trashed", { title: "List Trashed Content", description: "List soft-deleted content items in a collection's trash. These items " + "can be restored with content_restore or permanently deleted with " + "content_permanent_delete.", inputSchema: z.object({ collection: z.string().describe("Collection slug"), limit: z.number().int().min(1).max(100).optional().describe("Max items (default 50)"), cursor: z.string().min(1).max(2048).optional().describe("Pagination cursor"), }), annotations: { readOnlyHint: true }, }, async (args, extra) => { requireScope(extra, "content:read"); requireDraftAccess(extra); const ec = getEmDash(extra); return unwrap( await ec.handleContentListTrashed(args.collection, { limit: args.limit, cursor: args.cursor, }), ); }, ); server.registerTool( "content_duplicate", { title: "Duplicate Content", description: "Create a copy of an existing content item. The duplicate is created " + "as a draft with '(Copy)' appended to the title and an auto-generated slug.", inputSchema: z.object({ collection: z.string().describe("Collection slug"), id: z.string().describe("Content item ID or slug to duplicate"), }), }, async (args, extra) => { requireScope(extra, "content:write"); requireRole(extra, Role.CONTRIBUTOR); const ec = getEmDash(extra); return unwrap(await ec.handleContentDuplicate(args.collection, args.id)); }, ); server.registerTool( "content_translations", { title: "Get Content Translations", description: "Get all locale variants of a content item. Returns the translation group " + "and a summary of each locale version (id, locale, slug, status). Only " + "relevant when i18n is enabled on the site.", inputSchema: z.object({ collection: z.string().describe("Collection slug"), id: z.string().describe("Content item ID or slug"), }), annotations: { readOnlyHint: true }, }, async (args, extra) => { requireScope(extra, "content:read"); const ec = getEmDash(extra); const result = await ec.handleContentTranslations(args.collection, args.id); // Filter out non-published translations for users without draft // access so a subscriber can't enumerate locales that aren't yet live. if (result.success && !canReadDrafts(extra)) { const data = result.data && typeof result.data === "object" ? // eslint-disable-next-line typescript/no-unsafe-type-assertion -- handler returns unknown data; narrowed by typeof check (result.data as Record) : undefined; const translations = Array.isArray(data?.translations) ? data.translations : []; const filtered = translations.filter(isPublished); return unwrap({ success: true, data: { ...data, translations: filtered }, }); } return unwrap(result); }, ); // ===================================================================== // Byline tools // ===================================================================== server.registerTool( "byline_list", { title: "List Bylines", description: "List bylines (author/contributor credits) with optional filtering and " + "pagination. Bylines are standalone records referenced by content items; " + "use the returned id with content_create/content_update or byline_get. Use " + "the nextCursor value from the response to fetch the next page.", inputSchema: z.object({ search: z.string().optional().describe("Filter by display name or slug substring"), isGuest: z.boolean().optional().describe("Filter by guest (true) or linked-user (false)"), userId: z.string().optional().describe("Filter to the byline linked to a CMS user ID"), locale: z.string().optional().describe("Filter by locale (omit for all)"), limit: z.number().int().min(1).max(100).optional().describe("Max items (default 50)"), cursor: z.string().min(1).max(2048).optional().describe("Pagination cursor"), }), annotations: { readOnlyHint: true }, }, async (args, extra) => { requireScope(extra, "content:read"); const ec = getEmDash(extra); try { const { BylineRepository } = await import("../database/repositories/byline.js"); const repo = new BylineRepository(ec.db); return jsonResult( await repo.findMany({ search: args.search, isGuest: args.isGuest, userId: args.userId, locale: args.locale, limit: args.limit, cursor: args.cursor, }), ); } catch (error) { return respondHandlerError(error, "BYLINE_LIST_ERROR"); } }, ); server.registerTool( "byline_get", { title: "Get Byline", description: "Get a single byline by its ID, including bio, avatar, website, linked " + "user, and any custom fields.", inputSchema: z.object({ id: z.string().describe("Byline ID"), }), annotations: { readOnlyHint: true }, }, async (args, extra) => { requireScope(extra, "content:read"); const ec = getEmDash(extra); try { const { BylineRepository } = await import("../database/repositories/byline.js"); const byline = await new BylineRepository(ec.db).findById(args.id); if (!byline) return respondError("NOT_FOUND", `Byline '${args.id}' not found`); return jsonResult(byline); } catch (error) { return respondHandlerError(error, "BYLINE_GET_ERROR"); } }, ); server.registerTool( "byline_create", { title: "Create Byline", description: "Create a new byline (author/contributor credit). The slug must be unique " + "and contain only lowercase letters, digits, and hyphens. Link the byline " + "to a CMS user via userId, or leave it as a standalone guest credit. The " + "returned id can then be passed to content_create/content_update bylines.", inputSchema: z.object({ ...bylineCreateBody.shape }), annotations: { destructiveHint: false }, }, async (args, extra) => { requireScope(extra, "content:write"); requireRole(extra, Role.EDITOR); const ec = getEmDash(extra); try { const { handleBylineCreate } = await import("../api/handlers/bylines.js"); const result = await handleBylineCreate(ec.db, args); if (result.success) await invalidateBylines(); return unwrap(result); } catch (error) { return respondHandlerError(error, "BYLINE_CREATE_ERROR"); } }, ); server.registerTool( "byline_update", { title: "Update Byline", description: "Update an existing byline. Any field can be omitted to leave it " + "unchanged. Renaming the slug must not collide with another byline.", inputSchema: z.object({ id: z.string().describe("Byline ID to update"), ...bylineUpdateBody.shape, }), }, async (args, extra) => { requireScope(extra, "content:write"); requireRole(extra, Role.EDITOR); const ec = getEmDash(extra); try { const { handleBylineUpdate } = await import("../api/handlers/bylines.js"); const { id, ...input } = args; const result = await handleBylineUpdate(ec.db, id, input); if (result.success) await invalidateBylines(); return unwrap(result); } catch (error) { return respondHandlerError(error, "BYLINE_UPDATE_ERROR"); } }, ); server.registerTool( "byline_delete", { title: "Delete Byline", description: "Permanently delete a byline. Any content crediting this byline loses the " + "association, and it is cleared as a primary byline where set.", inputSchema: z.object({ id: z.string().describe("Byline ID to delete"), }), annotations: { destructiveHint: true }, }, async (args, extra) => { requireScope(extra, "content:write"); requireRole(extra, Role.EDITOR); const ec = getEmDash(extra); try { const { BylineRepository } = await import("../database/repositories/byline.js"); const deleted = await new BylineRepository(ec.db).delete(args.id); if (!deleted) return respondError("NOT_FOUND", `Byline '${args.id}' not found`); await invalidateBylines(); return jsonResult({ deleted: args.id }); } catch (error) { return respondHandlerError(error, "BYLINE_DELETE_ERROR"); } }, ); server.registerTool( "byline_translations", { title: "List Byline Translations", description: "Return every locale variant of a byline, identified via its shared translation_group.", inputSchema: z.object({ id: z.string().describe("Byline id (or translation_group)"), }), annotations: { readOnlyHint: true }, }, async (args, extra) => { requireScope(extra, "content:read"); const ec = getEmDash(extra); try { const { handleBylineTranslations } = await import("../api/handlers/bylines.js"); return unwrap(await handleBylineTranslations(ec.db, args.id)); } catch (error) { return respondHandlerError(error, "BYLINE_TRANSLATIONS_ERROR"); } }, ); // ===================================================================== // Schema tools // ===================================================================== server.registerTool( "schema_list_collections", { title: "List Collections", description: "List all content collections defined in the CMS. Each collection " + "represents a content type (e.g. posts, pages, products) with its own " + "schema and database table. Returns slug, label, supported features, " + "and timestamps.", inputSchema: z.object({}), annotations: { readOnlyHint: true }, }, async (_args, extra) => { requireScope(extra, "schema:read"); requireRole(extra, Role.EDITOR); const ec = getEmDash(extra); try { const { SchemaRegistry } = await import("../schema/index.js"); const registry = new SchemaRegistry(ec.db); const items = await registry.listCollections(); return jsonResult({ items }); } catch (error) { return respondHandlerError(error, "SCHEMA_LIST_ERROR"); } }, ); server.registerTool( "schema_get_collection", { title: "Get Collection Schema", description: "Get detailed info about a collection including all field definitions. " + "Fields describe the data model: name, type (string, text, number, " + "boolean, datetime, portableText, image, reference, json, select, " + "multiSelect, slug), constraints, and validation rules. Use this to " + "understand what data content_create and content_update expect.", inputSchema: z.object({ slug: z .string() .describe( "Collection slug (e.g. 'posts'). Use schema_list_collections to see available slugs.", ), }), annotations: { readOnlyHint: true }, }, async (args, extra) => { requireScope(extra, "schema:read"); requireRole(extra, Role.EDITOR); const ec = getEmDash(extra); try { const { SchemaRegistry } = await import("../schema/index.js"); const registry = new SchemaRegistry(ec.db); const collection = await registry.getCollectionWithFields(args.slug); if (!collection) { return respondError("NOT_FOUND", `Collection '${args.slug}' not found`); } return jsonResult(collection); } catch (error) { return respondHandlerError(error, "SCHEMA_GET_ERROR"); } }, ); server.registerTool( "schema_create_collection", { title: "Create Collection", description: "Create a new content collection (content type). This creates a database " + "table and schema definition. The slug must be lowercase alphanumeric " + "with underscores, starting with a letter. Supports: 'drafts' (draft/" + "publish workflow), 'revisions' (version history), 'preview' (live " + "preview), 'scheduling' (timed publish), 'search' (full-text indexing).", inputSchema: z.object({ slug: z .string() .regex(COLLECTION_SLUG_PATTERN) .describe("Unique identifier (lowercase letters, numbers, underscores)"), label: z.string().describe("Display name (plural, e.g. 'Blog Posts')"), labelSingular: z.string().optional().describe("Singular display name (e.g. 'Blog Post')"), description: z.string().optional().describe("Description of this collection"), icon: z.string().optional().describe("Icon name for the admin UI"), supports: z .array(z.enum(["drafts", "revisions", "preview", "scheduling", "search"])) .optional() .describe("Features to enable (default: ['drafts', 'revisions'])"), }), }, async (args, extra) => { requireScope(extra, "schema:write"); requireRole(extra, Role.ADMIN); const ec = getEmDash(extra); try { const { SchemaRegistry } = await import("../schema/index.js"); const registry = new SchemaRegistry(ec.db); const collection = await registry.createCollection({ slug: args.slug, label: args.label, labelSingular: args.labelSingular, description: args.description, icon: args.icon, // SchemaRegistry.createCollection now defaults `supports` to // ['drafts', 'revisions'] when undefined; pass through verbatim. supports: args.supports, }); ec.invalidateUrlPatternCache(); return jsonResult(collection); } catch (error) { return respondHandlerError(error, "SCHEMA_CREATE_ERROR"); } }, ); server.registerTool( "schema_delete_collection", { title: "Delete Collection", description: "Delete a collection and its database table. This is irreversible and " + "deletes all content in the collection. Use with extreme caution.", inputSchema: z.object({ slug: z.string().describe("Collection slug to delete"), force: z .boolean() .optional() .describe("Force deletion even if the collection has content (default false)"), }), annotations: { destructiveHint: true }, }, async (args, extra) => { requireScope(extra, "schema:write"); requireRole(extra, Role.ADMIN); const ec = getEmDash(extra); try { const { SchemaRegistry } = await import("../schema/index.js"); const registry = new SchemaRegistry(ec.db); await registry.deleteCollection(args.slug, { force: args.force }); ec.invalidateUrlPatternCache(); return jsonResult({ deleted: args.slug }); } catch (error) { return respondHandlerError(error, "SCHEMA_DELETE_ERROR"); } }, ); server.registerTool( "schema_create_field", { title: "Add Field to Collection", description: "Add a new field to a collection's schema. This adds a column to the " + "database table. Field types: string (short text), text (long text), " + "number (decimal), integer, boolean, datetime, select (single choice), " + "multiSelect (multiple), portableText (rich text), image, file, " + "reference (link to another collection), json, slug (URL-safe id). " + "For select/multiSelect, provide choices in validation.options array.", inputSchema: z.object({ collection: z.string().describe("Collection slug to add the field to"), slug: z .string() .regex(COLLECTION_SLUG_PATTERN) .describe("Field identifier (lowercase letters, numbers, underscores)"), label: z.string().describe("Display name for the field"), type: z .enum([ "string", "text", "number", "integer", "boolean", "datetime", "select", "multiSelect", "portableText", "image", "file", "reference", "json", "slug", ]) .describe("Data type for this field"), required: z.boolean().optional().describe("Whether the field is required (default false)"), unique: z.boolean().optional().describe("Whether values must be unique (default false)"), defaultValue: z.unknown().optional().describe("Default value for new items"), validation: z .object({ min: z.number().optional(), max: z.number().optional(), minLength: z.number().optional(), maxLength: z.number().optional(), pattern: z.string().optional(), options: z .array(z.string()) .optional() .describe("Allowed values for select/multiSelect"), }) .optional() .describe("Validation constraints"), options: z .object({ collection: z .string() .optional() .describe("Target collection slug for reference fields"), rows: z.number().optional().describe("Number of rows for textarea"), }) .passthrough() .optional() .describe("Widget configuration"), searchable: z .boolean() .optional() .describe("Include in full-text search index (default false)"), translatable: z .boolean() .optional() .describe( "Whether this field is translatable (default true). " + "Non-translatable fields are synced across all locales in a translation group.", ), }), }, async (args, extra) => { requireScope(extra, "schema:write"); requireRole(extra, Role.ADMIN); const ec = getEmDash(extra); try { const { SchemaRegistry } = await import("../schema/index.js"); const registry = new SchemaRegistry(ec.db); const field = await registry.createField(args.collection, { slug: args.slug, label: args.label, type: args.type, required: args.required, unique: args.unique, defaultValue: args.defaultValue, validation: args.validation, options: args.options, searchable: args.searchable, translatable: args.translatable, }); return jsonResult(field); } catch (error) { return respondHandlerError(error, "FIELD_CREATE_ERROR"); } }, ); server.registerTool( "schema_delete_field", { title: "Remove Field from Collection", description: "Remove a field from a collection. This drops the column from the " + "database table and deletes all data in that field. Irreversible.", inputSchema: z.object({ collection: z.string().describe("Collection slug"), fieldSlug: z.string().describe("Field slug to remove"), }), annotations: { destructiveHint: true }, }, async (args, extra) => { requireScope(extra, "schema:write"); requireRole(extra, Role.ADMIN); const ec = getEmDash(extra); try { const { SchemaRegistry } = await import("../schema/index.js"); const registry = new SchemaRegistry(ec.db); await registry.deleteField(args.collection, args.fieldSlug); return jsonResult({ deleted: args.fieldSlug, collection: args.collection }); } catch (error) { return respondHandlerError(error, "FIELD_DELETE_ERROR"); } }, ); // ===================================================================== // Media tools // ===================================================================== server.registerTool( "media_list", { title: "List Media", description: "List uploaded media files (images, documents, etc.) with optional MIME " + "type filtering and pagination. Returns file metadata including filename, " + "URL, dimensions, and alt text.", inputSchema: z.object({ mimeType: z .string() .optional() .describe("Filter by MIME type prefix (e.g. 'image/', 'application/pdf')"), limit: z.number().int().min(1).max(100).optional().describe("Max items (default 50)"), cursor: z.string().min(1).max(2048).optional().describe("Pagination cursor"), }), annotations: { readOnlyHint: true }, }, async (args, extra) => { requireScope(extra, "media:read"); const ec = getEmDash(extra); return unwrap( await ec.handleMediaList({ mimeType: args.mimeType, limit: args.limit, cursor: args.cursor, }), ); }, ); server.registerTool( "media_create", { title: "Register Uploaded Media", description: "Register a media file that has already been uploaded to storage. The " + "caller is responsible for placing the file at `storageKey` (typically " + "using a signed upload URL obtained from the admin UI or a separate API). " + "This tool persists the metadata record so the file is discoverable via " + "media_list / media_get and can be referenced by content. For binary " + "uploads the MCP transport is not appropriate — use the signed-upload " + "flow instead.", inputSchema: z.object({ filename: z.string().describe("Original filename (e.g. 'logo.png')"), mimeType: z.string().describe("MIME type (e.g. 'image/png')"), storageKey: z.string().describe("Storage path/key the file was uploaded to"), size: z.number().int().nonnegative().optional().describe("File size in bytes"), width: z.number().int().positive().optional().describe("Image width in pixels"), height: z.number().int().positive().optional().describe("Image height in pixels"), contentHash: z.string().optional().describe("Hash of the file contents (for dedupe)"), blurhash: z.string().optional().describe("Blurhash for image placeholders"), dominantColor: z .string() .optional() .describe("Hex color string for the image's dominant color"), }), }, async (args, extra) => { requireScope(extra, "media:write"); requireRole(extra, Role.AUTHOR); const { emdash, userId } = getExtra(extra); return unwrap( await emdash.handleMediaCreate({ filename: args.filename, mimeType: args.mimeType, storageKey: args.storageKey, size: args.size, width: args.width, height: args.height, contentHash: args.contentHash, blurhash: args.blurhash, dominantColor: args.dominantColor, authorId: userId, }), ); }, ); server.registerTool( "media_get", { title: "Get Media Item", description: "Get details of a single media file by its ID. Returns metadata " + "including filename, MIME type, size, dimensions, alt text, and URL.", inputSchema: z.object({ id: z.string().describe("Media item ID"), }), annotations: { readOnlyHint: true }, }, async (args, extra) => { requireScope(extra, "media:read"); const ec = getEmDash(extra); return unwrap(await ec.handleMediaGet(args.id)); }, ); server.registerTool( "media_update", { title: "Update Media Metadata", description: "Update the metadata of an uploaded media file. You can change the " + "alt text, caption, and dimensions. The file itself cannot be changed.", inputSchema: z.object({ id: z.string().describe("Media item ID"), alt: z.string().optional().describe("Alt text for accessibility"), caption: z.string().optional().describe("Caption text"), width: z.number().int().optional().describe("Image width in pixels"), height: z.number().int().optional().describe("Image height in pixels"), }), }, async (args, extra) => { requireScope(extra, "media:write"); requireRole(extra, Role.AUTHOR); const ec = getEmDash(extra); // Fetch media item for ownership check const existing = await ec.handleMediaGet(args.id); if (!existing.success) { return unwrap(existing); } const media = (existing.data as Record | undefined)?.item as | Record | undefined; const authorId = typeof media?.authorId === "string" ? media.authorId : ""; requireOwnership(extra, authorId, "media:edit_own", "media:edit_any"); return unwrap( await ec.handleMediaUpdate(args.id, { alt: args.alt, caption: args.caption, width: args.width, height: args.height, }), ); }, ); server.registerTool( "media_delete", { title: "Delete Media", description: "Permanently delete an uploaded media file. Removes the database record " + "and the file from storage. Content referencing this media will have " + "broken references. Cannot be undone.", inputSchema: z.object({ id: z.string().describe("Media item ID"), }), annotations: { destructiveHint: true }, }, async (args, extra) => { requireScope(extra, "media:write"); requireRole(extra, Role.AUTHOR); const ec = getEmDash(extra); // Fetch media item for ownership check const existing = await ec.handleMediaGet(args.id); if (!existing.success) { return unwrap(existing); } const media = (existing.data as Record | undefined)?.item as | Record | undefined; const authorId = typeof media?.authorId === "string" ? media.authorId : ""; requireOwnership(extra, authorId, "media:delete_own", "media:delete_any"); return unwrap(await ec.handleMediaDelete(args.id)); }, ); // ===================================================================== // Search tool // ===================================================================== server.registerTool( "search", { title: "Search Content", description: "Full-text search across content collections. Searches indexed fields " + "for matching content. Collections must have 'search' in their supports " + "list and fields must be marked as searchable. Returns collection, item " + "ID, title, excerpt, and relevance score.", inputSchema: z.object({ query: z.string().describe("Search query text"), collections: z .array(z.string()) .optional() .describe("Limit search to specific collection slugs (all if omitted)"), locale: z .string() .optional() .describe("Filter results by locale (omit to search all locales)"), limit: z.number().int().min(1).max(50).optional().describe("Max results (default 20)"), }), annotations: { readOnlyHint: true }, }, async (args, extra) => { requireScope(extra, "content:read"); const ec = getEmDash(extra); try { const { searchWithDb } = await import("../search/index.js"); const results = await searchWithDb(ec.db, args.query, { collections: args.collections, locale: args.locale, limit: args.limit, }); return jsonResult(results); } catch (error) { return respondHandlerError(error, "SEARCH_ERROR"); } }, ); // ===================================================================== // Taxonomy tools // ===================================================================== server.registerTool( "taxonomy_list", { title: "List Taxonomies", description: "List all taxonomy definitions (e.g. categories, tags). Taxonomies are " + "classification systems applied to content. Each has a name, label, and " + "can be hierarchical (categories) or flat (tags). Optionally filter by " + "locale.", inputSchema: z.object({ locale: z.string().optional().describe("Filter by locale (omit for all)"), }), annotations: { readOnlyHint: true }, }, async (args, extra) => { requireScope(extra, "content:read"); const ec = getEmDash(extra); try { const { handleTaxonomyList } = await import("../api/handlers/taxonomies.js"); return unwrap(await handleTaxonomyList(ec.db, { locale: args.locale })); } catch (error) { return respondHandlerError(error, "TAXONOMY_LIST_ERROR"); } }, ); server.registerTool( "taxonomy_list_terms", { title: "List Taxonomy Terms", description: "List terms in a taxonomy with pagination. Terms are individual entries " + "(e.g. specific categories or tags). Hierarchical taxonomies can have " + "parent-child relationships.", inputSchema: z.object({ taxonomy: z.string().describe("Taxonomy name (e.g. 'categories', 'tags')"), limit: z.number().int().min(1).max(100).optional().describe("Max items (default 50)"), cursor: z.string().min(1).max(2048).optional().describe("Pagination cursor"), locale: z.string().optional().describe("Filter by locale (omit for all)"), }), annotations: { readOnlyHint: true }, }, async (args, extra) => { requireScope(extra, "content:read"); const ec = getEmDash(extra); try { const { handleTaxonomyList } = await import("../api/handlers/taxonomies.js"); const listResult = await handleTaxonomyList(ec.db, { locale: args.locale }); if (!listResult.success) return unwrap(listResult); const taxonomies = (listResult.data as { taxonomies: Array<{ name: string; id?: string }> }) .taxonomies; const taxonomy = taxonomies.find((t: { name: string }) => t.name === args.taxonomy); if (!taxonomy) return respondError("NOT_FOUND", `Taxonomy '${args.taxonomy}' not found`); const { TaxonomyRepository } = await import("../database/repositories/taxonomy.js"); const { decodeCursor, encodeCursor, InvalidCursorError } = await import("../database/repositories/types.js"); const repo = new TaxonomyRepository(ec.db); const limit = Math.min(args.limit ?? 50, 100); const terms = await repo.findByName(args.taxonomy, { locale: args.locale }); // Manual keyset pagination over the sorted-by-label results. // Using a base64-encoded `(label, id)` cursor matches the // scheme other list endpoints use and tolerates concurrent // deletion of the cursor-term — the cursor is a position, // not a row reference, so a missing row just means we skip // past it rather than erroring. let startIdx = 0; if (args.cursor) { let decoded: { orderValue: string; id: string }; try { decoded = decodeCursor(args.cursor); } catch (error) { if (error instanceof InvalidCursorError) { return respondError("INVALID_CURSOR", error.message); } throw error; } // Find the first term that sorts strictly after the cursor // position. Stable order is `(label asc, id asc)` so a // `(label, id)` tuple comparison is the keyset. startIdx = terms.findIndex( (t) => t.label > decoded.orderValue || (t.label === decoded.orderValue && t.id > decoded.id), ); if (startIdx < 0) startIdx = terms.length; } const page = terms.slice(startIdx, startIdx + limit); const hasMore = startIdx + limit < terms.length; const last = page.at(-1); const nextCursor = hasMore && last ? encodeCursor(last.label, last.id) : undefined; return jsonResult({ items: page.map((t) => ({ id: t.id, name: t.name, slug: t.slug, label: t.label, parentId: t.parentId, description: typeof t.data?.description === "string" ? t.data.description : undefined, locale: t.locale, translationGroup: t.translationGroup, })), nextCursor, }); } catch (error) { return respondHandlerError(error, "TAXONOMY_LIST_TERMS_ERROR"); } }, ); server.registerTool( "taxonomy_create_term", { title: "Create Taxonomy Term", description: "Create a new term in a taxonomy. For hierarchical taxonomies like " + "categories, you can specify a parentId to create a child term. The " + "parent must exist and belong to the same taxonomy. The parent's " + "ancestor chain must not exceed 100 levels — attempts to attach a " + "new term beneath a chain of 100+ existing ancestors are rejected.", inputSchema: z.object({ taxonomy: z.string().describe("Taxonomy name (e.g. 'categories', 'tags')"), slug: z.string().describe("URL-safe identifier for the term"), label: z.string().describe("Display name"), parentId: z.string().optional().describe("Parent term ID for hierarchical taxonomies"), description: z.string().optional().describe("Description of the term"), locale: z.string().optional().describe("Locale for the new term (e.g. 'es')"), translationOf: z .string() .optional() .describe("Term id to join as a translation (same translation_group)"), }), }, async (args, extra) => { requireScope(extra, "taxonomies:manage"); requireRole(extra, Role.EDITOR); const ec = getEmDash(extra); try { const { handleTermCreate } = await import("../api/handlers/taxonomies.js"); return unwrap( await handleTermCreate(ec.db, args.taxonomy, { slug: args.slug, label: args.label, parentId: args.parentId, description: args.description, locale: args.locale, translationOf: args.translationOf, }), ); } catch (error) { return respondHandlerError(error, "TAXONOMY_TERM_CREATE_ERROR"); } }, ); server.registerTool( "taxonomy_update_term", { title: "Update Taxonomy Term", description: "Update an existing term in a taxonomy. Any field can be omitted to leave " + "it unchanged. Renaming a term's slug must not collide with another term in " + "the same taxonomy. Set parentId to null to detach from a parent. The new " + "parent must exist, belong to the same taxonomy, and not introduce a cycle " + "(a term cannot be its own ancestor). The new parent's ancestor chain must " + "not exceed 100 levels — reparenting under a chain of 100+ ancestors is " + "rejected.", inputSchema: z.object({ taxonomy: z.string().describe("Taxonomy name (e.g. 'categories', 'tags')"), termSlug: z.string().describe("Current slug of the term to update"), slug: z.string().optional().describe("New slug (must be unique in the taxonomy)"), label: z.string().optional().describe("New display name"), parentId: z.string().nullable().optional().describe("New parent term ID; null to detach"), description: z.string().optional().describe("New description"), }), }, async (args, extra) => { requireScope(extra, "taxonomies:manage"); requireRole(extra, Role.EDITOR); const ec = getEmDash(extra); try { const { handleTermUpdate } = await import("../api/handlers/taxonomies.js"); return unwrap( await handleTermUpdate(ec.db, args.taxonomy, args.termSlug, { slug: args.slug, label: args.label, parentId: args.parentId, description: args.description, }), ); } catch (error) { return respondHandlerError(error, "TAXONOMY_TERM_UPDATE_ERROR"); } }, ); server.registerTool( "taxonomy_delete_term", { title: "Delete Taxonomy Term", description: "Permanently delete a term from a taxonomy. Any content tagged with this " + "term loses the association. Cannot delete a term that has children — " + "delete children first.", inputSchema: z.object({ taxonomy: z.string().describe("Taxonomy name"), termSlug: z.string().describe("Slug of the term to delete"), }), annotations: { destructiveHint: true }, }, async (args, extra) => { requireScope(extra, "taxonomies:manage"); requireRole(extra, Role.EDITOR); const ec = getEmDash(extra); try { const { handleTermDelete } = await import("../api/handlers/taxonomies.js"); return unwrap(await handleTermDelete(ec.db, args.taxonomy, args.termSlug)); } catch (error) { return respondHandlerError(error, "TAXONOMY_TERM_DELETE_ERROR"); } }, ); server.registerTool( "taxonomy_term_translations", { title: "List Term Translations", description: "Return every locale variant of a taxonomy term, identified via its shared translation_group.", inputSchema: z.object({ id: z.string().describe("Term id (or translation_group)"), }), annotations: { readOnlyHint: true }, }, async (args, extra) => { requireScope(extra, "content:read"); const ec = getEmDash(extra); try { const { handleTermTranslations } = await import("../api/handlers/taxonomies.js"); return unwrap(await handleTermTranslations(ec.db, args.id)); } catch (error) { return respondHandlerError(error, "TERM_TRANSLATIONS_ERROR"); } }, ); // ===================================================================== // Menu tools // ===================================================================== server.registerTool( "menu_list", { title: "List Menus", description: "List navigation menus. Menus are per-locale: filter by `locale` to " + "get just one locale's worth, or omit to list every row (one per " + "locale per menu name).", inputSchema: z.object({ locale: z.string().optional().describe("Filter by locale (omit for all)"), }), annotations: { readOnlyHint: true }, }, async (args, extra) => { requireScope(extra, "content:read"); const ec = getEmDash(extra); try { const { handleMenuList } = await import("../api/handlers/menus.js"); return unwrap(await handleMenuList(ec.db, { locale: args.locale })); } catch (error) { return respondHandlerError(error, "MENU_LIST_ERROR"); } }, ); server.registerTool( "menu_get", { title: "Get Menu with Items", description: "Get a menu by name, including its items. When multiple locales exist, " + "pass `locale` to pick the right one.", inputSchema: z.object({ name: z.string().describe("Menu name (e.g. 'main', 'footer')"), locale: z.string().optional().describe("Locale to resolve the menu for"), }), annotations: { readOnlyHint: true }, }, async (args, extra) => { requireScope(extra, "content:read"); const ec = getEmDash(extra); try { const { handleMenuGet } = await import("../api/handlers/menus.js"); return unwrap(await handleMenuGet(ec.db, args.name, { locale: args.locale })); } catch (error) { return respondHandlerError(error, "MENU_GET_ERROR"); } }, ); server.registerTool( "menu_translations", { title: "List Menu Translations", description: "Return every locale variant of a menu, identified via the shared translation_group.", inputSchema: z.object({ id: z.string().describe("Menu id (or translation_group)"), }), annotations: { readOnlyHint: true }, }, async (args, extra) => { requireScope(extra, "content:read"); const ec = getEmDash(extra); try { const { handleMenuTranslations } = await import("../api/handlers/menus.js"); return unwrap(await handleMenuTranslations(ec.db, args.id)); } catch (error) { return respondHandlerError(error, "MENU_TRANSLATIONS_ERROR"); } }, ); server.registerTool( "menu_create", { title: "Create Menu", description: "Create a new navigation menu. The `name` is the stable identifier used " + "by site templates (e.g. 'main', 'footer'); `label` is the human-readable " + "name shown in the admin. Menus are per-locale, so pass `locale` when " + "the same menu name exists in multiple translations. Add items afterwards " + "with menu_set_items. If `translationOf` is set, `locale` must also be set.", // `locale`-when-`translationOf` is enforced inside handleMenuCreate // so REST/SDK callers get the same guard. The description above // documents the rule; the handler returns VALIDATION_ERROR. inputSchema: z.object({ name: z .string() .regex(COLLECTION_SLUG_PATTERN) .describe("Stable identifier (lowercase letters, numbers, underscores)"), label: z.string().describe("Display name for the admin"), locale: z.string().optional().describe("Locale for this menu (e.g. 'fr-fr')"), translationOf: z .string() .optional() .describe("Existing menu id to create this locale variant from"), }), }, async (args, extra) => { requireScope(extra, "menus:manage"); requireRole(extra, Role.EDITOR); const ec = getEmDash(extra); try { const { handleMenuCreate } = await import("../api/handlers/menus.js"); return unwrap( await handleMenuCreate(ec.db, { name: args.name, label: args.label, locale: args.locale, translationOf: args.translationOf, }), ); } catch (error) { return respondHandlerError(error, "MENU_CREATE_ERROR"); } }, ); server.registerTool( "menu_update", { title: "Update Menu", description: "Update a menu's label. The `name` (stable identifier) cannot be changed. " + "On multi-locale installs, pass `locale` so the correct translation is updated.", inputSchema: z.object({ name: z.string().describe("Menu name to update"), label: z.string().describe("New display label"), locale: z.string().optional().describe("Locale of the menu to update"), }), }, async (args, extra) => { requireScope(extra, "menus:manage"); requireRole(extra, Role.EDITOR); const ec = getEmDash(extra); try { const { handleMenuUpdate } = await import("../api/handlers/menus.js"); return unwrap( await handleMenuUpdate(ec.db, args.name, { label: args.label, locale: args.locale }), ); } catch (error) { return respondHandlerError(error, "MENU_UPDATE_ERROR"); } }, ); server.registerTool( "menu_delete", { title: "Delete Menu", description: "Delete a menu. Items are also removed. Cannot be undone. On multi-locale " + "installs, pass `locale` so only the intended translation is removed.", inputSchema: z.object({ name: z.string().describe("Menu name to delete"), locale: z.string().optional().describe("Locale of the menu to delete"), }), annotations: { destructiveHint: true }, }, async (args, extra) => { requireScope(extra, "menus:manage"); requireRole(extra, Role.EDITOR); const ec = getEmDash(extra); try { const { handleMenuDelete } = await import("../api/handlers/menus.js"); return unwrap(await handleMenuDelete(ec.db, args.name, { locale: args.locale })); } catch (error) { return respondHandlerError(error, "MENU_DELETE_ERROR"); } }, ); server.registerTool( "menu_set_items", { title: "Set Menu Items", description: "Replace the entire item list of a menu in one call. This is atomic: the " + "existing items are deleted and the new list is inserted in the order " + "provided. Use this rather than per-item add/remove tools so the resulting " + "order and parent links are unambiguous. On multi-locale installs, pass " + "`locale` so only the intended translation is rewritten.", inputSchema: z.object({ name: z.string().describe("Menu name to update"), locale: z.string().optional().describe("Locale of the menu to rewrite"), items: z .array( z.object({ label: z.string().describe("Item display text"), type: z .enum(["custom", "page", "post", "taxonomy", "collection"]) .describe("Item kind"), customUrl: z .string() .optional() .describe("URL for type='custom' items (ignored otherwise)"), referenceCollection: z .string() .optional() .describe("Target collection slug for content references"), referenceId: z.string().optional().describe("Target content/term ID for references"), titleAttr: z.string().optional().describe("HTML title attribute"), target: z.string().optional().describe("HTML target attribute, e.g. '_blank'"), cssClasses: z.string().optional().describe("Space-separated CSS classes"), /** * Items are positioned by array index, but parents may be referenced * by their array index — items with `parentIndex` set are nested under * the item at that position. Items without `parentIndex` are top-level. */ parentIndex: z .number() .int() .nonnegative() .optional() .describe( "Array index of the parent item (must be earlier in the list). Omit for top-level items.", ), }), ) .describe("Ordered list of menu items"), }), }, async (args, extra) => { requireScope(extra, "menus:manage"); requireRole(extra, Role.EDITOR); const ec = getEmDash(extra); try { const { handleMenuSetItems } = await import("../api/handlers/menus.js"); return unwrap( await handleMenuSetItems(ec.db, args.name, args.items, { locale: args.locale }), ); } catch (error) { return respondHandlerError(error, "MENU_SET_ITEMS_ERROR"); } }, ); // ===================================================================== // Revision tools // ===================================================================== server.registerTool( "revision_list", { title: "List Revisions", description: "List revision history for a content item. Revisions are snapshots " + "created on publish or update. Returns newest-first. Requires the " + "collection to support 'revisions'.", inputSchema: z.object({ collection: z.string().describe("Collection slug"), id: z.string().describe("Content item ID or slug"), limit: z.number().int().min(1).max(50).optional().describe("Max revisions (default 20)"), }), annotations: { readOnlyHint: true }, }, async (args, extra) => { requireScope(extra, "content:read"); requireDraftAccess(extra); const ec = getEmDash(extra); return unwrap( await ec.handleRevisionList(args.collection, args.id, { limit: args.limit, }), ); }, ); server.registerTool( "revision_restore", { title: "Restore Revision", description: "Restore a content item to a previous revision. Replaces the current " + "draft with the specified revision's data. Not automatically published — " + "use content_publish afterward if needed.", inputSchema: z.object({ revisionId: z.string().describe("Revision ID to restore"), }), }, async (args, extra) => { requireScope(extra, "content:write"); requireRole(extra, Role.AUTHOR); const { emdash, userId } = getExtra(extra); // Fetch the revision to discover the parent content entry const revision = await emdash.handleRevisionGet(args.revisionId); if (!revision.success) { return unwrap(revision); } const revItem = revision.data?.item; if (!revItem?.collection || !revItem?.entryId) { return respondError( "VALIDATION_ERROR", "Revision is missing collection or entry reference", ); } // Fetch the content entry to check ownership const existing = await emdash.handleContentGet(revItem.collection, revItem.entryId); if (!existing.success) { return unwrap(existing); } requireOwnership( extra, extractContentAuthorId(existing.data), "content:edit_own", "content:edit_any", ); return unwrap(await emdash.handleRevisionRestore(args.revisionId, userId)); }, ); // ===================================================================== // Settings tools // ===================================================================== server.registerTool( "settings_get", { title: "Get Site Settings", description: "Get all site-wide settings (title, tagline, logo, favicon, URL, " + "date/time formatting, social links, SEO defaults). Media references " + "(logo, favicon, defaultOgImage) include resolved URLs. Unset values " + "are omitted from the response.", inputSchema: z.object({}), annotations: { readOnlyHint: true }, }, async (_args, extra) => { requireScope(extra, "settings:read"); requireRole(extra, Role.EDITOR); const ec = getEmDash(extra); try { const { handleSettingsGet } = await import("../api/handlers/settings.js"); return unwrap(await handleSettingsGet(ec.db, ec.storage)); } catch (error) { return respondHandlerError(error, "SETTINGS_READ_ERROR"); } }, ); server.registerTool( "settings_update", { title: "Update Site Settings", description: "Update one or more site-wide settings. This is a partial update: only " + "the fields provided are changed; omitted fields are left as-is. Returns " + "the full settings object after the update. To set a media reference " + "(logo, favicon, seo.defaultOgImage), pass an object with `mediaId` " + "(and optional `alt`) — the media item must already exist (use " + "media_create first).", inputSchema: z.object({ title: z.string().optional().describe("Site title"), tagline: z.string().optional().describe("Site tagline / short description"), logo: settingsMediaReferenceSchema .optional() .describe("Logo media reference ({ mediaId, alt? })"), favicon: settingsMediaReferenceSchema .optional() .describe("Favicon media reference ({ mediaId, alt? })"), url: z .union([ z .string() .url() .refine((u) => HTTP_SCHEME_PATTERN.test(u), "URL must use http or https"), z.literal(""), ]) .optional() .describe("Canonical site URL (http or https). Empty string clears it."), postsPerPage: z .number() .int() .min(1) .max(100) .optional() .describe("Default page size for content listings"), dateFormat: z.string().optional().describe("Date format token string"), timezone: z.string().optional().describe("IANA timezone identifier"), social: settingsSocialSchema.optional().describe("Social handles / URLs"), seo: settingsSeoSchema.optional().describe("Site-wide SEO defaults"), }), }, async (args, extra) => { requireScope(extra, "settings:manage"); requireRole(extra, Role.ADMIN); const ec = getEmDash(extra); try { const { handleSettingsUpdate } = await import("../api/handlers/settings.js"); return unwrap(await handleSettingsUpdate(ec.db, ec.storage, args)); } catch (error) { return respondHandlerError(error, "SETTINGS_UPDATE_ERROR"); } }, ); return server; }