zola-mcp 0.3.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/.claude-plugin/marketplace.json +29 -0
- package/.claude-plugin/plugin.json +16 -0
- package/.mcp.json +11 -0
- package/README.md +221 -0
- package/dist/bundle.js +23227 -0
- package/dist/client.js +194 -0
- package/dist/index.js +10 -0
- package/package.json +42 -0
- package/skills/zola/SKILL.md +53 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { dirname, join } from 'path';
|
|
2
|
+
import { fileURLToPath } from 'url';
|
|
3
|
+
try {
|
|
4
|
+
const { config } = await import('dotenv');
|
|
5
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
config({ path: join(__dirname, '..', '.env'), override: false });
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
// bundled mode — rely on process.env
|
|
10
|
+
}
|
|
11
|
+
const BASE_URL = 'https://www.zola.com';
|
|
12
|
+
const USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36';
|
|
13
|
+
// Session refresh: GET this endpoint with usr+guid cookies to obtain a fresh us
|
|
14
|
+
// token plus CSRF tokens in one round-trip. Zola auto-refreshes on this path.
|
|
15
|
+
const REFRESH_PATH = '/website-nav/web-api/v1/user/get';
|
|
16
|
+
function decodeJwtExp(token) {
|
|
17
|
+
const parts = token.split('.');
|
|
18
|
+
if (parts.length < 3 || !parts[1]) {
|
|
19
|
+
throw new Error(`Invalid JWT structure: expected 3 dot-separated parts, got ${parts.length}`);
|
|
20
|
+
}
|
|
21
|
+
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
|
|
22
|
+
if (typeof payload.exp !== 'number') {
|
|
23
|
+
throw new Error('JWT payload missing numeric "exp" claim');
|
|
24
|
+
}
|
|
25
|
+
return payload.exp;
|
|
26
|
+
}
|
|
27
|
+
function parseCookies(headers) {
|
|
28
|
+
const cookies = {};
|
|
29
|
+
const setCookieValues = typeof headers.getSetCookie === 'function'
|
|
30
|
+
? headers.getSetCookie()
|
|
31
|
+
: // Fallback for environments without getSetCookie(). The comma-split heuristic
|
|
32
|
+
// can misparse cookies whose values contain commas not preceded by a space,
|
|
33
|
+
// but this branch is never reached on Node 18.14+ which is our target runtime.
|
|
34
|
+
(headers.get('set-cookie') ?? '').split(/,(?=[^ ])/).filter(Boolean);
|
|
35
|
+
for (const raw of setCookieValues) {
|
|
36
|
+
const match = raw.match(/^([^=]+)=([^;]*)/);
|
|
37
|
+
if (match)
|
|
38
|
+
cookies[match[1].trim()] = match[2].trim();
|
|
39
|
+
}
|
|
40
|
+
return cookies;
|
|
41
|
+
}
|
|
42
|
+
const SETUP_HINT = 'To fix: open Zola in Chrome → DevTools → Application → Cookies → www.zola.com → ' +
|
|
43
|
+
'copy "usr" → ZOLA_REFRESH_TOKEN and "guid" → ZOLA_GUID in .env';
|
|
44
|
+
export class ZolaClient {
|
|
45
|
+
sessionToken = null;
|
|
46
|
+
sessionExpiry = null;
|
|
47
|
+
csrfSecret = null;
|
|
48
|
+
csrfToken = null;
|
|
49
|
+
async request(method, path, body) {
|
|
50
|
+
await this.ensureSession();
|
|
51
|
+
if (method !== 'GET')
|
|
52
|
+
await this.ensureCsrf();
|
|
53
|
+
return this.doRequest(method, path, body);
|
|
54
|
+
}
|
|
55
|
+
async doRequest(method, path, body, isAuthRetry = false, isRateRetry = false) {
|
|
56
|
+
const headers = {
|
|
57
|
+
accept: 'application/json',
|
|
58
|
+
'user-agent': USER_AGENT,
|
|
59
|
+
cookie: this.buildCookieHeader(),
|
|
60
|
+
};
|
|
61
|
+
if (body !== undefined)
|
|
62
|
+
headers['content-type'] = 'application/json';
|
|
63
|
+
if (method !== 'GET' && this.csrfToken) {
|
|
64
|
+
headers['x-csrf-token'] = this.csrfToken;
|
|
65
|
+
}
|
|
66
|
+
const response = await fetch(`${BASE_URL}${path}`, {
|
|
67
|
+
method,
|
|
68
|
+
headers,
|
|
69
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
70
|
+
});
|
|
71
|
+
if (response.status === 401 && !isAuthRetry) {
|
|
72
|
+
this.sessionToken = null;
|
|
73
|
+
this.sessionExpiry = null;
|
|
74
|
+
await this.refresh();
|
|
75
|
+
return this.doRequest(method, path, body, true, isRateRetry);
|
|
76
|
+
}
|
|
77
|
+
if (response.status === 429) {
|
|
78
|
+
if (!isRateRetry) {
|
|
79
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
80
|
+
return this.doRequest(method, path, body, isAuthRetry, true);
|
|
81
|
+
}
|
|
82
|
+
throw new Error('Rate limited by Zola API');
|
|
83
|
+
}
|
|
84
|
+
if (!response.ok) {
|
|
85
|
+
throw new Error(`Zola API error: ${response.status} ${response.statusText} for ${method} ${path}`);
|
|
86
|
+
}
|
|
87
|
+
const text = await response.text();
|
|
88
|
+
return (text ? JSON.parse(text) : null);
|
|
89
|
+
}
|
|
90
|
+
async ensureSession() {
|
|
91
|
+
// Session still valid with comfortable margin
|
|
92
|
+
if (this.sessionToken && this.sessionExpiry) {
|
|
93
|
+
if (this.sessionExpiry.getTime() - Date.now() > 5 * 60 * 1000)
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
// Note: ZOLA_SESSION_TOKEN is read from env only on first load (sessionToken === null).
|
|
97
|
+
// If the env value is rotated mid-run, restart the server to pick up the new token.
|
|
98
|
+
if (this.sessionToken === null) {
|
|
99
|
+
const envSession = process.env.ZOLA_SESSION_TOKEN;
|
|
100
|
+
if (envSession) {
|
|
101
|
+
try {
|
|
102
|
+
const exp = decodeJwtExp(envSession);
|
|
103
|
+
if (exp * 1000 - Date.now() > 5 * 60 * 1000) {
|
|
104
|
+
this.sessionToken = envSession;
|
|
105
|
+
this.sessionExpiry = new Date(exp * 1000);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// Invalid JWT in env — fall through to refresh
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
await this.refresh();
|
|
115
|
+
}
|
|
116
|
+
async ensureCsrf() {
|
|
117
|
+
if (this.csrfToken)
|
|
118
|
+
return;
|
|
119
|
+
// CSRF tokens are normally obtained during refresh(). This fallback handles
|
|
120
|
+
// the case where a valid ZOLA_SESSION_TOKEN was used and refresh() was skipped.
|
|
121
|
+
try {
|
|
122
|
+
const resp = await fetch(`${BASE_URL}/`, {
|
|
123
|
+
headers: { 'user-agent': USER_AGENT },
|
|
124
|
+
});
|
|
125
|
+
const cookies = parseCookies(resp.headers);
|
|
126
|
+
if (cookies['_csrf'])
|
|
127
|
+
this.csrfSecret = cookies['_csrf'];
|
|
128
|
+
if (cookies['CSRF-TOKEN'])
|
|
129
|
+
this.csrfToken = cookies['CSRF-TOKEN'];
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
// non-fatal: proceed without CSRF
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
async refresh() {
|
|
136
|
+
const refreshToken = process.env.ZOLA_REFRESH_TOKEN;
|
|
137
|
+
if (!refreshToken)
|
|
138
|
+
throw new Error('ZOLA_REFRESH_TOKEN must be set');
|
|
139
|
+
const guid = process.env.ZOLA_GUID;
|
|
140
|
+
if (!guid)
|
|
141
|
+
throw new Error('ZOLA_GUID must be set');
|
|
142
|
+
// GET /website-nav/web-api/v1/user/get with usr+guid — Zola auto-issues a
|
|
143
|
+
// fresh us token and CSRF cookies in the Set-Cookie response headers.
|
|
144
|
+
const cookieParts = [`usr=${refreshToken}`, `guid=${guid}`];
|
|
145
|
+
if (this.sessionToken)
|
|
146
|
+
cookieParts.push(`us=${this.sessionToken}`);
|
|
147
|
+
let resp;
|
|
148
|
+
try {
|
|
149
|
+
resp = await fetch(`${BASE_URL}${REFRESH_PATH}`, {
|
|
150
|
+
headers: {
|
|
151
|
+
accept: 'application/json',
|
|
152
|
+
'user-agent': USER_AGENT,
|
|
153
|
+
cookie: cookieParts.join('; '),
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
throw new Error(`Zola session refresh network error: ${String(err)}\n${SETUP_HINT}`);
|
|
159
|
+
}
|
|
160
|
+
if (!resp.ok) {
|
|
161
|
+
throw new Error(`Zola session refresh failed: ${resp.status} ${resp.statusText}\n${SETUP_HINT}`);
|
|
162
|
+
}
|
|
163
|
+
const cookies = parseCookies(resp.headers);
|
|
164
|
+
if (!cookies['us']) {
|
|
165
|
+
throw new Error('Zola session refresh returned no session token — ZOLA_REFRESH_TOKEN or ZOLA_GUID may be expired.\n' +
|
|
166
|
+
SETUP_HINT);
|
|
167
|
+
}
|
|
168
|
+
const exp = decodeJwtExp(cookies['us']);
|
|
169
|
+
this.sessionToken = cookies['us'];
|
|
170
|
+
this.sessionExpiry = new Date(exp * 1000);
|
|
171
|
+
// Capture CSRF tokens from the same response to avoid a separate GET /
|
|
172
|
+
if (cookies['_csrf'])
|
|
173
|
+
this.csrfSecret = cookies['_csrf'];
|
|
174
|
+
if (cookies['CSRF-TOKEN'])
|
|
175
|
+
this.csrfToken = cookies['CSRF-TOKEN'];
|
|
176
|
+
}
|
|
177
|
+
buildCookieHeader() {
|
|
178
|
+
const parts = [];
|
|
179
|
+
if (this.sessionToken)
|
|
180
|
+
parts.push(`us=${this.sessionToken}`);
|
|
181
|
+
const refreshToken = process.env.ZOLA_REFRESH_TOKEN;
|
|
182
|
+
if (refreshToken)
|
|
183
|
+
parts.push(`usr=${refreshToken}`);
|
|
184
|
+
const guid = process.env.ZOLA_GUID;
|
|
185
|
+
if (guid)
|
|
186
|
+
parts.push(`guid=${guid}`);
|
|
187
|
+
if (this.csrfSecret)
|
|
188
|
+
parts.push(`_csrf=${this.csrfSecret}`);
|
|
189
|
+
if (this.csrfToken)
|
|
190
|
+
parts.push(`CSRF-TOKEN=${this.csrfToken}`);
|
|
191
|
+
return parts.join('; ');
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
export const client = new ZolaClient();
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
+
const server = new McpServer({
|
|
4
|
+
name: 'zola-mcp',
|
|
5
|
+
version: '0.3.0',
|
|
6
|
+
});
|
|
7
|
+
// Domain tool registrations are added here as each domain is built.
|
|
8
|
+
// Example (future): registerRegistryTools(server);
|
|
9
|
+
const transport = new StdioServerTransport();
|
|
10
|
+
await server.connect(transport);
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "zola-mcp",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Zola wedding MCP server for Claude",
|
|
5
|
+
"author": "Claude Sonnet 4.6 (AI) <https://www.anthropic.com/claude>",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/chrischall/zola-mcp.git"
|
|
9
|
+
},
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
".claude-plugin",
|
|
14
|
+
"skills",
|
|
15
|
+
".mcp.json"
|
|
16
|
+
],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=20.6.0"
|
|
19
|
+
},
|
|
20
|
+
"type": "module",
|
|
21
|
+
"bin": {
|
|
22
|
+
"zola-mcp": "dist/index.js"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "tsc && npm run bundle",
|
|
26
|
+
"bundle": "esbuild src/index.ts --bundle --platform=node --format=esm --external:dotenv --outfile=dist/bundle.js",
|
|
27
|
+
"dev": "node --env-file=.env dist/index.js",
|
|
28
|
+
"test": "vitest run",
|
|
29
|
+
"test:watch": "vitest"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
33
|
+
"dotenv": "^17.4.0"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/node": "^25.5.0",
|
|
37
|
+
"@vitest/coverage-v8": "^4.1.2",
|
|
38
|
+
"esbuild": "^0.27.5",
|
|
39
|
+
"typescript": "^6.0.2",
|
|
40
|
+
"vitest": "^4.1.2"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: zola
|
|
3
|
+
description: This skill should be used when the user asks about Zola wedding planning data. Triggers on phrases like "check Zola", "Zola vendors", "wedding budget", "Zola guests", "RSVP status", "seating chart", "vendor inquiries", "wedding registry", "gift tracker", or any request involving wedding vendors, guest list, budget, seating, events, registry, or inquiry management on Zola.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# zola-mcp
|
|
7
|
+
|
|
8
|
+
MCP server for Zola — 27 tools for managing your entire wedding via the Zola mobile API.
|
|
9
|
+
|
|
10
|
+
## Tools
|
|
11
|
+
|
|
12
|
+
### Vendors
|
|
13
|
+
- `list_vendors` — List all booked vendors
|
|
14
|
+
- `search_vendors` — Search vendors by name/category
|
|
15
|
+
- `add_vendor` — Book a new vendor
|
|
16
|
+
- `update_vendor` — Update vendor details
|
|
17
|
+
- `remove_vendor` — Unbook a vendor
|
|
18
|
+
|
|
19
|
+
### Budget
|
|
20
|
+
- `get_budget` — Budget summary with all items
|
|
21
|
+
- `update_budget_item` — Update cost or note
|
|
22
|
+
|
|
23
|
+
### Guests
|
|
24
|
+
- `list_guests` — List all guest groups with stats
|
|
25
|
+
- `add_guest` — Add a guest group
|
|
26
|
+
- `update_guest_address` — Update mailing address
|
|
27
|
+
- `remove_guest` — Remove a guest group
|
|
28
|
+
|
|
29
|
+
### Seating
|
|
30
|
+
- `list_seating_charts` — List charts
|
|
31
|
+
- `get_seating_chart` — Chart with tables/seats/occupants
|
|
32
|
+
- `list_unseated_guests` — Guests not yet seated
|
|
33
|
+
- `assign_seat` — Assign guest to seat
|
|
34
|
+
|
|
35
|
+
### Inquiries
|
|
36
|
+
- `list_inquiries` — All vendor inquiries
|
|
37
|
+
- `get_inquiry_conversation` — Full conversation
|
|
38
|
+
- `mark_inquiry_read` — Mark as read
|
|
39
|
+
|
|
40
|
+
### Events & RSVPs
|
|
41
|
+
- `list_events` — All events with RSVP counts
|
|
42
|
+
- `track_rsvps` — RSVP tracking per event
|
|
43
|
+
- `update_event` — Update event details
|
|
44
|
+
|
|
45
|
+
### Registry & Gifts
|
|
46
|
+
- `get_registry` — Registry categories and items
|
|
47
|
+
- `get_gift_tracker` — Gifts received and thank-you status
|
|
48
|
+
|
|
49
|
+
### Discovery
|
|
50
|
+
- `get_wedding_dashboard` — Planning overview
|
|
51
|
+
- `search_storefronts` — Search marketplace
|
|
52
|
+
- `get_storefront` — Full vendor details
|
|
53
|
+
- `list_favorites` — Saved vendors
|