@adsim/wordpress-mcp-server 1.0.0 → 3.0.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 (64) hide show
  1. package/.env.example +8 -0
  2. package/.github/workflows/ci.yml +20 -0
  3. package/LICENSE +1 -1
  4. package/README.md +596 -135
  5. package/index.js +1367 -0
  6. package/package.json +21 -33
  7. package/src/auth/bearer.js +72 -0
  8. package/src/transport/http.js +264 -0
  9. package/tests/helpers/mockWpRequest.js +135 -0
  10. package/tests/unit/governance.test.js +260 -0
  11. package/tests/unit/tools/comments.test.js +170 -0
  12. package/tests/unit/tools/media.test.js +279 -0
  13. package/tests/unit/tools/pages.test.js +222 -0
  14. package/tests/unit/tools/plugins.test.js +268 -0
  15. package/tests/unit/tools/posts.test.js +310 -0
  16. package/tests/unit/tools/revisions.test.js +299 -0
  17. package/tests/unit/tools/search.test.js +190 -0
  18. package/tests/unit/tools/seo.test.js +248 -0
  19. package/tests/unit/tools/site.test.js +133 -0
  20. package/tests/unit/tools/taxonomies.test.js +220 -0
  21. package/tests/unit/tools/themes.test.js +163 -0
  22. package/tests/unit/tools/users.test.js +113 -0
  23. package/tests/unit/transport/http.test.js +300 -0
  24. package/vitest.config.js +12 -0
  25. package/dist/constants.d.ts +0 -13
  26. package/dist/constants.d.ts.map +0 -1
  27. package/dist/constants.js +0 -10
  28. package/dist/constants.js.map +0 -1
  29. package/dist/index.d.ts +0 -3
  30. package/dist/index.d.ts.map +0 -1
  31. package/dist/index.js +0 -33
  32. package/dist/index.js.map +0 -1
  33. package/dist/schemas/index.d.ts +0 -308
  34. package/dist/schemas/index.d.ts.map +0 -1
  35. package/dist/schemas/index.js +0 -191
  36. package/dist/schemas/index.js.map +0 -1
  37. package/dist/services/formatters.d.ts +0 -22
  38. package/dist/services/formatters.d.ts.map +0 -1
  39. package/dist/services/formatters.js +0 -52
  40. package/dist/services/formatters.js.map +0 -1
  41. package/dist/services/wp-client.d.ts +0 -38
  42. package/dist/services/wp-client.d.ts.map +0 -1
  43. package/dist/services/wp-client.js +0 -102
  44. package/dist/services/wp-client.js.map +0 -1
  45. package/dist/tools/content.d.ts +0 -4
  46. package/dist/tools/content.d.ts.map +0 -1
  47. package/dist/tools/content.js +0 -196
  48. package/dist/tools/content.js.map +0 -1
  49. package/dist/tools/posts.d.ts +0 -4
  50. package/dist/tools/posts.d.ts.map +0 -1
  51. package/dist/tools/posts.js +0 -179
  52. package/dist/tools/posts.js.map +0 -1
  53. package/dist/tools/seo.d.ts +0 -4
  54. package/dist/tools/seo.d.ts.map +0 -1
  55. package/dist/tools/seo.js +0 -241
  56. package/dist/tools/seo.js.map +0 -1
  57. package/dist/tools/taxonomy.d.ts +0 -4
  58. package/dist/tools/taxonomy.d.ts.map +0 -1
  59. package/dist/tools/taxonomy.js +0 -82
  60. package/dist/tools/taxonomy.js.map +0 -1
  61. package/dist/types.d.ts +0 -160
  62. package/dist/types.d.ts.map +0 -1
  63. package/dist/types.js +0 -3
  64. package/dist/types.js.map +0 -1
@@ -0,0 +1,299 @@
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
+ let consoleSpy;
14
+ beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
15
+ afterEach(() => { consoleSpy.mockRestore(); });
16
+
17
+ // =========================================================================
18
+ // Shared mock data
19
+ // =========================================================================
20
+
21
+ const revisionsList = [
22
+ {
23
+ id: 101,
24
+ parent: 1,
25
+ date: '2024-01-01',
26
+ date_gmt: '2024-01-01T00:00:00',
27
+ modified: '2024-01-01',
28
+ modified_gmt: '2024-01-01T00:00:00',
29
+ author: 1,
30
+ title: { rendered: 'Revision 1' },
31
+ excerpt: { rendered: '<p>Excerpt</p>' },
32
+ slug: '1-revision-v1',
33
+ },
34
+ {
35
+ id: 102,
36
+ parent: 1,
37
+ date: '2024-01-02',
38
+ date_gmt: '2024-01-02T00:00:00',
39
+ modified: '2024-01-02',
40
+ modified_gmt: '2024-01-02T00:00:00',
41
+ author: 1,
42
+ title: { rendered: 'Revision 2' },
43
+ excerpt: { rendered: '' },
44
+ slug: '1-revision-v2',
45
+ },
46
+ ];
47
+
48
+ const singleRevision = {
49
+ id: 101,
50
+ parent: 1,
51
+ date: '2024-01-01',
52
+ author: 1,
53
+ title: { rendered: 'Revision 1' },
54
+ content: { rendered: '<p>Old content</p>' },
55
+ excerpt: { rendered: '<p>Old excerpt</p>' },
56
+ };
57
+
58
+ const restoreRevisionData = {
59
+ id: 101,
60
+ parent: 1,
61
+ title: { raw: 'Old Title', rendered: 'Old Title' },
62
+ content: { raw: '<p>Old content</p>', rendered: '<p>Old content</p>' },
63
+ };
64
+
65
+ const restoreUpdateResponse = {
66
+ id: 1,
67
+ title: { rendered: 'Old Title' },
68
+ status: 'publish',
69
+ };
70
+
71
+ // =========================================================================
72
+ // wp_list_revisions
73
+ // =========================================================================
74
+
75
+ describe('wp_list_revisions', () => {
76
+ it('SUCCESS — returns total, post_id, post_type, note, and revisions without content', async () => {
77
+ mockSuccess(revisionsList);
78
+
79
+ const res = await call('wp_list_revisions', { post_id: 1 });
80
+ const data = parseResult(res);
81
+
82
+ expect(data.total).toBe(2);
83
+ expect(data.post_id).toBe(1);
84
+ expect(data.post_type).toBe('post');
85
+ expect(data.note).toContain('wp_get_revision');
86
+ expect(data.revisions).toHaveLength(2);
87
+
88
+ // Revisions should not include content field
89
+ const rev = data.revisions[0];
90
+ expect(rev.id).toBe(101);
91
+ expect(rev.parent).toBe(1);
92
+ expect(rev.date).toBe('2024-01-01');
93
+ expect(rev.author).toBe(1);
94
+ expect(rev.title).toBe('Revision 1');
95
+ expect(rev.slug).toBe('1-revision-v1');
96
+ expect(rev).not.toHaveProperty('content');
97
+ });
98
+
99
+ it('ERROR 404 — returns message about "no revisions available"', async () => {
100
+ mockError(404, '{"code":"rest_post_not_found"}');
101
+
102
+ const res = await call('wp_list_revisions', { post_id: 999 });
103
+ expect(res.isError).toBe(true);
104
+ expect(res.content[0].text).toContain('no revisions available');
105
+ });
106
+
107
+ it('AUDIT — logs list action on success', async () => {
108
+ mockSuccess(revisionsList);
109
+
110
+ await call('wp_list_revisions', { post_id: 1 });
111
+
112
+ const logs = getAuditLogs();
113
+ const entry = logs.find(l => l.tool === 'wp_list_revisions');
114
+ expect(entry).toBeDefined();
115
+ expect(entry.status).toBe('success');
116
+ });
117
+ });
118
+
119
+ // =========================================================================
120
+ // wp_get_revision
121
+ // =========================================================================
122
+
123
+ describe('wp_get_revision', () => {
124
+ it('SUCCESS — returns full revision with content', async () => {
125
+ mockSuccess(singleRevision);
126
+
127
+ const res = await call('wp_get_revision', { post_id: 1, revision_id: 101 });
128
+ const data = parseResult(res);
129
+
130
+ expect(data.id).toBe(101);
131
+ expect(data.parent).toBe(1);
132
+ expect(data.date).toBe('2024-01-01');
133
+ expect(data.author).toBe(1);
134
+ expect(data.title).toBe('Revision 1');
135
+ expect(data.content).toBe('<p>Old content</p>');
136
+ expect(data.excerpt).toBe('<p>Old excerpt</p>');
137
+ });
138
+
139
+ it('ERROR 404 — returns message mentioning wp_list_revisions', async () => {
140
+ mockError(404, '{"code":"rest_revision_not_found"}');
141
+
142
+ const res = await call('wp_get_revision', { post_id: 1, revision_id: 999 });
143
+ expect(res.isError).toBe(true);
144
+ expect(res.content[0].text).toContain('wp_list_revisions');
145
+ });
146
+
147
+ it('AUDIT — logs read action on success', async () => {
148
+ mockSuccess(singleRevision);
149
+
150
+ await call('wp_get_revision', { post_id: 1, revision_id: 101 });
151
+
152
+ const logs = getAuditLogs();
153
+ const entry = logs.find(l => l.tool === 'wp_get_revision');
154
+ expect(entry).toBeDefined();
155
+ expect(entry.status).toBe('success');
156
+ });
157
+ });
158
+
159
+ // =========================================================================
160
+ // wp_restore_revision
161
+ // =========================================================================
162
+
163
+ describe('wp_restore_revision', () => {
164
+ it('SUCCESS — restores revision and verifies PUT/POST call with revision data', async () => {
165
+ // First call: GET revision data
166
+ mockSuccess(restoreRevisionData);
167
+ // Second call: POST update to post
168
+ mockSuccess(restoreUpdateResponse);
169
+
170
+ const res = await call('wp_restore_revision', { post_id: 1, revision_id: 101 });
171
+ const data = parseResult(res);
172
+
173
+ expect(data.restored).toBe(true);
174
+ expect(data.post_id).toBe(1);
175
+ expect(data.revision_id).toBe(101);
176
+ expect(data.title).toBe('Old Title');
177
+
178
+ // Verify the second fetch call sent the revision's title and content
179
+ expect(fetch.mock.calls.length).toBeGreaterThanOrEqual(2);
180
+ const updateCall = fetch.mock.calls[1];
181
+ const body = JSON.parse(updateCall[1].body);
182
+ expect(body.title).toBe('Old Title');
183
+ expect(body.content).toBe('<p>Old content</p>');
184
+ });
185
+
186
+ it('GOVERNANCE — blocked by WP_READ_ONLY', async () => {
187
+ const original = process.env.WP_READ_ONLY;
188
+ process.env.WP_READ_ONLY = 'true';
189
+ try {
190
+ const res = await call('wp_restore_revision', { post_id: 1, revision_id: 101 });
191
+ expect(res.isError).toBe(true);
192
+ expect(res.content[0].text).toContain('READ-ONLY');
193
+ } finally {
194
+ if (original === undefined) delete process.env.WP_READ_ONLY;
195
+ else process.env.WP_READ_ONLY = original;
196
+ }
197
+ });
198
+
199
+ it('ERROR 404 on GET revision — does not attempt PUT', async () => {
200
+ mockError(404, '{"code":"rest_revision_not_found"}');
201
+
202
+ const res = await call('wp_restore_revision', { post_id: 1, revision_id: 999 });
203
+ expect(res.isError).toBe(true);
204
+
205
+ // Only one fetch call should have been made (the GET), no PUT/POST
206
+ expect(fetch.mock.calls).toHaveLength(1);
207
+ });
208
+
209
+ it('AUDIT — logs entries on success', async () => {
210
+ mockSuccess(restoreRevisionData);
211
+ mockSuccess(restoreUpdateResponse);
212
+
213
+ await call('wp_restore_revision', { post_id: 1, revision_id: 101 });
214
+
215
+ const logs = getAuditLogs();
216
+ const entries = logs.filter(l => l.tool === 'wp_restore_revision');
217
+ expect(entries.length).toBeGreaterThanOrEqual(1);
218
+ expect(entries.some(e => e.status === 'success')).toBe(true);
219
+ });
220
+ });
221
+
222
+ // =========================================================================
223
+ // wp_delete_revision
224
+ // =========================================================================
225
+
226
+ describe('wp_delete_revision', () => {
227
+ it('SUCCESS — returns deleted true with revision_id and post_id', async () => {
228
+ mockSuccess({ deleted: true });
229
+
230
+ const res = await call('wp_delete_revision', { post_id: 1, revision_id: 101 });
231
+ const data = parseResult(res);
232
+
233
+ expect(data.deleted).toBe(true);
234
+ expect(data.revision_id).toBe(101);
235
+ expect(data.post_id).toBe(1);
236
+ });
237
+
238
+ it('SUCCESS — force=true is present in the fetch URL', async () => {
239
+ mockSuccess({ deleted: true });
240
+
241
+ await call('wp_delete_revision', { post_id: 1, revision_id: 101 });
242
+
243
+ const fetchUrl = fetch.mock.calls[0][0];
244
+ expect(fetchUrl).toContain('force=true');
245
+ });
246
+
247
+ it('GOVERNANCE — blocked by WP_READ_ONLY', async () => {
248
+ const original = process.env.WP_READ_ONLY;
249
+ process.env.WP_READ_ONLY = 'true';
250
+ try {
251
+ const res = await call('wp_delete_revision', { post_id: 1, revision_id: 101 });
252
+ expect(res.isError).toBe(true);
253
+ expect(res.content[0].text).toContain('READ-ONLY');
254
+ } finally {
255
+ if (original === undefined) delete process.env.WP_READ_ONLY;
256
+ else process.env.WP_READ_ONLY = original;
257
+ }
258
+ });
259
+
260
+ it('GOVERNANCE — blocked by WP_DISABLE_DELETE', async () => {
261
+ const original = process.env.WP_DISABLE_DELETE;
262
+ process.env.WP_DISABLE_DELETE = 'true';
263
+ try {
264
+ const res = await call('wp_delete_revision', { post_id: 1, revision_id: 101 });
265
+ expect(res.isError).toBe(true);
266
+ expect(res.content[0].text).toContain('WP_DISABLE_DELETE');
267
+ } finally {
268
+ if (original === undefined) delete process.env.WP_DISABLE_DELETE;
269
+ else process.env.WP_DISABLE_DELETE = original;
270
+ }
271
+ });
272
+
273
+ it('ERROR 404 — returns revision not found message', async () => {
274
+ mockError(404, '{"code":"rest_revision_not_found"}');
275
+
276
+ const res = await call('wp_delete_revision', { post_id: 1, revision_id: 999 });
277
+ expect(res.isError).toBe(true);
278
+ expect(res.content[0].text).toContain('Revision not found');
279
+ });
280
+
281
+ it('ERROR 403 — returns message about delete_posts capability', async () => {
282
+ mockError(403, '{"code":"rest_forbidden"}');
283
+
284
+ const res = await call('wp_delete_revision', { post_id: 1, revision_id: 101 });
285
+ expect(res.isError).toBe(true);
286
+ expect(res.content[0].text).toContain('delete_posts');
287
+ });
288
+
289
+ it('AUDIT — logs permanent_delete action on success', async () => {
290
+ mockSuccess({ deleted: true });
291
+
292
+ await call('wp_delete_revision', { post_id: 1, revision_id: 101 });
293
+
294
+ const logs = getAuditLogs();
295
+ const entry = logs.find(l => l.tool === 'wp_delete_revision');
296
+ expect(entry).toBeDefined();
297
+ expect(entry.status).toBe('success');
298
+ });
299
+ });
@@ -0,0 +1,190 @@
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
+ let consoleSpy;
14
+ beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
15
+ afterEach(() => { consoleSpy.mockRestore(); });
16
+
17
+ // =========================================================================
18
+ // wp_search
19
+ // =========================================================================
20
+
21
+ describe('wp_search', () => {
22
+ const searchResults = [
23
+ { id: 1, title: 'Post 1', url: 'https://test.example.com/post-1', type: 'post', subtype: 'post' },
24
+ { id: 10, title: 'About', url: 'https://test.example.com/about', type: 'post', subtype: 'page' },
25
+ ];
26
+
27
+ it('SUCCESS — returns query, total, and results array', async () => {
28
+ mockSuccess(searchResults);
29
+
30
+ const res = await call('wp_search', { search: 'test' });
31
+ const data = parseResult(res);
32
+
33
+ expect(data.query).toBe('test');
34
+ expect(data.total).toBe(2);
35
+ expect(data.results).toHaveLength(2);
36
+
37
+ const first = data.results[0];
38
+ expect(first).toEqual(
39
+ expect.objectContaining({ id: 1, title: 'Post 1', url: 'https://test.example.com/post-1', type: 'post', subtype: 'post' })
40
+ );
41
+ const second = data.results[1];
42
+ expect(second).toEqual(
43
+ expect.objectContaining({ id: 10, title: 'About', url: 'https://test.example.com/about', type: 'post', subtype: 'page' })
44
+ );
45
+ });
46
+
47
+ it('ERROR 403 — returns error message', async () => {
48
+ mockError(403, '{"code":"rest_forbidden"}');
49
+
50
+ const res = await call('wp_search', { search: 'secret' });
51
+ expect(res.isError).toBe(true);
52
+ expect(res.content[0].text).toContain('403');
53
+ });
54
+
55
+ it('AUDIT — logs search action on success', async () => {
56
+ mockSuccess(searchResults);
57
+
58
+ await call('wp_search', { search: 'audit-test' });
59
+
60
+ const logs = getAuditLogs();
61
+ const entry = logs.find(l => l.tool === 'wp_search');
62
+ expect(entry).toBeDefined();
63
+ expect(entry.status).toBe('success');
64
+ expect(entry.action).toBe('search');
65
+ });
66
+ });
67
+
68
+ // =========================================================================
69
+ // wp_list_post_types
70
+ // =========================================================================
71
+
72
+ describe('wp_list_post_types', () => {
73
+ const typesResponse = {
74
+ post: { slug: 'post', name: 'Posts', description: '', hierarchical: false, rest_base: 'posts' },
75
+ page: { slug: 'page', name: 'Pages', description: '', hierarchical: true, rest_base: 'pages' },
76
+ product: { slug: 'product', name: 'Products', description: 'Custom products', hierarchical: false, rest_base: 'products' },
77
+ };
78
+
79
+ it('SUCCESS — returns total and post_types array', async () => {
80
+ mockSuccess(typesResponse);
81
+
82
+ const res = await call('wp_list_post_types');
83
+ const data = parseResult(res);
84
+
85
+ expect(data.total).toBe(3);
86
+ expect(data.post_types).toHaveLength(3);
87
+
88
+ const product = data.post_types.find(t => t.slug === 'product');
89
+ expect(product).toEqual(
90
+ expect.objectContaining({ slug: 'product', name: 'Products', description: 'Custom products', hierarchical: false, rest_base: 'products' })
91
+ );
92
+ });
93
+
94
+ it('ERROR 403 — returns error message', async () => {
95
+ mockError(403, '{"code":"rest_forbidden"}');
96
+
97
+ const res = await call('wp_list_post_types');
98
+ expect(res.isError).toBe(true);
99
+ expect(res.content[0].text).toContain('403');
100
+ });
101
+
102
+ it('AUDIT — logs list action on success', async () => {
103
+ mockSuccess(typesResponse);
104
+
105
+ await call('wp_list_post_types');
106
+
107
+ const logs = getAuditLogs();
108
+ const entry = logs.find(l => l.tool === 'wp_list_post_types');
109
+ expect(entry).toBeDefined();
110
+ expect(entry.status).toBe('success');
111
+ expect(entry.action).toBe('list');
112
+ });
113
+ });
114
+
115
+ // =========================================================================
116
+ // wp_list_custom_posts
117
+ // =========================================================================
118
+
119
+ describe('wp_list_custom_posts', () => {
120
+ const typesResponse = {
121
+ post: { slug: 'post', name: 'Posts', description: '', hierarchical: false, rest_base: 'posts' },
122
+ page: { slug: 'page', name: 'Pages', description: '', hierarchical: true, rest_base: 'pages' },
123
+ product: { slug: 'product', name: 'Products', description: 'Custom products', hierarchical: false, rest_base: 'products' },
124
+ };
125
+
126
+ const productPosts = [
127
+ {
128
+ id: 50,
129
+ title: { rendered: 'Widget' },
130
+ status: 'publish',
131
+ date: '2024-01-01',
132
+ link: 'https://test.example.com/product/widget',
133
+ slug: 'widget',
134
+ type: 'product',
135
+ meta: {},
136
+ },
137
+ ];
138
+
139
+ it('SUCCESS — fetches types then custom posts', async () => {
140
+ // First call: GET /types
141
+ mockSuccess(typesResponse);
142
+ // Second call: GET /products?...
143
+ mockSuccess(productPosts);
144
+
145
+ const res = await call('wp_list_custom_posts', { post_type: 'product' });
146
+ const data = parseResult(res);
147
+
148
+ expect(data.post_type).toBe('product');
149
+ expect(data.total).toBe(1);
150
+ expect(data.posts).toHaveLength(1);
151
+ expect(data.posts[0]).toEqual(
152
+ expect.objectContaining({ id: 50, title: 'Widget', status: 'publish', slug: 'widget', type: 'product' })
153
+ );
154
+ });
155
+
156
+ it('ERROR — post type not found', async () => {
157
+ // Return types without the requested type
158
+ const limitedTypes = {
159
+ post: { slug: 'post', name: 'Posts', description: '', hierarchical: false, rest_base: 'posts' },
160
+ page: { slug: 'page', name: 'Pages', description: '', hierarchical: true, rest_base: 'pages' },
161
+ };
162
+ mockSuccess(limitedTypes);
163
+
164
+ const res = await call('wp_list_custom_posts', { post_type: 'nonexistent' });
165
+ expect(res.isError).toBe(true);
166
+ expect(res.content[0].text).toContain('not found');
167
+ });
168
+
169
+ it('ERROR 403 — returns error', async () => {
170
+ mockError(403, '{"code":"rest_forbidden"}');
171
+
172
+ const res = await call('wp_list_custom_posts', { post_type: 'product' });
173
+ expect(res.isError).toBe(true);
174
+ expect(res.content[0].text).toContain('403');
175
+ });
176
+
177
+ it('AUDIT — logs list action on success', async () => {
178
+ mockSuccess(typesResponse);
179
+ mockSuccess(productPosts);
180
+
181
+ await call('wp_list_custom_posts', { post_type: 'product' });
182
+
183
+ const logs = getAuditLogs();
184
+ const entry = logs.find(l => l.tool === 'wp_list_custom_posts');
185
+ expect(entry).toBeDefined();
186
+ expect(entry.status).toBe('success');
187
+ expect(entry.action).toBe('list');
188
+ expect(entry.params.post_type).toBe('product');
189
+ });
190
+ });