@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.
- package/.env.example +8 -0
- package/.github/workflows/ci.yml +20 -0
- package/LICENSE +1 -1
- package/README.md +596 -135
- package/index.js +1367 -0
- package/package.json +21 -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/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
package/package.json
CHANGED
|
@@ -1,60 +1,48 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adsim/wordpress-mcp-server",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "MCP
|
|
3
|
+
"version": "3.0.0",
|
|
4
|
+
"description": "A Model Context Protocol (MCP) server for WordPress REST API integration. Manage posts, search content, and interact with your WordPress site through any MCP-compatible client.",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"main": "
|
|
6
|
+
"main": "index.js",
|
|
7
7
|
"bin": {
|
|
8
|
-
"wordpress-mcp-server": "
|
|
8
|
+
"wordpress-mcp-server": "./index.js",
|
|
9
|
+
"@adsim/wordpress-mcp-server": "./index.js"
|
|
9
10
|
},
|
|
10
11
|
"scripts": {
|
|
11
|
-
"
|
|
12
|
-
"
|
|
13
|
-
"
|
|
14
|
-
"
|
|
15
|
-
"clean": "rm -rf dist"
|
|
12
|
+
"start": "node index.js",
|
|
13
|
+
"test": "vitest run",
|
|
14
|
+
"test:watch": "vitest",
|
|
15
|
+
"test:coverage": "vitest run --coverage"
|
|
16
16
|
},
|
|
17
17
|
"keywords": [
|
|
18
18
|
"mcp",
|
|
19
|
+
"model-context-protocol",
|
|
19
20
|
"wordpress",
|
|
21
|
+
"wordpress-api",
|
|
20
22
|
"rest-api",
|
|
21
23
|
"claude",
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"rankmath",
|
|
26
|
-
"yoast",
|
|
27
|
-
"content-management",
|
|
28
|
-
"wp-api"
|
|
24
|
+
"ai",
|
|
25
|
+
"automation",
|
|
26
|
+
"cms"
|
|
29
27
|
],
|
|
30
|
-
"author":
|
|
31
|
-
"name": "Georges Cordewiener",
|
|
32
|
-
"email": "georges@adsim.be",
|
|
33
|
-
"url": "https://adsim.be"
|
|
34
|
-
},
|
|
28
|
+
"author": "Georges Cordewiener <georges@adsim.be>",
|
|
35
29
|
"license": "MIT",
|
|
36
30
|
"repository": {
|
|
37
31
|
"type": "git",
|
|
38
|
-
"url": "https://github.com/
|
|
32
|
+
"url": "git+https://github.com/adsimbe/wordpress-mcp-server.git"
|
|
39
33
|
},
|
|
40
34
|
"bugs": {
|
|
41
|
-
"url": "https://github.com/
|
|
35
|
+
"url": "https://github.com/adsimbe/wordpress-mcp-server/issues"
|
|
42
36
|
},
|
|
43
|
-
"homepage": "https://github.com/
|
|
37
|
+
"homepage": "https://github.com/adsimbe/wordpress-mcp-server#readme",
|
|
44
38
|
"engines": {
|
|
45
39
|
"node": ">=18.0.0"
|
|
46
40
|
},
|
|
47
|
-
"files": [
|
|
48
|
-
"dist/**/*",
|
|
49
|
-
"README.md",
|
|
50
|
-
"LICENSE"
|
|
51
|
-
],
|
|
52
41
|
"dependencies": {
|
|
53
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
54
|
-
"
|
|
42
|
+
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
43
|
+
"node-fetch": "^3.3.2"
|
|
55
44
|
},
|
|
56
45
|
"devDependencies": {
|
|
57
|
-
"
|
|
58
|
-
"typescript": "^5.5.0"
|
|
46
|
+
"vitest": "^4.0.18"
|
|
59
47
|
}
|
|
60
48
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bearer token authentication for HTTP transport.
|
|
3
|
+
*
|
|
4
|
+
* Provides timing-safe token validation to prevent timing attacks,
|
|
5
|
+
* structured JSON audit logging on stderr, and configurable auth bypass.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Validate Bearer token from Authorization header.
|
|
12
|
+
*
|
|
13
|
+
* @param {import('node:http').IncomingMessage} req
|
|
14
|
+
* @param {string|null} authToken Expected token. null = auth disabled (passthrough).
|
|
15
|
+
* @returns {{ valid: boolean, status?: number, body?: object }}
|
|
16
|
+
*/
|
|
17
|
+
export function validateBearerToken(req, authToken) {
|
|
18
|
+
// Auth disabled — passthrough
|
|
19
|
+
if (!authToken) {
|
|
20
|
+
return { valid: true };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const header = req.headers['authorization'] || '';
|
|
24
|
+
const match = header.match(/^Bearer\s+(.+)$/i);
|
|
25
|
+
|
|
26
|
+
if (!match) {
|
|
27
|
+
logAuthFailure(req, 'missing_or_malformed_token');
|
|
28
|
+
return {
|
|
29
|
+
valid: false,
|
|
30
|
+
status: 401,
|
|
31
|
+
body: { error: 'Unauthorized', code: 'INVALID_TOKEN' },
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const provided = match[1];
|
|
36
|
+
|
|
37
|
+
// Timing-safe comparison: pad both to same length to avoid leaking length info
|
|
38
|
+
const expected = Buffer.from(authToken, 'utf-8');
|
|
39
|
+
const received = Buffer.from(provided, 'utf-8');
|
|
40
|
+
|
|
41
|
+
const maxLen = Math.max(expected.length, received.length);
|
|
42
|
+
const a = Buffer.alloc(maxLen);
|
|
43
|
+
const b = Buffer.alloc(maxLen);
|
|
44
|
+
expected.copy(a);
|
|
45
|
+
received.copy(b);
|
|
46
|
+
|
|
47
|
+
if (expected.length !== received.length || !timingSafeEqual(a, b)) {
|
|
48
|
+
logAuthFailure(req, 'invalid_token');
|
|
49
|
+
return {
|
|
50
|
+
valid: false,
|
|
51
|
+
status: 401,
|
|
52
|
+
body: { error: 'Unauthorized', code: 'INVALID_TOKEN' },
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return { valid: true };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {import('node:http').IncomingMessage} req
|
|
61
|
+
* @param {string} reason
|
|
62
|
+
*/
|
|
63
|
+
function logAuthFailure(req, reason) {
|
|
64
|
+
const record = {
|
|
65
|
+
timestamp: new Date().toISOString(),
|
|
66
|
+
event: 'auth_failure',
|
|
67
|
+
reason,
|
|
68
|
+
ip: req.socket?.remoteAddress || null,
|
|
69
|
+
path: req.url || null,
|
|
70
|
+
};
|
|
71
|
+
console.error(`[AUTH] ${JSON.stringify(record)}`);
|
|
72
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP Streamable Transport Manager for WordPress MCP Server.
|
|
3
|
+
*
|
|
4
|
+
* Implements MCP 2025-03-26 Streamable HTTP spec:
|
|
5
|
+
* - POST /mcp → JSON-RPC request handling (JSON or SSE)
|
|
6
|
+
* - GET /mcp → SSE stream for server-to-client notifications
|
|
7
|
+
* - DELETE /mcp → terminate session
|
|
8
|
+
* - GET /health → health check
|
|
9
|
+
*
|
|
10
|
+
* Bearer auth, Origin validation, session management, and audit logging
|
|
11
|
+
* are applied before any MCP processing.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createServer as createHttpServer } from 'node:http';
|
|
15
|
+
import { randomUUID } from 'node:crypto';
|
|
16
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
17
|
+
import { validateBearerToken } from '../auth/bearer.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {object} HttpConfig
|
|
21
|
+
* @property {number} [port=3000]
|
|
22
|
+
* @property {string} [host='0.0.0.0']
|
|
23
|
+
* @property {string|null} [authToken=null]
|
|
24
|
+
* @property {string[]} [allowedOrigins=[]]
|
|
25
|
+
* @property {number} [sessionTimeoutMs=1800000] 30 minutes
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const DEFAULT_CONFIG = {
|
|
29
|
+
port: 3000,
|
|
30
|
+
host: '0.0.0.0',
|
|
31
|
+
authToken: null,
|
|
32
|
+
allowedOrigins: [],
|
|
33
|
+
sessionTimeoutMs: 30 * 60 * 1000,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export class HttpTransportManager {
|
|
37
|
+
/**
|
|
38
|
+
* Create an HTTP server wired to MCP.
|
|
39
|
+
*
|
|
40
|
+
* Each new session gets its own MCP Server + Transport pair (the SDK requires
|
|
41
|
+
* one transport per server instance). The `serverFactory` creates a fresh,
|
|
42
|
+
* fully-configured Server on each `initialize` request.
|
|
43
|
+
*
|
|
44
|
+
* @param {() => import('@modelcontextprotocol/sdk/server/index.js').Server} serverFactory
|
|
45
|
+
* @param {HttpConfig} [config]
|
|
46
|
+
* @returns {import('node:http').Server}
|
|
47
|
+
*/
|
|
48
|
+
createServer(serverFactory, config = {}) {
|
|
49
|
+
const cfg = { ...DEFAULT_CONFIG, ...config };
|
|
50
|
+
|
|
51
|
+
if (!cfg.authToken) {
|
|
52
|
+
console.error('[WARN] MCP HTTP transport started WITHOUT Bearer auth (MCP_AUTH_TOKEN not set). Any client can connect.');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Map of sessionId → { transport, server, lastActivity }
|
|
56
|
+
const sessions = new Map();
|
|
57
|
+
|
|
58
|
+
// Session reaper
|
|
59
|
+
const reaper = setInterval(() => {
|
|
60
|
+
const now = Date.now();
|
|
61
|
+
for (const [id, entry] of sessions) {
|
|
62
|
+
if (now - entry.lastActivity > cfg.sessionTimeoutMs) {
|
|
63
|
+
entry.transport.close().catch(() => {});
|
|
64
|
+
sessions.delete(id);
|
|
65
|
+
console.error(`[INFO] Session ${id} expired (timeout ${cfg.sessionTimeoutMs}ms)`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}, 60_000);
|
|
69
|
+
reaper.unref();
|
|
70
|
+
|
|
71
|
+
const httpServer = createHttpServer(async (req, res) => {
|
|
72
|
+
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
|
73
|
+
const pathname = url.pathname;
|
|
74
|
+
|
|
75
|
+
// ── Health endpoint ──
|
|
76
|
+
if (pathname === '/health' && req.method === 'GET') {
|
|
77
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
78
|
+
res.end(JSON.stringify({ status: 'ok', transport: 'http', version: '3.0.0' }));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── Only /mcp from here ──
|
|
83
|
+
if (pathname !== '/mcp') {
|
|
84
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
85
|
+
res.end(JSON.stringify({ error: 'Not Found' }));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── Origin validation ──
|
|
90
|
+
if (cfg.allowedOrigins.length > 0) {
|
|
91
|
+
const origin = req.headers['origin'];
|
|
92
|
+
if (origin && !cfg.allowedOrigins.includes(origin)) {
|
|
93
|
+
logHttpAudit(req, 'origin_rejected', { origin });
|
|
94
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
95
|
+
res.end(JSON.stringify({ error: 'Forbidden', code: 'ORIGIN_NOT_ALLOWED' }));
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Bearer auth ──
|
|
101
|
+
const auth = validateBearerToken(req, cfg.authToken);
|
|
102
|
+
if (!auth.valid) {
|
|
103
|
+
res.writeHead(auth.status, { 'Content-Type': 'application/json' });
|
|
104
|
+
res.end(JSON.stringify(auth.body));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── Route by method ──
|
|
109
|
+
if (req.method === 'POST') {
|
|
110
|
+
await handlePost(req, res, serverFactory, sessions);
|
|
111
|
+
} else if (req.method === 'GET') {
|
|
112
|
+
await handleGet(req, res, sessions);
|
|
113
|
+
} else if (req.method === 'DELETE') {
|
|
114
|
+
await handleDelete(req, res, sessions);
|
|
115
|
+
} else {
|
|
116
|
+
res.writeHead(405, { 'Content-Type': 'application/json', 'Allow': 'GET, POST, DELETE' });
|
|
117
|
+
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
httpServer.on('close', () => {
|
|
122
|
+
clearInterval(reaper);
|
|
123
|
+
for (const [, entry] of sessions) {
|
|
124
|
+
entry.transport.close().catch(() => {});
|
|
125
|
+
}
|
|
126
|
+
sessions.clear();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
return httpServer;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ──────────────────────────────────────────────────────────────
|
|
134
|
+
// POST /mcp — JSON-RPC request
|
|
135
|
+
// ──────────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
async function handlePost(req, res, serverFactory, sessions) {
|
|
138
|
+
// Read body
|
|
139
|
+
const body = await readBody(req);
|
|
140
|
+
let parsed;
|
|
141
|
+
try {
|
|
142
|
+
parsed = JSON.parse(body);
|
|
143
|
+
} catch {
|
|
144
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
145
|
+
res.end(JSON.stringify({ error: 'Invalid JSON' }));
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Check if this is an initialization request (method === 'initialize')
|
|
150
|
+
const isInit = isInitializeRequest(parsed);
|
|
151
|
+
const sessionId = req.headers['mcp-session-id'];
|
|
152
|
+
|
|
153
|
+
if (isInit) {
|
|
154
|
+
// Create a new transport + server for this session
|
|
155
|
+
const transport = new StreamableHTTPServerTransport({
|
|
156
|
+
sessionIdGenerator: () => randomUUID(),
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const mcpServer = serverFactory();
|
|
160
|
+
|
|
161
|
+
transport.onclose = () => {
|
|
162
|
+
const sid = transport.sessionId;
|
|
163
|
+
if (sid && sessions.has(sid)) {
|
|
164
|
+
sessions.delete(sid);
|
|
165
|
+
console.error(`[INFO] Session ${sid} closed`);
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
// Connect fresh MCP server to this transport
|
|
170
|
+
await mcpServer.connect(transport);
|
|
171
|
+
|
|
172
|
+
// Handle the request — this will generate the session ID
|
|
173
|
+
await transport.handleRequest(req, res, parsed);
|
|
174
|
+
|
|
175
|
+
// Store session after initialization
|
|
176
|
+
const sid = transport.sessionId;
|
|
177
|
+
if (sid) {
|
|
178
|
+
sessions.set(sid, { transport, server: mcpServer, lastActivity: Date.now() });
|
|
179
|
+
console.error(`[INFO] New HTTP session: ${sid}`);
|
|
180
|
+
}
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Non-init request: must have a valid session
|
|
185
|
+
if (!sessionId || !sessions.has(sessionId)) {
|
|
186
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
187
|
+
res.end(JSON.stringify({ error: 'Bad Request', code: 'INVALID_SESSION' }));
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const entry = sessions.get(sessionId);
|
|
192
|
+
entry.lastActivity = Date.now();
|
|
193
|
+
await entry.transport.handleRequest(req, res, parsed);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ──────────────────────────────────────────────────────────────
|
|
197
|
+
// GET /mcp — SSE stream for server-to-client notifications
|
|
198
|
+
// ──────────────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
async function handleGet(req, res, sessions) {
|
|
201
|
+
const sessionId = req.headers['mcp-session-id'];
|
|
202
|
+
|
|
203
|
+
if (!sessionId || !sessions.has(sessionId)) {
|
|
204
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
205
|
+
res.end(JSON.stringify({ error: 'Bad Request', code: 'INVALID_SESSION' }));
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const entry = sessions.get(sessionId);
|
|
210
|
+
entry.lastActivity = Date.now();
|
|
211
|
+
await entry.transport.handleRequest(req, res);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ──────────────────────────────────────────────────────────────
|
|
215
|
+
// DELETE /mcp — Terminate session
|
|
216
|
+
// ──────────────────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
async function handleDelete(req, res, sessions) {
|
|
219
|
+
const sessionId = req.headers['mcp-session-id'];
|
|
220
|
+
|
|
221
|
+
if (!sessionId || !sessions.has(sessionId)) {
|
|
222
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
223
|
+
res.end(JSON.stringify({ error: 'Session not found' }));
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const entry = sessions.get(sessionId);
|
|
228
|
+
await entry.transport.close();
|
|
229
|
+
sessions.delete(sessionId);
|
|
230
|
+
console.error(`[INFO] Session ${sessionId} terminated via DELETE`);
|
|
231
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
232
|
+
res.end(JSON.stringify({ status: 'session_terminated' }));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ──────────────────────────────────────────────────────────────
|
|
236
|
+
// Helpers
|
|
237
|
+
// ──────────────────────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
function readBody(req) {
|
|
240
|
+
return new Promise((resolve, reject) => {
|
|
241
|
+
const chunks = [];
|
|
242
|
+
req.on('data', (chunk) => chunks.push(chunk));
|
|
243
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
|
|
244
|
+
req.on('error', reject);
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function isInitializeRequest(parsed) {
|
|
249
|
+
if (Array.isArray(parsed)) {
|
|
250
|
+
return parsed.some((msg) => msg.method === 'initialize');
|
|
251
|
+
}
|
|
252
|
+
return parsed && parsed.method === 'initialize';
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function logHttpAudit(req, event, extra = {}) {
|
|
256
|
+
const record = {
|
|
257
|
+
timestamp: new Date().toISOString(),
|
|
258
|
+
event,
|
|
259
|
+
ip: req.socket?.remoteAddress || null,
|
|
260
|
+
path: req.url || null,
|
|
261
|
+
...extra,
|
|
262
|
+
};
|
|
263
|
+
console.error(`[HTTP] ${JSON.stringify(record)}`);
|
|
264
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared test helpers for WordPress MCP Server tool tests.
|
|
3
|
+
*
|
|
4
|
+
* Provides:
|
|
5
|
+
* - makeRequest(name, args) — build the { params } envelope handleToolCall expects
|
|
6
|
+
* - mockSuccess(data, opts) — configure fetch to return a successful JSON response
|
|
7
|
+
* - mockError(status, body) — configure fetch to return an HTTP error response
|
|
8
|
+
* - parseResult(result) — extract parsed JSON from the MCP { content } wrapper
|
|
9
|
+
* - getAuditLogs() — collect [AUDIT] lines written to console.error
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fetch from 'node-fetch';
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Request builder
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Build the request object that handleToolCall expects.
|
|
20
|
+
* @param {string} name Tool name, e.g. "wp_search"
|
|
21
|
+
* @param {object} args Tool arguments
|
|
22
|
+
* @returns {{ params: { name: string, arguments: object } }}
|
|
23
|
+
*/
|
|
24
|
+
export function makeRequest(name, args = {}) {
|
|
25
|
+
return { params: { name, arguments: args } };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Fetch mock helpers
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Configure node-fetch mock to resolve with a JSON success response.
|
|
34
|
+
* Supports chaining: call multiple times via mockImplementationOnce to queue
|
|
35
|
+
* sequential responses.
|
|
36
|
+
*
|
|
37
|
+
* @param {*} data JSON-serialisable payload
|
|
38
|
+
* @param {object} [opts]
|
|
39
|
+
* @param {number} [opts.status] HTTP status code (default 200)
|
|
40
|
+
* @param {boolean} [opts.once] If true use mockImplementationOnce (default true)
|
|
41
|
+
*/
|
|
42
|
+
export function mockSuccess(data, opts = {}) {
|
|
43
|
+
const status = opts.status ?? 200;
|
|
44
|
+
const response = {
|
|
45
|
+
ok: true,
|
|
46
|
+
status,
|
|
47
|
+
headers: {
|
|
48
|
+
get: (h) => {
|
|
49
|
+
if (h === 'content-type') return 'application/json';
|
|
50
|
+
return null;
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
json: () => Promise.resolve(data),
|
|
54
|
+
text: () => Promise.resolve(JSON.stringify(data)),
|
|
55
|
+
};
|
|
56
|
+
const impl = () => Promise.resolve(response);
|
|
57
|
+
|
|
58
|
+
if (opts.once === false) {
|
|
59
|
+
fetch.mockImplementation(impl);
|
|
60
|
+
} else {
|
|
61
|
+
fetch.mockImplementationOnce(impl);
|
|
62
|
+
}
|
|
63
|
+
return response;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Configure node-fetch mock to resolve with an HTTP error response.
|
|
68
|
+
*
|
|
69
|
+
* @param {number} status HTTP status code (e.g. 403, 404)
|
|
70
|
+
* @param {string} [body] Response body text
|
|
71
|
+
* @param {object} [opts]
|
|
72
|
+
* @param {boolean} [opts.once] If true use mockImplementationOnce (default true)
|
|
73
|
+
*/
|
|
74
|
+
export function mockError(status, body = '', opts = {}) {
|
|
75
|
+
const response = {
|
|
76
|
+
ok: false,
|
|
77
|
+
status,
|
|
78
|
+
headers: {
|
|
79
|
+
get: (h) => {
|
|
80
|
+
if (h === 'content-type') return 'application/json';
|
|
81
|
+
if (h === 'retry-after') return null;
|
|
82
|
+
return null;
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
json: () => Promise.resolve({}),
|
|
86
|
+
text: () => Promise.resolve(body),
|
|
87
|
+
};
|
|
88
|
+
const impl = () => Promise.resolve(response);
|
|
89
|
+
|
|
90
|
+
if (opts.once === false) {
|
|
91
|
+
fetch.mockImplementation(impl);
|
|
92
|
+
} else {
|
|
93
|
+
fetch.mockImplementationOnce(impl);
|
|
94
|
+
}
|
|
95
|
+
return response;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Result parser
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Parse the JSON payload from the MCP tool result envelope.
|
|
104
|
+
*
|
|
105
|
+
* handleToolCall returns `{ content: [{ type: 'text', text: '...' }] }`.
|
|
106
|
+
* This extracts and JSON-parses the text field.
|
|
107
|
+
*
|
|
108
|
+
* @param {object} result Return value from handleToolCall
|
|
109
|
+
* @returns {object} Parsed JSON object
|
|
110
|
+
*/
|
|
111
|
+
export function parseResult(result) {
|
|
112
|
+
const text = result?.content?.[0]?.text;
|
|
113
|
+
if (!text) return result;
|
|
114
|
+
return JSON.parse(text);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// Audit log collector
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Return all `[AUDIT]` entries that have been written to console.error
|
|
123
|
+
* during the current test. Relies on the `consoleSpy` created in each
|
|
124
|
+
* test file's `beforeEach`.
|
|
125
|
+
*
|
|
126
|
+
* @returns {object[]} Array of parsed audit-log JSON objects.
|
|
127
|
+
*/
|
|
128
|
+
export function getAuditLogs() {
|
|
129
|
+
const spy = console.error;
|
|
130
|
+
if (!spy || !spy.mock) return [];
|
|
131
|
+
return spy.mock.calls
|
|
132
|
+
.map((c) => c[0])
|
|
133
|
+
.filter((msg) => typeof msg === 'string' && msg.startsWith('[AUDIT]'))
|
|
134
|
+
.map((msg) => JSON.parse(msg.replace('[AUDIT] ', '')));
|
|
135
|
+
}
|