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/dist/state.js ADDED
@@ -0,0 +1,81 @@
1
+ // Persistent state for the SiteZen MCP. Lives at ~/.sitezen/state.json so it
2
+ // survives Claude Desktop restarts and is shared across sessions on the same
3
+ // machine. Cross-platform: works on Windows, macOS, Linux.
4
+ //
5
+ // Contents:
6
+ // - sites: every WP site the user has connected (url + connection_key + label
7
+ // + last_used). Created via the connect_site tool. Looked up at conversion
8
+ // time so the user never has to paste site+key again after the first time.
9
+ // - license_cache: the validated license response, cached 1 hour so the MCP
10
+ // stays fast and works briefly offline.
11
+ //
12
+ // The file is created lazily on first write — read returns a default empty
13
+ // shape if the file does not exist. NEVER throws on a missing/corrupted file
14
+ // (corrupted → log + treat as empty so the user can recover by re-connecting).
15
+ import * as fs from "node:fs";
16
+ import * as path from "node:path";
17
+ import * as os from "node:os";
18
+ const STATE_DIR = path.join(os.homedir(), ".sitezen");
19
+ const STATE_FILE = path.join(STATE_DIR, "state.json");
20
+ function emptyState() {
21
+ return { sites: [] };
22
+ }
23
+ export function readState() {
24
+ try {
25
+ if (!fs.existsSync(STATE_FILE))
26
+ return emptyState();
27
+ const raw = fs.readFileSync(STATE_FILE, "utf8");
28
+ const parsed = JSON.parse(raw);
29
+ if (!parsed || !Array.isArray(parsed.sites))
30
+ return emptyState();
31
+ return parsed;
32
+ }
33
+ catch {
34
+ // Corrupted file — start fresh. User can re-connect their site.
35
+ return emptyState();
36
+ }
37
+ }
38
+ export function writeState(state) {
39
+ if (!fs.existsSync(STATE_DIR)) {
40
+ fs.mkdirSync(STATE_DIR, { recursive: true });
41
+ }
42
+ fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2), "utf8");
43
+ }
44
+ export function canonicalUrl(url) {
45
+ return url.trim().replace(/\/+$/, "").toLowerCase();
46
+ }
47
+ export function findSite(state, url) {
48
+ const c = canonicalUrl(url);
49
+ return state.sites.find((s) => canonicalUrl(s.url) === c);
50
+ }
51
+ export function upsertSite(site) {
52
+ const state = readState();
53
+ const existing = findSite(state, site.url);
54
+ if (existing) {
55
+ existing.connection_key = site.connection_key;
56
+ existing.label = site.label ?? existing.label;
57
+ existing.last_used = site.last_used;
58
+ }
59
+ else {
60
+ state.sites.push(site);
61
+ }
62
+ writeState(state);
63
+ }
64
+ export function removeSite(url) {
65
+ const state = readState();
66
+ const before = state.sites.length;
67
+ state.sites = state.sites.filter((s) => canonicalUrl(s.url) !== canonicalUrl(url));
68
+ const removed = state.sites.length < before;
69
+ if (removed)
70
+ writeState(state);
71
+ return removed;
72
+ }
73
+ export function touchSite(url) {
74
+ const state = readState();
75
+ const site = findSite(state, url);
76
+ if (site) {
77
+ site.last_used = new Date().toISOString();
78
+ writeState(state);
79
+ }
80
+ }
81
+ export const STATE_PATH_FOR_DISPLAY = STATE_FILE;
@@ -0,0 +1,131 @@
1
+ import { z } from "zod";
2
+ import { wpRequest, ok } from "./wp-client.js";
3
+ import { readState, upsertSite, removeSite, findSite, canonicalUrl, STATE_PATH_FOR_DISPLAY } from "./state.js";
4
+ import { validateLicense, planLabel } from "./license.js";
5
+ import { Errors } from "./errors.js";
6
+ function failResult(f) {
7
+ return { content: [{ type: "text", text: JSON.stringify(f, null, 2) }], isError: true };
8
+ }
9
+ /**
10
+ * Pings the SiteZen plugin's auth-protected /detect endpoint to verify the
11
+ * URL + connection key are valid before saving. Returns one of:
12
+ * "ok" — site is reachable and the key works
13
+ * "unreachable" — DNS / network / CORS / WP not responding
14
+ * "bad_key" — site responded with 401/403
15
+ */
16
+ async function probeSite(siteUrl, connectionKey) {
17
+ try {
18
+ // Use a lightweight endpoint that requires auth. /detect with empty
19
+ // markup returns immediately so it's cheap.
20
+ await wpRequest("/detect", {
21
+ method: "POST",
22
+ body: { html: "<div></div>" },
23
+ siteUrl,
24
+ connectionKey,
25
+ });
26
+ return "ok";
27
+ }
28
+ catch (e) {
29
+ const msg = e instanceof Error ? e.message : String(e);
30
+ if (/\b40[13]\b/.test(msg))
31
+ return "bad_key";
32
+ return "unreachable";
33
+ }
34
+ }
35
+ export function registerSessionTools(server) {
36
+ /* ─── list_connected_sites ─────────────────────────────────────────── */
37
+ server.tool("list_connected_sites", "List every WordPress site the user has connected (persists across Claude Desktop restarts), plus their current license plan and remaining quota. Call this BEFORE asking the user for site URL / connection key — if they have a site saved already and their plan has space, reuse it instead of asking again. Returns: {sites:[{url,label,last_used}], license:{plan,sites_used,sites_allowed,conversions_remaining}, can_add_more_sites}.", {}, async () => {
38
+ const state = readState();
39
+ const license = await validateLicense(process.env.SITEZEN_LICENSE_KEY);
40
+ if (!license)
41
+ return failResult(Errors.noLicenseKey());
42
+ const sitesUsed = state.sites.length;
43
+ const canAddMore = license.sites_allowed < 0 || sitesUsed < license.sites_allowed;
44
+ return ok({
45
+ ok: true,
46
+ sites: state.sites.map((s) => ({
47
+ url: s.url,
48
+ label: s.label || s.url,
49
+ last_used: s.last_used,
50
+ })),
51
+ license: {
52
+ plan: planLabel(license.plan),
53
+ sites_used: sitesUsed,
54
+ sites_allowed: license.sites_allowed < 0 ? "unlimited" : license.sites_allowed,
55
+ conversions_remaining: license.conversions_remaining < 0 ? "unlimited" : license.conversions_remaining,
56
+ upgrade_url: license.upgrade_url,
57
+ },
58
+ can_add_more_sites: canAddMore,
59
+ state_file: STATE_PATH_FOR_DISPLAY,
60
+ instruction: state.sites.length === 0
61
+ ? "No sites connected yet. Ask the user for the site URL and connection key, then call connect_site."
62
+ : "Show the saved site(s) to the user. Ask which one to use; if they want a new site and can_add_more_sites is false, surface the upgrade_url.",
63
+ });
64
+ });
65
+ /* ─── connect_site ─────────────────────────────────────────────────── */
66
+ server.tool("connect_site", "Save a new WordPress site connection (URL + connection key). Validates the site is reachable AND the key is correct BEFORE saving. Enforces the plan's site limit — refuses to save if the user is already at their limit. After this succeeds, all conversion tools (create_page, push_section_to_page, etc.) can use the site by URL without the user re-sharing the key. Returns clear errors if the URL is unreachable, the key is invalid, or the plan limit is hit.", {
67
+ site_url: z.string().url().describe("The WordPress site's URL, e.g. https://example.com. Must be reachable and have the SiteZen plugin installed + activated."),
68
+ connection_key: z.string().min(8).describe("The site's SiteZen connection key from WordPress → SiteZen → Connection."),
69
+ label: z.string().optional().describe("Optional friendly name to show in the connected-sites list (e.g. 'Acme staging'). Defaults to the URL."),
70
+ }, async ({ site_url, connection_key, label }) => {
71
+ const license = await validateLicense(process.env.SITEZEN_LICENSE_KEY);
72
+ if (!license)
73
+ return failResult(Errors.noLicenseKey());
74
+ const state = readState();
75
+ const existing = findSite(state, site_url);
76
+ // Enforce site-count limit when adding a NEW site (re-saving the
77
+ // same URL just refreshes its key, doesn't consume a slot).
78
+ if (!existing) {
79
+ const isUnlimited = license.sites_allowed < 0;
80
+ if (!isUnlimited && state.sites.length >= license.sites_allowed) {
81
+ return failResult(Errors.limitReachedSites(planLabel(license.plan), license.sites_allowed, license.upgrade_url));
82
+ }
83
+ }
84
+ // Probe before saving — bad URLs or bad keys never reach state.json.
85
+ const probe = await probeSite(site_url, connection_key);
86
+ if (probe === "unreachable")
87
+ return failResult(Errors.siteUnreachable(site_url));
88
+ if (probe === "bad_key")
89
+ return failResult(Errors.invalidConnectionKey(site_url));
90
+ upsertSite({
91
+ url: canonicalUrl(site_url),
92
+ connection_key,
93
+ label: label || canonicalUrl(site_url),
94
+ last_used: new Date().toISOString(),
95
+ });
96
+ return ok({
97
+ ok: true,
98
+ message: existing
99
+ ? `Refreshed connection key for ${site_url}.`
100
+ : `Connected ${site_url}. You can now convert directly to this site without sharing the key again.`,
101
+ site: { url: canonicalUrl(site_url), label: label || canonicalUrl(site_url) },
102
+ });
103
+ });
104
+ /* ─── disconnect_site ──────────────────────────────────────────────── */
105
+ server.tool("disconnect_site", "Remove a saved WordPress site connection. Frees a slot under the user's plan site-limit. Does NOT touch the WordPress site itself — only deletes the saved URL+key from this machine's state.json. Use when the user wants to swap to a different site under a single-site plan, or no longer uses a site.", {
106
+ site_url: z.string().url().describe("The URL of the site to disconnect (must match a saved entry)."),
107
+ }, async ({ site_url }) => {
108
+ const removed = removeSite(site_url);
109
+ if (!removed)
110
+ return failResult(Errors.siteNotFound(site_url));
111
+ return ok({
112
+ ok: true,
113
+ message: `Disconnected ${site_url}. The site itself is untouched — only the saved credentials were removed.`,
114
+ });
115
+ });
116
+ /* ─── get_license_status ──────────────────────────────────────────── */
117
+ server.tool("get_license_status", "Show the user's current SiteZen plan and quota. Useful before a conversion to confirm there are conversions remaining, or when the user asks 'what plan am I on'. Returns {plan, conversions_remaining, sites_used, sites_allowed, upgrade_url}.", {}, async () => {
118
+ const license = await validateLicense(process.env.SITEZEN_LICENSE_KEY);
119
+ if (!license)
120
+ return failResult(Errors.noLicenseKey());
121
+ const state = readState();
122
+ return ok({
123
+ ok: true,
124
+ plan: planLabel(license.plan),
125
+ conversions_remaining: license.conversions_remaining < 0 ? "unlimited" : license.conversions_remaining,
126
+ sites_used: state.sites.length,
127
+ sites_allowed: license.sites_allowed < 0 ? "unlimited" : license.sites_allowed,
128
+ upgrade_url: license.upgrade_url,
129
+ });
130
+ });
131
+ }