@bettercms-ai/mcp 0.33.0 → 0.35.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/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
@@ -2217,19 +2218,32 @@ function authPrompt(err) {
2217
2218
  ].join("\n");
2218
2219
  return { content: [{ type: "text", text }], isError: true };
2219
2220
  }
2221
+ var currentProject = new AsyncLocalStorage();
2222
+ var projectIdArg = z.string().min(1).optional().describe(
2223
+ "only needed when your grant covers the whole WORKSPACE: the project to act on (from list_projects). A project-scoped key ignores it."
2224
+ );
2225
+ var withProjectId = (def) => ({
2226
+ ...def,
2227
+ config: def.config.inputSchema.projectId ? def.config : { ...def.config, inputSchema: { ...def.config.inputSchema, projectId: projectIdArg } },
2228
+ handler: (args) => {
2229
+ const project = typeof args?.projectId === "string" && args.projectId ? args.projectId : void 0;
2230
+ return currentProject.run(project, () => def.handler(args));
2231
+ }
2232
+ });
2220
2233
  function buildToolDefs(deps) {
2221
2234
  async function withClient(fn) {
2222
2235
  const token = await deps.auth.getAccessToken();
2236
+ const project = currentProject.getStore();
2223
2237
  try {
2224
- return await fn(deps.createClient(token));
2238
+ return await fn(deps.createClient(token, project));
2225
2239
  } catch (err) {
2226
2240
  if (err instanceof BetterCMSError && err.status === 401) {
2227
2241
  const next = await deps.auth.refresh() ?? await deps.auth.getAccessToken();
2228
- return await fn(deps.createClient(next));
2242
+ return await fn(deps.createClient(next, project));
2229
2243
  }
2230
2244
  if (err instanceof BetterCMSError && err.status === 409 && err.bodyCode === "PROJECT_DELETED") {
2231
2245
  const next = await deps.auth.resetAndReauthorize();
2232
- return await fn(deps.createClient(next));
2246
+ return await fn(deps.createClient(next, project));
2233
2247
  }
2234
2248
  throw err;
2235
2249
  }
@@ -2882,14 +2896,14 @@ function buildToolDefs(deps) {
2882
2896
  def(
2883
2897
  "set_binding_mode",
2884
2898
  "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.",
2899
+ "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
2900
  z.object({ declaredBindings: z.boolean().describe("true = trust the template's declared bindings; false = text-match (the default)") }).shape,
2887
2901
  async (c, a) => ok("Recorded the binding mode.", await data(c, "PATCH", `/management/projects/current/binding-mode`, { declaredBindings: a.declaredBindings }))
2888
2902
  ),
2889
2903
  def(
2890
2904
  "submit_conversion_receipt",
2891
2905
  "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.",
2906
+ "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
2907
  z.object({
2894
2908
  briefDigest: z.string().min(1).describe("The `briefDigest` get_conversion_brief { complete: true } returned. Must match the receipt's own."),
2895
2909
  receipt: z.record(z.string(), z.unknown()).describe("The receipt `npx @bettercms-ai/convert --receipt out.json` wrote, verbatim.")
@@ -2955,17 +2969,19 @@ function buildToolDefs(deps) {
2955
2969
  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
2970
  ),
2957
2971
  // ── Code + deploy (parity with remote /mcp; needs artifact:write) ──
2972
+ // Both grant shapes carry that scope now; a workspace-wide one names its target per
2973
+ // call via `projectId` and is re-checked against it. @see management/projects.ts
2958
2974
  def(
2959
2975
  "pull_project_source",
2960
2976
  "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, returns `github: {owner, repo}` so you can `git clone` that instead.",
2977
+ "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
2978
  z.object({}).shape,
2963
2979
  async (c) => ok("Project source.", await data(c, "GET", `/management/projects/source`))
2964
2980
  ),
2965
2981
  def(
2966
2982
  "deploy_project",
2967
2983
  "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.",
2984
+ "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
2985
  z.object({ data: z.string().min(1).describe("base64 .tgz/.zip of the project"), mimeType: z.string().optional() }).shape,
2970
2986
  async (c, a) => ok("Deploy queued.", await raw(c, `/management/projects/deploy`, s(a.data), a.mimeType))
2971
2987
  ),
@@ -3025,7 +3041,7 @@ function buildToolDefs(deps) {
3025
3041
  def(
3026
3042
  "get_conversion_plan",
3027
3043
  "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.",
3044
+ "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
3045
  z.object({}).shape,
3030
3046
  async (c) => ok("Approved conversion plan.", await data(c, "GET", `/management/projects/current/conversion-plan`))
3031
3047
  ),
@@ -3757,7 +3773,7 @@ ${lines.join("\n")}`, found);
3757
3773
  // through the client's request plumbing — no bespoke SDK method per endpoint.
3758
3774
  ...lifecycleTools()
3759
3775
  ];
3760
- return defs;
3776
+ return defs.map(withProjectId);
3761
3777
  }
3762
3778
  function registerTools(server, deps) {
3763
3779
  const withElicit = {
@@ -3949,11 +3965,18 @@ in preview and is blank in production. Always pass the project's id.
3949
3965
  accepts it and ignores it**, silently. No error, no warning, wrong project.
3950
3966
 
3951
3967
  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.
3968
+ writing to.
3969
+
3970
+ **If the grant covers the WHOLE WORKSPACE, that is not a blocker and there is nothing to
3971
+ stop for.** Pass \`projectId\` on every project tool \u2014 \`list_projects\` gives you the id, and
3972
+ \`get_project { projectId }\` confirms you are addressing the right site. Every tool takes it,
3973
+ including the code and deploy ones. Do NOT send the user to re-scope the connection; the only
3974
+ thing that genuinely blocks you is a grant on a different WORKSPACE, which is what an empty or
3975
+ foreign \`list_projects\` tells you.
3976
+
3977
+ If the connected project is simply the WRONG one for the work \u2014 a project-scoped grant pointed
3978
+ elsewhere \u2014 that is the case the user has to fix, in the BetterCMS dashboard under
3979
+ **Settings \u2192 Connected AI clients**. No tool can switch it.
3957
3980
 
3958
3981
  If you must probe, probe with a \`create_content_model\` \u2014 models are deletable
3959
3982
  (\`delete_content_model\`, soft-delete) and **there is no \`delete_component\`**. A component
@@ -4135,6 +4158,17 @@ the canvas but skips release annotation and publish-time injection, because ther
4135
4158
  disk to annotate. Copy rendered on the client must carry the attributes in the HYDRATED DOM,
4136
4159
  and only the canvas sees it \u2014 a release scan cannot.
4137
4160
 
4161
+ **On a workspace-wide connection, pass \`projectId\` on every call in this section** \u2014 that is
4162
+ all it takes; the code and deploy tools work from such a connection and nobody needs to re-scope
4163
+ anything. See \xA79.
4164
+
4165
+ **Which recipe.** There are TWO below and they are not alternatives you pick by taste \u2014 call
4166
+ \`get_binding_report\` and \`get_conversion_brief\` first and let the answer choose. A brief that
4167
+ comes back WITH PAGES means this project was imported and deployed, so its schema and values were
4168
+ derived for you already \u2192 **Recipe A**. A 404 or an empty brief means the site is not in the CMS
4169
+ yet \u2192 **Recipe B**. \`get_next_steps\` works on every plan and names the next unfinished step
4170
+ either way.
4171
+
4138
4172
  **If you know Sanity, this is the same shape under different names:**
4139
4173
 
4140
4174
  defineType schema in code -> content models / page fields (create_content_model,
@@ -4152,11 +4186,12 @@ and only the canvas sees it \u2014 a release scan cannot.
4152
4186
  its build, the schema and the values already exist \u2014 a page per route, a field per element, and
4153
4187
  the original copy carried on each field as its \`defaultValue\`. Call \`get_conversion_brief\`
4154
4188
  first: it lists those pages, their routes, every bindable path with its current and original
4155
- value, and the attributes to declare. SKIP steps 3 and 4 below and bind the keys it names \u2014
4156
- registering the schema again builds a second one over the first.
4189
+ value, and the attributes to declare. SKIP Recipe B's steps 3 and 4 below and bind the keys it
4190
+ names \u2014 registering the schema again builds a second one over the first.
4157
4191
 
4158
- **Run the CODEMOD rather than editing by hand.** For a derived site the whole of step 5 is
4159
- mechanical, and there is a tool that does it. THE ORDER, and every step of it matters:
4192
+ **Recipe A \u2014 imported and deployed: the codemod order.** For a derived site the whole of Recipe
4193
+ B's step 5 is mechanical, and there is a tool that does it. Run the codemod rather than editing by
4194
+ hand. THE ORDER, and every step of it matters:
4160
4195
 
4161
4196
  1. \`get_conversion_brief { complete: true }\` \u2014 the COMPLETE brief, not the capped one. It comes
4162
4197
  back uncapped and paged (50 pages at a time): follow \`cursor\` until it stops coming back and
@@ -4176,7 +4211,11 @@ mechanical, and there is a tool that does it. THE ORDER, and every step of it ma
4176
4211
  what lets the coverage meter say WHY a path is undeclared instead of only that it is; without
4177
4212
  it every one of them reads \`not-declared\`, which looks like a broken site rather than work
4178
4213
  with a reason. It records and releases nothing.
4179
- 5. Push, or \`deploy_project\`, and wait for the release to be live (step 6 below).
4214
+ 5. Push, or \`deploy_project\`. A PLAIN HTML repo \u2014 no \`package.json\` \u2014 deploys by PUSH ONLY:
4215
+ \`deploy_project\` refuses such an archive by design, so pushing is the whole lane. Either way,
4216
+ poll \`get_deploy_status\` until \`slot\` is \`current\` (or \`awaitingPromote\` is false) before you
4217
+ read the report in step 6 \u2014 a report read against the previous release is the previous
4218
+ release's verdict.
4180
4219
  6. \`get_binding_report\`. Alongside \`unmatched\` it now carries \`coverage\` \u2014 \`declared\` (what the
4181
4220
  brief listed), \`bound\` (what the build declares) and \`pending\` with the reasons. Fix what it
4182
4221
  names and release again until \`coverage.pending\` is empty.
@@ -4185,14 +4224,15 @@ mechanical, and there is a tool that does it. THE ORDER, and every step of it ma
4185
4224
 
4186
4225
  **Or let BetterCMS propose the edit.** \`get_conversion_plan\` returns an APPROVED conversion \u2014 the
4187
4226
  exact new contents of each template file, reviewed by a human in the dashboard and already checked
4188
- against this project's real field paths. When there is one, apply it instead of doing step 5 by
4189
- hand: check out its \`baseHeadOid\`, branch from there, write each file's \`content\` verbatim, and
4190
- carry on from step 6. A 404 with \`code: "no-approved-plan"\` means nobody approved one, so the
4227
+ against this project's real field paths. When there is one, apply it instead of Recipe A's step 2
4228
+ (the codemod): check out its \`baseHeadOid\`, branch from there, write each file's \`content\`
4229
+ verbatim, and carry on from Recipe A's step 5. A 404 with \`code: "no-approved-plan"\` means nobody approved one, so the
4191
4230
  conversion is yours to write.
4192
4231
 
4193
- **The steps.**
4232
+ **Recipe B \u2014 not in the CMS yet: the steps.**
4194
4233
 
4195
- 1. \`pull_project_source\` (or clone the \`github\` remote it returns). Read the SOURCE. Never
4234
+ 1. \`pull_project_source\` (or clone the \`github\` remote it returns \u2014 \`cloneUrl\` on the \`branch\`
4235
+ it names, which is the branch the provisioned Action builds). Read the SOURCE. Never
4196
4236
  reconstruct content from the deployed HTML \u2014 that is how a site ends up bound to a copy of
4197
4237
  its own stale build.
4198
4238
  2. Decide the architecture WITH the human (\xA710) and record it: \`set_authoring_preference\`.
@@ -4266,7 +4306,9 @@ conversion is yours to write.
4266
4306
  then switch the project to "Run as a server app" on the Hosting page. Never migrate a
4267
4307
  static site to SSR without asking the human first.
4268
4308
  Then \`get_binding_report.canvas.lane\` reads \`bridge\` or \`draft-route\` instead of \`none\`.
4269
- 6. Push, or \`deploy_project\`; poll \`get_deploy_status\` until it is live. Then
4309
+ 6. Push, or \`deploy_project\`. A PLAIN HTML repo \u2014 no \`package.json\` \u2014 deploys by PUSH ONLY:
4310
+ \`deploy_project\` refuses such an archive by design. Poll \`get_deploy_status\` until \`slot\` is
4311
+ \`current\` (or \`awaitingPromote\` is false). Then
4270
4312
  \`get_binding_report\` \u2014 still \`text-match\`, and \`unmatched\` should be EMPTY because the
4271
4313
  values are byte-equal to what the build renders. On a converted site read \`coverage\` too: it
4272
4314
  counts the PINNED brief's paths, which \`unmatched\` cannot, because \`unmatched\` only ever
@@ -4279,6 +4321,10 @@ conversion is yours to write.
4279
4321
  text against its entry values yourself before you call the page done. Then publish, and
4280
4322
  fetch the live URL cache-busted (\xA712: publish and deploy are separate claims).
4281
4323
  \`get_next_steps\` keeps reporting the gap until every one of these holds.
4324
+
4325
+ **Done means.** Whichever recipe you ran: \`get_binding_report\` reads \`mode "declared"\`,
4326
+ \`unmatched\` empty, \`bound\` above 0, \`coverage.pending\` empty, and \`canvas.lane\` is \`bridge\` or
4327
+ \`draft-route\` when the site should show structural drafts.
4282
4328
  `;
4283
4329
 
4284
4330
  // src/prompts.ts
@@ -4735,7 +4781,10 @@ function buildServer(deps) {
4735
4781
  { name: SERVER_NAME, version: SERVER_VERSION, ...SERVER_DISPLAY },
4736
4782
  {
4737
4783
  capabilities: { tools: {}, prompts: {}, resources: {} },
4738
- 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."
4784
+ // 🔴 The plain-language ask, routed — the stdio twin of the hosted connector's line
4785
+ // (src/routes/mcp/index.ts MCP_INSTRUCTIONS). "Make my site editable" has one recipe and
4786
+ // one definition of done; without this an agent converts the page it landed on and stops.
4787
+ 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."
4739
4788
  }
4740
4789
  );
4741
4790
  server.registerResource(
@@ -4752,7 +4801,10 @@ function buildServer(deps) {
4752
4801
  );
4753
4802
  registerTools(server, {
4754
4803
  auth: deps.auth,
4755
- createClient: (apiKey) => BetterCMS.management({ apiKey, baseUrl: deps.managementBaseUrl })
4804
+ // `project` is the per-call target of a workspace-wide grant; the SDK sends it as
4805
+ // X-BCMS-Project. Omitted entirely when absent so a project-scoped key's request is
4806
+ // byte-identical to before.
4807
+ createClient: (apiKey, project) => BetterCMS.management({ apiKey, baseUrl: deps.managementBaseUrl, ...project ? { project } : {} })
4756
4808
  });
4757
4809
  registerPrompts(server);
4758
4810
  return server;