sbuilder-mcp 0.2.0 → 0.2.2

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.
@@ -1,9 +1,10 @@
1
1
  import { z } from 'zod';
2
+ import { composeWarnings } from '../domains/site/findings.js';
2
3
  import { text } from '../mcp/response.js';
3
4
  import { loadSource, saveSource } from '../transport/pages.js';
4
5
  import { PageDoc } from '../domains/site/document.js';
5
6
  import { addSubtree, setMany, moveNode, removeNode, duplicateNode, } from '../domains/site/builder.js';
6
- import { request } from '../transport/http.js';
7
+ import { request, redact } from '../transport/http.js';
7
8
  import { siteToken } from './credentialpick.js';
8
9
  import { validateForSave } from '../domains/site/validate.js';
9
10
  import { reviewDesign, REVIEW_NOTICE } from '../domains/site/review.js';
@@ -42,6 +43,7 @@ export class PageSession {
42
43
  pageId = '';
43
44
  live = null;
44
45
  stale = null;
46
+ warnings = [];
45
47
  boxes = [];
46
48
  constructor(ctx) {
47
49
  this.ctx = ctx;
@@ -90,8 +92,15 @@ export class PageSession {
90
92
  this.doc = PageDoc.from(src.document);
91
93
  this.siteId = siteId;
92
94
  this.pageId = pageId;
95
+ // The platform's own account of what it could not compose. Typed on the
96
+ // response since the transport was written and read by nothing until now.
97
+ this.warnings = composeWarnings(src.warnings);
93
98
  return this.doc.outline();
94
99
  }
100
+ /** What the server said it could not compose when this page was opened. */
101
+ composeWarnings() {
102
+ return this.warnings;
103
+ }
95
104
  current() {
96
105
  if (!this.doc)
97
106
  throw new Error('sbuilder: no page is open — call sb_page_open first');
@@ -151,9 +160,11 @@ export function registerPageTools(server, ctx) {
151
160
  'the renderer finds no root and publishes an EMPTY BODY. The next save from here writes ' +
152
161
  'the canonical key and fixes it; publish afterwards.'
153
162
  : undefined;
163
+ const warnings = session.composeWarnings();
154
164
  return text({
155
165
  outline,
156
166
  ...(blank_page_repair ? { blank_page_repair } : {}),
167
+ ...(warnings.length ? { compose_warnings: warnings } : {}),
157
168
  ...reviewField(ctx, doc),
158
169
  });
159
170
  });
@@ -423,9 +434,12 @@ export function registerPageTools(server, ctx) {
423
434
  ...(is_homepage !== undefined ? { isHomepage: is_homepage } : {}),
424
435
  ...(settings ? { settings } : {}),
425
436
  };
437
+ // `settings` is the one free-form object a caller hands this server, so the
438
+ // preview and the echo both go through redact — everything else on this
439
+ // path is built from narrow arguments.
426
440
  if (dry_run !== false)
427
- return text({ dry_run: true, would_post: path, body });
428
- return text(await request({
441
+ return text({ dry_run: true, would_post: path, body: redact(body) });
442
+ const res = redact(await request({
429
443
  base: ctx.base,
430
444
  method: 'POST',
431
445
  path,
@@ -433,6 +447,24 @@ export function registerPageTools(server, ctx) {
433
447
  body,
434
448
  fetchImpl: ctx.fetchImpl,
435
449
  }));
450
+ // A COLLIDING SLUG IS RENAMED, NOT REFUSED. `uniqueSlug` suffixes -1, -2 …
451
+ // and its own comment says it "never errors"
452
+ // (server/internal/page/service.go:877). ErrSlugConflict exists and maps to
453
+ // 409; this path never reaches it. So the create answers 200 carrying a
454
+ // DIFFERENT slug than the one asked for, and every link the caller then
455
+ // authors to the slug it requested is dead.
456
+ const got = res.page?.slug;
457
+ const renamed = slug && typeof got === 'string' && got !== slug;
458
+ return text({
459
+ ...res,
460
+ ...(renamed
461
+ ? {
462
+ slug_renamed: `The slug "${slug}" was already taken, so the platform stored ` +
463
+ `"${got}" instead and reported success. Link to "${got}", or free the name and ` +
464
+ 'create it again.',
465
+ }
466
+ : {}),
467
+ });
436
468
  });
437
469
  server.registerTool('sb_publish', {
438
470
  description: 'Compile the draft into the live page. PUBLISH CASCADES: a page sharing a global ' +
@@ -451,7 +483,7 @@ export function registerPageTools(server, ctx) {
451
483
  const body = { pageIds: [page_id] };
452
484
  if (dry_run !== false)
453
485
  return text({ dry_run: true, would_post: path, body });
454
- return text(await request({
486
+ const res = (await request({
455
487
  base: ctx.base,
456
488
  method: 'POST',
457
489
  path,
@@ -459,6 +491,31 @@ export function registerPageTools(server, ctx) {
459
491
  body,
460
492
  fetchImpl: ctx.fetchImpl,
461
493
  }));
494
+ // A PUBLISHED ROW CARRIES THE WHOLE RENDERED PAGE — document, html and css
495
+ // — and publish CASCADES, so returning the response as it arrives pours
496
+ // every republished page's markup into the reader. Kept: what identifies
497
+ // the row and what a caller would act on.
498
+ const published = (res.published ?? []).map((p) => ({
499
+ pageId: p.pageId,
500
+ ...(p.slug !== undefined ? { slug: p.slug } : {}),
501
+ ...(p.isHomepage ? { isHomepage: true } : {}),
502
+ }));
503
+ // PUBLISH SKIPS A PAGE WITH NO SAVED DRAFT and still answers 200 with
504
+ // whatever did publish (`server/internal/page/service.go:650`, a bare
505
+ // `continue`). sb_page_create followed by sb_publish does exactly that:
506
+ // the call succeeds, the page never flips to published, and the URL 404s.
507
+ const landed = published.some((p) => p.pageId === page_id);
508
+ return text({
509
+ published,
510
+ ...(published.length !== (res.total ?? published.length) ? { total: res.total } : {}),
511
+ ...(landed
512
+ ? {}
513
+ : {
514
+ not_published: `Page ${page_id} has no saved draft, so the platform published ` +
515
+ 'nothing for it and reported success anyway. Open it with sb_page_open, save an ' +
516
+ 'edit, then publish again.',
517
+ }),
518
+ });
462
519
  });
463
520
  return session;
464
521
  }
@@ -34,8 +34,10 @@ const SECRET_KEYS = /^(authorization|token|access_?token|refresh_?token|password
34
34
  /**
35
35
  * Replace credential-shaped values with a marker, recursively.
36
36
  *
37
- * Every dry-run preview goes through this, so it is the only thing standing
38
- * between a `dry_run` result and a bearer token sitting in a transcript. It keys
37
+ * Every preview that can carry a FREE-FORM object goes through this — `sb_api_call`'s
38
+ * body and `sb_page_create`'s `settings`, the two places a caller supplies a shape
39
+ * this server does not type. The rest of the dry-run previews echo patch counts or
40
+ * bodies built from narrow arguments, which cannot hold a credential. It keys
39
41
  * off the FIELD NAME rather than the value's shape on purpose: a token format
40
42
  * can change tomorrow, while the field name is what this repo controls.
41
43
  */
@@ -66,18 +66,27 @@ export async function uploadMedia(ctx, siteId, source) {
66
66
  throw new ApiError(res.status, 'non_json_response', raw.slice(0, 400));
67
67
  }
68
68
  if (!res.ok) {
69
- // THE UPLOAD SURFACE TAKES A SESSION ONLY.
69
+ // AN API KEY CAN UPLOAD — THE PLATFORM WIDENED THIS.
70
70
  //
71
- // `/api/media/{siteId}` is mounted behind `RequireAuth` — not the
72
- // `RequireAuthOrDefer` that lets a `wbk_` key open `/api/sites`
73
- // (server/internal/server/router.go). So a key-only install, which is the
74
- // one the store's Agent app hands out and the one the README recommends,
75
- // gets a bare "unauthorized" from the ONE tool that cannot be replaced by
76
- // sb_api_call, because the body is multipart. Found on a live run.
71
+ // `/api/media` is now mounted behind `RequireAuthOrDefer`
72
+ // (server/internal/server/router.go:2821), the same gate `/api/sites` uses,
73
+ // and `upload_agentkey_test.go` pins the three answers that make it safe:
74
+ // the key's own site only, the same permission the session path checks, and
75
+ // no key at all still meaning 401. The platform's own comment gives the
76
+ // reason it changed — the media LIBRARY already took a key while the UPLOAD
77
+ // refused one, so a merchant could hand an agent a key that manages every
78
+ // image the store has and cannot add one.
79
+ //
80
+ // This message used to say "an API key cannot upload" and send the caller to
81
+ // set SB_EMAIL. That is now the wrong instruction: with a key present, a 401
82
+ // here means the KEY is wrong for this call, not that the wrong KIND of
83
+ // credential was used, and the old text sent people to fix something that
84
+ // was never broken.
77
85
  if ((res.status === 401 || res.status === 403) && ctx.apiKey && !ctx.session.loggedIn()) {
78
- throw new ApiError(res.status, 'media_needs_session', 'sbuilder: the media upload endpoint takes a session token only — an API key cannot ' +
79
- 'upload. Set SB_EMAIL and SB_PASSWORD and call sb_connect, then retry. Every other ' +
80
- 'tool works with the key alone.');
86
+ throw new ApiError(res.status, 'media_key_refused', 'sbuilder: the platform refused this API key for the upload. It accepts a key, so the ' +
87
+ 'cause is the key itself: it needs the media permission, and it must belong to THIS ' +
88
+ 'site — a key minted for another site is refused before the upload is read. Check the ' +
89
+ "key's scopes and its site, or set SB_EMAIL / SB_PASSWORD to upload as a person.");
81
90
  }
82
91
  const env = (parsed ?? {});
83
92
  const fieldText = env.fields
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sbuilder-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "MCP server that designs and operates a Store Builder site — pages, data, theme and publish — through the platform's own API and live-edit protocol.",
5
5
  "mcpName": "io.github.vuluu2k/sbuilder-mcp",
6
6
  "type": "module",