sitezen-mcp 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +107 -0
- package/dist/conversion-log.js +67 -0
- package/dist/conversion-rules.md +1361 -0
- package/dist/errors.js +37 -0
- package/dist/figma.js +1369 -0
- package/dist/index.js +37 -0
- package/dist/license.js +121 -0
- package/dist/normalize.js +692 -0
- package/dist/state.js +81 -0
- package/dist/tools-session.js +131 -0
- package/dist/tools.js +1378 -0
- package/dist/validate.js +114 -0
- package/dist/wp-client.js +130 -0
- package/package.json +35 -0
package/dist/validate.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SiteZen HTML validator — strict enforcement, NO invented values.
|
|
3
|
+
*
|
|
4
|
+
* Runs server-side inside the MCP before any HTML is pushed to WordPress.
|
|
5
|
+
* The model (Claude in Desktop) generates HTML using REAL Figma data;
|
|
6
|
+
* this validator catches violations of the universal rules and REFUSES
|
|
7
|
+
* the push, returning a clear list of what's wrong so the model can
|
|
8
|
+
* regenerate using the Figma values it already has.
|
|
9
|
+
*
|
|
10
|
+
* What this does NOT do:
|
|
11
|
+
* - Invent font-sizes, padding values, colors, or any other design value
|
|
12
|
+
* - Auto-rewrite HTML with formulas the validator made up
|
|
13
|
+
* - Modify the model's intent in any way
|
|
14
|
+
*
|
|
15
|
+
* What this DOES do:
|
|
16
|
+
* - Check that emitted HTML obeys the structural rules (section root,
|
|
17
|
+
* scoped id, responsive clamp on large values, mobile breakpoint)
|
|
18
|
+
* - Return a list of violations with specific instructions for the
|
|
19
|
+
* model to fix using its existing Figma data
|
|
20
|
+
*
|
|
21
|
+
* Rule philosophy: the model has the Figma data (text content, hex
|
|
22
|
+
* colors, font sizes, layout values). The validator's job is to make
|
|
23
|
+
* sure the model USES that data correctly — not to second-guess it.
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* Validate HTML against the SiteZen universal rules. Returns ok=false
|
|
27
|
+
* with a list of specific violations if any rule is broken. On ok=true,
|
|
28
|
+
* the HTML passes and can be pushed verbatim.
|
|
29
|
+
*
|
|
30
|
+
* The HTML is NEVER modified by this function. If the model emitted
|
|
31
|
+
* something wrong, the model must regenerate using the Figma values
|
|
32
|
+
* it fetched.
|
|
33
|
+
*/
|
|
34
|
+
export function validateHtml(html) {
|
|
35
|
+
const errors = [];
|
|
36
|
+
// ── Rule 1: No markdown code fences ───────────────────────────────
|
|
37
|
+
if (/^```/m.test(html) || /```\s*$/m.test(html)) {
|
|
38
|
+
errors.push("Markdown code fences detected (``` ... ```). Strip them — the output must be raw HTML starting with '<'.");
|
|
39
|
+
}
|
|
40
|
+
// ── Rule 2: Must have a <section> tag ─────────────────────────────
|
|
41
|
+
const sectionMatch = html.match(/<section\b([^>]*)>/i);
|
|
42
|
+
if (!sectionMatch) {
|
|
43
|
+
errors.push("No <section> tag found. SiteZen wraps each push as one section-per-block; the outermost element must be <section>.");
|
|
44
|
+
// Without a section, the remaining rules can't meaningfully run.
|
|
45
|
+
return { ok: false, errors };
|
|
46
|
+
}
|
|
47
|
+
// ── Rule 3: <section> must have id=\"sz-...\" for CSS scoping ─────
|
|
48
|
+
const sectionAttrs = sectionMatch[1] || "";
|
|
49
|
+
const idMatch = sectionAttrs.match(/\bid="(sz-[a-zA-Z0-9_-]+)"/);
|
|
50
|
+
if (!idMatch) {
|
|
51
|
+
errors.push("<section> is missing id=\"sz-something\". Add an id like id=\"sz-hero\" and scope all CSS in this section under that id (e.g. #sz-hero h1 { ... }).");
|
|
52
|
+
}
|
|
53
|
+
// ── Rule 4: Font sizes > 18px must use clamp() ────────────────────
|
|
54
|
+
// The values inside clamp() come from Figma — never from this validator.
|
|
55
|
+
const rawLargeFonts = [];
|
|
56
|
+
const fontSizeRegex = /font-size:\s*(\d+(?:\.\d+)?)px\s*;/gi;
|
|
57
|
+
let match;
|
|
58
|
+
while ((match = fontSizeRegex.exec(html)) !== null) {
|
|
59
|
+
const size = parseFloat(match[1]);
|
|
60
|
+
if (size > 18)
|
|
61
|
+
rawLargeFonts.push(size);
|
|
62
|
+
}
|
|
63
|
+
if (rawLargeFonts.length > 0) {
|
|
64
|
+
errors.push(`Found ${rawLargeFonts.length} raw font-size value(s) > 18px (${rawLargeFonts.join("px, ")}px). ` +
|
|
65
|
+
"Per universal rule §0.2, every font-size > 18px must use clamp() for responsive scaling. " +
|
|
66
|
+
"Use the EXACT values from your Figma data — do not invent them. Pattern: " +
|
|
67
|
+
"font-size: clamp(<mobile-min-from-Figma-or-design-judgment>, <preferred-vw-derived-from-Figma-px>, <Figma-desktop-px>px). " +
|
|
68
|
+
"Regenerate the affected styles using your Figma text node fontSize values.");
|
|
69
|
+
}
|
|
70
|
+
// ── Rule 5: Padding/margin > 40px must use clamp() ────────────────
|
|
71
|
+
const rawLargeSpacing = [];
|
|
72
|
+
const spacingRegex = /\b(padding|margin):\s*([^;]+);/gi;
|
|
73
|
+
while ((match = spacingRegex.exec(html)) !== null) {
|
|
74
|
+
const prop = match[1];
|
|
75
|
+
const value = match[2];
|
|
76
|
+
const largeValues = value
|
|
77
|
+
.trim()
|
|
78
|
+
.split(/\s+/)
|
|
79
|
+
.map((part) => {
|
|
80
|
+
const m = part.match(/^(\d+(?:\.\d+)?)px$/);
|
|
81
|
+
return m ? parseFloat(m[1]) : null;
|
|
82
|
+
})
|
|
83
|
+
.filter((n) => n !== null && n > 40);
|
|
84
|
+
if (largeValues.length > 0) {
|
|
85
|
+
rawLargeSpacing.push({ property: prop, values: largeValues });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (rawLargeSpacing.length > 0) {
|
|
89
|
+
const summary = rawLargeSpacing
|
|
90
|
+
.map((s) => `${s.property}: ${s.values.join("px, ")}px`)
|
|
91
|
+
.join(" | ");
|
|
92
|
+
errors.push(`Found raw spacing values > 40px (${summary}). ` +
|
|
93
|
+
"Per universal rule §0.2, padding/margin > 32px must use clamp() for responsive scaling. " +
|
|
94
|
+
"Use the EXACT padding/margin values from your Figma layout — do not invent them. Pattern: " +
|
|
95
|
+
"padding: clamp(<small-mobile-px>, <vw-derived-from-design-px>, <Figma-desktop-px>px). " +
|
|
96
|
+
"Regenerate the affected styles using your Figma frame paddingTop/Right/Bottom/Left values.");
|
|
97
|
+
}
|
|
98
|
+
// ── Rule 6: Must include mobile breakpoint @media query ───────────
|
|
99
|
+
if (!/@media\s*\(\s*max-width:\s*768px\s*\)/i.test(html)) {
|
|
100
|
+
errors.push("No @media (max-width: 768px) block found. Per universal rule §0.2, every section must include a mobile breakpoint with: " +
|
|
101
|
+
"stacked layouts (flex-direction: column for previously horizontal flex), shrunk font sizes, " +
|
|
102
|
+
"reduced padding, and any decoration repositioning needed. The values come from how the design " +
|
|
103
|
+
"should adapt — not formulas. Add a real @media block with rules derived from the Figma design intent.");
|
|
104
|
+
}
|
|
105
|
+
// ── Rule 7: white-space: nowrap on headings causes overflow ───────
|
|
106
|
+
if (/(<h[1-6][^>]*style="[^"]*?white-space:\s*nowrap)/i.test(html)) {
|
|
107
|
+
errors.push("Detected white-space: nowrap on a heading element. This causes horizontal overflow on mobile " +
|
|
108
|
+
"when the headline is long. Remove white-space: nowrap from headings; let them wrap naturally.");
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
ok: errors.length === 0,
|
|
112
|
+
errors,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin HTTP client wrapping the SiteZen plugin's REST API.
|
|
3
|
+
*
|
|
4
|
+
* The plugin exposes /wp-json/sitezen/v1/* endpoints authenticated by an
|
|
5
|
+
* X-SiteZen-Key header (the per-site connection key the user generates
|
|
6
|
+
* from WP Admin → SiteZen → Connection).
|
|
7
|
+
*
|
|
8
|
+
* All MCP tools go through this client — kept deliberately tiny so any
|
|
9
|
+
* SiteZen REST endpoint can be hit by passing the path + method, without
|
|
10
|
+
* needing a per-endpoint wrapper.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Reads + validates the env vars the operator set in their Claude Desktop
|
|
14
|
+
* MCP server config. Throws if either is missing so the first tool call
|
|
15
|
+
* fails fast with a clear message instead of mysteriously 401'ing later.
|
|
16
|
+
*/
|
|
17
|
+
export function getConfig() {
|
|
18
|
+
const siteUrl = process.env.SITEZEN_SITE_URL;
|
|
19
|
+
const connectionKey = process.env.SITEZEN_CONNECTION_KEY;
|
|
20
|
+
if (!siteUrl) {
|
|
21
|
+
throw new Error("SITEZEN_SITE_URL env var is required. Add it to your Claude Desktop MCP " +
|
|
22
|
+
"server config (e.g. \"env\": {\"SITEZEN_SITE_URL\": \"https://yoursite.com\"}).");
|
|
23
|
+
}
|
|
24
|
+
if (!connectionKey) {
|
|
25
|
+
throw new Error("SITEZEN_CONNECTION_KEY env var is required. Get it from WP Admin → SiteZen → " +
|
|
26
|
+
"Connection, then add to Claude Desktop MCP server config.");
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
// Strip a trailing slash so we can naively concatenate paths.
|
|
30
|
+
siteUrl: siteUrl.replace(/\/+$/, ""),
|
|
31
|
+
connectionKey,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Make an authenticated request to the SiteZen REST API.
|
|
36
|
+
*
|
|
37
|
+
* `path` is relative to /wp-json/sitezen/v1 (so pass `/pages`, not
|
|
38
|
+
* `/wp-json/sitezen/v1/pages`).
|
|
39
|
+
*
|
|
40
|
+
* On non-2xx the response body is included in the thrown Error so MCP
|
|
41
|
+
* tool errors carry the real WP/SiteZen complaint instead of just a
|
|
42
|
+
* status code.
|
|
43
|
+
*/
|
|
44
|
+
export async function wpRequest(path, init = {}) {
|
|
45
|
+
const config = (init.siteUrl && init.connectionKey)
|
|
46
|
+
? { siteUrl: init.siteUrl.replace(/\/+$/, ""), connectionKey: init.connectionKey }
|
|
47
|
+
: getConfig();
|
|
48
|
+
let url = `${config.siteUrl}/wp-json/sitezen/v1${path}`;
|
|
49
|
+
if (init.query) {
|
|
50
|
+
const params = new URLSearchParams();
|
|
51
|
+
for (const [key, value] of Object.entries(init.query)) {
|
|
52
|
+
if (value === undefined)
|
|
53
|
+
continue;
|
|
54
|
+
params.set(key, String(value));
|
|
55
|
+
}
|
|
56
|
+
const queryString = params.toString();
|
|
57
|
+
if (queryString)
|
|
58
|
+
url += `?${queryString}`;
|
|
59
|
+
}
|
|
60
|
+
const headers = {
|
|
61
|
+
"X-SiteZen-Key": config.connectionKey,
|
|
62
|
+
};
|
|
63
|
+
let body;
|
|
64
|
+
if (init.body !== undefined) {
|
|
65
|
+
headers["Content-Type"] = "application/json";
|
|
66
|
+
body = JSON.stringify(init.body);
|
|
67
|
+
}
|
|
68
|
+
const response = await fetch(url, {
|
|
69
|
+
method: init.method || "GET",
|
|
70
|
+
headers,
|
|
71
|
+
body,
|
|
72
|
+
});
|
|
73
|
+
const responseText = await response.text();
|
|
74
|
+
if (!response.ok) {
|
|
75
|
+
// WP REST errors are usually JSON {code, message, data}. Include the
|
|
76
|
+
// raw body so the model can read the actual reason in its tool result.
|
|
77
|
+
throw new Error(`SiteZen REST ${init.method || "GET"} ${path} → ${response.status}: ${responseText.slice(0, 500)}`);
|
|
78
|
+
}
|
|
79
|
+
// Some endpoints return empty body on success; handle that gracefully.
|
|
80
|
+
if (!responseText)
|
|
81
|
+
return undefined;
|
|
82
|
+
try {
|
|
83
|
+
return JSON.parse(responseText);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// Endpoint returned non-JSON success body — return as-is so caller can use it.
|
|
87
|
+
return responseText;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Tool-result helper: wraps any JSON-serializable value as the MCP
|
|
92
|
+
* `{ content: [{type: "text", text: ...}] }` shape, with a couple of
|
|
93
|
+
* MCP-client-friendly conveniences:
|
|
94
|
+
* - errors are flagged with isError: true so the client surfaces them
|
|
95
|
+
* - everything is JSON-stringified with 2-space indent for readability
|
|
96
|
+
*/
|
|
97
|
+
export function ok(payload) {
|
|
98
|
+
return {
|
|
99
|
+
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Resolve a site URL into the connection info from state.json. Returns the
|
|
104
|
+
* single saved site when only one exists (so the user doesn't have to specify
|
|
105
|
+
* site_url every call). Returns null when no sites are saved or the requested
|
|
106
|
+
* URL isn't connected — caller surfaces the appropriate Errors.* response.
|
|
107
|
+
*/
|
|
108
|
+
export function resolveSiteFromState(state, siteUrl) {
|
|
109
|
+
if (state.sites.length === 0)
|
|
110
|
+
return null;
|
|
111
|
+
if (!siteUrl && state.sites.length === 1) {
|
|
112
|
+
return { siteUrl: state.sites[0].url, connectionKey: state.sites[0].connection_key };
|
|
113
|
+
}
|
|
114
|
+
if (!siteUrl)
|
|
115
|
+
return null; // ambiguous — caller must ask the user
|
|
116
|
+
const canon = siteUrl.trim().replace(/\/+$/, "").toLowerCase();
|
|
117
|
+
const match = state.sites.find((s) => s.url.trim().replace(/\/+$/, "").toLowerCase() === canon);
|
|
118
|
+
return match ? { siteUrl: match.url, connectionKey: match.connection_key } : null;
|
|
119
|
+
}
|
|
120
|
+
export function err(message, extra) {
|
|
121
|
+
return {
|
|
122
|
+
content: [
|
|
123
|
+
{
|
|
124
|
+
type: "text",
|
|
125
|
+
text: JSON.stringify({ error: message, ...extra }, null, 2),
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
isError: true,
|
|
129
|
+
};
|
|
130
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "sitezen-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "SiteZen MCP server — lets Claude Desktop (or any MCP client) drive a SiteZen-enabled WordPress site directly. No Vercel, no platform API, no API-credit burn for the operator. The end user's Claude subscription pays for LLM tokens.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"sitezen-mcp": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "dist/index.js",
|
|
10
|
+
"files": [
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsc && node -e \"require('fs').copyFileSync('src/conversion-rules.md','dist/conversion-rules.md')\"",
|
|
18
|
+
"dev": "tsx src/index.ts",
|
|
19
|
+
"start": "node dist/index.js",
|
|
20
|
+
"prepublishOnly": "npm run build"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@modelcontextprotocol/sdk": "^1.0.4",
|
|
24
|
+
"cheerio": "^1.2.0",
|
|
25
|
+
"zod": "^3.23.8"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/node": "^20.14.0",
|
|
29
|
+
"tsx": "^4.16.0",
|
|
30
|
+
"typescript": "^5.5.0"
|
|
31
|
+
},
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=18.0.0"
|
|
34
|
+
}
|
|
35
|
+
}
|