@bettercms-ai/mcp 0.34.0 → 0.36.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/README.md CHANGED
File without changes
package/dist/index.js CHANGED
@@ -11,6 +11,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
11
11
  import { BetterCMS } from "@bettercms-ai/sdk";
12
12
 
13
13
  // src/tools.ts
14
+ import { AsyncLocalStorage } from "async_hooks";
14
15
  import { z } from "zod";
15
16
 
16
17
  // ../types/src/component.ts
@@ -2023,29 +2024,30 @@ async function askFramework(deps) {
2023
2024
  }
2024
2025
  var AUTHORING_CHOICES = ["components", "fields"];
2025
2026
  var AUTHORING_LABELS = {
2026
- components: "Components \u2014 reusable section components placed as blocks; editors add, reorder and swap sections without touching a schema. Best for marketing and landing sites",
2027
- fields: "Fields \u2014 a typed field schema per page. Best for blogs, catalogues and directories, where many rows share one shape"
2027
+ components: "Components (recommended) \u2014 reusable section components placed as blocks; editors add, reorder and swap sections without touching a schema. Recommend it for every marketing, landing, agency or product site",
2028
+ fields: "Fields \u2014 a typed field schema per page. Only for a blog, catalogue or directory where many rows share one shape; editors can change a page's copy but cannot add, reorder or swap its sections"
2028
2029
  };
2029
2030
  var AUTHORING_PROMPT = [
2030
2031
  "Ask the user which authoring architecture this site should use, then call set_authoring_preference again with their answer as `preference`:",
2031
2032
  ...AUTHORING_CHOICES.map((c, i) => ` ${i + 1}. ${c} \u2014 ${AUTHORING_LABELS[c]}`),
2032
2033
  "",
2033
- "Answering 'components' does not convert anything \u2014 there is no field-to-block converter. It means you author the sections yourself: create_component, then publish_component (an unpublished component renders as NOTHING on the live site), then set_page_content placing `component` blocks. Once a page has blocks, list_extraction_candidates and extract_component fold the repeats.",
2034
+ "On a site whose pages were DERIVED at import, answering 'components' redoes nothing: after set_authoring_preference, get_componentize_plan proposes one component per section (it reads the PUBLISHED pages \u2014 no deploy needed to reach it), you confirm it with the user, then componentize_sections places them with `props.bind` (dry run first \u2014 they land as drafts and the page keeps its fields, values and bindings, so click-to-edit and the coverage meter are unchanged), then publish_component each one, publish the pages, run `npx @bettercms-ai/convert --componentize` in the repo, and deploy. For a section group the plan lists as NO_GROUP_ROOT or NOT_A_SECTION there is nothing to componentize, so author that section with create_component and set_page_content.",
2034
2035
  "",
2036
+ "Recommend components unless the user says the site is schema-first.",
2035
2037
  "Do not choose on their behalf. This is asked once per project."
2036
2038
  ].join("\n");
2037
2039
  async function askAuthoring(deps) {
2038
2040
  if (!deps.elicit) return { prompt: AUTHORING_PROMPT };
2039
2041
  try {
2040
2042
  const res = await deps.elicit({
2041
- message: "Which authoring architecture should this site use?",
2043
+ message: "Which authoring architecture should this site use? Components is recommended for a marketing site.",
2042
2044
  requestedSchema: {
2043
2045
  type: "object",
2044
2046
  properties: {
2045
2047
  preference: {
2046
2048
  type: "string",
2047
2049
  title: "Authoring architecture",
2048
- description: "How this site's pages are composed. Asked once per project.",
2050
+ description: "How this site's pages are composed. Components is recommended unless this is a schema-first site (a blog, catalogue or directory). Asked once per project.",
2049
2051
  enum: [...AUTHORING_CHOICES],
2050
2052
  enumNames: AUTHORING_CHOICES.map((c) => AUTHORING_LABELS[c])
2051
2053
  }
@@ -2217,19 +2219,32 @@ function authPrompt(err) {
2217
2219
  ].join("\n");
2218
2220
  return { content: [{ type: "text", text }], isError: true };
2219
2221
  }
2222
+ var currentProject = new AsyncLocalStorage();
2223
+ var projectIdArg = z.string().min(1).optional().describe(
2224
+ "only needed when your grant covers the whole WORKSPACE: the project to act on (from list_projects). A project-scoped key ignores it."
2225
+ );
2226
+ var withProjectId = (def) => ({
2227
+ ...def,
2228
+ config: def.config.inputSchema.projectId ? def.config : { ...def.config, inputSchema: { ...def.config.inputSchema, projectId: projectIdArg } },
2229
+ handler: (args) => {
2230
+ const project = typeof args?.projectId === "string" && args.projectId ? args.projectId : void 0;
2231
+ return currentProject.run(project, () => def.handler(args));
2232
+ }
2233
+ });
2220
2234
  function buildToolDefs(deps) {
2221
2235
  async function withClient(fn) {
2222
2236
  const token = await deps.auth.getAccessToken();
2237
+ const project = currentProject.getStore();
2223
2238
  try {
2224
- return await fn(deps.createClient(token));
2239
+ return await fn(deps.createClient(token, project));
2225
2240
  } catch (err) {
2226
2241
  if (err instanceof BetterCMSError && err.status === 401) {
2227
2242
  const next = await deps.auth.refresh() ?? await deps.auth.getAccessToken();
2228
- return await fn(deps.createClient(next));
2243
+ return await fn(deps.createClient(next, project));
2229
2244
  }
2230
2245
  if (err instanceof BetterCMSError && err.status === 409 && err.bodyCode === "PROJECT_DELETED") {
2231
2246
  const next = await deps.auth.resetAndReauthorize();
2232
- return await fn(deps.createClient(next));
2247
+ return await fn(deps.createClient(next, project));
2233
2248
  }
2234
2249
  throw err;
2235
2250
  }
@@ -2863,7 +2878,7 @@ function buildToolDefs(deps) {
2863
2878
  def(
2864
2879
  "set_authoring_preference",
2865
2880
  "Set the site's authoring architecture",
2866
- "Record which authoring architecture this site uses \u2014 'components' (reusable section components placed as blocks; editors add, reorder and swap sections without touching a schema \u2014 best for marketing and landing sites) or 'fields' (a typed field schema per page \u2014 best for blogs, catalogues and directories). ASK THE USER; do not pick for them. Called without `preference`, this tool asks them directly (or hands you the question to ask). deploy_project, deploy_from_upload and promote_project all refuse with 409 AUTHORING_DECISION_REQUIRED until it is set, and that refusal carries this project's real page counts to show the user. Answering 'components' does NOT convert anything \u2014 there is no field-to-block converter; it means you author the sections yourself: create_component, then publish_component (an unpublished component renders as NOTHING on the live site), then set_page_content placing `component` blocks. Asked once per project; re-callable if the user changes their mind.",
2881
+ "Record which authoring architecture this site uses \u2014 'components' (RECOMMENDED: reusable section components placed as blocks; editors add, reorder and swap sections without touching a schema \u2014 recommend it for every marketing, landing, agency or product site) or 'fields' (a typed field schema per page \u2014 only for a blog, catalogue or directory where many rows share one shape). ASK THE USER; do not pick for them. Called without `preference`, this tool asks them directly (or hands you the question to ask). deploy_project, deploy_from_upload and promote_project all refuse with 409 AUTHORING_DECISION_REQUIRED until it is set, and that refusal carries this project's real page counts to show the user. On a site whose pages were DERIVED at import, answering 'components' redoes nothing: after set_authoring_preference, get_componentize_plan proposes one component per section (it reads the PUBLISHED pages \u2014 no deploy needed to reach it), you confirm it with the user, then componentize_sections places them with `props.bind` (dry run first \u2014 they land as drafts and the page keeps its fields, values and bindings, so click-to-edit and the coverage meter are unchanged), then publish_component each one, publish the pages, run `npx @bettercms-ai/convert --componentize` in the repo, and deploy. Recommend components unless the user says the site is schema-first. Asked once per project; re-callable if the user changes their mind.",
2867
2882
  // Optional in the schema for exactly the reason `framework` is above: a required arg is
2868
2883
  // rejected by the SDK before the handler runs, which would kill the elicitation below
2869
2884
  // and leave the model guessing. Optional here, answered by a human there. The backend
@@ -2882,14 +2897,14 @@ function buildToolDefs(deps) {
2882
2897
  def(
2883
2898
  "set_binding_mode",
2884
2899
  "Set how the site's bindings are resolved",
2885
- "Switch this project between the two binding resolvers, from the NEXT release on. `declaredBindings: true` makes the annotator trust the template's own data-bcms-field / data-bcms-props and never guess from rendered text \u2014 the durable state; `false` returns to text-matching, which works once (at import, when the CMS values equal the built copy) and breaks the first time anyone edits a value. Call it ONLY after every page's copy is declared in the template: undeclared fields stop being editable. The order is push \u2192 release \u2192 get_binding_report shows mode 'text-match' with 0 unmatched \u2192 set_binding_mode \u2192 release again \u2192 get_binding_report shows mode 'declared'. Flipping back is the same call. REQUIRES a project-scoped connection carrying the artifact:write scope \u2014 the same authority that deploys the site \u2014 because this decides what every future release does to every page; a workspace-wide grant is refused with 403. See section 13 of the bettercms://playbook/schema resource.",
2900
+ "Switch this project between the two binding resolvers, from the NEXT release on. `declaredBindings: true` makes the annotator trust the template's own data-bcms-field / data-bcms-props and never guess from rendered text \u2014 the durable state; `false` returns to text-matching, which works once (at import, when the CMS values equal the built copy) and breaks the first time anyone edits a value. Call it ONLY after every page's copy is declared in the template: undeclared fields stop being editable. The order is push \u2192 release \u2192 get_binding_report shows mode 'text-match' with 0 unmatched \u2192 set_binding_mode \u2192 release again \u2192 get_binding_report shows mode 'declared'. Flipping back is the same call. REQUIRES the artifact:write scope \u2014 the same authority that deploys the site \u2014 because this decides what every future release does to every page. On a workspace-wide connection pass `projectId`: the scope is there, but the human who authorized the connection must be able to publish THAT project or it is refused with 403 PROJECT_PUBLISH_DENIED. See section 13 of the bettercms://playbook/schema resource.",
2886
2901
  z.object({ declaredBindings: z.boolean().describe("true = trust the template's declared bindings; false = text-match (the default)") }).shape,
2887
2902
  async (c, a) => ok("Recorded the binding mode.", await data(c, "PATCH", `/management/projects/current/binding-mode`, { declaredBindings: a.declaredBindings }))
2888
2903
  ),
2889
2904
  def(
2890
2905
  "submit_conversion_receipt",
2891
2906
  "Record what the conversion codemod could and could not do",
2892
- "Hand BetterCMS the codemod's own account of a conversion run, so the coverage meter can say WHY a path is not declared instead of only that it is not. Submit the receipt `npx @bettercms-ai/convert` wrote (`--receipt out.json`) for the SAME `briefDigest` get_conversion_brief { complete: true } returned: `{ briefDigest, receipt }`, where the receipt carries `paths: { declared, rewritten, alreadyDeclared, pending[{ route, scope, path, kind, file, reason, message }] }`. `paths.declared` must equal rewritten + alreadyDeclared + pending.length, and each pending `reason` is one of the converter's own (IN_EXPRESSION, AMBIGUOUS_LITERAL, REPEATER_FIXED_LENGTH, PARSE_ERROR, \u2026) \u2014 a path with no receipt row simply reads `not-declared`. It is a RECORD, not a release: it changes nothing about the site, and the meter picks it up on the next get_binding_report after the next deploy. A 404 `unknown-brief` means that digest was never issued here, so convert against a brief this project actually returned. Requires a project-scoped connection carrying artifact:write, the same authority as set_binding_mode.",
2907
+ "Hand BetterCMS the codemod's own account of a conversion run, so the coverage meter can say WHY a path is not declared instead of only that it is not. Submit the receipt `npx @bettercms-ai/convert` wrote (`--receipt out.json`) for the SAME `briefDigest` get_conversion_brief { complete: true } returned: `{ briefDigest, receipt }`, where the receipt carries `paths: { declared, rewritten, alreadyDeclared, pending[{ route, scope, path, kind, file, reason, message }] }`. `paths.declared` must equal rewritten + alreadyDeclared + pending.length, and each pending `reason` is one of the converter's own (IN_EXPRESSION, AMBIGUOUS_LITERAL, REPEATER_FIXED_LENGTH, PARSE_ERROR, \u2026) \u2014 a path with no receipt row simply reads `not-declared`. It is a RECORD, not a release: it changes nothing about the site, and the meter picks it up on the next get_binding_report after the next deploy. A 404 `unknown-brief` means that digest was never issued here, so convert against a brief this project actually returned. Requires artifact:write, the same authority as set_binding_mode; on a workspace-wide connection pass `projectId`.",
2893
2908
  z.object({
2894
2909
  briefDigest: z.string().min(1).describe("The `briefDigest` get_conversion_brief { complete: true } returned. Must match the receipt's own."),
2895
2910
  receipt: z.record(z.string(), z.unknown()).describe("The receipt `npx @bettercms-ai/convert --receipt out.json` wrote, verbatim.")
@@ -2955,17 +2970,19 @@ function buildToolDefs(deps) {
2955
2970
  async (c, a) => ok("Updated page.", await data(c, "PATCH", `/management/pages/${s(a.pageId)}/meta`, { title: a.title, slug: a.slug, blockJson: a.blockJson, metaTitle: a.metaTitle, metaDescription: a.metaDescription, status: a.status }))
2956
2971
  ),
2957
2972
  // ── Code + deploy (parity with remote /mcp; needs artifact:write) ──
2973
+ // Both grant shapes carry that scope now; a workspace-wide one names its target per
2974
+ // call via `projectId` and is re-checked against it. @see management/projects.ts
2958
2975
  def(
2959
2976
  "pull_project_source",
2960
2977
  "Pull the project's live source",
2961
- "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, `github` carries owner, repo, branch and cloneUrl \u2014 clone it and work on THAT branch, because it is the one the provisioned Action builds from; a commit on any other branch never reaches the live site.",
2978
+ "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, `github` carries owner, repo, branch and cloneUrl \u2014 clone it and work on THAT branch, because it is the one the provisioned Action builds from; a commit on any other branch never reaches the live site. On a workspace-wide connection pass `projectId` (from list_projects) to say which site's source to pull.",
2962
2979
  z.object({}).shape,
2963
2980
  async (c) => ok("Project source.", await data(c, "GET", `/management/projects/source`))
2964
2981
  ),
2965
2982
  def(
2966
2983
  "deploy_project",
2967
2984
  "Deploy new source/build",
2968
- "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. Returns the release id + sha \u2014 then poll get_deploy_status until it is live. IMPORTANT \u2014 you MUST exclude node_modules, .git, and build output/caches (dist, build, .next, .astro, .cache) BEFORE creating the archive: a source deploy is reinstalled and built server-side, so those are never needed, and the upload has a hard size ceiling (~100 MB) enforced before the request reaches the server \u2014 an archive that includes node_modules is rejected in transit (a 413/502 with no server-side detail). Keep the archive to your own source files. Server-side stripping exists as a safety net, but it runs AFTER the upload and cannot rescue an over-limit body. For a LARGE archive (or if this returns a 413/502), use create_deploy_upload + deploy_from_upload instead \u2014 that path uploads straight to storage with no size ceiling. Returns `canvas.lane` \u2014 which live-preview lane the last release gave the editor (`bridge` / `draft-route` / `none`; see the playbook's section 11) \u2014 and `editorUrl`, the visual editor to hand the user.",
2985
+ "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. Returns the release id + sha \u2014 then poll get_deploy_status until it is live. IMPORTANT \u2014 you MUST exclude node_modules, .git, and build output/caches (dist, build, .next, .astro, .cache) BEFORE creating the archive: a source deploy is reinstalled and built server-side, so those are never needed, and the upload has a hard size ceiling (~100 MB) enforced before the request reaches the server \u2014 an archive that includes node_modules is rejected in transit (a 413/502 with no server-side detail). Keep the archive to your own source files. Server-side stripping exists as a safety net, but it runs AFTER the upload and cannot rescue an over-limit body. For a LARGE archive (or if this returns a 413/502), use create_deploy_upload + deploy_from_upload instead \u2014 that path uploads straight to storage with no size ceiling. Returns `canvas.lane` \u2014 which live-preview lane the last release gave the editor (`bridge` / `draft-route` / `none`; see the playbook's section 11) \u2014 and `editorUrl`, the visual editor to hand the user. On a workspace-wide connection pass `projectId` (from list_projects) to say which site this ships to.",
2969
2986
  z.object({ data: z.string().min(1).describe("base64 .tgz/.zip of the project"), mimeType: z.string().optional() }).shape,
2970
2987
  async (c, a) => ok("Deploy queued.", await raw(c, `/management/projects/deploy`, s(a.data), a.mimeType))
2971
2988
  ),
@@ -3025,7 +3042,7 @@ function buildToolDefs(deps) {
3025
3042
  def(
3026
3043
  "get_conversion_plan",
3027
3044
  "Get the approved conversion to apply",
3028
- "The APPROVED conversion a human reviewed in the BetterCMS dashboard: the exact new contents of each template file, already checked against this project's real field paths. Apply it instead of writing the bindings by hand. Check out `baseHeadOid` (the exact commit it was written against \u2014 a plan applied to a different base is a different change), branch from there, write each file's `content` verbatim (whole file, no merge, no reformatting), then push or deploy however this project ships. `stale.head` / `stale.brief` say the repository or the CMS moved since it was approved: stop and ask for a fresh proposal rather than applying it anyway. `receipt.paths.pending` lists every field the codemod could NOT bind, each with a reason (`DIALECT_UNSUPPORTED`, `PROP_TARGET_NOT_FOUND`, `REPEATER_FIXED_LENGTH`, \u2026) \u2014 apply the plan first, then bind those by hand. Finish the loop the same way as a hand conversion \u2014 get_binding_report until `unmatched` is empty, then set_binding_mode { declaredBindings: true } and release once more. 404 with `code: \"no-approved-plan\"` means nobody has approved one: use get_conversion_brief and do the conversion yourself. Requires a project-scoped connection carrying artifact:write.",
3045
+ "The APPROVED conversion a human reviewed in the BetterCMS dashboard: the exact new contents of each template file, already checked against this project's real field paths. Apply it instead of writing the bindings by hand. Check out `baseHeadOid` (the exact commit it was written against \u2014 a plan applied to a different base is a different change), branch from there, write each file's `content` verbatim (whole file, no merge, no reformatting), then push or deploy however this project ships. `stale.head` / `stale.brief` say the repository or the CMS moved since it was approved: stop and ask for a fresh proposal rather than applying it anyway. `receipt.paths.pending` lists every field the codemod could NOT bind, each with a reason (`DIALECT_UNSUPPORTED`, `PROP_TARGET_NOT_FOUND`, `REPEATER_FIXED_LENGTH`, \u2026) \u2014 apply the plan first, then bind those by hand. Finish the loop the same way as a hand conversion \u2014 get_binding_report until `unmatched` is empty, then set_binding_mode { declaredBindings: true } and release once more. 404 with `code: \"no-approved-plan\"` means nobody has approved one: use get_conversion_brief and do the conversion yourself. Requires artifact:write; on a workspace-wide connection pass `projectId`.",
3029
3046
  z.object({}).shape,
3030
3047
  async (c) => ok("Approved conversion plan.", await data(c, "GET", `/management/projects/current/conversion-plan`))
3031
3048
  ),
@@ -3757,7 +3774,7 @@ ${lines.join("\n")}`, found);
3757
3774
  // through the client's request plumbing — no bespoke SDK method per endpoint.
3758
3775
  ...lifecycleTools()
3759
3776
  ];
3760
- return defs;
3777
+ return defs.map(withProjectId);
3761
3778
  }
3762
3779
  function registerTools(server, deps) {
3763
3780
  const withElicit = {
@@ -3788,7 +3805,8 @@ touching a schema. This is what \`create_component\` + \`create_page(blockJson)\
3788
3805
  A collection (\`create_content_model\`) plus entries.
3789
3806
 
3790
3807
  Most real sites are both: components for the marketing pages, a collection for the blog.
3791
- Decide before your first call; converting later means rewriting content.
3808
+ Decide before your first call; converting later means rewriting content \u2014 unless the site was
3809
+ DERIVED at import, where the componentize lane in \xA710 reuses every field.
3792
3810
 
3793
3811
  ## 2. The decision tree
3794
3812
 
@@ -3949,11 +3967,18 @@ in preview and is blank in production. Always pass the project's id.
3949
3967
  accepts it and ignores it**, silently. No error, no warning, wrong project.
3950
3968
 
3951
3969
  So call \`get_project\` (no arguments) first \u2014 it reports the project you are actually
3952
- writing to. If that is not where the work belongs \u2014 or it answers that the grant covers the
3953
- whole workspace \u2014 stop and tell the user the one-step fix: in the BetterCMS dashboard open
3954
- **Settings \u2192 Connected AI clients** and use **Scope to project** on this connection. No tool
3955
- can switch it, but the user does NOT need to re-authorize or re-add the server: the next call
3956
- after that runs with the new scope. Then retry.
3970
+ writing to.
3971
+
3972
+ **If the grant covers the WHOLE WORKSPACE, that is not a blocker and there is nothing to
3973
+ stop for.** Pass \`projectId\` on every project tool \u2014 \`list_projects\` gives you the id, and
3974
+ \`get_project { projectId }\` confirms you are addressing the right site. Every tool takes it,
3975
+ including the code and deploy ones. Do NOT send the user to re-scope the connection; the only
3976
+ thing that genuinely blocks you is a grant on a different WORKSPACE, which is what an empty or
3977
+ foreign \`list_projects\` tells you.
3978
+
3979
+ If the connected project is simply the WRONG one for the work \u2014 a project-scoped grant pointed
3980
+ elsewhere \u2014 that is the case the user has to fix, in the BetterCMS dashboard under
3981
+ **Settings \u2192 Connected AI clients**. No tool can switch it.
3957
3982
 
3958
3983
  If you must probe, probe with a \`create_content_model\` \u2014 models are deletable
3959
3984
  (\`delete_content_model\`, soft-delete) and **there is no \`delete_component\`**. A component
@@ -3969,31 +3994,49 @@ out, let the user pick, call \`set_authoring_preference\`, then deploy again.
3969
3994
  It exists because an imported site arrives **field-driven whether anyone chose that or not**
3970
3995
  \u2014 a crawl-based import (Webflow, a starter, a template) emits pages with a typed field schema
3971
3996
  and an empty block tree, because that is all a crawl can infer. Nobody decided it. On a
3972
- marketing site it is the wrong answer, and \xA71 already says why converting later means
3973
- rewriting content. So the platform stops once, at the last moment it is still cheap.
3974
-
3975
- **Answering \`components\` does not convert anything.** There is no field-to-block converter,
3976
- and \`extract_component\` cannot stand in for one: it scans \`blockJson\`, which is empty on
3977
- exactly the pages that would need converting. What it means is that you author the sections,
3978
- in this order:
3979
-
3980
- 1. create_component per section (they land as DRAFTS)
3981
- 2. publish_component each one \u2014 unpublished renders as NOTHING, on a page that 200s
3982
- 2b. publish_layout if you authored site chrome \u2014 the layout is a separate publish
3983
- 3. set_page_content place them as \`component\` blocks on the page
3984
- 4. list_extraction_candidates / extract_component
3985
- now that blocks exist, fold any section repeated 3+ times
3986
-
3987
- Step 2 is not a formality: \`componentize_sections\` and \`create_component\` both land components
3997
+ marketing site it is the wrong answer. So the platform stops once, at the last moment it is
3998
+ still cheap \u2014 and on a derived site that moment costs nothing, because the componentize lane
3999
+ below reuses every field the import already derived.
4000
+
4001
+ **Recommend \`components\`.** On a site whose pages were DERIVED at import \u2014 the site \xA713
4002
+ describes \u2014 it redoes nothing: the componentize lane turns each top-level field GROUP into a
4003
+ component placement, and the page keeps its fields, its values and its bindings. In this order:
4004
+
4005
+ 1. set_authoring_preference { preference: "components" }
4006
+ 2. get_componentize_plan one component per section, computed live; creates nothing. It
4007
+ reads this project's PUBLISHED pages, so no deploy is needed
4008
+ to reach it \u2014 the deploy is the last step, not the first
4009
+ 3. show the user the plan and CONFIRM \u2014 it says how many components and which pages
4010
+ 4. componentize_sections \`dryRun: true\` first, then for real; components land as DRAFTS
4011
+ and each placement carries \`props.bind\` to the page's field
4012
+ group, so click-to-edit and the coverage meter do not change
4013
+ 5. publish_component each one \u2014 unpublished renders as NOTHING, on a page that 200s
4014
+ 5b. publish_layout if you authored site chrome \u2014 the layout is a separate publish
4015
+ 6. publish the pages
4016
+ 7. npx @bettercms-ai/convert --componentize in the repo, so its templates render these
4017
+ sections from \`pages[].blocks\`
4018
+ 8. deploy the ONLY deploy this sequence needs
4019
+
4020
+ Step 5 is not a formality: \`componentize_sections\` and \`create_component\` both land components
3988
4021
  as DRAFTS, so \`publish_component\` each one and publish the page \u2014 otherwise the canvas keeps
3989
4022
  painting the PUBLISHED copy and the editor reports N components unpublished.
3990
4023
 
3991
- **Answering \`fields\` is a real answer, not a deferral.** A blog, a catalogue or a directory
3992
- is schema-first by design (\xA71) and should stay that way. Say so and move on.
4024
+ **A site with nothing to componentize you author by hand.** Where the plan offers nothing for
4025
+ a group (\`NO_GROUP_ROOT\` \u2014 the page's field keys are still the derive lane's own;
4026
+ \`NOT_A_SECTION\` \u2014 a lone scalar with no family), and on a site with no derived pages at all,
4027
+ the order is \`create_component\` per section \u2192 \`publish_component\` each \u2192 \`set_page_content\`
4028
+ placing \`component\` blocks \u2192 \`list_extraction_candidates\` / \`extract_component\` to fold any
4029
+ section repeated 3+ times. \`extract_component\` is not a converter and cannot stand in for the
4030
+ componentize lane: it scans \`blockJson\`, which is empty on exactly the pages that would need
4031
+ converting.
3993
4032
 
3994
- Either way: ask, do not choose. The 409 carries this project's actual page counts \u2014 how many
3995
- are field-driven, block-driven, and how many place a reusable component \u2014 so quote those to
3996
- the user rather than describing the choice in the abstract.
4033
+ **Answering \`fields\` is a real answer, not a deferral** \u2014 for a SCHEMA-FIRST site. A blog, a
4034
+ catalogue or a directory is schema-first by design (\xA71) and should stay that way. On anything
4035
+ else it costs the editor the ability to add, reorder or swap sections. Say so and move on.
4036
+
4037
+ Either way: ask, do not choose \u2014 recommending is not answering. The 409 carries this project's
4038
+ actual page counts \u2014 how many are field-driven, block-driven, and how many place a reusable
4039
+ component \u2014 so quote those to the user rather than describing the choice in the abstract.
3997
4040
 
3998
4041
  ## 11. The canvas: what makes an imported site EDITABLE
3999
4042
 
@@ -4135,6 +4178,10 @@ the canvas but skips release annotation and publish-time injection, because ther
4135
4178
  disk to annotate. Copy rendered on the client must carry the attributes in the HYDRATED DOM,
4136
4179
  and only the canvas sees it \u2014 a release scan cannot.
4137
4180
 
4181
+ **On a workspace-wide connection, pass \`projectId\` on every call in this section** \u2014 that is
4182
+ all it takes; the code and deploy tools work from such a connection and nobody needs to re-scope
4183
+ anything. See \xA79.
4184
+
4138
4185
  **Which recipe.** There are TWO below and they are not alternatives you pick by taste \u2014 call
4139
4186
  \`get_binding_report\` and \`get_conversion_brief\` first and let the answer choose. A brief that
4140
4187
  comes back WITH PAGES means this project was imported and deployed, so its schema and values were
@@ -4754,7 +4801,10 @@ function buildServer(deps) {
4754
4801
  { name: SERVER_NAME, version: SERVER_VERSION, ...SERVER_DISPLAY },
4755
4802
  {
4756
4803
  capabilities: { tools: {}, prompts: {}, resources: {} },
4757
- instructions: "BetterCMS never executes a customer's Section renderer or app code. An ordinary MCP connection is not a push runner: explicitly poll list_section_validation_requests, claim one request at an exact git commit, run implementation and responsive checks inside the user's own repository and real app shell, then submit manifest + validation with that requestId and complete it\u2014or truthfully fail it when implementation/evidence is missing. Never invent a manifest, a passing validation, or visual evidence; these tools cannot grant the separate human Visual Approval required for publication."
4804
+ // 🔴 The plain-language ask, routed — the stdio twin of the hosted connector's line
4805
+ // (src/routes/mcp/index.ts MCP_INSTRUCTIONS). "Make my site editable" has one recipe and
4806
+ // one definition of done; without this an agent converts the page it landed on and stops.
4807
+ instructions: "When the user asks to make a site or all of its pages editable, to convert it, or to componentize the whole site: this is playbook \xA713. Read `bettercms://playbook/schema` \xA713, call get_binding_report and get_conversion_brief { complete: true }, convert EVERY route the brief lists, and finish only when get_binding_report shows coverage.pending empty on every route \u2014 not when the first page works. On a workspace-wide connection pass projectId on every call; never ask the user to re-scope the connection. BetterCMS never executes a customer's Section renderer or app code. An ordinary MCP connection is not a push runner: explicitly poll list_section_validation_requests, claim one request at an exact git commit, run implementation and responsive checks inside the user's own repository and real app shell, then submit manifest + validation with that requestId and complete it\u2014or truthfully fail it when implementation/evidence is missing. Never invent a manifest, a passing validation, or visual evidence; these tools cannot grant the separate human Visual Approval required for publication."
4758
4808
  }
4759
4809
  );
4760
4810
  server.registerResource(
@@ -4771,7 +4821,10 @@ function buildServer(deps) {
4771
4821
  );
4772
4822
  registerTools(server, {
4773
4823
  auth: deps.auth,
4774
- createClient: (apiKey) => BetterCMS.management({ apiKey, baseUrl: deps.managementBaseUrl })
4824
+ // `project` is the per-call target of a workspace-wide grant; the SDK sends it as
4825
+ // X-BCMS-Project. Omitted entirely when absent so a project-scoped key's request is
4826
+ // byte-identical to before.
4827
+ createClient: (apiKey, project) => BetterCMS.management({ apiKey, baseUrl: deps.managementBaseUrl, ...project ? { project } : {} })
4775
4828
  });
4776
4829
  registerPrompts(server);
4777
4830
  return server;