@adsim/wordpress-mcp-server 4.6.0 → 5.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 (48) hide show
  1. package/.env.example +18 -0
  2. package/README.md +851 -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/shared/api.js +79 -0
  9. package/src/shared/audit.js +39 -0
  10. package/src/shared/context.js +15 -0
  11. package/src/shared/governance.js +98 -0
  12. package/src/shared/utils.js +148 -0
  13. package/src/tools/comments.js +50 -0
  14. package/src/tools/content.js +353 -0
  15. package/src/tools/core.js +114 -0
  16. package/src/tools/editorial.js +634 -0
  17. package/src/tools/fse.js +370 -0
  18. package/src/tools/health.js +160 -0
  19. package/src/tools/index.js +96 -0
  20. package/src/tools/intelligence.js +2082 -0
  21. package/src/tools/links.js +118 -0
  22. package/src/tools/media.js +71 -0
  23. package/src/tools/performance.js +219 -0
  24. package/src/tools/plugins.js +368 -0
  25. package/src/tools/schema.js +417 -0
  26. package/src/tools/security.js +590 -0
  27. package/src/tools/seo.js +1633 -0
  28. package/src/tools/taxonomy.js +115 -0
  29. package/src/tools/users.js +188 -0
  30. package/src/tools/woocommerce.js +1008 -0
  31. package/src/tools/workflow.js +409 -0
  32. package/src/transport/http.js +39 -0
  33. package/tests/unit/helpers/pagination.test.js +43 -0
  34. package/tests/unit/tools/bulkUpdate.test.js +188 -0
  35. package/tests/unit/tools/diagnostics.test.js +397 -0
  36. package/tests/unit/tools/dynamicFiltering.test.js +100 -8
  37. package/tests/unit/tools/editorialIntelligence.test.js +817 -0
  38. package/tests/unit/tools/fse.test.js +548 -0
  39. package/tests/unit/tools/multilingual.test.js +653 -0
  40. package/tests/unit/tools/performance.test.js +351 -0
  41. package/tests/unit/tools/runWorkflow.test.js +150 -0
  42. package/tests/unit/tools/schema.test.js +477 -0
  43. package/tests/unit/tools/security.test.js +695 -0
  44. package/tests/unit/tools/site.test.js +1 -1
  45. package/tests/unit/tools/users.crud.test.js +399 -0
  46. package/tests/unit/tools/validateBlocks.test.js +186 -0
  47. package/tests/unit/tools/visualStaging.test.js +271 -0
  48. package/tests/unit/tools/woocommerce.advanced.test.js +679 -0
@@ -0,0 +1,188 @@
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 } 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
+ delete process.env.WP_READ_ONLY;
20
+ });
21
+ afterEach(() => {
22
+ consoleSpy.mockRestore();
23
+ delete process.env.WP_READ_ONLY;
24
+ });
25
+
26
+ function mockPost(overrides = {}) {
27
+ const { title, content, excerpt, ...rest } = overrides;
28
+ return {
29
+ id: 1,
30
+ title: { rendered: title || 'Test Post' },
31
+ content: { rendered: content || '<p>Hello world. Replace me please.</p>' },
32
+ excerpt: { rendered: excerpt || '' },
33
+ status: 'publish',
34
+ slug: 'test-post',
35
+ link: 'https://example.com/test-post/',
36
+ date: '2025-06-15T10:00:00',
37
+ modified: '2025-06-15T10:00:00',
38
+ meta: { custom_key: 'old_value' },
39
+ ...rest
40
+ };
41
+ }
42
+
43
+ // =========================================================================
44
+ // 1. dry_run: true → returns preview without modifying
45
+ // =========================================================================
46
+ describe('wp_bulk_update', () => {
47
+ it('dry_run=true returns preview without modifying posts', async () => {
48
+ // GET /posts?... → list of IDs
49
+ mockSuccess([{ id: 1 }, { id: 2 }]);
50
+ // GET /posts/1 → post detail for preview
51
+ mockSuccess(mockPost({ id: 1 }));
52
+ // GET /posts/2 → post detail for preview
53
+ mockSuccess(mockPost({ id: 2, title: 'Post 2' }));
54
+
55
+ const res = await call('wp_bulk_update', {
56
+ filters: { status: 'publish' },
57
+ operations: [{ type: 'replace_text', params: { search: 'Hello', replace: 'Hi' } }],
58
+ dry_run: true
59
+ });
60
+ const data = parseResult(res);
61
+ expect(data.mode).toBe('dry_run');
62
+ expect(data.posts_affected).toBe(2);
63
+ expect(data.operations_preview).toHaveLength(2);
64
+ expect(data.warning).toContain('dry_run=false');
65
+ });
66
+
67
+ // =========================================================================
68
+ // 2. dry_run: false + confirm: false → preview + warning
69
+ // =========================================================================
70
+ it('dry_run=false confirm=false returns preview with confirmation warning', async () => {
71
+ mockSuccess([{ id: 1 }]);
72
+ mockSuccess(mockPost({ id: 1 }));
73
+
74
+ const res = await call('wp_bulk_update', {
75
+ filters: { category_id: 5 },
76
+ operations: [{ type: 'update_status', params: { status: 'draft' } }],
77
+ dry_run: false,
78
+ confirm: false
79
+ });
80
+ const data = parseResult(res);
81
+ expect(data.mode).toBe('preview');
82
+ expect(data.warning).toContain('confirm=true');
83
+ });
84
+
85
+ // =========================================================================
86
+ // 3. dry_run: false + confirm: true → executes + audit log
87
+ // =========================================================================
88
+ it('dry_run=false confirm=true executes bulk update', async () => {
89
+ // GET /posts?... → IDs
90
+ mockSuccess([{ id: 1 }]);
91
+ // GET /posts/1 → post detail
92
+ mockSuccess(mockPost({ id: 1 }));
93
+ // POST /posts/1 → updated
94
+ mockSuccess(mockPost({ id: 1, title: 'Updated' }));
95
+
96
+ const res = await call('wp_bulk_update', {
97
+ filters: { status: 'publish' },
98
+ operations: [{ type: 'update_status', params: { status: 'draft' } }],
99
+ dry_run: false,
100
+ confirm: true
101
+ });
102
+ const data = parseResult(res);
103
+ expect(data.success).toBe(true);
104
+ expect(data.posts_updated).toBe(1);
105
+ expect(data.posts_failed).toHaveLength(0);
106
+ expect(data.duration_ms).toBeGreaterThanOrEqual(0);
107
+ });
108
+
109
+ // =========================================================================
110
+ // 4. replace_text → correct changes preview
111
+ // =========================================================================
112
+ it('replace_text operation shows correct occurrences in preview', async () => {
113
+ mockSuccess(mockPost({ id: 1, content: '<p>foo bar foo baz foo</p>' }));
114
+
115
+ const res = await call('wp_bulk_update', {
116
+ post_ids: [1],
117
+ operations: [{ type: 'replace_text', params: { search: 'foo', replace: 'qux' } }],
118
+ dry_run: true
119
+ });
120
+ const data = parseResult(res);
121
+ const changes = data.operations_preview[0].changes;
122
+ expect(changes[0].type).toBe('replace_text');
123
+ expect(changes[0].occurrences).toBe(3);
124
+ });
125
+
126
+ // =========================================================================
127
+ // 5. update_meta → meta correctly updated
128
+ // =========================================================================
129
+ it('update_meta operation updates meta in execute mode', async () => {
130
+ mockSuccess(mockPost({ id: 1, meta: { my_key: 'old' } }));
131
+ mockSuccess(mockPost({ id: 1, meta: { my_key: 'new_val' } }));
132
+
133
+ const res = await call('wp_bulk_update', {
134
+ post_ids: [1],
135
+ operations: [{ type: 'update_meta', params: { key: 'my_key', value: 'new_val' } }],
136
+ dry_run: false,
137
+ confirm: true
138
+ });
139
+ const data = parseResult(res);
140
+ expect(data.success).toBe(true);
141
+ expect(data.posts_updated).toBe(1);
142
+ });
143
+
144
+ // =========================================================================
145
+ // 6. limit respected (max 500)
146
+ // =========================================================================
147
+ it('respects limit of 500 maximum', async () => {
148
+ const manyIds = Array.from({ length: 600 }, (_, i) => i + 1);
149
+ // post_ids mode: no filter query, goes straight to preview GETs (max 10)
150
+ for (let i = 0; i < 10; i++) {
151
+ mockSuccess(mockPost({ id: i + 1 }));
152
+ }
153
+
154
+ const res = await call('wp_bulk_update', {
155
+ post_ids: manyIds,
156
+ operations: [{ type: 'update_status', params: { status: 'draft' } }],
157
+ dry_run: true,
158
+ limit: 600
159
+ });
160
+ const data = parseResult(res);
161
+ expect(data.posts_affected).toBe(500);
162
+ });
163
+
164
+ // =========================================================================
165
+ // 7. WP_READ_ONLY=true → blocked
166
+ // =========================================================================
167
+ it('is blocked when WP_READ_ONLY=true', async () => {
168
+ process.env.WP_READ_ONLY = 'true';
169
+
170
+ const res = await call('wp_bulk_update', {
171
+ post_ids: [1],
172
+ operations: [{ type: 'update_status', params: { status: 'draft' } }]
173
+ });
174
+ expect(res.isError).toBe(true);
175
+ expect(res.content[0].text).toContain('READ-ONLY');
176
+ });
177
+
178
+ // =========================================================================
179
+ // 8. post_ids empty + filters empty → explicit error
180
+ // =========================================================================
181
+ it('returns error when neither post_ids nor filters provided', async () => {
182
+ const res = await call('wp_bulk_update', {
183
+ operations: [{ type: 'update_status', params: { status: 'draft' } }]
184
+ });
185
+ expect(res.isError).toBe(true);
186
+ expect(res.content[0].text).toContain('post_ids or at least one filter');
187
+ });
188
+ });
@@ -0,0 +1,397 @@
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 } from '../../../index.js';
7
+ import { mockSuccess, mockError, getAuditLogs, makeRequest, parseResult } from '../../helpers/mockWpRequest.js';
8
+
9
+ function call(name, args = {}) {
10
+ return handleToolCall(makeRequest(name, args));
11
+ }
12
+
13
+ // ════════════════════════════════════════════════════════════
14
+ // SITE HEALTH
15
+ // ════════════════════════════════════════════════════════════
16
+
17
+ describe('wp_get_site_health_status', () => {
18
+ let consoleSpy;
19
+ beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
20
+ afterEach(() => { consoleSpy.mockRestore(); });
21
+
22
+ it('returns health score with issue counts', async () => {
23
+ fetch.mockResolvedValue(mockSuccess([
24
+ { label: 'PHP update', status: 'critical', badge: { label: 'Security' }, description: 'Old PHP', test: 'php_version' },
25
+ { label: 'HTTPS', status: 'good', badge: { label: 'Security' }, description: 'HTTPS active', test: 'https_status' },
26
+ { label: 'Debug mode', status: 'recommended', badge: { label: 'Performance' }, description: 'Debug on', test: 'debug_enabled' }
27
+ ]));
28
+ const result = await call('wp_get_site_health_status');
29
+ const data = parseResult(result);
30
+ expect(data.score).toBe('critical');
31
+ expect(data.total_issues).toBe(3);
32
+ expect(data.counts.critical).toBe(1);
33
+ expect(data.counts.recommended).toBe(1);
34
+ expect(data.counts.good).toBe(1);
35
+ });
36
+
37
+ it('returns good when no issues', async () => {
38
+ fetch.mockResolvedValue(mockSuccess([]));
39
+ const result = await call('wp_get_site_health_status');
40
+ const data = parseResult(result);
41
+ expect(data.score).toBe('good');
42
+ expect(data.total_issues).toBe(0);
43
+ });
44
+
45
+ it('logs audit entry', async () => {
46
+ fetch.mockResolvedValue(mockSuccess([]));
47
+ await call('wp_get_site_health_status');
48
+ const logs = getAuditLogs();
49
+ expect(logs.find(l => l.tool === 'wp_get_site_health_status')).toBeDefined();
50
+ });
51
+ });
52
+
53
+ describe('wp_list_site_health_issues', () => {
54
+ let consoleSpy;
55
+ beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
56
+ afterEach(() => { consoleSpy.mockRestore(); });
57
+
58
+ it('returns all issues', async () => {
59
+ fetch.mockResolvedValue(mockSuccess([
60
+ { label: 'Issue 1', status: 'critical', badge: { label: 'Security' }, description: '<p>Desc 1</p>', actions: '', test: 'test_1' },
61
+ { label: 'Issue 2', status: 'recommended', badge: { label: 'Perf' }, description: '<p>Desc 2</p>', actions: '', test: 'test_2' }
62
+ ]));
63
+ const result = await call('wp_list_site_health_issues');
64
+ const data = parseResult(result);
65
+ expect(data.total).toBe(2);
66
+ expect(data.filter).toBe('all');
67
+ expect(data.issues[0].label).toBe('Issue 1');
68
+ });
69
+
70
+ it('filters by severity', async () => {
71
+ fetch.mockResolvedValue(mockSuccess([
72
+ { label: 'Critical', status: 'critical', badge: {}, description: '', actions: '', test: 't1' },
73
+ { label: 'Good', status: 'good', badge: {}, description: '', actions: '', test: 't2' }
74
+ ]));
75
+ const result = await call('wp_list_site_health_issues', { severity: 'critical' });
76
+ const data = parseResult(result);
77
+ expect(data.total).toBe(1);
78
+ expect(data.issues[0].label).toBe('Critical');
79
+ });
80
+ });
81
+
82
+ describe('wp_get_site_health_info', () => {
83
+ let consoleSpy;
84
+ beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
85
+ afterEach(() => { consoleSpy.mockRestore(); });
86
+
87
+ it('returns full system info summary', async () => {
88
+ fetch.mockResolvedValue(mockSuccess({
89
+ 'wp-server': { label: 'Server', fields: { php_version: { label: 'PHP', value: '8.2.0' }, server_architecture: { label: 'Arch', value: 'Linux x86_64' } } },
90
+ 'wp-database': { label: 'Database', fields: { server_version: { label: 'MySQL', value: '8.0.33' } } }
91
+ }));
92
+ const result = await call('wp_get_site_health_info');
93
+ const data = parseResult(result);
94
+ expect(data['wp-server'].label).toBe('Server');
95
+ expect(data['wp-server'].fields.php_version).toBe('8.2.0');
96
+ expect(data['wp-database'].fields.server_version).toBe('8.0.33');
97
+ });
98
+
99
+ it('returns single section when requested', async () => {
100
+ fetch.mockResolvedValue(mockSuccess({
101
+ 'wp-server': { label: 'Server', fields: { php_version: { label: 'PHP', value: '8.2.0' } } },
102
+ 'wp-database': { label: 'Database', fields: { server_version: { label: 'MySQL', value: '8.0.33' } } }
103
+ }));
104
+ const result = await call('wp_get_site_health_info', { section: 'wp-server' });
105
+ const data = parseResult(result);
106
+ expect(data.section).toBe('wp-server');
107
+ expect(data.label).toBe('Server');
108
+ });
109
+
110
+ it('errors on invalid section', async () => {
111
+ fetch.mockResolvedValue(mockSuccess({
112
+ 'wp-server': { label: 'Server', fields: {} }
113
+ }));
114
+ const result = await call('wp_get_site_health_info', { section: 'nonexistent' });
115
+ expect(result.isError).toBe(true);
116
+ expect(result.content[0].text).toContain('not found');
117
+ });
118
+ });
119
+
120
+ // ════════════════════════════════════════════════════════════
121
+ // DEBUG & CRON
122
+ // ════════════════════════════════════════════════════════════
123
+
124
+ describe('wp_get_debug_log', () => {
125
+ let consoleSpy;
126
+ beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
127
+ afterEach(() => { consoleSpy.mockRestore(); });
128
+
129
+ it('returns debug log lines', async () => {
130
+ fetch.mockResolvedValue(mockSuccess({
131
+ lines: ['[01-Jan-2024] PHP Warning: test', '[01-Jan-2024] PHP Fatal error: crash'],
132
+ total_lines: 2,
133
+ file_size: 1024,
134
+ last_modified: '2024-01-01T00:00:00Z'
135
+ }));
136
+ const result = await call('wp_get_debug_log', { lines: 50, level: 'all' });
137
+ const data = parseResult(result);
138
+ expect(data.lines_returned).toBe(2);
139
+ expect(data.file_size).toBe(1024);
140
+ expect(data.lines).toHaveLength(2);
141
+ });
142
+
143
+ it('passes correct basePath for mcp-diagnostics', async () => {
144
+ fetch.mockResolvedValue(mockSuccess({ lines: [], total_lines: 0 }));
145
+ await call('wp_get_debug_log');
146
+ const [url] = fetch.mock.calls[0];
147
+ expect(url).toContain('/wp-json/mcp-diagnostics/v1/debug-log');
148
+ });
149
+
150
+ it('returns error on 404 (plugin not installed)', async () => {
151
+ fetch.mockResolvedValue(mockError(404, 'No route'));
152
+ const result = await call('wp_get_debug_log');
153
+ expect(result.isError).toBe(true);
154
+ });
155
+ });
156
+
157
+ describe('wp_get_cron_events', () => {
158
+ let consoleSpy;
159
+ beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
160
+ afterEach(() => { consoleSpy.mockRestore(); });
161
+
162
+ it('returns cron events with overdue detection', async () => {
163
+ const pastTimestamp = Math.floor(Date.now() / 1000) - 3600;
164
+ const futureTimestamp = Math.floor(Date.now() / 1000) + 3600;
165
+ fetch.mockResolvedValue(mockSuccess({
166
+ events: [
167
+ { hook: 'wp_cron_test', args: [], schedule: 'hourly', interval: 3600, next_run: pastTimestamp },
168
+ { hook: 'wp_update_check', args: [], schedule: 'twicedaily', interval: 43200, next_run: futureTimestamp }
169
+ ]
170
+ }));
171
+ const result = await call('wp_get_cron_events');
172
+ const data = parseResult(result);
173
+ expect(data.total).toBe(2);
174
+ expect(data.events[0].overdue).toBe(true);
175
+ expect(data.events[1].overdue).toBe(false);
176
+ expect(data.events[0].next_run_date).toBeTruthy();
177
+ });
178
+
179
+ it('filters by hook name', async () => {
180
+ fetch.mockResolvedValue(mockSuccess({
181
+ events: [
182
+ { hook: 'wp_cron_test', args: [], schedule: 'hourly', interval: 3600, next_run: 1704067200 },
183
+ { hook: 'other_hook', args: [], schedule: 'daily', interval: 86400, next_run: 1704067200 }
184
+ ]
185
+ }));
186
+ const result = await call('wp_get_cron_events', { hook: 'wp_cron_test' });
187
+ const data = parseResult(result);
188
+ expect(data.total).toBe(1);
189
+ expect(data.events[0].hook).toBe('wp_cron_test');
190
+ });
191
+ });
192
+
193
+ describe('wp_get_transients', () => {
194
+ let consoleSpy;
195
+ beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
196
+ afterEach(() => { consoleSpy.mockRestore(); });
197
+
198
+ it('returns transients with expiration info', async () => {
199
+ const futureExp = Math.floor(Date.now() / 1000) + 3600;
200
+ const pastExp = Math.floor(Date.now() / 1000) - 3600;
201
+ fetch.mockResolvedValue(mockSuccess({
202
+ transients: [
203
+ { key: 'cache_data', expiration: futureExp, size_bytes: 512 },
204
+ { key: 'old_cache', expiration: pastExp, size_bytes: 128 }
205
+ ]
206
+ }));
207
+ const result = await call('wp_get_transients');
208
+ const data = parseResult(result);
209
+ expect(data.total).toBe(2);
210
+ expect(data.transients[0].expired).toBe(false);
211
+ expect(data.transients[1].expired).toBe(true);
212
+ expect(data.transients[0].expiration_date).toBeTruthy();
213
+ });
214
+
215
+ it('handles transients with no expiry', async () => {
216
+ fetch.mockResolvedValue(mockSuccess({
217
+ transients: [
218
+ { key: 'permanent', expiration: 0, size_bytes: 256 }
219
+ ]
220
+ }));
221
+ const result = await call('wp_get_transients');
222
+ const data = parseResult(result);
223
+ expect(data.transients[0].expiration_date).toBe('no expiry');
224
+ expect(data.transients[0].expired).toBe(false);
225
+ });
226
+
227
+ it('passes correct basePath', async () => {
228
+ fetch.mockResolvedValue(mockSuccess({ transients: [] }));
229
+ await call('wp_get_transients', { filter: 'expired', search: 'cache' });
230
+ const [url] = fetch.mock.calls[0];
231
+ expect(url).toContain('/wp-json/mcp-diagnostics/v1/transients');
232
+ expect(url).toContain('filter=expired');
233
+ expect(url).toContain('search=cache');
234
+ });
235
+ });
236
+
237
+ // ════════════════════════════════════════════════════════════
238
+ // PLUGIN COMPATIBILITY
239
+ // ════════════════════════════════════════════════════════════
240
+
241
+ describe('wp_check_php_compatibility', () => {
242
+ let consoleSpy;
243
+ beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
244
+ afterEach(() => { consoleSpy.mockRestore(); });
245
+
246
+ it('reports compatible and incompatible plugins', async () => {
247
+ // First call: /plugins, Second call: /info (site health)
248
+ fetch
249
+ .mockImplementationOnce(() => Promise.resolve({
250
+ ok: true, status: 200,
251
+ headers: { get: (h) => h === 'content-type' ? 'application/json' : null },
252
+ json: () => Promise.resolve([
253
+ { plugin: 'akismet/akismet.php', name: 'Akismet', version: '5.0', status: 'active', requires_php: '7.4' },
254
+ { plugin: 'legacy/legacy.php', name: 'Legacy Plugin', version: '1.0', status: 'active', requires_php: '8.3' },
255
+ { plugin: 'noinfo/noinfo.php', name: 'No Info', version: '2.0', status: 'active', requires_php: null },
256
+ { plugin: 'inactive/inactive.php', name: 'Inactive', version: '1.0', status: 'inactive', requires_php: '7.0' }
257
+ ]),
258
+ text: () => Promise.resolve('[]')
259
+ }))
260
+ .mockImplementationOnce(() => Promise.resolve({
261
+ ok: true, status: 200,
262
+ headers: { get: (h) => h === 'content-type' ? 'application/json' : null },
263
+ json: () => Promise.resolve({
264
+ 'wp-server': { label: 'Server', fields: { php_version: { label: 'PHP', value: '8.2.0' } } }
265
+ }),
266
+ text: () => Promise.resolve('{}')
267
+ }));
268
+
269
+ const result = await call('wp_check_php_compatibility');
270
+ const data = parseResult(result);
271
+ expect(data.php_version).toBe('8.2.0');
272
+ expect(data.total_active).toBe(3); // Only active plugins
273
+ expect(data.incompatible_count).toBe(1); // legacy requires 8.3, current is 8.2
274
+ const akismet = data.plugins.find(p => p.name === 'Akismet');
275
+ expect(akismet.status).toBe('compatible');
276
+ const legacy = data.plugins.find(p => p.name === 'Legacy Plugin');
277
+ expect(legacy.status).toBe('incompatible');
278
+ const noinfo = data.plugins.find(p => p.name === 'No Info');
279
+ expect(noinfo.status).toBe('unknown');
280
+ });
281
+
282
+ it('logs audit entry', async () => {
283
+ fetch
284
+ .mockImplementationOnce(() => Promise.resolve({
285
+ ok: true, status: 200,
286
+ headers: { get: (h) => h === 'content-type' ? 'application/json' : null },
287
+ json: () => Promise.resolve([]),
288
+ text: () => Promise.resolve('[]')
289
+ }))
290
+ .mockImplementationOnce(() => Promise.resolve({
291
+ ok: true, status: 200,
292
+ headers: { get: (h) => h === 'content-type' ? 'application/json' : null },
293
+ json: () => Promise.resolve({ 'wp-server': { label: 'Server', fields: { php_version: { value: '8.2.0' } } } }),
294
+ text: () => Promise.resolve('{}')
295
+ }));
296
+
297
+ await call('wp_check_php_compatibility');
298
+ const logs = getAuditLogs();
299
+ expect(logs.find(l => l.tool === 'wp_check_php_compatibility')).toBeDefined();
300
+ });
301
+ });
302
+
303
+ describe('wp_get_active_hooks', () => {
304
+ let consoleSpy;
305
+ beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
306
+ afterEach(() => { consoleSpy.mockRestore(); });
307
+
308
+ it('returns hooks with callbacks', async () => {
309
+ fetch.mockResolvedValue(mockSuccess({
310
+ hooks: [
311
+ { name: 'init', type: 'action', callbacks: [{ function: 'wp_init', priority: 10, accepted_args: 0 }] },
312
+ { name: 'the_content', type: 'filter', callbacks: [{ function: 'wpautop', priority: 10, accepted_args: 1 }, { function: 'do_shortcode', priority: 11, accepted_args: 1 }] }
313
+ ]
314
+ }));
315
+ const result = await call('wp_get_active_hooks', { type: 'all' });
316
+ const data = parseResult(result);
317
+ expect(data.total).toBe(2);
318
+ expect(data.hooks[0].name).toBe('init');
319
+ expect(data.hooks[0].type).toBe('action');
320
+ expect(data.hooks[1].callbacks).toHaveLength(2);
321
+ });
322
+
323
+ it('passes search and type params', async () => {
324
+ fetch.mockResolvedValue(mockSuccess({ hooks: [] }));
325
+ await call('wp_get_active_hooks', { type: 'filters', search: 'content', per_page: 25 });
326
+ const [url] = fetch.mock.calls[0];
327
+ expect(url).toContain('/wp-json/mcp-diagnostics/v1/hooks');
328
+ expect(url).toContain('type=filters');
329
+ expect(url).toContain('search=content');
330
+ expect(url).toContain('per_page=25');
331
+ });
332
+
333
+ it('returns error on 404 (plugin not installed)', async () => {
334
+ fetch.mockResolvedValue(mockError(404, 'No route'));
335
+ const result = await call('wp_get_active_hooks');
336
+ expect(result.isError).toBe(true);
337
+ });
338
+ });
339
+
340
+ // ════════════════════════════════════════════════════════════
341
+ // READ-ONLY CHECK — All diagnostic tools are read-only
342
+ // ════════════════════════════════════════════════════════════
343
+
344
+ describe('Diagnostic tools are not blocked by WP_READ_ONLY', () => {
345
+ let consoleSpy;
346
+ beforeEach(() => {
347
+ fetch.mockReset();
348
+ consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
349
+ process.env.WP_READ_ONLY = 'true';
350
+ });
351
+ afterEach(() => {
352
+ consoleSpy.mockRestore();
353
+ delete process.env.WP_READ_ONLY;
354
+ });
355
+
356
+ const readOnlyTools = [
357
+ 'wp_get_site_health_status',
358
+ 'wp_list_site_health_issues',
359
+ 'wp_get_site_health_info',
360
+ 'wp_get_debug_log',
361
+ 'wp_get_cron_events',
362
+ 'wp_get_transients',
363
+ 'wp_check_php_compatibility',
364
+ 'wp_get_active_hooks'
365
+ ];
366
+
367
+ readOnlyTools.forEach(toolName => {
368
+ it(`${toolName} is not blocked by WP_READ_ONLY`, async () => {
369
+ // Mock a generic successful response
370
+ fetch.mockResolvedValue(mockSuccess(
371
+ toolName.includes('list') || toolName.includes('events') || toolName.includes('hooks')
372
+ ? []
373
+ : {}
374
+ ));
375
+ // For wp_check_php_compatibility which makes 2 calls
376
+ if (toolName === 'wp_check_php_compatibility') {
377
+ fetch
378
+ .mockImplementationOnce(() => Promise.resolve({
379
+ ok: true, status: 200,
380
+ headers: { get: (h) => h === 'content-type' ? 'application/json' : null },
381
+ json: () => Promise.resolve([]),
382
+ text: () => Promise.resolve('[]')
383
+ }))
384
+ .mockImplementationOnce(() => Promise.resolve({
385
+ ok: true, status: 200,
386
+ headers: { get: (h) => h === 'content-type' ? 'application/json' : null },
387
+ json: () => Promise.resolve({ 'wp-server': { label: 'S', fields: { php_version: { value: '8.2' } } } }),
388
+ text: () => Promise.resolve('{}')
389
+ }));
390
+ }
391
+
392
+ const result = await call(toolName);
393
+ // Should NOT be blocked
394
+ expect(result.isError).toBeFalsy();
395
+ });
396
+ });
397
+ });