@local-labs-jpollock/local-cli 0.0.1

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 (86) hide show
  1. package/addon-dist/bin/mcp-stdio.js +2808 -0
  2. package/addon-dist/lib/common/constants.d.ts +22 -0
  3. package/addon-dist/lib/common/constants.js +26 -0
  4. package/addon-dist/lib/common/theme.d.ts +68 -0
  5. package/addon-dist/lib/common/theme.js +126 -0
  6. package/addon-dist/lib/common/types.d.ts +298 -0
  7. package/addon-dist/lib/common/types.js +6 -0
  8. package/addon-dist/lib/main/config/ConnectionInfo.d.ts +25 -0
  9. package/addon-dist/lib/main/config/ConnectionInfo.js +82 -0
  10. package/addon-dist/lib/main/index.d.ts +12 -0
  11. package/addon-dist/lib/main/index.js +3322 -0
  12. package/addon-dist/lib/main/mcp/McpAuth.d.ts +37 -0
  13. package/addon-dist/lib/main/mcp/McpAuth.js +87 -0
  14. package/addon-dist/lib/main/mcp/McpServer.d.ts +67 -0
  15. package/addon-dist/lib/main/mcp/McpServer.js +343 -0
  16. package/addon-dist/lib/main/mcp/tools/changePhpVersion.d.ts +7 -0
  17. package/addon-dist/lib/main/mcp/tools/changePhpVersion.js +81 -0
  18. package/addon-dist/lib/main/mcp/tools/cloneSite.d.ts +7 -0
  19. package/addon-dist/lib/main/mcp/tools/cloneSite.js +66 -0
  20. package/addon-dist/lib/main/mcp/tools/createSite.d.ts +7 -0
  21. package/addon-dist/lib/main/mcp/tools/createSite.js +137 -0
  22. package/addon-dist/lib/main/mcp/tools/deleteSite.d.ts +7 -0
  23. package/addon-dist/lib/main/mcp/tools/deleteSite.js +72 -0
  24. package/addon-dist/lib/main/mcp/tools/exportDatabase.d.ts +7 -0
  25. package/addon-dist/lib/main/mcp/tools/exportDatabase.js +72 -0
  26. package/addon-dist/lib/main/mcp/tools/exportSite.d.ts +7 -0
  27. package/addon-dist/lib/main/mcp/tools/exportSite.js +103 -0
  28. package/addon-dist/lib/main/mcp/tools/getLocalInfo.d.ts +7 -0
  29. package/addon-dist/lib/main/mcp/tools/getLocalInfo.js +72 -0
  30. package/addon-dist/lib/main/mcp/tools/getSite.d.ts +7 -0
  31. package/addon-dist/lib/main/mcp/tools/getSite.js +68 -0
  32. package/addon-dist/lib/main/mcp/tools/getSiteLogs.d.ts +7 -0
  33. package/addon-dist/lib/main/mcp/tools/getSiteLogs.js +149 -0
  34. package/addon-dist/lib/main/mcp/tools/helpers.d.ts +59 -0
  35. package/addon-dist/lib/main/mcp/tools/helpers.js +179 -0
  36. package/addon-dist/lib/main/mcp/tools/importDatabase.d.ts +7 -0
  37. package/addon-dist/lib/main/mcp/tools/importDatabase.js +109 -0
  38. package/addon-dist/lib/main/mcp/tools/importSite.d.ts +7 -0
  39. package/addon-dist/lib/main/mcp/tools/importSite.js +149 -0
  40. package/addon-dist/lib/main/mcp/tools/index.d.ts +26 -0
  41. package/addon-dist/lib/main/mcp/tools/index.js +117 -0
  42. package/addon-dist/lib/main/mcp/tools/listBlueprints.d.ts +7 -0
  43. package/addon-dist/lib/main/mcp/tools/listBlueprints.js +54 -0
  44. package/addon-dist/lib/main/mcp/tools/listServices.d.ts +7 -0
  45. package/addon-dist/lib/main/mcp/tools/listServices.js +112 -0
  46. package/addon-dist/lib/main/mcp/tools/listSites.d.ts +7 -0
  47. package/addon-dist/lib/main/mcp/tools/listSites.js +62 -0
  48. package/addon-dist/lib/main/mcp/tools/openAdminer.d.ts +7 -0
  49. package/addon-dist/lib/main/mcp/tools/openAdminer.js +59 -0
  50. package/addon-dist/lib/main/mcp/tools/openSite.d.ts +7 -0
  51. package/addon-dist/lib/main/mcp/tools/openSite.js +62 -0
  52. package/addon-dist/lib/main/mcp/tools/renameSite.d.ts +7 -0
  53. package/addon-dist/lib/main/mcp/tools/renameSite.js +70 -0
  54. package/addon-dist/lib/main/mcp/tools/restartSite.d.ts +7 -0
  55. package/addon-dist/lib/main/mcp/tools/restartSite.js +56 -0
  56. package/addon-dist/lib/main/mcp/tools/saveBlueprint.d.ts +7 -0
  57. package/addon-dist/lib/main/mcp/tools/saveBlueprint.js +89 -0
  58. package/addon-dist/lib/main/mcp/tools/startSite.d.ts +7 -0
  59. package/addon-dist/lib/main/mcp/tools/startSite.js +54 -0
  60. package/addon-dist/lib/main/mcp/tools/stopSite.d.ts +7 -0
  61. package/addon-dist/lib/main/mcp/tools/stopSite.js +54 -0
  62. package/addon-dist/lib/main/mcp/tools/toggleXdebug.d.ts +7 -0
  63. package/addon-dist/lib/main/mcp/tools/toggleXdebug.js +69 -0
  64. package/addon-dist/lib/main/mcp/tools/trustSsl.d.ts +7 -0
  65. package/addon-dist/lib/main/mcp/tools/trustSsl.js +59 -0
  66. package/addon-dist/lib/main/mcp/tools/wpCli.d.ts +7 -0
  67. package/addon-dist/lib/main/mcp/tools/wpCli.js +110 -0
  68. package/addon-dist/lib/main.d.ts +1 -0
  69. package/addon-dist/lib/main.js +10 -0
  70. package/addon-dist/lib/renderer/index.d.ts +7 -0
  71. package/addon-dist/lib/renderer/index.js +479 -0
  72. package/addon-dist/package.json +73 -0
  73. package/bin/lwp.js +10 -0
  74. package/lib/bootstrap/index.d.ts +98 -0
  75. package/lib/bootstrap/index.js +493 -0
  76. package/lib/bootstrap/paths.d.ts +28 -0
  77. package/lib/bootstrap/paths.js +96 -0
  78. package/lib/client/GraphQLClient.d.ts +38 -0
  79. package/lib/client/GraphQLClient.js +71 -0
  80. package/lib/client/index.d.ts +4 -0
  81. package/lib/client/index.js +10 -0
  82. package/lib/formatters/index.d.ts +75 -0
  83. package/lib/formatters/index.js +139 -0
  84. package/lib/index.d.ts +8 -0
  85. package/lib/index.js +1173 -0
  86. package/package.json +72 -0
@@ -0,0 +1,2808 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Local MCP Server - stdio transport
4
+ *
5
+ * This script acts as an MCP server using stdio transport,
6
+ * bridging to Local's GraphQL API for site management.
7
+ */
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const os = require('os');
12
+ const readline = require('readline');
13
+ const http = require('http');
14
+ const https = require('https');
15
+ const { URL } = require('url');
16
+
17
+ // Get Local's GraphQL connection info
18
+ function getGraphQLConnectionInfo() {
19
+ const platform = os.platform();
20
+ let configPath;
21
+
22
+ switch (platform) {
23
+ case 'darwin':
24
+ configPath = path.join(os.homedir(), 'Library', 'Application Support', 'Local', 'graphql-connection-info.json');
25
+ break;
26
+ case 'win32':
27
+ configPath = path.join(process.env.APPDATA || '', 'Local', 'graphql-connection-info.json');
28
+ break;
29
+ default:
30
+ configPath = path.join(os.homedir(), '.config', 'Local', 'graphql-connection-info.json');
31
+ }
32
+
33
+ try {
34
+ const content = fs.readFileSync(configPath, 'utf-8');
35
+ return JSON.parse(content);
36
+ } catch (err) {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ // Make GraphQL request to Local (compatible with Node.js < 18)
42
+ async function graphqlRequest(query, variables = {}) {
43
+ const info = getGraphQLConnectionInfo();
44
+ if (!info) {
45
+ throw new Error('Local is not running or GraphQL connection info not found');
46
+ }
47
+
48
+ const body = JSON.stringify({ query, variables });
49
+ const url = new URL(info.url);
50
+ const isHttps = url.protocol === 'https:';
51
+ const httpModule = isHttps ? https : http;
52
+
53
+ return new Promise((resolve, reject) => {
54
+ const req = httpModule.request({
55
+ hostname: url.hostname,
56
+ port: url.port || (isHttps ? 443 : 80),
57
+ path: url.pathname,
58
+ method: 'POST',
59
+ headers: {
60
+ 'Content-Type': 'application/json',
61
+ 'Content-Length': Buffer.byteLength(body),
62
+ 'Authorization': `Bearer ${info.authToken}`,
63
+ },
64
+ }, (res) => {
65
+ let data = '';
66
+ res.on('data', chunk => data += chunk);
67
+ res.on('end', () => {
68
+ if (res.statusCode < 200 || res.statusCode >= 300) {
69
+ reject(new Error(`GraphQL request failed: ${res.statusCode}`));
70
+ return;
71
+ }
72
+ try {
73
+ const result = JSON.parse(data);
74
+ if (result.errors) {
75
+ reject(new Error(result.errors[0].message));
76
+ return;
77
+ }
78
+ resolve(result.data);
79
+ } catch (err) {
80
+ reject(new Error(`Failed to parse response: ${err.message}`));
81
+ }
82
+ });
83
+ });
84
+
85
+ req.on('error', reject);
86
+ req.write(body);
87
+ req.end();
88
+ });
89
+ }
90
+
91
+ // Tool definitions
92
+ const tools = [
93
+ {
94
+ name: 'list_sites',
95
+ description: 'List all WordPress sites in Local with their name, ID, status, and domain',
96
+ inputSchema: {
97
+ type: 'object',
98
+ properties: {
99
+ status: {
100
+ type: 'string',
101
+ enum: ['all', 'running', 'stopped'],
102
+ description: 'Filter sites by status (default: all)',
103
+ },
104
+ },
105
+ },
106
+ },
107
+ {
108
+ name: 'get_site',
109
+ description: 'Get detailed information about a WordPress site',
110
+ inputSchema: {
111
+ type: 'object',
112
+ properties: {
113
+ site: {
114
+ type: 'string',
115
+ description: 'Site name or ID',
116
+ },
117
+ },
118
+ required: ['site'],
119
+ },
120
+ },
121
+ {
122
+ name: 'start_site',
123
+ description: 'Start a WordPress site',
124
+ inputSchema: {
125
+ type: 'object',
126
+ properties: {
127
+ site: {
128
+ type: 'string',
129
+ description: 'Site name or ID',
130
+ },
131
+ },
132
+ required: ['site'],
133
+ },
134
+ },
135
+ {
136
+ name: 'stop_site',
137
+ description: 'Stop a running WordPress site',
138
+ inputSchema: {
139
+ type: 'object',
140
+ properties: {
141
+ site: {
142
+ type: 'string',
143
+ description: 'Site name or ID',
144
+ },
145
+ },
146
+ required: ['site'],
147
+ },
148
+ },
149
+ {
150
+ name: 'restart_site',
151
+ description: 'Restart a WordPress site (stops then starts it)',
152
+ inputSchema: {
153
+ type: 'object',
154
+ properties: {
155
+ site: {
156
+ type: 'string',
157
+ description: 'Site name or ID',
158
+ },
159
+ },
160
+ required: ['site'],
161
+ },
162
+ },
163
+ {
164
+ name: 'wp_cli',
165
+ description: 'Run a WP-CLI command against a WordPress site. Site will be auto-started if not running.',
166
+ inputSchema: {
167
+ type: 'object',
168
+ properties: {
169
+ site: {
170
+ type: 'string',
171
+ description: 'Site name or ID',
172
+ },
173
+ command: {
174
+ type: 'array',
175
+ items: { type: 'string' },
176
+ description: 'WP-CLI command as array, e.g. ["plugin", "list", "--format=json"]',
177
+ },
178
+ },
179
+ required: ['site', 'command'],
180
+ },
181
+ },
182
+ {
183
+ name: 'create_site',
184
+ description: 'Create a new WordPress site in Local. The site will be created and started automatically. Optionally create from an existing blueprint.',
185
+ inputSchema: {
186
+ type: 'object',
187
+ properties: {
188
+ name: {
189
+ type: 'string',
190
+ description: 'Site name (required)',
191
+ },
192
+ php_version: {
193
+ type: 'string',
194
+ description: 'PHP version (e.g., "8.2.10"). Uses Local default if not specified.',
195
+ },
196
+ blueprint: {
197
+ type: 'string',
198
+ description: 'Blueprint name to create the site from. Use list_blueprints to see available blueprints.',
199
+ },
200
+ },
201
+ required: ['name'],
202
+ },
203
+ },
204
+ {
205
+ name: 'delete_site',
206
+ description: 'Delete a WordPress site. Requires confirm=true as a safety measure.',
207
+ inputSchema: {
208
+ type: 'object',
209
+ properties: {
210
+ site: {
211
+ type: 'string',
212
+ description: 'Site name or ID',
213
+ },
214
+ confirm: {
215
+ type: 'boolean',
216
+ description: 'Must be true to confirm deletion',
217
+ },
218
+ },
219
+ required: ['site', 'confirm'],
220
+ },
221
+ },
222
+ {
223
+ name: 'get_local_info',
224
+ description: 'Get information about Local installation including version, platform, and available tools',
225
+ inputSchema: {
226
+ type: 'object',
227
+ properties: {},
228
+ },
229
+ },
230
+ {
231
+ name: 'open_site',
232
+ description: 'Open a WordPress site in the default browser. Site will be auto-started if not running.',
233
+ inputSchema: {
234
+ type: 'object',
235
+ properties: {
236
+ site: {
237
+ type: 'string',
238
+ description: 'Site name or ID',
239
+ },
240
+ path: {
241
+ type: 'string',
242
+ description: 'Path to open (default: /, use /wp-admin for admin panel)',
243
+ },
244
+ },
245
+ required: ['site'],
246
+ },
247
+ },
248
+ {
249
+ name: 'clone_site',
250
+ description: 'Clone an existing WordPress site to create a copy with a new name',
251
+ inputSchema: {
252
+ type: 'object',
253
+ properties: {
254
+ site: {
255
+ type: 'string',
256
+ description: 'Site name or ID to clone',
257
+ },
258
+ new_name: {
259
+ type: 'string',
260
+ description: 'Name for the cloned site',
261
+ },
262
+ },
263
+ required: ['site', 'new_name'],
264
+ },
265
+ },
266
+ {
267
+ name: 'export_site',
268
+ description: 'Export a WordPress site to a zip file. Site will be auto-started if not running.',
269
+ inputSchema: {
270
+ type: 'object',
271
+ properties: {
272
+ site: {
273
+ type: 'string',
274
+ description: 'Site name or ID to export',
275
+ },
276
+ output_path: {
277
+ type: 'string',
278
+ description: 'Output directory path (default: ~/Downloads)',
279
+ },
280
+ },
281
+ required: ['site'],
282
+ },
283
+ },
284
+ {
285
+ name: 'list_blueprints',
286
+ description: 'List all available site blueprints (templates)',
287
+ inputSchema: {
288
+ type: 'object',
289
+ properties: {},
290
+ },
291
+ },
292
+ {
293
+ name: 'save_blueprint',
294
+ description: 'Save a site as a blueprint (template) for creating new sites. Site will be auto-started if not running.',
295
+ inputSchema: {
296
+ type: 'object',
297
+ properties: {
298
+ site: {
299
+ type: 'string',
300
+ description: 'Site name or ID to save as blueprint',
301
+ },
302
+ name: {
303
+ type: 'string',
304
+ description: 'Name for the blueprint',
305
+ },
306
+ },
307
+ required: ['site', 'name'],
308
+ },
309
+ },
310
+ // Phase 8: WordPress Development Tools
311
+ {
312
+ name: 'export_database',
313
+ description: 'Export a site database to a SQL file. Site will be auto-started if not running.',
314
+ inputSchema: {
315
+ type: 'object',
316
+ properties: {
317
+ site: {
318
+ type: 'string',
319
+ description: 'Site name or ID',
320
+ },
321
+ output_path: {
322
+ type: 'string',
323
+ description: 'Output file path (default: ~/Downloads/<site-name>.sql)',
324
+ },
325
+ },
326
+ required: ['site'],
327
+ },
328
+ },
329
+ {
330
+ name: 'import_database',
331
+ description: 'Import a SQL file into a site database. Site will be auto-started if not running.',
332
+ inputSchema: {
333
+ type: 'object',
334
+ properties: {
335
+ site: {
336
+ type: 'string',
337
+ description: 'Site name or ID',
338
+ },
339
+ sql_path: {
340
+ type: 'string',
341
+ description: 'Path to the SQL file to import',
342
+ },
343
+ },
344
+ required: ['site', 'sql_path'],
345
+ },
346
+ },
347
+ {
348
+ name: 'open_adminer',
349
+ description: 'Open Adminer database management UI for a site. Site will be auto-started if not running.',
350
+ inputSchema: {
351
+ type: 'object',
352
+ properties: {
353
+ site: {
354
+ type: 'string',
355
+ description: 'Site name or ID',
356
+ },
357
+ },
358
+ required: ['site'],
359
+ },
360
+ },
361
+ {
362
+ name: 'trust_ssl',
363
+ description: 'Trust the SSL certificate for a site (may require admin password)',
364
+ inputSchema: {
365
+ type: 'object',
366
+ properties: {
367
+ site: {
368
+ type: 'string',
369
+ description: 'Site name or ID',
370
+ },
371
+ },
372
+ required: ['site'],
373
+ },
374
+ },
375
+ {
376
+ name: 'rename_site',
377
+ description: 'Rename a WordPress site',
378
+ inputSchema: {
379
+ type: 'object',
380
+ properties: {
381
+ site: {
382
+ type: 'string',
383
+ description: 'Current site name or ID',
384
+ },
385
+ new_name: {
386
+ type: 'string',
387
+ description: 'New name for the site',
388
+ },
389
+ },
390
+ required: ['site', 'new_name'],
391
+ },
392
+ },
393
+ {
394
+ name: 'change_php_version',
395
+ description: 'Change the PHP version for a site',
396
+ inputSchema: {
397
+ type: 'object',
398
+ properties: {
399
+ site: {
400
+ type: 'string',
401
+ description: 'Site name or ID',
402
+ },
403
+ php_version: {
404
+ type: 'string',
405
+ description: 'Target PHP version (e.g., "8.2.10", "8.1.27")',
406
+ },
407
+ },
408
+ required: ['site', 'php_version'],
409
+ },
410
+ },
411
+ {
412
+ name: 'import_site',
413
+ description: 'Import a WordPress site from a zip file',
414
+ inputSchema: {
415
+ type: 'object',
416
+ properties: {
417
+ zip_path: {
418
+ type: 'string',
419
+ description: 'Path to the zip file to import',
420
+ },
421
+ site_name: {
422
+ type: 'string',
423
+ description: 'Name for the imported site (optional)',
424
+ },
425
+ },
426
+ required: ['zip_path'],
427
+ },
428
+ },
429
+ // Phase 9: Site Configuration & Dev Tools
430
+ {
431
+ name: 'toggle_xdebug',
432
+ description: 'Enable or disable Xdebug for a site',
433
+ inputSchema: {
434
+ type: 'object',
435
+ properties: {
436
+ site: {
437
+ type: 'string',
438
+ description: 'Site name or ID',
439
+ },
440
+ enabled: {
441
+ type: 'boolean',
442
+ description: 'True to enable, false to disable',
443
+ },
444
+ },
445
+ required: ['site', 'enabled'],
446
+ },
447
+ },
448
+ {
449
+ name: 'get_site_logs',
450
+ description: 'Get log file contents for a site',
451
+ inputSchema: {
452
+ type: 'object',
453
+ properties: {
454
+ site: {
455
+ type: 'string',
456
+ description: 'Site name or ID',
457
+ },
458
+ log_type: {
459
+ type: 'string',
460
+ enum: ['php', 'nginx', 'mysql', 'all'],
461
+ description: 'Type of logs to retrieve (default: php)',
462
+ },
463
+ lines: {
464
+ type: 'number',
465
+ description: 'Number of lines to return (default: 100)',
466
+ },
467
+ },
468
+ required: ['site'],
469
+ },
470
+ },
471
+ {
472
+ name: 'list_services',
473
+ description: 'List available service versions (PHP, MySQL, Nginx)',
474
+ inputSchema: {
475
+ type: 'object',
476
+ properties: {
477
+ type: {
478
+ type: 'string',
479
+ enum: ['php', 'database', 'webserver', 'all'],
480
+ description: 'Filter by service type (default: all)',
481
+ },
482
+ },
483
+ },
484
+ },
485
+ // Phase 10: Cloud Backups
486
+ {
487
+ name: 'backup_status',
488
+ description: 'Check if cloud backups are available and authenticated. Shows Dropbox and Google Drive status.',
489
+ inputSchema: {
490
+ type: 'object',
491
+ properties: {},
492
+ },
493
+ },
494
+ {
495
+ name: 'list_backups',
496
+ description: 'List all cloud backups for a site from Dropbox or Google Drive',
497
+ inputSchema: {
498
+ type: 'object',
499
+ properties: {
500
+ site: {
501
+ type: 'string',
502
+ description: 'Site name or ID',
503
+ },
504
+ provider: {
505
+ type: 'string',
506
+ enum: ['dropbox', 'googleDrive'],
507
+ description: 'Cloud storage provider',
508
+ },
509
+ },
510
+ required: ['site', 'provider'],
511
+ },
512
+ },
513
+ {
514
+ name: 'create_backup',
515
+ description: 'Create a backup of a site to cloud storage (Dropbox or Google Drive). Site will be auto-started if not running.',
516
+ inputSchema: {
517
+ type: 'object',
518
+ properties: {
519
+ site: {
520
+ type: 'string',
521
+ description: 'Site name or ID',
522
+ },
523
+ provider: {
524
+ type: 'string',
525
+ enum: ['dropbox', 'googleDrive'],
526
+ description: 'Cloud storage provider',
527
+ },
528
+ note: {
529
+ type: 'string',
530
+ description: 'Optional note/description for the backup',
531
+ },
532
+ },
533
+ required: ['site', 'provider'],
534
+ },
535
+ },
536
+ {
537
+ name: 'restore_backup',
538
+ description: 'Restore a site from a cloud backup. Site will be auto-started if not running. WARNING: This will overwrite current site files and database.',
539
+ inputSchema: {
540
+ type: 'object',
541
+ properties: {
542
+ site: {
543
+ type: 'string',
544
+ description: 'Site name or ID',
545
+ },
546
+ provider: {
547
+ type: 'string',
548
+ enum: ['dropbox', 'googleDrive'],
549
+ description: 'Cloud storage provider',
550
+ },
551
+ snapshot_id: {
552
+ type: 'string',
553
+ description: 'Snapshot ID to restore (from list_backups)',
554
+ },
555
+ confirm: {
556
+ type: 'boolean',
557
+ description: 'Must be true to confirm restoration',
558
+ },
559
+ },
560
+ required: ['site', 'provider', 'snapshot_id', 'confirm'],
561
+ },
562
+ },
563
+ {
564
+ name: 'delete_backup',
565
+ description: 'Delete a backup from cloud storage',
566
+ inputSchema: {
567
+ type: 'object',
568
+ properties: {
569
+ site: {
570
+ type: 'string',
571
+ description: 'Site name or ID',
572
+ },
573
+ provider: {
574
+ type: 'string',
575
+ enum: ['dropbox', 'googleDrive'],
576
+ description: 'Cloud storage provider',
577
+ },
578
+ snapshot_id: {
579
+ type: 'string',
580
+ description: 'Snapshot ID to delete (from list_backups)',
581
+ },
582
+ confirm: {
583
+ type: 'boolean',
584
+ description: 'Must be true to confirm deletion',
585
+ },
586
+ },
587
+ required: ['site', 'provider', 'snapshot_id', 'confirm'],
588
+ },
589
+ },
590
+ {
591
+ name: 'download_backup',
592
+ description: 'Download a backup as a ZIP file to the Downloads folder',
593
+ inputSchema: {
594
+ type: 'object',
595
+ properties: {
596
+ site: {
597
+ type: 'string',
598
+ description: 'Site name or ID',
599
+ },
600
+ provider: {
601
+ type: 'string',
602
+ enum: ['dropbox', 'googleDrive'],
603
+ description: 'Cloud storage provider',
604
+ },
605
+ snapshot_id: {
606
+ type: 'string',
607
+ description: 'Snapshot ID to download (from list_backups)',
608
+ },
609
+ },
610
+ required: ['site', 'provider', 'snapshot_id'],
611
+ },
612
+ },
613
+ {
614
+ name: 'edit_backup_note',
615
+ description: 'Update the note/description for a backup',
616
+ inputSchema: {
617
+ type: 'object',
618
+ properties: {
619
+ site: {
620
+ type: 'string',
621
+ description: 'Site name or ID',
622
+ },
623
+ provider: {
624
+ type: 'string',
625
+ enum: ['dropbox', 'googleDrive'],
626
+ description: 'Cloud storage provider',
627
+ },
628
+ snapshot_id: {
629
+ type: 'string',
630
+ description: 'Snapshot ID to edit (from list_backups)',
631
+ },
632
+ note: {
633
+ type: 'string',
634
+ description: 'New note/description for the backup',
635
+ },
636
+ },
637
+ required: ['site', 'provider', 'snapshot_id', 'note'],
638
+ },
639
+ },
640
+ // Phase 11: WP Engine Connect
641
+ {
642
+ name: 'wpe_status',
643
+ description: 'Check WP Engine authentication status',
644
+ inputSchema: {
645
+ type: 'object',
646
+ properties: {},
647
+ },
648
+ },
649
+ {
650
+ name: 'wpe_authenticate',
651
+ description: 'Authenticate with WP Engine. Opens browser for OAuth login.',
652
+ inputSchema: {
653
+ type: 'object',
654
+ properties: {},
655
+ },
656
+ },
657
+ {
658
+ name: 'wpe_logout',
659
+ description: 'Logout from WP Engine and clear stored credentials',
660
+ inputSchema: {
661
+ type: 'object',
662
+ properties: {},
663
+ },
664
+ },
665
+ {
666
+ name: 'list_wpe_sites',
667
+ description: 'List all sites from your WP Engine account. Requires authentication.',
668
+ inputSchema: {
669
+ type: 'object',
670
+ properties: {
671
+ account_id: {
672
+ type: 'string',
673
+ description: 'Filter by specific WP Engine account ID (optional)',
674
+ },
675
+ },
676
+ },
677
+ },
678
+ // Phase 11b: Site Linking
679
+ {
680
+ name: 'get_wpe_link',
681
+ description: 'Get WP Engine connection details for a local site. Shows if the site is linked to a WPE environment and sync capabilities.',
682
+ inputSchema: {
683
+ type: 'object',
684
+ properties: {
685
+ site: {
686
+ type: 'string',
687
+ description: 'Site name or ID',
688
+ },
689
+ },
690
+ required: ['site'],
691
+ },
692
+ },
693
+ // Phase 11c: Sync Operations
694
+ {
695
+ name: 'push_to_wpe',
696
+ description: 'Push local site files and/or database to WP Engine. Site will be auto-started if not running. Requires confirm=true to prevent accidental overwrites.',
697
+ inputSchema: {
698
+ type: 'object',
699
+ properties: {
700
+ site: {
701
+ type: 'string',
702
+ description: 'Site name or ID',
703
+ },
704
+ include_database: {
705
+ type: 'boolean',
706
+ description: 'Include database in push (default: false)',
707
+ },
708
+ confirm: {
709
+ type: 'boolean',
710
+ description: 'Must be true to confirm push operation (required for safety)',
711
+ },
712
+ },
713
+ required: ['site', 'confirm'],
714
+ },
715
+ },
716
+ {
717
+ name: 'pull_from_wpe',
718
+ description: 'Pull files and/or database from WP Engine to local site. Site will be auto-started if not running. Requires confirm=true to prevent accidental overwrites.',
719
+ inputSchema: {
720
+ type: 'object',
721
+ properties: {
722
+ site: {
723
+ type: 'string',
724
+ description: 'Site name or ID',
725
+ },
726
+ include_database: {
727
+ type: 'boolean',
728
+ description: 'Include database in pull (default: false)',
729
+ },
730
+ confirm: {
731
+ type: 'boolean',
732
+ description: 'Must be true to confirm pull operation (required for safety)',
733
+ },
734
+ },
735
+ required: ['site', 'confirm'],
736
+ },
737
+ },
738
+ {
739
+ name: 'get_sync_history',
740
+ description: 'Get sync history (push/pull operations) for a local site.',
741
+ inputSchema: {
742
+ type: 'object',
743
+ properties: {
744
+ site: {
745
+ type: 'string',
746
+ description: 'Site name or ID',
747
+ },
748
+ limit: {
749
+ type: 'number',
750
+ description: 'Maximum number of history events to return (default: 10)',
751
+ },
752
+ },
753
+ required: ['site'],
754
+ },
755
+ },
756
+ {
757
+ name: 'get_site_changes',
758
+ description: 'Preview what files have changed between local site and WP Engine. Site will be auto-started if not running. Uses Magic Sync dry-run comparison.',
759
+ inputSchema: {
760
+ type: 'object',
761
+ properties: {
762
+ site: {
763
+ type: 'string',
764
+ description: 'Site name or ID',
765
+ },
766
+ direction: {
767
+ type: 'string',
768
+ enum: ['push', 'pull'],
769
+ description: 'Direction of comparison: "push" shows local changes to upload, "pull" shows remote changes to download (default: push)',
770
+ },
771
+ },
772
+ required: ['site'],
773
+ },
774
+ },
775
+ ];
776
+
777
+ // Find site by name or ID
778
+ async function findSite(siteIdentifier) {
779
+ const data = await graphqlRequest(`
780
+ query {
781
+ sites {
782
+ id
783
+ name
784
+ status
785
+ domain
786
+ }
787
+ }
788
+ `);
789
+
790
+ const sites = data.sites || [];
791
+ const searchLower = siteIdentifier.toLowerCase();
792
+
793
+ // Try exact ID match first
794
+ let site = sites.find(s => s.id === siteIdentifier);
795
+ if (site) return site;
796
+
797
+ // Try exact name match
798
+ site = sites.find(s => s.name.toLowerCase() === searchLower);
799
+ if (site) return site;
800
+
801
+ // Try partial name match
802
+ site = sites.find(s => s.name.toLowerCase().includes(searchLower));
803
+ if (site) return site;
804
+
805
+ return null;
806
+ }
807
+
808
+ // Security: Validate snapshot ID format (restic uses hex hashes)
809
+ function isValidSnapshotId(snapshotId) {
810
+ if (!snapshotId || typeof snapshotId !== 'string') return false;
811
+ // Restic snapshot IDs are hex strings, 8-64 characters (short prefix or full hash)
812
+ return /^[a-f0-9]{8,64}$/i.test(snapshotId);
813
+ }
814
+
815
+ // Security: Validate SQL file path
816
+ function isValidSqlPath(sqlPath) {
817
+ if (!sqlPath || typeof sqlPath !== 'string') return false;
818
+ const path = require('path');
819
+ const fs = require('fs');
820
+ const resolvedPath = path.resolve(sqlPath);
821
+ // Check path doesn't contain traversal and ends with .sql
822
+ return resolvedPath.endsWith('.sql') && !sqlPath.includes('..') && fs.existsSync(resolvedPath);
823
+ }
824
+
825
+ // Performance: Timeout wrapper for long-running operations
826
+ async function withTimeout(promise, timeoutMs, operationName) {
827
+ let timeoutId;
828
+ const timeoutPromise = new Promise((_, reject) => {
829
+ timeoutId = setTimeout(() => {
830
+ reject(new Error(`${operationName} timed out after ${timeoutMs / 1000} seconds`));
831
+ }, timeoutMs);
832
+ });
833
+
834
+ try {
835
+ const result = await Promise.race([promise, timeoutPromise]);
836
+ clearTimeout(timeoutId);
837
+ return result;
838
+ } catch (error) {
839
+ clearTimeout(timeoutId);
840
+ throw error;
841
+ }
842
+ }
843
+
844
+ // Timeout constants (in milliseconds)
845
+ const TIMEOUT_SYNC_OPERATION = 300000; // 5 minutes for push/pull
846
+ const TIMEOUT_BACKUP_OPERATION = 600000; // 10 minutes for backup operations
847
+
848
+ // Helper: Sleep for specified milliseconds
849
+ function sleep(ms) {
850
+ return new Promise(resolve => setTimeout(resolve, ms));
851
+ }
852
+
853
+ // Auto-start site if not running (used by tools that require a running site)
854
+ async function ensureSiteRunning(site) {
855
+ // Check current status
856
+ const data = await graphqlRequest(`
857
+ query($id: ID!) {
858
+ site(id: $id) {
859
+ id
860
+ status
861
+ }
862
+ }
863
+ `, { id: site.id });
864
+
865
+ const currentStatus = data.site?.status;
866
+
867
+ if (currentStatus === 'running') {
868
+ return { wasStarted: false };
869
+ }
870
+
871
+ // Start the site
872
+ await graphqlRequest(`
873
+ mutation($id: ID!) {
874
+ startSite(id: $id) {
875
+ id
876
+ status
877
+ }
878
+ }
879
+ `, { id: site.id });
880
+
881
+ // Brief wait for services to be ready
882
+ await sleep(2000);
883
+
884
+ return { wasStarted: true };
885
+ }
886
+
887
+ // Tool handlers
888
+ async function handleTool(name, args) {
889
+ switch (name) {
890
+ case 'list_sites': {
891
+ const data = await graphqlRequest(`
892
+ query {
893
+ sites {
894
+ id
895
+ name
896
+ status
897
+ domain
898
+ hostConnections {
899
+ hostId
900
+ remoteSiteId
901
+ remoteSiteEnv
902
+ accountId
903
+ }
904
+ }
905
+ }
906
+ `);
907
+
908
+ let sites = data.sites || [];
909
+
910
+ if (args.status === 'running') {
911
+ sites = sites.filter(s => s.status === 'running');
912
+ } else if (args.status === 'stopped') {
913
+ sites = sites.filter(s => s.status !== 'running');
914
+ }
915
+
916
+ // Transform hostConnections to wpeConnection for easier reading
917
+ sites = sites.map(site => {
918
+ const wpeConnection = site.hostConnections?.find(c => c.hostId === 'wpe');
919
+ return {
920
+ ...site,
921
+ hostConnections: undefined, // Remove raw hostConnections
922
+ wpeConnection: wpeConnection ? {
923
+ remoteSiteId: wpeConnection.remoteSiteId,
924
+ environment: wpeConnection.remoteSiteEnv || null,
925
+ // Note: For full install name and capabilities, use get_wpe_link
926
+ canPushPull: true, // All WPE-connected sites can push/pull
927
+ } : null,
928
+ };
929
+ });
930
+
931
+ return {
932
+ content: [{
933
+ type: 'text',
934
+ text: JSON.stringify({ sites, count: sites.length }, null, 2),
935
+ }],
936
+ };
937
+ }
938
+
939
+ case 'get_site': {
940
+ const site = await findSite(args.site);
941
+ if (!site) {
942
+ return {
943
+ content: [{ type: 'text', text: `Site not found: ${args.site}` }],
944
+ isError: true,
945
+ };
946
+ }
947
+ return {
948
+ content: [{ type: 'text', text: JSON.stringify(site, null, 2) }],
949
+ };
950
+ }
951
+
952
+ case 'start_site': {
953
+ const site = await findSite(args.site);
954
+ if (!site) {
955
+ return {
956
+ content: [{ type: 'text', text: `Site not found: ${args.site}` }],
957
+ isError: true,
958
+ };
959
+ }
960
+
961
+ await graphqlRequest(`
962
+ mutation($id: ID!) {
963
+ startSite(id: $id) {
964
+ id
965
+ status
966
+ }
967
+ }
968
+ `, { id: site.id });
969
+
970
+ return {
971
+ content: [{ type: 'text', text: `Started site: ${site.name}` }],
972
+ };
973
+ }
974
+
975
+ case 'stop_site': {
976
+ const site = await findSite(args.site);
977
+ if (!site) {
978
+ return {
979
+ content: [{ type: 'text', text: `Site not found: ${args.site}` }],
980
+ isError: true,
981
+ };
982
+ }
983
+
984
+ await graphqlRequest(`
985
+ mutation($id: ID!) {
986
+ stopSite(id: $id) {
987
+ id
988
+ status
989
+ }
990
+ }
991
+ `, { id: site.id });
992
+
993
+ return {
994
+ content: [{ type: 'text', text: `Stopped site: ${site.name}` }],
995
+ };
996
+ }
997
+
998
+ case 'restart_site': {
999
+ const site = await findSite(args.site);
1000
+ if (!site) {
1001
+ return {
1002
+ content: [{ type: 'text', text: `Site not found: ${args.site}` }],
1003
+ isError: true,
1004
+ };
1005
+ }
1006
+
1007
+ await graphqlRequest(`
1008
+ mutation($id: ID!) {
1009
+ restartSite(id: $id) {
1010
+ id
1011
+ status
1012
+ }
1013
+ }
1014
+ `, { id: site.id });
1015
+
1016
+ return {
1017
+ content: [{ type: 'text', text: `Restarted site: ${site.name}` }],
1018
+ };
1019
+ }
1020
+
1021
+ case 'wp_cli': {
1022
+ // Security: Block dangerous WP-CLI commands that could execute arbitrary code
1023
+ const BLOCKED_WP_COMMANDS = [
1024
+ 'eval',
1025
+ 'eval-file',
1026
+ 'shell',
1027
+ 'db query',
1028
+ 'db cli',
1029
+ ];
1030
+
1031
+ const commandStr = (args.command || []).join(' ').toLowerCase();
1032
+ for (const blocked of BLOCKED_WP_COMMANDS) {
1033
+ if (commandStr.includes(blocked)) {
1034
+ return {
1035
+ content: [{ type: 'text', text: JSON.stringify({
1036
+ error: `Command '${blocked}' is blocked for security reasons. Use Local's terminal for shell access.`,
1037
+ }) }],
1038
+ isError: true,
1039
+ };
1040
+ }
1041
+ }
1042
+
1043
+ const site = await findSite(args.site);
1044
+ if (!site) {
1045
+ return {
1046
+ content: [{ type: 'text', text: `Site not found: ${args.site}` }],
1047
+ isError: true,
1048
+ };
1049
+ }
1050
+
1051
+ // Auto-start site if not running
1052
+ const { wasStarted } = await ensureSiteRunning(site);
1053
+
1054
+ const data = await graphqlRequest(`
1055
+ mutation($input: WpCliInput!) {
1056
+ wpCli(input: $input) {
1057
+ success
1058
+ output
1059
+ error
1060
+ }
1061
+ }
1062
+ `, {
1063
+ input: {
1064
+ siteId: site.id,
1065
+ args: args.command,
1066
+ },
1067
+ });
1068
+
1069
+ const result = data.wpCli;
1070
+ if (!result.success) {
1071
+ return {
1072
+ content: [{ type: 'text', text: `WP-CLI error: ${result.error}` }],
1073
+ isError: true,
1074
+ };
1075
+ }
1076
+
1077
+ return {
1078
+ content: [{ type: 'text', text: result.output || 'Command completed successfully' }],
1079
+ };
1080
+ }
1081
+
1082
+ case 'create_site': {
1083
+ if (!args.name) {
1084
+ return {
1085
+ content: [{ type: 'text', text: 'Error: name is required' }],
1086
+ isError: true,
1087
+ };
1088
+ }
1089
+
1090
+ const input = {
1091
+ name: args.name,
1092
+ };
1093
+
1094
+ if (args.php_version) {
1095
+ input.phpVersion = args.php_version;
1096
+ }
1097
+
1098
+ if (args.blueprint) {
1099
+ input.blueprint = args.blueprint;
1100
+ }
1101
+
1102
+ // Debug: log the input being sent
1103
+ console.error('[MCP stdio] create_site input:', JSON.stringify(input));
1104
+ console.error('[MCP stdio] args.blueprint value:', args.blueprint);
1105
+
1106
+ const data = await graphqlRequest(`
1107
+ mutation($input: CreateSiteInput!) {
1108
+ createSite(input: $input) {
1109
+ success
1110
+ error
1111
+ siteId
1112
+ siteName
1113
+ siteDomain
1114
+ }
1115
+ }
1116
+ `, { input });
1117
+
1118
+ const result = data.createSite;
1119
+ if (!result.success) {
1120
+ return {
1121
+ content: [{ type: 'text', text: `Failed to create site: ${result.error}` }],
1122
+ isError: true,
1123
+ };
1124
+ }
1125
+
1126
+ const fromBlueprint = args.blueprint ? ` (from blueprint: ${args.blueprint})` : '';
1127
+ return {
1128
+ content: [{
1129
+ type: 'text',
1130
+ text: `Created site: ${result.siteName}${fromBlueprint}\nDomain: ${result.siteDomain}\nID: ${result.siteId}\nAdmin: admin / password\n\nThe site is being provisioned and will start automatically.`,
1131
+ }],
1132
+ };
1133
+ }
1134
+
1135
+ case 'delete_site': {
1136
+ if (!args.confirm) {
1137
+ return {
1138
+ content: [{ type: 'text', text: 'Deletion not confirmed. Set confirm=true to delete the site.' }],
1139
+ isError: true,
1140
+ };
1141
+ }
1142
+
1143
+ const site = await findSite(args.site);
1144
+ if (!site) {
1145
+ return {
1146
+ content: [{ type: 'text', text: `Site not found: ${args.site}` }],
1147
+ isError: true,
1148
+ };
1149
+ }
1150
+
1151
+ await graphqlRequest(`
1152
+ mutation($input: DeleteSiteInput!) {
1153
+ deleteSite(input: $input) {
1154
+ success
1155
+ error
1156
+ }
1157
+ }
1158
+ `, {
1159
+ input: {
1160
+ id: site.id,
1161
+ trashFiles: true,
1162
+ },
1163
+ });
1164
+
1165
+ return {
1166
+ content: [{ type: 'text', text: `Deleted site: ${site.name}` }],
1167
+ };
1168
+ }
1169
+
1170
+ case 'get_local_info': {
1171
+ const data = await graphqlRequest(`
1172
+ query {
1173
+ sites {
1174
+ id
1175
+ }
1176
+ }
1177
+ `);
1178
+
1179
+ const siteCount = data.sites?.length || 0;
1180
+ const platform = os.platform();
1181
+ const platformName = platform === 'darwin' ? 'macOS' : platform === 'win32' ? 'Windows' : 'Linux';
1182
+
1183
+ const info = {
1184
+ mcpServerVersion: '1.0.0',
1185
+ platform: platformName,
1186
+ arch: os.arch(),
1187
+ siteCount: siteCount,
1188
+ availableTools: tools.map(t => t.name),
1189
+ transport: 'stdio',
1190
+ };
1191
+
1192
+ return {
1193
+ content: [{ type: 'text', text: JSON.stringify(info, null, 2) }],
1194
+ };
1195
+ }
1196
+
1197
+ case 'open_site': {
1198
+ const site = await findSite(args.site);
1199
+ if (!site) {
1200
+ return {
1201
+ content: [{ type: 'text', text: `Site not found: ${args.site}` }],
1202
+ isError: true,
1203
+ };
1204
+ }
1205
+
1206
+ // Auto-start site if not running
1207
+ await ensureSiteRunning(site);
1208
+
1209
+ const sitePath = args.path || '/';
1210
+ const data = await graphqlRequest(`
1211
+ mutation($input: OpenSiteInput!) {
1212
+ openSite(input: $input) {
1213
+ success
1214
+ error
1215
+ url
1216
+ }
1217
+ }
1218
+ `, {
1219
+ input: {
1220
+ siteId: site.id,
1221
+ path: sitePath,
1222
+ },
1223
+ });
1224
+
1225
+ const result = data.openSite;
1226
+ if (!result.success) {
1227
+ return {
1228
+ content: [{ type: 'text', text: `Failed to open site: ${result.error}` }],
1229
+ isError: true,
1230
+ };
1231
+ }
1232
+
1233
+ return {
1234
+ content: [{ type: 'text', text: `Opened ${site.name} in browser: ${result.url}` }],
1235
+ };
1236
+ }
1237
+
1238
+ case 'clone_site': {
1239
+ const site = await findSite(args.site);
1240
+ if (!site) {
1241
+ return {
1242
+ content: [{ type: 'text', text: `Site not found: ${args.site}` }],
1243
+ isError: true,
1244
+ };
1245
+ }
1246
+
1247
+ if (!args.new_name) {
1248
+ return {
1249
+ content: [{ type: 'text', text: 'Error: new_name is required' }],
1250
+ isError: true,
1251
+ };
1252
+ }
1253
+
1254
+ const data = await graphqlRequest(`
1255
+ mutation($input: CloneSiteInput!) {
1256
+ cloneSite(input: $input) {
1257
+ success
1258
+ error
1259
+ newSiteId
1260
+ newSiteName
1261
+ newSiteDomain
1262
+ }
1263
+ }
1264
+ `, {
1265
+ input: {
1266
+ siteId: site.id,
1267
+ newName: args.new_name,
1268
+ },
1269
+ });
1270
+
1271
+ const result = data.cloneSite;
1272
+ if (!result.success) {
1273
+ return {
1274
+ content: [{ type: 'text', text: `Failed to clone site: ${result.error}` }],
1275
+ isError: true,
1276
+ };
1277
+ }
1278
+
1279
+ return {
1280
+ content: [{
1281
+ type: 'text',
1282
+ text: `Cloned ${site.name} to ${result.newSiteName}\nNew site ID: ${result.newSiteId}\nNew domain: ${result.newSiteDomain}`,
1283
+ }],
1284
+ };
1285
+ }
1286
+
1287
+ case 'export_site': {
1288
+ const site = await findSite(args.site);
1289
+ if (!site) {
1290
+ return {
1291
+ content: [{ type: 'text', text: `Site not found: ${args.site}` }],
1292
+ isError: true,
1293
+ };
1294
+ }
1295
+
1296
+ // Auto-start site if not running
1297
+ await ensureSiteRunning(site);
1298
+
1299
+ const data = await graphqlRequest(`
1300
+ mutation($input: ExportSiteInput!) {
1301
+ exportSite(input: $input) {
1302
+ success
1303
+ error
1304
+ exportPath
1305
+ }
1306
+ }
1307
+ `, {
1308
+ input: {
1309
+ siteId: site.id,
1310
+ outputPath: args.output_path || null,
1311
+ },
1312
+ });
1313
+
1314
+ const result = data.exportSite;
1315
+ if (!result.success) {
1316
+ return {
1317
+ content: [{ type: 'text', text: `Failed to export site: ${result.error}` }],
1318
+ isError: true,
1319
+ };
1320
+ }
1321
+
1322
+ return {
1323
+ content: [{
1324
+ type: 'text',
1325
+ text: `Exported ${site.name} to:\n${result.exportPath}`,
1326
+ }],
1327
+ };
1328
+ }
1329
+
1330
+ case 'list_blueprints': {
1331
+ const data = await graphqlRequest(`
1332
+ query {
1333
+ blueprints {
1334
+ success
1335
+ error
1336
+ blueprints {
1337
+ name
1338
+ lastModified
1339
+ phpVersion
1340
+ webServer
1341
+ database
1342
+ }
1343
+ }
1344
+ }
1345
+ `);
1346
+
1347
+ const result = data.blueprints;
1348
+ if (!result.success) {
1349
+ return {
1350
+ content: [{ type: 'text', text: `Failed to list blueprints: ${result.error}` }],
1351
+ isError: true,
1352
+ };
1353
+ }
1354
+
1355
+ if (!result.blueprints || result.blueprints.length === 0) {
1356
+ return {
1357
+ content: [{ type: 'text', text: 'No blueprints found. Use save_blueprint to create one from an existing site.' }],
1358
+ };
1359
+ }
1360
+
1361
+ return {
1362
+ content: [{
1363
+ type: 'text',
1364
+ text: JSON.stringify({ blueprints: result.blueprints, count: result.blueprints.length }, null, 2),
1365
+ }],
1366
+ };
1367
+ }
1368
+
1369
+ case 'save_blueprint': {
1370
+ const site = await findSite(args.site);
1371
+ if (!site) {
1372
+ return {
1373
+ content: [{ type: 'text', text: `Site not found: ${args.site}` }],
1374
+ isError: true,
1375
+ };
1376
+ }
1377
+
1378
+ if (!args.name) {
1379
+ return {
1380
+ content: [{ type: 'text', text: 'Error: name is required' }],
1381
+ isError: true,
1382
+ };
1383
+ }
1384
+
1385
+ // Auto-start site if not running
1386
+ await ensureSiteRunning(site);
1387
+
1388
+ const data = await graphqlRequest(`
1389
+ mutation($input: SaveBlueprintInput!) {
1390
+ saveBlueprint(input: $input) {
1391
+ success
1392
+ error
1393
+ blueprintName
1394
+ }
1395
+ }
1396
+ `, {
1397
+ input: {
1398
+ siteId: site.id,
1399
+ name: args.name,
1400
+ },
1401
+ });
1402
+
1403
+ const result = data.saveBlueprint;
1404
+ if (!result.success) {
1405
+ return {
1406
+ content: [{ type: 'text', text: `Failed to save blueprint: ${result.error}` }],
1407
+ isError: true,
1408
+ };
1409
+ }
1410
+
1411
+ return {
1412
+ content: [{
1413
+ type: 'text',
1414
+ text: `Saved ${site.name} as blueprint: ${result.blueprintName}\n\nYou can now create new sites from this blueprint.`,
1415
+ }],
1416
+ };
1417
+ }
1418
+
1419
+ // Phase 8: WordPress Development Tools
1420
+ case 'export_database': {
1421
+ const site = await findSite(args.site);
1422
+ if (!site) {
1423
+ return {
1424
+ content: [{ type: 'text', text: `Site not found: ${args.site}` }],
1425
+ isError: true,
1426
+ };
1427
+ }
1428
+
1429
+ // Auto-start site if not running
1430
+ await ensureSiteRunning(site);
1431
+
1432
+ const data = await graphqlRequest(`
1433
+ mutation($input: ExportDatabaseInput!) {
1434
+ exportDatabase(input: $input) {
1435
+ success
1436
+ error
1437
+ outputPath
1438
+ }
1439
+ }
1440
+ `, {
1441
+ input: {
1442
+ siteId: site.id,
1443
+ outputPath: args.output_path || null,
1444
+ },
1445
+ });
1446
+
1447
+ const result = data.exportDatabase;
1448
+ if (!result.success) {
1449
+ return {
1450
+ content: [{ type: 'text', text: `Failed to export database: ${result.error}` }],
1451
+ isError: true,
1452
+ };
1453
+ }
1454
+
1455
+ return {
1456
+ content: [{
1457
+ type: 'text',
1458
+ text: `Exported database for ${site.name} to:\n${result.outputPath}`,
1459
+ }],
1460
+ };
1461
+ }
1462
+
1463
+ case 'import_database': {
1464
+ const site = await findSite(args.site);
1465
+ if (!site) {
1466
+ return {
1467
+ content: [{ type: 'text', text: `Site not found: ${args.site}` }],
1468
+ isError: true,
1469
+ };
1470
+ }
1471
+
1472
+ if (!args.sql_path) {
1473
+ return {
1474
+ content: [{ type: 'text', text: 'Error: sql_path is required' }],
1475
+ isError: true,
1476
+ };
1477
+ }
1478
+
1479
+ // Security: Validate SQL path to prevent path traversal attacks
1480
+ if (!isValidSqlPath(args.sql_path)) {
1481
+ return {
1482
+ content: [{ type: 'text', text: 'Error: Invalid SQL path. Path must end with .sql, exist on disk, and not contain path traversal sequences (..).' }],
1483
+ isError: true,
1484
+ };
1485
+ }
1486
+
1487
+ // Auto-start site if not running
1488
+ await ensureSiteRunning(site);
1489
+
1490
+ const data = await graphqlRequest(`
1491
+ mutation($input: ImportDatabaseInput!) {
1492
+ importDatabase(input: $input) {
1493
+ success
1494
+ error
1495
+ }
1496
+ }
1497
+ `, {
1498
+ input: {
1499
+ siteId: site.id,
1500
+ sqlPath: args.sql_path,
1501
+ },
1502
+ });
1503
+
1504
+ const result = data.importDatabase;
1505
+ if (!result.success) {
1506
+ return {
1507
+ content: [{ type: 'text', text: `Failed to import database: ${result.error}` }],
1508
+ isError: true,
1509
+ };
1510
+ }
1511
+
1512
+ return {
1513
+ content: [{
1514
+ type: 'text',
1515
+ text: `Successfully imported database into ${site.name} from:\n${args.sql_path}`,
1516
+ }],
1517
+ };
1518
+ }
1519
+
1520
+ case 'open_adminer': {
1521
+ const site = await findSite(args.site);
1522
+ if (!site) {
1523
+ return {
1524
+ content: [{ type: 'text', text: `Site not found: ${args.site}` }],
1525
+ isError: true,
1526
+ };
1527
+ }
1528
+
1529
+ // Auto-start site if not running
1530
+ await ensureSiteRunning(site);
1531
+
1532
+ const data = await graphqlRequest(`
1533
+ mutation($input: OpenAdminerInput!) {
1534
+ openAdminer(input: $input) {
1535
+ success
1536
+ error
1537
+ }
1538
+ }
1539
+ `, {
1540
+ input: {
1541
+ siteId: site.id,
1542
+ },
1543
+ });
1544
+
1545
+ const result = data.openAdminer;
1546
+ if (!result.success) {
1547
+ return {
1548
+ content: [{ type: 'text', text: `Failed to open Adminer: ${result.error}` }],
1549
+ isError: true,
1550
+ };
1551
+ }
1552
+
1553
+ return {
1554
+ content: [{
1555
+ type: 'text',
1556
+ text: `Opened Adminer for ${site.name}`,
1557
+ }],
1558
+ };
1559
+ }
1560
+
1561
+ case 'trust_ssl': {
1562
+ const site = await findSite(args.site);
1563
+ if (!site) {
1564
+ return {
1565
+ content: [{ type: 'text', text: `Site not found: ${args.site}` }],
1566
+ isError: true,
1567
+ };
1568
+ }
1569
+
1570
+ const data = await graphqlRequest(`
1571
+ mutation($input: TrustSslInput!) {
1572
+ trustSsl(input: $input) {
1573
+ success
1574
+ error
1575
+ }
1576
+ }
1577
+ `, {
1578
+ input: {
1579
+ siteId: site.id,
1580
+ },
1581
+ });
1582
+
1583
+ const result = data.trustSsl;
1584
+ if (!result.success) {
1585
+ return {
1586
+ content: [{ type: 'text', text: `Failed to trust SSL: ${result.error}` }],
1587
+ isError: true,
1588
+ };
1589
+ }
1590
+
1591
+ return {
1592
+ content: [{
1593
+ type: 'text',
1594
+ text: `Trusted SSL certificate for ${site.name}`,
1595
+ }],
1596
+ };
1597
+ }
1598
+
1599
+ case 'rename_site': {
1600
+ const site = await findSite(args.site);
1601
+ if (!site) {
1602
+ return {
1603
+ content: [{ type: 'text', text: `Site not found: ${args.site}` }],
1604
+ isError: true,
1605
+ };
1606
+ }
1607
+
1608
+ if (!args.new_name) {
1609
+ return {
1610
+ content: [{ type: 'text', text: 'Error: new_name is required' }],
1611
+ isError: true,
1612
+ };
1613
+ }
1614
+
1615
+ const data = await graphqlRequest(`
1616
+ mutation($input: McpRenameSiteInput!) {
1617
+ mcpRenameSite(input: $input) {
1618
+ success
1619
+ error
1620
+ newName
1621
+ }
1622
+ }
1623
+ `, {
1624
+ input: {
1625
+ siteId: site.id,
1626
+ newName: args.new_name,
1627
+ },
1628
+ });
1629
+
1630
+ const result = data.mcpRenameSite;
1631
+ if (!result.success) {
1632
+ return {
1633
+ content: [{ type: 'text', text: `Failed to rename site: ${result.error}` }],
1634
+ isError: true,
1635
+ };
1636
+ }
1637
+
1638
+ return {
1639
+ content: [{
1640
+ type: 'text',
1641
+ text: `Renamed ${site.name} to ${result.newName}`,
1642
+ }],
1643
+ };
1644
+ }
1645
+
1646
+ case 'change_php_version': {
1647
+ const site = await findSite(args.site);
1648
+ if (!site) {
1649
+ return {
1650
+ content: [{ type: 'text', text: `Site not found: ${args.site}` }],
1651
+ isError: true,
1652
+ };
1653
+ }
1654
+
1655
+ if (!args.php_version) {
1656
+ return {
1657
+ content: [{ type: 'text', text: 'Error: php_version is required' }],
1658
+ isError: true,
1659
+ };
1660
+ }
1661
+
1662
+ const data = await graphqlRequest(`
1663
+ mutation($input: ChangePhpVersionInput!) {
1664
+ changePhpVersion(input: $input) {
1665
+ success
1666
+ error
1667
+ phpVersion
1668
+ }
1669
+ }
1670
+ `, {
1671
+ input: {
1672
+ siteId: site.id,
1673
+ phpVersion: args.php_version,
1674
+ },
1675
+ });
1676
+
1677
+ const result = data.changePhpVersion;
1678
+ if (!result.success) {
1679
+ return {
1680
+ content: [{ type: 'text', text: `Failed to change PHP version: ${result.error}` }],
1681
+ isError: true,
1682
+ };
1683
+ }
1684
+
1685
+ return {
1686
+ content: [{
1687
+ type: 'text',
1688
+ text: `Changed PHP version for ${site.name} to ${result.phpVersion}`,
1689
+ }],
1690
+ };
1691
+ }
1692
+
1693
+ case 'import_site': {
1694
+ if (!args.zip_path) {
1695
+ return {
1696
+ content: [{ type: 'text', text: 'Error: zip_path is required' }],
1697
+ isError: true,
1698
+ };
1699
+ }
1700
+
1701
+ const data = await graphqlRequest(`
1702
+ mutation($input: ImportSiteInput!) {
1703
+ importSite(input: $input) {
1704
+ success
1705
+ error
1706
+ siteId
1707
+ siteName
1708
+ }
1709
+ }
1710
+ `, {
1711
+ input: {
1712
+ zipPath: args.zip_path,
1713
+ siteName: args.site_name || null,
1714
+ },
1715
+ });
1716
+
1717
+ const result = data.importSite;
1718
+ if (!result.success) {
1719
+ return {
1720
+ content: [{ type: 'text', text: `Failed to import site: ${result.error}` }],
1721
+ isError: true,
1722
+ };
1723
+ }
1724
+
1725
+ return {
1726
+ content: [{
1727
+ type: 'text',
1728
+ text: `Imported site: ${result.siteName}\nSite ID: ${result.siteId}`,
1729
+ }],
1730
+ };
1731
+ }
1732
+
1733
+ // Phase 9: Site Configuration & Dev Tools
1734
+ case 'toggle_xdebug': {
1735
+ const site = await findSite(args.site);
1736
+ if (!site) {
1737
+ return {
1738
+ content: [{ type: 'text', text: `Site not found: ${args.site}` }],
1739
+ isError: true,
1740
+ };
1741
+ }
1742
+
1743
+ if (typeof args.enabled !== 'boolean') {
1744
+ return {
1745
+ content: [{ type: 'text', text: 'Error: enabled must be true or false' }],
1746
+ isError: true,
1747
+ };
1748
+ }
1749
+
1750
+ const data = await graphqlRequest(`
1751
+ mutation($input: ToggleXdebugInput!) {
1752
+ toggleXdebug(input: $input) {
1753
+ success
1754
+ error
1755
+ enabled
1756
+ }
1757
+ }
1758
+ `, {
1759
+ input: {
1760
+ siteId: site.id,
1761
+ enabled: args.enabled,
1762
+ },
1763
+ });
1764
+
1765
+ const result = data.toggleXdebug;
1766
+ if (!result.success) {
1767
+ return {
1768
+ content: [{ type: 'text', text: `Failed to toggle Xdebug: ${result.error}` }],
1769
+ isError: true,
1770
+ };
1771
+ }
1772
+
1773
+ return {
1774
+ content: [{
1775
+ type: 'text',
1776
+ text: `Xdebug ${result.enabled ? 'enabled' : 'disabled'} for ${site.name}`,
1777
+ }],
1778
+ };
1779
+ }
1780
+
1781
+ case 'get_site_logs': {
1782
+ const site = await findSite(args.site);
1783
+ if (!site) {
1784
+ return {
1785
+ content: [{ type: 'text', text: `Site not found: ${args.site}` }],
1786
+ isError: true,
1787
+ };
1788
+ }
1789
+
1790
+ const data = await graphqlRequest(`
1791
+ mutation($input: GetSiteLogsInput!) {
1792
+ getSiteLogs(input: $input) {
1793
+ success
1794
+ error
1795
+ logs {
1796
+ type
1797
+ content
1798
+ path
1799
+ }
1800
+ }
1801
+ }
1802
+ `, {
1803
+ input: {
1804
+ siteId: site.id,
1805
+ logType: args.log_type || 'php',
1806
+ lines: args.lines || 100,
1807
+ },
1808
+ });
1809
+
1810
+ const result = data.getSiteLogs;
1811
+ if (!result.success) {
1812
+ return {
1813
+ content: [{ type: 'text', text: `Failed to get logs: ${result.error}` }],
1814
+ isError: true,
1815
+ };
1816
+ }
1817
+
1818
+ let output = `Logs for ${site.name}:\n`;
1819
+ for (const log of result.logs) {
1820
+ output += `\n=== ${log.type.toUpperCase()} (${log.path}) ===\n${log.content}\n`;
1821
+ }
1822
+
1823
+ return {
1824
+ content: [{ type: 'text', text: output }],
1825
+ };
1826
+ }
1827
+
1828
+ case 'list_services': {
1829
+ const data = await graphqlRequest(`
1830
+ query($type: String) {
1831
+ listServices(type: $type) {
1832
+ success
1833
+ error
1834
+ services {
1835
+ role
1836
+ name
1837
+ version
1838
+ }
1839
+ }
1840
+ }
1841
+ `, {
1842
+ type: args.type || 'all',
1843
+ });
1844
+
1845
+ const result = data.listServices;
1846
+ if (!result.success) {
1847
+ return {
1848
+ content: [{ type: 'text', text: `Failed to list services: ${result.error}` }],
1849
+ isError: true,
1850
+ };
1851
+ }
1852
+
1853
+ return {
1854
+ content: [{
1855
+ type: 'text',
1856
+ text: JSON.stringify({ services: result.services, count: result.services.length }, null, 2),
1857
+ }],
1858
+ };
1859
+ }
1860
+
1861
+ // Phase 10: Cloud Backups
1862
+ case 'backup_status': {
1863
+ const data = await graphqlRequest(`
1864
+ query {
1865
+ backupStatus {
1866
+ available
1867
+ featureEnabled
1868
+ dropbox {
1869
+ authenticated
1870
+ accountId
1871
+ email
1872
+ }
1873
+ googleDrive {
1874
+ authenticated
1875
+ accountId
1876
+ email
1877
+ }
1878
+ message
1879
+ error
1880
+ }
1881
+ }
1882
+ `);
1883
+
1884
+ const result = data.backupStatus;
1885
+ if (result.error) {
1886
+ return {
1887
+ content: [{ type: 'text', text: `Backup status check failed: ${result.error}` }],
1888
+ isError: true,
1889
+ };
1890
+ }
1891
+
1892
+ return {
1893
+ content: [{
1894
+ type: 'text',
1895
+ text: JSON.stringify(result, null, 2),
1896
+ }],
1897
+ };
1898
+ }
1899
+
1900
+ case 'list_backups': {
1901
+ const site = await findSite(args.site);
1902
+ if (!site) {
1903
+ return {
1904
+ content: [{ type: 'text', text: `Site not found: ${args.site}` }],
1905
+ isError: true,
1906
+ };
1907
+ }
1908
+
1909
+ const data = await graphqlRequest(`
1910
+ query ListBackups($siteId: ID!, $provider: String!) {
1911
+ listBackups(siteId: $siteId, provider: $provider) {
1912
+ success
1913
+ siteName
1914
+ provider
1915
+ backups {
1916
+ snapshotId
1917
+ timestamp
1918
+ note
1919
+ siteDomain
1920
+ services
1921
+ }
1922
+ count
1923
+ error
1924
+ }
1925
+ }
1926
+ `, {
1927
+ siteId: site.id,
1928
+ provider: args.provider,
1929
+ });
1930
+
1931
+ const result = data.listBackups;
1932
+ if (!result.success) {
1933
+ return {
1934
+ content: [{ type: 'text', text: `Failed to list backups: ${result.error}` }],
1935
+ isError: true,
1936
+ };
1937
+ }
1938
+
1939
+ return {
1940
+ content: [{
1941
+ type: 'text',
1942
+ text: JSON.stringify({
1943
+ siteName: result.siteName,
1944
+ provider: result.provider,
1945
+ backups: result.backups,
1946
+ count: result.count,
1947
+ }, null, 2),
1948
+ }],
1949
+ };
1950
+ }
1951
+
1952
+ case 'create_backup': {
1953
+ const site = await findSite(args.site);
1954
+ if (!site) {
1955
+ return {
1956
+ content: [{ type: 'text', text: `Site not found: ${args.site}` }],
1957
+ isError: true,
1958
+ };
1959
+ }
1960
+
1961
+ // Auto-start site if not running
1962
+ await ensureSiteRunning(site);
1963
+
1964
+ const data = await withTimeout(
1965
+ graphqlRequest(`
1966
+ mutation CreateBackup($siteId: ID!, $provider: String!, $note: String) {
1967
+ createBackup(siteId: $siteId, provider: $provider, note: $note) {
1968
+ success
1969
+ snapshotId
1970
+ timestamp
1971
+ message
1972
+ error
1973
+ }
1974
+ }
1975
+ `, {
1976
+ siteId: site.id,
1977
+ provider: args.provider,
1978
+ note: args.note,
1979
+ }),
1980
+ TIMEOUT_BACKUP_OPERATION,
1981
+ 'Create backup'
1982
+ );
1983
+
1984
+ const result = data.createBackup;
1985
+ if (!result.success) {
1986
+ return {
1987
+ content: [{ type: 'text', text: `Failed to create backup: ${result.error}` }],
1988
+ isError: true,
1989
+ };
1990
+ }
1991
+
1992
+ return {
1993
+ content: [{
1994
+ type: 'text',
1995
+ text: JSON.stringify({
1996
+ success: true,
1997
+ snapshotId: result.snapshotId,
1998
+ timestamp: result.timestamp,
1999
+ message: result.message,
2000
+ }, null, 2),
2001
+ }],
2002
+ };
2003
+ }
2004
+
2005
+ case 'restore_backup': {
2006
+ // Security: Validate snapshot ID format
2007
+ if (!isValidSnapshotId(args.snapshot_id)) {
2008
+ return {
2009
+ content: [{ type: 'text', text: JSON.stringify({
2010
+ error: 'Invalid snapshot ID format. Use list_backups to get valid snapshot IDs.',
2011
+ }) }],
2012
+ isError: true,
2013
+ };
2014
+ }
2015
+
2016
+ const site = await findSite(args.site);
2017
+ if (!site) {
2018
+ return {
2019
+ content: [{ type: 'text', text: `Site not found: ${args.site}` }],
2020
+ isError: true,
2021
+ };
2022
+ }
2023
+
2024
+ // Auto-start site if not running
2025
+ await ensureSiteRunning(site);
2026
+
2027
+ const data = await withTimeout(
2028
+ graphqlRequest(`
2029
+ mutation RestoreBackup($siteId: ID!, $provider: String!, $snapshotId: String!, $confirm: Boolean) {
2030
+ restoreBackup(siteId: $siteId, provider: $provider, snapshotId: $snapshotId, confirm: $confirm) {
2031
+ success
2032
+ message
2033
+ error
2034
+ }
2035
+ }
2036
+ `, {
2037
+ siteId: site.id,
2038
+ provider: args.provider,
2039
+ snapshotId: args.snapshot_id,
2040
+ confirm: args.confirm === true,
2041
+ }),
2042
+ TIMEOUT_BACKUP_OPERATION,
2043
+ 'Restore backup'
2044
+ );
2045
+
2046
+ const result = data.restoreBackup;
2047
+ if (!result.success) {
2048
+ return {
2049
+ content: [{ type: 'text', text: `Failed to restore backup: ${result.error}` }],
2050
+ isError: true,
2051
+ };
2052
+ }
2053
+
2054
+ return {
2055
+ content: [{
2056
+ type: 'text',
2057
+ text: JSON.stringify({
2058
+ success: true,
2059
+ message: result.message,
2060
+ }, null, 2),
2061
+ }],
2062
+ };
2063
+ }
2064
+
2065
+ case 'delete_backup': {
2066
+ // Security: Validate snapshot ID format
2067
+ if (!isValidSnapshotId(args.snapshot_id)) {
2068
+ return {
2069
+ content: [{ type: 'text', text: JSON.stringify({
2070
+ error: 'Invalid snapshot ID format. Use list_backups to get valid snapshot IDs.',
2071
+ }) }],
2072
+ isError: true,
2073
+ };
2074
+ }
2075
+
2076
+ const site = await findSite(args.site);
2077
+ if (!site) {
2078
+ return {
2079
+ content: [{ type: 'text', text: `Site not found: ${args.site}` }],
2080
+ isError: true,
2081
+ };
2082
+ }
2083
+
2084
+ const data = await graphqlRequest(`
2085
+ mutation DeleteBackup($siteId: ID!, $provider: String!, $snapshotId: String!, $confirm: Boolean) {
2086
+ deleteBackup(siteId: $siteId, provider: $provider, snapshotId: $snapshotId, confirm: $confirm) {
2087
+ success
2088
+ deletedSnapshotId
2089
+ message
2090
+ error
2091
+ }
2092
+ }
2093
+ `, {
2094
+ siteId: site.id,
2095
+ provider: args.provider,
2096
+ snapshotId: args.snapshot_id,
2097
+ confirm: args.confirm === true,
2098
+ });
2099
+
2100
+ const result = data.deleteBackup;
2101
+ if (!result.success) {
2102
+ return {
2103
+ content: [{ type: 'text', text: `Failed to delete backup: ${result.error}` }],
2104
+ isError: true,
2105
+ };
2106
+ }
2107
+
2108
+ return {
2109
+ content: [{
2110
+ type: 'text',
2111
+ text: JSON.stringify({
2112
+ success: true,
2113
+ deletedSnapshotId: result.deletedSnapshotId,
2114
+ message: result.message,
2115
+ }, null, 2),
2116
+ }],
2117
+ };
2118
+ }
2119
+
2120
+ case 'download_backup': {
2121
+ const site = await findSite(args.site);
2122
+ if (!site) {
2123
+ return {
2124
+ content: [{ type: 'text', text: `Site not found: ${args.site}` }],
2125
+ isError: true,
2126
+ };
2127
+ }
2128
+
2129
+ // Security: Validate snapshot ID format
2130
+ if (!isValidSnapshotId(args.snapshot_id)) {
2131
+ return {
2132
+ content: [{ type: 'text', text: 'Error: Invalid snapshot ID format. Expected hex string (8-64 characters).' }],
2133
+ isError: true,
2134
+ };
2135
+ }
2136
+
2137
+ const data = await graphqlRequest(`
2138
+ mutation DownloadBackup($siteId: ID!, $provider: String!, $snapshotId: String!) {
2139
+ downloadBackup(siteId: $siteId, provider: $provider, snapshotId: $snapshotId) {
2140
+ success
2141
+ filePath
2142
+ message
2143
+ error
2144
+ }
2145
+ }
2146
+ `, {
2147
+ siteId: site.id,
2148
+ provider: args.provider,
2149
+ snapshotId: args.snapshot_id,
2150
+ });
2151
+
2152
+ const result = data.downloadBackup;
2153
+ if (!result.success) {
2154
+ return {
2155
+ content: [{ type: 'text', text: `Failed to download backup: ${result.error}` }],
2156
+ isError: true,
2157
+ };
2158
+ }
2159
+
2160
+ return {
2161
+ content: [{
2162
+ type: 'text',
2163
+ text: JSON.stringify({
2164
+ success: true,
2165
+ filePath: result.filePath,
2166
+ message: result.message,
2167
+ }, null, 2),
2168
+ }],
2169
+ };
2170
+ }
2171
+
2172
+ case 'edit_backup_note': {
2173
+ const site = await findSite(args.site);
2174
+ if (!site) {
2175
+ return {
2176
+ content: [{ type: 'text', text: `Site not found: ${args.site}` }],
2177
+ isError: true,
2178
+ };
2179
+ }
2180
+
2181
+ const data = await graphqlRequest(`
2182
+ mutation EditBackupNote($siteId: ID!, $provider: String!, $snapshotId: String!, $note: String!) {
2183
+ editBackupNote(siteId: $siteId, provider: $provider, snapshotId: $snapshotId, note: $note) {
2184
+ success
2185
+ snapshotId
2186
+ note
2187
+ error
2188
+ }
2189
+ }
2190
+ `, {
2191
+ siteId: site.id,
2192
+ provider: args.provider,
2193
+ snapshotId: args.snapshot_id,
2194
+ note: args.note,
2195
+ });
2196
+
2197
+ const result = data.editBackupNote;
2198
+ if (!result.success) {
2199
+ return {
2200
+ content: [{ type: 'text', text: `Failed to edit backup note: ${result.error}` }],
2201
+ isError: true,
2202
+ };
2203
+ }
2204
+
2205
+ return {
2206
+ content: [{
2207
+ type: 'text',
2208
+ text: JSON.stringify({
2209
+ success: true,
2210
+ snapshotId: result.snapshotId,
2211
+ note: result.note,
2212
+ }, null, 2),
2213
+ }],
2214
+ };
2215
+ }
2216
+
2217
+ // Phase 11: WP Engine Connect
2218
+ case 'wpe_status': {
2219
+ const data = await graphqlRequest(`
2220
+ query {
2221
+ wpeStatus {
2222
+ authenticated
2223
+ email
2224
+ accountId
2225
+ accountName
2226
+ tokenExpiry
2227
+ error
2228
+ }
2229
+ }
2230
+ `);
2231
+
2232
+ const result = data.wpeStatus;
2233
+ if (result.error && !result.authenticated) {
2234
+ return {
2235
+ content: [{ type: 'text', text: `WP Engine status check failed: ${result.error}` }],
2236
+ isError: true,
2237
+ };
2238
+ }
2239
+
2240
+ if (!result.authenticated) {
2241
+ return {
2242
+ content: [{
2243
+ type: 'text',
2244
+ text: JSON.stringify({
2245
+ authenticated: false,
2246
+ message: 'Not authenticated with WP Engine. Use wpe_authenticate to login.',
2247
+ }, null, 2),
2248
+ }],
2249
+ };
2250
+ }
2251
+
2252
+ return {
2253
+ content: [{
2254
+ type: 'text',
2255
+ text: JSON.stringify({
2256
+ authenticated: true,
2257
+ email: result.email,
2258
+ accountId: result.accountId,
2259
+ accountName: result.accountName,
2260
+ tokenExpiry: result.tokenExpiry,
2261
+ }, null, 2),
2262
+ }],
2263
+ };
2264
+ }
2265
+
2266
+ case 'wpe_authenticate': {
2267
+ const data = await graphqlRequest(`
2268
+ mutation {
2269
+ wpeAuthenticate {
2270
+ success
2271
+ email
2272
+ message
2273
+ error
2274
+ }
2275
+ }
2276
+ `);
2277
+
2278
+ const result = data.wpeAuthenticate;
2279
+ if (!result.success) {
2280
+ return {
2281
+ content: [{ type: 'text', text: `WP Engine authentication failed: ${result.error}` }],
2282
+ isError: true,
2283
+ };
2284
+ }
2285
+
2286
+ return {
2287
+ content: [{
2288
+ type: 'text',
2289
+ text: JSON.stringify({
2290
+ success: true,
2291
+ email: result.email,
2292
+ message: result.message || 'Authentication initiated. Please complete the login in your browser.',
2293
+ }, null, 2),
2294
+ }],
2295
+ };
2296
+ }
2297
+
2298
+ case 'wpe_logout': {
2299
+ const data = await graphqlRequest(`
2300
+ mutation {
2301
+ wpeLogout {
2302
+ success
2303
+ message
2304
+ error
2305
+ }
2306
+ }
2307
+ `);
2308
+
2309
+ const result = data.wpeLogout;
2310
+ if (!result.success) {
2311
+ return {
2312
+ content: [{ type: 'text', text: `WP Engine logout failed: ${result.error}` }],
2313
+ isError: true,
2314
+ };
2315
+ }
2316
+
2317
+ return {
2318
+ content: [{
2319
+ type: 'text',
2320
+ text: JSON.stringify({
2321
+ success: true,
2322
+ message: result.message || 'Logged out from WP Engine',
2323
+ }, null, 2),
2324
+ }],
2325
+ };
2326
+ }
2327
+
2328
+ case 'list_wpe_sites': {
2329
+ const data = await graphqlRequest(`
2330
+ query($accountId: String) {
2331
+ listWpeSites(accountId: $accountId) {
2332
+ success
2333
+ error
2334
+ sites {
2335
+ id
2336
+ name
2337
+ environment
2338
+ phpVersion
2339
+ primaryDomain
2340
+ accountId
2341
+ accountName
2342
+ sftpHost
2343
+ sftpUser
2344
+ }
2345
+ count
2346
+ }
2347
+ }
2348
+ `, {
2349
+ accountId: args.account_id || null,
2350
+ });
2351
+
2352
+ const result = data.listWpeSites;
2353
+ if (!result.success) {
2354
+ return {
2355
+ content: [{ type: 'text', text: `Failed to list WP Engine sites: ${result.error}` }],
2356
+ isError: true,
2357
+ };
2358
+ }
2359
+
2360
+ return {
2361
+ content: [{
2362
+ type: 'text',
2363
+ text: JSON.stringify({ sites: result.sites, count: result.count }, null, 2),
2364
+ }],
2365
+ };
2366
+ }
2367
+
2368
+ // Phase 11b: Site Linking
2369
+ case 'get_wpe_link': {
2370
+ const site = await findSite(args.site);
2371
+ if (!site) {
2372
+ return {
2373
+ content: [{ type: 'text', text: `Site not found: ${args.site}` }],
2374
+ isError: true,
2375
+ };
2376
+ }
2377
+
2378
+ // Use the getWpeLink query which enriches with CAPI data
2379
+ const data = await graphqlRequest(`
2380
+ query($siteId: ID!) {
2381
+ getWpeLink(siteId: $siteId) {
2382
+ linked
2383
+ siteName
2384
+ connections {
2385
+ remoteInstallId
2386
+ installName
2387
+ environment
2388
+ accountId
2389
+ portalUrl
2390
+ primaryDomain
2391
+ }
2392
+ connectionCount
2393
+ capabilities {
2394
+ canPush
2395
+ canPull
2396
+ syncModes
2397
+ magicSyncAvailable
2398
+ databaseSyncAvailable
2399
+ }
2400
+ message
2401
+ error
2402
+ }
2403
+ }
2404
+ `, { siteId: site.id });
2405
+
2406
+ const result = data.getWpeLink;
2407
+ if (result.error) {
2408
+ return {
2409
+ content: [{ type: 'text', text: `Failed to get WPE link: ${result.error}` }],
2410
+ isError: true,
2411
+ };
2412
+ }
2413
+
2414
+ if (!result.linked) {
2415
+ return {
2416
+ content: [{
2417
+ type: 'text',
2418
+ text: JSON.stringify({
2419
+ linked: false,
2420
+ siteName: result.siteName,
2421
+ message: result.message || 'Site is not linked to any WP Engine environment.',
2422
+ }, null, 2),
2423
+ }],
2424
+ };
2425
+ }
2426
+
2427
+ return {
2428
+ content: [{
2429
+ type: 'text',
2430
+ text: JSON.stringify({
2431
+ linked: true,
2432
+ siteName: result.siteName,
2433
+ connections: result.connections,
2434
+ connectionCount: result.connectionCount,
2435
+ capabilities: result.capabilities,
2436
+ }, null, 2),
2437
+ }],
2438
+ };
2439
+ }
2440
+
2441
+ // Phase 11c: Sync Operations
2442
+ case 'push_to_wpe': {
2443
+ const site = await findSite(args.site);
2444
+ if (!site) {
2445
+ return {
2446
+ content: [{ type: 'text', text: `Site not found: ${args.site}` }],
2447
+ isError: true,
2448
+ };
2449
+ }
2450
+
2451
+ // Auto-start site if not running
2452
+ await ensureSiteRunning(site);
2453
+
2454
+ const data = await withTimeout(
2455
+ graphqlRequest(`
2456
+ mutation($localSiteId: ID!, $remoteInstallId: ID!, $includeSql: Boolean, $confirm: Boolean) {
2457
+ pushToWpe(localSiteId: $localSiteId, remoteInstallId: $remoteInstallId, includeSql: $includeSql, confirm: $confirm) {
2458
+ success
2459
+ message
2460
+ error
2461
+ }
2462
+ }
2463
+ `, {
2464
+ localSiteId: site.id,
2465
+ remoteInstallId: site.id, // Will be resolved by the mutation using hostConnections
2466
+ includeSql: args.include_database || false,
2467
+ confirm: args.confirm || false,
2468
+ }),
2469
+ TIMEOUT_SYNC_OPERATION,
2470
+ 'Push to WP Engine'
2471
+ );
2472
+
2473
+ const result = data.pushToWpe;
2474
+ if (!result.success) {
2475
+ return {
2476
+ content: [{ type: 'text', text: result.error || 'Push failed' }],
2477
+ isError: true,
2478
+ };
2479
+ }
2480
+
2481
+ return {
2482
+ content: [{
2483
+ type: 'text',
2484
+ text: JSON.stringify({
2485
+ success: true,
2486
+ message: result.message,
2487
+ }, null, 2),
2488
+ }],
2489
+ };
2490
+ }
2491
+
2492
+ case 'pull_from_wpe': {
2493
+ // Require confirmation for destructive operation
2494
+ if (!args.confirm) {
2495
+ return {
2496
+ content: [{ type: 'text', text: JSON.stringify({
2497
+ error: 'Pull requires confirm=true to prevent accidental overwrites. This operation will overwrite local files' + (args.include_database ? ' and database' : '') + '.',
2498
+ }) }],
2499
+ isError: true,
2500
+ };
2501
+ }
2502
+
2503
+ const site = await findSite(args.site);
2504
+ if (!site) {
2505
+ return {
2506
+ content: [{ type: 'text', text: `Site not found: ${args.site}` }],
2507
+ isError: true,
2508
+ };
2509
+ }
2510
+
2511
+ // Auto-start site if not running
2512
+ await ensureSiteRunning(site);
2513
+
2514
+ const data = await withTimeout(
2515
+ graphqlRequest(`
2516
+ mutation($localSiteId: ID!, $remoteInstallId: ID!, $includeSql: Boolean, $confirm: Boolean) {
2517
+ pullFromWpe(localSiteId: $localSiteId, remoteInstallId: $remoteInstallId, includeSql: $includeSql, confirm: $confirm) {
2518
+ success
2519
+ message
2520
+ error
2521
+ }
2522
+ }
2523
+ `, {
2524
+ localSiteId: site.id,
2525
+ remoteInstallId: site.id, // Will be resolved by the mutation using hostConnections
2526
+ includeSql: args.include_database || false,
2527
+ confirm: args.confirm || false,
2528
+ }),
2529
+ TIMEOUT_SYNC_OPERATION,
2530
+ 'Pull from WP Engine'
2531
+ );
2532
+
2533
+ const result = data.pullFromWpe;
2534
+ if (!result.success) {
2535
+ return {
2536
+ content: [{ type: 'text', text: result.error || 'Pull failed' }],
2537
+ isError: true,
2538
+ };
2539
+ }
2540
+
2541
+ return {
2542
+ content: [{
2543
+ type: 'text',
2544
+ text: JSON.stringify({
2545
+ success: true,
2546
+ message: result.message,
2547
+ }, null, 2),
2548
+ }],
2549
+ };
2550
+ }
2551
+
2552
+ case 'get_sync_history': {
2553
+ const site = await findSite(args.site);
2554
+ if (!site) {
2555
+ return {
2556
+ content: [{ type: 'text', text: `Site not found: ${args.site}` }],
2557
+ isError: true,
2558
+ };
2559
+ }
2560
+
2561
+ const data = await graphqlRequest(`
2562
+ query($siteId: ID!, $limit: Int) {
2563
+ getSyncHistory(siteId: $siteId, limit: $limit) {
2564
+ success
2565
+ siteName
2566
+ events {
2567
+ remoteInstallName
2568
+ timestamp
2569
+ environment
2570
+ direction
2571
+ status
2572
+ }
2573
+ count
2574
+ error
2575
+ }
2576
+ }
2577
+ `, {
2578
+ siteId: site.id,
2579
+ limit: args.limit || 10,
2580
+ });
2581
+
2582
+ const result = data.getSyncHistory;
2583
+ if (!result.success) {
2584
+ return {
2585
+ content: [{ type: 'text', text: result.error || 'Failed to get sync history' }],
2586
+ isError: true,
2587
+ };
2588
+ }
2589
+
2590
+ // Format events for readability
2591
+ const formattedEvents = result.events.map(e => ({
2592
+ ...e,
2593
+ timestamp: new Date(e.timestamp).toISOString(),
2594
+ }));
2595
+
2596
+ return {
2597
+ content: [{
2598
+ type: 'text',
2599
+ text: JSON.stringify({
2600
+ siteName: result.siteName,
2601
+ events: formattedEvents,
2602
+ count: result.count,
2603
+ }, null, 2),
2604
+ }],
2605
+ };
2606
+ }
2607
+
2608
+ case 'get_site_changes': {
2609
+ const site = await findSite(args.site);
2610
+ if (!site) {
2611
+ return {
2612
+ content: [{ type: 'text', text: `Site not found: ${args.site}` }],
2613
+ isError: true,
2614
+ };
2615
+ }
2616
+
2617
+ // Auto-start site if not running
2618
+ await ensureSiteRunning(site);
2619
+
2620
+ const data = await graphqlRequest(`
2621
+ query($siteId: ID!, $direction: String) {
2622
+ getSiteChanges(siteId: $siteId, direction: $direction) {
2623
+ success
2624
+ siteName
2625
+ direction
2626
+ added {
2627
+ path
2628
+ instruction
2629
+ size
2630
+ type
2631
+ }
2632
+ modified {
2633
+ path
2634
+ instruction
2635
+ size
2636
+ type
2637
+ }
2638
+ deleted {
2639
+ path
2640
+ instruction
2641
+ size
2642
+ type
2643
+ }
2644
+ totalChanges
2645
+ message
2646
+ error
2647
+ }
2648
+ }
2649
+ `, {
2650
+ siteId: site.id,
2651
+ direction: args.direction || 'push',
2652
+ });
2653
+
2654
+ const result = data.getSiteChanges;
2655
+ if (!result.success) {
2656
+ return {
2657
+ content: [{ type: 'text', text: result.error || 'Failed to get site changes' }],
2658
+ isError: true,
2659
+ };
2660
+ }
2661
+
2662
+ // Format output for readability
2663
+ const output = {
2664
+ siteName: result.siteName,
2665
+ direction: result.direction,
2666
+ summary: result.message,
2667
+ totalChanges: result.totalChanges,
2668
+ };
2669
+
2670
+ // Only include non-empty arrays
2671
+ if (result.added.length > 0) {
2672
+ output.added = result.added.map(f => f.path);
2673
+ }
2674
+ if (result.modified.length > 0) {
2675
+ output.modified = result.modified.map(f => f.path);
2676
+ }
2677
+ if (result.deleted.length > 0) {
2678
+ output.deleted = result.deleted.map(f => f.path);
2679
+ }
2680
+
2681
+ return {
2682
+ content: [{
2683
+ type: 'text',
2684
+ text: JSON.stringify(output, null, 2),
2685
+ }],
2686
+ };
2687
+ }
2688
+
2689
+ default:
2690
+ return {
2691
+ content: [{ type: 'text', text: `Unknown tool: ${name}` }],
2692
+ isError: true,
2693
+ };
2694
+ }
2695
+ }
2696
+
2697
+ // Process MCP messages
2698
+ async function processMessage(message) {
2699
+ const { id, method, params } = message;
2700
+
2701
+ switch (method) {
2702
+ case 'initialize':
2703
+ return {
2704
+ jsonrpc: '2.0',
2705
+ id,
2706
+ result: {
2707
+ protocolVersion: '2024-11-05',
2708
+ capabilities: { tools: {} },
2709
+ serverInfo: {
2710
+ name: 'local-mcp',
2711
+ version: '1.0.0',
2712
+ },
2713
+ },
2714
+ };
2715
+
2716
+ case 'notifications/initialized':
2717
+ return null; // No response for notifications
2718
+
2719
+ case 'tools/list':
2720
+ return {
2721
+ jsonrpc: '2.0',
2722
+ id,
2723
+ result: { tools },
2724
+ };
2725
+
2726
+ case 'tools/call':
2727
+ try {
2728
+ const result = await handleTool(params.name, params.arguments || {});
2729
+ return {
2730
+ jsonrpc: '2.0',
2731
+ id,
2732
+ result,
2733
+ };
2734
+ } catch (err) {
2735
+ return {
2736
+ jsonrpc: '2.0',
2737
+ id,
2738
+ result: {
2739
+ content: [{ type: 'text', text: `Error: ${err.message}` }],
2740
+ isError: true,
2741
+ },
2742
+ };
2743
+ }
2744
+
2745
+ case 'ping':
2746
+ return {
2747
+ jsonrpc: '2.0',
2748
+ id,
2749
+ result: {},
2750
+ };
2751
+
2752
+ default:
2753
+ if (method?.startsWith('notifications/')) {
2754
+ return null;
2755
+ }
2756
+ return {
2757
+ jsonrpc: '2.0',
2758
+ id,
2759
+ error: {
2760
+ code: -32601,
2761
+ message: `Unknown method: ${method}`,
2762
+ },
2763
+ };
2764
+ }
2765
+ }
2766
+
2767
+ // Main loop
2768
+ const rl = readline.createInterface({
2769
+ input: process.stdin,
2770
+ terminal: false,
2771
+ });
2772
+
2773
+ let pendingRequests = 0;
2774
+ let closing = false;
2775
+
2776
+ function checkExit() {
2777
+ if (closing && pendingRequests === 0) {
2778
+ process.exit(0);
2779
+ }
2780
+ }
2781
+
2782
+ rl.on('line', async (line) => {
2783
+ pendingRequests++;
2784
+ try {
2785
+ const message = JSON.parse(line);
2786
+ const response = await processMessage(message);
2787
+
2788
+ if (response) {
2789
+ console.log(JSON.stringify(response));
2790
+ }
2791
+ } catch (err) {
2792
+ console.log(JSON.stringify({
2793
+ jsonrpc: '2.0',
2794
+ error: {
2795
+ code: -32700,
2796
+ message: `Parse error: ${err.message}`,
2797
+ },
2798
+ }));
2799
+ } finally {
2800
+ pendingRequests--;
2801
+ checkExit();
2802
+ }
2803
+ });
2804
+
2805
+ rl.on('close', () => {
2806
+ closing = true;
2807
+ checkExit();
2808
+ });