webcake-storefront-mcp 1.0.1
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 +1166 -0
- package/dist/api.js +346 -0
- package/dist/auth/login.js +87 -0
- package/dist/builder/catalog.js +186 -0
- package/dist/builder/factory.js +1677 -0
- package/dist/builder/guide.js +64 -0
- package/dist/builder/page.js +149 -0
- package/dist/config.js +97 -0
- package/dist/db.js +96 -0
- package/dist/guides.js +93 -0
- package/dist/http.js +120 -0
- package/dist/index.js +73 -0
- package/dist/install.js +140 -0
- package/dist/mongo.js +102 -0
- package/dist/server.js +63 -0
- package/dist/smoke.js +81 -0
- package/dist/tools/apps.js +7 -0
- package/dist/tools/articles.js +53 -0
- package/dist/tools/automation.js +8 -0
- package/dist/tools/builder-extras.js +165 -0
- package/dist/tools/builder.js +124 -0
- package/dist/tools/cms-files.js +255 -0
- package/dist/tools/collections.js +31 -0
- package/dist/tools/combos.js +72 -0
- package/dist/tools/context.js +158 -0
- package/dist/tools/customers.js +13 -0
- package/dist/tools/global-sources.js +662 -0
- package/dist/tools/images.js +875 -0
- package/dist/tools/orders.js +32 -0
- package/dist/tools/pages.js +621 -0
- package/dist/tools/products.js +38 -0
- package/dist/tools/promotions.js +131 -0
- package/dist/tools/site-style.js +157 -0
- package/package.json +38 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { resolvePreviewUrl } from "../config.js";
|
|
3
|
+
import { parse as parseHtml } from "node-html-parser";
|
|
4
|
+
const ALLOWED_IMG = /^image\/(jpe?g|png|webp)$/;
|
|
5
|
+
/** Fetch a URL into a Buffer with a size cap. */
|
|
6
|
+
async function fetchBuffer(url, maxBytes = 15 * 1024 * 1024) {
|
|
7
|
+
const res = await fetch(url, { redirect: "follow" });
|
|
8
|
+
if (!res.ok)
|
|
9
|
+
throw new Error(`Fetch failed (${res.status}) for ${url}`);
|
|
10
|
+
const ct = (res.headers.get("content-type") || "").split(";")[0].trim();
|
|
11
|
+
const ab = await res.arrayBuffer();
|
|
12
|
+
if (ab.byteLength > maxBytes)
|
|
13
|
+
throw new Error(`Image too large (${ab.byteLength} bytes, max ${maxBytes}).`);
|
|
14
|
+
return { buf: Buffer.from(ab), contentType: ct };
|
|
15
|
+
}
|
|
16
|
+
/** Ensure a buffer is jpeg/png/webp (the only types the CDN base64 endpoint accepts). */
|
|
17
|
+
async function toAllowedImage(buf, contentType) {
|
|
18
|
+
if (ALLOWED_IMG.test(contentType))
|
|
19
|
+
return { buf, contentType };
|
|
20
|
+
const sharp = (await import("sharp")).default;
|
|
21
|
+
const out = await sharp(buf).jpeg({ quality: 85 }).toBuffer();
|
|
22
|
+
return { buf: out, contentType: "image/jpeg" };
|
|
23
|
+
}
|
|
24
|
+
export function registerBuilderExtraTools(server, api, handle) {
|
|
25
|
+
// ── Stock images (Pexels) ──────────────────────────────────────────────────
|
|
26
|
+
server.tool("search_images", `Search stock photos (Pexels) to use on a page. Returns hosted image URLs you can put straight into an image element's runtime.config.src.
|
|
27
|
+
Requires the PEXELS_API_KEY environment variable.`, {
|
|
28
|
+
query: z.string().describe("Subject to search, e.g. 'coffee shop interior'"),
|
|
29
|
+
per_page: z.number().min(1).max(30).default(6).describe("How many results (default 6)"),
|
|
30
|
+
orientation: z.enum(["landscape", "portrait", "square"]).optional().describe("Preferred orientation"),
|
|
31
|
+
}, ({ query, per_page, orientation }) => handle(async () => {
|
|
32
|
+
const key = process.env.PEXELS_API_KEY;
|
|
33
|
+
if (!key)
|
|
34
|
+
return { error: "PEXELS_API_KEY env var is not set. Add it to use stock image search." };
|
|
35
|
+
const url = new URL("https://api.pexels.com/v1/search");
|
|
36
|
+
url.searchParams.set("query", query);
|
|
37
|
+
url.searchParams.set("per_page", String(per_page));
|
|
38
|
+
if (orientation)
|
|
39
|
+
url.searchParams.set("orientation", orientation);
|
|
40
|
+
const res = await fetch(url, { headers: { Authorization: key } });
|
|
41
|
+
if (!res.ok)
|
|
42
|
+
return { error: `Pexels error ${res.status}` };
|
|
43
|
+
const json = await res.json();
|
|
44
|
+
const photos = (json.photos || []).map((p) => ({
|
|
45
|
+
url: p.src && (p.src.large || p.src.original),
|
|
46
|
+
thumbnail: p.src && p.src.medium,
|
|
47
|
+
width: p.width,
|
|
48
|
+
height: p.height,
|
|
49
|
+
alt: p.alt || "",
|
|
50
|
+
credit: p.photographer,
|
|
51
|
+
source: p.url,
|
|
52
|
+
}));
|
|
53
|
+
return { query, total_results: json.total_results, photos };
|
|
54
|
+
}));
|
|
55
|
+
// ── Upload an image to the site CDN ─────────────────────────────────────────
|
|
56
|
+
server.tool("upload_image", `Upload an image to the site's CDN and get back a hosted URL. Accepts an http(s) URL or a data:image/...;base64 data URI. Non jpeg/png/webp inputs are converted to JPEG.
|
|
57
|
+
Use this for the user's own images; stock photos from search_images are already hosted and don't need uploading.`, {
|
|
58
|
+
url: z.string().describe("http(s) URL or data:image/...;base64,... data URI"),
|
|
59
|
+
}, ({ url }) => handle(async () => {
|
|
60
|
+
let buf, contentType;
|
|
61
|
+
if (url.startsWith("data:")) {
|
|
62
|
+
const m = url.match(/^data:([^;]+);base64,(.*)$/s);
|
|
63
|
+
if (!m)
|
|
64
|
+
return { error: "Malformed data URI." };
|
|
65
|
+
contentType = m[1];
|
|
66
|
+
buf = Buffer.from(m[2], "base64");
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
({ buf, contentType } = await fetchBuffer(url));
|
|
70
|
+
}
|
|
71
|
+
const norm = await toAllowedImage(buf, contentType);
|
|
72
|
+
const dataUri = `data:${norm.contentType};base64,${norm.buf.toString("base64")}`;
|
|
73
|
+
const res = await api.uploadImageBase64({ base64: dataUri, content_type: norm.contentType });
|
|
74
|
+
const hosted = (res && res.data) || (res && res.url) || null;
|
|
75
|
+
if (!hosted)
|
|
76
|
+
return { error: "Upload returned no URL.", raw: res };
|
|
77
|
+
return { success: true, url: hosted, content_type: norm.contentType };
|
|
78
|
+
}));
|
|
79
|
+
// ── Publish the site ────────────────────────────────────────────────────────
|
|
80
|
+
server.tool("publish_site", `Publish the whole site live — snapshots all current page sources into the live (published) version.
|
|
81
|
+
Note: BuilderX publishes at the SITE level, not per page; publishing makes every saved page go live.
|
|
82
|
+
Two-step safety: dry_run=true (default) describes what will happen; dry_run=false actually publishes.`, {
|
|
83
|
+
dry_run: z.boolean().default(true).describe("Preview (true) or publish for real (false)"),
|
|
84
|
+
}, ({ dry_run }) => handle(async () => {
|
|
85
|
+
const preview_url = await resolvePreviewUrl(api);
|
|
86
|
+
if (dry_run) {
|
|
87
|
+
return {
|
|
88
|
+
dry_run: true,
|
|
89
|
+
preview_url,
|
|
90
|
+
note: "This will publish ALL saved pages of the site live. Call again with dry_run=false to publish.",
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
const res = await api.publishSite({ is_publish: true });
|
|
94
|
+
const data = (res && res.data) || res;
|
|
95
|
+
return { success: true, published_at: data && data.published_at, site: data && data.name, preview_url };
|
|
96
|
+
}));
|
|
97
|
+
// ── Ingest reference HTML / URL into a blueprint ────────────────────────────
|
|
98
|
+
function blueprintFromHtml(html) {
|
|
99
|
+
const root = parseHtml(html, { blockTextElements: { script: false, style: false } });
|
|
100
|
+
const text = (el) => (el ? el.text.replace(/\s+/g, " ").trim() : "");
|
|
101
|
+
const attr = (sel, name) => {
|
|
102
|
+
const el = root.querySelector(sel);
|
|
103
|
+
return el ? el.getAttribute(name) : undefined;
|
|
104
|
+
};
|
|
105
|
+
const title = text(root.querySelector("title")) || attr('meta[property="og:title"]', "content");
|
|
106
|
+
const description = attr('meta[name="description"]', "content") || attr('meta[property="og:description"]', "content");
|
|
107
|
+
const ogImage = attr('meta[property="og:image"]', "content");
|
|
108
|
+
const headings = root
|
|
109
|
+
.querySelectorAll("h1, h2, h3")
|
|
110
|
+
.map((h) => ({ level: h.tagName.toLowerCase(), text: text(h) }))
|
|
111
|
+
.filter((h) => h.text)
|
|
112
|
+
.slice(0, 40);
|
|
113
|
+
const paragraphs = root
|
|
114
|
+
.querySelectorAll("p")
|
|
115
|
+
.map((p) => text(p))
|
|
116
|
+
.filter((t) => t.length > 20)
|
|
117
|
+
.slice(0, 40);
|
|
118
|
+
const images = root
|
|
119
|
+
.querySelectorAll("img")
|
|
120
|
+
.map((img) => ({ src: img.getAttribute("src"), alt: img.getAttribute("alt") || "" }))
|
|
121
|
+
.filter((i) => i.src && /^https?:\/\//.test(i.src))
|
|
122
|
+
.slice(0, 40);
|
|
123
|
+
const buttons = root
|
|
124
|
+
.querySelectorAll("a, button")
|
|
125
|
+
.map((b) => ({ text: text(b), href: b.getAttribute("href") || null }))
|
|
126
|
+
.filter((b) => b.text && b.text.length < 40)
|
|
127
|
+
.slice(0, 30);
|
|
128
|
+
// Collect colours mentioned in inline styles (rough palette signal).
|
|
129
|
+
const colors = new Set();
|
|
130
|
+
for (const el of root.querySelectorAll("[style]")) {
|
|
131
|
+
const style = el.getAttribute("style") || "";
|
|
132
|
+
for (const m of style.matchAll(/#[0-9a-fA-F]{3,8}\b|rgba?\([^)]+\)/g))
|
|
133
|
+
colors.add(m[0]);
|
|
134
|
+
if (colors.size > 24)
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
title,
|
|
139
|
+
description,
|
|
140
|
+
og_image: ogImage,
|
|
141
|
+
headings,
|
|
142
|
+
paragraphs,
|
|
143
|
+
images,
|
|
144
|
+
buttons,
|
|
145
|
+
palette: [...colors].slice(0, 24),
|
|
146
|
+
hint: "Rebuild this as BuilderX sections: map each heading group + its text/image/button into a new_section call. Generate fresh copy where useful; re-host external images with upload_image if you want them on the site CDN. This is a structural blueprint, not a 1:1 clone.",
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
server.tool("ingest_html", "Parse reference HTML into a structural blueprint (title, headings, paragraphs, images, buttons, colour palette) you can rebuild as BuilderX sections with new_section. Not a 1:1 clone.", {
|
|
150
|
+
html: z.string().describe("Raw HTML to analyse"),
|
|
151
|
+
}, ({ html }) => handle(async () => blueprintFromHtml(html)));
|
|
152
|
+
server.tool("ingest_url", "Fetch a public URL and parse it into a structural blueprint (see ingest_html). Note: client-rendered (React/Vue) pages may return little content.", {
|
|
153
|
+
url: z.string().describe("Public page URL to analyse"),
|
|
154
|
+
}, ({ url }) => handle(async () => {
|
|
155
|
+
const res = await fetch(url, { redirect: "follow", headers: { "User-Agent": "webcake-storefront-mcp" } });
|
|
156
|
+
if (!res.ok)
|
|
157
|
+
return { error: `Fetch failed (${res.status}).` };
|
|
158
|
+
const html = await res.text();
|
|
159
|
+
const blueprint = blueprintFromHtml(html);
|
|
160
|
+
if (!blueprint.headings.length && !blueprint.paragraphs.length) {
|
|
161
|
+
blueprint.warning = "Little text found — the page may be client-rendered (JS). Consider ingest_html with rendered source.";
|
|
162
|
+
}
|
|
163
|
+
return { url, status: res.status, ...blueprint };
|
|
164
|
+
}));
|
|
165
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { BUILD_GUIDE } from "../builder/guide.js";
|
|
3
|
+
import { listElements, getElement, buildElement } from "../builder/catalog.js";
|
|
4
|
+
import { buildSection, newPageSkeleton, validatePage, reassignIds, } from "../builder/page.js";
|
|
5
|
+
// Recursive spec for new_section / build_page children.
|
|
6
|
+
const elementSpec = z.object({
|
|
7
|
+
type: z.string().describe("Element type (see list_elements)"),
|
|
8
|
+
opts: z.record(z.any()).optional().describe("Factory opts: { style, config, specials, text, src, width, height, ... }"),
|
|
9
|
+
children: z.array(z.any()).optional().describe("Nested child specs (same shape) for container types"),
|
|
10
|
+
});
|
|
11
|
+
function parseSource(src) {
|
|
12
|
+
if (src == null)
|
|
13
|
+
return null;
|
|
14
|
+
return typeof src === "string" ? JSON.parse(src) : src;
|
|
15
|
+
}
|
|
16
|
+
function newPageId(res) {
|
|
17
|
+
return (res && res.data && res.data.id) || (res && res.id) || null;
|
|
18
|
+
}
|
|
19
|
+
export function registerBuilderTools(server, api, handle) {
|
|
20
|
+
server.tool("get_build_guide", "Get the BuilderX page authoring guide: page shape, the grid layout model, styling, breakpoints, forms/data, and the build workflow. Read this before building or heavily editing a page.", {}, () => handle(async () => ({ guide: BUILD_GUIDE })));
|
|
21
|
+
server.tool("list_elements", "List all BuilderX element/component types you can place on a page, grouped by category with a one-line summary and whether each is a container.", {}, () => handle(async () => listElements()));
|
|
22
|
+
server.tool("get_element", "Get the full detail of an element type: category, container flag, summary, and a live skeleton node (the authoritative default shape) you can copy and edit.", {
|
|
23
|
+
type: z.string().describe("Element type, e.g. 'text', 'button', 'grid-product'"),
|
|
24
|
+
}, ({ type }) => handle(async () => getElement(type)));
|
|
25
|
+
server.tool("new_element", "Build a single structurally-valid element node from the real builder factory. Returns the node — edit its specials/style, then place it in a section's children.", {
|
|
26
|
+
type: z.string().describe("Element type (see list_elements)"),
|
|
27
|
+
opts: z.record(z.any()).optional().describe("Factory opts: { text, src, width, height, style, config, specials, events }"),
|
|
28
|
+
}, ({ type, opts }) => handle(async () => buildElement(type, opts || {})));
|
|
29
|
+
server.tool("new_section", `Build a complete section node with children laid out in the builder's vertical grid.
|
|
30
|
+
Pass an array of element specs; each child is stacked top-to-bottom. Nest containers via the child's own 'children'.
|
|
31
|
+
Example children: [{ "type":"text", "opts":{"text":"Welcome","style":{"fontSize":"40px"}} }, { "type":"button", "opts":{"text":"Shop now"} }]`, {
|
|
32
|
+
children: z.array(elementSpec).default([]).describe("Child element specs, stacked vertically in the section"),
|
|
33
|
+
section_opts: z.record(z.any()).optional().describe("Optional factory opts for the section node itself"),
|
|
34
|
+
}, ({ children, section_opts }) => handle(async () => buildSection(children || [], section_opts || {})));
|
|
35
|
+
server.tool("new_page_skeleton", "Return an empty but valid page source: { sections: [] }. Add sections built with new_section, then save with build_page.", {}, () => handle(async () => newPageSkeleton()));
|
|
36
|
+
server.tool("validate_page", "Validate a page source ({ sections: [...] }). Returns errors (block saving: duplicate/missing ids, missing types) and warnings (unknown types, form fields without field_name, dangling event targets) plus stats. Always run this before build_page.", {
|
|
37
|
+
source: z.any().describe("Page source object or JSON string"),
|
|
38
|
+
}, ({ source }) => handle(async () => {
|
|
39
|
+
const parsed = parseSource(source);
|
|
40
|
+
return validatePage(parsed);
|
|
41
|
+
}));
|
|
42
|
+
server.tool("build_page", `Create a brand-new page AND set its full content source in one step.
|
|
43
|
+
Two-step safety: call with dry_run=true (default) to validate and preview, then dry_run=false to actually create + save.
|
|
44
|
+
The source must be { sections: [...] } — build sections with new_section. Validation errors block the real save.`, {
|
|
45
|
+
name: z.string().describe("Page name"),
|
|
46
|
+
slug: z.string().describe("URL slug, e.g. '/landing' or '/about'"),
|
|
47
|
+
source: z.any().describe("Full page source { sections: [...] } (object or JSON string)"),
|
|
48
|
+
type: z.string().optional().describe("Page type (optional)"),
|
|
49
|
+
is_homepage: z.boolean().default(false).describe("Set as the site homepage"),
|
|
50
|
+
dry_run: z.boolean().default(true).describe("Preview+validate only (true) or create+save (false)"),
|
|
51
|
+
}, ({ name, slug, source, type, is_homepage, dry_run }) => handle(async () => {
|
|
52
|
+
const parsed = parseSource(source);
|
|
53
|
+
const validation = validatePage(parsed);
|
|
54
|
+
if (dry_run) {
|
|
55
|
+
return {
|
|
56
|
+
dry_run: true,
|
|
57
|
+
validation,
|
|
58
|
+
request: { name, slug, type, is_homepage, sections: (parsed && parsed.sections || []).length },
|
|
59
|
+
hint: validation.valid
|
|
60
|
+
? "Looks valid. Call again with dry_run=false to create and save the page."
|
|
61
|
+
: "Fix the errors above before saving.",
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
if (!validation.valid) {
|
|
65
|
+
return { error: "Validation failed — not saving.", validation };
|
|
66
|
+
}
|
|
67
|
+
const created = await api.createPage({ name, slug, type, is_homepage });
|
|
68
|
+
const pageId = newPageId(created);
|
|
69
|
+
if (!pageId) {
|
|
70
|
+
return { error: "Page created but no id was returned; cannot save source.", created };
|
|
71
|
+
}
|
|
72
|
+
const saved = await api.updatePageSource(pageId, { source: parsed });
|
|
73
|
+
return {
|
|
74
|
+
success: true,
|
|
75
|
+
page_id: pageId,
|
|
76
|
+
name,
|
|
77
|
+
slug,
|
|
78
|
+
page_source_id: saved && saved.data && saved.data.id,
|
|
79
|
+
stats: validation.stats,
|
|
80
|
+
};
|
|
81
|
+
}));
|
|
82
|
+
server.tool("add_section", `Append a section to an EXISTING page's source. Reads the current source, appends your section, validates, and (when dry_run=false) saves.
|
|
83
|
+
The section is re-id'd to avoid collisions. Build it with new_section.
|
|
84
|
+
Two-step safety: dry_run=true (default) previews; dry_run=false saves.`, {
|
|
85
|
+
page_id: z.string().describe("Target page id"),
|
|
86
|
+
section: z.any().describe("A section node (from new_section) — object or JSON string"),
|
|
87
|
+
dry_run: z.boolean().default(true).describe("Preview only (true) or save (false)"),
|
|
88
|
+
}, ({ page_id, section, dry_run }) => handle(async () => {
|
|
89
|
+
const pagesRes = await api.listPages();
|
|
90
|
+
const pages = (pagesRes && pagesRes.data) || pagesRes || [];
|
|
91
|
+
const page = Array.isArray(pages) ? pages.find((p) => p.id === page_id) : null;
|
|
92
|
+
if (!page)
|
|
93
|
+
return { error: `Page "${page_id}" not found.` };
|
|
94
|
+
const source = parseSource(page.source && page.source.source) || newPageSkeleton();
|
|
95
|
+
if (!Array.isArray(source.sections))
|
|
96
|
+
source.sections = [];
|
|
97
|
+
const sectionNode = reassignIds(parseSource(section));
|
|
98
|
+
if (!sectionNode || sectionNode.type !== "section") {
|
|
99
|
+
return { error: "Provided 'section' is not a section node (type must be 'section')." };
|
|
100
|
+
}
|
|
101
|
+
source.sections.push(sectionNode);
|
|
102
|
+
const validation = validatePage(source);
|
|
103
|
+
if (dry_run) {
|
|
104
|
+
return {
|
|
105
|
+
dry_run: true,
|
|
106
|
+
page_id,
|
|
107
|
+
section_id: sectionNode.id,
|
|
108
|
+
validation,
|
|
109
|
+
total_sections: source.sections.length,
|
|
110
|
+
hint: validation.valid ? "Call again with dry_run=false to save." : "Fix errors before saving.",
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
if (!validation.valid)
|
|
114
|
+
return { error: "Validation failed — not saving.", validation };
|
|
115
|
+
const saved = await api.updatePageSource(page_id, { source });
|
|
116
|
+
return {
|
|
117
|
+
success: true,
|
|
118
|
+
page_id,
|
|
119
|
+
section_id: sectionNode.id,
|
|
120
|
+
total_sections: source.sections.length,
|
|
121
|
+
page_source_id: saved && saved.data && saved.data.id,
|
|
122
|
+
};
|
|
123
|
+
}));
|
|
124
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { HTTP_FUNCTION_GUIDE } from "../guides.js";
|
|
3
|
+
// ── HTTP function code parsing utilities ──
|
|
4
|
+
/**
|
|
5
|
+
* Parse exported functions from http_function code.
|
|
6
|
+
* Tracks braces properly, handles template literals and strings.
|
|
7
|
+
*/
|
|
8
|
+
function parseExportedFunctions(code) {
|
|
9
|
+
if (!code)
|
|
10
|
+
return [];
|
|
11
|
+
const lines = code.split("\n");
|
|
12
|
+
const functions = [];
|
|
13
|
+
const exportRe = /^export\s+const\s+(\w+)\s*=/;
|
|
14
|
+
for (let i = 0; i < lines.length; i++) {
|
|
15
|
+
const m = lines[i].match(exportRe);
|
|
16
|
+
if (!m)
|
|
17
|
+
continue;
|
|
18
|
+
const fullName = m[1];
|
|
19
|
+
const underIdx = fullName.indexOf("_");
|
|
20
|
+
const method = underIdx > 0 ? fullName.slice(0, underIdx) : "";
|
|
21
|
+
const funcName = underIdx > 0 ? fullName.slice(underIdx + 1) : fullName;
|
|
22
|
+
const startLine = i + 1;
|
|
23
|
+
// Find end by tracking brace depth
|
|
24
|
+
let depth = 0;
|
|
25
|
+
let started = false;
|
|
26
|
+
let endLine = startLine;
|
|
27
|
+
for (let j = i; j < lines.length; j++) {
|
|
28
|
+
for (const ch of lines[j]) {
|
|
29
|
+
if (ch === "{") {
|
|
30
|
+
depth++;
|
|
31
|
+
started = true;
|
|
32
|
+
}
|
|
33
|
+
if (ch === "}")
|
|
34
|
+
depth--;
|
|
35
|
+
}
|
|
36
|
+
if (started && depth <= 0) {
|
|
37
|
+
endLine = j + 1;
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
if (j === lines.length - 1)
|
|
41
|
+
endLine = j + 1;
|
|
42
|
+
}
|
|
43
|
+
functions.push({
|
|
44
|
+
name: fullName,
|
|
45
|
+
method,
|
|
46
|
+
function_name: funcName,
|
|
47
|
+
start_line: startLine,
|
|
48
|
+
end_line: endLine,
|
|
49
|
+
code: lines.slice(i, endLine).join("\n"),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return functions;
|
|
53
|
+
}
|
|
54
|
+
/** Extract import block (lines before first export) */
|
|
55
|
+
function extractImports(code) {
|
|
56
|
+
if (!code)
|
|
57
|
+
return "";
|
|
58
|
+
const lines = code.split("\n");
|
|
59
|
+
const out = [];
|
|
60
|
+
for (const line of lines) {
|
|
61
|
+
if (/^export\s/.test(line))
|
|
62
|
+
break;
|
|
63
|
+
out.push(line);
|
|
64
|
+
}
|
|
65
|
+
return out.join("\n").trim();
|
|
66
|
+
}
|
|
67
|
+
/** Replace a function in the code by name, returns new full content */
|
|
68
|
+
function replaceFunctionByName(code, funcName, newCode) {
|
|
69
|
+
const funcs = parseExportedFunctions(code);
|
|
70
|
+
const target = funcs.find((f) => f.name === funcName || f.function_name === funcName);
|
|
71
|
+
if (!target)
|
|
72
|
+
throw new Error(`Function "${funcName}" not found in file`);
|
|
73
|
+
const lines = code.split("\n");
|
|
74
|
+
const before = lines.slice(0, target.start_line - 1);
|
|
75
|
+
const after = lines.slice(target.end_line);
|
|
76
|
+
return [...before, newCode.trimEnd(), ...after].join("\n");
|
|
77
|
+
}
|
|
78
|
+
/** Remove a function from the code by name */
|
|
79
|
+
function removeFunctionByName(code, funcName) {
|
|
80
|
+
const funcs = parseExportedFunctions(code);
|
|
81
|
+
const target = funcs.find((f) => f.name === funcName || f.function_name === funcName);
|
|
82
|
+
if (!target)
|
|
83
|
+
throw new Error(`Function "${funcName}" not found in file`);
|
|
84
|
+
const lines = code.split("\n");
|
|
85
|
+
let start = target.start_line - 1;
|
|
86
|
+
while (start > 0 && lines[start - 1].trim() === "")
|
|
87
|
+
start--;
|
|
88
|
+
lines.splice(start, target.end_line - start);
|
|
89
|
+
return lines.join("\n");
|
|
90
|
+
}
|
|
91
|
+
/** Build function overview result */
|
|
92
|
+
function buildOverviewResult(content, fileId, schemas, includeGuide) {
|
|
93
|
+
const funcs = parseExportedFunctions(content);
|
|
94
|
+
const imports = extractImports(content);
|
|
95
|
+
const res = {
|
|
96
|
+
file_id: fileId,
|
|
97
|
+
total_lines: content.split("\n").length,
|
|
98
|
+
imports: imports || undefined,
|
|
99
|
+
functions: funcs.map((f) => ({
|
|
100
|
+
name: f.name,
|
|
101
|
+
method: f.method,
|
|
102
|
+
function_name: f.function_name,
|
|
103
|
+
start_line: f.start_line,
|
|
104
|
+
end_line: f.end_line,
|
|
105
|
+
lines: f.end_line - f.start_line + 1,
|
|
106
|
+
})),
|
|
107
|
+
collections: schemas,
|
|
108
|
+
hint: "Use get_http_function_snippet to read a specific function, or set overview=false to get full file",
|
|
109
|
+
};
|
|
110
|
+
if (includeGuide)
|
|
111
|
+
res.guide = HTTP_FUNCTION_GUIDE;
|
|
112
|
+
return res;
|
|
113
|
+
}
|
|
114
|
+
/** Helper to extract content and file_id from API response */
|
|
115
|
+
function extractContent(httpFunc) {
|
|
116
|
+
const content = (httpFunc?.data?.content) || (httpFunc?.content) || "";
|
|
117
|
+
const fileId = (httpFunc?.data?.id) || (httpFunc?.id) || undefined;
|
|
118
|
+
return { content, fileId };
|
|
119
|
+
}
|
|
120
|
+
/** Helper to build collection schemas */
|
|
121
|
+
function buildSchemas(collections) {
|
|
122
|
+
const raw = collections?.data;
|
|
123
|
+
const list = Array.isArray(raw) ? raw : Array.isArray(raw?.data) ? raw.data : [];
|
|
124
|
+
return list.map((c) => ({
|
|
125
|
+
name: c.name,
|
|
126
|
+
table_name: c.table_name,
|
|
127
|
+
fields: (c.schema || []).map((f) => ({
|
|
128
|
+
name: f.name,
|
|
129
|
+
type: f.type,
|
|
130
|
+
is_required: f.is_required,
|
|
131
|
+
reference: f.reference || undefined,
|
|
132
|
+
})),
|
|
133
|
+
}));
|
|
134
|
+
}
|
|
135
|
+
export function registerCmsFileTools(server, api, handle) {
|
|
136
|
+
server.tool("list_cms_files", "List all CMS files (HTTP functions, cron jobs, ...) for the site", {}, () => handle(() => api.listCmsFiles()));
|
|
137
|
+
server.tool("create_cms_file", `Create a new CMS file. Types: "http_function", "jobs_config", "default"`, {
|
|
138
|
+
name: z.string().describe("File name"),
|
|
139
|
+
content: z.string().describe("Code content (JavaScript or JSON)"),
|
|
140
|
+
type: z.enum(["http_function", "jobs_config", "default"]).default("default").describe("File type"),
|
|
141
|
+
}, ({ name, content, type }) => handle(() => api.createCmsFile({ name, content, type_create: "backend", type })));
|
|
142
|
+
server.tool("update_cms_file", "Update the code content of an existing CMS file", {
|
|
143
|
+
id: z.string().describe("CMS file ID"),
|
|
144
|
+
content: z.string().describe("New code content"),
|
|
145
|
+
name: z.string().optional().describe("Rename file"),
|
|
146
|
+
}, ({ id, content, name }) => handle(() => api.updateCmsFile(id, { content, ...(name && { name }) })));
|
|
147
|
+
// ── HTTP function — token-optimized read/write ──
|
|
148
|
+
server.tool("get_http_function", `Get the main HTTP function file. Choose the right mode for your task:
|
|
149
|
+
- overview=true: function names + line ranges only, NO code body. Use for: browsing, understanding structure, finding which function to read.
|
|
150
|
+
- overview=false (DEFAULT): full code + collection schemas. Use for: writing new features, refactoring, understanding full context.
|
|
151
|
+
Tip: for small fixes, use overview first then get_http_function_snippet to read just that function.
|
|
152
|
+
Add include_guide=true on first call to get the coding guide`, {
|
|
153
|
+
overview: z.boolean().default(false).describe("true=function list only, false=full code (default)"),
|
|
154
|
+
include_guide: z.boolean().default(false).describe("Include coding guide (only needed once)"),
|
|
155
|
+
}, ({ overview, include_guide }) => handle(async () => {
|
|
156
|
+
const [httpFunc, collections] = await Promise.all([
|
|
157
|
+
api.getHttpFunction(),
|
|
158
|
+
api.listCollections({ limit: 100 }).catch(() => null),
|
|
159
|
+
]);
|
|
160
|
+
const { content, fileId } = extractContent(httpFunc);
|
|
161
|
+
const schemas = buildSchemas(collections);
|
|
162
|
+
if (overview)
|
|
163
|
+
return buildOverviewResult(content, fileId, schemas, include_guide);
|
|
164
|
+
const res = { http_function: httpFunc, collections: schemas };
|
|
165
|
+
if (include_guide)
|
|
166
|
+
res.guide = HTTP_FUNCTION_GUIDE;
|
|
167
|
+
return res;
|
|
168
|
+
}));
|
|
169
|
+
server.tool("get_http_function_snippet", "Read specific function(s) by name. Much more token-efficient than reading the full file", {
|
|
170
|
+
function_names: z.array(z.string()).describe("Function names (e.g. ['get_Products', 'post_CreateOrder'])"),
|
|
171
|
+
}, ({ function_names }) => handle(async () => {
|
|
172
|
+
const { content } = extractContent(await api.getHttpFunction());
|
|
173
|
+
const allFuncs = parseExportedFunctions(content);
|
|
174
|
+
const nameSet = new Set(function_names);
|
|
175
|
+
const found = allFuncs.filter((f) => nameSet.has(f.name) || nameSet.has(f.function_name));
|
|
176
|
+
const notFound = function_names.filter((n) => !found.some((f) => f.name === n || f.function_name === n));
|
|
177
|
+
return {
|
|
178
|
+
imports: extractImports(content) || undefined,
|
|
179
|
+
functions: found.map((f) => ({
|
|
180
|
+
name: f.name, method: f.method, function_name: f.function_name,
|
|
181
|
+
start_line: f.start_line, end_line: f.end_line, code: f.code,
|
|
182
|
+
})),
|
|
183
|
+
not_found: notFound.length ? notFound : undefined,
|
|
184
|
+
};
|
|
185
|
+
}));
|
|
186
|
+
server.tool("edit_http_function", `Edit the HTTP function file by function name — best for targeted changes (fix a bug, rename, add one function).
|
|
187
|
+
For writing multiple new functions or major refactors, use update_http_function with full content instead.
|
|
188
|
+
Actions:
|
|
189
|
+
- "replace_function": replace an ENTIRE function by name with new code. Server finds function boundaries automatically.
|
|
190
|
+
- "add": append new function code at end of file.
|
|
191
|
+
- "remove": remove a function by name.
|
|
192
|
+
- "update_imports": replace the import block (lines before first export).
|
|
193
|
+
Returns updated function list after edit.`, {
|
|
194
|
+
action: z.enum(["replace_function", "add", "remove", "update_imports"]).describe("Edit action"),
|
|
195
|
+
function_name: z.string().optional().describe("Target function name (for replace_function and remove)"),
|
|
196
|
+
code: z.string().optional().describe("New function code (for replace_function and add) or new import block (for update_imports)"),
|
|
197
|
+
}, ({ action, function_name, code }) => handle(async () => {
|
|
198
|
+
const httpFunc = await api.getHttpFunction();
|
|
199
|
+
let { content, fileId } = extractContent(httpFunc);
|
|
200
|
+
if (action === "replace_function") {
|
|
201
|
+
if (!function_name)
|
|
202
|
+
throw new Error("replace_function requires function_name");
|
|
203
|
+
if (!code)
|
|
204
|
+
throw new Error("replace_function requires code (the new function code)");
|
|
205
|
+
content = replaceFunctionByName(content, function_name, code);
|
|
206
|
+
}
|
|
207
|
+
else if (action === "add") {
|
|
208
|
+
if (!code)
|
|
209
|
+
throw new Error("add requires code");
|
|
210
|
+
content = content.trimEnd() + "\n\n" + code.trimEnd() + "\n";
|
|
211
|
+
}
|
|
212
|
+
else if (action === "remove") {
|
|
213
|
+
if (!function_name)
|
|
214
|
+
throw new Error("remove requires function_name");
|
|
215
|
+
content = removeFunctionByName(content, function_name);
|
|
216
|
+
}
|
|
217
|
+
else if (action === "update_imports") {
|
|
218
|
+
if (code == null)
|
|
219
|
+
throw new Error("update_imports requires code (the new import block)");
|
|
220
|
+
const oldImports = extractImports(content);
|
|
221
|
+
if (oldImports) {
|
|
222
|
+
content = content.replace(oldImports, code.trimEnd());
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
content = code.trimEnd() + "\n\n" + content;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
await api.createOrUpdateHttpFunction({ content });
|
|
229
|
+
const schemas = buildSchemas(null);
|
|
230
|
+
return buildOverviewResult(content, fileId, schemas, false);
|
|
231
|
+
}));
|
|
232
|
+
server.tool("update_http_function", `Write the FULL HTTP function file content. Best for: writing new features, major refactors, or changes that touch multiple functions.
|
|
233
|
+
For small targeted edits (fix one function, add one function), use edit_http_function instead.
|
|
234
|
+
After update, auto-deploys to the bundle service`, {
|
|
235
|
+
content: z.string().describe("Full JS code content"),
|
|
236
|
+
}, ({ content }) => handle(() => api.createOrUpdateHttpFunction({ content })));
|
|
237
|
+
server.tool("run_function", `Run a deployed HTTP function. function_name excludes method prefix.
|
|
238
|
+
Example: "get_Products" → function_name="Products", method="GET"`, {
|
|
239
|
+
function_name: z.string().describe("Function name without method prefix (e.g. 'Products')"),
|
|
240
|
+
method: z.enum(["GET", "POST", "PUT", "PATCH"]).default("POST").describe("HTTP method"),
|
|
241
|
+
params: z.record(z.any()).optional().describe("Parameters"),
|
|
242
|
+
}, ({ function_name, method, params }) => handle(() => api.runFunction(function_name, method, params)));
|
|
243
|
+
server.tool("debug_function", "Run JS code in debug mode (without deploying). Returns execution result and console logs", {
|
|
244
|
+
content: z.string().describe("JS code to debug"),
|
|
245
|
+
function_name: z.string().describe("Function name to run"),
|
|
246
|
+
params: z.record(z.any()).optional().describe("Test parameters"),
|
|
247
|
+
}, ({ content, function_name, params }) => handle(() => api.debugFunction({ content, functionName: function_name, params })));
|
|
248
|
+
server.tool("save_file_version", "Save a version snapshot of a CMS file for rollback", {
|
|
249
|
+
cms_file_id: z.string().describe("CMS file ID"),
|
|
250
|
+
content: z.string().describe("Content to save"),
|
|
251
|
+
is_public: z.boolean().default(false).describe("Mark as public version"),
|
|
252
|
+
}, ({ cms_file_id, content, is_public }) => handle(() => api.saveFileVersion({ cms_file_id, content, is_public })));
|
|
253
|
+
server.tool("get_file_versions", "View version history of a CMS file", { cms_file_id: z.string().describe("CMS file ID") }, ({ cms_file_id }) => handle(() => api.getFileVersions({ cms_file_id })));
|
|
254
|
+
server.tool("toggle_debug_render", "Toggle debug render mode for a CMS file", { cms_file_id: z.string().describe("CMS file ID") }, ({ cms_file_id }) => handle(() => api.toggleDebugRender({ cms_file_id })));
|
|
255
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export function registerCollectionTools(server, api, handle) {
|
|
3
|
+
server.tool("list_collections", "List all database collections (tables) for the site. Returns collection names, table names, and field counts. Use get_collection for full schema details", {
|
|
4
|
+
page: z.number().optional().describe("Page number"),
|
|
5
|
+
limit: z.number().optional().describe("Items per page"),
|
|
6
|
+
term: z.string().optional().describe("Search by collection name"),
|
|
7
|
+
}, ({ page, limit, term }) => handle(async () => {
|
|
8
|
+
const res = await api.listCollections({ page, limit, term });
|
|
9
|
+
const collections = (res && res.data) || res || [];
|
|
10
|
+
if (!Array.isArray(collections))
|
|
11
|
+
return res;
|
|
12
|
+
return {
|
|
13
|
+
data: collections.map((c) => ({
|
|
14
|
+
id: c.id || c._id,
|
|
15
|
+
name: c.name,
|
|
16
|
+
table_name: c.table_name,
|
|
17
|
+
fields_count: (c.schema || []).length,
|
|
18
|
+
records_count: c.records_count || undefined,
|
|
19
|
+
})),
|
|
20
|
+
total: res.total || collections.length,
|
|
21
|
+
};
|
|
22
|
+
}));
|
|
23
|
+
server.tool("get_collection", "Get a specific collection's details including full schema (field names, types, constraints, references) and records", {
|
|
24
|
+
id: z.string().describe("Collection ID"),
|
|
25
|
+
}, ({ id }) => handle(() => api.getCollection(id)));
|
|
26
|
+
server.tool("query_collection_records", "Query records from a collection by table name. Use to inspect existing data", {
|
|
27
|
+
table_name: z.string().describe("Collection table name (e.g. 'subscribers', 'custom_orders')"),
|
|
28
|
+
page: z.number().optional().describe("Page number"),
|
|
29
|
+
limit: z.number().optional().describe("Items per page"),
|
|
30
|
+
}, ({ table_name, page, limit }) => handle(() => api.queryCollectionRecords(table_name, { page, limit })));
|
|
31
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
const COMBO_GUIDE = `
|
|
3
|
+
## Combo Product Types
|
|
4
|
+
|
|
5
|
+
### By item matching:
|
|
6
|
+
- is_variation=true — Variation-based: combo requires specific product variations
|
|
7
|
+
- is_variation=false — Product-based: combo requires specific products (any variation)
|
|
8
|
+
- is_categories=true — Category-based: combo requires items from specific categories with quantities
|
|
9
|
+
|
|
10
|
+
### Discount types:
|
|
11
|
+
- discount_amount > 0 — Fixed discount amount off combo price
|
|
12
|
+
- is_use_percent=true — Percentage discount (discount_by_percent %), capped by max_discount_by_percent
|
|
13
|
+
- is_value_combo=true — Fixed total price for combo (value_combo)
|
|
14
|
+
- is_free_shipping=true — Combo includes free shipping
|
|
15
|
+
|
|
16
|
+
### Key Fields:
|
|
17
|
+
- name: combo display name
|
|
18
|
+
- slug: URL-friendly identifier
|
|
19
|
+
- is_activated: whether combo is currently active
|
|
20
|
+
- start_time / end_time: combo schedule (UTC+7)
|
|
21
|
+
- images: combo images array
|
|
22
|
+
- categories: for category-based combos, array of {id, name, count} specifying required categories and quantities
|
|
23
|
+
- combo_product_variations: items that make up the combo (products/variations with count)
|
|
24
|
+
- bonus_items: free/bonus products included with the combo
|
|
25
|
+
`;
|
|
26
|
+
export function registerComboTools(server, api, handle) {
|
|
27
|
+
server.tool("list_combos", "List all combo/bundle products of the site. Use get_combo_items for combo composition details", {
|
|
28
|
+
page: z.number().optional().describe("Page number (default: 1)"),
|
|
29
|
+
limit: z.number().optional().describe("Items per page (default: 20)"),
|
|
30
|
+
term: z.string().optional().describe("Search by combo name"),
|
|
31
|
+
include_guide: z.boolean().optional().describe("Include combo type reference guide"),
|
|
32
|
+
}, ({ page, limit, term, include_guide }) => handle(async () => {
|
|
33
|
+
const res = await api.listCombos({ page, limit, term });
|
|
34
|
+
const combos = (res && res.combo_products) || (res && res.data) || [];
|
|
35
|
+
const result = {
|
|
36
|
+
data: Array.isArray(combos)
|
|
37
|
+
? combos.map((c) => ({
|
|
38
|
+
id: c.id,
|
|
39
|
+
name: c.name,
|
|
40
|
+
slug: c.slug,
|
|
41
|
+
custom_id: c.custom_id || undefined,
|
|
42
|
+
is_activated: c.is_activated,
|
|
43
|
+
is_variation: c.is_variation,
|
|
44
|
+
is_categories: c.is_categories || undefined,
|
|
45
|
+
discount_amount: c.discount_amount || undefined,
|
|
46
|
+
is_use_percent: c.is_use_percent || undefined,
|
|
47
|
+
discount_by_percent: c.discount_by_percent || undefined,
|
|
48
|
+
max_discount_by_percent: c.max_discount_by_percent || undefined,
|
|
49
|
+
is_value_combo: c.is_value_combo || undefined,
|
|
50
|
+
value_combo: c.value_combo || undefined,
|
|
51
|
+
is_free_shipping: c.is_free_shipping || undefined,
|
|
52
|
+
start_time: c.start_time || undefined,
|
|
53
|
+
end_time: c.end_time || undefined,
|
|
54
|
+
images: c.images || undefined,
|
|
55
|
+
categories: c.categories || undefined,
|
|
56
|
+
inserted_at: c.inserted_at,
|
|
57
|
+
}))
|
|
58
|
+
: combos,
|
|
59
|
+
total: res.total || (Array.isArray(combos) ? combos.length : 0),
|
|
60
|
+
};
|
|
61
|
+
if (include_guide)
|
|
62
|
+
result.guide = COMBO_GUIDE.trim();
|
|
63
|
+
return result;
|
|
64
|
+
}));
|
|
65
|
+
server.tool("get_combo_items", "Get items (products/variations) and bonus products that compose a combo. Returns combo_items (required items with count) and bonus_items (free gifts)", {
|
|
66
|
+
combo_product_id: z.string().describe("Combo product ID"),
|
|
67
|
+
is_variation_bonus: z.boolean().optional().describe("Whether bonus items are variation-based"),
|
|
68
|
+
}, ({ combo_product_id, is_variation_bonus }) => handle(async () => {
|
|
69
|
+
const res = await api.getComboItems(combo_product_id, { is_variation_bonus });
|
|
70
|
+
return (res && res.data && res.data.data) || (res && res.data) || res;
|
|
71
|
+
}));
|
|
72
|
+
}
|