b3a7f98e5a
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
196 lines
6.2 KiB
TypeScript
196 lines
6.2 KiB
TypeScript
/**
|
|
* 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<Database>;
|
|
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<Byline> {
|
|
const result = await harness.client.callTool({
|
|
name: "byline_create",
|
|
arguments: { slug, displayName, isGuest: true },
|
|
});
|
|
expect(result.isError, extractText(result)).toBeFalsy();
|
|
return extractJson<Byline>(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<Byline>(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<Byline>(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);
|
|
});
|
|
});
|