@adsim/wordpress-mcp-server 4.6.0 → 5.3.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 (51) hide show
  1. package/.env.example +18 -0
  2. package/README.md +867 -499
  3. package/companion/mcp-diagnostics.php +1184 -0
  4. package/dxt/manifest.json +715 -98
  5. package/index.js +166 -4786
  6. package/package.json +14 -6
  7. package/src/data/plugin-performance-data.json +59 -0
  8. package/src/plugins/adapters/acf/acfAdapter.js +55 -3
  9. package/src/shared/api.js +79 -0
  10. package/src/shared/audit.js +39 -0
  11. package/src/shared/context.js +15 -0
  12. package/src/shared/governance.js +98 -0
  13. package/src/shared/utils.js +148 -0
  14. package/src/tools/comments.js +50 -0
  15. package/src/tools/content.js +395 -0
  16. package/src/tools/core.js +114 -0
  17. package/src/tools/editorial.js +634 -0
  18. package/src/tools/fse.js +370 -0
  19. package/src/tools/health.js +160 -0
  20. package/src/tools/index.js +96 -0
  21. package/src/tools/intelligence.js +2082 -0
  22. package/src/tools/links.js +118 -0
  23. package/src/tools/media.js +71 -0
  24. package/src/tools/performance.js +219 -0
  25. package/src/tools/plugins.js +368 -0
  26. package/src/tools/schema.js +417 -0
  27. package/src/tools/security.js +590 -0
  28. package/src/tools/seo.js +1633 -0
  29. package/src/tools/taxonomy.js +115 -0
  30. package/src/tools/users.js +188 -0
  31. package/src/tools/woocommerce.js +1008 -0
  32. package/src/tools/workflow.js +409 -0
  33. package/src/transport/http.js +39 -0
  34. package/tests/unit/helpers/pagination.test.js +43 -0
  35. package/tests/unit/plugins/acf/acfAdapter.test.js +43 -5
  36. package/tests/unit/tools/bulkUpdate.test.js +188 -0
  37. package/tests/unit/tools/diagnostics.test.js +397 -0
  38. package/tests/unit/tools/dynamicFiltering.test.js +100 -8
  39. package/tests/unit/tools/editorialIntelligence.test.js +817 -0
  40. package/tests/unit/tools/fse.test.js +548 -0
  41. package/tests/unit/tools/multilingual.test.js +653 -0
  42. package/tests/unit/tools/performance.test.js +351 -0
  43. package/tests/unit/tools/postMeta.test.js +105 -0
  44. package/tests/unit/tools/runWorkflow.test.js +150 -0
  45. package/tests/unit/tools/schema.test.js +477 -0
  46. package/tests/unit/tools/security.test.js +695 -0
  47. package/tests/unit/tools/site.test.js +1 -1
  48. package/tests/unit/tools/users.crud.test.js +399 -0
  49. package/tests/unit/tools/validateBlocks.test.js +186 -0
  50. package/tests/unit/tools/visualStaging.test.js +271 -0
  51. package/tests/unit/tools/woocommerce.advanced.test.js +679 -0
@@ -0,0 +1,271 @@
1
+ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+
3
+ vi.mock('node-fetch', () => ({ default: vi.fn() }));
4
+
5
+ import fetch from 'node-fetch';
6
+ import { handleToolCall, _testSetTarget, TOOLS_DEFINITIONS } from '../../../index.js';
7
+ import { makeRequest, mockSuccess, parseResult } from '../../helpers/mockWpRequest.js';
8
+
9
+ function call(name, args = {}) {
10
+ return handleToolCall(makeRequest(name, args));
11
+ }
12
+
13
+ let consoleSpy;
14
+
15
+ beforeEach(() => {
16
+ fetch.mockReset();
17
+ consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
18
+ _testSetTarget('site1', { url: 'https://example.com', auth: 'Basic dGVzdDp0ZXN0' });
19
+ // Reset env vars
20
+ delete process.env.WP_VISUAL_STAGING;
21
+ delete process.env.WP_READ_ONLY;
22
+ });
23
+ afterEach(() => {
24
+ consoleSpy.mockRestore();
25
+ delete process.env.WP_VISUAL_STAGING;
26
+ delete process.env.WP_READ_ONLY;
27
+ });
28
+
29
+ function mockPage(overrides = {}) {
30
+ const { title, content, excerpt, ...rest } = overrides;
31
+ return {
32
+ id: 42,
33
+ title: { rendered: title || 'Live Page' },
34
+ content: { rendered: content || '<p>Live content</p>' },
35
+ excerpt: { rendered: excerpt || '' },
36
+ status: 'publish',
37
+ slug: 'live-page',
38
+ link: 'https://example.com/live-page/',
39
+ date: '2025-06-15T10:00:00',
40
+ modified: '2025-06-15T10:00:00',
41
+ parent: 0,
42
+ template: '',
43
+ meta: {},
44
+ ...rest
45
+ };
46
+ }
47
+
48
+ function mockStagingDraft(overrides = {}) {
49
+ const { title, content, excerpt, ...rest } = overrides;
50
+ return {
51
+ id: 99,
52
+ title: { rendered: title || '[STAGING] Live Page' },
53
+ content: { rendered: content || '<p>Updated staging content</p>' },
54
+ excerpt: { rendered: excerpt || '' },
55
+ status: 'draft',
56
+ slug: 'staging-live-page',
57
+ link: 'https://example.com/?p=99',
58
+ date: '2025-06-16T10:00:00',
59
+ modified: '2025-06-16T12:00:00',
60
+ meta: { _staging_source_id: 42 },
61
+ ...rest
62
+ };
63
+ }
64
+
65
+ // =========================================================================
66
+ // 1. wp_create_staging_draft — creates draft with correct _staging_source_id
67
+ // =========================================================================
68
+ describe('wp_create_staging_draft', () => {
69
+ it('creates a staging draft from a published page', async () => {
70
+ // 1st call: GET /pages/42 → published page
71
+ mockSuccess(mockPage());
72
+ // 2nd call: GET /pages?status=draft... → no existing drafts
73
+ mockSuccess([]);
74
+ // 3rd call: POST /pages → created draft
75
+ mockSuccess(mockStagingDraft());
76
+
77
+ const res = await call('wp_create_staging_draft', { source_id: 42, post_type: 'page' });
78
+ const data = parseResult(res);
79
+ expect(data.status).toBe('created');
80
+ expect(data.staging_draft_id).toBe(99);
81
+ expect(data.source_id).toBe(42);
82
+ });
83
+
84
+ // 2. Returns already_exists if draft exists
85
+ it('returns already_exists if staging draft already exists', async () => {
86
+ mockSuccess(mockPage());
87
+ mockSuccess([mockStagingDraft()]);
88
+
89
+ const res = await call('wp_create_staging_draft', { source_id: 42 });
90
+ const data = parseResult(res);
91
+ expect(data.status).toBe('already_exists');
92
+ expect(data.staging_draft_id).toBe(99);
93
+ });
94
+
95
+ // 3. Error if source is not published
96
+ it('throws error if source is not published', async () => {
97
+ mockSuccess(mockPage({ status: 'draft' }));
98
+
99
+ const res = await call('wp_create_staging_draft', { source_id: 42 });
100
+ expect(res.isError).toBe(true);
101
+ expect(res.content[0].text).toContain('Only published content can be staged');
102
+ });
103
+ });
104
+
105
+ // =========================================================================
106
+ // 4. wp_list_staging_drafts — returns list filtered by source_id
107
+ // =========================================================================
108
+ describe('wp_list_staging_drafts', () => {
109
+ it('returns staging drafts filtered by source_id', async () => {
110
+ // Posts search
111
+ mockSuccess([]);
112
+ // Pages search
113
+ mockSuccess([mockStagingDraft(), mockStagingDraft({ id: 100, meta: { _staging_source_id: 55 } })]);
114
+
115
+ const res = await call('wp_list_staging_drafts', { source_id: 42 });
116
+ const data = parseResult(res);
117
+ expect(data.total).toBe(1);
118
+ expect(data.staging_drafts[0].source_id).toBe(42);
119
+ });
120
+ });
121
+
122
+ // =========================================================================
123
+ // 5. wp_get_staging_preview_url — returns correct preview_url
124
+ // =========================================================================
125
+ describe('wp_get_staging_preview_url', () => {
126
+ it('returns preview URL for a staging draft', async () => {
127
+ mockSuccess(mockStagingDraft());
128
+
129
+ const res = await call('wp_get_staging_preview_url', { staging_id: 99 });
130
+ const data = parseResult(res);
131
+ expect(data.preview_url).toBe('https://example.com/?p=99&preview=true');
132
+ expect(data.source_id).toBe(42);
133
+ });
134
+
135
+ // 6. Error if not a staging draft
136
+ it('throws error if post is not a staging draft', async () => {
137
+ mockSuccess(mockPage({ id: 42, meta: {} }));
138
+
139
+ const res = await call('wp_get_staging_preview_url', { staging_id: 42 });
140
+ expect(res.isError).toBe(true);
141
+ expect(res.content[0].text).toContain('not a staging draft');
142
+ });
143
+ });
144
+
145
+ // =========================================================================
146
+ // 7-8. wp_merge_staging_to_live
147
+ // =========================================================================
148
+ describe('wp_merge_staging_to_live', () => {
149
+ // 7. confirm:false → dry-run
150
+ it('returns dry-run preview when confirm=false', async () => {
151
+ // GET /pages/99 → staging draft
152
+ mockSuccess(mockStagingDraft());
153
+ // GET /pages/42 → live page
154
+ mockSuccess(mockPage());
155
+
156
+ const res = await call('wp_merge_staging_to_live', { staging_id: 99, confirm: false });
157
+ const data = parseResult(res);
158
+ expect(data.status).toBe('dry_run');
159
+ expect(data.changes_preview).toBeDefined();
160
+ expect(data.changes_preview.title.from).toBe('Live Page');
161
+ });
162
+
163
+ // 8. confirm:true → merge + audit
164
+ it('merges staging draft to live when confirm=true', async () => {
165
+ // GET /pages/99 → staging draft
166
+ mockSuccess(mockStagingDraft());
167
+ // GET /pages/42 → live page (for comparison, though merge skips this in confirm path)
168
+ mockSuccess(mockPage());
169
+ // POST /pages/42 → updated live page
170
+ mockSuccess(mockPage({ modified: '2025-06-16T14:00:00' }));
171
+ // DELETE /pages/99 → deleted
172
+ mockSuccess({ deleted: true });
173
+
174
+ const res = await call('wp_merge_staging_to_live', { staging_id: 99, confirm: true });
175
+ const data = parseResult(res);
176
+ expect(data.status).toBe('merged');
177
+ expect(data.source_id).toBe(42);
178
+ });
179
+
180
+ // 9. Blocked by WP_READ_ONLY
181
+ it('is blocked when WP_READ_ONLY=true', async () => {
182
+ process.env.WP_READ_ONLY = 'true';
183
+
184
+ const res = await call('wp_merge_staging_to_live', { staging_id: 99 });
185
+ expect(res.isError).toBe(true);
186
+ expect(res.content[0].text).toContain('READ-ONLY');
187
+
188
+ delete process.env.WP_READ_ONLY;
189
+ });
190
+ });
191
+
192
+ // =========================================================================
193
+ // 10-11. wp_discard_staging_draft
194
+ // =========================================================================
195
+ describe('wp_discard_staging_draft', () => {
196
+ // 10. confirm:false → dry-run
197
+ it('returns dry-run when confirm=false', async () => {
198
+ mockSuccess(mockStagingDraft());
199
+
200
+ const res = await call('wp_discard_staging_draft', { staging_id: 99, confirm: false });
201
+ const data = parseResult(res);
202
+ expect(data.status).toBe('dry_run');
203
+ expect(data.source_id).toBe(42);
204
+ });
205
+
206
+ // 11. confirm:true → deletion + audit
207
+ it('discards staging draft when confirm=true', async () => {
208
+ mockSuccess(mockStagingDraft());
209
+ mockSuccess({ deleted: true });
210
+
211
+ const res = await call('wp_discard_staging_draft', { staging_id: 99, confirm: true });
212
+ const data = parseResult(res);
213
+ expect(data.status).toBe('discarded');
214
+ expect(data.staging_id).toBe(99);
215
+ });
216
+ });
217
+
218
+ // =========================================================================
219
+ // 12-14. WP_VISUAL_STAGING interception
220
+ // =========================================================================
221
+ describe('WP_VISUAL_STAGING interception', () => {
222
+ // 12. wp_update_post on published → intercepted
223
+ it('intercepts wp_update_post on published content when WP_VISUAL_STAGING=true', async () => {
224
+ process.env.WP_VISUAL_STAGING = 'true';
225
+ // GET /posts/42 → published post
226
+ mockSuccess({ id: 42, status: 'publish', title: { rendered: 'Test' }, content: { rendered: '' }, meta: {} });
227
+
228
+ const res = await call('wp_update_post', { id: 42, title: 'New Title' });
229
+ const data = parseResult(res);
230
+ expect(data.status).toBe('intercepted');
231
+ expect(data.reason).toContain('WP_VISUAL_STAGING');
232
+ });
233
+
234
+ // 13. wp_update_post on draft → NOT intercepted (normal flow)
235
+ it('does NOT intercept wp_update_post on draft content', async () => {
236
+ process.env.WP_VISUAL_STAGING = 'true';
237
+ // GET /posts/42 → draft post
238
+ mockSuccess({ id: 42, status: 'draft', title: { rendered: 'Test' }, content: { rendered: '' }, meta: {} });
239
+ // POST /posts/42 → updated
240
+ mockSuccess({ id: 42, status: 'draft', title: { rendered: 'New Title' }, link: 'https://example.com/?p=42', modified: '2025-06-16' });
241
+
242
+ const res = await call('wp_update_post', { id: 42, title: 'New Title' });
243
+ const data = parseResult(res);
244
+ expect(data.success).toBe(true);
245
+ });
246
+
247
+ // 14. WP_VISUAL_STAGING=false → normal (no interception)
248
+ it('does NOT intercept when WP_VISUAL_STAGING is not set', async () => {
249
+ // No WP_VISUAL_STAGING env var
250
+ // POST /posts/42 → updated
251
+ mockSuccess({ id: 42, status: 'publish', title: { rendered: 'New Title' }, link: 'https://example.com/test/', modified: '2025-06-16' });
252
+
253
+ const res = await call('wp_update_post', { id: 42, title: 'New Title' });
254
+ const data = parseResult(res);
255
+ expect(data.success).toBe(true);
256
+ });
257
+ });
258
+
259
+ // =========================================================================
260
+ // 15. TOOLS_DEFINITIONS contains the 5 new tools
261
+ // =========================================================================
262
+ describe('TOOLS_DEFINITIONS', () => {
263
+ it('contains the 5 visual staging tools with workflow category', () => {
264
+ const stagingTools = ['wp_create_staging_draft', 'wp_list_staging_drafts', 'wp_get_staging_preview_url', 'wp_merge_staging_to_live', 'wp_discard_staging_draft'];
265
+ for (const toolName of stagingTools) {
266
+ const tool = TOOLS_DEFINITIONS.find(t => t.name === toolName);
267
+ expect(tool, `${toolName} should exist in TOOLS_DEFINITIONS`).toBeDefined();
268
+ expect(tool._category).toBe('workflow');
269
+ }
270
+ });
271
+ });