@wpconvert/mcp 0.1.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.
Files changed (3) hide show
  1. package/README.md +43 -0
  2. package/package.json +32 -0
  3. package/src/server.mjs +257 -0
package/README.md ADDED
@@ -0,0 +1,43 @@
1
+ # @wpconvert/mcp
2
+
3
+ A [Model Context Protocol](https://modelcontextprotocol.io) server for [WPConvert.ai](https://wpconvert.ai). It lets an AI agent (Cursor, Claude Desktop, etc.) convert the current workspace folder into a WordPress theme — no manual zipping required.
4
+
5
+ It's a thin wrapper over the same HTTP API and smart-zip logic as the `wpconvert` CLI.
6
+
7
+ > Requires Node.js >= 18 and a WPConvert API key (Pro/Agency or PAYG credits).
8
+
9
+ ## Tools
10
+
11
+ | Tool | Purpose |
12
+ | --- | --- |
13
+ | `wpconvert_convert_folder` | Zip a folder (excluding `node_modules`, build output, secrets) and start a conversion. Returns a `jobId`. |
14
+ | `wpconvert_check_status` | Poll a job's status (`queued` / `processing` / `done` / `failed`). |
15
+ | `wpconvert_download_result` | Download a completed conversion to disk. |
16
+ | `wpconvert_explain_failure` | Return the failure reason for a failed job. |
17
+ | `wpconvert_quota` | Show remaining conversions / credits. |
18
+
19
+ ## Configure (Cursor / Claude Desktop)
20
+
21
+ ```json
22
+ {
23
+ "mcpServers": {
24
+ "wpconvert": {
25
+ "command": "npx",
26
+ "args": ["-y", "@wpconvert/mcp"],
27
+ "env": {
28
+ "WPCONVERT_API_KEY": "wpc_live_xxx"
29
+ }
30
+ }
31
+ }
32
+ }
33
+ ```
34
+
35
+ Optional: set `WPCONVERT_API_BASE` to point at a non-default API host (testing).
36
+
37
+ ## Typical agent flow
38
+
39
+ 1. `wpconvert_convert_folder { "path": "./my-site", "type": "theme" }` → `jobId`
40
+ 2. `wpconvert_check_status { "jobId": "..." }` (repeat until `done`; conversions take a few minutes)
41
+ 3. `wpconvert_download_result { "jobId": "..." }` → saved theme `.zip`
42
+
43
+ Billing, quotas, and refunds are identical to the dashboard and CLI. Secrets are excluded from the zip by default (set `includeEnv: true` only if you truly need them).
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@wpconvert/mcp",
3
+ "version": "0.1.0",
4
+ "description": "Model Context Protocol server for WPConvert.ai — lets an AI agent convert the current workspace folder into a WordPress theme.",
5
+ "license": "MIT",
6
+ "author": "WPConvert.ai",
7
+ "type": "module",
8
+ "bin": {
9
+ "wpconvert-mcp": "src/server.mjs"
10
+ },
11
+ "files": [
12
+ "src/",
13
+ "README.md"
14
+ ],
15
+ "engines": {
16
+ "node": ">=18"
17
+ },
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "dependencies": {
22
+ "@modelcontextprotocol/sdk": "^1.0.0",
23
+ "wpconvert": "^0.1.0"
24
+ },
25
+ "keywords": [
26
+ "mcp",
27
+ "model-context-protocol",
28
+ "wordpress",
29
+ "wpconvert",
30
+ "ai"
31
+ ]
32
+ }
package/src/server.mjs ADDED
@@ -0,0 +1,257 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * WPConvert MCP server.
4
+ *
5
+ * A thin Model Context Protocol wrapper around the SAME HTTP API + smart-zip
6
+ * logic the `wpconvert` CLI uses. It lets an agent in Cursor/Claude convert the
7
+ * current workspace folder into a WordPress theme without the user ever making a
8
+ * zip.
9
+ *
10
+ * Auth: WPCONVERT_API_KEY env (required). Optional WPCONVERT_API_BASE override.
11
+ *
12
+ * Tools:
13
+ * - wpconvert_convert_folder zip a folder + start a conversion (returns jobId)
14
+ * - wpconvert_check_status poll a job's status
15
+ * - wpconvert_download_result download a completed conversion to disk
16
+ * - wpconvert_explain_failure return the failure reason for a failed job
17
+ * - wpconvert_quota show remaining conversions / credits
18
+ *
19
+ * NOTE: convert returns a jobId immediately rather than blocking; the agent should
20
+ * poll wpconvert_check_status until status is "done", then download. (Conversions
21
+ * can take several minutes.)
22
+ */
23
+
24
+ import { createRequire } from 'module';
25
+ import path from 'path';
26
+ import fs from 'fs';
27
+
28
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
29
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
30
+ import {
31
+ ListToolsRequestSchema,
32
+ CallToolRequestSchema,
33
+ } from '@modelcontextprotocol/sdk/types.js';
34
+
35
+ // Reuse the CLI's CJS modules (api client, smart-zip, ignore rules).
36
+ const require = createRequire(import.meta.url);
37
+ const api = require('wpconvert/src/api');
38
+ const { planZip, buildZipBuffer, formatBytes } = require('wpconvert/src/zip');
39
+ const { detectSiteRoot, BUILD_DIRS } = require('wpconvert/src/detect');
40
+
41
+ const MULTIPART_CAP_MB = 50;
42
+ const VALID_TYPES = ['theme']; // only "theme" is supported for now
43
+ const COMING_SOON_TYPES = ['elementor', 'gutenberg']; // not available via API/MCP yet
44
+
45
+ function ok(text) {
46
+ return { content: [{ type: 'text', text }] };
47
+ }
48
+ function fail(text) {
49
+ return { content: [{ type: 'text', text }], isError: true };
50
+ }
51
+
52
+ const TOOLS = [
53
+ {
54
+ name: 'wpconvert_convert_folder',
55
+ description:
56
+ 'Zip a local folder (excluding node_modules, build output, and secrets by default) and start a WordPress theme conversion. Returns a jobId; poll wpconvert_check_status until "done", then call wpconvert_download_result. Uses 1 credit on success.',
57
+ inputSchema: {
58
+ type: 'object',
59
+ properties: {
60
+ path: { type: 'string', description: 'Absolute or relative path to the folder to convert.' },
61
+ type: { type: 'string', enum: VALID_TYPES, description: 'Export type. Only "theme" is supported right now.' },
62
+ name: { type: 'string', description: 'Project name (defaults to folder name).' },
63
+ maxAssetSizeMB: { type: 'number', description: 'Exclude individual files larger than this many MB.' },
64
+ includeEnv: { type: 'boolean', description: 'DANGER: include .env / secret files (default false).' },
65
+ },
66
+ required: ['path'],
67
+ },
68
+ },
69
+ {
70
+ name: 'wpconvert_check_status',
71
+ description: 'Check the status of a conversion job. Returns status (queued/processing/done/failed) and progress.',
72
+ inputSchema: {
73
+ type: 'object',
74
+ properties: { jobId: { type: 'string' } },
75
+ required: ['jobId'],
76
+ },
77
+ },
78
+ {
79
+ name: 'wpconvert_download_result',
80
+ description: 'Download a completed conversion to disk. Returns the saved file path.',
81
+ inputSchema: {
82
+ type: 'object',
83
+ properties: {
84
+ jobId: { type: 'string' },
85
+ outDir: { type: 'string', description: 'Directory to save into (default: current directory).' },
86
+ },
87
+ required: ['jobId'],
88
+ },
89
+ },
90
+ {
91
+ name: 'wpconvert_explain_failure',
92
+ description: 'Return the failure reason for a failed conversion job.',
93
+ inputSchema: {
94
+ type: 'object',
95
+ properties: { jobId: { type: 'string' } },
96
+ required: ['jobId'],
97
+ },
98
+ },
99
+ {
100
+ name: 'wpconvert_quota',
101
+ description: 'Show the account\'s remaining conversions and PAYG credits.',
102
+ inputSchema: { type: 'object', properties: {} },
103
+ },
104
+ ];
105
+
106
+ async function convertFolder(args) {
107
+ const target = args.path;
108
+ if (!target) return fail('A folder "path" is required.');
109
+ let root = path.resolve(process.cwd(), target);
110
+ if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
111
+ return fail(`Not a directory: ${root}`);
112
+ }
113
+ const type = (args.type || 'theme').toLowerCase();
114
+ if (COMING_SOON_TYPES.includes(type)) {
115
+ return fail(`"${type}" conversions aren't available yet — only "theme" is supported right now. Elementor and Gutenberg are coming soon.`);
116
+ }
117
+ if (!VALID_TYPES.includes(type)) return fail(`Invalid type "${type}". Use: ${VALID_TYPES.join(', ')}.`);
118
+
119
+ // Project name from what was pointed at (not a build dir like "dist").
120
+ let nameBase = path.basename(root.replace(/\/+$/, '')) || 'project';
121
+ if (BUILD_DIRS.includes(nameBase)) nameBase = path.basename(path.dirname(root)) || nameBase;
122
+
123
+ // Auto-detect the real site root (root vs dist/build/...). On a project that
124
+ // needs building (or has no site), return a clear message so the agent can
125
+ // relay it to the user instead of uploading the wrong thing.
126
+ const detected = detectSiteRoot(root);
127
+ if (!detected.root) return fail(detected.message);
128
+ const detectNote = detected.note ? `${detected.note}\n` : '';
129
+ root = detected.root;
130
+
131
+ const projectName = args.name || nameBase;
132
+ const maxAssetSizeBytes = args.maxAssetSizeMB ? Math.round(args.maxAssetSizeMB * 1024 * 1024) : undefined;
133
+
134
+ const { files, excludedLarge, totalBytes } = planZip(root, {
135
+ includeEnv: !!args.includeEnv,
136
+ maxAssetSizeBytes,
137
+ });
138
+ if (files.length === 0) return fail('No files to upload after applying ignore rules.');
139
+
140
+ const zipBuffer = buildZipBuffer(files);
141
+ const zipMB = zipBuffer.length / (1024 * 1024);
142
+ const elementor = undefined; // Elementor/Gutenberg not available via MCP yet (guarded above)
143
+
144
+ let submit;
145
+ if (zipMB <= MULTIPART_CAP_MB) {
146
+ submit = await api.convertMultipart(zipBuffer, { projectName, exportType: type, elementor });
147
+ } else {
148
+ const up = await api.getUploadUrl();
149
+ if (up.maxSizeMB && zipMB > up.maxSizeMB) {
150
+ return fail(`Zip is ${zipMB.toFixed(1)}MB but your plan (${up.plan || 'current'}) allows up to ${up.maxSizeMB}MB. No credit was used.`);
151
+ }
152
+ await api.putToSignedUrl(up.signedUrl, zipBuffer);
153
+ submit = await api.createJobFromStorage(up.jobId, { projectName, exportType: type, elementor });
154
+ }
155
+
156
+ const jobId = submit.jobId || submit.project_id || submit.id;
157
+ const warn = excludedLarge.length ? ` (excluded ${excludedLarge.length} large file(s))` : '';
158
+ return ok(
159
+ detectNote +
160
+ `Conversion queued. jobId=${jobId}\n` +
161
+ `Zipped ${files.length} files (${formatBytes(totalBytes)} uncompressed)${warn}.\n` +
162
+ `Poll wpconvert_check_status with this jobId until status is "done", then wpconvert_download_result.`
163
+ );
164
+ }
165
+
166
+ function renderApiError(e) {
167
+ const d = (e && e.details) || {};
168
+ switch (e && e.code) {
169
+ case 'missing_credentials':
170
+ return 'No API key configured. Set WPCONVERT_API_KEY in the MCP server environment.';
171
+ case 'invalid_api_key':
172
+ return 'Invalid or revoked API key.';
173
+ case 'insufficient_credits':
174
+ return `Out of credits.${d.buy_credits_url ? ' Buy credits: ' + d.buy_credits_url : ''}`;
175
+ case 'quota_exceeded':
176
+ return 'Monthly quota exceeded. Buy credits or wait for reset.';
177
+ case 'upgrade_required':
178
+ return e.message || 'This feature requires the Pro plan or credits.';
179
+ case 'too_many_active_jobs':
180
+ return `Too many conversions in progress (${d.current ?? '?'}/${d.cap ?? '?'}). Wait and retry.`;
181
+ case 'rate_limited':
182
+ return `Rate limited.${d.retry_after ? ` Retry in ~${d.retry_after}s.` : ''}`;
183
+ default:
184
+ return (e && e.message) || 'Request failed.';
185
+ }
186
+ }
187
+
188
+ async function handleCall(name, args) {
189
+ switch (name) {
190
+ case 'wpconvert_convert_folder':
191
+ return convertFolder(args);
192
+
193
+ case 'wpconvert_check_status': {
194
+ const s = await api.getStatus(args.jobId);
195
+ return ok(`status=${s.status}${s.progress != null ? ` progress=${s.progress}%` : ''}` +
196
+ `${s.status === 'failed' && s.error ? `\nerror: ${s.error}` : ''}`);
197
+ }
198
+
199
+ case 'wpconvert_download_result': {
200
+ const info = await api.getDownload(args.jobId);
201
+ if (!info.download_url) return fail('No download URL available yet. Poll status until done.');
202
+ const dir = args.outDir ? path.resolve(process.cwd(), args.outDir) : process.cwd();
203
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
204
+ const fileName = info.name || `${args.jobId}-theme.zip`;
205
+ const outPath = path.join(dir, fileName);
206
+ const bytes = await api.fetchBinary(info.download_url);
207
+ fs.writeFileSync(outPath, bytes);
208
+ return ok(`Saved ${fileName} (${formatBytes(bytes.length)}) to ${outPath}`);
209
+ }
210
+
211
+ case 'wpconvert_explain_failure': {
212
+ const s = await api.getStatus(args.jobId);
213
+ if (s.status !== 'failed') return ok(`Job is not failed (status=${s.status}).`);
214
+ return ok(`Conversion failed: ${s.error || 'unknown error'}`);
215
+ }
216
+
217
+ case 'wpconvert_quota': {
218
+ const q = await api.getQuota();
219
+ return ok(
220
+ `plan=${q.effectivePlan || 'unknown'} used=${q.current ?? '?'}/${q.max ?? '?'} ` +
221
+ `remaining=${q.remaining ?? '?'}${q.payg_credits != null ? ` payg=${q.payg_credits}` : ''}`
222
+ );
223
+ }
224
+
225
+ default:
226
+ return fail(`Unknown tool: ${name}`);
227
+ }
228
+ }
229
+
230
+ async function main() {
231
+ const server = new Server(
232
+ { name: 'wpconvert-mcp', version: '0.1.0-beta.0' },
233
+ { capabilities: { tools: {} } }
234
+ );
235
+
236
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
237
+
238
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
239
+ const { name, arguments: args } = request.params;
240
+ try {
241
+ return await handleCall(name, args || {});
242
+ } catch (e) {
243
+ if (e && e.name === 'ApiError') return fail(renderApiError(e));
244
+ return fail(e && e.message ? e.message : String(e));
245
+ }
246
+ });
247
+
248
+ const transport = new StdioServerTransport();
249
+ await server.connect(transport);
250
+ // Stderr is safe for logs (stdout is the MCP transport).
251
+ console.error('wpconvert-mcp server running on stdio');
252
+ }
253
+
254
+ main().catch((e) => {
255
+ console.error('Fatal:', e);
256
+ process.exit(1);
257
+ });