From bc7c63d4c444847cbc62e32439e5c907de56f4d1 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sun, 21 Jun 2026 08:07:43 +0100 Subject: [PATCH] feat(core): add byline management MCP tools Add byline_list, byline_get, byline_create, byline_update, byline_delete, and byline_translations tools to the MCP server, and a `bylines` argument on content_create so credits can be attached at creation (previously only content_update accepted them). Reads gate on content:read; writes on content:write + EDITOR role, matching the bylines:manage permission floor. Write tools invalidate the byline cache on success, mirroring the REST routes. --- .changeset/mcp-byline-tools.md | 5 + packages/core/src/astro/types.ts | 1 + packages/core/src/mcp/server.ts | 197 +++++++++++++++++- .../tests/integration/mcp/bylines.test.ts | 195 +++++++++++++++++ 4 files changed, 397 insertions(+), 1 deletion(-) create mode 100644 .changeset/mcp-byline-tools.md create mode 100644 packages/core/tests/integration/mcp/bylines.test.ts diff --git a/.changeset/mcp-byline-tools.md b/.changeset/mcp-byline-tools.md new file mode 100644 index 000000000..66264a7e9 --- /dev/null +++ b/.changeset/mcp-byline-tools.md @@ -0,0 +1,5 @@ +--- +"emdash": minor +--- + +Adds byline management to the MCP server: `byline_list`, `byline_get`, `byline_create`, `byline_update`, `byline_delete`, and `byline_translations` tools, plus a `bylines` argument on `content_create` so credits can be attached at creation time (previously only `content_update` accepted them). diff --git a/packages/core/src/astro/types.ts b/packages/core/src/astro/types.ts index d4a10544f..35a4fea7b 100644 --- a/packages/core/src/astro/types.ts +++ b/packages/core/src/astro/types.ts @@ -265,6 +265,7 @@ export interface EmDashHandlers { slug?: string; status?: string; authorId?: string; + bylines?: Array<{ bylineId: string; roleLabel?: string | null }>; locale?: string; translationOf?: string; createdAt?: string | null; diff --git a/packages/core/src/mcp/server.ts b/packages/core/src/mcp/server.ts index 1cfd26a66..7cf91e4fe 100644 --- a/packages/core/src/mcp/server.ts +++ b/packages/core/src/mcp/server.ts @@ -14,7 +14,12 @@ import { canActOnOwn, hasPermission, Role } from "@emdash-cms/auth"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import { contentBylineInputSchema, contentSeoInput } from "#api/schemas.js"; +import { + bylineCreateBody, + bylineUpdateBody, + contentBylineInputSchema, + contentSeoInput, +} from "#api/schemas.js"; import type { EmDashHandlers } from "../astro/types.js"; import { hasScope } from "../auth/api-tokens.js"; @@ -323,6 +328,11 @@ async function applyReadMarkdown( } } +async function invalidateBylines(): Promise { + const { invalidateBylineCache } = await import("../bylines/index.js"); + invalidateBylineCache(); +} + /** * Enforce a scope requirement on the current request. * @@ -644,6 +654,12 @@ export function createMcpServer(): McpServer { .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 }, }, @@ -681,6 +697,7 @@ export function createMcpServer(): McpServer { authorId: userId, locale: args.locale, translationOf: args.translationOf, + bylines: args.bylines, }); if (!result.success) return unwrap(result); const itemId = extractContentId(result.data); @@ -697,6 +714,7 @@ export function createMcpServer(): McpServer { authorId: userId, locale: args.locale, translationOf: args.translationOf, + bylines: args.bylines, }), ); }, @@ -1255,6 +1273,183 @@ export function createMcpServer(): McpServer { }, ); + // ===================================================================== + // 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 // ===================================================================== diff --git a/packages/core/tests/integration/mcp/bylines.test.ts b/packages/core/tests/integration/mcp/bylines.test.ts new file mode 100644 index 000000000..a24f4a0b1 --- /dev/null +++ b/packages/core/tests/integration/mcp/bylines.test.ts @@ -0,0 +1,195 @@ +/** + * MCP byline tools + content_create byline attachment. + * + * Covers the byline CRUD tools (byline_list/get/create/update/delete/ + * translations) and the new `bylines` argument on content_create, which + * forwards to the same setContentBylines path content_update already used. + */ + +import { Role } from "@emdash-cms/auth"; +import type { Kysely } from "kysely"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import type { Database } from "../../../src/database/types.js"; +import { + connectMcpHarness, + extractJson, + extractText, + isErrorResult, + type McpHarness, +} from "../../utils/mcp-runtime.js"; +import { setupTestDatabaseWithCollections, teardownTestDatabase } from "../../utils/test-db.js"; + +const ADMIN_ID = "user_admin"; +const AUTHOR_ID = "user_author"; + +interface Byline { + id: string; + slug: string; + displayName: string; + isGuest: boolean; + websiteUrl: string | null; +} + +describe("MCP byline tools", () => { + let db: Kysely; + let harness: McpHarness; + + beforeEach(async () => { + db = await setupTestDatabaseWithCollections(); + harness = await connectMcpHarness({ db, userId: ADMIN_ID, userRole: Role.ADMIN }); + }); + + afterEach(async () => { + if (harness) await harness.cleanup(); + await teardownTestDatabase(db); + }); + + async function createByline(slug: string, displayName: string): Promise { + const result = await harness.client.callTool({ + name: "byline_create", + arguments: { slug, displayName, isGuest: true }, + }); + expect(result.isError, extractText(result)).toBeFalsy(); + return extractJson(result); + } + + it("creates, gets, lists, updates and deletes a byline", async () => { + const created = await createByline("jane-doe", "Jane Doe"); + expect(created.slug).toBe("jane-doe"); + expect(created.displayName).toBe("Jane Doe"); + + const got = await harness.client.callTool({ + name: "byline_get", + arguments: { id: created.id }, + }); + expect(extractJson(got).displayName).toBe("Jane Doe"); + + const listed = await harness.client.callTool({ + name: "byline_list", + arguments: { search: "Jane" }, + }); + const items = extractJson<{ items: Byline[] }>(listed).items; + expect(items.some((b) => b.id === created.id)).toBe(true); + + const updated = await harness.client.callTool({ + name: "byline_update", + arguments: { id: created.id, displayName: "Jane Q. Doe" }, + }); + expect(extractJson(updated).displayName).toBe("Jane Q. Doe"); + + const deleted = await harness.client.callTool({ + name: "byline_delete", + arguments: { id: created.id }, + }); + expect(extractJson<{ deleted: string }>(deleted).deleted).toBe(created.id); + + const gone = await harness.client.callTool({ + name: "byline_get", + arguments: { id: created.id }, + }); + expect(isErrorResult(gone)).toBe(true); + expect(extractText(gone)).toContain("NOT_FOUND"); + }); + + it("rejects a non-http websiteUrl (httpUrl validation preserved)", async () => { + const result = await harness.client.callTool({ + name: "byline_create", + arguments: { slug: "evil", displayName: "Evil", websiteUrl: "javascript:alert(1)" }, + }); + expect(isErrorResult(result)).toBe(true); + }); + + it("byline_delete on a missing id returns NOT_FOUND", async () => { + const result = await harness.client.callTool({ + name: "byline_delete", + arguments: { id: "does-not-exist" }, + }); + expect(isErrorResult(result)).toBe(true); + expect(extractText(result)).toContain("NOT_FOUND"); + }); + + it("denies byline_create to an AUTHOR (below EDITOR)", async () => { + await harness.cleanup(); + harness = await connectMcpHarness({ db, userId: AUTHOR_ID, userRole: Role.AUTHOR }); + + const result = await harness.client.callTool({ + name: "byline_create", + arguments: { slug: "no-go", displayName: "No Go", isGuest: true }, + }); + expect(isErrorResult(result)).toBe(true); + expect(extractText(result)).toContain("INSUFFICIENT_PERMISSIONS"); + }); + + it("byline_translations returns the byline's own translation group", async () => { + const jane = await createByline("jane-tr", "Jane Tr"); + const result = await harness.client.callTool({ + name: "byline_translations", + arguments: { id: jane.id }, + }); + expect(result.isError, extractText(result)).toBeFalsy(); + const items = extractJson<{ items: Byline[] }>(result).items; + expect(items.some((b) => b.id === jane.id)).toBe(true); + }); + + it("content_create attaches bylines on the publish path", async () => { + const jane = await createByline("jane-pub", "Jane Pub"); + const created = await harness.client.callTool({ + name: "content_create", + arguments: { + collection: "post", + data: { title: "Published with byline" }, + status: "published", + bylines: [{ bylineId: jane.id }], + }, + }); + expect(created.isError, extractText(created)).toBeFalsy(); + const id = extractJson<{ item: { id: string } }>(created).item.id; + + const got = await harness.client.callTool({ + name: "content_get", + arguments: { collection: "post", id }, + }); + const item = extractJson<{ item: { status: string; primaryBylineId: string | null } }>( + got, + ).item; + expect(item.status).toBe("published"); + expect(item.primaryBylineId).toBe(jane.id); + }); + + it("content_create attaches bylines and sets the primary", async () => { + const jane = await createByline("jane-author", "Jane Author"); + const john = await createByline("john-editor", "John Editor"); + + const created = await harness.client.callTool({ + name: "content_create", + arguments: { + collection: "post", + data: { title: "Co-authored" }, + bylines: [ + { bylineId: jane.id, roleLabel: "Author" }, + { bylineId: john.id, roleLabel: "Editor" }, + ], + }, + }); + expect(created.isError, extractText(created)).toBeFalsy(); + const id = extractJson<{ item: { id: string } }>(created).item.id; + + const got = await harness.client.callTool({ + name: "content_get", + arguments: { collection: "post", id }, + }); + const item = extractJson<{ + item: { + primaryBylineId: string | null; + bylines?: Array<{ byline: { id: string }; roleLabel: string | null }>; + }; + }>(got).item; + + expect(item.primaryBylineId).toBe(jane.id); + expect(item.bylines).toHaveLength(2); + expect(item.bylines?.[0]?.byline.id).toBe(jane.id); + expect(item.bylines?.[0]?.roleLabel).toBe("Author"); + expect(item.bylines?.[1]?.byline.id).toBe(john.id); + }); +});