@mangerik/wordpress-mcp 0.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 +32 -0
- package/CHANGELOG.md +23 -0
- package/LICENSE +21 -0
- package/README.md +256 -0
- package/dist/config.d.ts +80 -0
- package/dist/config.js +84 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +145 -0
- package/dist/prompts.d.ts +7 -0
- package/dist/prompts.js +104 -0
- package/dist/resources.d.ts +13 -0
- package/dist/resources.js +64 -0
- package/dist/tools/batch.d.ts +14 -0
- package/dist/tools/batch.js +49 -0
- package/dist/tools/blocks.d.ts +4 -0
- package/dist/tools/blocks.js +202 -0
- package/dist/tools/comments.d.ts +4 -0
- package/dist/tools/comments.js +80 -0
- package/dist/tools/cpt.d.ts +16 -0
- package/dist/tools/cpt.js +97 -0
- package/dist/tools/jwt.d.ts +9 -0
- package/dist/tools/jwt.js +17 -0
- package/dist/tools/media.d.ts +4 -0
- package/dist/tools/media.js +101 -0
- package/dist/tools/multisite.d.ts +17 -0
- package/dist/tools/multisite.js +111 -0
- package/dist/tools/pages.d.ts +4 -0
- package/dist/tools/pages.js +101 -0
- package/dist/tools/posts.d.ts +4 -0
- package/dist/tools/posts.js +160 -0
- package/dist/tools/seo.d.ts +4 -0
- package/dist/tools/seo.js +269 -0
- package/dist/tools/site.d.ts +4 -0
- package/dist/tools/site.js +96 -0
- package/dist/tools/taxonomy.d.ts +4 -0
- package/dist/tools/taxonomy.js +147 -0
- package/dist/tools/users.d.ts +4 -0
- package/dist/tools/users.js +99 -0
- package/dist/tools/woocommerce.d.ts +4 -0
- package/dist/tools/woocommerce.js +400 -0
- package/dist/types.d.ts +26 -0
- package/dist/types.js +2 -0
- package/dist/wordpress-client.d.ts +223 -0
- package/dist/wordpress-client.js +519 -0
- package/package.json +67 -0
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
import axios from "axios";
|
|
2
|
+
import https from "node:https";
|
|
3
|
+
import FormData from "form-data";
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { basename } from "node:path";
|
|
6
|
+
const RETRY_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
7
|
+
export class WordPressClient {
|
|
8
|
+
baseUrl;
|
|
9
|
+
client;
|
|
10
|
+
maxRetries;
|
|
11
|
+
userAgent;
|
|
12
|
+
wcKey;
|
|
13
|
+
wcSecret;
|
|
14
|
+
authMode;
|
|
15
|
+
jwtNamespace;
|
|
16
|
+
username;
|
|
17
|
+
password;
|
|
18
|
+
jwtToken;
|
|
19
|
+
constructor(config) {
|
|
20
|
+
this.baseUrl = config.baseUrl.replace(/\/$/, "");
|
|
21
|
+
this.maxRetries = config.maxRetries ?? 3;
|
|
22
|
+
this.userAgent = config.userAgent ?? "WordPress-MCP-Server/1.0";
|
|
23
|
+
this.wcKey = config.wcConsumerKey;
|
|
24
|
+
this.wcSecret = config.wcConsumerSecret;
|
|
25
|
+
this.authMode = config.authMode ?? "application_password";
|
|
26
|
+
this.jwtNamespace = (config.jwtNamespace ?? "jwt-auth/v1").replace(/^\/|\/$/g, "");
|
|
27
|
+
this.username = config.username;
|
|
28
|
+
this.password = config.password;
|
|
29
|
+
this.jwtToken = config.jwtToken;
|
|
30
|
+
let authHeader;
|
|
31
|
+
if (this.authMode === "application_password") {
|
|
32
|
+
if (!config.username || !config.appPassword) {
|
|
33
|
+
throw new Error("application_password mode requires both username and appPassword");
|
|
34
|
+
}
|
|
35
|
+
const token = Buffer.from(`${config.username}:${config.appPassword}`).toString("base64");
|
|
36
|
+
authHeader = `Basic ${token}`;
|
|
37
|
+
}
|
|
38
|
+
else if (this.jwtToken) {
|
|
39
|
+
authHeader = `Bearer ${this.jwtToken}`;
|
|
40
|
+
}
|
|
41
|
+
// If JWT mode without a pre-issued token, the header is set lazily
|
|
42
|
+
// after `ensureJwt()` runs.
|
|
43
|
+
this.client = axios.create({
|
|
44
|
+
baseURL: `${this.baseUrl}/wp-json`,
|
|
45
|
+
headers: {
|
|
46
|
+
...(authHeader ? { Authorization: authHeader } : {}),
|
|
47
|
+
"User-Agent": this.userAgent,
|
|
48
|
+
Accept: "application/json",
|
|
49
|
+
},
|
|
50
|
+
timeout: config.timeoutMs ?? 30_000,
|
|
51
|
+
httpsAgent: config.verifySsl === false
|
|
52
|
+
? new https.Agent({ rejectUnauthorized: false })
|
|
53
|
+
: undefined,
|
|
54
|
+
// Don't throw on 4xx — we map errors ourselves for clearer messages.
|
|
55
|
+
validateStatus: () => true,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Ensure a usable JWT is loaded. Called once at startup (or on demand)
|
|
60
|
+
* by callers in JWT mode without a pre-issued token.
|
|
61
|
+
*/
|
|
62
|
+
async ensureJwt() {
|
|
63
|
+
if (this.authMode !== "jwt")
|
|
64
|
+
return;
|
|
65
|
+
if (this.jwtToken) {
|
|
66
|
+
this.client.defaults.headers["Authorization"] = `Bearer ${this.jwtToken}`;
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (!this.username || !this.password) {
|
|
70
|
+
throw new WPError("JWT mode without pre-issued token requires username + password", { status: 0, code: "jwt_missing_credentials" });
|
|
71
|
+
}
|
|
72
|
+
// Fetch token. We deliberately don't go through this.request() here
|
|
73
|
+
// because that path would try to attach Authorization.
|
|
74
|
+
const url = `${this.baseUrl}/wp-json/${this.jwtNamespace}/token`;
|
|
75
|
+
const res = await axios.post(url, { username: this.username, password: this.password }, {
|
|
76
|
+
timeout: 15_000,
|
|
77
|
+
headers: { "User-Agent": this.userAgent, "Content-Type": "application/json" },
|
|
78
|
+
validateStatus: () => true,
|
|
79
|
+
httpsAgent: this.client.defaults.httpsAgent,
|
|
80
|
+
});
|
|
81
|
+
if (res.status !== 200 || !res.data?.token) {
|
|
82
|
+
throw new WPError(`JWT token fetch failed: HTTP ${res.status} ${res.data?.message ?? ""} — ` +
|
|
83
|
+
`verify the JWT plugin is installed at namespace '${this.jwtNamespace}' ` +
|
|
84
|
+
`and that JWT_AUTH_SECRET_KEY is set in wp-config.php.`, {
|
|
85
|
+
status: res.status,
|
|
86
|
+
code: res.data?.code ?? "jwt_token_fetch_failed",
|
|
87
|
+
details: res.data,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
this.jwtToken = res.data.token;
|
|
91
|
+
this.client.defaults.headers["Authorization"] = `Bearer ${this.jwtToken}`;
|
|
92
|
+
}
|
|
93
|
+
// ─── Internals ────────────────────────────────────────────────────────────
|
|
94
|
+
buildQuery(params) {
|
|
95
|
+
if (!params)
|
|
96
|
+
return {};
|
|
97
|
+
const query = {};
|
|
98
|
+
for (const [key, value] of Object.entries(params)) {
|
|
99
|
+
if (value === undefined || value === null)
|
|
100
|
+
continue;
|
|
101
|
+
if (Array.isArray(value)) {
|
|
102
|
+
query[key] = value.join(",");
|
|
103
|
+
}
|
|
104
|
+
else if (typeof value === "boolean") {
|
|
105
|
+
query[key] = value ? 1 : 0;
|
|
106
|
+
}
|
|
107
|
+
else if (typeof value === "string" || typeof value === "number") {
|
|
108
|
+
query[key] = value;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return query;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Wrap axios request with retry on transient errors and structured error
|
|
115
|
+
* formatting that surfaces WordPress' own `code`/`message` fields.
|
|
116
|
+
*/
|
|
117
|
+
async request(config) {
|
|
118
|
+
// For WooCommerce routes, prefer Consumer Key/Secret if provided.
|
|
119
|
+
const finalConfig = this.applyWcAuth(config);
|
|
120
|
+
let attempt = 0;
|
|
121
|
+
// eslint-disable-next-line no-constant-condition
|
|
122
|
+
while (true) {
|
|
123
|
+
attempt++;
|
|
124
|
+
try {
|
|
125
|
+
const res = await this.client.request(finalConfig);
|
|
126
|
+
if (res.status >= 200 && res.status < 300) {
|
|
127
|
+
return {
|
|
128
|
+
data: res.data,
|
|
129
|
+
headers: (res.headers ?? {}),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
// Non-2xx
|
|
133
|
+
const shouldRetry = attempt <= this.maxRetries && RETRY_STATUSES.has(res.status);
|
|
134
|
+
if (shouldRetry) {
|
|
135
|
+
await this.sleep(this.backoffMs(attempt, res.headers));
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
throw this.toError(res.status, res.data, finalConfig);
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
if (err instanceof WPError)
|
|
142
|
+
throw err;
|
|
143
|
+
const ax = err;
|
|
144
|
+
const transient = ax.code === "ECONNRESET" ||
|
|
145
|
+
ax.code === "ETIMEDOUT" ||
|
|
146
|
+
ax.code === "EAI_AGAIN" ||
|
|
147
|
+
ax.code === "ECONNABORTED";
|
|
148
|
+
if (transient && attempt <= this.maxRetries) {
|
|
149
|
+
await this.sleep(this.backoffMs(attempt));
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
throw new WPError(`WordPress request failed: ${ax.message ?? String(err)}`, { cause: err, status: 0, code: ax.code ?? "network_error" });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
backoffMs(attempt, headers) {
|
|
157
|
+
// Honor Retry-After header if present.
|
|
158
|
+
if (headers && typeof headers === "object") {
|
|
159
|
+
const h = headers;
|
|
160
|
+
const retryAfter = h["retry-after"] ?? h["Retry-After"];
|
|
161
|
+
if (retryAfter) {
|
|
162
|
+
const secs = Number(retryAfter);
|
|
163
|
+
if (!Number.isNaN(secs))
|
|
164
|
+
return secs * 1000;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
// Exponential backoff with jitter, capped at 8s.
|
|
168
|
+
return Math.min(8000, 250 * 2 ** (attempt - 1)) + Math.random() * 200;
|
|
169
|
+
}
|
|
170
|
+
sleep(ms) {
|
|
171
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* If the request targets WooCommerce (/wc/* or /wc-analytics/*) AND
|
|
175
|
+
* Consumer Key/Secret are configured, attach them as query params and
|
|
176
|
+
* strip the Authorization header. WC accepts CK/CS over HTTPS as basic
|
|
177
|
+
* params; we always assume HTTPS and rely on TLS for confidentiality.
|
|
178
|
+
*/
|
|
179
|
+
applyWcAuth(config) {
|
|
180
|
+
if (!this.wcKey || !this.wcSecret)
|
|
181
|
+
return config;
|
|
182
|
+
const url = config.url ?? "";
|
|
183
|
+
const isWc = url.startsWith("/wc/") || url.startsWith("/wc-analytics/");
|
|
184
|
+
if (!isWc)
|
|
185
|
+
return config;
|
|
186
|
+
return {
|
|
187
|
+
...config,
|
|
188
|
+
params: {
|
|
189
|
+
...(config.params ?? {}),
|
|
190
|
+
consumer_key: this.wcKey,
|
|
191
|
+
consumer_secret: this.wcSecret,
|
|
192
|
+
},
|
|
193
|
+
headers: {
|
|
194
|
+
...(config.headers ?? {}),
|
|
195
|
+
// Avoid sending double credentials.
|
|
196
|
+
Authorization: undefined,
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
toError(status, body, config) {
|
|
201
|
+
const url = `${config.method?.toUpperCase() ?? "GET"} ${config.url}`;
|
|
202
|
+
if (body &&
|
|
203
|
+
typeof body === "object" &&
|
|
204
|
+
"message" in body &&
|
|
205
|
+
"code" in body) {
|
|
206
|
+
const b = body;
|
|
207
|
+
return new WPError(`${b.message} (${b.code}) [${url}]`, {
|
|
208
|
+
status,
|
|
209
|
+
code: b.code,
|
|
210
|
+
details: b.data,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
return new WPError(`HTTP ${status} from ${url}`, {
|
|
214
|
+
status,
|
|
215
|
+
code: "http_error",
|
|
216
|
+
details: body,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
// ─── Generic core (used by CPT/taxonomy generic tools) ────────────────────
|
|
220
|
+
/**
|
|
221
|
+
* Generic list call. `route` is e.g. "wp/v2/posts" or "wc/v3/products".
|
|
222
|
+
*/
|
|
223
|
+
async list(route, params) {
|
|
224
|
+
const { data, headers } = await this.request({
|
|
225
|
+
method: "GET",
|
|
226
|
+
url: `/${route}`,
|
|
227
|
+
params: this.buildQuery(params),
|
|
228
|
+
});
|
|
229
|
+
return {
|
|
230
|
+
data,
|
|
231
|
+
total: Number(headers["x-wp-total"] ?? data.length ?? 0),
|
|
232
|
+
pages: Number(headers["x-wp-totalpages"] ?? 1),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
async get(route, id, params) {
|
|
236
|
+
const { data } = await this.request({
|
|
237
|
+
method: "GET",
|
|
238
|
+
url: `/${route}/${id}`,
|
|
239
|
+
params: this.buildQuery(params),
|
|
240
|
+
});
|
|
241
|
+
return data;
|
|
242
|
+
}
|
|
243
|
+
async create(route, body) {
|
|
244
|
+
const { data } = await this.request({
|
|
245
|
+
method: "POST",
|
|
246
|
+
url: `/${route}`,
|
|
247
|
+
data: body,
|
|
248
|
+
headers: { "Content-Type": "application/json" },
|
|
249
|
+
});
|
|
250
|
+
return data;
|
|
251
|
+
}
|
|
252
|
+
async update(route, id, body) {
|
|
253
|
+
const { data } = await this.request({
|
|
254
|
+
method: "POST", // WP accepts POST for updates and many hosts strip PUT.
|
|
255
|
+
url: `/${route}/${id}`,
|
|
256
|
+
data: body,
|
|
257
|
+
headers: { "Content-Type": "application/json" },
|
|
258
|
+
});
|
|
259
|
+
return data;
|
|
260
|
+
}
|
|
261
|
+
async remove(route, id, force = false) {
|
|
262
|
+
const { data } = await this.request({
|
|
263
|
+
method: "DELETE",
|
|
264
|
+
url: `/${route}/${id}`,
|
|
265
|
+
params: { force },
|
|
266
|
+
});
|
|
267
|
+
return data;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Low-level escape hatch: send an arbitrary GET / POST to a fully
|
|
271
|
+
* specified path. Used by tools that work with string IDs (e.g. block
|
|
272
|
+
* templates) or non-`wp/v2` routes that don't fit list/get/create/update.
|
|
273
|
+
*/
|
|
274
|
+
async raw(opts) {
|
|
275
|
+
const { data } = await this.request({
|
|
276
|
+
method: opts.method ?? "GET",
|
|
277
|
+
url: opts.path.startsWith("/") ? opts.path : `/${opts.path}`,
|
|
278
|
+
params: opts.query ? this.buildQuery(opts.query) : undefined,
|
|
279
|
+
data: opts.body,
|
|
280
|
+
headers: opts.body ? { "Content-Type": "application/json" } : undefined,
|
|
281
|
+
});
|
|
282
|
+
return data;
|
|
283
|
+
}
|
|
284
|
+
// ─── Convenience wrappers (wp/v2 namespace) ───────────────────────────────
|
|
285
|
+
posts = {
|
|
286
|
+
list: (p) => this.list("wp/v2/posts", p),
|
|
287
|
+
get: (id, p) => this.get("wp/v2/posts", id, p),
|
|
288
|
+
create: (body) => this.create("wp/v2/posts", body),
|
|
289
|
+
update: (id, body) => this.update("wp/v2/posts", id, body),
|
|
290
|
+
remove: (id, force = false) => this.remove("wp/v2/posts", id, force),
|
|
291
|
+
revisions: (id, p) => this.list(`wp/v2/posts/${id}/revisions`, p),
|
|
292
|
+
};
|
|
293
|
+
pages = {
|
|
294
|
+
list: (p) => this.list("wp/v2/pages", p),
|
|
295
|
+
get: (id, p) => this.get("wp/v2/pages", id, p),
|
|
296
|
+
create: (body) => this.create("wp/v2/pages", body),
|
|
297
|
+
update: (id, body) => this.update("wp/v2/pages", id, body),
|
|
298
|
+
remove: (id, force = false) => this.remove("wp/v2/pages", id, force),
|
|
299
|
+
};
|
|
300
|
+
media = {
|
|
301
|
+
list: (p) => this.list("wp/v2/media", p),
|
|
302
|
+
get: (id) => this.get("wp/v2/media", id),
|
|
303
|
+
update: (id, body) => this.update("wp/v2/media", id, body),
|
|
304
|
+
remove: (id, force = true) => this.remove("wp/v2/media", id, force),
|
|
305
|
+
};
|
|
306
|
+
categories = {
|
|
307
|
+
list: (p) => this.list("wp/v2/categories", p),
|
|
308
|
+
get: (id) => this.get("wp/v2/categories", id),
|
|
309
|
+
create: (body) => this.create("wp/v2/categories", body),
|
|
310
|
+
update: (id, body) => this.update("wp/v2/categories", id, body),
|
|
311
|
+
remove: (id, force = false) => this.remove("wp/v2/categories", id, force),
|
|
312
|
+
};
|
|
313
|
+
tags = {
|
|
314
|
+
list: (p) => this.list("wp/v2/tags", p),
|
|
315
|
+
get: (id) => this.get("wp/v2/tags", id),
|
|
316
|
+
create: (body) => this.create("wp/v2/tags", body),
|
|
317
|
+
update: (id, body) => this.update("wp/v2/tags", id, body),
|
|
318
|
+
remove: (id, force = false) => this.remove("wp/v2/tags", id, force),
|
|
319
|
+
};
|
|
320
|
+
comments = {
|
|
321
|
+
list: (p) => this.list("wp/v2/comments", p),
|
|
322
|
+
get: (id) => this.get("wp/v2/comments", id),
|
|
323
|
+
create: (body) => this.create("wp/v2/comments", body),
|
|
324
|
+
update: (id, body) => this.update("wp/v2/comments", id, body),
|
|
325
|
+
remove: (id, force = false) => this.remove("wp/v2/comments", id, force),
|
|
326
|
+
};
|
|
327
|
+
users = {
|
|
328
|
+
list: (p) => this.list("wp/v2/users", p),
|
|
329
|
+
get: (id) => this.get("wp/v2/users", id),
|
|
330
|
+
me: async () => {
|
|
331
|
+
const { data } = await this.request({ method: "GET", url: "/wp/v2/users/me" });
|
|
332
|
+
return data;
|
|
333
|
+
},
|
|
334
|
+
create: (body) => this.create("wp/v2/users", body),
|
|
335
|
+
update: (id, body) => this.update("wp/v2/users", id, body),
|
|
336
|
+
remove: async (id, reassignTo) => {
|
|
337
|
+
const { data } = await this.request({
|
|
338
|
+
method: "DELETE",
|
|
339
|
+
url: `/wp/v2/users/${id}`,
|
|
340
|
+
params: { force: true, reassign: reassignTo },
|
|
341
|
+
});
|
|
342
|
+
return data;
|
|
343
|
+
},
|
|
344
|
+
};
|
|
345
|
+
// ─── Site / discovery ─────────────────────────────────────────────────────
|
|
346
|
+
async siteInfo() {
|
|
347
|
+
// The /wp-json index does not require auth. Use a clean axios call so we
|
|
348
|
+
// don't accidentally send Basic auth to a public endpoint.
|
|
349
|
+
const res = await axios.get(`${this.baseUrl}/wp-json`, {
|
|
350
|
+
timeout: 15_000,
|
|
351
|
+
headers: { "User-Agent": this.userAgent },
|
|
352
|
+
validateStatus: () => true,
|
|
353
|
+
});
|
|
354
|
+
if (res.status !== 200) {
|
|
355
|
+
throw new WPError(`Cannot reach REST root: HTTP ${res.status}`, {
|
|
356
|
+
status: res.status,
|
|
357
|
+
code: "rest_root_unreachable",
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
return res.data;
|
|
361
|
+
}
|
|
362
|
+
async settings() {
|
|
363
|
+
const { data } = await this.request({
|
|
364
|
+
method: "GET",
|
|
365
|
+
url: "/wp/v2/settings",
|
|
366
|
+
});
|
|
367
|
+
return data;
|
|
368
|
+
}
|
|
369
|
+
async updateSettings(body) {
|
|
370
|
+
const { data } = await this.request({
|
|
371
|
+
method: "POST",
|
|
372
|
+
url: "/wp/v2/settings",
|
|
373
|
+
data: body,
|
|
374
|
+
});
|
|
375
|
+
return data;
|
|
376
|
+
}
|
|
377
|
+
async types() {
|
|
378
|
+
const { data } = await this.request({ method: "GET", url: "/wp/v2/types" });
|
|
379
|
+
return data;
|
|
380
|
+
}
|
|
381
|
+
async taxonomies() {
|
|
382
|
+
const { data } = await this.request({
|
|
383
|
+
method: "GET",
|
|
384
|
+
url: "/wp/v2/taxonomies",
|
|
385
|
+
});
|
|
386
|
+
return data;
|
|
387
|
+
}
|
|
388
|
+
async search(query, params) {
|
|
389
|
+
const { data, headers } = await this.request({
|
|
390
|
+
method: "GET",
|
|
391
|
+
url: "/wp/v2/search",
|
|
392
|
+
params: { search: query, ...params },
|
|
393
|
+
});
|
|
394
|
+
return {
|
|
395
|
+
data,
|
|
396
|
+
total: Number(headers["x-wp-total"] ?? 0),
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
// ─── Media upload (multipart) ─────────────────────────────────────────────
|
|
400
|
+
async uploadMediaFromFile(filePath, metadata) {
|
|
401
|
+
const fileContent = readFileSync(filePath);
|
|
402
|
+
const fileName = basename(filePath);
|
|
403
|
+
const form = new FormData();
|
|
404
|
+
form.append("file", fileContent, fileName);
|
|
405
|
+
if (metadata?.title)
|
|
406
|
+
form.append("title", metadata.title);
|
|
407
|
+
if (metadata?.alt_text)
|
|
408
|
+
form.append("alt_text", metadata.alt_text);
|
|
409
|
+
if (metadata?.caption)
|
|
410
|
+
form.append("caption", metadata.caption);
|
|
411
|
+
if (metadata?.description)
|
|
412
|
+
form.append("description", metadata.description);
|
|
413
|
+
if (metadata?.post != null)
|
|
414
|
+
form.append("post", String(metadata.post));
|
|
415
|
+
const { data } = await this.request({
|
|
416
|
+
method: "POST",
|
|
417
|
+
url: "/wp/v2/media",
|
|
418
|
+
data: form,
|
|
419
|
+
headers: {
|
|
420
|
+
...form.getHeaders(),
|
|
421
|
+
"Content-Disposition": `attachment; filename="${fileName}"`,
|
|
422
|
+
},
|
|
423
|
+
maxBodyLength: Infinity,
|
|
424
|
+
maxContentLength: Infinity,
|
|
425
|
+
});
|
|
426
|
+
return data;
|
|
427
|
+
}
|
|
428
|
+
async uploadMediaFromUrl(fileUrl, metadata) {
|
|
429
|
+
const res = await axios.get(fileUrl, {
|
|
430
|
+
responseType: "arraybuffer",
|
|
431
|
+
timeout: 60_000,
|
|
432
|
+
});
|
|
433
|
+
const buf = Buffer.from(res.data);
|
|
434
|
+
const fileName = basename(new URL(fileUrl).pathname) || "upload.bin";
|
|
435
|
+
const form = new FormData();
|
|
436
|
+
form.append("file", buf, fileName);
|
|
437
|
+
if (metadata?.title)
|
|
438
|
+
form.append("title", metadata.title);
|
|
439
|
+
if (metadata?.alt_text)
|
|
440
|
+
form.append("alt_text", metadata.alt_text);
|
|
441
|
+
if (metadata?.caption)
|
|
442
|
+
form.append("caption", metadata.caption);
|
|
443
|
+
if (metadata?.description)
|
|
444
|
+
form.append("description", metadata.description);
|
|
445
|
+
if (metadata?.post != null)
|
|
446
|
+
form.append("post", String(metadata.post));
|
|
447
|
+
const { data } = await this.request({
|
|
448
|
+
method: "POST",
|
|
449
|
+
url: "/wp/v2/media",
|
|
450
|
+
data: form,
|
|
451
|
+
headers: {
|
|
452
|
+
...form.getHeaders(),
|
|
453
|
+
"Content-Disposition": `attachment; filename="${fileName}"`,
|
|
454
|
+
},
|
|
455
|
+
maxBodyLength: Infinity,
|
|
456
|
+
maxContentLength: Infinity,
|
|
457
|
+
});
|
|
458
|
+
return data;
|
|
459
|
+
}
|
|
460
|
+
// ─── Batch (/batch/v1) — WP 5.6+ ─────────────────────────────────────────
|
|
461
|
+
/**
|
|
462
|
+
* Discover the batch endpoint capabilities. Returns the parsed OPTIONS body
|
|
463
|
+
* which contains `endpoints[0].args.requests.maxItems` (default 25).
|
|
464
|
+
*/
|
|
465
|
+
async batchOptions() {
|
|
466
|
+
const { data } = await this.request({
|
|
467
|
+
method: "OPTIONS",
|
|
468
|
+
url: "/batch/v1",
|
|
469
|
+
});
|
|
470
|
+
return data;
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Send a batch of write operations to /batch/v1.
|
|
474
|
+
* GET is not supported by core; only POST/PUT/PATCH/DELETE.
|
|
475
|
+
*/
|
|
476
|
+
async batch(requests, validation = "normal") {
|
|
477
|
+
const { data } = await this.request({
|
|
478
|
+
method: "POST",
|
|
479
|
+
url: "/batch/v1",
|
|
480
|
+
data: { validation, requests },
|
|
481
|
+
headers: { "Content-Type": "application/json" },
|
|
482
|
+
});
|
|
483
|
+
return data;
|
|
484
|
+
}
|
|
485
|
+
// ─── JWT helper ──────────────────────────────────────────────────────────
|
|
486
|
+
/** Validate the current JWT against /token/validate. */
|
|
487
|
+
async validateJwt() {
|
|
488
|
+
if (this.authMode !== "jwt") {
|
|
489
|
+
throw new WPError("Server is not running in JWT mode", {
|
|
490
|
+
status: 0,
|
|
491
|
+
code: "wrong_auth_mode",
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
const { data } = await this.request({
|
|
495
|
+
method: "POST",
|
|
496
|
+
url: `/${this.jwtNamespace}/token/validate`,
|
|
497
|
+
});
|
|
498
|
+
return data;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Custom error class that carries the WP REST API error code & status.
|
|
503
|
+
* Tool runners convert this to MCP `isError` results.
|
|
504
|
+
*/
|
|
505
|
+
export class WPError extends Error {
|
|
506
|
+
status;
|
|
507
|
+
code;
|
|
508
|
+
details;
|
|
509
|
+
constructor(message, opts) {
|
|
510
|
+
super(message);
|
|
511
|
+
this.name = "WPError";
|
|
512
|
+
this.status = opts.status;
|
|
513
|
+
this.code = opts.code;
|
|
514
|
+
this.details = opts.details;
|
|
515
|
+
if (opts.cause)
|
|
516
|
+
this.cause = opts.cause;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
//# sourceMappingURL=wordpress-client.js.map
|
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mangerik/wordpress-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP Server for WordPress REST API — connect AI agents to WordPress (posts, pages, media, users, custom post types, WooCommerce, Yoast / Rank Math, block themes, multisite, batch).",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"wordpress-mcp": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist/**/*.js",
|
|
12
|
+
"dist/**/*.d.ts",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE",
|
|
15
|
+
"CHANGELOG.md",
|
|
16
|
+
".env.example"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc && node scripts/postbuild.mjs",
|
|
20
|
+
"start": "node dist/index.js",
|
|
21
|
+
"dev": "tsx src/index.ts",
|
|
22
|
+
"watch": "tsc --watch",
|
|
23
|
+
"typecheck": "tsc --noEmit",
|
|
24
|
+
"smoke": "node scripts/smoke-test.mjs",
|
|
25
|
+
"prepublishOnly": "npm run typecheck && npm run build && npm run smoke"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
29
|
+
"axios": "^1.7.0",
|
|
30
|
+
"form-data": "^4.0.0",
|
|
31
|
+
"zod": "^3.23.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/node": "^20.0.0",
|
|
35
|
+
"tsx": "^4.0.0",
|
|
36
|
+
"typescript": "^5.4.0"
|
|
37
|
+
},
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=18"
|
|
40
|
+
},
|
|
41
|
+
"keywords": [
|
|
42
|
+
"mcp",
|
|
43
|
+
"model-context-protocol",
|
|
44
|
+
"wordpress",
|
|
45
|
+
"wp-rest-api",
|
|
46
|
+
"woocommerce",
|
|
47
|
+
"yoast",
|
|
48
|
+
"rank-math",
|
|
49
|
+
"ai",
|
|
50
|
+
"agent",
|
|
51
|
+
"claude",
|
|
52
|
+
"kiro",
|
|
53
|
+
"llm"
|
|
54
|
+
],
|
|
55
|
+
"homepage": "https://github.com/mangerik/wordpress-mcp#readme",
|
|
56
|
+
"repository": {
|
|
57
|
+
"type": "git",
|
|
58
|
+
"url": "git+https://github.com/mangerik/wordpress-mcp.git"
|
|
59
|
+
},
|
|
60
|
+
"bugs": {
|
|
61
|
+
"url": "https://github.com/mangerik/wordpress-mcp/issues"
|
|
62
|
+
},
|
|
63
|
+
"publishConfig": {
|
|
64
|
+
"access": "public"
|
|
65
|
+
},
|
|
66
|
+
"license": "MIT"
|
|
67
|
+
}
|