@bettercms-ai/mcp 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/SKILL.md CHANGED
@@ -58,6 +58,24 @@ Tools: `list_components`, `get_component`, `create_component`, `update_component
58
58
  Authoring `blockJson` blind is error-prone — keep trees small, confirm with the user,
59
59
  and read back with `get_component` to verify.
60
60
 
61
+ ## AI content & SEO
62
+ Tools: `write_content`, `generate_seo_meta`. Guided prompts: `build_site`,
63
+ `generate_landing_pages`, `seo_optimize`. These use the workspace's own Anthropic key
64
+ (BYOK, unmetered) or platform AI credits, and return a *suggestion* — you apply it.
65
+
66
+ 1. **Write copy** → `write_content { action, text, instructions?, targetLang?, context? }`.
67
+ `action`: `write` (draft from a brief), `rewrite` (improve existing copy), `translate`
68
+ (needs `targetLang`). Returns the text — apply it with `set_page_content`, `create_entry`,
69
+ or `update_entry`.
70
+ 2. **SEO meta** → `generate_seo_meta { text, context? }` returns
71
+ `{ metaTitle, metaDescription, keywords, jsonLd? }`. Read the page/entry, pass its content
72
+ as `text`, then apply the suggestion via the page/entry update tools.
73
+ 3. **Programmatic pages** → for a template + a dataset, loop rows: `write_content` per slot →
74
+ `create_entry` → `generate_seo_meta`. Confirm the first row or two first. For hundreds of
75
+ rows, the dashboard AI Page Builder bulk-imports a CSV.
76
+
77
+ Always show suggestions to the user before applying; never invent brand facts.
78
+
61
79
  ## Errors
62
80
  - `401/403` → the MCP key needs (re)authorizing (re-run "Connect your AI").
63
81
  - `409 slug_taken` → offer a different slug.
package/dist/index.js CHANGED
@@ -438,6 +438,25 @@ function buildToolDefs(deps) {
438
438
  metaTitle: z.string().optional().describe("SEO meta title"),
439
439
  metaDescription: z.string().optional().describe("SEO meta description")
440
440
  });
441
+ const writeContentInput = z.object({
442
+ action: z.enum(["write", "rewrite", "translate"]).describe("'write' = draft from a brief, 'rewrite' = improve existing copy, 'translate' = needs targetLang"),
443
+ text: z.string().min(1).describe("the source text (a brief for 'write', the copy to change otherwise)"),
444
+ instructions: z.string().optional().describe("optional extra guidance, e.g. 'make it punchier'"),
445
+ targetLang: z.string().optional().describe("required for 'translate', e.g. 'Spanish'"),
446
+ context: z.string().optional().describe("optional surrounding context, e.g. the page title")
447
+ });
448
+ const generateSeoMetaInput = z.object({
449
+ text: z.string().min(1).describe("the content to derive SEO metadata from"),
450
+ context: z.string().optional().describe("optional surrounding context, e.g. the page slug")
451
+ });
452
+ const createModelInput = z.object({
453
+ name: z.string().min(1).describe("human model name, e.g. 'Blog Post'"),
454
+ slug: slug.describe("url-safe unique slug, e.g. 'blog-post'"),
455
+ description: z.string().optional(),
456
+ fields: z.array(fieldObject).optional().describe(
457
+ "the model's typed schema fields. 'group'/'repeater' NEST their child fields (any depth) \u2014 don't flatten zones into top-level fields."
458
+ )
459
+ });
441
460
  const addFieldInput = z.object({
442
461
  modelId: z.string().min(1).describe("id of the content model to extend"),
443
462
  ...fieldShape
@@ -488,7 +507,7 @@ function buildToolDefs(deps) {
488
507
  pageId: z.string().min(1).describe("id of the page to delete (from list_pages)")
489
508
  });
490
509
  const deleteEntryInput = z.object({
491
- entryId: z.string().min(1).describe("id of the content entry to delete (from list_entries)")
510
+ entryId: z.string().min(1).describe("id of the content entry to delete (from list_content_entries)")
492
511
  });
493
512
  const deleteModelInput = z.object({
494
513
  modelId: z.string().min(1).describe("id of the content model to delete (from list_content_models)")
@@ -578,6 +597,266 @@ function buildToolDefs(deps) {
578
597
  const getComponentInput = z.object({
579
598
  componentId: z.string().min(1).describe("component id (from list_components)")
580
599
  });
600
+ function lifecycleTools() {
601
+ const def = (name, title, description, shape, run) => ({
602
+ name,
603
+ config: { title, description, inputSchema: shape },
604
+ handler: guard(async (args) => withClient((client) => run(client, args)))
605
+ });
606
+ const q = (obj) => {
607
+ const p = new URLSearchParams();
608
+ for (const [k, v] of Object.entries(obj)) if (v !== void 0 && v !== null) p.set(k, String(v));
609
+ const s2 = p.toString();
610
+ return s2 ? `?${s2}` : "";
611
+ };
612
+ const data = async (client, method, path, body) => (await client.fetchJSON(client.url(path), {
613
+ method,
614
+ ...body !== void 0 ? { body: JSON.stringify(body), headers: { "content-type": "application/json" } } : {}
615
+ })).data;
616
+ const s = (v) => v;
617
+ const raw = async (client, path, base64, mimeType) => (await client.fetchJSON(client.url(path), {
618
+ method: "POST",
619
+ body: Buffer.from(base64, "base64"),
620
+ headers: { "content-type": mimeType ?? "application/octet-stream" }
621
+ })).data;
622
+ return [
623
+ def(
624
+ "list_media",
625
+ "List media assets",
626
+ "List the images/assets already in the connected project's Media Library (id, url, filename, mimeType, size, alt/caption). Reuse an existing asset instead of re-uploading. Filter with `search` or `type` ('image'|'video'|\u2026).",
627
+ z.object({ search: z.string().optional(), type: z.string().optional(), limit: z.number().optional(), page: z.number().optional() }).shape,
628
+ async (c, a) => ok("Media assets.", await data(c, "GET", `/management/media${q({ search: a.search, type: a.type, limit: a.limit, page: a.page })}`))
629
+ ),
630
+ def(
631
+ "get_media",
632
+ "Get a media asset",
633
+ "Get one media asset by id \u2014 its CDN url, filename, MIME type, size, and alt/caption. Use the url as the value of an 'image' field.",
634
+ z.object({ assetId: z.string().min(1).describe("media asset id (from list_media / upload_asset)") }).shape,
635
+ async (c, a) => ok("Media asset.", await data(c, "GET", `/management/media/${s(a.assetId)}`))
636
+ ),
637
+ def(
638
+ "delete_media",
639
+ "Delete a media asset",
640
+ "Delete a media asset from the Media Library (soft delete \u2014 reversible from the dashboard trash). Provide assetId.",
641
+ z.object({ assetId: z.string().min(1).describe("media asset id (from list_media)") }).shape,
642
+ async (c, a) => ok("Deleted media asset.", await data(c, "DELETE", `/management/media/${s(a.assetId)}`))
643
+ ),
644
+ def(
645
+ "delete_form",
646
+ "Delete a form",
647
+ "Delete a form by id. Soft delete \u2014 the form stops rendering and accepting submissions, but leads already collected against it are preserved. Use it to clean up forms created by mistake.",
648
+ z.object({ formId: z.string().min(1).describe("form id (from list_forms)") }).shape,
649
+ async (c, a) => ok("Deleted form.", await data(c, "DELETE", `/management/forms/${s(a.formId)}`))
650
+ ),
651
+ def(
652
+ "list_form_submissions",
653
+ "List a form's submissions (leads)",
654
+ "List the SUBMISSIONS (leads) a form has received \u2014 each with its submitted field values. Filter with status ('inbox'|'spam') and paginate with limit/page.",
655
+ z.object({ formId: z.string().min(1), status: z.enum(["inbox", "spam"]).optional(), limit: z.number().optional(), page: z.number().optional() }).shape,
656
+ async (c, a) => ok("Form submissions.", await data(c, "GET", `/management/forms/${s(a.formId)}/submissions${q({ status: a.status, limit: a.limit, page: a.page })}`))
657
+ ),
658
+ def(
659
+ "delete_form_submission",
660
+ "Delete a form submission",
661
+ "Delete one form submission (lead) \u2014 e.g. to clear spam. Provide formId and submissionId (from list_form_submissions).",
662
+ z.object({ formId: z.string().min(1), submissionId: z.string().min(1) }).shape,
663
+ async (c, a) => ok("Deleted submission.", await data(c, "DELETE", `/management/forms/${s(a.formId)}/submissions/${s(a.submissionId)}`))
664
+ ),
665
+ def(
666
+ "list_redirects",
667
+ "List redirects",
668
+ "List the URL redirects configured for the connected project (source path \u2192 destination, type).",
669
+ z.object({}).shape,
670
+ async (c) => ok("Redirects.", await data(c, "GET", `/management/redirects`))
671
+ ),
672
+ def(
673
+ "update_redirect",
674
+ "Update a redirect",
675
+ "Update an existing redirect in place \u2014 change where it points, its HTTP status, or disable it without deleting. Prefer this over delete+create: it keeps the redirect's id and preserves the chain-collapse rewrites of other rules pointing at it. Only provided fields change. Loops 422, duplicate sources 409.",
676
+ z.object({ redirectId: z.string().min(1).describe("redirect id (from list_redirects)"), sourcePath: z.string().min(1).optional(), destination: z.string().min(1).optional(), redirectType: z.enum(["301", "302", "307", "308"]).optional(), isActive: z.boolean().optional().describe("set false to disable without deleting") }).shape,
677
+ async (c, a) => ok("Updated redirect.", await data(c, "PATCH", `/management/redirects/${s(a.redirectId)}`, { sourcePath: a.sourcePath, destination: a.destination, redirectType: a.redirectType, isActive: a.isActive }))
678
+ ),
679
+ def(
680
+ "create_redirect",
681
+ "Create a redirect",
682
+ "Create a URL redirect \u2014 e.g. a 301 after renaming a page's slug. `sourcePath` is the path to redirect FROM ('/old-page'), `destination` the path/url TO ('/new-page'). Chains collapse; loops/dupes rejected. Defaults to 301.",
683
+ z.object({ sourcePath: z.string().min(1), destination: z.string().min(1), redirectType: z.enum(["301", "302", "307", "308"]).optional() }).shape,
684
+ async (c, a) => ok("Created redirect.", await data(c, "POST", `/management/redirects`, { sourcePath: a.sourcePath, destination: a.destination, redirectType: a.redirectType }))
685
+ ),
686
+ def(
687
+ "delete_redirect",
688
+ "Delete a redirect",
689
+ "Delete a URL redirect by id (from list_redirects). Provide redirectId.",
690
+ z.object({ redirectId: z.string().min(1) }).shape,
691
+ async (c, a) => ok("Deleted redirect.", await data(c, "DELETE", `/management/redirects/${s(a.redirectId)}`))
692
+ ),
693
+ def(
694
+ "get_seo",
695
+ "Get site SEO",
696
+ "Get the connected project's site-wide SEO settings \u2014 default meta (title/description/ogImage), JSON-LD siteSchema, and robots/sitemap/rss config.",
697
+ z.object({}).shape,
698
+ async (c) => ok("SEO settings.", await data(c, "GET", `/management/seo`))
699
+ ),
700
+ def(
701
+ "update_seo",
702
+ "Update site SEO",
703
+ "Set site-wide SEO defaults. `seoDefaults` { metaTitle, metaDescription, ogImage, twitterHandle } applies to every page unless overridden. Optionally robotsConfig / sitemapConfig / rssConfig. Each field is REPLACED whole. Rebuilds the site.",
704
+ z.object({ seoDefaults: z.record(z.string(), z.unknown()).optional(), siteSchema: z.record(z.string(), z.unknown()).optional(), robotsConfig: z.record(z.string(), z.unknown()).optional(), sitemapConfig: z.record(z.string(), z.unknown()).optional(), rssConfig: z.record(z.string(), z.unknown()).optional() }).shape,
705
+ async (c, a) => ok("Updated SEO.", await data(c, "PATCH", `/management/seo`, a))
706
+ ),
707
+ def(
708
+ "get_site_files",
709
+ "List site files",
710
+ "List the AI-crawler files installed on the connected project (llms.txt / llms-full.txt) \u2014 metadata only.",
711
+ z.object({}).shape,
712
+ async (c) => ok("Site files.", await data(c, "GET", `/management/site-files`))
713
+ ),
714
+ def(
715
+ "set_site_file",
716
+ "Set a site file",
717
+ "Create or replace an AI-crawler file served at the site root \u2014 `kind` 'llms.txt' or 'llms-full.txt' \u2014 with `content` (plain text, max 5 MB). Rebuilds the site.",
718
+ z.object({ kind: z.enum(["llms.txt", "llms-full.txt"]), content: z.string() }).shape,
719
+ async (c, a) => ok("Saved site file.", await data(c, "PUT", `/management/site-files/${s(a.kind)}`, { content: a.content }))
720
+ ),
721
+ def(
722
+ "delete_site_file",
723
+ "Delete a site file",
724
+ "Remove an AI-crawler file (llms.txt / llms-full.txt) from the connected project. Provide kind.",
725
+ z.object({ kind: z.enum(["llms.txt", "llms-full.txt"]) }).shape,
726
+ async (c, a) => ok("Deleted site file.", await data(c, "DELETE", `/management/site-files/${s(a.kind)}`))
727
+ ),
728
+ def(
729
+ "promote_project",
730
+ "Promote staging \u2192 production",
731
+ "Promote the connected project's STAGED build to PRODUCTION \u2014 the one-click publish. Flips prod to the newest staged release (no rebuild) after the QA scan passes. Managed hosting only; needs a publish-enabled connection.",
732
+ z.object({}).shape,
733
+ async (c) => ok("Promoted to production.", await data(c, "POST", `/management/projects/promote`))
734
+ ),
735
+ def(
736
+ "list_entry_versions",
737
+ "List a content entry's versions",
738
+ "List a content entry's version history (newest first) \u2014 each version's number, data snapshot, and when it was saved. Use it to find a past state to restore.",
739
+ z.object({ entryId: z.string().min(1) }).shape,
740
+ async (c, a) => ok("Entry versions.", await data(c, "GET", `/management/content/entries/${s(a.entryId)}/versions`))
741
+ ),
742
+ def(
743
+ "restore_entry_version",
744
+ "Restore a content entry to a past version",
745
+ "Restore a content entry to a past version (undo). Copies that version's data back as the current DRAFT (non-destructive). Publish afterwards to take it live. Provide entryId and the version number (from list_entry_versions).",
746
+ z.object({ entryId: z.string().min(1), version: z.number().int().positive() }).shape,
747
+ async (c, a) => ok("Restored entry version.", await data(c, "POST", `/management/content/entries/${s(a.entryId)}/versions/${a.version}/restore`))
748
+ ),
749
+ // ── Project / workspace (parity with remote /mcp) ──
750
+ def(
751
+ "get_project",
752
+ "Get the connected project",
753
+ "Get the connected project's info \u2014 id, name, slug, subdomain, and its live URL (https://<handle>.bettercms.site). Use it to tell the user where their site is published / link the result.",
754
+ z.object({}).shape,
755
+ async (c) => ok("Project.", await data(c, "GET", `/management/projects/current`))
756
+ ),
757
+ def(
758
+ "list_projects",
759
+ "List projects",
760
+ "List the projects this key can see \u2014 id, name, slug, and live URL each. A project-scoped key sees only its own project; a workspace-level key sees every project in the workspace. Use it to find a project you created earlier, or to confirm which sites exist before acting.",
761
+ z.object({}).shape,
762
+ async (c) => ok("Projects.", await data(c, "GET", `/management/projects`))
763
+ ),
764
+ def(
765
+ "update_project",
766
+ "Update the connected project",
767
+ "Update the connected project's settings \u2014 rename it, change its slug/description, SEO defaults, or visibility. Only the provided fields change.",
768
+ z.object({ name: z.string().optional(), slug: z.string().optional(), description: z.string().optional(), visibility: z.string().optional(), seoDefaults: z.record(z.string(), z.unknown()).optional() }).shape,
769
+ async (c, a) => ok("Updated project.", await data(c, "PATCH", `/management/projects/current`, a))
770
+ ),
771
+ def(
772
+ "create_project",
773
+ "Create a new project",
774
+ "Create a NEW project (site) in the connected workspace. Blank by default; pass templateId to seed curated content, or framework ('next'|'astro') to record the starter. Returns the new project's id and slug. (Use clone_project instead to duplicate an existing project.)",
775
+ z.object({ name: z.string().min(1), slug: z.string().optional(), description: z.string().optional(), templateId: z.string().optional(), framework: z.enum(["next", "astro"]).optional() }).shape,
776
+ async (c, a) => ok("Created project.", await data(c, "POST", `/management/projects`, a))
777
+ ),
778
+ def(
779
+ "clone_project",
780
+ "Clone a project",
781
+ "Clone (duplicate) a project as a reusable template into the connected workspace. Copies pages, content models + entries, components, media, forms, and SEO/custom-code settings; excludes submissions, analytics, domains, and secrets. Returns the new project's id and slug. Omit sourceProjectId to clone the connected project.",
782
+ z.object({ sourceProjectId: z.string().optional(), name: z.string().optional(), slug: z.string().optional() }).shape,
783
+ async (c, a) => ok("Cloned project.", await data(c, "POST", `/management/projects/clone`, a))
784
+ ),
785
+ def(
786
+ "create_template",
787
+ "Save a project/page as a template",
788
+ "Save a project (or one page) as a REUSABLE template \u2014 a frozen snapshot of its content models, pages, entries, components, and forms (secrets/domains/analytics excluded). Later seed a new project from it with create_project { templateId }. Set visibility 'public' to list it in the cross-workspace gallery. Returns the new template's id.",
789
+ z.object({ sourceProjectId: z.string().min(1), name: z.string().min(1), scope: z.string().optional(), sourcePageId: z.string().optional(), description: z.string().optional(), visibility: z.string().optional() }).shape,
790
+ async (c, a) => ok("Created template.", await data(c, "POST", `/management/templates`, a))
791
+ ),
792
+ def(
793
+ "list_templates",
794
+ "List saved templates",
795
+ "List your workspace's saved templates (id, name, scope, visibility). Use a template's id as create_project { templateId } to seed a new project from it.",
796
+ z.object({}).shape,
797
+ async (c) => ok("Templates.", await data(c, "GET", `/management/templates`))
798
+ ),
799
+ // ── Content models (read/metadata; parity with remote /mcp) ──
800
+ def(
801
+ "list_content_models",
802
+ "List content models",
803
+ "List the content models (reusable schemas for dynamic collections like Blog/Products) in the connected project.",
804
+ z.object({}).shape,
805
+ async (c) => ok("Content models.", await data(c, "GET", `/management/content/models`))
806
+ ),
807
+ def(
808
+ "get_content_model",
809
+ "Get a content model",
810
+ "Get one content model by id INCLUDING its full field schema (keys, types, nested group/repeater children). Read this before add_field so you know the existing keys.",
811
+ z.object({ modelId: z.string().min(1) }).shape,
812
+ async (c, a) => ok("Content model.", await data(c, "GET", `/management/content/models/${s(a.modelId)}`))
813
+ ),
814
+ def(
815
+ "update_content_model",
816
+ "Update a content model's metadata",
817
+ "Rename a content model or edit its description/slug (metadata only \u2014 does NOT touch fields; use add_field to extend the schema). Provide modelId plus the fields to change.",
818
+ z.object({ modelId: z.string().min(1), name: z.string().optional(), slug: z.string().optional(), description: z.string().optional() }).shape,
819
+ async (c, a) => ok("Updated content model.", await data(c, "PATCH", `/management/content/models/${s(a.modelId)}`, { name: a.name, slug: a.slug, description: a.description }))
820
+ ),
821
+ def(
822
+ "get_content_types",
823
+ "Get generated TypeScript types",
824
+ "Get the auto-generated TypeScript types for the connected project's content models/pages. Pull these to write correctly-typed code against the BetterCMS delivery SDK in the user's site.",
825
+ z.object({}).shape,
826
+ async (c) => ok("Content types.", await data(c, "GET", `/management/content/types`))
827
+ ),
828
+ // ── Pages (metadata edit; parity with remote /mcp) ──
829
+ def(
830
+ "update_page",
831
+ "Edit a page's metadata",
832
+ "Edit a page's metadata \u2014 title, slug, SEO metaTitle/metaDescription, and publish status (draft|published). This is the slug/title/SEO editor (it does NOT change the field schema or content \u2014 use add_page_field / set_page_content for those). Renaming the slug keeps the page's content intact. Provide pageId plus the fields to change.",
833
+ z.object({ pageId: z.string().min(1), title: z.string().optional(), slug: z.string().optional(), metaTitle: z.string().optional(), metaDescription: z.string().optional(), status: z.enum(["draft", "published"]).optional() }).shape,
834
+ async (c, a) => ok("Updated page.", await data(c, "PATCH", `/management/pages/${s(a.pageId)}/meta`, { title: a.title, slug: a.slug, metaTitle: a.metaTitle, metaDescription: a.metaDescription, status: a.status }))
835
+ ),
836
+ // ── Code + deploy (parity with remote /mcp; needs artifact:write) ──
837
+ def(
838
+ "pull_project_source",
839
+ "Pull the project's live source",
840
+ "Get the connected project's CURRENT live source/build so you can edit it locally. Returns a presigned tarball download url (1h) + the live commit sha \u2014 download it, extract, edit the files, then call deploy_project. If the project is connected to a GitHub repo, returns `github: {owner, repo}` so you can `git clone` that instead.",
841
+ z.object({}).shape,
842
+ async (c) => ok("Project source.", await data(c, "GET", `/management/projects/source`))
843
+ ),
844
+ def(
845
+ "deploy_project",
846
+ "Deploy new source/build",
847
+ "Deploy new source/build for the connected project and make it live at its <handle>.bettercms.site. Pass a .tgz or .zip of the project as a base64 string in `data`: SOURCE (has package.json) is built server-side in an isolated sandbox; a prebuilt static site is served as-is. node_modules/.git are stripped automatically. Returns the release id + sha \u2014 then poll get_deploy_status until it is live.",
848
+ z.object({ data: z.string().min(1).describe("base64 .tgz/.zip of the project"), mimeType: z.string().optional() }).shape,
849
+ async (c, a) => ok("Deploy queued.", await raw(c, `/management/projects/deploy`, s(a.data), a.mimeType))
850
+ ),
851
+ def(
852
+ "get_deploy_status",
853
+ "Get deploy/build status",
854
+ "Get the connected project's deploy/build status: state (idle|queued|building|failed), whether it's publishing, the live commit sha, when it went live, and any build error. Poll this after deploy_project until state is idle with your sha live.",
855
+ z.object({}).shape,
856
+ async (c) => ok("Deploy status.", await data(c, "GET", `/management/projects/deploy-status`))
857
+ )
858
+ ];
859
+ }
581
860
  const defs = [
582
861
  {
583
862
  name: "list_pages",
@@ -644,6 +923,28 @@ function buildToolDefs(deps) {
644
923
  })
645
924
  )
646
925
  },
926
+ {
927
+ name: "create_content_model",
928
+ config: {
929
+ title: "Create a content model (reusable schema)",
930
+ description: "Create a content model \u2014 a reusable schema for a dynamic collection (Blog, Products, Testimonials). `fields` may NEST: type 'group' = one nested object of child fields; type 'repeater' = a repeatable array of child objects. Put child fields in each group/repeater's own `fields` (any depth).",
931
+ inputSchema: createModelInput.shape
932
+ },
933
+ handler: guard(
934
+ async (args) => withClient(async (client) => {
935
+ const model = await client.createModel({
936
+ name: args.name,
937
+ slug: args.slug,
938
+ ...args.description !== void 0 ? { description: args.description } : {},
939
+ fields: toFields(args.fields)
940
+ });
941
+ return ok(
942
+ `Created content model '${model.name}' (${model.fields.length} field(s)).`,
943
+ model
944
+ );
945
+ })
946
+ )
947
+ },
647
948
  {
648
949
  name: "add_field",
649
950
  config: {
@@ -685,23 +986,20 @@ function buildToolDefs(deps) {
685
986
  )
686
987
  },
687
988
  {
688
- name: "create_entry",
989
+ name: "create_content_entry",
689
990
  config: {
690
991
  title: "Create a content entry",
691
- description: "Create a content entry under a model. If data/status are provided they are applied in a follow-up update (entries are created as empty drafts).",
992
+ description: "Create a content entry under a model. Pass its field VALUES in `data`, keyed by field key \u2014 INCLUDE ALL REQUIRED FIELDS (create validates them). New entries are drafts; pass status:'published' to take it live. Read get_content_model first for the field keys and which are required.",
692
993
  inputSchema: createEntryInput.shape
693
994
  },
694
995
  handler: guard(
695
996
  async (args) => withClient(async (client) => {
696
997
  const created = await client.createEntry({
697
998
  contentModelId: args.contentModelId,
698
- ...args.slug !== void 0 ? { slug: args.slug } : {}
999
+ ...args.slug !== void 0 ? { slug: args.slug } : {},
1000
+ ...args.data !== void 0 ? { data: args.data } : {}
699
1001
  });
700
- const needsUpdate = args.data !== void 0 || args.status !== void 0;
701
- const entry = needsUpdate ? await client.updateEntry(created.id, {
702
- ...args.data !== void 0 ? { data: args.data } : {},
703
- ...args.status !== void 0 ? { status: args.status } : {}
704
- }) : created;
1002
+ const entry = args.status !== void 0 && args.status !== "draft" ? await client.updateEntry(created.id, { status: args.status }) : created;
705
1003
  return ok(
706
1004
  `Created entry '${entry.slug}' (id ${entry.id}, status ${entry.status}).`,
707
1005
  entry
@@ -713,7 +1011,7 @@ function buildToolDefs(deps) {
713
1011
  name: "set_page_content",
714
1012
  config: {
715
1013
  title: "Set a page's field values (content)",
716
- description: "Set a page's field VALUES \u2014 the actual content. For a SINGLETON page (Home, About, Site Settings) this creates or updates its one entry, so call it again to edit. `data` is keyed by field key: a nested 'array' (zone) value is an OBJECT { nonRepeatable: { childKey: value }, repeatable: [ { childKey: value } ] }; a primitive 'array' is a plain list; an 'image' value is an asset URL. Read the schema first with get_page. This is how you populate Home/About/Settings \u2014 create_entry is for dynamic collections only.",
1014
+ description: "Set a page's field VALUES \u2014 the actual content. For a SINGLETON page (Home, About, Site Settings) this creates or updates its one entry, so call it again to edit. `data` is keyed by field key: a nested 'array' (zone) value is an OBJECT { nonRepeatable: { childKey: value }, repeatable: [ { childKey: value } ] }; a primitive 'array' is a plain list; an 'image' value is an asset URL. Read the schema first with get_page. This is how you populate Home/About/Settings \u2014 create_content_entry is for dynamic collections only.",
717
1015
  inputSchema: setPageContentInput.shape
718
1016
  },
719
1017
  handler: guard(
@@ -730,7 +1028,7 @@ function buildToolDefs(deps) {
730
1028
  )
731
1029
  },
732
1030
  {
733
- name: "list_entries",
1031
+ name: "list_content_entries",
734
1032
  config: {
735
1033
  title: "List content entries (incl. drafts)",
736
1034
  description: "List content entries \u2014 including drafts \u2014 filtered by model and/or page. Use it to SEE existing content before editing. For a singleton page, pass its pageId to get its single entry.",
@@ -748,7 +1046,7 @@ function buildToolDefs(deps) {
748
1046
  )
749
1047
  },
750
1048
  {
751
- name: "get_entry",
1049
+ name: "get_content_entry",
752
1050
  config: {
753
1051
  title: "Get a content entry (with its values)",
754
1052
  description: "Get one content entry by id INCLUDING its `data` (field values), even when draft.",
@@ -762,7 +1060,7 @@ function buildToolDefs(deps) {
762
1060
  )
763
1061
  },
764
1062
  {
765
- name: "update_entry",
1063
+ name: "update_content_entry",
766
1064
  config: {
767
1065
  title: "Update a content entry's values",
768
1066
  description: "Update a content entry's `data` (field values) and/or status by id. `data` is keyed by field key; a nested 'array' (zone) value is an object { nonRepeatable: {\u2026}, repeatable: [{\u2026}] }, a primitive 'array' is a plain list. Use this to edit an existing entry; for a singleton page prefer set_page_content.",
@@ -811,7 +1109,7 @@ function buildToolDefs(deps) {
811
1109
  )
812
1110
  },
813
1111
  {
814
- name: "delete_entry",
1112
+ name: "delete_content_entry",
815
1113
  config: {
816
1114
  title: "Delete a content entry",
817
1115
  description: "DELETE a single content entry. Destructive but REVERSIBLE (soft-delete, restorable from the dashboard) and audit-logged. Use it to remove content created by mistake.",
@@ -959,7 +1257,41 @@ function buildToolDefs(deps) {
959
1257
  return ok(`Updated component '${cmp.name}' (id ${cmp.id}).`, cmp);
960
1258
  })
961
1259
  )
962
- }
1260
+ },
1261
+ // ── AI content + SEO actions (Option B) ────────────────────────────────────
1262
+ {
1263
+ name: "write_content",
1264
+ config: {
1265
+ title: "Write, rewrite, or translate content",
1266
+ description: "AI-write a piece of copy: action 'write' (draft from a brief), 'rewrite' (improve existing copy), or 'translate' (needs targetLang). Returns the suggested text \u2014 apply it with set_page_content or update_content_entry. Uses the workspace's own Anthropic key (BYOK, unmetered) or platform AI credits.",
1267
+ inputSchema: writeContentInput.shape
1268
+ },
1269
+ handler: guard(
1270
+ async (args) => withClient(async (client) => {
1271
+ const suggestion = await client.writeContent(args);
1272
+ return ok(`Generated ${args.action} suggestion (${suggestion.length} chars).`, { suggestion });
1273
+ })
1274
+ )
1275
+ },
1276
+ {
1277
+ name: "generate_seo_meta",
1278
+ config: {
1279
+ title: "Generate SEO metadata",
1280
+ description: "Generate SEO metadata (metaTitle, metaDescription, keywords, optional JSON-LD) for a piece of content. Pass the page/entry content as `text`. Returns a suggestion to apply via update_page or the entry SEO fields.",
1281
+ inputSchema: generateSeoMetaInput.shape
1282
+ },
1283
+ handler: guard(
1284
+ async (args) => withClient(async (client) => {
1285
+ const meta = await client.generateSeoMeta(args);
1286
+ return ok(`Generated SEO metadata: "${meta.metaTitle}".`, meta);
1287
+ })
1288
+ )
1289
+ },
1290
+ // ── Lifecycle tools (parity with the remote /mcp surface) ──────────────────
1291
+ // Media management, form submissions (leads), redirects, SEO, AEO site-files,
1292
+ // promote, and entry version history. These call the management endpoints straight
1293
+ // through the client's request plumbing — no bespoke SDK method per endpoint.
1294
+ ...lifecycleTools()
963
1295
  ];
964
1296
  return defs;
965
1297
  }
@@ -1021,9 +1353,9 @@ above, including nested group/repeater), required?. Confirm, then call:
1021
1353
  - a page \u2192 \`add_page_field\` { pageId, key, label, type, ... }
1022
1354
  Both are additive: they reject a key that already exists and never retype/overwrite
1023
1355
  an existing field (edit those in the dashboard).`;
1024
- var ENTRY_FLOW = `### Create an entry \u2192 \`create_entry\` tool
1356
+ var ENTRY_FLOW = `### Create an entry \u2192 \`create_content_entry\` tool
1025
1357
  Create a content entry under a model. Ask: which model (id), the field values (data),
1026
- status (draft/published). Then call \`create_entry\` { contentModelId, data?, status?, slug? }.`;
1358
+ status (draft/published). Then call \`create_content_entry\` { contentModelId, data?, status?, slug? }.`;
1027
1359
  var FORM_FLOW = `### Form authoring \u2192 \`create_form\` / \`update_form\` tools
1028
1360
  Author a form (then the user embeds it with \`<BcmsForm form={getForm('Name')} />\` from
1029
1361
  @bettercms-ai/next). Confirm the fields with the user BEFORE creating. Never guess fields.
@@ -1050,6 +1382,32 @@ confirm. Never guess the layout.
1050
1382
  4. **Confirm the structure** (AskUserQuestion: show the block tree), then \`create_component\`
1051
1383
  { name, slug, category?, blockJson, props? } (returns the id), or \`update_component\`
1052
1384
  { componentId, ... } \u2014 note updating re-bakes every page that embeds it.`;
1385
+ var BUILD_SITE_FLOW = `### Build a whole site (schema \u2192 pages \u2192 AI copy) \u2192 composes the flows below
1386
+ End-to-end authoring from a repo or a brief. Confirm-first at every stage.
1387
+ 1. **Schema** \u2014 run the whole-project schema design above: propose the page/zone/field tree,
1388
+ get the user's approval, then \`create_page\` per page.
1389
+ 2. **Copy** \u2014 for each page/entry, draft the real text with \`write_content\` (action 'write',
1390
+ pass the section as the brief + the page title as \`context\`). Review with the user, then
1391
+ apply via \`set_page_content\` / \`create_content_entry\` / \`update_content_entry\`.
1392
+ 3. **SEO** \u2014 run the SEO flow to fill metaTitle/metaDescription for each page.
1393
+ Never invent brand facts \u2014 ask the user for anything the repo/brief doesn't state.`;
1394
+ var LANDING_PAGES_FLOW = `### Generate landing pages (programmatic SEO / ABM) \u2192 \`write_content\` + \`create_content_entry\` + \`generate_seo_meta\`
1395
+ Spin up many pages sharing one template, each personalized per row (company, keyword, persona).
1396
+ 1. **Template** \u2014 ensure a dynamic page or content model exists for the template
1397
+ (\`create_page\` pageType 'dynamic' / \`create_content_model\`); its fields are the per-page slots.
1398
+ 2. **Dataset** \u2014 get the list of targets from the user (rows of variables, e.g. company + industry).
1399
+ 3. **Per row (loop)** \u2014 draft each slot with \`write_content\` (the row's variables as the brief/
1400
+ \`context\`), pick a unique slug, then \`create_content_entry\` { contentModelId, data, slug, status }.
1401
+ Add SEO with \`generate_seo_meta\` on the drafted copy and store it on the entry.
1402
+ Confirm the first 1\u20132 rows with the user before generating the rest. For hundreds of rows,
1403
+ the dashboard AI Page Builder bulk-imports a CSV \u2014 mention it.`;
1404
+ var SEO_FLOW = `### Optimize SEO \u2192 \`generate_seo_meta\` + the page/entry update tools
1405
+ Fill or refresh SEO metadata across the site.
1406
+ 1. **Target** \u2014 pick the pages/entries (\`list_pages\` / \`list_content_entries\`); confirm scope with the user.
1407
+ 2. **Per target** \u2014 read its content (\`get_page\` / \`get_content_entry\`), call \`generate_seo_meta\` with that
1408
+ content as \`text\`, review the suggested metaTitle/metaDescription/keywords, then apply it via
1409
+ the page/entry update tools (or the dashboard SEO fields).
1410
+ Keep titles ~60 chars and descriptions ~155; don't overwrite good existing meta without asking.`;
1053
1411
  function registerPrompts(server) {
1054
1412
  server.registerPrompt(
1055
1413
  "studio",
@@ -1070,7 +1428,7 @@ function registerPrompts(server) {
1070
1428
  ${request ? `The user's request: "${request}".
1071
1429
  ` : ""}
1072
1430
  First, **preflight**: confirm the bettercms tools are loaded (\`create_page\`, \`add_field\`,
1073
- \`create_entry\`). If \`create_page\` is missing and only \`create_model\` shows, the host has a
1431
+ \`create_content_entry\`). If \`create_page\` is missing and only \`create_model\` shows, the host has a
1074
1432
  stale cached MCP \u2014 tell the user to run \`rm -rf ~/.npm/_npx\` and restart, then stop.
1075
1433
 
1076
1434
  Then **route** to the matching sub-flow below based on the request and conversation
@@ -1095,6 +1453,12 @@ ${FORM_FLOW}
1095
1453
 
1096
1454
  ${COMPONENT_FLOW}
1097
1455
 
1456
+ ${BUILD_SITE_FLOW}
1457
+
1458
+ ${LANDING_PAGES_FLOW}
1459
+
1460
+ ${SEO_FLOW}
1461
+
1098
1462
  This assistant is extensible: when new BetterCMS tools are added, a new sub-flow appears
1099
1463
  here \u2014 route to it the same way.`
1100
1464
  }
@@ -1119,7 +1483,7 @@ here \u2014 route to it the same way.`
1119
1483
  type: "text",
1120
1484
  text: `Design the BetterCMS content schema for this repository.${request ? ` Focus: "${request}".` : ""}
1121
1485
 
1122
- Preflight: if \`create_page\` isn't available (only create_model/add_field/create_entry),
1486
+ Preflight: if \`create_page\` isn't available (only create_model/add_field/create_content_entry),
1123
1487
  the host has a stale cached MCP \u2014 tell the user to \`rm -rf ~/.npm/_npx\` and restart, then stop.
1124
1488
 
1125
1489
  ${SCHEMA_PROPOSAL_FLOW}
@@ -1149,7 +1513,7 @@ landed in. On 409 slug_taken, offer an alternative slug; on 401/403, the MCP key
1149
1513
  type: "text",
1150
1514
  text: `Create a BetterCMS page via the \`create_page\` tool.${request ? ` The user wants: "${request}".` : ""}
1151
1515
 
1152
- Preflight: if \`create_page\` isn't available (only create_model/add_field/create_entry),
1516
+ Preflight: if \`create_page\` isn't available (only create_model/add_field/create_content_entry),
1153
1517
  the host has a stale cached MCP \u2014 tell the user to \`rm -rf ~/.npm/_npx\` and restart, then stop.
1154
1518
 
1155
1519
  ${PAGE_FLOW}
@@ -1213,11 +1577,88 @@ On 409 slug_taken, offer an alternative slug; on 401/403, the MCP key needs (re)
1213
1577
  ]
1214
1578
  })
1215
1579
  );
1580
+ server.registerPrompt(
1581
+ "build_site",
1582
+ {
1583
+ title: "Build a site (guided, end-to-end)",
1584
+ description: "Author a whole BetterCMS site from a repo or a brief: design the schema, create the pages, draft the copy with AI (write_content), and fill SEO (generate_seo_meta).",
1585
+ argsSchema: {
1586
+ request: z2.string().optional().describe("what to build, e.g. 'a SaaS marketing site from this repo'")
1587
+ }
1588
+ },
1589
+ ({ request }) => ({
1590
+ messages: [
1591
+ {
1592
+ role: "user",
1593
+ content: {
1594
+ type: "text",
1595
+ text: `Build a BetterCMS site end-to-end.${request ? ` The user wants: "${request}".` : ""}
1596
+
1597
+ ${BUILD_SITE_FLOW}
1598
+
1599
+ ${SEO_FLOW}
1600
+
1601
+ Confirm each stage with the user before writing. On 401/403, the MCP key needs (re)authorizing.`
1602
+ }
1603
+ }
1604
+ ]
1605
+ })
1606
+ );
1607
+ server.registerPrompt(
1608
+ "generate_landing_pages",
1609
+ {
1610
+ title: "Generate landing pages (programmatic SEO / ABM)",
1611
+ description: "Spin up many personalized landing pages from one template + a dataset, drafting each page's copy with write_content and SEO with generate_seo_meta.",
1612
+ argsSchema: {
1613
+ request: z2.string().optional().describe("the campaign, e.g. 'a page per target company for our ABM push'")
1614
+ }
1615
+ },
1616
+ ({ request }) => ({
1617
+ messages: [
1618
+ {
1619
+ role: "user",
1620
+ content: {
1621
+ type: "text",
1622
+ text: `Generate programmatic landing pages.${request ? ` The user wants: "${request}".` : ""}
1623
+
1624
+ ${LANDING_PAGES_FLOW}
1625
+
1626
+ On 401/403, the MCP key needs (re)authorizing.`
1627
+ }
1628
+ }
1629
+ ]
1630
+ })
1631
+ );
1632
+ server.registerPrompt(
1633
+ "seo_optimize",
1634
+ {
1635
+ title: "Optimize SEO (guided)",
1636
+ description: "Generate and apply SEO metadata (title, description, keywords, JSON-LD) across pages/entries with generate_seo_meta.",
1637
+ argsSchema: {
1638
+ request: z2.string().optional().describe("scope, e.g. 'all blog posts' or 'the home page'")
1639
+ }
1640
+ },
1641
+ ({ request }) => ({
1642
+ messages: [
1643
+ {
1644
+ role: "user",
1645
+ content: {
1646
+ type: "text",
1647
+ text: `Optimize SEO metadata.${request ? ` The user wants: "${request}".` : ""}
1648
+
1649
+ ${SEO_FLOW}
1650
+
1651
+ On 401/403, the MCP key needs (re)authorizing.`
1652
+ }
1653
+ }
1654
+ ]
1655
+ })
1656
+ );
1216
1657
  }
1217
1658
 
1218
1659
  // src/server.ts
1219
1660
  var SERVER_NAME = "bettercms";
1220
- var SERVER_VERSION = "0.1.0";
1661
+ var SERVER_VERSION = "1.3.0";
1221
1662
  function buildServer(deps) {
1222
1663
  const server = new McpServer(
1223
1664
  { name: SERVER_NAME, version: SERVER_VERSION },