webcake-landing-mcp 1.0.46 → 1.0.48

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
@@ -89,7 +89,7 @@ persists it (source-only — the page opens in the editor where re-saving render
89
89
  | **npx (local)** — runs on your machine | Personal daily use, full control | browser `login`, a JWT, or none (reference tools) |
90
90
  | **Hosted URL** — use our live server, nothing to install | No Node.js, teams, the claude.ai dialog | your personal `?jwt=` link / `x-webcake-jwt` header |
91
91
 
92
- The **reference + generation tools** (`get_generation_guide`, `list_elements`, `validate_page`, …) and the **ingest tools** (`ingest_html`, `ingest_url` — turn an existing HTML or URL into a layout anchor so the AI can recreate or adapt it) work with **zero config**; only the **persistence tools** (`create_page`, `update_page`, `add_section`, `list_pages`, `find_pages`, `get_page`, `list_organizations`) need a token. Credentials resolve in order: **per-request header → env var → saved `auth.json`** (`login`).
92
+ The **reference + generation tools** (`get_generation_guide`, `list_elements`, `validate_page`, …) and the **ingest tools** (`ingest_html`, `ingest_url` — turn an existing HTML or URL into a layout anchor so the AI can recreate or adapt it) work with **zero config**; only the **persistence tools** (`create_page`, `update_page`, `add_section`, `patch_page`, `list_pages`, `find_pages`, `get_page`, `list_organizations`) need a token. Credentials resolve in order: **per-request header → env var → saved `auth.json`** (`login`).
93
93
 
94
94
  > 🛠️ Prefer a shell-script installer (`install.sh`/`install.ps1`), a cloned local build, or hand-written per-IDE config? See **[docs/manual-install.md](docs/manual-install.md)**.
95
95
 
@@ -1,4 +1,18 @@
1
1
  [
2
+ {
3
+ "v": "1.0.48",
4
+ "d": "10/06/2026",
5
+ "type": "Added",
6
+ "en": "create_page now caches the expanded source in an in-memory draft store when validation fails and returns a draft_id alongside the validation errors,…",
7
+ "vi": "create_page nay lưu source đã expand vào bộ nhớ draft khi xác thực thất bại và trả về draft_id kèm theo danh sách lỗi, cho phép agent chỉ sửa các…"
8
+ },
9
+ {
10
+ "v": "1.0.47",
11
+ "d": "10/06/2026",
12
+ "type": "Added",
13
+ "en": "New patch_page tool edits an existing page by element id without re-sending the whole source: the agent sends per-element ops (update, replace,…",
14
+ "vi": "Công cụ patch_page mới cho phép chỉnh sửa trang hiện có theo element id mà không cần gửi lại toàn bộ source: agent gửi các op theo element (update,…"
15
+ },
2
16
  {
3
17
  "v": "1.0.46",
4
18
  "d": "09/06/2026",
@@ -26,19 +40,5 @@
26
40
  "type": "Changed",
27
41
  "en": "The GET / web guide page has refreshed copy throughout: updated page title and meta description, simplified FAQ answers in both English and…",
28
42
  "vi": "Trang hướng dẫn GET / được làm mới toàn bộ nội dung: cập nhật tiêu đề trang và meta description, đơn giản hóa câu trả lời FAQ bằng cả tiếng Anh và…"
29
- },
30
- {
31
- "v": "1.0.42",
32
- "d": "09/06/2026",
33
- "type": "Fixed",
34
- "en": "validate_page now raises an error when a countdown element's specials.language is set to a value outside the eight supported word-values (vietnam,…",
35
- "vi": "validate_page nay phát lỗi khi element countdown có specials.language là giá trị nằm ngoài tám word-value được hỗ trợ (vietnam, english, filipino,…"
36
- },
37
- {
38
- "v": "1.0.41",
39
- "d": "09/06/2026",
40
- "type": "Changed",
41
- "en": "get_generation_guide and server instructions now require the agent to write all page copy in the same language the user is chatting in, with full,…",
42
- "vi": "get_generation_guide và hướng dẫn server nay yêu cầu agent viết toàn bộ nội dung trang bằng cùng ngôn ngữ người dùng đang nhắn tin, với đầy đủ dấu…"
43
43
  }
44
44
  ]
@@ -134,7 +134,9 @@ WORKFLOW (recommended)
134
134
  6. To save: call list_organizations, show the orgs to the user and ask which to use (default to is_default). Then create_page (dry_run first, then dry_run:false with the chosen organization_id).
135
135
 
136
136
  EDITING an existing page
137
- - list_pages → let the user pick (or take a page_id from a URL).
137
+ - find_pages / list_pages → let the user pick (or take a page_id from a URL).
138
138
  - get_page(page_id) → you get the live { page, popup, settings, ... }. Edit it surgically: change only the elements the user asked for (text/styles/specials/events); keep every other element, its id, and coordinates intact. Never regenerate the whole tree for a small change.
139
- - To add an element: build it with new_element, give it a unique id, set top/left/width/height inside the right section's children.
140
- - validate_page update_page(page_id, source) (dry_run first, then dry_run:false).`;
139
+ - SMALL edit PREFER patch_page(page_id, patches): send ONLY the changed elements by id, not the whole source. Ops — {op:'update',id,specials?,styles?:{desktop?,mobile?},config?:{desktop?,mobile?},events?,properties?} (shallow-merge; op defaults to 'update'), {op:'replace',id,element}, {op:'remove',id}, {op:'add',parent_id,element}. The MCP fetches the live source, applies the ops, validates the whole tree, and saves. Reserve update_page(page_id, full source) for when you're rewriting most of the page.
140
+ - To add an element: give it a unique id + top/left/width/height, then patch_page({op:'add', parent_id:<section id>, element:<node>}).
141
+ - FIX-AFTER-ERROR (never rebuild the whole source): when create_page/update_page/add_section reports validation errors, fix ONLY the offending element ids with patch_page. If create_page failed, its error returns a draft_id (source cached) → patch_page({ draft_id, patches, dry_run:false }) fixes + creates the page (a wrong type → { op:'update', id, type:'<allowed type>' }). If update_page/add_section failed, the page has a page_id → patch_page({ page_id, patches }).
142
+ - patch_page/update_page default to dry_run=true (preview); pass dry_run:false to save.`;
@@ -13,7 +13,11 @@ RULES (follow for every request):
13
13
  - BUILD THE SOURCE IN ONE PASS — gather everything you need BEFORE assembling the source, then build the FULL tree once. BATCH the reads: when a section needs several element types (section + text-block + image-block + button + form + input), call get_element({types:[…]}) ONCE instead of one call per type — same for images, call search_images({queries:[…]}) ONCE with one query per image slot (it dedups + parallelizes and returns one best photo per query). Do NOT interleave get_element calls between create_page previews and rebuild. create_page/update_page take the entire source as input, so each call re-ships the whole page — re-previewing repeatedly wastes the request.
14
14
  - create_page and update_page DEFAULT to dry_run=true (a safety net for ambiguous requests). When the user's intent is clear AND validate_page already passed (no errors), SKIP the dry-run and call with dry_run=false directly — saves one round-trip. Use dry_run=true only when (a) the request is ambiguous about target/content, (b) the user explicitly asks to "preview" or "xem trước", (c) this is an update_page that overwrites significant existing content, or (d) you genuinely need to inspect the redacted payload. Never loop dry-runs to "check" the source — validate_page is the validator. Do not run dry-run then dry-run again before the real write.
15
15
  - LARGE PAGES (4+ sections) — build INCREMENTALLY to avoid the giant single create_page payload that can drop the connection: create_page with a SMALL skeleton (empty/near-empty page) to get a page_id, then call add_section once per section (each call ships ONLY that section; the backend appends it server-side and rejects duplicate ids — no whole-source get+put). Small pages can still go in one create_page pass.
16
- - EDIT existing pages surgically: find_pages (locate the page by name/domain/id when you don't already have a page_id) → get_page → change ONLY what was asked → keep every other element, its id, and coordinates validate_page update_page. Never regenerate the whole tree for a small change.
16
+ - EDIT existing pages surgically: find_pages (locate the page by name/domain/id when you don't already have a page_id) → get_page → change ONLY what was asked → keep every other element, its id, and coordinates. For a SMALL edit, PREFER patch_page over update_page: send only the changed elements by id (ops: update/replace/remove/add) instead of re-shipping the whole tree — the MCP fetches the live source, merges, validates and saves server-side. Use update_page only when you're rewriting most of the page. Never regenerate the whole tree for a small change.
17
+ - FIX-AFTER-ERROR (don't rebuild — this is the #1 time-waster): when create_page / update_page / add_section returns validation errors, DO NOT regenerate and re-send the whole source. Send only the fix via patch_page:
18
+ · create_page failed → its error returns a draft_id (the source is cached server-side). Call patch_page({ draft_id, patches:[…], dry_run:false }) with ops fixing ONLY the listed elements; it re-validates the merged tree and CREATES the page. A wrong element type is the most common error → { op:'update', id:'<element id>', type:'<allowed type>' } (run list_elements if unsure). The draft keeps your fixes across rounds and expires in ~30 min.
19
+ · update_page / add_section failed → the page already has a page_id → patch_page({ page_id, patches:[…] }) the offending ids.
20
+ Either way you ship a tiny diff, not the large payload that drops the connection.
17
21
  - Organizations: call list_organizations and ask which to use; default to the is_default org. Endpoints are owner-scoped (only the account's own pages).
18
22
  - REFERENCE INPUT — if the user provides a layout reference, USE it as the layout anchor (don't ignore it, don't re-invent from scratch). Three input modes: (1) IMAGE/screenshot attached in chat → analyze it natively (no tool call): identify section flow (hero/features/form/cta/footer), heading hierarchy, dominant colors, font feel, then map sections to Webcake elements. (2) HTML string → call ingest_html(html) to get a compact AST. (3) URL → call ingest_url(url) for the same AST. The AST classifies sections by role and lists headings/subheadings/ctas/images/form_fields plus brand hints (colors/fonts) — use it for LAYOUT + HIERARCHY, then generate FRESH content tailored to the user's brand (don't 1:1 copy text). intent='clone' only when the user explicitly asks to mirror the original; default intent='adapt'. The reference workflow PRESERVES craft rules above (centering, page margin, premium spacing, real images) — apply them on top of the reference layout, don't bypass them.
19
23
 
@@ -28,4 +32,4 @@ MODEL (essentials):
28
32
  - Visible content lives in specials (text, src, field_name…), never in styles. Colors as rgba(). Animation in config.animation={name,delay,duration,repeat}. Form inputs need a unique specials.field_name (use canonical keys: full_name, phone_number, email, address, quantity).
29
33
  - IMAGES: include them (hero/product, feature icons, about photo). PREFER REAL PHOTOS — call search_images with a short English subject (e.g. 'fresh coffee cup') and put a returned URL (src.large for a hero/banner, src.medium for a card/thumb) into the image-block specials.src; it works out of the box (a shared proxy supplies images). Only if search_images returns ok:false, FALL BACK to a PLACEHOLDER sized to the box: https://placehold.co/<width>x<height>. (gallery.media = array of OBJECTS {type:'image',link:'<url>',linkVideo:'',typeVideo:'youtube',imageCompression:true} — NOT plain strings, the gallery reads item.link; video.specials.img = poster). NEVER leave src empty (renders blank). Ensure text contrasts with its section background.
30
34
 
31
- Start by calling get_generation_guide. Tools: get_generation_guide, list_elements, get_element, new_element, new_page_skeleton, get_page_schema, validate_page, search_images, ingest_html, ingest_url, list_organizations, create_page, list_pages, find_pages, get_page, update_page, add_section.`;
35
+ Start by calling get_generation_guide. Tools: get_generation_guide, list_elements, get_element, new_element, new_page_skeleton, get_page_schema, validate_page, search_images, ingest_html, ingest_url, list_organizations, create_page, list_pages, find_pages, get_page, update_page, add_section, patch_page.`;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Tiny in-memory store for page sources that FAILED create_page validation, so the
3
+ * model can fix ONLY the broken elements (patch_page with a draft_id) instead of
4
+ * re-emitting the whole source. The create-before-save gap: a failed create has no
5
+ * page_id to patch against, so we hold the source here keyed by a random draft_id.
6
+ *
7
+ * Bounded + TTL'd; a lost draft (process restart, eviction, expiry) just means the
8
+ * model falls back to re-sending the full source via create_page — never a failure.
9
+ * Process-global, but draft_ids are random/unguessable AND persisting still uses the
10
+ * CALLER's own creds, so a draft only ever yields a page in the caller's account.
11
+ */
12
+ import { randomUUID } from "node:crypto";
13
+ const TTL_MS = 30 * 60 * 1000; // 30 minutes
14
+ const MAX_ENTRIES = 50;
15
+ const store = new Map();
16
+ function sweep(now) {
17
+ for (const [id, d] of store)
18
+ if (now - d.created > TTL_MS)
19
+ store.delete(id);
20
+ while (store.size > MAX_ENTRIES) {
21
+ let oldestId;
22
+ let oldestTs = Infinity;
23
+ for (const [id, d] of store) {
24
+ if (d.created < oldestTs) {
25
+ oldestTs = d.created;
26
+ oldestId = id;
27
+ }
28
+ }
29
+ if (oldestId)
30
+ store.delete(oldestId);
31
+ else
32
+ break;
33
+ }
34
+ }
35
+ /** Cache a failed source. Returns the draft_id to hand back to the model. */
36
+ export function putDraft(draft) {
37
+ const now = Date.now();
38
+ sweep(now);
39
+ const id = `draft_${randomUUID().replace(/-/g, "").slice(0, 20)}`;
40
+ store.set(id, { ...draft, created: now });
41
+ return id;
42
+ }
43
+ /** Replace a draft's source after applying patches (refreshes its TTL). */
44
+ export function updateDraft(id, source) {
45
+ const d = store.get(id);
46
+ if (d) {
47
+ d.source = source;
48
+ d.created = Date.now();
49
+ }
50
+ }
51
+ /** Fetch a live (non-expired) draft, or null if missing/expired. */
52
+ export function getDraft(id) {
53
+ sweep(Date.now());
54
+ return store.get(id) ?? null;
55
+ }
56
+ export function deleteDraft(id) {
57
+ store.delete(id);
58
+ }
@@ -13,6 +13,7 @@ import { z } from "zod";
13
13
  import { text } from "../mcp/response.js";
14
14
  import { readConfig, configFromHeaders } from "../persistence/config.js";
15
15
  import { buildRequestRedacted, buildUpdateRequestRedacted, buildAppendRequestRedacted, createPage, listOrganizations, listPages, searchPages, getPageSource, updatePageSource, appendSection, } from "../persistence/webcake-client.js";
16
+ import { putDraft, getDraft, updateDraft, deleteDraft } from "../persistence/draft-cache.js";
16
17
  export function registerPersistenceTools(server, domain) {
17
18
  // Resolve config from THIS request's headers (remote per-user JWT) first, then env.
18
19
  const cfgFor = (extra) => readConfig(configFromHeaders(extra?.requestInfo?.headers));
@@ -52,12 +53,17 @@ export function registerPersistenceTools(server, domain) {
52
53
  const expanded = domain.expand(source);
53
54
  const result = domain.validate(expanded);
54
55
  if (!result.valid) {
56
+ // Cache the failed source so the model can fix ONLY the broken elements via
57
+ // patch_page({ draft_id }) instead of regenerating + re-shipping the whole
58
+ // source (there is no page_id yet, so patch_page can't target a live page).
59
+ const draft_id = putDraft({ source: expanded, name: pageName, organization_id: orgId });
55
60
  return text({
56
61
  created: false,
57
62
  reason: "validation_failed",
58
63
  errors: result.errors,
59
64
  warnings: result.warnings,
60
- hint: "Fix the errors (run validate_page) before creating.",
65
+ draft_id,
66
+ hint: "Do NOT rebuild the whole source — it is cached as draft_id. Fix ONLY the listed elements with patch_page({ draft_id, patches:[…], dry_run:false }); it re-validates the merged tree and creates the page. A wrong element type → { op:'update', id:'<element id>', type:'<allowed type>' } (run list_elements/get_element if unsure of the exact type name). The draft expires in ~30 min.",
61
67
  });
62
68
  }
63
69
  const parsed = domain.coerce(expanded);
@@ -366,4 +372,309 @@ export function registerPersistenceTools(server, domain) {
366
372
  warnings: mergedResult.warnings,
367
373
  });
368
374
  });
375
+ // 14) Patch page (surgical element edit / fix-after-error) -------------------
376
+ // Why this exists: update_page takes the ENTIRE source as one tool argument, so
377
+ // fixing one bad element — or making a small edit — forces the model to re-emit
378
+ // the whole (often huge) page JSON, the same large payload that can drop the
379
+ // client↔Claude connection. patch_page lets the model send ONLY the diff: a list
380
+ // of per-element ops keyed by element id. The MCP fetches the live source, applies
381
+ // the ops, validates the WHOLE merged tree, and PUTs — the big merge lives on the
382
+ // robust MCP↔backend link, never in a tool argument the model has to stream.
383
+ //
384
+ // This is the fix-after-error path: when create_page/add_section/update_page
385
+ // returns validation errors, the model corrects ONLY the offending element ids via
386
+ // patch_page instead of rebuilding the source. It is also the everyday surgical-edit
387
+ // path (change one element's text/color/position without resending the tree).
388
+ //
389
+ // Ops (each keyed by element id, found anywhere in page or popup):
390
+ // { op:"update", id, specials?, styles?:{desktop?,mobile?}, config?:{desktop?,mobile?}, events?, properties? }
391
+ // — shallow-merge the given fields into the existing element (op defaults to "update").
392
+ // { op:"replace", id, element } — swap the whole node in place (compact authoring ok; keeps the id).
393
+ // { op:"remove", id } — delete the element and its subtree.
394
+ // { op:"add", parent_id, element } — append a new child element to the parent container.
395
+ // Unlike create_page's env-less preview, patch_page MUST read the live page, so it
396
+ // needs creds even on dry_run; dry_run only gates the final write.
397
+ const asArray = (input) => {
398
+ let v = input;
399
+ if (typeof v === "string") {
400
+ try {
401
+ v = JSON.parse(v);
402
+ }
403
+ catch {
404
+ /* not JSON — wrap as single */
405
+ }
406
+ }
407
+ return Array.isArray(v) ? v : [v];
408
+ };
409
+ // Find a node by id within the real array refs (so remove/add mutate the live tree),
410
+ // recursing into children; returns the node, its parent array, and index.
411
+ const findById = (arr, id) => {
412
+ for (let i = 0; i < arr.length; i++) {
413
+ const n = arr[i];
414
+ if (!n || typeof n !== "object")
415
+ continue;
416
+ if (n.id === id)
417
+ return { node: n, parentArr: arr, index: i };
418
+ if (Array.isArray(n.children)) {
419
+ const found = findById(n.children, id);
420
+ if (found)
421
+ return found;
422
+ }
423
+ }
424
+ return null;
425
+ };
426
+ const mergeStyleMap = (node, kind, byBp) => {
427
+ const touched = [];
428
+ for (const bp of ["desktop", "mobile"]) {
429
+ const patch = byBp?.[bp];
430
+ if (!patch || typeof patch !== "object")
431
+ continue;
432
+ node.responsive = node.responsive ?? {};
433
+ node.responsive[bp] = node.responsive[bp] ?? { config: {}, styles: {} };
434
+ node.responsive[bp][kind] = { ...(node.responsive[bp][kind] ?? {}), ...patch };
435
+ touched.push(`${bp}.${kind}`);
436
+ }
437
+ return touched;
438
+ };
439
+ server.tool("patch_page", "Edits a page by element id WITHOUT re-sending the whole source — the surgical-edit and fix-after-error path. Targets EITHER a live page (page_id) OR a cached failed-create source (draft_id, returned by a create_page that failed validation). Send only a list of per-element ops; the MCP loads the source, applies them, validates the WHOLE merged tree (blocks on errors), and saves (update for page_id; create for draft_id). Ops: {op:'update',id,type?,specials?,styles?:{desktop?,mobile?},config?:{desktop?,mobile?},events?,properties?} (shallow-merges; op defaults to 'update'; `type` fixes a wrong element type), {op:'replace',id,element}, {op:'remove',id}, {op:'add',parent_id,element}. Use this to fix the elements a failed create_page/update_page/add_section reported (e.g. a bad type → {op:'update',id,type:'button'}) instead of rebuilding the page. DEFAULTS to dry_run=true (loads + merges + validates + previews, no write); dry_run=false to save. Needs WEBCAKE_API_BASE + WEBCAKE_JWT (a draft_id patch only needs creds to actually create; a page_id patch reads the live page so needs creds even on dry_run).", {
440
+ page_id: z.string().optional().describe("Edit a LIVE page by id (from create_page, list_pages, or find_pages; must be owned by the account). Provide page_id OR draft_id."),
441
+ draft_id: z.string().optional().describe("Fix a CACHED source from a create_page that failed validation (the create_page error returns draft_id). The patched tree is created as a new page once valid. Provide page_id OR draft_id."),
442
+ patches: z
443
+ .any()
444
+ .describe("One op object or an array of them (object/array or JSON string). Each targets an element by id: {op:'update',id,type?,specials?,styles?:{desktop?,mobile?},config?:{desktop?,mobile?},events?,properties?} merges fields into the element (op may be omitted; set `type` to fix a wrong element type); {op:'replace',id,element} swaps the node; {op:'remove',id} deletes it; {op:'add',parent_id,element} appends a child to a container."),
445
+ dry_run: z
446
+ .boolean()
447
+ .optional()
448
+ .describe("Default TRUE — load, merge, validate and preview the resulting save WITHOUT writing. Set false to actually save."),
449
+ }, { title: "Patch Webcake Page (by element id)", readOnlyHint: false, destructiveHint: true, openWorldHint: true }, async ({ page_id, draft_id, patches, dry_run }, extra) => {
450
+ const isDry = dry_run !== false; // default true (safe)
451
+ const ops = asArray(patches).filter((p) => p != null && typeof p === "object");
452
+ if (ops.length === 0) {
453
+ return text({ patched: false, reason: "no_patches", hint: "Pass an op object or a non-empty array of { op, id, … } ops." });
454
+ }
455
+ if (!page_id && !draft_id) {
456
+ return text({ patched: false, reason: "no_target", hint: "Pass page_id (a live page) or draft_id (the cached source from a failed create_page)." });
457
+ }
458
+ const { config, missing } = cfgFor(extra);
459
+ // Resolve the base source: a cached draft (create-before-save), else a live page.
460
+ let base;
461
+ const draft = draft_id ? getDraft(draft_id) : null;
462
+ if (draft_id) {
463
+ if (!draft) {
464
+ return text({
465
+ patched: false,
466
+ reason: "draft_expired",
467
+ hint: "The cached draft is gone (expired after ~30 min or evicted). Re-send the full source via create_page.",
468
+ });
469
+ }
470
+ base = draft.source; // already an expanded full tree
471
+ }
472
+ else {
473
+ if (!config) {
474
+ return text({
475
+ patched: false,
476
+ reason: "missing_env",
477
+ missing_env: missing,
478
+ hint: "Configure WEBCAKE_API_BASE and WEBCAKE_JWT (env), or send the x-webcake-jwt header (remote), then retry.",
479
+ });
480
+ }
481
+ const current = await getPageSource(config, page_id);
482
+ if (!current.ok || current.source == null) {
483
+ return text({
484
+ patched: false,
485
+ reason: "fetch_failed",
486
+ status: current.status,
487
+ error: current.error ?? "Page source not found.",
488
+ hint: "Check the page_id (find_pages/list_pages) and that the account owns it.",
489
+ });
490
+ }
491
+ base = current.source;
492
+ if (typeof base === "string") {
493
+ try {
494
+ base = JSON.parse(base);
495
+ }
496
+ catch {
497
+ return text({ patched: false, reason: "bad_source", hint: "The stored page source could not be parsed." });
498
+ }
499
+ }
500
+ }
501
+ const treeRoots = [base.page, base.popup].filter((a) => Array.isArray(a));
502
+ const locate = (id) => {
503
+ for (const r of treeRoots) {
504
+ const hit = findById(r, id);
505
+ if (hit)
506
+ return hit;
507
+ }
508
+ return null;
509
+ };
510
+ // Apply every op against the live tree. A missing target aborts the whole
511
+ // patch (we never write a partial edit).
512
+ const applied = [];
513
+ const notFound = [];
514
+ const badOps = [];
515
+ for (const p of ops) {
516
+ const op = p.op ?? "update";
517
+ if (op === "add") {
518
+ const pid = p.parent_id ?? p.id;
519
+ if (typeof pid !== "string" || p.element == null) {
520
+ badOps.push(`add needs parent_id + element`);
521
+ continue;
522
+ }
523
+ const hit = locate(pid);
524
+ if (!hit) {
525
+ notFound.push({ op, id: pid });
526
+ continue;
527
+ }
528
+ hit.node.children = Array.isArray(hit.node.children) ? hit.node.children : [];
529
+ hit.node.children.push(p.element);
530
+ applied.push({ op, parent_id: pid, added_id: p.element?.id });
531
+ continue;
532
+ }
533
+ if (typeof p.id !== "string") {
534
+ badOps.push(`${op} needs a string id`);
535
+ continue;
536
+ }
537
+ const hit = locate(p.id);
538
+ if (!hit) {
539
+ notFound.push({ op, id: p.id });
540
+ continue;
541
+ }
542
+ if (op === "remove") {
543
+ hit.parentArr.splice(hit.index, 1);
544
+ applied.push({ op, id: p.id });
545
+ }
546
+ else if (op === "replace") {
547
+ if (p.element == null) {
548
+ badOps.push(`replace ${p.id} needs element`);
549
+ continue;
550
+ }
551
+ const repl = p.element;
552
+ if (repl && typeof repl === "object" && repl.id == null)
553
+ repl.id = p.id;
554
+ hit.parentArr[hit.index] = repl;
555
+ applied.push({ op, id: p.id });
556
+ }
557
+ else {
558
+ // update (default)
559
+ const changed = [];
560
+ if (typeof p.type === "string" && p.type.trim() !== "") {
561
+ hit.node.type = p.type;
562
+ changed.push("type");
563
+ }
564
+ if (p.specials && typeof p.specials === "object") {
565
+ hit.node.specials = { ...(hit.node.specials ?? {}), ...p.specials };
566
+ changed.push("specials");
567
+ }
568
+ changed.push(...mergeStyleMap(hit.node, "styles", p.styles));
569
+ changed.push(...mergeStyleMap(hit.node, "config", p.config));
570
+ if (Array.isArray(p.events)) {
571
+ hit.node.events = p.events;
572
+ changed.push("events");
573
+ }
574
+ if (p.properties && typeof p.properties === "object") {
575
+ hit.node.properties = { ...(hit.node.properties ?? {}), ...p.properties };
576
+ changed.push("properties");
577
+ }
578
+ applied.push({ op: "update", id: p.id, changed });
579
+ }
580
+ }
581
+ if (badOps.length > 0) {
582
+ return text({ patched: false, reason: "bad_ops", bad_ops: badOps, hint: "Each op needs id (or parent_id for add) and an element where required." });
583
+ }
584
+ if (notFound.length > 0) {
585
+ return text({
586
+ patched: false,
587
+ reason: "target_not_found",
588
+ not_found: notFound,
589
+ hint: "No element with that id exists on the live page. Run get_page to see the current ids; ids are case-sensitive.",
590
+ });
591
+ }
592
+ // Validate the WHOLE merged tree (hydrate sparse replaced/added nodes first).
593
+ const expanded = domain.expand(base);
594
+ const result = domain.validate(expanded);
595
+ // DRAFT path: the source came from a failed create_page. Keep the applied fixes
596
+ // cached between rounds; once valid, CREATE the page (no page_id yet).
597
+ if (draft_id && draft) {
598
+ if (!result.valid) {
599
+ updateDraft(draft_id, base); // persist the partial fixes for the next patch round
600
+ return text({
601
+ patched: false,
602
+ reason: "validation_failed",
603
+ errors: result.errors,
604
+ warnings: result.warnings,
605
+ patches_applied: applied,
606
+ draft_id,
607
+ hint: "Still invalid — fix the remaining errors with another patch_page({ draft_id, … }). Your applied fixes are kept in the draft.",
608
+ });
609
+ }
610
+ const parsed = domain.coerce(expanded);
611
+ if (isDry) {
612
+ updateDraft(draft_id, base);
613
+ return text({
614
+ dry_run: true,
615
+ draft_id,
616
+ patches_applied: applied,
617
+ validation: { valid: true, warnings: result.warnings, stats: result.stats },
618
+ env_ready: missing.length === 0,
619
+ missing_env: missing,
620
+ request: config
621
+ ? buildRequestRedacted(config, draft.name ?? "AI Page", parsed, draft.organization_id)
622
+ : { note: "Set WEBCAKE_API_BASE + WEBCAKE_JWT (env) or send the x-webcake-jwt header to enable creation." },
623
+ hint: "Draft is now valid. Re-run with dry_run=false to create the page from it.",
624
+ });
625
+ }
626
+ if (!config) {
627
+ updateDraft(draft_id, base); // keep the now-valid draft so a creds-ready retry can persist it
628
+ return text({ patched: false, reason: "missing_env", missing_env: missing, hint: "Add WEBCAKE_API_BASE + WEBCAKE_JWT, then retry patch_page({ draft_id, dry_run:false })." });
629
+ }
630
+ const outcome = await createPage(config, draft.name ?? "AI Page", parsed, draft.organization_id);
631
+ if (outcome.ok)
632
+ deleteDraft(draft_id); // created — drop the draft
633
+ return text({
634
+ patched: outcome.ok,
635
+ created: outcome.ok,
636
+ from_draft: draft_id,
637
+ patches_applied: applied,
638
+ page_id: outcome.page_id,
639
+ editor_url: outcome.editor_url,
640
+ preview_url: outcome.preview_url,
641
+ status: outcome.status,
642
+ error: outcome.error,
643
+ warnings: result.warnings,
644
+ });
645
+ }
646
+ // LIVE-PAGE path: edit an existing page (page_id) and update it in place.
647
+ if (!result.valid) {
648
+ return text({
649
+ patched: false,
650
+ reason: "validation_failed",
651
+ errors: result.errors,
652
+ warnings: result.warnings,
653
+ patches_applied: applied,
654
+ hint: "The edit produced an invalid tree — fix the listed errors in your ops, then retry.",
655
+ });
656
+ }
657
+ const parsed = domain.coerce(expanded);
658
+ if (isDry) {
659
+ return text({
660
+ dry_run: true,
661
+ page_id,
662
+ patches_applied: applied,
663
+ validation: { valid: true, warnings: result.warnings, stats: result.stats },
664
+ request: buildUpdateRequestRedacted(config, page_id, parsed),
665
+ hint: "Re-run with dry_run=false to actually save the edit.",
666
+ });
667
+ }
668
+ const outcome = await updatePageSource(config, page_id, parsed);
669
+ return text({
670
+ patched: outcome.ok,
671
+ patches_applied: applied,
672
+ page_id: outcome.page_id,
673
+ editor_url: outcome.editor_url,
674
+ preview_url: outcome.preview_url,
675
+ status: outcome.status,
676
+ error: outcome.error,
677
+ warnings: result.warnings,
678
+ });
679
+ });
369
680
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webcake-landing-mcp",
3
- "version": "1.0.46",
3
+ "version": "1.0.48",
4
4
  "description": "MCP server exposing Webcake landing-page element schemas + AI usage hints, and persisting LLM-generated page sources to a Webcake backend.",
5
5
  "mcpName": "io.github.vuluu2k/webcake-landing-mcp",
6
6
  "type": "module",