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/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