diff --git a/.changeset/forms-mcp-tools.md b/.changeset/forms-mcp-tools.md new file mode 100644 index 0000000000..5a53463d2e --- /dev/null +++ b/.changeset/forms-mcp-tools.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/plugin-forms": minor +--- + +Adds admin-authorized MCP tools for creating, managing, duplicating, and deleting forms, plus listing, reviewing, updating, deleting, and exporting submissions. diff --git a/packages/plugins/forms/package.json b/packages/plugins/forms/package.json index 8847cb04ed..770809a01f 100644 --- a/packages/plugins/forms/package.json +++ b/packages/plugins/forms/package.json @@ -26,7 +26,7 @@ "license": "MIT", "peerDependencies": { "astro": ">=6.0.0-beta.0", - "emdash": "workspace:>=0.11.0", + "emdash": "workspace:>=0.30.0", "react": "^18.0.0 || ^19.0.0", "@phosphor-icons/react": "^2.1.10", "@cloudflare/kumo": "catalog:" diff --git a/packages/plugins/forms/src/index.ts b/packages/plugins/forms/src/index.ts index 3234c5f94f..da415d886d 100644 --- a/packages/plugins/forms/src/index.ts +++ b/packages/plugins/forms/src/index.ts @@ -44,6 +44,7 @@ import { formCreateSchema, formDeleteSchema, formDuplicateSchema, + formsListSchema, formUpdateSchema, submissionDeleteSchema, submissionGetSchema, @@ -141,42 +142,53 @@ export function createPlugin(_options: FormsPluginOptions = {}): ResolvedPlugin // --- Admin routes (require auth) --- "forms/list": { + permission: "plugins:manage", + input: formsListSchema, handler: formsListHandler, }, "forms/create": { + permission: "plugins:manage", input: formCreateSchema, handler: formsCreateHandler as never, }, "forms/update": { + permission: "plugins:manage", input: formUpdateSchema, handler: formsUpdateHandler as never, }, "forms/delete": { + permission: "plugins:manage", input: formDeleteSchema, handler: formsDeleteHandler as never, }, "forms/duplicate": { + permission: "plugins:manage", input: formDuplicateSchema, handler: formsDuplicateHandler as never, }, "submissions/list": { + permission: "plugins:manage", input: submissionsListSchema, handler: submissionsListHandler as never, }, "submissions/get": { + permission: "plugins:manage", input: submissionGetSchema, handler: submissionGetHandler as never, }, "submissions/update": { + permission: "plugins:manage", input: submissionUpdateSchema, handler: submissionUpdateHandler as never, }, "submissions/delete": { + permission: "plugins:manage", input: submissionDeleteSchema, handler: submissionDeleteHandler as never, }, "submissions/export": { + permission: "plugins:manage", input: exportSchema, handler: exportHandler as never, }, @@ -193,6 +205,72 @@ export function createPlugin(_options: FormsPluginOptions = {}): ResolvedPlugin }, }, + mcp: { + tools: { + listForms: { + description: "List forms with their status and submission counts.", + route: "forms/list", + input: formsListSchema, + destructive: false, + }, + createForm: { + description: "Create a form with pages, fields, and settings.", + route: "forms/create", + input: formCreateSchema, + destructive: false, + }, + updateForm: { + description: "Update an existing form's definition, settings, or status.", + route: "forms/update", + input: formUpdateSchema, + destructive: true, + }, + deleteForm: { + description: "Delete a form and optionally its submissions.", + route: "forms/delete", + input: formDeleteSchema, + destructive: true, + }, + duplicateForm: { + description: "Duplicate an existing form with an optional new name or slug.", + route: "forms/duplicate", + input: formDuplicateSchema, + destructive: false, + }, + listSubmissions: { + description: + "List submissions for a form, optionally filtered by status or starred state.", + route: "submissions/list", + input: submissionsListSchema, + destructive: false, + }, + getSubmission: { + description: "Get a single form submission by ID.", + route: "submissions/get", + input: submissionGetSchema, + destructive: false, + }, + updateSubmission: { + description: "Update a submission's status, starred state, or notes.", + route: "submissions/update", + input: submissionUpdateSchema, + destructive: true, + }, + deleteSubmission: { + description: "Permanently delete a form submission and its uploaded files.", + route: "submissions/delete", + input: submissionDeleteSchema, + destructive: true, + }, + exportSubmissions: { + description: "Export filtered submissions for a form as CSV or JSON.", + route: "submissions/export", + input: exportSchema, + destructive: false, + }, + }, + }, + admin: { settingsSchema: { turnstileSiteKey: { type: "string", label: "Turnstile Site Key" }, diff --git a/packages/plugins/forms/src/schemas.ts b/packages/plugins/forms/src/schemas.ts index b693c9dabe..03cd57aac1 100644 --- a/packages/plugins/forms/src/schemas.ts +++ b/packages/plugins/forms/src/schemas.ts @@ -106,6 +106,8 @@ const formSettingsSchema = z.object({ // ─── Form CRUD Schemas ────────────────────────────────────────── +export const formsListSchema = z.object({}); + export const formCreateSchema = z.object({ name: z.string().min(1).max(200), slug: z diff --git a/packages/plugins/forms/tests/mcp-tools.test.ts b/packages/plugins/forms/tests/mcp-tools.test.ts new file mode 100644 index 0000000000..e3199ebb85 --- /dev/null +++ b/packages/plugins/forms/tests/mcp-tools.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; + +import { createPlugin } from "../src/index.js"; + +const expectedTools = { + listForms: { route: "forms/list", destructive: false }, + createForm: { route: "forms/create", destructive: false }, + updateForm: { route: "forms/update", destructive: true }, + deleteForm: { route: "forms/delete", destructive: true }, + duplicateForm: { route: "forms/duplicate", destructive: false }, + listSubmissions: { route: "submissions/list", destructive: false }, + getSubmission: { route: "submissions/get", destructive: false }, + updateSubmission: { route: "submissions/update", destructive: true }, + deleteSubmission: { route: "submissions/delete", destructive: true }, + exportSubmissions: { route: "submissions/export", destructive: false }, +} as const; + +describe("forms plugin MCP tools", () => { + it("exposes all private form operations as admin-only tools", () => { + const plugin = createPlugin(); + const tools = plugin.mcp?.tools; + + expect(Object.keys(tools ?? {})).toEqual(Object.keys(expectedTools)); + + for (const [name, expected] of Object.entries(expectedTools)) { + const tool = tools?.[name]; + const route = plugin.routes[expected.route]; + + expect(tool, `${name} MCP definition`).toBeDefined(); + expect(tool?.route).toBe(expected.route); + expect(tool?.destructive).toBe(expected.destructive); + expect(tool?.description.length).toBeGreaterThan(0); + expect(tool?.input).toBe(route?.input); + expect(route?.permission).toBe("plugins:manage"); + } + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d556eb31dc..e6b8bb1cb2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2157,7 +2157,7 @@ importers: specifier: '>=6.0.0-beta.0' version: 6.0.0-beta.20(@types/node@25.9.1)(aws4fetch@1.0.20)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.55.2)(tsx@4.21.0)(typescript@6.0.0-beta)(yaml@2.9.0) emdash: - specifier: workspace:>=0.11.0 + specifier: workspace:>=0.30.0 version: link:../../core react: specifier: ^18.0.0 || ^19.0.0