@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,260 @@
1
+ import { vi, describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } 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
+ // 1. WP_READ_ONLY blocks ALL 13 write tools
15
+ // ────────────────────────────────────────────────────────────
16
+
17
+ describe('WP_READ_ONLY=true', () => {
18
+ let consoleSpy;
19
+
20
+ beforeAll(() => {
21
+ process.env.WP_READ_ONLY = 'true';
22
+ });
23
+ afterAll(() => {
24
+ delete process.env.WP_READ_ONLY;
25
+ });
26
+ beforeEach(() => {
27
+ fetch.mockReset();
28
+ consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
29
+ });
30
+ afterEach(() => {
31
+ consoleSpy.mockRestore();
32
+ });
33
+
34
+ const writeTools = [
35
+ { name: 'wp_create_post', args: { title: 'T', content: 'C' } },
36
+ { name: 'wp_update_post', args: { id: 1, title: 'T' } },
37
+ { name: 'wp_delete_post', args: { id: 1 } },
38
+ { name: 'wp_create_page', args: { title: 'T', content: 'C' } },
39
+ { name: 'wp_update_page', args: { id: 1, title: 'T' } },
40
+ { name: 'wp_upload_media', args: { url: 'https://example.com/img.png' } },
41
+ { name: 'wp_create_comment', args: { post: 1, content: 'C' } },
42
+ { name: 'wp_create_taxonomy_term', args: { taxonomy: 'category', name: 'T' } },
43
+ { name: 'wp_update_seo_meta', args: { id: 1, title: 'SEO title' } },
44
+ { name: 'wp_activate_plugin', args: { plugin: 'test/test.php' } },
45
+ { name: 'wp_deactivate_plugin', args: { plugin: 'test/test.php' } },
46
+ { name: 'wp_restore_revision', args: { post_id: 1, revision_id: 2 } },
47
+ { name: 'wp_delete_revision', args: { post_id: 1, revision_id: 2 } },
48
+ ];
49
+
50
+ it.each(writeTools)('blocks $name', async ({ name: toolName, args }) => {
51
+ const result = await call(toolName, args);
52
+
53
+ expect(result.isError).toBe(true);
54
+ expect(result.content[0].text).toContain('READ-ONLY');
55
+
56
+ const logs = getAuditLogs(consoleSpy);
57
+ const entry = logs.find(l => l.tool === toolName);
58
+ expect(entry).toBeDefined();
59
+ expect(entry.status).toBe('blocked');
60
+ });
61
+
62
+ it('does NOT block fetch (no network calls made)', () => {
63
+ expect(fetch).not.toHaveBeenCalled();
64
+ });
65
+ });
66
+
67
+ // ────────────────────────────────────────────────────────────
68
+ // 2. WP_DRAFT_ONLY blocks publish status on create/update post
69
+ // ────────────────────────────────────────────────────────────
70
+
71
+ describe('WP_DRAFT_ONLY=true', () => {
72
+ let consoleSpy;
73
+
74
+ beforeAll(() => {
75
+ process.env.WP_DRAFT_ONLY = 'true';
76
+ });
77
+ afterAll(() => {
78
+ delete process.env.WP_DRAFT_ONLY;
79
+ });
80
+ beforeEach(() => {
81
+ fetch.mockReset();
82
+ consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
83
+ });
84
+ afterEach(() => {
85
+ consoleSpy.mockRestore();
86
+ });
87
+
88
+ it('blocks wp_create_post with status:publish', async () => {
89
+ const result = await call('wp_create_post', { title: 'T', content: 'C', status: 'publish' });
90
+
91
+ expect(result.isError).toBe(true);
92
+ expect(result.content[0].text).toContain('DRAFT-ONLY');
93
+ });
94
+
95
+ it('blocks wp_update_post with status:publish', async () => {
96
+ const result = await call('wp_update_post', { id: 1, status: 'publish' });
97
+
98
+ expect(result.isError).toBe(true);
99
+ expect(result.content[0].text).toContain('DRAFT-ONLY');
100
+ });
101
+
102
+ it('allows wp_create_post with status:draft', async () => {
103
+ fetch.mockResolvedValue(mockSuccess({ id: 2, title: { rendered: 'T' }, status: 'draft', link: 'https://test.example.com/?p=2', slug: 'test' }));
104
+ const result = await call('wp_create_post', { title: 'T', content: 'C', status: 'draft' });
105
+
106
+ expect(result.isError).toBeUndefined();
107
+ const data = parseResult(result);
108
+ expect(data.success).toBe(true);
109
+ });
110
+ });
111
+
112
+ // ────────────────────────────────────────────────────────────
113
+ // 3. WP_DISABLE_DELETE blocks delete tools
114
+ // ────────────────────────────────────────────────────────────
115
+
116
+ describe('WP_DISABLE_DELETE=true', () => {
117
+ let consoleSpy;
118
+
119
+ beforeAll(() => {
120
+ process.env.WP_DISABLE_DELETE = 'true';
121
+ });
122
+ afterAll(() => {
123
+ delete process.env.WP_DISABLE_DELETE;
124
+ });
125
+ beforeEach(() => {
126
+ fetch.mockReset();
127
+ consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
128
+ });
129
+ afterEach(() => {
130
+ consoleSpy.mockRestore();
131
+ });
132
+
133
+ it('blocks wp_delete_post', async () => {
134
+ const result = await call('wp_delete_post', { id: 1 });
135
+
136
+ expect(result.isError).toBe(true);
137
+ expect(result.content[0].text).toContain('WP_DISABLE_DELETE');
138
+
139
+ const logs = getAuditLogs(consoleSpy);
140
+ const entry = logs.find(l => l.tool === 'wp_delete_post');
141
+ expect(entry).toBeDefined();
142
+ expect(entry.status).toBe('blocked');
143
+ });
144
+
145
+ it('blocks wp_delete_revision', async () => {
146
+ const result = await call('wp_delete_revision', { post_id: 1, revision_id: 2 });
147
+
148
+ expect(result.isError).toBe(true);
149
+ expect(result.content[0].text).toContain('WP_DISABLE_DELETE');
150
+
151
+ const logs = getAuditLogs(consoleSpy);
152
+ const entry = logs.find(l => l.tool === 'wp_delete_revision');
153
+ expect(entry).toBeDefined();
154
+ expect(entry.status).toBe('blocked');
155
+ });
156
+ });
157
+
158
+ // ────────────────────────────────────────────────────────────
159
+ // 4. WP_DISABLE_PLUGIN_MANAGEMENT blocks plugin write tools
160
+ // ────────────────────────────────────────────────────────────
161
+
162
+ describe('WP_DISABLE_PLUGIN_MANAGEMENT=true', () => {
163
+ let consoleSpy;
164
+
165
+ beforeAll(() => {
166
+ process.env.WP_DISABLE_PLUGIN_MANAGEMENT = 'true';
167
+ });
168
+ afterAll(() => {
169
+ delete process.env.WP_DISABLE_PLUGIN_MANAGEMENT;
170
+ });
171
+ beforeEach(() => {
172
+ fetch.mockReset();
173
+ consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
174
+ });
175
+ afterEach(() => {
176
+ consoleSpy.mockRestore();
177
+ });
178
+
179
+ it('blocks wp_activate_plugin', async () => {
180
+ const result = await call('wp_activate_plugin', { plugin: 'test/test.php' });
181
+
182
+ expect(result.isError).toBe(true);
183
+ expect(result.content[0].text).toContain('WP_DISABLE_PLUGIN_MANAGEMENT');
184
+
185
+ const logs = getAuditLogs(consoleSpy);
186
+ const entry = logs.find(l => l.tool === 'wp_activate_plugin');
187
+ expect(entry).toBeDefined();
188
+ expect(entry.status).toBe('blocked');
189
+ });
190
+
191
+ it('blocks wp_deactivate_plugin', async () => {
192
+ const result = await call('wp_deactivate_plugin', { plugin: 'test/test.php' });
193
+
194
+ expect(result.isError).toBe(true);
195
+ expect(result.content[0].text).toContain('WP_DISABLE_PLUGIN_MANAGEMENT');
196
+
197
+ const logs = getAuditLogs(consoleSpy);
198
+ const entry = logs.find(l => l.tool === 'wp_deactivate_plugin');
199
+ expect(entry).toBeDefined();
200
+ expect(entry.status).toBe('blocked');
201
+ });
202
+
203
+ it('does NOT block wp_list_plugins', async () => {
204
+ fetch.mockResolvedValue(mockSuccess([
205
+ {
206
+ plugin: 'test/test.php',
207
+ name: 'Test',
208
+ version: '1.0',
209
+ status: 'active',
210
+ author: 'Test',
211
+ description: { rendered: 'desc' }
212
+ }
213
+ ]));
214
+
215
+ const result = await call('wp_list_plugins', {});
216
+
217
+ expect(result.isError).toBeUndefined();
218
+ const data = parseResult(result);
219
+ expect(data.total).toBe(1);
220
+ expect(data.plugins[0].name).toBe('Test');
221
+ });
222
+ });
223
+
224
+ // ────────────────────────────────────────────────────────────
225
+ // 5. Governance flag combinations
226
+ // ────────────────────────────────────────────────────────────
227
+
228
+ describe('Governance flag combinations', () => {
229
+ let consoleSpy;
230
+
231
+ beforeEach(() => {
232
+ fetch.mockReset();
233
+ consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
234
+ });
235
+ afterEach(() => {
236
+ delete process.env.WP_READ_ONLY;
237
+ delete process.env.WP_DISABLE_PLUGIN_MANAGEMENT;
238
+ consoleSpy.mockRestore();
239
+ });
240
+
241
+ it('WP_DISABLE_PLUGIN_MANAGEMENT=true + WP_READ_ONLY=false still blocks wp_activate_plugin', async () => {
242
+ process.env.WP_DISABLE_PLUGIN_MANAGEMENT = 'true';
243
+ process.env.WP_READ_ONLY = 'false';
244
+
245
+ const result = await call('wp_activate_plugin', { plugin: 'test/test.php' });
246
+
247
+ expect(result.isError).toBe(true);
248
+ expect(result.content[0].text).toContain('WP_DISABLE_PLUGIN_MANAGEMENT');
249
+ });
250
+
251
+ it('WP_READ_ONLY=true + WP_DISABLE_PLUGIN_MANAGEMENT=false still blocks wp_activate_plugin (via readOnly)', async () => {
252
+ process.env.WP_READ_ONLY = 'true';
253
+ process.env.WP_DISABLE_PLUGIN_MANAGEMENT = 'false';
254
+
255
+ const result = await call('wp_activate_plugin', { plugin: 'test/test.php' });
256
+
257
+ expect(result.isError).toBe(true);
258
+ expect(result.content[0].text).toContain('READ-ONLY');
259
+ });
260
+ });
@@ -0,0 +1,170 @@
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
+ // wp_list_comments
15
+ // ────────────────────────────────────────────────────────────
16
+
17
+ describe('wp_list_comments', () => {
18
+ let consoleSpy;
19
+ beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
20
+ afterEach(() => { consoleSpy.mockRestore(); });
21
+
22
+ it('returns formatted comment list on success', async () => {
23
+ fetch.mockResolvedValue(mockSuccess([
24
+ { id: 1, post: 1, parent: 0, author_name: 'John', date: '2024-01-01', status: 'approved', content: { rendered: '<p>Great post!</p>' }, link: 'https://test.example.com/post-1#comment-1' }
25
+ ]));
26
+
27
+ const result = await call('wp_list_comments');
28
+ const data = parseResult(result);
29
+
30
+ expect(data.total).toBe(1);
31
+ expect(data.page).toBe(1);
32
+ expect(data.comments).toHaveLength(1);
33
+ expect(data.comments[0].id).toBe(1);
34
+ expect(data.comments[0].post).toBe(1);
35
+ expect(data.comments[0].parent).toBe(0);
36
+ expect(data.comments[0].author_name).toBe('John');
37
+ expect(data.comments[0].date).toBe('2024-01-01');
38
+ expect(data.comments[0].status).toBe('approved');
39
+ expect(data.comments[0].content).toBe('Great post!');
40
+ expect(data.comments[0].link).toBe('https://test.example.com/post-1#comment-1');
41
+ });
42
+
43
+ it('returns error on 403', async () => {
44
+ fetch.mockResolvedValue(mockError(403));
45
+
46
+ const result = await call('wp_list_comments');
47
+
48
+ expect(result.isError).toBe(true);
49
+ });
50
+
51
+ it('returns error on 404', async () => {
52
+ fetch.mockResolvedValue(mockError(404));
53
+
54
+ const result = await call('wp_list_comments');
55
+
56
+ expect(result.isError).toBe(true);
57
+ });
58
+
59
+ it('logs audit entry with status success', async () => {
60
+ fetch.mockResolvedValue(mockSuccess([
61
+ { id: 1, post: 1, parent: 0, author_name: 'John', date: '2024-01-01', status: 'approved', content: { rendered: '<p>Great post!</p>' }, link: 'https://test.example.com/post-1#comment-1' }
62
+ ]));
63
+
64
+ await call('wp_list_comments');
65
+ const logs = getAuditLogs(consoleSpy);
66
+ const entry = logs.find(l => l.tool === 'wp_list_comments');
67
+
68
+ expect(entry).toBeDefined();
69
+ expect(entry.status).toBe('success');
70
+ });
71
+
72
+ it('passes post filter parameter correctly', async () => {
73
+ fetch.mockResolvedValue(mockSuccess([
74
+ { id: 1, post: 5, parent: 0, author_name: 'Jane', date: '2024-02-01', status: 'approved', content: { rendered: '<p>Filtered</p>' }, link: 'https://test.example.com/post-5#comment-1' }
75
+ ]));
76
+
77
+ const result = await call('wp_list_comments', { post: 5 });
78
+ const data = parseResult(result);
79
+
80
+ expect(data.total).toBe(1);
81
+ expect(data.comments[0].post).toBe(5);
82
+ });
83
+ });
84
+
85
+ // ────────────────────────────────────────────────────────────
86
+ // wp_create_comment
87
+ // ────────────────────────────────────────────────────────────
88
+
89
+ describe('wp_create_comment', () => {
90
+ let consoleSpy;
91
+ beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
92
+ afterEach(() => { consoleSpy.mockRestore(); });
93
+
94
+ it('creates a comment on success', async () => {
95
+ fetch.mockResolvedValue(mockSuccess({
96
+ id: 2, post: 1, status: 'approved', content: { rendered: '<p>Nice article</p>' }
97
+ }));
98
+
99
+ const result = await call('wp_create_comment', { post: 1, content: 'Nice article' });
100
+ const data = parseResult(result);
101
+
102
+ expect(data.success).toBe(true);
103
+ expect(data.message).toBe('Comment created');
104
+ expect(data.comment.id).toBe(2);
105
+ expect(data.comment.post).toBe(1);
106
+ expect(data.comment.status).toBe('approved');
107
+ expect(data.comment.content).toBe('Nice article');
108
+ });
109
+
110
+ it('is blocked in READ-ONLY mode (governance)', async () => {
111
+ process.env.WP_READ_ONLY = 'true';
112
+
113
+ const result = await call('wp_create_comment', { post: 1, content: 'Blocked comment' });
114
+
115
+ expect(result.isError).toBe(true);
116
+ expect(result.content[0].text).toContain('READ-ONLY');
117
+
118
+ delete process.env.WP_READ_ONLY;
119
+ });
120
+
121
+ it('returns error on 403', async () => {
122
+ fetch.mockResolvedValue(mockError(403));
123
+
124
+ const result = await call('wp_create_comment', { post: 1, content: 'Forbidden' });
125
+
126
+ expect(result.isError).toBe(true);
127
+ });
128
+
129
+ it('returns error on 404', async () => {
130
+ fetch.mockResolvedValue(mockError(404));
131
+
132
+ const result = await call('wp_create_comment', { post: 999, content: 'Missing post' });
133
+
134
+ expect(result.isError).toBe(true);
135
+ });
136
+
137
+ it('logs audit entry with status success on create', async () => {
138
+ fetch.mockResolvedValue(mockSuccess({
139
+ id: 2, post: 1, status: 'approved', content: { rendered: '<p>Nice article</p>' }
140
+ }));
141
+
142
+ await call('wp_create_comment', { post: 1, content: 'Nice article' });
143
+ const logs = getAuditLogs(consoleSpy);
144
+ const entry = logs.find(l => l.tool === 'wp_create_comment');
145
+
146
+ expect(entry).toBeDefined();
147
+ expect(entry.status).toBe('success');
148
+ expect(entry.target).toBe(2);
149
+ });
150
+
151
+ it('logs audit entry with status blocked in READ-ONLY mode', async () => {
152
+ process.env.WP_READ_ONLY = 'true';
153
+
154
+ await call('wp_create_comment', { post: 1, content: 'Blocked' });
155
+ const logs = getAuditLogs(consoleSpy);
156
+ const entry = logs.find(l => l.tool === 'wp_create_comment');
157
+
158
+ expect(entry).toBeDefined();
159
+ expect(entry.status).toBe('blocked');
160
+
161
+ delete process.env.WP_READ_ONLY;
162
+ });
163
+
164
+ it('requires post and content fields', async () => {
165
+ const result = await call('wp_create_comment', {});
166
+
167
+ expect(result.isError).toBe(true);
168
+ expect(result.content[0].text).toContain('post');
169
+ });
170
+ });