@adsim/wordpress-mcp-server 1.0.0 → 3.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.
- package/.env.example +8 -0
- package/.github/workflows/ci.yml +20 -0
- package/LICENSE +1 -1
- package/README.md +596 -135
- package/dxt/build-mcpb.sh +7 -0
- package/dxt/manifest.json +112 -0
- package/index.js +1367 -0
- package/package.json +22 -33
- package/src/auth/bearer.js +72 -0
- package/src/transport/http.js +264 -0
- package/tests/helpers/mockWpRequest.js +135 -0
- package/tests/unit/dxt/manifest.test.js +78 -0
- package/tests/unit/governance.test.js +260 -0
- package/tests/unit/tools/comments.test.js +170 -0
- package/tests/unit/tools/media.test.js +279 -0
- package/tests/unit/tools/pages.test.js +222 -0
- package/tests/unit/tools/plugins.test.js +268 -0
- package/tests/unit/tools/posts.test.js +310 -0
- package/tests/unit/tools/revisions.test.js +299 -0
- package/tests/unit/tools/search.test.js +190 -0
- package/tests/unit/tools/seo.test.js +248 -0
- package/tests/unit/tools/site.test.js +133 -0
- package/tests/unit/tools/taxonomies.test.js +220 -0
- package/tests/unit/tools/themes.test.js +163 -0
- package/tests/unit/tools/users.test.js +113 -0
- package/tests/unit/transport/http.test.js +300 -0
- package/vitest.config.js +12 -0
- package/dist/constants.d.ts +0 -13
- package/dist/constants.d.ts.map +0 -1
- package/dist/constants.js +0 -10
- package/dist/constants.js.map +0 -1
- package/dist/index.d.ts +0 -3
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -33
- package/dist/index.js.map +0 -1
- package/dist/schemas/index.d.ts +0 -308
- package/dist/schemas/index.d.ts.map +0 -1
- package/dist/schemas/index.js +0 -191
- package/dist/schemas/index.js.map +0 -1
- package/dist/services/formatters.d.ts +0 -22
- package/dist/services/formatters.d.ts.map +0 -1
- package/dist/services/formatters.js +0 -52
- package/dist/services/formatters.js.map +0 -1
- package/dist/services/wp-client.d.ts +0 -38
- package/dist/services/wp-client.d.ts.map +0 -1
- package/dist/services/wp-client.js +0 -102
- package/dist/services/wp-client.js.map +0 -1
- package/dist/tools/content.d.ts +0 -4
- package/dist/tools/content.d.ts.map +0 -1
- package/dist/tools/content.js +0 -196
- package/dist/tools/content.js.map +0 -1
- package/dist/tools/posts.d.ts +0 -4
- package/dist/tools/posts.d.ts.map +0 -1
- package/dist/tools/posts.js +0 -179
- package/dist/tools/posts.js.map +0 -1
- package/dist/tools/seo.d.ts +0 -4
- package/dist/tools/seo.d.ts.map +0 -1
- package/dist/tools/seo.js +0 -241
- package/dist/tools/seo.js.map +0 -1
- package/dist/tools/taxonomy.d.ts +0 -4
- package/dist/tools/taxonomy.d.ts.map +0 -1
- package/dist/tools/taxonomy.js +0 -82
- package/dist/tools/taxonomy.js.map +0 -1
- package/dist/types.d.ts +0 -160
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -3
- package/dist/types.js.map +0 -1
|
@@ -0,0 +1,163 @@
|
|
|
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 themesList = [
|
|
22
|
+
{
|
|
23
|
+
stylesheet: 'twentytwentyfour',
|
|
24
|
+
template: 'twentytwentyfour',
|
|
25
|
+
name: { rendered: 'Twenty Twenty-Four' },
|
|
26
|
+
description: { rendered: '<p>Modern theme</p>' },
|
|
27
|
+
status: 'active',
|
|
28
|
+
version: '1.0',
|
|
29
|
+
author: { rendered: 'WordPress' },
|
|
30
|
+
author_uri: 'https://wordpress.org',
|
|
31
|
+
theme_uri: 'https://wordpress.org/themes/twentytwentyfour',
|
|
32
|
+
requires_wp: '6.4',
|
|
33
|
+
requires_php: '7.0',
|
|
34
|
+
tags: { rendered: ['blog', 'one-column'] },
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
stylesheet: 'twentytwentythree',
|
|
38
|
+
template: 'twentytwentythree',
|
|
39
|
+
name: { rendered: 'Twenty Twenty-Three' },
|
|
40
|
+
description: { rendered: '<p>Classic theme</p>' },
|
|
41
|
+
status: 'inactive',
|
|
42
|
+
version: '1.2',
|
|
43
|
+
author: { rendered: 'WordPress' },
|
|
44
|
+
author_uri: '',
|
|
45
|
+
theme_uri: '',
|
|
46
|
+
requires_wp: '6.1',
|
|
47
|
+
requires_php: '5.6',
|
|
48
|
+
tags: { rendered: ['blog', 'two-column'] },
|
|
49
|
+
},
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
const singleTheme = themesList[0];
|
|
53
|
+
|
|
54
|
+
// =========================================================================
|
|
55
|
+
// wp_list_themes
|
|
56
|
+
// =========================================================================
|
|
57
|
+
|
|
58
|
+
describe('wp_list_themes', () => {
|
|
59
|
+
it('SUCCESS — returns total, active_theme, and themes array', async () => {
|
|
60
|
+
mockSuccess(themesList);
|
|
61
|
+
|
|
62
|
+
const res = await call('wp_list_themes');
|
|
63
|
+
const data = parseResult(res);
|
|
64
|
+
|
|
65
|
+
expect(data.total).toBe(2);
|
|
66
|
+
expect(data.active_theme).toBe('Twenty Twenty-Four');
|
|
67
|
+
expect(data.themes).toHaveLength(2);
|
|
68
|
+
|
|
69
|
+
const tt4 = data.themes.find(t => t.stylesheet === 'twentytwentyfour');
|
|
70
|
+
expect(tt4.template).toBe('twentytwentyfour');
|
|
71
|
+
expect(tt4.name).toBe('Twenty Twenty-Four');
|
|
72
|
+
expect(tt4.description).toBe('Modern theme');
|
|
73
|
+
expect(tt4.status).toBe('active');
|
|
74
|
+
expect(tt4.version).toBe('1.0');
|
|
75
|
+
expect(tt4.author).toBe('WordPress');
|
|
76
|
+
expect(tt4.tags).toEqual(['blog', 'one-column']);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('SUCCESS — active_theme_name is extracted from the active theme', async () => {
|
|
80
|
+
mockSuccess(themesList);
|
|
81
|
+
|
|
82
|
+
const res = await call('wp_list_themes');
|
|
83
|
+
const data = parseResult(res);
|
|
84
|
+
|
|
85
|
+
// active_theme should be the name.rendered of the theme with status 'active'
|
|
86
|
+
expect(data.active_theme).toBe('Twenty Twenty-Four');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('ERROR 403 — returns message about switch_themes capability', async () => {
|
|
90
|
+
mockError(403, '{"code":"rest_forbidden"}');
|
|
91
|
+
|
|
92
|
+
const res = await call('wp_list_themes');
|
|
93
|
+
expect(res.isError).toBe(true);
|
|
94
|
+
expect(res.content[0].text).toContain('switch_themes');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('AUDIT — logs list action on success', async () => {
|
|
98
|
+
mockSuccess(themesList);
|
|
99
|
+
|
|
100
|
+
await call('wp_list_themes');
|
|
101
|
+
|
|
102
|
+
const logs = getAuditLogs();
|
|
103
|
+
const entry = logs.find(l => l.tool === 'wp_list_themes');
|
|
104
|
+
expect(entry).toBeDefined();
|
|
105
|
+
expect(entry.status).toBe('success');
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// =========================================================================
|
|
110
|
+
// wp_get_theme
|
|
111
|
+
// =========================================================================
|
|
112
|
+
|
|
113
|
+
describe('wp_get_theme', () => {
|
|
114
|
+
it('SUCCESS — returns all theme fields with tags as array', async () => {
|
|
115
|
+
mockSuccess(singleTheme);
|
|
116
|
+
|
|
117
|
+
const res = await call('wp_get_theme', { stylesheet: 'twentytwentyfour' });
|
|
118
|
+
const data = parseResult(res);
|
|
119
|
+
|
|
120
|
+
expect(data.stylesheet).toBe('twentytwentyfour');
|
|
121
|
+
expect(data.template).toBe('twentytwentyfour');
|
|
122
|
+
expect(data.name).toBe('Twenty Twenty-Four');
|
|
123
|
+
expect(data.description).toBe('Modern theme');
|
|
124
|
+
expect(data.status).toBe('active');
|
|
125
|
+
expect(data.version).toBe('1.0');
|
|
126
|
+
expect(data.author).toBe('WordPress');
|
|
127
|
+
expect(data.author_uri).toBe('https://wordpress.org');
|
|
128
|
+
expect(data.theme_uri).toBe('https://wordpress.org/themes/twentytwentyfour');
|
|
129
|
+
expect(data.requires_wp).toBe('6.4');
|
|
130
|
+
expect(data.requires_php).toBe('7.0');
|
|
131
|
+
expect(Array.isArray(data.tags)).toBe(true);
|
|
132
|
+
expect(data.tags).toEqual(['blog', 'one-column']);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('ERROR 404 — returns message mentioning wp_list_themes', async () => {
|
|
136
|
+
mockError(404, '{"code":"rest_theme_not_found"}');
|
|
137
|
+
|
|
138
|
+
const res = await call('wp_get_theme', { stylesheet: 'nonexistent' });
|
|
139
|
+
expect(res.isError).toBe(true);
|
|
140
|
+
expect(res.content[0].text).toContain('wp_list_themes');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('ERROR 403 — returns message about switch_themes capability', async () => {
|
|
144
|
+
mockError(403, '{"code":"rest_forbidden"}');
|
|
145
|
+
|
|
146
|
+
const res = await call('wp_get_theme', { stylesheet: 'twentytwentyfour' });
|
|
147
|
+
expect(res.isError).toBe(true);
|
|
148
|
+
expect(res.content[0].text).toContain('switch_themes');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('AUDIT — logs read action on success', async () => {
|
|
152
|
+
mockSuccess(singleTheme);
|
|
153
|
+
|
|
154
|
+
await call('wp_get_theme', { stylesheet: 'twentytwentyfour' });
|
|
155
|
+
|
|
156
|
+
const logs = getAuditLogs();
|
|
157
|
+
const entry = logs.find(l => l.tool === 'wp_get_theme');
|
|
158
|
+
expect(entry).toBeDefined();
|
|
159
|
+
expect(entry.status).toBe('success');
|
|
160
|
+
expect(entry.target).toBe('twentytwentyfour');
|
|
161
|
+
expect(entry.target_type).toBe('theme');
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
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_users
|
|
15
|
+
// ────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
describe('wp_list_users', () => {
|
|
18
|
+
let consoleSpy;
|
|
19
|
+
beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
|
|
20
|
+
afterEach(() => { consoleSpy.mockRestore(); });
|
|
21
|
+
|
|
22
|
+
it('returns formatted user list on success', async () => {
|
|
23
|
+
fetch.mockResolvedValue(mockSuccess([
|
|
24
|
+
{ id: 1, name: 'Admin', slug: 'admin', link: 'https://test.example.com/author/admin', roles: ['administrator'], avatar_urls: { '96': 'https://secure.gravatar.com/avatar/xxx?s=96' } }
|
|
25
|
+
]));
|
|
26
|
+
|
|
27
|
+
const result = await call('wp_list_users');
|
|
28
|
+
const data = parseResult(result);
|
|
29
|
+
|
|
30
|
+
expect(data.total).toBe(1);
|
|
31
|
+
expect(data.users).toHaveLength(1);
|
|
32
|
+
expect(data.users[0].id).toBe(1);
|
|
33
|
+
expect(data.users[0].name).toBe('Admin');
|
|
34
|
+
expect(data.users[0].slug).toBe('admin');
|
|
35
|
+
expect(data.users[0].link).toBe('https://test.example.com/author/admin');
|
|
36
|
+
expect(data.users[0].roles).toEqual(['administrator']);
|
|
37
|
+
expect(data.users[0].avatar).toBe('https://secure.gravatar.com/avatar/xxx?s=96');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('returns error on 403', async () => {
|
|
41
|
+
fetch.mockResolvedValue(mockError(403));
|
|
42
|
+
|
|
43
|
+
const result = await call('wp_list_users');
|
|
44
|
+
|
|
45
|
+
expect(result.isError).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('returns error on 404', async () => {
|
|
49
|
+
fetch.mockResolvedValue(mockError(404));
|
|
50
|
+
|
|
51
|
+
const result = await call('wp_list_users');
|
|
52
|
+
|
|
53
|
+
expect(result.isError).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('logs audit entry with status success', async () => {
|
|
57
|
+
fetch.mockResolvedValue(mockSuccess([
|
|
58
|
+
{ id: 1, name: 'Admin', slug: 'admin', link: 'https://test.example.com/author/admin', roles: ['administrator'], avatar_urls: { '96': 'https://secure.gravatar.com/avatar/xxx?s=96' } }
|
|
59
|
+
]));
|
|
60
|
+
|
|
61
|
+
await call('wp_list_users');
|
|
62
|
+
const logs = getAuditLogs(consoleSpy);
|
|
63
|
+
const entry = logs.find(l => l.tool === 'wp_list_users');
|
|
64
|
+
|
|
65
|
+
expect(entry).toBeDefined();
|
|
66
|
+
expect(entry.status).toBe('success');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('returns multiple users correctly', async () => {
|
|
70
|
+
fetch.mockResolvedValue(mockSuccess([
|
|
71
|
+
{ id: 1, name: 'Admin', slug: 'admin', link: 'https://test.example.com/author/admin', roles: ['administrator'], avatar_urls: { '96': 'https://secure.gravatar.com/avatar/aaa?s=96' } },
|
|
72
|
+
{ id: 2, name: 'Editor', slug: 'editor', link: 'https://test.example.com/author/editor', roles: ['editor'], avatar_urls: { '96': 'https://secure.gravatar.com/avatar/bbb?s=96' } },
|
|
73
|
+
{ id: 3, name: 'Author', slug: 'author', link: 'https://test.example.com/author/author', roles: ['author'], avatar_urls: { '96': 'https://secure.gravatar.com/avatar/ccc?s=96' } }
|
|
74
|
+
]));
|
|
75
|
+
|
|
76
|
+
const result = await call('wp_list_users');
|
|
77
|
+
const data = parseResult(result);
|
|
78
|
+
|
|
79
|
+
expect(data.total).toBe(3);
|
|
80
|
+
expect(data.users).toHaveLength(3);
|
|
81
|
+
expect(data.users[0].name).toBe('Admin');
|
|
82
|
+
expect(data.users[1].name).toBe('Editor');
|
|
83
|
+
expect(data.users[2].name).toBe('Author');
|
|
84
|
+
expect(data.users[1].roles).toEqual(['editor']);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('passes search parameter correctly', async () => {
|
|
88
|
+
fetch.mockResolvedValue(mockSuccess([
|
|
89
|
+
{ id: 1, name: 'Admin', slug: 'admin', link: 'https://test.example.com/author/admin', roles: ['administrator'], avatar_urls: { '96': 'https://secure.gravatar.com/avatar/xxx?s=96' } }
|
|
90
|
+
]));
|
|
91
|
+
|
|
92
|
+
const result = await call('wp_list_users', { search: 'admin' });
|
|
93
|
+
const data = parseResult(result);
|
|
94
|
+
|
|
95
|
+
expect(data.total).toBe(1);
|
|
96
|
+
expect(data.users[0].name).toBe('Admin');
|
|
97
|
+
|
|
98
|
+
// Verify fetch was called with search param in URL
|
|
99
|
+
const fetchUrl = fetch.mock.calls[0][0];
|
|
100
|
+
expect(fetchUrl).toContain('search=admin');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('handles users without avatar_urls', async () => {
|
|
104
|
+
fetch.mockResolvedValue(mockSuccess([
|
|
105
|
+
{ id: 1, name: 'NoAvatar', slug: 'noavatar', link: 'https://test.example.com/author/noavatar', roles: ['subscriber'] }
|
|
106
|
+
]));
|
|
107
|
+
|
|
108
|
+
const result = await call('wp_list_users');
|
|
109
|
+
const data = parseResult(result);
|
|
110
|
+
|
|
111
|
+
expect(data.users[0].avatar).toBeUndefined();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for HTTP Streamable Transport and Bearer auth.
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* - validateBearerToken: valid, invalid, absent, authToken null
|
|
6
|
+
* - Origin validation: allowed, disallowed, absent
|
|
7
|
+
* - Health endpoint: 200 + correct JSON
|
|
8
|
+
* - Session ID: UUID v4 format, uniqueness
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
12
|
+
import { validateBearerToken } from '../../../src/auth/bearer.js';
|
|
13
|
+
|
|
14
|
+
// ─────────────────────────────────────────────────────────────
|
|
15
|
+
// validateBearerToken
|
|
16
|
+
// ─────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
describe('validateBearerToken', () => {
|
|
19
|
+
let consoleSpy;
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
23
|
+
});
|
|
24
|
+
afterEach(() => consoleSpy.mockRestore());
|
|
25
|
+
|
|
26
|
+
function fakeReq(authHeader) {
|
|
27
|
+
return {
|
|
28
|
+
headers: authHeader !== undefined ? { authorization: authHeader } : {},
|
|
29
|
+
socket: { remoteAddress: '127.0.0.1' },
|
|
30
|
+
url: '/mcp',
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
it('accepts a valid token', () => {
|
|
35
|
+
const result = validateBearerToken(fakeReq('Bearer my-secret'), 'my-secret');
|
|
36
|
+
expect(result.valid).toBe(true);
|
|
37
|
+
expect(result.status).toBeUndefined();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('rejects an invalid token', () => {
|
|
41
|
+
const result = validateBearerToken(fakeReq('Bearer wrong-token'), 'my-secret');
|
|
42
|
+
expect(result.valid).toBe(false);
|
|
43
|
+
expect(result.status).toBe(401);
|
|
44
|
+
expect(result.body).toEqual({ error: 'Unauthorized', code: 'INVALID_TOKEN' });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('rejects when Authorization header is absent', () => {
|
|
48
|
+
const result = validateBearerToken(fakeReq(undefined), 'my-secret');
|
|
49
|
+
expect(result.valid).toBe(false);
|
|
50
|
+
expect(result.status).toBe(401);
|
|
51
|
+
expect(result.body.code).toBe('INVALID_TOKEN');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('rejects a malformed Authorization header (no Bearer prefix)', () => {
|
|
55
|
+
const result = validateBearerToken(fakeReq('Basic abc123'), 'my-secret');
|
|
56
|
+
expect(result.valid).toBe(false);
|
|
57
|
+
expect(result.status).toBe(401);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('passes when authToken is null (auth disabled)', () => {
|
|
61
|
+
const result = validateBearerToken(fakeReq(undefined), null);
|
|
62
|
+
expect(result.valid).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('passes when authToken is null even with a token provided', () => {
|
|
66
|
+
const result = validateBearerToken(fakeReq('Bearer something'), null);
|
|
67
|
+
expect(result.valid).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('logs auth failure to stderr as JSON', () => {
|
|
71
|
+
validateBearerToken(fakeReq('Bearer bad'), 'good');
|
|
72
|
+
expect(consoleSpy).toHaveBeenCalled();
|
|
73
|
+
const logLine = consoleSpy.mock.calls[0][0];
|
|
74
|
+
expect(logLine).toContain('[AUTH]');
|
|
75
|
+
const json = JSON.parse(logLine.replace('[AUTH] ', ''));
|
|
76
|
+
expect(json.event).toBe('auth_failure');
|
|
77
|
+
expect(json.ip).toBe('127.0.0.1');
|
|
78
|
+
expect(json.path).toBe('/mcp');
|
|
79
|
+
expect(json.timestamp).toBeDefined();
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// ─────────────────────────────────────────────────────────────
|
|
84
|
+
// HTTP server integration tests (health, origin, session)
|
|
85
|
+
// ─────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Helper: start a temporary HTTP server from HttpTransportManager,
|
|
89
|
+
* run a test, then close.
|
|
90
|
+
*/
|
|
91
|
+
async function withHttpServer(config, fn) {
|
|
92
|
+
const { HttpTransportManager } = await import('../../../src/transport/http.js');
|
|
93
|
+
const { Server } = await import('@modelcontextprotocol/sdk/server/index.js');
|
|
94
|
+
|
|
95
|
+
function serverFactory() {
|
|
96
|
+
return new Server(
|
|
97
|
+
{ name: 'test-mcp', version: '0.0.1' },
|
|
98
|
+
{ capabilities: { tools: {} } },
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const manager = new HttpTransportManager();
|
|
103
|
+
const httpServer = manager.createServer(serverFactory, config);
|
|
104
|
+
|
|
105
|
+
await new Promise((resolve) => httpServer.listen(0, '127.0.0.1', resolve));
|
|
106
|
+
const { port } = httpServer.address();
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
await fn({ port, httpServer });
|
|
110
|
+
} finally {
|
|
111
|
+
await new Promise((resolve) => httpServer.close(resolve));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function httpGet(port, path, headers = {}) {
|
|
116
|
+
return fetch(`http://127.0.0.1:${port}${path}`, { method: 'GET', headers });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function httpPost(port, path, body, headers = {}) {
|
|
120
|
+
return fetch(`http://127.0.0.1:${port}${path}`, {
|
|
121
|
+
method: 'POST',
|
|
122
|
+
headers: {
|
|
123
|
+
'Content-Type': 'application/json',
|
|
124
|
+
'Accept': 'application/json, text/event-stream',
|
|
125
|
+
...headers,
|
|
126
|
+
},
|
|
127
|
+
body: JSON.stringify(body),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function httpDelete(port, path, headers = {}) {
|
|
132
|
+
return fetch(`http://127.0.0.1:${port}${path}`, { method: 'DELETE', headers });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
describe('Health endpoint', () => {
|
|
136
|
+
let consoleSpy;
|
|
137
|
+
beforeEach(() => { consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
|
|
138
|
+
afterEach(() => consoleSpy.mockRestore());
|
|
139
|
+
|
|
140
|
+
it('returns 200 with correct JSON', async () => {
|
|
141
|
+
await withHttpServer({}, async ({ port }) => {
|
|
142
|
+
const res = await httpGet(port, '/health');
|
|
143
|
+
expect(res.status).toBe(200);
|
|
144
|
+
const data = await res.json();
|
|
145
|
+
expect(data).toEqual({ status: 'ok', transport: 'http', version: '3.0.0' });
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('returns 404 for unknown paths', async () => {
|
|
150
|
+
await withHttpServer({}, async ({ port }) => {
|
|
151
|
+
const res = await httpGet(port, '/unknown');
|
|
152
|
+
expect(res.status).toBe(404);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe('Origin validation', () => {
|
|
158
|
+
let consoleSpy;
|
|
159
|
+
beforeEach(() => { consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
|
|
160
|
+
afterEach(() => consoleSpy.mockRestore());
|
|
161
|
+
|
|
162
|
+
it('allows request when origin is in allowedOrigins', async () => {
|
|
163
|
+
await withHttpServer({ allowedOrigins: ['http://allowed.com'] }, async ({ port }) => {
|
|
164
|
+
const res = await httpPost(port, '/mcp', { method: 'initialize' }, {
|
|
165
|
+
Origin: 'http://allowed.com',
|
|
166
|
+
});
|
|
167
|
+
// Should not be 403 (may be 400 from MCP, but NOT 403)
|
|
168
|
+
expect(res.status).not.toBe(403);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('rejects request when origin is not in allowedOrigins', async () => {
|
|
173
|
+
await withHttpServer({ allowedOrigins: ['http://allowed.com'] }, async ({ port }) => {
|
|
174
|
+
const res = await httpPost(port, '/mcp', { method: 'initialize' }, {
|
|
175
|
+
Origin: 'http://evil.com',
|
|
176
|
+
});
|
|
177
|
+
expect(res.status).toBe(403);
|
|
178
|
+
const data = await res.json();
|
|
179
|
+
expect(data.code).toBe('ORIGIN_NOT_ALLOWED');
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('allows request when no Origin header is sent and allowedOrigins is set', async () => {
|
|
184
|
+
await withHttpServer({ allowedOrigins: ['http://allowed.com'] }, async ({ port }) => {
|
|
185
|
+
const res = await httpPost(port, '/mcp', { method: 'initialize' });
|
|
186
|
+
expect(res.status).not.toBe(403);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('allows any origin when allowedOrigins is empty', async () => {
|
|
191
|
+
await withHttpServer({ allowedOrigins: [] }, async ({ port }) => {
|
|
192
|
+
const res = await httpPost(port, '/mcp', { method: 'initialize' }, {
|
|
193
|
+
Origin: 'http://any.com',
|
|
194
|
+
});
|
|
195
|
+
expect(res.status).not.toBe(403);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe('Bearer auth on HTTP endpoints', () => {
|
|
201
|
+
let consoleSpy;
|
|
202
|
+
beforeEach(() => { consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
|
|
203
|
+
afterEach(() => consoleSpy.mockRestore());
|
|
204
|
+
|
|
205
|
+
it('rejects /mcp without token when authToken is set', async () => {
|
|
206
|
+
await withHttpServer({ authToken: 'secret123' }, async ({ port }) => {
|
|
207
|
+
const res = await httpPost(port, '/mcp', { method: 'initialize' });
|
|
208
|
+
expect(res.status).toBe(401);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('allows /mcp with valid token', async () => {
|
|
213
|
+
await withHttpServer({ authToken: 'secret123' }, async ({ port }) => {
|
|
214
|
+
const res = await httpPost(port, '/mcp', { method: 'initialize' }, {
|
|
215
|
+
Authorization: 'Bearer secret123',
|
|
216
|
+
});
|
|
217
|
+
expect(res.status).not.toBe(401);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('health endpoint does not require auth', async () => {
|
|
222
|
+
await withHttpServer({ authToken: 'secret123' }, async ({ port }) => {
|
|
223
|
+
const res = await httpGet(port, '/health');
|
|
224
|
+
expect(res.status).toBe(200);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe('Session management', () => {
|
|
230
|
+
let consoleSpy;
|
|
231
|
+
beforeEach(() => { consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
|
|
232
|
+
afterEach(() => consoleSpy.mockRestore());
|
|
233
|
+
|
|
234
|
+
it('initialize request returns Mcp-Session-Id in UUID v4 format', async () => {
|
|
235
|
+
await withHttpServer({}, async ({ port }) => {
|
|
236
|
+
const res = await httpPost(port, '/mcp', {
|
|
237
|
+
jsonrpc: '2.0',
|
|
238
|
+
id: 1,
|
|
239
|
+
method: 'initialize',
|
|
240
|
+
params: {
|
|
241
|
+
protocolVersion: '2025-03-26',
|
|
242
|
+
capabilities: {},
|
|
243
|
+
clientInfo: { name: 'test', version: '1.0' },
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
const sessionId = res.headers.get('mcp-session-id');
|
|
247
|
+
expect(sessionId).not.toBeNull();
|
|
248
|
+
expect(typeof sessionId).toBe('string');
|
|
249
|
+
// UUID v4 format: 8-4-4-4-12 hex
|
|
250
|
+
expect(sessionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('different sessions produce unique IDs', async () => {
|
|
255
|
+
await withHttpServer({}, async ({ port }) => {
|
|
256
|
+
const body = {
|
|
257
|
+
jsonrpc: '2.0',
|
|
258
|
+
id: 1,
|
|
259
|
+
method: 'initialize',
|
|
260
|
+
params: {
|
|
261
|
+
protocolVersion: '2025-03-26',
|
|
262
|
+
capabilities: {},
|
|
263
|
+
clientInfo: { name: 'test', version: '1.0' },
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
const res1 = await httpPost(port, '/mcp', body);
|
|
267
|
+
const id1 = res1.headers.get('mcp-session-id');
|
|
268
|
+
|
|
269
|
+
const res2 = await httpPost(port, '/mcp', { ...body, id: 2 });
|
|
270
|
+
const id2 = res2.headers.get('mcp-session-id');
|
|
271
|
+
|
|
272
|
+
expect(id1).not.toBeNull();
|
|
273
|
+
expect(id2).not.toBeNull();
|
|
274
|
+
expect(id1).not.toBe(id2);
|
|
275
|
+
});
|
|
276
|
+
}, 15000);
|
|
277
|
+
|
|
278
|
+
it('rejects POST without session ID for non-init requests', async () => {
|
|
279
|
+
await withHttpServer({}, async ({ port }) => {
|
|
280
|
+
const res = await httpPost(port, '/mcp', {
|
|
281
|
+
jsonrpc: '2.0',
|
|
282
|
+
id: 1,
|
|
283
|
+
method: 'tools/list',
|
|
284
|
+
params: {},
|
|
285
|
+
});
|
|
286
|
+
expect(res.status).toBe(400);
|
|
287
|
+
const data = await res.json();
|
|
288
|
+
expect(data.code).toBe('INVALID_SESSION');
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('DELETE with invalid session returns 404', async () => {
|
|
293
|
+
await withHttpServer({}, async ({ port }) => {
|
|
294
|
+
const res = await httpDelete(port, '/mcp', {
|
|
295
|
+
'Mcp-Session-Id': 'nonexistent-session-id',
|
|
296
|
+
});
|
|
297
|
+
expect(res.status).toBe(404);
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
});
|
package/vitest.config.js
ADDED
package/dist/constants.d.ts
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
export declare const SERVER_NAME = "wordpress-mcp-server";
|
|
2
|
-
export declare const SERVER_VERSION = "1.0.0";
|
|
3
|
-
export declare const CHARACTER_LIMIT = 50000;
|
|
4
|
-
export declare const WP_REST_BASE = "/wp-json/wp/v2";
|
|
5
|
-
export declare const DEFAULT_PER_PAGE = 10;
|
|
6
|
-
export declare const MAX_PER_PAGE = 100;
|
|
7
|
-
export declare const POST_STATUSES: readonly ["publish", "draft", "pending", "private", "future", "trash"];
|
|
8
|
-
export type PostStatus = (typeof POST_STATUSES)[number];
|
|
9
|
-
export declare const POST_ORDERBYS: readonly ["date", "id", "title", "slug", "modified", "author", "relevance"];
|
|
10
|
-
export type PostOrderBy = (typeof POST_ORDERBYS)[number];
|
|
11
|
-
export declare const ORDER_DIRECTIONS: readonly ["asc", "desc"];
|
|
12
|
-
export type OrderDirection = (typeof ORDER_DIRECTIONS)[number];
|
|
13
|
-
//# sourceMappingURL=constants.d.ts.map
|
package/dist/constants.d.ts.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../src/constants.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,WAAW,yBAAyB,CAAC;AAClD,eAAO,MAAM,cAAc,UAAU,CAAC;AAEtC,eAAO,MAAM,eAAe,QAAQ,CAAC;AAErC,eAAO,MAAM,YAAY,mBAAmB,CAAC;AAE7C,eAAO,MAAM,gBAAgB,KAAK,CAAC;AACnC,eAAO,MAAM,YAAY,MAAM,CAAC;AAEhC,eAAO,MAAM,aAAa,wEAAyE,CAAC;AACpG,MAAM,MAAM,UAAU,GAAG,CAAC,OAAO,aAAa,CAAC,CAAC,MAAM,CAAC,CAAC;AAExD,eAAO,MAAM,aAAa,6EAA8E,CAAC;AACzG,MAAM,MAAM,WAAW,GAAG,CAAC,OAAO,aAAa,CAAC,CAAC,MAAM,CAAC,CAAC;AAEzD,eAAO,MAAM,gBAAgB,0BAA2B,CAAC;AACzD,MAAM,MAAM,cAAc,GAAG,CAAC,OAAO,gBAAgB,CAAC,CAAC,MAAM,CAAC,CAAC"}
|
package/dist/constants.js
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
export const SERVER_NAME = "wordpress-mcp-server";
|
|
2
|
-
export const SERVER_VERSION = "1.0.0";
|
|
3
|
-
export const CHARACTER_LIMIT = 50000;
|
|
4
|
-
export const WP_REST_BASE = "/wp-json/wp/v2";
|
|
5
|
-
export const DEFAULT_PER_PAGE = 10;
|
|
6
|
-
export const MAX_PER_PAGE = 100;
|
|
7
|
-
export const POST_STATUSES = ["publish", "draft", "pending", "private", "future", "trash"];
|
|
8
|
-
export const POST_ORDERBYS = ["date", "id", "title", "slug", "modified", "author", "relevance"];
|
|
9
|
-
export const ORDER_DIRECTIONS = ["asc", "desc"];
|
|
10
|
-
//# sourceMappingURL=constants.js.map
|
package/dist/constants.js.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"constants.js","sourceRoot":"","sources":["../src/constants.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,WAAW,GAAG,sBAAsB,CAAC;AAClD,MAAM,CAAC,MAAM,cAAc,GAAG,OAAO,CAAC;AAEtC,MAAM,CAAC,MAAM,eAAe,GAAG,KAAK,CAAC;AAErC,MAAM,CAAC,MAAM,YAAY,GAAG,gBAAgB,CAAC;AAE7C,MAAM,CAAC,MAAM,gBAAgB,GAAG,EAAE,CAAC;AACnC,MAAM,CAAC,MAAM,YAAY,GAAG,GAAG,CAAC;AAEhC,MAAM,CAAC,MAAM,aAAa,GAAG,CAAC,SAAS,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS,EAAE,QAAQ,EAAE,OAAO,CAAU,CAAC;AAGpG,MAAM,CAAC,MAAM,aAAa,GAAG,CAAC,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,QAAQ,EAAE,WAAW,CAAU,CAAC;AAGzG,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAAC,KAAK,EAAE,MAAM,CAAU,CAAC"}
|
package/dist/index.d.ts
DELETED
package/dist/index.d.ts.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":""}
|
package/dist/index.js
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
-
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
-
import { createClientFromEnv } from "./services/wp-client.js";
|
|
5
|
-
import { registerPostsTools } from "./tools/posts.js";
|
|
6
|
-
import { registerTaxonomyTools } from "./tools/taxonomy.js";
|
|
7
|
-
import { registerSEOTools } from "./tools/seo.js";
|
|
8
|
-
import { registerContentTools } from "./tools/content.js";
|
|
9
|
-
import { SERVER_NAME, SERVER_VERSION } from "./constants.js";
|
|
10
|
-
async function main() {
|
|
11
|
-
// Create WordPress API client from environment variables
|
|
12
|
-
const client = createClientFromEnv();
|
|
13
|
-
// Create MCP server
|
|
14
|
-
const server = new McpServer({
|
|
15
|
-
name: SERVER_NAME,
|
|
16
|
-
version: SERVER_VERSION,
|
|
17
|
-
});
|
|
18
|
-
// Register all tools
|
|
19
|
-
registerPostsTools(server, client);
|
|
20
|
-
registerTaxonomyTools(server, client);
|
|
21
|
-
registerSEOTools(server, client);
|
|
22
|
-
registerContentTools(server, client);
|
|
23
|
-
// Start server with stdio transport
|
|
24
|
-
const transport = new StdioServerTransport();
|
|
25
|
-
await server.connect(transport);
|
|
26
|
-
console.error(`${SERVER_NAME} v${SERVER_VERSION} started successfully`);
|
|
27
|
-
console.error(`Connected to: ${process.env.WP_API_URL}`);
|
|
28
|
-
}
|
|
29
|
-
main().catch((error) => {
|
|
30
|
-
console.error(`Fatal error starting ${SERVER_NAME}:`, error.message);
|
|
31
|
-
process.exit(1);
|
|
32
|
-
});
|
|
33
|
-
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAEA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AACpE,OAAO,EAAE,oBAAoB,EAAE,MAAM,2CAA2C,CAAC;AACjF,OAAO,EAAE,mBAAmB,EAAE,MAAM,yBAAyB,CAAC;AAC9D,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAC5D,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAClD,OAAO,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAC;AAC1D,OAAO,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAE7D,KAAK,UAAU,IAAI;IACjB,yDAAyD;IACzD,MAAM,MAAM,GAAG,mBAAmB,EAAE,CAAC;IAErC,oBAAoB;IACpB,MAAM,MAAM,GAAG,IAAI,SAAS,CAAC;QAC3B,IAAI,EAAE,WAAW;QACjB,OAAO,EAAE,cAAc;KACxB,CAAC,CAAC;IAEH,qBAAqB;IACrB,kBAAkB,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACnC,qBAAqB,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACtC,gBAAgB,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,oBAAoB,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAErC,oCAAoC;IACpC,MAAM,SAAS,GAAG,IAAI,oBAAoB,EAAE,CAAC;IAC7C,MAAM,MAAM,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;IAEhC,OAAO,CAAC,KAAK,CAAC,GAAG,WAAW,KAAK,cAAc,uBAAuB,CAAC,CAAC;IACxE,OAAO,CAAC,KAAK,CAAC,iBAAiB,OAAO,CAAC,GAAG,CAAC,UAAU,EAAE,CAAC,CAAC;AAC3D,CAAC;AAED,IAAI,EAAE,CAAC,KAAK,CAAC,CAAC,KAAY,EAAE,EAAE;IAC5B,OAAO,CAAC,KAAK,CAAC,wBAAwB,WAAW,GAAG,EAAE,KAAK,CAAC,OAAO,CAAC,CAAC;IACrE,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC,CAAC,CAAC"}
|